35. 【C语言】与操作系统对话:系统调用与文件底层 I/O

前面第二十四、二十五篇,我们学过了 C 标准库的文件操作——fopenfprintffreadfwrite。它们用起来很方便,有缓冲、有格式化。但你是否想过:这些函数底层是怎么把数据写到磁盘的?FILE * 到底包装了什么?

今天我们往下沉一层,直接和操作系统内核对话:学习 系统调用(System Call)文件底层 I/O。这是 C 程序员从“应用层”迈向“系统层”的关键一步。理解它们,你才能真正搞懂 I/O 的性能差异从何而来,也能开始写更贴近硬件的程序。


一、什么是系统调用?

操作系统掌管着所有硬件资源:CPU、内存、磁盘、网络。普通程序不能直接访问硬件(否则一个 bug 就能毁掉整个系统),必须通过操作系统提供的“服务窗口”来请求资源。这个窗口就是系统调用

可以把系统调用想象成“政府办事窗口”:

  • 你(用户程序)填好申请表(设置参数),
  • 递进窗口(触发系统调用),
  • 操作系统内核审核并执行(访问硬件),
  • 把结果返回给你。

C 标准库中的很多函数(如 printfmallocfopen)内部最终都会调用系统调用。但我们也可以绕过标准库的包装,直接使用系统调用:这就是 Unix/Linux 下的 POSIX 系统调用,如 openreadwriteclose

注意:这些函数在 Windows 上也存在对应版本(如 CreateFileReadFile),但名称和行为不同。本文以 POSIX(Linux/macOS)为例。Windows 用户可以在 WSL 中跟随。


二、文件描述符:内核眼中的“文件身份证”

标准 C 库用 FILE * 表示文件。而系统调用用一个整数来表示打开的文件:这个整数叫文件描述符(File Descriptor,FD)

每个进程启动时,操作系统自动打开三个标准文件描述符:

文件描述符符号常量含义
0STDIN_FILENO标准输入(键盘)
1STDOUT_FILENO标准输出(屏幕)
2STDERR_FILENO标准错误(屏幕)

这三个常量定义在 <unistd.h> 中。你每天用的 printf,底层就是向文件描述符 1 写入数据;scanf 就是从 0 读取数据。

新打开的文件会被分配一个当前未使用的最小整数作为描述符。比如你打开一个文件,它可能得到 3


三、底层 I/O 函数:openreadwriteclose

使用这些函数需要包含头文件:

#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:缓冲区的秘密

你可能想问:既然标准库最终也是调用系统调用,那 fprintfwrite 有什么区别?答案是:缓冲区

标准 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 会刷新缓冲区。如果你混用 printfwrite(STDOUT_FILENO, ...),可能会发现输出顺序不对,因为两者使用了不同的缓冲机制。解决方法是统一用一种,或在混用前调用 fflush(stdout)

何时直接使用系统 I/O?

  • 你需要精确控制每次写入的大小、时机(如写入网络包、设备寄存器)。
  • 你操作的是文件描述符(如管道、socket、设备文件),而标准 C 没有封装它。
  • 你已经在做大量数据的整块读写,且想避免用户态缓冲的额外拷贝。

日常文本文件的读写用标准 I/O 就足够了,更安全、更方便。


六、errno 与错误处理

系统调用失败时返回 -1NULL,同时设置全局变量 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,通过 perrorstrerror 获取信息。

理解系统调用,你就理解了 C 程序是如何与整个计算机系统交互的。这也是嵌入式开发、网络编程、操作系统开发的基础——在那些领域,你几乎天天和文件描述符打交道。

下一篇文章,我们将利用这些知识,探索 C 语言中静态库与动态库的制作与使用:把代码打包成 .a.so,让你的模块可以被其他程序共享和复用。这是从“写代码”走向“发布软件”的必经之路。


课后小练习

  1. 用系统调用实现一个简易的 cat 程序:接收文件名作为命令行参数,将文件内容输出到标准输出。如果文件名没传,则从标准输入读取并输出(相当于 cat 不带参数的行为)。
  2. openreadwrite 实现一个函数 int file_copy(const char *src, const char *dst),在内部处理所有错误,返回 0 表示成功,-1 表示失败。对比它与标准 C 的 fread/fwrite 实现的差异。
  3. 混用 printfwrite(STDOUT_FILENO, ...),分别观察在换行和不换行时输出顺序的变化。调用 fflush(stdout) 后再试一次,理解缓冲机制。
  4. (小挑战)写一个程序,在文件末尾追加当前的时间戳(time() 获取)。要求使用 O_APPEND 标志,并验证每次运行后文件新内容确实在末尾。

我们下期见!

💡获取本系列示例代码请访问 GitCode 仓库

工作原理: 外部传感器(如电阻应变式称重传感器)产生的微小模拟电压信号输入到 HX711 的模拟输入通道(通道 A 或通道 B)。信号先经过片内低噪声可编程放大器放大,放大倍数根据通道及设置确定(如通道 A 为 128、64 等,通道 B 为 32)。放大后的信号进入 24 位 A/D 转换器,进行模数转换。转换后的数字信号经内部数字信号处理后,通过 DOUT/DT 管脚以串行通讯方式输出给外部微控制器(如单片机)。微控制器根据接收到的数据进行后续处理,如计算重量、显示数值等。 通信协议: HX711 微控制器通过串行通讯。当 DOUT/DT 为高电平时,表示 HX711 内部正在进行数据转换,此时微控制器不应向 PD_SCK/SCK 发送时钟信号。当 DOUT/DT 变为低电平时,表明数据转换完成,微控制器可通过 PD_SCK/SCK 向 HX711 发送时钟信号,读取 24 位数据。每发送一个时钟脉冲,HX711 将 DOUT/DT 上的数据位移出一位,微控制器依次读取。 数据读取: 微控制器不断查询 DOUT/DT 管脚状态,等待 DOUT/DT 变为低电平。 DOUT/DT 变为低电平后,微控制器开始通过 PD_SCK/SCK 发送 24 个时钟脉冲。 在每个时钟脉冲上升沿,读取 DOUT/DT 管脚的电平状态,将 24 个读取到的电平状态组合成 24 位数据。 根据需要对读取到的数据进行处理,如转换为实际物理量(如重量)。 应用场景: 电子秤:各类商业电子秤、家用体重秤等,将压力传感器信号转换为数字信号,实现精准称重。 工业称重系统:如物料称重、配料系统等,对原材料或产品进行精确计量。 传感器信号采集:配合应变片式传感器、压力传感器等,采集微小的物理量变化并转换为数字信号供后续处理。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值