C 语言经验分享
10 月 24 日 · 计算机类专场

C 语言学习经验分享

场次
10 月 24 日 计算机类专场
课程
C 语言程序设计 B
姓名
刘**
班级
数据 24-3
专业
数据科学与大数据技术
零基础友好 40 分钟分享 资源推荐 课程路线 工具速查 指针讲解 自测与答疑
↓ 滚动阅读 · ← → 切章 按 → 翻页 · 右上角可切回阅读模式

自我介绍

头像
刘** 热心的 CS 爱好者
数据科学与大数据技术 · 数据 24-3

00开场:为什么还要学 C

C 语言不是"找工作直接用的语言",但它是CS科班能力的地基。

01资源篇:视频课怎么选

第 1-4 周 基础语法 第 5-8 周 数组 · 函数 · 指针 第 9-12 周 结构体 · 文件 全程 刷题 · 自测
一学期的大致节奏:前四周打基础,中间把指针啃下来,后段补结构与文件,刷题贯穿全程。

浙大翁恺 C 语言

  • 平台:中国大学 MOOC《C语言程序设计》,B 站有完整搬运
  • 特点:讲得细、节奏慢、口语化,几乎零门槛,配套 PTA 习题
  • 适合:零基础跟学,跟完应付期末完全够
  • 注意:深度有限,有些细节点到为止,必须配合敲代码

哈佛 CS50

  • 平台:edX 免费,B 站有中文字幕版
  • 特点:讲得生动,Problem Set 质量很高。C 部分会讲内存、指针、文件操作,视角和国内教材完全不同
  • 适合:看完翁恺基础之后,挑 C 相关周次看,尤其推荐动手做 Problem Set
  • 注意:英文语速快,先看中文字幕;作业难度不低,做不完不要有挫败感

B 站 · Frank《C 语言大师系列》

  • 平台:B 站 UP 主「Frank 讲明白」,前篇 41 小时、后篇 23 小时,合计六十多小时,全程免费
  • 特点:讲得深。课本上一句"指针就是一个地址"就带过的东西,他会拆到内存布局、函数调用栈、寄存器、编译链接这一层;前篇打语法和核心(含指针、数组、结构体、文件),后篇往系统里走。风格很口语,边写边骂课本,不容易困
  • 适合:翁恺跟完之后接,或者指针卡住时按目录挑对应章节看。想要"为什么是这样"而不只是"该怎么写",看这个
  • 注意:体量太大,别想着从头看到尾。按目录挑「指针 / 内存 / 函数 / 结构体」对应集数,一次只啃一集,看完立刻自己写一遍
建议路线
翁恺打基础,CS50 换个视角,Frank 的《C 语言大师》用来深挖——尤其是指针和内存那几集。全程自己敲代码,看视频和敲代码的时间比建议 1:2,光看不动手等于没学。学完 C 之后往哪走,看文末第 11 节的课程推荐。

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 坑。

先看两个别碰的。它们不是"过时了所以不好",是真的会让你学到错的东西:

这两个别再用了
VC 6.0(Visual C++ 6.0):1998 年的产品。编译器只认 C89,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刷题平台:按阶段来,别一上来就卷

洛谷 入门语法 AtCoder ABC 算法思维 力扣 · 牛客 面试与笔试 Codeforces 竞赛深入
推荐顺序:先把语法刷熟,再练算法思维,然后面向面试,竞赛看兴趣深入。

入门:洛谷

  • 网址: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 系统,想打竞赛再深入
  • 提醒:竞赛和工程是两条路,刷题上头之前先想清楚目标,目前竞赛性价比低
刷题方法
  1. 先自己想 30 分钟,想不出再看题解
  2. 题解看懂不算数,关掉自己重写一遍
  3. 写不出来就把卡住的点记下来,定期复习
  4. 每天 1~2 题比周末突击 20 题有效得多

写完一道题不确定对不对?除了交评测等结果,还可以自己造数据对拍:写一份暴力解跟它比,几百组数据跑下来,错在哪组一目了然。具体做法见第 08 节的「从对拍到 difftest」。

05核心难点:指针怎么理解

5.1 先建立内存模型

把内存想象成一排酒店房间:

内存示意
地址          内容
0x1000      [  42  ]      <- 变量 a,int a = 42;
0x1004      [ 0x1000 ]    <- 变量 p,int *p = &a;

三句话记住:

  1. 变量名只是给你看的,CPU 只认地址
  2. 每个变量都住在某个地址上
  3. 指针就是"房间号"本身,它自己也有房间

