3. Makefile基础:Makefile的作用、基本规则、变量与自动变量

大家好,我是你们的嵌入式工具链讲师。今天我们来聊聊Makefile。

说实话,我见过太多工程师在项目里手动编译、手动链接,改一个文件就要敲一长串命令。嗯,我刚开始做嵌入式那会儿也这样,直到有一次项目文件多了,漏编译了一个模块,排查了整整一个下午……从那以后,我再也不敢不用Makefile了。

3.1 Makefile到底有什么用?

简单说,Makefile就是一份「自动化编译说明书」。你告诉它:源文件有哪些、依赖什么头文件、用什么编译器、最终生成什么目标。然后一个make命令,全部搞定。

它的核心价值有三点:

  • 自动化:一条命令完成编译、链接、清理等所有操作
  • 增量编译:只重新编译修改过的文件,节省大量时间
  • 可维护:编译规则写在文件里,换环境、换人接手都能快速上手

重要概念:Makefile的核心思想是「如果依赖比目标新,就重新生成目标」。说白了,它只做必要的事,不做重复的事。

3.2 Makefile的基本规则

一个Makefile规则长什么样?其实就三部分:

目标: 依赖
    命令

举个例子:

main.o: main.c
    gcc -c main.c -o main.o

意思是:要生成main.o,需要main.c。如果main.c比main.o新,就执行下面的编译命令。

注意,命令前面必须是一个Tab键,不是空格。我见过不少新手在这里栽跟头——明明规则写对了,就是报错,一看,空格代替了Tab。嗯,这个坑我当年也踩过。

一个完整的Makefile通常包含多个规则:

final.bin: main.o uart.o i2c.o
    gcc main.o uart.o i2c.o -o final.bin

main.o: main.c main.h
    gcc -c main.c -o main.o

uart.o: uart.c uart.h
    gcc -c uart.c -o uart.o

i2c.o: i2c.c i2c.h
    gcc -c i2c.c -o i2c.o

clean:
    rm -f *.o final.bin

你想想看,如果每次改一个文件都要手动敲这些命令,是不是很崩溃?有了Makefile,一个make就全搞定了。

3.3 变量与自动变量

上面的例子有个问题:重复写了太多gcc、-c、-o。如果我想换编译器,比如从gcc换成arm-none-eabi-gcc,难道要一个个改?

这时候变量就派上用场了。

3.3.1 自定义变量

CC = gcc
CFLAGS = -Wall -O2
TARGET = final.bin

$(TARGET): main.o uart.o i2c.o
    $(CC) main.o uart.o i2c.o -o $(TARGET)

main.o: main.c main.h
    $(CC) $(CFLAGS) -c main.c -o main.o

uart.o: uart.c uart.h
    $(CC) $(CFLAGS) -c uart.c -o uart.o

i2c.o: i2c.c i2c.h
    $(CC) $(CFLAGS) -c i2c.c -o i2c.o

clean:
    rm -f *.o $(TARGET)

变量用$(变量名)引用。这样换编译器时,只需要改第一行的CC就行。我个人习惯把所有可能变动的参数都提取成变量,比如编译器、编译选项、链接选项、输出目录等。这样项目移植到新平台时,改起来特别快。

3.3.2 自动变量

自动变量是Makefile内置的「快捷方式」。它们能自动获取当前规则中的目标、依赖等信息。常用的有这几个:

自动变量 含义 示例值(上面规则中)
$@ 当前规则的目标 main.o
$< 第一个依赖文件 main.c
$^ 所有依赖文件(去重) main.c main.h
$? 比目标新的依赖文件 (视情况而定)

用自动变量改写上面的例子:

CC = gcc
CFLAGS = -Wall -O2
TARGET = final.bin

$(TARGET): main.o uart.o i2c.o
    $(CC) $^ -o $@

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

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

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

clean:
    rm -f *.o $(TARGET)

你看,$@自动变成目标文件名,$<自动变成第一个依赖。代码简洁多了,而且不容易写错。

我的小技巧:在调试Makefile时,可以用$(info 变量名=$@)打印自动变量的值。我曾经用这招排查过一个诡异的编译顺序问题,省了不少时间。

3.4 模式规则——让Makefile更优雅

上面还是有点啰嗦,三个.o文件的规则几乎一模一样。能不能合并?当然可以,用模式规则:

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

%是通配符,匹配任意文件名。这条规则的意思是:任何.o文件,如果它对应的.c和.h有更新,就重新编译。

完整的Makefile变成:

CC = gcc
CFLAGS = -Wall -O2
TARGET = final.bin
OBJS = main.o uart.o i2c.o

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

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

clean:
    rm -f *.o $(TARGET)

是不是清爽多了?以后加一个新模块,只需要在OBJS里加一个.o文件,编译规则自动生效。

注意:模式规则中的%.h依赖可能会让Makefile认为每个.c文件都必须有对应的.h文件。如果某些.c没有头文件,可以单独写规则覆盖,或者去掉%.h依赖,只在具体规则中声明头文件依赖。

3.5 伪目标——别和文件名搞混了

你有没有想过,如果当前目录下恰好有一个叫clean的文件,那make clean会怎样?它会认为clean已经是最新的,什么都不做。

为了避免这种尴尬,我们用.PHONY声明伪目标:

.PHONY: clean

clean:
    rm -f *.o $(TARGET)

伪目标不检查文件时间戳,每次都执行。我建议把所有不生成文件的目标都声明为.PHONY,比如clean、all、install等。

3.6 一个完整的嵌入式Makefile示例

最后,给你看一个我在STM32项目里用过的Makefile框架:

# 工具链
CC = arm-none-eabi-gcc
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy

# 编译选项
CFLAGS = -mcpu=cortex-m4 -mthumb -Wall -O2
LDFLAGS = -T stm32f4.ld

# 文件
TARGET = firmware.elf
BIN = firmware.bin
SRCS = main.c uart.c i2c.c spi.c
OBJS = $(SRCS:.c=.o)

# 默认目标
all: $(BIN)

# 链接
$(TARGET): $(OBJS)
    $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS)

# 生成bin文件
$(BIN): $(TARGET)
    $(OBJCOPY) -O binary $< $@

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

# 清理
.PHONY: clean
clean:
    rm -f $(OBJS) $(TARGET) $(BIN)

这个框架我用了好几年,从Cortex-M0到M7都适用。你只需要改芯片型号、源文件列表和链接脚本就行。

好了,这一章的内容就到这里。Makefile的变量和自动变量是基础中的基础,但用好它们,你的编译效率能提升一个档次。下一章我们聊聊Makefile的条件判断和函数,那才是真正让Makefile「活起来」的东西。