本文是《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 标准关系图(简化)

ANSI C 1989

POSIX.1-2001
SUSv3 UNIX03

POSIX.1 1988

POSIX.1 1996 ISO

POSIX.1b 1993
实时扩展

POSIX.1c 1995
线程

POSIX.2 1992
Shell工具

XPG3 1989

XPG4 1992

XPG4v2 1994
SUSv1 UNIX95

SUSv2 1997
UNIX98

ISO C99 1999

POSIX.1g 2000
Socket

POSIX.1-2008
SUSv4

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 等公司)是分离的。没有哪家发行版去做官方一致性认证,原因是:

  1. 费用高
  2. 每次新版本都要重新测试
    但 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。之后分裂出:

BSD Net/2 1991

386/BSD 1992

NetBSD 1993
强调可移植性

FreeBSD 1993
强调性能

OpenBSD 1996
强调安全

DragonFly BSD 2003
SMP架构新设计

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. 章节总结

用一张图串联全章核心脉络:

1969 Ken Thompson
Bell 实验室
第一个 UNIX

1973 Dennis Ritchie
设计 C 语言
UNIX 用 C 重写

1974 授权给大学
附带源代码

BSD 分支
伯克利

System V 分支
AT&T

1984 Stallman
GNU 项目

GCC bash glibc...
缺内核

1991 Torvalds
Linux 内核

GNU + Linux
完整操作系统

各 Linux 发行版

POSIX 1988

SUSv3 2001

SUSv4 2008

LSB 标准
二进制兼容

附:核心概念速查


概念 解释
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 表示压缩的可执行文件)

内核做哪些事?

Linux 内核

进程调度
决定谁用CPU、用多久

内存管理
虚拟内存、隔离进程

提供文件系统
创建/读写/删除文件

进程创建与终止
加载程序、回收资源

设备访问
统一接口访问硬件

网络
收发网络数据包

系统调用API
程序请求内核服务的入口

重点理解——虚拟内存管理的两大好处:

  1. 进程隔离:进程 A 读不到进程 B 的内存,也读不到内核的内存,保证安全
  2. 按需加载:进程不需要把全部代码加载进内存,只加载当前用到的部分,节省 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=rx 5other=rx 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 库函数(fopenprintfscanf 等),它们是对系统调用的封装:

应用程序
    |
    | 调用 fopen/printf/fread...
    v
stdio 库(glibc)
    |
    | 内部调用 open/write/read...
    v
内核系统调用

UNIX/Linux 的文件没有"文件结束符"字符——read() 返回 0 字节才表示到达文件末尾。

2.6 程序

程序有两种存在形式:

  • 源代码:人类可读的文本(如 C 语言)
  • 二进制机器码:CPU 可执行的指令
    源代码经过编译、链接转为二进制。脚本文件(Shell脚本、Python脚本)是例外——它们直接由解释器读取执行。
    过滤器(Filter):从 stdin 读数据、处理后写到 stdout 的程序,如 catgrepsortawk
    命令行参数: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/fna6ro1be
fork() 之后,子进程拥有父进程数据段、堆、栈的副本,代码段则共享(只读)。
执行新程序:execve()
execve() 用新程序替换当前进程的内存空间(代码、数据、堆、栈全部替换):

进程 A 调用 fork()
    └── 子进程 B(父的副本)
            └── 子进程 B 调用 execve("ls", ...)
                    └── 子进程 B 变成 ls 程序运行

进程 ID(PID)和父进程 ID(PPID)

  • 每个进程有唯一的整数 PID
  • 每个进程记录创建它的父进程的 PPID
  • 由此形成一棵进程树

init/systemd
PID=1

login
PID=100

bash
PID=200

ls
PID=300

grep
PID=301

sshd
PID=150

bash
PID=250

进程终止与退出状态

进程可通过两种方式终止:

  1. 主动:调用 _exit()exit(),指定退出状态码
  2. 被动:收到信号被杀死,终止状态由信号类型决定
    按惯例:
  • 退出状态 = 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 机制:

IPC机制

信号 Signal
通知事件发生

管道 Pipe / FIFO
在进程间传递数据流

套接字 Socket
同主机或跨网络通信

文件锁 File Locking
防止并发修改文件

消息队列 Message Queue
交换数据包

信号量 Semaphore
同步进程动作

共享内存 Shared Memory
最快的IPC,直接共享内存页

为何有这么多机制? 历史原因:

  • 管道、FIFO → 来自 System V
  • 套接字(Socket) → 来自 BSD
    两者功能有重叠(都能让同机器进程传数据),但因历史和标准原因,现代 UNIX/Linux 两种都保留了。

2.11 信号(Signal)

信号是"软件中断":通知进程某个事件或异常条件发生了。

谁发送信号?


发送方 场景举例
内核 进程访问非法内存地址(SIGSEGV)、子进程终止(SIGCHLD)
其他进程 kill 命令、程序调用 kill() 系统调用
进程自身 调用 raise() 向自己发信号
用户键盘 Ctrl+C → SIGINT,Ctrl+Z → SIGTSTP

