拿 STM32 做 OOK 通讯,起因是我一个无线遥控项目里临时推上去的方案。本来想直接用现成遥控器,但设备端需要自定义按键和上报,干脆自己用 CubeMX 配置了一套 OOK 发送 + OOK 接收的小链路。整套做完,模块成本不到十块钱,代码量也不大,效果却很稳。这篇东西不讲高大上的 LoRa,也不堆厂商 SDK,只把 OOK 发送、OOK 接收、CubeMX 配置这三件事讲透,顺带把我踩过的坑和能直接复用的代码整理出来。适合手里刚好有 STM32F103C8T6 这类板子、想做低成本无线点对点控制或者数据采集上报的人。
1. OOK 的调制原理与选型陷阱
1.1 OOK 的本质就是“开关灯”
OOK 全称 On-Off Keying,开关键控。想理解它,不用翻通信原理课本,就把它想象成用手电筒发信号:开一下、关一下,开着代表 1,关着代表 0。射频 OOK 也一样,背后的载波一直在振荡,但只有在发送“1”的时候,载波才被放出去;发送“0”的时候,载波被切断。
这句听起来简单,实际工程上很容易被人绕晕。很多人以为用 STM32 做 OOK 就必须让单片机的某个引脚输出 433MHz 或 315MHz 的高频信号,不是的。高频载波由射频发射模块内部的振荡电路负责,STM32 只负责给模块的 DATA 引脚一个高电平或低电平,让模块把载波“放出来”或者“憋回去”。所以配置层面,你真正要面对的是一个普通 GPIO 输出和一个普通 GPIO 输入,最多加个定时器测脉冲宽度,复杂度并不高。
1.2 为什么不用 FSK、LoRa 这些“看起来更好”的方案
FSK 是通过频率偏移来区分 0 和 1,抗干扰能力比 OOK 好很多,但同样做一对收发,FSK 模块和高频头成本更高,调试也更麻烦。LoRa 更不用说了,通信距离和穿墙能力确实牛,但一颗 LoRa 模块的价格能抵好几个 OOK 模块,而且很多场景下根本用不到那几十公里的余量。
OOK 模块的优势是便宜、简单、源码可控。比如 433MHz ISM 频段的发射模块,几块钱一个,接收模块也就几块钱,用户自己写协议,十几行代码就能跑起来。适合距离几十米到一两百米、对数据率要求不高、又不希望依赖厂商私有协议的场合。代价是抗干扰弱、没有频率分集、接收端静态时容易输出噪声,这些坑后面会逐一处理。
从这张表能很直观看清差异:
| 方案 | 成本 | 抗干扰能力 | 通信距离 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|---|
| OOK | 很低 | 一般 | 几十米至几百米 | 低 | 遥控、传感器上报、灯光控制 |
| FSK | 中 | 较好 | 中远距离 | 中 | 无线抄表、工业数传 |
| LoRa | 高 | 很强 | 上千米 | 中高 | 低功耗广域物联网 |
选型时还有一个常见坑:看到“ASK”模块就觉得和 OOK 不兼容。实际上 ASK(幅移键控)和 OOK 在绝大多数低成本射频模块上是同一回事,模块数据手册里经常两个词混着写,正面写着 ASK,背面参数写着 OOK,别纠结,直接按 OOK 协议来用就行。
2. CubeMX 工程搭建:芯片选型、时钟、引脚与调试口
2.1 芯片包和工程创建
用 STM32CubeMX 建工程,第一步是确保电脑里装了对应系列芯片的固件包。很多人一打开 CubeMX 搜索 STM32F103C8T6,发现列表是空的,点下载又半天没反应,就是这个原因。
在 CubeMX 的 Help → Manage Embedded Software Packages 里找到 STM32F1 系列,把最新稳定版固件包装好。装完之后再新建工程,选择 STM32F103C8T6,这款芯片 64KB Flash、20KB RAM,跑一路 OOK 收加一路 OOK 发,绰绰有余。
工程创建时还有个隐藏选项,很多人不太注意:Project Settings 里的 Toolchain/IDE 要选 MDK-ARM V5 或 V5.27 以上版本。如果你用的是 Keil MDK,版本太老可能打不开生成的项目。另外,Keil 里如果打开工程后没有看到器件型号,是芯片包没装到 Keil 里,去 Keil 的 Pack Installer 补一个 STM32F1xx_DFP 即可。回头看 CubeMX 这一步,它生成的初始化代码结构基本是固定的,外设配置写清楚,后面改起来就顺。
2.2 时钟和外设引脚规划
推荐一套引脚分配,不用改动默认的 UART,非常省事:
| 引脚 | 模式 | 作用 |
|---|---|---|
| PA0 | GPIO_EXTI0,双沿触发 | 接 OOK 接收模块的 DATA 输出 |
| PA1 | GPIO_Output,推挽输出 | 接 OOK 发射模块的 DATA 输入 |
| PA9 | USART1_TX | 调试日志输出 |
| PA10 | USART1_RX | 调试日志接收 |
| TIM3 | 内部时基定时器,1MHz 计数 | 测脉冲宽度、微秒延时 |
时钟配置上,如果板子上有 8MHz 外部晶振,就选 HSE 作为 PLL 时钟源,倍频到 72MHz 系统时钟。如果手头板子没有外部晶振,用 HSI 内部时钟也可以,但要注意定时器时钟频率跟着变,后面Prescaler的计算要重新算一遍。黑洞坑往往都在这里:你觉得初始化没问题,实际定时器跑的频率不是 1MHz,解码就全乱。
PA0 作为接收输入,建议在 GPIO 配置里把上下拉设为 Pull-up。原因很简单,廉价 OOK 接收模块在无信号时会输出随机噪声,上拉可以避免引脚悬空,给后续软件消抖打底。PA0 一定要选择外部中断模式,并且选 Rising/Falling edge trigger detection,不是只选上升沿也不是只选下降沿。因为我们要靠测量高电平持续时间来解调数据,需要精确知道引脚何时拉高、何时拉低。
TIM3 配置也很关键:不要看它名字叫“时基”,它不需要输出 PWM,只要内部向上计数就行。目标是把计数频率做成 1MHz,这样计数器加 1 就是 1 微秒,脉冲宽度直接读数值,不用换算。72MHz 总线时钟分频 71 得到 1MHz,Period写到 0xFFFF。在 CubeMX 里把 Prescaler 填 71,Counter Period 填 65535,其他保持默认。
UART1 主要用来做调试打印。写协议的时候,串口日志是最快的信息渠道,没有之一。发送端发完一帧,打印一个 “TX OK”;接收端每解析完一个字节,打印当前字节和校验状态,数据对不对一眼就能看出来。
2.3 生成代码后的初始化顺序
CubeMX 生成代码后,main 函数里会自动调用MX_GPIO_Init()、MX_USART1_UART_Init()、MX_TIM3_Init()。注意,HAL_TIM_Base_Start(&htim3)不会自动被调用,必须手动加在 main 的 while 循环之前。否则定时器根本没跑起来,接收端所有时间测量都是 0。
还有一个容易踩的点:中断回调函数要在哪里写。HAL_GPIO_EXTI_Callback是一个弱函数,CubeMX 默认在 stm32f1xx_it.c 里只帮你声明了中断服务函数,实际处理还是要在用户代码里实现。我习惯在 main.c 底部写回调函数,然后在外部中断服务函数里调用HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0),CubeMX 生成的代码已经帮你处理了入栈出栈,不用动。
3. 发送端实现:长脉冲、短脉冲与数据帧设计
3.1 先决定“T”,再决定协议
很多新手一上来就复制协议代码,结果换了一个模块、换了一根天线,距离和误码率完全不一样。核心原因是没有先明确一个基本时间单位 T。
我用的是长短脉冲编码:发送一个 bit 时,拉高 DATA 引脚持续 T 代表 0,持续 2T 代表 1,然后统一拉低 T 作为位间隔。为了接收端能用统一标准解码,T 不能太快,也不能太慢。太慢,发送一帧数据要几十毫秒,实时性差;太快,廉价 433MHz 接收模块解调不出来。测试下来 T=300us 比较合适,对应 1 个比特最短 600us、最长 900us,实际有效数据率在 1.2kbps 左右。这个速度用来传遥控命令、温湿度、开关状态完全够用。
这样设计的好处是抗噪声能力强。接收端不需要判断绝对的“1.5T”到底多少微秒,只需要测量高电平时间,落在 0.5T 到 1.5T 之间就当 0,落在 1.5T 到 2.5T 之间就当 1。只要模块输出电平稳定,就算系统时钟有点偏差,码型也能正确解析。
3.2 帧格式与发送代码
一帧数据按以下顺序发送:
| 字段 | 内容 | 长度 |
|---|---|---|
| 前导同步序列 | 16 组短高低脉冲,每组高 1T、低 1T | 16 个高脉冲 |
| 起始位 | 高脉冲 3T,低 1T | 1 组 |
| 数据字节 | 地址、命令、数据、校验 | 4 字节 |
| 结束 | 拉低至少 5ms | 1 个时间片 |
前导同步有两层意义:一是让发射模块进入稳定振荡状态,让接收模块建立自动增益;二是给接收端一个明确的时间基准,后面量到的“短”、“长”才能判断。起始位 3T 比普通 1 长出一截,接收端看到它就知道,接下来的高脉冲要按数据位解析了。
发送端核心代码很简单:
#define TICK_US 300 #define TX_HIGH() HAL_GPIO_WritePin(TX_PIN_GPIO_Port, TX_PIN_Pin, GPIO_PIN_SET) #define TX_LOW() HAL_GPIO_WritePin(TX_PIN_GPIO_Port, TX_PIN_Pin, GPIO_PIN_RESET) static void DelayUs(uint16_t us) { __HAL_TIM_SET_COUNTER(&htim3, 0); while (__HAL_TIM_GET_COUNTER(&htim3) < us); } static void SendPulse(uint16_t high_us) { TX_HIGH(); DelayUs(high_us); TX_LOW(); DelayUs(TICK_US); } static void SendPreamble(void) { uint8_t i; for (i = 0; i < 16; i++) SendPulse(TICK_US); // 16 组短脉冲,每组高 1T 低 1T SendPulse(3 * TICK_US); // 起始位,高 3T 低 1T } static void SendByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & (0x01 << i)) // LSB first SendPulse(2 * TICK_US); else SendPulse(TICK_US); } } void OOK_SendFrame(uint8_t addr, uint8_t cmd, uint8_t payload) { uint8_t frame[4]; frame[0] = addr; frame[1] = cmd; frame[2] = payload; frame[3] = addr + cmd + payload; // 累加和校验,够用就行 SendPreamble(); SendByte(frame[0]); SendByte(frame[1]); SendByte(frame[2]); SendByte(frame[3]); TX_LOW(); DelayUs(5000); // 帧间隔,避免连续发送时接收端状态机会粘连 }调用方式:
OOK_SendFrame(0x01, 0x11, 0x25);地址、命令、数据随便自定义。校验不必用 CRC16,OOK 链路误码多数来自单比特翻转,累加和已经能拦住绝大多数问题,而且代码量少几个量级。
3.3 什么时候才需要 PWM 载波
如果你用的是常见的 433MHz OOK 发射模块,英文字母全拼 datasheet 上只标了 DATA、VCC、GND 三个引脚,那么 STM32 完全不用输出载波,上述 GPIO 翻转就足够了。
只有两种情况需要 CubeMX 里额外配 PWM:
- 用红外发射管而不是射频模块,正常需要输出 38kHz 左右载波;
- 自己想搭一个 OOK 发射振荡电路,没有现成模块内置振荡器。
这种情况下,配置一个 TIM PWM 输出时,比如 TIM2_CH1,先算好分频:72MHz 主频,Prescaler=71 后计数时钟变成 1MHz,Period=26 时 PWM 频率大约 38.5kHz,Duty 设 50%。发送“1”时开 PWM,发送“0”时关 PWM。
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); // 发 1 HAL_TIM_PWM_Stop(&htim2, TIM_CHANNEL_1); // 发 0这样其实就构成了一个二级调制的 OOK:先拿数据开关载波,再把载波送到红外管或后续功放。上面 SendPulse 函数里的TX_HIGH()和TX_LOW()替换成 PWM 启停函数即可。用模块时,这条 PWM 链路是不需要的,别画蛇添足。
4. 接收端实现:双沿中断 + 定时器捕获把脉冲翻译成字节
4.1 测量高低电平宽度的思路
接收端要做的只有一件事:把 PA0 上高电平持续的时间量出来,然后和 T 比较。测量用 PA0 的外部中断触发,配合 TIM3 的自由计数值来标注时间点。
逻辑是这样的:
- PA0 从低变高,进入 EXTI 回调,记录当前 TIM3 计数值为
rise_tick; - PA0 从高变低,进入 EXTI 回调,用当前计数值减去
rise_tick,得到高电平持续时间; - 把高电平持续时间交给我们自己的解码状态机。
之所以选双沿触发而不是只测下降沿,是因为接收模块无信号时输出会有随机翻转,只有双沿测量才能完整恢复出脉冲序列。另外,建议外部中断优先级不要设太高,默认 2 就够。解码过程中偶尔丢一个误码可以靠校验滤掉,但不建议把大量处理放在中断里。
4.2 解码状态机与关键代码
状态机分成两个阶段:同步阶段,识别前导和起始位;数据阶段,连续解码 4 个字节。为了简单,我把“短脉冲计数”当作前导检测条件。连续收到若干个 1T 的高脉冲后,如果突然来一个 3T 高脉冲,就认为同步完成。
核心代码:
#define OOK_FRAME_LEN 4 #define OOK_TICK_US 300 typedef enum { OOK_STATE_PREAMBLE = 0, OOK_STATE_DATA } OOK_State; static volatile OOK_State ook_state = OOK_STATE_PREAMBLE; static volatile uint8_t preamble_cnt = 0; static volatile uint8_t byte_cnt = 0; static volatile uint8_t bit_cnt = 0; static volatile uint8_t cur_byte = 0; static volatile uint8_t packet[OOK_FRAME_LEN]; static volatile uint8_t frame_ready = 0; static volatile uint16_t rise_tick = 0; static void ProcessOokHighPulse(uint16_t high_us) { if (high_us < (OOK_TICK_US * 0.5) || high_us > (OOK_TICK_US * 3.5)) { // 明显不是有效脉冲,大概率是噪声,直接丢弃 return; } if (ook_state == OOK_STATE_PREAMBLE) { if ((preamble_cnt >= 12) && (high_us > OOK_TICK_US * 2.5)) { // 已经积累足够多短脉冲,又遇到长脉冲,视为起始位 ook_state = OOK_STATE_DATA; byte_cnt = 0; bit_cnt = 0; cur_byte = 0; } else if (high_us < OOK_TICK_US * 1.5) { preamble_cnt++; } else { preamble_cnt = 0; } return; } if (ook_state == OOK_STATE_DATA) { if (high_us > OOK_TICK_US * 2.5) { // 数据阶段又出现长脉冲,认为是新一帧的起始,重置状态 byte_cnt = 0; bit_cnt = 0; cur_byte = 0; return; } if (high_us > OOK_TICK_US * 1.5) cur_byte |= (0x01 << bit_cnt); // 长脉冲:1 bit_cnt++; if (bit_cnt == 8) { packet[byte_cnt] = cur_byte; byte_cnt++; bit_cnt = 0; cur_byte = 0; if (byte_cnt >= OOK_FRAME_LEN) { frame_ready = 1; byte_cnt = 0; ook_state = OOK_STATE_PREAMBLE; preamble_cnt = 0; } } } } void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == RX_PIN_Pin) { if (HAL_GPIO_ReadPin(RX_PIN_GPIO_Port, RX_PIN_Pin) == GPIO_PIN_SET) { rise_tick = __HAL_TIM_GET_COUNTER(&htim3); } else { uint16_t high_us = __HAL_TIM_GET_COUNTER(&htim3) - rise_tick; ProcessOokHighPulse(high_us); } } }主循环里轮询frame_ready:
if (frame_ready) { frame_ready = 0; uint8_t sum = packet[0] + packet[1] + packet[2]; if (packet[3] == sum) { printf("cmd ok: %02X %02X %02X\r\n", packet[0], packet[1], packet[2]); } else { printf("crc err: %02X %02X %02X %02X\r\n", packet[0], packet[1], packet[2], packet[3]); } }注意几个细节:
preamble_cnt和high_us的比较都是整数运算,OOK_TICK_US * 0.5会先转成浮点,再转回整数,效率低但能用。工程上我一般是写成high_us < 120和high_us > 900这种魔数,并加注释,避免每次改协议都要翻公式。- TIM3 是 16 位计数器,计满 65535 会清零。如果 T 取 300us,一帧最长也就几毫秒,不会遇到翻转。如果你芯片用量小,实验时改了 T 到几十毫秒,就得考虑计数器溢出处理。
- 接收停止后最好把
preamble_cnt清零,防止下一帧刚开始时残留计数导致误判。
4.3 重复帧、丢帧和校验
OOK 链路没有链路层确认,发送端是一次性的。如果接收端校验失败,这一帧就丢了,应用层如果有需求就要发重传指令。我在实际项目里是这么做的:接收端每收到一帧合法数据,先看地址是不是自己的,不是就丢弃;是自己的,再比对命令是不是上一条。如果连续收到重复命令,只在状态变化时执行动作,避免继电器或 LED 输出反复翻转。
校验只算累加和,不校验长度,因为帧长固定 4 字节。如果以后要动态长度,建议在数据段前加长度字节,校验改成“从地址到长度”。
5. 联调排错:最常被坑的五个细节
5.1 接收模块静态噪声
第一次接好电路,打开串口调试,你会发现即使没人按键,PA0 上也是一堆乱七八糟的脉冲,短则几微秒,长则几十微秒。这不是代码问题,也不是模块坏了,而是廉价超外差接收模块在无信号时自动增益会把底噪放大成方波。解决方案就是前面说的状态机:所有小于 0.5T 的脉冲直接丢弃,前导同步序列没锁定前,不接受任何数据。用 T=300us 时,小于等于 150us 的脉冲全丢,绝大部分噪声都进不来。
5.2 供电和电平不一致
这一条排查最常见。无线模块发射瞬间电流可能冲到几十毫安,如果和 STM32 共用一条很细的杜邦线供电,MCU 有可能瞬间复位,现象就是每次按完发送,设备就像重启了一次,串口日志开头那段初始化信息反复刷。处理办法:发射模块单独用 100uF 电解电容 + 0.1uF 陶瓷电容就近跨接在 VCC 和 GND 之间,接收模块和 MCU 之间至少保证共地。
电平匹配也要验证。某些模块的 DATA 输入兼容 3.3V,某些模块的 DATA 最低高电平要求接近 3V,STM32 的 3.3V 推挽输出勉强能带,但长期可靠性不好。如果模块说明书明确写 5V TTL,建议 STM32 GPIO 出来加一个 NPN 三极管或者 2N7002 MOS 管做电平转换,别直连硬怼。
5.3 波形和距离都撑不住
如果把 T 改小到 100us 想提高速率,大概率会翻车。433MHz OOK 接收模块的数据带宽普遍不高,很多模块上升沿/下降沿时间都要上百微秒,高电平持续 100us 根本识别不出来。实战经验是:T 最小不要低于 200us,再低就基本告别“低成本模块”了。想提升速率,换 FSK 模块要比压编码更快。
距离不达标,第一件事不是加发射功率,而是检查天线。模块自带的“天线”大多是一根 17cm 左右的导线,对应 433MHz 四分之一波长。有人图整洁把它剪短成 5cm,距离直接掉一半。第二件事是看模块放在哪,不要放在大平面铜皮正上方,不要贴近电源线,更不要放在金属外壳里。OOK 对天线环境相当敏感,换根满波长线往往立竿见影。
5.4 逻辑分析仪是排错神器
联调时不要盲目改代码。先用逻辑分析仪同时抓 PA1(发送端)和 PA0(接收端),看两组波形是否一致。常见情况是发送端波形很标准,接收端波形毛刺很多。毛刺不影响“判断高低电平”,但会让脉冲宽度变形。如果波形完全不对,再查接线。
我最常做的一个自测动作是:不接射频模块,把发送端 PA1 直接接到接收端 PA0,不上电的模块电路全部断开。这样能在 0 成本干扰下验证协议代码本身。协议通了,再接射频模块,就能把问题定位到无线链路,而不是一直在 MCU 代码里打转。
表里整理了几种典型的现场现象和排查方向:
| 现象 | 可能原因 | 优先排查 |
|---|---|---|
| 接收端一直噪声但无完整帧 | 前导脉冲太短或接收模块带宽不足 | 增大 T,检查 DATA 引脚接法 |
| 近距离能通,稍远就丢 | 天线太短、供电不足、模块靠金属太近 | 换天线、加电容 |
| 发送一次,接收端收到好几条 | 接收端没有过滤重复命令 | 状态机加命令去重 |
| 一按发送,MCU 就复位 | 发射模块瞬间拉低电源电压 | 模块附近加去耦电容 |
| 串口日志乱码 | 波特率不一致或电平不兼容 | 检查 UART 配置、共地 |
6. 这套 OOK 收发还能往下扩展什么
6.1 双向应答与多设备组网
单向遥控做通后,下一个需求通常是“我要知道设备执行成功没有”。这时候可以在设备端也加一路 OOK 发送模块,上位机加一路 OOK 接收模块,做成半双工应答:主机发一帧“设置命令”,设备收到后回一帧“确认”。因为 OOK 工作在同一个频点上,不能同时收发,要用时分方式,设备发完确认后等几百毫秒再回。协议上再加设备 ID,一主多从就出来了。
多设备组网时,帧格式里的地址字段要单独占一个字节,可区分 256 个节点。接收端在解析完帧后先比较地址,不是自己的地址直接丢弃,不执行动作。接入数量超过十几个以后,建议把帧间隔拉长到 20ms 以上,避免各设备随机发帧时碰撞概率太高。
6.2 低功耗上报场景
如果你想把 OOK 收发做到电池供电的传感器里,有几个点要注意:
- 发送端尽量用短时脉冲唤醒射频发射模块,不要让它一直上电,只在上报到瞬间拉高 VCC。
- 接收端平时可以进 STOP 模式,PA0 的外部中断能当唤醒源。但要注意,停止模式下 TIM3 不工作,唤醒后需要用一段固定脉宽的同步头重新建立时间基准,再做数据解码。
- 不要每个 bit 都通过主循环翻转,低功耗下用 DMA 或者简单的 GPIO 位带操作减少 CPU 唤醒时间。
6.3 一点私人建议
最后再说一个土办法:把发送端的延时时间T和接收端的判决阈值写成宏,放在同一个头文件里,而不是各自写死在代码里。OOK 项目最烦人的就是改完 T 忘了同步改接收端。我把TICK_US放在ook_config.h,发送和接收共用,就算中间换了模块,也只动这一个文件。实测下来,这样管理几十次联调之后,你就能把绝大部分时间花在真正的问题上,而不是在代码里翻来翻去比大小。