The Linux Programming interface学习:第一章:UNIX 与 Linux 的历史及标准
"UNIX"这个词有两种用法:定义一(官方认证): 通过了 The Open Group 官方一致性测试、拥有 UNIX 商标授权的操作系统。目前 Linux 和 FreeBSD 没有获得这个官方认证(原因是时间和费用成本太高)。定义二(习惯用法): 行为和风格像经典 UNIX 系统的操作系统。按这个定义,Linux 就是 UNIX 系统。本书采用这个定义。2.2 UNIX 名字的由来UNIX 是
本文是《The Linux Programming Interface》第一章的详细中文解读,力求通俗易懂。
1. UNIX 是什么?两种定义
"UNIX"这个词有两种用法:
定义一(官方认证): 通过了 The Open Group 官方一致性测试、拥有 UNIX 商标授权的操作系统。目前 Linux 和 FreeBSD 没有获得这个官方认证(原因是时间和费用成本太高)。
定义二(习惯用法): 行为和风格像经典 UNIX 系统的操作系统。按这个定义,Linux 就是 UNIX 系统。本书采用这个定义。
2. UNIX 和 C 语言的诞生简史
2.1 时间线总览
1969 Ken Thompson 在 Bell 实验室用汇编语言写出第一个 UNIX(运行在 PDP-7 上)
|
1970 UNIX 移植到更强大的 PDP-11(也用汇编语言)
|
1971 第一版 UNIX 发布(已有 ls、cp、sh 等命令)
|
~1973 Dennis Ritchie 设计 C 语言,UNIX 内核用 C 重写
|
1974 第五版 UNIX 开始授权给大学使用
|
1979 第七版 UNIX 发布,UNIX 分裂为 BSD 和 System V 两大分支
2.2 UNIX 名字的由来
UNIX 是对 MULTICS(Multiplexed Information and Computing Service)的双关语戏称。MULTICS 是 AT&T、MIT、通用电气共同参与的操作系统项目,AT&T 因项目进展不顺而退出。Thompson 借鉴了 MULTICS 的几个核心思想:
- 树形文件系统
- 独立的命令解释程序(Shell)
- 文件是无结构的字节流
2.3 C 语言为何诞生于此?
Dennis Ritchie 设计 C 语言,是为了给 UNIX 写内核。C 的"家谱"如下:
BCPL(更早的语言)
└── B(Thompson 实现)
└── C(Ritchie 设计,约 1973 年成熟)
C 语言为什么成功? 因为它填补了空白:
- FORTRAN 是给科学家算数学的
- COBOL 是给商业数据处理的
- C 是程序员为自己写的:小巧、高效、强大、务实
UNIX 用 C 重写后,就可以移植到不同硬件平台上——这是划时代的进步。
2.4 UNIX 早期版本列表
| 版本 | 时间 | 重要事件 |
|---|---|---|
| 第一版 | 1971年11月 | 运行于 PDP-11,已有 ls、cp、sh 等工具 |
| 第二版 | 1972年6月 | AT&T 内部安装到 10 台机器 |
| 第三版 | 1973年2月 | 加入 C 编译器,首次实现管道(pipe) |
| 第四版 | 1973年11月 | 几乎完全用 C 语言写成 |
| 第五版 | 1974年6月 | 已安装到 50 多个系统,开始授权给大学 |
| 第六版 | 1975年5月 | 首次在 AT&T 外广泛使用 |
关键背景: AT&T 受美国政府反垄断协议限制,不能出售软件,只能以象征性费用授权给大学,并附带源代码。这使 UNIX 在大学里迅速普及,1977 年已有 500 个站点在运行 UNIX,其中包括 125 所美国大学。
3. BSD 和 System V 的诞生
3.1 BSD(伯克利软件发行版)
1975-1976 年,Ken Thompson 在加州大学伯克利分校做访问教授,与研究生们一起大幅扩展了 UNIX。其中一位研究生 Bill Joy 后来联合创办了 Sun Microsystems。
伯克利的贡献包括:
- C shell(csh)
- vi 编辑器
- 改进的文件系统(Berkeley Fast File System)
- sendmail 邮件程序
- 完整的 TCP/IP 网络实现(4.2BSD,1983年)—— 这为互联网奠定了基础
BSD 的重要版本:
3BSD(1979年12月)→ 4.1BSD → 4.2BSD(1983,含TCP/IP)→ 4.3BSD(1986)→ 4.4BSD(1993)
3.2 System V(AT&T 官方路线)
美国反垄断诉讼迫使 AT&T 拆分(1982年生效),此后 AT&T 可以出售 UNIX 了:
- 1981年:System III 发布
- 1983年:System V 第一版
- 1989年:System V Release 4(SVR4)——吸收了大量 BSD 特性,成为最终形态
3.3 两大分支的商业后代
| 来源 | 商业系统 |
|---|---|
| BSD | SunOS、FreeBSD、NetBSD、OpenBSD |
| System V | AIX(IBM)、HP-UX(惠普)、Solaris(Sun)、XENIX(微软/SCO) |
4. Linux 的诞生
4.1 GNU 项目:万事俱备,只欠内核
1984年,Richard Stallman(RMS)离开 MIT,启动 GNU 项目(GNU’s Not UNIX,递归缩写)。
Stallman 的核心理念: “自由"不是"免费”,而是法律意义上的自由——用户可以查看、修改、分发源代码。他反对商业软件对源代码的封锁。
1985年成立自由软件基金会(FSF),并创立了:
GNU GPL(通用公共许可证)
- 软件必须以源代码形式提供
- 可以自由重新分发
- 修改后的版本也必须使用 GPL
- 不能额外限制用户的权利
GNU 项目生产的重要软件:
| 软件 | 说明 |
|---|---|
| Emacs | 文本编辑器 |
| GCC | GNU 编译器集合(支持C、C++等) |
| bash | Shell |
| glibc | GNU C 标准库 |
到1990年代初,GNU 几乎完成了一套完整的类 UNIX 系统,唯独缺少一个可用的内核(GNU/HURD 项目进展迟缓)。
4.2 Linux 内核:那颗最后的拼图
1991年,芬兰赫尔辛基大学学生 Linus Torvalds 为自己的 Intel 80386 PC 写了一个操作系统内核。
他受到 Minix(Andrew Tanenbaum 教授为教学设计的小型类 UNIX 内核)的启发,但 Minix 为了可移植性牺牲了对 386 处理器的充分利用。
1991年10月5日,Torvalds 在 Usenet 新闻组 comp.os.minix 发出那封著名的公告(版本 0.02):
“你是否怀念 Minix-1.1 那段岁月……我正在为 AT-386 电脑开发一个自由版的 Minix 风格系统……它已经可以运行 bash、gcc、gnu-make、gnu-sed……”
内核随后以 GNU GPL 发布,大量程序员加入开发。
Linux 版本里程碑:
0.02(1991年10月)→ 1.0(1994年3月)→ 1.2(1995年3月)
→ 2.0(1996年6月)→ 2.2(1999年1月)→ 2.4(2001年1月)
→ 2.6(2003年12月)
4.3 Linux 发行版的出现
Linux 内核本身只是操作系统的核心,用户还需要文件系统、工具、库等。最初用户要自己组装,门槛很高。
于是出现了发行版(Distribution)——将内核和各种软件打包成易于安装的完整系统:
| 发行版 | 首次出现 |
|---|---|
| Slackware | 1993年(最古老的商业发行版) |
| Debian | 1993年 |
| SUSE | 1990年代中期 |
| Red Hat | 1990年代中期 |
| Ubuntu | 2004年 |
5. 标准化:为何需要?如何推进?
5.1 问题的根源
UNIX 分裂为 BSD 和 System V 两大派系后,各商业厂商又在自己的版本上添加私有特性。结果:同一段代码在不同 UNIX 系统上行为不同,移植非常困难。
5.2 C 语言标准化
| 标准 | 时间 | 别名 |
|---|---|---|
| ANSI C | 1989年 | C89、ISO C90 |
| C99 | 1999年 | 增加 long long、布尔类型、C++风格注释等 |
C 标准独立于操作系统,只要系统有 C 实现,程序就能移植。
5.3 POSIX 标准
POSIX(Portable Operating System Interface,可移植操作系统接口)由 IEEE 制定,目标是让程序源代码级别可移植。
名字由 Richard Stallman 建议,发音:“pahz-icks”(像 positive)。
POSIX 关键版本:
| 标准 | 时间 | 内容 |
|---|---|---|
| POSIX.1 | 1988年(IEEE),1990年(ISO) | 基础系统调用 API |
| POSIX.1b | 1993年 | 实时扩展(信号量、共享内存、消息队列等) |
| POSIX.1c | 1995年 | POSIX 线程(pthreads) |
| POSIX.2 | 1992年 | Shell 和命令行工具 |
| POSIX.1g | 2000年 | 网络 API(套接字 socket) |
5.4 Single UNIX Specification(SUS)
X/Open 公司(后与 OSF 合并为 The Open Group)在 POSIX 基础上制定了更严格的标准:
| 版本 | 时间 | 别名 |
|---|---|---|
| SUSv1 | 1994年 | UNIX 95 |
| SUSv2 | 1997年 | UNIX 98 |
| SUSv3 | 2001年 | POSIX.1-2001,UNIX 03 |
| SUSv4 | 2008年 | POSIX.1-2008 |
SUSv3 是里程碑:由 IEEE、The Open Group、ISO 联合制定(奥斯汀小组),合并了之前所有 POSIX 和 SUS 标准,共约 3700 页,规定了:
- 84 个头文件规范
- 1123 个系统接口
- 160 个 Shell 工具
两层一致性:
POSIX 一致性(基础层)
↓
XSI 一致性(扩展层)= POSIX + 额外接口
↓
获得 UNIX 03 品牌认证
XSI 扩展包括:线程、mmap、dlopen、System V IPC、syslog、poll 等。
5.5 标准关系图(简化)
6. SUSv4 的主要变化(2008年)
与 SUSv3 相比变化较小,主要:
- 新增函数:
dirfd()、fdopendir()、fexecve()、futimens()、mkdtemp()等 - 新增
openat()系列: 相对于文件描述符而非当前目录解析路径(更安全) - 变为强制: dlopen API、实时信号、POSIX 信号量、POSIX 定时器
- 标记为废弃:
asctime()、ctime()、gettimeofday()、getitimer() - 从标准中删除:
gethostbyname()、gethostbyaddr()、vfork()
7. Linux 与标准的关系
7.1 为何 Linux 没有 UNIX 官方认证?
Linux 内核开发(Torvalds 主导)与 Linux 发行版分发(RedHat、Canonical 等公司)是分离的。没有哪家发行版去做官方一致性认证,原因是:
- 费用高
- 每次新版本都要重新测试
但 Linux 在实践中高度符合 POSIX/SUS 标准,这是其商业成功的基础。
7.2 Linux 标准基础(LSB)
不同 Linux 发行版之间存在细微差异(类似早期 UNIX 的分裂)。
LSB(Linux Standard Base) 的目标:确保二进制兼容性——在同一硬件平台上,为任意 LSB 兼容系统编译一次的程序,可以在所有 LSB 兼容系统上运行。
| 对比维度 | POSIX 目标 | LSB 目标 |
|---|---|---|
| 级别 | 源代码可移植 | 二进制可移植 |
| 做法 | 定义 API | 定义 ABI |
| 跨硬件 | 可以 | 不行(同硬件平台) |
二进制兼容是商业独立软件供应商(ISV)在 Linux 上发布产品的基本前提。
8. BSD 旁支:386/BSD 与现代 BSD
1992年,Bill 和 Lynne Jolitz 将 BSD 移植到 x86-32,称为 386/BSD。之后分裂出:
USL vs. Berkeley 官司(1992-1994):
AT&T 子公司 USL 起诉 BSDi,声称 BSD 代码仍含有 AT&T 专有代码。最终1994年1月和解:
- 加州大学移除 18000 个文件中的 3 个
- 对约 70 个文件添加 USL 版权声明
- 修改结果作为 4.4BSD-Lite 于1994年6月发布
9. Linux 内核版本号规则
9.1 早期规则(2.6 之前)
版本号格式:x.y.z
- x x x:主版本
- y y y:次版本(偶数 = 稳定版,奇数 = 开发版)
- z z z:修订号
稳定版:2.0, 2.2, 2.4, 2.6
开发版:2.1, 2.3, 2.5
开发版功能稳定后 → 变为下一个稳定版。例如 2.5 开发完成 → 发布 2.6。
9.2 2.6 之后的新规则
2.4 到 2.6 之间间隔近三年,社区不满,改变模型:
- 不再区分稳定版和开发版
- 每个 2.6.z 都可以包含新功能
- 发布周期约三个月
- 若有紧急 bug/安全修复 → 发布
2.6.z.r(r 为小修订号)
10. 章节总结
用一张图串联全章核心脉络:
附:核心概念速查
| 概念 | 解释 |
|---|---|
| UNIX | 1969年诞生于 Bell 实验室的操作系统 |
| C 语言 | 为写 UNIX 而生,1973年成熟 |
| BSD | 伯克利分支,含 TCP/IP |
| System V | AT&T 官方商业路线 |
| GNU | Stallman 的自由软件项目,提供除内核外的所有工具 |
| GPL | GNU 通用公共许可证,保证软件永远自由 |
| Linux | Torvalds 1991年写的内核,结合 GNU 工具形成完整系统 |
| POSIX | IEEE 制定的源代码可移植标准 |
| SUS | The Open Group 制定的更严格标准,UNIX 品牌认证依据 |
| LSB | Linux 发行版间的二进制兼容标准 |
| XSI | SUS 的扩展层,满足此层才能称为 UNIX 03 |
第二章:Linux 系统编程基础概念详解
本文是《The Linux Programming Interface》第二章的详细中文解读,适合有其他操作系统经验、初接触 Linux/UNIX 的读者。
2.1 操作系统的核心:内核(Kernel)
什么是内核?
"操作系统"这个词有两种用法:
- 广义:整个软件包,包括内核 + 命令行工具 + 图形界面 + 文件工具 + 编辑器等
- 狭义:专指那个负责管理和分配计算机资源(CPU、内存、设备)的核心软件
"内核"就是狭义的操作系统,也是本书关注的重点。
Linux 内核的可执行文件通常位于/boot/vmlinuz。名字来历: - 早期 UNIX 内核叫
unix - 支持虚拟内存后改名
vmunix - Linux 对应改成
vmlinuz(z 表示压缩的可执行文件)
内核做哪些事?
重点理解——虚拟内存管理的两大好处:
- 进程隔离:进程 A 读不到进程 B 的内存,也读不到内核的内存,保证安全
- 按需加载:进程不需要把全部代码加载进内存,只加载当前用到的部分,节省 RAM,让更多进程可以同时运行
内核模式 vs 用户模式
CPU 硬件支持两种工作模式:
+---------------------------+
| 用户模式 | <- 普通程序运行在这里
| 只能访问用户空间内存 |
| 不能直接操作硬件 |
+---------------------------+
| 内核模式 | <- 内核运行在这里
| 可以访问所有内存 |
| 可以执行特权指令(halt等) |
+---------------------------+
这样设计的目的:防止用户程序破坏内核数据或乱操作硬件。
进程视角 vs 内核视角
进程眼中的世界(不确定性、透明性):
- 不知道自己什么时候会被"暂停"让给别人用 CPU
- 不知道自己在内存的哪个位置
- 不知道文件存在磁盘的哪个扇区
- 不能直接和其他进程通信
- 不能自己创建新进程,也不能直接操作硬件
内核眼中的世界(全知全能): - 知道所有进程的状态,维护着每个进程的数据结构
- 知道每个文件在磁盘的物理位置
- 知道每个进程的虚拟内存到物理内存的映射
- 所有进程间通信都经过内核中转
- 负责创建、终止进程,负责所有设备 I/O
记住:后文说"进程创建了另一个进程",本质是"进程请求内核帮它创建另一个进程",内核才是真正的执行者。
2.2 Shell(命令解释器)
Shell 是一个特殊程序,读取用户输入的命令,执行对应的程序。
- 在 UNIX 中,Shell 是一个普通用户进程,不是内核的一部分(这与某些操作系统不同)
- 用户登录时创建的 Shell 叫做 登录 Shell(login shell)
主要 Shell 对比
| Shell | 全名 | 作者 | 特点 |
|---|---|---|---|
| sh | Bourne shell | Steve Bourne | 最古老,第七版UNIX标准Shell,所有UNIX都有 |
| csh | C shell | Bill Joy(BSD) | 语法像C语言,有命令历史、别名、作业控制,但与sh不兼容 |
| ksh | Korn shell | David Korn(AT&T) | sh的继承者,兼容sh,又有csh的交互特性 |
| bash | Bourne Again Shell | Brian Fox & Chet Ramey | GNU对sh的重实现,Linux最常用,兼容sh |
- Shell 不只是交互用,还可以写Shell脚本:包含变量、循环、条件、函数的文本文件
- Linux 上
sh实际上就是bash在模拟sh模式运行 - POSIX.2 基于 Korn shell 制定了标准 Shell 规范
2.3 用户和组
用户
每个用户有:
- 唯一的用户名(username)
- 唯一的数字用户ID(UID)
用户信息存放在/etc/passwd,每行格式如下:
用户名:密码占位:UID:GID:描述:主目录:登录Shell
mtk:x:1000:1000:Michael Kerrisk:/home/mtk:/bin/bash
出于安全,实际密码(加密后)存在 /etc/shadow,只有特权用户可读。
组
用户可以属于多个组(早期UNIX只能属于一个,BSD引入多组概念,后被POSIX采纳)。
组信息存放在 /etc/group:
组名:密码占位:GID:成员列表
developers:x:1001:mtk,alice,bob
超级用户(root)
- UID = 0,登录名通常是
root - 绕过所有权限检查:可以读写任何文件、向任何进程发信号
- 系统管理员用此账号执行管理任务
2.4 目录层次、链接与文件
单一目录树
Linux(和所有UNIX)维护一棵单一的目录树,根节点是 /(根目录)。
与 Windows 不同——Windows 每个磁盘有自己的目录树(C:, D:…),Linux 只有一棵树。
/
├── bin/
│ └── bash
├── boot/
│ └── vmlinuz
├── etc/
│ ├── group
│ └── passwd
├── home/
│ └── mtk/
│ └── .bashrc
└── usr/
└── include/
└── sys/
├── stdio.h
└── types.h
文件类型
| 类型 | 说明 |
|---|---|
| 普通文件(regular file) | 存储数据的文件,最常见 |
| 目录(directory) | 特殊文件,内容是文件名到文件引用的映射表 |
| 设备文件(device) | 代表硬件设备 |
| 管道(pipe) | 进程间通信用 |
| 套接字(socket) | 网络通信用 |
| 符号链接(symbolic link) | 指向另一个文件的"快捷方式" |
硬链接(Hard Link)vs 符号链接(Symbolic Link)
硬链接:目录中一个"文件名 + 指针"条目,多个硬链接指向同一份数据。
符号链接(软链接):一个特殊文件,内容是另一个文件的路径名(类似 Windows 快捷方式)。
硬链接:
目录条目A ──┐
├──> [实际数据]
目录条目B ──┘
符号链接:
目录条目 ──> [符号链接文件,内容="/path/to/target"] ──> [实际数据]
- 若符号链接指向的文件不存在,该链接称为悬空链接(dangling link)
- 内核在解析路径时会自动跟随符号链接(递归,但有次数上限防止循环)
特殊目录条目
每个目录都有两个特殊条目:
.(点):指向目录自身..(点点):指向父目录(根目录的..指向自身)
文件名规则
- 最长 255 字符
- 不能含
/和\0(空字符) - 推荐只用:
[-._a-zA-Z0-9](SUSv3 定义的可移植文件名字符集,共65个字符) - 避免以
-开头(可能被 Shell 误认为选项参数)
路径名
绝对路径:以 / 开头,从根目录出发定位文件
/home/mtk/.bashrc
/usr/include/sys/types.h
相对路径:相对于进程当前工作目录,不以 / 开头
# 从 /usr 目录出发
include/sys/types.h
# 从 /home/mtk/avr 目录出发,访问 /home/mtk/.bashrc
../mtk/.bashrc
当前工作目录(CWD)
每个进程都有一个当前工作目录,是解析相对路径的基准。
- 进程从父进程继承 CWD
- 登录 Shell 的 CWD 初始为用户主目录(
/etc/passwd中定义) - Shell 中用
cd命令改变 CWD
文件权限
每个文件有所有者(user)、所属组(group)、其他人(other) 三类访问者,每类有三种权限:
| 权限 | 对普通文件 | 对目录 |
|---|---|---|
| r(读) | 读取文件内容 | 列出目录中的文件名 |
| w(写) | 修改文件内容 | 在目录中添加/删除/重命名文件 |
| x(执行) | 执行该文件 | 进入目录、访问其中文件 |
共 3 × 3 = 9 3 \times 3 = 9 3×3=9 个权限位,通常用八进制表示,如 755:
7 ⏟ o w n e r = r w x 5 ⏟ g r o u p = r − x 5 ⏟ o t h e r = r − x \underbrace{7}_{owner=rwx} \quad \underbrace{5}_{group=r-x} \quad \underbrace{5}_{other=r-x} owner=rwx
7group=r−x
5other=r−x
5
2.5 文件 I/O 模型
I/O 的统一性(Universality of I/O)
UNIX 最重要的设计哲学之一:对所有文件类型使用相同的系统调用。
open() → 打开文件/设备
read() → 读取数据
write() → 写入数据
close() → 关闭
无论操作的是磁盘文件、键盘、网络套接字,还是管道,都用这套接口。内核内部负责将请求转给对应的文件系统或设备驱动。
文件描述符(File Descriptor)
文件描述符是一个小的非负整数,代表一个打开的文件。
进程启动时自动继承三个标准文件描述符:
| 描述符编号 | 名称 | 对应 stdio 流 | 默认连接 |
|---|---|---|---|
| 0 | 标准输入(stdin) | stdin | 键盘 |
| 1 | 标准输出(stdout) | stdout | 终端屏幕 |
| 2 | 标准错误(stderr) | stderr | 终端屏幕 |
stdio 库
C 程序通常不直接调用系统调用,而是用 stdio 库函数(fopen、printf、scanf 等),它们是对系统调用的封装:
应用程序
|
| 调用 fopen/printf/fread...
v
stdio 库(glibc)
|
| 内部调用 open/write/read...
v
内核系统调用
UNIX/Linux 的文件没有"文件结束符"字符——read() 返回 0 字节才表示到达文件末尾。
2.6 程序
程序有两种存在形式:
- 源代码:人类可读的文本(如 C 语言)
- 二进制机器码:CPU 可执行的指令
源代码经过编译、链接转为二进制。脚本文件(Shell脚本、Python脚本)是例外——它们直接由解释器读取执行。
过滤器(Filter):从 stdin 读数据、处理后写到 stdout 的程序,如cat、grep、sort、awk。
命令行参数:C 程序的main函数声明为:
// 完整示例:打印所有命令行参数
#include <iostream> // 标准输入输出流
// argc: 参数个数(包含程序名本身)
// argv: 参数字符串数组
// argv[0] = 程序名, argv[1] = 第一个参数, ...
int main(int argc, char* argv[]) {
// 打印参数总数
std::cout << "共有 " << argc << " 个参数" << std::endl;
// 遍历所有参数(从0开始,0是程序名)
for (int i = 0; i < argc; i++) {
std::cout << "argv[" << i << "] = " << argv[i] << std::endl;
}
return 0; // 返回0表示成功
}
https://godbolt.org/z/cYjYeGbnr
2.7 进程(Process)
什么是进程?
进程 = 正在执行中的程序的一个实例。
同一个程序可以同时运行多个实例(如你打开了三个终端窗口,就有三个 bash 进程)。
进程的内存布局
每个进程的虚拟内存空间分为四个段:
高地址
+------------------+
| 栈 Stack | <- 函数调用、局部变量,动态增长(向下)
+------------------+
| ↓ |
| |
| ↑ |
+------------------+
| 堆 Heap | <- 动态分配的内存(malloc/new),向上增长
+------------------+
| 数据段 Data | <- 全局变量、静态变量
+------------------+
| 代码段 Text | <- 程序指令(只读)
+------------------+
低地址
进程的创建与执行
创建进程:fork()
#include <iostream>
#include <unistd.h> // fork(), getpid(), getppid()
#include <sys/wait.h> // wait()
int main() {
std::cout << "父进程 PID=" << getpid() << std::endl;
pid_t pid = fork(); // 创建子进程
if (pid < 0) {
// fork 失败
std::cerr << "fork 失败" << std::endl;
return 1;
} else if (pid == 0) {
// 子进程执行这里(fork返回0)
std::cout << "子进程 PID=" << getpid()
<< " 父PID=" << getppid() << std::endl;
} else {
// 父进程执行这里(fork返回子进程PID)
std::cout << "父进程创建了子进程,子PID=" << pid << std::endl;
wait(nullptr); // 等待子进程结束,回收资源
}
return 0;
}
https://godbolt.org/z/fna6ro1befork() 之后,子进程拥有父进程数据段、堆、栈的副本,代码段则共享(只读)。
执行新程序:execve()execve() 用新程序替换当前进程的内存空间(代码、数据、堆、栈全部替换):
进程 A 调用 fork()
└── 子进程 B(父的副本)
└── 子进程 B 调用 execve("ls", ...)
└── 子进程 B 变成 ls 程序运行
进程 ID(PID)和父进程 ID(PPID)
- 每个进程有唯一的整数 PID
- 每个进程记录创建它的父进程的 PPID
- 由此形成一棵进程树
进程终止与退出状态
进程可通过两种方式终止:
- 主动:调用
_exit()或exit(),指定退出状态码 - 被动:收到信号被杀死,终止状态由信号类型决定
按惯例:
- 退出状态 = 0 → 成功
- 退出状态 ≠ 0 → 出错
Shell 变量$?保存最近一条命令的退出状态。
父进程用wait()系统调用获取子进程的终止状态。
进程的用户/组 ID(身份凭证)
每个进程关联多种 ID:
| ID 类型 | 说明 |
|---|---|
| 实际用户ID(real UID) | 进程属于哪个用户,从父进程继承 |
| 有效用户ID(effective UID) | 决定进程访问资源时的权限,通常与实际UID相同 |
| 实际组ID(real GID) | 进程属于哪个组 |
| 有效组ID(effective GID) | 决定组权限 |
| 补充组ID(supplementary GIDs) | 进程额外所属的组 |
Set-User-ID(SUID)机制:允许程序在执行时将有效UID设为程序文件所有者的UID,从而临时获得特殊权限(如 passwd 命令需要修改 /etc/shadow,但普通用户没权限,SUID 让它临时以 root 权限运行)。
特权进程与 Capabilities
传统定义:有效 UID = 0(root)的进程是特权进程,绕过所有权限检查。
Linux 2.2+ 的改进——Capabilities(能力):
将超级用户权限拆分成一组独立的"能力"单元,例如:
CAP_KILL:向任意进程发信号CAP_NET_ADMIN:网络管理操作CAP_SYS_REBOOT:重启系统
这样可以只授予程序它需要的那部分特权,而不是全部 root 权限,更安全。
init 进程
系统启动时,内核创建第一个进程:init(PID 永远是 1,程序路径 /sbin/init)。
- 所有其他进程都是 init 的子孙(通过
fork()链) - init 以超级用户权限运行
- init 不能被杀死(即使是 root)
- 只有系统关机时才终止
守护进程(Daemon)
守护进程的特点:
- 长期运行:通常随系统启动,随系统关闭
- 后台运行:没有控制终端,不与用户直接交互
常见守护进程举例:
| 守护进程 | 功能 |
|---|---|
| syslogd | 记录系统日志 |
| httpd | 提供 HTTP Web 服务 |
| sshd | 提供 SSH 远程登录服务 |
| crond | 定时任务调度 |
环境变量
每个进程有一个环境列表:名称=值的键值对集合,存储在进程用户空间内存中。
- 子进程从父进程继承环境变量的副本
exec()后新程序可以继承旧环境,也可以指定新环境
Shell 中设置环境变量:
export MYVAR='Hello world'
C/C++ 中访问环境变量:
#include <iostream>
#include <cstdlib> // getenv()
int main() {
// getenv 获取环境变量值,不存在则返回 nullptr
const char* home = getenv("HOME");
const char* path = getenv("PATH");
if (home) {
std::cout << "主目录: " << home << std::endl;
}
if (path) {
std::cout << "PATH: " << path << std::endl;
}
return 0;
}
https://godbolt.org/z/bhb94dfco
常用环境变量:
| 变量名 | 含义 |
|---|---|
| HOME | 用户主目录路径 |
| PATH | Shell 搜索可执行文件的目录列表 |
| USER | 当前用户名 |
| SHELL | 当前 Shell 路径 |
| LANG | 语言/区域设置 |
资源限制
进程可以通过 setrlimit() 设置资源使用上限,每种资源有两个值:
- 软限制(soft limit):当前实际限制值,进程可在 [ 0 , 硬限制 ] [0, \text{硬限制}] [0,硬限制] 范围内调整
- 硬限制(hard limit):软限制的上界,非特权进程只能降低硬限制,不能提高
Shell 中用ulimit命令查看/设置,子进程继承父进程的限制。
2.8 内存映射(mmap)
mmap() 系统调用在进程虚拟地址空间创建一段新的映射区域,分两类:
| 类型 | 说明 |
|---|---|
| 文件映射(file mapping) | 将文件的一部分映射到内存,访问内存就是访问文件内容,按需加载 |
| 匿名映射(anonymous mapping) | 没有对应文件,页面初始化为0,用于动态内存分配 |
共享映射 vs 私有映射:
- 共享映射:多个进程映射同一区域,一个进程的修改对其他进程可见,并写回文件
- 私有映射:修改只对自己可见,不影响其他进程,也不写回文件
内存映射的用途: - 加载程序的代码段(text segment)
- 分配新的零初始化内存
- 内存映射 I/O(高性能文件读写)
- 进程间通信(通过共享映射)
2.9 静态库与共享库
静态库(Static Library / Archive)
链接时把库中用到的目标模块直接复制进可执行文件。
编译链接阶段:
程序.o + libfoo.a → 可执行文件(含libfoo的代码副本)
缺点:
- 每个程序都有自己的库副本 → 浪费磁盘空间
- 多个程序同时运行 → 内存中有多份相同代码 → 浪费内存
- 库更新后 → 所有程序必须重新链接
共享库(Shared Library)
链接时只在可执行文件中记录"需要这个库",运行时由动态链接器加载。
编译链接阶段:
程序.o + libfoo.so → 可执行文件(只记录依赖)
运行阶段:
动态链接器 → 找到 libfoo.so → 加载到内存 → 解析函数调用
优点:
- 内存中只需一份库代码,所有进程共用
- 节省磁盘空间
- 库更新后,所有程序下次运行自动使用新版本,无需重新链接
2.10 进程间通信(IPC)与同步
Linux 提供丰富的 IPC 机制:
为何有这么多机制? 历史原因:
- 管道、FIFO → 来自 System V
- 套接字(Socket) → 来自 BSD
两者功能有重叠(都能让同机器进程传数据),但因历史和标准原因,现代 UNIX/Linux 两种都保留了。
2.11 信号(Signal)
信号是"软件中断":通知进程某个事件或异常条件发生了。
谁发送信号?
| 发送方 | 场景举例 |
|---|---|
| 内核 | 进程访问非法内存地址(SIGSEGV)、子进程终止(SIGCHLD) |
| 其他进程 | kill 命令、程序调用 kill() 系统调用 |
| 进程自身 | 调用 raise() 向自己发信号 |
| 用户键盘 | Ctrl+C → SIGINT,Ctrl+Z → SIGTSTP |
进程收到信号的处理方式
- 默认动作:每种信号都有默认行为(如 SIGTERM 默认杀死进程)
- 忽略信号:
signal(SIGXXX, SIG_IGN)或sigaction设置忽略 - 自定义信号处理函数(signal handler):程序员定义函数,信号到来时自动调用
#include <iostream>
#include <csignal>
#include <unistd.h>
#include <sys/wait.h>
void handleSigint(int signum) {
std::cout << "\n收到信号 " << signum
<< " (SIGINT),优雅退出" << std::endl;
exit(0);
}
int main() {
pid_t self = getpid(); // 记录父进程 PID,fork 后子进程用
pid_t pid = fork();
if (pid == 0) {
// 子进程:等 3 秒后向父进程发 SIGINT
sleep(3);
std::cout << "\n[子进程] 发送 SIGINT 给父进程 (pid=" << self << ")" << std::endl;
kill(self, SIGINT); // kill() 并不是"杀死",而是"发送信号"
exit(0);
}
// 父进程:注册信号处理函数,进入主循环
signal(SIGINT, handleSigint);
std::cout << "运行中,3秒后子进程自动发送 SIGINT..." << std::endl;
while (true) {
sleep(1);
std::cout << "." << std::flush;
}
waitpid(pid, nullptr, 0);
return 0;
}
信号的挂起(Pending)与阻塞(Blocking)
- 信号产生到送达之间,若进程尚未处理,信号处于**挂起(pending)**状态
- 进程可以将信号加入信号掩码(signal mask),阻塞它
- 被阻塞的信号保持挂起,直到解除阻塞后才送达
2.12 线程(Thread)
每个进程可以有多个线程。线程是进程内的执行单元:
同一进程的线程共享:
- 虚拟地址空间(代码、数据、堆)
- 文件描述符
- 信号处理设置
每个线程独有: - 自己的栈(局部变量、函数调用信息)
- 线程 ID
进程
├── 共享:代码段、数据段、堆、文件描述符
├── 线程1:独立的栈
├── 线程2:独立的栈
└── 线程3:独立的栈
线程间通信通过共享全局变量,用**互斥锁(mutex)和条件变量(condition variable)**同步。
多线程的优点:
- 共享数据方便(全局变量)
- 某些算法用多线程更自然
- 在多核处理器上可真正并行执行
2.13 进程组与 Shell 作业控制
Shell 执行一条命令(或管道)时,把相关进程放入同一个进程组(process group),也叫一个作业(job)。
# Shell 为这条管道创建三个进程,放入同一个进程组
ls -l | sort -k5n | less
进程组 ID = 组长进程(第一个进程)的 PID。
内核可以向整个进程组发信号,例如:Ctrl+C 向前台进程组的所有成员发 SIGINT。
2.14 会话、控制终端与控制进程
会话(Session) 是一组进程组的集合,所有进程有相同的会话ID。
会话(Session)
├── 前台进程组(只有一个) ← 可以读写终端
│ ├── 进程A
│ └── 进程B
├── 后台进程组1
│ └── 进程C
└── 后台进程组2
└── 进程D
- 会话有一个控制终端(controlling terminal)
- 控制进程(controlling process) = 会话首领,终端断开时收到 SIGHUP
- 同一时刻只有一个前台进程组可以读写终端
&结尾的命令放入后台进程组
2.15 伪终端(Pseudoterminal)
伪终端是一对连接的虚拟设备(主设备 master + 从设备 slave):
ssh客户端
|
v
[主设备 master] <---数据---> [从设备 slave]
|
v
终端程序(如bash)
从设备对终端程序来说"看起来就是一个真实终端"。主设备连接驱动程序(如 SSH 服务端、终端模拟器)。
应用场景:
- X Window 系统下的终端窗口(xterm、gnome-terminal)
- SSH、telnet 等网络登录服务
2.16 日期与时间
进程关心两种时间:
实际时间(Real time):
- 日历时间(Calendar time):从 Epoch(1970年1月1日00:00:00 UTC)到现在的秒数,用整数存储
- 流逝时间(Elapsed/Wall clock time):进程从启动到现在经过的时间
进程时间(Process time / CPU time):
CPU时间 = 用户CPU时间 + 系统CPU时间 \text{CPU时间} = \text{用户CPU时间} + \text{系统CPU时间} CPU时间=用户CPU时间+系统CPU时间 - 用户CPU时间(user time):进程在用户模式下执行代码花费的时间
- 系统CPU时间(system time):进程触发系统调用,内核代表它执行花费的时间
用time命令查看:
$ time ls -R /
real 0m2.345s # 实际流逝时间
user 0m0.123s # 用户CPU时间
sys 0m0.456s # 系统CPU时间
2.17 客户端-服务器架构
客户端-服务器模型将应用分为两部分:
客户端进程 服务器进程
| |
|-------- 请求消息 ------------>|
| | (处理请求)
|<-------- 响应消息 ------------|
服务器的价值:
- 效率:共享一个资源(打印机、数据库)比每台机器各一个更经济
- 控制与协调:集中管理防止并发冲突,统一安全控制
- 异构环境:客户端和服务器可以运行在不同硬件/操作系统上
2.18 实时(Realtime)
实时系统的关键不是"快",而是有保证的响应截止时间(deadline)。
- 银行 ATM、飞机导航、工厂自动化流水线都是实时系统
- 传统 UNIX 不是实时操作系统(多用户分时调度与实时需求冲突)
- 近年 Linux 内核逐步增加原生实时支持
POSIX.1b 定义了实时扩展:异步 I/O、共享内存、内存锁、实时时钟和定时器、替代调度策略、实时信号、消息队列、信号量。
注意本书术语区分:
- real time(两个词)= 日历时间/流逝时间
- realtime(一个词)= 实时系统/实时响应能力
2.19 /proc 文件系统
/proc 是一个虚拟文件系统,不对应磁盘上的真实文件,而是内核数据结构的"窗口"。
/proc/
├── 1/ <- init 进程的信息目录
│ ├── status <- 进程状态
│ ├── maps <- 内存映射
│ └── fd/ <- 打开的文件描述符
├── cpuinfo <- CPU 信息
├── meminfo <- 内存信息
├── net/ <- 网络相关
└── sys/ <- 内核参数(可修改)
/proc/PID/目录对应每个运行中的进程- 文件内容通常是人类可读的文本,Shell 脚本可以直接
cat和解析 - 修改某些文件可以改变内核行为(通常需要 root 权限)
/proc是 Linux 特有的,不在任何标准中规定
# 查看当前进程的内存映射
cat /proc/self/maps
# 查看 CPU 型号
cat /proc/cpuinfo | grep "model name"
# 查看内存总量
cat /proc/meminfo | grep MemTotal
全章概念关系图
核心概念速查表
| 概念 | 简要说明 |
|---|---|
| 内核 | 管理CPU/内存/设备的核心软件 |
| 用户模式/内核模式 | CPU的两种工作模式,隔离保护 |
| Shell | 命令解释器,是普通用户进程 |
| UID/GID | 用户/组的数字标识符 |
| 根目录 / | 单一目录树的顶点 |
| 文件描述符 | 代表打开文件的小整数(0=stdin,1=stdout,2=stderr) |
| 进程 | 运行中的程序实例 |
| PID/PPID | 进程ID/父进程ID |
| fork() | 创建子进程的系统调用 |
| execve() | 用新程序替换当前进程 |
| 信号 | 软件中断,通知进程某事件发生 |
| 线程 | 进程内的执行单元,共享地址空间 |
| IPC | 进程间通信机制的统称 |
| 守护进程 | 长期在后台运行,无控制终端 |
| init | PID=1,所有进程的祖先 |
| mmap | 内存映射,将文件或匿名区域映射到虚拟内存 |
| 静态库 | 链接时代码复制进可执行文件 |
| 共享库 | 运行时动态加载,内存中只有一份 |
| /proc | 查看内核内部状态的虚拟文件系统 |
| Epoch | 1970-01-01 00:00:00 UTC,UNIX时间基准点 |
第三章:系统编程基础概念详解
本文是《The Linux Programming Interface》第三章的详细中文解读。
本章核心:系统调用的工作原理、库函数、错误处理、可移植性。
3.1 系统调用(System Call)
什么是系统调用?
系统调用是进程进入内核的受控入口点。进程通过系统调用请求内核代劳完成某些任务,比如:
- 创建新进程(
fork()) - 执行 I/O(
read()、write()) - 创建管道(
pipe())
三个关键特点:
- 模式切换:系统调用让 CPU 从用户模式切换到内核模式,才能访问受保护的内核内存
- 固定集合:系统调用集合是固定的,每个系统调用有唯一编号(程序用名字,内核用编号)
- 参数传递:参数在用户空间和内核空间之间双向传递
系统调用的执行流程(x86-32 为例)
下面以 execve() 为例(系统调用编号 = 11),展示从用户程序到内核的完整路径:
逐步详解
第1步: 应用程序调用 C 库中的包装函数(wrapper function),如 execve()。从程序员角度看,这和调用普通函数没区别。
第2步: 包装函数把参数从栈复制到特定 CPU 寄存器(内核期望从寄存器读参数)。
第3步: 包装函数把系统调用编号写入 %eax 寄存器(内核靠这个编号区分是哪个系统调用)。
第4步: 包装函数执行 int 0x80 陷阱指令(trap instruction),CPU 切换到内核模式,跳转到中断向量表第 128(0x80)项对应的处理程序。
较新的 x86-32 架构用
sysenter指令代替int 0x80,速度更快。Linux 2.6 内核和 glibc 2.3.2 开始支持。
第5步(内核内部):system_call()例程依次做:
- a) 保存寄存器值到内核栈
- b) 检查系统调用编号有效性
- c) 以编号为下标,查
sys_call_table表,找到对应服务例程(如sys_execve())并调用 - d) 恢复寄存器,将返回值压栈
- e) 返回包装函数,CPU 切回用户模式
第6步: 若服务例程返回负值(表示出错),包装函数取反后存入全局变量errno,然后返回 − 1 -1 −1 给调用者。
系统调用的开销
系统调用有不可忽视的开销,即使是最简单的 getppid()(只返回父进程ID):
- 在 x86-32 / Linux 2.6.25 上,调用
getppid()约需 0.3 微秒 - 相同系统上,调用一个普通 C 函数(只返回整数)约需 0.011 微秒
系统调用比普通函数调用慢约 20 倍,原因是模式切换、参数验证、内核栈操作等开销。
约定:本书后续说"调用系统调用 xyz()“,实际含义是"调用 glibc 中调用该系统调用的包装函数”。
3.2 库函数(Library Function)
库函数是标准 C 库(glibc)中大量函数的统称。它们的用途非常多样。
两类库函数:
库函数
├── 不使用系统调用的库函数
│ └── 例:strcmp()、strlen()、memcpy() 等字符串/内存操作
│ (完全在用户空间完成,开销很小)
│
└── 封装系统调用的库函数
└── 例:fopen() → 内部调用 open() 系统调用
printf() → 内部调用 write() 系统调用
malloc() → 内部调用 brk() 系统调用
库函数通常提供比系统调用更友好的接口:
| 系统调用 | 对应库函数 | 库函数的额外价值 |
|---|---|---|
write() |
printf() |
格式化输出、数据缓冲 |
brk() |
malloc() / free() |
内存块管理记账工作 |
open() |
fopen() |
缓冲、错误处理封装 |
3.3 GNU C 库(glibc)
Linux 最常用的标准 C 库实现是 GNU C Library(glibc)。
- 主要开发者:Roland McGrath(早期),后由 Ulrich Drepper 维护
- 主页:
http://www.gnu.org/software/libc/ - 其他轻量替代:
uClibc(嵌入式设备)、diet libc
查询 glibc 版本的方法
方法一:直接运行库文件
$ /lib/libc.so.6
# 输出包含版本号,如:GNU C Library stable release version 2.10.1
方法二:用 ldd 定位库文件
$ ldd myprog | grep libc
# 输出:libc.so.6 => /lib/tls/libc.so.6 (0x4004b000)
方法三:编译时检查宏(适合同机编译运行)
glibc 2.0 起定义了两个宏 __GLIBC__ 和 __GLIBC_MINOR__,可在 #ifdef 中使用。
方法四:运行时查询(适合跨机器场景)
#include <iostream>
#include <gnu/libc-version.h> // gnu_get_libc_version()
int main() {
// gnu_get_libc_version() 返回指向静态字符串的指针,如 "2.12"
const char* ver = gnu_get_libc_version();
std::cout << "glibc 版本: " << ver << std::endl;
return 0;
}
https://godbolt.org/z/f8v6hdr3T
3.4 错误处理
核心原则
几乎每个系统调用和库函数都应检查返回值。 不检查返回值是常见的 bug 来源,会浪费大量调试时间。
极少数系统调用永远不失败(如 getpid()、_exit()),不需要检查。
系统调用错误处理机制
系统调用失败时的惯例:
- 返回 − 1 -1 −1
- 将全局变量
errno设为正整数(标识具体错误类型)errno的错误码以E开头,定义在<errno.h>中,例如:
| 错误码 | 含义 |
|---|---|
| EPERM | 操作不允许(权限不足) |
| ENOENT | 文件或目录不存在 |
| EINTR | 系统调用被信号中断 |
| EACCES | 权限被拒绝 |
| ENOMEM | 内存不足 |
| EINVAL | 无效参数 |
| EBUSY | 设备或资源忙 |
重要注意事项:
- 成功的系统调用不会重置
errno为 0,所以errno可能保留上次错误的值 - 极少数成功调用也可能将
errno设为非零值(SUSv3 允许) - 正确顺序:先判断返回值是否为 − 1 -1 −1,再看
errno
特殊情况:有些系统调用(如getpriority())成功时也可能返回 − 1 -1 −1,处理方式:
errno = 0; // 调用前清零
int prio = getpriority(PRIO_PROCESS, 0);
if (prio == -1 && errno != 0) {
// 确认是真正出错,而非合法的 -1 返回值
}
errno 与错误信息的转换
perror():直接打印带描述的错误信息到 stderr
#include <cstdio> // perror()
#include <cerrno> // errno
#include <fcntl.h> // open()
#include <cstdlib> // EXIT_FAILURE
int main() {
// 尝试打开一个不存在的文件
int fd = open("/不存在的文件", 0);
if (fd == -1) {
// perror 输出:open: No such file or directory
perror("open");
exit(EXIT_FAILURE);
}
return 0;
}
https://godbolt.org/z/q1KGfod8jstrerror():将错误号转换为描述字符串(返回的字符串可能是静态分配的,后续调用会覆盖)
#include <iostream>
#include <cstring> // strerror()
#include <cerrno> // ENOENT 等
int main() {
// 将错误号转为字符串
std::cout << strerror(ENOENT) << std::endl; // "No such file or directory"
std::cout << strerror(EACCES) << std::endl; // "Permission denied"
return 0;
}
https://godbolt.org/z/fnTehGcr6
库函数的错误处理分类
| 类型 | 失败返回值 | 是否设 errno | 如何诊断 |
|---|---|---|---|
| 类似系统调用 | -1 | 是 | 同系统调用处理方式 |
| 其他返回值 | 如 NULL | 是 | 可用 perror()/strerror() |
| 不使用 errno | 各异 | 否 | 查该函数手册页 |
3.5 示例程序中的公共工具
书中几乎所有示例程序都使用一套公共头文件和错误处理函数,下面详细解释它们的设计。
公共头文件 tlpi_hdr.h
这个头文件把常用头文件集中在一处,并定义了简单的工具宏:
// ===== tlpi_hdr.h 解析版(加了详细注释)=====
#ifndef TLPI_HDR_H
#define TLPI_HDR_H // 防止重复包含
#include <sys/types.h> // 系统数据类型:pid_t、uid_t、off_t 等
#include <stdio.h> // 标准 I/O:printf、fopen、perror 等
#include <stdlib.h> // 通用工具:exit()、malloc()、EXIT_FAILURE 等
#include <unistd.h> // POSIX API:read()、write()、fork()、getpid() 等
#include <errno.h> // errno 变量和错误码常量(EPERM、ENOENT 等)
#include <string.h> // 字符串操作:strcpy、strcmp、strerror 等
#include "get_num.h" // 自定义:安全解析命令行数字参数
#include "error_functions.h" // 自定义:错误诊断函数
// 定义布尔类型(C89 没有内置 bool)
typedef enum { FALSE, TRUE } Boolean;
// min/max 宏:用括号保护操作数,避免宏展开副作用
#define min(m,n) ((m) < (n) ? (m) : (n))
#define max(m,n) ((m) > (n) ? (m) : (n))
#endif
错误诊断函数体系
书中定义了一套层次清晰的错误处理函数:
各函数的适用场景:
| 函数 | 适用场景 | 终止方式 |
|---|---|---|
errMsg() |
打印错误但继续运行 | 不终止 |
errExit() |
系统调用失败,需退出 | exit() |
err_exit() |
子进程(fork后)失败退出 | _exit() |
errExitEN() |
POSIX 线程函数失败 | exit() |
fatal() |
不设 errno 的库函数失败 | exit() |
usageErr() |
命令行用法错误 | exit() |
cmdLineErr() |
命令行参数值错误 | exit() |
errExit vs err_exit 的关键区别:err_exit() 用 _exit() 终止,这在 fork() 之后的子进程中非常重要:
_exit()不会刷新 stdio 缓冲区 → 不会把父进程缓冲区中的数据重复输出_exit()不会调用atexit()注册的退出处理函数 → 不会触发父进程注册的清理逻辑
POSIX 线程函数的错误处理(为何需要errExitEN):
POSIX 线程函数(如pthread_create())与传统系统调用不同:- 传统系统调用:失败返回 − 1 -1 −1,错误码在
errno - POSIX 线程函数:失败直接返回正整数错误码,成功返回 0
多线程程序中,errno被实现为宏,展开后是一次函数调用(获取线程私有的 errno),每次使用都有开销:
// 低效写法:两次访问 errno(一次赋值,一次读取)
errno = pthread_create(&thread, NULL, func, &arg);
if (errno != 0)
errExit("pthread_create");
// 高效写法:直接把错误码传给 errExitEN
int s;
s = pthread_create(&thread, NULL, func, &arg);
if (s != 0)
errExitEN(s, "pthread_create"); // s 就是错误码,避免多次访问 errno
错误函数实现核心逻辑
// ===== 下面是一个简化的完整可运行示例,展示错误处理的核心思路 =====
#include <iostream>
#include <cstdio> // fprintf, vsnprintf, fflush
#include <cstdarg> // va_list, va_start, va_end
#include <cstring> // strerror
#include <cerrno> // errno
#include <cstdlib> // exit, EXIT_FAILURE
// 打印错误信息到 stderr(内部辅助函数)
// useErr: 是否打印 errno 对应的错误描述
// err: errno 的值
// flushStdout: 是否先 flush stdout(避免输出交错)
static void outputError(bool useErr, int err, bool flushStdout,
const char* format, va_list ap) {
char userMsg[500], errText[500], buf[500];
// 格式化用户提供的消息
vsnprintf(userMsg, sizeof(userMsg), format, ap);
if (useErr) {
// 拼接错误号对应的描述,如 "[ENOENT No such file or directory]"
snprintf(errText, sizeof(errText), " [%s]", strerror(err));
} else {
snprintf(errText, sizeof(errText), ":");
}
snprintf(buf, sizeof(buf), "ERROR%s %s\n", errText, userMsg);
if (flushStdout)
fflush(stdout); // 先清空标准输出,避免乱序
fputs(buf, stderr);
fflush(stderr);
}
// 打印错误信息,不终止程序
void errMsg(const char* format, ...) {
va_list argList;
int savedErrno = errno; // 保存 errno,防止被我们自己的操作修改
va_start(argList, format);
outputError(true, errno, true, format, argList);
va_end(argList);
errno = savedErrno; // 恢复 errno
}
// 打印错误信息,然后终止程序
void errExit(const char* format, ...) {
va_list argList;
va_start(argList, format);
outputError(true, errno, true, format, argList);
va_end(argList);
exit(EXIT_FAILURE);
}
// 演示用法
int main() {
// 尝试打开不存在的文件
FILE* f = fopen("/tmp/不存在的文件_xyz_abc", "r");
if (f == nullptr) {
// 输出类似:ERROR [No such file or directory] 打开文件失败
errMsg("打开文件失败");
// 继续执行...
}
// 模拟一个致命错误
int fd = -1;
if (fd == -1) {
errExit("fd 无效,程序退出");
// 这行不会执行
}
return 0;
}
数字命令行参数解析(getInt / getLong)
书中用 getInt() 和 getLong() 替代 atoi()/strtol(),原因是后者几乎不做错误检查。
flags 参数的位标志(可用 | 组合):
| 标志常量 | 八进制值 | 含义 |
|---|---|---|
GN_NONNEG |
01 |
值必须 ≥ 0 \geq 0 ≥0 |
GN_GT_0 |
02 |
值必须 > 0 > 0 >0 |
GN_ANY_BASE |
0100 |
自动识别进制(like strtol base=0) |
GN_BASE_8 |
0200 |
八进制解析 |
GN_BASE_16 |
0400 |
十六进制解析 |
// ===== getInt/getLong 完整可运行示例 =====
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <climits> // INT_MAX, INT_MIN
#include <cerrno>
// 标志常量(与书中一致)
#define GN_NONNEG 01 // 值 >= 0
#define GN_GT_0 02 // 值 > 0
#define GN_ANY_BASE 0100 // 自动识别进制
#define GN_BASE_8 0200 // 八进制
#define GN_BASE_16 0400 // 十六进制
// 内部错误输出并退出
static void gnFail(const char* fname, const char* msg,
const char* arg, const char* name) {
fprintf(stderr, "%s 错误", fname);
if (name != nullptr)
fprintf(stderr, "(参数名:%s)", name);
fprintf(stderr, ": %s\n", msg);
if (arg != nullptr && *arg != '\0')
fprintf(stderr, " 非法输入: %s\n", arg);
exit(EXIT_FAILURE);
}
// 核心解析函数
static long getNum(const char* fname, const char* arg,
int flags, const char* name) {
long res;
char* endptr;
int base;
// 空字符串检查
if (arg == nullptr || *arg == '\0')
gnFail(fname, "空字符串", arg, name);
// 根据 flags 决定进制
base = (flags & GN_ANY_BASE) ? 0 :
(flags & GN_BASE_8) ? 8 :
(flags & GN_BASE_16) ? 16 : 10;
errno = 0;
res = strtol(arg, &endptr, base); // 核心转换
if (errno != 0)
gnFail(fname, "strtol() 失败", arg, name);
if (*endptr != '\0') // 还有非数字字符残留
gnFail(fname, "包含非数字字符", arg, name);
if ((flags & GN_NONNEG) && res < 0)
gnFail(fname, "不允许负数", arg, name);
if ((flags & GN_GT_0) && res <= 0)
gnFail(fname, "值必须大于0", arg, name);
return res;
}
long getLong(const char* arg, int flags, const char* name) {
return getNum("getLong", arg, flags, name);
}
int getInt(const char* arg, int flags, const char* name) {
long res = getNum("getInt", arg, flags, name);
if (res > INT_MAX || res < INT_MIN)
gnFail("getInt", "超出 int 范围", arg, name);
return (int)res;
}
// 演示
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "用法: " << argv[0] << " <数字>" << std::endl;
return 1;
}
// 解析为正整数
int n = getInt(argv[1], GN_GT_0, "count");
std::cout << "解析结果: " << n << std::endl;
// 解析十六进制(如输入 "0xff")
if (argc >= 3) {
long hex = getLong(argv[2], GN_ANY_BASE | GN_NONNEG, "hex_val");
std::cout << "十六进制解析结果: " << hex << std::endl;
}
return 0;
}
errno 错误名称表(ename 数组)
书中定义了 ename[] 数组,将数字错误码映射到符号名称字符串,原因是:
strerror()返回"No such file or directory",但手册页用的是ENOENT- 打印符号名方便直接查手册
部分错误码对应多个名称(用/分隔),因为它们数值相同: EAGAIN / EWOULDBLOCK:System V 和 BSD 对同一概念的不同命名——非阻塞操作无法立即完成时返回
3.6 可移植性问题
3.6.1 特性测试宏(Feature Test Macros)
不同标准规定了不同的 API,头文件通过特性测试宏决定暴露哪些定义。
为什么需要它?
同一个头文件(如 <unistd.h>)中可能有属于不同标准的函数声明。通过宏控制,可以:
- 只暴露某个标准要求的定义
- 避免使用非标准函数导致移植问题
常用特性测试宏
| 宏名 | 来源 | 效果 |
|---|---|---|
_POSIX_SOURCE |
POSIX 标准 | 暴露 POSIX.1-1990 定义 |
_POSIX_C_SOURCE=200112 |
POSIX 标准 | 暴露 POSIX.1-2001 基础规范 |
_POSIX_C_SOURCE=200809 |
POSIX 标准 | 暴露 POSIX.1-2008 基础规范 |
_XOPEN_SOURCE=600 |
SUSv3 | 暴露 SUSv3(XSI)全部定义 |
_XOPEN_SOURCE=700 |
SUSv4 | 暴露 SUSv4(XSI)全部定义 |
_BSD_SOURCE |
glibc 特有 | 暴露 BSD 扩展定义 |
_SVID_SOURCE |
glibc 特有 | 暴露 System V 定义 |
_GNU_SOURCE |
glibc 特有 | 暴露所有上述定义 + GNU 扩展 |
_XOPEN_SOURCE 值的由来:
SUSv2、SUSv3、SUSv4 分别是 X/Open 规范的第 5、6、7 期(Issue),所以对应值是 500、600、700。
使用方式:
// 方式一:在源码最顶部定义(必须在 include 之前)
#define _XOPEN_SOURCE 600
#include <unistd.h>
// ...
# 方式二:编译时通过 -D 选项定义
cc -D_XOPEN_SOURCE=600 prog.c
SUSv3 的要求:
_POSIX_C_SOURCE=200112→ POSIX 一致性(不含 XSI 扩展)_XOPEN_SOURCE=600→ SUSv3 完整一致性(包含 XSI 扩展)
SUSv3 规定:设置_XOPEN_SOURCE=600应包含_POSIX_C_SOURCE=200112的所有特性,因此只需定义前者。SUSv4 同理(700 包含 200809)。
本书示例程序的编译命令:
cc -std=c99 -D_XOPEN_SOURCE=600 prog.c
3.6.2 系统数据类型(System Data Types)
问题:为什么不直接用 int、long?
不同 UNIX 实现、不同硬件平台、不同版本上,同一概念的类型可能不同:
long在 32 位系统是 4 字节,64 位系统是 8 字节- Linux 2.2 的 UID/GID 是 16 位,Linux 2.4 起变为 32 位
- 进程 ID 在某系统是
int,在另一系统可能是long
解决方案: SUSv3 定义了一套标准系统数据类型,用typedef实现,名字通常以_t结尾。
部分重要类型:
| 类型名 | SUSv3 要求 | 用途 |
|---|---|---|
pid_t |
有符号整数 | 进程ID、进程组ID、会话ID |
uid_t |
整数 | 用户数字标识符 |
gid_t |
整数 | 组数字标识符 |
off_t |
有符号整数 | 文件偏移量或大小 |
size_t |
无符号整数 | 对象字节大小 |
ssize_t |
有符号整数 | 字节计数(可为负表示错误) |
time_t |
整数或浮点 | 从 Epoch 起的秒数(日历时间) |
clock_t |
整数或浮点 | 系统时间(时钟滴答数) |
mode_t |
整数 | 文件权限和类型 |
ino_t |
无符号整数 | 文件 inode 编号 |
dev_t |
算术类型 | 设备号(主设备号+次设备号) |
nlink_t |
整数 | 硬链接计数 |
rlim_t |
无符号整数 | 资源限制值 |
socklen_t |
至少32位整数 | socket 地址结构大小 |
sig_atomic_t |
整数 | 可原子访问的数据类型 |
正确打印系统数据类型的值:
问题:pid_t 可能是 int 或 long,printf 的格式符必须匹配,否则未定义行为。
解决方案:统一转为 long,使用 %ld:
#include <iostream>
#include <cstdio>
#include <unistd.h> // getpid()
#include <sys/types.h> // pid_t
int main() {
pid_t mypid = getpid();
// 错误做法:直接用 %d 或 %ld 可能与实际类型不符
// printf("PID: %d\n", mypid); // 若 pid_t 是 long,可能截断
// 正确做法:统一 cast 为 long,使用 %ld
printf("My PID is %ld\n", (long)mypid);
// off_t 可能是 long long,需要特殊处理
off_t filesize = 1234567890LL;
printf("File size: %lld bytes\n", (long long)filesize);
return 0;
}
3.6.3 其他可移植性问题
结构体初始化顺序:
SUSv3 规定的标准结构体(如 sembuf)字段顺序在不同实现中可能不同,因此:
// 不可移植的写法(依赖字段顺序)
// struct sembuf s = { 3, -1, SEM_UNDO }; // 危险!
// 可移植写法一:逐字段赋值
struct sembuf s;
s.sem_num = 3;
s.sem_op = -1;
s.sem_flg = SEM_UNDO;
// 可移植写法二:C99 指定初始化器(推荐)
struct sembuf s2 = { .sem_num = 3, .sem_op = -1, .sem_flg = SEM_UNDO };
可能不存在的宏:
// WCOREDUMP 不在 SUSv3 中,某些系统可能没有
#ifdef WCOREDUMP
if (WCOREDUMP(status)) {
printf("子进程产生了 core dump 文件\n");
}
#endif
头文件的跨实现差异:
不同 UNIX 实现可能需要不同的头文件来声明同一函数。本书中标注 /* For portability */ 的头文件在 Linux/SUSv3 上可能不必须,但为了兼容其他系统(尤其老系统)应包含。
3.7 章节总结
第四章:文件 I/O——通用 I/O 模型详解
本文是《The Linux Programming Interface》第四章的详细中文解读。
本章核心:open()、read()、write()、close()、lseek()五大系统调用,以及 UNIX I/O 的统一性哲学。
4.1 概述
文件描述符(File Descriptor)
所有 I/O 系统调用都通过文件描述符(fd) 引用打开的文件。
文件描述符是一个小的非负整数,可以引用:
- 普通磁盘文件
- 管道(pipe)/ FIFO
- 套接字(socket)
- 终端(terminal)
- 设备文件
每个进程有自己独立的一套文件描述符。
三个标准文件描述符
进程启动时,Shell 已经为其打开了三个标准文件描述符:
| 描述符编号 | POSIX 常量名 | stdio 流 | 默认连接 |
|---|---|---|---|
| 0 | STDIN_FILENO |
stdin |
标准输入(键盘) |
| 1 | STDOUT_FILENO |
stdout |
标准输出(终端屏幕) |
| 2 | STDERR_FILENO |
stderr |
标准错误(终端屏幕) |
推荐用 POSIX 常量名(如
STDIN_FILENO)而非直接写数字 0、1、2,可读性更好。
注意:stdin/stdout/stderr是 stdio 库的流对象,可通过freopen()重定向到其他文件,重定向后底层文件描述符编号可能改变,不能再假定stdout对应描述符 1。
四个核心 I/O 系统调用
| 系统调用 | 功能 | 成功返回 | 失败返回 |
|---|---|---|---|
open() |
打开或创建文件 | 文件描述符(非负整数) | -1 |
read() |
从文件读数据 | 实际读取字节数(0=EOF) | -1 |
write() |
向文件写数据 | 实际写入字节数 | -1 |
close() |
关闭文件描述符 | 0 | -1 |
最简单的文件复制程序(C++ 完整注释版)
下面是书中 copy.c 的 C++ 实现,包含详细注释:
// 功能:将第一个参数指定的文件复制到第二个参数指定的文件
// 用法:./copy 源文件 目标文件
// 示例:./copy oldfile newfile
#include <iostream>
#include <cstring> // strcmp
#include <cstdlib> // exit, EXIT_SUCCESS, EXIT_FAILURE
#include <sys/stat.h> // S_IRUSR 等权限常量,mode_t
#include <fcntl.h> // open(), O_RDONLY 等标志
#include <unistd.h> // read(), write(), close()
#include <cerrno> // errno
#include <cstdio> // perror
// 每次读写的缓冲区大小(可用 cc -DBUF_SIZE=4096 覆盖)
#ifndef BUF_SIZE
#define BUF_SIZE 1024
#endif
int main(int argc, char* argv[]) {
// 检查命令行参数数量
if (argc != 3 || strcmp(argv[1], "--help") == 0) {
std::cerr << "用法: " << argv[0] << " 源文件 目标文件" << std::endl;
exit(EXIT_FAILURE);
}
// ===== 打开源文件(只读)=====
int inputFd = open(argv[1], O_RDONLY);
if (inputFd == -1) {
perror(("打开源文件失败: " + std::string(argv[1])).c_str());
exit(EXIT_FAILURE);
}
// ===== 打开/创建目标文件(只写)=====
// O_CREAT: 文件不存在则创建
// O_WRONLY: 只写模式
// O_TRUNC: 如果文件已存在,截断为零长度(覆盖写)
int openFlags = O_CREAT | O_WRONLY | O_TRUNC;
// 新文件权限:rw-rw-rw-(0666)
// S_IRUSR/S_IWUSR: 所有者读/写
// S_IRGRP/S_IWGRP: 组读/写
// S_IROTH/S_IWOTH: 其他人读/写
// 注意:实际权限还受进程 umask 影响
mode_t filePerms = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP |
S_IROTH | S_IWOTH;
int outputFd = open(argv[2], openFlags, filePerms);
if (outputFd == -1) {
perror(("打开目标文件失败: " + std::string(argv[2])).c_str());
exit(EXIT_FAILURE);
}
// ===== 循环读写,直到源文件读完(EOF)=====
char buf[BUF_SIZE];
ssize_t numRead;
// read() 返回 0 表示 EOF,返回 -1 表示出错,返回正数表示读到的字节数
while ((numRead = read(inputFd, buf, BUF_SIZE)) > 0) {
// write() 必须写完所有读到的字节
// 注意:write() 可能写入少于请求的字节(部分写入)
if (write(outputFd, buf, numRead) != numRead) {
std::cerr << "写入目标文件时未能写完整个缓冲区" << std::endl;
exit(EXIT_FAILURE);
}
}
// 检查 read() 是否因错误退出循环(而不是正常 EOF)
if (numRead == -1) {
perror("read 失败");
exit(EXIT_FAILURE);
}
// ===== 关闭文件描述符 =====
if (close(inputFd) == -1) {
perror("关闭源文件失败");
exit(EXIT_FAILURE);
}
if (close(outputFd) == -1) {
perror("关闭目标文件失败");
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}
4.2 I/O 的统一性(Universality of I/O)
UNIX I/O 最重要的设计哲学:用同一套系统调用操作所有类型的文件和设备。
open() / read() / write() / close()
|
| 同一套接口
|
+----+----+----+----+----+
| | | | | |
磁盘 管道 套接字 终端 设备
文件 FIFO /dev/tty
同一个 copy 程序可以做:
./copy test test.old # 普通文件 → 普通文件
./copy a.txt /dev/tty # 普通文件 → 当前终端(打印出来)
./copy /dev/tty b.txt # 从终端读入 → 保存到文件
./copy /dev/pts/16 /dev/tty # 一个终端的输入 → 另一个终端
实现原理: 每种文件系统和设备驱动都实现了同一套 I/O 系统调用接口,内核内部处理具体差异,应用程序不需要关心底层细节。
例外情况: 若需要访问某文件系统或设备的特定功能(超出通用模型),使用 ioctl() 系统调用(见4.8节)。
4.3 打开文件:open()
函数原型
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags, ... /* mode_t mode */);
// 成功:返回文件描述符(非负整数)
// 失败:返回 -1,并设置 errno
参数说明:
pathname:文件路径(若是符号链接,会自动跟随解引用)flags:位掩码,指定访问模式和其他选项mode:仅在创建新文件时有效,指定文件权限
open() 返回最小可用描述符
SUSv3 规定:open() 成功时,返回当前最小的未使用文件描述符。
利用这个特性,可以强制在特定描述符上打开文件:
// 强制让某文件使用描述符 0(标准输入)
if (close(STDIN_FILENO) == -1) { // 先关闭描述符 0
perror("close");
}
// 此时描述符 0 空闲,open() 保证返回 0
int fd = open(pathname, O_RDONLY);
// 现在读标准输入就等于读 pathname 指向的文件
flags 参数详解
flags 由三类常量组成,用 | 组合:
第一类:文件访问模式(必选其一)
| 常量 | 含义 |
|---|---|
O_RDONLY |
只读打开 |
O_WRONLY |
只写打开 |
O_RDWR |
读写打开 |
注意:O_RDWR ≠ O_RDONLY | O_WRONLY,后者是逻辑错误!
第二类:文件创建标志(影响 open() 行为本身,不可检索/修改)
| 常量 | SUSv3 | 含义 |
|---|---|---|
O_CREAT |
v3 | 文件不存在则创建(此时必须提供 mode 参数) |
O_EXCL |
v3 | 与 O_CREAT 合用:文件已存在则报错(EEXIST),实现原子创建 |
O_TRUNC |
v3 | 文件已存在则截断为零长度(需要写权限) |
O_DIRECTORY |
- | pathname 不是目录则报错(ENOTDIR),需 _GNU_SOURCE |
O_NOFOLLOW |
- | pathname 是符号链接则报错(ELOOP),需 _GNU_SOURCE |
O_CLOEXEC |
v4 | 设置 close-on-exec 标志(exec 后自动关闭)Linux 2.6.23+ |
O_LARGEFILE |
- | 32位系统支持大文件(>2GB) |
第三类:文件状态标志(影响后续 I/O 行为,可用 fcntl 检索/修改)
| 常量 | SUSv3 | 含义 |
|---|---|---|
O_APPEND |
v3 | 每次写入前自动定位到文件末尾(原子追加) |
O_NONBLOCK |
v3 | 非阻塞模式打开 |
O_SYNC |
v3 | 同步写:每次 write() 等待数据和元数据写入磁盘 |
O_DSYNC |
v4 | 同步写:只等待数据写入磁盘(Linux 2.6.33+) |
O_ASYNC |
- | 信号驱动I/O(对终端/FIFO/套接字有效) |
O_NOATIME |
- | 读文件时不更新 st_atime,Linux 2.6.8+,需 _GNU_SOURCE |
O_NOCTTY |
v3 | 防止终端设备成为控制终端 |
O_DIRECT |
- | 绕过内核缓冲区直接 I/O,需 _GNU_SOURCE |
open() 常见用法示例
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
int main() {
int fd;
// 1. 只读打开已有文件
fd = open("startup", O_RDONLY);
if (fd == -1) { perror("open startup"); exit(1); }
close(fd);
// 2. 读写方式打开(不存在则创建),截断为零长度
// 权限:rw-------(只有文件所有者可读写)
fd = open("myfile", O_RDWR | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR);
if (fd == -1) { perror("open myfile"); exit(1); }
close(fd);
// 3. 只写追加模式(日志文件典型用法)
// 每次 write() 都自动追加到文件末尾
fd = open("w.log", O_WRONLY | O_CREAT | O_APPEND,
S_IRUSR | S_IWUSR);
if (fd == -1) { perror("open w.log"); exit(1); }
close(fd);
// 4. 原子创建:保证我是文件创建者(文件已存在则失败)
fd = open("lockfile", O_WRONLY | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR);
if (fd == -1) { perror("open lockfile(已存在)"); }
else { close(fd); }
return 0;
}
open() 常见错误码
| errno | 含义 |
|---|---|
EACCES |
权限不足(文件权限或目录权限不允许),或文件不存在且无法创建 |
EISDIR |
试图以写方式打开目录 |
EMFILE |
进程打开文件数达到资源限制(RLIMIT_NOFILE) |
ENFILE |
系统全局打开文件数达到上限 |
ENOENT |
文件不存在且未指定 O_CREAT,或路径中某目录不存在 |
EROFS |
试图写只读文件系统上的文件 |
ETXTBSY |
试图写正在执行的可执行文件 |
EEXIST |
O_CREAT |
ELOOP |
路径中符号链接循环,或指定 O_NOFOLLOW 时路径是符号链接 |
creat() 系统调用(已过时)
早期 UNIX 的 open() 只有两个参数,无法创建文件,因此有 creat():
int creat(const char *pathname, mode_t mode);
// 等价于:
fd = open(pathname, O_WRONLY | O_CREAT | O_TRUNC, mode);
现代程序应使用 open(),creat() 只在老代码中出现。
4.4 读取文件:read()
函数原型
#include <unistd.h>
ssize_t read(int fd, void *buffer, size_t count);
// 成功:返回实际读取字节数(0 = 到达文件末尾 EOF)
// 失败:返回 -1,设置 errno
参数:
fd:文件描述符buffer:存放读取数据的内存缓冲区(调用者负责分配足够大的内存)count:最多读取的字节数(size_t是无符号整数类型)
返回值:- 正数:实际读取的字节数
0:到达文件末尾(EOF)-1:出错
重要:read() 不添加 ‘\0’
read() 读取的是原始字节序列,不会在末尾添加字符串终止符 '\0'。
这是因为 read() 可能读取二进制数据(整数、结构体),没有"字符串"的概念。
// 错误示范:buffer 末尾没有 '\0',printf 会读到垃圾数据
#define MAX_READ 20
char buffer[MAX_READ];
read(STDIN_FILENO, buffer, MAX_READ);
printf("%s\n", buffer); // 危险!可能打印乱码
// 正确做法:手动添加 '\0'
char buffer2[MAX_READ + 1]; // 多分配 1 字节给 '\0'
ssize_t numRead = read(STDIN_FILENO, buffer2, MAX_READ);
if (numRead == -1) { perror("read"); exit(1); }
buffer2[numRead] = '\0'; // 手动终止字符串
printf("%s\n", buffer2); // 安全
read() 可能读取少于请求的字节数
对于磁盘文件,read() 读取的字节数可能少于 count,原因包括:
- 接近文件末尾
- 从管道/套接字/终端读取时,数据还没来
- 终端默认遇到换行符
'\n'就停止
处理"短读"的正确方式: 循环调用read()直到读够或 EOF。
4.5 写入文件:write()
函数原型
#include <unistd.h>
ssize_t write(int fd, void *buffer, size_t count);
// 成功:返回实际写入字节数(可能 < count)
// 失败:返回 -1,设置 errno
可能写入少于 count 字节的原因:
- 磁盘空间已满
- 进程文件大小资源限制(
RLIMIT_FSIZE)被触发
重要:write() 成功不等于数据已写入磁盘!
内核会缓冲磁盘 I/O(为了效率),write()成功只意味着数据进入内核缓冲区。若需要保证写入磁盘,要用fsync()或O_SYNC标志(第13章详解)。
4.6 关闭文件:close()
函数原型
#include <unistd.h>
int close(int fd);
// 成功:返回 0
// 失败:返回 -1,设置 errno
关闭文件的意义:
- 释放文件描述符供后续使用(文件描述符是有限资源)
- 减少内核资源消耗
- 允许内核进行某些清理操作(如 NFS 提交)
注意: 进程终止时,所有打开的文件描述符会被自动关闭。但长期运行的程序(Shell、网络服务器)如果不及时关闭,会耗尽描述符。
必须检查 close() 的返回值:
if (close(fd) == -1) {
perror("close");
exit(EXIT_FAILURE);
}
// 可能捕获到的错误:
// - 关闭未打开的描述符
// - 同一描述符关闭两次
// - NFS:文件系统提交失败(数据未真正写到远端磁盘)
4.7 改变文件偏移量:lseek()
文件偏移量是什么?
内核为每个打开的文件维护一个文件偏移量(file offset),也叫读写指针:
- 表示下一次
read()/write()开始的位置 - 以字节为单位,相对于文件起始位置,从 0 开始
- 文件打开时初始值为 0
- 每次
read()/write()后自动向后移动相应字节数
函数原型
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
// 成功:返回新的文件偏移量
// 失败:返回 -1,设置 errno
whence 参数
whence 决定 offset 的参考基点:
文件内容(共 N 字节):
字节编号: 0 1 2 ... N-2 N-1 N N+1 ...
^
EOF 之后
SEEK_SET(0): 从文件头计算 → 新偏移 = offset
SEEK_CUR(1): 从当前位置计算 → 新偏移 = 当前偏移 + offset
SEEK_END(2): 从文件末尾计算 → 新偏移 = 文件大小 + offset
| whence 值 | 含义 |
|---|---|
SEEK_SET |
从文件头偏移 offset 字节(offset 必须 ≥ 0) |
SEEK_CUR |
从当前位置偏移 offset 字节(可正可负) |
SEEK_END |
从文件末尾偏移 offset 字节(可正可负) |
常用 lseek() 操作
#include <unistd.h>
#include <sys/types.h> // off_t
// 假设 fd 是已打开的文件描述符
// 移动到文件开头
lseek(fd, 0, SEEK_SET);
// 移动到文件末尾的下一个字节(即 EOF 位置)
lseek(fd, 0, SEEK_END);
// 移动到文件最后一个字节
lseek(fd, -1, SEEK_END);
// 向前移动 10 字节
lseek(fd, -10, SEEK_CUR);
// 获取当前偏移量(不移动)
off_t curr = lseek(fd, 0, SEEK_CUR);
// 移到文件末尾后 10001 字节处(会产生文件空洞!)
lseek(fd, 10000, SEEK_END);
lseek() 只修改内核记录,不进行实际磁盘访问。
lseek() 的限制
并非所有文件类型都支持 lseek():
| 文件类型 | lseek 支持? | 失败时 errno |
|---|---|---|
| 普通文件 | 支持 | - |
| 磁盘/磁带设备 | 支持 | - |
| 管道(pipe) | 不支持 | ESPIPE |
| FIFO | 不支持 | ESPIPE |
| 套接字(socket) | 不支持 | ESPIPE |
| 终端(terminal) | 不支持 | ESPIPE |
文件空洞(File Hole)
若将文件偏移量移到文件末尾之后,再执行 write(),就会产生文件空洞:
写入前(文件大小 = 10 字节):
[H][e][l][l][o][W][o][r][l][d]
0 1 2 3 4 5 6 7 8 9
执行:lseek(fd, 100000, SEEK_SET) → 偏移量移到 100000
执行:write(fd, "abc", 3) → 在 100000-100002 写入数据
写入后(文件名义大小 = 100003 字节):
[H][e][l]...[l][d][0][0][0]...[0][0][0][a][b][c]
0 1 2 8 9 10 11 12 99999 99999 100000 100001 100002
|←————————— 文件空洞(全为 \0)——————————→|
文件空洞的关键特性:
- 读取空洞区域返回全零字节(
\0) - 空洞不占用磁盘空间(文件系统不为空洞分配磁盘块)
- 文件名义大小(
ls -l显示的)可远大于实际占用磁盘空间 - 大多数原生 UNIX 文件系统支持,但 VFAT 等不支持(会写入实际零字节)
- 典型应用:core dump 文件(进程崩溃时产生,通常有大量空洞)
注意事项:
文件系统以块(block)为单位分配空间(通常 1024 1024 1024、 2048 2048 2048 或 4096 4096 4096 字节)。如果空洞的边缘落在块的中间,该块仍会被分配,块中空洞部分填零字节。
若以后向空洞中写入数据:即使文件大小不变,内核也要为空洞分配块,磁盘空间会减少。
lseek() + read()/write() 综合演示程序
// seek_io.c:演示 lseek、read、write 的综合使用
// 用法:./seek_io 文件名 操作序列...
// 操作:
// sN → 定位到偏移 N(SEEK_SET)
// rN → 读 N 字节,显示为文本
// RN → 读 N 字节,显示为十六进制
// wSTR → 写入字符串 STR
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cctype> // isprint()
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
// 将字符串解析为 long,基础版本
static long parseLong(const char* s) {
char* end;
long val = strtol(s, &end, 0); // base=0 自动识别进制(0x前缀=十六进制,0前缀=八进制)
if (*end != '\0') {
fprintf(stderr, "无效数字: %s\n", s);
exit(EXIT_FAILURE);
}
return val;
}
int main(int argc, char* argv[]) {
if (argc < 3 || strcmp(argv[1], "--help") == 0) {
fprintf(stderr, "用法: %s 文件 {r<长度>|R<长度>|w<字符串>|s<偏移量>}...\n",
argv[0]);
exit(EXIT_FAILURE);
}
// 打开文件(不存在则创建,读写模式)
int fd = open(argv[1], O_RDWR | O_CREAT,
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 逐个处理操作参数
for (int ap = 2; ap < argc; ap++) {
char op = argv[ap][0]; // 操作字符(r/R/w/s)
const char* arg = &argv[ap][1]; // 操作参数(字符 or 数字)
switch (op) {
case 'r': // 读取,显示为文本
case 'R': { // 读取,显示为十六进制
size_t len = (size_t)parseLong(arg);
char* buf = (char*)malloc(len);
if (!buf) { perror("malloc"); exit(EXIT_FAILURE); }
ssize_t numRead = read(fd, buf, len);
if (numRead == -1) { perror("read"); exit(EXIT_FAILURE); }
if (numRead == 0) {
printf("%s: 已到文件末尾\n", argv[ap]);
} else {
printf("%s: ", argv[ap]);
for (ssize_t j = 0; j < numRead; j++) {
if (op == 'r') {
// 可打印字符原样输出,否则显示 '?'
printf("%c", isprint((unsigned char)buf[j]) ? buf[j] : '?');
} else {
// 十六进制显示
printf("%02x ", (unsigned int)(unsigned char)buf[j]);
}
}
printf("\n");
}
free(buf);
break;
}
case 'w': { // 写入字符串
ssize_t numWritten = write(fd, arg, strlen(arg));
if (numWritten == -1) { perror("write"); exit(EXIT_FAILURE); }
printf("%s: 写入了 %ld 字节\n", argv[ap], (long)numWritten);
break;
}
case 's': { // 定位到指定偏移量
off_t offset = (off_t)parseLong(arg);
if (lseek(fd, offset, SEEK_SET) == -1) { perror("lseek"); exit(EXIT_FAILURE); }
printf("%s: 定位成功\n", argv[ap]);
break;
}
default:
fprintf(stderr, "操作符必须是 r/R/w/s 之一: %s\n", argv[ap]);
exit(EXIT_FAILURE);
}
}
exit(EXIT_SUCCESS);
}
演示:创建文件空洞并验证
# 创建空文件
touch tfile
# 定位到偏移 100000,写入 "abc"
./seek_io tfile s100000 wabc
# s100000: 定位成功
# wabc: 写入了 3 字节
# 查看文件大小
ls -l tfile
# -rw-rw-rw- 1 user group 100003 ... tfile
# 文件大小 = 100003 字节(100000字节空洞 + 3字节数据)
# 读取空洞区域(偏移 10000,读 5 字节)
./seek_io tfile s10000 R5
# s10000: 定位成功
# R5: 00 00 00 00 00 ← 空洞中读出全零
验证空洞不占磁盘空间:
# ls -s 显示实际占用的 512 字节块数
ls -ls tfile
# 8 -rw-rw-rw- 1 user group 100003 ... tfile
# 只占 8 个块(约 4096 字节),而非 100003 字节那么多
4.8 通用 I/O 模型之外:ioctl()
当某个文件系统或设备的操作超出 open/read/write/close 这套通用模型时,使用 ioctl():
#include <sys/ioctl.h>
int ioctl(int fd, int request, ... /* argp */);
// 成功:返回值依 request 而定
// 失败:返回 -1,设置 errno
参数说明:
fd:要操作的文件描述符request:操作码(设备特定头文件中定义的常量)argp:可选参数,通常是指向整数或结构体的指针ioctl()是一个"万能接口",具体能做什么完全取决于fd引用的文件类型和request值。SUSv3 只规范了 STREAMS 相关的ioctl()操作,其他操作均不在标准内。
4.9 章节总结
核心设计理念回顾:
- 统一 I/O(Universality):同一套调用操作所有文件类型
- 文件描述符:内核管理的小整数,进程通过它引用打开的文件
- 文件偏移量:内核自动维护,
lseek()可手动调整 - 文件空洞:偏移量跳过文件末尾写入,空洞不占磁盘空间
- write 成功 ≠ 到磁盘:内核有 I/O 缓冲机制(第13章详解)
4.10 练习题解答
练习 4-1:实现 tee 命令
tee 从标准输入读取,同时写入标准输出和指定文件:
标准输入 → tee → 标准输出(终端显示)
└→ 指定文件(保存副本)
// tee.c:实现 tee 命令
// 用法:./tee [-a] 文件名
// -a 选项:追加模式(不覆盖已有内容)
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
static void printUsage(const char* prog) {
fprintf(stderr, "用法: %s [-a] 文件名\n", prog);
exit(EXIT_FAILURE);
}
int main(int argc, char* argv[]) {
bool appendMode = false;
// 解析 -a 选项
int opt;
while ((opt = getopt(argc, argv, "a")) != -1) {
switch (opt) {
case 'a':
appendMode = true; // 追加模式
break;
default:
printUsage(argv[0]);
}
}
// optind 是 getopt 处理后剩余参数的起始索引
if (optind >= argc)
printUsage(argv[0]);
const char* outFile = argv[optind];
// 根据是否追加模式,选择不同的 open 标志
int openFlags = O_WRONLY | O_CREAT | (appendMode ? O_APPEND : O_TRUNC);
// 文件权限:rw-rw-rw-(受 umask 约束)
mode_t filePerms = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP |
S_IROTH | S_IWOTH;
int outFd = open(outFile, openFlags, filePerms);
if (outFd == -1) {
perror("open 输出文件失败");
exit(EXIT_FAILURE);
}
// 循环读取标准输入,写到标准输出和目标文件
char buf[4096];
ssize_t numRead;
while ((numRead = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
// 写到标准输出(描述符 1)
if (write(STDOUT_FILENO, buf, numRead) != numRead) {
perror("写标准输出失败");
exit(EXIT_FAILURE);
}
// 写到目标文件
if (write(outFd, buf, numRead) != numRead) {
perror("写目标文件失败");
exit(EXIT_FAILURE);
}
}
if (numRead == -1) {
perror("读标准输入失败");
exit(EXIT_FAILURE);
}
if (close(outFd) == -1) {
perror("close 失败");
exit(EXIT_FAILURE);
}
return EXIT_SUCCESS;
}
使用示例:
# 编译
g++ -o tee tee.cpp
# 将 ls 输出同时显示在终端并保存到文件
ls -l | ./tee output.txt
# 追加模式
echo "新增行" | ./tee -a output.txt
练习 4-2:支持文件空洞的 cp 命令
普通 cp 复制含空洞的文件时,会将空洞中的零字节实际写入目标文件,导致目标文件比源文件占用更多磁盘空间。改进版本检测空洞(全零块)并用 lseek() 跳过,保留空洞特性:
// cp_hole.c:保留文件空洞的 cp 命令
// 用法:./cp_hole 源文件 目标文件
// 原理:读到全零块时,用 lseek() 跳过而不写入,
// 从而在目标文件中重建空洞
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#define BUF_SIZE 4096
// 检查缓冲区是否全为零(即是否是空洞区域)
static bool isAllZero(const char* buf, ssize_t len) {
for (ssize_t i = 0; i < len; i++) {
if (buf[i] != '\0')
return false;
}
return true;
}
int main(int argc, char* argv[]) {
if (argc != 3) {
fprintf(stderr, "用法: %s 源文件 目标文件\n", argv[0]);
exit(EXIT_FAILURE);
}
// 打开源文件(只读)
int inFd = open(argv[1], O_RDONLY);
if (inFd == -1) { perror("open 源文件"); exit(EXIT_FAILURE); }
// 打开目标文件(创建/覆盖,读写)
// 需要读写模式是因为 lseek 后面可能需要写最后一字节确认文件大小
int outFd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH);
if (outFd == -1) { perror("open 目标文件"); exit(EXIT_FAILURE); }
char buf[BUF_SIZE];
ssize_t numRead;
bool lastWasHole = false; // 记录上一块是否是空洞
while ((numRead = read(inFd, buf, BUF_SIZE)) > 0) {
if (isAllZero(buf, numRead)) {
// 全零块:用 lseek 跳过,在目标文件中创建空洞
if (lseek(outFd, numRead, SEEK_CUR) == -1) {
perror("lseek"); exit(EXIT_FAILURE);
}
lastWasHole = true;
} else {
// 有实际数据:正常写入
if (write(outFd, buf, numRead) != numRead) {
perror("write"); exit(EXIT_FAILURE);
}
lastWasHole = false;
}
}
if (numRead == -1) { perror("read"); exit(EXIT_FAILURE); }
// 特殊处理:如果文件以空洞结尾,需要写一个零字节确保文件大小正确
// 否则 lseek 超过 EOF 但没有 write,文件大小不会扩展
if (lastWasHole) {
if (lseek(outFd, -1, SEEK_CUR) == -1) {
perror("lseek"); exit(EXIT_FAILURE);
}
char zero = '\0';
if (write(outFd, &zero, 1) != 1) {
perror("write 结尾零字节"); exit(EXIT_FAILURE);
}
}
if (close(inFd) == -1) { perror("close inFd"); exit(EXIT_FAILURE); }
if (close(outFd) == -1) { perror("close outFd"); exit(EXIT_FAILURE); }
return EXIT_SUCCESS;
}
验证效果:
# 编译
g++ -o cp_hole cp_hole.cpp
# 用 seek_io 创建含空洞的源文件
./seek_io tfile s100000 wabc
ls -ls tfile
# 显示:8 -rw-rw-rw- 1 user 100003 tfile(只占约4096字节磁盘空间)
# 用普通 cp 复制(空洞被填充实际零字节)
cp tfile tfile_normal
ls -ls tfile_normal
# 显示:200 -rw-rw-r-- 1 user 100003 tfile_normal(占约100KB)
# 用 cp_hole 复制(保留空洞)
./cp_hole tfile tfile_hole
ls -ls tfile_hole
# 显示:8 -rw-rw-rw- 1 user 100003 tfile_hole(只占约4096字节)
Linux 文件 I/O 深度解析
原文来自《The Linux Programming Interface》第 5 章
1. 原子性与竞态条件
什么是原子性?
原子性(Atomicity):系统调用的所有步骤作为一个不可分割的整体执行,中途不会被其他进程或线程打断。
竞态条件(Race Condition):两个进程操作共享资源时,结果取决于 CPU 调度顺序,产生不确定的错误。
案例一:独占创建文件的竞态问题
错误做法(两步操作,非原子)
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
int main(int argc, char* argv[]) {
// 第一步:检查文件是否存在
int fd = open(argv[1], O_WRONLY);
if (fd != -1) {
// 文件已存在
printf("[PID %ld] 文件 \"%s\" 已存在\n", (long)getpid(), argv[1]);
close(fd);
} else {
if (errno != ENOENT) {
perror("open");
exit(EXIT_FAILURE);
} else {
// !!!危险窗口:此处可能被其他进程抢先创建文件!!!
// 第二步:创建文件
fd = open(argv[1], O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 此处声明"我创建了文件"可能是错误的!
printf("[PID %ld] 独占创建文件 \"%s\"\n", (long)getpid(), argv[1]);
}
}
return 0;
}
竞态时序图
进程 A 进程 B
| |
| open(file, O_WRONLY) |
| --> 返回 -1(文件不存在) |
| |
| [时间片到期,CPU 切换给 B] |
| | <-- 进程 B 开始运行
| | open(file, O_WRONLY)
| | --> 返回 -1
| | open(file, O_WRONLY|O_CREAT)
| | --> 成功,文件被 B 创建
| |
| [CPU 切回 A] |
| open(file, O_WRONLY|O_CREAT) |
| --> 也成功!(文件已存在也能打开)
|
| A 错误地以为自己创建了文件!!
正确做法(原子操作)
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
int main(int argc, char* argv[]) {
// O_CREAT | O_EXCL 合并为单个原子操作:
// "如果文件不存在则创建,否则报错" —— 两步合一,不可中断
int fd = open(argv[1], O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR);
if (fd == -1) {
if (errno == EEXIST) {
printf("文件已存在\n");
} else {
perror("open");
exit(EXIT_FAILURE);
}
} else {
printf("[PID %ld] 独占创建成功\n", (long)getpid());
close(fd);
}
return 0;
}
案例二:多进程追加文件的竞态问题
错误做法
#include <unistd.h>
#include <cstdio>
void bad_append(int fd, const char* buf, size_t len) {
// 第一步:移动到文件末尾
if (lseek(fd, 0, SEEK_END) == -1) {
perror("lseek");
return;
}
// 危险!此处若被另一进程抢先写入,偏移量就过时了!
// 第二步:写入数据
if (write(fd, buf, len) != (ssize_t)len) {
fprintf(stderr, "写入失败或不完整\n");
}
}
问题:进程 A 执行完 lseek 后,进程 B 也写了数据,文件变长了。进程 A 恢复后,用旧的偏移量写入,就会覆盖 B 刚写的内容。
正确做法:O_APPEND 标志
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
int main() {
// O_APPEND 使每次 write() 之前的"移到末尾"和"写入"合并为原子操作
int fd = open("logfile.txt", O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
const char* msg = "一条日志\n";
write(fd, msg, 9); // 安全!内核保证原子追加
close(fd);
return 0;
}
注意:NFS 文件系统不支持
O_APPEND的原子性,会退化为非原子的两步操作。
2. fcntl():文件控制操作
fcntl() 是一个多功能的文件控制系统调用:
int fcntl(int fd, int cmd, ...);
| 参数 | 说明 |
|---|---|
fd |
目标文件描述符 |
cmd |
操作类型(如 F_GETFL、F_SETFL 等) |
... |
根据 cmd 决定是否需要第三个参数 |
读取和修改文件状态标志
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
int main() {
int fd = open("test.txt", O_RDWR);
if (fd == -1) { perror("open"); exit(EXIT_FAILURE); }
// 读取当前标志
int flags = fcntl(fd, F_GETFL);
if (flags == -1) { perror("fcntl"); exit(EXIT_FAILURE); }
// 检查是否以同步写方式打开
if (flags & O_SYNC)
printf("同步写模式\n");
// 检查访问模式(读/写/读写)
// O_RDONLY=0, O_WRONLY=1, O_RDWR=2 不是独立位,需用 O_ACCMODE 掩码提取
int accessMode = flags & O_ACCMODE;
if (accessMode == O_RDWR)
printf("读写模式\n");
// 动态添加 O_APPEND 标志(不重新打开文件)
flags |= O_APPEND;
if (fcntl(fd, F_SETFL, flags) == -1) { perror("fcntl"); exit(EXIT_FAILURE); }
printf("已开启追加模式\n");
close(fd);
return 0;
}
可修改的标志:O_APPEND、O_NONBLOCK、O_NOATIME、O_ASYNC、O_DIRECT
3. 文件描述符、打开文件描述、i-node 的关系
内核用三层结构管理文件:
进程 A 的文件描述符表 系统级打开文件表 i-node 表
┌──────────────────────┐ ┌─────────────────────┐ ┌──────────────┐
│ fd 0 → [flags|ptr]──┼────→ │ 描述 #0 │──→ │ i-node #100 │
│ fd 1 → [flags|ptr]──┼──┐ │ offset=0 │ │ 文件类型 │
│ fd 2 → [flags|ptr]──┼──┘ │ status_flags │ │ 权限 │
│ fd 20→ [flags|ptr]──┼──┘ │ inode_ptr │ │ 大小/时间戳 │
└──────────────────────┘ ↑ └─────────────────────┘ └──────────────┘
│
进程 B 的文件描述符表 │ ┌─────────────────────┐ ┌──────────────┐
┌──────────────────────┐ │ │ 描述 #1 │──→ │ i-node #200 │
│ fd 0 → [flags|ptr]──┼──┼──→│ offset=73 │ │ │
│ fd 2 → [flags|ptr]──┼──┘ │ status_flags │ └──────────────┘
└──────────────────────┘ └─────────────────────┘
| 层次 | 存储位置 | 包含信息 |
|---|---|---|
| 文件描述符表 | 每个进程独立 | close-on-exec 标志、指向打开文件描述的指针 |
| 打开文件描述表 | 内核全局共享 | 当前文件偏移量、状态标志、访问模式、i-node 指针 |
| i-node 表 | 内核全局共享 | 文件类型、权限、锁、大小、时间戳 |
关键结论
- 同一打开文件描述的多个 fd(如
dup()产生的):共享偏移量和状态标志 - 不同打开文件描述指向同一 i-node(如两次独立
open()同一文件):各自有独立偏移量 - close-on-exec 标志属于 fd 级别,私有,不共享
4. 复制文件描述符
dup() 和 dup2()
#include <unistd.h>
#include <fcntl.h>
#include <cstdio>
#include <cstdlib>
int main() {
// 打开一个文件
int fd1 = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
if (fd1 == -1) { perror("open"); exit(EXIT_FAILURE); }
// dup():返回最小可用的新 fd,与 fd1 共享同一打开文件描述
int fd2 = dup(fd1);
printf("fd1=%d, fd2=%d\n", fd1, fd2); // fd1=3, fd2=4(通常)
// dup2():指定新 fd 的编号,常用于 I/O 重定向
// 将标准输出(fd=1)重定向到 fd1 指向的文件
if (dup2(fd1, STDOUT_FILENO) == -1) { perror("dup2"); exit(EXIT_FAILURE); }
// 现在 printf 输出到 output.txt
printf("这行写到文件里\n");
close(fd1);
close(fd2);
return 0;
}
Shell 重定向原理
命令 ./prog > result.log 2>&1 的执行顺序:
第一步:> result.log
fd[1] (stdout) --> result.log 的打开文件描述
第二步:2>&1
dup2(1, 2)
fd[2] (stderr) --> 同 fd[1] --> result.log 的打开文件描述
结果:stdout 和 stderr 都写入同一个 result.log
各种复制方式对比
| 函数/操作 | 语法 | 特点 |
|---|---|---|
dup(oldfd) |
返回最小可用 fd | 简单 |
dup2(oldfd, newfd) |
指定新 fd 编号 | 若 newfd 已开则先关闭 |
dup3(oldfd, newfd, O_CLOEXEC) |
同 dup2,可设 close-on-exec | Linux 2.6.27+,Linux 专属 |
fcntl(oldfd, F_DUPFD, startfd) |
从 startfd 起找最小可用 fd | 灵活指定范围 |
fcntl(oldfd, F_DUPFD_CLOEXEC, startfd) |
同上,自动设 close-on-exec | Linux 2.6.24+ |
5. 指定偏移量的 I/O:pread() 和 pwrite()
这两个调用在指定位置读写,但不改变文件当前偏移量:
#include <unistd.h>
#include <fcntl.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
int main() {
int fd = open("data.bin", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1) { perror("open"); exit(EXIT_FAILURE); }
// 在偏移量 100 处写入,不影响当前 offset
const char* msg = "Hello";
ssize_t written = pwrite(fd, msg, strlen(msg), 100);
printf("写入 %zd 字节到偏移 100\n", written);
// 当前 offset 仍为 0(pwrite 不移动它)
char buf[6] = {0};
ssize_t nread = pread(fd, buf, 5, 100); // 从偏移 100 读
printf("从偏移 100 读到:%s\n", buf);
close(fd);
return 0;
}
pread() 等价于以下原子操作(但实际是一次系统调用,效率更高):
pread ( f d , b u f , c o u n t , o f f s e t ) ≡ { orig = lseek ( f d , 0 , S E E K _ C U R ) lseek ( f d , o f f s e t , S E E K _ S E T ) read ( f d , b u f , c o u n t ) lseek ( f d , o r i g , S E E K _ S E T ) \text{pread}(fd, buf, count, offset) \equiv \begin{cases} \text{orig} = \text{lseek}(fd, 0, SEEK\_CUR) \\ \text{lseek}(fd, offset, SEEK\_SET) \\ \text{read}(fd, buf, count) \\ \text{lseek}(fd, orig, SEEK\_SET) \end{cases} pread(fd,buf,count,offset)≡⎩
⎨
⎧orig=lseek(fd,0,SEEK_CUR)lseek(fd,offset,SEEK_SET)read(fd,buf,count)lseek(fd,orig,SEEK_SET)
多线程场景优势:所有线程共享同一文件偏移量,若用 lseek + read 会有竞态,pread/pwrite 天然避免此问题。
6. 分散-聚集 I/O:readv() 和 writev()
一次系统调用完成多个缓冲区的读写:
#include <sys/uio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
int main() {
// === writev 示例:聚集输出 ===
int fd = open("scatter.dat", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
if (fd == -1) { perror("open"); exit(EXIT_FAILURE); }
char header[] = "HEAD";
int count = 42;
char trailer[] = "TAIL";
// 定义三个缓冲区
struct iovec iov[3];
iov[0].iov_base = header; // 缓冲区起始地址
iov[0].iov_len = sizeof(header); // 缓冲区字节数
iov[1].iov_base = &count;
iov[1].iov_len = sizeof(count);
iov[2].iov_base = trailer;
iov[2].iov_len = sizeof(trailer);
// 一次系统调用,原子写入三个缓冲区(按 iov[0]->iov[1]->iov[2] 顺序)
ssize_t total = writev(fd, iov, 3);
printf("总共写入 %zd 字节\n", total);
close(fd);
// === readv 示例:分散输入 ===
fd = open("scatter.dat", O_RDONLY);
if (fd == -1) { perror("open"); exit(EXIT_FAILURE); }
char r_header[5] = {0};
int r_count = 0;
char r_trailer[5] = {0};
struct iovec riov[3];
riov[0].iov_base = r_header; riov[0].iov_len = sizeof(r_header);
riov[1].iov_base = &r_count; riov[1].iov_len = sizeof(r_count);
riov[2].iov_base = r_trailer; riov[2].iov_len = sizeof(r_trailer);
// 一次读取,自动分发到三个缓冲区
ssize_t nr = readv(fd, riov, 3);
printf("读取 %zd 字节:header=%s, count=%d, trailer=%s\n",
nr, r_header, r_count, r_trailer);
close(fd);
return 0;
}
数据布局示意:
文件内容(连续字节):
[HEAD\0][00 00 00 2A][TAIL\0]
↑ ↑ ↑
iov[0] iov[1] iov[2]
优势:
- 便利:避免手动拼接缓冲区
- 原子性:writev 的写入是原子的,不会与其他进程的写入交错
- 性能: 1 1 1 次系统调用 < n n n 次
write()调用(减少用户态/内核态切换开销)
7. 文件截断:truncate() 和 ftruncate()
#include <unistd.h>
#include <fcntl.h>
#include <cstdio>
#include <cstdlib>
int main() {
// truncate():通过路径名截断,文件必须可写
if (truncate("bigfile.dat", 1024) == -1) {
perror("truncate");
}
// 若原来 > 1024 字节:超出部分丢失
// 若原来 < 1024 字节:用空字节(\0)填充至 1024 字节(产生"文件空洞")
// ftruncate():通过已打开的文件描述符截断
int fd = open("anotherfile.dat", O_WRONLY);
if (fd == -1) { perror("open"); exit(EXIT_FAILURE); }
if (ftruncate(fd, 512) == -1) {
perror("ftruncate");
}
// 注意:ftruncate 不改变文件的当前偏移量
close(fd);
return 0;
}
| 特性 | truncate() | ftruncate() |
|---|---|---|
| 指定文件方式 | 路径名 | 文件描述符 |
| 文件是否需先 open | 不需要 | 需要(且必须以写方式打开) |
| 符号链接 | 自动解引用 | - |
| 改变文件偏移量 | - | 不改变 |
8. 非阻塞 I/O
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
int main() {
// 打开时指定 O_NONBLOCK
int fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
char buf[128];
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 没有数据可读,非阻塞返回(而不是卡住等待)
printf("暂时没有数据,稍后再试\n");
} else {
perror("read");
}
}
close(fd);
return 0;
}
- 对普通磁盘文件:
O_NONBLOCK通常无效(内核缓冲区保证不阻塞) - 对管道、FIFO、socket、终端:有效
9. 大文件 I/O(LFS)
32 位系统中 off_t 是有符号长整型,最大表示 2 31 − 1 = 2 147 483 647 2^{31} - 1 = 2\,147\,483\,647 231−1=2147483647 字节(约 2 GB)。
推荐方式:定义 _FILE_OFFSET_BITS=64
// 编译时加 -D_FILE_OFFSET_BITS=64,或在代码最前面定义:
#define _FILE_OFFSET_BITS 64
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
int main() {
// off_t 现在是 64 位,最大 2^63-1 字节
int fd = open("hugefile", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1) { perror("open"); exit(EXIT_FAILURE); }
off_t bigOffset = 10000000000LL; // 10 GB
if (lseek(fd, bigOffset, SEEK_SET) == -1) {
perror("lseek");
exit(EXIT_FAILURE);
}
write(fd, "end", 3);
// 打印 off_t 要转成 long long 再用 %lld
printf("偏移量:%lld\n", (long long)bigOffset);
close(fd);
return 0;
}
编译命令:
gcc -D_FILE_OFFSET_BITS=64 -o largefile largefile.cpp
10. /dev/fd 虚拟目录
内核为每个进程提供 /dev/fd/ 目录,其中 /dev/fd/n 对应该进程打开的第 n 个文件描述符。
打开 /dev/fd/1 ≡ dup(1) (复制标准输出)
实用场景:某些命令只接受文件名参数,使用 /dev/fd/0 代替标准输入:
# 传统方式(依赖程序支持 "-" 表示 stdin)
ls | diff - oldfilelist
# 用 /dev/fd 更通用
ls | diff /dev/fd/0 oldfilelist
| 快捷链接 | 等价于 |
|---|---|
/dev/stdin |
/dev/fd/0(标准输入) |
/dev/stdout |
/dev/fd/1(标准输出) |
/dev/stderr |
/dev/fd/2(标准错误) |
11. 创建临时文件
mkstemp():安全的临时文件
#include <cstdlib>
#include <unistd.h>
#include <cstdio>
int main() {
// 模板末尾必须是 6 个 X,会被替换为随机字符串
char tmpl[] = "/tmp/myapp_XXXXXX";
// mkstemp 原子地生成唯一名称并打开(O_EXCL 保证独占)
int fd = mkstemp(tmpl);
if (fd == -1) { perror("mkstemp"); return 1; }
printf("生成的临时文件名:%s\n", tmpl);
// 立即 unlink:文件名从目录中消失,但 fd 仍可用
// 当 fd 关闭后,文件内容才真正被删除
unlink(tmpl);
// 正常使用 fd 读写...
write(fd, "临时数据", 8);
close(fd); // 关闭后文件彻底消失
return 0;
}
tmpfile():stdio 临时文件流
#include <cstdio>
int main() {
// 创建匿名临时文件,返回 FILE*,关闭时自动删除
FILE* fp = tmpfile();
if (!fp) { perror("tmpfile"); return 1; }
fprintf(fp, "临时内容\n");
rewind(fp);
char buf[64];
fgets(buf, sizeof(buf), fp);
printf("读回:%s", buf);
fclose(fp); // 自动删除文件
return 0;
}
| 函数 | 返回值 | 自动删除 | 推荐度 |
|---|---|---|---|
mkstemp() |
文件描述符(int) | 需手动 unlink | 推荐 |
tmpfile() |
FILE 指针 | 关闭时自动 | 推荐 |
tmpnam() / tempnam() / mktemp() |
文件名字符串 | 不处理 | 不推荐(有安全漏洞) |
总结
原子性
├── O_CREAT | O_EXCL → 独占创建文件
└── O_APPEND → 安全追加(lseek+write 合一)
fcntl()
├── F_GETFL / F_SETFL → 读取/修改状态标志
└── F_DUPFD → 复制文件描述符
三层结构
├── fd 表(进程私有) → close-on-exec 标志
├── 打开文件描述(共享)→ 偏移量、状态标志
└── i-node(共享) → 文件元数据
高级 I/O
├── pread/pwrite → 指定偏移,不移动当前 offset
├── readv/writev → 分散-聚集,原子,减少系统调用
└── preadv/pwritev → 二者结合
大文件:_FILE_OFFSET_BITS=64(off_t 变 64 位)
临时文件:mkstemp()(fd)/ tmpfile()(FILE*)
Linux 进程深度解析
原文来自《The Linux Programming Interface》第 6 章
1. 程序与进程的区别
程序(Program):磁盘上的一个文件,描述了如何构建一个进程。包含:
- 二进制格式标识(现代 Linux 用 ELF 格式)
- 机器语言指令(算法的编码)
- 程序入口地址(从哪里开始执行)
- 数据(全局变量初始值、字符串常量等)
- 符号和重定位表(调试、动态链接用)
- 共享库信息(需要哪些 .so 文件)
进程(Process):程序的一次运行实例。内核为其分配资源(虚拟内存、文件描述符表、信号处理信息等)。
一个程序 --> 可以产生多个进程
多个进程 --> 可以运行同一个程序
2. 进程 ID 与父进程 ID
每个进程有唯一的 PID(进程 ID),是一个正整数。
#include <unistd.h>
#include <cstdio>
int main() {
// getpid():获取当前进程的 PID
pid_t myPid = getpid();
printf("我的进程 ID:%d\n", myPid);
// getppid():获取父进程的 PID
pid_t parentPid = getppid();
printf("我的父进程 ID:%d\n", parentPid);
return 0;
}
PID 的规则
- Linux 默认最大 PID 为 32,767(可通过
/proc/sys/kernel/pid_max调整) - 64 位系统最大可调至 2 22 ≈ 4 , 000 , 000 2^{22} \approx 4,000,000 222≈4,000,000
- 达到上限后从 300 开始重用(跳过 1~299,因为低编号被系统进程占用)
- PID = 1 是
init进程,所有进程的祖先
进程树结构
孤儿进程:父进程先死,子进程被 init(PID=1)收养,此后 getppid() 返回 1。
3. 进程的内存布局
一个进程的虚拟内存分为若干段(Segment):
高地址
┌─────────────────────────────┐ 0xC0000000(内核空间,用户不可访问)
│ 内核空间 │
├─────────────────────────────┤
│ argv / environ(命令行参数和环境变量)│
├─────────────────────────────┤ 栈顶
│ │
│ 栈 Stack(向下增长) │
│ │
│ ↓ ↓ ↓ ↓ ↓ ↓ ↓ │
├─────────────────────────────┤
│ (未分配,可增长区域) │
├─────────────────────────────┤
│ ↑ ↑ ↑ ↑ ↑ ↑ ↑ │
│ │
│ 堆 Heap(向上增长) │
│ │ <- program break(堆顶)
├─────────────────────────────┤ &end
│ 未初始化数据段 BSS │ 全局/静态未初始化变量,运行时清零
├─────────────────────────────┤ &edata
│ 已初始化数据段 │ 全局/静态已初始化变量
├─────────────────────────────┤ &etext
│ 代码段 Text(只读) │ 机器指令,可共享
├─────────────────────────────┤ 0x08048000
低地址
各段详解
| 段名 | 存放内容 | 特点 |
|---|---|---|
| 代码段 Text | 程序机器指令 | 只读,多进程共享同一份 |
| 已初始化数据段 | 显式赋初值的全局/静态变量 | 从可执行文件加载 |
| 未初始化数据段 BSS | 未赋初值的全局/静态变量 | 运行时清零,不占磁盘空间 |
| 栈 Stack | 函数局部变量、参数、返回值 | 向低地址增长 |
| 堆 Heap | 动态分配内存(malloc) | 向高地址增长 |
对应代码示例
#include <cstdio>
#include <cstdlib>
// 全局,未初始化 --> BSS 段(运行时自动清零)
char globBuf[65536];
// 全局,已初始化 --> 已初始化数据段
int primes[] = { 2, 3, 5, 7 };
static int square(int x) {
// result:栈帧中的局部变量(square 的栈帧)
int result = x * x;
return result; // 返回值通过寄存器传递
}
static void doCalc(int val) {
// val:doCalc 栈帧中的参数
printf("The square of %d is %d\n", val, square(val));
if (val < 1000) {
// t:doCalc 栈帧中的局部变量
int t = val * val * val;
printf("The cube of %d is %d\n", val, t);
}
}
int main(int argc, char* argv[]) {
// key:已初始化数据段(static 关键字)
static int key = 9973;
// mbuf:BSS 段(static,未初始化)
static char mbuf[10240000];
// p:栈帧(main 的局部变量)
char* p;
// malloc 分配的内存在堆上,p 指向它
p = (char*)malloc(1024);
doCalc(key);
free(p);
return 0;
}
为什么 10 MB 的 mbuf 数组不会让可执行文件变大?
因为 mbuf 在 BSS 段(未初始化),可执行文件只记录"需要 10MB 空间"这个信息,实际内存由程序加载器在运行时分配并清零,磁盘上不存储这些零。
4. 虚拟内存管理
核心思想
程序具有局部性(Locality of Reference):
- 空间局部性:访问了地址 X,很快会访问 X 附近的地址(顺序执行)
- 时间局部性:刚访问过的地址,不久后很可能再次访问(循环)
利用局部性,操作系统只需将程序的一部分放在物理内存中运行。
分页机制
程序虚拟地址空间(很大) 物理内存 RAM(有限)
┌──────────────┐ ┌──────────────┐
│ page 0 │──页表映射──────→ │ page frame 1│
│ page 1 │──────────────── │ page frame 3│
│ page 2 │ (在磁盘上) │ page frame 7│
│ page 3 │──────────────→ │ page frame 4│
│ page 4 │ (在磁盘上) └──────────────┘
│ ... │
└──────────────┘ swap 区(磁盘)
┌──────────────┐
│ page 2 备份 │
│ page 4 备份 │
└──────────────┘
- 页(Page):虚拟地址空间的固定大小单元,x86-32 上为 4096 字节( 4 KB 4 \text{ KB} 4 KB)
- 页框(Page Frame):物理内存的固定大小单元,大小与页相同
- 缺页错误(Page Fault):访问的页不在物理内存中,内核从磁盘加载
虚拟内存的优势
- 进程间相互隔离,无法访问对方内存
- 多进程可共享同一份代码(代码段只读共享)
- 支持内存保护(可读/可写/可执行权限独立控制)
- 进程的虚拟地址空间可以超过物理内存大小
5. 栈与栈帧
栈向低地址增长,每次函数调用压入一个栈帧(Stack Frame),函数返回时弹出。
每个栈帧包含:
- 函数的局部变量(自动变量)
- 函数的参数
- 调用链接信息(返回地址、寄存器保存副本)
执行 square() 时的栈状态
高地址
┌──────────────────────┐
│ C 运行时启动函数帧 │
├──────────────────────┤
│ main() 的栈帧 │ <- argc, argv, key, p, mbuf...
├──────────────────────┤
│ doCalc() 的栈帧 │ <- val, t(如果 val<1000)
├──────────────────────┤
│ square() 的栈帧 │ <- x, result <-- 栈指针(SP)指向这里
└──────────────────────┘
低地址(栈向下增长)
6. 命令行参数 argc 和 argv
#include <cstdio>
#include <cstdlib>
int main(int argc, char* argv[]) {
// argc:参数个数(包含程序名本身)
// argv[0]:程序名
// argv[1] ~ argv[argc-1]:实际参数
// argv[argc]:NULL(哨兵值)
for (int j = 0; j < argc; j++) {
printf("argv[%d] = %s\n", j, argv[j]);
}
// 另一种写法:用指针遍历,遇到 NULL 停止
char** p;
for (p = argv; *p != NULL; p++) {
puts(*p);
}
return 0;
}
执行 ./necho hello world 时的内存结构:
argc = 3
argv:
argv[0] --> "necho\0"
argv[1] --> "hello\0"
argv[2] --> "world\0"
argv[3] --> NULL
小技巧:多个程序名(硬链接)指向同一可执行文件,通过检查 argv[0] 决定行为。例如 gzip、gunzip、zcat 都是同一个可执行文件。
7. 环境变量
每个进程有一个环境列表,形如 NAME=value 的字符串数组。子进程继承父进程环境的副本(单向、一次性传递)。
数据结构
environ(全局指针)
|
v
[0] --> "LOGNAME=mtk\0"
[1] --> "SHELL=/bin/bash\0"
[2] --> "HOME=/home/mtk\0"
[3] --> "PATH=/usr/local/bin:/usr/bin:/bin:.\0"
[4] --> "TERM=xterm\0"
[5] --> NULL
读取环境变量
#include <cstdlib>
#include <cstdio>
extern char** environ; // C 运行时提供的全局变量
int main() {
// 方法一:遍历 environ 打印全部
char** ep;
for (ep = environ; *ep != NULL; ep++) {
puts(*ep);
}
// 方法二:getenv() 获取单个变量的值
// 返回指向值字符串的指针(不要修改它!),找不到返回 NULL
char* shell = getenv("SHELL");
if (shell != NULL) {
printf("当前 shell:%s\n", shell);
}
return 0;
}
https://godbolt.org/z/acbnv4Paz
修改环境变量
#include <cstdlib>
#include <cstdio>
extern char** environ;
int main() {
// putenv():添加或修改,直接使用传入的字符串(不复制!)
// 注意:不能用栈上的自动变量,因为函数返回后会失效
putenv((char*)"MYVAR=hello"); // 传入 name=value 格式
// setenv():更安全,内部复制字符串,不依赖原指针
// 参数:变量名, 值, 是否覆盖(0=不覆盖,非0=覆盖)
setenv("GREET", "Hello world", 0); // 如果 GREET 已存在则不改变
// unsetenv():删除环境变量
unsetenv("BYE");
// clearenv():清空整个环境(等价于 environ = NULL)
// 注意:可能有内存泄漏问题(setenv 分配的缓冲区无法释放)
clearenv();
// 打印当前环境
for (char** ep = environ; ep && *ep != NULL; ep++) {
puts(*ep);
}
return 0;
}
函数对比
| 函数 | 作用 | 是否复制字符串 | 安全性 |
|---|---|---|---|
getenv(name) |
读取变量值 | - | 不要修改返回值 |
putenv("N=V") |
设置变量 | 不复制,直接用指针 | 不能传栈变量 |
setenv(name, val, overwrite) |
设置变量 | 内部复制 | 推荐 |
unsetenv(name) |
删除变量 | - | 推荐 |
clearenv() |
清空所有 | - | 注意内存泄漏 |
完整示例:修改环境变量
#define _GNU_SOURCE
#include <cstdlib>
#include <cstdio>
extern char** environ;
int main(int argc, char* argv[]) {
// 清空继承来的环境
clearenv();
// 把命令行参数作为 NAME=VALUE 加入环境
for (int j = 1; j < argc; j++) {
if (putenv(argv[j]) != 0) {
perror("putenv");
return 1;
}
}
// 如果 GREET 不存在(overwrite=0),则设为默认值
if (setenv("GREET", "Hello world", 0) == -1) {
perror("setenv");
return 1;
}
// 删除 BYE 变量
unsetenv("BYE");
// 打印当前环境
for (char** ep = environ; ep && *ep != NULL; ep++) {
puts(*ep);
}
return 0;
}
https://godbolt.org/z/K5Phsjnz7
8. 非局部跳转:setjmp() 和 longjmp()
为什么需要它?
C 的 goto 只能在同一函数内跳转。但在多层嵌套调用中遇到错误,逐层返回很繁琐:
main() --> funcA() --> funcB() --> funcC() --> 发现严重错误!
需要跳回 main()
setjmp/longjmp 提供了跨函数的"跳跃"能力,相当于"非局部 goto"。
基本原理
setjmp 保存:程序计数器(PC)、栈指针(SP)、其他寄存器。longjmp 恢复:弹出中间所有栈帧(“展开栈”),恢复到 setjmp 时的状态。
完整示例
#include <csetjmp>
#include <cstdio>
#include <cstdlib>
// env 必须是全局变量,因为 setjmp 和 longjmp 在不同函数中
static jmp_buf env;
static void f2() {
// 从 f2 跳回,val=2 表示"来自 f2"
longjmp(env, 2);
}
static void f1(int argc) {
if (argc == 1) {
// 从 f1 跳回,val=1 表示"来自 f1"
longjmp(env, 1);
}
f2(); // 否则调用 f2
}
int main(int argc, char* argv[]) {
// setjmp 第一次调用返回 0
// longjmp 跳回后,setjmp 返回 longjmp 传入的 val
switch (setjmp(env)) {
case 0:
// 初始调用,正常执行
printf("初始 setjmp 完成,调用 f1()\n");
f1(argc); // 这里会触发 longjmp,永不返回
break;
case 1:
printf("从 f1() 跳回来了\n");
break;
case 2:
printf("从 f2() 跳回来了\n");
break;
}
return 0;
}
https://godbolt.org/z/497jMbvM8
执行流程(无命令行参数时):
main()
setjmp(env) --> 返回 0,保存现场
调用 f1(1)
f1 中 argc==1,执行 longjmp(env, 1)
栈展开:f1 的栈帧被丢弃
跳回 main() 中 setjmp 的位置
setjmp "假装"返回 1
switch case 1:打印"从 f1() 跳回来了"
volatile 与编译器优化问题
longjmp 会恢复寄存器,导致优化后的局部变量值被"回滚"。用 volatile 强制变量放在内存(而非寄存器),避免被优化器错误处理。
先看 setjmp 保存了什么:
setjmp(env) 保存的内容
├── 程序计数器 PC(跳回哪一行)
├── 栈指针 SP(恢复调用栈)
├── 帧指针 BP
└── 被调用者保存寄存器(rbx, r12-r15 等)
不保存:调用者保存寄存器(rax, rcx, rdx, rsi, rdi 等)
问题就在这里。编译器优化时会把局部变量放进寄存器而不是栈:
int main() {
int val = 0; // 优化后放进 rax 寄存器,不在栈上
if (setjmp(env) == 0) {
val = 42; // rax = 42
f(); // 内部调用 longjmp
}
printf("%d\n", val); // 你以为是 42,实际可能是 0
}
执行流程:
① setjmp() → 保存现场,rax=0(此时 val=0)
② val = 42 → rax=42,但 env 里记录的还是 rax=0
③ longjmp() → 恢复 env 里的寄存器,rax 被回滚到 0
④ printf(val) → 打印 0,不是 42
解决方法是加 volatile:
volatile int val = 0; // 强制编译器每次从内存读写,不放寄存器
加了 volatile 之后:
① setjmp() → val 在栈内存里,值为 0
② val = 42 → 写入栈内存,值为 42
③ longjmp() → 只恢复寄存器,栈内存的 42 不受影响
④ printf(val) → 从内存读,正确打印 42
本质是:longjmp 恢复的是寄存器快照,栈内存不动。优化把变量从内存搬到寄存器,longjmp 一跳寄存器被覆盖,变量就跟着"回滚"了。
#include <csetjmp>
#include <cstdio>
#include <cstdlib>
static jmp_buf env;
static void doJump(int nvar, int rvar, int vvar) {
printf("doJump 内:nvar=%d rvar=%d vvar=%d\n", nvar, rvar, vvar);
longjmp(env, 1);
}
int main() {
int nvar; // 普通变量,可能被优化到寄存器
register int rvar; // 明确要求放寄存器
volatile int vvar; // 强制放内存,longjmp 后值保持正确
nvar = 111; rvar = 222; vvar = 333;
if (setjmp(env) == 0) {
// 修改后调用 doJump,触发 longjmp
nvar = 777; rvar = 888; vvar = 999;
doJump(nvar, rvar, vvar);
} else {
// 开启优化时:nvar 和 rvar 可能回退到 111, 222
// vvar 因为 volatile,始终正确保持 999
printf("longjmp 后:nvar=%d rvar=%d vvar=%d\n", nvar, rvar, vvar);
}
return 0;
}
编译对比:
# 无优化:三个变量都正确显示 777 888 999
g++ -o test test.cpp
# 有优化:nvar 和 rvar 可能回退为 111 和 222
g++ -O2 -o test test.cpp
setjmp/longjmp 使用限制与风险
| 限制/风险 | 说明 |
|---|---|
| setjmp 调用位置 | 只能用在 if/switch/while 条件中,或独立语句,不能赋值给变量 |
| 不能跳入已返回的函数 | longjmp 目标函数必须仍在调用栈上,否则行为未定义(崩溃或更糟) |
| 优化与 volatile | 使用 -O 编译时,非 volatile 局部变量值可能不正确 |
| 可读性极差 | 非局部跳转让控制流混乱,尽量避免使用 |
建议:能用正常返回值处理错误就用正常方式。
setjmp/longjmp仅在信号处理等极少数场景中使用(此时用sigsetjmp/siglongjmp)。
总结
进程 = 程序的运行实例
|
├── PID(唯一标识)+ PPID(父进程 ID)
| 进程树根节点是 init(PID=1)
|
├── 内存布局(虚拟地址空间)
| 代码段(只读共享)
| 已初始化数据段(全局/静态,有初值)
| BSS 段(全局/静态,无初值,运行时清零)
| 堆(malloc 动态分配,向上增长)
| 栈(函数调用,向下增长)
|
├── 虚拟内存管理
| 分页:程序按页加载,缺页时从磁盘换入
| 好处:隔离、共享、保护、支持超出物理内存
|
├── 命令行参数
| argc = 参数个数,argv[0] = 程序名
| argv[argc] = NULL(哨兵)
|
├── 环境变量
| NAME=VALUE 格式,子进程继承副本
| getenv/setenv/unsetenv/clearenv
|
└── setjmp / longjmp
跨函数非局部跳转
注意:volatile、不跳已返回的函数、避免滥用
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)