Linux 系统编程 01:系统调用本质与基础文件 IO
前言:
从本篇开始正式进入 Linux 系统编程系列,从零开始系统化覆盖文件操作、进程管理、线程并发、进程间通信、网络编程等核心内容,全部贴合嵌入式 Linux、后端服务开发的岗位实际需求,从底层原理到代码实战逐层拆解,完成从 C 语言语法到 Linux 原生开发的能力跃迁。
一、系统编程与系统调用基础
1. 什么是系统编程
简单来说,系统编程就是直接调用操作系统内核提供的接口进行开发,利用操作系统的底层能力完成文件管理、内存调度、进程并发、网络通信等功能。
我们日常使用的 C 标准库函数(fopen/malloc/printf等),底层最终都依赖操作系统提供的系统调用实现。系统编程就是跳过封装层,直接和操作系统内核交互,拥有更细的控制粒度、更高的执行效率,同时也更贴近硬件与系统底层。
2. 系统调用的本质:用户态与内核态
操作系统为了保护系统安全,将运行环境分为两个层级:
- 用户态:应用程序运行的层级,只能访问受限的内存,不能直接操作硬件,执行效率相对低。
- 内核态:操作系统内核运行的层级,可以访问所有系统资源、直接操作硬件,拥有最高权限。
系统调用就是用户态程序进入内核态的唯一合法入口。当我们调用open/read这类系统调用时,CPU 会从用户态切换到内核态,执行内核中的对应逻辑,完成后再切换回用户态返回结果。
3. 系统调用的核心特点
- 由操作系统内核提供,运行在内核态,权限更高
- 调用会触发上下文切换,有一定的性能开销
- 属于 POSIX 标准,所有类 Unix 系统(Linux、Unix、MacOS)通用
- 功能更底层,控制粒度更细,支持很多标准库没有的高级特性
二、文件描述符: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 有五个核心系统调用:open、close、read、write、lseek,是所有文件操作的基础。
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:文件不存在则创建,此时必须指定第三个参数modeO_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指向的内存 - 返回值有三种情况(面试高频考点):
- 返回值 > 0:实际读取到的字节数
- 返回值 = 0:已经读到文件末尾(EOF),没有更多数据
- 返回值 = -1:读取失败,具体错误通过
errno判断
核心注意:read 不保证一次读满 count 字节。当文件剩余内容不足、遇到信号中断、管道数据未就绪时,都会出现 “部分读取”,生产环境必须循环处理,不能认为一次调用就能读完所有数据。
4. write:写入文件内容
函数原型
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
- 功能:将
buf中count字节的数据写入文件描述符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
常见用途:
- 计算文件大小:
lseek(fd, 0, SEEK_END)返回值就是文件大小 - 指定位置读写:随机访问文件内容
- 生成空洞文件:指针移到文件末尾之后再写入,中间区域形成文件空洞
四、实战:系统调用实现文件拷贝
结合以上五个系统调用,实现一个完整的文件拷贝程序,包含完整的错误处理与部分读写兼容。
#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:什么是系统调用?用户态和内核态有什么区别?
答:
- 系统调用是操作系统内核提供给应用程序的接口,是用户态进入内核态的唯一合法入口。
- 用户态是应用程序运行的层级,权限受限,不能直接操作硬件;内核态是操作系统运行的层级,拥有最高权限,可以访问所有系统资源。 执行系统调用时,CPU 会从用户态切换到内核态,执行完内核逻辑后再切回用户态,这个过程叫上下文切换,有一定性能开销。
Q2:read 函数的返回值有哪几种情况?分别代表什么?
答: 一共有三种情况:
- 返回值大于 0:成功读取到的字节数
- 返回值等于 0:已经读到文件末尾,没有更多数据
- 返回值等于 - 1:读取失败,具体错误通过 errno 判断,常见的比如 EINTR 是被信号中断,EAGAIN 是非阻塞下暂无数据
Q3:文件描述符的本质是什么?分配规则是什么?
答:
- 文件描述符是一个非负整数,本质是进程文件描述符表的索引,内核通过它找到对应的文件实体。
- 分配遵循最小可用原则:每次打开新文件,分配当前编号最小的未使用的非负整数;关闭后编号会被回收复用。
Q4:系统 IO 和标准库 IO 有什么区别?什么时候选系统 IO?
答:
- 系统 IO 是操作系统原生系统调用,运行在内核态,没有用户态缓冲,控制粒度更细;标准库 IO 是 C 标准库在用户态的封装,自带缓冲区,减少系统调用次数,小块读写性能更好。
- 适合用系统 IO 的场景:大块数据批量传输、需要非阻塞 / 文件锁等底层控制、内存资源受限的嵌入式场景、需要精确控制刷盘时机。
Q5:什么是文件空洞?它有什么特点?
答:
- 文件空洞是文件中逻辑存在但不占用实际磁盘空间的区域,内容全为 0。 通过 lseek 将文件指针移到文件末尾之后的位置,再执行 write 写入,中间跳过的部分就形成了文件空洞。
- 特点:文件的逻辑大小大于实际占用的磁盘大小,读取空洞区域会返回 0,常用于虚拟机镜像、大文件预分配等场景。
2. 常见易错坑点
- 认为 read 每次都会读满指定字节数,不处理部分读取,导致数据读取不完整
- write 不处理部分写入,管道 / 网络场景下一次写不完就结束,造成数据丢失
- 打开文件失败后不做错误处理,继续使用无效 fd 操作,引发崩溃
- 只打开不关闭,长期运行导致文件描述符泄漏,最终无法打开任何文件
- 用 errno 是否为 0 判断函数是否成功,忽略成功调用不会修改 errno 的规则
- 忽略 EINTR 错误,被信号中断就判定为失败,没有重试逻辑
- lseek 之后直接读写,不检查返回值,指针移动失败导致数据读写位置错误
以上就是 Linux 系统编程的开篇内容,基础文件 IO 是所有系统编程的基石,后续所有进程、管道、网络编程的操作,本质都是基于文件描述符的读写操作。
制作不易,如果对你有用,希望能点赞收藏支持一下。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)