C 语言学习经验分享
自我介绍
00开场:为什么还要学 C
C 语言不是"找工作直接用的语言",但它是CS科班能力的地基。
01资源篇:视频课怎么选
浙大翁恺 C 语言
- 平台:中国大学 MOOC《C语言程序设计》,B 站有完整搬运
- 特点:讲得细、节奏慢、口语化,几乎零门槛,配套 PTA 习题
- 适合:零基础跟学,跟完应付期末完全够
- 注意:深度有限,有些细节点到为止,必须配合敲代码
哈佛 CS50
- 平台:edX 免费,B 站有中文字幕版
- 特点:讲得生动,Problem Set 质量很高。C 部分会讲内存、指针、文件操作,视角和国内教材完全不同
- 适合:看完翁恺基础之后,挑 C 相关周次看,尤其推荐动手做 Problem Set
- 注意:英文语速快,先看中文字幕;作业难度不低,做不完不要有挫败感
B 站 · Frank《C 语言大师系列》
- 平台:B 站 UP 主「Frank 讲明白」,前篇 41 小时、后篇 23 小时,合计六十多小时,全程免费
- 特点:讲得深。课本上一句"指针就是一个地址"就带过的东西,他会拆到内存布局、函数调用栈、寄存器、编译链接这一层;前篇打语法和核心(含指针、数组、结构体、文件),后篇往系统里走。风格很口语,边写边骂课本,不容易困
- 适合:翁恺跟完之后接,或者指针卡住时按目录挑对应章节看。想要"为什么是这样"而不只是"该怎么写",看这个
- 注意:体量太大,别想着从头看到尾。按目录挑「指针 / 内存 / 函数 / 结构体」对应集数,一次只啃一集,看完立刻自己写一遍
02教材篇:别买谭浩强,别买地摊书
关于谭浩强
- 初版是 1991 年,代码风格陈旧,大量使用
gets()这类已被 C11 标准移除的危险函数 - 示例重应试、轻工程,很多写法在现代 C 里是不推荐的
- 部分概念表述不准确(比如早期版本的
void main())
关于地摊书
凡是标题带"21 天精通""从入门到放弃""一本就够"的,一律别买。这类书多数是拼凑出来的,代码错误多,还会把坏习惯教给你。
入门先读这个:阮一峰的《C 语言教程》
免费、在线、不废话,一个周末能读完:wangdoc.com/clang/intro。
- 快:没有长篇铺垫,概念讲完就给例子,适合先把"C 大概长什么样"建立起来
- 现代:全篇用 GCC 命令行举例,
-std=c99、a.out、-o这些一开始就讲清楚,跟你上机用的是同一套东西 - 不绕:不堆术语,读到不懂的地方可以直接往下走,不会卡死
但它偏"语法与要点",讲到够用就停:指针只到能看懂的程度,内存布局、链接这些底层不展开。所以它是入门的第一本,不是唯一一本。
书单:入门一本,进阶一本,够了
| 资源 | 定位 | 什么时候看 |
|---|---|---|
| 阮一峰《C 语言教程》 | 免费在线,最快建立整体印象 | 入门第一周,边看边敲 |
| 《C Primer Plus》第 6 版 | 进阶:厚、全、讲得细,适合系统补细节,或当工具书查 | 入门之后(课程讲到指针前后)再啃,不必从头读到尾 |
| 《深入理解计算机系统》(CSAPP) | 底层视角,进阶 | 大二再看,偏难的"计算机导论" |
路线可以这么排:阮一峰过一遍 → 上课跟老师、每段代码自己敲 → 哪里卡住就翻《C Primer Plus》对应那章 → CSAPP 留到大二。《C Primer Plus》七八百页,从头读到尾的人很少,把它当"哪儿不懂查哪儿"更现实。
03开发环境:别在选工具上卡住
写 C 之前得先有个地方能编译、能运行、能调试。这件事本身不难,但选错了会先卡掉你半天——而且是被工具坑,不是被 C 坑。
先看两个别碰的。它们不是"过时了所以不好",是真的会让你学到错的东西:
for (int i = 0; ...) 这种写法直接编不过(C99 才允许);跟课程评测机、竞赛环境也不是一套——你在它上面"跑通了",换个 gcc 就是一堆错。Dev-C++(原版 5.11):2005 年就停止维护了,现在流传的版本调试器常和 gdb 对不上,中文路径、UTF-8 编码、报错定位都容易出毛病。看着像能用,真出问题没人修。
那用什么?按你现在要干的事选,不按"哪个更专业"选。下面这两组,界限很清楚:前四个是新手友好型——装完就能写,不用配环境,够你应付整个学期;后四个是长期型——要花半天到几天配置,但能用两三年不换。
| 工具 | 好在哪 | 代价 | 适合 |
|---|---|---|---|
| 新手友好 · 装完就能写作业 | |||
| 小熊猫 C++ Red Panda |
Dev-C++ 的现代重写版,自带编译器和调试器,中文界面,装完双击就能编译运行,C99 之后的语法全支持 | 生态小,只适合单文件作业,做不了大工程 | 上课写作业的首选,Dev-C++ 的正经替代 |
| OICPP IDE 信息学竞赛专用 |
为 OI 从头做的:云编译(一键验 Linux 下能不能过编)、对拍器、样例测试带差异高亮、一键抓取洛谷 / Codeforces / AtCoder 的样例,还能 PDF 分屏看题面 | 只管算法题;云功能依赖网络;项目比较新,遇到怪问题主要靠作者和文档 | 认真打 OI / 天梯赛 / 蓝桥的,天天刷题的 |
| CP Editor | 轻,一键编译运行、把样例贴进去自动比对、能直接提交到洛谷 / Codeforces | 只管单文件;代码高亮和补全比较弱,界面也偏简陋——冲着"好看好写"去的话会失望 | 偶尔刷题、只想要个极简外壳 |
| Visual Studio 2022 社区版,免费 |
真正省事:调试器是所有选项里最好用的,智能提示最全,出问题点一下就停在那儿 | 太重:装完几十 GB,启动慢,写一个 .c 也要先"新建项目" | 课程设计、大作业(Windows) |
| 可长期用 · 陪你到大三大四 | |||
| VS Code | 轻量、跨平台、插件多,写代码、看 diff、跑终端在一个窗口里;生态最大——很多你听说过的 AI 编辑器(Cursor、Trae 等)都是拿它二开出来的,所以在这儿学的一套,到那边基本原样能用 | 要自己配:装扩展、装编译器、配 tasks.json 和 launch.json |
想长期用、写项目、愿意配一次 |
| Zed Rust + GPU 渲染 |
和 VS Code 同一档的图形编辑器,但底层是 Rust + GPU 直绘:启动快、打字延迟低、内存占用小。原生 Git、内置 AI 和协作、支持 Vim 模式;学生用学校邮箱可以免费用 Pro | 扩展生态小得多(八百多个 vs VS Code 五万),C/C++ 的调试也得自己接;遇到冷门需求可能找不到插件 | 嫌 VS Code 卡、又不想放弃图形界面 |
| vim 终端里的老家伙 |
任何一台 Linux 上基本都有,改一行配置、连服务器救急,它永远在。资源占用几乎为零 | 学习成本全在按键上;插件和老配置体系比较旧,新手容易配出一堆冲突 | 需要"随处在、随手动"的人;只想应急的话学会 i / Esc / :wq 就够 |
| Neovim vim 的现代分支 |
异步执行、Lua 配置、内置 LSP,插件生态活跃;配好之后是效率最高的一档,也能装发行版(LazyVim 等)直接用 | 配置是无底洞:头一个月基本在改配置,不是写代码 | 对工具本身感兴趣、愿意长期投入 |
翻译成一句话的建议:
- 只想赶紧把作业跑起来:小熊猫 C++。装完就能用,中文界面不折腾
- 认真刷算法题 / 打比赛:OICPP。云编译、对拍、样例抓取这套流程,比手贴样例省太多
- 只是偶尔刷两题:CP Editor 够用,但别指望它的高亮和补全——它就是个外壳
- Windows 上写课程设计:Visual Studio。慢是慢,但调试真省事
- 想长期用一套、以后写项目:VS Code。花半小时配好,之后最舒服(配置见 12.3 节)
- 嫌 VS Code 卡:Zed。同样的图形界面,快一档,代价是插件少
- 想折腾、以后要上服务器:vim。值得学,但别在赶作业的那一周开始学
- 想一步到位、两三年不换:后四个里挑一个用熟就行,别三个都装——装三个不如用精一个
但有一件事跟工具无关:不管你用哪个,命令行那套迟早要会(第 12 节)。评测机、服务器、CI 上没有 IDE,只有 gcc 和一个黑框。IDE 是让你写作业舒服,命令行是让你在别人的机器上也能干活。
而且现在已经是 AI 的时代。不管你今天选哪个编辑器,两三年后大概率都在做 agentic coding——把需求丢给 agent,让它自己读仓库、改代码、跑测试、失败了自己修。到那时候,你敲得快不快、主题好不好看,都不再是竞争力;真正稀缺的是"能不能看懂它在改什么、判断它改得对不对"。
这也是为什么这门课要学 C:不是为了以后天天写 C,是为了懂底层——内存怎么排、指针指向哪、栈帧怎么建、链接器把什么拼在一起。这些恰好是 agent 干活时你看不见的那一层,也是你唯一还能比它更清楚的地方。工具会一轮轮换掉,这一层不会。
补一句:vim 的改版、发行版,还有几个平替
上面表里 vim 只占一行,其实它的生态比"一个编辑器"大得多。真要往这条路上走,这几个词迟早会撞见:
- Neovim:vim 的分支,现在社区的主力。异步执行(跑补全、跑格式化不卡界面)、配置用 Lua、内置 LSP。你在网上看到的"vim 配置",八成说的是它
- gVim:vim 的图形界面版,鼠标能点,其余操作一样。Windows 上想试又不想全键盘,可以先从它开始
- 发行版(配好的一整套):LazyVim / LunarVim / NvChad / AstroNvim。主题、补全、文件树、LSP 全替你配齐,装完直接写代码——比自己从零写配置省几个月
- 插件生态:vim 难配就难在什么都要自己挑。常见的几类:文件跳转(Telescope / fzf-lua)、文件树(nvim-tree / oil)、语法高亮(Treesitter)、补全与诊断(LSP + cmp / blink)、状态栏(lualine)、Git 标记(gitsigns),插件管理器用
lazy.nvim - 成本要提前知道:从零配一套顺手的 Neovim,头一个月基本在改配置而不是写代码。这不算坏事,但要挑空的时候做,别赶作业那一周
而且不是所有人都需要模态编辑。只想"在终端里改个文件"的话,下面这几个更省事:
| 工具 | 特点 | 什么时候用 |
|---|---|---|
nano |
几乎所有 Linux 都预装,屏幕底部直接列着快捷键:^O 保存、^X 退出(^ 就是 Ctrl)。零学习成本 |
连服务器改配置、临时救急。不会 vim 也能立刻上手 |
micro |
nano 的现代版:Ctrl+S 保存、Ctrl+Q 退出,鼠标能点,有语法高亮,按键跟常见编辑器一致 |
嫌 nano 太原始,又不想学模态编辑 |
helix |
模态编辑,但「先选中再操作」,比 vim 直观;自带 LSP、语法高亮和主题,配置文件很短 | 想要 vim 的效率,但不想折腾配置 |
顺序建议:先会用 nano 应急 → 觉得不够就用 micro 或 helix → 真打算长期投入,再上 Neovim 的发行版。别一上来就从零配 vim,那是最容易被劝退的路径。
04刷题平台:按阶段来,别一上来就卷
入门:洛谷
- 网址:luogu.com.cn
- 新手村题目(P1000 起步)专门给零基础准备,中文题面,题解多
- 学完数组和循环就可以开始刷,建议每周 3~5 题
- 中小学生很多
面试导向:力扣 LeetCode
- 网址:leetcode.cn
- 校招面试题的主力来源,题型系统
- C 语言的痛点:力扣没有内置哈希表、动态数组,很多题要自己手写,比较痛苦,但正好练指针和
malloc/free - 建议先刷简单题,后面做hot100,面试有用
main函数测试
笔试真题:牛客网
- 网址:nowcoder.com
- 大量校招公司真题,还有 C 语言专项选择题(指针、
sizeof、内存对齐这类坑题),适合查漏补缺 - ACM 模式(自己处理输入输出)和核心代码模式都有,两种都要会
- 讨论区可以看就业情况,改简历之类的,今年惨不忍睹
竞赛:Codeforces 与 AtCoder
- 全英文,国际平台
- AtCoder:ABC(AtCoder Beginner Contest)的 A~D 题质量高、题面清晰,对大一新生友好,推荐从 ABC 开始,每周六晚八点
- Codeforces:英文题面较长,Div.2 的 A/B 题难度不一,有 rating 系统,想打竞赛再深入
- 提醒:竞赛和工程是两条路,刷题上头之前先想清楚目标,目前竞赛性价比低
- 先自己想 30 分钟,想不出再看题解
- 题解看懂不算数,关掉自己重写一遍
- 写不出来就把卡住的点记下来,定期复习
- 每天 1~2 题比周末突击 20 题有效得多
写完一道题不确定对不对?除了交评测等结果,还可以自己造数据对拍:写一份暴力解跟它比,几百组数据跑下来,错在哪组一目了然。具体做法见第 08 节的「从对拍到 difftest」。
05核心难点:指针怎么理解
5.1 先建立内存模型
把内存想象成一排酒店房间:
地址 内容
0x1000 [ 42 ] <- 变量 a,int a = 42;
0x1004 [ 0x1000 ] <- 变量 p,int *p = &a;
三句话记住:
- 变量名只是给你看的,CPU 只认地址
- 每个变量都住在某个地址上
- 指针就是"房间号"本身,它自己也有房间
5.2 两个运算符
&x:取地址(拿到房间号)*p:解引用(顺着房间号走进去,操作里面的东西)*和&互为反操作
int *p,那个 * 不是解引用,只是标记"p 是个指针"。声明看类型,表达式才看运算。
类型很重要:int * 解引用时读 4 个字节,char * 读 1 个字节;指针加 1 不是地址加 1,而是加上"一个元素的大小"。
动手感受一下
下面这个演示模拟了 int a = 42; int *p = &a; 在内存里的样子。点「自动演示」看完整过程,也可以自己点按钮单步操作。地址是示意值。
5.3 进终端看:指针在指令层面做了什么
上面那个演示是"内存视角"。但 CPU 真正执行时,指针操作会被拆成两条指令,中间必须经过寄存器。寄存器是 CPU 内部的一小块超快存储,只有十几个格子;一条指令也不能直接把一个内存地址的内容搬到另一个内存地址——必须"内存 → 寄存器 → 内存"绕一圈。这就是指针操作看起来很绕的根本原因。
下面这个迷你终端是教学模拟(不是真 shell):输入 C 语句,它一步步执行,把 寄存器和内存格子的变化实时画在右边。先敲这几条试试:
回车执行,↑ / ↓ 翻历史命令,右上角可以重置。试试按顺序敲这五条:
int a = 42; // 常量直接写进 a 的内存
int *p = &a; // 取 a 的地址,存进 p
*p = 100; // 顺着 p 里的地址写 → 改的是 a
p a // 42 变成了 100
int *q = NULL; // 空指针,试试 *q = 1; 会怎样
跑完会发现三件事,正好是理解指针的关键:
&a不读 a 的值,只算地址。对应的指令是leaq(load effective address)——它只做地址计算,根本不访问那块内存。这类 C 与汇编的对应关系,12.4 节末尾有完整对照表- 指针变量自己也有地址、也占内存。64 位系统里
int *占 8 字节,int占 4 字节,所以 a 在 0x1000,p 在 0x1008 *p = 100是两条指令:先movq p(%rip), %rax把地址读进寄存器,再movl $100, (%rax)往这个地址写。p 从头到尾没变,变的是 a
*p 是一个符号,CPU 执行的是"读地址 → 再访存"两次操作。中间那个寄存器(rax)就是"临时拎着的门牌号"。理解了这个模型,int **pp、指针数组、链表节点里的 next 都只是这个动作套娃。
5.4 完整示例
#include <stdio.h>
int main(void) {
int a = 42;
int *p = &a; // p 存放 a 的地址
printf("a 的值 = %d\n", a); // 42
printf("a 的地址 = %p\n", &a); // 0x7ffd...
printf("p 的值 = %p\n", p); // 和 &a 相同
printf("p 指向的 = %d\n", *p); // 42
*p = 100; // 通过指针改 a
printf("a 现在 = %d\n", a); // 100
return 0;
}
5.5 指针和数组:其实只有一层语法糖
先把结论摆出来:C 里没有"数组下标"这个独立的东西,a[i] 只是 *(a + i) 的另一种写法。C 标准里下标运算符的定义就是这一条:E1[E2] 等价于 *((E1) + (E2))。既然是纯替换,而加法又能交换,那 2[arr] 也是合法的——能编过,但没人这么写。
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr; // 数组名退化为首元素地址
arr[2] // 语法糖
*(arr + 2) // 把糖拆开:先算地址,再解引用
*(2 + arr) // 加法可交换
2[arr] // 所以这个也合法(别在代码里这么写)
四行代码取到的是同一个 int(值为 3)。同理,结构体的 p->x 也只是 (*p).x 的糖,所有"看起来像运算符"的东西,底下都是地址加偏移。
那数组和指针是同一个东西吗?不是
"数组就是指针"这句话只在传参这一个场景下成立。真正的区别有三条,都能自己敲出来验:
数组 int a[5] | 指针 int *p | |
|---|---|---|
sizeof | 20(整个数组) | 8(一个地址,64 位) |
& 之后是什么类型 | int (*)[5],指向"5 个 int 的数组" | int **,指向指针 |
| 能不能赋值 | a = p; 编译错误,数组名不是左值 | p = a; 可以 |
| 有没有自己的存储 | 没有,它只是那块内存的名字 | 有,指针本身也占 8 字节 |
第二行值得盯一下。加 1 之后两者的差距会立刻暴露:
int a[5];
a + 1 // 地址 + 4 字节:跳过 1 个 int
&a + 1 // 地址 + 20 字节:跳过了整个数组
sizeof(a) // 20
sizeof(&a) // 8
同一个 a,加 1 走的距离差了 5 倍——因为 &a 的类型是"指向整个数组的指针",而 a 已经退化成"指向首元素的指针"。
为什么会"退化"
数组名在绝大多数表达式里会自动变成"指向首元素的指针",这个转换叫 decay(退化)。只有三个地方不退化:
sizeof(a):量的是整个数组,不是指针&a:得到"指向数组"的指针,类型int (*)[5]char s[] = "abc";:这里是把内容拷贝进数组,不是把指针赋过去
除此之外,代码里写 a 的地方,编译器都当成 &a[0]。
传参时它俩真的等价
函数参数里写 int a[]、int a[10]、int *a,编译器看到的是同一个东西。方括号里的数字会被直接忽略——它只是给人看的注释,不是长度检查。
void f(int arr[]) { // 等价于 void f(int *arr)
printf("%zu\n", sizeof(arr)); // 8,不是 20
}
void g(int arr[10]) { // 10 被忽略,和上面一模一样
arr[10] = 1; // 照样编过,没有任何人拦你
}
int arr[10] 里的 10 骗不了编译器,也拦不住越界——这是 C 里最经典的"看着像检查,其实什么都没有"。
二维数组:a[i][j] 是两层糖
新手常以为二维数组是"指针的指针",不是。int a[3][4] 是一整块 48 字节的连续内存,a 的类型是 int (*)[4](指向"4 个 int 的数组"的指针),a[i][j] 展开成 *(*(a + i) + j)。
int a[3][4];
a[1][2] // 就是 *(*(a + 1) + 2)
sizeof(a) // 48(3 * 4 * 4)
sizeof(a[0]) // 16(一行 4 个 int)
sizeof(a[0][0]) // 4(一个 int)
int **q = a; // 错:int ** 和 int (*)[4] 不是一回事
指针数组和数组指针,只差一对括号,但意思完全不同:
int *p[4]; // 数组,4 个元素,每个都是 int* → 指针数组
int (*q)[4]; // 指针,1 个,指向"4 个 int 的数组" → 数组指针
读法是从名字开始,先看括号里,再看右边,最后看左边:(*q)[4] → q 是指针,指向 4 个 int 的数组。
a[i],读代码想 *(a + i)。语法糖是为了让你写得清楚,不是让你忘记底下是什么——忘了底层,sizeof 和传参这两个坑就会一直踩。
5.6 为什么需要指针:函数参数
C 语言是值传递:函数收到的只是拷贝,改不了外面的变量。
void bad_swap(int x, int y) { // 无效!改的是副本
int t = x; x = y; y = t;
}
void good_swap(int *x, int *y) { // 有效
int t = *x; *x = *y; *y = t;
}
int main(void) {
int a = 1, b = 2;
good_swap(&a, &b);
printf("%d %d\n", a, b); // 2 1
return 0;
}
指针存在的三大理由:
- 让函数能修改调用者的数据
- 避免大结构体的拷贝,提高效率
- 表达"可能为空、可能没有"的语义(
NULL)
5.7 指针的常见坑(考前必背)
- 野指针:
int *p; *p = 10;指针没初始化,指向随机地址,直接崩溃 - 空指针解引用:
int *p = NULL; *p = 10;崩溃 - 悬空指针:函数返回局部变量的地址,函数结束后栈内存失效
- 数组越界:长度为 10 的数组访问
arr[10],不一定立刻崩溃,但会写坏相邻内存,这种"没崩的"最危险 - 修改字符串字面量:
char *s = "hello"; s[0] = 'H';未定义行为 sizeof与strlen混淆:前者包含\0,后者不包含malloc与free不配对:进阶内容,先记住"要配对"
5.8 练习方法
每写一个指针程序,在纸上画三列:变量名、地址、内容,用箭头画出指向关系。画一个月,指针就通了。指针这关至少留两周专门啃,别跳。二级指针先不用深究,见到不慌就行。
5.9 未定义行为(UB):编译器没义务按你想的来
上面反复出现"未定义行为"这个词。它不是"会报错"——恰恰相反,它连错都不报。
C 标准对一些写法不规定任何行为,只写一句 "undefined behavior",意思是编译器可以做任何事:照你想的跑、算出个垃圾值、把整段代码删掉、或者让程序在完全无关的地方崩掉。它描述的不是"某种错误结果",而是"这里没有规则"。
| 写法 | 为什么是 UB | 你可能看到的现象 |
|---|---|---|
arr[10](长度 10) | 那块内存不归你 | 多半没事,换个优化等级或编译器就崩 |
int *p; *p = 1; | 野指针 | 段错误,或者悄悄写坏别的变量 |
free(p); *p = 1; | 释放后使用 | 内存被别人复用后才崩,最难查 |
int x; if (x == 0) | 读未初始化变量 | 有时是 0,有时是垃圾值 |
x + 1(x 为 INT_MAX) | 有符号整数溢出 | 编译器可假定不会发生,直接删掉你的判断 |
a / 0(整数) | 除零 | 直接崩;浮点除零得到的是 inf |
char *s = "hi"; s[0] = 'H'; | 字面量在只读段 | 段错误 |
i = i++ + 1; | 同一句里改了两次 | 结果随编译器、随优化等级变 |
// 1. 你以为它在检查溢出,其实 -O2 下整个 if 会消失
int add_one(int x) {
if (x + 1 < x) { // 有符号溢出是 UB:编译器直接假定不会发生
printf("overflow!\n");
}
return x + 1;
}
// 2. 未初始化的变量,值不是"随机的",是"什么都可能"
int x;
if (x == 0) puts("zero"); // 进不进得去,天知道
// 3. 同一句里改两次同一个变量
int i = 0;
i = i++ + 1; // 不同编译器给出 0 或 1,甚至不同优化等级都不一样
-O0 基本按你写的顺序逐条翻译,越界写在栈上常常"刚好"落在没用的地方,于是侥幸跑对。一旦打开 -O2,编译器就开始利用"UB 不会发生"这个假设做变换——不是它变坏了,是它开始兑现你从没兑现过的承诺。所以"我这儿跑得好好的"从来不是证据。
怎么防,从便宜到贵:
- 编译开
-Wall -Wextra:未初始化、隐式声明、可疑比较,它能替你挡掉一批 - 用 sanitizer 跑一遍:
gcc -g -fsanitize=address,undefined main.c -o main。ASan 抓越界和 use-after-free,UBSan 抓有符号溢出、非法移位、空指针解引用——慢 2~5 倍,但报错精确到行号,比 gdb 事后猜强得多 - Linux 上再上 valgrind:
valgrind ./main,不用重新编译 - 根本上还是别写:变量一定初始化、指针先判空、循环写
i < n而不是i <= n、free之后把指针置NULL
i++ + i++、问"这段代码为什么要这么写",考的就是这件事。
06五道题自测
指针刚讲完,先花两分钟做下面五道题——全是刚讲过的东西。点击选项会立即给出对错和解析,答错了就往上翻回去看对应的那一条。
07代码可读性:写给三个月后的自己
先把一件事说清楚:可读性不是"好看",是维护成本。你写的代码有两个读者——编译器只关心语法对不对,三个月后的你才决定它还改不改得动。课程作业几十行,怎么写都能跑;但到了几百行、几千行,难读的代码会让你连自己的 bug 都找不着。
这一段讲四件事:名字怎么起、缩进怎么缩、注释写什么、函数怎么切。全是立刻能用上的,不需要任何新知识。
7.1 命名:让名字自己解释
一条判断标准就够:把这个名字读出来,能不能知道它是什么、用来干什么。读完还得猜的,就该改。
| 别这么写 | 改成 | 为什么 |
|---|---|---|
int a, b; | int studentCount, passedCount; | 类型不是身份,a、b 没有信息量 |
int flag; | int isSorted; | flag 只说"有个标记",没说标记什么 |
void f(int x); | int maxOf(int a, int b); | 函数名要说"做什么",动词开头 |
int temp; | int prevValue; | temp 只说"临时",没说临时存什么 |
int n;(跨二十行) | int nodeCount; | 作用域越大,名字要越具体 |
几条可以直接照做的规则:
- 布尔量用 is / has / can 开头:
isEmpty、hasCycle、canMove。这样if (isEmpty)读起来就是一句人话 - 常量全大写:
MAX_SIZE、INF,跟普通变量一眼区分开 - 指针名字带上"指向什么":
p用在三行的循环里没毛病,但head、current、prevNode比p1 p2 p3强太多 - 名字长度看作用域:循环下标
i、j是惯例,可以短;跨函数、跨几十行的变量必须写全
n、m、i、j 是公认缩写,题解和教科书都这么写,这时候不必强行改成 count。但只要代码是"要给别人看、要被别人改"的(课程设计、项目、实习),标准立刻变严。这是分场景,不是双重标准。
附:算法竞赛里的常见缩写
读题解、看队友代码,会撞见一大堆两三个字母的变量。它们不是随手起的——每个都有约定俗成的含义,看懂一套,所有题解就都通了。下面这份整理自几个流传很广的「算竞命名指南」,常用的一网打尽:
| 缩写 | 展开 | 含义 |
|---|---|---|
i, j, k | index | 循环下标,多层循环按顺序用 |
n, m | number | 规模:n 是元素个数,m 是边数或询问数 |
ans | answer | 最终答案 |
res | result | 中间结果 |
sum / tot | sum / total | 求和 / 总数 |
cnt | count | 计数器 |
cur | current | 当前量:当前节点、当前状态 |
tmp | temporary | 临时变量,交换和暂存用 |
val | value | 值 |
len / sz | length / size | 长度 / 大小(子树、容器) |
flag / ok | flag / okay | 布尔标记,是否成立 |
pre / nxt | previous / next | 前驱 / 后继(next 在 C++ 里是保留字,所以写 nxt) |
l, r | left, right | 区间左右端点 |
mid / low, high | middle / low, high | 二分中点与上下界 |
pos | position | 位置(下标或坐标) |
x, y / id | — / identifier | 坐标或输入量 / 编号 |
N / M / MAXN | maximum | 数组长度上界,常写 const int N = 1e5 + 5;。多留 5 个防越界 |
delta | delta | 增量、差值(差分、扫描线里常见) |
op / opt | operation / optimal | 操作 / 最优解 |
图论和树上有一套几乎全世界通用的搭配:
| 缩写 | 展开 | 含义 |
|---|---|---|
u, v, w | vertex, weight | 一条边的两个端点,w 是边权 |
fa / rt | father / root | 父节点(并查集)/ 根节点 |
dep | depth | 深度(树、BFS 层数) |
dis / dist | distance | 距离 |
deg | degree | 度数(入度 / 出度) |
vis | visited | 是否访问过 |
g[] | graph | 邻接表 |
head / to / nxt | head, to, next | 链式前向星三件套:表头、终点、下一条边 |
mst / scc | MST / SCC | 最小生成树 / 强连通分量 |
字符串、数学和算法名也各有一套:
| 缩写 | 展开 | 含义 |
|---|---|---|
s / t | string | 主串 / 模式串(成对出现) |
pat | pattern | 模式串(KMP、正则里) |
ch / str | char / string | 单个字符 / 字符串 |
mod / inf | modulo / infinity | 模数 / 无穷大(常写 0x3f3f3f3f) |
eps | epsilon | 浮点比较的容差,常写 const double eps = 1e-8;,判断 fabs(a - b) < eps 而不是 a == b |
gcd / lcm | greatest common divisor | 最大公约数 / 最小公倍数 |
dp / f | dynamic programming | DP 数组,f 是另一个万能名 |
st | state / stack | 状态(状压 DP),或栈——看语境 |
que / stk | queue / stack | 队列 / 栈 |
mp / mat | map / matrix | 映射(哈希表)/ 矩阵 |
cmp / init / chk | compare / initialize / check | 比较 / 初始化 / 校验 |
build / update / query | build, update, query | 数据结构三件套:建树、修改、询问 |
st 可能是 stack、start 或 state,p 可能是 pointer 或 parent,d 可能是 distance 或 degree。但同一份代码里,一个缩写只能有一个意思——如果你在一处用 st 表示栈、另一处表示起点,读的人就得来回猜,这比写全名更糟。
所以什么时候能缩写、什么时候不能,界线其实很清楚:
- 能缩写:作用域就几行(循环下标)、含义有行业共识(
cnt、res、cur)、写完就丢(比赛代码) - 别缩写:结构体字段、函数参数、跨文件的接口、要交上去的作业、要给队友接着改的代码——这些地方的名字会被反复读到,省那两个字母不值
以上整理自这几份传阅很广的清单,想看得更全可以直接翻:hzwer 版《算法竞赛变量命名指南》、按类别整理的命名方式、带示例的速查表。
7.2 缩进与格式:让结构一眼可见
缩进不是为了好看,是为了把"谁属于谁"画出来。读代码时眼睛先扫缩进轮廓,再往里看内容。
- 统一 4 个空格(或统一 2 个),一个文件里不许混用
- 绝不 tab 和空格混用。这就是"我这儿好好的、你那儿全乱"的元凶,也是 git diff 里一堆假改动的来源。VS Code 里把
editor.insertSpaces设成true、editor.tabSize设成4,按 Tab 自动变成四个空格 - 左大括号不换行(K&R 风格),项目内统一就行,不用纠结哪种更"正确"
- 一行只做一件事:
if (a) b();这种压缩写法,调试时连断点都不好下 - 用空行分组:变量声明一段、核心逻辑一段、return 前空一行
同一段冒泡排序,两种写法,逻辑一模一样:
int main(){int a[100],n,i,j,t;scanf("%d",&n);
for(i=0;i<n;i++)scanf("%d",&a[i]);
for(i=0;i<n;i++)for(j=0;j<n-i-1;j++)if(a[j]>a[j+1]){t=a[j];a[j]=a[j+1];a[j+1]=t;}
for(i=0;i<n;i++)printf("%d ",a[i]);return 0;}
#include <stdio.h>
#define MAX_N 100
int main(void) {
int values[MAX_N];
int n;
scanf("%d", &n);
for (int i = 0; i < n; i++) {
scanf("%d", &values[i]);
}
// 冒泡:每轮把最大的送到末尾
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (values[j] > values[j + 1]) {
int tmp = values[j];
values[j] = values[j + 1];
values[j + 1] = tmp;
}
}
}
for (int i = 0; i < n; i++) {
printf("%d ", values[i]);
}
return 0;
}
差别只在缩进、空格、命名和空行。但第二种你能一眼看出"外层在轮次、内层在比较、三个变量在交换"——这才是缩进真正省下的时间。
7.3 注释:解释"为什么",不是"是什么"
代码本身已经说了"在做什么",注释再复述一遍就是噪音。值钱的注释全是"为什么":为什么这里要 +1、为什么这个看着多余的分支不能删、这个魔数是从哪个公式来的。
// 废话:只是把代码翻译成中文
i++;
// 有用:解释了意图,别人就不敢乱删
i++; // 跳过行尾的 '\n',否则读下一个字段会多出一个空串
// 有用:解释了魔数的来源
// 采样率 44100 来自课程给定的音频格式,不要改
int samplesPerSecond = 44100;
- 函数头写清四件事:做什么、参数含义、返回什么、特殊情况(比如传 NULL 会怎样)
- 别用注释屏蔽死代码:不要了就删,git 里有历史。留一堆注释掉的代码,只会让后面的人不敢动
- 改了代码就改注释:过期注释比没有注释更危险,它会主动骗人
反过来:注释掉代码,什么时候是对的
上面那条针对的是长期留存。但作为一种临时手段,注释掉代码非常好用——它是手边最快的那个"开关",比加 if (0)、比删了再撤销都利索。
- 二分定位:程序挂得莫名其妙,先把后半段整个注释掉,跑一遍。还挂 → 问题在前半段;不挂 → 在后半段。重复四五次,几百行就缩到十几行
- 隔离依赖:怀疑是某个函数搞坏的,先把它的调用注释掉跑一次,因果关系立刻清楚,再决定往哪查
- 验证"这行有没有用":注释掉跑一遍,比盯着代码推理快得多。尤其是那些看起来多余的分支
// TODO(10-23): 临时屏蔽,等接口改完恢复。为什么不靠注释保存"以前怎么写"?git 已经替你存档了。想看历史版本,
git log -p / git diff 比翻一堆注释掉的代码清楚得多。注释里的历史,三个月后只会变成"谁也不敢删"的垃圾。
7.4 函数:小、单一、早返回
一个函数只做一件事。判断标准很土但有效:能不能用一句话说完它干了什么,而且这句话里不出现"然后"。"找到最大值然后打印然后写进文件"——三个动作,就该拆成三个函数。
// 不好读:括号越走越深,正常情况被埋在最里面
int getScore(Student *s) {
if (s != NULL) {
if (s->scores != NULL) {
if (s->count > 0) {
return s->scores[0];
}
}
}
return -1;
}
// 好读:先把所有异常挡在门口
int getScore(Student *s) {
if (s == NULL) return -1;
if (s->scores == NULL) return -1;
if (s->count == 0) return -1;
return s->scores[0];
}
- 嵌套超过 3 层就该重构,早返回是最省事的解法
- 函数控制在一屏内(约 50 行)。超了,说明它做的事不止一件
- 参数超过 4~5 个,考虑打包成结构体
- 有副作用的函数,名字里体现出来:
list_pop会改链表,list_peek不会——看名字就该知道
7.5 写完先过一遍这六条
- 变量名读出来像人话吗?
a1、a2、temp、flag还剩几个? - 缩进和大括号风格,整个文件是一致的吗?
- 有没有超过 3 层的嵌套?
- 有没有哪个函数超过一屏?
- 有没有注释在解释"这行在干什么"?如果有,多半是代码本身没写清楚
- 同一段逻辑复制粘贴了三处以上吗?那就该抽成函数了
snake_case 还是 camelCase——这些争论了几十年也没结论。唯一真正重要的是:同一个项目里保持一致。想系统看规范,可以翻 Linux 内核的 Documentation/process/coding-style.rst 或 Google C++ Style Guide;但大一阶段,做到"你自己前后一致"就已经超过大多数人了。
08Debug 能力:比会写代码更重要
写代码的时间有一半是在调试。调试能力是初学者和入门者的分水岭。
第一原则
编译器的报错是朋友,不是敌人。报错列表先看第一条,后面的错误往往是连锁反应,改掉第一个可能全消失。编译时加上 -Wall -Wextra,让编译器把警告都说出来。
四个实用手段
- printf 大法:在最可疑的变量前后打印,缩小范围,初学者最实用
- 断点单步:VS Code + gdb 或 IDE 自带调试器,学会看变量值和调用栈。gdb 命令速查见 第 12 节
- 最小复现:把有问题的代码复制到新文件,删无关部分,一直删到 bug 消失,最后留下的那段就是问题
- 二分注释法:注释掉一半代码,看 bug 还在不在,不断二分定位
常见错误分类
| 类型 | 表现 | 常见原因 |
|---|---|---|
| 编译错误 | 编不过 | 语法、缺分号、类型不匹配、漏头文件 |
| 链接错误 | 编译过了链接失败 | 函数声明了没定义、main 重复 |
| 运行时错误 | 段错误 | 指针未初始化、数组越界、free 后使用 |
| 逻辑错误 | 能跑但结果不对 | 条件写错、循环边界、变量未更新 |
段错误排查清单:指针初始化了吗?数组越界了吗?释放后又用了吗?
从对拍到 difftest:让两份实现互相验证
前面这些手段都靠"人眼看"。还有一种更省力的思路:写两份实现,让它们互相比。竞赛里叫对拍(也叫"拍"),工业界叫差分测试 differential testing,在模拟器和芯片领域简称 difftest。三个名字是同一个想法,差别只在于"比多细"。
对拍永远需要三样东西:
sol:你写的主程序,可能是错的(要验的就是它)brute:暴力解法,慢得要死,但一定对gen:随机数据生成器
然后循环:造一组数据 → 两个程序各跑一遍 → 比输出。一样就换下一组,不一样就立刻停下,把那组数据留下来。
三个程序都用 C 写,驱动用一段 shell 脚本(Windows 上在 Git Bash 里跑):
gcc -O2 -o sol sol.c
gcc -O2 -o brute brute.c
gcc -O2 -o gen gen.c
for i in $(seq 1 1000); do
./gen > in.txt # 造一组数据
./sol < in.txt > a.txt # 你的解
./brute < in.txt > b.txt # 暴力解
if ! diff -q a.txt b.txt > /dev/null; then
echo "第 $i 组挂了,数据留在 in.txt"
break
fi
done
生成器是成败关键,几行就够,但要注意几点:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
srand(1); // 固定种子:挂了能原样复现
int n = rand() % 8; // 规模小到暴力跑得完
printf("%d\n", n);
for (int i = 0; i < n; i++) printf("%d ", rand() % 10);
return 0;
}
- 规模小,但结构刁钻:n = 0、n = 1、全相同、已排序、逆序、全是极值——这些边界比随机数更容易拍出问题
- 先用一道你确定写对的题验证脚本本身。如果连对解都报"不一致",八成是输出末尾的空格或换行不一样,不是真 bug
- 挂了之后先做最小化:把出错的输入不断删元素,删到"再删就不挂了"为止。最后往往只剩十几行,bug 一眼就能看出来
但注意:对拍比的是"最终输出"。而输出对了,过程可能是错的(越界写、未定义行为、内存泄漏);更麻烦的是有些程序根本没有输出——比如你在写一个模拟器、一个操作系统。这时候就该 difftest 上场了。
difftest 把比较点从"最后一行输出"挪到"每执行一条指令之后的状态":寄存器、PC、内存,逐字节比。参考实现一般是 QEMU 这类"已经确定正确"的权威实现。南京大学 PA 就是这么做的,伪代码简化后大概长这样:
void difftest_step(vaddr_t pc, vaddr_t npc) {
CPU_state ref_r;
ref_difftest_exec(1); // 让参考实现也执行一条
ref_difftest_regcpy(&ref_r, DIFFTEST_TO_DUT); // 取回它的寄存器
if (!isa_difftest_checkregs(ref_r, pc)) { // 逐字段比,不是整块 memcmp
printf("[difftest] mismatch at pc = 0x%08x\n", pc);
isa_reg_display(); // 我的状态
ref_reg_display(); // 参考实现的状态
assert(0); // 当场停下
}
}
报错大概长这样(不同 ISA 寄存器名不一样),差别就在那一个寄存器上:
[difftest] mismatch at pc = 0x80000010
ref: eax = 0x00000001 ecx = 0x00000002
dut: eax = 0x00000000 ecx = 0x00000002
| 对比项 | 对拍 | difftest |
|---|---|---|
| 比什么 | 程序的最终输出 | 每条指令后的寄存器 / PC / 内存 |
| 拿谁当标尺 | 自己写的暴力解 | QEMU 这类权威实现 |
| 定位精度 | 一组数据 | 一条指令 |
| 代价 | 要额外写一份暴力解 | 要有金标准,慢很多,只在调试时开 |
json,写排序就对 qsort,写 NEMU 就对 QEMU。想不出反例、或者改一处怕弄坏另一处的时候,这套办法比盯着代码看有效得多。
09AI 使用原则:外功可以借,内功借不来
两年前的经验是"别依赖 AI,面试要手撕"。这句话现在只对了一半:手撕确实少了。越来越多的面试直接给你一个仓库、一个需求,让你开着 Copilot / Cursor 现场做,或者在 take-home 里随便用——考的是你和 AI 协作的能力:怎么拆问题、怎么审它的输出、跑不通了怎么定位。不适应这个变化,等于自己把机会推掉。
但另一件事没变:内功和外功,是两种能力。
- 外功:工具用得熟、prompt 写得清楚、知道哪个模型擅长什么、能快速验收。速成很快——AI 自己就能教你,几周补得上来;但过时也快——去年的工具,今年已经没人提了
- 内功:代码读得懂、对错判得出、内存画得出、崩了能定位、复杂度心里有数。这些 AI 教不了你,只能自己一行行写。学得慢,但不折旧——指针和内存模型几十年没变过
而面试考 AI coding 的时候,筛的恰恰是内功:AI 十秒钟给你二十行,你能不能一眼看出 i <= n 越界了、malloc 没判空、字符串字面量被改了——这才是他们想看的。AI 写得越快,这个判断力越值钱。
| 对比项 | 内功 | 外功 |
|---|---|---|
| 是什么 | 读得懂、判得对、修得动 | 会用工具、写得清 prompt、接得上流程 |
| AI 能替你补吗 | 不能 | 能,几周就够 |
| 怎么练 | 关掉 AI 手写、读别人的代码、自己 debug | 多用、多看别人怎么用 |
| 面试里怎么体现 | 一眼看出 AI 的代码错在哪 | 十分钟把能跑的原型搭起来 |
| 过时速度 | 很慢,三十年前的书今天还能看 | 很快,一年换一轮 |
把 AI 当副驾驶,别当代驾
- 自己先写 20 分钟,不是 0 分钟(那叫外包),也不是两小时(那叫浪费)
- 报错先自己读。要给 AI 看,就连"我试过什么、我猜是哪里"一起给它,别只贴一行错误
- 问"为什么",别问"给我答案"。好问法:"我这段在
n = 0时输出乱码,为什么?"坏问法:"这题怎么写" - AI 给完先别运行:从头读一遍,标出你不同意的地方,再去验证
- 关掉对话自己重写一遍。写不出来,说明刚才只是"看懂了",不是会了
- 自己补边界:0、1、最大值、空指针,AI 经常只写"正常情况"
gets()、sizeof 用错、指针类型对不上,它照样自信地给你。
教学在改,但界线没变:让 AI 教你,不是替你写
学校这两年在推 AI 进课堂:作业开始允许用、考试形式在改、有的课干脆考"你怎么用 AI"。这不是放水,是承认现实——以后工作就是这样,早点适应没坏处。
但有一条界线得先划清楚。C 语言这门课,本质上是"1+1、加减乘除"。它不考你能不能想出什么巧妙算法,考的是基本动作会不会:变量怎么声明、循环怎么写、指针指向哪。就像小学算术,你可以让老师把"为什么 3×4 = 12"讲十遍,但你不能让老师替你把作业本填了。
- 让 AI 讲:这个概念什么意思、这段代码为什么错、这个报错在说什么、还有没有别的写法——这是把 AI 当家教,越用越占便宜
- 让 AI 写:题目丢进去、答案复制出来交上去——这不叫"会用 AI",这叫把唯一的练习机会让掉了。而且老师一眼能看出来:变量命名、注释习惯、一问思路就卡住,全露
更现实的一点:这门课是后面所有课的地基。数据结构、操作系统、编译原理,全都默认你会指针和内存。在这里让 AI 替你做,不是省了一门课,是把后面那几门一起挖空。
附:如果你打算"跑路"硬件,C 就是那张门票
说点更现实的。这两年互联网这边在收缩——校招名额、实习转正、涨薪幅度都不如从前,"降本增效"四个字大家都听腻了。与此同时另一头在扩:新能源车、消费电子、工业控制、机器人、无人机、芯片,都属于软硬结合的方向。这些岗位招人时不太看你刷了多少题,看的是你能不能把程序烧进板子里,让它真的动起来。
而这条路的第一道门槛就是 C,绕不过去。不是"学过 C 就行",是这几样必须真会:
- 位运算:置位
|=、清位&= ~、移位。寄存器就是这么一位一位操作的 - 指针和内存映射:外设寄存器在手册里就是一个地址,写
*(volatile uint32_t *)0x40021000 = 1;才叫"点了个灯"。指针在这里不是练习题,是唯一的操作方式 volatile:编译器会把"它看起来没变"的变量优化掉,可硬件随时在改它。少写一个volatile,程序就"偶尔"不对- 结构体对齐和大小端:解析协议包时字段偏一个字节,整条通信全乱,而且不报错
- 栈有多大:单片机上可能只有几 KB。递归深一点、局部数组大一点就直接溢出——这里没有操作系统替你兜底
- 调试手段:常常没有串口可 printf,断点常常一跑就飞,得会看寄存器、看波形、对着数据手册猜
看出来了吗?这些恰好就是前面几节讲的"内功":指针、内存布局、编译链接。所以你不需要"重新学一门语言",只要把 C 的底层补齐,再补领域知识(时钟、中断、外设、RTOS)。反过来说,如果 C 只停在"考试能过"的水平,转过去等于从零开始。
至于 AI 在这个方向上的角色:它能帮你写上层业务逻辑、搭驱动框架、解释手册里的缩写,但"这个时序对不对""这个寄存器为什么读回来是这个值"——它替不了你。越靠近物理世界,判断力越值钱,这也正是前面说内功不折旧的原因。
10答疑
11课程推荐:从工具到造一台计算机
这一段放在最后,现场来不及讲。C 语言课只解决"能写出程序",接下来真正该补的,是程序在机器上到底怎么跑起来:内存怎么排、指令怎么执行、链接器干了什么、进程怎么切换。这条路列了四门课(其中 ICS 的中文版和原版其实是同一门,算一门),难度差得很大,按顺序打最省事。
① MIT The Missing Semester最易
- 讲什么:shell 与脚本、vim、Git、tmux、调试与性能分析、命令行环境。全是课上不教、但写代码天天要用的东西
- 为什么放第一:它不教你写程序,只教你怎么高效地用电脑干活。C 语言学得再熟,不会 Git、不会 shell,一上手项目就卡住
- 什么时候看:大一一上来就可以看,和 C 语言同步。一周能刷完,边看边在自己机器上敲
- 注意:内容浅但密度高,看完必须立刻在自己机器上练一遍,光看等于没看
不想等课程更新到那一讲的话,文末第 12 节已经把这四样工具最常用的部分整理成了可以直接抄的速查。
② OpenCamp · Learning C Camp入门到进阶
- 是什么:OpenCamp 社区办的 C 语言训练营,编课思路学《新概念英语》——不孤立讲语法点,每课给一段可编译、可运行的经典代码,用真实程序带知识点
- 怎么排:Unit 0 从
int main开始跑通第一个程序;Unit 1 覆盖数组、字符串、结构体、指针、函数指针;Unit 2 用实现ls、sort、sed这些 Unix 命令练系统编程;再往后 Unit 4 写 C 编译器、Unit 5 写 C 内核 - 为什么推荐:这是这份材料里唯一把"C 本身"练到底的一门。学校课上到结构体就结束了,它能一路把你带到"自己实现 printf""写词法分析器"这种程度——正好也是第 05 节那些指针、内存概念的实战版
- 配套工具 Clings:Rustlings 风格的命令行练习,读报错 → 修代码 → 保存 → 自动验证,一条命令就能拉起整套练习,很适合练手感
- 注意:训练营按寒暑假开营,错过就跟着往期材料自学;Unit 0–2 大一就能跟,Unit 4/5 建议放到学完 ICS 之后,不然编译器那关会很痛苦
③ ICS:CMU 15-213 与天津大学版进阶
- 先说结论:CMU 的 15-213 和天津大学的《计算机系统基础》讲的是同一套东西——数据表示、汇编、处理器、存储层次、链接、异常控制流、虚拟内存。区别只在语言和节奏,不用当成两门课
- 天大版:中文授课,把一学期的内容拆成两个学期,节奏更慢。啃原版啃不动的时候,先跟它过一遍
- CMU 原版:一学期讲完,语速和作业量都是原汁原味。精华全在 lab:Data(位运算)、Bomb(读汇编拆炸弹)、Attack(缓冲区溢出)、Cache、Malloc(自己写 malloc)、Shell、Proxy(写个 Web 代理)
- 关键:两边实验一模一样,课本都是 CSAPP。所以最优解不是二选一,而是——用中文版听讲解,拿原版 lab 动手
- 注意:这是"系统课"不是"C 语言第二遍"。Bomb Lab 做完,汇编这关就算过了;Malloc Lab 做完,指针和内存管理才算真懂。别只看视频不做 lab
④ 南京大学 PA(Programming Assignment)硬核
- 讲什么:不再"看懂"计算机,而是自己造一台。从取指译码开始实现指令、异常、IO、ELF 加载,一路做到能真正跑起程序,往上可以接操作系统和编译器
- 为什么最难:它是工程,不是听课。代码量大、调试周期长,很多 bug 只能靠自己扒波形和日志
- 什么时候做:过完 ICS 之后,最好放在寒暑假集中推进。想走体系结构、编译、OS 方向的话,这一段经历含金量很高
- 注意:做不完是常态,没必要因为卡在某一关就全盘放弃。哪怕只做完指令实现那几关,你对"程序怎么跑起来"的理解也已经是另一个层次
- 大一上:C 语言 + Missing Semester(周末看完,立刻配好自己的环境)
- 大一寒假:刷 Learning C Camp 的 Unit 0–2,趁热把 C 练熟
- 大一下:跟 ICS,中文版或原版选一个就行(同一门课,别重复学两遍)
- 大二上:把 ICS 的 lab 挑着做,重点 Bomb Lab 与 Malloc Lab
- 大二寒暑假:有余力再上 PA,当项目做,不当课听
12工具链:没人教,但默认你会
这一段是整场分享里最"功利"的部分,现场没时间展开,但你回去一定会用到。学校课上不会讲,可这四样东西——学长默认你会、助教默认你会、公司面试官默认你会、连报错信息都默认你看得懂。它们恰好就是 MIT 那门 Missing Semester 的内容,课名直译过来就是"缺失的一学期":学校默认有人教过你,其实并没有。
| 工具 | 你用它干什么 | 不会的后果 |
|---|---|---|
shell | 运行一切命令,把工具串成流程 | 连编译都得靠 IDE 点按钮,换个环境就不会了 |
gcc | 把 .c 变成能跑的程序 | 报错看不懂,也不知道怎么加调试信息 |
gdb | 程序崩了,找出是哪一行、哪个变量 | 只会 printf 二分,越界和悬垂指针根本查不出来 |
git | 存档、回滚、协作、交作业 | 代码写崩了没处找回,简历上也没有 GitHub |
12.1 shell:命令行才是主界面
编译器、调试器、git 全都是命令行程序,图形界面只是套在外面的一层壳。服务器上没有图形界面,以后跑实验、连远程机器,你面对的就是一个黑框。先接受这件事,剩下的就是记命令。
| 想干什么 | 命令 | 备注 |
|---|---|---|
| 我在哪个目录 | pwd | 迷路就敲它 |
| 看目录内容 | ls -la | -a 显示隐藏文件,-l 看权限和时间 |
| 切换目录 | cd ../src / cd - | cd - 跳回上一个目录 |
| 建目录 / 建空文件 | mkdir -p a/b / touch f.c | -p 会把父目录一起建出来 |
| 复制 / 移动改名 | cp -r src dst / mv a.c b.c | 复制目录必须带 -r |
| 删文件 / 删目录 | rm f.c / rm -rf build | rm -rf 没有回收站,回车前看清路径 |
| 看文件 | less main.c / head -n 20 f / tail -f log | tail -f 用来盯运行日志 |
| 按内容搜 | grep -rn "main" . | -r 递归,-n 带行号 |
| 按名字找文件 | find . -name "*.c" | 通配符要加引号,否则被 shell 先展开 |
| 程序装在哪 | which gcc | 排查 command not found 第一步 |
三个必须先搞懂的概念,就三句话:
- 一切输出都是文本流:程序往
stdout吐字符,往stderr吐错误 - 管道
|:把前一条命令的输出,直接当成后一条的输入 - 重定向:
>覆盖写入、>>追加、2>收错误、2>&1把错误并进正常输出
# 编译并运行:&& 表示前一条成功(返回 0)才执行后一条
gcc main.c -o main && ./main
# 管道:数一数编译日志里有多少行 error
cat build.log | grep -c "error"
# 重定向:正常输出和错误输出都收进同一个文件
./main > out.txt 2>&1
# 找出所有 .c 文件,统计总行数
find . -name "*.c" | xargs wc -l
还有两个坑,几乎人人踩过:
- PATH:敲命令时系统只去
$PATH列出的目录里找。用echo $PATH看一眼,自己装的东西不在里面就会出现gcc: command not found - 引号:单引号
'...'里面原样不动,双引号"..."会解析变量。变量用在路径上时永远加双引号,否则路径带空格会被拆成两个参数 - 大小写敏感:Linux 下
Main.c和main.c是两个文件
cmd 里学这些,PowerShell 的语法也不一样(ls 只是个别名,重定向行为也不同)。两条正路:① 装 Git for Windows,用自带的 Git Bash,命令基本和 Linux 一致;② 直接上 WSL2(管理员终端里 wsl --install),得到一个真 Linux。编译器装 MinGW-w64,或者干脆全在 WSL 里做。VS Code 的内置终端就是 shell,可以直接在里面敲。
12.2 vim:编辑器的保底技能
先说清楚:不是让你放弃 VS Code 改用 vim。但有两种场景躲不掉:一是 ssh 连到服务器上,只有 vi/vim,改一行配置就得用它;二是面试和实习里,别人默认你会。而且 vim 的按键习惯在 VS Code、IDEA、JetBrains 里都有插件,学一次到处能用。
vim 最反直觉的地方是它有模式。打开文件先落在普通模式,这时候按字母是"命令",不是"打字"。新手 90% 的困惑——"一排 i 出来一堆字""怎么打不出字了""退不出去"——都是模式没搞对。
下面这些在普通模式下按,背完就能干活:
| 想干什么 | 按键 | 记忆点 |
|---|---|---|
| 光标移动 | h j k l | 左 下 上 右,右手不用离开主键区 |
| 按词跳 | w / b | word 前进,back 后退 |
| 行首 / 行尾 | 0 / $ | 0 是开头,$ 是结尾(正则里同理) |
| 文件开头 / 结尾 | gg / G | 大写 G 一下到底 |
| 跳到第 50 行 | 50G | 编译报错第 50 行时直接跳过去 |
| 进入插入模式 | i / a / o | 光标前插 / 后插 / 下面开新行 |
| 回到普通模式 | Esc | 忘了就先按两下,不亏 |
| 删一个字符 / 一整行 | x / dd | dd 删掉的那行进了剪贴板 |
| 复制一行 / 粘贴 | yy / p | yank 拽走,paste 贴回来 |
| 撤销 | u | undo,按几次退几步 |
| 搜索 | /关键字 再 n | n 下一个,N 上一个 |
| 保存退出 / 不保存退出 | :wq / :q! | write quit,感叹号是强制 |
改一个文件的最小流程,就四步:
vim main.c # 打开文件,落在普通模式
i # 进入插入模式,开始打字
Esc # 打完,回到普通模式
:wq # 保存并退出
Esc,再敲 :q! 回车——不保存强制退出。如果连冒号都打不出来,说明你还在插入模式,先按 Esc。这个顺序记住,就不会被关在 vim 里。
真正会用到它的场景长这样(ssh 上服务器改配置,没有图形界面):
ssh user@server
vi /etc/nginx/nginx.conf
# 按 i 改几行,Esc 回普通模式
# :wq 存盘退出,权限不够就 :q! 放弃
练习建议:别一上来装一堆插件。先跑一遍 vimtutor——vim 自带的交互式教程,30 分钟就能把上面这些按一遍。平时在 VS Code 里装个 Vim 插件,先把方向键禁用掉逼自己用 hjkl,一周就顺手了。想了解 Neovim、各种发行版和 nano 这类平替,见 03 节末尾。
12.3 VS Code:日常写代码就住在这儿
vim 是保底技能,但平时写作业没必要为难自己。日常主力直接上 VS Code 就行:免费、跨平台、装完就能用。更关键的是,前面讲的 shell、gcc、gdb、git 全都能在同一个窗口里完成——内置终端敲命令,扩展负责补全和断点,侧边栏直接看 diff。一个窗口干完所有事,不用来回切。
要装的东西只有三样,顺序别搞反:
- VS Code 本体:官网下载,一路下一步
- C/C++ 扩展(Microsoft 官方出品):补全、跳转、调试都靠它
- 编译器:Windows 装 MinGW-w64(或按 12.1 节直接上 WSL),macOS 执行
xcode-select --install,Linux 装build-essential
gcc: command not found,或者提示"无法检测到编译器"。配好的第一个验证:打开内置终端敲 gcc --version,能看到版本号才算过。
几个用了就回不去的快捷键:
| 快捷键 | 干什么 |
|---|---|
Ctrl + ` | 打开内置终端(` 是 Esc 下面那个键),就是 12.1 节那个 shell |
Ctrl + Shift + P | 命令面板,记不住快捷键就在这儿搜 |
Ctrl + P | 按文件名秒开文件,项目大了离不开 |
Ctrl + Shift + F | 全局搜索:这个变量到底在哪些文件里用过 |
F12 / Alt + ← | 跳到定义 / 跳回来(读别人的代码靠它) |
F5 | 启动调试,第一次会让你选 C++ (GDB/LLDB) |
Shift + Alt + F | 格式化代码,缩进乱了一键整齐 |
表里只是开头,再补六个几乎天天用的:Ctrl + / 注释 / 取消注释(配合 07 节说的"临时开关"用法)、Ctrl + D 选中下一个相同的词(改名、批量修一起改)、Alt + ↑ / ↓ 整行上下移动、Shift + Alt + ↓ 复制当前行、Ctrl + Shift + K 删掉整行、F2 重命名(改一个变量名,整个文件跟着变)。
快捷键怎么练
- 别背表,按需学:某个动作你连着做了三次,就去查有没有快捷键——这时候查一次就记住了
- 一次只加三个:一周记三个,一个月就是十几个。一口气背四十个的结果是一个都想不起来
- 贴出来:写在便利贴上贴显示器边,或者开个 notes 文件,用到了瞄一眼
- 先干掉最打断思路的四个:复制整行、删整行、跳到行首行尾、上下移动行。这四个动作每次都要摸鼠标,一次就断一次思路
- 记不住就
Ctrl + Shift + P:命令面板里搜动作名,右边直接显示对应快捷键,顺手就学会了
第一次按 F5,它会自动生成 .vscode/tasks.json 和 launch.json,里面那条编译命令基本就是 12.4 节推荐的那行 gcc -g ...。想自己控制就改这两个文件,不用背。
它的生态:半个行业都建在它上面
VS Code 有一个开源内核(Code - OSS,MIT 协议),所以很多你听说过的编辑器,其实是它二开出来的:
- Cursor / Windsurf / Trae / Kiro:常见的 AI 编辑器,界面、快捷键、插件体系几乎和 VS Code 一样,只是把 AI 塞进了核心
- VSCodium:社区把微软的遥测和品牌去掉、重新编译的干净版
- Eclipse Theia:这个不是 fork,而是照同一套插件协议重写的框架——GitLab 的 Web IDE、不少云平台的在线编辑器都基于它
- vscode.dev / GitHub Codespaces:浏览器里那个,还是同一套东西
对你有两个实际影响:① 在这里练的快捷键、装的扩展、写过的 tasks.json,换到 Cursor 那类工具里基本原样能用,不是白学;② 反过来说,那些 AI 编辑器"好用"的一半功劳属于 VS Code,别全记在 AI 头上。
- Code Runner 跑不了带输入的程序。它默认在只读的"输出"面板里跑,
scanf一执行就卡住不动——这是新生第一大坑。要输入就按F5用调试器跑,或者在终端里手动./main - 插件别贪多。C/C++ 加一个中文语言包就够了,装一堆只会互相打架、拖慢启动
- 中文乱码:Windows 终端不是 UTF-8 时,
printf("中文")会变乱码。先在终端里chcp 65001试试,实在不行就转 WSL
要是已经习惯了 vim 的手感,装个 Vim 扩展就能在 VS Code 里继续用 hjkl,两边技能不冲突。
12.4 gcc:编译不是一步完成的
很多人以为 gcc main.c 是一个动作,其实它内部走了四步。知道这四步,报错信息你就能看懂一半——因为报错会明确告诉你"死在哪个阶段"。
gcc -E main.c -o main.i # ① 预处理:展开 #include、#define,删掉注释
gcc -S main.i -o main.s # ② 编译:C 代码变成汇编
gcc -c main.s -o main.o # ③ 汇编:汇编变成机器码(目标文件)
gcc main.o -o main # ④ 链接:拼上库,得到可执行文件
gcc main.c -o main # 一步到位,等价于上面四步
预处理到底在干什么
上面写了 gcc -E,但这一步到底干了什么?一句话:它不懂 C 语法,只做文本替换。编译器还没开始理解你的代码,预处理先把源码"摊开"——所以预处理阶段连 int 是什么都不知道,更谈不上类型检查。
具体四件事:
#include是粘贴:把整个头文件的文本原样搬进来,遇到#include就递归展开。一个<stdio.h>展开后就有几千行#define是替换:#define MAX 100之后,代码里出现MAX的地方全部被换成了100,纯粹的字符串替换,编译器根本不知道有个叫 MAX 的变量- 条件编译是删代码:
#ifdef/#ifndef/#endif按条件整段保留或丢弃。同一份源码编出 Windows 版和 Linux 版,靠的就是这个 - 收尾:删掉所有注释、拼接反斜杠续行、插入
# 1 "main.c"这样的行号标记——后面报错能指回你写的那一行,全靠这些标记
前后对比一下就清楚了:
#include <stdio.h>
#define MAX 100
int main(void) {
// 打印上限
printf("%d\n", MAX);
return 0;
}
int main(void) {
printf("%d\n", 100);
return 0;
}
注释没了,MAX 变成了 100,而 printf 的声明是从 stdio.h 里粘贴了一大堆东西换来的(上面那几千行里,绝大多数你根本用不到)。想亲眼看:
gcc -E main.c -o main.i # 只做预处理,得到展开后的文本
wc -l main.i # 数数有多少行,通常几千到几万
tail -n 20 main.i # 看最后几行,就是你的代码被替换后的样子
#define SQUARE(x) x*x,然后写 SQUARE(1 + 2),你以为是 9,实际展开成 1 + 2*1 + 2,等于 5。正确写法是给每个参数和整体都加括号:#define SQUARE(x) ((x)*(x))。更推荐直接写 static inline int square(int x) { return x * x; }——有类型检查,也不会被重复求值。
还有一个必须知道的小事:#define MAX 100 后面不要加分号。因为它会被原样替换进代码里,int a[MAX]; 会变成 int a[100;];,直接语法错误。
为什么头文件一定要写 #ifndef
先看一个必然发生的场景。你有两个头文件,stack.h 用到了 list.h 里的节点定义,于是它自己 #include "list.h";而 main.c 两个都要用:
#include "list.h" // 直接包含
#include "stack.h" // 这个里面又包含了一次 list.h
预处理是纯文本粘贴,它不认识"这个文件刚才是不是已经贴过了"。所以 list.h 的内容会被原样贴进 main.c 两次,展开后长这样:
struct Node { int val; struct Node *next; }; // 第一次,来自 list.h
struct Node { int val; struct Node *next; }; // 第二次,来自 stack.h 里的 include
struct Stack { struct Node *top; };
同一个作用域里 struct Node 被定义了两次,编译器只能报错:
In file included from stack.h:2,
from main.c:2:
list.h:1:8: error: redefinition of 'struct Node'
1 | struct Node { int val; struct Node *next; };
| ^~~~
注意报错里的 In file included from stack.h:2, from main.c:2——它明确告诉你"这个文件是被谁带进来的"。头文件卫士就是专门堵这个洞的。
写法只有三行,但每一行都有作用:
#ifndef LIST_H // 如果 LIST_H 这个宏还没被定义
#define LIST_H // 那就定义它,然后继续往下读
// (反过来:已经定义过,就跳到最后的 #endif)
struct Node {
int val;
struct Node *next;
};
#endif /* LIST_H */
走一遍就明白了:
- 第一次包含:
LIST_H还不存在 →#ifndef成立 → 保留整段,同时#define LIST_H把它登记上 - 第二次包含:
LIST_H已经定义 →#ifndef不成立 → 整段被悄悄丢掉,一行都不剩
几个容易踩的点:
- 宏名跟着文件名走:
list.h→LIST_H,my_stack.h→MY_STACK_H。因为它是全局的宏,两个头文件撞名会互相把对方"卫士"掉,症状是"怎么少了一半声明" #define必须写在#ifndef里面,而且要紧紧跟在后面。写到外面去,就等于每次都登记一遍,等于没写#endif放在文件最后一行,后面加个/* LIST_H */注释,几百行的头文件里才不会配错对- 它防的是"同一份源码里重复粘贴",不是"被多个 .c 包含"。两个 .c 各自包含
list.h是完全正常的,各自展开一份,互不影响
#pragma once,编译器自己记住"这个文件路径已经展开过",不用你起名字,也不会撞名。它不是 C 标准的一部分,但 GCC / Clang / MSVC 全都支持,日常写作业用它更省事。唯一要小心的是同一个文件通过不同路径(软链接、多份拷贝)被包含时,它可能认不出来——而 #ifndef 只认宏名,反而更稳。两种都行,别在同一个文件里混着写。
参数不用背全,这些必须熟:
| 参数 | 作用 | 什么时候用 |
|---|---|---|
-o 名字 | 指定输出文件名 | 永远写,不写默认叫 a.out |
-Wall -Wextra | 打开警告 | 永远开,很多 bug 它提前告诉你 |
-g | 生成调试信息 | 要调试就必须加,否则 gdb 看不到变量名和行号 |
-O0 / -O2 | 优化级别 | 调试用 -O0,交作业/跑数据用 -O2 |
-std=c11 | 指定语言标准 | 避免编译器默认标准不一致导致的怪问题 |
-c | 只编译不链接 | 多文件工程分开编译时用 |
-I 目录 | 加头文件搜索路径 | #include "my.h" 找不到时 |
-L 目录 -l库名 | 加库路径与库 | 用 math.h 要写 -lm |
gcc -std=c11 -Wall -Wextra -g -O0 main.c -o main —— 编译期把警告全打开,调试信息带上,优化关掉。以后所有作业都从这里开始改。
C 和汇编怎么对应:一行语句变成哪几条指令
四阶段里第 ② 步「编译」就是把 C 变成汇编。这一步不必神秘化,它的规律很死:变量落到栈上的固定槽位,运算先进寄存器,分支变成比较加跳转,函数变成 call 和 ret。想亲眼看,把 -S 加进命令就行:
gcc -S -O0 -masm=intel main.c -o main.s # 只到汇编,Intel 语法(更好读)
cat main.s # 直接看
不加 -masm=intel 出来的是 AT&T 语法,两者说的是同一件事,只是写反了:
| 语法 | 同一条 a = 42 |
|---|---|
| AT&T(gcc 默认) | movl $42, -4(%rbp) |
| Intel | mov DWORD PTR [rbp-4], 42 |
区别就三条:AT&T 是源在前、目的在后,寄存器带 %,立即数带 $;Intel 全部反过来,更像人话。加 -masm=intel 之后,网上教程和教材里抄来的汇编才看得懂。
先认识几个寄存器,够看 90% 的代码:
| 寄存器 | 干什么的 |
|---|---|
rax | 返回值、临时结果。低 32 位叫 eax,再低 16 位叫 ax |
rdi rsi rdx rcx r8 r9 | 整型参数按顺序放在这六个里,第 7 个起才走栈 |
rsp | 栈顶指针,push / pop 就是在动它 |
rbp | 栈帧基址。局部变量都在 [rbp-8]、[rbp-16] 这些位置 |
rip | 下一条要执行的指令地址,call / 跳转改的就是它 |
然后是这张对照表,左边是你写的,右边是编译器给的。看熟这张表,再看到汇编就不慌了:
| C 代码 | 对应的汇编(Intel 语法) | 要点 |
|---|---|---|
int a = 42; | mov DWORD PTR [rbp-4], 42 | 局部变量就是栈上一个固定槽位,名字编译完就没了 |
b = a + c; | mov eax, [rbp-4]add eax, [rbp-8]mov [rbp-12], eax | 运算必须先搬进寄存器,内存之间不能直接相加 |
if (a > b) ... | cmp eax, [rbp-8]jle .L2 | 条件是反着跳的:不满足就跳过 if 块 |
for (i = 0; i < n; i++) | .L2: cmp ... jge .L3... ; jmp .L2 | 循环 = 标签 + 比较 + 条件跳走 + 末尾跳回 |
f(a, b); | mov edi, amov esi, bcall f | 实参先摆进 rdi/rsi,返回值回来时在 eax 里 |
p = &a; | lea rax, [rbp-4] | 只算地址,不读那块内存——所以 & 和 [] 能被合并成一条 |
*p = 100; | mov rax, [rbp-16]mov DWORD PTR [rax], 100 | 两步:先把地址读进寄存器,再往那个地址写 |
return s; | mov eax, [rbp-4]leave / ret | 返回值固定走 eax,ret 就是把 rip 弹回调用方 |
注意「DWORD PTR」这四个字:它标的是宽度。char 是 BYTE(1 字节)、short 是 WORD(2)、int 是 DWORD(4)、指针和 long 是 QWORD(8)。指针类型写错、printf 格式符用错,在汇编这一层看就是宽度对不上——它不会报错,只会读错几个字节。
拿一个真函数走一遍,比看表有用:
int sum(int *a, int n) {
int s = 0;
for (int i = 0; i < n; i++) {
s += a[i];
}
return s;
}
sum:
push rbp ; 保存调用方的栈帧
mov rbp, rsp ; 建立自己的栈帧
mov QWORD PTR [rbp-24], rdi ; 参数 a(指针,8 字节)存进栈
mov DWORD PTR [rbp-12], esi ; 参数 n(int,4 字节)存进栈
mov DWORD PTR [rbp-4], 0 ; s = 0
mov DWORD PTR [rbp-8], 0 ; i = 0
.L2: ; ← 循环开头
mov eax, DWORD PTR [rbp-8] ; 取 i
cmp eax, DWORD PTR [rbp-12] ; 和 n 比
jge .L3 ; i >= n 就跳出(反着跳)
mov eax, DWORD PTR [rbp-8] ; 取 i
cdqe ; 32 位扩成 64 位(做地址要用)
lea rdx, [rax*4] ; i * sizeof(int) = i * 4
mov rax, QWORD PTR [rbp-24] ; 取 a 的地址
add rax, rdx ; a + i*4 → 第 i 个元素的地址
mov eax, DWORD PTR [rax] ; 顺着地址读出 a[i]
add DWORD PTR [rbp-4], eax ; s += a[i]
add DWORD PTR [rbp-8], 1 ; i++
jmp .L2 ; 跳回循环开头
.L3:
mov eax, DWORD PTR [rbp-4] ; 返回值放 eax
pop rbp ; 恢复调用方的栈帧
ret
这段汇编把前面几节讲的东西全兑现了:
- 局部变量真的是"栈上的槽位":
s在[rbp-4]、i在[rbp-8],编译器排的位置,跟你写的顺序没关系 a[i]被拆成"基址 + i×4":lea rdx,[rax*4]算出偏移,再add到指针上。这就是 5.5 节说的"指针算术按类型步进"的物理实现- 函数返回后栈帧就作废了:
pop rbp之后[rbp-4]那块内存不再属于你。所以"返回局部变量的地址"是 UB——不是玄学,是这块地已经被回收了 - 每次递归调用都会 push 一层新栈帧:递归太深撑爆栈 → 段错误,原因也在这里
-O0 是"一条语句一条语句地翻",最适合学习。-O2 会把变量全塞进寄存器、把循环改成指针步进、把整段计算提出来甚至向量化——你写的第 5 行可能跑到第 20 行去执行,也可能整段消失。要对照语法就看 -O0;要研究优化,就换个心态,别指望一一对应。顺便说,这也是"UB 在 -O2 下行为突变"的原因:优化器正是按"你保证过不会越界"来重排代码的。
要不要背?不用。只要能把骨架对上:变量 → 栈槽位、表达式 → 寄存器、if/for → 比较加跳转、函数 → call 加 ret。真需要看汇编的场合就三个:调段错误要看寄存器、ICS 的 Bomb Lab 要读反汇编、想搞明白某个 UB 到底怎么被优化掉。其余时候,知道它长什么样就够了。
报错也分两类,区别很重要:
- 编译错误:语法、类型不匹配。编译器能指出具体行号,按第一条改
- 链接错误:
undefined reference to 'foo'。函数声明了但没定义,或者编译时忘了把实现它的.c加进去、忘了-lm。这类错误没有行号,新手最容易懵 - 运行时崩溃(段错误):编译链接都过了,一跑就死。这已经不归 gcc 管了,交给下面这个工具
12.5 多文件:为什么要分 .h 和 .c,路径又是怎么找的
单文件写到几百行以后会同时遇到三个问题:找不到函数、改一行要重编全部、两个人没法同时改。解决办法就是拆文件。但拆之前得先分清两个词:
- 声明(declaration):告诉编译器"有这么个东西,它长这样"。函数原型、
struct定义、extern变量都算 - 定义(definition):告诉编译器"它到底是什么",真正分配内存、给出函数体
规则只有一条:定义只能有一份,声明可以有很多份。而编译器是单个 .c 单独编译的——它编 main.c 时根本看不见 list.c。所以 main.c 想调用 list_push,就必须先看到它的声明,这就轮到头文件了。
于是分工固定成这样:
| 文件 | 放什么 | 不放什么 |
|---|---|---|
list.h | 函数原型、struct / typedef、宏、extern 变量声明 | 函数体、变量定义(否则每个包含它的 .c 都会各定义一份) |
list.c | 函数的定义、私有变量、static 辅助函数 | 不需要暴露给别人的东西 |
main.c | main 函数,调用别人的接口 | 别人的实现细节 |
一个最小可跑的三件套:
#ifndef LIST_H
#define LIST_H
struct Node {
int val;
struct Node *next;
};
void list_push(struct Node **head, int val);
int list_sum(const struct Node *head);
extern int g_node_count; // 声明:只是个"通知",不分配内存
#endif /* LIST_H */
#include "list.h"
int g_node_count = 0; // 定义:真正在这里分配
void list_push(struct Node **head, int val) {
struct Node *n = malloc(sizeof *n);
if (!n) return;
n->val = val;
n->next = *head;
*head = n;
g_node_count++;
}
#include "list.h"
int main(void) {
struct Node *head = NULL;
list_push(&head, 1);
return 0;
}
注意到 list.c 自己也 #include "list.h" 了吗?这不是多余——让编译器替你检查声明和定义是否一致。参数写错了,编译就会报错,比链接期或运行期才发现强得多。
编译命令:一步和分步
# 一步到位:把所有 .c 都列上
gcc -std=c11 -Wall -Wextra -g main.c list.c -o app
# 分步:更接近真实工程的做法
gcc -c -std=c11 -Wall -Wextra -g main.c -o main.o
gcc -c -std=c11 -Wall -Wextra -g list.c -o list.o
gcc main.o list.o -o app
分步的价值在于:改了 list.c 就只重编 list.c,main.o 原样复用。文件多了以后,这个差别是几十秒和几秒的差别。-c 的意思是"只编译成 .o,先别链接"——链接是最后一步,把各个 .o 里的符号拼起来。
文件路径到底怎么找
这是多文件最容易懵的地方,因为编译器没有"项目根目录"这个概念,它只认两样:你在命令行里写的路径(相对你当前所在的目录),以及 -I 参数给的目录。IDE 之所以看起来"什么都能找到",是它在背后替你补了这些参数。
一个正常的项目长这样:
project/
|-- include/
| `-- list.h
|-- src/
| |-- main.c
| `-- list.c
`-- build/
在 project/ 目录下敲:
gcc -std=c11 -Wall -Wextra -g -Iinclude src/main.c src/list.c -o build/app
拆开看每个路径归谁管:
| 写法 | 谁用它 | 相对谁 |
|---|---|---|
src/main.c | gcc 直接读这个源文件 | 你敲命令时所在的目录 |
-Iinclude | 只影响 #include 的搜索 | 同样是当前目录 |
-o build/app | 输出文件放哪 | 当前目录;build/ 必须已经存在,gcc 不会帮你建目录 |
#include "list.h" | 预处理阶段找头文件 | 先找包含它的那个文件所在目录,再找 -I,最后系统目录 |
<stdio.h> | 同上 | 只找 -I 和系统目录,不找你自己的目录 |
几条由此推出的结论,都是踩过才记住的:
- 双引号和尖括号的区别就在第一站。
"list.h"先在自己身边找(src/main.c里写它,先看src/list.h),所以同一目录的头文件不用加任何参数;<stdio.h>跳过这一步,直接去系统目录,这样才不会误撞上你自己写的同名文件 -I只管头文件,不管 .c。写了-Iinclude之后,#include "list.h"能找到include/list.h,但list.c还是得自己写全路径src/list.c- 头文件里再
#include,是相对那个头文件的目录算的,不是相对 main.c。所以include/下的头文件互相引用,写文件名就行 - 报错
fatal error: list.h: No such file or directory,八成是-I写错或没写;报错No such file or directory指向main.c本身,那是你站错目录了,先pwd看看自己在哪 - Windows 上别用反斜杠。
gcc src\main.c在 shell 里\是转义符,会变成奇怪的字符;统一写正斜杠src/main.c,MinGW 一样认 - 别在命令里写死绝对路径。
E:/Desktop/project/src/main.c换台机器就废,而且复制到别人电脑上第一条命令就跑不起来
多文件之后,编译命令会越来越长,所以最终还是要交给 Makefile——但这次你知道它在替你做什么了($< 是源文件,$@ 是目标文件,-I 和路径原样抄进去):
最小可用的 Makefile(注意命令前面是 Tab,不是空格):
CC = gcc
CFLAGS = -std=c11 -Wall -Wextra -g -Iinclude
app: src/main.c src/list.c include/list.h
$(CC) $(CFLAGS) src/main.c src/list.c -o build/app
clean:
rm -f build/app
之后只要敲 make 就能编译,make clean 清理。以后加文件,把新的 .c 和 .h 加到 app: 后面那行依赖里就行。
12.6 gdb:程序崩了,别再靠 printf 猜
printf 能定位问题,但它只能告诉你"我打到这一行了"。gdb 能告诉你崩溃时完整调用栈、每一层的变量值、内存里到底是什么。段错误、越界、野指针这几类问题,printf 基本查不出来。
前提:编译时必须加 -g,否则 gdb 只能看到地址。
| 命令 | 缩写 | 作用 |
|---|---|---|
run | r | 从头开始跑 |
break main / break list.c:12 | b | 下断点,按函数名或行号 |
next | n | 单步执行,不进入函数 |
step | s | 单步执行,进入函数 |
continue | c | 放行到下一个断点 |
print p / print *p | p | 看变量值 / 看指针指向的内容 |
backtrace | bt | 看调用栈,崩溃排查第一命令 |
frame 1 | f 1 | 切到栈的第 1 层,看调用者的变量 |
info locals | 一次列出当前所有局部变量 | |
x/4xw p | 按十六进制看 p 指向的 4 个字内存 | |
quit | q | 退出 |
一次真实的排错长这样(链表写错了导致段错误):
$ gcc -g main.c -o main
$ gdb ./main
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401156 in node_value (p=0x0) at list.c:12
12 return p->value;
(gdb) bt
#0 node_value (p=0x0) at list.c:12
#1 0x0000000000401180 in main () at list.c:30
(gdb) frame 1
(gdb) print head
$1 = (struct Node *) 0x0
(gdb) quit
读法:程序死在 list.c 第 12 行,p 是空指针。bt 显示是 main 调的,切到第 1 层一看 head 本来就是 NULL——问题不在 node_value,而在 main 里没有判空。这就是 gdb 比 printf 值钱的地方:它直接告诉你"错在谁身上"。
三个提高效率的技巧:
gdb -tui ./main或运行中按Ctrl+X再按A,切出源码窗口,边看代码边走print arr@5直接打印数组前 5 个元素,print/x var用十六进制显示watch x:变量x一被修改就停下来,专治"不知道谁把它改坏了"
12.7 git:不是可选项
git 最容易被误解成"给别人协作用的"。其实对单人项目它更值钱:写崩了能回滚,改错了能对比,交作业有记录。而且 GitHub 主页现在基本等于程序员的第二张简历。
git init # 把一个文件夹变成仓库
git status # 最常用:哪些改了、哪些已暂存
git add list.c # 把指定文件放进暂存区
git commit -m "实现链表插入" # 存一个快照,说明写"做了什么"
git log --oneline # 看历史,一行一条
git diff # 看还没 add 的具体改动
分支和远程,四个命令就够开始:
git switch -c dev # 新建并切到 dev 分支(老写法 checkout -b)
git switch main # 切回 main
git merge dev # 把 dev 合进来
git push -u origin main # 第一次推,之后直接 git push
真正救命的是下面这几条,建议抄在便签上:
| 我把什么搞坏了 | 怎么救 |
|---|---|
| 某个文件改乱了,想丢弃改动 | git restore main.c |
| 已经 add 了,但不想提交它 | git restore --staged main.c |
| 提交信息写错(还没 push) | git commit --amend |
| 手头没做完,但要临时切分支 | git stash → 回来 git stash pop |
| 刚提交的内容想撤掉(保留代码) | git reset --soft HEAD~1 |
| 分支删错了、reset 过头了 | git reflog 找回历史位置,几乎万能 |
另外:别把编译产物提交进去。仓库根目录放一个 .gitignore:
*.o
*.exe
a.out
.vscode/
build/
git add + commit。一周之后这些就变成肌肉记忆了。真卡住了,回 Missing Semester 的第 1、2、6 讲对着补。
12.8 动手:在页面里开一个终端
命令看一百遍不如敲三遍。下面这个终端是跑在页面里的迷你 shell,带一个假文件系统,行为尽量照着 bash 来:命令打错会报 command not found,路径不对会报 No such file or directory,gcc 编译成功时一句都不输出。
照着敲一遍,12.1 节那张命令表就活了:
pwd # 我在哪
ls # 有什么
cat main.c # 看看代码
gcc main.c -o main # 编译(成功则无输出)
./main # 运行,输出 100
cd .. # 上一层
tree # 树形看结构
badcommand # 故意打错,看 bash 怎么骂你
man、grep、ps、| 全都能用。把这个页面当成"先熟悉一下手感",别当成替代品。
先把 C 和指针弄明白,再回头补这些也不迟。