STM32 可移植教程 01:VSCode 环境搭建 + 点亮 LED(实战篇)
很多人学 STM32,第一个门槛不是 C 语言,也不是 GPIO,而是开发环境。
你用 Keil 写过几篇教程之后,应该已经感觉到:Keil 的编辑器确实不太顺手。代码补全基本没有,跳转定义经常失效,界面风格还停留在十几年前。写几十行代码还行,写几百行就开始难受了。
那能不能用 VSCode 来写 STM32?能。
而且不只是写代码------VSCode 可以负责编辑、编译、下载、调试一整套流程。Keil 退回到只管"编译和下载"的角色,现在连这两个角色都可以交给更现代的工具链:
text
CubeMX(配引脚、生成 Makefile 工程)
→ VSCode(写代码)
→ arm-none-eabi-gcc(编译)
→ OpenOCD(下载、调试)
这篇教程就做一件事:把上面这条工具链从头到尾跑通,并且让你亲手点亮一个 LED。 环境配置和 GPIO 点灯合并成一篇,因为在 VSCode 工具链里,最好的验证方式就是直接跑一个完整的外设工程。
环境通了,LED 闪了,后面蜂鸣器、按键、串口、定时器全都顺了。
还有一个更重要的原因促使我换掉 Keil:手敲代码。
学 STM32,复制粘贴跑通的代码,和一行一行自己敲出来的代码,学到的东西差很远。敲错、报错、排查、修正------这个循环才是真正在学。但 Keil 的编辑器太简陋了,没有像样的代码补全,跳转定义经常失效,在这种环境下逼自己手敲代码,不是锻炼,是折磨。
VSCode 就不一样。IntelliSense 在你敲 HAL_GPIO_ 的时候自动弹出候选,Ctrl+点击直接跳到函数定义,红色波浪线在你编译之前就告诉你哪里写错了。手敲代码从一个痛苦的过程变成了一件顺手的事------你仍然在逐行思考每一行在干什么,但编辑器在帮你省掉那些没有学习价值的机械动作。
所以本系列教程有一个不同于大多数教程的习惯:代码会完整展示在文章里,但不会提供现成的 .c/.h 文件让你直接下载复制。你要对着文章一行一行敲。VSCode 会让你敲得很顺。
本篇目标
看完并照着做完这一篇,你至少要完成这几件事:
- 安装 ARM GCC 编译器(arm-none-eabi-gcc);
- 安装 OpenOCD(用于 ST-Link 下载和调试);
- 安装 GNU Make(构建工具);
- 安装 VS Code 和必备扩展;
- 用 CubeMX 创建一个 Makefile 工程;
- 学会 VSCode 的三个工程配置文件(tasks.json、launch.json、c_cpp_properties.json);
- 在 VSCode 里一键编译;
- 用 OpenOCD 下载程序到开发板;
- 让一个 LED 每 500 ms 闪烁一次。
本篇的跑通标准很简单:
text
VSCode 里按 Ctrl+Shift+B 编译成功
终端里执行 make flash 下载成功
开发板上的 LED 以 500 ms 间隔稳定闪烁
准备工作
你需要准备这些东西:
| 项目 | 说明 |
|---|---|
| 电脑 | Windows 10 / Windows 11 |
| 开发板 | 任意 STM32 开发板,只要板上有 LED 或你外接一个 LED |
| 下载器 | ST-LINK/V2 或板载 ST-LINK |
| USB 线 | 一定要是数据线,不要只用充电线 |
| 软件 | 下面工具安装部分会逐个下载安装 |
| 原理图 | 最好能找到开发板 LED 那一小块原理图 |
为了让教程可移植,我不会把步骤死绑在某一块开发板上。
你手上可以是 STM32F103C8T6 最小系统板、STM32F407 开发板、Nucleo 板、或者公司自己的 STM32 板子。本文以 STM32F103ZET6 做演示,但凡是和具体芯片、具体板子有关的地方,我都会提醒你应该改哪里。
先说清楚:这些工具分别干什么
从 Keil 切到 VSCode 之后,原来 Keil 一个软件做的事情,现在分给了四个工具。新手最容易困惑的就是"为什么装这么多东西"。
可以这样理解:
| 工具 | 原来 Keil 里对应什么 | 干什么 |
|---|---|---|
| CubeMX | Keil 里也能配引脚但不好用 | 图形化配置芯片引脚、时钟、外设,生成初始化代码和 Makefile |
| arm-none-eabi-gcc | ARMCC 编译器(Keil 内置) | 把 C 代码编译成 ARM 机器码 |
| OpenOCD | Keil 内置的 ST-Link Debugger | 通过 ST-Link 把程序下载到芯片,以及调试 |
| GNU Make | Keil 的工程管理和构建系统 | 按照 Makefile 规则调用编译器,管理编译顺序 |
| VS Code | Keil 编辑器 | 写代码、代码补全、跳转定义、集成终端 |
一句话记住:
CubeMX 负责把工程搭起来,VSCode 负责写代码,ARM GCC 负责编译,OpenOCD 负责下载。
第一步:安装 ARM GNU Toolchain
ARM GNU Toolchain 是 ARM 官方维护的开源编译器套件,核心就是 arm-none-eabi-gcc。
下载入口:
text
https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
在页面里找到 Windows 版本,一般文件名类似:
text
arm-gnu-toolchain-13.3.rel1-mingw-w64-i686-arm-none-eabi.exe

