C | Unix编程 | 1 - 实现简版系统命令

450 阅读6分钟

一、前言

  • 主要使用C语言标准库函数实现程序。
  • C语言本身只提供一些基础的语言特性和标准库函数,如内存管理、字符串处理、文件IO等。标准C语言库并不包含进程、线程、网络库的内容,这些高级特性需要使用操作系统提供的API或第三方库来实现。
  • 本文主要环境是使用类Unix系统,基于Linux的Ubuntu 20.04TLS 搭建APUE环境。环境搭建参考上篇使用Ubuntu搭建apue环境的绝佳姿势,附带一系列工具操作

二、 UNIX简要知识点

系统体系

image.png

systemcall: 系统调用是实现其功能的内核代码的入口点。如write(2)函数

library routines: 公用函数库,是构建在系统调用接口之上.

shell : 提供一个运行其他应用程序的接口。

应用可以选择使用公共函数库或直接通过系统调用。

程序设计

image.png Unix中一切皆文件。 从标准输入,到标准输出,标准错误,每个都有对应描述符。

标准IO

  • 内核提供unbuffered非缓存I/O。如:open(2),read(2),write(2),lseek(2),close(2)
  • 内核提供buffered 缓存IO。如:fopen(3),fread(3),fwrite(3),getc(3),putc(3)

tips :

fread(3) 中的数字 3 是与库函数相关的一个章节号。在 Unix/Linux 系统中,手册页(Man Pages)是一种文档形式,用于描述特定命令、库函数等的使用和语法。手册页被组织成不同的章节,每个章节都涉及不同的主题。这些章节通常是:

  1. 用户命令(User Commands):这个章节包括用户可以直接调用的命令,如 ls、cd 和 grep 等。
  2. 系统调用(System Calls):这个章节包括系统调用和 C 库函数等。
  3. 库函数(Library Functions):这个章节包括 C 库函数和标准函数库等。
  4. 特殊文件(Special Files):这个章节包括很少使用的设备和文件,如 /dev 和 /proc 等。
  5. 文件格式和约定(File Formats and Conventions):这个章节包括文件格式和约定,如配置文件格式等。
  6. 游戏和娱乐(Games and Demos):这个章节包括游戏和演示软件等。
  7. 杂项(Miscellaneous):这个章节包括其他未分类的文档。

使用man man image.png

可以使用man 命令来访问这些内容,试试输入 man 3 fread image.png

时间

有时我们想知道一个程序花费了多长时间运行

这个时间用三个不同值来衡量

  • clock time :已经过去的总时间
  • user CPU time :用户花费的时间
  • systerm CPU time:系统CPU 或者花费在内核空间过程的时间

你可能认为 clocktime =User time + System time,但是被忽略了一种进程被阻塞的场景:比如在等待IO 。

让我们来验证一下,找系统中一个目录中源码有多少行

    time find /usr/include/ -name '*.[ch]' -exec cat {} ; | wc -l

改进方式

    time find /usr/include/ -name '*.[ch]' -print | xargs cat | wc -l

对比差异 , 可以看见各个阶段花费时间。 对比差异

我们可以使用time测量方式,来优化我们的程序,找到花费时间最多的地方。

三、 一些常见命令简约实现

ls命令

简版列出文件目录命令,是后续主要实现程序目标,最终达到和具有和系统ls命令一样的功能(如附加参数 ,usage等)

#include <stdio.h>      //提供输入输出函数定义
#include <dirent.h>     //提供读取目录和目录数据结构定义 POSIX 标准
#include <errno.h>      //提供错误代码定义
#include <stdlib.h>     //提供 exit() 函数定义
#include <string.h>     //提供 strerror() 函数定义
                        // 为了方便,后面使用apue.h,里面自动包含常用的头
int main(int argc, char *argv[]) {
  DIR *dir = NULL;                //定义指向目录数据结构的指针
  struct dirent *df;              //定义指向目录项数据结构的指针
  if (argc != 2) {                //检查命令行参数个数是否为2
    fprintf(stderr, "usage: %s dirent_name", argv[0]);   //输出使用说明
    exit(EXIT_FAILURE);           //退出程序
  }
  errno = 0;                      //将错误代码初始化为0
  if ((dir = opendir(argv[1])) == NULL) {    //尝试打开指定目录,如果失败则输出错误信息
    fprintf(stdout, "unable to open '%s' %s", argv[1], strerror(errno));
    exit(EXIT_FAILURE);           //退出程序
  }

  while ((df = readdir(dir)) != NULL) {      //循环读取目录中的每个目录项
    fprintf(stdout, "%s\n", df->d_name);     //输出目录项名称
  }

  exit(EXIT_SUCCESS);             //正常结束程序
}

cp 命令

