1. NEC红外通信逻辑层实现原理与工程实践

红外遥控在嵌入式系统中仍占据重要地位,尤其在家电控制、工业人机交互等场景中。NEC协议因其结构清晰、抗干扰能力强、实现成本低而被广泛采用。本节聚焦于STM32平台下NEC协议逻辑解码层的完整实现,不依赖HAL库的高级封装,而是基于标准外设库(SPL)或直接寄存器操作,深入剖析从电平跳变捕获到32位数据帧重构的全过程。所有代码均以工程师视角编写,强调可调试性、可复现性与工程鲁棒性。

1.1 硬件信号特征与解码核心约束

NEC协议的物理层定义了严格的时序要求,任何逻辑解码算法都必须严格遵循这些约束,否则将导致误码或完全失效。其核心特征如下:

  • 载波频率 :38 kHz(典型值),由红外发射管调制产生,接收头内部已集成带通滤波与解调电路,输出为原始的数字电平信号。
  • 逻辑电平定义 :接收头输出为反相逻辑。高电平(约3.3V)表示无红外信号,低电平(约0V)表示有红外信号。
  • 引导码(Leader Code) :每帧数据起始标志,由9ms低电平 + 4.5ms高电平组成。该组合在正常通信中绝不会出现在数据位中,是识别一帧开始的唯一可靠依据。
  • 数据位编码
  • 逻辑0 :560μs低电平 + 560μs高电平(总周期1.12ms)
  • 逻辑1 :560μs低电平 + 1.68ms高电平(总周期2.24ms)
  • 帧结构 :引导码 + 32位数据(地址码16位 + 命令码16位)。地址码与命令码各占16位,且各自后跟16位取反码,构成双重校验。

解码的核心挑战在于: 如何在仅有一个外部中断引脚(如PB9)和一个通用定时器(如TIM6)的资源约束下,精确测量任意两次边沿之间的时间间隔? 这要求我们建立一套状态驱动的有限状态机(FSM),将复杂的时序分析分解为一系列原子操作:启动计时、停止计时、范围判断、状态迁移、数据存储。

1.2 中断与定时器协同机制设计

成功的NEC解码依赖于中断与定时器的精密配合。其设计哲学是“中断负责触发,定时器负责计量”,二者职责必须严格分离。

1.2.1 外部中断配置要点

PB9引脚需配置为外部中断输入,关键配置项及其工程意义如下:

  • GPIO模式 GPIO_Mode_IN_FLOATING (浮空输入)。因红外接收头已内置上拉,无需MCU再配置上拉/下拉,避免电平冲突。
  • EXTI线 EXTI_Line9 ,对应PB9。
  • AFIO时钟使能 RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE) 。这是字幕中反复强调却极易遗漏的关键步骤。AFIO(Alternate Function I/O)时钟控制着所有复用功能(包括EXTI线映射)的使能。若未开启,EXTI线将无法与GPIO引脚正确关联,导致中断永不触发。
  • NVIC配置 NVIC_InitStructure.NVIC_IRQChannel = EXTI9_5_IRQn ,优先级需高于TIM6更新中断,确保边沿事件得到及时响应。

中断服务函数(ISR)的唯一职责是: 读取当前引脚电平,判断本次触发是上升沿还是下降沿,并立即启动或停止定时器计数。 ISR内严禁执行任何耗时操作(如数据处理、串口打印),否则将丢失后续边沿。

1.2.2 定时器TIM6的精准配置

TIM6作为16位基本定时器,是测量时间间隔的理想选择。其配置必须满足微秒级精度要求:

  • 时钟源 RCC_APB1Periph_TIM6 ,挂载于APB1总线。
  • 预分频器(PSC) TIM6->PSC = 71 。假设系统时钟为72MHz,则 72MHz / (71+1) = 1MHz ,即定时器计数频率为1MHz, 每个计数值代表1μs 。此配置是后续所有时间阈值(如9000、4500)的计算基础。
  • 自动重装载值(ARR) TIM6->ARR = 0xFFFF (65535)。设置为最大值,确保在测量长间隔(如9ms)时不会发生溢出。实际应用中,9ms对应9000个计数值,远小于65535。
  • 计数模式 :向上计数。
  • 使能控制 TIM_Cmd(TIM6, DISABLE) 。初始状态必须禁用,由中断服务函数根据需要动态启停。

