☰
二进制瘦身与调试还原:strip、objcopy、eu-strip 完全指南
2026/10/1 11:47:27 网站建设 项目流程

前段时间组里做发布瘦身,一个带调试信息编译的 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 更规范。

工具归属强项典型场景
stripbinutils符号剥离、体积瘦身发布前原地去符号
objcopybinutils按 section 精细搬运生成/挂接 debuglink
eu-stripelfutils一步完成分离与剥离发行版 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 myapp

eu-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 加载一个剥离后的二进制时,会按顺序找调试信息:

  1. .gnu_debuglink 指定的文件:先找二进制同目录,再找同目录下的.debug/子目录,最后找 debug-file-directory(通常是/usr/lib/debug)。
  2. build-id:gdb 读二进制里的 .note.gnu.build-id(readelf -n查看),然后去/usr/lib/debug/.build-id/下按ab/cdef...debug的路径找。这个路径规则几乎是发行版统一标准。
  3. 手动指定路径:set debug-file-directory /path或symbol-file。

所以你在排查线上问题时,只需要把对应的 myapp.debug 放到 myapp 旁边,或者丢到 gdb 能找到的目录,然后正常启动:

gdb ./myapp core

gdb 会打印 "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.debug

symbol-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 高频翻车点与应对

  1. 在原件上直接 strip,没有备份。这是所有坑里最疼的一个。strip 默认原地写,strip 完想再找回调试信息几乎不可能。我现在所有脚本都是先 cp 一个.full保留,再对副本操作。

  2. 共享库剥离后 dlopen 正常,但排查工具看不到内部函数。.dynsym 里只有导出符号,内部静态函数的符号在 .symtab 里,strip 之后dladdr、perf 都只能看到模块名看不到函数名。处理办法:如果还需要内部符号用于 profiling 或 backtrace,编译共享库时加-rdynamic或用导出符号白名单,别把希望寄托在 .symtab 上。

  3. 对 .o / .a 用strip -s。静态链接时链接器需要 .symtab 里的重定位符号,你把 .o/.a 剥干净,最后链接直接报 undefined reference 或无法重定位。静态库和中间目标文件不属于"发布瘦身"范畴,别顺手一刀。

  4. 为了"极致瘦身"删 .eh_frame。程序能跑,但一抛 C++ 异常、一打 backtrace 就露馅。别省这几百 KB,不值得。

  5. gdb 说找不到调试文件,但 .gnu_debuglink 明明写了。检查三个点:文件名是否一致(objcopy 写入的是短文件名,别把路径带进去);CRC 是否匹配(debug 文件必须来自同一次构建);debug-file-directory 路径是否对。

  6. build-id 和 debuglink 两套机制混用。如果二进制里没有 .note.gnu.build-id(老工具链编的),gdb 只能靠 debuglink 名字匹配。反过来,如果用 build-id 目录放 debug 文件,就不要再费劲改它的文件名,目录结构已经编码了 ID。

  7. 剥离后nm看不到符号,不代表readelf -s也看不到。判断一个文件是否被完全剥离,用file命令看 "stripped" 标记,或者readelf -S看有没有 .symtab。很多人拿nm结果直接说"没符号了",忽略了 .dynsym 里其实还有导出符号。

  8. 调试文件版本管理不严格。同一个应用每天构建几十次,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 文件归档进符号库,已经成了肌肉记忆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询