
做无线遥控、智能家居这类项目很多人第一反应是上 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/ASK315M/433M/868M几十 bps 到几 kbps几十米到两三百米最低遥控、报警、简单开关量FSK433M/868M/2.4G几十 kbps 到几百 kbps几百米中等无线模块、高速数据采集LoRa433M/868M/915M0.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-FST433M OOK 超外差接收模块比如 XD-RF-5V433MHz 弹簧天线或者直导线天线两根杜邦线若干、面包板、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 发射模块 DATAGPIO_Output推挽输出默认低电平PA0接收信号接 OOK 接收模块 DATATIM2_CH1输入捕获模式PA9 / PA10USART1 调试打印USART1 TX/RX115200PA0 在 STM32F103 上是 TIM2_CH1 的默认映射引脚选这个引脚的原因是 TIM2 在 F103 里是 32 位定时器计数寄存器可以放在 0xFFFFFFFF这样接收端测脉冲宽度时基本不用处理定时器溢出重算的问题。如果选 TIM3 的 PA616 位定时器在 1MHz 计数频率下每 65ms 就溢出了一次处理起来麻烦很多。这里有个容易被忽略的细节PA0 也是芯片的 WKUP 引脚但正常唤醒功能不用管配置成 TIM2_CH1 之后对唤醒功能没有任何影响。GPIO 配置里PA0 我们不需要单独配置上下拉因为定时器复用功能模式下输入电平由外部接收模块决定一般模块空闲输出电平是确定的高或低软件解码的时候再做极性适配。2.3 定时器输入捕获与串口配置TIM2 的配置是接收链路的核心。CubeMX 中进入 TIM2 的配置界面选择 Channel1 为 Input Capture direct mode。参数按以下配置Prescaler预分频71Counter Period0xFFFFFFFFClock Division不分频重复计数器0Input Filter0 或者小一点的滤波值预分频 71 的原因要算一下。APB1 定时器时钟在 72MHz 主频下是 72MHz预分频值写入 71 等于做 72 分频所以定时器计数频率变成 72MHz / 72 1MHz也就是 1us 计数一次。这个频率非常适合 OOK 解码300us 的脉冲宽度对应计数值 3008000us 的引导码对应计数值 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位值高电平宽度低电平宽度总周期1560us560us1120us0280us840us1120us每个位周期内高电平宽度相差一倍接收端只要测量高电平时间跟阈值比如 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 这种通讯方式最怕的就是“看着像是解出来了但实际每个边沿都有抖动”。把波形对齐检查一遍确认高电平宽度吻合再去看解码结果这样排错的速度比盲改代码快好几倍。做这种低成本无线链路最大的体会就是把每个环节的地基打扎实电源稳、天线对、波形准剩下的就是软件上的细活了。