08 这个编号摆在系列里其实挺微妙。前面几篇一直在聊嵌入式软件怎么和 AI 编程配合、提示词该写到什么颗粒度、模型能替你干什么不能替你干什么,说到底都还是纸上的功夫。到了第八篇,终于要落一块真实的板子,建第一个 STM32 工程。我自己的习惯是:第一个工程宁可小、宁可丑,但必须能跑通、能看到现象、能一遍遍复现。嵌入式软件开发跟纯应用层代码不一样,代码敲完只是开始,时钟、引脚、下载器、驱动、编译选项,任何一环没对上,板子就是一块安静的塑料片,你甚至不知道是自己写错了还是线接反了。这篇就把第一个 STM32 工程的完整路径摊开讲,包括环境怎么搭、AI 在哪些环节真能省事、哪些环节必须自己盯死,以及我这些年反复踩过的坑。
1. 第一个 STM32 工程,慢下来才是真的快
1.1 这个工程真正练的不是“点灯”
很多人对第一个 STM32 工程的印象就是点个 LED,觉得没技术含量,急着跳到定时器、串口、PID 这些看起来更高级的东西上去。我的看法正好相反:第一个工程的核心价值不在功能,而在于把“从想法到板子上跑起来”这条链路完整走通一遍。你要亲手经历选芯片、装工具链、配时钟、生成骨架、写业务代码、编译、下载、复位、观察现象这一整条路径,任何一环缺失,后面的项目都会在同一个地方翻车。
举个很常见的场景:有人照着视频把点灯代码抄了一遍,编译下载都成功了,灯也亮了,但一换到自己画的板子上就不亮。原因往往不是代码,而是他从来不知道自己那块芯片的外部晶振是多少兆、CubeMX 里 HSE 填的是多少、板子上 LED 接的是哪个引脚、是高电平点亮还是低电平点亮。这些细节在抄代码的时候全被隐藏掉了,只有自己从头做一遍,才会被迫去搞清楚。
所以我把第一个工程的定位定成三个词:小、可观测、可复现。小是指功能少到一眼能看完;可观测是指每一步操作都有对应的现象反馈,比如灯闪、串口出字、调试器能停住;可复现是指换一台电脑、换一块同型号板子,照着记录还能再跑一遍。这三条做到了,你后面做温湿度计、做电机控制、做车载以太网通信,遇到问题时至少有一套自己验证过的锚点可以对照。
1.2 AI 在这个阶段能省你多少事
聊到嵌入式软件 AI 编程,很多人的第一反应是“让模型直接把整个工程写出来”。我实测下来,在第一个工程这个阶段,AI 最有价值的不是替你写完所有代码,而是三件事:第一,帮你生成结构正确的模板代码,比如 GPIO 初始化、串口重定向、定时器中断回调这些有固定套路的片段;第二,帮你解释报错,尤其是 Keil 那种一句话里塞了五六个信息的编译错误,丢给模型让它翻译成人话,效率比翻手册高;第三,帮你整理提示词和检查清单,把你的硬件条件翻译成结构化的描述,减少来回试错。
但AI也有一件事干不了,而且这个阶段特别危险:它不知道你的板子长什么样。模型不知道你的晶振是 8M 还是 25M,不知道你的 LED 接在 PA5 还是 PC13,不知道你的下载器是 ST-Link 还是 J-Link,更不知道你的供电是 3.3V 还是 5V。你如果不把这些信息喂给它,它就会按最常见的情况“猜”,而猜错的代价是你花两个小时排查一个根本不存在的代码问题。我踩过的最典型的一次,是模型给我写了一段 STM32F4 的 GPIO 初始化,而我的板子是 F103,寄存器基地址对不上,编译能过,运行直接进 HardFault。
提示:把 AI 当成一个手速极快、但完全不认识你硬件的实习生。所有涉及具体引脚、时钟频率、电压等级的信息,必须由你写在提示词里,写完之后自己再核对一遍。
1.3 我给第一个工程定的目标
具体一点,第一个 STM32 工程我建议只做三件事:一个 LED 周期性闪烁、一路串口能打印文字、一个按键能改变闪烁频率。就这三样。LED 验证 GPIO 输出和时钟配置,串口验证引脚复用、波特率和 printf 重定向,按键验证 GPIO 输入和消抖逻辑。三样加起来代码量不超过两百行,但把 STM32 最核心的几条路都摸了一遍。
为什么不建议一开始就上定时器、PWM、ADC?不是说不能学,而是第一个工程的目标是建立信心和排查手感。功能一多,出错时的变量就多,你分不清是配置错了、代码错了还是硬件错了。等功能少的时候把现象调到“预期什么就出现什么”,再往上加模块,每加一个就只引入一个新变量,排查起来才轻松。
2. 环境搭建:工具链选对,能省一半返工
2.1 Keil5 与芯片包,绕开下载慢和版本错配
Windows 下做 STM32 开发,Keil MDK5 还是主流选择,生态资料多、教程全,遇到问题容易搜到答案。安装本身没什么难度,真正卡人的是芯片包。Keil 装完之后默认是不带 STM32 器件库的,你新建工程时会发现器件列表里空空如也,或者只有几个 ARM 的通用核。这时候要装 STM32F1xx_DFP 这类器件支持包。
常规做法是在 Keil 里点 Pack Installer 在线下载,但网速和服务器状态经常不配合,几十兆的包下半小时是常事。我的建议是直接去芯片厂商官网找对应系列的 DFP 包,手动下载 .pack 文件,双击就能安装,速度快且稳定。版本选择上有个小坑:不用追最新。新版本包有时候会把旧的启动文件或头文件改名,你抄的教程里用的还是老版本,编译就会报找不到文件的错。选一个和教程、和 CubeMX 输出兼容性都经过验证的版本更稳妥,装完在 Pack Installer 里能看到已安装状态就说明生效了。
注意:如果电脑上同时装了 Keil C51 和 MDK,两者共用部分注册表和目录结构,装的时候顺序和路径要留意,否则容易出现打开工程提示器件缺失、或者 C51 的器件跑到 MDK 列表里这种怪现象。装完两个环境后,各自新建一个空工程验证一遍,别等到写了一半才发现工具链是坏的。
2.2 CubeMX 生成骨架,先配 Debug 引脚再生成
CubeMX 的价值在于把时钟树、引脚复用、外设初始化这些容易出错的部分图形化,生成的代码结构也相对规范。新建工程时先选芯片型号,比如很常见的 STM32F103C8T6,选完进入配置界面。这里有个顺序我觉得很重要:先配 SYS 里的 Debug 模式,把它设成 Serial Wire,再去配时钟和外设。
为什么先配这个?因为 STM32 的 SWD 调试引脚默认是复用给调试器的,如果你在引脚分配里不小心把它当成普通 GPIO 用了,芯片就会被锁住,下次下载器连不上,只能靠复位时序或者擦除整片来救。CubeMX 里把 Debug 设成 Serial Wire,就是在代码里保留这两个引脚给调试用,避免你自己手滑。
时钟配置里,HSE 选 Crystal/Ceramic Resonator,然后在 Clock Configuration 页面填实际晶振频率,常见的是 8MHz,PLL 倍频到 72MHz。生成的代码会自动配置时钟树,但前提是你填的数对。我见过有人板子上是 8M 晶振,CubeMX 里默认留了 25M,结果所有基于时钟的延时、波特率全偏,现象就是串口打印乱码、延时时间不对。这种问题从代码上完全看不出来,必须回到硬件参数上核对。
2.3 下载器与驱动,一次装对省心
下载调试器这块,ST-Link 是性价比很高的选择,兼容性和资料都齐全,接 SWD 四根线就够:3.3V、GND、SWDIO、SWCLK。接线时有个细节容易被忽略——很多小板的 3.3V 引脚是给外部供电还是作为参考电压,用途不一样。稳妥的做法是用板子自己的电源供电,下载器只接 GND、SWDIO、SWCLK 三根,参考电压接不接看你的下载器要求,别贸然把 5V 接到 3.3V 的引脚上。
驱动装完之后,最好先用厂商提供的独立下载工具连一次芯片,能识别到芯片型号和 Flash 容量,说明链路是通的。之后再回到 Keil 里配置调试器,这样出问题时能快速判断是硬件链路问题还是开发环境问题。J-Link 也常用,功能更强,但正版价格高,兼容版在部分型号上需要额外配置,新手第一个工程用 ST-Link 更省心。
3. 把需求翻译成提示词:让模型写出能用的第一版代码
3.1 一条能用的提示词长什么样
嵌入式软件 AI 编程里,提示词的质量几乎决定生成代码的可用比例。我总结的模板是“芯片型号 + 库类型 + 外设 + 引脚 + 期望行为 + 约束条件”六段式。举个我自己用过的例子:
芯片:STM32F103C8T6,HSE 8MHz,主频 72MHz 库:STM32 HAL 库,CubeMX 生成的工程骨架 需求:PC13 上的 LED 每 500ms 翻转一次,主循环里用 HAL_Delay 实现 约束: 1. 不要修改 CubeMX 生成的初始化代码 2. 所有外设时钟使能放在 MX_GPIO_Init 里,用 __HAL_RCC_GPIOx_CLK_ENABLE 3. 不要在中断里调用 HAL_Delay 4. 代码加中文注释,说明每一行的作用这个提示词的好处是把模型最容易搞错的几个点提前锁死了:芯片型号决定寄存器和外设数量,库类型决定 API 长什么样,引脚决定初始化哪个端口,约束条件避免它生成阻塞式代码或者乱改初始化。你把这段丢给任意一个主流模型,生成的代码基本能直接编译,剩下要改的只是细节。
反过来说,如果你只写一句“帮我写个 STM32 点灯程序”,模型就会给你一段极其通用的代码,引脚随机、库不确定、时钟没配,你还得从头改到尾,最后还不如自己写。省下来的时间全花在改代码上,这就是典型的负收益。
3.2 AI 生成代码的三类硬伤
用 AI 辅助写嵌入式代码,有三类错误出现频率特别高,必须养成习惯性检查。第一类是 API 幻觉,模型会编出一个看起来很像但实际不存在的函数名,比如把 HAL_GPIO_TogglePin 写成 HAL_GPIO_Toggle,或者参数顺序搞反。编译时报“未定义引用”就是这类问题的信号。
第二类是时钟使能遗漏。写寄存器风格代码时,模型经常忘了先使能 GPIO 或外设时钟,代码逻辑完全正确,但外设就是不工作。HAL 库因为 CubeMX 已经生成好了初始化,这个问题会少一些,但如果你让模型在初始化函数外面额外操作某个引脚,它可能不会补上使能语句。
第三类是中断上下文误用。模型很容易在中断回调里直接写 HAL_Delay,因为从语言上看这样写最简洁。但 HAL_Delay 依赖 SysTick 中断累加计数,如果你当前就在一个优先级不低于 SysTick 的中断里,SysTick 无法抢占,计数永远不更新,代码就卡死在那里。这个问题运行时才暴露,而且表现是“程序不动了”,排查起来很费时间。
提示:拿到 AI 生成的代码后,先做三件事再编译——查函数名是否存在于头文件、查用到的 GPIO 端口有没有使能时钟、查有没有在中断里调用阻塞函数。这三步花两分钟,能省掉后面两小时的调试。
3.3 人工复核清单
我给自己的复核清单是这样的:
- 芯片型号和外设数量对得上,F103C8 没有某些高级定时器,别用错
- 引脚编号和实际板子一致,PC13 不是 PA13
- 时钟频率和晶振一致,HSE_VALUE 宏定义对不对
- 所有用到的 GPIO 端口都使能了时钟
- 没有在中断服务函数里出现 HAL_Delay、printf 这类阻塞调用
- 延时时间和预期一致,注意 HAL_Delay 参数单位是毫秒
- 主循环里没有遗漏的 while 死等,会挡住其他逻辑
这份清单不需要背,做两三个工程自然就记住了。它最大的作用是把“模型可能出错的地方”变成“我固定要看的地方”,降低认知负担。
4. 核心代码拆解:时钟、GPIO、延时、串口
4.1 时钟树,72MHz 是怎么算出来的
STM32F103 常见的 72MHz 主频不是凭空来的,它由外部晶振经过 PLL 倍频得到。典型路径是:HSE 8MHz 作为 PLL 输入,经过预分频和倍频,输出 72MHz 作为系统时钟 SYSCLK。AHB 预分频器设为 1,所以 HCLK 也是 72MHz;APB1 预分频器设为 2,得到 36MHz,因为 APB1 总线上挂的外设最高只能到 36MHz;APB2 预分频器设为 1,得到 72MHz。
这里有个新手容易困惑的点:挂在 APB1 上的定时器,实际时钟不是 36MHz,而是 72MHz。原因是当 APB 预分频系数不为 1 时,定时器时钟会自动倍频一次。这个规则写在参考手册里,但很多人配定时器的时候没注意,算出来的定时周期总是不对。比如你想用 TIM2 做 1ms 定时,按 36MHz 算分频系数,实际会慢一倍。
CubeMX 的 Clock Configuration 页面会把这条路径画出来,你要做的是核对三个数:输入晶振频率、PLL 倍频系数、各总线预分频系数。这三个数对了,后面的延时、波特率、定时器才有一个可靠的时间基准。嵌入式软件开发里,时钟是一切时间相关功能的地基,这块配错,上层代码写得再漂亮也是错的。
4.2 GPIO 点灯,三种写法与取舍
点灯代码看着简单,其实有三种写法,分别对应不同的理解层次。第一种是 HAL 库函数写法:
while (1) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); HAL_Delay(500); }这种写法可读性最好,移植性也强,缺点是执行效率比直接操作寄存器低,不过点灯这种场景完全无所谓。第二种是直接操作 BSRR 寄存器:
while (1) { GPIOC->BSRR = GPIO_PIN_13; /* 置位,输出高 */ HAL_Delay(500); GPIOC->BSRR = (uint32_t)GPIO_PIN_13 << 16; /* 复位,输出低 */ HAL_Delay(500); }这种写法效率高,而且 BSRR 是原子操作,不会有读改写带来的竞争问题,适合在中断里快速翻转引脚。第三种是用 ODR 寄存器异或:
GPIOC->ODR ^= GPIO_PIN_13;这种写法最简洁,但它是读改写三步操作,如果同一个端口在不同中断里被操作,可能出现竞争。第一个工程我建议先用第一种,等理解清楚了再去看后两种,知道它们各自适合什么场合就行。
还有一个细节是输出模式的选择。推挽输出适合直接驱动 LED 或数字信号,开漏输出需要外部上拉电阻,适合做电平转换或者总线通信。输出速度等级在点灯场景选低就行,速度越高功耗和电磁干扰越大,没必要为了“看起来快”选最高档。LED 的极性也要注意,有些板子是低电平点亮,你写高电平反而灭,这时候把逻辑反过来就行,不用怀疑代码。
4.3 HAL_Delay 为什么会卡死
HAL_Delay 卡死是新手问得最多的问题之一。它的实现原理是:HAL_Init 里调用 HAL_InitTick,把 SysTick 配成 1ms 中断一次,每次中断让全局变量 uwTick 加一。HAL_Delay 做的就是记录当前 uwTick,然后在一个 while 循环里等差值达到设定值。
卡死的原因基本就三类。第一类是在中断里调用。如果你的中断优先级高于 SysTick 的中断优先级,SysTick 抢不进来,uwTick 不增长,while 永远不满足退出条件。第二类是全局关了中断,比如调用了关中断的宏之后忘了开,SysTick 同样进不来。第三类是时钟配置错误导致 SysTick 的计数频率不对,1ms 实际变成了几百微秒或者几毫秒,表现是延时时间明显偏差,严重时看起来像卡死。
排查方法很直接:在 HAL_Delay 前后加上串口打印或者翻转一个空闲引脚,用示波器或逻辑分析仪看波形。如果引脚一直不变,说明确实卡在里面;如果变化但周期不对,说明是时钟问题。这个排查思路我用了很多年,比盯着代码猜快得多。
注意:如果你的项目里确实需要在中断里做短延时,不要用 HAL_Delay,可以用一个空循环做粗略延时,或者用定时器计数做非阻塞延时。空循环的延时不精确,但对一些时序不敏感的场景够用。
4.4 串口 printf 重定向,第一个可观测窗口
点灯只能告诉你程序在跑,串口能告诉你程序跑到哪了、变量是多少。第一个工程里把串口调通,后面所有调试都会轻松很多。CubeMX 里配置 USART1 为异步模式,波特率 115200,8 位数据位、1 位停止位、无校验,然后生成代码。
要让 printf 能用,需要重定向 fputc 函数:
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }同时要在 Keil 的工程选项里勾选 Use MicroLIB,否则标准库的 printf 会依赖一些你没有实现的底层函数,链接时找不到符号。这是一个非常经典的坑,现象是编译报一堆 undefined symbol,很多人以为是串口配置错了,其实是库选项没勾。
重定向之后还有个坑是中文乱码,这跟编码格式有关,源文件用 UTF-8 保存,串口助手也设成 UTF-8 接收,一般就正常了。我自己的习惯是调试信息用英文和数字,正式打印需要给用户看的内容再考虑中文,减少编码带来的干扰。
5. 编译烧录调试全流程
5.1 编译报错分类处理
Keil 的编译报错信息量很大,但归类之后其实就几种。第一种是找不到头文件,报 cannot open source input file,通常是芯片包没装、Include Paths 没配全,或者文件路径里有中文和空格。第二种是未定义符号,链接阶段报 undefined symbol,常见于函数名写错、库没加、MicroLIB 没勾。第三种是语法错误,模型生成的代码里偶尔会混入不支持的语法,比如 C99 的变长数组在某些配置下报错。
处理顺序我建议是先解决头文件问题,再解决符号问题,最后看语法。因为一个缺失的头文件会引发一连串连锁报错,你从下往上看全是红的,其实只有第一个是真的。清理编译(Rebuild)也很重要,有时候是旧的中间文件残留导致报错,全部重新编译一遍就能排除这类干扰。
5.2 烧录配置的细节
Keil 里点下载按钮之前,要在 Options 的 Debug 页选对调试器,比如 ST-Link Debugger,然后在 Settings 里确认能识别到芯片。Flash Download 页里要勾选 Reset and Run,这个选项决定了烧录完成后程序是否自动运行。没勾的话,烧完芯片停在复位状态,你会以为程序没跑起来,其实是没启动。
还要确认下载算法和芯片 Flash 大小匹配。选错了算法,烧录会失败或者烧到错误的地址。第一次配置的时候,下载器设置里显示芯片 ID 和 Flash 容量,和你的芯片型号对得上,心里就有底了。如果下载报 No target connected,检查供电、接线、复位引脚状态,实在不行按住复位键再点下载,松开复位的时机在下载指令发出之后。
5.3 在线调试怎么看
第一个工程跑起来之后,花点时间学一下在线调试,收益很大。Keil 里点 Debug 按钮进入调试模式,可以打断点、单步执行、看变量、看寄存器、看调用栈。我调试时最常用的三个窗口是 Watch 窗口看变量值、Call Stack 窗口看函数调用关系、Peripherals 窗口看外设寄存器状态。特别是 GPIO 的寄存器,能直接看到输出电平是否和你预期一致,省得拿万用表去量。
一个实用的技巧是在关键位置打断点,比如进入主循环前、调用某个初始化函数后,观察程序是不是真的走到了那里。相比加打印语句再重新烧录,断点调试不用反复下载,效率高很多。第一次用可能会觉得陌生,但用两三次之后就离不开了。
6. 常见问题速查与避坑经验
6.1 高频问题速查表
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 编译提示找不到 stm32f1xx_hal.h | 芯片包未安装或头文件路径缺失 | 安装对应 DFP 包,检查 Include Paths |
| 链接报 undefined symbol | 函数名拼错、库未添加、未勾 MicroLIB | 核对函数名,勾选 Use MicroLIB |
| 下载提示 No target connected | 接线错误、供电不足、复位状态异常 | 检查 SWD 四线,按住复位再下载 |
| 烧录成功但程序不跑 | 未勾 Reset and Run、BOOT 跳线错误 | 勾选选项,检查 BOOT0 电平 |
| LED 不亮 | 引脚号错、极性反、限流电阻过大 | 用调试器看 ODR 寄存器,改极性 |
| 串口打印乱码 | 波特率不一致、HSE_VALUE 与实际晶振不符 | 统一 115200,核对晶振频率 |
| HAL_Delay 卡死 | 中断中调用、中断被关闭、时钟配置错 | 移到主循环,检查优先级和时钟 |
| 芯片连不上下载器 | SWD 引脚被复用为普通 GPIO | 用复位下载,CubeMX 中设 Debug 为 Serial Wire |
| 定时器周期不准 | 忽略 APB 预分频对定时器时钟的影响 | 按 72MHz 而非 36MHz 计算分频 |
这张表我建议存下来,遇到问题时先对号入座,多数情况能快速定位方向。表格里的原因只是高频项,不是全部,但覆盖了新手第一个工程里百分之八十的坑。
6.2 我踩过的几个坑
第一个坑是晶振频率填错。我早期做过一块板子,CubeMX 里默认 8M,实际板子上是 12M 的晶振,结果串口波特率偏差接近百分之五十,打印出来全是乱码,我花了很久怀疑是串口助手的问题。后来用示波器量了实际波特率波形才发现偏差,回到 CubeMX 改掉 HSE_VALUE 宏和时钟配置就正常了。从那以后我养成了一个习惯:拿到新板子先用调试器读出时钟配置寄存器,确认实际运行频率。
第二个坑是在中断里调用打印函数。早期为了调试方便,在定时器中断里加了一句串口打印,结果串口发送是阻塞的,中断执行时间变长,影响了主循环的时序,还偶发卡死。后来改成在中断里只置一个标志位,主循环检测标志位再打印,问题就没了。这个经验后来在很多项目里都用得上,中断里只做最短的必要操作。
第三个坑是把调试引脚复用掉了。有一次为了让引脚分配好看,把 PA13、PA14 分给了别的功能,结果下载器连不上芯片,试了各种方法才用复位时序救回来。CubeMX 里 SYS 的 Debug 选项设成 Serial Wire,生成的代码会自动保留这两个引脚,这个设置我后来在每一个工程里都会检查一遍。
6.3 从第一个工程往外延伸的方向
第一个工程跑通之后,往外扩展的路径其实很清晰。想练定时器,就把延时从 HAL_Delay 换成定时器中断,做非阻塞的周期任务;想练串口,就加上协议解析,让它能接收上位机指令并回传数据;想练 GPIO 输入,就把按键加上消抖,做长短按识别;想接传感器,就从最简单的温湿度模块开始,把 I2C 或单总线协议摸一遍。
再往深走,可以做一个小型的闭环项目,比如基于 STM32 的温湿度计加报警器,或者水族箱的温控和水泵控制,这些都是能在一两周内看到成果的方向。如果对控制算法感兴趣,可以试试电机驱动和 PID 调试,用串口把实时数据打出来画曲线,理解参数对系统的影响。再往上走,像数字电源、逆变器这类方向门槛就高不少,涉及高压和安规,建议先把低压控制部分玩透,别一上来就碰市电相关的电路。
我个人觉得,第一个 STM32 工程最大的收获不是那块闪着的 LED,而是你手里多了一套可复用的验证方法:知道怎么搭环境、怎么问 AI、怎么查时钟、怎么看寄存器、怎么定位一个现象背后的原因。这套方法在你后面做任何嵌入式项目时都会反复用到,比记住某个具体函数的用法有价值得多。
最后分享一个小技巧:每做完一个工程,把关键的配置参数和踩过的坑记在一个单独的文档里,包括芯片型号、晶振频率、时钟配置、引脚分配和当时卡住的问题。过半年你再回头看,这些东西比任何教程都管用,因为那是你自己验证过的。