本文整理自个人 STM32 学习笔记,以 STM32F103C8T6(F1 系列)为例,基于 CubeMX + CubeIDE + HAL 库 开发环境,从 GPIO 开始一步步讲到串口的中断、DMA 与不定长接收,最后附上 DMA 原理详解。适合刚入门单片机、想系统梳理 HAL 库开发脉络的同学。

前言
嵌入式开发的起点往往就是点亮一颗 LED、读一个按键、发一条串口数据。看似简单的背后,是 GPIO、中断、通信外设三条主线。这篇文章把主线串起来讲清楚:先讲清楚"硬件结构",再讲"HAL 库函数怎么用",最后给出"实战例程与踩坑记录"。

一、GPIO:最基础的外设
1.1 什么是 GPIO
GPIO(General Purpose Input/Output,通用输入输出)接口,可以在 STM32 控制下读取外部电路电压或向外输出电压。功能上分三类:普通输入/输出、复用(交给片上外设使用)、模拟(直接送 ADC 等模拟外设)。

1.2 GPIO 端口基本结构
一个 GPIO 引脚内部大致由四部分组成:

保护二极管:瞬间电压超过 VDD 时上方二极管导通,把电流引入电源吸收;低于 VSS 时下方二极管引入 GND。只能抵御瞬间波动。注意:标注 FT 的引脚可 5V 容忍。
寄存器:32 位一组的特殊内存单元,是程序与硬件电路之间的桥梁——操作单片机最底层就是操作寄存器。
输出驱动器(P-MOS / N-MOS 两个 MOS 管):
推挽输出:两个 MOS 协同工作,由芯片内部电源驱动,输出 3.3V / 0V,驱动能力强;
开漏输出:只有 N-MOS 工作,输出高电平时是高阻态(断路),必须靠外部上拉电阻/外部电源驱动,通常搭配 5V 容忍引脚使用。
控制来源又分为"输出数据寄存器"与"片上外设"两种,于是细分出推挽、开漏、复用推挽、复用开漏四种输出模式。
输入驱动器:支持上拉/下拉/浮空输入(STM32 内部自带上下拉电阻),信号经过 TTL 施密特触发器得到明确的高低电平,一路存入输入数据寄存器,一路直接送给片上外设;不经过触发器、直接输入的是模拟输入。
施密特触发器的作用:设置两个参考电压,高于高参考判为高电平、低于低参考判为低电平、两者之间维持原状态,从而解决噪声在参考电压附近"徘徊"时电平误判的问题。
1.3 GPIO 常用 HAL 库函数


// 读取引脚电平 GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);

// 翻转引脚电平(引脚必须已配置为输出模式) void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);

// SysTick 毫秒级阻塞延时(禁止在中断/回调中使用!) void HAL_Delay(uint32_t Delay); ```

1.4 按键消抖(软件方式)
机械按键按下时会有 10~20ms 的抖动期,直接读电平会"抖"出多次触发。软件消抖经典写法:二次检测 + 等松手。