5.2 两个运算符

  • &x:取地址(拿到房间号)
  • *p:解引用(顺着房间号走进去,操作里面的东西)
  • * 和 & 互为反操作
地址 0x1000 a 42 地址 0x1008 p 0x1000 &a:取地址 *p:解引用
& 负责取地址,* 沿着地址取值,两个运算符的方向正好相反。
最容易被绕晕的一点
声明里的 int *p,那个 * 不是解引用,只是标记"p 是个指针"。声明看类型,表达式才看运算。

类型很重要:int * 解引用时读 4 个字节,char * 读 1 个字节;指针加 1 不是地址加 1,而是加上"一个元素的大小"。

动手感受一下

下面这个演示模拟了 int a = 42; int *p = &a; 在内存里的样子。点「自动演示」看完整过程,也可以自己点按钮单步操作。地址是示意值。

内存演示:a 和 p
地址 0x1000 · int a
a 42
地址 0x1008 · int *p
p 0x1000
第 1 步 取地址 第 2 步 存入指针 第 3 步 按地址找 第 4 步 按地址写
点击「自动演示」看完整过程,或点击下方按钮单步操作。
提示:解引用时,标有地址的小标签会沿箭头飞到目标格子,那就是"按地址寻址"的过程。

5.3 进终端看:指针在指令层面做了什么

上面那个演示是"内存视角"。但 CPU 真正执行时,指针操作会被拆成两条指令,中间必须经过寄存器。寄存器是 CPU 内部的一小块超快存储,只有十几个格子;一条指令也不能直接把一个内存地址的内容搬到另一个内存地址——必须"内存 → 寄存器 → 内存"绕一圈。这就是指针操作看起来很绕的根本原因。

下面这个迷你终端是教学模拟(不是真 shell):输入 C 语句,它一步步执行,把 寄存器和内存格子的变化实时画在右边。先敲这几条试试:

c-sim · 教学用迷你终端(模拟,不是真 shell)
$
寄存器
内存

回车执行,↑ / ↓ 翻历史命令,右上角可以重置。试试按顺序敲这五条:

建议的操作顺序
int a = 42;      // 常量直接写进 a 的内存
int *p = &a;     // 取 a 的地址,存进 p
*p = 100;        // 顺着 p 里的地址写 → 改的是 a
p a              // 42 变成了 100
int *q = NULL;   // 空指针,试试 *q = 1; 会怎样

跑完会发现三件事,正好是理解指针的关键:

  1. &a 不读 a 的值,只算地址。对应的指令是 leaq(load effective address)——它只做地址计算,根本不访问那块内存。这类 C 与汇编的对应关系,12.4 节末尾有完整对照表
  2. 指针变量自己也有地址、也占内存。64 位系统里 int * 占 8 字节,int 占 4 字节,所以 a 在 0x1000,p 在 0x1008
  3. *p = 100 是两条指令:先 movq p(%rip), %rax 把地址读进寄存器,再 movl $100, (%rax) 往这个地址写。p 从头到尾没变,变的是 a
这就是"指针为什么难"的答案
你写 *p 是一个符号,CPU 执行的是"读地址 → 再访存"两次操作。中间那个寄存器(rax)就是"临时拎着的门牌号"。理解了这个模型,int **pp、指针数组、链表节点里的 next 都只是这个动作套娃。

5.4 完整示例

C
#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] 也是合法的——能编过,但没人这么写。

C
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
sizeof20(整个数组)8(一个地址,64 位)
& 之后是什么类型int (*)[5],指向"5 个 int 的数组"int **,指向指针
能不能赋值a = p; 编译错误,数组名不是左值p = a; 可以
有没有自己的存储没有,它只是那块内存的名字有,指针本身也占 8 字节

第二行值得盯一下。加 1 之后两者的差距会立刻暴露:

C
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,编译器看到的是同一个东西。方括号里的数字会被直接忽略——它只是给人看的注释,不是长度检查。

C
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)。

C
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] 不是一回事

指针数组和数组指针,只差一对括号,但意思完全不同:

C
int *p[4];     // 数组,4 个元素,每个都是 int*      → 指针数组
int (*q)[4];   // 指针,1 个,指向"4 个 int 的数组"   → 数组指针

读法是从名字开始,先看括号里,再看右边,最后看左边:(*q)[4] → q 是指针,指向 4 个 int 的数组。

一句话
写代码用 a[i],读代码想 *(a + i)。语法糖是为了让你写得清楚,不是让你忘记底下是什么——忘了底层,sizeof 和传参这两个坑就会一直踩。

5.6 为什么需要指针:函数参数

C 语言是值传递:函数收到的只是拷贝,改不了外面的变量。

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;
}

