工程基础全面速查手册

速查总览:这一页怎么查

本页是工程基础的速查手册,按问题翻而不是从头读:每章第一张卡把该章的语法 / API / 命令 / 参数一次列全,后面的卡再展开语义与坑。这一章给三张索引——章节地图、这条路怎么走与工具链选型、标准与版本年表。

全页十八章,按「语言 → 内存 → 系统 → 网络 → 结构与算法 → 工程工具」排列。不必顺读:先在下表按关键词定位,再翻该章第一张速查表。

章节地图

章查什么关键词
01装工具链、编译运行命令、编译 / 链接 / 运行期三类报错反查gcc · a.out · 段错误
02类型与范围、运算符、控制流、printf / scanf 格式串全表int32_t · switch · %zu
03指针声明读法、malloc 家族、内存布局、内存 bug 症状反查* 与 & · free · 对齐
04宏与预处理、多文件与 Make / CMake、C99–C23 特性、进程与线程 API#define · Makefile · pthread
05深水区:标准库里最容易用错的那批函数strncpy · qsort · strtol
06按任务找头文件:C 标准库 API 速查stdio · string · time
07按任务找系统调用:POSIX 速查、errno 反查open · fork · EAGAIN
08socket 调用序列、分帧、并发模型、epoll、排错bind · 粘包 · epoll
09数据结构选型与复杂度,以及各自的 C 实现动态数组 · 哈希表 · 图
10复杂度分析与算法模板大 O · 二分 · DP
11编译开关、sanitizer、gdb、测试与覆盖率、怎么计时-Wall · ASan · gdb
12Linux 命令全表、Windows PowerShell 7 对照ls · grep · pwsh
13Shell 语法、参数扩展、健壮脚本set -euo · [[ ]] · $@
14Git 命令全表与撤销场景反查rebase · reset · reflog
15Docker 命令、Dockerfile 指令、Compose 字段build · volume · compose
16终端控制(ANSI / 原始模式 / ncurses)与 GUI 库对照转义序列 · ncurses · raylib
17按主题的资料与动手项目索引、自测清单CS:APP · Beej · 项目

三条最常见的查法

你手上有的去哪
一条报错原文,或一个崩溃现象01 章两张反查表;errno 值查 07 章,socket 症状查 08 章
一个函数名,想知道怎么用、在哪个头文件06 章(标准库)、07 章(POSIX);最容易用错的那批在 05 章
一条命令记不清参数Linux 与 PowerShell 12 章、Shell 13 章、Git 14 章、Docker 15 章,每章首卡都是命令全表
这十八章不是十八门并列的课,是一条路——本章后面那张十站表是它的骨架。查表时如果发现「这个概念前面某章讲过」,顺着括号里的章号跳过去看一眼:C 的指针、POSIX 的文件描述符、socket 的分帧、Git 的对象模型,都是后面反复要用的地基,跳过去的代价会在后面某一章加倍还回来。
本页以 C23 + POSIX + Linux 为基准:编译命令默认 gcc(clang 的开关基本通用),系统调用默认 Linux 行为,Windows 一律假设你在 WSL2 里。凡是较新的语言特性都在表里标了标准版本(C11 / C23)。

十八章串起来就是一句话:你写了一个小工具,它从第一行代码走到别人机器上跑起来。下表的每一站对应本页的一章。

十站主线

站要解决的问题章
① 跑通 hello装工具链、会读报错01
② 写代码类型、指针、结构体02 · 03
③ 编译链接多文件、Makefile、CMake04
④ 选对容器数组还是哈希表?O(n) 还是 O(log n)?09 · 10
⑤ 跑在 Linux 上文件描述符、权限、进程、管道07 · 12
⑥ 让别人连得上socket、分帧、并发模型08
⑦ 崩了怎么查测试先兜住,gdb / ASan 再定位11
⑧ 重复的活自动化构建 / 测试 / 部署脚本13
⑨ 敢改敢回退分支实验、历史排查14
⑩ 交付到别处镜像打包,终结「在我机器上能跑」15

迷失在某章细节里时,回到这条线问一句:「我学的这个东西,让哪一站不再卡住?」

为什么这条路从 C 出发

理由说明
它是理解机器的透镜指针、内存布局、未定义行为,都是别的语言替你藏掉的东西
它是系统接口的母语POSIX、socket、内核 API 全是 C 签名——任何语言的底层文档最终都指向这里
它足够小关键字二三十个,一周能看完全貌,剩下的时间都用在理解机器上
不等于不等于「必须先精通 C 才能往下走」:目标是拿 C 看懂内存,不是背全标准。复杂声明、宏技巧卡住超过一天就先跳过

工具链选型

平台装什么备注
Linuxsudo apt install build-essential gdb valgrind多半已自带 gcc
macOSxcode-select --installgcc 命令实际调用的是 clang;Valgrind 支持很差,用 ASan
WindowsWSL2(Ubuntu)最省事:本页 12–15 章的 Linux / Shell / Git / Docker 全都直接可用
Windows(不装 WSL)MSYS2 的 MinGW-w64能编 C,但 POSIX 那几章会处处碰壁
gcc vs clang两个都装同一份代码用两个编译器各编一遍,警告能多抓一批;clang 的报错更友好
验证gcc --version && gdb --version && make --version && git --version四条都通就可以开始了

和别的页怎么分工

问题去哪一页
「C 编译出来的东西 CPU 怎么执行」计算机组成页
「进程、虚拟内存、调度背后发生了什么」操作系统页
「mov / call 到底是什么」汇编页
「TCP 三次握手、HTTP 报文格式」计算机网络页(本页 08 章只讲怎么用 socket 写出来)
「gcc 内部怎么把源码变成汇编」编译原理页
别把十站当成「每站磨到完美才能进下一站」的线性通关——实际路线是螺旋的:第一遍求「每站都到过」,两百行的玩具粗糙地走完全程,比两千行的项目卡死在第三站有价值得多;第二遍再挑最感兴趣的一站往深做。
给这条线找一个真实载体:现在就建一个你自己用得上的两百行小工具(日志统计、批量重命名、待办清单都行),每学完一章回来给它加上对应那一站——04 章补 Makefile、08 章加个 socket 接口、14 章纳入 Git。走完十站,它就是「工程基本功」的实物证明。

C 的标准每十年左右一版,编译器默认用哪一版一直在变——所以写代码时最好把 -std= 显式写出来。

C 标准年表

标准带来了什么本页哪章
C89 / C90语言的地基;变量必须在块首声明、没有 // 注释(这两条是老代码的标志)02
C99// 注释、在需要时才声明变量、for (int i …)、<stdint.h> 固定宽度整数、<stdbool.h>、snprintf、VLA、复合字面量、指定初始化 .x = 1、inline、restrict02 · 04
C11_Static_assert、_Generic、<stdatomic.h> 与 <threads.h>、匿名结构体与联合、_Alignas / _Alignof;VLA 改为可选、gets 被删04
C17 / C18只修缺陷,没有新特性——很多项目口中的「现代 C」其实指这一版—
C23constexpr、auto、nullptr、bool / true / false 成为关键字、_BitInt(N)、#embed、[[nodiscard]] 等标准属性、二进制字面量 0b1010、typeof;隐式函数声明彻底成为错误04
编译器现状gcc 15 默认就是 -std=gnu23;clang 同期版本对 C23 的覆盖也基本可用。想要可移植就显式写 -std=c17 或 -std=c2301

-std= 怎么选

写法含义
-std=c23 / -std=c17 / -std=c99严格标准模式:GNU 扩展关掉,可移植性最好
-std=gnu23标准 + GNU 扩展(语句表达式、typeof 的老写法等);不写 -std 时的默认值就是这一档
-pedantic / -pedantic-errors凡是标准之外的写法都告警 / 直接报错——想确认代码真的可移植时加它
__STDC_VERSION__编译期探测标准版本:C99 是 199901L、C11 是 201112L、C17 是 201710L、C23 是 202311L

POSIX 与特性测试宏

宏打开什么
_POSIX_C_SOURCE 200809LPOSIX.1-2008 接口:getline、strdup、sigaction 等
_XOPEN_SOURCE 700POSIX + X/Open 扩展(更大的一套)
_GNU_SOURCEglibc 全家桶:上面两套加上 GNU 私有扩展(asprintf、memmem…)
_DEFAULT_SOURCEglibc 的默认集合,通常不用手写
关键规则必须写在所有 #include 之前,否则不生效;症状是「明明有这个函数却报 implicit declaration」(07 章)
严格标准模式的副作用-std=c17 会关掉 POSIX 扩展;要么改用 -std=gnu17,要么显式定义上面的宏
「C23 有这个特性」不等于「你现在就能用」:要看编译器版本、看目标平台的工具链、也要看项目约定的 -std。嵌入式与老项目停在 C99 甚至 C89 的非常多。判断能不能写,唯一可靠的办法是把 -std= 按目标环境钉死,然后让编译器告诉你——而不是凭印象。
把常用开关做成一个别名一次配好:alias cw='gcc -std=c23 -Wall -Wextra -g -fsanitize=address,undefined',之后练习一律 cw x.c -o x && ./x——越界与未定义行为会当场报出文件和行号,这一条能省掉本页一半的排错时间。

起步 · 命令 · 报错速查

三张表解决「跑不起来」:工具链与编译命令表(装什么、gcc / clang 的全部常用开关、程序骨架)、编译与链接报错反查表、运行期崩溃反查与调试手段——后两张都按报错原文查病因与修法。

一条命令把源码变成能跑的程序:gcc hello.c -o hello,然后 ./hello。./ 不能省——Linux 默认不从当前目录找可执行文件。下面四张表覆盖日常全部用法。

装与验

平台命令
Linuxsudo apt install build-essential gdb valgrind
macOSxcode-select --install(gcc 实际是 clang)
WindowsWSL2(Ubuntu),本页 12–15 章直接可用;不装 WSL 则用 MSYS2 的 MinGW-w64
验证gcc --version 能打出版本号就成了
后缀必须是 .c写成 .cpp 会按 C++ 编译——简单程序照样过,于是你以为在写 C

gcc / clang 开关全表

开关作用
-o 名字指定输出名;省掉它会生成 a.out(Unix 沿用几十年的默认名,不是出错)
-Wall -Wextra及格线,永远带上:返回局部变量地址、有无符号混比、字符串截断全靠它们现形
-Werror / -pedantic把警告当错误 / 标准之外的写法一律告警
-g带调试信息——不加它 gdb 只能给你一堆地址
-O0 / -O2 / -Os / -Og不优化(调试用)/ 常用发布档 / 优化体积 / 优化但仍好调试
-std=c23 / -std=gnu17选语言标准(00 章有年表)
-fsanitize=address,undefined越界、泄漏、UB 当场停下并报行号;慢几倍,发布时去掉
-c / -E / -S只编译成 .o / 只做预处理 / 只生成汇编——想看宏展开成什么就用 -E
-I 目录 / -L 目录 / -l名字头文件搜索路径 / 库搜索路径 / 链接库(-lm 数学、-lpthread 线程)
-D宏=值 / -U宏命令行定义 / 取消定义宏
-pthread线程程序编译和链接都要加(比单写 -lpthread 稳妥,07、04 章)
-MMD -MP自动生成头文件依赖,喂给 Makefile(04 章)
-shared -fPIC / -static做动态库 / 全静态链接
-v把编译器实际执行的每一步打出来

程序骨架:三种

形态写法要点
最小可运行#include <stdio.h> + int main(void) { … return 0; }
带命令行参数int main(int argc, char *argv[]);argv[0] 是程序名,真正的参数从 argv[1] 开始,argv[argc] 是 NULL
多文件声明放 .h(带 include guard)、定义放 .c;编译 gcc a.c b.c -o app(04 章)
片段怎么跑本页后面多是片段:补上头文件、塞进 main、结尾 return 0 就能编

退出码

值含义
0 / 非 0成功 / 失败——main 的返回值就是进程退出码;echo $? 查看
谁在读它shell 的 && 与 ||、CI 的成败判定、脚本里的 if(13 章)
128 + 信号号被信号杀死:139 = 段错误(11)、134 = abort(6)、136 = 除零(8)、137 = 被 KILL(9)、130 = Ctrl-C(2)
exit(码) / _exit(码)正常退出(会刷缓冲、跑 atexit)/ 立即退出(不刷缓冲)
#include <stdio.h>   /* ① 搬进 printf 的声明 */

int main(void) {      /* ② 程序入口,不接受参数 */
    printf("Hello, C!\n");  /* ③ 调用;\n 是一个换行符 */
    return 0;          /* ④ 退出码 0 = 成功 */
}

# 验证 ④:退出码真的传出去了
gcc hello.c -o hello && ./hello
echo $?                  # 0;把 return 改成 3 这里就是 3
C 默认几乎不做检查,但检查并没有消失,只是被放进了你必须自己打开的开关里。不加 -Wall -Wextra,一半会变成段错误的问题在编译期一声不吭;不加 -g,gdb 只能给你地址;不加 sanitizer,越界写可能几百行之后才以另一个变量被改坏的形式暴露出来。这三组开关是本页所有排错手段的前提。
把推荐开关做成别名一次配好:alias cw='gcc -std=c23 -Wall -Wextra -g -fsanitize=address,undefined',练习一律 cw x.c -o x && ./x。编辑器先别纠结——VS Code 装个 C/C++ 扩展就够走完本页。

C 的错误按发生阶段分三类:编译期(有文件名和行号)、链接期(undefined reference 这类,不指向某一行)、运行期(跑起来才炸,本章后面)。先分诊,范围立刻缩小一个数量级。

编译期:按原文查

原文病因修法
expected ';' before '…'漏分号报的位置常在下一行——真正的问题在报错行或它上一行
'x' undeclared (first use in this function)拼错、没声明、不在作用域看清大小写与所在的块
implicit declaration of function 'printf'漏了 #include补头文件;C23 起这是硬错误(老标准下只是警告,然后在运行期变成 UB)
fatal error: xxx.h: No such file or directory头文件路径不对,或没装开发包加 -I;系统库要装 -dev / -devel 包
incompatible types when assigning to type 'X' from type 'Y'类型对不上两边类型都写出来了,照着改
passing argument 1 of 'f' makes pointer from integer without a cast传了整数给指针参数十有八九是漏了 &(03 章)
format '%d' expects argument of type 'int', but argument 2 has type 'long'格式串与参数类型不符这是 -Wall 才会查的;size_t 用 %zu、long 用 %ld(02 章)
comparison of integer expressions of different signedness有符号与无符号混比别忽略:i < strlen(s) 里 i 为负时会变成巨大的无符号数(02 章)
control reaches end of non-void function有路径没返回值补 return
dereferencing pointer to incomplete type只有前向声明,没有完整定义包含定义该结构体的头文件
array subscript is above array bounds编译期能看出的越界能查到的只是一小部分,其余交给 ASan(见本章后面)
assignment discards 'const' qualifier把 const 指针赋给非 const改签名,别强转掉 const(03 章)
Makefile:3: *** missing separatorMakefile 的命令行用了空格必须是 Tab(04 章)

链接期:七种拼不起来

原文病因修法
undefined reference to 'foo'函数只声明没定义,或少编了一个 .c把所有 .c 一起给 gcc,或链上对应的 .o
undefined reference to 'sqrt'用了 <math.h> 却没链数学库末尾加 -lm
undefined reference to 'pthread_create'线程库没链上加 -pthread(编译与链接都要)
undefined reference to 'main'main 拼错了,或根本没写检查拼写(mian 是经典)
multiple definition of 'x'在头文件里定义了变量或函数,被多个 .c 各包一次头文件里只写 extern 声明,定义放进某一个 .c(04 章)
ld: cannot find -lfoo库文件不在搜索路径上加 -L,或装对应的开发包
collect2: error: ld returned 1 exit status这只是「链接器失败了」的收尾行真正的错误在它上面几行
顺序问题静态库的 -l 写在了源文件前面-l 一律放在最后:链接器从左到右扫,只解析已经出现过的未定义符号
警告不是噪音,是还没发作的 bug。「隐式声明」「有无符号混比」「格式串不匹配」「返回局部变量地址」——这四类警告几乎每一条都对应一种运行期的诡异行为。养成 -Wall -Wextra 全绿再跑的习惯,比学会任何调试技巧都省时间;项目里能上 -Werror 就上。
永远从第一条错误开始修:C 的编译错误常常连锁——少一个大括号或分号能连带报出十几条。修完一条重新编译,比对着一屏错误逐条猜快得多。

编译链接全过、跑起来才炸的问题,C 里都归结为一句话:你碰了不该碰的内存,或者做了标准没有定义的事。下表按现象反查。

按崩溃现象反查

现象典型病因先用什么查
Segmentation fault(退出码 139)解引用 NULL / 野指针、数组越界、栈溢出(无限递归)、写字符串字面量ASan,其次 gdb bt
Bus error未对齐访问、映射区之外(较少见)gdb
Aborted(134)assert 失败,或 glibc 检测到堆被破坏看它上面那行:free(): invalid pointer、double free or corruption、malloc(): corrupted top size
Floating point exception(136)整数除以 0 或对 0 取模(浮点除零不会崩,给 inf / nan)看除数来源
stack smashing detected写越过了局部数组的边界,被栈保护哨兵抓到ASan 能直接指出哪一次写
退出码 137,没有任何输出被 SIGKILL 杀掉——多半是内存耗尽被 OOM killer 干掉dmesg、看内存曲线
程序被 13 号信号带走(141)SIGPIPE:往已关闭的连接 / 管道写(08 章)忽略 SIGPIPE 并检查 EPIPE
结果时对时错、换台机器就变未初始化变量、越界读、UB 被优化器放大UBSan + ASan;换个 -O 级别和另一个编译器再编一次
输出没出现就崩了printf 没有换行时留在缓冲区,进程崩了就丢了加 \n 或 fflush(stdout)(02 章)
程序挂住不动死锁、read 阻塞在没有数据的 fd、等一个不会退出的子进程gdb -p 进程号 后 bt,或 strace -p
内存越用越多泄漏ASan 的 LeakSanitizer 或 valgrind --leak-check=full

调试手段:从便宜到贵

手段命令抓什么
printf 调试记得 fflush(stdout) 或用 fprintf(stderr, …)(stderr 不带缓冲)粗定位
assertassert(p != NULL);——-DNDEBUG 会整条删掉,别把副作用写进去不变式
ASan-fsanitize=address -g越界、悬空、double free、泄漏
UBSan-fsanitize=undefined整数溢出、移位越界、空指针解引用、未对齐
TSan-fsanitize=thread数据竞争(不能和 ASan 同时开,04 章)
Valgrindvalgrind --leak-check=full ./prog同类问题,不用重编但慢十几倍;与 sanitizer 二选一,不能叠用
gdbgdb ./prog → run看现场(下表)
core dumpulimit -c unlimited 后 gdb ./prog core让崩溃现场留到事后(11 章)
strace / ltracestrace ./prog它到底调了哪些系统调用、卡在哪一个

十条够用的 gdb 命令

命令作用
run / run 参数跑起来(崩溃会停在现场)
bt打印调用栈——崩了第一件事就是它
frame N / up / down切到某一层栈帧
info locals / info args当前帧的局部变量 / 参数
p 表达式 / p *p / p arr[3]打印值,能直接算表达式
break 文件:行 / break 函数下断点;break f if x > 10 是条件断点
watch 变量变量被改时停下——查「谁把它改坏了」的利器
next / step / finish / continue下一行 / 进函数 / 跑完当前函数 / 继续
list / x/16xb 地址看源码 / 按字节看内存
gdb -p 进程号挂到一个正在跑(或卡住)的进程上

前提是编译时带了 -g,并且调试期用 -O0 或 -Og——高优化级别下变量会被优化掉,gdb 只会告诉你 <optimized out>。

「在我机器上没问题」在 C 里通常意味着未定义行为,而不是运气。越界读、未初始化变量、有符号溢出这些,在 -O0 下可能正常、-O2 下就错,换个编译器又变一个样——因为优化器有权假设 UB 不会发生。所以别用「换个写法就好了」结案:用 ASan / UBSan 确认根因,否则它只是躲到了下一次发布里。
分诊顺序背下来就够用:有行号 → 编译期;undefined reference → 链接期;跑起来才死 → 运行期。运行期再按上表的现象分:段错误先上 ASan,结果随机先上 UBSan,挂住不动先 bt。

C 语言基础

C 语言的地基:类型与宽度、运算符与转换规则、控制流、数组与字符串、printf / scanf 的格式串、函数与复合类型。首卡一张表覆盖全部语法,后面各卡展开语义与坑。

C 的类型系统很小,但宽度由实现决定、转换规则会悄悄改变值——这一章的坑几乎全从这两句话来。

基本类型

类型标准保证Linux 64 位实际printf
char1 字节;有无符号是实现定义的1(x86 上有符号)%c / %hhd
short≥ 16 位2%hd
int≥ 16 位4(−21 亿 ~ 21 亿)%d
long≥ 32 位8(Windows 上是 4)%ld
long long≥ 64 位8%lld
float / double—4 / 8(约 7 / 15 位有效数字)%f(两者都用 %f)
boolC23 起是关键字;C99 需 <stdbool.h>1%d
size_t无符号,sizeof 的结果类型8%zu
ptrdiff_t / ssize_t指针相减 / 可为负的大小8%td / %zd
int32_t uint64_t…<stdint.h>,宽度写死按名字PRId32 / PRIu64(<inttypes.h>)

要范围常量:<limits.h> 的 INT_MAX / CHAR_BIT、<stdint.h> 的 INT32_MAX / SIZE_MAX、<float.h> 的 DBL_EPSILON。

转换规则:值会怎么变

规则说明
整型提升char / short / bool 一进表达式先提升为 int——所以 char a = 200 相加不会在 char 里溢出
有无符号混用同宽度时无符号赢:-1 < 1u 结果是 假,因为 -1 变成了 4294967295
经典事故for (int i = 0; i < strlen(s) - 1; i++)——空串时 strlen 返回 0,0 - 1 是巨大的无符号数,循环直接越界
窄化大转小直接截断高位(无符号取模,有符号在 C23 前是实现定义)
有符号溢出是未定义行为不是「绕回负数」——编译器有权假设它不发生并据此优化;无符号溢出才是良定义的模运算
浮点转整数向零截断,不是四舍五入;超出范围是 UB
怎么防开 -Wall -Wextra 抓混比、用 -fsanitize=undefined 抓溢出(01 章)

运算符:优先级从高到低

类别运算符要点
后缀[] () . -> i++ i--—
一元++i --i + - ! ~ (类型) * & sizeofsizeof 是运算符不是函数;它的操作数不求值
算术* / % + -整数相除截断;% 的符号跟被除数;除以 0 是 UB(浮点除零给 inf)
移位<< >>移位数 ≥ 位宽是 UB;负数左移是 UB
关系 / 相等< <= > >= == !=浮点别用 ==,比较差的绝对值
位& ^ |优先级低于 ==——a & 1 == 0 实际是 a & (1 == 0),老老实实加括号
逻辑&& ||短路,且是序列点:左边一定先求值完
三元 / 赋值 / 逗号?: = += &=… ,赋值是右结合的表达式,有值
位运算惯用法取位 (x >> n) & 1、置位 x |= 1u << n、清位 x &= ~(1u << n)、翻转 x ^= 1u << n——移位一律用无符号数

控制流

写法要点
if (条件)非 0 即真;if (p) 就是 if (p != NULL)
switchcase 只能是整型常量表达式;不写 break 会贯穿(有意为之时 C23 可标 [[fallthrough]]);default 位置随意
for (int i = 0; i < n; i++)循环变量声明在括号里是 C99 起
while / do { } while (…);后者至少执行一次,注意结尾分号
break / continue只跳一层;要跳多层用 goto 或标志变量
goto 标签;唯一被广泛接受的用法是集中清理(goto cleanup,03 章)

printf 说明符

说明符含义
%d %u %ld %lld %zuint / 无符号 / long / long long / size_t
%x %X %o %#x十六进制 / 大写 / 八进制 / 带 0x 前缀
%f %.2f %e %g浮点(默认 6 位小数)/ 两位 / 科学计数 / 自动选
%s %c %p %%字符串 / 字符 / 指针 / 一个百分号
%-10s %10s %05d左对齐 / 右对齐 / 补零——对齐表格输出全靠它
%.*s / %*d宽度或精度从参数里取:printf("%.*s", n, buf) 打印前 n 个字符
snprintf(buf, sizeof buf, …)写字符串一律用它:不会溢出,返回的是「本该写多少」——返回值 ≥ 缓冲区大小就说明被截断了
缓冲stdout 连终端时行缓冲、重定向到文件时全缓冲;崩溃前的输出可能丢——加 \n 或 fflush,stderr 不带缓冲

scanf:四条必须记住的规则

规则说明
① 看返回值返回成功赋值的项数,不是读了几个字节;到文件尾返回 EOF。不检查返回值 = 用了没赋值的变量
② 空白规则%d / %s 会先跳过空白;%c 不跳——所以读完数字再读字符会先读到那个残留的换行
③ %s 必须限宽char b[20]; scanf("%19s", b);——不限宽就是缓冲区溢出(宽度不含结尾的 '\0')
④ 失败不消耗输入输入 abc 给 %d 会原地卡住,循环里读就变成无限循环——要么读走那一行,要么改用下一条
更稳的办法fgets 按行读进缓冲,再用 sscanf / strtol 解析——出错时你手里还留着整行,可以报错、可以跳过(本章后面有专卡)
格式串和参数类型对不上是未定义行为,而且编译器默认不查。printf("%d", 某个 long)、用 %d 打 size_t、%s 传了个 int——轻则打出垃圾,重则当场段错误。-Wall 会检查这一类(format '%d' expects argument of type 'int'),这是必须开警告的头号理由。
两条能挡住大批 bug 的默认动作:写字符串只用 snprintf(不用 strcpy / sprintf / strncpy,05 章有对照),读输入只用 fgets + strtol(不用裸 scanf)。这两条几乎覆盖了 C 里事故的一半。

C 的类型不是抽象标签,而是「这个变量占几个字节、这些字节怎么解释」的直接声明——同一段内存换个类型读出来就是另一个值。选对类型是写对 C 的第一步。

先把尺寸记住

  • 在 x86-64 Linux(gcc/clang)上 sizeof 是:char 1、short 2、int 4、long 8、long long 8、float 4、double 8、long double 16、指针 8、size_t 8。
  • long 的宽度最不可移植:同样是 64 位系统,Linux 上 long 是 8 字节,Windows 上只有 4 字节。想「确定就是 32 位」就用 <stdint.h> 的 int32_t/uint64_t——名字自带宽度,换平台不变。
  • sizeof 的结果类型是 size_t(无符号),打印必须用 %zu;用 %d 打印它是格式不匹配的 UB。它也是「有符号 vs 无符号」那一整类 bug 的源头(见本章「整数提升与隐式转换」卡)。
  • 浮点默认选 double 不选 float:double 约 15~16 位有效十进制数字,float 只有约 7 位;printf 传参时 float 还会自动提升成 double,省不下什么。

变量的生存期与只读

  • 局部变量在栈上,函数一返回它的内存立刻作废——把局部变量或局部数组的地址 return 出去,gcc 会直接告警 warning: function returns address of local variable [-Wreturn-local-addr],跑起来是段错误。
  • static 局部变量只初始化一次、跨调用保值,存在静态区而非栈上;代价是函数从此带了状态、不再可重入。
  • const 只是「编译器不让你改」的只读约定,不等于编译期常量:数组维度、case 标签这些要求常量表达式的位置,const int 用不了,只能用 #define 或 enum。
  • char 是否带符号由实现决定:x86-64 Linux 上 char 有符号,(char)200 打印出来是 -56。要明确表达「一个字节的原始数据」就写 unsigned char。
#include <stdint.h>
#include <stdbool.h>  /* C23 起 bool 内置,此行仅为兼容老编译器 */

int32_t  i = -42;        /* 精确 32 位有符号,跨平台推荐 */
uint64_t u = 1000000ULL;  /* 无符号 64 位,可表示到 ~1.8e19 */
double   d = 3.14159;    /* 8 字节,~15 位有效数字 */
bool     ok = true;

void counter() {
    static int n = 0;    /* 只初始化一次,跨调用保持 */
    n++;
}
const int MAX = 100;     /* 只读;编译期常量用 #define/enum */
未初始化的局部变量是未定义行为(UB)——它的值是栈上的垃圾,读取它可能引发难查的 bug 甚至安全漏洞。养成声明即初始化的习惯(int x = 0;)。
gcc 15 默认按 C23 编译——__STDC_VERSION__ 是 202311。这带来两个直接变化:① bool/true/false 已经是关键字,不再需要 #include <stdbool.h>(写上也无害,还能兼容老编译器);② 调用未声明的函数从「警告」升级成硬错误,原文是 error: implicit declaration of function。想按旧标准验证代码,显式加 -std=c17 即可——用 -std=c17 编译一段没有 stdbool.h 的 bool 会直接报 error: unknown type name 'bool'。

运算符本身没有难度,会咬人的是结果和直觉不一样的那几处:整数除法悄悄截断、移位越过符号位是未定义行为、switch 不写 break 会一路穿下去。

算术与逻辑:三条反直觉

  • 整数除法截断:17/5 得 3 而不是 3.4。要浮点必须先转一个操作数,(double)17/5 得 3.4。
  • 除法向零取整,取模的符号跟着被除数(C99 起明确规定)。-17/5 == -3、-17%5 == -2、17/-5 == -3、17%-5 == 2。推论:判奇偶写 n%2 == 1 对负数不成立,要写 n%2 != 0。
  • && 与 || 短路:左边已能定胜负时,右边根本不求值。if (a != 0 && side()) 在 a 为 0 时 side() 的调用次数是 0。这正是 if (i < n && a[i] > 0) 这种「先判边界再取值」惯用法安全的原因——两个条件写反顺序就先越界了。
  • 三目 cond ? a : b 是表达式不是语句,可以直接嵌在赋值或参数里;但别拿它写多层嵌套,可读性会瞬间崩塌。

位运算:三个动作和一颗雷

  • 置位 flags |= 1u << k、清位 flags &= ~(1u << k)、取位 (flags >> k) & 1u——这三件套背下来就够用。u = 0xF0, k = 4 时依次得 240 / 224 / 1。
  • 写 1u 而不是 1:1 是有符号 int,1 << 31 移进符号位在 C23 里仍然是未定义行为。用 -fsanitize=undefined 编译后运行会报出 runtime error: left shift of 1 by 31 places cannot be represented in type 'int'。位掩码一律用无符号类型。
  • 负数右移是实现定义行为:主流实现上 -5 >> 1 得 -3(算术右移,补符号位),主流编译器都如此,但标准并不保证。做位运算就别用有符号数。
  • 移位量必须小于类型位宽:32 位的 x << 32 同样是 UB,不是「得 0」。
int q = 17 / 5;          /* 3,整数除法截断 */
double f = (double)17 / 5; /* 3.4,需强转 */

/* 位操作技巧:设置/清除/读取第 k 位 */
flags |= (1u << k);         /* 置位(用 1u:1<<31 是符号位 UB) */
flags &= ~(1u << k);        /* 清位 */
int bit = (flags >> k) & 1; /* 取位 */

switch (grade) {
    case 'A': puts("优"); break; /* break 防 fall-through */
    default: puts("其他");
}
int max = (a > b) ? a : b;  /* 三目运算符 */
把 == 手滑写成 =:if (x = 0) 是赋值再取值,条件恒为假、x 还被悄悄改掉,而语法完全合法。-Wall 会提示 suggest parentheses around assignment used as truth value,别把它当噪音。更隐蔽的是有符号溢出:INT_MAX + 1 不是「绕回负数」而是 UB——它通常打印出 -2147483648,但 -fsanitize=undefined 明确报 runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'。优化器完全有权按「程序员保证不溢出」来重写你的代码,所以「我试了一下没事」不能作数。
switch 的各 case 之间默认穿透(fall-through):漏写 break 就会继续执行下一个 case 的语句,这是 C 最经典的手滑 bug。故意穿透时写一行 /* fall through */ 注释或 C23 的 [[fallthrough]]; 标明意图,并打开 -Wimplicit-fallthrough 让编译器把无意的穿透全报出来。另外 switch 只能对整数、字符、枚举分派,浮点和字符串都不行——按字符串分派得写 if 链配 strcmp。

C 在做算术、比较、赋值之前会悄悄转换类型——这套规则不写在代码里,却决定结果对不对。三条根:窄类型先升成 int、有符号遇上无符号要让位、宽的塞进窄的会截断。

整型提升与窄化

  • 整型提升:比 int 窄的类型(char、short)参与运算前一律先升成 int。所以 char a = 100, b = 100; a * b 得 10000——是在 int 范围里算的,不会溢出 char。
  • 这条根也解释了 ctype.h 的经典坑:在 char 有符号的平台(x86-64 Linux 即是)上,(char)200 是 -56,直接传给 isalpha() 就是个负的 int,属于 UB。正确写法永远是 isalpha((unsigned char)c)。
  • 窄化赋值截断高位:int big = 300; unsigned char uc = big; 得到的是 44(300 − 256)。加 -Wconversion 能把这类隐式截断全部揪出来。
  • 整数与浮点混合时结果向浮点靠,但转换发生在运算之后才是坑:double avg = sum / n; 若 sum、n 都是整数,先做的是整数除法、小数当场丢掉。必须写成 sum / (double)n,强转其中一个操作数。

有符号与无符号相遇

  • 同宽度下有符号数被转成无符号数:-1 摇身变成 4294967295(32 位无符号下)。int i = -1; i < 1u 的结果是假——这行代码看上去毫无问题,却几乎总是错的。
  • strlen/sizeof 返回的是无符号 size_t,拿它和有符号变量比较是这类 bug 的头号来源。-Wall -Wextra 会报 warning: comparison of integer expressions of different signedness: 'int' and 'size_t' {aka 'long unsigned int'} [-Wsign-compare]——这条警告绝不能忽略。
  • 无符号数永远 >= 0,所以 for (size_t i = n; i >= 0; i--) 是永真死循环。-Wextra 直接点破:warning: comparison of unsigned expression in '>= 0' is always true [-Wtype-limits]。
  • 底线:同一个表达式里别混符号性;下标与计数统一用 size_t,确需混用就显式强转让意图写在脸上,并让 -Wsign-compare 常开替你巡逻。
/* ① 有符号 vs 无符号:有符号被转成无符号 */
int i = -1;
if (i < 1u)              /* 假!i 被当成 4294967295 */
    puts("不会执行");

/* 高发变形:和 sizeof/strlen(size_t 无符号)比较 */
size_t len = strlen(s);   /* 先存下来:既统一了符号,也避免每轮重扫 O(n²) */
for (size_t k = 0; k < len; k++) { }

/* ② 整型提升:char/short 运算前升成 int */
char a = 100, b = 100;
int p = a * b;            /* 10000:先升 int 再乘,不会溢出 char */

/* ③ 窄化:高位被截断 */
int big = 300;
unsigned char uc = big;  /* 44:300 % 256,高位丢失 */

/* 混合运算:结果向更宽/浮点的一方靠 */
double avg = sum / (double)n; /* 强转一个操作数,避免整数除法截断 */
最隐蔽的是有符号-无符号比较:for (size_t i = n; i >= 0; i--) 是永真死循环(循环那卡也会撞上),if (idx < count) 里若 idx 有符号且为负,会被当成巨大正数而误判。规则记不牢就守一条底线:下标、计数、与 size_t 比较时统一用无符号,或显式强转到同一符号性,并让 -Wsign-compare 常开替你抓。
拿不准一个表达式被悄悄转成了什么类型,让编译器自己招:printf("%s", _Generic(a+b, int: "int", unsigned int: "unsigned int", long: "long", unsigned long: "unsigned long", default: "other"))。gcc 15 char a=100, b=100 时 a+b 打出 int(整型提升)、1+1u 打出 unsigned int(有符号让位)、sizeof(int) 打出 unsigned long(正是 size_t,无符号)——前两条根加「sizeof 是无符号」一行代码全验完。

三种循环按「什么时候知道该不该继续」来选:次数已知用 for、看条件用 while、至少要执行一次用 do-while。写对循环的关键不在语法,而在边界——绝大多数循环 bug 都是差一次(off-by-one)。

三种形态

  • for (init; cond; step):计数循环标配。C99 起可把计数器声明在循环头(for (int i = 0; ...)),作用域仅限循环内——推荐,避免变量泄漏到外面。
  • while (cond):先判断后执行,条件为假一次都不跑;适合「不知道要跑几次、由数据说了算」的场景(读输入、遍历链表)。
  • do { ... } while (cond);:先执行后判断,保证至少跑一次——菜单循环、「输错重来」的典型选择。注意结尾有分号。

控制与惯用法

  • break 跳出本层循环,continue 直接进入下一轮;嵌套循环里 break 只能跳一层,要跳多层用 goto cleanup 或把内层提成函数。
  • 遍历 n 个元素的标准写法是半开区间:for (i = 0; i < n; i++)——写 <= 就是越界。
  • 无限循环写 for (;;) 或 while (1),配 break 退出——服务与事件处理的主循环形态。
  • 「边读边判」是 C 循环最常见的形态:while ((c = getchar()) != EOF)、while (fgets(buf, sizeof(buf), fp))——赋值放进条件里,读到头自然结束(下面输入输出几张卡会反复见到)。
/* for:次数已知;i 声明在循环头,作用域仅限循环内 */
for (int i = 0; i < n; i++)   /* 半开区间 [0, n):标准写法 */
    sum += i;                    /* 纯标量累加;数组下标 a[i] 见本章后面 */

/* do-while:至少执行一次——菜单/重试的标配 */
int choice;
do {
    show_menu();
    choice = read_choice();
} while (choice != 0);          /* 注意结尾分号 */

/* break 找到即止,continue 跳过本轮 */
for (int i = 0; i < n; i++) {
    if (a[i] < 0) continue;    /* 负数不处理 */
    if (a[i] == key) { pos = i; break; }
}

/* 无符号数倒序遍历的安全写法:i-- 放进条件 */
for (size_t i = n; i-- > 0; )
    process(a[i]);
三个高发 bug:① off-by-one——<= 和 < 差一次就越界,坚持半开区间 [0, n) 最不容易错;② 无符号倒序死循环——for (size_t i = n-1; i >= 0; i--) 中 i >= 0 对无符号数永真(用上面 i-- > 0 的写法);③ 多打一个分号——for (i = 0; i < n; i++); 循环体成了空语句,后面的花括号变成循环外的独立语句、只执行一次(换成 while (c); 则多半直接死循环)。
别把不变的计算放进循环条件:for (int i = 0; i < strlen(s); i++) 每一轮都要把整个字符串重扫一遍,O(n) 的活变成 O(n²)——先 size_t len = strlen(s); 存下来再循环。同理,嵌套循环里 break 只跳一层;要一次跳出多层,要么设一个标志变量,要么把内层循环提成一个函数直接 return(比 goto 更易读),这往往也正是把过长函数拆短的好时机。

C 的数组和字符串都不随身携带长度:数组传进函数就退化成一个裸指针,字符串靠一个值为 0 的字节标记结尾。这一条 1970 年代的极简设计,决定了下面所有的用法和几乎所有的坑。

数组:长度会在传参时丢掉

  • 定义时 int a[5] = {1,2,3}; 剩下两个自动补 0;int a[5] = {0}; 是整个数组清零的惯用法。二维数组 int grid[2][3] 在内存里是一行接一行连续存放的,按行遍历比按列遍历对缓存友好得多。
  • 数组名在几乎所有表达式里都退化(decay)成指向首元素的指针,所以 a[i] 本质就是 *(a+i)——下标只是指针算术的语法糖(b[1] 和 1[b] 竟然完全等价)。
  • 退化的直接后果:长度当场丢失。int a[5] 在定义它的作用域里 sizeof(a) 是 20,传进 void f(int a[10]) 后函数里 sizeof(a) 只剩 8(一个指针)——所以传数组必须额外传长度 n。形参写成 int a[10] 也骗不了编译器,gcc 会警告 warning: 'sizeof' on array function parameter 'a' will return size of 'int *' [-Wsizeof-array-argument]。
  • 唯二不退化的场合是 sizeof(数组) 和 &数组——所以 sizeof(a)/sizeof(a[0]) 求元素个数只在数组本尊所在的作用域里有效。

字符串:全靠结尾那个 0 字节

  • 字符串就是「以值为 0 的字节结尾的 char 数组」。strlen 必须从头扫到那个 0 才知道长度,是 O(n)——放进循环条件就成了 O(n²)。
  • 少写一个结尾 0,strlen 和 printf("%s", s) 就会一路读过边界,直到碰巧撞上下一个 0 字节。缓冲区溢出、越界读这一整类经典漏洞全部源于此。
  • strncpy 是重灾区:源串放不下时它不补结尾 0。strncpy(dst, "hello!world", 6) 之后这 6 个字节是 68 65 6c 6c 6f 21,一个 0 都没有——之后当字符串用即 UB。gcc 会警告 warning: 'strncpy' output truncated copying 6 bytes from a string of length 11 [-Wstringop-truncation]。
  • 拷贝一律改用 snprintf(dst, sizeof(dst), "%s", src):最多写 size−1 个字符、必定补结尾 0,返回值是「完整结果本应有的长度」。把 20 个字符塞进 char d2[16],返回 20 而实际 strlen 是 15——两个数一比就知道被截断了。

最小复现:越界写与「长度会丢」

  • char d[8]; strcpy(d, "hello, world");(12 字符塞进 8 字节),用 -g -fsanitize=address 编译运行——stack-buffer-overflow 当场报出。换成 snprintf(d, sizeof d, "%s", "hello, world") 重跑则安全截断:同一个操作,一个是漏洞、一个是截断。
  • 验「长度会丢」:main 里 int a[5] 的 sizeof a 是 20,传进 void f(int a[10]) 后只剩 8(指针大小),gcc 还会给一条 -Wsizeof-array-argument。长度在传参那一刻蒸发。
  • 验「全靠结尾 0」:char s[4] = {'a','b','c','d'};(没留放 '\0' 的位置)再 printf("%s", s)——ASan 报 stack-buffer-overflow,因为它一路读过了边界。
int arr[5] = {1, 2, 3};      /* 后两个补 0 */
int grid[2][3] = {{1,2,3},{4,5,6}};

/* 传数组必须带长度(decay 成指针)*/
void print_n(int *a, int n) {
    for (int i = 0; i < n; i++) printf("%d ", a[i]);
}

/* 字符串 = 以 '\0' 结尾的 char 数组 */
char s[32] = "hello";
size_t len = strlen(s);          /* 5,不含 '\0' */

/* 安全拷贝:snprintf 一定补 '\0',永不越界 */
char dst[32];
const char *src = "hello, world";
snprintf(dst, sizeof(dst), "%s", src); /* 优于 strncpy:截断时它仍补 '\0' */
C 字符串的两大越界来源:① char 数组忘了 '\0' 结尾——strlen/printf("%s", s) 会一路读到下一个碰巧的 0 字节,读越界;② 数组下标越界(a[n],或写满 n 个字符却没给 '\0' 留位)是 UB。strncpy 尤其隐蔽:源串超长时它不补 '\0',之后当字符串用即 UB——所以优先用 snprintf,或在 strncpy 后手动补 dst[sizeof(dst)-1] = '\0';(strcpy/strncpy/snprintf 到底该挑哪个,05 章「字符串函数:同一件事,三个选择」有完整选型与对比)。
字符串字面量不可修改。char *s = "hello"; s[0] = 'H'; 语法完全合法,跑起来直接段错误(退出码 139)——字面量放在只读段里。要可修改就写成数组 char s[] = "hello";,这会把内容拷进栈上的数组。麻烦的是默认编译一声不吭:-Wall -Wextra 对这行毫无提示,必须显式加 -Wwrite-strings 才会报 warning: initialization discards 'const' qualifier from pointer target type。所以只读用途请一律写 const char *s,让编译器在你写坏它之前拦住你。

printf 的格式串是一门微型语言:%[标志][宽度][.精度][长度]转换。把这五段拆开,任何 %-08.3f 都能一眼读懂;反过来,格式与实参类型对不上是未定义行为——所以这五段值得一次学透。

五段结构逐个看

  • 标志:- 左对齐、0 补零、+ 强制显示正号、空格(正数留一空位)、# 加进制前缀(%#x 打出 0xff)。
  • 宽度:最少占几列,不足补空格;写 * 则宽度由下一个实参动态传入。
  • 精度:对 %f 是小数位数、对 %s 是最多打印几个字符、对 %d 是最少数字位数;同样可写 .* 动态传入。
  • 长度:声明实参的真实类型——%ld long、%lld long long、%zu size_t、%hd short。
  • 转换:d 有符号、u 无符号、x/o 十六/八进制、f/e/g 浮点(%g 自动挑更短的写法并去尾零)、c 字符、s 串、p 指针、%% 字面百分号。

三件必须知道的事

  • printf 是可变参数函数,不检查也不转换类型:用 %d 打 long、用 %s 打整数都是 UB——不是「打印得不对」,而是「什么都可能发生」。
  • 唯一的宽容来自默认实参提升:float 传参时自动升为 double,所以 %f 通吃两者——但 scanf 里必须区分 %f 与 %lf。
  • 输出分流:正常结果走 stdout(printf),错误与日志走 fprintf(stderr, ...);拼字符串一律用 snprintf——最多写 size-1 个字符并自动补 '\0',返回「完整结果本应有的长度」,和缓冲区大小一比就知道有没有被截断。
printf("[%5d] [%-5d] [%05d]\n", 42, 42, 42);
/* [   42] [42   ] [00042]:右对齐 / 左对齐 / 补零 */

printf("%+d %#x %.3f %g\n", 42, 255, 3.14159, 0.000031);
/* +42 0xff 3.142 3.1e-05 */

printf("%.3s|%*d|\n", "hello", 6, 42);
/* hel|    42|:精度截断字符串,宽度 6 由实参传入 */

long long big = 1LL << 40;
printf("%lld %zu %p\n", big, sizeof(big), (void*)&big);

/* 拼字符串用 snprintf:永不越界,返回值可测截断 */
char path[32];
int need = snprintf(path, sizeof(path), "user-%d.log", id);
if (need >= (int)sizeof(path)) { /* 结果被截断了 */ }
格式与实参不匹配是 UB,编译时开 -Wall(含 -Wformat)让编译器替你核对每一个 %。更危险的是把外部输入直接当格式串:printf(user_input) 是教科书级的格式化字符串漏洞(输入里混入 %s%n 就能读写内存)——原样打印请写 printf("%s", s) 或 fputs(s, stdout)。
%g 会在 %f 和 %e 之间自动挑更短的写法并去掉尾零,打印「量级未知」的浮点数最省心——printf("%g", 0.000031) 输出 3.1e-05;要固定小数位数做对齐报表还是用 %.2f。另外别忘了 printf 有返回值:成功时是写出的字符数,出错返回负数。把输出重定向到写满的磁盘或已关闭的管道时 printf 是真的会失败的,所以认真写落盘的程序应当检查它以及 fclose 的返回值——只 fflush 不查返回值等于没查。

scanf 不是「读一行再解析」,而是在输入流上按格式串逐项匹配:匹配成功就消费字符,遇到匹配不上的字符就停下,并把它留在流里等下一次读取。理解这一点,再记住三条空白规则,绝大多数「输入错乱」当场破案。

三条空白规则

  • %d/%lf/%s 等大多数转换会先跳过一切空白(空格、Tab、换行)再开始读——所以循环 scanf("%d", &x) 能自然读取用空格或换行分隔的一串数字。
  • %c 是例外:不跳空白,读到的就是流里的下一个字节,哪怕是上次残留的换行。想「跳过空白读下一个可见字符」,在格式串里加一个空格:" %c"——格式串中的空白意为「匹配任意多个空白字符」。
  • %s 遇空白即停(读不了带空格的内容),且不检查长度——务必限宽 %63s;要读到行尾改用 scanset:%63[^\n](读取除换行外的一切)。至于被 C11 从标准里删除的 gets(),永远别碰。

返回值与进阶写法

  • scanf 返回成功赋值的项数,输入耗尽返回 EOF:scanf("%d %d", &a, &b) == 2 才说明两个都读到了。不检查返回值 = 拿未初始化变量继续算。
  • %*s:星号表示匹配但丢弃(跳过一列不要的数据);%5d:最多消费 5 个数字字符。
  • 类型对应比 printf 更严格:float 用 %f、double 必须 %lf、long 用 %ld——scanf 拿到的是指针,没有类型提升兜底。
int a; char op; double b;
/* " %c" 的前导空格:跳过残留换行再读运算符 */
if (scanf("%d %c %lf", &a, &op, &b) != 3) {
    /* 输入不合法:非法字符还留在流里,须先清理 */
}

/* scanset:读到行尾为止(类似读整行),务必限宽 */
char name[64];
scanf(" %63[^\n]", name);

/* 星号 = 匹配但丢弃:跳过第一列,只取第二列 */
int age;
scanf("%*s %d", &age);
最高频的坑:scanf 读完数字会把换行留在缓冲区。scanf("%d") 之后再 getchar() 或 fgets(),拿到的是那个残留的 '\n'——表现为「程序把我的输入跳过去了」。清理惯用法:while ((c = getchar()) != '\n' && c != EOF);(getchar/EOF/缓冲区详见本章后面「字符流、EOF 与缓冲」)。不要用 fflush(stdin):标准只定义了刷新输出流,刷输入流是 UB,个别平台碰巧能用不代表可移植。
调试 scanf 最快的办法是先把返回值打出来:它返回成功赋值的项数,一眼就能看出是第几项开始匹配失败的。scanf("%d", &n) 读完 42 之后,紧接着 getchar() 拿到的是 10——正是那个换行符,这就是「程序把我的输入跳过去了」的全部真相;把格式串改成 " %c" 加一个前导空格,就正常读到了下一个可见字符。经验法则:scanf 适合读格式固定的文件或竞赛输入,交互式输入请直接用本章「健壮读入」那卡的 fgets 方案。

stdin/stdout/stderr 不是「键盘和屏幕」,而是三条带缓冲的字节流(类型都是 FILE*,与本章「文件 I/O」是同一套机制)。理解字节怎么流、缓冲何时倒出,「printf 了却没显示」这类灵异事件就都有了答案。

逐字符读写与 EOF

  • getchar()/putchar(c) 一次一个字节;fgetc/fputc 是任意流的版本。「循环 getchar 直到 EOF」就是 Unix 过滤器程序的骨架。
  • 接返回值必须用 int:EOF 是个负数常量(通常 -1),char 装不下——用 char c 接,轻则把合法字节 0xFF 误判成结束,重则永远等不到 EOF 死循环。
  • 终端里手动制造 EOF:Linux/macOS 按 Ctrl+D,Windows 按 Ctrl+Z 再回车;重定向或管道时读到数据尾自然产生 EOF。

三种缓冲模式

  • stderr 不缓冲——错误必须立刻可见;stdout 接终端时行缓冲——攒到 \n 才真正写出;stdout 重定向到文件/管道时全缓冲——攒满一块(常见 4KB)才写。
  • 两大灵异现象由此解释:① 不带 \n 的提示语迟迟不出现——加 fflush(stdout) 强制刷出;② 程序崩溃时最后几行 printf「消失」——它们死在缓冲区里没来得及落地,所以调试信息应该走 stderr。
  • setvbuf(stdout, NULL, _IONBF, 0) 可整个关掉 stdout 缓冲(调试期常用)。
/* 经典过滤器:把 stdin 逐字符搬到 stdout,直到 EOF */
int c;                     /* 必须 int:char 装不下 EOF */
while ((c = getchar()) != EOF)
    putchar(c);

/* 同一骨架顺手改成行数统计(wc -l 的核心) */
long lines = 0;
while ((c = getchar()) != EOF)
    if (c == '\n') lines++;

/* 不带换行的提示语:手动刷出,保证立刻可见 */
printf("请输入名字: ");
fflush(stdout);
接 getchar() 的变量必须是 int,绝不能是 char。EOF 是个负数常量(通常是 −1),而 char 有符号时只能表示 −128~127:文件里一旦出现字节 0xFF,转成 char 恰好也是 −1,会被误判成文件结束——读纯文本时看不出问题,读二进制文件当场截断。反过来,若某平台上 char 无符号,那 EOF 永远比不出相等,循环就再也不会结束。同理 fgetc/getc 的返回值也一律用 int 接:它们返回的是「0~255 的字节值」或「EOF」,这个并集本来就装不进一个字节。
同一个程序,./app 和 ./app | cat 的输出时机可能完全不同——前者行缓冲、后者全缓冲。printf 之后程序崩了却看不到输出,不代表没执行到那一行:在怀疑点后面加 fflush(stdout),或干脆用 fprintf(stderr, ...) 打调试信息——stderr 不缓冲,永不失踪。

直接拿 scanf 做交互输入有个死穴:匹配失败时非法字符留在流里,下轮循环读到的还是它,重试就成了死循环。工程上的标准姿势是拆成两步:fgets 把一整行安全读进来,再用 sscanf 或 strtol 在这个字符串上解析——无论解析成败,这一行都已被消费,输入流永远干净。

两步走

  • 读行:fgets(buf, sizeof(buf), stdin) 最多读 size-1 个字符并自动补 '\0',返回 NULL 表示 EOF 或出错。它会保留行尾换行符,惯用一行去掉:buf[strcspn(buf, "\n")] = '\0';
  • 解析:sscanf(buf, "%d,%d", &x, &y) 用法与 scanf 相同但作用于字符串,照样检查返回值;纯数字解析用 strtol/strtod 更严格可控。

数字解析为什么用 strtol 而不是 atoi

  • atoi("abc") 返回 0,与 atoi("0") 无法区分——完全没有错误报告,溢出还是 UB。
  • strtol(str, &end, 10) 什么都告诉你:end == str 说明一个数字都没读到;*end 不是 '\n'/'\0' 说明数字后面跟着垃圾字符;溢出则返回 LONG_MAX/LONG_MIN 并置 errno = ERANGE(超出可表示范围)(errno 是库函数报告「为什么失败」的机制,用它之前必须先 errno = 0——它不是普通全局变量,原因见 07 章「errno:错误码是怎么工作的」)。第三个参数是进制(10、16,或 0 自动识别 0x/0 前缀)。
/* 交互读整数的标准姿势:失败重新提示,绝不死循环 */
char line[64];
long v;
for (;;) {
    printf("输入端口号: "); fflush(stdout);
    if (!fgets(line, sizeof(line), stdin)) return 1; /* EOF */

    char *end;
    errno = 0;
    v = strtol(line, &end, 10);
    if (end != line && errno == 0
        && (*end == '\n' || *end == '\0')) break; /* 合法 */
    puts("不是合法整数,请重试");
}   /* 非法输入只污染 line 这个局部数组,stdin 始终干净 */
strtol 的错误检查必须三项查全,只查一项等于没查:① end == str 说明一个数字都没读到——strtol("abc") 返回 0,和真的输入 0 无法区分;② *end 既不是换行也不是 0,说明数字后面跟着垃圾——"12abc" 会安静地返回 12;③ errno == ERANGE 说明溢出,此时返回的是 LONG_MAX/LONG_MIN 而不是真值。还有一个人人踩的坑:errno 只在出错时被设置,成功时不会被清零——调用前必须自己写一行 errno = 0;,否则你读到的可能是上一次某个无关库函数留下的陈旧值。
fgets 读回的行里若没有 \n,说明该行比缓冲区长,剩余部分还留在流里等下一次 fgets——可据此循环拼接或整段丢弃。POSIX 平台另有自动扩容的 getline(&buf, &len, stdin),读任意长的行最省心(用完记得 free)。

程序一退出,内存里的一切随之蒸发——数据想活过进程,就得跟文件打交道。fopen(path, mode) 打开、返回 FILE*,务必判空并最终 fclose(关闭时才把缓冲真正写盘,所以 fclose 本身也可能失败)。

mode 决定能读还是能写、会不会清空原文件

  • "r" 只读,文件不存在就失败;"w" 只写,清空原内容或新建(手滑用 w 打开重要文件 = 当场清零);"a" 追加,总写到末尾;
  • 加 + 变读写:"r+" 读写不清空、"w+" 读写但清空、"a+" 读 + 追加写;
  • 加 "b" 是二进制模式——Windows 上要紧:文本模式会把 \n ⇄ \r\n 自动翻译,读写二进制务必带 b("rb"/"wb");Linux/macOS 上 b 无区别。

读写与定位

  • 文本:fprintf/fscanf 格式化、fgets/fputs 按行;二进制:fwrite/fread 整块读写结构体,返回成功的元素个数,要检查;
  • 定位:fseek(fp, off, SEEK_SET/CUR/END) 跳转、ftell 报当前偏移、rewind 回开头;
  • fflush(fp) 强制把缓冲写出;stdin/stdout/stderr 本身就是 FILE*,前几张 I/O 卡用的就是它们。
FILE *fp = fopen("data.txt", "r");
if (!fp) { perror("fopen"); return 1; }

char line[256];
while (fgets(line, sizeof(line), fp))    // 返回 NULL 即结束
    fputs(line, stdout);
fclose(fp);

/* 写文本 + 立刻落盘 */
FILE *out = fopen("log.txt", "a");       // 追加,不清空原文件
fprintf(out, "%s = %d\n", key, val);
fflush(out);                             // 不等缓冲满就写出
fclose(out);

/* 二进制:整条记录读写 + 随机定位 */
FILE *bin = fopen("rec.bin", "wb");      // wb:二进制,Windows 上别漏 b
fwrite(&rec, sizeof(Record), 1, bin);
fclose(bin);

bin = fopen("rec.bin", "rb");
fseek(bin, 2 * sizeof(Record), SEEK_SET); // 跳到第 3 条记录
if (fread(&rec, sizeof(Record), 1, bin) != 1)  // 检查读到的条数!
    fprintf(stderr, "读取不足\n");
long pos = ftell(bin);                   // 当前字节偏移
fclose(bin);
① 别拿 while (!feof(fp)) 当循环条件:feof 只有在一次读取失败之后才变真,于是最后一次读到的数据会被处理两遍——两行的文件打出三行、末行重复。正确姿势是用读函数自己的返回值当条件:while (fgets(buf, sizeof buf, fp)),或检查 fread 返回的条数。② 区分「到末尾」与「真出错」:读函数停下后,feof 为真是正常读完、ferror 为真才是出错,两者处理方式不同,别一律当结束。③ Windows 文本模式的坑:不带 b 打开时 \r\n 会被翻译成 \n,改写二进制数据、还让 ftell 的偏移和实际字节数对不上——凡二进制一律 "rb"/"wb"。
想看穿 stdio 的缓冲,拿 strace 照一眼:strace -e trace=read,openat ./app 显示 fgets 底下真正的系统调用是 read(3, "...", 4096)——一次读满 4KB 进用户态缓冲,之后的 fgets 都在内存里切行,这就是 stdio 这层「带缓冲的外壳」存在的意义。

函数是 C 唯一的抽象手段,规则短到两条:参数一律按值拷贝、返回值只有一个。C 里绝大多数「为什么我改了没生效」和「为什么崩在函数返回之后」,都能追到这两条上。

值传递,以及「改不动」的真相

  • 形参是实参的副本,函数里改形参调用方一无所知。把 User 结构体按值传进函数改 age,返回后调用方仍是 30;改成传 User * 再改,才真的变成 999。
  • 要修改调用方的变量,就传它的地址:void swap(int *a, int *b) 配 swap(&x, &y)。数组看似「例外」,其实不是特例——只是数组名退化成了指针,函数拿到的仍然是一个按值拷贝的指针。
  • 多返回值有三条路:传出参指针(int parse(const char *s, long *out),返回值留给错误码,这是 C 标准库最常见的形态)、返回结构体(整体拷贝,小结构体这样最清爽)、返回 malloc 出来的指针(那就必须约定清楚谁负责 free)。
  • 调用前必须先有声明。 gcc 15 默认按 C23 编译,调用未声明的函数已经是硬错误而不再是警告,原文:error: implicit declaration of function 'undeclared' [-Wimplicit-function-declaration]。把原型放进头文件,或至少放在文件顶部。

递归:先写出口,再算深度

  • 递归函数的第一行永远是出口(终止条件),然后才是「向出口靠近一步」的递推。少了出口就是无限递归——连编译器都看得出来:warning: infinite recursion detected [-Winfinite-recursion],跑起来是段错误。
  • 那个段错误的真身是栈溢出:每层调用都要在栈上压一个栈帧,而栈是有限的。栈通常只有 8 MB 量级(ulimit -s 可查,各系统不同)。在这个前提下,一个几乎没有局部变量的递归函数能压到几十万层;只要给每层加一个 1 KB 的局部数组,几千层就崩了——同一台机器上就差两个数量级。递归深度取决于每帧多大,不存在一个通用的安全数字,要知道自己的上限只能。
  • 所以「深度可能随输入线性增长」的递归(遍历链表、按元素个数递归)在 C 里应当改写成循环;只有「深度是 log n」的递归(二分、平衡树)才可以放心用。
  • 递归写对了也不代表结果对——返回值一样会溢出。long long 版阶乘:20! 是 2432902008176640000(还对),21! 直接翻成负数 −4249290049419214848。

最小复现:栈溢出与返回值溢出

  • 去掉出口的递归 void rec(int n){ printf("%d\n", n); rec(n+1); }:数到几万层后 Segmentation fault(退出码 139)。而 gcc -Wall 在编译期就报了 infinite recursion detected [-Winfinite-recursion]。
  • 量本机深度:给每帧加 char pad[1024];(要真用它一下,否则被优化掉),层数会从几十万掉到几千——深度取决于每帧多大,没有通用安全值。
  • 返回值同样会溢出:factorial(20) 正常,factorial(21) 翻成负数 −4249290049419214848(long long 也兜不住)——递归逻辑对 ≠ 结果对。
/* 值传递:传指针才能改调用方的变量 */
void swap(int *a, int *b) {
    int t = *a; *a = *b; *b = t; /* *a 解引用 */
}
swap(&x, &y);                  /* &x 取地址 */

/* 递归:先写出口(终止条件),再写递推 */
long long factorial(int n) {
    if (n <= 1) return 1;     /* 出口:缺了它就无限递归 */
    return n * factorial(n-1);
}
void f() 和 void f(void) 在老标准里不是一回事:() 表示「参数未指定」,编译器不检查实参个数,f(1,2,3) 也照样编过。C23 起 () 才终于等价于 (void),但为了兼容老代码和老编译器,无参函数请一直写 (void)。另一个高频手滑是递归里忘了让参数向出口靠近:return n * fact(n) 少写一个减一,出口条件就永远不满足,表现和根本没写出口完全一样——栈溢出、段错误,而且这一版编译器的 -Winfinite-recursion 未必抓得住。
函数返回结构体是整体拷贝,几个字段的小结构体这么写既安全又清晰(x86-64 上编译器通常直接用寄存器传回,没有额外开销)。但绝不能返回局部数组或局部变量的地址——函数一返回那块栈内存就作废了。gcc 对这类代码会直接警告 warning: function returns address of local variable [-Wreturn-local-addr],跑起来段错误,-fsanitize=address 报的是 SEGV on unknown address 0x000000000000。要把数据带出函数只有三条路:调用方先备好缓冲区、把指针传进来(snprintf 就是这个模式,也是最推荐的)、返回 malloc 的堆内存并约定谁 free、或者用 static 缓冲区(代价是函数从此不可重入)。

程序的输入不止 stdin。main(int argc, char *argv[]) 是操作系统把命令行敲的词切好后递给你的入口,而 main 的返回值是递回去的退出码——一进一出,构成 C 程序与 shell 之间的完整契约。

argc 与 argv 的精确形状

  • argc 是参数个数,包含程序名本身;argv[0] 是程序名,真正的参数是 argv[1] 到 argv[argc-1]。
  • argv[argc] 标准保证是 NULL—— ./ti a b 得到 argc=3、argv[3] 打印为 (nil)。所以既能按 argc 计数遍历,也能按 NULL 哨兵遍历(execv 这类接口正是靠后者)。
  • 参数一律是字符串,哪怕你敲的是数字。要当数字用得自己 strtol/strtod 转,并按本章「健壮读入」那卡的三项检查法验错——命令行参数是外部输入,和用户敲进 stdin 的东西一样不可信。
  • 带空格的参数由 shell 负责拆分和去引号,程序侧拿到的已经是干净的一项:./app "hello world" 只占 一个 argv 元素。通配符同理,*.txt 是 shell 先展开成一串文件名才递进来的,不是你的程序去做匹配。

退出码:程序说给 shell 听的那句话

  • return 0 表示成功、非 0 表示失败(等价于 exit(0);<stdlib.h> 另有可移植的 EXIT_SUCCESS/EXIT_FAILURE)。
  • shell 用 $? 读它,&& 与 || 的短路、set -e、CI 判定成败全靠它——不认真返回退出码的程序没法被脚本使用(详见 13 章 Shell)。
  • 退出码只有低 8 位有效(0~255),别拿它传数据;错误的细节该写到 stderr,退出码只回答「成还是不成」。
  • 用法出错时的标准做法:把提示用 fprintf 写到 stderr 再 return 1;——提示走 stderr,这样正常输出被管道接走时提示语不会混进去污染数据。
int main(int argc, char *argv[]) {
    printf("程序名:%s\n", argv[0]);
    printf("收到 %d 个参数\n", argc - 1);

    /* 逐个遍历真正的参数(跳过 argv[0])*/
    for (int i = 1; i < argc; i++)
        printf("  argv[%d] = %s\n", i, argv[i]);

    /* 参数是字符串,要数字得自己转 */
    if (argc < 2) {
        fprintf(stderr, "用法:%s <端口>\n", argv[0]);
        return 1;              /* 非 0 退出码 = 失败 */
    }
    long port = strtol(argv[1], NULL, 10);
    printf("端口 = %ld\n", port);
    return 0;                  /* 0 = 成功 */
}
不检查 argc 就伸手拿 argv[1]:程序不带参数运行时,argv[1] 正是那个 NULL 哨兵——strlen(argv[1])、strtol(argv[1], ...) 当场段错误(退出码 139)。更迷惑的是 glibc 的 printf("%s", NULL) 会打印 (null) 而不是崩溃——那是 glibc 的额外照顾,标准里是 UB,换个实现就崩,别拿它当保护网。永远先 if (argc < 2) 报用法退出,再碰 argv[1]。
无参数时写 int main(void);别写 void main()——那不是标准 C。要解析 -v、--help、-o file 这类选项,手写很繁琐,POSIX 的 getopt 专门干这个。带空格的参数已由 shell 拆分并去引号,程序侧拿到的就是干净的一项。

这四个关键字都在做同一件事:给一组字节起个有意义的名字。struct 把相关字段并排打包,union 让成员共用同一块内存,enum 给整数常量起名,typedef 给类型起别名。

struct:C 里最常用的自定义类型

  • 成员用 . 访问,通过指针用 ->(p->name 就是 (*p).name 的简写)。
  • 结构体是值语义:赋值、传参、返回都是整体拷贝。User b = a; 之后改 b.age,a.age 纹丝不动;同理把结构体按值传进函数改字段,调用方看不到任何变化——想让函数改,就传 User *。
  • 指定初始化器(C99 起)让初始化不依赖字段顺序、也不怕日后往结构体里加字段:User a = { .age = 30, .name = "Alice" };——顺序写反照样正确,没写到的字段自动清零。
  • typedef struct { ... } User; 免去每次写 struct User。但公开 API 里更常见的是 typedef struct User User; 配一个只在 .c 文件里定义的 struct——这就是 C 的不透明指针,用来把实现细节彻底藏起来(标准库的 FILE 正是这么做的)。

对齐填充、union 与 enum 的真实行为

  • 编译器会在字段之间插入填充字节,让每个成员落在自己的对齐边界上,所以 sizeof(结构体) 不等于各字段之和。struct { char c; int i; } 是 8 字节(c 后面填了 3 个);struct { char a; double d; char b; } 竟要 24 字节,把两个 char 挪到 double 后面写成 struct { double d; char a; char b; } 就只要 16 字节——大字段放前面、小字段放后面能实实在在省内存(这一条在 03 章「结构体内存布局」里会展开讲透)。
  • 有填充字节就意味着两条禁令:别用 memcmp 比较两个结构体——填充字节里是垃圾,两个字段完全相同的结构体 memcmp 可能 返回 170(非 0,即「不等」);也别把结构体原样 fwrite 到文件当持久化格式,换个编译器或平台布局就变了。
  • union 的所有成员共用同一块内存,sizeof 取最大成员(union { int i; float f; char *s; } 是 8)。同一时刻只有最后写入的那个成员有效,读别的成员就是在重新解释同一堆字节——写 v.i = 1078530011 再读 v.f,得到 3.141593。所以 union 的主要用途不是「省内存」,而是带标签的和类型:你必须自己额外存一个 tag 记住当前存的是哪个成员,语言本身不会替你记。
  • enum 就是命名的整数常量,可显式指定值、后续的接着往下数:enum Color { RED, GREEN = 5, BLUE }; 得 0、5、6,sizeof(enum Color) 是 4。它不是独立类型、不做范围检查——把 99 赋给一个 enum Color,-Wall -Wextra 一声不吭地编过并打印出 99。
typedef struct {
    char name[32];
    int  age;
} User;                       /* typedef 免去每次写 struct */

User u = { "Alice", 30 };
User *p = &u;
printf("%s", p->name);        /* 指针用 -> 访问成员 */

/* union:成员共享内存,同时只有一个有效 */
union Value { int i; float f; char *s; };

/* enum:命名整数常量 */
enum Color { RED, GREEN = 5, BLUE }; /* 0, 5, 6 */
结构体里放 char *name 和放 char name[32] 是完全不同的两件事。放数组,字符串的字节就住在结构体里面,赋值和传参会连内容一起拷走,代价是长度写死、结构体变大(struct { char name[32]; int age; } 是 36 字节)。放指针,结构体里只有一个地址,拷贝结构体只拷了这个地址——两份结构体从此指向同一块内存(浅拷贝),一个 free 了另一个立刻变成悬空指针,改一个另一个也跟着变。所以结构体一旦含指针成员,你就必须自己想清楚谁负责分配、谁负责释放;通行做法是配一对 xxx_new() / xxx_free() 函数,把所有权规则写死在接口里,而不是靠注释提醒。
定义一组相关常量优先用 enum 而不是 #define:enum 的值是编译器认识的符号,调试器里显示成 GREEN 而不是干巴巴的 5;更实用的是 switch 漏掉某个枚举值时编译器会主动提醒——原文 warning: enumeration value 'BLUE' not handled in switch [-Wswitch],而 -Wswitch 已包含在 -Wall 里,等于白送。enum 还能用在数组维度、case 标签这些要求编译期常量的位置,那正是 const int 做不到的事。唯一的限制是 enum 只能是整数,浮点常量还得用 #define 或 static const double。

内存与指针

指针是 C 最大的分水岭:地址与类型、数组退化、动态内存、内存布局与对齐,以及泄漏 / 悬空 / 越界 / double-free 的症状与抓法。段错误从哪儿来,答案都在这一章。

指针只有一件事:它存的是地址,而它的类型决定了「按几个字节、怎么解释」那块内存。声明看不懂时用右左法则——从变量名出发,先右后左。

声明怎么读

声明读法
int *p;p 是指向 int 的指针
int **pp;指向「指向 int 的指针」的指针(要在函数里改调用方的指针就用它)
int *a[10];数组,10 个 int*([] 优先级高于 *)
int (*a)[10];指针,指向「10 个 int 的数组」
int (*f)(int);函数指针:f 指向「吃 int 返回 int 的函数」
int *f(int);函数,返回 int*
看不懂时cdecl 命令行工具,或 用 typedef 拆成两步——真实代码里后者更该做

const 的四种组合

写法不能改什么
const int *p / int const *p不能通过 p 改所指的值,但 p 可以指向别处
int * const pp 不能改指向,值可以改
const int * const p都不能改
记法看 const 在 * 的哪一边:左边管值,右边管指针本身
用在参数上size_t len(const char *s) 是在向调用方承诺「我不改你的数据」——能加就加

指针与数组

事实说明
p + n按 sizeof(*p) 跨步,不是加 n 个字节
p - q得到元素个数,类型 ptrdiff_t;只有指向同一数组时才有定义
a[i] 等价于 *(a + i)所以 a[i] 与 i[a] 合法且等价(冷知识,别写)
数组退化数组当参数传时退化成指针:函数里 sizeof(参数) 得到的是指针大小——长度必须另外传
&a vs a值相同、类型不同:&a + 1 跨过整个数组
void *通用指针:可与任意对象指针互转、不能解引用、不能做算术(GNU 扩展按 1 字节算)
NULL / nullptr空指针;C23 起有真正的 nullptr。解引用空指针是 UB(多数平台表现为段错误)
字符串字面量char *s = "hi"; 指向只读段,改它是 UB;要改就写 char s[] = "hi";

动态内存

函数要点
p = malloc(n * sizeof *p)不清零;惯用法是 sizeof *p(类型改了不用跟着改);必须检查返回值是否为 NULL
calloc(n, size)清零,且内部会检查 n * size 是否溢出
realloc(p, n)扩缩容;失败返回 NULL 而原块仍然有效——所以永远不要写 p = realloc(p, n);,先用临时变量接
free(p)free(NULL) 是合法的空操作;free 之后立刻 p = NULL;,防悬空与 double free
aligned_alloc(对齐, 大小)C11;需要特定对齐(SIMD、DMA)时用
不能 free 的栈变量、字符串字面量、已经被移动过的指针(free(p + 1) 是 UB)
谁负责释放写进函数注释里:返回的指针归调用方,还是指向内部静态缓冲?这是 C 接口最重要的一条约定(05 章)

内存布局与对齐

区域放什么特点
text机器码只读、可共享
rodata字符串字面量、const 全局只读,改了就段错误
data / bss初始化过的全局 / 未初始化或初值为 0 的全局bss 不占文件体积,自动清零
heapmalloc 来的手动管理,向上增长
stack局部变量、参数、返回地址向下增长,函数返回即失效;默认 8 MB 左右,递归太深就爆
对齐alignof / alignas(C11)、offsetof结构体按最大成员对齐,成员间与末尾会插 padding
省内存的排法把成员按大小从大到小排,能显著减少填充;sizeof 结构体几乎不等于成员之和

内存 bug 症状反查

症状病因怎么抓
内存越用越多泄漏:忘了 free,或提前 return 漏掉清理ASan 的泄漏检测 / valgrind --leak-check=full;多资源清理用 goto cleanup
值莫名其妙变了 / 时对时错越界写:改坏了相邻变量ASan(会直接告诉你越过了哪个对象几个字节)
刚 free 完还能用,过一会儿崩悬空指针(use-after-free)ASan;free 后置 NULL
double free or corruption同一块 free 两次,或 free 了不是 malloc 来的地址ASan;理清所有权
函数返回后数据变乱码返回了局部数组的地址-Wall 会警告;改成由调用方传缓冲进来,或 malloc
结果每次运行都不同用了未初始化的内存MSan / Valgrind;声明时就给初值
Segmentation fault 无线索空指针 / 野指针 / 栈溢出gdb 的 bt(01 章)
sizeof 对指针和数组给的是两回事。char buf[64] 在本函数内 sizeof buf 是 64,一旦作为参数传进别的函数就退化成指针,sizeof 变成 8——于是 memset(buf, 0, sizeof buf) 只清了 8 个字节,而且一句警告都没有。数组传参必须同时传长度,这是 C 里最常见的静默错误之一。
四条把内存问题挡在门外的规矩:① 声明就初始化(指针给 NULL)② malloc 必检返回值 ③ free 后立刻置 NULL ④ 谁分配谁释放,实在不行就在函数注释里写清所有权。再加上平时编译挂着 -fsanitize=address,这一章的坑基本就绕过去了。
先读 · 写出能跑的代码

指针是 C 里第一个真正的分水岭,但它本身很简单:指针就是一个存放「地址」的变量。难的从来不是这句话,而是它带来的那一整套后果。

两个运算符,一进一出

  • 把指针想成一张写着门牌号的纸条:纸条本身很小,顺着它才能找到真正的那块内存。& 是写下门牌号(取地址),* 是照着门牌号去开门(解引用)。两者互为逆操作。
  • 指针是有类型的,而类型决定了「开门之后按什么格式读这片字节」。同一个地址,当成 int* 读是 4 个字节一个数,当成 char* 读就是 1 个字节一个数——地址相同,看到的东西完全不同。

指针算术按「格子」走,不按字节走

  • p + 1 前进的不是 1 个字节,而是 sizeof(*p) 个字节。拿 int a[10] 验一下:a + 1 与 a 相差 4 字节,正好一个 int。编译器替你把「第几个元素」换算成了地址。
  • 这也解释了 const 和 * 的四种组合为什么容易记混——顺着纸条的类比就清楚了:const 在 * 左边锁住的是「门后的东西」(所指数据不可改),在 * 右边锁住的是「纸条本身」(指针不能改指向别处)。

最小复现:地址、指针算术与野指针

  • int n=42; int *p=&n;:用 %p 打 &n 与 p,两个是同一个地址;*p 才是 42。
  • 指针算术按格子走:int a[4]; 时 (void*)a 与 (void*)(a+1) 相差 4,换成 double 差 8——p+1 跳的是 sizeof(*p) 而不是 1。
  • 野指针:int *p;(不初始化)后 *p = 5;,多跑几次——可能段错误,也可能诡异地不崩(把别的变量写坏了);改成 int *p = NULL; 则是确定的当场崩。这就是 pitfall 里「先赋 NULL 好查一万倍」的由来。
int  n = 42;
int *p = &n;        /* p 存 n 的地址 */
*p = 100;            /* 通过指针改 n */

int arr[] = {10,20,30};
int *q = arr;       /* 指向 arr[0] */
q++;                /* 前进 sizeof(int),指向 arr[1] */
printf("%d", q[1]);  /* 30,等价 *(q+1) */

/* const 四组合:const 在 * 左锁数据,右锁指针 */
const int *cp;     /* 不能改 *cp,能改 cp */
int * const pc = &n;  /* 能改 *pc,不能改 pc */
指针变量本身也是要初始化的。int *p; 之后 p 里是栈上的随机字节,此时 *p = 5 就是往一个随机地址写数据——可能当场段错误,也可能碰巧写在你另一个变量上,把程序搞成一团糟却不崩。声明时给不出有意义的值就先赋 NULL:解引用空指针是确定的立刻崩溃,比「随机地址悄悄写坏内存」好查一万倍。同理,函数不能返回指向自己局部变量的指针——函数一返回那块栈帧就作废了,编译器对此有专门警告:gcc -Wall 报 warning: function returns address of local variable [-Wreturn-local-addr]。
读复杂声明有个屡试不爽的办法:从变量名出发,向右读到底、再向左,遇到括号先收。int *p[10] 是"p 是含 10 个 int* 的数组",int (*p)[10] 是「p 是指向含 10 个 int 的数组的指针」——括号把 * 先和 p 绑住,意思就反了。另一个习惯:星号紧贴变量名写(int *p 而不是 int* p),因为 int* a, b; 里 b 根本不是指针,星号只作用于 a;写成 int *a, *b; 就不会误会。

数组和指针在 C 里纠缠不清,根子上只有一条规则:数组名出现在几乎所有表达式里,都会「求值为指向首元素的指针」——这叫退化(decay)。C 里关于数组的怪事,几乎都是它的推论。

退化推出的三件事

  • a[i] 本质就是 *(a + i),下标只是指针算术的语法糖。所以 a[3] 和 3[a] 竟然完全等价——两者地址相同。这不是应该写的代码,但它证明了下标运算的真身。
  • 把数组传进函数,传的其实是首元素指针,长度当场丢失。同一个 int a[10]:在定义它的作用域里 sizeof a 是 40,传进函数后在函数内 sizeof 只有 8(一个指针的大小)。这就是 C 的函数为什么永远要额外传一个长度参数。
  • 唯二不退化的场合是 sizeof(数组) 和 &数组。&a + 1 跨过 40 字节(整个数组),而 a + 1 只跨 4 字节(一个元素)——同一个 a,加了取地址符含义就全变了。

void* 与 NULL

  • void* 是"只有地址、没有类型"的通用指针:它能接收任何对象指针,但不能直接解引用(编译器不知道该读几个字节),必须先转回具体类型。qsort、memcpy 这类泛型接口全靠它加一个显式的 size 参数来工作。
  • NULL 表示"不指向任何对象「,是唯一一个可以安全地用来表达」没有「的指针值。它和」未初始化的野指针"是两回事:NULL 是确定的、可判断的,野指针是随机的。
/* void*:通用指针,泛型 swap */
void gswap(void *a, void *b, size_t sz) {
    char tmp[sz];           /* C99 VLA */
    memcpy(tmp, a, sz);
    memcpy(a, b, sz);
    memcpy(b, tmp, sz);
}
gswap(&ia, &ib, sizeof(int));   /* int 可 */
gswap(&da, &db, sizeof(double)); /* double 也可 */

/* NULL 解引用前必须判空 */
if (p != NULL) *p = 5;
字符串字面量不可修改。char *s = "hello"; 里的 "hello" 放在只读段,s[0] = 'H' 是未定义行为——直接段错误(退出码 139)。要能改就写成数组:char s[] = "hello";,这样字符串被拷贝到栈上,改它完全合法。两种写法长得几乎一样,行为却天差地别,是最常踩的雷之一。另外 void* 与函数指针之间的相互转换并不被标准保证(只是多数平台上能用),别把回调函数塞进 void* 参数里传来传去。
既然长度会丢,就让长度和指针形影不离:函数签名一律写成 f(int *a, size_t n) 的形式,工程里更常见的做法是定义一个只有两个字段的小结构体(指针 + 长度)一起传,从此不可能忘。想在数组本尊所在的作用域算元素个数,用 sizeof(a) / sizeof(a[0])——但务必确认 a 真的是数组而不是已经退化的指针,否则算出来的是 8/4 = 2 这种毫无意义的数字,而且编译器不会报错。

栈上的变量由编译器安排、出作用域自动消失;要在运行期才知道大小、或者要活得比当前函数久的数据,就得自己去堆上要。这也意味着:借了就得还。

四个函数各管一件事

  • malloc(n) 要 n 字节,内容是未初始化的垃圾;calloc(n, size) 要 n 个元素并全部清零;realloc(p, n) 调整已有块的大小;free(p) 归还。
  • 每次分配都必须检查返回值是否为 NULL。这不是形式主义——内存耗尽时它真的会返回 NULL,而不检查就解引用,等于把「内存不够」这个可处理的错误变成了段错误。

realloc 的两个陷阱

  • 它可能把数据搬到新地址。原来那个指针在 realloc 之后就可能已经失效了——凡是别处还存着指向这块内存的指针,全部要一起更新,否则它们立刻变成悬空指针。
  • 失败时原指针仍然有效、原数据完好,它只是返回 NULL。所以 p = realloc(p, n); 这种写法是教科书级的错误:一旦失败,p 被 NULL 覆盖,那块原本还好好的内存再也没人能找到它,直接泄漏。正确写法是先接到一个临时指针,确认非空后再赋回去。

最小复现:realloc 搬家与悬空指针

  • 从 malloc(2*sizeof(int)) 起步、装满就 realloc 翻倍、push 进 1..100,每次 realloc 前后都打 printf("%p", (void*)a)——某几次地址真的会变,「realloc 可能搬家」由此成为看得见的事实。
  • 搬家之后继续用搬家前存下的旧指针写一个值,用 -g -fsanitize=address 编译运行——heap-use-after-free 当场报出,并指出这块内存哪行分配、哪行失效。
  • 把分配写成 a = realloc(a, huge) 这个「教科书级错误」的形状,与 code 里「先接临时指针」的写法对照:失败那一刻,原块再没人握着它的地址。
int *a = malloc(n * sizeof(int));
if (!a) { perror("malloc"); return 1; } /* 必查 */

/* realloc:先存到临时指针,成功再覆盖 */
int *tmp = realloc(a, new_n * sizeof(int));
if (!tmp) { free(a); return 1; } /* 原 a 仍有效 */
a = tmp;

free(a);
a = NULL;   /* 好习惯:释放后置 NULL,避免悬空 */
谁分配、谁释放,要在写代码之前就说清楚——这是 C 工程里最难的一件事,因为语言不帮你记。函数返回一块 malloc 出来的内存时,必须在文档或函数名里挑明「调用方负责 free」,否则泄漏几乎是必然的。另外两个高频细节:free 之后立刻把指针置 NULL,这样后续误用会当场崩在明面上,也让重复 free 变成合法的空操作(free(NULL) 是安全的);还有 free 只能用于 malloc 一族返回的原始指针,传一个指向块中间的指针、或者栈上变量的地址,都是未定义行为。
calloc(n, size) 不仅清零,还内置溢出检查(n*size 溢出会返回 NULL),比 malloc(n*size) 更安全。分配数组优先 calloc。

这些 bug 不是「不小心」,而是 C 的设计必然会有的产物——它不做边界检查、不做垃圾回收,分配、释放、是否越界全交给你,编译器一概不管。

高发的五类

  • 内存泄漏:malloc 后忘了 free,程序跑得越久占得越多。
  • 悬空指针(use-after-free):free 之后继续用那块内存。
  • 缓冲区溢出:写越界,严重时能覆盖函数返回地址——这正是经典栈溢出攻击的原理。
  • 重复释放(double-free):同一块内存 free 两次,会破坏分配器自己的记账数据。
  • 返回局部变量地址:函数一返回栈帧就作废,指针指向的内容随时被下一次函数调用覆盖。

真正严重的是:它们不崩

  • 这几类几乎都属于未定义行为(UB),标准对 UB 不规定任何结果。所以最坏的情况不是崩溃,而是它看起来跑对了。
  • 一段 use-after-free:free 之后再读那块内存,程序不崩、不报错,只是把原本的 1 读成了 63——那块内存已经被分配器拿去记账了。如果这是一个金额、一个下标,你只会得到一个安静的错误答案。
  • UB 还会「传染」给优化器:编译器有权假定 UB 不会发生(比如「你解引用了这个指针,那我就认定它非空」),于是把你后面的判空整个优化删掉。这就是为什么同一份代码 -O0 正常、-O2 出错。靠测试排除不了 UB。

最小复现:五个 bug,两轮对照

五个各写四五行,分两轮跑——第一轮看它们有多安静,第二轮看工具怎么把它们揪出来。

  • 第一轮 · 裸跑 gcc bug.c && ./a.out:use-after-free 那个不崩、不报错,只把 1 读成一个垃圾值(每次可能不同)。
  • 第二轮 · 外挂检查 gcc -g -fsanitize=address,undefined bug.c && ./a.out:四个当场被抓并给出行号——UAF → heap-use-after-free、越界 → heap-buffer-overflow、double-free → attempting double-free、泄漏 → 退出时 LeakSanitizer: detected memory leaks。
  • 第五个不一样:返回局部变量地址时,ASan 给不出前四个那样「带分配行号的具名堆错误」——gcc 已在编译期把这个悬空地址替换成 NULL,解引用只撞到一条光秃秃的 SEGV on unknown address 0x000000000000(UBSan 同时报 load of null pointer)。真正点名它的是编译期那条 function returns address of local variable [-Wreturn-local-addr](不加 -Wall 都报)。结论:没有一把万能钥匙,编译警告、ASan、valgrind 各管一段,得都开着。
/* ❌ 悬空指针 */
free(p);
*p = 5;           /* UB!内存已归还 */
p = NULL;          /* ✅ free 后立即置 NULL */

/* ❌ 返回局部变量地址 */
int* bad() {
    int x = 42;
    return &x;     /* x 在函数返回后销毁 */
}                  /* 应返回 malloc 的指针或用输出参数 */
这些 bug 常常不会立即崩溃,而是悄悄破坏内存、在远处引发诡异行为。务必上工具:valgrind 查泄漏与非法访问,编译加 -fsanitize=address,undefined 让越界/UAF/溢出当场暴露。
既然靠眼睛和测试都不行,就把检查外挂回来,而且要默认开着。调试构建固定加上 -fsanitize=address,undefined -g:同一段 use-after-free,不加时静默给出错误答案,加了 ASan 后当场停下并打印 ERROR: AddressSanitizer: heap-use-after-free,还会分别指出这块内存是在哪一行分配的、又是在哪一行被 free 的——等于把破案线索直接摆在你面前。查泄漏则用 valgrind --leak-check=full。这两个开关能替你省掉本页一大半的排错时间,代价只是调试时慢几倍。
进阶 · 精确规则与陷阱(第一遍可跳过)

变量能当参数传,「要做什么动作」也能当参数传——这就是函数指针。C 没有对象、没有 lambda,它是实现回调、策略与多态的唯一手段。

它是什么,怎么声明

  • 函数名和数组名一样会退化成指向该函数的指针,所以 f、&f 拿到的是同一个东西,调用时写 op(a,b) 或 (*op)(a,b) 也都行——这套「随便怎么写都对」反而是它读起来别扭的原因。
  • 声明的形状是 int (*op)(int, int):括号不能省,省了就变成 int *op(int,int)——一个返回 int* 的函数声明,意思完全不同。实践中一律用 typedef 起个名字,别让这种括号出现在函数签名里。

它能换来什么

  • 把「策略」从算法里抽出来:qsort 不知道你要排什么、按什么规则排,它只调用你给的比较函数——同一份排序代码因此能用于任何类型。bsearch、signal 也是同一个套路。
  • 函数指针数组 = 跳转表:把一组处理函数按编号放进数组,用 table[cmd](args) 一步派发,替掉又长又慢的 if-else 链。解释器、状态机、命令分发普遍这么写。
typedef int (*BinOp)(int, int); /* 简化声明 */
int add(int a, int b) { return a + b; }

BinOp ops[] = { add, sub, mul };    /* 跳转表 */
printf("%d", ops[0](2, 3));      /* 5 */

/* qsort 的比较函数(升序)*/
int cmp(const void *a, const void *b) {
    int x = *(const int*)a, y = *(const int*)b;
    return (x > y) - (x < y); /* 不用 a-b 减法:大值相减会溢出 UB */
}
qsort(arr, n, sizeof(int), cmp);
函数指针和普通数据指针是两个世界:ISO C 不保证 void* 与函数指针之间能无损转换(在某些架构上代码地址和数据地址根本不一样宽)。想存一组回调就老实用函数指针类型或它的 typedef,别塞进 void*。还有一个易漏点:签名必须完全一致。把一个 int(*)(int) 的函数强转成 void(*)(void) 去调用是未定义行为——编译器可能一声不吭地放行(因为你自己加了强制转换),而参数和返回值在调用约定层面已经对不上了,崩在哪儿全看运气。
qsort 的比较函数有个固定骨架必须记牢:签名是 int cmp(const void *a, const void *b),参数是指向元素的指针,不是元素本身,所以第一步永远是先转回来再解引用(int x = *(const int *)a;)。返回值只看符号:负、零、正分别表示小于、等于、大于——不要求返回 -1/0/1。另外别图省事写成 return x - y;:两个大整数相减会溢出,符号就反了;老老实实写比较,或者用 (x > y) - (x < y)。

C 没有异常、没有析构函数。一个函数里申请了内存、文件、锁,任何一步失败都得把前面已经申请的按相反顺序还回去——这个问题的标准答案,恰恰是那个名声最差的关键字。

为什么是 goto

  • 用嵌套 if 处理,代码会被推成金字塔:申请五样资源就缩进五层,而且每个失败分支都要重复写一遍清理,极易漏放或顺序写反。
  • 改成"所有出错分支都跳到函数尾部的统一清理段,按与申请相反的顺序逐级回滚",缩进就平了,清理逻辑只写一份。这是 Linux 内核和大量 C 库通用的错误处理骨架。
  • 注意这里的 goto 只向前跳、只跳到函数尾、只做清理——和「面条式跳转」是两回事。受批评的是任意方向的乱跳,不是这种单向的、结构化的用法。

让它不必费心也安全的诀窍

  • 所有资源变量先统一初始化为「空值」:指针置 NULL、文件描述符置 -1、FILE* 置 NULL。
  • 清理段对每个资源先判空再释放。这样从任意一个失败点跳进来都是安全的——还没申请到的那些资源,此刻仍是空值,会被跳过。不必为每种失败情形各写一个入口。
int load(const char *path) {
    FILE  *fp  = NULL;
    char  *buf = NULL;
    int    rc  = -1;         /* 默认失败 */

    fp = fopen(path, "rb");
    if (!fp)  goto done;      /* 还没申请 buf,直接去清理 */

    buf = malloc(4096);
    if (!buf) goto done;      /* fp 已开,清理段会关掉它 */

    if (fread(buf, 1, 4096, fp) == 0) goto done;
    rc = 0;                    /* 走到这里才算成功 */

done:                            /* 统一出口:按相反顺序回滚,判空后释放 */
    free(buf);                /* free(NULL) 安全 */
    if (fp) fclose(fp);
    return rc;
}
两个真会踩的限制。①goto 不能跳过变长数组(VLA)的声明——跳进它的作用域是未定义行为,编译器通常会直接报错拒绝。清理段里也别声明新变量。②清理顺序必须与申请顺序严格相反,尤其当资源之间有依赖时:先关文件再释放那个文件正在用的缓冲区是对的,反过来就是 use-after-free。写的时候把申请和释放上下对照着看一遍,这类错误光看单侧代码是看不出来的。另外 goto 只能在同一个函数内跳转,跨函数的清理仍然要靠返回错误码层层上报。
只往前跳、只用于清理,别用 goto 实现循环或往回跳。资源很多时可给每种资源设一个标签(fail_buf:、fail_fp:)从对应点跳入,少做几步无用清理;资源少时像上面一个 done 标签配判空最简单。free(NULL) 是合法空操作,所以「先置 NULL、统一 free」的写法很安全。

程序跑起来之后,内存不是一整片,而是分成用途不同的几个区。知道自己的数据落在哪个区,很多「为什么会崩」「为什么这么慢」就有了答案。

五个区,各有脾气

  • 栈:局部变量与函数参数,向下增长,分配只是移动一个寄存器所以极快,但很小(通常 8 MB 量级),且函数一返回就作废。
  • 堆:malloc 的地盘,向上增长,大小灵活但每次分配都要走分配器的记账逻辑,比栈慢得多。
  • BSS:未初始化(或初始化为 0)的全局与静态变量,程序启动时由系统统一清零。
  • Data:有非零初值的全局与静态变量,初值必须真的存在可执行文件里。
  • Text:机器码与字符串字面量,只读——这就是改字符串字面量会段错误的原因。

两个由此推出的实用结论

  • 大数据结构必须放堆上。在函数里写 int buf[1000000] 会直接把栈撑爆(栈溢出通常表现为启动就段错误,且没有任何提示);同样,递归太深也会栈溢出,这是递归算法的现实上限。
  • BSS 不占可执行文件体积:编译器只需在文件里记一句「这里清零 N 字节」,不必把一整片 0 存进去。一个 int big[1000000]; 放 BSS 时文件几乎不变大,一旦给它加个非零初值就挪进 Data,文件立刻膨胀 4MB。
int g_init = 10;        /* Data 段 */
int g_zero;             /* BSS 段,自动清零 */
const char *s = "hi";   /* "hi" 在 Text(只读)*/

void foo() {
    int  local = 42;     /* Stack:返回即销毁 */
    int *heap = malloc(4); /* heap 指针在栈,*heap 在堆 */
    free(heap);
}

/* 高地址 ↑ Stack | ... | Heap | BSS | Data | Text ↓ 低地址 */
别把「能跑」当成没越界。栈上数组稍微写越界一点,覆盖的往往是同一栈帧里的另一个局部变量——程序照常跑完,只是某个值莫名其妙变了,这种现象和「内存布局」直接相关却极难联想到。另外static 局部变量不在栈上,它住在 BSS/Data 区:只初始化一次、函数返回后依然存在,所以返回指向 static 局部变量的指针是合法的(不像普通局部变量)——但代价是它全局唯一且不是线程安全的,多次调用会互相覆盖,多线程下更是直接数据竞争。localtime 那个著名的坑就是这么来的。
这张图不必背,跑一遍就忘不掉:把 &g_init、&g_zero、&local、heap 用 %p 打印出来比较高低;用 size a.out 看 text/data/bss 三段大小,再给一个大全局数组加上初始值,观察可执行文件体积暴涨(BSS→Data,印证「BSS 不占文件体积」);想看编译器把变量放进哪个段,gcc -S 输出的汇编里 .data/.bss/.rodata 一目了然。

结构体的 sizeof 往往大于各字段大小之和。多出来的那部分不是浪费,是 CPU 的要求:每种类型都得落在自己的对齐边界上,编译器就在字段之间插入填充字节(padding) 凑齐。

字段顺序真的会改变体积

  • 两个字段完全相同、只是顺序不同的结构体:struct {char; int; char;} 的 sizeof 是 12,而 struct {int; char; char;} 只有 8——省了三分之一。前者两个 char 被 int 隔开,各自后面都得补齐。
  • 规则可以概括成两条:每个字段要对齐到自身大小的倍数;结构体总大小要是其中最大对齐的整数倍(所以末尾也可能补齐)。据此,把大类型排前面、小类型聚在一起就能自然省下填充。
  • 查真实偏移用 offsetof(<stddef.h>),别自己数。C11 的 alignas/alignof(<stdalign.h>)可以显式指定或查询对齐。

由此推出:结构体不能直接当二进制格式

  • 既然 padding 由编译器决定、字段的字节序由 CPU 决定,那么把结构体用 fwrite 整块写进文件或塞进网络包(见 2 章文件 I/O)就不可移植——换台机器、换个编译器、甚至换个编译选项,读出来就是乱的。
  • 跨机器交换数据只有一条正路:逐字段序列化,每个字段显式约定宽度与字节序。这也是 08 章网络编程里「分帧」要反复强调的同一件事。

最小复现:把 padding 逼出来

  • 跑 code 里的 Bad / Good 两个结构体,确认 12 vs 8。再对 Bad 逐字段打印 offsetof(struct Bad, a/b/c),得到 a=0, b=4, c=8——a 与 b 之间空的 3 字节就是 padding。
  • 换一个更浪费的:struct { char; double; char; short; },先用两条规则手算 sizeof,再跑出来对答案。
  • 把字段从大到小重排再算一次,体积当场降下来——这就是所谓的零成本优化。
#include <stddef.h>   /* offsetof */

struct Bad  { char a; int b; char c; }; /* 常见 12 字节:a 后填 3、c 后填 3 */
struct Good { int b; char a; char c; }; /* 紧凑 8 字节:同样字段,换个顺序 */

printf("%zu %zu\n", sizeof(struct Bad), sizeof(struct Good));
printf("b 偏移 %zu\n", offsetof(struct Good, b));

/* 位域:把标志压进按位的空间 */
struct Flags {
    unsigned visible : 1;   /* 只占 1 位 */
    unsigned level   : 3;   /* 占 3 位,表示 0-7 */
};

/* C11 显式对齐(需 <stdalign.h>)*/
alignas(16) char buf[64]; /* buf 起始地址是 16 的倍数 */
别硬编码结构体大小或字段偏移(不同平台/编译器 padding 不同),要用 sizeof/offsetof。位域的底层位排布由实现定义(哪端先放、能否跨存储单元都不保证),别用位域直接映射网络协议或文件格式——那种场合应手动移位与掩码(见 02 章位运算)。想去掉填充可用 #pragma pack 或 __attribute__((packed)),但代价是非对齐访问在某些架构上会变慢甚至崩溃。
想知道填充藏在哪里,别靠脑补,用 offsetof 把每个字段的偏移打印出来——相邻两个字段的偏移差大于前一个字段的 sizeof,中间那段就是 padding。想让编译器帮你盯着,gcc/clang 的 -Wpadded 会在每次插入填充时给出提示(信息量很大,通常只在优化某个热点结构体时临时打开)。真要压缩体积,先调字段顺序:这是零成本的——不改语义、不改可移植性、不影响访问速度,只是把声明重排一下。

现代 C 与工程

把 C 写成工程:预处理与宏、多文件编译与链接、Make 与 CMake、C99 / C11 / C23 的新特性,以及进程、管道、信号、线程与原子操作。

这一章是「把 C 写成工程」的那一层:预处理器怎么用、多文件怎么拼、构建怎么写、新标准给了什么、以及怎么开进程和线程。五张表。

预处理指令与宏

写法说明
#define N 100 / #define MAX(a,b) ((a) > (b) ? (a) : (b))参数和整体都要加括号——不加括号的宏是经典事故源
#include <x.h> / #include "x.h"尖括号找系统目录;双引号先找当前目录
#ifdef / #ifndef / #if / #elif / #else / #endif条件编译;#if 里未定义的标识符当 0
include guard#ifndef FOO_H + #define FOO_H + #endif,或直接 #pragma once(非标准但到处都支持)
# / ##参数变成字符串 / 拼接成一个标识符——调试宏与代码生成靠它们
__VA_ARGS__ / __VA_OPT__变参宏;__VA_OPT__(C23)解决「一个参数都不传时多一个逗号」
预定义__FILE__ __LINE__ __func__ __DATE__ __STDC_VERSION__——日志宏的标配
多语句宏包成 do { … } while (0),这样 if (x) 宏(); else … 才不会散架
看宏展开成什么gcc -E 文件.c——宏出问题时第一件事
能不用就不用常量用 enum 或 const(C23 可用 constexpr)、短函数用 static inline:它们有类型、可调试

多文件与链接

规则说明
.h 放什么只放声明:函数原型、extern 变量声明、类型定义、宏。定义放进 .c
static 在文件作用域内部链接:这个函数/变量只属于本文件——C 的「私有」就靠它
extern声明「这个变量定义在别处」;在头文件里写 int g; 会撞 multiple definition
编译单元一个 .c + 它展开后的所有头 = 一个翻译单元,各自编成 .o,最后链接
分步gcc -c a.c → a.o;gcc a.o b.o -o app
链接顺序-l 放最后:链接器从左到右扫,只解析已出现的未定义符号

Make 与 CMake

要点写法
Make 规则目标: 依赖 换行后用 Tab 开头写命令——用空格就报 missing separator
自动变量$@ 目标、$< 第一个依赖、$^ 全部依赖
模式规则%.o: %.c + $(CC) $(CFLAGS) -c $< -o $@
.PHONY: all clean test声明「这些不是文件名」——目录下真有个叫 clean 的文件时就靠它
增量与并行按时间戳决定重编;make -j8 并行;头文件依赖用 -MMD -MP 自动生成
CMake 最小骨架cmake_minimum_required(VERSION 3.20) → project(demo C) → add_executable(app a.c b.c)
CMake 常用命令target_link_libraries / target_include_directories / target_compile_options——一律用 target_ 开头的那套
CMake 怎么跑cmake -S . -B build 配置,cmake --build build -j 构建;-DCMAKE_BUILD_TYPE=Debug
C 没有官方包管理器依赖靠:系统包管理器 + pkg-config --cflags --libs 名字、把源码 vendor 进仓库、或 CMake 的 FetchContent。别找 Cargo 那种东西,没有

C99 / C11 / C23 特性矩阵

特性标准一句话
// 注释、就地声明变量、<stdint.h>、<stdbool.h>、snprintfC99今天写 C 的最低基线
指定初始化 .x = 1 / 复合字面量 (结构体){…}C99让结构体初始化可读
VLA int a[n]C99(C11 起可选)尽量别用:栈上分配不可控,n 大就爆栈
restrict / volatileC99 / C89「这两个指针不重叠」的承诺 / 「每次都真的去读内存」;volatile 不是线程同步工具
_Static_assertC11编译期断言:结构体大小、类型宽度当场卡死
_GenericC11按类型分派,C 版本的「泛型」
<stdatomic.h> / 匿名结构体与联合 / _AlignasC11无锁原语 / 少写一层名字 / 手动对齐
constexpr auto nullptr bool 成关键字C23把现代写法写进语言
_BitInt(N) / #embed / 0b1010 / [[nodiscard]] / typeofC23任意位宽整数 / 把文件内容嵌进二进制 / 二进制字面量 / 标准属性

进程与线程 API

API要点
fork()一次调用两次返回:子进程得 0、父进程得子进程 pid、失败 -1;内存是写时复制的副本,不共享
execvp("ls", argv) 等成功就不返回(整个进程被换掉);返回了就一定是出错了
waitpid(pid, &st, 0)收尸;用 WIFEXITED(st) / WEXITSTATUS(st) / WIFSIGNALED(st) 解读状态
僵尸进程子进程死了但没人 wait——父进程必须收尸,或忽略 SIGCHLD
pipe(fd) + dup2fd[0] 读、fd[1] 写;两边都要关掉自己不用的那一端,否则读端永远等不到 EOF
信号处理函数里面只能调异步信号安全的函数(write 可以,printf / malloc 不行);标志变量要写成 volatile sig_atomic_t;用 sigaction 而不是 signal(07 章)
pthread_create(&t, NULL, fn, arg)fn 的签名必须是 void *fn(void *);编译加 -pthread
pthread_join / pthread_detach等它结束并取返回值 / 让它自己回收
pthread_mutex_lock / unlock保护共享数据;临界区越小越好,出错路径别忘了解锁
pthread_cond_wait(&cv, &mtx)必须放在 while (条件不成立) 循环里——存在虚假唤醒;配 signal / broadcast
<stdatomic.h>atomic_int c; atomic_fetch_add(&c, 1);——默认内存序是最强的 seq_cst,先用默认,别急着调弱
怎么查竞争-fsanitize=thread(不能与 ASan 同时开,01 章)
volatile 不能替代锁或原子操作。它只告诉编译器「别把这个变量缓存进寄存器」,既不保证原子性,也不阻止 CPU 乱序——多线程共享的计数器写成 volatile int 依然会丢更新。它真正的用途只有三处:内存映射的硬件寄存器、信号处理函数里改的标志(配 sig_atomic_t)、setjmp 附近的局部变量。线程之间一律用互斥锁或 <stdatomic.h>。
构建工具的选法很简单:单文件到十来个文件用 Makefile(十几行就够,还顺手学会了增量构建的本质);要跨平台、要给别人当依赖、要接 IDE,就上 CMake。中间没有太多需要犹豫的地带。

编译器还没上场,你的代码就先被一台只认文字、不认类型的替换机改写过一遍——宏的所有怪脾气都从这里来。

预处理器在编译前做文本替换:对象式宏定义常量、函数式宏(所有参数和整体都要加括号防优先级 bug)、#ifdef 条件编译、头文件用 include guard 防重复包含。# 字符串化、## token 拼接;__FILE__/__LINE__ 内置宏与 C99 的 __func__(预定义标识符,非宏)便于日志。
#define MAX(a, b)  ((a) > (b) ? (a) : (b)) /* 括号必加 */
#define SQ(x)     ((x) * (x))

/* 可变参数宏做日志(##__VA_ARGS__ 是 GNU 扩展;C23 标准写法用 __VA_OPT__(,)) */
#define LOG(fmt, ...) \
    fprintf(stderr, "[%s:%d] " fmt "\n", \
            __FILE__, __LINE__, ##__VA_ARGS__)

/* 头文件保护:防重复包含 */
#ifndef MYLIB_H
#define MYLIB_H
/* ... 声明 ... */
#endif   /* 或用 #pragma once */
函数式宏只是文本替换,有多次求值陷阱:MAX(i++, j) 可能让 i 自增两次。能用 static inline 函数就别用宏——它有类型检查、不重复求值,且同样零开销。
多语句宏必须用 do { ... } while (0) 包住:写成裸的几条语句,放进不带花括号的 if 后面就散架——宏里带声明时当场编译错误(gcc 15 报 expected expression),纯语句的宏更隐蔽:编译能过,第二条语句却静默逃出了 if(只有 -Wmultistatement-macros 警告)。想看预处理器的家底,echo | gcc -dM -E - 一口气列出全部四百多个预定义宏——__GNUC__、__STDC_VERSION__ 都在里面。

C 没有反射、没有代码生成——想让「枚举值」和它的「字符串名」「属性表」始终对得上,最常出的 bug 就是加了个枚举值却忘了更新字符串表。X-Macro 用预处理器把一份列表展开成多份代码,从根上消灭这种「平行数据不同步」。

套路:列表接受一个 X,反复喂不同的 X

  • 把列表定义成一个「接受 X 的宏」:#define COLORS(X) X(RED) X(GREEN) X(BLUE);
  • 每次拿不同的生成器宏当 X 展开它:一次生成 enum、一次生成字符串数组;
  • 加新颜色只改 COLORS 那一行,enum 和字符串表自动同步。

典型用场

  • 枚举 ↔ 字符串互转(日志、调试打印、协议字段名);
  • 操作码 / 状态机表(opcode + handler + 名字 三列平行);
  • 配置字段、序列化(字段名 + 类型 + 偏移一次声明、多处生成);
  • 寄存器 / 命令表——嵌入式最爱。
/* 单一数据源:列表只写这一次 */
#define COLORS(X)  X(RED) X(GREEN) X(BLUE)

/* 两个「生成器」宏,轮流当上面的 X */
#define GEN_ENUM(name)  name,
#define GEN_STR(name)   #name,

enum Color { COLORS(GEN_ENUM) };              /* → RED, GREEN, BLUE, */
const char *color_name[] = { COLORS(GEN_STR) };  /* → "RED","GREEN","BLUE", */

printf("%s\n", color_name[GREEN]);          /* 打印 "GREEN" */

/* 加一个颜色只改 COLORS 那行,enum 与字符串表一起变 */
/* 大列表常放进单独 .def 文件、反复 #include:
     #define X(name) ...  #include "colors.def"  #undef X */
代价是可读性与可调试性:展开后的代码肉眼看不到、报错指向宏定义那一行、IDE 跳转常失灵——排错时用 gcc -E file.c 把宏展开看真容。所以别滥用:只在「多份数据必须严格平行、且会频繁增删」时上;就三五个枚举、手写一次字符串表反而更清楚。
X-Macro 的全部价值就一句话:消灭「平行列表手动同步」的 bug——加删一个成员只动一处,编译器替你把所有派生表改好。C++ 里这活儿交给模板 / constexpr,C++26 更有静态反射(见 C++ 页反射卡);但在 C 里 X-Macro 至今仍是首选,没有更干净的替代。

printf 那手「任意个参数」你也能写——stdarg.h 把这套机制拆开给你用,代价是类型检查从此全靠自觉。

printf 能吃任意个参数,靠的不是魔法,而是 <stdarg.h> 这套机制——你也能用它写自己的可变参数函数(日志、格式化包装)。规矩是:函数至少要有一个具名参数打头,形参表以 ... 结尾。四件套:va_list 声明游标,va_start(ap, last) 以最后一个具名参数为锚开始,va_arg(ap, 类型) 按你指定的类型逐个取,va_end(ap) 收尾。要害在于:可变参数不携带类型信息——编译器不检查、也无从检查,取值类型全靠你和调用方的约定(printf 用格式串约定,自定义函数常用计数或哨兵值)。把 va_list 转发给 vprintf/vsnprintf/vfprintf 这些 v 开头的版本,几行就能写出带前缀的日志函数。
#include <stdarg.h>
#include <stdio.h>

/* 求任意个 int 之和:第一个具名参数 count 说明有几个 */
int sum(int count, ...) {
    va_list ap;
    va_start(ap, count);     /* 锚在最后一个具名参数上 */
    int total = 0;
    for (int i = 0; i < count; i++)
        total += va_arg(ap, int); /* 按 int 逐个取 */
    va_end(ap);             /* 必须收尾 */
    return total;
}
sum(3, 10, 20, 30);      /* 60 */

/* 自写日志:把可变参数原样转发给 vfprintf */
void mylog(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    fprintf(stderr, "[LOG] ");
    vfprintf(stderr, fmt, ap); /* v 版本吃 va_list */
    va_end(ap);
}
mylog("port=%d host=%s\n", 8080, "localhost");
可变参数会经历默认实参提升(与 02 章「整数提升」卡同源):float 一律升成 double、比 int 窄的整数升成 int——所以 va_arg(ap, float)、va_arg(ap, char) 都是错的,必须取 double、int。取的个数或类型和实参对不上就是 UB:和本章「宏与预处理器」卡里的 LOG 宏不同(那是纯文本替换、由 printf 兜底核对格式),这里是你自己在解析、没有安全网。所以可变参数函数总得有个「知道该取几个、什么类型」的约定:计数、哨兵 NULL,或像 printf 那样的格式串。
给自己的 printf 风格函数挂上 __attribute__((format(printf, 1, 2)))(两个数字:格式串和首个可变参数的位置),编译器就会像查 printf 一样查每个调用点——传错类型立刻 -Wformat 警告,把「格式串约定」从口头协议变成编译器合同。另外 C23 起 va_start(ap) 允许只写一个参数(c17 及以前报 requires 2 arguments)。

从一个 .c 变成一堆 .c,会暴露 C 的一个关键特性:编译器一次只看得见一个文件,跨文件的一致性没有任何人替你检查。下面所有的坑,都是这一条的推论。

编译单元:编译器的视野边界

  • 一个 .c,加上它 #include 进来的全部内容,展开后合成一个编译单元(翻译单元)。编译器一次只处理一个,产出一个 .o。
  • 然后链接器把这些 .o 拼起来:把「这里用了 foo」和「那里定义了 foo」对上号。
  • 关键在于链接器只认符号的名字,不认类型。它不知道 foo 是函数还是变量、参数是什么、返回什么——名字对上就接。这就是下面几个坑的共同根源。

.h 放声明,.c 放定义

  • 声明说「有这么个东西,长这样」——函数原型、extern int x;。定义真正产生实体——函数体、int x = 0;。
  • 头文件里只能放声明。把定义写进 .h,一旦被两个 .c 都包含,就是链接期的重复定义:
    ld: multiple definition of `counter'; first defined here
  • 文件作用域的 static 表示只在本编译单元可见——这是 C 的封装手段。别的 .c 想用就会撞上:
    ld: undefined reference to `hidden'

include guard:为什么每个头文件都要有

  • 同一个 .c 里把一个头文件包含两次,往往不是手滑——而是间接的:a.h 和 b.h 都包含了 c.h,你只写了两行 include 就会遇到。
  • 没有防护就是编译期错误:
    error: redefinition of `counter'
    note: previous definition of `counter' with type `int'
  • 防护写法两种:老式的 #ifndef X_H / #define X_H / #endif(标准、到处能用),或 #pragma once(一行搞定,编译器几乎都支持但非标准)。
  • 注意这两种「重复」炸在不同阶段:同一个编译单元里重复 → 编译期;两个编译单元各定义一次 → 链接期。这正好是 01 章「编译期 / 链接期 / 运行期」分诊法的实战用例。

最隐蔽的一个:跨文件类型不一致,全程零警告

一个文件定义 double,另一个文件声明成 int:

// val2.c
double value = 3.14;

// use2.c
extern int value;
printf("%d\n", value);

拿 -Wall -Wextra 编译并链接:

编译链接 exit=0        <- 一条警告都没有
运行输出: 1374389535   <- 把 double 的前四个字节当 int 读了
  • 为什么没人拦?因为两个 .c 从来没有同时出现在编译器眼前,而链接器只对名字,不对类型。
  • 唯一的解法是纪律:声明只写一份,放在头文件里;然后让定义它的那个 .c 也 #include 自己的头文件。这样声明和定义就落进了同一个编译单元,编译器当场就能发现:
    val.c:2:8: error: conflicting types for `value'; have `double'
    val.h:1:12: note: previous declaration of `value' with type `int'
  • 这条规矩看着像洁癖,其实是 C 里唯一能让编译器帮你核对跨文件一致性的办法。函数同理——原型写在 .h,实现的那个 .c 包含它。

链接顺序是有讲究的

  • 库要放在用它的文件后面。链接器从左到右扫,遇到库时只挑「目前还缺的符号」。放反了就等于还没人要它的时候就扫过去了:
    gcc usr.o -L. -ltri     -> 成功
    gcc -L. -ltri usr.o     -> undefined reference to `triple'
  • -lm(数学库)至今仍然必需,尽管 glibc 2.34 起 libpthread 已并入 libc(-pthread 不加也能链接了),libm 没有跟着并。
  • 这里有个容易骗过自测的地方:写 sqrt(2.0) 这种常量实参,编译器会在编译期直接算出结果,二进制里根本没有 sqrt 这个符号,于是不加 -lm 也能链接成功——换成运行期才知道的值,立刻就 undefined reference to `sqrt'。验证链接问题时别用常量做实参。
# 分步编译(理解原理)
gcc -c math.c -o math.o      # 编译为目标文件
gcc -c main.c -o main.o
gcc math.o main.o -o app     # 链接

# 一步到位 + 开启警告(小项目)
gcc *.c -o app -Wall -Wextra -std=c23
把定义写进头文件是最常见的一个:int counter = 0; 放在 .h 里,只被一个 .c 包含时一切正常,等第二个 .c 也包含它,才在链接期炸出 multiple definition——而这时你往往已经忘了那一行是什么时候写的。头文件里要放变量,写 extern int counter;(声明),把 int counter = 0;(定义)留在某一个 .c 里。
更危险的是不报错的那一类:跨文件的类型不一致编译和链接都不会有任何提示,只在运行时给出垃圾数据(上面那个 1374389535)。防住它只有一个办法——别手写 extern 声明散落在各个 .c 里,一律从头文件来,并且让定义方也包含那个头文件。
最后一条:#include "x.h" 用双引号先搜当前目录、#include <x.h> 用尖括号搜系统路径。自己的头文件用错成尖括号,通常表现为「明明文件就在旁边却说找不到」。
文件一多,手敲 gcc 命令就不现实了——下面两张卡的 make 与 CMake 正是为此而生:声明依赖、只重编改动过的文件、一条命令构建整个项目。
调试链接问题时有两个趁手的工具:nm 文件.o 列出这个目标文件里的符号(T 是本文件定义的、U 是它还缺的、t 是 static 的),gcc -E 文件.c 把预处理展开后的样子打出来——「这个头文件到底被包含了几次」「宏展开成了什么」,看一眼就清楚,不用猜。

make 是最经典的构建自动化工具。一个 Makefile 就是一组规则,每条规则回答三个问题:要造什么(目标)、依赖谁(前置)、怎么造(命令)。make 的独门绝技是只看修改时间——目标比它的依赖旧,才重新执行命令,于是「改一个文件只重编这一个」的增量构建自然成立。

一条规则的解剖

目标(target): 依赖(prerequisites)
<Tab>命令(recipe)
  • 目标:通常是要生成的文件名(app、main.o);也可以是伪目标(clean)——不产出同名文件、只是一组动作,须用 .PHONY 声明,否则一旦目录里真有个叫 clean 的文件,规则就永远「不过时」而不执行。
  • 依赖:这个目标依赖哪些文件;任一依赖比目标新,就触发重建。
  • 命令:必须以 Tab 缩进(不是空格),在子 shell 里执行;默认会回显,前缀 @ 可静音。
  • 直接敲 make 构建第一条规则的目标(习惯上叫 all);make clean 则指定构建某个目标。

变量的四种赋值——最容易含糊的点

  • = 递归展开:用到时才求值,右侧引用的变量取「最终值」,容易意外自我引用甚至死循环。
  • := 立即展开:定义当下就求值,行为最可预测——默认优先用它。
  • ?= 仅当未定义时赋值:给可被环境或命令行覆盖的默认值(CC ?= gcc,之后 make CC=clang 即可覆盖)。
  • += 追加:往已有值后面接(CFLAGS += -g)。

自动变量与模式规则:消除重复的关键

  • 规则命令里这些符号免去手写文件名:$@=目标名、$^=全部依赖(去重)、$<=第一个依赖、$?=比目标新的那些依赖、$*=模式规则里 % 匹配到的主干。
  • %.o: %.c 是模式规则:一条规则批量描述「任何 .o 都由同名 .c 编出」,省得为每个文件各写一遍。
  • $(wildcard *.c) 收集源文件,$(patsubst %.c,%.o,$(SRCS)) 把 .c 列表转成 .o 列表——目录里新增一个 .c,Makefile 完全不用改。

头文件依赖:make 最大的陷阱与根治

make 只比时间戳、不看内容:你改了 math.h,但规则里 main.o 只声明了依赖 main.c——make 看不出 main.o 过时,于是不重编,造成「明明改了却没生效」的诡异现象。手写头文件依赖既繁琐又极易漏。根治办法是让编译器自动生成:gcc -MMD -MP 编译时顺带产出 .d 文件(内含 main.o: main.c math.h 这样的依赖行),再用 -include 引进来,依赖关系全自动、永不遗漏(见下方代码)。

# Makefile —— 自动收集源文件 + 自动头文件依赖
CC     := gcc
CFLAGS := -Wall -Wextra -std=c23 -O2 -MMD -MP   # -MMD -MP:顺带生成 .d 依赖
SRCS   := $(wildcard *.c)                        # 目录下所有 .c
OBJS   := $(patsubst %.c,%.o,$(SRCS))            # .c 列表 → .o 列表

# 第一条规则 = 默认目标:可执行文件依赖全部 .o
app: $(OBJS)
	$(CC) $(CFLAGS) $^ -o $@       # $^=全部 .o  $@=app

# 模式规则:每个 .o 由同名 .c 编出
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@    # $<=对应的那个 .c

# 伪目标:不产出同名文件,只是一组动作
.PHONY: clean
clean:
	rm -f $(OBJS) $(OBJS:.o=.d) app

# 引入自动生成的头文件依赖(前缀 - 忽略首次尚不存在)
-include $(OBJS:.o=.d)

# make       默认构建 app(只重编改动过的文件)
# make -j8   8 路并行编译,吃满多核
# make -n    dry-run:只打印将执行的命令、不真正运行
# make -B    强制全量重建   make clean  清理
① 命令必须用 Tab 缩进,用空格会报 missing separator——make 最著名的坑,编辑器悄悄把 Tab 转成空格是常见诱因。② 每行命令在独立子 shell 里执行,因此 cd sub 不会影响下一行;要连贯执行得写成一行、用 && 连接。③ 忘了 .PHONY,当目录里恰好有同名文件时规则会「永不过时」而被静默跳过。
真实项目里很少手写复杂 Makefile,多用 CMake(本章后面)生成。但读懂 Makefile 仍是硬技能——排查构建问题、看懂 make VERBOSE=1 或 CMake 生成的 Makefile 打印出的真实编译命令时都靠它。想系统深入可读 GNU Make 手册的 automatic variables 与 functions 两章。

项目一旦要跨平台(Linux/macOS/Windows)、跨编译器,手写 Makefile 就难以维护。CMake 是元构建工具(meta-build):你在 CMakeLists.txt 里只声明「有哪些目标、依赖什么、要什么标准」,CMake 据此生成各平台真正的构建文件(Unix Makefile / Ninja / Visual Studio / Xcode)。掌握它几乎是参与任何 C/C++ 开源项目的前提。

两段式:配置 → 构建

  • ① 配置(configure/generate):cmake -B build -G Ninja 读 CMakeLists.txt、探测编译器与依赖,在 build/ 里生成对应工程。
  • ② 构建:cmake --build build 调用底层工具(Ninja/make/MSBuild)真正编译——这一步平台无关,CI 脚本因此一套通吃。
  • 所有中间产物都进 build/(out-of-source 构建),不污染源码树,rm -rf build 即彻底清理。

一切皆 target:Modern CMake 的核心

现代 CMake 的心法是面向 target而非全局设置:给具体目标设属性,并用三个关键字控制属性是否传递给依赖它的其他 target。

  • PRIVATE:只自己用(实现文件需要的私有头目录/宏)。
  • INTERFACE:只给用户、自己不用(纯头文件库的典型)。
  • PUBLIC = PRIVATE + INTERFACE:自己用、也传给用户(库的公开头目录)。

于是 target_link_libraries(app PRIVATE core) 一句,会把 core 的 PUBLIC/INTERFACE 头目录、编译选项、宏定义自动带给 app——依赖一处声明、处处生效。别再用 include_directories、link_libraries 这类污染全局的老命令。

引入第三方库的三条路

  • find_package(fmt REQUIRED):找系统已装的库(经 xxxConfig.cmake 或 Findxxx.cmake),配合 vcpkg / Conan 包管理器最常用。
  • add_subdirectory(third_party/lib):把随源码一起放进来的子项目纳入构建(git submodule 常这么用)。
  • FetchContent:配置阶段直接从 Git 拉取并构建依赖,无需预装——小项目「开箱即用」的现代做法。

构建类型、缓存变量与 Presets

  • 用 -D 传缓存变量覆盖默认:-DCMAKE_BUILD_TYPE=Release 选优化档(Debug 带调试信息、Release 开 -O3 -DNDEBUG)。
  • 缓存变量首次配置后存进 build/CMakeCache.txt,之后不必重复传;改错了要么删 build/ 重来,要么再 -D 覆盖。
  • CMakePresets.json 把生成器、构建类型、编译选项固化成命名预设,cmake --preset debug 一键复现,团队与 CI 共享同一套——比在 README 里写「请这样配置」可靠得多。
# CMakeLists.txt —— 只描述「要什么」,不写平台细节
cmake_minimum_required(VERSION 3.21)   # C_STANDARD 23 需 3.21+
project(myapp LANGUAGES C)

set(CMAKE_C_STANDARD 23)             # 这是 C 项目:用 C_ 而非 CXX_
set(CMAKE_C_STANDARD_REQUIRED ON)

# 一组源文件先编成库,再让可执行文件用它
add_library(core src/math.c src/util.c)
target_include_directories(core PUBLIC include)   # PUBLIC:用 core 者自动获得头目录

add_executable(app src/main.c)
target_link_libraries(app PRIVATE core)          # 传递性:app 自动拿到 core 的公开属性

# 找系统依赖,跨平台写法一致
find_package(Threads REQUIRED)
target_link_libraries(app PRIVATE Threads::Threads)

# 配置并构建:
# cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
# cmake --build build -j
# ctest --test-dir build      运行 add_test 注册的测试
不传 CMAKE_BUILD_TYPE 时它是空串——既不是 Debug 也不是 Release,编译命令里一个 -O 旗标都没有(cmake 4.2,--verbose 可见),程序莫名慢几倍就是它。配置时明确 -DCMAKE_BUILD_TYPE=Release(加上 -O3 -DNDEBUG)或 Debug;并且它存进了 build/CMakeCache.txt——之后改 CMakeLists 里的默认值不影响老 build 目录,「改了怎么没反应」十有八九是缓存在作怪。
学习顺序:先能读懂并改动别人的 CMakeLists.txt,再系统学。认准「一切皆 target」的现代写法即可避坑——网上很多教程仍是 CMake 2.x 的过时全局命令风格。联动:CMake 最终生成的往往就是本章前面那种 Makefile,cmake --build build --verbose(或对 make 生成器 make VERBOSE=1)能看到它替你写好的真实编译命令。

别找 C 版的 Cargo / npm——没有,短期也不会有。C 管理第三方库靠三招:系统包管理器装、pkg-config 定位、或把源码 vendor 进自己的仓库。

三招

  • 系统包管理器(apt / dnf / brew / pacman):apt install libcurl4-openssl-dev 把头文件 + 库装到系统路径;最省事,但版本你说了不算、换发行版就变、不可复现;
  • pkg-config:装好后用它吐编译 / 链接旗标,免得手写 -I/-l/-L;
  • vendor / submodule:把依赖源码直接放进仓库(third_party/ 或 git submodule)一起编——版本完全可控、可复现,代价是仓库变大、要自己跟上游。

想要「真·包管理器」体验

  • 用 vcpkg / Conan(见上面 CMake 卡)——它们也支持 C 库;或用 CMake FetchContent 配置期拉源码。这几条把 C++ 生态那套借给 C 用,是目前最接近 Cargo 的做法。
# 系统包管理器 + pkg-config(最常见)
sudo apt install libcurl4-openssl-dev   # 装的是 -dev 包(带头文件!)
gcc app.c $(pkg-config --cflags --libs libcurl) -o app
# --cflags 吐 -I 路径,--libs 吐 -lcurl 等链接旗标

# Makefile 里接 pkg-config
# CFLAGS += $(shell pkg-config --cflags libcurl)
# LDLIBS += $(shell pkg-config --libs   libcurl)

# vendor:源码直接进仓库,编进自己的构建
# third_party/cJSON/cJSON.c → 加进 SRC 列表一起编
忘装 -dev / -devel 包是头号坑:apt install libcurl4(运行库)只有 .so、没有头文件,一编译就 curl/curl.h: No such file or directory——开发要装带头文件与 .pc 文件的 libcurl4-openssl-dev。pkg-config 报 No package 'xxx' found 也多半是没装 -dev 包,或 PKG_CONFIG_PATH 没包含它的 .pc。
优先级:能 pkg-config 就 pkg-config(免手写路径、跨机器还算稳);要可复现 / 上 CI 就 vendor 或 FetchContent(版本锁死);只有快速试验才直接 apt install 后手写 -lxxx。多个库链一起时注意 链接顺序:-l 要放在用到它的 .o 之后。

1999 年这次修订把 C 从「变量必须堆在函数开头」的年代拉进现代——这些写法今天看理所当然,正说明它赢了。

C99 带来诸多便利:混合声明(for(int i=...))、VLA 变长数组(运行时定大小)、复合字面量(匿名对象)、指定初始化器({[5]=9})、restrict 指针别名提示、static inline 内联函数、<stdbool.h>/<stdint.h>。
/* 复合字面量:匿名对象直接用 */
Point p = (Point){ .x = 3, .y = 4 };
int *a = (int[]){ 1, 2, 3 };

/* 指定初始化器:其余自动为 0 */
int sparse[10] = { [0]=1, [9]=9 };

/* static inline:比宏安全,消除调用开销 */
static inline int clamp(int v, int lo, int hi) {
    return v < lo ? lo : (v > hi ? hi : v);
}
VLA(int arr[n])分配在栈上,n 较大时会栈溢出崩溃,且 C11 起变为可选特性。大小不定的数组优先用 malloc 放堆上。
拿不准某个写法是标准 C99 还是编译器私货,让编译器自己招供:gcc -std=c99 -pedantic-errors 一编便知——凡不属于所选标准的扩展当场报错,还会说明它属于哪一版(比如范围 case 1 ... 5 报「not support range expressions ... before C2Y」)。

这两个限定符都不改变程序逻辑,只是在跟优化器谈条件——volatile 喊停,restrict 放行。

这两个类型限定符都是在对编译器的优化喊话,方向正好相反。volatile 说「别自作聪明,每次都老老实实读写这块内存」——因为它的值可能被程序之外的力量改变:内存映射的硬件寄存器、信号处理函数里改的标志(所以是 volatile sig_atomic_t,见本章信号卡)。没有 volatile,编译器可能把「反复读同一地址」优化成只读一次、甚至整段删掉。restrict(C99,只用于指针)则反过来说「我保证:这块内存在该指针生命期内只通过它访问,没有别的指针指向它」——有了这个承诺,编译器就敢放心优化(不必担心写 *a 会顺带改变 *b),memcpy 的两个参数正是 restrict 的(所以要求区间不重叠)。
/* volatile:值会被「程序之外」改变,不能缓存进寄存器 */
volatile int *reg = (int *)0x40000000; /* 硬件寄存器地址 */
while (*reg == 0) { }     /* 每次都真的去读;去掉 volatile 可能死循环 */

volatile sig_atomic_t quit = 0; /* 信号处理函数会改它(见信号卡)*/

/* restrict:承诺 dst/src 不重叠,编译器可激进优化 */
void copy(int *restrict dst,
          const int *restrict src, size_t n) {
    for (size_t i = 0; i < n; i++)
        dst[i] = src[i];         /* 保证不重叠才敢这样优化 */
}
最常见的误解:volatile 不能用于线程同步。它只保证「不被优化掉、每次真读真写」,既不保证操作原子,也不建立跨线程的内存序(一个 volatile 自增照样是读-改-写三步、照样丢更新)。线程间共享要用 <stdatomic.h> 的原子类型或互斥锁(见本章原子操作卡),别拿 volatile 凑。反过来,restrict 是你对编译器的承诺:一旦违背(真的传了重叠指针)就是 UB,编译器不替你检查。
restrict 的效果肉眼可见:把右边的 copy 用 gcc -O2 -S 编译,带 restrict 的版本整个循环被替换成一句 jmp memcpy,删掉它就退回逐元素拷贝的循环(gcc 15)——一句承诺换来一次库调用级的优化,也是观察优化器最直观的实验。

C 没有停在 1999 年——泛型选择、编译期断言这些「别的语言才有」的东西,正一个版本一个版本地进入标准。C17 是个例外:它是 C11 的纯勘误版,一个新特性都没加,所以「C11 有什么」基本就是「C17 有什么」。

C11 带来的三样,以及 C17 为什么没有新东西

C11 的 _Generic 按表达式类型在编译期选分支,是实现类型安全泛型宏的关键——它让 <tgmath.h> 这类类型泛型接口得以用可移植的标准 C 写出来(tgmath.h 本身是更早的 C99 特性)。C11 还有 <threads.h> 线程、<stdatomic.h> 原子操作(都在本章后面几张卡展开)、_Static_assert 编译期断言。至于 C23 在语言层的现代化(constexpr、auto 推导、nullptr、_BitInt、#embed 等),本章后面专门展开。

编译期断言的三种写法,各自从哪一档开始能用(分档)

写法C11 / C17C23
_Static_assert(cond, "msg")可以可以(保留为兼容拼法)
_Static_assert(cond)(省消息)不行,报 ISO C11 does not support omitting the string in '_Static_assert'可以
裸 static_assert(不含任何头文件)不行——它当时只是 <assert.h> 里的一个宏可以——C23 起是关键字
  • C23 在这件事上做了两处改动:把 static_assert 提升为关键字(不必再 #include <assert.h>,也不再需要那个带下划线的丑名字),以及允许省略消息(省了就拿表达式本身当消息);
  • 上表每一格都可以用 tip 里那条方法自行确认——-std=cNN -pedantic-errors 分档试编。不加 -pedantic-errors 时 gcc 会宽容放行,边界根本看不出来;
  • 写新代码直接用 static_assert(gcc 15 不加 -std 默认就是 C23);要兼容老编译器时才退回 _Static_assert,或 #include <assert.h> 后用宏形式。

它和运行期的 assert 是两件事

static_assertassert(<assert.h>)
什么时候检查编译期运行期
条件能是什么只能是编译期常量表达式任意表达式
加了 -DNDEBUG照样检查(仍编译失败)整条表达式被删掉
失败时编译失败打印信息并 abort(),退出码 134
  • 能在编译期判的,永远别推到运行期:sizeof 关系、类型宽度、数组长度与枚举个数是否对齐、配置宏是否互斥——这些都该是 static_assert;
  • 运行期 assert 的完整用法,以及那个「NDEBUG 会连括号里的副作用一起删掉」的坑,见 06 章 <assert.h> 卡;
  • 失败时的输出带文件、行号、函数名、原始表达式四样(形如 a.out: t.c:2: main: Assertion `x == 1' failed.),所以表达式本身就是错误信息,不必再额外写说明。
/* _Generic:按类型选择,做类型安全的泛型 abs */
#define gen_abs(x) _Generic((x), \
    int:       abs(x),  \
    long:      labs(x), \
    float:     fabsf(x),\
    double:    fabs(x))

gen_abs(-5);     /* 选 abs */
gen_abs(-3.14);  /* 选 fabs */

/* C11 编译期断言 */
_Static_assert(sizeof(int) == 4, "需要 32 位 int");
_Generic 按类型精确匹配、不做隐式转换,两个坑:①字符字面量 'a' 在 C 里类型是 int,_Generic('a', char: ..., int: ...) 选中的是 int 分支;②没列出的类型直接编译失败——把 unsigned 传给只写了 int/double 的泛型宏,报 selector of type 'unsigned int' is not compatible with any association。要么把类型枚举全,要么给 default 分支兜底。
手上编译器在哪一档,打印 __STDC_VERSION__ 就知道:C11=201112L、C17=201710L、C23=202311L(gcc 15 不加 -std 已是 202311L)。验证某特性属于哪版标准,用 -std=c17 -pedantic-errors 这样分档试编——比如 typeof c17 档报错、c23 档通过,标准特性与 GNU 扩展立见分晓。

C23(gcc 15 起不加 -std 就是默认档)把过去要靠宏、编译器扩展或惯用法凑出来的东西,正式写进了语言语法。挑几个真正改变日常写法的。

常量与类型:constexpr / auto / nullptr

  • constexpr——有类型的编译期常量。比 #define 强在它是真正的具名对象、参与类型检查、不会裸文本替换;比 enum 强在可以是任意类型(double、指针都行,enum 只能是 int)。
  • auto——从初始化器推导类型,写 auto n = SIZE * 2; 就不必重复写 int。
  • nullptr——有自己类型(nullptr_t)的空指针常量,终结 NULL 到底展开成 0 还是 (void*)0 的历史歧义。

字面量与嵌入:0b / _BitInt / #embed / %b / unreachable

  • 二进制字面量 0b1010 + 数字分隔符 ':0b1010'1100、1'000'000——' 只为断句可读,不改变数值;printf 的 %b 反过来把整数按二进制打出,与 0b 对称。
  • _BitInt(N)——精确 N 位的整数类型,做硬件寄存器、协议位段、定点数时不必再靠手工掩码硬凑位宽。
  • #embed "file"——把任意文件的字节当初始化列表直接嵌进源码,取代 xxd -i 生成 C 数组那套老把戏。
  • unreachable()(<stddef.h>)——标注「这里到不了」,既是文档,也让优化器删掉死代码;但真走到了就是 UB,别拿它兜运行期分支。
  • static_assert 成了关键字——不必再 #include <assert.h>,也不必写带下划线的 _Static_assert,消息还可以省略(省了就拿表达式本身当消息)。三种写法各从哪一档开始能用,见本章前面的分档表。
/* gcc 15 默认就是 C23,下面不加 -std 直接编 */
#include <stdio.h>
#include <stddef.h>            /* unreachable() */

constexpr int    SIZE = 16;    /* 有类型的编译期常量,胜过 #define */
constexpr double GAIN = 1.5;   /* enum 做不到:常量可以是 double */

int parity(int x) {
    switch (x & 1) {
        case 0: return 0;
        case 1: return 1;
        default: unreachable();  /* 到不了;走到了就是 UB */
    }
}

int main(void) {
    auto  n    = SIZE * 2;         /* 推导为 int,无需写类型 */
    int  *p    = nullptr;          /* 有自己类型的空指针,不再靠 NULL 宏 */
    int   mask = 0b1010'1100;      /* 二进制字面量 + 数字分隔符 */
    int   big  = 1'000'000;        /* ' 只为可读,不改变值 */
    _BitInt(12) reg = 0xABC;       /* 精确 12 位:寄存器/协议字段 */

    printf("%b\n", mask);          /* %b:二进制输出,与 0b 对称 */
    printf("%d %d %d %p %.1f\n", n, big, parity(mask), (void *)p, GAIN);
    (void)reg;
    return 0;
}
三个照搬 C++ 会栽的地方:①C 的 constexpr 只能修饰对象,没有 constexpr 函数——写 constexpr int sq(int x){...} 报 'constexpr' requires an initialized data declaration;要在编译期算函数值仍得靠宏或 enum。②_BitInt 溢出静默回绕:_BitInt(4) t = 8; 实际存成 -8(4 位有符号),-Wall -Wextra 一声不吭——位宽卡得紧,赋值前自己保证范围。③auto 换了身份:C23 前 auto 是个等于什么都没写的存储类关键字,老代码里的 auto int i = 0; 中那个 auto 纯属修饰(就是普通的 int i = 0;),和新的类型推导毫无关系——看到老代码里的 auto 别以为它用了新特性。
常量该用谁:能用 constexpr 就别用 #define——宏没有类型、没有作用域、调试器里也看不到,只在需要预处理阶段就展开(条件编译、字符串拼接)时才非它不可;enum 仅在你要一组关联的 int 名字时用。想确认某特性到底属于哪版标准,拿 -std=c17 -pedantic-errors 分档试编:constexpr 报 'constexpr' undeclared、_BitInt 报 ISO C does not support '_BitInt(N)' before C23、unreachable 报 implicit declaration——一编便知是标准特性还是 GNU 扩展。关于 nullptr:C23 起它是类型安全的空指针关键字,新代码首选(在 _Generic / 可变参数 / 类型判断里比 NULL 宏更不易出错);但本指南正文示例仍大量沿用 NULL——它在存量 C 代码里无处不在,你必须认得,两者也可自由混用。

会调 bug 和不会调的差距不在眼力,在工具箱——下面每样工具都比「多打几行 printf」高一个量级。

第一道防线是开足警告:-Wall -Wextra -Werror 把警告当错误。调试加 -g -O0 配 gdb(断点/单步/看栈)。assert 做开发期断言(NDEBUG 可关)。valgrind 查内存问题,-fsanitize=address,undefined 运行时捕获越界、UAF、整数溢出等 UB。
# 编译期:警告拉满 + 调试信息
gcc main.c -Wall -Wextra -Werror -g -O0 -o app

# 运行期检查:内存 + 未定义行为
gcc main.c -fsanitize=address,undefined -g -o app
valgrind --leak-check=full ./app

# gdb 常用:break / run / next / step / print / bt
gdb ./app
调试版必须 -g -O0:对 -O2 编出的程序下断点,断点会漂到别的行、单步乱跳,print 变量直接给你 <optimized out>(gcc 15 + gdb)——不是 gdb 坏了,是那个变量真的被优化没了。另外 ASan 和 valgrind 不能叠着用:valgrind 跑 ASan 编译的二进制,报 ASan runtime does not come first in initial library list,一次选一个。
perror 与 strerror(errno) 把系统调用失败翻译成人话(如「No such file or directory」)。任何返回错误码的系统/库调用,第一时间检查并打印 errno,能省下大量排查时间。
进程、线程与并发

fork() 是 Unix 创造进程的唯一方式,而它最反直觉的地方就写在标题里:一次调用,两次返回。

「两次返回」到底怎么读

  • 调用后内核把当前进程整个复制一份,于是同一行 fork() 在父子两个进程里各返回一次:父进程拿到子进程 PID(>0)、子进程拿到 0、失败返回 -1。
  • 所以「我是谁」只能靠返回值来分辨。if (pid == 0) 那一支只有子进程会走,else 只有父进程会走——两条路写在同一份源码里,却由两个进程分头执行。
  • 此后两者各跑各的:变量、堆、文件描述符表都是各自一份,改自己的不影响对方。

写时复制:说是复制,其实没真复制

  • 「复制整个地址空间」听着极慢,实际不是——内核用写时复制(COW):父子先共享同一份物理页并标记为只读,谁真的去写,才在那一刻单独复制那一页。
  • 于是 fork 本身很便宜,代价被推迟到真正发生写入时。这也解释了为什么 fork 紧接着 exec(本章后面)几乎不浪费:刚「复制」的页还没被写过,就被新程序整个丢掉了。

最小复现:进程一分为二与缓冲区复制

  • 跑 code 那段:一次运行打出两行(父一行、子一行),同一个 return 0 被两个进程各执行一次。再在 fork() 前定义 int x = 0;、子进程里改成 x = 99 并打印,父进程 sleep(1) 后打印的 x 还是 0——各改各的副本。
  • 缓冲区那个坑:在 fork 前 printf("before fork\n")。直连终端 ./a 只打一次;./a | wc -c 却数出 24 字节(12 的两倍)——进管道变全缓冲、\n 不再刷新,没刷的缓冲被 fork 整个复制。fork 前加一句 fflush(stdout) 即消。这是「终端好好的、一进管道就出错」最经典的一例。
#include <unistd.h>
#include <stdio.h>

int main(void) {
    pid_t pid = fork();          /* 从这一行开始一分为二 */
    if (pid < 0) { perror("fork"); return 1; }

    if (pid == 0)
        printf("子进程: 我的 pid=%d\n", getpid());
    else
        printf("父进程: 子进程 pid=%d\n", pid);
    return 0;                    /* 两个进程都会执行到这里 */
}
fork 之后父子的执行顺序不确定,别假设谁先跑。fork 复制的是包括标准 IO 缓冲区在内的整个用户空间——若 fork 前 printf 的内容还滞留在缓冲区(没换行、没刷新),父子会各带一份、各打印一次,出现莫名其妙的重复输出。fork 前先 fflush(stdout) 可避免。这个坑还会随输出去向而变脸:printf("before fork\n"); fork(); 直连终端时是行缓冲、\n 当场把它刷出去,只打一次;一旦重定向进管道或文件就变成全缓冲、\n 不再触发刷新、没刷出的缓冲被 fork 连同复制,立刻变两次—— ./buf | wc -c 数出 24 字节,正是 12 字节输出的两倍。「在终端好好的,一进管道就出错」的经典来源。注意换行不能省:去掉那个 \n,直连终端也会变两次——没有换行,行缓冲同样得憋到进程退出才刷,父子于是各刷一份。
判断代码跑在谁身上,永远只信 fork 的返回值,别绕道去比 getpid()。还有:三个返回值都要处理。漏掉 -1 尤其阴——失败时 pid 是 -1,它不等于 0,于是走 else 分支,父进程会拿着 -1 去 waitpid,而 waitpid(-1, ...) 的语义恰好是「等任意一个子进程」。程序不报错、不崩溃,只是安静地等错了对象。

fork 只能复制自己,要跑别的程序得靠 exec。一句话:fork 复制 → exec 换脑 → wait 收尸,三步凑齐就是一个 shell。

exec:换脑,且一去不返

  • exec 系列把当前进程的内存映像整个替换成新程序——代码段、数据、堆栈全换掉,但进程号不变、打开的文件描述符默认继承。正因为 fd 会被继承,shell 才能先重定向再 exec。
  • 成功就永不返回(调用它的那行之后的代码已经被覆盖掉了)。所以 exec 后面紧跟的语句只有一种含义:exec 失败了。不写错误处理,失败时会直接往下淌进本不该执行的代码。

wait:退出码与僵尸

  • 父进程用 wait/waitpid 阻塞等子进程结束,并取回退出状态。status 是打包过的,别直接当数字用——要靠宏拆:WIFEXITED 判断是否正常退出、WEXITSTATUS 取退出码,WIFSIGNALED/WTERMSIG 则对应「被信号打死」。
  • 子进程退出后,内核会保留一条记录(退出码等)直到父进程来取——这段时间它就是僵尸进程:已经死了,但还占着一个进程表项。父进程不 wait,僵尸就一直堆着。

最小复现:迷你 shell 与一只僵尸

  • 把 code 补成完整程序:子进程 execvp("ls", ...)、父进程 waitpid 打印退出码。再把 "ls" 换成一个不存在的命令——exec 失败,perror 报 No such file or directory,子进程走 _exit(127),父进程收到退出码 127(shell 里的 command not found 正是这个 127)。
  • 造一只僵尸:子进程立刻 _exit(0),父进程 sleep(30) 里故意不 wait。另开终端 ps -eo pid,stat,comm | grep 程序名——STAT 那列的 Z(defunct)就是它;给父进程补上 wait,它立刻消失。长跑的服务忘了回收子进程,进程表就是这样被慢慢填满的。
#include <unistd.h>
#include <sys/wait.h>

pid_t pid = fork();
if (pid == 0) {                    /* 子进程 */
    char *argv[] = { "ls", "-l", NULL };
    execvp("ls", argv);             /* 成功则不再返回 */
    perror("execvp");              /* 只有 exec 失败才会走到这 */
    _exit(127);
}
int status;
waitpid(pid, &status, 0);          /* 阻塞等这个子进程 */
if (WIFEXITED(status))
    printf("子进程退出码 %d\n", WEXITSTATUS(status));
两个坑都出在「exec 失败之后」。①失败分支必须用 _exit 而不是 exit 或 return:子进程此刻还带着 fork 来的标准 IO 缓冲区副本,exit 会把它刷一遍,父进程稍后再刷一遍,同一段输出打两次;_exit 直接退出、不碰缓冲区。更糟的是 return——子进程会继续执行父进程的代码,两个进程抢着往下跑。②僵尸是真能看见的:让子进程先退、父进程 sleep 着不 wait,ps -eo pid,stat,comm 里那一行 STAT 为 Z 就是它。长跑的服务进程忘了 wait,进程表会被慢慢填满。
这段十几行就是一个 shell 的内核。抓住「fork 复制 → exec 替换 → wait 回收」这条主线,后面的 Linux 与 Shell 两章就全串起来了:你在命令行敲的每条命令,shell 内部都走了一遍这个循环(& 后台运行,不过是父进程不 wait 而已)。

进程的地址空间互相隔离,想通信就得让内核搭桥。两种最基础的桥,性格正好相反:管道是同步的字节流,信号是异步的一次性通知。

管道:一对 fd 组成的单向字节流

  • pipe() 一次给出两个文件描述符:读端 fd[0]、写端 fd[1],数据只能从写端流向读端。配合 fork(子进程继承这两个 fd)与 dup2(把管道接到标准输入输出上),就实现了 shell 的 a | b。
  • 它是字节流,没有消息边界——写三次不等于读三次,和 TCP 完全一样的性质,08 章会把这件事展开讲透。
  • 用完必须各关各的无用端:子进程读就关掉写端,父进程写就关掉读端。这不是洁癖,下面 tip 里说的挂死就出在这。

信号:异步打断,不排队

  • 常见的几个:SIGINT(Ctrl-C)、SIGTERM(kill 默认发的)、SIGCHLD(子进程状态变化)、SIGSEGV(段错误)、SIGPIPE(往没人读的管道里写)。用 signal 或 sigaction 注册处理函数。
  • 信号会在任意时刻打断主流程去执行处理函数,执行完再跳回原处继续——它不是消息队列,同种信号连发多次可能只被处理一次,别拿它传递计数或数据。

最小复现:管道挂死与信号优雅退出

  • 跑 code 里管道那段:父写 "hi child\n"、子读并打到屏幕——这就是 echo hi | cat 在内核里的动作。
  • 管道挂死:把子进程改成循环读到 EOF(while ((n = read(fd[0], buf, sizeof buf)) > 0) write(1, buf, n);),并删掉子进程里的 close(fd[1])——子进程自己还攥着写端,「所有写端都关闭」这个 EOF 条件永远不成立,read 死等,程序卡住(timeout 2 都等不到它退出)。补回那句 close 立刻正常,这是 tip 里的头号管道坑。
  • 信号那半:注册 SIGINT 处理函数、里面只置 quit = 1,主循环写 while (!quit) pause();——Ctrl-C 时是优雅退出而不是被默认动作直接杀死。处理函数只设标志、真正的收尾交回主循环。
#include <unistd.h>
#include <signal.h>

/* 1) 管道:父写、子读 */
int fd[2];
pipe(fd);
if (fork() == 0) {              /* 子:关掉写端,从读端收 */
    close(fd[1]);
    char buf[64];
    int n = read(fd[0], buf, sizeof buf);
    write(1, buf, n);            /* 原样打到标准输出 */
} else {                          /* 父:关掉读端,往写端发 */
    close(fd[0]);
    write(fd[1], "hi child\n", 9);
}

/* 2) 信号:收到 kill 时优雅退出 */
volatile sig_atomic_t quit = 0;
void on_term(int s) { quit = 1; }   /* 只置标志,收尾留给主循环 */
signal(SIGTERM, on_term);
信号处理函数里能做的事极其有限:只能调用异步信号安全的函数(write 可以,printf/malloc 不行——它们可能正在被打断的那一刻持有内部锁,重入会死锁或崩溃)。标准做法就是处理函数里只设一个 volatile sig_atomic_t 标志位,真正的工作放回主循环做。
两个救命细节。①管道读到 EOF 的条件是「所有写端都已关闭」——只要还有任何一个进程持有写端(包括忘了 close(fd[1]) 的自己),read 就会一直阻塞等下去。「管道程序莫名挂死」十有八九是这个。②能用 sigaction 就别用 signal:后者的行为在不同系统上并不一致。这个差异是实打实的——在 glibc 上,用 signal() 装处理函数,被打断的 read 会自动重启(返回 0,errno 为 0),因为 glibc 默认带上了 SA_RESTART;换成 sigaction 且不设 SA_RESTART,同一段代码就返回 -1 且 errno == EINTR。所以「装了信号处理函数就一定会拿到 EINTR」(EINTR 意思是「这次调用被信号打断了」)是错的,拿不拿得到取决于你怎么装——但既然两种都可能,慢系统调用的返回值就必须判 EINTR 并重试。

进程之间什么都不共享,通信得穿过内核;线程走另一条路——同一个地址空间里开多条执行流。便宜由此而来,麻烦也由此而来。

共享什么,不共享什么

  • pthread_create 开一条线程去跑指定函数,pthread_join 等它结束并回收(不 join 又不 detach,就是线程版的资源泄漏)。
  • 同进程的线程共享全局变量、堆、文件描述符表,但各有独立的栈。所以传数据不用管道、不用序列化,指针递过去就行——这是线程比进程「便宜」的真正含义。
  • 反过来说:没有任何东西替你挡着。两个线程同时读写同一个变量而不加保护,就是数据竞争,行为未定义。

为什么 counter++ 会丢更新

  • 它看着是一条语句,机器码却是读 → 加 → 写三步。两个线程交错执行时,可能都读到 100、都加成 101、都写回 101——两次自增只生效了一次。
  • 循环一百万次,这种交错会发生几十万次。右边的程序期望 2000000,实际跑出来总是明显小于它,而且每次的数都不一样——正因为交错是随机的,这个数字不该有定值,你自己跑三遍就能看到三个不同的结果。
  • 这类 bug 最难查的地方在于它不崩溃——程序照常跑完、照常给出一个结果,只是那个结果是错的。

最小复现:把数据竞争抓现行

  • code 那段连跑三遍:counter 每次都小于 2000000、且三次各不相同(如 1170827 / 1203091 / 1011436)——丢更新,而且这个数根本不该有定值。
  • 开 TSan:gcc race.c -o race -pthread -fsanitize=thread -g 再跑——WARNING: ThreadSanitizer: data race 当场指出是哪两个线程、在 counter++ 哪一行冲突。把 counter 的类型改成 _Atomic long(或加互斥锁,见本章后面),TSan 闭嘴、结果稳稳回到 2000000。
#include <pthread.h>
#include <stdio.h>

long counter = 0;                  /* 被两个线程共享 */

void *work(void *arg) {
    for (int i = 0; i < 1000000; i++)
        counter++;                   /* 非原子:读→加→写三步,会丢更新 */
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, work, NULL);
    pthread_create(&t2, NULL, work, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter=%ld (期望 2000000)\n", counter);
    return 0;                    /* 几乎总是小于 200 万 */
}
编译线程程序始终带 -pthread(gcc a.c -pthread)。但要知道它现在不再等于「链接线程库」:glibc 2.34 起 libpthread 已并入 libc,不加也能链接——在这类新系统上,gcc race.c -o race 不带该选项照样编译通过、照样跑出结果。别把这当成可以省的理由:旧 glibc、musl、其它 Unix 上依然会报 undefined reference to `pthread_create',而且 -pthread 还会定义 _REENTRANT 并调整编译期行为。它属于「没有任何理由省」的开关,只是它替你挡掉的报错,在新系统上已经看不见了。
数据竞争靠肉眼和测试都很难抓——它时有时无、换台机器就复现不了。用 TSan:gcc a.c -o a -pthread -fsanitize=thread -g,跑一遍就会直接打出 WARNING: ThreadSanitizer: data race 并指明是哪两个线程、在哪一行冲突(右边那段代码会被当场抓出来)。写多线程代码时把它默认开着,和 03 章的 ASan 一样,是性价比最高的一个开关。

本章前面那个数据竞争,靠这两件工具解决,各管一件事:互斥锁管「别同时改」,条件变量管「等着变」。

互斥锁:把三步变回一步

  • 进临界区前 lock、出来 unlock,保证同一时刻只有一个线程在动共享数据——「读→加→写」这三步中途就不会被人插进来。
  • 关键是凡是碰这份数据的地方都要加同一把锁。有一处漏了,其余全加也白搭:竞争只需要两个参与者,其中一个不守规矩就够了。

条件变量:为什么不能只用锁

  • 光有锁,「等队列非空」只能写成循环反复抢锁查看,即忙等——空转烧 CPU,还因为一直占着锁反而拖慢了那个本该来填数据的线程。
  • pthread_cond_wait 做的是一件原子的事:放开锁 + 让线程睡下。睡着时不占 CPU、也不占锁,生产者才进得来;被 signal 唤醒后它会自动重新拿回锁再返回,所以醒来那一刻你依旧安全地处在临界区里。
  • "锁 + 条件变量 + while 检查"这三件套就是生产者 / 消费者模型的标准配方,右边这段可以直接当模板抄。
#include <pthread.h>

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  ready_cv = PTHREAD_COND_INITIALIZER;
int ready = 0;

void *consumer(void *a) {
    pthread_mutex_lock(&mtx);
    while (!ready)                    /* while 而非 if:防虚假唤醒 */
        pthread_cond_wait(&ready_cv, &mtx);  /* 放锁+睡,醒来自动重新持锁 */
    /* ... 安全地消费共享数据 ... */
    pthread_mutex_unlock(&mtx);
    return NULL;
}

void *producer(void *a) {
    pthread_mutex_lock(&mtx);
    ready = 1;
    pthread_cond_signal(&ready_cv);      /* 唤醒等待的消费者 */
    pthread_mutex_unlock(&mtx);
    return NULL;
}
两个高频坑:①等条件必须用 while 而非 if——存在虚假唤醒,醒来后要重新检查条件是否真的成立。②用多把锁时固定加锁顺序,否则线程 A 持锁 1 求锁 2、B 持锁 2 求锁 1,谁也不让谁就死锁。C11 的 <threads.h> 提供了 mtx_t / cnd_t 这套标准化等价物,但工程实践仍以 pthreads 为主。
锁保护的是「数据」,不是「代码」——这是最容易搞反的一点。别问「这段函数要不要加锁」,要问「这份数据被哪些线程碰过,它们是不是都在拿同一把锁」。顺着这个问法,习惯就变成了:定义共享数据时,就在它旁边定义那把锁并写清注释(如 /* 由 mtx 保护 */),而不是等出问题了再回头找该在哪加。另外 pthread_cond_signal 只唤醒一个等待者,pthread_cond_broadcast 唤醒全部——拿不准时用 broadcast 更安全,代价只是多几次无用唤醒(反正有 while 兜着)。

不是所有共享都值得上一把锁。如果临界区里只有「给计数器加一」这一件事,加锁解锁的开销比那件事本身还大。

原子类型:把三步交给硬件

  • C11 的 <stdatomic.h> 提供原子类型:对 atomic_long 的自增由硬件指令保证不可分割,那个「读→加→写」的窗口从根上消失了,不需要锁。
  • 右边这段和线程那张卡的代码只差一行,结果却精确等于 2000000,把丢掉的更新一分不差地补了回来。
  • 适用范围也就到这了:计数器、标志位、单个指针的交换这类「一个变量、一步操作」。一旦临界区里要同时改两个变量并保持它们一致,原子就无能为力了——那是锁的活。

内存序:便宜背后的代价

  • 多线程下「我这个线程的写,什么时候对别的线程可见」并不是理所当然的——编译器和 CPU 都会重排指令。memory_order 就是你对这件事提出的要求。
  • 默认的 seq_cst 最强也最符合直觉(所有线程看到的操作顺序一致),代价是要插内存屏障。追求性能时可以退到 acquire / release,但那需要你能准确说清「哪个写必须在哪个读之前可见」。
  • 背后的硬件原理(为什么会重排、缓存之间怎么同步)见计算机组成页的「内存一致性模型」与「MESI」两卡。
#include <stdatomic.h>
#include <pthread.h>

atomic_long counter = 0;

void *work(void *a) {
    for (int i = 0; i < 1000000; i++)
        atomic_fetch_add(&counter, 1);  /* 原子自增,无需锁 */
    return NULL;
}
/* 这次 counter 精确等于 200 万——原子操作补回了本章前面丢掉的更新 */
「每个操作都是原子的」不等于「这段逻辑是原子的」——这是原子操作最大的误区。if (atomic_load(&flag) == 0) atomic_store(&flag, 1); 里两个调用各自原子,合起来却不是:两个线程可能都读到 0,然后都写入 1,双双认为「是我抢到的」。要做这种「看一眼再决定」的事,得用单条原子指令 atomic_compare_exchange_strong(CAS),把比较和写入合成一步。另外别把 memory_order_relaxed 当成「更快的默认值」随手用:它只保证该操作本身原子,不保证任何顺序,除了纯计数器之外用错了极难查——拿不准就留在默认的 seq_cst。
选型顺序,从便宜到贵:能不共享就不共享(每线程各持一份、最后汇总)> 简单共享用原子 > 复杂临界区用互斥锁 > 需要等待用条件变量。锁的粒度是性能关键:锁住的范围越小并行度越高,但也越容易漏保护——先做对,再做快。

C 标准库:深水区

标准库的深水区——手册里查得到签名,查不到「这么用会错」:strncpy 不保证留结尾符、atoi 无法报告失败、qsort 的比较函数写成相减会在大整数上排错、localtime 返回的指针会被下次调用冲掉。本章按「同一件事有三个函数时挑哪个、返回的指针归谁、哪些函数不可重入」组织;「函数怎么调、参数是什么」在下一章速查,两章是「深水区 vs 速查」的分工。

C 标准库有一批函数,名字看着像、行为差很远,而且用错了通常不报错、只是悄悄给个坏结果。这一章就是这批函数的对照表。

字符串复制:三选一

函数行为结论
strcpy(dst, src)不看目标大小,能写多长写多长只在已确认长度时用
strncpy(dst, src, n)源短就用 '\0' 填满剩下的 n 个字节;源长就不写结尾的 '\0'它不是「安全版 strcpy」——两种行为都出人意料
snprintf(dst, sizeof dst, "%s", src)总是以 '\0' 结尾;返回「本该写多少」默认就用它;返回值 ≥ 缓冲区大小 = 被截断了
strlcpyBSD 出身,行为最合直觉不是 C 标准,glibc 较新版本才有——可移植代码别依赖
strcat 在循环里每次都从头找结尾拼 n 段变成 O(n²):自己维护写入位置,或一次 snprintf
memcpy vs memmove前者要求区域不重叠可能重叠就用 memmove

返回的指针归谁:三类

类别代表函数规矩
指向静态缓冲localtime ctime asctime strtok getenv strerror inet_ntoa下次调用就被覆盖,不能 free,多线程下更危险——要留就立刻拷一份
调用方负责 freestrdup getline asprintf(后两个是 POSIX / GNU)忘了就是泄漏
指向入参内部strchr strstr strrchr不能 free;原串被释放或修改后它就失效
可重入版本localtime_r strtok_r strerror_r多线程一律用带 _r 的,由你传缓冲进去
strtok 的两个坑它会修改原串(把分隔符改成 '\0'),而且靠内部静态状态——嵌套使用必错,用 strtok_r

qsort / bsearch 的契约

规则说明
比较函数签名int cmp(const void *a, const void *b)——参数是指向元素的指针,里面要先转型再解引用
返回值三态:小于 0 / 0 / 大于 0,不是 bool
别写 return *x - *y;会溢出(大正数减大负数);写成 (*x > *y) - (*x < *y)
不保证稳定相等元素的相对顺序可能变——要稳定就把原下标编进比较键
bsearch 的前提数组必须已按同一个比较函数排好序,否则结果无意义(不报错)
找不到时bsearch 返回 NULL——它不告诉你「本该插在哪」,要那个就自己写二分(10 章)

字符串转数字

函数能不能报错结论
atoi(s)不能:"abc" 和 "0" 都给 0,溢出是 UB别用
strtol(s, &end, 10)三重判据:① end == s 说明一个字符都没解析成功 ② *end 非空说明有尾巴 ③ errno == ERANGE 说明溢出标准答案;调用前把 errno 清零
strtoul / strtod / strtoll同上strtoul 会把 "-1" 转成巨大的无符号数,要自己先拦负号
sscanf(s, "%d", &n)只能看「匹配了几项」快但粗:分不清「溢出」和「后面有垃圾字符」

标准库的边界

函数边界
rand() / srand()够用于游戏和洗牌,绝不能用于密码 / 令牌(那要用操作系统的随机源);rand() % n 分布有偏;不播种的话每次运行序列相同
<ctype.h> 的 isalpha 等参数必须是 unsigned char 或 EOF——直接传可能为负的 char 是 UB
setlocale(LC_ALL, "")会改变小数点符号、大小写规则、排序——解析配置文件时的隐形杀手(有的地区小数点是逗号)
宽字符 wchar_t宽度因平台而异(Linux 4 字节、Windows 2 字节);处理 UTF-8 通常不需要它,按字节处理更省事
signal()ISO C 只保证极少的行为;POSIX 下一律用 sigaction(07 章)
gets()C11 已经把它从标准里删了——看到它就说明这份代码很老
strncpy 是这一章最值得单独记住的反直觉函数。它的名字让人以为是「带上限的 strcpy」,实际上它源自一种早已消失的定长字段格式:源串短就用 '\0' 把整个缓冲填满(白白多写几千字节),源串长就一个 '\0' 都不写(后续任何 strlen 都会越界读)。把它从你的工具箱里删掉,写字符串只用 snprintf。
遇到一个不熟的库函数,先在 man 3 函数名 里确认三件事:① 出错时返回什么 ② 返回的指针归谁 ③ 是不是可重入。这三条覆盖了 C 标准库九成的误用——比记住函数本身重要得多。

「把一个字符串拷进缓冲区」这件最基本的事,C 给了你至少三个函数,而最常被推荐的那个(strncpy)恰恰是最容易用错的。选型看的不是签名,是契约——这也是本章其余各卡共同的判据。

选型表

  • strcpy(dst, src)——不做任何长度检查。只有在你能证明目标装得下时才用(比如刚 malloc(strlen(src)+1))。
  • strncpy(dst, src, n)——名字看着是「安全版 strcpy」,但它根本不是为此设计的(见下),日常别用。
  • snprintf(dst, n, "%s", src)——推荐的默认选择。永远补 \0,能截断,还能顺便拼接和格式化。返回值是「本该写入的长度」,比 n 大就说明被截断了——这是唯一可靠的截断检测方式(08 章写 HTTP 服务器时正栽在这上面)。
  • memcpy(dst, src, n)——拷贝已知长度的字节,不管 \0。处理二进制数据用它,而且它通常最快。区域重叠要用 memmove。

strncpy 的两个反直觉行为

它其实是为一种早已过时的定长字段格式设计的,于是有两个和「安全拷贝」预期完全不符的行为:

  • ① 源串太长时,它不补结尾符。把 10 个字符拷进 8 字节缓冲:
    结果 8 字节: abcdefgh    末字节值 = 104   <- 是 'h',不是 0
    此后这块内存不再是合法的 C 字符串,交给 strlen/printf("%s") 就会一路读到下一个碰巧的 0,直接读越界。
  • ② 源串太短时,它把剩下的空间全部填 0,而不是只补一个结尾符:
    strncpy(d, "ab", 8) 后的 8 字节: 97 98 0 0 0 0 0 0
    缓冲区很大而字符串很短时,这是白白的开销。
  • 非要用 strncpy,就必须自己手动补上结尾符:dst[n-1] = '\0';。但既然都要多写一行,不如直接用 snprintf。
  • gcc 认得这个坑,源串是字面量时会给出 warning: 'strncpy' output truncated copying 8 bytes from a string of length 10 [-Wstringop-truncation]——但它只在编译期看得见长度时才报,源串来自运行时输入就没有任何提示了。

两个和「长度」有关的常见错误

  • sizeof 和 strlen 不是一回事:char s[16] = "abc"; 的 sizeof s 是 16(缓冲区大小),strlen(s) 是 3(内容长度)。而一旦数组作为参数传进函数就退化成指针,sizeof 只剩指针大小——02 章「数组与字符串」讲过这个退化。
  • 循环 strcat 是 O(n²):strcat 每次都要从头扫到结尾才知道往哪儿接。在循环里拼 n 段,总代价是 n² 级的。正确做法是自己维护一个「当前写到哪」的指针或偏移,或者用 snprintf 的返回值往前推进。这不是微优化——拼几万段时是秒级和分钟级的差别。

比较:别写 == -1

  • strcmp 的契约只保证符号:小于返负、等于返 0、大于返正。不保证是 -1/0/1。
  • glibc 上strcmp("a","c")、strcmp("a","z")、strcmp("apple","banana") 全都返回 -1,但这只是这个实现碰巧如此——换个平台可能返回字符差值。写 if (strcmp(a,b) == -1) 的代码能在你的机器上通过全部测试,然后在别人的机器上悄悄失效。一律写 < 0 / == 0 / > 0。
/* 默认选择:snprintf——永远补 \0,还能检测截断 */
char dst[64];
int need = snprintf(dst, sizeof dst, "%s/%s", dir, name);
if (need < 0 || (size_t)need >= sizeof dst) {
    /* 被截断了:need 是「本该写入」的长度,不是实际写入的 */
    return -1;
}

/* 确定装得下时才用 strcpy:这里长度是自己算的 */
char *copy = malloc(strlen(src) + 1);
if (copy) strcpy(copy, src);

/* 二进制数据用 memcpy,不关心 \0;重叠区域用 memmove */
memcpy(buf, packet, payload_len);

/* 非要用 strncpy,必须自己补结尾符 */
strncpy(dst, src, sizeof dst);
dst[sizeof dst - 1] = '\0';      /* 少了这行就可能不是字符串 */

/* 循环拼接:别用 strcat,自己推进偏移 */
size_t off = 0;
for (int i = 0; i < n; i++) {
    int w = snprintf(out + off, sizeof out - off, "%s,", item[i]);
    if (w < 0 || (size_t)w >= sizeof out - off) break;   /* 满了 */
    off += (size_t)w;
}

/* 比较:只看符号,别拿返回值和 -1 比 */
if (strcmp(a, b) < 0) { }        /* 对 */
if (strcmp(a, b) == -1) { }      /* 错:不保证是 -1 */
这一整类 bug 的共同点是「测试全过,上线出事」:截断、少一个结尾符、缓冲区差一个字节,在短输入下全都表现正常,只有当输入恰好顶到边界时才暴露。所以测试字符串处理时,一定要专门喂正好等于缓冲区大小和比缓冲区大一个字节的输入。
另外别忘了给结尾符留位置:char buf[8] 能装的是 7 个字符加一个 \0。把「缓冲区大小」和「能装多少字符」当成同一个数,是差一错误的头号来源。
有 strlcpy / strlcat 的话优先用它们——BSD 出身,行为就是大多数人以为 strncpy 应该有的样子:永远补结尾符、返回「本该写入的长度」好检测截断。glibc 从 2.38 起才提供,更老的系统上没有;要跨平台要么自己实现一份(十来行),要么老实用 snprintf。不确定目标平台就用 snprintf,它到处都有。

C 没有所有权的语法,但标准库的每个返回指针的函数都有一份所有权约定——只不过写在手册里,不写在类型里。用错的后果分两种:泄漏,或者用到已经失效的数据。这组约定相当于 C++ 那边的「迭代器何时失效」:不是「怎么调用」,是「拿到手的东西能活多久」。

标准库返回的指针只有三类

  • ① 调用方负责 free:malloc/calloc/realloc,以及 strdup(POSIX)。拿到就欠了一笔债,忘了还就是泄漏。
  • ② 指向你自己传进去的缓冲区内部:strchr/strstr/strtok/memchr 返回的是入参内部的某个位置。不用释放,但它的寿命跟着原缓冲区——原缓冲区被 free 或出了作用域,这个指针立刻悬空。
  • ③ 指向库内部的静态缓冲:strerror/localtime/gmtime/asctime/ctime/getenv。不能释放,而且下一次调用同一个函数就会把内容冲掉。这一类最危险,因为出错时数据是「变了」而不是「没了」。

第三类有多容易踩:localtime

先取 1970 年的时间存着,再调一次 localtime 取另一个时间,然后回头读第一个指针:

第一次 localtime 记下  1970
第二次调用之后,同一个指针读到  1999
两个指针是否同一地址: 是
  • 两次调用返回的是同一个地址——第二次直接把第一次的结果覆盖了。你手里那个「1970 年的 struct tm」在你没碰它的情况下变成了 1999 年。
  • 典型出错场景:printf("%s -> %s", ctime(&start), ctime(&end));——两个 ctime 返回同一个缓冲,打出来是两个一样的时间。
  • 解法:拿到就立刻拷走(struct tm copy = *localtime(&t);),或者用 POSIX 的可重入版 localtime_r(&t, &my_tm)——由你提供缓冲区,不碰静态数据。

strtok:既改你的入参,又有隐藏状态

  • 它就地改写原字符串:把每个分隔符替换成 \0。"a,b,c" 切完之后,原缓冲区变成 97 0 98 0 99 0——原串被破坏了,再想用完整的它已经来不及。所以不能拿它切字符串字面量(那是只读内存,会段错误),要先拷一份。
  • 它还把「切到哪了」存在库内部的静态变量里,所以第二次起传 NULL。后果是:不能同时切两个字符串(会互相打断),多线程里用它是灾难,被调用的函数里如果也用了 strtok,你的循环会莫名其妙地乱掉。
  • 替代:POSIX 的 strtok_r(把状态交给你自己保存),或者干脆用 strchr 自己走一遍——通常也就多五行。

怎么把约定写进自己的代码

  • C 表达不了所有权,所以只能靠命名和文档:返回需要释放的内存时,函数名里带上 _new/_dup/_create,并在头文件的声明上方一行写明「调用方负责 free」。
  • 更省事的办法是反过来设计:让调用方传缓冲区进来(像 snprintf、localtime_r 那样),函数只负责填。这样根本没有所有权转移,也就没有泄漏——标准库后来补的可重入版本,走的都是这条路。
/* ① 调用方负责 free */
char *s = strdup(name);
if (!s) return -1;
/* ... 用 s ... */
free(s);

/* ② 指向入参内部:不用 free,但寿命跟着 line 走 */
char *p = strchr(line, ':');
if (p) *p = '\0';            /* 改的是 line 自己 */

/* ③ 静态缓冲:下一次调用就没了——立刻拷走 */
struct tm snapshot = *localtime(&t);   /* 拷值,不存指针 */

/* 或者用可重入版,缓冲区由你提供(POSIX) */
struct tm buf;
localtime_r(&t, &buf);

/* strtok 会破坏原串:先拷一份再切 */
char line2[256];
snprintf(line2, sizeof line2, "%s", original);
char *save;                          /* strtok_r 的状态由你保存 */
for (char *tok = strtok_r(line2, ",", &save);
     tok;
     tok = strtok_r(NULL, ",", &save)) {
    /* 处理 tok */
}

/* 自己的函数:把约定写在名字和注释里 */
/* 返回新分配的字符串;调用方负责 free */
char *config_get_new(const char *key);

/* 更好:让调用方给缓冲区,没有所有权转移 */
int config_get(const char *key, char *out, size_t cap);
最隐蔽的一种:把第 ② 类当成第 ① 类去 free。free(strchr(s, ',')) 传的是块中间的地址,不是 malloc 返回的原始指针——这是未定义行为,通常当场崩溃或者破坏堆结构。free 只能收下 malloc 一族原样返回的那个指针。
另一个高频错误是把静态缓冲的内容存起来当长期数据:char *msg = strerror(errno); 存进结构体,几行之后另一处代码又调了一次 strerror,你存的那条错误信息就变成别人的了。要留就 strdup 一份,或者当场拷进自己的缓冲区。
判断一个陌生函数属于哪一类,看手册里有没有「static」「the returned pointer may be invalidated」这类字眼,或者有没有配套的 _r 版本——存在 xxx_r 几乎就等于宣告「xxx 用了静态缓冲、不可重入」。localtime_r、strtok_r、strerror_r、readdir_r 都是这么来的。多线程程序里看到没有 _r 的那一版,先停下来想一秒。

这是 C 标准库里唯一的「泛型算法」,也是最容易写出能跑、能过测试、但结果是错的代码的地方。它对应 C++ 那边「让自定义类型进容器:比较器与哈希」那张卡——问题不在怎么调用 qsort,在于你交给它的那个函数满不满足契约。

头号陷阱:比较函数写成相减

  • 网上最常见的写法是 return *(int*)a - *(int*)b;。它在小整数上完全正确,于是能通过你写的所有测试。
  • 但两个大整数相减会溢出(有符号溢出还是未定义行为),符号就翻了。拿 {INT_MAX, -2, 0, INT_MIN, 5}
    相减版结果:  0  5  2147483647  -2147483648  -2      <- 完全错乱
    正确版结果:  -2147483648  -2  0  5  2147483647
  • 正确写法是比较而不是相减:return (x > y) - (x < y);——这个小惯用法一次给出 -1/0/1 三态,没有任何溢出风险。

契约的其余部分

  • 参数是 const void *,指向的是元素本身。比较 char * 数组时,参数是指向指针的指针——要写 *(const char **)a,少一层解引用是这里的经典错误。
  • 必须是一致的全序:cmp(a,b) 和 cmp(b,a) 符号相反、相等有传递性。比较函数如果自相矛盾(比如里面用了随机数或浮点 NaN),qsort 的行为是未定义的——不只是结果乱,是可能越界访问。
  • 浮点要小心 NaN:任何和 NaN 的比较都返回假,于是 (x>y)-(x<y) 对 NaN 恒为 0,破坏传递性。数据里可能有 NaN 就得先单独处理。
  • qsort 不保证稳定——相等元素的原有相对次序标准不作要求。glibc 的实现在某些规模下碰巧是稳定的,这恰恰是危险所在:依赖稳定性的代码能在你的机器上通过测试,换个数据规模或换个平台就出错。要稳定就把原始下标作为最后一个比较字段放进比较函数里。

bsearch 的前提:数组必须已排序

  • bsearch 不会检查数组是否有序,也无法检查——它就是二分,走错方向就再也回不来。
  • 在未排序的 {5,1,4,2,3} 里逐个查 1 到 5,
    查 1 -> 没找到(但它就在数组里)
    查 2 -> 没找到
    查 3 -> 没找到
    查 4 -> 找到
    查 5 -> 没找到
  • 危险的方向是「漏查」而不是「查错」:它安静地告诉你「不存在」,而元素明明在那儿。没有崩溃、没有警告——这类 bug 只能靠「用之前先确认排过序」的纪律来防。
  • 而且 bsearch 和 qsort 必须用同一个比较函数(至少是同一个序)。用 A 排序、用 B 查找,结果同样是未定义的。
#include <stdlib.h>

/* 整数比较:三态惯用法,无溢出风险 */
static int cmp_int(const void *a, const void *b) {
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);        /* 不要写 x - y */
}

/* 字符串数组:元素本身是 char*,所以参数是 char** */
static int cmp_str(const void *a, const void *b) {
    return strcmp(*(const char **)a, *(const char **)b);
}

/* 多字段 + 保稳定:把原始下标当最后一个比较字段 */
struct rec { int key; int idx; };
static int cmp_rec(const void *a, const void *b) {
    const struct rec *x = a, *y = b;
    if (x->key != y->key) return (x->key > y->key) - (x->key < y->key);
    return (x->idx > y->idx) - (x->idx < y->idx);   /* 打破平局 */
}

int a[] = {5, 1, 4, 2, 3};
size_t n = sizeof a / sizeof a[0];

qsort(a, n, sizeof a[0], cmp_int);          /* 必须先排序 */

int key = 4;
int *hit = bsearch(&key, a, n, sizeof a[0], cmp_int);
if (hit) { /* 命中,hit 指向数组里那个元素 */ }
qsort 的元素大小参数错了不会报错,只会给你一堆垃圾。四个参数里 sizeof 那个写成 sizeof(a)(整个数组)而不是 sizeof(a[0])(单个元素),编译器完全不会拦——它接受的是 size_t,多大都合法。运行结果是元素被按错误的粒度搬来搬去,数据当场稀碎。写 qsort 时逐个核对四个参数,这是 C 里少数几个「参数写错但类型全对」的高危调用。
还有一条:比较函数里别做 I/O、别用全局状态。qsort 不保证调用次数和顺序,在里面 printf 调试会让你更困惑;需要额外上下文时,标准 C 没有传参的办法(GNU 有 qsort_r),只能靠把上下文塞进元素本身。
(x > y) - (x < y) 这个惯用法值得背下来。它对任何可比较的类型都成立(整数、浮点、指针),一次给出三态,没有溢出、不依赖类型宽度,而且编译器通常能生成无分支的代码。看到别人写 a - b 当比较函数,基本可以断定作者没考虑过大数——顺手改掉。

「把用户输入的字符串变成数字」是每个程序都要做的事,而 C 给的三个选项里,最好写的那个(atoi)无法报告任何错误。这是本章「选型」主题最干净的一个例子。

atoi:失败和 0 长得一模一样

  • atoi("abc") = 0
    atoi("0")   = 0
    两者无法区分。输入是垃圾还是真的 0,函数没有任何办法告诉你——它没有返回错误的通道。
  • 更糟的是溢出是未定义行为:atoi("99999999999999999999") 不保证返回任何特定值,也不设 errno。
  • 结论很干脆:atoi/atol/atof 在任何要处理不可信输入的地方都不该出现。它们只适合「这个字符串是我自己刚生成的、绝对合法」的场合,而那种场合通常也不需要转换。

strtol:三个判据缺一不可

strtol 能报告一切,代价是你要检查三件事:

  • ① 一个数字都没解析出来:end == 输入。这时返回 0,但没解析成功。
  • ② 溢出:errno == ERANGE,返回值是 LONG_MAX/LONG_MIN。因为返回值本身是合法数值,只能靠 errno 判断——所以调用前必须 errno = 0(原因见 07 章 errno 卡)。
  • ③ 后面还有尾巴:*end != '\0',比如 "12abc" 会解析出 12 然后停在 a。要不要接受取决于你的场景,但必须是有意识的决定,不能默认忽略。
  • 还有个细节:第三个参数是进制,传 0 表示「自动识别」——0x 开头按十六进制、0 开头按八进制。所以用户输入 "010" 会变成 8。要十进制就明确写 10。

sscanf:方便,但错误信息很粗

  • sscanf(s, "%d", &v) 返回成功赋值的项数,所以至少能判断「成没成」——比 atoi 强。
  • 但它区分不了溢出(行为未定义),也不告诉你停在哪里,尾部有垃圾时默认接受。适合解析你自己控制的格式,不适合校验外部输入。
  • 02 章「健壮读入」讲的 fgets + strtol 组合之所以是推荐姿势,原因就在这里:读入和解析分开,各自都能报错。
#include <stdlib.h>
#include <errno.h>

/* 把字符串严格解析成 long;成功返 0,失败返 -1 */
int parse_long(const char *s, long *out) {
    char *end;
    errno = 0;                       /* 判据② 的前提 */
    long v = strtol(s, &end, 10);      /* 明确写 10,别传 0 */

    if (end == s)          return -1;   /* ① 一个数字都没有 */
    if (errno == ERANGE)   return -1;   /* ② 溢出 */
    while (*end == ' ' || *end == '\n') end++;   /* 容忍尾部空白 */
    if (*end != '\0')       return -1;   /* ③ 还有别的垃圾 */

    *out = v;
    return 0;
}

/* 要 int 就再加一道范围检查——strtol 只保证 long 的范围 */
int parse_int(const char *s, int *out) {
    long v;
    if (parse_long(s, &v) != 0) return -1;
    if (v < INT_MIN || v > INT_MAX) return -1;
    *out = (int)v;
    return 0;
}

/* 反例:这两行是等价的「我不在乎对不对」 */
int n1 = atoi(argv[1]);              /* 失败也返回 0 */
int n2 = atoi("abc");                 /* 同样是 0 */
「检查了返回值」不等于「检查对了」。strtol 最常见的半吊子用法是只判 errno 不判 end——于是 "abc" 顺利返回 0、errno 也是 0,程序把它当成了合法的零。三个判据是三种不同的失败,缺任何一个都留下一类漏洞。
另外注意 strtol 会跳过前导空白并接受正负号," -42" 是合法输入。如果你的场景不该接受负数(比如端口号、长度),要自己单独判——它不会替你把关。
浮点用 strtod,判据和 strtol 一样三条,但多一个坑:小数点是本地化相关的。在某些 locale 下(德语、法语等)小数分隔符是逗号,strtod("3.14", ...) 可能只解析出 3。程序如果要处理固定格式的数据(配置文件、网络协议),启动时把数值 locale 显式设成 C:setlocale(LC_NUMERIC, "C");,别让用户的系统语言影响你的解析结果。

C 标准库有几个部分,最好的用法是知道它们存在、然后绕开。这不是挑刺——它们大多是 1989/1999 年为当时的问题设计的,今天的需求已经超出了它们的设计目标。认清边界,比硬用它们更省事。

rand:够用在哪,不够用在哪

  • 绝对不能用于任何安全用途——密钥、令牌、密码、会话 ID。它是可预测的伪随机数,知道种子就能算出整个序列。安全场景在 Linux 上用 getrandom() 或读 /dev/urandom。
  • rand() % n 有取模偏差:如果 RAND_MAX + 1 不能被 n 整除,小余数出现的概率会略高。不过要说清楚量级——glibc 上 RAND_MAX 是 2147483647,取模到个位数时偏差约为七亿分之一,实跑三百万次根本测不出来。所以对游戏掷骰子这类用途,% n 完全够用;只有当 n 大到和 RAND_MAX 一个量级时,偏差才会变得实际。
  • 真正该注意的反而是:rand 的随机性质量不由标准保证,不同实现差别很大;不调 srand 时序列每次运行都一样(这在调试时是优点,发布时是 bug);而 srand(time(NULL)) 在一秒内启动多次会拿到同一个种子。

宽字符与 locale:现代程序基本不该碰

  • wchar_t 的宽度由实现决定——Linux 上通常 4 字节,Windows 上 2 字节。它不是一个可移植的 Unicode 方案,用它写跨平台代码只会痛苦。
  • 今天的标准答案是:内部一律用 UTF-8 存在普通的 char 数组里,只在真正需要按字符(而非字节)操作时才解码。大部分程序——拼接、比较、传输、存储——根本不需要解码,UTF-8 的设计就是为了让字节操作依然安全。
  • setlocale 会全局地改变 printf 的小数点、strcoll 的排序规则、isalpha 的判定。在库代码里调它是在给调用方埋雷。要调就只在 main 里调一次,而且明确指定要改哪一类(如只改 LC_TIME)。

signal:ISO C 的那份太弱

  • ISO C 的 signal() 只保证极少的东西,信号处理函数里能合法做的事少得惊人:只能读写 volatile sig_atomic_t 类型的变量、调用 _Exit 之类的少数函数。printf、malloc、free 在信号处理函数里都是不安全的——它们可能正被主流程执行到一半。
  • 标准的处理模式是:处理函数里只设一个标志位,真正的活儿回到主循环里做。
  • 要认真处理信号就用 POSIX 的 sigaction(07 章 POSIX 那章有),它能精确控制重启语义和屏蔽字;signal() 的行为在不同系统上甚至不一致。

setjmp/longjmp:看着像异常,其实不是

  • 它能跨函数跳转,常被拿来模拟异常。但它不做任何清理——跳过去的路上,那些已经 malloc 的内存、已经 open 的文件、已经加上的锁,全都不会被释放。
  • 而且跳转后,非 volatile 的局部变量的值是未定义的。
  • 结论:除非在写解释器、协程这类确实需要非局部跳转的东西,老老实实用返回值传错误,配合 03 章那个 goto cleanup 的单出口清理链。
/* rand:可以用在哪 */
srand((unsigned)time(NULL));       /* 一秒内多次启动会撞种子 */
int dice = rand() % 6 + 1;         /* 游戏掷骰子:偏差可忽略 */

/* 安全用途:别用 rand,走系统的随机源 */
/* #include <sys/random.h> */
unsigned char token[32];
getrandom(token, sizeof token, 0);   /* Linux;或读 /dev/urandom */

/* 文本:UTF-8 存在普通 char 里,别碰 wchar_t */
const char *name = "张三";            /* 6 字节,2 个字符 */
strlen(name);                        /* 6:字节数,不是字符数 */

/* 解析固定格式前,把数值 locale 钉死 */
setlocale(LC_NUMERIC, "C");          /* 只在 main 里调 */

/* 信号处理函数:只设标志,别干活 */
static volatile sig_atomic_t should_stop = 0;

static void on_sigint(int sig) {
    (void)sig;
    should_stop = 1;                  /* 唯一安全的操作 */
}

while (!should_stop) {
    /* 真正的收尾工作放在这里做,不在处理函数里 */
}
本章这些「别用」的结论都有前提,别当教条。atoi 在处理你自己刚 snprintf 出来的字符串时没有任何问题;rand 做游戏和模拟完全够用;strtok 在单线程里切一个临时缓冲也挺好。真正要判断的是「这个输入可信吗、这段代码会被并发调用吗、失败了要不要能报告」——想清楚这三个问题,选哪个函数自然就定了。
反过来最要不得的是因为「听说不安全」就到处套一层自己写的封装:手写的字符串函数出 bug 的概率,通常比正确使用 snprintf 高得多。
「标准库没有」不等于「不能用」。C 标准库刻意保持极小,很多今天算基础设施的东西它都没有:动态字符串、哈希表、线程池、JSON、正则、高精度计时。这不是让你每次都自己造轮子——POSIX(07 章)补上了系统那一层,而单头文件库(single-header library)补上了数据结构那一层:一个 .h 丢进项目就能用,不引入构建依赖。知道边界在哪,才知道该往哪儿找。

C 标准库速查

本章是上一章「C 标准库:深水区」的速查伴侣——那章讲该挑哪个函数、返回的指针归谁,这一章按头文件把签名一条条列出来备查,对应 C++ 页「标准库 STL」与「标准库速查」的分工。C 标准库不大,却是每个 C 程序的地基:格式化 I/O、动态内存、字符串与内存操作、字符分类、数学、时间、布尔与变参、错误处理、信号与非局部跳转、本地化与宽字符。这一章按头文件分卡——每个函数上方一行注释按「作用|入参|成功返回|失败返回」四段速读(纯函数无失败段,同构的一组把共有段落提到组头),当作写 C 时随手可翻的函数参考。重复的用法只指向 02/04 章展开处,不再赘述。注意边界:这里只收 ISO C 标准头;unistd.h、sys/* 属于 POSIX,glibc 私有扩展属于 GNU——都不是标准库,另见 07 章「POSIX / 系统编程」。

C 标准库一共二十几个头文件,日常只会用到其中十来个。先在下表按「我要干什么」定位到头文件,再翻本章对应的那张卡。

按任务找

要做的事头文件代表函数
打印与读取<stdio.h>printf snprintf fgets scanf fopen fread fseek perror
内存、转换、退出、排序<stdlib.h>malloc free strtol qsort bsearch exit rand getenv
字符串与内存块<string.h>strlen strcmp strchr strstr memcpy memmove memset
判断字符类别<ctype.h>isdigit isalpha isspace toupper(参数要转 unsigned char)
数学<math.h>sqrt pow fabs floor round——链接要加 -lm
时间<time.h>time clock localtime strftime difftime
固定宽度整数<stdint.h> / <inttypes.h>int32_t uint64_t SIZE_MAX / PRId64
布尔、变参、通用定义<stdbool.h> <stdarg.h> <stddef.h>bool(C23 起内建)/ va_list va_arg / size_t offsetof NULL
断言与错误码<assert.h> <errno.h>assert(-DNDEBUG 会整条删掉)/ errno strerror
边界常量<limits.h> <float.h>INT_MAX CHAR_BIT / DBL_EPSILON
信号(可移植子集)<signal.h>signal raise sig_atomic_t——POSIX 下改用 sigaction(07 章)
跨函数跳转<setjmp.h>setjmp longjmp——用得极少,且与局部变量、资源清理相处得很差
本地化与宽字符<locale.h> <wchar.h> <wctype.h>setlocale wchar_t wprintf(05 章讲了它的坑)
原子与线程<stdatomic.h> <threads.h>C11;<threads.h> 是可选的,实际项目基本用 pthread(04、07 章)
对齐、类型泛型、复数、浮点环境<stdalign.h> <tgmath.h> <complex.h> <fenv.h>用得少,但知道它们存在

三条查法

你手上有的去哪
一个函数名,想知道签名与返回值man 3 函数名——第 3 节是库函数,第 2 节是系统调用(07 章)
「这个函数安全吗 / 返回的指针归谁」05 章:标准库里最容易用错的那一批全在那儿
标准库里没有的(网络、目录、进程)07 章 POSIX——C 标准库故意不管这些,它要能在没有操作系统的机器上也成立
C 标准库和 POSIX 是两套东西,混在一起是最大的困惑来源。printf / malloc / strlen 属于 C 标准,任何平台都有;open / fork / socket / opendir 属于 POSIX,Windows 原生并不提供(所以本页建议用 WSL2)。看到一个函数在 Windows 上编不过,先确认它属于哪一套——这比找「替代库」更能省时间。
man 是这一章真正的主角:man 3 printf 给你完整的格式串表,man 3 qsort 给你比较函数签名。本地的 man 页比多数中文博客准确,而且永远和你机器上的实现一致。查不到就 man -k 关键词 按描述搜。

<stdio.h> 管标准输入输出:格式化打印、按流读写、文件定位与缓冲。三条标准流 stdin/stdout/stderr 类型都是 FILE*。下面把常用函数各配一个最小示例;用法在 02 章「格式化输出/输入」「字符流」「文件 I/O」有详解。

/* —— 格式化输出 —— */
/* 格式化输出到 stdout|传 格式串+实参|成功返写出的字符数|出错返负数 */
printf("%d 项\n", n);
/* 格式化输出到指定流|传 流+格式串+实参|成功返写出字符数|出错返负数 */
fprintf(stderr, "错误: %s\n", msg);
char buf[64];
/* 安全格式化到缓冲|传 缓冲+容量n+格式|成功返本应写入长度(≥n 即被截断,最多写 n-1 字符补 '\0')|编码出错返负数 */
int need = snprintf(buf, sizeof buf, "u-%d", id);
/* 格式化到缓冲(不限长、易溢出,优先 snprintf)|传 缓冲+格式|成功返写出字符数|出错返负数 */
sprintf(buf, "%d", id);
/* snprintf 的 va_list 版(自封装日志用,见「布尔·变参」卡)|传 缓冲+容量+格式+va_list|成功返本应长度|出错返负数 */
vsnprintf(buf, sizeof buf, fmt, ap);
/* fprintf 的 va_list 版|传 流+格式+va_list|成功返写出字符数|出错返负数 */
vfprintf(stderr, fmt, ap);

/* —— 格式化输入 —— */
int a, b;
/* 从 stdin 按格式读|传 格式串+目标地址|成功返匹配并赋值的项数(应 ==2)|到 EOF 返 EOF */
scanf("%d %d", &a, &b);
/* 从字符串按格式解析|传 源串+格式+目标地址|成功返匹配项数|首项前即失败返 EOF */
sscanf("3,5", "%d,%d", &a, &b);
/* 从流按格式读|传 流+格式+目标地址|成功返匹配项数|到 EOF/出错返 EOF */
fscanf(fp, "%d", &a);

/* —— 文件打开与整块读写 —— */
/* 打开文件|传 路径+模式(r/w/a,+b 二进制)|成功返 FILE*|失败返 NULL */
FILE *fp = fopen("rec.bin", "rb");
/* 按 errno 打印「前缀: 描述」到 stderr|传 前缀串|无返回值 */
if (!fp) { perror("fopen"); return 1; }
/* 按块读|传 缓冲+单块大小+块数+流|成功返读到的块数|少于请求数即遇 EOF/错误 */
size_t got = fread(rec, sizeof *rec, 10, fp);
/* 按块写|传 源+单块大小+块数+流|成功返写出的块数|少于请求数即写出错 */
fwrite(rec, sizeof *rec, got, fp);
/* 关闭并刷新流|传 流|成功返 0|出错返 EOF */
fclose(fp);

/* —— 行与字符读写 —— */
char line[256];
/* 读一行|传 缓冲+容量n+流|成功返 line(最多读 n-1 字符含换行、补 '\0')|EOF/错误返 NULL */
fgets(line, sizeof line, stdin);
/* 写字符串到流(不含 '\0'、不换行)|传 字符串+流|成功返非负|出错返 EOF */
fputs(line, stdout);
/* 写字符串到 stdout 并自动补换行|传 字符串|成功返非负|出错返 EOF */
puts("done");
/* 从 stdin 读一个字符(=fgetc(stdin),须用 int 接)|无参|成功返字符 0-255|到尾/出错返 EOF */
int ch = getchar();
/* 写一个字符到 stdout(=fputc(ch,stdout))|传 字符|成功返该字符|出错返 EOF */
putchar(ch);
/* 从流读一个字符(getc 是其宏版)|传 流|成功返字符|到尾/出错返 EOF */
ch = fgetc(fp);
/* 向流写一个字符(putc 是其宏版)|传 字符+流|成功返该字符|出错返 EOF */
fputc(ch, fp);
/* 退回一个字符到流供下次读|传 字符+流|成功返该字符|失败返 EOF(至少保证退 1 个) */
ungetc(ch, fp);

/* —— 刷新、定位与错误 —— */
/* 刷新流缓冲|传 流(NULL=所有输出流)|成功返 0|出错返 EOF */
fflush(stdout);
/* 设置流缓冲模式|传 流+缓冲区+模式+大小(_IONBF 无/_IOLBF 行/_IOFBF 全)|成功返 0|失败返非 0 */
setvbuf(stdout, NULL, _IONBF, 0);
/* 移动读写位置|传 流+偏移+基准(SEEK_SET/CUR/END)|成功返 0|失败返 -1 */
fseek(fp, 0, SEEK_END);
/* 取当前读写位置(字节偏移,此处即文件大小)|传 流|成功返偏移量|出错返 -1L */
long sz = ftell(fp);
/* 回到流开头并清错误标志(=fseek(fp,0,SEEK_SET))|传 流|无返回值 */
rewind(fp);
/* 测流是否到文件尾(区分「读完」与「读错」)|传 流|到尾返非 0 否则 0 */
if (feof(fp)) { }
/* 测流是否出错|传 流|出错返非 0 否则 0 */
if (ferror(fp)) { }
/* 清流的 EOF 与出错标志|传 流|无返回值 */
clearerr(fp);
/* 删除文件(rename("a","b") 改名/移动)|传 路径|成功返 0|失败返非 0 */
remove("old.tmp");
三个高频坑。①fflush(stdin) 是未定义行为——fflush 只对输出流有定义,网上流传的「清空输入缓冲」写法在很多平台上什么也不做;要丢弃残留输入就自己 while ((c = getchar()) != '\n' && c != EOF);。②scanf 不吃掉行尾换行,紧接着的 fgets 会立刻读到一个空行,这是「程序没等我输入就跑过去了」的头号原因。③缓冲模式随输出去向而变:stdout 连终端时是行缓冲、重定向进管道或文件就变成全缓冲,而 stderr 不缓冲——于是「直接跑好好的,一重定向日志顺序就乱了」。要紧的输出后跟一次 fflush(stdout)。
拼字符串一律用 snprintf(永不越界,返回值 ≥ 容量即说明被截断),别用 sprintf;调试信息走 fprintf(stderr, ...)——stderr 不缓冲,程序崩溃时也不会丢。

<stdlib.h> 是杂而关键的工具箱:动态内存、字符串转数值、随机数、通用排序查找、进程控制与环境访问。下面把常用函数各配一个最小示例;动态内存详见 03 章。

/* —— 动态内存 —— */
/* 分配未初始化内存|传 字节数|成功返内存指针|失败返 NULL */
int *a = malloc(n * sizeof *a);
/* 分配并清零(内置 n×size 溢出检查)|传 元素个数+单块大小|成功返内存指针|失败返 NULL */
int *b = calloc(n, sizeof *b);
/* 调整已分配块大小、可能搬迁|传 原指针+新字节数|成功返新指针|失败返 NULL 且原块仍有效 */
a = realloc(a, 2*n * sizeof *a);
/* 释放 malloc 系分配的内存|传 该内存指针(可 NULL)|无返回值 */
free(b);

/* —— 字符串转数值 —— */
/* 串转 int(不推荐,无法报错)|传 字符串|成功返值|遇非法返 0 */
int i = atoi("42");
/* 串转 double(不推荐,无法报错)|传 字符串|成功返值|遇非法返 0 */
double d = atof("3.14");
char *end;
/* 串转 long|传 串+endptr+base(0/8/10/16)|成功返值、endptr 指向停下处|溢出返 LONG_MAX/MIN 且 errno=ERANGE,无法转换返 0;v=42 */
long v = strtol("2a", &end, 16);
/* 串转 unsigned long(strtol 无符号版)|传 串+endptr+base|成功返值|溢出返 ULONG_MAX 且 errno=ERANGE;u=255 */
unsigned long u = strtoul("ff", &end, 16);
/* 串转 double|传 串+endptr|成功返值|溢出返 ±HUGE_VAL 且 errno=ERANGE,无法转换返 0;x=1500.0 */
double x = strtod("1.5e3", &end);
/* 串转 long long(64 位版,另有 strtoull)|传 串+endptr+base|成功返值|溢出返 LLONG_MAX/MIN 且 errno=ERANGE */
long long ll = strtoll("9000000000", &end, 10);

/* —— 随机数 —— */
/* 设随机序列起点(只播一次)|传 种子|无返回值 */
srand(time(NULL));
/* 取伪随机数|无参|返回 [0, RAND_MAX] 的 int(无失败) */
int r = rand() % 100;

/* —— 排序与查找 —— */
/* 通用快排、原地排序(cmp 见 03 章)|传 首地址+元素个数+元素大小+比较函数|无返回值 */
qsort(a, n, sizeof *a, cmp);
int key = 7;
/* 有序数组二分查找|传 键地址+数组+个数+元素大小+比较函数|命中返元素指针|未命中返 NULL */
int *hit = bsearch(&key, a, n, sizeof *a, cmp);

/* —— 进程控制 —— */
/* 注册退出回调(后注册先执行)|传 回调函数|成功返 0|失败返非 0 */
atexit(cleanup);
/* 正常终止进程(刷新并关闭所有流)|传 退出码|不返回 */
exit(EXIT_SUCCESS);
/* 异常终止(触发 SIGABRT、不做清理)|无参|不返回 */
abort();

/* —— 环境与整数 —— */
/* 取环境变量值|传 变量名|存在返值字符串|不存在返 NULL */
char *path = getenv("PATH");
/* 交 shell 执行命令|传 命令串|成功返命令退出状态|无法启动 shell 返 -1 */
system("ls -l");
/* 取绝对值|传 int|返回其绝对值(无失败);m=5 */
int m = abs(-5);
/* 取绝对值(long 版)|传 long|返回其绝对值(无失败);ml=5 */
long ml = labs(-5L);
/* 同时求商与余|传 被除数+除数|返回 div_t{.quot 商,.rem 余}(无失败);.quot=3、.rem=2 */
div_t qr = div(17, 5);
别用 atoi 解析用户输入——它没有任何报错手段。atoi("abc") 返回 0,和用户真的输入 "0" 完全无法区分;溢出时更是直接未定义行为。正确做法是 strtol:它给你两条独立的错误线索——endptr 指向停下来的位置(解析 "abc" 时 endptr 仍等于输入起点,说明一个字符都没吃进去),errno 在溢出时被置为 ERANGE(传 20 位数字返回 9223372036854775807 即 LONG_MAX,同时 errno == ERANGE)。调用前记得先 errno = 0。
atoi/atof 完全不报错,能用 strtol/strtod 就别用它们;分配数组优先 calloc(清零且防 n×size 溢出)。

<string.h> 提供以 '\0' 结尾的字符串操作,以及不看内容的原始内存块操作(mem* 一族)。下面把常用函数各配一个最小示例。

char buf[32];

/* —— 长度与复制 —— */
/* 拷贝字符串(含 '\0',dst 须够大)|传 dst+src|返回 dst(无失败) */
strcpy(buf, "hello");
/* 求串长|传 字符串|返回长度(不含 '\0',无失败);此处 5 */
size_t n = strlen(buf);
/* 定长拷贝(源超长不补 '\0',详见 02 章)|传 dst+src+n|返回 dst(无失败) */
strncpy(buf, "hi", 3);
/* 拼接到 dst 末尾|传 dst+src|返回 dst(无失败) */
strcat(buf, ", world");
/* 定长拼接(最多追加 n 字节并补 '\0')|传 dst+src+n|返回 dst(无失败) */
strncat(buf, "!!!", 2);
/* malloc 一份副本并拷入(POSIX/C23,用完 free)|传 字符串|成功返新指针|失败返 NULL */
char *dup = strdup("hi");

/* —— 比较 —— */
/* 按字典序比较整串|传 两串|返回 负/0/正(相等为 0,无失败) */
if (strcmp(buf, "hello, world") == 0) { }
/* 只比前 n 字节|传 两串+n|返回 负/0/正(无失败);此处 0 */
strncmp("hello", "help", 3);

/* —— 查找与切分 —— */
/* 找字符首次出现|传 串+字符|命中返该处指针|未命中返 NULL */
char *c = strchr(buf, ',');
/* 找字符最后一次出现|传 串+字符|命中返指针|未命中返 NULL */
char *e = strrchr(buf, 'l');
/* 找子串首次出现|传 串+子串|命中返指针|未命中返 NULL */
char *w = strstr(buf, "world");
/* 找「集合内任一字符」首次出现|传 串+字符集合|命中返指针|未命中返 NULL */
char *p = strpbrk(buf, ",;");
/* 数开头连续「属于集合」的字符|传 串+集合|返回该长度(无失败) */
size_t pre = strspn(buf, "0123456789");
/* 数开头连续「不属于集合」的字符(即到首个分隔符的长度)|传 串+集合|返回该长度(无失败) */
size_t cut = strcspn(buf, ",;");
/* 按分隔符切分(改写原串、不可重入)|传 串+分隔符集合(续切传 NULL)|命中返下一段指针|无更多返 NULL */
char *tok = strtok(s, ",;");

/* —— 内存块操作 —— */
/* 拷 n 字节(区间不可重叠)|传 dst+src+n|返回 dst(无失败) */
memcpy(dst, src, 16);
/* 允许重叠的安全拷贝|传 dst+src+n|返回 dst(无失败) */
memmove(buf + 1, buf, 5);
/* 把 n 字节全置为 c(常用于清零)|传 dst+字节值c+n|返回 dst(无失败) */
memset(buf, 0, sizeof buf);
/* 按字节比较 n 字节|传 两块+n|返回 负/0/正(无失败) */
memcmp(dst, src, 16);
/* 在前 n 字节找字节 c|传 块+字节c+n|命中返指针|未命中返 NULL */
void *hit = memchr(buf, 'w', n);
strncpy 在源串占满 n 字节时不补 '\0',须手动置尾,否则后续按字符串处理会越界(呼应 02/03 章);重叠区间必须用 memmove,memcpy 结果未定义。
strtok 会改写原串、用内部静态状态、不可重入,多线程或嵌套解析用 strtok_r(POSIX)。

<ctype.h> 判断和转换单个字符。所有函数的参数都必须是 unsigned char 的值或 EOF,传入可能为负的普通 char 是未定义行为。下面把常用函数各配一个最小示例。

unsigned char c = 'A';

/* —— 分类判断(下列 8 个同构,作用见各行)—— */
/* 共同约定|传 一个 unsigned char 值或 EOF|命中该类返非 0|否则返 0(无其它失败) */
isalpha(c);          /* 是否字母;'A' → 非 0 */
isdigit('7');        /* 是否十进制数字;'7' → 非 0 */
isalnum(c);          /* 是否字母或数字 */
isspace(' ');        /* 是否空白(空格/Tab/换行等);' ' → 非 0 */
isupper(c);          /* 是否大写字母 */
islower(c);          /* 是否小写字母 */
ispunct('!');        /* 是否可打印的标点符号 */
isxdigit('F');       /* 是否十六进制数字 0-9a-fA-F */

/* —— 大小写转换 —— */
/* 转小写|传 字符|返回其小写,非大写字母原样返回(无失败);'A' → 'a' */
int lo = tolower(c);
/* 转大写|传 字符|返回其大写,非小写字母原样返回(无失败);'a' → 'A' */
int up = toupper(lo);
别把返回值当 bool 比:isalpha 只保证「命中返非 0」,不保证返 1,写 if (isalpha(c) == 1) 在某些实现上永远为假。toupper/tolower 返回的是 int,赋回 char 之前先确认它不是 EOF。
记住这个固定写法:isspace((unsigned char)c)——参数永远先转 unsigned char。原因是 char 在多数平台上是有符号的,读到中文、emoji 等非 ASCII 字节时会变成负数,而这些函数只接受 unsigned char 范围的值或 EOF,传负数是未定义行为(实现上通常是查表时数组下标越界,读到表外的垃圾)。另外 getchar 返回的是 int 而非 char,正是为了让 EOF 能和所有合法字节区分开——接它的变量别图省事写成 char。

<math.h> 提供浮点数学函数,参数与返回值多为 double。链接时需加 -lm(如 gcc prog.c -lm)。下面把常用函数各配一个最小示例。

#include <math.h>

/* —— 幂与对数(作用见各行)—— */
/* 组约定|传 double(幂类传两个)|成功返 double|定义域错返 NaN+errno=EDOM、溢出返 ±HUGE_VAL+errno=ERANGE */
sqrt(2.0);            /* 平方根(x 须 ≥0,否则 NaN);≈ 1.414 */
cbrt(27.0);           /* 立方根(负数合法);3.0 */
pow(2.0, 10);         /* base 的 exp 次方(传 底,指数);1024.0 */
exp(1.0);             /* 自然指数 e 的 x 次方;≈ 2.718 */
log(exp(1.0));       /* 自然对数 ln x(x>0;x=0 返 -inf、x<0 返 NaN);1.0 */
log10(1000.0);        /* 常用对数 lg x(同上定义域);3.0 */

/* —— 三角(作用见各行)—— */
/* 组约定|正/余/正切传弧度、反三角传比值|成功返 double|比值越界域错返 NaN */
sin(0.0);             /* 正弦;0.0 */
cos(0.0);             /* 余弦;1.0 */
tan(0.0);             /* 正切;0.0 */
asin(1.0);            /* 反正弦,返回弧度(比值须 [-1,1],越界 NaN);≈1.571 */
acos(1.0);            /* 反余弦,返回弧度(比值须 [-1,1],越界 NaN);0.0 */
atan(1.0);            /* 反正切,返回弧度;≈0.785 */
atan2(1.0, 1.0);      /* 由 (y,x) 求弧度角、区分象限(比 atan 实用);≈0.785 */

/* —— 取整(作用见各行)—— */
/* 组约定|传 double|成功返取整后的 double|无失败 */
floor(2.7);           /* 向下取整;2.0 */
ceil(2.1);            /* 向上取整;3.0 */
round(2.5);           /* 四舍五入到最近整数;3.0 */
trunc(-2.7);          /* 向零截断;-2.0 */

/* —— 其它(各行标注入参与返回,均无 errno 失败)—— */
fabs(-3.5);           /* 浮点绝对值|传 x|返回 |x|;3.5 */
fmod(7.5, 2.0);       /* 浮点取余|传 x,y|返回 x 除以 y 的余数;1.5 */
fmax(1.0, 2.0);       /* 取较大|传两值|返回较大者;2.0 */
fmin(1.0, 2.0);       /* 取较小|传两值|返回较小者;1.0 */
hypot(3.0, 4.0);      /* 斜边长|传 x,y|返回 √(x²+y²) 且防中间溢出;5.0 */
log2(8.0);            /* 以 2 为底对数|传 x|返回 log₂x;3.0 */

/* —— 判定特殊值(宏;传 double,返回真假 非 0/0,无失败)—— */
isnan(x);              /* 是否 NaN——NaN 连自己都不相等,只能这样判 */
isinf(x);              /* 是否 ±无穷(如 1.0/0.0) */
/* 编译:gcc prog.c -lm */
-lm 到今天依然必须加,而且这一点和 -pthread 已经分道扬镳了:glibc 2.34 起 libpthread 并入了 libc(不加也能链接),但 libm 没有。漏掉 -lm 报的是链接期错误 undefined reference to `sqrt'——注意它不是编译错误,代码本身合法,所以报错出现在很靠后的阶段,信息也不指向源码行。看到这种 undefined reference 就该条件反射想到「少链了个库」,而不是回头找语法问题。
浮点数不能用 == 判等,用 fabs(a-b) < eps(更严谨的阈值 DBL_EPSILON 见本章后面)。要可靠检测域错/溢出别只看返回值:NaN 会在后续运算里传染、不一定当场发现——应先 errno = 0 再调用、随后查 errno==EDOM/ERANGE,或用 <fenv.h> 的 feclearexcept/fetestexcept 检查 FE_INVALID/FE_OVERFLOW 等浮点异常标志。

<time.h> 处理日历时间与计时。核心类型:time_t(自 Epoch 起的秒数)与 struct tm(分解成年月日时分秒的结构)。下面把常用函数各配一个最小示例。

#include <time.h>

/* —— 取时间与计时 —— */
/* 取当前日历时间|传 time_t 指针(可 NULL)|成功返自 Epoch(1970-01-01 UTC)起的秒数|失败返 (time_t)-1 */
time_t now = time(NULL);
/* 取程序 CPU 用时|无参|成功返 CPU 时钟数(÷CLOCKS_PER_SEC 得秒)|不可用返 (clock_t)-1 */
clock_t t0 = clock();
/* 求两时刻秒差|传 两个 time_t|返回相差的秒数 double(无失败) */
double dt = difftime(now, start);

/* —— 分解与合成 —— */
/* 转本地时区分解时间|传 time_t 指针|成功返 struct tm*(指向内部静态缓冲)|失败返 NULL */
struct tm *lt = localtime(&now);
/* 转 UTC 分解时间|传 time_t 指针|成功返 struct tm*(同为静态缓冲)|失败返 NULL */
struct tm *ut = gmtime(&now);
/* 分解时间合成回时间戳|传 struct tm*(按本地时区)|成功返 time_t|失败返 (time_t)-1 */
time_t back = mktime(lt);

/* —— 格式化 —— */
char ts[64];
/* 按格式输出时间串(%Y %m %d %H %M %S 等)|传 缓冲+容量+格式+tm*|成功返写出字符数|装不下返 0 */
strftime(ts, sizeof ts, "%Y-%m-%d %H:%M:%S", lt);

/* —— struct tm 常用字段 —— */
int year = lt->tm_year + 1900;   /* tm_year:自 1900 起的年数 */
int mon  = lt->tm_mon + 1;       /* tm_mon:0-11,+1 才是日常月份 */
int day  = lt->tm_mday;             /* tm_mday:1-31 */
int secs = lt->tm_hour*3600 + lt->tm_min*60 + lt->tm_sec; /* tm_hour / tm_min / tm_sec 时分秒 */
int wday = lt->tm_wday;             /* tm_wday:0=周日 */
int yday = lt->tm_yday;             /* tm_yday:0-365,当年第几天 */
localtime/gmtime 返回指向内部静态缓冲的指针,非线程安全、且会被下次调用覆盖;tm_year 要 +1900、tm_mon 要 +1 才是日常习惯的年、月。
localtime/gmtime/ctime 返回的是指向同一块静态缓冲区的指针,不是你的数据。连着调两次 localtime 拿到的两个指针完全相等,第二次的结果把第一次的整个覆盖掉——想比较两个时间点,先取到的那份已经变了。两条出路:马上把 struct tm 整体拷贝一份存起来,或者直接用可重入版 localtime_r(POSIX,自带输出缓冲区)。同理它不是线程安全的,多线程里一律用 _r 版本。

三个体量极小却随处可见的头文件:C99 的布尔类型、写「参数个数不定」函数用的变参机制,以及一批通用定义(NULL、size_t、offsetof)。下面各配一个最小示例。

#include <stdbool.h>
#include <stdarg.h>
#include <stddef.h>

/* —— 布尔 <stdbool.h>(C99) —— */
bool ok = true;                    /* bool/true/false,本质是 _Bool;任何非 0 存进去都变 1 */
if (!ok) return false;

/* —— 变参函数 <stdarg.h>(下列 3 个都是宏、均无返回值)—— */
void log_line(const char *fmt, ...) { /* ... 表示后面还有不定个实参 */
    va_list ap;
    /* 启动变参读取|传 va_list+最后一个具名形参|使 ap 指向首个变参 */
    va_start(ap, fmt);
    /* 转发整包变参给 v* 版函数(见 stdio 卡)|传 流+格式+va_list|成功返写出字符数、出错返负数 */
    vfprintf(stderr, fmt, ap);
    /* 收尾,必须与 va_start 配对|传 va_list */
    va_end(ap);
}
/* 逐个取用|int x = va_arg(ap, int):传 va_list+类型|返回该类型的下一个实参|类型数错即 UB */

/* —— 通用定义 <stddef.h>(下面前 3 个是类型/常量,非函数)—— */
size_t n = sizeof buf;             /* size_t:表示大小/下标的无符号类型(sizeof 的结果类型) */
void *p = NULL;                      /* NULL:空指针常量 */
ptrdiff_t d = &a[3] - &a[0];   /* ptrdiff_t:两指针相减的有符号结果类型;d=3 */
/* 求字段偏移(宏)|传 结构体类型+字段名|返回该字段在结构体内的字节偏移(无失败) */
size_t off = offsetof(struct S, field);
变参函数不做任何类型检查:va_arg 取的类型必须与调用方实际传入的完全一致,数错、少取、多取都是未定义行为——这也是 printf 格式串和实参对不上就崩的根因。
没有 <stdbool.h> 的老代码常用 int 加 0/1 冒充布尔;能用 C99 就直接 bool,语义更清楚。

几个体量小但处处要用的头文件:断言、错误码,以及描述类型边界的常量宏。下面把常用接口各配一个最小示例。

#include <assert.h>
#include <errno.h>
#include <limits.h>
#include <stdint.h>
#include <float.h>

/* —— 断言 <assert.h> —— */
/* 运行期断言(宏,详见 04 章)|传 一个表达式|为真无操作|为假打印信息并 abort;NDEBUG 时整条被编译掉 */
assert(ptr != NULL);

/* —— 错误处理 <errno.h> —— */
errno = 0;                         /* errno 是左值,可赋值;用前必须先清零 */
long v = strtol(s, NULL, 10);
if (errno == ERANGE)               /* 溢出 */
    /* 错误码转可读串|传 错误码|返回其字符串(指向静态缓冲、勿改,无失败) */
    fprintf(stderr, "%s\n", strerror(errno));
/* 按 errno 打印「前缀: 描述」到 stderr(详见 04 章调试卡)|传 前缀串|无返回值 */
perror("open");

/* —— 整数边界 <limits.h> —— */
int hi = INT_MAX, lo = INT_MIN;     /* int 上限 / 下限 */
unsigned um = UINT_MAX;             /* unsigned 上限 */
long lm = LONG_MAX;                 /* long 上限 */
size_t sm = SIZE_MAX;               /* size_t 上限(来自 <stdint.h>) */
int bits = CHAR_BIT;                /* 一个字节的位数(通常为 8) */

/* —— 固定宽度 <stdint.h> —— */
int32_t  a = INT32_MAX;              /* 精确 32 位,对应边界宏 INT32_MAX(详见 02 章) */
uint32_t b = 0u;                    /* 精确无符号 32 位 */
int64_t  c = 0;                     /* 精确 64 位 */
uint64_t d = UINT64_MAX;             /* 精确无符号 64 位,对应边界宏 UINT64_MAX */

/* —— 可移植打印定宽类型 <inttypes.h> —— */
printf("%" PRId64 "\n", c);       /* PRIdN/PRIuN/PRIxN 展开成本平台的长度修饰,省得在 %d/%ld 间猜 */

/* —— 浮点特性 <float.h> —— */
if (fabs(x - y) < DBL_EPSILON) { }   /* 1.0 与下一个可表示 double 的最小差,用作比较阈值 */
float  fe = FLT_EPSILON;             /* float 版精度阈值 */
double dm = DBL_MAX;                /* double 可表示的最大值 */
断言里绝不能写有副作用的表达式。-DNDEBUG(发布构建的标配)会把 assert(...) 连同括号里的整个表达式一起删掉,不是「不检查」而是「根本不执行」。拿 assert(++n > 0) 试一下:调试构建打印 n=1,加 -DNDEBUG 后打印 n=0——那个自增彻底消失了。这类 bug 只在发布版出现,极难查。另外 errno 只在调用确实失败之后才有意义:成功的调用不保证把它清零,所以判断顺序永远是"先看返回值是否失败,再读 errno",而不是反过来拿 errno 判断有没有出错。
固定宽度类型(int32_t 等)让「就是 32 位」跨平台成立;比较浮点用 DBL_EPSILON 做阈值,比随手写 1e-9 更严谨。

ISO C 只规定了信号机制的最小内核:用 signal() 注册处理函数、raise() 自发信号,以及一小撮信号编号。真正实用的信号编程(sigaction/kill/信号屏蔽)属于 POSIX,见 07 章。下面是标准可移植的那部分。

#include <signal.h>

/* 处理函数里只能碰 volatile sig_atomic_t 与异步信号安全函数 */
volatile sig_atomic_t got = 0;

/* 信号处理函数:签名固定为 void f(int) */
void on_int(int sig) { got = sig; }   /* 只置标志,重活留给主循环 */

/* 注册处理函数|传 信号编号+处理函数(或 SIG_IGN 忽略/SIG_DFL 默认)|成功返之前的处理函数|失败返 SIG_ERR */
if (signal(SIGINT, on_int) == SIG_ERR) return 1;

/* 主动发信号给自己|传 信号编号|成功返 0|失败返非 0 */
raise(SIGTERM);

while (!got) { }                /* 等 Ctrl-C:收到后 got 被置为 SIGINT */
/* 标准信号:SIGINT(中断) SIGTERM(终止) SIGABRT(abort) SIGSEGV(段错误) SIGFPE(算术错) SIGILL(非法指令) */
处理函数里能做的事被 ISO C 限得极死:只能给 volatile sig_atomic_t 赋值,或调用 abort/_Exit/signal,碰其它变量或调 printf/malloc 都是未定义行为。signal() 的语义各平台还不统一(有的收到信号后自动重置为默认动作)——要可靠地长期捕获,应改用 POSIX 的 sigaction(见 07 章)。
ISO C 这层信号可移植但几乎不够用:它只保证了 signal/raise 和一小撮信号编号,连「处理函数执行完之后是否还保持注册」这种基本问题,标准都留给了实现——所以才有旧代码在处理函数第一行重新 signal() 一次的写法。真要写信号相关的东西,直接用 07 章的 sigaction:语义确定、能显式控制是否重启被打断的系统调用、还能在处理期间屏蔽其它信号。这里这套只需要看得懂,别拿它写新代码。

setjmp 把一个「存档点」存进 jmp_buf,longjmp 能从任意深的调用层一步跳回那个点——相当于跨函数的 goto,多用于深层错误的集中处理。用得少,但要看得懂。

#include <setjmp.h>

jmp_buf env;                     /* 存档缓冲,通常放全局 */

void deep(int n) {
    if (n == 0)
        longjmp(env, 42);        /* 跳回 setjmp 处,第二参作为其返回值(不能为 0) */
    deep(n - 1);
}

/* 设置存档点|传 jmp_buf|直接调用时返 0|被 longjmp 跳回时返 longjmp 的第二参 */
int r = setjmp(env);
if (r == 0)
    deep(5);                    /* 首次:r==0,往下钻 */
else
    printf("跳回,值=%d\n", r);     /* longjmp 之后:r==42 */
longjmp 之后,函数里非 volatile 的局部变量值是不确定的;被跳过的栈帧上任何未释放的资源(malloc、锁、打开的文件)不会自动回收,直接泄漏。它绕开了正常返回路径——除非在做错误恢复框架,否则优先用普通返回值传错误。
一条硬规则:凡是在 setjmp 之后被修改、且要在 longjmp 回来后继续用的局部变量,都必须声明为 volatile。否则它可能正待在寄存器里,longjmp 恢复现场时把寄存器一并还原,你读到的就是跳转前的旧值——而且开不开优化结果还不一样,典型的「debug 版对、release 版错」。另外 longjmp 是粗暴的栈展开:它不做任何清理,沿途函数申请的内存、打开的文件全部泄漏。所以它只适合「出大错了,直接退回顶层」这一种场景。

默认程序跑在 "C" 区域(只认 ASCII)。setlocale 切到系统区域后,字符分类、排序、时间格式才按本地规则走;wchar_t 与 <wchar.h>/<wctype.h> 一族则以「一个码位一个 wchar_t」处理非 ASCII 文本。下面各配一个最小示例。

#include <locale.h>
#include <wchar.h>
#include <wctype.h>

/* 设置区域|传 类别(LC_ALL 等)+区域名(""取环境变量、NULL 只查询)|成功返当前区域串|失败返 NULL */
setlocale(LC_ALL, "");           /* 按环境(如 zh_CN.UTF-8)启用本地化 */

/* —— 宽字符 I/O 与字符串(与窄版一一对应)—— */
wchar_t wbuf[64];
wprintf(L"宽字符:%ls\n", L"你好");   /* printf 宽版,字面量加 L 前缀 */
size_t wl = wcslen(L"héllo");         /* strlen 宽版:按 wchar_t 个数计 */
wcscpy(wbuf, L"复制");                /* strcpy 宽版 */
int cmp = wcscmp(L"a", L"b");        /* strcmp 宽版 */

/* —— 宽字符分类 <wctype.h>(ctype 的宽版,认本地字母)—— */
iswalpha(L'中');              /* 是否字母(本地规则下汉字算字母)→ 非 0 */
iswdigit(L'7');              /* 是否数字 */
towupper(L'a');              /* 转大写宽字符 */

/* —— 多字节 ⇄ 宽字符转换(按当前区域编码,如 UTF-8)—— */
/* 多字节串转宽串|传 目标wchar数组+源多字节串+目标容量|成功返写入的wchar数|遇非法字节返 (size_t)-1 */
size_t n = mbstowcs(wbuf, "你好", 64);
wchar_t 宽度不跨平台:Linux/macOS 是 4 字节(存 UTF-32 码位),Windows 是 2 字节(存 UTF-16,容不下增补平面)——想要可移植的定宽用 C11 的 char16_t/char32_t(<uchar.h>)。不调 setlocale(LC_ALL, "") 时,宽/多字节转换只按 ASCII 处理,中文会直接失败。
两点。①程序启动时不调用 setlocale 就一直待在 "C" 区域(只认 ASCII),想跟随用户环境要显式写 setlocale(LC_ALL, "")——空字符串才是「按环境变量走」,传 "C" 反而是锁死。②wchar_t 的宽度不可移植:Linux 上是 4 字节(放得下一个码位),Windows 上是 2 字节,放不下增补平面的字符,于是同一份代码对 emoji 的行为两边不一致。今天的通行做法是不用宽字符:内部统一按 UTF-8 存进普通 char 数组,只在调用必须用宽字符的系统 API 时才转换。

C 标准库剩下的头文件大多小众或很薄,这里一次点清:复数运算、浮点异常/舍入控制,以及一批「知道有、用到再查」的补充头。

/* —— <complex.h> 复数(C99)—— */
#include <complex.h>
double complex z = 3.0 + 4.0*I;  /* complex 类型 + 虚数单位 I */
double m = cabs(z);              /* 模 |z|=5;creal/cimag 取实/虚部,cexp/csqrt 等复数版数学函数 */

/* —— <fenv.h> 浮点环境(C99;配合 #pragma STDC FENV_ACCESS ON)—— */
#include <fenv.h>
feclearexcept(FE_ALL_EXCEPT);    /* 清浮点异常标志 */
double r = 1.0 / 3.0;
if (fetestexcept(FE_INEXACT)) { }  /* 查是否发生 FE_OVERFLOW/FE_DIVBYZERO/FE_INVALID/FE_INEXACT */
fesetround(FE_TONEAREST);        /* 设舍入方向 */

/* —— 其余标准头,一句话点名(用到再查手册)—— */
/* <tgmath.h>      泛型数学:sqrt(x) 按 x 类型自动选 sqrtf/sqrt/sqrtl */
/* <uchar.h>       C11:char16_t / char32_t 与 UTF-16/32 转换 */
/* <stdalign.h>    C11:alignas / alignof 控制对齐(C23 起成关键字,可免包含) */
/* <stdnoreturn.h> C11:noreturn 标注不返回的函数——C23 改用 [[noreturn]] */
/* <iso646.h>      and/or/not 等运算符的可读别名(老式键盘遗产) */
/* <stdatomic.h> 原子类型、<threads.h> C11 线程——见 04 章并发卡 */
<fenv.h> 有个特别容易踩空的前提:要读浮点异常标志或改舍入模式,必须先写 #pragma STDC FENV_ACCESS ON。不写的话编译器会默认你不关心浮点环境,于是放手做各种重排和常量折叠——它可能把整个计算挪到编译期算掉,你后面 fetestexcept 查到的标志自然是空的,代码看着完全合理却永远查不出异常。更麻烦的是不少编译器根本不支持这个 pragma(会警告并忽略),所以真要严格控制浮点行为,别只依赖标准,还得配合编译选项(如关掉 -ffast-math 一类的激进优化)。
链接注意:<complex.h> 与 <fenv.h> 底层是数学库,编译同样常需 -lm。这些头日常极少直接用——记住「有这么个东西、归哪个头」即可,真要用时再翻手册。

POSIX / 系统编程

POSIX 是 Unix/Linux 为 C 定义的系统编程接口——ISO C 标准库之外、直连内核的那一层:低层文件描述符 I/O、进程与信号、线程、网络套接字、内存映射。套接字在这里只列系统调用速查,真正怎么用它写出正确、扛并发的程序是下一章(08 网络编程)的主题。这一章沿用 06 章的体例按头文件分卡,每个系统调用上方一行注释按「作用|入参|成功返回|失败返回」四段速读。和 ISO C 最大的区别是错误约定:这些调用几乎都「成功返 0 或正值、失败返 -1 并置 errno」,务必逐次检查。概念上的「为什么」(fork 写时复制、僵尸进程、数据竞争)在 04 章已讲透,这里是随手可翻的函数速查——唯一的例外是开篇两卡「文件描述符到底是什么」与「errno:错误码是怎么工作的」:fd 和 errno 是这一章和下一章的公共地基(上面那句错误约定说的就是后者),却都从没被正面讲过一次,所以单独拆开讲透;GNU/glibc 私有扩展单列一卡并标注可移植性。

POSIX 是「操作系统给 C 程序的接口」。两条贯穿全章的事实:文件描述符只是一张表的下标,出错信息统一走 errno。

按任务找系统调用

要做的事调用头文件
读写文件(低层)open read write close lseek pread / pwrite<fcntl.h> <unistd.h>
复制 / 重定向 fddup dup2 fcntl(设 O_NONBLOCK / FD_CLOEXEC)<unistd.h> <fcntl.h>
文件元数据与权限stat fstat lstat chmod access S_ISDIR(st.st_mode)<sys/stat.h>
遍历目录opendir readdir closedir(d_name)<dirent.h>
进程fork execvp _exit getpid getppid chdir getcwd<unistd.h>
等子进程wait waitpid + WIFEXITED / WEXITSTATUS / WIFSIGNALED<sys/wait.h>
进程间通信pipe mkfifo shm_open + mmap<unistd.h> <sys/mman.h>
信号sigaction kill sigprocmask sigemptyset alarm<signal.h>
线程pthread_create pthread_join pthread_mutex_* pthread_cond_*(编译加 -pthread)<pthread.h>
网络socket bind listen accept connect send recv setsockopt(08 章)<sys/socket.h> <netinet/in.h>
内存映射mmap munmap mprotect msync<sys/mman.h>
时间与休眠clock_gettime(CLOCK_MONOTONIC)nanosleep gettimeofday<time.h>
IO 多路复用select poll epoll_create1 / epoll_ctl / epoll_wait(Linux)<sys/epoll.h>(08 章)
GNU / glibc 扩展getline asprintf strdup memmem——要先 #define _GNU_SOURCE(写在所有 include 之前)—

文件描述符:三层结构

层是什么推论
① fd进程自己那张表的下标,一个小整数0 / 1 / 2 是标准输入 / 输出 / 错误;dup2(fd, 1) 就是「把 1 号槽指向别处」
② 打开文件表项记录读写偏移与状态标志fork 后父子共享同一项——所以偏移是共享的
③ inode真正的文件对象两次 open 同一文件得到两个独立偏移
为什么 socket 也能 read / write因为它们也是 fd——管道、终端、socket、普通文件在这一层是同一种东西
fd 泄漏忘了 close,到上限后 open / accept 返回 EMFILE;ls -l /proc/自己/fd 或 lsof -p 能看当前占用

errno 的三条规则

规则说明
① 先看返回值,再看 errno系统调用失败通常返回 -1(有的返回 NULL)——没失败时 errno 的值是垃圾
② 成功不会清零成功的调用不保证把 errno 复位;要用 strtol 那类「靠 errno 判溢出」的函数,调用前自己 errno = 0;
③ 立刻取走紧接着调用 printf / perror 之外的任何函数都可能改写它——先存进局部变量
它不是全局变量现代实现里 errno 是个宏,展开成每线程一份的存储——所以多线程下各查各的
怎么打印perror("open") 或 fprintf(stderr, "open: %s\n", strerror(errno))

errno 反查

值含义通常该怎么办
EINTR调用被信号打断重试——阻塞调用要写在重试循环里
EAGAIN / EWOULDBLOCK非阻塞 fd 上现在没数据 / 写不进不是错误:等下一次可读可写事件(08 章)
ENOENT / EACCES / EEXIST路径不存在 / 没权限 / 已存在检查路径与权限位
EBADFfd 无效或已关闭多半是重复 close,或用了已关闭的 fd
EMFILE / ENFILE本进程 / 全系统 fd 用尽找泄漏;确有需要就调 ulimit -n
EPIPE往已关闭的管道 / 连接写同时会收到 SIGPIPE——先忽略它(08 章)
EADDRINUSE端口被占(常见于刚重启的服务)SO_REUSEADDR(08 章)
ECONNREFUSED / ETIMEDOUT / ECONNRESET没人监听 / 超时 / 对端重置查对端、查防火墙
EINVAL / ENOMEM / ENOSPC参数非法 / 内存不足 / 磁盘满—
ECHILD / ESRCH没有子进程可等 / 进程不存在—
阻塞的系统调用会被信号打断,返回 -1 且 errno == EINTR。这不是错误,只是「被打断了,还没做完」。没有处理它的代码,在程序注册了信号处理函数(或者仅仅是窗口大小变化产生 SIGWINCH)之后就会随机失败——症状是「平时好好的,偶尔读一半就报错」。凡是 read / write / accept / waitpid,都该写成「遇到 EINTR 就重试」的循环。
man 2 xxx 是系统调用,man 3 xxx 是库函数。man 页的 ERRORS 一节会列出这个调用可能返回的每一个 errno 以及各自的含义——排查系统调用失败时,那一节比任何博客都直接。
两块地基:先把这两样搞清楚

从这一章起,fd 会出现在几乎每一张卡上:open 返回它、pipe 一次给你两个、socket 返回的也是它,08 章整章都在摆弄它。可它通常只被一句「内核用来标识打开文件的小整数」带过——于是后面所有内容都建在一个黑盒上。把黑盒拆开之后,08 章那句「socket 也是文件描述符」就不是打比方,而是字面意义上的同一套机制。

它是一个下标,不是一个对象

  • fd 的类型是 int,这不是图省事:它就是一个数组下标——内核给每个进程存着一张「打开文件表」,fd 是这张表的行号。你手里从来没拿到过「文件」,只拿到了一个编号。
  • 分配规则是永远给当前最小的可用编号。0/1/2 已被 shell 预先占成 stdin/stdout/stderr,所以进程里第一次 open 通常拿到 3;close(3) 之后再 open,又会拿回 3。
  • 它只在本进程内有意义。进程 A 的 fd 3 和进程 B 的 fd 3 毫无关系,把「3」这个数字传给另一个进程没有任何用处——真要把一个已打开的 fd 交给别的进程,得走 Unix 域套接字的 SCM_RIGHTS,或者靠 fork 继承。

三层结构:为什么两个 fd 有时共享偏移、有时不共享

这是 fd 唯一真正需要记住的模型。从 fd 到磁盘上的数据,中间隔着三张表:

进程 A 的 fd 表        打开文件表(全系统一份)        inode 表
  0 ──┐                ┌─> [偏移=6, O_RDONLY] ──┐
  1   │                │                        ├─> data.txt 的 inode
  3 ──┼────────────────┘                        │   (文件本体、大小、权限)
  5 ──┘  dup 出来的                             │
  4 ───────────────────> [偏移=0, O_RDONLY] ────┘  另一次 open,另一条表项
  • 中间那层(打开文件表项)才是存「读到哪了」的地方——偏移量和打开标志在这里,不在 fd 上,也不在文件上。
  • open 两次同一个文件 → 两条独立表项 → 各读各的,偏移互不影响。
  • dup/dup2 → 两个 fd 指向同一条表项 → 偏移共享,一个读了另一个跟着前进。
  • fork → 子进程复制的是左边那张 fd 表,指向的仍是父进程那条表项 → 父子共享偏移。

把上面三条跑出来(右边代码的输出)

[1] 第一次 open fd=3, 第二次 fd=4
[1] close(3) 之后再 open, 又拿到 fd=3     <- 复用最小空号
[2] fd3 读到 "ABC" 之后, fd4 的偏移仍是 0  <- 两次 open,互不相干
[3] dup(3)=5 ; 读之前 fd3 偏移=3, fd5 偏移=3
[3] fd5 读到 "DEF" ; 现在 fd3 偏移=6, fd5 偏移=6   <- 同进同退
  • 第 [3] 行是全卡的重点:动的是 fd5,fd3 的偏移跟着变了。这两个数字如果不是同一个东西,shell 的重定向、2>&1、日志追加就都无从解释。
  • fork 那条同样成立:父进程读掉 "ABC" 后 fork,子进程接着读到的是 "DEF" 而不是 "ABC";子进程读完,父进程的偏移也变成了 6。

为什么 socket 能直接用 read/write

  • fd 表项里存的并不是「一个文件」,而是一个指向内核对象的指针,那个对象自带一张函数表——读、写、关各指向一份不同的实现。
  • 于是 read(fd, ...) 的含义完全取决于 fd 背后挂着什么:普通文件走文件系统,管道走内存里的环形缓冲,套接字走协议栈。调用方的代码一个字都不用改——这才是「一切皆文件」的真正含义,它说的不是「什么都存成文件」,而是「什么都用同一组动词操作」。
  • 把 open、pipe、socket 写在同一个程序里挨着调,拿到的编号会从同一个空间里连着发下来(3、然后一对 4/5、然后 6)——它们不是三套各自独立的编号;用 write/read(不是 send/recv)往管道 fd 上收发,正常工作。
  • 反过来想认出某个 fd 背后是什么,用 fstat 加 S_ISREG/S_ISFIFO/S_ISSOCK 宏即可。
  • 那网络代码为什么还是习惯写 send/recv?因为它们多一个 flags 参数(MSG_PEEK 偷看不取走、MSG_NOSIGNAL 抑制 SIGPIPE 等),是 socket 专属的。flags 传 0 时,send/recv 与 write/read 完全等价。

fd 是有限资源,用光了服务不会崩、只会半死

  • 每个进程能同时持有的 fd 有上限,ulimit -n 查(发行版默认常见是 1024,容器和现代发行版里往往调高了,自己跑一下看你的机器是多少)。
  • 超限后 open/accept/socket 全部返回 -1,errno == EMFILE,perror 打出 Too many open files。写个循环一直 open 就能撞到:能开的个数正好是 ulimit -n 减去已经占用的那几个(0/1/2 加上你自己开的)。
  • 这是服务端最经典的一类事故:某条路径忘了 close,跑几小时后 accept 开始失败,但进程不退出、日志也不一定报警——表现为「服务时好时坏」,极难和「忘了关 fd」联系起来。
  • 区分两个错误码:EMFILE 是本进程用超了,ENFILE 是整个系统用超了(后者说明机器上另有元凶)。
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(void) {
    int f = open("data.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    write(f, "ABCDEFGHIJ", 10);
    close(f);

    /* [1] 编号:总是给最小的可用号,close 后立刻复用 */
    int a = open("data.txt", O_RDONLY);   /* 3(0/1/2 已被占) */
    int b = open("data.txt", O_RDONLY);   /* 4 */
    close(a);
    int c = open("data.txt", O_RDONLY);   /* 又是 3 */

    /* [2] 两次 open = 两条打开文件表项 = 两个独立偏移 */
    char buf[4] = {0};
    read(c, buf, 3);                     /* c 读走 "ABC",c 的偏移 -> 3 */
    lseek(b, 0, SEEK_CUR);                 /* b 的偏移仍是 0,纹丝不动 */

    /* [3] dup = 两个 fd 指向同一条表项 = 偏移共享 */
    int d = dup(c);                       /* 复制到当前最小空号 */
    read(d, buf, 3);                     /* d 读走 "DEF",不是 "ABC" */
    printf("c=%ld d=%ld\n",              /* 6 和 6:动 d,c 跟着走 */
           (long)lseek(c, 0, SEEK_CUR),
           (long)lseek(d, 0, SEEK_CUR));

    /* [4] 同一个编号空间里,什么种类的 fd 都能并排躺着 */
    int pfd[2];
    pipe(pfd);                            /* 一次给一对,接着往下发号 */
    write(pfd[1], "hi", 2);               /* 管道上用 write,不必用 send */
    read(pfd[0], buf, 2);

    return 0;
}
fd 编号会被立刻回收,这让「关早了」成为一类极隐蔽的 bug。close(fd) 之后那个号立刻可用,下一次 open/accept 很可能正好拿到同一个号。于是「关了之后又误用一次」不会报错——它会安静地读写到另一个毫不相干的文件或连接上。多线程服务里尤其危险:线程 A 刚 close(7),线程 B 的 accept 拿到了 7,此时线程 A 的重复 close(7) 就把 B 的新连接掐了,而两边代码看起来都没错。防法是close 之后立刻把变量置成 -1,并约定「谁打开谁负责关、只关一次」。
另外两条:fork 出的子进程默认继承全部 fd(连监听套接字一起),不想带过去就在 open/socket 时加 O_CLOEXEC/SOCK_CLOEXEC;同一个文件别混用 FILE* 和裸 fd,stdio 自带一层缓冲,两套各记各的位置必然打架。
查 fd 问题的第一现场是 /proc/<pid>/fd/——内核把每个 fd 当成软链接摆在那儿,ls -l 一眼看到每个编号背后到底是谁:
ls -l /proc/self/fd/
0 -> /dev/pts/0            # 终端
3 -> /home/me/data.txt     # 普通文件
7 -> socket:[123456]       # 套接字,方括号里是 inode 号
8 -> pipe:[20432]          # 管道
怀疑泄漏就 ls /proc/<pid>/fd | wc -l 隔几分钟看一次,只增不减就是漏了;再 ls -l 看是哪个路径重复出现了几百次,泄漏点当场现形。没有 /proc 的系统上用 lsof -p <pid>。

本章开头那句约定——「几乎都成功返 0 或正值、失败返 -1 并置 errno」——是这一章和下一章每张卡都在用的东西:从 open 到 accept,判断出没出错、出了什么错,全靠它。可 errno 本身长什么样、什么时候有效,几乎从没人正面讲一次。它看起来像个全局的 int,但三条关键性质都和全局变量不一样,每一条都对应一类真实的 bug。

它不是全局变量,是「每个线程一份」的宏

  • 现代 C 里 errno 是一个宏,展开成对一个线程局部对象的解引用(glibc 上是 (*__errno_location()))。它能取地址、能赋值,看着像变量,但每个线程各有一份。
  • 好处很实在:多线程程序里,A 线程的失败不会污染 B 线程的 errno。—子线程里故意 open 一个不存在的路径拿到 errno=2,主线程回来一看自己的 errno 仍是 0。
  • 推论:老书上的 extern int errno; 今天会直接链接失败,报错长这样:
    errno: TLS definition in libc.so.6 section .tbss
           mismatches non-TLS reference
    「TLS」就是 thread-local storage。碰到这行报错,答案永远是删掉那句声明,改成 #include <errno.h>。

三条使用规则,每条对应一类 bug

  • ① 先看返回值,再看 errno——绝不能反过来。errno 只在函数确实失败之后才有意义;成功的调用不会把它清零。一次失败的 open 把它置成 2(ENOENT)后,紧接着一次成功的 open 回来,errno 仍然是 2。所以「调用完直接看 errno 非零就当出错了」是错的——那个 2 可能是十行之前留下的尸体。
  • ② 要用就立刻存下来。标准允许任何库函数在成功时也修改 errno。所以「失败了 → 先 fprintf 打条日志 → 再读 errno 决定怎么处理」这个顺序是不保险的,中间那个 fprintf 有权把它改掉。养成习惯:判定失败后第一件事就是 int saved = errno;,之后一律用 saved。
  • ③ 少数函数必须调用前手动清零。strtol/strtoul/strtod 这类返回值本身就是合法值、无法用返回值区分错误的函数,唯一的判据就是 errno——所以规矩是 errno = 0; → 调用 → 查 errno。strtol("99999999999999999999", NULL, 10):返回 LONG_MAX(9223372036854775807),errno 被置成 ERANGE(34)。不先清零,就分不清这个 ERANGE 是这次的还是上次的。

把错误码变成人话

  • perror("前缀") 往 stderr 打一行 前缀: 错误描述,最省事:
    perror("myprog");   ->   myprog: Permission denied
  • strerror(errno) 返回描述字符串,适合自己拼日志。但它可能返回静态缓冲、不保证线程安全,多线程里要么用 strerror_r(注意 GNU 版和 POSIX 版签名不一样,一个返回 char* 一个返回 int),要么用 printf 的 %m(glibc 扩展,直接打印当前 errno 的描述)。
  • strerror(0) 返回的是 "Success"——顺带说明 0 不是一个错误码,它表示「没有错误」。日志里出现 Success 基本就是踩了规则 ①:在没失败的地方去读了 errno。
  • 工程里比 perror 更常用的写法是把操作对象也带上,否则日志里一堆 No such file or directory 根本不知道说的是哪个文件:
    fprintf(stderr, "open %s failed: %s\n", path, strerror(errno));

该认脸的几个错误码

  • EINTR——被信号打断,数据一个字节没丢,重试即可,不是错误。08 章的 readn/writen 三条铁律之一就是它。
  • EAGAIN / EWOULDBLOCK——非阻塞 fd 上「现在没有数据 / 现在写不进去」,同样不是错误,是让你稍后再来。Linux 上这两个宏值相同,但 POSIX 只保证「可能相同」,要可移植就两个都判。
  • ENOENT(路径不存在)、EACCES(权限不足)、EEXIST(已存在)——文件操作三件套。
  • EMFILE / ENFILE——fd 用光了,前者本进程、后者全系统(见本章前面)。
  • ECONNREFUSED(对面没监听)、EPIPE(往已关闭的连接写)、ETIMEDOUT——网络三件套,08 章会逐个展开。
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>

/* 规则①:判据是返回值,errno 只用来问「为什么」 */
int fd = open(path, O_RDONLY);
if (fd < 0) {                     /* 先看返回值 */
    int saved = errno;             /* 规则②:立刻存下来 */
    fprintf(stderr, "open %s failed: %s\n", path, strerror(saved));
    if (saved == ENOENT) { /* 文件不在,也许该创建 */ }
    if (saved == EACCES) { /* 没权限,换条路 */ }
    return -1;
}

/* 规则②的反例:中间夹了库调用,errno 就不保险了 */
if (fd < 0) {
    fprintf(stderr, "出错了\n");      /* 这一行有权改掉 errno */
    if (errno == ENOENT) { }        /* 读到的可能已经不是刚才那个了 */
}

/* 规则③:strtol 无法用返回值报错,必须先清零 */
errno = 0;
char *end;
long v = strtol(s, &end, 10);
if (end == s)            { /* 一个数字都没解析出来 */ }
else if (errno == ERANGE) { /* 溢出,v 是 LONG_MAX/LONG_MIN */ }
else if (*end != '\0')   { /* 后面还有尾巴,比如 "12abc" */ }

/* EINTR:重试,不是错误 */
ssize_t n;
do { n = read(fd, buf, sizeof buf); } while (n < 0 && errno == EINTR);
最常见的一条:拿 errno 当「有没有出错」的判据。if (errno) { ... } 或者「调用完发现 errno 非零就报错」——因为成功的调用不清零,这会把上一次失败的旧值当成本次的错误,制造出大量根本不存在的假故障;反过来,如果上一次恰好没出错,真的失败也可能被漏掉。判据永远是返回值(-1/NULL/EOF),errno 只回答「为什么」。
另外三条:别把 errno 跨函数传递(要传就传 saved 那个副本,或者干脆返回自己的错误码);strtol 一族忘了先 errno = 0 会让上一次的 ERANGE 顶包;线程里别指望 errno 能跨线程传递状态——它是每线程一份的,子线程置的值主线程读不到。
strace 会把 errno 用符号名直接显示出来,排错时比在代码里加 printf 快得多——它连你没写日志的那些调用一起显示:
strace -e trace=openat ./app
openat(AT_FDCWD, "/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)
右边的 = -1 ENOENT (...) 就是返回值和 errno。「程序说文件打不开,但我明明看见它在」这类问题,strace 一跑就知道它到底去找了哪个路径。网络程序同理:strace -e trace=network ./server 能看到每个 socket/bind/accept 的真实返回。
文件描述符与目录

stdio 的 FILE* 底下就是这层:内核用文件描述符(小整数 fd,0/1/2 是 stdin/stdout/stderr)标识打开的文件。open 在 <fcntl.h>,读写关在 <unistd.h>。它们无缓冲、每次都是真正的系统调用——要缓冲用 stdio,要精确控制(非阻塞、传 fd、直接 I/O)才下到这层。

#include <fcntl.h>
#include <unistd.h>

/* 打开文件|传 路径+标志(O_RDONLY/O_WRONLY/O_RDWR 组合 O_CREAT/O_APPEND/O_TRUNC)+权限(仅 O_CREAT 用)|成功返 fd(≥0)|失败返 -1 置 errno */
int fd = open("data.txt", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }

char buf[4096];
/* 读字节|传 fd+缓冲+最多读 n|成功返实际读入字节数(0=EOF)|失败返 -1 置 errno */
ssize_t got = read(fd, buf, sizeof buf);

int out = open("copy.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
/* 写字节|传 fd+缓冲+写 n|成功返实际写出字节数(可能 <n,须循环补写)|失败返 -1 置 errno */
ssize_t put = write(out, buf, got);

/* 移动读写位置|传 fd+偏移+基准(SEEK_SET/CUR/END)|成功返新的绝对偏移|失败返 -1 */
off_t pos = lseek(fd, 0, SEEK_END);   /* 跳到末尾=取文件大小 */

/* 关闭 fd|传 fd|成功返 0|失败返 -1 */
close(fd); close(out);

/* 复制 fd 到指定号(先关目标)|传 旧fd+新fd|成功返新fd|失败返 -1 */
dup2(out, STDOUT_FILENO);          /* 重定向 stdout 到文件——shell 的 > 就靠它 */
两个高频坑:① read/write 可能只处理了一部分(尤其管道、socket、被信号打断),返回值 < 请求数是常态,写入必须循环直到写完(08 章有现成的 readn/writen)。② 返回 -1 且 errno==EINTR(被信号打断)时应重试而非报错。别把 fd 层和 FILE* 层的读写混在同一文件上——两套缓冲会打架。
这一层最重要的一条:read/write 的返回值是「这次实际处理了多少字节」,它随时可能小于你请求的数量——这不是错误,是正常行为。请求读 65536 字节、而数据分两批到达时,第一次 read 可能只返回区区几个字节。所以正确写法永远是循环到读满或读完为止,而不是假设一次调用就搞定。管道、套接字、终端上这件事几乎必然发生,普通文件上偶尔发生——后者的「偶尔」才是最坑的,因为你本地测不出来。另外返回 -1 且 errno == EINTR 时应当重试而非报错。

stat 一族把文件的元信息(大小、类型、权限、时间戳、inode)填进 struct stat。判断类型用 S_IS* 宏,不是比大小。access(在 <unistd.h>)快速探存在/可读写。

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

struct stat st;
/* 取文件元数据|传 路径+struct stat*|成功返 0 并填充 st|失败返 -1 置 errno */
if (stat("data.txt", &st) == 0) {
    off_t  size = st.st_size;        /* 字节大小 */
    mode_t mode = st.st_mode;        /* 类型+权限位 */
    time_t mt   = st.st_mtime;       /* 最后修改时间(配 localtime 用)*/

    /* 类型判断用宏,别手算 */
    if (S_ISREG(mode)) { }             /* 普通文件 */
    if (S_ISDIR(mode)) { }             /* 目录 */
    if (S_ISLNK(mode)) { }             /* 符号链接(需 lstat 才不跟随)*/
}
/* fstat:对已打开的 fd 取元数据|传 fd+struct stat*|成功返 0|失败返 -1 */
fstat(fd, &st);

/* 建目录|传 路径+权限|成功返 0|失败返 -1(已存在则 errno==EEXIST)*/
mkdir("out", 0755);
/* 改权限|传 路径+权限|成功返 0|失败返 -1 */
chmod("run.sh", 0755);

/* 探测可访问性|传 路径+模式(F_OK 存在/R_OK 读/W_OK 写/X_OK 执行)|可访问返 0|否则返 -1 置 errno */
if (access("cfg", F_OK) == 0) { }   /* 文件存在 */
access 之后再 open 是经典的 TOCTOU 竞态(检查到使用之间文件可能被换掉)——安全场景应直接 open 再看错误,别先 access。st_size 对目录/管道无意义。stat 跟随符号链接、lstat 不跟随。
stat 会跟随符号链接,lstat 不会——想知道「这个名字本身是不是一个软链」,只能用 lstat;用 stat 你看到的是它指向的那个东西。递归遍历目录时这个区别是致命的:用错就会顺着指向父目录的软链无限循环下去。另外别用 access 来决定「能不能打开」:它检查完到你真正 open 之间,权限可能已经变了(经典的 TOCTOU 竞态,也是一类安全漏洞)。正确姿势是直接 open,失败了再看 errno。

列目录内容用 opendir 拿到目录流,readdir 一次吐一个条目直到 NULL。要类型/大小还得对每个名字再 stat 一次。

#include <dirent.h>

/* 打开目录|传 路径|成功返 DIR*|失败返 NULL 置 errno */
DIR *d = opendir(".");
if (!d) { perror("opendir"); return 1; }

struct dirent *e;
/* 读下一条|传 DIR*|成功返 struct dirent*(内部静态,勿存)|读完或出错返 NULL */
while ((e = readdir(d)) != NULL) {
    /* 跳过 . 与 .. 两个特殊项 */
    if (e->d_name[0] == '.' &&
        (e->d_name[1] == '\0' || (e->d_name[1] == '.' && e->d_name[2] == '\0')))
        continue;
    printf("%s\n", e->d_name);       /* d_name:条目名(不含路径)*/
}
/* 关闭目录流|传 DIR*|成功返 0|失败返 -1 */
closedir(d);
readdir 返回顺序不保证有序(要排序自己收集后 qsort);d_name 只是名字,要完整路径得自己拼「目录/名字」再传 stat。返回 NULL 既可能读完也可能出错——先 errno=0 再看 errno 区分。递归遍历目录树可直接用 nftw(<ftw.h>)省去手写。
两个必知的细节。①readdir 一定会吐出 "." 和 "..",递归遍历时不显式跳过这两项就会原地打转;而且返回顺序不保证有序(不是字母序,也不是创建序),要排序得自己来。②struct dirent 里的 d_type 能省掉一次 stat,但它不可靠:在 ext4 上能正常返回 DT_REG(值 8),可在某些文件系统上它一律是 DT_UNKNOWN(值 0)。所以标准写法是"先看 d_type,等于 DT_UNKNOWN 再退回去 lstat",而不是二选一。
进程与信号

进程相关系统调用的大本营。「为什么 fork 一次返回两次、什么是写时复制/僵尸进程」在 04 章已讲透,这里是函数速查。fork + exec + wait 就是 shell 跑每条命令的模型。

#include <unistd.h>

/* 创建子进程(复制自身)|无参|父进程里返子 PID(>0)、子进程里返 0|失败返 -1 置 errno */
pid_t pid = fork();

/* 用新程序替换当前进程映像|传 命令+参数列表(NULL 结尾)|成功不返回|失败才返 -1 */
execvp("ls", (char*[]){ "ls", "-l", NULL });  /* execlp/execv/execve 一族,传参形式不同 */

pid_t me     = getpid();     /* 本进程 PID(无失败)*/
pid_t parent = getppid();    /* 父进程 PID(无失败)*/

/* 无缓冲结束进程(fork 后子进程 exec 失败时该用它,绕开 atexit 与 stdio 刷缓冲)|传 退出码|不返回 */
_exit(127);

int fd[2];
/* 建管道|传 int[2]|成功返 0 并填 fd[0]读端/fd[1]写端|失败返 -1 */
pipe(fd);

chdir("/tmp");              /* 改工作目录|传 路径|成功返 0|失败返 -1 */
char cwd[4096];
getcwd(cwd, sizeof cwd);   /* 取工作目录|传 缓冲+容量|成功返 buf|装不下返 NULL 置 errno */

unsigned left = sleep(3);   /* 睡 n 秒|传 秒数|睡满返 0|被信号提前唤醒返剩余秒数 */
fork 前若 stdio 缓冲区里有没刷出的内容,父子会各带一份、各打印一次——fork 前先 fflush(stdout)。exec 成功后原来的代码整个被覆盖,其后的行只有失败时才执行到。多线程程序里 fork 后到 exec 之间只能调 async-signal-safe 函数。
一条容易被忽略的规则:子进程默认继承父进程所有打开的文件描述符。这既是 shell 能先重定向再 exec 的原理,也是个泄漏源——服务进程 fork 出子进程去执行外部命令时,监听套接字、日志句柄会一并被带过去,子进程不关的话,你重启服务时会发现端口还被占着(那个子进程还攥着它)。要么在子进程里显式 close,要么打开时就带上 O_CLOEXEC 标志(fcntl 里对应 FD_CLOEXEC),让它在 exec 时自动关闭——后者才是现代写法。

父进程必须 wait 子进程,取回退出状态、腾出进程表项——否则子进程变僵尸。状态是个打包的整数,要用 WIF*/W*STATUS 宏拆开看。

#include <sys/wait.h>

int status;
/* 等任一子进程结束|传 int* 收状态|成功返结束的子 PID|无子进程/失败返 -1 */
pid_t done = wait(&status);

/* 等指定子进程|传 PID(-1=任意)+int*+标志(0 阻塞、WNOHANG 非阻塞立即返)|成功返子 PID(WNOHANG 且无就绪返 0)|失败返 -1 */
waitpid(pid, &status, 0);

/* —— 用宏解读 status —— */
if (WIFEXITED(status))          /* 是否正常 return/exit 结束 */
    printf("退出码 %d\n", WEXITSTATUS(status));  /* 取 0-255 退出码 */
if (WIFSIGNALED(status))        /* 是否被信号杀死 */
    printf("被信号 %d 杀死\n", WTERMSIG(status));  /* 取致命信号编号 */
不回收子进程会攒僵尸,占满进程表。常见做法是给 SIGCHLD 装处理函数,在里面 while (waitpid(-1, &st, WNOHANG) > 0); 循环收干(一次信号可能对应多个子进程退出)。WEXITSTATUS 只在 WIFEXITED 为真时才有意义,别不判断就取。
一次 wait 只回收一个子进程,而信号不排队:三个子进程几乎同时退出,SIGCHLD 可能只送达一次。所以在 SIGCHLD 处理函数里写一句 wait 是错的——必然漏掉僵尸。固定写法是循环收干净:while (waitpid(-1, &st, WNOHANG) > 0);,WNOHANG 保证没有可收的子进程时立刻返回而不阻塞。另外若你根本不关心子进程的退出码,可以直接把 SIGCHLD 设成 SIG_IGN,内核就不再产生僵尸,连 wait 都省了。

比 06 章 ISO C 的 signal() 强得多:sigaction 语义明确、可控制是否重启被打断的系统调用;kill 给别的进程发信号;sigprocmask 临时屏蔽信号保护临界区。

#include <signal.h>

volatile sig_atomic_t stop = 0;
void on_term(int sig) { stop = 1; }

/* 注册信号处理(比 signal 更可靠)|传 信号+新 sigaction*(可NULL)+旧 sigaction*(可NULL)|成功返 0|失败返 -1 */
struct sigaction sa = {0};
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask);          /* 处理期间额外屏蔽的信号集,先清空 */
sa.sa_flags = SA_RESTART;          /* 让被打断的慢系统调用自动重启 */
sigaction(SIGTERM, &sa, NULL);

/* 给进程发信号|传 目标PID(>0 该进程/0 本进程组/-1 广播)+信号(0=只探测存活)|成功返 0|失败返 -1 */
kill(pid, SIGTERM);

/* —— 信号集与屏蔽 —— */
sigset_t set;
sigemptyset(&set);                /* 清空信号集|成功返 0 */
sigaddset(&set, SIGINT);           /* 往集合加一个信号|成功返 0 */
/* 改本线程的信号屏蔽字|传 how(SIG_BLOCK/UNBLOCK/SETMASK)+新集*+旧集*(可NULL)|成功返 0|失败返 -1 */
sigprocmask(SIG_BLOCK, &set, NULL);  /* 进临界区前挡住 SIGINT */
处理函数里依然只能调异步信号安全函数(write 行、printf/malloc 不行),标准姿势还是「只置一个 volatile sig_atomic_t 标志」。SIGKILL(9) 与 SIGSTOP 无法被捕获或忽略——想优雅退出要处理的是 SIGTERM。信号会打断阻塞的系统调用使其返 EINTR,加 SA_RESTART 可自动重启大部分。
sigaction 相对 signal 的关键优势是行为由你显式指定,而不是由平台决定。最典型的就是 SA_RESTART:在 glibc 上,用 signal() 装的处理函数自动带上了它,被打断的 read 会悄悄重启;换成 sigaction 且不设该标志,同一段代码就返回 -1 且 errno == EINTR。两种都可能,所以慢系统调用一律要判 EINTR 重试。另外 kill 名字唬人,其实是「发任意信号」,kill(pid, 0) 不发信号、只用来探测某个进程是否还在。
线程 · 内存映射

同进程线程共享内存,是最常用的并发方式。数据竞争、互斥锁、条件变量的「为什么」在 04 章讲透,这里按函数速查。编译链接都要加 -pthread。

#include <pthread.h>

void *worker(void *arg) { return NULL; }   /* 线程函数签名固定:void* f(void*) */

pthread_t t;
/* 创建线程|传 pthread_t*+属性(NULL 默认)+线程函数+传给它的参数|成功返 0|失败返错误码(不置 errno!) */
pthread_create(&t, NULL, worker, NULL);
/* 等线程结束并取返回值|传 pthread_t+void** 收返回值(可NULL)|成功返 0|失败返错误码 */
pthread_join(t, NULL);
/* 或分离:不再 join,结束即自动回收——pthread_detach(t) */

/* —— 互斥锁 —— */
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&mtx);          /* 进临界区(阻塞直到拿到)|成功返 0 */
/* ... 改共享数据 ... */
pthread_mutex_unlock(&mtx);        /* 出临界区|成功返 0 */

/* —— 条件变量:等某个条件成立 —— */
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
pthread_mutex_lock(&mtx);
while (!ready)                      /* while 而非 if:防虚假唤醒 */
    pthread_cond_wait(&cv, &mtx);    /* 原子地「放锁+睡」,被唤醒后重新持锁 */
pthread_mutex_unlock(&mtx);
/* 另一线程:改好条件后 pthread_cond_signal(&cv) 唤醒 */
pthreads 函数失败返回错误码本身、不设 errno(和别的系统调用不同),别去查 errno。等条件必须 while 不能 if(虚假唤醒);多锁要固定加锁顺序防死锁。忘了 -pthread 会链接失败或行为诡异。C11 的 <threads.h>(thrd_*/mtx_*)是标准化等价物,但工程仍以 pthreads 为主。
pthread 这套 API 不走 errno,它把错误码直接作为返回值返回——这和你在本页学的所有其它系统调用都相反,极易写错。给 pthread_attr_setstacksize 传非法值:返回 22(即 EINVAL),而 errno 仍是 0。所以判断失败要写 if ((rc = pthread_mutex_lock(&m)) != 0),翻译错误信息用 strerror(rc) 而不是 strerror(errno);习惯性写 perror(...) 只会打印出"Success"。另外线程要么 join 要么 detach,两样都不做就是线程版的资源泄漏。

TCP/UDP 网络编程的系统调用层。服务端 socket→bind→listen→accept,客户端 socket→connect,之后两边都用 send/recv(或 read/write)收发。地址结构与字节序辅助在 <netinet/in.h>/<arpa/inet.h>。这里只管「函数怎么调」;粘包分帧、SIGPIPE、并发模型、epoll 事件循环这些「怎么写对」的内容,全部在 08 章 网络编程。

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

/* 建套接字|传 域(AF_INET IPv4)+类型(SOCK_STREAM TCP/SOCK_DGRAM UDP)+协议(0 自动)|成功返 fd|失败返 -1 */
int s = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);       /* 端口转网络字节序(大端);htonl 转 32 位 */
addr.sin_addr.s_addr = INADDR_ANY; /* 监听所有网卡;或 inet_pton(AF_INET,"1.2.3.4",&addr.sin_addr) */

/* —— 服务端 —— */
int yes = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);  /* 允许重启快速复用端口 */
bind(s, (struct sockaddr*)&addr, sizeof addr);  /* 绑地址|成功返 0|失败返 -1 */
listen(s, 128);                  /* 转为监听、设 backlog|成功返 0|失败返 -1 */
/* 取一个已完成连接|传 监听fd+对端地址*(可NULL)+长度*|成功返新的连接 fd|失败返 -1 */
int conn = accept(s, NULL, NULL);

char buf[1024];
/* 收数据|传 fd+缓冲+长度+标志(0)|成功返收到字节数(0=对端关闭)|失败返 -1 */
ssize_t n = recv(conn, buf, sizeof buf, 0);
/* 发数据|传 fd+缓冲+长度+标志(0)|成功返发出字节数(可能 <请求)|失败返 -1 */
send(conn, buf, n, 0);

/* —— 客户端:connect(s, (struct sockaddr*)&addr, sizeof addr) 成功返 0、失败返 -1 —— */
一切多字节数字进网络前都要转网络字节序(htons/htonl),忘了就在大小端不同的机器间乱套。TCP 是字节流、没有消息边界:一次 recv 可能收到半条或多条,必须自己按长度前缀/分隔符切分。send 也可能只发出一部分,要循环补发。recv 返 0 是对端正常关闭,别当出错。
这张表只是函数索引,别指望照着它就能写出正确的网络程序。这套调用单看都很简单,难的全在它们之间:send 可能只发出去一半、recv 收到的不是你发的那一「条」、对端断开后继续写会被 SIGPIPE 直接打死进程、accept 循环怎么撑住上万连接。这些「怎么写对」的内容全部在 08 章。

mmap 把文件或匿名内存直接映射进地址空间,之后像访问数组一样读写——省掉 read/write 的拷贝,也是大块内存分配(malloc 底层)和进程间共享内存的机制。

#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>

int fd = open("big.bin", O_RDONLY);
struct stat st; fstat(fd, &st);

/* 建映射|传 建议地址(NULL 由内核选)+长度+保护(PROT_READ/WRITE)+标志(MAP_SHARED/PRIVATE)+fd+文件偏移|成功返映射首地址|失败返 MAP_FAILED((void*)-1) */
char *p = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
close(fd);                          /* 映射建立后 fd 就能关了 */

char first = p[0];                  /* 直接当内存访问,内核按缺页惰性载入 */

/* 匿名映射:不关联文件,等价于向内核直接要一大块内存 */
char *heap = mmap(NULL, 1<<20, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);

/* 改一段映射的保护属性|传 地址+长度+新保护|成功返 0|失败返 -1 */
mprotect(heap, 4096, PROT_READ);   /* 把首页设为只读——再写就 SIGSEGV */

/* 解除映射|传 首地址+长度|成功返 0|失败返 -1 */
munmap(p, st.st_size);
munmap(heap, 1<<20);
mmap 失败返回的是 MAP_FAILED(即 (void*)-1),不是 NULL,判错别写成 == NULL。映射长度向上取整到整页;越过文件实际大小的那部分页访问会触发 SIGBUS。MAP_SHARED 的写才落回文件,MAP_PRIVATE 是写时复制、改动不落盘。
mmap 失败返回的是 MAP_FAILED,不是 NULL——判断必须写 if (p == MAP_FAILED)。失败时返回值是 0xffffffffffffffff(即 (void *)-1),用 if (!p) 判断的话永远判不出错,然后拿这个值去解引用当场段错误。另外两点:映射的长度会被向上取整到整页,但访问超出文件实际大小的那部分会收到 SIGBUS(不是段错误,别找错方向);写回磁盘的时机由内核决定,要确保落盘得显式 msync。

glibc 在标准之外加了不少顺手的函数,很多要在包含头文件之前 #define _GNU_SOURCE 才可见。它们好用但不可移植——换 musl、BSD、Windows 可能没有,写跨平台代码前先想清楚。

#define _GNU_SOURCE            /* 必须在所有 #include 之前 */
#include <stdio.h>
#include <string.h>

/* —— 安全读整行(推荐替代 fgets,自动扩容)—— */
char *line = NULL; size_t cap = 0;
/* 读一行(含换行)|传 char**行缓冲(自动 malloc/realloc)+size_t*容量+流|成功返读入字节数|EOF/失败返 -1 */
ssize_t len = getline(&line, &cap, stdin);
free(line);                    /* 由 getline 分配,用完自己 free */

/* —— 格式化到新分配的缓冲(省去先算长度)—— */
char *msg;
/* 分配并格式化|传 char**(接收 malloc 出的串)+格式+实参|成功返写入长度|失败返 -1 */
int n = asprintf(&msg, "user-%d", 42);
free(msg);

/* —— 其它常用扩展 —— */
char *dup = strdup("copy me");   /* 复制字符串到新 malloc 的缓冲(C23 起已入标准);用完 free */
char *d2  = strndup("hello", 3); /* 最多复制 n 字节版 */
void *hit = memmem(hay, hn, "needle", 6);  /* 在内存块里找子块(strstr 的二进制版) */
一旦用了这些,代码就绑死 glibc;strdup/strndup 到 C23 才进标准,之前都算扩展。getline/asprintf 分配的缓冲要自己 free。想保持可移植,要么改用标准替代(fgets+手动扩容、两趟 snprintf),要么用 #ifdef __GLIBC__ 兜底。
判断标准边界的实用心法:man 手册页底部的 CONFORMING TO / STANDARDS 段会写明某函数属于 C 标准、POSIX 还是 GNU 扩展——拿不准某个头/函数归哪一层时,查它最快。

网络编程

socket 编程的全部手艺:调用序列与地址、TCP 的三堵墙(没有消息边界、send / recv 都可能只做一半、连接关闭远比想象中复杂)、并发模型从「一次一个」到 epoll、UDP 与客户端写法,最后是一个浏览器能打开的 HTTP 服务器与排错工具箱。协议本身为什么这样设计(三次握手、拥塞控制、TLS)属于计算机网络页,这里只管「怎么写对」。

写网络程序只有两条主线:服务端 socket → bind → listen → accept、客户端 socket → connect。之后两边都只是在一个 fd 上读写(07 章)。

调用序列

服务端客户端
socket(AF_INET, SOCK_STREAM, 0)getaddrinfo("host", "80", &hints, &res)
setsockopt(SO_REUSEADDR)socket(…) 按 res 里的族来建
bind(fd, (struct sockaddr*)&addr, sizeof addr)connect(fd, res->ai_addr, res->ai_addrlen)
listen(fd, backlog)—
accept(fd, …) → 拿到一个新 fd—
之后两边一样:read / recv、write / send、close

API 与地址

函数要点
htons / htonl / ntohs端口和 IP 要转成网络字节序(大端);忘了就是「连不上但也不报错」
inet_pton(AF_INET, "1.2.3.4", &addr.sin_addr)字符串转地址;反向是 inet_ntop(别用老的 inet_addr / inet_ntoa)
struct sockaddr_in 与那个强制转换API 收的是通用的 struct sockaddr *,所以到处都要转型——这是 C 没有泛型留下的疤
getaddrinfo客户端一律用它:同时处理 DNS 与 IPv4 / IPv6,遍历 ai_next 逐个试;用完 freeaddrinfo
send / recv vs write / read前者多一个 flags(MSG_NOSIGNAL / MSG_PEEK / MSG_WAITALL)
close vs shutdownclose 关整个 fd;shutdown(fd, SHUT_WR) 只关写方向——对端会读到 EOF,而你还能继续读(半关闭)
常用 setsockoptSO_REUSEADDR(重启就能 bind)、TCP_NODELAY(关 Nagle,小包立刻发)、SO_RCVTIMEO / SO_SNDTIMEO(超时)、SO_KEEPALIVE
UDPSOCK_DGRAM + sendto / recvfrom:有消息边界、不可靠、不保序;一次 recvfrom 收一整个数据报,缓冲不够剩下的就丢了

三条铁律

律说明
① TCP 没有消息边界发两次 "ab" "cd",对端可能一次收到 "abcd",也可能分三次收到——所谓「粘包」不是 bug,是 TCP 的定义。解法:长度前缀(最通用)、分隔符、或定长记录
② 返回值就是「这次实际搬了多少」read / write 可能只处理了一部分——必须写成循环(readn / writen),不能假设一次到位
③ 每个返回值都要分三种情况> 0 正常;== 0 表示对端已关闭(不是「没数据」);< 0 再看 errno:EINTR 重试、EAGAIN 等事件、其它才是真错误

并发模型与 epoll

模型特点
一次一个连接最简单,第二个客户端只能干等
每连接一进程(fork)隔离好、开销大;要处理 SIGCHLD 防僵尸(07 章)
每连接一线程轻一些,但上千连接就吃不消;共享状态要加锁(04 章)
线程池 + 队列控制住线程数,实际项目常见
IO 多路复用(事件循环)单线程管上万连接——nginx、Redis 走的路
select 的三个硬伤fd 上限 FD_SETSIZE(通常 1024)、每次都要重建集合、返回后要 O(n) 扫一遍
epoll_create1(0)建一个 epoll 实例
epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev)注册 / 修改(MOD)/ 删除(DEL);ev.events 常用 EPOLLIN EPOLLOUT EPOLLRDHUP EPOLLET EPOLLONESHOT
epoll_wait(ep, evs, n, timeout)返回就绪的那几个,不用扫全部
LT vs ET水平触发(默认):只要还有数据就一直通知,好写;边沿触发:只在状态变化时通知一次——必须把 fd 设成非阻塞并一直读到 EAGAIN,否则剩下的数据再也收不到

症状反查

症状原因解法
bind: Address already in use上一次的连接还在 TIME_WAITsetsockopt(SO_REUSEADDR),写在 bind 之前
connect: Connection refused那个端口没人监听ss -ltnp 看服务是否真起来了、端口对不对
连接超时(无响应)防火墙丢包、地址不可达和上一条的区别:被拒绝是「有人明确说不」,超时是「石沉大海」
程序毫无征兆地死掉,退出码 141往已关闭的连接写,收到 SIGPIPE(默认动作是终止进程)signal(SIGPIPE, SIG_IGN) 或 send(..., MSG_NOSIGNAL),然后检查 EPIPE
收到的数据是半条 / 两条粘一起没分帧长度前缀(铁律①)
非阻塞下 read 返回 -1,errno == EAGAIN正常现象:现在没数据回事件循环等下一次可读
ET 模式下有数据却收不到通知上次没读干净读到 EAGAIN 为止
客户端只能连上一个,后面的排队没做并发见上一张表

排错工具箱

工具看什么
ss -ltnp谁在监听哪个端口(-t TCP、-l 监听、-n 不解析、-p 进程)
ss -tn state time-wait看 TIME_WAIT 堆积
tcpdump -i lo -A port 8080线上到底传了什么字节——分帧问题的最终裁判
strace -e trace=network ./server它到底调了哪些网络调用、卡在哪一个
nc -v host port / curl -v手工发一段、看完整交互——先确认服务端行为,再怀疑客户端代码
把 read / write 的返回值当成「全做完了」,是网络编程里最常见也最难复现的 bug。本机回环测试几乎永远一次就搬完,于是代码看起来完全正常;一上真实网络、数据一大,就开始出现「偶尔少了几个字节」。从第一行就把它们写成循环,并且把 == 0(对端关闭)和 EINTR / EAGAIN 分开处理——这三种情况少处理任何一种,都会在压力上来时变成线上事故。
调试网络程序的固定顺序:① ss 确认在监听 → ② nc 或 curl 手工连一下 → ③ tcpdump 看真实字节 → ④ 才回来读代码。一大半「代码有 bug」的直觉,最后查出来是端口不对、绑在了 127.0.0.1 而不是 0.0.0.0、或者对端根本没发那个字段。
从 fd 到连接

一条网络连接在程序眼里就是一个文件描述符,read / write / close 原样能用(fd 的三层结构见 07 章第 2 卡)。但这个 fd 有三个别的 fd 没有的脾气,本章大半篇幅都在处理它们。

三堵墙

脾气表现去哪一卡
没有边界send 三次,对端可能一次全收到,也可能收到半条——文件不会这样,管道也很少这样第 05、06 卡
随时会做一半send 一千字节可能只发出去三百;recv 可能被信号打断第 06 卡
会被对面单方面掐断对端崩了、拔网线了、进程被 kill 了,你的 fd 还在——写下去会让进程无声无息地死掉第 07 卡

一次通信的全流程

服务端                         客户端
socket()   建 fd
bind()     占住端口
listen()   转为监听
accept()  ←──────── connect()  三次握手在内核里完成
   │                    │
   └──── recv/send ─────┘      收发(就是 read/write)
close()                close()  四次挥手

握手与挥手全部由内核代劳——accept 返回时连接早已建好。所以本章几乎不谈 TCP 状态机,只关心 API 的返回值和错误码。

和其它页的分工

问题去哪
怎么写出正确、扛得住并发的 C 网络程序(API、错误码、并发模型、排错)本章
协议为什么这样设计:三次握手为什么是三次、拥塞控制、TLS、HTTP/2计算机网络页
epoll 在内核里怎么实现、五种 I/O 模型的分类、io_uring操作系统页
两个方向的误读都很常见。一是以为要先啃完三次握手与 TCP 状态机才能动手——握手是内核代劳的,写对本章的代码只需要 API、返回值和 errno;二是反过来把这一章当成「背 socket 调用顺序」——五步 API 半小时就能抄会,真正出 bug 的几乎全是上面那三堵墙,只记调用顺序的结果就是本机全对、上线全错。
本章代码在 Linux 上写、在 Linux 上跑。Windows 装 WSL2 即可(工具链见 00 章第 2 卡),不要试图在原生 Windows 上编译——Windows 用的是 Winsock:头文件是 <winsock2.h>、要先 WSAStartup、关闭用 closesocket、错误码不走 errno 而是 WSAGetLastError。API 形状像但细节处处不同,混着来只会徒增困惑。

在建连接之前得先能说清「我要连哪里」。这件小事上 C 的网络 API 留了两个历史包袱:一个是字节序,一个是没有泛型时代硬造出来的「伪继承」。两个都得知道,否则代码能编译却连不上。

字节序:为什么要 htons

  • 多字节整数在内存里的排列顺序,各家 CPU 不一样:x86/ARM 通常小端(低位字节在前),而网络协议统一规定用大端,称为网络字节序。
  • 所以端口号、IP 这类要进协议头的数字,发出去前必须转:htons(host to network, short,16 位)、htonl(32 位),收回来用 ntohs/ntohl。
  • 只有进协议字段的数字要转。你自己在 TCP 流里传的业务数据,只要收发两端约定好就行——但既然要定,一般也跟着定成大端,省得日后换平台出错。

sockaddr 家族:C 版的「基类指针」

  • bind/connect/accept 都收 struct sockaddr *,但你实际填的是 struct sockaddr_in(IPv4)或 sockaddr_in6(IPv6)。C 没有继承,于是靠强制转换模拟:所有变体开头都是同样的 sa_family 字段,函数先读它再决定怎么解释后面的字节。
  • 这就是为什么每份网络代码里都有那句难看的 (struct sockaddr *)&addr——它不是可选的风格,是 API 的要求。
  • 要存「不知道是 v4 还是 v6」的地址,用 struct sockaddr_storage:它足够大能装下任何一种。

最小复现:字节序与 inet_pton 的返回值

  • unsigned int n = htonl(0x12345678);,再用 unsigned char *p = (unsigned char *)&n; 逐字节 printf("%02x ", p[i]):x86 上打出 12 34 56 78,那是网络大端序;把 htonl 去掉重跑变成 78 56 34 12,那是本机小端序。
  • 把 inet_pton(AF_INET, "127.0.0.1", ...) 的字符串改成 "999.0.0.1" 并打印返回值——格式非法返 0(不是 −1,也不置 errno),这是最容易漏掉的一处错误检查。
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

struct sockaddr_in addr = {0};        /* 一定要清零:有保留字段 */
addr.sin_family = AF_INET;                /* IPv4 */
addr.sin_port   = htons(9000);          /* 端口转大端 */

/* 监听本机所有网卡 */
addr.sin_addr.s_addr = htonl(INADDR_ANY);

/* 或指定一个 IP:文本转二进制|传 族+字符串+目标|成功返 1|格式错返 0|族不对返 -1 */
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

/* 反向:二进制转文本,打日志用|返回 dst 指针|失败返 NULL */
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof ip);
printf("%s:%d\n", ip, ntohs(addr.sin_port));   /* 读出来记得转回主机序 */

/* 调用时的强制转换——所有网络代码里都长这样 */
bind(fd, (struct sockaddr *)&addr, sizeof addr);
INADDR_ANY 的值是 0,转不转字节序结果都一样,于是很多代码直接写 addr.sin_addr.s_addr = INADDR_ANY; ——能跑,但是个坏习惯:换成任何别的地址常量就错了,而且错得很隐蔽(连不上或连到奇怪的地方)。老代码里的 inet_addr 也别再用:它拿 -1 同时表示「出错」和合法地址 255.255.255.255,无法区分,一律改用 inet_pton。
真正的工程代码里,手工填地址的方式主要用在服务端 bind;客户端连别人一律用 getaddrinfo(见本章「客户端该怎么写」卡)——它一次解决域名解析、IPv4/IPv6 兼容和端口服务名,代码还更短。

一段完整的、可以直接编译运行的服务器:你发什么它回什么。它一次只服务一个连接(并发是后面几卡的事),但五个系统调用一个不少,是本章其余所有内容的骨架。

五步各自在做什么

  • socket():要一个 fd。此时它什么都还不是,既没地址也没角色。
  • bind():把 fd 和「本机哪个 IP、哪个端口」绑定。不绑的话内核会随机给你一个端口——客户端无所谓,服务端必须绑。
  • listen():把它变成监听 fd。第二个参数 backlog 是「已完成握手但你还没 accept 的连接」能排多长的队。
  • accept():从队列里取一个连接,返回一个全新的 fd。这是最容易搞混的地方——监听 fd 和连接 fd 是两个不同的东西,前者永远不用来收发数据。
  • recv()/send():在连接 fd 上收发。recv 返回 0 表示对端正常关闭。

分三关验证:监听 → 接受 → 回显

  • 第 1 关 · 只监听:只写 socket + bind + listen,末尾用 pause() 挂住。另开终端 ss -tln | grep 9000,看到 LISTEN 0 128 0.0.0.0:9000 即为通过;此时还连不上,因为没有 accept。
  • 第 2 关 · 能接:加 accept,连上就 printf("有人来了\n"),先不收发。用 nc 127.0.0.1 9000 连一下验证。
  • 第 3 关 · 回显:补上 recv / send 循环,即为完整成品。

再改坏它,看两个典型症状——

  • 删掉 SO_REUSEADDR 那两行,连一次、Ctrl-C、立刻重启:bind: Address already in use(端口卡在 TIME-WAIT,ss -tan | grep 9000 看得到,约 60 秒自解,第 8 卡专讲)。把那两行加回就好。
  • 把 send 外层的 while (off < n) 循环删掉、只 send 一次:本机测几乎看不出问题——而这个「看不出」正是第 5、6 卡的引子,短写与粘包都是本机测不出、一上真实网络才爆炸的坑。
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main(void) {
    int srv = socket(AF_INET, SOCK_STREAM, 0);
    if (srv < 0) { perror("socket"); return 1; }

    int yes = 1;      /* 为什么必须有这行,见本章「TIME_WAIT 与 SO_REUSEADDR」 */
    setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

    struct sockaddr_in addr = {0};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port        = htons(9000);

    if (bind(srv, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("bind");   return 1; }
    if (listen(srv, 128) < 0)                                    { perror("listen"); return 1; }
    printf("listening on 9000\n");

    for (;;) {
        int conn = accept(srv, NULL, NULL);   /* 阻塞等;返回的是新 fd */
        if (conn < 0) { perror("accept"); continue; }

        char buf[4096];
        ssize_t n;
        while ((n = recv(conn, buf, sizeof buf, 0)) > 0) {
            ssize_t off = 0;
            while (off < n) {                /* send 可能只发一部分,必须循环 */
                ssize_t w = send(conn, buf + off, n - off, 0);
                if (w < 0) { perror("send"); break; }
                off += w;
            }
        }
        /* n == 0:对端关闭;n < 0:出错 */
        close(conn);
    }
}
跑起来之后,这三种报错你一定会撞上,先认脸:
① bind: Address already in use——端口被占。可能是上一个进程还活着(ss -tanp | grep 9000 看看),也可能是残留的 TIME_WAIT(本章「TIME_WAIT 与 SO_REUSEADDR」专讲)。
② bind: Permission denied——你想绑 1024 以下的端口却不是 root。开发阶段一律用 8080、9000 这类高位端口,别去动 80。
③ 客户端侧的 Connection refused——服务端没在这个端口上监听。它是好消息:说明包到了对方机器、对方内核明确回了拒绝;真正难查的是什么都不返回一直卡着,那通常是防火墙丢包或 IP/端口填错。
编译并两个终端对着测:
gcc -Wall -Wextra -o echo echo.c && ./echo   # 终端 A
nc 127.0.0.1 9000                            # 终端 B,敲什么回什么
ss -tan | grep 9000                          # 终端 C,看连接状态流转
在终端 C 里你能亲眼看到自己的连接从 ESTAB 走到 TIME-WAIT——把 nc 关掉再看一次,这比读十页 TCP 状态机都直观。
写对:字节流的真相

这是网络编程的头号陷阱,而且它在本机测试时几乎不出现、一上真实网络就爆炸——所以必须在写第一行业务逻辑之前就理解它。

问题

  • TCP 提供的是有序字节流,不是消息序列。它保证你发出去的字节按顺序、不重不丢地到达,但完全不保证怎么切分。
  • 你 send 三次,对方可能:一次 recv 全收到(俗称「粘包」);分三次收到;甚至第一次收到两条半(俗称「拆包」)。这不是 bug,是 TCP 的定义。
  • 成因:发送端有 Nagle 算法和发送缓冲会合并小包,中间链路按 MTU 切分,接收端缓冲里攒了多少你就能读到多少。

最小复现(本机就能看到)

让客户端连续 send 三次,服务端故意等 200 毫秒再 recv 一次:

// 客户端
send(c, "AAA", 3, 0);
send(c, "BBB", 3, 0);
send(c, "CCC", 3, 0);

// 服务端
usleep(200000);
ssize_t n = recv(conn, buf, sizeof buf - 1, 0);

实际输出:

客户端 send 了 3 次,服务端 recv 1 次拿到 9 字节: "AAABBBCCC"
  • 三条消息糊成了一条。如果你的协议是「一次 recv 就是一条命令」,这里就已经错了。
  • 把 usleep 去掉,多半又能分三次收到——这正是它最隐蔽的地方:时序一变结果就变,本机跑一百次都对,上线就出事。
不要试图用「加个 sleep」「每次多 recv 几遍」「假设一次 recv 就是一条」来绕过它——这些做法在低负载下都能骗过测试。唯一正解是在字节流之上自己定义消息边界,也就是本章后面的分帧。
另外记住反面:UDP 恰恰相反,它保留边界(一次 sendto 对应一次 recvfrom),代价是可能丢、可能乱序、可能重复。选 TCP 还是 UDP,这是最实际的一条区别。
「拆包」也在本机就能逼出来,不用等真实网络:一次 send 1 MB,接收端拿同样 1 MB 的大缓冲区 recv——loopback send 一次返回 1048576,第一次 recv 却只拿到十二万多字节,分了 5 次才收完(具体数字每次都变,这正是字节流的本性)。写协议代码时用「连发小包+对端延迟收」造粘、用「单发大块」造拆,两种切分都提前演练过,比上线碰运气强得多。

本章前面的解法:在字节流上自己划边界。工程上只有两种主流做法,配上一对必须自己写的辅助函数。

两种分帧方式

  • 长度前缀(最常用):每条消息前面加固定长度的头,写明后面有多少字节。二进制友好、解析 O(1)、不用扫描内容。gRPC、Redis 的批量回复、绝大多数自定义 RPC 都这么干。
  • 分隔符:用一个不会出现在内容里的字节序列收尾,比如 HTTP 头部用 \r\n\r\n、Redis 命令用 \r\n。人可读、便于 telnet 调试,但要处理转义,且必须逐字节扫描。
  • 真实协议常常两者都用:HTTP 头部用分隔符(好读),正文用 Content-Length 长度前缀(好切)——这不是拍脑袋,是各取所长。

三条铁律(每条都对应一类线上事故)

  • ① 返回值必须查,而且不能只查负数。send/recv 返回的是实际处理的字节数,小于你要求的是常态而非异常。写必须循环到写完。
  • ② errno == EINTR 要重试不是报错。阻塞中的系统调用被信号打断就会这样返回 -1,数据一个字节都没丢,重新调一次即可。
  • ③ recv 返回 0 是对端正常关闭,不是错误。把它当错误处理会到处打假告警;把它当「没数据」继续循环,则会死循环把 CPU 打满。

验证:同样的三次连发,这回收对了

拿本章前面那个「连发 AAA、BBB、CCC」的客户端去打下面这份代码,服务端输出:

收到完整消息: "AAA" (3 字节)
收到完整消息: "BBB" (3 字节)
收到完整消息: "CCC" (3 字节)
  • 对比本章前面的 "AAABBBCCC":底层收到的字节完全一样,差别只在于这一层代码知道每条消息到哪里结束。分帧要解决的就是这一件事。
/* 死等到读满 n 字节|返回 n|对端提前关闭返 0|出错返 -1 */
static ssize_t readn(int fd, void *buf, size_t n) {
    char *p = buf;
    size_t left = n;
    while (left > 0) {
        ssize_t r = recv(fd, p, left, 0);
        if (r < 0) { if (errno == EINTR) continue; return -1; }  /* 铁律② */
        if (r == 0) return 0;                                    /* 铁律③ */
        left -= (size_t)r; p += r;                              /* 铁律① */
    }
    return (ssize_t)n;
}

/* 死等到写完 n 字节|返回 n|出错返 -1 */
static ssize_t writen(int fd, const void *buf, size_t n) {
    const char *p = buf;
    size_t left = n;
    while (left > 0) {
        ssize_t w = send(fd, p, left, 0);
        if (w < 0) { if (errno == EINTR) continue; return -1; }
        left -= (size_t)w; p += w;
    }
    return (ssize_t)n;
}

/* —— 在 readn/writen 之上做长度前缀分帧 —— */
int send_msg(int fd, const char *s) {
    uint32_t len = htonl((uint32_t)strlen(s));   /* 4 字节头,网络字节序 */
    if (writen(fd, &len, 4) < 0) return -1;
    return writen(fd, s, strlen(s)) < 0 ? -1 : 0;
}

/* 收一条完整消息|返回消息长度|对端关闭返 0|出错返 -1 */
ssize_t recv_msg(int fd, char *out, size_t cap) {
    uint32_t len;
    ssize_t r = readn(fd, &len, 4);
    if (r <= 0) return r;
    len = ntohl(len);
    if (len >= cap) return -1;      /* 必须查上界!见 pitfall */
    if (len > 0) {                /* len==0 是合法空消息,别去调 readn */
        r = readn(fd, out, len);
        if (r <= 0) return -1;   /* 帧头都读到了正文却断 = 协议错,不是正常收尾 */
    }
    out[len] = '\0';
    return len;
}
if (len >= cap) return -1; 这行不是防御性编程,是安全底线。长度前缀来自网络,也就是完全由攻击者控制。少了这行,对方发一个长度 0xFFFFFFFF 过来,你就得到一个远程触发的缓冲区溢出或巨额内存分配——这是长度前缀协议最经典的漏洞形态,CVE 里成打地躺着。凡是「先读长度、再按长度读内容」的地方,紧跟着就要有上界检查。
readn/writen 这对函数是 Stevens《UNIX 网络编程》里的经典命名,几乎每个 C 网络项目都有一份自己的版本——写网络代码的第一件事就是先把它俩敲出来,后面所有收发都走它们,三条铁律就一次性解决了。注意 readn 是阻塞语义,配非阻塞 socket(本章后面的 epoll 部分)要改写成「读到 EAGAIN 就返回、把半条消息存进每连接的缓冲区」。

建立连接只有一种方式,关闭却有好几种,而且默认行为里藏着一个能让你的服务器无声无息退出的地雷。

close 与 shutdown 的区别

  • close(fd):释放这个 fd。只有当最后一个引用消失时连接才真正关闭——fork 之后父子都持有同一个连接,父进程 close 了不会断开,这是多进程服务器最常见的「连接不释放」原因。
  • shutdown(fd, SHUT_WR):半关闭——发一个 FIN 告诉对方「我说完了」,但仍然能继续读对方的数据。fd 本身不释放。
  • 什么时候需要半关闭:请求-响应型协议里,客户端发完请求要让服务端知道「问完了,你可以处理并回复了」,又不能真关掉否则收不到回复。SHUT_WR 正是为此存在。

SIGPIPE:默认行为是杀掉你的进程

  • 往一个对端已经关闭的连接上写,内核给你的进程发 SIGPIPE。这个信号的默认动作是终止进程——没有报错、没有日志,服务器就没了。
  • 对服务器来说这是灾难:任何一个客户端只要在你回复到一半时按下 Ctrl-C,你的整个服务就下线了。
  • 解法就一行:signal(SIGPIPE, SIG_IGN); 放在 main 开头。忽略之后,send 会规规矩矩地返回 -1 并置 errno == EPIPE,交给你的错误处理。

同一份代码,加不加一行的天壤之别

让客户端连上就立刻 close,服务端随后连写三次:

$ ./sigpipe_demo            # 默认行为
第 1 次 send 成功,返回 4
$ echo $?
141                          # 141 = 128 + 13,13 号信号就是 SIGPIPE

$ ./sigpipe_demo ignore      # 开头加了 signal(SIGPIPE, SIG_IGN)
第 1 次 send 成功,返回 4
第 2 次 send 失败: Broken pipe
第 3 次 send 失败: Broken pipe
$ echo $?
0
  • 注意第一次 send 是成功的:它只是把数据交给了本地内核的发送缓冲,此时本端还不知道对面已经没人了。数据发过去,对端因为已经 close(fd 彻底释放、再没人读)而回一个 RST,本端连接这才被标记为已断——所以要等 RST 回来之后的某一次 send 才报错。本地回环上稳定是第二次;跨真实网络时 RST 还在路上,可能连写好几次才发现。
  • 和 shutdown(SHUT_WR) 对照着看,这件事才算讲清楚:同一份代码,把客户端的 close 换成 shutdown(SHUT_WR),四次 send 全部成功——因为对方只是不再发了,它还在读。SIGPIPE 的触发条件不是「对端发了 FIN」,而是「对端已完全关闭、连读都不读了」。这正是开头 close 与 shutdown 之别的实际后果。
  • 这解释了一个常见困惑:为什么「对方明明已经断开,我还能成功写一次」。也说明写成功不等于对方收到了——真要确认,只能靠应用层的确认消息。
装一个自己的信号处理函数没有任何好处:SIGPIPE 是 send 同步产生的,处理函数返回后 send 照样返回 -1 且 errno == EPIPE,结果和 SIG_IGN 一模一样——却多了「改动全进程状态」「处理函数里几乎什么都不能做」两个代价。要么 SIG_IGN,要么用下面的 MSG_NOSIGNAL。多线程程序里也要注意:SIG_IGN 是整个进程范围的,设一次就够;而信号处理函数在哪个线程执行是不确定的(见 07 章信号卡)。
另有一个逐 send 生效的替代品:send(fd, buf, n, MSG_NOSIGNAL),只对这一次调用抑制信号——写库代码时比全局改进程状态更礼貌。
Linux 上还有 SO_KEEPALIVE:连接闲置约两小时后开始发探测包,能发现「对端机器直接断电」这种连 FIN 都发不出的死连接。但两小时的默认值对大多数服务毫无意义,且能不能调、怎么调都因系统而异——工程上更可靠的做法是在自己的协议里加心跳消息,超时由你自己定。

几乎每个人写第一个服务器时都会撞上:改完代码、Ctrl-C、重新运行,结果 bind: Address already in use。等一分钟又好了。这不是 bug,是 TCP 设计的必然结果。

不加 SO_REUSEADDR 会怎样

$ ./noreuse &                # 启动服务器
$ nc 127.0.0.1 9500 &        # 连上去,保持连接
$ pkill -x noreuse           # 杀掉服务器
$ ss -tan | grep 9500
TIME-WAIT 0 0 127.0.0.1:9500 127.0.0.1:48264   # 端口还挂着
$ ./noreuse                  # 立刻重启
bind: Address already in use

为什么会有 TIME_WAIT

  • 主动关闭连接的那一方(这里是被 kill 掉的服务器),在挥手结束后要在 TIME_WAIT 状态停留一段时间(典型 60 秒),期间这个「本地 IP+端口」组合不能重新绑定。
  • 它存在的两个理由:① 最后那个 ACK 可能丢失,得留着连接信息才能重发;② 防止旧连接的迟到数据包窜进新建的同四元组连接里,造成数据错乱。
  • 换句话说,TIME_WAIT 是为了正确性主动付出的代价,不是缺陷。计算机网络页有完整的状态机推导。

SO_REUSEADDR 做的事

  • 它告诉内核:就算有 TIME_WAIT 状态的旧连接占着这个端口,也允许我 bind。这正是每份服务器代码开头都有那三行 setsockopt 的原因。
  • 它不会让你抢占一个正在 LISTEN 的端口——那种情况依然报 Address already in use,所以不用担心两个服务互相踩。
  • 规矩:必须在 bind 之前调用,之后调用没有任何效果(也不报错,最坑)。
int yes = 1;
/* 设置套接字选项|传 fd+层级+选项名+值指针+值长度|成功返 0|失败返 -1 */
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
bind(srv, (struct sockaddr *)&addr, sizeof addr);   /* 顺序不能反 */

/* —— Linux 另有一个名字很像、作用完全不同的选项 —— */
/* SO_REUSEPORT: 允许多个进程 bind 同一个 IP+端口,内核在它们之间分发新连接 */
/* 用途是多进程服务器免锁负载均衡,不是用来解决重启 bind 失败的 */
setsockopt(srv, SOL_SOCKET, SO_REUSEPORT, &yes, sizeof yes);
服务端满屏 TIME_WAIT 是正常的,客户端满屏 TIME_WAIT 才是要查的问题。正常情况下先关闭的是客户端,TIME_WAIT 堆在客户端。如果你在服务端看到几万个 TIME_WAIT,说明是服务端在主动断开——常见于短连接服务或反向代理,端口耗尽后新连接就建不出来了。此时正确的解法是改用长连接(HTTP keep-alive)或连接池,而不是去调内核参数硬压。网上流传的 tcp_tw_recycle 尤其不要碰:它在 NAT 环境下会随机丢弃连接,Linux 4.12 起已经被彻底移除。
TIME_WAIT 的倒计时可以亲眼看:ss -tano 会在 timer: 列显示剩余秒数——刚被杀掉的服务器端口显示 timer:(timewait,59sec,0),一秒一秒往下走,归零后端口自动解禁。调试时不加 SO_REUSEADDR 也有笨办法:等它数完。但正经写服务器就把那三行 setsockopt 当固定开头——它只在有 TIME_WAIT 挡路时起作用,平时零成本。
扛并发

前面那个 echo 服务器有个致命缺陷:accept 之后的整个收发循环里,第二个客户端只能在队列里干等。让它同时服务多人,历史上依次出现过四种做法——它们不是「旧的被淘汰」,而是各有各的适用区间。

四代模型对照

模型做法能扛多少还在用吗
迭代式循环里一个个处理1只适合教学和纯计算的批处理
每连接一进程accept 后 fork几百还在——隔离性最好,一个连接崩了不影响别人。老式 CGI、部分 FTP 服务器
每连接一线程accept 后 pthread_create几千大量在用。每线程默认 8MB 栈是主要限制,代码最好写
线程池预建 N 条线程抢任务上万主流。避免了创建/销毁开销,还能限制并发上限防雪崩
事件循环单线程 epoll十万+见下两卡。Nginx、Redis 走的路

选型的真实依据

  • 连接少而每个连接干很重的活(比如每请求都要查数据库、算几百毫秒)→ 线程池。瓶颈在 CPU 和下游,省线程没意义。
  • 连接极多但每个都很闲(长连接推送、聊天、IoT)→ 事件循环。这类场景里线程模型会被内存和上下文切换活活压死,这就是所谓的 C10K 问题。
  • 需要强隔离(跑不可信代码、担心崩溃传染)→ 多进程。
  • 现实中的高性能服务器往往是组合:多进程各跑一个事件循环(Nginx),或事件循环 + 后台线程池处理阻塞操作。

最小复现:并发缺陷与两种改法

  • 先看缺陷:拿最初那个迭代版 echo,开两个 nc 同时连、都别断开——第二个敲什么都没回音,它卡在 accept 前的队列里,因为第一个连接的收发循环还没退出。
  • 每连接一线程:把 code 里那段套上去(accept 后 pthread_create、malloc 一份 fd 传进去、pthread_detach),编译加 -pthread。两个 nc 再连,这次各聊各的、互不阻塞。
  • fork 版:accept 后 fork,子进程 close(srv) 只管 conn、父进程 close(conn) 继续 accept。故意漏掉父进程那个 close(conn),用 ss 就能看到连接关不掉——正好印证 tip 里那条「close 只是减引用」。
/* —— 每连接一线程:改动很小,是最容易上手的并发化 —— */
static void *serve(void *arg) {
    int conn = *(int *)arg;
    free(arg);
    char buf[4096];
    ssize_t n;
    while ((n = recv(conn, buf, sizeof buf, 0)) > 0)
        send(conn, buf, n, 0);
    close(conn);
    return NULL;
}

for (;;) {
    int conn = accept(srv, NULL, NULL);
    if (conn < 0) continue;

    int *pc = malloc(sizeof *pc);   /* 关键:不能传 &conn */
    *pc = conn;

    pthread_t t;
    if (pthread_create(&t, NULL, serve, pc) != 0) { close(conn); free(pc); continue; }
    pthread_detach(t);                 /* 不 join,结束即自动回收 */
}
/* 编译要加 -pthread */
那个 malloc 不是多此一举。如果直接写 pthread_create(&t, NULL, serve, &conn),传的是栈上变量的地址——下一轮 accept 立刻把它改成新的 fd,新线程读到的可能是别人的连接。这是多线程服务器的经典竞态,症状是「偶尔把 A 的响应发给了 B」,极难复现。要么像上面这样每次 malloc 一份,要么在 fd 是 int 的前提下用 (void *)(intptr_t)conn 直接按值传。
另外别忘了 pthread_detach:不 join 又不 detach,线程结束后资源不回收,跑几万个连接就把内存耗尽了。
多进程版本的对应坑在 fd 上:fork 之后父子都持有 conn 和 srv 两个 fd,父进程必须 close(conn)、子进程必须 close(srv),否则引用计数不归零,连接永远不真正关闭。这正是第 07 卡「close 只是减引用」的直接后果。

线程模型的问题在于「一个连接一条执行流」。要让一条执行流盯住成千上万个连接,需要两样东西:非阻塞 I/O,和一个能一次问「这堆 fd 里哪些有动静」的机制。

先把 fd 改成非阻塞

  • 默认情况下 recv 没数据就睡——一条执行流就被这一个连接占死了。加上 O_NONBLOCK 后,没数据时它立刻返回 -1 并置 errno == EAGAIN(等同于 EWOULDBLOCK)。
  • 关键认知:EAGAIN 不是错误,是「现在没有,待会再来」。事件循环里必须把它和真错误分开处理,否则会把正常情况当异常关掉连接。
  • 但光有非阻塞不够:如果靠 while 循环挨个试探所有 fd,就成了忙轮询,CPU 直接 100%。所以必须配一个「阻塞地等待任意一个 fd 就绪」的调用——这就是 select/epoll 的职责。

先给这类机制起个名字:I/O 多路复用

  • 「用一条执行流同时盯住一堆 fd」这件事有个通用名字——I/O 多路复用(I/O multiplexing)。select、poll、epoll 是它在 Linux 上的三代实现,BSD/macOS 那边叫 kqueue;Windows 也有 select,但那边的高性能服务器走的是 IOCP,属于另一套模型(见下条)。记住这个名字:查资料、读别人的代码、和人讨论架构时用的都是它,而不是「select 那一套」。
  • 它仍然是同步的——这是最容易误会的一点。多路复用只负责回答「哪些 fd 现在可以读了」,真正的 recv 还是你自己调、字节还是你自己搬。所以它属于「同步 + 非阻塞」,和真异步(io_uring、IOCP:内核连搬运都替你做完再通知你)完全不是一回事。五种 I/O 模型的完整分类见操作系统页的 I/O 章,本章只管怎么用。

select 的三个硬伤

  • fd 数量有硬上限。fd_set 是个定长位图,大小由 FD_SETSIZE 决定,glibc 上固定为 1024(sizeof(fd_set) 正好 128 字节)。而进程能打开多少 fd 由 ulimit -n 决定,各发行版差别很大——本机是 10240,你自己跑一下看。两个数一对比就看出问题:能开的 fd 数往往远超 select 能监视的数。fd 值 ≥ 1024 时 FD_SET 会越界写内存:现代发行版默认开的 fortify 检查通常会让程序当场 abort 并打印 *** bit out of range 0 - FD_SETSIZE on fd_set ***;而一旦检查被关掉(或用老工具链),就是静默踩坏栈——两种下场都是灾难。
  • 每次调用都要重传全集。select 会改写传入的 fd_set(用来返回结果),所以每轮循环都得重新拷贝一份,内核也得把整个集合从用户态拷进来再线性扫一遍——O(n),n 是 fd 总数而不是就绪数。
  • 返回结果不告诉你是谁。它只说「有 k 个就绪」,你得从 0 到 maxfd 挨个 FD_ISSET 试探。一万个连接里只有一个有数据,你也得问一万次。
#include <fcntl.h>
#include <sys/select.h>

static void set_nonblock(int fd) {
    int fl = fcntl(fd, F_GETFL, 0);   /* 先取原标志,别直接覆盖 */
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

/* 注意:下面这个骨架用的是【阻塞】fd —— select 已保证「就绪才读」,单线程下不会卡住。 */
/* 想换成非阻塞 fd(本章后面的 epoll 必须这么做),recv 的错误分支就得先把 EAGAIN 分流出去, */
/* 否则会把「暂时没数据」当成出错,把好好的连接关掉。 */

fd_set all;
FD_ZERO(&all);
FD_SET(srv, &all);
int maxfd = srv;

for (;;) {
    fd_set ready = all;                  /* 必须每轮重拷:select 会改写它 */
    /* 等任一 fd 就绪|传 最大fd+1+读集+写集+异常集+超时(NULL=永久)|返就绪个数|超时返 0|失败返 -1 */
    if (select(maxfd + 1, &ready, NULL, NULL, NULL) < 0) {
        if (errno == EINTR) continue;
        perror("select"); break;
    }

    for (int fd = 0; fd <= maxfd; fd++) {   /* 只能挨个试探 —— 硬伤三 */
        if (!FD_ISSET(fd, &ready)) continue;

        if (fd == srv) {
            int conn = accept(srv, NULL, NULL);
            if (conn < 0) continue;
            if (conn >= FD_SETSIZE) { close(conn); continue; }  /* 硬伤一:装不下只能拒 */
            FD_SET(conn, &all);
            if (conn > maxfd) maxfd = conn;
        } else {
            char buf[4096];
            ssize_t r = recv(fd, buf, sizeof buf, 0);
            if (r > 0) send(fd, buf, r, 0);
            else if (r < 0 && errno == EINTR) continue;   /* 铁律② */
            else { FD_CLR(fd, &all); close(fd); }   /* r==0 对端关闭,r<0 真出错 */
        }
    }
}
FD_SETSIZE 不要试图靠 #define 改大——glibc 里它和 fd_set 的类型定义绑死,改了行为未定义。需要监视超过 1024 个 fd 就该换 epoll,这不是调优问题而是选型问题。
poll 是个中间产物:用数组代替位图,去掉了数量上限,但每次仍要传全集、仍是 O(n) 扫描。它的真正价值是可移植(POSIX 标准,各家 Unix 都有),而 epoll 只有 Linux 有。
select 至今没绝迹,因为它有两个 epoll 比不了的优点:可移植(连 Windows 都有),以及超时参数直接支持微秒精度。所以「只需要等两三个 fd,还要带个超时」这类小场景(比如给 connect 加超时),用 select 反而比 epoll 干净。

epoll 把 select 的三个硬伤一次全解决了,代价是只有 Linux 有。它是 Nginx、Redis、以及几乎所有现代高并发服务器的地基。

先说清楚 epoll 是个什么东西

  • 它不是一个函数,是内核里的一个对象。这个对象里装着两样东西:一张关注表(你登记过哪些 fd、各自关心什么事件),和一条就绪队列(这些 fd 里此刻真的有动静的那几个)。
  • 数据到达时,内核把它放进 socket 接收缓冲区的同时,顺手把这个 fd 挂到就绪队列上。所以 epoll_wait 根本不用扫描——它只是去就绪队列里取,队列空就睡着等。select 是每次都把一万个 fd 问一遍,epoll 是内核平时就替你记好账,这才是两者的根本差别,不只是接口顺手一点。三个系统调用刚好对应三件事:建表、改表、取就绪。
  • 这个内核对象是用 fd 来引用的,所以 epoll_create1 返回的是个 int。它没有对应的磁盘文件,是个匿名 inode——readlink("/proc/self/fd/3") 得到 anon_inode:[eventpoll]。但凡是 fd 的规矩它都守:close 回收、fork 被继承、用光了一样受 ulimit -n 限制。
  • 最后补一个名字:「epoll_wait 拿到一批就绪 fd → 逐个分发给对应的处理逻辑 → 回到 epoll_wait 接着等」,这个死循环叫事件循环,这个设计模式的正式名字是 Reactor(反应堆)。下面的骨架就是它最小的样子;libevent、libuv(Node.js 的底座)、muduo 干的是同一件事,只是额外替你把每连接的缓冲区和回调管起来了。

它凭什么快

  • fd 集合常驻内核:epoll_ctl 注册一次就行,不用每轮重传。省掉了 select 每次的用户态↔内核态大块拷贝。
  • 只返回就绪的:epoll_wait 填给你一个「有事的 fd」数组,复杂度是 O(就绪数) 而不是 O(总数)。一万个连接里一个活跃,就只处理一个。
  • 没有写死的数量上限:受 ulimit -n(以及 /proc/sys/fs/epoll/max_user_watches)约束,而不是某个编译期宏。

三个系统调用

  • epoll_create1(0) —— 创建一个 epoll 实例,返回 fd(是的,epoll 实例本身也是个 fd)。
  • epoll_ctl(ep, ADD/MOD/DEL, fd, &ev) —— 增删改要监视的 fd 和关心的事件。
  • epoll_wait(ep, events, n, timeout) —— 阻塞到有 fd 就绪,返回个数,结果填在 events 数组里。

struct epoll_event:代码里那两个字段是什么

登记时传进去、就绪时取回来,用的是同一个结构体。它只有两个字段,但两个都藏着坑。

  • events 是位掩码而不是单个值:EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLRDHUP(对端半关闭)用 | 组合着填。
  • EPOLLERR 和 EPOLLHUP 不用注册,内核照样报给你。只注册 EPOLLIN,对端 close 之后 epoll_wait 返回的是 EPOLLIN|EPOLLHUP;只注册 EPOLLOUT,管道读端全关之后返回的是 EPOLLOUT|EPOLLERR。所以每次拿到事件都得先查这两位,否则一个已经出错的 fd 会被你当成「可读」,然后一路 recv 下去。反过来 EPOLLRDHUP 则是不注册就永远收不到(对端 shutdown(SHUT_WR) 之后,没注册时只报 EPOLLIN,注册了才多出 EPOLLRDHUP)。
  • data 是个 union(fd / ptr / u32 / u64 四选一),内核一个字节都不解释,你填什么就原样还给你——它存在的唯一目的,是让你在事件返回时认出这是谁。下面的骨架填的是 .data.fd,图省事;真实项目几乎都填 .data.ptr,指向自己的连接对象(里面有 fd、读写缓冲区、协议状态机),这样 epoll_wait 一返回就直接拿到整个连接,不必再查表。塞进去的指针原样返回,可以直接解引用。既然是 union 就只能填一个:填了 ptr,fd 就得自己存在那个结构体里。

LT 与 ET:epoll 唯一需要慎重的选择

  • 水平触发 LT(默认):只要缓冲区里还有数据就一直通知
    优好写、容错高——这次没读完,下次 epoll_wait 还会提醒你。行为和 select/poll 一致,改造成本低
    短同一批数据可能触发多次唤醒,极限性能略低
  • 边沿触发 ET(EPOLLET):只在「从没有到有」的状态变化瞬间通知一次
    优唤醒次数最少,高负载下开销更小
    短必须一次把数据读到 EAGAIN 为止,否则剩下的数据再也不会通知你——连接就这么静悄悄地挂起了。而且 ET 下 fd 必须是非阻塞的,否则最后那次读会把整个事件循环阻塞死
    为何LT 报告的是「状态」,ET 报告的是「事件」。事件只发生一次,错过就没了。

建议:先用 LT。下面的骨架就是 LT 的。等你真的测出唤醒开销是瓶颈了再换 ET,届时 readn 那套阻塞写法也要一并重写。

#include <sys/epoll.h>

set_nonblock(srv);
int ep = epoll_create1(0);          /* 成功返 fd|失败返 -1 */

struct epoll_event ev = { .events = EPOLLIN, .data.fd = srv };
epoll_ctl(ep, EPOLL_CTL_ADD, srv, &ev);  /* 把监听 fd 加进去 */

struct epoll_event events[64];
for (;;) {
    /* 等就绪|传 epfd+结果数组+数组容量+超时ms(-1=永久)|返就绪个数|失败返 -1 */
    int n = epoll_wait(ep, events, 64, -1);
    if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; }

    for (int i = 0; i < n; i++) {      /* 只遍历就绪的 —— 和 select 的本质差别 */
        int fd = events[i].data.fd;

        if (fd == srv) {
            for (;;) {                   /* 循环 accept:一次唤醒可能来了多个连接 */
                int conn = accept(srv, NULL, NULL);
                if (conn < 0) break;      /* EAGAIN:队列空了 */
                set_nonblock(conn);
                struct epoll_event ce = { .events = EPOLLIN, .data.fd = conn };
                epoll_ctl(ep, EPOLL_CTL_ADD, conn, &ce);
            }
        } else {
            char buf[4096];
            ssize_t r = recv(fd, buf, sizeof buf, 0);
            if (r > 0) {
                send(fd, buf, r, 0);    /* 生产代码这里要处理发不完的情况,见 pitfall */
            } else {
                epoll_ctl(ep, EPOLL_CTL_DEL, fd, NULL);
                close(fd);
            }
        }
    }
}
这个骨架里的 send 是简化的,生产代码不能这么写。非阻塞 fd 上 send 随时可能只发出一部分甚至返回 EAGAIN(对方读得慢、发送缓冲满了)。而事件循环里绝对不能原地循环重试——那会把整条执行流卡死,所有其他连接跟着饿死。正确做法是:给每个连接配一个待发缓冲区,发不完的存起来,然后 epoll_ctl 改成同时监听 EPOLLOUT,等可写事件来了再续发,发完再把 EPOLLOUT 摘掉。这套「每连接读写缓冲 + 状态机」才是 Reactor 的真正主体,也是所有网络库(libevent、libuv、muduo)替你做掉的事。
还有一条纪律:顺序必须是先 EPOLL_CTL_DEL 再 close,反过来做就晚了——fd 关掉之后再 DEL 会直接返回 EBADF。还有一条:epoll_ctl 的三个操作别用混——同一个 fd 重复 EPOLL_CTL_ADD 会失败并置 EEXIST,要改它关心的事件(比如临时加上 EPOLLOUT)必须用 EPOLL_CTL_MOD。
常听人说「close 会自动摘掉」,这话只在该 fd 是这个连接的最后一个引用时成立:epoll 记的是内核里的打开文件对象而不是 fd 号,一旦这个连接被 dup 过或经历过 fork,关掉一个引用不会摘除,epoll 会继续报告一个你已经不认识、而且随时可能被别人复用的 fd 号。
想验证它真的在并发:起服务器后开三个终端同时 nc 127.0.0.1 9200,三个都能独立收发——而本章第一个 echo 服务器在这里只有第一个终端有反应。单线程、无锁、三个连接同时活着,这就是事件循环的全部魅力。
epoll 在内核里怎么实现(红黑树 + 就绪链表 + 回调)、以及它和 io_uring 的代际差异,见操作系统页的 I/O 章。
上层与排错

把 SOCK_STREAM 换成 SOCK_DGRAM,整套 API 就换了一种气质:没有 listen、没有 accept、没有连接的概念,每次收发都自带对方地址。

三个差别,逐条对照

  • 无连接:不握手。服务端 bind 完直接 recvfrom,客户端 sendto 就能发。省掉一个 RTT 的建连延迟。
  • 有边界:一次 sendto 对应一次 recvfrom,不会粘、不会拆——第 04、05 卡那个折磨人的分帧问题在 UDP 上根本不存在。把「连发 AAA、BBB」改成 UDP:
    第 1 次 recvfrom 拿到 3 字节 "AAA",来自 127.0.0.1:46612
    第 2 次 recvfrom 拿到 3 字节 "BBB",来自 127.0.0.1:46612
    对照 TCP 的 "AAABBBCCC",差别一目了然。
  • 不可靠:可能丢、可能乱序、可能重复,而且你完全不会被告知。sendto 返回成功只意味着「交给内核了」。

什么时候真的该用 UDP

  • 丢一点无所谓、迟到不如不到:实时音视频、游戏状态同步。重传一个 200 毫秒前的画面帧毫无价值,TCP 的「死也要送到」反而有害。
  • 一问一答的极短交互:DNS 查询。一个包问、一个包答,建连的开销比数据本身还大。
  • 一对多:广播和组播,TCP 根本做不到。
  • 反过来说:只要你开始给 UDP 加序号、加确认、加重传,就该停下来想想是不是该用 TCP——除非你在做 QUIC 那种量级的事情。

最小复现:UDP 版 echo 与静默截断

  • SOCK_STREAM 改 SOCK_DGRAM,删掉 listen / accept,收发换成 recvfrom / sendto(把 recvfrom 拿到的 peer 原样回给 sendto)。测试端用 nc -u 127.0.0.1 9300。
  • 验「有边界」:连发三个小包,服务端每次 recvfrom 只拿到一个完整包——对照 TCP echo 那边的粘连,分帧问题在这里凭空消失。
  • 验「静默截断」:把接收缓冲开成 char buf[4],发一个 10 字节包过去——多出来的 6 字节直接丢弃,recvfrom 不报错也不提示。这条与 TCP「下次接着读」的直觉正相反。
int s = socket(AF_INET, SOCK_DGRAM, 0);   /* 唯一的改动就在这 */

struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_ANY);
a.sin_port   = htons(9300);
bind(s, (struct sockaddr *)&a, sizeof a);
/* 没有 listen,没有 accept */

char buf[65536];      /* 按 UDP 上限开,宁大勿小——小了会静默截断 */
struct sockaddr_in peer;
socklen_t plen = sizeof peer;
/* 收一个数据报|传 fd+缓冲+容量+标志+对端地址出参+长度出入参|返字节数|失败返 -1 */
ssize_t n = recvfrom(s, buf, sizeof buf, 0, (struct sockaddr *)&peer, &plen);

/* 原样回给它|传 fd+数据+长度+标志+目标地址+地址长度|返字节数|失败返 -1 */
sendto(s, buf, n, 0, (struct sockaddr *)&peer, plen);
接收缓冲区小于数据报会静默截断:多出来的字节直接丢弃,recvfrom 不会报错也不会告诉你还剩多少——和 TCP「下次接着读」的直觉完全相反。所以缓冲区一律按最大可能的数据报开,稳妥做法是直接取 65507(UDP 载荷的理论上限,发 65507 成功、65508 起一律 EMSGSIZE)。别拿 1500 当上限——那是链路层 MTU,而且以太网上不分片的最大 UDP 载荷其实是 1472(1500 减去 20 字节 IP 头和 8 字节 UDP 头)。
另一条:别发大数据报。超过 MTU 的会在 IP 层分片,而任何一个分片丢了整个数据报就废了——发 8KB 相当于把丢包率放大了六倍。实践中把应用数据控制在 1200 字节以内最稳妥(给隧道封装留余量)。
UDP 也可以 connect!它不发任何包,只是在内核里记住默认对端,之后就能用 send/recv 而不必每次带地址。好处有两个:少写参数,以及能收到 ICMP 错误——不 connect 的 UDP socket 收不到「端口不可达」这类反馈,问题会变得无从查起。

服务端讲完了,客户端看着更简单——socket + connect 两步。但真实世界的客户端有三件事必须做对,否则在别人的网络里一定出问题。

① 用 getaddrinfo,不要手工填地址

  • 它一次解决四件事:域名解析(DNS)、服务名转端口("http" → 80)、IPv4/IPv6 通吃、以及返回多个候选地址让你逐个尝试。
  • 一个域名解析出多个 IP 是常态(负载均衡、CDN、双栈)。只试第一个就放弃是典型的业余写法——正确做法是遍历整个链表,直到某一个连上。
  • 用完必须 freeaddrinfo。错误码不走 errno,要用 gai_strerror 翻译。

② connect 的超时不能靠默认

  • 阻塞式 connect 在对方不回应时,会按内核的 SYN 重传策略死等。Linux 上这个次数由 net.ipv4.tcp_syn_retries 决定,默认 6——初始 1 秒起指数退避,SYN 在第 1/3/7/15/31/63 秒重传,最后一次等满后到第 127 秒才放弃返回 ETIMEDOUT。对交互式程序,两分多钟的卡死等同于死机。(cat /proc/sys/net/ipv4/tcp_syn_retries 可以看你机器上的值,这也解释了为什么不同人测出来的秒数不一样。)
  • 加超时的标准做法:把 fd 设成非阻塞 → connect 立刻返回 -1 且 errno == EINPROGRESS → 用 select/poll 等它可写并带上你自己的超时 → 再用 getsockopt(SO_ERROR) 取真正的结果(可写不代表成功,也可能是失败了)。

③ 重试要退避,而且要有上限

  • 失败立刻重连、再失败再立刻重连,等于对一个已经出问题的服务发起 DDoS——服务恢复的瞬间被全部客户端同时压垮,这就是惊群式重连。
  • 标准做法是指数退避加随机抖动:等待 1s、2s、4s、8s…… 每次再乘一个 0.5~1.5 的随机因子,把大家的重试时刻打散。

最小复现:手写客户端与连接超时

  • 用 code 里 getaddrinfo 的骨架连 "127.0.0.1" / "9000",连上后从 stdin fgets 一行 → send → recv 打印——这就是一个最小版的 nc。
  • 把目标换成真实域名 "example.com" / "80",发一行 GET / HTTP/1.0\r\n\r\n,读回真正的 HTTP 响应——同一份客户端代码同时通吃域名解析与 IPv4 / IPv6。
  • 验超时:连一个不回应的地址(如 10.255.255.1:80),默认阻塞的 connect 会卡很久才 ETIMEDOUT——这就是客户端必须自己接管超时的原因(做法见上面第 ② 段)。
#include <netdb.h>

struct addrinfo hints = {0}, *list, *p;
hints.ai_family   = AF_UNSPEC;      /* IPv4 或 IPv6 都接受 */
hints.ai_socktype = SOCK_STREAM;

/* 解析主机+服务|传 主机名+端口或服务名+提示+结果出参|成功返 0|失败返错误码(非 errno) */
int rc = getaddrinfo("example.com", "80", &hints, &list);
if (rc != 0) { fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc)); return 1; }

int fd = -1;
for (p = list; p; p = p->ai_next) {          /* 逐个候选试,别只试第一个 */
    fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
    if (fd < 0) continue;
    if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break;  /* 连上了 */
    close(fd);
    fd = -1;
}
freeaddrinfo(list);                        /* 别忘了释放 */
if (fd < 0) { fprintf(stderr, "全部候选地址都连不上\n"); return 1; }
客户端不要随手加 SO_REUSEADDR 或自己 bind 端口。让内核分配临时端口是对的——手工绑定会在高频短连接时迅速耗尽端口,还可能撞上别的程序。
另一个高频错误:把域名解析放在热路径上。getaddrinfo 是阻塞调用而且可能走网络(DNS),在事件循环里直接调它会把整个循环卡住几十毫秒到几秒。事件驱动程序要么用专门的异步 DNS 库,要么把解析扔到后台线程。
这段代码连 example.com,能看到它先打印候选地址再连接:
试 198.18.3.151 (IPv4)... 连上了
响应首行: HTTP/1.1 200 OK

(你跑出来的 IP 多半和这个不一样——上面这个地址来自本机网络环境的 DNS 拦截;换个网络、或者域名本身有多个 A 记录,输出都会变。这恰恰是「别把第一个候选地址当成唯一答案」的现实理由。)

把主机名换成一个纯 IPv6 的域名,同一份代码不用改一个字符也能连上——这就是用 getaddrinfo 而不是 inet_pton 的实际回报。

用浏览器打开一个自己用 C 写的服务器,需要的全部材料前面几卡已经齐了。HTTP 之所以适合当第一个真实协议,是因为它是纯文本的——你能直接看懂每一个字节。

协议长什么样

请求:                        响应:
GET /hello HTTP/1.1          HTTP/1.1 200 OK
Host: localhost:8080         Content-Type: text/html; charset=utf-8
User-Agent: curl/8.5.0       Content-Length: 50
(空行)                        Connection: close
                             (空行)
                             <h1>hello from C</h1>...
  • 第一行是请求行(方法 路径 版本),之后每行一个头部,空行表示头部结束,再往后是正文。
  • 行分隔符是 \r\n 不是 \n——这是最常见的手写 HTTP 错误,写成 \n 时 curl 可能容忍,浏览器直接白屏。
  • 注意这里两种分帧方式同时出现:头部用分隔符(空行)切,正文用 Content-Length 这个长度前缀切。正是分帧那一卡讲的「各取所长」。
signal(SIGPIPE, SIG_IGN);      /* 必须!浏览器随时会中断连接 */

for (;;) {
    int c = accept(srv, NULL, NULL);
    if (c < 0) continue;

    char req[8192];
    ssize_t n = recv(c, req, sizeof req - 1, 0);
    if (n <= 0) { close(c); continue; }
    req[n] = '\0';

    char method[16] = "", path[1024] = "";
    sscanf(req, "%15s %1023s", method, path);   /* 宽度限制防溢出 */

    char body[2048];
    int blen = snprintf(body, sizeof body,
        "<h1>hello from C</h1><p>method=%s path=%s</p>", method, path);
    if (blen < 0 || (size_t)blen >= sizeof body) { close(c); continue; }  /* 截断了就别发,见 pitfall */

    char resp[4096];
    int rlen = snprintf(resp, sizeof resp,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html; charset=utf-8\r\n"
        "Content-Length: %d\r\n"       /* 必须和正文长度一致 */
        "Connection: close\r\n"
        "\r\n%s", blen, body);
    if (rlen < 0 || (size_t)rlen >= sizeof resp) { close(c); continue; }

    ssize_t off = 0;
    while (off < rlen) {                       /* 老规矩:循环写完 */
        ssize_t w = send(c, resp + off, rlen - off, 0);
        if (w < 0) break;
        off += w;
    }
    close(c);
}
这份代码是教学玩具,绝不能对外网开放,三个致命问题:
① 假设一次 recv 能收全请求头——正是第 05 卡讲的错误。真实实现必须循环读到出现 \r\n\r\n 为止,还要给「一直不发完」的连接设超时,否则一个 Slowloris 攻击就能耗光你的连接。
② 把 path 原样拼进 HTML——反射型 XSS。任何进入输出的外部输入都必须转义。
③ 如果扩展成读文件返回,必须过滤 ..,否则 GET /../../etc/passwd 就是一个目录穿越漏洞。
这三条不是吓唬人:它们分别对应现实中最常见的 DoS、XSS 和路径穿越,每一类都在 CVE 库里有成千上万条记录。
还有一条 C 语言自身的坑值得单独记住:snprintf 返回的是「本该写入」的长度,不是「实际写入」的长度。缓冲区不够时它返回的数会大于缓冲区容量——直接拿去当长度用,就会让 send 读越界,还会让 Content-Length 声称一个比实际发出去更大的值,客户端就挂在那儿等永远不会到的字节。上面代码里那两行 >= sizeof 检查就是为此。
验证方式(三种都试一遍):
curl -sSi http://127.0.0.1:8080/hello   # 看完整响应,含头部
浏览器打开 http://localhost:8080/hello   # 看渲染结果
nc 127.0.0.1 8080                        # 手敲请求,感受纯文本协议
curl 的输出:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 50
Connection: close

<h1>hello from C</h1><p>method=GET path=/hello</p>
用 nc 手敲时记得请求行之后要多敲一个回车(那个空行)——没有它服务器会一直等你把头部发完。

网络程序出问题时,光看代码常常看不出来——因为问题可能根本不在你的进程里。这几个命令能让你分清「我的锅、内核的锅、还是对面的锅」。

三个必会命令

  • ss(取代老旧的 netstat)—— 看连接状态。ss -tanp 列出所有 TCP 连接、状态和占用的进程。排查的第一步永远是它:端口到底有没有在监听?连接建起来了吗?卡在哪个状态?
  • tcpdump —— 抓包。sudo tcpdump -i any -nn port 9000 -X 把这个端口的收发原样打出来。它是唯一的客观仲裁者:包到底出没出去、对方到底回没回。
  • strace —— 跟系统调用。strace -f -e trace=network ./server 显示每一次 socket 相关调用的参数和返回值。程序卡住时最有用:直接告诉你它阻塞在哪个调用上。

症状对号入座

症状先查常见原因
Connection refusedss -tanp | grep 端口服务没起、绑错网卡(绑了 127.0.0.1 却从外部连)、端口写错
连接一直卡着不返回tcpdump 看 SYN 有没有回应防火墙丢弃(不是拒绝)、IP 不可达、对方过载
连上了但收不到数据strace 看阻塞在哪分帧写错互相等对方、忘了发结束标记、双方都在 recv
服务器莫名退出退出码是不是 141SIGPIPE(见关闭那卡)
连接数上不去ulimit -n 和 ss -sfd 上限、backlog 太小、TIME_WAIT 堆积
ss 里显示 ESTAB 不等于连接还活着。空闲的 TCP 连接双方一个包都不发:对端断电、拔线、中间 NAT 表项过期,你这边看到的仍是 ESTAB,而且会一直是——要等你下次写入并耗完漫长的重传超时(或开了 SO_KEEPALIVE 才有的两小时后探测——它默认是关的)才会发现。所以别拿 ESTAB 数量当服务健康指标,判断连接是否存活要靠应用层心跳(见关闭那卡)。
一个把「玄学」变成「可观测」的习惯:每写一个网络程序,都在旁边开一个终端跑 watch -n1 'ss -tan | grep 你的端口'。连接从 SYN-SENT → ESTAB → FIN-WAIT → TIME-WAIT 的整个流转在你眼前实时演出,本章讲的每一个概念都会变成看得见的东西。

数据结构

十种数据结构的选型与实现:复杂度对照、一句话判据,以及每种结构的 C 实现要点与真实用途。

选结构只问三件事:要不要顺序、查得多还是改得多、键是什么形状。下表按这三条排。

复杂度对照

结构查找插入 / 删除空间有序
定长数组按下标 O(1),按值 O(n)—O(n)插入序
动态数组同上末尾 O(1) 均摊;中间 O(n)O(n)插入序
单 / 双向链表O(n)已持有节点时 O(1)O(n) + 每节点一个指针插入序
栈 / 队列只看两端O(1)O(n)LIFO / FIFO
BST(不平衡)平均 O(log n),最坏 O(n)同左O(n)中序即有序
平衡树(AVL / 红黑)O(log n) 保证O(log n)O(n)是
二叉堆取最值 O(1)插入 / 弹出 O(log n);建堆 O(n)O(n)(数组实现)只保证堆顶
哈希表平均 O(1),最坏 O(n)平均 O(1)O(n),有空槽浪费无序
字典树O(词长),与词数无关O(词长)大(每节点一个子表)按字典序
图:邻接表遍历邻居 O(度)O(1) 加边O(V + E)—
图:邻接矩阵查两点是否相邻 O(1)O(1)O(V²)—

一句话选型

需求选
不确定用什么动态数组——连续内存对缓存友好,实际速度经常打败「理论更优」的结构
按名字 / ID 查哈希表
要按顺序遍历,还要能查范围平衡树(或排好序的数组 + 二分,只读场景后者更快)
反复取最大 / 最小堆
只在两端进出栈 / 队列(数组实现的循环队列)
前缀匹配、自动补全字典树
频繁在中间插删且已持有位置链表——注意「已持有位置」这个前提,否则找过去就是 O(n)
图:稀疏 / 稠密邻接表 / 邻接矩阵(E 接近 V² 才考虑矩阵)

实现要点

结构关键
动态数组容量满了按倍数扩容(×2 或 ×1.5),才有 O(1) 均摊;realloc 要用临时变量接(03 章)
链表用二级指针 Node ** 能把「删头节点」的特判消掉;反转记住三指针 prev / cur / next
循环队列用 head、tail 加取模;留一个空位或单独存计数,才能区分「空」和「满」
BST删除节点分三种情况:无子、单子、双子(用中序后继顶替)
平衡树AVL 严格平衡、查得更快;红黑树旋转更少、写多时更划算。面试常问,工程里通常用现成库
堆数组下标:父 (i-1)/2、左子 2i+1、右子 2i+2;上浮 / 下沉两个函数就够
哈希表三件事:哈希函数(djb2 之类够用)、冲突处理(链地址最好写)、装载因子超过约 0.75 就扩容并重新散列
字典树每个节点一个子指针数组 + 一个「是不是词尾」标记;内存是它的主要代价
图的遍历BFS 用队列(无权图的最短边数),DFS 用递归或显式栈;两者都要 visited 数组,否则有环就死循环(10 章)
大 O 相同不代表实际速度相同。链表和动态数组的遍历都是 O(n),但数组连续、能被缓存和预取利用,链表每一步都可能是一次缓存未命中——在几万个元素的规模上,差距常常是数倍。所以「中间插删多」这个理由要成立,前提是你已经持有那个节点;如果每次都要先找过去,链表在两头都输。
自己实现一遍的价值不在「以后要用」,而在于把复杂度从背诵变成直觉:亲手写过扩容,就再也不会问「为什么 append 是 O(1)」;亲手处理过哈希冲突,就明白为什么它最坏是 O(n)。写完记得用 -fsanitize=address 跑一遍(11 章)——指针类结构最容易漏掉边界。

选数据结构,本质是问一句:我最频繁的操作是哪个,哪种结构在这个操作上最快?下表是常用结构的「操作代价」速查,后面每张卡再逐个展开。(下面的 O(…) 记号含义见 10 章的「大 O 与复杂度分析」。)

常用结构一览

结构查找插入/删除有序典型用途
动态数组下标 O(1) / 按值 O(n)尾部均摊 O(1),中间 O(n)否下标访问、遍历、当栈用
链表O(n)持有节点 O(1)否频繁头尾增删、LRU
哈希表平均 O(1)平均 O(1)否去重、计数、按 key 查
平衡树O(log n)O(log n)是有序 + 范围查询
堆取最值 O(1)O(log n)部分优先队列、Top-K、调度
字典树O(词长)O(词长)前缀前缀匹配、自动补全

怎么选

  • 要按下标随机访问、顺序遍历 → 动态数组。
  • 只按 key 存取、要最快、不在乎顺序 → 哈希表。
  • 要 key 有序、还要范围查询(把大于 x 的都拿出来)→ 平衡树(std::map)。
  • 反复取最大 / 最小、做 Top-K、优先级调度 → 堆。
  • 大量字符串前缀匹配、自动补全 → 字典树。
  • 建模关系 / 网络 / 依赖 → 图。
复杂度表只数「操作次数」,不含常数,更不含内存访问模式——照表选型是最常见的出错方式。拿 1000 万个整数求和跑一下就知道:连续数组,和「用 malloc 逐个分配、next 指向被打乱」的链表,同是 O(n),耗时能差两三个数量级(具体倍数看你的 CPU 和缓存,自己跑一下)。表里两者都写「遍历 O(n)」,可差距全在缓存上。所以这张表只用来排除量级明显不对的选项(别拿 O(n) 的链表当随机访问用);量级相当时一律先默认连续内存的动态数组,等真测出瓶颈再换。
没有「最好」的数据结构,只有「最适合当前操作组合」的。同一份数据,若读多写少、要有序就用平衡树;若只按 key 存取就用哈希表——权衡的正是不同操作的复杂度。

最常用的线性表:一块连续内存顺序存元素,像一排编了号的座位——报座位号能瞬间找到人(下标 O(1)),但想在中间加座位,后面所有人都得挪位子(O(n))。C++ vector、Python list、Java ArrayList 都是它。

快在哪、慢在哪

  • 随机访问 O(1):元素连续排列,地址 = 首地址 + 下标 × 元素大小,一步算出。
  • 缓存友好:连续内存契合 CPU 缓存行,遍历比链表快得多。
  • 中间插入 / 删除 O(n):要用 memmove 把后面的元素整体搬移。
  • 自动扩容:容量满了就申请一块两倍大的新内存、拷贝过去(realloc)。

为什么是「翻倍」扩容

  • 若每次只 +1,追加 n 个元素总搬移次数是 1+2+…+n ≈ O(n²)。
  • 翻倍后,n 次追加的总拷贝量约为 2n,均摊到每次就是 O(1)——这就是「均摊分析」。
typedef struct {
    int   *data;      /* 指向堆上的连续内存 */
    size_t size;      /* 已用元素个数 */
    size_t capacity;  /* 当前最多能装多少 */
} Vec;

/* 尾部追加:容量满了「先翻倍再写入」,均摊 O(1) */
void vec_push(Vec *v, int x) {
    if (v->size == v->capacity) {  /* 装满了,得先扩容 */
        size_t newcap = v->capacity ? v->capacity * 2 : 4; /* 空的给 4 格,否则翻倍 */
        int *t = realloc(v->data, newcap * sizeof(int)); /* 搬家到更大的内存块 */
        if (!t) return;          /* 扩容失败:原内存仍然有效 */
        v->data = t;               /* 成功了才让 data 改指新块 */
        v->capacity = newcap;      /* 成功后才更新容量 */
    }
    v->data[v->size++] = x;        /* 写到尾部,size 顺手加一 */
}

/* 按下标访问 O(1);中间插入/删除要 memmove 搬移,O(n) */
int vec_get(Vec *v, size_t i) { return v->data[i]; }
realloc 的返回值必须用临时变量接收。若写成 v->data = realloc(v->data, ...),一旦失败返回 NULL,就把原来的指针冲掉了——原内存块既没释放也找不回,直接内存泄漏。
「翻倍扩容」的收益比你想的更隐蔽。用 realloc 逐个追加时,「每次 +1」只比「翻倍」慢一个小常数倍——因为 glibc 的 realloc 常能原地扩展而不真拷贝,把 O(n²) 悄悄兜住了。但一旦换成 malloc + memcpy + free(跨语言、换分配器时的常态),真面目立刻现形:差距拉开到三个数量级,而且n 翻一倍、耗时翻四倍——这个「翻四倍」才是 O(n²) 的签名,比任何绝对秒数都可靠,也是你自己该去验的那个量。别把正确性押在分配器的善意上,倍增策略自己写好。

用指针把一个个节点串起来,像寻宝游戏——每张纸条写着「下一张藏在哪」。节点在内存里四散分布,不像数组连续。想加塞一个节点,只需改两根指针(O(1));但想找第 k 个,只能从头一个个数(O(n))。

三种形态

  • 单链表:只有 next,只能单向走。
  • 双向链表:加一个 prev,可前后走,还能拿到节点就 O(1) 删它(不必先找前驱)。
  • 循环链表:尾节点 next 指回头,适合轮询调度。

数组 vs 链表

操作数组链表
按下标访问O(1)O(n)
头部插入 / 删除O(n)O(1)
已知节点删除O(n)O(1)
内存 / 缓存连续、缓存友好分散、跳指针慢

链表在真实世界

  • Linux 内核到处是双向链表(list_head):任务队列、定时器、malloc 的空闲块链——这些场景都是「拿着节点要 O(1) 摘除 / 插队」,正踩在链表的长处上。LRU 缓存 = 哈希表 + 双向链表(哈希定位节点、链表挪到队头),也是同一思路。
  • 反过来,顺序遍历时数组几乎总是赢:链表每走一步都是一次可能 cache miss 的指针跳转。默认用动态数组,确认有大量 O(1) 插删需求再换链表。

基本功:反转

  • 反转链表是最好的指针操作练习——三指针滚动:一路把每个节点的 next 掉头指向前一个,同时用一个指针记住原来的后继、别把链走丢。能不看代码画出每一步三个指针的位置,说明指针真的学通了。
typedef struct Node { int data; struct Node *next; } Node;

/* 头插 O(1):新节点排到最前面。head 是「二级指针」——
   函数要修改调用者手里的头指针本身,就必须拿到它的地址 */
void push_front(Node **head, int v) {
    Node *n = malloc(sizeof(Node));           /* 造一个新节点 */
    n->data = v; n->next = *head; *head = n; /* 新节点指向旧头,头再改指它 */
}

/* 反转链表:三指针滚动,O(n)
   prev/cur 一路向右,把每根 next 箭头原地掉头 */
Node* reverse(Node *head) {
    Node *prev = NULL, *cur = head; /* prev 起初为空:原头反转后就是尾 */
    while (cur) {
        Node *next = cur->next; /* 先记住后继——下一步就要断开它 */
        cur->next = prev;       /* 箭头掉头:改指前一个节点 */
        prev = cur; cur = next; /* 两个指针整体右移一格 */
    }
    return prev;  /* cur 已走到 NULL,prev 停在原尾巴=新头 */
}
链表真正的代价不在复杂度表里,而在缓存。遍历求和会看到三档:连续数组最快;节点按分配顺序串起来的链表慢几倍;把 next 的指向打乱后(长期增删的链表就是这个样子)再掉两个数量级——每步指针跳转都是一次几乎必然 cache miss 的随机访问,CPU 还没法预取。注意三档的差距远大于机器之间的差异,换台机器绝对值会变,这个排序不会变。另一个常被忽略的点:链表的「插删 O(1)」只在已经握着节点指针时成立;若还要先遍历找到它,整体又回到 O(n),优势全部还回去。
双向链表删除节点不用先遍历找前驱(单链表的痛点),拿到节点指针即可 O(1) 摘除:把 node->prev->next 和 node->next->prev 接起来即可(注意头尾的 NULL 判断)。

两种「受限」的线性表——只开放特定的进出口,反而让它们在特定场景无可替代。

栈 Stack(LIFO 后进先出)

  • 像叠盘子:只能在同一端放和取,最后放的最先被拿走。
  • push / pop 都是 O(1),数组或链表都能实现。
  • 用途:括号匹配、表达式求值、函数调用栈、DFS、撤销(Undo)、浏览器后退。

队列 Queue(FIFO 先进先出)

  • 像排队:一端进(队尾)、另一端出(队头),先来先服务。
  • 数组实现用循环下标 (i+1)%cap,让队头队尾绕圈复用空间,避免「假溢出」。
  • 用途:BFS 逐层扩展、任务 / 消息调度、生产者-消费者缓冲。
/* 栈应用:括号匹配 */
int balanced(const char *s) {
    char stack[256]; int top = 0; /* 数组当栈用,top 指向栈顶上方的空位 */
    for (; *s; s++) {                /* 逐字符扫描,*s 是当前字符 */
        if (*s=='('||*s=='['||*s=='{') stack[top++] = *s; /* 左括号:压栈等配对 */
        else if (*s==')'||*s==']'||*s=='}') {
            if (!top) return 0;       /* 栈空还来右括号:右括号多了 */
            char c = stack[--top];     /* 弹出最近压入的左括号 */
            if ((c=='('&&*s!=')') ||
                (c=='['&&*s!=']') ||
                (c=='{'&&*s!='}')) return 0; /* 类型对不上 */
        }
    }
    return top == 0;             /* 栈清空才算全配平(防左括号多出)*/
}

/* 队列:循环数组入队 */
void enqueue(Queue *q, int v) {
    if (q->size == CAP) return;     /* 满则拒绝 */
    q->data[q->tail] = v;           /* 新元素放在队尾 */
    q->tail = (q->tail + 1) % CAP; /* 取模:走到数组末尾就绕回 0 */
    q->size++;
}

/* 出队:从 head 取,head 同样循环前进 */
int dequeue(Queue *q) {
    int v = q->data[q->head];       /* 先取走队头元素 */
    q->head = (q->head + 1) % CAP; /* 队头指针也绕圈前进 */
    q->size--;
    return v;                       /* 调用前需判 size>0 */
}
手写数组栈最常见的坑是只判空不判满。上面 balanced 里 stack[top++] = *s 之前只有 if (!top) 这条下溢检查,没有上溢检查——拿 300 个 ( 喂进去,直接 *** stack smashing detected *** 然后 abort;这还是 gcc 默认开的栈金丝雀救的场,关掉保护就是静默改写返回地址。压栈前必须补一句 if (top == 256) return 0;。循环队列则是对称的另一个坑:head == tail 同时表示「空」和「满」,要么像上面那样额外维护 size,要么空一格不用,二选一,别两个都不做。
递归的本质就是在用系统调用栈:每层调用把返回地址和局部变量压栈,返回时弹出。所以任何递归都能改写成「显式栈 + 循环」——当递归太深可能撑爆调用栈(栈溢出)时,这正是救命的手段。

前面的线性结构里,数组按值查找、链表连按下标都要 O(n) 一个个扫。为了把查找降到 O(log n),就得走向有层级的树形结构:BST 把「二分查找」变成一棵树——每个节点满足左子树都比它小、右子树都比它大。查一个值就像二分:比当前节点小就往左、大就往右,每步砍掉一半。

三个关键性质

  • 插入 / 查找 / 删除都是 O(h)(h 是树高):平衡时 h≈log n,退化时 h≈n。
  • 中序遍历(左→根→右)正好输出升序序列——这是 BST 最漂亮的性质。
  • 删除分三种:无子直接摘;单子把孩子接上来;双子用「后继」(右子树里最小的节点)替换,再删掉那个后继。
typedef struct BST { int key; struct BST *l, *r; } BST;

/* 插入:递归找空位挂新节点。每层都把(可能新建的)子树根
   返回,靠 t->l = insert(...) 这一步重新接回父节点 */
BST* insert(BST *t, int k) {
    /* 走到空位:新节点就诞生在这里 */
    if (!t) { t = malloc(sizeof(BST)); t->key=k; t->l=t->r=NULL; return t; }
    if (k < t->key)      t->l = insert(t->l, k); /* 比根小 → 去左子树 */
    else if (k > t->key) t->r = insert(t->r, k); /* 比根大 → 去右子树 */
    return t;            /* 相等不插(去重),把子树根原样交还上层 */
}

/* 中序遍历:左→根→右,输出升序 */
void inorder(BST *t) {
    if (!t) return;
    inorder(t->l);
    printf("%d ", t->key);
    inorder(t->r);
}

/* 查找:比根小走左、大走右,O(h) */
BST* search(BST *t, int k) {
    while (t && t->key != k)              /* 撞到 NULL 或找到就停 */
        t = (k < t->key) ? t->l : t->r; /* 小往左、大往右,一步砍掉一半 */
    return t;                 /* NULL 表示没找到 */
}

/* 删除:无子/单子直接接;双子用后继(右子树最小)替换 */
BST* erase(BST *t, int k) {
    if (!t) return NULL;                          /* 树里没有这个值 */
    if (k < t->key)      t->l = erase(t->l, k); /* 目标在左边,删完接回 */
    else if (k > t->key) t->r = erase(t->r, k); /* 目标在右边 */
    else {                                        /* 找到了:分三种情况删 */
        if (!t->l) { BST *r=t->r; free(t); return r; } /* 无左子:右孩子顶上 */
        if (!t->r) { BST *l=t->l; free(t); return l; } /* 无右子:左孩子顶上 */
        BST *s = t->r;            /* 找右子树最小=后继 */
        while (s->l) s = s->l;
        t->key = s->key;          /* 用后继值覆盖 */
        t->r = erase(t->r, s->key); /* 再删后继 */
    }
    return t;
}
普通 BST 在有序数据插入时退化成链表(树高 O(n),操作变 O(n))。生产中用自平衡树:AVL(严格平衡)或红黑树(放宽平衡、旋转更少)。Linux 内核、C++ std::map 都用红黑树。
想亲眼看见 BST 退化,就别用随机数测它。往上面这份 insert 里灌 10 万个 key,把树高和平均比较次数打出来(这两个数只取决于插入顺序,和机器无关,谁跑都一样):乱序插入树高约 40、平均比较约 20 步;按 0…99999 有序插入,树高直接变成 100000——整棵树退化成一条链,平均比较 49999.5 步,是前者的两千多倍。严重的是「有序输入」恰恰是生产里最常见的:自增主键、时间戳、已排好序的导入文件。所以自测树结构时第一组数据就该是有序的,随机数据只会让你误以为一切正常。

普通 BST 遇到有序数据会退化成链表。平衡树的解法是:每次插删后若发现某处「一边太高」,就用旋转把子树转一转,把树高强行压回 O(log n)。旋转只动几根指针,仍是 O(1)。

两种主流平衡树

  • AVL 树:严格平衡,任意节点左右子树高度差 ≤ 1。查得最快,但插删可能触发较多旋转——适合读多写少。
  • 红黑树:用「红 / 黑染色」规则放宽平衡(最长路径 ≤ 最短的 2 倍),插删旋转更少——适合写也频繁的通用场景。

旋转的直觉

  • 「右旋」= 把左孩子提上来当根,原根降为它的右孩子;「左旋」镜像。像跷跷板把重的一端压下去。

用在哪

  • C++ std::map/std::set、Java TreeMap、Linux 内核进程调度与内存区间管理,都是红黑树。
  • 数据库 / 文件系统索引用它的「多路」亲戚 B 树 / B+ 树——一个节点存多个 key,减少磁盘寻道。
/* 右旋:LL 型失衡时,把左孩子 x 提上来当根 */
Node* rotate_right(Node *y) {
    Node *x = y->left;     /* x 是即将提上来当根的左孩子 */
    y->left  = x->right;   /* x 的右子树过继给 y 的左边 */
    x->right = y;          /* y 降为 x 的右孩子 */
    update_height(y);      /* 先更新下面的 y */
    update_height(x);      /* 再更新上面的 x(顺序不能反)*/
    return x;             /* x 成为这棵子树的新根 */
}
两个反直觉的地方。① O(log n) 的常数很大:每下降一层都是一次指针跳转,和链表一样属于随机内存访问,而随机访问比顺序扫描慢两个数量级是各机器上普遍成立的。所以元素只有几十上百个时,「排好序的数组 + 二分」通常比平衡树更快也更省内存,别一看到「要有序」就上平衡树。② 别手写:AVL 的双旋分类、红黑树的删除修复有十几种情况,插入写对了删除照样错,而且错法往往是「树还能用,只是悄悄退化」,测不出来。C 里要么用现成库,要么老实用有序数组。
红黑树 vs AVL 怎么选:AVL 把平衡卡得更死,树更矮、查询更快,适合查多改少;红黑树放宽了平衡要求,插入删除时旋转次数更少、写入更省,所以被标准库和内核选作默认。抓住「用平衡的严格程度,换查询与写入的快慢」这个权衡,就能自己推断该选哪种,而不必去记具体的染色规则。

堆是一棵只保证「堆顶是最值」的完全二叉树——它不像 BST 那样全局有序,所以取最小值 O(1)、但要找某个普通元素仍是 O(n)。因为是完全二叉树,可以直接用数组存,不需要指针。

怎么用数组装一棵树

  • 下标映射:节点 i 的左子 2i+1、右子 2i+2、父 (i-1)/2。
  • 最小堆满足父 ≤ 子;插入后上浮、删顶后把末尾补到顶再下沉,都是 O(log n)。
  • 取最值(堆顶)O(1),这正是它的杀手锏。

用在哪

  • 它是优先队列的底层实现。
  • Dijkstra / Prim 取「当前最近点」、Top-K(维护大小为 K 的堆,O(n log K))、堆排序、定时器 / 任务调度取「最早到期」。
/* 上浮:插入后向上恢复堆序 */
void sift_up(MinHeap *h, int i) {
    while (i > 0) {                /* 浮到根(下标 0)就到顶了 */
        int p = (i - 1) / 2;      /* 完全二叉树存数组:父下标=(i-1)/2 */
        if (h->data[p] <= h->data[i]) break; /* 父不比我大,堆序已恢复 */
        swap(&h->data[p], &h->data[i]); i = p;  /* 比父小就换上去,继续向上 */
    }
}

/* 下沉:删顶后向下恢复堆序 */
void sift_down(MinHeap *h, int i) {
    for (;;) {
        int l=2*i+1, r=2*i+2, m=i; /* 左右孩子下标;m 记父子三个里最小的 */
        if (l<h->size && h->data[l]<h->data[m]) m=l; /* 左孩子更小? */
        if (r<h->size && h->data[r]<h->data[m]) m=r; /* 右孩子更小? */
        if (m==i) break;                /* 自己就是最小的,不用再沉 */
        swap(&h->data[i], &h->data[m]); i=m; /* 和最小的孩子换,去下一层接着修 */
    }
}

/* 插入:放末尾再上浮,O(log n) */
void push(MinHeap *h, int x) {
    h->data[h->size] = x;      /* 先放到数组末尾(树的最后一个叶子)*/
    sift_up(h, h->size++);     /* 从那里往上浮到该在的位置 */
}

/* 弹出最小:取顶,末尾补位再下沉 */
int pop_min(MinHeap *h) {
    int top = h->data[0];        /* 堆顶即最小 */
    h->data[0] = h->data[--h->size]; /* 拿末尾元素补到顶,size 减一 */
    sift_down(h, 0);             /* 补上来的往下沉,恢复堆序 */
    return top;
}
三个高频误解。① 堆数组不是有序数组:依次 push 5 3 8 1 9 2 7,底层数组是 1 3 2 5 9 8 7——直接遍历打印根本不递增,只有逐个 pop_min 才得到 1 2 3 5 7 8 9。② 上面的 pop_min 没判空:h->data[--h->size] 在 size == 0 时读的是 data[-1],越界读之外还把 size 弄成 −1,之后全乱;调用前必须自己保证 size > 0。③ 堆只保证堆顶是最值,找任意一个普通元素仍是 O(n),也没有现成的「改某个元素优先级」操作。
建堆别一个个 push(那是 O(n log n))。正确做法是把数据直接铺进数组,再从最后一个非叶子节点倒着往前逐个下沉——整体只要 O(n)。堆排序就是这么开局的。

哈希表解决的是按 key 直接取值。它用一个哈希函数把 key「算」成一个桶下标,直接定位——像储物柜按编号直达,平均 O(1) 增删查。代价是它不保证任何顺序。

三个概念:哈希、冲突、负载因子

  • 哈希函数:把任意 key 打散成一个较均匀的整数,再对桶数取模得到下标。
  • 冲突(两个 key 落到同一桶)不可避免,两种解法见下表。
  • 负载因子 = 元素数 / 桶数,衡量桶的拥挤度。超过约 0.75 就扩容并把所有元素重新散列(rehash),把查找拉回 O(1)。

两种冲突解法

方式做法特点
链地址法每个桶挂一条链表,冲突就往链上加简单灵活,删除容易
开放寻址冲突就按规则探测下一个空桶无指针、缓存友好,删除较麻烦
/* djb2:经典字符串哈希 */
unsigned hash(const char *key) {
    unsigned h = 5381;         /* djb2 的经典起始魔数 */
    for (; *key; key++)          /* 逐字符搅拌,直到串尾的 0 */
        h = h * 33 + (unsigned char)*key; /* 乘 33 再加当前字符,把 key 打散 */
    return h % HT_SIZE;          /* 取模,压进桶数组的下标范围 */
}

/* 链地址法插入(头插),key 用 strdup 复制 */
void set(HashMap *m, const char *key, int val) {
    unsigned i = hash(key);    /* 先算出 key 落在哪个桶 */
    for (Entry *e=m->bucket[i]; e; e=e->next)   /* 沿桶里的冲突链找 */
        if (!strcmp(e->key, key)) { e->val = val; return; } /* 已有:改值(strcmp 相等返回 0)*/
    Entry *n = malloc(sizeof(Entry));          /* 链上没有:新建节点 */
    n->key = strdup(key); n->val = val;        /* 复制 key,防外部串被改/释放 */
    n->next = m->bucket[i]; m->bucket[i] = n;   /* 头插进桶链,O(1) */
}

/* 查找:定位桶后沿链表比较 key */
int get(HashMap *m, const char *key, int *out) {
    unsigned i = hash(key);    /* 同一个哈希函数,才能找到当初的桶 */
    for (Entry *e=m->bucket[i]; e; e=e->next)   /* 同桶可能挂着多个 key,逐个比 */
        if (!strcmp(e->key, key)) {
            *out = e->val; return 1;  /* 命中 */
        }
    return 0;                       /* 不存在 */
}
几个坑:①最坏情况(大量 key 冲突到同一桶)会退化成 O(n),抗攻击场景要用随机化哈希种子;②哈希表无序,要有序遍历请改用平衡树;③C 标准库没有内置哈希表,需自己实现或用第三方(如头文件库 uthash)——自己实现时别忘 strdup 复制 key(否则悬空),释放时同时 free key 和节点。
扩容该不该预留,取决于你在乎吞吐还是延迟。插入 100 万个 key,从 16 个桶起步一路长上去,共 rehash 17 次、累计搬运约 1.5n 个节点(这正是「均摊 O(1)」的来源,是个和机器无关的算术事实)。预分配足够的桶能省掉这些搬运,但总耗时只快一两成——纯吞吐场景真不值得为它操心。可要是把每次 insert 的耗时单独记下来,你会看到少数几次比中位数慢上千倍,那正是扩容发生的瞬间。所以游戏帧、实时交易这类看尾延迟的地方,必须按预估容量一次性预分配,把这根尖刺去掉。

专治字符串的树:把单词按字符一层层拆开,公共前缀只存一份。查一个词的耗时只跟词的长度有关,跟词典里有多少词无关——这是哈希表做不到的前缀能力。

怎么存、为什么与词数无关

  • 每个节点代表一个字符位置,孩子指针按字符分叉(小写英文就是 26 个)。
  • 用一个 end 标记「一个完整单词到此结束」,区分 app 和 apple。
  • 插入 / 查询 / 前缀匹配都是 O(词长)。

用在哪

  • 搜索框自动补全、输入法联想、敏感词过滤、拼写检查。
  • 路由器按最长前缀匹配 IP(其变体基数树 / 压缩前缀树)。
typedef struct Trie {
    struct Trie *next[26]; /* 26 个字母的分叉 */
    bool end;               /* 是否有单词在此结束 */
} Trie;

/* 插入单词:沿字符下钻,缺孩子就新建 */
void insert(Trie *root, const char *w) {
    Trie *p = root;              /* p 是「当前走到的节点」 */
    for (; *w; w++) {            /* 逐字符走到字符串结尾 */
        int c = *w - 'a';      /* 字符转下标:'a'→0 … 'z'→25 */
        if (!p->next[c])         /* 这个字符还没路 */
            p->next[c] = calloc(1, sizeof(Trie)); /* calloc 清零:孩子全 NULL、end=false */
        p = p->next[c];          /* 下钻到下一层 */
    }
    p->end = true;             /* 标记单词结尾 */
}

/* 查询:走到底且 end 为真才算真的存在 */
bool search(Trie *root, const char *w) {
    Trie *p = root;
    for (; *w && p; w++) p = p->next[*w - 'a']; /* 沿字符走,断路则 p 变 NULL */
    return p && p->end;        /* 没断路、且恰好是词尾,才算有这个词 */
}
每个节点固定带 26(甚至 256)个孩子指针,词稀疏时非常费内存。海量 key 场景改用压缩前缀树 / 基数树(把只有一个孩子的链压成一条边)或双数组 Trie。
选 Trie 前先问一句:你要的是前缀能力,还是只是「查这个词在不在」?只查存在性的话哈希表几乎全面胜出,Trie 的内存代价大到超乎想象——光看结构体就知道:sizeof(Trie) 是 216 字节(26 个 8 字节指针 + bool + 对齐填充,64 位平台上都是这个数)。而每多一个未共享的前缀字符就多一个节点,灌进几十万个随机词后,整棵树能吃掉原文本两个数量级的内存。只有自动补全、最长前缀匹配这类哈希表办不到的需求才值得付这个价。另外 int c = *w - 'a' 遇到大写或数字会算出负下标('A' - 'a' 就是 −32),入口必须先校验字符范围。

图 = 顶点 + 边,用来建模一切「关系网络」:社交好友、地图路网、任务依赖、网页链接。存图就像存通讯录:邻接表是「每人一份自己的好友名单」,邻接矩阵是「全班 V×V 的关系登记表」——查任意两人认不认识一眼可见,但没关系的格子也白占地方。

两种存法对照

邻接表邻接矩阵
空间O(V+E),只存真实存在的边O(V²),没边的格子也占
查 u–v 有没有边O(u 的度),要顺着名单找O(1),g[u][v] 直接看
遍历 u 的邻居快,名单上全是真邻居慢,要扫整行 V 个格子
适用稀疏图(绝大多数场景)稠密小图、频繁查两点关系

本章这几卡共用的 5 节点例图

    0
   / \
  1   2        无向图,5 条边:
   \ /         0-1、0-2、1-3、2-3、3-4
    3
    |
    4

邻接表(按编号升序写):
0 → [1, 2]    1 → [0, 3]    2 → [0, 3]
3 → [1, 2, 4]    4 → [3]

注意:下面代码用头插法加边,链上邻居实际是加边的逆序。为方便手推,本章后面的遍历推演统一假设「按编号从小到大访问邻居」。

#define V 5                      /* 顶点数:例图 5 个点 */

/* 邻接表节点 = 一条出边:指向谁 + 名单上的下一条 */
typedef struct Edge {
    int to;                     /* 这条边通向的顶点 */
    struct Edge *next;          /* 同一顶点的下一条边 */
} Edge;

/* 图 = 每个顶点挂一条「好友名单」链表 */
typedef struct {
    Edge *head[V];              /* head[u] 是 u 的名单表头 */
} Graph;

/* 加一条有向边 u→v:头插,O(1)(同链表卡的 push_front) */
void addEdge(Graph *g, int u, int v) {
    Edge *e = malloc(sizeof(Edge));
    e->to = v;
    e->next = g->head[u];       /* 新边插到名单最前面 */
    g->head[u] = e;
}

/* 无向边 = 两条方向相反的有向边,必须加两次 */
void addUndirected(Graph *g, int u, int v) {
    addEdge(g, u, v);
    addEdge(g, v, u);
}
两个高频易错点:① 无向图忘记双向加边——只 addEdge(u, v) 会让从 v 出发的遍历「看不见」这条边,连通分量数直接算错;② 邻接矩阵的 O(V²) 陷阱——V = 10⁵ 时矩阵要 10¹⁰ 个格子,内存直接爆掉,大图必须用邻接表。
头插法让链上邻居顺序与加边顺序相反——不同实现、不同加边顺序,遍历出的邻居次序都可能不同。所以图算法的正确性绝不能依赖邻居的访问顺序,只能依赖「每个邻居都会被访问到」。

两种遍历都是「系统地点名每个顶点、每条边各看一次」(都是 O(V+E)),区别只在下一个访问谁:BFS 像水波纹,一圈圈向外扩散——第一次浸到某点,走的必然是最少步数;DFS 像走迷宫,一条路摸到黑,撞墙再退回最近的岔口换条路。

对照

遍历用什么怎么走擅长
BFS队列一圈圈按层扩展无权图最短跳数
DFS递归 / 栈一条路走到底再回溯连通分量、拓扑排序、找环

分步推演:在本章前面的 5 节点图上跑 BFS(0)

步骤出队新发现并入队队列已访问
0—0(起点)[0]{0}
101、2[1, 2]{0,1,2}
213[2, 3]{0,1,2,3}
32(3 已标记,不重复入队)[3]不变
434[4]{0,1,2,3,4}
54无[],结束全部
  • 访问顺序 0, 1, 2, 3, 4,天然分层:{0} → {1,2} → {3} → {4}——层号就是到起点的最短跳数。
  • 第 3 步里 2 也连着 3,但 3 已被 1 抢先发现——标记的作用就是保证每点只入队一次。

同一张图跑 DFS(0)

  1. 访问 0,走第一个邻居 → 1
  2. 访问 1,邻居 0 已访问,走 → 3
  3. 访问 3,邻居 1 已访问,走 → 2
  4. 访问 2,邻居 0、3 全已访问,撞墙回溯到 3
  5. 3 还剩邻居 → 4,访问后全图走完

访问顺序 0, 1, 3, 2, 4——对比 BFS 的 0, 1, 2, 3, 4:一个层层扫,一个钻到底再回头。

/* 两个函数签名对齐:seen 都由调用者提供并清零,
   这样求连通分量时可对每个未访问点循环调用 */

/* BFS:队列,逐层访问 */
void bfs(Graph *g, int src, bool seen[]) {
    int queue[V], head = 0, tail = 0;  /* V 见本章前面;很大时应 malloc */
    seen[src] = true;                     /* 起点先标记 */
    queue[tail++] = src;                    /* 再入队 */
    while (head < tail) {              /* head 追上 tail 即队空 */
        int u = queue[head++];          /* 出队:本轮访问 u */
        for (Edge *e = g->head[u]; e; e = e->next) /* 扫 u 的整条邻居链 */
            if (!seen[e->to]) {
                seen[e->to] = true;   /* 入队时立即标记,不是出队时! */
                queue[tail++] = e->to;
            }
    }
}

/* DFS:递归版 */
void dfs(Graph *g, int u, bool seen[]) {
    seen[u] = true;                  /* 进门先标记,防转圈 */
    for (Edge *e = g->head[u]; e; e = e->next)
        if (!seen[e->to]) dfs(g, e->to, seen); /* 没见过就一头钻进去 */
}

/* 调用示例:
   bool seen[V] = {false};
   bfs(&g, 0, seen);   // 或 dfs(&g, 0, seen); */
两个经典 bug:① 递归 DFS 爆栈——但阈值别拍脑袋。在常见的 8 MB 默认栈上(ulimit -s 可查,各系统不同),这份 dfs 能撑到十万量级的深度——所以 10⁵ 个点的链状图往往「刚好还活着」,余量却薄得很。更要留意的是同一份代码,-O0 会比 -O2 早崩好几倍,因为优化会显著压缩栈帧,而你调试时用的恰恰是先崩的那个。真正的判据是栈帧大小 × 深度 < 栈上限,而不是某个记住的层数:函数里多一个几百字节的局部数组,阈值立刻掉一个数量级。想知道自己的阈值,写个递归计数器二分一下即可。深度可能上 10⁵ 就该改成「显式栈 + 循环」的迭代版(正是栈与队列卡说的「递归 ↔ 栈」通用手法);② 出队时才标记——同一顶点会被多个邻居重复入队,队列膨胀、复杂度退化,必须入队瞬间就标记。
选哪种看你要什么:求「无权图最短路 / 最少步数」用 BFS——它一圈圈向外扩,第一次到达某点必然走了最少步;求「连通分量、拓扑排序、检测环、路径存在性」用 DFS——一条路走到底更省事。带权最短路则要下一章的 Dijkstra。

算法分析

算法速查:数据规模与可接受复杂度的对应、十类模板(双指针、滑窗、二分、分治、回溯、贪心、图算法、动态规划)、排序全表——每个都配模板与易错点。

算法题和工程问题的共同起点是同一个判断:数据规模多大,因此什么复杂度能过。下表是这个判断的速查。

规模 → 可接受的复杂度

n 的量级大致能接受典型算法
≤ 10O(n!)全排列、暴力枚举
≤ 20O(2ⁿ)子集枚举、状压 DP
≤ 500O(n³)Floyd 全源最短路
≤ 5000O(n²)二维 DP、朴素双循环
≤ 10⁶O(n log n)排序、二分、堆——绝大多数工程问题落在这一档
≥ 10⁷O(n) 或 O(log n)一次扫描、双指针、哈希
常数也重要时大 O 忽略常数与缓存行为——两个都是 O(n log n) 的实现可能差三倍(09 章)

十类模板

套路用在哪关键
双指针(对撞)有序数组找两数之和、回文判断左右向中间收
双指针(快慢)链表找环 / 找中点、原地去重一个走一步一个走两步
滑动窗口最长不重复子串、定长区间统计右扩到不合法,再收左边;每个元素最多进出一次 → O(n)
二分查找有序数组定位mid = lo + (hi - lo) / 2 防溢出;写成「找第一个 ≥ x」的下界形式最不容易错
二分答案「最小的最大值」这类问题把「能不能做到 x」写成判定函数,再对答案二分
分治归并排序、快速选择拆 → 递归 → 合并
回溯排列组合、N 皇后、数独选择 → 递归 → 撤销;剪枝决定能不能跑完
贪心区间调度、找零必须能论证局部最优不会毁掉全局——否则就是错的
BFS / DFS图与网格遍历、最短边数队列 / 递归 + visited(09 章)
动态规划最优子结构 + 重叠子问题四件事:状态、转移、边界、遍历顺序;空间可用滚动数组压掉一维

图算法

算法解决复杂度与前提
BFS无权图最短路O(V + E)
Dijkstra带权最短路配优先队列 O((V+E) log V);权必须非负
拓扑排序(Kahn)依赖顺序O(V + E);输出个数不足 V 就说明有环
并查集连通性、判环、Kruskal路径压缩 + 按秩合并后几乎是 O(1)

排序全表

算法平均最坏空间稳定什么时候用
冒泡 / 选择O(n²)O(n²)O(1)稳 / 不稳教学
插入O(n²)O(n²)O(1)稳小数组或近乎有序时非常快——库里的快排在小区间就切到它
归并O(n log n)O(n log n)O(n)稳要稳定、或数据在链表 / 外存上
快排O(n log n)O(n²)O(log n) 栈不稳默认选择;枢轴取三数取中避开最坏
堆排O(n log n)O(n log n)O(1)不稳要最坏也有保证、且不能多用内存
计数 / 基数O(n + k)O(n + k)O(k)稳值域小且是整数
qsort(标准库)直接用它就好;不保证稳定,比较函数别写成相减(05 章)
二分查找最容易错的不是思路,是边界。lo <= hi 还是 lo < hi、hi = mid 还是 hi = mid - 1,组合起来能写出四个版本、其中三个有 bug。固定用一套「找第一个满足条件的位置」的写法(区间 [lo, hi)、循环条件 lo < hi、成立时 hi = mid 否则 lo = mid + 1),并且始终用 lo + (hi - lo) / 2 算中点——(lo + hi) / 2 在大数组上会溢出。
工程里真正高频的只有三样:排序(直接用 qsort)、二分(记住下界写法)、哈希(用来把 O(n²) 降成 O(n))。「循环里套 contains」几乎是所有慢代码的共同形状——看到它就想哈希表(09 章)。

大 O 不量精确耗时,只回答一个问题:数据规模翻倍,工作量会怎么涨?O(n) 翻倍则翻倍,O(n²) 翻倍则变四倍。它忽略常数和低阶项,只看「增长量级」。为什么能忽略常数?因为 n 一大,增长快的那一项就会把「常数 × n」远远压过——比较两个算法谁更优时,起决定作用的是量级而非那个前面的系数。

常见量级(n = 10⁶ 时的操作数)

量级操作数 ≈典型来源
O(1)1哈希查、数组下标
O(log n)20二分、平衡树
O(n)10⁶遍历、双指针
O(n log n)2×10⁷归并 / 快排
O(n²)10¹²(不可用)双重循环、朴素比较
O(2ⁿ)天文数字子集枚举、朴素递归

三个常见误区

  • 只看主项:3n² + 100n 就是 O(n²),常数和低阶项都丢掉。
  • 分清平均 vs 最坏:快排平均 O(n log n)、最坏 O(n²);哈希平均 O(1)、最坏 O(n)。
  • 空间也要算:额外数组、递归栈深度都计入空间复杂度。
/* O(1):常数 */
int x = a[0];

/* O(n):单层循环 */
for (int i=0; i<n; i++) { ... }

/* O(n²):双层嵌套 */
for (int i=0; i<n; i++)
    for (int j=0; j<n; j++) { ... }

/* O(log n):每次规模减半 */
for (int i=1; i<n; i*=2) { ... } /* i: 1→2→4→…,约 log₂n 步到 n */

/* O(n log n):外层 n、内层每次减半(如排序) */
for (int i=0; i<n; i++)
    for (int j=1; j<n; j*=2) { ... }
「渐进复杂度更优」在小规模上经常反过来。把一大批随机整数切成等长小段分别排序,你会看到:段长只有几十时,O(n²) 的插入排序反而比 qsort 快好几倍,要到段长上千才被反超(具体的交叉点取决于 CPU 与标准库实现,得自己测)。原因是 qsort 每次比较都要走一次函数指针调用,常数远大于插入排序的一次内存移动。所以别拿「我复杂度更低」去论证小数据场景更快——标准库对小区间切换插入排序,正是同一个道理。
大 O 回答「规模涨了会怎样」,不回答「现在快不快」——这两件事经常给出相反的建议。把 1000 万个整数加起来,连续数组和「节点四散、next 被打乱」的链表都是标准的 O(n),耗时却能差两三个数量级——大 O 把这几百倍整个吞进了那个被忽略的常数里。正确用法是分两步:先用大 O 排除量级选错的方案(O(n²) 处理 10⁶ 数据就别想了);量级定下来之后,剩下的性能问题只能靠——内存布局、缓存、分支预测都不在这套记号的射程内。

用两个下标在数组 / 字符串上协同移动,把很多 O(n²) 的暴力降到 O(n)。核心是「多扫一遍不如让两个指针配合着只扫一遍」。

三种形态

  • 对撞指针:一头一尾往中间夹。用于有序数组里找配对、原地反转、判回文。
  • 快慢指针:一个走一步、一个走两步。用于链表判环(有环两者必相遇)、找中点、找倒数第 k 个。
  • 滑动窗口:右指针扩、左指针缩,维护一个满足条件的区间。用于连续子数组 / 子串的最长 / 最短问题。

为什么行得通

  • 对撞指针能用在有序数组上,靠的正是有序性:当前和太大就只能收右界、太小就只能收左界,这个「往哪挪」的判断是单调确定的,所以一遍扫过去不会漏。
  • 滑动窗口适合连续子数组 / 子串求最长 / 最短:右指针扩、左指针缩,每个元素最多进出窗口一次,故整体 O(n)。

不只是技巧

  • 归并排序的合并步骤、数据库的 merge join、diff 工具对齐两个文件——本质都是「两个有序序列上的双指针」:各自只前进不后退,每个元素恰好看一次。
  • 滑动窗口就是限流器与 TCP 流量控制的核心形态:在数据流上维护一个动态伸缩的合法区间。带走的应该是「协同移动、每个元素只处理常数次」这个思想,而不是某道具体题目。
/* 对撞指针:有序数组找和为 target 的两个数 */
int two_sum(int a[], int n, int target) {
    int lo = 0, hi = n - 1;   /* 一头一尾,向中间夹 */
    while (lo < hi) {                /* 相遇就没得配了 */
        int s = a[lo] + a[hi];       /* 当前两端配对的和 */
        if (s == target) return 1;   /* 找到 */
        (s < target) ? lo++ : hi--;      /* 小了挪左界,大了挪右界——
                                            靠有序性,这一步不会漏解 */
    }
    return 0;                        /* 夹到相遇也没配上 */
}

/* 滑动窗口:最长无重复字符子串的长度 */
int longest_unique(const char *s) {
    int last[256];                        /* last[c]:字符 c 上次出现的下标 */
    for (int i=0; i<256; i++) last[i] = -1; /* -1 = 还没出现过 */
    int start = 0, best = 0;          /* start 是窗口左端;i 就是右端 */
    for (int i = 0; s[i]; i++) {      /* 右端一格格扩 */
        unsigned char c = s[i];             /* 转无符号,防负下标 */
        if (last[c] >= start)               /* 上次出现落在窗口内=重复了 */
            start = last[c] + 1;   /* 左端直接跳到重复字符的下一格 */
        last[c] = i;                          /* 记下 c 的最新位置 */
        int len = i - start + 1;      /* 当前窗口长度 */
        if (len > best) best = len;
    }
    return best;
}
对撞指针必须先排序,这是最容易漏掉的前提。拿上面的 two_sum 跑随机数据对拍,几秒就能撞出反例:a = {7, 13, 16, 11, 2, 9}、target = 13,数组里明明有 11 + 2 = 13,函数却返回 0——无序时「和太大就收右界」这个判断不再成立,指针朝错误方向挪,一路把解跳了过去。它不报错、不崩溃,只是安静地少给你一组答案。判据很简单:只要移动决策依赖「往这边挪值会变大」,就一定要先保证序。
判断一段双指针代码是不是真的 O(n),只看一句话:两个指针是否都只前进、从不后退。滑动窗口里 start 只会往右跳(上面 longest_unique 的 start = last[c] + 1 就是),所以每个元素最多进出窗口各一次,总步数不超过 2n。反过来,如果你写着写着出现了「把左指针拉回去重扫」,那已经不是双指针,而是伪装成双指针的 O(n²) 暴力,复杂度分析要重来。另一个自查点是窗口收缩条件:它必须与扩张条件互斥且单调,否则同一个位置反复扩缩,轻则退化、重则死循环。

每次比较中间元素,就能砍掉一半的搜索范围,O(log n)。前提是数据有序——或者更本质地说,答案空间是单调的。

写对二分的三件事

  • mid = lo + (hi - lo) / 2 而不是 (lo + hi) / 2,防止两数相加整数溢出。
  • lower_bound:找第一个 ≥ target 的位置,用 [lo, hi) 半开区间写法,返回插入点。
  • 二分答案:二分的真正前提不是「数组有序」而是单调——只要「x 行不行」这个判定随 x 单调变化,就能抛开数组、直接对答案空间二分,每次用判定函数砍掉一半。

二分在真实世界

  • git bisect(Git 章会见到)就是对提交历史二分:判定函数是「这个版本有没有 bug」,几十次构建就能从上千个提交里揪出罪魁——正是「答案空间单调」的活例子。
  • 数据库 B+ 树在节点内定位 key、C 标准库的 bsearch、在按时间排好的日志里定位「几点开始出错」——凡是有序或单调的地方,二分都该是第一反应。
int binary_search(int a[], int n, int target) {
    int lo = 0, hi = n - 1;   /* 闭区间 [lo, hi]:两端都是候选 */
    while (lo <= hi) {               /* 闭区间非空要带等号 */
        int mid = lo + (hi - lo) / 2; /* 防溢出:等价 (lo+hi)/2 但不会加爆 */
        if      (a[mid] == target) return mid;
        else if (a[mid] <  target) lo = mid + 1; /* 目标在右半,mid 本身已排除 */
        else                      hi = mid - 1; /* 目标在左半,同理 ±1 */
    }
    return -1;                       /* 区间夹空:不存在 */
}

/* lower_bound:第一个 >= target 的下标 */
int lower_bound(int a[], int n, int t) {
    int lo = 0, hi = n;       /* 半开区间 [lo, hi):注意 hi 可以指到 n */
    while (lo < hi) {                /* 半开区间非空不带等号 */
        int mid = lo + (hi - lo) / 2;
        if (a[mid] < t) lo = mid + 1; else hi = mid; /* <t 则排除 mid;
                                否则 mid 可能就是答案,hi=mid 保留它 */
    }
    return lo;                       /* lo==hi,即第一个 ≥t 的位置(可能是 n)*/
}
二分最容易死在边界:区间用 [lo, hi] 闭区间就配 while (lo <= hi) 且更新 mid±1;用 [lo, hi) 半开就配 while (lo < hi) 且 hi = mid。固定一种写法别混用,否则不是漏解就是死循环。
(lo + hi) / 2 的溢出不是纸上谈兵,它能直接把程序打成段错误:开一个 20 亿字节的有序 char 数组(下标仍在 int 范围内),目标放末尾——lo + (hi - lo) / 2 版本正常返回;(lo + hi) / 2 版本在第二次迭代就崩了:此时 lo = 1000000000、hi = 1999999999,相加超过 INT_MAX 回绕成负数,mid 算出 -647483648,紧接着 a[mid] 直接 SIGSEGV。这个 bug 曾在 JDK 的 Arrays.binarySearch 里潜伏近十年才被发现——把 lo + (hi - lo) / 2 变成肌肉记忆,没有例外。

排序是算法的「练功房」——分治、递归、原地交换、稳定性等思想都在这里集中体现。按复杂度分三档:初级 O(n²)、进阶 O(n log n)、非比较 O(n+k)。归并和快排都建立在分治思想上——把数组拆成两半各自排好再合起来,这套「拆→解→合」的思路详见后面的「递归、分治与回溯」卡。

七种排序对照

算法平均最坏额外空间稳定一句话
冒泡O(n²)O(n²)O(1)是相邻交换,可提前退出
选择O(n²)O(n²)O(1)否每轮选最小,交换最少
插入O(n²)O(n²)O(1)是近乎有序时接近 O(n)
归并O(n log n)O(n log n)O(n)是稳定,适合链表 / 外排
快排O(n log n)O(n²)O(log n)否平均最快、原地
堆排O(n log n)O(n log n)O(1)否最坏也稳,但常数大
计数O(n+k)O(n+k)O(n+k)是值域小的整数专用

怎么选

  • 稳定 = 相等元素排完保持原相对顺序。这不是学术属性:把一张已按「日期」排好的账单再按「金额」排序,稳定排序能保住同金额内的日期顺序——多关键字排序全靠它。
  • 小数组 / 近乎有序 → 插入;通用内存排序 → 快排;要稳定、排链表或外部排序 → 归并;整数且值域小 → 计数 / 基数。

标准库真正在用什么(以及为什么)

  • C++ std::sort 是内省排序(introsort):快排打头(平均最快、原地、缓存友好),递归过深就切换堆排(堵死 O(n²) 最坏情况),小区间切插入(小数组常数最小)——三个算法各取所长拼成一个。
  • Python 的 sorted、Java 的对象排序用 Timsort(归并 + 插入的混合):先扫出数据里现成的有序段(run)再归并。选它是因为真实数据常常「部分有序」——日志天然按时间、每次只追加少量新数据后重排——Timsort 在这类输入上接近 O(n),而且稳定。
  • 所以「七种排序」没有一种被原样用在生产里,工程实现全是混合策略。学基础排序的意义不是把它们背下来,而是理解每种快在哪、怕什么,从而看懂这些组合为什么这么拼。

深入理解:O(n log n) 这道天花板从哪来

  • n 个元素有 n! 种可能的初始排列,排序算法必须把它们全部区分开;而一次比较最多把「剩余可能性」砍掉一半,所以至少要 log₂(n!) ≈ n log n 次比较。这是信息论下界,不是经验值——任何只靠比较的算法都逃不掉。
  • 计数 / 基数排序能到 O(n+k),正是因为它们不比较:直接拿值当数组下标,用「值域有限」这条额外信息绕过了下界。看懂这一点就明白:「突破下界」靠的从来是更多的先验信息,不是更聪明的比较。
/* 冒泡:相邻逆序就交换,稳定 O(n²) */
void bubble_sort(int a[], int n) {
    for (int i=0; i<n-1; i++) {
        int swapped = 0;         /* 记录本轮有没有发生交换 */
        for (int j=0; j<n-1-i; j++)  /* 末尾 i 个已就位,不用再比 */
            if (a[j] > a[j+1]) { swap(&a[j], &a[j+1]); swapped=1; } /* 逆序就交换:大的冒向末尾 */
        if (!swapped) break;              /* 一轮没交换=已有序,提前退出 */
    }
}

/* 选择:每轮选最小放到前面,交换最少 */
void selection_sort(int a[], int n) {
    for (int i=0; i<n-1; i++) {  /* i 左边是已排好的前缀 */
        int min = i;                     /* 先假设 a[i] 是最小 */
        for (int j=i+1; j<n; j++)  /* 去未排序区找真正的最小 */
            if (a[j] < a[min]) min = j;
        swap(&a[i], &a[min]);            /* 最小值归位到 i */
    }
}

/* 插入:把 a[i] 插入前面有序段,近乎有序时 O(n) */
void insertion_sort(int a[], int n) {
    for (int i=1; i<n; i++) {    /* a[0..i-1] 已有序,像手里理好的牌 */
        int key = a[i], j = i-1;    /* key 是这轮要插的新牌 */
        while (j>=0 && a[j]>key)       /* 比 key 大的逐个右移,腾出位置 */
            a[j+1] = a[j];   /* 拆成两步:写 a[j+1] 和改 j */
            j--;                /* 合成一句 a[j+1]=a[j--] 是 UB,会真排错 */
        a[j+1] = key;                    /* 落进腾出来的空位 */
    }
}

/* 归并排序:稳定 O(n log n) */
void merge_sort(int a[], int l, int r, int tmp[]) {
    if (l >= r) return;        /* 只剩 1 个元素:天然有序,递归出口 */
    int m = l + (r - l) / 2;  /* 从中点劈成两半 */
    merge_sort(a, l, m, tmp);
    merge_sort(a, m+1, r, tmp);  /* 左右两半先各自排好 */
    /* 合并两段有序数组:i、j 分别指向两段开头,像发牌一样比着拿 */
    int i=l, j=m+1, k=l;
    while (i<=m && j<=r) tmp[k++] = (a[i]<=a[j]) ? a[i++] : a[j++]; /* 谁小拿谁;<= 让相等时
                                          先拿左边,这就是「稳定」的来源 */
    while (i<=m) tmp[k++] = a[i++];  /* 一边拿完,另一边剩的直接抄走 */
    while (j<=r) tmp[k++] = a[j++];
    for (int x=l; x<=r; x++) a[x] = tmp[x]; /* 从 tmp 抄回原数组 */
}

/* 快排:Lomuto 分区,原地、平均 O(n log n) */
void quick_sort(int a[], int lo, int hi) {
    if (lo >= hi) return;         /* 0 或 1 个元素,不用排 */
    int pivot = a[hi], i = lo;     /* 末元素当基准;i 是「小于区」的下一个空位 */
    for (int j=lo; j<hi; j++)  /* j 从左到右检视每个元素 */
        if (a[j] < pivot)            /* 小的换到左区,i 右移扩大小于区 */
            swap(&a[i++], &a[j]);
    swap(&a[i], &a[hi]);            /* 基准落到 i:左边全小、右边全不小 */
    quick_sort(a, lo, i-1);        /* 基准已就位,递归排左右两区 */
    quick_sort(a, i+1, hi);
}

/* 堆排:原地建大顶堆再逐个弹到末尾 */
void heapify(int a[], int n, int i) {
    while (1) {                        /* 迭代下沉:不递归,额外空间 O(1) */
        int big=i, l=2*i+1, r=2*i+2; /* 左右孩子下标;big 记三者最大 */
        if (l<n && a[l]>a[big]) big=l;   /* 左孩子更大? */
        if (r<n && a[r]>a[big]) big=r;   /* 右孩子更大? */
        if (big==i) break;              /* 父已最大:停 */
        swap(&a[i], &a[big]); i=big;     /* 换下去,继续沉到被换的子树 */
    }
}
void heap_sort(int a[], int n) {
    for (int i=n/2-1; i>=0; i--) heapify(a, n, i); /* 从最后一个非叶子
                                       倒着逐个下沉,O(n) 建好大顶堆 */
    for (int i=n-1; i>0; i--) {     /* 堆顶(最大)换到末尾=就位 */
        swap(&a[0], &a[i]);
        heapify(a, i, 0);             /* 堆缩小到 i 个,新顶下沉修堆 */
    }
}

/* 计数排序:值域 [0,k],非比较 O(n+k)、稳定 */
void counting_sort(int a[], int n, int k) {
    int *cnt = calloc(k+1, sizeof(int)), *out = malloc(n*sizeof(int)); /* cnt 清零 */
    for (int i=0; i<n; i++) cnt[a[i]]++;          /* 值直接当下标:cnt[v]=v 出现几次 */
    for (int v=1; v<=k; v++) cnt[v] += cnt[v-1]; /* 前缀和:cnt[v]=「≤v 的个数」,
                                       正好是 v 这段在结果里的结束位置 */
    for (int i=n-1; i>=0; i--)               /* 从后往前放,相等元素保持原顺序(稳定)*/
        out[--cnt[a[i]]] = a[i];                     /* 先减一得下标,再放进去 */
    for (int i=0; i<n; i++) a[i] = out[i];  /* 抄回原数组 */
    free(cnt); free(out);
}
快排若固定取末元素作 pivot,在已经有序的数组上会退化成 O(n²)。生产实现必须用随机化 pivot 或三数取中,并对小区间切换插入排序——这正是标准库内省排序的做法。
用 qsort 时,比较函数永远不要写成 return a - b。对 {2000000000, -2000000000, 5, -7, 1500000000, -1500000000} 用 return *(int*)x - *(int*)y,排完的结果是 -7 1500000000 2000000000 -2000000000 -1500000000 5——完全乱掉,因为 2000000000 - (-2000000000) 在 int 里回绕成了 -294967296,比较器于是给出自相矛盾的大小关系。而且有符号溢出本身就是未定义行为,开优化后还可能更离奇。正确写法是 return (a > b) - (a < b);,只产出 −1/0/1,永不溢出。

三者一脉相承:递归是函数自己调自己,把大问题拆成同形的小问题;分治是把问题拆成几半各自解决再合并;回溯是在一棵「决策树」上一步步试探,走不通就退回来换一条路。

递归三要素

  • 出口(base case):小到不能再拆时直接返回——没有它就无限递归、栈溢出。
  • 递推关系:怎么用子问题的解拼出当前问题的解。
  • 缩小规模:每次调用都要更接近出口。

分治 vs 回溯

  • 分治:子问题互相独立,合并结果。归并排序、快排、二分都是分治。
  • 回溯:枚举所有可能,用「选择→递归→撤销」在决策树上深搜。全排列、组合、N 皇后、数独、子集。
/* 回溯:输出数组的全排列。
   前 start 个位置已定,本层只回答「谁来坐第 start 位」 */
void permute(int a[], int n, int start) {
    if (start == n) {              /* 出口:每个位置都定了,得到一种排列 */
        print(a, n);
        return;
    }
    for (int i = start; i < n; i++) { /* 剩下的元素轮流试坐 start 位 */
        swap(&a[start], &a[i]);   /* 选:把 a[i] 放到当前位 */
        permute(a, n, start + 1); /* 递归:定下一位 */
        swap(&a[start], &a[i]);   /* 撤销:换回来,别污染下一轮试探(回溯)*/
    }
}
递归爆栈没有异常、没有报错,就是一声 SIGSEGV。而这个阈值没有通用数字,只有公式:栈帧大小 × 递归深度 < 栈上限。在常见的 8 MB 栈(ulimit -s 可查)上二分一下,就能体会跨度有多大:只带一个 int 参数的最简递归能到几十万层;给每帧加一个 1 KB 的局部数组,几千层就崩了——差了两个数量级,而代码看上去几乎没变。连编译选项都算变量:同一个函数 -O0 会比 -O2 早崩好几倍,偏偏调试时用的就是先崩的那个。所以别按「点数」拍脑袋,要么按公式估,要么写个计数器你自己的环境。深度可能上 10⁵ 就该考虑改「显式栈 + 循环」。
回溯的骨架是「选择→递归→撤销」,其中「撤销」是关键:因为同一个状态变量在整棵决策树里反复复用,试完一条路必须把它恢复原样,否则残留的选择会污染下一条分支,跑出错误结果。想清「当前在做什么选择、什么时候算走到终点」,模板就自然浮现了。

每一步都选当下看起来最好的,选完不反悔。它简单又快,但只有满足特定条件时才真正得到全局最优——用之前得先证明「局部最优能拼出全局最优」。

两个前提

  • 贪心选择性质:每步的局部最优选择,不会堵死通往全局最优的路。
  • 最优子结构:大问题的最优解包含子问题的最优解。

经典应用

  • 区间调度(选最多不重叠活动)、找零(面额规整时)、哈夫曼编码、最小生成树(Kruskal / Prim)、Dijkstra。

贪心不总对

  • 硬币面额 {1,5,6,9} 凑 11:贪心拿 9+1+1=3 枚,最优其实是 5+6=2 枚。面额不规整时必须改用 DP。
/* 区间调度:选最多互不重叠的活动 */
/* 关键贪心:总选「结束最早」的,给后面留最多空间 */
int max_activities(Interval *a, int n) {
    sort_by_end(a, n);            /* 按结束时间升序排 */
    int count = 0, last_end = INT_MIN; /* last_end:已选活动占用到几点 */
    for (int i = 0; i < n; i++)  /* 按「结束早→晚」逐个考虑 */
        if (a[i].start >= last_end) {  /* 开始不早于上个结束=不冲突 */
            count++;                        /* 不冲突就要——这就是那步贪心 */
            last_end = a[i].end;      /* 更新占用到的时刻 */
        }
    return count;
}
别想当然地用贪心。能不能贪,要先证明或用反例检验:找不到反例、又能论证局部最优推出全局最优,才用贪心;否则老实上动态规划或穷举。
证明贪心正确太费劲时,有个更省事的办法:对拍。把贪心版和一个笨但一定对的版本(暴力枚举或 DP)都写出来,随机造几万组小规模数据跑一遍比结果——上面 {1,5,6,9} 凑 11 那个反例,就是这么在几秒内被抓出来的,靠脑补往往想不到。若几万组都一致,再回头找证明也更有底气。真要论证,通常落到两类套路之一:交换论证(把任一最优解一步步换成贪心解且不变差,区间调度按结束时间排就是这么证的)与拟阵(最小生成树属于这一类)。

从一个起点出发,像水波一样一圈圈向外确定到各点的最短距离。每次取当前距离最小、还没定的点,用它去「松弛」(更新)邻居的距离。要求边权非负——负权会让「已确定」的假设失效。

取最小点,松弛邻居

  • 朴素实现每轮线性找最小点,O(V²),稠密图够用。
  • 用优先队列(堆)取最小点,优化到 O((V+E) log V),稀疏图更快。
  • 「松弛」= 若 dist[u] + w(u,v) < dist[v] 就更新 dist[v]。

最短路算法怎么选

场景算法
无权图BFS
非负权、单源Dijkstra
含负权、单源Bellman-Ford
任意两点间(全源)Floyd-Warshall
/* Dijkstra 朴素版 O(V²),邻接矩阵 */
void dijkstra(int g[V][V], int src) {
    int dist[V], done[V] = {0};   /* dist 距离估计;done 是否已敲定 */
    for (int i=0; i<V; i++) dist[i] = INT_MAX; /* 先全设成「不可达」*/
    dist[src] = 0;                  /* 起点到自己距离 0 */
    for (int c=0; c<V-1; c++) { /* 每轮敲定一个点的最短距离 */
        /* 取未完成中距离最小的 u——它的 dist 不可能再变小了 */
        int u = -1;                 /* -1 表示还没选到候选 */
        for (int v=0; v<V; v++)
            if (!done[v] && (u<0 || dist[v]<dist[u])) u = v;
        done[u] = 1;                /* u 就此敲定,以后不再碰 */
        /* 松弛邻居:借道 u 若能更近,就更新 */
        for (int v=0; v<V; v++)
            if (g[u][v] && dist[u]!=INT_MAX &&   /* 有边且 u 可达 */
                dist[u]+g[u][v] < dist[v])            /* 经 u 中转确实更近 */
                dist[v] = dist[u] + g[u][v];
    }
}
除了「不能有负权」,上面这份朴素实现还有两个必须知道的约束。① 它用 g[u][v] 非零表示有边,于是权为 0 的真实边会被当成不存在——建 0 →(权0) 1 →(权3) 2 外加 0 →(权10) 2,跑出来 dist[1] 竟是 INT_MAX(判成不可达)、dist[2] 是 10 而非正确的 3。有零权边就必须另设 INF 哨兵表示「无边」。② 松弛里 dist[u] + g[u][v] 靠 dist[u] != INT_MAX 才没溢出;把 INF 换成别的大数又漏掉这个判断,相加会回绕成负数,反而被当成「更近」。
堆优化版把「线性扫描找最小点」换成从优先队列弹出——每次松弛成功就把 (新距离, 点) 塞进堆。这正是第 8 章「堆 / 优先队列」在算法里的头号应用。顺带一提:地图导航与路由协议 OSPF 跑的就是 Dijkstra(导航用加了启发式的变体 A*)——「非负权最短路」是真实基础设施每天在解的问题。

两个处理「依赖 / 连通」关系的实用工具。

拓扑排序(Kahn 算法)

  • 给有依赖的任务排一个能顺利执行的顺序:每条边 u→v 都让 u 排在 v 前面(先修课先上)。
  • 做法:反复取入度为 0(没有前置依赖)的点输出,并把它指向的点入度 −1。
  • 若最终输出点数 ≠ 总点数,说明有环——存在循环依赖,无法排序。
  • 真实世界:make 决定编译顺序、包管理器决定依赖安装顺序、电子表格决定公式重算顺序、打包器决定模块加载顺序——全是拓扑排序。
  • 下面代码里的 Graph/Edge 邻接表与 V(顶点数)沿用 09 章「图:怎么存」那卡的结构。

并查集(Union-Find)

  • 专门快速回答「这两个元素连通吗」以及「把两组合并」。
  • 路径压缩 + 按秩合并后,单次操作近乎 O(1) 摊还。
  • 用途:判断图连通、朋友圈 / 岛屿数量、Kruskal 最小生成树(合并前先查是否已连通以避免成环)。
/* 拓扑排序 Kahn:反复取入度为 0 的点 */
int topo_sort(Graph *g, int n, int order[]) {
    int indeg[V] = {0}, q[V], h=0, t=0, cnt=0; /* indeg 入度表;q 手写队列 */
    for (int u=0; u<n; u++)        /* 统计入度:数每个点被多少条边指着 */
        for (Edge *e=g->head[u]; e; e=e->next) indeg[e->to]++;
    for (int u=0; u<n; u++)
        if (!indeg[u]) q[t++] = u;   /* 入度 0=没有前置依赖,先入队 */
    while (h < t) {
        int u = q[h++]; order[cnt++] = u; /* 出队一个「可执行」的点,记入结果 */
        for (Edge *e=g->head[u]; e; e=e->next)
            if (--indeg[e->to] == 0) q[t++] = e->to; /* u 完成,后继的前置
                                          少一个;清零则它也就绪 */
    }
    return cnt == n;             /* 没输出完所有点=剩下的互相卡死=有环 */
}

/* 并查集:路径压缩 + 按秩合并 */
int parent[V], rnk[V];
/* 用之前必须初始化,否则全 0 = 所有点同属一集合,且不报任何错 */
void uf_init(int n) { for (int i=0; i<n; i++) { parent[i]=i; rnk[i]=0; } }

int find(int x) {
    if (parent[x] != x)              /* 自己不是根:继续往上找 */
        parent[x] = find(parent[x]); /* 路径压缩:沿途的点直接改挂到根下,
                                          下次再查一步到位 */
    return parent[x];
}

bool unite(int a, int b) {
    int ra = find(a), rb = find(b); /* 先找到两人各自的根 */
    if (ra == rb) return false;       /* 同根=早就连通,不用合 */
    if (rnk[ra] < rnk[rb]) { int t=ra; ra=rb; rb=t; } /* 换名字,保证 ra 是高的那棵 */
    parent[rb] = ra;                  /* 矮树挂到高树下,总高度不变 */
    if (rnk[ra] == rnk[rb]) rnk[ra]++; /* 一样高时合并,才会长高一层 */
    return true;
}
并查集最致命的坑是忘了初始化——上面的 uf_init 不是可选步骤。全局数组默认全 0,在这个未初始化状态下 find(3) 和 find(5) 都返回 0,所有点一上来就被判成同一个集合,unite 一律返回 false。程序不崩、不报错,只是连通性答案全错、最小生成树一条边都建不出来——这类「静默给出错误答案」的 bug 比崩溃难查得多。另外递归版 find 在还没被路径压缩过的长链上同样会爆栈——深度取决于链长,能撑多深没有通用数字——和本章「递归、分治与回溯」那卡说的是同一条:栈帧大小 × 深度 < 栈上限。
两者都顺手送你一个额外结论,别浪费。① Kahn 结尾的 cnt == n 是免费的环检测:输出不满说明有环,而且没被输出的那些点正好就是卡在环里的——报「循环依赖」时把它们列出来,比只说一句「检测到环」有用得多,包管理器和构建工具就是这么定位问题包的。② 并查集的 unite 返回 false 意味着两点本来就连通,也就是这条边会成环——Kruskal 建最小生成树正是靠这个免费判环,不必另跑一次搜索。

DP 的本质就一句话:把「会重复计算的子问题」的答案记下来复用,避免指数级重算。算 fib(5) 时 fib(2) 被反复算很多次——这就是「重叠子问题」,存起来就快了。

三要素

  • 状态:dp[i] 到底表示什么?(把它一句话说清是成败关键)
  • 转移方程:怎么用更小的状态推出当前状态?
  • 边界:最小状态的初始值是多少?

什么时候用 DP

  • 问「最优 / 计数 / 可行性」,且有重叠子问题 + 最优子结构 + 无后效性(当前决策只看状态、不管怎么到的)。

三步走

  • ① 先写记忆化递归(自顶向下,最好想);② 翻成递推表格(自底向上);③ 发现只依赖前一行就压缩空间(如背包压成一维)。

DP 在真实软件里

  • git diff 要找「两个文件的最小改动」,核心就是 LCS / 编辑距离一族的算法(Myers 差分)——下面的 LCS 代码就是它的原型。
  • 拼写纠错、模糊搜索、DNA 序列比对,都靠编辑距离衡量「两个串差多远」。
  • 背包是一切「预算有限、收益最大」决策的原型:广告投放、云资源分配、装箱调度。
  • 输入法与语音识别的 Viterbi 解码、正则引擎的匹配也是 DP。所以学 DP 不是背题型,而是练「识别重叠子问题、把状态一句话定义清楚」这个建模动作。
/* 记忆化:斐波那契,重叠子问题只算一次 */
long long memo[100];  /* 0 表示还没算过 */
long long fib(int n) {
    if (n < 2) return n;          /* 边界 */
    if (memo[n]) return memo[n];   /* 命中缓存,直接返回 */
    return memo[n] = fib(n-1) + fib(n-2); /* 算完顺手存进缓存再返回 */
}

/* 0/1 背包:一维滚动数组,容量逆序 */
int knapsack(int w[], int v[], int n, int W) {
    int dp[W+1];               /* dp[j]:容量 j 能装出的最大价值 */
    for (int j=0; j<=W; j++) dp[j] = 0; /* 边界:什么都不装=0 */
    for (int i=0; i<n; i++)   /* 逐个物品考虑「装不装」 */
        for (int j=W; j>=w[i]; j--)   /* 容量逆序扫:保证 dp[j-w[i]]
                                        还是「没装过 i」的旧值,防重复装 */
            if (dp[j-w[i]] + v[i] > dp[j])   /* 腾出 w[i] 再装 i,更划算? */
                dp[j] = dp[j-w[i]] + v[i];         /* 划算就装 */
    return dp[W];
}

/* 最长公共子序列 LCS:二维表,O(mn) */
int lcs(const char *a, const char *b) {
    int m = strlen(a), n = strlen(b);
    int dp[m+1][n+1];       /* dp[i][j]:a 前 i 个 vs b 前 j 个的 LCS 长 */
    for (int i=0; i<=m; i++)
        for (int j=0; j<=n; j++) {
            if (i==0 || j==0) dp[i][j] = 0;  /* 边界:空串和谁比都是 0 */
            else if (a[i-1] == b[j-1])   /* 末字符相同(dp 下标比串下标多 1)*/
                dp[i][j] = dp[i-1][j-1] + 1; /* 各退一格的答案 +1 */
            else                            /* 不同:丢掉 a 或 b 的末字符,取大 */
                dp[i][j] = dp[i-1][j] > dp[i][j-1]
                         ? dp[i-1][j] : dp[i][j-1];
        }
    return dp[m][n];
}
两个「不报错但答案错」的经典坑。① 一维背包的内层循环方向:上面写的是 j 从 W 递减,改成递增就悄悄变成完全背包——物品 w = {2,3,4}、v = {3,4,5}、W = 6,逆序得 8(0/1,选 2 和 4),正序得 9(把第一个物品装了三次)。逆序才能保证 dp[j-w[i]] 还是「没装过 i」的旧值。② 二维 DP 表用 VLA 放栈上:上面 lcs 里的 int dp[m+1][n+1] 是变长数组,8 MB 栈下两个 1400 字符的串还能跑,2000 字符就直接 SIGSEGV——真拿它 diff 文件必然崩。表一大就改 malloc,或压成滚动的一维数组。
画出状态转移图往往比直接写代码更快理清思路——把状态之间的依赖关系画成箭头,转移方程和填表顺序常常一目了然。

工程实践

怎么知道程序是对的:编译器警告开关(有一处几乎所有人都想错)、sanitizer、gdb 与 core dump、测试与覆盖率,以及「先测量再优化」的纪律。C 没有官方测试框架也没有包管理器,这些能力都得自己接上。

C 不替你检查,但检查工具一应俱全,只是默认关着。这一章就是把它们一次打开。

警告与优化开关

开关作用
-Wall -Wextra及格线;但在 -O0 下很多检查跑不起来——有些警告依赖优化期的数据流分析
-Wshadow / -Wconversion / -Wformat=2变量遮蔽 / 隐式窄化 / 格式串更严
-Werror把警告变成错误——新项目一开始就该开,老项目按文件逐步开
-fanalyzer(gcc)静态分析:能查出跨函数的空指针、double free、泄漏
-O0 / -Og / -O2 / -O3 / -Os不优化 / 优化但保持可调试 / 常用发布档 / 更激进(不一定更快)/ 优化体积
-g / -g3调试信息 / 连宏定义一起留下
-flto / -march=native跨文件优化 / 针对本机 CPU(产物不可移植)
用两个编译器各编一遍gcc 与 clang 的警告集合不同,互相能补出一批

Sanitizer 与 Valgrind

工具开法抓什么
ASan-fsanitize=address -g -O1堆 / 栈 / 全局越界、use-after-free、double free、以及泄漏
UBSan-fsanitize=undefined(配 UBSAN_OPTIONS=print_stacktrace=1)有符号溢出、移位越界、空指针解引用、未对齐、枚举越界
TSan-fsanitize=thread数据竞争;不能与 ASan 同时开
MSan(clang)-fsanitize=memory读未初始化的内存;要求所有依赖也用它编,门槛较高
Valgrindvalgrind --leak-check=full --track-origins=yes ./p类似 ASan,不用重新编译但慢十几倍;与 sanitizer 二选一
怎么读报告第一段是出错位置,第二段是这块内存在哪分配 / 释放的——两段结合基本就能定位
常规做法开发与 CI 里一直挂着 ASan + UBSan,发布构建去掉

gdb 与 core dump

命令作用
gdb ./p → run 参数 → bt跑起来,崩了看调用栈
frame N / info locals / p 表达式切帧 / 看局部变量 / 打印值
break 文件:行 / break f if x > 10 / watch 变量断点 / 条件断点 / 变量被改时停下
next step finish continue单步(不进函数)/ 进函数 / 跑完本函数 / 继续
gdb -p 进程号挂到卡住的进程上看它停在哪
core dumpulimit -c unlimited 打开;产物位置看 /proc/sys/kernel/core_pattern;事后 gdb ./p core 直接回到崩溃现场
前提编译带 -g,调试期用 -O0 或 -Og——高优化下变量会显示为 <optimized out>

测试、覆盖率与 CI

做法说明
C 没有标准测试框架四十行宏就能写一个够用的:记录用例、比较期望值、统计通过 / 失败并用退出码返回
assert 不是测试它在 -DNDEBUG 下会被整条删掉,而且第一个失败就终止进程——测试要跑完全部并汇总
接进构建Makefile 里加 test: 目标跑测试二进制;失败要返回非 0,CI 才看得懂(01 章)
覆盖率gcc --coverage 编译并运行后用 gcov / lcov 看;行覆盖会骗人,要看分支覆盖
CIGitHub Actions 里跑「构建 + 测试 + ASan 构建再跑一遍」三件事就够用
测什么边界与出错路径:空输入、长度为 0、越界、malloc 失败、非法参数——bug 基本都住在这里

怎么计时(先测量再优化)

规则说明
用对时钟clock_gettime(CLOCK_MONOTONIC, …)——clock() 量的是 CPU 时间、time() 只有秒
编译器可能把你要测的代码删了结果没人用就是死代码;把结果累加并打印出来,或写进 volatile 变量
别在 -O0 下测那不是你的程序将来的样子
多跑几次取中位数;第一次通常最慢(缓存与页错误)
先定位再优化perf stat ./p 看整体,perf record / perf report 或 valgrind --tool=callgrind 找热点
顺序算法复杂度 → 数据结构与内存访问模式 → 编译选项 → 才是微优化(10、09 章)
「优化」在 C 里最常见的失败姿势,是省掉了测量。没有数字就无法证明改好了,而 C 的直觉尤其不可靠:编译器可能已经把你手写的「优化」还原掉,也可能因为你的改动放弃了内联。三个问题答不上来就别动手——现在多慢?目标多快?这段占总耗时的百分之几?
一份值得长期用的开发档开关:-std=c23 -Wall -Wextra -Werror -g -Og -fsanitize=address,undefined。发布档换成 -O2 并去掉 sanitizer。两套配置都写进 Makefile 的不同目标里,别每次手敲。
规范:名字怎么起

C 和 C++ 一样没有官方命名规范,但传统上统一得多——标准库(malloc / strlen)、Linux 内核、POSIX 清一色 snake_case。规则还是那两条:一致,并服从既有代码库。

主流约定

  • 变量 / 函数 / 结构体标签:snake_case(read_line、struct file_header);
  • 类型 / typedef:snake_case,常带 _t 后缀(有保留风险,见下);
  • 宏 / 常量 / 枚举值:UPPER_CASE(#define MAX_LEN 256、COLOR_RED);
  • 文件:源 .c、头 .h,文件名 snake_case。

C 没有 namespace:用前缀当伪命名空间

  • 模块名做前缀,把一组相关函数 / 类型归拢:list_init / list_push / list_free;
  • 这是 C 库避免全局符号冲突的标准做法——看 SQLite 的 sqlite3_、libcurl 的 curl_。公开 API 一律带库名前缀,否则多个库链到一起极易撞车。

别踩保留名

  • _Foo(下划线 + 大写)、含双下划线的名字在任何位置都保留给实现;全局的 _foo 也保留——撞名是 UB;
  • 慎用 _t 后缀:POSIX 把它保留了,你的 widget_t 可能和未来标准类型撞名,严格的代码库会避开、改用别的后缀或前缀。
/* 传统 C:全 snake_case + 模块前缀当命名空间 */
typedef struct {
    char  *data;
    size_t len;
} str_buf;                    /* 类型:snake_case(_t 后缀有保留风险,见下)*/

void   str_buf_init(str_buf *b);        /* 模块前缀 str_buf_ 归拢一组函数 */
size_t str_buf_len(const str_buf *b);

#define STR_BUF_MIN 16       /* 宏 / 常量:UPPER_CASE */
enum { COLOR_RED, COLOR_GREEN };  /* 枚举值:UPPER_CASE */

/* 保留名——别自己起:
     _Foo   my__var   → 任何位置保留给实现
     strXxx / memXxx  → <string.h> 保留的前缀 */
两个 C 特有的坑:① 标识符别以「下划线 + 大写」或双下划线开头(_Buf、__len)——保留给实现(编译器内部、关键字扩展就用它们),撞名是 UB;下划线放名字中间 / 结尾才安全。② 慎给自定义类型加 _t:<stdint.h> / POSIX 保留了 _t 结尾,widget_t 有朝一日可能和标准类型撞名——稳妥就不加,或换个后缀。
团队别为大小写吵架——抄一份成熟规范(Linux kernel style / GNU coding standards)用 clang-format 强制即可。C 特有的一条最重要:用模块名前缀替代 namespace,公开符号一律带库名前缀(mylib_),否则多库链接时全局符号撞车,报的还是一头雾水的 multiple definition。
测试:怎么知道它是对的

别的语言到这一步是「装个测试框架」,C 没有这一步:ISO C 标准库里没有任何测试设施,也没有一个像 pytest、JUnit 那样的事实标准。好消息是——一个够用的 C 测试框架短到你可以在半小时内自己写完,而且写一遍就能看懂那些大框架到底在做什么。

先分清:assert 不是测试

  • assert 是开发期的内部一致性检查,断言「这里绝不该发生」。它在 -DNDEBUG 下整条语句消失——包括你写在里面的副作用。
  • 测试是外部的行为验证:喂进已知输入、比对已知输出、失败了要继续跑完其余用例并汇总,而不是第一声就 abort。
  • 所以 assert(add(2,3) == 5) 当测试用是不合格的:第一个失败就整个进程死掉,你看不到还有几个坏了;发布构建里它还会被整个优化掉。

一个测试框架真正需要的只有三件事

  • 比较并记录失败,而不是终止——右边的 ASSERT_EQ 用 __FILE__/__LINE__ 定位,用 #expr 把表达式原文印出来。
  • 汇总:跑了几个、挂了几个。
  • 用退出码汇报:全过返回 0,有失败返回非 0。这一条是它能接进 make 和 CI 的全部前提——脚本读不懂你打印的文字,只读退出码(02 章「命令行参数与 main」讲过这件事)。

跑起来长这样

三个用例故意让第三个失败:

ok   test_add
ok   test_mul
  FAIL minitest.c:35: 42 == mul(6, 6)
       expected: 42
       actual:   36
FAIL test_mul_bad

3 tests, 1 failed
  • 关键是它没有停在第三个上,而是跑完、汇总、然后用退出码 1 告诉外面「有问题」。
  • 要写多少测试才够?起步的判据很实在:每修一个 bug,先写一个能复现它的测试。这样测试集就是你踩过的坑的集合,而不是为了凑覆盖率写出来的样子货。

什么时候该换成现成的

  • 自己写的这套顶得住相当长一段时间。真正让人想换的是这几件:需要 setup/teardown、需要参数化同一组用例、需要把崩溃的用例隔离在子进程里(这样一个段错误不会带走整轮测试)、需要标准格式报告好接 CI。
  • 常见选择:Unity(极轻,嵌入式圈的常客)、Check(每个用例 fork 到独立进程,最能扛崩溃)、cmocka(自带 mock 与内存泄漏检查)、Criterion(自动注册用例,不用手写 RUN_TEST 列表)。
/* minitest.c —— 够用的最小测试框架 */
#include <stdio.h>

static int tests_run = 0, tests_failed = 0;

/* 记录失败并返回,不终止进程;#expected 把表达式原文变成字符串 */
#define ASSERT_EQ(expected, actual)                                     \
    do {                                                                \
        long _e = (long)(expected), _a = (long)(actual);                \
        if (_e != _a) {                                                 \
            printf("  FAIL %s:%d: %s == %s\n", __FILE__, __LINE__,      \
                   #expected, #actual);                                 \
            printf("       expected: %ld\n       actual:   %ld\n", _e, _a); \
            tests_failed++;                                             \
            return;                                                     \
        }                                                               \
    } while (0)

#define RUN_TEST(fn)                                                    \
    do {                                                                \
        int _before = tests_failed;                                     \
        tests_run++;                                                    \
        fn();                                                           \
        printf("%s %s\n", tests_failed == _before ? "ok  " : "FAIL",   \
               #fn);                                                    \
    } while (0)

/* 被测代码 */
static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }

/* 用例必须是 void():ASSERT_EQ 里那个 return 要求如此 */
static void test_add(void)     { ASSERT_EQ(5, add(2, 3)); }
static void test_mul(void)     { ASSERT_EQ(6, mul(2, 3)); }
static void test_mul_bad(void) { ASSERT_EQ(42, mul(6, 6)); }

int main(void) {
    RUN_TEST(test_add);
    RUN_TEST(test_mul);
    RUN_TEST(test_mul_bad);
    printf("\n%d tests, %d failed\n", tests_run, tests_failed);
    return tests_failed != 0;   /* 有失败就非 0——CI 全靠这个 */
}
宏定义里的多行续行符和分号是重灾区。上面的 ASSERT_EQ 用 do { ... } while (0) 包住,不是装饰——不这么写,if (x) ASSERT_EQ(a, b); else ... 会因为宏展开成多条语句而编译错误或者绑错分支(04 章「宏与预处理器」讲过这个惯用法)。另外 ASSERT_EQ 里把两边都转成 long 比较是个刻意的简化:它比不了浮点(要用误差范围)、比不了指针和字符串(char* 比的是地址不是内容,要单独写 ASSERT_STR_EQ 调 strcmp)。用它比字符串是最常见的一个假通过。
写测试最大的阻力从来不是框架,是被测代码没法被测。一个函数如果同时做「算」和「读文件 / 打日志 / 发网络包」,你就没法只验证「算」的部分。把纯计算和副作用分开——纯函数收参数返结果,副作用留在调用方——代码立刻变得可测,而且这个拆分本身就会让设计变清楚。这也是为什么「难写测试」通常是设计问题的信号,而不是测试工具的问题。

测试只有自动跑才有价值——靠人记得跑的测试,等于没有。把本章前面那个测试二进制接进 make、量出覆盖率、再挂到 CI 上。

接进 make:靠的还是退出码

  • make test 的全部机制就是「跑一条命令,命令返回非 0 就整个 make 失败」。所以本章前面最后那句 return tests_failed != 0; 是这一切的地基。
  • 一个容易写错的细节:测试二进制退出 1,但 make 自己退出 2——make 用自己的码汇报「配方失败」,不会透传。两者都非 0,CI 都能抓到,但别在文档里写死「make test 返回 1」。

覆盖率:行覆盖会骗你,分支覆盖不会

  • gcc 自带 --coverage,配 gcov 就能出报告,不用装东西。编译时加 --coverage,跑一遍程序,再 gcov 源文件.c。
  • 报告里 ##### 标记的就是一次都没执行到的行:
            1:    4:    if (n > 0) {
            1:    5:        return 1;
            -:    6:    } else {
        #####:    7:        return -1;      <- 从没跑到
  • 关键在这里:同一份代码,行覆盖率报 85.71%,看着挺高;加 gcov -b 看分支,「至少走过一次」的分支只有 50%——一个 if 你只测了成立的那一支,行数上却已经算「覆盖」了。只看行覆盖率是自我安慰,要看就看分支。
  • 覆盖率的正确用法是找没测到的地方,不是拿去当 KPI。为凑数字写的测试不验证任何行为,只会让报告变好看。

挂到 CI:一个够用的 GitHub Actions

  • C 项目的 CI 最小有用集是三件:编译零警告、测试全过、sanitizer 下再跑一遍测试。第三件最值钱——很多内存 bug 只在 sanitizer 下才现形(下一组卡的主题)。
  • 右边的 workflow 里 actions/checkout 写的是 v7。网上大量教程停留在 v4/v5,照抄旧版本号不会立刻报错,但会拿到过时的 Node 运行时并可能触发弃用警告——用之前去仓库的 releases 页确认当前主版本。
  • ubuntu-latest 跟着 GitHub 走,不等于最新的 Ubuntu 版本(新版通常要显式写版本名才能用)。同理,别假设 runner 上的 gcc 是哪个版本——要确定就在 workflow 里先跑一句 gcc --version 打出来,或者干脆用容器锁死。
# ---------- Makefile ----------
CC     = gcc
CFLAGS = -Wall -Wextra -std=c23 -g

.PHONY: test coverage clean

test: minitest
	./minitest                   # 非 0 则 make 失败

minitest: minitest.c
	$(CC) $(CFLAGS) -o $@ $<

coverage: minitest.c
	$(CC) --coverage -O0 -o cov $<
	./cov || true                # 有失败也要出报告
	gcov -b minitest.c           # -b:连分支覆盖一起

clean:
	rm -f minitest cov *.o *.gcda *.gcno *.gcov

# ---------- .github/workflows/ci.yml ----------
name: ci
on: [push, pull_request]
jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7      # 用前确认当前主版本
      - run: gcc --version             # 别假设,打出来
      - name: 编译必须零警告
        run: gcc -Wall -Wextra -Werror -O2 -c minitest.c -o /dev/null
      - name: 跑测试
        run: make test
      - name: sanitizer 下再跑一遍
        run: |
          gcc -g -fsanitize=address,undefined -fno-sanitize-recover=all \
              -o t_asan minitest.c
          ./t_asan
把覆盖率数字当目标,是这里最容易走歪的地方。「行覆盖 85%」和「代码是对的」之间没有因果关系:上面那个例子 85.71% 的行覆盖背后是只走过一半的分支,而分支才是 bug 藏身的地方。更极端的是,一个只调用函数、不检查任何返回值的「测试」能把覆盖率刷得很高,却一个 bug 都抓不到。
另外 --coverage 编出来的二进制不要拿去测性能,也别发布:它在每个基本块里插了计数器,还会在退出时写 .gcda 文件。
CI 里那句 -fno-sanitize-recover=all 不是可选的:UBSan 默认只打印一行然后让程序继续跑完,退出码仍然是 0。不加这个标志,CI 会在满屏 runtime error: 的情况下显示绿色通过——下一组卡会把这个坑单独摊开讲。
让 bug 自己现形

「我开了 -Wall -Wextra」几乎是 C 社区的标准答案。但-Wall -Wextra 并不是一组固定的检查——gcc 相当一部分警告是建立在优化器的数据流分析之上的,而 -O0 根本不构建那些信息。结果就是:你在调试构建里看到的零警告,什么都不能证明。

同一份代码,同样的 -Wall -Wextra,只差一个 -O

一段塞了四个真 bug 的代码(数组越界写、变量未初始化就用、malloc 之后泄漏、scanf 不查返回值):

gcc -Wall -Wextra -O0 -c buggy.c    ->  0 条警告
gcc -Wall -Wextra -O2 -c buggy.c    ->  4 条警告

-O2 报出来的是:

warning: ignoring return value of ‘scanf’ [-Wunused-result]
warning: iteration 5 invokes undefined behavior [-Waggressive-loop-optimizations]
warning: array subscript 5 is above array bounds of ‘int[5]’ [-Warray-bounds=]
warning: ‘total’ is used uninitialized [-Wuninitialized]
  • 四个 bug 里的三个,只有开了优化才看得见。而大多数人的日常构建正是 -g -O0。
  • 所以纪律是:调试用 -O0 -g,但检查要另跑一遍 -O2(哪怕只是 -c 编译不链接、丢掉产物)。CI 里加一条 -O2 -Werror 的编译步骤,成本几秒,收益是上面这四条。

-fanalyzer:gcc 自带的静态分析器

  • gcc 从 10 版起内置 -fanalyzer,做的是跨语句的路径敏感分析,能抓到普通警告抓不到的东西——上面那个连 -O2 都漏掉的内存泄漏,它抓到了:
    warning: leak of ‘buf’ [CWE-401] [-Wanalyzer-malloc-leak]
    warning: dereference of possibly-NULL ‘buf’ [CWE-690]
    warning: use of uninitialized value ‘total’ [CWE-457]
  • 它还会打印完整的事件路径(「分配在这里 → 经过这个分支 → 在这里离开作用域」),比一行警告有用得多。
  • 代价是慢,而且在大型代码上误报不少。合理用法:不进日常构建,进 CI 或者定期扫一遍。

还值得加的几个警告

  • -Wshadow(内层变量遮蔽外层,改错对象的经典来源)、-Wconversion(隐式窄化,配 02 章「整数提升与隐式转换」看)、-Wwrite-strings(改字符串字面量,02 章那张卡专门提过它默认不报)、-Wvla(禁掉变长数组)、-Wformat=2(格式串更严)。
  • -Werror 把警告升级成错误:在 CI 上开,在本地开发时别开——本地开着它,改到一半连编译都过不了,很快就会有人去删警告而不是修 bug。
# 日常开发:能调试,跑得快
gcc -g -O0 -Wall -Wextra -o app *.c

# 检查专用:不要产物,只要警告(几秒钟的事)
gcc -O2 -Wall -Wextra -Wshadow -Wconversion -Wformat=2 \
    -fsyntax-only *.c

# 静态分析:慢,但能抓跨语句的问题(泄漏、空指针路径)
gcc -fanalyzer -c *.c -o /dev/null

# CI:警告即失败
gcc -O2 -Wall -Wextra -Werror -c *.c -o /dev/null

# 想知道某条警告到底归哪个开关管,看方括号里那个名字:
#   warning: ... [-Warray-bounds=]   -> 就是 -Warray-bounds
# 想单独关掉某条:-Wno-那个名字
「本地零警告」和「代码没问题」是两回事,上面那 4 : 0 的对比就是证据。反过来也要小心:-O2 下多出来的警告里,-Waggressive-loop-optimizations 这类的潜台词是「我发现这里有 UB,于是我按 UB 来优化了」——编译器不是在提醒你有个小瑕疵,是在告诉你它已经据此改变了你的代码。这类警告必须当错误对待,别用 -Wno- 关掉了事。
-fsyntax-only 是「只检查不产出」的快捷方式,比编译再删产物干净。不过要注意它不做优化,所以拿不到上面那些依赖数据流的警告——要那些就得老老实实 -O2 -c 一遍(产物丢到 /dev/null)。把这两条和 -fanalyzer 一起塞进 Makefile 的一个 check 目标里,比记在脑子里靠谱。

C 最难查的 bug 有一个共同点:它们不在出错的地方崩。越界写会安静地改掉隔壁的变量,释放后使用可能在几百次调用之后才发作,数据竞争则是每次跑结果都不一样。sanitizer 的作用就是把这些「延迟发作」变成「当场崩溃并打印出案发现场」——它是过去十年 C/C++ 工程里收益最高的单项工具,没有之一。

三个,各管一摊

  • ASan(-fsanitize=address):堆/栈越界、释放后使用、重复释放、内存泄漏。日常最常用的一个。
  • UBSan(-fsanitize=undefined):有符号溢出、移位超宽、空指针解引用、对齐错误等未定义行为。
  • TSan(-fsanitize=thread):数据竞争。不能和 ASan 同时开,要分两轮跑。
  • 都要求重新编译(链接时也要带同样的标志),并且都建议配 -g,否则报告里没有行号。

报告怎么读:三段式

ASan 的越界报告,关键就三段——什么错、在哪出的、这块内存哪来的:

==935==ERROR: AddressSanitizer: heap-buffer-overflow ...
WRITE of size 4 at 0x... thread T0
    #0 ... in main hbo.c:5              <- 出事的那一行
0x... is located 0 bytes after 32-byte region [...]   <- 越过了右边界
allocated by thread T0 here:
    #0 ... in malloc
    #1 ... in main hbo.c:4              <- 这块内存是这里分配的
SUMMARY: AddressSanitizer: heap-buffer-overflow hbo.c:5 in main
  • 释放后使用是四段——多一段「freed by thread T0 here」告诉你是谁先把它释放的,措辞是 heap-use-after-free(带 heap- 前缀)。
  • 泄漏报告有个容易看花眼的地方:ERROR 行署名是 LeakSanitizer,SUMMARY 行却署名 AddressSanitizer。两个都对,是同一个工具的两个部件。
  • TSan 的报告则会给出两条冲突的访问加各自线程的创建位置。一个无锁的 counter++ 通常会被报成两条竞争(读-写各一条)——因为它本来就是「读、改、写」三步,这一点 04 章「线程」那卡讲过。

三个真实的坑

  • ① UBSan 默认不中止,退出码是 0。它打印一行 runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int',然后程序照常跑完、照常返回 0。CI 里只看退出码就会全部漏掉。加 -fno-sanitize-recover=all,让它一发现就退出非 0。ASan 和 TSan 没这个问题(都非 0 退出)。
  • ② ASan 和 Valgrind 不能一起用,而且失败得很像成功。拿 valgrind 去跑 ASan 编出来的程序,会看到 ASan runtime does not come first in initial library list,然后 valgrind 报告 0 allocs, 0 errors——看起来干干净净通过了,实际上那个 bug 两边都没检查。要用 valgrind 就用普通编译的二进制。
  • ③ 别拿 sanitizer 构建去测性能或发布。时间大约慢一倍上下、内存大约涨一到三倍(取决于小对象有多密集,ASan 要给每块分配加红区)——都是个位数倍,不是天塌下来,但足以让性能数字失去意义。
# 日常最常用的组合:地址 + UB 一起开,发现即退出
gcc -g -O1 -fsanitize=address,undefined -fno-sanitize-recover=all \
    -o app *.c
./app

# 数据竞争单独一轮(TSan 不能和 ASan 同时开)
gcc -g -O1 -fsanitize=thread -o app_tsan *.c
./app_tsan

# 泄漏检查默认随 ASan 开启;只想要泄漏检查可以单开
gcc -g -fsanitize=leak -o app_leak *.c

# 让 ASan 在报错时直接给出更多上下文
ASAN_OPTIONS=abort_on_error=1:detect_leaks=1 ./app

# 没有 sanitizer 的场合(比如要检查未初始化读),valgrind 仍然有用
# 注意:必须用【普通编译】的二进制,不能是 ASan 编的
gcc -g -O0 -o app_plain *.c
valgrind --leak-check=full --track-origins=yes ./app_plain
sanitizer 只能发现「真的执行到了」的错误。它是运行时工具,不是静态分析——一条从没被走到的分支里藏着的越界,ASan 一个字都不会说。这正是它和本章前面 -fanalyzer 的分工:静态分析看所有路径但会误报,sanitizer 零误报但只看走过的路径。两个都要,而让 sanitizer 走到更多路径的办法,就是写更多测试。
还有一条常被忽略:链接时也必须带 -fsanitize=...。只在编译阶段加、链接时漏掉,会得到一堆 undefined reference to '__asan_...'。
把 sanitizer 接到测试上,收益翻倍。单独跑 sanitizer 只能检查你这次手动走到的路径;而用 sanitizer 编译整个测试套件再跑一遍,等于让每一个用例都顺带做一次内存检查——这是前面「把测试接进 CI」那一卡里第三个步骤的全部意义。很多项目的 CI 就是两轮:一轮普通编译跑测试(快),一轮 ASan+UBSan 跑同一套测试(慢但深)。
崩了怎么查

段错误只给你一行 Segmentation fault (core dumped),什么线索都没有。gdb 的作用是把这一行变成「哪个函数、哪一行、当时各个变量是什么值」。你不需要学会 gdb 的全部——下面这五个命令能解决绝大多数崩溃。

前提:编译时带上 -g -O0

  • -g 生成调试信息(行号、变量名),不影响运行速度、也不改变代码,只是让可执行文件变大。发布版要去掉可以用 strip。
  • -O0 关闭优化。优化过的代码里变量可能被寄存器化、函数被内联、语句被重排,gdb 会频繁告诉你 <optimized out> 或者行号乱跳。调试期一律 -O0。

一次完整的定位

批处理模式一条命令跑完(不用进交互界面):

gcc -g -O0 -o segv segv.c
gdb -batch -ex run -ex bt -ex 'frame 1' -ex 'info locals' ./segv

输出(省去 gdb 的开场白):

Program received signal SIGSEGV, Segmentation fault.
0x000055555555518b in sum_node (p=0x0, bias=7) at segv.c:7
7	    return p[0] + local_a + local_b;

#0  0x... in sum_node (p=0x0, bias=7) at segv.c:7
#1  0x... in compute (bias=7) at segv.c:13
#2  0x... in main () at segv.c:18

#1  0x... in compute (bias=7) at segv.c:13
13	    return sum_node(ptr, bias) + depth;
ptr = 0x0
depth = 3
  • 第一段直接给出崩在哪一行,而且参数值就印在函数名后面:p=0x0——空指针,一眼定案。
  • bt(backtrace)给出调用链,从崩溃点 #0 一路回到 main。真正的错误往往不在 #0,而在上面某一层——#0 只是「用了那个坏值的地方」。
  • 所以接着 frame 1 跳到调用者那一层,info locals 打印它的局部变量:ptr = 0x0。坏值是在这一层产生的,该修的是这里。

够用的命令表

  • run / r——开跑(要传参数就 run 参数1 参数2)。
  • bt——调用栈。bt full 连每层的局部变量一起打。
  • frame N / f N——切到第 N 层;up / down 上下移动。
  • print 表达式 / p——打印变量,能算表达式:p arr[i]、p *node、p i*2。
  • break 文件:行号 / b——下断点;b func if x == 0 是条件断点,循环里只在出问题的那次停下来,比按一万次 next 强。
  • next(跳过函数调用)/ step(进入函数)/ continue。
  • watch 变量——变量被改写时中断。查「这个值是什么时候变脏的」几乎只能靠它。
# 编译:调试信息 + 关优化
gcc -g -O0 -o app app.c

# 交互式
gdb ./app
  (gdb) run                    # 跑到崩
  (gdb) bt                     # 调用栈:错误常在上层,不在 #0
  (gdb) frame 1                # 切到调用者
  (gdb) info locals            # 这一层的局部变量
  (gdb) print ptr              # 单看某个值
  (gdb) break util.c:42        # 下断点
  (gdb) break foo if n == 0    # 只在 n==0 那次停
  (gdb) watch total            # total 被改写时中断
  (gdb) continue

# 批处理:不进交互界面,一条命令出结论(适合脚本和 CI)
gdb -batch -ex run -ex bt -ex 'info locals' ./app

# 给崩溃的程序传参数
gdb -batch -ex 'run input.txt' -ex bt --args ./app input.txt
-O2 下调试是在浪费时间。优化后变量常被 gdb 报成 <optimized out>、小函数被内联后在 bt 里整层消失、单步时行号来回跳。看到这些现象不是 gdb 坏了,是你忘了换成 -O0。
反过来也有个陷阱:有些 bug 只在 -O2 下复现(通常意味着代码里有 UB,编译器按 UB 优化了)。这时不要硬在 -O2 下用 gdb 硬啃,改用 UBSan 去定位根因——那才是对症的工具。
崩溃前的 printf 没打出来,不代表没执行到那一行。stdout 在重定向到文件或管道时是全缓冲的,进程崩溃时缓冲区里的内容全部丢失——这一点 02 章「字符流、EOF 与缓冲」讲过,而它在调试崩溃时尤其容易骗人:你会以为程序没走到那里,于是往错误的方向找。调试输出一律走 fprintf(stderr, ...)(不缓冲),或者在关键点补 fflush(stdout)。更好的办法是别用 printf 调试,直接上 gdb。

gdb 要求你能重现崩溃。可线上那次崩溃、半夜跑批时那次崩溃,你没法重现——core 文件就是崩溃瞬间的完整内存快照,事后用 gdb 打开它,能拿到和当场调试几乎一样的信息:调用栈、每一层的变量值。

默认是关的,先把它打开

  • ulimit -c 查当前允许的 core 文件大小,绝大多数发行版默认是 0——也就是根本不生成。ulimit -c unlimited 打开(只对当前 shell 及其子进程有效)。
  • 生成到哪、叫什么名,由 /proc/sys/kernel/core_pattern 决定。如果它是一个裸文件名(比如 core),就生成在程序的工作目录;配上 core_uses_pid=1 会带上进程号,成为 core.1234。
  • 如果 core_pattern 以 | 开头,说明被某个守护进程接管了(常见的是 systemd-coredump、apport)——这时 core 不在当前目录,要用 coredumpctl list / coredumpctl gdb 去取。先看这个文件,再去找 core,能省很多困惑。

用起来

ulimit -c unlimited
./segv                       // 崩了,生成 core.701

gdb -batch -ex bt -ex 'frame 1' -ex 'info locals' ./segv core.701

输出和当场调试基本一致:

Core was generated by `./segv'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x... in sum_node (p=0x0, bias=7) at segv.c:7
#1  0x... in compute (bias=7) at segv.c:13
#2  0x... in main () at segv.c:18
ptr = 0x0
depth = 3
  • 注意命令要同时给出可执行文件和 core 文件:core 里只有内存和寄存器,符号和行号得从那个二进制里读。
  • 推论:拿去调试的二进制必须和产生 core 的那个完全一致(同一次编译的产物)。重新编译过、或者 strip 过,行号就对不上了。生产环境的做法是发布 strip 过的版本,同时留一份带调试符号的原件。
# 1. 打开(默认是 0,即不生成)
ulimit -c unlimited
ulimit -c                       # 确认已生效

# 2. 看 core 会去哪里、叫什么
cat /proc/sys/kernel/core_pattern
cat /proc/sys/kernel/core_uses_pid
#   输出是裸名字(如 core)    -> 生成在程序的工作目录
#   输出以 | 开头              -> 被守护进程接管,用 coredumpctl 取

# 3. 崩溃后用 gdb 打开:可执行文件 + core 文件,缺一不可
gdb ./app core.1234
  (gdb) bt
  (gdb) frame 1
  (gdb) info locals

# 批处理版
gdb -batch -ex bt -ex 'info locals' ./app core.1234

# 被守护进程接管时
coredumpctl list
coredumpctl gdb 1234
ulimit -c unlimited 只对当前 shell 和它之后启动的子进程有效,而且不会影响已经在跑的服务。以为设了就万事大吉、结果线上崩溃依然没有 core,多半是这个原因——服务是由 systemd 之类拉起来的,要在服务单元里配(LimitCORE=infinity),不是在你的终端里配。
另外 core 文件是完整内存快照:一个吃了 8 GB 内存的进程会写出一个几 GB 的文件,可能瞬间撑爆磁盘,而且里面可能含有明文密码、密钥、用户数据——生产环境开 core 前要想清楚存到哪、谁能读、多久清理。
确认哪个 core 是你的,用 file。目录里堆着好几个 core 时,file core.* 会把每个 core 的来源程序打出来:
core.701: ELF 64-bit LSB core file ..., from './segv', execfn: './segv'
这在用 timeout、xargs 这类包装器跑程序时特别要紧——包装器自己也可能留下一个 core(它在子进程死于某个信号后会给自己重发同一个信号),对着它调试只会看到包装器的栈,白忙一场。
性能

关于性能,最贵的错误不是优化得不够,是优化了不该优化的地方——凭直觉猜热点,几乎总是猜错。纪律只有一条:先量出来,再动手。而在 C 里,「量」这件事本身还有一个专属陷阱。

陷阱:你想测的代码,可能根本没被执行

  • 写基准测试的经典写法是「循环一百万次做点什么,计时」。问题在于:如果计算结果没有被使用,-O2 会把整个循环删掉。
  • 这不是理论。一个累加但结果不用的函数,-O0 下老老实实跑,-O2 下耗时变成 0.0000 秒——而且用 nm 一看,那个函数的符号在 -O2 的二进制里整个消失了,objdump -d 里也找不到对应指令。同一份文件里另一个「结果被返回并打印」的函数则正常存在、正常耗时。
  • 而「让结果被用掉」还不够。把累加结果赋给一个 volatile 变量——这是网上最常见的建议——对 for (i<N) acc += i 完全无效:这个循环有闭式解,编译器认得出来,于是用求和公式一步算出结果再赋值,循环照样没了,耗时照样是 0.0000。
  • 真正跑得起来的写法有两种:让累加器自己是 volatile(每轮都必须落回内存,但这本身会引入额外的内存流量),或者测一个没有闭式解的运算(比如带乘法与移位的哈希混合)。两种在同一台机器上都从 0.0000 变成了实实在在的耗时。
  • 看到「优化后快了一万倍」,先怀疑是不是被删了:用 nm 看符号还在不在、objdump -d 看指令还有没有,比盯着秒数猜靠谱。

怎么计时

  • 用 clock_gettime(CLOCK_MONOTONIC, ...):单调时钟,不受系统时间调整影响,纳秒精度。time() 只有秒、clock() 量的是 CPU 时间而不是墙钟(多线程下会大于实际经过的时间)。
  • 在 -O0 下测性能没有意义——那是给调试用的构建,和你要发布的东西不是一回事。
  • 同一份代码 -O0 换 -O2,快几倍是常态。但具体几倍完全取决于代码形状:一个受内存延迟支配的随机访问循环可能只快一点点,一个能被向量化的浮点循环可能快很多。这个数字必须自己测,抄别人的没有意义。
  • 测之前先让机器安静下来:关掉别的负载,同一档配置跑三遍取中位数,别用单次结果下结论。

动手的顺序

  • ① 先确认真的慢,并且定一个目标(「这个接口要从 300ms 降到 100ms」)。没有目标的优化会无限进行下去。
  • ② 找热点,别猜。有 perf 就用 perf record / perf report(Linux 上的标准剖析器,能出火焰图);没有就退而求其次,在几个可疑段落两侧插 clock_gettime 手动计时,二分着往下缩。
  • ③ 先改算法,再改代码。把 O(n²) 换成 O(n log n) 的收益,通常大于任何微优化——这是 10 章那整章的实际用途。
  • ④ 再考虑数据布局。连续内存对缓存友好(09 章讲动态数组时那些体感就来自这里),减少指针跳转、减少分配次数,往往比换写法更有效。
  • ⑤ 编译选项几乎是免费的:-O2 是默认该有的;-O3 不一定更快(代码膨胀可能反伤缓存),要测;-march=native 能用上本机指令集,但产物就不能拿到别的机器上跑了。
  • ⑥ 每一步都重新测量。没有测量支撑的优化,一半时间在做无用功,另一半在制造 bug。
#include <time.h>
#include <stdio.h>

/* 单调时钟计时:不受系统时间调整影响 */
static double now_sec(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec / 1e9;
}

int main(void) {
    double t0 = now_sec();

    /* 陷阱:下面这版【救不了】——循环有闭式解,-O2 用公式一步算完,
          哪怕结果被 volatile 用掉了,仍是 0.0000 秒 */
    // volatile long sink = 0;
    // long acc = 0;
    // for (long i = 0; i < N; i++) acc += i;
    // sink = acc;

    /* 能真跑起来:累加器自己是 volatile,每轮都必须写回内存 */
    volatile long acc = 0;
    for (long i = 0; i < N; i++) acc += i;

    printf("%.4f s (acc=%ld)\n", now_sec() - t0, acc);
    return 0;
}
三个让基准测试说谎的经典原因:① 结果没被使用,循环被整个删掉(上面那条,最常见);② 只跑了一次——第一次跑要预热缓存、要触发缺页、可能还在等 CPU 升频,结果普遍偏慢;③ 测的规模太小,几微秒的差异淹没在计时开销和调度噪声里,跑到至少几百毫秒再看数字。
还有一条不算陷阱但常被忘记:--coverage 和 sanitizer 编出来的二进制不能用来测性能,它们都往代码里插了额外的东西。测性能请用干净的 -O2 构建。
怀疑代码被优化掉了,就去看符号表——这是最直接的证据,比盯着耗时猜强:
nm ./bench_O2 | grep 函数名        # 没输出 = 函数整个没了
objdump -d ./bench_O2 | grep -A20 函数名
顺带一提,perf 在很多 WSL / 容器 / 云主机上装不上或者用不了(内核不支持,或 /proc/sys/kernel/perf_event_paranoid 限制了权限)。跑不起来别硬磕,clock_gettime 手动埋点 + 二分定位能解决绝大多数「到底慢在哪」的问题,只是笨一点。

Linux 与 PowerShell

Linux 命令速查:导航与查看、文件操作与查找、权限、进程与服务、文本三剑客与管道、网络与包管理;章末附 Windows PowerShell 7 的命令对照与对象管道。

命令行的全部套路就一句:小工具 + 管道。每个命令只做一件事,用 | 把它们接起来。

导航与查看

命令作用
pwd / cd - / cd ~当前目录 / 回上一个目录 / 回家目录
ls -lah长格式 + 隐藏文件 + 人类可读的大小
tree -L 2 / du -sh * / df -h目录树 / 每项占多大 / 磁盘剩余
cat / less / head -n 20 / tail -f全打印 / 翻页(q 退出)/ 前 20 行 / 跟踪追加(看日志)
file / stat / wc -l看类型 / 看元数据 / 数行数
路径约定/etc 配置、/var/log 日志、/usr/bin 程序、/tmp 临时、/proc 内核暴露的虚拟文件

文件操作与查找

命令作用
cp -r / mv / rm -r递归复制 / 移动或改名 / 递归删除——rm -rf 没有回收站
mkdir -p a/b/c / touch连中间目录一起建 / 建空文件或更新时间戳
ln -s 目标 链接名软链接(ls -l 里带 -> 的那种)
find . -name "*.c" -newer x -size +1M按名字 / 时间 / 大小找;-exec 命令 {} + 对结果批量执行
which / type / locate这个命令是哪个可执行文件 / 是别名还是内建 / 按名字快速搜
xargs把标准输入变成参数:find . -name '*.o' | xargs rm(文件名带空格时用 -print0 配 xargs -0)

权限与所有权

写法含义
-rwxr-xr--三组三位:属主 / 属组 / 其他;首字符 - 文件、d 目录、l 链接
数字r=4 w=2 x=1:755 = 属主全权、其他可读可执行;644 = 普通文件常用
chmod 755 f / chmod +x f设权限 / 加可执行位(脚本跑不起来通常就是缺它)
chown 用户:组 f / chgrp改属主 / 改属组(要 root)
目录上的 x意思是「能进入 / 能穿过」,不是「能执行」——没有它就 cd 不进去
sudo / umask以 root 执行一条 / 新建文件的默认权限掩码

进程与服务

命令作用
ps aux | grep 名字 / pgrep -a 名字找进程与它的 pid
top / htop实时看 CPU 与内存占用
kill pid / kill -9 pid / pkill 名字发 TERM(可被处理,优先用)/ 发 KILL(不可拦截,进程没机会清理)/ 按名字杀
jobs / fg / bg / 命令 & / Ctrl-Z作业控制:后台 / 调回前台 / 挂起
nohup 命令 & / tmux退出终端后继续跑 / 可重连的会话(服务器上的必备品)
systemctl status|start|stop|enable 服务服务管理;journalctl -u 服务 -f 看它的日志
lsof -p pid / lsof -i :8080这个进程开了哪些 fd / 谁占了这个端口(07、08 章)
strace -p pid / ltrace跟踪系统调用 / 库调用

文本三剑客与管道

命令典型用法
grepgrep -rn "pattern" . 递归带行号;-i 忽略大小写、-v 反选、-E 扩展正则、-C 3 带上下文
sedsed -i 's/旧/新/g' f 就地替换(-i 直接改文件,先不带它预览);sed -n '10,20p' 取行段
awkawk '{print $2}' 取第二列;awk -F: '$3 >= 1000 {print $1}' 按分隔符与条件筛
sort / uniq -c / cut / tr排序(-n 数值、-k2 按列)/ 去重并计数(必须先 sort) / 切列 / 换字符
经典组合cat log | awk '{print $1}' | sort | uniq -c | sort -rn | head——「谁出现最多」的万能套路
管道与重定向| 接上一个的输出;> 覆盖、>> 追加、2>&1 合并错误流、< 从文件读、/dev/null 丢弃

网络、包管理与压缩

命令作用
curl -fsSL 地址 / wget抓取;-v 看完整交互、-o 文件 保存
ss -ltnp / ping / dig谁在监听 / 通不通 / 解析域名(08 章)
ssh 用户@主机 / scp / rsync -av远程登录 / 复制 / 增量同步(大目录用它)
apt install|remove|search(Debian 系)包管理;其它发行版是 dnf / pacman / apk
tar -czf x.tar.gz 目录/ / tar -xzf x.tar.gz打包压缩 / 解开——c 建、x 解、z 走 gzip、f 指定文件名
快捷键Ctrl-C 中断、Ctrl-D 发 EOF、Ctrl-R 搜历史命令、Ctrl-A / Ctrl-E 行首行尾、Ctrl-L 清屏
rm -rf 没有回收站,而且它对待空格非常认真。rm -rf / tmp/foo 比 rm -rf /tmp/foo 只多一个空格,后果完全不同。三个习惯能挡住绝大多数事故:删之前先把同样的条件换成 ls 跑一遍、变量一律加引号(rm -rf "$dir",13 章)、危险操作前先 pwd 确认自己在哪。
Ctrl-R 和 !$ 是命令行效率的两个杠杆:前者反向搜历史,后者代表「上一条命令的最后一个参数」(mkdir 很长的路径 之后 cd !$)。再加上 man 命令 与 命令 --help,日常查询基本不用开浏览器。

在 Linux 里做任何事之前都要先回答两个问题:自己在哪、东西放在哪。

一切皆文件,目录树从根 / 出发。pwd 看当前位置、cd 切换、ls -lah 列出(含隐藏与大小)。路径分绝对(以 / 开头)与相对(. 当前、.. 上级、~ 家目录)。FHS 约定:/etc 配置、/var 日志、/home 用户、/usr 程序、/tmp 临时。
pwd                  # 当前目录绝对路径
cd ~/projects         # ~ = 家目录
cd -                 # 回到上一个目录
ls -lah              # 长格式 + 隐藏文件 + 人类可读大小
tree -L 2            # 树状查看,限 2 层深

# 关键目录(FHS)
# /etc 配置  /var 日志  /home 用户  /usr 程序  /tmp 临时
stat file.txt        # 文件详细元信息(大小/权限/时间)
cd 失败不会让脚本停下来。cd /nonexistent-dir; pwd 报完 No such file or directory 之后仍停在原目录继续往下跑——后面那句 rm -rf * 就删在了错误的地方。脚本里的 cd 永远写成 cd /path || exit 1,或者用 && 串联后续命令。另一个静默坑:~ 在引号里不展开。echo ~/bin 得到家目录全路径,而 echo "~/bin" 原样吐出 ~/bin;mkdir "~/bin" 会在当前目录下真的建出一个名叫 ~ 的目录。要拼路径请用 $HOME。
交互式导航的三个肌肉记忆:cd - 在最近两个目录间来回跳、cd 不带参数直接回家、ls -lt 按时间排序把最近改动的文件顶到最上面。想看清一个路径到底是什么,stat 比 ls -l 全(权限、三个时间戳、inode、硬链接数都有);判断是不是软链接看 ls -l 有没有箭头,找出所有断掉的软链接用 find . -xtype l,能精确列出指向不存在目标的那些链接。路径里有空格一律加引号,cd my dir 会被当成两个参数。

复制、移动、删除这些天天用的命令看着简单,但 cp 和 find 的几个行为细节不搞清楚,脚本一重跑就出乱子。

cp -r 复制(目录加 -r)、mv 移动/重命名、rm -r 删除、mkdir -p 建多级目录、touch 建空文件。find 按条件递归搜索并执行操作。ln -s 建软链接(快捷方式)。通配符 *(任意多字符)、?(单字符)。
cp -r src/. dst/             # 把 src 的内容倒进 dst(见 tip:写成 src/ 结果不幂等)
mv old.txt new.txt           # 重命名(同目录即改名)
mkdir -p a/b/c               # 一次建多级目录

# find:按名字/类型/时间搜索,并对结果执行命令
find . -name "*.log" -mtime +7 -delete   # 删 7 天前日志
find . -type f -name "*.c" -exec grep -l TODO {} +

ln -s /opt/app/bin/run ~/run  # 软链接(快捷方式)
rm -rf 不可恢复、无回收站。永远先确认路径,绝不在不确定时对变量路径执行 rm -rf "$dir"/(若 $dir 为空会变成 rm -rf /)。危险操作先 ls 同样的 glob 看看命中哪些文件。
cp -r src/ dst/ 的结果取决于 dst 存不存在:同一条命令,dst 不存在时得到 dst/a.txt,dst 已存在时却变成 dst/src/a.txt——重跑一次脚本就多套一层目录。想稳定表达「把 src 的内容倒进 dst」,写 cp -r src/. dst/。把 find 的结果交给别的命令时用 -print0 配 xargs -0:默认按空白切分,my file.log 会被拆成 my 和 file.log 两个不存在的参数。批量执行优先 -exec cmd {} +(攒一批调一次),比每个文件调一次的分号形式快得多。

rwx 那九个字母是 Linux 安全模型的入口,读不懂它就看不懂 Permission denied 到底在拒绝什么。

ls -l 开头的 rwxr-xr-- 表示属主/属组/其他三组的读(r=4)写(w=2)执行(x=1)权限。chmod 用八进制(755)或符号(u+x)改权限;chown user:group 改属主。sudo 临时提权,目录的 x 权限表示「可进入」。
ls -l script.sh
# -rwxr-xr--  属主 rwx | 属组 r-x | 其他 r--

chmod 755 script.sh     # rwxr-xr-x(八进制)
chmod u+x,go-w file     # 符号法:属主加 x,组/他去 w
chown alice:devs file  # 改属主:属组
chmod -R a+rX docs/     # 递归;大写 X 只给目录补 x(可进入)

sudo systemctl restart nginx  # 提权执行
chmod -R 644 dir 的后果比「目录进不去」更糟:它抹掉顶层目录的 x 之后连自己都递归不进去了,报 chmod: cannot access 'd1/d2': Permission denied 然后半途而废,剩下的文件停在未知权限状态。递归改权限一律用大写 X。第二个高频坑:sudo 不提权重定向——sudo echo x > /etc/foo 里创建文件的是你当前这个普通用户的 shell,报错前缀是脚本名而不是被调命令名,照样 Permission denied;要写成 echo x | sudo tee /etc/foo。另外 SSH 私钥权限只要宽于 600,ssh 会直接拒绝使用它。
对目录来说,x 权限的含义是「能否进入(cd)」、r 是「能否列出内容(ls)」——这也是为什么只给 r 不给 x 的目录能看见名字却进不去。常见值:可执行脚本/目录 755、普通文件 644、私密文件(如 SSH 私钥)600。递归改权限别用 chmod -R 644——它会把目录的 x 一并抹掉,目录从此进不去;用大写 X(只给目录和本就可执行的文件加 x)。

程序一跑起来就是进程,找得到它、用信号跟它交流、必要时体面地送走它,是排障最基本的功夫。

ps aux / top / htop 看进程;kill PID 发信号(默认 TERM 优雅退,kill -9 强杀 KILL)。前后台:& 后台运行、jobs/fg/bg、nohup 防挂断。systemctl 管 systemd 服务(start/stop/status/enable),journalctl 看日志。
ps aux | grep nginx       # 找进程
top                       # 实时资源占用(q 退出)

kill 1234                 # 发 TERM 信号(优雅退出)
kill -9 1234              # 发 KILL(强制,最后手段)
pkill -f "python app.py"  # 按命令行匹配杀

nohup ./server &          # 后台运行且不随终端关闭而退出

systemctl status nginx    # 服务状态
journalctl -u nginx -f    # 跟踪服务日志
KILL 信号进程捕获不到。一个注册了 TERM 清理逻辑的脚本,收到 TERM 时会打出清理日志,而 kill -9 之后清理完全没跑——临时文件、锁文件、没 flush 的数据全留在原地。只在 TERM 等了几秒仍不退时才用它。pkill -f 关键词 更危险:它拿关键词去匹配整条命令行,你自己那个碰巧包含关键词的 shell、编辑器、甚至这条 pkill 本身都可能被波及,一屏进程一起没。能用 pkill -x 进程名 就别用 -f;非用不可先 pgrep -af 关键词 把名单看一遍。
ps aux | grep nginx 永远会多出一行——grep 自己也在进程表里,匹配数是 2,其中一条就是那个 grep。要么补 | grep -v grep,要么直接用 pgrep -a nginx(-a 连整条命令行一起打,一步到位)。杀之前先想清楚匹配范围:pgrep -x 精确匹配进程名,pgrep -f 匹配整条命令行。默认发的 TERM 意思是「请你自己收尾」,给了程序跑清理逻辑的机会,永远先试它,别一上来就 -9。

日志排查九成的活儿就是挑行、换字、分列,grep、sed、awk 一人管一样,配上管道就是一条流水线。

一句话记住分工:grep 挑行、sed 换字、awk 分列。查看:cat/less/head/tail -f。grep 按正则过滤行(-r 递归、-i 忽略大小写、-n 行号)。sed 流式编辑(替换 s/old/new/g)。awk 按列处理($1 $2 是字段)。配 cut/sort/uniq/wc 能拼出强大的一行流水线。
tail -f app.log                  # 实时跟踪日志增长
grep -rni "error" logs/          # 递归/行号/忽略大小写

# sed:替换(-i 原地改文件)
sed -i 's/localhost/0.0.0.0/g' conf

# awk:取第 1 列并求和
awk '{ sum += $1 } END { print sum }' nums.txt

# 统计访问最多的 IP(经典组合)
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head
grep 的第一个参数是正则不是字面串。grep "1.2.3.4" 会把 1a2b3c4 一起捞出来,因为 . 匹配任意字符——查 IP、版本号、路径这类含点的东西一律加 -F,查单词加 -w。第二个坑:grep 没匹配到任何行时退出码是 1,在 set -e 或 set -o pipefail 的脚本里,「这次没搜到」会被当成失败让整个脚本静默中止,这种地方要写 || true。第三个:sed -i 遇到软链接会把链接本身替换成普通文件,改完之后链接没了、原文件内容纹丝未动,你以为改了其实没改。
uniq 只合并相邻的重复行——对 b/a/b/a 直接跑 uniq -c 得到的是四行计数 1,必须先 sort。这正是 sort | uniq -c | sort -rn 这个组合固定成型的原因。awk 用默认空白分隔时会把连续空格折叠成一个分隔符,但一旦指定 -F, 就不折叠了:a,,c 的字段数是 3、第二列是空串,处理 CSV 时这个差别直接决定列号对不对。sed 的分隔符不必是斜杠,改路径时写 s|/usr/local|/opt| 能省掉一整排转义。

单个命令能做的事有限,管道和重定向把它们串起来,才是命令行真正的威力所在。

管道 | 把前一命令的 stdout 接到下一命令的 stdin。重定向:> 覆盖、>> 追加、< 输入、2>&1 合并错误流。包管理:Debian 系 apt、RHEL 系 dnf。curl/wget 下载,ssh/scp 远程登录与传文件。
cat a.txt | sort | uniq > out.txt  # 管道 + 覆盖输出
./run >> app.log 2>&1            # 追加,且合并 stderr 到 stdout

sudo apt update && sudo apt install -y git
curl -fsSL https://example.com/install.sh | bash

ssh user@host                  # 远程登录
scp file.txt user@host:/tmp/    # 拷文件到远程
curl ... | bash 会直接执行远程脚本——只对完全信任的源这么做,否则等于把机器交给对方。不确定时先 curl 下载到文件、less 审一遍再运行。
> 是 shell 在命令启动之前就把文件清空的—— sort f.txt > f.txt 得到一个 0 字节的空文件,源数据当场蒸发。要原地处理必须先写临时文件再 mv。2>&1 的位置同样有讲究,它的含义是「让 stderr 指向 stdout 此刻指向的地方」,所以只能放在 > file 后面:cmd 2>&1 > file 里 stderr 跑到了终端上、只有 stdout 进了文件。管道的退出码默认只看最后一环,false | true 的退出码是 0;要看每一环用 PIPESTATUS,或者开 set -o pipefail。

左列是 Linux 命令,中列是 PowerShell 7(pwsh)的写法。根本区别只有一条:管道里流的是对象而不是文本,所以 grep / awk 的活儿多半换成按属性筛选。

先分清是哪一个 PowerShell

项Windows PowerShell 5.1PowerShell 7
程序名powershell.exe,系统自带,不再加新功能pwsh.exe,winget install Microsoft.PowerShell
查版本$PSVersionTable.PSVersion同左
&& / ||语法错误支持(7.0 起)
> 写出的编码UTF-16LE 带 BOM,别的工具常读成乱码UTF-8 无 BOM
curl 是什么Invoke-WebRequest 的别名真正的 curl.exe
平台仅 WindowsWindows / Linux / macOS

导航与文件

LinuxPowerShell 7说明
pwd / cd - / cd ~同左都是别名,行为一致(pwd = Get-Location)
ls -lahGet-ChildItem -Force(别名 ls dir gci)加 -Force 才列出隐藏文件;-la 这种合并写法不认
tree -L 2tree /f系统自带的 tree.com,不能限深度
du -sh 目录(gci 目录 -Recurse -File | Measure-Object Length -Sum).Sum / 1MB1KB 1MB 1GB 是语言内置的数字后缀
df -hGet-PSDrive -PSProvider FileSystem每个盘的已用 / 剩余
cat / head -n 20 / tail -n 20Get-Content f / -TotalCount 20 / -Tail 20cat type gc 都是 Get-Content
tail -f app.logGet-Content app.log -Wait -Tail 10跟踪日志用这个
wc -l(Get-Content f | Measure-Object -Line).Lines
stat f / file fGet-Item f | Format-List *列出全部属性:三个时间戳、大小、属性位
touch fNew-Item f文件已存在会报错;只想更新时间写 (Get-Item f).LastWriteTime = Get-Date
mkdir -p a/b/cmkdir a/b/cpwsh 的 mkdir 自动建中间目录;脚本里写全称 New-Item -ItemType Directory -Force
cp -r / mv / rm -rfCopy-Item -Recurse / Move-Item / Remove-Item -Recurse -Force参数不能合并写,rm -rf 报「找不到与参数名称 'rf' 匹配的参数」
find . -name "*.log"gci -Recurse -Filter *.log只要文件加 -File,只要目录加 -Directory
find . -mtime +7 -deletegci -Recurse -File | ? LastWriteTime -lt (Get-Date).AddDays(-7) | Remove-Item -WhatIf-WhatIf 只演习不删,确认名单后再去掉
ln -s 目标 链接New-Item -ItemType SymbolicLink -Path 链接 -Target 目标需要管理员,或在设置里打开「开发者模式」
which gitGet-Command git(gcm)只要路径:(gcm git).Source
chmod / chownicacls权限模型是 ACL,没有 rwx;下载的脚本被拦先 Unblock-File

文本处理

LinuxPowerShell 7说明
grep -rn error logs/gci logs -Recurse -File | Select-String error输出自带「文件:行号:内容」;sls 是别名
grep -i默认就不区分大小写要区分加 -CaseSensitive
grep -F / grep -v-SimpleMatch / -NotMatch模式默认是正则,和 grep 一样
grep -oE '[0-9]+' f(Select-String '\d+' f -AllMatches).Matches.Value只取匹配到的那一段
sed -i 's/a/b/g' f(Get-Content f) -replace 'a','b' | Set-Content f-replace 按正则替换;括号不能省,原因见本章「PowerShell 对象管道与 Linux 习惯的坑」
awk '{print $1}'gc f | % { (-split $_)[0] }一元 -split 按空白切并丢掉行首空白,和 awk 默认行为一致
sort | uniq -c | sort -rn | headGroup-Object | Sort-Object Count -Descending | Select-Object -First 10Group-Object 不要求相邻,不用先排序
sort -uSort-Object -UniqueGet-Unique 和 uniq 一样只合并相邻行
cut -d, -f2(CSV)Import-Csv f.csv | Select-Object 列名按表头取列,不用数第几列
jq '.a.b' f.json(Get-Content f.json -Raw | ConvertFrom-Json).a.b写回用 ConvertTo-Json -Depth 10:默认深度只有 2,更深的层被截成类型名
diff a bCompare-Object (gc a) (gc b)diff 在 pwsh 里就是它的别名,输出 => / <= 标记差异方向
xargsForEach-Object(%)并行:% -Parallel { … } -ThrottleLimit 8(7.0 起)
tee out.txtTee-Object out.txt

进程、服务与环境

LinuxPowerShell 7说明
ps aux | grep nodeGet-Process *node*ps 是别名;按名字通配,不会像 grep 那样把自己也匹配进来
topGet-Process | Sort-Object CPU -Descending | Select-Object -First 10只是一次快照;实时看开任务管理器
kill PID / kill -9Stop-Process -Id 1234 / 加 -Force按名字:Stop-Process -Name node
cmd & / jobs / fgcmd & / Get-Job / Receive-Job -Wait末尾的 & 生成后台作业(6.0 起),等价于 Start-Job { cmd }
systemctl status|restart xGet-Service x / Restart-Service x改服务状态需要管理员
journalctl -n 20Get-WinEvent -LogName System -MaxEvents 20应用日志换 -LogName Application
sudo cmdsudo cmdWindows 11 24H2 起自带,在「设置 → 系统 → 开发者选项」里开启;旧系统用 Start-Process pwsh -Verb RunAs
time cmdMeasure-Command { cmd }返回 TimeSpan,看 .TotalMilliseconds
env / echo $PATHgci env: / $env:PATH -split ';'
export X=1$env:X = '1'只在当前会话有效;永久写入用 [Environment]::SetEnvironmentVariable('X','1','User')
~/.bashrc / source$PROFILE / . $PROFILE文件不存在先 New-Item $PROFILE -Force
alias ll='ls -la'function ll { gci -Force @args }Set-Alias 只能指向命令本身,带不了参数
Ctrl-R / !$ / $?Ctrl-R / $ / $? 与 $LASTEXITCODE$? 是布尔值;外部程序的退出码看 $LASTEXITCODE
> /dev/null| Out-Null 或 > $null
man lsGet-Help gci -Examples第一次用先 Update-Help;-Online 打开网页版

网络与包管理

LinuxPowerShell 7说明
curl / wgetcurl.exe 照用 / Invoke-WebRequest url -OutFile fpwsh 7 里没有 wget 这个别名
curl api | jqInvoke-RestMethod api(irm)直接返回解析好的对象,JSON 不用再过 jq
ping -c 4 hostTest-Connection host -Count 4
nc -zv host 443Test-NetConnection host -Port 443看 TcpTestSucceeded
dig / nslookupResolve-DnsName example.com-Type MX 查指定记录
ip addrGet-NetIPAddress / ipconfig
ss -ltnp / lsof -i :8080Get-NetTCPConnection -LocalPort 8080 -State ListenOwningProcess 列就是 PID,再交给 Get-Process -Id
ssh / scp / tar同名Windows 10(1809 起)自带 OpenSSH 客户端与 tar
zip / unzipCompress-Archive / Expand-Archive
apt install gitwinget install --id Git.Git -e-e 精确匹配 id;用 scoop 的写 scoop install git
apt update && apt upgradewinget upgrade --all
apt search xwinget search x
长得像 Linux 的别名,行为并不一样。where git 什么也找不到,因为 where 是 Where-Object 的别名,查程序位置要写 where.exe git 或 gcm git;sc 是服务控制程序 sc.exe,不是 Set-Content;gci -Include *.txt 不加 -Recurse 时静默返回空,按扩展名筛选用 -Filter。另外 ls、sort、cat 这批别名只在 Windows 上有,Linux 上的 pwsh 会直接调系统同名程序,脚本里一律写全称。
不认识的对象先接 | Get-Member:它列出这个对象有哪些属性和方法,之后 Where-Object / Select-Object 按属性名取即可。命令名一律是「动词-名词」,记不住就按名词搜:Get-Command *-Service。

bash 管道传文本行,下一环靠 grep / awk 重新切;pwsh 管道传带属性的对象,下一环直接按属性名取。

四个命令覆盖九成场景:Where-Object 筛选、Select-Object 取列或取前 N 个、Sort-Object 排序、Group-Object 分组计数。比较运算符是 -eq -ne -gt -lt -like -match,不是 == 和 >。
# 占内存最多的 5 个进程:只要名字、PID 和 MB 数
Get-Process | Sort-Object WS -Descending |
  Select-Object -First 5 Name, Id, @{n='MB'; e={ [int]($_.WS / 1MB) }}

# 近 7 天改过的 .log,按大小倒序
Get-ChildItem -Recurse -Filter *.log |
  Where-Object LastWriteTime -gt (Get-Date).AddDays(-7) |
  Sort-Object Length -Descending

# 请求最多的 IP,对照 awk '{print $1}' | sort | uniq -c | sort -rn | head
Get-Content access.log | ForEach-Object { (-split $_)[0] } |
  Group-Object | Sort-Object Count -Descending | Select-Object -First 10 Count, Name

# 调 JSON 接口,直接取字段
$repo = Invoke-RestMethod https://api.github.com/repos/PowerShell/PowerShell
$repo.stargazers_count

# 脚本里的 set -e
$ErrorActionPreference = 'Stop'                   # cmdlet 出错就抛异常
$PSNativeCommandUseErrorActionPreference = $true   # 外部程序非零退出也抛(7.4 起)
> 在 PowerShell 里永远是重定向。if ($a > 5) 不报错:它把 $a 写进一个名叫 5 的文件,条件判为假。Get-Content f | … | Set-Content f 报「文件正由另一进程使用」,因为边读边写,把读取包进括号 (Get-Content f) 先整体读完。中文 Windows 控制台默认代码页 936(GBK),外部程序输出 UTF-8 会乱码,在 $PROFILE 里加 [Console]::OutputEncoding = [Text.Encoding]::UTF8。
单引号原样、双引号插值:"$env:USERPROFILE\code" 会展开变量,'$x' 原样输出。转义字符是反引号,所以 Windows 路径里的 \ 不用转义。字符串比较默认不区分大小写,要区分用 -ceq / -cmatch。

Shell 脚本

Shell 脚本速查:引用规则、参数扩展全表、条件测试、循环与函数、特殊变量,以及 set -euo pipefail 加 trap 这条健壮脚本的底线。

Shell 脚本的核心只有两件事:一切都是字符串,以及什么时候会被再次切分。这一章的坑几乎全来自第二件。

变量与引用

写法含义
名=值等号两边不能有空格
"$var"双引号:展开变量,但不再做单词切分——默认永远这么写
'$var'单引号:完全原样,不展开
$(命令)命令替换(比反引号好,可嵌套)
$((1 + 2))算术运算
export 名=值 / local 名=值传给子进程 / 函数内局部变量
数组=(a b c)"${数组[@]}" 全部元素(带引号才安全)、${#数组[@]} 个数、${数组[0]} 取一个

参数扩展

写法作用
${var:-默认}为空就用默认值(不改变量)
${var:=默认}为空就赋上默认值
${var:?消息}为空就报错退出——校验必填参数的最短写法
${#var}长度
${var#前缀} / ${var##前缀}删最短 / 最长匹配的前缀(${路径##*/} = 取文件名)
${var%后缀} / ${var%%后缀}删最短 / 最长匹配的后缀(${文件%.c} = 去扩展名)
${var/旧/新} / ${var//旧/新}替换第一个 / 全部
${var:2:3}从下标 2 取 3 个字符
记法# 在键盘左边所以删前缀,% 在右边所以删后缀

条件测试

写法含义
[[ … ]]bash 的测试(比 [ … ] 安全:里面不做单词切分,支持 && 与 =~)
-f 文件 / -d 目录 / -e 存在 / -r -w -x文件判断
-z "$s" / -n "$s"字符串为空 / 非空
"$a" == "$b" / != / =~ 正则字符串比较 / 正则匹配
-eq -ne -lt -le -gt -ge数字比较——字符串用 ==、数字用 -eq,混用是经典错误
if … then … elif … else … fi条件是命令的退出码:0 为真(和 C 相反)
case $x in a|b) …;; *) …;; esac多分支;*) 是兜底

循环、函数与特殊变量

写法说明
for f in *.c; do … done遍历文件;没有匹配项时循环变量会是字面量 *.c(可用 shopt -s nullglob 避免)
while IFS= read -r line; do … done < 文件逐行读的标准写法:IFS= 保住首尾空格、-r 不吃反斜杠
for ((i=0; i<10; i++)) / untilC 风格计数 / 条件为假时循环
函数名() { … }参数同样是 $1 $2;return 只能返回 0–255 的状态码,要返回数据就 echo 出来再用 $(…) 接
$0 $1 $#脚本名 / 第一个参数 / 参数个数
"$@" vs $*"$@" 把每个参数当成独立的一项(几乎总是你要的);$* 会拼成一个字符串
$? $$ $!上条命令的退出码 / 本进程 pid / 最后一个后台任务的 pid
shift / getopts参数左移一位 / 解析 -a -b 值 这类短选项

健壮脚本与常见坑

做法说明
set -euo pipefail放在脚本开头:出错即停(-e)、用未定义变量即停(-u)、管道中任一环失败都算失败(pipefail)
trap 'rm -f "$tmp"' EXIT退出时清理临时文件,无论正常还是异常
mktemp安全地建临时文件 / 目录,别自己拼 /tmp/名字$$
#!/usr/bin/env bash脚本首行;用了 bash 特性就别写 #!/bin/sh(有些系统上它是 dash)
变量不加引号头号坑:路径带空格时 rm $f 会删错东西;if [ $x = y ] 在 x 为空时直接语法错
cd 可能失败写成 cd "$d" || exit 1——否则后面的命令在错误的目录里执行
管道里的变量赋值丢了… | while read …; done 跑在子 shell 里,改的变量出了循环就没了;改用重定向或进程替换
shellcheck 脚本.sh写完必跑:上面这些坑它几乎全能查出来
什么时候别用 Shell超过一两百行、有复杂数据结构、要处理非文本——换 Python 或写个 C 小工具
Shell 里不加引号的变量不是「值」,而是「一段会被重新切分和通配的文本」。f="my file.txt" 之后,rm $f 会被切成两个参数,于是删掉 my 和 file.txt 两个不存在的文件;如果变量恰好是 *,那就展开成当前目录下的所有文件。规则简单到没有例外:所有变量引用都写成 "$var",数组写成 "${arr[@]}"。
两条能把脚本质量抬一个档次的默认动作:开头写 set -euo pipefail,写完跑 shellcheck。前者让失败当场停下而不是带着错误往下跑,后者会把「变量没加引号」这类隐患逐条指出来——它们加起来大概能挡住九成的脚本事故。

shell 里的变量不是简单的文本替换——引号加不加、加哪种,直接决定值会不会被拆散、要不要展开。

赋值等号两侧不能有空格(x=1)。取值用 $x 或 ${x}。引号至关重要:双引号保留变量展开、单引号完全字面、无引号有词分裂与通配陷阱。命令替换 $(cmd) 把命令输出嵌入。
name="world"           # 等号两侧不能有空格
echo "Hello, ${name}!"  # 双引号:展开变量
echo 'Hello, ${name}'   # 单引号:完全字面,不展开

# 命令替换:把输出存进变量
today=$(date +%F)
files=$(ls | wc -l)
echo "${today}: ${files} 个文件"

export PATH="$HOME/bin:$PATH"  # 导出为环境变量
变量引用几乎总要加双引号:"$var"。否则含空格的值会被拆成多个词、含 * 会被通配展开——这是 shell 脚本最经典的 bug 来源(尤其文件路径含空格时)。
记一条就够:除了赋值右侧和 [[ ]] 内部,变量和命令替换一律加双引号。x=$var 不会分词,但 ls $var 会——目录名 dir with space 不加引号会被拆成三个不存在的路径。冒号的有无是两种语义:${v:-默认} 把空串也当没设置,${v-默认} 只在变量完全没定义时兜底,v="" 时前者给出默认值、后者给出空串。另外命令替换会吃掉输出末尾的所有换行,所以 d=$(date) 不用担心带尾巴,但也别指望用它保留尾部空行。

很多要请 sed、basename 出场的字符串活儿,Bash 自带的参数扩展就地就能干完,连子进程都不用起。

参数扩展是 Bash 的瑞士军刀:${var:-默认} 给默认值、${#var} 取长度、${var%.*} 去后缀、${var//a/b} 全局替换、${var:0:3} 切片。数组用 ${arr[@]} 取全部、${#arr[@]} 取个数。方向记忆:# 砍头(去前缀)、% 砍尾(去后缀);单个是最短匹配,双写 ##/%% 是最长匹配。
file="report.txt"
echo "${file%.*}"      # report(去掉 .txt 后缀)
echo "${file##*.}"     # txt(取扩展名)
echo "${name:-guest}"  # name 为空则用 guest
echo "${#file}"        # 10(字符串长度)

# 数组
fruits=(apple banana cherry)
echo "${fruits[1]}"     # banana
echo "${#fruits[@]}"   # 3(元素个数)
for f in "${fruits[@]}"; do echo "$f"; done
数组展开必须写成带引号的 "${arr[@]}"。arr=("a b" "c") 时,不加引号展开成 a/b/c 三个词,用 [*] 则粘成 a b c 一个词,只有加引号的 [@] 老老实实给出两个元素。第二个高频误写:${#arr} 不是元素个数,它等价于 ${#arr[0]}——arr=(hello world foo) 时它是 5(hello 的长度),要个数只能写 ${#arr[@]}。同理 $arr 只取第一个元素,不是整个数组。
参数扩展能替掉一大半 basename/dirname/sed 调用,而且不 fork 子进程,循环里跑几千次差别非常明显。方向靠键盘位置记:# 在 $ 键左边所以砍头(去前缀),% 在右边所以砍尾(去后缀);单个是最短匹配,双写是最长匹配。另外 ${v:-}(默认值故意留空)是 set -u 脚本里的标准兜底写法——开了 set -u 之后裸引用一个没定义的变量会直接 unbound variable 退出,套上它就安全了。

条件判断是脚本的分水岭,而新式 [[ ]] 与老式 [ ] 的差别,正是脚本莫名走错分支的常见根源。

用 [[ ... ]] 测试(比老式 [ ] 更安全):文件 -f(存在且普通)、-d(目录)、-z(空串)、-n(非空);字符串用 ==/!=,整数用 -eq -lt -gt。多分支用 if/elif/else/fi 或 case。
if [[ -z "$config" ]]; then        # 没传参数
    echo "用法:$0 <config>"; exit 1
elif [[ ! -f "$config" ]]; then    # 传了但文件不存在
    echo "找不到配置:$config"; exit 1
else
    echo "加载配置:$config"
fi

if (( count > 10 )); then echo "太多"; fi  # 算术比较

case "$1" in
    start) echo "启动" ;;
    stop)  echo "停止" ;;
    *)     echo "用法: $0 {start|stop}" ;;
esac
老式 [ ] 里变量不加引号时,值为空或含空格会变成语法错误而不是「条件不成立」。v="" 时 [ $v == "" ] 报 [: ==: unary operator expected,w="a b" 时 [ $w = "a b" ] 报 [: too many arguments,两者退出码都是 2——放在 if 里就表现成静默走了 else 分支,你很难发现。同一组表达式换成 [[ ]] 全部判断正确。还有 > < 在 [[ ]] 里是字典序:[[ "10" > "9" ]] 为假,比数字必须用 -gt 或 (( ))。
[[ ]] 里 == 的右侧不加引号就是 glob 匹配,加了引号才是字面比较—— [[ $f == *.txt ]] 成立而 [[ $f == "*.txt" ]] 不成立。判断后缀、前缀根本不用请 grep 出场;要正则则换 =~。数值比较用 (( a > b )) 最自然,写起来跟 C 一样还能顺手做算术。文件测试要分清:-e 存在、-f 普通文件、-d 目录、-L 软链接——一个指向不存在目标的断链,-e 和 -f 都是假、只有 -L 为真,所以「找出断链」的条件是 -L 且非 -e。

循环本身不难,难的是逐行读文件这件事——随手写的读法多半会在空格、反斜杠或最后一行上出错。

for x in 列表 遍历;while read line 逐行读文件(最稳的读法);C 风格 for ((i=0;i<n;i++));until 条件为真才停。break/continue 控制流。遍历文件列表时务必给变量加引号防空格。
# 遍历文件(加引号防空格)
for f in *.txt; do
    echo "处理 $f"
done

# 逐行读文件(最稳:IFS 置空 + read -r)
while IFS= read -r line; do
    echo "> $line"
done < input.txt

# C 风格计数循环
for (( i=0; i<5; i++ )); do echo "$i"; done
别用 for line in $(cat file) 读文件——它按空白(不是按行)分割,还会通配展开。正确做法是 while IFS= read -r line; do ...; done < file。
while read 放在管道右边会掉进子 shell:cat f | while read l; do n=$((n+1)); done 跑完 n 还是 0,改成 done < f 才是 3——循环里累加的计数、拼好的字符串,出了循环全部蒸发。要读的是命令输出就用进程替换 done < <(cmd),把管道拆掉。另外文件最后一行没有换行符时,read 会读到内容却返回非零,导致最后一行被整行丢弃;把循环条件写成 while IFS= read -r l || [[ -n "$l" ]] 才完整。IFS= 是为了不剥掉行首尾空白,-r 是为了不让反斜杠被吞掉。

shell 的函数和别的语言长得像但骨子里不同——return 传的是退出码不是值,参数也没有名字只有编号。

函数内用 $1 $2 取位置参数、$@ 全部参数、$# 参数个数、$? 上条命令退出码(0 成功)。变量默认全局,函数内务必用 local 声明局部。return 返回退出码(不是值);要返回数据用 echo + 命令替换。
greet() {
    local name="${1:-world}"  # local + 默认值
    echo "Hello, $name"     # 用 echo「返回」数据
}
result=$(greet "Alice")        # 命令替换接住输出

# 脚本参数
echo "脚本名: $0,第一个参数: $1"
echo "共 $# 个参数: $@"

mycmd
if [[ "$?" -ne 0 ]]; then echo "上条命令失败"; fi
local v=$(cmd) 会吞掉命令的退出码。local v=$(false) 之后 $? 是 0——你拿到的是 local 这个内建命令的退出码;把它拆成 local v 和 v=$(false) 两行,才能拿到真正的 1。同样的坑在 export、declare 上一模一样,配 set -e 时尤其隐蔽:该中止的地方它安静地继续跑下去。另外 $? 只保存上一条命令的退出码,中间插一句 echo 就被覆盖成 0 了;要用就在命令的下一行立刻存进变量。
函数没有真正的返回值:return 只能给 0–255 的退出码,return 300 会静默变成 44(对 256 取模)、return -1 变成 255——绝不能拿它传数据,传数据一律 echo 出来再用命令替换接住。"$@" 和 "$*" 也不是一回事:三个参数下 "$@" 展开成三个独立的词、"$*" 粘成一个词,转发参数给别的命令时永远用 "$@"。开了 set -u 后没传参数时裸写 $1 会直接 unbound 退出,用 ${1:-} 兜底;"$@" 本身则不报错。

shell 默认遇到错误会闷头继续跑,生产脚本要靠 set -euo pipefail 和 trap 把它变成出错即停、退出必清理。

生产脚本开头三件套:#!/usr/bin/env bash shebang + set -euo pipefail(遇错即停、未定义变量报错、管道任一环失败即失败)。trap 注册退出/中断时的清理。here-doc <<EOF 嵌入多行文本,xargs 把输入变参数批量执行。IFS 是 Shell 用于切分字符串的分隔符,默认包含空格、制表、换行;将其设为仅换行与制表可避免含空格的文件名被错误拆分。
#!/usr/bin/env bash
set -euo pipefail        # 健壮性三件套
IFS=$'\n\t'

# trap:脚本退出时清理临时文件
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

# here-doc:嵌入多行
cat <<EOF > config.yaml
host: localhost
port: 8080
EOF

# xargs:把每行变成参数批量处理(-P 并行)
# -print0/-0 必配:否则含空格的文件名会被拆开
find . -name "*.log" -print0 | xargs -0 -P 4 -n 1 gzip
set -e 的失效范围比想象中大。函数一旦出现在 if、&&、|| 的条件位置,函数体内的 set -e 就整个关闭——if fail; then 里那个 false 后面的语句照样执行,脚本最后还以退出码 0 收场。第二个高频事故:(( c++ )) 在 c 为 0 时退出码是 1(后缀自增的「值」是自增前的 0),在 set -e 下当场把脚本干掉,写 (( ++c )) 才安全。第三个:trap "rm -f $tmp" EXIT 用双引号会在注册那一刻就把路径展开定死,之后再改 tmp 清理的还是旧值——一律用单引号。
set -e(遇错即停)是脚本健壮性的基石,但对「允许失败」的命令要显式放行:cmd || true。调试脚本时加 set -x 可逐行打印实际执行的命令,定位问题极快。

Git

Git 速查:三区模型与日常命令、分支合并与远程、按「我搞砸了」反查撤销办法、历史排查四件套(log -S / blame / bisect / reflog)。

Git 的全部概念只有三区加一条指针链:工作区 → 暂存区 → 本地仓库,分支只是指向某个提交的指针。下面第三张表是最值钱的那张——按「我干了什么蠢事」查怎么救。

日常工作流

命令作用
git init / git clone 地址新建仓库 / 克隆
git status -sb最常敲的一条:简洁地看当前状态
git add 文件 / git add -p放进暂存区 / 逐块挑选要提交的改动
git commit -m "消息" / git commit --amend提交 / 改上一条提交(消息或内容)
git diff / git diff --staged工作区与暂存区的差异 / 暂存区与上次提交的差异
git log --oneline --graph --all一屏看清分支结构
.gitignore忽略产物(*.o、build/);已经被跟踪的文件加进去也没用,要先 git rm --cached

分支、合并与远程

命令作用
git switch -c 新分支 / git switch 分支建并切 / 切换(比老的 checkout 语义清楚)
git merge 分支合并,保留真实历史;冲突时改完文件 git add 再 git commit
git rebase 主干把自己的提交搬到主干最新处,历史线性好看
merge 还是 rebase自己的本地分支可以 rebase,已经推给别人的分支只能 merge——rebase 会重写提交 ID
git fetch / git pull --rebase只取不合 / 取回并把本地提交搬到最新(避免一堆无意义的合并提交)
git push -u origin 分支推送并建立跟踪关系
git stash / git stash pop临时收起改动去干别的 / 拿回来
git cherry-pick 提交只把某一个提交搬到当前分支
git tag -a v1.0 -m ""打版本标签(git push --tags 推上去)

「我搞砸了」反查

状况怎么救
改乱了,想丢掉工作区的修改git restore 文件(丢了就没了)
add 错了,想撤出暂存区git restore --staged 文件
提交信息写错了(还没推)git commit --amend
想撤掉最近一次提交但保留改动git reset --soft HEAD~1
想彻底丢掉最近一次提交git reset --hard HEAD~1——危险,改动一并丢弃
已经推出去了,要撤销git revert 提交:生成一个反向提交。公共分支上永远用 revert,不要 reset + 强推
提交到了错的分支在错分支上 git reset --hard HEAD~1,再到对的分支 git cherry-pick 那个提交
分支删了 / reset 过头了,提交找不回来git reflog:它记录了 HEAD 的每一次移动,找到那个哈希再 git reset --hard 哈希——Git 里几乎没有真正丢得掉的提交
合并冲突不想合了git merge --abort / git rebase --abort
误删了文件git restore 文件;已提交的用 git checkout 提交 -- 文件

历史排查

命令回答什么问题
git log -S "某段代码"哪次提交引入或删除了这段代码
git log -p 文件 / git log --follow 文件这个文件的改动历史 / 跨改名追踪
git blame 文件每一行是谁在哪次提交写的(为了理解上下文,不是为了追责)
git bisect start / bad / good二分查找哪次提交引入了 bug——几十次提交只要试五六次(10 章的二分)
git show 提交 / git diff A..B看某次提交的完整内容 / 两点之间的差异

约定与配置

项建议
提交粒度一个提交一件事,能用一句话说清;这样 revert 和 bisect 才好用
提交消息首行 50 字以内的祈使句(fix: 修正越界),需要解释就空一行写正文——写清「为什么」,「做了什么」看 diff 就有
分支策略个人项目:主干 + 短命特性分支足够;别一个分支开三个月
常用配置git config --global user.name/user.email、init.defaultBranch main、pull.rebase true、core.autocrlf(跨平台换行)
别提交的东西密钥、大二进制、构建产物——密钥一旦提交过,改历史也很难彻底清干净,正确做法是立刻吊销那把密钥
git reset --hard 和在公共分支上强推,是 Git 里仅有的两个真会丢东西的操作。前者丢掉未提交的工作区改动(reflog 也救不回来,因为它从未被提交过);后者会把同事基于旧历史的提交变成孤儿。规矩:本地没提交过的改动先 git stash,公共分支上的撤销一律用 git revert。
理解 Git 只需要建立一个心智模型:提交是不可变的快照,分支和 HEAD 只是指向快照的指针。reset 是移动指针、revert 是追加一个反向快照、rebase 是照着老快照重做一批新快照。想不通某条命令干了什么,就画一遍指针。

Git 的困惑几乎都源于没分清工作区、暂存区、版本库这三个区,先把 add 和 commit 各管哪一段搞明白。

Git 有三个区域:工作区(改动)、暂存区(git add 挑选要提交的改动)、版本库(git commit 永久记录)。git status 看状态、git log --oneline 看历史。理解「add 是挑选、commit 是定格」是用好 Git 的关键。
git init                # 初始化仓库
git clone <url>          # 克隆远程仓库

git status               # 看哪些改了/暂存了
git add file.c           # 改动 → 暂存区
git add -p               # 交互式逐块挑选(强烈推荐)
git commit -m "feat: 添加登录"  # 暂存区 → 版本库

git log --oneline --graph  # 紧凑的历史图
git add 定格的是当时那一刻的内容:add 之后又改了同一个文件,git status --short 会显示 MM file(左暂存区、右工作区),此时 git commit 只提交暂存的旧版本,新改动被悄悄留下——这是「明明改了却没生效」的头号原因。习惯用 git commit -am 也有坑:-a 只自动暂存已跟踪文件,新建的文件根本不会进去。提交前用 git status --short 和 git diff --staged 各扫一眼,比事后 amend 便宜得多。
git add -p 让你逐块审查并挑选改动入暂存区,既能复查自己的代码、又能把无关改动拆成多个清晰的提交。养成 commit 前 git diff --staged 复查的习惯。

分支在 Git 里便宜到可以随手开,真正要想清楚的是合回去的时候用 merge 还是 rebase。

分支是低成本的并行开发线。git switch -c feat 建并切换(旧写法 checkout -b)。合并两种:merge 保留分叉历史、产生合并提交;rebase 把提交「搬」到目标分支顶端、得到线性历史。冲突需手动解决后 add 再继续。要注意 rebase 得到线性历史的代价是重写了这些提交(生成新的哈希)。
git switch -c feature/login  # 建并切到新分支
git switch main              # 切回主分支
git branch -d feature/login   # 删除已合并分支

# 合并:保留分叉,产生 merge commit
git merge feature/login

# 变基:线性历史(把当前分支搬到 main 顶端)
git rebase main

# 冲突时:手动改 → git add → git rebase --continue
不要 rebase 已经推送、别人可能已基于其工作的公共分支——rebase 重写提交历史,会与他人的副本产生分歧。黄金法则:rebase 只用于本地尚未共享的提交,公共历史用 merge。
选型心法一句话:历史要不要给别人看——公共分支用 merge 保留真实分叉,本地的脏提交在推送之前用 rebase -i 整理成干净的一串。rebase 中途冲突搞乱了随时 git rebase --abort 原地满血复活,没有任何代价,所以不必怕它。另一个免费安全网是 git branch -d:分支还没合并进来它会拒绝删除并报 error: the branch 'feat' is not fully merged,这时应先确认那些提交是否还要,别顺手换成 -D 硬删。

本地玩明白了,下一步是和远程同步——fetch、pull、push 三个动作各自动了哪里,要分得清清楚楚。

git remote 管远程地址(默认名 origin)。git fetch 只下载不合并(安全)、git pull = fetch + merge(或 --rebase)。git push 推本地提交;首次推新分支用 -u 建立追踪关系。
git remote -v               # 查看远程地址
git fetch origin            # 下载远程更新(不动本地分支)
git pull --rebase origin main # 拉取并变基(历史更干净)

git push -u origin feature/login  # 首推 + 建立追踪
git push                     # 之后直接 push 即可
push 被拒有两种报错,含义完全不同:(fetch first) 配 hint「the remote contains work that you do not have locally」是别人先推了,拉下来再推即可;(non-fast-forward) 则多半是你 amend/rebase 改写了已推送的提交。后一种千万别用 git push -f 了事——它会直接覆盖别人在这期间推上去的提交;要强推请用 --force-with-lease,远程存在你没见过的新提交时它会拒绝。另外本地与远程分叉时直接 git pull 会 fatal: Need to specify how to reconcile divergent branches,得先配好 pull.rebase。
git pull --rebase 在拉取他人提交时避免产生无意义的合并提交,让历史保持线性。可设为默认:git config --global pull.rebase true。

写错了想反悔是天天发生的事,Git 给了一排撤销工具,关键是按「改动走到了哪个区」选对那一个。

按场景选工具:git restore file 丢弃工作区改动、git restore --staged file 取消暂存;git commit --amend 修补最后一次提交;git reset --soft/--mixed/--hard 回退(soft 只退 HEAD、改动留暂存区;mixed(默认)再退暂存区、改动留工作区;hard 三区全退、彻底丢弃——回退越狠、丢得越多);git revert 用新提交抵消旧提交(安全,适合公共历史);git stash 暂存现场。
git restore file.c           # 丢弃工作区改动(未暂存)
git restore --staged file.c   # 取消暂存(保留改动)
git commit --amend            # 修补上一次提交(改信息/补文件)

git reset --soft HEAD~1      # 撤销提交,改动留在暂存区
git revert <hash>            # 生成「反向」提交(公共历史安全)

git stash                    # 暂存当前改动,清空工作区
git stash pop                # 恢复并移除最近的 stash
git reset --hard 和 git checkout . 会永久丢弃未提交的改动,无法找回。要撤销「提交」优先用 revert(它只是追加反向提交、不改历史)。即便误删,git reflog 通常还能找回已提交的内容。
记住一条分界线就够:进过版本库的东西丢不了,没进过的丢了就是丢了。reset --hard、rebase 出错、误删分支毁掉的都是提交,git reflog 里都有记录(默认可达的留 90 天、不可达的留 30 天),一句 git reset --hard HEAD@{2} 就能拉回来;而 git restore / git checkout -- file 丢弃的是从未提交过的改动,git 里没有任何副本,reflog 也救不了。还要注意 reset --hard 并不清场:未跟踪的文件它一个都不删,那是 git clean 的活。所以危险操作前随手 git stash 或 commit 一个存档点,成本几乎为零。

这行代码谁改的、bug 是哪个提交引进来的——答案都在历史里,问题是怎么问。

排查利器:git diff 看改动、git blame file 看每行最后谁改的、git log -S"字符串" 找引入某代码的提交、git bisect 二分定位引入 bug 的提交、git cherry-pick 摘取单个提交、git reflog 找回「丢失」的提交(本地后悔药)。
git diff HEAD~3             # 和 3 次提交前对比
git blame -L 10,20 file.c   # 看 10-20 行的提交来源
git log -S"funcName"       # 找引入/删除该字符串的提交

# bisect:二分查找引入 bug 的提交
git bisect start
git bisect bad               # 当前坏
git bisect good v1.0        # 这个版本好 → 自动二分

git reflog                  # 所有 HEAD 移动记录(找回丢失提交)
git cherry-pick <hash>      # 把某提交摘到当前分支
git blame 极易被格式化提交带偏:项目做过一次全量改缩进或换 formatter,之后每一行都指向那次毫无信息量的提交。加 -w 忽略纯空白改动通常就能回到真正的作者提交;更彻底的做法是把噪音提交的哈希写进 .git-blame-ignore-revs 并配 blame.ignoreRevsFile。另外 reflog 是纯本地的:新 clone 出来只有一条 clone 记录,别人机器上的误操作你查不到,你的 reflog 也 push 不上去。还有 bisect 结束后务必 git bisect reset,否则一直停在分离 HEAD 上。
git bisect 真正的杀手锏是 git bisect run <脚本>:脚本退出码 0 记为 good、非 0 记为 bad,git 自动跑完整个二分并打印 xxx is the first bad commit,几十个提交也就几秒。找代码来源时注意 -S 与 -G 的分工:log -S"foo" 只报该字符串出现次数发生变化的提交(真正引入或删除它的那次),log -G"foo" 报所有 diff 里碰过它的提交(含挪位置、改格式)。想找「谁写的」用 -S,想找「谁动过」用 -G。

多人协作光会命令不够,还得有约定——什么不进仓库、提交信息怎么写、分支怎么流转。

.gitignore 排除构建产物/密钥/依赖目录。团队常用功能分支 + Pull Request:从 main 切分支、推送后开 PR、评审通过再合并。提交信息推荐 Conventional Commits:type(scope): 描述(feat/fix/docs/refactor...),便于自动生成 changelog 与语义化版本。
# .gitignore(绝不提交密钥与产物)
node_modules/
*.o
*.log
.env
build/

# Conventional Commits 提交规范
# feat: 新功能   fix: 修 bug   docs: 文档
# refactor: 重构  test: 测试   chore: 杂务
git commit -m "fix(auth): 修复 token 过期未刷新"

# 交互式 rebase:合并/重排/改写提交(推送前整理)
git rebase -i HEAD~3
密钥/密码/.env 一旦提交,即使后续删除也仍留在历史里,等于泄露(尤其公开仓库)。务必在第一次提交前就写好 .gitignore;若已误提交敏感信息,需用 git filter-repo 清理历史并立即轮换该密钥。
.gitignore 只拦未跟踪的文件,对已经提交过的文件完全无效——把 .env 写进 .gitignore 后再改它,git status 照样显示 M .env。正确做法是 git rm --cached .env 停止跟踪(文件仍留在磁盘上)再提交,但要清楚这只让它不再被新提交记录,旧历史里的内容一个字都没少。所以顺序很重要:新项目先写好 .gitignore 再第一次 git add,别指望事后补救。同理 rebase -i 整理提交也只在推送前做。

Docker

Docker 速查:命令全表、Dockerfile 指令与 CMD / ENTRYPOINT 的区别、层缓存与多阶段构建、卷与网络、Compose 字段与生产实践。

三个概念分清就不会乱:镜像是只读模板、容器是镜像跑起来的进程、卷是容器外的数据。容器不是虚拟机,它就是被隔离的进程。

命令全表

命令作用
docker run -it --rm 镜像 命令跑一个;-it 交互、--rm 退出即删(试玩必加)
docker run -d -p 8080:80 --name web 镜像后台跑;-p 宿主端口:容器端口、起个名字
docker ps / docker ps -a运行中的 / 包括已退出的
docker logs -f 容器看输出(容器里的程序要往 stdout / stderr 打日志,别写进文件)
docker exec -it 容器 sh进到正在跑的容器里看看——排查的第一招
docker stop / start / rm / rm -f停 / 启 / 删(要先停)/ 强删
docker build -t 名字:标签 .构建镜像;最后那个 . 是构建上下文目录
docker images / docker rmi / docker pull / push列 / 删镜像 / 拉 / 推
docker cp / docker inspect / docker stats拷文件进出 / 看完整配置 / 看资源占用
docker system df / docker system prune -a看 Docker 占了多少磁盘 / 清理——磁盘莫名满了先查这个

Dockerfile 指令

指令作用
FROM 基础镜像起点;选带版本号的 tag,别用 latest
WORKDIR /app设工作目录(比一堆 cd 清楚)
COPY 源 目标 / ADD拷进镜像 / ADD 会自动解压、还能拉 URL,容易出意外——默认用 COPY
RUN 命令构建时执行,每条 RUN 产生一层;用 && 把安装和清理串成一条
ENV / ARG运行时环境变量 / 只在构建时存在的变量
EXPOSE 80只是文档声明,真正映射端口靠 -p
USER appuser别用 root 跑
HEALTHCHECK让编排系统知道「活着」和「能服务」的区别
CMD vs ENTRYPOINTENTRYPOINT 是固定的可执行文件,CMD 是默认参数、能被命令行覆盖;两者都用 exec 形式 ["prog", "arg"]——shell 形式会让进程不是 1 号、收不到停止信号
.dockerignore把 .git、node_modules、构建产物挡在上下文之外——构建慢通常就是它没写

构建优化

手段说明
层缓存的顺序把变得最少的放前面:先 COPY 依赖清单并安装依赖,再 COPY 源码——这样改一行代码不会重装依赖
多阶段构建FROM gcc AS build 编译 → FROM debian:slim 只 COPY --from=build 拷产物:编译器不进最终镜像,体积能小一个数量级
选小基础镜像-slim 或 alpine(alpine 用 musl,C 程序可能有兼容问题)
清理要在同一层RUN apt-get install … && rm -rf /var/lib/apt/lists/*——分成两条 RUN 是省不掉体积的,上一层已经写进去了

数据、网络与 Compose

项说明
容器里的改动会丢容器删了,写在容器文件系统里的数据就没了——要持久化就挂卷
-v 卷名:/data / -v $(pwd):/app命名卷(生产用)/ 绑定挂载(开发时把源码映进去)
网络同一个自定义网络里的容器可以用服务名互相访问——这是 Compose 能直接写 db:5432 的原因
compose.yaml 常用字段services / image / build / ports / volumes / environment / depends_on / restart / healthcheck
docker compose up -d / down / logs -f / ps起一组 / 停并删 / 看日志 / 看状态
depends_on 只管启动顺序不等对方「准备好」——数据库还没接受连接时你的服务已经在连了。要么配 healthcheck + condition,要么在应用里重试
生产实践非 root 用户、只读根文件系统、资源限制(--memory / --cpus)、健康检查、日志打到 stdout
容器里的进程是 1 号进程,而 1 号进程对信号的默认处理和别处不一样。用 shell 形式写 CMD node app.js,真正的 1 号进程是 shell,你的程序收不到 docker stop 发来的 SIGTERM,十秒后被 SIGKILL 强杀——症状是「停容器很慢而且数据没存完」。用 exec 形式 CMD ["node", "app.js"],并在程序里处理 SIGTERM(07 章)。
把 Docker 当成「可复现的环境说明书」而不是「更轻的虚拟机」:Dockerfile 的价值在于它把「需要装什么、怎么装」写成了可执行、可版本管理的文本——「在我机器上能跑」终结于此,而不是终结于镜像本身。

Docker 的一切都建立在「镜像是模板、容器是实例」这一对概念上,先分清它俩再谈命令。

镜像(image)是只读模板,容器(container)是镜像的运行实例——镜像之于容器,如类之于对象。docker run 启动、ps 看运行中、exec -it 进容器、logs -f 看日志、stop/rm 停止删除。
docker pull nginx:1.27          # 拉镜像(带版本标签)
docker run -d --name web -p 8080:80 nginx
# -d 后台  --name 命名  -p 主机:容器 端口映射

docker ps                       # 运行中的容器(-a 含已停止)
docker exec -it web bash        # 进入容器交互式 shell
docker logs -f web              # 跟踪容器日志
docker stop web && docker rm web  # 停止并删除
docker run 每次都新建一个容器,它不是「启动那个已有的」——想再跑一次停掉的容器用 docker start。搞混的后果是停止的容器悄悄堆积,docker ps 默认还看不见它们(要 -a),几个月后镜像加容器吃掉几十 GB 磁盘。日常加 --rm 让容器退出即删,定期 docker system df 看占用、docker system prune 清理。另外-p 的顺序是「主机:容器」,写反了不会报错——端口照样映射成功,只是映射到了你没料到的地方,然后你会对着连不上的服务查半天。
容器是临时的:删掉容器,里面写的数据就没了(除非挂了卷)。镜像 tag 务必钉死版本(nginx:1.27 而非 nginx:latest),否则不同时间拉到的「latest」可能不一致,破坏可复现性。

镜像不是手动攒出来的——Dockerfile 用一条条指令把构建过程写成脚本,每条指令就是一层。

Dockerfile 是构建镜像的脚本,每条指令产生一层(layer):FROM 基础镜像、WORKDIR 工作目录、COPY 拷文件、RUN 执行命令(装依赖)、ENV 环境变量、EXPOSE 声明端口、CMD/ENTRYPOINT 容器启动命令。
FROM node:22-slim
WORKDIR /app

# 先拷依赖清单并安装(利用层缓存,见本章后面)
COPY package*.json ./
RUN npm ci --omit=dev

# 再拷源码
COPY . .

ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]   # 容器启动命令
CMD node server.js(shell 形式)和 CMD ["node","server.js"](exec 形式)不等价,而且区别会在生产上咬你。shell 形式实际执行的是 /bin/sh -c "node server.js",于是 PID 1 是 sh 而不是你的程序;docker stop 发出的 SIGTERM 送给 PID 1,sh 不会转发给子进程——你的程序收不到退出信号,默认等 10 秒后被 SIGKILL 强杀,来不及关连接、刷缓冲、优雅收尾。一律用 exec 形式(JSON 数组)。另一个坑:每条 RUN 都是独立一层,在后面的层里 rm 掉前面层写入的文件,镜像并不会变小——那份数据仍留在历史层里(连同密钥)。要删就得和创建它的命令写在同一条 RUN 里。
CMD 提供默认命令(可被 docker run 后的参数覆盖);ENTRYPOINT 定义固定入口(把容器变成一个「可执行程序」)。二者可配合:ENTRYPOINT 设程序、CMD 设默认参数。

同样功能的镜像可以是几 GB 也可以是几 MB,差别就在会不会排层缓存、用不用多阶段构建。

docker build -t name:tag . 构建。优化关键:层缓存——把不常变的步骤(装依赖)放前面、常变的(拷源码)放后面,改代码就不必重装依赖。多阶段构建把编译环境与运行环境分离,最终镜像只含产物,体积骤减。.dockerignore 排除无关文件。
# 多阶段构建:编译阶段 + 精简运行阶段
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd

# 运行阶段:只拷可执行文件,基于极小镜像
FROM gcr.io/distroless/static
COPY --from=build /app /app
ENTRYPOINT ["/app"]

# docker build -t myapp:1.0 .
# 最终镜像可能只有几 MB,不含编译器/源码
每条 RUN 都生成一层,且删文件的层无法真正缩小前面层的体积。装包要在同一个 RUN 里装完并清理缓存(如 apt-get install ... && rm -rf /var/lib/apt/lists/*),否则缓存留在镜像里白白增重。
层缓存的规则只有一条,但决定了 Dockerfile 该怎么排:从第一条内容发生变化的指令开始,它以及它之后的所有层全部作废重建。所以顺序永远是越不常变的越靠前——先 COPY package.json 再 RUN npm ci,最后才 COPY . .;要是把 COPY . . 提到安装依赖之前,你改一个字符就得重装一遍全部依赖。.dockerignore 也不只是为了减体积:漏掉它,node_modules、.git 这些高频变动的目录会被算进 COPY . . 的内容里,于是每次构建缓存都必然失效,这是「明明只改了一行却构建了五分钟」的常见原因。

容器随删随建,数据和端口却要长久稳定——卷和网络就是把这两样从容器生命周期里解耦出来的机制。

容器删了数据就没——持久化用卷:具名卷(-v data:/path,Docker 管理,适合数据库)、绑定挂载(-v $(pwd):/app,映射主机目录,适合开发热更新)。端口用 -p 主机:容器 暴露。容器间通信建自定义网络后可用容器名当主机名互访。
# 具名卷:数据库数据持久化
docker run -d -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:17

# 绑定挂载:开发时映射本地代码(改了即时生效)
docker run -w /app -v $(pwd):/app -p 3000:3000 node:22 npm run dev

# 自定义网络:容器间用「名字」互相访问
docker network create appnet
docker run -d -e POSTGRES_PASSWORD=secret --name db --network appnet postgres
docker run -d --name api --network appnet myapi
# api 容器内可用主机名 db 连数据库
-p 8080:80 默认绑的是 0.0.0.0,即对整个公网开放。更严重的是 Docker 直接往 iptables 里插规则,绕过了 UFW 这类防火墙——你以为 ufw deny 8080 挡住了,实际外网照样连得进来。云主机上把数据库这样跑起来,等同于裸奔。只给本机用的服务必须写成 -p 127.0.0.1:8080:80。另一个高频坑是开发时用绑定挂载:-v $(pwd):/app 会连同容器里镜像构建好的 /app/node_modules 一起遮盖掉,换成主机上那个(可能不存在、或是给别的操作系统装的),于是「构建时好好的,一挂载就报找不到模块」。解法是再叠一个匿名卷 -v /app/node_modules 把它挡回来。
两种挂载法对应两种场景,别混用:具名卷(-v data:/path)由 Docker 管理、跨容器留存,数据库一律用它;绑定挂载(-v $(pwd):/app)把主机目录直接映射进去,只适合开发时的热更新。两者有个关键差异:挂到镜像里本来就有内容的目录上时,具名卷在首次使用时会把镜像里的原有内容复制进卷,而绑定挂载是直接遮盖——主机目录是空的,容器里那个目录就是空的。

真实应用很少只有一个容器,Compose 把应用加数据库加缓存整套写进一个文件,一条命令拉起。

Compose 用一个 compose.yaml 声明多容器应用(应用+数据库+缓存),一条命令拉起整套。docker compose up -d 后台启动、down 停止清理、logs -f 看日志。depends_on 控制启动顺序,服务间用服务名互连。(注意是 docker compose 带空格的 v2 语法。)
# compose.yaml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/app
    depends_on:
      - db
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

# docker compose up -d   启动全部
# docker compose down    停止并清理
docker compose down 不会删具名卷,这是有意为之(免得一条命令抹掉数据库)。想连数据一起清干净要显式 down -v——反过来说,当你「重置环境」却发现旧数据还在时,原因通常就在这。还有两点容易困惑:①Compose 会给网络和卷自动加上项目名前缀(默认取所在目录名),所以把项目目录改个名,再 up 就会创建一整套全新的卷,旧数据看着像凭空消失了。②改完 compose.yaml 要重新 docker compose up -d 才会重建受影响的容器,restart 是拿旧配置重启,改了半天没生效多半是这个。
depends_on 只保证「先启动」,不保证「已就绪」——这是 Compose 最经典的误解。数据库容器起来了不等于 Postgres 已经能接受连接,api 往往会在数据库初始化那几秒里连接失败退出。要真正等待,得给被依赖方写 healthcheck,再把依赖声明为 condition: service_healthy。不过更稳妥的思路是让应用自己带重试:编排层的就绪保证只在启动那一刻有效,而数据库在运行期间照样会重启——能扛住重连的应用,在哪儿都不怕。

本地跑通和上生产之间隔着安全与稳定性的一整段路——降权、探活、限资源,下面就是那份检查单。

上生产要点:用最小基础镜像(alpine/distroless)减小攻击面与体积;以非 root 用户运行(降权);加 HEALTHCHECK 让编排系统能探活;设资源限制(CPU/内存)防单容器拖垮宿主;敏感配置走环境变量/密钥管理,绝不写进镜像。
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev

USER node                      # 降权:非 root 运行

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

# 运行时限制资源:
# docker run --memory=512m --cpus=1.0 myapp
容器默认以 root 运行——一旦应用被攻破,攻击者就在容器里有 root 权限,配合内核漏洞可能逃逸到宿主。生产镜像务必加 USER 降权,并避免把 Docker socket(/var/run/docker.sock)挂进不可信容器。
三条能立刻落地的。①别用 :latest:它不是「最新」的承诺而是一个会漂移的标签,今天构建和下个月构建可能拿到完全不同的基础镜像,让「在我机器上能跑」以最隐蔽的方式回归——锁到具体版本(甚至 @sha256: 摘要)。②日志一律写 stdout/stderr,别在容器里写日志文件:容器是随时可被销毁重建的,写进去的文件跟着一起没,而写到标准流才能被 docker logs 和各种采集系全部一接管(这正好呼应 02 章说的"stderr 不缓冲")。③如果你的程序会 fork 子进程,加上 --init:容器里的 PID 1 不承担收养孤儿进程的职责,没有它,退出的子进程会在容器里堆成僵尸——04 章讲的那个问题,换到容器里会更容易发生。

C 的界面:终端 UI 与 GUI 库

界面这一层:终端的 ANSI 转义序列与原始模式、ncurses 的常用 API,以及 C 要做图形界面时的四条路与各自的代价。

终端本身就是一个 UI:你往 stdout 写的特殊字节序列,终端会当成控制命令。想要更多就上 ncurses;真要窗口界面,C 有四条路。

ANSI 转义序列(不用任何库)

序列作用
\033[2J / \033[H清屏 / 光标回左上角
\033[行;列H把光标移到指定位置(行列从 1 开始)
\033[31m … \033[0m前景色(30–37)…重置;背景色是 40–47,亮色 90–97
\033[1m / \033[7m加粗 / 反色
\033[?25l / \033[?25h隐藏 / 显示光标
\r 配合不换行原地刷新一行——进度条就是这么做的(记得 fflush(stdout))
注意输出重定向到文件时这些序列会变成乱码:用 isatty(STDOUT_FILENO) 判断再决定上不上色

终端模式:为什么按一个键没反应

概念说明
默认是行缓冲(canonical)模式终端驱动攒够一整行(按下回车)才交给你的程序,而且自动回显——所以游戏式的即时按键收不到
原始模式用 <termios.h>:tcgetattr 取出设置 → 关掉 ICANON 与 ECHO → tcsetattr 写回
务必恢复程序退出(包括崩溃)没恢复,终端会一直处于不回显状态——注册 atexit 并处理信号;救急命令是 reset
方向键不是一个字节,是 \033[A 这样的转义序列——要自己做状态机解析
窗口大小ioctl(TIOCGWINSZ) 取行列;SIGWINCH 在用户拖动窗口时送达
所以才要库上面每一条都得处理不同终端的差异——这正是 ncurses 存在的理由

ncurses 常用 API

函数作用
initscr() / endwin()进入 / 退出前必须调用,否则终端留在奇怪状态
cbreak() / noecho() / keypad(stdscr, TRUE)即时按键 / 不回显 / 把方向键解析成 KEY_UP 这类常量
mvprintw(y, x, "…") / addch在指定位置输出
refresh()ncurses 写的是内存里的虚拟屏幕,不刷新就什么都看不到
getch() / timeout(ms)读一个键 / 设成非阻塞,游戏循环要靠它
newwin / wrefresh / box子窗口 / 刷新子窗口 / 画边框
start_color() / init_pair / attron(COLOR_PAIR(1))颜色要成对注册再启用
编译gcc x.c -o x -lncurses

C 要做图形界面:四条路

方案特点代价
GTK正统的原生 C 桌面工具包,控件齐全,Linux 上是一等公民API 面大、要学它的对象系统与信号机制;打包依赖多
raylib纯 C、单库、API 极简,画图形 / 做小游戏最省事不是「桌面控件」,按钮菜单要自己画
SDL跨平台的窗口、输入、音频底座,游戏生态成熟比 raylib 底层,样板更多
Dear ImGui即时模式 UI,做调试面板与工具界面极快本体是 C++,C 项目要走绑定
选择建议要画东西 / 做游戏 → raylib;要正经桌面应用 → GTK;给自己的程序加个调试面板 → Dear ImGui。做命令行工具的话,先想清楚是不是真的需要窗口——TUI 往往更合适
改了终端模式就必须保证恢复,包括崩溃路径。程序在原始模式下段错误退出,终端会停在「不回显、不换行」的状态:你打字看不见、回车不起作用,看起来像是终端坏了。所以 tcsetattr 改设置后立刻 atexit 注册恢复函数,并给 SIGINT / SIGSEGV 也装上(07 章)。已经卡住时,盲敲 reset 回车能救回来。
在加任何 GUI 库之前,先试试转义序列 + \r 原地刷新能不能满足需求:进度条、状态行、彩色日志、简单的仪表盘,这些用几十行纯 C 就能做出来,而且零依赖、能在 ssh 里用、重定向到文件也不崩。

先分清一件事:终端不是「只能一行行往下打印」的设备。它认一套控制指令(ANSI 转义序列),能定位光标、改颜色、清屏——所谓 TUI 库,本质就是把这套指令包装得好用一点。

三条最基本的转义序列

  • \033[2J 清屏、\033[H 光标回到左上角、\033[10;20H 移到第 10 行第 20 列;
  • \033[31m 红色前景、\033[0m 恢复默认;\033[?25l 隐藏光标;
  • \033 就是 ESC(十进制 27),所以这些序列在 C 里就是普通字符串——printf("\033[2J\033[H") 即可清屏并归位。

光有转义序列为什么还不够

  • 输入是行缓冲的:默认情况下 getchar() 要等用户按回车才拿得到字符(终端处于「熟模式」canonical mode)。做交互界面需要切到原始模式(raw / cbreak),按一个键就能读到;
  • 回显要关掉:否则用户按的方向键会被终端自己打印出来;
  • 终端能力各不相同:颜色数、按键编码(同一个 F1 在不同终端里发不同的序列)、窗口尺寸变化。这些差异由 terminfo 数据库描述,而查 terminfo、切模式、算屏幕差异并最小化重绘——这就是 ncurses 存在的理由。

不用库能走多远

  • 进度条、彩色日志、简单菜单:裸转义序列完全够,还不用引依赖;
  • 一旦出现「多个可滚动区域」「窗口重叠」「不闪烁地局部刷新」「方向键与功能键」,就该上 ncurses 了——这几件事自己写会很快失控。
// 不用任何库的「彩色 + 定位 + 原始模式」最小示例(POSIX)
#include <stdio.h>
#include <termios.h>
#include <unistd.h>

static struct termios saved;

void raw_on(void) {
    tcgetattr(STDIN_FILENO, &saved);
    struct termios t = saved;
    t.c_lflag &= ~(ICANON | ECHO);      // 关行缓冲、关回显
    tcsetattr(STDIN_FILENO, TCSANOW, &t);
}
void raw_off(void) { tcsetattr(STDIN_FILENO, TCSANOW, &saved); }

int main(void) {
    raw_on();
    printf("\033[2J\033[H");                 // 清屏 + 归位
    printf("\033[5;10H\033[31m按任意键退出\033[0m");
    fflush(stdout);
    getchar();                                // 原始模式下按一个键就返回
    raw_off();
    return 0;
}
printf 的输出是带缓冲的,界面会「延迟半拍」。终端在行缓冲模式下,没有换行符的输出会一直待在缓冲区里——于是你画了半屏东西却什么都没显示,直到下一次换行或缓冲区满。TUI 程序里每次绘制结束都要 fflush(stdout)(ncurses 的 refresh() 内部就在做这件事)。这个坑的症状是「代码看着对,屏幕就是不动」。
改了终端模式一定要在退出前恢复,包括异常退出:程序崩在原始模式下,用户的 shell 会变成「打字看不见、回车不换行」的状态(补救办法是盲打 reset 回车)。稳妥做法是 atexit(raw_off) 再加上 SIGINT / SIGTERM 的处理函数——这和 12 章讲的信号处理是同一套东西,这里是它最直观的用武之地。

ncurses 是终端 UI 的事实标准(本机 WSL 上是 6.6),vim、htop、menuconfig 都建立在它或它的同类之上。它的心智模型只有一句话:你在内存里画一块「虚拟屏幕」,调 refresh() 时它算出与真实屏幕的差异,只发送必要的转义序列。

六个函数就能跑起来

  • initscr() 初始化并进入全屏模式;endwin() 退出并还原终端(必须调,否则终端会乱);
  • cbreak() 关行缓冲、noecho() 关回显、keypad(stdscr, TRUE) 让方向键/功能键返回 KEY_UP 这类符号常量而不是一串转义字符;
  • mvprintw(y, x, fmt, ...) 在指定位置写字、refresh() 把改动刷到屏幕、getch() 读一个键;
  • curs_set(0) 隐藏光标——不隐藏的话光标会在界面上乱跳,是「看起来很业余」的主要来源。

链接选项踩过的坑

  • WSL Ubuntu 上头文件 /usr/include/ncurses.h 已随开发包提供,不加 -lncurses 直接编译会在链接期失败,报的是 undefined reference to `initscr' 一串(编译期没问题,因为头文件声明是全的)——这正是 04 章讲的「编译期 vs 链接期」的又一个现场;
  • 加上 -lncurses 后正常产出,一个带边框与计数器的小程序,动态链接产物只有约 16 KB;
  • 对比一下量级就有感觉了:同样功能的 Go 版 TUI 程序(静态链接)是几 MB 量级。C 的界面程序小,是因为把 libc 与 ncurses 留给了系统。

三个必须知道的细节

  • 坐标是 (y, x) 不是 (x, y):ncurses 全套 API 都是先行后列,写反了不报错,只是画到别处;
  • 窗口尺寸会变:用户拖动终端会发 SIGWINCH,要重新取 LINES / COLS 并重画;
  • 宽字符要用 ncursesw:中文/emoji 占两列,普通 ncurses 会算错宽度导致错位,链接 -lncursesw 并调 setlocale(LC_ALL, "")。
// 编译:gcc tui.c -o tui -lncurses   (少了 -lncurses 就是 undefined reference)
#include <ncurses.h>

int main(void) {
    initscr();                       // 进入全屏模式
    cbreak(); noecho();             // 按键立刻可读、不回显
    keypad(stdscr, TRUE);           // 方向键变成 KEY_UP / KEY_DOWN
    curs_set(0);                    // 隐藏光标

    int count = 0, ch;
    while (1) {
        clear();
        box(stdscr, 0, 0);            // 画边框
        mvprintw(2, 4, "count = %d   (+ 加一, q 退出)", count);
        refresh();                    // 只发送差异,不是整屏重画
        ch = getch();
        if (ch == 'q') break;
        if (ch == '+') count++;
    }
    endwin();                        // 必须还原终端
    return 0;
}
ncurses 程序不能和 printf 混用。ncurses 维护着自己的虚拟屏幕,你直接 printf 出去的内容它不知道,下一次 refresh() 就会把那块区域按自己的记录覆盖掉——症状是「调试用的输出闪一下就没了」。要在 ncurses 程序里调试,把日志写文件(fprintf(logfp, ...))再 tail -f 看,或者用 gdb 挂上去。
refresh() 的差异算法就是 TUI 不闪烁的秘密:你可以放心地每帧 clear() 后重画整屏,ncurses 只会把真正变了的字符发出去。不要自己去做「只更新变化部分」的优化——那是库已经做完的事,自己做反而容易出错。这一点和虚拟 DOM 的思路是同一个:上层随便重画,下层算最小差异。

C 语言没有官方 GUI,社区给的方案分两种范式,选错范式比选错库代价大得多——它决定了你的程序结构长什么样。

两种范式

  • 保留模式(retained mode):你创建一棵控件树(按钮、输入框各是一个对象),库负责保存状态与重绘,你写回调函数响应事件。GTK、Motif、Win32 都是这一派——像在写事件驱动的服务器;
  • 即时模式(immediate mode):没有控件对象,每一帧你把整个界面重新「声明」一遍,if (Button("确定")) { ... } 这样直接拿返回值。Dear ImGui 是代表——像在写游戏主循环;
  • 判据:界面复杂、要长期驻留、要跟系统外观一致 → 保留模式;工具/调试面板/游戏内界面、状态本来就在你手里 → 即时模式。

四条具体的路

方案范式适合代价
GTK 4保留Linux 桌面应用、要跟系统主题一致依赖多、跨平台打包麻烦、学习曲线陡
raylib 6.0即时(自绘)小游戏、图形教学、可视化控件要自己搭,不是 GUI 工具包
Dear ImGui 1.92(cimgui 绑定)即时调试面板、编辑器、内部工具C++ 写的,C 项目要走 cimgui;外观不「原生」
SDL 3底层要自己掌控窗口/输入/渲染它只给你窗口和像素,控件一个都没有

先问一句:真的需要 GUI 吗

  • 命令行 + 配置文件能覆盖大多数系统工具的需求,而且可脚本化、可远程、好测试;
  • TUI(见本章前面)是很多场景的甜点:远程 SSH 可用、体积极小、开发成本低;
  • Web 界面:C 程序内嵌一个小 HTTP 服务器(07 章的 socket 编程),界面交给浏览器——跨平台问题一次解决,代价是多一层协议。路由器、打印机、NAS 的管理界面几乎全是这条路。
// 即时模式的手感(Dear ImGui 风格伪代码):每帧重新声明整个界面
while (!should_close()) {
    frame_begin();
    if (Button("保存")) save_file();      // 没有 onClick 回调,直接拿返回值
    SliderInt("音量", &volume, 0, 100);   // 状态是你自己的变量
    frame_end();
}

// 保留模式的手感(GTK 风格伪代码):建对象 + 挂回调
GtkWidget *btn = gtk_button_new_with_label("保存");
g_signal_connect(btn, "clicked", G_CALLBACK(on_save), NULL);
gtk_window_present(GTK_WINDOW(win));
g_main_loop_run(loop);                     // 控制权交给库
GUI 库会把「跨平台」这三个字的成本推迟到打包那一天。代码写完能在你的 Linux 上跑,不等于能发给 Windows 用户:GTK 应用要连同一大堆运行时 DLL、主题、图标一起打包,体积几十 MB 起步;用了 cgo/系统库的方案还会把交叉编译变成噩梦。选 GUI 库时把「怎么发给用户」当成选型的一部分,而不是最后一步——很多项目就是卡在这一步改回了 Web 界面。
即时模式最反直觉、也最省心的一点:它没有「界面状态」这个东西。按钮亮不亮、列表选中哪一项,全都是你自己的普通变量,于是「界面和数据不同步」这一整类 bug 直接消失了。代价是每帧都要重新计算与绘制,因此它适合本来就有主循环的程序(游戏、模拟器、渲染器),不适合「大部分时间在等用户操作」的桌面应用。

资料索引与自测清单

收尾两张表:按主题的资料与动手项目索引(每个方向读什么、造什么、产出物是什么)与自测清单,外加三条最常见的走法错误。

基础只有在造过东西之后才真正长在身上。下表按主题给出动手项目(带产出物)与读物——路线不在长,在每一步都能拿出造好的东西。

按主题

主题动手项目(产出物)读物
C 与内存
(02–05 章)
① 自己的小型容器库(动态数组 + 哈希表 + 链表),ASan / UBSan 与 Valgrind 各跑一遍、零报错(两者不能同时开)② 简化版 malloc(空闲链表 + 分割合并)③ 玩具 shell(fork / exec + 管道)K&R《C 程序设计语言》过手感;然后直奔 CS:APP 并做配套 lab——它是从「会写 C」到「懂系统」的那座桥
数据结构与算法
(09–10 章)
比刷题更有效的是在项目里解决真实的算法问题:给工具加 LRU 缓存(哈希 + 双向链表)、写迷你 diff(最长公共子序列)、对大日志做 Top-K 统计(堆)、给配置系统做依赖解析(拓扑排序)《算法导论》按需选读,不必从头啃;配可视化网站把过程「看」进脑子
网络编程
(08 章)
三级跳,每级都有能演示的产出物:① 多人聊天室(epoll 事件循环 + 长度前缀分帧 + 广播)→ ② 能跑通静态网站的 HTTP 服务器(解析头部、按 Content-Length 发正文、keep-alive、防目录穿越)→ ③ 小型 RPC 框架(自定义二进制协议、请求 ID 匹配、超时重试)Stevens《UNIX 网络编程 卷 1》是不过时的圣经;免费的 Beej's Guide 更短更友好,适合第二遍。做完再去读现成实现收获翻倍:muduo(结构最清晰)、libuv、Redis 的 ae.c(几百行,一晚上能读完)
系统与 POSIX
(07 章)
把 cat / ls / tail -f / wc 各写一个简化版——一天一个,最划算的练习;再做个目录监控或迷你 shell《UNIX 环境高级编程》当字典查;机制层面的「为什么」去操作系统页
Linux / Shell
(12–13 章)
把日常重复操作全部脚本化,每个脚本过一遍 shellcheck;租台便宜 VPS 从零配一套服务(用户、权限、systemd、日志)——最接近实战的 Linux 练习想更深可以试 Linux From Scratch,或写一个内核模块
Git
(14 章)
亲手拆开 .git 目录看对象模型:git cat-file -p 逐层看 commit → tree → blob。理解「一切都是指向快照的指针」之后,reset / rebase / reflog 再也不可怕Pro Git(免费在线)
Docker
(15 章)
用 unshare + cgroup 手搓一个几十行的迷你容器,看破「容器 = 被隔离的进程」;再往编排方向走 Kubernetes官方文档足够;先弄懂命名空间与 cgroup 再看编排
工程实践
(11 章)
回头给自己写过的任何一个项目补上:测试 + make test + CI + ASan 构建——这一步做过一次,以后所有项目都会顺手带上—

和别的页怎么接

想深入的方向去哪
CPU 怎么执行、缓存与流水线计算机组成页
进程调度、虚拟内存、文件系统的实现操作系统页
TCP / HTTP 协议本身的「为什么」计算机网络页
mov / call 与调用约定汇编页
编译器内部怎么工作编译原理页
换一门更现代的系统语言C++ 页 / Rust 页——先把这一页的内存模型走通,它们会好懂得多
最常见的走法错误是把读物当成前置条件。CS:APP、《UNIX 网络编程》都是千页量级的砖头书,从第一页啃到最后一页的人极少走得完。正确顺序是先动手做项目,卡住了再按目录去查对应章节——书是字典,不是关卡。另一个变形是同时开三个项目、每个都停在百分之三十:一个聊天室从头走到能给人演示,比三个烂尾工程加起来都有说服力。
挑项目的判据只有一条:你自己会用它。整理下载文件夹的小工具、监控某个目录的脚本、给自己的服务做的健康检查——这类东西有明确的完成标准,能逼你走完「写 → 编 → 调 → 打包 → 交付」的整条链路(00 章那十站),而这条链路才是真正的分水岭。

检验这一页学没学会,不看读完了几章,看下面这些事能不能在不查资料的情况下做出来。做不到的那一条,就是下一个该动手的地方。

自测清单

能不能……对应章
拿到一条报错,十秒内说出它属于编译期 / 链接期 / 运行期哪一类01
说清 -1 < 1u 为什么是假,以及 strlen(s) - 1 为什么危险02
读出 int (*f)(int) 与 int *a[10] 的区别,并说清 const 放在 * 两边的差异03
不看资料写出一个带 头文件、多个 .c、Makefile 的小项目04
说出 strncpy 为什么不是「安全版 strcpy」,以及 localtime 返回的指针归谁05
说清文件描述符是什么,以及为什么 socket 也能 read / write07
解释「粘包」不是 bug,并写出长度前缀分帧与 readn 循环08
给一个场景当场说出该用哪种数据结构以及为什么(含复杂度)09
手写一个不会越界、不会死循环的二分查找10
拿到一个段错误,用 ASan 或 gdb 定位到具体那一行11
写一个带 set -euo pipefail 与 trap 清理、能过 shellcheck 的脚本13
把提交搞砸之后救回来(reflog / revert / reset 各用在什么场合)14
写一个多阶段构建的 Dockerfile,并说清 CMD 与 ENTRYPOINT 的区别15

一个统一标准

标准说明
能否向别人讲清「为什么」为什么动态数组是默认容器、为什么哈希表怕恶意冲突、为什么 rebase 不能碰公共分支、为什么容器不是虚拟机
讲得清说明这块知识已经连成网
讲不清的那个点就是下一个该动手验证的地方——写个最小复现比再读三遍解释有用得多

三条最常见的走法错误

错误症状改法
把砖头书当前置条件书签停在第 80 页,半年没动书是字典,不是关卡:先做项目,卡住了按目录查
同时开三个项目每个都停在百分之三十一个做到能给人演示,再开下一个
只读不敲看的时候都懂,一写就错代码逐行敲一遍,然后故意改坏它——把 free 删掉看 ASan 报什么,把下标越界一位看会发生什么
别把这十三条当成考试,它们是「卡住时该去哪」的索引。真实的学习路径是螺旋的:你会在做聊天室时回头补 08 章的分帧、在查内存泄漏时回头补 03 章的所有权、在 CI 挂了时回头补 13 章的退出码。一条都还没打勾也完全正常——重要的是每次卡住都知道翻哪一章,而这正是这一页想成为的东西。
自测清单里有一条特别值得优先补:「拿到一个段错误,用 ASan 或 gdb 定位到具体那一行」。它是本页所有内容里复利最高的一项技能——因为在 C 里,你往后每天都会遇到它,而会不会这一手,直接决定一个 bug 是十分钟还是一下午。