写在前面

入坑嵌入式开发的人,第一课多半是这样的:打开 Keil(或者 IAR、STM32CubeIDE),点几下鼠标创建工程,在编辑框里写代码,然后点那个绿色的 “Build” 按钮。编译、烧录、调试,全在一个软件里完成——IDE 帮你把一切都打包好了。

这很舒服。但你有没有想过:IDE 背后到底发生了什么?

双击一个按钮,几个 G 的软件就开始全自动运转,中间的细节全都藏起来了。而当你有一天需要让服务器自动帮你编译、或者仅仅是偶尔想用 VS Code 加命令行来写代码——IDE 的"便利"就成了枷锁。


为什么我要"告别 IDE"?

说实话,我并不是反对 IDE。Keil 和 CubeIDE 在调试上确实有不可替代的优势。但我想说的是:我们可以不依赖它们

独立于 IDE 之外构建的好处:

  1. 环境可复制——一个 Makefile + 依赖文件就能在任意机器上复现构建,不怕环境迁移
  2. 方便自动化——比如让服务器每天自动帮你编译一次,服务器上没有图形界面,不可能跑 IDE,但命令行工具可以
  3. 更清晰的认知——当你知道每一行命令行参数在做什么,你就真正理解了编译的本质
  4. 轻量灵活——没有 IDE 的数 GB 安装包,一个文本编辑器 + 工具链就可以开工
  5. 版本控制纯文本——Makefile 和配置文件都是文本文件,放进 Git 管理比 IDE 的二进制项目文件舒服太多

快速上手:从 CubeMX 出发

最快的方式,就是在 STM32CubeMX 里正常配置你的芯片和外设,我以最简单的OLED和按键配置为例,看看能否成功移植UGUI图形库。
在这里插入图片描述
Project Manager 页面将 Toolchain / IDE 选为 Makefile,点击生成。
在这里插入图片描述
CubeMX 会替你生成一套完整的 GCC 工程——包含 Makefile、链接脚本、启动文件和 HAL 驱动。你不需要手动搭建任何东西,拿到手就是一个可以直接 make 的项目。


工具链全家桶

这条工作流需要三个核心工具,各司其职:

  • Arm GNU Toolchainarm-none-eabi-gcc)— 交叉编译器,把代码编译成 ARM Cortex-M 能跑的机器码
  • GNU Make — 构建调度器,根据 Makefile 自动判断哪些文件需要重新编译
  • OpenOCD — 调试烧录工具,通过 ST-Link 把固件写入芯片 Flash

后续会分别介绍每个工具的安装和验证方式。


第一步:安装 Arm GNU Toolchain

去 ARM 官网下载 arm-none-eabi-gcc 的 Windows 安装包:

🔗 https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads

进入页面后找到 Windows 版本的 .exe 安装包下载安装即可。
在这里插入图片描述
解压完成后,一定要把文件里的路径(比如我的是D:\arm-gnu-toolchain\binD:\arm-gnu-toolchain\arm-none-eabi\bin)添加到环境变量PATH中。
在这里插入图片描述
在这里插入图片描述
添加环境变量的地方在Windows设置→系统→系统信息→高级系统设置→环境变量,我用的是Win10版本,不同版本中方法可能不同。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
添加完环境变量后,按下Ctrl + R输入cmd,打开终端验证:

arm-none-eabi-gcc --version

在这里插入图片描述

如果能看到版本号(比如 arm-none-eabi-gcc (GNU Toolchain ...) 15.2.1),就说明安装成功了。

添加系统环境变量 PATH 的方式后面还会用到。


编译:GCC 做了两件事

CubeMX 生成的代码,本质上是多个 .c 文件加一个汇编启动文件startup_stm32f103xb.s。GCC 的工作分两步:

第一步:编译(.c → .o)

每个 .c 文件单独编译成一个 .o(目标文件)。一个典型的编译命令长这样:

arm-none-eabi-gcc -c \
    -mcpu=cortex-m3 -mthumb \           # 目标 CPU 是 ARM Cortex-M3
    -DSTM32F103xB -DUSE_HAL_DRIVER \    # 预定义宏
    -ICore/Inc -IDrivers/...            # 头文件搜索路径
    -Og                                 # 调试优化
    -g -gdwarf-2                        # 生成调试信息
    -fdata-sections -ffunction-sections # 方便链接时去掉无用代码
    Core/Src/main.c -o build/main.o

