
简介采用STM32F103实现红外遥控信号学习与发送的完整Keil工程资源包适合嵌入式入门及红外通信开发者参考。压缩包内共95个文件以39个C源文件、43个头文件和启动汇编文件为主同时包含Keil工程配置与调试输出文件整体仅383KB目录按SYSTEM、HARDWARE、USER等模块清晰划分。已有389人浏览学习。工程支持NEC、RC5等常见红外编码格式的接收、解码、存储与重发并附带红外收发、按键、TFT显示、Flash存储、PWM、LED、串口等外设驱动代码便于直接运行和二次开发。通过这套源码可快速理解GPIO、定时器、PWM及红外时序分析等关键知识点为实际遥控器克隆与家电控制项目打下基础。1. 不用专用编码芯片STM32F103照样把 NEC 红外遥控学下来红外学习这个需求最早我是被一个空调遥控器逼出来的柜机的原装遥控丢了网上买万能遥控要对码表翻半天干脆用手里现成的 STM32F103 开发板做个能“学码、存码、发码”的小工具。这套源码包就是当时留下来的完整工程Keil MDK 直接打开USER/test.uvproj就能编译下载目录里HARDWARE下的IR、IRSend、LEARN_KEY、FLASH、PWM、KEY、TFT、LED、USART1已经把功能模块切好了不是 demo 级别的点灯工程。工程的核心是两条链路一条是接收链路用 GPIO 加定时器输入捕获把红外接收头送出的脉冲间隔量出来另一条是发送链路用定时器 PWM 产生 38kHz 载波再按 NEC 协议把载波切成长短不一的包络。整篇文章围绕这两条链路展开适合两类人看一类是做课程设计、毕业设计想把“红外学习遥控器”这个题目真正跑通的另一类是已经在做智能家居红外网关想搞清楚学习型和转发型方案在时序、存储上的差别。2. 红外学习前端GPIO、TIM2 输入捕获与 NEC 时序还原2.1 先看工程目录HARDWARE 下层与引脚分配的对应关系打开源码包HARDWARE目录下的模块名和实际硬件是对得上的先给出我在这套工程里确认过的引脚分配后面所有代码都以这个分配为准工程目录功能引脚外设HARDWARE/IR红外接收头输入PA0TIM2_CH1HARDWARE/PWM38kHz 载波产生PA7TIM3_CH2HARDWARE/IRSend载波开断控制PA7TIM3 CCERHARDWARE/KEY学习/发送模式切换PB0EXTI0HARDWARE/LEARN_KEY学习状态机与解码-软件逻辑HARDWARE/FLASH码值掉电存储-内部 FlashHARDWARE/USART1调试打印PA9/PA10USART1HARDWARE/LED状态指示PC13GPIO 推挽把接收管脚放在 PA0 是有讲究的PA0 对应 TIM2_CH1 的默认映射不需要额外配置 AFIO 重映射标准库工程里少一行GPIO_PinRemapConfig少一个出错的点。红外接收头我用的是 HS0038 这类一体化接收头OUT 引脚直接接 PA0VCC 和 GND 之间并一个 10uF 电解电容和一个 0.1uF 瓷片电容这个电容不能省开关电源纹波大的时候会直接导致解码误码。HS0038 的输出是反相的没有红外信号时 OUT 输出高电平收到 38kHz 载波时 OUT 被拉低载波持续多久低电平就持续多久。所以接收头出来的信号和发射端波形是反的这一点在写解码逻辑时一定要记住。2.2 TIM2 输入捕获测量脉宽序列的配置红外学习的本质是把遥控器发出的每个脉冲间隔记录下来而不是去严格区分每个 bit。NEC 协议里一个数据位由一个 560us 的载波脉冲加一段静默间隔组成逻辑 0 的间隔约 560us逻辑 1 的间隔约 1.69ms。所以只要能量出“相邻上升沿之间的时间”就能反推出这一位是 0 还是 1。TIM2 的配置代码如下void IR_Recv_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_InitStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); /* TIM2 挂 APB1 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPD; /* 下拉输入空闲保持低 */ GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_InitStructure.TIM_Prescaler 71; /* 72MHz / 72 1MHz1tick 1us */ TIM_InitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_InitStructure.TIM_Period 0xFFFF; /* 16 位最大计数值 */ TIM_InitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_InitStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; /* 上升沿触发捕获 */ TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x08; /* 采样滤波滤毛刺 */ TIM_ICInit(TIM2, TIM_ICInitStructure); TIM_SelectInputTrigger(TIM2, TIM_TS_TI1FP1); /* TI1 作为触发源 */ TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_Reset); /* 捕获事件自动清零计数器 */ TIM_Cmd(TIM2, ENABLE); NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }这段配置里最关键的是TIM_SlaveMode_Reset。它让 TIM2 在每次捕获到上升沿时把计数器自动清零这样TIM_GetCapture1(TIM2)读到的值自然就是“上一次上升沿到这一次上升沿”的时间差单位是微秒。为什么把预分频设成 71因为 72MHz 时钟被 72 分频后是 1MHz计数器每跳一次就是 1us读出来的数值直接就是 us调试时不用再换算。TIM_Period 0xFFFF意味着最大能测量约 65ms 的间隔NEC 的引导码周期是 13.5ms完全够用即使以后想兼容其他遥控协议65ms 上限也基本覆盖了大部分编码格式。2.2.1 为什么选“上升沿间隔 电平回读”而不是同时捕获两个边沿很多新手第一次做红外学习会想用输入捕获同时抓上升沿和下降沿分别测高低电平宽度。这个思路本身没错但 STM32F103 的单个捕获通道在同一个时刻只能配置一种捕获极性要同时捕获两种边沿得用两个通道映射同一个引脚或者每次中断里动态切换TIM_ICPolarity代码复杂度一下就上去了。我在这个工程里采用的是更省事的做法只捕获上升沿在中断里再去读一次引脚电平判断当前处于高电平还是低电平从而知道刚刚结束的这段间隔是“载波脉冲段”还是“静默间隔段”。中断处理函数如下extern volatile uint16_t g_raw[64]; /* 脉宽间隔数组单位 us */ extern volatile uint16_t g_raw_cnt; extern volatile uint8_t g_raw_done; extern volatile uint8_t g_raw_idle; void TIM2_IRQHandler(void) { uint16_t interval; if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); interval TIM_GetCapture1(TIM2); /* 相邻上升沿间隔 */ if (interval 200) return; /* 小于 200us 的窄脉冲滤掉 */ if (g_raw_cnt 0) { if (interval 10000) return; /* 等待第一个超过 10ms 的引导码间隔 */ g_raw_idle 0; } g_raw[g_raw_cnt] interval; if (g_raw_cnt 64) { g_raw_cnt 0; g_raw_done 1; } else if (interval 30000) { /* 超过 30ms 的空闲说明一帧已经结束 */ g_raw_done 1; g_raw_cnt 0; } } }这里头两个 if 值得单独解释第一个interval 200是滤波红外接收头在信号抖动时会产生极窄的脉冲直接丢弃第二个if (g_raw_cnt 0)要求第一段被记录的间隔必须大于 10ms也就是引导码防止环境光干扰或随机毛刺把学习流程带偏。NEC 一帧数据由引导码加 32bit 数据再加结束位组成转折点一共是 34 到 35 个64 个元素的数组绰绰有余。2.3 NEC 帧格式与波形阈值整个学习过程可以看作是对下面这张表的实现NEC 协议几个关键时间我整理如下数据段发射端波形接收头输出相邻上升沿间隔引导码9ms 载波 4.5ms 空闲9ms 低 4.5ms 高约 13.5ms逻辑 0560us 载波 560us 空闲560us 低 560us 高约 1.12ms逻辑 1560us 载波 1.69ms 空闲560us 低 1.69ms 高约 2.25ms结束位560us 载波560us 低之后是长空闲注意表格里最后一列才是TIM_GetCapture1读到的值。因为捕获的是上升沿间隔逻辑 0 的 1.12ms 和逻辑 1 的 2.25ms 之间有接近 1ms 的差距阈值取 1.5ms 非常稳。至于为什么接收头输出的低电平时间是 560us而上升沿间隔却是“低电平 高电平”的总和原因就在于 2.2 节里提到的反相和上升沿捕获策略这也是这套方案和教科书上“直接测脉宽”最大的不同——它不care当前电平是正是反只关心沿和沿之间的距离。2.4 学习状态机从按键触发到接收完成HARDWARE/LEARN_KEY这个模块本质上是一个三态状态机空闲态、接收态、保存态。我的实现里用KEY按键的长按和短按来切换typedef enum { LEARN_IDLE 0, LEARN_WAIT, LEARN_SAVED } LEARN_State_t; void LEARN_Key_Scan(void) { if (KEY_LongPress()) /* 长按 3 秒进入学习模式 */ { learn_state LEARN_WAIT; g_raw_cnt 0; g_raw_done 0; LED_Blink(50); /* 快闪表示等待接收 */ } else if (KEY_ShortPress()) /* 短按切换发送槽位 */ { active_slot (active_slot 1) % IR_SLOT_MAX; IR_Store_Index(active_slot); } } void LEARN_Task(void) { if (learn_state LEARN_WAIT g_raw_done) { if (NEC_Decode((uint16_t *)g_raw, RAW_LEN, rx_code)) { IR_Store_Code(active_slot, rx_code); /* 解码成功存协议字段 */ learn_state LEARN_SAVED; LED_On(1000); /* 常亮 1 秒提示成功 */ } else { IR_Store_Raw(active_slot, (uint16_t *)g_raw); /* 解码失败存原始波形 */ learn_state LEARN_SAVED; } } }LEARN_Task放在主循环里轮询不在中断里做解码原因很直接NEC 解码过程中有反码校验和移位这些操作在中断里执行会拖长中断服务时间下一帧数据来的时候可能来不及响应。中断里只做采集状态机里做处理这套架构对后续扩展 RC5、SIRC 等协议也友好。3. 解码与存储把学到的码落进内部 Flash3.1 NEC 解码一步到位引导码定位与反码校验解码函数接收的是上一章采集到的间隔数组。前面说过每个数据位的间隔要么在 1.12ms 附近要么在 2.25ms 附近阈值取 1.5ms 就能区分 0 和 1。下面是完整的 NEC 解码实现typedef struct { uint8_t addr; /* 地址码 */ uint8_t addr_inv; /* 地址反码 */ uint8_t cmd; /* 命令码 */ uint8_t cmd_inv; /* 命令反码 */ } NEC_Code_t; uint8_t NEC_Decode(uint16_t *buf, uint16_t cnt, NEC_Code_t *out) { uint16_t i 0; uint32_t code 0; uint8_t bit; /* 1. 查找引导码间隔超过 10ms 才认为是引导码起点 */ while (i cnt buf[i] 10000) i; if (i cnt) return 0; i; /* 2. 连续读取 32 个数据位的间隔 */ if (i 32 cnt) return 0; for (int b 0; b 32; b) { bit (buf[i] 1500) ? 1 : 0; /* 2.25ms 判为 11.12ms 判为 0 */ code (code 1) | bit; } /* 3. 拆字段做反码校验 */ out-addr (uint8_t)(code 0xFF); out-addr_inv (uint8_t)((code 8) 0xFF); out-cmd (uint8_t)((code 16) 0xFF); out-cmd_inv (uint8_t)((code 24) 0xFF); if ((out-addr ^ out-addr_inv) ! 0xFF) return 0; if ((out-cmd ^ out-cmd_inv) ! 0xFF) return 0; return 1; }这段代码有三个边界条件容易踩坑。第一个是buf[i] 10000的循环它必须处理“数组开头有一段噪声”的情况否则会把噪声误当引导码第二个是i 32 cnt的判断红外接收头偶尔会漏掉最后一个沿导致数组长度少 1这个提前判断能避免越界读第三个是反码校验NEC 规定地址码和命令码都是“原码 反码”双字节校验失败直接丢弃这一帧避免把错误码存进 Flash。这里还要提一下 NEC 的重复码长按遥控器按键时遥控器发出的是 9ms 引导码加 2.25ms 间隔加 560us 结束位整帧只有两个间隔远小于 32bit 数据。上面的解码函数会直接返回 0但在实际应用中重复码应该被识别为“继续发送上一次的命令”而不是丢弃。我的做法是在LEARN_Task里额外判断cnt 2 buf[0] 10000满足条件就复用上一帧的码值。3.2 解码失败时的 RAW 回退存波形而不是存协议NEC 是目前最普及的红外协议但空调、风扇、投影仪里还有大量基于 RC5、SIRC 甚至私有协议的产品。这些协议的时间基准完全不同比如 RC5 使用 Manchester 编码一个 bit 占用 1.778ms无法用 NEC 的 1.5ms 阈值去解码。这个工程做了一层回退如果NEC_Decode返回 0就把原始间隔数组原封不动存下来发送时按原始波形重放。原始波形存储的结构我定义为typedef struct { uint8_t valid; /* 0x5A 表示有效 */ uint8_t proto; /* 0NEC, 1RAW */ uint8_t raw_len; /* 间隔个数 */ uint8_t slot; uint16_t raw_buf[64]; /* 间隔序列单位 us */ } IR_Slot_t;这个结构体的大小是 1111128共 132 字节。存 NEC 码时raw_buf里可以不用填满但raw_len必须保持正确发送模块依赖它来决定重放多少个间隔。这种“协议字段 原始波形”双轨存储的方式比单纯只存解码值要稳妥得多——录个不知名协议的空调查码等发出去才发现解码不支持那才是真麻烦。3.3 内部 Flash 存储与槽位管理HARDWARE/FLASH模块负责把学习结果写到 STM32F103 的内部 Flash。F103C8 的中容量 Flash 每页是 1KB我一般把最后两页0x0800F800 和 0x0800FC00划给红外码存储总共可以放 8 个槽位每个槽位 132 字节。写入的典型代码段如下#define IR_FLASH_BASE 0x0800F800 /* 最后一页起始地址 */ #define IR_SLOT_MAX 8 #define IR_SLOT_SIZE sizeof(IR_Slot_t) void IR_Store_Code(uint8_t slot, NEC_Code_t *code) { IR_Slot_t data; uint32_t addr; uint16_t *p; if (slot IR_SLOT_MAX) return; data.valid 0x5A; data.proto 0; /* NEC 协议 */ data.raw_len 0; data.slot slot; /* 把 NEC 四个字段转成间隔序列NEC_Decode 的逆操作 */ BuildNECRaw(data, code); /* 内部填入 raw_buf */ addr IR_FLASH_BASE slot * IR_SLOT_SIZE; FLASH_Unlock(); FLASH_ErasePage(addr 0xFFFFF800); /* 页擦除地址必须页对齐 */ p (uint16_t *)data; for (uint8_t i 0; i IR_SLOT_SIZE / 2; i) { FLASH_ProgramHalfWord(addr i * 2, p[i]); } FLASH_Lock(); printf(slot %d saved, addr0x%08X\r\n, slot, addr); }3.3.1 FLASH_ErasePage 与写入的中断保护FLASH_ErasePage的参数是页起始地址所以传入前一定要做addr 0xFFFFF800的页对齐运算如果直接传入一个没有对齐的地址擦除行为是不确定的。另外一点写 Flash 期间 CPU 会被阻塞如果这时候有定时器中断或者串口中断进来中断服务函数里的 Flash 读取操作可能会导致读等待。所以我在FLASH_Unlock()之前把TIM2中断暂时挂起写完再恢复这种做法在 F103 标准库里是安全的NVIC_InitTypeDef nvic {0}; nvic.NVIC_IRQChannel TIM2_IRQn; nvic.NVIC_IRQChannelCmd DISABLE; NVIC_Init(nvic); FLASH_Unlock(); FLASH_ErasePage(...); /* ... 写入 ... */ FLASH_Lock(); nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic);还有一种常见做法是把__disable_irq()用在整个写入过程中但我不推荐全局关中断因为FLASH_ProgramHalfWord逐半字写入 132 字节需要一段时间全局关中断会让 SysTick 停摆后续所有delay_ms全部失效。4. IR_SEND 发送链路38kHz 载波 PWM 与波形重放4.1 38kHz 载波产生参数计算红外发射管需要在 38kHz 左右的载波上才能被一体化接收头识别载波频率偏太多接收灵敏度会断崖式下降。用定时器 PWM 产生载波是最直接的方式本工程用 TIM3_CH2 映射到 PA7。对于 72MHz 的系统时钟参数计算如下参数值说明TIM_Prescaler0不做分频保持 72MHzTIM_Period189472MHz / 1895 ≈ 37994HzTIM_Pulse631占空比约 33%PWM 模式TIM_CWMP_PWM1高电平在前输出极性TIM_OCPolarity_High默认红外发射管不能长时间满占空比工作常用占空比是三分之一33% 既保证接收头能稳定解调又不会让管子过热。初始化代码如下void IR_Send_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_InitStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; /* 带上拉输出 */ GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_InitStructure.TIM_Prescaler 0; TIM_InitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_InitStructure.TIM_Period 1894; TIM_InitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, TIM_InitStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_Pulse 631; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Disable; /* 默认不输出 */ TIM_OC2Init(TIM3, TIM_OCInitStructure); TIM_Cmd(TIM3, ENABLE); }TIM_OutputState_Disable这一步很有用初始化后 PWM 通道是被禁止的PA7 保持原有电平只有需要发送时才打开通道这样避免上电瞬间 PA7 直接输出 38kHz 载波干扰其他设备。4.2 按 NEC 时序控制 PWM 开断IR_Send_Code 的实现发送 NEC 帧的过程本质上是把一张“何时开载波、何时关载波”的时间表按顺序执行完。开载波和关载波只需要操作TIM3-CCER寄存器的CC2E位#define IR_CARRIER_ON() TIM3-CCER | TIM_CCER_CC2E #define IR_CARRIER_OFF() TIM3-CCER ~TIM_CCER_CC2E void IR_Send_Byte(uint8_t byte) { for (uint8_t i 0; i 8; i) { IR_CARRIER_ON(); /* 先发 560us 载波脉冲 */ delay_us(560); IR_CARRIER_OFF(); if (byte 0x01) /* LSB first */ { delay_us(1690); /* 逻辑 1 的静默间隔 */ } else { delay_us(560); /* 逻辑 0 的静默间隔 */ } byte 1; } } void IR_Send_Code(NEC_Code_t *code) { __disable_irq(); /* 发送期间关中断防止时序漂移 */ IR_CARRIER_ON(); delay_us(9000); /* 引导码 9ms 载波 */ IR_CARRIER_OFF(); delay_us(4500); /* 引导码 4.5ms 静默 */ IR_Send_Byte(code-addr); IR_Send_Byte(code-addr_inv); IR_Send_Byte(code-cmd); IR_Send_Byte(code-cmd_inv); IR_CARRIER_ON(); delay_us(560); /* 结束位 */ IR_CARRIER_OFF(); __enable_irq(); }注意 NES 数据是低位先发IR_Send_Byte里byte 0x01之后byte 1就是低位优先的典型写法。这里delay_us(9000)这类长延时我是用 SysTick 的CYCCNT寄存器实现的精度在 1us 级别直接调用标准库的Delay函数时要注意确认底层实现是us还是ms很多新手在这个地方把 9000us 写成 9000ms发射管直接冒烟。4.3 时序精度问题关闭中断、SysTick 延时与优化上面IR_Send_Code里用了__disable_irq()把中断关掉了这不是偷懒而是 NEC 对时间精度确实敏感。560us 的载波脉冲如果被一个串口中断打断哪怕只延长 100us接收端解码依然大概率成功但如果是 1.69ms 差出 200us部分要求严格的接收方案就开始误码了。关中断发送一帧 NEC 数据大约耗时 33ms期间 CPU 不响应任何中断这在纯遥控场景里是可接受的但如果你的系统里还有电机控制、看门狗喂狗这类硬实时任务就不能用这种阻塞式发送。一种折中方案是把发送过程搬进定时器中断用一个状态机按索引推进。比如用 TIM4 产生一个 10us 的 tick中断里根据间隔表判断当前时刻应该开载波还是关载波。这样阻塞时间从 33ms 降到 10us 级别代价是代码复杂度明显上升。工程里如果对实时性要求高我建议直接用硬件方式把待发送的间隔序列通过 DMA 写入一个定时器的自动重装载寄存器用定时器自动完成整个开断过程CPU 完全解放。不过这套源码里的IR_Send_Code是阻塞版本适合先把逻辑跑通。4.3.1 进阶用定时器 Gate 替代延时发送如果要在阻塞发送的基础上做一点提升可以保留IR_CARRIER_ON/OFF结构但把四个字节的发送改成查表驱动。具体做法是先把地址码、反码、命令码、反码拼接成一个 32 位无符号数然后预先构建一张“载波段长度 静默段长度”的间隔表用 for 循环一次性发完void IR_Send_RawFromTable(uint16_t *table, uint8_t len) { __disable_irq(); for (uint8_t i 0; i len; i 2) { IR_CARRIER_ON(); delay_us(table[i]); /* 载波段 */ IR_CARRIER_OFF(); delay_us(table[i 1]); /* 静默段 */ } __enable_irq(); }这个函数的好处是不仅适用于 NEC也适用于 3.2 节里提到的 RAW 回退存储。学习到的未知协议间隔数组只要按照“载波段、静默段”的交替顺序存进raw_buf发送时直接喂给这个函数就能重放。这也是我建议把IR_Slot_t.raw_buf的间隔顺序定义为“载波段、静默段、载波段、静默段”交替的原因一表通用。5. 回环自测与排错验证学习/重发一致性的一套实用打法5.1 波形对比的三个检查点做完整个工程后很多人的第一反应是拿去对电视按一下没反应就开始怀疑代码。我建议先用示波器或者逻辑分析仪检查三个点检查点测量位置故障现象接收头输出波形HS0038 OUT 引脚按下遥控器无低电平脉冲串 → 接收头虚焊或供电不足载波频率PA7 引脚频率偏差超过 2kHz → 重新计算 TIM3 分频和重装值载波包络时序发射管阳极引导码只有 8ms 或 10ms →delay_us精度有问题没有示波器也没关系串口打印解码值也能定位大部分问题。工程里USART1模块已经接好了printf重定向学习成功后会自动打印addr... cmd...。5.2 定时器捕获测频率用 TIM2 校准载波38kHz 载波到底准不准可以用另一路输入捕获来测。把 PA7 通过一个 1kΩ 电阻直接连到 PA0利用刚才的 TIM2 上升沿捕获在连续捕获模式下测 PWM 周期。PWM 波的相邻上升沿间隔就是载波周期频率等于 1MHz 除以间隔uint32_t carrier_freq 1000000 / g_raw[0]; /* g_raw 里捕获的是 PWM 周期 */ printf(carrier %lu Hz\r\n, carrier_freq);实测下来TIM_Period 1894配置对应的频率是 37994Hz和标准 38kHz 只差 6Hz完全可以忽略。如果测出来差了几百赫兹多半是系统时钟不是 72MHz——用标准库工程时要检查system_stm32f10x.c里PLL_M的配置这个文件在USER目录下很多毕设工程卡在这里。5.3 发一组、学一组、再对比回环自检的完整流程最后分享一个我在调试时最常用的回环自测法原理很简单先把学习到的码发送出去让接收头自己把信号再采一遍然后对比两次数解码值是否一致。接线方式就是 5.2 节那样PA7 出 1kΩ 电阻接 PA0同时把红外发射管也接在 PA7 上光和电两条路径一起验证。void Loopback_Test(void) { NEC_Code_t tx_code, rx_code; IR_Load_Code(0, tx_code); /* 从 Flash 读取槽位 0 的码 */ IR_Send_Code(tx_code); /* 发送一帧 */ delay_ms(50); /* 等待接收头状态复位 */ g_raw_cnt 0; g_raw_done 0; while (!g_raw_done); /* 等待学习完成 */ if (NEC_Decode((uint16_t *)g_raw, RAW_LEN, rx_code)) { printf(TX addr%02X cmd%02X\r\n, tx_code.addr, tx_code.cmd); printf(RX addr%02X cmd%02X\r\n, rx_code.addr, rx_code.cmd); if (tx_code.addr rx_code.addr tx_code.cmd rx_code.cmd) { printf(loopback OK\r\n); } else { printf(loopback FAIL\r\n); } } }注意这里有个容易翻车的点发送过程本身是关中断的发送完 50ms 内不要立刻开学习接收否则上一次发送时 PA7 的高电平状态可能被 TIM2 捕获进去产生一个虚假的宽脉冲。50ms 的间隔足够接收头输出恢复到高电平空闲状态。回环自测跑通后再把 LED 接在 PA7 上做视觉指示开发效率会高很多。最后把两侧间隔序列用 USART1 打印出来对比即便解码失败也能直接看出是引导码缺失还是数据位误判。本文还有配套的精品资源点击获取