简介:这是一份基于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 的理由。
| 协议 | 载波频率 | 调制方式 | 典型设备 |
|---|---|---|---|
| NEC | 38kHz | 脉冲位置调制(PPM) | 电视、机顶盒 |
| RC5 | 36kHz | 曼彻斯特编码 | 飞利浦系设备 |
| Sony SIRC | 40kHz | 脉冲宽度调制(PWM) | 索尼电视、音响 |
| RC6 | 36kHz | 相位调制 | 部分飞利浦遥控器 |
发射管建议选 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 定时器时钟是 72MHz,TIM2 用作 1µs 时基时设置预分频值 PSC=71;TIM3 生成载波时设置 PSC=1、ARR=946,输出频率约 38.0kHz。还有一个容易被忽略的点:如果使用 HAL 库且 CubeMX 默认把 APB1 分频为 2,定时器时钟依然是 72MHz,因为定时器时钟会先倍频到 PCLK1 的两倍。
| 外设 | 引脚 | 作用 | 配置要点 |
|---|---|---|---|
| TIM2 | 内部 | 1µs 时基 | PSC=71,ARR=0xFFFF |
| TIM3_CH1 | PA6 | 38kHz 载波输出 | PWM1 模式,占空比 50% |
| EXTI0 | PA0 | 接收头解调输出 | 上升沿和下降沿都触发 |
| 发射驱动 | 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 定时器时钟下 PSC=71,即 72MHz / (71+1) = 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是跳变之后引脚的电平状态。接收头空闲时输出高电平,收到载波时输出低电平,所以lvl=0代表这一段时间内有载波,发射时要让发射管导通。
#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µs,80µ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); /* 默认不发射 */Pulse=473是Period/2的取整值。载波频率允许有 ±2kHz 偏差,接收头带宽能覆盖,但不要随手把 ARR 设成整数 1000 之类的值,那样频率只有 36kHz,部分 40kHz 的 Sony 遥控器会解不出来。发射时要保持占空比稳定,所以只通过TIM_CCxChannelCmd使能或关闭 PWM 输出,不动态改 ARR。
4.2 发射循环:用同一个 TIM2 保证时间基准不回飘
发射时逐条读取ir_buf,如果lvl==0,说明这一段需要有载波,打开 PWM;lvl==1则关闭 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,指令开销基本一致。如果发射后家电完全无反应,先查极性:本设计里接收头低电平代表有载波,所以lvl==0时使能 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 次回环误差的均值为准,而不是拍脑袋定一个固定值。
本文还有配套的精品资源,点击获取