ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式UART驱动开发实战:从裸机寄存器到Linux TTY框架

嵌入式UART驱动开发实战:从裸机寄存器到Linux TTY框架

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++; } }

这里有几个关键点:

  1. 读DR寄存器会自动清除RXNE标志,不需要手动清除。但如果你先读SR再读DR,效果一样。
  2. ORE(Overrun Error)必须处理。如果RXNE还没被清除,新数据又来了,就会触发ORE。不处理的话,后续数据全部丢失。
  3. 中断里尽量少做事。把数据丢进环形缓冲区就赶紧退出,解析和处理放到主循环里做。

发送中断的处理逻辑:

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驱动里非常普遍。典型的做法是:

  1. 接收方向:配置DMA循环缓冲区,DMA自动把UART FIFO的数据搬到内存。驱动定期检查DMA的写指针位置,把新数据推送到TTY层。
  2. 发送方向:驱动把数据准备好后启动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最常见的问题,没有之一。排查顺序:

  1. 确认双方波特率一致。这是最基本但也最容易被忽略的。我遇到过好几次是对方设备默认波特率不是115200,而是9600。
  2. 检查时钟源配置。如果MCU的UART时钟源选错了,算出来的波特率就是错的。用示波器量一下TX引脚上的波形,测一下最小脉冲宽度,反推波特率。
  3. 检查数据位、停止位、校验位配置。双方必须完全一致。
  4. 检查电平匹配。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 关于波特率自适应的实现思路

有些场景下需要自动检测对方的波特率。实现思路是:用定时器捕获起始位的下降沿,然后测量一个位的宽度。具体做法是:

  1. 配置RX引脚为边沿触发中断。
  2. 检测到下降沿后,启动定时器。
  3. 在定时器中断里以已知的时间间隔采样RX引脚电平,重建出波特率。

这个方法在STM32的参考手册里有详细描述,核心是利用起始位的下降沿和后续数据位的跳变来推算位宽。实现起来不算复杂,但需要仔细处理噪声和误差累积。

7. 从驱动到应用:数据收发的完整链路

7.1 发送流程的完整梳理

一个完整的发送流程是这样的:

  1. 应用层调用发送函数,把数据写入发送环形缓冲区。
  2. 如果发送中断未使能,使能TXE中断。
  3. TXE中断触发,从环形缓冲区取一个字节写入DR寄存器。
  4. 如果缓冲区空了,关闭TXE中断。
  5. 等待TC(Transmission Complete)标志置位,确认最后一个字节发送完成。

这里有个细节:TC标志和TXE标志的区别。TXE表示数据寄存器空了,可以写下一个字节;TC表示移位寄存器也空了,整个帧发送完毕。在需要精确控制发送完成时间的场景(比如RS485的方向切换),必须等TC标志,不能只等TXE。

7.2 接收流程的完整梳理

接收流程:

  1. RXNE中断触发,从DR读取数据。
  2. 把数据写入接收环形缓冲区。
  3. 检查ORE标志,如果有溢出则记录并处理。
  4. 应用层从环形缓冲区读取数据,进行协议解析。

如果用了空闲中断(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驱动的时候,都会用得上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进