4、UART驱动设计:轮询模式与中断模式、环形缓冲区实现、波特率配置
UART,串口通信,嵌入式开发里最基础也最常用的外设之一。说实话,我见过不少工程师写了几年代码,UART驱动还是停留在「能发能收就行」的阶段。但实际项目中,UART驱动设计的好坏,直接决定了系统的稳定性和响应速度。
今天我们就来聊聊UART驱动的三个核心话题:轮询与中断模式怎么选、环形缓冲区怎么实现、波特率配置有哪些坑。嗯,这些都是我踩过坑的地方。
4.1 轮询模式 vs 中断模式
先说说轮询模式。说白了,就是CPU不停地去查UART的接收寄存器有没有数据。代码写起来简单,但代价很大——CPU被死死占住了。
轮询模式的典型场景:系统初始化阶段、调试信息输出、低功耗要求不高的简单应用。
// 轮询发送一个字节
void uart_poll_send(uint8_t data) {
// 等待发送缓冲区空
while (!(UART->SR & UART_SR_TXE));
UART->DR = data;
}
// 轮询接收一个字节
uint8_t uart_poll_recv(void) {
// 等待接收数据就绪
while (!(UART->SR & UART_SR_RXNE));
return (uint8_t)(UART->DR & 0xFF);
}
你看,两个while循环就把CPU锁死了。如果系统里还有其他任务要跑,这种写法就很不合适。
中断模式就不一样了。数据来了,硬件自动触发中断,CPU在中断服务程序里处理数据。处理完继续干别的事。这样CPU利用率高得多。
我个人习惯:调试阶段先用轮询模式,快速验证硬件没问题。正式版本再切到中断模式。这样定位问题更快。
// UART中断服务程序(简化版)
void UART_IRQHandler(void) {
uint8_t data;
// 检查接收中断标志
if (UART->SR & UART_SR_RXNE) {
data = (uint8_t)(UART->DR & 0xFF);
// 把数据放入环形缓冲区
ringbuf_put(&rx_ringbuf, data);
}
// 检查发送完成中断(如果需要)
if (UART->SR & UART_SR_TC) {
// 可以在这里处理发送完成后的逻辑
}
}
中断模式也有代价——中断服务程序要尽量短。你想想看,如果中断里做太多事,会影响其他中断的响应。所以,中断里通常只做数据搬运,真正的处理逻辑放在主循环里。
我曾经踩过的坑:在中断服务程序里调用了printf(),结果系统直接卡死。因为printf内部可能用了轮询方式等待发送完成,而发送完成中断又被自己占着...死锁了。所以,中断里千万别做复杂操作。
4.2 环形缓冲区实现
为什么要用环形缓冲区?说白了,就是解决「生产者-消费者」问题。中断服务程序是生产者,往缓冲区里放数据。主循环是消费者,从缓冲区里取数据。两者速度不匹配,就需要一个缓冲区来解耦。
环形缓冲区,也叫循环队列。它用固定大小的数组,通过头尾指针来管理数据。数据写满了就覆盖旧数据,或者阻塞等待。实现起来不复杂,但细节要注意。
// 环形缓冲区结构体
typedef struct {
uint8_t *buffer; // 数据缓冲区
uint32_t size; // 缓冲区大小
volatile uint32_t head; // 写指针
volatile uint32_t tail; // 读指针
} ringbuf_t;
// 初始化
void ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint32_t size) {
rb->buffer = buf;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节
int ringbuf_put(ringbuf_t *rb, uint8_t data) {
uint32_t next_head = (rb->head + 1) % rb->size;
// 缓冲区满了?
if (next_head == rb->tail) {
return -1; // 满了,返回错误
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return 0;
}
// 读取一个字节
int ringbuf_get(ringbuf_t *rb, uint8_t *data) {
// 缓冲区空了?
if (rb->head == rb->tail) {
return -1; // 空了
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
return 0;
}
这里有个关键点:head和tail用了volatile关键字。为什么?因为中断和主循环会同时访问这两个变量。不加volatile,编译器可能优化掉一些读写操作,导致数据不一致。
避坑指南:环形缓冲区的大小要选好。太小了容易丢数据,太大了浪费内存。我一般根据波特率和数据量来估算。比如115200波特率,每秒最多约11.5KB数据。如果主循环每10ms处理一次,缓冲区至少要有115字节。实际项目中我通常会留2-3倍余量。
还有一个细节:判断缓冲区满和空。上面的代码用了一个技巧——head追上tail表示空,tail追上head表示满。但这样会浪费一个字节的空间(因为满的时候不能写)。你也可以用计数方式,但要注意原子操作。
4.3 波特率配置
波特率配置,看起来就是往寄存器里写个值。但实际项目中,波特率不准会导致通信失败,而且问题很难排查。
波特率的计算公式:
波特率 = 时钟频率 / (16 * 分频系数)
分频系数就是我们要配置的值。不同的芯片,时钟源可能不同。有的用APB时钟,有的用专门的UART时钟。配置前一定要确认清楚。
| 时钟频率 | 目标波特率 | 理论分频系数 | 实际分频系数 | 实际波特率 | 误差 |
|---|---|---|---|---|---|
| 16 MHz | 115200 | 8.68 | 9 | 111111 | 3.5% |
| 16 MHz | 9600 | 104.17 | 104 | 9615 | 0.16% |
| 8 MHz | 115200 | 4.34 | 4 | 125000 | 8.5% |
你看,8MHz时钟下跑115200波特率,误差高达8.5%。这种误差下,通信基本不可靠。我遇到过这种情况,调试了整整一天,最后发现是时钟源选错了。
我曾经踩过的坑:某次项目用了内部RC振荡器,标称16MHz,实际只有15.8MHz。波特率配置按16MHz算,结果误差超过了5%。通信时好时坏,最后用示波器一量才发现问题。所以,波特率配置前,一定要用示波器或逻辑分析仪确认实际时钟频率。
配置代码示例:
// 波特率配置函数
void uart_set_baudrate(uint32_t periph_clock, uint32_t baudrate) {
uint32_t divisor;
uint32_t remainder;
uint32_t fraction;
// 计算分频系数
divisor = periph_clock / (16 * baudrate);
remainder = periph_clock % (16 * baudrate);
// 计算小数部分(如果硬件支持)
fraction = (remainder * 16) / (16 * baudrate);
// 写入寄存器
UART->BRR = (divisor << 4) | (fraction & 0x0F);
// 实际波特率验证
uint32_t actual_baud = periph_clock / (16 * divisor + fraction);
uint32_t error = (actual_baud > baudrate) ?
(actual_baud - baudrate) : (baudrate - actual_baud);
uint32_t error_percent = (error * 100) / baudrate;
if (error_percent > 3) {
// 误差超过3%,建议报警
// 实际项目中可以打印警告信息
}
}
这里我加了一个误差检查。如果误差超过3%,就报警。为什么是3%?因为UART通信的容错能力一般在3-5%之间。超过这个范围,数据就容易出错。
我建议:配置完波特率后,用示波器量一下TX引脚的波形。看一个bit的宽度是否准确。比如115200波特率,一个bit应该是8.68微秒。这个方法比任何计算都靠谱。
4.4 综合设计建议
最后,总结一下UART驱动设计的几个要点:
- 模式选择:调试用轮询,正式用中断。如果数据量特别大,可以考虑DMA模式。
- 缓冲区大小:根据波特率和处理周期计算,留2-3倍余量。
- 波特率精度:误差控制在3%以内,用示波器验证。
- 中断服务程序:只做数据搬运,不做复杂处理。
- 临界区保护:中断和主循环共享的变量,用volatile关键字,必要时关中断保护。
嗯,UART驱动看起来简单,但要做好,细节真不少。希望这些经验能帮你少走弯路。下一章我们聊聊SPI驱动设计,那个又有新的坑等着我们。