第4章 UART驱动开发:从协议到RTOS框架
UART,说白了就是串口。做嵌入式的人,没人离得开它。我入行那会儿,调试全靠串口打印,连个像样的调试器都没有。现在虽然工具多了,但UART依然是驱动开发的基础课——你搞不定它,后面的I2C、SPI、CAN都别想碰。
这一章,咱们把UART从协议到驱动,从轮询到DMA,再到RTOS下的框架设计,一条线捋清楚。
4.1 UART协议基础——你得知道它在说什么
UART全称是Universal Asynchronous Receiver/Transmitter。注意"异步"这两个字——收发双方没有独立的时钟线,全靠约定的波特率来对齐。
核心要点:UART是异步串行通信,一帧数据包含起始位、数据位、校验位、停止位。双方必须约定完全相同的帧格式和波特率,否则收到的全是乱码。
一帧UART数据长这样:
| 起始位 | 数据位(5~9位) | 校验位(可选) | 停止位(1/1.5/2位) |
|---|---|---|---|
| 1位,低电平 | LSB先发,MSB后发 | 奇校验/偶校验/无 | 高电平,表示帧结束 |
我遇到过最坑的事:一个同事把数据位配成了7位,但上位机用的是8位。结果每次收到数据都少一位,后面全错位。查了整整一下午,最后发现是协议没对齐。嗯,这种低级错误,犯过一次就再也不会忘了。
我的习惯:项目初期就把UART参数写成一个结构体,放在头文件里。8N1(8数据位、无校验、1停止位)是最通用的配置,除非有特殊要求,否则别乱改。
4.2 轮询模式收发——简单但别滥用
轮询模式,就是CPU死等。发送时,等发送寄存器空;接收时,等接收寄存器非空。代码写起来最直接,但效率最低。
// UART轮询发送一个字节
void uart_poll_send(uint8_t data) {
// 等待发送缓冲区空
while (!(UART->SR & UART_SR_TXE));
// 写入数据
UART->DR = data;
}
// UART轮询接收一个字节
uint8_t uart_poll_recv(void) {
// 等待接收数据就绪
while (!(UART->SR & UART_SR_RXNE));
// 读取数据
return (uint8_t)(UART->DR & 0xFF);
}
这段代码,初学者都能看懂。但我要说的是——轮询模式只适合两个场景:
- 系统初始化阶段:RTOS还没跑起来,打印个启动信息
- 调试阶段:临时打个log,不需要考虑性能
注意:轮询模式会阻塞CPU。如果你在RTOS的任务里用轮询收数据,整个任务都会被卡住。我曾经见过有人用轮询收GPS数据,结果任务优先级设低了,数据全丢了。别这么干。
4.3 中断模式收发——RTOS下的标配
中断模式,说白了就是让硬件帮你盯着。数据来了,硬件触发中断,CPU在中断服务函数里处理。这样CPU不用死等,可以干别的事。
中断模式的关键点有两个:
- 中断服务函数要快:只做最必要的事,比如把数据扔进环形缓冲区
- 数据保护:中断和任务共享的数据,必须加临界区保护
// UART中断接收服务函数(简化版)
void UART_IRQHandler(void) {
uint8_t data;
if (UART->SR & UART_SR_RXNE) {
data = (uint8_t)(UART->DR & 0xFF);
// 把数据放入环形缓冲区
ringbuf_push(&rx_ringbuf, data);
}
// 处理溢出错误
if (UART->SR & UART_SR_ORE) {
// 读DR清标志
(void)UART->DR;
}
}
我个人的习惯是:中断里只做数据搬运,不做协议解析。协议解析放到任务里做,这样不会拖慢中断响应。
避坑指南:我曾经在中断里调用了printf,结果printf内部又用了UART发送,导致递归中断,系统直接挂掉。记住——中断服务函数里,别调用任何可能触发同一中断的函数。
4.4 DMA模式收发——高性能场景的利器
DMA,就是让数据自己走,不经过CPU。CPU只需要告诉DMA控制器:"从内存A搬N个字节到UART数据寄存器",然后就可以去干别的事了。搬完了,DMA给你个中断通知一声。
DMA模式适合什么场景?
- 大数据量收发:比如通过串口升级固件,一次传几KB甚至几MB
- 高波特率通信:921600bps以上,CPU逐字节处理根本扛不住
- 实时性要求高:CPU不能被UART占用太多时间
DMA配置的几个关键参数:
| 参数 | 说明 | 我的建议 |
|---|---|---|
| 传输方向 | 内存到外设(发送)/ 外设到内存(接收) | 明确区分,别搞反 |
| 数据宽度 | 字节/半字/字 | UART一般用字节 |
| 循环模式 | 是否自动重载 | 接收常用,发送少用 |
| 中断触发 | 传输完成/半完成/错误 | 至少开传输完成中断 |
// DMA发送配置示例(伪代码)
void uart_dma_send(uint8_t *buf, uint32_t len) {
// 配置DMA源地址(内存)
DMA->CMAR = (uint32_t)buf;
// 配置DMA目标地址(UART数据寄存器)
DMA->CPAR = (uint32_t)&UART->DR;
// 配置传输长度
DMA->CNDTR = len;
// 使能DMA通道
DMA->CCR |= DMA_CCR_EN;
// 使能UART的DMA发送请求
UART->CR3 |= USART_CR3_DMAT;
}
注意:DMA接收有个坑——你不知道数据什么时候来,来多少。所以DMA接收一般配合空闲中断(IDLE interrupt)使用:DMA一直收,收到空闲中断时,就知道一帧数据结束了。我当年做4G模块驱动时,就靠这个方案收AT指令的响应。
4.5 RTOS下串口驱动框架设计
好了,前面都是基础。现在咱们聊聊RTOS下的串口驱动该怎么设计。这不是简单的把轮询改成中断就完事了,而是要从架构层面考虑。
一个成熟的RTOS串口驱动框架,通常包含这几层:
这个框架,说白了就是分层解耦。每一层只关心自己的事,不越界。我见过很多新手写驱动,应用层直接操作寄存器,结果换个芯片平台,代码全废了。
具体到RTOS下的实现,有几个关键点:
- 使用信号量或消息队列:中断收到数据后,释放信号量或发消息给等待的任务
- 支持超时机制:接收不能无限等,要有超时返回
- 缓冲区管理:环形缓冲区是标配,大小根据波特率和数据量估算
- 多任务安全:多个任务可能同时操作同一个串口,需要互斥保护
// RTOS串口驱动接收接口示例
int uart_read(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms) {
uint32_t received = 0;
// 获取互斥锁,防止多任务冲突
if (xSemaphoreTake(uart_dev[port].mutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) {
return -1; // 超时未获取到锁
}
while (received < len) {
// 从环形缓冲区取数据
if (ringbuf_pop(&uart_dev[port].rx_ringbuf, &buf[received])) {
received++;
} else {
// 缓冲区空,等待信号量
if (xSemaphoreTake(uart_dev[port].rx_sem, pdMS_TO_TICKS(10)) != pdTRUE) {
// 超时,返回已接收的数据量
xSemaphoreGive(uart_dev[port].mutex);
return received;
}
}
}
xSemaphoreGive(uart_dev[port].mutex);
return received;
}
我的经验:环形缓冲区的大小,我一般按波特率来算。比如115200bps,一秒钟最多约11.5KB数据。如果系统能在100ms内处理完,缓冲区设2KB就够了。但如果你做的是4G模块,数据可能突发,缓冲区至少给4KB。宁可多占点内存,也别丢数据。
4.6 三种收发模式的对比与选型
最后,咱们把三种模式放在一起对比一下,方便你根据实际场景选型。
| 模式 | CPU占用 | 实时性 | 适合场景 | 代码复杂度 |
|---|---|---|---|---|
| 轮询模式 | 高(一直占用) | 差(阻塞) | 初始化、调试打印 | 低 |
| 中断模式 | 中(仅触发时占用) | 好(非阻塞) | 常规通信、命令交互 | 中 |
| DMA模式 | 低(几乎不占用) | 最好(后台传输) | 大数据量、高波特率 | 高 |
选型建议:
- 项目初期调试,用轮询,简单直接
- 产品正式版,用中断模式,性价比最高
- 遇到大数据量或高波特率,上DMA,别犹豫
最后提醒一句:不管用哪种模式,别忘了处理错误。UART常见的错误有溢出错误(ORE)、帧错误(FE)、校验错误(PE)。我见过有人只开了接收中断,没开错误中断,结果溢出后UART直接罢工,数据全丢了。错误处理,一定要做。