干这行久了都会遇到同一个场景:新产品上线后跑了半天,线上进程突然状态不对,要么 CPU 占用飘到几百,要么程序直接崩掉连日志都来不及打。这时候我一般不会急着去翻业务代码,而是先看进程状态,再确认手里的二进制有没有带调试信息,最后用 GDB 一层层扒开问题。Linux 进程管理、GCC 编译、GDB 调试,这三样东西在很多人看来是独立技能,但从我实际解决问题的经验看,它们就是一条完整的排查链路。
这篇文章我会把这三块放到一起讲,从最常用的 ps、top、信号操作开始,再讲 GCC 编译时如何把调试信息做进二进制,最后通过 GDB 把崩溃问题定位到代码行。同时会穿插我实际踩过的坑,比如 gcc 升级后版本不生效、gdb 怎么判断某个地址的内存是否已经被释放、VSCode 配 GCC 工具链到底配什么。对正在做 Linux 开发、嵌入式项目或者运维排查的同学,这套东西读完后基本可以直接套用。
1. Linux 进程管理:动手之前先把“状态”看懂
1.1 查看进程的常用命令:ps、top、htop 怎么选
排查任何程序问题,第一步都是回答“这个进程现在到底在干嘛”。Linux 下最常用的两个进程查看命令是 ps 和 top,但很多人其实没搞清楚它们的分工。
ps 是快照式命令,适合做瞬态查看和脚本处理。排查崩溃、确认 PID 是否存在、查看进程父子关系时,我基本都用 ps aux 或者 ps -ef。这两个写法看起来很像,实际输出字段有差异:ps aux 是 BSD 风格,会显示 CPU 占用、内存占用、VSZ、RSS,适合快速看资源消耗;ps -ef 是 System V 风格,会显示 UID、PID、PPID,适合梳理进程父子关系。日常我一般直接 ps aux,要看父进程时再补 ps -ef。
top 是动态刷新模式,适合持续观察。比如进程在跑,但想确认它是不是真的在消耗 CPU,或者想盯着内存是不是持续上涨,top 比 ps 直观得多。按 P 按 CPU 排序,按 M 按内存排序,按 t 看 CPU 总量的条形统计,这几个快捷键我几乎每次都用。
htop 属于增强版 top,交互体验更好,可以鼠标点击、树状显示进程,还能直接在界面里发信号给进程。看起来方便,但很多服务器默认没装,还得先 apt install htop 或者 yum install htop,所以我反而习惯了直接 top,毕竟哪个机器都有。
pidstat 是 sysstat 包里的命令,适合做单进程维度的定时采样,例如打算观察进程 1234 的 CPU 和内存变化:
bash复制pidstat -u -r -p 1234 1 10
这条命令每秒采样一次,连续 10 次,能清晰看到该进程的 CPU 使用率、内存变化趋势,比 top 的持续盯屏更适合记录和对比。
1.2 进程状态 STAT 里藏着的关键信息
进程状态字段是排查问题的重灾区。一个进程卡住了,用 ps aux 看 STAT 列,就能迅速判断是“没在干活”还是“卡在内核里出不来了”。
Linux 进程状态常见的有这几种:
| 状态码 | 含义 | 常见场景 | 是否能被杀掉 |
|---|---|---|---|
| R | Running / Runnable | 正在运行或处于就绪队列 | 能 |
| S | Sleeping(可中断) | 等待 IO、等待锁、正常休眠 | 能 |
| D | Uninterruptible Sleep | 不可中断睡眠,通常卡在内核 IO | 很难杀掉,甚至 kill -9 也没用 |
| Z | Zombie | 子进程已退出但父进程没回收 | 杀不掉,只能让父进程处理或退出 |
| T | Stopped / Traced | 被 Ctrl+Z 挂起,或被 gdb 中断 | 能,kill -SIGCONT 恢复 |
| X | Dead | 完全退出,极少见 | 无需处理 |
我印象最深的是 D 状态进程。曾经有个服务读某个 NFS 挂载目录,网络存储出现故障后,进程一直处于 D 状态,kill -9 都杀不掉,因为它在内核态等待 IO,信号根本来不及处理。最后只能重启机器或者等超时才恢复。所以看到 D 状态,不要浪费时间发信号,先排查底层 IO 是否异常。
Z 状态也是面试和日常经常碰到的。僵尸进程的产生原理很简单:子进程 exit 之后,会向父进程发送 SIGCHLD,如果父进程没有调用 wait/waitpid 去回收,这个子进程就会残留为僵尸。它不占 CPU,也不占内存,但会占一个 PID。你没法直接 kill 僵尸,因为进程已经死了,唯一的办法是让它的父进程退出,交给 init 进程回收,或者让父进程程序里正确写 wait 逻辑。
顺带说一下,ps 里看线程要用 -L 参数。进程和线程的关系,可以用一句话概括:进程是资源分配单位,线程是调度单位。一个进程崩溃不可怕,但多线程里一个线程越界写内存,可能把别的线程的数据一起写坏,这时候查错尤其费劲。排查线程状态时我常用:
bash复制ps -eLf | grep 进程名
或者:
bash复制top -H -p PID
后者会展示指定进程内的所有线程 CPU 使用情况,哪个线程把 CPU 吃光了,一眼就能看到。
1.3 进程生命周期、信号与前后台调度
进程从创建到结束,绕不开 fork 和 exec 两个系统调用。开发时不用手写这些,但理解生命周期对排查程序是否被正常拉起、退出码代表的含义有帮助。
Linux 的信号机制必须要熟,因为运维和调试过程里,信号是我们和进程交互最主要的方式。
| 信号 | 编号 | 默认行为 | 我常用的场景 |
|---|---|---|---|
| SIGHUP | 1 | 终止进程 | 终止挂断的会话进程,很多守护进程重载配置用它 |
| SIGINT | 2 | 终止进程 | Ctrl+C 触发,相当于前台进程被中断 |
| SIGKILL | 9 | 无法捕获、无法忽略 | 最后手段,强制杀掉进程 |
| SIGTERM | 15 | 终止进程 | kill 默认发这个,程序可以捕获做清理 |
| SIGSTOP | 19 | 停止进程 | 和 Ctrl+Z 类似,进程进入 T 状态 |
| SIGCONT | 18 | 让停止的进程继续 | 恢复被 SIGSTOP 的进程 |
发信号时,kill 后面可以接 PID,pkill 可以接进程名,killall 也可以按名字批量发。但要记住,kill -9 是最后手段,因为它不留给进程任何清理机会,锁文件、临时文件、共享内存都可能处于脏状态。遇到需要优雅重启的程序,优先 kill -SIGTERM PID,让它自己处理完收尾工作。
前台和后台切换也是日常高频操作。在终端里启动一个程序,按 Ctrl+Z 会把它挂起,进程进入 T 状态。接着用:
bash复制jobs
bg %1
fg %1
可以把作业放到后台继续执行,或者拉回前台。注意,bg 后的进程虽然放到后台,但它的 stdout 还是连着当前终端,如果终端断开,进程可能收到 SIGHUP 被终止。所以真正要长期跑,应该用:
bash复制nohup ./server &
或者更稳妥地配合输出重定向:
bash复制nohup ./server > server.log 2>&1 &
nohup 的本质是忽略 SIGHUP 信号,进程即使终端断开也能存活。另一种方式是 setsid ./server,让进程完全脱离当前会话,进入新的进程组与会话,实际守护进程的标准做法就是按这个思路来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GCC 编译:给调试打好底子
2.1 GCC 编译四阶段与核心参数
GCC 从源码到可执行文件,一共经历四个阶段:预处理、编译、汇编、链接。理解这四个阶段,调试时很多报错就能对号入座。
bash复制gcc -E main.c -o main.i # 预处理:展开头文件、宏替换
gcc -S main.i -o main.s # 编译:生成汇编代码
gcc -c main.s -o main.o # 汇编:生成目标文件(机器码)
gcc main.o -o main # 链接:整合生成可执行文件
实际工作中我不会拆成四步,太繁琐,基本都是直接一步到位:
bash复制gcc -g -Wall -O0 main.c helper.c -o app
但这几个参数值得说透:
-g 是生成调试信息,让 GDB 能把机器指令对应回源码行号、变量名。没有 -g,GDB 只能看到原始的汇编和内存地址,基本没法看。-ggdb 比 -g 生成更多专供 GDB 使用的信息,比如宏定义。嵌入式或者后端 C 程序我一般更推荐 -ggdb。
-Wall 是打开所有常见编译警告。很多人忽略警告,但警告往往是隐患的线索,比如未初始化变量、类型不匹配、函数声明缺失。曾有一次排查崩溃问题,最后发现是一个函数没有显式声明,编译器把它隐式推断成返回 int,导致指针被截断,这种问题 -Wall 一开始就会报警告。
-O0 是关闭优化。调试阶段务必用 O0,否则 GDB 单步时变量会被优化掉,行号也可能对不上。发布阶段再考虑 -O2 甚至 -O3。这里也解释了一个疑惑,为什么 VSCode 里配置 GCC 后,F5 调试时变量窗口显示 <optimized out>——大概率是直接用了 release 模式的编译参数。调试阶段关优化,问题能少一半。
如果程序很大,我习惯分开编译,把每个 .c 文件先编译成 .o,最后一起链接,这样改一个文件不用全量重新编译。这个方式和 Makefile 的思想一致。
2.2 头文件路径与库文件链接
写实际项目,几乎一定会用到第三方库。头文件找不到、库文件找不到、链接报 undefined reference,这三个问题的解决思路都围绕几个编译参数。
-I 指定头文件搜索路径:
bash复制gcc -I./include main.c -o app
-L 指定库文件搜索路径,-l 指定链接的库名:
bash复制gcc main.c -L./lib -lmylib -o app
注意 -lmylib 会链 libmylib.so 或 libmylib.a,mylib 的命名规则是去掉 lib 前缀和 .so/.a 后缀。比如 zlib 的库文件是 libz.so,链接时写 -lz。
这里最容易踩的坑是链接顺序。GCC 链接器处理目标文件是从左往右的,一个库只处理一次,如果库 A 依赖于库 B,但 A 在 B 后面,链接时可能报 undefined reference。经验法则是把库文件放在源文件或目标文件之后,依赖库放在被依赖库之后。也就是说:
bash复制gcc main.o libA.a libB.a -o app
如果 libA 依赖 libB,A 还要在 B 前面。
静态库和动态库的选择,编译时默认优先链接动态库(.so),如果同一目录下同时存在 .so 和 .a,优先 .so。想强制静态链接,可以加 -static,但注意 glibc 静态链接后在部分系统上可能引发 DNS 解析问题,这个后面在常见问题里再细说。程序运行期,如果找不到动态库,可以用 ldd app 查看依赖,用 LD_LIBRARY_PATH 临时指定路径,或者编译时加 -Wl,-rpath,/path/to/lib 写入 RUNPATH。
2.3 GCC 升级后为什么还是旧版本
这个坑我见过太多次了,也帮同事填过不少次。现象通常是执行:
bash复制gcc --version
版本还是老的,明明从源码安装到了 /usr/local/bin,但 shell 执行的还是 /usr/bin/gcc。
原因基本有三个方面:
第一,PATH 环境变量顺序问题。shell 从 PATH 指定的路径从左到右找可执行文件,找到第一个就不找了。如果 /usr/bin 排在 /usr/local/bin 前面,那即使 /usr/local/bin/gcc 更新,系统也只会用 /usr/bin/gcc。排查方法:
bash复制echo $PATH
which gcc
ls -l $(which gcc)
第二,shell 命令缓存。bash 会把命令路径缓存到哈希表里,升级新版本后直接执行 gcc,shell 可能还在用旧缓存。处理方法是清缓存:
bash复制hash -r
第三条路是软链接。确认新的 gcc 在 /usr/local/bin 后,可以改软链接统一版本:
bash复制ls -l /usr/local/bin/gcc
ln -sf /usr/local/bin/gcc-13 /usr/local/bin/gcc
或者用 update-alternatives 管理版本:
bash复制update-alternatives --install /usr/bin/gcc gcc /usr/local/bin/gcc-13 100
要提醒一句,不要图省事直接覆盖 /usr/bin/gcc,因为系统很多底层软件依赖特定版本的 GCC,强行覆盖可能把编译环境搞挂。安全做法是让新版通过 /usr/local/bin 和 PATH 优先级生效。
2.4 VSCode 配置 GCC 工具链
VSCode 远程连 Linux 开发也是常事。配置 GCC 工具链时,核心只有两个文件:tasks.json 管编译,launch.json 管调试。
tasks.json 里配置编译命令,简单示例:
json复制{
"version": "2.0.0",
"tasks": [
{
"label": "build app",
"type": "shell",
"command": "gcc",
"args": [
"-g", "-Wall", "-O0",
"${workspaceFolder}/*.c",
"-o", "${workspaceFolder}/app"
],
"group": "build"
}
]
}
launch.json 里指定要调试的程序和 GDB 路径:
json复制{
"version": "0.2.0",
"configurations": [
{
"name": "debug app",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/app",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"miDebuggerPath": "/usr/bin/gdb",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"preLaunchTask": "build app"
}
]
}
最常遇到的坑是 miDebuggerPath 写错,或者 program 路径不对。如果用 WSL 或者 SSH 远程,要在 VSCode 的 Remote 插件环境下,确认 GDB 路径是 Linux 系统里的路径,而不是 Windows 本地的路径。
3. GDB 调试:能从入门到排查线上问题
3.1 启动 GDB 的三种方式与基本操作
GDB 调试有三种启程方式,对应三种完全不同的场景。
第一种,直接调试一个新进程:
bash复制gdb ./app
(gdb) run arg1 arg2
第二种,附加到已经运行的进程,适合排查线上问题:
bash复制gdb -p PID
第三种,调试崩溃产生的 core 文件:
bash复制gdb ./app core
进入 GDB 后,基础操作必须熟练。我列一个最常用的速查表:
| 命令 | 缩写 | 作用 |
|---|---|---|
| run | r | 启动程序,可以带参数 |
| break | b | 下断点:b 行号 / b 函数名 / b 文件:行号 |
| continue | c | 继续运行到下一个断点 |
| next | n | 单步执行,不进入函数 |
| step | s | 单步执行,进入函数 |
| finish | fin | 执行完当前函数并返回 |
| p | 打印变量值:p variable / p &variable | |
| x | x | 查看内存:x/10x addr |
| bt | bt | 查看调用栈 |
| frame | fr | 切换栈帧 |
| info locals | i lo | 查看当前函数所有局部变量 |
| list | l | 显示断点附近源码 |
| delete | d | 删除断点 |
刚开始用 GDB 的人容易搞混 next 和 step,记法就是:next 是“跳过”,step 是“进入”。比如一个函数里调用了 strcpy,你想看 strcpy 内部怎么执行,就用 step;想直接跳过 strcpy 到下一条语句,就用 next。
3.2 断点进阶:条件断点、监视点、临时断点
调试循环体或者数据量大的代码时,直接打断点往往在海量迭代里浪费大量时间,每次都要按 continue 半天。条件断点能彻底解决这个问题:
bash复制(gdb) break server_demo.c:42 if tick == 100
意思是在文件第 42 行停下,但只有当 tick 等于 100 的时候才停。实现原理是 GDB 每次执行到断点位置都会检查条件,满足才真正停下。
监视点是更强悍的能力。如果你怀疑某个内存地址被谁改写了,用 watch 可以捕捉内存写入的瞬间:
bash复制(gdb) watch global_var
(gdb) watch *(int*)0x7ffff7b89000
只要这个变量的值发生变化,GDB 就会立即中断,并显示触发中断的代码位置。这个特性在排查堆内存被越界写时非常有用。
临时断点用 tbreak,断点命中一次后自动删除,适合在某处停一次,配合后续单步操作。
3.3 判断一个地址是否已经被释放
调试堆相关问题时,经常遇到“这个指针指向的内存到底有没有被 free 过”的疑问。我先说一个我自己的经验:GDB 里单靠看内容是不准确的,因为内存被释放后,里面的数据可能还在,也可能被其他代码重新写入。判断地址是否被释放,我一般按下面几步来。
首先,确认地址区域是否属于堆。运行:
bash复制(gdb) info proc mappings
如果地址落在 heap 区间,说明是堆内存。其次,glibc 在 free 一块小内存后,会在被释放 chunk 的 fd/bk 指针位置写入 main_arena 相关的内核地址。可以对比释放前后的内存内容:
bash复制(gdb) x/4gx 0x5555555592a0
释放前可能看到业务数据,释放后如果前两个字段变成 0x7f... 这种类似 libc 地址的值,基本可以确定这块内存已经被释放并被放入空闲链表了。
如果还想定位是谁访问了这块已释放的内存,可以用 watch 监视地址:
bash复制(gdb) watch *(char(*) [32]) 0x5555555592a0
一旦有写入,GDB 会立刻停住。但注意,操作系统和 glibc 底层也可能因为某些机制触达这块内存,所以结果还要结合调用栈判断。
更可靠的方案,是编译时用 AddressSanitizer 替代纯 GDB 排查。编译时加:
bash复制gcc -g -fsanitize=address main.c -o app
运行时如果发生堆越界、double free、use-after-free,程序会直接打印详细的错误报告,包括触发位置。这个工具和 GDB 配合起来,排查内存bug效率能翻倍。
3.4 多线程调试的关键操作
多线程程序崩溃后,GDB 的 bt 只能看到当前线程的调用栈,但问题往往是另一个线程导致的。此时先看所有线程:
bash复制(gdb) info threads
(gdb) thread apply all bt
thread apply all bt 会把所有线程的调用栈一次打出来,这是多线程崩溃后我第一件做的事。先看哪个线程停在哪个位置,再切换过去:
bash复制(gdb) thread 3
(gdb) bt
单步调试多线程时有个关键的坑:默认情况下,GDB 单步执行当前线程,其他线程也在跑。这会导致变量值在你眼前“莫名其妙”变化。要避免干扰,设置:
bash复制(gdb) set scheduler-locking on
这样单步时只执行当前线程,其他线程全部锁住,变量才可控。排查完记得恢复:
bash复制(gdb) set scheduler-locking off
3.5 Core Dump 调试:崩溃后的第一手证据
程序崩溃后,最宝贵的资料就是 core dump。这里面包含了进程崩溃瞬间的内存快照、寄存器、调用栈,是 GDB 分析崩溃问题最直接的现场证据。
但默认情况下很多 Linux 环境下 core 是关闭的。排查前先确认:
bash复制ulimit -c
如果是 0,说明不会生成 core 文件。临时开启:
bash复制ulimit -c unlimited
注意这条命令只对当前 shell 会话有效,要永久生效可以写到 /etc/security/limits.conf 里。core 文件的生成路径和格式由内核参数控制:
bash复制cat /proc/sys/kernel/core_pattern
很多发行版默认通过 systemd-coredump 管理,core 会生成到 systemd-coredump 的目录,而不是当前目录。这时可以改 kernel 参数把 core 固定到自定义目录:
bash复制echo "/var/cores/core.%e.%p" > /proc/sys/kernel/core_pattern
生产环境建议把 core 目录单独挂载并定期清理,否则一次大崩溃就能把磁盘写满。
拿到 core 后,启动 GDB:
bash复制gdb ./app /var/cores/core.app.12345
(gdb) bt
如果编译时带了 -g,就能直接看到崩溃函数和行号。如果需要看崩溃瞬间的局部变量:
bash复制(gdb) frame 0
(gdb) info locals
3.6 附加到运行中的进程
排查运行中程序的问题,gdb attach 是免不了的一招。直接:
bash复制gdb -p PID
附加成功后,进程会暂停,这时候可以用 info threads、bt、p variable 查看当前状态。甚至可以临时改值:
bash复制(gdb) set var need_cleanup = 1
我曾在排查一个死循环问题时,用 GDB attach 到进程,查看调用栈后发现卡在某个循环的等待逻辑里,顺手改了标志位让程序自行恢复,替维护人员争取了重启时间。
不过要记住,GDB attach 会暂停进程,这对线上服务是有影响的,操作前务必确认低峰期或者征得同意。有些系统出于安全考虑限制了 ptrace 能力,如果附加时报权限不足,检查 /proc/sys/kernel/yama/ptrace_scope,非必需情况下不要直接改成 0 关闭保护,优先用同用户权限。
4. 综合实战:一个 double free 服务的完整排查过程
4.1 场景与复现代码
为了把进程管理、GCC、GDB 串起来,我写一个模拟服务程序。它有两个线程,主线程负责业务计数,采集线程负责定期清理任务对象,但代码里故意犯了一个典型的 double free 错误。
c复制// server_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
typedef struct {
int tick;
int ready;
} TaskInfo;
static TaskInfo *g_task = NULL;
static int g_main_done = 0;
void *collector(void *arg) {
// 等待主线程业务完成
while (!g_main_done) {
usleep(10000);
}
// 错误代码:主线程已经释放过 g_task,这里又释放一次
free(g_task);
printf("[collector] collector freed task\n");
return NULL;
}
int main(void) {
pthread_t tid;
int i;
g_task = (TaskInfo *)malloc(sizeof(TaskInfo));
g_task->tick = 0;
g_task->ready = 1;
pthread_create(&tid, NULL, collector, NULL);
for (i = 0; i < 5; i++) {
sleep(1);
g_task->tick++;
printf("[main] tick = %d\n", g_task->tick);
}
// 主线程释放一次
free(g_task);
printf("[main] main freed task\n");
g_main_done = 1;
pthread_join(tid, NULL);
printf("service exit\n");
return 0;
}
程序的基本逻辑是主线程累积计数 5 次后释放全局任务对象,采集线程等待主线程完成后也去释放同一个对象,于是 double free。编译命令如下:
bash复制gcc -g -Wall -O0 server_demo.c -o server_demo -lpthread
4.2 用进程管理工具观察程序运行
后台启动程序,先观察状态:
bash复制./server_demo &
echo $!
$! 能拿到刚启动进程的 PID。然后用 ps 看基本信息:
bash复制ps -ef | grep server_demo
正常情况下会看到一个 STAT 为 S 的进程,代表它在正常睡眠,没有异常消耗 CPU。再输出线程信息:
bash复制top -H -p PID
会看到两个线程,一个对应 main 线程,一个对应 collector 线程。此时一切看起来很正常,但程序跑几秒后就会因为 double free 崩溃。
4.3 崩溃与 core 文件生成
程序运行的日志大致如下:
text复制[main] tick = 1
[main] tick = 2
[main] tick = 3
[main] tick = 4
[main] tick = 5
[main] main freed task
[collector] collector freed task
malloc(): invalid size (unsorted)
Aborted (core dumped)
崩溃原因是 glibc 的分配器检测到被释放的 chunk 状态不对,主动触发 abort。如果之前执行过:
bash复制ulimit -c unlimited
并且 core_pattern 配置了合适的路径,这里就会生成 core 文件。用 ls /var/cores 能看到类似 core.server_demo.12345 的文件。
4.4 用 GDB 分析现场
加载 core 文件:
bash复制gdb ./server_demo /var/cores/core.server_demo.12345
进入 GDB 后,先打调用栈:
bash复制(gdb) bt
输出类似:
text复制#0 __GI_abort () at abort.c:79
#1 __libc_message (action=action@entry=do_abort, fmt=fmt@entry=...) at libc_fatal.c:155
#2 malloc_printerr (str=str@entry=...) at malloc.c:5347
#3 _int_free (av=..., p=..., have_lock=0) at malloc.c:4173
#4 __GI___libc_free (mem=...) at malloc.c:3357
#5 collector () at server_demo.c:18
#6 start_thread (arg=...) at pthread_create.c:477
#7 clone () at sysdeps/unix/sysv/linux/x86_64/clone.S:95
虽然崩溃发生在 collector 线程,但真正的原因要看另一处 free。运行:
bash复制(gdb) thread apply all bt
把两个线程的调用栈都看一遍。main 线程的调用栈虽然停在 pthread_join,但上面能看到 main 函数里也有一次 free。此时基本可以断定:同一块内存 g_task 被主线程和 collector 线程各释放了一次。
再确认指针的值:
bash复制(gdb) print g_task
会看到 g_task 指向一个非空地址,但该地址已经被释放过了。由于代码里有 g_main_done 作为等待标志,collector 才敢 free,但它并不知道主线程已经 free 过同一个指针。
4.5 修复与验证
修复这个 bug 有两种思路。最简单的做法,是主线程 free 后将全局指针置为 NULL,collector 释放前判断空值:
c复制free(g_task);
g_task = NULL;
collector 端:
c复制while (!g_main_done) {
usleep(10000);
}
if (g_task) {
free(g_task);
g_task = NULL;
}
重新编译:
bash复制gcc -g -Wall -O0 server_demo.c -o server_demo -lpthread
./server_demo
这次能看到程序正常跑完,输出 service exit,不再出现 abort。整个排查过程,其实就是先通过 ps 和 top 确认进程运行状态,再用 GDB 的 thread apply all bt 抓到多线程调用栈,最后在代码里修复根因。这条链路在绝大多数 Linux 下的崩溃排查里都能复用。
5. 高频问题速查表与避坑经验
5.1 高频问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| gcc 编译出的版本还是旧版 | PATH 顺序、shell 缓存 | echo $PATH、which gcc、hash -r |
| 编译报 undefined reference | 链接顺序错误、库名写错 | 将 -l 放到源文件之后,检查库名是否正确 |
| 找不到头文件 | -I 路径不对 | 确认头文件实际目录,编译输出加 -v 查看搜索路径 |
| 程序运行时提示找不到动态库 | LD_LIBRARY_PATH 未设置 | ldd app 查看依赖,用 rpath 固化路径 |
| 段错误但没有生成 core | ulimit -c 为 0,core_pattern 不对 | 临时 ulimit -c unlimited,配置 core_pattern |
| GDB 显示 no symbol table | 编译忘记加 -g | 重新编译加 -g |
| 调试时变量显示 optimized out | 编译优化级别过高 | 调试用 -O0 |
| 多线程崩溃却不知哪个线程引起 | 共享数据竞争、double free | thread apply all bt 查看所有线程调用栈 |
| GDB 附加进程失败 | ptrace 权限限制 | 检查 yama ptrace_scope 和用户权限 |
5.2 我的一些实际经验
调试这件事,工具只是基础,真正决定效率的是流程。
编译阶段就做好符号保留。发布二进制时,可以用 objcopy --only-keep-debug app app.debug 把符号表抽出来单独存放,线上运行时不影响性能,崩溃后拿符号文件配合 core 也能还原调用栈。
别轻易 kill -9。很多开发人员一着急就 kill -9,结果程序没机会清理临时文件、释放锁,重启后各种脏数据问题。先给 SIGTERM,等几秒,不行再 SIGKILL,这个习惯能少处理很多善后工作。
进程 D 状态和 Z 状态不要盲目等待。D 状态大概率是 IO 卡死,Z 状态的根源在父进程。遇到这类问题,重点放在存储和父进程代码逻辑上,不要反复发信号浪费时间。
我自己的体会是,GDB 用得越熟练,写代码时就越谨慎。你用 GDB 看过几次内存布局、几次多线程交错,就知道哪些地方容易埋雷,写代码时自然会更注意。尤其像 double free、use-after-free 这类问题,肉眼往往很难发现,但 GDB 一两分钟就能给出答案。如果你正在从“能编译能跑”往“会定位问题、能改复杂 bug”前进,花点时间把进程管理、GCC 参数和 GDB 调试这三块吃透,收益会非常明显。
