实验室搬家翻出一个旧空调遥控器,我想做个不联网的离线控制器:STM32F103C8T6 接一个 VS1838B 一体化红外接收头,按下遥控器就把键值打到串口,后面再映射成继电器动作。第一版代码很直白——PA0 配成输入,主循环里不断读电平,用 HAL_Delay(1) 数高电平持续了几个毫秒,据此判断是 0 还是 1。

单独跑一个只做解码的工程时它工作得不错,我一度以为这事已经完了。加上 OLED 刷新和串口上报之后,同一个键按三次,常常只有一次能解出来,另外两次不是校验失败,就是解出一个根本不存在的键值。换接收头、换遥控器电池、把发射距离从两米缩到半米,问题都在。

最后拿逻辑分析仪抓 PA0 的波形,才确认遥控器发得一点问题没有,是我这边在丢时间。

一、接收头输出的不是”有红外/没红外”

先要把这件事说清楚,否则后面的时序全是错的。VS1838B 这类一体化接收头内部集成了 38kHz 带通、AGC 和检波电路,它输出的已经是解调后的基带信号,不是载波。无信号时输出高电平,收到 38kHz 调制时会拉低。也就是说,MCU 看到的是反相的逻辑:有红外 = 低电平。

NEC 协议一帧的结构是固定的:

段 时长 说明
引导码低 9ms 帧开始标志
引导码高 4.5ms 引导码结束
逻辑 0 560us 低 + 560us 高 单个位总长约 1.12ms
逻辑 1 560us 低 + 1690us 高 单个位总长约 2.25ms
重复码 9ms 低 + 2.25ms 高 + 560us 低 长按时的短帧,不含键值

数据一共 32 位:地址 8 位、地址反码 8 位、命令 8 位、命令反码 8 位。每一位的低电平都是 560us,信息藏在高电平的长度里,560us 是 0,1690us 是 1。记住这一条,后面的状态机才有依据:我们要测的始终是上升沿到下降沿之间的那段宽度。

二、轮询丢码的根因:你测的不是边沿,是”碰巧看到的那一刻”

我当时用的是这样一段代码:

while (1) {
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
        uint16_t us = 0;
        while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
            HAL_Delay(1);   /* 1ms 粒度,还想量 560us */
            us += 1000;
        }
        /* 用 us 判断这是引导码还是数据位 */
    }
    OLED_Refresh();
    printf("key=%d\r\n", last_key);
}

问题出在三个地方,而且是叠在一起的。

第一,时间基准不是一个时钟,而是主循环本身。HAL_Delay(1) 给的是毫秒粒度,而 0 和 1 的差别只有 1130us,靠”数了几次 1ms”去分辨,余量薄得像纸。第二,主循环会被别的事情拉长。OLED 走软件 I2C 刷一屏要几毫秒,printf 在 115200 波特率下阻塞发送几十个字节约 5ms,一次刷新就比一个数据位(最长 2.25ms)还长,中间必然漏掉整位。第三,丢一位不会报错,只会让后面的 32 位整体错位,于是要么反码校验不过,要么凑出一个”看起来合理”的错误键值。

说到底,轮询得到的信息是”我在某个时刻看到高电平”,而 NEC 需要的是”这个边沿发生在哪个时刻”。这是两种完全不同的量。只要主循环有抖动,后者就测不准,再小心也没用。

三、把时间戳交给硬件:TIM2 输入捕获的状态机

换思路:让定时器自由计数,边沿到来时由硬件把计数值锁存进捕获寄存器(CCR)。中断延迟只影响我什么时候去读,不会改变读到的那个已经锁存的值。这就是输入捕获和轮询的本质差别。

先把定时器配成 1MHz 计数:72MHz 主频除以 72 的分频,1 个计数正好 1us。这个分频不是随手写的——如果用 72MHz 直接计数,16 位计数器每 0.91ms 就溢出一次,9ms 的引导码直接对不上;分到 1MHz 后,16 位可以量到 65.5ms,一帧 NEC 的任何一段都装得下。而且 CCR 相减用的是 16 位无符号减法,即使真的回绕也会自动得到正确的差值,不用额外处理。

