3. Makefile 入门:从“手动敲命令”到“一键编译”

说实话,我刚入行那会儿,最烦的就是编译。每次改一行代码,都要敲一长串 gcc 命令,还得记着链接哪个库、输出什么文件。后来老工程师甩给我一个 Makefile,说“你以后就敲个 make 就行”。我一看,好家伙,这玩意儿比女朋友还难懂。

但用顺手之后,我再也回不去了。Makefile 说白了就是一套“自动化编译脚本”,你告诉它规则,它帮你干活。今天我就带你把它啃下来。

3.1 什么是 Makefile?

Makefile 是 make 工具的配置文件。make 会读取这个文件,判断哪些文件需要重新编译,然后执行对应的命令。

它的核心逻辑就三个词:目标、依赖、命令。

核心公式:

目标文件 : 依赖文件列表
→ 执行命令(Tab 开头)

举个例子。你有个 led.c 和 main.c,想编译成 led.o 和 main.o,最后链接成 firmware.elf。手动敲命令很累,但写成 Makefile 就清爽了。

3.2 基本语法:目标、依赖、命令

看个最简单的例子:

firmware.elf: main.o led.o
    arm-none-eabi-gcc -o firmware.elf main.o led.o

main.o: main.c
    arm-none-eabi-gcc -c main.c -o main.o

led.o: led.c
    arm-none-eabi-gcc -c led.c -o led.o

这里 firmware.elf 是最终目标,它依赖 main.o 和 led.o。如果这两个 .o 文件比 .elf 新,make 就会执行下面的链接命令。

嗯,这里有个坑:命令前面必须是 Tab 键,不能用空格。我见过有人因为这个错误卡了一下午,死活编译不过。

避坑指南:Makefile 里的命令缩进必须是 Tab,不是 4 个空格。如果你用编辑器自动把 Tab 转成空格了,make 会报“missing separator”错误。我曾经在 CI 环境里被这个坑过,排查了半小时才发现是编辑器设置的问题。

3.3 伪目标:别让文件名坑了你

你想想看,如果目录下恰好有一个叫 clean 的文件,那 make clean 会怎样?make 会认为这个目标已经是最新的了,什么都不执行。

解决办法就是声明它为伪目标:

.PHONY: clean
clean:
    rm -f *.o firmware.elf

伪目标不检查文件是否存在,每次都执行命令。我个人习惯把所有非文件目标都加上 .PHONY,比如 all、clean、flash、debug。

3.4 变量:别写死,用变量

写死的 Makefile 很难维护。换个编译器、改个源文件路径,你得改好几处。用变量就灵活多了:

CC      = arm-none-eabi-gcc
CFLAGS  = -Wall -O2 -mcpu=cortex-m4
TARGET  = firmware.elf
SRCS    = main.c led.c uart.c
OBJS    = $(SRCS:.c=.o)

$(TARGET): $(OBJS)
    $(CC) $(CFLAGS) -o $@ $^

%.o: %.c
    $(CC) $(CFLAGS) -c $< -o $@

.PHONY: clean
clean:
    rm -f $(OBJS) $(TARGET)

这里我用了几个自动变量:

变量 含义 例子
$@ 目标文件名 firmware.elf
$^ 所有依赖文件 main.o led.o uart.o
$< 第一个依赖文件 main.c

我的习惯:变量名用大写,赋值用 = 或 :=。= 是递归展开,:= 是简单展开。我一般用 :=,避免变量引用顺序出问题。

3.5 常用函数:让 Makefile 更聪明

Makefile 里也有函数,虽然语法有点怪,但很实用。

