第14章:调试实战:版本与崩溃

同一份代码、同一个 CR2,换个编译器版本就从「一打开就崩」变正常——包青天升堂:先对质版本(14.1-14.2),再封存现场(14.4)、让证据可读(14.5)、外层补刀(14.6),正式结掉第 6 章那桩 g++ 悬案

⚖️

本章导师:包青天

核心方法论:铁面无私

「断案不看人,只看证据。第 6 章 6.7 节存档的那桩悬案——同一份代码、同一个 CR2,g++ 4.8.5 编译的程序一执行 open_file 就崩溃,升到 4.9.2 重编就好了——今天正式结案。升堂顺序先定下:先搞懂 ABI 为什么让「版本」成为隐形凶手(14.1),学会编译器、标准库、头文件三方对质(14.2),复盘素材现场并结案(14.3),然后学两件让证据完整的法器:core dump 封存现场(14.4)与 -g -O0 让证据可读(14.5),再用 strace 做外层定位(14.6),最后立一套让案子不再发生的预防规矩(14.7)。铁面无私的第一条:不许凭感觉猜,先取证。」

14.1 ABI 兼容性:为什么换个编译器版本,程序就变了

先把「版本」这个词拆开。我们平时说的 API(Application Programming Interface,应用编程接口)是源码层面的约定:函数叫什么、参数是什么类型,头文件里写得明明白白。但编译器把源码变成二进制之后,二进制之间协作靠的是另一份契约——ABI(Application Binary Interface,应用二进制接口):函数调用时参数怎么传、返回值怎么取(调用约定);结构体、类在内存里按什么顺序排布、每个成员偏移多少(布局);虚函数表(vtable)长什么样;C++ 的符号名怎么被修饰(name mangling);异常怎么传播。关键结论:源码兼容不等于二进制兼容。同一段源码,用不同编译器、甚至同一编译器的不同版本编出来的 .o 和 .so,拼在一起未必能跑——编译期谁也没报错,运行期才爆雷。这就是「版本之坑」的根源。

C++ 是 ABI 最敏感的领域之一,因为标准库类型的内部布局由实现决定:std::stringstd::vector 这些最常见的类型,在不同版本的 libstdc++ 里内存结构可能完全不同。更麻烦的是,libstdc++.so.6 这个 soname 自 GCC 3.4 起就再没变过——动态链接器只看 soname,根本不看 GCC 版本号。版本差异被藏进了符号版本(symbol versioning)里:GLIBCXX_3.4.19 是 GCC 4.8 引入的,GLIBCXX_3.4.20 是 GCC 4.9 引入的(现行 GCC 12.x 已到 GLIBCXX_3.4.30 之后)。所以「换了 g++ 版本」对二进制文件名的世界毫无察觉——同一个文件名,里面装的东西变了,这才是坑能埋这么深的原因。历史上最著名的例子是 GCC 5.1 引入的 _GLIBCXX_USE_CXX11_ABI 双 ABI:std::string 从写时复制(COW)改为短字符串优化(SSO),内部布局彻底改变,旧 ABI 编译的库与新 ABI 编译的程序混用,轻则崩溃重则静默出错。

回到素材案例的背景:那台机器是 CentOS 7,自带的 g++ 是 4.8.5(GCC 4.8 系列是第一个宣称完整支持 C++11 的版本,但实现缺陷不少,4.9 修复了一大批)。第 6 章 6.4 节那条 configure 命令 CXX=/usr/local/bin/g++ 说明机器上还装了非系统默认的编译器——多个 g++ 并存,正是工具链混用的土壤。同一个 libstdc++.so.6 文件,库和程序却可能是不同编译器编的,内部的 C++11 对象实现细节对不上,内存被悄悄写坏,直到某一步深挖数据时当场崩掉。4.8.5 崩、4.9.2 好,最合理的解释就是这一层:标准库实现缺陷 + 工具链版本不一致。14.3 节正式结案。

