简介“C语言学习资料 实例”以程序实例为主线服务于刚接触C语言、希望把语法知识转化为动手能力的初学者也适合需要回顾核心概念的开发者查漏补缺。内容围绕基本语法、算法、函数和文件操作展开配有学习笔记与可参考的示例代码便于按主题理解变量与数据类型、流程控制、排序查找、参数传递、数组遍历、指针与动态内存、结构体与联合体以及文件读写等知识点。压缩包共8个文件其中7个cpp源码文件和1个doc学习笔记整体约8KB体量轻巧适合随查随练源码可用于对照笔记运行与修改文档则承担概念梳理和要点归纳。目前已有135人学习说明其作为入门练习材料具有一定参考价值。读者可借助这些实例建立从语法认知到代码实现的连接逐步熟悉模块化组织程序和处理常见编程任务的思路为后续项目开发打基础。1. C语言学习笔记加程序实例它到底能替你省掉哪几步很多人学 C 语言卡住的地方不是语法而是“书看完了不知道下一行代码该写什么”。这份 C 语言学习资料的定位正好卡在这个缝里学习笔记负责把概念讲透程序实例负责把概念变成能编译、能跑、能改的文件。它适合刚过完语法、还没独立写过一个完整程序的人也适合工作几年、指针和内存管理一直靠“抄来能跑”糊过去的人。接下来按看清结构、配好编译链、跑通、拆核心、避坑、沉淀模板的顺序走一遍每一步都落到命令和代码上。2. 实例包解构先看清目录再决定从哪份 .c 开始读拿到一个 C 语言学习资料包最省时间的动作不是双击第一个.c而是先花十分钟弄清它的组织方式。教研资料和工程代码的差别就在这里工程代码通常有统一的构建入口而学习资料往往是一堆独立的小main每个文件都是一道自足的练习题。读错顺序的代价是你在一个需要三个源文件才能链接成功的例子上耗掉半小时最后怀疑自己环境有问题。2.1 用三条命令摸清目录分层先看目录层级和源文件分布这比翻 README 快。多数资料包会按“基础语法 / 指针 / 结构体 / 文件IO / 算法 / 综合练习”分目录个别包会按章节号命名比如ch03、ch07。名字不重要重要的是判断哪一层是“可以单独编译”的叶子目录。# 只看两层目录快速判断包的组织方式 find . -maxdepth 2 -type d | sort # 统计每个一级目录下 .c 文件的数量数量多的通常是重点章节 find . -name *.c | awk -F/ {print $2} | sort | uniq -c | sort -rn # 找现成的构建脚本和说明文件有 Makefile 的目录优先读 find . -maxdepth 3 \( -name Makefile -o -name *.md -o -name CMakeLists.txt \)第一条命令给的是骨架-maxdepth 2避免被深层目录刷屏。第二条里的awk -F/以斜杠分列$2取一级目录名uniq -c统计数量sort -rn按数量倒序输出结果一眼就能看出哪个目录是主体。第三条是在找“作者已经替你准备好的入口”有 Makefile 的目录说明这批例子是成组编译的读的时候要按组读不能拆开单看。接下来按“能被main直接跑起来”的优先级排序单个.c自带main的先跑需要多文件链接的放到第二轮只有头文件和声明、明显是给你补实现的放到最后。这个顺序的好处是反馈快跑通一个就有正反馈不至于在环境问题上先翻车。目录特征通常含义建议动作单文件带main无头文件语法点演示直接编译运行改参数看输出多文件 同名.h模块化示例先编译链接再按调用链读只有.h或函数骨架练习题自己补实现再对照笔记带Makefile或测试脚本综合项目先make跑通再拆模块2.2 把编译链固定下来别让环境变成黑匣子C 语言实例跑不起来八成不是代码问题是编译器版本和标准不一致。老教材里//注释、变长数组、gets这些写法在不同标准下行为不同先确认工具链再谈代码。# 确认编译器和构建工具的版本记录下来后面排错要用 gcc --version | head -n 1 clang --version | head -n 1 make --version | head -n 1三条命令任意一条报“command not found”就先把对应的工具装上再往下走。版本不用追求最新但要知道自己用的是哪一个因为后面-Wpedantic报出来的警告内容会跟版本相关。统一编译命令建议固定成一行读实例期间不要随意加减参数# 固定 C11 标准打开常用警告保留调试符号关闭优化 gcc -stdc11 -Wall -Wextra -Wpedantic -Wshadow -g -O0 hello.c -o hello逐个说清楚这几个参数。-stdc11把语言标准钉死避免编译器默认用更新的标准导致老代码报错-Wall -Wextra是最常用的一组警告能揪出未使用变量、符号比较、隐式声明这类问题-Wpedantic严格按标准检查会拦下void main之类的非标准写法-Wshadow专门抓变量遮蔽学习资料里内外层同名变量很常见-g保留调试信息是后面用 gdb 的前提-O0关闭优化保证源码行号和实际执行对得上。提示第一次通读实例时不要加-Werror。学习资料里的老代码警告成堆一开就把编译卡死反而看不到运行结果。等你要把某个例子收进自己项目时再开。2.3 多文件实例先编译成目标文件再链接带多个源文件的例子是最容易劝退的一类。直接gcc main.c stack.c -o demo当然能过但出问题时你不知道是哪个翻译单元的问题。分成两步编译错误定位会清楚很多。# 第一步各自编译成目标文件-I 指定头文件目录 gcc -stdc11 -Wall -Wextra -g -O0 -Iinclude -c stack.c -o stack.o gcc -stdc11 -Wall -Wextra -g -O0 -Iinclude -c main.c -o main.o # 第二步链接注意静态库的依赖顺序被依赖的放后面 gcc stack.o main.o -o stack_demo-c表示只编译不链接产出.o。-Iinclude告诉编译器去哪找#include stack.h路径写错会报fatal error: stack.h: No such file or directory这是头文件问题里最常见的一种。链接阶段如果报undefined reference to stack_push先检查是不是漏了某个.o再检查函数名拼写和static修饰符——被static修饰的函数只在本翻译单元可见跨文件调用必然链接失败。这套流程跑顺之后后面每读一个例子都可以复用同样的命令只是换文件名。真正需要注意的是“先跑通再改”不要在还没跑通的时候就去改代码否则出了问题你分不清是原代码的坑还是你改出来的坑。3. 三类核心实例指针、结构体、文件IO各自怎么拆C 语言学习资料里的实例看着杂真正撑起难度的就三块指针、结构体、文件 IO。这三块也是面试和工作里翻车率最高的地方。拆实例的正确姿势不是逐行读而是先找到“所有权”和“生命周期”这两条线再回头看语法细节。3.1 指针实例从取地址到链表重点是所有权指针章节的实例通常从“交换两个整数”开始到链表结束。前面的例子用来熟悉和*真正有价值的是链表因为它同时涉及动态分配、指针改写和释放。读这类例子时先问一句这块内存在哪里分配、由谁释放、释放之后还有没有人引用。#include stdio.h #include stdlib.h typedef struct Node { int value; struct Node *next; } Node; /* 头插返回新的头指针分配失败时原链表保持不变 */ static Node *push_front(Node *head, int v) { Node *n malloc(sizeof(*n)); /* 用 sizeof(*n)字段改名也不会算错 */ if (n NULL) { return head; /* 失败不破坏已有链表 */ } n-value v; n-next head; return n; } /* 释放整条链必须先保存 next 再 free 当前节点 */ static void list_free(Node *head) { while (head ! NULL) { Node *next head-next; /* 先存后继否则 free 之后就是悬空指针 */ free(head); head next; } } /* const 修饰的是节点内容只读遍历不该有机会改数据 */ static void list_print(const Node *head) { for (const Node *p head; p ! NULL; p p-next) { printf(%d - , p-value); } printf(NULL\n); } int main(void) { Node *head NULL; for (int i 0; i 5; i) { head push_front(head, i); } list_print(head); list_free(head); return 0; }push_front里用sizeof(*n)而不是sizeof(Node)这是血泪经验结构体字段增删时前者永远正确后者改一次漏一次。list_free里先存next再free顺序反了就是标准的 use-after-free程序可能在free那行不崩、在后面遍历时才崩排查起来很费劲。list_print的参数加了const编译器会帮你拦住“遍历时顺手改数据”的误操作。链表实例还有一处值得注意释放用循环不用递归。递归写法短但链表一长就爆栈学习资料里数据量小看不出来换成真实数据就翻车。跑通之后可以做个小实验把头插改成尾插观察返回值处理有什么不同——这是检验自己有没有真理解所有权的最快方式。3.2 结构体实例对齐、填充与序列化结构体章节的实例大多在演示“怎么定义、怎么访问”很少讲清楚sizeof为什么比字段加起来大。这个点不弄清楚后面写文件、发网络包、对接硬件寄存器时全是坑。#include stdio.h #include stddef.h struct Record { char tag; int id; char flag; }; /* 显式编码按字段写字节不依赖编译器填充和本机字节序 */ size_t record_encode(const struct Record *r, unsigned char *buf, size_t cap) { if (cap 6) { /* 1 4 1先做长度校验 */ return 0; } size_t off 0; buf[off] (unsigned char)r-tag; buf[off] (unsigned char)(r-id 0xFF); /* 固定小端 */ buf[off] (unsigned char)((r-id 8) 0xFF); buf[off] (unsigned char)((r-id 16) 0xFF); buf[off] (unsigned char)((r-id 24) 0xFF); buf[off] (unsigned char)r-flag; return off; } int main(void) { printf(sizeof(struct Record) %zu\n, sizeof(struct Record)); printf(offsetof tag %zu\n, offsetof(struct Record, tag)); printf(offsetof id %zu\n, offsetof(struct Record, id)); printf(offsetof flag %zu\n, offsetof(struct Record, flag)); return 0; }在常见的 4 字节对齐平台上sizeof输出是 12 而不是 6tag占 1 字节后面补 3 字节让id对齐到 4 的倍数id占 4 字节flag占 1 字节尾部再补 3 字节让整体大小是最大对齐数的整数倍。offsetof来自stddef.h用来打印每个字段的真实偏移比对一下就知道填充加在哪了。record_encode演示的是“不要直接fwrite整个结构体”。把结构体原样写进文件填充字节的内容是未定义的换个编译器或换个平台读出来字段就错位。手写字节序还有个前提通信双方要约定好大小端代码里固定小端只是其中一种约定。想压缩体积可以调整字段顺序int, char, char排下来通常只要 8 字节这个实验自己做一遍比看十篇文章都管用。3.3 文件IO实例返回值、errno 和资源释放文件操作实例最容易写出的问题是“正常路径对错误路径全漏”。学习资料里的例子为了短经常省略返回值检查照着抄进项目就是定时炸弹。#include stdio.h #include errno.h #include string.h int copy_file(const char *src, const char *dst) { FILE *in fopen(src, rb); if (in NULL) { fprintf(stderr, open %s: %s\n, src, strerror(errno)); return -1; } FILE *out fopen(dst, wb); if (out NULL) { fprintf(stderr, open %s: %s\n, dst, strerror(errno)); fclose(in); /* 出错路径也要关掉已经打开的文件 */ return -1; } char buf[4096]; size_t n; while ((n fread(buf, 1, sizeof(buf), in)) 0) { if (fwrite(buf, 1, n, out) ! n) { /* 短写必须当错误处理 */ fprintf(stderr, write failed: %s\n, strerror(errno)); fclose(in); fclose(out); return -1; } } if (ferror(in)) { /* fread 返回 0 也可能是出错不一定是 EOF */ fprintf(stderr, read failed: %s\n, strerror(errno)); fclose(in); fclose(out); return -1; } fclose(in); fclose(out); return 0; }几个参数和返回值值得逐条记。rb和wb里的b在 Linux 上没区别在 Windows 上不加会把\r\n转换掉拷贝二进制文件必须加。fread返回实际读到的元素个数返回 0 时要靠ferror区分“读完了”和“读错了”只看返回值会把磁盘错误当成正常结束。fwrite的返回值小于请求数量说明写满了或者出错了短写不处理拷出来的文件就是截断的。errno的用法也有讲究它只在函数失败后立刻有效中间夹了别的库调用就可能被覆盖所以strerror(errno)要写在紧挨着失败点的地方。这个例子跑通之后可以故意把目标路径改成一个不存在的目录观察错误分支有没有正确关掉源文件——这种反向验证比正着跑十遍有用。4. 让程序自己交代问题gdb、assert 与 Makefile 增量编译实例跑出预期结果不代表没问题很多内存错误在小数据量下是隐性的。这一章讲三个手段用调试器定位崩溃点用断言把不变量钉住用 Makefile 把几十个例子收进一个工程。三者配合能把“玄学崩溃”压缩成一条明确的行号。4.1 gdb 与 core dump先拿到崩溃现场程序段错误时最怕的是只知道“崩了”不知道“崩在哪”。打开 core dump再用 gdb 加载崩溃点会直接给出来。# 允许生成 core 文件只在当前 shell 生效 ulimit -c unlimited # 带调试符号编译先不加 sanitizer保证 gdb 能正常接管 gcc -stdc11 -Wall -Wextra -g -O0 list.c -o list # 运行如果崩溃会生成 core然后在 gdb 里看回溯 gdb -q ./list进入 gdb 之后这几个命令够用了# (gdb) run 运行程序 # (gdb) bt 打印调用栈从崩溃点往外看 # (gdb) frame 1 切到第 1 帧看调用者的上下文 # (gdb) info locals 打印当前帧所有局部变量 # (gdb) print *head 打印指针指向的结构体内容 # (gdb) x/8xw head 按十六进制看内存bt的第一行通常就是出错函数和行号frame N用来在调用链上切换info locals在怀疑某个变量被踩坏时特别有用。print *head能确认指针是不是空、next指向哪里。x/8xw是按 8 个 4 字节字看内存遇到“指针值看起来像 ASCII”这种诡异现象时用它确认。如果崩溃在库函数里比如strcpy、free第一帧往往不是你自己的代码往上切几帧才能看到调用点。这时候-O0就体现出价值了开了优化之后变量被寄存器化gdb 打印出来的值经常和你预期不一致白白浪费时间。4.2 assert把内部不变量写进代码调试器是事后手段断言是事前手段。两者分工要清楚assert用于检查“按设计不可能发生”的内部状态不用于校验用户输入。#include assert.h #include stddef.h /* 环形缓冲区写入调用方必须保证 pos 在范围内 */ int ring_write(char *buf, size_t cap, size_t pos, char ch) { assert(buf ! NULL); /* 内部不变量缓冲区一定已分配 */ assert(cap 0); /* 内部不变量容量一定大于 0 */ assert(pos cap); /* 内部不变量写入位置一定合法 */ buf[pos] ch; return 0; }关键点是assert在定义了NDEBUG的发布版本里会被整个去掉所以它绝不能承担参数校验的职责。外部传进来的数据该用if判断返回错误码内部状态才用断言。读学习资料时看到满屏assert别照抄先想清楚这个条件在正式编译时还需不需要保留。配合静态检查可以再进一步。-fanalyzer是编译器自带的路径分析能在不运行的情况下找出一部分空指针解引用和资源泄漏# 只做分析不产出目标文件把警告当线索用 gcc -stdc11 -Wall -Wextra -fanalyzer -c stack.c -o /dev/null # 外部静态检查工具按需安装 cppcheck --enablewarning,style --stdc11 .静态分析误报不少不要指望它清空所有警告把它当成“多一双眼睛”就够了。真正要养成习惯的是每读完一个实例把它编进自己的工程让警告一直保持在你眼皮底下。4.3 Makefile 把散落的实例收进一个工程学习资料里有几十个独立main一个个敲编译命令效率太低。写一个通配符规则所有.c自动生成同名可执行文件改哪个编哪个。CC : gcc CFLAGS : -stdc11 -Wall -Wextra -Wpedantic -g -O0 SRC : $(wildcard *.c) BIN : $(SRC:.c) .PHONY: all clean all: $(BIN) # 模式规则每个 x.c 编成可执行文件 x %: %.c $(CC) $(CFLAGS) $ -o $ clean: rm -f $(BIN)这里有三处容易踩的地方。$(wildcard *.c)是 Make 自己的函数不是 shell 的 glob写错成*.c裸放会拿不到文件列表。命令行前面必须是 Tab 而不是空格这是 Make 最经典的报错来源编辑器里要显式显示空白字符。$是第一个依赖这里就是.c$是目标名两个自动变量配合模式规则就不用给每个文件手写一条规则了。如果某个实例需要额外的链接参数单独加一行目标覆盖即可比如sock_demo: LDFLAGS -lpthread。这样既保留了批量编译的便利又不用为个例破坏整体结构。跑make -n可以先看它打算执行什么命令确认没问题再真跑。5. 避坑与常见问题编译能过但结果不对的五个现场C 语言最难受的地方在于编译通过率和正确率几乎不相关。下面五条是读实例、抄实例时出现频率最高的现场每条都按“现象、原因、解决”走一遍遇到时按顺序对照。5.1 字符串拷贝之后没立刻崩过几行才段错误现象strcpy目标缓冲区开小了执行那一行没事函数返回或者下一次printf时才崩用 gdb 看栈已经乱了。原因栈上的越界写破坏了相邻变量或返回地址破坏点和使用点不在同一处所以报错位置具有欺骗性。这类问题在数据短的时候完全不显现数据一长就爆。解决所有字符串拷贝改用snprintf并显式限长把长度算清楚再传。编译时加-fstack-protector-strong很多栈溢出会在函数返回时被拦截。复现阶段直接用 AddressSanitizer它会在越界写发生的那一行报错比 gdb 事后分析快得多。养成习惯只要函数参数里有char *加长度就先确认长度是不是由调用方保证的。5.2 sizeof(struct) 和字段累加对不上写文件读回来错位现象把结构体fwrite到文件再读回来某些字段变成乱码或者换一台机器编译同一个结构体大小变了。原因编译器按对齐规则在字段之间插入填充字节填充内容是未定义的不同平台的对齐要求和字节序也可能不同。直接把结构体内存布局当成文件格式等于把编译器的实现细节写进了协议。解决序列化一律按字段逐个写读的时候按同样顺序逐个解。用offsetof打印每个字段的真实偏移对照笔记里的对齐规则确认填充位置。确实需要紧凑布局时用#pragma pack或属性标注但要清楚这会带来非对齐访问的性能损失只在对协议有硬要求时用。5.3 scanf 和 fgets 混用第二次输入被直接跳过现象先scanf(%d, n)读一个数字紧接着fgets读一行字符串结果fgets读到空行程序像跳过了一步。原因scanf读走数字后把换行符留在了输入缓冲区fgets一进来就读到这个换行立刻返回。这不是 bug是格式化输入和行输入混用的必然结果。解决统一输入方式全部用fgets读整行再用strtol转换出错处理也更清楚。如果必须混用就在scanf之后加一个循环吃掉缓冲区里剩余的字符直到换行。读取返回值也要检查scanf返回成功赋值的项数返回 0 说明输入格式不匹配此时缓冲区里还留着脏数据不清理会污染下一次读取。5.4 函数返回局部数组的指针结果时对时错现象编译只给一条警告运行时有时正常、有时打印出乱码换个编译选项结果又变了。原因局部数组分配在栈上函数返回后这块内存就失效了返回的指针指向一片随时会被覆盖的区域。之所以有时看起来正常是因为那块栈内存还没来得及被别的调用覆盖。解决由调用方传入缓冲区并带上容量函数只负责填充或者内部malloc并在文档里写清楚由谁释放。编译时把-Wreturn-local-addr打开这类问题基本都能在编译期拦住。读别人的实例时看到“返回指针”的函数第一反应就该是问这块内存的所有权归谁。5.5 循环里 fopen 不关跑几百次后突然打不开文件现象小数据量测试完全正常批量跑几百个文件之后fopen开始返回NULLerrno是EMFILE。原因错误分支提前return时漏掉了fclose文件描述符只增不减达到进程上限后就再也开不了新文件。这类泄漏在短小的示例里根本暴露不出来一上批量就现原形。解决把资源释放收敛到单一出口用goto cleanup把正常路径和错误路径汇合到一处按打开的反序关闭。验证时用valgrind --track-fdsyes跑一遍泄漏的 fd 数量会直接列出来。更彻底的做法是把FILE *包一层小结构体配一个统一的关闭函数避免每处都手写fclose。6. 沉淀成固定动作一遍 sanitizer 扫全部实例再动手读读到这里方法其实已经齐了缺的是一个能重复执行的动作。我现在的习惯是拿到任何一份 C 语言实例包先不读代码先用 AddressSanitizer 和 UBSan 把所有能独立编译的实例扫一遍把有问题的先标出来再开始按章节读。# 逐个编译并运行先暴露内存和未定义行为问题 for f in $(find . -name *.c -not -path */.*); do out/tmp/$(basename ${f%.c}) if gcc -stdc11 -g -O0 -fsanitizeaddress,undefined \ -fno-omit-frame-pointer $f -o $out 2/tmp/build.log; then ASAN_OPTIONSdetect_leaks1:abort_on_error1 \ $out /dev/null 2/tmp/run.log \ || echo [FAIL] $f - $(head -n 3 /tmp/run.log) else echo [BUILD-FAIL] $f fi done几个参数值得说明。-fsanitizeaddress,undefined同时打开内存错误检测和未定义行为检测前者抓越界、释放后使用、双重释放后者抓有符号溢出、移位越界、空指针解引用。-fno-omit-frame-pointer保证栈回溯完整缺了它报错信息会少几层调用。detect_leaks1打开泄漏检测abort_on_error1让第一次出错就停住方便对着报错行看代码。这个循环有两个已知的误报来源要提前说清楚一是有些实例需要命令行参数或标准输入直接跑会失败二是部分例子本来就是演示错误用法的被 sanitizer 抓出来是设计如此。所以脚本的输出只能当线索不能当结论每条[FAIL]都要人工过一眼。真正的效率提升来自第二遍把扫出来有问题的例子单独建一个清单按问题类型归类——越界一类、泄漏一类、未定义行为一类。同一类问题看三个例子比零散看三十个例子印象深得多。我一般会在清单旁边记一行“这个坑我当时是怎么误判的”过一段时间回看误判记录比正确结论更有价值。从那以后我每接一个实例包都强制先跑一遍这个循环再翻代码宁可多花二十分钟也不愿意在一个已经埋了越界写的例子上调试一下午。希望帮到你。本文还有配套的精品资源点击获取