前段时间组里做发布瘦身,一个带调试信息编译的 C++ 服务从 40MB 压到 1.2MB,靠的就是 strip 这一刀。当时图快,脚本里顺手写了个 strip,结果真到线上崩了的那天,gdb 打开 core 全是???,我盯着旁边 objcopy 生成的 .debug 备份文件才回过神来——符号表早被我剥了,调试信息又没挂回去。后来把 eu-strip 也拉进流程,才算把"剥离"和"导回"这两件事彻底理顺。
这篇就把我整理过的完整玩法写出来。主题一句话:用 strip、eu-strip、objcopy 这几个工具,把二进制里的符号表和调试信息安全地剥掉,又能在出问题时原样导回来调试。不管是做 Linux 服务发布、容器镜像瘦身、嵌入式根文件系统裁剪,还是想在给客户交付二进制的同时内部保留可调试能力,这篇都适用。全程用真实命令和数据说话,遇到坑也会明说。
1. 剥离之前先搞清楚:你丢掉的符号表和调试信息,到底差在哪
用 strip 之前,我一直以为"符号表"和"调试信息"是一回事,反正都是那些函数名、变量名。后来吃了一次亏才明白,这俩在 ELF 文件里是完全不同的两批数据,剥离时处理策略也完全不同。
1.1 ELF 里那些容易被一刀切的 Section
一个用-g编译出来的 ELF,里面除了 .text(代码)、.data(已初始化数据)、.bss(未初始化数据)这些正经段,还躺着几类"看着没用但关键时刻要命"的东西:
- .symtab 和 .strtab:这就是狭义上的符号表,记录函数名、全局变量名、它们的地址和类型。
nm读的是它,gdb 里info functions靠的也是它。它在编译链接阶段起主要作用,运行时除了动态链接器要用的那部分,基本不参与执行。 - .debug_系列*:DWARF 调试信息,包括 .debug_info(类型和变量信息)、.debug_line(源码行号表)、.debug_abbrev、.debug_str、.debug_loc、.debug_ranges 等等。gdb 里能
print局部变量、能list源码、能单步,全靠这批数据。它和 .symtab 是两套体系,strip -g只删 .debug_*,不会动 .symtab;strip默认的完整剥离才两个一起删。 - .comment、.note.GNU-stack、.note.gnu.build-id:编译器和工具链留下的元信息,体积很小,多数情况下可以留着。
你可以用readelf -S自己看一眼。一个简单的 hello world 用gcc -g编译,debug section 的数量通常在 10 个以上,体积甚至比 .text 还大几倍。这就是为什么"带调试信息的二进制"动辄几十兆,而实际代码可能只有几百 KB。
1.2 一定要留下的部分:动态符号表、异常展开表和其他
新手最容易踩的认知坑,是觉得 strip 就是把符号全删掉,那动态链接库怎么办?
实际上,动态链接的可执行文件和共享库都有两张符号表。一张是 .symtab,链接时用的全量符号表,运行时用不上,可以放心删;另一张是 .dynsym 和 .dynstr,动态链接器解析 PLT/GOT、做符号重定位靠的就是它,这张表不能删。GNU strip 默认的--strip-all知道这个道理,它会保留 .dynsym/.dynstr,所以 strip 完动态链接的程序,ldd 和 dlopen 基本不受影响。
另外一个必须留的是 .eh_frame / .eh_frame_hdr。这是 C++ 异常处理和栈回溯(unwind)的表。有些"极致瘦身"方案会把它也删掉,删完程序可能还能跑,但一旦抛异常、打 backtrace,或者 gdb 里看调用栈,就会得到一堆空帧。我的建议是:发布版可以删 debug、删 symtab,但 .eh_frame 永远别动。
还有一个容易忽略的:如果你用了backtrace()、dladdr()这类运行时 API,它们读的是 .dynsym 而不是 .symtab。所以 strip 之后dladdr还能返回模块路径,但函数名基本就丢了,除非你编译时加-rdynamic把符号导到动态符号表里。这个点很多人查崩溃时才意识到。
2. 三把剪刀的脾气:strip、objcopy、eu-strip 各自适合哪种活
能对 ELF 动刀的工具不止一个,但真正干这活的就这三个:binutils 家的 strip 和 objcopy,elfutils 家的 eu-strip。它们定位不同,不要只看名字就随便用。
2.1 strip:日常瘦身的主力,选项看着简单但门道很深
strip 属于 GNU binutils,几乎所有 Linux 发行版都自带。默认行为是去掉所有非重定位需要的符号,相当于--strip-all和--discard-all的组合。常用参数:
strip -s/--strip-all:去掉所有符号,包括 .symtab,但保留 .dynsym。strip -g/--strip-debug:只删 .debug_* 系列,符号表保留。strip --strip-unneeded:去掉重定位处理不需要的所有符号,连本地符号也清掉,适合共享库。strip -N sym/--strip-symbol=sym:单独删某个符号。strip -K sym/--keep-symbol=sym:保留某个符号,其他照删。strip -o out:不原地改,输出到新文件,配合脚本很好用。
注意一点:strip 不带-o是原地修改。在 CI 脚本里跑之前,一定要保证原件有备份,或者直接对构建产物副本动手。
2.2 objcopy:按 Section 精细操作,调试文件的生成与回挂都靠它
objcopy 和 strip 是同一个娘胎出来的,但 strip 更像是 objcopy 常见功能的封装。objcopy 的优势在于可以按 section 级别做精确控制,比如:
objcopy --only-keep-debug app app.debug:只保留调试相关 section,生成一个"调试侧车文件"。objcopy --add-gnu-debuglink=app.debug app:往发布版二进制的 .gnu_debuglink section 写入文件名和 CRC32 校验值,让 gdb 知道该去哪里找调试文件。objcopy --remove-section=.comment app:删掉指定 section,如果你确实需要做极端裁剪。
很多人不知道的是,--add-gnu-debuglink后的文件查找规则有几条:gdb 会先找同目录下同名文件,再找.debug/子目录,最后找 debug-file-directory。调试文件本身最好是从同一份构建产物生成的,不然 CRC 对不上,gdb 会拒绝加载。
2.3 eu-strip:一条命令完成"抽调试信息 + 剥离",elfutils 生态的加分项
eu-strip 来自 elfutils 包,是红帽系发行版构造 debuginfo 包的标准工具。它最大的价值,是把前面要两步才能做完的事合成一条:
eu-strip -f app.debug app效果等价于:把调试 section 连同符号信息写进 app.debug,再把 app 原地剥离。它还会顺手处理一些 binutils 不怎么管的小问题,比如 section group、压缩 debug 段(配合-gz的 DWARF 5)更友好。
它的配套工具也值得认识:
- eu-unstrip:把剥离后的程序和调试文件重新合并成一个完整带调试信息的程序,相当于"导回符号表"的终极方案。
- eu-readelf / eu-nm:查看 ELF 信息的替代品。
如果团队做的是嵌入式、汽车电子这类需要严格 audit 的交付,eu-strip 配合 build-id 管理,比手工 objcopy 更规范。
| 工具 | 归属 | 强项 | 典型场景 |
|---|---|---|---|
| strip | binutils | 符号剥离、体积瘦身 | 发布前原地去符号 |
| objcopy | binutils | 按 section 精细搬运 | 生成/挂接 debuglink |
| eu-strip | elfutils | 一步完成分离与剥离 | 发行版 debuginfo 打包 |
3. 发布期标准动作:抽调试文件、剥符号表、挂 debuglink 一条龙
现在说正事。一个完整的发布流程,我建议按下面四步走。这个流程解决的核心问题是:发布版要瘦、能跑,但出问题时又能把调试能力"导回"来。
3.1 第一步:先别急着 strip,把调试信息抽出来
假设你刚从 CI 拿到一个带-g的构建产物,叫 myapp,约 38MB。第一件事不是 strip,而是生成调试侧车文件:
cp myapp myapp.full objcopy --only-keep-debug myapp myapp.debug这样 myapp.debug 里就只保留了 .debug_* 系列 section 和必要的 section header。注意:--only-keep-debug生成的文件保留了和原文件一致的地址布局(只有 debug 内容保留实际数据),所以它的地址范围和原文件是对齐的,后续手动加载符号时不会错位。这个细节很重要,别看到它体积小就怀疑它没用。
3.2 第二步:对发布镜像做剥离
接下来对原始 myapp 做剥离。我的习惯是:
strip --strip-debug --strip-unneeded myapp参数怎么选?
--strip-debug只清 .debug_*,符号表还在。如果你想让客户或同事还能用nm看到函数名做问题定位,这一步就够。- 如果连函数名都不想留,直接
strip -s。 --strip-unneeded会顺手把本地符号和重定位不需要的符号全部清掉。对可执行文件,它和-s差别不大;但如果你处理的是共享库,我更推荐它,因为行为更保守,会把重定位过程需要的符号完整保留。
如果你还想再压一层,可以加-X/--discard-locals去掉以.L开头的局部符号,或者干脆在链接期用-s让 ld 直接不生成。但我个人不推荐在链接期剥:一是链接期就做,后面想生成调试文件就麻烦了;二是构建产物保持完整,剥离放到发布阶段统一管理,出问题还能回头。
3.3 第三步:把调试文件挂回去
剥离完成之后,把调试文件挂到发布版二进制的 .gnu_debuglink 上:
objcopy --add-gnu-debuglink=myapp.debug myapp这一步会在 myapp 里新增一个 .gnu_debuglink section,里面存着调试文件名(myapp.debug)和 CRC32 值。之后把这个剥离版 myapp 和 myapp.debug 一起放进发布目录,或者把 myapp.debug 单独归档到内部符号库,都可以。
验证一下:
file myapp # 应该显示 stripped readelf -x .gnu_debuglink myapp # 能看到文件名和 CRC nm myapp | head # 应该输出 no symbols ls -lh myapp myapp.debug我实际跑过的一个对比:一个带界面的程序,-g编译 38MB,剥离后 2.1MB,debug 文件 36MB。发布包从 38MB 降到 2.1MB,而调试能力一点没丢,这就是这套流程的价值。
3.4 用 eu-strip 简化流程
如果你装了 elfutils,这套动作可以压缩成:
cp myapp myapp.full eu-strip -f myapp.debug myappeu-strip -f 会自动把 debug 信息写进 myapp.debug,并把 myapp 原地剥干净。但有一个坑:eu-strip 常见版本不会自动写 .gnu_debuglink。所以发布目录里还是要手动补一步objcopy --add-gnu-debuglink,或者干脆走 build-id 路线。很多发行版的做法是用 eu-strip 生成 myapp.debug,然后放到/usr/lib/debug/.build-id/xx/xxxxxxxx.debug,靠 build-id 匹配。这种方式在二进制改名、挪位置之后依然有效,比 debuglink 只靠名字匹配更稳。
4. 出事后导回符号表:gdb 匹配机制与手动静默加载
剥离只是手段,能导回才是目的。下面把"导回"的几种方式都讲清楚。
4.1 gdb 找调试文件的三种途径
gdb 加载一个剥离后的二进制时,会按顺序找调试信息:
- .gnu_debuglink 指定的文件:先找二进制同目录,再找同目录下的
.debug/子目录,最后找 debug-file-directory(通常是/usr/lib/debug)。 - build-id:gdb 读二进制里的 .note.gnu.build-id(
readelf -n查看),然后去/usr/lib/debug/.build-id/下按ab/cdef...debug的路径找。这个路径规则几乎是发行版统一标准。 - 手动指定路径:
set debug-file-directory /path或symbol-file。
所以你在排查线上问题时,只需要把对应的 myapp.debug 放到 myapp 旁边,或者丢到 gdb 能找到的目录,然后正常启动:
gdb ./myapp coregdb 会打印 "Reading symbols from ./myapp... Reading symbols from ./myapp.debug..."。看到这句,说明 debuglink 生效了。之后 backtrace、print 局部变量、info locals 全部可用,和没 strip 之前几乎一样。
有一点差别要提醒:剥离过的二进制在 gdb 里能list源码,前提是 debug 文件里有足够的行号信息,而--only-keep-debug生成的文件保留了所有 DWARF 数据,所以没问题。如果只用了strip -g而不是整套流程,那 debug 文件可能只有 symtab 没有行号,调试体验会打折。
4.2 手动加载与原位还原:symbol-file 与 eu-unstrip
有些场景下你手头只有剥离版二进制和调试文件,但二进制里没写 .gnu_debuglink(比如 eu-strip 生成的),或者你不想改发布文件,可以用手动方式:
(gdb) file ./myapp (gdb) symbol-file ./myapp.debugsymbol-file命令会把 myapp.debug 里的符号表"附加"到当前程序上。只要地址布局一致(--only-keep-debug保证了这个前提),gdb 就能正常解析。
如果你想要的是一个完整的、带调试信息的二进制,比如要发给同事直接断点调试,用 eu-unstrip:
eu-unstrip myapp myapp.debug -o myapp.full执行完,myapp.full 就是把剥离版和调试文件合并还原的完整程序,.symtab、.debug_* 全会回来。对"导回符号表及调试信息"这个需求来说,这是终极解法。
4.3 用剥离后的二进制 + sidecar 调试文件排查 core dump
再说一个真实工作流。线上服务崩了,现场通常只有剥离版二进制和 core。你把 core 拷回开发机,再带上同版本的 myapp.debug,这样操作:
gdb ./myapp ./core (gdb) thread apply all bt只要 debug 文件版本和二进制一致,崩栈上的函数名、参数、局部变量全都能看。如果版本对不上,gdb 会提示找不到调试信息或者直接给出 warning,这时候别硬调,回去确认构建号和 build-id。我见过太多人在这上面浪费一整天:core 是旧的,二进制是新的,符号全错位,怎么看怎么诡异。
如果临时没有 gdb 环境,还有个快速手段:addr2line 配合 debug 文件就能把地址翻译成文件行号。
addr2line -Cf -e myapp.debug 0x401234这个命令在分析 perf、backtrace 日志里的十六进制地址时尤其好使,不用拉起整个 gdb。
5. 实测数据与避坑清单:这一路我踩过的和替你踩过的
最后把这几年折腾出来的经验完整倒一遍。先看一组真实数据(同一份 C++ 服务):
| 处理阶段 | 体积 |
|---|---|
原始-g3构建 | 38MB |
strip -s后 | 1.2MB |
| myapp.debug 侧车文件 | 36.5MB |
| 发布包(归档后,debug 入库) | 1.2MB |
瘦身效果是实打实的,容器镜像、升级包、嵌入式 flash 都受益。但工具越好用,翻车的地方越多。
5.1 高频翻车点与应对
在原件上直接 strip,没有备份。这是所有坑里最疼的一个。strip 默认原地写,strip 完想再找回调试信息几乎不可能。我现在所有脚本都是先 cp 一个
.full保留,再对副本操作。共享库剥离后 dlopen 正常,但排查工具看不到内部函数。.dynsym 里只有导出符号,内部静态函数的符号在 .symtab 里,strip 之后
dladdr、perf 都只能看到模块名看不到函数名。处理办法:如果还需要内部符号用于 profiling 或 backtrace,编译共享库时加-rdynamic或用导出符号白名单,别把希望寄托在 .symtab 上。对 .o / .a 用
strip -s。静态链接时链接器需要 .symtab 里的重定位符号,你把 .o/.a 剥干净,最后链接直接报 undefined reference 或无法重定位。静态库和中间目标文件不属于"发布瘦身"范畴,别顺手一刀。为了"极致瘦身"删 .eh_frame。程序能跑,但一抛 C++ 异常、一打 backtrace 就露馅。别省这几百 KB,不值得。
gdb 说找不到调试文件,但 .gnu_debuglink 明明写了。检查三个点:文件名是否一致(objcopy 写入的是短文件名,别把路径带进去);CRC 是否匹配(debug 文件必须来自同一次构建);debug-file-directory 路径是否对。
build-id 和 debuglink 两套机制混用。如果二进制里没有 .note.gnu.build-id(老工具链编的),gdb 只能靠 debuglink 名字匹配。反过来,如果用 build-id 目录放 debug 文件,就不要再费劲改它的文件名,目录结构已经编码了 ID。
剥离后
nm看不到符号,不代表readelf -s也看不到。判断一个文件是否被完全剥离,用file命令看 "stripped" 标记,或者readelf -S看有没有 .symtab。很多人拿nm结果直接说"没符号了",忽略了 .dynsym 里其实还有导出符号。调试文件版本管理不严格。同一个应用每天构建几十次,debug 文件和发布二进制必须严格对应。我们的做法是:构建脚本里用 build-id 命名 debug 文件,并打进每次发布的 manifest;出问题时按 manifest 拉对应 debug 文件,而不是从服务器上"随便找一个"。
5.2 一个顺手的小脚本
最后把我常用的发布脚本核心片段放出来,直接抄就能用:
# release.sh 片段 BIN=myapp # 1. 保留完整版本,出问题还能回头 cp "$BIN" "$BIN.full" # 2. 生成侧车调试文件 objcopy --only-keep-debug "$BIN" "$BIN.debug" # 3. 剥离发布版本 strip --strip-debug --strip-unneeded "$BIN" # 4. 挂 debuglink objcopy --add-gnu-debuglink="$BIN.debug" "$BIN" # 5. 记录 build-id 和校验值 readelf -n "$BIN" | grep -i 'build id' sha256sum "$BIN" "$BIN.debug" > "$BIN.sha256"这套脚本我在好几个项目里反复用,唯一改过的就是第 3 步的参数:有的项目要求保留符号给客户做日志定位,就只跑--strip-debug;有的项目完全不希望暴露符号,就用strip -s。没有一个参数是万能的,但流程骨架是通用的。
说到最后,回到开头那个场景。我一直觉得,"把调试信息抽到 sidecar 文件、发布版继续正常运行"这件事,和日志里"一边写文件一边打印显示"的思路是一模一样的——重要信息不能只留在现场,得找地方落盘。剥离版二进制是"打印显示"的那份,debug 侧车文件就是"落盘日志",出事故时拿着它随时能把现场还原出来。习惯了这个思路之后,我每次发布都会顺手把 debug 文件归档进符号库,已经成了肌肉记忆。