☰
UART串口通信从协议到实战:DMA、RS485与Linux驱动避坑指南
2026/10/27 1:03:34 网站建设 项目流程

UART这东西,说起来简单,做起来全是细节。几乎每个嵌入式工程师入行第一个外设就是它,但真到了项目里,能把它用利索的人并不多。我见过太多人串口打印调通了就觉得自己会了,结果一上DMA就丢包,一跑高波特率就出乱码,一接RS485就收发打架。这一期就把UART从协议层到驱动层再到实战踩坑,完整地捋一遍。不管你是刚接触STM32的新手,还是已经在调Linux串口驱动的老手,这里应该都能找到点有用的东西。

1. UART协议的本质:异步到底意味着什么

1.1 起始位、数据位、校验位、停止位的物理含义

UART全称Universal Asynchronous Receiver/Transmitter,关键词就在Asynchronous——异步。异步的意思是通信双方没有共享时钟线,全靠约定的波特率来对时间。这就引出一个核心问题:接收方怎么知道一帧数据从哪开始?答案就是起始位。

发送方在空闲时线路保持高电平(逻辑1),要发数据时先拉低一个bit时间,这就是起始位。接收方检测到这个下降沿,就知道数据要来了,然后按照约定的波特率在每个bit时间的中间点采样。为什么要在中间点采样?因为发送方和接收方的时钟不可能完全一致,总有误差。在起始位下降沿对齐之后,越往后累积误差越大,在bit中间采样能最大化容错空间。一般UART能容忍的时钟误差在2%到3%左右,超过这个范围就会采样错位,表现为乱码或帧错误。

数据位通常是7位或8位,LSB先行。校验位可选奇校验、偶校验、无校验,甚至还有mark/space校验。校验位的作用是检测单bit翻转错误,但它的检错能力很有限——只能检测奇数个bit错误,偶数个bit同时翻转就检测不出来。所以实际项目中,如果对可靠性要求高,一般不用UART自带的校验位,而是在应用层加CRC。

停止位是拉高线路表示一帧结束,可以是1位、1.5位或2位。1.5位只在某些特定场景用,比如老式的电传打字机。现在绝大多数场景用1位停止位就够了。

一帧完整的数据格式是这样的:

空闲(高) | 起始位(低) | D0 | D1 | D2 | D3 | D4 | D5 | D6 | D7 | 校验位 | 停止位(高) | 空闲(高)

1.2 波特率与时钟误差的计算关系

波特率表示每秒传输的符号数,在UART里就是每秒传输的bit数。常见的波特率有9600、19200、38400、57600、115200、230400、460800、921600等。为什么是这些值?因为它们都是标准时钟频率分频能得到比较精确结果的值。

以STM32F103为例,APB2总线频率72MHz,要得到115200的波特率,分频系数是72MHz / 115200 = 625。这个值是整数,所以误差为0。但如果要得到921600,72MHz / 921600 = 78.125,不是整数,就需要用小数波特率发生器来逼近,会有一定误差。

计算误差的公式:

误差 = (实际波特率 - 目标波特率) / 目标波特率 × 100%

一般要求误差小于2%,最好小于1%。如果两个设备的误差方向相反,总误差就是两者之和。比如发送方偏+1.5%,接收方偏-1.5%,总误差就是3%,已经接近临界值了,长时间通信可能出问题。

实操建议:选波特率时优先选能整除时钟源频率的值。比如72MHz时钟下,115200、57600、38400、19200、9600都能整除,误差为0。而像250000这种非标准波特率,误差可能就比较大。

1.3 全双工、半双工与单线半双工的区别

标准UART是全双工的,TX和RX分开,可以同时收发。但有些场景为了省线,会用半双工。半双工又分两种:

一种是RS485那种差分半双工,物理层是两根线(A和B),收发共用一对差分线,需要方向控制引脚(DE/RE)来切换收发状态。这种在工业现场非常常见,抗干扰能力强,传输距离远。

另一种是单线半双工(Single Wire Half Duplex),TX和RX合并到一根线上,靠内部电路实现收发切换。STM32的USART就支持这种模式,配置HDSEL位即可。这种模式省一根线,但收发不能同时进行,而且需要处理好总线冲突。