几个关键参数的作用:

  • -mcpu=cortex-m3 -mthumb——告诉编译器你不是在给 x86 电脑编译,而是给一块 ARM Cortex-M3 芯片编译,并且只使用 Thumb 指令集
  • -fdata-sections -ffunction-sections——把每个函数和变量单独放一个段,后面链接时才能精准地删除没用的部分
  • -I——告诉编译器去哪找 #include 的头文件

汇编启动文件(startup_stm32f103xb.s)处理方式类似,只是多了 -x assembler-with-cpp 表示"先过一遍 C 预处理器再汇编"。

第二步:链接(.o → .elf)

所有 .o 文件合成一个 .elf 可执行文件。这一步需要一份链接脚本.ld),它相当于芯片的"地契",定义了内存怎么分配:

arm-none-eabi-gcc \
    build/main.o build/gpio.o build/... \
    -mcpu=cortex-m3 -mthumb \
    -specs=nano.specs \                        # 用精简版 C 库
    -TSTM32F103XX_FLASH.ld \                   # 指定链接脚本
    -lc -lm -lnosys \                          # 标准库
    -Wl,-Map=build/UGUI_Test.map,--cref \      # 生成内存映射文件
    -Wl,--gc-sections \                         # 删除未使用的函数和数据
    -o build/UGUI_Test.elf

链接脚本里长这样:

MEMORY
{
    RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
    FLASH (rx)  : ORIGIN = 0x8000000,  LENGTH = 64K
}

大概意思是:这片芯片有 64KB 的 Flash(从 0x08000000 开始,存代码)和 20KB 的 RAM(从 0x20000000 开始,存变量)。

链接结束后,可以用 arm-none-eabi-size 看看固件大小:

arm-none-eabi-size build/UGUI_Test.elf
   text    data     bss     dec     hex filename
  14908      12    3332   18252    474c build/UGUI_Test.elf
  • text 14KB → 代码占 Flash
  • data 12B → 已初始化的变量占 RAM(同时在 Flash 存备份)
  • bss 3332B → 未初始化的变量占 RAM

格式转换

.elf 文件是编译器格式,烧录通常用 .hex.bin

arm-none-eabi-objcopy -O ihex      build/UGUI_Test.elf build/UGUI_Test.hex
arm-none-eabi-objcopy -O binary -S build/UGUI_Test.elf build/UGUI_Test.bin

三者的区别:

  • .elf — 包含调试信息,GDB 调试、OpenOCD 烧录都用它
  • .hex — ASCII 文本格式,烧录器通用性好
  • .bin — 纯二进制,体积最小,适合 OTA 升级

第二步:安装和配置 Make

下载 GNU Make 的独立 Windows 版本:

🔗 https://gnuwin32.sourceforge.net/packages/make.htm

进入页面后点击 Setup 栏的下载链接并安装 Make
在这里插入图片描述
同样,把这个路径添加到环境变量。
在这里插入图片描述
完成后打开终端验证:

make --version

在这里插入图片描述

能看到版本号就说明装好了。


Makefile:把上面的命令串起来

手动一条一条敲 GCC 命令太累了。Makefile 就是把这些命令自动化——你只需要敲 make,它自动判断哪些文件变了、只重新编译需要的部分、再链接成最终的 .elf

即使读者从来没接触过 Makefile 也没关系,本文会告诉读者在 Makefile 中应该如何添加自己的文件。但建议读者之后再自主学习基本的 Makefile 语法,产生更深刻的理解。

源文件怎么写进来

CubeMX 生成的 Makefile 里,源文件用两种方式列举:

######################################
# source
######################################
# C sources
C_SOURCES =  \
Core/Src/main.c \
Core/Src/gpio.c \
Core/Src/tim.c \
Core/Src/stm32f1xx_it.c \
Core/Src/stm32f1xx_hal_msp.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_gpio_ex.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_tim.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_tim_ex.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_rcc.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_rcc_ex.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_gpio.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_dma.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_cortex.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_pwr.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_flash.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_flash_ex.c \
Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_exti.c \
Core/Src/system_stm32f1xx.c

