1. 从一个“看起来很简单”的驱动说起
UART串口驱动,几乎是每个嵌入式工程师入行后接触的第一个真正意义上的外设驱动。很多人第一次点亮LED之后,紧接着就是调串口打印,因为后续所有调试都指望它输出log。但恰恰是这个“最简单”的驱动,在实际项目里翻车的概率一点都不低——波特率对不上、数据乱码、中断收不完整、DMA和中断打架、低功耗唤醒丢字节,这些问题我在不同平台上都踩过。
这篇内容围绕“UART串口驱动”展开,面向的是有一定单片机或嵌入式Linux基础、准备自己动手写一套串口驱动的开发者,也适合那些已经能用现成库、但想搞清楚底层到底发生了什么的人。我会从整体设计思路讲到寄存器级配置,再到中断/DMA收发、环形缓冲区、常见问题排查,尽量把每个“为什么这么写”讲透。串口驱动的核心关键词无非是:波特率、数据位、停止位、校验位、FIFO、中断、DMA、环形缓冲区、流控。这些词背后每一个都有坑,我们一个一个拆。
需要先说明的是,不同芯片的UART IP核差异很大,寄存器名字和位定义各不相同。所以我不打算贴某一款具体芯片的寄存器手册,而是用“通用UART模型”来讲,涉及具体参数时给出计算方法,你套到自己平台上即可。这也是我认为写驱动最该具备的能力——不是背寄存器,而是理解模型。
2. 整体设计与方案选型:为什么串口驱动值得认真设计
2.1 串口驱动的三层结构划分
一个能上生产环境的UART驱动,我习惯把它拆成三层:硬件抽象层、缓冲管理层、接口层。硬件抽象层负责寄存器读写、波特率计算、引脚复用配置;缓冲管理层负责收发数据的暂存和搬运;接口层对上提供统一的读写API,比如uart_write、uart_read、uart_ioctl。
为什么要这么分?因为串口在不同项目里的用法差别极大。有时候它只是调试口,发发log就行,阻塞发送都能接受;有时候它是和外部模块通信的业务口,必须支持不定长接收、超时断帧、DMA搬运。如果一开始就把寄存器操作和业务逻辑揉在一起,后面换芯片或者改通信协议时,改动量会让你怀疑人生。分层之后,换芯片只动硬件抽象层,改协议只动接口层,缓冲管理层基本可以复用。
我见过不少新手写的串口代码,发送函数里直接while(!TXE);死等,接收靠轮询,一旦业务量上来CPU全耗在串口上。这不是不能用,而是没给后续留余地。所以方案选型的第一步,是先明确这个串口在你的系统里扮演什么角色。
2.2 轮询、中断、DMA三种收发方式怎么选
这三种方式没有绝对优劣,关键看数据量和实时性要求。我整理了一张对照表,方便你按场景选:
| 方式 | CPU占用 | 适用数据量 | 实时性 | 典型场景 |
|---|---|---|---|---|
| 轮询 | 极高 | 极小(几字节) | 差 | 上电初始化打印、极简场景 |
| 中断 | 中等 | 小到中等 | 好 | 命令交互、不定长协议 |
| DMA | 低 | 大或高频 | 好 | 高速通信、大批量传输 |
实际项目里最常见的是“中断+环形缓冲区”组合,发送可以用中断逐字节发,也可以用DMA整块发。接收端我强烈建议至少用中断,因为接收是被动事件,你永远不知道对方什么时候发数据,轮询接收要么丢数据要么浪费CPU。
如果波特率超过115200且数据连续,中断方式每字节一次中断的开销就开始明显了,这时候接收端上DMA配合空闲中断(IDLE)是更稳的选择。空闲中断的好处是:一帧数据发完,总线空闲一个字节时间就触发中断,你可以在中断里一次性把DMA收到的整帧数据取走,既不用逐字节判断超时,也不会频繁进中断。
2.3 环形缓冲区为什么几乎是标配
中断接收最大的问题是:中断里不能做耗时操作,但业务层处理数据的速度又不确定。如果中断里直接把数据交给业务,业务一卡,后续数据就丢了。环形缓冲区就是解决这个速度不匹配的经典结构。
它的原理很朴素:一块固定大小的数组,一个写指针(中断里移动),一个读指针(业务里移动)。写指针追上读指针表示满,读指针追上写指针表示空。中断只管往写指针位置塞数据然后移动写指针,业务只管从读指针位置取数据然后移动读指针,两边互不阻塞。
这里有个细节很多人忽略:读写指针在多任务或中断+主循环环境下访问时,需要考虑原子性。单写单读的场景下,只要指针是自然对齐的整型,多数平台读写是原子的,可以不加锁;但如果是多写或多读,就必须加临界区保护。我一般会在写指针更新时关中断,读指针更新时视情况处理,具体后面实操部分讲。
3. 核心细节解析:从波特率到寄存器配置
3.1 波特率计算:一个必须算准的参数
波特率算错,现象就是满屏乱码或者根本收不到。UART的波特率来自系统时钟分频,公式一般是:
波特率 = 时钟源频率 / (分频系数) 分频系数 = 时钟源频率 / (16 × 波特率) // 过采样16倍的情况以常见的时钟源频率为例,假设UART时钟是48MHz,目标波特率115200,过采样16倍:
分频系数 = 48000000 / (16 × 115200) = 26.0417这个结果不是整数,说明存在误差。实际写入寄存器的分频值通常是整数部分加上小数部分(很多芯片支持小数分频)。误差计算公式:
误差 = (实际波特率 - 目标波特率) / 目标波特率 × 100%一般要求误差控制在2%以内,超过3%通信就不可靠了。我实测下来,115200在48MHz时钟下用整数分频26,实际波特率是115384,误差约0.16%,完全可用。但如果你的时钟源是奇葩频率,比如25MHz,那就要仔细算了,必要时换时钟源或者用支持小数分频的配置。
注意:有些芯片的波特率寄存器写的是分频值减一,有些写的是分频值本身,还有些把整数和小数拆成两个寄存器。一定要对着手册确认,我因为少减这个1,调了整整一个下午。
3.2 数据格式:数据位、停止位、校验位
一帧串口数据的组成是:起始位(1位,固定低电平)+ 数据位(5到9位)+ 校验位(可选)+ 停止位(1或2位)。收发双方必须完全一致,否则解析必然出错。
数据位一般用8位,这是最通用的。7位多见于一些老设备或特定协议。校验位有奇校验、偶校验、无校验、标记、空格几种,实际项目里无校验最常见,因为上层协议通常自带CRC。停止位一般1位,2位用于一些对时序要求宽松的老设备。
这里有个容易踩的坑:停止位配置成2位时,有些芯片实际发送的是1.5位或2位,接收端如果只按1位判断,可能在帧间隔上出问题。我建议除非对方设备明确要求,否则统一用8N1(8数据位、无校验、1停止位),这是兼容性最好的组合。
3.3 FIFO的触发阈值设置
现代UART基本都带FIFO,深度从8字节到64字节不等。FIFO的触发阈值决定了什么时候产生中断。发送FIFO阈值设得高,一次中断能塞更多数据,中断次数少;接收FIFO阈值设得低,能更及时响应,但中断频繁。
我的经验是:发送阈值设为FIFO深度的一半偏上,接收阈值设为1或2。为什么接收阈值设这么低?因为接收是被动的,你不知道对方发多少,设高了会导致数据在FIFO里滞留,如果对方发完就等你响应,你还在等FIFO满,那就死锁了。接收端配合空闲中断使用,阈值设低反而更安全。
3.4 引脚复用与电气连接
串口引脚通常需要配置复用功能,把GPIO切换成UART的TX/RX。这一步看似简单,但有两个坑:一是复用寄存器没使能,引脚还是普通GPIO,自然没波形;二是TX和RX接反,这个低级错误我见过太多次,包括我自己早期也犯过。
电气上,TTL电平的串口是TX接对方RX、RX接对方TX、GND对接。如果两边电平不一致(比如3.3V对5V),需要电平转换。长距离通信或者工业环境,通常加隔离或者转成RS485差分信号,这就涉及方向控制引脚(DE/RE),驱动里要额外处理收发方向切换。
4. 实操过程:手写一套中断加环形缓冲的串口驱动
4.1 数据结构定义
先把核心数据结构定下来,这是整个驱动的骨架。我用C语言描述,你可以直接套:
#define UART_RX_BUF_SIZE 256 #define UART_TX_BUF_SIZE 256 typedef struct { volatile uint16_t head; // 写指针,中断里改 volatile uint16_t tail; // 读指针,业务里改 uint8_t buf[UART_RX_BUF_SIZE]; } ring_buf_t; typedef struct { ring_buf_t rx; ring_buf_t tx; uint32_t baudrate; uint8_t data_bits; uint8_t stop_bits; uint8_t parity; } uart_dev_t;head和tail用volatile修饰,防止编译器优化掉看似“没被改变”的变量。缓冲区大小取2的幂,这样可以用位与代替取模运算,效率更高:
#define BUF_MASK (UART_RX_BUF_SIZE - 1) next = (head + 1) & BUF_MASK;4.2 环形缓冲区读写实现
写操作在中断里调用,读操作在业务里调用:
// 中断里调用,返回实际写入字节数 uint16_t ring_write(ring_buf_t *r, const uint8_t *data, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { uint16_t next = (r->head + 1) & BUF_MASK; if (next == r->tail) break; // 满 r->buf[r->head] = data[i]; r->head = next; } return i; } // 业务里调用 uint16_t ring_read(ring_buf_t *r, uint8_t *data, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { if (r->tail == r->head) break; // 空 data[i] = r->buf[r->tail]; r->tail = (r->tail + 1) & BUF_MASK; } return i; }单写单读场景下,head只被中断改,tail只被业务改,两者互不干扰,不需要加锁。但要注意:判断“满”的条件是next == tail,这意味着缓冲区实际可用容量是SIZE - 1,牺牲一个字节换取无锁判断,很划算。
4.3 中断服务函数编写
接收中断里,把FIFO里的数据全部搬进环形缓冲区:
void UART_IRQHandler(void) { uint32_t status = UART->STATUS; // 接收中断 if (status & RX_NOT_EMPTY) { while (UART->STATUS & RX_NOT_EMPTY) { uint8_t byte = UART->RDR; ring_write(&uart.rx, &byte, 1); } } // 空闲中断,一帧结束 if (status & IDLE_FLAG) { UART->STATUS = IDLE_FLAG; // 清标志 uart_frame_ready = 1; // 通知业务层 } // 发送中断 if (status & TX_EMPTY) { uint8_t byte; if (ring_read(&uart.tx, &byte, 1)) { UART->TDR = byte; } else { UART->CTRL &= ~TX_INT_EN; // 发完关中断 } } }这里的关键点:接收中断里用while循环把FIFO读空,而不是只读一个字节。因为FIFO可能一次积累了好几个字节,只读一个会导致下次中断很快又来,增加中断次数。发送中断里,如果环形缓冲区空了就关闭发送中断,避免一直进中断空转。
4.4 发送函数的阻塞与非阻塞设计
发送函数我一般提供两个版本。阻塞版用于初始化阶段或者简单场景:
void uart_send_blocking(const uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { while (!(UART->STATUS & TX_EMPTY)); UART->TDR = data[i]; } while (!(UART->STATUS & TX_COMPLETE)); // 等最后一个字节发完 }非阻塞版把数据塞进发送环形缓冲区,然后使能发送中断,立即返回:
uint16_t uart_send_async(const uint8_t *data, uint16_t len) { uint16_t written = ring_write(&uart.tx, data, len); UART->CTRL |= TX_INT_EN; // 使能发送中断 return written; }非阻塞版要注意返回值,如果缓冲区满了,写入的字节数会小于请求长度,业务层需要处理这种情况,要么重试要么丢弃。我一般会在接口层加一个“等待可写”的机制,避免业务层反复轮询。
4.5 初始化流程与参数配置
初始化顺序很重要,我按实际调试顺序列一下:
- 使能UART和对应GPIO的时钟
- 配置GPIO复用为UART功能
- 复位UART外设
- 配置波特率寄存器(先算分频值)
- 配置数据位、停止位、校验位
- 配置FIFO触发阈值
- 使能接收中断、空闲中断
- 使能UART收发
- 初始化环形缓冲区指针
第4步的波特率配置,我习惯先写分频值再使能UART,因为有些芯片在UART使能状态下改波特率会出问题。第7步的中断使能,接收和空闲中断必须开,发送中断按需开。
实操心得:初始化完成后,先发一个测试字节,用示波器或者逻辑分析仪抓一下波形,确认波特率对不对。我遇到过时钟源配置错误导致实际波特率差一倍的情况,光看代码根本看不出来,一抓波形就露馅了。
5. 常见问题与排查技巧实录
5.1 乱码问题的排查路径
乱码是串口最典型的问题,原因通常按概率排序:波特率不匹配、时钟源配置错误、数据格式不一致、地线没接好。排查顺序我建议这样:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 全部乱码 | 波特率差太多 | 核对双方波特率,抓波形测位宽 |
| 偶尔乱码 | 波特率误差偏大 | 计算误差,换时钟源或分频 |
| 固定位置乱码 | 数据格式不一致 | 核对数据位/校验位/停止位 |
| 高波特率才乱 | 信号完整性 | 缩短线长,检查地线 |
抓波形测位宽是最直接的方法:一个位的宽度应该是1/波特率秒。115200波特率下,一位约8.68微秒。如果测出来是17微秒左右,说明实际波特率是57600,差了一倍,多半是分频计算或者时钟源搞错了。
5.2 接收丢数据的几种情况
丢数据比乱码更隐蔽,因为可能只是偶尔丢一帧。常见原因有:
- 中断优先级太低,被其他中断抢占太久,FIFO溢出
- 环形缓冲区太小,业务处理慢,写指针追上读指针
- 接收中断里做了耗时操作,比如直接解析协议
- DMA模式下,DMA和CPU同时访问缓冲区没有同步
我的处理原则是:中断里只做搬运,不做解析;缓冲区大小按最坏情况估算,比如对方一帧最大256字节,业务处理最慢10毫秒,那缓冲区至少要能装下10毫秒内可能到达的所有数据。如果实在不够,就要考虑流控或者提高业务处理速度。
5.3 中断和DMA混用的坑
用DMA接收时,空闲中断触发后,要先停止DMA,读取剩余数据长度,再重启DMA。顺序错了会丢数据或者重复读。具体流程:
void UART_IDLE_IRQHandler(void) { UART->STATUS = IDLE_FLAG; uint16_t remaining = DMA->NDTR; // 剩余未传输数 uint16_t received = DMA_BUF_SIZE - remaining; DMA->CR &= ~DMA_EN; // 关DMA process_data(dma_buf, received); // 处理数据 DMA->NDTR = DMA_BUF_SIZE; // 重置计数 DMA->CR |= DMA_EN; // 重启DMA }这里有个细节:NDTR读取必须在关DMA之前,因为关DMA后计数器可能被清零。另外,处理数据期间如果对方又发来新数据,DMA已经关了,会丢。所以要么处理得足够快,要么用双缓冲。双缓冲就是准备两块DMA缓冲区,一块在处理时另一块继续接收,处理完切换,这是高速场景的标准做法。
5.4 低功耗唤醒丢首字节
低功耗场景下,串口通常配置成能唤醒系统。但唤醒需要时间,从收到起始位到系统时钟稳定,可能已经过了好几个位的时间,导致首字节丢失。解决办法有两个:一是让对方在正式数据前发一个唤醒字节(比如0x00),驱动里丢弃这个字节;二是用带自动波特率检测的UART,唤醒后重新同步。
我实测下来,第一种方法最稳,代价是协议上多一个字节。第二种依赖芯片支持,不是所有平台都有。
5.5 常见问题速查表
| 问题 | 排查方向 | 快速验证 |
|---|---|---|
| 完全无输出 | 时钟、引脚复用、使能位 | 量TX引脚有无波形 |
| 乱码 | 波特率、数据格式 | 抓波形测位宽 |
| 丢数据 | 中断优先级、缓冲区大小 | 加大缓冲区观察 |
| 只能收不能发 | TX引脚、发送使能 | 回环测试 |
| 只能发不能收 | RX引脚、接收使能 | 短接TX/RX自测 |
| 高波特率出错 | 信号完整性、时钟精度 | 降波特率对比 |
回环测试是排查收发问题的利器:把TX和RX短接,发什么应该收到什么。如果回环正常但对外通信不正常,问题就在外部接线或对方设备;如果回环都不正常,问题在驱动本身。
6. 驱动稳定性与可维护性的几点经验
6.1 错误处理与状态上报
串口驱动不能只管收发,还要能上报错误。常见的错误有:帧错误(停止位不对)、校验错误、溢出错误(FIFO满)、噪声错误。这些错误标志在状态寄存器里,中断里读到后应该清除并记录,业务层可以通过接口查询。
我一般会在驱动里维护一个错误计数器,每种错误一个计数。调试阶段打印出来,能快速定位是线路问题还是配置问题。比如溢出错误多,说明中断响应不及时;帧错误多,说明波特率或停止位不匹配。
6.2 接口层的统一抽象
如果项目里可能有多个串口,接口层最好做成统一抽象。定义一个操作集结构体:
typedef struct { int (*init)(uart_dev_t *dev, uint32_t baud); int (*send)(uart_dev_t *dev, const uint8_t *data, uint16_t len); int (*recv)(uart_dev_t *dev, uint8_t *data, uint16_t len); int (*ioctl)(uart_dev_t *dev, int cmd, void *arg); } uart_ops_t;这样上层业务不关心底层是哪个串口、用的中断还是DMA,换平台时只换ops实现。这个思路在嵌入式Linux里就是file_operations那一套,在裸机上自己实现一个简化版即可。
6.3 测试用例设计
驱动写完必须测,我一般设计这几类用例:单字节收发、连续大块收发、不定长帧收发、边界条件(缓冲区满、空)、错误注入(故意发错波特率的数据)。连续大块收发能暴露缓冲区设计和流控问题,不定长帧能验证空闲中断和断帧逻辑。
测试时用两个串口互发是最方便的,一个发一个收,对比数据是否一致。如果没有两个串口,用USB转串口模块配合上位机工具也行。我习惯写一个简单的上位机脚本,随机长度随机内容发送,跑个几万次,看有没有丢包或错包。
6.4 代码可移植性的一些习惯
最后分享几个我养成的习惯。寄存器操作全部用宏或者内联函数封装,不直接在业务代码里出现寄存器名;波特率计算写成独立函数,输入时钟频率和目标波特率,输出分频值和误差;中断服务函数尽量短,超过20行的逻辑就考虑拆出去;所有魔法数字定义成宏,比如FIFO深度、缓冲区大小。
这些习惯短期内看不出好处,但当你三个月后回头改代码,或者把驱动移植到新平台时,会感谢当时的自己。串口驱动虽然简单,但它是很多系统的调试命脉,值得多花点心思把它写扎实。
我个人在实际项目中的体会是,串口驱动最难的不是写出来,而是写出来之后在各种异常情况下都不掉链子。线路干扰、对方设备异常、电源波动,这些实验室里遇不到的情况,现场全都会遇到。所以错误处理、超时机制、缓冲区保护,这些“防御性”的代码,才是区分能用和好用的关键。