指针存在的三大理由:

  1. 让函数能修改调用者的数据
  2. 避免大结构体的拷贝,提高效率
  3. 表达"可能为空、可能没有"的语义(NULL)

5.7 指针的常见坑(考前必背)

  1. 野指针:int *p; *p = 10; 指针没初始化,指向随机地址,直接崩溃
  2. 空指针解引用:int *p = NULL; *p = 10; 崩溃
  3. 悬空指针:函数返回局部变量的地址,函数结束后栈内存失效
  4. 数组越界:长度为 10 的数组访问 arr[10],不一定立刻崩溃,但会写坏相邻内存,这种"没崩的"最危险
  5. 修改字符串字面量:char *s = "hello"; s[0] = 'H'; 未定义行为
  6. sizeof 与 strlen 混淆:前者包含 \0,后者不包含
  7. 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;同一句里改了两次结果随编译器、随优化等级变
三个经典 UB,看起来都很正常
// 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 下看起来好好的
-O0 基本按你写的顺序逐条翻译,越界写在栈上常常"刚好"落在没用的地方,于是侥幸跑对。一旦打开 -O2,编译器就开始利用"UB 不会发生"这个假设做变换——不是它变坏了,是它开始兑现你从没兑现过的承诺。所以"我这儿跑得好好的"从来不是证据。

怎么防,从便宜到贵:

  1. 编译开 -Wall -Wextra:未初始化、隐式声明、可疑比较,它能替你挡掉一批
  2. 用 sanitizer 跑一遍:gcc -g -fsanitize=address,undefined main.c -o main。ASan 抓越界和 use-after-free,UBSan 抓有符号溢出、非法移位、空指针解引用——慢 2~5 倍,但报错精确到行号,比 gdb 事后猜强得多
  3. Linux 上再上 valgrind:valgrind ./main,不用重新编译
  4. 根本上还是别写:变量一定初始化、指针先判空、循环写 i < n 而不是 i <= n、free 之后把指针置 NULL
一句话记住
能跑不代表对。C 语言把"你保证过的事"当成真的:你写了越界,它就有权假设你不会越界。面试里问 i++ + i++、问"这段代码为什么要这么写",考的就是这件事。

06五道题自测

指针刚讲完,先花两分钟做下面五道题——全是刚讲过的东西。点击选项会立即给出对错和解析,答错了就往上翻回去看对应的那一条。

已答 0 / 5 答对 0 题

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, kindex循环下标,多层循环按顺序用
n, mnumber规模:n 是元素个数,m 是边数或询问数
ansanswer最终答案
resresult中间结果
sum / totsum / total求和 / 总数
cntcount计数器
curcurrent当前量:当前节点、当前状态
tmptemporary临时变量,交换和暂存用
valvalue值
len / szlength / size长度 / 大小(子树、容器)
flag / okflag / okay布尔标记,是否成立
pre / nxtprevious / next前驱 / 后继(next 在 C++ 里是保留字,所以写 nxt)
l, rleft, right区间左右端点
mid / low, highmiddle / low, high二分中点与上下界
posposition位置(下标或坐标)
x, y / id— / identifier坐标或输入量 / 编号
N / M / MAXNmaximum数组长度上界,常写 const int N = 1e5 + 5;。多留 5 个防越界
deltadelta增量、差值(差分、扫描线里常见)
op / optoperation / optimal操作 / 最优解

图论和树上有一套几乎全世界通用的搭配:

缩写展开含义
u, v, wvertex, weight一条边的两个端点,w 是边权
fa / rtfather / root父节点(并查集)/ 根节点
depdepth深度(树、BFS 层数)
dis / distdistance距离
degdegree度数(入度 / 出度)
visvisited是否访问过
g[]graph邻接表
head / to / nxthead, to, next链式前向星三件套:表头、终点、下一条边
mst / sccMST / SCC最小生成树 / 强连通分量

字符串、数学和算法名也各有一套:

缩写展开含义
s / tstring主串 / 模式串(成对出现)
patpattern模式串(KMP、正则里)
ch / strchar / string单个字符 / 字符串
mod / infmodulo / infinity模数 / 无穷大(常写 0x3f3f3f3f)
epsepsilon浮点比较的容差,常写 const double eps = 1e-8;,判断 fabs(a - b) < eps 而不是 a == b
gcd / lcmgreatest common divisor最大公约数 / 最小公倍数
dp / fdynamic programmingDP 数组,f 是另一个万能名
ststate / stack状态(状压 DP),或栈——看语境
que / stkqueue / stack队列 / 栈
mp / matmap / matrix映射(哈希表)/ 矩阵
cmp / init / chkcompare / initialize / check比较 / 初始化 / 校验
build / update / querybuild, 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 函数:小、单一、早返回

