1. 为什么要在STM32开发中用GCC编译
很多刚接触STM32的朋友,第一反应都是装Keil或者IAR,点一下按钮就能编译下载,确实省事。但我在实际项目里摸爬滚打这些年,越来越觉得把工具链换成GCC是一件值得折腾的事。原因不复杂:GCC完全免费、跨平台、可脚本化、能和CI/CD流水线无缝对接,而且你一旦搞懂了背后的编译流程,对整个嵌入式系统的理解会上一个台阶。
这篇文章要聊的,就是怎么用GCC把STM32工程从源码一路编译成可以烧进芯片的二进制文件。我会从工具选型、工程结构、启动文件、链接脚本、Makefile编写,一直讲到实际编译过程中会遇到的各种坑。适合已经会用Keil但想转向开源工具链的开发者,也适合刚学STM32、想从底层理解编译过程的新手。读完你至少能做到:拿到一份STM32源码,自己写Makefile,用命令行完成编译,并且知道每一步在干什么。
先说清楚一个前提:GCC本身只是一个编译器集合,它不认识STM32。你需要额外的东西来配合——启动文件、链接脚本、以及针对ARM Cortex-M内核的GCC工具链。这三样凑齐了,GCC才能干Keil干的活。下面我一步步拆开讲。
2. 工具链选型与环境搭建
2.1 ARM GCC工具链的几种选择
给STM32用的GCC工具链,市面上主要有这么几个来源。第一个是ARM官方曾经维护的GNU Arm Embedded Toolchain,后来转由Arm维护,现在叫Arm GNU Toolchain。第二个是各家Linux发行版仓库里自带的gcc-arm-none-eabi包。第三个是一些第三方打包的版本。
我个人的建议是:如果你在Linux或者macOS上开发,直接用系统包管理器装最省事。Ubuntu下一条命令:
sudo apt install gcc-arm-none-eabi binutils-arm-none-eabiWindows用户稍微麻烦一点,需要下载Arm GNU Toolchain的安装包,装完之后把bin目录加到PATH环境变量里。装完验证一下:
arm-none-eabi-gcc --version能打印出版本号就说明装好了。这里有个细节要注意:工具链的版本不要太老也不要太新。太老的版本可能不支持某些C语言特性,太新的版本有时候会对代码里的未定义行为报更严格的警告,导致原本能编译的工程突然报错。我一般选近两年内的稳定版本。
2.2 还需要哪些配套工具
光有GCC不够,你还需要几个配套工具。binutils里的objcopy用来把ELF文件转成bin或者hex,objdump用来反汇编排查问题,size用来查看各个段占用了多少Flash和RAM。这些在装工具链的时候一般会一起装上。
另外强烈建议装一个make。Windows下可以用MinGW或者MSYS2提供的make,Linux和macOS自带。如果你不想用make,也可以用CMake,但CMake最终还是要调用make或者ninja来执行构建,所以底层逻辑是一样的。我下面主要用Makefile来演示,因为它的逻辑最透明,你能清楚看到每一条编译命令。
还有一个容易被忽略的工具:OpenOCD或者pyocd,用来下载和调试。不过这篇文章主要聚焦编译,下载调试的部分简单提一下就行。
2.3 目录结构怎么规划
一个干净的GCC工程目录,我习惯这么组织:
project/ ├── Makefile ├── startup/ │ └── startup_stm32f103.s ├── linker/ │ └── stm32f103.ld ├── src/ │ ├── main.c │ └── ... ├── inc/ │ └── ... ├── lib/ │ └── (第三方库) └── build/ └── (编译产物)把启动文件和链接脚本单独放,是因为它们和芯片型号强相关,换芯片的时候只需要替换这两个文件。build目录用来放编译产物,方便清理。这个结构不是强制的,但清晰的分层能让你在工程变大之后不至于手忙脚乱。
3. 启动文件与链接脚本的核心细节
3.1 启动文件到底在干什么
很多人用Keil的时候从来没打开过启动文件,因为Keil会自动帮你加进去。但用GCC的时候,你得自己准备一个。启动文件通常是汇编写的,后缀是.s。它干的事情按顺序是:定义中断向量表、初始化栈指针、调用SystemInit、跳转到main函数。
中断向量表的第一个位置放的是初始栈指针的值,第二个位置放的是复位处理函数的地址。芯片上电之后,硬件会自动从Flash的起始地址读取这两个值。所以链接脚本必须保证向量表被放在Flash的最开头,这一点后面讲链接脚本的时候会再强调。
启动文件里还有一个关键点:调用main之前要先调用SystemInit。这个函数一般在厂商提供的库文件里,负责配置时钟。如果你忘了调用它,芯片会跑在默认的内部时钟上,速度可能只有几MHz,而且外设的时钟源也不对,串口之类的会工作不正常。
3.2 链接脚本的关键段落
链接脚本的后缀是.ld,它告诉链接器把各个段放到内存的什么位置。STM32的Flash和RAM地址是固定的,比如STM32F103C8T6的Flash从0x08000000开始,大小64KB,RAM从0x20000000开始,大小20KB。链接脚本里最重要的就是MEMORY和SECTIONS这两块。
MEMORY段定义存储器的起始地址和大小:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K }SECTIONS段定义各个代码和数据段怎么摆放。.text段放代码和常量,放到FLASH里。.data段放已初始化的全局变量,运行时要在RAM里,但初始值存在FLASH里,启动的时候由启动代码拷贝过去。.bss段放未初始化的全局变量,启动时清零。
这里有个容易踩的坑:栈和堆的大小。链接脚本里通常会给一个符号来标记栈顶,启动文件里会引用这个符号。如果你改动了RAM的大小或者布局,记得同步改链接脚本,否则可能出现栈溢出但编译不报错的情况,运行时才莫名其妙死机。
3.3 向量表的重定位问题
如果你的程序不是从Flash启动,比如做Bootloader,那向量表需要重定位到RAM里。这时候链接脚本里要加一个.vector_table段,并且启动文件里要调用SCB->VTOR来设置向量表偏移。这个属于进阶内容,新手先不用管,但要知道有这么回事,以后遇到Bootloader跳转App跑不起来的问题,第一反应就应该是查向量表。
4. Makefile编写与编译流程实操
4.1 Makefile的基本骨架
Makefile的核心就是定义变量、定义规则、定义依赖。我先给一个能用的最小骨架,然后逐段解释。
TARGET = firmware BUILD_DIR = build CC = arm-none-eabi-gcc OBJCOPY = arm-none-eabi-objcopy SIZE = arm-none-eabi-size CPU = -mcpu=cortex-m3 FPU = MCU = $(CPU) -mthumb $(FPU) C_SOURCES = $(wildcard src/*.c) ASM_SOURCES = startup/startup_stm32f103.s C_INCLUDES = -Iinc C_DEFS = -DSTM32F103xB CFLAGS = $(MCU) $(C_DEFS) $(C_INCLUDES) -Og -Wall -fdata-sections -ffunction-sections LDFLAGS = $(MCU) -Tlinker/stm32f103.ld -Wl,--gc-sections -Wl,-Map=$(BUILD_DIR)/$(TARGET).map OBJECTS = $(addprefix $(BUILD_DIR)/,$(notdir $(C_SOURCES:.c=.o))) OBJECTS += $(addprefix $(BUILD_DIR)/,$(notdir $(ASM_SOURCES:.s=.o))) all: $(BUILD_DIR)/$(TARGET).elf $(BUILD_DIR)/$(TARGET).bin $(BUILD_DIR)/%.o: src/%.c | $(BUILD_DIR) $(CC) -c $(CFLAGS) $< -o $@ $(BUILD_DIR)/%.o: startup/%.s | $(BUILD_DIR) $(CC) -c $(MCU) $< -o $@ $(BUILD_DIR)/$(TARGET).elf: $(OBJECTS) $(CC) $(OBJECTS) $(LDFLAGS) -o $@ $(SIZE) $@ $(BUILD_DIR)/$(TARGET).bin: $(BUILD_DIR)/$(TARGET).elf $(OBJCOPY) -O binary $< $@ $(BUILD_DIR): mkdir -p $@ clean: rm -rf $(BUILD_DIR)4.2 编译参数逐个拆解
-mcpu=cortex-m3指定目标内核,这个必须和你的芯片一致。STM32F1是Cortex-M3,F4是M4,F0是M0。写错了编译能过,但生成的指令可能不兼容。
-mthumb表示生成Thumb指令集。Cortex-M系列只支持Thumb,所以这个必须有。忘了加的话链接会报错。
-Og是优化等级,表示在保持调试体验的前提下做适度优化。发布版本可以换成-O2或-Os。-Os是优化体积,对Flash紧张的芯片很有用。
-Wall打开常用警告。我建议再加上-Wextra,虽然会多出一些噪音,但能帮你发现潜在问题。
-fdata-sections和-ffunction-sections让每个函数和数据单独成段,配合链接器的--gc-sections可以剔除没用的代码,减小固件体积。这两个参数在资源紧张的STM32上几乎是必加的。
-Tlinker/stm32f103.ld指定链接脚本。-Wl,--gc-sections把链接器参数传进去,开启段回收。-Wl,-Map=...生成map文件,排查内存占用的时候非常有用。
4.3 从源码到bin的完整流程
执行make之后,实际发生的事情是这样的:编译器先把每个.c文件编译成.o目标文件,汇编器把.s文件也变成.o。然后链接器把所有.o按照链接脚本的规则拼成一个.elf文件。最后objcopy把.elf里的有效内容提取出来,生成纯二进制的.bin文件。
.elf文件里除了代码和数据,还包含调试信息、符号表等。这些在最终烧录的时候不需要,所以要用objcopy去掉。bin文件就是纯粹的字节流,烧到Flash的0x08000000地址就能跑。
我习惯在链接之后跑一下size命令,看看text、data、bss各占多少。text是代码和常量,占Flash。data是已初始化全局变量,既占Flash又占RAM。bss是未初始化全局变量,只占RAM。如果text加data超过了Flash大小,链接就会报错。
5. 常见问题与排查技巧实录
5.1 编译报错速查表
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
undefined reference to_start | 没加启动文件 | 把startup_xxx.s加入编译 |
| region FLASH overflowed | 代码太大超过Flash | 开-Os优化,检查是否引入了不必要的库 |
| cannot find linker script | 链接脚本路径写错 | 检查-T后面的路径 |
undefined reference toSystemInit | 没链接厂商库 | 把system_stm32f1xx.c加入编译 |
| warning: implicit declaration | 头文件没包含 | 检查C_INCLUDES路径 |
5.2 几个我踩过的坑
第一个坑是启动文件的文件名。不同厂商给的启动文件命名不一样,有的是startup_stm32f103xb.s,有的是startup_stm32f103c8.s。你得根据芯片的具体型号选对文件,选错了中断向量表的长度和内容都不对,程序跑起来会莫名其妙进HardFault。
第二个坑是宏定义。STM32的标准外设库和HAL库都依赖一个芯片型号宏,比如STM32F103xB。这个宏决定了头文件里包含哪些寄存器定义。忘了定义的话,编译会报一堆未定义的寄存器名。这个宏要放在C_DEFS里,用-D传进去。
第三个坑是链接脚本里的栈大小。默认的链接脚本可能只给了1KB栈,如果你的程序里用了递归或者大的局部数组,栈会溢出。溢出的表现是程序跑飞或者变量值莫名其妙被改。排查方法是看map文件里栈的地址范围,然后在调试器里观察栈指针有没有超出范围。
第四个坑是优化等级和volatile。开-O2之后,编译器可能会把某些它认为没用的变量读写优化掉。如果你的变量是被中断修改的,必须加volatile关键字,否则主循环里读到的永远是旧值。这个问题在-O0下不会出现,一开优化就暴露,很容易让人以为是编译器有bug。
5.3 用map文件排查内存问题
map文件是链接器生成的报告,里面详细列出了每个函数、每个变量被放在了什么地址,占了多少字节。当你遇到Flash不够用或者RAM不够用的时候,map文件是第一手资料。
我一般这么看:先看文件末尾的Memory Configuration,确认Flash和RAM的总量。然后看Discarded input sections,这里列的是被--gc-sections回收掉的代码,如果发现某个你需要的函数被回收了,说明它没有被任何地方引用,或者引用链断了。最后看全局符号表,按大小排序,找出占用最大的几个函数和变量。
有个技巧:在Makefile里加一个size目标,编译完自动打印各段大小,这样每次编译都能看到变化,及时发现体积膨胀。
6. 进阶话题与工程化建议
6.1 用CMake替代Makefile
Makefile写起来直观,但工程大了之后维护成本高。CMake可以生成Makefile或者Ninja文件,跨平台更方便。用CMake的话,核心是写一个toolchain文件,指定编译器为arm-none-eabi-gcc,然后设置CMAKE_C_FLAGS和链接参数。CMake的语法比Makefile友好,而且CLion、VSCode这些编辑器对CMake的支持更好。
不过CMake也有它的坑,比如工具链文件的加载时机、交叉编译的检测逻辑,新手可能会被绕晕。我的建议是先用Makefile把流程跑通,理解每一步在干什么,再迁移到CMake。
6.2 集成到CI流水线
GCC工具链最大的优势之一就是可以脚本化。你可以在CI服务器上装好工具链,每次提交代码自动编译,生成bin文件,甚至自动跑单元测试。这在团队协作里价值很大,能保证每个人的代码都能编译通过,不会出现“在我机器上是好的”这种情况。
具体做法是写一个CI脚本,步骤就是装工具链、make、检查产物是否存在、检查size是否超标。如果超标就报错,防止有人不小心引入了大体积的库。
6.3 调试信息的处理
GCC默认会生成调试信息,但如果你用了-O2,调试体验会变差,因为代码被优化得面目全非。我的做法是开发阶段用-Og加-g3,发布阶段用-Os去掉调试信息。这样开发时能单步调试,发布时体积最小。
另外,elf文件里的调试信息可以用objcopy单独提取出来,生成一个.debug文件,需要调试的时候再加载。这样烧录的bin文件可以很小,但调试信息不丢失。
7. 我个人在实际操作中的几点体会
折腾GCC编译STM32这件事,最大的收获不是省了买Keil的钱,而是把整个编译链接过程搞明白了。以前用IDE的时候,点一下编译按钮,中间发生了什么完全不知道。换成GCC之后,每一条命令、每一个参数、每一个报错,你都得自己去查、去理解。这个过程很痛苦,但熬过去之后,你对嵌入式系统的认知会完全不一样。
我现在拿到任何一个新的STM32型号,第一件事就是找它的启动文件和链接脚本,然后照着这篇文章的流程搭一个Makefile。快的话半小时就能跑通点灯。这套流程我已经在好几个项目上复用过了,包括一些需要定制Bootloader的场景,因为链接脚本完全可控,做起来比在IDE里改配置灵活得多。
最后分享一个小技巧:如果你不确定某个编译参数的作用,直接arm-none-eabi-gcc --help看文档,或者在编译命令后面加-v看详细输出。GCC的文档很全,只是需要花点时间读。另外,把每次编译的完整命令保存下来,出问题的时候对比一下,能快速定位是哪个参数导致的。