☰
STM32+433MHz OOK无线通讯实战:CubeMX配置与编解码详解
2026/10/3 8:08:45 网站建设 项目流程

做无线遥控、无线传感器这类小项目的人应该都绕不开这个组合:STM32加OOK通讯。我最近刚好把一套433MHz的OOK发送接收调通了,从CubeMX配置到编解码协议全部自己写了一遍,整个过程踩了不少坑,也整理出了一些可以直接抄作业的配置思路。这篇就把STM32做OOK无线通讯的完整方案拆开讲清楚,重点放在CubeMX怎么配置、发送端怎么编码、接收端怎么解码,以及实际调试中那些文档里根本不会写的细节。

这套方案能解决的问题很直接:用最少的硬件成本,让两块STM32之间隔几十米无线传数据。适合正在做无线遥控、无线门铃、车位锁、农业大棚温湿度采集这类项目的人参考。如果你手里已经有STM32开发板和几块钱一对的433MHz模块,看完这篇基本就能把收发链路跑起来。

1. 先搞懂OOK原理,再想代码怎么写

1.1 OOK调制到底是啥

OOK全称是On-Off Keying,开关键控。说人话就是:用载波“有”和“没有”来表示数据。载波有,代表1;载波没有,代表0。这和普通按键发报一样,按住发报键是长音,松手是短音,区别只是OOK用无线电载波来承载。

用到433MHz射频模块上的时候,事情更简单。发射模块内部自带了振荡电路,你只需要给它一个数字电平:给高电平,模块就起振发射载波;给低电平,模块就停振。接收模块正好反过来,收到载波时DATA脚输出高电平,收不到就输出低电平。所以从STM32的角度看,OOK通讯就是“发送端控制GPIO高低电平,接收端测量GPIO高低电平持续时间”。

这里有个容易误解的地方:很多人以为要用STM32生成433MHz的载波信号,其实完全不用,射频模块自己把载波搞定,单片机只负责“要不要让模块工作”这个开关动作。这就像你控制一个灯泡,不需要自己发电,只需要决定开关是闭合还是断开。

1.2 为什么不用现成的编码芯片

市面上315MHz、433MHz遥控器几乎清一色用EV1527、PT2262这类专用编码芯片,一个芯片也就几毛钱,配上晶振和MOS管就能用。既然有现成方案,为什么还要用STM32做OOK?

我选择这个方案的关键原因是灵活性和数据量。编码芯片的协议是写死的,帧格式固定,每次按键只发几个字节,没法传传感器数据。STM32这边想怎么定义协议都可以,一帧传温度、湿度、设备ID,甚至转发RS485总线数据都不在话下。数据率也可以自己调,低速能传更远,高速能传更多数据,这个主动权很重要。

另外还有成本考虑。如果项目里本来就要用STM32做控制或采集,加一个OOK射频模块的成本就是几块钱,而不需要再买一颗编码芯片再加一颗解码芯片。特别是多对一、一对多的组网场景,用软件协议处理设备地址、重发机制,比硬件编解码灵活太多。

1.3 整个系统的技术架构

这套OOK通讯系统的架构不复杂,就是一条发射链路加一条接收链路:

发送端:STM32按照自定义帧格式,把要发送的数据编码成一串不同宽度的脉冲,通过GPIO控制433MHz发射模块的DATA引脚,让模块按脉冲节奏起振和停振。

接收端:433MHz接收模块把空中的射频信号解调成数字电平,送到STM32的一个定时器输入捕获引脚。STM32用输入捕获功能测量每个高电平的持续时间,把时间值翻译回0或1,再按帧格式组装成完整数据。

至于CubeMX在整个项目里的作用,就是帮我们快速完成时钟树配置、GPIO初始化、定时器输入捕获初始化、串口调试初始化这些底层工作。有人觉得CubeMX生成的代码太臃肿,但做OOK这种对时序有要求的项目,直接把精力放在业务逻辑上远比扣寄存器高效。

2. 硬件选型与接线:模块、引脚、天线

2.1 433MHz射频模块怎么选

