1. 为什么UART值得单独拿出来讲
嵌入式开发里,UART串口大概是每个工程师最早接触、也最容易被低估的外设。说它简单,两根线就能通数据;说它复杂,从波特率误差到DMA接收丢首帧,从TTL电平到RS485差分传输,从裸机轮询到Linux tty子系统,每一层都能挖出足够写一篇长文的内容。我做了十多年驱动开发,调试过的串口问题少说也有几百个,从8位单片机到ARM Cortex-A系列,从裸跑到Linux内核,UART几乎是贯穿始终的那根线。
这篇内容面向的是有一定嵌入式基础、正在做或准备做UART驱动开发的工程师。不管你是用STM32 HAL库做串口收发,还是在Linux下写tty驱动,或者用FPGA自己实现一个UART收发模块,这里面的思路和踩坑经验都能直接参考。我会从协议底层讲到驱动实现,从裸机讲到操作系统,从硬件电平讲到软件封装,尽量把每个关键决策背后的逻辑说清楚。
先明确一个定位:UART是异步串行通信,没有时钟线,靠双方约定的波特率来同步。这个“异步”两个字,决定了它所有的设计取舍。没有时钟线意味着成本低、接线少,但也意味着对时序精度敏感、对起始位检测依赖强。理解了这一点,后面很多问题就顺理成章了。
2. UART协议核心细节拆解
2.1 帧结构与时序逻辑
UART的一帧数据由起始位、数据位、校验位、停止位组成。起始位是逻辑0,持续一个位时间,接收方检测到下降沿后开始按波特率采样。数据位通常是5到9位,LSB先行。校验位可选奇校验、偶校验、无校验。停止位是逻辑1,可以是1位、1.5位或2位。
这里有个容易被忽略的点:接收方并不是在起始位下降沿立刻采样,而是在检测到下降沿后,等待半个位时间再开始采样第一个数据位。这个“半位等待”是为了让采样点落在每个位周期的中间,最大化容错空间。你可以这样理解:如果采样点偏前或偏后,一旦累积误差超过半个位时间,就会采错。所以波特率误差的容忍度大约是±2%到±3%,具体取决于帧长度和采样策略。
实际项目中,我一般建议波特率误差控制在1%以内。怎么算?假设系统时钟是72MHz,你要115200波特率,分频系数是72000000/(16115200)=39.0625。如果取39,实际波特率是72000000/(1639)=115384,误差0.16%,完全够用。但如果取38,误差就超过2%了,长帧传输可能出错。所以配置寄存器时一定要算清楚,别随便填个近似值。
2.2 电平标准与物理层差异
TTL电平的UART是0V到3.3V或5V,直接连MCU引脚。RS232是负逻辑,-3V到-15V表示逻辑1,+3V到+15V表示逻辑0,需要电平转换芯片。RS485是差分信号,抗干扰能力强,适合长距离和多点通信。
热词里有人问“TTL UART通过光耦能传多远”,这个问题很实际。光耦的作用是隔离,不是放大。传输距离取决于光耦的响应速度和驱动电流。普通PC817光耦,波特率超过9600就开始失真,因为它的上升沿和下降沿时间在微秒级。要传更远或更快,得用高速光耦如6N137,配合合适的限流电阻。但即便如此,TTL加光耦的距离也就几十米量级,再远就得转RS485或光纤。
RS485的传输距离和波特率成反比。100kbps以下可以到1200米,115200bps大概能到几百米。实际布线时,双绞线、终端电阻、共地处理缺一不可。我见过太多现场问题是因为没接终端电阻或者地电位差太大导致的通信不稳定。
2.3 流控机制:什么时候需要RTS/CTS
硬件流控用RTS和CTS两根线。发送方拉低RTS表示“我要发数据”,接收方拉低CTS表示“我准备好了”。如果接收方缓冲区快满了,就拉高CTS让发送方暂停。软件流控用XON/XOFF字符,在数据流里插入特殊字符来控制。
什么时候用流控?当你的接收方处理速度跟不上发送方,且数据不能丢的时候。比如MCU通过串口往PC传大量日志,PC端程序如果偶尔卡顿,没有流控就会丢数据。但流控也有代价:多两根线,协议复杂度增加,而且如果流控线本身受干扰,反而添乱。我的经验是,短距离、低波特率、数据量不大时,不用流控;高速率或大数据量时,优先考虑DMA加环形缓冲区,流控作为辅助。
3. 裸机驱动开发实操
3.1 初始化配置的完整流程
以STM32F103为例,用HAL库初始化UART的步骤大致如下。首先使能GPIO和UART时钟,配置TX为复用推挽输出,RX为浮空输入或上拉输入。然后设置UART参数:波特率、字长、停止位、校验位、模式、硬件流控。最后使能UART。
但这里有几个细节值得展开。第一,GPIO速度要设对。TX引脚如果速度设太低,高波特率下波形上升沿变缓,可能导致接收方采样错误。一般设50MHz或最高档。第二,RX引脚建议开上拉,防止悬空时收到乱码。第三,如果用到DMA,要在UART初始化之后配置DMA通道,并把UART的DR寄存器地址作为外设地址。
我习惯在初始化完成后加一段自检代码:发送一个已知字节,然后检查TXE和TC标志是否正常置位。这能快速判断时钟和引脚配置有没有问题。
3.2 中断收发与环形缓冲区设计
轮询方式简单但效率低,中断方式更实用。发送中断在TXE置位时触发,往DR写下一个字节。接收中断在RXNE置位时触发,从DR读数据存入缓冲区。
但直接在中断里处理数据是大忌。正确做法是中断只负责搬运数据到环形缓冲区,主循环或任务去消费。环形缓冲区的大小要根据数据速率和处理周期来定。假设波特率115200,每秒最多11520字节,主循环每10ms处理一次,那缓冲区至少要116字节。实际我会留2到4倍余量。
环形缓冲区的实现有个经典陷阱:读写指针相等时,是空还是满?解决办法是牺牲一个字节空间,或者额外维护一个计数变量。我倾向于用计数变量,逻辑更清晰。下面是一个简化的C语言实现思路:
typedef struct { uint8_t buffer[256]; volatile uint16_t head; volatile uint16_t tail; volatile uint16_t count; } ring_buffer_t; int ring_buffer_put(ring_buffer_t *rb, uint8_t data) { if (rb->count >= sizeof(rb->buffer)) return -1; rb->buffer[rb->head] = data; rb->head = (rb->head + 1) % sizeof(rb->buffer); rb->count++; return 0; } int ring_buffer_get(ring_buffer_t *rb, uint8_t *data) { if (rb->count == 0) return -1; *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % sizeof(rb->buffer); rb->count--; return 0; }注意head和tail要用volatile修饰,因为它们在中断和主循环中都会被访问。count的增减要保证原子性,在8位或16位MCU上,如果count是16位,读写可能是非原子的,需要在临界区保护。
3.3 DMA接收的配置与首帧丢失问题
热词里有人提到“stm32f103使用hal库串口dma接收首帧丢失”,这是个非常典型的问题。原因通常是DMA通道使能顺序不对,或者没有清除UART的接收标志。
正确的初始化顺序是:先配置UART,再配置DMA,使能DMA通道,最后使能UART的接收。如果先使能UART再配置DMA,第一个字节到达时DMA还没准备好,就会丢。另外,HAL库的HAL_UART_Receive_DMA函数内部会做一系列操作,如果之前有残留的RXNE标志,也可能导致问题。我一般会在初始化后手动读一次DR寄存器清标志。
还有一个隐蔽的坑:DMA传输完成中断里,如果直接重新启动DMA接收,中间有个窗口期可能丢数据。解决办法是用双缓冲模式,或者用DMA的空闲中断(IDLE)来触发数据处理,而不是等传输完成。STM32的UART支持IDLE中断,总线空闲一个字节时间后触发,非常适合不定长数据的接收。
4. Linux下的UART驱动与调试
4.1 tty子系统与串口设备节点
Linux把串口抽象成tty设备,设备节点通常是/dev/ttyS0、/dev/ttyAMA0、/dev/ttyUSB0等。内核里的串口驱动分为两层:上层是tty核心,下层是具体硬件的serial driver。写驱动时主要实现uart_ops结构体里的函数,比如startup、shutdown、start_tx、stop_tx、set_termios等。
调试时常用的命令有:dmesg | grep tty查看串口注册信息,ls /dev/tty*列出设备节点,stty -F /dev/ttyS0 -a查看当前配置。要修改波特率可以用stty -F /dev/ttyS0 115200。如果串口被占用,lsof /dev/ttyS0或fuser /dev/ttyS0可以查出是哪个进程。
热词里有人问“win7下怎么查看串口被哪个程序占用”,Windows下可以用Process Explorer或者handle.exe,但更简单的是在设备管理器里看端口号,然后用mode命令查看状态。不过Windows的串口占用检测确实不如Linux方便,这是事实。
4.2 从串口接收数据丢失的排查思路
Linux下串口丢数据,常见原因有几个。第一,缓冲区溢出。内核的tty缓冲区默认不大,如果应用层读取不及时,数据就丢了。可以用cat /proc/tty/driver/serial查看溢出计数。解决办法是增大缓冲区或提高读取优先级。
第二,中断共享或延迟。如果串口中断和其他高优先级中断共享,可能被延迟处理。可以看/proc/interrupts确认中断号,然后用chrt或taskset调整应用线程的调度策略。
第三,DMA配置问题。有些平台的串口DMA需要正确配置突发长度和FIFO阈值,否则高速率下会丢。这个得查具体芯片手册。
第四,流控没开。如果发送方速度快,接收方没流控,丢数据是必然的。硬件流控要确保RTS/CTS线接对,软件流控要双方都支持。
我一般按这个顺序排查:先看溢出计数,再看中断统计,然后检查流控配置,最后用示波器看波形质量。
4.3 串口封装与上层应用接口
在应用层,我习惯把串口操作封装成统一的接口,屏蔽底层差异。比如提供一个serial_open、serial_config、serial_read、serial_write、serial_close的API。配置参数用结构体传递,包括设备路径、波特率、数据位、停止位、校验位、流控。
读取时要注意超时处理。如果直接用read阻塞,程序可能卡死。我一般用select或poll加超时,或者设置VMIN和VTIME。VMIN是至少读取的字节数,VTIME是等待时间(单位0.1秒)。比如VMIN=0、VTIME=10表示最多等1秒,有数据就返回。
写入时要注意部分写的问题。write返回的字节数可能小于请求的字节数,需要循环写直到全部完成。另外,写入后最好用tcdrain等待数据真正发送出去,再关闭设备。
5. 跨平台与特殊场景实战
5.1 FPGA实现UART发送ASCII字符串
用Verilog实现UART发送,核心是波特率发生器和状态机。波特率发生器就是一个计数器,计数到分频值时翻转。状态机负责加载数据、移位输出、插入起始位和停止位。
发送ASCII字符串时,通常用一个FIFO或ROM存储字符串,状态机从FIFO读数据,逐字节发送。关键点是时序:起始位要精确一个位时间,数据位按LSB顺序输出,停止位保持高电平。如果波特率是115200,系统时钟50MHz,分频系数是50000000/115200≈434。实际取434,误差0.02%,没问题。
调试时可以用SignalTap或ILA抓波形,看起始位、数据位、停止位是否对齐。常见问题是状态机在停止位还没结束时就跳回空闲,导致下一帧起始位提前。解决办法是停止位也用一个完整的位时间计数器。
5.2 ESP32在PlatformIO下的串口输出配置
用VSCode加PlatformIO开发ESP32,串口输出默认是UART0,引脚GPIO1和GPIO3。在platformio.ini里可以配置monitor_speed来设置串口监视器的波特率。代码里用Serial.begin(115200)初始化,Serial.println输出。
如果想用其他UART,比如UART1或UART2,可以指定引脚。ESP32的UART支持引脚矩阵,几乎任意GPIO都能映射。但要注意,有些引脚在启动时有特殊功能,比如GPIO0是启动模式选择,GPIO2是启动日志输出,用之前要查清楚。
Arduino框架下,Serial是硬件串口,Serial1和Serial2是额外的。如果用USB转串口芯片,比如CH340或FT232R,需要装驱动。Linux下一般自带CH340驱动,Windows下可能要手动装。装完驱动后,设备节点是/dev/ttyUSB0或COMx。
5.3 虚拟机串口配置与透传
在VMware或VirtualBox里配置串口,可以把宿主机的物理串口映射给虚拟机,也可以创建虚拟串口对。VMware里在虚拟机设置中添加串口,选择“使用物理串口”或“使用命名管道”。命名管道的方式可以在两个虚拟机之间或虚拟机和宿主机之间通信。
配置时要注意波特率和流控要和宿主机一致。如果宿主机串口被占用,虚拟机就映射不了。另外,虚拟机的串口中断延迟可能比物理机大,高速率下容易丢数据。如果只是调试,115200以下问题不大。
5.4 RS485与多机通信
RS485半双工通信需要控制收发方向。通常用一个GPIO控制收发器的DE/RE引脚。发送前拉高DE,发送完拉低。这里的关键是发送完成的判断:不能只看TXE,要看TC(传输完成)标志,确保最后一个字节的停止位也发完了再切换方向。
多机通信时,每个从机有地址,主机发地址帧,从机比对地址后决定是否响应。协议可以自定义,也可以用Modbus RTU。Modbus RTU的帧间隔是3.5个字符时间,用来区分帧边界。实现时用定时器检测总线空闲时间,超过3.5个字符时间就认为一帧结束。
RS485的终端电阻在总线两端各接一个120欧姆电阻。中间节点不要接。如果通信距离短、节点少,不接也能凑合,但长距离或高速率时必须接。共地也很重要,如果各地电位差太大,收发器可能损坏,可以用隔离型收发器。
6. 常见问题速查与避坑经验
6.1 串口调试常见问题排查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 收不到任何数据 | TX/RX接反 | 交换两根线测试 | 确认交叉连接 |
| 收到乱码 | 波特率不匹配 | 核对双方波特率 | 统一波特率并计算误差 |
| 偶尔丢数据 | 缓冲区溢出 | 查看溢出计数 | 增大缓冲区或加流控 |
| 首字节丢失 | DMA使能顺序 | 检查初始化流程 | 先配DMA再使能UART |
| 长距离通信失败 | 电平不匹配 | 测量信号幅度 | 加电平转换或转RS485 |
| 中断不触发 | 中断未使能 | 检查NVIC配置 | 使能对应中断向量 |
| 发送完成标志不置位 | 时钟未使能 | 检查RCC配置 | 使能UART时钟 |
| 串口被占用 | 其他进程打开 | lsof或fuser | 关闭占用进程 |
6.2 实操心得与避坑技巧
第一个心得:永远不要相信“默认配置”。很多开发板的例程里波特率是9600,但实际项目可能需要115200甚至更高。改波特率时,记得同步改上位机和所有相关设备。
第二个心得:示波器是串口调试的终极武器。当软件层面查不出问题时,用示波器看TX和RX波形,测量位宽、上升沿时间、噪声。我遇到过因为电源纹波导致串口误码的情况,软件查了半天,示波器一测就发现了。
第三个心得:环形缓冲区的大小要留足余量。我一般按最大数据速率的2到4倍来设计。比如115200波特率,每秒11520字节,如果主循环10ms处理一次,缓冲区至少116字节,实际用256或512。
第四个心得:DMA接收一定要用IDLE中断。等DMA传输完成再处理,中间可能丢数据。IDLE中断在总线空闲时触发,能及时处理不定长数据。
第五个心得:Linux下串口权限问题很常见。普通用户默认没有/dev/ttyS0的读写权限,要么用sudo,要么把用户加入dialout组。后者更安全。
第六个心得:跨平台开发时,串口API差异很大。Windows用CreateFile和ReadFile,Linux用open和read。封装一层抽象接口能省很多事。
6.3 工具选型参考
串口调试助手方面,Windows下常用的有SSCOM、XCOM、友善串口调试助手。Linux下可以用minicom、picocom、screen。我個人偏好picocom,轻量、命令行、支持脚本。
USB转串口芯片,CH340便宜但驱动兼容性一般,FT232R稳定但贵,CP2102居中。如果做产品,建议用FT232或CP2102,少很多驱动麻烦。
逻辑分析仪方面,Saleae的软件好用但硬件贵,国产的DSLogic性价比高。抓UART波形时,设置采样率至少是波特率的10倍以上,否则可能采不准。
7. 从驱动到应用的完整链路思考
UART驱动开发不只是配置寄存器那么简单。从硬件电平到协议帧,从中断处理到缓冲区管理,从内核驱动到应用接口,每一层都有它的设计考量和取舍。我见过很多项目,底层驱动写得没问题,但应用层读取不及时导致丢数据,最后排查了半天才发现是上层的问题。
所以做UART开发,要有全链路的视角。硬件上确认电平匹配、接线正确、终端电阻合适;驱动上确认初始化顺序、中断优先级、DMA配置;应用上确认缓冲区大小、读取周期、流控策略。任何一层出问题,表现都是“串口不通”或“丢数据”,但根因可能完全不同。
最后分享一个我常用的调试方法:在驱动层加统计计数器,记录发送字节数、接收字节数、溢出次数、校验错误次数。应用层定期打印这些统计,一旦发现溢出或错误增长,就能快速定位问题方向。这个方法在多个项目中帮我省了大量排查时间。