导读
线上进程崩溃后,最让人头疼的一种现场是:没有业务错误码,没有完整调用栈,系统日志里只剩下一行 segfault。
例如,Nginx HTTPS 代理在一次国密双向认证压测中,Worker 偶发退出,kern.log 留下了两类记录:
nginx[780589]: segfault at 28 ip 0000555c3f496487 sp ... error 6 in nginx[...]
nginx[...]: segfault at 50 ip 0000000000477073 sp ... error 4 in nginx[...]日志看起来只有一串地址,但已经足够回答三个关键问题:
- 程序访问了什么地址?
- 哪条指令触发了崩溃?
- 最终访问的是哪个空指针字段?
本文只讲清这条定位路径:如何从 segfault at 28 出发,结合 ip、addr2line、ASan 和反汇编,把一行内核日志映射到具体源码语句。
本文现场来自特定 TLCP/GMVPN 双证书环境,但日志分析方法同样适用于其他 Linux 原生进程的空指针崩溃。
segfault at 28 不是代码地址
先拆解这行日志:
nginx[780589]: segfault at 28 ip 0000555c3f496487 sp ... error 6 in nginx[...]其中几个字段最重要:
nginx[780589]:崩溃进程及 PID;segfault at 28:程序试图访问非法地址0x28;ip:CPU 触发崩溃时正在执行的指令地址;sp:当时的栈指针;error:页错误类型;in nginx[...]:发生崩溃的二进制及其映射信息。
最容易犯的错误,是拿 0x28 去执行 addr2line。
segfault at 28 不是代码执行位置,而是程序尝试读写的目标地址。真正用于映射源码的是 ip。
0x28、0x50 这种非常小的地址也不是随机噪声,它们通常指向一种典型模式:
NULL + 结构体字段偏移假设有下面的访问:
obj->field = NULL;如果 obj == NULL,而 field 在结构体中的偏移是 0x28,CPU 实际访问的地址就是:
0x0 + 0x28 = 0x28因此,在还没有查看源码之前,segfault at 28 已经给出了第一层判断:程序很可能正在访问空指针对象中偏移为 0x28 的成员。
在本次 x86_64 现场中,error 6 对应写访问失败,error 4 对应读访问失败。这也为后面的指令分析提供了方向。
真正该追的是 ip
如果二进制没有开启 PIE,可以直接把 ip 交给 addr2line:
addr2line -f -C -e /path/to/nginx 0x477073如果启用了 PIE/ASLR,则需要根据运行时指令地址和二进制映射基址计算相对偏移,再进行映射。
这里有一个硬要求:
必须使用崩溃现场实际运行的 Nginx 二进制和对应调试符号。
只要二进制重新编译过,即使源码改动很小,函数地址也可能发生变化。
本次映射结果落在 OCSP 回调处理函数:
ngx_ssl_ocsp_handler
src/event/ngx_event_openssl_stapling.c:1605对应代码可以简化为:
c = ctx->data;
ocsp = c->ssl->ocsp;
ocsp->ctx = NULL;结合前面的非法地址,可以提出两个待验证的判断:
segfault at 0x28:可能是ocsp == NULL,执行ocsp->ctx = NULL时写入了NULL + 0x28;segfault at 0x50:可能是c->ssl == NULL,读取c->ssl->ocsp时访问了NULL + 0x50。
注意,这里还只是根据地址和源码做出的推断。要把“大概率”变成确定结论,还需要验证寄存器值、机器指令和字段偏移。
ASan 与反汇编把证据闭合
本机复现时,ASan 给出了更直接的报告:
runtime error: member access within null pointer of type 'struct ngx_ssl_ocsp_t'
ERROR: AddressSanitizer: SEGV on unknown address 0x000000000028
The signal is caused by a WRITE memory access.
#0 ngx_ssl_ocsp_handler src/event/ngx_event_openssl_stapling.c:1605
rax = 0x0000000000000000这几行信息说明:
- 崩溃是一次写访问;
- 写入目标是零页附近的
0x28; - 崩溃发生在
ngx_ssl_ocsp_handler(); - 执行崩溃指令时,
rax == 0。
对应位置的反汇编如下:
mov 0x50(%rbx), %rax
...
movq $0x0, 0x28(%rax)最后一条指令表示向 rax + 0x28 写入 0。既然当时 rax == 0,实际写入地址就是 0x28。
使用 GDB 检查结构体字段偏移,结果也能对应上:
offsetof(ngx_ssl_ocsp_t, ctx) == 0x28
offsetof(ngx_ssl_connection_s, ocsp) == 0x50至此,证据链已经闭合:
kern.log: segfault at 0x28,写访问
→ addr2line: ngx_ssl_ocsp_handler:1605
→ ASan: rax == 0
→ 反汇编: 向 rax + 0x28 写入
→ GDB: ngx_ssl_ocsp_t.ctx 的偏移正是 0x28
→ 执行 ocsp->ctx = NULL 时,ocsp == NULL这比单纯看到源码后猜测“可能是空指针”更可靠。系统日志、源码行、寄存器、机器指令和结构体布局给出了同一个答案。
从一行 segfault 日志定位空指针的四步法
以后再遇到类似日志,可以按下面的顺序排查。
1. 区分访问地址和指令地址
segfault at是程序访问的非法地址;ip是触发崩溃的指令地址;- 不要直接拿
segfault at后面的值映射源码。
2. 判断是否符合 NULL + offset
0x10、0x28、0x50 这类小地址,优先检查是否为空指针访问结构体成员。再结合页错误码判断是读失败还是写失败。
3. 使用准确二进制映射源码
保留线上二进制、调试符号、构建版本和加载基址。使用 addr2line 定位函数与源码行,避免拿重新编译的文件分析旧地址。
4. 用运行时证据验证推断
通过 ASan、Core Dump、GDB 或反汇编确认:
- 哪个寄存器为零;
- 机器指令访问了什么偏移;
- 该偏移对应哪个结构体成员;
- 现场是读空指针还是写空指针。
做到这一步,就能从“某个 Worker 崩了”收敛到“某个函数正在访问哪个失效字段”。
延伸阅读
本文是我对一次完整故障复盘中“如何从 segfault 日志定位空指针”部分的专题整理。
如果目标只是从系统日志定位具体空指针,上面的四步方法已经可以独立复用。但源码定位只能回答“崩在哪里”,还不能回答:
为什么 OCSP 异步回调执行时,连接上的状态已经失效?
完整故障复盘继续分析了这个问题,其中包括:
- 同一次 TLCP 握手为什么发出两个 OCSP 请求;
- 同一连接中
fd:12与fd:13的响应乱序; - 迟到回调如何越过它依赖的连接生命周期;
- 如何构造最小环境,稳定复现原本偶发的崩溃。
完整排障记录:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。