ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解Linux strip:符号表、调试信息与发布实践

深入理解Linux strip:符号表、调试信息与发布实践 我先说个场景你八成遇到过自己电脑上编译好的C程序跑起来一点事没有但一放到服务器或者客户机器上偶现崩溃想用gdb看下调用栈结果bt打出来全是??地址是一堆十六进制数字函数名一个都看不到。这种时候多半和发布时做了strip脱不开干系。strip是Linux下再常见不过的一个小命令作用是从可执行文件里删掉符号表和调试信息给程序“瘦身”。很多人对它只有个模糊概念以为就是给二进制减减肥也有人干脆不敢用怕strip完程序就坏了还有人strip完出了线上事故排查问题时对着裸奔的二进制欲哭无泪。这命令到底动了什么手脚、删了哪些东西、哪些该删哪些必须留这篇文章就把它彻底讲明白。不管你是刚入门的C新手还是被线上问题折磨过的老开发看完应该都能在心里画出一张清晰的图。1. 先搞清楚可执行文件里到底装了些什么1.1 可执行文件不是一团二进制是分段的很多人直觉里认为编译出来的可执行文件就是一坨机器指令CPU直接加载就跑。这个理解不算错但太粗糙了。实际上无论是Linux下的ELF格式还是Windows下的PE格式可执行文件内部都是分“节”section组织的就像一本精装书前面有目录、章节有分节正文居中索引在最后。一个典型C程序编译出来的ELF文件里常见的节包括.text编译后的机器码程序真正跑起来时执行的指令都在这里。.data已初始化的全局变量和静态变量比如你在代码里写的int g_count 42;这个42就存在.data里。.bss未初始化或初始化为0的全局变量/静态变量它本身不占文件空间只在加载时分配并清零。.rodata只读数据比如字符串字面量Hello, World、const常量等。.comment、.note.*编译器和构建工具留下的注释信息、构建说明。.symtab符号表记录函数名、全局变量名和它们对应的地址。.debug_*以.debug_开头的调试信息节包括行号表、变量类型、源文件路径等。其中前几个节是程序运行必需的属于“正文”。后面.symtab和.debug_*这类严格来说对运行没有直接作用它们的存在是为了“工具”服务的——链接器、调试器、性能分析器要依靠这些信息才能正常工作。你可以在终端里用readelf -S看一个ELF文件的所有节也能用objdump -h查看。我随手拿一个编译出的C程序执行readelf -S输出会是大几十行普通C程序节数量轻松超过30个带调试信息的更是能到40个以上。这段结构的理解是后面所有内容的基础。很多人搞不懂strip做了什么就是因为脑海里没有“节”的概念以为strip是改了机器码、改了程序内容。其实strip从头到尾只和“元信息”较劲不做手术式的代码修改。1.2 符号表程序里那些名字的“电话簿”符号表英文叫symbol table通俗点说就是一份“名字到地址”的映射表。你的代码里写了int add(int a, int b) { return a b; }编译链接之后add这个符号名会记录在.symtab里同时记录它在.text里的偏移地址。这样做的目的是什么最重要的是为了“链接”。当你的程序由多个目标文件.o文件链接而成时链接器需要在不同文件之间解析相互引用的符号。比如a.o里调用了b.o里定义的函数doWork()链接器必须翻b.o的符号表确认doWork确实存在、地址在哪然后才能把call指令的跳转目标填对。所以符号表是编译链接过程的“胶水”。没有它多文件项目的链接工作就做不了。但需要注意一个重要区别符号表分两类。静态符号表.symtab记录文件内所有符号包括全局函数、全局变量、静态变量等。它对链接器有用但程序装载运行后动态链接器和CPU根本不会看它。动态符号表.dynsym记录需要参与动态链接的导出符号和外部依赖符号。这个才是程序运行时动态链接器ld.so真正要用的。我见过不少开发者以为“strip之后程序里的符号全没了动态库就找不到导出的函数了”。这是混淆了静态符号表和动态符号表。对于动态库.so来说strip默认会保留.dynsym否则库就没法被其他程序加载了。具体哪些保留哪些删除后面细说。符号表的代价是体积。一个符号通常包括名字、类型、地址、大小等信息一个C程序编译出来符号数量是几万到几十万个。RTTI、模板实例化、异常信息都会膨胀符号数量。这也是为什么strip在C程序上的瘦身效果格外明显——C符号本身就长std::vectorint, std::allocatorint ::push_back这种符号一个就好几十字节。1.3 调试信息比符号表更“奢侈”的东西符号表记录“函数叫什么、地址在哪”调试信息记录的东西要多得多。-g选项编译生成的就是这套信息底层格式在Linux上通常是DWARFDebugging With Attributed Record Formats。一套完整的调试信息包括行号表.debug_line每条机器指令对应源代码的第几行。变量信息.debug_info每个局部变量的名字、类型、作用域、所在的寄存器和栈偏移。类型信息结构体、类、枚举的定义成员变量和它们的类型、偏移。宏定义、包含路径等。所以有了调试信息调试器才能做到断点打在源码第78行而不是某个地址。查看某个局部变量它能根据寄存器或栈位置把一个值还原出来。崩溃时能精准定位到源码的某一帧。代价自然是体积。DWARF信息比符号表占空间多得多。一个简单的“Hello World”程序不加-g可能只有16KB加了-g直接变成几百KB。项目大一点带完整调试信息的二进制动辄几百MB非常常见。理解了这两类“元信息”的本质再去看strip思路就豁然开朗strip就是一把“删除手术刀”把可执行文件里那些运行期用不到的辅助信息给清掉。2. strip命令是怎么“下手”的2.1 strip的各个参数到底删了什么strip命令是GNU binutils工具链的一员各Linux发行版基本都自带了。它的参数不少但日常用得最多的是这几个参数作用实际场景-s/--strip-all删除所有符号信息包括静态符号表和调试信息但不删动态符号表发布最终的二进制追求最小体积-g/--strip-debug只删除调试信息保留符号表还想保留函数名做崩溃分析但不需要单步调试--strip-unneeded删除链接时不需要的符号保留可能需要的符号更温和的瘦身方式适合拿不准的场景--only-keep-debug把调试信息单独抽到一个文件里主体文件里删除调试信息生成独立的调试符号文件配合发布使用--remove-section.xxx手动指定删除某个特定节精细化控制平时少用其中-s和--strip-all是比较激进的删除它会移除.symtab以及所有.debug_*节但注意它不会删除.dynsym、.strtab动态符号字符串表相关的某些部分这些程序运行时还需要的东西。--strip-unneeded则更懂人心它会逐个符号分析只删那些“链接时不需要的符号”而保留那些“可能被动态链接用到的符号”。这种模式比-s更保守删完之后出问题的概率更低但瘦身效果没-s彻底。我个人在给第三方SDK做裁剪时偏好用这个毕竟运行期稳定大于一切。如果想看得更细strip的帮助信息里还提供了-R/--remove-section来手动删掉指定节。我曾经为了把一个嵌入式固件压进Flash硬是手动把.comment、.note这些没用的节一个个删掉。但一般人的项目用不到这个自由度。2.2 从二进制层面看strip的实际效果光说参数太虚我们看一组实际数据。我随便写了一个包含大量STL使用、异常处理和全局对象的C程序#include iostream #include vector #include string #include memory #include stdexcept class Base { public: virtual ~Base() default; virtual std::string name() const { return Base; } }; class Derived : public Base { public: std::string name() const override { return Derived value_; } private: std::string value_{demo}; }; int main() { std::vectorstd::unique_ptrBase items; for (int i 0; i 100; i) { items.push_back(std::make_uniqueDerived()); } for (const auto item : items) { std::cout item-name() std::endl; } return 0; }编译命令g -g -O2 -o demo demo.cpp用ls -l看一下未strip的原始带调试信息版本约 1240 KB。执行strip -g只删调试信息约 96 KB。执行strip -s全删约 88 KB。注意这个对比#原始版本里有大量.debug_info、.debug_line、.debug_str等节占了最大头而strip -g之后这些调试节全没了但符号表还在strip -s再进一步把.symtab也拿掉了。再用size命令对比一下各节的大小text data bss dec hex filename 20458 1400 664 22522 57fa demo - 原始带调试信息 20458 1400 664 22522 57fa demo_strip_g 20458 1400 664 22522 57fa demo_strip_s有意思的是size命令输出的text/data/bss在三个版本里完全一样。这说明strip删掉的都是“非正文”信息程序的核心机器码部分毫发无损。这就是为什么strip完的程序还能照常运行。用readelf -S看节分布更直观原始版本.debug_*节有十几二十个每个几千到几万字节。strip -g后.debug_*全消失.symtab还在。strip -s后.symtab也没了但.dynsym、.plt、.got这些动态链接相关的东西依然健在。这就是strip的全貌它是个精准的“元信息清理工”删的全是“运行用不到”的内容。2.3 为什么strip完程序照常运行这可能是很多人最后的疑虑没有符号表程序凭什么还能正常运行关键在于一个认知处理器执行指令时不看函数名的。一个call指令到了CPU眼里就是一个目标地址。这个地址在链接时已经被算好了写死在指令里或者通过GOT/PLT做动态绑定。程序装载后CPU只是忠实地跳转到地址它不关心这个地址对应的人类可读名字是doWork还是doWork_123。调试器、崩溃分析工具之所以需要符号名是因为它们要站在“人类理解”的角度工作你说“程序崩在地址0x401234”人类没法立刻反应这是哪一行代码你说“程序崩在Derived::name()函数里”开发者的脑子直接就跳到了对应的代码处。打个比方一本书的正文是程序逻辑目录和索引是符号表/调试信息。你把索引撕掉书照样能从头读到尾内容一点不少但是你想快速查某个关键词在第几页就费劲了只能翻。动态链接的情况下分析稍微复杂一点程序在运行时调用一个动态库里的函数它需要符号名去动态库里查找地址。这个查找用的就是.dynsym不是.symtab。strip默认保留动态符号表如果目标是可执行文件通常对它的.dynsym做保留处理对共享库必然不能砍掉导出的动态符号所以动态链接流程不受任何影响。这也是为什么strip命令的设计哲学如此简洁**删掉运行不需要的保留链接和加载还需要的。**它把一切判断建立在“这个信息在进程启动后还会不会被人查”这个标准上。3. strip的利与弊什么时候该用、什么时候别碰3.1 收益体积瘦身、逆向门槛、部署清洁度strip最直接的好处就是减小可执行文件的体积。这不是小事。你的程序如果做成Docker镜像每大1MB就意味着拉取更慢、磁盘占用更多、集群资源消耗更大。对于镜像动辄上百MB、层数极多的微服务环境一个几十MB的二进制如果能靠strip压缩到几MB省下的成本直观可见。举个例子我们组之前有一个网关服务编译出来带调试信息接近300MB。当时做的第一件事就是发布流程里加上strip二进制直接降到45MB。镜像从750MB缩到不到500MB。对几十个节点的集群来说发布时拉取镜像的时间省了将近一半。体积之外还有一层隐性收益降低被逆向分析的风险。符号表里带着全部的内部函数名、全局变量名对逆向工程而言等于送了一份地图。strip之后恶意分析者拿到二进制看到的是一堆地址而不是友好名字分析门槛高了很多。虽然不至于说安全到哪去但确实增加了对方的工作量。第三个层面是部署环境的“清洁度”。很多企业会要求生产环境禁止保留带源码路径的调试信息因为.debug_*节里会包含本地构建时的绝对路径比如/home/zhangsan/build/src/main.cpp这些属于环境泄露可能有合规风险。strip会把这些路径一并清理掉符合安全审计的要求。3.2 代价调试、崩溃分析、性能剖析全面受影响天下没有免费的午餐。strip的代价在出问题的时候才真正体现出来gdb单步调试直接“瞎了”。list看不到源码print variable打印不出局部变量info locals一片空白。崩溃时backtrace变成光秃秃的地址。bt输出里全是0x55555555xxxx没有函数名无法第一时间定位崩溃点。perf、valgrind等性能分析工具的信息会严重降级因为它们的symbolization依赖符号表。线上core dump分析变得艰难。你需要额外保存符号文件还得保证二进制版本和符号文件一一对应。这些代价里最疼的是线上崩溃分析。曾经有次我们的服务在客户环境偶发段错误现场没有保留带符号的二进制只有个strip过的release版本。gdb打开core dumpbt一长串地址看起来就像加密文件。我们花了将近一天时间靠查看汇编、对比反汇编结果才勉强定位到大致位置。从那以后我就在发布流程里强制规定strip之后的产物必须配套保留一份未strip的副本并妥善归档。可能有人问那我能不能不strip保持调试能力又避免体积问题可以。有专门的方案正式发布的二进制用strip -s或--strip-debug处理同时用objcopy --only-keep-debug单独把调试信息抽到一个.debug文件里。gdb分析时用symbol-file加载这个外部符号文件即可。这是目前大型项目的主流做法后面实操章节会详解。3.3 strip对运行性能有没有影响这个很多人关心直接说结论strip对运行性能几乎没有影响。原因前面讲过了程序运行时的指令流、数据段、堆栈布局都没有变strip只是删掉了文件里那些不会被加载进内存的“辅助段”。唯一可能的微小差异有以下几点文件被裁小之后磁盘读入的字节数少了理论上加载速度会有一丁点提升。某些strip工具会把节的内容重新排列可能导致页对齐数量变化有时会让文件变大几KB或变小几KB但这对运行期无感。如果程序用了dladdr之类的函数来查询自身符号名strip之后这些调用会失败或返回空但这算功能受影响不是性能问题。所以不要为了“性能”去strip也不要去担心strip会“降低性能”。它就是个纯粹的体积和元信息管理工具和CPU指令执行效率没有半毛钱关系。4. 实操一套可复用的发布流程4.1 构建时保留带符号的版本最佳实践永远不是在发布前才想起来strip而是在构建流程里把“符号管理”设计好。推荐的企业级做法是这样的# 1. 编译出带完整调试信息的目标文件 g -g -O2 -o demo_full demo.cpp # 2. 生成独立的调试信息文件 objcopy --only-keep-debug demo_full demo_full.debug # 3. 对主体二进制执行strip删掉调试信息和静态符号 strip --strip-debug demo_full此时你得到了两个文件demo_full体积较小包含完整符号表如果你用的--strip-debug或彻底裸奔如果用的-s适合直接用于生产环境。demo_full.debug独立的调试信息文件包含全部DWARF信息必要时用于gdb。demo_full.debug里不会有一行一节的机器码它只是一个“符号包”。要分析core dump时gdb可以这么加载gdb demo_full core (gdb) symbol-file demo_full.debug (gdb) bt这里有个小细节symbol-file加载单独的调试文件时gdb需要知道这个调试文件对应的是哪个二进制。objcopy --only-keep-debug生成的.debug文件里有一个特殊的.gnu_debuglink节记录着对应二进制的构建ID和文件名。gdb找到main binary后如果同一个目录存在demo_full.debug它会自动关联。你也可以用eu-unstrip或者GNU工具链的debuginfod做自动符号服务不过那是后话了。4.2 发布前strip的完整命令组合不同场景推荐不同的strip参数。我个人在项目里总结了一套决策表场景推荐方式说明发布给客户/嵌入式的release程序strip -s追求最小体积降低逆向风险发布给内部使用的服务端程序strip --strip-debug 归档完整符号保留函数名方便崩溃分析发布动态库给第三方strip --strip-unneeded保留动态导出符号尽量瘦身CI流水线统一处理strip -sobjcopy --only-keep-debug主体最小化符号单独归档举一个我的内部服务发布脚本片段#!/bin/bash set -euo pipefail BIN_NAMEgateway_server VERSION$(git rev-parse --short HEAD) BUILD_DIRbuild_release mkdir -p $BUILD_DIR/symbols/$VERSION # 编译 cmake -S . -B $BUILD_DIR -DCMAKE_BUILD_TYPERelWithDebInfo cmake --build $BUILD_DIR -j$(nproc) # 剥离调试信息到独立文件 objcopy --only-keep-debug $BUILD_DIR/$BIN_NAME $BUILD_DIR/symbols/$VERSION/$BIN_NAME.debug # 发布主体二进制再清理一层 strip --strip-debug $BUILD_DIR/$BIN_NAME # 归档 cp $BUILD_DIR/$BIN_NAME $BUILD_DIR/$BIN_NAME.$VERSION cp $BUILD_DIR/symbols/$VERSION/$BIN_NAME.debug $BUILD_DIR/symbols-review/ echo build done: $BIN_NAME.$VERSION流程逻辑很简单编译时肯定要带调试信息RelWithDebInfo或手动加-g然后用objcopy --only-keep-debug把调试信息抽成独立文件归档最后对面向发布的二进制做strip。这样线上跑的是精简版本地/服务器上留一份带符号的将来分析core dump用。4.3 用addr2line在strip后还原调用栈即使你已经发布了strip过的二进制也不是完全没救。如果崩溃现场留下了函数地址你还能用addr2line把它还原成源码位置。addr2line的原理是对于一个ELF二进制的某个虚拟地址查找它落在哪个函数范围内、对应的源码文件是哪个、在第几行。操作步骤# 假设崩溃地址是 0x4015a0 addr2line -e demo_full -f -C 0x4015a0输出类似Derived::name() demo.cpp:18-f显示函数名-C做C名字修整demangle让_ZN7Derived4nameB5cxx11Ev变成Derived::name()。非常有用。但这要求一个前提你必须有一份未strip的二进制或者至少保留了符号表比如你用的是strip --strip-debug而不是strip -s。如果二进制已经被-s删得干干净净addr2line也无能为力因为它需要符号表来匹配地址和函数。那么线上崩溃地址怎么拿通常有两种来源系统日志/core dump中的崩溃地址。程序自己捕获异常、信号时打印的堆栈地址。你可以看到现在很多大型C项目都会在发布版本里内置一个“崩溃捕获模块”收到SIGSEGV等信号时用backtrace或者backtrace_symbols_fd打印出调用栈的十六进制地址然后写到日志文件。这些地址配合保存好的带符号副本用addr2line就能还原。这正好呼应了网上有人搜的“vs调试信息保存到日志文档同时打印显示”这类需求——本质上大家追求的是同一个目标发布出去的二进制可以精简但出问题时得有能力还原轨迹。5. 常见问题与排查技巧实录5.1 strip之后gdb没有函数名怎么办这个问题几乎所有人都踩过。root cause很简单二进制里用于调试的符号信息没了。解法有三个发布时保留一份未strip的副本本地拿来分析。前面讲的独立.debug符号文件方案。gdb的.gdbindex方案把符号信息打包成索引加载更快但体积不省。个人习惯线上服务我一定保留完整符号副本并打上构建时间戳。没有这个习惯等于裸奔上战场。5.2 对动态库strip之后程序启动报symbol lookup error有一种典型报错是./demo: symbol lookup error: ./libfoo.so: undefined symbol: _ZN3BarC1Ev很多人第一反应是“strip把动态库的导出符号删了”。这里要分清楚情况strip默认不会删除动态库的.dynsym导出符号不会被删。如果你确实看到动态库的导出符号丢了基本上是用了过激的参数比如-R .dynsym这种手动删除节的方式或者用了某些非标准的裁剪工具。更多时候这个报错的真正原因是版本不匹配链接时的动态库和运行时的动态库不是一个版本某个函数在新库里改名或不存在了。strip只是背锅侠。排查方法# 查看动态库的导出符号 nm -D libfoo.so | grep _ZN3BarC1Evnm -D列出动态符号表如果这里能看到那个符号说明导出没问题看不到才考虑strip的问题。5.3 我想把strip完的二进制再“还原”符号可能吗不可能。strip删除符号表是不可逆的不是压缩是删除。所谓“从二进制里恢复符号名”最多只能通过反汇编猜测、通过与已知版本二进制比对指纹来推断做不到精确还原。C的符号名里虽然包含了大量信息但也只是把函数名、参数类型、命名空间编码进去了变量名和源码行号之类的是彻底消失了。所以发布流程里留符号副本这个习惯必须前置不能后补。等出了事故再想“还原”二进制大多数情况下是无解的。5.4 单片机/嵌入式裸机固件需要strip吗很多做嵌入式C的朋友会问ARM裸机或者RTOS的固件strip有用吗大多数情况下链接器生成最终bin/hex固件文件时本身就只会把需要的节写入输出文件。符号表通常在链接后的elf文件里存在但等你用objcopy转成bin格式烧录时符号和调试信息已经天然被丢弃了。所以对最终烧录的文件来说不存在“strip不strip”的问题。但如果你交付的是.elf文件给客户做调试那么strip就有意义了你可以交付一个strip掉调试信息的elf供他们运行和集成同时自己保留完整版本遇到问题还能自己调。5.5 哪些“符号”在strip之后依然保留这可能是新手最容易困惑的点。总结一下strip之后哪些信息还在信息类型是否保留说明动态导出符号.dynsym保留动态链接器必需PLT/GOT表项保留动态链接跳转需要build-id.note.gnu.build-id保留用于唯一标识二进制版本静态符号表.symtab删除链接期用完就没用了调试信息.debug_*删除代码分析的奢侈品字符串常量保留属于.rodata程序运行要用RTTI信息.data.rel.ro等保留运行时typeid/dynamic_cast依赖特别注意build-id这一点很多程序的崩溃日志里会打印Build ID: xxxxxx。这个id在strip之后依然存在它等于二进制的“指纹”。我在做符号归档时就是用build-id来校验崩溃的二进制和保存的符号副本是否匹配比对不一致就拒绝分析避免用错的符号信息误导排查。5.6 Windows下对应的“strip”是什么Windows C开发者常搜“visual c redistributable”“MSVC”这类关键字。Windows下对应的概念是发布模式编译/O2、/MT默认不会生成大量符号表信息调试信息默认放在独立的PDB文件里。如果要彻底不带调试信息可以设置链接器选项/DEBUG:NO或在Visual Studio里关闭“生成调试信息”。第三方二进制裁剪MSVC没有完全对等的strip命令MinGW环境下的strip可以交叉处理目标文件但微软官方推荐的“最小化发布”方式是直接不发PDB。符号服务可以用微软的SymStore把PDB归档类似Linux下保留.debug文件的做法。所以Windows下你很少见到“对exe执行strip”这种操作PDB天然独立发布exe本身就不带调试信息。这也是Windows和Linux开发习惯上一个很大的差异。写在最后我在实际的工作里看待strip的态度是这样的它是发布流程里必不可少的一环但也必须和符号归档绑定使用。只做strip不留符号是拿程序的“可诊断性”做赌注不strip直接发布是拿磁盘空间和维护成本开玩笑。两种极端都不可取。如果你在搭建自己的构建发布流程我建议从今天开始就把“输出两个文件——精简版二进制 独立符号文件”作为标准操作。第一次配置可能觉得麻烦但当某天线上真的崩了、你能在十分钟内用addr2line还原出服务员崩溃的代码行时你会感激这个习惯。技术上的事很多时候不复杂复杂的是事前想到、事后不慌。
返回列表