
1. 为什么UART值得单独拿出来讲一期做嵌入式驱动开发这些年如果让我挑一个最能检验基本功的外设UART串口绝对排得进前三。它看起来简单——两根线一发一收配置个波特率就能跑——但真正在产品里把它用稳、用透踩过的坑能写满一个笔记本。我见过太多项目I2C能调通、SPI能跑起来结果卡在串口上数据偶尔丢一字节、DMA收一半不触发中断、RS485方向切换时机不对导致总线冲突、低功耗唤醒后第一个字节永远是乱的。这一期就围绕UART串口把我在STM32、GD32、全志V3s、Linux平台以及FPGA上做串口驱动的经验做一次系统梳理。不管你是刚接触嵌入式的新手还是已经写过几版串口驱动想查漏补缺的老手这篇内容都能给你一些可以直接抄作业的东西。核心会覆盖UART协议的本质、寄存器级配置逻辑、中断与DMA的取舍、Linux下的串口编程模型、RS485半双工方向控制、常见故障排查以及那些文档里不会写的实操心得。先给一个整体认知UART不是“配好波特率就完事”的外设它的稳定性取决于时钟精度、FIFO策略、中断优先级、DMA搬运边界、电平转换电路、软件流控这六个层面的协同。任何一层出问题表现都是“串口不稳定”但根因完全不同。下面逐层拆开讲。2. UART协议本质与驱动开发的核心关注点2.1 从电平跳变到字节UART到底怎么传数据UART的全称是通用异步收发器关键词在“异步”两个字。它没有独立的时钟线收发双方靠约定的波特率各自计时。一帧数据的结构是1个起始位低电平、5到9个数据位通常是8位、可选的校验位、1或2个停止位高电平。空闲时线路保持高电平。这里有个很多人忽略的细节UART的起始位检测是依靠下降沿触发的。接收端在检测到下降沿后会在起始位中间时刻采样确认然后每隔一个波特率周期采样一次数据位。这意味着如果发送端和接收端的波特率误差超过一定程度采样点就会偏移到数据位的边缘甚至跳变处导致误码。那误差允许多少理论上一帧10位数据采样点累积偏移不能超过半个位时间。粗略计算双方波特率误差之和应小于5%实际工程中建议控制在2%以内。这就是为什么内部RC振荡器做串口通信容易出问题——RC振荡器温漂可能到3%以上加上对方也有误差很容易超限。所以但凡对串口稳定性有要求的场景必须用外部晶振。2.2 驱动开发者真正要关心的四件事写UART驱动不是把数据从寄存器搬进搬出就完了。我总结下来驱动层面真正要解决的是四个问题第一时钟配置与波特率精度。不同芯片的UART时钟源不同有的是APB总线时钟有的可以独立选择。分频系数的计算方式也各异有的芯片支持小数分频有的只能整数分频。分频误差直接决定通信是否可靠。第二数据搬运机制的选择。轮询、中断、DMA三种方式各有适用场景。轮询适合极低速率、极低数据量的场景中断适合中等速率、数据量不大且对实时性有要求的场景DMA适合高速率、大数据量、CPU需要腾出手干别的事的场景。选错了不是不能用而是效率低或者容易出bug。第三错误处理与状态恢复。溢出错误、帧错误、校验错误、噪声错误——这些标志位必须处理。不处理的话错误状态会锁死接收逻辑表现为“串口突然收不到数据了”。第四流控与缓冲管理。硬件流控用RTS/CTS两根线软件流控用XON/XOFF字符。环形缓冲区的大小、读写指针的原子操作、缓冲区满时的策略这些决定了高负载下会不会丢数据。2.3 一个容易被忽视的点TTL电平不等于RS232电平很多新手把MCU的TX/RX直接接到PC的串口上结果要么没反应要么烧芯片。原因很简单MCU的UART输出的是TTL电平0V/3.3V或0V/5V而传统PC串口是RS232电平正负12V左右两者电气特性完全不同。中间必须加电平转换芯片比如MAX3232。现在更常见的做法是用USB转串口芯片比如CH340、CP2102、FT232R、FT231X这些芯片内部完成了USB协议到UART的转换输出的是TTL电平可以直接接MCU。FT232R和FT231X的区别值得说一下FT232R支持完整的调制解调器信号DTR、DSR、DCD等适合需要硬件流控的场景FT231X精简了这些信号只保留TX/RX/RTS/CTS成本更低适合只需要基本串口功能的场景。选型时根据实际需求来别多花钱买用不上的功能。3. 寄存器级配置以STM32和GD32为例拆解UART初始化3.1 波特率计算整数分频与小数分频的差异以STM32F103为例UART的时钟来自APB总线。假设APB2时钟为72MHz要配置115200波特率。STM32的USART_BRR寄存器分为整数部分和小数部分小数部分用4位表示精度为1/16。计算公式是USARTDIV fCK / (16 × 波特率)。代入数值72,000,000 / (16 × 115200) 39.0625。整数部分是39小数部分是0.0625 × 16 1。所以BRR寄存器写入0x27139 4 | 1。实际波特率 72,000,000 / (16 × 39.0625) 115,200误差为0。这是理想情况。但如果APB时钟是36MHz呢36,000,000 / (16 × 115200) 19.53125。整数部分19小数部分0.53125 × 16 8.5取整为8。实际分频值 19 8/16 19.5。实际波特率 36,000,000 / (16 × 19.5) 115,384。误差 (115384 - 115200) / 115200 ≈ 0.16%完全可接受。GD32F470VET6的UART配置逻辑类似但时钟树更复杂UART可以挂在APB1或APB2上时钟频率也不同。配置前一定要查清楚当前UART挂在哪条总线上总线时钟是多少。我见过有人直接抄STM32的代码到GD32上结果波特率差了十万八千里就是因为时钟源没搞对。3.2 GPIO复用配置别在这省事UART的TX和RX引脚需要配置为复用功能。以STM32为例TX配置为复用推挽输出RX配置为浮空输入或上拉输入。这里有个经验RX引脚建议配置为上拉输入因为串口空闲时线路是高电平上拉可以避免线路悬空时产生虚假起始位。GD32F470VET6的引脚复用功能更丰富同一个引脚可能对应多个UART。配置时要注意AF编号选错了AF引脚就没有UART功能。我一般会在初始化代码里加一句断言或者打印确认引脚复用配置正确。3.3 初始化顺序一个容易翻车的地方UART初始化的正确顺序是先配置GPIO复用再使能UART时钟然后配置UART参数波特率、数据位、停止位、校验位、流控最后使能UART。如果顺序反了比如先使能UART再配置GPIO可能在配置期间UART就已经开始工作引脚状态不确定导致收到乱码或者触发错误中断。还有一个细节使能UART之前建议先清除所有状态标志位。有些芯片上电后标志位是随机值不清的话可能一使能就进错误中断。// STM32 UART初始化示例精简版 void uart_init(uint32_t baudrate) { // 1. 使能GPIO和UART时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // 2. 配置GPIO GPIO_InitTypeDef gpio; gpio.GPIO_Pin GPIO_Pin_9; // TX gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_10; // RX gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); // 3. 配置UART参数 USART_InitTypeDef uart; uart.USART_BaudRate baudrate; uart.USART_WordLength USART_WordLength_8b; uart.USART_StopBits USART_StopBits_1; uart.USART_Parity USART_Parity_No; uart.USART_HardwareFlowControl USART_HardwareFlowControl_None; uart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, uart); // 4. 清除标志位并使能 USART_ClearFlag(USART1, USART_FLAG_TC | USART_FLAG_RXNE | USART_FLAG_ORE); USART_Cmd(USART1, ENABLE); }这段代码看起来简单但每一行都有讲究。比如GPIO速度配置为50MHz是因为UART在高波特率下引脚翻转频率较高速度配置太低会导致波形畸变。再比如RX配置为浮空输入如果外部有上拉电阻浮空没问题如果外部没有上拉建议改成上拉输入。4. 中断、DMA与环形缓冲数据搬运的三种姿势4.1 中断方式什么时候用怎么用才不出错中断方式适合数据量不大、但对响应速度有要求的场景。比如接收不定长命令帧每来一个字节进一次中断把数据存入缓冲区同时启动一个定时器做帧间隔检测。如果超过一定时间没有新数据就认为一帧接收完毕。中断方式最大的坑是中断优先级配置不当。如果UART中断优先级低于某个高频中断比如定时器中断在高负载时UART中断可能被延迟响应导致接收溢出。STM32的UART接收中断标志RXNE在数据寄存器被读取后自动清除但如果中断延迟太久下一个字节已经到达就会触发溢出错误ORE。ORE一旦置位如果不读DR寄存器清除接收逻辑就卡死了。我的做法是UART接收中断优先级设为中等偏上高于普通定时器但低于系统关键中断。同时在中断服务函数里只做最少的操作——读数据、存缓冲区、清标志其他处理放到主循环或任务里。4.2 DMA方式配置对了很省心配错了很闹心DMA方式适合高速率、大数据量的场景。比如串口屏通信、GPS模块数据接收、与上位机大批量数据交换。DMA的基本配置包括源地址UART数据寄存器、目标地址内存缓冲区、传输方向、数据宽度、传输模式单次/循环、优先级。STM32的UART DMA接收有个经典问题DMA传输完成中断只在缓冲区满时触发。如果你用DMA接收不定长数据缓冲区设了256字节但对方只发了10字节就不发了DMA不会触发完成中断你也不知道数据已经来了。解决办法有两种一是用DMA的“空闲线检测”功能STM32的USART支持IDLE中断当总线空闲时触发中断此时读取DMA剩余传输数量就能知道收到了多少字节二是用定时器配合定期检查DMA传输进度。GD32F470VET6的DMA控制器功能更强支持链表模式和多通道优先级但配置也更复杂。我建议先用简单的单通道模式跑通再根据需求逐步增加复杂度。4.3 环形缓冲区串口驱动的“万能缓冲”不管用中断还是DMA环形缓冲区都是串口驱动的标配。它的核心是两个指针读指针和写指针。写指针在中断或DMA完成回调里推进读指针在应用层读取数据时推进。当读指针等于写指针时缓冲区为空当写指针的下一个位置等于读指针时缓冲区为满。环形缓冲区的难点在于多线程/多中断环境下的原子操作。如果写指针在中断里修改读指针在主循环里修改那么判断缓冲区空/满时就需要关中断或者用原子操作。我一般会在读写指针操作时短暂关中断虽然有一点点性能损失但换来了逻辑的绝对可靠。缓冲区大小怎么定经验公式是缓冲区大小 ≥ 波特率 / 10 × 最大处理延迟。比如115200波特率每秒最多11520字节如果主循环最大延迟10ms那缓冲区至少要116字节。实际我会留2到4倍余量用512字节或1024字节。// 环形缓冲区结构定义 typedef struct { uint8_t *buffer; uint16_t size; volatile uint16_t read_idx; volatile uint16_t write_idx; } ring_buffer_t; // 写入一个字节在中断中调用 bool ring_buffer_put(ring_buffer_t *rb, uint8_t data) { uint16_t next (rb-write_idx 1) % rb-size; if (next rb-read_idx) { return false; // 缓冲区满 } rb-buffer[rb-write_idx] data; rb-write_idx next; return true; } // 读取一个字节在主循环中调用 bool ring_buffer_get(ring_buffer_t *rb, uint8_t *data) { if (rb-read_idx rb-write_idx) { return false; // 缓冲区空 } *data rb-buffer[rb-read_idx]; rb-read_idx (rb-read_idx 1) % rb-size; return true; }这段代码在单生产者单消费者模型下是安全的不需要额外加锁。但如果多个中断源都往同一个缓冲区写就需要加保护了。5. Linux下的UART编程与驱动调试5.1 从应用层操作串口termios配置详解在Linux下串口设备表现为/dev/ttyS0、/dev/ttyUSB0这样的字符设备文件。应用层通过termios结构体配置波特率、数据位、停止位、校验位和流控。配置流程是打开设备文件O_RDWR | O_NOCTTY | O_NDELAY用tcgetattr获取当前配置修改termios结构体用tcsetattr写回配置。波特率的设置比较特殊需要用cfsetispeed和cfsetospeed函数而不是直接赋值。#include termios.h #include fcntl.h #include unistd.h int uart_open(const char *device, int baudrate) { int fd open(device, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { return -1; } struct termios options; tcgetattr(fd, options); // 设置波特率 cfsetispeed(options, baudrate); cfsetospeed(options, baudrate); // 8N1配置 options.c_cflag ~PARENB; // 无校验 options.c_cflag ~CSTOPB; // 1位停止位 options.c_cflag ~CSIZE; options.c_cflag | CS8; // 8位数据位 // 启用接收器和本地模式 options.c_cflag | (CLOCAL | CREAD); // 原始模式不做任何特殊字符处理 options.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); options.c_iflag ~(IXON | IXOFF | IXANY); options.c_oflag ~OPOST; // 设置读取超时 options.c_cc[VMIN] 0; options.c_cc[VTIME] 10; // 1秒超时 tcsetattr(fd, TCSANOW, options); tcflush(fd, TCIOFLUSH); return fd; }这段代码里有个细节VMIN和VTIME的组合决定了read函数的行为。VMIN0、VTIME10表示read会等待最多1秒如果1秒内没有数据就返回0。如果VMIN1、VTIME0read会阻塞直到至少读到1个字节。根据实际需求选择。5.2 排查串口被占用lsof和fuser的实战用法在Windows下查看串口被哪个程序占用可以用设备管理器或者Process Explorer。在Linux下lsof和fuser是两个利器。# 查看哪个进程打开了/dev/ttyUSB0 lsof /dev/ttyUSB0 # 或者用fuser fuser /dev/ttyUSB0 # 查看所有串口设备 ls -l /dev/ttyS* /dev/ttyUSB* /dev/ttyACM*如果lsof显示某个进程占用了串口但你已经关掉了程序可能是进程没有正常退出用kill命令结束它。还有一种情况是串口被内核驱动占用比如console输出重定向到了串口这时候需要修改内核启动参数或者用setserial调整。Ubuntu下查看串口设备的命令还有dmesg | grep tty可以看到内核检测到的串口设备及其地址。插拔USB转串口设备时dmesg会输出设备识别信息包括芯片型号和分配的设备节点。5.3 Linux串口接收数据丢失的常见原因Linux下串口接收丢数据通常有以下几个原因一是缓冲区溢出。Linux内核的串口驱动有默认的缓冲区大小如果应用层读取不及时数据就会丢失。可以通过调整驱动参数或者提高应用层读取频率来解决。二是中断共享冲突。有些工控板的串口中断和其他外设共享中断线如果其他外设中断处理时间过长串口中断被延迟就会丢数据。三是电源管理。某些平台上串口在系统空闲时会被挂起唤醒后第一个字节可能丢失。解决办法是关闭串口的电源管理或者在唤醒后丢弃第一个字节。四是DMA配置问题。如果用了DMA接收但DMA缓冲区和应用层缓冲区之间的同步没做好也会丢数据。6. RS485半双工与光耦隔离的实战细节6.1 RS485方向控制时机就是一切RS485是半双工总线收发共用一对差分线。发送数据前需要把收发器切换到发送模式发送完成后切回接收模式。这个切换时机的把握是RS485通信稳定性的关键。切换太早最后一个字节还没发完就切到接收总线上的数据会被自己接收回来造成回环。切换太晚对方已经回复了但你还处于发送模式对方的回复就丢了。正确的做法是发送数据写入UART数据寄存器后等待发送完成标志TC置位再切换方向。注意是TCTransmission Complete而不是TXETransmit Data Register Empty。TXE只表示数据寄存器空了但最后一个字节可能还在移位寄存器里往外发。TC置位才表示最后一个停止位已经发送完毕。// RS485发送函数 void rs485_send(uint8_t *data, uint16_t len) { RS485_TX_EN(); // 切换到发送模式 for (uint16_t i 0; i len; i) { while (!USART_GetFlagStatus(USART1, USART_FLAG_TXE)); USART_SendData(USART1, data[i]); } while (!USART_GetFlagStatus(USART1, USART_FLAG_TC)); // 等待发送完成 RS485_RX_EN(); // 切换回接收模式 }实际项目中我还会在切换方向后加一个微小的延时几微秒确保收发器芯片完成状态切换。不同品牌的收发器切换时间不同查数据手册确认。6.2 光耦隔离TTL UART能传多远TTL电平的UART通信距离很短通常不超过1米。如果要延长距离有几种方案一是转成RS485差分信号可以传到1200米二是加光耦隔离既能延长距离又能隔离地环路。光耦隔离的传输距离取决于光耦的响应速度和驱动电流。普通光耦如PC817的上升沿和下降沿时间在微秒级适合波特率不超过19200的场景。高速光耦如6N137的响应时间在纳秒级可以支持115200甚至更高的波特率。光耦隔离电路的设计要点发光二极管侧需要限流电阻光敏三极管侧需要上拉电阻。限流电阻的计算假设驱动电压3.3V光耦正向压降1.2V期望电流10mA则限流电阻 (3.3 - 1.2) / 0.01 210Ω取标准值220Ω。上拉电阻一般取1K到10K阻值越小上升沿越快但功耗越大。6.3 单线半双工与全双工怎么连接单线半双工比如LIN总线或者某些传感器的单线UART只有一根数据线收发分时复用。要和全双工设备连接需要外部电路做收发切换。简单的方法是用一个三极管或者模拟开关把单线信号分时切换到全双工的TX和RX上。但要注意方向控制信号的产生通常需要MCU的一个GPIO来控制。更优雅的方案是用支持单线半双工模式的UART外设。STM32的USART支持HDSEL位配置为单线半双工模式TX和RX在内部连接到同一个引脚硬件自动处理收发切换。这种模式下软件只需要正常读写数据寄存器不需要手动控制方向。7. 常见问题排查速查表与避坑心得7.1 串口问题速查表现象可能原因排查方法解决方案完全无数据TX/RX接反交换TX和RX试试纠正接线完全无数据波特率不匹配用示波器测位宽统一双方波特率收到乱码波特率误差大计算分频误差换外部晶振或调整分频收到乱码数据位/停止位/校验位不匹配检查双方配置统一帧格式偶尔丢字节中断优先级低查看中断延迟提高UART中断优先级偶尔丢字节缓冲区溢出检查缓冲区大小和读取频率增大缓冲区或提高读取频率接收一段时间后卡死溢出错误未清除检查ORE标志在中断中读DR清OREDMA接收不定长数据不触发DMA完成中断只在满时触发检查DMA配置用IDLE中断或定时器配合RS485发送后收不到回复方向切换太早示波器看方向控制信号等TC置位后再切换低功耗唤醒后首字节错误串口时钟未稳定检查唤醒流程唤醒后延时再使能串口7.2 那些文档里不会写的实操心得心得一串口调试助手要选对。市面上串口调试助手很多但有些在高速率下会丢数据。我常用的是SecureCRT和Putty稳定性好支持日志记录。Windows下也可以试试SSCOM功能全但高速率下偶尔会卡。Linux下直接用minicom或picocom配合脚本可以自动化测试。心得二示波器是串口调试的终极武器。遇到通信问题先用示波器看波形。看空闲电平对不对、起始位下降沿是否干净、位宽是否和波特率匹配、停止位是否到位。很多问题一看波形就清楚了比猜来猜去快得多。心得三串口线不要贪便宜。劣质USB转串口线在高速率下误码率很高尤其是CH340的某些廉价版本。FT232R和CP2102的稳定性明显更好。如果项目对串口稳定性要求高别在串口线上省钱。心得四STM32串口调试PID时打印不要放在中断里。很多人调PID时喜欢在中断里printf结果printf本身耗时较长导致中断执行时间过长影响PID控制周期。正确做法是把数据存入缓冲区在主循环里统一打印。心得五GD32F470VET6的串口引脚有重映射功能用之前先查数据手册确认AF编号。我遇到过有人把UART0的引脚配置成了UART1的AF结果死活收不到数据查了半天才发现是AF选错了。心得六Linux下串口权限问题。普通用户默认没有权限打开/dev/ttyS0或/dev/ttyUSB0需要把用户加入dialout组或者用sudo运行。加入组的命令是sudo usermod -a -G dialout $USER然后重新登录生效。心得七FPGA实现串口发送ASCII字符串时注意波特率计数器的位宽。比如50MHz时钟115200波特率分频系数是50,000,000 / 115200 ≈ 434。计数器需要至少9位2^9512 434。如果位宽不够计数器会溢出波特率就错了。心得八串口烧录失败时先检查BOOT引脚和复位时序。很多芯片的串口烧录需要特定的BOOT引脚组合比如STM32需要BOOT0拉高、BOOT1拉低然后复位。如果BOOT引脚不对芯片不会进入Bootloader串口烧录自然失败。8. 从驱动到应用串口封装C库的设计思路8.1 为什么要做串口封装裸写串口驱动每个项目都要重新配置一遍寄存器、写一遍中断服务函数、实现一遍环形缓冲区。效率低不说还容易引入不一致的bug。做一个通用的串口封装C库可以把这些重复劳动抽象掉让应用层只需要调用几个简单的API。我设计的串口封装库包含以下模块硬件抽象层HAL负责寄存器操作驱动层负责中断/DMA处理和环形缓冲区管理应用层提供send、receive、set_baudrate等接口。这样换芯片时只需要改HAL层上层代码不用动。8.2 封装库的核心接口设计// 串口句柄 typedef struct uart_handle { void *hw; // 硬件相关指针 ring_buffer_t rx_buffer; // 接收缓冲区 ring_buffer_t tx_buffer; // 发送缓冲区 uint32_t baudrate; void (*tx_complete_cb)(void); // 发送完成回调 void (*rx_data_cb)(uint8_t); // 接收数据回调 } uart_handle_t; // 初始化 int uart_init(uart_handle_t *handle, uint32_t baudrate); // 发送数据非阻塞 int uart_send(uart_handle_t *handle, const uint8_t *data, uint16_t len); // 接收数据非阻塞 int uart_receive(uart_handle_t *handle, uint8_t *data, uint16_t len); // 获取接收缓冲区中可读字节数 uint16_t uart_available(uart_handle_t *handle); // 注册回调 void uart_register_callback(uart_handle_t *handle, void (*tx_cb)(void), void (*rx_cb)(uint8_t));这个设计的核心思想是非阻塞。发送和接收都不阻塞调用者数据放入缓冲区后立即返回实际的数据搬运由中断或DMA在后台完成。应用层通过uart_available查询可读数据量通过回调处理接收事件。8.3 跨平台适配的注意事项不同芯片的UART外设差异很大封装库的HAL层需要处理这些差异。比如STM32的UART状态标志位和GD32的不同全志V3s的UART寄存器布局又是另一套。我的做法是在HAL层定义一组统一的接口函数每个平台实现自己的版本。// HAL层接口定义 typedef struct uart_hal { void (*enable_clock)(void); void (*config_gpio)(void); void (*config_uart)(uint32_t baudrate); void (*enable_uart)(void); void (*disable_uart)(void); void (*send_byte)(uint8_t data); uint8_t (*receive_byte)(void); bool (*is_tx_complete)(void); bool (*is_rx_ready)(void); void (*clear_error_flags)(void); } uart_hal_t;这样换平台时只需要实现一个新的uart_hal_t结构体上层驱动逻辑完全复用。这个思路在多个项目中验证过移植一个新平台的UART驱动半天时间就够了。9. 高速串口与DMA的进阶优化9.1 串口DMA的双缓冲策略当串口速率达到1Mbps以上或者数据量非常大时单缓冲DMA可能会出现“处理不过来”的情况。比如DMA正在往缓冲区A写数据CPU正在处理缓冲区B的数据但缓冲区B还没处理完DMA已经把缓冲区A写满了这时候就丢数据了。双缓冲策略是解决这个问题的经典方案。准备两个缓冲区A和BDMA先写A写满后触发中断CPU开始处理A同时DMA切换到B继续写。B写满后触发中断CPU处理BDMA切回A。如此循环只要CPU处理一个缓冲区的速度大于DMA填满一个缓冲区的速度就不会丢数据。STM32的DMA支持双缓冲模式DBM配置好两个内存地址后硬件自动切换。GD32的DMA也支持类似功能。用双缓冲时要注意中断里只做缓冲区切换和标志设置实际数据处理放到主循环否则中断执行时间太长还是会丢数据。9.2 串口DMA发送的坑什么时候数据真正发出去了DMA发送完成中断触发时数据已经从内存搬到了UART数据寄存器但不一定已经从引脚发出去了。如果此时就切换RS485方向或者关闭串口最后一个字节可能还没发完。正确的做法是DMA发送完成中断触发后再等待UART的TC标志置位然后才做后续操作。STM32的USART支持DMA发送完成中断和TC中断可以配置为DMA发送完成后自动触发TC中断在TC中断里做收尾工作。9.3 高波特率下的时钟精度要求波特率越高对时钟精度的要求越苛刻。115200波特率时2%的误差还能接受到了1Mbps误差必须控制在1%以内到了3Mbps以上误差要小于0.5%。这意味着高波特率场景下必须使用高精度晶振±10ppm以内并且要仔细计算分频误差。有些芯片的UART支持自动波特率检测可以在通信开始时自动测量对方波特率并调整自己的分频值这在一定程度上缓解了时钟精度问题但增加了协议复杂度。10. 一些实战场景的串口方案选型10.1 与上位机通信USB转串口芯片选型和PC通信最方便的方案是用USB转串口芯片。CH340便宜但驱动安装有时会出问题Win7下需要手动装驱动。CP2102稳定性好驱动支持完善但价格稍高。FT232R和FT231X是工业级选择稳定性最好但价格也最高。如果项目对成本敏感且通信速率不高115200以下CH340够用。如果对稳定性要求高选CP2102或FT系列。FT231X比FT232R便宜如果不需要完整的调制解调器信号选FT231X更划算。10.2 多串口扩展用FPGA还是多UART芯片有些项目需要4个、8个甚至更多串口。MCU自带的UART不够用有两种扩展方案一是用FPGA实现多个UART二是用多UART扩展芯片如SC16IS752通过I2C或SPI扩展2个UART。FPGA方案灵活可以实现任意数量的UART但开发工作量大需要写Verilog代码。多UART芯片方案简单驱动现成但数量受限于芯片规格。我的经验是如果串口数量不超过4个用多UART芯片如果超过4个或者有特殊协议需求用FPGA。10.3 串口屏通信协议设计与超时处理串口屏如迪文、陶晶驰通过UART和MCU通信通常有自定义的协议帧格式。设计协议时要注意帧头帧尾要明确数据长度要固定或可计算校验方式要可靠推荐CRC16超时重传机制要完善。超时处理是串口屏通信的难点。屏幕响应时间不确定超时设太短会误判设太长会影响体验。我的做法是发送命令后启动一个定时器超时时间设为屏幕手册标称最大响应时间的1.5倍。超时后重试重试3次仍失败则报错。10.4 低功耗场景串口唤醒与数据保持电池供电的设备MCU大部分时间在休眠串口需要支持唤醒功能。STM32的UART支持在停止模式下通过RX引脚下降沿唤醒。配置时要注意唤醒后UART时钟需要重新稳定第一个字节可能丢失。解决办法是在唤醒后先丢弃一个字节或者让发送方在正式数据前先发一个唤醒字节。低功耗场景下串口的功耗也要考虑。UART外设本身功耗不高但电平转换芯片和收发器的功耗可能不小。选型时优先选低功耗型号不通信时可以通过GPIO切断收发器电源。11. 我个人在实际操作中的几点体会串口这东西入门容易精通难。我做了这么多年嵌入式到现在也不敢说把所有串口的坑都踩完了。但有几点体会是通用的第一永远不要假设串口是可靠的。不管波特率多低、线缆多短都要在协议层加校验和重传。我见过太多项目因为串口偶尔丢一个字节导致整个系统异常最后查了几天才发现是串口问题。第二调试串口和通信串口要分开。调试打印用的串口和实际业务通信的串口不要共用否则调试信息会干扰业务数据而且调试串口通常配置简单没有校验和流控不适合传业务数据。第三串口日志要有时标和级别。调试阶段串口打印的日志加上时间戳和日志级别排查问题时效率翻倍。时间戳可以用MCU的定时器实现日志级别用宏定义控制发布版本关掉低级别日志。第四示波器和逻辑分析仪是串口调试的最佳搭档。很多问题看波形一目了然比在代码里加打印快得多。逻辑分析仪还能解码UART协议直接看到字节内容非常方便。第五串口驱动要写单元测试。环形缓冲区的读写、波特率计算、错误处理逻辑这些都可以在PC上写单元测试验证。把驱动逻辑和硬件操作分离PC上测试纯逻辑部分硬件上测试寄存器操作部分能提前发现很多bug。这个内容后续还可以这样扩展把串口驱动和RTOS结合讲一讲在FreeRTOS下如何设计串口任务和中断的优先级或者深入讲一讲UART的硬件流控在实际项目中的配置和调试再或者做一个串口协议分析工具自动解析自定义协议帧。串口这个主题往深了挖还有很多可以聊的。