第4章 UART串口通信驱动:数据收发与中断处理
UART,说白了就是串口通信。这东西在嵌入式开发里太常见了,几乎每个板子都会用到。我刚开始做驱动时,觉得UART挺简单的——不就是发数据收数据嘛。但真正深入进去才发现,这里面的门道还真不少。
今天咱们就聊聊UART驱动的核心:数据收发和中断处理。我会结合自己踩过的坑,把这块讲透。
4.1 UART硬件基础回顾
先简单过一下UART的硬件结构。UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。它用两根线通信:TX发送,RX接收。
为什么叫异步?因为没有独立的时钟线。收发双方得约定好相同的波特率,比如115200、9600这些常见的。我见过不少新手在这上面栽跟头——两边波特率没设对,数据全是乱码。
UART的数据帧格式是这样的:
| 起始位 | 数据位(5-8位) | 校验位(可选) | 停止位(1/1.5/2位) |
|---|---|---|---|
| 0 | LSB...MSB | 奇/偶校验 | 1 |
我个人习惯用8位数据位、无校验、1位停止位,也就是常说的8N1。这个配置兼容性最好,大部分设备都支持。
4.2 轮询模式的数据收发
最简单的收发方式就是轮询。CPU不停地检查状态寄存器,看看有没有数据可读,或者能不能发数据。
发送一个字节的流程:
- 等待发送缓冲区空(TXE标志位)
- 把数据写入数据寄存器
- 等待发送完成(TC标志位)
接收一个字节的流程:
- 等待接收数据就绪(RXNE标志位)
- 从数据寄存器读取数据
代码实现大概是这样的:
// 轮询发送一个字节
void uart_send_byte(uint8_t data)
{
// 等待发送缓冲区空
while (!(UART->SR & UART_SR_TXE));
// 写入数据
UART->DR = data;
// 等待发送完成
while (!(UART->SR & UART_SR_TC));
}
// 轮询接收一个字节
uint8_t uart_recv_byte(void)
{
// 等待接收数据就绪
while (!(UART->SR & UART_SR_RXNE));
// 读取数据
return (uint8_t)(UART->DR);
}
这种写法简单直接,但有个致命问题:CPU被死死占住了。你想想看,如果波特率是9600,接收一个字节就要等大约1ms。这期间CPU啥也干不了。
4.3 中断驱动的数据收发
实际项目中,我们几乎都用中断方式。数据来了,硬件自动触发中断,CPU在中断服务程序里处理。这样CPU就能腾出手来做其他事情。
UART中断主要有这么几种:
- RXNE中断:接收数据寄存器非空,也就是收到数据了
- TXE中断:发送数据寄存器空,可以发下一个字节
- TC中断:发送完成,最后一个字节已经发出去了
- IDLE中断:总线空闲,常用于帧结束判断
- 错误中断:溢出、帧错误、校验错误等
我一般只使能RXNE中断和TC中断。TXE中断用得少,因为它的触发条件太频繁了——只要缓冲区空就触发,容易造成中断风暴。
4.3.1 接收中断处理
接收中断是最常用的。每收到一个字节,硬件就触发一次中断。我们在中断里把数据读出来,存到环形缓冲区里。
为什么要用环形缓冲区?因为中断处理要快,不能在里面做复杂操作。把数据先存起来,等主循环再去处理,这是典型的「生产者-消费者」模式。
// 环形缓冲区结构体
#define RX_BUF_SIZE 256
typedef struct {
uint8_t buffer[RX_BUF_SIZE];
volatile uint16_t head;
volatile uint16_t tail;
} ring_buffer_t;
ring_buffer_t rx_ring;
// 接收中断服务函数
void UART_IRQHandler(void)
{
uint8_t data;
// 检查是否是接收中断
if (UART->SR & UART_SR_RXNE) {
data = (uint8_t)(UART->DR);
// 存入环形缓冲区
uint16_t next_head = (rx_ring.head + 1) % RX_BUF_SIZE;
if (next_head != rx_ring.tail) { // 缓冲区没满
rx_ring.buffer[rx_ring.head] = data;
rx_ring.head = next_head;
}
// 如果缓冲区满了,数据直接丢弃
// 实际项目中可以加个溢出计数
}
}
// 从环形缓冲区读取一个字节
uint8_t ring_read_byte(uint8_t *data)
{
if (rx_ring.head == rx_ring.tail) {
return 0; // 缓冲区空
}
*data = rx_ring.buffer[rx_ring.tail];
rx_ring.tail = (rx_ring.tail + 1) % RX_BUF_SIZE;
return 1;
}
4.3.2 发送中断处理
发送中断稍微复杂一点。我们一般用「中断+状态机」的方式来实现。主循环里把要发的数据放到发送缓冲区,然后使能TXE中断。中断里逐个字节往外发,发完了就关中断。
// 发送缓冲区
#define TX_BUF_SIZE 128
typedef struct {
uint8_t buffer[TX_BUF_SIZE];
volatile uint16_t count; // 待发送字节数
volatile uint16_t index; // 当前发送位置
volatile uint8_t busy; // 发送忙标志
} tx_buffer_t;
tx_buffer_t tx_buf;
// 启动发送
void uart_send_data(uint8_t *data, uint16_t len)
{
// 等待上一次发送完成
while (tx_buf.busy);
// 拷贝数据到发送缓冲区
uint16_t copy_len = (len > TX_BUF_SIZE) ? TX_BUF_SIZE : len;
memcpy(tx_buf.buffer, data, copy_len);
tx_buf.count = copy_len;
tx_buf.index = 0;
tx_buf.busy = 1;
// 使能TXE中断,开始发送
UART->CR1 |= UART_CR1_TXEIE;
}
// 发送中断服务函数
void UART_IRQHandler(void)
{
// 发送中断
if (UART->SR & UART_SR_TXE) {
if (tx_buf.index < tx_buf.count) {
// 还有数据要发
UART->DR = tx_buf.buffer[tx_buf.index++];
} else {
// 数据发完了,关中断
UART->CR1 &= ~UART_CR1_TXEIE;
tx_buf.busy = 0;
}
}
// 接收中断
if (UART->SR & UART_SR_RXNE) {
// ... 接收处理代码
}
}
这里有个细节:为什么不用TC中断而用TXE中断?因为TXE中断在缓冲区空时立即触发,而TC中断要等到最后一个字节的停止位发送完才触发。用TXE中断,字节之间没有间隙,发送效率更高。
4.4 中断优先级与嵌套
UART中断的优先级设置也很关键。我一般把UART中断设成中等优先级,比系统滴答定时器低,但比普通外设高。
为什么?因为UART数据接收有实时性要求。如果优先级太低,被其他中断长时间阻塞,可能会丢数据。但也不能太高,否则会影响系统调度。
具体设置要看芯片的NVIC(嵌套向量中断控制器)。以STM32为例:
// 设置UART中断优先级
NVIC_SetPriority(UART_IRQn, 2); // 优先级值越小,优先级越高
NVIC_EnableIRQ(UART_IRQn);
4.5 常见问题与避坑指南
做UART驱动这么多年,踩过的坑真不少。挑几个典型的说说:
- 数据丢失:最常见的原因是中断处理太慢,下一个字节来了,上一个还没读走。解决办法是提高中断优先级,或者用DMA。
- 乱码:波特率不匹配、时钟配置错误、数据位/停止位设置不一致。先检查硬件连接,再用示波器看波形。
- 中断风暴:TXE中断使能后没及时关掉,或者接收中断里没清标志位。每次中断处理完都要检查标志位是否清除。
- 缓冲区溢出:环形缓冲区太小,或者主循环处理太慢。适当增大缓冲区,或者用流控机制。
我曾经在一个项目里,UART接收偶尔丢数据。查了三天,最后发现是中断里用了全局变量没加volatile,编译器优化后读取了错误的值。从那以后,所有中断共享的变量我都加volatile,成了强迫症。
4.6 小结
UART驱动看似简单,但要做好真不容易。轮询模式适合调试,中断模式才是产品级的做法。环形缓冲区是中断处理的好搭档,volatile关键字不能忘,中断优先级要合理设置。
下一章咱们聊聊DMA+UART的高性能收发方案。那个更刺激,能把CPU彻底解放出来。到时候见。