/* PA0 -> TIM2_CH1;初始化时捕获极性设为下降沿 */
typedef enum { S_IDLE, S_LEADER_HIGH, S_BIT_FALL, S_BIT_RISE } ir_state_t;

static volatile ir_state_t s_state = S_IDLE;
static volatile uint16_t   s_last;      /* 上一次捕获的 CCR */
static volatile uint32_t   s_frame;
static volatile uint8_t    s_bits;
static volatile uint8_t    s_idle_ms;
volatile uint8_t  ir_ready;
volatile uint32_t ir_frame;

static void ir_pol(uint32_t p) { __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, p); }

static uint8_t rev8(uint8_t x)
{
    x = (uint8_t)((x & 0xF0) >> 4 | (x & 0x0F) << 4);
    x = (uint8_t)((x & 0xCC) >> 2 | (x & 0x33) << 2);
    return (uint8_t)((x & 0xAA) >> 1 | (x & 0x55) << 1);
}

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance != TIM2) return;

    uint16_t ccr = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
    uint16_t dt  = (uint16_t)(ccr - s_last);   /* 16 位无符号相减,回绕自动正确 */
    s_last = ccr;
    s_idle_ms = 0;

    switch (s_state) {
    case S_IDLE:                       /* 任意下降沿都当作一次帧的起点 */
        ir_pol(TIM_ICPOLARITY_RISING);
        s_state = S_LEADER_HIGH;
        break;

    case S_LEADER_HIGH:                /* 上升沿,dt = 引导码低电平宽度 */
        ir_pol(TIM_ICPOLARITY_FALLING);
        if (dt > 8000 && dt < 10000) { s_frame = 0; s_bits = 0; s_state = S_BIT_FALL; }
        else                         { s_state = S_IDLE; }
        break;

    case S_BIT_FALL:                   /* 下降沿,dt = 上一位的高电平宽度 */
        ir_pol(TIM_ICPOLARITY_RISING);
        s_state = S_BIT_RISE;
        if (dt > 3000) break;          /* 这是引导码的 4.5ms 高电平,跳过 */
        if      (dt > 400  && dt < 800)  s_frame = (s_frame << 1) | 0u;
        else if (dt > 1400 && dt < 1900) s_frame = (s_frame << 1) | 1u;
        else { s_state = S_IDLE; break; }          /* 位宽不像 NEC,整帧作废 */
        if (++s_bits == 32) {
            s_state = S_IDLE;
            uint8_t a = rev8(s_frame >> 24), ai = rev8(s_frame >> 16);
            uint8_t c = rev8(s_frame >> 8),  ci = rev8(s_frame);
            if ((uint8_t)(a ^ ai) == 0xFF && (uint8_t)(c ^ ci) == 0xFF) {
                ir_frame = s_frame;            /* 主循环里再解析 */
                ir_ready = 1;
            }
        }
        break;

    case S_BIT_RISE:                   /* 上升沿,dt = 这一位的低电平,约 560us */
        ir_pol(TIM_ICPOLARITY_FALLING);
        s_state = (dt > 300 && dt < 900) ? S_BIT_FALL : S_IDLE;
        break;
    }
}

/* 放在 1ms 的 SysTick 回调里:帧间空闲超时复位,等下一帧 */
void IR_Tick1ms(void)
{
    if (s_state != S_IDLE && ++s_idle_ms > 20) {
        s_state = S_IDLE;
        ir_pol(TIM_ICPOLARITY_FALLING);
    }
}

这段代码里有几个决定成败的细节,值得单独说一下。

为什么要来回切捕获极性。 一个定时器通道在同一时刻只能捕获一种边沿。我让它先等下降沿,抓住位开始;再切成上升沿,量出高电平宽度;然后切回下降沿等下一位。每切一次,状态机就前进一格,逻辑上不会重复计同一条边沿。

