全景:这条路怎么走
这一页是十五个主题的合订本,但它们不是并列的十五门课,而是一条路:先跑通第一个程序 → 用 C 理解机器 → 用 POSIX 系统调用直连内核 → 把 socket 写成扛得住并发的服务 → 亲手造数据结构与算法 → 在 Linux 里生活 → 用 Shell/Git/Docker 把代码变成工程。开始前先花五分钟看清这条路,之后每一章你都知道自己走到了哪里、为什么要走这段。
这条路的设计只有一个原则:每一段都为下一段扫清障碍。C 排在最前,不是因为它常用,而是因为它是理解机器的透镜。
路径的逻辑
- 01 先跑通再学语法:把编译器装上、程序真跑起来、学会把报错分成编译期 / 链接期 / 运行期——这一章省下的时间比它花掉的多;
- 02–06 拿 C 看内存:指针、内存布局、未定义行为,都是别的语言替你藏掉的东西;
- 之后是通用工程能力:数据结构与复杂度、Linux 与 Shell、Git、构建与测试——换任何语言都还成立。
与其它页面的衔接
- 「C 编译出来的东西 CPU 怎么执行」→ 计算机组成页;「进程与文件描述符背后发生了什么」→ 操作系统页;「
mov/call到底是什么」→ 汇编页。
把十五章串成一条线:你写了一个小工具,它从第一行代码走到别人机器上跑起来,每一站对应本页的一章。
十站旅程
① 跑通 hello 装工具链, 会读报错 // 01 章 ② 写代码 types/指针/结构体 // 02–03 章 ③ 编译链接 gcc -Wall, 多文件, Makefile // 04 章 ④ 选对容器 数组还是哈希表? O(n) 还是 O(log n)? // 09–10 章 ⑤ 跑在 Linux 权限, 进程, 管道, 日志 // 07 · 12 章 ⑥ 让别人连得上 socket, 分帧, 并发模型 // 08 章 ⑦ 崩了怎么查 测试先兜住, gdb/ASan 再定位 // 11 章 ⑧ 重复的活自动化 构建/测试/部署脚本 // 13 章 Shell ⑨ 敢改敢回退 分支实验, 历史排查 // 14 章 Git ⑩ 交付到别处 镜像打包,「在我机器上能跑」终结 // 15 章 Docker
- 迷失在某章细节里时,回到这条线问一句:「我学的这个东西,让哪一站不再卡住?」——本页所有内容都在这十站里有一个位置。
深入理解
- 这条线也是招聘市场说的「工程基本功」的真实含义:不是背八股,而是独立走完这十站、每一站卡住了知道怎么自救。走完一遍(哪怕程序只有两百行),比读完十份面经有用。
本页几乎每张卡都带可运行的代码或命令。读懂了 ≠ 学会了——学会的标志是:亲手敲一遍、故意改坏它、再修好。
三个习惯
- 敲,不要复制:代码卡里的例子逐行敲进编辑器编译运行。敲的过程会暴露你以为懂了其实没懂的地方(分号、星号的位置、为什么这里要取地址)。
- 改坏它:把 free 删掉看 Valgrind 报什么、把数组下标越界看 ASan 怎么抓、把 Dockerfile 的层顺序换掉看构建慢多少。错误信息是最好的老师,前提是你主动制造错误。
- 建一个练习仓库:学完 14 章 Git 后把练习纳入版本管理——工具章的内容不用「学」,用两周自然就会了。
环境准备(一次搞定)
Windows: 装 WSL2 (Ubuntu) —— 本页 Linux/Shell 章直接可用 macOS: 自带终端即可, brew 补齐工具 工具: gcc 或 clang, gdb, make, git, docker 一条验证: gcc --version && git --version && docker --version
- 编译永远带着
-Wall -Wextra,调试期再加-g -fsanitize=address——让编译器和 ASan 在第一时间告诉你哪里写错了,这两个开关能替你省下本页一半的排错时间。
-Wall -Wextra 的每一条警告读完再跑,才是这页最省时间的读法。(ASan 与 Valgrind 为什么不能叠着用,见 04 章。)alias cw='gcc -std=c23 -Wall -Wextra -g -fsanitize=address,undefined',之后练习一律 cw x.c -o x && ./x——越界与 UB 当场报出文件和行号。跑通第一个 C 程序
先把环境搭起来、把一个程序真的跑起来,再谈语法。这一章做四件事:装好编译器、跑通 hello、逐行看懂它、学会读报错。后面所有章节的代码片段,都默认你已经有一个能编译运行的环境。
这一卡只有一个目标:让你的机器能把一段 C 源码变成一个真的能跑的程序。在这之前,读再多语法都是空的。
按平台装编译器
- Linux 多半已自带
gcc,没有就sudo apt install build-essential;macOS 跑xcode-select --install(编译器实际是 clang,但gcc命令一样能用); - Windows 装 WSL2(Ubuntu)最省事——不但 C 的工具链齐全,本页 12–15 章的 Linux/Shell/Git/Docker 也全都直接可用;不想装 WSL 就用 MSYS2 的 MinGW-w64;
- 验证只需一条:
gcc --version能打出版本号就成了。
两步:编译,然后运行
- 编译
gcc hello.c -o hello,运行./hello。-o指定输出文件名——省掉它 gcc 会生成一个叫a.out的文件,这是 Unix 沿用几十年的默认名,看到它别以为出错了; ./不能省:Linux 默认不从当前目录找可执行文件,直接敲hello会得到 command not found。./的意思就是「就在当前这个目录里」。
/* hello.c */
#include <stdio.h>
int main(void) {
printf("Hello, C!\n");
return 0;
}
# 编译 → 运行
gcc hello.c -o hello
./hello # 输出:Hello, C!
# 省掉 -o 就得到 a.out
gcc hello.c && ./a.out.c。写成 .cpp,gcc 会按 C++ 编译——简单程序照样能过,于是你以为在写 C、实际写的是 C++,直到某个只有 C 才有的行为对不上才发现。-Wall -Wextra(04 章细讲)。上一卡那个程序你已经跑起来了,但每一行都还是黑箱。这五行里没有一行是装饰,拆开看完,你就能读懂本页后面几乎所有代码的骨架。
四个部分各管一件事
#include <stdio.h>——把标准输入输出的声明搬进来,没有它编译器不认识printf;尖括号找系统目录,双引号先找当前目录;int main(void)是程序唯一的入口;printf里的不换行的话,输出会和 shell 提示符黏在一起。
最后那个 return 不是走过场
main的返回值就是这个进程的退出码,惯例 0 成功、非 0 失败;改成return 3,跑完echo $?得到 3;- 这不是细枝末节——shell 的
&&、CI 的成败判定、脚本里的条件分支,读的全是这个数。
#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#include <stdio.h> 在老编译器上只是个警告、程序还能跑——编译器会自作主张假设那个函数「返回 int、参数随便」,一旦真实签名对不上就是未定义行为。C23 起这条终于变成硬错误。main、结尾 return 0。新手最大的时间黑洞是看不懂报错。C 的错误按发生阶段分三类——先分清是哪一类,排查范围立刻缩小一个数量级。
① 编译期:代码本身不合法 ② 链接期:合法但拼不起来
- 编译期报错带文件名:行:列;漏分号时报的位置常在下一行——真正的问题在报错行或它上一行;
- 链接期最典型的是
undefined reference to 'foo',不指向源码某一行:函数只声明没定义、少链接一个库、漏了某个.c;
③ 运行期:编译链接全过,跑起来才炸
- 解引用空指针时
-Wall -Wextra一句警告都没有,运行才Segmentation fault(退出码 139); - 段错误 = 访问了不该访问的内存,没有行号——这正是下面那些开关要解决的问题。
让编译器和 sanitizer 替你把错误提前
- C 默认几乎不做检查,但检查只是被放进了你必须自己打开的开关:
gcc a.c -o a -Wall -Wextra -g,两个都加才算及格线; - 这些警告不是噪音:返回局部变量地址、有无符号混比、字符串截断,全靠它们才现形;
- 运行期那类靠
-fsanitize=address,undefined:出错当场停下并报出位置,慢几倍,发布时去掉。
# ① 编译期:漏分号(注意它指的是上一行)
# hello.c:4:26: error: expected ';' before 'return'
# ② 链接期:main 拼成了 mian
# undefined reference to `main'
# collect2: error: ld returned 1 exit status
/* ③ 运行期:编译零警告,跑起来才炸 */
int *p = NULL;
printf("%d\n", *p); /* Segmentation fault,退出码 139 */#include 会直接报硬错 implicit declaration——答案永远是「漏了 include」。C 语言基础
C 是贴近硬件的过程式语言:静态类型、手动内存、编译为机器码。掌握类型、运算符、控制流、数组与字符串、输入输出、函数与复合类型,是理解一切系统软件的地基。
C 的类型不是抽象标签,而是「这个变量占几个字节、这些字节怎么解释」的直接声明——同一段内存换个类型读出来就是另一个值。选对类型是写对 C 的第一步。
先把尺寸记住
- 在 x86-64 Linux(gcc/clang)上
sizeof是:char1、short2、int4、long8、long long8、float4、double8、long double16、指针 8、size_t8。 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 */int x = 0;)。__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]);<= 和 < 差一次就越界,坚持半开区间 [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当场报出。把strcpy换成snprintf(d, sizeof d, "%s", "hello, world")重跑:不崩了,安全截断。同一个操作,一个是漏洞、一个是安全。 - 验「长度会丢」:main 里对
int a[5]打sizeof a得 20,传进void f(int a[10])里再打只剩 8,gcc 还顺手警告-Wsizeof-array-argument。长度在传参那刻蒸发,亲眼看过就再不会忘「传数组必带 n」。 - 验「全靠结尾 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' */'\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是最少数字位数;同样可写.*动态传入。 - 长度:声明实参的真实类型——
%ldlong、%lldlong long、%zusize_t、%hdshort。 - 转换:
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)) { /* 结果被截断了 */ }-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("%d") 之后再 getchar() 或 fgets(),拿到的是那个残留的 '\n'——表现为「程序把我的输入跳过去了」。清理惯用法:while ((c = getchar()) != '\n' && c != EOF);(getchar/EOF/缓冲区详见下一卡「字符流、EOF 与缓冲」)。不要用 fflush(stdin):标准只定义了刷新输出流,刷输入流是 UB,个别平台碰巧能用不代表可移植。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;,否则你读到的可能是上一次某个无关库函数留下的陈旧值。\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"。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),看 21! 从正数翻成负数(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 未必抓得住。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 = 成功 */
}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 的灵魂,也是最大陷阱来源。理解地址、解引用、动态分配与内存布局,才能写出正确高效的 C——以及看懂段错误从何而来。
指针是 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;把int换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覆盖,那块原本还好好的内存再也没人能找到它,直接泄漏。正确写法是先接到一个临时指针,确认非空后再赋回去。
现在你来:写一个会长大的数组
- 从
malloc(2*sizeof(int))起步,装满就realloc翻倍,push 进 1..100。每次 realloc 前后都printf("%p", (void*)a)——你会亲眼看到某几次地址真的变了,「realloc 可能搬家」从此不是一句话。 - 故意犯错: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,避免悬空 */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),根本看不出「问题出在返回了局部地址」。真正一句点名这个 bug 的,是编译期那条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 的指针或用输出参数 */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);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 章网络编程里「分帧」要反复强调的同一件事。
现在你来:亲手把填充逼出来
- 把右边 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 的倍数 */sizeof/offsetof。位域的底层位排布由实现定义(哪端先放、能否跨存储单元都不保证),别用位域直接映射网络协议或文件格式——那种场合应手动移位与掩码(见 02 章位运算)。想去掉填充可用 #pragma pack 或 __attribute__((packed)),但代价是非对齐访问在某些架构上会变慢甚至崩溃。offsetof 把每个字段的偏移打印出来——相邻两个字段的偏移差大于前一个字段的 sizeof,中间那段就是 padding。想让编译器帮你盯着,gcc/clang 的 -Wpadded 会在每次插入填充时给出提示(信息量很大,通常只在优化某个热点结构体时临时打开)。真要压缩体积,先调字段顺序:这是零成本的——不改语义、不改可移植性、不影响访问速度,只是把声明重排一下。现代 C 与工程
从单文件玩具到真实工程:预处理器、多文件编译、C99/C11/C17/C23 新特性、文件 I/O、调试工具链,再到进程、线程与并发编程——是写出可维护 C 项目的工程能力。
编译器还没上场,你的代码就先被一台只认文字、不认类型的替换机改写过一遍——宏的所有怪脾气都从这里来。
预处理器在编译前做文本替换:对象式宏定义常量、函数式宏(所有参数和整体都要加括号防优先级 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 */gcc -E file.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");float 一律升成 double、比 int 窄的整数升成 int——所以 va_arg(ap, float)、va_arg(ap, char) 都是错的,必须取 double、int。取的个数或类型和实参对不上就是 UB:和本章「宏与预处理器」卡里的 LOG 宏不同(那是纯文本替换、由 printf 兜底核对格式),这里是你自己在解析、没有安全网。所以可变参数函数总得有个「知道该取几个、什么类型」的约定:计数、哨兵 NULL,或像 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=c23int 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 清理missing separator——make 最著名的坑,编辑器悄悄把 Tab 转成空格是常见诱因。② 每行命令在独立子 shell 里执行,因此 cd sub 不会影响下一行;要连贯执行得写成一行、用 && 连接。③ 忘了 .PHONY,当目录里恰好有同名文件时规则会「永不过时」而被静默跳过。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。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);
}int arr[n])分配在栈上,n 较大时会栈溢出崩溃,且 C11 起变为可选特性。大小不定的数组优先用 malloc 放堆上。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,编译器不替你检查。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 / C17 | C23 |
|---|---|---|
_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_assert | assert(<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;
}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(下一张卡)几乎不浪费:刚「复制」的页还没被写过,就被新程序整个丢掉了。
现在你来:亲眼看进程一分为二
- 把右边跑起来——一次运行打出两行(父一行、子一行),同一个
return 0被两个进程各执行一次。改一改:fork()前定义int x = 0;,子进程里改成x = 99并打印,父进程sleep(1)后也打印x——父进程的x还是 0,亲证「fork 之后各改各的副本」。 - 撞缓冲区那个坑: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; /* 两个进程都会执行到这里 */
}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 + 制造一只僵尸
- 把右边补成完整程序跑通:子进程
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));_exit 而不是 exit 或 return:子进程此刻还带着 fork 来的标准 IO 缓冲区副本,exit 会把它刷一遍,父进程稍后再刷一遍,同一段输出打两次;_exit 直接退出、不碰缓冲区。更糟的是 return——子进程会继续执行父进程的代码,两个进程抢着往下跑。②僵尸是真能看见的:让子进程先退、父进程 sleep 着不 wait,ps -eo pid,stat,comm 里那一行 STAT 为 Z 就是它。长跑的服务进程忘了 wait,进程表会被慢慢填满。& 后台运行,不过是父进程不 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注册处理函数。 - 信号会在任意时刻打断主流程去执行处理函数,执行完再跳回原处继续——它不是消息队列,同种信号连发多次可能只被处理一次,别拿它传递计数或数据。
现在你来:搭一条管道,再撞一次挂死
- 把右边管道那段跑通:父写
"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 标志位,真正的工作放回主循环做。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 最难查的地方在于它不崩溃——程序照常跑完、照常给出一个结果,只是那个结果是错的。
现在你来:把数据竞争抓现行
- 把右边跑三遍——
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 并调整编译期行为。它属于「没有任何理由省」的开关,只是它替你挡掉的报错,在新系统上已经看不见了。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 标准库:深水区
上一章教你写 C,这一章带你进标准库的深水区——那些查手册查不到、但不知道就会错的事。C 标准库很小,小到你会以为它没什么可讲——但它是 1970 年代的设计,很多函数的行为和名字给人的直觉相反:strncpy 不保证给你留结尾符、atoi 无法告诉你解析失败了、qsort 的比较函数写成相减会在大整数上排错、localtime 返回的指针会被下一次调用冲掉。查手册能查到签名,查不到「这么用会错」——所以本章讲的是水面之下的部分:同一件事有三个函数时该挑哪个、返回的指针归谁、哪些函数不可重入。「这个函数怎么调、参数是什么」不在这里查——那是下一章「C 标准库速查」的活,两章是「深水区 vs 速查」的分工,和 C++ 页那对姐妹章一个体例。本章所有反直觉的行为都在 Linux 上用 gcc 验证过。
「把一个字符串拷进缓冲区」这件最基本的事,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 */另外别忘了给结尾符留位置:
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 一份,或者当场拷进自己的缓冲区。_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 高得多。.h 丢进项目就能用,不引入构建依赖。知道边界在哪,才知道该往哪儿找。C 标准库速查
本章是上一章「C 标准库:深水区」的速查伴侣——那章讲该挑哪个函数、返回的指针归谁,这一章按头文件把签名一条条列出来备查,对应 C++ 页「标准库 STL」与「标准库速查」的分工。C 标准库不大,却是每个 C 程序的地基:格式化 I/O、动态内存、字符串与内存操作、字符分类、数学、时间、布尔与变参、错误处理、信号与非局部跳转、本地化与宽字符。这一章按头文件分卡——每个函数上方一行注释按「作用|入参|成功返回|失败返回」四段速读(纯函数无失败段,同构的一组把共有段落提到组头),当作写 C 时随手可翻的函数参考。重复的用法只指向 02/04 章展开处,不再赘述。注意边界:这里只收 ISO C 标准头;unistd.h、sys/* 属于 POSIX,glibc 私有扩展属于 GNU——都不是标准库,另见 07 章「POSIX / 系统编程」。
<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(非法指令) */volatile sig_atomic_t 赋值,或调用 abort/_Exit/signal,碰其它变量或调 printf/malloc 都是未定义行为。signal() 的语义各平台还不统一(有的收到信号后自动重置为默认动作)——要可靠地长期捕获,应改用 POSIX 的 sigaction(见 07 章)。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 私有扩展单列一卡并标注可移植性。
从这一章起,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;
}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 自带一层缓冲,两套各记各的位置必然打架。/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 deniedstrerror(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 函数。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) 唤醒 */errno(和别的系统调用不同),别去查 errno。等条件必须 while 不能 if(虚假唤醒);多锁要固定加锁顺序防死锁。忘了 -pthread 会链接失败或行为诡异。C11 的 <threads.h>(thrd_*/mtx_*)是标准化等价物,但工程仍以 pthreads 为主。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 的二进制版) */strdup/strndup 到 C23 才进标准,之前都算扩展。getline/asprintf 分配的缓冲要自己 free。想保持可移植,要么改用标准替代(fgets+手动扩容、两趟 snprintf),要么用 #ifdef __GLIBC__ 兜底。man 手册页底部的 CONFORMING TO / STANDARDS 段会写明某函数属于 C 标准、POSIX 还是 GNU 扩展——拿不准某个头/函数归哪一层时,查它最快。网络编程
上一章说「socket 也是文件描述符」,这一章把它展开成一门手艺:从一个能被 nc 连上的 echo 服务器起步,先解决所有新手都会撞上的三堵墙——TCP 没有消息边界、send/recv 都可能只做一半、连接关闭远比想象中复杂;再沿「一次一个连接 → 每连接一线程 → select → epoll」把并发模型走一遍,最后亲手写一个浏览器能打开的 HTTP 服务器。本章所有代码都在 Linux(含 WSL)上用 gcc 跑通,报错原文和运行输出都是真的照抄下来的;协议本身为什么这样设计(三次握手、拥塞控制、TLS)属于计算机网络页,这里只管「怎么写对」。
07 章已经把结论给了你:一条网络连接在程序眼里就是一个文件描述符,read/write/close 原样能用。(要是「fd 到底是什么」对你还是个黑盒,先回 07 章第 1 卡——那张卡把 fd 的三层结构拆开了,本章后面每一张都建在它上面。)既然如此,为什么还要单独一章?因为这个 fd 有三个别的 fd 没有的脾气,而它们正是新手所有 bug 的来源。
三堵墙——本章的主线
- 它没有边界:你
send三次,对方可能一次就全收到,也可能收到半条。文件不会这样,管道也很少这样。→ 第 04、05 卡 - 它随时会做一半:
send一千字节可能只发出去三百,recv可能被信号打断。→ 第 05 卡 - 它会被对面单方面掐断:对端崩了、拔网线了、进程被 kill 了,你的 fd 还在,写下去会让你的进程无声无息地死掉。→ 第 06 卡
一次通信的全流程
服务端 客户端 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。想知道「内核怎么做到的」去那里。
<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:它足够大能装下任何一种。
现在你来:把字节序看进眼里
- 写六行小程序:
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。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 秒自解——下一卡专讲)。把那两行加回就好。 - 把
send外层的while (off < n)循环删掉、只send一次:本机测几乎看不出问题——而这个「看不出」正是后面两卡的引子,短写和粘包都是本机测不出、一上真实网络才爆炸的坑。
#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; /* 为什么必须有这行,见本章第 07 卡 TIME_WAIT */
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(本章第 07 卡专讲)。②
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 之别的实际后果。 - 这解释了一个常见困惑:为什么「对方明明已经断开,我还能成功写一次」。也说明写成功不等于对方收到了——真要确认,只能靠应用层的确认消息。
send 同步产生的,处理函数返回后 send 照样返回 -1 且 errno == EPIPE,结果和 SIG_IGN 一模一样——却多了「改动全进程状态」「处理函数里几乎什么都不能做」两个代价。要么 SIG_IGN,要么用下面的 MSG_NOSIGNAL。多线程程序里也要注意:SIG_IGN 是整个进程范围的,设一次就够;而信号处理函数在哪个线程执行是不确定的(见 07 章信号卡)。另有一个逐
send 生效的替代品:send(fd, buf, n, MSG_NOSIGNAL),只对这一次调用抑制信号——写库代码时比全局改进程状态更礼貌。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);tcp_tw_recycle 尤其不要碰:它在 NAT 环境下会随机丢弃连接,Linux 4.12 起已经被彻底移除。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 变成能同时服务两个人
- 先看到问题:拿最初那个迭代版 echo,开两个
nc同时连、都别断开。你会发现第二个敲什么都没回音——它卡在accept前的队列里,因为第一个连接的收发循环还没退出。这就是「一次一个」的致命伤,亲眼看到再谈改进。 - 再动手改:把右边「每连接一线程」那段套上去(
accept后pthread_create、malloc一份 fd 传进去、pthread_detach),gcc ... -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,线程结束后资源不回收,跑几万个连接就把内存耗尽了。fork 之后父子都持有 conn 和 srv 两个 fd,父进程必须 close(conn)、子进程必须 close(srv),否则引用计数不归零,连接永远不真正关闭。这正是第 06 卡「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 那种量级的事情。
现在你来:把 echo 改成 UDP 版
SOCK_STREAM改SOCK_DGRAM,删掉listen/accept,收发换成recvfrom/sendto(把recvfrom拿到的peer原样回给sendto)。测试端nc -u 127.0.0.1 9300。- 验「有边界」:客户端连发三个小包,服务端每次
recvfrom只拿到一个完整包——对照 TCP echo 那边的粘连,UDP 这里天然一发一收,分帧问题凭空消失。 - 踩「静默截断」:故意把接收缓冲开成
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 字节以内最稳妥(给隧道封装留余量)。
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 的随机因子,把大家的重试时刻打散。
现在你来:给自己的 echo 写个客户端
- 用右边
getaddrinfo的骨架连"127.0.0.1"/"9000",连上后从stdinfgets一行 →send→recv打印。你就有了自己的nc,从此不必再借工具测服务端。 - 把目标换成真实域名
"example.com"/"80",发一行GET / HTTP/1.0\r\n\r\n,读回真正的 HTTP 响应——同一份客户端代码,亲证getaddrinfo的域名解析与 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 库,要么把解析扔到后台线程。试 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 能收全请求头——正是第 04 卡讲的错误。真实实现必须循环读到出现 \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 refused | ss -tanp | grep 端口 | 服务没起、绑错网卡(绑了 127.0.0.1 却从外部连)、端口写错 |
| 连接一直卡着不返回 | tcpdump 看 SYN 有没有回应 | 防火墙丢弃(不是拒绝)、IP 不可达、对方过载 |
| 连上了但收不到数据 | strace 看阻塞在哪 | 分帧写错互相等对方、忘了发结束标记、双方都在 recv |
| 服务器莫名退出 | 退出码是不是 141 | SIGPIPE(见关闭那卡) |
| 连接数上不去 | ulimit -n 和 ss -s | fd 上限、backlog 太小、TIME_WAIT 堆积 |
ss 里显示 ESTAB 不等于连接还活着。空闲的 TCP 连接双方一个包都不发:对端断电、拔线、中间 NAT 表项过期,你这边看到的仍是 ESTAB,而且会一直是——要等你下次写入并耗完漫长的重传超时(或开了 SO_KEEPALIVE 才有的两小时后探测——它默认是关的)才会发现。所以别拿 ESTAB 数量当服务健康指标,判断连接是否存活要靠应用层心跳(见关闭那卡)。watch -n1 'ss -tan | grep 你的端口'。连接从 SYN-SENT → ESTAB → FIN-WAIT → TIME-WAIT 的整个流转在你眼前实时演出,本章讲的每一个概念都会变成看得见的东西。数据结构
数据结构决定了每种操作的代价。数组、链表、栈、队列、树、平衡树、堆、哈希、字典树、图——先看懂它们「在哪种操作上快、在哪种操作上慢」,才能在写代码时选对容器。下面每种结构都配直觉类比、复杂度对照和真实用途。
选数据结构,本质是问一句:我最频繁的操作是哪个,哪种结构在这个操作上最快?下表是常用结构的「操作代价」速查,后面每张卡再逐个展开。(下面的 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、优先级调度 → 堆。
- 大量字符串前缀匹配、自动补全 → 字典树。
- 建模关系 / 网络 / 依赖 → 图。
malloc 逐个分配、next 指向被打乱」的链表,同是 O(n),耗时能差两三个数量级(具体倍数看你的 CPU 和缓存,自己跑一下)。表里两者都写「遍历 O(n)」,可差距全在缓存上。所以这张表只用来排除量级明显不对的选项(别拿 O(n) 的链表当随机访问用);量级相当时一律先默认连续内存的动态数组,等真测出瓶颈再换。最常用的线性表:一块连续内存顺序存元素,像一排编了号的座位——报座位号能瞬间找到人(下标 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),优势全部还回去。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;
}std::map 都用红黑树。insert 里灌 10 万个 key,把树高和平均比较次数打出来(这两个数只取决于插入顺序,和机器无关,谁跑都一样):乱序插入树高约 40、平均比较约 20 步;按 0…99999 有序插入,树高直接变成 100000——整棵树退化成一条链,平均比较 49999.5 步,是前者的两千多倍。严重的是「有序输入」恰恰是生产里最常见的:自增主键、时间戳、已排好序的导入文件。所以自测树结构时第一组数据就该是有序的,随机数据只会让你误以为一切正常。普通 BST 遇到有序数据会退化成链表。平衡树的解法是:每次插删后若发现某处「一边太高」,就用旋转把子树转一转,把树高强行压回 O(log n)。旋转只动几根指针,仍是 O(1)。
两种主流平衡树
- AVL 树:严格平衡,任意节点左右子树高度差 ≤ 1。查得最快,但插删可能触发较多旋转——适合读多写少。
- 红黑树:用「红 / 黑染色」规则放宽平衡(最长路径 ≤ 最短的 2 倍),插删旋转更少——适合写也频繁的通用场景。
旋转的直觉
- 「右旋」= 把左孩子提上来当根,原根降为它的右孩子;「左旋」镜像。像跷跷板把重的一端压下去。
用在哪
- C++
std::map/std::set、JavaTreeMap、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 成为这棵子树的新根 */
}堆是一棵只保证「堆顶是最值」的完全二叉树——它不像 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;
}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),也没有现成的「改某个元素优先级」操作。哈希表解决的是按 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; /* 不存在 */
}uthash)——自己实现时别忘 strdup 复制 key(否则悬空),释放时同时 free key 和节点。专治字符串的树:把单词按字符一层层拆开,公共前缀只存一份。查一个词的耗时只跟词的长度有关,跟词典里有多少词无关——这是哈希表做不到的前缀能力。
怎么存、为什么与词数无关
- 每个节点代表一个字符位置,孩子指针按字符分叉(小写英文就是 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; /* 没断路、且恰好是词尾,才算有这个词 */
}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} |
| 1 | 0 | 1、2 | [1, 2] | {0,1,2} |
| 2 | 1 | 3 | [2, 3] | {0,1,2,3} |
| 3 | 2 | (3 已标记,不重复入队) | [3] | 不变 |
| 4 | 3 | 4 | [4] | {0,1,2,3,4} |
| 5 | 4 | 无 | [],结束 | 全部 |
- 访问顺序 0, 1, 2, 3, 4,天然分层:{0} → {1,2} → {3} → {4}——层号就是到起点的最短跳数。
- 第 3 步里 2 也连着 3,但 3 已被 1 抢先发现——标记的作用就是保证每点只入队一次。
同一张图跑 DFS(0)
- 访问 0,走第一个邻居 → 1
- 访问 1,邻居 0 已访问,走 → 3
- 访问 3,邻居 1 已访问,走 → 2
- 访问 2,邻居 0、3 全已访问,撞墙回溯到 3
- 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); */ulimit -s 可查,各系统不同),这份 dfs 能撑到十万量级的深度——所以 10⁵ 个点的链状图往往「刚好还活着」,余量却薄得很。更要留意的是同一份代码,-O0 会比 -O2 早崩好几倍,因为优化会显著压缩栈帧,而你调试时用的恰恰是先崩的那个。真正的判据是栈帧大小 × 深度 < 栈上限,而不是某个记住的层数:函数里多一个几百字节的局部数组,阈值立刻掉一个数量级。想知道自己的阈值,写个递归计数器二分一下即可。深度可能上 10⁵ 就该改成「显式栈 + 循环」的迭代版(正是栈与队列卡说的「递归 ↔ 栈」通用手法);② 出队时才标记——同一顶点会被多个邻居重复入队,队列膨胀、复杂度退化,必须入队瞬间就标记。算法分析
算法把数据结构真正用起来解决问题,优劣由复杂度衡量。本章从大 O 出发,覆盖双指针、二分、排序、递归回溯、贪心、图算法与动态规划——每个都配直觉、模板与易错点,帮你从「能跑」走到「跑得快、想得清」。
大 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) { ... }qsort 快好几倍,要到段长上千才被反超(具体的交叉点取决于 CPU 与标准库实现,得自己测)。原因是 qsort 每次比较都要走一次函数指针调用,常数远大于插入排序的一次内存移动。所以别拿「我复杂度更低」去论证小数据场景更快——标准库对小区间切换插入排序,正是同一个道理。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——无序时「和太大就收右界」这个判断不再成立,指针朝错误方向挪,一路把解跳了过去。它不报错、不崩溃,只是安静地少给你一组答案。判据很简单:只要移动决策依赖「往这边挪值会变大」,就一定要先保证序。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);
}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]); /* 撤销:换回来,别污染下一轮试探(回溯)*/
}
}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;
}{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 在还没被路径压缩过的长链上同样会爆栈——深度取决于链长,能撑多深没有通用数字——和本章「递归、分治与回溯」那卡说的是同一条:栈帧大小 × 深度 < 栈上限。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,或压成滚动的一维数组。工程实践
前面九章教你把程序写出来,这一章教你信得过它。C 没有官方测试框架、没有包管理器、编译器默认也几乎不拦着你——所以「怎么知道它是对的」在 C 里比在别的语言里更依赖自觉。本章走四件事:给 C 搭测试(框架短到可以自己写,40 行)、把编译器的检查能力开满(这里有一个几乎所有人都想错的地方)、用 sanitizer 把内存与并发的 bug 逼到当场崩溃、以及崩了之后用 gdb 和 core 文件倒推现场。最后一卡讲性能——但顺序是「先测量,再优化」,因为编译器很可能已经把你想测的代码整个删掉了。本章所有报错原文和工具输出都是在 Linux(含 WSL)上用 gcc 真跑出来的;具体耗时和倍数只给量级,你的机器不会和这里一样。
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> 保留的前缀 */_Buf、__len)——保留给实现(编译器内部、关键字扩展就用它们),撞名是 UB;下划线放名字中间 / 结尾才安全。② 慎给自定义类型加 _t:<stdint.h> / POSIX 保留了 _t 结尾,widget_t 有朝一日可能和标准类型撞名——稳妥就不加,或换个后缀。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另外
--coverage 编出来的二进制不要拿去测性能,也别发布:它在每个基本块里插了计数器,还会在退出时写 .gcda 文件。-fno-sanitize-recover=all 不是可选的:UBSan 默认只打印一行然后让程序继续跑完,退出码仍然是 0。不加这个标志,CI 会在满屏 runtime error: 的情况下显示绿色通过——下一组卡会把这个坑单独摊开讲。「我开了 -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-那个名字-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-fanalyzer 的分工:静态分析看所有路径但会误报,sanitizer 零误报但只看走过的路径。两个都要,而让 sanitizer 走到更多路径的办法,就是写更多测试。还有一条常被忽略:链接时也必须带
-fsanitize=...。只在编译阶段加、链接时漏掉,会得到一堆 undefined reference to '__asan_...'。段错误只给你一行 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 1234ulimit -c unlimited 只对当前 shell 和它之后启动的子进程有效,而且不会影响已经在跑的服务。以为设了就万事大吉、结果线上崩溃依然没有 core,多半是这个原因——服务是由 systemd 之类拉起来的,要在服务单元里配(LimitCORE=infinity),不是在你的终端里配。另外 core 文件是完整内存快照:一个吃了 8 GB 内存的进程会写出一个几 GB 的文件,可能瞬间撑爆磁盘,而且里面可能含有明文密码、密钥、用户数据——生产环境开 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;
}还有一条不算陷阱但常被忘记:
--coverage 和 sanitizer 编出来的二进制不能用来测性能,它们都往代码里插了额外的东西。测性能请用干净的 -O2 构建。nm ./bench_O2 | grep 函数名 # 没输出 = 函数整个没了
objdump -d ./bench_O2 | grep -A20 函数名顺带一提,perf 在很多 WSL / 容器 / 云主机上装不上或者用不了(内核不支持,或 /proc/sys/kernel/perf_event_paranoid 限制了权限)。跑不起来别硬磕,clock_gettime 手动埋点 + 二分定位能解决绝大多数「到底慢在哪」的问题,只是笨一点。Linux
Linux 是服务器与开发的默认环境。掌握文件系统、权限、进程、文本处理与管道,就能在命令行里高效地导航、排查和自动化——这是工程师的日常底座。
在 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 -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 | headgrep 的第一个参数是正则不是字面串。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。Shell 脚本
Shell(Bash)把命令编织成自动化脚本。掌握变量引用、参数扩展、条件循环、函数与健壮性设置,就能把重复劳动变成可靠的一键脚本。
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"; donefor 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 "上条命令失败"; filocal 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 gzipset -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 是分布式版本控制的事实标准。理解三区模型、分支合并、远程协作与历史排查,才能在团队里安全地提交、回退、协作而不丢代码。
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 --continuemerge 保留真实分叉,本地的脏提交在推送之前用 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 即可(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 # 恢复并移除最近的 stashgit 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.gitignore;若已误提交敏感信息,需用 git filter-repo 清理历史并立即轮换该密钥。.gitignore 只拦未跟踪的文件,对已经提交过的文件完全无效——把 .env 写进 .gitignore 后再改它,git status 照样显示 M .env。正确做法是 git rm --cached .env 停止跟踪(文件仍留在磁盘上)再提交,但要清楚这只让它不再被新提交记录,旧历史里的内容一个字都没少。所以顺序很重要:新项目先写好 .gitignore 再第一次 git add,别指望事后补救。同理 rebase -i 整理提交也只在推送前做。Docker
Docker 用容器把应用和依赖打包成可移植的镜像,「一次构建,处处运行」。掌握镜像/容器、Dockerfile、Compose 与生产实践,就能交付一致、隔离、可复现的环境。
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 的顺序是「主机:容器」,写反了不会报错——端口照样映射成功,只是映射到了你没料到的地方,然后你会对着连不上的服务查半天。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/*),否则缓存留在镜像里白白增重。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 myappUSER 降权,并避免把 Docker socket(/var/run/docker.sock)挂进不可信容器。:latest:它不是「最新」的承诺而是一个会漂移的标签,今天构建和下个月构建可能拿到完全不同的基础镜像,让「在我机器上能跑」以最隐蔽的方式回归——锁到具体版本(甚至 @sha256: 摘要)。②日志一律写 stdout/stderr,别在容器里写日志文件:容器是随时可被销毁重建的,写进去的文件跟着一起没,而写到标准流才能被 docker logs 和各种采集系全部一接管(这正好呼应 02 章说的"stderr 不缓冲")。③如果你的程序会 fork 子进程,加上 --init:容器里的 PID 1 不承担收养孤儿进程的职责,没有它,退出的子进程会在容器里堆成僵尸——04 章讲的那个问题,换到容器里会更容易发生。C 的界面:终端 UI 与 GUI 库
前面十五章写的程序都只有 printf 一个输出口。这一章补上「界面」这一层:终端是怎么被当成 UI 用的、ncurses 解决了什么、以及 C 程序要做图形界面时有哪几条路——顺带把「一个界面要付多少体积」这笔账算清楚。
在装任何库之前先搞清楚一件事:终端不是「只能一行行往下打印」的设备。它认一套控制指令(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() 内部就在做这件事)。这个坑的症状是「代码看着对,屏幕就是不动」。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;
}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); // 控制权交给库cgo/系统库的方案还会把交叉编译变成噩梦。选 GUI 库时把「怎么发给用户」当成选型的一部分,而不是最后一步——很多项目就是卡在这一步改回了 Web 界面。从这里到精通:路线图
这一页铺完了地基:语言、内存、数据结构与算法、四件日常工具。但基础只有在造过东西之后才真正长在身上——最后这一章按主题给出下一步的动手项目、进阶读物与自测标准。
按主题给出下一步,每一步都有明确的产出物——路线不在长,在每一步都能拿出造好的东西。
C 与内存
- 写一个自己的小型容器库(动态数组 + 哈希表 + 链表),用
-fsanitize=address,undefined跑一遍、再单独用 Valgrind 跑一遍(两者不能同时开),做到零报错——本页数据结构章的代码就是起点。 - 再挑战实现简化版
malloc(空闲链表 + 分割合并)和一个玩具 shell(fork/exec+ 管道),内存布局、指针、进程与文件描述符从此不再抽象。 - 读物:K&R《C 程序设计语言》过一遍手感,然后直奔 CS:APP(深入理解计算机系统)并做配套 lab——它是从「会写 C」到「懂系统」的那座桥。
数据结构与算法
- 比题海更有效的是在项目里解决真实的算法问题:给自己的工具加 LRU 缓存(哈希 + 双向链表)、写一个迷你 diff(LCS)、对大日志做 Top-K 统计(堆)、给配置系统做依赖解析(拓扑排序)。
- 需要系统补课时《算法导论》按需选读即可,不必从头啃;配合可视化网站(VisuAlgo)把过程「看」进脑子。
网络编程
- 三级跳,每一级都有能给人看的产出物:① 多人聊天室(epoll 事件循环 + 长度前缀分帧,广播消息给所有在线连接)→ ② 能跑通静态网站的 HTTP 服务器(循环读到空行、解析头部、按
Content-Length发正文、支持 keep-alive、防目录穿越)→ ③ 一个小型 RPC 框架(自定义二进制协议、请求 ID 与响应匹配、超时与重试)。 - 做完之后再去读现成实现,收获会翻倍:muduo(C++,但 Reactor 结构最清晰)、libuv(跨平台事件循环,Node.js 的底座)、Redis 的 ae.c(几百行的事件循环,一晚上能读完)。
- 读物:Stevens《UNIX 网络编程 卷1》是这个领域不过时的圣经;免费的 Beej's Guide to Network Programming 更短更友好,适合当第二遍复习。协议本身的「为什么」去计算机网络页。
Linux / Shell / Git / Docker
- 把日常重复操作全部脚本化,每个脚本过一遍 shellcheck;租台便宜 VPS 从零配置一套服务(用户、权限、systemd、日志),是最接近实战的 Linux 练习。想更深就试 LFS(Linux From Scratch)或写一个内核模块。
- Git:读 Pro Git(免费在线)并亲手拆开
.git目录看对象模型——理解「一切都是指向快照的指针」之后,reset/rebase/reflog 再也不可怕。 - Docker:用
unshare+ cgroup 手搓一个几十行的迷你容器,看破「容器 = 被隔离的进程」的本质;再往编排方向走 Kubernetes。