进程收到信号的处理方式

  1. 默认动作:每种信号都有默认行为(如 SIGTERM 默认杀死进程)
  2. 忽略信号signal(SIGXXX, SIG_IGN)sigaction 设置忽略
  3. 自定义信号处理函数(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

进程组/作业

ls -l
PID=301

sort -k5n
PID=302

less
PID=303

进程组 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

全章概念关系图

内核

进程调度

内存管理
虚拟内存

文件系统

设备访问

网络

系统调用API

进程

fork 创建子进程

execve 执行新程序

进程间通信

信号

线程

管道/FIFO

套接字

共享内存

消息队列

信号量

进程组
作业控制

会话
控制终端

核心概念速查表


概念 简要说明
内核 管理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()
    三个关键特点:
  1. 模式切换:系统调用让 CPU 从用户模式切换到内核模式,才能访问受保护的内核内存
  2. 固定集合:系统调用集合是固定的,每个系统调用有唯一编号(程序用名字,内核用编号)
  3. 参数传递:参数在用户空间和内核空间之间双向传递

系统调用的执行流程(x86-32 为例)

下面以 execve() 为例(系统调用编号 = 11),展示从用户程序到内核的完整路径:

sys_execve() 服务例程 内核 system_call() CPU glibc 包装函数 应用程序 sys_execve() 服务例程 内核 system_call() CPU glibc 包装函数 应用程序 调用 execve(path, argv, envp) 将参数复制到寄存器 将系统调用编号11写入 %eax 执行 int 0x80 陷阱指令 切换到内核模式 跳转到 system_call() 保存寄存器到内核栈 检查系统调用编号合法性 查表 sys_call_table[11] 调用 sys_execve() 验证参数合法性 执行实际操作 返回结果状态 从内核栈恢复寄存器 切换回用户模式 返回包装函数 若返回值<0,设置 errno 返回 -1(失败)或结果值(成功)

逐步详解

第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 设备或资源忙

重要注意事项:

  1. 成功的系统调用不会重置 errno 为 0,所以 errno 可能保留上次错误的值
  2. 极少数成功调用也可能将 errno 设为非零值(SUSv3 允许)
  3. 正确顺序:先判断返回值是否为 − 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/q1KGfod8j
strerror():将错误号转换为描述字符串(返回的字符串可能是静态分配的,后续调用会覆盖)

#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 终止
flush stdio缓冲区

err_exit
用 _exit 终止
不flush缓冲区
不调用exit handler

errExitEN
指定 errnum
用于 pthreads

通用/命令行错误

fatal
不涉及 errno

usageErr
打印 Usage: 前缀

cmdLineErr
打印命令行错误前缀

各函数的适用场景:

函数 适用场景 终止方式
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)

问题:为什么不直接用 intlong
不同 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 可能是 intlongprintf 的格式符必须匹配,否则未定义行为。
解决方案:统一转为 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 章节总结

本章核心内容

系统调用

库函数

错误处理

可移植性

受控进入内核的入口

6步执行流程

有不可忽视的开销
约为普通函数的20倍

不调用系统调用的库函数
纯用户空间

封装系统调用的库函数
提供更友好接口

Linux常用实现: glibc

系统调用: 返回-1
设置 errno

perror/strerror
转换为可读描述

书中自定义函数体系
errMsg/errExit/err_exit等

特性测试宏
控制头文件暴露的定义

系统数据类型
pid_t uid_t off_t等

结构体初始化
用指定初始化器

第四章:文件 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
打开文件
得到fd

read
从fd读数据

write
向fd写数据

close
关闭fd
释放资源


系统调用 功能 成功返回 失败返回
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

关闭文件的意义:

  1. 释放文件描述符供后续使用(文件描述符是有限资源)
  2. 减少内核资源消耗
  3. 允许内核进行某些清理操作(如 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 四大系统调用

open(pathname, flags, mode)
打开/创建文件
返回文件描述符 fd

read(fd, buf, count)
读最多 count 字节
返回0=EOF

write(fd, buf, count)
写最多 count 字节
成功≠已到磁盘

close(fd)
释放文件描述符
必须检查返回值

lseek(fd, offset, whence)
调整文件偏移量
支持文件空洞

ioctl(fd, request, argp)
通用模型之外的特殊操作

核心设计理念回顾:

  • 统一 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_APPENDO_NONBLOCKO_NOATIMEO_ASYNCO_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 nwrite() 调用(减少用户态/内核态切换开销)

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 2311=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 2224,000,000
  • 达到上限后从 300 开始重用(跳过 1~299,因为低编号被系统进程占用)
  • PID = 1 是 init 进程,所有进程的祖先

进程树结构

init
PID=1

systemd
PID=xxx

login
PID=yyy

bash
PID=zzz

你的程序
PID=aaa

子进程
PID=bbb

孤儿进程:父进程先死,子进程被 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] 决定行为。例如 gzipgunzipzcat 都是同一个可执行文件。

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"。

基本原理

longjmp(env,2) 直接跳回

调用

调用

longjmp(env,2) 直接跳回

main()
setjmp(env)
保存当前状态

f1()

f2()

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、不跳已返回的函数、避免滥用
Logo

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

更多推荐