POCSAG 协议考古 - SX1276 Packet Mode 实现
上一篇文章讨论了硬件选型,最终选择了基于 Ebyte E32 开发板(SX1276 + STM32F103C8T6)的方案。这一篇来聊聊软件实现——如何用最简单的方式接收 POCSAG 信号。
现有开源实现:Direct Mode 的"全手动挡"路线
GitHub 上能找到的几个 POCSAG/LBJ 接收项目,不约而同地选择了同一条路:SX1276 的 Direct Mode(直接模式,也叫 Continuous Mode)。要理解 Packet Mode 为什么更好,首先得看清楚 Direct Mode 到底在做什么。
SX1276 的 Continuous Mode 硬件配置
以 SX1276_Receive_LBJ 为例,它的底层是 RadioLib 的 SX127x 驱动。首先看 SX127x::directMode() 如何配置芯片:
1int16_t SX127x::directMode() {
2 setMode(RADIOLIB_SX127X_STANDBY);
3
4 // DIO1 → DCLK (连续数据时钟输出), DIO2 → DATA (连续解调数据输出)
5 SPIsetRegValue(REG_DIO_MAPPING_1,
6 RADIOLIB_SX127X_DIO1_CONT_DCLK | RADIOLIB_SX127X_DIO2_CONT_DATA, 5, 2);
7
8 // 不需要等 preamble 或 RSSI 触发,直接开始接收
9 setAFCAGCTrigger(RADIOLIB_SX127X_RX_TRIGGER_NONE);
10
11 // 切换到 Continuous Mode(PACKET_CONFIG_2 bit 6 = 1)
12 return SPIsetRegValue(REG_PACKET_CONFIG_2,
13 RADIOLIB_SX127X_DATA_MODE_CONTINUOUS, 6, 6);
14}
三个关键操作:
- DIO 重映射:DIO1 由"FifoLevel 中断"变成"DCLK 时钟输出",DIO2 变成"DATA 数据输出"。此时 SX1276 不再是一个"收包"的芯片,而退化成一个纯 FSK 解调器——它解调出比特流,但不对数据做任何结构化处理。
- RX Trigger 设为 NONE:不需要 preamble 也不等 RSSI,芯片持续输出解调比特。
- Packet Mode → Continuous Mode:关闭了 Packet Engine,FIFO 不再自动管理。
RadioLib 的比特级状态机
硬件输出 DCLK+DATA 后,MCU 端需要逐 bit 处理。RadioLib 的做法是:把 DIO1(DCLK)接到 MCU 的 GPIO 中断引脚,每个 DCLK 上升沿触发一次 ISR。调用链如下:
1DCLK 上升沿 (DIO1)
2 → EXTI ISR
3 → PagerClientReadBit() // 全局静态回调
4 → SX127x::readBit(pin)
5 → digitalRead(DIO2) // 读 DATA 引脚,得到 1 bit
6 → PhysicalLayer::updateDirectBuffer(bit) // 核心状态机
updateDirectBuffer() 是 RadioLib Direct Mode 的核心——一个用纯软件实现的比特流解析状态机:
1void PhysicalLayer::updateDirectBuffer(uint8_t bit) {
2 // 阶段 1: 载波检测 —— 连续 64 个全 0 或全 1 说明频道有信号
3 if (!gotCarrier && !gotPreamble && !gotSync) {
4 carrierBuffer = (carrierBuffer << 1) | bit;
5 if (carrierBuffer == 0x0000000000000000 ||
6 carrierBuffer == 0xFFFFFFFFFFFFFFFF) {
7 gotCarrier = true;
8 }
9 }
10
11 // 阶段 2: Preamble 检测 —— 64 个交替 1/0
12 if (!gotPreamble && !gotSync) {
13 preambleBuffer = (preambleBuffer << 1) | bit;
14 if (preambleBuffer == 0xAAAAAAAAAAAAAAAA ||
15 preambleBuffer == 0x5555555555555555) {
16 gotPreamble = true;
17 }
18 }
19
20 // 阶段 3: 同步字匹配 —— 逐 bit 移位比对 FSC (0x7CD215D8)
21 if (!gotSync) {
22 syncBuffer = (syncBuffer << 1) | bit;
23 if ((syncBuffer & syncWordMask) == syncWord) {
24 gotSync = true;
25 bufferWritePos = 0; // 复位缓冲指针,准备收数据
26 bufferBitPos = 0;
27 }
28 }
29 // 阶段 4: 数据收集 —— 同步后逐 bit 拼成字节
30 else {
31 if (bit) buffer[bufferWritePos] |= (0x01 << bufferBitPos);
32 else buffer[bufferWritePos] &= ~(0x01 << bufferBitPos);
33 bufferBitPos++;
34 if (bufferBitPos == 8) { // 凑满 1 字节
35 buffer[bufferWritePos] = reflect(buffer[bufferWritePos], 8);
36 bufferWritePos++;
37 bufferBitPos = 0;
38 }
39 }
40}
四个阶段依次流转,每个 ISR 都要跑一遍:
| 阶段 | 实现 | 触发条件 |
|---|---|---|
| 载波检测 | 软件 64-bit 移位寄存器 | 连续 64 个 0 或 64 个 1 |
| Preamble 检测 | 软件 64-bit 移位寄存器 | 交替 1/0 序列(0xAAAA 或 0x5555) |
| 同步字匹配 | 软件 32-bit 移位寄存器 | 与 FSC(0x7CD215D8 取反)匹配 |
| 逐 bit 收数据 | 软件每 8 bit 位反转,写入缓冲区 | 同步后自动进入 |
这套状态机每个 ISR(也就是每个 bit)都要跑一遍——移位寄存器、掩码比对、条件分支。对 1200 bps 的 POCSAG 来说,每秒触发 1200 次 ISR;对 2400 bps 就是 2400 次。在 72 MHz 的 STM32F103 上当然跑得动,但代价是 CPU 被持续打断,ISR 里做了一堆本该由硬件完成的工作。
updateDirectBuffer 收完字节后,上层 PagerClient 再按 POCSAG 协议做 BCH 纠错、地址匹配、BCD/ASCII 解码——这部分无论是 Direct Mode 还是 Packet Mode 都绕不开,属于应用层逻辑,不展开。
RP2040 方案:用 PIO 卸掉 bit 采样
RP2040-Based-LBJ-Receiver 的 SX1276 配置和 RadioLib 完全一样——Continuous Mode,DIO1=DCLK,DIO2=DATA。区别在于 bit 采样不再用 GPIO 中断,而是用 RP2040 的 PIO 硬件状态机:
1@rp2.asm_pio(in_shiftdir=rp2.PIO.SHIFT_LEFT, autopush=True, push_thresh=32)
2def pocsag_rx():
3 label("wait_low")
4 jmp(pin, "wait_low") # DCLK 高电平时等待
5 label("wait_high")
6 jmp(pin, "read_data") # DCLK 上升沿 → 读数据
7 jmp("wait_high") # 否则继续等
8 label("read_data")
9 in_(pins, 1) # 从 DATA 引脚读 1 bit,移入 ISR
autopush=True, push_thresh=32 意味着 PIO 每收满 32 个 bit 就自动推送到 RX FIFO。主循环用 sm.get() 读出下一个 32-bit 块——注意 PIO 不知道 codeword 边界,这个 32-bit 窗口可能横跨两个 codeword。具体对齐靠后续软件的同步字匹配解决:
1while self.sm.rx_fifo() > 0:
2 word = (self.sm.get() ^ 0xFFFFFFFF) & 0xFFFFFFFF # 极性取反
3 for i in range(31, -1, -1):
4 bit = (word >> i) & 1
5 if not self.synced:
6 # 逐 bit 移入 32 位窗口,匹配 FSC = 0x7CD215D8
7 self.sync_window = ((self.sync_window << 1) | bit) & 0xFFFFFFFF
8 if self.sync_window == self.POCSAG_SYNC:
9 self.synced = True
10 else:
11 # 同步后逐 bit 拼 codeword,做 BCH 纠错,解 BCD 数字
12 ...
PIO 把最底层的 bit 采样从 ISR 中解放出来,但同步字匹配仍然靠 MicroPython 逐 bit 移位比对 FSC——这部分在 Packet Mode 里是 SX1276 硬件自动完成的。至于 BCH 纠错和 BCD 解码,属于应用层协议处理,两种方案都绕不开。
两套方案共同的问题
两种方案的差异只在最底层的 bit 采样方式不同(ISR vs PIO),但再往上看,同步字匹配这一步都是软件逐 bit 做的。SX1276 的硬件链路本是:
1天线 → 解调 → Packet Engine(preamble 检测 + 同步字匹配 + FIFO)→ MCU
Direct Mode 把 Packet Engine 旁路了,用软件重做了一遍它本来就能干的事。而 Packet Engine 本身就能做 preamble 检测、同步字匹配和 FIFO 管理——只要把 POCSAG 的数据格式映射到它的工作模型上。
为什么 Packet Mode 完全可行
回到第一篇中的 POCSAG 协议格式。一次完整的 POCSAG 传输的结构是:
1Preamble (576 bit) | Batch 1 | Batch 2 | ...
每个 Batch:
1FSC (32 bit) | Frame 0 (64 bit) | ... | Frame 7 (64 bit)
一个 Batch 去掉 FSC 之后,正好是 8 × 64 = 512 bit = 64 字节。
关键在于:POCSAG 的 Batch 结构是定长的。每一帧固定 2 个 codeword(64 bit),每个 Batch 固定 8 帧 + 1 个同步码(544 bit)。虽然不同消息的 codeword 数量不同,但这不影响我们按 Batch 为单位接收——就好比一个固定大小的卡车,无论里面装了多少件货,车身尺寸是不变的。
所以策略很简单:
- 把 SX1276 的同步字寄存器设为 FSC(
0x7CD215D8) - Payload 长度设为 64 字节(一个 Batch 去掉 FSC 的部分)
- 让硬件自动检测 preamble、匹配 FSC,然后收满 64 字节扔进 FIFO
- DIO0 触发 PayloadReady 中断后,一次性把 FIFO 读出来
这就把问题从"逐 bit 采样、拼装"简化成了"收到中断,读一块 buffer"。
代码实现
完整代码已开源在 pocsag_receiver,下面挑关键部分讲。
初始化
SX1276 的 FSK Packet Mode 初始化基于 Semtech 官方 SDK 的 SX1276FskInit,在此基础上覆盖 POCSAG 相关的寄存器:
1void SX1276_FSK_Init(uint32_t freq, uint32_t bitrate, uint32_t fdev,
2 uint32_t rx_bw)
3{
4 // 接入 Semtech SDK
5 SX1276InitIo();
6 SX1276Reset();
7
8 // 基本 FSK 参数
9 FskSettings.RFFrequency = freq;
10 FskSettings.Bitrate = bitrate; // 1200 bps
11 FskSettings.Fdev = fdev; // 4500 Hz
12 FskSettings.RxBw = rx_bw; // 12.5 kHz
13 FskSettings.RxBwAfc = rx_bw;
14 FskSettings.CrcOn = false; // POCSAG 自带 BCH,不需要 CRC
15 FskSettings.AfcOn = true; // 开启自动频率校正
16 FskSettings.PayloadLength = 64; // 一个 Batch 去掉 FSC
17
18 SX1276FskInit();
19
20 // POCSAG 覆盖寄存器
21 SX1276Write(REG_PACKETCONFIG1, 0x00); // 定长包,关闭 CRC/地址过滤
22 SX1276Write(REG_SYNCVALUE1, 0x83); // FSC (0x7CD215D8) 取反
23 SX1276Write(REG_SYNCVALUE2, 0x2D);
24 SX1276Write(REG_SYNCVALUE3, 0xEA);
25 SX1276Write(REG_SYNCVALUE4, 0x27);
26 SX1276Write(REG_PREAMBLELSB, 0x20); // Preamble 检测长度
27 SX1276Write(REG_RXTIMEOUT2, 4); // 约 53ms 超时
28}
几个值得注意的点:
同步字取反。POCSAG 协议规定高频代表 0、低频代表 1,而 SX1276 的 FSK 默认是高频 = 1。二者极性相反,所以写入 SX1276 同步字寄存器时需要整体按位取反。FSC 0x7CD215D8 取反后为 0x832DEA27,拆成四个字节就是 0x83, 0x2D, 0xEA, 0x27。
Payload 长度设为 64 字节。这是整个方案的核心——收满一个 Batch 的数据。Packet Mode 下 SX1276 的硬件会先检测 preamble,匹配到 FSC 后开始往 FIFO 填数据,填满 64 字节后触发 PayloadReady。
DIO 映射与中断
SX1276 有 6 个 DIO 引脚,通过 REG_DIOMAPPING1 和 REG_DIOMAPPING2 可以灵活映射:
1// DIO0 → PayloadReady (00) 收满 PayloadLength 字节时触发
2// DIO1 → FifoLevel (00) FIFO 达到阈值时触发
3SX1276Write(REG_DIOMAPPING1, 0x00);
DIO0 和 DIO1 各自服务于不同目的:
- DIO0 (PayloadReady):64 字节收满,通知 MCU 来读
- DIO1 (FifoLevel):FIFO 到半满(32 字节)时触发,用来提前采样 RSSI 和 AFC
DIO1 的提前采样是实践中的一个优化。等 PayloadReady 触发时,射频信号可能已经结束或变弱,此时读到的 RSSI 不准确。而在 FIFO 半满时,信号还在持续接收中,读到的信号质量参数比 PayloadReady 时刻更可靠。所以 DIO1 中断里只做一件事——记录当前的 RSSI/AFC/FEI 值:
1void EXTI3_IRQHandler(void) // DIO1
2{
3 dio1_fifo_level = 1;
4 // 标记需要在主循环中采样(不在 ISR 里做 SPI 操作)
5}
主循环
主循环的逻辑极其简单:
1while (1) {
2 if (SX1276_FSK_ReadPacketIfReady(rx_buf, sizeof(rx_buf), &rx_total)) {
3 // FIFO 数据的极性也需要取反
4 for (int i = 0; i < rx_total; i++)
5 rx_buf[i] ^= 0xFF;
6
7 uart_hex_dump(rx_buf, rx_total, ...);
8 rx_count++;
9 }
10}
SX1276_FSK_ReadPacketIfReady 内部检查 DIO0 是否触发,触发后从 FIFO 读出 64 字节,并采样 PayloadReady 时刻的 RSSI/AFC。
同样地,FIFO 中读出的数据字节也需要整体按位取反——和同步字的原因一样,SX1276 的 FSK 极性与 POCSAG 相反。
Packet Mode 方案把 RF 层的脏活(preamble 检测、同步字匹配、FIFO 管理)全部交给了 SX1276 硬件,MCU 上只需要等中断、读 buffer、通过串口发出去。协议解析(BCH 纠错、消息解码)目前放在 PC 端完成,后续可以挪回 MCU 上。
效果
在目标频点实测,配合 12.5 kHz 带宽和 SX1276 的硬件 AFC,接收效果稳定。UART 输出的 hex dump 通过 PC 端工具做 BCH 纠错和消息解码后,能准确还原出寻呼内容。
#技术 #POCSAG | 微信打赏 | 转载必须注明原文链接

提交中...