7、Makefile实战:嵌入式C项目:构建一个包含多目录、多文件的嵌入式C项目,自动生成依赖,增量编译的实现。
好,咱们今天来点真格的。
前面几章讲了Makefile的语法、变量、函数,也聊了CMake的基本玩法。但说实话,那些都是“零件”。今天这一章,咱们要把这些零件组装起来,真正做一个能用的嵌入式C项目构建系统。
我当年刚入行的时候,接手过一个老项目。那个项目的Makefile,怎么说呢……是一个文件,三千多行。每次编译都要全量构建,喝杯咖啡回来还没编完。后来我实在受不了,花了一个周末重写了整个构建系统。今天要讲的,就是那次重构后沉淀下来的核心思路。
本章核心目标:一个支持多目录、自动依赖生成、增量编译的嵌入式C项目Makefile。
7.1 项目结构设计
先看看我们要构建的项目长什么样。嵌入式项目嘛,通常都有这么几个目录:
project/
├── Makefile
├── src/
│ ├── main.c
│ ├── uart/
│ │ ├── uart.c
│ │ └── uart.h
│ ├── spi/
│ │ ├── spi.c
│ │ └── spi.h
│ └── timer/
│ ├── timer.c
│ └── timer.h
├── inc/
│ └── common.h
└── build/
├── obj/
└── dep/
这个结构很典型。src下面按模块分目录,inc放全局头文件,build放编译产物。我个人习惯把目标文件和依赖文件分开存放,这样清理的时候方便。
你可能会问:“为什么不用IDE?点一下编译不就行了?”嗯,IDE确实方便。但嵌入式项目往往要跨平台、跨工具链,而且CI/CD流水线里跑的是命令行。Makefile是那个“一次写好,到处运行”的东西。
7.2 核心Makefile实现
好,咱们直接上代码。这个Makefile我拆成几个部分来讲,每一部分都有它的道理。
7.2.1 变量定义与工具链配置
# 工具链
CC = arm-none-eabi-gcc
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy
SIZE = arm-none-eabi-size
# 编译选项
CFLAGS = -mcpu=cortex-m4 -mthumb -Wall -O2 -g
CFLAGS += -I./inc -I./src
LDFLAGS = -T link.ld -nostartfiles
# 目录定义
SRC_DIR = ./src
INC_DIR = ./inc
BUILD_DIR = ./build
OBJ_DIR = $(BUILD_DIR)/obj
DEP_DIR = $(BUILD_DIR)/dep
TARGET = $(BUILD_DIR)/firmware.elf
这里有个小细节:-I./inc -I./src。为什么要把src也加进去?因为有些头文件和源文件放在同一个目录下,比如uart.h就在uart.c旁边。如果不加这个路径,编译器就找不到了。我曾经在这个坑里摔过一次,排查了半天才发现是头文件路径没加全。
7.2.2 源文件与目标文件自动收集
# 递归查找所有.c文件
SRCS = $(shell find $(SRC_DIR) -name "*.c")
# 生成对应的目标文件路径
OBJS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS))
# 生成依赖文件路径
DEPS = $(patsubst $(SRC_DIR)/%.c, $(DEP_DIR)/%.d, $(SRCS))
这里用了find命令递归查找。你想想看,如果项目有几十个目录,手动一个个列出来得多痛苦。自动收集才是正道。
但要注意:find是shell命令,不是Makefile内置函数。这意味着它在Windows上可能不好使。如果你在Windows下用MinGW或者MSYS2,问题不大。但如果是纯Windows环境,你可能得换成$(wildcard ...)配合$(shell dir /s /b ...)。嗯,跨平台总是要付出一点代价的。
7.2.3 自动依赖生成
这是本章的重头戏。自动依赖生成,说白了就是让编译器帮你算出每个.c文件依赖了哪些.h文件。这样当某个.h文件改了,只有依赖它的.c文件才会重新编译。
# 编译规则:生成.o和.d文件
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(dir $@)
@mkdir -p $(DEP_DIR)/$(dir $*)
$(CC) $(CFLAGS) -MMD -MP -MF $(DEP_DIR)/$*.d -c $< -o $@
关键就在-MMD -MP -MF这三个选项:
-MMD:生成依赖文件,但不包含系统头文件。嵌入式项目里系统头文件基本不变,没必要追踪。-MP:为每个依赖的头文件生成一个伪目标。这能防止头文件被删除后Make报错。我吃过这个亏——删了一个不再需要的头文件,结果make直接罢工了。-MF:指定依赖文件的输出路径。
生成的.d文件长这样:
build/obj/uart/uart.o: src/uart/uart.c src/uart/uart.h inc/common.h
src/uart/uart.h:
inc/common.h:
看到没?每个头文件后面跟了一个空规则。这就是-MP的作用——告诉Make:“这个头文件如果不存在,别报错,忽略就行。”
7.2.4 包含依赖文件
# 包含所有生成的依赖文件
-include $(DEPS)
前面加了个-,意思是“如果文件不存在,忽略错误”。第一次编译时.d文件还没生成,不加这个-的话Make会直接报错退出。
这里有个顺序问题:-include必须放在规则定义之后。因为Make需要先知道怎么生成.d文件,然后才能去包含它们。如果顺序反了,第一次编译时Make会找不到规则。
7.2.5 链接与最终目标
$(TARGET): $(OBJS)
$(LD) $(LDFLAGS) $^ -o $@
$(SIZE) $@
.PHONY: all clean
all: $(TARGET)
clean:
rm -rf $(BUILD_DIR)
链接这一步其实没什么花头。但我在$(TARGET)后面加了个$(SIZE)命令,输出固件大小。嵌入式项目对flash和RAM大小很敏感,每次编译完看一眼大小,心里有个数。
7.3 增量编译的原理
为什么这个Makefile能实现增量编译?说白了就是Make的“时间戳比较”机制。
Make会检查每个目标文件和它的依赖文件的时间戳。如果依赖比目标新,就重新编译。否则跳过。
举个例子:你改了uart.h,Make发现uart.o依赖uart.h,而uart.h比uart.o新,于是重新编译uart.c。但spi.o不依赖uart.h,所以不动。这就是增量编译的核心逻辑。
小技巧:如果你改了头文件但不想重新编译所有依赖它的源文件,可以用touch命令只更新那个头文件的时间戳。但说实话,我建议你老老实实重新编译——省那点时间,不如省点排查bug的时间。
7.4 完整Makefile一览
把上面所有片段拼起来,就是一个完整的Makefile:
# 工具链
CC = arm-none-eabi-gcc
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy
SIZE = arm-none-eabi-size
# 编译选项
CFLAGS = -mcpu=cortex-m4 -mthumb -Wall -O2 -g
CFLAGS += -I./inc -I./src
LDFLAGS = -T link.ld -nostartfiles
# 目录定义
SRC_DIR = ./src
INC_DIR = ./inc
BUILD_DIR = ./build
OBJ_DIR = $(BUILD_DIR)/obj
DEP_DIR = $(BUILD_DIR)/dep
TARGET = $(BUILD_DIR)/firmware.elf
# 源文件与目标文件
SRCS = $(shell find $(SRC_DIR) -name "*.c")
OBJS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS))
DEPS = $(patsubst $(SRC_DIR)/%.c, $(DEP_DIR)/%.d, $(SRCS))
# 编译规则
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(dir $@)
@mkdir -p $(DEP_DIR)/$(dir $*)
$(CC) $(CFLAGS) -MMD -MP -MF $(DEP_DIR)/$*.d -c $< -o $@
# 包含依赖文件
-include $(DEPS)
# 链接
$(TARGET): $(OBJS)
$(LD) $(LDFLAGS) $^ -o $@
$(SIZE) $@
.PHONY: all clean
all: $(TARGET)
clean:
rm -rf $(BUILD_DIR)
这个Makefile大概40行。40行,搞定一个多目录、自动依赖、增量编译的嵌入式项目构建系统。比那个三千行的老古董清爽多了吧?
7.5 核心逻辑流程图
下面这张图展示了整个构建系统的核心流程。我画的时候特意把“依赖生成”和“增量编译”这两个关键环节突出了一下。
7.6 避坑指南
最后,分享几个我踩过的坑:
坑1:依赖文件没生成就编译
第一次编译时,.d文件还不存在。-include虽然不会报错,但Make也拿不到依赖信息。所以第一次编译永远是全量编译。这其实没问题——反正第一次本来就要全量编译。但如果你在-include之前加了其他规则,可能会出问题。我的建议是:把-include放在所有规则之后。
坑2:头文件路径遗漏
如果某个头文件不在-I指定的路径里,编译器会报错。但更隐蔽的问题是:即使你手动加了-I路径,如果路径写错了,编译器找不到头文件,依赖也不会生成。我曾经因为路径末尾多了一个空格,排查了整整两个小时。
坑3:清理不彻底
如果你改了项目结构——比如新增了一个目录,或者删除了一个源文件——旧的.d文件可能还留在build目录里。Make会尝试包含这些已经不存在的依赖文件,虽然不会报错,但可能会产生一些奇怪的行为。我的习惯是:每次改项目结构后,先make clean再make。
好了,这一章的内容就到这里。Makefile这东西,说白了就是“规则+依赖”的游戏。你把规则写清楚,把依赖理明白,剩下的交给Make去处理。增量编译不是什么黑魔法,它就是Make最基础的时间戳比较机制。但用好它,能让你的嵌入式项目构建效率提升一个量级。
公众号:蓝海资料掘金营,微信deep3321