1. 一次“只能发不能收”的串口故障,让我重新认识了NRF52832的RTS/CTS
大概两年前,我在做一个基于NRF52832的多从机BLE数据采集项目,板子上一路UART负责接收传感器主板的高速数据,另一路通过BLE通知手机端。当时遇到一个非常诡异的问题:传感器主板每10ms发一帧28字节的数据,板子上电后UART发送一切正常,但接收几乎不触发,偶尔能收到几个字节还是乱码。用逻辑分析仪抓RX引脚波形,数据帧完整、波特率也正确,但NRF52832就是“视而不见”。后来查了整整两天,根因出在CTS引脚的电平状态上——这正是标题里RTS/CTS硬件流控最容易被忽视的坑。
NRF52832作为一颗BLE SoC,串口通信和普通MCU不一样,它需要在应用逻辑和BLE协议栈之间来回切换,CPU经常被SoftDevice抢占。如果只是简单把TX/RX接上、调个波特率就想稳定跑大数据量,几乎不可能。所以我这次把串口通信完整梳理了一遍,从SDK配置到硬件流控(RTS/CTS)实战,把配置项、接线逻辑、排查链路全部摊开聊,适合正在做NRF52832开发、或者从STM32转过来还没摸清Nordic SDK风格的人参考。文章风格偏实战,不聊教科书,只聊我真实踩过的坑和验证过的参数。
2. 驱动选型与SDK配置:为什么Nordic的UART“看着简单,用着全是坑”
2.1 两套API并存:nrf_drv_uart和app_uart不要混着用
NRF5 SDK里串口有两套常见的使用方式,这对新手来说是最先遇到的坑。一套是legacy驱动,头文件是nrf_drv_uart.h,底层基于EasyDMA,通过事件回调工作,支持阻塞和非阻塞收发。另一套是app_uart.h,它在驱动之上封装了一层应用FIFO,配合app_fifo和app_scheduler使用,典型的例程就是官方仓库里的ble_app_uart。
我见过很多人在论坛上复制代码,一会儿用nrf_drv_uart_rx_buffer,一会儿又用app_uart_put,编译过了但运行逻辑错乱。两套API定位不一样:
| 对比项 | nrf_drv_uart | app_uart |
|---|---|---|
| 底层机制 | EasyDMA + 事件回调 | FIFO缓冲 + 底层驱动 |
| 适用场景 | 大数据量、需要控制DMA收发的场景 | 不定长字符串、简单串口透传 |
| 依赖模块 | nrf_drv_uart驱动本身 | app_fifo、可选app_scheduler |
| 接收方式 | 注册回调,DMA搬运到指定缓冲区 | 中断+应用层FIFO,数据先落地再处理 |
| 排查难度 | 较直接,层级少 | 黑盒一层,出错时不好定位 |
我的建议是:新项目直接用nrf_drv_uart.h,原因很简单——它更贴近底层,DMA行为可控,出了问题能追到寄存器和事件层面。app_uart虽然写起来快,但内部多了一层FIFO,如果串口数据量大且BLE任务频繁抢占,FIFO满了以后的处理逻辑很容易把人绕晕。
2.2 sdk_config.h里那些“写了等于没写”的关键宏
Nordic工程的串口配置集中在sdk_config.h,但很多人只改了代码里的参数,忽略了配置文件里的宏开关,结果编译时IDE不报错,运行时行为却完全不是预期。以下几个宏我建议逐个确认:
// 使能UART0驱动 #define UART0_ENABLED 1 // legacy驱动是否支持EasyDMA #define NRF_DRV_UART_EASY_DMA 1 // UART0在驱动层默认是否使用EasyDMA #define UART0_CONFIG_USE_EASY_DMA 1 // UART0中断优先级,实测放低优先级最稳 #define UART0_CONFIG_IRQ_PRIORITY 3 // 非EasyDMA模式下默认的接收缓冲区大小 #define UART0_CONFIG_DEFAULT_RX_BUFFER_SIZE 256这里特别容易踩的一个坑是NRF_DRV_UART_EASY_DMA和UART0_CONFIG_USE_EASY_DMA要同时打开。有些模板工程默认只开了前者,后者是0,导致代码里用nrf_drv_uart_rx_buffer时实际走的是传统中断接收,表现就是“偶尔丢字节”“高速传输时断流”。我在一个工程里就因此排查了很久,最后逐个宏比对才发现这个不一致。
另一个和EasyDMA强相关的坑是接收缓冲区。EasyDMA模式要求RX buffer必须位于RAM中,并且驱动会把接收到的数据直接DMA到这块缓冲区。如果缓冲区太小,数据一旦超过长度,后续数据就会直接覆盖旧数据,而且回调触发时机不好把握。实测下来,115200波特率、每10ms一帧28字节的场景,RX buffer至少给到256字节才比较从容。
2.3 引脚分配顺序:别在初始化里“叠buff”
NRF52832的UART引脚是PPI/GPIO可配置的,不像某些MCU锁死在固定引脚上。这本来是优点,但也带来一个经典问题:引脚复用冲突。我见过有人在初始化UART之前,先用nrf_gpio_cfg_output把TX引脚手动拉高,再用驱动初始化,结果驱动配置引脚时反而把之前的状态覆盖,甚至出现TX引脚输出异常的问题。
正确的顺序应该是:先配置时钟和功耗策略,再调用UART驱动初始化,引脚配置统一交给驱动API处理,不要自己在外面手动先配一遍nrf_gpio_cfg_*。如果某个引脚还需要复用给SPI或I2C,一定要在原理图上先确认,NRF52832的P0口一共才32个,RX/TX/RTS/CTS各占一个,对于多外设项目来说引脚规划比写代码更重要。
3. 硬件流控RTS/CTS实战:接线方向、电平逻辑和故障表象
3.1 为什么在NRF52832上,硬件流控几乎是刚需
先解释清楚为什么要用硬件流控,否则很多人会认为“流控是可选项,能不用就不用”。NRF52832的CPU要同时处理BLE协议栈和用户应用逻辑。当连接参数比较激进时(比如连接间隔7.5ms、从机Latency设为0),SoftDevice会频繁占用CPU来收发BLE数据包。此时如果串口对端——例如一块STM32传感主板——正以115200波特率连续发送数据,CPU恰好不在串口中断上,接收缓冲就会被新数据覆盖,造成丢帧。
软件流控XON/XOFF虽然也能暂停对端发送,但依赖接收端实时解析数据内容,一旦数据本身是二进制流,XON/XOFF字符可能混在业务数据里,反而制造混乱。RTS/CTS是纯硬件信号控制,不占数据带宽,接收方处理不过来时直接通过电平通知发送方暂停,这是BLE多任务场景下最可靠的串口流控方案。
3.2 RTS/CTS接线方向:交叉连接,不是同名相连
硬件流控接线最大的误区就是把RTS接RTS、CTS接CTS。RTS/CTS的本质是“反向交叉通知”:发送方的RTS信号应当接接收方的CTS输入,接收方的RTS信号应当接发送方的CTS输入。对于NRF52832来说,具体连接方式如下:
| NRF52832引脚 | 连接目标 |
|---|---|
| NRF_TX | 对端RX |
| NRF_RX | 对端TX |
| NRF_RTS | 对端CTS |
| NRF_CTS | 对端RTS |
| GND | 对端GND |
我在项目里曾经把RTS接到对端RTS,结果是NRF这边发送数据时,对端收到了但没有回应;对端发送数据时,NRF也不接收。因为RTS代表“我有能力接收”,CTS代表“你可以发送”,两者信号含义完全不同,同名直连等于把两个“声明能力”的引脚接在一起,没有形成真正的通知链路。
3.3 电平逻辑:低电平有效,空闲状态是低
RTS/CTS都是低电平有效。RTS输出为低表示“接收方空闲,可以接收”;CTS输入为低表示“对端可以接收,我方可以发送”。当接收方FIFO快满时,把RTS拉高,发送方看到CTS变高后暂停发送;等接收方处理完,再把RTS拉低,发送方恢复发送。
这个逻辑看起来简单,但实际项目里有几个容易翻车的点。第一,USB转串口模块(CH340、CP2102、FT232等)默认很多不启用硬件流控,你即使把NRF侧的RTS/CTS引脚接过去了,模块软件里没开流控选项,RTS/CTS引脚就是悬空或者固定电平,达不到流量控制效果。第二,对端模块开启流控但接线只接了部分线,比如接了RTS没接CTS,这种情况下NRF的CTS引脚悬空,电平不确定,轻则表现为偶发数据卡死,重则直接变成“能发不能收”。
3.4 流控启动失败时的三种典型故障表象
把我在实际项目中遇到的流控相关故障表现整理成一张表,方便大家自查:
| 故障现象 | 可能原因 |
|---|---|
| 发送正常、接收偶尔丢字节 | 对端发送速度过快,RTS信号未正确通知对端暂停 |
| 发送数据卡死,对端收不到 | NRF的CTS引脚电平异常,被置为高,NRF认为对端不可接收 |
| 收发都不工作,逻辑分析仪看波形正常 | RTS/CTS线接反,或引脚复用冲突,驱动初始化失败 |
| 不用流控时一切正常,一开流控就死 | 对端设备不支持RTS/CTS,流控信号悬空 |
最后一种情况尤其值得注意:如果对端只是普通TTL串口,没有RTS/CTS引脚,千万不要在NRF侧把流控强行打开。实在需要流控的场景,应该选用带硬件流控输出的USB转串口模块,并在驱动侧开启对应的流控选项。
4. 一次“CTS引脚悬空”引起的故障完整排查链路
4.1 现象记录与初步判断
某次调试中,我手里的板子症状非常稳定:传感器主板每10ms发一帧28字节数据,NRF52832的串口接收回调偶尔触发一次,收到的内容还是错的。用逻辑分析仪抓NRF_RX引脚,波形完整、波特率正确,说明硬件发送链路没问题。同时,NRF52832向PC端发送数据完全正常,于是初步的判断是:问题出在接收路径的软件配置上,而不是物理链路。
4.2 排查第一步:核对初始化和回调
我当时用的是nrf_drv_uart非阻塞接收,初始化代码如下:
nrf_drv_uart_config_t uart_config = NRF_DRV_UART_DEFAULT_CONFIG; uart_config.pseltxd = TX_PIN_NUMBER; uart_config.pselrxd = RX_PIN_NUMBER; uart_config.pselcts = CTS_PIN_NUMBER; uart_config.pselrts = RTS_PIN_NUMBER; uart_config.baudrate = NRF_UART_BAUDRATE_115200; uart_config.hwfc = NRF_UART_HWFC_ENABLED; nrf_drv_uart_init(&uart_config, uart_event_handler); nrf_drv_uart_rx_buffer(&uart_rx_buf[0], RX_BUFFER_SIZE);从代码层面看,引脚、波特率、流控都配置了,回调函数也有APP_UART_DATA_READY分支。刚开始我怀疑是回调函数里处理太慢,导致DMA缓冲被覆盖,于是把回调里所有业务逻辑全部注释掉,只保留一个计数变量,故障依旧。这说明不是回调处理速度的问题。
4.3 排查第二步:用万用表量CTS引脚,暴露悬空问题
接下来我拿万用表去量NRF_CTS引脚的对地电压,读数在0.3V和1.2V之间漂移,不是典型的高电平也不是典型的低电平。这个信号正是NRF52832判断“对端是否可以接收”的输入,如果对端模块的RTS引脚没有真正拉低或拉高,CTS就会处于悬空状态。而当时我用的USB转串口模块确实只接了TX/RX两条线,模块端的RTS和CTS引脚完全悬空。
这里的逻辑要理清:CTS低电平有效,意思是CTS为低时NRF才认为对端可以接收,允许自己发送。CTS电平不定,NRF内部状态机就可能时而允许发送、时而禁止发送,表面上表现为“发送偶尔卡顿”。但这还不能解释“接收不触发”的问题。于是我进一步想:RTS信号是否也被悬空影响?对端传感器主板没有RTS/CTS引脚,NRF的RTS输出信号处于悬空状态,不能通知对端暂停发送,结果就是传感器主板持续高速发数据,NRF接收路径的FIFO不断溢出,最终数据在驱动层就被丢弃了。
4.4 排查第三步:关闭流控做对照实验,锁定根因
为了确认根因,我把hwfc改成NRF_UART_HWFC_DISABLED,再次上电,收发立刻恢复正常。这基本坐实了问题就出在硬件流控上。之后我在NRF_CTS引脚上加了一个10k欧上拉电阻到VDD,保证CTS引脚在没有对端驱动时默认是高电平,即“对端不可接收”,避免NRF盲目发送;同时把NRF_RTS引脚悬空问题通过连接一个对地电阻固定住,传感器主板直接以无流控方式通信,问题彻底解决。
这次排错的核心结论是:硬件流控必须建立在双方都支持、都接线、都配置到位的前提下,任何一端缺失,RTS/CTS信号就会处于不确定状态,轻则数据卡顿,重则完全收发失败。对于不支持流控的对端设备,宁可关闭流控,也不要留一个悬空的电平让驱动去猜。
4.5 一个自查小技巧:串口回环测试
如果你也遇到串口接收异常,建议先做一个最简单的回环测试:把NRF的TX和RX短接,再把RTS和CTS短接,用串口助手发一串字节,如果接收回来的内容和发送一致,说明驱动状态机和引脚配置基本没问题。做了这个测试还不行,再去查对端设备的流控能力和接线,不要一开始就在协议栈和中断优先级上浪费大量时间。
5. 与BLE共存时的串口体验优化:优先级、调度和缓冲区配置
5.1 SoftDevice对UART中断优先级的限制
NRF52832上跑BLE必须包含SoftDevice协议栈,这直接影响了串口中断的优先级。SoftDevice要求应用层不能把中断优先级设置为高于它可接受的范围,否则可能导致协议栈异常,甚至死机。串口驱动的中断优先级虽然在sdk_config.h里可配,但实测最稳的做法是用APP_IRQ_PRIORITY_LOWEST(数值为3)或者APP_IRQ_PRIORITY_LOW(数值为2)。不要把UART中断优先级拉到最高,否则你会在奇怪的时序下触发SoftDevice硬错,最常见的就是NRF_ERROR_SOFTDEVICE_NOT_ENABLED或无缘无故的复位。
5.2 回调里不要做耗时操作,用Scheduler排队
很多从STM32转过来的人习惯在串口中断回调里直接对数据做解析、写Flash、更新状态机,这在NRF52832上非常危险。因为UART回调运行在中断上下文,BLE协议栈的中断优先级可能在它之上或之下,一旦在回调里处理太久,SoftDevice的内部时序就会被打乱。我在项目里的标准做法是:UART回调只做一件事——用app_sched_event_put把数据指针和长度提交到调度队列,然后在主循环里通过app_sched_execute逐个处理。这种写法把中断上下文的时间压缩到微秒级别,BLE连接稳定性有了明显提升。
static void uart_event_handler(nrf_drv_uart_event_t *p_event, void *p_context) { if (p_event->type == NRF_DRV_UART_EVT_RX_DATA) { uint32_t err_code; // 只做一件事:把数据指针交给调度器,不在这里解析业务逻辑 err_code = app_sched_event_put(&p_event->data.rx_data, sizeof(nrf_drv_uart_rx_data_t)); APP_ERROR_CHECK(err_code); } }5.3 缓冲区大小与调度器队列长度的平衡
串口接收缓冲区太小会丢数据,太大又浪费RAM,NRF52832内部只有64KB RAM,BLE协议栈还要占掉一部分。我在115200波特率下实测的经验值如下:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| UART RX buffer | 256 ~ 512字节 | 满足高速数据帧的暂存,不推荐低于128 |
| app_sched队列长度 | 8 ~ 16 | 取决于单帧数据量和事件提交频率 |
| 调度器事件大小 | 按实际数据长度,建议压缩成指针 | 不要在事件里复制整包大数组 |
| app_timer工作线程优先级 | APP_IRQ_PRIORITY_LOW | 避免和UART冲突 |
如果你的串口数据是定长帧,建议在回调里只提交一个“帧起始标记”,主循环再去DMA缓冲区读取完整数据,这样能大幅减少调度器压力。如果数据流非常频繁且连续,则应考虑把UART接收做成双缓冲乒乓切换,一块缓冲在DMA接收时,另一块在应用层处理,避免DMA覆盖和调度延时的叠加效应。
5.4 时钟源对UART波特率的影响
NRF52832的UART波特率源来自HFCLK,如果硬件上使用的是内部RC振荡器(HFINT),波特率会存在一定偏差,长时间大量传输时可能出现偶发乱码。比较好的做法是确保系统使用外部高频晶振(HFXO),并且在高频时钟稳定后再初始化UART。在SDK配置中可通过NRF_CLOCK相关代码显式启动HFXO,再进入UART收发,实测误码率有明显改善。对于115200波特率,内部RC偏差通常还在可接受范围内,但如果上到460800甚至921600,推荐直接使用外部晶振。