8C程序设计语言(第2版)

UNIX系统接口

系统调用

文件描述符系统调用进程控制

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第8章 UNIX系统接口

系统调用

一、导读

1.1 本章学习目标

第8章是《C程序设计语言》的最后一章,也是从"C语言编程"跨越到"系统编程"的桥梁。前面七章讲解的都是C语言本身——标准库函数、语法特性、数据类型。而本章将进入操作系统的领域,讲解UNIX(以及Linux、macOS等类UNIX系统)提供的系统调用接口。

本章的核心学习目标包括:

  • 理解文件描述符:操作系统如何用非负整数标识打开的文件。
  • 掌握低级I/O系统调用:open、read、write、lseek、close 的用法。
  • 理解系统调用与标准I/O库的关系:stdio 是如何在系统调用之上构建的?
  • 掌握文件权限和模式:chmod、stat 等文件操作。
  • 学会进程控制:fork、exec、wait 的基本用法。
  • 理解系统调用的错误处理:errno 和 perror 的使用。

1.2 为什么本章如此重要

系统调用是用户程序与操作系统内核交互的唯一方式。理解系统调用,你才能真正理解程序在操作系统上是如何运行的。

K&R 在第八章的讲解有两个重要目的:

揭示标准I/O库的底层实现:当你理解了 read 和 write 系统调用,你就能理解 printf 和 fopen 在底层做了什么。

展示系统编程的基本技能:文件操作、进程创建、错误处理——这些是编写系统工具(如 cat、cp、ls、shell)的基础。

更重要的是,系统调用是理解计算机系统的窗口。通过系统调用,你可以看到操作系统如何管理文件、如何创建进程、如何分配资源。这些知识对于理解CSAPP(《深入理解计算机系统》)中的内容至关重要。

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

文件描述符和文件指针(FILE *)有什么区别?它们之间如何转换?

为什么系统调用比标准I/O库函数"慢"?慢在哪里?

fork() 创建的子进程和父进程有什么区别?它们共享什么?不共享什么?

exec 函数族是如何"替换"当前进程的?调用 exec 后原来的代码还在吗?

为什么 read 的返回值可能小于请求的字节数?这一定是错误吗?

errno 是如何工作的?为什么每次系统调用前都应该重置它?


二、核心概念详解

2.1 文件描述符

在UNIX系统中,一切皆文件。普通文件、目录、设备(键盘、显示器、打印机)、管道、套接字——所有I/O操作都通过统一的文件接口来完成。

文件描述符(file descriptor) 是一个非负整数,操作系统用它来标识一个打开的文件。每个进程都有自己独立的文件描述符表:

文件描述符标准名称默认连接
0STDIN_FILENO键盘(标准输入)
1STDOUT_FILENO终端(标准输出)
2STDERR_FILENO终端(标准错误)
3, 4, 5, ...其他打开的文件
c
#include <unistd.h>

// STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO 定义在 <unistd.h> 中
// 它们的值分别是 0, 1, 2

当程序打开一个新文件时,操作系统分配最小的可用文件描述符。如果0、1、2都已占用(通常如此),下一个打开的文件获得描述符3。

2.2 低级I/O系统调用

2.2.1 open:打开文件

c
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

flags 参数指定打开模式,可以用 | 组合:

标志含义
O_RDONLY只读
O_WRONLY只写
O_RDWR读写
O_CREAT文件不存在则创建
O_TRUNC文件存在则截断(清空)
O_APPEND每次写入前定位到文件末尾
O_EXCL与 O_CREAT 一起用,文件已存在则失败
c
// 只读打开
int fd = open("data.txt", O_RDONLY);
if (fd == -1) {
    perror("open");
    exit(1);
}

// 创建新文件(或截断已有文件),权限为 rw-r--r--
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);

// 追加模式
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);

权限模式:0644 是八进制数,表示 rw-r--r--(所有者读写,其他人只读)。权限受 umask 影响:实际权限 = mode & ~umask。

2.2.2 read:读取数据

c
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

read 从文件描述符 fd 读取最多 count 字节到 buf 中。返回值:

  • 正数:实际读取的字节数(可能小于 count)
  • 0:到达文件末尾
  • -1:发生错误
c
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer));

if (n > 0) {
    // 成功读取n字节
    buffer[n] = '\0';  // 如果作为字符串处理
    printf("Read: %s\n", buffer);
} else if (n == 0) {
    printf("End of file\n");
} else {
    perror("read");
}

