3. UART通信与中断驱动:从协议到实战
UART,说白了就是嵌入式世界里最常用的“串口”。我刚开始做嵌入式那会儿,第一个调通的通信外设就是它。别看它协议简单,但用好了,它就是调试利器;用不好,丢数据、卡死、中断风暴,哪个都能让你头疼一整天。
这一章,咱们就把它彻底吃透。从协议基础到HAL库的轮询/中断模式,再到环形缓冲区的实现,最后来一个完整的收发实战。嗯,跟着我的节奏来。
3.1 UART协议基础:你该知道的那些事
UART(Universal Asynchronous Receiver/Transmitter)是异步串行通信。什么叫异步?就是没有单独的时钟线,收发双方靠约定的波特率来同步。
一个完整的数据帧长这样:
| 起始位 | 数据位(5~9位) | 校验位(可选) | 停止位(1/1.5/2位) |
|---|---|---|---|
| 1位低电平 | LSB先发,MSB后发 | 奇校验/偶校验/无 | 高电平,表示帧结束 |
最常用的配置是:115200波特率、8位数据位、无校验、1位停止位,也就是我们常说的“115200 8N1”。
3.2 HAL库UART轮询模式:简单但别滥用
轮询模式,就是CPU死等。发送一个字节,等发送完成;接收一个字节,等数据到来。
代码很简单:
// 发送
HAL_UART_Transmit(&huart1, (uint8_t *)"Hello\r\n", 7, HAL_MAX_DELAY);
// 接收
uint8_t rx_byte;
HAL_UART_Receive(&huart1, &rx_byte, 1, HAL_MAX_DELAY);
但我要说句实话:轮询模式在实战中几乎不用。为什么?
- 发送时CPU空转等待,浪费算力
- 接收时阻塞,如果对方不发数据,程序就卡死了
- 无法处理不定长数据
我见过一个项目,新手用轮询收GPS数据,结果GPS模块偶尔丢一帧,程序就永远卡在接收函数里。嗯,后来改成中断就好了。
3.3 HAL库UART中断模式:这才是实战该用的
中断模式的核心思想:发完或收到数据后,硬件自动触发中断,CPU在中断服务函数里处理。这样CPU不用空等,可以干别的活。
HAL库的中断收发API:
// 中断发送(非阻塞)
HAL_UART_Transmit_IT(&huart1, tx_buf, len);
// 中断接收(非阻塞)
HAL_UART_Receive_IT(&huart1, rx_buf, len);
注意看第二个参数——len。HAL库的中断接收是定长接收:它必须收满len个字节,才会调用回调函数。这就引出一个问题:如果对方只发了3个字节,而你设了10个字节,那回调永远不会触发。
所以,实战中我很少直接用HAL_UART_Receive_IT。我更喜欢用单字节中断接收,配合环形缓冲区。
3.4 环形缓冲区:解决数据粘包和丢失的利器
环形缓冲区(Ring Buffer),说白了就是一个固定大小的数组,加上读指针和写指针。数据从写指针写入,从读指针读出。当指针到达数组末尾时,自动绕回开头。
为什么需要它?
- 中断服务函数里不能做复杂处理,只能快速存数据
- 主循环或任务可以慢慢取数据,两者互不干扰
- 解决数据粘包:先存起来,等完整的一帧到了再处理
一个简单的环形缓冲区实现:
#define RING_BUF_SIZE 256
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} ring_buffer_t;
// 写入一个字节
int ring_buf_put(ring_buffer_t *rb, uint8_t data) {
uint16_t next_head = (rb->head + 1) % RING_BUF_SIZE;
if (next_head == rb->tail) {
return -1; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return 0;
}
// 读取一个字节
int ring_buf_get(ring_buffer_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return -1; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
return 0;
}
3.5 串口收发实战:中断+环形缓冲区
好了,理论说完了,咱们来实战。目标是:用中断方式接收不定长数据,存入环形缓冲区,主循环里解析处理。
第一步:初始化UART和环形缓冲区
ring_buffer_t uart_rb;
uint8_t rx_byte;
void uart_init(void) {
// MX_USART1_UART_Init() 由CubeMX生成
// 启动单字节中断接收
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
第二步:中断回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (huart->Instance == USART1) {
// 把收到的字节存入环形缓冲区
ring_buf_put(&uart_rb, rx_byte);
// 重新启动单字节接收
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
}
注意:回调里必须重新调用HAL_UART_Receive_IT,否则只收一个字节就停了。这是HAL库的设计——每次中断接收完成后,接收功能就关闭了。
第三步:主循环里处理数据
void main_loop(void) {
uint8_t data;
while (1) {
if (ring_buf_get(&uart_rb, &data) == 0) {
// 处理收到的数据
process_byte(data);
}
// 其他任务...
}
}
这个架构的好处是:中断只负责存,主循环负责处理。中断服务函数执行时间极短(微秒级),不会影响系统实时性。
3.6 知识结构图:UART中断驱动架构
下面这张图展示了整个架构的流程和数据流向:
3.7 常见问题与避坑指南
做UART驱动开发,有几个坑我几乎每次都会遇到:
- 中断优先级设置不当:如果UART中断优先级太低,可能被其他高优先级中断长时间打断,导致数据丢失。我一般把UART中断设为中等优先级(如NVIC优先级分组4,抢占优先级2)。
- 缓冲区溢出:主循环处理速度跟不上中断接收速度,缓冲区满了就丢数据。解决办法:增大缓冲区,或者用DMA(下一章会讲)。
- 临界区保护:如果主循环和中断同时访问环形缓冲区,可能读到脏数据。我的做法:在读取时关中断,或者用原子操作。
huart->Instance。从那以后,我每个串口的回调都加这个判断,成了肌肉记忆。
3.8 总结:记住这三句话
好了,这一章的内容就这些。总结一下核心要点:
- 轮询模式只适合调试,实战必须用中断或DMA
- 环形缓冲区是中断驱动架构的基石,中断只存、主循环只取
- HAL库的中断接收是定长的,实战中要用单字节接收+环形缓冲区