STM32 的 Makefile 文件解析#

Makefile 的前置知识#

一个 makefile 是由一系列的规则(rule)组成的。一条完整的规则包括目标(target)、依赖(prerequistites)、方法(recipe):

target ... : prerequistites ...
    recipe
    ...
    ...

依赖和方法不一定需要同时存在,只要保证至少有一个就行。当要生成目标时, make 会递归地寻找依赖关系,逐步生成目标。如果找到最底层依然无法满足生成条件就会报错。默认情况下,make 会且只会执行第一条规则。如果要执行指定的规则需要显式说明,如 make clean 调用 clean 规则清除文件。注意方法前的空白是一个制表符 TAB ,有些编辑器会自作主张把制表符替换成空格,从而导致 make 执行失败。

解析 Makefile 文件#

本文解析的是由 STM32CubeMX 生成的 STM32F030C8 的 Makefile 文件,只使能 SWCLK 和 SWDIO 引脚,其他配置保持原始状态。

我们首先搜索冒号,找到第一条规则

all: $(BUILD_DIR)/$(TARGET).elf $(BUILD_DIR)/$(TARGET).hex $(BUILD_DIR)/$(TARGET).bin

第一个依赖是 $(BUILD_DIR)/$(TARGET).elf,把变量替换成实际值,即 build/makefile.elf。一开始这个 elf 文件是不存在的,所以我们找到生成 elf 的规则。

$(BUILD_DIR)/$(TARGET).elf: $(OBJECTS) Makefile
    $(CC) $(OBJECTS) $(LDFLAGS) -o $@
    $(SZ) $@

发现其第一个依赖是 $(OBJECTS) 变量。找到变量 $(OBJECTS) 的赋值如下

# list of objects
OBJECTS = $(addprefix $(BUILD_DIR)/,$(notdir $(C_SOURCES:.c=.o)))
vpath %.c $(sort $(dir $(C_SOURCES)))
# list of ASM program objects
OBJECTS += $(addprefix $(BUILD_DIR)/,$(notdir $(ASM_SOURCES:.s=.o)))
vpath %.s $(sort $(dir $(ASM_SOURCES)))

其中 $(C_SOURCES:.c=.o) 把 C_SOURCES 里的所有 .c 文件后缀改成 .o。$(notdir names...) 函数删除 name 路径中的目录名,只保留文件名。$(addprefix prefix,names … ) 函数为 name 名添加前缀。再加下面一行对 .s 文件的处理,此时 OBJECTS 的值为:

OBJECTS =
build/main.o \
build/stm32f0xx_it.o \
build/stm32f0xx_ll_gpio.o \
build/stm32f0xx_ll_pwr.o \
build/stm32f0xx_ll_exti.o \
build/stm32f0xx_ll_rcc.o \
build/stm32f0xx_ll_utils.o \
build/system_stm32f0xx.o \
build/startup_stm32f030x8.o

再看 vpath %.c $(sort $(dir $(C_SOURCES))) 。$(dir names … ) 函数提取 name 中的目录部分;$(sort list) 函数对 list 中的元素排序,并删除重复的元素,我们主要用到它的去重功能;vpath pattern directories 语句为命名符合 pattern 的文件指定搜索路径 directories。vapth 使用方法中的 pattern 需要包含「%」字符。「%」的意思是匹配零或若干字符,例如,%.c 表示所有以 .c 结尾的文件。所以本段第一句表示,所有 .c 文件都在下面的目录中搜索(如果某文件在当前目录没有找到的话)。

Core/Src/
Drivers/STM32F0xx_HAL_Driver/Src/

汇编过程#

回到主线,变量 $(OBJECTS) 代表的一系列 .o 文件并不存在,所以跳到下面的规则意图创建 .o 文件。

$(BUILD_DIR)/%.o: %.c Makefile | $(BUILD_DIR)
    $(CC) -c $(CFLAGS) -Wa,-a,-ad,-alms=$(BUILD_DIR)/$(notdir $(<:.c=.lst)) $< -o $@

让我们先看依赖列表中竖线 | 的作用。| 用于区分普通依赖和 order-only 依赖(翻译成顺序依赖?)。| 左边的是普通依赖。普通依赖有两个作用,其一是作为方法的前置条件,要求规则在执行方法前先执行依赖或保证依赖存在;其二是当依赖更新时,指示目标过时需要重新构建。| 右边的是 order-only 依赖,只要求依赖先于目标构建,不要求当依赖有更新时强制更新目标。即 order-only 依赖只具备普通依赖的第一个作用,不具备第二个作用。第一个依赖是对应的 .c 文件,它总是存在的,满足条件。第二个依赖是 Makefile,它总是存在的,满足条件。这里的 Makefile 利用普通依赖的第二个作用,当 Makefile 文件有更新时会重新编译 .o 文件。第三个依赖 $(BUILD_DIR) 即 build 不存在。所以跳转到规则

