尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
STM32红外遥控空调实战:从码值抓取到整机联调
简介这份资源面向嵌入式开发初学者与智能家居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 画个图也能看出时序对不对。从那以后我每次调红外项目都强制先用接收头把原装遥控器的码抓下来存成文件再开始写发送代码。这个习惯帮我省了很多来回试的时间也避免了自己瞎猜协议导致的玄学问题。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

MySQL慢SQL优化全解析:索引设计与执行计划实战

MySQL慢SQL优化全解析:索引设计与执行计划实战

1. 核心设计思路:为什么SQL优化不能只看“加索引”?聊到MySQL性能优化,很多人第一反应就是“加索引”。这个想法没错,但如果看任何一条慢SQL上来就加索引,那基本就是在碰运气。MySQL里一条SQL从客户端发出,…

📅 2026/10/17 19:40:23
物理AI:让机器人从执行指令到学会“问为什么”的技术路径

物理AI:让机器人从执行指令到学会“问为什么”的技术路径

最近和做机器人集成的朋友聊天,他感叹:以前调试机械臂出问题,查报警码、改程序、重新跑一遍就结束了;现在团队开始对着失败轨迹问“为什么这个动作在这种摩擦系数下会打滑”“为什么传感器读到这个位姿却抓偏了”。我听到这句话时…

📅 2026/10/17 19:40:23
正则表达式漫谈:从元字符到实战应用全解析

正则表达式漫谈:从元字符到实战应用全解析

很多人对正则表达式又爱又恨,爱它极强的文本处理能力,恨它那堆看起来像乱码的符号。我在日常开发和数据处理里用了十多年正则,坦白说,早期也踩过不少坑。但一旦掌握这套规则,你会发现它在日志分析、表单校验、数据清洗…

📅 2026/10/17 19:35:22
MORE NEWS

更多资讯

📰

从“蹭Token”讲起:身份令牌、JWT续签与报错排查全指南

最近总看到有人在评论区刷“去酒吧蹭 Token”,乍一看我以为是哪个新开的精酿馆搞了币圈主题夜。后来发现,这梗跟酒吧没有半点关系,它本质上是在调侃 Token 这个词已经火到连喝酒都能拿来玩谐音梗了。技术圈里,Token 最常见的含义是…

📰

“拐跑”别人的 OpenClaw 让它给你干活:大模型应用安全-01 之 Markdown 注入与提示词越狱防护

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Linux虚拟机搭建Hadoop伪分布式环境:从零开始的大数据入门实验

简介:面向大数据入门读者的实验一配套文档,系统讲解Linux操作与Hadoop操作的基础训练。内容围绕三个核心目标展开:掌握Linux虚拟机安装、熟悉常用Linux命令、完成Hadoop伪分布式安装,适合正在准备大数据课程实验或需要搭建单机实验…

📰

DBSCAN聚类算法实战:从原理到调参的完整指南

1. 为什么DBSCAN至今仍是聚类算法里的“万金油”做数据分析和挖掘的朋友,大概率都经历过这样的场景:手里攥着一份用户行为数据或者传感器采集数据,想按相似度把样本分成几堆,结果用K-Means跑出来的结果怎么看怎么别扭——有的簇被…

📰

Kettle RowSet并发机制与内存调优深度解析

简介:本资源是一份面向1–3年经验研发人员的Kettle(Pentaho Data Integration)入门级教学PPT,聚焦ETL核心流程与工具实践,解决数据同步、清洗与集成中的基础建模与落地难题。内容覆盖ETL概念本质、Kettle安装部署&…

📰

Flink SQL中ROW_NUMBER() OVER窗口报错解析与避坑指南

1. 从一个真实报错说起:ROW_NUMBER() 为什么在 Flink SQL 里“水土不服”第一次在 Flink SQL 里写ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)去重的时候,我满心以为这就是个标准 SQL 操作,结果提交作业直接给我甩了个异常&#xff0…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