# Wildcard: 自动匹配 BSP 和 Middlewares 下所有 .c 文件
C_SOURCES += $(wildcard BSP/*.c)
C_SOURCES += $(wildcard Middlewares/ugui/src/*.c)

$(wildcard ...) 会自动扫描目录——以后在 BSP/ 下面新增一个 .c 文件,连 Makefile 都不用改,这非常适合用于添加我们自己的驱动文件。
在这里插入图片描述

头文件搜索路径也类似:

# C includes
C_INCLUDES =  \
-ICore/Inc \
-IDrivers/STM32F1xx_HAL_Driver/Inc \
-IDrivers/STM32F1xx_HAL_Driver/Inc/Legacy \
-IDrivers/CMSIS/Device/ST/STM32F1xx/Include \
-IDrivers/CMSIS/Include \
-IBSP \
-IMiddlewares/ugui/inc

这些 -I 参数和 GCC 命令行的 -I 完全对等,就是告诉编译器去哪找头文件。

目标文件去哪了

所有 .o 都集中到 build/ 目录下,不会散落在源码目录里:

OBJECTS = $(addprefix $(BUILD_DIR)/,$(notdir $(C_SOURCES:.c=.o)))
vpath %.c $(sort $(dir $(C_SOURCES)))

这样做有一个很实际的好处:清理时只需要删掉整个 build/ 文件夹,一条命令就能把编译产物全部清除,不会在源码目录里留下散落的 .o.d 文件。
反之,你的各种输出文件将散落在项目的根目录下,及其难以整理,请无需担心,这些工作在使用CubeMX生成项目文件时就都自动帮你完成了,这里仅建议读者使用 Makefile 时养成好的习惯。

增量编译的逻辑

这是 Makefile 最巧妙的地方。编译时自动生成 .d 文件,记录这个 .o 依赖哪些头文件:

CFLAGS += -MMD -MP -MF"$(@:%.o=%.d)"

生成的 .d 内容类似:

build/main.o: Core/Src/main.c Core/Inc/main.h Core/Inc/gpio.h

Makefile 末尾把这些 .d 文件全部加载进来:

-include $(wildcard $(BUILD_DIR)/*.d)

结果:你改了 gpio.h,下次 make 会自动重新编译所有依赖于 gpio.h 的文件——完全不用你操心。

编译和链接规则

# 模式规则:任意 .c 文件 → 对应的 .o
$(BUILD_DIR)/%.o: %.c Makefile | $(BUILD_DIR)
	$(CC) -c $(CFLAGS) -Wa,-a,-ad,-alms=$(BUILD_DIR)/$(notdir $(<:.c=.lst)) $< -o $@

# 链接所有 .o 成 .elf
$(BUILD_DIR)/$(TARGET).elf: $(OBJECTS) Makefile
	$(CC) $(OBJECTS) $(LDFLAGS) -o $@

Make 会自动匹配这些规则——你在源码目录下新增一个 led.c,只要它出现在 C_SOURCES 里,Make 就知道怎么编译它。


第三步:安装 OpenOCD

OpenOCD(Open On-Chip Debugger)是开源的烧录/调试工具。从 GitHub Releases 页面下载 Windows 版本:

🔗 https://github.com/openocd-org/openocd/releases

Assets 找到最新版本的 Windows 安装包,解压即可使用。
在这里插入图片描述
同样需要把解压目录的 bin/ 加入 PATH。
在这里插入图片描述
装好验证:

openocd --version

在这里插入图片描述


烧录:OpenOCD 把程序送进芯片

IDE 里点"下载"按钮,背后实际做的也是这些事——只是 OpenOCD 把过程摆在了你面前。

命令行的方式

将STLink接入电脑,直接在终端敲:

openocd -f interface/stlink.cfg \
        -f target/stm32f1x.cfg \
        -c "program build/[在Makefile里面写的项目名称].elf verify reset exit"

这条命令在做什么?

  1. -f interface/stlink.cfg——加载 ST-Link 调试器配置(OpenOCD 内置)
  2. -f target/stm32f1x.cfg——加载 STM32F1 芯片配置(也是内置的)
  3. program <file>——把 .elf 烧进 Flash
  4. verify——烧完后校验数据对不对
  5. reset——复位芯片,让程序从头开始跑
  6. exit——退出

写到 Makefile 里

每次都敲那么长一串很烦。把它写进 Makefile,以后只需要 make flash

flash: $(BUILD_DIR)/$(TARGET).elf
	openocd -f interface/stlink.cfg \
	        -f target/stm32f1x.cfg \
	        -c "program $(BUILD_DIR)/$(TARGET).elf verify reset exit"

flash 前面加了一个依赖 $(BUILD_DIR)/$(TARGET).elf,意思是:烧录之前先确保代码已经编译好。如果你改了代码但忘了 make,直接 make flash 也会自动先编译再烧录。

这里需要注意的是,如果你的编译文件里包括名为flash.cclean.c之类的东西,可能导致make flash不可用,这时只需要在 Makefile 里添加一行.PHONY: flash即可(伪目标语法)。

通信链路

你的电脑 (OpenOCD)
   │ USB
   ▼
ST-Link (调试器,开发板自带的那个)
   │ SWD (两根线: SWDIO + SWCLK)
   ▼
STM32 芯片
   │
   ├── 写入 Flash (0x08000000)
   └── 复位 → 开始跑你的程序

整个链路就是:电脑 → USB → ST-Link → SWD → 芯片 Flash,没有任何 IDE 参与。


完整工作流:从源码到运行

把上面所有的环节串起来,就是一条完整的命令链:

# Step 0: 下载 ARM GNU Toolchain, Make, OpenOCD 并配置好 PATH

# Step 1: 一键编译(自动处理增量编译)
make

# Step 2: 烧录到芯片并运行
make flash

# Step 3: 清理编译产物(可选)
make clean

执行 make 时发生的完整流程:

make
 └── 读取 Makefile
 └── 读取已存在的 .d 文件
 └── 检查哪些 .o 需要重新编译
     └── 对每个过期源文件:
          arm-none-eabi-gcc -c main.c → build/main.o
          arm-none-eabi-gcc -c gpio.c → build/gpio.o
          arm-none-eabi-gcc -c startup.s → build/startup.o
          ...
 └── 链接所有 .o → build/UGUI_Test.elf
 └── objcopy → build/UGUI_Test.hex / .bin

执行 make flash 时的完整流程:

make flash
 └── 先检查 .elf 是否是最新的(不是则先编译)
 └── openocd ... program ... verify reset exit
     └── ST-Link 通过 SWD 写入 Flash
     └── 验证数据完整性
     └── 复位 MCU
     └── 你的程序开始运行!

让我们来看看执行的效果:
在这里插入图片描述
如此一来,我们就成功烧录了。


对比 IDE 方式

一个表格看懂差异:

环节Keil / CubeIDE纯命令行
安装包大小几 GB ~ 十几 GB三个工具不到 1 GB
编译点一下 Build 按钮make 回车
烧录点一下 Download 按钮make flash 回车
增量编译自动(不可控)自动(透明可控)
跨平台仅限 WindowsLinux/macOS/Windows 统一
自动化集成(让服务器自动编译)基本不可能天然支持
项目文件二进制/XML 工程文件纯文本 Makefile
调试内置调试器OpenOCD + GDB(同样可行)
入门门槛稍高(需要理解工具链)

命令行方式的唯一代价就是你需要理解工具链的工作原理,但当你理解了这些底层原理,任何 IDE 对你来说都不再是黑盒。


更进一步:CMake

Makefile 虽然直接,但项目一多、平台一变,维护它就有点累了。这时候可以考虑升级到 CMake

CMake 的思路是:你写一份 CMakeLists.txt 描述项目结构,CMake 根据你指定的工具链自动生成对应的构建文件——可以是 Makefile,也可以是 Ninja、Visual Studio 工程等。换句话说,它不自己编译,而是先当个"翻译官"。

这对嵌入式开发很实用。移植到另一个芯片时,你需要做的只是换一套工具链文件(toolchain file)和改几行配置,项目本身的源文件结构完全不用动。比如同一个 CMakeLists.txt,配不同的 toolchain file 就能分别编译 STM32F1 和 STM32F4 的固件。

当然,对于单个芯片、固定平台的小项目,Makefile 已经够用了,不必强上 CMake。


结语

"告别 IDE"不是目的,理解工具链才是。

IDE 和命令行从来不是对立关系。更好的做法是:先用 IDE 快速上手和理解 MCU 外设配置(CubeMX 的可视化配置确实方便),然后用命令行接管构建过程。用好各自的优势,而不是把自己绑定在任何一个工具上。

当你敲下 make flash,看着代码干净利落地从源码变成芯片上的运行程序——那一瞬间对"编译"二字的理解,远比点一个按钮来得深刻。


本文基于 STM32F103 + GNU Arm Embedded Toolchain 15.2 + GNU Make + OpenOCD 0.12 编写

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