NRF24L01小车控制收发程序:链路对齐与避坑指南
简介:面向嵌入式入门者与遥控小车开发者的NRF24L01无线收发程序参考,聚焦2.4GHz频段下主控与小车端之间的指令传输与执行控制,可用于课程设计、毕业设计及原型验证。压缩包内含2个C源文件,整体约7KB,分别承载无线模块初始化、频道与地址配置、数据包收发以及小车端解码驱动等核心逻辑,便于在Arduino、AVR或STM32等平台上对照移植。已有242人学习,说明其在短距离无线通信入门场景中具有一定参考价值。读者可从中梳理GFSK调制、数据速率与发射功率设置、中断处理、CRC校验和自动重传等环节,并理解控制命令与速度信息如何封装成数据包,再由接收端解析并驱动电机。对希望搭建NRF24L01遥控小车通信链路的读者,这份程序提供了可复用的收发框架与调试思路,也便于后续扩展避障传感器或多车竞技功能。
1. NRF24L01 小车控制收发程序:点亮模块只是第一步,卡住人的往往在链路层
“NRF24L01 小车控制收发程序”这类资源,下载下来第一眼往往很朴素:一个发射端工程、一个接收端工程,加上几个寄存器封装函数,总共不到一千行。真正让人卡住的不是代码量,而是从“烧进去能跑”到“在地上能稳稳跑”之间那段几乎没人写进注释的链路细节。地址少对齐一个字节、CE 脉冲短了几微秒、电机一转电源就塌下去,表现出的现象却是同一个:小车不动。这份资源值得拆的地方,正是把 2.4GHz 短包收发这层黑匣子摊开,让你知道每一帧是怎么被发出去、被确认、被丢掉的。它适合正在做遥控小车、课程设计、短距离遥控链路的人,也适合已经能用但一跑到两三米外就断连、想搞清楚边界在哪的人。
2. 收发链路的分层设计:地址、通道与 32 字节载荷怎么对齐
在真正写业务逻辑之前,先不要急着调用“发送一个包”的函数。NRF24L01 这类模块的很多问题不是软件逻辑错,而是物理层与链路层的参数在两端没对齐。我的习惯是把配置拆成三层看:SPI 这一层决定主控能不能跟模块说上话,地址与通道这一层决定两个模块认不认识对方,载荷这一层决定业务数据能不能被正确解释。三层的出错现象完全不同,但表面症状都是“收不到”,所以先把三层分开,后面排查才会有顺序。
2.1 模块引脚与 SPI 时序:先让寄存器读得出 ID
模块上的八个引脚里,真正需要主控干预的只有五个:CE、CSN、SCK、MOSI、MISO,IRQ 是可选的。CE 决定模块处于收发使能状态,CSN 是 SPI 的片选,它拉低期间才允许传输。SPI 的时钟极性要与模块对齐:CPOL = 0、CPHA = 0,也就是空闲时 SCK 为低电平,第一个上升沿采样。数据位序是 MSB 在前,速率不要超过 8MHz;实际使用中 2 到 4MHz 最稳定,跳线较长、杜邦线较乱的情况下,把速率降到 1MHz 往往能让原本时好时坏的通信变得稳定。
动手前先写三个底层原子操作,后面所有寄存器读写都建立在它们之上。
/* 平台相关的引脚操作,按你手上的 MCU 换成对应的 GPIO 接口 */ static void csn_low(void) { gpio_write(CSN_PORT, CSN_PIN, 0); } static void csn_high(void) { gpio_write(CSN_PORT, CSN_PIN, 1); } static void ce_low(void) { gpio_write(CE_PORT, CE_PIN, 0); } static void ce_high(void) { gpio_write(CE_PORT, CE_PIN, 1); } /* 全双工交换一个字节:发出去的同时把 MISO 上的位收进来。 这里用软件模拟,方便在任何板子上先验证时序;有硬件 SPI 时替换成一行 transfer 即可 */ static uint8_t spi_rw(uint8_t out) { uint8_t in = 0; for (int i = 0; i < 8; i++) { if (out & 0x80) gpio_write(MOSI_PORT, MOSI_PIN, 1); else gpio_write(MOSI_PORT, MOSI_PIN, 0); out <<= 1; gpio_write(SCK_PORT, SCK_PIN, 1); /* 模式 0:上升沿采样 */ in <<= 1; if (gpio_read(MISO_PORT, MISO_PIN)) in |= 0x01; gpio_write(SCK_PORT, SCK_PIN, 0); } return in; } /* 读寄存器:命令字节低 5 位是地址,高位 0 表示读 */ uint8_t nrf_read_reg(uint8_t reg) { uint8_t val; csn_low(); spi_rw(reg & 0x1F); val = spi_rw(0xFF); /* 送哑字节,换回目标寄存器的值 */ csn_high(); return val; } /* 写寄存器:命令字节高位为 001 */ void nrf_write_reg(uint8_t reg, uint8_t val) { csn_low(); spi_rw(0x20 | (reg & 0x1F)); spi_rw(val); csn_high(); }这段代码里有两个容易忽略的点。第一个是片选必须在完整的一字节命令加一字节数据之后才释放,中途不能抖;如果用的是硬件 SPI 加 DMA,务必确认 CSN 的拉低和拉高发生在整次传输的外侧。第二个是那个哑字节 0xFF,它的作用是把模块内部的移位寄存器的时钟打出来,如果不发,读回的值会一直是 0。写完这几个函数,第一件要做的事不是配置通道,而是读状态寄存器:正常情况下上电复位后的值是 0x0E,读 SETUP_AW 寄存器应该得到 0x03。如果一次性读出 0x00 或 0xFF,说明 SPI 根本没通,后面的所有参数配置都是白费功夫。
2.2 五字节地址与通道映射:为什么两边必须逐字节对齐
地址是这套链路里最容易出错的地方,因为它有对称性的陷阱。发射端的 TX_ADDR 要等于接收端的 RX_ADDR_P0,同时发射端自己的 RX_ADDR_P0 也必须写成同样的值——原因是发射端需要靠这个地址接收接收端回传的自动应答包。很多人只改了其中一处,结果现象很有迷惑性:发送状态显示成功,接收端却什么都没收到,因为应答根本没回来。
| 参数 | 寄存器 | 发射端取值 | 接收端取值 | 说明 |
|---|---|---|---|---|
| 地址宽度 | SETUP_AW | 0x03 | 0x03 | 0x03 表示 5 字节地址 |
| 发送地址 | TX_ADDR | 0xA1B2C3D4E5 | — | 仅发射端有效 |
| 管道 0 地址 | RX_ADDR_P0 | 0xA1B2C3D4E5 | 0xA1B2C3D4E5 | 收发两端必须一致 |
| 射频通道 | RF_CH | 76 | 76 | 实际频率 = 2400 + 76 = 2476MHz |
| 空中速率 | RF_SETUP | 0x06 | 0x06 | 1Mbps,兼顾距离与稳定性 |
| 载荷宽度 | RX_PW_P0 | — | sizeof(帧) | 两端必须一致,否则收包被截断 |
地址值的选取也有讲究。五个字节里最好有三个以上不同,不要用 0x00 或 0xFF 去填充,也不要用示例代码里那种 0xE7E7E7E7E7 的经典值。原因很实际:如果同一场地里有两套设备都用了示例地址,并且通道相同,两套小车会互相收到对方的控制帧,表现出来就是小车偶尔听别人的话。多台设备同场时,把地址最后一个字节按车号递增,通道也错开两到三个,是最省事的做法。通道间隔建议至少 2MHz,因为 1Mbps 速率下每个通道占用约 1MHz 带宽,紧邻的通道会有邻道干扰。
2.3 载荷结构体设计:把摇杆、按键、油门压进一帧
载荷宽度的设置在两端必须一致,这是硬性要求。NRF24L01 支持 1 到 32 字节的静态载荷,也支持带长度前缀的动态载荷。遥控小车这种场景,帧长固定、字段固定,用静态载荷更省心,接收端不需要解析长度,也不会因为长度字段被干扰而出错。
#pragma pack(push, 1) /* 关掉结构体对齐填充,保证两端 sizeof 一致 */ typedef struct { uint8_t seq; /* 帧序号,0~255 循环,用于丢包统计与去重 */ int8_t thr; /* 油门 -100 ~ +100,0 为中位 */ int8_t str; /* 转向 -100 ~ +100,正为右转 */ uint8_t btn; /* 按键位图:bit0 喇叭 bit1 大灯 bit2 急停 */ uint8_t chk; /* 整帧异或校验,防止 ACK 载荷串位 */ } ctrl_frame_t; #pragma pack(pop) /* sizeof(ctrl_frame_t) == 5,两端把这个值写进 RX_PW_P0 */用#pragma pack的理由是编译器默认会按 4 字节对齐,seq、thr、str、btn、chk五个字段原本占 5 字节,对齐之后可能变成 8 字节。两端如果一端开了 pack 一端没开,载荷宽度就对不上,接收端拿到的是被截断或者补齐的数据,表现成油门乱跳。校验字节chk不是给无线链路用的,链路层本身有 CRC,它是给应用层用的,用来挡掉那些长度对但内容已经错位的帧,比如 ACK 载荷和普通载荷共用一个管道时的串位。
3. 发射端程序骨架:从摇杆采样到一帧数据进入 TX FIFO
发射端的工作可以拆成三件事:把模块配置成发送模式、把摇杆和按键的物理量翻译成业务字段、在合适的时机把这一帧推进 TX FIFO。这三件事的时序是有依赖的,初始化没完成就写 FIFO,数据会丢;发送没结束就写下一帧,会覆盖还没发出去的内容。所以发射端的代码结构最好是“初始化一次、循环里只做采样和触发”。
3.1 初始化序列:寄存器写入顺序与关键参数
寄存器的写入顺序有讲究,核心原则是先让模块进入掉电或待机状态再配置,最后才通电。CE 拉低是第一步,它保证配置过程中模块不会突然开始收发。
void nrf_tx_init(void) { ce_low(); /* 配置期间必须处于待机 */ delay_ms(10); nrf_write_reg(REG_CONFIG, 0x0A); /* EN_CRC=1, PWR_UP=1, PRIM_RX=0 发送模式 */ nrf_write_reg(REG_EN_AA, 0x01); /* 只让管道 0 开自动应答 */ nrf_write_reg(REG_EN_RXADDR,0x01); /* 使能管道 0,用于接收 ACK */ nrf_write_reg(REG_SETUP_AW, 0x03); /* 5 字节地址宽度 */ nrf_write_reg(REG_SETUP_RETR,0x1A); /* 重发延时 500us,重发 10 次 */ nrf_write_reg(REG_RF_CH, 76); /* 2476MHz,两端一致 */ nrf_write_reg(REG_RF_SETUP, 0x06); /* 1Mbps,0dBm */ nrf_write_addr(REG_TX_ADDR, addr, 5); /* 发送地址 */ nrf_write_addr(REG_RX_ADDR_P0,addr, 5); /* 与发送地址相同,才能收 ACK */ nrf_flush_tx(); /* 清空发送 FIFO */ nrf_flush_rx(); nrf_write_reg(REG_STATUS, 0x70); /* 写 1 清中断标志 */ delay_ms(2); /* 等晶振稳定,规格书要求 1.5ms */ }REG_CONFIG的 0x0A 拆开看是:bit1 置 1 打开 CRC,bit0 置 1 上电,bit0 为 0 表示发送模式。SETUP_RETR的 0x1A 高四位是重发间隔,0x1 对应 500 微秒,低四位是重发次数 10 次,这个组合在室内环境够用;如果现场干扰大,把重发次数提到 15 次,代价是单帧最坏延迟变长。RF_SETUP的 0x06 表示 1Mbps 速率、0dBm 发射功率,如果手上模块带功率放大,0dBm 依然是最稳妥的起点,先跑通再往上调。
3.2 发送主循环:状态机、超时与重发次数
发送一帧的触发条件很明确:CE 需要给出一个至少 10 微秒的高电平脉冲,模块才会把 FIFO 里的内容打包发出去。发送完成后有三种结果,都体现在状态寄存器上:TX_DS 表示收到对方应答,发送成功;MAX_RT 表示重发次数用完仍然没有应答;两者都不置位表示还在发。
/* 返回 0 成功,1 重发超限,2 超时 */ uint8_t nrf_send_frame(const ctrl_frame_t *f) { ce_low(); nrf_flush_tx(); /* 丢掉可能残留的旧帧 */ csn_low(); spi_rw(0xA0); /* W_TX_PAYLOAD 命令 */ const uint8_t *p = (const uint8_t *)f; for (uint8_t i = 0; i < sizeof(*f); i++) spi_rw(p[i]); csn_high(); ce_high(); /* 脉冲宽度 15us,规格要求 >=10us */ delay_us(15); ce_low(); uint32_t t0 = millis(); while (millis() - t0 < 10) { /* 单帧最多等 10ms */ uint8_t st = nrf_read_reg(REG_STATUS); if (st & 0x20) { nrf_write_reg(REG_STATUS, 0x20); return 0; } /* TX_DS */ if (st & 0x10) { nrf_write_reg(REG_STATUS, 0x10); nrf_flush_tx(); return 1; } /* MAX_RT */ } nrf_flush_tx(); return 2; }这套轮询写法比等 IRQ 简单,缺点是占用 CPU。遥控场景的发送频率一般是 50Hz,也就是 20 毫秒一帧,10 毫秒的等待窗口在预算之内。如果主控还要跑别的任务,把 IRQ 引脚接到外部中断更合适,中断里只置标志位,实际清状态放回主循环做。要注意 STATUS 寄存器的清除方式是写 1,不是写 0,写成 0 会把其他标志一起保留下来,后面判断就会出错。
3.3 数据打包与死区处理:摇杆物理量到 -100~+100 的映射
摇杆的原始 ADC 值不能直接发出去,一是范围跟业务无关,二是中位存在个体差异。我一般会做三步:上电时采样若干次取中位作为零点、对零点附近设置死区、把剩余区间线性映射到 -100 到 +100。
#define ADC_CENTER_DEFAULT 2048 #define DEAD_ZONE 60 /* ADC 计数值,约对应摇杆 3% 行程 */ static int16_t center_thr = ADC_CENTER_DEFAULT; static int16_t center_str = ADC_CENTER_DEFAULT; /* 上电校准:连续采 32 次取平均,避开手放在摇杆上的干扰 */ void stick_calibrate(void) { int32_t s1 = 0, s2 = 0; for (int i = 0; i < 32; i++) { s1 += adc_read(CH_THR); s2 += adc_read(CH_STR); delay_ms(2); } center_thr = s1 / 32; center_str = s2 / 32; } int8_t axis_map(int16_t raw, int16_t center) { int16_t d = raw - center; if (d > -DEAD_ZONE && d < DEAD_ZONE) return 0; /* 死区内直接归零 */ if (d > 0) return (int8_t)((d - DEAD_ZONE) * 100 / (2047 - DEAD_ZONE)); return (int8_t)((d + DEAD_ZONE) * 100 / (2048 - DEAD_ZONE)); }死区的作用不只是让手松开时小车不动,它还避免了中位抖动导致的频繁微调。死区取 60 是个经验值,摇杆质量差、回中弹簧软的情况下可以提到 100 到 150;太小了车会在中位附近来回抽搐。映射完成之后,帧序号seq每次发送加一,接收端靠它统计丢包率,这比看信号强度直观得多,因为模块本身并不提供可靠的 RSSI 读数。
4. 接收端程序骨架:从空中包到两路电机 PWM 的落地
接收端的逻辑比发射端多一层:不仅要把包收下来,还要判断这个包是不是可信的、是不是过期的,最后才交给电机。这层判断缺了,小车会出现两种典型故障——收不到包时保持上一次的油门继续跑,或者偶尔收到一个错帧就猛地抽一下。
4.1 接收初始化与取包:轮询还是中断
接收端的地址和载荷宽度必须与发射端的发送地址一致。初始化完成后把 CE 拉高,模块就一直在监听状态,任何时刻收到匹配地址的包都会进 RX FIFO。
void nrf_rx_init(void) { ce_low(); delay_ms(10); nrf_write_reg(REG_CONFIG, 0x0B); /* PRIM_RX=1 接收模式,开 CRC,上电 */ nrf_write_reg(REG_EN_AA, 0x01); /* 管道 0 自动应答 */ nrf_write_reg(REG_EN_RXADDR, 0x01); /* 使能管道 0 */ nrf_write_reg(REG_SETUP_AW, 0x03); nrf_write_reg(REG_RF_CH, 76); nrf_write_reg(REG_RF_SETUP, 0x06); nrf_write_addr(REG_RX_ADDR_P0, addr, 5); nrf_write_reg(REG_RX_PW_P0, sizeof(ctrl_frame_t)); /* 必须等于发射端帧长 */ nrf_flush_rx(); nrf_write_reg(REG_STATUS, 0x70); ce_high(); /* 进入监听 */ delay_ms(2); } /* 有数据返回 1 并填 buf,否则返回 0;非阻塞,可以放在主循环里 */ uint8_t nrf_poll_rx(uint8_t *buf, uint8_t len) { uint8_t st = nrf_read_reg(REG_STATUS); if (!(st & 0x40)) return 0; /* RX_DR 未置位,没新包 */ csn_low(); spi_rw(0x61); /* R_RX_PAYLOAD */ for (uint8_t i = 0; i < len; i++) buf[i] = spi_rw(0xFF); csn_high(); nrf_write_reg(REG_STATUS, 0x40); /* 写 1 清 RX_DR */ nrf_flush_rx(); /* FIFO 只有三级,及时清 */ return 1; }这里有个细节值得说:RX FIFO 深度只有三级,如果主循环取包不够快,新包会把旧包挤掉,而且 FIFO 满了之后模块会停止应答,发射端那边就会看到 MAX_RT。所以取包之后立刻flush_rx是必要的。另外RX_PW_P0一定要写,很多人忘了这一句,模块默认载荷宽度是 0,会把所有包当成空包丢掉,现象就是发射端显示发送成功、接收端永远收不到。
4.2 帧校验与失联保护:让小车在丢包时停下来
无线的本质是不可靠,链路层有 CRC 和自动重发,但那只保证“这一帧要么完整要么没到”,不保证“帧一定按时到”。业务层要自己做两件事:一是校验内容是否合理,二是判断这帧是否已经过期。
static uint32_t last_rx_ms = 0; static uint8_t last_seq = 0; static uint16_t lost_cnt = 0, total_cnt = 0; /* 返回 1 表示这一帧可信,可以用于控制 */ uint8_t frame_check(const ctrl_frame_t *f) { uint8_t x = 0; const uint8_t *p = (const uint8_t *)f; for (uint8_t i = 0; i < sizeof(*f) - 1; i++) x ^= p[i]; if (x != f->chk) return 0; /* 校验失败,丢弃 */ if (f->thr < -100 || f->thr > 100) return 0; /* 越界值直接不信 */ if (f->str < -100 || f->str > 100) return 0; total_cnt++; uint8_t gap = (uint8_t)(f->seq - last_seq - 1); if (gap && gap < 128) lost_cnt += gap; /* 序号跳变即丢包 */ last_seq = f->seq; last_rx_ms = millis(); return 1; } /* 主循环里每 10ms 调一次,超过 200ms 没收到可信帧就停车 */ uint8_t link_alive(void) { return (millis() - last_rx_ms) < 200; }200 毫秒这个阈值是按 50Hz 发送频率算的,相当于连续丢 10 帧才判定失联。如果发送频率降到 20Hz,阈值要相应放宽到 300 到 400 毫秒,否则正常丢几帧就会误触发停车,开起来一顿一顿的。判定失联之后的动作必须是主动刹车,把两路 PWM 直接写 0 并拉低电机使能,不能只是“不更新”,因为不更新等于保持上一次的占空比,这是最容易翻车的地方。
4.3 差速混控与 PWM 限幅:把油门和转向合成两路输出
控制量映射到电机是最后一步。两轮差速小车,常用的是油门加转向的混控:左轮 = 油门 + 转向,右轮 = 油门 − 转向,结果再限幅到 −100 到 +100。
void mixer(int8_t thr, int8_t str, int16_t *out_l, int16_t *out_r) { int16_t l = (int16_t)thr + str; int16_t r = (int16_t)thr - str; /* 等比例缩放而不是简单截断,保留转向比例关系 */ int16_t peak = 0; if (l > peak) peak = l; if (-l > peak) peak = -l; if (r > peak) peak = r; if (-r > peak) peak = -r; if (peak > 100) { l = l * 100 / peak; r = r * 100 / peak; } *out_l = l; *out_r = r; } /* 占空比换算:注意电机启动死区,小占空比带不动 */ void apply_pwm(int16_t l, int16_t r) { uint16_t dl = (l == 0) ? 0 : (uint16_t)(PWM_MIN + abs(l) * (PWM_MAX - PWM_MIN) / 100); uint16_t dr = (r == 0) ? 0 : (uint16_t)(PWM_MIN + abs(r) * (PWM_MAX - PWM_MIN) / 100); motor_set(MOTOR_L, l >= 0, dl); motor_set(MOTOR_R, r >= 0, dr); }为什么会用等比例缩放而不是直接截断?直接截断的话,油门 100、转向 50 时左轮算出来 150 被截成 100,右轮 50 保留,两轮的比值从 3:1 变成了 2:1,实际转弯半径会比预期小。缩放保住了比例关系,代价是整体速度下降,但在低速遥控场景里这点损失可以接受。PWM_MIN是电机启动死区,普通减速电机大概在 15% 到 25% 之间,这个值要实测,设小了车不走,设大了低速段一格一格跳。
5. 避坑与排查:调不通时按这五条顺序查
这套链路的坑分布很有规律,从下往上依次是电源、时序、配置、业务。排查时按这个顺序走,不要一上来就怀疑地址配错,因为大部分“收不到”其实是电源或者 SPI 的问题。
5.1 通信层踩坑:读不到寄存器与永远重发超限
现象一:状态寄存器读回一直是 0x00 或 0xFF。原因通常是 SPI 根本没通,可能是 CSN 拉低和第一个时钟沿之间没有间隔,或者 MISO 被外部上拉、下拉钳住,也可能是软件模拟 SPI 的位序搞反了。解决方式是先把时钟速率降到 1MHz 以下,用示波器或者逻辑分析仪看 CSN 与第一个 SCK 上升沿的关系,确认 CSN 先拉低至少半个时钟周期。MISO 悬空时读回 0xFF 是正常的,读回 0x00 一般说明线接反或者引脚配置成了输出。
现象二:发送端一直返回 MAX_RT,接收端没反应。原因有三个层次:地址不匹配(包括宽度设成 4 字节但只写了 5 个字节)、通道不一致、两端的自动应答配置不一致。最常见的是一次改了两边代码却只烧录了一边。解决方式是把两端的地址、通道、速率、载荷宽度四个参数打印出来逐项对比,不要在脑子里对。另外EN_AA在发射端和接收端都要打开,只开一端的话应答不会回来,同样会一直重发。
现象三:能收到包,但距离只有一两米,出了房间就断。原因是发射功率和其他参数没配对,或者模块供电不足。0dBm 在空旷环境的标称距离是几十米,室内有墙的话十米左右是常见值。解决方式是先把速率从 2Mbps 降到 1Mbps 甚至 250kbps,速率越低灵敏度越高、距离越远;再检查模块背面那个小电容,很多廉价模块只焊了一个 100nF,旁边并一个 10uF 的钽电容能明显改善。
现象四:两套设备同场时互相干扰。原因是地址和通道都用了示例代码里的默认值。解决方式是每台设备分配不同的地址末字节与不同的通道,并且同一套设备的发射端和接收端参数要成对改。现场设备多于三套时,建议固定几组“地址+通道”组合预先烧录,不要在现场临时改。
5.2 电源与机械层踩坑:电机一转就断连
现象五:小车静止时通信正常,一给油就丢包、甚至模块失联需要重新上电。原因是电机换向瞬间的电流冲击把电源拉塌,同时电刷火花产生宽带干扰,直接盖住了 2.4GHz 频段的一部分。这是血泪经验里最典型的一条。解决方式分三步:电机两端各焊一个 100nF 陶瓷电容,外壳到端子再各接一个,抑制火花;电机与主控、模块分开供电,或者至少在模块电源脚旁边放 10uF 加 100nF 的退耦组合;模块的天线区域远离电机线和大电流走线,能拉开五厘米以上就拉开。做到这三点,绝大多数“一动就断”都能解决。
现象六:上电偶尔不工作,重新插拔一次就好。原因是电源上升沿太慢,模块内部上电复位没有完成,主控就开始发配置命令了。解决方式是在初始化最开始加 10 到 50 毫秒的延时,并且在写完 PWR_UP 之后严格等 1.5 毫秒以上再拉高 CE。这个延时看着不起眼,却是“玄学不通”的常见来源。
6. 进阶用法:用 ACK 载荷把电池电压捎回遥控器
小车端的状态想回传,最直觉的做法是让小车也当发射端,收到指令后切到发送模式回一帧。这种方式的代价是切换模式要时间,而且切换期间收不到指令,链路会周期性出现空窗。更省事的做法是用自动应答的附带载荷:应答包本身可以携带最多 32 字节数据,接收端在收到指令的同时把遥测塞进应答里,发射端不需要切换模式就能从自己的 RX FIFO 里读出来。
接收端(小车端)侧,把应答载荷写进管道 0 的 ACK 缓冲区。
/* 在 nrf_rx_init 之后调用一次,之后每次数据变化再调用 */ void nrf_set_ack_payload(const uint8_t *data, uint8_t len) { ce_low(); /* 写 ACK 载荷时必须先退出接收态 */ csn_low(); spi_rw(0xA8); /* W_ACK_PAYLOAD,管道 0 */ for (uint8_t i = 0; i < len; i++) spi_rw(data[i]); csn_high(); ce_high(); } /* 主循环里每 100ms 更新一次:电池电压、丢包计数、电机堵转标志 */ void telemetry_update(void) { uint8_t t[3]; t[0] = (uint8_t)(battery_mv() / 20); /* 压缩成 1 字节,20mV 分辨率 */ t[1] = (uint8_t)(lost_cnt > 255 ? 255 : lost_cnt); t[2] = motor_stall_flag; nrf_set_ack_payload(t, sizeof(t)); }发射端侧,应答载荷会作为普通数据进入 RX FIFO,需要主动去读,同时发射端的EN_RXADDR和RX_ADDR_P0必须已经配置好,这也是第 2 章强调发射端也要写 RX_ADDR_P0 的真正原因。
/* 每次 nrf_send_frame 成功之后调用,把应答里的遥测读出来 */ uint8_t nrf_read_ack_payload(uint8_t *buf, uint8_t len) { uint8_t st = nrf_read_reg(REG_STATUS); if (!(st & 0x40)) return 0; /* RX_DR 未置位,说明应答没带载荷 */ csn_low(); spi_rw(0x61); /* R_RX_PAYLOAD */ for (uint8_t i = 0; i < len; i++) buf[i] = spi_rw(0xFF); csn_high(); nrf_write_reg(REG_STATUS, 0x40); return 1; }这套做法的边界要说清楚:应答载荷只有在这一帧被成功应答时才会回来,如果发射端处于 MAX_RT 状态,就读不到遥测,所以遥测的更新频率天然跟随链路质量,链路差的时候正好也是你最需要知道的时候,这反而成了一个有用的信号。另外W_ACK_PAYLOAD必须在 CE 拉低的状态下写,在接收态直接写会被忽略,这个坑我踩过一次,现象是遥测永远是上电第一帧的值,查了半天以为是校验位错。
从那以后我每次调这类链路,都强制走一遍固定顺序:先读状态寄存器确认 SPI 通,再打印两端的四个关键参数逐项对比,然后空载测三米内的丢包率,最后才装车带电机动起来测。跳过前三步直接装车的,基本都要返工。希望帮到你。
本文还有配套的精品资源,点击获取