说实话,红外遥控这玩意儿放在十年前绝对是智能家居的顶流,现在虽然被Wi-Fi、蓝牙抢了不少风头,但家里的老电视、机顶盒、空调、风扇遥控器还是那一堆。想把这些老物件重新联网,第一步通常就是让单片机听懂红外信号。树莓派Pico作为一块几十块钱的MCU开发板,搭配VS1838B这种几毛钱一颗的一体化红外接收头,正好是入门嵌入式最值得做的小实验之一。
这个项目表面上是“接一个接收头、读几个引脚电平”的事,实际做起来会发现,真正的核心全在时序上:VS1838B解调出来的信号是一串高低电平脉冲,单片机要做的不是读高低电平状态,而是精确测量每个脉冲持续了多少微秒,再按NEC红外协议把这些脉冲翻译成地址码和按键码。Pico的MicroPython环境让实验门槛很低,RP2040芯片自带的PIO外设又能让你在不需要逻辑分析仪的情况下,把脉冲时序测到微秒级精度。这篇内容就围绕这个实验,把硬件连接、协议细节、采集方案、解码流程和常见坑一次性讲透,适合刚接触单片机和嵌入式协议解析的人参考。
1. 这个实验到底在做什么?
1.1 为什么选树莓派Pico和VS1838B
树莓派Pico最吸引我的点是它足够便宜、上手简单,而且有两个非常能打的外设:一个是可以精确计时的PIO状态机,另一个是硬件级中断机制。对于红外接收这类依赖时序测量的任务,普通单片机也能做,但要么靠中断里拼手速,要么靠轮询赌运气,Pico的方案灵活得多。
VS1838B是一体化红外接收头,内部集成了光电二极管、前置放大、限幅器、带通滤波器和解调器,外部只需要三个引脚就能工作。它专门接收38kHz载波调制的红外信号,把光信号变成电信号后,直接输出TTL电平给单片机。对比用普通光敏二极管自己搭放大电路的做法,用VS1838B简直是傻瓜级方案,这也是它在开发板和DIY圈子里普及率极高的原因。
这个实验的完整链路是:遥控器按键 -> 红外LED发射38kHz载波 -> VS1838B接收并解调 -> 输出脉冲序列 -> Pico测量脉冲宽度 -> 按NEC协议解析出按键码。整个过程涵盖了传感器读取、时序测量、协议解析、状态机编程这些嵌入式基本功,做完之后你基本就摸清了红外通信的全部套路。
1.2 实验全链路与我的整体思路
我的推进顺序是先硬件后软件:先把VS1838B接到Pico上,用最简单的代码确认能收到波形,再逐步加上NEC协议解析,最后接一个红外发射管做回放。很多人一上来就盯着协议细节看,结果波形都收不到,后面全是空谈。
实际操作中我会刻意保留一个“原始信号观察期”。在这个阶段,不急着解析协议,而是把接收头输出的所有脉冲宽度打印出来,先肉眼确认能不能看到9ms、4.5ms、0.56ms、1.68ms这些典型值。一旦这些数值出现,说明硬件和采集链路都没问题,后面做的就只是纯软件解码了。这样做的好处是遇到问题能快速定位,是硬件接错了还是协议解析写错了,一看脉冲数据就知道。
2. 硬件连接与几个必须先确认的细节
2.1 VS1838B引脚与接线表
VS1838B常见封装有三种,直插和贴片都有,引脚顺序并不完全一样。最保险的方法是看器件上的丝印和规格书,不过大多数常见的直插封装引脚定义是:凸起半圆朝向自己,从左到右依次是OUT、GND、VCC。贴片封装则常标为VCC、GND、OUT,方向差异很大,买回来先查数据手册永远是最稳妥的。
推荐接线方式如下:
| VS1838B引脚 | 接到Pico | 说明 |
|---|---|---|
| VCC | 3V3(即3.3V引脚) | 不要接VSYS或5V,见下节说明 |
| GND | GND | 共地必须接好 |
| OUT | GP16 | 任意GPIO都行,选GP16方便接线 |
| VCC与GND之间 | 10uF电解电容 + 0.1uF瓷片电容 | 并联,尽量靠近接收头 |
这里我特意强调一点:VS1838B的工作电压范围通常是2.7V到5.5V,理论上接3.3V完全能工作,但输出高电平会接近供电电压。如果你直接把VCC接到5V,OUT脚输出的高电平也会接近5V,而RP2040的GPIO不是5V容忍引脚,这会带来损坏风险。最简单可靠的做法是让接收头也用3.3V供电,Pico可以直接识别,不用加电平转换,接线最干净。
2.2 供电、电平与抗干扰的坑
VS1838B内部有自动增益控制电路,对电源纹波比较敏感。USB供电本身噪声就大,如果还同时驱动屏幕、电机之类的东西,红外解码成功率会明显下降。我这里建议在接收头的VCC和GND之间并联一个10uF电解电容和0.1uF瓷片电容,电容尽量靠近接收头引脚,能很大程度上滤掉高频干扰。条件允许的话,用电池供电或者单独的稳压模块供电,稳定性会再上一个台阶。
输出脚要不要上拉电阻是个常见争议。VS1838B输出级实际上是集电极开路输出,很多模块板上已经自带上拉电阻,如果你买的是模块而不是裸接收头,不需要额外处理。如果买的是裸芯片,我在GP16上接了10k上拉电阻到3.3V。实测下来,不加上拉在多数情况下也能识别,但加上之后高电平更干净,解码成功率更高,强烈建议保留。
飞线长度也是个容易被忽略的问题。红外接收头的输出线不要太长,最好控制在20厘米以内。我之前试过用30厘米的杜邦线连接,结果在手机充电器旁边解码偶尔出错,把线缩短到10厘米,问题消失。信号在长线上容易串入干扰,高频环境下尤其明显。
3. 红外遥控的基础:NEC协议看一眼再说
3.1 38kHz载波与接收头内部在干什么
红外遥控的本质是让红外LED以38kHz的频率快速亮灭,亮代表有载波,灭代表无载波。用载波调制的主要原因是抗干扰。家里有阳光、白炽灯、荧光灯,这些光源都含红外成分,如果不做调制,接收端分不清遥控器信号和环境噪声。给信号加上38kHz的“身份标签”,接收头内部带通滤波器只认这个频率,其他频率统统滤掉。
VS1838B做的事情是:探测到38kHz载波存在时,输出引脚拉到低电平;没有载波时,输出高电平。所以你从OUT脚读到的信号是已经解调过的脉冲串,高电平表示“没红外”,低电平表示“正在接收红外”。这里有个细节很多人一开始会搞反:接收头空闲时输出的是高电平,而不是低电平,接上LED做指示时会表现为待机常亮、按键时闪烁。理解了这个反逻辑,后面看时序图才不会晕。
打个比方,遥控器发来的原始信号是“货”,38kHz载波是“包装箱”,VS1838B就是那个拆箱员。它只负责拆开包装把货递给你,至于货里面装的是什么内容,那是协议解析该做的事。
3.2 NEC协议帧格式与时序规则
NEC协议是目前最常见的红外遥控协议之一,很多电视、机顶盒、风扇遥控器都用它。完整的一帧数据由引导码、地址码、地址反码、命令码、命令反码组成,发送顺序是低位先发,每字节都是LSB first。
具体时序规则非常明确:
- 引导码:9ms载波 + 4.5ms空闲
- 逻辑“0”:0.56ms载波 + 0.56ms空闲
- 逻辑“1”:0.56ms载波 + 1.68ms空闲
- 重复码:9ms载波 + 2.25ms空闲 + 0.56ms载波
- 整帧结构:引导码 + 8位地址 + 8位地址反码 + 8位命令 + 8位命令反码
由于接收头输出是反逻辑,所以Pico采集到的低电平时长就对应遥控器发出的载波时长,高电平时长对应空闲时长。你在代码里看到“[9000, 4500, 560, 560, ...]”这种脉冲列表,其实就是上面这些数值按时间顺序拼出来的。
地址反码和命令反码的作用是校验。比如命令码是0x45,反码就是0xBA,加起来验证确保信号没被干扰。有些变种协议比如NEC扩展模式会把16位地址合并发送,不发送地址反码,判断方法就是解码后反码校验不成立,但后面16位数据仍能猜出地址和命令。
3.3 时序容差为什么重要
现实世界没有绝对精确的时序。遥控器的晶振有误差,接收头解调有延迟,Pico的定时器也有测量误差,所以解码时绝对不能拿“必须是9000us”这种死标准去判断,而是要给每个值留容差区间。常见的做法是允许正负25%或正负30%的偏差。
我举一个实际例子:命令“1”的高电平标称是1.68ms,命令“0”的高电平标称是0.56ms,两者差距大约1.1ms,容差很容易区分。但有些廉价遥控器发射的脉宽偏差能达到20%,如果阈值设得太死,高电平1.1ms在判断时就会模棱两可。我通常把0和1的判定阈值定在1.0ms左右,高电平超过1.0ms记为1,低于1.0ms记为0,实测下来各种遥控器都能稳定识别。
4. 用MicroPython实测采集NEC红外信号
4.1 方案一:GPIO下降沿中断+微秒时间戳
最直接的做法是用GPIO的外部中断,在每个上升沿和下降沿记录一次当前时间戳,然后算出相邻两个边沿之间的间隔。这样采集到的就是连续的高低电平脉冲宽度。
代码思路如下:
from machine import Pin import utime IR_PIN = 16 ir_pin = Pin(IR_PIN, Pin.IN, Pin.PULL_UP) edges = [] last_us = 0 max_edges = 256 def irq_handler(pin): global last_us now = utime.ticks_us() diff = utime.ticks_diff(now, last_us) last_us = now if len(edges) < max_edges: edges.append(diff) ir_pin.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=irq_handler) while True: if len(edges) > 0: print(edges) edges.clear() utime.sleep_ms(100)这里有几个细节需要注意。第一,中断回调里绝对不要print,否则MicroPython的虚拟机调度会严重干扰时序,实测下来解码成功率会大幅下降。正确的做法是回调里只做时间戳记录和列表追加,在主循环里统一处理数据。第二,utime.ticks_us()返回的是微秒级递增计数,需要配合ticks_diff()做差值,因为底层计数会回绕。第三,列表长度要做限制,防止长时间按着遥控器不松导致内存无限增长。
这个方案的优点是简单直观,代码量小,适合第一次做红外实验时验证思路。缺点是MicroPython的IRQ响应延迟不固定,RP2040跑MicroPython的虚拟机会让中断回调的响应时间抖动达到几微秒甚至几十微秒。对于NEC这种动辄几百微秒的脉冲宽度来说,几十微秒误差完全在容差范围内,所以实际使用完全没问题。
4.2 方案二:PIO状态机硬核精确计时
如果你想把时序误差压到微秒级以内,或者想释放CPU让主循环去做其他事,PIO是更好的选择。RP2040的PIO是独立于CPU运行的状态机,每条指令执行周期精确可控,非常适合做这种周期性边沿检测。
我给一个可以实际跑起来的PIO采集方案。这个状态机做的事情是:检测到引脚下降沿后开始计数,当引脚重新变高时记录低电平脉宽;紧接着开始计高电平时间,当引脚再次变低时记录高电平脉宽。这样它会把接收头输出的高低电平脉宽交替推送到FIFO,主循环只需要读取数据。
from machine import Pin import rp2 IR_PIN = 16 ir_pin = Pin(IR_PIN, Pin.IN, Pin.PULL_UP) @rp2.asm_pio() def ir_widths(): pull() # 等主机给初始计数值 out(y, 32) # Y = 0xFFFFFFFF label("main") wait(0, pin, 0) # 等待引脚变低,低脉冲开始 mov(x, y) # X装入初始计数值 label("count_low") jmp(pin, "low_end") # 如果引脚变高,低脉冲结束 jmp(x--, "count_low") # 否则继续循环计数 jmp("count_low") label("low_end") mov(isr, x) # 保存低脉宽计数值 push() mov(x, y) # 重置计数值 label("count_high") jmp(not_pin, "high_end") jmp(x--, "count_high") jmp("count_high") label("high_end") mov(isr, x) push() jmp("main") sm = rp2.StateMachine(0, ir_widths, freq=2_000_000, in_base=ir_pin, jmp_pin=ir_pin) sm.put(0xffffffff) sm.active(1) pulses = [] while True: if sm.rx_fifo(): raw = sm.get() pulse_us = 0xffffffff - raw pulses.append(pulse_us) print(len(pulses), pulse_us)这段代码里有个换算逻辑需要说明。状态机频率设为2MHz,每条PIO指令周期0.5us。计数循环中两条指令算一个周期,正好1us。因此从初始值0xFFFFFFFF减到当前值,这个差值就直接等于脉冲宽度微秒数。主循环里用0xffffffff - raw就得到了微秒值。
PIO方案的精确度远高于中断方案,而且不依赖CPU。缺点是代码理解门槛略高,不过把它作为一个硬件计时器来用其实很简单:填初始值、启动、读数据。很多复杂的时序解码,比如DHT11温湿度传感器、单总线协议,都可以复用这套状态机思路。
5. NEC协议解码与按键映射实操
5.1 解码流程与容差判定
采集到高低脉宽序列之后,下一步就是把脉宽序列翻译成数据帧。我习惯先写一个容差匹配函数,然后依次识别引导码、数据位、重复码。容差取25%,这个值在实测中能兼容绝大多数遥控器。
def match_width(value, target): return abs(value - target) < target * 0.25 def nec_decode(pulses): if len(pulses) < 66: return None if not match_width(pulses[0], 9000): return None if not match_width(pulses[1], 4500): return None bits = 0 idx = 2 for _ in range(32): low = pulses[idx] high = pulses[idx + 1] if not match_width(low, 560): return None if match_width(high, 560): bit = 0 elif match_width(high, 1680): bit = 1 else: return None bits = (bits << 1) | bit idx += 2 addr = (bits >> 24) & 0xFF addr_inv = (bits >> 16) & 0xFF cmd = (bits >> 8) & 0xFF cmd_inv = bits & 0xFF if addr_inv != (~addr & 0xFF): return None if cmd_inv != (~cmd & 0xFF): return None return addr, cmd解码流程中的位拼接顺序值得多说几句。由于NEC协议是低位先发,代码里每收到一个bit就左移一位再或上新bit,处理完32位之后,第一个bit会落在最高位,和协议文档里的“低位先发”形成了镜像关系。在实际中不需要纠结字节顺序,因为只要地址反码和命令反码能对上,说明解析逻辑是自洽的,你拿到的是一个稳定的码值。如果直接拿来和网上查到的键码表对照不匹配,把整段数据按位反转一下再对照即可。
5.2 按键码映射与重复码处理
解码成功后,地址码和命令码就会稳定对应某个遥控器上的按键。以电视遥控器为例,地址码通常固定为0x00,每个按键对应一个命令码。可以维护一个码表:
keymap = { 0x45: "电源", 0x40: "音量+", 0x19: "音量-", 0x16: "频道+", 0x15: "频道-", }按住遥控器按键不松手时,发射端会先发送一次完整帧,然后每隔约110ms发送一次重复码。重复码只有引导码加2.25ms间隔,不解码的话会空出很多无意义数据。更好的做法是在解码函数里同时识别重复码,并返回一个特殊标志,让上层逻辑知道“当前按键被持续按住”。
def is_repeat_code(pulses): if len(pulses) < 3: return False return ( match_width(pulses[0], 9000) and match_width(pulses[1], 2250) and match_width(pulses[2], 560) )识别出重复码后,你可以让上层代码继续执行“长按”逻辑,比如连续增加音量。
5.3 实测调试记录
我用一个常见的电视遥控器做测试,采集到的一帧数据大致是这种节奏:
[9008, 4502, 562, 564, 562, 1698, ...]9008和4502完美匹配引导码,数据位部分规律地出现560us左右和1700us左右的脉冲对。解析结果是地址0x00、命令码0x45。按下“音量+”键时命令码变成0x40,“频道+”变成0x16,跟码表完全对得上。
有一个实测现象值得记录:不同品牌遥控器的地址码不一定是0x00,有些空调遥控器甚至用16位地址。所以做通用红外学习设备时,不能写死任何地址码,只做数据帧的保存和回放就好了,学习哪把遥控器就存那把的数据。
6. 常见问题与排查技巧实录
6.1 现象速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 完全没有信号,pulses为空 | 引脚接错、供电不足、VS1838B损坏 | 检查VCC/GND/OUT,测量VCC电压 |
| 收到一堆随机脉冲但解不出码 | 白炽灯或阳光干扰 | 关闭光源或遮挡接收头;检查接收角度 |
| 能解出重复码,但没有数据帧 | 解码容差太严 | 把容差从10%放宽到25% |
| 地址码校验老失败 | LSB位序处理有误 | 用反码校验判断,必要时反转位序 |
| 按住按键才出数据,松开就都是重复码 | 这是正常现象 | 把重复码当成长按事件处理 |
| 解码在远距离失效 | 接收头供电不足或电源噪声大 | 加滤波电容,换电池供电 |
表格里“按住按键才出数据”是新手最容易误判的地方。NEC协议设计上就是按一次发一个完整帧,长按时发重复码,这不要当成故障,反而是协议工作的正常表现。
6.2 几个我踩过的细节坑
第一个坑是MicroPython里IRQ回调做print。我一开始为了调试方便,在中断回调里直接把时间戳打印出来,结果原本稳定的解码变得时好时坏。原因是print本身耗时严重,执行期间中断被阻塞,后续边沿时间戳全部错乱。把打印挪到主循环之后,问题立刻消失。
第二个坑是接收头的供电。有段时间解码成功率飘忽不定,时好时坏,后来发现是USB口供电电压纹波太大。VS1838B内部增益很高,对电源噪声敏感,尤其在使用廉价充电头供电时特别明显。加个10uF电容并缩短飞线后,问题基本解决。
第三个坑是PIO状态机的初始值加载。PIO程序里用了pull()和out(y, 32)等待主机输入初始值,但如果在启动状态机前忘了调用sm.put(0xffffffff),状态机会一直阻塞在pull()处,表现为完全没反应。这个坑很容易被忽略,因为代码语法没问题,但运行逻辑就卡在最前面。
第四个坑是接收头引脚的共地。VS1838B的GND必须和Pico的GND连在一起,很多人只接了信号线和电源线,忘了共地,导致信号完全没有。这个问题排查起来很隐蔽,因为看着哪都正常,就是收不到数据。
7. 这还不算完:红外学习与发射扩展
做完接收和解码之后,自然就会想到反向操作:能不能把学到的红外信号再发出去?答案是肯定的,而且事情到这个阶段才真正好玩起来。
最简单的方法是给Pico接一个红外发射管,用PWM模块产生38kHz载波,然后按NEC协议的时序控制PWM的开关。MicroPython里初始化38kHz PWM很轻松:
from machine import Pin, PWM TX_PIN = 17 tx_pwm = PWM(Pin(TX_PIN, Pin.OUT)) tx_pwm.freq(38000) tx_pwm.duty_u16(0)发送时,如果当前时刻需要载波,就把duty_u16设成半占空比;不需要载波时设成0。这样就能复刻出和遥控器一致的脉冲序列。精度上,MicroPython控制PWM状态切换会有几十微秒的误差,但只要保证总脉宽偏差在25%容差内,接收端依然能正常识别。
更进一步,可以把学习到的脉冲保存到Flash里,用JSON格式持久化:
import ujson saved_codes = {} saved_codes["power"] = pulses with open("ir_codes.json", "w") as f: ujson.dump(saved_codes, f)下次启动时加载这个文件,按一下Pico上的按钮就能回放任意学习到的红外码,这就是一个简易万能遥控器的雏形。把学习到的码存起来之后,剩下就是发射部分的事情。你完全可以接着做。