做嵌入式开发尤其是STM32这颗芯片你会发现无论过去多少年论坛里翻来覆去讨论的例子始终是那几样点亮LED、PWM呼吸灯、串口收发、ADC采集电压。有人觉得这些太基础但我要说正是这些“老掉牙”的例子构成了整个STM32开发的地基。我见过不少同学上来就追复杂项目的结果连GPIO的复用功能都没搞清后面调试得欲哭无泪。这几个经典例子每一个背后都牵扯到时钟树、寄存器映射、外设中断、总线时序这些核心概念。把它们吃透你再看传感器、屏幕、电机驱动这些五花八门的模块套路都是一样的。这篇文章不讲空话直接从实际工程角度把我在这些例子里踩过的坑、验证过的细节、推荐的做法分享出来。无论你是刚拿到开发板的小白还是用STM32做过一两样小东西但想更系统的工程师这篇文章都适合你。我会把从配置到代码、从现象到原理的过程拆开揉碎尽量让每个人都能照着做并且真正理解为什么要这么做。1. 这些经典例子为什么值得反复练1.1 从点灯到点亮一个职业方向很多初学者觉得点灯太幼稚但仔细想想GPIO的控制逻辑几乎渗透在所有外设驱动里。你操作一个I2C总线最终无非是把SCL和SDA两根线拉高拉低你驱动一个SPI接口的屏幕本质上也是通过寄存器配置去控制引脚电平时序。所以点灯不是目的理解GPIO的配置、复用、速度、上下拉才是目的。同样PWM呼吸灯背后是定时器的计数模式、预分频系数、自动重装载值和输出比较逻辑这些参数在电机控制、电源调压、音频输出中都会用到。经典例子的价值在于它是“最小可运行系统”。一个例子跑通了你就能把硬件初始化、时钟配置、外设寄存器这些抽象概念具象化。一个例子调试通你其实已经掌握了芯片最底层的操作逻辑——读手册、查寄存器、写配置、看波形。这一套流程以后面对任何新芯片都适用。我常说不要不会点灯就想着画板子步子大了真容易摔。1.2 学习路径和资源配套建议学这些例子我建议按下面这条路线走别贪多GPIO点灯和按键掌握时钟使能、引脚模式、高低电平控制、延时函数。定时器基础与PWM掌握定时器的时基单元、中断、PWM模式、占空比调节。串口通信掌握USART配置、波特率计算、重定向printf、中断收发。ADC采集与DMA掌握模拟量采集、多通道切换、DMA搬运数据。看门狗与低功耗掌握系统稳定性设计和低功耗应用思路。配套资源方面官方手册肯定是第一位的特别要配套阅读参考手册中的相关章节。网上现成的代码可以拿来跑但要自己重写一遍尤其把寄存器版的例子和HAL库版的例子对照着看。我当年就是把同一个串口例程用了三种实现方式各写一遍——寄存器操作、标准库、HAL库虽然麻烦但让我彻底搞懂了底层和封装的关系。2. 第一个绕不开的例子GPIO点灯与按键消抖2.1 原理GPIO输出与输入GPIO是芯片上最基础也最灵活的一组引脚。输出模式下你通过写输出数据寄存器让引脚输出高电平或低电平从而驱动LED亮灭输入模式下你读取输入数据寄存器知道引脚当前电平。但默认情况下引脚内部电路不一定是稳定状态所以输入模式往往需要配上拉电阻或下拉电阻。比如常见的按键接法键按下接地不按下引脚悬空这时就必须开启内部上拉电阻否则读到的电平会飘忽不定。STM32的GPIO还涉及一个经常被忽视的参数速度。它不是指数字传输速率而是对应输出驱动电路的翻转斜率可选低速、中速、高速。如果引脚驱动LED用低速就够了因为LED对边沿要求很低。但如果你模拟I2C或SPI高速通信速度等级不够就会出现波形圆润、通讯失败的隐患。这个参数很多人不看结果遇到过一些莫名其妙的问题其实源头在这儿。2.2 HAL库配置与代码编写假设我们要点一个接在PC13上的LED同时用一个接在PA0上的按键控制开关。在HAL库下核心配置如下GPIO_InitTypeDef GPIO_InitStruct {0}; // 使能GPIOA和GPIOC时钟 __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); // PC13配置为推挽输出低速无上下拉 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); // PA0配置为输入模式内部上拉 GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // 初始状态LED灭高电平熄灭 HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);主循环中按下按键翻转LED状态while (1) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { HAL_Delay(20); // 消抖延时 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET); // 等待释放 } } }注意这里翻转了两次状态判断先检测按键按下延时20ms后再确认一次避免了机械抖动带来的误触发。等待释放的循环是为了防止一次按下触发多次翻转这种防抖逻辑在工业按键上尤其重要。2.3 按键消抖的细节与测试按键消抖看起来简单实际工程里要注意的点不少。机械按键在按下和释放瞬间簧片会产生大约5~20ms的抖动期间电平会连续跳变。只延时一次还不够因为如果你的主循环里还有其他任务延时20ms会让整个系统卡顿一下。更好的做法是使用状态机加定时器轮询每2ms扫描一次按键连续采样多次都为低电平才确认按下这样既不阻塞主循环又能准确消抖。我实际测试过软件消抖延时选15~25ms比较合适太短没效果太长影响手感。如果你用的是低功耗芯片消抖过程还要考虑是否唤醒系统很多时候会把按键检测放在中断里配合事件计数器实现。这里有一点提醒在中断里做消抖延时是禁忌因为中断函数占用时间过长会挤掉其他优先级任务正确做法是中断里只置个标记消抖和按键处理放到主循环中完成。3. 用定时器PWM做呼吸灯这是理解硬件定时器的分水岭3.1 定时器基础与PWM输出原理定时器的本质是对内部时钟源进行计数。在STM32中APB1和APB2总线上的定时器时钟不同需要根据型号区分。基本定时器只能做内部计数和触发中断通用定时器则具备输入捕获、输出比较和PWM生成能力高级定时器还多了互补输出和刹车功能。呼吸灯用的是PWM模式也就是定时器的输出比较功能。PWM输出的核心在于一个周期内高电平时长占整个周期的比例即占空比。比如周期为1kHz的PWM如果高电平占0.5ms占空比就是50%LED亮度大约是最大亮度的一半。呼吸灯无非是让占空比按照一个缓慢变化的曲线从0%递增到100%再从100%递减到0%。这里面有两个关键参数频率和占空比分辨率。频率决定了LED会不会肉眼可见的闪烁一般大于100Hz就感知不出来了。占空比分辨率则由定时器计数范围决定比如10位分辨率就是0~1023档值越大调节越细腻。3.2 配置定时器与计算占空比以STM32F103系列的定时器TIM3为例假设系统时钟72MHzAPB1分频后定时器时钟也是72MHz。我们要输出频率为20kHz的高分辨率PWM并希望占空比调节步进足够细。计算公式如下PWM频率 定时器时钟 / (预分频系数 1) / (自动重装载值 1)如果预分频系数设35自动重装载值设为99则PWM频率 72MHz / 36 / 100 20kHz。控制精度就变成1/100这个分辨率做LED呼吸灯完全够用但做电机控制可能不够。如果频率改为1kHz自动重装载值设719预分频系数设0那么分辨率就是1/720调节会更顺手但要注意频率过低会看到明显的闪烁或听到电机啸叫。HAL库下配置PWM模式输出到某个引脚比如PA0需要查引脚复用表确认它是否支持TIM3的通道2时相关初始化代码大致如下其中Period就是自动重装载值减1Prescaler就是预分频减1TIM_HandleTypeDef htim3; TIM_OC_InitTypeDef sConfigOC; htim3.Instance TIM3; htim3.Init.Prescaler 35; htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 99; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(htim3); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 50; // 初始占空比50% sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_2); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_2);改变占空比时直接调用__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_2, new_pulse);这里的new_pulse会实时更新比较寄存器PWM波形随之变化。3.3 实测现象与参数微调做呼吸灯最直观的现象就是LED亮度平滑变化。如果出现闪烁多半是PWM频率太低调到5kHz以上基本看不见闪烁。如果亮度变化忽快忽慢原因多半是你把占空比的更新放在了中断里中断频率和PWM频率不匹配或者更新值计算用了延时函数导致节奏不匀。我更推荐的做法是在主循环中用一个递增变量每次循环改变占空比后延时20~50ms不要死等。另外还有个容易踩的坑调HAL_TIM_PWM_Init之前必须确保对应GPIO复用功能已配置否则定时器输出不会灌到引脚上。比如PA0要复用为TIM3_CH2需要做如下设置GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_TIM3_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; GPIO_InitStruct.Alternate GPIO_AF2_TIM3; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);很多人忽略Alternate字段结果引脚总是输出不了波形。把GPIO_MODE_AF_PP和GPIO_AF2_TIM3填对了问题自然消失。4. 串口通信让芯片和电脑说话4.1 USART串口初始化与printf重定向串口是STM32和PC调试之间最亲密的桥梁。初始化USART的套路和其他外设差不多使能时钟、配置GPIO复用、设置波特率、数据位、停止位、校验位。常用配置是115200-8-N-1即波特率115200数据位8位无校验1位停止位。波特率不要随便设必须和上位机、调试工具保持一致否则收到的全是乱码。HAL库下初始化代码UART_HandleTypeDef huart1; huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart1);然后重定向printf函数让标准输出重定向到串口。在ARM GCC环境下用类似下面的写法在MDK中则重写fputcint fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }这样你在主程序里写printf(“hello\n”)电脑串口助手就能收到。4.2 中断接收与环形缓冲区设计串口最简单的收发方法是HAL_UART_Transmit阻塞发送、HAL_UART_Receive阻塞接收。但对工程而言阻塞接收会卡死主循环所以更常用的做法是中断接收。每收到一个字节中断里自动触发回调你在回调里把数据丢进环形缓冲区主循环再去解析。经典实现如下#define BUFFER_SIZE 256 uint8_t rec_buffer[BUFFER_SIZE]; uint16_t rec_head 0; uint16_t rec_tail 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rec_buffer[rec_head] rec_byte; rec_head (rec_head 1) % BUFFER_SIZE; HAL_UART_Receive_IT(huart1, rec_byte, 1); // 重新开启接收 } } // 初始化启动接收 HAL_UART_Receive_IT(huart1, rec_byte, 1);这里有两个细节很容易出问题。一是HAL_UART_Receive_IT必须在回调里重新调用否则只接收一次就停了。二是环形缓冲区读写指针要处理好否则缓冲区满了会把旧数据覆盖掉。实际使用时判断缓冲区是否为空的方法是检查(rec_head - rec_tail)是否为0判断是否满的方法可以预留一个空位。如果觉得每次一字节中断太频繁可以开启空闲中断和DMA方式一次接收整帧数据。这样CPU开销更低适合高波特率、大批量传输的场景。不过新手最好先把手动单字节中断跑通再升级到DMA理解会更扎实。4.3 调试上位机的选择与经验串口调通之后选择上位机软件也很关键。最简单的串口助手很多可视化串口调试工具可以显示波形但注意有的工具需要正确解析你发送的数据帧格式。我习惯自定义一个简单协议比如帧头0xAA 0x55接着是数据长度然后是数据体和校验和。这样上位机才能准确提取数据避免字符串误分帧。调试中发现“能发送但接收不到”的情况先检查TX和RX有没有接反如果电脑端显示乱码检查波特率、时钟配置中的外部晶振频率、USB转串口芯片质量。有些USB转串口模块瞬间占用率高也会丢字节。高速调试时建议波特率降到57600或38400稳定性会好很多。5. ADC采集与DMA协同把模拟世界变成数字世界5.1 ADC工作原理与配置要点ADC本质是逐次逼近型转换器它会按照采样时钟对输入电压进行采样保持然后逐位比较输出电压对应的数字编码。STM32的ADC常见分辨率是12位也就是数字量范围0~4095。如果你使用3.3V参考电压那么一个LBS对应大约0.8mV的电压变化。外界传感器的0~3.3V电压经过ADC采样后就能变成一个可计算的数值。配置ADC时有几个参数要关注采样时间采样时间越长内部采样电容充电越充分测量越准确但转换速度变慢。一般用默认值但如果你想采集高阻抗源采样时间就要加长。分辨率有的STM32支持可配置分辨率12位最常用如果你不需要太细腻可以设为10位或8位来加快转换速度。连续转换模式开启后是持续采集不开启则每次手动触发。连续模式配合DMA很顺手。以下是一路ADC采集电压的核心初始化片段ADC_HandleTypeDef hadc1; hadc1.Instance ADC1; hadc1.Init.ScanConvMode DISABLE; hadc1.Init.ContinuousConvMode ENABLE; hadc1.Init.NbrOfConversion 1; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; HAL_ADC_Init(hadc1); ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_0; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig);启动ADC并用阻塞方式读取一次HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 1000); adc_val HAL_ADC_GetValue(hadc1); HAL_ADC_Stop(hadc1);5.2 多通道连续采样 DMA传输如果只有一个通道手动读没问题但三个通道以上还要不同时间段读取不同传感器数据手动轮询就有些麻烦了。这时候把ADC设置为扫描模式加连续转换同时开启DMA让数据自动从ADC外设搬运到内存数组里。每次转换完成DMA自动把数据放到数组中CPU只需处理数组中的数据。配置多通道时要把ScanConvMode设为ENABLE设置NbrOfConversion为通道数量。比如三个通道hadc1.Init.ScanConvMode ENABLE; hadc1.Init.NbrOfConversion 3;然后分别配置3个通道Rank分别设1、2、3。启动就变成HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, 3);DMA会循环把最新三次转换结果填到adc_buf[0]、adc_buf[1]、adc_buf[2]中。注意adc_buf必须是uint32_t数组因为ADC数据寄存器是32位容器。这块有个大坑STM32 ADC的DMA模式和普通DMA不同尤其有些型号如果采用循环模式数据会不断写入同一片内存使用时必须保证在下一次半满或全满中断前把数据取走。更稳妥的做法是开双缓冲也就是两个DMA缓冲轮流切换一个存数据时另一个让主程序处理避免读写冲突。5.3 数据滤波与工程实现ADC采集到的数据直接使用会很飘因为传感器本身带噪电源也未必干净。最简单的软件滤波有几种限幅滤波、中值滤波、滑动平均滤波。我推荐先采用滑动平均滤波尤其是采集电位器分压电压这种缓变信号效果立竿见影。滑动平均的代码很简单几乎每本书都有#define FILTER_N 8 uint32_t filter_buf[FILTER_N]; uint32_t filter_sum 0; uint32_t filter_index 0; uint32_t filter_add(uint32_t new_val) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] new_val; filter_sum new_val; filter_index (filter_index 1) % FILTER_N; return filter_sum / FILTER_N; }这个方式的本质是一个固定长度的队列每来一个新数据就挤掉一个最老的数据最后取平均。滤波窗口N越大越平滑但响应越慢。实际测电压N选8或16比较好测电流波形这类动态信号窗口选大了会把峰值抹平要视场景取舍。工程实现时还要注意如果ADC输入引脚悬空读出来的值是随机浮动的这是正常现象不代表代码错了。给引脚接一个确定电压再进行标定才有意义。6. 代码调试时的坑与排查心得6.1 时钟和引脚的隐形雷STM32新手最常见的问题之一是换了芯片型号以后代码直接死在SystemInit里。原因多半是目标芯片的启动文件、外部晶振频率、锁相环配置没有改过来。比如外部晶振是8MHz你却按25MHz配置HSE就起振不了时钟源切不过去。还有一个隐形雷就是引脚复用冲突。很多引脚复合功能很多比如PA9和PA10既可以是串口1的TX/RX也可能是USB的VBUS等。在参考手册的Pinout表格里每个引脚都标识了它是绝对复用还是默认复用。你如果不查直接用GPIO配置库去初始化结果功能不对百思不得其解。排查方法很简单用逻辑分析仪或示波器看引脚波形如果你配置了DMA或定时器但引脚没有信号优先怀疑复用功能配置没写对。另外很多STM32引脚默认是JTAG调试口比如PA13、PA14、PB3、PB4。如果你用了这些引脚做普通IO而调试器还启用了JTAG引脚电平就会被调试器占用。这时候要么关闭JTAG只保留SWD要么换用不冲突的引脚。6.2 调试工具与方法推荐推荐的调试工具不是越贵越好。几十块钱的ST-Link克隆版足够你完成SWD调试和烧录再加一个逻辑分析仪售价也不高但能看到GPIO时序、串口波形、PWM输出。如果再配一个桌上型万用表日常电压测量就齐了。示波器有钱就上没有逻辑分析仪也能对付。调试时我喜欢在关键函数入口处加全局变量标记而不全靠断点。因为断点碰到定时器中断或DMA回调会打乱时序。更实用的方法是用一段GPIO翻转代码测某个函数执行时间比如给某个引脚接个小灯函数开始时置高、结束置低拿逻辑分析仪一看就知道时间消耗。嵌入式调试要相信自己看到的波形而不是打印出来的无限循环消息。6.3 硬件的几个常用小技巧最后分享几个硬件上的排查技巧。第一系统不稳定时先查电源去耦电容STM32的每个VDD引脚附近都必须有100nF的电容有些开发板偷懒只放一个运行时可能偶发复位。第二MCU复位引脚要接上拉电阻加100nF电容不能悬空。第三很多开发板LED是低电平点亮高电平熄灭你的代码逻辑要跟着反着来否则你以为灯坏了其实只是极性反了。还有一个经验遇到信号质量差的场合比如长导线传输的PWM信号在接收端加一个滤波电容到地可以很大程度消除抖动。虽然这会让边沿变缓但对大多数应用无所谓。总之硬件排错一定从电源、地、复位、时钟四个方面查起这四个釜底抽薪的问题解决掉软件问题才会浮出水面不然你永远在为一个根本不必修的问题调试。说了这么多其实这些经典例子最迷人的地方不在于“跑了”“亮了”而在于你通过它们开始真正读懂芯片手册开始学会用波形和数据说话。我在实际项目中碰过无数个问题是代码之外的比如电源噪声导致ADC读数跳晶振匹配电容不对导致串口波特率偏差。基础例子练熟了这些排查思路就会变成你的直觉。如果你正在学这些例子遇到问题别急着怀疑硬件先查配置、再查接线然后查波形基本都能把问题缩小到很小的范围。慢慢来把这几个例子吃透后面做什么都会顺手很多。
阅读完成 · 觉得有帮助?