接了个小项目:做一个便携式数据采集终端,主控选了瑞萨 RA4M2,蓝牙芯片用了 DA14531。需求本身一句话就能说完——MCU 把采集到的数据通过蓝牙发到手机,手机也能下发命令给 MCU。这就是典型的 UART 到 BLE 双向透传场景。但真把这条链路从驱动层写到应用层、再到手机端联调通过,中间遇到的问题比我预想的多得多。这篇文章把我从 RA4M2 侧 UART 驱动、DA14531 侧 BLE 固件、到手机端调试抓包的完整过程整理出来,顺带把协议设计、粘包处理、低功耗调优这些实操细节都讲清楚。适合刚拿到 RA 系列 MCU 想做蓝牙透传的开发者,也适合准备把 DA14531 接到外部 MCU 上做低功耗产品的同学参考。
1. 为什么选 RA4M2 和 DA14531 这个组合,透传链路到底怎么分工
1.1 透传的本质是什么
“透传”听起来很玄,拆开看就两个方向:一是“上行”,MCU 通过 UART 把数据交给蓝牙芯片,蓝牙芯片通过 BLE 的 Notify 推给手机;二是“下行”,手机通过 BLE Write 把数据写给蓝牙芯片,蓝牙芯片通过 UART 发给 MCU。两边都是透明管道,业务层不感知中间经过了几道协议栈。
但这里有个最容易犯的错误:以为“透明”就可以不做协议设计。裸透传在两边速率匹配、数据量小、环境干净的情况下确实能跑,一旦手机端回包、UART 侧持续灌数据、或者断线重连,裸透传直接会变成乱码灾区。后面我会专门讲协议层的处理方式。
1.2 分工:一个管业务,一个管射频
RA4M2 是瑞萨 RA 系列的 Cortex-M33 内核 MCU,主频做到 100MHz,片上带 SCI(也就是 UART)、SPI、I2C、ADC、DAC、CAN 这些外设,还有 TrustZone 安全特性。在这个项目里,它只关心采集、解析、业务逻辑,完全不碰蓝牙协议。
DA14531 是 Dialog(现在归瑞萨)的超低功耗 BLE SoC,Cortex-M0+ 内核,主打极低功耗和小体积。它的工作就是跑 BLE 协议栈、维护连接、收发射频数据,并通过 UART 和 RA4M2 交换数据。DA14531 本身也能当 MCU 用,但在这套方案里它老老实实当从机协处理器。
选型逻辑其实很简单:
- 业务复杂、外设要求高,用 RA4M2 做主控,不把业务塞进蓝牙芯片里,开发和维护都轻松。
- 对静态功耗和待机功耗敏感,DA14531 的优势就体现出来了,BLE 芯片单独管理射频,主控可以深度睡眠,不互相拖累。
- 两块芯片都是瑞萨家族产品,DA14531 的 SDK 和 RA4M2 的 FSP 工程在工具链上都有官方支持,联调出问题能少绕弯路。
1.3 硬件连接与数据链路
物理层连接极简单,标准三线串口:
| RA4M2 引脚 | DA14531 引脚 | 说明 |
|---|---|---|
| TX (Pxxx) | RX | RA4M2 发送、DA14531 接收 |
| RX (Pxxx) | TX | DA14531 发送、RA4M2 接收 |
| GND | GND | 必须共地 |
有条件的可以接 RTS/CTS 做硬件流控,但如果走的是协议层流控(后面会讲),三线就够用。波特率我用的是 115200,8N1,这个值在稳定性和吞吐量之间比较折中。DA14531 对输入时钟敏感,如果两边波特率误差超过 ±2%,长帧数据就会偶发乱码,这点后面踩坑部分专门说。
上行路径:RA4M2 传感器采集 → UART TX → DA14531 UART RX → BLE Notify → 手机。下行路径:手机 → BLE Write → DA14531 → UART TX → RA4M2 UART RX → 业务处理。两条路径独立,理论上可以全双工,但实际做不到完全同时跑,因为共享同一根 UART 和同一个 BLE 连接事件。
2. RA4M2 侧 UART 驱动:从 FSP 配置到中断收发和调试串口
2.1 FSP 工程里创建 SCI 模块
RA4M2 的软件开发基本就是在 e2 studio 里搭 FSP(Flexible Software Package)。新建工程后,在 Configuration 里添加一个 Stack,选r_sci_uart。
关键参数要理解着填:
- Baud Rate:115200。如果后面要跑大流量数据,可以上 230400 甚至 460800,但 DA14531 侧对波特率误差的容忍度要实测,别一上来就直接翻倍调。
- Data Bits / Stop Bits / Parity:8 / 1 / None,通用配置。
- Interrupt Callback:这个回调函数是 UART 事件的中枢,所有收发完成、接收一个字节、错误事件都会进来。
- 接收模式:我建议直接用
Receive配合中断,或者更简单粗暴的方式——打开接收中断,逐字节进回调。
FSP 生成代码后,在hal_data.c里能看到初始化的句柄g_uart0_ctrl和配置结构体g_uart0_cfg,我们不用手动改生成代码,只在回调函数和业务接口里操作。
打开串口:
fsp_err_t err = R_SCI_UART_Open(&g_uart0_ctrl, &g_uart0_cfg); if (err != FSP_SUCCESS) { // 处理初始化失败 }2.2 发送和接收:中断、DMA 还是同步阻塞
发送一个数据块用R_SCI_UART_Write。这个接口是异步的,调用后立即返回,真正发送完通过回调事件UART_EVENT_TX_DATA_COMPLETE通知。如果业务代码里调完 Write 立刻又往同一块 buffer 写数据,会踩缓冲区被覆盖的坑。我的做法是维护一个“发送忙”标志位:
volatile bool uart_tx_busy = false; bool uart_send(uint8_t *buf, uint16_t len) { if (uart_tx_busy) return false; // 前一次还没发完 uart_tx_busy = true; R_SCI_UART_Write(&g_uart0_ctrl, buf, len); return true; } void user_uart_callback(uart_callback_args_t * p_args) { if (p_args->event == UART_EVENT_TX_DATA_COMPLETE) { uart_tx_busy = false; } }接收这件事,强烈不建议用R_SCI_UART_Read等固定字节数的方式。因为 UART 收的是不定长数据,你不知道 RA4M2 这一帧有多少字节。固定Read会导致后续字节堆在 FIFO 里,下一帧来就错位。
我最终采用的方式是:FSP 配置里把接收通道打开,回调里处理UART_EVENT_RX_CHAR事件,每个字节都压进一个环形缓冲区,然后丢给协议解析状态机。这种方式的优点是不依赖硬件 FSM(帧状态机)、不用精确计算接收长度、实现简单可靠。
#define RING_BUF_SIZE 256 typedef struct { uint8_t buf[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; ring_buffer_t uart_rx_ring; void ring_push(ring_buffer_t *q, uint8_t byte) { uint16_t next = (q->head + 1) % RING_BUF_SIZE; if (next != q->tail) { // 满了就丢,或者置溢出标志 q->buf[q->head] = byte; q->head = next; } } void user_uart_callback(uart_callback_args_t * p_args) { if (p_args->event == UART_EVENT_RX_CHAR) { ring_push(&uart_rx_ring, (uint8_t)p_args->data); } }到这里,RA4M2 侧只负责把字节收进环形缓冲,后面怎么组帧、怎么校验,全部交给协议层。这样做的好处是 UART 驱动和业务解耦,后面换协议、加加密都不用来回改驱动。
DMA 和 DTC 要不要上?我的建议是:先别上。对于透传这种不定长数据,DMA 需要配合空闲中断检测帧尾,很多 MCU 的 SCI 没有现成的空闲中断,需要外部 TIMER 配合超时判定,复杂度上来不说,排错成本也高。等 115200 抢不过 CPU 的时候再优化不迟。
2.3 调机时绕不开的 USB 转串口问题
开发和联调阶段我用的 USB 转串口模块是 FT232 和 FT231 系列。这两个芯片在 Windows 下需要装 FTDI 官方 VCP 驱动,不装的话设备管理器里显示未知设备,装好之后出的是USB Serial Port (COMx)。Linux 下内核自带ftdi_sio驱动,插上就能用,一般无需手动装。
用串口工具直接看日志时,注意检查波特率是否和 FSP 配置一致。很多人调了半天发现乱码,最后发现是串口助手波特率设错了。
提示:如果你手头只有 FT232 早期的模块,在 Win10/Win11 下偶尔会遇到驱动签名问题,解决方式是去 FTDI 官网下载最新驱动手动安装,别用系统自动搜索。
3. DA14531 侧 BLE 透传固件:官方 SDK 怎么改出双通道
3.1 SDK 工程与例程选择
DA14531 的开发包是官方 SDK,工程一般基于 Keil 或 IAR 打开,例程里有一堆ble_app_peripheral、pxp_reporter、custom_service之类的模板。
做透传功能,我建议从custom_service例程出发,而不是直接拿现成的 UART 透传 demo。因为透传业务核心是一个自定义服务:两个特征值,一个用于手机写(下行),一个用于手机订阅通知(上行)。官方例程里服务定义的结构、回调注册的写法都现成,改起来最顺手。
工程里需要关心的文件:user_profile相关定义头文件(服务/特征 UUID)、user_callback_config(协议栈回调配置)、以及最核心的app_ble或user_callback相关的回调函数。
3.2 自定义服务的特征定义
我在官方模板基础上定义了一个串口服务,UUID 可以直接用自定义 128 位,也可以选一个不冲突的 16 位短 UUID。需要注意:如果手机 App 是用通用蓝牙调试工具(比如 nRF Connect)来连,特征描述写得清晰一点会省很多事,Characteristic Properties 要分清 Write 和 Notify。
下行通道(手机 → 设备):一个 Write 属性特征,手机把命令写进来,DA14531 在协议栈回调里拿到数据后,交给 UART 发出去。这个回调大概长这样:
void app_ble_server_rx_write_cb(uint16_t conn_idx, uint16_t handle, uint8_t *data, uint16_t len) { /* 把 data 通过 UART 发给 RA4M2 */ uart_send(data, len); }上行通道(设备 → 手机):一个 Notify 属性特征。DA14531 从 UART 收到 RA4M2 的数据后,调协议栈的 notify 接口,把数据放到 BLE 的发送队列里。通知前要确认手机端已经订阅了 CCCD,否则不该发。
3.3 UART 接收与 BLE 发送的衔接
DA14531 侧的 UART 接收也是中断方式,逐字节进缓冲。这里和 RA4M2 侧的区别在于:DA14531 是单核跑协议栈和应用,UART 中断里一定不要做耗时操作。我的处理是:UART 中断只把字节推进环形缓冲,然后在主循环里检测到数据后,调用 BLE 通知接口发送。
BLE 发送接口一次能发多少字节,取决于 MTU 协商结果。默认 23 字节 MTU 下,单个通知的数据部分最多 20 字节。如果业务帧远远超过 20 字节,就必须分包发送。每发一包之间要留意协议栈的 buffer 是否够用,别一次性把发送队列塞满导致后续通知失败。
判断“上一包是否发完”,用协议栈提供的发送完成回调,或者在应用层做一个简单的发送窗口限制。总线速率为 115200 时,发送一包 20 字节的数据大约占 2ms 左右,BLE 侧一个连接事件能发好几包,所以小帧短数据基本不会堵。真正堵的是长帧大数据,这个问题在协议层解决。
3.4 MTU 协商:不处理会断连或丢包
很多第一次做 BLE 透传的人都会卡在“手机收不到超过 20 字节的数据”这个问题上。原因就是只用了默认 MTU(23 字节)。
DA14531 支持在连接后协商 MTU。手机端(比如 iOS 的 CoreBluetooth 或 Android 的 BluetoothGatt)会在连接建立后主动发起 MTU 协商请求。DA14531 侧要允许这个协商,并在GATT_MTU_UPDATED事件里读取当前实际 MTU 值。协商后通常能达到 247 字节。
处理长帧时,我按当前mtu - 3作为单包载荷上限来切包(ATT 层每包有 3 字节头)。这样可以最大化吞吐,不用纠结每次发 20 字节的小包。
注意:如果手机端不主动协商 MTU,那么不管固件怎么优化,单包上限依然是 20 字节。测试工具建议用 nRF Connect,在连接后手动请求一个较大的 MTU,方便验证大包透传。
4. 双向透传的协议设计:帧格式、粘包、流控全都要管
4.1 为什么不能简单“进来就发出去”
先算一笔账:115200 波特率下,UART 的有效数据吞吐大约 11.5KB/s;BLE 在连接间隔 30ms、MTU 247 的条件下,单向实际吞吐能做到 20KB/s 以上。也就是说,UART 侧是瓶颈。如果 RA4M2 持续以 115200 向 DA14531 灌数据,DA14531 的 BLE 发送队列很快就满了,如果满队列就直接丢弃,手机端就会看到随机丢包。
下行方向更明显:手机一次 Write 大包(假设 240 字节)从 BLE 到达 DA14531,再通过 115200 发给 RA4M2,约需要 21ms。如果手机连续写好几包,UART 要排很长的队。如果协议层没有背压机制,丢数据是必然的。
所以必须做帧协议。做帧协议的核心目的不是加密,而是让接收方能准确判断“一条消息从哪开始、到哪结束、是否完整、是否出错”。
4.2 帧格式:头、长、类型、数据、校验
我采用的帧格式很简单,适合大多数透传场景:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 1 | 固定0xAA |
| 长度 | 1 | 数据区长度,最大 240 或 255 视需求 |
| 类型 | 1 | 区分上行数据、下行命令、应答等 |
| 数据 | N | 业务数据 |
| 校验 | 1 | CRC8 或 简单累加和 |
校验用 CRC8 比累加和可靠,裸透传时乱码往往是一两个字节出错,CRC8 能拦下绝大多数。长度字段的最大值必须结合 BLE MTU 来定:如果协商后 MTU 是 247,那么数据区最大取 240,这样一帧 BLE 通知刚好装下。
4.3 接收端状态机:解决粘包和半包问题
UART 接收是字节流,没有天然帧边界,所以必须用状态机解析。核心思路:空闲态只等0xAA,收到帧头后进入长度态,再按长度收正文,最后收校验字节。任何一个环节校验失败,回到空闲态重新等头。
typedef enum { RX_WAIT_HEAD, RX_WAIT_LEN, RX_WAIT_TYPE, RX_WAIT_DATA, RX_WAIT_CRC, } rx_state_t; static rx_state_t rx_state = RX_WAIT_HEAD; static uint8_t rx_buf[260]; static uint16_t rx_len; static uint16_t rx_idx; void protocol_rx_byte(uint8_t byte) { switch (rx_state) { case RX_WAIT_HEAD: if (byte == 0xAA) { rx_state = RX_WAIT_LEN; } break; case RX_WAIT_LEN: rx_len = byte; if (rx_len == 0 || rx_len > 240) { rx_state = RX_WAIT_HEAD; // 长度非法,重新同步 } else { rx_idx = 0; rx_state = RX_WAIT_TYPE; } break; case RX_WAIT_TYPE: rx_buf[rx_idx++] = byte; rx_state = RX_WAIT_DATA; break; case RX_WAIT_DATA: rx_buf[rx_idx++] = byte; if (rx_idx >= rx_len + 1) { // +1 因为含类型字节 rx_state = RX_WAIT_CRC; } break; case RX_WAIT_CRC: if (byte == crc8(rx_buf, rx_len + 1)) { protocol_handle_frame(rx_buf, rx_len + 1); } rx_state = RX_WAIT_HEAD; // 无论对错,都重新等头 break; default: rx_state = RX_WAIT_HEAD; break; } }这个状态机的核心价值是:无论半包、粘包、还是中间混入噪声字节,只要不满足帧格式,最终都会回到RX_WAIT_HEAD重新同步。RA4M2 和 DA14531 两侧我用了完全相同的状态机,只不过一个解析来自传感器的上行帧,一个解析来自手机的下行帧。
4.4 分包与重组:超过 MTU 时怎么办
如果业务数据(比如某条日志或传感器快照)超过一帧能承载的 240 字节,必须在应用层分包。分包的原则是:每个分片都有序号和分片总数,接收方收集齐后再重组。我封了两层:
- 帧层:解决 UART/BLE 字节流里的边界问题,一帧 ≤ 240 字节。
- 消息层:解决多帧拼接问题,一条消息拆成多帧,带
seq和total。
手机端收数据时,按seq顺序拼装,超时未收齐则丢弃整条消息。这么做看似多了一层,但大大减少了大包丢失后“不知道哪块缺了”的排查成本。
下行方向,手机端 App 也需要同样的分包逻辑。我见过不少团队直接把整条消息塞进一次 Write,超过当前 MTU 数据长度就直接失败或截断,这就是没有做消息层分包的后果。
4.5 流控与背压:不光靠硬件 RTS/CTS
硬件流控能解决 UART 侧的缓冲溢出,但解决不了 BLE 侧的排队溢出。在 RA4M2 → DA14531 方向,DA14531 收 UART 数据太快、BLE 通知来不及发时,必须有背压机制。
最简单的背压:分块确认。上行数据每帧带一个序号,手机收到并成功写入后回一个确认帧,DA14531 收到确认才继续发下一批。如果若干秒没收到确认,就停止发送并上报“链路拥塞”。
但并不是所有场景都需要确认机制。如果对实时性要求高、允许少量丢失(比如传感器实时数据),那就不用做确认,直接丢帧重传都不需要。我在这里采用了一个折中:普通数据不确认,关键命令必须确认——因为命令丢了后果严重。
5. 联调中最容易栽的跟头:从现象到根因的完整排查过程
5.1 第一类坑:手机收不到超过 20 字节的数据
现象:手机端 nRF Connect 订阅通知后,RA4M2 发了一条 100 字节的消息,手机只收到前 20 字节,后面的直接消失,有时连接还会断开。
排查过程:
- 先用串口日志确认 DA14531 侧确实从 UART 收到了完整 100 字节。
- 日志里打印
user_ble_send的返回值,发现发送长包时返回错误,说明单次 notify 长度超限。 - 检查 MTU 协商事件是否触发,发现手机端默认 MTU 就是 23,没有主动协商。
- 在 nRF Connect 里手动请求 MTU 到 247,再测,问题消失。
结论:要么在手机端 App 里主动请求大 MTU,要么固件侧强制按 20 字节分包。最稳的做法是两者都做——固件永远按当前 MTU 分包,App 端尽量把 MTU 协商上去。
5.2 第二类坑:RA4M2 连续发送时偶尔丢第一帧
现象:RA4M2 开机初始化后发的第一条 UART 数据,DA14531 偶尔收不到,或者收到的是乱码。后续数据都正常。
排查链路:
- 在 DA14531 的 UART 接收回调里打点,看第一个字符是否进来。
- 发现 DA14531 串口初始化和 RA4M2 上电发送存在竞争:RA4M2 复位后立刻发数据,此时 DA14531 可能还在跑 BLE 协议栈初始化,UART 外设还没打开,数据就丢了。
- 增加“就绪握手”:DA14531 初始化完成后主动通过 UART 给 RA4M2 发一个
READY帧,RA4M2 收到后才会业务数据。这比单纯 delay 延时可靠得多。
修完后,这个坑再没出现过。很多“偶发丢首包”的问题,根子都是上电时序,别急着怀疑驱动,先把两边的初始化顺序理清楚。
5.3 第三类坑:Wireshark 抓包定位“字节对但语义错”
有一段时间手机收到数据后内容看起来是对的,但解析结果是乱的。数据链路到底走的哪条路、具体在哪个环节被改写了?我拿出 Wireshark 配合 nRF 抓包器,只过滤当前设备的 BLE 地址,看空中包的实际内容。
Wireshark 里抓 BLE 有几个实用过滤表达式:
- 只看某台设备:
btle.advertising_address == aa:bb:cc:dd:ee:ff - 只看某个服务 handle 的写入/通知:
btatt.handle == 0x000e - 只看通知数据:
btatt.opcode == 0x1b
抓包结果发现:空中包的内容和 DA14531 发出来的 UART 内容是一致的,说明 BLE 链路没问题;问题出在 RA4M2 发给 DA14531 之前,RA4M2 应用层把字节序搞反了。两层验证下来,问题定位得非常快。
提示:抓 BLE 空中包的时候,抓包器会模拟一个协议栈去接收数据,会影响一部分连接时序,低功耗数据会变得不准,所以抓包时看内容和逻辑,别把功耗数据当最终结论。
5.4 第四类坑:波特率压线导致的偶发乱码
这是最后联调时发现的:DA14531 对 UART 波特率误差比想象中敏感。RA4M2 的 SCI 用内部时钟分频,115200 在某些时钟配置下实际波特率偏差超过 1%,短帧不明显,但碰到比较长的数据帧时,后半段就会出现零星乱码。
排查逻辑:串口日志打印内容前段正常、后段偶发 0x01、0x02 之类的杂散字节;示波器看波形没有毛刺;双方配置的波特率都是 115200。最后看了 RA4M2 FSP 里实际生成的波特率计算值,发现偏差,调整分频系数或改用更好的时钟源后,乱码消失。
遇到偶发乱码,建议直接打印R_SCI_UART_InfoGet返回的实际波特率设置值,比肉眼对参数快得多。
6. 把待机电流压下来:低功耗调优的实测记录和教训
6.1 系统功耗预算
先看有多少优化空间。整机功耗等于 RA4M2 功耗加 DA14531 功耗加外设功耗。我在联调时不关注功耗,功能稳定后才开始逐个环节扣电流。
| 模块 | 工作电流 | 待机电流 | 备注 |
|---|---|---|---|
| RA4M2 | 约 30mA(100MHz 运行) | 几十 µA 级(Sleep 模式) | 实际看运行外设和时钟配置 |
| DA14531 | 连接状态下几 mA | Extended Sleep 约 1-3µA | 核心优势在待机 |
| USB 转串口模块 | 20-30mA | 未断电时依然耗电 | 调试完必须拔掉 |
实测下来,整机待机电流从最初的 300µA 级别降到几十 µA 级别,只做两件事:让 DA14531 进入 Extended Sleep,让 RA4M2 进入低功耗模式并关闭调试串口供电。
6.2 DA14531 怎么进睡眠
DA14531 的睡眠由协议栈管理,应用层要设置允许睡眠的宏,同时保证 UART 引脚在睡眠时不能浮空导致误触发。DA14531 睡眠时,UART 接收边沿可以唤醒芯片(由引脚配置决定),唤醒后协议栈自动恢复。
这里有个容易踩的坑:睡眠期间,如果 RA4M2 通过 UART 发来一帧较长的数据,DA14531 唤醒需要时间,前几个字节可能直接丢失。如果业务要求主控随时能唤醒 DA14531 并可靠收数据,要么在协议层做“唤醒后再重发”,要么给 DA14531 的 UART RX 加一个专用的唤醒中断响应逻辑。
6.3 RA4M2 侧的策略
RA4M2 进低功耗前,要把 UART 接收引脚配成外部中断唤醒模式,因为 SCI 外设在深度睡眠下不工作,不能靠 UART 中断唤醒。唤醒后重新初始化 UART 外设,再继续业务。这个流程要测的是“唤醒后第一次接收是否稳定”,很多低功耗产品就是挂在唤醒后外设重初始化这个环节。
我的做法是把“UART 初始化”抽成一个独立函数,睡眠前关闭外设、配置唤醒引脚;唤醒后调用相同初始化函数,确保状态一致。这比在睡眠前后到处加补丁代码要省心。
6.4 实测功耗的两个重要教训
第一个教训是:测量待机电流时,JTAG/SWD 调试器和 USB 转串口模块必须拔掉。一开始我没拔,测出来整机电流一直在 5mA 以上,怎么调都下不去,后来发现是 USB 转串口模块通过 TX/RX 引脚给 DA14531 灌电,造成假的高电流。拔掉后数据立刻正常。
第二个教训是:RA4M2 开发板上的电源指示灯和电平转换芯片也会耗电,模块和核心板分开量才能拿到真实数据。产品化阶段,建议自己画最小系统板来测,开发板的数据只能做参考。
项目做完整套链路后,我的体会是:RA4M2 和 DA14531 的组合关键在于“各管一摊”,协议栈不用挤在一个芯片里,业务代码也不用为了低功耗做过多妥协。而双向透传能不能稳定跑,最终考验的不只是驱动,而是协议设计是否认真处理了粘包、分包、流控和时序。
最后再分享一个小技巧:在 RA4M2 和 DA14531 之间串一个十六进制日志口,两边同时打点,联调时所有问题都能迅速定位在“主控侧”“蓝牙侧”还是“手机侧”。调试阶段多花的这一根线,能省下大量猜谜时间。