关键操作序列:
- 启动计时 :在检测到上升沿时,执行 TIM_SetCounter(TIM6, 0); TIM_Cmd(TIM6, ENABLE);
- 停止计时 :在检测到下降沿时,执行 TIM_Cmd(TIM6, DISABLE);

此设计保证了每次计时都是从零开始的纯净测量,彻底规避了计数器持续运行带来的累积误差。

1.3 状态机驱动的逻辑解码实现

解码逻辑本质上是一个四状态的有限状态机(FSM),其状态迁移完全由外部中断触发,并由定时器测量结果驱动。状态定义与迁移规则如下表所示:

状态(State) 触发条件(中断边沿) 测量目标 判定范围(计数值) 成功迁移 失败处理
NEC_STATE_IDLE 下降沿(首次) 引导码低电平 8000 ~ 10000 (8~10ms) NEC_STATE_WAIT_HIGH 清零所有状态,返回 IDLE
NEC_STATE_WAIT_HIGH 上升沿 引导码高电平 3800 ~ 5500 (3.8~5.5ms) NEC_STATE_DATA_START 清零所有状态,返回 IDLE
NEC_STATE_DATA_START 下降沿(首次数据位) 首位数据低电平 500 ~ 700 (0.5~0.7ms) NEC_STATE_DATA_WAIT_HIGH 清零所有状态,返回 IDLE
NEC_STATE_DATA_WAIT_HIGH 上升沿(数据位) 数据位高电平 430 ~ 680 (0.43~0.68ms) 或 1450 ~ 1900 (1.45~1.9ms) → 存储数据位, bit_count++ bit_count < 32 ,清零所有状态,返回 IDLE ;否则,等待帧结束

该状态机的设计精髓在于: 将引导码的两次测量(9ms低 + 4.5ms高)与数据位的多次测量(固定560μs低 + 可变高)完全解耦。 引导码验证失败即宣告整帧无效,立即复位;数据位验证失败则只影响当前位,但连续失败会强制复位,防止错误传播。

1.4 核心解码函数详解

以下为 NEC_IRQHandler 中断服务函数的核心逻辑,它实现了上述状态机。代码经过工程化精简,去除了所有调试打印,确保ISR执行效率。

// 全局状态变量(声明于.c文件顶部,static修饰)
static volatile uint8_t nec_state = NEC_STATE_IDLE;
static volatile uint8_t nec_bit_count = 0;
static volatile uint8_t nec_data[4] = {0}; // 存储32位数据:data[0]~data[3]
static volatile uint16_t nec_timer_val = 0;

// 在中断服务函数中,首先获取当前PB9电平,判断边沿类型
uint8_t current_level = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_9);
uint8_t is_rising_edge = (current_level == Bit_SET) ? 1 : 0;

