C 语言系统教程

C Programming · 入门环境、变量类型、运算符、流程控制、函数、数组字符串、指针、内存管理、结构体、文件 IO、预处理与数据结构基础,23
← 返回教程馆
3阶段
23章节
23源文档
12 周计划配套生态

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
正文 1-80/0
正文 9-160/0
正文 17-220/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

📖 C 语言系统教程:从零基础到企业级开发 README.md

一份循序渐进、体系完整的 C 语言教程:从第一行 Hello, World 写起,覆盖指针与内存管理、数据结构与算法、多线程与网络编程,最终落到代码规范、构建系统、单元测试、性能优化与跨平台兼容性等企业级工程实践。


本教程的特点

  • 循序渐进:章节按学习曲线排列,前一节永远是后一节的地基,不需要"先跳到第 7 章看一眼指针"才能读懂第 6 章。
  • 每个知识点都配示例:所有示例代码按 C11/C17 标准编写,可在 GCC / Clang / MSVC 上编译。
  • 直面常见错误:每个主题都配有"常见错误"小节,用 ❌ / ✅ 对照展示错误写法与正确写法——这些错误全部来自真实工程中最常出现的问题。
  • 调试方法随行:不止教语法,更教"出错了怎么查"——从 printf 大法到 GDB、Valgrind、AddressSanitizer 的实战用法。
  • 面向工程:最后部分完整覆盖企业级 C 开发的工程化实践,可直接作为团队规范与培训材料。

约定的编译与验证方式

本书示例默认按 C11/C17 编写(涉及标准演进时会明确标注 C89/C99/C11/C23 的差异)。推荐编译命令:

# GCC / Clang:开启全量告警,学习阶段强烈建议保持这个习惯
gcc -std=c17 -Wall -Wextra -Wpedantic -g main.c -o main

# 需要检测内存问题时,叠加 AddressSanitizer(GCC/Clang 均支持)
gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined main.c -o main

# MSVC(开发者命令提示符)
cl /W4 /std:c17 /Zi main.c

注意:本教程撰写环境未附带编译器,示例代码均按标准编写并经过人工审校,但仍建议你在自己的机器上编译运行每一个示例——C 语言是练出来的,不是读出来的。

目录与学习路线

第一部分 · 基础篇(零基础入门)

章 标题 内容概要
01 入门与环境搭建 C 的定位与适用场景、编译流程四阶段、三大编译器/环境搭建、第一个程序逐行解析、编译错误 vs 链接错误 vs 运行时错误
02 变量与数据类型 基本类型与取值范围、sizeof、常量与字面量、类型转换与提升、printf/scanf、未定义行为初识
03 运算符与表达式 算术/关系/逻辑/位/赋值/条件运算符、优先级与结合性、短路求值、++ 的坑、移位陷阱
04 流程控制 if/else、switch、三种循环、break/continue/goto、悬空 else、= 误作 ==
05 函数 声明与定义、参数传递(值传递)、作用域与生命周期、递归与栈帧、static 函数、变参函数入门
06 数组与字符串 一维/多维数组、数组与指针的关系、C 字符串与 string.h、缓冲区溢出与安全字符串函数

第二部分 · 核心进阶篇

章 标题 内容概要
07 指针 指针与地址、指针与数组、指针运算、二级指针、函数指针、void*、野指针与空指针
08 内存管理 内存布局五段、malloc/calloc/realloc/free、内存泄漏、悬垂指针、重复释放、Valgrind 与 ASan
09 结构体、联合体与枚举 结构体定义与使用、嵌套与自引用、内存对齐与 padding、位域、联合体、枚举、typedef
10 文件操作 FILE* 与文本/二进制模式、顺序读写、随机访问、文件缓冲、错误检测、常见坑(二进制模式跨平台差异)
11 预处理器与多文件工程 #include 与头文件守卫、宏定义与陷阱、条件编译、extern/static 与链接属性、头文件设计原则

第三部分 · 数据结构与算法篇

章 标题 内容概要
12 常用数据结构实现 动态数组、单/双链表、栈、队列、哈希表、二叉搜索树——全部手写实现并附复杂度分析
13 常用算法实现 冒泡/插入/快排/归并排序、二分查找、递归与分治、复杂度分析、通用排序接口(qsort 风格)

第四部分 · 系统编程篇

章 标题 内容概要
14 多线程与并发 竞态条件、C11 <threads.h> 与 POSIX <pthread.h>、互斥锁、条件变量、生产者-消费者、死锁、原子操作
15 网络编程 TCP/UDP 基础、socket API 全流程、回显服务器与客户端实战、字节序与网络字节序、I/O 多路复用概览
16 调试与错误处理 错误处理惯例(返回值/errno/assert)、GDB 入门、Valgrind、Sanitizer、日志与防御性编程

第五部分 · 企业级工程实践篇

章 标题 内容概要
17 代码规范与模块化设计 命名与格式规范、注释规范、模块划分、接口设计、信息隐藏(不透明指针)、错误码设计
18 构建与依赖管理 Makefile 完整实战、CMake 项目模板、第三方依赖管理(vcpkg/Conan/子模块)、版本与发布
19 单元测试 测试思想、自研极简测试框架、Unity / CMocka 实战、可测性设计、覆盖率
20 性能优化 优化的方法论(先测量)、profiling 工具、编译器优化等级、缓存友好代码、常见优化手法与反模式
21 跨平台兼容性 数据模型差异(LP64/LLP64)、字节序、对齐、平台 API 差异与抽象层、特性检测、可移植性清单

附录

建议的学习方式

  1. 零基础读者:按顺序通读第一、二部分,每一章的示例都亲手敲一遍并编译运行;遇到"常见错误"小节时,故意把代码改错、观察报错信息——认识错误信息本身就是核心技能。
  2. 有经验的开发者:可直接跳读第三至五部分;第 7、8、11、17、18 章建议精读,它们是 C 工程质量与事故率的分水岭。
  3. 作为参考手册使用:附录 A 的错误速查表按"症状"组织,出问题时先查它;各章的"常见错误"小节也可独立检索。

全书代码约定

// 注释与命名遵循本书第 17 章的规范:
// - 函数与变量用小写蛇形命名(snake_case)
// - 类型与宏用大写或大写下划线
// - 每个示例都可以独立编译成一个可执行文件
#include <stdio.h>

int main(void)          /* main 的两种标准写法之一:无参数时写 void */
{
    printf("Hello, C!\n");
    return 0;           /* 返回 0 表示成功——这是约定,也是规范 */
}

准备好了吗?从 第 01 章:入门与环境搭建 开始。

📄 第 01 章 入门与环境搭建 01-入门与环境搭建.md

学习目标:理解 C 语言的定位与适用场景;理解从源代码到可执行文件的完整流程;搭好自己的编译环境;写出并逐行理解第一个程序;能区分编译错误、链接错误和运行时错误。


1.1 C 语言是什么,为什么今天还要学它

C 语言诞生于 1972 年(Dennis Ritchie,贝尔实验室),是为编写 UNIX 操作系统而设计的。五十多年过去,它依然是:

  • 操作系统的母语:Linux、Windows 内核、macOS/XNU 的大部分代码是 C。
  • 一切运行时的地基:Python、PHP、Lua 的解释器是 C 写的;JVM、V8 的底层大量使用 C/C++。
  • 嵌入式世界的统治者:单片机、汽车电子、家电固件,绝大多数是 C。
  • 性能与可控性的代名词:C 给了你直接操纵内存的能力——这也是它危险的原因。

一句话总结 C 的性格:信任程序员,不替你兜底。它不会检查数组越界,不会自动初始化内存,不会回收垃圾。这份自由是它高性能的来源,也是无数 Bug 的温床。本书会反复强调这一点。

C 标准的演进

标准 年份 重要新特性
C89/ANSI C 1989 第一个标准化版本,经典教科书的基础
C99 1999 行注释 //、变长数组 VLA、long long、声明可放语句后、内联函数
C11 2011 多线程库 <threads.h>、原子操作 <stdatomic.h>、匿名结构/联合、_Static_assert
C17 2018 缺陷修正版本,无新特性
C23 2024 constexpr、[[attributes]]、auto 类型推断、空指针关键字 nullptr

本书基准:C17,遇到版本差异会明确标注。工业界最常见的要求是"兼容 C99"(很多嵌入式编译器只支持到 C99),所以涉及 C99 就够用的写法时也会提示。


1.2 从源代码到可执行文件:编译的四个阶段

很多初学者以为"编译"是一步到位的黑盒。理解下面四个阶段,是看懂报错信息的前提:

 main.c ──预处理──> main.i ──编译──> main.s ──汇编──> main.o ──链接──> main(.exe)
          (cpp)              (cc1)             (as)              (ld)
  1. 预处理(Preprocess):展开 #include、替换 #define 宏、处理条件编译。产物仍是 C 代码。
  2. 编译(Compile):把 C 代码翻译成汇编代码,同时做语法检查。语法错误在这一步报出。
  3. 汇编(Assemble):把汇编代码翻译成二进制的目标文件(.o / .obj)。此时函数调用还只是"占位符"。
  4. 链接(Link):把多个目标文件和库拼在一起,填上所有符号的真实地址。"未定义引用"错误在这一步报出。

用 GCC 可以观察每个阶段的产物:

gcc -E main.c -o main.i   # 只做预处理,打开 main.i 看 #include 展开后有多大
gcc -S main.c -o main.s   # 生成汇编
gcc -c main.c -o main.o   # 生成目标文件(不链接)
gcc main.o -o main        # 链接

为什么这很重要:当你看到 undefined reference to 'foo',就该知道这不是语法写错了,而是链接器找不到 foo 的实现——多半是忘了编译某个 .c 文件,或忘了链接某个库。第 11 章会展开讲。


1.3 搭建开发环境

你只需要两样东西:编译器 和 编辑器(或 IDE)。三选一即可。

方案 A:Linux(推荐初学者)

Linux 天生就是 C 的家,工具链齐全:

# Debian/Ubuntu
sudo apt update && sudo apt install build-essential gdb

# Fedora/RHEL
sudo dnf install gcc gcc-c++ gdb make

# 验证
gcc --version && gdb --version

编辑器用 VS Code(装 C/C++ 扩展)或 Vim,终端里编译运行,最贴近真实工作流。

方案 B:Windows