某宝上最常见的433MHz收发套件就是那种引脚间距2.54mm的小模块,一对大概几块钱。发射模块常见型号有FS1000A,接收模块常见型号有XD-RF-5V、RWS-371之类。选的时候注意三点。

第一,工作电压。很多发射模块标称5V供电,而STM32F103是3.3V供电。如果直接用3.3V给模块供电,发射功率会打折,距离明显缩短。我实测把FS1000A接到5V供电、STM32用3.3V GPIO控制DATA脚,拉高时模块DATA脚电平和3.3V差不多,模块也能正常工作,因为模块内部把DATA端电平识别阈值定得比较低。但不同批次模块的阈值不一样,最稳妥的办法是加一个三极管做电平转换,或者直接买3.3V兼容版本的模块。

第二,接收模块的DAT输出逻辑。市面上接收模块有两种设计,一种空闲时DAT输出低电平,收到载波拉高;另一种反过来。这个差异会直接影响解码状态机的初始状态,买回来先用万用表或示波器确认一下。我用的模块是空闲低电平,后文的代码都按这个逻辑写。

第三,是否自带解码。有些接收模块比如超外差接收头的DAT引脚直接输出原始的OOK解调信号,有些模块(比如带数据整形输出的)会把数据整理成UART电平。做OOK实验一定要买输出原始解调信号的模块,也就是常说的“不带解码”版本。

2.2 CubeMX引脚分配与硬件接线

我用的是STM32F103C8T6,最小系统板。引脚分配如下:

发送端:

  • PA1,推挽输出,接433MHz发射模块DATA引脚
  • PA9/PA10,USART1,接USB转TTL调试打印

接收端:

  • PA6,复用功能,接433MHz接收模块DATA引脚,同时这是TIM3_CH1的输入捕获引脚
  • PA9/PA10,USART1,接USB转TTL调试打印

接线图不画了,口述一下:发射模块VCC接5V,GND接GND,DATA接PA1;接收模块VCC接3.3V或5V(看模块说明),GND接GND,DATA接PA6。

有个特别容易踩的坑:调试两块板子的时候,USB转TTL的GND和两块板子的GND之间如果没有共地,串口打印会出现乱码或者完全没输出。我是做了个小转接板,把两块板子、两个USB转TTL的GND全部拧在一起,才彻底解决。

2.3 天线和供电细节

天线这东西很多人忽略。模块上预留了一个ANT焊盘或者天线焊点,焊一根大约12cm~17cm的单股铜线上去,效果立竿见影。433MHz的四分之一波长天线理论长度是17.2cm,实际用12cm左右的也行,铜线要尽量竖直,别贴着GND铜皮或者电池。

供电方面,之前提过发射模块用5V供电能在传输距离上得到更好的表现,但要注意模块发射瞬间电流可能有几十毫安甚至上百毫安,如果直接从开发板的LDO引5V,压降会很大。我查过数据手册后外接了一路5V稳压输出,并且在天线端加了滤波电容,实测对稳定性有明显帮助。

3. CubeMX配置逐个说清

3.1 工程基础配置

打开CubeMX,芯片选择STM32F103C8Tx。SYS里Debug选Serial Wire,因为我把PA13/PA14留作SWD下载调试,不配置的话下载一次之后芯片的SWD引脚会被GPIO占用导致无法再次下载。Timebase Source选TIM6,避免SysTick和HAL_Delay抢资源。

RCC里HSE选Crystal/Ceramic Resonator,也就是外部8MHz晶振。然后在Clock Configuration里把系统时钟配置到72MHz,APB1分频到36MHz,APB2到72MHz。这一步看着繁琐,但定时器时钟正确与否直接决定输入捕获的时间测量准不准。

推荐看一下CubeMX的时钟树窗口,下面会显示各总线的实际频率。APB1定时器时钟是72MHz,APB2定时器时钟也是72MHz,后面配置定时器预分频时要按这个数字计算。

3.2 发送通道配置:GPIO还是PWM

