1. 为什么UART值得单独拎出来讲
搞嵌入式的人都有一个共识:能把UART玩明白,基本就摸到了驱动开发的门槛。这不是夸张。UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)看起来简单——不就是收发几个字节嘛——但真正在项目里踩过坑的人都知道,从时钟配置到中断处理,从环形缓冲区到DMA搬运,每一个环节都藏着让人熬夜的细节。
我在带新人的时候经常说一句话:UART是嵌入式驱动开发的“hello world”,但也是最能暴露基本功的模块。你去看一个人的UART驱动写得怎么样,基本就能判断他对中断、DMA、并发、缓冲区管理的理解到了什么层次。
这篇内容适合谁看?如果你刚开始接触嵌入式Linux驱动或者裸机开发,正在啃UART这块硬骨头,那这篇就是写给你的。如果你已经写过几个UART驱动,但总觉得稳定性差、丢数据、偶尔卡死,那这篇也能帮你把思路理清楚。我会从整体设计思路讲到具体的寄存器操作,从裸机轮询讲到中断+DMA的完整方案,尽量把每个“为什么这么做”都讲透。
先明确一个范围:这里讨论的UART驱动开发,覆盖裸机环境(寄存器级操作)和嵌入式Linux环境(TTY驱动框架)两条线。两条线的底层原理是相通的,但工程实现差异很大,我会分别展开。
2. UART驱动的整体设计思路拆解
2.1 先搞清楚UART到底在做什么
很多人写驱动的时候是“照着例程改”,改完能跑就完事了。但如果你不理解UART的物理层和数据链路层在干什么,遇到问题就无从下手。
UART的本质是串行、异步、全双工的通信方式。拆开来看:
- 串行:数据一位一位地发,一根TX线一根RX线就够了。这跟SPI、I2C的同步串行不同,UART不需要时钟线。
- 异步:没有时钟线意味着收发双方要靠约定的波特率来同步。这就引出一个关键问题——波特率误差。一般要求收发双方的波特率误差控制在2%以内,超过这个值就可能出现采样错误。
- 全双工:TX和RX独立工作,可以同时收发。
一帧UART数据的结构是这样的:起始位(1位低电平)+ 数据位(5-9位,通常8位)+ 校验位(可选)+ 停止位(1或2位高电平)。接收方检测到起始位的下降沿后,在每一位的中间时刻采样,这就是所谓的“中点采样”策略。
注意:很多初学者会忽略起始位检测的噪声过滤问题。实际工程中,接收引脚上可能有毛刺,如果不对起始位做多次采样确认,很容易误触发接收。
2.2 驱动架构怎么选:轮询、中断还是DMA
这是UART驱动设计的第一个关键决策。三种方式各有适用场景:
| 方式 | CPU占用 | 吞吐量 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 极高 | 低 | 最简单 | 调试输出、极低速率通信 |
| 中断 | 中等 | 中等 | 中等 | 常规数据通信,速率≤115200bps |
| 中断+DMA | 低 | 高 | 较高 | 高速通信、大批量数据传输 |
我的一般建议是:调试串口用轮询就够了,产品级通信至少上中断,数据量大的必须上DMA。为什么?因为轮询方式下CPU会一直等在那里,什么也干不了。中断方式虽然解放了CPU,但每个字节都触发一次中断,在高波特率下中断开销非常可观。DMA则是让硬件自己去搬运数据,CPU只在半满/全满的时候处理一次。
2.3 环形缓冲区:UART驱动的灵魂
不管你用中断还是DMA,环形缓冲区(Ring Buffer)几乎是标配。为什么?因为UART的收发是异步的——数据什么时候来你不知道,应用程序什么时候来读你也不知道。如果没有缓冲区,要么丢数据,要么阻塞。
环形缓冲区的核心思想很简单:一块固定大小的内存,读指针和写指针各自移动,写到末尾就绕回开头。但实现上有几个关键点:
- 读写指针的原子性:在中断里写指针,在应用里读指针,如果是指针操作不是原子的,就会出问题。在32位MCU上,对齐的32位读写通常是原子的,但为了保险,该加锁还是要加锁。
- 满/空判断:写指针追上读指针表示满,读指针追上写指针表示空。但这样会浪费一个字节的空间。另一种做法是额外维护一个count变量。
- 溢出策略:缓冲区满了怎么办?丢弃新数据还是覆盖旧数据?这个要根据业务场景决定。调试日志通常丢新数据,控制命令通常不能丢。
typedef struct { uint8_t *buffer; uint32_t size; volatile uint32_t head; // 写指针 volatile uint32_t tail; // 读指针 } ring_buffer_t; static inline uint32_t rb_used(ring_buffer_t *rb) { return (rb->head - rb->tail) & (rb->size - 1); } static inline uint32_t rb_free(ring_buffer_t *rb) { return rb->size - 1 - rb_used(rb); }上面这段代码用了位掩码来做环形回绕,前提是缓冲区大小必须是2的幂。这是一个非常实用的技巧,比取模运算快得多。
3. 裸机UART驱动的核心细节与实操要点
3.1 时钟与波特率配置:最容易翻车的地方
波特率配置是UART驱动里第一个容易出问题的地方。很多人算出来的波特率寄存器值不对,导致通信乱码或者完全不通。
先看公式。以常见的MCU为例,波特率计算公式通常是:
BaudRate = f_clk / (16 × USARTDIV)
其中f_clk是UART模块的时钟源频率,USARTDIV是一个分频系数,分为整数部分和小数部分。比如f_clk = 72MHz,目标波特率115200:
USARTDIV = 72000000 / (16 × 115200) = 39.0625
整数部分 = 39,小数部分 = 0.0625 × 16 = 1
所以BRR寄存器应该写入 (39 << 4) | 1 = 0x271。
但这里有个坑:实际波特率和理论波特率之间会有误差。误差计算公式是:
误差 = (实际波特率 - 目标波特率) / 目标波特率 × 100%
用上面的值反算:实际波特率 = 72000000 / (16 × 39.0625) = 115200,误差为0。但如果你用的时钟频率除不尽,比如f_clk = 25MHz,目标115200:
USARTDIV = 25000000 / (16 × 115200) = 13.563
整数部分 = 13,小数部分 = 0.563 × 16 = 9.01,取9
实际USARTDIV = 13 + 9/16 = 13.5625
实际波特率 = 25000000 / (16 × 13.5625) = 115207
误差 = (115207 - 115200) / 115200 = 0.006%,完全没问题。
但如果你的时钟源选得不好,误差可能超过2%。比如f_clk = 8MHz,目标115200:
USARTDIV = 8000000 / (16 × 115200) = 4.34
整数部分 = 4,小数部分 = 0.34 × 16 = 5.44,取5
实际USARTDIV = 4 + 5/16 = 4.3125
实际波特率 = 8000000 / (16 × 4.3125) = 115942
误差 = (115942 - 115200) / 115200 = 0.64%,还行。
但如果目标波特率是921600呢?f_clk = 8MHz:
USARTDIV = 8000000 / (16 × 921600) = 0.542
整数部分 = 0,这就不行了。所以高波特率下必须保证时钟源频率足够高。
实操心得:在选时钟源的时候,先算一下目标波特率下的USARTDIV是否大于1,小数部分的精度是否够。如果时钟源可以选,尽量选能整除的。比如要跑115200,选36.864MHz的时钟源就非常理想,因为36.864M / (16 × 115200) = 20,整数,零误差。
3.2 GPIO引脚配置:别小看这两根线
UART的TX和RX引脚配置看起来简单,但有几个细节容易忽略:
- TX引脚:配置为复用推挽输出,高速模式。推挽是为了有足够的驱动能力,高速是为了保证边沿陡峭。
- RX引脚:配置为浮空输入或者上拉输入。如果外部已经有上拉电阻,就配浮空;如果没有,建议配内部上拉,防止悬空时收到噪声。
- 复用功能映射:很多MCU的引脚复用功能需要额外配置AFR寄存器,别忘了这一步。
我在一个项目里遇到过一个问题:UART发送正常但接收偶尔丢数据。查了半天发现是RX引脚配置成了推挽输出,导致外部信号和MCU输出打架。改成浮空输入后问题消失。这种低级错误在紧张的项目周期里真的很容易犯。
3.3 中断处理:RXNE和TXE的正确用法
中断方式下,最核心的两个标志位是RXNE(Receive Data Register Not Empty)和TXE(Transmit Data Register Empty)。
接收中断的处理逻辑:
void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t data = USART1->DR; // 读DR自动清除RXNE ring_buffer_write(&rx_rb, data); } if (USART1->SR & USART_SR_ORE) { // 溢出错误处理 (void)USART1->DR; // 读DR清除ORE (void)USART1->SR; overrun_count++; } }这里有几个关键点:
- 读DR寄存器会自动清除RXNE标志,不需要手动清除。但如果你先读SR再读DR,效果一样。
- ORE(Overrun Error)必须处理。如果RXNE还没被清除,新数据又来了,就会触发ORE。不处理的话,后续数据全部丢失。
- 中断里尽量少做事。把数据丢进环形缓冲区就赶紧退出,解析和处理放到主循环里做。
发送中断的处理逻辑:
void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_TXE) { if (tx_rb_used(&tx_rb) > 0) { USART1->DR = ring_buffer_read(&tx_rb); } else { // 没有数据了,关闭TXE中断 USART1->CR1 &= ~USART_CR1_TXEIE; } } }发送中断的典型模式是:有数据要发的时候才使能TXE中断,发完了就关掉。这样可以避免TXE中断一直触发,白白消耗CPU。
3.4 错误处理:帧错误、噪声错误、校验错误
UART通信中可能出现的错误类型:
| 错误类型 | 标志位 | 原因 | 处理方式 |
|---|---|---|---|
| 溢出错误 | ORE | 数据未及时读取 | 读DR和SR清除,记录统计 |
| 噪声错误 | NE | 信号上有毛刺 | 读DR清除,可考虑丢弃该帧 |
| 帧错误 | FE | 停止位不是高电平 | 读DR清除,通常丢弃该帧 |
| 校验错误 | PE | 校验位不匹配 | 读DR清除,丢弃该帧 |
这些错误标志的清除顺序有讲究:必须先读SR再读DR,否则标志可能清除不干净。这是很多MCU参考手册里明确写的,但实际写代码时很容易忽略。
4. 嵌入式Linux下UART/TTY驱动的实现要点
4.1 TTY驱动框架:三层结构
嵌入式Linux下的UART驱动走的是TTY框架,分为三层:
- TTY核心层:提供统一的字符设备接口,处理线路规程(line discipline)。
- TTY线路规程层:处理终端特殊字符(如Ctrl+C)、回显等。
- TTY驱动层:也就是我们写的UART驱动,负责和硬件打交道。
写一个UART驱动,核心是实现struct uart_ops里的回调函数:
static const struct uart_ops my_uart_ops = { .tx_empty = my_tx_empty, .set_mctrl = my_set_mctrl, .get_mctrl = my_get_mctrl, .stop_tx = my_stop_tx, .start_tx = my_start_tx, .stop_rx = my_stop_rx, .startup = my_startup, .shutdown = my_shutdown, .set_termios = my_set_termios, .type = my_type, .config_port = my_config_port, .release_port = my_release_port, .request_port = my_request_port, };其中set_termios是最复杂的,因为要处理波特率、数据位、停止位、校验位、流控等各种参数的变更。
4.2 中断处理与DMA的配合
在Linux下,UART驱动的中断处理通常分为两部分:
- 上半部(硬中断):读取硬件状态,把数据从FIFO搬到TTY的缓冲区,或者启动DMA。
- 下半部(软中断/工作队列):把数据推送到线路规程层,唤醒等待的进程。
DMA的使用在Linux UART驱动里非常普遍。典型的做法是:
- 接收方向:配置DMA循环缓冲区,DMA自动把UART FIFO的数据搬到内存。驱动定期检查DMA的写指针位置,把新数据推送到TTY层。
- 发送方向:驱动把数据准备好后启动DMA,DMA完成后触发中断,驱动再启动下一批。
注意:DMA和CPU之间的缓存一致性问题。如果DMA写内存后CPU直接读,可能读到旧数据。需要用
dma_sync_single_for_cpu()之类的API做同步。这个问题在ARM平台上尤其常见,因为ARM的缓存不是硬件一致的。
4.3 流控:硬件流控和软件流控
流控是UART通信中防止数据丢失的重要机制。两种方式:
- 硬件流控(RTS/CTS):需要额外的两根线。发送方在发送前检查CTS是否有效,接收方在缓冲区快满时拉高RTS。这种方式响应快,不占用数据带宽。
- 软件流控(XON/XOFF):用特殊字符XON(0x11)和XOFF(0x13)来控制。不需要额外线缆,但会占用数据带宽,而且如果数据里恰好包含这两个字符就会出问题。
在Linux下,硬件流控通过termios的CRTSCTS标志使能。但要注意,不是所有UART控制器都支持硬件流控,用之前先查芯片手册。
4.4 一个容易忽略的问题:console和驱动的关系
在Linux系统里,UART经常被用作console输出。这时候有个坑:console的写操作是在中断上下文里进行的,如果你的驱动在发送时睡眠(比如等待DMA完成),就会出问题。
解决方案是给console提供一个轮询式的写函数(console_write),不依赖中断和DMA,直接往FIFO里塞数据。虽然效率低,但保证了在系统崩溃时还能输出日志。
5. 常见问题与排查技巧实录
5.1 通信乱码:从波特率查起
乱码是UART最常见的问题,没有之一。排查顺序:
- 确认双方波特率一致。这是最基本但也最容易被忽略的。我遇到过好几次是对方设备默认波特率不是115200,而是9600。
- 检查时钟源配置。如果MCU的UART时钟源选错了,算出来的波特率就是错的。用示波器量一下TX引脚上的波形,测一下最小脉冲宽度,反推波特率。
- 检查数据位、停止位、校验位配置。双方必须完全一致。
- 检查电平匹配。3.3V的MCU和5V的设备直连,可能识别不了。需要电平转换。
5.2 丢数据:缓冲区大小和中断优先级
丢数据的排查思路:
- 缓冲区太小:如果应用层读取不及时,环形缓冲区满了就会丢数据。解决办法是加大缓冲区,或者提高应用层的读取频率。
- 中断优先级太低:如果UART中断被其他高优先级中断打断太久,RXNE来不及处理就会ORE。解决办法是适当提高UART中断优先级。
- 中断里处理太慢:如果在中断里做数据解析、打印日志等耗时操作,就会导致后续数据丢失。记住原则:中断里只做数据搬运,不做数据处理。
5.3 发送卡死:TXE标志和中断使能
发送卡死通常是因为TXE中断没有正确使能或关闭。常见场景:
- 发送完成后忘记关闭TXE中断,导致中断一直触发,系统卡在中断里。
- 发送过程中TXE中断被意外关闭,数据发了一半就停了。
我的做法是:用一个状态变量记录发送状态,在中断里根据状态决定是否继续发送或关闭中断。这样逻辑清晰,不容易出错。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无数据 | TX/RX接反、引脚未配置、时钟未使能 | 示波器量TX引脚 |
| 乱码 | 波特率不匹配、时钟源错误 | 量波形测波特率 |
| 偶尔丢数据 | 缓冲区溢出、中断延迟 | 加大缓冲区、提高中断优先级 |
| 发送卡死 | TXE中断未关闭、DMA未完成 | 检查中断状态和DMA配置 |
| 接收卡死 | ORE未处理、RXNE中断未使能 | 检查错误标志和中断使能 |
| 高波特率下误码 | 时钟源频率不够、信号完整性差 | 换时钟源、缩短走线 |
6. 几个实战中总结的避坑经验
6.1 初始化顺序很重要
UART的初始化顺序应该是:使能时钟 → 配置GPIO → 配置UART参数 → 使能UART → 使能中断。这个顺序不能乱。我见过有人在使能UART之后才配置GPIO,结果一开始发送的数据全是乱的。
6.2 环形缓冲区大小要留余量
环形缓冲区的大小不要刚好等于一帧数据的长度。比如你的协议一帧是64字节,缓冲区至少给256字节。为什么?因为中断可能连续触发,应用层可能来不及读。留余量就是留容错空间。
6.3 调试串口和通信串口要分开
如果条件允许,调试输出用一个UART,业务通信用另一个UART。混在一起的话,调试信息会干扰业务数据,排查问题的时候非常痛苦。我在一个项目里因为只有一个串口,调试信息和Modbus数据混在一起,查了一整天才发现是调试打印把Modbus帧打断了。
6.4 用DMA的时候注意对齐
DMA传输对地址对齐有要求。有些DMA控制器要求源地址和目的地址都按字对齐。如果缓冲区没有对齐,DMA可能传输错误的数据或者效率极低。用__attribute__((aligned(4)))来保证缓冲区对齐。
6.5 低功耗场景下的UART处理
在低功耗应用里,UART是个耗电大户。如果系统要进低功耗模式,需要:
- 关闭UART时钟
- 把TX引脚配置为低电平输出(避免漏电流)
- 把RX引脚配置为中断唤醒源(如果需要)
但要注意,唤醒后UART需要重新初始化,因为时钟关闭后寄存器状态可能丢失。
6.6 关于波特率自适应的实现思路
有些场景下需要自动检测对方的波特率。实现思路是:用定时器捕获起始位的下降沿,然后测量一个位的宽度。具体做法是:
- 配置RX引脚为边沿触发中断。
- 检测到下降沿后,启动定时器。
- 在定时器中断里以已知的时间间隔采样RX引脚电平,重建出波特率。
这个方法在STM32的参考手册里有详细描述,核心是利用起始位的下降沿和后续数据位的跳变来推算位宽。实现起来不算复杂,但需要仔细处理噪声和误差累积。
7. 从驱动到应用:数据收发的完整链路
7.1 发送流程的完整梳理
一个完整的发送流程是这样的:
- 应用层调用发送函数,把数据写入发送环形缓冲区。
- 如果发送中断未使能,使能TXE中断。
- TXE中断触发,从环形缓冲区取一个字节写入DR寄存器。
- 如果缓冲区空了,关闭TXE中断。
- 等待TC(Transmission Complete)标志置位,确认最后一个字节发送完成。
这里有个细节:TC标志和TXE标志的区别。TXE表示数据寄存器空了,可以写下一个字节;TC表示移位寄存器也空了,整个帧发送完毕。在需要精确控制发送完成时间的场景(比如RS485的方向切换),必须等TC标志,不能只等TXE。
7.2 接收流程的完整梳理
接收流程:
- RXNE中断触发,从DR读取数据。
- 把数据写入接收环形缓冲区。
- 检查ORE标志,如果有溢出则记录并处理。
- 应用层从环形缓冲区读取数据,进行协议解析。
如果用了空闲中断(IDLE),可以在总线空闲时触发一次中断,告诉应用层“一帧数据接收完毕”。这在Modbus等基于帧的协议里非常有用,可以避免用超时来判断帧结束。
7.3 中断优先级和临界区保护
在RTOS环境下,UART中断的优先级配置很关键。如果UART中断优先级低于configMAX_SYSCALL_INTERRUPT_PRIORITY,就不能在中断里调用RTOS的API(如xQueueSendFromISR)。这个坑我踩过,现象是系统偶尔死机,查了很久才发现是中断优先级配置错了。
临界区保护方面,环形缓冲区的读写指针操作如果是32位的,在32位MCU上通常是原子的。但如果缓冲区大小超过4GB(不可能),或者指针是16位的,就需要加临界区保护。保守的做法是:在操作读写指针前后关中断,虽然损失一点性能,但保证了正确性。
8. 测试与验证:怎么确认驱动是稳定的
8.1 压力测试
写完驱动后,必须做压力测试。我的做法是:
- 连续发送大量数据(比如1MB),检查是否有丢包。
- 在发送的同时接收数据,检查全双工是否正常。
- 用不同的波特率测试(9600、115200、921600),检查高波特率下的稳定性。
- 故意制造错误(比如拔掉RX线),检查错误处理是否正常。
8.2 用逻辑分析仪抓波形
逻辑分析仪是调试UART的利器。抓一段波形,解码出来,和发送的数据对比。如果发现解码错误,看是哪个位错了,就能判断是波特率问题还是信号完整性问题。
8.3 长时间运行测试
UART驱动的问题有时候是偶发的,跑几分钟没问题不代表没问题。我的经验是:至少跑24小时的压力测试。如果24小时内没有丢包、没有卡死、没有错误累积,基本可以认为驱动是稳定的。
8.4 错误注入测试
故意在通信过程中注入错误,比如:
- 短接TX和RX,测试自收发。
- 在数据线上加噪声,测试噪声错误处理。
- 故意降低对方波特率,测试帧错误处理。
这些测试能暴露很多正常测试发现不了的问题。
9. 一些进阶话题
9.1 RS485半双工的方向控制
RS485是半双工的,发送和接收共用一对差分线。方向控制(DE/RE引脚)的切换时机很关键:
- 发送前:拉高DE,使能发送器。但要注意,拉高DE到第一个字节开始发送之间需要一点延时,让总线稳定。
- 发送后:等TC标志置位后,再拉低DE,切换到接收模式。如果提前拉低,最后一个字节可能发不出去。
这个延时时间取决于收发器的特性,一般几百微秒到几毫秒。我通常会在驱动里加一个可配置的延时参数,根据实际硬件调整。
9.2 多串口的管理
有些项目需要同时管理多个UART。这时候要注意:
- 每个UART有独立的环形缓冲区和状态变量。
- 中断服务函数要区分是哪个UART触发的。
- 如果多个UART共用DMA通道,需要做仲裁。
在Linux下,每个UART对应一个TTY设备节点(如/dev/ttyS0、/dev/ttyS1),管理起来相对简单。在裸机下,建议用一个结构体数组来管理所有UART实例,避免全局变量满天飞。
9.3 与上层协议的配合
UART驱动只是最底层的数据搬运工,真正干活的是上层协议。驱动设计的时候要考虑上层协议的需求:
- 如果上层是Modbus RTU,需要支持帧间隔检测(3.5个字符时间)。
- 如果上层是自定义协议,可能需要支持可变长度帧。
- 如果上层需要流控,驱动要提供相应的接口。
我的建议是:驱动层尽量保持通用,协议相关的逻辑放到应用层。驱动只负责可靠地收发字节流,怎么解析是应用层的事。这样驱动的复用性最好。
10. 个人体会
写了这么多,最后说几句掏心窝子的话。
UART驱动开发这件事,入门容易精通难。你可以在半天内写出一个能收发的驱动,但要让它在各种极端情况下都稳定可靠,需要大量的测试和调试。我自己的经验是,每一个看似莫名其妙的bug,背后都有一个被忽略的细节。可能是时钟配置差了一点,可能是中断优先级低了一级,可能是缓冲区小了一个字节。
另外,不要迷信例程。例程能跑通不代表你的项目就能跑通,因为硬件环境、时钟配置、中断优先级都不一样。每次移植驱动,都要重新审视每一个配置项,确认它在当前环境下是正确的。
还有一点:多动手,多抓波形。示波器和逻辑分析仪能帮你看到很多代码里看不出来的问题。信号完整性、时序偏差、噪声干扰,这些东西只有用仪器才能观察到。
UART虽然老,但它是嵌入式系统里最可靠的通信方式之一。把UART驱动写好,不仅是为了当前的项目,更是为了建立一套驱动开发的思维模式——理解硬件、管理资源、处理并发、保证可靠性。这套思维模式,在你后面写SPI、I2C、USB驱动的时候,都会用得上。