安装时注意两项:
- 安装路径不要带中文、空格和特殊符号,推荐类似
C:\arm-gnu-toolchain或D:\Embedded\Tools\arm-gnu-toolchain。 - 安装到最后一步时,一定要勾选 "Add path to environment variable" 。这样装完后在终端里直接敲
arm-none-eabi-gcc就能用。
装完之后验证一下。打开终端(Win+R 输入 cmd,或者 PowerShell),输入:
bash
arm-none-eabi-gcc --version
如果看到类似下面的输出,说明装好了:
text
arm-none-eabi-gcc.exe (Arm GNU Toolchain 15.2.Rel1 (Build arm-15.86)) 15.2.1 20251203
如果提示 'arm-none-eabi-gcc' is not recognized,说明 PATH 没生效。两个可能原因:安装时没勾选"Add to PATH",或者安装后没有重新打开终端。先去检查系统环境变量里有没有 <安装路径>\bin,没有的话手动加上,然后重新打开终端。

第二步:安装 OpenOCD
OpenOCD(Open On-Chip Debugger)是开源调试工具,通过 ST-Link 和芯片通信,负责下载程序和调试。
下载入口(xpack 预编译版,免安装):
text
https://github.com/xpack-dev-tools/openocd-xpack/releases
找到最新的 Windows 版本,一般文件名类似:
text
xpack-openocd-0.12.0-3-win32-x64.zip
下载后解压到一个固定目录,比如 C:\openocd 或 D:\Embedded\Tools\openocd。
然后把 <解压目录>\bin 加入系统 PATH。例如如果你解压到 D:\Embedded\Tools\openocd\xpack-openocd-0.12.0-6-win32-x64\xpack-openocd-0.12.0-6,就要把它的 bin 子目录加到 PATH。

装完之后验证:
bash
openocd --version
期望看到类似:
text
xPack Open On-Chip Debugger 0.12.0+dev-01850-geb6f2745b-dirty (2025-02-07-10:08)

第三步:安装 GNU Make
Make 是构建工具,负责按照 CubeMX 生成的 Makefile 来调用编译器。
如果你的系统已经有 make(可以试试 make --version),就不用再装。没有的话:
方案一(推荐):用 winget 安装。
bash
winget install GnuWin32.Make
方案二:去 https://gnuwin32.sourceforge.net/packages/make.htm 下载安装包。
装完后把 C:\Program Files (x86)\GnuWin32\bin 加入 PATH。
验证:
bash
make --version
期望看到类似:
text
GNU Make 3.81

给不喜欢折腾 PATH 的读者
如果你不想手动加 PATH,也可以把这三个工具的 bin 目录路径记下来,后续在 VSCode 的 tasks.json 里用绝对路径。但长期来看,加到 PATH 里是最省事的。
第四步:安装 VS Code 和必备扩展
VS Code 下载入口:
text
https://code.visualstudio.com/
安装时记得勾选 "Add to PATH" ,这样在终端里可以直接敲 code 打开 VSCode。

装完后打开 VSCode,点左侧的扩展图标(或者按 Ctrl+Shift+X),搜索并安装以下 4 个扩展:
| 扩展名 | 作者 | 用途 |
|---|---|---|
| C/C++ | Microsoft | 代码补全、语法高亮、跳转定义、IntelliSense |
| Cortex-Debug | marus25 | 调试 STM32、查看寄存器和变量 |
| ARM Assembly | dan-c-underwood | ARM 汇编语法高亮(看 startup 文件用) |
| LinkerScript | Zixuan Wang | 链接脚本(.ld 文件)语法高亮 |
不装 Cortex-Debug 也能编译和下载,但后面调试时你会需要它。建议一次装齐。
验证 VSCode 安装:在终端里输入:
bash
code --version
期望看到:
text
1.xx.x
第五步:三个工具都装好的自检清单
在继续之前,请确认这四条命令在终端里都能正常输出(不是报错):
bash
arm-none-eabi-gcc --version
openocd --version
make --version
code --version
四条都通了,说明工具链已就绪。我们开始创建第一个工程。
第六步:用 CubeMX 创建 Makefile 工程
现在我们用 CubeMX 创建一个 LED 点灯工程。和以前 Keil 版教程最大的区别只有一步:Toolchain 选 Makefile 而不是 MDK-ARM。
6.1 新建工程,选芯片
打开 STM32CubeMX,点击:
text
File → New Project
在芯片选择页面输入你的芯片型号。我演示用的是 STM32F103ZET6,你手上是什么芯片就搜什么。

双击芯片型号,进入配置界面。
6.2 配置调试接口(非常重要)
在左侧找到:
text
System Core → SYS
把 Debug 设置为:
text
Serial Wire
为什么必须做这一步: 如果你忘了开 Serial Wire,后面程序可能会把 SWD 调试引脚(PA13/PA14)当普通 GPIO 用了。一旦下载进去,ST-Link 就再也连不上芯片了,只能靠 BOOT0 拉高来恢复。这是新手最常见的"一次性连接"事故。

