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.huart.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 核心逻辑流程图

下面这张图展示了整个构建系统的核心流程。我画的时候特意把“依赖生成”和“增量编译”这两个关键环节突出了一下。

Makefile增量编译核心流程 1. 扫描源文件 2. 检查.d依赖文件 3. 比较时间戳 需要重新编译 跳过(无需编译) 编译 → 链接 → 输出 已是最新,无需操作 扫描阶段 决策阶段 编译阶段 跳过阶段

7.6 避坑指南

最后,分享几个我踩过的坑:

坑1:依赖文件没生成就编译

第一次编译时,.d文件还不存在。-include虽然不会报错,但Make也拿不到依赖信息。所以第一次编译永远是全量编译。这其实没问题——反正第一次本来就要全量编译。但如果你在-include之前加了其他规则,可能会出问题。我的建议是:把-include放在所有规则之后。

坑2:头文件路径遗漏

如果某个头文件不在-I指定的路径里,编译器会报错。但更隐蔽的问题是:即使你手动加了-I路径,如果路径写错了,编译器找不到头文件,依赖也不会生成。我曾经因为路径末尾多了一个空格,排查了整整两个小时。

坑3:清理不彻底

如果你改了项目结构——比如新增了一个目录,或者删除了一个源文件——旧的.d文件可能还留在build目录里。Make会尝试包含这些已经不存在的依赖文件,虽然不会报错,但可能会产生一些奇怪的行为。我的习惯是:每次改项目结构后,先make cleanmake

好了,这一章的内容就到这里。Makefile这东西,说白了就是“规则+依赖”的游戏。你把规则写清楚,把依赖理明白,剩下的交给Make去处理。增量编译不是什么黑魔法,它就是Make最基础的时间戳比较机制。但用好它,能让你的嵌入式项目构建效率提升一个量级。


公众号:蓝海资料掘金营,微信deep3321