# 编译器自报家门:素材环境 CentOS 7 的默认 g++
g++ --version | head -1
# g++ (GCC) 4.8.5
gcc -dumpversion
# 4.8.5

# 换成 4.9.2 之后:
g++ --version | head -1
# g++ (GCC) 4.9.2
# soname 不变,版本藏在符号版本里:问 4.8.5 的标准库认识哪些符号
strings /usr/lib64/libstdc++.so.6 | grep GLIBCXX | tail -5
# GLIBCXX_3.4.17
# GLIBCXX_3.4.18
# GLIBCXX_3.4.19          (GCC 4.8 引入)  <- 4.8 的库到这里为止

# 4.9.2 的标准库:多出 3.4.20
strings /usr/local/lib64/libstdc++.so.6 | grep GLIBCXX | tail -3
# GLIBCXX_3.4.19
# GLIBCXX_3.4.20          (GCC 4.9 引入)  <- 4.9 的库才有这一条
ABI 要素是什么版本变化时的风险
调用约定参数与返回值怎么传递(寄存器/栈)新旧编译产物互相调用会错位
类/结构体布局成员在内存中的偏移同一类型两边布局不同,数据被读错位
虚函数表 vtable多态调用的跳转表表结构不同,虚调用崩或调用错函数
名字修饰C++ 符号名编码规则修饰规则不同,链接期直接找不到符号
标准库内部实现std::string / std::vector 的内存结构实现不同,跨版本混用即内存破坏

14.2 版本对质:编译器、标准库、头文件三方会审

包青天办案的第一条规矩:先把「版本」拆成三方,再逐个问话。任何版本类问题,都逃不出这三方:①编译器版本(g++ 是 4.8.5 还是 4.9.2,决定代码生成方式和头文件内容);②C++ 标准库实现(libstdc++.so.6,运行期真正执行对象布局的那份文件);③头文件与库的匹配(编译期用的是新头文件,链接/运行期抓到的却是旧库——「旧库 vs 新头文件」是最高发的一种错位)。三方版本矩阵一列出来,凶手通常就现形了。注意一个反直觉的点:报错发生在哪个阶段,本身就指向嫌疑方——链接期 undefined reference(第 13 章已结案的 fribidi 就是这一宗)多半是符号缺失或链接顺序;运行期 version 'GLIBCXX_3.4.20' not found 是标准库太旧;而运行期直接 Segmentation fault、什么报错都没有,是内存层面已经坏了,多半是 ABI 不匹配或实现缺陷——14.3 节的素材案就是这一种。

三方对质的具体问法,包青天准备了四问,一问一方:问编译器——g++ --version 自报家门;问编译器会链接哪份标准库——g++ -print-file-name=libstdc++.so.6(这条最容易被忽略:你以为链接的是系统标准库,编译器却可能带着自己的);问标准库认识哪些符号版本——strings ... | grep GLIBCXX(14.1 节用过);问程序运行期抓到哪份——ldd 程序 | grep stdc++。四问下来,编译环境的版本矩阵与运行环境的版本矩阵并排一放,任何一列对不上,就是案发点。经典的判决书长这样:程序是用 4.9+ 编的,需要 GLIBCXX_3.4.20,运行机器上的 libstdc++ 只有 3.4.19——「版本太旧,运行环境配不上编译产物」。

# 四问对质:一问编译器版本
g++ --version | head -1
# g++ (GCC) 4.8.5

# 二问:这个编译器会链接哪份标准库(可能不是你想象的那份)
g++ -print-file-name=libstdc++.so.6
# /usr/lib/gcc/x86_64-redhat-linux/4.8.5/libstdc++.so.6

# 三问:这份标准库认识哪些符号版本
strings $(g++ -print-file-name=libstdc++.so.6) | grep GLIBCXX | tail -2
# GLIBCXX_3.4.18
# GLIBCXX_3.4.19

