资讯详情

资讯详情

Linux C/C++开发必学:GCC编译、静态库动态库与Makefile实战

在Linux下做C/C开发脱离了GCC和Makefile几乎是寸步难行——而恰恰是这两个东西劝退了不少刚从IDE环境转过来的朋友。你可能会想我明明用一行gcc就能编译出程序为什么还要学库的机制为什么还要写Makefile为什么同一个程序在这台机器上跑得好好的换一台就报cannot open shared object file这篇是Linux开发系列的第三篇就专门把GCC编译、静态库动态库、基础Makefile和深入Makefile串起来讲清楚帮助你把能编译变成懂编译。1. GCC那一条命令背后其实藏了四个独立的步骤1.1 预处理、编译、汇编、链接各干各的活很多人在IDE里点一下运行就完事对底层发生了什么没有概念。但到了Linux命令行环境你写下的gcc -o app main.c看起来是一条命令GCC内部却做了四件完全不同的工作预处理、编译、汇编、链接。这四个阶段各有各的输入输出也各有各的报错风格。打个比方这就像你要出一本书。预处理是把稿子中的引用和宏定义全部替换成正文编辑把所有#include、#define展开生成一份真正完整的原始稿编译是把中文翻译成英文把C代码翻译成汇编指令汇编是把翻译好的稿子打印成单页文件每条汇编指令变成机器码生成目标文件.o链接则是把几百页单页文件装订成一本成品书把所有目标文件、库文件合并成最终可执行文件。你可以在任意一步停手看看中间产物长什么样gcc -E main.c -o main.i # 预处理展开所有头文件和宏 gcc -S main.i -o main.s # 编译成汇编文件 gcc -c main.s -o main.o # 汇编成目标文件此时已经是二进制 gcc main.o -o app # 链接生成可执行文件很多时候你根本不关心中间产物所以GCC提供了一个复合流程一条gcc -o app main.c直接走完。但理解四个阶段对你排查问题有决定性帮助报错时如果箭头指向某个头文件里的宏那是预处理阶段的问题如果指向了undefined reference to xxx那是链接阶段的问题——链接报错的排查思路和编译报错完全不一样这个后面展开讲。1.2 编译参数不是背出来的是用场景推出来的新手看GCC参数列表会觉得眼花缭乱其实你只需要围绕几个实际场景去记忆参数自然就串起来了。参数作用典型场景-E只做预处理排查宏展开、头文件包含问题-S只生成汇编看编译器生成的汇编代码做底层调试-c只编译不链接生成.o为后续打包库或批量编译做准备-o file指定输出文件名所有阶段通用-I目录添加头文件搜索路径工程头文件不在默认路径时使用-L目录添加库文件搜索路径链接时告诉链接器去哪里找.so或.a文件-l名字链接指定名字的库如-lm链数学库-lpthread链线程库-g生成调试信息配合GDB调试建议开发阶段一直开着-Wall打开常见警告让潜在问题提前暴露强烈建议开启-O2开启二级优化发布版本常用调试版本别用否则变量信息错乱-fPIC生成位置无关代码创建动态库必须加-shared生成共享库配合-fPIC使用-stdc11指定语言标准老代码可能需要降级为c99甚至gnu89-D宏名定义宏相当于在代码开头加#define我个人的固定写法是调试阶段用gcc -g -Wall -O0发布阶段换gcc -O2 -Wall动态库构建用gcc -fPIC -shared。这几个组合记住了覆盖日常90%的需求。1.3 链接选项顺序是个隐性坑这里提前说一个我踩过的坑-l库参数的顺序不是随便写的。链接器在解析目标文件时是从左到右扫描的遇到一个未解析的符号会去右边的库中查找。假设你的主程序依赖libAlibA又依赖libB那么正确写法是gcc main.o -lA -lB -o app如果写成了-lB -lA链接器扫描到main.o时还没看到任何库接着遇到libB发现它里面的符号没人需要直接忽略再遇到libA时才找到缺失的符号但此时libB已经被抛在脑后了结果就是undefined reference。这个顺序问题在大型工程里特别容易让人抓狂因为明明每个库都存在就是链接不过。2. 静态库与动态库从ar打包到ldd排查的完整链路2.1 为什么非要有库这种东西代码写多了你会发现很多功能是重复的字符串处理、数学计算、文件操作……如果每个程序都把源码复制一份维护成本吃不消。库就是把一组常用功能预先编译打包给别的程序调用。静态库相当于把实现代码直接复印到你的程序里动态库则像图书馆你的程序运行时按需去借阅。静态库在Linux下通常命名为libxxx.a用ar工具打包动态库命名为libxxx.so用GCC的-shared生成。两者各有优劣对比项静态库.a动态库.so链接时机编译链接时嵌入可执行文件程序运行时才加载可执行文件大小大小运行时依赖无拷贝即可运行目标机器必须有对应.so升级维护需要重新链接只需替换.so程序免重编内存占用每个进程各有一份多个进程共享同一份内存映射从我实际项目的经验看工具类小程序用静态库省心需要频繁升级或体积敏感的模块用动态库有些开源依赖则只提供静态库或只提供动态库你没有选择空间。明白原理比纠结选哪个更重要。2.2 静态库ar rcs一句话收工假设你有add.c和sub.c两个源文件要给别的程序提供加法、减法功能。先把它们分别编译成目标文件gcc -c add.c -o add.o gcc -c sub.c -o sub.o然后用ar打包成静态库ar rcs libcalc.a add.o sub.or表示插入文件c表示创建库s表示生成索引——索引能让链接器快速查找符号不能省。打包完后可以用ar t libcalc.a查看库里的目标文件列表用nm libcalc.a查看里面定义了哪些符号这两个命令排查为什么链接不到时非常有用。程序要使用这个库编译命令变为gcc main.c -L. -lcalc -o app这里-L.告诉链接器在当前目录找库-lcalc会自动匹配libcalc.a或libcalc.so。你只需要记住规则库文件名去掉lib前缀和.a/.so后缀剩下的名字就是-l参数的内容。2.3 动态库-fPIC和-shared必须成对出现动态库的编译命令和静态库完全不同gcc -fPIC -shared add.c sub.c -o libcalc.so-fPIC生成位置无关代码意思是代码本身不依赖固定的加载地址运行时可以被加载到内存的任意位置。不加这个参数多个进程共享同一个动态库时可能会出现问题。-shared告诉GCC输出的是共享库而不是可执行文件。生成后同样用gcc main.c -L. -lcalc -o app编译。注意一个问题当前目录如果没有libcalc.a只有libcalc.so链接器会直接用动态库如果两个都存在默认优先选.so。你需要明确知道当前链接的是哪一个否则很容易出现编译过了但运行版本不对的诡异问题。2.4 编译通过了却运行不了——动态库搜索路径的真相这是我从入门到工作这么多年看到过最多新人栽跟头的地方编译命令没问题链接时也没有报错但一运行就报error while loading shared libraries: libcalc.so: cannot open shared object file: No such file or directory根因在于链接器找库和运行时找库走的是两套完全不同的路径机制。编译时-L参数指定的目录有效头文件用-I指定这些都只影响编译链接阶段。运行时操作系统只从默认路径如/lib、/usr/lib和/etc/ld.so.conf.d/配置的路径里查找动态库不会自动去你的当前目录翻。我当时调试自己的一个动态库Demo时最直接的解决方法是设置环境变量export LD_LIBRARY_PATH. ./appLD_LIBRARY_PATH告诉动态链接器额外搜索哪些目录。注意这个环境变量只对当前终端有效想永久生效要写进shell配置但我不建议把本地开发目录写进全局配置那会成为安全隐患。更正规的做法是写入系统加载配置sudo cp libcalc.so /usr/local/lib/ sudo ldconfig如果想搞清楚一个可执行文件到底依赖哪些动态库、它们分别在哪里用ldd命令ldd app输出的每一行都展示一个被依赖的库及其实际解析路径如果某一行显示not found说明该库在当前系统的搜索路径中不存在。ldd是我排查运行时缺失依赖的第一工具没有之一。3. 基础Makefile先把目标、依赖、命令这三个词刻在脑子里3.1 为什么还要学Makefile直接gcc不行吗当你只有一个源文件时确实一行gcc够了。但工程一旦有多个源文件、多个库、频繁的增量修改你就会发现问题每次改一个文件难道全部重新编译Makefile解决的核心问题就两个记录构建规则和判断哪些文件需要重新构建。Makefile的判断依据是文件时间戳如果目标文件不存在或者某个依赖文件比目标文件新就重新执行对应命令。这就是增量编译的底层逻辑。理解了这个原理你就能看懂Makefile的一切语法设计——它本质上是一个带依赖关系的自动化脚本。3.2 最小可用的Makefile长什么样写一个最简单的Makefile假设工程只有main.capp: main.c gcc -o app main.c第一行app是目标冒号后面main.c是依赖。第二行是生成目标要执行的命令。注意几个细节命令前面必须是一个Tab字符不能用空格替代——这是新手第一次写Makefile几乎必踩的坑make会直接报missing separator。默认执行make时make会寻找第一个目标作为最终目标。上面这段已经能工作但太简陋了。工程稍微复杂一点标准写法需要引入中间目标文件app: main.o gcc -o app main.o main.o: main.c gcc -c main.c -o main.o clean: rm -f app main.o这个结构里如果我改了main.cmake发现main.o比main.c旧就去编译main.omain.o更新后app又会重新链接。增量编译就这样跑起来了。3.3 变量与自动变量告别硬编码上面每个文件名都要写好几遍改个名字要改三处太容易出错。引入变量的做法CC gcc CFLAGS -Wall -g TARGET app OBJS main.o utils.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $(TARGET) $(OBJS) main.o: main.c utils.h $(CC) $(CFLAGS) -c main.c -o main.o utils.o: utils.c utils.h $(CC) $(CFLAGS) -c utils.c -o utils.o clean: rm -f $(TARGET) $(OBJS)现在只需改文件顶部变量其他位置自动跟着变。但每个.c文件都写一条编译规则还是很繁琐尤其源文件数量多了以后。这里开始引入自动变量让规则更通用main.o: main.c utils.h $(CC) $(CFLAGS) -c $ -o $$表示第一个依赖文件$表示目标文件名。于是main.o: main.c utils.h这条规则里$就是main.c$就是main.o。还有$^表示所有依赖文件。这三个自动变量覆盖率极高。3.4 伪目标为什么需要.PHONY上面的clean有一个潜在风险万一当前目录恰好存在一个名为clean的文件make会认为目标已经是最新直接跳过命令。解决办法就是把它声明为伪目标.PHONY: clean clean: rm -f $(TARGET) $(OBJS).PHONY告诉make这个名字不对应真实文件每次都必须执行命令。除了clean常见的伪目标还有all、install、test凡是名字不和文件名绑定的都建议声明成伪目标。这是个需要刻意养成的好习惯。4. 深入Makefilewildcard、自动依赖和多目录工程的组织方式4.1 源文件多了用wildcard自动收集前面那种每个.o写一条规则的写法在源文件超过十个时就开始变得笨重。工程里源文件列表应该自动收集而不是手写。wildcard函数就是干这个的SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c, build/%.o, $(SRCS))第一行把所有src/目录下的.c文件收集起来第二行用patsubst把每个src/xxx.c转换成对应的build/xxx.o。以后新增源文件Makefile不需要改一行。如果还想按需排除某些文件可以用filter-out函数比如排除测试文件SRCS : $(filter-out src/test_%.c, $(wildcard src/*.c))这些函数在自动化处理文件列表时几乎天天用值得花十分钟过一遍官方文档里的函数说明。4.2 静态模式规则用一条规则编译所有.c文件有了文件列表接下来是让make自动为每个.c文件执行编译命令。静态模式规则是这时的最优解build/%.o: src/%.c $(CC) $(CFLAGS) -c $ -o $这个%.o模式会自动匹配所有需要生成的.o文件并把对应的.c文件作为依赖。加上前面的OBJS变量整个核心骨架就出来了CC : gcc CFLAGS : -Wall -O2 -g TARGET : bin/app SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c, build/%.o, $(SRCS)) $(TARGET): $(OBJS) $(CC) -o $(TARGET) $(OBJS) build/%.o: src/%.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -rf build bin .PHONY: clean注意build/和bin/目录需要先创建可以在规则里加一条mkdir -p命令$(TARGET): $(OBJS) mkdir -p bin $(CC) -o $(TARGET) $(OBJS) build/%.o: src/%.c mkdir -p build $(CC) $(CFLAGS) -c $ -o $4.3 头文件改动后make不知道——自动依赖生成如果你按上面的规则直接跑很快会遇到一个隐蔽问题我改了utils.h但main.c和utils.c都依赖它make却没有任何反应因为它只记录了.c文件与.o文件的依赖并不知道.h文件。这个坑比前面所有坑加起来都折磨人因为程序往往能编译成功——只是用了旧的头文件内容。解决方案是让编译器自己生成依赖关系。GCC提供了-MMD选项在编译每个.c文件的同时生成一个.d文件里面记录了该.c文件包含的所有头文件依赖gcc -MMD -MP -c src/main.c -o build/main.o生成的内容大致是build/main.o: src/main.c src/utils.h include/common.h然后在Makefile里把这些.d文件包含进来DEPS : $(OBJS:.o.d) -include $(DEPS)-include前面的减号表示如果.d文件不存在也不要报错首次编译时确实还没有。此后任何头文件改动make都能精确定位到需要重编译的.o文件并触发规则。这套机制我强烈建议从一开始就写进工程模板免得后面返工。4.4 多目录工程的结构与VPATH工程稍微正式一点就会分目录src/放源码include/放头文件lib/放第三方库build/放中间文件bin/放成品。此时Makefile里需要额外指定头文件搜索路径和库搜索路径INC_DIR : include LIB_DIR : lib CFLAGS : -Wall -O2 -g -I$(INC_DIR) LDFLAGS : -L$(LIB_DIR) LIBS : -lcalc -lm链接目标时$(TARGET): $(OBJS) $(CC) -o $(TARGET) $(OBJS) $(LDFLAGS) $(LIBS)如果源码分布在多个子目录比如src/core/、src/utils/可以用VPATH或vpath告诉make去哪里找源文件VPATH src/core:src/utils这样build/%.o: %.c就能匹配到多个目录下的源文件而不用为每个子目录写单独模式。多目录工程的核心原则是源文件收集、头文件路径、库路径、输出目录四件事在文件开头写清楚剩下的交给模式规则。5. 库与Makefile实测中遇到过的几个坑和我的固定写法5.1 静态库和动态库同名时优先选择的结果或许不是你要的我早先调试一个模块时当前目录下同时存在libcalc.a和libcalc.so链接命令写的是-lcalc。我以为链接的是静态库后来用ldd查看可执行文件发现它依赖的动态库是libcalc.so——链接器默认优先动态库。想让某个模块强制用静态版本只需显式给出文件路径gcc main.c ./libcalc.a -o app或者加-static全局静态链接不推荐会产生大量兼容性问题。我现在的习惯是目录里同时存在两种库时我会先执行ls -l libcalc.*看清楚都有什么再决定用文件名直接指定还是靠-l自动匹配以免链接结果与自己预期不符。5.2 动态库名与-l参数的对齐问题动态库的命名规则是libxxx.so但如果某个第三方库提供的文件名是xxx.so.1.2带版本号-lxxx依然能匹配前提是存在一个名为libxxx.so的软链接指向它这也是标准做法ln -s libxxx.so.1.2 libxxx.so花十分钟为库建立libxxx.so的软链接不仅方便编译也让后续维护者一眼就能看出这个库存在。我见过不少团队把带版本号的库直接丢在目录里编译时还要临时改Makefile属于本来可以避免的额外工作。5.3 make -j并行编译的偶发错误为了提速我习惯用make -j8但工程里如果多个目标写到同一个输出文件、或者依赖关系不完整并行编译会随机出现文件被占用找不到目标等奇怪报错。这类问题一般不是工具问题而是规则没有正确声明依赖。我的处理方式很固定先用不带-j的命令串行编译一遍确认规则本身无误再并行编译一旦出现随机错误就把可疑规则单独拆出来执行。特别是自定义命令里用到临时文件的要给不同目标分配不同临时文件名避免竞争。5.4 我现在的工程级Makefile模板下面这个模板是我这些年沉淀下来、直接拿来改就能用的版本算是把前面所有要素浓缩在了一起CC : gcc CFLAGS : -Wall -Wextra -O2 -g -Iinclude LDFLAGS : -Llib LIBS : -lcalc -lm SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c, build/%.o, $(SRCS)) DEPS : $(OBJS:.o.d) TARGET : bin/app all: $(TARGET) $(TARGET): $(OBJS) mkdir -p bin $(CC) -o $ $(OBJS) $(LDFLAGS) $(LIBS) build/%.o: src/%.c mkdir -p build $(CC) $(CFLAGS) -MMD -MP -c $ -o $ -include $(DEPS) clean: rm -rf build bin .PHONY: all clean用这个模板时把源文件放进src/、头文件放进include/、第三方库放进lib/运行make就能得到bin/app。依赖点就一句话C源文件放对目录、库名写对其他一切自动。我在实际项目中反复用它搭建新工程几乎没有再为构建配置花过额外时间。编译这条链路学到这个程度基本上Linux下的中小规模C/C工程你都能驾驭。后续如果要深入可以继续看交叉编译、GDB调试、链接脚本但那些都是在今天的底盘上盖楼。先把GCC的参数语义、库的加载机制、Makefile的依赖推导吃透后面学什么都快。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →