前言:

从本篇开始正式进入 Linux 系统编程系列,从零开始系统化覆盖文件操作、进程管理、线程并发、进程间通信、网络编程等核心内容,全部贴合嵌入式 Linux、后端服务开发的岗位实际需求,从底层原理到代码实战逐层拆解,完成从 C 语言语法到 Linux 原生开发的能力跃迁。


一、系统编程与系统调用基础

1. 什么是系统编程

简单来说,系统编程就是直接调用操作系统内核提供的接口进行开发,利用操作系统的底层能力完成文件管理、内存调度、进程并发、网络通信等功能。

我们日常使用的 C 标准库函数(fopen/malloc/printf等),底层最终都依赖操作系统提供的系统调用实现。系统编程就是跳过封装层,直接和操作系统内核交互,拥有更细的控制粒度、更高的执行效率,同时也更贴近硬件与系统底层。

2. 系统调用的本质:用户态与内核态

操作系统为了保护系统安全,将运行环境分为两个层级:

  • 用户态:应用程序运行的层级,只能访问受限的内存,不能直接操作硬件,执行效率相对低。
  • 内核态:操作系统内核运行的层级,可以访问所有系统资源、直接操作硬件,拥有最高权限。

系统调用就是用户态程序进入内核态的唯一合法入口。当我们调用open/read这类系统调用时,CPU 会从用户态切换到内核态,执行内核中的对应逻辑,完成后再切换回用户态返回结果。

3. 系统调用的核心特点

  1. 由操作系统内核提供,运行在内核态,权限更高
  2. 调用会触发上下文切换,有一定的性能开销
  3. 属于 POSIX 标准,所有类 Unix 系统(Linux、Unix、MacOS)通用
  4. 功能更底层,控制粒度更细,支持很多标准库没有的高级特性

二、文件描述符:Linux 一切皆文件

“一切皆文件” 是 Linux 最核心的设计哲学,所有的资源(普通文件、目录、硬件设备、管道、套接字等)都被抽象成文件,通过统一的文件操作接口进行读写。

1. 文件描述符的本质

文件描述符(File Descriptor,简称 fd)是一个非负整数,是进程访问文件的句柄。

  • 内核在每个进程的 PCB(进程控制块)中维护了一张文件描述符表
  • fd 就是这张表的下标索引,内核通过 fd 找到对应的文件实体
  • 每个进程的文件描述符表相互独立,互不干扰

2. 默认打开的三个文件描述符

任何一个 C 程序启动时,内核都会默认打开三个文件描述符,不需要手动创建:

文件描述符 宏定义 对应含义 默认指向
0 STDIN_FILENO 标准输入 终端键盘
1 STDOUT_FILENO 标准输出 终端屏幕
2 STDERR_FILENO 标准错误 终端屏幕

我们常用的printf/scanf底层,本质就是通过 1 号和 0 号文件描述符执行写入和读取系统调用。

3. 文件描述符分配规则

遵循最小可用原则:每次打开新文件时,系统会从 0 开始查找,分配当前编号最小的未被使用的非负整数。 关闭一个文件描述符后,该编号会被回收,下次打开新文件时会优先复用这个编号。


三、核心文件 IO 系统调用详解

基础文件 IO 有五个核心系统调用:openclosereadwritelseek,是所有文件操作的基础。

1. open:打开或创建文件

函数原型

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

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

参数说明

  • pathname:要打开的文件路径,可以是相对路径或绝对路径
  • flags:文件打开方式,由必选参数 + 可选参数按位或组合
    • 必选三选一:
      • O_RDONLY:只读模式打开
      • O_WRONLY:只写模式打开
      • O_RDWR:读写模式打开
    • 常用可选参数:
      • O_CREAT:文件不存在则创建,此时必须指定第三个参数mode
      • O_TRUNC:打开文件时清空文件原有内容
      • O_APPEND:追加模式,每次写入自动定位到文件末尾
      • O_NONBLOCK:非阻塞模式,用于设备文件、管道等
  • mode:新建文件的权限,一般写八进制数,如0644,代表所有者读写、组和其他只读

返回值

  • 成功:返回新分配的文件描述符
  • 失败:返回-1,同时设置全局变量errno标记具体错误原因

2. close:关闭文件

函数原型

#include <unistd.h>