为什么返回值可能小于 `count`? 这不是错误!可能的原因:

  • 读取普通文件时接近文件末尾
  • 从终端读取时,用户输入的字符数少于 count
  • 从管道或网络套接字读取时,数据尚未全部到达
  • 被信号中断

2.2.3 write:写入数据

c
#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

write 将 buf 中的 count 字节写入文件描述符 fd。返回值:

  • 正数:实际写入的字节数(可能小于 count)
  • -1:发生错误
c
const char *msg = "Hello, World!\n";
ssize_t n = write(STDOUT_FILENO, msg, strlen(msg));

if (n == -1) {
    perror("write");
}

注意:write 的返回值也可能小于 count。在循环中写入大量数据时,需要检查并处理部分写入的情况:

c
ssize_t write_all(int fd, const void *buf, size_t count) {
    const char *p = (const char *)buf;
    size_t remaining = count;
    
    while (remaining > 0) {
        ssize_t n = write(fd, p, remaining);
        if (n == -1) {
            if (errno == EINTR) continue;  // 被信号中断,重试
            return -1;
        }
        p += n;
        remaining -= n;
    }
    return count;
}

2.2.4 close:关闭文件

c
#include <unistd.h>

int close(int fd);

关闭文件描述符,释放相关资源。进程退出时会自动关闭所有打开的文件描述符,但显式关闭是好的编程习惯:

c
if (close(fd) == -1) {
    perror("close");
}

2.2.5 lseek:文件定位

c
#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);
c
// 移动到文件开头
lseek(fd, 0, SEEK_SET);

// 获取当前文件大小
off_t size = lseek(fd, 0, SEEK_END);

// 从当前位置前进100字节
lseek(fd, 100, SEEK_CUR);

// 创建稀疏文件(文件中包含空洞)
lseek(fd, 1000000, SEEK_SET);
write(fd, "end", 3);  // 文件中0到999999之间是空洞,不占磁盘空间

2.3 系统调用 vs 标准I/O库

特性系统调用标准I/O库
接口open/read/write/closefopen/fread/fprintf/fclose
缓冲无缓冲有缓冲
头文件<unistd.h>, <fcntl.h><stdio.h>
可移植性UNIX/LinuxANSI C标准
功能基础I/O格式化I/O
文件标识文件描述符(int)文件指针(FILE *)

标准I/O库是在系统调用之上构建的:

应用程序
   ↓
printf / fprintf / fread / fwrite  (标准I/O库,带缓冲)
   ↓
write / read                        (系统调用,无缓冲)
   ↓
内核VFS层
   ↓
文件系统驱动
   ↓
磁盘设备驱动

标准I/O库的缓冲机制减少了系统调用的次数。例如,调用100次 printf 可能只触发几次 write 系统调用。这大幅提高了I/O性能。

2.4 错误处理

系统调用出错时返回 -1(或 NULL),并设置全局变量 errno 表示错误类型:

c
#include <errno.h>
#include <string.h>

int fd = open("nonexistent.txt", O_RDONLY);
if (fd == -1) {
    // 方式1:perror
    perror("open");  // 输出: open: No such file or directory
    
    // 方式2:strerror
    fprintf(stderr, "open failed: %s\n", strerror(errno));
    
    // 方式3:直接检查errno
    if (errno == ENOENT) {
        fprintf(stderr, "File not found\n");
    } else if (errno == EACCES) {
        fprintf(stderr, "Permission denied\n");
    }
}

常见 `errno` 值:

errno含义
ENOENT文件不存在
EACCES权限不足
EEXIST文件已存在
EBADF无效的文件描述符
EINTR被信号中断
ENOMEM内存不足
EIOI/O错误
ENOSPC磁盘空间不足

重要规则:errno 只在系统调用失败时才有意义。成功的系统调用不会重置 errno。因此,检查 errno 之前必须先检查系统调用的返回值。

2.5 文件状态与权限

2.5.1 stat:获取文件信息

c
#include <sys/stat.h>
#include <unistd.h>

struct stat st;
if (stat("file.txt", &st) == -1) {
    perror("stat");
    exit(1);
}

printf("File size: %ld bytes\n", st.st_size);
printf("Permissions: %o\n", st.st_mode & 0777);
printf("Owner UID: %d\n", st.st_uid);
printf("Last modified: %ld\n", st.st_mtime);

