简介:基于STM32的万能红外遥控器设计文档,面向嵌入式爱好者、电子竞赛队伍及智能家居开发者。文档完整记录一款集红外控制、红外学习、语音控制、Wi-Fi远程控制于一体的遥控器项目,从需求分析、硬件选型到STM32程序逻辑、Qt上位机开发均有覆盖,并给出ESP8266模块调试、语音识别固件烧录等关键环节的实操细节。功能上涵盖100组红外指令学习与发送、4x4电容矩阵键盘操作、1.44寸LCD状态显示、蜂鸣器反馈,以及Android/Windows上位机APP远程控制,语音指令支持小爱同学、小度、天猫精灵等常见助手。技术栈涉及STM32、ESP8266、Qt、海凌科V20语音识别模块等;包体为单个PDF文件,大小46.43MB,内容按项目实现顺序编排,含硬件原理图、实物图、核心代码注释、调试流程与注意事项。已有79人学习,适合作为毕业设计、课程设计或智能家居产品预研的参考资料。
1. 方案是怎么定下来的:STM32做万能遥控器的核心优势
茶几上堆着四五个遥控器,电视一个、机顶盒一个、空调一个、风扇一个,客厅里找不到哪个对应哪个,这大概是每个家庭都经历过的事。“万能红外遥控器”这个思路其实很早就有了,市面上几十块钱的红外学习遥控器也不少,但做嵌入式开发的人都会有一个念头:与其买一个功能固定的,不如自己用单片机做一个。既能学习原装遥控器的码,又能自定义按键布局,还能加屏幕显示当前状态,比买来的产品自由太多。
选STM32而不是其他平台,主要是三个方面考虑。第一是片内外设足够丰富,定时器的输入捕获和PWM输出恰好可以覆盖红外的接收解码和发射调制两条链路,不需要额外芯片。第二是Flash空间够大,STM32F103C8T6有64KB Flash,除了固件之外还能划出一块区域存码库,不像51单片机那么紧张。第三是生态成熟,标准外设库和HAL库的资料都非常多,尤其是后面要说的NEC红外协议,几乎每个做红外遥控的人都是从NEC开始入门的。
整套系统的架构并不复杂:红外接收头负责捕获原装遥控器的信号,STM32通过定时器输入捕获把脉冲宽度解析成数据帧,然后按协议存入内部Flash;需要使用时,再通过定时器PWM产生38kHz载波,由IO口控制红外发射管把编码数据发出去。
用户交互端加一个OLED屏幕和几个按键,就能做到不依赖上位机独立操作。整机成本算下来,核心板的十几块钱加上接收头、发射管、屏幕、按键,总共不到三十块,比起直接买一个学习型遥控器的价格也没差太多,但多出来的可玩性是成品设备给不了的。
我用的时候遇到过一个问题,很多便宜的“万能遥控器”其实只是内置了码库,对老型号设备经常匹配不上,而自学习方案完全没有这个问题——只要原装遥控器还能按出信号,就能学进来。这也是我坚持做学习型而不是纯码库型的原因。
2. 红外解码原理:先看懂NEC协议,再谈采集与存储
2.1 NEC协议的时序拆解
红外遥控的起点是红外编码协议。市面上主流家电用的协议有好几种:NEC、RC5、RC6、Sony SIRC、飞利浦的RCS,等等。这些协议各不相同,但基本逻辑是一致的:用一定频率的载波(通常是38kHz)承载数据脉冲,接收端通过高低电平的持续时间来区分逻辑0和逻辑1。
NEC协议是其中最典型也最容易理解的,大部分国产家电遥控器都用它,所以它天然适合作为自学习红外遥控的第一步。NEC一帧数据的结构是:
- 引导码:9ms高电平 + 4.5ms低电平
- 16位地址码(通常8位地址 + 8位反码)
- 8位数据码 + 8位反码
- 算上结束位(约560us的高电平),一帧总时长约70ms
逻辑0的表示是560us载波 + 560us空闲,逻辑1是560us载波 + 1690us空闲。注意NEC还有扩展协议,地址码可能达到16位,但解码原理不变。
接收端的红外接收头(如HS0038B或VS1838B)负责了一部分解调工作:其内部集成放大、限幅、带通滤波和检波电路,输出端直接把38kHz载波解调为数字电平。也就是说接收头输出的就是上图这些脉冲的包络线,高电平对应当前载波存在(实际输出是反相的),低电平表示空闲。
对于MCU来说,解码的核心任务就变成:记录每一次电平跳变的时间戳,计算脉冲宽度,再按照协议规则去解释这些宽度。
2.2 用定时器输入捕获记录脉冲宽度
STM32定时器的输入捕获功能在这里派上了大用场。思路很简单:把红外接收头的输出脚接到定时器的捕获通道上,配置上升沿和下降沿都触发捕获,在每个跳变沿读取当前计数器的值,前后相减就得到了脉冲宽度。
以STM32F103C8T6为例,TIM2挂载在APB1总线上,主频72MHz,配置输入捕获为双沿触发,计数器工作在最大值为0xFFFF的16位模式下。72MHz下计数周期约13.9ns,NEC协议最短的560us对应约40320个计数,远小于65535的上限,不会溢出。即使遇到极端的长时间无信号,也不用担心,只会进入空闲状态。
解码部分可以用一个简单的状态机写逻辑。核心代码如下:
typedef struct { uint16_t address; uint8_t command; uint8_t command_inv; uint8_t is_repeat; uint8_t frame_ok; } NEC_Frame_t; void IR_Decode(uint32_t pulse_us, NEC_Frame_t *frame) { static uint8_t bit_count = 0; static uint32_t code = 0; static uint8_t state = 0; static uint16_t addr = 0; // 判断引导码:9ms 高电平 + 4.5ms 低电平 if (state == 0) { if (pulse_us > 8500 && pulse_us < 9500) { state = 1; } return; } if (state == 1) { if (pulse_us > 4300 && pulse_us < 4700) { state = 2; // 正常引导码之后的数据位 bit_count = 0; code = 0; } else if (pulse_us > 1900 && pulse_us < 2300) { // 重复帧引导码:9ms + 2.25ms,对应按键长按 frame->is_repeat = 1; frame->frame_ok = 1; state = 0; } else { state = 0; } return; } // 数据位:先判断低电平维持区间即数据位的空闲段 if (pulse_us < 100 || pulse_us > 2000) { state = 0; return; } if (bit_count < 32) { code <<= 1; if (pulse_us > 1450) code |= 1; // 1.69ms 空闲 => 逻辑1 bit_count++; } if (bit_count == 32) { frame->address = (code >> 16) & 0xFFFF; frame->command = (code >> 8) & 0xFF; frame->command_inv = code & 0xFF; frame->frame_ok = 1; frame->is_repeat = 0; bit_count = 0; state = 0; } }代码里用了脉冲宽度阈值的方式判断逻辑1,实际使用中,560us和1690us之间的裕量很大,只要接收头质量正常、距离不要太远,判断基本不会出错。需要特别说明的是,接收头输出的电平是反相的——有载波时输出低电平,无载波时输出高电平。捕获时间戳时不需要关心电平本身,只要在翻转沿记录时间差即可,但如果你要自己实现判断高电平还是低电平,这一点千万别搞反,网上好多初学者在这里栽了跟头。
2.3 自学习模式下接收质量的坑
自学习模式对接收质量的要求比对码库模式更高,因为码库是“识别”,自学习是“记录”。识别只要读出数据就行,记录则要把数据能完整还原成发射时序。
实测下来,接收距离、环境光干扰、接收头的摆放角度都会影响学习成功率。最好的做法是学习时把遥控器对准接收头,距离控制在5~15cm,环境光不要太强,尤其要避开阳光直射和节能灯(节能灯的红外干扰比较明显)。
如果按了学习键但捕获到的脉冲宽度毫无规律,先别急着怀疑代码,用示波器或逻辑分析仪看接收头输出。没有仪器的话,可以用串口把每次捕获的脉宽时间戳打印出来,肉眼观察有没有9ms的引导码,这比瞎猜快得多。
3. 码库存储与数据结构:不是所有数据都值得存成原始时间戳
3.1 两种存储策略的取舍
自学习型遥控器需要存数据。怎么存?演化的路上有两条分支:
- 方案A:直接把捕获到的高低电平时间戳按顺序存成数组。优点是适配所有红外协议,不用解析,缺点是极占空间。一个NEC帧约20多个脉冲,每个脉冲存uint16_t的时间戳,需要40多字节,这还好;但像空调遥控器这种复杂的协议,一帧可能有上百个脉冲,存储开销就比较难看。
- 方案B:先解析成协议数据(地址码、数据码),发射时再按协议规则重建波形。优点是省空间、便于管理和匹配,缺点是只能支持已解析的固定协议,遇到未知协议就无能为力了。
对于NEC协议,方案B非常划算,一个完整按键的命令只占2字节地址 + 2字节数据的空间。但作为万能学习型遥控器,你永远不知道用户会拿什么遥控器来学,所以更好的架构是把两种方案做混合:已知协议走解析通道,未知协议按原始脉冲时间戳保存。
我在这个项目里把两种都做了。对于NEC协议,存储结构可以压到最小,一个按键的码库记录是这样:
typedef struct { uint8_t protocol; // 协议类型:0=NEC,1=原始脉冲 uint8_t is_ext; // 是否扩展地址 uint16_t address; uint8_t cmd; uint8_t cmd_inv; uint16_t raw_len; // 原始脉冲模式下有效 uint16_t raw_data[200];// 原始脉冲模式下的时间戳数组 } CodeEntry_t;NEC协议下raw_data不启用,一条记录只占约7字节。对于原始脉冲模式,raw_data保存每个脉冲的时间长度,发射时按1:1还原。空调遥控器一帧的脉冲数比较多,加上引导码可能上百个,200个uint16_t的数组能覆盖绝大多数场景。
3.2 Flash分区与磨损均衡
STM32F103C8T6的Flash是64KB,按1KB一页划分。项目固件编译后一般不到30KB,所以还有相当一部分空间可以规划成码库区。我习惯把码库放在最后一个扇区附近,比如地址0x0800F000开始的8页(8KB),能存几百条NEC码,对于家用场景完全够用。
但这里有个必须提醒的坑:STM32 Flash擦写有寿命限制,F103的Flash擦写次数约为1万次。如果每学习一个按键就写一次Flash,用久了会出问题。做自学习遥控器时,学习操作不可能像读取操作那么频繁,但也要做好保护。我加了一个简单的磨损均衡机制:码库区不固定写同一页,而是按顺序往后面页写,写完一轮再从头覆盖。这样能延长Flash寿命。更简单粗暴的做法是学习前把新数据写入RAM,等用户确认保存后再统一写入Flash,避免学习过程中反复擦写。
还要注意Flash按页擦除的特性。如果想更新码库里的某一条记录,不能只给它擦掉重写,通常做法是把整页读出来,在RAM里修改目标条目,然后整页擦除再写回。STM32F103的单页是1KB,读出来改掉再写回的时间大约几十毫秒,用户完全无感。代码里用HAL库的HAL_FLASH_Erase和HAL_FLASH_Program就能完成,注意操作前必须解锁Flash:
HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase_cfg = { .TypeErase = FLASH_TYPEERASE_PAGES, .PageAddress = CODE_BANK_ADDR, .NbPages = 1 }; uint32_t page_err = 0; HAL_FLASHEx_Erase(&erase_cfg, &page_err); // 逐字节写入... HAL_FLASH_Lock();3.3 码库的索引管理
数据存进去了,怎么快速找到?我用了一个非常简单的两层索引:第一层是码库条目的线性表,每条记录包含协议类型、地址、命令和存储位置等元数据,存于Flash末尾,使用时一次性读入RAM;第二层是用户自定义的按键映射表,指定“按键1对应哪条码库记录”。
有了这两层,按遥控器上的数字1时,先查按键映射表得到码库索引,再根据索引找到具体码库记录,解析发射。整个查找过程在几十条记录里做线性扫描,耗时几乎可以忽略。
4. 发射电路与38kHz调制:为什么同一个定时器能干两件事
解码用定时器输入捕获,发射又要用定时器PWM来产生38kHz载波,这时候有一个问题:同一个定时器还能同时用来捕获吗?答案是看资源分配。F103C8T6有TIM1到TIM4共四个定时器,外加一个基本定时器TIM6。通常的解码用TIM2的CH1,发射用TIM3的CH1,两者互不干扰。
发射电路的原理很简单,STM32的IO口输出高低电平控制红外发射管发光,但IO口直接驱动能力有限,而且红外发射管的压降和电流需求通常需要外接三极管来放大。典型电路是:PA6输出PWM波,经过一个NPN三极管(比如S8050)或MOS管当作开关管,驱动红外发射管。LED串一个几十欧姆的限流电阻接到VCC。
发射时序的控制方式有两种。一种是用定时器PWM输出38kHz载波,需要发射载波时开PWM,不需要就关掉,码元时间靠延时函数或阻塞计数控制。另一种是用GPIO翻转产生载波,对时序精度要求高而且浪费CPU,不推荐。
先说载波频率的计算。STM32的定时器时钟来自APB1倍频后的72MHz,设置TIM3的PSC分频系数和ARR自动重装值就能得到指定频率的PWM。38kHz对应的ARR计算如下:
- 定时器时钟 = 72MHz
- 需要PWM频率 = 38kHz
- ARR + 1 = 72MHz / (PSC + 1) / 38kHz
取PSC = 0,则ARR ≈ 1894。为了保证更精细的频率调节和占空比控制,实际工程里我习惯先分频:PSC = 1,ARR = 72MHz / 2 / 38kHz ≈ 947。占空比设置在1/4到1/3之间比较合适,红外发射管峰值电流大、平均电流低,载波导通时间太短发射距离会缩小,太长会发热甚至烧管。
发射NEC一帧数据时,只要按协议要求的时间控制PWM的开关即可。伪代码:
void NEC_SendByte(uint8_t data) { for (int i = 0; i < 8; i++) { IR_PWM_ON(); DelayUs(560); IR_PWM_OFF(); if (data & 0x01) { DelayUs(1690); } else { DelayUs(560); } data >>= 1; } }注意一个细节:NEC协议在数据位之间的空闲期是不发载波的,所以发射完一个位后要完全关闭PWM。如果你用的是片上定时器加DMA或中断方式产生精确时序,那更优雅;对于学习型遥控器,除了空调这类长帧协议外,NEC的发送用阻塞延时就能有很好的效果,没必要上DMA。STM32主频72MHz,每条延时指令误差在微秒级以内,一个70ms的帧累计误差小于1%,家电设备完全识别得了。
发射管的选择也有讲究。市场上常见的红外发射管有两类:一类是850nm波长的小功率管,适合短距离3米内;另一类是940nm的中功率管,加上透镜能打8米以上。我实际测试过,用普通Φ5发射管+100欧姆限流电阻+3.3V供电,直射距离能达到4~5米,转弯或隔着玻璃会衰减,要在客厅场景全覆盖的话,建议用两个发射管并联,或者选峰值电流更大的管子。
再提一个容易踩坑的地方:38kHz是NEC的标准载波,但有些空调遥控器用38.5kHz或37.9kHz,区别很小,接收头带宽通常能容忍;而Sony的SIRC协议用40kHz,如果你要兼容,可以把定时器ARR改成固定值来调整载波频率。万能红外遥控器的“万能”很大程度上体现在这里——不是把一种协议做得多完美,而是能覆盖不同设备的载波差异。
5. 按键映射与用户界面:从“能发射”到“好用的遥控器”之间差了什么
自学习搞定、发射搞定,这个项目其实已经能用了。但从能用到好用,还得解决一个实际问题:用户怎么知道按哪个键对应哪个设备?总不能每次发射前都拿串口看调试信息。
我的做法是加了一个0.96英寸OLED屏幕,用I2C接口连接,通过四个按键操作界面。开机后屏幕显示当前设备通道(比如“TV”或“AC”),上下键切换通道,左右键选择要执行的按键功能。每个通道对应一套码库映射表,按下“学习”功能键后,屏幕提示“Learning...”,此时把原装遥控器对准接收头按对应按键,学习完成后屏幕显示“OK”,这套交互流程和商品遥控器非常接近。
代码结构上,我用了简单的菜单状态机,不引入RTOS,裸机轮询按键加屏幕刷新。OLED显示用软件I2C,成本低接线也方便,标准库的I2C外设在这类低速率场景下不如软件模拟顺手。
按键处理必须做消抖。红外接收头对杂散信号很敏感,如果按键消抖做不好,按一下可能触发两次学习,存储的数据就乱了。我用最简单的20ms延时消抖,10ms读一次电平,连续两次一致才认为按键生效,实测误触率降到可以忽略。
还有一个从实际使用中总结出来的体验优化:空调遥控器因为有“开机/关机”状态差异、风速档位切换、制热制冷切换等,它的每个按键对应的红外命令不是固定不变的,而是“以当前状态为输入、生成不同的数据帧”。如果用原始脉冲记录方式,要原样存储空调遥控器的所有按键状态组合,非常占空间;用协议解析方式,则至少要知道该空调协议的类型。对于这部分,我提供了一种笨办法,就是“多帧连续学习”——长按某个自定义键时连续学习多帧,发射时按顺序播放,模拟空调遥控器按键时连续发多帧的行为。实测控制格力和美的的老款空调基本靠谱,新款变频空调协议复杂,只能靠大码库覆盖。
6. 实测中的坑与解决:载波、距离、数据错乱的真实教训
6.1 载波频率不对导致的学习成功但发射失败
第一次做完发射功能时,我遇到一个非常迷惑的现象:用原装遥控器对着接收头学习,学习成功;但把学习到的数据发射出去,电视毫无反应。用手机摄像头看发射管,能看到红外光在闪,说明确实在发,但设备不认。
排查到最后发现是载波频率偏差太大。我配置的PWM频率计算是38kHz,但忽略了一点:STM32的APB1定时器时钟不是直接等于72MHz的。F103的APB1最大频率只有36MHz,定时器如果要达到72MHz,必须使能定时器时钟的2倍频。在标准库的SystemInit配置里,默认情况下RCC_PCLK1_Div2会把APB1设为36MHz,但定时器外设的时钟源在APB1分频系数大于1时会自动翻倍。用CubeMX配置的工程一般会自动处理好,但手动配置标准库工程时漏了这个细节,PWM频率就会变成19kHz而不是38kHz,差了一倍,接收头根本没反应。
解决方法就是在初始化代码里检查RCC时钟树,确认TIM3的时钟确实为72MHz。用CubeMX重新生成一次时钟配置,或者直接拿HAL_RCC_GetPCLK1Freq()打印出来验证。
6.2 发射距离受占空比和电流的双重影响
红外发射管驱动的限流电阻值直接决定了发射功率。100欧姆电阻在3.3V下,峰值电流约30mA(忽略三极管压降),这个电流对于普通Φ5发射管来说距离有限。实测在原装遥控器电池满电的情况下,原装发射管峰值电流能到100mA量级,单管发射距离能到10米以上,我的电路只有其1/3。
后来我把驱动改成了两个发射管串联再配一个三极管,限流电阻换成47欧姆,峰值电流到50mA左右,发射距离提升到约7米。但这还没完——载波占空比也会影响发射管平均功耗,如果占空比设到50%,连续发射一帧70ms,发射管的平均电流会更高,时间久了有烧管风险。实测用1/3占空比既能保证距离又比较安全。
6.3 学习时收到的数据为什么有时候会出现随机跳变
一种典型的“幽灵bug”是:同一把原装遥控器,同一个按键,学习两次得到的数据不一样。排查后发现是接收头在逻辑1和逻辑0的边界处产生了一个额外脉冲,也就是硬件消抖电路在某些信号摆率下产生了振铃。接收头输出的脉冲本身不够干净,再加上MCU捕获的边沿有些是“假边沿”,导致脉冲宽度识别出现偏差。
解决办法是在解码状态机里加一个最小脉冲宽度过滤:小于100us的脉冲直接丢弃。同时,自学习之前连续读取三帧,三帧数据完全一致才认为学习成功。如果三帧有差异,提示用户重新操作。这个策略非常有效,把学习成功率从90%左右拉到了接近100%。
7. 已知协议之外的扩展方向:把这套方案做成通用红外平台
NEC协议只是红外遥控协议的一份子。如果你想兼容更多设备,需要了解其他协议的差异。RC5协议是曼彻斯特编码(中间跳变表示逻辑),SIRC协议用脉冲间隙的个数表示数值,空调类设备多是自定义协议。不同协议之间的共性是“载波频率 + 脉冲时序”,只要你的采集和发射链路能适配足够宽的频率范围,方案就能继续扩展。
我在设计时保留了两个通用接口:
typedef struct { uint32_t carrier_hz; void (*send_bit)(uint8_t bit); void (*send_frame)(const uint8_t *data, uint8_t len); int (*decode_frame)(const uint16_t *pulses, uint16_t len, uint8_t *out, uint8_t *out_len); } IR_Protocol_t;每增加一种协议,就实现一套send/decode回调注册进来。UI上的设备类型选择,本质就是在切换这份协议表。这样一来,万能红外遥控器本身就成了一个红外协议研究平台,后续加RF射频遥控(315MHz/433MHz)模块也只是复用同一套按键映射和界面逻辑,只不过把红外收发头换成射频模组。
我自己后续又在这套硬件上跑过LVGL的简单移植,用带SPI接口的大尺寸屏替换了OLED,操作界面从三行文字变成了图形化图标,整体体验提升明显。前提是MCU从F103C8T6换成了F429或F407这类Flash和RAM更大的型号,说明了这套方案的可扩展空间确实不小。对于刚入门的同学,我建议先把NEC协议吃透,把学习、存储、发射整条链路跑通,再考虑扩展其他协议——因为底层逻辑通了,往上加东西只是时间问题。
本文还有配套的精品资源,点击获取