# 四问:程序运行期抓到哪份标准库(与二问对比,看有没有漂移)
ldd ./raw2ppm | grep stdc++
# libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x00007f...)
# 运行期版本报错的经典现场(编译环境 4.9+,运行环境 4.8)
$ ./raw2ppm IMG_0001.CR2
# ./raw2ppm: /usr/lib64/libstdc++.so.6: version `GLIBCXX_3.4.20' not found
#             ^^^^^^^^^^^^^^^^^^^^^^^^ 这一行直接点名:标准库版本太旧

# 验证运行环境的符号版本上限:
strings /usr/lib64/libstdc++.so.6 | grep GLIBCXX | tail -1
# GLIBCXX_3.4.19          <- 缺 3.4.20,实锤
报错形态嫌疑方取证命令
链接期 undefined reference符号缺失 / 链接顺序nm -D、检查 -l(第 13 章已结案)
运行期 version 'GLIBCXX_x.y.z' not found标准库太旧strings libstdc++ | grep GLIBCXX
运行期 Segmentation fault,无任何报错ABI 不匹配 / 实现缺陷core dump + gdb(14.4、14.5 节)
包青天提示

报错本身会说话:先读形态,再定方向。「not found」指向版本,「undefined reference」指向链接,「Segmentation fault」指向内存——三种形态三种取证路线,别一上来就开 gdb 抓段错误,也别一上来就重装库。铁面无私的第一步,是让报错把话说完整。

14.3 案例复盘:LibRaw open_file 崩溃现场(第 6 章悬念结案)

现在正式接手第 6 章 6.7 节存档的悬案。素材原文:「在程序中执行 open_file CR2 的时候程序崩溃,因为自己的 g++ 编译是 4.8.5 的,但是更新到 4.9.2 后就成功了。」把现场还原:同一份代码、同一个 CR2 文件、同样的编译命令,g++ 4.8.5 编出来的程序一执行 open_file 就 Segfault;把编译器升到 4.9.2,库和程序全量重编,同样的代码跑通了。先看涉案 API:LibRaw 的 C++ 接口是 int LibRaw::open_file(const char *fname),按返回码约定工作——返回 0(LIBRAW_SUCCESS)表示成功,出错返回错误码(系统调用出错为正数、LibRaw 内部错误为负数,C API 对应 libraw_open_file)。注意一个关键细节:真正常见的打开失败是「返回一个错误码」,而素材里是「直接段错误」——返回码是程序层面的失败,段错误是内存层面的失败。崩溃发生在比「返回值」深得多的位置,说明问题不在文件、不在参数,而在程序自身的内存已经坏了。

包青天结案陈词。证据链有三环:①第 6 章 6.4 节的 configure 命令 CXX=/usr/local/bin/g++ 证明机器上装了非系统默认的编译器——多个 g++ 并存,工具链混用的土壤已经存在;②GCC 4.8 是第一个宣称完整支持 C++11 的版本,但 libstdc++ 的 C++11 实现缺陷不少,4.9 修复了一大批;③库与程序若由不同版本编译、共享同一个 libstdc++.so.6,内部的类布局与实现细节对不上,内存被悄悄写坏,直到 open_file 深挖数据时当场崩掉。判决:工具链版本不一致,叠加 4.8.x 标准库实现缺陷。处方:统一编译器版本,库与程序全量重编——这正是素材做的「更新到 4.9.2 后就成功了」。诚实说明:由于当时没有留下调试信息,具体崩在哪一行已经不可考——这正是 14.4、14.5 两节的意义:把「下次」的证据留足,让结案陈词有据可依,而不是靠推测。

// raw2ppm.cpp —— 素材程序的简化骨架(LibRaw C++ API,现行版)
// 打开 CR2 后读基本信息;真正的崩溃发生在 open_file 内部
int main(int argc, char **argv) {
    LibRaw raw;
    int ret = raw.open_file(argv[1]);   // 打开 CR2,返回码约定
    if (ret != LIBRAW_SUCCESS) {
        fprintf(stderr, "open_file failed: %d\n", ret);
        return 1;
    }
    printf("make=%s model=%s\n",
           raw.imgdata.idata.make,
           raw.imgdata.idata.model);
    return 0;
}

