ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

gcc编译产物.o/.a/.so/.out全解析:链接、选型与踩坑

gcc编译产物.o/.a/.so/.out全解析:链接、选型与踩坑 前阵子帮朋友收拾一个 C 小项目他把 gcc 编译出来的东西一股脑塞进压缩包发给了同事一个.out、几个.o、一个.a、一个.so全都平铺在同一个目录里。同事解压之后直接跑那个.out屏幕上蹦出一行error while loading shared libraries: libxxx.so: cannot open shared object file。他当时问我的第一句话是这些不都是 gcc 编译出来的产物吗为什么有的能直接双击运行有的必须成对出现还有的明明在目录里却死活找不到这个问题其实挺典型的。在 linux 上敲 gcc 命令的人很多但真正能把这四种文件讲清楚的人不多。.o是目标文件.a是静态库.so是共享库.out是可执行文件它们并不是四种平行的输出格式而是一条流水线上四个不同阶段的半成品和成品。搞不清这条流水线遇到链接报错就只能靠猜猜对了叫运气猜错了就是一下午。这篇内容会沿着一条真实的编译流水线走一遍从一份.c源码出发看gcc -c到底做了什么、.o里面装了什么、ar怎么把它们打成.a、-fPIC和-shared怎么做出.so、链接器最后怎么缝出一个能跑的.out。然后重点讲四种产物在真实交付场景里怎么选、怎么混用以及那些文档里不会写、但每次都会让人卡住的坑——undefined reference的六种成因、multiple definition在 gcc 10 之后的行为变化、GLIBCXX_3.4.29 not found这类跨机器 ABI 事故、改了库却不生效的缓存问题。适合已经会敲 gcc、但对编译产物还停留在能跑就行阶段的开发者。1. 一条 gcc 命令背后其实藏着四道工序1.1 预处理、编译、汇编、链接各自产出什么很多人以为gcc hello.c -o hello是一步到位的操作实际上 gcc 只是个驱动程序它按顺序调用了四个外部程序cpp预处理、cc1编译器本体、as汇编器、ld链接器。这四步分别对应的产物是.i预处理后的源码、.s汇编代码、.o目标文件、可执行文件。我们拿一个最小例子来拆/* hello.c */ #include stdio.h int main(void) { printf(hello\n); return 0; }分步走一遍每一步只看它产出了什么gcc -E hello.c -o hello.i # 预处理展开头文件、宏去掉注释 gcc -S hello.i -o hello.s # 编译把 C 翻译成汇编 gcc -c hello.s -o hello.o # 汇编把汇编翻译成机器码但还不链接 gcc hello.o -o hello # 链接补上 printf 的地址生成可执行文件-E出来的hello.i会把人吓一跳一份十几行的源码变成上千行因为stdio.h被完整展开了。-S出来的hello.s里能看到call printfPLT这样的行——注意这里 printf 还只是一个名字没有真实地址。-c出来的hello.o已经是纯二进制了用file看会发现它标着relocatable也就是可重定位文件不是可执行文件。只有最后一步链接之后printf的真实地址才被填进去。提示调试阶段用gcc -save-temps hello.c -o hellogcc 会把.i、.s、.o全部保留在当里一次命令看全四个阶段比逐个敲省事得多。1.2 为什么 gcc 默认输出的名字叫 a.out新手第一个困惑往往是我没写输出名为什么生成了一个叫a.out的文件这个名字来自上世纪七十年代的 Unix当时链接器的输出定义就叫 assembler output简称a.out。这个名字沿用至今纯粹是历史包袱没有任何技术含义。想改名字就加-o。这里有个很容易踩的细节-o后面跟的如果是一个已存在的目录gcc 会报错而不是把文件塞进去。另外gcc -o hello hello.c和gcc hello.c -o hello这两种写法都对因为 gcc 是按参数扫描、而不是按位置解析的但习惯上把-o放在最后更符合大多数人的阅读顺序。gcc hello.c # 生成 a.out gcc hello.c -o hi # 生成 hi ./hi1.3 分步编译不是炫技而是排查问题的唯一手段有人会问既然一条命令就能编译为什么要拆成四步答案在报错的时机上。四个阶段的错误长得完全不一样阶段典型报错说明预处理fatal error: xxx.h: No such file or directory头文件找不到检查-I编译error: expected ; before } token语法或类型错误汇编少见通常是汇编语法不兼容-masm相关链接undefined reference to xxx符号找不到跟语法无关把这条边界分清楚你就能一眼判断问题出在代码写错了还是链接配置错了。我见过太多人拿着undefined reference去反复检查头文件有没有#include其实头文件只管声明链接器要的是定义这两件事完全是两回事。2. .o 目标文件能被链接但不能被运行2.1 一个 .o 里到底装了什么用readelf -h hello.o看它的头部Type一栏写的是REL (Relocatable file)。这一个字就定性了它是给链接器吃的原料不是给操作系统执行的成品。.o内部被切成了若干节section每节承担不同职责.text机器指令也就是函数的代码.data已初始化的全局变量和静态变量.bss未初始化或初始化为 0的全局变量实际不占文件空间只在运行时占内存.rodata只读数据比如字符串字面量.symtab/.strtab符号表与字符串表记录我这里定义了什么、还缺什么.rela.text重定位表记录哪几个位置现在填的是占位值链接时要改.bss的设计很有意思它不占磁盘空间因为里面全是 0只要在加载时把那块内存清零就行。所以一个定义了int buf[1000000];的.o可能只有几 KB但一运行就吃掉 4MB 内存——这是新手看到文件这么小、内存这么大时最常见的困惑来源。2.2 符号表链接器眼里的欠条写两个文件来看符号/* add.c */ int add(int a, int b) { return a b; }/* main.c */ #include stdio.h int add(int a, int b); int main(void) { printf(%d\n, add(2, 3)); return 0; }分别编译成.o然后看符号gcc -c add.c -o add.o gcc -c main.c -o main.o nm add.o # 0000000000000000 T add nm main.o # U add # U printf # 0000000000000000 T mainT表示这个符号在本文件里被定义了且在.text段U表示 Undefined也就是我这里用到了但定义在别处链接的时候你得给我补上。链接器的核心工作就是把所有.o的U和T对账全部对上才算成功对不上就报undefined reference。这就解释了一个常见现象main.o单独是没法运行的因为它欠着add和printf两张欠条。gcc main.o add.o -o app这一步就是让链接器把add.o和libc里的定义拿来还账。2.3 重定位表地址还没定先留个坑.o里调用函数的指令在编译期并不知道目标函数最终会被放在哪个地址。它只是一个占位值同时在.rela.text里记一笔这里需要重定位。用objdump -dr add.o或者objdump -dr main.o能看到类似这样的条目R_X86_64_PLT32 add-0x4意思就是这条call指令的位移字段等链接时按最终地址填回去。用生活类比这就像装修时先留一个插座底盒线还没接——.o就是留了一堆底盒的毛坯房链接才是真正接线通电的工序。2.4 一个反直觉的结论.o 不是越小越省事有种做法是把所有源码一次性编译gcc *.c -o app。它和先编.o再链接的结果在功能上没差别但流程上差别很大分开编译时改一个.c只需要重编那一个.o其他.o直接复用一股脑编译则每次都要重新翻译所有文件。项目一大这个差距就是几分钟到几十分钟。团队协作项目里.o与增量编译几乎是绑定的这也是为什么几乎所有构建系统make、cmake、ninja都在维护源文件到.o的依赖图。3. .a 静态库把一堆 .o 打成一个包3.1 ar 打包的机制与索引.a文件本质就是一个归档文件archive里面平铺着若干.o再加一张索引表。做它用的是ar命令跟 gcc 没关系只不过大家都习惯在 gcc 工具链里一起用。ar rcs libmath.a add.o sub.o ar t libmath.a # 列出成员add.o sub.o nm libmath.a # 列出所有成员里的符号rcs三个字母分别是r插入替换同名成员、c创建不提示已存在、s生成索引。老教程里会教你ar r libmath.a *.o之后再单独跑一次ranlib libmath.a那是因为早期ar不自动建索引。现在s参数已经包含了这个动作ranlib基本可以退休了。注意库里.o的命名要唯一。如果你把两个不同目录下同名的util.o塞进同一个.a第二个会直接替换掉第一个编译能过、运行时行为诡异。构建脚本里给中间产物加路径前缀或者重命名是很常见的规避手段。3.2 从零做一个 libmath.a 并链接出可执行文件假设目录结构是这样proj/ ├── include/mathkit.h ├── src/add.c ├── src/sub.c └── app/main.c# 编译-I 指定头文件搜索路径 gcc -c -Iinclude src/add.c -o add.o gcc -c -Iinclude src/sub.c -o sub.o # 打包 ar rcs libmathkit.a add.o sub.o # 链接-L 指定库搜索路径-l 指定库名 gcc app/main.c -Iinclude -L. -lmathkit -o app_static ./app_static这里有个必须记住的换算规则-lmathkit会自动去找libmathkit.so或libmathkit.a库名要去掉lib前缀和扩展名。新手常见的错误是写-llibmathkit.a那就永远找不到。3.3 链接顺序为什么会让静态库报未定义引用这是静态库最坑的一点。下面这两条命令一条能过一条必挂gcc -L. -lmathkit app/main.c -o app_static # 报 undefined reference gcc app/main.c -L. -lmathkit -o app_static # 正常原因在于链接器ld对参数是从左到右单向扫描的。它维护两个集合已定义符号和未定义符号。扫到app/main.c时main里用到了addadd进入未定义集合接着扫到libmathkit.a链接器发现我现在需要一个 add库里正好有 add.o 能提供它于是把add.o抽出来放进去。如果顺序反了链接器先看到库此时未定义集合是空的它认为这个库里没有任何我需要的成员直接跳过库里的内容一个都不会被拉进来。等后面再遇到main.c的未定义符号时库里已经过期了。同理还有循环依赖liba.a里的代码用libb.alibb.a又反过来用liba.a此时无论谁前谁后都不行。标准解法是把它们包在 group 里让链接器反复扫gcc app/main.c -Wl,--start-group -la -lb -Wl,--end-group -o app-Wl,前缀的含义是后面的参数原样传给链接器 ldgroup 是链接器的选项而不是 gcc 的选项所以必须这么写。3.4 静态库的只抽取用到的成员特性是优点也是陷阱静态库和直接把所有.o都列出来最大的区别在于链接器只会从库里抽取当前还没有被满足的符号所在的那个.o。用不到的成员根本不会进最终可执行文件所以静态库能自动帮你做按需裁剪。但这个优点背后藏着陷阱。假如sub.o里有个全局构造函数__attribute__((constructor))或者依赖静态初始化注册一些东西而主程序从来没有直接调用sub里的任何函数那么这个sub.o就不会被抽取注册逻辑直接消失。程序能编译、能链接功能却少了一块。要强制整个库都进来用gcc app/main.c -L. -Wl,--whole-archive -lmathkit -Wl,--no-whole-archive -o app--whole-archive后面必须跟--no-whole-archive收尾否则连libc都会被整份拉进来那就有意思了。4. .so 共享库符号在运行时才兑现4.1 -fPIC 到底解决了什么问题做.so的第一步编译时必须加-fPICgcc -fPIC -c -Iinclude src/add.c -o add.o gcc -fPIC -c -Iinclude src/sub.c -o sub.o gcc -shared -o libmathkit.so add.o sub.oPIC是 Position Independent Code位置无关代码。为什么共享库必须要它因为共享库在内存里不是每个进程一份而是多个进程共享同一份物理内存页。如果代码里有绝对地址那就只能固定在某个虚拟地址上两个进程一冲突就得重定位重定位意味着要改代码页内容一改就变成进程私有共享也就无从谈起。加了-fPIC之后编译器不再生成绝对地址访问而是通过 GOT全局偏移表和 PLT过程链接表做相对寻址。代价是每次跨模块调用多一次间接跳转性能损失通常在 1% 以内换来的是代码页可以真正被多个进程复用。如果你忘了-fPIC直接编.o再-shared会看到这样一条很有辨识度的报错relocation R_X86_64_32 against .rodata can not be used when making a shared object; recompile with -fPIC报错信息里已经把解法写出来了照着加-fPIC重编就行。提示其实从实践角度看如果你的.o既要进.a又要进.so那就统一都加-fPIC。多花的这点开销可以忽略但能省掉静态库版本能过、共享库版本挂掉这种莫名其妙的来回。4.2 SONAME 与三个软链接的约定共享库有个很容易被忽略的命名惯例它直接决定了运行时能不能找到库。做过-shared之后建议按 soname 规范做三条软链接gcc -shared -Wl,-soname,libmathkit.so.1 -o libmathkit.so.1.0.0 add.o sub.o ln -sf libmathkit.so.1.0.0 libmathkit.so.1 ln -sf libmathkit.so.1 libmathkit.so三个名字各有用途缺一不可名字用途谁在用libmathkit.so.1.0.0真实文件完整版本号磁盘上的实体libmathkit.so.1SONAME主版本号运行时加载器 ld.solibmathkit.so链接名开发用链接器 ld-lmathkit关键在于链接时 gcc 用libmathkit.so找到库但写进可执行文件DT_NEEDED字段的是-Wl,-soname指定的那个名字也就是libmathkit.so.1。运行时操作系统只认libmathkit.so.1所以这条软链接必须存在。删掉libmathkit.so不影响运行删掉libmathkit.so.1就跑不起来了。主版本号的含义是 ABI 版本只要接口二进制兼容升级就覆盖libmathkit.so.1.x.y并保持.so.1链接不变已经编译好的程序不用重编一旦接口不兼容就出libmathkit.so.2两代并存互不干扰。这就是共享库能只换库不换程序的底层机制。4.3 运行时报找不到库的三条修复路径链接阶段过了运行阶段报这个./app: error while loading shared libraries: libmathkit.so.1: cannot open shared object file这是新手遇到的最高频问题之一。原因是运行时加载器 ld.so 的搜索路径里没有你的目录。它按顺序找DT_RPATH若存在且未设 RUNPATH、LD_LIBRARY_PATH、DT_RUNPATH、/etc/ld.so.cache、/lib、/usr/lib。三种修法按推荐程度排# 1. 推荐把 rpath 写进可执行文件随包分发不依赖环境变量 gcc app/main.c -Iinclude -L. -lmathkit -Wl,-rpath,$ORIGIN -o app_shared # 2. 临时调试环境变量只对本次命令生效 LD_LIBRARY_PATH. ./app_shared # 3. 系统级注册进 ldconfig 缓存 sudo cp libmathkit.so.1.0.0 /usr/local/lib/ sudo ldconfig$ORIGIN是一个占位符代表可执行文件自身所在的目录。因为整个应用是打包成目录交付的库和可执行文件放在一起用$ORIGIN就能做到拷到哪都能跑这是发布二进制包时最常用的做法。注意$ORIGIN外面的单引号不能省也不能换成双引号否则 shell 会先把$ORIGIN当成变量展开成空字符串rpath 就被写成空值了。在 Makefile 里写要写成$$ORIGIN因为 make 会先吃掉一个$。4.4 ldd、readelf -d、LD_DEBUG 三件套排查共享库问题我固定用这三条命令顺序不能乱ldd app_shared # linux-vdso.so.1 # libmathkit.so.1 ./libmathkit.so.1 (0x00007f...) # libc.so.6 /lib/x86_64-linux-gnu/libc.so.6 # /lib64/ld-linux-x86-64.so.2ldd直接告诉你每个依赖最终解析到了哪个文件、有没有not found。readelf -d app_shared | grep -E NEEDED|RPATH|RUNPATH # (NEEDED) Shared library: [libmathkit.so.1] # (RUNPATH) Library runpath: [$ORIGIN]readelf -d看的是可执行文件里静态记录的依赖名和搜索路径能验证 rpath 有没有写进去、写对没有。LD_DEBUGlibs ./app_shared 21 | head -40LD_DEBUG是运行时加载器的调试开关它会把加载器试过的每一个路径都打出来。当前面两条还看不出问题时这条能直接指出它到底去哪儿找了、在哪个目录停下的。5. .out 可执行文件链接器把碎片缝成一个整体5.1 静态链接与动态链接生成的可执行文件差在哪里同样一份源码gcc默认产出的是动态链接的可执行文件加-static才是全静态。两者最直观的差别用file一看就明白gcc hello.c -o hello_dyn gcc -static hello.c -o hello_static file hello_dyn # ELF 64-bit LSB pie executable, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2 file hello_static # ELF 64-bit LSB executable, statically linked, for GNU/Linux 3.2.0, not stripped ls -lh hello_dyn hello_static # -rwxr-xr-x 16K hello_dyn # -rwxr-xr-x 760K hello_static一个 hello world十几 KB 和七百多 KB 的差距全在libc上。动态链接版本里只写了我需要 libc.so.6真正的代码在运行时由加载器映射进来静态链接版本把用到的 libc 代码整段复制进了可执行文件。顺便说一个很多人没注意的点现代发行版的 gcc 默认生成的是PIE位置无关可执行文件所以file输出里能看到pie executable。PIE 配合 ASLR 让可执行文件本身的加载地址也是随机的属于默认的安全加固。如果你想关掉它用-no-pie但一般没有必要。5.2 全静态链接不是万能的静态链接一个文件拷到哪都能跑听起来很美但有几个真实限制glibc 的静态链接对 NSS 不友好。getaddrinfo、getpwnam这类函数在运行时需要动态加载 NSS 模块全静态时会出现告警甚至功能失效。做网络程序时这一点必须提前验证不能等到交付前才发现。dlopen在全静态下会告警很多依赖插件机制的第三方库会踩到。内核版本兼容性。静态链接的程序不依赖目标机的 libc但仍然依赖内核系统调用接口跨内核版本差太多还是可能出问题。所以更常见的折中是只静态运行时库、业务库仍然动态g main.cpp -static-libgcc -static-libstdc -o app这一招主要用来解决后面要讲的GLIBCXX版本缺失问题。5.3 交付前必查的几个属性.out、.o、.a、.so全都是 ELF 格式所以检查手法是通用的。我一般在交付前固定跑一遍命令看什么判断标准file 目标文件类型与链接方式是 relocatable 还是 executableldd 目标动态依赖能否解析不允许出现not foundsize 目标text/data/bss 三段体积排查体积异常的来源nm -C 目标符号表确认导出符号是否正确readelf -d 目标DT_NEEDED / RPATH确认依赖名与搜索路径strings 目标 | grep GCC编译工具链版本排查 ABI 事故另外发布前用strip app去掉符号表能把体积减掉一大截代价是 core dump 的调用栈会缺符号名。开发阶段一定要留符号生产环境再 strip别搞反了。6. 四种产物的选型与混用别凭感觉决定6.1 一张表看清四种产物的定位产物ELF 类型能否单独运行何时产生典型使用场景.oREL否gcc -c增量编译、打包成库.aar 归档否ar rcs单文件交付、裁剪体积.soDYN否需被加载gcc -shared多程序共享、可独立升级.outEXEC / DYN是gcc链接最终交付给用户运行这张表的重点在最后一列.o和.a、.so都不是给最终用户的东西它们是给构建过程或者加载器的中间形态。把.o交付出去、把.a当成.so去LD_LIBRARY_PATH引用都是概念没理清导致的低级失误。6.2 什么时候必须用 .a什么时候必须用 .so选静态库.a的场景通常有这几个特征交付物要求是单文件目标环境不允许安装额外库或者目标机权限受限装不了/usr/local/lib依赖的第三方库版本混乱宁可把代码固化进来也不愿意处理版本冲突库本身很小动态加载带来的收益还不如管理成本高有硬性性能要求希望避免 PLT 间接跳转带来的开销选共享库.so的场景则相反同一份库代码被多个程序使用希望内存里只保留一份物理页需要在不重新编译主程序的情况下修复库里的 bug这就是所谓的灰度升级插件化架构主程序运行时用dlopen按需加载不同实现库很大比如图形、数据库驱动不可能塞进每个可执行文件6.3 同名库同时存在ld 会选谁这是一个非常隐蔽的问题。如果-L指定的目录里同时有libmathkit.so和libmathkit.a链接器默认优先选.so。你可能以为自己在做静态链接实际生成的是动态依赖然后部署到没有库的机器上就炸了。强制走静态有两种写法# 写法一直接给静态库全路径绕开 -l 的自动选择 gcc app/main.c -Iinclude ./libmathkit.a -o app_static # 写法二用 Bstatic/Bdynamic 显式切换 gcc app/main.c -Iinclude -L. -Wl,-Bstatic -lmathkit -Wl,-Bdynamic -o app_static写法二要注意-Wl,-Bdynamic必须补上否则后面-l出来的 libc 也会被静态链接那你就会得到一个又大又可能有 NSS 问题的东西。7. 那些年踩过的坑以及完整的排查链路7.1 undefined reference 的六种成因按概率排序第一条忘了把.o或库加进去。gcc main.o -o app少了add.o链接器直接告诉你undefined reference to add。第二条库的链接顺序错了。前面第 3.3 节讲过-l必须放在使用它的目标文件之后。第三条C 与 C 混编没加extern C。C 有名字改编机制add在 C 里会被编成_Z3addii这样的符号名C 编译器生成的add跟它对不上。头文件里加#ifdef __cplusplus extern C { #endif int add(int a, int b); #ifdef __cplusplus } #endif第四条声明与定义签名不一致。比如头文件声明int add(int, int)实现里写成了int add(int, long)在 C 里这是两个完全不同的重载符号在 C 里虽然能编过但const、参数个数不一致一样会出问题。第五条符号被隐藏了。如果库编译时用了-fvisibilityhidden那么除了显式标注__attribute__((visibility(default)))的符号其余全都不导出外部自然找不到。这是很多第三方库接口明明在、就是链不上的真实原因。第六条架构不匹配。在 x86_64 上拿着 arm64 的.a去链接报错信息里的关键字是file format not recognized或者incompatiblefile libxxx.a一看就露馅。排查顺序我一般是这样先nm -C看未定义符号到底叫什么再到候选库里用nm -C 库名 | grep 符号名查它是否真的存在最后检查链接顺序和混淆问题。这三步能覆盖九成以上情况。7.2 链接期能过、运行期才炸的 undefined symbol有一个比链接报错更难缠的情况.so自身的未定义符号默认情况下链接器不检查。也就是说liba.so里用到了func_b但你没把libb.so链接进来gcc -shared照样成功运行dlopen或者首次调用时才报undefined symbol: func_b。要让它提前暴露编译共享库时加gcc -shared -Wl,--no-undefined -o libmathkit.so add.o sub.o -lm--no-undefined会让链接器要求所有符号在链接期就能解析把运行期事故提前到构建期。这个选项在新项目的构建脚本里我基本是默认加的能省掉大量线上排查。7.3 GLIBCXX_3.4.29 not found 与跨机器 ABI这是跨环境部署最常见的坑。在开发机上比如 GCC 13编译的程序拿到目标机比如 GCC 9上跑./app: /lib/x86_64-linux-gnu/libstdc.so.6: version GLIBCXX_3.4.29 not found原因是符号版本化libstdc 给每个 ABI 版本的符号打了标记新版本编译器生成的代码引用的是新标记老库上根本没有这个标记。解决方案按推荐度排列在目标环境或版本更低的容器镜像里编译。这是最干净的方案CI 里用低版本基础镜像构建是通行做法。加-static-libstdc -static-libgcc把 C 运行时静态链进去。体积会增加一点但能彻底摆脱这个依赖。只在编译机上装新 libstdc 拷过去风险是可能引发其他程序异常一般不推荐。反过来 glibc 也会报类似version GLIBC_2.34 not found处理思路相同但 glibc 是不能用-static-libc那种方式绕的只能在低版本环境编译。提示项目里统一在 CI 的低版本镜像构建、开发机只做本地调试是避免这类事故最省心的组织方式。7.4 库明明换了行为却一点没变几种可能挨个排查加载的根本不是你以为的那个文件。ldd app会告诉你真实路径。系统里同时存在/usr/lib/libfoo.so.1和/usr/local/lib/libfoo.so.1是常见情况LD_LIBRARY_PATH的优先级又高于 ldconfig 缓存很容易加载到旧的。ldconfig 缓存没更新。手工往/usr/local/lib拷了库但没跑sudo ldconfig加载器看不到。用ldconfig -p | grep 库名确认缓存里的路径和版本。覆盖方式不对。正在被进程映射的.so用cp直接覆盖时如果 inode 没变某些情况下是原地写已运行的进程看不到新内容新启动的进程能看到。用cp到临时文件再mv原子替换更稳妥。生产环境更规范的做法是并发部署新版本文件、再切换软链接。程序里被静态链了旧版本。如果之前用--whole-archive或者直接给了.a路径那换.so完全不影响程序里那份代码是编译进去的。7.5 gcc 本身的环境问题偶尔会遇到更底层的问题装完 gccgcc -v还是显示旧版本。几种常见原因which -a gcc # 看 PATH 里所有 gcc ls -l /usr/bin/gcc # 可能是软链接到 gcc-11 update-alternatives --config gcc # 多版本共存时的切换入口 hash -r # 清掉当前 shell 的命令路径缓存最后那条hash -r很实用shell 会缓存可执行文件的路径刚切完版本时gcc可能还指向老的hash -r清一下就好。至于安装本身如果apt装完仍提示找不到先确认apt update执行过、PATH 里包含/usr/bin。离线环境里装 gcc 更容易出问题因为 gcc 依赖cpp、binutils含as、ld、ar等一串包只装gcc一个包往往会缺件。用dpkg -l | grep -E gcc|binutils对照一下比较稳妥。8. 一个可复现的完整工程源码到三种交付形态8.1 目录结构与源码/* include/mathkit.h */ #ifndef MATHKIT_H #define MATHKIT_H #ifdef __cplusplus extern C { #endif int add(int a, int b); int sub(int a, int b); #ifdef __cplusplus } #endif #endif/* src/add.c */ #include mathkit.h int add(int a, int b) { return a b; }/* src/sub.c */ #include mathkit.h int sub(int a, int b) { return a - b; }/* app/main.c */ #include stdio.h #include mathkit.h int main(void) { printf(2 3 %d\n, add(2, 3)); printf(2 - 3 %d\n, sub(2, 3)); return 0; }8.2 Makefile同一份源码产出三种形态CC : gcc CFLAGS : -O2 -Wall -Iinclude PICFLAG : -fPIC OBJS : add.o sub.o LIBNAME : mathkit SONAME : lib$(LIBNAME).so.1 REALNAME : lib$(LIBNAME).so.1.0.0 all: lib$(LIBNAME).a lib$(LIBNAME).so app_static app_shared # 所有中间 .o 统一带 -fPIC静态库和共享库共用 %.o: src/%.c include/$(LIBNAME).h $(CC) $(CFLAGS) $(PICFLAG) -c $ -o $ # 静态库 lib$(LIBNAME).a: $(OBJS) ar rcs $ $^ # 共享库带 soname 和三条软链接 lib$(LIBNAME).so: $(OBJS) $(CC) -shared -Wl,-soname,$(SONAME) -Wl,--no-undefined -o $(REALNAME) $^ ln -sf $(REALNAME) $(SONAME) ln -sf $(SONAME) $ # 静态链接版本直接给 .a 全路径避免被同名 .so 抢走 app_static: app/main.c lib$(LIBNAME).a $(CC) $(CFLAGS) $ ./lib$(LIBNAME).a -o $ # 动态链接版本写 rpath拷到哪都能跑 app_shared: app/main.c lib$(LIBNAME).so $(CC) $(CFLAGS) $ -L. -l$(LIBNAME) -Wl,-rpath,$$ORIGIN -o $ clean: rm -f *.o *.a *.so *.so.* app_static app_shared .PHONY: all clean跑一遍make ./app_static # 2 3 5 # 2 - 3 -1 ./app_shared # 2 3 5 # 2 - 3 -1 ldd ./app_shared # libmathkit.so.1 ./libmathkit.so.1 # libc.so.6 /lib/x86_64-linux-gnu/libc.so.6 ldd ./app_static # not a dynamic executable ls -lh app_static app_shared # 16K app_shared # 17K app_static这个例子里两个可执行文件的体积差不多因为业务库太小了静态链接省下来的只有 libc 那一份。真正拉开差距的是引用了图形库、加密库这类大依赖的项目。8.3 验证交付包是否自洽把app_shared、libmathkit.so.1.0.0、libmathkit.so.1三个文件拷到一个干净目录然后把原目录整个删掉再运行mkdir /tmp/pkg cp app_shared libmathkit.so.1.0.0 libmathkit.so.1 /tmp/pkg/ cd /tmp/pkg ln -sf libmathkit.so.1.0.0 libmathkit.so.1 ldd ./app_shared # 应该显示 libmathkit.so.1 ./libmathkit.so.1 ./app_shared这一步很有必要。因为在构建目录里跑得好好的很可能只是因为LD_LIBRARY_PATH或者当前目录恰好被搜到了。换到干净目录跑一次才是真正的交付验证。8.4 改一个纯头文件时的连锁反应最后分享一个小技巧。如果你只改了include/mathkit.h里的一个函数声明严格来说所有#include它的.c都要重编因为头文件内容是直接展开进.c的Makefile 里就得把头文件写进依赖就像上面%.o: src/%.c include/$(LIBNAME).h那样。但项目一大手写头文件依赖会漏常见做法是让编译器自己生成依赖文件CFLAGS -MMD -MP -include $(OBJS:.o.d)-MMD让 gcc 在编译时顺便输出一份.d依赖文件-MP为每个头文件补一条假目标避免头文件被删后 make 报错-include把这些.d悄悄引入。这三行加上去改任何头文件都能触发正确的重编比自己维护依赖列表靠谱得多。我个人在交付带.so的项目时还有个小习惯把所有构建产物用readelf -d和ldd过一遍再打包确认DT_NEEDED里没有绝对路径、没有开发机上的临时目录RUNPATH是$ORIGIN而不是/home/xxx/build。这种低级问题出一次对方的排查成本可能比你重新构建一遍高得多。
返回列表