c if (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET) { HAL_Delay(10); // 跳过机械抖动期 if (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET) { HAL_GPIO_TogglePin(LED_RED_GPIO_Port, LED_RED_Pin); while (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET) {} // 等松手,防止重复触发 } }

踩坑:while 死等式消抖会阻塞主循环,在复杂项目里应改用"定时查询 + 状态"的方式(后面状态机一节会讲非阻塞写法)。

二、外部中断 EXTI 与 NVIC
2.1 中断是什么
中断机制:CPU 暂停当前程序,转去执行处理异步事件的中断服务函数(ISR),处理完再回到被打断的地方继续执行。外部中断就是检测 GPIO 电平变化的中断,触发方式分上升沿 / 下降沿 / 双边沿。

2.2 EXTI 的信号流
F1 系列有 19 个外部中断控制器,共用一套寄存器但连线独立;EXTI0~15 对应同名 GPIO 引脚。信号流为:

GPIO → 输入驱动器 → 边沿检测电路 → 请求挂起寄存器 → NVIC

几个关键概念:

事件屏蔽寄存器:事件 ≠ 中断。事件 = 信号直接送给外设自行处理;中断 = 送 CPU 执行代码。
边沿检测电路 + 软件中断事件寄存器:两者经过"或门"后进入请求挂起寄存器(软件可以模拟中断)。
请求挂起寄存器 + 中断屏蔽寄存器:两者经过"与门",只有屏蔽位同时为 1(软件配置)才向 NVIC 发信号。
2.3 NVIC 与中断优先级
NVIC(嵌套向量中断控制器)是 Cortex-M 内核的专用硬件,负责优先级管理(支持嵌套)、使能/屏蔽、Pending 状态记录,以及硬件自动压栈/出栈恢复现场。

优先级分两层:抢占优先级 + 响应优先级,数字越小越优先。

两个中断同时发生:先比抢占优先级 → 再比响应优先级 → 再比向量表顺序;
中断 A 执行中,中断 B 到来:只比抢占优先级(抢占优先级更高的才能打断 A)。
每个中断向量用 4 个二进制位存优先级,CubeMX 中通过 NVIC → Priority Group 配置分配方式(默认 4 位全作抢占,即 0~15 级)。

2.4 中断向量表
EXTI0~4 各有独立向量;EXTI5~9 共享 EXTI9_5,EXTI10~15 共享 EXTI15_10。中断处理函数运行完若 NVIC 仍监测到中断激活会重复运行,因此必须清除请求挂起位(CubeIDE 生成的代码默认已处理)。

2.5 CubeIDE 里的中断文件
stm32f1xx_it.c 包含两类内容:

内核异常中断:HardFault、MemManage、BusFault、SysTick、PendSV 等空框架;
外设中断入口:EXTI0_IRQHandler、TIM1_UP_IRQHandler、USART1_IRQHandler 等(NVIC 使能后自动生成)。
踩坑:HAL_Delay 不能在中断回调中使用——回调本身就是中断的一部分,执行 HAL_Delay 等于执行一个优先级很低的 SysTick 中断,会变成"注定无结果的死等"。

三、串口通信(一):轮询模式
3.1 TTL 串口基础
TTL 串口只需两根数据线(TX 发送 / RX 接收)加共地线即可双向同时通信(全双工)。串口是异步 UART:每字节 10 bit(1 起始 + 8 数据 + 1 停止),波特率 = 每秒码元数,常见 115200 / 9600 / 19200 / 38400,两端必须一致。CubeMX 中的 Word Length / Parity / Stop Bits 一般保持默认。

坑:串口 / IIC / PWM 等复用配置可能覆盖下载调试器接口,导致"下载一次后无法再下载"。此时手动设置 System Core → SYS → Debug → Serial Wire(STLINK 模式)即可恢复。

3.2 轮询收发函数
```c // 阻塞式发送:函数会一直等到数据发完或超时 HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef huart, const uint8_t pData, uint16_t Size, uint32_t Timeout); // huart:串口句柄(&huart1 / &huart2)指定用哪个串口 // pData:数据指针(带 const,函数不会改数据);Size:字节数(不是字符串长度!含 '\0' 要算上,可用 strlen) // Timeout:超时时间(毫秒),HAL_MAX_DELAY 表示无限等待

// 阻塞式接收:等 Size 个字节收满或超时才返回 HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef huart, uint8_t pData, uint16_t Size, uint32_t Timeout); // pData 没有 const——数据会被函数写入 ```

返回值统一为 HAL_StatusTypeDef 枚举:

返回值	含义
HAL_OK	成功
HAL_TIMEOUT	超时
HAL_ERROR	出错(未初始化、引脚配置错误、硬件故障等)
HAL_BUSY	串口正忙
3.3 轮询模式的致命缺陷
CPU 占用率极高:等待硬件响应时 CPU 被完全"卡住",无法执行主循环里的其他任务;
容易丢数据:CPU 没及时轮询时,新数据会覆盖旧数据;
不适合多任务 / 实时系统;
只能接收固定长度。
底层机制:发送靠 TDR / 发送移位寄存器,接收靠 RDR / 接收移位寄存器,CPU 死循环查询状态寄存器。这就是为什么要引入中断模式和 DMA 模式。

四、串口通信(二):中断模式
4.1 关键标志位
硬件检测到关键事件会主动发起中断:

RXNE:RDR 收到完整一帧,置位并触发中断,ISR 中读取数据;
TXE:TDR 数据已转走,可以写下一字节(连续发送的关键);
TC:整帧发送结束;
错误中断:ORE(溢出)、FE(帧错误)、NE(噪声)。
4.2 中断收发函数
```c // 中断(非阻塞)发送:把数据指针存起来 + 使能中断后立即返回,发完自动调回调 HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef huart, uint8_t pData, uint16_t Size); // 返回 HAL_OK 启动成功 / HAL_BUSY 串口正忙 / HAL_ERROR 参数或底层错误

// 中断(非阻塞)接收:向硬件"预约",绑定缓冲区 + 长度并使能 RXNE,收满后自动调回调 HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef huart, uint8_t pData, uint16_t Size); ```

注意事项:这两个函数没有超时参数;Receive_IT 要放在循环外面(放循环内会在未收完时反复重新启动接收);不能在接收函数下面立刻分析数据,必须在回调中分析。

4.3 HAL 中断处理的"三层结构"
中断入口函数(硬件对接层):名称固定,如 USART1_IRQHandler,CubeMX 自动生成,只负责调用下一层并传递句柄;
HAL 通用处理函数(底层驱动层):读状态寄存器识别中断源 → 清标志 → 判断是否关中断使能 → 任务完成则调用回调;
中断回调函数(用户业务层):__weak 弱定义(HAL_UART_RxCpltCallback / HAL_UART_TxCpltCallback),在 main.c 的 USER CODE BEGIN 4 区域重写覆盖。
所以:个人业务代码只写回调,不要直接改中断入口函数(一个 USART 只有一个中断向量,被多个中断源共用,CubeMX 已处理好类型判断)。

4.4 回调解析例程:串口指令控灯
上位机发送形如 R1 / G0 / B1 的两字节指令,分别控制红绿蓝 LED 的亮灭:

c void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { HAL_UART_Transmit_IT(&huart2, receiveData, 2); // 回显 GPIO_PinState state = GPIO_PIN_SET; if (receiveData[1] == '0') state = GPIO_PIN_RESET; if (receiveData[0] == 'R') HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, state); else if (receiveData[0] == 'G') HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, state); else if (receiveData[0] == 'B') HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, state); // 这里绝不能写 HAL_Delay(1000),否则会发生"优先级翻转"式的死等! HAL_UART_Receive_IT(&huart2, receiveData, 2); // 回调末尾再次启动,实现持续接收 }

五、串口通信(三):DMA 模式与不定长接收
5.1 DMA 底层在做什么
以"串口收 100 字节到内存"为例,DMA 的全过程:

配置阶段(CPU 写寄存器):源地址 PAR = 串口数据寄存器地址、目标地址 MAR = SRAM 缓冲地址、数据量 CNDTR = 100、控制字 CCR(方向=外设→内存,目标地址自增);
触发阶段:EN 位置 1;
核心执行(纯硬件):自动循环"读总线 → 写总线 → 计数器减 1 → 地址自增 → 判断计数器是否为 0";
完成:计数器到 0 停止,硬件置 TCIF(传输完成标志);
结束:TCIF 经硬件连线通知 NVIC → CPU 跳 ISR 处理数据并清标志。
5.2 DMA 收发函数
c HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size); HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size); // 参数与中断模式一致;配置 DMA 源/目标地址并使能请求后立即返回; // 完成后触发中断并调用回调;持续接收须在回调中再次调用 Receive_DMA 重新启动