MinGW-w64(GCC 的 Windows 移植):

  1. 下载 MSYS2(https://www.msys2.org)并安装;
  2. 在 MSYS2 终端执行 pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-gdb make;
  3. 把 C:\msys64\ucrt64\bin 加入系统 PATH;
  4. 打开新的 CMD/PowerShell 验证 gcc --version。

或者直接安装 Visual Studio(社区版免费),使用其中完整的 MSVC 工具链与调试器,在"开发者命令提示符"里编译:cl /W4 main.c。

方案 C:macOS

macOS 自带 Clang,装 Xcode 命令行工具即可:

xcode-select --install
clang --version   # 注意:macOS 上 gcc 命令实际是 clang 的别名

在线环境(零安装)

想立刻试一行代码:https://godbolt.org (Compiler Explorer,还能看汇编)或 https://ideone.com。


1.4 第一个程序:逐行解析

/* hello.c —— 我的第一个 C 程序 */
#include <stdio.h>

int main(void)
{
    printf("Hello, World!\n");
    return 0;
}

逐行拆解:

代码 作用
/* ... */ 块注释,可跨行。C99 起也可用 // 行注释
#include <stdio.h> 预处理指令:把标准输入输出头文件的内容原样粘贴到这里。stdio = standard input/output。printf 的函数声明在里面
int main(void) 程序入口。返回类型 int,参数 (void) 表示不接收任何参数。操作系统调用 main,其返回值会传给操作系统(echo $? 可查看)
{ ... } 函数体,语句块
printf("Hello, World!\n"); 库函数调用,向标准输出打印字符串。\n 是换行转义符;语句以分号结尾
return 0; 返回 0 表示程序成功结束(EXIT_SUCCESS 的约定值)。main 可以省略 return,但显式写出是好习惯

编译并运行:

gcc -std=c17 -Wall -Wextra -g hello.c -o hello
./hello            # Windows 下是 hello.exe 或 .\hello.exe
echo $?            # Linux/macOS 查看退出码,应为 0

常见错误(第一次编译就会遇到的)

❌ 错误 1:文件扩展名不对

hello.txt: file not recognized   (或 cl 报 cannot open source file)

GCC 根据扩展名决定处理方式,源文件必须是 .c,不是 .txt 或 .cpp(.cpp 会被当 C++ 编译,某些 C 代码会报错)。

❌ 错误 2:漏写分号

printf("Hello")     /* ❌ 上一行末尾缺分号 */
error: expected ';' before 'return'

注意报错位置往往在下一行——编译器读完 return 才发现上一个语句没结束。这是初学者最常被误导的地方:报错行号 ≠ 出错行号,要往前看一行。

❌ 错误 3:大小写敏感

Printf("Hello\n");  /* ❌ C 区分大小写,标准库全小写 */
warning: implicit declaration of function 'printf'  →  链接期 undefined reference

❌ 错误 4:main 写成 void main()

void main(void) { ... }   /* ❌ 非标准写法,某些编译器接受但不可移植 */
int main(void) { ... }    /* ✅ 标准写法 */

1.5 三类错误:编译错误、链接错误、运行时错误

C 的错误分三个时间点暴露,定位方法完全不同。这个分类贯穿全书。

1. 编译错误(compile-time error)——语法问题

编译器直接拒绝,最容易被发现:

int x = 10
printf("%d\n", x);   /* ❌ 上一行缺分号 */
error: expected ';' before 'printf'

特征:有 error: 字样,不会生成可执行文件。warning: 则是"可疑但不致命",学习阶段请把警告当错误对待(-Wall -Wextra -Werror)。

2. 链接错误(link error)——符号找不到

/* main.c */
int main(void) { foo(); return 0; }   /* 调用了 foo 但没提供实现 */
undefined reference to `foo'     (GCC)
LNK2019: unresolved external symbol _foo  (MSVC)

特征:每个 .c 都单独编译通过,但链接时失败。常见原因:

  • 忘了编译/链接提供该函数的 .c 文件;
  • 用了数学函数 sqrt() 却没链接数学库:gcc main.c -lm(Linux 下需要 -lm);
  • 声明与定义的函数名拼写不一致。

3. 运行时错误(runtime error)——最危险的类别

程序能编译、能链接、能运行——然后崩溃,或者更糟:不崩溃但算出错误结果。

int a[3] = {1, 2, 3};
printf("%d\n", a[5]);   /* ❌ 越界读。编译器不报错,运行可能打印垃圾值,也可能"看起来正常" */

运行时错误的典型表现:

症状 大概率原因
Segmentation fault(段错误)/ Windows 0xC0000005 解引用了野指针、空指针,或越界写破坏了内存
数值莫名出错 未初始化变量、整数溢出、类型转换截断
"有时候对有时候错" 未初始化的值恰好有时是 0;竞态条件(第 14 章)
程序越跑越慢、内存涨满 内存泄漏(第 8 章)

为什么 C 的运行时错误难缠:越界读一个数组元素,程序可能完全正常(那块内存恰好可读);一旦换成越界写,破坏的可能是别的变量、栈上返回地址——崩溃点往往远离出错点。这正是本书反复强调 Valgrind / ASan(第 8、16 章)的原因:用工具把"沉默的越界"变成"立刻的报错"。


1.6 开发工作流建议

从第一天就建立好习惯,后面省一半调试时间:

  1. 永远开全量警告编译:-Wall -Wextra -Wpedantic。警告是免费的代码审查。
  2. 小步快跑:写 5~20 行就编译一次,错误刚出现就消灭,不要写 200 行再编译。
  3. 用 -g 编译调试版:保留调试符号,GDB 才能显示行号。发布时才用 -O2(第 20 章)。
  4. 版本控制从第一天开始:git init,每完成一个小功能就 commit。回滚是程序员最大的安全感。
  5. 不要用 IDE 的"一键运行"当拐杖:至少学会在命令行手动编译,你才能读懂错误、理解参数。

1.7 小结

  • C 信任程序员:性能与危险并存,纪律是必需品。
  • 编译四阶段:预处理 → 编译 → 汇编 → 链接。语法错误在编译期,undefined reference 在链接期,段错误在运行期。
  • 标准写法:int main(void)、语句带分号、C 区分大小写、文件用 .c 扩展名。
  • 三个习惯:全量警告、小步编译、-g 调试编译。

思考与练习

  1. 把 hello.c 里的 ; 删掉编译一次,把报错行号与实际出错行号对比,体会"报错在后"。
  2. 新建 math.c,调用 sqrt(2.0)(记得 #include <math.h>),在 Linux 上分别用有/无 -lm 链接,观察区别。
  3. 写一个程序连续打印三行不同的问候语,故意写错一处大小写,记录你看到的两阶段报错(编译 warning + 链接 error)。
  4. 查阅 man 3 printf(Linux)或标准文档,找出 printf 的函数原型。原型这个概念将在第 5 章正式登场。

下一章:变量与数据类型 →

📄 第 02 章 变量与数据类型 02-变量与数据类型.md

学习目标:掌握 C 的基本类型体系与取值范围;理解"类型决定内存解释方式"这一核心思想;掌握常量、字面量、类型转换规则;正确使用 printf/scanf;初步认识未定义行为。


2.1 变量:内存中的具名格子

变量就是给一块内存起的名字。定义变量时必须指定类型,类型决定了:

  1. 这块格子有多大(几个字节);
  2. 里面的比特位如何解释(整数?浮点数?);
  3. 能对它做哪些操作。
int    age    = 25;       /* 4 字节,按补码整数解释 */
double price  = 9.99;     /* 8 字节,按 IEEE 754 双精度解释 */
char   grade  = 'A';      /* 1 字节,通常按 ASCII 解释 */

C 变量定义的一般形式:

类型名 变量名 = 初始值;      /* 推荐定义即初始化 */
类型名 变量名;               /* ⚠️ 此时它的值是"未定义"的——多半是垃圾 */

铁律:定义变量时必须初始化,没有例外。 未初始化的局部变量的值是不确定的(indeterminate value),读它是未定义行为。全局变量和 static 变量会被自动清零(第 5 章讲作用域时会再强调),但依赖这一点写代码会让人困惑,显式写 = 0 更好。


2.2 基本数据类型全景

整数类型

类型 典型大小(字节) 典型范围 打印格式
char 1 -128 ~ 127(或有符号性由实现定义) %c / %d
short 2 -32768 ~ 32767 %hd
int 4(现代主流) 约 ±21 亿 %d
long Linux 64 位:8;Windows 64 位:4 见第 21 章数据模型 %ld
long long 8 约 ±9.2×10¹⁸ %lld
对应 unsigned 版本 同上 ≥ 0,上限翻倍 %u %lu %llu

关键规则:

  • C 标准只保证下限:int 至少 16 位、long 至少 32 位、long long 至少 64 位。具体大小由实现决定。
  • char 是否有符号由编译器决定(x86 上通常有符号,ARM 上常见无符号)——不要依赖。
  • 需要精确位宽时,用 <stdint.h> 的定宽类型,这是工程惯例:
#include <stdint.h>

uint8_t  u8  = 255;         /* 恰好 8 位无符号 */
int32_t  s32 = -1000000;    /* 恰好 32 位有符号 */
uint64_t big = 18446744073709551615ULL;  /* 恰好 64 位无符号 */

❌ 经典错误:有符号与无符号比较

int scores[] = {90, 80, 70};
size_t n = sizeof(scores) / sizeof(scores[0]);   /* n 是 size_t,无符号 */

for (int i = 0; i <= n - 1 - 1; i++) { /* 复杂写法暂且不提 */ }

/* 最典型的翻车现场: */
for (size_t i = n - 1; i >= 0; i--) {  /* ❌ 死循环! */
    printf("%zu ", scores[i]);
}

i >= 0 对无符号类型永远为真:当 i 减到 0 再减 1 时,回绕成 SIZE_MAX(最大值)。正确写法:

for (size_t i = n; i > 0; i--) {        /* ✅ 用 i > 0 做条件 */
    printf("%d ", scores[i - 1]);
}

浮点类型

类型 大小 有效数字(约) 打印格式
float 4 字节 6~7 位 %f
double 8 字节 15~16 位 %f(printf 中 float 自动提升为 double)
long double 实现定义 更高 %Lf

❌ 经典错误:直接用 == 比较浮点数

double a = 0.1 + 0.2;
if (a == 0.3) { ... }        /* ❌ 不成立!0.1+0.2 == 0.30000000000000004 */

二进制浮点无法精确表示多数十进制小数。正确做法:用误差容限比较。

#include <math.h>
if (fabs(a - 0.3) < 1e-9) { /* ✅ 差值足够小即视为相等 */ }

工程建议:除非有明确理由(省内存、硬件限制),默认用 double。float 的精度在累积运算中损耗很快。

char 与字符

char 本质是小整数,字符来自字符集编码:

char c = 'A';
printf("%d\n", c);   /* 65 —— 'A' 的 ASCII 码 */
char next = c + 1;   /* 'B':字符可以做算术 */

转义字符速查:\n 换行、\t 制表、\\ 反斜杠、\' 单引号、\" 双引号、\0 空字符、\r 回车。

注意:'A'(单引号,一个字符)与 "A"(双引号,含 \0 的字符串)是完全不同的东西,后者类型是 char[2](第 6 章详述)。

void 类型

void 表示"无类型",三种用法:函数无返回值(void f(void))、函数不接收参数、万能指针 void*(第 7 章)。不能定义 void 类型的变量。


2.3 sizeof:问类型要大小

sizeof 是编译期运算符(不是函数),返回字节数,类型是 size_t:

printf("int: %zu\n", sizeof(int));          /* 4 */
printf("double: %zu\n", sizeof(double));    /* 8 */

int arr[10];
printf("arr: %zu\n", sizeof(arr));          /* 40:整个数组 */
printf("elem: %zu\n", sizeof(arr[0]));      /* 4:单个元素 */
printf("count: %zu\n", sizeof(arr) / sizeof(arr[0]));  /* 10:元素个数,惯用法 */
  • sizeof 表达式的类型是 size_t(无符号),打印用 %zu。
  • 经典陷阱:sizeof('a') 在 C 中是 sizeof(int) = 4(字符字面量类型是 int),而在 C++ 中是 1。
  • 陷阱二:sizeof 在编译期求值,不会执行里面的副作用:int n = 0; sizeof(n++); 之后 n 仍是 0。

2.4 常量与字面量

/* 字面量(literal)——直接写进代码的值 */
42          /* int */
42L         /* long */
42U         /* unsigned */
42LL        /* long long */
3.14        /* double(默认!)*/
3.14f       /* float */
3.14L       /* long double */
1e-3        /* 科学计数法,double */
'A'         /* char */
"hello"     /* 字符串字面量,类型 char[6](含 \0) */
0x1F        /* 十六进制 */
0777        /* 八进制(以 0 开头!见下方陷阱) */
0b1010      /* 二进制,C23 起标准;GCC/Clang 扩展早已支持 */

❌ 经典错误:前导 0 的八进制陷阱

int timeout = 010;    /* 开发者以为是 10,实际是 8! */

用户输入身份证号、日期这类"看起来该补 0"的数据时尤其危险。十进制字面量永远不要以 0 开头。

符号常量:const 与 #define

const int MAX_USERS = 1000;          /* 方式一:const 变量,有类型,受作用域约束 */
#define MAX_USERS 1000               /* 方式二:宏,预处理期文本替换(第 11 章) */

工程上优先 const:有类型检查、能进调试器。宏适合做条件编译和带参数的展开(第 11 章)。

枚举常量(第 9 章详述):

enum Color { RED, GREEN, BLUE };   /* RED=0, GREEN=1, BLUE=2 */

2.5 类型转换:隐式提升与显式转换

整数提升与算术转换(隐式)

在表达式里,"较小"的类型会自动提升为"较大"的类型再运算:

int i = 3;
double d = 2.0;
double r = i / d;      /* i 被提升为 double → 1.5 ✅ */

int a = 7 / 2;         /* 两个 int 相除 → 整数除法 → 3(截断小数)*/
double b = 7 / 2;      /* ❌ 仍是 3!除法先做,后赋值。两个 int 相除在赋值前完成 */
double c = 7.0 / 2;    /* ✅ 3.5:有 double 参与即浮点除法 */
double e = (double)7 / 2;  /* ✅ 3.5:显式转换 */

❌ 高频错误:整数除法截断

平均分、百分比、进度条 这类计算最容易踩:

int total = 3, passed = 2;
double ratio = passed / total;            /* ❌ 0.0,不是 0.666 */
double ratio = (double)passed / total;    /* ✅ 0.6667 */

记口诀:除法看操作数,不看接收者。

有符号/无符号混合运算的暗坑

int i = -1;
unsigned u = 1;
if (i < u)          /* ❌ 结果为假!-1 被转换成巨大的无符号数 4294967295 */
    printf("i < u");

规则:有符号与无符号比较时,有符号一方被转换为无符号。这是 C 排名第一的反直觉规则之一,也是循环条件死循环(见 2.2 节)的根源。

显式转换(强制类型转换)

double pi = 3.99;
int truncated = (int)pi;        /* 3:向零截断,不是四舍五入 */
int rounded   = (int)(pi + 0.5);/* 4:正数四舍五入的惯用法 */

截断方向的规则:向零取整。-3.99 → -3。若需要四舍五入,可用 round()(<math.h>)后再转换。

❌ 超范围转换是未定义行为:(int)1e20、(char)300 结果不可预测(有的平台饱和、有的回绕)。写入窄类型时的安全写法是先判断范围或用 unsigned 回绕语义。


2.6 printf 与 scanf:与外界对话

printf 格式速查

int    i = -42;
unsigned u = 42;
double d = 3.14159;
char   c = 'A';
char  *s = "hello";
size_t z = 8;

printf("%d %u %f %c %s %zu\n", i, u, d, c, s, z);
printf("%5d|%-5d|%05d|\n", 42, 42, 42);   /*    42|42   |00042| */
printf("%.2f\n", 3.14159);                 /* 3.14:保留两位小数 */
printf("%x %o %e\n", 255, 8, 12345.678);   /* ff 10 1.234568e+04 */
printf("%ld %lld\n", 100L, 100LL);

❌ 经典错误:格式符与实参类型不匹配——未定义行为

printf("%d\n", 3.14);       /* ❌ %d 读 int,实参是 double:输出垃圾值 */
printf("%f\n", 10);         /* ❌ %f 读 double,实参是 int:崩溃或垃圾值 */
printf("%s\n", 42);         /* ❌ 把 42 当地址解引用:段错误 */

这类错误 GCC 的 -Wall -Wformat 能查出大多数(printf 是编译器重点照顾的对象),所以再次强调:警告必须看。

更危险的是 printf 的第一个参数来自外部输入:

printf(user_input);      /* ❌ 若输入含 %s %n,会读/写栈内存 → 安全漏洞 */
printf("%s", user_input);/* ✅ 永远把动态内容作为参数传入 */

scanf:注意取地址符

int age;
double salary;
char name[32];                       /* 数组名本身就是地址,不加 & */

printf("姓名 年龄: ");
if (scanf("%31s %d", name, &age) == 2) {   /* %31s 限制读入宽度,防止溢出 */
    printf("%s 今年 %d 岁\n", name, age);
} else {
    printf("输入格式错误\n");
}

三条铁律:

  1. 基本类型变量前必须加 &(把地址交给 scanf 才能写回值);数组名不加(第 6 章解释为什么)。
  2. 检查返回值:scanf 返回成功读取的项目数。输入流不是数字时匹配失败,错误字符会留在缓冲区,导致后续 scanf 全部失败——初学阶段用 fgets + sscanf/strtol(第 6 章)代替裸 scanf 是更稳妥的路线。
  3. %s 必须限宽(%31s),否则等于把缓冲区溢出的钥匙交给用户。

2.7 未定义行为初识

未定义行为(Undefined Behavior, UB) 是 C 标准中"不做任何承诺"的情形。一旦触发,编译器可以做任何事:产生垃圾结果、删除你的安全检查、甚至时间倒流式的优化。本章已遇到三处:

  1. 读取未初始化的自动变量;
  2. 有符号整数溢出(INT_MAX + 1);
  3. 超范围的浮点转整数。

初学阶段只需建立"UB = 违约,后果自负"的观念,常见 UB 的系统清单见附录 A,第 16 章会讲用 -fsanitize=undefined 抓现行。

有符号溢出 UB vs 无符号回绕:unsigned char x = 250; x += 10; 得到 4(模 256 回绕,这是定义良好的);而 int 溢出没有定义。涉及计数器、哈希的代码,用无符号类型天然安全。


2.8 调试方法:变量值的"案发现场"

  • 打印大法:怀疑哪个变量的值,就在关键节点 printf("%s:%d %s=%d\n", __FILE__, __LINE__, "x", x);。__FILE__/__LINE__ 是预置宏(第 11 章)。
  • 警惕"打印后正常":加了一句 printf bug 就消失,往往暗示未初始化内存(printf 恰好"摸"了那块内存)或时序问题,而不是打印本身修复了什么。
  • 调试器观察:GDB 中 print x、ptype x(看类型)、display x(每步自动显示);VS/VS Code 里把变量加入 Watch 窗口。
  • 让警告当侦探:-Wall 能直接抓到"未初始化就使用"(warning: 'x' is used uninitialized)与大部分 printf 格式不匹配。养成看到警告就修的习惯,能消灭一半变量类 Bug。

2.9 小结

  • 类型 = 大小 + 解释方式 + 可用操作。优先 double、定宽类型 <stdint.h>、精确知道 int/long 差异。
  • 整数除法先于赋值发生:(double)a / b。
  • 有符号/无符号混算是坑王:循环下标用 size_t 时条件写 i > 0 或从 0 正向遍历。
  • %d↔int、%f↔double、%zu↔size_t、%s↔char*,格式符必须与实参严格匹配。
  • 定义即初始化;scanf 加 &、限宽、查返回值。
  • UB 是违约:不读未初始化值、不让有符号数溢出。

思考与练习

  1. 写程序打印 sizeof 全部基本类型的结果,对比你的平台与本章表格。
  2. int x = 5; double y = x / 2; 和 double z = x / 2.0; 的值分别是什么?先心算,再运行验证。
  3. 编写程序读入一个圆的半径,输出面积(保留 3 位小数)。故意把 %.3f 写成 %d 观察后果。
  4. 解释为什么 for (unsigned i = 10; i >= 0; i--) 是死循环,并给出两种正确的倒序遍历写法。
  5. 用 char 存储字符 '0',打印它的整数值。思考字符 '0' 与数字 0 的区别。

上一章:入门与环境搭建 | 下一章:运算符与表达式 →

📄 第 03 章 运算符与表达式 03-运算符与表达式.md

学习目标:掌握 C 全部运算符的语义与优先级;理解短路求值及其在防御性编程中的应用;避开自增自减、移位、混合赋值的经典陷阱。


3.1 运算符总览

类别 运算符
算术 + - * / %
关系 == != < <= > >=
逻辑 && || !
位运算 & | ^ ~ << >>
赋值 = += -= *= /= %= <<= >>= &= |= ^=
条件 ?:
自增自减 ++ --
其他 sizeof , () 类型转换 取地址/解引用(第 7 章)成员访问 . ->(第 9 章)

3.2 算术运算符

int a = 17, b = 5;
printf("%d %d\n", a / b, a % b);   /* 3 2 */

要点:

  1. 整数除法向零截断:-17 / 5 == -3(不是 -4),-17 % 5 == -2。% 结果的符号与被除数相同:17 % -5 == 2,-17 % 5 == -2。
  2. % 只能用于整数。浮点取余用 fmod()(<math.h>)。
  3. 除零:整数除以 0 是未定义行为(很多平台触发硬件异常,程序直接崩溃,如 Linux 的 SIGFPE);浮点除以 0 得到 inf 或 nan,不会崩——两种行为都别依赖,除法前先判断除数。
if (b != 0) {
    r = a / b;
} else {
    /* 处理除零:报错、返回错误码或使用安全默认值 */
}

3.3 关系与逻辑运算符

比较的结果是 int:真为 1,假为 0。C 没有布尔类型(C99 起可用 <stdbool.h> 的 bool,本质是宏):

#include <stdbool.h>
bool ok = (a > b);        /* ok 是 0 或 1 */
if (ok) { ... }

短路求值:&& 和 || 的"懒"

&& 左边为假则右边不求值;|| 左边为真则右边不求值。这是 C 里最重要的防御性编程武器:

/* 先判空再解引用——顺序不能反 */
if (p != NULL && p->value > 0) { ... }   /* ✅ p 为 NULL 时不会执行 p->value */

/* 先防除零再相除 */
if (n != 0 && sum / n > threshold) { ... }

/* 找到即停 */
if (x == 1 || x == 2 || x == 3) { ... }

❌ 经典错误:数学写法直接搬

if (0 <= x <= 100)      /* ❌ 恒为真!解析为 (0 <= x) <= 100,即 0或1 <= 100 */
if (x >= 0 && x <= 100) /* ✅ */

❌ 经典错误:== 写成 =

if (x = 5) { ... }    /* ❌ 赋值表达式值为 5(真),条件恒真,且 x 被改写 */
if (x == 5) { ... }   /* ✅ */

防御技巧:把常量写在左边 if (5 == x)——漏写 = 时会编译报错(不能给字面量赋值),即 Yoda 条件。另一个通用解法:开 -Wall,GCC 会对 if (x = 5) 给出警告,外加建议给不作为条件的赋值加双括号 if ((p = malloc(n)) == NULL)。

! 与"逻辑归一"

!expr 把任何非零值归一为 0,把 0 归一为 1。常见于判断失败:

if (!fp) { /* fopen 返回 NULL,即失败 */ }

3.4 位运算符

C 能直接操作比特,这是它处理协议、硬件寄存器、位标志的看家本领。以 8 位视角演示(unsigned char x):

unsigned char x = 0b10110100;

x & 0x0F        /* 按位与:清高位留低 4 位 → 0x04   掩码提取 */
x | 0x40        /* 按位或:置位第 6 位          → 0xF4   打开某个 flag */
x ^ 0xFF        /* 按位异或:取反指定位(异或两次还原,可用于简单加密/交换技巧) */
~x              /* 按位取反:所有位翻转 */
x << 1          /* 左移 1 位:低位补 0 → 0x68(无符号相当于 ×2) */
x >> 4          /* 右移 4 位:取出高 4 位 → 0x0B */

位运算四大惯用法

/* 1. 置位第 n 位(n 从 0 计) */
flags |= (1u << n);

/* 2. 清除第 n 位 */
flags &= ~(1u << n);

/* 3. 测试第 n 位 */
if (flags & (1u << n)) { /* 该位为 1 */ }

/* 4. 翻转第 n 位 */
flags ^= (1u << n);

这就是"位标志(bit flags)":一个 int 可以塞 32 个布尔开关,open() 的 O_RDWR | O_CREAT、fopen 内部模式都是这么设计的。

❌ 移位陷阱一:移位位数 ≥ 类型宽度

int x = 1 << 32;     /* ❌ UB!int 只有 32 位 */
int y = 1 << 31;     /* ❌ 1 是 int,移到符号位:实现定义/UB;写 1u << 31 才稳 */

❌ 移位陷阱二:对有符号负数右移

有符号数右移是"实现定义"(多数编译器做算术右移补符号位),可移植代码应只对无符号类型做移位。

unsigned x = 1u << 31;    /* ✅ 无符号上移位与乘除 2^k 等价且定义良好 */

❌ 移位陷阱三:优先级低于算术

1 << n + 1 解析为 1 << (n + 1)——移位优先级比加法低。要么加括号,要么背熟优先级表(3.7 节)。


3.5 赋值与复合赋值

赋值是表达式,其值就是赋进去的值,因此可以连赋:

a = b = c = 0;      /* 从右向左:c=0 → b=(c) → a=(b) */

复合赋值 x op= y 等价于 x = x op (y)——注意右侧是先整体求值再运算,x *= 2 + 3 等价于 x = x * 5,不是 x = x * 2 + 3。

❌ 陷阱:把 == 的比较结果赋值链

int a = 1, b = 2;
if (a = b == 2) { /* a 被赋成 (b==2) 即 1,条件恒真 */ }

可读性建议:一行只做一件事。连赋值用于初始化一组相关变量可以,嵌在复杂条件里不行。


3.6 自增与自减:++i 与 i++

++i 前置:先加后用,表达式的值是加完后的;i++ 后置:先用后加,表达式的值是加之前的。

int i = 5;
printf("%d\n", i++);   /* 打印 5,i 变成 6 */
printf("%d\n", ++i);   /* i 先变 7,打印 7 */

int a[3] = {10, 20, 30};
int k = 0;
int v = a[k++];        /* v = 10,k = 1:先取 a[0] 再 k 自增 */

❌ 最高频 UB:同一表达式中对同一变量多次修改

i = i++;              /* ❌ UB:修改了两次,顺序未定 */
a[i] = i++;           /* ❌ UB:读 i 和改 i 的顺序未定 */
f(i++, i++);          /* ❌ UB:两个实参求值顺序未定(不是从左到右!) */
x = i++ + ++i;        /* ❌ UB */

C 只对少数运算符(&&、||、?:、,)规定求值顺序,其余一律不保证。规则很简单:

一条语句中,每个变量最多被修改一次;如果既读又写,读只能是为了确定写入的值。

实践中进一步简化:自增自减只在单独一行使用(i++; 或经典 for 循环),绝不出现在表达式中间。既规避 UB,又省去读者心算前置后置。

❌ UB 示例:, 与 && 中的例外正好提醒我们

int j = (i++, i++);   /* ✅ 合法:逗号运算符保证从左到右,j 是第二个 i++ 的旧值 */

但可读性差,工程代码中不推荐。


3.7 条件运算符与逗号运算符

条件运算符 ?: 是唯一的三元运算符:

int max = (a > b) ? a : b;
/* 相当于 if/else,但它是表达式,可以出现在初始化、参数等"语句放不下"的地方 */
printf("%d 个苹果\n", n, n == 1 ? "" : "s");

注意:不要嵌套超过一层,a ? b : c ? d : e 这种写法可读性极差;两个分支类型不同时会发生隐式转换(cond ? 1 : 2.0 结果是 double),偶尔出其不意。

逗号运算符:从左到右依次求值,整个表达式的值取最右边。主要用途是 for 循环里同步推进两个变量:

for (i = 0, j = n - 1; i < j; i++, j--) { /* 双向夹逼,如回文判断 */ }

3.8 运算符优先级:背这张表就够

完整优先级表很长,工程上真正需要刻在脑子里的只有相对关系。下表从高到低:

优先级 运算符 记忆点
1 () [] . -> 后缀++/-- 后缀/调用最高
2 ! ~ 前缀++/-- 单目- (类型) sizeof * & 单目第二
3 * / % 算术第三
4 + -
5 << >> 低于加减!
6 < <= > >= 关系高于相等
7 == !=
8 & 位与
9 ^ 位异或
10 | 位或
11 && 逻辑与
12 || 逻辑或
13 ?: 条件
14 = 及复合赋值 赋值几乎最低
15 , 最低

三条高频结论:

  1. 算术 > 移位 > 关系 > 位与/异或/或 > 逻辑:所以 x & 3 == 1 是 x & (3 == 1),错!位运算和相等比较混用必须加括号:(x & 3) == 1。
  2. 赋值优先级低:if (c = getchar() != EOF) 解析为 c = (getchar() != EOF),错!要写 if ((c = getchar()) != EOF)。
  3. 括号免费:不确定就加括号。过度加括号的唯一代价是多敲几个键,收益是零歧义。

❌ 综合陷阱实例

/* 想表达"年份是闰年" */
if (year % 4 == 0 && year % 100 != 0 || year % 400 == 0)   /* 结果碰巧正确,但全靠背优先级 */
if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) /* ✅ 括号说明意图 */

3.9 表达式求值顺序与序列点(进阶)

3.6 节的 UB 规则背后是"序列点"(C11 中称 sequencing)概念:在没有顺序约束的子表达式之间,编译器可以任意安排(甚至交错)求值。这就是为什么:

int i = 0;
int arr[2] = {0, 0};
arr[i] = ++i;    /* UB:arr[0]=1 还是 arr[1]=1?标准说:不知道 */

现代编译器(Clang)甚至会直接对这种代码报警并拒绝优化。写出确定性行为的方法:拆成多条语句:

++i;
arr[i] = i;

3.10 调试方法:表达式出错了怎么查

  • 拆语句验证:把可疑的复合表达式拆成多行,给中间结果命名(int masked = x & 0xFF;),一边拆一边打印,往往拆到一半就发现优先级或顺序问题。
  • 让编译器替你读心:gcc -Wall -Wextra 会提示 suggest parentheses around arithmetic in operand of '<<'、value computed is not used(i++ 的值没用上)等。
  • ** bitten by UB?开 Sanitizer**:-fsanitize=undefined 能在运行时抓出有符号溢出、坏移位等(详见第 16 章)。
  • 怀疑优先级:直接加括号重编译,如果结果变了,恭喜找到了病灶。

3.11 小结

  • 比较结果只有 0 和 1;没有三连不等式。
  • &&/|| 短路求值是防御性编程第一工具;条件顺序:先判空/判零,再使用。
  • 位运算四件套:|= 置位、&= ~ 清位、& 测位、^= 翻位;移位只对无符号做,位数小于宽度,写 1u << n。
  • 一条语句里同一变量只修改一次;++/-- 只单独成句。
  • 优先级拿不准就加括号;== 误写 = 靠编译器警告兜底。

思考与练习

  1. 不运行程序,写出以下表达式的值(设 int x = 7, y = 2;):x / y、x % y、(double)x / y、x >> 1、x & 3、!x。
  2. 用位运算实现:判断整数 n 是否为偶数;把 n 的最低位清零。
  3. 找出并改正错误:if (ch = 'q' || ch = 'Q') break;(提示:有多处)。
  4. 解释 int i = 3; printf("%d %d\n", i, i++); 为什么输出不确定,并改写成确定性行为。
  5. 用位标志设计一个文件权限变量(读=4、写=2、执行=1),实现"授予写权限""收回执行权限""查询是否有读权限"三个函数。

上一章:变量与数据类型 | 下一章:流程控制 →

📄 第 04 章 流程控制 04-流程控制.md

学习目标:熟练使用 if/else、switch 与三种循环;理解循环不变量与循环三要素;掌握 break/continue/goto 的正确用法与禁区;避开悬空 else、赋值条件、switch 穿透等经典陷阱。


4.1 程序的三种基本结构

任何程序都能由三种结构组合而成:顺序(默认,从上到下)、选择(分支)、循环。C 的流程控制语句全部围绕这三者,没有例外。


4.2 if / else:分支

if (score >= 60) {
    printf("及格\n");
} else if (score >= 90) {
    /* ⚠️ 永远到不了这里:>=90 也满足 >=60,且 else if 从上往下匹配 */
} else {
    printf("不及格\n");
}

❌ 经典错误:条件区间顺序颠倒。正确写法是从特殊到一般或从大到小:

if (score >= 90) {
    printf("优秀\n");
} else if (score >= 80) {
    printf("良好\n");
} else if (score >= 60) {
    printf("及格\n");
} else {
    printf("不及格\n");
}

要点与陷阱:

  1. 悬空 else(dangling else):
if (a > 0)
    if (b > 0)
        printf("both\n");
else                  /* ⚠️ 这个 else 属于内层 if!缩进骗了你 */
    printf("a<=0\n");

else 总是与最近的未匹配 if 结合。规则:if/else 永远写花括号,即使只有一句——这是全书最重要的风格纪律之一,Git 上无数真实事故源于省略括号后追加代码(Apple 的 goto fail 漏洞就是此类问题的著名案例,见第 17 章)。

  1. 条件里少写赋值:if (x = 10) 恒真(第 3 章)。开 -Wall。

  2. 比较浮点:不要 if (d == 0.0) 判断除法结果,用容差(第 2 章)。

  3. 早返回优于深嵌套(卫语句):

/* ❌ 金字塔嵌套,三层以后就难读了 */
if (p != NULL) {
    if (p->next != NULL) {
        if (p->next->len > 0) {
            do_work(p);
        }
    }
}

/* ✅ 卫语句:先排除异常,主逻辑零缩进 */
if (p == NULL)            return -1;
if (p->next == NULL)      return -1;
if (p->next->len <= 0)    return -1;
do_work(p);

4.3 switch:多路分支

switch (cmd) {
case CMD_START:
    start();
    break;                 /* 每个 case 以 break 结尾 */
case CMD_STOP:
    stop();
    break;
case CMD_QUIT:
    save();
    /* 故意穿透(fallthrough):退出前保存后继续执行 CMD_STOP 的逻辑 */
case CMD_STOP_SILENT:
    stop();
    break;
default:                   /* 兜底:未知命令 */
    fprintf(stderr, "unknown cmd %d\n", cmd);
    break;
}

switch 的规则与陷阱:

  1. 条件必须是整数类型(含 char、枚举):switch 不支持字符串比较、不支持浮点、不支持范围(GCC 扩展 case 1 ... 5 非标准)。

  2. case 后是常量表达式:case n:(n 是变量)编译报错;case 1+2: 可以。

  3. 忘记 break 就穿透:最常见的逻辑错误。误穿透通常表现为"后面的 case 也执行了"。在故意穿透的 case 末尾加注释 /* fall through */,多数静态分析工具认识它,读者也知道这不是遗漏。

    case 2:
        do_b();
        /* fall through */
    case 3:
        do_common();   /* 2 和 3 都执行这里 */
        break;
    
  4. 变量声明限制(C89 遗留):case 标签后不能直接定义带初始化的变量,会跳过初始化。要么把声明放到 switch 之前,要么给每个 case 加花括号形成块作用域:

    switch (x) {
    case 1: {
        int local = compute();    /* ✅ 花括号内声明 */
        use(local);
        break;
    }
    default:
        break;
    }
    
  5. 永远写 default:即使"不可能走到"。枚举新增值、数据被污染时,default 是最后一道岗。配合 assert 或错误处理。

  6. if vs switch 怎么选:离散值 3 个以上选 switch(编译器可优化成跳转表);范围判断、浮点、多条件组合只能 if。


4.4 while / do-while / for:循环

循环三要素

写任何循环前先问自己三个问题,缺一不可:

  1. 初始化:循环变量从哪里开始?
  2. 继续条件:什么时候继续,什么时候停?
  3. 推进:循环体里是否有语句让条件趋向终止?(没有就是死循环)

while:先判断后执行

int i = 1;              /* 要素 1:初始化 */
int sum = 0;
while (i <= 100) {      /* 要素 2:条件 */
    sum += i;
    i++;                /* 要素 3:推进 */
}
printf("%d\n", sum);    /* 5050 */

适用场景:不知道要循环多少次,如"读输入直到 EOF""链表走到尾"。

do-while:先执行后判断(至少跑一次)

char choice;
do {
    show_menu();
    scanf(" %c", &choice);   /* 注意 %c 前的空格:跳过空白符 */
} while (choice != 'q');

适用场景:菜单、重试逻辑——至少要执行一次的场合。注意末尾的分号。

for:三要素写在一行

for (int i = 0; i < n; i++) { ... }
/*    └初始化┘ └条件┘  └推进┘   i 的作用域仅在循环内(C99 起),推荐 */

适用场景:已知次数或范围的遍历。三个位置都可以省略,但必须有其他机制保证终止:

for (;;) { ... }        /* 惯用的"无限循环",等价 while(1),循环体内用 break 退出 */

❌ 经典错误:循环边界差一(off-by-one)

int a[10];
for (int i = 0; i <= 10; i++)   /* ❌ i=10 时越界访问 a[10] */
    a[i] = i;

for (int i = 0; i < 10; i++)    /* ✅ C 数组下标从 0 到 n-1,用 < n */
    a[i] = i;

C 惯例的统一解法:左闭右开 [0, n)。i 从 0 到 n-1,配合第 6 章的"指针第一个超出末尾"语义,边界永远自洽。背下这个口诀,80% 的差一错误消失。

❌ 经典错误:在循环体内修改循环边界

int n = 10;
for (int i = 0; i < n; i++) {
    if (some_cond) n = 5;    /* ⚠️ 边界漂移,难以推理;确需如此请用 while 并注释 */
}

❌ 经典错误:浮点数当循环变量

for (double x = 0.0; x != 1.0; x += 0.1) { }   /* ❌ 可能永不终止:0.1 无法精确表示,累加有误差 */
for (int i = 0; i < 10; i++) { double x = i / 10.0; }  /* ✅ 整数计数,浮点换算 */

嵌套循环

/* 打印九九乘法表 */
for (int i = 1; i <= 9; i++) {
    for (int j = 1; j <= i; j++) {
        printf("%d*%d=%-4d", j, i, j * i);
    }
    putchar('\n');
}

嵌套层数超过 3 层就该警觉:要么提取函数,要么说明数据结构选错了。


4.5 break / continue / goto

break:跳出一层循环或 switch

int found = -1;
for (int i = 0; i < n; i++) {
    if (a[i] == target) {
        found = i;
        break;            /* 找到即停,避免无谓的后续扫描 */
    }
}

break 只跳最近的一层。双层循环想一次退出,需要标志位、提取函数 + return,或 goto:

/* 提取函数是最干净的方案 */
static int find_in_matrix(int m[3][3], int target) {
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            if (m[i][j] == target)
                return i * 3 + j;   /* 直接 return 退出所有层 */
    return -1;
}

continue:跳过本轮,进入下一次

for (int i = 0; i < n; i++) {
    if (!is_valid(a[i]))
        continue;          /* 无效数据跳过,主逻辑不缩进 */
    process(a[i]);
}

注意 while 中 continue 会跳到条件判断,如果推进语句写在条件之后、循环体末尾,会漏掉推进导致死循环——while 里的推进语句尽量放循环体开头。

goto:C 里合法但纪律森严

goto 只有一个被工程界广泛接受的用途:统一错误清理(Linux 内核代码遍地都是这个模式):

int load_config(const char *path)
{
    FILE *fp = fopen(path, "r");
    if (fp == NULL)
        return -1;

    char *buf = malloc(4096);
    if (buf == NULL)
        goto err_fp;               /* 跳到清理,先释放 fp */

    if (parse(fp, buf) != 0)
        goto err_buf;

    free(buf);
    fclose(fp);
    return 0;

err_buf:                           /* 从深到浅逆序清理 */
    free(buf);
err_fp:
    fclose(fp);
    return -1;
}

规则:只向下跳、只在同一函数内、只跳向清理标签。跨函数、向上跳、跳进循环体,都是维护性灾难。


4.6 综合示例:猜数字游戏

把本章全部元素组装起来:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    srand((unsigned)time(NULL));
    int secret = rand() % 100 + 1;    /* 1~100 */
    int guess = 0;
    int tries = 0;

    printf("我想了一个 1~100 的数,猜猜看(输入 0 放弃):\n");

    for (;;) {                        /* 主循环:次数未知,退出条件在内部 */
        printf("你的猜测: ");
        if (scanf("%d", &guess) != 1) {       /* 输入不是数字 */
            fprintf(stderr, "请输入数字\n");
            while (getchar() != '\n') { }     /* 清空输入缓冲区,防死循环 */
            continue;
        }
        if (guess == 0) {
            printf("答案是 %d,下次再来。\n", secret);
            break;
        }
        tries++;
        if (guess > secret) {
            printf("大了\n");
        } else if (guess < secret) {
            printf("小了\n");
        } else {
            printf("猜对了!用了 %d 次\n", tries);
            break;
        }
    }
    return 0;
}

这个例子里包含:无限循环 + 内部 break(次数未知场景的标准形态)、输入错误处理 + 缓冲区清理(否则非数字输入会让 scanf 反复失败造成死循环——初学者第一大坑)、卫语句式分支。


4.7 调试方法:循环类 Bug 怎么查

症状 排查方向
程序"卡住不动" 死循环。GDB 中 Ctrl+C 停下,bt 看卡在哪个循环;检查推进语句、循环条件
结果差 1 边界 < 与 <=、起始 0 还是 1;打印首尾两轮的 i 值
少处理/多处理一条数据 输入末尾的换行/EOF 处理;scanf 返回值检查
结果随机错 循环体内的数组越界(脏了别的变量);ASan 上(第 16 章)
穷举太慢 检查内层循环是否重复计算可提取的不变量(第 20 章代码移动)

调试循环的基本功:在循环首行打印循环变量 + 关键状态,人工(或脚本)核对每一轮是否符合预期。收敛后删除这些打印。


4.8 小结

  • if/else 永远带花括号;用卫语句压平嵌套。
  • switch 只收整数,每个 case 记得 break,故意穿透要注释,default 必须写。
  • 循环三要素:初始化、条件、推进;数组遍历用左闭右开 [0, n)。
  • break 跳一层;goto 只用于统一错误清理且只向下跳。
  • 浮点不做循环变量;scanf 失败要清缓冲区。

思考与练习

  1. 用三种循环分别实现 1~100 中所有 3 的倍数之和,比较可读性。
  2. 写程序判断一个整数是否为质数;再改进:只检查到 √n(想想为什么)。
  3. 打印所有水仙花数(三位数,各位立方和等于自身),并用嵌套循环打印 10 行杨辉三角。
  4. 故意在 scanf("%d", &x) 后不处理非法输入,输入字母观察死循环,然后按 4.6 节修复。
  5. 把 4.5 节 load_config 的 goto 模式改写成"每个错误分支手动清理"的版本,对比两种写法在"新增一个资源申请步骤"时的修改成本。

上一章:运算符与表达式 | 下一章:函数 →

📄 第 05 章 函数 05-函数.md

学习目标:掌握函数的声明、定义与调用;深刻理解 C 的值传递;理解作用域、生命周期与存储类别(auto/static/extern);会写递归并理解栈帧;掌握头文件中函数原型的工程意义;了解变参函数。


5.1 为什么需要函数

函数是封装与复用的最小单位。好的函数:做一件事、名字说清楚这件事、参数与返回值构成清晰的契约。判断拆分是否合理的经验法则:如果你描述函数功能需要用"并且"连接两个动词,就该拆成两个函数。


5.2 声明与定义

定义(definition)= 函数的完整实现;声明(declaration,即原型 prototype)= 只告诉编译器"有这么个函数,长这样":

/* 声明(原型):通常放头文件里 */
int add(int a, int b);
double circle_area(double radius);
void greet(const char *name);      /* 只读参数习惯标 const(第 7 章展开) */

/* 定义:放 .c 文件里 */
int add(int a, int b)
{
    return a + b;
}

为什么必须声明在前? C 编译器从上往下读文件,调用时尚未见过原型,就会按 C89 的隐式声明规则"猜"——返回 int、参数类型不管——猜错了在 64 位平台上就是灾难(返回指针的函数被当成 int,指针被截断)。现代编译器会警告 implicit declaration,C23 起直接报错。

❌ 经典错误:调用在前、定义在后、没有原型

int main(void) {
    printf("%d\n", add(1, 2));   /* ⚠️ add 还没被声明 */
}
int add(int a, int b) { return a + b; }

修复三选一(推荐 1):

/* 1. 文件顶部写原型 */
int add(int a, int b);

/* 2. 把定义挪到调用之前(小文件可以,工程上依赖顺序很脆弱) */

/* 3. 放进 add.h,调用方 #include "add.h" —— 多文件工程的标准做法(第 11 章) */

参数名与默认参数:C 没有默认参数、没有重载、没有命名参数——这是与 C++ 的重大差异。需要"多版本"行为时,惯用法是拆成两个函数(open_file / open_file_with_flags)。

main 的两种标准形式

int main(void)                 /* 不读命令行参数 */
int main(int argc, char *argv[])  /* 读命令行参数 */

argv[0] 是程序名,argv[1..argc-1] 是参数,argv[argc] 是 NULL:

/* sum.c: ./sum 1 2 3 输出 6 */
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    long total = 0;
    for (int i = 1; i < argc; i++)
        total += strtol(argv[i], NULL, 10);
    printf("%ld\n", total);
    return 0;
}

5.3 参数传递:C 只有值传递

C 的一切实参都是拷贝传值。函数内修改形参,不影响调用方的实参:

void try_change(int x)
{
    x = 100;               /* 改的是副本 */
}

int main(void)
{
    int a = 1;
    try_change(a);
    printf("%d\n", a);     /* 仍然是 1! */
    return 0;
}

想让函数修改调用方的变量怎么办?传它的地址——被拷贝的是地址,但通过地址能摸到原变量(第 7 章详细展开):

void real_change(int *px)
{
    *px = 100;             /* 解引用,修改指针指向的原变量 */
}

real_change(&a);           /* a 变成 100 */

这是 C 的核心心智模型:"输出"必须通过指针。例如 scanf("%d", &x) 传地址的原因就在这里。

数组是唯一的"例外":数组名作实参时退化为指针(第 6 章),函数内能修改原数组——但严格说这仍是值传递(拷贝的是首元素地址),不是真例外:

void fill(int arr[], int n)      /* arr[] 形式上是数组,实质是 int* */
{
    for (int i = 0; i < n; i++)
        arr[i] = 0;              /* 修改的是调用方的数组 */
}

5.4 返回值

  • return expr; 结束函数并返回值;return; 用于 void 函数提前退出。
  • 返回值类型必须匹配(或可隐式转换),开 -Wall 能抓漏写 return 的路径(warning: control reaches end of non-void function)。
  • ❌ 绝对禁止返回局部变量的地址:
int *bad(void)
{
    int local = 42;
    return &local;      /* ❌ local 存在栈上,函数返回后栈帧作废 */
}                       /* 调用方拿到的是悬垂指针,读它是 UB */

合法的替代:返回值本身(拷贝出来)、静态存储(有重入问题,慎用)、调用方提供的缓冲区(工程首选)、堆内存(调用方负责 free,第 8 章)。

返回错误码是 C 的主流约定:0/EXIT_SUCCESS 表示成功,非 0 表示失败(第 16、17 章展开错误处理设计)。


5.5 作用域与生命周期

概念 含义
作用域(scope) 名字在代码里"可见"的范围(空间维度)
生命周期(lifetime) 变量的存储"存活"的时间(时间维度)
int g = 10;                  /* 全局作用域,整个程序运行期存活 */

void f(void)
{
    int local = 1;           /* 块作用域,进入 f 时诞生,返回时消亡 */
    static int count = 0;    /* 块作用域 + 静态生命周期:只初始化一次,
                                跨调用保持值,但别人看不见这个名字 */
    count++;
    printf("%d %d\n", local, count);
}

❌ 经典错误:循环内变量作用域误判

for (int i = 0; i < 5; i++) { int t = i * 2; }
/* printf("%d", t);  ❌ t 已出作用域,编译报错 */
/* printf("%d", i);  ❌ 同样,i 也只属于 for 的作用域(C99 起) */

static 的两副面孔(初学最易混淆):

  1. 修饰局部变量:改变生命周期(存到静态区,函数返回不销毁),不改作用域。用于"函数记住状态",如生成 ID:static int next_id = 0; return ++next_id;。
  2. 修饰全局变量/函数:改变链接属性——仅限本 .c 文件可见(第 11 章详述),是模块信息隐藏的基础设施。

❌ static 局部变量的坑:非线程安全。两个线程同时调用含 static int 的函数,计数会互相踩(第 14 章)。另外静态变量在递归里所有层共享一份,通常不是你想要的。


5.6 递归

函数直接或间接调用自己。两个必备要素:基准情形(递归何时停)与递归步骤(问题规模如何缩小)。

/* 阶乘:n! = n × (n-1)!,0! = 1 */
long factorial(int n)
{
    if (n <= 1)              /* 基准情形 */
        return 1;
    return n * factorial(n - 1);   /* 规模缩小 */
}

/* 斐波那契(反例:指数复杂度) */
long fib_bad(int n)
{
    if (n < 2) return n;
    return fib_bad(n - 1) + fib_bad(n - 2);   /* 重复计算爆炸:fib(40) 要算上亿次 */
}

/* 修正:带备忘的版本,O(n) */
long fib_memo(int n, long memo[])
{
    if (n < 2) return n;
    if (memo[n] != 0) return memo[n];
    return memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo);
}

递归的代价:栈帧

每次函数调用都会在调用栈上压入一个栈帧(保存局部变量、返回地址等)。递归深度 N 就有 N 层栈帧——栈空间有限(Linux 默认约 8MB,Windows 默认 1MB),深度递归会栈溢出(stack overflow),表现为段错误,且崩在"看不出问题"的地方。

/* factorial(100000) 就会爆栈 */

工程判断:递归适合"分叉"的问题(树的遍历、分治算法),线性递归多数可改循环。深度不可控的输入(如递归解析用户数据)必须改成显式栈的迭代版本,否则是拒绝服务漏洞。


5.7 函数设计实践

参数设计

/* ✅ 好的签名:意图自解释,输入输出分明 */
int read_all_lines(const char *path, char ***lines_out, size_t *count_out);
/*   返回错误码         输入              输出(指针的指针)      输出(数量)   */

/* ❌ 差的签名 */
int process(char*, int, char*, char*);   /* 参数是什么?谁是输出?int 是数量还是模式? */

工程惯例(第 17 章展开):

  1. 返回错误码,结果通过指针参数输出;
  2. 输出参数命名带 _out 后缀或放参数列表末尾;
  3. 只读的指针参数标 const;
  4. 参数个数超过 5 个考虑封装成结构体;
  5. 数组参数必须同时传递长度——C 不知道数组大小,这是缓冲区溢出(第 6 章)的头号根源。

一个函数只做一件事

/* ❌ 一函数多职:既是解析又是校验又是存储 */
/* ✅ 拆分后各自可测 */
int  parse_config(const char *text, config_t *out);
int  validate_config(const config_t *cfg);
int  save_config(const char *path, const config_t *cfg);

5.8 变参函数(了解)

printf 家族就是变参函数。自己实现需要 <stdarg.h>:

#include <stdarg.h>
#include <stdio.h>

/* 简易版:求一组 int 的和,约定第一个参数是数量 */
int sum_all(int count, ...)
{
    va_list ap;
    va_start(ap, count);          /* 从 count 之后开始取 */
    int total = 0;
    for (int i = 0; i < count; i++)
        total += va_arg(ap, int); /* 逐个取,类型必须与实参一致 */
    va_end(ap);
    return total;
}

printf("%d\n", sum_all(4, 1, 2, 3, 4));   /* 10 */

约束:变参函数至少要有一个固定参数;va_arg 取错类型是 UB;没有任何办法知道实参个数/类型——必须靠约定传递(printf 靠格式串)。工程中优先用固定参数 + 结构体。


5.9 内联与优化提示(了解)

C99 起 inline 是给编译器的优化建议(类似 C++),含义微妙(涉及外部定义规则)。初学阶段不要手写 inline,让 -O2 决定;跨文件性能优化交给编译器 LTO(第 20 章)。


5.10 调试方法:函数相关 Bug 怎么查

  • "改了没生效" → 90% 是值传递误解。检查:是否需要传指针?传的是 &x 吗?形参是指针吗?
  • 栈溢出段错误 → 递归没有基准情形或深度过大。GDB bt(backtrace)会显示成百上千层重复的栈帧,一眼定位到递归函数。局部大数组也会撑爆栈(改成 static、堆分配,见第 8 章)。
  • 返回值诡异 → 漏 return 的分支、返回了局部变量地址。-Wall + ASan(返回栈地址会被立刻标记)。
  • 调试器单步:GDB 里 break 函数名、step(进入函数)、next(跳过)、finish(跑完当前函数并打印返回值)——finish 是验证"这个函数返回了什么"最快的方式。
  • 画调用栈:理解"函数 A 调 B 调 C"的栈结构是调试一切的基础,bt 输出从下往上是 A→B→C。

5.11 小结

  • 原型先行:调用前必须可见声明,工程上原型进头文件。
  • C 只有值传递;"输出"靠指针;数组传参会退化为指针且必须带长度。
  • 不要返回局部变量地址;返回错误码是主流。
  • static 局部变量 = 记忆;static 全局/函数 = 本文件私有。
  • 递归三问:基准情形?规模缩小?深度可控?线性递归优先改循环。
  • 函数做一件事,签名即文档,数组必带长度。

思考与练习

  1. 写函数 void swap(int *a, int *b),在 main 中验证交换成功;再写一个 void swap_bad(int a, int b),解释它为什么失败。
  2. 实现递归版 sum_digits(int n)(数位求和),并写出它的循环版;对比两者。
  3. 写函数 int max_of(const int *arr, size_t n) 返回数组最大值下标,空数组时返回 -1。测试:单元素、全负数、重复最大值。
  4. 用 GDB 观察递归:对 factorial(5) 打断点,bt 查看栈帧,frame 3 跳到中间帧并打印该层的局部变量。
  5. 实现 long power(int base, int exp):循环版与"快速幂"递归版(power(b, e) = power(b, e/2)²),体会递归把 O(n) 降到 O(log n) 的力量。

上一章:流程控制 | 下一章:数组与字符串 →

📄 第 06 章 数组与字符串 06-数组与字符串.md

学习目标:掌握数组的定义、初始化、遍历与多维数组;理解"数组名退化为指针"这一关键规则;掌握 C 字符串的本质与 string.h 常用函数;系统认识缓冲区溢出并掌握安全写法(fgets/snprintf/边界函数)。


6.1 一维数组

数组是同类型元素的连续内存块。定义时长度必须是编译期常量(C99 起允许变长数组 VLA,但工程上不推荐,见 6.5 节):

int scores[5];                          /* 未初始化:元素是垃圾值! */
int primes[5] = {2, 3, 5, 7, 11};       /* 完整初始化 */
int zeros[100] = {0};                   /* 首元素 0,其余自动补 0 —— 清零惯用法 */
int partial[5] = {1, 2};                /* 1, 2, 0, 0, 0 */
int inferred[] = {10, 20, 30};          /* 编译器推断长度为 3 */

printf("%zu\n", sizeof(primes) / sizeof(primes[0]));  /* 5:元素个数惯用法 */

访问与修改:

scores[0] = 95;              /* 下标从 0 开始 */
int last = scores[4];        /* 最后一个元素是下标 n-1 */
/* scores[5] 越界!编译器通常不报错(对变量下标),运行时是 UB */

C 不做越界检查——不是"越界会崩溃",而是"越界是 UB,可能崩、可能脏了别的变量、可能看起来没事"。这是 C 与 Python/Java 最大的安全差异。

❌ 经典错误:越界写破坏相邻变量

int i;
int a[3] = {0};
for (i = 0; i <= 3; i++)    /* ❌ i=3 越界 */
    a[i] = 0;
/* 编译器可能恰好把 i 安排在 a[3] 的位置 → 越界写把 i 清零 → 死循环!
   这类"灵异现象"是越界写的典型表现 */

❌ 经典错误:用变量长度定义数组后,换编译器就编不过

int n = 10;
int a[n];          /* C99 起"合法"(VLA),但 C11 起是可选特性,MSVC 不支持 */

长度来自运行期数据时,用堆分配(第 8 章 malloc)。


6.2 数组与指针的第一课:退化(decay)

这是 C 的关键规则,第 7 章会从指针角度重新审视:

数组名在大多数表达式中自动退化为"指向首元素的指针"。例外只有三个:sizeof(数组名)、&数组名、字符串字面量初始化字符数组。

推论:

  1. sizeof 在函数内拿不到数组大小。数组作参数时传的是指针:
void print_arr(int arr[], size_t n)   /* 形参 int arr[] 实质是 int *arr */
{
    printf("%zu\n", sizeof(arr));     /* 打印指针大小(8),不是数组大小! */
}

/* 所以:数组作参数必须同时传长度。这是 C API 的铁律 */
  1. 函数内可以修改调用方的数组(拷贝的是地址,第 5 章)。
  2. 数组不能整体赋值、不能作为返回值:
int a[3] = {1, 2, 3}, b[3];
b = a;                    /* ❌ 编译错误。用 memcpy 或逐元素拷贝 */
memcpy(b, a, sizeof(a));  /* ✅ 需 #include <string.h> */

6.3 多维数组

C 的多维数组是"数组的数组",内存上完全连续、按行存储:

int matrix[3][4] = {
    { 1,  2,  3,  4},
    { 5,  6,  7,  8},
    { 9, 10, 11, 12},
};

matrix[1][2] = 7;              /* 第 2 行第 3 列 */
printf("%zu\n", sizeof(matrix) / sizeof(matrix[0]));          /* 3:行数 */
printf("%zu\n", sizeof(matrix[0]) / sizeof(matrix[0][0]));    /* 4:列数 */

/* 遍历 */
for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 4; j++)
        printf("%4d", matrix[i][j]);
    putchar('\n');
}

传参时必须写明列数(编译器要靠它计算 matrix[i][j] 的地址偏移):

void print_matrix(int m[][4], size_t rows);    /* ✅ 列数必填 */
/* 等价写法:void print_matrix(int (*m)[4], size_t rows); */

"多维"超过二维时工程上更常用指针数组或一维数组手动展开(行 × 列数 + 列号),见第 7、8 章。


6.4 C 字符串:以 '\0' 结尾的字符数组

C 没有字符串类型。C 字符串 = 字符数组 + 约定结尾是 '\0'(空字符)。所有 <string.h> 函数、所有"字符串"操作,都依赖这个约定找到结尾。

char s1[6] = {'h', 'e', 'l', 'l', 'o', '\0'};  /* 手工逐字符 */
char s2[]  = "hello";                          /* 编译器自动加 \0:长度 6 */
char *s3   = "hello";                          /* 指向字符串字面量(只读!) */

printf("%zu\n", sizeof(s2));   /* 6:含 \0 的内存大小 */
printf("%zu\n", strlen(s2));   /* 5:不含 \0 的逻辑长度 */

sizeof vs strlen:前者是编译期的内存大小(含 \0),后者是运行期数到 \0 的字符数(不含)。strlen 是 O(n)——别把它写进循环条件:

for (size_t i = 0; i < strlen(s); i++)  /* ❌ 每轮都 O(n),整体 O(n²) */
for (size_t i = 0; s[i] != '\0'; i++)   /* ✅ 或先把 strlen 存下来 */

❌ 致命错误:字符串没有 \0

char buf[5] = {'h','e','l','l','o'};   /* ⚠️ 没有 \0!不是合法字符串 */
strlen(buf);   /* UB:会一直读到内存中恰好的 0 为止,长度随机、可能越界崩溃 */

❌ 经典错误:修改字符串字面量

char *p = "hello";
p[0] = 'H';      /* ❌ 字面量通常在只读段,段错误(现代系统) */
char arr[] = "hello";
arr[0] = 'H';    /* ✅ 数组副本,在栈上,可写 */

口诀:要可写用数组 char s[] = "...",只读别名用指针 const char *p = "..."。指向字面量时指针应声明为 const char *,让编译器替你挡住误写。

string.h 常用函数

#include <string.h>

size_t  len = strlen(s);                       /* 长度 */
char   *dot = strchr(s, '.');                  /* 找第一个 '.',没有则 NULL */
char   *sub = strstr(s, "key");                /* 找子串 */

strcpy(dst, src);       /* 拷贝(含 \0)。❌ 不检查 dst 空间,危险! */
strncpy(dst, src, n);   /* 历史遗留陷阱:src 长时无 \0,短时填充 \0。少用 */
strcat(dst, src);       /* 拼接。❌ 同样不检查空间 */
strcmp(a, b);           /* 比较:<0 / 0 / >0。注意"相等"是 == 0,不是真值! */
strncmp(a, b, n);       /* 比较前 n 个字符 */
memset(buf, 0, size);   /* 填充字节,清零惯用法 */
memcpy(dst, src, size); /* 内存拷贝(不处理 \0,区间不得重叠) */
memmove(dst, src, size);/* 允许重叠的内存拷贝 */

❌ 高频错误:strcmp 的返回值当布尔用

if (strcmp(a, b))      /* ❌ 意思是"不相等"时为真——逻辑反了 */
if (strcmp(a, b) == 0) /* ✅ 相等 */
if (!strcmp(a, b))     /* ✅ 同上,惯用写法 */

❌ 高频错误:strcpy/strcat 溢出

char name[16];
strcpy(name, argv[1]);   /* ❌ 用户输入超过 15 字符 → 栈溢出 → 可被利用的安全漏洞 */

安全替代(C11 Annex K 与 POSIX 皆有,可移植写法见下):

/* ✅ 跨平台最稳的组合:snprintf + 手动拼接检查 */
#include <stdio.h>

char full[64];
snprintf(full, sizeof(full), "%s/%s", dir, name);   /* 截断但不溢出;返回值> sizeof(full) 表示被截断 */

/* ✅ 长度检查后再拷贝 */
size_t n = strlen(src);
if (n >= sizeof(dst) - 1) { /* 太长,报错 */ }
else memcpy(dst, src, n + 1);       /* +1 连 \0 一起拷 */

逐字符处理的两个惯用循环

/* 1. 遍历每个字符 */
for (const char *p = s; *p != '\0'; p++) { /* *p 是当前字符 */ }

/* 2. 原地转换(如转大写),用 ctype.h */
#include <ctype.h>
for (char *p = s; *p; p++)
    *p = (char)toupper((unsigned char)*p);  /* 注意:ctype 函数的参数要用 unsigned char 转换 */

ctype 细节:isalpha 等函数的参数若传负值的 char(如 UTF-8 字节)是 UB,惯用法是先 (unsigned char) 转换。


6.5 缓冲区溢出:C 的头号安全漏洞

把本章知识与第 5 章栈帧知识合起来看溢出的危害:

void check_password(const char *input)
{
    char buf[16];
    int authorized = 0;              /* 恰好排在 buf 后面的栈内存 */

    strcpy(buf, input);              /* ❌ input 超过 15 字节时,越界写会覆盖 authorized */
}

攻击者输入 16 字节填充 + 4 字节非零值,authorized 被改写,密码检查形同虚设;更精心构造的输入甚至能覆盖返回地址、劫持程序(栈溢出攻击)。缓冲区溢出至今仍是 CVE 排行榜常客。

防御清单:

  1. 永不使用 gets()(C11 已废除)、不检查边界的 strcpy/strcat/sprintf;
  2. 读用户输入用 fgets,并处理结尾换行符:
char line[64];
if (fgets(line, sizeof(line), stdin) == NULL) { /* EOF 或错误 */ }
line[strcspn(line, "\n")] = '\0';    /* 去掉行尾换行符的惯用法 */

注意 fgets 一次最多读 sizeof(line)-1 字符,超长输入会残留在输入流里,需要时清空或继续读。 3. 格式化进缓冲区用 snprintf(返回"本应写入的长度",可检测截断); 4. 所有"往数组里放东西"的代码,问自己一句:最长的合法输入是多长?超了怎么办? 答不上来就是漏洞。 5. 编译期加持:-fsanitize=address(第 8、16 章)能在测试中立刻抓到溢出;_FORTIFY_SOURCE(GCC/Clang)对可确定大小的调用做强化。


6.6 综合示例:安全的学生成绩统计

#include <stdio.h>
#include <string.h>

#define MAX_NAME 32
#define MAX_STUDENTS 100

int main(void)
{
    char names[MAX_STUDENTS][MAX_NAME];   /* 二维字符数组:存 MAX_STUDENTS 个名字 */
    int  scores[MAX_STUDENTS];
    size_t count = 0;
    char line[128];

    printf("输入 姓名 分数(空行结束):\n");
    while (count < MAX_STUDENTS && fgets(line, sizeof(line), stdin) != NULL) {
        if (line[0] == '\n')              /* 空行退出 */
            break;

        char name[MAX_NAME];
        int score;
        if (sscanf(line, "%31s %d", name, &score) != 2) {  /* %31s 限宽防溢出 */
            fprintf(stderr, "格式错误,跳过: %s", line);
            continue;
        }
        snprintf(names[count], MAX_NAME, "%s", name);      /* 安全拷贝 */
        scores[count] = score;
        count++;
    }

    if (count == 0) {
        printf("没有输入数据\n");
        return 1;
    }

    long total = 0;
    size_t best = 0;
    for (size_t i = 0; i < count; i++) {
        total += scores[i];
        if (scores[i] > scores[best])
            best = i;
    }
    printf("人数 %zu,平均 %.2f,最高分 %s(%d)\n",
           count, (double)total / count, names[best], scores[best]);
    return 0;
}

要点:fgets+sscanf 组合代替裸 scanf(一行缓冲、可判空行、错误不阻塞)、%31s 限宽、snprintf 拷贝、count 用 size_t 且处处检查上限。


6.7 调试方法:数组与字符串 Bug 怎么查

症状 排查方向
字符串长度/内容诡异 打印 strlen 与 sizeof 对比;检查初始化是否含 \0;strncpy 陷阱
打印乱码直到崩溃 忘了 \0 或 %s 传了非字符串指针。GDB x/16bx buf 查看原始字节
某个不相干变量的值被改 数组越界写踩了它。ASan 上,报告会直接指出溢出的数组与写入位置
输入"吞掉"或死循环 scanf/fgets 混用残留换行;非法输入滞留缓冲区;打印每次读取的返回值
复制后字符串被截断 目标缓冲区太小被 snprintf 截断(检查返回值);strncpy 不写 \0

调试字符串的核心工具:十六进制视图。GDB 中 x/s buf(当字符串看)、x/32c buf(当字符看)、x/32bx buf(当字节看)。'\0' 位置一目了然。


6.8 小结

  • 数组连续存储、下标 0 起、C 不查越界;sizeof(a)/sizeof(a[0]) 求个数。
  • 数组传参退化为指针:函数签名必带长度;sizeof 只在定义数组的作用域有效。
  • C 字符串 = char[] + '\0' 约定;sizeof 含 \0,strlen 不含且 O(n)。
  • 白名单函数:fgets、snprintf、memcpy(不重叠)/memmove(重叠)、strncmp、strcspn;黑名单:gets、strcpy、strcat、sprintf。
  • 字面量只读:可写用 char s[],别名用 const char *。
  • 输入永远限宽,输出永远查截断。

思考与练习

  1. 编写函数 size_t my_strlen(const char *s) 与 char *my_strcpy(char *dst, const char *src)(返回 dst),只用指针遍历,不调用库函数。
  2. 实现一个"安全的字符串拼接":int safe_concat(char *dst, size_t dst_size, const char *a, const char *b),空间不足返回 -1 且 dst 内容不被破坏。
  3. 统计一行输入中单词的个数与最长单词(单词=非空白连续段)。用 fgets 读取。
  4. 写程序把一个 3×3 矩阵转置(原地交换 m[i][j] 与 m[j][i]),并解释为什么内层循环要从 j = i + 1 开始。
  5. 用 ASan 编译 6.6 节程序,把 %31s 改成 %s,输入超长名字,观察 ASan 的报错输出(第 16 章有详细解读)。

上一章:函数 | 下一章:指针 →

📄 第 07 章 指针 07-指针.md

学习目标:建立"地址"的正确心智模型;掌握取地址与解引用;理解指针与数组、指针与函数、指针与字符串的关系;掌握二级指针、函数指针与 void*;系统认识野指针、空指针与悬垂指针的防御方法。


7.1 心智模型:内存是一条长长的街道

把内存想象成一条街道,每个字节是一间房,地址就是门牌号:

地址:    0x1000  0x1001  0x1002  0x1003 ...
内容:    [ int x = 42 占 4 间房 ]

变量 x 是"住着 42 的 4 间连体房";指针就是记录门牌号的纸条。指针变量的值是某个字节的编号,类型则告诉 C"从这间房起、连续读几间、按什么方式解释":

int x = 42;
int *p = &x;      /* p 存的是 x 的地址;p 的类型 int* 表示"指向 int" */

printf("%p\n", (void *)p);   /* 打印地址,如 0x7ffd... (%p 要求 void*) */
printf("%d\n", *p);          /* 42:解引用,沿着地址找到房间取出内容 */
printf("%zu\n", sizeof(p));  /* 8(64 位平台):指针本身的大小与指向类型无关 */

两个核心运算符:

  • & 取地址:&x 得到 x 的地址(类型 int*)。
  • * 解引用:*p 沿地址访问目标——既能读(int v = *p)也能写(*p = 100,等于改 x)。

声明语法的心法:int *p 读作"*p 是 int"——即"对 p 解引用得到一个 int",所以 p 的类型是 int*。这个读法能解开一切声明谜题(int **pp、int *arr[3]、int (*f)(int),见后文)。

指针三问(拿到任何指针先问):指向哪里?指向的类型是什么?它还有效吗? 三个问题答案不明时使用指针,就是在拆炸弹。


7.2 指针的意义:它解决什么问题

初学者常问"既然有变量为什么还要指针"。指针的三件武器:

  1. 让函数修改调用方的数据(值传递的出路,第 5 章):scanf("%d", &x)、swap(&a, &b)。
  2. 在运行期向系统要内存(堆):链表、树等"长度未知、生长中"的数据结构全靠指针串联(第 8、12 章)。
  3. 间接层与多态:函数指针让"调用哪个函数"成为运行期决定(回调);void* 让容器能装任何类型(qsort)。

7.3 指针与数组:一对貌合神离的搭档

第 6 章埋下的伏笔在此揭晓:数组名在表达式中退化为指向首元素的指针。

int a[5] = {10, 20, 30, 40, 50};
int *p = a;              /* 等价于 int *p = &a[0]; */

printf("%d\n", *p);        /* 10 */
printf("%d\n", *(p + 1));  /* 20:指针算术,+1 步进一个 int(4 字节) */
printf("%d\n", p[1]);      /* 20:p[i] 就是 *(p+i) 的语法糖! */
printf("%d\n", a[1]);      /* 20:a[i] 编译后同样是 *(a+i) */

下标运算符的真相:a[i] ≡ *(a + i)。数组下标和指针运算在编译后是同一种东西。甚至 2[a] 也合法(*(2+a)),但没人这么写。

指针算术的步长由指向类型决定:

int    *pi = (int *)0x1000;   pi + 1 → 0x1004   /* +4 字节 */
double *pd = (double *)0x1000; pd + 1 → 0x1008  /* +8 字节 */
char   *pc = (char *)0x1000;   pc + 1 → 0x1001  /* +1 字节 */

"指针加减"只在数组内有意义:合法区间是 [首元素地址, 末元素地址+1](末尾后一位允许计算、允许比较,但不允许解引用)。跳到别的对象再算术,是 UB。

数组和指针的三处不同(背下来,面试与实战双高频):

数组 int a[5] 指针 int *p
sizeof 20(整个数组) 8(指针自身)
&a 的类型 int(*)[5](指向整个数组的指针) int**(指向指针的指针)
能否重新赋值 不能(a 是常量地址) 能(p = 其他地址)

7.4 指针与函数:参数、返回值、函数指针

出参模式(回顾与强化)

/* 计算除法,同时返回商和余数:返回值承担错误码,结果走指针参数 */
int divmod(int a, int b, int *quot, int *rem)
{
    if (b == 0)
        return -1;             /* 失败 */
    *quot = a / b;             /* 通过地址写回调用方 */
    *rem  = a % b;
    return 0;                  /* 成功 */
}

int q, r;
if (divmod(17, 5, &q, &r) == 0)
    printf("%d ... %d\n", q, r);

const 与指针参数:const char *s 表示"通过这个指针不许写",既保护数据又表达意图(第 5 章已用,原理在此)。四种组合:

const int *p;        /* 指向的数据不可写(最常用) */
int *const p;        /* 指针本身不可改(少用) */
const int *const p;  /* 都不可改 */

读法心法:const 修饰它左边的东西(左边没东西才修饰右边)。

返回指针

可以返回:堆地址(malloc,第 8 章)、静态存储地址、调用方传入的地址(在参数里找输出位置)。不能返回局部变量地址(悬垂,第 5 章)。返回"可能失败"的指针时约定返回 NULL,如 malloc、fopen、strchr。

函数指针:把"行为"当数据

函数也有地址。函数指针让我们把函数作为参数传递(回调机制):

#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

/* 声明一个函数指针:int (*)(int, int) —— "指向 (int,int)->int 函数的指针" */
int apply(int (*op)(int, int), int x, int y)
{
    return op(x, y);        /* 通过指针调用,两种等价写法:op(x,y) 或 (*op)(x,y) */
}

int main(void)
{
    printf("%d\n", apply(add, 3, 2));   /* 5 */
    printf("%d\n", apply(sub, 3, 2));   /* 1 */

    int (*fp)(int, int) = add;          /* 变量形式 */
    printf("%d\n", fp(10, 5));          /* 15 */
    return 0;
}

声明读法:int (*fp)(int, int) —— *fp 是"接受 (int,int) 返回 int 的函数",所以 fp 是指向这种函数的指针。(对比陷阱:int *fp(int, int) 读作"*fp(...) 是 int",即返回 int* 的函数——括号决定命运。)

标准库最重要的应用——qsort:

#include <stdlib.h>

int cmp_int(const void *a, const void *b)      /* qsort 要求的固定签名 */
{
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);                  /* 安全写法:不直接相减(防溢出) */
}

int arr[] = {5, 1, 4, 2, 3};
qsort(arr, 5, sizeof(int), cmp_int);           /* {1,2,3,4,5} */

qsort 把"排序算法"(它实现)与"比较规则"(你实现)解耦——这就是函数指针的工程价值。第 13 章会实现自己的通用排序。

函数指针的typedef:签名一长就难读,先 typedef:

typedef int (*compare_fn)(const void *, const void *);
void my_sort(void *base, size_t n, size_t size, compare_fn cmp);

7.5 二级指针与指针数组

指针数组 vs 数组指针(读法决定一切)

const char *names[3] = {"Alice", "Bob", "Carol"};
/* names 是"数组,元素是 const char*"——字符串表惯用法 */
for (int i = 0; i < 3; i++)
    printf("%s\n", names[i]);

int (*row)[4];       /* row 是"指向 int[4] 的指针"——配合二维数组传参 */
int m[3][4];
row = m;             /* row+1 跳过一整行(16 字节) */

二级指针:修改"指针本身"

值传递意味着:函数想修改调用方的指针变量,必须传指针的地址:

/* 典型场景:函数内部分配内存,交给调用方 */
int alloc_block(int **out, size_t n)
{
    int *p = malloc(n * sizeof(int));
    if (p == NULL)
        return -1;
    *out = p;               /* 修改调用方的指针 */
    return 0;
}

int *data;
if (alloc_block(&data, 100) == 0) { ... free(data); }

main 的 argv:指针数组的实战形态

int main(int argc, char *argv[])
/* argv 的结构:argv → [char*, char*, ..., NULL]
                 每个元素指向一个命令行参数字符串 */

动态字符串数组(第 12 章数据结构的基础形态):

char **lines = malloc(n * sizeof(char *));   /* n 个 char* 的数组 */
/* lines[i] = malloc(len_i + 1); 存入各行 */
/* 释放:先逐个 free(lines[i]),再 free(lines)——顺序不能反 */

7.6 void*:万能指针

void* 是"指向未知类型的指针"——只知道地址,不知道如何解释。任何对象指针都可隐式转成 void*,反向转换需显式:

void *v = &x;              /* int* → void*:隐式,OK */
int  *p = (int *)v;        /* void* → int*:显式转换,程序员负责类型正确 */

三大用途:

  1. 泛型接口:malloc 返回 void*(所以不用强转就能赋给任何类型指针——与 C++ 不同);memcpy/qsort/bsearch 的参数都是 void*。
  2. 容器装载任意类型:自己写的链表节点 void *data; 可挂任何东西(代价:丢失类型检查,工程上常用带类型标签的变体,第 12 章)。
  3. 不透明接口:API 只暴露 handle_t(内部是 void* 或前向声明的结构体指针),隐藏实现(第 17 章)。

约束:void* 不能解引用、不能做算术(大小未知)。C 严谨写法在 %p 打印、与对象指针互转时涉及 void* 中转。


7.7 NULL、野指针、悬垂指针:指针的三大天敌

NULL:刻意的"指向虚无"

int *p = NULL;        /* NULL 在 <stddef.h> 等头文件定义,通常是 ((void*)0) */
if (p != NULL) {      /* 使用前判空——防御性编程的基本功 */
    printf("%d\n", *p);
}
/* 简写:if (p) 等价 if (p != NULL);C23 引入 nullptr 关键字 */

解引用 NULL 在多数系统上触发段错误——这是"好"的崩溃:位置确定、调试器直接停下(可以设置让程序在崩溃点 core dump)。比崩溃更糟的是野指针:悄悄破坏数据。

野指针:没初始化的指针

int *p;          /* ❌ 未初始化:值是栈上的随机比特(垃圾地址) */
*p = 10;         /* 往随机地址写!可能崩,可能污染任意内存 */

防御铁律:声明指针时立即初始化——要么指向有效对象,要么 NULL。 开 -Wall(GCC 的 -Wuninitialized 会抓多数情况)。

悬垂指针:指向已释放/已销毁的内存

int *dangling(void) {
    int local = 42;
    return &local;      /* ❌ 函数返回后栈帧销毁 */
}

/* 场景二:free 之后继续用 */
free(buf);
printf("%s\n", buf);     /* ❌ 悬垂:内存可能已被复用,读出垃圾或崩溃 */
buf = NULL;              /* ✅ free 后立刻置 NULL(配合判空使用) */

防御三件套:

  1. free(p); p = NULL;——成对书写;重复 free(NULL) 是安全的(标准规定为空操作),重复 free 非 NULL 指针是 UB(double free,堆破坏)。
  2. 不在两个地方持有同一块堆内存的所有权(谁分配谁释放,第 8 章所有权原则)。
  3. 测试期全程 ASan:悬垂访问(use-after-free)会被立刻精确报出。

7.8 综合示例:指针版字符串函数

亲手实现一遍,是对指针理解的最佳检验:

#include <stddef.h>

/* 计算字符串长度 */
size_t my_strlen(const char *s)
{
    const char *p = s;
    while (*p != '\0')
        p++;
    return (size_t)(p - s);      /* 指针相减:得到元素个数(不是字节数) */
}

/* 拷贝:返回 dst 便于链式调用 */
char *my_strcpy(char *dst, const char *src)
{
    char *ret = dst;             /* 记住起点 */
    while ((*dst++ = *src++) != '\0') { }   /* 经典一行:拷贝、推进、以 \0 结束 */
    return ret;
}

/* 比较:负/零/正 */
int my_strcmp(const char *a, const char *b)
{
    while (*a != '\0' && *a == *b) {
        a++;
        b++;
    }
    return (unsigned char)*a - (unsigned char)*b;
}

逐字拆解 *dst++ = *src++:取 *src 的值 → 赋给 *dst → 两个指针各自前进一格 → 循环条件是刚赋的值是否为 \0。这一行综合了指针解引用、后置自增、赋值表达式的值——理解它,指针算术就毕业了。


7.9 调试方法:指针 Bug 怎么查

症状 大概率原因 工具/方法
一上来就段错误 解引用 NULL/野指针 GDB 崩溃点 bt;打印指针值是否为 0 或明显异常(如 0x55aa... 的垃圾值)
"好端端"的变量被改 越界写/悬垂写踩到它 ASan:报告直接给出"哪行写、踩了哪个对象、那对象是谁分配的"
free 后崩溃或数据错乱 use-after-free / double free ASan 精确定位;养成 free 后置 NULL
打印地址 %p 结果是 0x0 或全 f 忘初始化或已被置空;%p 传了非指针 打印时 (void*)p
函数指针调用崩溃 签名不匹配(调用约定/参数错位) 检查 typedef 与函数定义是否一致
指针相减结果离谱 忘了是"元素个数"而非字节数;两指针不在同一数组 检查类型与来源

GDB 指针三板斧:

print p          # 指针的值(地址)
print *p         # 解引用看内容(无效地址会报 Cannot access memory)
x/4xw p          # 以十六进制字(4字节)查看 p 指向的 16 字节内存

运行时检测矩阵(详见第 16 章):ASan(越界/悬垂/重复释放)、UBSan(对齐/空指针算术)、Valgrind(未初始化读、泄漏)。测试期全开是现代 C 工程的底线配置。


7.10 小结

  • 指针 = 地址 + 解释方式。声明读法:"*p 是 T" ⇒ p 是 T*。
  • a[i] ≡ *(a+i);指针算术步长由类型决定;只在自己数组区间内有效。
  • 数组与指针不同:sizeof、&、可赋值性三处不同;传参必须带长度。
  • 出参模式:错误码返回 + 指针输出;const 修饰意图;函数指针实现回调与泛型。
  • void* 用于泛型与隐藏实现;不可解引用。
  • 三大天敌:野指针(未初始化)、悬垂(用已失效内存)、NULL(判空防崩溃)。free 后置 NULL。
  • 指针三问:指向哪?什么类型?还有效吗?

思考与练习

  1. 画出 int a[4] = {1,2,3,4}; int *p = a + 1; 的内存示意图,标出 *p、p[2]、*(p-1)、&a[3] - p 的值。
  2. 实现 void my_reverse(char *s)(原地反转字符串)与 int my_atoi(const char *s)(字符串转整数,处理正负号),只允许指针遍历。
  3. 写函数 void find_min_max(const int *a, size_t n, int *min_out, int *max_out),空数组返回失败——综合出参 + 错误码模式。
  4. 用函数指针实现一个简易计算器:int calc(int a, int b, char op) 内部用函数指针数组分派 + - * /(想想除零怎么处理)。
  5. 解释以下每行声明:int *a[5];、int (*b)[5];、int (*c)(int);、int **d;、const char *e;。

上一章:数组与字符串 | 下一章:内存管理 →

📄 第 08 章 内存管理 08-内存管理.md

学习目标:掌握进程内存布局五段结构;熟练使用 malloc/calloc/realloc/free;建立所有权(ownership)思维;系统掌握内存泄漏、悬垂指针、重复释放、越界访问四大病的成因与防治;会用 Valgrind 与 AddressSanitizer 检测内存问题。


8.1 进程内存布局:五段结构

一个 C 程序运行时的内存,从低地址到高地址大致分为五段:

高地址
┌─────────────────┐
│      栈 Stack    │ ← 局部变量、函数参数、返回地址。向下生长。
│       ↓          │    由编译器自动管理,速度快、空间小(Linux 默认 ~8MB)
│                 │
│   (空闲区域)    │
│       ↑          │
│      堆 Heap     │ ← malloc/free 管理的区域。向上生长。
│                 │    程序员手动管理,空间大(可到 GB 级),分配稍慢
├─────────────────┤
│   BSS 段         │ ← 未显式初始化的全局/static 变量(自动清零)
├─────────────────┤
│   数据段 Data    │ ← 已初始化的全局/static 变量
├─────────────────┤
│   代码段 Text    │ ← 机器指令(只读);字符串字面量通常也在只读区
└─────────────────┘
低地址

三种存储位置的对照(务必内化):

栈 堆 静态区(Data/BSS)
谁管理 编译器自动进出栈 程序员 malloc/free 程序启动到结束
生命周期 所在函数调用期间 从 malloc 到 free 整个程序运行期
大小限制 小(~MB) 大(GB 级) 编译期确定
速度 最快 较慢(需搜索/维护元数据) 快
典型错误 返回局部变量地址、栈溢出 泄漏、悬垂、越界 重入/线程安全问题
int g_init = 1;        /* Data 段 */
int g_zero;            /* BSS 段(自动为 0) */
static int s_cnt;      /* BSS 段 */

void f(void) {
    int local = 0;             /* 栈 */
    static int counter = 0;    /* 静态区(初始化只执行一次) */
    int *heap = malloc(100);   /* heap 指针本身在栈,指向的内存在堆 */
    free(heap);
}

栈溢出实例:int big[10 * 1024 * 1024];(40MB 局部数组)在默认 8MB 栈上直接爆。大数据(尤其大小依赖运行期输入时)放堆上。


8.2 malloc / calloc / realloc / free

四个函数都在 <stdlib.h>:

void *malloc(size_t size);                      /* 分配 size 字节,内容未初始化 */
void *calloc(size_t n, size_t size);            /* 分配 n*size 字节,并清零 */
void *realloc(void *p, size_t new_size);        /* 调整已分配块大小 */
void  free(void *p);                            /* 释放 */

标准用法:

#include <stdlib.h>

int *arr = malloc(10 * sizeof(int));       /* 注意:字节数 = 个数 × 单个大小 */
if (arr == NULL) {                          /* 分配可能失败!必须检查 */
    fprintf(stderr, "out of memory\n");
    return 1;
}
/* malloc 的内存是"脏"的,用前要么初始化,要么改用 calloc */
int *zeroed = calloc(10, sizeof(int));      /* 10 个 int,全 0 */

arr[0] = 42;
free(arr);                                  /* 用完必须还 */
arr = NULL;                                 /* 悬垂防御:置空 */

free(zeroed);

四条铁律:

  1. 检查返回值:malloc 失败返回 NULL。解引用 NULL 段错误。嵌入式/长跑服务尤其要处理。
  2. 字节数永远写 n * sizeof(T):手写 malloc(40) 硬编码字节是定时炸弹(换类型就错)。
  3. malloc/calloc 配对 free,一次不多不少:不 free = 泄漏;free 两次 = 堆破坏(UB)。
  4. free 只接受三种指针:malloc 家族的返回值、NULL(合法空操作)、其间未移动过的指针。对栈变量、数组中间地址、偏移后的指针 free,全是 UB。

不要对 malloc 返回值强制转换:(int *)malloc(...) 在 C 中多余(void* 隐式转换),且会掩盖"忘了 #include <stdlib.h>"的隐式声明错误——这条与 C++ 不同。

realloc 的正确姿势

realloc 尝试原地扩容,失败时在别处分配并拷贝、释放原块——失败时原块仍有效:

/* ❌ 经典错误:泄漏原块 */
arr = realloc(arr, new_n * sizeof(int));
/* 若 realloc 失败返回 NULL,arr 被覆盖成 NULL,原内存再也找不回来 */

/* ✅ 正确姿势:临时变量中转 */
int *tmp = realloc(arr, new_n * sizeof(int));
if (tmp == NULL) {
    /* arr 依然有效,按原样处理失败(或优雅退出) */
} else {
    arr = tmp;
}

realloc(NULL, n) 等价 malloc(n)——这让"从零增长"的代码可以统一处理。扩容后旧指针全部失效(可能整块搬家),别再持有旧地址。


8.3 常见错误四兄弟(按真实事故率排序)

1. 内存泄漏(memory leak):只借不还

void leaky(void) {
    char *buf = malloc(1024);
    if (error_happens)          /* ❌ 提前 return 忘了 free */
        return;
    free(buf);
}

泄漏的特点:当下无症状,长跑服务(服务器、守护进程)内存缓慢上涨,最终 OOM 被系统杀掉。泄漏的常见来源:提前 return 的分支、异常路径、realloc 覆盖、丢失唯一指针(p = p->next 之前忘了暂存要释放的节点)。

2. 悬垂指针(dangling pointer):用已归还的房间

int *p = malloc(sizeof(int));
free(p);
*p = 10;              /* ❌ use-after-free:堆可能已把这块复用给别人 */

危害比泄漏更直接:读出脏数据、破坏正在使用这块内存的其他数据,症状"随机",极难排查。

3. 越界访问(buffer overflow):多写一格

int *a = malloc(5 * sizeof(int));
a[5] = 0;             /* ❌ 越界写:踩坏堆管理元数据,崩溃点可能在很久以后的某次 free */
free(a);              /* ⚠️ 此时报 "corrupted top size" 之类的诡异错误 */

堆越界往往延迟爆炸:出错时(分配时)与案发时(越界写时)相距甚远。这是 ASan/Valgrind 存在的意义——把"案发"定位到"现场"。

4. 重复释放(double free):同一房间结两次账

free(p);
free(p);              /* ❌ UB:堆元数据被二次释放破坏,著名的安全漏洞类型 */

通用的防治心法:

  • 所有权原则:每块堆内存只有一个"所有者"模块负责释放;指针传递分"移交所有权"与"借用"两种,接口文档写明(第 17 章)。
  • free 后置 NULL:free(p); p = NULL;——重复 free 变成无害的 free(NULL)。
  • 分配与释放对称:谁分配谁释放(同层),不要 A 模块分配 B 模块释放(除非文档明确移交)。
  • 对称路径检查:写每个 return/goto 出口时,清点已分配资源是否全部释放(第 4 章 goto 清理模式正是为此而生)。

8.4 动态数组:堆内存的典型工程形态

"长度运行期才知道的数组"——第 6 章 VLA 的正解:

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int  *data;
    size_t len;
    size_t cap;          /* 容量:已分配的槽位数 */
} int_vec;

int vec_init(int_vec *v, size_t cap)
{
    v->data = calloc(cap, sizeof(int));
    if (v->data == NULL)
        return -1;
    v->len = 0;
    v->cap = cap;
    return 0;
}

int vec_push(int_vec *v, int value)
{
    if (v->len == v->cap) {                       /* 满了,扩容 2 倍(均摊 O(1)) */
        size_t new_cap = v->cap ? v->cap * 2 : 8;
        int *tmp = realloc(v->data, new_cap * sizeof(int));
        if (tmp == NULL)
            return -1;
        v->data = tmp;
        v->cap = new_cap;
    }
    v->data[v->len++] = value;
    return 0;
}

void vec_free(int_vec *v)
{
    free(v->data);
    v->data = NULL;
    v->len = v->cap = 0;
}

int_vec 就是简化版 std::vector:容量倍增策略让 n 次 push 的总成本是 O(n)(均摊每次 O(1))。这个模式在第 12 章数据结构中反复出现。

多维动态数组:

/* 方式一:指针数组(行地址不连续,但支持不规整行) */
int **grid = malloc(rows * sizeof(int *));
for (size_t i = 0; i < rows; i++)
    grid[i] = calloc(cols, sizeof(int));
/* 释放:先逐行 free,再 free(grid)——顺序反了就泄漏整行 */

/* 方式二:一维展开(连续内存,缓存友好,第 20 章首选) */
int *flat = calloc(rows * cols, sizeof(int));
/* 访问:flat[i * cols + j] */
free(flat);

8.5 检测工具:让内存 Bug 现形

AddressSanitizer(ASan)——首选

编译时插桩,运行时精确报错。GCC/Clang 一行参数:

gcc -g -fsanitize=address,undefined -fno-omit-frame-pointer prog.c -o prog
./prog

对 use-after-free、越界(栈/堆/全局)、double free、泄漏(退出时汇报)全部当场报错,输出包含:出错代码行、内存块分配/释放的完整调用栈。速度损失约 2 倍,测试与调试必须全程开启(发布版再去掉)。

Valgrind(memcheck)——不重编译的经典

valgrind --leak-check=full --show-leak-kinds=all ./prog

无需重编译,跑一遍即得:非法读写(精确到行)、未初始化值使用、泄漏明细("definitely lost / indirectly lost")。代价是程序慢 10~50 倍,适合中小测试集;Windows 上需 WSL 或替代品(Dr. Memory)。

简单泄漏计数(自制工具思路)

把 malloc/free 包一层宏或函数,统计当前存活块数,程序退出时非零即有泄漏。第 19 章单元测试框架可接入此钩子,每个测试结束后自检"借了要还"。

工具之外的习惯:新代码提交前跑一遍 ASan 测试集,等于把四大病挡在门外;老代码报障时第一反应 Valgrind/ASan 复现,而不是"看代码猜"。


8.6 内存管理设计模式(工程视角)

  1. 构造/析构成对:模块提供 xxx_create/xxx_free 或 xxx_init/xxx_destroy,资源生命周期跟着对象走(第 17 章)。
  2. 谁分配谁释放:跨模块传递指针时在文档写明所有权("调用方拥有" vs "被调方接管")。
  3. 避免所有权纠缠:同一块内存不被两个模块自由读写/释放;共享访问用"借用"(borrow)语义 + 生命周期约定。
  4. 大对象早规划:预估大小的一次性分配(realloc 少搬家);流式处理用固定缓冲循环,不把整个文件读进内存。
  5. 失败路径先写:写 malloc 的同时就写好失败分支与清理路径,事后补是泄漏之源。

8.7 调试方法:内存问题排查路线图

程序崩溃/数据错乱
    │
    ├─ 能稳定复现? ── ASan 重编译运行 → 直接给出案发行号与分配栈
    │
    ├─ 难复现(偶发)? ── 怀疑未初始化/竞态:
    │        Valgrind(未初始化读)+ UBSan;多线程看第 14 章 TSan
    │
    └─ 内存持续上涨? ── Valgrind --leak-check 找 definitely lost
             检查:提前 return 路径 / realloc 覆盖 / 容器删除逻辑

GDB 辅助:info proc mappings(看内存布局)、x/16xw addr(查看原始内存)、watchpoint(watch *p,"谁改了这块内存"的终极武器——变量被神秘修改时,对它设硬件观察点,GDB 在写入瞬间停下并给出调用栈)。

watchpoint 实战:

(gdb) break main
(gdb) run
(gdb) watch g_config          # g_config 被改写时停下
(gdb) continue
Hardware watchpoint 2: g_config    # 每次改写:旧值→新值,栈帧一目了然

8.8 小结

  • 五段布局:栈(自动、小、快)、堆(手动、大、慢)、Data/BSS(静态)、Text。
  • malloc 检查 NULL;字节数 n * sizeof(T);realloc 用临时变量;free 后置 NULL。
  • 四大病:泄漏、悬垂、越界、重复释放——ASan/Valgrind 当场定罪,测试期全开。
  • 所有权原则:谁分配谁释放,接口写明;构造析构成对,失败路径与分配同步写。
  • 动态数组 = data/len/cap + 倍增扩容,是后续一切容器的地基。

思考与练习

  1. 编写程序故意触发四种内存错误(各一段),分别用 ASan 与 Valgrind 运行,收集两者的报错格式并对比可读性。
  2. 给 8.4 的 int_vec 增加 vec_pop、vec_get(v, i, int *out)(越界返回 -1)与"删除指定值的所有元素"功能,用 ASan 测试边界。
  3. 实现二维动态数组(指针数组版与一维展开版),各自写出正确的释放函数;故意写错释放顺序,观察 Valgrind 报告。
  4. 写一个 mem_trace 小工具:#define MALLOC(n) dbg_malloc(n, __FILE__, __LINE__) 记录每次分配,退出时打印未释放清单。
  5. 用 GDB watchpoint 找出:程序中全局变量 g_state 被谁改成了 -1(提示:先造一个这样的程序,再现场抓捕)。

上一章:指针 | 下一章:结构体、联合体与枚举 →

📄 第 09 章 结构体、联合体与枚举 09-结构体-联合体与枚举.md

学习目标:掌握结构体的定义、初始化、传参与嵌套;理解内存对齐与 padding,会计算并优化结构体大小;掌握位域、联合体、枚举的用途与陷阱;熟练使用 typedef 与结构体指针访问(->)。


9.1 结构体:把相关数据打包

C 的基本类型只能表达"一个数"。现实对象(学生、坐标、网络包)由多个字段组成——结构体(struct)把它们捆成一个新类型:

struct student {
    char   name[32];
    int    age;
    double gpa;
};

struct student s1;                       /* 定义变量(此时未初始化!局部变量是垃圾值) */
struct student s2 = {"Alice", 20, 3.8};  /* 按顺序初始化 */
struct student s3 = {.age = 20, .name = "Bob"};   /* 指定初始化器(C99):可乱序、可省略 */
struct student s4 = s2;                  /* 整体拷贝:所有字段逐字节复制(数组字段也能拷!) */

s1.age = 19;                             /* 访问成员:点号 */
printf("%s\n", s2.name);

typedef 让类型名更顺手:

typedef struct {
    double x;
    double y;
} point_t;                /* 之后直接写 point_t p; */

/* 或保留标签名+typedef(自引用结构必须先有标签名,见下) */
typedef struct node node_t;
struct node {
    int    value;
    node_t *next;         /* 指向同类型:必须用带标签的声明 */
};

结构体的赋值、传参与返回

结构体是值类型:赋值是整体拷贝,传参也是拷贝(第 5 章规则同样适用):

void move_by_value(point_t p) { p.x = 999; }     /* 改的是副本 */
void move_in_place(point_t *p) { p->x = 999; }   /* ✅ 传指针才修改原对象 */

工程惯例:小结构体(≤16~32 字节)可值传递;一般情况传指针,只读加 const(const point_t *p),成员访问用箭头:

void print_point(const point_t *p)
{
    printf("(%.2f, %.2f)\n", p->x, p->y);   /* p->x 是 (*p).x 的语法糖 */
}

❌ 经典错误:解引用空/野结构体指针。p->x 等价 (*p).x,p 无效即 UB。s->member 与 s.member 混用错对象(s 是指针却用 .)是编译错误,反过来(s 是结构体却用 ->)也是。

嵌套与自引用

typedef struct {
    point_t pos;          /* 嵌套:完整类型成员,直接内含 */
    char    name[16];
} entity_t;

entity_t e = { {1.0, 2.0}, "player" };   /* 嵌套花括号初始化 */
printf("%.1f\n", e.pos.y);               /* 逐层点开 */

结构体不能包含自己类型的完整成员(大小无穷递归),但可以包含自己的指针——链表、树的基石(第 12 章):

struct node { int value; struct node *next; };   /* ✅ 指针大小固定,合法 */

9.2 内存对齐与 padding:结构体多大?

对齐规则

CPU 访问内存偏爱"整齐"的地址(如 4 字节 int 期望地址是 4 的倍数),编译器因此在成员之间插入填充字节(padding)。规则:

  1. 每个成员的地址必须是其"对齐要求"的整数倍(通常对齐要求 = 成员大小,double 在 32 位平台常为 4 或 8);
  2. 结构体总大小必须是最大成员对齐要求的整数倍(尾部 padding)。
struct bad {
    char  c;      /* 偏移 0,占 1 字节 */
                  /* 3 字节 padding(让 int 落在 4 的倍数) */
    int   i;      /* 偏移 4,占 4 字节 */
    char  c2;     /* 偏移 8,占 1 字节 */
                  /* 3 字节尾部 padding(总大小凑成 4 的倍数) */
};                /* sizeof = 12,而不是 6! */

成员排序影响大小——把大成员放前面:

struct good {
    int   i;      /* 偏移 0~3 */
    char  c;      /* 偏移 4 */
    char  c2;     /* 偏移 5 */
                  /* 2 字节尾部 padding */
};                /* sizeof = 8:同样的成员,省了 1/3 */

经验法则:按成员大小从大到小排列,可显著减小体积——千万级对象数组里每省 8 字节就是省百 MB 内存(第 20 章性能篇呼应)。sizeof + offsetof(<stddef.h>)可实测每个成员的偏移,对齐解析不要靠猜:

#include <stddef.h>
printf("%zu %zu %zu %zu\n",
       offsetof(struct bad, c), offsetof(struct bad, i),
       offsetof(struct bad, c2), sizeof(struct bad));   /* 0 4 8 12 */

对齐的三个进阶话题

1. 手动控制对齐:_Alignas(C11)可提高对齐;__attribute__((packed))(GCC/Clang 扩展)取消 padding——packed 结构体的成员地址可能不对齐,取其成员指针跨平台有风险,仅用于文件格式/协议解析等特殊场景。

2. 跨平台二进制兼容:不同编译器/平台的对齐规则可能不同,直接把结构体整体 fwrite 到文件/网络,在另一平台读回可能错位(第 21 章展开:序列化应逐字段处理)。

3. 柔性数组(flexible array member,C99):结构体末尾放一个不完整数组,实现"头 + 变长数据"的单块分配:

typedef struct {
    size_t len;
    char   data[];        /* 柔性数组:不占 sizeof,跟在头后面 */
} buffer_t;

buffer_t *b = malloc(sizeof(buffer_t) + 64);   /* 头 + 64 字节数据一次分配 */
b->len = 64;
memcpy(b->data, src, 64);
free(b);                /* 一次释放 */

相比"结构体里放 char * 再分配两块",柔性数组少一次分配/释放、缓存更友好。


9.3 位域:按比特分字段

协议头、硬件寄存器里常有"某 3 位是类型、某 1 位是标志"。位域(bit field)让编译器替你切割:

typedef struct {
    unsigned int type   : 3;    /* 3 位:0~7 */
    unsigned int urgent : 1;    /* 1 位 */
    unsigned int seq    : 12;   /* 12 位 */
} flags_t;

flags_t f = {0};
f.type = 5;
f.urgent = 1;
printf("%zu\n", sizeof(f));   /* 通常 4(打包进一个 unsigned int) */

约束与注意:位域类型必须是有/无符号整型;跨"存储单元"的位域如何摆放由实现定义——位域布局不要直接写进网络协议/文件格式(跨平台不可靠,第 21 章;协议解析用移位+掩码手工处理,第 3 章)。位域也不可取地址。


9.4 联合体:同一内存多种解释

联合体(union)所有成员共享同一段内存,大小 = 最大成员:

union value {
    int    i;
    float  f;
    char   bytes[4];
};                       /* sizeof = 4 */

union value v;
v.i = 0x41424344;        /* 写入 i */
printf("%c%c\n", v.bytes[0], v.bytes[1]);   /* 按另一成员读:小端机上是 'D''C' */

关键特性与陷阱:

  • 一次只能"激活"一个成员:写 f 后读 i 在技术上依赖实现(C11 起多数编译器按"类型双关"支持),但跨端读别的成员本质上依赖字节序——上面的演示在小端/大端机器结果不同(第 21 章)。
  • sizeof(union) = max(sizeof(成员)),对齐取成员最大者。
  • 经典应用:带标签的变体(tagged union)——C 里表达"这个值要么是 int、要么是字符串、要么是错误"的标准手法:
typedef enum { V_INT, V_STR, V_ERR } value_kind;

typedef struct {
    value_kind kind;      /* 标签:当前哪个成员有效 */
    union {
        long    i;
        const char *s;
    } as;
} json_value_t;

json_value_t v = { .kind = V_INT, .as.i = 42 };
if (v.kind == V_INT)          /* 使用前必须查标签! */
    printf("%ld\n", v.as.i);

脚本引擎、配置解析、协议消息这类"多种可能形态"的数据,工程上都用 tagged union。联合体常见于嵌入式内存优化:协议缓冲区只需一块内存,按包头决定按什么结构解释。


9.5 枚举:有名字的整数常量

enum color { RED, GREEN, BLUE };          /* RED=0, GREEN=1, BLUE=2 自动编号 */
enum status { OK = 0, NOT_FOUND = 404, SERVER_ERROR = 500 };  /* 可指定,后续自动+1 */

enum color c = GREEN;
printf("%d\n", c);        /* 1:枚举本质是 int */

/* 工程惯例:错误码、状态机状态、命令字都用枚举,告别魔法数字 */
typedef enum {
    ERR_OK = 0,
    ERR_NOMEM,        /* -1 自动顺延?注意默认从 0 顺延,别混着正负 */
    ERR_INVALID,
    ERR_TIMEOUT
} err_t;

注意点:

  1. 枚举成员是 int 常量,作用域是文件级——两个枚举里不能有同名成员(这是 C 枚举的著名局限,工程上用前缀规避开:COLOR_RED / FRUIT_RED)。
  2. switch 全覆盖:switch 枚举时把所有成员列出,配合 GCC 的 -Wswitch(漏成员会警告);不要依赖"枚举连续"做数组下标除非显式赋值。
  3. C23 起可给枚举指定底层类型(enum e : uint8_t);C 之前的枚举大小由实现决定(通常是 int)。

9.6 结构体实践:一个完整的模块数据设计

综合本章 + 前两章,设计一个"任务队列"的数据骨架(第 12 章会补全行为):

#include <stddef.h>
#include <stdint.h>

typedef enum {
    TASK_PENDING,
    TASK_RUNNING,
    TASK_DONE,
    TASK_FAILED
} task_state_t;

typedef struct task {
    uint32_t        id;
    char           *name;          /* 堆分配的字符串:结构体持有它 */
    task_state_t    state;
    int             retry;
    struct task    *next;          /* 链表串联 */
} task_t;

typedef struct {
    task_t *head;
    task_t *tail;
    size_t  count;
} task_queue_t;

/* 创建与销毁成对:资源跟着对象走(第 8 章所有权原则) */
task_t *task_create(uint32_t id, const char *name)
{
    task_t *t = calloc(1, sizeof(task_t));
    if (t == NULL)
        return NULL;
    t->name = strdup(name);      /* POSIX;可移植版:malloc+memcpy(第 6 章) */
    if (t->name == NULL) {
        free(t);
        return NULL;
    }
    t->id = id;
    t->state = TASK_PENDING;
    return t;
}

void task_destroy(task_t *t)
{
    if (t == NULL)
        return;
    free(t->name);               /* 先释放成员资源 */
    free(t);                     /* 再释放结构体本身 */
}

设计要点:calloc 清零初始化(所有字段确定);字符串指针成员必须深拷贝(strdup),否则两个结构体共享同一字符串、释放时 double free;析构函数处理 NULL;枚举表达状态机。


9.7 调试方法:结构体类 Bug 怎么查

症状 排查方向
结构体成员值莫名错位 序列化/网络收发时两端对齐不一致;offsetof 实测两端偏移对比;协议改用逐字段序列化
sizeof 与预期不符 画内存图算 padding;-fpack-struct/packed 验证;检查成员顺序
字符串成员打印乱码/崩溃 浅拷贝共享指针、双重释放;p->name 指向已释放内存(ASan 报 use-after-free)
tagged union 读出垃圾 忘查标签或标签越界;在赋值处打印 kind 追踪
位域值不对 位域跨存储单元的实现定义;改用显式移位掩码并打印中间值

GDB 结构体三板斧:

print s          # 打印整个结构体所有成员
print s.name     # 取成员
ptype task_t     # 打印类型定义(成员布局一目了然)
print sizeof(s)  # 实际大小

把 print s 的输出与手画内存图对照,是理解 padding 最快的方式。


9.8 小结

  • 结构体 = 相关字段的值类型;赋值/传参整体拷贝,修改用指针 ->。
  • 对齐决定大小:成员按大到小排;offsetof 实测;序列化不直接整块搬运。
  • 位域省空间但布局实现定义,别进协议文件;协议解析用移位掩码。
  • union 共享内存:tagged union 是 C 表达"多形态"的标准手法,先查标签再用。
  • 枚举消灭魔法数字;成员作用域文件级,命名加前缀。
  • 自引用指针是链表树的基础;含指针成员的类型要配 create/destroy,深拷贝。

思考与练习

  1. 写程序用 sizeof 与 offsetof 验证 struct {char a; int b; char c;} 与重排成员后的两种大小,画出内存图。
  2. 实现二维平面向量的 point_t:add/sub/dot 三个函数(值传递与指针传递各写一版,比较签名可读性)。
  3. 用 tagged union 实现一个 shape_t(圆/矩形/三角形),提供 double shape_area(const shape_t *s),switch 全覆盖。
  4. 用位运算(不用位域)解析 IPv4 报文头的前 4 字节:版本(高 4 位)与首部长度(低 4 位)。
  5. 给 9.6 的 task_t 写 task_clone(深拷贝:含 name 字符串),并用 ASan 验证"克隆体销毁后原件仍完好"。

上一章:内存管理 | 下一章:文件操作 →

📄 第 10 章 文件操作 10-文件操作.md

学习目标:掌握 C 标准库文件 I/O 全流程(打开/读写/关闭);理解文本模式与二进制模式的差异及跨平台影响;掌握顺序读写与随机访问;理解缓冲机制;掌握文件操作的错误检测与处理。


10.1 FILE 指针与标准库 I/O 模型

C 通过 FILE*(<stdio.h>)操作文件。FILE 是库内部的流对象,记录缓冲区、当前位置、错误标志等。流(stream) 的心智模型:文件被抽象成一条可以顺序读写的字节河,内部有"当前位置"游标。

程序启动时自动打开三个流,无需你管理:

流 用途 默认连接
stdin 标准输入 键盘
stdout 标准输出 屏幕
stderr 标准错误 屏幕(无缓冲,报错专用)

之前用的 printf(...) 就是 fprintf(stdout, ...) 的简写。报错为什么用 stderr:用户可能把 stdout 重定向到文件(./prog > out.txt),错误信息混进数据文件就是事故;stderr 独立通行。


10.2 打开与关闭:fopen / fclose

FILE *fp = fopen("data.txt", "r");    /* 模式:读 */
if (fp == NULL) {                      /* 失败返回 NULL,必须检查 */
    perror("fopen data.txt");          /* perror:打印 errno 对应的错误描述(第 16 章) */
    return 1;
}
/* ... 使用 ... */
fclose(fp);                            /* 用完关闭:冲刷缓冲区、释放资源 */

打开模式速查:

模式 含义 文件不存在 文件已存在
"r" 只读 fopen 失败 从头读
"w" 只写 创建 清空!(最高频事故源)
"a" 追加 创建 写入总在末尾
"r+" 读写(不创建) 失败 从头开始
"w+" 读写(创建/清空) 创建 清空
"a+" 读+追加写 创建 写在末尾

以上每种都可加 b 变成二进制模式:"rb"、"wb+"。

❌ 事故模板:日志程序每天清空自己的日志

FILE *log = fopen("app.log", "w");   /* ❌ 每次启动全灭 */
FILE *log = fopen("app.log", "a");   /* ✅ 追加 */

fclose 也要检查(写文件时):fclose 会冲刷缓冲区,磁盘满时 fclose 返回 EOF——只查 fwrite 不查 fclose,"写入成功"可能是假象。写文件的完整检查:

if (fwrite(data, 1, n, fp) != n || fclose(fp) != 0) {
    fprintf(stderr, "write failed\n");   /* fp 已无效,不再 fclose */
    fp = NULL;
}

10.3 文本模式 vs 二进制模式

文本模式("r"/"w"):库会做换行符转换。Windows 的换行是 \r\n,Unix 是 \n:

  • Windows 上文本模式读:\r\n → \n;写:\n → \r\n。
  • Unix 上文本/二进制模式行为相同(无转换)。

二进制模式("rb"/"wb"):字节原样进出,无任何转换。

跨平台规则(第 21 章呼应):

  1. 处理人读的文本 → 文本模式,让库帮你抹平平台差异;
  2. 处理结构化数据(结构体、图片、自定义格式) → 必须二进制模式;
  3. Unix 惯例:一切皆字节,很多 C 工程在所有平台都用 "rb"/"wb" 并自己统一换行,行为最可控。

❌ 陷阱:Windows 文本模式下 ftell/fseek 的偏移不可靠(换行转换改变了字节数)。需要对字节精确定位时用二进制模式。


10.4 顺序读写

字符与行

int c;                                  /* 注意:是 int!fgetc 返回 int 以容纳 EOF */
while ((c = fgetc(fp)) != EOF)          /* 逐字符读的经典循环 */
    putchar(c);

char line[256];
while (fgets(line, sizeof(line), fp) != NULL)   /* 逐行读 */
    fputs(line, stdout);                /* line 自带换行符 */

fputc('A', fp);                         /* 写字符 */
fputs("hello\n", fp);                   /* 写字符串 */

❌ 经典错误:用 char 接 fgetc

char c;
while ((c = fgetc(fp)) != EOF) { }   /* ❌ EOF 是 -1;char 可能无符号,-1 与 0xFF 混淆 → 死循环 */
int c;                                /* ✅ int 能同时容纳 0~255 和 EOF(-1) */

❌ 经典错误:feof 当循环条件

while (!feof(fp)) {            /* ❌ feof 只在"已读失败后"才置位:会多处理最后一次 */
    fgets(line, sizeof(line), fp);
    process(line);             /* 最后一行会被处理两次或处理一条空数据 */
}

while (fgets(line, sizeof(line), fp) != NULL) {  /* ✅ 以"读成功"为条件 */
    process(line);
}

feof 的正确定位:读循环结束后用它区分"正常结束(EOF)"还是"读错误(ferror)",而不是当循环条件。

格式化读写

fprintf(fp, "%s,%d,%.2f\n", name, age, gpa);    /* 写:与 printf 同参数 */
fscanf(fp, "%63[^,],%d,%lf\n", name, &age, &gpa); /* 读:%[^,] 读到逗号为止(限宽!) */

CSV 解析用 fscanf 能应付简单场景;字段可能为空/含逗号时要用 fgets + strtok/手动分割。

二进制读写:fread / fwrite

typedef struct {
    uint32_t id;
    double   score;
} record_t;

record_t rec = {1, 95.5};

/* 写:整块字节 */
if (fwrite(&rec, sizeof(rec), 1, fp) != 1) { /* 失败处理 */ }

/* 读:整块字节 */
record_t in;
if (fread(&in, sizeof(rec), 1, fp) != 1) {
    /* 返回的"完整块数"小于请求数:文件不足或错误 */
}

fwrite(&rec, sizeof(rec), 1, fp) 把结构体的原始内存字节写入。⚠️ 这么做的隐患(第 9、21 章伏笔):padding 与字节序都是平台相关的——这种文件只适合本机临时存储,跨平台交换必须定义"逐字段序列化 + 网络字节序"。

fread 返回成功读取的完整项数(不是字节)。


10.5 随机访问:fseek / ftell / rewind

long pos = ftell(fp);            /* 当前位置(距文件头的字节偏移) */

fseek(fp, 0, SEEK_END);          /* 定位到末尾 —— 文件大小的经典测法 */
long size = ftell(fp);
rewind(fp);                      /* 回到开头(等价 fseek(fp, 0, SEEK_SET)) */

fseek(fp, 5L, SEEK_SET);         /* 绝对定位:距开头 5 字节 */
fseek(fp, -4L, SEEK_CUR);        /* 相对定位:从当前位置回退 4 字节 */

三基点:SEEK_SET(文件头)、SEEK_CUR(当前位置)、SEEK_END(文件尾,偏移可为负)。

应用:定长记录的随机访问——二进制模式 + 定长结构体是数据库引擎的原型:

/* 读写第 idx 条记录(定长),O(1) 跳转 */
void read_record(FILE *fp, size_t idx, record_t *out)
{
    fseek(fp, (long)(idx * sizeof(record_t)), SEEK_SET);
    if (fread(out, sizeof(record_t), 1, fp) != 1) { /* 失败处理 */ }
}

测文件大小的注意事项:fseek(fp, 0, SEEK_END) + ftell 对大文件(>2GB)要用 long long 版本(POSIX ftello/fseeko,或 C11 前 _ftelli64 等平台变体,第 21 章);且这是"技巧"而非"保证",工程上 stat() 系函数更可靠。


10.6 缓冲:看不见的中间层

标准库默认对文件 I/O 做缓冲:fwrite 通常先写进内存缓冲区,攒够(典型 4KB~8KB)或 fclose/fflush 时才真正落盘。

fflush(fp);                       /* 手动冲刷:立即把缓冲区写入 */
setvbuf(fp, NULL, _IONBF, 0);     /* 无缓冲(调试实时输出用) */
setvbuf(fp, NULL, _IOFBF, 65536); /* 全缓冲 64KB(大批量顺序读写提速) */
setvbuf(fp, NULL, _IOLBF, 0);     /* 行缓冲:遇 \n 冲刷(交互日志) */

三个实际问题:

  1. 调试 printf 看不见输出:stdout 连到终端是行缓冲、重定向到文件/管道变全缓冲——程序崩溃时缓冲区丢失,"打印了却没输出"。解决办法:关键日志用 stderr 或每次 fflush。
  2. 崩溃丢数据:写完就崩,缓冲区没落盘。重要数据写后 fflush(甚至 fsync,平台 API)。
  3. 多进程/多线程写同一文件:缓冲错乱、内容交错(第 14 章线程安全)。

10.7 错误检测

if (ferror(fp)) { ... }    /* 流上发生过读写错误 */
if (feof(fp))    { ... }   /* 已到文件尾(且是读过失败后) */
clearerr(fp);              /* 清除错误/EOF 标志 */

fopen 失败时的诊断组合拳:

FILE *fp = fopen(path, "r");
if (fp == NULL) {
    fprintf(stderr, "cannot open %s: %s\n", path, strerror(errno));  /* <string.h>, <errno.h> */
    return -1;
}

errno 机制详见第 16 章。


10.8 综合示例:学生记录的写读回环

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

typedef struct {
    char   name[32];          /* 定长字段:二进制文件友好 */
    uint32_t id;
    double gpa;
} student_t;

static int write_students(const char *path, const student_t *arr, size_t n)
{
    FILE *fp = fopen(path, "wb");            /* 二进制模式:字节原样 */
    if (fp == NULL) {
        fprintf(stderr, "open %s for write: %s\n", path, strerror(errno));
        return -1;
    }
    size_t written = fwrite(arr, sizeof(student_t), n, fp);
    int fail = (written != n) || (fclose(fp) != 0);   /* fclose 也要查 */
    if (fail)
        fprintf(stderr, "write %s failed (%zu/%zu records)\n", path, written, n);
    return fail ? -1 : 0;
}

static int read_students(const char *path)
{
    FILE *fp = fopen(path, "rb");
    if (fp == NULL) {
        fprintf(stderr, "open %s: %s\n", path, strerror(errno));
        return -1;
    }

    student_t s;
    size_t count = 0;
    while (fread(&s, sizeof(s), 1, fp) == 1) {       /* 以读成功为条件 */
        printf("%-12s id=%u gpa=%.2f\n", s.name, s.id, s.gpa);
        count++;
    }
    if (ferror(fp))                                   /* 区分 EOF 与错误 */
        fprintf(stderr, "read error at record %zu\n", count);
    fclose(fp);
    return 0;
}

int main(void)
{
    student_t students[] = {
        {"Alice",   1001, 3.85},
        {"Bob",     1002, 3.42},
        {"Charlie", 1003, 3.99},
    };
    size_t n = sizeof(students) / sizeof(students[0]);

    if (write_students("students.bin", students, n) != 0)
        return 1;
    return read_students("students.bin") != 0;
}

10.9 调试方法:文件 I/O Bug 怎么查

症状 排查方向
fopen 失败 路径对不对(相对路径基于工作目录不是程序目录);权限;文件名拼写。perror/strerror(errno) 给出原因(No such file / Permission denied)
读到的内容"乱" 模式选错(二进制数据用文本模式被换行转换);文本文件被按结构体解释;忘了 \0
文件内容丢失/为空 "w" 清空了旧文件;忘了 fclose/fflush 程序就崩了;写失败未检查
读循环多跑/少跑一轮 feof 当循环条件;fread 返回值没用;最后一行无换行符时 fscanf 的行为
偏移定位错乱 文本模式下 ftell/fseek;文件大于 long 范围
输出"看不见" 缓冲:重定向下 stdout 全缓冲;崩溃丢缓冲区;改 stderr 或 fflush

调试手段:

  • 对照字节:xxd file.bin | head(Linux)或 Format-Hex(PowerShell)直接看文件原始字节,与代码预期逐字节核对——I/O 调试的"显微镜"。
  • 最小复现:把出问题的读写逻辑抽成 20 行的小程序独立运行,排除主程序干扰。
  • strace(Linux):strace -e trace=file ./prog 列出全部真实文件系统调用(打开路径、返回码),看代码层"以为的"与系统层"实际发生的"差异。

10.10 小结

  • fopen 模式决定一切:w 清空、a 追加、b 不转换;fopen/fclose 都要检查。
  • fgetc 用 int 接;读循环条件是"读成功",feof 只用于事后区分。
  • fread/fwrite 处理整块字节;结构体整体读写仅限本机(padding+字节序平台相关)。
  • fseek/ftell 实现随机访问;定长记录 + 二进制 = 简易数据库。
  • 缓冲是隐形层:崩溃丢数据、重定向改变行为;关键处 fflush。
  • 诊断三件套:strerror(errno)、ferror、xxd 看原始字节。

思考与练习

  1. 写程序统计一个文本文件的行数、单词数、字符数(简化版 wc)。
  2. 把 10.8 的学生文件扩展成"追加记录 + 按学号查找(fseek 随机访问)+ 修改某条记录"的小型记录管理器。
  3. 对比实验:同一程序分别用 "w" 与 "wb" 在 Windows 写 "a\nb\n",用 xxd/Format-Hex 查看文件字节差异。
  4. 故意在 fwrite 后不 fclose 直接 return,观察文件内容是否完整;加上 fclose 再对比。再用 ASan 跑一遍看资源泄漏报告。
  5. 用 setvbuf 关闭 stdout 缓冲,验证"崩溃前打印的日志不再丢失"。

上一章:结构体、联合体与枚举 | 下一章:预处理器与多文件工程 →

📄 第 11 章 预处理器与多文件工程 11-预处理器与多文件工程.md

学习目标:掌握预处理器四大能力(包含、宏、条件编译、预置宏);理解翻译单元、声明/定义、链接属性(internal/external);掌握头文件设计规范与头文件守卫;能正确组织多文件工程并理解各链接错误的成因。


11.1 预处理器是什么

在编译器真正分析代码之前,预处理器(preprocessor) 先对源文本做机械的文本变换。它不懂 C 语法,只认以 # 开头的指令。四大能力:

  1. 文件包含:#include
  2. 宏:#define(对象宏与函数式宏)
  3. 条件编译:#if / #ifdef / #endif
  4. 预置宏与特殊指令:__FILE__、#error、#pragma 等

理解"预处理只是文本替换"是避开一切宏陷阱的钥匙。


11.2 #include 与头文件守卫

两种包含形式

#include <stdio.h>     /* 尖括号:到系统头文件目录找(编译器路径) */
#include "mylib.h"     /* 双引号:先到当前目录找,找不到再去系统路径 */

#include 就是原样粘贴。stdio.h 展开后有几万行——用 gcc -E hello.c | wc -l 亲眼看看。

多文件工程的基本结构

project/
├── include/            ← 对外头文件
│   └── stack.h
├── src/
│   ├── stack.c
│   └── main.c
└── Makefile            ← 第 18 章展开
/* stack.h —— 接口(声明) */
#ifndef STACK_H
#define STACK_H            /* 头文件守卫:防止重复包含 */

#include <stddef.h>

typedef struct stack stack_t;   /* 不透明类型(见 11.5) */

stack_t *stack_create(size_t capacity);
void     stack_destroy(stack_t *s);
int      stack_push(stack_t *s, int value);
int      stack_pop(stack_t *s, int *out);

#endif /* STACK_H */
/* stack.c —— 实现(定义) */
#include "stack.h"
#include <stdlib.h>

struct stack {              /* 内部结构只在本 .c 定义:外部看不到细节 */
    int   *data;
    size_t top;
    size_t cap;
};
/* ... create/destroy/push/pop 的实现 ... */
/* main.c —— 使用者 */
#include "stack.h"
#include <stdio.h>

int main(void)
{
    stack_t *s = stack_create(16);
    if (s == NULL)
        return 1;
    stack_push(s, 42);
    int v;
    if (stack_pop(s, &v) == 0)
        printf("%d\n", v);
    stack_destroy(s);
    return 0;
}

编译链接:gcc -Iinclude src/main.c src/stack.c -o app。.c 各自独立编译成 .o,再由链接器缝合——第 1 章的四阶段在此落地。

头文件守卫:为什么必须有

stack.h 被 a.c 和 b.c 各包含一次没问题(分开编译);但 a.h 包含 stack.h,main.c 又同时包含 a.h 和 stack.h——同一头文件在一个翻译单元里被粘贴两次,重复定义,编译报错。守卫用宏挡住第二次:

#ifndef STACK_H        /* 第一次包含:STACK_H 未定义 → 定义它,内容生效 */
#define STACK_H
/* ... 内容 ... */
#endif                 /* 第二次包含:STACK_H 已定义 → 整段跳过 */

守卫宏名必须全局唯一(工程惯例 项目_路径_文件名_H)。GCC/Clang 还支持 #pragma once(更简洁,几乎所有编译器支持,但非 ISO 标准),两者选一即可,开源项目两种都常见。

头文件设计原则(工程铁律)

  1. 头文件放声明,源文件放定义。头文件里出现"定义"(函数体、变量实体)是链接错误与维护灾难之源。例外:static inline 小函数、类型定义、宏。
  2. 头文件自给自足:只依赖自己需要的头(使用者 include 你之后不该还得再 include 别的才能编译)。
  3. 头文件不 include 不需要的东西:拖慢编译、制造依赖网。能用前向声明(struct foo;)就不用 include。
  4. 头文件保持幂等:任何包含顺序、重复包含都能正确编译(守卫保证)。
  5. 一个模块一对文件(foo.h + foo.c),头文件即模块文档。

11.3 宏:文本替换的力量与危险

对象宏与函数式宏

#define MAX_USERS  1000               /* 对象宏:文本替换 MAX_USERS → 1000 */
#define PI         3.14159265358979
#define ARRAY_SIZE(a)  (sizeof(a) / sizeof((a)[0]))   /* 函数式宏 */

int users[MAX_USERS];
for (int i = 0; i < ARRAY_SIZE(users); i++) { ... }

❌ 宏四大经典陷阱

陷阱一:参数不加括号——运算符优先级灾难

#define SQ(x) x * x
int r = SQ(2 + 3);        /* 展开为 2 + 3 * 2 + 3 = 11,不是 25! */
#define SQ(x) ((x) * (x))  /* ✅ 参数与整体都加括号 */

陷阱二:参数多次求值——副作用翻倍

#define MAX(a, b) ((a) > (b) ? (a) : (b))
int m = MAX(i++, j);      /* ❌ i++ 可能被执行两次! */

函数不会,宏会。含副作用的实参传给宏是 UB 温床。带函数语义的宏,参数名要唯一化防护(GCC 扩展 __auto_type/语句表达式,或干脆用函数)。

陷阱三:多语句宏——if-else 支离

#define SWAP(a, b)  { int t = (a); (a) = (b); (b) = t; }

if (cond)
    SWAP(x, y);           /* ❌ 分号多事:else 悬空,编译错误 */
else ...
#define SWAP(a, b)  do { int t = (a); (a) = (b); (b) = t; } while (0)  /* ✅ 标准解法 */

do { } while (0) 包装让宏在任何语境都表现为"一条语句"——读开源 C 代码必遇的惯用法。

陷阱四:宏不是作用域。#define 从定义行起生效到文件尾(或 #undef),无视块作用域,可能悄悄改掉不相干代码里的标识符。宏名全部大写是自我保护(提醒读者"这是宏")。

宏的正当用途

上面尽是丑话,但宏有不可替代的场景:

  1. 条件编译开关(下一节);
  2. 编译期常量与防重复代码的简单展开(ARRAY_SIZE 无法用函数实现——需要类型/大小信息);
  3. 字符串化与拼接(日志、断言的实现基础):
#define STR_(x)  #x          /* # 把参数变成字符串字面量 */
#define STR(x)   STR_(x)     /* 两层:让宏参数先展开再字符串化 */
#define CONCAT_(a, b) a##b   /* ## 把两个记号拼成一个 */
#define CONCAT(a, b)  CONCAT_(a, b)

printf("line " STR(__LINE__) "\n");   /* line 42 */
CONCAT(get_, value)();                 /* 展开为 get_value() */
  1. X-macro:一份列表生成枚举+字符串表,保证永不脱节(第 17 章错误码表用它):
#define ERROR_LIST(X) \
    X(ERR_OK,        "success")      \
    X(ERR_NOMEM,     "out of memory") \
    X(ERR_TIMEOUT,   "timed out")

#define ENUM_ITEM(id, msg)  id,
#define MSG_ITEM(id, msg)   [id] = msg,
typedef enum { ERROR_LIST(ENUM_ITEM) } err_t;
static const char *err_msg[] = { ERROR_LIST(MSG_ITEM) };
#undef ENUM_ITEM            /* 用完即 undef,不污染命名空间 */
#undef MSG_ITEM

选择原则:能用 const 变量、static inline 函数、枚举解决的,不用宏——它们有类型、有作用域、能进调试器。

预置宏

宏 含义
__FILE__、__LINE__ 当前文件名、行号(日志/断言的根基)
__func__(C99) 当前函数名
__DATE__、__TIME__ 编译时间
__STDC_VERSION__ 标准版本:199901L(C99)、201112L(C11)、201710L(C17)
__GNUC__、_MSC_VER、__clang__ 编译器识别(条件编译用)
__linux__、_WIN32、__APPLE__ 平台识别(第 21 章)

11.4 条件编译

/* 1. 调试开关 */
#ifdef DEBUG
    #define LOG(fmt, ...) fprintf(stderr, "[DEBUG] " fmt "\n", ##__VA_ARGS__)
#else
    #define LOG(fmt, ...) ((void)0)     /* 发布版:展开为空,零成本 */
#endif

/* 2. 平台差异 */
#if defined(_WIN32)
    #include <windows.h>
#elif defined(__linux__)
    #include <unistd.h>
#endif

/* 3. 版本/特性检测 */
#if __STDC_VERSION__ >= 201112L
    /* 可以用 C11 特性 */
#else
    /* 回退实现 */
#endif

/* 4. 防止重复包含与配置开关 */
#define ENABLE_FEATURE_X 1
#if ENABLE_FEATURE_X
    ...feature code...
#endif

注意:#if 的条件是预处理期常量表达式,不能用 sizeof(那是编译期不是预处理期);#ifdef 只看宏是否定义,不看值——#if defined(X) && X == 1 才是"定义且为 1"。

工程纪律:条件编译是可维护性杀手(每个分支都要测),能集中就集中——平台差异收敛到少数几个头文件/封装函数里(第 21 章的抽象层模式),业务代码保持无 #ifdef。


11.5 声明、定义与链接属性

声明 vs 定义(贯穿全书的概念,此处收束)

  • 声明:告诉编译器"这个名字存在,类型如此"——不产生实体。extern int g_count;、int add(int, int);
  • 定义:创建实体(分配存储/给出函数体)。int g_count = 0;、int add(int a, int b) { ... }
  • 变量可声明多次、定义一次;函数同理(函数默认声明即定义的一部分……准确说:无函数体的声明是原型,有体的是定义)。

链接属性:三个名字谁能被别的文件看见

例子 其他 .c 可见?
external(外部链接) 普通全局函数/变量 ✅ 可见(这正是跨文件协作机制)
internal(内部链接) static 修饰的全局函数/变量 ❌ 仅本翻译单元
none(无链接) 局部变量、函数参数 —(本来就不在链接范围)
/* util.c */
int  util_public_add(int a, int b) { return a + b; }     /* 对外服务 */
static int util_validate(int x) { return x >= 0; }       /* 内部细节:别的文件看不到 */
static int g_cache;                                       /* 内部全局状态 */

工程纪律:一切不被其他文件使用的函数与全局变量,一律 static。 收益:命名冲突归零、编译器内联优化更激进、接口面一目了然——static 是 C 的"private"。

extern:跨文件共享变量

/* config.c */
int g_verbose = 0;                    /* 定义(唯一一次) */

/* config.h */
extern int g_verbose;                 /* 声明:告诉别人它存在(不分配存储) */

/* main.c */
#include "config.h"
g_verbose = 1;                        /* 直接使用 */

工程建议:全局可写变量是模块耦合的大杀器,优先用访问函数(int config_verbose(void))把状态藏进 .c(配合 static),extern 只留给真正的常量表。❌ 头文件里定义变量(int g_x; 放头文件):每个包含它的 .c 各得一份定义,链接时多重定义报错(或更糟:C99 下的"临时定义"静默合并成错误行为)。

inline 的链接语义(进阶了解)

C99 的 inline 与 C++ 不同:inline 声明不产生外部定义,需要同文件再给一个 extern inline 或非 inline 的定义,否则链接错。简单可靠用法:头文件里 static inline(每个翻译单元一份,无链接问题,小函数够用)。


11.6 多文件工程编译与常见链接错误

gcc -Iinclude -c src/stack.c -o build/stack.o    /* 编译:只查本文件的语法/原型 */
gcc -Iinclude -c src/main.c  -o build/main.o
gcc build/main.o build/stack.o -o app             /* 链接:缝合符号 */
/* 或一步到位 */
gcc -Iinclude src/main.c src/stack.c -o app

三大链接错误与处方:

错误信息 原因 处方
undefined reference to 'foo' 有声明无定义;漏编译某 .c;漏链库(-lm);C++ 编译器编译了 C(名字修饰) 检查文件清单;nm xxx.o 查符号是否在;库加 -l
multiple definition of 'foo' 定义出现在头文件里,被多个 .c 包含 定义挪回 .c;头文件只留声明
undefined reference to 'main' 没有 main;编译命令里没有含 main 的文件 检查入口文件

隐式声明的坑(本章开头预告的收尾):调用了没原型匹配的函数,C89 时代按"返回 int"处理,链接居然成功但运行崩溃(64 位指针被截断)。-Wall 把 implicit declaration 升为错误(-Werror=implicit-function-declaration),C23 起直接违法。


11.7 调试方法:预处理与链接问题怎么查

  • 看预处理结果:gcc -E file.c -o file.i——宏展开了什么、头文件带来什么,一目了然。宏诡异地不工作时,第一反应就是看 .i。
  • 查符号表:nm build/stack.o 列出符号(T 定义、U 未决);链接错误时对照两边符号名是否一致(尤其 C/C++ 混编的 _foo vs foo@@...)。
  • 宏展开报错难读:GCC 加 -E 预处理或用 -fdirectives-only;Clang 的诊断对宏有展开上下文,读它。
  • 头文件守卫失效(宏名撞车):gcc -H file.c 会打印每个 include 的嵌套层级。
  • 条件编译分支验证:gcc -E -DDEBUG file.c | grep ... 验证某个开关下代码是否真的在场。

11.8 小结

  • 预处理是文本替换:#include 粘贴、宏展开、条件裁剪;看不懂就 gcc -E。
  • 头文件守卫防重复包含;头文件放声明、源文件放定义;头文件自给自足、最小依赖。
  • 宏四陷阱:括号、多次求值、多语句、无作用域;do{}while(0)、全大写、能不用就不用。
  • static 是 C 的 private:非导出一律 static;extern 只声明不定义;头文件里不定义变量。
  • 链接错误三型:undefined(缺实现/缺库)、multiple(头文件里定义了)、缺 main。
  • __FILE__/__LINE__/__func__ 支撑日志与断言;平台差异收敛到少数封装点。

思考与练习

  1. 把第 9 章的 task_queue 拆成 task.h/task.c/main.c 三文件工程,用 gcc 三步(-c/-c/链接)手动构建,用 nm 观察各 .o 的 T/U 符号。
  2. 写宏 MAX3(a,b,c) 返回三者最大值,测试传 i++ 会发生什么;改写成 static inline 函数版再测。
  3. 用 X-macro 定义一个命令表(命令枚举 + 名字字符串 + 处理函数指针),实现 main 里根据输入字符串分发,新增命令只改一处。
  4. 制造三种链接错误各一次(删掉某 .c、头文件里放定义、漏 -lm),记录报错原文,建立自己的"错误-处方"笔记。
  5. 用 gcc -E 观察 assert(x > 0) 在定义/不定义 NDEBUG 两种情况下的展开差异,理解标准库头是怎么用条件编译的。

上一章:文件操作 | 下一章:常用数据结构实现 →

📄 第 12 章 常用数据结构实现 12-常用数据结构实现.md

学习目标:用纯 C 手写动态数组、链表、栈、队列、哈希表与二叉搜索树;掌握每种结构的内存管理模型(谁分配、谁释放);理解各结构的时间复杂度与选型依据。本章代码可直接作为工程模板库使用。


12.0 选型速查表

结构 插入 删除 查找 特点与典型用途
动态数组 尾部均摊 O(1),中间 O(n) O(n) 下标 O(1) 默认首选;缓存友好(第 20 章)
单链表 头部 O(1) 知道前驱 O(1) O(n) 频繁头部增删、实现简单容器
双链表 两端 O(1) 知道节点 O(1) O(n) LRU 缓存、需要反向遍历
栈(数组实现) O(1) O(1) — 递归转迭代、括号匹配、DFS
队列(环形数组) O(1) O(1) — 任务队列、BFS、生产者消费者(第 14 章)
哈希表 O(1) 均摊 O(1) 均摊 O(1) 均摊 键值缓存、去重、符号表
二叉搜索树 O(log n) 均衡时 O(log n) O(log n) 有序遍历、范围查询

C 实现数据结构的核心挑战只有一个:内存所有权。下面每种实现都明确"create/destroy 配对、元素归谁管"。


12.1 动态数组(vector)

第 8 章的 int_vec 升级为通用版——用 void* + 元素大小实现"装任何类型":

/* vec.h */
#ifndef VEC_H
#define VEC_H
#include <stddef.h>

typedef struct {
    void   *data;        /* 元素存储区 */
    size_t  len;         /* 已用元素数 */
    size_t  cap;         /* 已分配槽位数 */
    size_t  elem_size;   /* 单个元素字节数 */
} vec_t;

int      vec_init(vec_t *v, size_t elem_size, size_t cap);
void     vec_free(vec_t *v);
int      vec_push(vec_t *v, const void *elem);        /* 深拷贝 elem 指向的内容 */
void    *vec_at(vec_t *v, size_t i);                  /* 返回第 i 个元素的地址,越界 NULL */
#endif
/* vec.c */
#include "vec.h"
#include <stdlib.h>
#include <string.h>

int vec_init(vec_t *v, size_t elem_size, size_t cap)
{
    if (elem_size == 0)
        return -1;
    v->data = malloc((cap ? cap : 8) * elem_size);
    if (v->data == NULL)
        return -1;
    v->len = 0;
    v->cap = cap ? cap : 8;
    v->elem_size = elem_size;
    return 0;
}

void vec_free(vec_t *v)
{
    free(v->data);
    v->data = NULL;
    v->len = v->cap = 0;
    v->elem_size = 0;
}

int vec_push(vec_t *v, const void *elem)
{
    if (v->len == v->cap) {
        size_t new_cap = v->cap * 2;
        void *tmp = realloc(v->data, new_cap * v->elem_size);
        if (tmp == NULL)
            return -1;                    /* realloc 失败原块仍有效(第 8 章) */
        v->data = tmp;
        v->cap = new_cap;
    }
    memcpy((char *)v->data + v->len * v->elem_size, elem, v->elem_size);
    v->len++;
    return 0;
}

void *vec_at(vec_t *v, size_t i)
{
    if (i >= v->len)
        return NULL;
    return (char *)v->data + i * v->elem_size;
}

使用(存 double 或任何类型):

vec_t v;
vec_init(&v, sizeof(double), 0);
double x = 3.14;
vec_push(&v, &x);                       /* 传地址,容器内部拷贝 */
double *px = vec_at(&v, 0);             /* 取回地址,只读或修改均可 */
vec_free(&v);

设计要点:容器拷贝元素的字节(memcpy),不管元素内部是否有指针——元素内部还持有堆资源时,删除/扩容不会帮你释放(文档写明:"容器不拥有元素的内部资源",或提供 elem_free 回调,仿造 free 挂钩子的做法)。


12.2 单链表

链表节点 = 数据 + 下一跳指针(第 9 章自引用结构):

typedef struct slist_node {
    int                   value;
    struct slist_node    *next;
} slist_node_t;

typedef struct {
    slist_node_t *head;
    size_t        count;
} slist_t;

核心操作(含最易错的"头部插入"与"删除"):

/* 头插:O(1) */
int slist_push_front(slist_t *l, int value)
{
    slist_node_t *n = malloc(sizeof(*n));
    if (n == NULL)
        return -1;
    n->value = value;
    n->next = l->head;      /* 新节点指向原头 */
    l->head = n;            /* 头指针改指新节点 */
    l->count++;
    return 0;
}

/* 删除第一个等于 value 的节点:C 链表操作的经典难点 */
int slist_remove(slist_t *l, int value)
{
    slist_node_t **pp = &l->head;          /* 指向"指针"的游标:巧妙点 */
    while (*pp != NULL) {
        if ((*pp)->value == value) {       /* *pp 就是"当前节点的 next 槽位" */
            slist_node_t *dead = *pp;
            *pp = dead->next;              /* 让前驱的 next 指向后继——两种情况统一 */
            free(dead);
            l->count--;
            return 0;
        }
        pp = &(*pp)->next;                 /* 游标前进 */
    }
    return -1;                             /* 没找到 */
}

二级指针删除法的价值:传统写法要单独处理"删的是头节点"(改 l->head)与"删的是中间节点"(改前驱 prev->next)两种情况;node_t **pp 游标把两者统一为"改 *pp",无特判、无遗漏。这是 Linux 内核链表删除的经典技法,值得背下来。

遍历与销毁:

for (slist_node_t *p = l.head; p != NULL; p = p->next) {
    printf("%d ", p->value);
}

/* 销毁:先暂存 next 再 free —— 顺序错了就是 use-after-free */
void slist_free(slist_t *l)
{
    slist_node_t *p = l->head;
    while (p != NULL) {
        slist_node_t *next = p->next;    /* 先存 */
        free(p);                          /* 再删 */
        p = next;
    }
    l->head = NULL;
    l->count = 0;
}

❌ 链表四大经典错误:

  1. p = p->next 放在 free(p) 之后(use-after-free);
  2. 删除节点时忘了处理它是头节点的情况;
  3. 空链表 head == NULL 时没有守卫就解引用;
  4. 元素带堆资源(如 char *name)时销毁只 free 节点,泄漏字符串。

双链表(prev + next)思路相同,删除时改两个方向的指针;工程上直接看 Linux 内核 list_head(侵入式链表:链表节点嵌入宿主结构,用 container_of 宏从成员指针反推宿主地址——进阶必读)。


12.3 栈与队列(基于数组)

栈:后进先出

复用 12.1 的动态数组思路,只暴露 push/pop/peek(栈顶):

typedef struct {
    int    *data;
    size_t  top;      /* 下一个写入位置 = 元素个数 */
    size_t  cap;
} int_stack;

int  stack_init(int_stack *s, size_t cap);
void stack_free(int_stack *s) { free(s->data); s->data = NULL; s->top = s->cap = 0; }

int stack_push(int_stack *s, int v)
{
    if (s->top == s->cap) {          /* 扩容,同 vec_push */
        size_t nc = s->cap ? s->cap * 2 : 8;
        int *tmp = realloc(s->data, nc * sizeof(int));
        if (tmp == NULL) return -1;
        s->data = tmp; s->cap = nc;
    }
    s->data[s->top++] = v;
    return 0;
}

int stack_pop(int_stack *s, int *out)     /* 空栈返回 -1 */
{
    if (s->top == 0)
        return -1;
    *out = s->data[--s->top];
    return 0;
}

应用一例——括号匹配(编译器/编辑器的基本功):

int balanced(const char *s)
{
    int_stack st;
    if (stack_init(&st, 16) != 0) return -1;
    int ok = 1;
    for (; *s && ok; s++) {
        if (*s == '(') {
            ok = stack_push(&st, 0) == 0;
        } else if (*s == ')') {
            int dummy;
            ok = stack_pop(&st, &dummy) == 0;   /* 弹不到 = 多了右括号 */
        }
    }
    if (st.top != 0) ok = 0;                     /* 有剩 = 多了左括号 */
    stack_free(&st);
    return ok;
}

队列:先进先出(环形数组)

数组实现队列的难点在"头出尾进"会浪费前段空间——环形缓冲区解决:head/tail 下标对容量取模循环前进。留一个空位区分空与满(工程上最简方案):

typedef struct {
    int    *data;
    size_t  cap;       /* 物理容量;有效容量 cap-1 */
    size_t  head;      /* 出队位置 */
    size_t  tail;      /* 入队位置 */
} ring_queue;

int rq_init(ring_queue *q, size_t cap) { /* calloc cap+1 个槽 */ }
#define RQ_COUNT(q)  (((q)->tail - (q)->head + (q)->cap) % (q)->cap)

int rq_push(ring_queue *q, int v)        /* 满返回 -1 */
{
    size_t next = (q->tail + 1) % q->cap;
    if (next == q->head)                 /* tail 追上 head = 满 */
        return -1;
    q->data[q->tail] = v;
    q->tail = next;
    return 0;
}

int rq_pop(ring_queue *q, int *out)      /* 空返回 -1 */
{
    if (q->head == q->tail)              /* head==tail = 空 */
        return -1;
    *out = q->data[q->head];
    q->head = (q->head + 1) % q->cap;
    return 0;
}

环形队列是无锁单生产者/单消费者队列(第 14 章)、内核网络缓冲、音频/日志缓冲的通用底座。


12.4 哈希表

把 key 通过哈希函数映射到桶下标,O(1) 存取。C 里自己实现要处理两件事:哈希函数与冲突解决(链地址法最常用)。

#include <stdint.h>
#include <string.h>

/* FNV-1a:简单且分布良好的字符串哈希 */
static uint32_t hash_str(const char *s)
{
    uint32_t h = 2166136261u;
    while (*s) {
        h ^= (unsigned char)*s++;
        h *= 16777619u;
    }
    return h;
}

typedef struct entry {
    char          *key;          /* 拥有:strdup 进来 */
    long           value;
    struct entry  *next;         /* 链地址法:桶内冲突链 */
} entry_t;

typedef struct {
    entry_t **buckets;
    size_t    nbuckets;
    size_t    count;
} hashmap_t;

int hashmap_init(hashmap_t *m, size_t nbuckets)
{
    m->buckets = calloc(nbuckets, sizeof(entry_t *));   /* 全 NULL */
    if (m->buckets == NULL)
        return -1;
    m->nbuckets = nbuckets;
    m->count = 0;
    return 0;
}

/* 查找:返回 entry 或 NULL */
entry_t *hashmap_find(const hashmap_t *m, const char *key)
{
    uint32_t h = hash_str(key) % m->nbuckets;
    for (entry_t *e = m->buckets[h]; e != NULL; e = e->next)
        if (strcmp(e->key, key) == 0)
            return e;
    return NULL;
}

int hashmap_put(hashmap_t *m, const char *key, long value)
{
    entry_t *e = hashmap_find(m, key);
    if (e != NULL) {                 /* 已存在:更新 */
        e->value = value;
        return 0;
    }
    e = malloc(sizeof(*e));
    if (e == NULL)
        return -1;
    e->key = strdup(key);            /* 拷贝 key:不依赖调用方的生命周期 */
    if (e->key == NULL) {
        free(e);
        return -1;
    }
    e->value = value;
    uint32_t h = hash_str(key) % m->nbuckets;
    e->next = m->buckets[h];         /* 头插进桶 */
    m->buckets[h] = e;
    m->count++;
    return 0;
}

void hashmap_free(hashmap_t *m)
{
    for (size_t i = 0; i < m->nbuckets; i++) {
        entry_t *e = m->buckets[i];
        while (e != NULL) {
            entry_t *next = e->next;
            free(e->key);            /* 成员资源先放 */
            free(e);                 /* 节点后放 */
            e = next;
        }
    }
    free(m->buckets);
    m->buckets = NULL;
}

性能与调优:桶数太少 → 链变长退化成 O(n);装载因子(count/nbuckets)超过 ~0.75 就该rehash(桶数翻倍、全部重新入桶)。本章从简未实现 rehash,工程上必加。哈希函数要"雪崩"(输入小变化→输出大变化),字符串哈希勿用"首字符"这类弱函数。


12.5 二叉搜索树(BST)

性质:左子树所有节点 < 根 < 右子树所有节点。中序遍历即有序序列。

typedef struct bst_node {
    long             key;
    struct bst_node *left;
    struct bst_node *right;
} bst_node_t;

/* 查找:O(h),h 是树高 */
bst_node_t *bst_find(bst_node_t *root, long key)
{
    while (root != NULL) {
        if (key < root->key)       root = root->left;
        else if (key > root->key)  root = root->right;
        else                       return root;
    }
    return NULL;
}

/* 插入:沿路下行挂到空位。返回新树根 */
bst_node_t *bst_insert(bst_node_t *root, long key)
{
    if (root == NULL) {
        bst_node_t *n = malloc(sizeof(*n));
        if (n == NULL)
            return NULL;
        n->key = key;
        n->left = n->right = NULL;
        return n;
    }
    if (key < root->key)
        root->left = bst_insert(root->left, key);
    else if (key > root->key)
        root->right = bst_insert(root->right, key);
    /* 相等:忽略(集合语义) */
    return root;
}

/* 中序遍历:升序输出 */
void bst_inorder(const bst_node_t *root)
{
    if (root == NULL)
        return;
    bst_inorder(root->left);        /* 递归在这里(第 5 章:栈深 = 树高) */
    printf("%ld ", root->key);
    bst_inorder(root->right);
}

/* 删除:BST 最难的操作。三种情形:叶子直接删;单子树接上去;双子树找
   右子树最小节点(后继)替换本节点值,再递归删掉后继 */
bst_node_t *bst_remove(bst_node_t *root, long key)
{
    if (root == NULL)
        return NULL;
    if (key < root->key) {
        root->left = bst_remove(root->left, key);
    } else if (key > root->key) {
        root->right = bst_remove(root->right, key);
    } else {
        if (root->left == NULL) {                 /* 0 或 1 个孩子 */
            bst_node_t *r = root->right;
            free(root);
            return r;
        }
        if (root->right == NULL) {
            bst_node_t *l = root->left;
            free(root);
            return l;
        }
        bst_node_t *succ = root->right;           /* 双孩子:找后继 */
        while (succ->left != NULL)
            succ = succ->left;
        root->key = succ->key;                    /* 值替换 */
        root->right = bst_remove(root->right, succ->key);
    }
    return root;
}

/* 整树销毁:后序(先孩子后自己) */
void bst_free(bst_node_t *root)
{
    if (root == NULL)
        return;
    bst_free(root->left);
    bst_free(root->right);
    free(root);
}

BST 的阿喀琉斯之踵:按序插入 1,2,3,... 会退化成链表(O(n))。工程上用自平衡树(AVL/红黑树——Linux 内核的 rbtree)或跳表保证 O(log n);理解 BST 的旋转是学平衡树的前提。


12.6 数据结构调试方法

症状 排查方向
遍历死循环 链表 next 指回自己(插入顺序错);树有环
删除后节点"还在"或崩溃 删除没有接好前驱/后继;画图!prev/cur/next 三个指针逐一标注
查不到刚插入的元素 哈希函数与查找函数不一致;key 拷贝 vs 指针混用
free 后再访问崩溃 销毁顺序;嵌套资源未先释放
数量统计不对 count 维护漏了某条路径(插入/删除成功但 count 没同步)

画图是数据结构调试的第一生产力:纸上画节点和箭头,把每个指针的当前值标上去,与代码逐步对照——90% 的链表/树 Bug 在纸上现形。GDB 配合:

print *l->head             # 头节点全貌
print *l->head->next       # 沿链走
set print elements 0       # 不截断大数组

测试策略:每个操作后断言不变量(链表 count 与实际遍历数一致、BST 中序有序、哈希 find(刚 put 的) != NULL)——这正是第 19 章单元测试的素材。


12.7 小结

  • 动态数组(data/len/cap + 倍增)是默认首选:连续内存、缓存友好。
  • 链表价值在 O(1) 增删;二级指针游标法统一处理头删/中删;销毁先存 next 再 free。
  • 栈=受限数组顶针;环形队列用留空位法区分空满;两者是系统编程标配。
  • 哈希表 = 哈希函数 + 链地址 + 装载因子控制;key 要 strdup,释放要逐层。
  • BST 中序有序;删除三种情形,双孩子用后继替换;怕有序输入退化,工程换平衡树。
  • 一切结构:create/destroy 配对、所有权明确、不变量可断言。

思考与练习

  1. 给 vec_t 增加 vec_pop、vec_insert(i, elem)、vec_remove(i),ASan 验证边界。
  2. 用二级指针法实现"删除单链表中所有等于 value 的节点"。
  3. 实现双链表(含 prev),完成头插/尾插/删除/反向遍历,对比单链表删除的复杂度差异。
  4. 给 12.4 哈希表加 rehash:装载因子 > 0.75 时桶数翻倍重建,压测 10 万个 key 验证查找性能。
  5. 实现 BST 的 long bst_kth(const bst_node_t *root, long k)(第 k 小元素),提示:带计数的递归或中序迭代。

上一章:预处理器与多文件工程 | 下一章:常用算法实现 →

📄 第 13 章 常用算法实现 13-常用算法实现.md

学习目标:掌握冒泡/插入/快速/归并四种排序与二分查找的 C 实现;会用大 O 分析算法成本;掌握通用接口设计(void* + 比较回调,qsort 风格);理解"算法正确性优先,性能靠测量"的工程顺序。


13.1 大 O:算法成本的通用语言

大 O 描述"数据量 n 增大时,成本如何增长"——不比较常数,只比较增长形状:

复杂度 名称 n=1000 时的直觉 典型算法
O(1) 常数 立刻 数组下标、哈希查找
O(log n) 对数 ~10 步 二分查找、平衡树
O(n) 线性 1000 步 线性扫描
O(n log n) 线性对数 ~10⁴ 步 快排、归并(比较排序的下界)
O(n²) 平方 10⁶ 步 冒泡、插入(朴素实现)
O(2ⁿ) 指数 天文数字 朴素递归斐波那契

工程直觉:n=10⁵ 时 O(n²) ≈ 10¹⁰ 次操作,秒级起步;O(n log n) ≈ 10⁶·约17,毫秒级。先看量级再谈细节。

分析三板斧:

  1. 循环嵌套层数 × 每层次数(for i<n { for j<n } → O(n²));
  2. 递归:递归深度 × 每层工作量(归并:log n 层 × 每层 O(n) 合并 → O(n log n));
  3. 只留最高阶项,忽略常数(3n² + 5n + 2 → O(n²))。

13.2 排序算法四连

冒泡排序:教学起点,工程弃子

void bubble_sort(int a[], size_t n)
{
    for (size_t i = 0; i + 1 < n; i++) {        /* 共 n-1 轮 */
        int swapped = 0;
        for (size_t j = 0; j + 1 < n - i; j++) { /* 每轮把最大值"冒"到末尾 */
            if (a[j] > a[j + 1]) {
                int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t;
                swapped = 1;
            }
        }
        if (!swapped)               /* 一轮无交换 = 已有序,提前收工 */
            break;                  /* 最好情况 O(n);最坏/平均 O(n²) */
    }
}

价值在于理解"相邻交换排序"与提前终止优化;实际工程不用(比插入排序还慢)。

插入排序:小数量的冠军

像整理扑克牌:把每个新元素插入左侧已排好的部分:

void insertion_sort(int a[], size_t n)
{
    for (size_t i = 1; i < n; i++) {
        int key = a[i];                 /* 待插入的牌 */
        size_t j = i;
        while (j > 0 && a[j - 1] > key) {  /* 右移比它大的 */
            a[j] = a[j - 1];
            j--;
        }
        a[j] = key;
    }
}
  • 近乎有序的数据:接近 O(n)——工程中"小数组 + 近有序"场景的最快选择;
  • 稳定排序(相等元素保持原序);
  • 标准库快排对小分段都会切换到插入排序(混合策略,如 Timsort/introsort 的收尾)。

快速排序:平均最快的通用排序

分治三步:选基准(pivot)→ 分区(小的去左大的去右)→ 递归两侧:

static void swap_int(int *x, int *y) { int t = *x; *x = *y; *y = t; }

/* Lomuto 分区:以末元素为基准,返回基准最终位置 */
static size_t partition(int a[], size_t lo, size_t hi)
{
    int pivot = a[hi];
    size_t i = lo;                        /* i 左边(含)都是 < pivot 的 */
    for (size_t j = lo; j < hi; j++) {
        if (a[j] < pivot) {
            swap_int(&a[i], &a[j]);
            i++;
        }
    }
    swap_int(&a[i], &a[hi]);              /* 基准归位 */
    return i;
}

static void quick_sort_rec(int a[], long lo, long hi)
{
    while (lo < hi) {
        size_t p = partition(a, (size_t)lo, (size_t)hi);
        /* 递归较小的一半,循环较大的一半:把栈深压到 O(log n) */
        if ((long)p - lo < (long)hi - (long)p) {
            quick_sort_rec(a, lo, (long)p - 1);
            lo = (long)p + 1;
        } else {
            quick_sort_rec(a, (long)p + 1, hi);
            hi = (long)p - 1;
        }
    }
}

void quick_sort(int a[], size_t n)
{
    if (n > 1)
        quick_sort_rec(a, 0, (long)n - 1);
}

要点与陷阱:

  • 平均 O(n log n),最坏 O(n²):有序数组 + "末元素基准"就是最坏输入。缓解:随机选基准、三数取中(首中尾取中位数)。
  • 不稳定(相等元素次序可能变);需要稳定 → 归并。
  • 递归深度失控会爆栈——上面"小半递归、大半循环"的写法把栈深锁在 O(log n),这是工程版与教材版的关键差异。

归并排序:稳定与可预测

分治的另一半:先递归排两半,再合并两个有序数组:

/* merge 需要 O(n) 辅助空间 */
static void merge(int a[], int tmp[], size_t lo, size_t mid, size_t hi)
{
    size_t i = lo, j = mid, k = lo;
    while (i < mid && j < hi)
        tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];  /* <= 保证稳定 */
    while (i < mid)  tmp[k++] = a[i++];               /* 左边剩的 */
    while (j < hi)   tmp[k++] = a[j++];               /* 右边剩的 */
    memcpy(a + lo, tmp + lo, (hi - lo) * sizeof(int));
}

static void merge_sort_rec(int a[], int tmp[], size_t lo, size_t hi)
{
    if (hi - lo < 2)
        return;                       /* 0 或 1 个元素:天然有序 */
    size_t mid = lo + (hi - lo) / 2;
    merge_sort_rec(a, tmp, lo, mid);
    merge_sort_rec(a, tmp, mid, hi);
    merge(a, tmp, lo, mid, hi);
}

int merge_sort(int a[], size_t n)
{
    if (n < 2)
        return 0;
    int *tmp = malloc(n * sizeof(int));   /* 辅助数组一次分配(递归里分配是灾难) */
    if (tmp == NULL)
        return -1;
    merge_sort_rec(a, tmp, 0, n);
    free(tmp);
    return 0;
}
  • 恒定 O(n log n)(无最坏退化)、稳定、代价是 O(n) 辅助空间;
  • 天然适合外部排序(数据在磁盘/内存放不下时按块归并)与链表排序(链表归并不需要辅助数组);
  • mid = lo + (hi - lo) / 2 防止 (lo + hi) / 2 的加法溢出——二分类代码的通用防溢出写法。

选择小结

算法 平均 最坏 空间 稳定 工程定位
冒泡 O(n²) O(n²) O(1) ✅ 教学用
插入 O(n²) O(n²) O(1) ✅ 小/近有序数据的收尾
快排 O(n log n) O(n²) O(log n) 栈 ❌ 通用首选(库实现是混合优化版)
归并 O(n log n) O(n log n) O(n) ✅ 稳定性/外部排序/链表

工程第一原则:别手写排序,用标准库 qsort(或平台更优的 qsort_r)。手写的意义是理解原理与应对特殊场景(固定小范围键用计数排序等)。


13.3 通用接口:void* + 比较回调

第 7 章函数指针的实战收尾——把"int 专用排序"升级为"任意类型排序":

/* 通用交换:按字节交换任意大小的元素 */
static void swap_bytes(void *a, void *b, size_t size)
{
    unsigned char *pa = a, *pb = b;
    while (size--) {
        unsigned char t = *pa;
        *pa++ = *pb;
        *pb++ = t;
    }
}

/* 通用插入排序:对任意类型生效 */
void sort_generic(void *base, size_t n, size_t size,
                  int (*cmp)(const void *, const void *))
{
    unsigned char *arr = base;
    for (size_t i = 1; i < n; i++) {
        unsigned char tmp[size == 0 ? 1 : size];   /* VLA,见下注 */
        memcpy(tmp, arr + i * size, size);
        size_t j = i;
        while (j > 0 && cmp(arr + (j - 1) * size, tmp) > 0) {
            memcpy(arr + j * size, arr + (j - 1) * size, size);
            j--;
        }
        memcpy(arr + j * size, tmp, size);
    }
}

VLA 注:unsigned char tmp[size] 是变长数组(C99),栈分配、快,但 MSVC 不支持且 size 过大爆栈。工程替代:malloc 一个 size 缓冲(循环外),或限制此接口用于小元素。

比较函数的正确写法(防溢出 + 语义清晰):

int cmp_int(const void *a, const void *b)
{
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);        /* ✅ 只依赖大小关系,不会溢出 */
}
/* ❌ return x - y;  当 x=2e9, y=-2e9 时相减溢出,结果反号 */

int cmp_double(const void *a, const void *b)      /* 浮点版 */
{
    double x = *(const double *)a, y = *(const double *)b;
    return (x > y) - (x < y);        /* NaN 参与时返回 0,行为需自定 */
}

int cmp_student_by_gpa(const void *a, const void *b)   /* 按结构体字段 */
{
    const student_t *s1 = a, *s2 = b;
    return (s1->gpa > s2->gpa) - (s1->gpa < s2->gpa);
}

/* 结构体数组排序 */
qsort(students, n, sizeof(student_t), cmp_student_by_gpa);

13.4 二分查找

前提:数据必须已排序(前提不满足时结果是错的——不报错,最阴险)。

/* 返回找到的下标,找不到返回 (size_t)-1 */
long binary_search(const int a[], size_t n, int target)
{
    size_t lo = 0, hi = n;               /* 左闭右开 [lo, hi) */
    while (lo < hi) {
        size_t mid = lo + (hi - lo) / 2; /* 防溢出写法 */
        if (a[mid] == target)
            return (long)mid;
        if (a[mid] < target)
            lo = mid + 1;                /* 目标在右半 */
        else
            hi = mid;                    /* 目标在左半 */
    }
    return -1;
}

为什么这个边界写法值得背:[lo, hi) 左闭右开与循环条件 lo < hi 严格自洽——区间空即终止,不存在"差一"。历史上 Knuth 指出 (lo+hi)/2 溢出 Bug 在各种二分实现里存活了 20+ 年(含 Java 标准库),防溢出写法是底线。

工程应用——找插入位置(lower_bound):哈希表无序,"在有序数组里找第一个 ≥ x 的位置"是范围查询、去重、有序合并的地基:

/* 第一个 >= target 的下标(= n 表示不存在) */
size_t lower_bound(const int a[], size_t n, int target)
{
    size_t lo = 0, hi = n;
    while (lo < hi) {
        size_t mid = lo + (hi - lo) / 2;
        if (a[mid] < target)
            lo = mid + 1;
        else
            hi = mid;
    }
    return lo;
}

标准库对应物:bsearch()(只判存在,不返回插入点,且实现不保证稳定性)。

❌ 二分三大经典错误:未排序就二分(结果错)、(lo+hi)/2 溢出、边界更新写 hi = mid 配合 lo = mid 造成死循环(两边界必须严格收缩:一侧 mid+1 一侧 mid 或两侧都收缩——用 [lo,hi) 模板则天然正确)。


13.5 递归与分治的更多形态

二分幂(快速幂)——O(log n) 求 xⁿ(第 5 章练习的完整答案):

long long binpow(long long x, unsigned n)
{
    long long result = 1;
    while (n > 0) {
        if (n & 1)                 /* 当前二进制位是 1:乘上这一份 */
            result *= x;
        x *= x;                    /* 平方翻倍 */
        n >>= 1;
    }
    return result;
}

位运算 + 迭代实现递归思想(每次把问题规模减半),是"分治可以不用递归写"的示范。

分治的判断标准:子问题独立(不重叠)才用分治;子问题大量重叠(如朴素斐波那契)要加记忆化或改成动态规划:

/* 斐波那契的 O(n) 迭代版:两变量滚动,无栈风险 */
long fib_fast(int n)
{
    long a = 0, b = 1;             /* a=F(0), b=F(1) */
    for (int i = 0; i < n; i++) {
        long t = a + b;
        a = b;
        b = t;
    }
    return a;                      /* F(n) */
}

13.6 算法正确性验证方法

冒烟基准:先在手算过答案的小例子上验证(排序 {3,1,2})。

随机对拍(differential testing)——工程里最有效的算法验证手法:写一个"慢但显然正确"的实现作参照(如 O(n²) 选择排序、线性查找),与被测算法在成千上万组随机数据上比对结果:

#include <stdlib.h>

int check_sort(void)
{
    int a[200], b[200];
    for (int trial = 0; trial < 1000; trial++) {
        size_t n = (size_t)(rand() % 50) + 1;
        for (size_t i = 0; i < n; i++)
            a[i] = b[i] = rand() % 100 - 50;   /* 同一组数据喂两份 */
        quick_sort(a, n);
        insertion_sort(b, n);                   /* 参照实现 */
        if (memcmp(a, b, n * sizeof(int)) != 0) {
            fprintf(stderr, "mismatch at trial %d, n=%zu\n", trial, n);
            return -1;                          /* 打印数据,缩小复现 */
        }
    }
    return 0;
}

随机对拍能抓到所有"特殊输入"Bug(重复元素、全相等、单元素、已排序、逆序)——固定测试用例做不到全覆盖。

边界清单(写完必查):n=0、n=1、全相等、已有序、逆序、含极值(INT_MIN/INT_MAX)。


13.7 调试方法:算法 Bug 怎么查

  • 打印中间状态:排序每轮 print 一行、二分每轮打印 lo/mid/hi,人肉模拟与代码对账——不变量被破坏的那一刻就是 Bug 藏身处。
  • 缩到最小复现:随机对拍发现失败后,逐步缩短数组/缩小取值范围,直到 3~5 个元素就能复现,纸上画完。
  • 不变量断言:循环内 assert(lo <= mid && mid < hi);排序后 assert(is_sorted(a, n))(第 16 章 assert 详述)。
  • 怀疑溢出:中间乘法/减法的结果先换 long long 验证是否一致。
  • 复杂度验证:理论 O(n log n) 实测却卡死 → 大概率死循环(边界没收缩)或意外 O(n²)(比较回调写错,如 cmp 返回值恒真值非 -1/0/1)。

13.8 小结

  • 大 O 看增长形状;O(n log n) 是比较排序下界,O(n²) 在 10⁵ 量级已不可行。
  • 四种排序:冒泡教学、插入管小与近有序、快排平均最快(防最坏:随机基准+小半递归)、归并稳定可预测。
  • 通用接口 = void* + size + 比较回调;比较函数用 (x>y)-(x<y) 防溢出;生产代码用 qsort。
  • 二分的前提是已排序;[lo,hi) 左闭右开模板 + lo+(hi-lo)/2 是防错防溢出的标准形态;lower_bound 是工程高频变体。
  • 子问题重叠就别裸分治:记忆化/迭代化;快速幂示范"分治思想+位运算迭代"。
  • 算法验证三板斧:手算小例、边界清单、随机对拍。

思考与练习

  1. 实现选择排序并统计三种排序在 n=1000、10000、50000 随机数据上的实际耗时(clock()),与理论复杂度对照。
  2. 给 quick_sort 换成"三数取中"选基准,用有序数组压测前后耗时对比。
  3. 实现"按字符串长度排序的 char** 数组"的比较函数,用 qsort 验证;再做"长度相同按字典序"的二级比较。
  4. 用 lower_bound 实现"在有序数组中统计 target 出现次数"(upper_bound - lower_bound,自己实现 upper_bound)。
  5. 写随机对拍程序:你的 binary_search vs 线性扫描,100 万组随机用例;故意把边界写成 hi = mid - 1 观察哪种输入会暴露 Bug。

上一章:常用数据结构实现 | 下一章:多线程与并发 →

📄 第 14 章 多线程与并发 14-多线程与并发.md

学习目标:理解竞态条件的本质;掌握 C11 <threads.h> 与 POSIX <pthread.h> 两套线程 API;会用互斥锁保护共享数据、用条件变量实现等待/通知;实现经典的生产者-消费者;认识死锁、活锁、伪共享等问题及防治;了解原子操作与线程安全设计。


14.1 为什么会出事:竞态条件

线程 = 进程内的一条执行流。同进程的线程共享全部内存(全局变量、堆),各自只有栈和寄存器私有。共享就是危险:

/* 两个线程同时执行这个函数 100 万次 */
static long counter = 0;

void *worker(void *arg)
{
    (void)arg;
    for (int i = 0; i < 1000000; i++)
        counter++;               /* ❌ 看似一行,实际三步:读→改→写 */
    return NULL;
}
/* 期望 2000000,实际常得到小于它的随机值(如 1203456) */

counter++ 在机器层面是三条指令:读内存到寄存器 → 寄存器加 1 → 写回内存。两个线程交错执行时发生"丢失更新":

线程A: 读到 100
线程B: 读到 100          ← A 还没写回
线程A: 写回 101
线程B: 写回 101          ← B 覆盖了 A 的结果,一次加法凭空消失

这就是竞态条件(race condition):结果取决于线程执行时序。它的恐怖之处在于不可复现——调试器一加、打印一加、机器一换,Bug 就消失(Heisenbug 之王)。

数据竞争的严格定义:两个线程并发访问同一内存位置、至少一个是写、且没有任何同步手段排序它们。C11 起,数据竞争是未定义行为——不是"结果错",是"什么都可能发生"。

结论(本章纲领):凡是被多个线程访问、且至少一方写的数据,必须有同步保护——互斥锁、原子操作,或设计上就不共享。


14.2 线程的创建与回收

C11 标准线程(<threads.h>)

#include <threads.h>
#include <stdio.h>

static int worker(void *arg)          /* 线程函数:返回 int 状态码 */
{
    long id = (long)arg;
    printf("thread %ld running\n", id);
    return 0;
}

int main(void)
{
    thrd_t t1, t2;
    if (thrd_create(&t1, worker, (void *)1) != thrd_success ||
        thrd_create(&t2, worker, (void *)2) != thrd_success) {
        fprintf(stderr, "create failed\n");
        return 1;
    }
    thrd_join(t1, NULL);              /* 等线程结束并回收资源(必调!否则泄漏) */
    thrd_join(t2, NULL);
    return 0;
}
/* GCC 需要 -pthread(glibc 4.9+ 的 C11 线程基于 pthread 实现) */

POSIX 线程(<pthread.h>)——工业事实标准

#include <pthread.h>

static void *pthread_worker(void *arg)     /* 返回 void*(可传结果) */
{
    long id = (long)arg;
    printf("thread %ld\n", id);
    return (void *)id;                     /* 通过返回值传出结果 */
}

int main(void)
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, pthread_worker, (void *)1);
    pthread_create(&t2, NULL, pthread_worker, (void *)2);

    void *ret1;
    pthread_join(t1, &ret1);               /* 取回返回值 */
    printf("thread 1 returned %ld\n", (long)ret1);
    pthread_join(t2, NULL);
    return 0;
}
/* 编译:gcc -pthread prog.c -o prog (Linux 必加 -pthread) */

两套 API 对照:thrd_create↔pthread_create、thrd_join↔pthread_join、thrd_detach↔pthread_detach、mtx_t↔pthread_mutex_t、cnd_t↔pthread_cond_t。语义一致,本书后文以 pthread 为主(Linux 服务端事实标准),C11 写法同步给出。

线程要点

  • join 或 detach,二选一:不 join 且不 detach 的线程结束时资源不回收(线程存储泄漏);join 还提供天然的同步点。
  • 线程函数的参数只传一个 void*:需要多个参数时打包结构体,指针必须指向生命周期超出线程运行期的内存(❌ 传局部变量地址:主线程函数返回后即悬垂)。
  • 线程是昂贵的资源:创建/销毁有成本(微秒级),默认栈 8MB 虚拟内存;高并发服务用线程池(固定 N 线程 + 任务队列——本章末综合示例)。

14.3 互斥锁:把三步变成一步

#include <pthread.h>

static long counter = 0;
static pthread_mutex_t counter_lock = PTHREAD_MUTEX_INITIALIZER;  /* 静态初始化 */

static void *safe_worker(void *arg)
{
    (void)arg;
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&counter_lock);    /* 上锁:拿不到就睡 */
        counter++;                            /* 临界区:此刻只有一个线程 */
        pthread_mutex_unlock(&counter_lock);  /* 解锁:尽快! */
    }
    return NULL;
}
/* 这次结果稳定为 2000000 */

锁的语义:同一时刻只有一个线程能持有它;其他 lock 的线程阻塞等待。受同一把锁保护的那段代码叫临界区。

❌ 锁的四大使用错误:

  1. 临界区太大:把整个循环体、甚至整个业务函数都锁进去——并发度归零。原则:锁只包住真正碰共享数据的那几行。

  2. 忘记 unlock:提前 return/break 的路径漏解锁 → 死等。用 goto cleanup 统一出口,或封装"锁守卫"结构。

    pthread_mutex_lock(&m);
    if (error) {
        pthread_mutex_unlock(&m);   /* 每个出口都要有! */
        return -1;
    }
    ... 
    pthread_mutex_unlock(&m);
    
  3. 重复加锁(非递归锁):lock 两次自己 → 自锁死锁。函数间调用链上同一把锁加两次是常见事故(谨慎用 PTHREAD_MUTEX_RECURSIVE,通常说明设计有问题)。

  4. 锁与数据错配:数据 A、B 共用一把锁或 A 的操作用了 B 的锁——保护关系形同虚设。惯例:一把锁配一组数据,命名对应(config_lock 保护 g_config)。

C11 版本:mtx_init(&m, mtx_plain) / mtx_lock / mtx_unlock / mtx_destroy。

try_lock:拿不到锁立刻返回(不睡),用于"做别的去"或死锁检测:

if (pthread_mutex_trylock(&m) == 0) {
    /* 拿到了,干活 */
    pthread_mutex_unlock(&m);
} else {
    /* 忙,先干别的 */
}

14.4 条件变量:等待某个条件成立

互斥锁解决"互斥",条件变量解决"等待"——线程 B 要等"队列非空"才能干活,轮询(while (empty) sleep(1);)浪费且延迟大。条件变量提供"睡到被叫醒":

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  cv = PTHREAD_COND_INITIALIZER;
int ready = 0;                       /* 被锁保护的状态:条件本身 */

/* 等待方:必须用 while 循环! */
pthread_mutex_lock(&m);
while (ready == 0)                   /* while,不是 if——防虚假唤醒 */
    pthread_cond_wait(&cv, &m);      /* 原子地:解锁 + 睡眠;醒来时重新持锁 */
/* 此刻持锁,且 ready == 1 */
pthread_mutex_unlock(&m);

/* 通知方 */
pthread_mutex_lock(&m);
ready = 1;                           /* 先改条件(持锁) */
pthread_cond_signal(&cv);            /* 再唤醒一个等待者 */
pthread_mutex_unlock(&m);            /* 或 broadcast 唤醒所有等待者 */

为什么必须 while 而不能 if:

  1. 虚假唤醒(spurious wakeup):POSIX 允许 cond_wait 无故醒来,醒来后必须重查条件;
  2. 抢占插队:signal 之后、被唤醒线程拿到锁之前,第三者可能抢先进临界区把条件改掉(消费走最后一个任务)。

while (条件不满足) cond_wait(...) 是唯一正确的形态,任何教程/代码里的 if + wait 都是 Bug。


14.5 经典实战:生产者-消费者(有界阻塞队列)

把 12.3 的环形队列 + 互斥锁 + 条件变量组装起来——线程池、消息系统、日志系统的通用骨架:

#include <pthread.h>
#include <stdlib.h>

typedef struct {
    int *buf;
    size_t cap;
    size_t head, tail, count;
    pthread_mutex_t lock;
    pthread_cond_t  not_full;     /* 队列不满:生产者等它 */
    pthread_cond_t  not_empty;    /* 队列不空:消费者等它 */
    int closed;                   /* 关闭标志:通知消费者收工 */
} bounded_queue;

void bq_init(bounded_queue *q, size_t cap)
{
    q->buf = malloc(cap * sizeof(int));
    q->cap = cap;
    q->head = q->tail = q->count = 0;
    q->closed = 0;
    pthread_mutex_init(&q->lock, NULL);
    pthread_cond_init(&q->not_full, NULL);
    pthread_cond_init(&q->not_empty, NULL);
}

/* 生产者:队列满则睡到不满 */
int bq_push(bounded_queue *q, int v)
{
    pthread_mutex_lock(&q->lock);
    while (q->count == q->cap) {              /* while + wait:标准形态 */
        if (q->closed) {
            pthread_mutex_unlock(&q->lock);
            return -1;
        }
        pthread_cond_wait(&q->not_full, &q->lock);
    }
    q->buf[q->tail] = v;
    q->tail = (q->tail + 1) % q->cap;
    q->count++;
    pthread_cond_signal(&q->not_empty);       /* 叫醒一个等数据的消费者 */
    pthread_mutex_unlock(&q->lock);
    return 0;
}

/* 消费者:队列空则睡到不空;closed 后取完剩余返回 -1 */
int bq_pop(bounded_queue *q, int *out)
{
    pthread_mutex_lock(&q->lock);
    while (q->count == 0) {
        if (q->closed) {
            pthread_mutex_unlock(&q->lock);
            return -1;                        /* 关闭且取空:收工信号 */
        }
        pthread_cond_wait(&q->not_empty, &q->lock);
    }
    *out = q->buf[q->head];
    q->head = (q->head + 1) % q->cap;
    q->count--;
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->lock);
    return 0;
}

/* 关闭:唤醒所有等待者,让它们看到 closed */
void bq_close(bounded_queue *q)
{
    pthread_mutex_lock(&q->lock);
    q->closed = 1;
    pthread_cond_broadcast(&q->not_empty);    /* 广播:所有等待者都要醒 */
    pthread_cond_broadcast(&q->not_full);
    pthread_mutex_unlock(&q->lock);
}

signal vs broadcast 的选择:signal 唤醒一个(省惊群,适合"每份资源一个等待者"如本例的 push/pop 对称场景);broadcast 唤醒所有(条件是全局性的状态翻转、或等待者数量不定时用)。拿不准就 broadcast(性能略降但正确性稳)。


14.6 死锁:并发程序的头号杀手

两个线程各持一把锁、互等对方的锁,永远卡死:

线程1: lock(A) → 想拿 B(等线程2)
线程2: lock(B) → 想拿 A(等线程1)     ← 循环等待,双双冻结

死锁四条件(同时成立才会死锁):互斥、持有并等待、不可剥夺、循环等待。工程防治只抓最后一条:

全局锁序原则:所有锁按固定全序使用(如按地址、按编号),任何代码都不得逆序加锁。

/* 两把锁都要拿时,统一按地址从小到大加锁 */
pthread_mutex_t *first  = (uintptr_t)&lockA < (uintptr_t)&lockB ? &lockA : &lockB;
pthread_mutex_t *second = (first == &lockA) ? &lockB : &lockA;
pthread_mutex_lock(first);
pthread_mutex_lock(second);
/* ... */
pthread_mutex_unlock(second);
pthread_mutex_unlock(first);

其他手段:缩小临界区(少嵌套锁)、trylock + 失败回退重试、锁分层设计(模块间不互拿锁)。排查:GDB 附着卡死进程(gdb -p PID),thread apply all bt 查看所有线程栈——两个线程互相等在 pthread_mutex_lock 上,锁地址一对照,死锁对现形。


14.7 原子操作:无锁的轻量同步

对"单个整数的计数/标志"上互斥锁太重。原子操作保证单条指令级别的读改写不可分割:

C11 标准原子(<stdatomic.h>)

#include <stdatomic.h>

static atomic_long counter = 0;          /* 原子类型 */

void *worker(void *arg) {
    for (int i = 0; i < 1000000; i++)
        atomic_fetch_add(&counter, 1);   /* 原子自增:无锁、无竞态 */
    return NULL;
}
/* 200 万次原子加法,多线程结果精确正确 */

常用原子操作:atomic_load/store(读/写)、atomic_fetch_add/sub(加减)、atomic_compare_exchange_weak/strong(CAS,无锁数据结构的核心原语)。

简单标志位:atomic_bool

static atomic_bool g_stop = false;       /* 跨线程停止信号 */

/* 工作线程循环 */
while (!atomic_load(&g_stop)) { do_work(); }

/* 主线程/信号处理用 */
atomic_store(&g_stop, true);

原子操作的正确用法边界:

  • 适合:单变量的计数器、标志位、发布/读取指针。
  • 不适合:需要同时保护多个变量的一致性(如"更新 A 和 B")——那是互斥锁的领域。原子 + 锁不要对同一数据混用。
  • 内存序(memory_order_relaxed/acquire/release/seq_cst)是进阶深水区:默认 seq_cst(最强、最慢),初学保持默认;需要性能细化时先读《C++ Concurrency in Action》对应章节(C 与 C++ 内存序同源)。

14.8 线程安全设计

线程安全的三个层次(按优先级):

  1. 不共享(最好的同步是没有共享):线程局部存储(_Thread_local,C11)、每线程私有上下文、消息传递(把数据 move 进队列,而不是共享它)。
  2. 不可变共享:数据初始化后只读(配置表、常量查找表),随便并发读。
  3. 锁保护:明确所有者锁、临界区最小化、锁序文档化。

函数级线程安全分级(写库时给 API 标注,第 17 章):

等级 含义 例子
线程安全 任意并发调用正确 无共享状态或全用锁/原子的函数
可重入 不依赖全局/静态状态(含递归/中断场景) 纯参数运算函数
非线程安全 有共享可变状态,需调用方串行化 strtok、返回静态缓冲的 ctime、第 5 章 static 计数

标准库的经典坑:strtok 内部有静态游标——多线程并用会互相踩;多线程程序用 strtok_r(POSIX)。rand() 同理(用 rand_r 或线程局部状态)。errno 本身是线程局部的,可放心。

信号与多线程(Linux 进阶警告):信号处理函数里只能做原子写(volatile sig_atomic_t 标志),复杂工作交给专门的信号处理线程;pthread_mutex_lock 不是异步信号安全的。


14.9 综合示例:线程池

生产者-消费者模式的集大成应用——N 个工作线程 + 任务队列:

#include <pthread.h>
#include <stdlib.h>

typedef void (*task_fn)(void *arg);

typedef struct {
    task_fn fn;
    void   *arg;
} task_t;

typedef struct {
    task_t         *tasks;
    size_t          cap, head, tail, count;
    pthread_t      *workers;
    size_t          nworkers;
    pthread_mutex_t lock;
    pthread_cond_t  has_task;
    int             stop;
} thread_pool;

static void *pool_worker(void *arg)
{
    thread_pool *p = arg;
    for (;;) {
        pthread_mutex_lock(&p->lock);
        while (p->count == 0 && !p->stop)          /* 没任务也没停:睡 */
            pthread_cond_wait(&p->has_task, &p->lock);
        if (p->count == 0 && p->stop) {            /* 停了且没剩任务 */
            pthread_mutex_unlock(&p->lock);
            break;
        }
        task_t t = p->tasks[p->head];              /* 取任务(临界区内) */
        p->head = (p->head + 1) % p->cap;
        p->count--;
        pthread_mutex_unlock(&p->lock);

        t.fn(t.arg);                               /* 执行在锁外! */
    }
    return NULL;
}

int pool_start(thread_pool *p, size_t nworkers, size_t queue_cap)
{
    p->tasks = malloc(queue_cap * sizeof(task_t));
    p->workers = malloc(nworkers * sizeof(pthread_t));
    if (p->tasks == NULL || p->workers == NULL)
        return -1;
    p->cap = queue_cap;
    p->head = p->tail = p->count = 0;
    p->nworkers = nworkers;
    p->stop = 0;
    pthread_mutex_init(&p->lock, NULL);
    pthread_cond_init(&p->has_task, NULL);

    for (size_t i = 0; i < nworkers; i++)
        if (pthread_create(&p->workers[i], NULL, pool_worker, p) != 0)
            return -1;
    return 0;
}

int pool_submit(thread_pool *p, task_fn fn, void *arg)
{
    pthread_mutex_lock(&p->lock);
    if (p->count == p->cap) {                      /* 满了:工程上可选择阻塞/拒绝/扩容 */
        pthread_mutex_unlock(&p->lock);
        return -1;
    }
    p->tasks[p->tail] = (task_t){ fn, arg };
    p->tail = (p->tail + 1) % p->cap;
    p->count++;
    pthread_cond_signal(&p->has_task);
    pthread_mutex_unlock(&p->lock);
    return 0;
}

void pool_shutdown(thread_pool *p)
{
    pthread_mutex_lock(&p->lock);
    p->stop = 1;
    pthread_cond_broadcast(&p->has_task);          /* 唤醒所有工作线程 */
    pthread_mutex_unlock(&p->lock);
    for (size_t i = 0; i < p->nworkers; i++)
        pthread_join(p->workers[i], NULL);         /* 等全部退出再清资源 */
    free(p->tasks);
    free(p->workers);
    pthread_mutex_destroy(&p->lock);
    pthread_cond_destroy(&p->has_task);
}

本示例浓缩了本章全部要点:锁 + while-wait 条件变量、signal/broadcast 选择、临界区最小化(任务执行在锁外)、shutdown 的 join 收尾、成对的 init/destroy。


14.10 调试方法:并发 Bug 怎么抓

工具 用途 用法
ThreadSanitizer (TSan) 数据竞争检测器 gcc -fsanitize=thread -g;对疑似竞态的代码跑测试集,报出两处访问的精确位置与线程栈。与 ASan 互斥,二选一
GDB 多线程 查卡死/看现场 info threads、thread N、thread apply all bt——死锁分析主力
helgrind(Valgrind) 竞态(TSan 的替代) valgrind --tool=helgrind ./prog,慢但无需特殊编译
加压复现 稳定性验证 多跑 ×多线程 ×多核(taskset 绑核变化时序)×随机 sleep 注入
日志 + 时间戳 行为重建 每线程带 ID 的 fprintf(stderr, "[t%ld %ldus] ..."),stderr 无缓冲保真

方法论:竞态 Bug 不能靠"盯着代码想"确认,必须工具定罪(TSan)或压力复现(把偶发变必现)后再分析。修复后同样用工具+压测验证,"改好了感觉上没问题"在并发领域不算数。

设计回避:能改设计就不修竞态——把共享改成队列传递( actor 风格)、把读改写改成单写者+原子读,往往比调锁更快更稳。


14.11 小结

  • 共享可变数据 + 缺同步 = 数据竞争 = UB。保护手段:锁、原子、或不共享。
  • 线程必 join 或 detach;参数指向的生命周期要长于线程。
  • 锁纪律:临界区最小、每个出口解锁、一把锁配一组数据、全局锁序防死锁。
  • 条件变量唯一正确形态:while (条件不满足) cond_wait(&cv, &m);signal 唤一个、broadcast 唤全部。
  • 生产者-消费者 = 环形队列 + 锁 + 两个条件变量 + 关闭广播;线程池是其集大成应用。
  • 原子适合单变量计数/标志;多变量一致性交给锁;内存序初学保持默认。
  • 竞态排查靠 TSan/压力复现,不靠感觉;能改设计就别硬修。

思考与练习

  1. 复现 14.1 的丢失更新:无锁版打印错误结果;加锁版与原子版对比三种实现的耗时。
  2. 用条件变量实现"启动门闩":主线程准备就绪前,所有工作线程在起点等待(go 标志 + broadcast)。
  3. 把 14.9 线程池跑起来:提交 1000 个"累加自己序号到数组"的任务,验证正确性,TSan 全程把关。
  4. 制造一个死锁(两个线程逆序拿两把锁),用 GDB thread apply all bt 分析卡点,再用锁序原则修复。
  5. 思考题:bq_push 里如果把 while 改成 if,构造一个具体的线程交错时序让程序出错(提示:虚假唤醒或消费者插队)。

上一章:常用算法实现 | 下一章:网络编程 →

📄 第 15 章 网络编程 15-网络编程.md

学习目标:理解 TCP/UDP 的特性差异与适用场景;掌握 socket API 的完整流程(socket→bind→listen→accept→connect→read/write→close);实现可运行的 TCP 回显服务器与客户端;掌握字节序转换、粘包处理等工程要点;了解 I/O 多路复用与并发服务器模型。


15.1 网络编程的心智模型

C 网络编程 = 对 socket 文件描述符做系统调用。socket 是"网络端的文件":像读写文件一样 read/write,只是数据走到网卡,对端是另一台机器上的另一个 socket。

两种传输协议,选型先想清楚:

TCP UDP
连接 面向连接(三次握手) 无连接
可靠性 不丢、不乱序、自动重传 尽力而为,可能丢/乱序
边界 字节流:无消息边界(要自己拆包) 数据报:一次 send 一次 recv,边界天然
典型应用 HTTP、数据库、文件传输 DNS、游戏、实时音视频、心跳广播

本书以 TCP 为主(工程主流),UDP 给出收发骨架。

本章 API 以 POSIX(Linux/macOS) 为准;Windows 的 Winsock 兼容 95% 的接口,差异集中在初始化与 closesocket(15.7 节跨平台对照)。


15.2 socket API 全景:服务器与客户端的对称流程

服务器                          客户端
──────                          ──────
socket() 创建监听套接字
bind()   绑定 IP+端口
listen() 开始监听
accept() 阻塞等连接 ──────────> socket() 创建
        ←────────── connect()  发起连接(三次握手)
        返回新的连接套接字 connfd
read()/recv() ←─────────────── write()/send()
write()/send() ──────────────> read()/recv()
   (双向读写,可循环多次)
close() ──────────────────────> close()

关键概念:监听套接字(只负责接客)与连接套接字(accept 返回,负责与某一对端通信)是两个不同的 fd。


15.3 完整示例:TCP 回显服务器

客户端发什么就回什么——网络编程的 "Hello World",包含了全部基本步骤:

/* echo_server.c —— Linux/macOS */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>          /* close, read, write */
#include <errno.h>
#include <arpa/inet.h>       /* htons, inet_pton, ntohs */
#include <sys/socket.h>
#include <netinet/in.h>      /* sockaddr_in */

#define PORT      9000
#define BACKLOG   16          /* 等待队列长度 */
#define BUF_SIZE  4096

int main(void)
{
    /* 1. 创建 TCP socket(AF_INET=IPv4, SOCK_STREAM=TCP) */
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        fprintf(stderr, "socket: %s\n", strerror(errno));
        return 1;
    }

    /* 2. 允许重启时立刻复用 TIME_WAIT 端口(开发期必备) */
    int yes = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));

    /* 3. 绑定地址与端口 */
    struct sockaddr_in addr = {0};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);   /* 监听所有网卡;0.0.0.0 */
    addr.sin_port        = htons(PORT);         /* 端口转网络字节序! */
    if (bind(listenfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        fprintf(stderr, "bind: %s\n", strerror(errno));
        close(listenfd);
        return 1;
    }

    /* 4. 监听 */
    if (listen(listenfd, BACKLOG) < 0) {
        fprintf(stderr, "listen: %s\n", strerror(errno));
        close(listenfd);
        return 1;
    }
    printf("listening on port %d\n", PORT);

    for (;;) {                                   /* 主循环:接客 */
        struct sockaddr_in peer;
        socklen_t peerlen = sizeof(peer);

        /* 5. accept:阻塞等待,返回"连接套接字" */
        int connfd = accept(listenfd, (struct sockaddr *)&peer, &peerlen);
        if (connfd < 0) {
            if (errno == EINTR)                  /* 被信号打断:不算错,重来 */
                continue;
            fprintf(stderr, "accept: %s\n", strerror(errno));
            continue;
        }
        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
        printf("client %s:%u connected\n", ip, ntohs(peer.sin_port));

        /* 6. 回显循环:读多少回多少,直到对端关闭 */
        char buf[BUF_SIZE];
        ssize_t n;
        while ((n = read(connfd, buf, sizeof(buf))) > 0) {
            ssize_t sent = 0;
            while (sent < n) {                   /* write 可能只发一半:必须循环补发 */
                ssize_t w = write(connfd, buf + sent, (size_t)(n - sent));
                if (w < 0) {
                    perror("write");
                    goto disconnect;
                }
                sent += w;
            }
        }
        if (n < 0)
            perror("read");
disconnect:
        /* 7. 关闭连接套接字(监听套接字还开着) */
        close(connfd);
        printf("client disconnected\n");
    }
}

逐点讲解:

  • 字节序转换(易错第一名):网络字节序是大端,主机可能是小端。所有多字节数字进出协议都要转换:htons/htonl(主机→网络,short/long)、ntohs/ntohl(反向)。端口忘了 htons,程序在本机能连(恰好小端×两次抵消的错误对称),跨机就连不上——经典隐蔽 Bug。
  • INADDR_ANY:监听本机所有网卡;只监听本机回环用 inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr)。
  • read 返回 0 = 对端正常关闭(EOF),不是错误;< 0 才是错误。
  • write 少写:内核发送缓冲满时 write 只发部分字节——必须循环写(示例的 sent 补发循环)。read 同理可能只读到部分(更致命,见 15.5 粘包)。

15.4 TCP 回显客户端

/* echo_client.c —— 用法:./echo_client 127.0.0.1 9000 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "usage: %s <ip> <port>\n", argv[0]);
        return 1;
    }

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

    struct sockaddr_in serv = {0};
    serv.sin_family = AF_INET;
    serv.sin_port   = htons((unsigned short)atoi(argv[2]));
    if (inet_pton(AF_INET, argv[1], &serv.sin_addr) != 1) {   /* 点分十进制→二进制 */
        fprintf(stderr, "invalid ip\n");
        close(fd);
        return 1;
    }

    if (connect(fd, (struct sockaddr *)&serv, sizeof(serv)) < 0) {
        fprintf(stderr, "connect: %s\n", strerror(errno));
        close(fd);
        return 1;
    }

    char line[1024];
    while (fgets(line, sizeof(line), stdin) != NULL) {
        size_t len = strlen(line);
        if (write(fd, line, len) != (ssize_t)len) {   /* 小块写入,简化处理 */
            perror("write");
            break;
        }
        /* 简化模型:假设回显一次到齐(真实协议必须循环读,见 15.5) */
        char echo[1024];
        ssize_t n = read(fd, echo, sizeof(echo) - 1);
        if (n <= 0)
            break;
        echo[n] = '\0';
        fputs(echo, stdout);
    }
    close(fd);     /* 关闭时 TCP 发 FIN,服务器 read 返回 0 */
    return 0;
}

测试流程:

gcc -Wall -Wextra -g echo_server.c -o echo_server
gcc -Wall -Wextra -g echo_client.c -o echo_client
./echo_server &                 # 后台起服务器
./echo_client 127.0.0.1 9000    # 另开终端连上,输入回车看到回显

15.5 TCP 的核心难点:字节流没有消息边界(粘包/拆包)

TCP 只保证字节按序到达,不保证"一次 send 对应一次 recv":

发送方: send("AB")   send("CD")
接收方可能: read 得到 "ABCD"      ← 粘包
         或 read 得到 "A",再 read "BCD"   ← 拆包

这不是 Bug,是字节流的本质。应用层必须自己定义消息边界,三种标准方案:

  1. 定长消息:每次固定 64 字节。简单但浪费。
  2. 分隔符:如行协议 \n(HTTP 头、Redis 用此风格)。用 fgets 式逻辑切分。
  3. 长度前缀(length-prefixed,工程最常用):每条消息前放 4 字节长度(网络字节序):
/* 发送一条变长消息:4 字节长度头 + 载荷 */
int send_msg(int fd, const void *payload, uint32_t len)
{
    uint32_t nlen = htonl(len);
    /* 长度头 4 字节必须写满 */
    const char *p = (const char *)&nlen;
    size_t off = 0;
    while (off < sizeof(nlen)) {
        ssize_t w = write(fd, p + off, sizeof(nlen) - off);
        if (w <= 0) return -1;
        off += (size_t)w;
    }
    /* 载荷同理循环写满 */
    p = payload; off = 0;
    while (off < len) {
        ssize_t w = write(fd, p + off, len - off);
        if (w <= 0) return -1;
        off += (size_t)w;
    }
    return 0;
}

/* 接收恰好 n 字节(recv 循环补齐)——TCP 编程的必备函数 */
static ssize_t recv_full(int fd, void *buf, size_t n)
{
    size_t off = 0;
    while (off < n) {
        ssize_t r = read(fd, (char *)buf + off, n - off);
        if (r == 0) return 0;          /* 对端关闭 */
        if (r < 0) return -1;
        off += (size_t)r;
    }
    return (ssize_t)off;
}

/* 接收一条消息 */
int recv_msg(int fd, char *out, uint32_t out_cap, uint32_t *msg_len)
{
    uint32_t nlen;
    if (recv_full(fd, &nlen, sizeof(nlen)) != (ssize_t)sizeof(nlen))
        return -1;
    uint32_t len = ntohl(nlen);
    if (len > out_cap)                  /* 防恶意超大长度:容量校验! */
        return -1;
    if (recv_full(fd, out, len) != (ssize_t)len)
        return -1;
    *msg_len = len;
    return 0;
}

❌ 安全要点:长度前缀来自网络——必须校验上限再分配/接收,否则"超大长度"直接打爆内存(DoS 向量)。


15.6 UDP 骨架与并发服务器模型

UDP 收发

/* 无连接:服务端 bind 后直接 recvfrom/sendto,不需要 listen/accept */
int fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(9001);
bind(fd, (struct sockaddr *)&addr, sizeof(addr));

struct sockaddr_in peer; socklen_t plen = sizeof(peer);
char buf[2048];
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&peer, &plen);
sendto(fd, buf, (size_t)n, 0, (struct sockaddr *)&peer, plen);  /* 原路回给谁发的 */

UDP 每次收发自带对端地址,一次 recvfrom 恰好一条数据报(无粘包问题),代价是可靠性自己负责(超时重传、乱序重组——除非业务能容忍丢失,如状态同步/心跳,别自己造 TCP)。

并发服务器三种模型

每连接一线程(简单,适合连接数少):

/* accept 返回后把 connfd 交给新线程处理,主线程继续 accept */
int *fdp = malloc(sizeof(int)); *fdp = connfd;   /* 堆上传递,防悬垂 */
pthread_t tid;
pthread_create(&tid, NULL, client_thread, fdp);
pthread_detach(tid);

I/O 多路复用 select/poll/epoll(单线程管多连接,高并发正解):

/* select 骨架:同时等待监听 fd 与所有连接 fd 的可读事件 */
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);
int maxfd = listenfd;
for (每个连接 fd) { FD_SET(conn, &readfds); if (conn > maxfd) maxfd = conn; }

select(maxfd + 1, &readfds, NULL, NULL, NULL);   /* 阻塞到任一 fd 就绪 */
if (FD_ISSET(listenfd, &readfds)) { /* 新连接:accept */ }
for (每个连接 fd)
    if (FD_ISSET(conn, &readfds)) { /* 该连接有数据:read 处理 */ }

select 有 fd 上限(1024)且每次全量扫描;poll 无上限仍要全量扫描;Linux 高并发(万级连接)用 epoll(事件驱动增量维护,epoll_create/epoll_ctl/epoll_wait),macOS 用 kqueue。理解 select 后学 epoll 主要是 API 层差异,事件驱动模型一致。

进程 fork 模型(CGI 时代遗产,现代少用):accept 后 fork 子进程处理,父进程回收。隔离好、开销大。

选型速记:连接少(<100)→ 线程模型;连接多且多为空闲 → epoll;想省事且正确性优先 → 现成库(libevent/libuv)。


15.7 常见错误与调试

❌ 网络编程高频错误清单

错误 后果 修正
端口忘 htons 本机测试正常、跨机连不上(或端口错乱) 一切进出协议的数字过字节序函数
没设 SO_REUSEADDR 重启程序 bind 失败 "Address already in use"(TIME_WAIT 残留) 监听 socket 创建后立刻设置
write 不循环补发 大数据发送不完整 循环写到全部完成(或用 send(fd, buf, n, MSG_NOSIGNAL))
read 当消息边界用 协议错乱、偶发解析失败 长度前缀/分隔符 + recv_full
忘处理 EINTR 被信号打断时误判为错误退出 错误处理分支 if (errno == EINTR) continue;
accept 返回的 fd 泄漏 连接数涨满(fd 耗尽 "Too many open files") 每条路径 close;异常分支也不例外
阻塞 I/O 卡死主流程 客户端不发数据,服务器整线程挂起 超时(SO_RCVTIMEO)或多路复用
收包长度不校验就分配 被恶意长度打爆内存 一切网络输入视为敌意,先验上限

调试工具链

# 1. 看端口监听状态
ss -tlnp | grep 9000            # Linux
netstat -an | findstr 9000      # Windows

# 2. 不写客户端也能测服务器(万能瑞士军刀)
nc 127.0.0.1 9000               # 交互式收发;nc -u 为 UDP

# 3. 抓包看真实字节(调试协议的显微镜)
sudo tcpdump -i lo -X port 9000          # Linux
# Wireshark(图形界面,TCP 流重组、协议解析,必学)

# 4. 连接状态排查(TIME_WAIT 堆积、CLOSE_WAIT 泄漏)
ss -s                            # 统计概览;大量 CLOSE_WAIT = 你忘了 close

调试方法论:分层定位——先用 nc 确认服务器收发正常(排除客户端代码问题),再用 ss/netstat 看连接状态(排除 fd 泄漏/端口问题),最后用 Wireshark/tcpdump 对比"应用层以为发了什么"与"网络上实际是什么"(字节序、长度头、粘包问题一网打尽)。

Windows (Winsock) 差异速查

#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")   /* MSVC 链接;MinGW: -lws2_32 */

/* 任何 socket 调用前必须初始化 */
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return 1;
/* ... 程序结束前 WSACleanup(); */

#define CLOSE_SOCKET(s) closesocket(s)
typedef int socklen_t;
/* ssize_t 不存在:用 int;read/write 换成 recv/send */
#else
#define CLOSE_SOCKET(s) close(s)
#endif

差异集中四点:WSAStartup/Cleanup 生命周期、closesocket vs close、recv/send vs read/write、错误码 WSAGetLastError() vs errno。第 21 章会把这层差异封装成跨平台头。


15.8 小结

  • socket 是网络端的 fd;TCP 流程 socket→bind→listen→accept / connect→read/write→close;监听 fd 与连接 fd 是两回事。
  • 字节序函数是协议编程的第一纪律:htons/htonl/ntohs/ntohl 一个不能漏。
  • TCP 无边界:长度前缀 + recv_full 循环补齐 + 长度上限校验,是变长消息的标准解法。
  • write 少写、read 读 0(EOF)、EINTR 打断——三大必须处理的常规事件。
  • 并发模型:少连接用线程,高并发用 epoll 事件驱动。
  • 调试分层:nc 验收 → ss 看状态 → Wireshark 看字节。

思考与练习

  1. 跑通 15.3/15.4 回显服务,用 nc 替代客户端测试;观察 ss -t 中的连接状态变化。
  2. 实现"长度前缀协议"的 echo:客户端每次发一条变长消息,服务器解包后回显;用两连发消息验证粘包处理正确。
  3. 给服务器加"每连接一线程"并发,两个客户端同时收发互不干扰;对比 select 版本的实现复杂度。
  4. 把服务器改成 select 多客户端版本(一个文件不超过 120 行),用 3 个 nc 客户端并发验证。
  5. 安全实验:把 recv_msg 的长度校验删掉,发送长度字段为 0xFFFFFFFF 的"消息",观察内存异常;恢复校验后总结"网络输入皆敌意"清单。

上一章:多线程与并发 | 下一章:调试与错误处理 →

📄 第 16 章 调试与错误处理 16-调试与错误处理.md

学习目标:掌握 C 的错误处理三件套(返回值约定、errno、assert);建立防御性编程习惯;熟练使用 GDB 的核心调试命令;会用 Valgrind、ASan/UBSan/TSan 定位内存与并发问题;搭建结构化的日志体系。


16.1 错误处理的三件套

1. 返回值约定:C 的主流错误通道

C 没有 try/catch。返回错误码是事实标准:

/* 惯例:0(或 >=0)表示成功,负值/特定枚举表示失败 */
typedef enum {
    ERR_OK = 0,
    ERR_INVALID,      /* 参数非法 */
    ERR_NOMEM,        /* 内存不足 */
    ERR_IO,           /* 读写失败 */
    ERR_TIMEOUT
} err_t;

err_t process_file(const char *path)
{
    if (path == NULL || path[0] == '\0')   /* 先验参数(防御性) */
        return ERR_INVALID;
    ...
    return ERR_OK;
}

err_t e = process_file("data.txt");
if (e != ERR_OK) {
    fprintf(stderr, "process failed: %d\n", e);
    return 1;
}

配套纪律:

  • 调用方必须检查:-Wunused-result(对标注 warn_unused_result 的函数)+ 团队规范。返回值被忽略 = 定时炸弹。
  • 错误要传播或处理,不许吞:要么 return 上抛,要么记日志+降级,唯独不能"收到了但什么都不做"。
  • "不可能失败"的返回值也可以设计成败值表达:返回指针的函数失败给 NULL(malloc/fopen/strchr);返回下标的给 -1;返回数量的给负数。
  • 错误码的设计见第 17 章(X-macro 统一管理)。

2. errno:系统调用的失败原因

系统调用与标准库失败时设置全局 errno(<errno.h>,线程局部):

#include <errno.h>
#include <string.h>

FILE *fp = fopen("no_such_file", "r");
if (fp == NULL) {
    fprintf(stderr, "fopen: %s\n", strerror(errno));  /* No such file or directory */
    /* perror("fopen"); 等价简写:打印 "fopen: ..." */
}

三条规则:

  1. 只在"函数已报告失败"时读 errno(先查返回值,再用 errno 找原因);函数成功时不保证 errno 不变。
  2. errno 是覆盖式的:两次失败的 errno 会互相覆盖,失败后尽快取。
  3. errno == EINTR(被信号打断)通常不算错误,重试即可(第 15 章 socket 循环里已用)。

3. assert:开发期的"逻辑哨兵"

#include <assert.h>

void ratio(int total, int passed, double *out)
{
    assert(total > 0);          /* 程序员视角:这是逻辑前提,违反=代码有 Bug */
    *out = (double)passed / total;
}
  • assert(expr):expr 为假时打印"文件/行/表达式"并 abort()。
  • 编译期开关:#define NDEBUG 后所有 assert 变空(发布版零成本)。
  • 定位:assert 是"开发期合同检查"——验证的是代码自身的正确性,不是运行期输入。用户输入、文件内容、网络数据等"外部可能非法"的东西,必须用正常错误处理,不能用 assert(发布版它消失了,检查也就没了——安全漏洞)。
assert(p != NULL);              /* ✅ 调用约定保证非空,违反=内部 Bug */
if (user_input == NULL)         /* ✅ 外部输入:真正的错误处理路径 */
    return ERR_INVALID;

16.2 防御性编程清单

  1. 信任边界:函数入口验证"约定内"的参数(NULL、范围、长度),外部输入一律按敌意处理。
  2. 检查每一个可能失败的调用:malloc、fopen、系统调用。新写代码时"失败分支与成功分支同时写"。
  3. 初始化一切:变量、指针(NULL)、结构体(calloc 或显式赋值)。
  4. free 后置 NULL、close 后失效句柄。
  5. 编译警告零容忍:-Wall -Wextra -Wpedantic -Werror——警告是免费的静态分析。
  6. 升级到静态分析:gcc -fanalyzer、clang --analyze、cppcheck——能抓空指针解引用、泄漏、未初始化。
  7. Undefine 侥幸:"在我机器上是好的"不是证据;换平台/换优化等级重测(UB 在 -O2 下才会现形的比比皆是)。

16.3 GDB 实战入门

gcc -g -O0 buggy.c -o buggy      # -g 调试符号;-O0 关优化(行号不漂移)
gdb ./buggy

核心命令速查(覆盖 90% 场景):

break main            / b 16        # 按函数名/行号/文件:行号设断点
run [args]            / r           # 启动(可带参数)
next                  / n           # 单步(跳过函数调用)
step                  / s           # 单步(进入函数)
finish                              # 跑完当前函数并显示返回值
continue              / c           # 继续运行到下一个断点
print expr            / p x         # 求值打印(p *p、p arr[2]、p sizeof(x))
display x                           # 每次停下自动显示 x
info locals                         # 当前帧所有局部变量
bt                                  # 调用栈回溯(段错误第一现场必看)
frame 2               / f 2         # 跳到栈的第 2 帧(查看上层调用者变量)
watch x                             # 硬件观察点:x 被改写时暂停(抓"谁改了我的变量")
info breakpoints      / i b         # 断点列表;delete N 删
x/16xw addr                         # 查看内存:16 个字(4字节)十六进制
ptype var                           # 打印变量类型定义
list                  / l           # 查看源码
thread apply all bt                 # 多线程:所有线程的栈(死锁分析)

崩溃后分析三板斧:

  1. Core dump(事后取证):
ulimit -c unlimited        # 允许生成 core
./buggy                    # 崩溃后生成 core 文件
gdb ./buggy core           # 事尸检验:崩溃瞬间所有线程的栈、变量值
  1. 段错误现场:GDB run 让程序自己崩,崩溃瞬间自动停在出错行——bt + print 涉事指针,多数段错误当场结案(NULL?野值?越界后的指针?)。
  2. 断点 + 条件:break 42 if i == 999——只在大循环最后一轮停下,省去单步到天荒地老。

16.4 Sanitizer 与 Valgrind:运行时检测全家桶

工具 抓什么 用法 代价
ASan 越界(堆/栈/全局)、use-after-free、double free、内存泄漏 gcc -g -fsanitize=address 2x 慢
UBSan 未定义行为:有符号溢出、坏移位、空指针算术、错误对齐 gcc -g -fsanitize=undefined ~1.1x 慢
TSan 数据竞争 gcc -g -fsanitize=thread 5-15x 慢,与 ASan 互斥
Valgrind 全部内存错误 + 未初始化读 + 泄漏(免重编译) valgrind --leak-check=full ./prog 10-50x 慢

标准工作流:测试构建永远带 -fsanitize=address,undefined。跑测试 = 自动化排雷:

gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined \
    -fno-omit-frame-pointer src/*.c test/*.c -o test_all && ./test_all

ASan 报告解读示例(故意制造 use-after-free):

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x... 
READ of size 4 at 0x... thread T0
    #0 0x... in main buggy.c:15          ← 案发:这里读了已释放内存
    #1 0x... in __libc_start_main
freed by thread T0 here:
    #0 0x... in free
    #1 0x... in main buggy.c:12          ← 死亡证明:这里 free 的
previously allocated by thread T0 here:
    #1 0x... in main buggy.c:10          ← 出生证明:这里 malloc 的

三段式报告(案发/释放/分配)直接给出完整破案链——这就是测试期全开 Sanitizer 的意义:内存 Bug 从"灵异现象"变成"带证据链的明确报错"。


16.5 日志体系

printf 调试的工程化升级——分级、带上下文、可关闭:

/* log.h */
#ifndef LOG_H
#define LOG_H
#include <stdio.h>

typedef enum { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR } log_level_t;

void log_set_level(log_level_t lv);

#define LOG_DEBUG(fmt, ...) log_write(LOG_DEBUG, __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...)  log_write(LOG_INFO,  __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...)  log_write(LOG_WARN,  __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) log_write(LOG_ERROR, __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)

void log_write(log_level_t lv, const char *file, int line,
               const char *func, const char *fmt, ...);
#endif
/* log.c —— 简单实现(生产可用更完善的 log4c/自研异步日志) */
#include "log.h"
#include <stdarg.h>
#include <time.h>

static log_level_t g_level = LOG_INFO;

void log_set_level(log_level_t lv) { g_level = lv; }

void log_write(log_level_t lv, const char *file, int line,
               const char *func, const char *fmt, ...)
{
    static const char *tag[] = { "DEBUG", "INFO", "WARN", "ERROR" };
    if (lv < g_level)
        return;                              /* 低于当前级别的直接丢弃 */

    time_t now = time(NULL);
    char ts[32];
    strftime(ts, sizeof(ts), "%H:%M:%S", localtime(&now));

    fprintf(stderr, "[%s %s %s:%d] ", tag[lv], ts, file, line);

    va_list ap;
    va_start(ap, fmt);
    vfprintf(stderr, fmt, ap);               /* 把可变参数转发给 vfprintf */
    va_end(ap);

    fputc('\n', stderr);
}

使用与分级原则:

LOG_DEBUG("parsed %zu records, first=%s", n, first_name);  /* 开发细节:默认关闭 */
LOG_INFO("server listening on port %d", port);             /* 关键里程碑 */
LOG_WARN("retry %d/3 for %s", retry, path);                /* 可自动恢复的异常 */
LOG_ERROR("db write failed: %s", strerror(errno));         /* 需要人介入的问题 */

要点:错误日志必须带上下文(什么操作、什么参数、errno 是什么),一条日志能定位问题才算合格;多线程程序加锁写日志或用线程安全库;性能敏感路径的 DEBUG 日志先判级别再格式化(上面的实现已保证:级别不够直接 return,不付出格式化成本)。


16.6 调试方法论:从症状到根因

  1. 稳定复现:不能复现的问题不能修复。加压、加随机、缩短周期,把偶发变必现;实在不能复现 → 上 core dump / 全量日志。
  2. 二分定位:怀疑范围对半砍——注释代码块、二分提交历史(git bisect)、二分数据集。
  3. 让工具先说话:段错误先看 GDB 现场;内存怪象先跑 ASan/Valgrind;竞态先跑 TSan。人肉盯着代码找内存 Bug 是最低效路径。
  4. 假设-验证循环:提出一个可证伪的假设("是 realloc 覆盖了旧指针"),设计最小实验验证(打印 realloc 前后指针值),而不是改一通"试试看"。
  5. 修完后复测:用原复现用例 + Sanitizer + 回归测试确认修复,并思考同类问题还在哪里(一个 free 泄漏背后常有兄弟 Bug)。
  6. 写进笔记/测试:把根因转成一条回归测试用例,让 Bug 永不复发。

16.7 小结

  • 错误处理三件套:错误码返回(必查)、errno(失败后取)、assert(逻辑合同,非输入校验)。
  • 防御性编程:验证边界、初始化一切、警告零容忍、静态分析加持。
  • GDB 六板斧:断点、单步、print、bt、watchpoint、core dump 事后分析。
  • Sanitizer 全家桶按症状选用:内存→ASan、UB→UBSan、竞态→TSan、免编译→Valgrind;测试期全开是现代 C 工程底线。
  • 日志分级 + 上下文 + 可关闭;错误日志要"一条定位问题"。
  • 调试方法论:复现 → 二分 → 工具定罪 → 假设验证 → 回归固化。

思考与练习

  1. 给第 12 章的哈希表加完整错误处理:所有失败路径返回错误码,用 ASan 验证无泄漏。
  2. 写一个会段错误的程序(解引用 NULL),分别用 GDB 直接运行与 core dump 两种方式定位到出错行。
  3. 用 UBSan 抓现行:写 int x = INT_MAX; x++; 与 1 << 31,观察运行时报错。
  4. 实现 LOG 宏体系,在多线程程序中并发打日志 10 万条,检查输出是否交错损坏;思考修复方案。
  5. 综合复盘:挑你写过的最长一个程序,跑一遍 ASan + UBSan + cppcheck + gcc -fanalyzer,收集全部报告并逐条修复。

上一章:网络编程 | 下一章:代码规范与模块化设计 →

📄 第 17 章 代码规范与模块化设计 17-代码规范与模块化设计.md

学习目标:建立团队可执行的命名/格式/注释规范;掌握模块划分与接口设计原则;掌握信息隐藏的两种 C 手法(不透明指针、前向声明);设计统一的错误码与错误传播策略;理解"规范服务于可读性"的取舍观。


17.1 为什么 C 尤其需要规范

C 没有命名空间、没有异常、没有类——约束全靠自觉与约定。一套规范的直接收益:任何人接手任何模块,结构与风格无缝衔接;代码评审聚焦逻辑而非格式吵架;Bug 藏身处(命名歧义、隐藏依赖、隐式转换)被规则提前封死。

规范的本质:让正确的事容易做,让错误的事一眼可见。


17.2 命名规范

命名风格总表(本书全篇遵循)

对象 风格 示例
函数 小写下划线,模块_动词 或 模块_名词_动词 stack_push、config_load、list_find_node
局部变量/参数 小写下划线 buf_size、retry_count
结构体/枚举类型 小写下划线 + _t 后缀(或 PascalCase,团队二选一) task_queue_t、err_t
枚举成员/常量宏 大写下划线,带模块前缀 TASK_RUNNING、MAX_CONN
全局变量(慎用) g_ 前缀 g_config、g_shutdown
static 文件内变量 小写下划线 s_instance 或无前缀
指针参数表"输出" _out 后缀 result_out、len_out
布尔函数 is_/has_/can_ 前缀 is_empty、has_next

命名质量三问

  1. 见名知义:calc_total_price > calc > c。缩写仅用公认集合(buf/cnt/idx/len),自造缩写是噪音。
  2. 单位与语义入名:timeout_ms 而非 timeout(毫秒?秒?);name_cstr(以 \0 结尾)与 name_len(不含 \0)成对出现防歧义。
  3. 一致性压倒一切:load/save 全项目统一,别一处 open_file 另一处 file_read。

❌ 反面清单:int temp;(什么温度?临时什么?)、process2(改版另起名或删旧版)、data1/data2(数据无名等于无意义)、匈牙利前缀(dwSize 类,现代 IDE 时代已淘汰)。


17.3 格式规范

工具自动化的时代,格式不应消耗人的注意力:

/* 本书的基准风格(类 K&R / Linux 风格变体):4 空格缩进、括号换行随团队 */
int stack_push(stack_t *s, int value)
{
    if (s == NULL || s->len == s->cap)     /* 复杂条件:括号分组表意图 */
        return -1;

    s->data[s->len++] = value;
    return 0;
}

硬性建议:

  • 缩进用 4 空格(或 2/8,团队统一),禁 Tab 混用;
  • 单行长度上限 ~100 列,超长参数换行对齐;
  • 一行一语句;if/else/for/while 必带花括号(即使单句——4 章悬空 else、17 章末 Apple goto fail 案例的共同教训);
  • 空行分块:函数内按"参数校验 / 核心逻辑 / 收尾"分段;
  • 用格式化工具固化:clang-format(配置文件进版本库),CI 里 clang-format --dry-run 拦截不合规提交——格式吵架从此绝迹。

17.4 注释规范

注释解释"为什么",代码解释"怎么做"。复述代码的注释是负资产:

i++;                    /* ❌ i 自增 1 —— 废话 */
i++;                    /* 跳过起始分隔符 */   /* ✅ 说代码看不出的意图 */

必写注释的位置:

  1. 文件头:模块职责、所有权、并发约束;
  2. 公开函数(头文件里):参数含义与单位、返回值、错误码、所有权(谁分配谁释放)、线程安全性、副作用——这是 API 文档本体:
/**
 * 从文件加载配置到内存。
 *
 * @param path        配置文件路径(UTF-8)
 * @param cfg_out     成功时写入新分配的 config_t*,调用方负责 config_free()
 * @return ERR_OK 成功;ERR_IO 文件无法读取;ERR_INVALID 内容格式错误
 * @note 线程安全:是(无共享状态)
 */
err_t config_load(const char *path, config_t **cfg_out);
  1. 技巧/反直觉代码:为什么这样写("倍增扩容以保证均摊 O(1)");
  2. workaround:绕过的坑、关联的 issue 编号、移除条件(/* FIXME(jli): remove after v2.3 (issue #142) */);
  3. 魔法的数字:就地定义成带名常量,而不是注释解释 8192。

文档生成:Doxygen 可从上述注释自动生成 HTML 手册(@param/@return/@note 是其标准标记)。


17.5 模块化设计:C 的"类"怎么写

模块 = 一对 .h/.c + 明确边界

src/
├── include/
│   ├── config.h        ← 模块:配置(对外接口)
│   ├── logger.h        ← 模块:日志
│   └── session.h       ← 模块:会话管理
├── config.c
├── logger.c
└── session.c

模块设计原则:

  1. 单一职责:一个模块回答一个"它负责什么";描述时说不出一句话(或要说三句)都要重组。
  2. 单向依赖:session 可以用 config/logger,logger 不许反过来用 session——依赖图无环。环状依赖 = 设计错误(第 11 章 extern 互相包含就是典型症状)。
  3. 接口窄化:头文件暴露的函数越少越好;一切内部函数 static(第 11 章纪律)。
  4. 分层:基础层(内存/日志)← 中间层(容器/配置)← 业务层。上层可调下层,反向禁止。

信息隐藏手法一:不透明指针(opaque pointer)

把结构体定义藏进 .c,头文件只给前向声明——外界只能拿指针,摸不到成员,C 的"private":

/* session.h —— 外界视角 */
#ifndef SESSION_H
#define SESSION_H
#include <stddef.h>

typedef struct session session_t;      /* 前向声明:不透露任何成员 */

session_t *session_create(unsigned id);
void       session_destroy(session_t *s);
int        session_send(session_t *s, const char *data, size_t len);
unsigned   session_id(const session_t *s);

#endif
/* session.c —— 内部视角 */
#include "session.h"
#include <stdlib.h>
#include <string.h>

struct session {                       /* 定义只在这里:外界无法解引用 */
    unsigned    id;
    int         fd;
    char       *name;
    size_t      bytes_sent;
    struct session *next;              /* 内部细节随便改,外部零影响 */
};
/* ... 实现 ... */

价值:实现细节可以随意重构(改字段、换数据结构)而不动任何使用者;编译隔离(头文件改动波及面归零);误用编译期拦截(外界想 s->fd 直接编译错误)。这是 C 工程库设计的标准形态(GObject、OpenSSL、SQLite 皆是)。

代价:外界无法栈上分配对象(不知道大小)、无法直接遍历成员——都通过模块提供的函数完成。权衡默认取隐藏,性能敏感的内部模块才全透明。

信息隐藏手法二:get/set 而非公开成员

结构体必须公开时(小项目、性能热点),成员不直接暴露语义操作,仍通过函数进出(config_get_int(cfg, "timeout")),把合法性检查、通知、加锁的机会留在函数内。


17.6 错误处理的设计规范

统一错误码

全项目一套 err_t(第 16 章),用 X-macro 单点维护枚举与消息(第 11 章技法的落地):

/* errors.def —— 唯一需要编辑的清单 */
ERROR_DEF(ERR_OK,        "success")
ERROR_DEF(ERR_INVALID,   "invalid argument")
ERROR_DEF(ERR_NOMEM,     "out of memory")
ERROR_DEF(ERR_IO,        "io error")
ERROR_DEF(ERR_TIMEOUT,   "operation timed out")

/* errors.h —— 自动生成两侧 */
#ifndef ERRORS_H
#define ERRORS_H
#define ERROR_DEF(id, msg) id,
typedef enum { ERRORS_DEF_LIST } err_t;
#undef ERROR_DEF
const char *err_msg(err_t e);          /* err_msg(ERR_NOMEM) → "out of memory" */
#endif

新增错误只改 .def 一处,枚举/字符串表永不脱节。规则:错误码全局唯一(带模块前缀如 NET_TIMEOUT)、绝不复用已有码表新义、跨模块传播原样透传不悄悄转义。

错误传播三选一(全项目统一)

  1. 返回码上抛(主流):每层 if (e != ERR_OK) return e;,或带上下文包装(return log_and_return(ERR_IO, "write %s", path););
  2. 回调通知(异步/库回调场景):on_error(user_ctx, ERR_IO, detail);
  3. 约定输出参数:仅在返回值已被业务数据占用的接口(int find(..., err_t *err_out))。

禁区:静默吞错、用打印代替处理、exit() 出现在库代码(只有 main/框架层有权决定退出)、errno 与自定义码混用不区分。


17.7 所有权与内存契约

第 8 章原则的接口化表述——每个接口在文档里回答三个问题:

  1. 这个函数分配了什么?谁负责释放?("成功时 *out 由调用方 free")
  2. 传入的指针谁拥有?函数会保存它吗?("仅借用,不保存" vs "接管所有权")
  3. 失败时资源状态如何?("失败时无副作用"——强异常保证)
/* 契约示例:所有权标注完整的接口 */
/* strlist_add: 复制 str(仅借用),失败返回 ERR_NOMEM 且无副作用 */
/* strlist_take: 移出第 i 项,所有权移交调用方(调用方 free) */
err_t strlist_add(strlist_t *sl, const char *str);
char *strlist_take(strlist_t *sl, size_t i);

成对函数纪律:create/destroy、open/close、init/deinit、lock/unlock——名字成对出现,调用路径成对走完(每个 return 路径清点,第 8 章)。


17.8 案例研究:Apple "goto fail" 漏洞

2014 年 iOS/OSX 的著名 SSL 验证漏洞,C 编码纪律的反面教材:

if ((err = SSLHashSHA1.update(&hashCtx, &serverKeyParams)) != 0)
    goto fail;
    goto fail;                      /* ← 多出来的一行(合并冲突残留) */
if ((err = SSLHashSHA1.final(&hashCtx, &out)) != 0)
    goto fail;
...
fail:
    ...cleanup...

第二个 goto fail 无条件执行:跳过证书签名校验直接到失败标签,函数返回"成功"——中间人攻击畅行无阻。教训清单:

  1. if/else 必带花括号:此代码每个条件都无花括号,多出的 goto 静默生效;带花括号+合理缩进的版本,多出的一行立刻扎眼;
  2. -Wall 的 -Wunreachable-code/编译器告警与静态分析可拦(现代 clang 直接报 "will never be executed");
  3. 评审流程:这类"看起来无害"的 diff 恰是最该被机器+人双重把关的。

规范不是形式主义——这条漏洞的价值等价于"花括号纪律"四个字。


17.9 调试方法:规范类问题怎么查

  • 格式/风格争议:交给 clang-format/clang-tidy(bugprone-*、readability-* 检查族),机器裁判省人力;
  • 命名冲突:nm *.o 查重名符号;static 缺失导致的全局命名污染,编译时 -Wmissing-prototypes 辅助;
  • 隐藏依赖(模块偷偷用全局):grep 全局变量所有引用点,依赖图工具(如 cinclude2dot)画包含图找环;
  • 契约违背(内存所有权混乱):ASan 报告常是第一现场;接口注释补全契约是长期解。

17.10 小结

  • 命名:模块前缀 + 见名知义 + 单位入名;类型/宏/函数各有专属风格;一致性 > 个性。
  • 格式交给 clang-format;if 必带花括号(goto fail 的血训)。
  • 注释写"为什么":公开接口的参数/返回/所有权/线程安全是文档本体。
  • 模块化:单一职责、依赖无环、接口窄化;不透明指针是 C 的 private,实现可自由重构。
  • 错误码 X-macro 单点维护;传播策略全项目三选一;库代码禁 exit。
  • 所有权三问进接口文档:谁分配?谁保存?失败时状态?

思考与练习

  1. 把你此前的某个多文件练习重构为不透明指针风格库,验证"外部文件无法访问成员"的编译拦截。
  2. 为一个虚构项目写 errors.def(15 个错误码),实现 err_msg,Doxygen 或注释验证接口文档完整。
  3. 给第 14 章线程池头文件补全所有权与线程安全注释,逐函数过"三问"。
  4. 用 clang-format(或手工)把一段风格混乱的 50 行代码规范化,diff 对比。
  5. 复现 goto fail:写一个无花括号 if 的函数,插入多余 goto,观察 clang 什么告警能/不能抓到。

上一章:调试与错误处理 | 下一章:构建与依赖管理 →

📄 第 18 章 构建与依赖管理 18-构建与依赖管理.md

学习目标:理解构建系统的职责;能手写工程级 Makefile(增量编译、自动依赖、目标分层);掌握 CMake 项目模板与常用命令;了解第三方依赖管理的几种模式(系统库、vcpkg/Conan、源码内置);建立 CI 与发布的基本框架。


18.1 构建系统解决什么问题

第 11 章我们手动敲过:gcc -Iinclude src/main.c src/stack.c -o app。工程一放大,手敲立刻崩溃:

  • 100 个 .c 文件,改 1 个却全量重编(慢);
  • 编译参数(-I、-D、警告等级)散落在终端历史里(不可复现);
  • 换平台(Linux/macOS/Windows)命令完全不同(不可移植);
  • 第三方库的头文件路径、链接参数、版本依赖靠口头相传(不可维护)。

构建系统 = 把"怎么编译"写成代码:声明式描述目标与依赖,自动算出最小重编集合,跨平台生成对应命令。


18.2 Makefile:理解构建的最小模型

基本语法与第一个 Makefile

Makefile 由规则组成:目标、依赖、命令(命令前必须是 Tab,不是空格——头号新手坑):

# 目标: 依赖
# <Tab>命令

app: main.o stack.o
	gcc main.o stack.o -o app

main.o: main.c stack.h
	gcc -Iinclude -c main.c -o main.o

stack.o: stack.c stack.h
	gcc -Iinclude -c stack.c -o stack.o

clean:
	rm -f app *.o

运行 make 时:目标不存在或依赖比目标新 → 执行命令。Make 的核心价值就在这句:增量编译——改了 stack.c 只有 stack.o 和 app 重编。

make            # 构建默认目标(第一个目标)
make clean      # 清理

工程级 Makefile 模板

变量、自动变量、模式规则、目录遍历一体的实战模板:

# ===== 可配置区 =====
CC      := gcc
CFLAGS  := -std=c17 -Wall -Wextra -Wpedantic -g
LDFLAGS :=
LDLIBS  := -lm
SRC_DIR := src
OBJ_DIR := build
INC_DIR := include
TARGET  := app

# ===== 自动收集源文件 =====
SRCS := $(wildcard $(SRC_DIR)/*.c)
OBJS := $(patsubst $(SRC_DIR)/%.c,$(OBJ_DIR)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)                     # 依赖文件(头文件变更追踪)

# ===== 顶层目标 =====
.PHONY: all clean test
all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS)   # $^=全部依赖  $@=目标名

# ===== 模式规则:%.o 由 %.c 编译 =====
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
	$(CC) $(CFLAGS) -I$(INC_DIR) -MMD -MP -c $< -o $@   # $<=首个依赖(源文件)
                                                   # -MMD 生成 .d 依赖文件

$(OBJ_DIR):
	mkdir -p $(OBJ_DIR)

# 引入自动生成的依赖:头文件改动也能触发正确的重编
-include $(DEPS)

# ===== 其他目标 =====
test: $(TARGET)
	./run_tests.sh

clean:
	rm -rf $(OBJ_DIR) $(TARGET)

关键机制解析:

  • 自动变量:$@ 目标、$< 第一个依赖、$^ 全部依赖;
  • 模式规则 %.o: %.c:一条规则覆盖所有源文件,新增 .c 零修改;
  • 头文件依赖追踪:-MMD -MP 让编译器顺手生成 .d 文件(记录"哪个 .o 依赖哪些 .h"),-include 收编——改了头文件也会正确重编。没有这一步,"改了 .h 却没重编引用方"是 Make 工程的经典事故;
  • .PHONY:声明伪目标(clean 等不代表文件),防止同名文件干扰。

❌ Makefile 常见错误:命令前用空格(missing separator 报错);.o 规则漏头文件依赖(诡异的不重编);变量赋值用 = 却被环境变量覆盖(用 := 立即求值更稳)。


18.3 CMake:跨平台的现代主流

Makefile 直接写死了编译命令,跨平台(MSVC/NMake/Xcode)力不从心。CMake 是构建系统的生成器:你写平台无关的 CMakeLists.txt,它按平台生成 Makefile/Ninja 工程/Visual Studio 工程。

工程模板

project/
├── CMakeLists.txt
├── include/mylib/mylib.h
├── src/mylib.c
├── app/main.c
├── tests/test_mylib.c
└── third_party/          ← 依赖(18.4)
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(myapp VERSION 1.0.0 LANGUAGES C)

set(CMAKE_C_STANDARD 17)            # -std=c17
set(CMAKE_C_STANDARD_REQUIRED ON)

# 库目标:静态库
add_library(mylib STATIC
    src/mylib.c
)
target_include_directories(mylib PUBLIC include)   # PUBLIC:链接者也自动获得 include
target_compile_options(mylib PRIVATE -Wall -Wextra -Wpedantic)

# 可执行目标
add_executable(app app/main.c)
target_link_libraries(app PRIVATE mylib)           # 自动带上 mylib 的 include 与链接

# 测试(接入 CTest)
enable_testing()
add_executable(test_mylib tests/test_mylib.c)
target_link_libraries(test_mylib PRIVATE mylib)
add_test(NAME mylib_tests COMMAND test_mylib)

使用流程(out-of-source 构建:产物全在 build/,源码树永远干净):

cmake -B build -DCMAKE_BUILD_TYPE=Debug      # 配置(生成 build/ 下的工程)
cmake --build build                          # 编译(自动并行)
ctest --test-dir build                       # 跑测试
cmake --build build --target app             # 只构建某个目标
cmake -B build-release -DCMAKE_BUILD_TYPE=Release   # Release:-O3 -DNDEBUG

CMake 核心概念速记

  • 目标为中心:add_library/add_executable 定义目标,属性(include/链接/宏/选项)挂在目标上自动传播——永远用 target_* 系列,别用全局 include_directories/link_libraries(老写法,传染依赖);
  • PUBLIC/PRIVATE/INTERFACE:依赖传播三态——PUBLIC 连接者也继承、PRIVATE 仅自己用、INTERFACE 仅传递不消费;
  • 构建类型:Debug(-g)/Release(-O3 -DNDEBUG)/RelWithDebInfo——NDEBUG 会关掉所有 assert(第 16 章),发布前回归测试必须两种类型都跑;
  • 条件:if(WIN32) ... endif() 处理平台差异(第 21 章跨平台收口点)。

Makefile vs CMake 怎么选:学习构建原理/小型单目录项目 → Makefile;正式工程/跨平台/团队协作 → CMake(事实标准)。两者都要会读。


18.4 依赖管理

C 没有官方包管理器,工程上四种模式(按推荐顺序):

模式一:源码内置(vendoring)——嵌入式与中小项目的务实之选

把依赖源码直接放进 third_party/,随项目编译:

# 直接把第三方库作为子目录纳入构建
add_subdirectory(third_party/sqlite)
target_link_libraries(myapp PRIVATE sqlite3)
  • 优点:构建自包含(clone 即可编,无需联网装库)、版本锁定天然成立;
  • 缺点:仓库变大、升级靠手工;协议合规必须审查(GPL 传染等,license 目录留档)。

变体:git submodule——仓库引用而非拷贝,git submodule update --init 拉取;升级 = 移动 submodule 指针,可审计(大多数开源 C 项目此模式)。

模式二:系统包管理器(Linux 服务端传统)

sudo apt install libcurl4-openssl-dev        # 装头文件+库
pkg-config --cflags --libs libcurl           # 查编译参数
gcc app.c $(pkg-config --cflags --libs libcurl) -o app
find_package(CURL REQUIRED)                  # CMake 找系统库
target_link_libraries(app PRIVATE CURL::libcurl)
  • 优点:系统统一升级、安全补丁随发行版走;
  • 缺点:版本不可控("我这能编你那不能")、Windows 上没有等价物。服务器内部项目适用,对外发布产品慎用。

模式三:现代 C++/C 包管理器(vcpkg / Conan)

# vcpkg(微软系,CMake 集成丝滑)
vcpkg install fmt cjson
cmake -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
// vcpkg.json(清单模式:依赖进版本库,可复现)
{
  "name": "myapp",
  "version": "1.0.0",
  "dependencies": [ "cjson", "libuv" ]
}
  • 优点:跨平台、版本可锁、依赖清单化——最接近现代工程体验;
  • 缺点:生态以 C++ 库为主,纯 C 的长尾库覆盖不全。

依赖管理的通用纪律

  1. 版本锁定:任何模式都要能回答"三年后这台机器还能编出同样的二进制吗"(vendoring 提交进 git / submodule 固定 commit / vcpkg.json + baseline);
  2. 最小依赖:每加一个依赖问一遍"自己写 200 行能不能解决"——C 生态拉依赖的运维成本(ABI、安全通告、协议)经常超过收益;
  3. License 审查:MIT/Apache/Zlib 宽松;LGPL 动态链接注意;GPL 传染——进 third_party 前过法务清单;
  4. 禁拷贝二进制:third_party 里出现 .a/.dll 却没有源码与构建脚本 = 不可维护定时炸弹。

18.5 工程目录与 CI

推荐目录布局(中小型 C 工程)

project/
├── CMakeLists.txt
├── include/          ← 对外头文件(安装目标)
├── src/              ← 实现
├── app/              ← 可执行入口(可与 src 分离)
├── tests/            ← 单元测试(第 19 章)
├── third_party/      ← 外部依赖
├── docs/             ← 设计文档
├── .github/workflows/ci.yml   ← CI 配置
├── .clang-format     ← 格式规范(第 17 章)
└── README.md

CI 最小实践(GitHub Actions)

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure
        run: cmake -B build -DCMAKE_BUILD_TYPE=Debug
      - name: Build (warnings as errors, sanitizers on)
        run: |
          cmake --build build -- -j
      - name: Test
        run: ctest --test-dir build --output-on-failure

CI 黄金法则:本地能跑的命令 CI 原样跑。进阶标配:ASan/UBSan 构建单独 job、clang-format 检查、静态分析(cppcheck/clang-tidy)job、发布前 Release 构建 + 冒烟测试。

版本与发布要点

  • 语义化版本 MAJOR.MINOR.PATCH(接口不兼容/新增/修复);
  • 库的 ABI 意识:改结构体布局/函数签名 = MAJOR 升级(动态库用户会崩);
  • 发布产物:源码 tarball +(可选)各平台二进制 + 校验和 + 变更日志;
  • git tag v1.2.3 与版本号宏联动:add_definitions(-DMYAPP_VERSION="1.2.3")。

18.6 调试方法:构建问题怎么查

症状 排查
undefined reference 链接清单缺目标/库顺序(GCC 库依赖从右到左);ldd/nm 查符号;-Wl,--verbose 看链接过程
改了 .h 没重编 Makefile 缺 .d 依赖追踪;或旧构建目录脏——rm -rf build 重建(万能重启法)
找不到头文件 -I 路径错;gcc -H 或 cmake ... -DCMAKE_VERBOSE_MAKEFILE=ON 看真实命令
找不到库文件 ldconfig -p(系统库缓存)、ldd ./app(运行期解析);CMake 里 target_link_libraries 路径
"我这能编" 版本不一致:cmake --version、编译器版本、CMAKE_BUILD_TYPE 是否相同;清 build 目录重来
运行时找不到 .so RPATH 未设:cmake -DCMAKE_BUILD_RPATH=... 或安装到系统路径

万能三步:删 build 目录 → 重新配置 → 看 verbose 输出逐条核对命令。构建系统的问题 90% 是"缓存了错误状态"或"命令与预期不符",verbose 输出是唯一真相。


18.7 小结

  • 构建系统 = 把编译知识写成可复现代码:增量编译、参数集中、跨平台。
  • Makefile 核心:目标/依赖/命令 + 模式规则 + -MMD 头文件依赖追踪;命令前是 Tab。
  • CMake 以目标为中心:target_* 三态传播;Debug/Release 两种构建都要过测试。
  • 依赖四模式:vendoring(自包含)、系统库(服务器)、vcpkg/Conan(现代)、submodule;版本锁定 + license 审查是底线。
  • CI = 本地命令原样上云;构建问题先删缓存看 verbose。

思考与练习

  1. 把第 12 章的数据结构库做成完整工程:CMake + include/src/tests 目录 + -MMD 的 Makefile 双构建,验证"改一个头文件只重编相关 .o"。
  2. 用 vcpkg 装一个 JSON 库(如 cJSON),实现"读入 JSON 配置并打印"的最小程序,全程清单模式。
  3. 给项目接 GitHub Actions:Debug + ASan 两个 job,故意提交一个内存错误代码,观察 CI 拦截。
  4. 故意制造三种构建错误(链接缺库、头文件路径错、构建目录脏),用 18.6 的方法逐一排查并记录。
  5. 为你的库写 cmake --install 安装规则(头文件进 include、库进 lib),并在另一个 CMake 工程里 find_package 使用它。

上一章:代码规范与模块化设计 | 下一章:单元测试 →

📄 第 19 章 单元测试 19-单元测试.md

学习目标:建立"代码必须带测试"的工程观;从零实现一个极简测试框架(理解测试框架的本质);掌握 Unity / CMocka 的基本用法;掌握 C 代码的可测性设计(依赖注入、接口隔离);了解覆盖率与测试策略。


19.1 为什么 C 尤其需要测试

C 的运行时错误(越界、泄漏、悬垂)不报错、静默腐败——没有测试网的 C 项目,问题往往在客户现场以"随机崩溃"形式爆发,排查成本十倍于开发成本。测试的复利:

  1. 回归防线:改一处旧代码,跑一遍测试立刻知道有没有改崩别处(C 的全局命名空间与指针算术让"别处"格外容易中招);
  2. 设计压力:写不了测试的模块,多半耦合混乱——测试倒逼好设计;
  3. 活文档:测试用例就是"这个函数在边界输入下应该怎样"的机器可执行说明;
  4. 配合 Sanitizer:测试 + ASan/TSan(第 16 章)= 自动化排雷,CI 每次提交全量执行(第 18 章)。

测试金字塔在 C 的落法:大量单元测试(单个函数/模块)+ 少量集成测试(模块协作,如"配置加载→解析→查询"链路)+ 极少端到端(整个程序跑真实输入)。


19.2 从零写一个极简测试框架

测试框架的本质小到令人惊讶——一个宏 + 一个计数器:

/* minctest.h —— 60 行以内自研测试框架 */
#ifndef MINCTEST_H
#define MINCTEST_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int mt_run = 0, mt_fail = 0;
static const char *mt_current = "";

#define MT_RUN(name) do {                     \
    mt_current = #name;                        \
    mt_run++;                                  \
    printf("---- %s ----\n", mt_current);      \
} while (0)

#define MT_CHECK(cond) do {                                       \
    if (!(cond)) {                                                \
        mt_fail++;                                                \
        printf("[FAIL] %s:%d  %s\n  condition: %s\n",             \
               __FILE__, __LINE__, mt_current, #cond);            \
    }                                                             \
} while (0)

#define MT_CHECK_EQ_INT(a, b) do {                            \
    long long _a = (long long)(a), _b = (long long)(b);       \
    if (_a != _b) {                                           \
        mt_fail++;                                            \
        printf("[FAIL] %s:%d  %s\n  expected %lld, got %lld\n",\
               __FILE__, __LINE__, mt_current, _b, _a);       \
    }                                                         \
} while (0)

#define MT_CHECK_EQ_STR(a, b) do {                            \
    const char *_a = (a), *_b = (b);                          \
    if (_a == NULL || _b == NULL || strcmp(_a, _b) != 0) {    \
        mt_fail++;                                            \
        printf("[FAIL] %s:%d  %s\n  expected \"%s\", got \"%s\"\n", \
               __FILE__, __LINE__, mt_current,                \
               _b ? _b : "(null)", _a ? _a : "(null)");       \
    }                                                         \
} while (0)

#define MT_SUMMARY() do {                                     \
    printf("\n%d tests, %d failures\n", mt_run, mt_fail);     \
    return (mt_fail == 0) ? EXIT_SUCCESS : EXIT_FAILURE;      \
} while (0)

#endif

使用:

/* test_vec.c */
#include "minctest.h"
#include "vec.h"

static void test_push_and_get(void)
{
    MT_RUN(vec_push_get);

    vec_t v;
    MT_CHECK(vec_init(&v, sizeof(int), 0) == 0);

    for (int i = 0; i < 100; i++)
        MT_CHECK(vec_push(&v, &i) == 0);

    MT_CHECK_EQ_INT(vec_at(&v, 0) == NULL, 0);        /* 首元素存在 */
    int got;
    memcpy(&got, vec_at(&v, 42), sizeof(got));
    MT_CHECK_EQ_INT(got, 42);

    MT_CHECK(vec_at(&v, 100) == NULL);                /* 越界返回 NULL */

    vec_free(&v);
}

static void test_empty(void)
{
    MT_RUN(vec_empty);

    vec_t v;
    vec_init(&v, sizeof(int), 0);
    MT_CHECK(vec_at(&v, 0) == NULL);                  /* 空容器任何访问为 NULL */
    vec_free(&v);
}

int main(void)
{
    test_push_and_get();
    test_empty();
    MT_SUMMARY();
}

失败输出精确到文件行号与实际/期望值。这个 60 行框架就足以覆盖绝大多数单元测试需求——理解它之后,看 Unity/CMocka 的 API 只是同构物的豪华版。


19.3 Unity:最流行的 C 测试框架

Unity 是三个文件的纯 C 框架(unity.c/h + 支持),嵌入项目零依赖,嵌入式领域事实标准:

/* test_stack.c —— Unity 版 */
#include "unity.h"
#include "stack.h"

static stack_t *s;

void setUp(void)          /* 每个测试前自动执行:全新夹具 */
{
    s = stack_create(4);
    TEST_ASSERT_NOT_NULL(s);
}

void tearDown(void)       /* 每个测试后自动执行:清理,保证测试互不污染 */
{
    stack_destroy(s);
    s = NULL;
}

void test_push_then_pop_returns_last_value(void)
{
    TEST_ASSERT_EQUAL_INT(0, stack_push(s, 10));
    TEST_ASSERT_EQUAL_INT(0, stack_push(s, 20));

    int out = 0;
    TEST_ASSERT_EQUAL_INT(0, stack_pop(s, &out));
    TEST_ASSERT_EQUAL_INT(20, out);          /* 后进先出 */
    TEST_ASSERT_EQUAL_INT(0, stack_pop(s, &out));
    TEST_ASSERT_EQUAL_INT(10, out);
}

void test_pop_from_empty_fails(void)
{
    int out;
    TEST_ASSERT_EQUAL_INT(-1, stack_pop(s, &out));   /* 空栈 pop 返回失败 */
}

void test_push_beyond_capacity_fails(void)
{
    for (int i = 0; i < 4; i++)
        TEST_ASSERT_EQUAL_INT(0, stack_push(s, i));
    TEST_ASSERT_EQUAL_INT(-1, stack_push(s, 99));    /* 满栈拒绝 */
}

int main(void)
{
    UNITY_BEGIN();                    /* 收集全部 test_ 开头函数后运行 */
    RUN_TEST(test_push_then_pop_returns_last_value);
    RUN_TEST(test_pop_from_empty_fails);
    RUN_TEST(test_push_beyond_capacity_fails);
    return UNITY_END();
}

常用断言族:TEST_ASSERT_EQUAL_INT / NOT_EQUAL、TEST_ASSERT_TRUE / FALSE / NULL / NOT_NULL、TEST_ASSERT_EQUAL_MEMORY(a, b, n)(字节比较)、TEST_ASSERT_EQUAL_STRING、浮点用 TEST_ASSERT_DOUBLE_WITHIN(delta, expected, actual)(浮点等值必须带容差,第 2 章呼应)。

setUp/tearDown 的价值:每个测试都在全新状态运行(本例重建栈),测试之间零耦合——顺序无关、可单跑、可并行。


19.4 CMocka:带 Mock 的进阶选择

CMocka 特色是 mock(打桩):替换被依赖的函数,控制其返回值并记录调用。适合"被测代码依赖外部系统(网络/文件/时间)"的场景:

/* 被测代码:同步天气服务 */
typedef struct {
    int  (*fetch)(void *ctx, double *temp_out);   /* 依赖通过函数指针注入 */
    void *ctx;
} weather_client_t;

int weather_is_freezing(const weather_client_t *w)
{
    double t;
    if (w->fetch(w->ctx, &t) != 0)
        return -1;
    return t < 0.0;
}
/* 测试:用 CMocka 打桩 fetch,不联网即可测全部分支 */
#include <setjmp.h>
#include <cmocka.h>

static int mock_fetch(void *ctx, double *out)
{
    (void)ctx;
    *out = mock_type(double);      /* 每次调用弹出预设的返回值 */
    return 0;
}

static void test_freezing_when_below_zero(void **state)
{
    (void)state;
    weather_client_t w = { .fetch = mock_fetch, .ctx = NULL };

    will_return(mock_fetch, -5.0);                 /* 预设桩的返回值 */
    assert_int_equal(weather_is_freezing(&w), 1);
}

static void test_error_propagates(void **state)
{
    (void)state;
    weather_client_t w = { .fetch = mock_fetch, .ctx = NULL };
    /* 让桩返回错误码的变体:再写一个返回 -1 的桩函数 */
    assert_int_equal(weather_is_freezing(&w), -1);
}

int main(void)
{
    const struct CMUnitTest tests[] = {
        cmocka_unit_test(test_freezing_when_below_zero),
        cmocka_unit_test(test_error_propagates),
    };
    return cmocka_run_group_tests(tests, NULL, NULL);
}

要点:可测性的关键在依赖注入——依赖写成函数指针(或接口结构体),测试注入假实现;若代码里硬编码 curl_easy_perform(),任何框架都救不了。手搓替代:不用 CMocka 也能打桩——把依赖函数指针做成全局可替换(g_fetch = mock_fetch; 测试里换掉),Linux 还可用 ld 的 --wrap=symbol 链接期替换。


19.5 可测性设计:让代码"天生能测"

  1. 纯函数优先:输入→输出、无隐藏状态(全局/static)的函数最易测。副作用收敛到薄壳层(main、I/O 适配器),核心逻辑保持纯净。
  2. 依赖注入:时钟(now())、随机(rand())、文件、网络都通过接口传入——测试注入可控假体(固定时间、固定序列),还能测"时钟回拨""网络超时"等现实难复现场景。
  3. 小函数:50 行的函数往往内嵌多个决策点,测试要绕开上层直达分支;拆成 5~15 行的组合,每个分支直达。
  4. 返回错误码而非打印/退出:库函数 exit/fprintf(stderr) 使调用方(含测试)无法接管失败——失败语义走返回值(第 17 章)。
  5. 静态分配测试夹具:模块提供"重置"或把状态做成可注入的结构体实例,避免测试间通过 static 状态互相污染(或 setUp 里显式重置)。

反模式清单:测试里 sleep 依赖时序(竞态测试改用条件变量/事件,第 14 章)、断言里带浮点 ==、测试依赖执行顺序、一个测试函数验证八个不相关行为(拆开)、为覆盖率硬凑的空断言测试。


19.6 覆盖率与测试策略

覆盖率(gcov/lcov)

gcc -fprofile-arcs -ftest-coverage -g src/*.c test/*.c -o tests && ./tests
gcov src/mylib.c                  # 每行的执行次数
lcov --capture --directory . --output-file cov.info
genhtml cov.info -o cov_html      # 彩色 HTML 报告:哪行没测到一目了然

覆盖率回答"没测到什么"(低覆盖 = 盲区),但不回答"测得好不好"——100% 覆盖率下照样可能没有有效断言。工程基线:核心模块行覆盖 ≥ 80%,分支覆盖优先于行数指标,新增代码不许拉低整体覆盖。

测试用例设计清单(每写一个函数过一遍)

  1. 正常路径:典型输入,验证核心行为;
  2. 边界值:空/单元素/满容量/0/负数/最大值(INT_MAX、SIZE_MAX);
  3. 错误路径:非法输入 → 断言返回正确错误码且无副作用(malloc 路径失败时无泄漏——ASan 自动验证);
  4. 等价类抽样:输入域分组各取代表(正/负/零;短/长字符串;UTF-8 字节);
  5. 回归用例:每个修过的 Bug 固化为一条测试(Bug 复活检测器)。

与工具链的整合

  • CI 全量执行(第 18 章):Debug + ASan/UBSan 构建跑全部测试,TSan 构建跑并发测试;
  • CTest 聚合:每个测试文件注册为一个 CTest 条目,ctest --output-on-failure 失败时打印完整输出;
  • 测试命名:test_<模块>_<行为>_<条件>(test_bq_push_fails_when_full),失败信息即缺陷描述。

19.7 调试方法:测试本身的问题怎么查

症状 排查
测试单跑过、全跑挂 测试间状态泄漏(static/全局未重置);setUp 缺失
测试时好时坏(flaky) 依赖真实时间/随机/网络/线程时序 → 注入假体消除不确定性
ASan 在 tearDown 报泄漏 测试路径没释放夹具资源——测试比业务代码更早暴露泄漏
断言失败但输出难读 用带期望值的断言宏(EQUAL_INT)替代 CHECK(cond)
改了代码测试没重编 构建依赖缺失(第 18 章 -MMD/CMake 目标依赖)

心法:先怀疑被测代码,再怀疑测试;但如果"所有测试一起跑必挂"或"偶发失败",先怀疑测试自身的耦合——flaky test 的危害等同没有测试(狼来了效应)。


19.8 小结

  • 测试是 C 工程的回归防线与设计压力;单元+集成+端到端金字塔。
  • 测试框架本质 = 计数器 + 断言宏;60 行自研可用,Unity 是其标准版,CMocka 加 Mock 能力。
  • setUp/tearDown 保证测试互不污染;断言带期望值输出;浮点必须容差断言。
  • 可测性设计:纯函数、依赖注入、小函数、错误码返回、可重置状态——测试写不动的模块是设计信号。
  • 覆盖率找盲区不保证质量;用例清单:正常/边界/错误/等价类/回归。
  • CI 全量跑 + Sanitizer 加持;flaky 测试要当 bug 修。

思考与练习

  1. 用 19.2 的 minctest 为第 12 章哈希表写 10 条测试:put/get/覆盖更新/删除/碰撞链/空表/边界 key 长度,覆盖错误路径。
  2. 迁移到 Unity:同组测试改写为 setUp/tearDown 风格,体会夹具复用;接入 CTest。
  3. 可测性改造:给"读取当前配置并生成报告"的函数注入时间与配置来源接口,测试固定时钟下输出。
  4. 用 gcov/lcov 测量练习 1 的覆盖率,找出未覆盖分支补测到 90%+。
  5. 故意写一个依赖全局 static 计数的模块并测它——观察单跑/全跑差异,然后重构成可注入状态消除问题。

上一章:构建与依赖管理 | 下一章:性能优化 →

📄 第 20 章 性能优化 20-性能优化.md

学习目标:建立"先测量、后优化"的方法论;掌握 profiling 工具(perf/gprof/计时器);理解编译器优化等级与优化屏障;掌握缓存友好的数据布局与算法选择;了解常见优化手法与伪优化(负优化)清单。


20.1 方法论:优化的三原则

性能优化的第一课不是技巧,是纪律:

  1. 先测量,再动手。直觉在性能问题上错误率高得惊人——瓶颈常在没人猜的地方(一次隐式 strlen、一次日志格式化、一次缓存未命中)。没测过就优化 = 猜谜。
  2. 优化前问三个问题:真的慢吗(用户可感知?SLA 要求?)、慢在哪(热点)、值得吗(复杂度/可维护性代价 vs 收益)。90% 的代码不需要优化,10% 的代码贡献 90% 的耗时。
  3. 优化是带回归测试的改造。每一步优化前后:跑基准确认收益、跑测试确认正确性、跑 Sanitizer 确认没引入内存/竞态问题。没有基准与测试的"优化"是破坏行为。

优化工作流

确定目标(延迟/吞吐/内存)→ 测量基线 → 找热点(profiler)
   → 算法/数据结构层优化(收益最大)
   → 内存布局与访问模式优化
   → 编译器选项与内建函数
   → 逐步微调,每步重测

优化顺序的金字塔:换算法 > 换数据结构 > 改访问模式 > 改代码细节 > 调编译选项。自底向上做是常见浪费(在 O(n²) 上做微优化,不如换 O(n log n) 一次到位)。


20.2 测量工具箱

微观:精确计时

/* 单调时钟计时:perf_counter.h 的标准做法 */
#include <time.h>

static double now_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);     /* 单调钟:不受系统对时影响 */
    return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
}

void bench(void)
{
    double t0 = now_ms();
    /* 被测代码 */
    do_heavy_work();
    double t1 = now_ms();
    printf("elapsed: %.3f ms\n", t1 - t0);
}

要点:多次运行取统计(单次测量抖动大);防止编译器把"无副作用"的被测代码整个删掉(结果赋给 volatile 或打印);对比实验一次只改一个变量。

宏观:profiler 找热点

# perf(Linux 首选):采样 CPU 热点
perf record -g ./app          # -g 记录调用栈
perf report                   # 按耗时排序的函数榜 + 火焰图数据

# 生成火焰图(热点可视化,一眼看穿调用深处的开销)
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

# gprof(经典,需 -pg 编译)
gcc -pg app.c -o app && ./app && gprof ./app gmon.out

# Valgrind --tool=callgrind:精确调用计数(慢 50x,适合小规模精确分析)
valgrind --tool=callgrind ./app && kcachegrind callgrind.out.*

# 堆分析:内存分配热点
perf record -e malloc ./app   # 或 valgrind --tool=massif(内存峰值曲线)

读 profiler 的正确姿势:自顶向下找"占大头"的分支(self time 排序),关注惊喜项(没料到的函数);profiler 会扭曲行为(采样/插桩开销),结论要能被微观计时复现。


20.3 编译器:免费的性能

# Release 级优化
gcc -O2 -march=native -DNDEBUG app.c -o app

# 极致优化(谨慎:-O3 有时因代码膨胀反而更慢;-Ofast 破坏标准合规)
gcc -O3 -march=native -DNDEBUG app.c -o app
  • -O2 是发布默认;-O0 -g 是调试默认(优化与调试互斥——优化会重排/内联代码,断点行号漂移,变量"被优化掉");
  • -DNDEBUG 关 assert(第 16 章)——别忘了 Debug 与 Release 都要过测试(assert 关闭后走的路径可能不同);
  • -march=native:用本机 CPU 全部指令(发布产物要跨机器时改用 -march=x86-64-v2 等保守基线);
  • LTO(链接时优化):跨编译单元内联与死代码消除:gcc -O2 -flto + 链接同样加 -flto。第 5 章"别手写 inline"的底气就在这——LTO 让编译器替你做跨文件内联决策;
  • PGO(Profile-Guided Optimization):先跑真实负载收集 profile,再按热点布局代码,流水线/分支预测受益(-fprofile-generate → 运行 → -fprofile-use);
  • 优化屏障:有时编译器"聪明"得碍事(删掉基准代码、重排读写)。手段:volatile(强制真实读写,多线程标志位用它或 atomics,第 14 章)、asm volatile("" ::: "memory")(编译器内存屏障)、__attribute__((noinline))。

❌ 优化期的 UB 重审:-O2 下 UB 才真正发作——有符号溢出被假设"永不发生"进而删除判断、严格别名规则(aliasing)导致类型双关失效。优化前先 UBSan 过一遍(第 16 章);类型双关用 memcpy 或 union(有争议)代替强制指针转换。


20.4 缓存友好:现代性能的主战场

CPU 与内存的速度差百倍以上,数据访问模式往往比指令条数更决定性能:

  • L1/L2/L3 缓存行(cache line):内存以 64 字节为单位成块进缓存;顺序访问是缓存宠儿,随机跳跃是缓存杀手;
  • 空间局部性(用了这个字节,附近的也快被用)与时间局部性(刚用过的还会再用)。

手法一:数据布局 AoS vs SoA

/* AoS(Array of Structures):按对象组织 */
typedef struct { double x, y, z; int alive; } particle_t;
particle_t particles[N];
/* 更新所有 x:每 32 字节跳一次,缓存行只用 1/4 —— 浪费 */

/* SoA(Structure of Arrays):按字段组织 */
typedef struct {
    double *x, *y, *z;
    int    *alive;
} particles_soa_t;
/* 更新所有 x:连续扫一个数组,缓存行 100% 利用,还能向量化 */

结论:频繁整体遍历某一两个字段 → SoA;总是整对象读写 → AoS。物理引擎、数值计算、图像处理 SoA 常有数倍收益。

手法二:减少间接层

/* 指针数组:每步一次缓存未命中的概率 */
node_t **nodes;               /* 节点散落堆上 */
for (i) nodes[i]->value++;

/* 下标数组 + 连续存储:节点挤在一块内存(第 8 章一维展开思路) */
uint32_t *indices;            /* 先遍历下标(连续),跳向的节点也尽量连续 */

链表遍历慢的根源不是"指针运算"而是每步都缓存未命中——热数据改用动态数组/池化分配(从大块内存里切对象),遍历性能天差地别。

手法三:访问模式整理

  • 循环顺序匹配内存布局:二维数组按行遍历(m[i][j] 外层 i)——按列遍历(外层 j)每步跳一整行,慢数倍;
  • 循环合并:两个独立循环遍历同一数组 → 合成一个(数据只载入一次);
  • 分块(blocking/tiling):大矩阵乘法把工作切成缓存装得下的小块(经典 BLAS 优化);
  • 分支友好:把高频条件放前面;可预测分支(大多为真)成本低,随机分支(如排错路径)贵;排序输入再处理(如"先排序再统计区间"消灭分支抖动)是知名手法。

手法四:避免重复计算

/* 循环不变量外提(编译器大多会做,人写更稳) */
for (i = 0; i < strlen(s); i++)     /* ❌ 每轮 O(n) */
size_t len = strlen(s);             /* ✅ 提取一次 */
for (i = 0; i < len; i++)

/* 幂运算重用、查表替换三角函数/开方(空间换时间,注意精度) */

20.5 常见优化手法速查(按收益排序)

手法 典型收益 说明
换更好的算法/数据结构 O(n²)→O(n log n):数量级 第 13 章;收益永远排第一
减少 I/O 与系统调用 10x~1000x 批量读写(fwrite 块写 vs 逐字符)、合并小包、加缓冲
减少内存分配 数倍 重用缓冲区、对象池、reserve 预分配容量(动态数组 reserve 到目标大小,避免反复 realloc 搬家)
缓存友好布局 2~10x 本节 SoA/连续存储/遍历顺序
延迟/惰性处理 视场景 不算不用到的(惰性初始化)、批量延后提交
向量化(SIMD) 2~8x 让编译器自动向量化(连续内存+简单循环+-O2;-fopt-info-vec 查看是否成功)或手写 intrinsics
多线程并行 ≤ 核数倍 热点可拆分时(第 14 章);先确认单线程已优化——并行化串行瓶颈是白忙
微优化(寄存器变量、手工循环展开) 个位数 % 最后再做,多数被编译器做了;做了必须有基准证明收益

❌ 伪优化黑名单(常 slower 或纯噪音):

  • register 关键字(编译器比你会分配寄存器,C 已弃用其含义);
  • 手工循环展开不加基准验证(代码膨胀 → 指令缓存受害);
  • 用 >>/& 替代 ×/÷2ⁿ、%2ⁿ(编译器对常量早做了;负数右移还埋雷,第 3 章);
  • 过度 const/restrict 期待奇迹(有价值但不是魔法;restrict 需保证承诺否则 UB);
  • 在 O(n²) 算法上抠微操——换算法去吧。

20.6 内存性能专项

  1. 分配热点:高频小对象 → 对象池(一次 malloc 大块,自由链表复用,第 8 章);动态数组 push 前 reserve;字符串拼接用 snprintf 一次成型或扩容策略,避免反复 realloc。
  2. false sharing(伪共享):多线程各自写不同变量,但变量挤在同一缓存行 → 缓存行在核间弹跳,性能崩。对策:每线程的写变量按 64 字节对齐隔离(_Alignas(64) 或 padding)。
  3. 大页与 NUMA(服务器进阶):巨型数据集用 mmap/透明大页减少 TLB miss;NUMA 机器让线程访问本节点的内存(numactl)。
  4. 测量内存峰值:valgrind --tool=massif(谁涨的内存)、/usr/bin/time -v(最大 RSS)。

20.7 案例研究:一次完整的优化

任务:统计 1MB 文本中单词出现次数(简化场景,展示流程)。

基线(-O0 朴素实现):逐字符读 fgetc + 每次插入线性查找链表
    → 5.2 秒。profiler:90% 时间在 list_find(链表遍历)。

第 1 步(换数据结构):链表 → 哈希表(第 12 章),O(n²)→O(n)
    → 0.18 秒。瓶颈转移:哈希函数与字符串比较。

第 2 步(改 I/O):fgetc 逐字符 → fread 4KB 块 + 内存内扫描
    → 0.11 秒。瓶颈转移:strdup 分配。

第 3 步(减少分配):arena 池化所有 key 字符串(一次性大块分配)
    → 0.09 秒。瓶颈转移:哈希函数。

第 4 步(编译器):-O0 → -O2
    → 0.03 秒。总提速 ~170 倍。

验证:回归测试(第 19 章对拍)全过;ASan/Valgrind 无泄漏无越界;
     覆盖率不降。每一步都保留基准脚本,可随时重放。

复盘:没有一步是"代码微调"——算法、I/O 模式、分配策略、编译选项贡献了全部收益。这就是 20.1 方法论的现实样子。


20.8 调试方法:性能问题怎么查

症状 工具/方向
CPU 打满但不知在哪 perf top(实时)、perf record/report(离线)
CPU 不高却慢 等待型瓶颈:锁竞争(perf lock/加日志测量临界区)、I/O 等待(iostat、strace 统计 syscall 耗时)、页错误(perf stat -e page-faults)
偶发卡顿 延迟剖析:perf record -e sched:sched_switch;或分段打时间戳日志定位卡点
内存涨、GC 式抖动(分配风暴) massif + malloc 计数(perf record -e malloc)
优化后反而更慢 代码膨胀伤 icache;分支预测恶化;测量噪声——回到基准,逐项二分
理论快实测不快 确认真的在跑优化版(-O2?没被调试宏覆盖?);perf stat 看 IPC、缓存命中率(低 = 访存问题)

perf stat 快速体检:perf stat ./app 一次给出 cycles、instructions、cache-misses、branch-misses——cache-misses 高 → 20.4 节主攻;branch-misses 高 → 分支友好化;IPC 低 → 串行依赖重。


20.9 小结

  • 先测量后优化:基线 → profiler 找热点 → 算法/结构优先 → 每步带基准与回归测试。
  • 微观用单调钟多次测量;宏观 perf/gprof/callgrind 找热点,火焰图看全貌。
  • -O2 是发布默认;LTO/PGO 是免费/低成本优化;-O2 才现形的 UB 先用 UBSan 排干净。
  • 缓存是主战场:SoA/连续存储/按行遍历/池化分配/避免间接层;perf stat 的 cache-misses 是体检指标。
  • 手法收益排序:算法 > I/O 批量化 > 分配削减 > 缓存布局 > 向量化 > 微优化;伪优化黑名单心知肚明。
  • 多线程性能:锁竞争与伪共享是隐形杀手;测量驱动,不猜。

思考与练习

  1. 用 clock_gettime 给第 13 章四种排序在 n=10⁵ 随机/有序/逆序数据上建基准表,验证理论与实测的差距。
  2. 写一个 AoS/SoA 对照实验:10⁷ 个"粒子"只更新 x 字段一百万次,对比耗时并解释(perf stat 看缓存缺失数佐证)。
  3. 给第 12 章哈希表加 reserve 预分配 + rehash 阈值调优,用基准对比插入 10⁶ 个 key 的前后耗时。
  4. 用 perf record/report 找出 20.7 案例各版本的 Top3 热点函数,体会瓶颈迁移。
  5. 检测伪共享:两个线程各写自己的计数器,对比"相邻放置"与"64 字节对齐隔离"两种布局的吞吐。

上一章:单元测试 | 下一章:跨平台兼容性 →

📄 第 21 章 跨平台兼容性 21-跨平台兼容性.md

学习目标:理解可移植性的分层(标准 C → 平台 API → 硬件差异);掌握数据模型(LP64/LLP64/ILP32)与字节序两大硬件差异;掌握对齐、类型双关等实现细节问题;学会用抽象层封装平台差异;掌握特性检测写法与可移植性检查清单。


21.1 可移植性的分层模型

"跨平台"不是一句口号,是逐层收口的问题:

第 0 层:纯标准 C(ISO C17)
        —— 理论上任何合规编译器都能编。本书前 13 章大部分代码在此层。
第 1 层:POSIX(Unix 家族公约)
        —— Linux/macOS/BSD 通用;Windows 需要适配层。线程 pthread、文件系统、进程。
第 2 层:平台 API
        —— Windows API(WinSock/Win32)、macOS 框架、嵌入式 SDK。各说各话,必须封装。
第 3 层:硬件/ABI 差异
        —— 字长、字节序、对齐、调用约定。影响序列化与底层代码。

工程策略:业务代码钉死在第 0~1 层,把第 2~3 层差异全部压进少数"平台适配模块"。衡量一个 C 项目的可移植性,就数业务代码里 #ifdef _WIN32 出现的次数——越少越好(第 11 章条件编译纪律的跨平台版)。

src/
├── core/          ← 纯标准 C:算法、数据结构、业务逻辑(零平台分支)
└── platform/      ← 适配层:io_fs.c, thread_abst.c, net_abst.c, time_abst.c
    └── 平台分支全部集中在这里

21.2 数据模型:int/long 到底多大

同一份代码,Windows 与 64 位 Linux 的 long 大小不同——这是跨平台第一大坑:

数据模型 平台 char short int long pointer
ILP32 32 位 Linux/嵌入式 8 16 32 32 32
LP64 64 位 Linux/macOS 8 16 32 64 64
LLP64 64 位 Windows 8 16 32 32 64

后果实例:

long bytes = some_big_size;        /* Windows 上 long 是 32 位! */
printf("%ld\n", ptrdiff_result);   /* Linux 对,Windows 截断 */

size_t n = v.size();
if (n > LONG_MAX) ...              /* Linux OK;Windows 上 LONG_MAX 只有 21 亿 */

防御规则:

  1. 大小敏感处用定宽类型:<stdint.h> 的 int32_t/uint64_t/intptr_t 等——跨平台唯一确定的类型(第 2 章原则的工程版);
  2. 尺寸/下标/指针差一律 size_t/ptrdiff_t,格式串配 %zu/%td;
  3. 指针与整数互转用 intptr_t/uintptr_t,别用 long;
  4. long 只在"至少 32 位就够"的语义下使用;
  5. Windows 专属 API 的长度参数用其要求的类型(DWORD 等),平台层内转换。

printf 的平台差异补充:%zu(C99 起标准)在 MSVC 老版本不支持(VS2015+ 已支持);size_t 的稳妥跨平台打印可用 %Iu(MSVC)/条件宏,或直接用 inttypes.h:

#include <inttypes.h>
printf("value: %" PRIu64 "\n", (uint64_t)x);   /* PRIu64 在各平台展开为正确格式串 */

21.3 字节序(Endianness):多字节数字的排列之争

uint32_t x = 0x12345678 存在内存:

小端 little-endian(x86/绝大多数 ARM 配置):低位在低地址
    地址:  低 ──> 高
    内容:  78 56 34 12

大端 big-endian(网络字节序,SPARC 等老架构):高位在低地址
    内容:  12 34 56 78

危害场景:直接把多字节整数写进文件/网络(或 reinterpret 成字节),在另一字节序机器上读回就是错的。第 15 章 socket API 的 htons/htonl 就是在解这个问题——但标准只提供 16/32 位转换,且只该用于网络协议场景。

通用可移植的字节序处理:不要检测字节序再分支(__BYTE_ORDER__ 宏各家不同),用算术法,天然与宿主序无关:

#include <stdint.h>

/* 按协议定义的"大端"语义,从字节流组装 uint32 —— 任何宿主序结果一致 */
static inline uint32_t read_be32(const uint8_t *p)
{
    return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16)
         | ((uint32_t)p[2] << 8)  |  (uint32_t)p[3];
}

/* 反向:拆成字节流写入 */
static inline void write_be32(uint8_t *p, uint32_t v)
{
    p[0] = (uint8_t)(v >> 24);
    p[1] = (uint8_t)(v >> 16);
    p[2] = (uint8_t)(v >> 8);
    p[3] = (uint8_t)v;
}

/* 小端变体同理倒过来;浮点数没有标准字节序函数——序列化定点数或按 IEEE754 分字节定义 */

序列化总纪律(第 9、10 章的收口):跨机器/跨版本的二进制格式 = 逐字段按协议编码(定宽类型 + 显式字节序函数),永远不要整结构体 fwrite(padding 与字节序都是平台私产)。协议文件里写明版本号与字段布局,兼容性由协议层负责而非内存布局。


21.4 对齐与类型双关:实现定义的雷区

对齐相关

  • 未对齐访问:x86 容忍(慢);多数 ARM 上未对齐的指针解引用是 UB/硬件异常。__attribute__((packed)) 结构体的成员、直接把网络缓冲区强制转成结构体指针((header_t *)buf)都是雷:
/* ❌ 网络缓冲解析的跨平台杀手 */
header_t *h = (header_t *)recv_buf;      /* buf 对齐未知,可能未对齐访问 */

/* ✅ 逐字段用字节流读取(21.3 的 read_be16/be32 家族) */
uint16_t version = read_be16(recv_buf);
uint32_t length  = read_be32(recv_buf + 2);
  • 对齐分配:需要特殊对齐(SIMD 32/64 字节)时用 _Alignas(C11)或 aligned_alloc(align, size)(C11,注意 size 须是 align 倍数),free 释放——malloc 只保证"适合任何基本类型"的对齐。

类型双关(type punning)

float f = 1.0f;
uint32_t bits = *(uint32_t *)&f;    /* ❌ 违反严格别名(effective type 规则),-O2 下可能出错 */

uint32_t bits;
memcpy(&bits, &f, sizeof(bits));    /* ✅ memcpy 是标准认可的" reinterpret 字节"方式,
                                       编译器识别后零开销 */

严格别名(-fstrict-aliasing,-O2 默认开启)允许编译器假设"不同类型的指针不指向同一对象"——违反假设的代码在优化下随机出错。字节级重解释一律 memcpy 或 unsigned char(char 系指针访问任何对象都是合法的)*。

char 的符号性

char 是否有符号由实现定义(x86 多为 signed,ARM 常为 unsigned):

char c = fgetc(fp);              /* ❌ EOF(-1) 与 0xFF 在 unsigned char 平台上混淆 */
int c = fgetc(fp);               /* ✅(第 10 章规则,此处解释根因) */

if (isdigit(s[i])) ...           /* ❌ 若 char 为负(UTF-8 高字节)传参 UB */
if (isdigit((unsigned char)s[i]))/* ✅(第 6 章规则的根因) */

21.5 平台 API 差异与抽象层

典型差异对照

能力 POSIX(Linux/macOS) Windows
动态库 dlopen/dlsym(-ldl) LoadLibrary/GetProcAddress
线程 pthread_* Win32 线程(或 C11/C++11 实现)
文件 I/O open/read/write,ssize_t _open/_read,HANDLE 体系
时间 clock_gettime(CLOCK_MONOTONIC) QueryPerformanceCounter
文件与目录 opendir/readdir、stat FindFirstFile、GetFileAttributes
错误码 errno GetLastError()(CRT 部分映射 errno)
换行 \n 文本模式 \r\n(第 10 章)
路径分隔符 / \(多数 API 也接受 /)
socket BSD socket + close WinSock + closesocket + WSAStartup(第 15 章)

抽象层写法:接口归一,实现分支

以"高精度单调时间"为例(每个平台层一个函数的标准形态):

/* platform/time_abst.h —— 平台无关接口 */
#ifndef TIME_ABST_H
#define TIME_ABST_H
#include <stdint.h>

/* 返回单调时钟的纳秒数(不受系统对时影响) */
uint64_t abst_now_ns(void);
#endif
/* platform/time_abst.c —— 所有平台分支收口于此 */
#include "time_abst.h"

#if defined(_WIN32)
#include <windows.h>
uint64_t abst_now_ns(void)
{
    static LARGE_INTEGER freq;                     /* QPC 频率恒定,缓存 */
    static int inited = 0;
    if (!inited) { QueryPerformanceFrequency(&freq); inited = 1; }
    LARGE_INTEGER c;
    QueryPerformanceCounter(&c);
    return (uint64_t)(c.QuadPart * 1000000000.0 / freq.QuadPart);
}

#elif defined(__APPLE__)
#include <mach/mach_time.h>
uint64_t abst_now_ns(void)
{
    return mach_absolute_time() * 1;   /* 实际需 timebase 换算,此处示意 */
}

#else  /* POSIX */
#include <time.h>
uint64_t abst_now_ns(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}
#endif

三段式纪律:接口在 .h 保持纯净(无平台类型泄漏);实现里 #if 集中在 platform/ 目录;每个分支都要过 CI(第 18 章多平台 job 才有意义)。

特性检测:编译期探路

检测"有没有这个特性"比检测"是不是这个平台"更稳(发行版、SDK 差异远多于平台数):

/* 1. 标准版本特性 */
#if __STDC_VERSION__ >= 201112L
    _Static_assert(sizeof(void*) <= 8, "unexpected pointer size");
#else
    #define _Static_assert(cond, msg) typedef char static_assert_fail[(cond) ? 1 : -1]
#endif

/* 2. 编译器内建检测:先探测宏,再写 fallback */
#if defined(__GNUC__) && __GNUC__ >= 5
    #define HAS_BUILTIN_OVERFLOW __has_builtin(__builtin_add_overflow)
#else
    #define HAS_BUILTIN_OVERFLOW 0
#endif

#if HAS_BUILTIN_OVERFLOW
    if (__builtin_add_overflow(a, b, &r)) return ERR_OVERFLOW;
#else
    /* 手工溢出检测的通用实现 */
    if (a > 0 && b > INT_MAX - a) return ERR_OVERFLOW;
    r = a + b;
#endif

/* 3. 运行期系统调用版本检测(如 Linux 内核特性):先尝试,失败再降级,
      比编译期判断更符合真实运行环境 */

构建期检测的正规军是 CMake:check_function_exists/check_symbol_exists/check_c_source_compiles 把探测结果写成 config.h(#define HAVE_MEMFD_CREATE 1),源码统一 #ifdef HAVE_X——探测与使用分离,避免源码里堆砌编译器特有宏。


21.6 可移植性检查清单

代码层:

  • [ ] 大小敏感处用 <stdint.h> 定宽类型;尺寸用 size_t;打印用 %zu/PRIu64
  • [ ] 不依赖 char 的符号性;ctype 参数 (unsigned char) 化
  • [ ] 浮点比较不用 ==;不假设 sizeof(long) 与指针同宽
  • [ ] 二进制协议/文件逐字段序列化 + 显式字节序函数;不整结构体落盘
  • [ ] 不把网络缓冲强转结构体;字节重解释用 memcpy
  • [ ] 位域/枚举大小不进持久化格式
  • [ ] 未定义行为清单零容忍(UBSan 全绿)——UB 在不同平台的表现差异就是"玄学 Bug"之源

接口层:

  • [ ] 平台分支全部收敛在 platform/ 适配层,业务代码零 #ifdef
  • [ ] WinSock 生命周期(WSAStartup)、closesocket、recv/send 已封装
  • [ ] 路径处理用统一封装(分隔符、盘符、路径长度 MAX_PATH 限制)
  • [ ] 动态库/线程/时间等系统依赖走抽象接口,便于替换与测试注入(第 19 章)

构建层:

  • [ ] CMake 管理差异;check_* 探测特性生成 config.h
  • [ ] CI 覆盖目标平台矩阵(至少 Linux + Windows,需要则加 macOS/交叉编译)
  • [ ] Debug 与 Release、ASan 与 TSan 构建全部过测试
  • [ ] 警告等级 -Wall -Wextra 全平台一致(MSVC /W4),不降级迁就

文档层:

  • [ ] README 声明支持的平台/编译器矩阵与最低标准(如 C11)
  • [ ] 已知的平台行为差异记录在案(如文本模式换行、文件锁语义)

21.7 调试方法:平台差异类 Bug 怎么查

症状 排查方向
Linux 正常 Windows 崩溃/结果错 long 32 位截断(LLP64);路径反斜杠/盘符;WinSock 未 WSAStartup;文本模式换行
本机对、另一台机器错 字节序(x86 vs 大端架构);-march=native 产物跑在旧 CPU(非法指令);数据模型差异
优化开/关结果不同 UB(严格别名、有符号溢出、未初始化);-fsanitize=undefined 两种优化等级都跑
移植后数值精度漂移 float/double 混算路径不同;long double 尺寸差异(x86 80 位 vs ARM 64 位);FPU 控制字/编译器收缩(FLT_EVAL_METHOD)
序列化文件跨平台读回错位 padding 差异(offsetof 两端对比);字节序;结构体版本不匹配
未对齐崩溃(ARM 设备) packed 结构体成员指针;缓冲区强转结构体;改逐字段读取

通用方法论:跨平台 Bug 的第一现场往往在两端的数据格式对比——十六进制 dump(第 10 章)两边逐字节比对,错位/翻转的字节立刻指认真凶是 padding 还是字节序。其次是最小复现跨平台迁移:30 行的复现程序在两台机器上对跑,变量只剩平台本身。


21.8 小结

  • 可移植分层:标准 C → POSIX → 平台 API → 硬件;业务代码钉在低层,差异压进 platform/ 适配层。
  • 数据模型 LP64 vs LLP64:long 在 Windows 是 32 位——定宽类型 + size_t + inttypes.h 格式宏是解药。
  • 字节序:协议字段用 read_be32/write_be32 算术法;二进制持久化逐字段序列化,永不整结构体搬运。
  • 对齐与别名:缓冲区不强转结构体;字节重解释用 memcpy;ctype 参数 unsigned char 化。
  • 特性检测优于平台检测;CMake check_* 生成 config.h;探测与使用分离。
  • 清单驱动:代码/接口/构建/文档四层清单 + CI 平台矩阵,是可移植性的落地形态。

思考与练习

  1. 编写程序打印 sizeof(int/long/long long/void*/size_t),在 Windows 与 64 位 Linux(或 WSL)各跑一次,对照 21.2 表格。
  2. 实现 read_le16/le32/le16/le32 与对应 write 族,写一段"把结构化记录编码成字节流再解码回读"的自测(对拍两种字节序组装结果必须一致)。
  3. 制造一个 packed 结构体成员指针未对齐访问(x86 上能跑),阅读你的目标架构手册确认其后果,改写字节流版实现。
  4. 用 CMake check_symbol_exists 探测 strdup 与 clock_gettime,生成 config.h,代码里对缺失项提供 fallback 实现。
  5. 综合演练:把第 15 章的回显服务器整理成"core 逻辑 + platform 网络适配层"结构,使其在 Linux 与 Windows(WinSock)都能编译——统计改造后业务文件里的 #ifdef 数量,目标为 0。

上一章:性能优化 | 附录:常见错误速查表 →

📄 附录 appendix.md


附录 A:常见错误速查表

按"症状 → 原因 → 解法 → 详见章节"组织。排查问题先按症状查表。

A.1 编译错误

错误信息(GCC 典型原文) 原因 解法 章节
expected ';' before 'xxx' 上一语句缺分号 报错行往前看一行 01
expected declaration or statement at end of input 少了 } 数花括号;用编辑器括号匹配 04
unknown type name 'foo_t' 类型未定义/头文件未包含 补 include;检查头文件守卫名 09/11
implicit declaration of function 'xxx' 调用前无原型 原型前置或包含头文件;C23 起为错误 05/11
conflicting types for 'xxx' 声明与定义签名不一致 对齐原型与定义(参数个数/类型) 05
subscripted value is not an array 对非数组用 [] 检查变量类型;指针与数组声明混淆 07
invalid use of undefined type 用了只有前向声明的类型成员 不透明类型通过接口函数访问 17
lvalue required as ... 给右值赋值(如 5 == x 写成 5 = x 之外的常量赋值、给函数返回值赋值) 检查赋值目标 03
case label does not reduce to an integer constant switch 的 case 用了变量 case 必须常量;变量判断用 if 04
missing separator(make) 命令前没用 Tab Makefile 命令行改 Tab 缩进 18

A.2 链接错误

错误信息 原因 解法 章节
undefined reference to 'foo' 有声明无实现;漏编译某 .c;漏链库 检查编译清单;nm 查符号;-lm 等库参数 01/11
undefined reference to 'main' 没有入口文件 把含 main 的 .c 加进编译命令 11
multiple definition of 'foo' 头文件里放了定义;全局变量重复定义 定义移回 .c;头文件只留 extern 声明 11
cannot find -lxxx 库文件不在搜索路径 安装库或加 -L 路径;ldconfig 18
WinSock 符号未解析 忘了链接 ws2_32 MSVC #pragma comment(lib,"ws2_32") / MinGW -lws2_32 15

A.3 运行时崩溃(段错误 / 0xC0000005)

症状 高频原因 排查 章节
一进函数就崩 解引用 NULL/未初始化指针 GDB 崩溃点 print 指针;声明即初始化 07
崩在 strlen/strcpy 字符串无 \0;%s 传了非字符串 x/s/x/16bx 看原始字节 06
scanf("%s") 后崩 输入超长溢出栈 %31s 限宽;改用 fgets 06
返回后使用返回指针崩 返回了局部变量地址 返回堆/静态/调用方缓冲 05
free 相关路径崩 use-after-free / double free ASan 三段式报告直接定罪 08
大数组定义后崩 栈溢出(局部 8MB 限制) 堆分配或 static 08
递归深处崩 递归无界/基准情形缺失 bt 看栈深;改迭代或限深 05
网络服务器偶发崩 accept 的 fd 越界/泄漏;缓冲区太小 fd 泄漏检查;上限校验 15
多线程随机崩 数据竞争 TSan 定罪;补锁或改设计 14

A.4 "不崩溃但结果错"(最危险的类别)

症状 高频原因 排查 章节
循环计数少/多一次 边界 < vs <=;起始 0 vs 1 打印首尾轮循环变量;左闭右开口诀 04
累加结果随机少 竞态丢失更新 加锁/原子;TSan 14
数值巨大/负数异常 有符号溢出;有符号↔无符号转换 UBSan;检查混合运算 02/03
浮点比较恒假 == 直比浮点 容差比较 02
数组遍历死循环 unsigned i >= 0 恒真 i > 0 倒序或正向遍历 02
结构体成员值错位 跨平台 padding/字节序 offsetof 对比;逐字段序列化 09/21
字符串比较恒不等 strcmp(a,b) 返回值当真值用反了 strcmp(a,b) == 0 表示相等 06
"加 printf 就对了" 未初始化内存/时序敏感 移除打印用 ASan 复现 02/16
优化开了才出错 UB 在 -O2 发作(别名/溢出) UBSan 两种优化等级都跑 20/21

A.5 资源类问题

症状 原因 排查 章节
程序内存持续上涨 泄漏(提前 return / realloc 覆盖 / 丢唯一指针) Valgrind --leak-check;分配计数钩子 08
文件句柄耗尽 Too many open files fopen/accept 后错误路径未 close 每条 return 路径清点;lsof -p PID 10/15
文件内容为空/不完整 忘 fclose;写失败未检查 检查 fwrite 返回与 fclose 返回 10
"日志少了一截" stdout 重定向全缓冲,崩溃丢缓冲 stderr 或 fflush 10/16
端口重启连不上 TIME_WAIT 且未设 SO_REUSEADDR setsockopt 15

A.6 未定义行为(UB)重点清单

读未初始化变量 · 有符号整数溢出 · 除以零(整数)· 数组越界 · 解引用 NULL/野指针 · use-after-free · double free · 同表达式多次修改变量 · 移位位数 ≥ 类型宽度 · 负数左移 · 类型不匹配的 printf 参数 · 字符串无 \0 就用 · 违反严格别名的类型双关 · 未对齐访问 · 修改字符串字面量 · ctype 函数传负值 char · 数据竞争(C11 起并发场景)

统一防线:-Wall -Wextra + -fsanitize=address,undefined(并发加 -fsanitize=thread)+ Valgrind。


附录 B:C 关键字与标准库速查

B.1 关键字一览(C17,32+7 个)

auto      break     case      char      const     continue  default
do        double    else      enum      extern    float     for
goto      if        inline    int       long      register  restrict
return    short     signed    sizeof    static    struct    switch
typedef   union     unsigned  void      volatile  while
_Alignas  _Alignof  _Atomic   _Bool     _Complex  _Generic
_Imaginary _Noreturn _Static_assert _Thread_local

记忆分组:

  • 数据:char short int long float double signed unsigned void _Bool
  • 类型修饰:const volatile static extern register auto inline restrict _Alignas _Thread_local
  • 复合类型:struct union enum typedef sizeof _Alignof
  • 流程:if else switch case default for while do break continue goto return
  • 其他:_Atomic _Generic _Noreturn _Static_assert _Complex _Imaginary

B.2 最常用标准库函数

stdio.h:printf fprintf sprintf snprintf(snprintf 安全)· scanf fscanf sscanf · fgets fputc fputs fgetc · fread fwrite · fopen fclose fflush · fseek ftell rewind · ferror feof clearerr · perror · remove rename tmpfile

stdlib.h:malloc calloc realloc free · exit atexit abort · atoi atol strtol strtoul strtod(字符串转数用 strtol 族,能报错)· qsort bsearch · abs labs · rand srand(多线程慎用)

string.h:strlen strcpy strcat(危险)· strncpy strncat strncmp · strcmp strchr strrchr strstr strtok(非线程安全)· memset memcpy memmove memcmp · strerror · strcspn strspn

math.h:fabs sqrt pow floor ceil round fmod log log2 exp sin cos atan2 · isinf isnan

ctype.h:isalpha isdigit isspace isupper islower toupper tolower(参数 (unsigned char) 化)

stdint.h / inttypes.h:int8..64_t uint8..64_t intptr_t uintptr_t size_t · PRIu64 PRId32 格式宏

errno.h / stddef.h / stdbool.h:errno EDOM ERANGE · size_t ptrdiff_t NULL offsetof · bool true false

threads.h(C11):thrd_create thrd_join thrd_detach · mtx_init lock unlock destroy · cnd_init wait signal broadcast

stdatomic.h(C11):atomic_load store fetch_add compare_exchange · atomic_bool atomic_long


附录 C:学习路线与推荐书目

C.1 自查能力清单(本书学完应能做到)

  • [ ] 能不查资料写出:动态数组/链表/哈希表,含正确的 create/destroy
  • [ ] 能解释任意一个指针声明(int (*f)(void*) 级别)并画出内存图
  • [ ] 能用 ASan/Valgrind/GDB 独立定位一个内存错误
  • [ ] 能把一个模块按"不透明指针 + 成对接口 + 错误码"的标准写出库
  • [ ] 能为库编写 CMake 工程、Unity 测试集并接入 CI
  • [ ] 能实现多线程生产者-消费者并解释锁序与条件变量的 while 规则
  • [ ] 能实现 TCP 长度前缀协议的收发双方并处理粘包
  • [ ] 能说出跨平台三大坑(数据模型/字节序/对齐)各自的防御写法

C.2 进阶书单

语言深化:

  • 《C 程序设计语言》(K&R)——C 的圣经,短而深,适合重读
  • 《Expert C Programming: Deep C Secrets》(Peter van der Linden)——声明解析、链接、运行时内幕
  • 《C Traps and Pitfalls》(Andrew Koenig)——本书"常见错误"专题的书本形态
  • ISO/IEC 9899 标准文本(C17/C23 草案免费)——查 UB 与标准行为的最终依据

系统与底层:

  • 《深入理解计算机系统》(CSAPP)——从程序员视角看内存/缓存/链接/并发,性能优化的理论基础
  • 《Linux/UNIX 系统编程手册》(TLPI,Michael Kerrisk)——Linux 系统编程百科全书(第 14/15 章的深水区)
  • 《TCP/IP 详解 卷 1》——网络协议原理

工程与代码质量:

  • 《代码大全》《代码整洁之道》——通用工程质量观
  • Linux 内核源码中的 list.h、kfifo、rbtree——生产级 C 数据结构的最佳范本
  • SQLite、Redis、nginx 源码——读工业级 C 项目的模块化与抽象层设计

C.3 练手项目阶梯

  1. 温习级:通讯录(文件持久化)、简易计算器(函数指针分派)、文本词频统计(哈希表)
  2. 工程级:带测试与 CMake 的通用容器库(vec/list/hashmap 通用接口)、日志库(分级+异步缓冲)、ini/JSON 解析器
  3. 系统级:线程池 + 任务调度器、简易内存池、TCP 聊天室(select/epoll)、HTTP 静态服务器
  4. 挑战级:shell 解释器(fork/exec/管道)、Lua 式迷你脚本语言(tagged union + 递归下降)、小型 key-value 数据库(B+ 树/LSM 思想)

每个项目都遵守本书工程篇的标准:规范命名、错误码、单元测试、ASan 全绿、CMake 构建、README。


← 返回总目录

📁 配套文件目录

c-tutorial/
  01-入门与环境搭建.md
  02-变量与数据类型.md
  03-运算符与表达式.md
  04-流程控制.md
  05-函数.md
  06-数组与字符串.md
  07-指针.md
  08-内存管理.md
  09-结构体-联合体与枚举.md
  10-文件操作.md
  11-预处理器与多文件工程.md
  12-常用数据结构实现.md
  13-常用算法实现.md
  14-多线程与并发.md
  15-网络编程.md
  16-调试与错误处理.md
  17-代码规范与模块化设计.md
  18-构建与依赖管理.md
  19-单元测试.md
  20-性能优化.md
  21-跨平台兼容性.md
  README.md
  appendix.md
  .mimosa/
    hook-status/
      sess_4da6b83c-b4a1-4a1a-adc7-e0b2cbc1f9fa-7ff78cc5a9.json

完整源码 Gitee 仓库浏览 →