
简介这份《C语言学习资料 实例》面向C语言初学者与需要巩固基础的进阶开发者围绕基本语法、常用算法、函数封装与文件操作四条主线组织内容适合课堂实验配合练习、自学时查漏补缺也可作为动手开发前的语法回顾材料。压缩包共8个文件含7个cpp程序实例与1份doc学习笔记整体约8KB体积轻便、便于随时取用cpp文件分别对应基础输入输出、控制结构、函数应用、数组操作、指针与内存管理、结构体与联合体、文件操作七个主题doc笔记则梳理核心概念与代码要点可与源码对照阅读。目前已有135人学习下载。逐个运行并尝试改造这些实例能够直观看到printf与scanf的用法、循环与条件分支的组织、参数传递与返回值的设计、多维数组遍历、动态内存分配以及文件读写与错误处理的实际写法从而把语法知识落到可运行代码上逐步养成模块化组织程序的习惯。1. C 语言学习资料实例从「看得懂」到「跑得通」的距离有位读者给我看过他的笔记指针那一章抄得工工整整二级指针、数组指针、函数指针分类清楚可让他写一个带插入和销毁的单链表free的位置还是放错。这种落差不是理解问题而是缺少能编译、能打断点、能改参数再跑一遍的程序实例。这份 C 语言学习资料按「学习笔记 程序实例」成对组织笔记负责把语法边界和内存模型讲清楚实例负责给出一个可以直接用gcc编译、可以单步跟进去看的.c文件。它适合刚过完语法、想找手感的人也适合带新人时手里需要一套现成练习素材的人。速成谈不上但每个知识点都能落到一个文件上跑一遍确实比读十遍有用。2. 资料结构学习笔记、程序实例与练习的三层分法拿到一套 C 语言资料第一件事不是从头读而是先看目录怎么组织。目录结构决定了它是「一堆散装示例」还是「可以顺着章节走一遍的工程」。这套资料采用的是很典型的三层分法docs放学习笔记examples放能编译运行的完整程序实例exercises放带TODO的留白练习。三层各管一件事互不干扰读的时候可以只挑其中一层。2.1 目录骨架与命名约定实际展开后大致是下面这个结构。我按自己的习惯做了简化核心是「笔记章号」和「实例目录名」严格对齐翻笔记的时候不用猜代码在哪个文件夹。c-notes/ ├── docs/ # 学习笔记纯 Markdown按知识点分章 │ ├── 01-basic-types.md │ ├── 02-pointer.md │ ├── 03-array-string.md │ ├── 04-struct.md │ ├── 05-file-io.md │ └── 06-func-pointer.md ├── examples/ # 可独立编译的程序实例 │ ├── ch02_pointer/ │ │ ├── swap.c │ │ └── Makefile │ ├── ch04_struct/ │ │ ├── list.c │ │ └── Makefile │ └── ch05_fileio/ │ ├── copy_file.c │ └── Makefile ├── exercises/ # 留白练习函数体里是 TODO │ └── ch03_string/ │ └── my_strcpy.c ├── include/ # 多文件实例共用的头文件 │ └── calc.h └── Makefile # 顶层入口一条命令编译全部实例这个结构里最关键的是「每个实例目录自带一个 Makefile」。很多人写 C 语言练习习惯把所有.c扔在一个文件夹里最后靠gcc *.c一把梭。短期没问题但一旦涉及多文件、静态库、条件编译立刻就乱。每个目录自带构建文件好处是单个实例可以独立拷走、独立编译不会牵扯到别的章节坏处是文件数量变多所以顶层再放一个 Makefile 做统一调度用make -C进到子目录里执行。命名上有个细节值得说ch02_pointer对应02-pointer.md前缀数字强制同步。学习笔记里讲「指针与数组的三种等价写法」实例目录里就一定有swap.c、array_decay.c、ptr_arith.c三个文件。这种一一对应看着笨但对学习资料来说是刚需——读者翻到某一节想验证结论必须能三秒内定位到代码而不是全局搜关键字。exercises单独拆出来是另一层考虑。学习笔记和程序实例是「读」的练习是「写」的。练习文件里只保留函数签名、用例和TODO注释不提供实现避免读者一上来就看到答案。我一般会把练习的参考实现放在另一个分支或者单独目录读的时候不会被剧透。2.2 编译工具链三条路怎么选C 语言资料最怕的一件事是「代码没错环境不对」。同一份.c在不同编译器下报的错、给的警告、甚至运行结果都可能不一样尤其是涉及未定义行为的时候。所以在动手之前得先把工具链定下来。工具链典型平台标准支持适合场景需要注意GCCLinux / macOS / Windows(MinGW)C89 到 C17 较完整学习笔记的主力验证环境开-Wall -Wextra才看得见问题ClangmacOS / Linux诊断信息更友好排查难懂的编译错误警告项比 GCC 更细可能报得更多MSVCWindows对 C99 部分特性支持较晚需要和现有工程保持一致默认不认变长数组等特性给初学者的建议很直接笔记里的程序实例统一用-stdc11在 GCC 或 Clang 上先跑通再考虑换平台。标准版本锁死能避免大量「为什么我这里能编过、你那里编不过」的扯皮。单个文件编译最小命令长这样# 最朴素的编译指定标准、开警告、带调试信息 gcc -stdc11 -Wall -Wextra -g -O0 swap.c -o swap # 运行观察输出 ./swap参数逐个说清楚。-stdc11锁定语言标准不写的话 GCC 默认值随版本变化容易踩到「同一个文件换个版本行为就变了」的玄学问题。-Wall -Wextra是两档警告前者覆盖大部分常见问题隐式声明、未使用变量、格式化字符串不匹配后者补充一些更细的检查有符号无符号比较、参数未使用。-g生成调试符号是后面用gdb打断点的前提。-O0关掉优化初学阶段必须加——开了-O2之后变量可能被优化进寄存器gdb里print一个局部变量会告诉你「optimized out」非常打击信心。提示调试版本和发布版本一定要分开编译。调试时期望看到真实的执行顺序发布时才考虑-O2。混在一起用等于自己给自己制造黑匣子。Windows 上如果用的是 MinGW 系列命令基本一致只是可执行文件带.exe后缀如果用 MSVC 的cl参数写法完全不同/std:c11 /W4 /Zi /Od这也是为什么学习笔记里最好显式标注「本实例验证环境」——不是排他而是给读者一个已知可行的参照点。2.3 顶层 Makefile一条命令编译所有实例实例目录多了以后逐个cd进去敲gcc是体力活。顶层放一个 Makefile把「编译全部」「清理全部」「跑某一个」三件事固定下来学习节奏会顺很多。# 顶层 Makefile统一调度各章节实例 CC ? gcc CFLAGS ? -stdc11 -Wall -Wextra -Wpedantic -g -O0 SUBDIRS : $(wildcard examples/ch*) .PHONY: all clean list # 默认目标进入每个实例目录分别执行 make all: for dir in $(SUBDIRS); do \ echo $$dir; \ $(MAKE) -C $$dir || exit 1; \ done # 列出当前所有实例目录方便定位 list: echo $(SUBDIRS) # 清理所有子目录的中间文件和可执行文件 clean: for dir in $(SUBDIRS); do \ $(MAKE) -C $$dir clean; \ done逐段解释。CC ? gcc用的是「条件赋值」意思是如果外部已经通过环境变量指定了编译器比如make CCclang就用外部的没有才用gcc。这样切换编译器不用改文件。CFLAGS同理?让用户可以在命令行追加参数比如make CFLAGS-stdc11 -g -O0 -fsanitizeaddress临时开启地址检查跑一遍不改动仓库里的默认值。$(wildcard examples/ch*)是 GNU make 的通配函数扫描出所有章节目录。用通配而不是手写目录列表好处是新增章节不用回来改顶层文件坏处是拼错目录名不会报错所以配了list目标先看一眼扫出来的东西对不对。for循环里|| exit 1这一句容易被忽略。不加的话某个章节编译失败循环还会继续往下跑最后make的退出码可能是 0CI 里就变成了「明明有错却显示通过」。加上之后第一个失败点会立刻中断错误定位更快。子目录的 Makefile 则简单得多一个文件对应一个可执行文件# examples/ch02_pointer/Makefile CC ? gcc CFLAGS ? -stdc11 -Wall -Wextra -g -O0 TARGET : swap SRCS : swap.c $(TARGET): $(SRCS) $(CC) $(CFLAGS) $(SRCS) -o $ clean: rm -f $(TARGET)$是自动变量代表当前规则的目标名也就是swap。这种写法看着比直接敲命令多了一层但它把「用什么编译器、开什么警告」这件事集中到了一个变量里后面改写或者加文件只动一行。学习资料里放这种小 Makefile本身也是一种示范代码不只是能跑还得有可重复的构建方式。3. 核心程序实例指针、结构体、字符串、文件四类拆解C 语言学习笔记里最容易写得抽象的就是这四块指针、结构体、字符串、文件 IO。抽象的笔记读完记不住具体的代码跑一遍才留得下印象。这一章按「先看现象、再看实现、最后看边界」的顺序把每类里最有代表性的程序实例拆开讲。3.1 指针与数组一个 swap 引出的三种写法交换两个整数是几乎所有 C 语言教材的第一个指针例子但它恰好能说明「值传递」和「地址传递」的根本差别。实例目录里的swap.c同时给了两个版本放在一起对比效果比看十页文字强。/* examples/ch02_pointer/swap.c */ #include stdio.h /* 版本一值传递。形参是实参的副本函数内怎么改都影响不到调用方 */ void swap_by_value(int a, int b) { int t a; a b; b t; } /* 版本二指针传递。通过地址回写调用方的内存 */ void swap_by_pointer(int *a, int *b) { if (a NULL || b NULL) { /* 防御空指针直接返回避免解引用崩溃 */ return; } int t *a; *a *b; *b t; } int main(void) { int x 1, y 2; swap_by_value(x, y); printf(after value : x%d y%d\n, x, y); /* 输出 1 2没变 */ swap_by_pointer(x, y); printf(after pointer: x%d y%d\n, x, y); /* 输出 2 1变了 */ return 0; }逻辑说明。swap_by_value里a、b是main中x、y的拷贝函数返回时这两个局部变量就销毁了main里的原始变量从头到尾没被碰过。swap_by_pointer拿到的是地址*a和*b是对调用方内存的直接访问赋值会真实写入。参数说明int *a里的*属于声明的一部分表示「这是一个指向 int 的指针」不是解引用运算符调用时传x取地址。三者的关系可以用一句粗话记住声明里的*是类型表达式里的*是取值调用处的是给地址。if (a NULL || b NULL)这一行在多文件、跨模块调用时非常必要。单文件示例里传空指针是低级错误但真实项目里指针从函数返回值、结构体成员、回调参数里来来源一多就不可控。我一般会要求带指针参数的公开函数都做这层判断哪怕只在调试期加assert。再往下走一步是数组和指针的关系。笔记里会强调「数组名在大多数表达式里会退化成指向首元素的指针」实例里就配一个sum_array/* 数组名退化为指针后函数内部拿不到数组长度必须显式传入 */ long sum_array(const int *arr, size_t n) { long total 0; for (size_t i 0; i n; i) { total arr[i]; /* 等价于 *(arr i) */ } return total; }const int *arr表示指针指向的内容不可通过该指针修改但指针本身可以变。这是接口设计上的习惯只读数据就加const让编译器帮你拦住误写。size_t n是长度必须单独传——sizeof(arr)在函数内部拿到的是指针大小不是数组大小这是新手最常踩的坑之一。3.2 结构体与链表插入、遍历、释放的完整生命周期结构体章节里最有价值的实例是单链表因为它把「内存分配、指针操作、资源释放」三件事串在了一起。代码量不大但每一步的边界都得交代清楚。/* examples/ch04_struct/list.c */ #include stdio.h #include stdlib.h typedef struct Node { int value; struct Node *next; } Node; /* 头插法。返回新的头指针调用方必须接收返回值 */ Node *list_push(Node *head, int value) { Node *n (Node *)malloc(sizeof(Node)); if (n NULL) { return head; /* 分配失败保持原链表不变 */ } n-value value; n-next head; return n; } void list_print(const Node *head) { for (const Node *p head; p ! NULL; p p-next) { printf(%d - , p-value); } printf(NULL\n); } /* 释放整条链表。顺序错了就是 use-after-free */ void list_free(Node *head) { while (head ! NULL) { Node *next head-next; /* 先保存 next再释放当前节点 */ free(head); head next; } }逻辑说明。list_push用头插法新节点插在链表最前面时间复杂度O(1)。注意返回值是Node *调用方要写成head list_push(head, 10)。如果把head传进去指望函数内部改掉外部指针那得用二级指针Node **head这是另一个容易混淆的点修改「指针本身」要传指针的地址。malloc(sizeof(Node))这里没有对sizeof的结果再做乘法因为只分配一个节点。真实场景里分配数组要写malloc(n * sizeof(Node))并且要注意整数溢出——n是size_t时溢出概率低如果是有符号int大数相乘可能先溢出再转成size_t分配出一块比预想小得多的内存。稳妥写法是先判断n SIZE_MAX / sizeof(Node)。list_free的顺序是这段代码的核心先取head-next存到临时变量再free(head)最后head next。如果反过来先free(head)再去读head-next读取的就是已经归还给堆的内存属于 use-after-free很多时候不会立刻崩而是在后面某个malloc之后才出问题排查起来非常痛苦。遍历函数里用const Node *p而不是Node *p是接口层面的约束打印不应该修改链表那就让类型系统说清楚。指针本身要能在循环里前进所以const放在*左边表示指向的内容只读指针自身可改。3.3 字符串处理手写 strlen 与 strcpy 的边界字符串这一块标准库函数用起来简单但面试和实际排查里问的都是「你知道它内部怎么做的吗」。笔记里会要求手写一遍strlen和strcpy目的不是替代标准库而是理解 C 字符串「以\0结尾、没有长度字段」这个设计带来的一系列后果。/* exercises/ch03_string/my_strcpy.c 的参考实现 */ #include stddef.h size_t my_strlen(const char *s) { const char *p s; while (*p ! \0) { p; } return (size_t)(p - s); /* 指针相减得到元素个数 */ } /* 返回目标缓冲区末尾的指针方便链式拼接 */ char *my_strcpy(char *dst, const char *src) { char *p dst; while ((*p *src) ! \0) { /* 空循环体赋值、判断、自增都在条件里完成 */ } return dst; }my_strlen用指针相减求长度两个指针必须指向同一块连续内存比如同一个数组否则行为未定义。这里p和s都指向同一个字符串所以减法合法结果是ptrdiff_t转成size_t返回。my_strcpy里的while ((*p *src) ! \0)是 C 语言里经典得有点欺负人的写法。拆开看先执行*p *src赋值表达式的值是赋进去的字符然后p、src各自自增最后拿这个字符和\0比较。循环结束时\0也已经被复制过去了所以目标串正常终止。但这段代码有个致命前提目标缓冲区必须足够大。strcpy系列函数不检查边界源串比目标长就会越界写属于典型的缓冲区溢出。实际项目里我一般用带长度的版本/* 安全版本最多写入 size-1 个字符始终保证 \0 结尾 */ char *safe_strcpy(char *dst, size_t size, const char *src) { if (dst NULL || src NULL || size 0) { return dst; } size_t i 0; for (; i 1 size src[i] ! \0; i) { dst[i] src[i]; } dst[i] \0; return dst; }参数说明size是dst的总容量包括结尾的\0。循环条件是i 1 size意思是给终止符留一个位置。这段代码比strncpy更适合日常使用因为strncpy在源串长度不足时会用\0把剩余空间全部填满性能差而且源串长度正好等于size时不会补终止符坑过一次就记住了。3.4 文件读写fopen 模式与返回值检查文件 IO 的实例通常是一个「复制文件」的小程序看着简单但把打开模式、返回值检查、缓冲区大小这几件事都覆盖到了。/* examples/ch05_fileio/copy_file.c */ #include stdio.h #include stdlib.h #define BUF_SIZE 4096 int copy_file(const char *src_path, const char *dst_path) { FILE *in fopen(src_path, rb); if (in NULL) { perror(fopen src); /* 打印系统级错误原因比 printf 有用 */ return -1; } FILE *out fopen(dst_path, wb); if (out NULL) { perror(fopen dst); fclose(in); /* 打开第二个失败必须回滚第一个 */ return -1; } unsigned char buf[BUF_SIZE]; size_t nread 0; while ((nread fread(buf, 1, sizeof(buf), in)) 0) { size_t nwritten fwrite(buf, 1, nread, out); if (nwritten ! nread) { perror(fwrite); fclose(in); fclose(out); return -1; } } if (ferror(in)) { /* fread 返回 0 可能是读完也可能是出错 */ perror(fread); fclose(in); fclose(out); return -1; } fclose(in); fclose(out); return 0; }逻辑说明。打开模式用rb和wb显式加b表示二进制模式。在类 Unix 系统上文本模式和二进制模式没有区别但在一些平台上文本模式会做换行符转换复制图片、压缩包时就会损坏。写b是跨平台的好习惯。fread的返回值和ferror配合使用是这段代码的关键。fread返回 0 有两种可能读到文件末尾或者读取出错。只看返回值分不出来必须再调ferror(in)。很多示例代码省略了这一步读者照着写遇到磁盘错误时会静默地复制出一个截断的文件。fwrite也一样返回值是「实际写入的元素个数」。磁盘写满、管道断开都会导致写入数量小于请求数量必须检查。检查完之后记得关闭两个FILE *任何一个提前return都要把已打开的资源关掉否则就是文件描述符泄漏。这类「错误路径上的资源释放」是 C 语言里最容易漏的地方后面讲排查的时候会专门说。perror会打印「你给的字符串 冒号 系统错误原因」比printf(打开失败)的信息量大得多。用户看到No such file or directory和看到「打开失败」排查效率完全不在一个量级。4. 函数指针与多文件工程把散装实例拼成能维护的小项目单文件实例跑通之后下一步是把它们组织起来。这一章讲两件事函数指针怎么支撑「表驱动」的写法以及多文件编译时头文件和 Makefile 该怎么设计。这两块是从「会写 C」到「能维护 C 工程」之间最难跨的一步。4.1 函数指针与回调表一个计算器实例的两版实现计算器是函数指针最直观的例子。第一版用switch第二版用函数指针数组两版代码量差不多但扩展性差很多。/* 版本一switch 分派 */ int calc_switch(int a, int b, char op) { switch (op) { case : return a b; case -: return a - b; case *: return a * b; case /: return (b ! 0) ? a / b : 0; /* 除零返回 0调用方需自行判断 */ default: return 0; } }这版能跑但每加一个运算符就要改switch还要记得处理除零、溢出。第二版把「运算」抽成函数用一张表管理/* 版本二函数指针表驱动 */ typedef int (*binop_fn)(int, int); static int op_add(int a, int b) { return a b; } static int op_sub(int a, int b) { return a - b; } static int op_mul(int a, int b) { return a * b; } static int op_div(int a, int b) { return (b ! 0) ? a / b : 0; } /* 运算符表名称和函数一一对应新增运算符只需加一行 */ static const struct { char sym; binop_fn fn; } ops[] { { , op_add }, { -, op_sub }, { *, op_mul }, { /, op_div }, }; #define OP_COUNT ((int)(sizeof(ops) / sizeof(ops[0]))) int calc_table(int a, int b, char sym) { for (int i 0; i OP_COUNT; i) { if (ops[i].sym sym) { return ops[i].fn(a, b); /* 通过指针调用等同于 (*ops[i].fn)(a, b) */ } } return 0; /* 未找到运算符 */ }逐段说明。typedef int (*binop_fn)(int, int)定义了一个函数指针类型读法是binop_fn是指向「参数为两个 int、返回 int 的函数」的指针。用typedef之后结构体成员写binop_fn fn就很清爽不用每次都写一长串带括号的声明。static const struct { ... } ops[]是一个匿名结构体数组每个元素存一个符号和一个函数指针。static限制链接性只在本文件可见const让表放在只读段误写会直接触发段错误而不是悄悄改掉。OP_COUNT用sizeof计算元素个数加新运算符时不用手动改数字。调用处ops[i].fn(a, b)是标准写法(*ops[i].fn)(a, b)也对前者可读性更好。函数指针在这里的价值是「运行时选择」符号来自用户输入编译期不知道会走哪个分支表驱动把选择成本降到了数组遍历。这一版仍然有边界问题除零返回 0 会让1/0和0*0结果相同真实场景里应该返回状态码而不是魔数。这也是从实例代码走向工程代码时必须做的改造——示例可以简化接口不能含糊。4.2 头文件设计include guard 与 extern 的正确用法多文件工程的第一道坎是头文件。写错的话轻则重复定义报错重则链接期一堆undefined reference。这套资料的include/calc.h是标准写法/* include/calc.h */ #ifndef CALC_H #define CALC_H #include stddef.h /* 函数声明不带函数体 */ int calc_table(int a, int b, char sym); long sum_array(const int *arr, size_t n); /* 全局配置用 extern 声明定义放在某一个 .c 里 */ extern int g_verbose; #endif /* CALC_H */#ifndef CALC_H / #define CALC_H / #endif是 include guard防止同一个头文件被重复包含导致结构体、类型定义重复。现代编译器也支持#pragma once效果类似但不在标准里学习资料用 guard 更稳妥。extern int g_verbose;是声明告诉编译器「这个变量在别处定义」。真正的定义int g_verbose 0;只能出现在一个.c文件里。头文件里直接写int g_verbose 0;是新手高频错误如果这个头文件被两个.c包含链接时就会出现符号重复定义。头文件里只放声明、宏、类型定义不放函数体static inline除外。这条规则看着死板但它保证了「改一个.c不会触发全工程重编」。函数体放在头文件里任何一个包含它的源文件都会生成一份副本链接器还要处理重复符号。4.3 多文件编译Makefile 依赖规则与增量编译多文件编译的核心是「每个.c编译成.o最后链接成可执行文件」。手写命令很容易漏掉重编Makefile 的依赖规则就是干这个的。# 多文件实例的 Makefile 写法 CC ? gcc CFLAGS ? -stdc11 -Wall -Wextra -Wpedantic -g -O0 TARGET : calc OBJS : main.o calc.o array.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) $(OBJS) -o $ # 每个 .c 编译成 .o头文件变化也要触发重编 %.o: %.c include/calc.h $(CC) $(CFLAGS) -Iinclude -c $ -o $ clean: rm -f $(OBJS) $(TARGET)$是第一个依赖.c文件$是目标.o文件。-Iinclude告诉编译器去include目录找头文件。%.o: %.c include/calc.h是模式规则把任意.c映射到同名.o同时把头文件列为依赖——头文件一改所有.o都会重编。增量编译的价值在文件多的时候才明显改一个.c只重编这一个.o再重新链接几秒完成如果每次都用gcc *.c全量编译几十个文件的项目就要等十几秒。学习阶段感受不深但这是工程习惯的一部分早点建立没坏处。注意模式规则里把头文件写死成include/calc.h只是示例。真实项目头文件一多应该用-MMD -MP生成.d依赖文件再用-include $(DEPS)引入否则漏了依赖会出现「改了头文件但没重编」的诡异现象链接出来的行为对不上源码。5. 常见问题排查程序实例跑不通时的五个现场学习资料里的程序实例读者自己敲一遍十有八九跑不出一样的结果。原因集中在几类编译参数不一致、指针没初始化、输入函数残留、路径问题、编码问题。这一章按「先看现象、再找原因、最后给解法」的方式把最常见的五个现场过一遍。5.1 编译期头文件找不到与重复定义fatal error: calc.h: No such file or directory是最常见的第一个报错。原因基本是编译命令没带-I或者-I的相对路径写错了。gcc是从当前工作目录解析相对路径的如果cd到了别的目录再编译-Iinclude就找不到。解法是统一用 Makefile 管理编译命令路径写成相对于 Makefile 所在目录手动编译时用-I$(pwd)/include或者先确认pwd再敲命令。第二类是multiple definition of xxx。原因通常是头文件里定义了变量或者非inline函数被多个.c包含。解法是把定义挪到某一个.c里头文件只留extern声明如果确实需要在头文件里放函数体加static inline。第三类是undefined reference to xxx发生在链接阶段。编译单个.c能过但最后生成可执行文件时报错说明函数只有声明没有定义或者定义所在的.o没参与链接。检查两件事对应的.c是否加进了OBJS以及extern C是否误用纯 C 项目里不该出现。5.2 运行期段错误定位的三步法段错误Segmentation fault是 C 语言学习路上绕不开的东西它本身信息量很小只说「访问了不该访问的地址」。定位流程我固定成三步。第一步确认是哪个地址出错。用gdb跑一遍# 编译时带调试信息运行在调试器里 gcc -stdc11 -g -O0 -fsanitizeaddress list.c -o list gdb ./list进入gdb后敲run崩了之后敲btbacktrace会打印调用栈直接告诉你崩在哪个函数、哪一行。-fsanitizeaddress是地址检查器编译时插桩运行时能给出更精确的报告包括「读还是写」「地址偏移多少」「这块内存是谁分配的、谁释放的」。对 use-after-free 和越界访问它的报告比gdb的裸栈信息有用得多。第二步看崩的那一行在操作哪个指针。常见原因有三个指针未初始化就解引用、指针指向的内存已经free过、数组下标越界。前两个用-fsanitizeaddress基本能直接定位第三个要看循环边界和数组长度是否对得上。第三步如果崩的位置每次都不一样所谓「玄学崩溃」优先怀疑栈溢出或者堆破坏。递归太深会栈溢出越界写会破坏malloc的元数据表现为下一次malloc或free时才崩位置和根因完全对不上。这种情况把-fsanitizeaddress打开重跑一次通常能抓到第一次越界的位置。5.3 五个高频踩坑记录下面五条是我带人时反复见到的按「现象 → 原因 → 解决」写清楚。一、程序跑完没输出或者输出顺序乱七八糟。现象是printf的内容没出现或者崩溃前的内容丢了。原因是标准输出默认行缓冲输出到终端时按行刷新输出到管道或文件时按块缓冲程序异常退出时缓冲区没来得及刷新。解决是在调试用的printf后面加fflush(stdout)或者在崩溃前用setvbuf(stdout, NULL, _IONBF, 0)关掉缓冲。发布版本不要这么干性能损耗明显。二、scanf(%d, n)之后接着读字符读到了空行。现象是输入一个数字回车下一个scanf(%c, c)直接读到了换行符看起来像「跳过了输入」。原因是%d只消费数字回车留在输入缓冲区里%c不跳过空白字符。解决是在%c前加一个空格写成 %c或者用getchar()手动吃掉换行或者干脆整行读进来再用sscanf解析。三、局部数组返回给调用方拿到的是垃圾数据。现象是函数返回一个char *调用方打印出来内容随机甚至直接崩。原因是返回的是局部数组的地址函数返回后栈帧回收那块内存随时会被覆盖。解决是让调用方传缓冲区进来或者在函数内malloc并由调用方负责free并在注释里写清楚所有权归属。四、sizeof在函数参数里算不出数组长度。现象是void f(int arr[]) { printf(%zu, sizeof(arr)); }打印出 864 位平台指针大小不是数组真实长度。原因是数组作为参数会退化成指针sizeof拿到的是指针大小。解决是把长度作为单独参数传入或者用宏在数组还在作用域内时计算#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))注意这个宏只能在真正的数组上用不能用在指针上。五、文件路径带中文fopen返回 NULL。现象是英文路径能打开中文路径一律失败perror显示No such file or directory。原因通常是源文件编码和运行环境编码不一致字符串字面量里的中文在编译后变成了另一串字节。解决是统一源文件为 UTF-8Windows 控制台再单独处理代码页更稳妥的做法是把路径作为参数从外部传入程序内部不硬编码中文路径。这也是我后来写示例代码的习惯能不写死路径就不写死。6. 二次开发改造把实例沉淀成自己的工具库这套资料的实例是按知识点拆的每个文件都能独立编译但彼此之间没有复用关系。真正要拿它做二次开发第一步不是加功能而是把重复出现的代码抽出来。比如safe_strcpy、sum_array、链表操作这三类在多个章节里各写了一遍改造成一个include/下的公共头文件加一个src/下的实现文件后面所有实例都链接它改一处生效全局。改造时我一般按下面这个顺序走每一步都能独立验证不会一次改太多导致问题定位不了。改造步骤具体动作验证方式抽公共函数把重复的字符串、数组工具挪到src/util.c原实例改为包含util.h输出结果不变统一错误码用0成功、负值失败的约定替换魔数返回为每个失败分支写一个触发用例加单元测试每个公共函数配一个assert用例文件make test全绿才算完成开静态检查编译加-Werror再跑一遍-fsanitizeaddress零警告、零报告抽公共函数这一步最容易出问题的地方是「所有权没写清楚」。safe_strcpy是调用方提供缓冲区list_push是函数内部malloc、调用方负责list_free这两种模式必须在头文件注释里标出来否则接手的人一定用错。我的做法是函数名里带线索_into后缀表示写入调用方缓冲区_new后缀表示返回需要释放的新对象。统一错误码之后每个失败分支都要有对应的触发用例。比如fopen失败的分支测试时故意传一个不存在的路径fwrite失败的分支不好造可以用/dev/full这类特殊设备或者干脆在测试里注入一个假的写函数。没有触发过的错误分支等于没写。加单元测试不需要引入重型框架一个test_util.c里用assert加printf就够/* tests/test_util.c最朴素的断言式测试 */ #include assert.h #include string.h #include util.h int main(void) { char buf[8]; /* 正常截断源串比目标长必须保证 \0 结尾 */ safe_strcpy(buf, sizeof(buf), 0123456789); assert(strcmp(buf, 0123456) 0); /* 空指针保护不应崩溃直接返回 */ assert(safe_strcpy(NULL, 8, x) NULL); assert(safe_strcpy(buf, 0, x) buf); printf(all tests passed\n); return 0; }这段测试的价值在于把「边界条件」固定下来。size为 0、dst为 NULL、源串超长这三种情况手写代码时容易漏写成断言之后以后谁改实现都得先过这一关。最后一步是开-Werror再跑一遍地址检查。-Werror把警告升级成错误强制清掉所有「暂时不影响运行」的警告-fsanitizeaddress跑一遍所有测试用例抓内存问题。两样都过了这套改造才算站得住。说个自己的教训。早年我把示例代码直接抄进项目图省事没做所有权约定结果一个链表在三个模块之间传来传去最后谁该free谁都说不清线上跑了两天才定位到双重释放。从那以后我每次从资料里搬代码进项目都强制走一遍「抽公共函数、写清楚所有权、补边界用例」这三步哪怕只有二十行。这份资料里的实例本身写得规矩但规矩是给读者的起点不是终点。希望帮到你。本文还有配套的精品资源点击获取