6.3 配置 LED 引脚为 GPIO 输出
在右侧芯片引脚图上,找到你开发板的 LED 引脚。我的演示板 LED 接在 PB5,你可以根据你的原理图选择。
点击该引脚,在弹出的功能列表中选择:
text
GPIO_Output
然后在引脚上右键,选择 Enter User Label,输入:
text
LED
为什么设置 User Label: User Label 会给引脚起一个有意义的名字。CubeMX 生成代码时,会在 main.h 里自动生成 LED_GPIO_Port 和 LED_Pin 两个宏。你的应用代码里直接用 LED_GPIO_Port 和 LED_Pin,不用记 GPIOB 和 GPIO_PIN_5。换了板子,只要在 CubeMX 里重新给新引脚设置 User Label = LED,代码不用改。

6.4 配置 GPIO 输出参数
在左侧找到:
text
System Core → GPIO
找到你刚才配置的 LED 引脚,确认以下参数:
| 参数 | 值 | 为什么 |
|---|---|---|
| GPIO output level | Low | 初始化时 LED 默认熄灭 |
| GPIO mode | Output Push Pull | 推挽输出,能提供足够的驱动电流 |
| GPIO Pull-up/Pull-down | No pull-up and no pull-down | LED 电路已经确定了电平,不需要上下拉 |
| Maximum output speed | Low | 点灯不需要高速翻转,Low 就够了 |

6.5 时钟配置
新手阶段先不折腾时钟。CubeMX 默认用 HSI(内部高速 RC 振荡器,8 MHz),点灯绰绰有余。
后面做串口、定时器、ADC 等对时钟精度有要求的外设时,我们再去配置 HSE 和 PLL。
6.6 工程管理器配置(关键步骤)
切换到:
text
Project Manager
填写以下内容:
| 选项 | 值 | 说明 |
|---|---|---|
| Project Name | 01_led_gpio |
工程名,见名知义 |
| Project Location | 你的工程根目录,比如 D:\STM32_Project |
建议一个固定目录 |
| Toolchain / IDE | Makefile | 这是和 Keil 版教程唯一不同的地方! |
| 其他选项 | 保持默认 | 不用改 |
为什么选 Makefile 而不是 MDK-ARM: CubeMX 选择 Makefile 后,会生成一个 GNU Make 格式的 Makefile,这个 Makefile 可以被 ARM GCC 编译器直接使用。VSCode 的 tasks.json 通过调用 make 来编译,整套流程都基于这个 Makefile。
然后点击右上角:
text
GENERATE CODE
生成成功后,你的工程目录应该是这样:
text
01_led_gpio/
├── Core/
│ ├── Inc/
│ │ └── main.h # 包含 LED_GPIO_Port 和 LED_Pin 宏
│ └── Src/
│ ├── main.c # 主程序
│ ├── stm32f1xx_hal_msp.c # HAL 底层初始化
│ ├── stm32f1xx_it.c # 中断服务函数
│ └── system_stm32f1xx.c # 系统时钟初始化
├── Drivers/
│ ├── CMSIS/ # ARM Cortex-M 内核文件
│ └── STM32F1xx_HAL_Driver/ # STM32 HAL 库
├── Makefile # 构建规则(自动生成)
├── STM32F103ZETx_FLASH.ld # 链接脚本(自动生成)
└── 01_led_gpio.ioc # CubeMX 工程文件
第七步:VSCode 打开工程,配置三个 JSON 文件
现在用 VSCode 打开刚才生成的工程目录。
在终端里进入工程目录,执行:
bash
code D:\STM32_Project\01_led_gpio
或者在 VSCode 里:File → Open Folder,选择 01_led_gpio 目录。

