做无线遥控、智能家居这类项目,很多人第一反应是上 WiFi 或者蓝牙,结果调试协议栈、配对、功耗管理,还没务正业就先被工具链折腾得够呛。其实很多短距离、小数据量的控制场景,根本用不着那么复杂。STM32 加一个几块钱的 OOK 射频模块,再配合 CubeMX 把工程框架拉起来,从发送到接收,半天就能打通一条能跑的链路。这个组合看着原始,但在车库门、遥控插座、传感器报警、低成本遥控车这些项目里,它依然是最省事的方案之一。
OOK 说白了就是“开关键控”,发射端靠控制射频载波的开和关来传 0 和 1。我这次用一个 STM32F103C8T6,加常见的 433M 超外差接收模块和超再生发射模块,把 OOK 发送、OOK 接收、CubeMX 配置整条链路完整跑了一遍。整个过程很典型,也非常适合拿来当课设、毕设、以及刚入门无线通信时的第一个练手项目。下面把我实际的选型思路、CubeMX 配置方法、发送接收代码和踩过的坑全部整理出来。
1. 方案选型与整体设计:OOK 为什么还没被淘汰
1.1 OOK 是什么,为什么低成本项目都在用它
OOK 是 On-Off Keying 的缩写,翻译过来就是“开关键控”。它属于幅度键控(ASK)的一种特殊形式,原理非常直白:发射模块内部的振荡器开始工作时,天线向外辐射载波信号;振荡器停止时,载波消失。接收端检测到载波,输出一种电平状态,检测不到载波,输出另一种电平状态。这样一来,一个射频链路的收发逻辑就被简化成了“有没有载波”的判定,天然适合数字信号。
很多新接触无线开发的朋友会嫌弃它“没技术含量”,但实际上这种简单恰恰是优势。首先,它不需要复杂的基带调制和解调,MCU 只要用 GPIO 控制电平变化就行;其次,434M/315M 这个频段波长较长,绕射能力强,室内穿透和绕墙角表现比 2.4G 好不少;再就是模块成本极低,发射模块和接收模块加起来通常不到十块钱,对成本和体积敏感的产品来说,OOK 依然是大量出货的方案。
这个方案的定位很明确:适合几十米到一两百米的遥控场景,数据速率从几百 bps 到几 kbps 就够用了。它不适合传视频、传音频、传大量传感器数据,那些场景老老实实上 WiFi 或者 LoRa。
1.2 OOK、FSK、LoRa 怎么选,什么时候别用 OOK
我经常被问到,为什么不用 FSK 或者 LoRa。这里面的逻辑其实很简单:看距离、速率、成本、功耗四者的平衡。
| 方案 | 典型频段 | 速率 | 传输距离 | 成本 | 适用场景 |
|---|---|---|---|---|---|
| OOK/ASK | 315M/433M/868M | 几十 bps 到几 kbps | 几十米到两三百米 | 最低 | 遥控、报警、简单开关量 |
| FSK | 433M/868M/2.4G | 几十 kbps 到几百 kbps | 几百米 | 中等 | 无线模块、高速数据采集 |
| LoRa | 433M/868M/915M | 0.3kbps 到几十 kbps | 几公里 | 高 | 低功耗广域网、物联网终端 |
如果只是传递“开灯、关灯、开门、报警”这种开关量,OOK 完全够用。但像无线温湿度计需要定时上报完整数据包,数据量也不大,OOK 依然能跑,只是要自己多写几层编码和校验逻辑。如果项目要求强抗干扰、低功耗、远距离,比如农业环境监测,那 LoRa 会省心得多。简单说,工具没有高下之分,只有合不合适。OOK 的“简单”在某些需要抢占低成本市场的产品里,反而是核心竞争力。
1.3 整体系统架构和器件清单
我这次搭建的系统框图非常清晰,整个链路分三块:
- 发送端:STM32 的 GPIO 输出控制信号,接到 433M OOK 发射模块的 DATA 引脚。GPIO 输出高电平,模块开始发射载波;GPIO 输出低电平,模块停止发射。
- 接收端:433M OOK 接收模块的 DATA 引脚输出解调后的数字电平,接到 STM32 的定时器输入捕获引脚,MCU 通过测量高低电平宽度还原编码数据。
- 调试端:STM32 通过串口把接收到的原始脉宽、解析结果打印到电脑上,方便定位问题。
用到的器件也很常规:
- STM32F103C8T6 最小系统板一块
- 433M OOK 发射模块,比如 XD-FST
- 433M OOK 超外差接收模块,比如 XD-RF-5V
- 433MHz 弹簧天线或者直导线天线两根
- 杜邦线若干、面包板、5V 供电、USB-TTL 调试板
发射模块和接收模块的型号不需要很讲究,只要同为 433M,协议都是 OOK/ASK 就可以一对使用。个别模块频率可能偏移,实测时如果距离特别短,检查一下模块表面丝印频率,最好买同一个卖家配套的发收对。
2. CubeMX 工程配置:从零把 STM32 拉起来
2.1 芯片选型与工程创建
CubeMX 配置 STM32 的第一步就是选芯片。我用的 STM32F103C8T6,它在 CubeMX 里直接搜索 STM32F103C8T6 就能看到,选择后进入 Pinout & Configuration 界面。
有几个基础配置必须先设置好:
- RCC 里选择 HSE 为 Crystal/Ceramic Resonator,我这里板载 8MHz 晶振。
- SYS 里 Debug 选择 Serial Wire,否则生成工程后 ST-Link 可能连不上。
- 时钟树里把 PLL Source 设为 HSE,系统时钟拉到 72MHz。F103 最高主频就是 72MHz,这也是后面定时器分频计算的基础。
很多新手在这一步就踩坑,Debug 模式如果没有选 Serial Wire,烧录一次程序后调试口会被 GPIO 占用,之后再也连不上芯片,只能按住复位键抢时间擦除。这个设置看起来不起眼,但非常重要。
2.2 引脚规划与 GPIO 配置
我规划的引脚很简单,三路信号:
| 引脚 | 功能 | CubeMX 配置 |
|---|---|---|
| PA1 | 发射控制,接 OOK 发射模块 DATA | GPIO_Output,推挽输出,默认低电平 |
| PA0 | 接收信号,接 OOK 接收模块 DATA | TIM2_CH1,输入捕获模式 |
| PA9 / PA10 | USART1 调试打印 | USART1 TX/RX,115200 |
PA0 在 STM32F103 上是 TIM2_CH1 的默认映射引脚,选这个引脚的原因是 TIM2 在 F103 里是 32 位定时器,计数寄存器可以放在 0xFFFFFFFF,这样接收端测脉冲宽度时基本不用处理定时器溢出重算的问题。如果选 TIM3 的 PA6,16 位定时器在 1MHz 计数频率下每 65ms 就溢出了一次,处理起来麻烦很多。
这里有个容易被忽略的细节:PA0 也是芯片的 WKUP 引脚,但正常唤醒功能不用管,配置成 TIM2_CH1 之后对唤醒功能没有任何影响。GPIO 配置里,PA0 我们不需要单独配置上下拉,因为定时器复用功能模式下,输入电平由外部接收模块决定,一般模块空闲输出电平是确定的高或低,软件解码的时候再做极性适配。
2.3 定时器输入捕获与串口配置
TIM2 的配置是接收链路的核心。CubeMX 中进入 TIM2 的配置界面,选择 Channel1 为 Input Capture direct mode。参数按以下配置:
- Prescaler(预分频):71
- Counter Period:0xFFFFFFFF
- Clock Division:不分频
- 重复计数器:0
- Input Filter:0 或者小一点的滤波值
预分频 71 的原因要算一下。APB1 定时器时钟在 72MHz 主频下是 72MHz,预分频值写入 71 等于做 72 分频,所以定时器计数频率变成 72MHz / 72 = 1MHz,也就是 1us 计数一次。这个频率非常适合 OOK 解码,300us 的脉冲宽度对应计数值 300,8000us 的引导码对应计数值 8000,直接读寄存器就能得到微秒级的时间差。
接收中断这里要打开全局中断,NVIC 设置中把 TIM2 global interrupt 使能,优先级我这里给了 2,小于串口中断优先级,避免串口打印阻塞太厉害。
USART1 的配置很简单:Mode 选择 Asynchronous,波特率 115200,数据位 8,停止位 1,无校验。生成工程之后,用串口工具或者 STM32CubeMonitor 直接看打印数据。
2.4 生成代码与添加用户代码区
CubeMX 生成工程时,我选择 Toolchain 为 MDK-ARM,这样可以直接用 Keil 打开。生成后要在 main.c 中把用户代码写在 USER CODE BEGIN 和 USER CODE END 注释之间,这一点太重要了。如果直接写在空白区域,下次在 CubeMX 里改配置再重新生成工程,所有手写代码会被覆盖,辛辛苦苦写的发送接收函数就没了。
我习惯把 OOK 相关的发送函数、接收解码函数都拆成独立文件,比如 ook.c 和 ook.h,在 CubeMX 生成的 main.c 里只保留 GPIO 初始化、定时器初始化和 HAL_TIM_IC_Start_IT 的调用。这样逻辑清晰,也方便以后换芯片移植。
3. OOK 发送端实现:控制射频模块“开”和“关”
3.1 编码方式:为什么我用 PWM 位编码而不是曼彻斯特
OOK 发送端的核心问题是,怎么把“0”和“1”映射到载波的开关状态。最简单的做法是直接高电平表示 1,低电平表示 0,但这样遇到连续多个相同电平位时,接收端很难区分是几个位还是长时间电平没变化,而且接收模块输出边沿本身有抖动,很容易错位。
我这次用的是定长时隙的 PWM 位编码,每一个数据位的总周期固定为 1.12ms,用高电平宽度区分 0 和 1:
| 位值 | 高电平宽度 | 低电平宽度 | 总周期 |
|---|---|---|---|
| 1 | 560us | 560us | 1120us |
| 0 | 280us | 840us | 1120us |
每个位周期内高电平宽度相差一倍,接收端只要测量高电平时间,跟阈值比如 400us 比较,超过 400us 就判为 1,否则判为 0。这种编码的好处是每一位都有固定的高电平和低电平跳变,接收端可以利用跳变沿做同步,不会因为连续长电平而丢失位同步。
对比曼彻斯特编码,波形的含义更直接,调参也方便。如果以后要改成 868M 模块,只要把 560us 和 280us 这两个参数按模块特性稍微调整就能适配。
在正式数据帧之前,我还要发一个同步头:9ms 高电平加 4.5ms 低电平。接收端看到这个特殊的长高电平脉冲,就知道后面的数据要开始了。同步头的宽度远大于正常数据位,接收端判断阈值的时候非常容易识别。
3.2 微秒延时与发送函数实现
CubeMX 自带 HAL_Delay 只能做到毫秒级,OOK 发送需要 280us、560us、9ms 这样的精确时序,所以我要实现一个微秒级延时。最简单可靠的方式是用 DWT 模块,Cortex-M3 内核里有 Data Watchpoint and Trace 单元,其中一个 Cycle Counter 可以读取 CPU 运行周期数。
下面是我常用的微秒延时代码:
#include "stm32f1xx_hal.h" static void OOK_DelayUs(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks); } static void OOK_InitCycleCounter(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; }这里要注意,SystemCoreClock 在 72MHz 主频时是 72000000,所以每微秒的周期数就是 72。DWT->CYCCNT 是 32 位计数器,相减操作会自动处理回绕,所以用差值判断不会出错。
发送一个位的代码:
void OOK_SendBit(uint8_t bit) { if (bit) { HAL_GPIO_WritePin(TX_CTRL_GPIO_Port, TX_CTRL_Pin, GPIO_PIN_SET); OOK_DelayUs(560); HAL_GPIO_WritePin(TX_CTRL_GPIO_Port, TX_CTRL_Pin, GPIO_PIN_RESET); OOK_DelayUs(560); } else { HAL_GPIO_WritePin(TX_CTRL_GPIO_Port, TX_CTRL_Pin, GPIO_PIN_SET); OOK_DelayUs(280); HAL_GPIO_WritePin(TX_CTRL_GPIO_Port, TX_CTRL_Pin, GPIO_PIN_RESET); OOK_DelayUs(840); } }发送同步头和数据帧的函数:
void OOK_SendByte(uint8_t data) { for (int i = 7; i >= 0; i--) { OOK_SendBit((data >> i) & 0x01); } } void OOK_SendFrame(uint8_t addr, uint8_t data) { uint8_t checksum = addr + data; HAL_GPIO_WritePin(TX_CTRL_GPIO_Port, TX_CTRL_Pin, GPIO_PIN_SET); OOK_DelayUs(9000); HAL_GPIO_WritePin(TX_CTRL_GPIO_Port, TX_CTRL_Pin, GPIO_PIN_RESET); OOK_DelayUs(4500); OOK_SendByte(addr); OOK_SendByte(data); OOK_SendByte(checksum); HAL_GPIO_WritePin(TX_CTRL_GPIO_Port, TX_CTRL_Pin, GPIO_PIN_RESET); }帧格式是“地址 + 数据 + 校验和”。地址可以区分不同设备,校验和用来确认数据没被干扰破坏。整个帧一共 24 位,加上同步头,总耗时大约 9ms + 4.5ms + 24 * 1.12ms = 40ms 左右,满足绝大多数遥控场景的响应速度要求。
发送时如果有更高优先级的任务,可以把中断临时关闭,发送完再打开,避免中断打断时序。用__disable_irq()和__enable_irq()即可。
4. OOK 接收端实现:定时器输入捕获解码
4.1 接收模块的输出电平陷阱
接收模块比发射模块的门道多多了。同样是 433M OOK 接收模块,不同厂家、不同批次的模块空闲电平和极性都可能不一样。有的模块在没有信号时 DATA 输出高电平,有的输出低电平;接收到载波时也会表现出相反的翻转方向。
我在第一次调试时就遇到过这个问题:接收模块 DATA 引脚接 PA0 后,示波器看波形和发射端完全反相。后来确认这款模块无信号时输出高电平,收到 OOK 载波时反而拉低。如果不意识到这点,解码逻辑里的“高电平宽度”会变成“低电平宽度”,导致整个数据反相。
应对办法有两个。硬件上如果模块输出极性固定且反相,可以用一个三极管或者逻辑非门把信号反相后再给 MCU;软件上则直接在解码时把高电平当作低电平处理。我更推荐软件适配,因为改动只需要在中断回调里把高低电平的边沿方向对调即可,不用改硬件。
最直观的调试方法是先用示波器看接收模块 DATA 引脚波形。没有示波器的话,可以直接在 main 里用串口打印 PA0 引脚的电平状态,按住发射键看打印值变化,就能把极性摸清楚。
4.2 输入捕获中断代码:测脉冲宽度的核心
接收端我使用 TIM2_CH1 的输入捕获功能。核心思路是每次边沿变化时,定时器自动捕获当前计数器值并触发中断,我们在中断回调里记录时间点,通过两次相邻事件的时间差得到脉宽。
这里我实现的是“测量高电平宽度”的交替捕获逻辑:先等着上升沿,捕获到了就记录时间;然后把捕获极性切换为下降沿,等下降沿到来时计算差值。代码如下:
static uint32_t rising_time; static uint8_t waiting_falling = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { uint32_t current_time = __HAL_TIM_GET_COUNTER(&htim2); if (!waiting_falling) { rising_time = current_time; waiting_falling = 1; __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } else { uint32_t high_time = current_time - rising_time; waiting_falling = 0; __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); OOK_ProcessPulse(high_time); } } } }这个逻辑看着简单,但有两个细节要特别留意。
第一,__HAL_TIM_GET_COUNTER读取的是定时器当前计数值,不一定是 CCR1 寄存器的捕获值。严格来说,如果发生了溢出,这个时间差就会出错。由于 TIM2 是 32 位定时器,1MHz 下要 4295 秒才溢出一次,对于几十毫秒的 OOK 帧完全没有问题。如果选的是 16 位定时器,就要额外做溢出计数,复杂度会增加很多。
第二,每次进中断必须重新设置捕获极性。HAL 库在生成定时器初始化时默认是上升沿,如果丢失了某个边沿,状态机就会卡住。我在实际测试中遇到过“只抓到第一个上升沿,后面再也没有中断”的情况,排查后发现是边沿极性切换的宏没有在回调里正确执行,导致后续事件一直是同一边沿触发,而信号翻转方向已经变了。
在 main.c 里启动捕获中断的代码:
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1);还要记得在 CubeMX 生成的定时器初始化回调里,保证 TIM2 初始极性是上升沿。如果 CubeMX 里没有直接设置初始捕获极性,可以在启动捕获之前用下面这行强制设置一次:
__HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING);4.3 解码状态机与数据校验
拿到了高电平宽度,下一步就是解析数据。我用了非常简单的状态机:空闲等待同步头,识别到长高电平后进入数据接收,按位解析,最后校验。
状态枚举:
typedef enum { OOK_STATE_IDLE, OOK_STATE_SYNC, OOK_STATE_DATA } OOK_State;处理函数可以这样写:
#define OOK_SYNC_THRESHOLD_US 6000u #define OOK_BIT_THRESHOLD_US 400u static OOK_State state = OOK_STATE_IDLE; static uint8_t rx_buffer[3]; static uint8_t bit_index; static uint8_t byte_index; static uint8_t current_byte; void OOK_ProcessPulse(uint32_t high_us) { if (high_us > OOK_SYNC_THRESHOLD_US) { state = OOK_STATE_SYNC; byte_index = 0; bit_index = 0; current_byte = 0; return; } if (state == OOK_STATE_DATA) { uint8_t bit; if (high_us > OOK_BIT_THRESHOLD_US) bit = 1; else bit = 0; current_byte = (current_byte << 1) | bit; bit_index++; if (bit_index >= 8) { rx_buffer[byte_index] = current_byte; byte_index++; bit_index = 0; current_byte = 0; if (byte_index >= 3) { if (rx_buffer[2] == (uint8_t)(rx_buffer[0] + rx_buffer[1])) { OOK_OnValidFrame(rx_buffer[0], rx_buffer[1]); } state = OOK_STATE_IDLE; } } } }这段逻辑我在实际项目中一直用,所以要说一下潜在问题。当识别到同步头后,我把状态切到 SYNC,但这段代码里没有显式地区分 SYNC 和数据位,而是等下一个脉冲进来就直接进入数据解析。如果想更严格,可以在 SYNC 状态时专门吃掉同步头后面的低电平间隔,再切到 DATA,这样能防止误触发。不过对大多数遥控项目来说,上面的简化版已经足够稳定,因为同步头的高电平宽度远大于正常数据位,一旦识别到同步头,后面大概率是数据帧。如果想进一步提升,可以在 OOK_ProcessPulse 里加一个低电平时间变量,把同步头低电平也校验一遍。
串口打印输出的回调用一个简单的 printf 重定向就行:
void OOK_OnValidFrame(uint8_t addr, uint8_t data) { printf("addr: 0x%02X, data: 0x%02X\r\n", addr, data); }5. 联调排错与现场经验
5.1 联调步骤,避免瞎试
OOK 收发项目最怕一上来就把发送接收都跑起来,出了故障分不清是哪一端的问题。我习惯按三步走,每一步都验证透了再往下走。
第一步,先测发射。手头有示波器就抓 PA1 引脚的波形,确认高低电平宽度是否符合 560us、280us 的设定。没示波器也没关系,把发射模块 DATA 引脚接一个 LED,发送时如果 LED 亮灭闪烁符合预期,说明 GPIO 时序没问题。
第二步,测接收模块。把接收模块 DATA 引脚接到示波器,或者用万用表测电平,按下发送键观察接收模块输出是否有变化。这个步骤能确认两个模块配对是否成功、频率是否对齐。如果接收端输出没有任何反应,先检查天线有没有接,再用另一块板子或者示波器信号源模拟一个简单载波验证接收模块本身是否损坏。
第三步,整机联调。接收端串口打印原始高电平宽度,比如 9000、4500、560、280 这些数值,跟发送端理论值对照。如果偏差在 5% 以内,正常解码没问题;偏差超过 20%,就要考虑供电纹波、模块温漂或者晶振误差了。
我实际碰到过一个比较头痛的问题:发射端和接收端相距十几厘米以内时反而收不到,拉远了以后又正常。这是因为近距离下发射模块功率过大,直接把接收模块前级饱和了。这种“近场盲区”在超再生接收模块上比较常见,解决办法就是故意拉开距离测试,或者把发射功率降低,比如在发射模块 VCC 串联一个几欧姆电阻。
5.2 常见问题与排查速查表
我把这一路调试中见过的典型问题整理成了表格,项目出问题可以先从这几条入手:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 接收端完全没有数据口变化 | 天线缺失、模块供电异常、频率不匹配 | 检查天线、改用万用表测模块输出 |
| 距离很近却丢包 | 发射功率过大饱和接收端、接收模块超再生自激 | 拉开距离测试、降低发射电压、换超外差接收模块 |
| 数据反相、0/1 全颠倒 | 接收模块输出极性跟发射端相反 | 示波器看 DATA 波形,软件修改高低电平逻辑 |
| 能收到数据但校验老失败 | 供电电压跌落、发送时序被中断打断 | 用稳压电源、发送时关中断、检查晶振频率 |
| 中断只触发一次 | 捕获极性没有正确切换、NVIC 没使能 | 检查回调里边沿切换宏、确认 TIM2_IRQn 已使能 |
| 串口打印乱码 | 波特率不对、HSE 未配置或时钟树不对 | 检查系统时钟是否 72MHz、串口波特率 115200 |
| 数据传输偶尔错位一两位 | 位同步丢失、干扰信号误触发 | 加大同步头长度、加曼彻斯特编码、加重复发送次数 |
排查时有一个很重要的小技巧:先用频率计或示波器看看接收模块输出的载波突发时间。如果接收模块 DATA 引脚的空闲电平和收到信号时电平完全不对,可以先不管 STM32,用示波器观察模块本身,确认模块能不能解调出方波。基础波形不对,MCU 再怎么写代码也白搭。
5.3 实战优化建议,让链路更可靠
一通简单能跑之后,如果想用在真实项目里,还有几个优化点可以显著提升稳定性。
第一,连续发送多帧。发射端不要只发一帧,可以间隔 20ms 连续发三帧甚至五帧。接收端只要收到任意一帧校验通过就执行动作。这样能避免偶发的干扰丢包,遥控响应手感也会明显变好。代价是射频占用时间长一点,但几十毫秒内连续发完完全没问题。
第二,用重复码或者随机假数据做防误触发。如果场景是车库门、遥控锁这种安全相关的设备,建议在数据帧里加一个类似流水号的字段,接收端连续收到相同控制指令时只执行一次,避免遥控器被按住时反复触发。
第三,低功耗场景优化。发射端发送完数据后,把 GPIO 控制的发射模块 VCC 直接关断,不发送时模块不耗电;接收端如果只有事件触发才需要响应,可以定时器低功耗模式配合外部中断唤醒。如果是用 433M OOK 做野外传感器上报,尽量把发送时间压短,数据帧能少发就少发。
第四,给接收模块加滤波和上拉。接收模块 DATA 引脚在超再生方案里输出阻抗偏高,容易被 433M 附近的 WiFi、对讲机干扰。可以在 DATA 引脚对地接一个 100pF 小电容稍微滤波,或者按模块手册要求接一个 10k 上拉电阻来稳定静态电平。不过电容也别加太大,否则边沿会变缓,影响脉宽测量精度。我实测 100pF 在 1.12ms 位周期下基本没影响,但如果速率提高到几十 kbps,就要谨慎处理滤波电容了。
最后再分享一个我自己的习惯。做完第一版之后,我会拿一个逻辑分析仪或者示波器,把发射端发送的完整帧波形保存下来,同步抓接收端 DATA 引脚的波形,两个波形放一起对比。OOK 这种通讯方式最怕的就是“看着像是解出来了,但实际每个边沿都有抖动”。把波形对齐检查一遍,确认高电平宽度吻合,再去看解码结果,这样排错的速度比盲改代码快好几倍。做这种低成本无线链路,最大的体会就是把每个环节的地基打扎实:电源稳、天线对、波形准,剩下的就是软件上的细活了。