单线半双工和全双工设备连接时要注意:不能直接把单线半双工的TX/RX短接后接到全双工设备的TX和RX上,因为全双工设备发送时会在RX上收到自己发的数据(回显),需要软件过滤。正确的做法是用一个方向控制电路,或者在全双工侧软件忽略回显。

2. 从寄存器到HAL库:STM32串口驱动的三层理解

2.1 直接操作寄存器的必要性与场景

很多人一上来就用HAL库,觉得寄存器太底层没必要看。但实际项目中,有些场景你必须懂寄存器:

第一,HAL库的中断处理有额外开销。HAL_UART_IRQHandler里面有一大堆状态判断和回调,在高速通信时可能成为瓶颈。我实测过,用HAL库跑921600波特率连续接收,在72MHz的F103上会有丢包,换成直接操作寄存器就稳了。

第二,有些功能HAL库没有暴露出来,比如某些高级的DMA请求映射、智能卡模式、IrDA模式等,需要自己配置寄存器。

第三,调试的时候,如果串口不工作,你得会看寄存器来判断问题出在哪。是TE/RE没使能?是波特率配错了?是TXE/TC标志没等?这些都得看寄存器。

STM32的USART主要寄存器:

寄存器作用关键位
SR状态寄存器TXE, TC, RXNE, ORE, FE, PE
DR数据寄存器写入发送,读取接收
BRR波特率寄存器DIV_Mantissa, DIV_Fraction
CR1控制寄存器1UE, TE, RE, RXNEIE, TCIE
CR2控制寄存器2STOP
CR3控制寄存器3DMAT, DMAR

发送一个字节的流程:

// 等待发送数据寄存器空 while (!(USART1->SR & USART_SR_TXE)); // 写入数据 USART1->DR = data; // 等待发送完成(如果需要) while (!(USART1->SR & USART_SR_TC));

接收一个字节的流程:

// 等待接收数据寄存器非空 while (!(USART1->SR & USART_SR_RXNE)); // 读取数据 data = USART1->DR;

注意:读DR会自动清除RXNE标志,写DR会自动清除TXE标志。这是硬件行为,不需要手动清。

2.2 HAL库的封装逻辑与中断回调机制

HAL库把串口操作封装成了几个层次:

最底层是LL库(Low Layer),基本是寄存器的直接映射,但做了些宏封装。中间是HAL库,提供了HAL_UART_Transmit、HAL_UART_Receive、HAL_UART_Transmit_IT、HAL_UART_Receive_IT、HAL_UART_Transmit_DMA、HAL_UART_Receive_DMA等函数。最上层是回调函数,比如HAL_UART_TxCpltCallback、HAL_UART_RxCpltCallback、HAL_UART_ErrorCallback。

HAL库的中断接收机制是这样的:调用HAL_UART_Receive_IT(&huart1, buffer, length)之后,库会把buffer指针和length存到huart结构体里,然后使能RXNEIE中断。每收到一个字节,进中断,把数据存到buffer,指针后移,length减一。当length减到0时,关闭RXNEIE,调用HAL_UART_RxCpltCallback。

这个机制有个坑:如果你在回调里再次调用HAL_UART_Receive_IT,是可以的,但要注意不要在回调里做太耗时的事情,否则可能丢数据。另外,如果通信过程中出现错误(比如溢出错误ORE),HAL库会调用HAL_UART_ErrorCallback,并且默认会终止接收。你需要在错误回调里重新启动接收。

还有一个更隐蔽的坑:HAL库的HAL_UART_Receive_IT在接收完成之前,如果你再次调用它,会返回HAL_BUSY。很多人不知道这一点,在回调里重复调用导致接收卡死。

2.3 中断、DMA、轮询三种模式的选型依据

这三种模式没有绝对的好坏,关键看场景:

轮询模式最简单,适合调试打印、低速少量数据传输。缺点是CPU全程等待,效率低。如果你只是用来打印调试信息,轮询完全够用。

中断模式适合中等速率、不定长数据的接收。每收一个字节进一次中断,CPU开销和波特率成正比。115200波特率下,每字节约87微秒,中断开销大概几微秒,CPU占用率大概5%到10%。到了921600,每字节只有1微秒多,中断开销就占很大比例了,这时候中断模式就不合适了。