# 编译链接(pkg-config 统一入口,第 13 章讲过的姿势)
g++ -o raw2ppm raw2ppm.cpp $(pkg-config --cflags --libs libraw)
# 案发现场(素材原文,2020,CentOS 7 / g++ 4.8.5):
$ ./raw2ppm IMG_0001.CR2
# Segmentation fault (core dumped)   <- open_file 阶段直接崩,没有返回码

# 素材的解法:编译器 4.8.5 升到 4.9.2,库与程序全量重编
g++ --version | head -1
# g++ (GCC) 4.9.2

# 重编 LibRaw(第 6 章同款 configure,CXX 指到新编译器)
./configure --prefix=/usr/local/helios CXX=/usr/local/bin/g++
make && make install

# 重编程序,同样的代码:
g++ -o raw2ppm raw2ppm.cpp $(pkg-config --cflags --libs libraw)
$ ./raw2ppm IMG_0001.CR2
# make=Canon model=Canon EOS R5 size=6000x4000   <- 一切正常,结案
包青天提示

把「返回错误码」和「直接崩溃」当成两种完全不同的病:前者是程序还活着、按约定报告失败;后者是程序已经死了、连报告的机会都没有。遇到后者,别急着改代码——先问版本(14.2 四问),再封存现场(14.4),证据齐了再判。

14.4 core dump 分析:把崩溃现场完整封存

core dump(核心转储)是操作系统在程序崩溃时,把进程的完整内存镜像——连同寄存器、调用栈、信号信息——写成一个文件。有了它,即使当时没有开着调试器,也能事后完整复盘:gdb 程序 core文件 加载现场,bt 命令打印崩溃瞬间的调用栈,和当场抓住一模一样。前提有两个:core 生成开关打开(ulimit -c unlimited,默认可能是 0 即不生成),以及磁盘有空间。传统环境下 core 文件落在当前目录(名字是 corecore.<pid>);现代 systemd 发行版常常把 core 交给 systemd-coredump 托管,用 coredumpctl list 查看、coredumpctl gdb 直接进调试器——机制不同,证据一样。

分析动作就三板斧:bt(backtrace,回溯调用栈)——从崩溃点开始,一路列出每一层调用者,这是全场最值钱的一行输出;frame N——切换到第 N 帧,看那层的局部变量;print / info registers——打印变量值与寄存器现场。回到素材案:如果当时开了 core,就能看到崩溃帧在 LibRaw::open_file 内部,向上追到 main 的调用行——「崩在库内部、调用方毫不知情」本身就是 ABI 问题的典型特征(自己代码写错通常崩在自己的帧里)。注意一个细节:没有 -g 编译的程序,bt 里帧 0 往往显示 ?? ()——函数符号在,行号没有,这正是 14.5 节要解决的问题:调试信息决定证据的完整度。

# 第一步:打开 core 生成开关(默认可能是 0 = 不生成)
ulimit -c unlimited

# 第二步:复现崩溃,现场被自动封存
$ ./raw2ppm IMG_0001.CR2
# Segmentation fault (core dumped)

# 第三步:找到 core 文件(传统环境落在当前目录)
ls -lh core*
# -rw------- 1 root root 2.1M core.12345

# 现代 systemd 发行版:core 被 systemd-coredump 接管
coredumpctl list | tail -3
# Tue ... 12:34:01 CST 2.1M signal SIGSEGV /usr/local/bin/raw2ppm
# 第四步:gdb 加载 core 文件复盘——不用重新复现,现场就在文件里
gdb -q ./raw2ppm core.12345
# Core was generated by './raw2ppm IMG_0001.CR2'.
# Program terminated with signal SIGSEGV, Segmentation fault.
# (gdb) bt
# (gdb) 下面是没有 -g 时的典型输出:行号缺失、帧 0 是 ??
(gdb) bt
# #0  0x00007f2a4c1a34e5 in ?? ()
# #1  0x00007f2a4c2031c0 in LibRaw::open_file(char const*) ()
# #2  0x0000000000400f12 in main () at raw2ppm.cpp:9