一个函数只做一件事。判断标准很土但有效:能不能用一句话说完它干了什么,而且这句话里不出现"然后"。"找到最大值然后打印然后写进文件"——三个动作,就该拆成三个函数。

嵌套三层 vs 早返回
// 不好读:括号越走越深,正常情况被埋在最里面
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 写完先过一遍这六条

  1. 变量名读出来像人话吗?a1、a2、temp、flag 还剩几个?
  2. 缩进和大括号风格,整个文件是一致的吗?
  3. 有没有超过 3 层的嵌套?
  4. 有没有哪个函数超过一屏?
  5. 有没有注释在解释"这行在干什么"?如果有,多半是代码本身没写清楚
  6. 同一段逻辑复制粘贴了三处以上吗?那就该抽成函数了
风格之争没有标准答案
4 空格还是 2 空格、左括号换不换行、snake_case 还是 camelCase——这些争论了几十年也没结论。唯一真正重要的是:同一个项目里保持一致。想系统看规范,可以翻 Linux 内核的 Documentation/process/coding-style.rst 或 Google C++ Style Guide;但大一阶段,做到"你自己前后一致"就已经超过大多数人了。

08Debug 能力:比会写代码更重要

写代码的时间有一半是在调试。调试能力是初学者和入门者的分水岭。

第一原则

编译器的报错是朋友,不是敌人。报错列表先看第一条,后面的错误往往是连锁反应,改掉第一个可能全消失。编译时加上 -Wall -Wextra,让编译器把警告都说出来。

四个实用手段

  1. printf 大法:在最可疑的变量前后打印,缩小范围,初学者最实用
  2. 断点单步:VS Code + gdb 或 IDE 自带调试器,学会看变量值和调用栈。gdb 命令速查见 第 12 节
  3. 最小复现:把有问题的代码复制到新文件,删无关部分,一直删到 bug 消失,最后留下的那段就是问题
  4. 二分注释法:注释掉一半代码,看 bug 还在不在,不断二分定位

常见错误分类

类型表现常见原因
编译错误编不过语法、缺分号、类型不匹配、漏头文件
链接错误编译过了链接失败函数声明了没定义、main 重复
运行时错误段错误指针未初始化、数组越界、free 后使用
逻辑错误能跑但结果不对条件写错、循环边界、变量未更新

段错误排查清单:指针初始化了吗?数组越界了吗?释放后又用了吗?

从对拍到 difftest:让两份实现互相验证

前面这些手段都靠"人眼看"。还有一种更省力的思路:写两份实现,让它们互相比。竞赛里叫对拍(也叫"拍"),工业界叫差分测试 differential testing,在模拟器和芯片领域简称 difftest。三个名字是同一个想法,差别只在于"比多细"。

对拍永远需要三样东西:

  • sol:你写的主程序,可能是错的(要验的就是它)
  • brute:暴力解法,慢得要死,但一定对
  • gen:随机数据生成器

然后循环:造一组数据 → 两个程序各跑一遍 → 比输出。一样就换下一组,不一样就立刻停下,把那组数据留下来。

gen 造一组随机数据 你的解 sol 可能写错了 暴力 brute 慢,但一定对 diff 逐字节比输出 一致 → 换下一组 不一致 停下,留下 in.txt
对拍的价值不在"能跑",而在于它自动帮你找到了自己想不到的反例。

三个程序都用 C 写,驱动用一段 shell 脚本(Windows 上在 Git Bash 里跑):

run.sh
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

生成器是成败关键,几行就够,但要注意几点:

gen.c
#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 就是这么做的,伪代码简化后大概长这样:

difftest.c(简化)
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 这类权威实现
定位精度一组数据一条指令
代价要额外写一份暴力解要有金标准,慢很多,只在调试时开
本质是同一件事
找一个你信任的实现当标尺,用大量输入把你的实现和它对到完全一致。写编译器就对 clang,写 JSON 解析器就对 Python 的 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 当副驾驶,别当代驾

  1. 自己先写 20 分钟,不是 0 分钟(那叫外包),也不是两小时(那叫浪费)
  2. 报错先自己读。要给 AI 看,就连"我试过什么、我猜是哪里"一起给它,别只贴一行错误
  3. 问"为什么",别问"给我答案"。好问法:"我这段在 n = 0 时输出乱码,为什么?"坏问法:"这题怎么写"
  4. AI 给完先别运行:从头读一遍,标出你不同意的地方,再去验证
  5. 关掉对话自己重写一遍。写不出来,说明刚才只是"看懂了",不是会了
  6. 自己补边界:0、1、最大值、空指针,AI 经常只写"正常情况"
