前面第二十四、二十五篇,我们学过了 C 标准库的文件操作——fopen、fprintf、fread、fwrite。它们用起来很方便,有缓冲、有格式化。但你是否想过:这些函数底层是怎么把数据写到磁盘的?FILE * 到底包装了什么?
今天我们往下沉一层,直接和操作系统内核对话:学习 系统调用(System Call) 与 文件底层 I/O。这是 C 程序员从“应用层”迈向“系统层”的关键一步。理解它们,你才能真正搞懂 I/O 的性能差异从何而来,也能开始写更贴近硬件的程序。
一、什么是系统调用?
操作系统掌管着所有硬件资源:CPU、内存、磁盘、网络。普通程序不能直接访问硬件(否则一个 bug 就能毁掉整个系统),必须通过操作系统提供的“服务窗口”来请求资源。这个窗口就是系统调用。
可以把系统调用想象成“政府办事窗口”:
- 你(用户程序)填好申请表(设置参数),
- 递进窗口(触发系统调用),
- 操作系统内核审核并执行(访问硬件),
- 把结果返回给你。
C 标准库中的很多函数(如 printf、malloc、fopen)内部最终都会调用系统调用。但我们也可以绕过标准库的包装,直接使用系统调用:这就是 Unix/Linux 下的 POSIX 系统调用,如 open、read、write、close。
注意:这些函数在 Windows 上也存在对应版本(如
CreateFile、ReadFile),但名称和行为不同。本文以 POSIX(Linux/macOS)为例。Windows 用户可以在 WSL 中跟随。
二、文件描述符:内核眼中的“文件身份证”
标准 C 库用 FILE * 表示文件。而系统调用用一个整数来表示打开的文件:这个整数叫文件描述符(File Descriptor,FD)。
每个进程启动时,操作系统自动打开三个标准文件描述符:
| 文件描述符 | 符号常量 | 含义 |
|---|---|---|
| 0 | STDIN_FILENO | 标准输入(键盘) |
| 1 | STDOUT_FILENO | 标准输出(屏幕) |
| 2 | STDERR_FILENO | 标准错误(屏幕) |
这三个常量定义在 <unistd.h> 中。你每天用的 printf,底层就是向文件描述符 1 写入数据;scanf 就是从 0 读取数据。
新打开的文件会被分配一个当前未使用的最小整数作为描述符。比如你打开一个文件,它可能得到 3。
三、底层 I/O 函数:open、read、write、close
使用这些函数需要包含头文件:
#include <unistd.h> // read, write, close
#include <fcntl.h> // open 的标志
#include <sys/types.h> // 一些类型定义(有时需要)
#include <sys/stat.h> // 文件权限标志
1. open:打开文件
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
pathname:文件路径。flags:打开标志,控制读写行为,可以用|组合多个。mode:若创建新文件,指定其权限(如0644)。- 返回值:成功返回文件描述符(≥0),失败返回
-1并设置errno。
常用 flags:
| 标志 | 含义 |
|---|---|
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 读写 |
O_CREAT | 文件不存在则创建(需同时传 mode) |
O_TRUNC | 打开时清空文件内容 |
O_APPEND | 每次写入自动追加到末尾 |
示例:打开(或创建)一个文件用于写入:
int fd = open("data.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open 失败");
return 1;
}
2. write:写数据
ssize_t write(int fd, const void *buf, size_t count);
fd:文件描述符。buf:要写入的数据缓冲区。count:期望写入的字节数。- 返回值:实际写入的字节数。返回
-1表示出错。
const char *msg = "Hello, system call!\n";
ssize_t bytes_written = write(fd, msg, strlen(msg));
if (bytes_written == -1) {
perror("write 失败");
}
注意:write 不一定能一次性写完所有字节(尤其在网络 I/O 或磁盘满时),严谨的写法需要循环写入。
3. read:读数据
ssize_t read(int fd, void *buf, size_t count);
buf:存放读取内容的缓冲区。count:最多读取的字节数。- 返回值:实际读取的字节数。返回
0表示读到文件末尾;-1表示出错。
char buffer[100];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0'; // 手动加 '\0'
printf("读到: %s\n", buffer);
}
read 不会自动在末尾加 '\0',如果要当字符串用,必须自己加。
4. close:关闭文件
int close(int fd);
关闭文件描述符,释放内核资源。成功返回 0,失败返回 -1。
四、完整示例:用系统调用复制文件
把前面的知识组合起来,实现一个简单的文件复制程序,不用任何标准 C 文件函数。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <string.h>
#define BUFFER_SIZE 4096
int main(int argc, char *argv[]) {
if (argc != 3) {
const char *usage = "用法: ./copy <源文件> <目标文件>\n";
write(STDERR_FILENO, usage, strlen(usage));
return 1;
}
int src_fd = open(argv[1], O_RDONLY);
if (src_fd == -1) {
perror("打开源文件失败");
return 1;
}
int dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (dst_fd == -1) {
perror("打开目标文件失败");
close(src_fd);
return 1;
}
char buffer[BUFFER_SIZE];
ssize_t bytes_read;
while ((bytes_read = read(src_fd, buffer, BUFFER_SIZE)) > 0) {
ssize_t bytes_written = write(dst_fd, buffer, bytes_read);
if (bytes_written != bytes_read) {
perror("写入失败");
close(src_fd);
close(dst_fd);
return 1;
}
}
if (bytes_read == -1) {
perror("读取失败");
close(src_fd);
close(dst_fd);
return 1;
}
return 0;
}
编译运行:
gcc -o copy copy.c
./copy source.txt dest.txt
你会发现,这和标准库的 fopen/fread 风格截然不同——没有 FILE *,只有整数文件描述符;没有格式化,只有原始字节流。这就是底层。
五、标准 I/O vs 系统 I/O:缓冲区的秘密
你可能想问:既然标准库最终也是调用系统调用,那 fprintf 和 write 有什么区别?答案是:缓冲区。
标准 I/O (FILE*) | 系统 I/O (fd) | |
|---|---|---|
| 缓冲 | 用户态缓冲(默认有) | 无用户态缓冲 |
| 调用频率 | 攒够数据再系统调用 | 每次都是系统调用 |
| 适合场景 | 小数据量、格式化文本 | 大数据块、精确控制、设备 I/O |
标准 I/O 在用户态维护了一个缓冲区。你调用 fprintf,数据先写进缓冲区;缓冲区满了(或遇到换行、调用 fflush)才触发一次系统调用 write,真正交给内核。这极大地减少了系统调用的次数——而系统调用本身是昂贵的(涉及用户态/内核态切换)。
系统 I/O 没有这个缓冲。你每调用一次 write,就立刻切到内核态执行一次系统调用。如果你逐字节写 1MB 数据,就是 100 万次系统调用,慢得难以忍受。但如果每次写 4KB 或更大的块,效率可以很高。
// 高效:每次写大块
write(fd, buffer, 4096);
// 极低效:逐字节写
for (int i = 0; i < n; i++) {
write(fd, &data[i], 1); // 每次一个系统调用!
}
特殊情况:标准输出
标准 C 的 stdout 默认是行缓冲的——遇到换行符 \n 会刷新缓冲区。如果你混用 printf 和 write(STDOUT_FILENO, ...),可能会发现输出顺序不对,因为两者使用了不同的缓冲机制。解决方法是统一用一种,或在混用前调用 fflush(stdout)。
何时直接使用系统 I/O?
- 你需要精确控制每次写入的大小、时机(如写入网络包、设备寄存器)。
- 你操作的是文件描述符(如管道、socket、设备文件),而标准 C 没有封装它。
- 你已经在做大量数据的整块读写,且想避免用户态缓冲的额外拷贝。
日常文本文件的读写用标准 I/O 就足够了,更安全、更方便。
六、errno 与错误处理
系统调用失败时返回 -1 或 NULL,同时设置全局变量 errno,里面存着具体的错误码。perror 可以根据 errno 打印对应的错误信息,也可以手动检查:
#include <errno.h>
#include <string.h>
int fd = open("nonexist.txt", O_RDONLY);
if (fd == -1) {
printf("错误码: %d\n", errno);
printf("错误信息: %s\n", strerror(errno));
}
常见错误码:
ENOENT:文件不存在EACCES:权限不足EMFILE:进程打开的文件描述符已达上限ENOSPC:磁盘空间不足
系统编程中,检查每一个系统调用的返回值并恰当处理错误,是基本素养。
七、小结
今天你从标准 C 库往下沉了一层,直接触碰了操作系统内核的接口:
- 系统调用是用户程序请求内核服务的唯一方式。
- 文件描述符是内核管理打开文件的整数 ID,标准输入/输出/错误分别对应 0、1、2。
open/read/write/close是底层 I/O 的核心系统调用,操作的是原始字节流。- 标准 I/O vs 系统 I/O 的关键区别在于用户态缓冲区:标准 I/O 用它减少系统调用次数,提高小数据量场景的效率;系统 I/O 无缓冲,适合大数据块或精确控制场景。
- 系统调用出错时设置
errno,通过perror或strerror获取信息。
理解系统调用,你就理解了 C 程序是如何与整个计算机系统交互的。这也是嵌入式开发、网络编程、操作系统开发的基础——在那些领域,你几乎天天和文件描述符打交道。
下一篇文章,我们将利用这些知识,探索 C 语言中静态库与动态库的制作与使用:把代码打包成 .a 和 .so,让你的模块可以被其他程序共享和复用。这是从“写代码”走向“发布软件”的必经之路。
课后小练习
- 用系统调用实现一个简易的
cat程序:接收文件名作为命令行参数,将文件内容输出到标准输出。如果文件名没传,则从标准输入读取并输出(相当于cat不带参数的行为)。 - 用
open、read、write实现一个函数int file_copy(const char *src, const char *dst),在内部处理所有错误,返回 0 表示成功,-1 表示失败。对比它与标准 C 的fread/fwrite实现的差异。 - 混用
printf和write(STDOUT_FILENO, ...),分别观察在换行和不换行时输出顺序的变化。调用fflush(stdout)后再试一次,理解缓冲机制。 - (小挑战)写一个程序,在文件末尾追加当前的时间戳(
time()获取)。要求使用O_APPEND标志,并验证每次运行后文件新内容确实在末尾。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。

485

被折叠的 条评论
为什么被折叠?