DMA模式适合高速、大量数据的收发。DMA控制器直接在外设和内存之间搬数据,不需要CPU干预。但DMA有个问题:它不知道数据什么时候结束。对于不定长数据,需要用空闲中断(IDLE)来配合。IDLE中断在总线空闲一个字节时间后触发,可以用来判断一帧数据接收完毕。

选型参考表:

场景推荐模式理由
调试打印轮询简单,数据量小
命令交互中断+空闲中断不定长,速率不高
高速数据采集DMA+空闲中断高速,大量数据
固定长度协议DMA长度已知,效率最高
低功耗场景中断可以在无数据时休眠

3. 串口DMA收发:从配置到踩坑的完整链路

3.1 DMA通道选择与请求映射的注意事项

STM32的DMA请求映射是固定的,不是随便选的。以STM32F103为例,USART1的TX是DMA1通道4,RX是DMA1通道5。USART2的TX是DMA1通道7,RX是DMA1通道6。USART3的TX是DMA1通道2,RX是DMA1通道3。

如果你选错了通道,DMA不会报错,但数据就是不动。我见过有人调了半天以为DMA坏了,结果是通道选错了。

配置DMA的时候有几个关键参数:

  • 方向:外设到内存(接收)还是内存到外设(发送)
  • 外设地址:一般是&USARTx->DR
  • 内存地址:你的buffer
  • 数据宽度:外设和内存都要设成8位(Byte)
  • 模式:正常模式(发完就停)还是循环模式(循环覆盖)
  • 优先级:多个DMA通道同时工作时才需要关注
  • 中断:传输完成中断、半传输中断、传输错误中断

对于接收,一般用循环模式,这样DMA会一直往buffer里写,不会停。但循环模式下,你需要知道当前写到哪了,可以通过查询DMA的NDTR寄存器(剩余传输数量)来反推。

对于发送,一般用正常模式,发完就停,在传输完成中断里做后续处理。

3.2 空闲中断配合DMA实现不定长接收

这是串口接收的经典方案,也是我认为最实用的方案。原理是:DMA循环接收,数据不断写入buffer,同时使能串口的IDLE中断。当总线空闲时,触发IDLE中断,在中断里计算收到了多少数据(buffer总长度减去DMA剩余数量),然后处理数据,最后重置DMA。

具体步骤:

第一步,配置DMA为循环模式,内存地址为rx_buffer,长度为BUFFER_SIZE。

第二步,使能串口的IDLE中断:

__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

第三步,在中断服务函数里处理:

void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 计算接收长度 uint16_t recv_len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 处理数据 process_data(rx_buffer, recv_len); // 重置DMA __HAL_DMA_DISABLE(&hdma_usart1_rx); hdma_usart1_rx.Instance->CNDTR = BUFFER_SIZE; __HAL_DMA_ENABLE(&hdma_usart1_rx); } }

这里有个细节:清除IDLE标志的顺序。必须先读SR寄存器,再读DR寄存器,才能清除IDLE标志。HAL库的__HAL_UART_CLEAR_IDLEFLAG宏就是做这个操作的。

还有一个坑:如果在处理数据的时候又有新数据进来,DMA会继续写入,但你的处理函数可能还没执行完。所以处理函数要尽量快,或者用双buffer机制。

3.3 DMA发送的完成判断与缓冲区管理

DMA发送相对简单,但有几个点要注意:

第一,发送完成判断。HAL库提供了HAL_UART_Transmit_DMA,但这个函数是异步的,调用后立即返回。你需要用HAL_UART_GetState查询状态,或者用HAL_UART_TxCpltCallback回调来判断发送完成。

第二,缓冲区生命周期。DMA发送时,数据是从你的buffer里搬的,所以在发送完成之前,这个buffer不能被修改或释放。我见过有人在发送函数里用局部数组,函数返回后数组被回收,DMA搬的就是垃圾数据了。

第三,连续发送。如果前一次DMA发送还没完成,你又调用HAL_UART_Transmit_DMA,会返回HAL_BUSY。你需要等上一次完成后再发,或者用队列缓存待发送数据。

一个实用的发送管理方案:

#define TX_BUFFER_SIZE 512 static uint8_t tx_buffer[TX_BUFFER_SIZE]; static volatile uint16_t tx_head = 0; static volatile uint16_t tx_tail = 0; void uart_send_data(uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint16_t next = (tx_head + 1) % TX_BUFFER_SIZE; if (next != tx_tail) { tx_buffer[tx_head] = data[i]; tx_head = next; } } // 如果DMA空闲,启动发送 if (huart1.gState == HAL_UART_STATE_READY) { start_dma_send(); } } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { if (tx_tail != tx_head) { start_dma_send(); } } }

这个方案用环形缓冲区缓存待发送数据,DMA空闲时自动启动发送,发送完成后检查是否有新数据,有就继续发。

4. 那些年我在串口上踩过的坑

4.1 波特率不匹配导致的乱码排查思路

乱码是串口最常见的问题,没有之一。排查乱码,我一般按这个顺序来:

第一步,确认双方波特率是否一致。这个听起来很基础,但实际项目中经常出问题。比如一边设了115200,另一边设了9600,出来的就是乱码。还有一种情况是两边都设了115200,但一边的时钟源配置错了,实际波特率不是115200。

第二步,确认数据位、停止位、校验位是否一致。8N1是最常见的配置,但有些设备默认是7E1或者8E2。如果数据位不匹配,比如一边8位一边7位,出来的数据会错位。

第三步,用示波器或者逻辑分析仪看波形。测量一个bit的宽度,算出实际波特率。比如测到bit宽度是8.68微秒,那波特率就是1/8.68us ≈ 115200。如果测到的是9.6微秒,那就是104166,明显不对。

第四步,检查时钟配置。STM32的USART时钟来自APB总线,如果系统时钟配置错了,APB频率不对,波特率自然不对。比如你以为是72MHz,实际是64MHz,那波特率就偏了。

第五步,检查硬件。TX和RX是否接反了?地线是否共地?电平是否匹配?TTL电平是3.3V或5V,RS232是负逻辑,RS485是差分,这些不能直接混接。

4.2 串口被占用与驱动安装的常见问题

在Windows下调试串口,经常遇到"串口被占用"或者"找不到串口"的问题。

串口被占用,一般是某个程序打开了串口没释放。怎么查?可以用Process Explorer或者Handle工具,搜索串口名(比如COM3),就能找到是哪个进程占用了。还有一个简单办法:在设备管理器里禁用再启用串口,如果禁用失败,说明确实被占用了。

驱动安装问题,常见于USB转串口芯片。CH340、CP2102、FT232这些芯片都需要装驱动。FT232的驱动在Win10上一般能自动识别,但Win7可能需要手动装。CH340的驱动比较杂,不同版本可能不兼容,建议去官网下最新版。

还有一个坑:有些USB转串口线用的是山寨芯片,VID/PID和正品不一样,驱动装不上。这种线便宜但坑多,建议买正规品牌的。

在Linux下,串口设备一般是/dev/ttyUSB0或者/dev/ttyS0。查看串口设备可以用:

ls /dev/ttyUSB* ls /dev/ttyS* dmesg | grep tty

如果串口被占用,可以用lsof查看:

lsof /dev/ttyUSB0

4.3 高速波特率下的丢包与溢出错误处理

高速波特率下,丢包是常见问题。原因主要有几个:

第一,中断响应不及时。波特率越高,每个字节的时间越短,如果中断优先级低或者中断处理函数太长,就会来不及处理,导致溢出(ORE错误)。

第二,DMA配置不当。DMA优先级低,或者DMA通道被其他外设占用,导致搬运不及时。

第三,缓冲区太小。接收缓冲区太小,数据来得太快,来不及处理就覆盖了。

第四,硬件问题。线太长、干扰大、电平不匹配,导致误码。

