STM32定时器中断解码433M无线遥控:从脉宽测量到状态机
2026/9/13 19:42:02 网站建设 项目流程

简介:一份基于STM32定时器中断实现433M无线遥控解码的完整源程序包,适合正在学习STM32无线通信、智能家居遥控接入或需要快速实现RF433解码的嵌入式开发者。程序利用定时器中断精确捕获外部信号边沿,进而测量脉冲宽度,完成对433M遥控信号的软件解码,方案经实际测试可用,可直接烧录运行。压缩包整体仅5KB,包含1个头文件和1个C源文件,函数封装清晰,核心解码逻辑集中在rf433模块内,方便用户按需修改引脚或定时器配置,也能轻松移植到其他STM32系列工程中。目前已有584人学习下载。通过这份源码,读者既能理解定时器中断配合状态机解析无线协议的思路,也能直接调用现成接口获取遥控键值,省去从零编写和调试信号解码的繁琐过程,适合作为毕业设计、课设或量产项目的前期参考。

1. STM32定时器中断解码433M无线遥控,先搞懂“时间戳”

遥控器的接收模块DATA脚在按键时吐出的不是规整的串口数据,而是一串宽度不同的方波。信息不在电平高低里,而在“这个电平维持了多久”。所谓STM32定时器中断解码433M无线遥控,不是用定时器产生载波,也不是靠死循环等跳变,而是让定时器给每个边沿做一次精准计时,把脉宽序列喂给状态机,最终还原出芯片ID和按键值。这个标题看着像老工程,实际上解决的是低成本、不阻塞主循环的通用解码问题。接下来我会按“原理→最小工程→参数调校→产品化”的顺序把它讲透,配套代码可以直接抄进STM32项目里改着用。

2. 解码原理:433M无线遥控的0/1由脉宽决定,不是由电平决定

2.1 接收模块输出的是“有载波/无载波”的时间序列

很多第一次接触433M无线遥控的人会误以为解码要处理高频信号。实际上,市面上常见的超外差/超再生接收模块已经把射频部分做完了,DATA引脚输出的就是解调后的数字脉冲串。遥控器发射时,模块输出高电平;不发射时,模块输出低电平。载波频率433M只是运输工具,数据本身通过“载波有无持续了多久”来表达,也就是ASK/OOK调制。所以解码程序只需要做一件事:把DATA脚上每个高电平、低电平的持续时间准确测出来,再按协议把这些时间还原成0/1和一帧数据。

这个“测时间”的动作如果放在主循环里用循环等待,会挡住其他任务;如果放在外部中断里,中断服务函数又不能花太多时间做协议解析。于是行业里最常见的做法就是把定时器当成一块高精度秒表,在GPIO边沿中断里读一下当前计数值,差值就是脉宽。整段程序可以完全面向事件驱动。

2.2 EV1527和PT2262的时序特征

先看最常见的EV1527学习码遥控器。它一帧包含24bit,其中前20bit是遥控器ID,后4bit是按键值。EV1527的码元定义很清晰:一个码元由固定宽度的高电平和跟随的可变低电平组成。以很多模块实测值来看,单位脉宽T在320~350us附近,0码是“高约1T+低约1T”,1码是“高约1T+低约3T”,同步码是“高约1T+低几毫秒到十几毫秒”。PT2262则是一类更老的编码芯片,它的地址由芯片引脚电平配置,脉宽由外接振荡电阻决定,不同板子的实际宽度差异比EV1527大得多。参数可以用表格粗看,但具体到自己的接收模块一定要实测。

协议一帧组成0/1特征同步特征
EV152724bit,20bit ID+4bit键值高+短低 / 高+长低高+超长低
PT2262系列由芯片型号决定高低电平宽度比例不同超长低电平

这些协议都没有标准串口那样的起始位和停止位,也没有时钟线,唯一的同步依据就是对“时间”的度量。因此,解码程序的骨架就是“测量脉宽→判断脉宽→组装bit→组装帧”。

2.3 三种“定时器中断解码”的方案对比

标题里强调“定时器中断”,实际工程里有三种常见实现,很多人下到的源程序可能是其中任一种,容易看得一头雾水。

方案实现方式优点缺点
EXTI+定时器取时间戳每个边沿进EXTI中断,读自由运行定时器的CNT算差值精度高到1us,主循环不阻塞,代码通用有两个中断源,状态机要自己写
定时器输入捕获定时器通道自动记录边沿计数值,进CAPTURE中断中断次数少,硬件自动打时间戳双边沿捕获配置绕,换引脚麻烦
定时器固定间隔采样每10us进一次定时器中断,读GPIO再叠状态机只用一个定时器,逻辑直观10us中断频率高,433M这类宽脉冲尚可,短脉冲吃力

我一般选用第一种。只要把定时器预分频配成1us计数一次,一个32位定时器可以连续跑71分钟不回绕,完全够一次按键产生的几百个边沿用。方案B在STM32上能做,但双边沿捕获要两个通道配合,反而把简单问题复杂化;方案C适合给新手理解原理,占用中断资源太多,不如A干净。

2.4 为什么解码逻辑要放进状态机而不是临时拼代码

从时间戳到按键值,中间要经过同步码识别、bit组装、帧长度判断、出错恢复。写成一个状态机之后,每次EXTI中断只需执行几十条指令,既不会拖长中断时间,也方便后续扩展别的433M协议。状态机在源程序里通常对应一个 rf_decoder_feed(dur, level) 函数,中断只负责喂数据,主循环只负责收结果。

还要说明一点:定时器中断在这里是“提供时间基准”,不是“所有解码代码都在定时器中断里跑”。固定间隔采样的方案才会把状态机放进定时器中断里;而EXTI+定时器方案的中断里只有差分计算和一次状态机调用,耗时比定时器采样方案小得多。

3. 用STM32定时器中断解码433M无线遥控的最小工程

3.1 引脚与定时器分配

接收模块DATA脚接PA0,原因是PA0对应EXTI0,中断入口独立且开销小。TIM2配置成32位自由运行定时器,计数器每1us增加1。CubeMX里不需要给TIM2开中断,只要它默默计数就行,因为解码需要的是随时能读到精确计数值,而不是等定时器溢出。

外设配置作用
PA0GPIO_MODE_IT_RISING_FALLING,下拉双边沿外部中断
TIM2PSC=71,ARR=0xFFFFFFFF,向上计数1us自由运行计数器
EXTI0优先级设为1(高于普通外设)保证边沿不丢

下拉还是上拉,取决于接收模块空闲电平。多数超再生模块空闲时DATA为低,按键时输出高脉冲,所以用下拉;如果模块空闲为高,要用上拉。这一点错了,程序会不断进中断或者完全收不到脉冲。

3.2 GPIO与定时器初始化代码

下面是一段不依赖具体主程序的HAL库初始化,适用于STM32F103这类72MHz主频的芯片。计时公式是 timer_tick = APB定时器时钟 / (PSC+1),这里得到1MHz,即1格1us。

/* rf433_drv.c */ static TIM_HandleTypeDef htim2; void rf433_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_IT_RISING_FALLING; gpio.Pull = GPIO_PULLDOWN; /* 根据模块空闲电平选择上下拉 */ HAL_GPIO_Init(GPIOA, &gpio); HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn); } void rf433_timer_init(void) { __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; /* 72MHz / 72 = 1MHz,计数器+1=1us */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFFFFFF; /* 32位自由运行,长时间不回绕 */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start(&htim2); }

PSC=71是把72MHz时钟分频成1MHz;如果芯片跑在48MHz,PSC改成47;如果APB1定时器时钟不是72MHz,先确认RCC配置。ARR填0xFFFFFFFF后,TIM2永远不会触发更新中断,计数器从0数到0xFFFFFFFF再回绕,配合uint32_t减法的无符号特性,时间差依然正确。

3.3 边沿中断里做时间差分