工程打开后,我们要在 .vscode 目录下创建三个配置文件。这三个文件是 VSCode 管理工程的核心。
先说说这三个文件的关系:
| 文件 | 干什么 | 什么时候用 |
|---|---|---|
tasks.json |
定义任务:编译(make)、清理(make clean)等 | 按 Ctrl+Shift+B 编译时 |
launch.json |
定义调试配置:用什么调试器、下载什么文件 | 按 F5 开始调试时 |
c_cpp_properties.json |
告诉 IntelliSense 去哪找头文件、用哪个编译器 | 写代码时(代码补全、跳转) |
按 Ctrl+Shift+P,输入 task,选择 Tasks: Configure Default Build Task ,然后选 Create tasks.json file from template → Others 。这会创建 .vscode/tasks.json。
把下面的内容替换进去:
json
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "make",
"args": [
"-j8"
],
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": {
"owner": "cpp",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
}
},
{
"label": "clean",
"type": "shell",
"command": "make",
"args": [
"clean"
]
},
{
"label": "flash",
"type": "shell",
"command": "openocd",
"args": [
"-f", "interface/stlink.cfg",
"-f", "target/stm32f1x.cfg",
"-c", "program ${workspaceFolder}/build/${workspaceFolderBasename}.elf verify reset exit"
]
}
]
}
每段干什么用:
- build 任务 :执行
make -j8。-j8表示同时用 8 个线程编译,大大加快编译速度。isDefault: true意味着按Ctrl+Shift+B时直接执行这个任务。 - problemMatcher:用正则匹配 ARM GCC 的错误/警告输出格式,这样编译出错时,VSCode 的 Problems 面板里会列出错误,点击能直接跳到对应行。
- clean 任务 :执行
make clean,清理编译产物。如果你想从头完整重新编译,先 clean 再 build。 - flash 任务 :用 OpenOCD 通过 ST-Link 把编译好的程序下载到芯片。
stm32f1x.cfg是 STM32F1 系列的配置文件,如果你的芯片是 STM32F4,要改成stm32f4x.cfg;如果是 STM32G4,要改成stm32g4x.cfg。
如果你的芯片不是 STM32F1 系列 ,flask 任务里的
target/stm32f1x.cfg要换成你对应系列的配置文件。常见的有:
- STM32F0 →
stm32f0x.cfg- STM32F1 →
stm32f1x.cfg- STM32F4 →
stm32f4x.cfg- STM32F7 →
stm32f7x.cfg- STM32G0 →
stm32g0x.cfg- STM32G4 →
stm32g4x.cfg- STM32L4 →
stm32l4x.cfg
然后创建调试配置。点击左侧调试图标(虫子图标),点击 create a launch.json file ,选择 Cortex Debug。把下面的内容替换进去:
json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug (OpenOCD)",
"cwd": "${workspaceFolder}",
"executable": "${workspaceFolder}/build/${workspaceFolderBasename}.elf",
"type": "cortex-debug",
"request": "launch",
"servertype": "openocd",
"device": "STM32F103ZE",
"configFiles": [
"interface/stlink.cfg",
"target/stm32f1x.cfg"
],
"svdFile": "",
"preLaunchTask": "build",
"runToEntryPoint": "main"
}
]
}
每段干什么用:
- name:调试配置的名称,在 VSCode 调试面板的下拉菜单里显示。
- executable :指向编译生成的
.elf文件。CubeMX 生成的 Makefile 会把编译产物放在build/目录下,文件名和工程名一致。 - type: cortex-debug:使用 Cortex-Debug 扩展作为调试器。
- servertype: openocd:调试后端用 OpenOCD。
- device: STM32F103ZE :告诉 Cortex-Debug 目标芯片型号。这个要改成你自己的芯片型号 ,比如
STM32F103C8、STM32F407ZG、STM32G431RB。 - configFiles:OpenOCD 的配置文件,和 tasks.json 里的 flash 任务一致。
- preLaunchTask: build:每次按 F5 开始调试前,先自动执行 build 任务编译一次。
- runToEntryPoint: main :下载后自动运行到
main()函数入口处暂停。 - svdFile:先留空。SVD 文件能让 Cortex-Debug 显示外设寄存器,后面可以配置。
最后创建 IntelliSense 配置文件。按 Ctrl+Shift+P,输入 c/c++,选择 C/C++: Edit Configurations (JSON)。把下面的内容替换进去:
json
{
"configurations": [
{
"name": "STM32",
"includePath": [
"${workspaceFolder}/Core/Inc",
"${workspaceFolder}/Drivers/CMSIS/Device/ST/STM32F1xx/Include",
"${workspaceFolder}/Drivers/CMSIS/Include",
"${workspaceFolder}/Drivers/STM32F1xx_HAL_Driver/Inc",
"${workspaceFolder}/Drivers/STM32F1xx_HAL_Driver/Inc/Legacy"
],
"defines": [
"STM32F103xE",
"USE_HAL_DRIVER"
],
"compilerPath": "arm-none-eabi-gcc",
"intelliSenseMode": "gcc-arm",
"cStandard": "c11"
}
],
"version": 4
}
每段干什么用:
- includePath :告诉 IntelliSense 去哪里找头文件。这样你写代码时
#include "app_led.h"能找到文件,Ctrl+点击能跳转。如果你用的是其他芯片系列(如 STM32F4),要把路径里的STM32F1xx改成STM32F4xx。 - defines :预定义宏,和 Makefile 里的一致。
STM32F103xE要根据你的芯片型号改------比如 F103C8 是STM32F103xB,F407ZG 是STM32F407xx。这个值可以在 CubeMX 生成代码时看到,或在 Makefile 里搜索-DSTM32F。 - compilerPath :编译器路径。如果你已经把 arm-none-eabi-gcc 加入了 PATH,这里写名字就行。如果 PATH 没生效,要写完整路径,比如
D:/Embedded/Tools/arm-gnu-toolchain/bin/arm-none-eabi-gcc.exe。 - intelliSenseMode: gcc-arm:告诉 IntelliSense 按 ARM GCC 的语法规则来检查代码。
三个 JSON 文件配置完之后,你的 .vscode 目录应该是这样:
text
.vscode/
├── tasks.json
├── launch.json
└── c_cpp_properties.json

第八步:硬件连接
在写代码之前,先把 LED 搞清楚。
不同开发板的 LED 引脚可能不一样:
| 开发板类型 | 常见 LED 引脚示例 |
|---|---|
| STM32F103C8T6 最小系统板 | 常见是 PC13 |
| Nucleo-F103RB | 常见是 PA5 |
| 正点原子 Mini / Elite | 常见是 PE5 / PB5 |
| 普中 F103 开发板 | 可能是 PB5 / PE5 等 |
不要死记这些引脚。你真正要做的是:看原理图,找到 LED 对应的 GPIO。
LED 的有效电平也要注意:
两种常见接法:
text
接法 A(推挽输出高电平亮):
STM32 GPIO 引脚 → 限流电阻 → LED → GND
输出高电平 → LED 亮
接法 B(推挽输出低电平亮):
3.3V → 限流电阻 → LED → STM32 GPIO 引脚
输出低电平 → LED 亮
这不是小细节。 很多新手代码对了、配置对了,LED 就是不亮,最后发现只是有效电平搞反了。
本文演示板用的是 PB5,接法 A(高电平亮)。你的板子可能不同,后面代码里有一个宏专门处理这个问题。