$(BUILD_DIR):
    mkdir $@

这条规则里的方法中自动化变量 $@ 代指规则的目标名,所以这条规则的作用是创建 build 文件夹。

回到创建 .o 文件的规则,这时所有依赖全部满足,开始执行方法。利用 gcc 编译 .o 文件,我们逐个解析 gcc 的参数。

$(CC) -c $(CFLAGS) -Wa,-a,-ad,-alms=$(BUILD_DIR)/$(notdir $(<:.c=.lst)) $< -o $@
|     |  |          |                                                   |     |-- 代指规则名,即 .o 文件
|     |  |          |                                                   |-- 代指第一个依赖,即 .c 文件
|     |  |          |-- 对汇编器传递指令,紧接在 -Wa 后面的选项(逗号分割)就是专门传递给汇编器的指令选项。
|     |  |              这里表示生成 lst 文件。可以通过 as --help 查看具体信息
|     |  |-- CFLAGS 的内容比较丰富,我们一个一个过
|     |      |-- MCU MCU 相关的配置,可以查看 https://gcc.gnu.org/onlinedocs/gcc/ARM-Options.html
|     |      |   |-- -mcpu=cortex-m0,指示 MCU 是 cortex-m0 内核
|     |      |   |-- -mthumb,指示生成 Thumb 指令的目标文件。如果要和 ARM 指令交叉调用,可以加 -mthumb-interwork
|     |      |   |-- STM32F030 没有浮点运算单元,所以接下来的两个变量为空
|     |      |-- C_DEFS 定义宏并传递给编译器,宏名前都需要加 -D 前缀
|     |      |-- C_INCLUDES 指定头文件路径,宏名前都需要加 -I 前缀
|     |      |-- OPT,优化等级( -O0 -O1 -O2 -O3 -Os -Ofast -Og -Oz )
|     |      |-- -Wall,开启大部分警告提示
|     |      |-- -fdata-sections,数据项单独作为成段,链接时配合 -gc-sections 去掉不用的段,达到减小程序体积的效果
|     |      |-- -ffunction-sections,函数单独作为成段,链接时配合 -gc-sections 去掉不用的段,达到减小程序体积的效果
|     |      |-- -g,生成调试信息
|     |      |-- -gdwarf-2,生成 DWARF 格式的调试信息(如果支持的话)
|     |      |-- -MMD -MP -MF"$(@:%.o=%.d)",将不包括标准头文件的依赖关系写入 .d 文件
|     |-- 编译或汇编源文件,但没有链接
|-- arm-none-eabi-gcc

这时就编译汇编完所有 .c 文件。接下来汇编 .s 文件。

$(BUILD_DIR)/%.o: %.s Makefile | $(BUILD_DIR)
    $(AS) -c $(CFLAGS) $< -o $@
    |-- arm-none-eabi-gcc -x assembler-with-cpp

汇编 .s 文件的命令比较有意思。语句展开来是 arm-none-eabi-gcc -x assembler-with-cpp 。参数 -x assembler-with-cpp 指示到下一个 -x 选项之前的所有文件都当成 assembler-with-cpp ,使得 gcc 能够对 .s 文件做预处理。 gcc 编译一般分为四个阶段,分别是预处理、编译、汇编、链接。预处理的作用是宏展开和头文件替换,即将 .c 文件转成 .i 文件。编译的作用是把 c 代码转成汇编代码,即将 .i 文件转成 .s 文件。汇编的作用是将汇编代码转成对应的二进制形式的 cpu 指令,即将 .s 转成 .o 文件。链接的作用是把代码之间的引用关系关联起来,最终生成一个完整的程序 , 如 .elf 文件。编译过程在预处理过程之后,编译的产物 .s 文件自然不支持预处理。所以如果 .s 文件需要预处理的话,我们要显式指定上面的参数。把 .s 后缀改成大写的 .S 后缀可以让 gcc 自动使用 -x assembler-with-cpp 处理文件,这也是网上的很多文章会建议把 .s 后缀改成 .S 后缀的原因。这方面的知识可以参考 How to preprocess and compile an assembly file(.s) using gcc?