# 切换帧看调用者现场,寄存器也能查:
(gdb) frame 2
(gdb) info registers rip
命令作用典型输出
bt回溯调用栈,从崩溃点列出所有调用帧#0 崩溃帧 / #1 调用者 / #2 上层…
frame N切换到第 N 帧显示该帧的行号与函数
print 变量打印变量当前值$1 = 0x0(空指针铁证)
info registers查看寄存器现场rip / rsp 等关键寄存器

14.5 编译选项:-g 与 -O0,让证据可读

14.4 节留下的问题:为什么 bt 里没有行号、帧 0 是 ?? ()?因为编译时没带 -g-g 让编译器把调试信息(DWARF:行号、变量名、类型信息)写进二进制。没有 -g,gdb 还能靠符号表显示函数名,但行号、变量、源码一概没有——证据残缺到只能看出「崩在这个函数里」。发行版把调试信息单独拆成 debuginfo 包、生产二进制发布前用 strip 剥离调试信息,都是同一个原理的两种用法:调试信息是「证据」,平时可以收起来,破案时必须能拿出来。所以标准取证动作是:出问题,先 -g 重编一次,让证据齐全。

第二个开关是 -O0-O0 关闭优化;优化器(-O1/-O2/-O3)会重排指令、合并变量、内联函数,导致「源码行号与实际执行的机器码对不上」,变量被优化没——gdb 里 print 一个变量,回答是 <optimized out>(被优化掉了)。调试期用 -O0,行号与变量一一对应,单步执行如丝般顺滑;发布期用 -O2 是惯例。两者不冲突:-g 只增加文件体积、不拖慢运行,所以「发布二进制也保留 -g」(或保留一份带符号的副本)是常见做法;-O0 则只在调试构建里用。一句话:-g 保证「有证据」,-O0 保证「证据可信」。素材案如果当时用 -g -O0 编译,core 里的 bt 就能精确到崩溃那一行,结案陈词就不用靠推测了。

# 不带 -g 编译:gdb 有函数名,但行号与变量全缺
g++ -O2 -o raw2ppm raw2ppm.cpp $(pkg-config --cflags --libs libraw)
gdb -q ./raw2ppm core.12345
(gdb) bt
# #0  0x00007f2a4c1a34e5 in ?? ()        <- 没有行号,证据残缺

# 带 -g -O0 重编:每一行、每一个变量都可读
g++ -g -O0 -o raw2ppm raw2ppm.cpp $(pkg-config --cflags --libs libraw)
gdb -q ./raw2ppm core.12345
(gdb) bt
# #0  0x00007f2a4c1a34e5 in LibRaw::open_file (...) at libraw_cxx.cpp:2041
# #1  0x0000000000400f12 in main () at raw2ppm.cpp:9
(gdb) list
# 源码逐行显示——证据齐全,可以判案了
# -O2 的陷阱:变量被优化没了,print 等于白问
(gdb) print ret
# $1 = <optimized out>   <- 优化器认为它没用了,直接消除

# -O0 下同样的 print:值清清楚楚
(gdb) print ret
# $1 = -106   <- open_file 的错误码,程序确实走到了返回码检查

# 需要更多调试信息时用 -g3(含宏定义等),发布前可 strip 剥离:
g++ -g3 -O0 -o raw2ppm raw2ppm.cpp $(pkg-config --cflags --libs libraw)
strip raw2ppm   # 发布时剥掉调试信息,文件更小
包青天提示