5.3 串口接收不定长数据(空闲 Idle 中断)
空闲中断:触发条件与字节数无关,只要 RX 引脚无数据输入、接收从"忙碌"转为"空闲"就触发——正好用来判断"一帧数据发完了"。HAL 库提供三个变体:

c // 轮询模式(超时未触发 Idle 也会结束) HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint16_t *RxLen, uint32_t Timeout); // 中断模式 HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size); // DMA 模式 HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size); // Size:一次最大接收长度(填数组长度,防越界);轮询版 RxLen 用于记录实际有效字节数

两个实用技巧:

回调代码复用:不定长接收的回调比定长多一个参数 uint16_t Size(实际接收的有效字节数);好习惯是先判断是谁触发的回调(哪个串口),再在回调中重新启动接收;
关闭 DMA 传输过半中断:避免接收未完成就触发回调,可用宏清除相关中断位:
c #define __HAL_DMA_DISABLE_IT(__HANDLE__, __INTERRUPT__) \ ((__HANDLE__)->Instance->CCR &= ~(__INTERRUPT__)) // 用法:__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); // 关闭半传输中断 // INTERRUPT 可组合:DMA_IT_TC 传输完成 / DMA_IT_HT 半传输 / DMA_IT_TE 传输错误

六、蓝牙透传与简易数据包解析
6.1 蓝牙模块
蓝牙是短距离无线通信协议,分经典蓝牙与 BLE,有主机/从机模式(单片机通常做从机,先广播,主机扫描后发起连接)。蓝牙串口透传模块(如 BT25)的作用:把复杂的蓝牙协议简化为串口透传——单片机端完全不需要关心蓝牙协议,把模块当串口用即可。

