简介:这份资源面向STM32初学者与嵌入式入门开发者,围绕微控制器基础外设与典型应用场景,提供一套可直接参考的实践代码集合,帮助读者快速上手GPIO、定时器、中断、ADC与PWM等核心功能。压缩包共7个文件,全部为c源码,整体约8KB,涵盖流水灯、呼吸灯、小车寻迹、外部中断、按键与定时器等模块,每份代码对应一个独立实验方向,便于按需查阅与移植。其中流水灯演示GPIO电平控制与延时循环,呼吸灯借助PWM占空比调节实现亮度渐变,小车寻迹涉及传感器信号采集与电机方向控制,外部中断与定时器则帮助理解中断响应流程和时间基准配置。已有4041人学习,适合作为课程实验、自学练手或项目原型的起步参考,通过对照代码理解标准库或HAL库的调用方式,逐步建立STM32开发的基础知识体系。
1. 从点灯到串口回显:STM32 简单小程序到底能帮你跨过哪道坎
很多人第一次拿到 STM32 开发板,卡住的地方不是写不出代码,而是不知道一个“简单小程序”该简单到什么程度、跑通之后又能证明什么。我见过太多人一上来就啃时钟树和寄存器手册,结果三天没点亮一颗 LED,热情直接归零。其实 STM32 的入门路径可以拆成几个独立的小程序:GPIO 点灯、按键消抖、串口收发、定时器闪烁、PWM 调光、ADC 采样。每一个都只聚焦一个外设,代码量控制在几十行,但每一个都能让你真正理解“时钟使能—引脚配置—功能实现”这条主线。这篇文章面向的是刚接触 STM32 的嵌入式新手,以及想快速验证一块新板子好坏的工程师。我会把每个小程序的完整代码、参数含义、常见翻车点都摊开讲,你照着敲就能跑,跑完就知道下一步该往哪走。
2. 先把最小系统跑起来:GPIO 点灯与按键消抖的两个独立小程序
2.1 为什么点灯是检验板子死活的第一道关
拿到一块陌生的 STM32 板子,第一件事不是看手册,而是确认供电、晶振、下载器、LED 引脚这四样东西能不能形成闭环。点灯程序之所以经典,是因为它同时验证了时钟配置、GPIO 模式设置、下载电路和 LED 硬件通路。如果灯不亮,问题一定出在这四者之一,排查范围非常明确。
我一般用 HAL 库来写,因为新手最容易看懂,而且 STM32CubeMX 生成的初始化代码可以直接复用。下面这段代码基于常见的 STM32F103 系列,LED 接在 PA5,低电平点亮。
#include "stm32f1xx_hal.h" void SystemClock_Config(void); static void MX_GPIO_Init(void); int main(void) { HAL_Init(); // 初始化 HAL 库,配置 SysTick 为 1ms 中断 SystemClock_Config(); // 配置系统时钟,F103 通常跑到 72MHz MX_GPIO_Init(); // 初始化 GPIO 时钟和引脚 while (1) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); // 翻转 PA5 电平 HAL_Delay(500); // 阻塞延时 500ms,肉眼可见闪烁 } } static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); // 关键一步:使能 GPIOA 时钟,漏了灯永远不亮 GPIO_InitStruct.Pin = GPIO_PIN_5; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出 GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); }这段代码里最容易被忽略的是__HAL_RCC_GPIOA_CLK_ENABLE()。STM32 的外设时钟默认关闭,不使能时钟,你写再多寄存器配置都是白费。另一个参数是GPIO_MODE_OUTPUT_PP,推挽输出能同时拉高和拉低,适合驱动 LED;如果是开漏输出,高电平需要外部上拉电阻,新手很容易在这里翻车。
延时用HAL_Delay最简单,但它依赖 SysTick 中断,如果你在中断服务函数里调用它,程序会卡死。这一点后面讲定时器时会再展开。
2.2 按键消抖:为什么你的按键总是一按就触发好几次
按键程序比点灯更容易出玄学问题。机械按键在按下和松开的瞬间会产生 5 到 20 毫秒的抖动,如果直接用HAL_GPIO_ReadPin判断,一次按下可能被识别成好几次。很多新手以为是代码逻辑写错了,其实是没做消抖。
我一般用两种方式:阻塞式延时消抖和定时器中断消抖。先看阻塞式,适合对实时性要求不高的场景。
#define KEY_PIN GPIO_PIN_0 #define KEY_PORT GPIOA uint8_t Key_Scan(void) { static uint8_t key_up = 1; // 按键松开标志,静态变量保持状态 if (key_up && HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) { HAL_Delay(20); // 延时 20ms 跳过抖动窗口 if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) { key_up = 0; // 标记按键已按下,防止重复触发 return 1; } } else if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_SET) { key_up = 1; // 按键松开,恢复可检测状态 } return 0; }这里的 20ms 是经验值,大多数机械按键的抖动时间在 10ms 以内,取 20ms 留足余量。key_up这个静态变量是核心,没有它,按键按住不放会一直返回 1。注意HAL_Delay(20)会阻塞 CPU,如果你的系统里还有串口接收或 PWM 输出,这 20ms 可能导致数据丢失。所以更稳妥的做法是用定时器每 10ms 扫描一次按键,把消抖逻辑放在定时器中断里,主循环只读标志位。这个思路在后面定时器章节会具体实现。
按键的硬件接法也要注意:如果按键一端接 GPIO,另一端接 GND,GPIO 必须配置为上拉输入;如果接 VCC,则配置为下拉输入。配置反了,读到的电平永远不变,你会以为代码有问题,其实是引脚模式错了。
3. 串口收发与定时器:两个让程序“活起来”的小程序
3.1 串口回显:打通 PC 与 STM32 的第一条数据通道
串口是嵌入式开发中最重要的调试手段,没有之一。点灯只能告诉你程序在跑,串口能告诉你程序跑到哪了、变量是多少、收到了什么数据。一个最简单的串口回显程序,能把 PC 和 STM32 之间的数据通道打通,后面所有调试都靠它。
我用 USART1,波特率 115200,PA9 做 TX,PA10 做 RX。下面是初始化和回显的核心代码。
#include "stm32f1xx_hal.h" UART_HandleTypeDef huart1; void MX_USART1_UART_Init(void) { 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; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); } // 重定向 printf 到串口,方便调试 int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); uint8_t rx_buf; printf("STM32 UART Ready\r\n"); // 上电发一条消息,确认串口通了 while (1) { if (HAL_UART_Receive(&huart1, &rx_buf, 1, 100) == HAL_OK) { HAL_UART_Transmit(&huart1, &rx_buf, 1, 100); // 收到什么回什么 } } }HAL_UART_Receive的第四个参数是超时时间,单位毫秒。这里设 100ms,意味着如果没有收到数据,函数会阻塞 100ms 然后返回超时。在主循环里这样用没问题,但如果你还有别的任务,阻塞 100ms 就太久了。更合理的做法是用中断接收,每收到一个字节触发一次中断,把数据存进缓冲区,主循环再处理。中断接收的代码稍微复杂一点,但这是实际项目中的标准做法。
重定向printf依赖__io_putchar函数,不同编译器(Keil、IAR、GCC)对底层函数的命名要求不同。Keil 里常用fputc,GCC 里用_write。如果你编译报错说找不到__io_putchar,先确认你用的工具链和对应的重定向函数。
串口调试还有几个血泪经验:TX 和 RX 一定要交叉连接,即 STM32 的 TX 接 USB 转串口的 RX,RX 接 TX;共地一定要接,不共地数据全是乱码;波特率两边必须一致,115200 对 9600 就是一堆乱码。遇到乱码先查这三样,九成问题都在这里。
3.2 定时器闪烁:告别 HAL_Delay 的阻塞式延时
HAL_Delay用起来方便,但它是阻塞的,CPU 在延时期间什么也干不了。实际项目里,你不可能用HAL_Delay(500)来闪烁 LED,因为那 500ms 里串口收不到数据、按键检测不了、PWM 也更新不了。正确的做法是用定时器中断,让 LED 翻转发生在中断里,主循环该干嘛干嘛。
下面用 TIM2 配置一个 500ms 的定时中断,在中断回调里翻转 LED。
TIM_HandleTypeDef htim2; void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 7200 - 1; // 72MHz / 7200 = 10kHz 计数频率 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 5000 - 1; // 10kHz 下计 5000 次 = 500ms htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim2); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig); HAL_TIM_Base_Start_IT(&htim2); // 启动定时器并开启更新中断 } // 中断回调函数,HAL 库会自动调用 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); // 每 500ms 翻转一次 LED } }预分频器Prescaler和自动重装载值Period的计算是新手最容易搞混的地方。STM32F103 的 TIM2 挂在 APB1 总线上,时钟频率通常是 72MHz。预分频器设为 7200-1,实际分频系数是 7200,得到 10kHz 的计数频率。周期设为 5000-1,计满 5000 次就是 500ms。注意这两个寄存器写入的值都要减一,因为硬件是从 0 开始计数的。
中断回调函数里不要放耗时操作,翻转 LED 这种微秒级动作没问题,但如果你在里面调用HAL_Delay或者做浮点运算,中断就会卡住,其他中断也进不来。我见过有人在定时器中断里做串口打印,结果系统直接跑飞,这就是典型的踩坑。
4. PWM 调光与 ADC 采样:让 STM32 感知和控制模拟世界
4.1 PWM 调光:一个定时器通道就能做出呼吸灯
PWM 是嵌入式控制里最常用的技术之一,电机调速、LED 调光、舵机控制都靠它。STM32 的定时器自带 PWM 输出功能,配置好预分频和自动重装载值之后,只需要改比较寄存器的值就能调占空比。
下面用 TIM3 的通道 1 在 PA6 上输出 PWM,做一个呼吸灯效果。
TIM_HandleTypeDef htim3; TIM_OC_InitTypeDef sConfigOC = {0}; void MX_TIM3_PWM_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz 计数频率 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 1000 - 1; // 1MHz 下计 1000 次 = 1kHz PWM 频率 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&htim3); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 初始占空比 50% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 启动 PWM 输出 } // 呼吸灯效果:在主循环里逐渐改变占空比 void Breathing_LED(void) { for (int i = 0; i <= 1000; i += 10) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, i); HAL_Delay(10); } for (int i = 1000; i >= 0; i -= 10) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, i); HAL_Delay(10); } }PWM 频率设为 1kHz,人眼看不到闪烁,LED 调光效果很平滑。Pulse值决定占空比,范围从 0 到Period,这里 Period 是 999,所以 Pulse 设 500 就是 50% 占空比。__HAL_TIM_SET_COMPARE是宏,直接写比较寄存器,比调用 HAL 函数快得多,适合在循环里频繁调用。
呼吸灯的渐变步长和延时决定了效果是否顺滑。步长 10、延时 10ms,从 0 到 1000 需要 1 秒,一个完整的呼吸周期 2 秒,看起来比较自然。如果你把步长改成 50,变化就会很突兀。
4.2 ADC 采样:读取电位器电压并打印到串口
ADC 让 STM32 能感知外部模拟量,电位器是最简单的模拟信号源。下面用 ADC1 的通道 0 在 PA0 上采样,把结果通过串口打印出来。
ADC_HandleTypeDef hadc1; void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE; // 单通道,不扫描 hadc1.Init.ContinuousConvMode = DISABLE; // 单次转换,需要时再启动 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 数据右对齐,直接读就是 0-4095 hadc1.Init.NbrOfConversion = 1; HAL_ADC_Init(&hadc1); sConfig.Channel = ADC_CHANNEL_0; // PA0 对应通道 0 sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_55CYCLES_5; // 采样时间,信号源内阻大就加长 HAL_ADC_ConfigChannel(&hadc1, &sConfig); } uint16_t Read_ADC(void) { HAL_ADC_Start(&hadc1); // 启动转换 HAL_ADC_PollForConversion(&hadc1, 10); // 等待转换完成,超时 10ms return HAL_ADC_GetValue(&hadc1); // 读取 12 位结果 } // 在主循环里调用 void Print_ADC_Value(void) { uint16_t adc_val = Read_ADC(); float voltage = adc_val * 3.3f / 4095.0f; // 参考电压 3.3V,12 位分辨率 printf("ADC: %d, Voltage: %.2fV\r\n", adc_val, voltage); HAL_Delay(200); }STM32F103 的 ADC 是 12 位分辨率,输出范围 0 到 4095,对应 0 到 3.3V。SamplingTime这个参数很关键,如果信号源内阻较大(比如电位器阻值 10k 以上),采样时间太短会导致采样电容充不满,读数偏低且跳动。我一般先用 55.5 个周期,如果读数不稳再往上加。
ADC 的参考电压直接取自 VDDA,如果 VDDA 不稳或者没有接滤波电容,采样结果会跟着波动。这是硬件层面的坑,代码再怎么优化也解决不了。另外,PA0 如果同时被用作其他功能(比如待机唤醒),需要先关闭那个功能再配置为模拟输入。
5. 避坑与排查:STM32 简单小程序里最容易翻车的五个地方
5.1 程序下载后没反应,LED 就是不亮
现象:代码编译通过,下载器显示成功,但 LED 没有任何反应。
原因:九成是 GPIO 时钟没使能,或者 LED 引脚配置错了模式。还有可能是下载后没有复位,程序停在旧状态。
解决:先检查__HAL_RCC_GPIOx_CLK_ENABLE()有没有写,再确认 LED 接的是哪个引脚、高电平点亮还是低电平点亮。如果都没问题,按一下板子上的复位键,或者用下载器触发软复位。还有一种情况是 BOOT0 引脚被拉高,单片机从系统存储器启动,根本不跑你的程序,把 BOOT0 接 GND 再试。
5.2 串口打印出来全是乱码
现象:串口助手收到一堆看不懂的字符,偶尔夹杂几个正常字符。
原因:波特率不匹配、时钟配置错误、TX/RX 接反、没有共地,这四者占九成以上。
解决:先确认两边波特率都是 115200,再检查 STM32 的系统时钟是不是配到了 72MHz。如果时钟配错,实际波特率会偏离设定值,导致乱码。用示波器或者逻辑分析仪抓一下 TX 引脚波形,测量一个位的宽度,反推实际波特率。TX/RX 接反的话,串口助手什么也收不到,不是乱码,这个要区分开。
5.3 按键按一次触发多次
现象:按一下按键,串口打印出好几条按键消息。
原因:机械按键抖动,代码里没有消抖逻辑,或者消抖时间太短。
解决:加 20ms 延时消抖,并用静态变量标记按键状态。如果还是触发多次,检查按键硬件有没有并联电容,有些板子自带的电容太小,起不到消抖作用。另外,如果按键引脚配置成了浮空输入,电平会飘,改成上拉或下拉输入。
5.4 定时器中断进不去
现象:定时器配置好了,中断回调函数里翻转 LED 的代码就是不执行。
原因:中断优先级没设置、NVIC 没使能、回调函数名写错,或者定时器根本没启动。
解决:确认调用了HAL_TIM_Base_Start_IT()而不是HAL_TIM_Base_Start(),后者只启动定时器不开启中断。检查HAL_NVIC_SetPriority和HAL_NVIC_EnableIRQ有没有调用。回调函数名必须是HAL_TIM_PeriodElapsedCallback,拼写错误编译器不会报错,但函数永远不会被调用。
5.5 ADC 读数跳动很大
现象:电位器不动,ADC 读数在几十个数值范围内来回跳。
原因:采样时间太短、参考电压不稳、信号源内阻太大、没有做软件滤波。
解决:先把采样时间加到 239.5 个周期,如果还跳,在 VDDA 和 GND 之间并一个 100nF 加 10uF 的电容。软件上可以做多次采样取平均,比如连续采 16 次去掉最大最小值再平均,能明显改善跳动。如果对精度要求高,用外部基准电压芯片给 VREF+ 供电,比直接用 VDDA 稳得多。
6. 从能跑到好用:把简单小程序串成一个可扩展的框架
单个小程序跑通之后,下一步是把它们组织成一个可扩展的框架。我自己的习惯是每个外设一个独立的.c和.h文件,初始化函数统一在main里调用,中断回调集中在一个文件里分发。这样后面加新功能时,不用在几百行的main.c里翻来翻去。
具体做法是建一个bsp文件夹,里面放bsp_led.c、bsp_key.c、bsp_uart.c、bsp_timer.c。每个文件只暴露初始化函数和几个操作函数,比如LED_Toggle()、Key_Scan()、UART_SendString()。中断回调统一放在bsp_timer.c里,根据htim->Instance判断是哪个定时器,再调用对应的处理函数。
验证框架是否好用的方法很简单:加一个新外设,比如 SPI 驱动 OLED 屏幕,看看需要改几个文件。如果只需要新增bsp_oled.c和bsp_oled.h,在main.c里加一行初始化,那框架就是合格的。如果改得到处都是,说明耦合太紧,需要重构。
还有一个实用技巧是把printf重定向和断言结合起来。在bsp_uart.c里定义一个DEBUG_PRINT宏,发布版本里直接编译成空语句,调试版本里输出到串口。这样同一套代码既能调试又能发布,不用手动删打印语句。
我刚开始学 STM32 的时候,总觉得简单程序没什么好写的,点个灯、收个串口数据能证明什么。后来带新人多了才发现,恰恰是这些简单程序暴露了最基础的问题:时钟使能忘了、引脚模式配错了、中断优先级没设、阻塞延时用在了不该用的地方。这些问题在大项目里会被各种抽象层掩盖,但在简单程序里一目了然。把每个简单程序都跑通、跑稳,比急着上 RTOS 和文件系统重要得多。希望帮到你。
本文还有配套的精品资源,点击获取