STM32 上手:CubeMX + HAL 点亮第一个 LED
- 搞清 CubeMX 和 HAL 各是什么、为什么 STM32 几乎离不开它们
- 用 CubeMX 图形化选芯片、配一个 LED 输出引脚、配好时钟、生成 HAL 工程
- 在生成工程的 USER CODE 区写 while(1) 点灯,用 ST-Link 烧进去
- 拿一张 HAL ↔ ESP-IDF 对照表,把已有的 ESP32-S3 经验平移到 STM32
你前面几关都是在 ESP32-S3 上跑的:idf.py set-target、menuconfig、gpio_set_level,一套链路熟得不能再熟。现在因为某个项目——可能是要便宜的 BOM、要更狠的实时性、要一颗你能买到现货的 MCU——你得碰 STM32 了。一打开 STM32CubeMX,满屏的引脚阵列、一棵看不懂的时钟树、各种 AF1/AF2 复用功能号,瞬间有点劝退:怎么点个灯比 ESP32 难这么多?
别慌。STM32 不是更难,是把 ESP-IDF 帮你藏起来的东西摊到了你面前——时钟怎么来、引脚复用成什么、外设挂在哪条总线,这些 ESP-IDF 在 menuconfig 和默认配置里替你定了,STM32 让你用一个图形化工具 CubeMX 自己点。点完它生成一份 HAL(硬件抽象层)工程,你在里面写业务。这一篇就带你走完这条完整链路:选芯片 → 配引脚 → 配时钟 → 生成工程 → 点灯 → 烧录,再给你一张对照表,把你脑子里的 ESP-IDF 经验一条条平移过来。
这是 S 卷(STM32 迁移)的第一篇代码课,得把丑话说在前头:STM32 的 CubeMX / HAL 代码和你具体用的芯片型号、HAL 库版本、CubeMX 版本强相关。 本篇给的是主干流程参考——函数名、宏、引脚号、GPIOx/GPIO_PIN_y 这些,请以**你自己用 CubeMX 实际生成出来的工程 + ST 官方 HAL 文档(UM1725 这类)**为准。照搬别人帖子里的代码经常对不上型号——而这恰恰是该让 AI copilot 帮你抹平 HAL 样板的场景:把"我要 F103C8 的 PC13 配成推挽输出"喂给它,让它照 CubeMX 生成的结构填,你来核对引脚和总线。本篇不把任何一行 HAL 代码呈现为"确定无误"。
读这篇前,你最好已经在 ESP32-S3 上点亮过第一个 LED——知道"配引脚为输出、在循环里翻电平、加延时"这套点灯逻辑。本篇做的是同一件事,只是换到 STM32 的工具链上,你会发现思路一模一样,只是路径不同。
先搞清楚:CubeMX 和 HAL 分别是什么
STM32 的开发体验,绕不开 ST 自己的两件套,你得先分清它俩。
STM32CubeMX——一个图形化配置 + 代码生成器。 你在它的界面上:在芯片引脚图上点一个引脚,把它配成"GPIO 输出";在一棵时钟树界面上点几下,告诉芯片"用 8MHz 外部晶振,倍频到 72MHz 跑";勾上要用的外设(UART、SPI、定时器……)。点完 GENERATE CODE,它自动生成一整套初始化代码——把你刚才点的配置翻译成一堆 MX_GPIO_Init()、SystemClock_Config() 之类的 C 函数,连工程文件(Keil / STM32CubeIDE / Makefile)都给你建好。你不用手写一行寄存器配置。
HAL(Hardware Abstraction Layer,硬件抽象层)——ST 官方的驱动库。 CubeMX 生成的代码就是调用 HAL 库的。HAL 把"往某个寄存器写某几位"这种底层操作,包装成 HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET) 这种一看就懂、且跨 STM32 系列通用的函数。同一句 HAL_GPIO_TogglePin,在 F1、F4、G4、H7 上都能用,ST 替你抹平了不同系列寄存器的差异——这就是"抽象层"三个字的意思。
类比一下你熟的:CubeMX ≈ idf.py + menuconfig 的图形化版(帮你生成工程骨架 + 配置外设),HAL ≈ ESP-IDF 的那套 driver API(gpio_set_level 那一层)。ESP-IDF 把这两件事捏在命令行和 C 头文件里,STM32 把"配置"这一半挪到了图形界面。本质都是:先配置外设,再调驱动 API 干活。
为什么 STM32 要搞个 CubeMX?ESP32 怎么不用
你可能会问:ESP32-S3 我 idf.py set-target esp32s3 一句就完事,STM32 怎么非得点半天图?
因为 STM32 把两件 ESP-IDF 默认帮你定了的事,交给了你:
- 时钟树复杂,手配极易错。 STM32 的时钟不是一个频率,是一棵树:外部晶振(HSE)或内部 RC(HSI)进来 → 经过 PLL 倍频 → 分给 CPU、AHB 总线、APB1/APB2 外设总线……每一级都有分频系数。配错一个分频,轻则外设跑不起来,重则
HAL_Delay(500)延时根本不准。CubeMX 的时钟树界面会实时算出每条总线的频率、超频会标红,这是手填寄存器给不了的安全网。 - 引脚复用满天飞。 STM32 一个物理引脚能复用成 GPIO、UART_TX、SPI_MOSI、定时器通道里的好几个,靠"复用功能号(AF)"切换。哪个外设能用哪个引脚、冲突了没有,CubeMX 的引脚图会实时高亮、冲突变红。ESP32-S3 有 GPIO 矩阵,大部分外设能映射到几乎任意引脚,约束松得多,所以 ESP-IDF 里你直接在代码里写引脚号就行。
一句话:STM32 的硬件更"裸",约束更多,所以需要一个图形工具替你管住时钟和引脚这两个最容易翻车的地方。 这不是麻烦,是它给你的可控性——代价就是上手那张时钟树图。
完整流程:从选芯片到点灯
下面走一遍最小闭环。我以最经典的**"蓝丸"开发板(STM32F103C8T6)** 为例,它板载 LED 在 PC13(低电平点亮)。换 G4、F4 或别的板子,引脚和芯片型号自己对着改,流程完全一样。
第 1 步:CubeMX 选芯片、配引脚、配时钟
- 打开 STM32CubeMX,
File → New Project,在芯片选择器里搜你的型号(如STM32F103C8),双击进入引脚配置界面(Pinout view)。高频坑:型号别选错。 F103C8 和 F103CB 只差 Flash 大小、F103 和 F104 是两回事。选错型号生成的代码烧进去,轻则外设地址对不上,重则根本跑不起来。照着你手里芯片上激光打印的那行字选。
- 在引脚图上找到 PC13,左键点它,在弹出菜单里选 GPIO_Output。这一步等价于 ESP-IDF 里
gpio_set_direction(GPIO_NUM_x, GPIO_MODE_OUTPUT)——告诉芯片这个脚是输出。 - 切到 Clock Configuration(时钟树) 标签页。蓝丸板上有 8MHz 外部晶振,所以在左侧 PLL Source 选 HSE,把右边的 HCLK 目标填成 72(MHz)然后回车,CubeMX 会自动反推中间各级分频系数把它配通。看一眼所有数字都没标红,就说明时钟配好了——这一步省了,
HAL_Delay会不准甚至外设乱套。 - 切到 Project Manager 标签页:填工程名、选你的 Toolchain/IDE(STM32CubeIDE / MDK-ARM Keil / Makefile 任选)。强烈建议勾上
Code Generator → Generate peripheral initialization as a pair of '.c/.h' files per peripheral,生成的代码更清爽。 - 点右上角 GENERATE CODE。CubeMX 生成整个工程并可一键打开。
第 2 步:在 main.c 里写点灯逻辑
打开生成的 Core/Src/main.c,你会看到 main() 里已经被 CubeMX 填好了一串初始化。找到 while(1) 那段,在它的 USER CODE 区写点灯:
/* USER CODE BEGIN WHILE */
while (1)
{
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); // 翻转 PC13 电平
HAL_Delay(500); // 阻塞延时 500ms
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
整个 main() 生成出来大概长这样(中间的初始化是 CubeMX 写的,你别动):
int main(void)
{
HAL_Init(); // HAL 库初始化(配 SysTick、Flash 等),CubeMX 生成,必有
SystemClock_Config(); // 应用你在时钟树里配的 72MHz,CubeMX 生成
MX_GPIO_Init(); // 应用你配的 PC13 输出,CubeMX 生成
/* USER CODE BEGIN 2 */
/* USER CODE END 2 */
while (1)
{
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
HAL_Delay(500);
}
}
三句生成的初始化各管一摊,正好对应前面讲的:HAL_Init() 起 HAL 库本身,SystemClock_Config() 落地你点的那棵时钟树,MX_GPIO_Init() 落地你配的 PC13。你只在 while(1) 里翻电平 + 延时,跟 ESP32 点灯的循环体一模一样。
这是 CubeMX 工程最致命、也最高频的坑:用户代码必须写在 /* USER CODE BEGIN x */ 和 /* USER CODE END x */ 这对注释之间。 写在外面的代码,下次你回 CubeMX 改个配置、再点 GENERATE,会被它原样重新生成时整段冲掉,你的代码就没了。 CubeMX 重新生成时只保留这些 USER CODE 配对区里的内容,区外一律按模板覆盖。养成习惯:所有自己的代码,只往 BEGIN/END 之间塞。
第 3 步:烧录进去
STM32 没有 ESP32 那种串口直接下载,你需要一个 ST-Link 调试器(几块钱一个的克隆版就够用),它的 4 根线接到板子的 SWD 口:3V3 / GND / SWDIO / SWCLK。两条路烧:
- 图形化:STM32CubeProgrammer。ST 官方烧录工具,连上 ST-Link,选你编译出的
.hex或.bin,点 Download。或者直接在 STM32CubeIDE / Keil 里点那个"下载"按钮,IDE 内部就是调它。 - 命令行:OpenOCD(配合 ST-Link),适合脱离 IDE 的 Makefile 工作流,
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c "program firmware.bin verify reset exit"。
你应该看到什么
烧录成功后板子自动复位运行,板载 LED 开始 1 秒一个周期闪烁(亮 500ms、灭 500ms)。蓝丸的 PC13 是低电平点亮,所以 TogglePin 翻转时你看到的就是规律的一明一暗。
如果烧进去灯不闪、长亮或长灭——先别怀疑代码,八成是时钟没配对(HAL_Delay 不工作)或引脚选错。对着下面的排查表查。
HAL ↔ ESP-IDF 对照表(S 卷的灵魂)
这张表是这一卷的核心。你已经会 ESP-IDF 了,迁移到 STM32 不用从零学,把对应关系记住就行:
| 你在 ESP-IDF 里这么做 | STM32 HAL 里对应这么做 | 说明 |
|---|---|---|
idf.py set-target + menuconfig 配外设 |
CubeMX 图形化配引脚/时钟 + GENERATE | 都是"配置阶段",一个命令行一个图形界面 |
app_main(void) 入口 |
int main(void) + while(1) |
STM32 是裸机风格的标准 C main |
gpio_set_direction(.., GPIO_MODE_OUTPUT) |
在 CubeMX 里把引脚点成 GPIO_Output | STM32 引脚方向在 CubeMX 里配,不在代码里 |
gpio_set_level(GPIO_NUM_2, 1) |
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET) |
写引脚电平 |
| (ESP-IDF 无现成翻转,要先读再写) | HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13) |
HAL 直接给了翻转,点灯更省事 |
gpio_get_level(GPIO_NUM_x) |
HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) |
读引脚电平 |
vTaskDelay(pdMS_TO_TICKS(500)) |
HAL_Delay(500) |
注意:HAL_Delay 是阻塞死等;vTaskDelay 是 RTOS 让出 CPU。裸机 HAL 默认前者 |
ESP_LOGI(TAG, "...") 打印 |
没有现成的! 要自己配 UART + 重定向 printf | 见下方变体二,这是 STM32 新手第一个不适应的地方 |
最值得记的两条:一是 STM32 引脚方向在 CubeMX 里配,不在代码里(ESP-IDF 是代码里 set_direction);二是 STM32 没有 ESP_LOGI 这种开箱即用的日志,想 printf 调试得自己搭 UART,下面变体二讲。
为什么深一层:HAL、LL、寄存器三层怎么选
HAL 不是 STM32 编程的唯一一层。从抽象到裸,STM32 有三层可写,你该知道它们的取舍:
- HAL(高层抽象):
HAL_GPIO_WritePin这种。跨系列通用、好读、CubeMX 默认生成。代价是有一层封装开销,函数里做了不少参数检查和状态管理,对极致性能/极小代码体积场景偏重。新手和绝大多数项目,用 HAL 就对了。 - LL(Low-Layer,低层库):ST 提供的另一套更薄的库,
LL_GPIO_SetOutputPin(GPIOC, LL_GPIO_PIN_13)这种,几乎是寄存器操作的轻量包装,快、小,但不跨系列那么方便。CubeMX 里可以选生成 LL 代码。等你做到对速度/Flash 抠门的产品时再考虑。 - 直接操作寄存器:
GPIOC->ODR ^= (1 << 13)。最快最小,但最难写最难移植,得啃 datasheet 的寄存器表。除非你在抠中断里的几个时钟周期,否则别一上来就钻这层。
一句话:HAL 上手、LL 提速、寄存器榨干。本篇和 S 卷主线都用 HAL——它和你熟的 ESP-IDF driver API 是一个抽象高度,迁移最顺。
故障排查:点不亮 / 烧不进,按这个查
| 现象 | 最可能的原因 | 怎么办 |
|---|---|---|
| 灯完全不闪(长灭或长亮) | 时钟树没配对,HAL_Delay 不工作 |
回 CubeMX 时钟页确认 HCLK 配通没标红;确认 SystemClock_Config() 在 main 里被调到 |
| 改完配置重新生成后,代码没了 | 代码写到了 USER CODE BEGIN/END 区外 | 所有自己的代码必须写在配对注释之间,区外会被覆盖 |
| 灯闪但翻得不对/引脚不对 | GPIOx / GPIO_PIN_y 选错或板子 LED 不在这脚 |
对着你板子的原理图确认 LED 接哪个脚;蓝丸是 PC13 |
| ST-Link 连不上("no target / cannot connect") | SWD 接线错 / 没供电 / 芯片在低功耗 | 查 SWDIO/SWCLK/GND/3V3 四线;CubeProgrammer 里把模式设成 Connect under reset;按住板子复位再连 |
| 烧进去后一直起不来,反复重启 | BOOT0 跳线没设对 | BOOT0 要接 GND(从 Flash 启动);很多蓝丸出厂 BOOT0 跳线在 1(从系统存储器启动),烧完记得拨回 0 再复位 |
编译报找不到 HAL_GPIO_* |
HAL 库没被工程引入 / 用了 LL 模式 | 确认 CubeMX 生成时选的是 HAL(不是 LL);确认 stm32fxxx_hal.h 被包含 |
| 型号选错,外设地址对不上 | CubeMX 里选的芯片和手里的不是同一颗 | 照芯片上激光打印的型号重选,重新生成 |
BOOT0 跳线是 STM32 新手最隐蔽的坑。 STM32 靠 BOOT0(有的板还有 BOOT1)引脚电平决定从哪启动:接 GND 从用户 Flash 启动(跑你的程序),接 VCC 从系统存储器启动(进 ST 内置的串口 bootloader)。很多蓝丸出厂或上次串口烧录后跳线停在"1",这时你用 ST-Link 烧进去了程序却死活不运行——因为它根本没从 Flash 启动。把 BOOT0 拨回 0、复位,程序就跑了。
变体一:读一个按键(HAL_GPIO_ReadPin)
点灯是输出,反过来读输入也就一步。在 CubeMX 里把某个引脚(比如 PA0)点成 GPIO_Input(需要的话在右下角配上拉/下拉),重新生成,然后在 while(1) 里读它:
/* USER CODE BEGIN WHILE */
while (1)
{
// 假设按键按下时 PA0 为低电平(接了上拉)
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
{
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // 按下亮(PC13 低电平点亮)
}
else
{
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // 松开灭
}
HAL_Delay(20); // 简单去抖
/* USER CODE END WHILE */
}
HAL_GPIO_ReadPin 返回 GPIO_PIN_SET(高)或 GPIO_PIN_RESET(低),对照你按键的接法判断即可。这就是 ESP-IDF 里 gpio_get_level 的 STM32 版。
变体二:串口打印(HAL_UART_Transmit + 重定向 printf)
STM32 上你会立刻想念 ESP32 的 ESP_LOGI——HAL 没有现成日志,得自己配 UART 把字打出去。在 CubeMX 里把一个 USART(比如 USART1,引脚 PA9/PA10)的 Mode 选成 Asynchronous、波特率 115200,生成后它会给你一个 huart1 句柄。最直接的发法:
/* USER CODE BEGIN WHILE */
char msg[] = "hello from STM32\r\n";
while (1)
{
HAL_UART_Transmit(&huart1, (uint8_t *)msg, sizeof(msg) - 1, 100); // 末尾 100 是超时 ms
HAL_Delay(1000);
/* USER CODE END WHILE */
}
想直接用 printf,还得重定向 printf 的底层输出——把 C 库的 _write(GCC)或 fputc(Keil/ARMCC)指向 UART。这块写法因工具链而异,放在 USER CODE 区或单独文件里:
/* GCC 工具链(STM32CubeIDE)放在 USER CODE 区或单独 .c */
int _write(int fd, char *ptr, int len)
{
HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 100);
return len;
}
/* 之后就能 printf("temp=%d\r\n", t); 了 */
printf 重定向的函数名(_write vs fputc)、要不要加 setvbuf 关缓冲、Keil 还要勾 Use MicroLIB——这几样强依赖你的工具链和 HAL 版本,上面是 GCC 路线的参考写法,以你工程实际工具链 + ST 文档为准。这正是"STM32 没有 ESP_LOGI"带来的额外功课:日志这件 ESP-IDF 一行就有的事,在 STM32 上得你自己搭一次。搭好之后体验就和 ESP_LOGI 差不多了。
动手挑战
光看不动手,CubeMX 那套手感练不出来。给自己一个任务:用 CubeMX 从零做一个"按键控制 LED + 串口打印状态"的工程。
- 新建工程,选你手里的芯片型号,配一个 LED 输出脚、一个按键输入脚、一个 USART(115200)。把时钟树配通(HCLK 拉到你芯片的标称主频,别标红)。
- GENERATE CODE,在
while(1)的 USER CODE 区写:读按键 → 按下点灯、松开灭灯 → 每次状态变化用HAL_UART_Transmit打一行"LED ON"/"LED OFF"。 - 用 ST-Link + CubeProgrammer 烧进去,接个 USB 转 TTL 到 USART 引脚,串口助手打开看打印。
- 故意制造那个坑:回 CubeMX 改个无关配置、重新 GENERATE,看你写在 USER CODE 区里的代码是不是还在;再试着把一行代码写到 BEGIN/END 外面重新生成,亲眼看它被冲掉——这一下你就永远记住 USER CODE 区的规矩了。
- 把 BOOT0 拨到 1 复位一次,看程序为什么不跑,再拨回 0——亲手验证 BOOT0 的作用。
做完这一套,STM32 的"配置-生成-编码-烧录"闭环你就摸通了。卡住了,把 CubeMX 的配置截图、main.c 的 USER CODE 段、报错原文一起发给 AI,讲清"我配了什么、期望什么、实际什么"——HAL 样板多,这种活让 AI copilot 帮你抹平最划算。
小结 · 你现在掌握了什么
- 你分清了 CubeMX(图形化配引脚/时钟 + 生成工程,≈ idf.py + menuconfig)和 HAL(跨系列通用的驱动库,≈ ESP-IDF 的 driver API)。
- 你走通了 STM32 第一个工程的完整闭环:选芯片 → 配 PC13 输出 → 配时钟树 → GENERATE → 在 USER CODE 区写
HAL_GPIO_TogglePin+HAL_Delay→ ST-Link 烧录 → 看灯闪。 - 你拿到了 HAL ↔ ESP-IDF 对照表,能把已有的 ESP32-S3 经验平移:
gpio_set_level↔HAL_GPIO_WritePin、vTaskDelay↔HAL_Delay、app_main↔main()+while(1);也记住了两个差异——引脚方向在 CubeMX 配、STM32 没有现成的ESP_LOGI。 - 你知道了几个 STM32 真坑:USER CODE 区外的代码会被重新生成冲掉、时钟没配
HAL_Delay不准、BOOT0 跳线决定能不能从 Flash 启动、芯片型号别选错。 - 你清楚 HAL / LL / 寄存器三层的取舍:HAL 上手、LL 提速、寄存器榨干,主线用 HAL。
- 最重要的一条:本篇 HAL 代码是主干流程参考,函数名/宏/引脚强依赖你的芯片型号与 HAL 版本,以 CubeMX 实际生成的工程 + ST 官方 HAL 文档为准,别当成确定无误。
第一个工程点亮了,下一步就该让 STM32 真正干活——用 HAL 玩转那些常用外设(UART 中断收发、定时器、ADC、PWM、I2C/SPI)。接着看 HAL 常用外设速通,把这一卷的硬件能力补齐。想回看整条迁移路线,去 STM32 迁移卷总览。