解决溢出错误,关键是及时清除ORE标志并重新使能接收。在HAL库中,ORE错误会触发HAL_UART_ErrorCallback,你需要在回调里做:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->ErrorCode & HAL_UART_ERROR_ORE) { // 清除ORE标志 __HAL_UART_CLEAR_OREFLAG(huart); // 重新启动接收 HAL_UART_Receive_DMA(huart, rx_buffer, BUFFER_SIZE); } }

注意:清除ORE标志需要先读SR再读DR。HAL库的__HAL_UART_CLEAR_OREFLAG宏就是做这个的。

还有一个经验:如果波特率超过500k,建议用DMA+空闲中断方案,并且把DMA优先级设到最高,串口中断优先级也设高一点。另外,缓冲区至少要是最大帧长的两倍,留够处理时间。

4.4 RS485半双工收发的方向控制时序

RS485半双工需要控制DE/RE引脚来切换收发方向。这个切换时序很关键,搞不好就会丢数据或者总线冲突。

发送时的正确时序:

  1. 拉高DE(使能发送)
  2. 等待一小段时间(让收发器稳定)
  3. 发送数据
  4. 等待最后一个字节发送完成(TC标志置位)
  5. 拉低DE(切换回接收)

关键是第4步:必须等TC标志置位,而不是TXE。TXE只表示数据寄存器空了,但最后一个字节可能还在移位寄存器里没发完。如果这时候就拉低DE,最后一个字节会发不出去。

接收时的时序:

  1. 确保DE为低(接收模式)
  2. 等待数据
  3. 接收完成后处理

如果收发切换频繁,还要考虑收发器的切换时间。有些收发器切换需要几百纳秒到几微秒,太快了会导致数据丢失。

一个实用的RS485发送函数:

void rs485_send(uint8_t *data, uint16_t len) { // 切换为发送 HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_SET); // 等待收发器稳定 for (volatile int i = 0; i < 100; i++); // 发送数据 HAL_UART_Transmit(&huart1, data, len, 1000); // 等待发送完成 while (!(USART1->SR & USART_SR_TC)); // 切换为接收 HAL_GPIO_WritePin(DE_GPIO_Port, DE_Pin, GPIO_PIN_RESET); }

5. Linux下的串口编程与调试

5.1 termios配置的完整流程

Linux下串口编程的核心是termios结构体。配置流程一般是:

第一步,打开串口:

int fd = open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NDELAY);

O_NOCTTY表示不把串口作为控制终端,O_NDELAY表示非阻塞模式。

第二步,获取当前配置:

struct termios options; tcgetattr(fd, &options);

第三步,设置波特率:

cfsetispeed(&options, B115200); cfsetospeed(&options, B115200);

第四步,设置数据位、校验位、停止位:

options.c_cflag &= ~CSIZE; options.c_cflag |= CS8; // 8位数据 options.c_cflag &= ~PARENB; // 无校验 options.c_cflag &= ~CSTOPB; // 1位停止位

第五步,设置流控:

options.c_cflag &= ~CRTSCTS; // 无硬件流控 options.c_iflag &= ~(IXON | IXOFF | IXANY); // 无软件流控

第六步,设置原始模式:

options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); options.c_oflag &= ~OPOST;

第七步,设置超时:

options.c_cc[VMIN] = 0; options.c_cc[VTIME] = 10; // 1秒超时

第八步,应用配置:

tcsetattr(fd, TCSANOW, &options);

5.2 串口数据丢失的排查与解决

Linux下串口丢数据,常见原因:

第一,缓冲区溢出。Linux内核有串口缓冲区,如果应用程序读取不及时,缓冲区满了就会丢数据。可以用ioctl查看缓冲区状态:

int bytes; ioctl(fd, FIONREAD, &bytes);

第二,VMIN和VTIME设置不当。VMIN=0, VTIME=0表示非阻塞读,立即返回。VMIN=0, VTIME>0表示等待VTIME个0.1秒,有数据就返回。VMIN>0, VTIME=0表示阻塞读,直到读到VMIN个字节。根据你的协议选择合适的配置。

第三,串口参数不匹配。波特率、数据位、校验位、停止位任何一个不匹配都会导致数据错误。

第四,硬件流控问题。如果双方都开了硬件流控但没接RTS/CTS线,数据会发不出去。

第五,USB转串口芯片的延迟。有些USB转串口芯片有内部缓冲,会延迟数据上报。可以尝试设置低延迟模式:

#include <linux/serial.h> struct serial_struct serinfo; ioctl(fd, TIOCGSERIAL, &serinfo); serinfo.flags |= ASYNC_LOW_LATENCY; ioctl(fd, TIOCSSERIAL, &serinfo);

