实验室搬家翻出一个旧空调遥控器,我想做个不联网的离线控制器: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 节拍里做的超时复位。这几件事对齐之后,同一个遥控器连按几十次,键值都稳定了。
评论与讨论
使用 GitHub 账号登录即可留言(首次使用需在 giscus 中授权)