记住这对组合拳:-g -O0 是调试构建的默认姿势,「出问题先重编一次带 -g -O0 的版本」应该成为肌肉记忆。没有调试信息的 core 文件,就像没有指纹的凶案现场——不是不能查,而是能查的线索少了一大半。

14.6 strace 初步定位:从系统调用看程序走到哪一步

gdb 是「内窥镜」,从代码内部看;strace 是「外围监控」,从内核边界看——它跟踪进程发出的每一个系统调用(打开文件、读取、内存映射、网络…),打印每次调用的参数与返回值。调试时的定位价值在于:程序崩溃或挂起时,最后成功的那批系统调用告诉你「程序死前走到哪一步」。比如素材案:strace 输出里 open("IMG_0001.CR2") 成功返回文件描述符 3,接着 fstat、mmap、read 都正常,然后突然一行 --- SIGSEGV ---——这说明文件打开、读取都没问题,崩溃发生在之后的处理阶段,配合 14.2 的版本对质,方向立刻收窄。常用参数:-o 文件 把输出写文件(strace 输出很大,直接打屏会淹没现场);-f 跟随子进程(多进程程序必带);-e trace=open,openat,read 只跟踪关心的调用类型。

怎么读 strace 输出?三句话。第一,看 ENOENTopen("xxx", ...) = -1 ENOENT 表示文件找不到——这是「启动即退」类问题的最常见凶手,缺库缺配置文件都长这样。第二,看崩溃信号行--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0x8} ---,si_addr 是个低地址(如 0x8)说明在空指针附近解引用,高地址则更像越界写。第三,看最后成功调用与崩溃之间的缝隙:程序死在哪一类操作之后,问题就藏在那一段。strace 与 gdb 的分工:strace 先回答「哪类操作出的问题」(外层),gdb 再回答「哪一行代码出的问题」(内层)。注意一个副作用:strace 会显著拖慢程序(每次系统调用都要拦截),改变时序——它是初步定位工具,不是日常运行方式;素材案里 strace 的价值就是快速区分「文件层」与「内存层」的崩溃。

# 基本用法:跟踪全部系统调用,输出写文件(避免淹没屏幕)
strace -o trace.log ./raw2ppm IMG_0001.CR2
# Segmentation fault (core dumped)

# 崩溃程序死前最后一批调用:
tail -8 trace.log
# open("IMG_0001.CR2", O_RDONLY)                = 3   <- 文件打开成功
# fstat(3, {st_mode=S_IFREG|0644, st_size=35285760, ...}) = 0
# mmap(NULL, 35287040, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f2a4c000000
# read(3, "II*\u0000\u0010\u0000...", 65536)      = 65536   <- 头 64KB 读到了
# --- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0x8} ---
# +++ killed by SIGSEGV +++
# 读法:文件层全正常,崩溃在之后的解析阶段——嫌疑指向程序自身
# 只跟踪关心的调用类型,输出更干净:
strace -f -e trace=open,openat,read ./raw2ppm IMG_0001.CR2

# 另一种经典现场:文件找不到(启动即退类问题)
strace -o trace.log ./raw2ppm missing.CR2
# open("missing.CR2", O_RDONLY) = -1 ENOENT (No such file or directory)
# read(...)                     = -1 EBADF  <- 拿着无效 fd 继续读,立刻崩

# 对比:失败是「干净返回错误码」还是「直接 SIGSEGV」
# 前者 = 程序活着按约定报告;后者 = 内存层已坏(回到 14.2 的判读)

14.7 铁面无私:调试方法论与预防清单

把本章串一遍,就是包青天的办案五步:①先取证不猜——版本对质四问(14.2),把编译器、标准库、头文件三方的版本矩阵列出来;②读报错形态定方向——not found 指向版本、undefined reference 指向链接、Segmentation fault 指向内存(14.2 表格);③封存现场——core dump 把崩溃瞬间完整留下(14.4);④让证据可读——-g -O0 重编,行号变量齐全(14.5);⑤外层定位补全——strace 看死前最后一批系统调用(14.6)。五步做完,结论自然浮现,而不是靠「我觉得」。铁面无私的核心,就是不预设立场、不看人、只看证据——第 6 章 6.7 节那个「4.8.5 崩、4.9.2 好」的现象,用这套流程走一遍,落点就是「工具链版本不一致 + 实现缺陷」,处方就是统一版本全量重编,和素材的解法完全一致。

