资讯动态

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

发布时间:2026/10/23 1:22:32 来源:尧图企业网站定制
1. 为什么UART值得单独拎出来讲搞嵌入式的人都有一个共识能把UART玩明白基本就摸到了驱动开发的门槛。这不是夸张。UARTUniversal 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目标波特率115200USARTDIV 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目标115200USARTDIV 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目标115200USARTDIV 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 8MHzUSARTDIV 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的正确用法中断方式下最核心的两个标志位是RXNEReceive Data Register Not Empty和TXETransmit 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效果一样。OREOverrun 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线路规程层处理终端特殊字符如CtrlC、回显等。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层。发送方向驱动把数据准备好后启动DMADMA完成后触发中断驱动再启动下一批。注意DMA和CPU之间的缓存一致性问题。如果DMA写内存后CPU直接读可能读到旧数据。需要用dma_sync_single_for_cpu()之类的API做同步。这个问题在ARM平台上尤其常见因为ARM的缓存不是硬件一致的。4.3 流控硬件流控和软件流控流控是UART通信中防止数据丢失的重要机制。两种方式硬件流控RTS/CTS需要额外的两根线。发送方在发送前检查CTS是否有效接收方在缓冲区快满时拉高RTS。这种方式响应快不占用数据带宽。软件流控XON/XOFF用特殊字符XON0x11和XOFF0x13来控制。不需要额外线缆但会占用数据带宽而且如果数据里恰好包含这两个字符就会出问题。在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中断。等待TCTransmission 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驱动的时候都会用得上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