5.3 用select/poll实现多串口并发处理

当需要同时处理多个串口时,用select或poll可以避免阻塞在单个串口上。

select的用法:

fd_set readfds; struct timeval tv; while (1) { FD_ZERO(&readfds); FD_SET(fd1, &readfds); FD_SET(fd2, &readfds); tv.tv_sec = 1; tv.tv_usec = 0; int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv); if (ret > 0) { if (FD_ISSET(fd1, &readfds)) { read(fd1, buf, sizeof(buf)); } if (FD_ISSET(fd2, &readfds)) { read(fd2, buf, sizeof(buf)); } } }

poll的用法类似,但更灵活,没有fd数量限制:

struct pollfd fds[2]; fds[0].fd = fd1; fds[0].events = POLLIN; fds[1].fd = fd2; fds[1].events = POLLIN; while (1) { int ret = poll(fds, 2, 1000); if (ret > 0) { if (fds[0].revents & POLLIN) { read(fd1, buf, sizeof(buf)); } if (fds[1].revents & POLLIN) { read(fd2, buf, sizeof(buf)); } } }

经验之谈:如果串口数量少(少于10个),select和poll都够用。如果串口数量多或者需要更高性能,可以考虑epoll。但在嵌入式Linux上,串口数量一般不多,poll是最常用的选择。

6. 串口调试的实用工具与技巧

6.1 逻辑分析仪抓包分析串口波形

逻辑分析仪是调串口的利器。抓包之后,你可以直观地看到起始位、数据位、停止位,测量bit宽度,判断波特率是否正确。

用逻辑分析仪分析串口,几个关键点:

第一,设置正确的采样率。采样率至少是波特率的10倍以上,最好20倍。比如115200波特率,采样率至少2MHz,推荐4MHz或更高。

第二,设置正确的触发条件。可以设置下降沿触发(起始位),或者设置协议解码器自动解码。

第三,看波形质量。如果上升沿或下降沿不够陡峭,说明驱动能力不足或者线太长。如果波形有振铃,说明阻抗不匹配。

第四,测量bit宽度。在波形上测量一个bit的时间,算出实际波特率,和设定值对比。

6.2 串口调试助手的高级用法

串口调试助手不只是用来收发数据的,还有很多高级用法:

定时发送:可以设置每隔一定时间自动发送一帧数据,用于压力测试。

多条发送:可以预设多条命令,按顺序发送,用于自动化测试。

数据统计:可以统计发送和接收的字节数,用于评估通信质量。

HEX显示:可以以十六进制显示数据,方便分析二进制协议。

校验计算:有些调试助手支持自动计算CRC、校验和等,方便构造测试数据。

脚本功能:高级的调试助手支持脚本,可以自动响应特定数据,模拟设备行为。

6.3 常见USB转串口芯片的选型对比

芯片厂商最高波特率驱动特点
CH340沁恒2Mbps需安装便宜,兼容性好
CP2102Silicon Labs1Mbps需安装稳定,功耗低
FT232FTDI3Mbps需安装贵,但最稳定
PL2303Prolific1.2Mbps需安装老芯片,Win10兼容性差
CH9102沁恒4Mbps需安装CH340升级版,高速

选型建议:如果只是调试打印,CH340够用。如果需要高速或者高稳定性,选FT232。如果要用在工业环境,建议选带隔离的型号。

注意:市面上有很多山寨的FT232和PL2303,价格便宜但驱动装不上或者不稳定。买的时候注意辨别,正规渠道购买。

7. 串口驱动的进阶话题

7.1 串口协议层的封装设计

裸串口只能收发字节,实际项目中需要在上面封装协议层。一个好的协议层应该解决这些问题:

帧同步:怎么判断一帧的开始和结束?常见方案有帧头帧尾、长度字段、超时判断等。

数据完整性:怎么保证数据没错?常见方案有校验和、CRC、哈希等。

流量控制:怎么防止缓冲区溢出?常见方案有ACK/NACK、滑动窗口等。

错误恢复:出错后怎么恢复?常见方案有重传、超时重置等。

一个简单的协议帧格式示例:

| 帧头(2B) | 长度(1B) | 命令(1B) | 数据(NB) | CRC(2B) | 帧尾(1B) |

