第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) 是一个非负整数,操作系统用它来标识一个打开的文件。每个进程都有自己独立的文件描述符表:
| 文件描述符 | 标准名称 | 默认连接 |
|---|---|---|
| 0 | STDIN_FILENO | 键盘(标准输入) |
| 1 | STDOUT_FILENO | 终端(标准输出) |
| 2 | STDERR_FILENO | 终端(标准错误) |
| 3, 4, 5, ... | 其他打开的文件 |
#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:打开文件
#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 一起用,文件已存在则失败 |
// 只读打开
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:读取数据
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);read 从文件描述符 fd 读取最多 count 字节到 buf 中。返回值:
- 正数:实际读取的字节数(可能小于
count) - 0:到达文件末尾
- -1:发生错误
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:写入数据
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);write 将 buf 中的 count 字节写入文件描述符 fd。返回值:
- 正数:实际写入的字节数(可能小于
count) - -1:发生错误
const char *msg = "Hello, World!\n";
ssize_t n = write(STDOUT_FILENO, msg, strlen(msg));
if (n == -1) {
perror("write");
}注意:write 的返回值也可能小于 count。在循环中写入大量数据时,需要检查并处理部分写入的情况:
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:关闭文件
#include <unistd.h>
int close(int fd);关闭文件描述符,释放相关资源。进程退出时会自动关闭所有打开的文件描述符,但显式关闭是好的编程习惯:
if (close(fd) == -1) {
perror("close");
}2.2.5 lseek:文件定位
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);// 移动到文件开头
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/close | fopen/fread/fprintf/fclose |
| 缓冲 | 无缓冲 | 有缓冲 |
| 头文件 | <unistd.h>, <fcntl.h> | <stdio.h> |
| 可移植性 | UNIX/Linux | ANSI 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 表示错误类型:
#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 | 内存不足 |
EIO | I/O错误 |
ENOSPC | 磁盘空间不足 |
重要规则:errno 只在系统调用失败时才有意义。成功的系统调用不会重置 errno。因此,检查 errno 之前必须先检查系统调用的返回值。
2.5 文件状态与权限
2.5.1 stat:获取文件信息
#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:所有者/组的用户IDst_atime:最后访问时间st_mtime:最后修改时间st_ctime:状态变更时间
2.5.2 文件类型判断
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:修改文件权限
#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 目录操作
#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:创建子进程
#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 函数族用新程序替换当前进程的映像:
#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:
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:等待子进程
#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系统中进程间通信的一种简单形式:
#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 命令
#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
#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> exit3.3 案例三:文件信息查看器(简易 ls -l)
#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 系统调用创建管道,父进程写入数据,子进程读取并输出。