
1. 为什么要在 VSCode 里用 PlatformIO 搭 STM32Cube 工程如果你之前一直用 Keil MDK 或者 STM32CubeIDE 开发 STM32第一次听说“在 VSCode 里用 PlatformIO 跑 STM32Cube 框架”这件事大概率会有一个疑问这不是多此一举吗官方 IDE 图形化配置、一键生成代码、点一下就能编译下载为什么还要折腾命令行和配置文件我一开始也是这么想的直到项目里同时出现了三块不同厂商的芯片——一块 STM32、一块 ESP32、一块国产 RISC-V 内核的 MCU。如果每换一块芯片就换一套 IDE、换一套工程结构、换一套编译脚本光是环境切换就够让人崩溃。PlatformIO 最大的价值就在这里它把“工程结构”和“编译工具链”统一了你只需要在platformio.ini里改几行配置就能在同一套编辑器里管理不同架构的芯片工程。但 PlatformIO 默认给 STM32 用的是它自己封装的一套框架基于 libOpenCM3 或者 STM32Cube HAL 的封装很多从 CubeMX 转过来的开发者会发现我 CubeMX 里配好的时钟树、外设初始化代码怎么塞进 PlatformIO 工程里这就是这篇要解决的核心问题——在 PlatformIO 里使用 STM32Cube 框架而不是 PlatformIO 自带的 STM32 框架然后用最经典的 LED 闪烁来验证整条链路是否跑通。LED 闪烁这个例子看起来简单到不值一提但它其实是一个“最小可验证闭环”时钟配置对不对、GPIO 初始化有没有生效、编译链接有没有问题、下载烧录能不能成功、复位后程序能不能正常运行——这五个环节任何一个出问题LED 都不会闪。所以拿它当第一个工程来验证环境是最经济的选择。这篇文章适合三类人第一类是从 Keil/CubeIDE 想迁移到 VSCode 的 STM32 开发者第二类是已经用 PlatformIO 但只会用它默认框架、想接入 CubeMX 生成代码的人第三类是同时维护多种芯片平台、想统一开发环境的嵌入式工程师。下面我会从环境准备开始一步步把整个工程搭起来中间踩过的坑和绕过的弯路都会写清楚。2. 环境准备VSCode、PlatformIO 与工具链的安装细节2.1 VSCode 与 PlatformIO 插件的安装VSCode 的安装没什么好说的官网下载对应系统的安装包一路下一步就行。需要注意的是如果你之前装过其他嵌入式插件比如 Cortex-Debug、STM32 相关的插件建议先确认它们不会和 PlatformIO 冲突。我遇到过 Cortex-Debug 的配置和 PlatformIO 的调试配置互相覆盖的情况后来干脆在 PlatformIO 工程里只用 PlatformIO 自带的调试功能把 Cortex-Debug 的全局配置清掉了。PlatformIO 在 VSCode 里是以插件形式存在的。打开扩展面板搜索 “PlatformIO IDE”认准那个下载量最高的、图标是蚂蚁的插件。安装过程会稍微久一点因为它会自动下载 PlatformIO Core也就是命令行工具 pio。安装完成后左侧活动栏会出现一个蚂蚁图标那就是 PlatformIO 的入口。这里有个细节很多人会忽略PlatformIO Core 的安装路径默认在用户目录下的.platformio文件夹里。Windows 是C:\Users\你的用户名\.platformioLinux/macOS 是~/.platformio。这个目录后面会经常用到因为工具链、框架包、下载器驱动都放在这里。如果你 C 盘空间紧张可以在安装前设置环境变量PLATFORMIO_CORE_DIR指向其他盘但一旦装好了再迁移会比较麻烦建议一开始就规划好。2.2 STM32 工具链的自动下载与手动确认PlatformIO 的一个好处是当你第一次编译 STM32 工程时它会自动下载对应的工具链toolchain-gccarmnoneeabi和框架包。但这个过程在国内网络环境下可能会很慢甚至失败因为它的包服务器在境外。我的做法是先创建一个空的 STM32 工程让它触发工具链下载然后去.platformio/packages目录下确认几个关键包是否到位包名作用是否必须toolchain-gccarmnoneeabiARM Cortex-M 的 GCC 编译器必须framework-stm32cubeSTM32Cube 框架包HAL 库必须tool-stm32duinoSTM32duino 相关工具视情况tool-openocdOpenOCD 下载调试工具调试时需要tool-stlinkST-Link 下载工具用 ST-Link 时需要如果自动下载失败可以手动去 PlatformIO 的包仓库下载对应的压缩包解压到.platformio/packages目录下文件夹名字要和 PlatformIO 期望的一致。这个操作稍微有点繁琐但比反复重试网络要靠谱。注意framework-stm32cube这个包很大因为它包含了 STM32 全系列的 HAL 库。如果你只用某一款芯片可以在platformio.ini里通过board参数限定PlatformIO 会尽量只下载需要的部分但实际测试下来它还是会拉整个框架包。2.3 下载器驱动的确认LED 闪烁工程最终要烧到板子上才能验证。你需要确认自己用的下载器是什么ST-Link、J-Link、DAPLink还是串口 ISP。不同的下载器在platformio.ini里的upload_protocol配置不一样。我用的是最常见的 ST-Link V2Windows 下需要装 ST-Link 的驱动。装完之后在设备管理器里应该能看到 “STMicroelectronics STLink dongle” 这样的设备。如果显示的是未知设备或者带黄色感叹号说明驱动没装好后面 PlatformIO 上传时会报 “no device found” 的错误。Linux 下通常不需要额外装驱动但需要配置 udev 规则否则普通用户没有权限访问 USB 设备。这个规则文件一般在 PlatformIO 的安装目录里能找到复制到/etc/udev/rules.d/下然后重新加载 udev 规则即可。3. 创建工程从 PlatformIO 模板到 CubeMX 代码的融合3.1 用 PlatformIO 创建 STM32 工程的正确姿势在 VSCode 里按CtrlShiftP打开命令面板输入 “PlatformIO: New Project”然后依次选择Name工程名比如stm32cube_ledBoard搜索你的芯片型号比如STM32F103C8也就是常说的蓝板Framework这里先随便选一个因为后面我们要手动改成 STM32CubeLocation工程存放路径创建完成后PlatformIO 会生成一个标准的工程结构stm32cube_led/ ├── .pio/ # 编译输出目录自动生成 ├── include/ # 头文件目录 ├── lib/ # 第三方库目录 ├── src/ # 源代码目录 │ └── main.cpp # 默认生成的入口文件 ├── test/ # 测试目录 └── platformio.ini # 工程配置文件默认生成的main.cpp用的是 Arduino 风格的代码因为 PlatformIO 默认给 STM32 配的是 Arduino 框架。我们要做的是把它改成 STM32Cube 框架然后用自己的 HAL 代码替换掉。3.2 platformio.ini 的关键配置项拆解platformio.ini是整个工程的灵魂所有编译、下载、调试的行为都由它控制。下面是我实际用的配置逐项解释[env:genericSTM32F103C8] platform ststm32 board genericSTM32F103C8 framework stm32cube upload_protocol stlink debug_tool stlink build_flags -D STM32F103xB -D USE_HAL_DRIVER -I include -I src/Core/Inc -I src/Drivers/STM32F1xx_HAL_Driver/Inc -I src/Drivers/CMSIS/Device/ST/STM32F1xx/Include -I src/Drivers/CMSIS/Include build_src_filter * -Core/Src/main.c逐项说明platform ststm32指定了平台是 ST 的 STM32 系列。board genericSTM32F103C8指定了具体板子这个 generic 版本适用于大多数 F103C8T6 的最小系统板。framework stm32cube是核心它告诉 PlatformIO 使用 STM32Cube 框架而不是默认的 Arduino 框架。upload_protocol stlink和debug_tool stlink指定了下载和调试工具是 ST-Link。如果你用的是 DAPLink改成upload_protocol cmsis-dap即可。build_flags里的内容是最容易出问题的部分。-D STM32F103xB是芯片的宏定义告诉 HAL 库当前用的是哪款芯片。这个宏必须和你的芯片型号严格对应写错了会导致寄存器定义不匹配编译能过但运行会出问题。-D USE_HAL_DRIVER是启用 HAL 驱动的标志。后面的-I是头文件搜索路径必须把 CubeMX 生成的Core/Inc、HAL 驱动目录、CMSIS 目录都加进去否则编译时会报找不到头文件。build_src_filter里的-Core/Src/main.c是为了排除 CubeMX 生成的main.c因为 PlatformIO 默认会编译src目录下的所有源文件而我们要用自己的main.cpp作为入口两个main函数会冲突。3.3 把 CubeMX 生成的代码搬进 PlatformIO 工程这一步是整个流程里最容易出错的地方。CubeMX 生成的工程结构是这样的CubeMX_Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f1xx_hal_conf.h │ │ └── stm32f1xx_it.h │ └── Src/ │ ├── main.c │ ├── stm32f1xx_it.c │ ├── stm32f1xx_hal_msp.c │ └── system_stm32f1xx.c ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ │ ├── Inc/ │ │ └── Src/ │ └── CMSIS/ │ ├── Device/ST/STM32F1xx/Include/ │ └── Include/ └── MDK-ARM/ (或 STM32CubeIDE/)我的做法是在 PlatformIO 工程的src目录下建一个和 CubeMX 一样的结构把Core和Drivers两个目录整个复制进去。注意是复制到src下面不是工程根目录。这样build_flags里的路径才能对应上。复制完成后src目录结构变成src/ ├── Core/ │ ├── Inc/ │ └── Src/ ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ └── main.cpp (我们自己写的入口)这里有个坑CubeMX 生成的main.c里包含了SystemClock_Config()、MX_GPIO_Init()这些初始化函数以及while(1)主循环。如果你直接把它复制过来然后改后缀为.cpp会有一堆 C/C 混编的问题。我的建议是保留main.c不动但用build_src_filter把它排除掉然后自己写一个main.cpp在里面调用 CubeMX 生成的初始化函数。但这里又有一个问题main.c里的SystemClock_Config()和MX_GPIO_Init()是static的外部无法调用。解决办法是在main.h里把这两个函数的声明改成非 static或者在main.cpp里重新实现一遍。我选择的是后者——直接在main.cpp里写时钟配置和 GPIO 初始化不依赖 CubeMX 的main.c。这样虽然多写几行代码但工程结构更清晰也不会被 CubeMX 重新生成代码时覆盖掉。4. LED 闪烁代码的完整实现与逐行解析4.1 时钟配置为什么 72MHz 不是随便写的STM32F103C8T6 的最高主频是 72MHz这个数字来自它的时钟树设计。外部晶振通常是 8MHz经过 PLL 倍频后得到 72MHz。具体的计算路径是8MHzHSE÷ 1PLL 预分频 8MHz然后 8MHz × 9PLL 倍频 72MHz。这个 72MHz 再经过 AHB 预分频器不分频、APB1 预分频器÷2 得到 36MHz、APB2 预分频器÷1 得到 72MHz最终分配给各个外设。为什么 APB1 要除以 2因为 STM32F1 系列的 APB1 总线最高只能跑 36MHz而 APB2 可以跑 72MHz。如果你把 APB1 也设成 72MHz虽然芯片可能还能跑但已经超出规格了长期运行稳定性没有保证。在main.cpp里时钟配置的代码如下void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct {0}; RCC_OscInitStruct.OscillatorType RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState RCC_HSE_ON; RCC_OscInitStruct.HSEPredivValue RCC_HSE_PREDIV_DIV1; RCC_OscInitStruct.HSIState RCC_HSI_ON; RCC_OscInitStruct.PLL.PLLState RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL RCC_PLL_MUL9; if (HAL_RCC_OscConfig(RCC_OscInitStruct) ! HAL_OK) { Error_Handler(); } RCC_ClkInitStruct.ClockType RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider RCC_HCLK_DIV2; RCC_ClkInitStruct.APB2CLKDivider RCC_HCLK_DIV1; if (HAL_RCC_ClockConfig(RCC_ClkInitStruct, FLASH_LATENCY_2) ! HAL_OK) { Error_Handler(); } }注意FLASH_LATENCY_2这个参数。当主频超过 48MHz 时Flash 读取需要插入等待周期否则 CPU 取指会出错。72MHz 对应的是 2 个等待周期这个值不能随便改。4.2 GPIO 初始化推挽输出与开漏输出的选择LED 接在哪个引脚上取决于你的板子。我用的最小系统板上板载 LED 接在 PC13 上而且是低电平点亮LED 阳极接 VCC阴极接 PC13。所以配置的时候要输出高电平让 LED 熄灭输出低电平让 LED 点亮。void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOC_CLK_ENABLE(); HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); GPIO_InitStruct.Pin GPIO_PIN_13; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOC, GPIO_InitStruct); }这里有几个细节值得说__HAL_RCC_GPIOC_CLK_ENABLE()必须在配置 GPIO 之前调用否则 GPIO 寄存器写不进去。这是新手最常犯的错误之一——代码看起来没问题但 LED 就是不亮查半天发现是时钟没使能。GPIO_MODE_OUTPUT_PP是推挽输出。推挽输出的特点是能主动输出高电平和低电平驱动能力强。如果是开漏输出GPIO_MODE_OUTPUT_OD高电平需要外部上拉电阻才能拉高适合 I2C 这类需要线与的总线。驱动 LED 用推挽就够了。GPIO_SPEED_FREQ_LOW是输出速度。LED 闪烁这种应用对速度没要求设成 Low 可以降低电磁干扰和功耗。如果是驱动 SPI 或者 PWM就需要设成 High。HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET)这一句是在初始化之前先把引脚置高防止在配置过程中 LED 出现一闪而过的现象。这个技巧在驱动继电器或者蜂鸣器时特别有用可以避免上电瞬间的误动作。4.3 主循环与延时HAL_Delay 的底层逻辑主循环很简单while (1) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); HAL_Delay(500); }HAL_GPIO_TogglePin每次调用翻转引脚电平HAL_Delay(500)延时 500 毫秒。所以 LED 会以 1 秒为周期闪烁500ms 亮500ms 灭。HAL_Delay的底层实现依赖 SysTick 定时器。SysTick 是 Cortex-M 内核自带的一个 24 位递减计数器HAL 库把它配置成 1ms 中断一次HAL_Delay就是在这个中断里递减计数来实现延时的。这里有个隐藏的坑HAL_Delay在中断上下文里调用会导致死锁。因为HAL_Delay依赖 SysTick 中断来更新计数如果你在比 SysTick 优先级更高的中断里调用它SysTick 中断进不来计数永远不更新程序就卡死了。所以HAL_Delay只能在主循环或者低优先级中断里用。另外HAL_Delay的延时精度受中断影响。如果系统里有其他高优先级中断频繁触发HAL_Delay的实际延时会比设定值长。对于 LED 闪烁这种应用无所谓但如果是精确的时序控制就需要用定时器或者 DWT 周期计数器来实现。4.4 完整的 main.cpp 代码把上面的片段拼起来完整的main.cpp如下#include stm32f1xx_hal.h void SystemClock_Config(void); void MX_GPIO_Init(void); void Error_Handler(void); int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); HAL_Delay(500); } } void Error_Handler(void) { __disable_irq(); while (1) { } }HAL_Init()会初始化 Flash 接口、SysTick 定时器和 NVIC 中断优先级分组。这一步必须在任何 HAL 函数调用之前执行。Error_Handler里用了__disable_irq()关闭全局中断然后死循环。这是嵌入式里常见的错误处理方式——出了问题就停在那里方便调试器捕获现场。实际产品里可能会改成复位或者进入低功耗模式。5. 编译、下载与验证整条链路的实测记录5.1 编译过程中的常见报错与解决第一次编译大概率不会一次通过。我整理了几个最常见的报错和对应的解决办法报错一fatal error: stm32f1xx_hal.h: No such file or directory这说明头文件搜索路径没配对。检查build_flags里的-I路径是否和实际目录结构一致。注意 PlatformIO 的路径是相对于工程根目录的不是相对于src目录。报错二undefined reference to HAL_Init这说明 HAL 库的源文件没有被编译进去。检查src/Drivers/STM32F1xx_HAL_Driver/Src/目录下是否有对应的.c文件以及build_src_filter是否把它们排除了。默认情况下 PlatformIO 会编译src下所有源文件不需要额外配置。报错三multiple definition of main这是因为 CubeMX 的main.c和我们的main.cpp同时被编译了。在build_src_filter里加上-Core/Src/main.c把它排除掉。报错四region FLASH overflowed by X bytes这说明代码量超过了芯片的 Flash 容量。STM32F103C8T6 只有 64KB Flash如果开了太多 HAL 模块或者用了 printf 浮点输出很容易超。解决办法是关掉不用的 HAL 模块或者在stm32f1xx_hal_conf.h里把对应的HAL_XXX_MODULE_ENABLED注释掉。5.2 下载配置与 ST-Link 连接要点编译通过后点击 PlatformIO 底部的 “Upload” 按钮它会调用 ST-Link 把固件烧进去。如果连接正常你会看到类似这样的输出Configuring upload protocol... Looking for upload port... Uploading .pio/build/genericSTM32F103C8/firmware.elf xPack OpenOCD, x86_64 Open On-Chip Debugger 0.11.0dev Info : STLINK V2J37S7 (API v2) VID:PID 0483:3748 Info : Target voltage: 3.3V Info : stm32f1x.cpu: hardware has 6 breakpoints, 4 watchpoints ** Programming Started ** ** Programming Finished ** ** Verify Started ** ** Verified OK **如果卡在 “Looking for upload port” 不动通常是 ST-Link 驱动没装好或者 USB 线接触不良。换一根线试试或者重新插拔 ST-Link。如果报 “Target voltage: 0.0V”说明 ST-Link 没有检测到目标板的供电。检查板子是否上电以及 ST-Link 的 SWD 线是否接对。SWD 接口需要接四根线3.3V、GND、SWDIO、SWCLK。有些板子还需要接 RST但大多数情况下不接也能下载。5.3 验证 LED 闪烁与常见异常排查下载完成后板子会自动复位运行。如果一切正常LED 应该以 1 秒为周期闪烁。如果没有闪按以下顺序排查第一步确认 LED 的极性。用万用表量一下 LED 两端的电压如果 PC13 输出低电平时 LED 两端有 2V 左右的压降说明 LED 在亮如果一直是 0V 或者 3.3V说明 GPIO 没有翻转。第二步确认程序是否真的在运行。可以在while(1)里加一个计数器用调试器查看计数器的值是否在变化。如果计数器不变说明程序卡在了某个地方大概率是HAL_Init或者SystemClock_Config里。第三步确认时钟配置是否正确。如果 HSE 起振失败HAL 库会超时然后调用Error_Handler程序就死在Error_Handler的死循环里了。可以临时把时钟源改成 HSI内部 8MHz RC 振荡器看看 LED 是否能闪。如果能闪说明是外部晶振的问题检查晶振是否焊接良好、负载电容是否匹配。第四步确认下载的固件是不是最新的。有时候 PlatformIO 会缓存旧的编译结果导致下载的还是旧固件。执行一次 “Clean” 再重新编译下载。6. 从 LED 闪烁延伸出去的工程化经验6.1 把 CubeMX 重新生成代码纳入版本管理CubeMX 的一个痛点是每次重新生成代码它都会覆盖main.c里的用户代码段。虽然它用/* USER CODE BEGIN */和/* USER CODE END */标记了用户代码区域但如果你把代码写在了区域外面就会被冲掉。我的做法是CubeMX 只用来生成初始化代码生成完之后把Core和Drivers目录复制到 PlatformIO 工程里然后就不再直接用 CubeMX 的工程了。如果后面要改外设配置就在 CubeMX 里改好、重新生成、再复制过来用 Git 对比差异手动合并需要的部分。这样做的代价是每次改配置都要手动同步但好处是 PlatformIO 工程里的代码完全可控不会被 CubeMX 意外覆盖。对于外设配置不经常变的项目这个代价是可以接受的。6.2 多环境配置与条件编译platformio.ini支持多个[env:xxx]段可以针对不同的板子或者不同的编译选项定义不同的环境。比如[env:debug] build_flags ${env.build_flags} -D DEBUG_MODE [env:release] build_flags ${env.build_flags} -D RELEASE_MODE -Os这样在编译时可以选择 debug 环境带调试输出、不优化或者 release 环境关调试输出、开优化。对于 LED 闪烁这种小工程可能用不上但项目稍微大一点就很有用。6.3 调试配置用 ST-Link 在 VSCode 里打断点PlatformIO 自带的调试功能基于 OpenOCD 和 GDB。在platformio.ini里配置好debug_tool stlink之后按 F5 就可以启动调试会话。可以打断点、单步执行、查看变量和寄存器。我实测下来PlatformIO 的调试体验比 Keil 稍差一点主要是变量查看的响应速度慢一些但基本功能都有。如果你习惯用 Keil 的调试器可能需要适应一下。但好处是可以在 VSCode 里同时写代码和调试不用来回切换窗口。调试时有个小技巧在main.cpp的开头加一句volatile uint32_t debug_counter 0;然后在while(1)里debug_counter。调试时 watch 这个变量如果它在变化说明程序在正常运行如果不变说明卡住了。这个方法比打断点更轻量不会影响程序时序。6.4 这套方案适合什么场景不适合什么场景最后说一下这套方案的适用边界。如果你做的是以 STM32 为主、外设配置不复杂、希望统一开发环境的项目这套方案很合适。PlatformIO 的库管理、多平台支持、命令行编译都是实打实的效率提升。但如果你做的是高度依赖 CubeMX 图形化配置、外设频繁变动、团队里其他人只用 Keil 的项目强行迁移到 PlatformIO 可能会增加沟通成本。工具是为人服务的不是反过来。我自己的做法是新项目用 PlatformIO老项目继续用原来的工具链不为了统一而统一。另外PlatformIO 对 STM32 新芯片的支持有时候会滞后于官方。比如某款新出的 STM32 型号可能 CubeIDE 已经支持了但 PlatformIO 的ststm32平台还没更新。这种情况下要么等更新要么手动改平台配置会比较折腾。选型的时候要把这个因素考虑进去。