#include "apue.h"
#define BufferSize BUFSIZ
int main(int c, char *a[]) {
  int n;
  char buf[BufferSize];
  while ((n = read(STDIN_FILENO, buf, BufferSize)) > 0) {//n 为实际读取到的字节数,可能不足BufferSize的长度
   if (write(STDOUT_FILENO, buf, n) != n) // 注意第三个参数为n,确保只将已经读取到的有效数据写入文件,从而保证程序的正确性和稳定性
     err_sys("write error");
  }
  if (n < 0)
   err_sys("read error!");
  exit(0);

}

编译,使用 将a.txt内容复制到b.txt

gcc -std=c99 -Wall -Wextra    02_cp.c  -lapue -o 02_cp
./02_cp <a.txt >b.txt

cat 命令

使用fgetc putc实现

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main(int argc, char *argv[]) {
  errno = 0;
  int c;
  while ((c = fgetc(stdin)) != EOF) {
   if (putc(c, stdout) == EOF) {
     fprintf(stderr, "Unable to write: %s", strerror(errno));
     exit(EXIT_FAILURE);
   }
  }
  exit(EXIT_SUCCESS);

}

使用read write实现

#include "stdio.h"
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

#define BF 4096
int main(int c, char *v[]) {
  int n;
  char buff[BF];
  errno = 0;
  while ((n = read(STDIN_FILENO, buff, BF)) > 0) {
   if (write(STDOUT_FILENO, buff, n) != n) {
     fprintf(stderr, "Fail write:%s", strerror(errno));
     exit(EXIT_FAILURE);
   }
  }
  if (n < 0) {
   fprintf(stderr, "Fail read:%s", strerror(errno));
   exit(EXIT_FAILURE);
  }
  exit(EXIT_SUCCESS);
}
// 使用 
./cat <a.txt

shell 程序

实现一个shell程序,使其能运行其他命令

#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sysexits.h>
#include <unistd.h>

// 获取用户输入的函数
char *getinput(char *buffer, size_t buflen) {
  printf("%s@tsh> ", getlogin());
  return fgets(buffer, buflen, stdin);
}

// SIGINT 信号处理函数
void sig_handle(int signo) {
  printf("\nCaught SIGINT (Signal #%d)!\n%s@tsh> ", signo, getlogin());
  (void) fflush(stdout);
}

int main(int argc, char *argv[]) {
  char buf[BUFSIZ];  // 存储用户输入的缓冲区
  pid_t pid;         // 子进程 ID
  int status;        // 进程状态

  // 注册 SIGINT 信号处理函数
  if (signal(SIGINT, sig_handle) == SIG_ERR) {
   fprintf(stderr, "signal error: %s\n", strerror(errno));
   exit(EXIT_FAILURE);
  }

  // 循环读取用户输入
  while (getinput(buf, sizeof(buf))) {
   buf[strlen(buf) - 1] = '\0';  // 去除换行符
   int index = 0;
   char *temp;
   char *args[BUFSIZ];

   // 将用户输入分割成多个子串
   args[index++] = strtok(buf, " \n");
   while ((temp = strtok(NULL, " \n")) != NULL) {
     args[index++] = temp;
   }
   args[index] = NULL;

   // 创建子进程执行命令
   if ((pid = fork()) == -1) {  // 失败处理
     fprintf(stderr, "shell: can't fork: %s\n",
             strerror(errno));
     continue;
   } else if (pid == 0) {       // 子进程执行
     execvp(args[0], args);     // 执行用户命令
     fprintf(stderr, "shell: couldn't exec %s: %s\n", buf,
             strerror(errno));  // 如果执行失败,则输出错误信息
     exit(EX_UNAVAILABLE);      // 退出子进程
   }

   // 等待子进程结束
   if ((pid = waitpid(pid, &status, 0)) < 0) {
     fprintf(stderr, "shell: waitpid error: %s\n",
             strerror(errno));
   }
  }

  exit(EX_OK);  // 退出程序
}

具体的执行过程如下:

  1. 使用 signal() 函数注册 SIGINT 信号的处理函数 sig_handle()
  2. 进入一个循环。每次循环中,使用 getinput() 函数获取一行用户输入,并将其存储在字符数组 buf 中。
  3. 对于每行用户输入,先使用 strtok() 函数将其分解为多个子串,存储在字符指针数组 args 中,以便后续执行。
  4. 创建一个子进程,并在子进程中调用 execvp() 函数,执行用户输入的命令。如果创建子进程失败,则回到循环开头。
  5. 在父进程中等待子进程执行结束,并输出相应的错误信息(如果有的话)。
  6. 循环结束后,退出程序并返回相应的状态码。

测试

image.png

四、 最后

经过前面的步骤,我们基本大致能够对UNIX环境编程有了一些印象。这些年,无论上层的变化有多大多快,都是在这些基础之上衍生的,而的底层的一些实现往往是我们所忽略的部分。如果我们了解这些构造,知道其运作方式,将更有助于应对应用层编程,无形中会帮助我们能在编程道路上走得更远更踏实。