# 获取 src 目录下所有 .c 文件
SRCS := $(wildcard src/*.c)

# 把 .c 替换成 .o,并放到 obj 目录
OBJS := $(patsubst src/%.c, obj/%.o, $(SRCS))

# 添加头文件搜索路径
CFLAGS += -I$(shell pwd)/inc

# 条件判断:如果是调试版,加 -g
ifeq ($(DEBUG), 1)
    CFLAGS += -g -O0
endif

常用的就这几个:wildcard 找文件,patsubst 替换路径,shell 执行 shell 命令。够用了。

3.6 完整嵌入式 Makefile 示例

下面是我给 STM32F4 项目写的一个模板,你可以直接拿来改:

# 工具链
CC      := arm-none-eabi-gcc
LD      := arm-none-eabi-gcc
OBJCOPY := arm-none-eabi-objcopy
SIZE    := arm-none-eabi-size

# 目标
TARGET  := firmware
MCU     := cortex-m4

# 路径
SRC_DIR := src
INC_DIR := inc
BUILD_DIR := build

# 源文件
SRCS := $(wildcard $(SRC_DIR)/*.c)
OBJS := $(patsubst $(SRC_DIR)/%.c, $(BUILD_DIR)/%.o, $(SRCS))

# 编译参数
CFLAGS  := -mcpu=$(MCU) -mthumb -Wall -O2 -ffunction-sections -fdata-sections
CFLAGS  += -I$(INC_DIR)
LDFLAGS := -mcpu=$(MCU) -mthumb -Wl,--gc-sections -Wl,-Map=$(BUILD_DIR)/$(TARGET).map

# 默认目标
all: $(BUILD_DIR)/$(TARGET).elf $(BUILD_DIR)/$(TARGET).bin

# 编译
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c | $(BUILD_DIR)
    $(CC) $(CFLAGS) -c $< -o $@

# 链接
$(BUILD_DIR)/$(TARGET).elf: $(OBJS)
    $(LD) $(LDFLAGS) -o $@ $^
    $(SIZE) $@

# 生成 bin 文件
$(BUILD_DIR)/$(TARGET).bin: $(BUILD_DIR)/$(TARGET).elf
    $(OBJCOPY) -O binary $< $@

# 创建 build 目录
$(BUILD_DIR):
    mkdir -p $@

# 烧录(以 ST-Link 为例)
flash: $(BUILD_DIR)/$(TARGET).bin
    st-flash write $< 0x08000000

# 清理
.PHONY: all clean flash
clean:
    rm -rf $(BUILD_DIR)

这个 Makefile 做了几件事:自动扫描源文件、编译到 build 目录、生成 .elf 和 .bin、支持一键烧录。你只需要改 MCU 和路径就行。

关键点:

  • | $(BUILD_DIR) 是“仅当目录不存在时才创建”,属于 order-only 依赖。
  • -ffunction-sections -fdata-sections 配合 --gc-sections 可以去掉未使用的函数和数据,减小固件体积。
  • $(SIZE) $@ 会在链接后打印各段大小,方便你监控固件膨胀。

3.7 Makefile 知识体系

下面这张图帮你理清 Makefile 的核心脉络:

Makefile 核心知识体系 Makefile 核心知识体系 Makefile 基本语法 伪目标 .PHONY 变量与自动变量 常用函数 目标 : 依赖 Tab 命令 避免文件名冲突 clean / all / flash $@ $^ $< 自动变量 = 与 := 赋值区别 wildcard / patsubst shell / ifeq 条件 💡 核心原则 Makefile = 目标 + 依赖 + 命令,用变量避免写死,用伪目标避免文件冲突

3.8 避坑总结

最后,我把这些年踩过的坑列出来,你遇到了直接对照:

问题 现象 解决办法
Tab 用了空格 missing separator 编辑器设置 Tab 为实际 Tab,不要转空格
伪目标没加 .PHONY 同名文件存在时 make 不执行 所有非文件目标都加 .PHONY
变量赋值用了 = 变量引用顺序导致意外值 用 := 简单赋值
路径写死 换目录就得改 Makefile 用 wildcard 和 patsubst 动态生成
没加 -j 参数 单核编译,速度慢 make -j$(nproc) 并行编译

Makefile 这东西,说白了就是个“偷懒工具”。你花半小时写好它,以后每次编译就省下 5 分钟。时间长了,这笔账怎么算都划算。