每次边沿到达,就读一次TIM2计数值,和上一次的差值就是上一段电平持续了多少微秒。这段代码要短,不能放延时、打印或Flash写入。

static volatile uint32_t last_tick; static volatile uint8_t first_tick = 1; void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); } void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin != GPIO_PIN_0) return; uint32_t now = __HAL_TIM_GET_COUNTER(&htim2); if (first_tick) { first_tick = 0; last_tick = now; return; /* 第一次边沿没有上一段,丢弃 */ } uint32_t dur = now - last_tick; last_tick = now; uint8_t level = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); rf_decoder_feed(dur, level); }

当检测到下降沿时,读到的level为低,说明dur是刚结束的高电平宽度;当检测到上升沿时,读到的level为高,说明dur是刚结束的低电平宽度。用“当前电平”反推“刚结束的电平”是这段代码的关键,也是看源程序时最容易绕晕的地方。

注意:如果工程里已经由CubeMX生成了 EXTI0_IRQHandler,就不要再在别处重复定义,否则会链接冲突。标准外设库的老工程写法不太一样,但核心逻辑同样是“差分计数+状态机”。

3.4 解码状态机:把脉宽变成bit,再把bit变成帧

状态机只需要两个状态:等待同步和正在收数据。收到同步低电平后进入ST_DATA,之后每来一个合法的“高+低”组合就解出一个bit,攒够FRAME_BITS就置完成标志。

/* rf433_decode.h */ #define FRAME_BITS 24 #define HIGH_MIN_US 220 #define HIGH_MAX_US 500 #define LOW0_MIN_US 180 #define LOW0_MAX_US 600 #define LOW1_MIN_US 750 #define LOW1_MAX_US 2200 #define SYNC_MIN_US 3500
/* rf433_decode.c */ typedef enum { RF_WAIT_SYNC, RF_ST_DATA } rf_state_t; static rf_state_t state = RF_WAIT_SYNC; static volatile uint32_t rx_buf = 0; static volatile uint8_t rx_bits = 0; static volatile uint8_t high_ok = 0; volatile uint8_t rf_rx_done = 0; volatile uint32_t rf_rx_frame = 0; static void rf_add_bit(uint8_t bit) { rx_buf = (rx_buf << 1) | bit; if (++rx_bits >= FRAME_BITS) { rf_rx_frame = rx_buf; rf_rx_done = 1; state = RF_WAIT_SYNC; rx_bits = 0; } } void rf_decoder_feed(uint32_t dur_us, uint8_t level) { if (dur_us > 50000) { /* 键抬起后的长低电平或噪声 */ state = RF_WAIT_SYNC; rx_bits = 0; return; } if (level) { /* 刚结束的是低电平 */ if (dur_us >= SYNC_MIN_US) { state = RF_ST_DATA; rx_bits = 0; high_ok = 0; return; } if (state == RF_ST_DATA && high_ok) { if (dur_us >= LOW1_MIN_US && dur_us <= LOW1_MAX_US) { rf_add_bit(1); } else if (dur_us >= LOW0_MIN_US && dur_us <= LOW0_MAX_US) { rf_add_bit(0); } else { state = RF_WAIT_SYNC; rx_bits = 0; } } else if (state == RF_ST_DATA) { state = RF_WAIT_SYNC; rx_bits = 0; } } else { /* 刚结束的是高电平,所有数据位的高都应落在同一窗口 */ high_ok = (dur_us >= HIGH_MIN_US && dur_us <= HIGH_MAX_US); if (state == RF_ST_DATA && !high_ok) { state = RF_WAIT_SYNC; rx_bits = 0; } } }

逻辑说明分三块。第一,低电平分支里先判断同步,原因是同步码的低电平远长于数据位,一旦识别到同步,说明后面跟着一帧数据,此刻最安全的行为是清空前面残留的bit。第二,数据位的判断必须依赖high_ok,因为EV1527一个bit的高电平宽度基本固定,如果高电平不在窗口内,后面低电平再像0或1也不能信。第三,rx_buf左移填入bit,先收到的在高位,这样rf_rx_frame直接就是协议里的原始帧,不需要再按bit反转。