四件别交给 AI 的事
① 替你读报错 ② 替你推指针和内存 ③ 替你交没跑过的代码 ④ 在你解释不了的时候替你提交。前三件是内功的练习机会,让出去就永远练不出来;第四件是事故。顺带一句:AI 写 C 也常错——gets()、sizeof 用错、指针类型对不上,它照样自信地给你。

教学在改,但界线没变:让 AI 教你,不是替你写

学校这两年在推 AI 进课堂:作业开始允许用、考试形式在改、有的课干脆考"你怎么用 AI"。这不是放水,是承认现实——以后工作就是这样,早点适应没坏处。

但有一条界线得先划清楚。C 语言这门课,本质上是"1+1、加减乘除"。它不考你能不能想出什么巧妙算法,考的是基本动作会不会:变量怎么声明、循环怎么写、指针指向哪。就像小学算术,你可以让老师把"为什么 3×4 = 12"讲十遍,但你不能让老师替你把作业本填了。

  • 让 AI 讲:这个概念什么意思、这段代码为什么错、这个报错在说什么、还有没有别的写法——这是把 AI 当家教,越用越占便宜
  • 让 AI 写:题目丢进去、答案复制出来交上去——这不叫"会用 AI",这叫把唯一的练习机会让掉了。而且老师一眼能看出来:变量命名、注释习惯、一问思路就卡住,全露
交之前自检一句
"这段代码,我能不看屏幕给别人讲一遍吗?"讲得出来,AI 帮没帮都无所谓;讲不出来,那就是抄——只是抄的工具换了。

更现实的一点:这门课是后面所有课的地基。数据结构、操作系统、编译原理,全都默认你会指针和内存。在这里让 AI 替你做,不是省了一门课,是把后面那几门一起挖空。

附:如果你打算"跑路"硬件,C 就是那张门票

说点更现实的。这两年互联网这边在收缩——校招名额、实习转正、涨薪幅度都不如从前,"降本增效"四个字大家都听腻了。与此同时另一头在扩:新能源车、消费电子、工业控制、机器人、无人机、芯片,都属于软硬结合的方向。这些岗位招人时不太看你刷了多少题,看的是你能不能把程序烧进板子里,让它真的动起来。

而这条路的第一道门槛就是 C,绕不过去。不是"学过 C 就行",是这几样必须真会:

  • 位运算:置位 |=、清位 &= ~、移位。寄存器就是这么一位一位操作的
  • 指针和内存映射:外设寄存器在手册里就是一个地址,写 *(volatile uint32_t *)0x40021000 = 1; 才叫"点了个灯"。指针在这里不是练习题,是唯一的操作方式
  • volatile:编译器会把"它看起来没变"的变量优化掉,可硬件随时在改它。少写一个 volatile,程序就"偶尔"不对
  • 结构体对齐和大小端:解析协议包时字段偏一个字节,整条通信全乱,而且不报错
  • 栈有多大:单片机上可能只有几 KB。递归深一点、局部数组大一点就直接溢出——这里没有操作系统替你兜底
  • 调试手段:常常没有串口可 printf,断点常常一跑就飞,得会看寄存器、看波形、对着数据手册猜

看出来了吗?这些恰好就是前面几节讲的"内功":指针、内存布局、编译链接。所以你不需要"重新学一门语言",只要把 C 的底层补齐,再补领域知识(时钟、中断、外设、RTOS)。反过来说,如果 C 只停在"考试能过"的水平,转过去等于从零开始。

但别把"跑路"当口号
硬件不是避难所:起薪普遍低于互联网,项目周期长,不少岗位在二线或者园区,出差和产线调试是常态;而且它对"动手"的要求更硬,面试可能直接给你一块板子和一个示波器。看清这些再决定,别只因为"互联网卷"就一头扎进去。

至于 AI 在这个方向上的角色:它能帮你写上层业务逻辑、搭驱动框架、解释手册里的缩写,但"这个时序对不对""这个寄存器为什么读回来是这个值"——它替不了你。越靠近物理世界,判断力越值钱,这也正是前面说内功不折旧的原因。

一句话
外功让你快,内功让你不错;外功速成快,过时也快。去年大家用 Copilot,今年换 Cursor,明年不知道又是什么;而指针、内存、复杂度这些,三十年前的书翻开还是对的。工具会一轮轮换掉,判断力不会过期。AI 时代稀缺的不是"能写出代码",是"能判断这段代码对不对"——前者越来越便宜,后者越来越贵。