最后立预防规矩,让案子根本不发生。①工具链统一:编译器、标准库、头文件同源同版本,库与程序用同一套编译,升级就全量重编——素材案的正解;②版本快照:项目 README 里留一张版本表(g++ 版本、libraw 版本、GLIBCXX 尾号),换环境时三分钟对完;③调试构建带 -g -O0,发布保留符号副本;④开 coreulimit -c unlimited 写进启动脚本,崩溃必留现场;⑤报错先读形态,不盲改。第 6 章的悬念到此正式结案,第 13 章结尾那句「会在那里用 gdb 与 core dump 破案」也兑现了。下一章《性能优化:多线程、内存与缓存》接棒——崩的问题解决了,轮到「慢」的问题:先测后优,诸葛亮来掌舵。

# 版本快照三连:写进项目 README,换环境时三分钟对完
g++ --version | head -1
# g++ (GCC) 4.9.2
pkg-config --modversion libraw
# 0.21.3
strings /usr/lib64/libstdc++.so.6 | grep GLIBCXX | tail -1
# GLIBCXX_3.4.20
# 一键体检:程序运行期抓到哪份标准库(与编译环境对质)
ldd ./raw2ppm | grep stdc++
# libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x00007f...)

# core 开关确认(写进启动脚本,崩溃必留现场):
ulimit -c
# unlimited   <- 输出 unlimited 才算开着

# 调试构建默认姿势(肌肉记忆):
g++ -g -O0 -o raw2ppm raw2ppm.cpp $(pkg-config --cflags --libs libraw)

章末练习

练习 1:对错判断 入门

判断下列说法对错:① ABI 是源码层面的接口约定;② libstdc++.so.6 的 soname 会随 GCC 大版本变化;③ 程序运行报 version 'GLIBCXX_3.4.20' not found,说明运行环境的 libstdc++ 比编译环境的旧;④ 不带 -g 编译的程序,gdb 里也能显示源码行号;⑤ strace 跟踪的是程序的系统调用。

提示

回顾 14.1 节 ABI 的定义与 soname 不变的事实、14.2 节报错形态表、14.5 节 -g 的作用、14.6 节 strace 的原理。

参考答案

① 错——ABI 是二进制层面的契约(调用约定、类布局、符号修饰),API 才是源码层面(14.1 节);② 错——libstdc++.so.6 的 soname 自 GCC 3.4 起就没变过,版本差异藏在 GLIBCXX 符号版本里(14.1 节);③ 对——GLIBCXX_3.4.20 是 GCC 4.9 引入的,运行环境的库只有 3.4.19 及以前,说明它比编译环境旧(14.2 节);④ 错——没有 -g 就没有调试信息,gdb 只有函数名没有行号(14.5 节);⑤ 对——strace 跟踪每次系统调用的参数与返回值(14.6 节)。

练习 2:读版本报错 进阶

你在一台 CentOS 7 服务器上运行程序,报错:./app: /usr/lib64/libstdc++.so.6: version 'GLIBCXX_3.4.20' not found (required by ./app)。请回答:① 这个错误发生在哪个阶段?② 编译环境和运行环境的 libstdc++ 各是什么水平?③ 给出两条解决路径,并说明各自适用场景。

提示

「not found」是运行期动态链接器报的;GLIBCXX_3.4.20 对应 GCC 4.9;解法一升级运行环境,解法二降级编译环境——对应 14.2 节的三方对质与 14.3 节的「统一版本」处方。

参考答案

