先别急着 strip myapp,我先说个真实场景:我接过一个 C++ 服务端的发布包,代码编译出来 480MB,里面就十几个小模块,老板让我“瘦身”,当时手边最快的方式确实是 strip。但后来线上的崩溃日志全部退化成十六进制地址,一个函数名都看不到,当时排查问题差点把头发薅光。从那之后我才真正把符号表、调试信息、strip 命令这三者的关系彻底研究了一遍。
这篇文章就围绕一个核心问题展开:strip 命令到底是如何影响 C++ 可执行文件的。你会看到符号表是什么、调试信息存在哪、strip 具体删掉了什么、删完之后对调试和运行有什么副作用,以及“既想文件小、又想能排查崩溃”的正确做法。不管你是刚入门 C++,还是正在搞发布流程、嵌入式部署、CI/CD 打包,这篇文章都值得花十分钟看完。
1. 可执行文件里到底装了什么:从 ELF 视角拆解符号表和调试信息
很多人在 Windows 上用惯了 PE 格式,刚接触 Linux 下的 C++ 可执行文件会有点懵——ls -lh 看到的是一个文件,但用 readelf 一打开,里面其实是几十个“段”(section)拼起来的。要理解 strip 的作用,第一步就是搞明白这些段都是干什么的。
1.1 可执行文件不是只有代码
先跑一条最常用的命令看看段结构。假设有一个编译好的 C++ 程序 hello,执行:
bash复制readelf -S hello
你会看到类似这样的输出(节选):
code复制 [ 1] .text PROGBITS 0000000000001080 00001080
[ 2] .rodata PROGBITS 0000000000002000 00002000
[ 3] .data PROGBITS 0000000000004000 00002000
[ 4] .bss NOBITS 0000000000004010 00002010
[ 5] .symtab SYMTAB 0000000000000000 00003000
[ 6] .strtab STRTAB 0000000000000000 00004000
[ 7] .debug_info PROGBITS 0000000000000000 00005000
[ 8] .debug_line PROGBITS 0000000000000000 00006000
...
这里面:
.text存放编译后的机器指令,也就是程序真正的逻辑代码。.rodata存放只读数据,比如字符串常量、虚函数表(vtable)、RTTI 类型信息。.data存放已初始化的全局变量。.bss存放未初始化的全局变量,不占实际空间,加载时清零。.symtab就是符号表。.debug_*是 DWARF 调试信息的各个子段。.strtab是符号表中用到的字符串池,所有符号名字都集中存在这里。
简单说,代码段撑起了程序的功能,符号表和调试信息撑起了“可观测性”。没有前者程序跑不起来,没有后者程序也能跑,只是出了问题你看不懂。
1.2 符号表:链接器的“通讯录”
符号表(symbol table)可以理解成一份“通讯录”,记录着每个函数、全局变量、类型信息对应的符号名、地址、大小和属性。链接器在把多个 .o 文件合成可执行文件时,就是靠符号表找到“谁定义了谁、谁引用了谁”。
查看符号表最常用的命令是 nm:
bash复制nm -n hello
输出大概是这个样子:
code复制0000000000001149 T _Z12global_funcv
0000000000001150 T _Z3addii
0000000000001180 t _ZL12static_helperv
0000000000001210 T _Z9max_valueIiET_S0_S0_
0000000000004050 B _ZL4data
注意看第三列和第一列之间的对应关系:第二列的 T/t 表示代码段中的全局/局部符号,B/b 表示 BSS 段的全局/局部变量,U 表示未定义符号,D/d 表示已初始化数据。
这里有个 C++ 和 C 的重大区别:C++ 支持函数重载,所以编译器要对符号名做名字修饰(name mangling)。比如 int add(int, int) 在符号表里实际叫 _Z3addii,template <typename T> T max_value(T, T) 实例化成 int max_value(int, int) 之后叫 _Z9max_valueIiET_S0_S0_。这种修饰后的名字非常长,这也是为什么C++ 可执行文件里的符号表比 C 项目明显更占空间。
用一个符号名反着查,可以确认这一点:
bash复制c++filt _Z3addii
# 输出:int add(int, int)
模板、STL 容器、虚函数这些机制都会让符号表的条目数暴力增长。你哪怕代码只写了 100 行,只要 #include <vector> 再实例化两个 std::vector,符号表里就可能多出几十甚至上百个模板相关符号。
1.3 调试信息:调试器的“GPS 地图”
如果说符号表是通讯录,那调试信息(debug info)就是 GPS 地图。它记录了源码行号与机器指令地址的对应关系、变量名和变量内存位置的关系、函数参数列表、局部变量作用域、类型定义等。没有调试信息,断点没法精确到行,gdb 的 list 命令也显示不出源码。
调试信息不是 C++ 标准规定的格式,Linux 平台上主流是 DWARF。它分散成 .debug_info、.debug_line、.debug_abbrev、.debug_str、.debug_loc、.debug_ranges 等多个段。粗略数一下,一个开了 -g 编译的 C++ 二进制,debug 相关段的总量经常比 .text 还大,因为调试信息里存储的是“人读得懂”的冗余描述。
可以用这段命令直观感受调试信息的大小:
bash复制size -A hello | grep debug
输出类似:
code复制.debug_info 20136
.debug_abbrev 4021
.debug_line 10983
.debug_str 7822
.debug_loc 3152
这只是一个小程序,加起来已经有 46KB 左右,而 .text 可能才 6KB。放到真实的中大型 C++ 项目里,调试信息膨胀到几十 MB、几百 MB 一点不夸张。再加上模板实例化和 STL 会递归展开类型信息,.debug_info 里的类型图会特别庞大,这是 C++ 项目“编译产物巨大”的核心原因之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 那个“瘦身”工具 strip:它到底在干什么
很多人第一次知道 strip,是被“给可执行文件瘦身”这句话吸引的。但 strip 不是一个“压缩工具”,它是一个 “删除工具”——本质上就是把可执行文件里那些面向调试、面向符号解析的信息丢掉,让文件变小,同时让逆向分析更费劲。
2.1 strip 命令的家族成员和基本参数
Linux 下最常用的是 GNU binutils 自带的 strip,另外 LLVM 生态里也有对应的 llvm-strip,参数基本兼容。我日常用 GNU strip 比较多,下面这些参数是核心:
| 参数 | 作用 | 说明 |
|---|---|---|
-s / --strip-all |
移除所有符号 | 删除 .symtab、.strtab,也会顺带删掉调试信息 |
-g / --strip-debug |
仅移除调试信息 | 删除 .debug_* 段,符号表保留 |
--strip-unneeded |
移除不必要的符号 | 保留动态链接需要的符号,删掉可重定位相关的本地符号 |
-x / --discard-all |
移除所有局部符号 | 类似 strip-all,更激进 |
-K / --keep-symbol=<name> |
保留指定符号 | 可以多个 -K 叠加 |
--keep-file-symbols |
保留文件符号 | 保留来自输入 .o 的文件名符号 |
实际发布时最常用的是 -s 或 --strip-unneeded。两者区别是:-s 会尽可能删掉所有静态符号,--strip-unneeded 会先判断哪些符号是动态链接器还可能用到的,保留动态符号表。下面重点说这个动态符号表的区别。
2.2 strip 对可执行文件结构的具体改动
在 ELF 文件里有一个容易被忽略的现象:动态符号表 .dynsym 和静态符号表 .symtab 是两套独立的东西。
.dynsym是动态链接器在程序启动时用于解析动态库符号的,可执行文件或共享库在加载、重定位时离不开它。.symtab是给调试器、链接器、性能分析工具看的,正常运行的程序根本不需要它。
strip -s 的关键行为是:删除 .symtab、.strtab 以及所有 .debug_* 段,但绝不删除 .dynsym 和 .dynstr。如果你把一个动态链接的可执行文件 strip 之后还能正常运行,靠的就是这套动态符号表还在。
所以准确地说,strip 删掉的是“调试和静态分析信息”,而不是“程序运行状态的一部分”。这也是为什么它敢这么做。
你可以用 file 命令确认 strip 前后状态:
bash复制file hello
# 输出:ELF 64-bit LSB executable, x86-64, ..., not stripped
strip -s hello
file hello
# 输出:ELF 64-bit LSB executable, x86-64, ..., stripped
再看段表:
bash复制readelf -S hello | grep -E "symtab|strtab|debug"
strip 之前能查出一堆 .debug_*,strip 之后基本只可能剩下 .dynsym 和 .dynstr,所有 .debug_* 都不见了。
2.3 动手实测:一个C++程序 strip 前后全对比
光说不练假把式。我写了一个很小的测试程序,包含普通函数、全局变量、模板函数和 STL:
cpp复制#include <iostream>
#include <vector>
#include <string>
int add(int a, int b) {
return a + b;
}
template <typename T>
T max_value(T a, T b) {
return a > b ? a : b;
}
int global_counter = 0;
int main(int argc, char** argv) {
std::vector<int> data = {1, 2, 3, 4, 5};
std::string tag = "demo";
int sum = 0;
for (int v : data) {
sum = add(sum, v);
}
global_counter = sum;
std::cout << tag << " sum: " << sum << std::endl;
std::cout << "max: " << max_value(sum, 100) << std::endl;
return 0;
}
编译两个版本,一个开 -g 一个不开,方便对比:
bash复制g++ -O2 -g -o demo_g demo.cpp
g++ -O2 -o demo_release demo.cpp
查看体积和符号数量:
bash复制ls -lh demo_g demo_release
# -rwxr-xr-x ... 73K demo_g
# -rwxr-xr-x ... 16K demo_release
nm demo_g | wc -l
# 200 左右
现在只对开了 -g 的版本做 strip:
bash复制strip -s demo_g
ls -lh demo_g
# -rwxr-xr-x ... 13K demo_g
注意,-g 编译出来的 73K,strip 之后只有 13K,比直接用 -O2 编出来的 16K 还小。这说明什么?strip 把之前 -g 塞进去的调试信息和符号表都干掉了,剩下的机器指令、只读数据、动态符号、运行时元数据,基本和 “没用 -g 编译” 的文件接近。
再看符号表:
bash复制nm demo_g
# nm: demo_g: no symbols
nm 直接提示没有符号了。但动态符号还在:
bash复制readelf -s demo_g | head -20
你会看到 _start、__libc_start_main、_ITM_deregisterTMCloneTable 这些动态链接相关的符号。
这里补充一个实用经验:strip 对静态编译和动态编译的效果不同。上面例子是动态链接,-s 之后文件能跑。如果你加 -static 静态链接,那 .dynsym 也会极小甚至不存在,strip 之后符号表会非常干净,同时体积也不会像动态链接那样瞬间瘦很多——因为静态链接的程序本身就把库代码编进去了,符号和调试信息占比相对小。
3. strip对可执行文件的四个关键影响,逐个说清
上面已经动过手了,现在把影响系统化地梳理成四块。这也是面试、技术评审环节最容易被人追问的部分。
3.1 体积影响:从几百MB到几十MB
体积是 strip 最直观的影响。一个 C++ 项目如果开了 -g,比如某中间件服务编译出来 200MB,其中符号表 + 调试信息可能占了 150MB,strip -s 之后掉到 50MB 不是什么稀罕事。如果还想更小,可以再配合 -s 编译选项(GCC 的 -s 意思是编译时剥离符号表)或者 UPX 压缩,但那是另一套话题。
不过要泼一盆冷水:strip 不能压缩代码段和执行数据。如果你的二进制里面 90% 都是真正的机器指令,比如把整个 Qt 静态编译进去了,那 strip 也救不了你,该换思路换思路。
3.2 调试影响:gdb 还能做什么、不能做什么
这是最容易被忽略、也最容易线上翻车的点。
- strip -s 之后,gdb 里
info functions、info variables、list基本全废,断点只能按地址打断点,调试体验 = 裸奔。 - strip -g 之后,符号表还在,所以
info functions能看到函数名,但源码行号、变量名、类型信息全没了。也就是说,你能知道“调到了哪个函数”,但看不到“这一行是什么代码”,也看不到x = 5这种局部变量值。
更麻烦的是崩溃栈。线上程序崩溃后常用的流程是:
bash复制gdb -batch -ex run -ex bt ./myapp
strip 前的输出很友好:
code复制#0 0x0000555555555159 in add(int, int) at demo.cpp:5
#1 0x0000555555555210 in main at demo.cpp:19
strip 之后的输出变成了:
code复制#0 0x0000555555555159 in ?? ()
#1 0x0000555555555210 in ?? ()
很多运维同学看到 ?? 就抓瞎,但这其实不怪 strip,怪你没有提前保留一套带符号的副本。做法我在第 4 节会说。
3.3 安全与逆向影响:strip 到底能不能防破解
我见过不少朋友把 strip 当“加密工具”用,觉得把符号删了就没人能看懂逻辑了。这个想法比较天真。strip 确实能过滤掉一大部分“白给”的信息,比如函数名、全局变量名,但它删不掉:
.rodata里的字符串常量,比如日志前缀、SQL 语句、提示语;.rodata里的虚函数表(vtable)地址,结合 RTTI 能猜出类继承关系;- 二进制里清晰的函数边界(
__libc_start_main调用链、call指令的目标地址); - 通过反汇编工具(IDA/Ghidra/objdump)恢复出的控制流图。
所以 strip 的正确姿势是**“提高门槛,而不是一劳永逸”**。对于商品化软件,通常还要搭配编译器的 -fvisibility=hidden、混淆、加壳、反调试等手段。但对于很多内部分发的服务端程序,strip 删符号已经足够阻挡大多数“随便看看”的好奇心了。
3.4 对动态链接与库发布的影响
对动态库 .so 来说,strip 的情况再复杂一点。
共享库的核心作用就是“导出符号给外部程序用”,而导出符号恰恰放在动态符号表 .dynsym 里。strip -s 不会删除动态符号表,所以动态库导出函数在 strip 之后依然可见可用。你可以在 strip 之后跑:
bash复制nm -D libfoo.so
只要导出符号还在,dlopen 和 dlsym 就还能正常工作。
但要小心:如果你手动用 --strip-all 加重参数或者用了某些特殊的 strip 组合,导致动态符号表被误删,那这个 .so 基本就废了,外部程序一 dlopen 就会报 undefined symbol。看到这类问题,第一反应应该是“动态符号表受损,或者编译器导出属性配错了”,而不是“程序有 bug”。
至于静态库 .a,一般不建议在链接前 strip。因为静态库本质是 ar 打包的一堆 .o 文件,链接器需要 .symtab 来解析符号引用,尤其跨 .o 之间的交叉引用。你提前 strip 静态库,链接阶段会出一大堆 undefined reference,得不偿失。如果非要减体积,应该先链接出可执行文件,再对最终二进制 strip。
4. 实战技巧:既要小体积又要可调试的完整方案
strip 不是什么“坏东西”,它是一种发布手段。问题是很多人发布时只会 strip,不会“备份调试信息”。下面这套方案是我的标准操作,兼顾小体积和可调试。
4.1 分离调试信息:objcopy --only-keep-debug
GNU 工具链里有一个几乎和 strip 配套的命令 objcopy。思路很简单:把调试信息单独抽出来存成一个文件,然后把原二进制 strip 干净,最后在 stripped 二进制里写入一个指向调试文件的链接标记。这样线上跑的是瘦身后的文件,出了事故,拿到崩溃地址再去调试文件中还原栈。
完整命令序列:
bash复制# 1. 编译:开 -g,得到完整带调试信息的版本
g++ -O2 -g -o myapp myapp.cpp
# 2. 抽取调试信息,保存为 myapp.debug
objcopy --only-keep-debug myapp myapp.debug
# 3. 对原二进制 strip,但保留一个 debuglink 指向 myapp.debug
objcopy --strip-all --add-gnu-debuglink=myapp.debug myapp myapp.stripped
# 4. 实际运行的是 stripped 版本
mv myapp.stripped myapp
注意第 3 步的 --add-gnu-debuglink,它会在 ELF 里写入一个 .gnu_debuglink 段,内容是调试文件的文件名和 CRC 校验值。这样 gdb 在加载 myapp 时会自动去搜索 myapp.debug。
gdb 搜索调试文件的默认路径包括:
- 当前目录下的
.debug子目录; - 全局调试目录
/usr/lib/debug; - ELF 中
.gnu_debuglink指向的文件绝对路径。
实际部署时,我通常把 myapp.debug 保留在构建服务器,或者跟随发布包放到 /usr/lib/debug 下。崩溃地址可以直接在调试机上还原:
bash复制addr2line -e myapp.debug -f -C 0x555555555159
这条命令会输出对应的函数名和源码行号,前提是 myapp.debug 来自同一版本的构建,否则行号可能错位,甚至完全对不上。
4.2 构建系统里怎么集成 strip
这里分两种常见情况。
Makefile 场景。一般我不直接在编译阶段 strip,而是在发布阶段单独建一个 release 目标:
makefile复制CXXFLAGS += -O2 -g
release: myapp
objcopy --only-keep-debug myapp myapp.debug
objcopy --strip-all --add-gnu-debuglink=myapp.debug myapp myapp.stripped
mv myapp.stripped myapp
clean:
rm -f myapp myapp.debug *.o
CMake 场景。CMake 在 Release 构建时默认带 -O3,但不会自动 strip。我习惯在 install 阶段做处理:
cmake复制install(TARGETS myapp
RUNTIME DESTINATION bin)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/myapp.debug
DESTINATION lib/debug)
然后在脚本里对安装到 bin 下的东西执行 strip,并补充生成 debug 文件。更省事的方式是直接用 CMake 的 INSTALL(TARGETS ...) 配合 if(CMAKE_BUILD_TYPE STREQUAL "Release") 块里执行 strip 命令,不过那样不会帮你保留 debug 文件。我的原则是:自动化能做的,绝不手动做,但调试信息一定要保留副本。
提示:如果用的是 CMake 3.22+,你甚至可以在
target_link_options里加-Wl,--build-id。这个选项会生成一个build-id,gdb 能通过它去/usr/lib/debug/.build-id/下找对应调试文件,比.gnu_debuglink更智能,因为 build-id 是全局唯一的,不怕不同版本之间误链。
4.3 符号表还能用于性能分析和崩溃解析
我会额外提一个观点:符号表不只是给调试器用的,也是 perf 性能分析的重要数据源。
线上用 perf top 或者 perf record 时,如果没有符号表,perf 输出结果基本就是一串十六进制地址,什么热点函数都看不到。这时候你要么保留带符号的副本供离线分析,要么用 --build-id 和调试文件匹配,要么在采集机器上临时装一个带符号版本。做法上,保持“线上 stripped、线下 debuginfo”的设计就是最优解。
如果程序崩溃时连 gdb 都来不及挂载,可以先用 core dump 文件离线解析。配合 debuginfo 包,gdb 读取 core 后依旧能还原调用栈和局部变量。这套流程我在线上已经跑了好几年,稳定可靠。
4.4 另一种“瘦身”思路:编译选项与裁剪
strip 处理的是“已经生成的文件”,而编译阶段的一些选项可以从源头减少符号表和代码体积:
-ffunction-sections -fdata-sections:让每个函数、每个数据对象单独成段;-Wl,--gc-sections:链接时删除未被引用的段。-fvisibility=hidden:默认隐藏符号,只导出显式标记__attribute__((visibility("default")))的函数,能大幅收缩动态符号表。-s:直接让 GCC 不输出符号表到文件,效果相当于编译阶段就做 strip,但不影响运行时。
这些选项和 strip 是互补关系:编译期干掉无用数据 + 链接期裁剪未引用对象 + 发布期剥离调试信息,三步合起来,体积能压得比较极限。
5. 常见问题与避坑指南
最后是我这些年踩过的坑汇总。很多问题都不是 strip 本身造成的,而是我们没理解它在“删”什么、保留什么。
5.1 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| strip 后运行直接段错误 | 动态符号表被误删,或程序依赖 .symtab 做了运行时反射/自修改 |
检查 readelf -S 是否还有 .dynsym;不要对动态链接产物使用过度激进参数 |
| gdb 看不到函数名和源码 | 使用了 -s/--strip-all |
改用 -g 或 --strip-debug;保留 debuginfo 副本 |
崩渍栈全部变成 ?? |
线上二进制被 strip,gdb 无符号可读 | 用 addr2line 配合 .debug 文件还原 |
动态库 dlopen 后找不到符号 |
动态符号表受损,或导出属性设置不对 | 用 nm -D 检查导出符号;用 objcopy --strip-unneeded 而不是硬删 |
用 perf top 看不到函数名 |
缺少符号表 | 用带符号版本跑 perf,或离线解析 |
| strip 后体积变化不大 | 文件主体是代码/数据,不是符号 | 考虑 -ffunction-sections -fdata-sections -Wl,--gc-sections,或上 UPX |
静态库 .a strip 后链接失败 |
静态库需要 .symtab 供链接器解析 |
不要在链接前 strip 静态库 |
5.2 踩坑实录
第一个坑:strip --strip-all 和 --strip-unneeded 不是一回事。我早期图省力,直接 strip -s 处理所有产物。后来发现某个内部库被我用 -s 处理之后,dlopen 加载时报警告,因为库里的部分导出函数进了 .dynsym 不假,但本地符号全没了,某些依赖本地回调和段内符号的逻辑就出问题了。现在我的默认策略是:动态库用 --strip-unneeded,可执行文件用 -s。
第二个坑:先 strip 再杀 debuglink 的顺序。有人先执行 objcopy --strip-all,再执行 objcopy --add-gnu-debuglink,虽然文件最后看起来既有 stripped 状态又有 debuglink,但我在某些老版本 binutils 上遇到过 debuglink 指向的文件在 gdb 下识别不稳定的问题。所以我现在一律按第 4.1 节的顺序来:先抽调试信息,再 strip,同一行命令里加 debuglink,一气呵成。
第三个坑:不要只看文件后缀判断是否有符号。file 输出显示 stripped 只是说 strip 操作过,不代表动态符号表也没了。同样,readelf -S 看不到 .symtab 也不代表不能调试——只要 .gnu_debuglink 在,gdb 依然能跟着 link 找到外部 debug 文件。判断一个二进制是否还有调试能力,要看它能否在 gdb 里列出源码,而不只是看 nm 有没有输出。
5.3 几个容易被忽略的点
第一,strip 不会减少进程运行时的内存占用。.symtab 和 .debug_* 这些段本质上不会被加载到进程内存,它们只影响磁盘文件和加载时的解析时间。所以“strip 了就能省内存”是个误区。
第二,C++ 程序即便 strip 得很干净,.rodata 里依然会有 RTTI 字符串、虚表指针、局部字符串常量。如果这些字符串里带了源码路径、模块名、账号密码之类的敏感信息,那 strip 并不能保护它们,需要另行处理。
第三,现代构建系统里,CMAKE_BUILD_TYPE=Release 不会帮你 strip,你仍然要像 4.2 节那样在 install 或打包阶段自己处理,或者使用 set_target_properties(... PROPERTIES INTERPROCEDURAL_OPTIMIZATION TRUE) 配合链接器参数来优化。千万别以为 Release 版本默认就 stripped 了。
最后再分享一个小技巧:如果你有大量的崩溃地址需要还原,比如几万个,不要一个个跑 addr2line,可以先把崩溃地址按行整理成文件,然后循环批量处理,速度会快很多。或者干脆写一个小的 Python 脚本调用 addr2line --exe=myapp.debug,它的输出是流式的,处理百万行也不在话下。这类批量还原脚本基本每个 C++ 后端开发者都应该备一份。
我个人在实际操作中的体会是:strip 不是敌人,忘掉调试信息才是敌人。它帮你把发布包压小、把符号藏起来,但你得提前把“调试证据”保存在另一个安全的位置。花两分钟在构建脚本里加两条 objcopy 命令,线上排查事故的时候能省下两个晚上。
