1. 为什么要在 R128 上认真对待 UART 这个“老古董”
搞嵌入式的人对 UART 都不陌生,串口嘛,调试打印、模块通信、协议对接,几乎每个项目都绕不开它。但恰恰因为它太常见,很多人在全志 R128 这类异构 SoC 上反而容易翻车——R128 的 UART 资源分布在不同的核和不同的地址空间里,HAL 层又做了一层抽象,如果不搞清楚底层逻辑,直接照搬其他平台的代码,轻则收不到数据,重则系统卡死。
我这次拿到的任务是在 R128 SDK 上把 UART 的 HAL 模块跑通,并且整理出一套可复用的开发流程。R128 是全志推出的一款面向音频、物联网和智能硬件的高集成度芯片,内部有多个处理器核,UART 控制器数量也不少,SDK 里提供了 HAL 接口来屏蔽寄存器操作。但 HAL 不是万能的,它只负责“怎么配”,不负责“为什么这么配”。这篇内容就是把我从看手册、翻 SDK 源码到实际调通串口收发的过程完整记录下来,重点讲清楚 HAL 模块的设计思路、关键参数怎么算、代码怎么写、坑怎么避。
适合谁看?如果你手上有 R128 的开发板,正在做串口通信相关的功能,或者你负责维护 SDK 里的 HAL 层代码,那这篇内容应该能帮你省掉不少翻手册的时间。即使你用的是其他平台的 MCU,UART HAL 的设计逻辑也是相通的,底层思路可以直接借鉴。
2. R128 的 UART 资源分布与 HAL 模块设计逻辑
2.1 先搞清楚芯片上有几个串口、分别在哪
R128 的 UART 控制器不是随便哪个引脚都能用的,它涉及引脚复用、时钟域和中断路由三个层面的约束。在动手写代码之前,我做的第一件事是把 SDK 里board.dts和datasheet对照着看了一遍,确认哪些 UART 是真正引出来的、哪些被其他功能占用了。
从 SDK 的配置文件来看,R128 通常提供多路 UART,编号从 UART0 开始。UART0 一般被保留作为调试串口,系统启动时的日志就是从这里出来的,所以实际项目里最好别拿它做业务通信,否则调试信息和你自己的协议数据混在一起,排查问题会非常痛苦。我踩过这个坑:早期为了省事直接在 UART0 上跑业务协议,结果系统一打印内核日志就把我的数据帧冲乱了,后来老老实实换到 UART1 才消停。
HAL 层对 UART 的抽象主要围绕三个结构体展开:句柄结构体、配置结构体和回调注册接口。句柄里存的是控制器基地址、当前波特率、收发缓冲区指针这些运行时状态;配置结构体则是波特率、数据位、停止位、校验方式、硬件流控这些参数的集合。这种设计的好处是配置和运行分离,你可以在初始化时一次性把参数填好,后续收发只操作句柄,不用反复碰寄存器。
2.2 HAL 为什么要把寄存器和业务代码隔开
很多人觉得 HAL 就是“多此一举”,直接写寄存器多痛快。但在 R128 这种多核平台上,HAL 的价值非常明显。第一,R128 的不同核可能访问同一组 UART 控制器,HAL 层做了互斥和状态管理,避免两个核同时配置同一个串口导致状态错乱。第二,HAL 把时钟使能、引脚复用、中断注册这些“脏活”封装成了标准接口,你不需要在每个项目里重复写 pinmux 配置。第三,SDK 升级时如果寄存器地址变了,只要 HAL 接口不变,你的业务代码就不用动。
我翻了一下 SDK 里 UART HAL 的实现,核心函数大概有这么几个:初始化、反初始化、发送、接收、中断注册、波特率设置。初始化函数内部会依次做时钟门控打开、引脚复用配置、控制器复位、参数写入这几步。这里有个细节值得注意:时钟使能必须在配置寄存器之前完成,否则写进去的值不会生效。我在第一次调试时就因为把顺序搞反了,配了半天波特率死活不对,后来用逻辑分析仪抓波形才发现时钟根本没起来。
2.3 引脚复用配置是第一个拦路虎
R128 的引脚复用表比较密,同一个物理引脚可能对应 UART 的 TX、I2S 的时钟、或者 PWM 输出。HAL 层虽然提供了引脚配置接口,但具体用哪组引脚、怎么配,还是得看板级 dts 或者手动调 pinmux。我的做法是先在 dts 里把 UART 的 pinctrl 节点配好,确保内核启动时已经把引脚切到 UART 功能,然后在 HAL 初始化时不再重复配置引脚,只做控制器层面的设置。这样职责清晰,出问题也容易定位——如果完全没波形,先查引脚复用;如果有波形但数据不对,再查波特率和帧格式。
注意:R128 的部分引脚在上电默认状态下可能是其他功能,如果你在 HAL 里没有正确配置 pinmux,串口 TX 引脚可能一直输出低电平或者高阻态,用万用表量一下就能判断。
3. UART HAL 初始化的关键参数与计算过程
3.1 波特率到底是怎么算出来的
波特率是串口通信最核心的参数,配错了就是一堆乱码或者完全没反应。R128 的 UART 波特率由时钟源分频得到,HAL 层提供了一个hal_uart_set_baudrate之类的接口,你传进去一个目标波特率,它内部会根据当前时钟频率算出分频系数。但这里有个坑:不是所有波特率都能精确得到,分频系数是整数,实际波特率和目标值之间会有误差。
以常见的 115200 为例,假设 UART 时钟源是 48MHz,分频系数 = 48000000 / 115200 ≈ 416.67。取整后是 416 或者 417,实际波特率分别是 115384 和 115107,误差在 0.16% 左右,完全在可接受范围内(一般要求误差小于 2%)。但如果你要配一个比较偏的波特率,比如 250000,分频系数 = 192,刚好整除,误差为零。再比如 921600,分频系数 = 52.08,取整后误差会大一些,需要确认是否在容限内。
我在实际项目里遇到过一次波特率偏差导致通信不稳定的情况:目标波特率是 460800,时钟源配置成了 24MHz,算下来分频系数是 52.08,取整 52 后实际波特率是 461538,误差 0.16%,理论上没问题。但对方设备的晶振精度较差,两边误差叠加后偶尔出现帧错误。后来我把 R128 这边的时钟源切到 48MHz,分频系数变成 104.17,取整 104,实际波特率 461538,误差一样,但两边重新校准后通信就稳了。所以波特率误差要结合对端设备一起看,不能只看自己这边。
3.2 数据位、停止位和校验位的选择逻辑
这三个参数看起来简单,但组合起来有讲究。数据位一般是 8 位,停止位 1 位,校验位无,这是最常用的 8N1 格式。如果你的应用场景干扰比较大,可以考虑加奇偶校验,但代价是每帧多一个校验位,吞吐量略微下降。R128 的 HAL 支持 5 到 8 位数据位、1 或 2 位停止位、奇校验/偶校验/无校验。
我个人的经验是:短距离板内通信直接用 8N1,长距离或者工业环境再考虑加校验。另外要注意,有些传感器模块的 datasheet 里写的帧格式是 8E1(8 位数据、偶校验、1 位停止),如果你配成 8N1,收到的数据最高位可能不对,因为校验位被当成了数据位的一部分。这种问题用逻辑分析仪一抓就能看出来,帧结构明显对不上。
3.3 硬件流控要不要开
R128 的 UART 支持 RTS/CTS 硬件流控。如果你的应用场景是高速率、大数据量传输,而且对端也支持流控,建议打开。硬件流控的作用是:当接收方缓冲区快满时,拉高 RTS 信号告诉对方暂停发送,避免数据溢出。我在一个音频数据传输的项目里用过流控,波特率跑到 1.5Mbps,不开流控的话偶尔丢包,开了之后连续跑几个小时零丢包。
但流控也有代价:多占两个引脚,而且如果对端不支持流控,你这边开了也没用,反而可能因为 RTS 引脚状态不对导致通信异常。所以开不开流控取决于对端能力,不是单方面能决定的。
4. 从零写一个 UART 收发 Demo 的完整过程
4.1 初始化流程的代码骨架
下面这段代码是基于 SDK 里 HAL 接口的典型初始化流程,我做了适当简化,重点看顺序和参数:
uart_handle_t uart_handle; uart_config_t uart_cfg; uart_cfg.baudrate = 115200; uart_cfg.data_bits = UART_DATA_BITS_8; uart_cfg.stop_bits = UART_STOP_BITS_1; uart_cfg.parity = UART_PARITY_NONE; uart_cfg.flow_ctrl = UART_FLOW_CTRL_DISABLE; hal_uart_init(&uart_handle, UART_PORT_1, &uart_cfg); hal_uart_register_callback(&uart_handle, uart_rx_callback, NULL); hal_uart_enable_rx_interrupt(&uart_handle);这段代码看起来简单,但每一步都有讲究。hal_uart_init内部会做时钟使能和控制器复位,所以它必须是第一个调用的。回调注册要在使能中断之前完成,否则中断来了找不到处理函数。使能接收中断之后,串口就开始工作了,这时候如果有数据进来就会触发回调。
我建议在初始化之后加一个简单的自检:先发一个字节出去,用示波器或者逻辑分析仪确认波形正确,再开始正式的收发测试。这个习惯帮我省了很多时间——有一次初始化代码里引脚配错了,TX 引脚一直是低电平,自检时一眼就看出来了,不用等到上层协议跑起来才发现问题。
4.2 发送数据的两种方式:轮询和中断
HAL 层一般提供两种发送方式:轮询发送和中断发送。轮询发送就是死等,把数据一个个塞进发送寄存器,直到发完才返回。这种方式简单粗暴,适合发送少量数据,比如调试打印。但如果你要发一大包数据,轮询会阻塞 CPU,影响其他任务的实时性。
中断发送则是把数据放进缓冲区,使能发送中断,由中断服务程序逐个把数据搬进硬件寄存器。这种方式不阻塞 CPU,适合大数据量或者对实时性要求高的场景。我在实际项目里一般用中断发送,配合一个环形缓冲区,上层只管往缓冲区里写,底层中断负责往外搬。
这里有个细节:发送完成中断和发送空中断不是一回事。发送空中断是硬件寄存器空了就触发,发送完成中断是最后一个字节的停止位发完才触发。如果你需要在数据完全发完之后做某些操作(比如切换 RS485 方向),必须用发送完成中断,不能用发送空中断。
4.3 接收数据的中断处理和缓冲区管理
接收这块比发送更容易出问题。R128 的 UART 接收中断触发条件一般是接收 FIFO 达到一定水位或者超时。HAL 层的中断处理函数会读取数据并调用你注册的回调。如果你的回调里做太多事情,比如直接解析协议、操作其他外设,可能会阻塞后续中断,导致数据丢失。
我的做法是:回调里只做数据搬运,把收到的字节塞进环形缓冲区,然后设置一个标志位,让主循环或者任务去处理。这样中断处理时间极短,不会丢数据。环形缓冲区的大小要根据你的协议最大帧长和系统调度延迟来定,一般至少留两倍最大帧长的空间。
还有一个容易忽略的点:接收溢出错误。如果缓冲区满了或者中断被长时间关闭,硬件 FIFO 会溢出,HAL 层通常会设置一个错误标志。你需要在回调里检查这个标志,一旦发现溢出就清空缓冲区重新同步,否则后续数据全是错位的。我在调试时就遇到过因为溢出没处理,串口收到的数据整体偏移了一个字节,查了半天才发现是溢出标志没清。
4.4 用逻辑分析仪验证波形
代码写完之后,别急着跑协议,先用逻辑分析仪或者示波器看一下 TX 波形。一个标准的 8N1 帧,起始位是低电平,然后 8 个数据位(低位在前),最后是高电平的停止位。量一下位宽,115200 波特率下每位大约 8.68 微秒。如果位宽明显不对,说明波特率分频算错了;如果帧结构不对,说明数据位或者停止位配错了。
我习惯在发送测试数据时发 0x55 或者 0xAA,因为这两个字节的波形是方波,高低电平交替,用示波器看非常直观。0x55 是 01010101,波形上就是规整的方波,一眼就能看出位宽和电平是否正确。
5. 实际调试中遇到的典型问题与排查思路
5.1 收不到数据:从引脚到中断逐层排查
收不到数据是最常见的问题,排查要按信号流向一层层来。第一层看引脚:用万用表或者示波器量 TX 和 RX 引脚,确认 TX 有波形输出、RX 有信号输入。如果 TX 没波形,查 pinmux 配置和时钟使能;如果 RX 有信号但收不到,查中断是否使能、回调是否注册。
第二层看控制器状态:读一下 UART 的状态寄存器,看看接收 FIFO 里有没有数据、有没有错误标志。HAL 层一般提供了获取状态的接口,如果没有就直接读寄存器。我有一次遇到 RX 引脚有信号但 FIFO 一直是空的,后来发现是引脚复用配成了 GPIO 输入模式,信号根本没进到 UART 控制器。
第三层看中断路由:R128 的中断控制器配置比较灵活,UART 中断可以路由到不同的核。如果你在 A 核初始化 UART,但中断路由到了 B 核,A 核的回调永远不会被调用。这个坑比较隐蔽,因为代码看起来完全正确,但就是没反应。解决办法是在初始化时确认中断路由配置,或者直接在 dts 里把中断绑定到正确的核。
5.2 数据乱码:波特率、时钟和电平匹配
乱码问题八成是波特率不对,但也不全是。先确认两边波特率一致,再确认时钟源频率和 HAL 计算时用的一致。R128 的 UART 时钟源可以选不同的 PLL 输出,如果你在 dts 里改了时钟源但 HAL 初始化时没同步更新,算出来的分频系数就是错的。
还有一个容易被忽略的点:电平匹配。R128 的 UART 引脚是 3.3V 电平,如果你对接的是 5V 设备,直接连可能烧引脚或者通信不稳定。需要加电平转换电路,或者确认对方设备支持 3.3V 电平。我在一个项目里对接老式工控模块,对方是 5V TTL 电平,直接连上后数据时好时坏,加了电平转换芯片之后就稳了。
5.3 中断丢失和优先级配置
R128 的中断优先级配置会影响 UART 中断的响应及时性。如果 UART 中断优先级太低,被其他高优先级中断频繁打断,接收 FIFO 可能溢出。我的建议是把 UART 接收中断的优先级设到中等偏上,既保证及时响应,又不至于抢占关键的系统中断。
另外,中断处理函数里不要调用可能引起阻塞的 HAL 接口,比如带锁的发送函数。如果确实需要在中断里发送数据,用非阻塞的接口或者直接操作寄存器。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| TX 无波形 | 引脚复用未配置、时钟未使能 | 查 pinmux 和时钟门控寄存器 |
| TX 有波形但数据错 | 波特率分频错误、帧格式不匹配 | 逻辑分析仪量位宽和帧结构 |
| RX 有信号但收不到 | 中断未使能、回调未注册、引脚模式错误 | 查中断使能位和引脚复用 |
| 数据偶尔丢包 | 缓冲区溢出、中断优先级低 | 加大缓冲区、提高中断优先级 |
| 通信一段时间后死机 | 中断里阻塞操作、溢出未处理 | 检查回调函数、清溢出标志 |
6. HAL 层之外的几个实战心得
6.1 调试串口和业务串口一定要分开
前面提过 UART0 被系统日志占用的问题,这里再强调一次。R128 启动时 UART0 会输出大量内核日志,如果你的业务代码也跑在 UART0 上,日志和协议数据混在一起,解析起来非常痛苦。正确的做法是业务通信走 UART1 或更高编号的串口,UART0 专门留给调试。如果板子上只引出了一个串口,那就想办法在硬件上飞线或者用 USB 转串口模块另接一路。
6.2 环形缓冲区的实现细节
环形缓冲区是串口收发的标配,但实现上有几个细节要注意。第一,读写指针要用原子操作或者关中断保护,否则在多核或者中断上下文里可能竞争。第二,缓冲区满的时候要明确策略:是丢弃新数据还是覆盖旧数据,取决于你的协议是否允许丢帧。第三,留一个字节的空间用来区分“空”和“满”,否则读写指针相等时无法判断是空还是满。
我在 R128 上用的是单生产者单消费者模型:中断里只写、任务里只读,这样不需要加锁,只需要保证读写指针的原子性。R128 是 32 位架构,对齐的 32 位读写本身就是原子的,所以直接用普通变量就行,不用加内存屏障。
6.3 低功耗场景下的 UART 唤醒
R128 支持低功耗模式,UART 可以在收到数据时唤醒系统。这个功能在电池供电的物联网设备里很有用,但配置起来有讲究。首先要在休眠前把 UART 配置成唤醒源,其次要确认唤醒后的时钟恢复流程,否则唤醒后波特率可能不对。我在一个低功耗项目里遇到过唤醒后第一帧数据乱码的问题,后来发现是唤醒后时钟还没稳定就开始收数据了,加了一个短暂的延时之后就好了。
6.4 用 DMA 减轻 CPU 负担
R128 的 UART 支持 DMA 收发,HAL 层也提供了对应的接口。如果你的波特率很高或者数据量很大,用 DMA 可以显著降低 CPU 占用。配置 DMA 时要注意缓冲区的对齐和大小,以及 DMA 完成中断的处理。DMA 发送完成中断里记得切换 RS485 方向(如果用了的话),否则最后一个字节可能发不出去。
我个人在实际操作中的体会是:UART 这个模块本身不复杂,复杂的是它和时钟、引脚、中断、DMA 这些周边模块的耦合关系。在 R128 上尤其如此,因为它的资源分布比较分散,HAL 层虽然做了封装,但该看的寄存器还是得看,该量的波形还是得量。把初始化顺序理清楚、把中断和缓冲区管好、把调试手段用上,大部分问题都能快速定位。最后再分享一个小技巧:在 HAL 初始化函数里加几行日志打印,把实际配置的波特率、分频系数、引脚编号都打出来,出问题时对照日志和手册,比盲猜快得多。