int close(int fd);
  • 作用:关闭指定的文件描述符,释放对应的内核资源,文件引用计数减一
  • 返回值:成功返回 0,失败返回 - 1

工程注意:进程退出时内核会自动关闭所有打开的文件,但长期运行的程序必须手动 close,否则会出现文件描述符泄漏,耗尽进程的 fd 上限,最终无法打开任何文件。

3. read:读取文件内容

函数原型

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
  • 功能:从文件描述符fd中读取最多count字节的数据,存入buf指向的内存
  • 返回值有三种情况(面试高频考点):
    1. 返回值 > 0:实际读取到的字节数
    2. 返回值 = 0:已经读到文件末尾(EOF),没有更多数据
    3. 返回值 = -1:读取失败,具体错误通过errno判断

核心注意:read 不保证一次读满 count 字节。当文件剩余内容不足、遇到信号中断、管道数据未就绪时,都会出现 “部分读取”,生产环境必须循环处理,不能认为一次调用就能读完所有数据。

4. write:写入文件内容

函数原型

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);
  • 功能:将bufcount字节的数据写入文件描述符fd
  • 返回值:成功返回实际写入的字节数;失败返回 - 1

工程注意:普通磁盘文件通常能一次写满,但管道、套接字、终端等场景很容易出现部分写入。必须循环补写剩余内容,否则会造成数据丢失。

5. lseek:移动文件读写指针

函数原型

#include <sys/types.h>
#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);
  • 功能:调整文件读写指针的位置,所有读写操作都从指针位置开始
  • whence参数指定偏移基准:
    • SEEK_SET:从文件开头偏移offset字节
    • SEEK_CUR:从当前位置偏移offset字节,offset 可正可负
    • SEEK_END:从文件末尾偏移offset字节
  • 返回值:成功返回移动后指针距离文件开头的字节数;失败返回 - 1

常见用途

  1. 计算文件大小:lseek(fd, 0, SEEK_END) 返回值就是文件大小
  2. 指定位置读写:随机访问文件内容
  3. 生成空洞文件:指针移到文件末尾之后再写入,中间区域形成文件空洞

四、实战:系统调用实现文件拷贝

结合以上五个系统调用,实现一个完整的文件拷贝程序,包含完整的错误处理与部分读写兼容。

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>

#define BUF_SIZE 4096  // 缓冲区大小,一般设为页大小整数倍

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "用法:%s 源文件 目标文件\n", argv[0]);
        return 1;
    }

    // 1. 打开源文件(只读)
    int fd_src = open(argv[1], O_RDONLY);
    if (fd_src == -1) {
        fprintf(stderr, "打开源文件失败:%s\n", strerror(errno));
        return 1;
    }

    // 2. 打开目标文件(只写+创建+清空)
    int fd_dst = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd_dst == -1) {
        fprintf(stderr, "打开目标文件失败:%s\n", strerror(errno));
        close(fd_src);
        return 1;
    }

    // 3. 循环读写拷贝
    char buf[BUF_SIZE];
    ssize_t n_read;
    while ((n_read = read(fd_src, buf, BUF_SIZE)) > 0) {
        ssize_t written = 0;
        // 处理部分写入,保证所有数据都写入目标文件
        while (written < n_read) {
            ssize_t ret = write(fd_dst, buf + written, n_read - written);
            if (ret == -1) {
                if (errno == EINTR) continue; // 被信号中断,重试
                fprintf(stderr, "写入失败:%s\n", strerror(errno));
                close(fd_src);
                close(fd_dst);
                return 1;
            }
            written += ret;
        }
    }

    if (n_read == -1) {
        fprintf(stderr, "读取失败:%s\n", strerror(errno));
        close(fd_src);
        close(fd_dst);
        return 1;
    }

    // 4. 关闭文件
    close(fd_src);
    close(fd_dst);
    printf("文件拷贝完成\n");
    return 0;
}

五、系统 IO vs 标准库 IO 核心对比

很多人会混淆两者,这里从底层维度做清晰区分:

对比维度 系统 IO(open/read) 标准库 IO(fopen/fread)
层级 操作系统系统调用,内核级 C 标准库封装,用户态
缓冲区 无用户态缓冲区,内核有页缓存 自带用户态缓冲区,分全缓冲 / 行缓冲
调用开销 每次调用都触发上下文切换,单次开销大 批量读写减少系统调用次数,小块读写开销低
可移植性 POSIX 标准,类 Unix 通用,Windows 不兼容 C 标准,全平台通用
控制能力 更底层,支持非阻塞、文件锁、重定向等 封装度高,底层控制弱
适用场景 大块数据传输、底层控制、实时场景 普通文本读写、跨平台开发