10答疑

11课程推荐:从工具到造一台计算机

这一段放在最后,现场来不及讲。C 语言课只解决"能写出程序",接下来真正该补的,是程序在机器上到底怎么跑起来:内存怎么排、指令怎么执行、链接器干了什么、进程怎么切换。这条路列了四门课(其中 ICS 的中文版和原版其实是同一门,算一门),难度差得很大,按顺序打最省事。

难度递增 · 四门课各管一段,跳级容易劝退 MIT Missing Semester 工具与工程基础 Learning C Camp OpenCamp 训练营 从零到编译器内核 ICS 系统基础 CMU 15-213 = 天大版 教材 CSAPP 南京大学 PA 从零造一台计算机 硬核 · 周期长
四门课各管一段:先会用工具、把 C 练熟,再懂机器,最后自己造机器。第 ③ 个里的中文版和原版是同一门课,选一个听就行。

① MIT The Missing Semester最易

工具课 · 约 10 讲 · B 站有中字搬运
  • 讲什么:shell 与脚本、vim、Git、tmux、调试与性能分析、命令行环境。全是课上不教、但写代码天天要用的东西
  • 为什么放第一:它不教你写程序,只教你怎么高效地用电脑干活。C 语言学得再熟,不会 Git、不会 shell,一上手项目就卡住
  • 什么时候看:大一一上来就可以看,和 C 语言同步。一周能刷完,边看边在自己机器上敲
  • 注意:内容浅但密度高,看完必须立刻在自己机器上练一遍,光看等于没看

不想等课程更新到那一讲的话,文末第 12 节已经把这四样工具最常用的部分整理成了可以直接抄的速查。

② OpenCamp · Learning C Camp入门到进阶

中文训练营 · 有 CI 自动评测 · 从写第一个程序到实现编译器
  • 是什么: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 与天津大学版进阶

同一门课的两个版本 · 教材 CSAPP · 实验完全一致
  • 先说结论: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)硬核

"一生一芯"配套讲义 · 在 NEMU 上从一个空项目做起
  • 讲什么:不再"看懂"计算机,而是自己造一台。从取指译码开始实现指令、异常、IO、ELF 加载,一路做到能真正跑起程序,往上可以接操作系统和编译器
  • 为什么最难:它是工程,不是听课。代码量大、调试周期长,很多 bug 只能靠自己扒波形和日志
  • 什么时候做:过完 ICS 之后,最好放在寒暑假集中推进。想走体系结构、编译、OS 方向的话,这一段经历含金量很高
  • 注意:做不完是常态,没必要因为卡在某一关就全盘放弃。哪怕只做完指令实现那几关,你对"程序怎么跑起来"的理解也已经是另一个层次
怎么排时间
  1. 大一上:C 语言 + Missing Semester(周末看完,立刻配好自己的环境)
  2. 大一寒假:刷 Learning C Camp 的 Unit 0–2,趁热把 C 练熟
  3. 大一下:跟 ICS,中文版或原版选一个就行(同一门课,别重复学两遍)
  4. 大二上:把 ICS 的 lab 挑着做,重点 Bomb Lab 与 Malloc Lab
  5. 大二寒暑假:有余力再上 PA,当项目做,不当课听
别踩的坑
把 ICS 当成"C 语言再学一遍"是最常见的浪费。它讲的是程序与机器的关系:同样的 C 代码,为什么这样写快、那样写慢,为什么会段错误。带着"我要搞懂底层"的心态去学,才不亏。

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 buildrm -rf 没有回收站,回车前看清路径
看文件less main.c / head -n 20 f / tail -f logtail -f 用来盯运行日志
按内容搜grep -rn "main" .-r 递归,-n 带行号
按名字找文件find . -name "*.c"通配符要加引号,否则被 shell 先展开
程序装在哪which gcc排查 command not found 第一步

三个必须先搞懂的概念,就三句话:

  1. 一切输出都是文本流:程序往 stdout 吐字符,往 stderr 吐错误
  2. 管道 |:把前一条命令的输出,直接当成后一条的输入
  3. 重定向:> 覆盖写入、>> 追加、2> 收错误、2>&1 把错误并进正常输出
bash
# 编译并运行:&& 表示前一条成功(返回 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 是两个文件
Windows 用户先解决环境
别在 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 出来一堆字""怎么打不出字了""退不出去"——都是模式没搞对。