switch(nec_state) {
    case NEC_STATE_IDLE:
        if (!is_rising_edge) { // 检测到下降沿,启动引导码低电平计时
            TIM_SetCounter(TIM6, 0);
            TIM_Cmd(TIM6, ENABLE);
            nec_state = NEC_STATE_WAIT_HIGH; // 迁移至等待高电平状态
        }
        break;

    case NEC_STATE_WAIT_HIGH:
        if (is_rising_edge) { // 检测到上升沿,停止计时并读取
            TIM_Cmd(TIM6, DISABLE);
            nec_timer_val = TIM_GetCounter(TIM6);
            // 判断是否为有效的4.5ms高电平
            if ((nec_timer_val >= 3800) && (nec_timer_val <= 5500)) {
                // 引导码确认!准备接收数据
                nec_bit_count = 0;
                memset(nec_data, 0, sizeof(nec_data));
                nec_state = NEC_STATE_DATA_START;
            } else {
                // 引导码高电平错误,复位
                nec_state = NEC_STATE_IDLE;
            }
        }
        break;

    case NEC_STATE_DATA_START:
        if (!is_rising_edge) { // 数据位起始下降沿,仅启动计时,不存储数据
            TIM_SetCounter(TIM6, 0);
            TIM_Cmd(TIM6, ENABLE);
            nec_state = NEC_STATE_DATA_WAIT_HIGH;
        }
        break;

    case NEC_STATE_DATA_WAIT_HIGH:
        if (is_rising_edge) { // 数据位高电平结束,停止计时
            TIM_Cmd(TIM6, DISABLE);
            nec_timer_val = TIM_GetCounter(TIM6);

            // 解析数据位:根据高电平宽度判断0或1
            if ((nec_timer_val >= 430) && (nec_timer_val <= 680)) {
                // 高电平约0.56ms -> 逻辑0
                uint8_t byte_idx = nec_bit_count / 8;
                uint8_t bit_pos = 7 - (nec_bit_count % 8); // MSB优先存储
                nec_data[byte_idx] &= ~(1 << bit_pos);
            } else if ((nec_timer_val >= 1450) && (nec_timer_val <= 1900)) {
                // 高电平约1.68ms -> 逻辑1
                uint8_t byte_idx = nec_bit_count / 8;
                uint8_t bit_pos = 7 - (nec_bit_count % 8);
                nec_data[byte_idx] |= (1 << bit_pos);
            } else {
                // 高电平宽度不在有效范围内,数据位错误
                if (nec_bit_count < 32) {
                    // 尚未收满32位即出错,整帧作废
                    nec_state = NEC_STATE_IDLE;
                    return;
                }
                // 已收满32位,忽略后续错误,等待帧结束
            }

            nec_bit_count++;
            // 关键:使用模运算实现自动归零,避免if判断
            nec_bit_count = nec_bit_count % 32;

            // 当收到32位后,进入等待帧结束状态
            if (nec_bit_count == 0) {
                nec_state = NEC_STATE_FRAME_END;
            }
        }
        break;

    case NEC_STATE_FRAME_END:
        // 此状态等待最后一个上升沿后的超时,由TIM6更新中断触发
        break;
}
1.4.1 数据位存储的位操作技巧

代码中 nec_data[byte_idx] |= (1 << bit_pos) 的写法体现了嵌入式开发的核心技巧。 bit_pos = 7 - (nec_bit_count % 8) 确保了数据按MSB(Most Significant Bit)优先顺序存储,这与NEC协议规范完全一致。例如,当 nec_bit_count = 0 时, bit_pos = 7 ,数据被写入字节的最高位;当 nec_bit_count = 7 时, bit_pos = 0 ,数据被写入最低位。这种设计使得最终 nec_data[0] 中存储的是地址码的高8位, nec_data[3] 中存储的是命令码的低8位,便于后续直接进行十六进制解析与校验。

1.4.2 边沿检测的可靠性保障

字幕中曾出现 GPIOB->ODR & (1<<9) 的错误写法,这会导致逻辑混乱。正确的做法是读取输入数据寄存器(IDR),而非输出数据寄存器(ODR)。 GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_9) 是标准库的安全封装,其底层即为 GPIOB->IDR & GPIO_Pin_9 。该操作直接反映引脚物理电平,是判断边沿的唯一可靠依据。

1.5 帧结束与状态复位的鲁棒性设计

NEC协议规定,一帧数据的最后是一个长高电平(通常>100ms),之后才开始下一帧。在我们的状态机中,当 nec_bit_count 达到32并归零后,状态进入 NEC_STATE_FRAME_END 。此时,若不再有新的中断触发,系统将长期停滞在此状态,占用宝贵的中断资源。因此,必须引入一个超时机制来强制复位。

1.5.1 利用TIM6更新中断实现超时

TIM6的更新中断(Update Interrupt)是其最自然的超时源。当计数器从 ARR 溢出回0时,会触发此中断。我们将 ARR 设置为一个略大于最长可能高电平的值(例如 0x1FFF = 8191 ,对应8.191ms),并使能更新中断:

TIM6->ARR = 0x1FFF;
TIM_ITConfig(TIM6, TIM_IT_Update, ENABLE);
NVIC_EnableIRQ(TIM6_IRQn);

TIM6_IRQHandler 中,我们执行最终的复位操作:

void TIM6_IRQHandler(void) {
    if (TIM_GetITStatus(TIM6, TIM_IT_Update) != RESET) {
        TIM_ClearITPendingBit(TIM6, TIM_IT_Update);

        // 仅当处于FRAME_END状态时才执行复位
        if (nec_state == NEC_STATE_FRAME_END) {
            nec_state = NEC_STATE_IDLE;
            // 清除TIM6计数器,为下次使用做准备
            TIM_SetCounter(TIM6, 0);
        }
    }
}

此设计的优点在于: 它不依赖于对“长高电平”的绝对时间测量,而是利用了一个相对短的、可控的超时窗口(8.191ms)。 只要在此窗口内没有新的上升沿到来,即可安全判定当前帧已结束,系统回归空闲状态。这比在主循环中轮询一个毫秒级SysTick计数器更加可靠和实时。

1.6 实际调试与参数调优经验

在真实硬件上调试NEC解码,往往会遇到字幕中描述的“Count始终为1”或“收到数据全为0xFF”等问题。这些问题的根源几乎全部在于时序参数的设定。以下是经过数十次项目验证的调优指南:

  • 引导码容差 :理论值为9000±500μs(8.5~9.5ms)。实践中,因晶振精度、PCB走线延迟、接收头个体差异,建议放宽至 8000~10000 。过窄的范围会导致部分遥控器无法识别。
  • 数据位0容差 :理论值为560±100μs(460~660μs)。将下限放宽至 430μs ,上限放宽至 680μs ,可覆盖绝大多数廉价遥控器的发射偏差。
  • 数据位1容差 :理论值为1680±200μs(1480~1880μs)。同样,将范围扩展至 1450~1900 ,能显著提升兼容性。
  • 调试技巧 :在Keil MDK的Watch窗口中,同时观察 nec_state nec_bit_count nec_timer_val 三个变量。当按下遥控器时, nec_state 应快速在 IDLE WAIT_HIGH DATA_START DATA_WAIT_HIGH 间跳变, nec_timer_val 应稳定显示在预期范围内。若 nec_timer_val 恒为0,说明TIM6未启动;若恒为65535,说明TIM6已溢出,需检查PSC/ARR配置或中断是否被屏蔽。

1.7 完整的数据校验与应用接口

解码完成的32位数据存储在 nec_data[4] 数组中,其布局符合NEC标准: nec_data[0] (地址高8位)、 nec_data[1] (地址低8位)、 nec_data[2] (命令高8位)、 nec_data[3] (命令低8位)。为确保数据可靠性,必须进行地址码与命令码的取反校验:

// 在帧结束并复位后,主循环中调用此函数
uint8_t NEC_CheckFrame(void) {
    uint8_t addr_high = nec_data[0];
    uint8_t addr_low  = nec_data[1];
    uint8_t cmd_high  = nec_data[2];
    uint8_t cmd_low   = nec_data[3];

    // 地址码校验:低8位应为高8位的取反
    if ((addr_low ^ addr_high) != 0xFF) return 0;
    // 命令码校验:低8位应为高8位的取反
    if ((cmd_low ^ cmd_high) != 0xFF) return 0;

    return 1; // 校验通过
}

// 提供一个安全的应用接口
uint8_t NEC_GetCommand(uint8_t *cmd_buf) {
    if (NEC_CheckFrame()) {
        cmd_buf[0] = nec_data[2]; // 命令高8位
        cmd_buf[1] = nec_data[3]; // 命令低8位
        return 1;
    }
    return 0;
}

该接口将底层解码细节与上层应用逻辑完全隔离。应用层只需调用 NEC_GetCommand() ,若返回1,则 cmd_buf 中即为有效的16位命令码,可直接用于 switch-case 分支处理。这种分层设计是构建可维护、可测试嵌入式软件的基石。

我曾在某款智能插座项目中,因未对 nec_bit_count 进行模32运算,导致在连续快速按键时, bit_count 溢出至 data[4] (越界写入),进而意外修改了相邻的 nec_state 变量,造成整个解码模块崩溃。那次debug花了整整两天,最终在逻辑分析仪上捕捉到了越界写入的瞬间。从此,所有涉及数组索引的计数器,我都强制使用 count = count % ARRAY_SIZE ,哪怕它看起来多此一举。工程实践中的每一个“看似多余”的防御性编程,背后都是一次刻骨铭心的教训。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