6.2 简易数据包的核心结构
串口通信只保证"字节流",不保证"消息边界"。要在字节流里切出一个个完整的指令,需要自定义数据包,核心四要素:

字段	作用
帧头 Header	标注包的起始,如固定 0xAA
数据长度 Length	有效数据字节数(变长包用)
有效数据 Payload	业务数据
校验和 Checksum	验证出错/丢包,通常把所有字节累加取和
参考例程(含状态机、防粘包半包处理):https://docs.keysking.com/docs/stm32/example/UART_BLE_COMMAND

附:DMA 原理详解("周期窃取"就是插队)
DMA = Direct Memory Access(直接存储器访问)。STM32F103 中 DMA1 分管 7 条通道、DMA2 分管 5 条;每个 DMA 控制器同一时刻只能对一条通道搬运,按通道优先级安排顺序。

工作流程三阶段:

发起请求与仲裁总线:外设发起 DMA 搬运请求 → DMA 控制器向 CPU 请求总线 → CPU 在当前总线周期结束后让出总线控制权;
自主数据传输:DMA 接管总线,按预设参数放置地址、发读写控制信号、控制字节计数,独立完成搬运;
传输完成与中断:搬完释放总线控制权,向 CPU 发中断请求,CPU 恢复执行并运行 ISR 做后续处理。
"周期窃取"机制:注意,DMA 不是"交出控制权"而是"插队"——它不像 CPU 那样"连轴转",只在每次需要搬运 1 个(或少量)数据时临时抢夺 1 个总线周期,搬完立刻主动放弃。如果 DMA 恰好踩在 CPU 也想读写内存的节拍上,仲裁器判断优先级(通常 DMA 高于 CPU),CPU 被插入 1~2 个时钟周期的等待状态,等这 1 字节搬完立即恢复——对 CPU 来说极短,普通代码几乎感觉不到停顿。

DMA 与 CPU 的关系:DMA 在总线矩阵上有专属总线主接口("主"= 可发总线请求的主设备);两者都是主设备,总线是一套共用的、接口是专用的;访问对象不同时可并行,访问对象相同时由总线矩阵仲裁先后。

通道配置八要素:

外设地址寄存器 & 存储器地址寄存器:分别写入外设寄存器地址(如串口数据寄存器)和内存变量地址;
传输方向:内存→外设(发送)、外设→内存(接收/ADC 采样)、内存→内存(数组拷贝,很多 STM32 只有 DMA2 支持);
数据宽度:字节 8bit / 半字 16bit /32bit;
数据数量:一次搬运多少"数据项";
地址自增:内存地址自增要 Enable(数据依次存入数组);外设地址自增必须 Disable——外设寄存器只有一个固定物理地址,开启自增会跑到不存在的寄存器地址乱写,导致总线错误!
循环模式:普通模式搬完停止;循环模式计数器自动重载,指针跳回数组第 0 位继续(配合 ADC 连续采样);
优先级:低///非常高,多通道请求时决定先后;
DMA 触发源:每次搬运都需要触发信号,触发信号与通道绑定(如串口 2 的发送触发只连接在 DMA 通道 7 上,只有通道 7 能响应)。
总结
GPIO 是基石:理解推挽/开漏、上拉/下拉、施密特触发器,才能正确配置引脚;
中断 解决"实时响应":回调里不能延时,业务代码只写回调层;
串口三模式:轮询(简单但卡 CPU)→ 中断(非阻塞)→ DMA(零 CPU 搬运),加上空闲中断即可实现不定长接收;
通信之上要有协议:帧头 + 长度 + 数据 + 校验和,是解析任何数据流的通用套路;
DMA 的本质是"插队":每次只抢一个总线周期,对 CPU 几乎无感。
下一篇将介绍 IIC 传感器(AHT20 温湿度)、状态机编程、OLED 驱动、ADC 多通道采集与 RTC 实时时钟,欢迎继续阅读。

如果这篇文章对你有帮助,欢迎点赞收藏;如有错误或疑问,欢迎在评论区交流。
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