
把 DE2-115 这块板子拿来跑红外解码是我带 FPGA 课程设计时最常推荐的题目之一。原因很简单红外遥控器常见、成本低、现象直观但要把 NEC 协议在硬件上完整走一遍涉及边沿检测、时序测量、状态机、移位寄存器、数码管显示甚至 UART 调试几乎把数字逻辑的核心知识点串起来了。这篇文章不打算只给一份能跑的代码而是把 DE2-115 和红外解码这个组合里最容易被忽略的细节拆开讲清楚包括接收头输出波形和发射端为什么是反的、状态机里计数器按什么基准走、为什么有时候按键按一下会解出两三个码以及用 SignalTap 抓内部波形的方法。适合 FPGA 入门者、正在做课程设计的学生以及想折腾一块板子找回硬件手感的人。1. 解码对象NEC 协议的完整时序关系1.1 接收头输出与发射波形的反相关系市面上绝大多数红外遥控器走的都是 NEC 协议载波频率 38kHz。这个频率不是随便定的主要是兼顾发射管效率、环境光干扰抑制和接收距离。遥控器按键后发射管出来的是一串 38kHz 方波脉冲每一段载波脉冲对应协议里的一个“逻辑高”但这里有个关键点我们做实验用的是一体化接收头比如 VS1838B、HS0038、TSOP38238 这类它们内部集成了光敏二极管、自动增益控制、带通滤波解调和整形输出所以引脚上输出的已经是解调后的数字电平。值得注意的是接收头输出逻辑和发射端是反的没有红外信号时输出是高电平收到 38kHz 载波脉冲时输出被拉低。这个反相关系是新手第一道坎。如果你用示波器同时看遥控器发射管阳极波形和接收头 OUT 引脚会发现发射端“有载波”对应的恰好是接收头输出“低电平”。所以在设计状态机时不要总想着“高电平代表有数据”而应该先明确我们采集到的 ir_in 信号低电平段代表载波突发高电平段代表静默间隔。1.2 引导码、数据位和重复码的宽度判定NEC 协议一帧数据的结构很规整。先是引导码也就是 9ms 的载波突发接着 4.5ms 静默然后是高 8 位地址码、8 位地址反码、8 位命令码、8 位命令反码一共 32 位数据每字节都是 LSB最低位优先发送最后还有一个 562.5us 的结束脉冲。数据位的区分方式和很多人直觉不一样它不是直接看脉宽而是看两个下降沿之间静默间隔的长度。逻辑 0 是 562.5us 载波加 562.5us 静默总宽度 1.125ms逻辑 1 是 562.5us 载波加 1.6875ms 静默总宽度 2.25ms。也就是说每个数据位的载波突发段宽度都一样区别在于载波结束后的“高电平保持时间”。另外如果遥控器按键一直按住NEC 协议不会连续重复整帧 32 位数据而是每 110ms 左右发一个重复码。重复码的结构是 9ms 载波加 2.25ms 静默没有后续 32 位数据。很多第一次做实验的同学会疑惑“我明明没松键为什么后续数据抓不到了”这就是原因。设计时应该把重复码单独处理通常做法是保留上一次成功解码的键值只是在界面上让显示短暂闪烁或更新一个时间戳。1.3 本次实验解码目标与临界值设计做 FPGA 实验不能像写软件那样“差不多就行”每个时序阈值都要落成具体数字。假设我们以 1us 为计数节拍那么引导码低电平目标 9000us判定范围建议 8000 到 10000us。引导码后高电平目标 4500us判定范围 3500 到 5500us。重复码后高电平目标 2250us判定范围 1800 到 3000us。数据位低电平目标 562.5us判定范围 200 到 900us。数据位高电平判定小于 1300us 判为逻辑 0大于 1400us 且小于 3000us 判为逻辑 1。这里的判定范围为什么要放宽因为遥控器本身是分立元件振荡产生的载波434 晶振或者陶瓷谐振器都有误差不同品牌遥控器偏差更大。我实测过一批遥控器引导码宽度能在 8.7ms 到 9.4ms 之间浮动。如果阈值卡得太死换个遥控器立刻失灵。但也不能无限制放宽比如高电平和逻辑 1 的区间如果重叠解出来的位就可能错位。2. 在 DE2-115 上搭建硬件与时钟基准2.1 红外接收管的供电与信号接线DE2-115 的 GPIO 扩展口上有 5V 和 3.3V 两种电源引脚。接收头 VS1838B 的供电范围比较宽3.3V 到 5V 都能工作但我建议接 3.3V。原因有两个第一DE2-115 的 FPGA IO 管脚供电通常是 3.3V接收头输出高电平大概是 VCC 减去 0.7V 左右接 3.3V 时输出高电平在 2.6V 以上FPGA 识别没有压力第二直接用 5V 供电虽然接收头不会烧但输出高电平超过 3.3V长期使用对 Cyclone IV 的 IO 不够友好还得额外加分压或者加限流。接线就是三根线VS1838B 的 OUT 引脚接到 GPIO 的一个普通 IOVCC 接 3.3VGND 接 GND。接收头的平面侧朝向自己从左到右一般是 OUT、GND、VCC不同封装顺序可能不一样插线前最好看一眼数据手册。信号线尽量短一些红外接收头周围不要走高频时钟线否则容易把噪声耦合进解码电路。2.2 用 50MHz 主时钟产生 1MHz 节拍DE2-115 板载晶振是 50MHz周期 20ns。理论上可以直接用 50MHz 去做时序测量但计数器位宽会很大阈值计算也费劲。比如 9ms 引导码在 50MHz 下是 450000 个时钟周期需要 19 位计数器而用 1us 作为节拍9ms 就是 9000计数器只要 14 位。虽然 FPGA 里位宽多几位不是大事但调试时看 SignalTap 波形十进制数字越直观越好。所以我的做法是先在顶层分频出一个 1us 节拍信号 tick_1us整个解码状态机都基于这个节拍工作。reg [5:0] clk_div; wire tick_1us; always (posedge clk or negedge rst_n) begin if (!rst_n) clk_div 0; else if (clk_div 6d49) clk_div 0; else clk_div clk_div 1; end assign tick_1us (clk_div 6d49);计数器可以每次tick_1us为高时加 1其他时间保持不变。需要注意的是这个分频模块本身不要用异步复位以外的复杂逻辑保持简单因为它是全局时序基准任何毛刺都会直接传导到解码状态机。2.3 DE2-115 板载资源做显示与复位DE2-115 上适合做这个实验的资源很充裕。时钟用板载 50MHz 晶振复位可以用 KEY[0]它是低电平有效的按键按下时给rst_n拉低非常符合 FPGA 常用复位风格显示用拨码开关 SW[0] 切换查看地址码和命令码数码管 HEX0 到 HEX7 可以一次性把 32 位数据完整显示出来LEDR[0] 可以作为接收状态指示当 ir_in 为低时点亮方便不接示波器也能判断接收头是否工作。我建议在顶层模块里把这些资源都用上不要只做一个“黑盒解码器”。课程设计答辩或者自己验证时一个 LED 状态灯加上八个数码管比在 SignalTap 里翻波形高效得多。即使你最终目的是写一个更复杂的多功能遥控项目这套“最小显示系统”也能复用。3. Verilog 状态机从毛刺滤除到数据结构3.1 输入同步与毛刺滤除红外接收头输出信号不能直接进状态机。原因首先是跨时钟域问题接收头不是由 FPGA 时钟驱动的信号变化与 50MHz 时钟边沿没有确定关系直接采可能采到亚稳态。其次是环境红外干扰会产生极窄的毛刺如果不滤除状态机会被误触发。最基本的处理是两级同步器把信号打两拍降低亚稳态概率。这还不够我习惯再加一个简单的宽度滤波用计数器统计连续低电平的周期数只有低电平持续时间超过一定阈值比如 20 个 50MHz 时钟周期也就是 400ns 左右才认为这是一个有效的低电平事件。这个阈值要远小于 562.5us 的最小载波脉冲宽度所以不会吃掉正常数据同时又能滤掉大多数环境光突变和电源噪声引起的短毛刺。reg [1:0] ir_sync; always (posedge clk or negedge rst_n) begin if (!rst_n) ir_sync 2b11; else ir_sync {ir_sync[0], ir_in}; end reg [4:0] noise_cnt; reg ir_clean; always (posedge clk or negedge rst_n) begin if (!rst_n) begin noise_cnt 0; ir_clean 1; end else if (ir_sync[1]) begin noise_cnt 0; ir_clean 1; end else if (noise_cnt 5d20) begin ir_clean 0; end else begin noise_cnt noise_cnt 1; ir_clean 1; end end这段代码的意思只要输入为高就认为当前是空闲间隔输入变低后连续 20 个时钟周期仍然保持低才确认是有效低电平。ir_clean输出一个干净的解调信号后续状态机和边沿检测都基于它。3.2 边沿触发的宽度测量状态机解码的核心是一个测量高低电平宽度的状态机。我常用状态机的状态包括等待引导码、测量引导码低电平、测量引导码后高电平、逐位测量数据位低电平、逐位测量数据位高电平、处理重复码和输出完成。很多教材会把每个电平单独拆成状态这里我建议用“测量中”和“测量结束判定”两个阶段代码会清晰很多。核心状态转移逻辑大致是这样空闲时如果检测到 ir_clean 下降沿说明引导码载波段开始进入引导码低电平测量状态同时启动计数器。引导码低电平状态下如果 ir_clean 变为高就检查计数器值是否落在 8000 到 10000us 区间。满足则进入引导码高电平测量否则回空闲。引导码高电平状态下如果 ir_clean 变为低检查高电平持续时间落在 3500 到 5500us 则开始收数据位落在 1800 到 3000us 则判定为重复码其他值返回空闲。数据位低电平状态下等待 ir_clean 变高检查低电平是否在 200 到 900us这一步是兜底防止误码。数据位高电平状态下等待 ir_clean 变低根据高电平持续时间判决 0 或 1把结果移入移位寄存器然后继续下一个数据位直到收满 32 位。用文字描述状态机总觉得抽象代码会直接一些。下面是我在实际工程里用的一个精简状态转移段localparam S_IDLE 4d0; localparam S_LEAD_LOW 4d1; localparam S_LEAD_HIGH 4d2; localparam S_BIT_LOW 4d3; localparam S_BIT_HIGH 4d4; localparam S_REPEAT 4d5; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; end else begin case (state) S_IDLE: begin if (!ir_clean) begin state S_LEAD_LOW; cnt_us 0; end end S_LEAD_LOW: begin if (ir_clean) begin if (cnt_us 8000 cnt_us 10000) state S_LEAD_HIGH; else state S_IDLE; end end S_LEAD_HIGH: begin if (!ir_clean) begin if (cnt_us 3500 cnt_us 5500) state S_BIT_LOW; else if (cnt_us 1800 cnt_us 3000) state S_REPEAT; else state S_IDLE; end end S_BIT_LOW: begin if (ir_clean) begin if (cnt_us 200 || cnt_us 900) begin state S_IDLE; end else begin state S_BIT_HIGH; end end end S_BIT_HIGH: begin if (!ir_clean) begin if (cnt_us 1300) begin shift_reg {shift_reg[30:0], 1b0}; end else if (cnt_us 1400 cnt_us 3000) begin shift_reg {shift_reg[30:0], 1b1}; end else begin state S_IDLE; end bit_cnt bit_cnt 1; if (bit_cnt 5d31) state S_DONE; else state S_BIT_LOW; end end ... endcase end end计数器的更新逻辑我没全部贴出来但思路很明确在测量状态下每来一个 tick_1us计数器加 1状态切换时清零。注意判定分支里千万不要在同一个时钟沿既清零又判定否则会丢一个节拍。3.3 数据位判定和 32 位移位寄存器数据位判决最容易犯的错误是把“低电平宽度”当成判决依据。前面说过数据位区分看的是载波结束后的高电平静默时间。所以在 S_BIT_HIGH 状态下真正有意义的是从 ir_clean 变高到变低之间的计数也就是静默间隔长度。明白了这一点代码里把高电平区间阈值定在 1300us 和 1400us 之间就能把逻辑 0 的 562.5us 和逻辑 1 的 1687.5us 干净分开。32 位数据我建议用带移位的寄存器接收先收到的位放在最低位因为 NEC 协议本身是 LSB 优先。收满后高 8 位是地址码接着 8 位是地址反码再 8 位是命令码最后 8 位是命令反码。校验方法就是把两个反码字节分别和原码按位取反比较如果地址码和地址反码不一致、命令码和命令反码不一致就认为这帧数据无效。这个校验在 FPGA 里是纯组合逻辑成本很低但能过滤掉遥控器按键过程中被手指遮挡产生的半截帧。实际上我更建议把校验做成“在校验通过后只产生一次 done 脉冲”而不是一收满 32 位就立刻输出。因为有些遥控器在按键弹起时也会发一个不完整的尾巴不加校验会看到数码管闪一下错误值。4. 数码管与 UART把解出来的码变成人能看的东西4.1 七段数码管动态扫描DE2-115 上有八个七段数码管正好可以显示地址码和命令码各四个十六进制位。但如果每个数码管都独立保持数据再译码位宽会很大。实际工程里几乎都用动态扫描用 1kHz 左右的频率轮流点亮每个数码管每次只输出当前位的段码和位选由于人眼视觉暂留看起来就是同时亮的。扫描计数器直接复用解码模块的 50MHz 时钟分频到 1kHz 后产生一个 3 位扫描地址。段码译码用 case 语句把 4 位十六进制数转为 7 段码。这里要提醒一点DE2-115 的七段数码管是低电平点亮还是高电平点亮不同批次板子有点差异建议先看官方 DE2-115 例程里的HEX0赋值方式。如果方向反了现象就是正常显示变成了除了目标笔画以外全亮。我一般把段码输出做成参数化在顶层统一加一个~取反方便切换。4.2 简易 UART 发送模块数码管能用但毕竟只能显示十六进制想完整看地址码、反码、校验结果最好再接一路 UART把解码结果发到电脑串口终端。DE2-115 板载 USB-Blaster 本身不直接提供串口通常用 USB 转 TTL 模块接到 GPIO 的 UART 引脚或者用板子上另外的 UART 接口。UART 发送模块实现起来不复杂关键是分频计数。波特率 115200 时50MHz 时钟下每个 bit 占 434 个时钟周期。发送流程就是拉低 TX 线作为起始位然后按 LSB 到 MSB 发送 8 个数据位最后拉高作为停止位。下面的模块可以作为一个工具模块反复使用module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg txd ); localparam BIT_CNT_MAX CLK_FREQ / BAUD_RATE - 1; reg [15:0] clk_cnt; reg [2:0] bit_cnt; reg tx_busy; reg [7:0] tx_shift; assign txd tx_busy ? tx_shift[0] : 1b1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx_busy 0; tx_shift 0; bit_cnt 0; clk_cnt 0; end else if (!tx_busy) begin if (tx_start) begin tx_busy 1; tx_shift {tx_data, 1b0}; bit_cnt 0; clk_cnt 0; end end else begin if (clk_cnt BIT_CNT_MAX) begin clk_cnt 0; if (bit_cnt 3d7) begin tx_busy 0; end else begin bit_cnt bit_cnt 1; tx_shift {1b1, tx_shift[7:1]}; end end else begin clk_cnt clk_cnt 1; end end end endmodule发送一个字节后必须等待一段时间再发下一个否则调试终端会连续收到两个字节连在一起。发送 ASCII 码的时候把解码得到的 4 位十六进制数加上字符0或A - 10就能直接打印成可读字符。4.3 顶层例化与引脚分配顶层模块的逻辑就是把接收头信号接到解码模块、把解码结果接到显示模块和串口模块。我习惯在顶层里额外加一个数据锁存寄存器解码模块产生 done 脉冲时才把 shift_reg 锁存到展示用的寄存器。这样数码管不会在解码过程中闪烁变化。引脚分配可以用 Quartus 里的 Pin Planner 手动指定也可以直接编辑 QSF 文件。DE2-115 的板载资源引脚在官方手册里都能查到这里列几个我常用的信号名引脚建议说明clkPIN_Y2板载 50MHz 晶振rst_nPIN_M23KEY[0]按下为低sw_disp_selPIN_AB28SW[0]切换显示地址/命令led_recvPIN_G19LEDR[0]指示接收状态ir_in按 GPIO 实际连接分配红外接收头 OUT 引脚GPIO 的具体引脚号取决于你插线的位置不要硬背网上的表格打开 Pin Planner 看当前分配的引脚旁边提示的实际位置或者直接看 DE2-115 用户手册里的 GPIO 引脚对照表。最稳的做法是先用万用表量一下 GPIO 排针的丝印编号再在 Quartus 里把对应信号分配到那个引脚。5. 实测与排错从 SignalTap 波形到常见翻车现场5.1 用 SignalTap 抓内部分量代码写完后光靠板载 LED 调试还不够特别是指望找到“为什么某一位判断错了”这种问题。Quartus 自带的 SignalTap II 逻辑分析仪这时候就很有用它会把需要观察的信号实时抓下来直接显示成波形不会占用额外 IO 引脚。建议抓的信号包括ir_in、ir_clean、state、cnt_us、bit_cnt、shift_reg。触发条件可以设成 done 脉冲的上升沿或者!ir_clean下降沿。采样深度设 16K 就够因为 NEC 一帧数据从头到尾也就 70ms 左右1us 节拍下大约 7 万个采样点。如果采样频率太高比如直接用 50MHz 采样16K 深度只能抓 328us远远不够所以 SignalTap 采样时钟用 50MHz 没问题但要设触发位置在“pre trigger”且深度足够。我习惯让信号进入 SignalTap 之前先做一次跨时钟域同步避免抓到的信号在波形边缘出现不稳定值。5.2 遥控按一下却解出多个码的原因最常见的现象是按键一次数码管连续刷新了好几次甚至串口打印出好几行相同数据。这不是解码模块自己乱跳而是遥控器本身的问题。很多遥控器在按下和弹起时除了发送正常帧外还会先发送一次重复码或者把数据帧重复两次。NEC 协议规范里一次按键对应的发送序列可以是引导码加 32 位数据帧某些遥控器还会在松开时补一个引导码。解决办法不是去限制协议而是在顶层做一次“重复抑制”。最简单的是用一个延时计数器解码完成产生 done 后20ms 内不再接受新的 done 输出。因为正常情况下重复码间隔至少 100ms 以上这个 20ms 窗口能挡住同一帧数据里多个重复完成脉冲又不会影响真正的新按键。更精细的做法是识别到重复码时仅仅更新时间戳不更新显示寄存器。5.3 状态卡死不复位的处理有同学会遇到这种情况第一次按键正常第二次按键就没反应了按复位键又能继续解码。这多半是状态机进入了某个分支没有设计返回 IDLE 的路径。常见原因是数据位计数 bit_cnt 在达到 31 进入 S_DONE 后S_DONE 状态里没有在下一个时钟沿回到 S_IDLE或者回跳条件依赖了一个只存在一个时钟周期的信号导致状态机停在 S_DONE 不肯走。我的经验是所有非正常分支都必须有明确的“回退到 IDLE”动作。写状态机时最后用 default 分支兜底把未枚举的状态全部拉回 S_IDLE。另外S_DONE 可以作为一个只维持一个周期的高电平脉冲同时强制state S_IDLE。如果你后续还想在 S_DONE 里做延时显示那就要另加一个状态不要用同一个状态既做输出又做等待。5.4 电磁干扰和供电不干净的典型症状红外解码的常见故障里有一种特别难排查解码结果偶尔错一位比如命令码 0x45 解成了 0x47。查来查去发现代码逻辑没问题最后把接收头挪了个位置就好了。这是因为红外接收头对电源纹波比较敏感DE2-115 的 GPIO 口如果同时带动多个模块3.3V 上会有明显毛刺接收头输出端就会被叠加噪声。典型症状是ir_clean在数据位静默期间出现 1us 级别的毛刺高电平被毛刺打断静默长度测量偏差。应对策略有三条接收头电源引脚旁边加一个 10uF 和 0.1uF 去耦电容信号线尽量避开板上的电源走线实在不行就提高滤波阈值但注意不要超过 20us否则会把 562.5us 的载波脉冲边界吃掉。另一个常见问题是手放在接收头附近时解码失败。接收头的 AGC 电路会检测环境红外水平手靠近会反射红外信号导致载波突发被拉长引导码低电平宽度超过 10000us 判定上限。这种情况不是代码 bug而是实验环境的物理特性换个方向、远离桌面反光物体就好。5.5 SignalTap 辅助定位的具体排查链路如果真的遇到数据位错乱我推荐的排查顺序是先抓引导码确认 ir_clean 第一个低电平宽度是否在 8000 到 10000us 范围内再看引导码后高电平宽度然后确认第一个数据位的低电平宽度是否在 200 到 900us最后重点看第一个数据位的高电平宽度落在哪个区间。四段下来基本就能定位是状态机跳转条件错误还是某个阈值设得太紧。有一次我自己的工程就是这样引导码和首位都正常但第二个数据位开始全部错位。用 SignalTap 一看才发现问题出在我把 tick_1us 分频模块的复位信号和解码状态机的复位信号接反了导致内部计数器计数节拍不对有些状态多计了 1us。这类问题只看代码很难发现必须靠波形。6. 后续还能往哪些方向扩展红外解码做完之后很多人喜欢顺手再加点功能。我实际试过的两个扩展方向都觉得挺有价值。第一个是把解码得到的命令码通过 PWM 或者蜂鸣器做出来比如按遥控器数字键 1 到 8FPGA 驱动蜂鸣器产生对应的音阶这样整个系统就从“能解码”变成了“能响应”也更接近真实产品。第二个是扩展多个红外接收头放在不同方向然后根据哪个接收头先收到信号判断遥控器大致方位这套原理在智能家居的语音和遥控定位里都能用上。如果想把逻辑做得更工程化可以把解码模块封装成 AXI-Stream 或者简单的 FIFO 接口这样后续接 Nios II 软核或者 RISC-V 软核时CPU 可以直接读取解码结果而不是在硬件里堆一堆显示逻辑。FPGA 实验做到后面模块化意识的比重会逐渐超过具体语法知识红外解码这个题目刚好能让你体会一次完整的“硬件架构设计”流程。另外想提一个我在实际项目里反复用到的经验给状态机里的每个状态命名时不要图省事用 S0、S1、S2而是直接写 S_LEAD_LOW、S_BIT_HIGH 这种能看懂的名字。刚开始多敲几个字母排错时能省大量时间。SignalTap 里看状态值如果能直接看到状态名比对着状态编码表翻译舒服得多。最后分享一个小技巧如果手头没有遥控器可以用手机红外发射口或者另一个单片机加红外发射管模拟 NEC 波形来测试 FPGA 解码模块这样不依赖实体遥控器故障定位更可控。DE2-115 的红外解码实验说穿了就是教你怎么把一段连续的、带噪声的物理信号变成一组干净的二进制数据。这个能力不只是为了对付课程设计以后做传感器采集、通信协议解析都会反复用到。