简介:面向STM32开发者的433M无线遥控解码源码资源,重点演示使用定时器中断方式捕获并解析射频信号,避免阻塞查询方式占用处理器时间,从而提升解码实时性与抗干扰能力,适用于智能家居、遥控开关、门禁控制等需要稳定接收无线遥控键值的嵌入式场景。压缩包体积极小,仅约5KB,内含2个文件:一个头文件与一个C语言源文件,头文件负责声明解码接口、定义状态及配置参数,源文件实现定时器中断初始化、输入捕获、脉冲宽度测量和遥控码解析,模块划分清晰,可整体复制到现有STM32工程复用。例程已经过实际硬件测试,能够配合常见433M超外差接收模块直接运行,适合有一定单片机基础、希望快速掌握无线解码方法或正在开发无线遥控产品的工程师参考。由于源码规模小、逻辑简单,还便于理解定时器中断服务程序与射频通信时序,并可根据不同遥控协议调整解码参数。目前已有584人学习或下载,作为轻量级可直接运行的参考实现,性价比高。
1. 433M无线遥控解码,先从“量脉冲宽度”开始
拿到一个 433M 无线遥控器,想用 STM32 把按键值解出来,第一反应通常是找协议文档。但实际项目里更快的做法,是把遥控器按下去,用示波器看接收模块 DATA 引脚上的电平:绝大多数 433M 遥控芯片(EV1527、PT2262 及兼容型号)输出的只是一串宽窄不一的脉冲,数据藏在脉冲宽度里,而不是一串现成的 0 和 1。解码的难点不在射频端,而在如何把相邻边沿的时间差测准,再在定时器中断里把脉宽翻译成帧。
这类方案在智能插座、学习码门锁、无线遥控小车里特别常见。STM32 定时器在这里不是产生 PWM,而是做输入捕获:硬件记录边沿时刻,中断回调里只做减法,把脉宽序列交给解码状态机。相比“外部中断 + 软件延时”的写法,输入捕获受中断延迟影响小,时序更稳,代码量也更少。这篇文章会把初始化、脉宽阈值、帧校验和常见坑一次讲完。
2. 定时器输入捕获:STM32测量脉宽的初始化与中断设计
2.1 接收模块输出与“高/低电平宽度”的信号基础
433M 接收模块(超再生或超外差)内部已经完成射频解调,DATA 引脚直接输出 TTL 电平。MCU 侧不需要处理 433M 载波,要处理的只是电平跳变的时间点。以最常见的 EV1527 学习码芯片为例,遥控器按一下,模块会连续发送多帧,每帧由同步头加 24bit 数据组成。
EV1527 的每一位都由一个高电平加一个低电平构成,高电平宽度基本固定,低电平宽度决定 bit 值:窄低电平是 0,宽低电平是 1,更宽的低电平是同步头。换句话说,只要把每个边沿的时间差记下来,解码器根本不需要存整段波形,只需要按顺序判断低电平宽度。这就是定时器中断能扛住解码的原因:433M 的波特率实际上只有几千赫兹,每次中断里只做几次比较运算,CPU 占用极低。
2.2 定时器输入捕获初始化:预分频、周期与ICFilter取值
用 HAL 库配置定时器输入捕获时,最关键的参数是预分频、自动重载值和数字滤波器。我一般把计数频率设为 1MHz,也就是一个 tick 对应 1us,这样脉宽读出来就是微秒数,不用再做单位换算。自动重载值设成 0xFFFF,单次可测 65ms,对 433M 遥控码来说足够长。
TIM_HandleTypeDef htim2; void init_tim2_input_capture(void) { GPIO_InitTypeDef gpio = {0}; TIM_IC_InitTypeDef ic = {0}; __HAL_RCC_TIM2_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = GPIO_PIN_0; /* TIM2_CH1 对应 PA0 */ gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; /* 72MHz / 72 = 1MHz,1 tick = 1us */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFF; /* 最大测量 65535us */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(&htim2); ic.ICPolarity = TIM_INPUTCHANNELPOLARITY_FALLING; /* 先抓下降沿 */ ic.ICSelection = TIM_ICSELECTION_DIRECTTI; /* 直接映射到 TI1 */ ic.ICPrescaler = TIM_ICPSC_DIV1; /* 每个边沿都捕获 */ ic.ICFilter = 0x08; /* 数字滤波,滤掉窄毛刺 */ HAL_TIM_IC_ConfigChannel(&htim2, &ic, TIM_CHANNEL_1); HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0); /* 中断优先级调到最高 */ HAL_NVIC_EnableIRQ(TIM2_IRQn); HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); }预分频为什么选 72-1 而不是 71?这里减一是因为 STM32 的预分频器是从 0 开始计数的,写入 71 就是 72 分频。如果换了 48MHz 主频的芯片,要相应改成 48-1,保持 1us 的分辨率。ICFilter 的取值范围是 0 到 15,值越大滤波越强,但也会把窄脉冲滤掉。EV1527 的最短脉宽在 300us 左右,ICFilter 设 0x08 基本够用,如果看到解码丢帧严重,可以先把这个值降到 0 排查。
还需要补一个中断服务函数,HAL 库的输入捕获回调依赖它执行:
void TIM2_IRQHandler(void) { HAL_TIM_IRQHandler(&htim2); }2.3 中断回调中的脉宽计算与边沿极性切换
输入捕获中断触发时,CCR1 寄存器里存的是当前计数器的值。相邻两次捕获做差,得到的就是两次边沿之间的时间间隔。但只测间隔还不够,还要知道这个间隔是“高电平宽度”还是“低电平宽度”,方法是从当前的捕获极性反推:
volatile uint16_t last_tick = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { uint16_t now; uint16_t diff; uint8_t is_high; if (htim->Instance != TIM2) return; now = __HAL_TIM_GET_COUNTER(&htim2); diff = (uint16_t)(now - last_tick); /* 相邻边沿差,单位 us */ last_tick = now; /* 当前配置的是下降沿捕获,说明刚测完的是高电平 */ if (__HAL_TIM_GET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1) == TIM_INPUTCHANNELPOLARITY_FALLING) { is_high = 1; } else { is_high = 0; } decode_add_width(diff, is_high); /* 切换捕获极性,保证上升沿和下降沿都能抓到 */ if (is_high) { __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); } else { __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } }diff 的计算用uint16_t减法,利用无符号数回绕特性,即使 now 比 last_tick 小,也能得到正确差值,不用额外判断。decode_add_width 就是把脉宽喂给解码层,它只做分类和状态转移,不涉及 UART 打印或延时,避免在中断上下文里做慢操作。
2.4 中断优先级与中断服务函数的接法
定时器中断的优先级如果被串口中断、DMA 中断抢占,边沿时间戳就会延迟记录,导致脉宽测量出现几十微秒的抖动。对于 433M 解码来说,几十微秒偏差不至于致命,但如果系统里同时跑着 WiFi、Flash 写入之类的操作,干扰会叠加。稳妥的做法是把定时器中断优先级设为当前使用的最低优先级分组下的最高数值优先级,也就是抢占优先级和子优先级都设成 0。
另外要注意,HAL_TIM_IC_CaptureCallback 是弱定义回调,工程里有多个定时器通道时,每个回调都要先判断htim->Instance和htim->Channel。如果只写了一个定时器的判断就返回,其他定时器共用同一个 htim 指针,很容易串通道。
3. 帧格式分析与定时器中断解码状态机的实现
3.1 EV1527与PT2262的脉宽格式差异
解码器写得好不好,取决于对帧格式的理解。市面上常见的 433M 遥控芯片分两类:固定码芯片 PT2262 和学习码芯片 EV1527。两者都是通过脉宽编码,但编码方式不同。
EV1527 属于“脉宽调制”:高电平宽度固定,低电平宽度区分 0 和 1。每次按键发送多帧,每帧包含同步头和 24bit 数据。数据位中,窄低电平(约 300us 到 500us)为 0,宽低电平(约 900us 到 1300us)为 1,同步低电平在 9ms 左右。这类芯片也叫“学习码”,因为接收端可以学习遥控器 ID。
PT2262 属于“脉位调制”:每一位由两个窄脉冲组成,脉冲间隔短为 0,间隔长为 1,同步段是一个特别长的低电平。PT2262 的绝对时间由外部振荡电阻决定,不同板子差异很大,所以不能照抄 EV1527 的阈值。
| 编码类型 | 0 表示 | 1 表示 | 同步头 |
|---|---|---|---|
| EV1527 学习码 | 固定高电平 + 窄低电平 | 固定高电平 + 宽低电平 | 固定高电平 + 约 9ms 低电平 |
| PT2262 固定码 | 窄脉冲间隔短 | 窄脉冲间隔长 | 约 4 个数据位长度的低电平 |
如果只针对 EV1527,解码状态机只需要在低电平结束后判断宽度值;如果要做兼容,需要把“当前是第几个脉冲”也放进状态机。工程上很多接收模块方案只适配学习码,因为学习码遥控器通用性更好,固定码逐渐用得少了。这里以 EV1527 为主线,后面给出扩展思路。
3.2 用脉宽分类函数把电平宽度翻译成bit
有了脉宽序列,下一步是分类。我习惯把有效宽度分成四类:短、标准 1 宽度、同步、无效。阈值不是死的,同一个遥控器放久了或者电池电压变了,脉宽会漂移几十微秒,所以分类函数里要留余量。
#define W_SHORT_MIN 200 #define W_SHORT_MAX 600 #define W_LONG_MIN 900 #define W_LONG_MAX 1300 #define W_SYNC_MIN 8000 #define W_SYNC_MAX 12500 uint8_t classify_low_width(uint16_t w) { if (w > W_SHORT_MIN && w < W_SHORT_MAX) return 0; /* bit 0 */ if (w > W_LONG_MIN && w < W_LONG_MAX) return 1; /* bit 1 */ if (w > W_SYNC_MIN && w < W_SYNC_MAX) return 2; /* 同步头 */ return 0xFF; /* 无效宽度 */ }阈值上下限为什么要拉开这么多?因为超再生接收模块的脉宽会受温度、电压、环境干扰影响。EV1527 名义上窄低是 350us、宽低是 1050us,但同一批次器件实测可能到 450us 和 1150us。如果按 datasheet 的典型值套死,换一个遥控器就解不出来。把 0 判到 200 到 600us,1 判到 900 到 1300us,中间留了 300us 的死区,能避免阈值抖动时误判。
3.3 在定时器中断里跑解码状态机
状态机的职责是把连续的脉宽序列切分成帧。设计上有两种做法:一种是先在中断里把脉宽全部缓存,主循环离线解析;另一种是中断里直接跑状态机,只保留最终结果。后者内存占用小,代码也直观,适合学习码这种格式固定的场景。
状态机需要记住上一个高电平宽度,因为同步头的特征本质是“窄高 + 宽低”。只看低电平会把空闲低电平误认成同步头。以下代码放在 3.2 的分类函数之后即可编译运行:
typedef enum { DEC_WAIT_SYNC, DEC_GET_BITS } dec_state_t; static dec_state_t dec_state = DEC_WAIT_SYNC; static uint16_t last_high = 0; static uint8_t bit_cnt = 0; static uint32_t frame = 0; volatile uint32_t last_valid_frame = 0; volatile uint8_t frame_ready = 0; void decode_add_width(uint16_t w, uint8_t is_high) { uint8_t bit; if (is_high) { last_high = w; /* 只记住,不作为判定依据 */ return; } /* 处理同步头:窄高 + 宽低 */ if (w > W_SYNC_MIN && w < W_SYNC_MAX) { if (last_high > 200 && last_high < 600) { dec_state = DEC_GET_BITS; bit_cnt = 0; frame = 0; } return; } if (dec_state != DEC_GET_BITS) return; bit = classify_low_width(w); if (bit == 0xFF) { dec_state = DEC_WAIT_SYNC; /* 无效脉宽,重新等待同步 */ return; } frame = (frame << 1) | bit; bit_cnt++; if (bit_cnt >= 24) { last_valid_frame = frame; frame_ready = 1; dec_state = DEC_WAIT_SYNC; } }这段状态机的关键是“只看低电平”。EV1527 数据位的高电平基本不变,真正携带信息的是低电平宽度。同步头识别放在最前面,优先于数据位判断,否则 9ms 的宽度会被 classify_low_width 判成无效。bit_cnt 计数到 24 时,一帧完整数据就算收下了。
有个容易被忽略的细节:同步头识别后并没有把 sync 宽度当作 bit 送入 frame,所以 frame 里只有 24 位数据。如果统计出来发现 bit 顺序和你期望的按键值不一致,多半是遥控器先发低位,需要在取按键之前把位序反转,或直接按位域拆解。
3.4 主循环里的生产者/消费者取值
中断回调里只置frame_ready标志,主循环轮询消费。这样解码和业务解耦:解码部分不受主循环阻塞,主循环也不需要担心被中断打断。
while (1) { if (frame_ready) { frame_ready = 0; key_code = last_valid_frame & 0x0F; /* 低 4 位是按键 */ remote_id = last_valid_frame >> 4; /* 高 20 位是 ID */ } /* 其余业务代码 */ }把 last_valid_frame 的位拆规则写在表里:EV1527 标准格式是高 20 位遥控器 ID,低 4 位按键码。不同品牌遥控器可能反过来,实际项目先打印原始 frame 值,按几下不同按键,对比哪几位在变化,就能确定按键位。这个调试方法比看 datasheet 快得多。
4. 定时器中断解码不稳定?这几个参数和波形先查一遍
4.1 先解决波形:433M模块输出不是理想方波
解码不稳定,十有八九问题出在信号进入 STM32 之前。超再生接收模块的输出在边沿处常有拖尾和振铃,尤其没有加滤波电容时,示波器上看是一串衰减振荡,MCU 的输入引脚可能连续误触发多次。解决方法是先在模块 DATA 引脚和 GND 之间加一个 10nF 到 100nF 的电容,把高频噪声旁路掉;还不行的话,加一级施密特触发器缓冲器整形。
不要指望 ICFilter 数字滤波能解决所有问题。ICFilter 在定时器内部采样,采样窗口变宽后会滤掉窄脉冲,但拖尾振铃可能比有效脉宽还难分辨。波形层面干净了,软件解码才有意义。
4.2 定时器溢出对脉宽计算的影响
定时器 Period 设成 0xFFFF,计数到 65535 后会回绕到 0。正常情况下 433M 的两个边沿间隔不会超过 12ms,不存在溢出风险。但有一种情况例外:遥控器按下的瞬间,接收模块可能先输出一段长电平稳定状态,持续时间超过 65ms,这会导致计数器和 last_tick 之间出现一个完整的回绕周期,diff 计算异常。
应对办法是增加溢出计数,在溢出中断里标记:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) overflow_cnt++; }主循环里对比 overflow_cnt 是否长时间没有变化,判断遥控信号是否已经结束,顺便重置解码状态机。这样可以避免上一次按键的残余状态干扰下一次解码。
4.3 解码不稳定的参数调整对照表
工程上遇到的具体问题基本可以归成这几类:
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 同一个遥控器每次解出的值不同 | 脉宽阈值和实际时序不匹配 | 串口打印脉宽统计,按实际区间重设阈值 |
| 按一下没反应,按几下偶尔成功 | 同步头判断过严 | 放宽 W_SYNC_MIN/MAX,确认同步后的高电平宽度 |
| 松开按键后还会触发几次 | 同一次按键发送了多帧,没有去重 | 做三帧一致校验,连续相同再上报 |
| 靠近无线模块时正常,远了就不行 | 接收模块输出波形变差 | 用施密特整形或降低 ICFilter,同时检收信号强度 |
| 接上串口后解码失败 | 中断被串口抢占 | 定时器中断优先级设最高,串口打印移出中断回调 |
这个表可以当作排查 checklist。最有用的一条是第一条:先把脉宽真实值打出来,而不是猜。
4.4 用串口打印脉宽统计来定阈值
写一个临时的调试版本,在输入捕获回调里把 diff 通过 UART 发出来。注意不要在中断里直接调 printf,正确做法是把数据塞进环形缓冲,主循环再输出:
#define DBG_BUF_SIZE 128 static uint16_t dbg_buf[DBG_BUF_SIZE]; static uint8_t dbg_cnt = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { uint16_t now; uint16_t diff; if (htim->Instance != TIM2) return; now = __HAL_TIM_GET_COUNTER(&htim2); diff = (uint16_t)(now - last_tick); last_tick = now; if (diff > 50 && diff < 20000 && dbg_cnt < DBG_BUF_SIZE) dbg_buf[dbg_cnt++] = diff; /* 原有 decode_add_width 逻辑继续执行 */ }主循环里把 dbg_buf 内容打印成w=xxxx的形式,遥控器连续按 5 次,统计出现次数最多的三个区间,再回填到 classify_low_width 里。这套方法比看任何文档都准,因为每个遥控器、每块板子实测都会有一些差异。
5. DMA捕获环形缓冲与三帧重复校验
5.1 用DMA把时间戳搬进内存再离线解析
定时器中断每边沿一次,对 4kHz 的编码率来说完全够用,但系统里如果还有其他高频中断,中断延迟仍会影响测量精度。另一个思路是用 DMA 配合输入捕获,硬件把 CCR1 的值自动搬到内存,CPU 不用每个边沿都进中断。
static uint16_t dma_times[128]; HAL_TIM_IC_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)dma_times, 128);DMA 半满和全满中断触发时,批量处理时间戳序列,逐个做差得到脉宽。这条路能显著降低中断频率,代价是 DMA 模式只能固定捕获一种边沿极性,无法像中断回调里那样边捕获边翻转。要做上升沿下降沿都捕获,需要两个通道分别配两种极性,再用两个 DMA 流,数据和逻辑都复杂一截。
所以工程上多数项目仍然选择中断方式,DMA 方案更适合“一次完整捕获后离线分析”的场景,比如做射频波形记录仪或者协议分析工具。如果产品对低功耗有硬性要求,可以只在捕获期间用 DMA,分析完成后立刻关外设,比中断方式更好控制唤醒次数。
5.2 三帧一致校验,消除按键抖动和噪声误触发
遥控器按下后,发射芯片会连续发送多帧,帧与帧之间只有很短的空隙。单帧解码通过不意味着按键可靠,无线环境里一帧误码可能正好碰巧组成一个合法帧。最稳妥的方法是连续三帧完全一致才算有效:
static uint32_t frame_hist[3]; static uint8_t frame_idx = 0; uint8_t confirm_frame(uint32_t v) { frame_hist[frame_idx % 3] = v; frame_idx++; if (frame_idx < 3) return 0; if (frame_hist[0] == frame_hist[1] && frame_hist[1] == frame_hist[2]) return 1; return 0; }在主循环里把 last_valid_frame 传给 confirm_frame,返回值非 0 才执行按键逻辑。注意校验计数不能清零,否则每次按键都要重新攒三帧,实际手感会变迟钝。只需要保证三次值相同即可,三帧之间允许存在若干无效帧。如果某些遥控器只发两帧就停,就把判断条件改成frame_hist[0] == frame_hist[1],牺牲一点抗干扰换取响应速度。
本文还有配套的精品资源,点击获取