首页 / 资讯中心 / 文章详情

深入解析Mach-O __common节:C语言全局变量与链接器合并机制

深入解析Mach-O __common节:C语言全局变量与链接器合并机制 ★ FEATURED ARTICLE
1. 从链接器视角重新认识 __common 节如果你跟我一样平时喜欢把 Mach-O 文件拆开看会发现大部分精力都花在__TEXT、__DATA这些常规段上__common节总是一笔带过。但真正踩过坑的人都知道__common节恰恰是理解“C 语言全局变量到底怎么落地”的关键。它不是编译器随便加的花架子而是链接器、运行时、符号表三方博弈后的产物。这篇文章我想把__common节从头到尾捋一遍它解决什么问题、由谁生成、在什么情况下会变成别的节、怎么用工具观察它以及最容易被忽略的“合并规则”和“对齐陷阱”。内容尽量贴近实际工程不搞教科书式铺陈看完你能直接拿工具验证。1.1 为什么需要一个“公共块”机制先回到 C 语言的经典场景。你在两个.c文件里都写了int global_counter;注意这不是static int也不是extern int就是一个裸的全局变量定义。在 C 的标准里这属于“暂定定义”tentative definition因为编译器看到int global_counter;时不知道它是不是最终定义还是说别的文件里还有个int global_counter 5;在等着。如果两个文件都有暂定定义并且都没有显式初始化那么链接后应该合并成一个变量占用一份存储空间而不是报“重复定义”错误。问题是现代编译器大多按“一个翻译单元一个目标文件”的方式工作每个.o文件都独立编译。如果每个.o文件都把这个变量放进.data节那生成可执行文件时链接器就必须做“合并相同符号数据”的魔法如果放进.bss节那又没法区分“这个符号到底是不是最终定义”。于是 Unix 系工具链很早就引入了common symbol的概念对于这种暂定定义编译器不急着分配地址而是先把符号标记成“公共块”等到链接阶段再统一分配。Mach-O 里的__common节就是这种“公共块”在磁盘格式上的具体承载位置。从 Section 的角度看它挂在__DATA段下名字叫__common但它和__bss一样不占据文件字节只有size和align信息。关键区别在于__bss中的符号已经被认为是“本文件确定的零初始化数据”而__common中的符号还处于“待合并的公共块”状态。这个差别直接决定了你能否跨文件合并同名变量。提示Mach-O 中__common节通常没有sections的典型内容它在 section header 里存在主要作用是告诉 dyld 或静态链接器“这些符号是公共符号请按规则处理。”它不是用来存普通数据的。1.2 __common 与 __bss、__data 的本质区别要真正理解__common必须把它和另外两个容易混淆的节放在一起对比节是否占文件空间是否零初始化符号类型典型产生方式__data是否_N_SECTint x 5;__bss否是_N_SECTstatic int x;或int x 0;某些模式__common否是_N_UNDF_N_EXT注意表格里__bss和__common在“符号类型”上的差别。__bss里的符号是_N_SECT说明它有确定的 section 归属也就是说“这个文件自己决定了它放在哪、占多大”。而__common里的符号是_N_UNDF加_N_EXT表示“这个符号是未定义但外部可见的”只不过 undefined 之外还有一个n_value字段用来表达公共块的字节大小。这是从 a.out 时代就传下来的设计Mach-O 完整继承了这一套。那什么时候int x;会进__bss而不是__common如果你在文件里写了static int x;作用域限制在当前翻译单元不再需要跨文件合并编译器就会直接把它放到__bss。如果你写了int x 0;C 标准认为这是显式初始化不是暂定定义所以也能进__bss。只有当它是“外部链接、无初始化器”的全局变量时才走__common路径。1.3 谁在消费 __common 节编译期clang 负责生成__common里的符号。链接期静态链接器 ld64 负责读取所有目标文件的__common符号按同名合并规则生成最终布局。运行时dyld 在执行主程序和动态库时也会处理动态库里的 common symbols。不过对大部分 App 开发者来说最容易接触到的还是“生成 .o 文件”和“链接可执行文件”这两个阶段。这里有个容易忽略的点Swift 和 Objective-C 代码里很少直接产生 __common 节。因为.m和.swift文件中的全局变量基本上都有类型修饰或初始化方式编译器倾向于直接给出__data或__bss位置。真正大量产生__common的是 C/C 的全局变量尤其是老式 C 代码里不加static的模块级变量。这也解释了为什么很多 iOS 开发者在日常编译中完全感觉不到它的存在但只要引入第三方 C 库__common的问题就会冒出来。2. 从源码到 __common 的落盘路径2.1 clang 对暂定定义的处理流程我用一个最简单的例子演示// test.c int global_counter; int main() { return global_counter; }用 clang 编译成目标文件clang -c test.c -o test.o然后查看符号表nm -nm test.o输出里会看到0000000000000004 (__DATA,__common) external _global_counter注意那个地址0000000000000004它并不是真实地址而是这个 common 符号的对齐和大小组合编码后的值。在 Mach-O 的 nlist 结构里n_value对于 common symbol 来说表示“大小加上对齐信息”而不是运行时地址。这是网上很多文章没讲透的地方我当初也在这里卡了很久。clang 之所以这样做是因为global_counter没有任何初始化值类型是int4 字节默认对齐是 4 字节。它被暂定为 4 字节的公共块符号是 external等待链接器合并。2.2 链接器如何合并多个同名的 common 符号现在创建两个文件// a.c int shared_var; // b.c int shared_var;分别编译得到a.o和b.o然后链接clang a.o b.o -o demo正常情况下不会报重复定义错误而是合并成一个 4 字节的shared_var。链接器会从所有 common 符号里选取“最大大小”的那个作为最终大小同时取“最严格对齐”的对齐值。如果某个符号在其他文件里已经变成了普通定义比如int shared_var 3;那么 common 符号会被普通定义吸收最终按普通定义处理。这就有意思了如果多个文件的 common 符号大小不一致链接器并不会报错只会按最大值分配。比如一个文件写int common_arr[10];另一个文件写int common_arr[20];链接后大小是 80 字节20 个 int。这种行为在 C 标准里是未定义的但 Unix 链接器历来这么干Mach-O 也继承了。这是很多隐蔽 Bug 的根源后面我会专门举例。2.3 为什么最终可执行文件里看不到 __common 节你链接完可执行文件后再跑nm -nm demo会发现_shared_var的类型变成了(__DATA,__bss)而不是(__DATA,__common)。因为静态链接器已经完成了“公共块”到“具体存储位置”的转换合并后的符号被分配到最终二进制的__bss节里。__common只在 .o 文件阶段存在或者在某些动态库的中间产物里偶尔出现。这其实和 ELF 的.comm伪操作非常像。ELF 里.comm symbol, size, alignment定义的是 common symbol最终链接后也会被放进.bss。Mach-O 干的事本质上完全一致只是形式上多了一个__common节名。注意如果你的程序最终产出的可执行文件里仍然看到__common符号那大概率是没链接干净或者你的动态库特意保留了公共符号。对于普通 App 来说静态链接后就应该看不到它。3. __common 节的对齐规则与大小编码3.1 n_value 字段到底存了啥这是最容易被误解的地方。常规符号在 Mach-O 中nlist_64.n_value是符号的虚拟地址。但 common symbol 的n_value是低 8 字节在 64 位下或低 4 字节在 32 位下存符号大小高位部分存对齐的对数即align n_value 3264 位或n_value 832 位。用刚才test.o的例子看0000000000000004这个值里低 32 位是4高 32 位是0表示大小 4 字节对齐2^0 1。但是这里有个小陷阱我上面用nm显示的是0000000000000004看起来对齐值不应该是 1。实际上不同工具解析n_value的方式略有差异某些版本会把“对齐字节数”而不是“对数”直接塞进高位。所以在写解析工具时不要想当然地认为高 32 位就是log2最好参考MachOAnalyzer或llvm-objdump的实现。如果你做一个 64 位下的 16 字节对齐的 common 符号int aligned_var __attribute__((aligned(16)));编译后n_value高 32 位会是 4因为2^4 16低 32 位是 4大小。合起来就是0x0000000400000004。看到这个值你就知道它代表什么了。3.2 链接时对齐如何合并当多个 common 符号同名时对齐取最大值。这个很好理解如果一个文件要求 4 字节对齐另一个要求 16 字节对齐合并后必须满足 16 字节对齐否则第一个文件里对该变量的访问可能因对齐不足而崩溃。对于 ARM64非对齐访问可能直接异常所以这里的严格取最大值是必要的。不过有个细节如果同一个符号在某个 .o 文件里已经是普通定义并且它在一个要求低对齐的节里那么另一个文件里的 high-alignment common 符号并不会把最终地址抬到 high-alignment因为普通定义已经“占坑”了。这是非常容易出错的地方。正确做法是不要让同一个全局变量在不同文件里一边做普通定义一边做 common 定义除非你明确知道对齐要求。3.3 用 llvm-objdump 观察真实数据推荐直接用系统自带的工具验证不需要额外安装llvm-objdump --macho --section-headers test.o llvm-objdump --macho --syms test.o第二个命令的节信息中__DATA,__common会出现并且nlist条目里的n_type是0x0fN_EXT | N_UNDFn_sect是 0。这才是 common symbol 的完整画像。只看nm输出会漏掉这些细节。4. 动态库里的 __common 和 dyld 的处理4.1 动态库导出 common 符号会发生什么假设你构建一个 dylib里面有一个未初始化的全局变量int dylib_global;编译动态库时这个符号很可能会以 common 形式出现在 dylib 的符号表里。当 dyld 加载这个 dylib并把它和主程序链接时会执行类似静态链接器的合并逻辑如果主程序里也有同名的 common 符号那么 dyld 会合并它们。如果主程序里是普通定义那么 dylib 的 common 符号会被主程序的普通定义替代。这个机制在 ELF 里叫“symbol interposition”在 Mach-O 里也差不多只是实现细节不同。实际开发中如果你动态库里的全局变量和主程序的全局变量同名并且两者都是 common那么最终它们会指向同一份内存。很多人会利用这个特性实现跨 dylib 的共享状态但这是把双刃剑一旦初始化器不同行为就变得诡异。4.2 dyld 的 common symbol 合并规则dyld 处理 common symbol 时会做如下事情把所有可加载镜像中的 common 符号收集起来对每个符号名字寻找所有镜像里的同名条目普通定义优先级最高如果没有普通定义则取最大的 common 大小作为最终分配大小对齐取所有同名字符合并后的最大对齐。正因为这样如果你在 App 主二进制里写了一个int global;同时某个动态库发了int global[100];链接后最终分配的会是 400 字节。但是主程序代码里按int类型访问只用到前 4 字节动态库代码可能用到整个 400 字节。这种“和谐”其实暗藏风险一旦两个文件对这个变量的语义理解不一致整个程序的状态就乱了。4.3 如何避免动态库间的意外合并最稳妥的方式是给所有跨模块共享的全局变量加上显式初始化器int dylib_global 0;一旦加了 0编译器就不再生成 common 符号而是放进__bss或__data变成普通定义。普通定义不会和别的普通定义合并重复定义时符号解析规则会明确很多。虽然仍然可能 interpose但至少不会出现“大小不一还硬合并”的惊悚场面。我在自己的代码规范里就要求所有非 static 全局变量必须显式初始化就是为了从源头消灭__common带来的不确定性。提示 0并不能解决真正的重复定义错误。如果你的两个 .c 文件都写int x 0;然后链接那会直接报 duplicate symbol。显式初始化只是把“暂定定义”变成“确定定义”而不是把“重复定义”变成“合法”。5. 实际工程中 __common 引发的典型问题5.1 隐蔽的重复符号崩溃我在一个项目里遇到过静态库 A 中有一个文件叫config.c里面定义了int g_config_mode;暂定定义。静态库 B 中也有一个config.c同样写了int g_config_mode;。链接时因为两者都是 common 符号链接器根本不会报错直接合并成一个变量。问题在于两个库对g_config_mode的语义定义完全不同一个库约定 0 表示自动1 表示手动另一个库约定 0 表示关闭2 表示开启。最终运行结果就是时好时坏排查了很久才发现是两个同名 common 符号被静默合并了。这种问题在整包编译时尤其难查因为nm看 .o 文件时全是(__DATA,__common) external _g_config_mode根本分辨不出它们来自不同的库。5.2 对齐不一致导致 ARM64 下偶发崩溃另一次事故发生在 ARM64 上。某个 C 模块里定义了一个uint64_t cache_line __attribute__((aligned(64)));但另一个老旧库中同样名字的符号是普通uint64_t8 字节对齐。链接时因为普通定义已经占位common 符号被它吸收最终变量落在 8 字节对齐的位置上。可代码里用了vld1q_u64之类的对齐加载指令假设 64 字节对齐结果在特定地址上触发对齐异常偶发崩溃。解决方式当然不是去掉对齐属性而是确保所有定义处保持一致。这需要你审查第三方库是否在头文件里声明了带对齐属性的extern变量同时源文件又用了普普通通的暂定定义两边不一致就会踩雷。5.3 如何快速定位同名 common 符号如果你的工程比较大怀疑存在这类问题可以用脚本扫一遍所有 .o 文件里的 common 符号并统计重复。简单的命令find . -name *.o -exec nm -nm {} \; | grep __common | awk {print $NF} | sort | uniq -duniq -d那列就是出现多次的 common 符号名。看到之后再去每个 .o 里确认它们是否真的来自不同源文件。这个方法虽然粗糙但非常有效。另一个排查方向是看最终二进制的符号解析nm -m App | grep _g_config_mode如果符号节信息显示(__DATA,__bss)说明已经合并完成。要判断它来自哪些 .o只能回到中间产物去搜。5.4 链接器 flag 的干预手段ld64 没有像 GNU ld 那样提供直接的-fcommon/-fno-common开关来切换 common 行为但 clang 编译阶段有这个选项。默认情况下现代 clang 在 Darwin 平台上遇到暂定定义时仍然生成 common 符号你也可以加-fno-common让编译器把暂定定义直接放到__bss中变成普通定义。加上-fno-common后两个文件里同名暂定定义就会在链接时报出真正的重复符号错误而不是静默合并。这样做的好处是暴露问题坏处是某些老代码本来依赖 common 合并语义改了之后无法链接。如果要给第三方库局部关掉 common可以只在对应 target 的编译参数里加-fno-common但这个 flag 是对整个文件生效的无法精确到单个变量。如果你需要更细粒度控制只能用源码层面去改对要保留 common 语义的变量保持原样对希望变成普通定义的变量加上显式初始化或static。6. 从符号表到运行时__common 的值传递路径总结6.1 .o 阶段编译器输出一个或多个 common 符号它们没有确定的地址n_value编码了大小与对齐n_type是N_UNDF | N_EXTn_sect为 0。节表里存在__DATA,__common的 section header但对应的文件偏移为 0size 为 0它不是普通的数据存储节。6.2 静态链接阶段ld64 会扫描所有 common 符号若有同名普通定义则 common 符号自动变为对该普通定义的引用若全是 common则挑选最大大小与最大对齐最终分配地址落在可执行文件或动态库的__bss段中符号类型从N_UNDF变成N_SECTn_sect指向__bss对应节。这也是为什么做完静态链接后你再也看不到__common了。6.3 动态加载阶段dyld 在加载主二进制和动态库时如果动态库仍带有 common 符号会再执行一次合并。但 macOS 上很多系统库都会尽量避免导出 common 符号因为 Apple 鼓励所有跨镜像共享符号显式初始化。如果你自己创建动态库建议遵循同样原则避免把 common 符号泄漏出去。7. 值得注意的坑与个人建议汇总7.1 不要依赖 common 符号合并来做“隐式共享”有些开发者会把多个文件里的int global_flag;当作一种“无头文件 extern”的通信方式。这在单一可执行文件里可能碰巧工作但在动态库、静态库混合场景下非常危险。与其依赖这种隐式机制不如用头文件里显式声明extern int global_flag;并在一个源文件里定义int global_flag 0;。虽然多写几行代码但语义明确排查问题省心得多。7.2 谨慎对待第三方 C 库第三方 C 库为了兼容老式编译器经常出现大量暂定定义。接入时最好统一用-fno-common编译整个库看能不能链接过。如果链接不过说明库内存在故意依赖 common 合并的多文件全局变量你需要评估这种依赖是否安全。尤其在高性能计算、嵌入式交叉编译风格的代码中这种模式很常见。7.3 将 __common 视为一种“中间状态”而不是真正的存储区域很多初学者在解析 Mach-O 时试图从__DATA,__common中读取变量数据结果发现文件偏移为零以为系统出 bug 了。实际上__common节只承载符号元数据最终数据在__bss中运行时全部为 0。解析工具应该把__common里的符号当作“需要特殊处理的内存分配请求”而不是普通节内符号。如果你的自定义分析工具要对符号做归类一定要先看n_type是不是N_UNDF | N_EXT再决定如何解析n_value。7.4 遇到神秘全局变量被改的问题时先查同名符号只要程序中有多个 C/C 源文件并且出现“又没赋值但变量值莫名变了”的诡异现象第一批排查动作里就应该包含“扫描 common 符号重复”。这种问题的隐蔽性在于代码里每个文件看起来都合法单独编译都正常只有链接后会悄悄共享同一块内存。用我上面给的find nm uniq -d组合几分钟能定位大部分问题。8. 实操练习自己构造一个 __common 并验证整个生命周期8.1 准备两个目标文件// one.c int shared[4]; // two.c int shared[8];编译clang -c one.c -o one.o clang -c two.c -o two.o查看各自符号nm -nm one.o nm -nm two.o能看到_shared都是(__DATA,__common) external。注意one.o的n_value低 32 位是 16two.o的是 32。8.2 链接并观察合并结果clang one.o two.o -o merged nm -nm merged | grep _shared你会看到_shared变成了(__DATA,__bss) external大小显示 32 字节。也就是说最终结果取了two.o里的 32 字节。这个行为符合“最大者胜”的原则。8.3 再验证普通定义优先新建一个文件// three.c int shared 5;编译并与one.o链接clang three.c one.o -o merged_three nm -nm merged_three | grep _shared_shared会变成(__DATA,__data) external因为three.o中普通定义int shared 5;优先且分配在__data里初始值是 5。one.o的 common 定义被吸收不会产生额外空间。8.4 忽略对齐差异导致的实际差异把two.c改成int shared[8] __attribute__((aligned(16)));编译后查看two.o的n_value高 32 位会有对数 4。与one.o对齐 4链接后最终对齐取 16。你可以用nm -m看不出来但要验证真正的对齐值得借助otool -l或llvm-objdump --macho --syms分析 nlist 的n_value高位。这种级别的检查在实际排错里很有用。9. 聊聊 Mach-O 解析工具对 __common 的兼容性市面上很多 Mach-O 解析工具都只把__bss和__data当成正常节遇到__DATA,__common就忽略。结果就是符号表里明明有符号但按节遍历时找不到数据造成数据错乱。如果你自己写解析器对__common需要特殊处理在遍历 symtab 时遇到N_UNDF | N_EXT且n_value ! 0的符号不要尝试按n_sect找 section从n_value中解析大小和对齐如果只是想展示变量的“逻辑初始值”可以把所有 common 符号当作零初始化的未定义符号处理如果要重建最终布局需要模拟链接器合并规则也就是先找同名普通定义再找最大 common 大小。Apple 自己的nm和otool都处理得很好但开源库或者某些脚本工具就未必。我在写自己的 Mach-O 分析工具时专门为__common写了一个分支否则统计各节大小时总发现少一截。10. 最后分享一个我个人的查错经验以前我排查过一个诡异的线上崩溃某个 C 模块的全局配置整数在某次函数调用后突然变成极大的值但代码里所有写它的地方都加了锁。最终发现这个变量在模块 A 里是int config_value;common在模块 B 里也是int config_value;common但模块 B 其实把它当布尔值用某处代码执行了config_value -1;于是模块 A 读到的是0xFFFFFFFF。用nm一查两个 .o 里的 common 符号完全同名链接器静默合并谁都没报错。从那以后我的团队代码规范里多了一条硬性要求所有非 static 全局变量必须显式初始化并且头文件必须用 extern 声明。这样做确实让代码啰嗦了一点但换来的是链接期就能发现重复定义问题而不是把隐患留到运行时。如果你是从 ELF 世界转过来的__common对你来说不会陌生把它当成 Mach-O 版的.comm就行。但如果你只在 Apple 平台上开发以前没深入看过符号表那么在你下次遇到“变量莫名共享”这类问题前最好先把__common的行为刻进脑子里。它平时不显山不露水一旦冒出来就是大事。
阅读完成 · 觉得有帮助?
咨询建站