struct stat 的主要成员:

  • st_size:文件大小(字节)
  • st_mode:文件类型和权限
  • st_uid / st_gid:所有者/组的用户ID
  • st_atime:最后访问时间
  • st_mtime:最后修改时间
  • st_ctime:状态变更时间

2.5.2 文件类型判断

c
struct stat st;
stat("path", &st);

if (S_ISREG(st.st_mode))  printf("Regular file\n");
if (S_ISDIR(st.st_mode))  printf("Directory\n");
if (S_ISLNK(st.st_mode))  printf("Symbolic link\n");
if (S_ISCHR(st.st_mode))  printf("Character device\n");
if (S_ISBLK(st.st_mode))  printf("Block device\n");
if (S_ISFIFO(st.st_mode)) printf("FIFO (named pipe)\n");
if (S_ISSOCK(st.st_mode)) printf("Socket\n");

2.5.3 chmod:修改文件权限

c
#include <sys/stat.h>

// 设置权限为 rwxr-xr-x (755)
chmod("script.sh", S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH);

// 或使用八进制
chmod("script.sh", 0755);

2.6 目录操作

c
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>

// 创建目录
mkdir("newdir", 0755);

// 删除目录
rmdir("olddir");

// 打开目录
DIR *dir = opendir(".");
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
    printf("%s\n", entry->d_name);
}
closedir(dir);

// 获取当前工作目录
char cwd[1024];
getcwd(cwd, sizeof(cwd));
printf("Current directory: %s\n", cwd);

// 改变工作目录
chdir("/tmp");

// 删除文件
unlink("temp.txt");

// 重命名/移动文件
rename("old_name.txt", "new_name.txt");

2.7 进程控制

2.7.1 fork:创建子进程

c
#include <unistd.h>
#include <sys/wait.h>

pid_t pid = fork();

if (pid < 0) {
    perror("fork");
    exit(1);
} else if (pid == 0) {
    // 子进程:fork返回0
    printf("Child: PID = %d, Parent PID = %d\n", getpid(), getppid());
    exit(0);
} else {
    // 父进程:fork返回子进程的PID
    printf("Parent: PID = %d, Child PID = %d\n", getpid(), pid);
    
    int status;
    waitpid(pid, &status, 0);  // 等待子进程结束
    
    if (WIFEXITED(status)) {
        printf("Child exited with status %d\n", WEXITSTATUS(status));
    }
}

fork() 的关键特性:

  • 子进程是父进程的副本:获得父进程数据空间、堆、栈的副本
  • 子进程和父进程共享代码段(只读)
  • 子进程获得自己的PID
  • fork 在父进程中返回子进程的PID,在子进程中返回0
  • 现代UNIX系统使用写时复制(Copy-on-Write):只有在子进程或父进程修改数据时,才真正复制内存页

2.7.2 exec:执行新程序

exec 函数族用新程序替换当前进程的映像:

c
#include <unistd.h>

// execl:l表示参数列表(list),以NULL结尾
execlp("ls", "ls", "-l", NULL);

// execv:v表示参数数组(vector)
char *args[] = {"ls", "-l", NULL};
execv("/bin/ls", args);

// execl:指定完整路径
execl("/bin/ls", "ls", "-l", "-a", NULL);

函数名的后缀含义:

  • l(list):参数逐个列出,以 NULL 结尾
  • v(vector):参数以数组形式传递
  • p(path):在 PATH 环境变量中搜索可执行文件
  • e(environment):可以指定新的环境变量

`exec` 不会创建新进程——它替换当前进程。如果要运行新程序同时保持父进程,需要先 fork 再 exec:

c
pid_t pid = fork();
if (pid == 0) {
    // 子进程
    execlp("echo", "echo", "Hello from child!", NULL);
    perror("execlp");  // exec失败才会执行到这里
    exit(1);
} else {
    // 父进程
    wait(NULL);
    printf("Child finished\n");
}

2.7.3 wait:等待子进程

c
#include <sys/wait.h>

int status;
pid_t child_pid = wait(&status);