链接过程#

经过汇编之后,所有的 .o 文件已经生成。回到生成 elf 文件的规则。

$(BUILD_DIR)/$(TARGET).elf: $(OBJECTS) Makefile
    $(CC) $(OBJECTS) $(LDFLAGS) -o $@
    $(SZ) $@

变量 LDFLAGS 的选项包括

LDFLAGS = $(MCU) -specs=nano.specs -T$(LDSCRIPT) $(LIBDIR) $(LIBS) -Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref -Wl,--gc-sections
        |      |                  |             |         |       |                                          |-- 删除没用到的段
        |      |                  |             |         |       |-- 生成 map 文件
        |      |                  |             |         |-- -lc,链接标准 C 库 libc.a
        |      |                  |             |         |-- -lm,链接标准数学库 libm.a
        |      |                  |             |         |-- -lnosys,链接 libnosys.a
        |      |                  |             |-- 我们没有使用非标准库,保持为空
        |      |                  |-- 指定连接器脚本文件,文件名加 -T 前缀
        |      |-- 指定 nano.specs 配置,使用 newlib nano 库
        |-- 前文介绍过,这里不再展开说明

上面指定的库和 spec 文件都放在 lib/gcc/arm-none-eabi/12.3.1/thumb/v6-m/nofp 文件夹中,详细的链接参数参考 Link Options, spec 文件的语法参考 Spec Files。nano.specs 文件总的来说就是把所有用到的标准库都替换成 nano 版本,即把 newlib 改成 newlib-nano。所谓的 newlib-nano 是在 newlib 的基础上做了裁剪,比如取消对宽字符的支持,printf 不支持浮点数等。nano.specs 里面还涉及 libgloss 库。

libgloss 是提供启动代码和底层 I/O 支持的。

查看 newlib 源码,我们可以知道 libgloss 提供跟操作系统相关的函数。比如 write.c 文件定义了 write 函数,当程序中引用了 printf 等标准输出函数时,最终 printf 会调用 write 函数进行输出,如果没有定义 write 函数,那么链接就失败了。kill.c 源文件定义了 kill 系统函数,getpid.c 源文件定义了 getpid 系统函数,等等。

libgloss 目录下除了和处理器相关的子目录外,还有个很特别的子目录,那就是 libnosys 目录。这个目录下的源文件重新定义了 libgloss 的所有函数,但是所有函数都是空的,都是 stub 函数,完全是为了链接通过而定义的。如果程序并不实际使用系统函数,但是某些代码引用了系统函数,那么可以引入 libnosys,以便通过编译。libnosys 库是一个单一的库文件 libnosys.a,编译时直接指定 -lnosys 即可。

libgloss 除了一个 librdimon.a 库文件外,还包含了若干启动代码目标文件(crt*.o),编译时如果指定了 -lrdimon 还提示有符号未定义的话,需要把启动代码目标文件也链接进去。

后续工作#

这部分比较简单。生成 elf 文件后用二进制工具 arm-none-eabi-size.exe 显示文件大小,用 arm-none-eabi-objcopy.exe 生成 hex 文件和 bin 文件。clean 规则删掉整个 build 文件夹。最后的 -include $(wildcard $(BUILD_DIR)/*.d) 导入所有 .d 文件,用于加快编译速度。原理参考 makefile 中 include 的作用

优化 Makefile#

GCC Arm12.2 之后的编译器会出现 ``elf has a LOAD segment with RWX permissions`` 的警告。建议链接时加上 ``-Wl,--no-warn-rwx-segments`` 屏蔽该警告。原理参考以下链接:(更新版本的GCC已经修复这个问题)

GCC Arm 12.2 编译提示 LOAD segment with RWX permissions 警告

The linker's warnings about executable stacks and segments

GNU Linker: ELF has a LOAD segment with RWX permissions. Embedded ARM project

Windows 系统并不支持 rm 命令,直接执行 clean 规则会报错。我们首先判断系统类型,如果是 Windows 系统就利用 rmdir 命令删除文件夹。当然还有第二种方法,我们可以安装 windows-build-tools,这样就可以直接调用 rm 之类的命令。第二种方法这里不展开介绍了。

ifeq ($(OS),Windows_NT)
RM = -rmdir /s /q
else
RM = -rm -rf
endif

clean:
    $(RM) $(BUILD_DIR)

参考文档:

GNU make 官方文档

GCC Option Summary

Which Embedded GCC Standard Library? newlib, newlib-nano, …