简介:这份资源面向嵌入式开发初学者与智能家居DIY爱好者,提供基于STM32微控制器驱动红外模块控制格力空调的完整工程实践。内容围绕STM32的GPIO推挽输出、定时器PWM波形生成、NEC红外编码与解码、格力空调协议适配等核心知识点展开,帮助读者理解从红外信号发射到空调响应的完整链路,适合具备一定C语言与单片机基础、希望切入物联网控制场景的开发者。压缩包共226个文件,约8.35MB,以32个c源文件、34个h头文件、42个crf中间文件及44个o目标文件为主,另含uvprojx工程文件、sct分散加载文件、hex烧录文件与map映射文件,覆盖从源码到编译产物的完整工程结构。目前已有889人学习下载。读者可借此获得可直接编译的Keil工程、红外编码实现逻辑与调试排错思路,对照示波器验证信号时序,快速复现空调控制功能并迁移至其他品牌协议。
1. 用 STM32 复刻一台空调遥控器:从红外码值到整机联调
夏天最难受的不是热,是空调遥控器丢了。翻遍沙发缝、抽屉底,最后发现被压在某个快递盒下面——这种场景估计不少人都经历过。与其每次找遥控器,不如自己用 STM32 加一个红外发射模块,做一个能控制格力空调的“自制遥控器”。这个项目干的事情很直接:STM32 负责产生 38kHz 载波调制的红外信号,红外发射管把编码发出去,空调接收到就执行开关、调温、切换模式这些操作。适合已经玩过 STM32 GPIO 和定时器、想找一个“有实际反馈”的练手项目的开发者。它不涉及复杂的通信协议栈,但能让你把定时器 PWM、红外编码协议、状态机这几块知识串起来,做完之后对嵌入式时序控制会有完全不一样的理解。
2. 红外控制原理与硬件选型:为什么不是随便接个 LED 就行
2.1 格力空调红外编码的底层逻辑
格力空调用的红外协议,本质上是把一串二进制数据用特定时序的脉冲来表示。载波频率通常是 38kHz,也就是说红外发射管不是一直亮着,而是以 38kHz 的频率快速闪烁。一个“逻辑 1”和一个“逻辑 0”的区别,在于载波持续的时间长短不同。格力常见的帧格式是引导码加数据码加结束码,数据码里包含了温度、模式、风速、扫风等信息,具体位定义需要根据实际抓取的码值来解析。
这里有个容易翻车的地方:不同型号的格力空调,红外编码格式可能不一样。有的用 NEC 格式的变种,有的用自家定义的协议。所以第一步不是急着写代码,而是先用红外接收头把原装遥控器的码值抓下来。常见做法是找一个 1838 红外接收头,接在 STM32 的某个 GPIO 上,用输入捕获或者外部中断加定时器的方式,把高低电平的持续时间记录下来。
// 红外接收解码:记录高低电平持续时间 // 使用 TIM2 输入捕获,上升沿和下降沿都触发 void IR_Receive_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; TIM_IC_InitTypeDef TIM_ICInitStruct = {0}; // PA0 接红外接收头输出脚 __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // TIM2 通道1 输入捕获,双边沿触发 __HAL_RCC_TIM2_CLK_ENABLE(); TIM_ICInitStruct.ICPolarity = TIM_ICPOLARITY_BOTHEDGE; TIM_ICInitStruct.ICSelection = TIM_ICSELECTION_DIRECTTI; TIM_ICInitStruct.ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStruct.ICFilter = 0x0F; // 滤波,抗干扰 HAL_TIM_IC_Init(&htim2); HAL_TIM_IC_ConfigChannel(&htim2, &TIM_ICInitStruct, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); }这段代码的关键点在于双边沿触发和输入滤波。双边沿触发保证上升沿和下降沿都能被捕获,这样一次中断就能拿到一个完整脉冲的宽度。ICFilter 设为 0x0F 是为了滤掉红外接收头输出上的毛刺,实际调试时如果发现解码不稳定,可以适当调整这个值。定时器的预分频器设置决定了时间分辨率,一般用 1MHz 计数频率,也就是 1 微秒一个计数,这样记录下来的值直接就是微秒数,方便和协议时序对照。
2.2 发射电路:三极管驱动和限流电阻的计算
红外发射管不能直接接 GPIO 驱动,原因有两个:一是 GPIO 输出电流有限,通常只有 20mA 左右,而红外发射管瞬间电流可能需要 100mA 以上才能保证足够的发射距离;二是 38kHz 载波需要快速开关,GPIO 直接驱动波形会畸变。常见做法是用一个 NPN 三极管做开关,GPIO 控制基极,发射管接在集电极回路里。
限流电阻的计算要看发射管的额定电流和正向压降。假设用 5V 供电,发射管正向压降 1.2V,想要 100mA 电流,那么电阻就是 (5 - 1.2 - 0.3) / 0.1 ≈ 35Ω,其中 0.3V 是三极管饱和压降。实际选 33Ω 或 39Ω 都可以,但要注意电阻的功率,100mA 下 33Ω 电阻的功耗是 0.33W,得用 1/2W 的封装,不然会发热甚至烧掉。
注意:红外发射管有极性,长脚是正极,短脚是负极,接反了不亮。另外发射管和接收头不一样,接收头是解调后的输出,发射管就是普通的红外 LED,别买错了。
2.3 定时器 PWM 产生 38kHz 载波
38kHz 载波用定时器 PWM 输出最方便。假设 STM32 主频 72MHz,定时器预分频设为 0,自动重装载值设为 72M / 38k / 2 ≈ 947,这样 PWM 频率就是 38kHz,占空比 50%。为什么是除以 2?因为 PWM 模式 1 下,计数器从 0 到 ARR 走一个完整周期,输出先高后低,所以 ARR 值对应的是半个周期的高电平时间,要得到 38kHz 需要 ARR = 72M / 38k / 2。
// TIM3 通道1 输出 38kHz PWM,用于红外载波 void IR_Carrier_Init(void) { TIM_OC_InitTypeDef sConfigOC = {0}; __HAL_RCC_TIM3_CLK_ENABLE(); htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 947; // 72MHz / 38kHz / 2 ≈ 947 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 474; // 占空比 50% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1); // 先不启动,发送时再使能 HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); }发送红外码的时候,不是一直开着 PWM,而是根据码值控制 PWM 的输出和停止。逻辑 1 和逻辑 0 对应不同的载波持续时间,比如格力协议里逻辑 1 可能是 1.68ms 载波加 0.56ms 空闲,逻辑 0 是 0.56ms 载波加 0.56ms 空闲。具体数值要以抓到的码为准。实现上可以用一个状态机,在定时器中断里切换 PWM 的启停,或者用 DWT 延时做精确的微秒级控制。
3. 代码结构拆解:从红外码值到状态同步的完整链路
3.1 目录组织与模块划分
一个能跑起来的工程,目录结构不能太随意。我一般会这样分:
Project/ ├── Core/ │ ├── Inc/ │ │ ├── ir_encode.h // 红外编码声明 │ │ ├── ir_decode.h // 红外解码声明 │ │ ├── ac_state.h // 空调状态管理 │ │ └── main.h │ └── Src/ │ ├── ir_encode.c // 红外发送实现 │ ├── ir_decode.c // 红外接收解码 │ ├── ac_state.c // 状态机与码值映射 │ └── main.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ └── MDK-ARM/ └── Project.uvprojxir_encode.c 负责把空调状态转换成红外时序并发送,ir_decode.c 负责抓取原装遥控器的码值用于分析,ac_state.c 维护当前空调的开关、温度、模式等状态。这样分的好处是,当你换一个不同型号的空调时,只需要改 ac_state.c 里的码值映射表,编码和解码逻辑不用动。
3.2 红外发送的核心实现
发送部分的核心是一个根据码值数组产生时序的函数。码值数组里存的是每个脉冲的持续时间,正数表示载波开启,负数表示载波关闭。发送时遍历数组,正数就开 PWM 并延时,负数就关 PWM 并延时。
// 红外发送:根据码值数组产生时序 // code: 码值数组,正数=载波持续时间(us),负数=空闲持续时间(us) // len: 数组长度 void IR_Send(uint16_t *code, uint16_t len) { for (uint16_t i = 0; i < len; i++) { if (code[i] > 0) { // 开启 38kHz 载波 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); delay_us(code[i]); HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); } else { // 关闭载波,保持空闲 delay_us(-code[i]); } } } // 格力空调开机码示例(需根据实际抓取替换) uint16_t格力_开机[] = { 9000, -4500, // 引导码 560, -560, // 数据位... 560, -1690, // ... 省略中间数据位 560, -20000 // 结束码 };delay_us 的实现很关键,不能用 HAL_Delay,那个是毫秒级的。常见做法是用 DWT 的周期计数器做微秒延时,或者用一个基本定时器做计数。DWT 方式更简单,初始化之后直接读寄存器就行。
// DWT 微秒延时 void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t cycles = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < cycles); }DWT 初始化需要先使能 TRCENA 位,然后清零 CYCCNT。这个延时方式在 72MHz 下精度可以做到 1 微秒以内,足够红外时序使用。但要注意,如果中断打断延时,实际时间会偏长,所以发送红外码的时候最好关掉全局中断,发完再开。
3.3 状态机管理空调状态
空调遥控器不是每按一次都发完整状态,而是维护一个当前状态,每次操作只改变其中一项,然后发送完整的状态帧。所以需要一个状态结构体来记录当前温度、模式、风速、开关等。
typedef struct { uint8_t power; // 0=关,1=开 uint8_t mode; // 0=自动,1=制冷,2=除湿,3=送风,4=制热 uint8_t temp; // 16-30 uint8_t fan; // 0=自动,1=低,2=中,3=高 uint8_t swing; // 0=关,1=开 } AC_State_t; AC_State_t ac_state = {0, 1, 26, 0, 0}; // 默认制冷26度 // 根据状态生成红外码值数组 void AC_GenerateCode(AC_State_t *state, uint16_t *code, uint16_t *len) { // 根据 state 里的各项,查表得到对应的位模式 // 然后拼接成完整的码值数组 // 具体位定义需要根据抓取的码值逆向分析 }状态机的关键在于,每次按键操作只修改 ac_state 里的一个字段,然后调用 AC_GenerateCode 重新生成完整的码值数组并发送。这样逻辑清晰,也方便扩展。比如要加一个“定时关机”功能,只需要在结构体里加一个字段,在生成码值时处理对应的位就行。
3.4 码值逆向分析的方法
拿到原装遥控器的码值后,怎么知道哪几位对应温度、哪几位对应模式?常见做法是对比法:按一次温度加,抓一帧;再按一次温度加,再抓一帧。两帧对比,变化的位就是温度位。同理可以找出模式位、风速位。格力空调的码值一般比较规整,温度位通常是连续的几位二进制,直接对应温度值减 16。
比如抓到的两帧数据,第一帧温度 26 度,第二帧温度 27 度,对比发现第 12 到 15 位从 1010 变成了 1011,那这四位就是温度位,1010 对应 10,加上 16 就是 26 度。这种逆向分析需要耐心,但一旦搞清楚,后面改代码就很简单了。
4. 避坑与常见问题:红外发射距离短、解码不稳定怎么排查
4.1 发射距离只有几厘米
现象:红外发射管对着空调按,要凑得很近才有反应,稍微远一点就失灵。
原因:最常见的是限流电阻太大,导致发射管电流不够。其次是三极管没有完全饱和,或者发射管的发射角度不对。还有一种可能是 38kHz 载波频率偏了,空调接收头解调不出来。
解决:先用手机摄像头对着发射管,按发送键,看发射管是否亮。手机摄像头能拍到红外光,如果看到微弱的光,说明电路通了但电流不够。把限流电阻换小,比如从 100Ω 换成 33Ω,再试。如果还不行,检查三极管的基极电阻是不是太大,导致三极管没有进入饱和区。基极电阻一般用 1kΩ 左右,太大就换小。
4.2 解码时数据跳变
现象:用接收头抓码值时,同样的按键,每次抓到的数据都不一样,或者偶尔多出几个脉冲。
原因:红外接收头的输出有毛刺,或者输入捕获的滤波参数设置不当。另外,如果接收头的供电没有加滤波电容,电源噪声也会导致输出抖动。
解决:在接收头的电源脚旁边并一个 100nF 和 10uF 的电容。输入捕获的 ICFilter 值调大一些,比如从 0x0F 调到 0x0F 已经是最大了,如果还不行,就在软件里做二次校验,连续抓两帧,只有两帧一致才认为是有效数据。
4.3 发送时程序卡死
现象:调用 IR_Send 后,程序卡在延时循环里出不来。
原因:delay_us 里的 while 循环依赖 DWT->CYCCNT 的溢出,如果 DWT 没有正确初始化,CYCCNT 一直是 0,循环就永远不结束。
解决:检查 DWT 初始化代码,确保 TRCENA 位被置位,CYCCNT 被清零。另外,如果延时时间太长,比如超过 CYCCNT 的溢出周期,也会出问题。CYCCNT 是 32 位的,在 72MHz 下大约 59 秒溢出一次,红外码最长也就几十毫秒,不会溢出,但初始化必须做对。
4.4 换一台空调就失灵
现象:在自己空调上测试通过,换到另一台格力空调上就没反应。
原因:不同型号的格力空调,红外编码可能不同。有的用 32 位数据,有的用 48 位,温度位的偏移也可能不一样。
解决:重新抓取目标空调遥控器的码值,对比分析位定义。如果不想每次都抓,可以在代码里做多套码值表,通过按键切换或者自动识别。自动识别的思路是,先发一套码,如果空调没反应,再发另一套,但这需要空调有反馈机制,实际上不太好做。更实际的做法是,在代码里留一个宏定义,编译时选择对应的空调型号。
4.5 PWM 启动停止时的波形畸变
现象:用示波器看发射管两端的波形,发现每次 PWM 启动时第一个脉冲宽度不对。
原因:HAL_TIM_PWM_Start 和 Stop 函数内部有寄存器操作,启动时计数器可能不是从 0 开始,导致第一个周期不完整。
解决:在启动 PWM 之前,先手动把计数器清零,或者用强制输出模式先输出低电平,再启动 PWM。另一个办法是不频繁启停 PWM,而是用一个门控信号控制发射管的通断,PWM 一直开着,这样波形更干净。但门控方式需要额外的硬件电路,简单项目里还是用启停方式,只要在启动后稍微延时再开始计时就行。
5. 二次开发与扩展:把遥控器变成智能控制节点
5.1 增加串口指令控制
最直接的扩展是加一个串口接口,上位机通过串口发送指令,STM32 解析后执行对应的空调操作。这样就能把红外控制接入到电脑或者树莓派上,实现简单的自动化。
// 串口指令解析:格式 "T26" 表示设温度为26度 void UART_Cmd_Handler(uint8_t *cmd, uint16_t len) { if (len < 2) return; switch (cmd[0]) { case 'T': // 温度设置 ac_state.temp = atoi((char *)&cmd[1]); if (ac_state.temp < 16) ac_state.temp = 16; if (ac_state.temp > 30) ac_state.temp = 30; break; case 'M': // 模式切换 ac_state.mode = cmd[1] - '0'; break; case 'P': // 开关 ac_state.power = cmd[1] - '0'; break; default: return; } // 状态变化后重新发送红外码 uint16_t code[256]; uint16_t len_code; AC_GenerateCode(&ac_state, code, &len_code); IR_Send(code, len_code); }这个解析函数很简单,但扩展性不错。你可以继续加指令,比如风速、扫风、定时等。串口接收可以用中断方式,收到一个字节就存到缓冲区,收到换行符就认为一帧结束,调用解析函数。
5.2 接入温湿度传感器做自动控制
加一个 DHT11 或者 SHT30 温湿度传感器,读取环境温度,当温度超过设定阈值时自动开空调,低于阈值时自动关。这样就是一个最简单的温控系统。
// 自动温控逻辑,放在主循环里 void Auto_Control_Task(void) { static uint32_t last_check = 0; if (HAL_GetTick() - last_check < 5000) return; // 5秒检查一次 last_check = HAL_GetTick(); float current_temp = DHT11_Read_Temp(); if (current_temp > 28.0f && ac_state.power == 0) { ac_state.power = 1; ac_state.mode = 1; // 制冷 ac_state.temp = 26; AC_Send_State(); } else if (current_temp < 24.0f && ac_state.power == 1) { ac_state.power = 0; AC_Send_State(); } }这个逻辑里,5 秒检查一次是为了避免频繁开关空调。实际使用时,阈值和检查周期都可以调整。DHT11 的读取需要精确的微秒级延时,和红外发送的延时函数可以复用。
5.3 用 Flash 保存状态
每次断电后空调状态就丢了,下次上电得重新设置。可以把 ac_state 存到 STM32 内部的 Flash 里,上电时读出来。STM32F1 的 Flash 擦写需要注意,写之前要先擦除整个页,所以不能频繁写。常见做法是状态变化后不立即写,而是等 10 秒没有新变化再写,减少 Flash 磨损。
// 保存状态到 Flash(简化版,实际需要处理擦除和地址对齐) void Save_State_To_Flash(AC_State_t *state) { HAL_FLASH_Unlock(); __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); // 假设保存在 0x0801F000 页 FLASH_EraseInitTypeDef erase; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageAddress = 0x0801F000; erase.NbPages = 1; uint32_t page_error; HAL_FLASHEx_Erase(&erase, &page_error); uint32_t *p = (uint32_t *)state; for (int i = 0; i < sizeof(AC_State_t) / 4; i++) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, 0x0801F000 + i * 4, p[i]); } HAL_FLASH_Lock(); }这段代码里,擦除是整个页擦除,所以保存前要先擦。写入是按字写入,每次写 4 个字节。读取的时候直接读地址就行,不需要解锁。注意 Flash 的擦写次数有限,大概 1 万次左右,所以不要频繁保存。
5.4 调试技巧:用逻辑分析仪抓时序
调红外最头疼的是时序不对,但肉眼又看不出来。一个逻辑分析仪几十块钱,能直接看到 GPIO 上的波形,对照协议时序一比对,问题一目了然。我一般会把发射管的控制信号和 38kHz 载波信号同时接到逻辑分析仪的两个通道上,这样既能看载波有没有出来,又能看载波持续的时间对不对。
如果没有逻辑分析仪,也可以用另一个 STM32 的输入捕获来抓自己发出的信号,相当于自己做一个简易分析仪。虽然麻烦一点,但能省下买设备的钱。抓到的数据通过串口打到电脑上,用 Excel 画个图,也能看出时序对不对。
从那以后我每次调红外项目,都强制先用接收头把原装遥控器的码抓下来,存成文件,再开始写发送代码。这个习惯帮我省了很多来回试的时间,也避免了自己瞎猜协议导致的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取