写 C/C++ 程序绕不开编译,程序跑挂了要调试,怀疑进程卡在哪要追踪系统调用,查端口被谁占要列文件描述符。gcc、make、gdb、strace、lsof就是 Linux 上这五件事对应的基础工具。这一篇把每条命令最常用的部分讲清楚,看完能独立编译一个小程序、用 gdb 打断点、用 strace 看进程到底卡在哪。
一、gcc:编译四阶段
语法格式
gcc [选项] 源文件 -o 输出文件编译的四个阶段
很多人以为gcc hello.c -o hello是一步完成的,实际上它内部走了四步:预处理 → 编译 → 汇编 → 链接。每一步都能用参数单独看:
| 阶段 | 命令 | 产出 | 干什么 |
|---|---|---|---|
| 预处理 | gcc -E hello.c -o hello.i | 预处理后的 C 代码 | 展开#include、#define |
| 编译 | gcc -S hello.i -o hello.s | 汇编代码 | 翻译成汇编语言 |
| 汇编 | gcc -c hello.s -o hello.o | 目标文件(二进制) | 汇编成机器码 |
| 链接 | gcc hello.o -o hello | 可执行文件 | 合并库、解析符号 |
常用参数
| 参数 | 说明 |
|---|---|
-o 文件 | 指定输出文件名 |
-g | 生成调试信息,gdb 必备 |
-O0-O2 | 优化等级,0 不优化(调试用),2 常用 |
-Wall | 打开常用警告 |
-I 目录 | 头文件搜索路径 |
-L 目录 | 库文件搜索路径 |
-l 名字 | 链接库,如-lpthread |
-std=c99 | 指定 C 标准 |
示例与预期输出
先写一个最简单的hello.c:
#include <stdio.h> int main() { printf("hello\n"); return 0; }正常编译运行:
gcc hello.c -o hello ./hello预期输出:
hello想看预处理后的文件长什么样:
gcc -E hello.c | head -20会看到stdio.h整个被展开进来,几千行代码堆在前面。
编译时带调试信息:
gcc -g -Wall hello.c -o hello_dbg-g让 gdb 能对应到源码行号;不带-g也能调试,但看到的全是地址。
二、make 与 Makefile:自动编译
为什么需要 make
一个项目几十个.c文件,手敲gcc a.c b.c c.c -o app每次都要全量编译,改一个文件也得重来。make 的思路是:写一份规则文件(Makefile),告诉它每个文件依赖谁、怎么编译,它自动判断哪些文件改过、只重新编译改过的。
最小可用 Makefile
在项目目录下新建Makefile(首字母大写,无扩展名):
CC = gcc CFLAGS = -g -Wall hello: hello.o $(CC) hello.o -o hello hello.o: hello.c $(CC) $(CFLAGS) -c hello.c -o hello.o clean: rm -f hello hello.o三条核心规则
| 概念 | 说明 |
|---|---|
| 目标(target) | hello:前面那部分,想生成的东西 |
| 依赖(prerequisite) | 冒号后面,生成目标需要的文件 |
| 命令(recipe) | 下一行必须用Tab开头,不是空格 |
常用命令
| 命令 | 作用 |
|---|---|
make | 按第一个目标编译 |
make clean | 执行 clean 目标,清理产物 |
make -j4 | 4 个任务并行编译 |
示例与预期输出
make预期输出:
gcc -g -Wall -c hello.c -o hello.o gcc hello.o -o hello再跑一次make:
make: 'hello' is up to date.因为hello比hello.o新,没东西要重新编。改一下hello.c再make,只会重新编译hello.o这一步,链接那步会重做但很快。
⚠️ 最常见的坑
命令行下面那一行开头必须是Tab,用空格代替会报missing separator。编辑器里把 Tab 显示出来检查一下。
三、gdb:断点调试
语法格式
gdb ./可执行文件前提是编译时带了-g。
常用命令
| gdb 命令 | 简写 | 作用 |
|---|---|---|
break main | b main | 在 main 函数入口打断点 |
break 行号 | b 12 | 在当前文件第 12 行打断点 |
run | r | 运行程序 |
next | n | 单步执行,不进入函数 |
step | s | 单步执行,进入函数 |
print 变量 | p var | 打印变量值 |
bt | bt | 打印调用栈 |
continue | c | 继续运行到下一个断点 |
list | l | 看当前位置附近源码 |
quit | q | 退出 gdb |
示例与预期输出
gdb ./hello_dbg进入 gdb 交互界面:
GNU gdb (Ubuntu 9.2-0ubuntu1~20.04.1) 9.2 ... (gdb)在 main 打断点并运行:
(gdb) b main Breakpoint 1 at 0x1149: file hello.c, line 4. (gdb) r Starting program: /home/user/hello_dbg Breakpoint 1, main () at hello.c:4 4 printf("hello\n"); (gdb) n hello 5 return 0; (gdb) c Continuing. [Inferior 1 (process 12345) exited normally] (gdb) qb main停在 main 入口,r启动,n执行一行(printf 的输出hello同时打到屏幕),c跑到底退出。
程序崩溃时用gdb ./程序 core.xxx加载 core dump 文件,bt直接看崩溃那一刻的调用栈,这是定位段错误最快的方式。
四、strace:追踪系统调用
语法格式
strace [选项] 命令常用参数
| 参数 | 说明 |
|---|---|
-e trace=open,read | 只追踪指定的系统调用 |
-p PID | attach 到正在运行的进程 |
-c | 统计每个系统调用的次数和耗时 |
-o 文件 | 输出写到文件,不刷屏幕 |
-t | 每行前面加时间戳 |
示例与预期输出
看ls启动时做了哪些系统调用:
strace ls 2>&1 | head -15预期输出(节选):
execve("/usr/bin/ls", ["ls"], 0x7ffd3b2c5e80 /* 58 vars */) = 0 brk(NULL) = 0x55f8b2c2a000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3每一行是一次系统调用:调用名、参数、返回值。ENOENT后面括号里是错误描述,不用自己查 errno 表。
只看文件相关的调用:
strace -e trace=openat,open,read,write ls 2>&1 | head -5想看某个正在跑的进程到底在干嘛:
strace -p 12345Ctrl+C停止追踪,原进程继续跑。
程序跑慢了想知道卡在哪:
strace -c ./slow_program跑完会输出一张统计表,哪个系统调用占时间最多、失败次数最多,一眼看出来。
五、lsof:列出打开的文件
Linux 里一切皆文件,进程打开的文件、网络连接、管道、设备,在 lsof 里都是"打开的文件描述符"。
语法格式
lsof [选项]常用参数
| 参数 | 说明 |
|---|---|
-i :端口 | 列出占用指定端口的进程 |
-i TCP | 列出所有 TCP 连接 |
-p PID | 列出某个进程打开的所有文件 |
-u 用户名 | 列出某用户打开的文件 |
文件名 | 看哪个进程打开了这个文件 |
示例与预期输出
查 80 端口被谁占了:
sudo lsof -i :80预期输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME nginx 1234 root 6u IPv4 12345 0t0 TCP *:http (LISTEN)第一列进程名,第二列 PID,最后一列连接状态。知道 PID 就能kill或者去查它的配置。
查某个进程打开了哪些文件:
lsof -p 1234会列出该进程打开的所有.so动态库、日志文件、socket。程序报"找不到配置文件"时,用这个命令看它到底从哪个路径读的,比瞎猜快。
查某个文件被谁占用:
lsof /var/log/nginx/access.log预期输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME nginx 1235 root 7w REG 8,1 123456 100 /var/log/nginx/access.log想删一个文件但提示"设备或资源忙",十有八九就是某个进程还开着它。用 lsof 找到那个进程,停掉再删。
六、知识扩展:strace 为什么能看到别的进程在干嘛
strace 靠的是 Linux 内核的ptrace系统调用。一个进程(strace)可以通过 ptrace 把自己"attach"到另一个进程上,之后目标进程每发起一次系统调用,内核都会把它拦下,通知 strace,再把控制权还回去。所以 strace 不需要重新编译目标程序、不需要读它的源码,只要有权限(root 或同一个用户),就能看到任何进程的系统调用轨迹。
这也解释了为什么strace -p偶尔会让目标程序变慢——每次系统调用都多了一层上下文切换。生产环境跑 strace 记得加-c做短时间采样,别挂着不管。
make 那块的依赖判断同理:它不猜你改了什么,只看每个文件的修改时间(mtime)。目标文件比依赖文件新就跳过,旧了就重新跑命令。所以手动touch一下源文件能强制 make 重新编译,删掉中间产物再make也是这个原理。理解了时间戳比较,Makefile 里那些"奇怪"的行为就都能解释了。