三个模式,靠 Esc 和 : 来回切;打开文件先落在普通模式 插入模式 打字就在这个模式 普通模式 默认状态,按键是命令 命令行模式 :wq 存盘退出 i a o / Esc : / Esc
插入模式打不了命令,普通模式打不了字。卡住时先按 Esc,一定回到普通模式。

下面这些在普通模式下按,背完就能干活:

想干什么按键记忆点
光标移动h j k l左 下 上 右,右手不用离开主键区
按词跳w / bword 前进,back 后退
行首 / 行尾0 / $0 是开头,$ 是结尾(正则里同理)
文件开头 / 结尾gg / G大写 G 一下到底
跳到第 50 行50G编译报错第 50 行时直接跳过去
进入插入模式i / a / o光标前插 / 后插 / 下面开新行
回到普通模式Esc忘了就先按两下,不亏
删一个字符 / 一整行x / dddd 删掉的那行进了剪贴板
复制一行 / 粘贴yy / pyank 拽走,paste 贴回来
撤销uundo,按几次退几步
搜索/关键字 再 nn 下一个,N 上一个
保存退出 / 不保存退出:wq / :q!write quit,感叹号是强制

改一个文件的最小流程,就四步:

bash
vim main.c        # 打开文件,落在普通模式
i                 # 进入插入模式,开始打字
Esc               # 打完,回到普通模式
:wq               # 保存并退出
退不出去怎么办
先按 Esc,再敲 :q! 回车——不保存强制退出。如果连冒号都打不出来,说明你还在插入模式,先按 Esc。这个顺序记住,就不会被关在 vim 里。

真正会用到它的场景长这样(ssh 上服务器改配置,没有图形界面):

bash
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。一个窗口干完所有事,不用来回切。

要装的东西只有三样,顺序别搞反:

  1. VS Code 本体:官网下载,一路下一步
  2. C/C++ 扩展(Microsoft 官方出品):补全、跳转、调试都靠它
  3. 编译器:Windows 装 MinGW-w64(或按 12.1 节直接上 WSL),macOS 执行 xcode-select --install,Linux 装 build-essential
VS Code 不是编译器
它只是编辑器,真正干活的还是 gcc。只装了 VS Code 没配编译器,点"运行"照样报 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 头上。

三个必踩的坑
  1. Code Runner 跑不了带输入的程序。它默认在只读的"输出"面板里跑,scanf 一执行就卡住不动——这是新生第一大坑。要输入就按 F5 用调试器跑,或者在终端里手动 ./main
  2. 插件别贪多。C/C++ 加一个中文语言包就够了,装一堆只会互相打架、拖慢启动
  3. 中文乱码:Windows 终端不是 UTF-8 时,printf("中文") 会变乱码。先在终端里 chcp 65001 试试,实在不行就转 WSL

要是已经习惯了 vim 的手感,装个 Vim 扩展就能在 VS Code 里继续用 hjkl,两边技能不冲突。

12.4 gcc:编译不是一步完成的

很多人以为 gcc main.c 是一个动作,其实它内部走了四步。知道这四步,报错信息你就能看懂一半——因为报错会明确告诉你"死在哪个阶段"。

gcc main.c -o main 一步到位,内部其实是这四步 main.c 源文件 main.i 预处理 -E main.s 编译 -S main.o 汇编 -c main 链接 ld
预处理展开宏和头文件,编译生成汇编,汇编生成机器码,最后链接才把库拼进来。
bash
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 是什么都不知道,更谈不上类型检查。

具体四件事:

  1. #include 是粘贴:把整个头文件的文本原样搬进来,遇到 #include 就递归展开。一个 <stdio.h> 展开后就有几千行
  2. #define 是替换:#define MAX 100 之后,代码里出现 MAX 的地方全部被换成了 100,纯粹的字符串替换,编译器根本不知道有个叫 MAX 的变量
  3. 条件编译是删代码:#ifdef / #ifndef / #endif 按条件整段保留或丢弃。同一份源码编出 Windows 版和 Linux 版,靠的就是这个
  4. 收尾:删掉所有注释、拼接反斜杠续行、插入 # 1 "main.c" 这样的行号标记——后面报错能指回你写的那一行,全靠这些标记

前后对比一下就清楚了:

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 里粘贴了一大堆东西换来的(上面那几千行里,绝大多数你根本用不到)。想亲眼看:

bash
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 两个都要用:

main.c
#include "list.h"     // 直接包含
#include "stack.h"    // 这个里面又包含了一次 list.h

预处理是纯文本粘贴,它不认识"这个文件刚才是不是已经贴过了"。所以 list.h 的内容会被原样贴进 main.c 两次,展开后长这样:

预处理之后的 main.i(简化)
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——它明确告诉你"这个文件是被谁带进来的"。头文件卫士就是专门堵这个洞的。

