【工作空间】告别 IDE:一套纯命令行嵌入式开发工作流——基于 VSCode/GCC/Makefile/OpenOCD
写在前面
入坑嵌入式开发的人,第一课多半是这样的:打开 Keil(或者 IAR、STM32CubeIDE),点几下鼠标创建工程,在编辑框里写代码,然后点那个绿色的 “Build” 按钮。编译、烧录、调试,全在一个软件里完成——IDE 帮你把一切都打包好了。
这很舒服。但你有没有想过:IDE 背后到底发生了什么?
双击一个按钮,几个 G 的软件就开始全自动运转,中间的细节全都藏起来了。而当你有一天需要让服务器自动帮你编译、或者仅仅是偶尔想用 VS Code 加命令行来写代码——IDE 的"便利"就成了枷锁。
为什么我要"告别 IDE"?
说实话,我并不是反对 IDE。Keil 和 CubeIDE 在调试上确实有不可替代的优势。但我想说的是:我们可以不依赖它们。
独立于 IDE 之外构建的好处:
- 环境可复制——一个 Makefile + 依赖文件就能在任意机器上复现构建,不怕环境迁移
- 方便自动化——比如让服务器每天自动帮你编译一次,服务器上没有图形界面,不可能跑 IDE,但命令行工具可以
- 更清晰的认知——当你知道每一行命令行参数在做什么,你就真正理解了编译的本质
- 轻量灵活——没有 IDE 的数 GB 安装包,一个文本编辑器 + 工具链就可以开工
- 版本控制纯文本——Makefile 和配置文件都是文本文件,放进 Git 管理比 IDE 的二进制项目文件舒服太多
快速上手:从 CubeMX 出发
最快的方式,就是在 STM32CubeMX 里正常配置你的芯片和外设,我以最简单的OLED和按键配置为例,看看能否成功移植UGUI图形库。

Project Manager 页面将 Toolchain / IDE 选为 Makefile,点击生成。

CubeMX 会替你生成一套完整的 GCC 工程——包含 Makefile、链接脚本、启动文件和 HAL 驱动。你不需要手动搭建任何东西,拿到手就是一个可以直接 make 的项目。
工具链全家桶
这条工作流需要三个核心工具,各司其职:
- Arm GNU Toolchain(
arm-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\bin和D:\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
text14KB → 代码占 Flashdata12B → 已初始化的变量占 RAM(同时在 Flash 存备份)bss3332B → 未初始化的变量占 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"
这条命令在做什么?
-f interface/stlink.cfg——加载 ST-Link 调试器配置(OpenOCD 内置)-f target/stm32f1x.cfg——加载 STM32F1 芯片配置(也是内置的)program <file>——把.elf烧进 Flashverify——烧完后校验数据对不对reset——复位芯片,让程序从头开始跑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.c或clean.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 回车 |
| 增量编译 | 自动(不可控) | 自动(透明可控) |
| 跨平台 | 仅限 Windows | Linux/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 编写
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)