最近在带一个刚接触 Linux 的朋友过 C 的开发流程,发现编译和调试这两个环节,看着简单,真上手的时候一堆小细节会把人卡住。趁这个机会把我自己踩过的坑整理一下,也当是给自己留个备忘。
编译到底发生了什么
很多人写 C 就是gcc test.c -o test一条命令跑完,能出结果就完事了。但一旦中间出错,或者要分步看中间产物,就得搞清楚这条命令背后其实是四步。
我习惯把这四步对应到四个后缀,记起来不容易混:
- 预处理(
-E):把#include的头文件展开、宏替换掉,输出.i - 编译(
-S):把 C 源码翻译成汇编,输出.s - 汇编(
-c):把汇编转成机器码的目标文件,输出.o - 链接:把目标文件和库拼成可执行文件
这里有个特别容易栽跟头的地方:-S是大写,-c是小写。我第一次用的时候就记反了,gcc -s出来的是汇编,还纳闷半天为什么没生成目标文件。这个坑到现在我还得靠"S对应 aSsembly(汇编)"来提醒自己。
拿一段最简单的代码试一下四个参数,感受最直观。先写个test.c:
#include<stdio.h>intmain(){printf("hello\n");return0;}然后依次敲这四条,看每一步生成了什么文件:
gcc-Etest.c-otest.i# 预处理:test.i 里能看到 stdio.h 被展开成一大片gcc-Stest.c-otest.s# 编译:test.s 是汇编,能看到 call printfgcc-ctest.c-otest.o# 汇编:test.o 是二进制目标文件gcc test.o-otest# 链接: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 在内部顺手把预处理做了。要较真的话,应该显式分两步:
gcc-Etest.c-otest.i# 预处理,得到 .igcc-Stest.i-otest.s# 把 .i 编译成 .s平时我们不会真的这么分步,但搞清楚这个顺序,对理解"为什么有时候报错报在头文件里"很有帮助——因为那正是预处理阶段展开头文件时出的问题。
想验证每一步的产物对不对,可以用file看一眼:
filetest.i# C source, ASCII textfiletest.s# assembler sourcefiletest.o# ELF relocatable object filefiletest# ELF executable一目了然,比空想管用。
一次完整的调试流程
光会单个命令没用,得知道整条流程怎么串起来。我拿前面那个求平方和的程序走一遍。假设源码sum.c长这样:
#include<stdio.h>intprint(intnum){intret=num*num;returnret;}intmyfunc(intnum){inti=1,sum=0;while(i<=num){sum+=print(i);i++;}returnsum;}intmain(){intnum=0;scanf("%d",&num);intresult=myfunc(num);printf("%d",result);return0;}第一步,编译的时候带上-g,这步省了后面全废:
gcc-gsum.c-osum第二步,进 gdb 把这个程序挂上去:
gdbsum进去之后,(gdb)提示符就等着你下命令了。我一般先看两眼源码,确认行号,再设断点:
(gdb)list# 看看源码带行号(gdb)breakprint# 在 print 函数入口设断点第三步,跑起来:
(gdb)run程序会卡在scanf,敲5回车。然后第一次调用print(1)时命中断点,gdb 会停下并显示类似:
Breakpoint 1, print (num=1) at sum.c:3 3 int ret = num * num;第四步,单步走,边看变量:
(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]。
第五步,看完退出:
(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就看不到源码。这些坑踩过一次就记住了,写下来希望有人能少踩几个。