① 运行期——程序启动时动态链接器加载 libstdc++.so.6,发现需要的符号版本不存在,直接拒绝启动(14.2 节);② 编译环境的标准库至少到 GLIBCXX_3.4.20(GCC 4.9+),运行环境的库止于 3.4.19(GCC 4.8,CentOS 7 默认)——编译比运行新,两边对不上;③ 路径一:升级运行环境的编译器/标准库(重装新版 gcc 或系统升级),适合「运行环境可控」的场景;路径二:用运行环境同款的旧编译器(如 4.8.5)全量重编程序,适合「生产环境不能动」的场景——两条路殊途同归,都是让编译与运行两边的版本矩阵对齐(14.2、14.3 节)。

练习 3:core dump 取证流程 进阶

服务器上的图像处理程序每天凌晨崩溃一次,日志只有一行 Segmentation fault (core dumped),没有更多线索。按包青天的方法设计完整取证流程:① 先要确认哪两个前提?② 复现后依次执行哪些命令、看什么?③ 如果 bt 里行号全是问号,下一步该怎么办?

提示

前提对应 14.4 节 core 生成的两个条件;分析命令是 gdb + bt + frame + print;行号缺失对应 14.5 节的 -g——答案覆盖 ulimit、core 文件定位、coredumpctl、-g -O0 重编。

参考答案

① 两个前提:core 生成开关打开(ulimit -c unlimited,确认输出 unlimited)与磁盘有空间;还要确认 core 落在哪里——传统环境在当前目录(core 或 core.<pid>),systemd 发行版用 coredumpctl list 查(14.4 节);② 复现后执行 gdb -q 程序 core文件 加载现场,先 bt 看调用栈:崩溃帧在哪一层(自己代码还是库内部)、向上追到 main 的调用行;再用 frame N 切帧、print 变量 看关键值、info registers 看寄存器(14.4 节);③ 行号全是问号 = 没带 -g,用 -g -O0 重编后再复现一次取新 core,bt 就能精确到行;同时用 strace -o 看死前最后一批系统调用,区分是文件层还是内存层的问题(14.5、14.6 节)。

练习 4:跨机部署排错 挑战

场景:你在一台机器上编译好了一个用 LibRaw 读 CR2 的程序,拷到另一台服务器上,一运行就 Segmentation fault,在你自己的机器上却一切正常。两台机器都是 CentOS 7。按铁面无私的方法论,设计完整的排查步骤:① 第一件事查什么(为什么「我的机器正常」不能作为证据)?② 依次列出取证命令与判读标准;③ 如果确认是版本不一致,修复时要注意什么才能避免「修好这台、弄坏那台」?

提示

「我的机器正常」正是 14.1 节说的源码兼容不等于二进制兼容;先做 14.2 节四问对质两边的版本矩阵;取证按 14.4-14.6;修复记住 14.3 节「统一版本、全量重编」与 14.7 节预防清单。

参考答案

① 第一件事不是看代码,而是对质两边的版本矩阵:g++ --versiong++ -print-file-name=libstdc++.so.6strings ... | grep GLIBCXX | tail -1ldd app | grep stdc++——「我的机器正常」只能证明「我的机器上」正常,二进制兼容性由两边 ABI 是否一致决定,与源码无关(14.1、14.2 节);② 接着封存现场:ulimit -c unlimited 复现 → gdb -q app corebt 看崩溃帧(在库内部 = ABI 问题特征,在自己代码 = 逻辑问题);必要时 strace -o 看死前系统调用,区分文件层与内存层(14.4、14.6 节);③ 修复 = 统一版本全量重编:在服务器上用与编译机相同的编译器版本重编库与程序,或直接把编译环境整体搬到服务器;注意用 14.7 节的版本快照(README 版本表)保证两边一致,且只动目标机器的工具链、别顺手升级无关组件——「统一版本」的意思是三方的版本矩阵对齐,不是盲目升级到最新(14.3、14.7 节)。