
简介面向STM32初学者的基础实验代码合集内容覆盖GPIO输出控制、PWM脉宽调制、外部中断响应、定时器配置及ADC采样与PID调参等典型入门知识点适合刚接触ARM Cortex-M3内核开发、希望通过手写实验理解芯片寄存器配置与常用外设使用机制的读者。压缩包内共7个c源文件对应流水灯、呼吸灯、小车寻迹、按键及定时器等5个独立小程序包体仅8KB代码量精简易读所有源文件均含完整main函数与必要注释可在Keil MDK或STM32CubeIDE中直接打开对照学习。目前已有4036人浏览下载。从最简单的LED电平翻转到依赖传感器采集与电机驱动的小车循迹逻辑这批小程序基本串联起STM32开发的核心脉络既可用于课程实验参考也能作为后续综合项目的功能模块积累。 上手STM32这件事我见过太多人卡在第一步下载了一堆资料装了Keil和CubeMX结果打开例程一看几十个文件、上千行代码瞬间不知道从哪看起。其实STM32入门压根不用啃完整个固件库你把几个最基础的外设跑通了后面都是举一反三的活。这篇文章我就老老实实分享四个我当年一个一个调通、现在也经常拿来当测试底板的“简单小程序”GPIO点灯、定时器中断、串口收发、PWM输出。它们分别对应了你之后做任何项目都会用到的四个能力控制引脚、精确延时、数据通信、模拟信号输出。代码全部基于STM32F103C8T6最小系统板和标准外设库这也是目前网上教程和开发板资料最主流的组合。1. 第一个程序别急着写代码先搞懂GPIO的脾气GPIO是STM32最基础也最容易被忽视的外设。很多人点灯失败不是代码写错而是压根没搞明白引脚的工作模式。你在网上搜STM32点灯能看到一堆代码但很少有人解释为什么初始化时要设置成推挽输出为什么按键要配上拉输入。1.1 推挽输出和开漏输出选错就出怪问题GPIO输出模式里最常用的两个是推挽输出和开漏输出。推挽输出就是引脚内部有一对MOS管一个负责往外推电流输出高电平一个负责往里拉电流输出低电平所以它输出高电平时电压很干净基本就是3.3V。点亮LED用推挽输出电流路径清晰亮度稳定这是最标准的用法。开漏输出则只有拉低的能力输出高电平必须靠外部上拉电阻。它的好处是可以接不同电压的外部设备比如你的STM32是3.3V供电但外接一个5V的继电器模块用开漏输出配上拉到5V就能实现电平转换。但如果你用开漏输出直接点灯又不加上拉电阻LED会处于一种时亮时不亮的奇怪状态刚开始很容易被这种问题搞晕。1.2 标准库点灯三步走的固定套路所有STM32外设初始化在标准库里都是同一个套路开时钟、填结构体、调初始化函数。以点亮PA0口的LED为例// 第一步打开GPIOA的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 第二步填充GPIO初始化结构体 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; // 选择PA0 GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // 翻转速度50MHz GPIO_Init(GPIOA, GPIO_InitStructure); // 第三步控制电平 GPIO_SetBits(GPIOA, GPIO_Pin_0); // PA0输出高电平LED灭假设低电平点亮 GPIO_ResetBits(GPIOA, GPIO_Pin_0); // PA0输出低电平LED亮为什么一定要开时钟因为STM32的外设默认是断电状态你不用它就不给它供电这是它低功耗设计的基础。很多新手忘了这一步代码看着完全没错引脚就是没反应其实就是外设根本没工作。GPIO_Speed这个参数也值得说一句。它代表引脚能承受的最大翻转频率不是必须跑到50MHz。控制LED这种低频场景10MHz都绰绰有余但统一写成50MHz完全没问题后续驱动WS2812这类需要高速翻转的灯带时反而不用回来改。1.3 加一个按键学会读输入点灯只算输出加上按键才算完整。按键检测的电路设计有个小讲究按键一端接地另一端接PA1同时PA1内部上拉。这样按键没按下时PA1读到高电平按下时PA1被拉到地读到低电平。选内部上拉而不是外部上拉电阻省了一个元件代码也更干净。// PA1配置为上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_Init(GPIOA, GPIO_InitStructure); // 主循环里检测按键并翻转LED if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) RESET) { // 按键按下翻转PA0的电平 if (GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0) RESET) { GPIO_SetBits(GPIOA, GPIO_Pin_0); } else { GPIO_ResetBits(GPIOA, GPIO_Pin_0); } // 简单的延时消抖 Delay(10); }这里有个小细节翻转LED电平前我用GPIO_ReadOutputDataBit读取的是输出数据寄存器而不是输入数据寄存器。因为PA0是输出模式读输入寄存器拿不到当前输出状态的。这个问题看起来小但是很多初学者第一次做按键控制LED时都会卡住标记一下免得踩坑。GPIO这块彻底理解了后面所有外设的初始化代码你都会觉得眼熟因为套路完全一样开时钟、填结构体、调函数。2. 定时器中断让单片机学会自己安排时间跑通GPIO之后紧接着就轮到定时器。为什么我强烈建议第二个程序学定时器因为你迟早会发现主循环里的Delay延时函数是个站桩式的写法CPU啥也不干就在那空转数数。LED闪烁这种简单场景还好一旦你同时要处理按键扫描、串口接收、传感器读取一个Delay卡在那里其他事全耽误了。网上关于stm32延时函数delay卡死的搜索量一直很高根子就在这。2.1 定时器分频和计数72MHz怎么变成1Hz定时器的核心逻辑就是数数。STM32F103的内部时钟是72MHz定时器本质是一个计数器它数到某个设定值就触发一次事件。但72MHz太快了直接数的话1毫秒就是72000个数精度太高反而不方便。所以定时器内部有个预分频器先把72MHz降下来。以TIM2为例如果预分频寄存器PSC设为7199那么定时器输入时钟就变成72MHz / (71991) 10kHz也就是1秒钟计数10000次。自动重装载寄存器ARR设为999意思是计数到999就溢出一次。那么溢出频率就是10000 / (9991) 10Hz即100毫秒中断一次。计算公式定时时间 (PSC 1) × (ARR 1) / 72MHz这里你要注意PSC和ARR寄存器的值都要比实际倍数小1因为计数器是从0开始数的。这个细节几乎所有教程都会提但实操时还是有人会算错一次算错了现象也很典型LED闪烁频率和预期差很多倍或者干脆快得肉眼看不清。2.2 定时器中断完整配置代码// TIM2初始化100ms中断一次 void TIM2_Init(void) { // 1. 开TIM2时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 2. 配置定时器基础参数 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period 999; // ARR自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler 7199; // PSC预分频值 TIM_TimeBaseStructure.TIM_ClockDivision 0; // 时钟分割一般不用 TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数 TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 3. 开启更新中断 TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); // 4. 配置NVIC中断优先级 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 5. 启动定时器 TIM_Cmd(TIM2, ENABLE); } // 中断服务函数 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { // 这里放要周期执行的代码 GPIO_WriteBit(GPIOA, GPIO_Pin_0, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0))); TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 务必清中断标志 } }中断服务函数最后的清标志位操作是重中之重。忘了清标志中断会反复触发程序看起来就像卡死在中断里一样。这个错误我在刚学的时候犯过现象非常诡异连调试器都看不出问题最后是一行一行看代码才发现的。2.3 定时器还能用来干嘛定时器中断的例子调通之后你的程序结构会有一个质的提升。主循环里可以放心做按键扫描、显示刷新这些事时间的把控全部交给定时器。你要做的只是设置一个标志位或者一个计数变量主循环检测到变化再响应。常见玩法比如定时1秒累加一个变量数码管显示时分秒。定时100毫秒采样一次传感器数据凑够一定数量取平均。定时50毫秒扫描一次按键兼顾响应速度和CPU占用。这里顺带提一下TIM_ClockDivision这个参数不少人误以为它和分频有关其实它是用来配置外部滤波的与定时周期无关。抄代码的时候保留默认值0就行不用纠结。3. 串口通信给单片机装一个输出程序跑起来了怎么知道它内部发生了什么串口是目前最直接的答案。你完全可以把串口理解成单片机的嘴巴和耳朵往电脑上发调试信息接收电脑下发的指令。学习阶段它比任何调试器都直观。我在看网络热词里搜到stm32串口调试pid这就是串口的一个典型应用把PID控制器的实时输入、输出、误差通过串口发到电脑用波形软件画出来调参效率比盲调高太多。没有串口你想监控单片机内部数据几乎只能靠断点和点灯效率天差地别。3.1 串口初始化引脚复用和波特率STM32的串口引脚不是随便接的PA9、PA10是USART1的默认引脚但要启用它的串口功能还得把引脚配置成复用推挽输出这就是引脚复用的概念。void USART1_Init(void) { // 1. 开时钟USART1和GPIOA都开 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置PA9为TX复用推挽输出 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置PA10为RX浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, GPIO_InitStructure); // 4. 配置串口参数 USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate 115200; // 波特率 USART_InitStructure.USART_WordLength USART_WordLength_8b; // 8位数据位 USART_InitStructure.USART_StopBits USART_StopBits_1; // 1位停止位 USART_InitStructure.USART_Parity USART_Parity_No; // 无校验 USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; // 无硬件流控 USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; // 收发都开 USART_Init(USART1, USART_InitStructure); // 5. 使能串口 USART_Cmd(USART1, ENABLE); }波特率两端必须一致这是老生常谈。很多人初始化没问题就是连不上十有八九是电脑上串口助手的波特率没跟代码设成一样的。常见波特率有9600、115200工程上115200用的最多速度快且稳定。3.2 重定向printf调试输出一步到位直接用USART_SendData发数据太麻烦每次都要判断发送完成标志。更舒服的做法是重定向printf让C标准库的printf函数最终调用串口发送函数这样就能像写电脑程序一样输出调试信息。// 重定向fputc函数printf就会走串口发送 int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); // 等待发送完成 return ch; }需要在工程设置里勾选Use MicroLIB否则会因为标准库的底层实现问题导致printf失效。这个坑几乎人人都会遇到代码看起来完全没问题但串口就是没有输出最后发现是MDK里少勾了一个选项。while等待发送寄存器为空这一步也不能省。如果不等待就发送下一个字节极快的数据流会出现字节丢失导致串口接收方看到乱码。这个在跑正经项目、大量输出日志时尤其明显。3.3 串口接收学会听懂上位机的指令发送搞定后接收也要跟上。最简单的接收方式是查询主循环里不断检查USART_GetFlagStatus(USART1, USART_FLAG_RXNE)有数据就读取。但这种方式有个问题如果主循环里还有其他耗时的操作数据来了可能来不及处理缓冲区一满就直接丢了。所以工程上更推荐中断接收。每收到一个字节就触发一次USART中断你可以在中断服务函数里立即保存数据。void USART1_IRQHandler(void) { uint8_t data; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { data USART_ReceiveData(USART1); // 把data存入自己的接收缓冲区 receiveBuffer[receiveIndex] data; USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }这里有个常见做法协议里用一个固定结尾符比如0x0D 0x0A回车换行接收端判断到结尾符就认为一帧数据接收完成置一个标志位主循环发现标志位后再统一解析数据。这个方法能应对大部分串口通信场景从ESP8266到蓝牙模块用的都是这个思路。网上搜stm32 virtual com port 叹号的多半是串口驱动没装好或者电脑不识别。这个跟代码无关先检查驱动再看设备管理器的COM口号最后才是查接线。排查顺序很重要别一上来就怀疑程序。4. PWM输出让LED学会呼吸让电机学会变速PWM脉冲宽度调制听起来高大上本质就是在一段时间内快速翻转引脚电平通过改变高电平占空比高电平时间占总时间的比例来让外设产生不同效果。LED的亮度、电机的转速、舵机的角度全靠它控制。4.1 PWM机制定时器还能这么用PWM是定时器的一个进阶功能核心是一个比较寄存器CCR。定时器计数时会不断拿当前计数值和CCR比较计数值小于CCR时引脚输出高电平。计数值大于等于CCR时引脚输出低电平。所以改变CCR的值就能改变占空比改变ARR的值就能改变PWM频率。拿TIM3的通道1举例引脚是PA6生成1kHz、占空比可调的PWM信号初始化代码如下void PWM_Init(void) { // 1. 开时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置PA6为复用推挽输出 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置定时器基础参数 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period 999; // 频率72MHz/(711)/(9991) 1kHz TIM_TimeBaseStructure.TIM_Prescaler 71; // 预分频 TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 4. 配置PWM模式 TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; // PWM模式1 TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; // 初始占空比0% TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; // 高电平有效 TIM_OC1Init(TIM3, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); // 预装载使能 // 5. 启动定时器 TIM_Cmd(TIM3, ENABLE); }PWM模式1和模式2的区别也常有人问模式1是计数值小于CCR时输出有效电平默认高电平模式2相反。实际使用中模式1占绝大多数记住一个就好。4.2 呼吸灯让占空比动起来PWM配置好之后信号就在定时器硬件里自动生成了。要控制LED亮度或者电机转速只需要在主循环里不断更新CCR值。呼吸灯就是一个很直观的演示让占空比从0慢慢增加到100%再慢慢减回来while (1) { // 从暗变亮 for (uint16_t i 0; i 999; i 5) { TIM_SetCompare1(TIM3, i); // 设置CCR Delay(3); } // 从亮变暗 for (int i 999; i 0; i - 5) { TIM_SetCompare1(TIM3, i); Delay(3); } }呼吸灯看起来神奇原理就这么简单LED以很高频率快速开关人眼因为视觉暂留效应感知到的是平均亮度。频率越高闪烁感越不明显一般超过1kHz就很平滑了。这里Delay(3)的延时是为了让呼吸过程肉眼可见如果你直接连续改变CCR而没有任何延时呼吸会快得像频闪。4.3 PWM的延伸从调光到控制舵机电机呼吸灯跑通PWM这块就算入门了。它的应用范围远超点灯直流电机调速通过占空比控制电机两端平均电压配合电机驱动模块就能实现速度控制。比如两轮差速小车左右轮各用一个PWM通道两边占空比不同就能实现转向这正是热搜词里两轮差速小车stm32控制的核心思路。舵机控制舵机需要的是20ms周期、1ms到2ms的高电平脉宽对应0到180度角度。把ARR设为19999、CCR设成500到2500之间就能精确转角度。LED调光恒流驱动下PWM调光可以避免电流变化导致的色偏很多智能台灯和氛围灯就是这么做的。PWM真正的价值在于你配置好之后它就完全由硬件自动输出不需要CPU持续参与这为多路输出、复杂控制提供了极大的便利。5. 几个程序跑通了后面还会踩哪些坑程序都能跑了说明GPIO、定时器、串口、PWM这四块你已经拿下了。最后再结合我自己折腾的经验列几个必然遇到的问题和心态准备。5.1 下载调试相关的坑error: no stm32 target found!这类报错信息在网上搜得非常多。遇到它不用慌按这个顺序排查看看ST-Link和板子的接线对不对SWDIO、SWCLK、GND、3.3V四根线确认板子供电正常检查设备管理器里ST-Link是否被识别。还有一个容易忽视线ST-Link连接上了芯片但芯片内部的调试功能被禁用了比如代码里关了SWD口这时候需要按住板子的复位键点击下载的瞬间松开就能救回来。芯片包安装的问题也很常见Keil里新建工程找不到STM32芯片型号多半是芯片支持包没装。到Keil官网下载对应型号的Pack包双击安装重启Keil即可。这和C51工程是完全不同的体系很多人装了Keil C51再用它建STM32工程怎么都找不到芯片就是这个原因。stm32禁用jtag这个搜索热词我也关注到了当你想把调试接口的几个引脚省出来当普通GPIO用时可以在初始化里调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_Disable, ENABLE)释放引脚但一定要在确保调试器不再需要连接之后再干这件事否则下次下载程序就成了问题。5.2 代码层面的几个好习惯初始化后确认LED状态配置完GPIO后直接用GPIO_SetBits把LED置为灭的状态避免上电的瞬间引脚默认电平不确定导致LED闪一下。延时函数和中断服务的协调中断里用延时是大忌会让系统整个卡住。延时留给主循环用中断服务函数里只做标志位和数据的快速处理耗时操作全部丢给主循环。预留一个调试串口即使你的产品最终不连电脑开发阶段也建议预留一个串口用于日志输出。我见过太多人在硬件定型后才加调试口改动成本和心情损耗都是巨大的。这五个小程序覆盖的是基础能力的骨架往后的项目不管多复杂骨子里都是这些外设的组合。等跑通了这些例程再去看网上那些基于stm32的智能台灯、stm32鱼缸这类具体项目你会发现你已经能看懂它们的大致结构了剩下的只是不同的传感器、不同的算法、不同的通信协议而已。慢慢来把每个程序吃透比囫囵吞枣刷几十个例程有用得多。本文还有配套的精品资源点击获取