STM32CubeMX 生成的 Makefile 文件解析

发布时间 2023-09-29 04:45:53作者: suanite

STM32CubeMX 生成的 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

构建 elf 文件的规则中指定了 -lnosys ,导致每次构建都会提示 _close 之类底层函数没实现的警告,属实有点烦。建议取消 -lnosys 参数,如果觉得标准 IO 函数不适用,自己另外实现类似的函数就行。

还有 GCC Arm12.2 之后的编译器会出现 elf has a LOAD segment with RWX permissions 的警告。建议链接时加上 -Wl,--no-warn-rwx-segments 屏蔽该警告。原理参考以下链接:

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 命令删除文件夹。

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

clean:
	$(RM) $(BUILD_DIR)

在正式编译前输出 gcc 版本信息。

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

version:
	$(CC) --version

参考文档:

GNU make 官方文档

GCC Option Summary

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