// 检查子进程退出状态
if (WIFEXITED(status)) {
    printf("Child exited normally, status = %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
    printf("Child killed by signal %d\n", WTERMSIG(status));
}

// waitpid:等待特定子进程
pid_t child_pid = waitpid(pid, &status, 0);

// 非阻塞等待
pid_t result = waitpid(pid, &status, WNOHANG);
if (result == 0) {
    printf("Child still running\n");
} else if (result > 0) {
    printf("Child finished\n");
}

2.8 信号处理(简介)

信号是UNIX系统中进程间通信的一种简单形式:

c
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void sigint_handler(int sig) {
    printf("\nCaught SIGINT (Ctrl+C)! Press again to exit.\n");
}

int main(void) {
    signal(SIGINT, sigint_handler);
    
    printf("Running... Press Ctrl+C\n");
    while (1) {
        sleep(1);
    }
    
    return 0;
}

常见信号:

信号含义默认行为
SIGINT中断(Ctrl+C)终止
SIGTERM终止终止
SIGKILL强制终止(不可捕获)终止
SIGSTOP暂停(不可捕获)暂停
SIGCHLD子进程状态改变忽略
SIGPIPE写入已关闭的管道终止
SIGALRM定时器超时终止

三、实战案例

3.1 案例一:实现简易 cat 命令

c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFFER_SIZE 8192

void cat_file(int fd) {
    char buffer[BUFFER_SIZE];
    ssize_t n;
    
    while ((n = read(fd, buffer, BUFFER_SIZE)) > 0) {
        if (write_all(STDOUT_FILENO, buffer, n) == -1) {
            perror("write");
            return;
        }
    }
    
    if (n == -1) {
        perror("read");
    }
}

ssize_t write_all(int fd, const void *buf, size_t count) {
    const char *p = (const char *)buf;
    size_t remaining = count;
    
    while (remaining > 0) {
        ssize_t n = write(fd, p, remaining);
        if (n == -1) {
            if (errno == EINTR) continue;
            return -1;
        }
        p += n;
        remaining -= n;
    }
    return count;
}

int main(int argc, char *argv[]) {
    if (argc == 1) {
        cat_file(STDIN_FILENO);
    } else {
        for (int i = 1; i < argc; i++) {
            int fd = open(argv[i], O_RDONLY);
            if (fd == -1) {
                perror(argv[i]);
                continue;
            }
            cat_file(fd);
            close(fd);
        }
    }
    
    return 0;
}

3.2 案例二:实现简易 shell

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#define MAX_LINE 1024
#define MAX_ARGS 64

int main(void) {
    char line[MAX_LINE];
    char *args[MAX_ARGS];
    
    while (1) {
        printf("myshell> ");
        fflush(stdout);
        
        if (fgets(line, MAX_LINE, stdin) == NULL) {
            printf("\n");
            break;
        }
        
        // 去掉换行符
        line[strcspn(line, "\n")] = '\0';
        
        // 跳过空行
        if (line[0] == '\0') continue;
        
        // 解析命令
        int argc = 0;
        char *token = strtok(line, " \t");
        while (token != NULL && argc < MAX_ARGS - 1) {
            args[argc++] = token;
            token = strtok(NULL, " \t");
        }
        args[argc] = NULL;
        
        // 内置命令
        if (strcmp(args[0], "exit") == 0 || strcmp(args[0], "quit") == 0) {
            break;
        }
        if (strcmp(args[0], "cd") == 0) {
            if (args[1] == NULL) {
                chdir(getenv("HOME"));
            } else {
                if (chdir(args[1]) == -1) {
                    perror("cd");
                }
            }
            continue;
        }
        
        // 外部命令:fork + exec
        pid_t pid = fork();
        if (pid < 0) {
            perror("fork");
            continue;
        }
        
        if (pid == 0) {
            // 子进程
            execvp(args[0], args);
            perror(args[0]);
            exit(127);
        }
        
        // 父进程
        int status;
        waitpid(pid, &status, 0);
    }
    
    return 0;
}

运行示例:

myshell> ls -l
total 8
-rwxr-xr-x 1 user user 8192 Oct  5 10:00 myshell
myshell> echo hello world
hello world
myshell> pwd
/home/user
myshell> exit

3.3 案例三:文件信息查看器(简易 ls -l)

c
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <dirent.h>
#include <time.h>
#include <pwd.h>
#include <grp.h>

void print_file_info(const char *path, const char *name) {
    struct stat st;
    char fullpath[1024];
    snprintf(fullpath, sizeof(fullpath), "%s/%s", path, name);
    
    if (stat(fullpath, &st) == -1) {
        perror(name);
        return;
    }
    
    // 文件类型和权限
    char perms[11] = "----------";
    
    if (S_ISDIR(st.st_mode))  perms[0] = 'd';
    if (S_ISLNK(st.st_mode))  perms[0] = 'l';
    
    perms[1] = (st.st_mode & S_IRUSR) ? 'r' : '-';
    perms[2] = (st.st_mode & S_IWUSR) ? 'w' : '-';
    perms[3] = (st.st_mode & S_IXUSR) ? 'x' : '-';
    perms[4] = (st.st_mode & S_IRGRP) ? 'r' : '-';
    perms[5] = (st.st_mode & S_IWGRP) ? 'w' : '-';
    perms[6] = (st.st_mode & S_IXGRP) ? 'x' : '-';
    perms[7] = (st.st_mode & S_IROTH) ? 'r' : '-';
    perms[8] = (st.st_mode & S_IWOTH) ? 'w' : '-';
    perms[9] = (st.st_mode & S_IXOTH) ? 'x' : '-';
    
    // 所有者和组
    struct passwd *pw = getpwuid(st.st_uid);
    struct group *gr = getgrgid(st.st_gid);
    const char *owner = pw ? pw->pw_name : "?";
    const char *group = gr ? gr->gr_name : "?";
    
    // 修改时间
    char time_str[64];
    struct tm *tm = localtime(&st.st_mtime);
    strftime(time_str, sizeof(time_str), "%b %d %H:%M", tm);
    
    printf("%s %3ld %-8s %-8s %8ld %s %s\n",
           perms, (long)st.st_nlink, owner, group,
           (long)st.st_size, time_str, name);
}

int main(int argc, char *argv[]) {
    const char *dir = ".";
    if (argc > 1) dir = argv[1];
    
    DIR *d = opendir(dir);
    if (d == NULL) {
        perror(dir);
        return 1;
    }
    
    struct dirent *entry;
    while ((entry = readdir(d)) != NULL) {
        if (entry->d_name[0] == '.') continue;  // 跳过隐藏文件
        print_file_info(dir, entry->d_name);
    }
    
    closedir(d);
    return 0;
}

四、本章小结

第8章讲解了UNIX系统接口——程序与操作系统内核交互的桥梁:

文件描述符:非负整数,标识打开的文件。0/1/2 分别对应标准输入/输出/错误。

低级I/O:open/read/write/close 是最基础的系统调用。它们无缓冲,每次调用都直接陷入内核。标准I/O库(stdio)在这些系统调用之上构建了缓冲机制。

错误处理:系统调用失败时返回-1并设置 errno。使用 perror 或 strerror(errno) 获取错误描述。

文件状态:stat 获取文件信息(大小、权限、时间等)。chmod 修改权限。

进程控制:fork 创建子进程,exec 执行新程序,wait 等待子进程结束。fork-exec-wait 模式是UNIX中创建新进程的标准方式。

信号:进程间通信的简单机制。SIGINT(Ctrl+C)、SIGTERM(终止)等。

本章是K&R的收尾之作,它将你的视野从C语言本身扩展到了操作系统层面。掌握系统调用,你就能编写出与操作系统深度交互的程序——文件管理器、shell、网络服务器、系统监控工具。这也是理解CSAPP等系统课程的重要基础。

C语言虽然诞生于半个世纪前,但它仍然是世界上最广泛使用的编程语言之一。从操作系统内核(Linux、Windows)到嵌入式系统,从数据库引擎(SQLite、MySQL)到编程语言的解释器(Python、Ruby),C语言无处不在。掌握C语言,你就掌握了理解计算机系统的钥匙。


五、思考与练习

思考题

为什么文件描述符0、1、2有特殊含义?如果程序启动时标准输入被关闭了(fd 0不可用),打开新文件时会发生什么?

fork 之后,子进程和父进程的执行顺序是确定的吗?为什么?

为什么 exec 成功时不会返回?如果 exec 失败了会怎样?

read 返回0和返回-1有什么区别?分别代表什么含义?

为什么系统调用比库函数"慢"?在什么场景下这种性能差异会变得显著?

编程练习

实现 `cp` 命令:使用 open/read/write/close 系统调用复制文件。支持大文件(使用合适的缓冲区大小)。

实现 `ls -l` 命令:使用 opendir/readdir/stat 列出目录内容,显示文件权限、所有者、大小和修改时间。

编写一个程序:使用 fork 创建多个子进程,每个子进程执行不同的命令,父进程等待所有子进程完成后退出。

实现一个简单的管道:使用 pipe 系统调用创建管道,父进程写入数据,子进程读取并输出。

8/8UNIX
0%