很多人做STM32的第一个工程,都是照着教程把灯点亮,然后截图发个朋友圈就结束了。但如果你打算把AI编程真正拉进嵌入式开发流程里,第一个工程的意义完全不一样——它不是一个"Hello World"仪式,而是你为后面所有AI协作定下规矩的那次奠基。工程目录怎么摆、库用哪一版、命名习惯是什么、约束条件写在哪里,这些东西一旦定型,后面AI每次给你补代码都要吃这套上下文。定得好,AI给你的代码一次能跑;定得烂,你会在"编译报错—粘贴错误—再报错"的循环里耗掉一整晚。这篇就把我搭这个工程时的完整思路、每一步的取舍理由,以及事后复盘出来的坑,全部摊开讲一遍。
1. 第一个STM32工程真正要立的规矩,不是点灯而是可被AI接管的工程骨架
1.1 为什么"第一个工程"值得被单独拎出来对待
新手常见的做法是:打开Keil5,新建工程,勾几个库文件,写个main,烧进去,灯亮了,收工。这个过程本身没错,问题在于它没有留下任何"结构"。等你到第十个工程,想加串口、加定时器、加状态机,你会发现原来的目录是平的,所有代码都堆在main.c里,AI读进去也是一头雾水,只能瞎猜你的意图。我见过太多人抱怨AI写的STM32代码"感觉对但跑不起来",绝大多数时候不是AI不行,而是你给的工程上下文太脏了。
所以我把第一个工程的目标重新定义了:它不是一次点灯演示,而是一次"工程规范的最小实践"。最小,意味着内容不要多,一个GPIO输出就够;实践,意味着目录分层、命名规则、库版本、编译配置这些都要在这个最小工程里体现出来。之后所有工程都可以直接复制这个骨架,AI每次进来都能在同一种结构里工作,命中率会高得离谱。
1.2 我给这个工程定的三条验收标准
光有目标不够,得能验证。我给自己定了三条,你也可以直接抄:
- 能编译、能烧录、现象可复现:灯按预期闪烁,断电重启行为一致,这是底线。
- 目录结构可解释:任何一个文件,我能一句话说清它为什么在这里。说不清的,说明分层是多余的。
- AI能在不看我注释的情况下猜出主要逻辑:这条最狠,也最有用。我会把main.c的注释全删掉,让AI解释代码在干嘛,如果它能说对八成,说明命名和结构是自洽的;说不对,就回去改命名。
三条标准里,第三条是我从踩坑里悟出来的。早期我写注释写得很勤,结果AI反而被我啰嗦的注释带偏,因为它把注释当权威,而注释和代码一旦有偏差,它信任的是文字不是逻辑。后来我把注释砍到只剩必要的约束说明,命名做扎实,AI的表现立刻稳定了。
1.3 这个工程最终长什么样
先说结论,免得你看到后面迷路。最终成品是一个基于STM32F103C8T6的最小工程,用标准外设库,Keil5编译,一个LED接在PB12(避开了默认的JTAG引脚),1Hz闪烁,闪烁逻辑用SysTick中断实现而不是死循环delay。目录上分成Core、Drivers、App三层,AI要改的代码集中在App层,Core和Drivers层基本不动。
为什么是F103C8T6?因为它便宜、资料多、最小系统板满地都是,作为"第一个工程"的载体,容错成本最低。为什么用标准库不用HAL?这一点后面第3章会展开讲,核心原因是第一个工程里我希望"每一行代码我都能追到寄存器",HAL把太多东西藏起来了,对建立直觉不友好。等你对底层有感觉了,再切HAL或LL都行。
2. 环境搭建里最容易翻车的三件事:芯片包、库版本和目录结构
2.1 Keil5与STM32芯片包:版本不匹配是头号杀手
安装Keil MDK这一步本身没什么好说的,一路下一步就行,真正的坑在芯片包。Keil5装完之后,默认不一定带STM32F1的器件支持包(Device Family Pack),你需要用Pack Installer在线装,或者离线导入.pack文件。问题在于:很多人装了个比较新的F1包,然后从网上抄了一份老工程,结果编译时报一堆寄存器宏找不到的错误。
我的建议很朴素:先确定你要用的库版本,再倒推芯片包版本。如果你用标准外设库SPL 3.5.0,那就装一个和它年代相近的F1器件包;如果你用HAL,那就用较新的CubeMX生成的工程配套包。混搭没有绝对的错,但对新手来说,混搭带来的报错足够劝退。
还有一个细节值得强调:装了包不等于工程里引用了包。Keil工程新建时如果选择从已有工程复制或者空工程,器件型号是要在"Options for Target"里手动指定的,RTE里要不要勾组件,也决定了你是走CMSIS组件还是走你手动加的头文件路径。我见过有人纠结"为什么我头文件路径加了还是找不到stm32f10x.h",最后发现根本没在RTE里选器件。
2.2 目录结构为什么必须按"AI友好"的方式来组织
先看一个我早期觉得很整洁、后来被AI骂的目录:
Project/ main.c stm32f10x.h stm32f10x_gpio.c stm32f10x_rcc.c delay.c led.c ...平铺,什么都在根目录。人看还行,AI看就是灾难——它无法判断哪个文件是你要改的业务逻辑,哪个是厂商库。它经常会去"优化"厂商库文件,给你改出一些莫名其妙的宏定义,你还得逐个回滚。
我后来改成这样:
Project/ Core/ startup_stm32f10x_md.s system_stm32f10x.c Drivers/ CMSIS/ STM32F10x_StdPeriph_Driver/ App/ main.c app_led.c app_led.h app_tick.c app_tick.h Doc/ README.md pinmap.md关键不在于分几层,而在于边界清晰:Drivers层是"AI不许动"的区域,App层是"AI的主战场",Core层是"只读"区域。这个规则我会明确写进README里,因为AI读工程时,README往往是它最先吃进去的文件。把约束写在它第一眼能看到的地方,比你在对话里反复强调有效得多。
2.3 用命令行工具链做一次交叉验证
Keil的图形界面很好用,但它有个隐性风险:编译通过不代表你的代码没有依赖IDE的隐含配置。我习惯在搭完工程后,用arm-none-eabi-gcc加一条Makefile再编一遍。这一步不是必须,但价值很大——它逼你把所有宏定义、头文件路径、链接脚本都显式写出来。
具体来说,我会写一个最小的Makefile,把-D STM32F10X_MD -D USE_STDPERIPH_DRIVER这些宏、启动文件、链接脚本都列清楚。如果命令行能编过,说明你的工程配置是自洽的,AI给你的代码也更可能在其他环境下复用。这一步我第一次做的时候折腾了半天,主要是链接脚本的Flash和RAM地址要对着F103C8T6的规格填(64K Flash、20K RAM),中间还踩了个栈大小设太小的坑,程序一进中断就跑飞。
提示:命令行验证不是要你放弃Keil,而是给你一个"第二意见"。当AI生成的代码在Keil里行为诡异时,用命令行编译一次能快速排除是IDE配置问题还是代码问题。
3. 手写一版最小工程:时钟、启动文件和GPIO的取舍逻辑
3.1 新建工程时那几个选项为什么这么选
在Keil里新建工程,选好器件STM32F103C8后,会问你"是否复制启动文件"。这里我选择复制启动文件到工程目录而不是引用。原因很简单:AI无法访问Keil安装目录下的文件,它只能看见你工程里的东西。把启动文件放进工程,AI才能理解你的中断向量表长什么样,才可能帮你正确地添加中断服务函数。
启动文件我选的是startup_stm32f10x_md.s,md代表medium density,对应F103C8的64K Flash规格。这个选择不是随意的:F103系列有ld、md、hd、xl几种,选错了中断向量表偏移就不对,第一个中断触发时直接HardFault。我第一次搭工程时就踩过这个坑,用的是别人工程里的hd启动文件,结果SysTick一中断就死,查了两小时才发现是密度等级不匹配。
至于USE_STDPERIPH_DRIVER这个宏,它是标准库的开关,不定义的话你include了头文件也调不到库函数。这个宏写在Options → C/C++ → Define里,同时记得把STM32F10X_MD也写进去,两个缺一不可。
3.2 system文件做了什么,为什么值得花十分钟看懂
system_stm32f10x.c这个文件经常被无视,但它决定了你芯片的时钟树。F103上电默认走内部8MHz RC(HSI),经过PLL倍频后可以跑到72MHz。库里的SystemInit()函数会把时钟配到72MHz(如果你的宏定义选对了),但很多人不知道这个函数是被启动文件在进入main之前调用的。
看懂这一点的价值在于:当AI帮你调时钟时,你要能验证它有没有改对。比如你想从72MHz降到48MHz,AI可能会给你改PLL倍频系数,但忘了改Flash的等待周期(Latency)。时钟越高,Flash读取需要插入的等待周期越多,72MHz要2个等待周期,48MHz要1个。改错了不一定立刻崩,但在高频下会出现偶发的读取错误,这种问题极难排查。所以我在App层单独写了一个app_clock.c,把时钟配置逻辑从system文件里抽出来,让AI改这块时有个明确的落点,也方便我自己审。
3.3 GPIO点灯:从寄存器到库函数的完整映射
点灯这件事,我坚持先用寄存器写一遍,再用库函数写一遍。不是为了炫技,而是为了建立"库函数背后是什么"的直觉。
寄存器版本大概是这样:先开GPIOB的时钟(RCC_APB2ENR的位3),然后配置PB12为推挽输出50MHz(CRH寄存器的对应四位),最后写ODR的第12位控制电平。用库函数则是RCC_APB2PeriphClockCmd、GPIO_Init、GPIO_SetBits/ResetBits。两版对照着看,你会发现库函数就是把这些位操作包了一层。
为什么要费这个劲?因为当AI给你一段GPIO代码时,你如果能秒懂它配置的是哪个引脚、什么模式、什么速度,你就有能力一眼看出它有没有写错。比如AI偶尔会把GPIO_Mode_Out_PP写成GPIO_Mode_IPU(上拉输入),代码能编译,灯就是不亮,这时候你如果对模式位没感觉,就得靠反复试错。
注意:PB12这个引脚选择是有讲究的。STM32F103默认把PA13、PA14、PA15、PB3、PB4用作JTAG/SWD调试口,如果你把LED接在这些脚上,下载完程序后调试口被占用,下次可能连不上。避开它们能省掉一堆麻烦。
我做的引脚分配表放在Doc/pinmap.md里,格式很简单:
| 引脚 | 功能 | 模式 | 备注 |
|---|---|---|---|
| PB12 | LED_RUN | 推挽输出 | 低电平点亮 |
| PA9 | UART1_TX | 复用推挽 | 调试串口 |
| PA10 | UART1_RX | 浮空输入 | 调试串口 |
| PA13 | SWDIO | 复用 | 调试口,勿占用 |
这张表的价值在于,AI每次改代码前我都会让它先读这张表。有了明确的外部约束,它乱配引脚的概率会大幅下降。
4. 让AI真正参与进来:提示词结构、上下文投喂和验证闭环
4.1 直接把需求丢给AI为什么必然翻车
"帮我写个STM32点灯程序"——这种提示词你丢给任何AI,得到的都是一段泛泛的、基于HAL的、引脚随机指定的代码。它不知道你用哪个库,不知道你的引脚表,不知道你的目录结构,甚至不知道你用的是F1还是F4。它只能给你一个"统计学上最常见"的答案,而这个答案大概率跟你的工程对不上。
我早期就是这么干的,结果得到的代码里,时钟使能写的是__HAL_RCC_GPIOB_CLK_ENABLE(),而我工程里是标准库,根本没有这个宏。来回解释了好几轮,AI还是会在后续对话里"忘记"我用的是标准库。问题不在AI的智力,在于我没有把工程约束变成它每次都能看到的固定上下文。
4.2 我常用的三段式提示词结构
改法其实不复杂,我把每次给AI的提示词固定成三段:
- 背景段:一句话交代芯片型号、库类型、编译器。"STM32F103C8T6,标准外设库SPL 3.5.0,Keil MDK5,目录结构见附件README。"
- 约束段:把不可违反的规则列出来。"不要修改Drivers目录下任何文件;LED在PB12,低电平点亮;时钟已配置为72MHz,不要改时钟。"约束写具体,别写"请遵循最佳实践"这种空话。
- 任务段:说清要做什么、验收标准是什么。"在App/app_led.c里实现一个LED闪烁函数,用SysTick中断实现1Hz翻转,不要用delay死等。完成后说明你改了哪些文件、为什么这样改。"
这三段里最容易被忽视的是约束段。我实测下来,约束越具体,AI跑偏的概率越低,而且这个收益是边际递增的:写上三条关键约束,代码一次通过率能从三成提到七成;写上引脚和时钟约束,能到八成五以上。
4.3 把编译结果和现象反馈给AI,形成闭环
AI看不到你的板子,它只能看你给它的信息。所以每次它给完代码,我都会做同一套动作:编译,看有没有warning;烧录,看现象;然后把编译输出原封不动贴回去,包括warning。这一步很关键,因为warning里经常藏着AI的疏忽,比如类型不匹配、未使用的变量、隐式的函数声明。
举个例子,AI有一版代码里用了一个自定义的delay_ms()但从没定义过,Keil给了个隐式声明warning,程序能编过但delay完全不起作用,因为链接到了别处的同名符号。如果我不把warning贴回去,AI会以为一切正常。贴回去之后它立刻发现了问题,补上了实现。
更进阶一点,我会让它自己先做一遍静态检查。提示词里加一句:"在给出代码前,先逐行检查是否引用了未定义的符号、是否所有使用的外设都已使能时钟。"这一句话能让不少低级错误在它输出前就被自己拦下来。
4.4 AI能在哪些环节帮上真正的忙
可能有人会问,说了半天规矩,AI到底能帮什么?以这个最小工程为例,我实际让AI干了这些活:
- 根据我的引脚表生成GPIO初始化代码,省掉我查寄存器的功夫
- 把我手写的寄存器版点灯代码翻译成库函数版,两版对照验证逻辑一致
- 帮我写SysTick中断服务函数的骨架,包括中断优先级配置的说明
- 审我的启动文件密度等级选得对不对
- 把我的工程整理成一份README,逼我自己把目录规范写清楚
这些活的共同点是:有明确的、可验证的输入输出,且我能快速判断对错。反过来,我绝不会让AI去干"帮我设计整个系统的架构"这种事,它给的架构脱离硬件约束,看着漂亮,落地一地鸡毛。
5. 从点灯到定时器:把骨架扩展成真正能用的模板
5.1 用SysTick替代死循环delay,顺手解决卡死问题
第一个工程里如果只用delay_ms死等来闪烁,那么这个程序除了点灯什么都干不了。死等期间CPU完全被占用,你没法同时处理按键、串口、传感器。更隐蔽的问题是,很多网上的delay实现靠空循环计数,循环次数和编译器优化等级强相关,优化一开,"1秒"可能变成"0.2秒"。热词里那个"stm32延时函数delay卡死"的搜索,多半就是这类实现踩的坑。
我的做法是用SysTick。SysTick是Cortex-M内核自带的24位递减计数器,配置成1ms中断一次,在中断里维护一个全局的tick计数。这样主循环可以用非阻塞的方式判断时间:
volatile uint32_t g_tick = 0; void SysTick_Handler(void) { g_tick++; } uint8_t time_elapsed(uint32_t start, uint32_t interval) { return (g_tick - start) >= interval; }注意这里用减法而不是直接比较大小,是为了处理计数器回绕。g_tick - start在无符号运算下即使回绕也能得出正确的差值,这是个很实用的小技巧,AI如果给你直接写g_tick > start + interval,长时间运行后会出问题。
主循环变成这样:
uint32_t last = 0; while (1) { if (time_elapsed(last, 500)) { last = g_tick; GPIOB->ODR ^= (1 << 12); // 翻转PB12 } // 这里可以放其他任务 }灯还是1Hz闪,但CPU空出来了。这个改动看着小,却是我认为第一个工程最值得做的事——它把"点灯"从玩具变成了任务调度的雏形。
5.2 串口打印是调试的第一双眼睛
灯只能告诉你"程序在跑",串口能告诉你"程序跑到哪了、变量是多少"。所以我在这个最小工程里就把UART1接上了,波特率115200,PA9/PA10。不改动库的前提下,用标准库的USART_SendData配一个重定向的printf就够了。
这里有个AI经常搞错的点:printf重定向需要实现fputc,而且要在Keil工程里勾选"Use MicroLIB"或者自己实现_sys_write,否则程序链接时会报缺符号。我让AI写这段时,它第一次只给了fputc,没提MicroLIB,结果链接失败。后来我在约束段里加了一句"工程已启用MicroLIB",它就没再犯过。
有了串口之后,AI的调试价值直线上升。我可以把串口输出贴给它,它能根据打印信息推断程序状态。比如我打印tick值,发现它一直是0,AI立刻指出SysTick的中断优先级没配对,被其他中断屏蔽了。
5.3 把工程整理成可复用的模板
到这一步,工程已经能跑、能调、有自己的规范了。最后一步是把它"固化"下来:把不需要改的部分(Core、Drivers)归档,把App层的命名规则、引脚表、README整理好,下次新工程直接复制这个目录。
我习惯在README里写三样东西:目录说明、构建步骤、AI协作约定。第三样是这份模板的灵魂,比如"App层文件命名统一为app_xxx.c/h""新增外设必须在pinmap.md登记""修改时钟配置需同时更新Latency""Drivers层只读"。这些约定写下来,下次AI进来,先读README,再动手,跑偏的概率会低很多。
提示:模板不要过度设计。我第一版模板分了七层目录,结果每个小工程都要建一堆空文件夹,反而累赘。三层(Core/Drivers/App)是甜点区,够用且清爽。
6. 复盘:这个工程里我实际踩过的四个坑
6.1 芯片包与库文件版本对不上
前面提过一次,这里说细节。我一开始用的F1器件包是较新的版本,然后从一份老教材里抄了标准库的工程配置,结果stm32f10x.h里的某些宏在库文件里找不到对应定义,编译报一堆"未定义"。排查过程很绕:先去库文件里搜索这个宏,发现确实不存在,再去看教材的库版本,才意识到是版本差异。解决办法是统一到SPL 3.5.0这一套,器件包也用配套的。
这个坑的教训不是"要用某个特定版本",而是工程里所有依赖的版本要能互相说清。我在README里加了一节"工具链与库版本",把Keil版本、器件包版本、库版本都记下来。之后交接或者换电脑重装,直接照着装,省了重复排查。
6.2 AI生成的代码"能编译但不工作"
有一版AI给的SysTick初始化,它用了SysTick_Config(SystemCoreClock / 1000)这个CMSIS函数。看着很优雅,但问题是它把SysTick的中断优先级设成了最低(默认值),而我的串口中断优先级比它高。更麻烦的是,这个函数在配置失败时会返回1,AI没检查返回值。结果我板子上跑起来tick偶尔跳变,查了半天。
这件事让我形成了一个习惯:AI给的每一行初始化代码,我都要问一遍"如果这一步失败会怎样"。SysTick_Config可能失败、外设时钟可能没使能、GPIO模式可能被后续代码覆盖,这些"失败路径"AI基本不会主动考虑,需要我用提问逼它补上。
6.3 调试引脚复用后连不上板子
这个坑最经典。我早期把LED接在PB4上(因为手头板子那个位置好焊),烧录一次之后,Keil就再也连不上了。原因是PB4默认是JTAG的NJTRST引脚,程序跑起来把它配成了GPIO输出,调试口就废了。解决办法要么是按住复位键在复位瞬间下载,要么是用STM32的启动模式跳到系统存储器。最省事的当然是一开始就别用这些引脚。
后来我在pinmap.md里专门加了一行"PA13/PA14/PA15/PB3/PB4 保留为调试用途",并且把这个文件作为AI每次必读的上下文。这个动作之后,我再没遇到过AI给我把LED或者其他功能分配到调试脚上的情况。
6.4 优化等级改变了延时行为
有一次为了看效果,我把Keil的优化等级从-O0改成-O2,结果灯的闪烁频率明显变快了。原因是某个用于微调的循环被编译器优化掉了。这件事提醒我:任何依赖指令执行时间的代码在改优化等级后都要重新验证。既然我用的是SysTick中断计时,理论上不受优化影响,但中断服务函数本身如果被过度优化,也可能出问题。稳妥做法是给中断里访问的全局变量加volatile,这一点AI有时候会漏,需要你补。
我后来把这个检查也写进了给AI的约束段:"中断服务函数中访问的全局变量必须声明为volatile。"一句话,省掉无数玄学问题。
最后分享一个我自己的习惯。每次搭完一个新工程的最小骨架,我会在README的最后写一段"给未来的自己和AI的话",就三五句,说清这个工程是干嘛的、哪些地方是故意这么设计的、哪些地方暂时不用管。这段话写的时候觉得多余,隔一个月回来看,它能让你在两分钟内重新进入状态,也能让AI在第一次接触这个工程时少问很多问题。第一个STM32工程的价值,从来不是那盏灯,而是你借它把规矩立下来了。