最近在带一个刚接触 Linux 的朋友过 C 的开发流程,发现编译和调试这两个环节,看着简单,真上手的时候一堆小细节会把人卡住。趁这个机会把我自己踩过的坑整理一下,也当是给自己留个备忘。
编译到底发生了什么
很多人写 C 就是 gcc test.c -o test 一条命令跑完,能出结果就完事了。但一旦中间出错,或者要分步看中间产物,就得搞清楚这条命令背后其实是四步。
我习惯把这四步对应到四个后缀,记起来不容易混:
- 预处理(
-E):把#include的头文件展开、宏替换掉,输出.i - 编译(
-S):把 C 源码翻译成汇编,输出.s - 汇编(
-c):把汇编转成机器码的目标文件,输出.o - 链接:把目标文件和库拼成可执行文件
这里有个特别容易栽跟头的地方:-S 是大写,-c 是小写。我第一次用的时候就记反了,gcc -s 出来的是汇编,还纳闷半天为什么没生成目标文件。这个坑到现在我还得靠"S 对应 aSsembly(汇编)"来提醒自己。
拿一段最简单的代码试一下四个参数,感受最直观。先写个 test.c:
c
#include <stdio.h>
int main() {
printf("hello\n");
return 0;
}
然后依次敲这四条,看每一步生成了什么文件:
bash
gcc -E test.c -o test.i # 预处理:test.i 里能看到 stdio.h 被展开成一大片
gcc -S test.c -o test.s # 编译:test.s 是汇编,能看到 call printf
gcc -c test.c -o test.o # 汇编:test.o 是二进制目标文件
gcc test.o -o test # 链接:test 才是能 ./test 跑的可执行文件
跑完 ls 一下,test.i、test.s、test.o、test 四个文件都在,比背概念清楚多了。
另一个更值得较真的点是,编译阶段的输入到底是 .c 还是 .i。严格说,.c 要先经过预处理变成 .i,.i 才是编译这一步真正的输入:
test.c --预处理--> test.i --编译--> test.s --汇编--> test.o --链接--> test
你写 gcc -S test.c 能一步出 .s,不是因为跳过了预处理,而是 gcc 在内部顺手把预处理做了。要较真的话,应该显式分两步:
bash
gcc -E test.c -o test.i # 预处理,得到 .i
gcc -S test.i -o test.s # 把 .i 编译成 .s
平时我们不会真的这么分步,但搞清楚这个顺序,对理解"为什么有时候报错报在头文件里"很有帮助------因为那正是预处理阶段展开头文件时出的问题。
想验证每一步的产物对不对,可以用 file 看一眼:
bash
file test.i # C source, ASCII text
file test.s # assembler source
file test.o # ELF relocatable object file
file test # ELF executable
一目了然,比空想管用。
一次完整的调试流程
光会单个命令没用,得知道整条流程怎么串起来。我拿前面那个求平方和的程序走一遍。假设源码 sum.c 长这样:
c
#include <stdio.h>
int print(int num) {
int ret = num * num;
return ret;
}
int myfunc(int num) {
int i = 1, sum = 0;
while (i <= num) {
sum += print(i);
i++;
}
return sum;
}
int main() {
int num = 0;
scanf("%d", &num);
int result = myfunc(num);
printf("%d", result);
return 0;
}
第一步,编译的时候带上 -g,这步省了后面全废:
bash
gcc -g sum.c -o sum
第二步,进 gdb 把这个程序挂上去:
bash
gdb sum
进去之后,(gdb) 提示符就等着你下命令了。我一般先看两眼源码,确认行号,再设断点:
bash
(gdb) list # 看看源码带行号
(gdb) break print # 在 print 函数入口设断点
第三步,跑起来:
bash
(gdb) run
程序会卡在 scanf,敲 5 回车。然后第一次调用 print(1) 时命中断点,gdb 会停下并显示类似:
Breakpoint 1, print (num=1) at sum.c:3
3 int ret = num * num;
第四步,单步走,边看变量:
bash
(gdb) next # 执行完 ret = num * num 这行
(gdb) print num # 看 num 是几
(gdb) print ret # 看 ret 算出来是多少
(gdb) continue # 放行,下一次循环进 print(2) 会再停
print 被调了 5 次,断点会命中 5 次,每次 continue 放行一次。到最后程序输出结果 55,gdb 提示 [Inferior 1 (process ...) exited normally]。
第五步,看完退出:
bash
(gdb) quit
这套「-g 编译 → gdb 挂载 → list 看源码 → break 设断点 → run 跑 → next/print 单步看变量 → continue 放行 → quit 退出」的顺序,是我日常调试的标准动作,练熟了后面查 bug 都是这套路子的变形。
调试时最容易懵的两个瞬间
gdb 这东西,最吓人的不是报错,而是"没反应"。我有一次调试一个带 scanf 的程序,run 之后屏幕停在 (gdb) 提示符,我还以为程序死锁了。其实根本不是------程序跑到 scanf("%d", &num),正等着我敲数字。
这种时候直接输个数字回车就行,比如:
(gdb) 5 # 喂给 scanf
(gdb) print num # 看看读进来没有
(gdb) continue # 继续往下走
拿前面那个求平方和的例子说:程序里 main 有一句 scanf("%d", &num),run 起来后光标停在那,不是卡了,是它正在等你输入。敲 5 回车,程序才继续往下跑,第一次进入 print 函数时就命中你设的断点。后来想想这事挺蠢的,但第一次遇到的人十个有八个会愣住。
另一个高频问题是:设断点的时候不知道那行代码在第几行。我在没有行号的场景下会优先用函数名设断点,直接绕开行号:
(gdb) break print # 在 print 函数入口停
(gdb) break main # 在 main 入口停
如果确实想按行号来,list 命令能直接列出带行号的源码,看清楚了再 break 行号:
(gdb) list # 列出当前函数的代码
(gdb) list 1,50 # 列出第 1 到 50 行
比如 list 出来长这样:
1 #include <stdio.h>
2 int print(int num){
3 int ret = num * num;
4 return ret;
5 }
一看就知道 print 那行在第 3 行,直接 break 3 就行。
不过所有这些的前提是编译时加了 -g。忘了加 -g 的话,list 会告诉你没有符号表,源码都看不到,断点也就无从谈起。所以我自己的习惯是:只要是为了调试编的,一定加 -g,不省这一下。
常用命令记这么几个就够日常用了:
| 命令 | 缩写 | 干什么 |
|---|---|---|
next |
n |
单步,不钻进函数 |
step |
s |
单步,钻进函数 |
print 变量 |
p 变量 |
看变量值 |
continue |
c |
跑到下一个断点 |
bt |
--- | 看调用栈 |
quit |
q |
退出 |
那个 No such file or directory
这个报错也值得单独说一句。./sum 跑不起来,报 No such file or directory,大部分人第一反应是"文件没了",其实更多时候是根本没编译出 sum 这个可执行文件。
排查就三步:先 ls -l 看目录里到底有什么,再 gcc sum.c -o sum 编译,最后 ./sum 跑。特别要注意的是,gcc sum.c 不带 -o 的话,默认产物叫 a.out,这时候你跑 ./sum 当然找不到------文件压根不叫这名。
举个最典型的翻车现场:你以为已经编译好了,直接 ./sum,结果:
$ ./sum
-bash: ./sum: No such file or directory
跑个 ls 一看,目录里只有 sum.c,压根没有 sum。要么是忘了编译,要么是 gcc sum.c 没加 -o sum,产物叫 a.out 去了。补一句 gcc sum.c -o sum,再 ./sum 就通了。
编译和调试这块,说穿了就是几个反直觉的细节:-S/-c 的大小写、.i 和 .c 谁是编译的真输入、程序"卡住"其实是在等输入、忘了 -g 就看不到源码。这些坑踩过一次就记住了,写下来希望有人能少踩几个。