简单总结:标准库 IO 的底层,本质就是调用系统 IO 实现的,只是在用户态加了一层缓冲区来优化性能。


六、错误处理与 errno

所有系统调用失败时,都会返回 - 1,同时设置全局变量errno来标记具体的错误类型。

  • errno是一个整型变量,不同数值对应不同的错误原因
  • 可以用strerror(errno)将错误码转为人类可读的错误描述字符串
  • 也可以用perror("前缀")直接打印带前缀的错误信息

常见的错误码:

  • EINTR:调用被信号中断
  • EAGAIN:非阻塞模式下暂无数据,重试即可
  • ENOENT:文件不存在
  • EACCES:权限不足

注意:只有函数调用失败时,errno 的值才有意义;函数调用成功不会修改 errno 的值,不能通过 errno 是否为 0 来判断是否出错。


七、面试高频考点与易错坑点

1. 经典面试问答

Q1:什么是系统调用?用户态和内核态有什么区别?

答:

  1. 系统调用是操作系统内核提供给应用程序的接口,是用户态进入内核态的唯一合法入口。
  2. 用户态是应用程序运行的层级,权限受限,不能直接操作硬件;内核态是操作系统运行的层级,拥有最高权限,可以访问所有系统资源。 执行系统调用时,CPU 会从用户态切换到内核态,执行完内核逻辑后再切回用户态,这个过程叫上下文切换,有一定性能开销。

Q2:read 函数的返回值有哪几种情况?分别代表什么?

答: 一共有三种情况:

  1. 返回值大于 0:成功读取到的字节数
  2. 返回值等于 0:已经读到文件末尾,没有更多数据
  3. 返回值等于 - 1:读取失败,具体错误通过 errno 判断,常见的比如 EINTR 是被信号中断,EAGAIN 是非阻塞下暂无数据

Q3:文件描述符的本质是什么?分配规则是什么?

答:

  1. 文件描述符是一个非负整数,本质是进程文件描述符表的索引,内核通过它找到对应的文件实体。
  2. 分配遵循最小可用原则:每次打开新文件,分配当前编号最小的未使用的非负整数;关闭后编号会被回收复用。

Q4:系统 IO 和标准库 IO 有什么区别?什么时候选系统 IO?

答:

  1. 系统 IO 是操作系统原生系统调用,运行在内核态,没有用户态缓冲,控制粒度更细;标准库 IO 是 C 标准库在用户态的封装,自带缓冲区,减少系统调用次数,小块读写性能更好。
  2. 适合用系统 IO 的场景:大块数据批量传输、需要非阻塞 / 文件锁等底层控制、内存资源受限的嵌入式场景、需要精确控制刷盘时机。

Q5:什么是文件空洞?它有什么特点?

答:

  1. 文件空洞是文件中逻辑存在但不占用实际磁盘空间的区域,内容全为 0。 通过 lseek 将文件指针移到文件末尾之后的位置,再执行 write 写入,中间跳过的部分就形成了文件空洞。
  2. 特点:文件的逻辑大小大于实际占用的磁盘大小,读取空洞区域会返回 0,常用于虚拟机镜像、大文件预分配等场景。

2. 常见易错坑点

  1. 认为 read 每次都会读满指定字节数,不处理部分读取,导致数据读取不完整
  2. write 不处理部分写入,管道 / 网络场景下一次写不完就结束,造成数据丢失
  3. 打开文件失败后不做错误处理,继续使用无效 fd 操作,引发崩溃
  4. 只打开不关闭,长期运行导致文件描述符泄漏,最终无法打开任何文件
  5. 用 errno 是否为 0 判断函数是否成功,忽略成功调用不会修改 errno 的规则
  6. 忽略 EINTR 错误,被信号中断就判定为失败,没有重试逻辑
  7. lseek 之后直接读写,不检查返回值,指针移动失败导致数据读写位置错误

以上就是 Linux 系统编程的开篇内容,基础文件 IO 是所有系统编程的基石,后续所有进程、管道、网络编程的操作,本质都是基于文件描述符的读写操作。


制作不易,如果对你有用,希望能点赞收藏支持一下。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