简介:这份资源面向嵌入式初学者与PIC单片机开发者,提供16F676通过SPI接口驱动nRF24L01无线模块完成收发通信的完整程序,可用于智能家居、无线遥控、物联网设备等短距离2.4GHz通信场景的学习与二次开发。压缩包共22个文件,约56KB,以C源码、头文件、HEX烧录文件、MPLAB工程文件及编译中间文件为主,另含少量说明文本,覆盖从源码到可烧录固件的完整链路。目前已有324人学习下载。读者可从中获取SPI初始化、nRF24L01工作模式与通道频率配置、数据收发缓冲区操作、中断标志处理及CRC错误与重传监控等关键实现思路,并借助工程文件在MPLAB X IDE中直接编译调试,快速理解PIC单片机与无线模块协同工作的软硬件配合方式。
1. 用 PIC16F676 驱动 nRF24L01:8 位小单片机也能跑无线收发
手头只有一颗 8 引脚、Flash 才 1K 字的 PIC16F676,却想让它通过 nRF24L01 收发数据,这事听起来有点勉强,但确实能跑通。我最早做这个组合,是因为一个电池供电的遥控采集节点,主控只能用最便宜的小片子,无线部分又不想用红外或者 433 超再生那种玄学方案,最后选了 nRF24L01。PIC16F676 没有硬件 SPI,所有时序都得靠软件模拟,这是整个方案里最需要耐心的地方。这篇文章面向的是手里有 PIC16F676 和 nRF24L01 模块、想自己写收发程序但被时序和寄存器卡住的嵌入式开发者。我会把 SPI 模拟、寄存器配置、收发流程、常见翻车点都拆开讲,代码可以直接抄去改。
2. 先搞清楚 PIC16F676 和 nRF24L01 的硬件边界
2.1 为什么这两颗芯片搭配起来需要特别处理
PIC16F676 是 Microchip 的一款 8 位闪存单片机,14 引脚封装(实际可用 I/O 是 12 个),1K 字程序存储器,64 字节 RAM,没有硬件 SPI、没有硬件 UART,只有一个 8 位定时器和比较器。nRF24L01 是 Nordic 的 2.4GHz 收发芯片,通过 SPI 接口访问内部寄存器,支持 125 个频道、6 个管道、自动应答和自动重传。两者放在一起,最大的矛盾在于:nRF24L01 的 SPI 时钟最高可以到 10MHz,但 PIC16F676 在 4MHz 晶振下指令周期是 1μs,软件模拟 SPI 时一个时钟周期至少需要几个指令周期,实际能跑到的 SPI 速率大概在 100kHz 到 250kHz 之间。这个速率对 nRF24L01 来说完全够用,因为它的寄存器操作本身就不需要高速,真正影响的是收发切换时的响应延迟。
另一个边界是 RAM。PIC16F676 只有 64 字节 RAM,nRF24L01 的一包数据最多 32 字节,加上状态变量、指针、临时缓冲,很容易就把 RAM 吃满。我一般会留出至少 8 字节给栈和临时变量,实际可用于收发缓冲的也就 40 字节左右。这意味着你不能在中断里做大数组操作,也不能嵌套太多函数调用。
2.2 引脚分配与最小系统接线
PIC16F676 的引脚不多,必须精打细算。下面是我常用的接线方案,用 PORTA 和 PORTC 的部分引脚:
| PIC16F676 引脚 | 功能 | 接 nRF24L01 引脚 | 说明 |
|---|---|---|---|
| RA0 | 模拟输入/数字 I/O | 不接 | 留作电池电压检测 |
| RA1 | 数字 I/O | CE | 使能收发模式 |
| RA2 | 数字 I/O | CSN | 片选,低有效 |
| RA3 | 数字 I/O | SCK | SPI 时钟 |
| RA4 | 数字 I/O | MOSI | 主机输出 |
| RA5 | 数字 I/O | MISO | 主机输入 |
| RC0 | 数字 I/O | IRQ | 中断输出,可选 |
| VDD | 电源 | VCC | 3.3V |
| VSS | 地 | GND | 共地 |
注意 nRF24L01 的供电是 3.3V,不能直接接 5V。PIC16F676 如果跑在 5V,SPI 信号线上需要做电平转换,最简单的方法是在 MOSI、SCK、CSN、CE 上串 1kΩ 电阻再并 3.3V 稳压管,或者直接用 3.3V 给 PIC 供电。我一般让整个系统跑 3.3V,省掉电平转换的麻烦。
2.3 软件模拟 SPI 的时序要求
nRF24L01 的 SPI 模式是 Mode 0(CPOL=0,CPHA=0),也就是时钟空闲为低,数据在上升沿采样。PIC16F676 模拟 SPI 时,需要手动控制 SCK 的高低和数据的输出/采样。下面是最基础的三个函数:
// SPI 写一个字节,返回同时读到的字节 unsigned char spi_transfer(unsigned char data) { unsigned char i; unsigned char recv = 0; for (i = 0; i < 8; i++) { // 准备 MOSI:先放数据,再拉高 SCK if (data & 0x80) MOSI = 1; else MOSI = 0; data <<= 1; SCK = 1; // 上升沿,nRF24L01 采样 MOSI __delay_us(1); // 保持高电平至少 1us recv <<= 1; if (MISO) recv |= 0x01; // 下降沿前读取 MISO SCK = 0; // 下降沿 __delay_us(1); } return recv; }这段代码里,__delay_us(1)是为了保证 SCK 高低电平各维持至少 1μs,对应大约 250kHz 的 SPI 时钟。如果你把 PIC 超频到 8MHz,可以把延时缩短,但没必要,因为 nRF24L01 的寄存器操作对速度不敏感。关键点是:数据必须在 SCK 上升沿之前稳定,MISO 在 SCK 下降沿之前读取。很多新手翻车是因为先拉高 SCK 再改 MOSI,导致 nRF24L01 采样到错误的数据。
3. nRF24L01 的寄存器配置与收发流程
3.1 必须配置的五个核心寄存器
nRF24L01 有 20 多个寄存器,但实际做点对点收发,只需要重点配置下面五个:
| 寄存器地址 | 名称 | 配置值(示例) | 作用 |
|---|---|---|---|
| 0x00 | CONFIG | 0x0E 或 0x0F | 使能 CRC、16 位 CRC、上电、收发模式 |
| 0x01 | EN_AA | 0x01 | 使能管道 0 自动应答 |
| 0x02 | EN_RXADDR | 0x01 | 使能管道 0 接收 |
| 0x03 | SETUP_AW | 0x03 | 地址宽度 5 字节 |
| 0x05 | RF_CH | 0x4C | 射频频道 76(2.476GHz) |
| 0x06 | RF_SETUP | 0x07 | 0dBm 发射功率、2Mbps 速率 |
CONFIG 寄存器的 0x0E 表示:EN_CRC=1,CRCO=1(16 位 CRC),PWR_UP=1,PRIM_RX=0(发射模式)。如果要切到接收模式,把最低位改成 1,即 0x0F。RF_SETUP 的 0x07 表示 2Mbps 速率、0dBm 功率,如果通信距离不够,可以改成 0x06(1Mbps)或 0x05(250kbps),速率越低灵敏度越高。
3.2 写寄存器与读寄存器的命令格式
nRF24L01 的 SPI 命令分两类:写寄存器是 0x20 | 寄存器地址,读寄存器是 0x00 | 寄存器地址。写数据到发送缓冲区是 0xA0,读接收缓冲区是 0x61。下面是对应的封装函数:
// 写一个寄存器 void nrf_write_reg(unsigned char reg, unsigned char value) { CSN = 0; // 片选拉低 spi_transfer(0x20 | reg); // 写命令 + 寄存器地址 spi_transfer(value); // 写入的值 CSN = 1; // 片选拉高,锁存 } // 读一个寄存器 unsigned char nrf_read_reg(unsigned char reg) { unsigned char value; CSN = 0; spi_transfer(0x00 | reg); // 读命令 + 寄存器地址 value = spi_transfer(0xFF); // 发送哑数据,同时读回 CSN = 1; return value; } // 写发送缓冲区,payload 是数据指针,len 是长度 void nrf_write_payload(unsigned char *payload, unsigned char len) { unsigned char i; CSN = 0; spi_transfer(0xA0); // 写 TX FIFO 命令 for (i = 0; i < len; i++) spi_transfer(payload[i]); CSN = 1; }注意spi_transfer(0xFF)里的 0xFF 是哑数据,因为 SPI 是全双工,主机必须发送一个字节才能产生时钟,从而读回从机的数据。CSN 必须在整个命令序列期间保持低电平,命令字节和数据字节之间不能拉高,否则 nRF24L01 会认为命令结束。
3.3 发射流程与接收流程的代码实现
发射一包数据的完整流程是:拉低 CE 进入待机、写 TX 地址、写 payload、拉高 CE 触发发射、等待 IRQ 或查询 STATUS 寄存器。接收流程是:配置为接收模式、拉高 CE、等待 IRQ、读 payload、清中断标志。下面是简化后的收发函数:
// 发射一包数据,返回 1 表示成功,0 表示失败 unsigned char nrf_tx_packet(unsigned char *data, unsigned char len) { unsigned char status; CE = 0; nrf_write_reg(0x00, 0x0E); // 发射模式 nrf_write_reg(0x04, 0x00); // 清 STATUS 中断标志 nrf_write_payload(data, len); // 写数据到 TX FIFO CE = 1; // 触发发射 __delay_us(20); // 保持 CE 高至少 10us CE = 0; // 等待发射完成,最多等 10ms for (unsigned int timeout = 0; timeout < 1000; timeout++) { status = nrf_read_reg(0x07); // 读 STATUS if (status & 0x20) // TX_DS 置位,发射成功 return 1; if (status & 0x10) // MAX_RT 置位,重传失败 return 0; __delay_us(10); } return 0; } // 接收一包数据,返回数据长度,0 表示没收到 unsigned char nrf_rx_packet(unsigned char *buf) { unsigned char status, len; status = nrf_read_reg(0x07); if (status & 0x40) { // RX_DR 置位,收到数据 CSN = 0; spi_transfer(0x61); // 读 RX FIFO 命令 len = spi_transfer(0xFF); // 第一个字节是长度 for (unsigned char i = 0; i < len; i++) buf[i] = spi_transfer(0xFF); CSN = 1; nrf_write_reg(0x07, 0x40); // 清 RX_DR 标志 return len; } return 0; }发射函数里,CE 拉高后必须保持至少 10μs,nRF24L01 才会真正启动发射。等待 TX_DS 或 MAX_RT 的超时时间我设了 10ms,实际在 2Mbps 速率下,一包 32 字节的数据发射时间不到 200μs,加上自动重传,10ms 足够。接收函数里,读 RX FIFO 的第一个字节是数据长度,这个长度是发射端写入的 payload 长度,不是固定值。
4. 避坑与常见问题排查
4.1 现象:发射端一直返回失败,STATUS 的 MAX_RT 置位
原因通常是接收端没有上电、地址不匹配、频道不一致,或者自动应答配置错误。nRF24L01 的自动重传机制要求接收端开启 EN_AA 并且地址匹配,否则发射端会一直重传直到 MAX_RT。解决方法是先用nrf_read_reg读回接收端的 CONFIG、EN_AA、RF_CH、RX_ADDR_P0 四个寄存器,确认和发射端一致。特别是 RX_ADDR_P0 必须和发射端的 TX_ADDR 相同,很多人只配了 TX_ADDR 忘了配 RX_ADDR_P0。
4.2 现象:接收端收不到数据,但发射端显示成功
这种情况多半是接收端的 CE 没有拉高,或者 PRIM_RX 位没有置 1。nRF24L01 在接收模式下,CE 必须保持高电平才能持续监听。另外,接收端的 EN_RXADDR 必须使能对应的管道,如果用的是管道 0,EN_RXADDR 的最低位必须是 1。我遇到过因为nrf_write_reg(0x02, 0x01)写成了0x00,接收端完全不响应,查了半天才发现是寄存器值写错。
4.3 现象:通信距离很短,几厘米就断
先检查电源。nRF24L01 在发射瞬间电流会冲到 12mA 以上,如果电源去耦不好,电压会跌落导致芯片复位。我一般会在 nRF24L01 的 VCC 和 GND 之间并一个 10μF 钽电容和一个 100nF 陶瓷电容,越靠近模块越好。其次检查 RF_SETUP 的速率和功率设置,2Mbps 速率虽然快但灵敏度低,改成 1Mbps 或 250kbps 能明显增加距离。最后检查天线,PCB 天线周围不能铺地,也不能有金属遮挡。
4.4 现象:SPI 读写寄存器返回值全是 0x00 或 0xFF
这是典型的 SPI 时序问题。先用示波器看 SCK、MOSI、CSN 三根线,确认 CSN 拉低后 SCK 才有波形,MOSI 在 SCK 上升沿之前已经稳定。如果没有示波器,可以写一个最简单的测试:向 CONFIG 寄存器写 0x0E,再读回来,如果读回的不是 0x0E,说明 SPI 通信本身有问题。常见原因是 MISO 方向配置错误,PIC16F676 的 TRIS 寄存器没有把 MISO 对应的引脚设为输入。
4.5 现象:程序跑一段时间后死机或数据错乱
PIC16F676 的 RAM 只有 64 字节,如果收发缓冲定义得太大,或者中断里用了太多局部变量,栈会溢出。我一般把收发缓冲定义为全局数组,大小不超过 32 字节,中断里只置标志位,不做数据处理。另外,nRF24L01 的 STATUS 寄存器在每次操作后最好读一次并清除,否则中断标志累积会导致误判。
5. 进阶技巧:用状态机把收发逻辑串起来
5.1 为什么裸写收发函数容易翻车
上面给的nrf_tx_packet和nrf_rx_packet是阻塞式的,发射时要等 10ms,接收时要轮询。在实际项目里,如果主循环还有其他任务,比如读传感器、驱动数码管,阻塞式收发会让整个系统响应变慢。更麻烦的是,如果发射失败需要重发,或者接收和发射需要切换,裸写函数很容易把状态搞乱。我早期做的一个遥控器,就是因为发射函数里等了太久,按键响应延迟到肉眼可见。
5.2 用状态机管理收发切换
状态机的思路是把收发过程拆成几个状态:空闲、配置为发射、写数据、等待发射完成、配置为接收、等待接收。每个状态只做一件事,主循环每次进来推进一步。下面是一个简化的状态机框架:
// 收发状态定义 typedef enum { STATE_IDLE, STATE_TX_SETUP, STATE_TX_SEND, STATE_TX_WAIT, STATE_RX_SETUP, STATE_RX_WAIT } rf_state_t; rf_state_t rf_state = STATE_IDLE; unsigned char tx_buf[32]; unsigned char tx_len; unsigned char rx_buf[32]; unsigned char rx_len; // 主循环里调用,每次推进一步 void rf_task(void) { unsigned char status; switch (rf_state) { case STATE_IDLE: // 空闲时切到接收模式 rf_state = STATE_RX_SETUP; break; case STATE_TX_SETUP: CE = 0; nrf_write_reg(0x00, 0x0E); // 发射模式 nrf_write_reg(0x04, 0x00); // 清中断 nrf_write_payload(tx_buf, tx_len); rf_state = STATE_TX_SEND; break; case STATE_TX_SEND: CE = 1; __delay_us(20); CE = 0; rf_state = STATE_TX_WAIT; break; case STATE_TX_WAIT: status = nrf_read_reg(0x07); if (status & 0x20) { // 发射成功 nrf_write_reg(0x07, 0x20); rf_state = STATE_RX_SETUP; } else if (status & 0x10) { // 重传失败 nrf_write_reg(0x07, 0x10); rf_state = STATE_RX_SETUP; } // 否则继续等,不改变状态 break; case STATE_RX_SETUP: CE = 0; nrf_write_reg(0x00, 0x0F); // 接收模式 nrf_write_reg(0x07, 0x40); // 清 RX_DR CE = 1; rf_state = STATE_RX_WAIT; break; case STATE_RX_WAIT: status = nrf_read_reg(0x07); if (status & 0x40) { // 收到数据 rx_len = nrf_rx_packet(rx_buf); rf_state = STATE_IDLE; } break; } }这个状态机里,STATE_TX_WAIT和STATE_RX_WAIT不会阻塞,每次调用只查一次 STATUS,没完成就下次再查。主循环可以以 1ms 或更快的周期调用rf_task,按键和传感器处理放在主循环其他位置,互不干扰。注意STATE_TX_WAIT里没有超时计数,实际使用时需要加一个计数器,超过一定次数强制回到STATE_RX_SETUP,否则发射失败会卡死。
5.3 用 IRQ 引脚减少轮询开销
如果 PIC16F676 的引脚够用,可以把 nRF24L01 的 IRQ 接到 RC0 并配置外部中断。IRQ 在发射成功、接收成功、重传失败时都会拉低,中断里只置一个标志位,主循环根据标志位决定下一步。这样rf_task里就不需要频繁读 STATUS 寄存器,SPI 总线占用更少。PIC16F676 的 RB0/INT 引脚也可以用作外部中断,但要注意中断触发边沿配置。
5.4 地址和频道的动态切换
点对点通信时,地址和频道固定就行。但如果要做一对多,或者避免干扰,可以在运行时切换。nRF24L01 的 RX_ADDR_P0 到 RX_ADDR_P5 可以分别设置不同地址,发射时通过 TX_ADDR 指定目标。频道切换直接写 RF_CH 寄存器即可,但切换后需要重新进入接收模式。我一般把频道和地址做成一个配置表,存在 PIC16F676 的 EEPROM 里,上电时读出来初始化。
5.5 一个我踩过的坑:CE 和 CSN 的时序关系
最后说一个血泪经验。nRF24L01 的 CE 和 CSN 不能同时拉低,否则芯片会进入不确定状态。正确的顺序是:先拉低 CSN 做 SPI 操作,操作完拉高 CSN,再拉高 CE 触发发射或接收。我早期写代码时,在nrf_write_payload里忘了拉高 CSN,紧接着就拉高 CE,结果 nRF24L01 一直不工作,查了两天才发现是 CSN 没有释放。后来我养成了一个习惯:每个 SPI 操作函数结束时都强制CSN = 1,不管之前是什么状态。这个习惯帮我省了很多后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取