为什么解码放在下降沿。 因为信息在高电平时长里,而高电平结束的那一刻正是下降沿。放在上升沿解,量到的是恒定的 560us 低电平,永远解不出 1。

LSB 先发带来的字节倒序。 NEC 每个字节都是低位先发,左移拼接之后,发出去的第一位会落在最高位,所以 s_frame >> 24 拿到的是位序颠倒的地址。校验反码时无所谓——反码再倒序仍是反码的倒序,异或一定是 0xFF;但你要是拿它去查键值表,就必须先 rev8() 翻回来,否则会得到一堆看似随机的映射。这个坑我在第二块板子上才意识到,因为校验一直是对的,只有键值对不上。

中断里只做算术。 回调里不 printf、不 HAL_Delay、不刷屏。硬件锁存的时间戳不怕中断进得晚,但怕进得太晚——如果下一条边沿在我切极性之前就到了,CCR 会被覆盖,边沿直接丢失。保持 ISR 短,帧完整后置一个标志,解析和打印都丢给主循环。

四、捕获之外还得处理的三件事

长按的重复码。 按住不放时,遥控器不再重发完整帧,只每隔约 108ms 发一个 9ms 低 + 2.25ms 高 + 560us 低 的短帧。它的引导码低电平同样是 9ms,会被我的状态机当成一帧的开头,然后卡在等数据位。所以必须有超时复位:上面 IR_Tick1ms() 的作用就是这个。要不要把重复码当成”上一次键值再触发一次”,取决于你的交互——做继电器开关时我选择忽略,做音量加减时就需要它。

接收头的供电和干扰。 VS1838B 的 AGC 会被环境光里的 38kHz 成分拉低增益,日光灯、节能灯都有这个成分。我第一次装的时候接收头跟继电器共用一路 5V,继电器一吸合距离立刻掉一半。后来在接收头 VCC 脚就近加 100nF 加 10uF 去耦,信号线加 10k 上拉,问题基本消失。这不是软件能补救的,别指望改阈值。

引导码窗口要留余量。 上面写的是 8000–10000us,而不是严格的 9000。遥控器的 455kHz 晶振本身有误差,接收头还有传输延迟,实测常常抓到 8.7ms 附近。窗口太窄会把正常帧判掉,太宽又容易把干扰当成帧头。8–10ms 是我试下来比较稳的范围。

五、什么时候其实不必上输入捕获

输入捕获不是万能钥匙,选型要看场景:

方案 时间基准 精度 抗主循环阻塞 代价
主循环轮询 循环次数 / HAL_Delay 毫秒级,抖动大 差 最简单,只适合学习或节奏极固定的场景
EXTI + DWT 微秒计时 CPU 周期计数器 微秒级 中,依赖中断响应 不需要占定时器,但要自己维护计时源
定时器输入捕获 硬件锁存 CCR 与中断延迟无关 好 占一个定时器通道,需要状态机
输入捕获 + DMA 硬件搬运 最好 最好 帧长不定时难处理,NEC 这种变长编码并不划算

如果只是读一个固定时序的传感器、或者按键消抖那种毫秒级的事,轮询完全够用,没必要上捕获。但只要碰上”位宽携带信息”的协议——红外、DHT11、WS2812 的单线时序——时间戳的准确性就是协议的一部分,这时候让硬件去记边沿,比在主循环里跟自己的调度搏斗要靠谱得多。

小结

轮询丢码不是因为代码写得不够仔细,而是因为它测的是”电平”而不是”时刻”,任何一次主循环阻塞都会污染时间基准。输入捕获把这件事交给定时器硬件:边沿到来时锁存计数值,软件只负责按状态机解释宽度。真正要小心的反倒是协议细节——反相输出、高电平携带信息、LSB 先发导致的字节倒序、长按重复码,以及必须在 1ms 节拍里做的超时复位。这几件事对齐之后,同一个遥控器连按几十次,键值都稳定了。