news 2026/10/24 4:38:03

树莓派Pico+VS1838B红外接收与NEC协议解码实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
树莓派Pico+VS1838B红外接收与NEC协议解码实战指南

说实话,红外遥控这玩意儿放在十年前绝对是智能家居的顶流,现在虽然被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说明
VCC3V3(即3.3V引脚)不要接VSYS或5V,见下节说明
GNDGND共地必须接好
OUTGP16任意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上的按钮就能回放任意学习到的红外码,这就是一个简易万能遥控器的雏形。把学习到的码存起来之后,剩下就是发射部分的事情。你完全可以接着做。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 4:37:12

C语言文件读取防坑指南:从fopen到fread的常见问题与排查技巧

1. 文件打开失败的排查路径&#xff1a;别只盯着返回值先讲个我早期写C程序时的真实场景。当时用fopen("data.txt", "r")去读一个放在"可执行文件旁边"的配置文件&#xff0c;结果每次运行都能正常打开文件并输出内容&#xff0c;一切看起来都很…

作者头像 李华
网站建设 2026/10/24 4:36:01

免费版和高级版到底差在哪?智能创作工具实测与避坑指南

最近两个月&#xff0c;我几乎把市面上能叫得上名字的智能创作工具都翻来覆去试了一遍。起因是有位做自媒体的朋友问我&#xff1a;免费版和高级版到底差在哪&#xff0c;为什么有些工具我免费用得挺顺手&#xff0c;一换到付费就觉得亏得慌&#xff1f;这个问题看起来简单&…

作者头像 李华
网站建设 2026/10/24 4:35:58

CANN信号处理加速库SIP实战:异构计算下的语音与雷达性能优化

提到“CANN信号处理加速库SIP”&#xff0c;如果你做过语音识别或者雷达信号处理&#xff0c;并且已经在异构平台上做过一阵性能优化&#xff0c;大概率会和我一样兴奋&#xff1a;这层东西终于把“信号处理”当成一等公民来设计了。我刚接触SIP库时以为它只是又一组FFT封装&am…

作者头像 李华
网站建设 2026/10/24 4:35:15

COMSOL单边裂纹扩展仿真:从应力强度因子到内聚区模型

做结构强度和仿真的朋友&#xff0c;大概率都遇到过这类题&#xff1a;一块板条在拉伸荷载下带了一条单边裂纹&#xff0c;问它会不会开裂、要怎么扩展。这个题目放进COMSOL里做&#xff0c;其实就是单边裂纹扩展模型的典型应用。看起来简单&#xff0c;真正跑通并让结果可信&a…

作者头像 李华
网站建设 2026/10/24 4:35:14

CSP-S初赛递归阅读题全解析:从手算到通用解题法

2023年CSP-S提高组初赛的阅读程序第1题&#xff0c;考完以后被很多选手拿来复盘。代码不长&#xff0c;一个递归函数从头到尾解决问题&#xff0c;但真正在考场上能把四个小问全部做对的人不多。这道题表面考递归&#xff0c;实际考的是你能不能把一个十几行的程序&#xff0c;…

作者头像 李华
网站建设 2026/10/24 4:34:42

2023蓝桥杯省B组真题复盘:高频算法考点与备赛避坑指南

2023年的蓝桥杯省赛B组考完&#xff0c;网上讨论最多的不是题目本身难不难&#xff0c;而是“基础题没稳住&#xff0c;晋级线差几分”的遗憾。作为一个持续关注竞赛题目变化的开发者&#xff0c;我把这届省B组的整体风格、高频考点和容易踩的坑整理成这篇笔记。蓝桥杯省赛B组是…

作者头像 李华