写法只有三行,但每一行都有作用:

list.h(加了卫哨)
#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 能不能用
能。第一行写 #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 加进命令就行:

bash
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)
Intelmov 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, a
mov esi, b
call 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 格式符用错,在汇编这一层看就是宽度对不上——它不会报错,只会读错几个字节。

拿一个真函数走一遍,比看表有用:

C
int sum(int *a, int n) {
    int s = 0;
    for (int i = 0; i < n; i++) {
        s += a[i];
    }
    return s;
}
对应的汇编(-O0,注释是我加的,省掉了几条地址计算)
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 一层新栈帧:递归太深撑爆栈 → 段错误,原因也在这里
别拿 -O2 的汇编逐行对源码
-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.cmain 函数,调用别人的接口别人的实现细节

一个最小可跑的三件套:

list.h(接口)
#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 */
list.c(实现)
#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++;
}
main.c(调用方)
#include "list.h"

int main(void) {
    struct Node *head = NULL;
    list_push(&head, 1);
    return 0;
}

注意到 list.c 自己也 #include "list.h" 了吗?这不是多余——让编译器替你检查声明和定义是否一致。参数写错了,编译就会报错,比链接期或运行期才发现强得多。

编译命令:一步和分步

bash
# 一步到位:把所有 .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/ 目录下敲:

bash
gcc -std=c11 -Wall -Wextra -g -Iinclude src/main.c src/list.c -o build/app

拆开看每个路径归谁管:

写法谁用它相对谁
src/main.cgcc 直接读这个源文件你敲命令时所在的目录
-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,不是空格):

Makefile
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 只能看到地址。

命令缩写作用
runr从头开始跑
break main / break list.c:12b下断点,按函数名或行号
nextn单步执行,不进入函数
steps单步执行,进入函数
continuec放行到下一个断点
print p / print *pp看变量值 / 看指针指向的内容
backtracebt看调用栈,崩溃排查第一命令
frame 1f 1切到栈的第 1 层,看调用者的变量
info locals一次列出当前所有局部变量
x/4xw p按十六进制看 p 指向的 4 个字内存
quitq退出

一次真实的排错长这样(链表写错了导致段错误):

gdb 会话
$ 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 主页现在基本等于程序员的第二张简历。

工作区 正在改的文件 暂存区 add 过的改动 本地仓库 commit 快照 远程仓库 GitHub add commit push
commit 只在本地存档,push 才上传。没 push 的东西只存在于你这台电脑上。
bash
git init                          # 把一个文件夹变成仓库
git status                        # 最常用:哪些改了、哪些已暂存
git add list.c                    # 把指定文件放进暂存区
git commit -m "实现链表插入"       # 存一个快照,说明写"做了什么"
git log --oneline                 # 看历史,一行一条
git diff                          # 看还没 add 的具体改动

分支和远程,四个命令就够开始:

bash
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:

.gitignore
*.o
*.exe
a.out
.vscode/
build/
这四样怎么练
不需要先系统学完再动手。做法是:给自己定一周"只用命令行" —— 用 gcc 代替 IDE 的编译按钮,用 gdb 代替 printf,每次改完代码就 git add + commit。一周之后这些就变成肌肉记忆了。真卡住了,回 Missing Semester 的第 1、2、6 讲对着补。

12.8 动手:在页面里开一个终端

命令看一百遍不如敲三遍。下面这个终端是跑在页面里的迷你 shell,带一个假文件系统,行为尽量照着 bash 来:命令打错会报 command not found,路径不对会报 No such file or directory,gcc 编译成功时一句都不输出。

bash — 页面里的迷你 shell(演示)
liu@cs:~/cs$

照着敲一遍,12.1 节那张命令表就活了:

建议的操作顺序
pwd              # 我在哪
ls               # 有什么
cat main.c       # 看看代码
gcc main.c -o main   # 编译(成功则无输出)
./main           # 运行,输出 100
cd ..            # 上一层
tree             # 树形看结构
badcommand       # 故意打错,看 bash 怎么骂你
这只是演示
它没有真正的进程、权限和管道,文件系统也是内存里编出来的。真正的终端在你自己的机器上——Git Bash、WSL 或者 macOS 的 Terminal,那里 man、grep、ps、| 全都能用。把这个页面当成"先熟悉一下手感",别当成替代品。
以上两节(课程推荐、工具链)是现场讲不完的延伸内容,会上不展开。
先把 C 和指针弄明白,再回头补这些也不迟。
01 / 15
目录 · 点击跳转 (Esc 关闭)