主循环里这样取结果:

if (rf_rx_done) { rf_rx_done = 0; uint32_t frame = rf_rx_frame; uint32_t dev_id = frame >> 4; uint8_t key = frame & 0x0F; /* 业务逻辑:先比对dev_id,再分发key */ }

FRAME_BITS=24是针对EV1527的。PT2262或其他芯片机型改这个宏即可,但要注意有些协议一帧不是24bit,盲目按24去截断会在最后一个bit之前误报完成。

3.5 换芯片和换主频时怎么改

这三个地方最容易忘:预分频器按实际时钟改成1MHz计数;GPIO上下拉按模块空闲电平改;HAL_GPIO_EXTI_Callback在旧标准库里对应的是GPIO_EXTI_Callback或直接在中断函数里写判断,现在用HAL库则都要经过HAL_GPIO_EXTI_IRQHandler转发。

4. 433M解码参数标定:先数清楚脉宽,再定阈值

4.1 用示波器或逻辑分析仪量一组真实波形

拿到源程序后第一件事不是烧录,而是把接收模块DATA脚接上逻辑分析仪,按一次按键抓完整一帧。操作步骤:

  1. 接收模块DATA接逻辑分析仪通道,GND共地,采样率至少2MS/s。
  2. 触发方式选下降沿,单次触发,然后按遥控器任意键。
  3. 测量连续几个高电平和低电平宽度,记录最大、最小、典型值。
  4. 如果模块输出电平反相,波形看起来是低多高少,就把逻辑取反后再量。

我见过最省事的工具是几十元的24MHz采样逻辑分析仪配PulseView,看433M这类几十kHz的脉冲绰绰有余。测出来的数据像这样:

信号段实测范围初始参数判定
数据位高电平340~355usHIGH_MIN=220, HIGH_MAX=500通过
0码低电平330~360usLOW0_MIN=180, LOW0_MAX=600判0
1码低电平990~1080usLOW1_MIN=750, LOW1_MAX=2200判1
同步低电平8~9msSYNC_MIN=3500判同步

这个表是EV1527类模块的常见值,不能代表所有模块。不同批次接收模块的输出整形电阻会有差异,尤其是PT2262这类靠振荡电阻定脉宽的协议,差两三倍都很正常。

4.2 容差窗口怎么定

解码程序最忌讳用固定值比较,比如“如果低电平等于350us就是0”。实际波形受遥控器电池电压、温度、接收模块批次影响,脉宽会有20%左右的漂移。正确做法是把判定写成区间:

LOW0的区间要覆盖实测0码的分布,LOW1的区间覆盖实测1码的分布,两个区间之间留出至少300us的空隙。EV1527的0码约350us、1码约1050us,所以把0码上限放600,1码下限放750是安全的。如果测出来0码最大500、1码最小800,空隙只有300,应把区间收窄到450~700和750~1100。区间大了抗漂移,区间小了抗误判,要按实际模块折中。

4.3 三个必调参数与三张典型“失败脸”

第一个必调参数是SYNC_MIN_US。它的作用是识别一帧的开始,设太大容易漏同步,设太小容易把某个1码长低电平误判成同步。通常放在“实际同步低电平的一半”附近。EV1527同步低电平常见8ms以上,初始值3500us很安全。

第二个必调参数是LOW1_MIN_US。它决定了1码的最低门槛,和LOW0_MAX之间的差值就是容错带宽。差得太窄,遥控器离得远、波形变钝时就会误判。

第三个必调参数是FRAME_BITS。很多人解出来只有16bit或者总是多一位,问题往往不是算法,而是把24bit的帧硬按别人的协议挑。调试时先在串口打印rx_bits,看到真实位数再固定宏。

现象优先检查处理
解出来全是0或全是1接收模块电平是否反相读GPIO后取反再喂状态机
帧解出来少几位FRAME_BITS/LOW1_MAX过小打印rx_bits,按实际长度调整
靠近开发板时乱码上下拉和电源纹波换GPIO_PULLUP,接收模块远离开关节点

