简介:采用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 | 红外接收头输入 | PA0 | TIM2_CH1 |
HARDWARE/PWM | 38kHz 载波产生 | PA7 | TIM3_CH2 |
HARDWARE/IRSend | 载波开断控制 | PA7 | TIM3 CCER |
HARDWARE/KEY | 学习/发送模式切换 | PB0 | EXTI0 |
HARDWARE/LEARN_KEY | 学习状态机与解码 | - | 软件逻辑 |
HARDWARE/FLASH | 码值掉电存储 | - | 内部 Flash |
HARDWARE/USART1 | 调试打印 | PA9/PA10 | USART1 |
HARDWARE/LED | 状态指示 | PC13 | GPIO 推挽 |
把接收管脚放在 PA0 是有讲究的,PA0 对应 TIM2_CH1 的默认映射,不需要额外配置 AFIO 重映射,标准库工程里少一行GPIO_PinRemapConfig,少一个出错的点。红外接收头我用的是 HS0038 这类一体化接收头,OUT 引脚直接接 PA0,VCC 和 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 = 1MHz,1tick = 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 |
| 逻辑 0 | 560us 载波 + 560us 空闲 | 560us 低 + 560us 高 | 约 1.12ms |
| 逻辑 1 | 560us 载波 + 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. 解码与存储:把学到的码落进内部 Flash
3.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 判为 1,1.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; /* 0=NEC, 1=RAW */ uint8_t raw_len; /* 间隔个数 */ uint8_t slot; uint16_t raw_buf[64]; /* 间隔序列,单位 us */ } IR_Slot_t;这个结构体的大小是 1+1+1+1+128,共 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, addr=0x%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_Prescaler | 0 | 不做分频,保持 72MHz |
| TIM_Period | 1894 | 72MHz / 1895 ≈ 37994Hz |
| TIM_Pulse | 631 | 占空比约 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 打印出来对比,即便解码失败,也能直接看出是引导码缺失还是数据位误判。
本文还有配套的精品资源,点击获取