第九步:完整代码
说完硬件,开始写代码。
先说一个重要习惯:尽量对着文章敲代码,不要直接复制粘贴。
复制粘贴看起来省时间,实际上丢了两次学习机会。第一次是你敲错的时候------拼错 GPIO_PIN_SET、漏了 #endif、忘了在 Makefile 里加源文件------排查这些错误的过程,才是真正在学。第二次是你逐行读代码的时候------复制粘贴你不会读,手敲你会自然地在脑子里过一遍每一行在干什么。
如果你实在卡住了、想对照一下完整的源文件,每篇教程的配套资料里都提供了完整源码。但建议先自己敲,敲不通再去看。
和原 Keil 版教程一样,代码放在 Core 目录下:
Core/Inc/app_led.h--- LED 头文件(自己新建)Core/Src/app_led.c--- LED 实现文件(自己新建)
为什么要单独抽 app_led.h / app_led.c,而不是直接写在 main.c 里?
三个原因:
- main.c 是 CubeMX 自动生成的,重新生成时你写在里面的代码可能被覆盖(除非写在 USER CODE BEGIN/END 之间)。
- 后面做蜂鸣器、按键、串口时,每个外设一个文件,工程结构清晰。
- 换板子、换芯片时,外设模块的文件直接复用,改几个宏就行。
app_led.h
创建文件 Core/Inc/app_led.h,内容如下:
c
#ifndef APP_LED_H
#define APP_LED_H
#include "main.h"
void App_LED_Init(void);
void App_LED_On(void);
void App_LED_Off(void);
void App_LED_Toggle(void);
#endif
app_led.c
创建文件 Core/Src/app_led.c,内容如下:
c
#include "app_led.h"
/*
* LED_GPIO_Port and LED_Pin are generated by CubeMX in main.h.
* If your board uses another LED pin, change the pin and label in CubeMX.
*/
#ifndef LED_GPIO_Port
#error "LED_GPIO_Port is not defined. Set the LED pin User Label to LED in CubeMX."
#endif
#ifndef LED_Pin
#error "LED_Pin is not defined. Set the LED pin User Label to LED in CubeMX."
#endif
/*
* Many STM32 boards use active-low LEDs (GPIO_PIN_RESET turns LED ON).
*
* If your LED is active-high (GPIO_PIN_SET turns LED ON), change:
* APP_LED_ON_LEVEL -> GPIO_PIN_SET
* APP_LED_OFF_LEVEL -> GPIO_PIN_RESET
*/
#ifndef APP_LED_ON_LEVEL
#define APP_LED_ON_LEVEL GPIO_PIN_SET
#endif
#ifndef APP_LED_OFF_LEVEL
#define APP_LED_OFF_LEVEL GPIO_PIN_RESET
#endif
void App_LED_Init(void)
{
App_LED_Off();
}
void App_LED_On(void)
{
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, APP_LED_ON_LEVEL);
}
void App_LED_Off(void)
{
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, APP_LED_OFF_LEVEL);
}
void App_LED_Toggle(void)
{
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
这个文件的关键可以这样理解:
#ifndef LED_GPIO_Port+#error--- 如果你在 CubeMX 里忘了给 LED 引脚设置 User Label,编译时会直接报错提醒你。这是一种"让代码自己说话"的写法。APP_LED_ON_LEVEL和APP_LED_OFF_LEVEL--- 通过#ifndef实现可覆盖的默认值。本文默认高电平亮(GPIO_PIN_SET亮灯),如果你的板子是低电平亮,不需要改这个文件,而是在 CubeMX 生成的main.h里,或者在自己的app_config.h里提前定义这两个宏。App_LED_Init()调用App_LED_Off()--- 初始化时默认熄灭 LED。App_LED_On/Off/Toggle--- 都基于HAL_GPIO_WritePin和HAL_GPIO_TogglePin,代码短到不需要注释,函数名已经说明了行为。
把你的源文件加入编译
CubeMX 生成的 Makefile 默认只会编译它自己生成的那几个 C 文件 (main.c、stm32f1xx_it.c 等)。你自己加的 app_led.c 需要手动告诉 Makefile。
打开工程根目录的 Makefile,找到这两行(大约在第 60-80 行附近):
makefile
# C sources
C_SOURCES = \
Core/Src/main.c \
Core/Src/stm32f1xx_hal_msp.c \
...
在 C_SOURCES 列表里加入你自己的源文件:
makefile
# C sources
C_SOURCES = \
Core/Src/main.c \
Core/Src/stm32f1xx_hal_msp.c \
Core/Src/stm32f1xx_it.c \
Core/Src/system_stm32f1xx.c \
Core/Src/app_led.c \
...
后面每新增一个
.c文件,都要在这里加一行。换个角度看,这不是负担------它让你对"工程里有哪些源文件"有清晰的掌控。
第十步:main.c 调用方式
main.c 是 CubeMX 自动生成的,我们只在这三个标记之间写自己的代码:
/* USER CODE BEGIN Includes */....../* USER CODE END Includes *//* USER CODE BEGIN 2 */....../* USER CODE END 2 *//* USER CODE BEGIN WHILE */....../* USER CODE END WHILE */
为什么要遵守 USER CODE 区域的边界? CubeMX 重新生成代码时,会保留 USER CODE 区域里的内容,覆盖区域外的内容。所以你自己写的代码放在这三个区域里最安全。
1. Includes 区域
在 Core/Src/main.c 中找到:
c
/* USER CODE BEGIN Includes */
/* USER CODE END Includes */
在这两行之间加入:
c
/* USER CODE BEGIN Includes */
#include "app_led.h"
/* USER CODE END Includes */
2. 初始化区域
在 Core/Src/main.c 中找到:
c
/* USER CODE BEGIN 2 */
/* USER CODE END 2 */
在这两行之间加入(注意放在 MX_GPIO_Init() 之后,因为 LED 用到了 GPIO):
c
/* USER CODE BEGIN 2 */
App_LED_Init();
/* USER CODE END 2 */
3. while 循环区域
在 Core/Src/main.c 中找到:
c
/* USER CODE BEGIN WHILE */
/* USER CODE END WHILE */
在这两行之间加入 LED 闪烁逻辑:
c
/* USER CODE BEGIN WHILE */
while (1)
{
App_LED_Toggle();
HAL_Delay(500);
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
等一下------你会发现上面这段代码跨了三个区域。这是因为 CubeMX 生成的 while 循环结构是这样的:
c
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
实际上,最干净的写法是这样的:
c
/* USER CODE BEGIN WHILE */
while (1)
{
App_LED_Toggle();
HAL_Delay(500);
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
关键习惯: App_LED_Toggle() 和 HAL_Delay(500) 写在 /* USER CODE END WHILE */ 之前。这样 CubeMX 重新生成时,你的代码不会被覆盖。
第十一步:工程思考------不要用 HAL_Delay 锁住 CPU
上面的闪烁代码能跑,但它有一个隐藏的问题。
c
while (1)
{
App_LED_Toggle();
HAL_Delay(500); // CPU 在这空等 500ms,什么事都干不了
}
HAL_Delay(500) 不是一个"定时器",它是一个死循环。CPU 在里面不停地数数,数满 500ms 才能出去。这 500ms 里 CPU 不能做任何其他事情。
现在工程里只有 LED,你还感觉不到问题。但后面加上按键、串口、蜂鸣器、定时器之后,如果你敢在 while 循环里写一个 HAL_Delay(500),就意味着:
- 按键扫面每 500ms 才能跑一次------按键响应迟钝;
- 串口数据每 500ms 才能处理一次------数据可能丢;
- 蜂鸣器响完之前整个系统卡住不动。
阻塞的本质:CPU 只有一个,你让它空等,所有功能就一起等。
改法:用 HAL_GetTick() 看一眼时间就走
STM32 的 HAL 库在系统启动时就开始维护一个毫秒计数器,HAL_GetTick() 返回从开机到现在的毫秒数。这个函数不阻塞------它只是读一个变量,读完立刻返回。
用它来改造 LED 闪烁:
c
/* USER CODE BEGIN WHILE */
while (1)
{
static uint32_t last_tick = 0;
if (HAL_GetTick() - last_tick >= 500)
{
last_tick = HAL_GetTick();
App_LED_Toggle();
}
// 这里可以放其他事情,每一轮循环都会执行
// App_Key_Scan();
// App_UART_Process();
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
这个代码在做什么:
HAL_GetTick() - last_tick >= 500:看一眼表,"距离上次翻转过去 500ms 了吗?"- 没到 500ms:跳过 if 块,继续往下走。这一轮循环可能只花了几十微秒。
- 到了 500ms:更新
last_tick,翻转 LED。然后继续往下走。 - 下次循环回来再问一遍。
HAL_Delay 是人等车------你站那不动,干等。HAL_GetTick 是你路过站台看一眼------车没来就先干别的,下次路过再瞄一眼。
验证方法
代码改成非阻塞版本后,LED 闪烁效果和之前一模一样------每 500ms 翻转一次。区别在于:CPU 在两次翻转之间的 500ms 里是自由身,可以干其他事了。
现在你可能还没加其他功能,所以视觉上没有区别。但后面每加上一个外设------按键、串口、蜂鸣器------你都会回到这个非阻塞写法上来,因为你会发现再也没有 HAL_Delay 的位置了。
这一篇你只需要记住一句话:HAL_Delay 让 CPU 傻等,HAL_GetTick 让 CPU 可以同时做多件事。 这个意识比你调通任何外设都重要。
第十二步:编译
代码写好了,现在试编译。
在 VSCode 里按:
text
Ctrl+Shift+B
这相当于执行 make -j8。终端面板里会显示编译过程。
首次编译可能比较慢,因为 HAL 库的所有源文件都要编译一遍。后续改了代码再编译,Makefile 只会编译你改动过的文件,很快。
编译成功的标志: 终端输出最后几行没有 Error 字样,并且在 build/ 目录下生成了 01_led_gpio.elf 和 01_led_gpio.hex、01_led_gpio.bin。
text
arm-none-eabi-size build/01_led_gpio.elf
text data bss dec hex filename
3200 12 1572 4784 12b0 build/01_led_gpio.elf

如果编译报错,最常见的原因和解决方法:
| 错误 | 可能原因 | 解决方法 |
|---|---|---|
make: command not found |
make 不在 PATH 里 | 检查 make 是否安装并加入 PATH |
arm-none-eabi-gcc: command not found |
编译器不在 PATH 里 | 检查 ARM GCC 是否安装并加入 PATH |
undefined reference to App_LED_Init |
app_led.c 没加入编译 |
在 Makefile 的 C_SOURCES 里加上 Core/Src/app_led.c |
fatal error: app_led.h: No such file or directory |
app_led.h 路径不对 |
检查文件是否在 Core/Inc/ 下 |
| Makefile 语法错误 | 编辑 Makefile 时多加/少加了反斜杠 \ |
C_SOURCES 每行以 \ 结尾,最后一行不要 \ |
第十三步:下载
编译成功之后,把程序下载到开发板。
先确认三件事:
- 开发板已经通过 ST-Link 连接到电脑。
- 设备管理器里能正常识别 ST-Link(没有黄色感叹号)。
- USB 线是数据线(不是只能充电的线)。
在 VSCode 的终端里执行:
bash
make flash
前提是你的 tasks.json 里已经配置了 flash 任务(见第七步)。这个命令会调用 OpenOCD,通过 ST-Link 下载 build/01_led_gpio.elf 到芯片。
也可以在 VSCode 里按 Ctrl+Shift+P,输入 run task,选择 flash。
下载成功的标志: 终端输出最后几行出现:
text
** Programming Finished **
** Verify Started **
** Verified OK **
shutdown command invoked

下载成功后,按一下开发板的复位键(如果板上有),或者程序会自动运行。你应该能看到:
text
LED 以 500 ms 间隔闪烁(亮半秒、灭半秒,一直循环)
第十四步:调试(可选,但建议试一试)
上面用 make flash 只是下载。如果想调试------比如想看变量值、单步执行、设断点------就要用到 Cortex-Debug。
按 F5(或点左侧调试面板的绿色播放按钮),VSCode 会:
- 先编译(因为
preLaunchTask: build); - 启动 OpenOCD 连接到 ST-Link;
- 下载程序到芯片;
- 暂停在
main()函数入口; - 显示调试工具栏(继续、暂停、单步、跳出、重启、停止)。

你可以试一个简单的调试操作:
- 在
App_LED_Toggle()所在行点一下行号左侧,打一个红色断点。 - 按 F5 开始调试,程序会在断点处停住。
- 按 F10 单步执行,观察 LED 的变化。
- 按 F5 继续运行。
调试体验,VSCode + Cortex-Debug 明显比 Keil 强------变量窗口、寄存器窗口、调用堆栈窗口都可以自由拖拽排列,代码浏览也快很多。
如果按 F5 调试不成功,先检查:
| 现象 | 优先检查 |
|---|---|
| 调试按钮是灰的 | Cortex-Debug 扩展没装 |
| 报 "Unable to start debugging" | launch.json 里的 device 名称不对,要和芯片型号匹配 |
| OpenOCD 连不上 | ST-Link 接线、驱动、SWD 配置 |
| 程序下载后不运行 | launch.json 里没配 runToEntryPoint,或者需要手动按 Continue |
移植到其他板子的修改点
换一块开发板,LED 照样能跑。你需要改的地方:
| 要改的地方 | 为什么要改 | 在哪里改 |
|---|---|---|
| 芯片型号 | 不同芯片外设和引脚复用不同 | CubeMX 新建工程时重新选芯片 |
| LED 引脚 | 每块板子的 LED 接到不同 GPIO | CubeMX Pinout 界面,给新引脚设 User Label = LED |
| 有效电平 | 有的板子高电平亮,有的低电平亮 | app_led.c 里 APP_LED_ON_LEVEL / APP_LED_OFF_LEVEL |
| STM32 系列 | F1/F4/G4 的 HAL 库路径不同 | c_cpp_properties.json 里 includePath;tasks.json 里 OpenOCD target cfg |
| 芯片具体型号 | 不同型号的 FLASH/RAM 大小不同 | Makefile 里的 LDSCRIPT(链接脚本),CubeMX 会自动生成对应 .ld |
| HSE 频率 | 不同板子的外部晶振可能不同 | CubeMX Clock Configuration(后续用外部时钟时才需要) |
记住一个原则:网上教程用哪块板不重要,重要的是你知道自己的芯片型号、LED 接到哪个引脚、有效电平是什么。
常见问题排查
1. 终端里 arm-none-eabi-gcc 报 "not recognized"
| 优先检查 | 具体做法 |
|---|---|
| 安装没勾选 PATH | 重新安装 ARM GCC,勾选 "Add to PATH",或手动加 bin 目录到系统环境变量 |
| 终端没重启 | 把当前终端关掉,重新打开 |
| 安装路径有问题 | PATH 里 bin 目录的路径要和实际安装路径一致 |
2. make 编译报 "make: command not found"
| 优先检查 | 具体做法 |
|---|---|
| make 没装或没加 PATH | 用 winget 安装或手动加 C:\Program Files (x86)\GnuWin32\bin 到 PATH |
| 终端没重启 | 重启终端 |
3. OpenOCD 连不上芯片
按这个顺序查:
| 顺序 | 检查项 | 说明 |
|---|---|---|
| 1 | USB 线 | 确认是数据线,不是只能充电的线 |
| 2 | ST-Link 驱动 | 设备管理器中不能有黄色感叹号 |
| 3 | 供电 | 目标板要有电(3.3V) |
| 4 | 共地 | ST-Link 的 GND 和开发板 GND 要连通 |
| 5 | SWD 接线 | SWDIO(PA13)、SWCLK(PA14)不要接反 |
| 6 | CubeMX SYS Debug | 必须设置为 Serial Wire |
| 7 | OpenOCD 配置文件 | F1 系列用 stm32f1x.cfg,F4 系列用 stm32f4x.cfg |
4. LED 不亮
| 优先检查 | 具体做法 |
|---|---|
| 程序是否真的下载进去了 | 看 make flash 的输出,确认 Verified OK |
| 引脚对不对 | 在 CubeMX 里确认 LED 引脚和你原理图上的 GPIO 一致 |
| 有效电平对不对 | 高电平亮的 LED,APP_LED_ON_LEVEL 应该是 GPIO_PIN_SET;低电平亮的应该是 GPIO_PIN_RESET |
| LED 极性 | 万用表二极管档直接测一下 LED 是否完好,方向是否焊对 |
| 限流电阻 | 检查是否焊接正常,阻值是否合适 |
| User Label 是否设置 | 在 CubeMX 里确认 LED 引脚设置了 User Label = "LED" |
| 代码是否被编译进去了 | 检查 Makefile 的 C_SOURCES 里是否包含 Core/Src/app_led.c |
5. 编译报 "undefined reference to App_LED_xxx"
这说明 app_led.c 没有被编译。去 Makefile 的 C_SOURCES 列表里加上 Core/Src/app_led.c \。
6. IntelliSense 报错(红色波浪线),但编译通过了
IntelliSense 的配置(c_cpp_properties.json)和实际编译用的是不同配置。优先信任编译结果。
| 优先检查 | 具体做法 |
|---|---|
| includePath 是否正确 | 对照实际目录结构检查路径 |
| defines 是否正确 | STM32F103xE / USE_HAL_DRIVER 是否包含 |
| intelliSenseMode | 应该是 gcc-arm |
| 重启 IntelliSense | Ctrl+Shift+P → C/C++: Reset IntelliSense Database |
7. 下载后芯片再也连不上了
很可能是你把 SWD 调试引脚(PA13/PA14)用成了普通 GPIO。
解决方法:
- 把 BOOT0 拉高(接 3.3V),按复位键。
- 用 CubeProgrammer 或 OpenOCD 重新连接、擦除芯片。
- 把 BOOT0 恢复到低电平。
- 在 CubeMX 里确保
SYS → Debug → Serial Wire是打开的。
建议你建立一个固定工程目录
新手阶段不要把工程丢得到处都是。建议建一个目录:
text
D:\STM32_Project
以后每一篇教程一个文件夹:
text
D:\STM32_Project
├── 01_led_gpio
├── 02_buzzer_gpio
├── 03_key_input
├── 04_key_debounce
├── 05_key_exti
├── 06_usart_printf
└── ...
如果你习惯用 Git,也可以从第一篇就养成习惯:
bash
cd D:\STM32_Project\01_led_gpio
git init
git add .
git commit -m "01: VSCode env setup + LED GPIO"
不会 Git 也没关系,先把工程跑通最重要。
本篇小结
这一篇我们做了三件事:装好了全新的 VSCode 开发环境,点亮了第一个 LED,还学会了一个比点灯本身更重要的习惯------不让 CPU 傻等。
你可以按这张清单确认一下:
| 检查项 | 是否完成 |
|---|---|
| arm-none-eabi-gcc 能正常输出版本号 | |
| openocd 能正常输出版本号 | |
| make 能正常输出版本号 | |
| VS Code 安装了 C/C++、Cortex-Debug 扩展 | |
| CubeMX 能生成 Makefile 工程 | |
| VSCode 三个 JSON 配置文件就位 | |
| Ctrl+Shift+B 编译通过(0 Error) | |
| make flash 下载(Verified OK) | |
| LED 以 500 ms 间隔闪烁(非阻塞写法) | |
| 能说清楚 HAL_Delay 和 HAL_GetTick 的区别 |
做到最后一条,你的新工具链就已经打通了,而且一上手就写了一个真正工程级的 LED 闪烁------不用 HAL_Delay,用 HAL_GetTick 看一眼时间就走。后面加按键、串口、蜂鸣器,这个非阻塞的骨架直接复用。
以后每一篇教程的业务代码,和原来 Keil 版的完全一样------CubeMX 配置、HAL 调用、app_xxx.h/c 封装、移植方式------换的只是编辑器、编译器和调试器的使用方式。每篇后面都会像这篇一样,在基础功能跑通之后,加一节工程思考,往实战方向多走一步。
下一篇预告
LED 亮了之后,下一篇我们加上蜂鸣器:让板子响一声,同时 LED 翻转一次。
STM32 零基础可移植教程 02:蜂鸣器响一声,LED 跟着翻转一次。