注意:EXTI回调里不要调用printf、HAL_Delay、Flash写入。这些操作在中断里会拖长沿到沿的时间窗口,导致下一个边沿中断被延迟,解码成功率直线下降。

4.4 超时重置和Keil调试技巧

遥控器按键松开后,模块输出会落回空闲电平,此时两个边沿间隔会超过几十毫秒。状态机里已经加了dur_us>50000的强制复位,保证下一次按键能从干净的WAIT_SYNC开始。没有这个保护,中间一个噪声位会让状态机一直停在ST_DATA里等低电平,后边的帧全部废掉。

Keil里调试这类中断驱动的代码,不要直接在EXTI回调里打断点,每按一次键会断几百次,时序完全失真。可以设条件断点rf_rx_done==1,或者把rf_rx_frame加到Watch窗口,按下遥控器后看变量变化。如果怀疑某个脉宽判断不对,临时在rf_decoder_feed入口存最近的8个dur到一个数组里,break后查看数组,比printf好用得多。

5. 进阶:从“解出一帧”到“做成一个可用的遥控器”

5.1 把dev_id和key分开处理

EV1527一帧的24bit里,前20bit是设备ID,后4bit是按键值。如果只取key=frame&0x0F就分发业务,会遇到一个很实际的问题:手里两把不同遥控器如果是同型号同批次,ID可能相同,按键冲突;更危险的是,某把遥控器的地址位和另一把重合,按下A遥控器的键1会把B遥控器的键1一起触发。常见的做法是业务层用一个结构体同时保存dev_id和key:

typedef struct { uint32_t dev_id; uint8_t key; } rf_event_t;

每次收到完整帧,先比较dev_id是否在已学习的列表里,再按key分发动作。这样换遥控器时不会出现“明明没按却动了”的问题。

5.2 长按时重复帧的处理

433M遥控器在长按时会反复发射相同帧,间隔通常在几十毫秒到一百毫秒。如果主循环每发现rf_rx_done就去触发继电器、蜂鸣器,会被连续触发很多次。常见做法是做一次300ms去抖:

void rf_handle_event(uint32_t frame) { uint32_t dev_id = frame >> 4; uint8_t key = frame & 0x0F; static uint32_t last_id; static uint8_t last_key; static uint32_t last_ms; if (dev_id == last_id && key == last_key && HAL_GetTick() - last_ms < 300) { return; } last_id = dev_id; last_key = key; last_ms = HAL_GetTick(); /* 真正的按键业务写在这里 */ }

这个去抖放在解码状态机外面而不是中断里,因为它依赖HAL_GetTick()的1ms节拍,放中断里反而没必要。

5.3 学习码存储和地址冲突的最后一关

如果做的是遥控插座、车库门这类设备,最好把dev_id存进片内Flash,而不是每次上电重新学习。存的时候要连校验一起存,否则Flash某个字节磨损后可能出现一把从没配过的遥控器被放行。最简做法是存dev_id+key+CRC16,校验通过才启用。

5.4 量产前必做的自检步骤

最后给一个能直接放到工厂测试程序里的验证技巧:定义一个计数器,让测试员把同一把遥控器的同一个键连续按50次,统计rf_rx_done触发次数并检查50次里有没有出现不同的dev_id,只要dev_id不一致或成功率低于98%,就说明解码参数或者接收模块位置有问题。把这段统计留在产品里还有一个额外好处:用户上门投诉“经常失灵”时,可以让售后按住按键,同时读统计值,就能立刻区分是射频链路差还是测试环境差。

这套“定时器打时间戳+状态机”的解码模板换到38K红外遥控解码也一样能用,把时间窗口换掉就是另一个协议的解码器。如果换遥控器型号后解出的帧总是错位,先回到第4章量一遍真实波形,再动代码。

本文还有配套的精品资源,点击获取

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

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

立即咨询