发送端在CubeMX里的配置极其简单,因为就是一颗GPIO。将PA1配置为GPIO_Output,输出Level低,Maximum output speed High。有人可能会想,直接用定时器PWM输出不就能自动产生载波了吗?这个思路在另一类场景可以参考,但对于433MHz模块这种“高电平等于发射”的接口来说,数据引脚需要的不是连续的载波,而是编码后的电平序列。所以发送端不需要PWM,只要GPIO翻转速度够快就行。

F103的GPIO翻转速度,推挽输出模式下跑到几MHz没有问题,OOK编码的脉冲宽度都在百微秒级别,完全够用。CubeMX里GPIO速度设置为High,实测翻转边沿干净,没有明显的振铃。

还有一个进阶技巧:用DMA往GPIO的ODR寄存器写预编码好的电平序列,可以做到发送期间完全不占用CPU。不过这是后面的优化方向,第一次调通先用延时发送就行,逻辑更简单也更容易排查问题。

3.3 接收通道配置:定时器输入捕获

接收端是CubeMX配置的重点,也是难点。我使用TIM3_CH1,对应PA6引脚。

TIM3配置如下:

  • Clock Source选Internal Clock
  • Channel1选Input Capture direct mode
  • Prescaler设为72-1,所以计数频率是72MHz / 72 = 1MHz,计数器每1us加1
  • Counter Period设为65535(16位计数器最大值)
  • 触发信号选择上升沿触发,也就是TI1FP1
  • 极性选择上升沿,第一次捕获上升沿

这里要注意,在CubeMX里先选择上升沿,等代码生成后再在初始化代码里改成上升沿和下降沿都捕获。这样做的原因是CubeMX生成的输入捕获初始化默认只捕获一种边沿,但OOK解码必须同时捕获高电平和低电平的边界,通过两条边沿的时间差才能算出高电平持续时间。代码层面的修改后面细说。

3.4 串口调试与日志

串口配置没什么特别的,USART1,波特率115200,8位数据,无校验,1位停止位。CubeMX里把USART1的Global Interrupt打开,方便后面用中断收发日志。

这里分享一个小细节:OOK解码的调试,最快的方式就是在状态机的每个关键节点打一条日志。但如果直接用printf死等串口发送,串口发送过程中输入捕获中断会丢事件。所以我把日志输出设计成可开关的宏,调通之后直接关掉,或者在中断里只置标志位,在主循环里统一打印。

4. 软件实现:发送端编码与接收端解码

4.1 帧格式与编解码协议设计

OOK本身不定义帧格式,协议完全自己定。我参考了EV1527的脉宽调制思路,做了简化设计。

帧结构四部分组成:

同步头:一个长高电平,作为接收端识别帧起始的标志。我用的9ms高电平加4ms低电平。

数据位编码:

  • 逻辑1:高电平1.2ms,低电平0.4ms
  • 逻辑0:高电平0.4ms,低电平0.4ms

数据段:1字节设备地址加2字节数据,低位先发。设备地址用来过滤其他发射器的干扰,2字节数据载荷传什么都行,我用来传温湿度采样值。如果项目有更多数据,把数据段拉长即可。

帧间隔:至少10ms低电平,用来让接收端复位状态机。

接收端解码的核心逻辑其实就是测量高电平的持续时间。因为发射端每个逻辑位都以低电平0.4ms结尾,接收端可以利用这个规律做位同步。实际情况中,晶振误差、接收模块的上升沿延迟都会让脉宽产生几十微秒偏差,所以判定阈值要留出余量。

判定规则我定成这样:

  • 高电平持续0.85ms~1.6ms,且后面跟着的低电平在0.2ms~0.7ms,判定为逻辑1
  • 高电平持续0.2ms~0.7ms,且后面跟着的低电平在0.2ms~0.7ms,判定为逻辑0
  • 高电平持续2ms以上,判定为同步头
  • 其他情况一律认为帧错误,状态机复位

4.2 发送端代码实现

发送端最关键的一点是时序必须精确,所以我用DWT(Data Watchpoint and Trace)的CYCCNT寄存器来做微秒级延时,而不是用HAL_Delay。HAL_Delay基于SysTick,如果被中断频繁打断,脉宽会抖动,接收端解码就会出问题。

