TL;DR(太长不看版):本文面向刚接触 STM32F103 的开发者,用流水灯、蜂鸣器和传感器三个经典实验,帮你建立一套可复用的嵌入式代码理解框架。核心观点是:所有外设初始化都遵循"时钟 → 模式 → 初始状态"的统一模板;流水灯和蜂鸣器属于"输出型"外设,代码骨架同构(写引脚 + 延时/频率控制);传感器属于"输入型"外设,多出"触发 → 读取 → 解析 → 输出"的链路。读完你就能快速读懂任何 STM32 外设的初始化与主循环代码,为后续学习串口、定时器中断和 FreeRTOS 打好基础。
1. 引言
对于刚接触 STM32F103 的开发者来说,流水灯、蜂鸣器和传感器是三个最经典的入门实验。它们看似简单,却几乎覆盖了嵌入式开发的核心要素:GPIO 输出控制、定时器延时、外部中断、ADC 采集以及传感器数据的读取与处理。
本文不打算逐行贴出完整工程(那会淹没重点),而是从代码构成与基本结构的角度,帮你建立一套可复用的理解框架。读完你会明白:为什么流水灯和蜂鸣器的代码骨架几乎一样?传感器代码又为什么多出"初始化—读取—转换—输出"这几步?理解了这些,后续学习串口、定时器中断、FreeRTOS 都会顺畅很多。
2. 开发环境与工程结构
在分析代码之前,先明确我们面对的是怎样的工程。STM32F103 的开发通常有两种主流方式:
- 标准外设库(StdPeriph_Lib):经典、资料多,适合学习寄存器与库函数的对应关系。
- HAL 库(STM32CubeMX 生成):图形化配置,代码可读性好,适合快速上手和工程化开发。
无论用哪种库,一个完整的工程都包含以下固定部分:
Project/ ├── Core/ │ ├── Inc/ # 头文件(main.h、gpio.h、tim.h 等) │ └── Src/ # 源文件(main.c、gpio.c、tim.c 等) ├── Drivers/ │ ├── CMSIS/ # 内核相关定义 │ └── STM32F1xx_HAL_Driver/ # HAL 库源码 └── main.c # 主函数入口理解这个结构很重要:外设的"初始化"和"使用"是分离的。初始化代码通常由 CubeMX 生成在gpio.c、tim.c等文件中,而业务逻辑写在main.c的while(1)循环里。这也是初学者最容易困惑的地方——明明在main.c里没看到引脚配置,为什么灯能亮?因为配置在MX_GPIO_Init()里被调用了。
3. 流水灯代码的结构化理解
3.1 初始化阶段
流水灯的本质是多个 GPIO 引脚按顺序输出高低电平。初始化代码的核心是MX_GPIO_Init(),它做的事情可以归纳为三步:
voidMX_GPIO_Init(void){GPIO_InitTypeDef GPIO_InitStruct={0};// 1. 使能 GPIO 端口时钟__HAL_RCC_GPIOC_CLK_ENABLE();// 2. 配置引脚模式:推挽输出、无上下拉、低速GPIO_InitStruct.Pin=LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin;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);// 3. 初始状态:全部熄灭(高电平熄灭,取决于硬件接法)HAL_GPIO_WritePin(GPIOC,LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin,GPIO_PIN_SET);}这段代码的结构可以抽象为**“时钟 → 模式 → 初始状态”**三步曲。几乎所有 GPIO 外设(按键、蜂鸣器、传感器使能脚)的初始化都是这个套路。
3.2 主循环中的流水逻辑
流水效果的核心在while(1)循环里,常见写法有两种:
写法一:逐个点亮(最直观)
while(1){HAL_GPIO_WritePin(GPIOC,LED1_Pin,GPIO_PIN_RESET);// 亮HAL_Delay(200);HAL_GPIO_WritePin(GPIOC,LED1_Pin,GPIO_PIN_SET);// 灭HAL_GPIO_WritePin(GPIOC,LED2_Pin,GPIO_PIN_RESET);HAL_Delay(200);// ... 依次类推}写法二:移位法(更优雅,适合多灯)
uint16_tled_mask=0x0001;// 从第一盏灯开始while(1){HAL_GPIO_WritePin(GPIOC,LED_ALL_Pin,GPIO_PIN_SET);// 先全灭HAL_GPIO_WritePin(GPIOC,led_mask,GPIO_PIN_RESET);// 点亮当前位HAL_Delay(200);led_mask<<=1;// 左移一位if(led_mask>0x0008)led_mask=0x0001;// 循环回第一盏}理解要点:流水灯代码的骨架是"写引脚 → 延时 → 换下一个"。这里的HAL_Delay(200)是阻塞式延时,CPU 在延时期间空转。这是入门阶段可以接受的,但当你需要同时处理多个任务(比如流水灯的同时还要响应按键)时,就要改用定时器中断或非阻塞延时了——这是后话。
写法三:定时器中断驱动(非阻塞,进阶推荐)
当需要同时处理多个任务时,HAL_Delay()的阻塞特性就成了瓶颈。改用定时器中断后,CPU 在等待期间可以继续执行其他逻辑(如响应按键、刷新 OLED),流水灯的节奏完全由硬件定时器驱动。
// 1. 定时器初始化回调(CubeMX 生成在 tim.c 中)voidMX_TIM2_Init(void){TIM_ClockConfigTypeDef sClockSourceConfig={0};TIM_MasterConfigTypeDef sMasterConfig={0};htim2.Instance=TIM2;htim2.Init.Prescaler=7199;// 72MHz / 7200 = 10kHzhtim2.Init.CounterMode=TIM_COUNTERMODE_UP;htim2.Init.Period=1999;// 10kHz / 2000 = 5Hz,即 200ms 中断一次htim2.Init.ClockDivision=TIM_CLOCKDIVISION_DIV1;HAL_TIM_Base_Init(&htim2);}// 2. 启动定时器并开启中断(在 main() 中调用)HAL_TIM_Base_Start_IT(&htim2);// 3. 中断回调函数(HAL 库弱函数,在 main.c 中重写)voidHAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef*htim){staticuint16_tled_mask=0x0001;if(htim->Instance==TIM2){HAL_GPIO_WritePin(GPIOC,LED_ALL_Pin,GPIO_PIN_SET);// 先全灭HAL_GPIO_WritePin(GPIOC,led_mask,GPIO_PIN_RESET);// 点亮当前位led_mask<<=1;// 左移一位if(led_mask>0x0008)led_mask=0x0001;// 循环回第一盏}}// 4. 主循环:不再需要任何延时,可以处理其他任务while(1){// 响应按键、刷新 OLED、读取传感器……CPU 完全空闲}对比小结:
| 对比维度 | HAL_Delay()阻塞式 | 定时器中断驱动 |
|---|---|---|
| CPU 利用率 | 延时期间空转,浪费 | 等待期间可执行其他任务 |
| 多任务支持 | 差,难以同时响应按键 | 好,主循环可自由处理其他逻辑 |
| 代码复杂度 | 简单,易上手 | 稍复杂,需理解中断与回调 |
| 时序精度 | 依赖 SysTick,受中断影响 | 由硬件定时器保证,更精确 |
| 适用场景 | 入门学习、单任务 | 多任务、实时性要求高的工程 |
下面用一张 Mermaid 时序图直观对比两种方案下 CPU 的占用情况。横轴为时间,纵轴标注「主循环任务」与「定时器中断」两条泳道,色块表示 CPU 正在执行的内容:
读图要点:上半部分(灰色区域)中,主循环在HAL_Delay(200)期间完全空转,CPU 被白白占用;下半部分(绿色区域)中,主循环只在写引脚瞬间占用 CPU,其余时间片都可用于响应按键、刷新 OLED 等业务,流水节奏完全由定时器中断驱动。
理解要点:定时器中断方案的本质是把"延时等待"从主循环中剥离出来,交给硬件定时器去计时,到点后通过中断回调自动切换下一盏灯。主循环因此被解放出来,可以专心处理其他业务。这也是从"会抄代码"走向"会写代码"的关键一步——理解了中断驱动模型,后续学习 FreeRTOS 的任务调度会轻松很多。
4. 蜂鸣器代码的结构化理解
蜂鸣器与流水灯在代码结构上几乎完全一致,区别只在于:
- 蜂鸣器通常只有一个 GPIO 引脚;
- 有源蜂鸣器(自带振荡源)只需给高低电平即可发声;
- 无源蜂鸣器需要PWM 方波驱动,才能发出不同频率的声音。
4.1 有源蜂鸣器:与流水灯同构
// 初始化:与 LED 完全相同的三步曲// 主循环:按节奏翻转电平即可产生"滴滴"声while(1){HAL_GPIO_WritePin(BUZZER_Pin,GPIO_PIN_RESET);// 响HAL_Delay(100);HAL_GPIO_WritePin(BUZZER_Pin,GPIO_PIN_SET);// 停HAL_Delay(100);}4.2 无源蜂鸣器:引入 PWM
无源蜂鸣器需要定时器产生 PWM 信号。初始化代码多了一个MX_TIM3_Init(),结构变为:
// 1. GPIO 初始化(同上)// 2. 定时器初始化:设置预分频和自动重载值,决定 PWM 频率// 3. 配置 PWM 通道并启动HAL_TIM_PWM_Start(&htim3,TIM_CHANNEL_1);// 主循环:通过修改比较寄存器改变占空比,从而控制音量__HAL_TIM_SET_COMPARE(&htim3,TIM_CHANNEL_1,500);理解要点:蜂鸣器代码的骨架是"GPIO 初始化(+ 定时器初始化)→ 输出控制 → 延时/频率控制"。它与流水灯共享同一套 GPIO 初始化模板,多出来的定时器部分,本质上是把"延时控制"升级为"频率控制"。
5. 传感器代码的结构化理解
传感器(以常见的 DHT11 温湿度传感器为例)的代码结构与前面两个有本质区别。它不再是简单的"输出控制",而是**“输入读取 → 数据解析 → 结果输出”**的完整链路。
5.1 传感器代码的通用四段式结构
// 第一步:初始化(GPIO 配置为输出模式,用于发送起始信号)voidDHT11_Init(void){GPIO_InitTypeDef GPIO_InitStruct={0};// 1. 使能 GPIO 端口时钟__HAL_RCC_GPIOB_CLK_ENABLE();// 2. 配置引脚为推挽输出模式(初始用于发送起始信号)GPIO_InitStruct.Pin=DHT11_Pin;GPIO_InitStruct.Mode=GPIO_MODE_OUTPUT_PP;GPIO_InitStruct.Pull=GPIO_NOPULL;GPIO_InitStruct.Speed=GPIO_SPEED_FREQ_HIGH;// 高速,保证时序边沿陡峭HAL_GPIO_Init(DHT11_Port,&GPIO_InitStruct);// 3. 初始状态:拉高,保持总线空闲HAL_GPIO_WritePin(DHT11_Port,DHT11_Pin,GPIO_PIN_SET);}// 第二步:发送起始信号并切换为输入模式voidDHT11_Start(void){// 1. 主机拉低总线至少 18ms,作为起始信号HAL_GPIO_WritePin(DHT11_Port,DHT11_Pin,GPIO_PIN_RESET);HAL_Delay(20);// 拉低 20ms(要求 ≥18ms)// 2. 拉高总线,释放总线控制权HAL_GPIO_WritePin(DHT11_Port,DHT11_Pin,GPIO_PIN_SET);delay_us(30);// 拉高 30us(要求 20~40us)// 3. 切换为输入模式,等待 DHT11 响应GPIO_InitTypeDef GPIO_InitStruct={0};GPIO_InitStruct.Pin=DHT11_Pin;GPIO_InitStruct.Mode=GPIO_MODE_INPUT;// 输入模式,读取传感器数据GPIO_InitStruct.Pull=GPIO_PULLUP;// 上拉,保证空闲时为高电平HAL_GPIO_Init(DHT11_Port,&GPIO_InitStruct);}// 第三步:读取数据(逐位读取 40 位数据)uint8_tDHT11_ReadByte(void){uint8_tdata=0;for(inti=0;i<8;i++){// 等待低电平结束(每位数据以 50us 低电平开始)while(HAL_GPIO_ReadPin(DHT11_Port,DHT11_Pin)==GPIO_PIN_RESET);// 高电平持续时间决定 0 或 1:// - 26~28us 表示逻辑 0// - 70us 表示逻辑 1delay_us(40);// 采样点:高电平开始后 40usif(HAL_GPIO_ReadPin(DHT11_Port,DHT11_Pin)==GPIO_PIN_SET){data|=(0x80>>i);// 高电平仍为高 → 逻辑 1}// 等待该位高电平结束,进入下一位while(HAL_GPIO_ReadPin(DHT11_Port,DHT11_Pin)==GPIO_PIN_SET);}returndata;}// 第四步:数据校验与结果输出voidDHT11_ReadData(uint8_t*humidity,uint8_t*temperature){uint8_tdata[5]={0};// 40 位数据:湿度整/小、温度整/小、校验和// 1. 等待 DHT11 响应信号(低电平 80us + 高电平 80us)while(HAL_GPIO_ReadPin(DHT11_Port,DHT11_Pin)==GPIO_PIN_SET);// 等待拉低while(HAL_GPIO_ReadPin(DHT11_Port,DHT11_Pin)==GPIO_PIN_RESET);// 等待拉高while(HAL_GPIO_ReadPin(DHT11_Port,DHT11_Pin)==GPIO_PIN_SET);// 等待数据开始// 2. 连续读取 5 个字节(40 位)for(inti=0;i<5;i++){data[i]=DHT11_ReadByte();}// 3. 校验和验证:前 4 字节之和的低 8 位应等于第 5 字节if((uint8_t)(data[0]+data[1]+data[2]+data[3])==data[4]){*humidity=data[0];// 湿度整数部分*temperature=data[2];// 温度整数部分}else{// 校验失败:返回无效值,由调用方决定是否重试*humidity=0xFF;*temperature=0xFF;}}// 微秒级延时(基于 DWT 或定时器,HAL_Delay 最小精度为 1ms,无法满足 DHT11 时序)voiddelay_us(uint32_tus){// 以 DWT 为例:使能 DWT 周期计数器,实现精确微秒延时CoreDebug->DEMCR|=CoreDebug_DEMCR_TRCENA_Msk;DWT->CYCCNT=0;DWT->CTRL|=DWT_CTRL_CYCCNTENA_Msk;uint32_tstart=DWT->CYCCNT;uint32_tticks=us*(SystemCoreClock/1000000);// 72MHz → 每微秒 72 个时钟周期while((DWT->CYCCNT-start)<ticks);}5.2 主循环中的调用方式
while(1){DHT11_Start();DHT11_ReadData(&humidity,&temperature);// 将结果通过串口打印或显示在 OLED 上HAL_Delay(1000);// 传感器采样间隔通常需要 1s 以上}理解要点:传感器代码的骨架是"初始化 → 触发 → 读取 → 解析 → 输出"。与流水灯/蜂鸣器最大的不同在于:
- 数据流向相反:从"写引脚"变为"读引脚";
- 引入了时序协议:需要精确测量高低电平的持续时间来解析数据;
- 引入了校验机制:防止读取错误;
- 引入了采样间隔:传感器本身有响应时间限制。
6. 三类代码的统一抽象
把三个实验放在一起对比,你会发现它们其实共享同一个底层骨架:
┌─────────────────────────────────────────────────┐ │ STM32F103 外设代码通用骨架 │ ├─────────────────────────────────────────────────┤ │ 1. 时钟使能(RCC) │ │ 2. GPIO 初始化(模式/速度/上下拉) │ │ 3. (可选)定时器/ADC/中断等外设初始化 │ │ 4. 主循环: │ │ ├─ 流水灯:写引脚 → 延时 → 换下一个 │ │ ├─ 蜂鸣器:写引脚/PWM → 频率控制 │ │ └─ 传感器:触发 → 读取 → 解析 → 输出 │ └─────────────────────────────────────────────────┘用 Mermaid 表示三类实验的代码结构对比:
核心结论:流水灯和蜂鸣器是"输出型"外设,代码结构简单、同构;传感器是"输入型"外设,多出"读取与解析"环节。但它们的初始化部分完全一致,都遵循"时钟 → 模式 → 初始状态"的模板。掌握了这个模板,你就能快速读懂任何 STM32 外设的初始化代码。
7. 常见问题与调试建议
7.1 灯不亮 / 蜂鸣器不响
- 检查 GPIO 初始化是否被调用(
MX_GPIO_Init()是否在main()中执行); - 检查引脚号是否与硬件接线一致;
- 检查电平逻辑:有些板子是低电平点亮,有些是高电平点亮。
7.2 传感器读数异常
- 确认采样间隔大于 1 秒;
- 检查起始信号时序是否准确(延时是否被优化掉);
- 用示波器或逻辑分析仪观察数据线波形,这是最有效的排查手段。
7.3 延时不准
HAL_Delay()依赖 SysTick 中断,确认中断未被关闭;- 需要更精确的延时或并行任务时,改用定时器。
8. 总结
通过流水灯、蜂鸣器和传感器三个实验,我们实际上掌握了 STM32F103 开发的最小知识闭环:
- GPIO 初始化模板:时钟 → 模式 → 初始状态,适用于所有外设;
- 输出型外设(流水灯、蜂鸣器):写引脚 + 延时/频率控制;
- 输入型外设(传感器):触发 → 读取 → 解析 → 输出,多出时序协议与校验环节。
这三个实验的价值不在于"让灯亮起来"本身,而在于让你建立起"初始化与业务分离、外设分输入输出、主循环驱动一切"的嵌入式思维。带着这个框架去读任何 STM32 代码,你都会觉得清晰很多。
下一步建议:尝试把流水灯改为定时器中断驱动(非阻塞),再把传感器数据通过串口打印出来——这两个练习能帮你从"会抄代码"进阶到"会写代码"。