帧头用固定值比如0xAA55,长度表示数据长度,命令表示功能码,数据是有效载荷,CRC校验,帧尾用固定值比如0x0D。

解析的时候,用一个状态机逐字节处理:

typedef enum { STATE_HEADER1, STATE_HEADER2, STATE_LENGTH, STATE_CMD, STATE_DATA, STATE_CRC1, STATE_CRC2, STATE_TAIL } parse_state_t; void parse_byte(uint8_t byte) { static parse_state_t state = STATE_HEADER1; static uint8_t data[256]; static uint8_t data_len = 0; static uint8_t data_idx = 0; switch (state) { case STATE_HEADER1: if (byte == 0xAA) state = STATE_HEADER2; break; case STATE_HEADER2: if (byte == 0x55) state = STATE_LENGTH; else state = STATE_HEADER1; break; case STATE_LENGTH: data_len = byte; data_idx = 0; state = STATE_CMD; break; // ... 其他状态 } }

7.2 串口与RTOS的任务划分

在RTOS环境下,串口驱动一般这样划分任务:

接收任务:负责从串口读取数据,解析协议,分发到不同的处理任务。

发送任务:负责从发送队列取数据,通过串口发送。

协议处理任务:负责处理具体的命令,执行业务逻辑。

这样划分的好处是解耦,接收和发送互不阻塞,协议处理不影响通信。

用FreeRTOS的话,可以用队列来传递数据:

QueueHandle_t uart_rx_queue; QueueHandle_t uart_tx_queue; void uart_rx_task(void *pvParameters) { uint8_t data; while (1) { if (xQueueReceive(uart_rx_queue, &data, portMAX_DELAY) == pdTRUE) { // 处理数据 } } } void uart_tx_task(void *pvParameters) { uint8_t data; while (1) { if (xQueueReceive(uart_tx_queue, &data, portMAX_DELAY) == pdTRUE) { // 发送数据 } } }

中断服务函数里只做最少的操作:把数据放入队列,然后尽快退出。

7.3 低功耗场景下的串口唤醒设计

在电池供电的设备上,串口不能一直开着,否则功耗太高。常见的低功耗串口设计:

方案一:串口休眠,靠外部中断唤醒。串口的RX引脚配置为外部中断,当有数据来时,先触发外部中断,唤醒MCU,再切换为串口模式接收数据。

方案二:串口保持接收,但MCU休眠。串口配置为接收中断,MCU进入低功耗模式,当收到数据时,串口中断唤醒MCU。

方案三:串口完全关闭,靠定时唤醒。MCU定时唤醒,打开串口,检查是否有数据,没有就继续休眠。

方案一最省电,但需要额外的引脚和电路。方案二最简单,但串口本身有功耗。方案三适合对实时性要求不高的场景。

实操经验:STM32的串口在停止模式下,如果使能了接收中断,是可以在收到数据时唤醒MCU的。但要注意,唤醒后需要重新配置串口,因为停止模式下串口时钟可能被关闭了。

8. 写在最后

串口这东西,入门容易精通难。协议本身不复杂,但实际项目中会遇到各种各样的问题:硬件电平不匹配、波特率误差、DMA配置错误、中断优先级冲突、缓冲区溢出、RS485方向控制时序不对……每一个坑我都踩过,每一个坑都让我对串口的理解更深一层。

如果你现在正在调串口,遇到问题不要慌,按我上面说的排查思路一步步来:先确认硬件连接,再确认配置参数,然后用工具抓波形,最后查代码逻辑。大部分问题都能定位到。

还有一点体会:不要过度依赖HAL库。HAL库确实方便,但它隐藏了太多细节。当你遇到HAL库解决不了的问题时,还是得回到寄存器层面。所以,花点时间看看参考手册里的USART章节,理解每个寄存器的含义,绝对值得。

最后分享一个我常用的调试技巧:在串口初始化之后,先发一个固定的字符串,比如"UART OK\r\n"。如果串口调试助手能收到这个字符串,说明基本配置没问题。如果收不到,或者收到乱码,那就从时钟配置开始查。这个简单的自检步骤,能帮你快速判断问题出在初始化阶段还是后续通信阶段。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询