DWT延时的初始化:

static inline void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; }

发送指定脉宽的高电平或低电平:

static void OOK_SendPulse(GPIO_PinState level, uint32_t us) { HAL_GPIO_WritePin(TX_GPIO_Port, TX_Pin, level); uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }

发送一个字节,低位先发:

void OOK_SendByte(uint8_t data) { for (int i = 0; i < 8; i++) { if (data & 0x01) { OOK_SendPulse(GPIO_PIN_SET, 1200); OOK_SendPulse(GPIO_PIN_RESET, 400); } else { OOK_SendPulse(GPIO_PIN_SET, 400); OOK_SendPulse(GPIO_PIN_RESET, 400); } data >>= 1; } }

发送完整帧:

void OOK_SendFrame(uint8_t addr, uint16_t payload) { OOK_SendPulse(GPIO_PIN_SET, 9000); OOK_SendPulse(GPIO_PIN_RESET, 4000); OOK_SendByte(addr); OOK_SendByte((uint8_t)(payload & 0xFF)); OOK_SendByte((uint8_t)((payload >> 8) & 0xFF)); OOK_SendPulse(GPIO_PIN_RESET, 10000); }

这段代码的思路就是在GPIO上精确制造出同步头高电平9ms、低电平4ms,然后数据位按1.2ms/0.4ms或0.4ms/0.4ms的节奏翻转。所有时间单位通过DWT的CYCCNT换算成CPU周期,不受其他中断影响。

有一点需要强调,同步头的9ms高电平时,接收端模块会连续输出9ms高电平,这个时长足够让接收端从空闲状态进入同步检测状态。如果同步头太短,接收端还没来得及确认起始就错过了开头,所以我用了9ms这个比较充裕的数值。

4.3 接收端输入捕获与状态机解码

接收端的核心是TIM3的输入捕获中断。CubeMX生成的代码默认只捕获上升沿,我在初始化后手动加入了下降沿捕获切换逻辑。

首先,在初始化代码里启用两个通道的捕获和中断:

HAL_TIM_IC_Start_IT(&htim3, TIM_CHANNEL_1);

然后在中断回调函数里判断当前捕获到了上升沿还是下降沿:

uint32_t last_capture_time; uint8_t last_level; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { uint32_t now = HAL_TIM_ReadCapturedValue(&htim3, TIM_CHANNEL_1); uint32_t diff = now - last_capture_time; if (last_level == GPIO_PIN_SET) { // 刚捕获到下降沿,diff即高电平持续时间 OOK_ProcessHighPulse(diff); __HAL_TIM_SET_CAPTUREPOLARITY(&htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); last_level = GPIO_PIN_RESET; } else { // 刚捕获到上升沿,diff即低电平持续时间 OOK_ProcessLowPulse(diff); __HAL_TIM_SET_CAPTUREPOLARITY(&htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); last_level = GPIO_PIN_SET; } last_capture_time = now; } }

每次捕获边沿后马上切换捕获极性,这样就能连续捕获到电平跳变的时刻,用相邻两次捕获的时间差得到高电平或低电平的持续时间。

重点说下解码状态机的设计。我用一个简单枚举状态来管理:

typedef enum { OOK_STATE_IDLE, OOK_STATE_SYNC, OOK_STATE_DATA, OOK_STATE_DONE } OOK_State; static OOK_State ook_state = OOK_STATE_IDLE; static uint32_t bit_buffer; static uint8_t bit_count; static uint8_t rx_addr; static uint8_t rx_data[2];

高电平脉冲处理函数:

void OOK_ProcessHighPulse(uint32_t high_us) { switch (ook_state) { case OOK_STATE_IDLE: if (high_us > 5000) { ook_state = OOK_STATE_SYNC; } break; case OOK_STATE_SYNC: if (high_us >= 850 && high_us <= 1600) { ook_state = OOK_STATE_DATA; bit_count = 0; bit_buffer = 0; append_bit(1); } else if (high_us >= 200 && high_us <= 700) { ook_state = OOK_STATE_DATA; bit_count = 0; bit_buffer = 0; append_bit(0); } else { ook_state = OOK_STATE_IDLE; } break; case OOK_STATE_DATA: if (high_us >= 850 && high_us <= 1600) { append_bit(1); } else if (high_us >= 200 && high_us <= 700) { append_bit(0); } else { ook_state = OOK_STATE_IDLE; } break; default: ook_state = OOK_STATE_IDLE; break; } }

append_bit函数负责把收到的位组装到缓冲区,满24位后触发帧处理:

static void append_bit(uint8_t bit) { bit_buffer = (bit_buffer << 1) | bit; bit_count++; if (bit_count == 24) { rx_addr = (bit_buffer >> 16) & 0xFF; rx_data[0] = (bit_buffer >> 8) & 0xFF; rx_data[1] = bit_buffer & 0xFF; ook_state = OOK_STATE_DONE; } }

主循环里检查OOK_STATE_DONE状态,读取数据并校验设备地址,然后处理具体业务逻辑。校验通过后把状态机复位回IDLE等下一帧。

4.4 环形缓冲区与数据校验

上面的代码版本适合一帧只有3字节的场景。如果数据量更大,比如发送16字节的传感器数据,状态机内直接组包会有问题,因为输入捕获中断里不适合做太多处理。

我当时做了个简单的环形缓冲区优化:输入捕获中断里只负责把测量到的脉冲宽度放到环形缓冲区,真正解码是在主循环里做的。这样中断服务函数的时间压缩到1us以内,大大减少了中断嵌套和调度的影响。

环形缓冲区代码不复杂,但注意缓冲大小要大于一帧脉冲数的两倍,我用的是256个元素:

#define PULSE_BUF_SIZE 256 static uint32_t pulse_buf[PULSE_BUF_SIZE]; static volatile uint8_t pulse_head; static volatile uint8_t pulse_tail; void pulse_buf_push(uint32_t width, uint8_t level) { uint8_t next = (pulse_head + 1) % PULSE_BUF_SIZE; if (next != pulse_tail) { pulse_buf[pulse_head] = (level << 24) | width; pulse_head = next; } }

编码数据时,高位存的电平状态,低位存时间宽度,空间换时间。解码器在主循环里不断地从环形缓冲区取数据,再喂给状态机。

5. 实测结果与调参过程

5.1 第一次通电:没反应

两块板子烧录程序后上电,复位,发送端的LED按预期闪烁,但接收端串口什么都没打印。我以为代码有问题,花了一晚上排查,最后发现是发射模块的供电问题——模块标称5V供电,我偷懒直接从STM32的3.3V引脚取电,模块没起振,所以接收端什么都收不到。

换5V供电后,发射模块DAT引脚拉高时,用万用表量模块输出端有2.5V左右的电压波动,这时打开串口助手,接收端已经能正确解析出温度数据。

这次经历给我的教训是:先确认模块供电和射频链路,再怀疑代码。发射端DAT拉高时如果模块正常工作,用手触摸天线或者靠近接收端,接收模块的DAT输出会有明显的电平变化。没有这个变化,说明模块根本没工作,问题在供电路径上。

5.2 距离测试和时序调整

供电解决后,我在办公桌上测试,距离大约3米就能稳定收到。拿到楼下空旷区域,把发射端放在地面,接收端拿在手里,走到约35米的时候开始出现丢包。这个距离对于几块钱的模块来说差不多到极限了。

后来做了几个改动,距离从35米提升到了60米左右:

发射端加长天线,换成17cm左右。

数据率降下来,原来的1.2ms/0.4ms改成1.8ms/0.6ms,逻辑0改成0.6ms/0.6ms。低级数据率时接收带宽更窄,误码率更低。

发射模块供电加100uF电解电容,避免发射瞬间电压跌落。

接收端代码把判定阈值放宽到2.4ms以内都认为是逻辑1,同步头判定阈值提高到8ms以上。

5.3 可以进一步参考的参数记录

给一个我实测可用的参数表,后面有需要的同学可以直接参考:

参数数值说明
载波频率433.92MHz常见ISM频段
逻辑1高电平1800us从1200us调上来
逻辑1低电平600us
逻辑0高电平600us
逻辑0低电平600us
同步头高电平9000us保持不变
同步头低电平4000us
定时器计数频率1MHzPrescaler=71
接收判定1下限1200us
接收判定1上限2400us
接收判定0上限900us
数据长度24bit8bit地址+16bit数据

这个参数表的重点是接收判定阈值必须和发射脉宽匹配。发射端逻辑1高电平是1800us,接收端判定阈值的中心就必须落在1800us附近,否则温度漂移或者晶振误差累积后会把1判成0。我在实际解码逻辑里还加了低脉冲宽度的组合判断,也就是一条逻辑位的高低电平宽度之和应该在2200us~2500us之间,这样即使单个边沿受干扰,组合判断也能拒绝大部分噪声。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因排查方法
接收端完全无数据发射模块供电不足或电压不匹配测量发射模块VCC电压,确认是5V并稳定
接收端完全无数据发射模块天线缺失焊接17cm天线
接收端完全无数据数据引脚接错确认发射DATA接PA1,接收DATA接PA6
接收端乱码两个板子GND电位不一致用杜邦线把两块板GND连起来共地
有数据但校验失败模块DAT空闲电平逻辑判断反了用示波器或万用表测空闲时电平
有数据但校验失败接收端判定阈值和发射脉宽不匹配用逻辑分析仪抓DATA脚波形,量出实际脉宽再调阈值
距离短接收模块供电不足单独给接收模块供电,别和电机共用电源
距离短天线方向不对发射天线保持竖直,别贴着金属外壳
干扰严重其他家电辐射或灯具干扰降低数据率,加重复帧,帧内加16位CRC校验

6.2 一个记忆深刻的问题:接收模块输出的“额外脉冲”

调试到后期遇到一个很头疼的问题,发射端明明只在发送一帧数据,接收端却时不时解析出多一个字节的杂音。后来用逻辑分析仪抓波形才发现,接收模块在载波消失的边缘会输出一个短暂的低电平毛刺,这个毛刺宽度只有几微秒,但恰好能让输入捕获中断误判一次边沿。

解决办法是在解码状态机里增加最小脉宽过滤,低于50us的脉冲直接丢弃。这个和判定阈值配合起来,解码稳定性提升了不少。

另一个问题是外部32.768kHz看门狗晶振和433MHz模块靠得比较近,每次看门狗喂狗时射频模块就会间歇性丢帧。后来把看门狗喂狗操作挪到主循环的空闲时间,并且给模块加了屏蔽铝箔纸,问题明显缓解。

6.3 关于调试工具

做OOK调试,示波器或者逻辑分析仪几乎是必备的。我用的是普通八通道逻辑分析仪,接在接收模块DAT输出引脚上,直接观察脉冲波形。解码时如果出现问题,先看波形上每一段的宽度是否符合预期,再对照代码找原因。这样能把“硬件问题”和“软件问题”快速区分开。

如果没有逻辑分析仪,也可以用串口把输入捕获测出的脉宽时间打印出来。我早期就是这样做的:把每段高电平时间按格式打出来,在串口助手里看数值分布,从而确定正确的判定阈值。这个办法虽然笨一点,但是不需要额外硬件,对入门阶段非常友好。

写这套代码的过程中,我最大的体会是:OOK通讯的难点不在STM32本身,而在“时间”这个概念上。发射端要精确产生时间,接收端要精确测量时间,两边的时钟基准还都有误差。用STM32做OOK,本质就是在用定时器解决时间的产生和测量问题。CubeMX把定时器的初始化简化了很多,但原理还是要搞清楚:为什么预分频设71计数频率就是1MHz,为什么16位计数器会溢出需要做差值计算,这些直接决定了解码的可靠性和距离上限。产品级的OOK方案还会加64位CRC、跳频、功率控制这些更高级的机制,但从这个基础框架开始一步步调,后续不管接什么无线模块都能很快上手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询