STM32学习型红外遥控器:录波形实现协议无关的万能遥控

STM32学习型红外遥控器:录波形实现协议无关的万能遥控 简介这是一份基于STM32的学习型红外遥控器完整工程源码包适合嵌入式初学者、STM32开发者以及需要实现红外遥控学习与复制的项目人员。资源围绕STM32与C#上位机展开涵盖红外信号采集、协议解码、编码存储与再次发射的完整链路并配有上位机交互思路帮助读者从硬件底层到上位机联调建立整体认识。压缩包共114个文件包含大量H头文件与C源码、少量汇编与备份配置以及Keil工程文件uvproj/uvopt源码以STM32标准外设库定时器、ADC、USART、I2C、FSMC等调用为主整体约282KB目录结构清晰便于阅读与二次开发。目前已有192人学习下载。借助该工程读者可以快速掌握NEC等红外协议的捕获与解析方法、定时器中断与状态机设计以及通过串口与C#上位机通信的典型实现也可基于现有代码直接移植到自己的遥控器项目中。1. 学习型红外遥控器不查表直接“录波形”家里的空调遥控器丢了最省事的替代方案不是买原装而是一个能“录”的遥控器。学习型红外遥控器做的事情很简单把接收头看到的每个高低电平的持续时间按顺序记下来发射时再用 38kHz 载波原样放出去。它不关心协议叫什么NEC、RC5、Sony 还是某个国产风扇自定义的脉冲只要载波落在接收头带宽内都能兼容。对 stm32 来说这套设计只需要一个定时器做微秒时间戳、一个定时器做载波输出再加两个 GPIO成本极低却是典型的 stm32 项目中断、定时器、Flash 写入和回环验证全都能碰到。新手可以照着一块 STM32F103C8T6 开发板复现做了多年嵌入式的人也能在时间戳精度、DMA 重放、Flash 磨损这类细节里找到值得抠的地方。2. 硬件选型与定时器分配接收头、38kHz 载波与 GPIO 规划2.1 接收头与发射管的选型要点红外接收头是一体化解调器件内部集成 PIN 光电二极管和带通滤波器。常见型号包含 TSOP1838、VS1838B中心频率 38kHz供电范围 2.7V 到 5.5V输出端空闲时被上拉到高电平收到载波时输出低电平。另一个容易忽略的参数是带宽TSOP 系列对中心频率有约 3kHz 的接收窗口也就是说 36kHz 的 RC5 信号也能被 38kHz 接收头解调增益会略微下降但解码仍然可靠。做学习型遥控器时统一选 38kHz 接收头因为市面上绝大多数遥控器都在 36~40kHz 范围内这也是发射载波选 38kHz 的理由。协议载波频率调制方式典型设备NEC38kHz脉冲位置调制PPM电视、机顶盒RC536kHz曼彻斯特编码飞利浦系设备Sony SIRC40kHz脉冲宽度调制PWM索尼电视、音响RC636kHz相位调制部分飞利浦遥控器发射管建议选 IR333C 这类 5mm 红外 LED峰值波长 940nm与接收头的滤光片匹配。驱动电路不能由 GPIO 直接灌电流常见做法是 NPN 三极管9013 或 S8050做开关PWM 信号接到基极集电极串联限流电阻连接发射管。假设供电 3.3V、发射管压降 1.2V、三极管饱和压降 0.2V峰值电流按 50mA 计算限流电阻取 (3.3 - 1.2 - 0.2) / 0.05 38Ω实际取 33Ω 或 39Ω 都行。占空比保持 50% 时平均电流只有一半发射管和电阻的功率都在安全范围内。2.2 定时器与 GPIO 的分配方案整个系统只需要两个定时器一个产生微秒时间基准一个产生 38kHz 载波 PWM。以 STM32F103C8T6 为例APB1 定时器时钟是 72MHzTIM2 用作 1µs 时基时设置预分频值 PSC71TIM3 生成载波时设置 PSC1、ARR946输出频率约 38.0kHz。还有一个容易被忽略的点如果使用 HAL 库且 CubeMX 默认把 APB1 分频为 2定时器时钟依然是 72MHz因为定时器时钟会先倍频到 PCLK1 的两倍。外设引脚作用配置要点TIM2内部1µs 时基PSC71ARR0xFFFFTIM3_CH1PA638kHz 载波输出PWM1 模式占空比 50%EXTI0PA0接收头解调输出上升沿和下降沿都触发发射驱动PA6 经三极管驱动红外发射管基极串联 1kΩ 电阻GPIO 初始化代码在 HAL 库里就是常规几步关键是 PA0 要配置为双边沿外部中断且开启内部上拉。接收头输出本身有上拉这里再开上拉是为了避免引线较长时悬空抖动。GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin GPIO_PIN_0; /* PA0 接接收头输出 */ gpio.Mode GPIO_MODE_IT_RISING_FALLING; /* 双边沿触发 */ gpio.Pull GPIO_PULLUP; /* 防悬空抖动 */ HAL_GPIO_Init(GPIOA, gpio); HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn); gpio.Pin GPIO_PIN_6; /* PA6 复用为 TIM3_CH1 */ gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, gpio);这里有一个经常踩坑的地方PA6 是 TIM3_CH1 的默认复用引脚但如果是 PB0 或 PB1 的重映射版本需要先调用__HAL_AFIO_REMAP_TIM3_ENABLE()并配置 AFIO 时钟。使用标准库新建工程时这个步骤容易漏HAL 库则要求在HAL_GPIO_Init之前把引脚复用功能绑定到定时器上。建议在 CubeMX 里直接勾选 TIM3 的 Channel1 输出让工具自动生成复用配置人工改代码时再核对一遍。2.3 电源与布局上的三个注意点红外发射瞬间电流可能冲到 80mA 以上如果开发板电源走线细接收头的供电会被拉低直接表现为解码错误率随发射距离增大而上升。3.3V 电源两端至少并一个 100µF 电解电容和一个 100nF 陶瓷电容并且两者都尽量靠近发射驱动电路。接收头到 PA0 的连线越短越好超过 10cm 时建议在接收头 VCC 和 GND 之间额外并一个 10µF 电容。学习型遥控器对布局的要求不高但电源纹波会直接影响解码门限这一步值得做扎实。3. 红外解码用 stm32 定时器记录每个电平的微秒宽度3.1 为什么学习型解码要“电平时间”而不是“协议码”普通红外遥控器驱动里查协议码表就能发信号但学习型设备面对的是未知协议没法预先知道位定义。不管 NEC 还是 RC5底层都是载波调制过的脉冲序列载波存在时接收头输出低电平载波结束时输出高电平。解码要做的只是记录每一次跳变的时刻相邻两次跳变的差值就是某一电平的持续时间一帧信号最后变成一张“电平持续时间表”。这张表不再需要理解任何协议发射时原样重放即可。解码方案时间精度CPU 占用适合场景外部中断 定时器时间戳1µs每次跳变约 2µs学习型遥控器最通用PWM 输入捕获与定时器时钟一致极低测载波频率或规则方波查询轮询 GPIO不确定高不推荐做红外解码有一种说法是直接用 stm32 定时器捕获测频率就行那其实是用来校准载波频率的不是用来解帧的。PWM 输入捕获一次只能拿到一个周期的脉宽和周期值红外帧包含几十个宽度不等的脉冲频繁切换捕获通道会让逻辑变得很别扭。外部中断加时间戳的方法实现最简单协议无关性最好代价是每个边沿都要进一次中断在 72MHz 的主频下完全可接受。3.2 用 TIM2 搭建 1µs 时间基准先把 TIM2 配成自由运行计数器计数值每 1µs 加一。72MHz 定时器时钟下 PSC71即 72MHz / (711) 1MHz。ARR 设为 0xFFFF使计数器在 65535µs 后回绕NEC 引导码约 9ms最长的电平一般不超过 20ms所以 16 位计数器够用。htim2.Instance TIM2; htim2.Init.Prescaler 71; /* 72MHz / 72 1MHz */ htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; /* 65535us 溢出 */ htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim2); HAL_TIM_Base_Start(htim2);配置完成后__HAL_TIM_GET_COUNTER(htim2)读到的值就是当前时刻的微秒数。PSC 的值决定时间刻度改成 9 就是 0.1µs 精度但计数器溢出周期缩短到 6553.5µs需要额外处理溢出中断改成 719 就是 10µs 精度解码短脉冲会丢细节。对红外信号来说 1µs 是精度和溢出周期的平衡点不建议为了追求更高精度而牺牲简单性。3.3 外部中断回调保存边沿间隔和跳变后的电平解码缓冲区用结构体数组保存电平时间us是上一个跳变到当前跳变经过的微秒数lvl是跳变之后引脚的电平状态。接收头空闲时输出高电平收到载波时输出低电平所以lvl0代表这一段时间内有载波发射时要让发射管导通。#define BUF_MAX 256 #define IDLE_TIMEOUT_US 20000 /* 20ms 无跳变认为一帧结束 */ typedef struct { uint16_t us; /* 上一跳变到本次跳变的宽度 */ uint16_t lvl; /* 本段电平状态0 表示载波脉冲 */ } ir_entry_t; ir_entry_t ir_buf[BUF_MAX]; volatile uint16_t frame_len 0; static uint16_t last_edge_us 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin ! GPIO_PIN_0) return; uint16_t now (uint16_t)__HAL_TIM_GET_COUNTER(htim2); uint16_t duration (uint16_t)(now - last_edge_us); last_edge_us now; if (duration IDLE_TIMEOUT_US) frame_len 0; /* 静默超时开始新一帧 */ if (frame_len BUF_MAX) { ir_buf[frame_len].us duration; ir_buf[frame_len].lvl (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) ? 0 : 1; frame_len; } }这段代码里最关键的是now - last_edge_us的写法。两个uint16_t相减即使定时器回绕结果依然正确因为模运算保证差值在 ±65535 之间都能还原。唯一的前提是单个电平持续不超过 65535µs红外协议里不会出现这种情况。帧开始的判定用了静默超时法任何一次电平持续时间达到 20ms就认为一个学习帧结束下一段数据是新的一帧。20ms 这个值大于所有常见遥控器的最长间隔又远小于重复码之间 110ms 的静默录空调长帧时也不会提前断帧。3.4 抖动过滤和丢帧策略红外遥控器在按键按下的瞬间会有机械抖动接收头输出可能产生几个微秒的毛刺。处理办法是在回调里加一个最小脉宽过滤duration 80时直接忽略本条并覆盖上一条。因为常见的红外最短脉冲约为 562µs80µs 的阈值不会伤害正常数据却能滤掉绝大多数触点抖动。if (duration 80) { if (frame_len 0) frame_len--; return; }这里的原理是一个 30µs 的毛刺会把原来的电平切成两段丢弃毛刺的同时要把上一条记录也删掉否则帧内会多出一个假边沿。更稳妥的方案是用 TIM 的输入捕获滤波但外部中断没有硬件滤波软件过滤反而是最可控的。另外不要在 EXTI 回调里打印日志、操作 LCD 或者做 Flash 操作这些都会让时间戳失真解码得到的电平宽度整体偏大。printf 只用于调试且只打关键帧量产时不打。4. 红外发射用 38kHz PWM 按时序表重放学习帧4.1 载波 PWM 的生成与默认关闭发射管不能直接按电平表拉高拉低红外接收端只认载波调制信号所以必须先产生 38kHz 的 PWM再用电平表中的时间控制 PWM 的使能和关闭。TIM3 配置成 PWM1 模式预分频值 1重载值 946实际载波频率约 38.0kHz。占空比选 50%兼顾发射功率和管子的平均电流。htim3.Instance TIM3; htim3.Init.Prescaler 1; /* 计数时钟 36MHz */ htim3.Init.Period 946; /* 36MHz / 947 ≈ 38.0kHz */ htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(htim3); TIM_OC_InitTypeDef oc {0}; oc.OCMode TIM_OCMODE_PWM1; oc.Pulse 473; /* 50% 占空比 */ oc.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(htim3, oc, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); TIM_CCxChannelCmd(TIM3, TIM_CHANNEL_1, TIM_CCx_DISABLE); /* 默认不发射 */Pulse473是Period/2的取整值。载波频率允许有 ±2kHz 偏差接收头带宽能覆盖但不要随手把 ARR 设成整数 1000 之类的值那样频率只有 36kHz部分 40kHz 的 Sony 遥控器会解不出来。发射时要保持占空比稳定所以只通过TIM_CCxChannelCmd使能或关闭 PWM 输出不动态改 ARR。4.2 发射循环用同一个 TIM2 保证时间基准不回飘发射时逐条读取ir_buf如果lvl0说明这一段需要有载波打开 PWMlvl1则关闭 PWM。每段持续us微秒后切到下一条。延时函数也基于 TIM2 实现解码和发射共用同一时间基准避免用HAL_Delay的毫秒级误差。void delay_us(uint32_t us) { uint16_t start (uint16_t)__HAL_TIM_GET_COUNTER(htim2); while ((uint16_t)((uint16_t)__HAL_TIM_GET_COUNTER(htim2) - start) us); } void ir_send_frame(const ir_entry_t *p, uint16_t n) { for (uint16_t i 0; i n; i) { if (p[i].lvl 0) TIM_CCxChannelCmd(TIM3, TIM_CHANNEL_1, TIM_CCx_ENABLE); else TIM_CCxChannelCmd(TIM3, TIM_CHANNEL_1, TIM_CCx_DISABLE); delay_us(p[i].us); } TIM_CCxChannelCmd(TIM3, TIM_CHANNEL_1, TIM_CCx_DISABLE); }这段循环在执行时每条命令本身要消耗 1~2µs所以实际电平宽度会比表里的值大一点。常见做法是在延时里减掉固定补偿量CALIB_DELTA_US初设 3µs之后用回环验证校准。这个补偿量对整帧所有电平一起生效因为不管是开 PWM 还是关 PWM指令开销基本一致。如果发射后家电完全无反应先查极性本设计里接收头低电平代表有载波所以lvl0时使能 PWM反过来如果换了一种高电平有效的接收头型号判断条件要取反这是学习型遥控器最容易翻车的地方。4.3 长帧和低占用场景下的 DMA 方案上面的循环在发射期间一直占着 CPU普通遥控器一帧只有几十毫秒问题不大。但如果要支持空调的长帧或者设备同时要响应串口和按键可以用定时器输出比较加 DMA 的方式做无 CPU 干预发射。常见方案是把电平表转换成“定时器计数值数组”每一条赋值给 TIM2 的比较寄存器比较事件触发 DMA 去切换 PWM 通道的使能状态。底层原理不复杂但 DMA 描述符要管理好数组长度变化时容易踩内存越界。发射方式CPU 占用时间抖动代码复杂度循环 delay_us高约 2~3µs低TIM 比较中断中约 1µs中TIM DMA几乎为零低于 0.5µs高用循环方式实现学习型遥控器已经足够稳定因为红外接收端对脉宽有较大的容忍范围NEC 协议允许 ±10% 的偏差2µs 抖动对 562µs 的位脉冲没有影响。DMA 方案在需要连续发射多帧、或者发射期间想保持串口响应流畅时才值得引入。不管用哪种方式载波 PWM 始终由 TIM3 硬件产生切换的只是输出使能这一点不要改成 GPIO 翻转来模拟载波那样频率和占空比都会飘。5. 帧存储与回环验证把学习帧写进片上 Flash 并按误差校准5.1 用片上 Flash 保存学习帧STM32F103C8T6 的 Flash 共 64KB最后 1KB 页地址是 0x0800FC00。学习帧由 256 条ir_entry_t组成每条 4 字节最多 1KB正好占用一页。写入放在帧学习完成后的主循环里做绝不能在 EXTI 回调里写 Flash因为写期间 Flash 忙等待会让解码中断延迟上百微秒直接破坏后续帧的时间戳。#define IR_STORE_ADDR 0x0800FC00 void ir_flash_save(uint16_t cnt) { FLASH_EraseInitTypeDef f {0}; uint32_t err 0; HAL_FLASH_Unlock(); f.TypeErase FLASH_TYPEERASE_PAGES; f.PageAddress IR_STORE_ADDR; f.NbPages 1; HAL_FLASHEx_Erase(f, err); uint32_t dst IR_STORE_ADDR; HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, dst, 0xA55A); dst 2; HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, dst, cnt); dst 2; for (uint16_t i 0; i cnt; i) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, dst, ir_buf[i].us); dst 2; HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, dst, ir_buf[i].lvl); dst 2; } HAL_FLASH_Lock(); }写入格式是帧头标记 0xA55A、边沿数、边沿数据依次存储。读取时先检查帧头再根据边沿数加载数据最后算一遍 CRC防止 Flash 里残留的随机数据被当成有效遥控码。如果用的是 32KB Flash 的 STM32F103C6T6最后 1KB 页地址是 0x08007C00要根据芯片型号改宏定义。5.2 回环验证自己发自己收用误差量化校准把学习好的帧发射出去在同一块板子上用接收头重新解码得到一组新帧对比新旧帧的边缘时间表。对比逻辑允许每条电平宽度有 ±50µs 的误差边沿数量允许相差 2 条因为首尾段可能因为静默判定被截掉。int compare_frames(ir_entry_t *a, ir_entry_t *b, uint16_t na, uint16_t nb) { if (abs((int)na - (int)nb) 2) return -1; uint16_t n (na nb) ? na : nb; for (uint16_t i 0; i n; i) { if (a[i].lvl ! b[i].lvl) return -2; int32_t d (int32_t)a[i].us - b[i].us; if (d 50 || d -50) return -3; } return 0; }比对失败时优先看是不是固定偏差。把所有电平误差取平均如果平均误差在 3µs 左右说明发射循环的指令开销没补够调大CALIB_DELTA_US如果误差随电平长度增加而变大说明时间基准有问题检查 TIM2 是否被其他外设重配置过。对比通过后再拿真实的空调或电视遥控器做最终验证。5.3 一个提升成功率的小技巧裁剪首尾空闲段学习帧的第一条记录往往是进入静默判定前那段 20ms 的空闲高电平最后一条也可能是 20ms 的空闲段。发射端对前导静默和尾部静默并不敏感但它们会把整帧时间拉长 40ms连续发射时会产生明显的延迟感。发射前做一次扫描跳过开头lvl ! 0的记录也跳过结尾lvl ! 0的记录只保留中间有载波的部分。这个裁剪不会影响帧内容因为首尾空闲段本来就不携带信息。配合串口打印帧头、帧长和每条电平宽度能在一分钟内判断录制是否正常再用逻辑分析仪勾住 PA6 看波形里的载波是否连续能确认 PWM 有没有被中断响应打断。校准系数的最终取值以 10 次回环误差的均值为准而不是拍脑袋定一个固定值。本文还有配套的精品资源点击获取