简介:本资源是面向嵌入式开发工程师与DSP初学者的TMS320C6747平台UART通信底层驱动实践包,聚焦串口初始化、收发控制与中断处理等核心功能实现,解决实际项目中调试输出、传感器通信及多机交互等典型需求。压缩包共16个文件,含关键源码uart.c、工程配置文件(.pjt、.paf2)、编译输出(.out、.map、.sbl)、目标文件(.obj)、链接配置(.lkf)及日志(.log),其中C源文件与映射文件对理解寄存器配置、波特率计算及FIFO操作逻辑尤为关键。资源大小82KB,结构紧凑,便于快速集成到CCS开发环境中。目前已有123人学习下载,读者可直接获取完整可编译工程,掌握UARTCR/UARTBR寄存器配置方法、uart_init()与uart_isr()等函数实现细节,并通过main.c与uart_test.obj验证收发流程,为TI C6000系列DSP串口开发提供即用型参考范例。 接手过一个项目,压缩包名就叫uart.rar,里面躺着一个孤零零的uart.c,没有 README,没有头文件,连注释都少得可怜。这种场景在嵌入式开发里太常见了——老同事离职留下的代码、网上扒下来的参考工程、开发板厂商提供的驱动例程,往往都是以这种极其朴素的面貌出现。你明明知道这是一个串口通信的C语言源文件,却不知道它适配哪颗芯片、用的什么编译器、能不能直接在 STM32 上跑通。这篇文章我就以这份典型的uart.c为引子,把 UART 串口通信从代码结构、协议原理、移植调试到实战排查的整套经验拆开揉碎了讲清楚。
不管你是刚开始接触单片机的小白,还是已经在用串口调试各种外设的进阶开发者,这篇文章的核心价值在于:拿到任何一个来历不明的 UART 驱动代码,你能自己判断它能不能用,怎么改能用,出了问题从哪里查。
1. 从拿到uart.c开始:先摸清代码的家底再谈移植
1.1 一份典型UART驱动源码的骨架长什么样
在GitHub上随手一搜"uart.c",或者打开某个芯片厂商的例程包,你会发现这类文件的代码结构高度相似。剥掉外壳看本质,一份能用的UART驱动无非由四大块组成:初始化函数、发送函数、接收函数和中断服务函数。当然,工程化程度高的代码还会带缓冲区管理、超时处理、环形队列等进阶模块,但核心永远是这四件事。
我建议你在拿到任何uart.c时,第一件事不是看每个函数怎么实现,而是先画出一个函数清单。比如这份文件里可能有个UART_Init(),内部调用了GPIO_Init()和USART_Init(),说明它基于标准外设库;如果看到HAL_UART_Init(),那它就是 STM32CubeMX 风格的 HAL 库代码;如果看到直接操作UART_REG->CTRL之类的寄存器地址,那大概率是寄存器级操作,或者是 NXP、Microchip 等厂商的寄存器模型。搞清楚代码基于什么平台和库,才能决定后续的移植工作量有多大。
还有一个常被忽略的细节:看文件头部的#include语句。如果包含的是stm32f1xx_hal.h,那这是 STM32F1 系列;如果是imxrt.h,那就是 NXP i.MX RT 系列。头文件往往比函数体本身更能说明问题,因为函数可以跨平台抄,头文件路径几乎骗不了人。
1.2 从函数命名风格读出代码的"性格"
写嵌入式代码的人都有自己的习惯,函数命名就是最直观的性格标签。有人喜欢uart_send_char()这种全小写下划线风格,有人喜欢UART_SendChar()这种大驼峰风格,还有人直接写SendChar()不带任何前缀。
我自己的经验是:带模块前缀的命名方式(比如UART_开头)通常意味着作者有良好的模块化意识,代码的可读性和可维护性不会太差;裸函数名的代码往往是从某个大文件里乱剪出来的,用起来要格外小心。还有一类代码会在函数名前加HAL_或LL_,这已经是ST官方库的命名规范了,基本能确定是STM32生态。
另外,看你的uart.c里有没有static修饰的内部函数,也能判断作者的封装水平。如果所有函数都是全局可见的,说明这个驱动和业务逻辑是耦合在一起的,移植的时候要把这些耦合点全部解开。
1.3 为什么很多UART驱动代码长得很像但移植时五花八门
表面上看,UART 的驱动逻辑各厂家大差不差,无非是配置引脚、设置波特率、使能中断、收发数据。但真正移植起来,各家的差异点会让你头皮发麻。
首先是时钟树的差异。STM32F103 的 USART1 挂载在 APB2 总线上,USART2 和 USART3 挂在 APB1 上,两条总线的主频可能不一样,波特率寄存器 BRR 的计算结果就完全不同。其次是引脚复用的差异,同样是 UART1,F1 系列的 TX/RX 只能固定在 PA9/PA10,而 F4 系列可以通过 AFIO 重映射到 PB6/PB7。如果原代码是针对 F1 写的,你要移植到 F4 上,引脚配置这块就得重写。
再看中断处理机制,有些芯片的 UART 中断是一个总入口,比如USART1_IRQHandler(),进中断后要通过__HAL_UART_GET_FLAG()逐个判断是发送完成、接收完成还是错误中断;而有些芯片(比如某些国产MCU)会为 TX、RX、错误分别提供独立的中断入口。这些差异决定了代码结构的不同,也是移植时最容易踩坑的地方。
所以我给你一个非常实用的建议:拿到uart.c的第一时间,先别急着编译,花十分钟做一次"代码家底调查",把芯片平台、库版本、时钟树配置、引脚定义、中断入口这五件事搞清楚,能帮你省下后面一整天的调试时间。
2. 串口初始化里的门道:波特率、帧结构、引脚配置缺一不可
2.1 波特率计算:115200这个数字背后的数学题
很多入门教程在讲串口初始化时,都是直接把115200这个数字填进去,然后告诉读者"这是常用的波特率"——但如果你理解了波特率寄存器的工作原理,调起参来就能有的放矢。
以 STM32 的 USART 为例,波特率寄存器 BRR 实际上存储的是PCLK / 波特率这个比值。假设你的 APB2 总线时钟是 72MHz,想得到 115200 的波特率,那 BRR 的值就是72000000 / 115200 = 625,近似为 625。这个 625 不是随便算的,它是一个 16 位的定点数,高 12 位是整数部分,低 4 位是小数部分。625 对应的十六进制是0x271,把它左移 4 位得到0x2710,配置到 BRR 寄存器里就能得到最接近 115200 的实际波特率。
这里有一个关键点:如果系统时钟不是波特率的整数倍关系,实际波特率会产生误差。比如 PCLK 是 50MHz,算出的 BRR 值带小数,四舍五入后误差可能在百分之一左右。串口通信对波特率误差的要求通常是 ±2%,所以大多数情况下没问题,但如果你用的是 1,000,000bps 之类的高速波特率,或者通信双方有一方的晶振精度不够,就可能出现乱码。
所以,在移植uart.c时,一定要核对你板子的时钟主频是多少。代码里RCC_Configuration()如果设置的是72MHz,而你的板子外部晶振是8MHz,那系统时钟会被倍频到 72MHz,BRR 值不用变;如果你的外部晶振是25MHz,倍频关系不一样,BRR 值就要重新算。
2.2 帧结构里的三个参数:数据位、停止位、校验位为什么不能随便配
串口通信的帧结构相当于一个信封,双方得约定好信封上哪里是地址、哪里是内容,才能正确拆信。数据位、停止位、校验位这三个参数,就是这个约定的具体内容。
数据位一般有 8 位和 9 位两种选择。8 位数据位最常见,适合传输 ASCII 码或单字节数据;9 位数据位通常用于多机通信,第九位作为地址/数据的标志位,比如 9 位模式下,当第九位为 1 时表示地址帧,为 0 时表示数据帧。如果你只是做常规的传感器数据采集或和 PC 通信,8 位数据位就够用了。
停止位有 0.5、1、1.5、2 位四种选项,STM32 的 USART 支持 1 位和 2 位停止位为主。停止位越多,帧间隔越长,通信效率略降,但容错性更好。实际项目中绝大多数用的是 1 位停止位,2 位停止位常见于对时序要求比较苛刻或长线传输的场景。
校验位有奇校验、偶校验和无校验三种。奇偶校验的作用是检测单比特错误,但对双比特错误无能为力。在工业现场,如果线路干扰比较大,建议使用奇校验或偶校验;如果通信双方都不支持校验,那就两端都配成无校验,保证一致。
下面这张表是我项目里常用的配置组合,可以直接参考:
| 使用场景 | 数据位 | 停止位 | 校验位 |
|---|---|---|---|
| PC 串口调试助手通信 | 8 | 1 | None |
| Modbus RTU 协议 | 8 | 1 | None(或 Even) |
| 多机地址帧通信 | 9 | 1 | None(第9位作标志) |
| 工业长线抗干扰传输 | 8 | 1 | Even |
| 低速传感器采样 | 8 | 2 | None |
2.3 GPIO复用配置:为什么TX要配置成推挽复用,RX要配置成浮空输入或上拉输入
UART 初始化时最容易被忽略的就是 GPIO 配置。很多新手在uart.c里看到 GPIO 配置代码只是一扫而过,觉得"反正配个引脚嘛,差不多就行",但引脚模式配错了,串口是死活调不通的。
TX 引脚要配置为复用推挽输出(AF_PP),目的是让 USART 外设控制这个引脚的电平输出。如果配成普通推挽输出,USART 的发送数据就无法通过这个引脚送出去;如果配成开漏输出,那需要外部上拉电阻才能输出高电平,否则波形就不对。
RX 引脚要配置为浮空输入或上拉输入。浮空输入的意思是引脚电平完全由外部决定,适合 RX 和对方的 TX 直接相连的场景;上拉输入则是在内部把引脚拉高,适合线缆较长、容易出现电平漂移的场景。如果你的板子上 RX 引脚已经外部接了上拉电阻,那用浮空输入就够了;如果没接,建议用上拉输入,避免悬空时电平不稳定导致收到乱码。
还有一个细节:引脚复用功能的编号。STM32F4 系列的每个引脚有多个可选复用功能,比如 PA9 可以做 USART1_TX(AF7),也可以做 TIM1_CH2(AF1)。配置时必须通过GPIO_PinAFConfig()指定正确的 AF 编号,否则引脚功能不对,数据照样出不去。这个坑在从 F1 移植到 F4 时特别常见,因为 F1 的引脚复用是通过重映射寄存器控制的,机制完全不同。
2.4 初始化顺序也很重要:先开时钟,再配GPIO,最后配串口
看uart.c里的初始化函数,初始化顺序一般很有讲究,打乱的后果往往是"代码没问题但就是不出数据"。
正确的顺序是:先使能对应 GPIO 端口和 USART 外设的时钟,然后配置 GPIO 引脚模式和复用功能,接着配置 USART 的参数(波特率、字长、停止位、校验位、流控),再使能 USART 外设,最后使能相关中断(如果需要的话)。
为什么必须先开时钟再配寄存器?因为寄存器的物理本质是触发器,没有时钟输入时,写寄存器是无效的。如果你没开 GPIO 的时钟就去配CRL/CRH寄存器,数据根本写不进去,寄存器读回来还是复位值。
我遇到过不止一次的问题是:初始化代码里RCC_APB2PeriphClockCmd()使能的时钟和实际用的 USART 外设不匹配。比如配的是 USART1 但使能的是 USART2 的时钟,这种情况下代码编译不会报错,运行也不会死机,但串口完全没反应。这种问题排查起来很痛苦,因为方向错了的话查半天都找不到原因。
3. 收发数据的实现层次:轮询、中断、DMA怎么选才对
3.1 轮询模式:最简单的收发方式,但效率最低
轮询模式指 CPU 反复去检查发送数据寄存器是否为空、接收数据寄存器是否有新数据。这种方式的代码最简单,但代价是 CPU 被长时间占用。
发送的逻辑大致是:写一个字节到DR寄存器,然后循环等待TXE(发送数据寄存器空)标志位置位,再写下一个字节。接收的逻辑也类似:循环等待RXNE(接收数据寄存器非空)标志位置位,然后读DR寄存器。
轮询模式适合什么场景呢?适合发送固定较短的指令、接收响应前先做延时等待、系统里没有其他高实时性任务的情况。比如你要通过串口控制一个舵机,每秒钟只发一次角度指令,那轮询完全够用。
但如果在轮询接收的同时系统里还有别的任务要跑,就麻烦了。while (!(USART1->SR & USART_FLAG_RXNE));这一句如果对方一直不发数据,CPU 就会死等在这里,整个系统卡死。所以轮询接收通常配合超时机制一起使用,比如设置一个计数器,超过一定次数没等到数据就退出等待。
3.2 中断模式:收发不阻塞CPU,但中断优先级和标志位坑不少
中断模式是项目里最常用的收发方式。基本原理是:使能串口的接收中断或发送完成中断,当有数据收到或发送完成时,硬件触发中断,CPU 跳出主程序去执行中断服务函数(ISR),处理完再回到主程序继续干活。
接收中断的典型写法是:使能USART_IT_RXNE中断,在USARTx_IRQHandler()里读取DR寄存器,把数据存到缓冲区。这里有一个非常关键的细节:读DR寄存器这个动作本身就清除了RXNE标志位。如果你在中断里没有及时读数据,另一个字节又来了,新数据就会覆盖旧数据,造成丢字节。
发送中断的写法比较反直觉。很多人以为发送中断是"发送一个字节后触发",用来通知 CPU"可以发下一个了"。这种理解不算错但有歧义。更准确地说,TXE中断是"发送数据寄存器空了"触发,如果你在中断里不写新的数据,这个中断会一直触发,导致 CPU 反复进中断出不来。所以常见的做法是:发第一个字节前使能TXE中断,在中断里写完最后一个字节后立刻失能TXE中断,避免无穷中断。
中断优先级也是个容易翻车的地方。如果串口中断优先级设得太低,而主循环里有其他中断频繁抢占,串口数据就可能粘包或丢失;如果设得太高,又可能影响其他实时性要求高的中断响应。我常用的策略是:接收中断和定时器中断用相同的优先级或略低一点,保持主程序和关键外设的响应平衡。
3.3 环形缓冲区:解决串口粘包和丢字节的实用方案
在实际项目中,只用中断存一个字节是远远不够的。因为你的主循环可能有其他耗时操作,比如处理传感器数组、刷新 OLED 屏幕、执行 PID 计算,这些操作期间如果串口持续来数据,单字节缓冲根本装不下。这时候就需要一个环形缓冲区(Ring Buffer)。
环形缓冲区的思路很简单:用一个固定大小的数组,配两个索引,一个写索引(存到哪儿了)和一个读索引(读到哪儿了)。写操作只动写索引,读操作只动读索引,当写索引追上读索引时缓冲区满,当读索引追上写索引时缓冲区空。这种结构天然适合"一边中断写入、一边主循环读出"的生产者-消费者模型。
用 C 语言实现一个环形缓冲区并不复杂,核心就几个函数:初始化时把读索引和写索引都清零;写数据时先判断缓冲区是否满;读数据时先判断缓冲区是否空。但有个细节容易出问题:索引的环绕处理。比如缓冲区大小是 256,索引范围 0~255,当写索引到 255 后下一个位置应该是 0。如果直接用index++,索引会溢出变成 256,越界访问数组。所以代码里通常写index = (index + 1) % BUFFER_SIZE;,或者更高效的写法index = (index + 1) & (BUFFER_SIZE - 1);,前提是缓冲区大小是 2 的幂。
下面是我项目里一个典型的环形缓冲区实现框架,你可以参考:
#define UART_RX_BUF_SIZE 256 typedef struct { uint8_t buffer[UART_RX_BUF_SIZE]; volatile uint16_t head; // 写索引 volatile uint16_t tail; // 读索引 } ring_buffer_t; void rb_init(ring_buffer_t *rb) { rb->head = 0; rb->tail = 0; } uint8_t rb_is_empty(ring_buffer_t *rb) { return (rb->head == rb->tail); } uint8_t rb_is_full(ring_buffer_t *rb) { return ((rb->head + 1) % UART_RX_BUF_SIZE) == rb->tail; } uint8_t rb_write(ring_buffer_t *rb, uint8_t data) { if (rb_is_full(rb)) { return 0; // 缓冲区满,丢弃 } rb->buffer[rb->head] = data; rb->head = (rb->head + 1) % UART_RX_BUF_SIZE; return 1; } uint8_t rb_read(ring_buffer_t *rb, uint8_t *data) { if (rb_is_empty(rb)) { return 0; } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % UART_RX_BUF_SIZE; return 1; }3.4 DMA模式:高速收发不求人,但配置复杂度翻倍
如果你要做的是大容量、高速率的数据传输,比如通过串口把 ADC 采样数据源源不断传到上位机,或者从 WiFi 模块接收几百个字节的数据包,轮询和中断就有点力不从心了。这时候就该 DMA(直接内存访问)出马了。
DMA 的本质是让数据在串口外设和内存之间直接搬移,搬移过程完全不需要 CPU 干预。你只需要把源地址、目的地址、传输长度配置好,启动传输后 CPU 就可以去干别的事了。传输完成后 DMA 控制器会产生一个传输完成中断,通知 CPU"数据已经搬完了"。
STM32 的串口 DMA 有两种常用模式:DMA 发送和 DMA 接收。DMA 发送比较简单,把数据数组的首地址作为源地址,串口的数据寄存器地址作为目的地址,传输长度设为字节数,启动后就等着传输完成中断。DMA 接收稍微复杂一点,因为接收侧你不知道对方什么时候发、发多少。常用的方案是空闲中断(IDLE Interrupt)+ DMA:DMA 一直在收,当串口检测到总线上空闲超过一个字节时间时,触发空闲中断,在中断里根据 DMA 当前计数值判断本次接收了多少字节。
DMA 模式最大的坑在于配置顺序。如果 DMA 还没配置好就先使能了串口接收,数据可能会丢失。正确顺序是先配置 DMA 通道、使能 DMA,再使能串口接收 DMA 请求。
4. 把别人写的uart.c移植到目标板,最容易翻车的五个环节
4.1 时钟树配置不一致:波特率乱套的第一大元凶
前面提到过,STM32 的 BRR 寄存器值取决于外设时钟。如果你从一份为 F103 写的uart.c移植到 F407 上,F103 用的是 72MHz 主频,F407 如果还是期望按 72MHz 算波特率,但实际系统时钟被配置成了 168MHz,那实际波特率就会变成 115200 的 168/72 倍,约 268800,接收方全部乱码。
这种问题最坑的地方在于:初始化代码看起来完全正常,寄存器配置也是按常规套路写的,但就是通信不上。排查思路是:先看系统时钟初始化代码(通常是SystemInit()或RCC_Configuration()),确认主频到底是多少,再回头核对uart.c里计算 BRR 的基准时钟是否和实际主频一致。
我建议你在移植后第一件事就是跑一个最简单的回声测试:在串口助手里发一个字符,看板子能不能原样回传。如果回传乱码,就先检查时钟;如果完全没反应,再检查 GPIO 配置和引脚连接。
4.2 中断入口名称不匹配:编译器报了错还算好,最怕不报错
不同系列芯片的中断服务函数名称是固定的,写错一个字符,编译链接都会报错。这还算相对好排查的。更隐蔽的问题是:你在 Keil 或 IAR 的启动文件里没开某个中断,或者中断向量表里没写对,编译能过、程序能跑,但一旦这个中断触发,程序就跳飞。
从 F1 移植到 F4 时,中断服务函数的名称可能相同(都是USART1_IRQHandler),但中断向量表的位置和启动文件不同。如果你用的是原来的启动文件,而芯片换了系列,中断向量就完全错位了。所以移植时一定要带上目标芯片对应的启动文件,不要沿用旧工程的启动文件。
4.3 引脚复用功能配置遗漏:功能写在寄存器里,不在空气里
F4 系列和 L4 系列在引脚复用配置上比 F1 复杂得多。F1 只需要配置 GPIO 的 CRL/CRH 寄存器就能完成复用,而 F4 必须额外调用GPIO_PinAFConfig()给引脚指定具体的复用功能编号。
举个例子,如果你要把 USART1 的 TX(PA9)配置为复用功能,不仅要把 PA9 的模式设置为复用推挽输出,还要调用GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1);这条语句。如果漏了这一步,GPIO 的初始状态可能是模拟输入或浮空输入,串口完全没有输出波形。
这个错误非常隐蔽,因为代码能编译通过,MCU 也正常跑,但示波器看 PA9 引脚就是没有信号。排查时要特别留意:你用的芯片系列需不需要额外配置 AF 编号。
4.4 发送和接收的电气特性不匹配:3.3V和5V之间的电平博弈
嵌入式系统里常见的电压轨是 3.3V 和 5V 两种。如果你的 MCU 是 3.3V 供电,而你要通信的模块(比如老旧的 GPS 模块或工业传感器)是 5V 逻辑,直接连接 RX/TX 可能会损坏 3.3V 器件。
通常的解决办法是使用电平转换芯片(比如 MAX3232、SP3232)或者电阻分压。如果是单向通信(MCU 发、模块收),可以用电阻分压把 3.3V 降到模块能接受的电平;如果是双向通信,建议用真正意义上的电平转换芯片,不要为了省成本直接硬接。
另外还要注意共地问题:两个设备之间的 GND 必须连在一起,否则 RX/TX 之间的电压差没有参考基准,信号电平完全不可测。经常有人调串口调不通,最后发现是 GND 没连或者接触不良。
4.5 USB转串口芯片驱动不匹配:电脑上根本识别不到串口
现在很多开发板调试串口是通过板上自带的 USB 转串口芯片实现的,常见的芯片有 CH340、CP2102、FT232R、FT231X 等。不同芯片在电脑上需要不同的驱动程序。
比如 FT232R 和 FT231X 都用 FTDI 官方的 VCP 驱动,Windows 系统一般会自动安装,但如果系统更新策略比较严格,可能装不上,你就需要去官网手动下载驱动。CP2102 用的是 Silicon Labs 的驱动包,CH340 则需要沁恒的驱动。很多"串口打不开"的问题,其实不是代码问题,而是电脑的设备管理器里端口根本没出现。
判断方法很简单:插上 USB 后打开设备管理器,看"端口(COM和LPT)"下面有没有新设备。如果出现黄色感叹号,说明驱动不对;如果压根没出现,可能是 USB 线质量问题,有些线只能充电不能传数据,这个坑也很大。
5. 调UART通信时的故障排查顺序:从硬件到软件,一步步缩小范围
5.1 第一步永远先量波形:示波器或逻辑分析仪能告诉你 80% 的真相
我调串口这么多年,最大的心得就是:不要一上来就怀疑代码。优先用工具确认物理层有没有问题。
最简单的方法是拿示波器量 TX 引脚的静态电平。正常情况下,串口空闲时 TX 应为高电平。如果 TX 是低电平,说明 MCU 没正常工作或者引脚配置不对。然后让代码循环发送0x55(二进制01010101,波形是方波),在示波器上应该能看到清晰的高低电平翻转,并且高电平时间宽度对应的波特率应该接近设定值。
如果没有示波器,逻辑分析仪是更廉价的选择。几十块钱的逻辑分析仪配合免费的 PulseView 或 Saleae Logic 软件,就能直接解码 UART 协议,把发送的字节内容和实际波特率清清楚楚摆在你面前。这比肉眼盯着串口助手看乱码要高效得多。
5.2 第二步用串口助手做回路测试:先自检,再联调
硬件波形没问题后,下一步是把 PC 和板子连起来做测试。最简单的是回路测试:把板子的 TX 和 RX 短接,然后在代码里写一个"收到字节就回传"的逻辑,再用串口助手发数据。如果发什么回什么,说明 MCU 的 UART 收发链路是完全通的。
这个测试能排除一堆问题:代码里 UART 收发逻辑是否正确、USB 转串口芯片是否正常工作、串口助手配置是否匹配。一次回路测试通过,说明 MCU 这边基本没问题;如果回路测试都不通,优先检查 GPIO 配置、时钟配置、中断配置这几块。
5.3 第三步检查参数字段:波特率、数据位、停止位、校验位全部对齐
联调时碰到乱码或收不到数据,常见的参数问题有几个:波特率不一致(最常见)、校验位不一致(一端开了校验一端没开)、数据位不一致(一端 8 位一端 9 位)。
这里有个小技巧:如果通信两端参数死活对不上,可以把波特率调低,比如从 115200 降到 9600,排除高速率下的信号完整性问题。工业现场的线缆如果较长(超过 20 厘米),高速率下的波形畸变会明显加剧,降速往往是立竿见影的解决办法。
另外,串口助手的 DTR/RTS 信号也可能干扰通信。有些模块(比如 ESP8266 的某些烧录板)会受 DTR/RTS 电平控制进入下载模式,串口助手勾选了这两个信号可能导致模块复位或进入奇怪的状态。通信失败时不妨把 DTR/RTS 都取消勾选再试。
5.4 第四步查中断和缓冲区:一切正常但偶发丢字节,往这里查
如果物理层没问题、参数也对齐了,但出现偶发性的丢字节或粘包,问题大概率出在中断处理和缓冲区设计上。
先查中断优先级:如果串口接收中断优先级太低,被更高优先级的中断长时间打断,接收缓冲区的数据可能被新数据覆盖。再查中断服务函数里有没有做耗时操作:在 ISR 里调用printf、malloc、延时函数,都是大忌,这些操作会阻塞后续中断的处理。
再查缓冲区大小和读写逻辑:环形缓冲区的读写索引是不是用了volatile修饰;缓冲区大小是不是 2 的幂;读写索引的更新是否在临界区(关中断保护)内完成。还有一个隐蔽的问题是:如果主循环读数据的频率低于串口来数据的频率,缓冲区最终会溢出,新数据被丢弃。这时候要么增大缓冲区,要么提高读取频率,要么在协议层面做流控。
5.5 第五步看协议层:正常收发但数据不对,可能是协议解析的问题
排除硬件和驱动层问题后,如果数据能收到但内容不对,就要往协议层找原因。常见的协议层问题包括:字节序(大小端)不一致、字段偏移算错、CRC 校验算法不匹配、转义字符处理漏了等。
比如你从uart.c的接收缓冲区里读出了一串原始字节,如果对方发的是大端序的数据,而你在代码里按小端序解析,数值就会完全不对。这种情况下,逻辑分析仪解码出来的原始字节和你代码里接收到的字节如果完全一致,说明传输没问题,问题一定出在解析逻辑上。
我的建议是:调协议时一定要在关键节点打印或断点观察原始字节,不要只看最终解析结果。经常出现的情况是,接收代码收到了正确的0x01 0x02 0x03 0x04,但解析成结构体的赋值逻辑有误,导致最终结果错得莫名其妙。
6. 把UART驱动写出好味道:工程化改造和防御性编程
6.1 给驱动加一层"接口壳":上层业务不直接操作寄存器
在我早期写代码的时候,业务函数里经常直接调用USART_SendData(),后来维护过几个项目下来,我深刻体会到这种做法有多痛苦——换芯片平台时,业务代码和驱动代码耦合在一起,改起来牵一发动全身。
更好的做法是给 UART 驱动加一层薄薄的接口壳,比如定义统一的发送函数uart_send_bytes(uint8_t *buf, uint16_t len)和接收函数uart_recv_byte(uint8_t *byte)。驱动层内部可以是基于寄存器的、基于 HAL 库的、基于 LL 库的,但业务层只依赖这两三个接口。以后换平台,只需要重写驱动层内部的实现,业务层一行都不用动。
具体来说,接口设计遵循几个原则:发送函数不要自己加延时;接收函数不要阻塞等待(除非你明确需要阻塞模式);错误返回值要清晰,比如返回0表示成功,返回-1表示参数无效,返回-2表示缓冲区满。
6.2 防御性编程:让驱动函数在各种极端输入下都不崩溃
嵌入式系统里,最怕的就是驱动函数在异常情况下把整个系统搞崩。防御性编程的核心就是:函数入口先做参数检查,发现不合理的情况就立刻返回错误码,而不是继续往深处执行。
举几个 UART 驱动里最常见的防御性检查:
uart_send_bytes收到NULL指针:直接返回错误码,不要memcpy空指针len为 0:直接返回,不发送- 缓冲区满:不要覆盖已有数据,返回"写入失败"
- 串口外设没有初始化:用一个状态标志位记录初始化是否完成,未初始化时调用收发函数直接返回错误
你可能会觉得这些检查降低了效率,但在实际项目中,防御性编程省下的调试时间远超那点性能损失。我在共享一个uart.c给同事后,对方在某个调用点传了一个NULL指针进来,如果没有检查,系统直接 hardfault,而项目里没有调试器,排查起来会非常痛苦。
6.3 扩展思考:把"串口驱动"升级为"通信中间件"
当项目里多个外设都通过串口通信时(比如一个接 WiFi 模块,一个接 GPS 模块,一个接调试串口),每个外设单独维护一份uart.c显然不合理。这时候可以考虑把 UART 驱动抽象为通信中间件,通过回调函数把收到的一帧数据上报给不同模块。
具体做法是:定义统一的帧协议格式(比如帧头 + 长度 + 数据 + 校验),每个串口对应一个帧解析器,解析完毕后通过注册的回调函数通知上层模块。这样上层模块只需要关心"收到一帧完整数据"这个事件,而不用关心底层是轮询、中断还是 DMA 实现的。
这个设计在大型固件项目里几乎是标配。比如你有一个数据采集设备,通过 UART0 连接传感器、UART1 连接上位机、UART2 连接调试通道,每个通道的数据格式不同、处理逻辑不同,但底层驱动的骨架完全一致。抽出一个通信中间件层,能让代码整洁度和可维护性提升一个档次。
6.4 从"能用"到"好用"的小细节:超时、重试、日志
最后分享几个让 UART 驱动从"能用"变成"好用"的小细节。
第一个是发送超时机制。用轮询发送时,如果对方硬件故障或者线路断开,while(!TXE)可能会死等。给发送加上超时计数,比如配置一个 100ms 的软件定时器,超时后放弃发送并返回错误码,可以避免系统卡死。
第二个是接收空闲超时。接收的时候,如果一帧数据的分包间隔较长,你需要在设定时间内没有新数据到来时,认为这一帧已经结束,然后交给上层处理。这正好可以利用串口的空闲中断(IDLE)实现,也可以用软件定时器的超时回调实现。
第三个是日志打印的唯一出口。调试时尽量封装一个uart_printf()函数,统一管理格式化、加时间戳、加换行,不要到处直接调用printf。这样量产时想关闭调试日志,只需要改一个宏开关,不用到处删代码。
写在最后:一次UART调试的经验沉淀
这篇文章的内容,很大程度上来自我在某个项目里被一个"不听话"的串口折腾了两天的经历。当时用的是一份从同事那里继承的uart.c,基于 F103 写的,但板子换成了 F407。第一天的排查工作完全在后半夜进行,查中断、查 GPIO、查波特率公式,甚至怀疑到芯片本身是不是坏了,结果最后发现只是引脚复用功能编号没配——就是GPIO_PinAFConfig少写一行的事。
后来我把这个教训固化成一条自查清单:拿到任何外设驱动代码,第一件事是确认芯片系列和库版本;第二件事是确认时钟树和引脚复用;第三件事才是看逻辑对不对。这套清单后来帮我省下的时间,远超写这些内容的时间。
如果你也在调试串口,无论是移植一份来历不明的uart.c还是从零写自己的驱动,希望这篇内容能让你少走几步弯路。
本文还有配套的精品资源,点击获取