简介:面向C51单片机开发者的NRF24L01无线通信实现方案,基于2.4GHz频段,覆盖SPI接口配置、收发模式切换、数据管道监听与中断处理等关键环节,适用于智能家居、遥控系统等短距离通信场景。包体共12个文件,包含C源文件、hex固件烧录文件、uvproj工程配置,以及lst、m51、obj、bak等编译辅助文件,完整反映Keil工程从编辑到烧录的整个过程,压缩后仅35KB,便于快速下载使用。测试程序已经实际运行验证,可直接套用:发送端填充数据启动传输,接收端通过数据管道监听并读取内容,同时给出CE、CSN、SCK、MISO、MOSI引脚定义,帮助快速搭建硬件;开发者还可在此基础上修改通信频道、发射功率、CRC校验与地址配置,用于多设备组网,也可结合源码梳理寄存器初始化顺序、发送状态机与接收中断服务例程。目前已有160人学习下载,适合需要快速落地NRF24L01模块并理解底层收发逻辑的嵌入式开发者。
1. 为什么一个 2.4G 模块的 C51 工程值得拆开看
在 51 单片机上调 NRF24L01,最常见的失败不是数据发不出去,而是收发双方各自以为自己在工作:发送端 STATUS 的 TX_DS 置位,接收端却连 RX_DR 都等不到。这个问题通常不在无线链路,而在 CE 脉冲宽度、SPI 时序和寄存器初始化顺序这些肉眼难查的细节。nrf.zip 里这套 Keil C51 工程,把发送、接收、寄存器配置都收敛在 nrf.c 一个文件内,配合 nrf.hex 可以直接烧到板子上验证链路。
它不是库,也不算驱动全家桶,更像一份经过实机验证的最小可运行参考。适合两类人:一类是刚接触 NRF24L01,想用 C51 快速打通 2.4G 链路的人;另一类是在 STM32 上调试失误,想回来对照 51 的裸机时序找原因的人。下文会从引脚定义、SPI 读写、寄存器配置到发送接收代码逐层拆,最后回到 nrf.zip 的工程文件和烧录排错。
2. NRF24L01 硬件特性与 C51 引脚映射
2.1 收发前端与 CE/CSN 的职责
NRF24L01 的行为和普通串口 Wi-Fi 模块不同,它并不负责把串口数据直接变成无线电。芯片内部是 2.4GHz 射频前端、带自主协议处理的状态机、一个 32 级发送 FIFO 和 3 级接收 FIFO,单片机只通过 SPI 读写寄存器。CE 脚是模块状态机的总开关:待机时 CE=0,发送模式下 CE 从低到高会触发一个数据包发射,接收模式下 CE 保持高电平持续接收。注意一个细节:发送模式的 CE 高电平只需要 10us 以上,不必一直保持;而接收模式如果 CE 掉到低,芯片会立刻退出 RX 状态,已经收到的包还留在 FIFO 里,但不会继续收新包。
CSN 是 SPI 片选输入,低电平有效。所有 SPI 操作必须把 CSN 拉低后再动 SCK,结束再拉高。SCK 空闲电平为低,MOSI 在上升沿被采样,MISO 在下沿换数据。很多人把 CSN 和 CE 接反,程序里用普通 IO 置位清零看似正常,但以为 CSN 拉高就使能发射,最终导致空中没有任何信号。
IRQ 是漏极开路输出,内部三个事件 TX_DS、RX_DR、MAX_RT 任何一个置位都会把 IRQ 拉低。C51 中断资源紧张时,可以把 IRQ 接到 P3.2 外部中断 0;不用中断时也可以轮询 STATUS 寄存器,只是不能把 IRQ 拉低当作接收的唯一判断,因为发送完成同样会拉低 IRQ。
2.2 nrf.c 里的引脚定义与接线参考
nrf.c 顶部的 sbit 定义是整个工程的接线说明。用 Keil C51 时,sbit 不能像普通变量一样跨文件 extern,所以常见做法是把整套引脚定义放在 nrf.c 文件最顶部,改板子只需要改这几行:
// nrf.c 中引脚定义,按实际接线修改 sbit NRF_CE = P1^0; // Chip Enable,发送时拉高触发 sbit NRF_CSN = P1^1; // SPI 片选,低有效 sbit NRF_SCK = P1^2; // SPI 时钟 sbit NRF_MOSI = P1^3; // 主机输出 sbit NRF_MISO = P1^4; // 主机输入 sbit NRF_IRQ = P3^2; // 中断输出,不用时可不接| 模块引脚 | 方向 | 作用 | 推荐接法 |
|---|---|---|---|
| VCC | 电源 | 1.9V-3.6V 供电 | 3.3V,不能直接接 5V |
| GND | 电源 | 地 | 与 MCU 共地 |
| CE | 输入 | 使能/触发发送 | P1.0 |
| CSN | 输入 | SPI 片选 | P1.1 |
| SCK | 输入 | SPI 时钟 | P1.2 |
| MOSI | 输入 | SPI 数据输入 | P1.3 |
| MISO | 输出 | SPI 数据输出 | P1.4 |
| IRQ | 输出 | 中断请求 | P3.2(可选) |
这组定义决定了 CSN 和 CE 都由普通 IO 控制,因此工程里用软件 SPI 是必然选择。如果换板子,只需要改 sbit 行,不用动寄存器逻辑。MISO 是模块的 3.3V 输出,直接接 5V 单片机通常能读到高电平,但如果你发现回读的数据一直不对,先在 MISO 上串一个 1k 电阻再试。
2.3 电源与电平匹配:为什么模块会热、数据会丢
NRF24L01 发射瞬间峰值电流在十几毫安级别,稳压性能差的模块供电会出现明显跌落,表现是发送端偶尔成功、偶尔连 ACK 都收不到。常见做法是在模块 VCC 和 GND 之间放一个 10uF 电解电容和一个 0.1uF 陶瓷电容,电容尽量靠近模块引脚。C51 开发板大多是 5V 供电,模块必须用 3.3V LDO 供电,不能从 5V 直接串电阻降压。
信号线方面,CE、CSN、SCK、MOSI 都从 5V 单片机输出,可以串 330R 到 1k 电阻限流;MISO 是模块输出,方向不能接反。如果模块长期工作在 5V VCC,内部 LDO 会过热,表现为传输距离明显变短甚至 Irq 一直拉低。调试这类问题,先用万用表量模块 VCC 引脚,空载 3.3V 不等于发射瞬间也有 3.3V,必要时用示波器看发射瞬间的电压塌陷。
3. C51 环境下的 SPI 初始化与寄存器配置
3.1 软件 SPI:模式 0 的关键约定
C51 的增强型型号有硬件 SPI,但引脚位置和使用习惯不一,nrf.c 这类测试工程普遍用普通 GPIO 模拟 SPI,原因有两个:一是 89C52 本身没有硬件 SPI,二是软件 SPI 的时钟频率可控,方便兼容不同型号的单片机。NRF24L01 的 SPI 接口按模式 0 处理,SCK 空闲为低,数据在上升沿采样,MISO 在下降沿之后输出新的数据位。这里有一个很隐蔽的坑:如果同一个引脚之前被初始化成输出高,比如驱动过继电器,SCK 一上电就是高电平,NRF24L01 会把第一个无意义的上升沿当成传输启动,之后寄存器回读可能全 FF。所以 nRF24L01_SPI_RW 函数里每个字节循环开始前都要把 SCK 拉低,初始化函数的开头也要先强制 NRF_SCK = 0。
提示:如果所有寄存器回读都是 0xFF,优先检查 CSN 在传输期间是否一直保持低电平,以及 SCK 空闲是否为低。
3.2 字节读写与寄存器读写函数
软件 SPI 读写单字节是 NRF24L01 通信的地基。下面这段代码按 MSB first 发送,同时从 MISO 读回数据:
unsigned char nRF24L01_SPI_RW(unsigned char byte) { unsigned char i, bit_mask = 0x80, ret = 0; for (i = 0; i < 8; i++) { NRF_SCK = 0; if (byte & bit_mask) NRF_MOSI = 1; else NRF_MOSI = 0; NRF_SCK = 1; ret <<= 1; if (NRF_MISO) ret |= 0x01; bit_mask >>= 1; } NRF_SCK = 0; return ret; }这段代码的核心逻辑是:每个 bit 先在 SCK 低电平时把 MOSI 电平准备好,然后拉高 SCK 让模块采样,同时把 MISO 读回来移入 ret。NRF24L01 最高支持 8Mbps 的 SPI 时钟,软件模拟在 12MHz 晶振下通常能跑到几百 kbps,完全够用。注意 ret 是在 SCK 高电平期间读入的,在这个速度下采样窗口足够宽,但如果改用超频后的 STC 单片机,仍要确保 GPIO 翻转后的建立时间不要小于 50ns。
有了字节读写,再封装寄存器读写。NRF24L01 的寄存器指令格式是最高位代表读写方向,低 5 位是寄存器地址,写入指令为 0x20 | reg,读取指令为 0x00 | reg:
unsigned char nRF24L01_WrReg(unsigned char reg, unsigned char val) { unsigned char status; NRF_CSN = 0; status = nRF24L01_SPI_RW((reg & 0x1F) | 0x20); // W_REGISTER nRF24L01_SPI_RW(val); NRF_CSN = 1; return status; } unsigned char nRF24L01_RdReg(unsigned char reg) { unsigned char val; NRF_CSN = 0; nRF24L01_SPI_RW(reg & 0x1F); // R_REGISTER val = nRF24L01_SPI_RW(0x00); NRF_CSN = 1; return val; }返回的 status 是第一条指令执行时模块在 STATUS 寄存器里吐出的当前值,可以用来判断上一次操作是否完成。CSN 在整个读写下拉过程中不能中途拉高,否则指令被截断。一个常见的错误是在 SPI_RW 内部也操作 CSN,会导致所有寄存器写入都在半路丢失。
3.3 初始化顺序与关键寄存器参数表
NRF24L01 的寄存器数量多,但真正决定链路能否建立的只有那么几个。一个能跑通点对点的初始化流程如下:
#define CONFIG 0x00 #define EN_AA 0x01 #define EN_RXADDR 0x02 #define SETUP_AW 0x03 #define SETUP_RETR 0x04 #define RF_CH 0x05 #define RF_SETUP 0x06 #define STATUS 0x07 void nRF24L01_Init_TX(void) { NRF_CE = 0; // 配置期间关闭收发 nRF24L01_WrReg(CONFIG, 0x0E); // 2 字节 CRC,PWR_UP,发送模式 nRF24L01_WrReg(EN_AA, 0x01); // 仅 pipe0 自动应答 nRF24L01_WrReg(EN_RXADDR, 0x01); // 启用 pipe0 nRF24L01_WrReg(SETUP_AW, 0x03); // 5 字节地址宽度 nRF24L01_WrReg(SETUP_RETR, 0x1A); // 500us 间隔,最多重发 10 次 nRF24L01_WrReg(RF_CH, 0x40); // 无线频道 64 nRF24L01_WrReg(RF_SETUP, 0x26); // 2Mbps,0dBm nRF24L01_WrReg(STATUS, 0x0E); // 清三个中断标志 DelayMs(2); // 等待 PWR_UP 稳定 }对应参数表如下:
| 寄存器 | 地址 | 初始化值 | 说明 |
|---|---|---|---|
| CONFIG | 0x00 | 0x0E | 上电、2 字节 CRC、发送模式 |
| EN_AA | 0x01 | 0x01 | 仅 pipe0 自动应答 |
| EN_RXADDR | 0x02 | 0x01 | 只开 pipe0 接收地址 |
| SETUP_AW | 0x03 | 0x03 | 地址宽度 5 字节 |
| SETUP_RETR | 0x04 | 0x1A | 500us 间隔,10 次重发 |
| RF_CH | 0x05 | 0x40 | 2.440GHz 附近 |
| RF_SETUP | 0x06 | 0x26 | 2Mbps,0dBm |
| STATUS | 0x07 | 0x0E | 清 TX_DS、RX_DR、MAX_RT |
CONFIG 的 0x0E 是发送模式配置,接收模式要把第 3 位置 1,也就是 0x0F。如果做双向通信,切换模式时先 CE=0,再改 CONFIG,再 CE=1,不能直接修改 PRIM_RX 位。SETUP_RETR 只在自动应答模式下有效,高 4 位是重发间隔,低 4 位是重发次数,0x1A 的间隔对大多数近距离场景足够。初始化完成后不要急着发数据,先确认 STATUS 回读不是 0xFF,再往下走。
RF_CH 是收发双方必须严格一致的参数。工程里初始化为 0x40 相当于 2.440GHz,如果你的板子附近有 Wi-Fi 或其他 2.4G 设备,可以改到 0x50 再测试,注意两端必须一起改。RF_SETUP 的 0x26 表示 2Mbps 速率和 0dBm 发射功率,想降低误码率可以把速率降到 1Mbps,把 RF_SETUP 改成 0x20 附近的值,但 1Mbps 与 2Mbps 两端的速率设置不一致时,收包会表现为偶发错误。
4. 发送与接收程序设计:数据管道、状态机与中断处理
4.1 发送包:CE 脉冲和清中断的顺序
写数据包时不能只把载荷写进 FIFO,还要处理自动应答模式下的地址关系。发送端的 RX_ADDR_P0 必须等于接收端的接收地址,因为芯片要在发完数据后切换到接收状态去等 ACK。先补两个多字节读写函数:
void nRF24L01_WrBuf(unsigned char reg, unsigned char *buf, unsigned char len) { NRF_CSN = 0; nRF24L01_SPI_RW((reg & 0x1F) | 0x20); while (len--) nRF24L01_SPI_RW(*buf++); NRF_CSN = 1; } void nRF24L01_RdBuf(unsigned char reg, unsigned char *buf, unsigned char len) { NRF_CSN = 0; nRF24L01_SPI_RW(reg & 0x1F); while (len--) *buf++ = nRF24L01_SPI_RW(0x00); NRF_CSN = 1; }然后发送函数可以写成:
unsigned char tx_addr[5] = {0xB3, 0xB3, 0xB3, 0xB3, 0xB3}; unsigned char nRF24L01_Send(unsigned char *buf) { unsigned char status; unsigned int timeout = 0xFFFF; NRF_CE = 0; // 进入待机,停止发送/接收 nRF24L01_WrReg(STATUS, 0x0E); // 清中断 nRF24L01_WrBuf(RX_ADDR_P0, tx_addr, 5); nRF24L01_WrBuf(TX_ADDR, tx_addr, 5); NRF_CSN = 0; nRF24L01_SPI_RW(0xA0); // WR_TX_PLOAD while (nRF_TX_PLOAD_WIDTH--) // 写入有效载荷 nRF24L01_SPI_RW(*buf++); NRF_CSN = 1; NRF_CE = 1; DelayUs(15); // 保持 CE 高电平至少 10us NRF_CE = 0; // 触发发射 while (((status = nRF24L01_RdReg(STATUS)) & 0x0E) == 0) { if (--timeout == 0) return 0xFE; } nRF24L01_WrReg(STATUS, 0x0E); if (status & 0x10) return 1; // TX_DS:发送完成 if (status & 0x20) return 0; // MAX_RT:重发超限 return 0xFF; }这里有几个顺序不能乱。先清 STATUS,是为了让上次的发送完成标志不干扰本轮等待;写 TX_ADDR 和 RX_ADDR_P0 使用同一个 tx_addr,是为了自动应答模式下能收到 ACK;写 TX_FIFO 之后必须给一个大于 10us 的 CE 高电平,数据包才会真正进入发射流程,否则只是留在 FIFO 里。等待循环用了超时保护,避免在 MAX_RT 标志未置位时死循环。返回值 1 表示发送成功且收到 ACK,0 表示达到重发上限,0xFE 表示超时,0xFF 留给其他异常。
4.2 接收函数:轮询和中断 ISR
接收端初始化要做的额外事情是把 CONFIG 改为 0x0F,并把 RX_ADDR_P0 设为与发送端一致的地址。接收函数读 STATUS,看 RX_DR 位是否置位:
#define RX_PLOAD_WIDTH 8 unsigned char nRF24L01_Recv(unsigned char *buf) { unsigned char status = nRF24L01_RdReg(STATUS); if (status & 0x40) { // RX_DR:收到新数据 nRF24L01_RdBuf(0x61, buf, RX_PLOAD_WIDTH); // RD_RX_PLOAD nRF24L01_WrReg(STATUS, 0x0E); // 清中断 return 1; } return 0; }0x61 是读接收载荷的命令。读完必须清 STATUS 的 RX_DR,否则 IRQ 一直为低,下一次轮询会重复读到同一包。如果使用中断方式,把 NRF_IRQ 接到外部中断 0,在下降沿中断服务函数里先回读 STATUS,判断 RX_DR 后再调用 nRF24L01_RdBuf;如果是 MAX_RT,先清标志再决定是否重发。中断函数里尽量缩短 SPI 操作,不要在 ISR 里跑长延时函数,否则 C51 的中断嵌套和重入问题会很快暴露。
4.3 数据管道、自动应答和地址字节序
NRF24L01 最多支持 6 个接收数据管道,EN_RXADDR 里每一位对应一个管道。pipe0 和 pipe1 拥有完整的 5 字节地址,pipe2 到 pipe5 只有最低一个字节可配置,高 4 字节继承自 pipe1。单对单通信时全部使用 pipe0 最省事,不需要把 EN_AA 和 EN_RXADDR 开太多。特别注意,EN_AA 开启的管道数量不要超过 EN_RXADDR,否则没有对应接收地址的管道也会尝试回 ACK,反而降低接收灵敏度。
地址字节序是 NRF24L01 最容易踩的坑。芯片在空中按 LSB first 发送地址,写入地址寄存器时需要把想要的可读地址反过来。比如想用逻辑地址 0x0102030405,数组要写成:
unsigned char tx_addr[5] = {0x05, 0x04, 0x03, 0x02, 0x01};如果两端都用同一个数组,通信也能成立,但用无线抓包工具会看到地址是反的。判断地址是否匹配,不要凭肉眼比较十六进制字符串,直接确认两端数组内容相同即可。
4.4 STATUS 寄存器状态机怎么判断
STATUS 的低 5 位里,bit5 是 TX_DS,bit4 是 MAX_RT,bit3 是 RX_DR。三者的含义和处理逻辑如下:
| 标志位 | 含义 | 处理方式 |
|---|---|---|
| TX_DS (bit5) | 数据发送完成,自动应答模式下表示已收到 ACK | 清标志,继续下一包 |
| MAX_RT (bit4) | 达到最大重发次数,链路可能断开 | 清标志,检查地址和 RF_CH |
| RX_DR (bit3) | 接收 FIFO 中有新数据 | 读 FIFO,清标志 |
处理状态机时建议先判断 MAX_RT,再判断 TX_DS,因为 MAX_RT 置位时 TX_DS 不会同时置位。调试时如果发现发送端总是返回 0,重点查 SETUP_RETR 是否被写成了 0x00,这会禁用重发,导致开了自动应答却收不到 ACK,发送端误报失败。
5. 从 nrf.zip 到板级验证:Keil 工程文件、编译链接与烧录排错
5.1 打开 nrf.uvproj 之前先确认 C51 编译器
把 nrf.zip 解压后,里面有一组 Keil 工程文件。nrf.uvproj 是 uVision 工程文件,直接双击或从 Keil 的 Project-Open 打开;nrf.uvopt 是调试和窗口布局配置;nrf.lnp 是链接器控制文件,由 Keil 根据 Target 选项自动生成,不需要手工编辑;nrf.M51 是内存分布文件,编译后可以查看 51 的 data、idata、xdata 占用;nrf.LST 是 C 编译列表文件;nrf.hex 是最终烧录文件,用 STC-ISP 或通用编程器直接烧。
如果电脑里之前只装过 Keil5 的 ARM 编译器,打开这个工程会发现 Target 选项里看不到 C51 相关设置,编译直接报工具链错误。这种情况需要补装 Keil C51 编译器,装完后重新打开工程即可。Keil5 要兼容 C51 和 STM32 工程,正确做法是安装时把 C51 和 ARM 两个编译器都选上,或者后续单独补装。双击 nrf.uvproj 后如果弹出迁移提示,直接确认,uVision 会自动转换工程。
5.2 烧录后验证:从回读 STATUS 开始
第一次拿到 nrf.hex,不要急着收发数据,先烧录后打开串口或仿真,回读 STATUS 寄存器。如果读到 0xFF,说明 SPI 物理层有问题,优先检查 CSN 和 SCK 的信号极性。如果 STATUS 能读到 0x0E,说明寄存器通路已经打通,可以用两套板子做回环测试。
实际调试时我的习惯是先固定单字节载荷 0xAA,把 RF_CH 固定,再动地址。发送端在 nRF24L01_Send 返回 1 后,用逻辑分析仪抓 CE 引脚,能看到 CE 高电平保持约 15us 后拉低;如果 CE 根本没拉高,说明程序卡在地址写入阶段。接收端再把收到的数据从串口打印出来,整条链路就闭环了。最后调重发参数时,自动应答模式下 SETUP_RETR 的时间间隔不要低于 500us,给 ACK 接收留足窗口;两端的 RF_CH、SETUP_AW 和地址数组必须完全一致,只改发送端不改接收端,是偶发丢包的第一原因。
本文还有配套的精品资源,点击获取