1. 项目概述从遥控器到数码管的红外解码之旅搞硬件的朋友手边或多或少都有几块FPGA开发板。学完了点灯、按键消抖、数码管显示这些基础操作后总想找个有点意思又不太复杂的项目练练手把之前零散的知识点串起来。红外遥控解码就是一个绝佳的选择。它几乎是我们身边最常见的无线通信方式之一从电视、空调到各种智能家电无处不在。自己动手用FPGA实现一个红外接收解码器不仅能深入理解这种通信协议的时序精髓还能顺带巩固状态机设计、时钟分频、边沿检测等核心的FPGA开发技能。这个项目的目标很明确使用一个普通的红外接收头比如常见的HS0038接收来自遥控器发射的红外编码信号通过FPGA内部的逻辑进行解码最终将解码得到的32位原始码值实时显示在四位数码管上。整个过程从红外光的调制与解调到数字信号的捕获与解析再到最终的人机界面显示形成了一个完整的信号链。对于初学者而言成功看到数码管上随着遥控器按键而变化的十六进制数字时那种成就感是单纯仿真通过无法比拟的。它让你真切地感受到自己写的代码正在物理世界里与真实的电子元件进行交互。2. 红外遥控协议基础与解码核心思路在动手写代码之前我们必须先搞清楚“敌人”的通信规则。市面上绝大多数消费电子产品的红外遥控都采用一种被称为“NEC协议”或其变体的编码格式。虽然不同厂商可能有细微调整但基本框架大同小异。理解这个协议是解码成功的前提。2.1 NEC协议帧结构解析一次完整的按键按下遥控器发出的不是一个简单的电平信号而是一帧精心设计的数据包。这帧数据主要包含以下几个部分引导码这是一帧数据的“开场白”用于唤醒接收端并提供一个时间基准。标准NEC协议的引导码由一个持续9ms的38kHz载波脉冲对应接收头输出低电平紧跟一个持续4.5ms的空闲期接收头输出高电平组成。这个独特的9ms4.5ms组合在数据流中非常醒目是帧起始的可靠标志。用户码通常为16位可以理解为遥控器的“身份证号”用于区分不同厂家的设备。比如电视遥控器的用户码和空调遥控器的就不同防止误操作。用户反码紧接在用户码之后是用户码的逐位取反。这提供了一种简单的校验机制接收端可以通过对比用户码和其反码初步判断数据在传输过程中是否出现严重错误。数据码8位代表具体的按键值。例如音量加、电源键等都有其唯一的数据码。数据反码8位是数据码的逐位取反作用与用户反码类似用于校验数据码。因此一帧完整的数据通常是引导码9ms低4.5ms高16位用户码16位用户反码8位数据码8位数据反码总计约67.5ms引导码13.5ms 32位数据 * 每位约2.25ms。需要注意的是数据发送时是低位LSB在前高位MSB在后。2.2 逻辑“0”与“1”的时空定义解码的关键在于正确识别每一个数据位是“0”还是“1”。NEC协议规定每一位数据都以一个持续0.56ms的38kHz载波脉冲低电平开始这个脉冲被称为“起始位”。区别在于紧随其后的高电平持续时间逻辑“0”起始位后保持高电平0.56ms。逻辑“1”起始位后保持高电平1.68ms。所以每一位的周期是固定的对于“0”是0.56ms低 0.56ms高 1.12ms对于“1”是0.56ms低 1.68ms高 2.24ms。可以看到“1”的周期是“0”的两倍。解码时我们就是在测量起始低电平过后高电平的持续时间。如果高电平持续约0.56ms后变低则该位是“0”如果持续约1.68ms后变低则该位是“1”。2.3 FPGA解码的核心挑战与策略FPGA是并行执行的硬件而红外信号是串行的时序流。我们的核心任务是将这个串行流准确地“翻译”成并行的数据字。这里有几个关键点亚稳态处理红外接收头输出的信号是异步于FPGA系统时钟的。直接用系统时钟去采样这个信号极易产生亚稳态导致误判。必须采用同步器两级或更多级寄存器链进行同步处理。精确计时我们需要测量引导码和数据位的高低电平宽度精度通常在微秒(μs)级别。FPGA的主时钟频率很高如50MHz周期20ns直接用它来计数会得到非常大的计数值。通常的做法是进行合理分频产生一个适合测量红外信号宽度的“采样时钟”。状态机设计解码过程天然是一个顺序流程等待引导码 - 验证引导码 - 接收32位数据 - 输出显示。使用有限状态机来清晰地描述这个过程是最规范、最可靠的设计方法。边沿检测我们关心信号何时从高变低下降沿或从低变高上升沿因为电平的跳变点标志着一位数据的开始或结束。通过寄存信号的历史状态可以精确地检测到这些边沿。基于以上分析我们的解码方案可以规划为首先对红外输入信号进行同步化和边沿检测然后利用一个分频计数器来量化电平持续时间最后用一个状态机控制整个解码流程在适当的边沿时刻根据计数值判断当前是引导码还是数据位并逐位拼接出完整数据。3. 硬件平台搭建与关键模块设计理论清晰后我们开始着手实现。假设我们使用一块常见的FPGA开发板如Altera Cyclone IV或Xilinx Spartan-6核心时钟50MHz板上带有四位数码管。3.1 硬件连接与接口定义硬件连接非常简单红外接收头HS0038VCC- 开发板3.3V或5V注意查看接收头规格GND- 开发板GNDOUT- FPGA某个通用IO引脚例如PIN_E1在代码中定义为IR输入信号。四位数码管通常是共阳或共阴极通过段选led_db[7:0]控制a-g段和dp点和位选led_cs[3:0]控制哪一位亮信号驱动。需要根据原理图确认是共阳还是共阴以及段码表。我们的Verilog模块接口可以定义如下module IR_Receiver ( input wire clk, // 系统时钟如50MHz input wire rst_n, // 低电平有效的全局复位 input wire IR, // 红外接收头信号输入 output reg [3:0] led_cs, // 数码管位选信号 output reg [7:0] led_db // 数码管段选信号 );注意IR信号在空闲时为高电平当接收到38kHz载波时输出低电平。这一点非常重要是后续逻辑判断的基础。3.2 信号同步与边沿检测模块这是确保系统稳定性的第一道关卡。我们使用两级寄存器进行同步再用一级寄存器锁存前一状态用于边沿检测。reg IR_sync0, IR_sync1, IR_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin IR_sync0 1b1; // 空闲时为高 IR_sync1 1b1; IR_sync2 1b1; end else begin IR_sync0 IR; // 第一级同步 IR_sync1 IR_sync0; // 第二级同步此信号可用于稳定判断 IR_sync2 IR_sync1; // 锁存前一状态 end end // 边沿检测信号 wire IR_falling_edge (IR_sync2 1b1) (IR_sync1 1b0); // 下降沿高 - 低 wire IR_rising_edge (IR_sync2 1b0) (IR_sync1 1b1); // 上升沿低 - 高 wire IR_change IR_falling_edge | IR_rising_edge; // 任何跳变这里IR_sync1代表了同步化、稳定后的当前红外信号状态。IR_sync2是IR_sync1上一个时钟周期的状态。通过比较它们就能在信号发生变化的那个时钟周期产生一个周期宽度的脉冲信号IR_falling_edge或IR_rising_edge。这个脉冲是我们后续所有计时和状态转换的触发器。3.3 时钟分频与脉宽测量模块我们需要一个“尺子”来测量高、低电平的持续时间。直接使用50MHz周期20ns计数数值会很大9ms需要计数450,000次。为了简化逻辑和节省资源我们可以先进行一次分频。根据NEC协议最小的计时单位是0.56ms560μs。为了保证测量精度我们希望在0.56ms内至少采样16次。那么采样周期应为 560μs / 16 35μs。我们的系统时钟周期是20ns所以分频系数为 35μs / 20ns 1750。我们设计两个计数器div_counter计数1750个系统时钟产生一个35μs的周期信号div_clk_en。width_counter在div_clk_en有效时计数其值代表了当前电平持续了多少个“35μs”单位。reg [10:0] div_counter; // 计数0-1749需11位 (2048 1750) reg div_clk_en; // 分频使能信号高电平一个系统时钟周期 reg [8:0] width_counter; // 脉宽计数器最大计数值需能覆盖1.68ms (1.68ms/35us48) always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_counter 11d0; div_clk_en 1b0; end else begin if (IR_change) begin // 关键信号跳变时清零分频计数器重新开始测量新电平 div_counter 11d0; div_clk_en 1b0; end else begin if (div_counter 11d1749) begin div_counter 11d0; div_clk_en 1b1; // 每1750个时钟周期产生一个使能脉冲 end else begin div_counter div_counter 1b1; div_clk_en 1b0; end end end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin width_counter 9d0; end else begin if (IR_change) begin // 信号跳变清零脉宽计数器 width_counter 9d0; end else if (div_clk_en) begin // 每35us脉宽计数器加1 width_counter width_counter 1b1; end end end现在width_counter的值就代表了当前电平持续的“35μs”的个数。例如当width_counter计数到大约257时257 * 35μs ≈ 9ms我们就认为检测到了引导码的9ms低电平。实操心得为什么要在IR_change时清零计数器这是解码准确性的生命线。红外信号每次跳变都意味着前一个电平的结束和新电平的开始。此时必须将测量“尺子”归零才能开始测量新电平的宽度。如果忘记清零width_counter会一直累加导致所有测量结果都是错的。3.4 解码状态机设计这是整个解码器的“大脑”。我们设计一个四状态的状态机IDLE空闲状态等待引导码开始检测到持续的低电平。LEADER_LOW已进入引导码低电平阶段等待上升沿并验证低电平宽度是否为9ms左右。LEADER_HIGH已进入引导码高电平阶段等待下降沿并验证高电平宽度是否为4.5ms左右。RECEIVE_DATA引导码验证通过开始接收32位数据。在此状态下根据上升沿和下降沿以及width_counter的值判断每一位是“0”还是“1”。状态转移图文字描述IDLE-LEADER_LOW: 当IR_sync1为低电平时检测到低电平开始。LEADER_LOW-LEADER_HIGH: 当检测到IR_rising_edge且此时width_counter值在9ms对应范围如240-270内。LEADER_LOW-IDLE: 如果IR_rising_edge时width_counter不在9ms范围内说明不是合法引导码回到空闲。LEADER_HIGH-RECEIVE_DATA: 当检测到IR_falling_edge且此时width_counter值在4.5ms对应范围如120-140内。LEADER_HIGH-IDLE: 如果IR_falling_edge时width_counter不在4.5ms范围内回到空闲。RECEIVE_DATA-IDLE: 成功接收完32位数据或者接收过程中出现超时、电平宽度错误等异常。RECEIVE_DATA状态内循环处理每一位数据。localparam [1:0] IDLE 2b00, LEADER_LOW 2b01, LEADER_HIGH 2b10, RECEIVE_DATA 2b11; reg [1:0] current_state, next_state; reg [5:0] bit_cnt; // 数据位计数器0-31 reg [31:0] data_shift_reg; // 32位移位寄存器用于拼接数据 reg data_valid; // 数据接收完成有效标志 // 状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state IDLE; else current_state next_state; end // 下一状态逻辑 always (*) begin next_state current_state; // 默认保持当前状态 case (current_state) IDLE: begin if (IR_sync1 1b0) next_state LEADER_LOW; // 检测到低电平开始 end LEADER_LOW: begin if (IR_rising_edge) begin if ((width_counter 240) (width_counter 270)) // 约9ms范围 next_state LEADER_HIGH; else next_state IDLE; // 引导码错误 end end LEADER_HIGH: begin if (IR_falling_edge) begin if ((width_counter 120) (width_counter 140)) // 约4.5ms范围 next_state RECEIVE_DATA; else next_state IDLE; // 引导码错误 end end RECEIVE_DATA: begin if (bit_cnt 6d32) begin // 收到32位 next_state IDLE; end else if (/* 超时或错误判断 */) begin next_state IDLE; // 接收错误 end end default: next_state IDLE; endcase end3.5 数据位接收与拼接逻辑在RECEIVE_DATA状态下我们需要处理每一位数据。每一位都以一个0.56ms的低电平起始位开始以高电平结束。因此每次IR_rising_edge低电平结束高电平开始我们应检查刚结束的低电平宽度是否约为0.56ms对应width_counter约16。如果不是则说明数据位格式错误可置错误标志。每次IR_falling_edge高电平结束下一位低电平开始我们根据刚结束的高电平宽度来判断该数据位是“0”还是“1”如果width_counter值在“0”的范围内如12-20则该位为0。如果width_counter值在“1”的范围内如40-56则该位为1。如果都不在则数据错误。由于数据是低位在前我们需要将接收到的位从右向左或从左向右移位后反向拼接到一个32位寄存器中。// 在RECEIVE_DATA状态下的输出逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin bit_cnt 6d0; data_shift_reg 32d0; data_valid 1b0; end else begin data_valid 1b0; // 默认清零 case (current_state) IDLE, LEADER_LOW, LEADER_HIGH: begin bit_cnt 6d0; data_shift_reg 32d0; end RECEIVE_DATA: begin if (IR_rising_edge) begin // 检查起始低电平宽度是否合法约0.56ms if (!((width_counter 12) (width_counter 20))) begin // 错误处理可跳回IDLE或记录错误 bit_cnt 6d0; data_shift_reg 32d0; end end else if (IR_falling_edge) begin // 根据刚结束的高电平宽度判断数据位 if ((width_counter 12) (width_counter 20)) begin // 判断为“0” data_shift_reg {1b0, data_shift_reg[31:1]}; // 右移新位在LSB注意顺序 bit_cnt bit_cnt 1b1; end else if ((width_counter 40) (width_counter 56)) begin // 判断为“1” data_shift_reg {1b1, data_shift_reg[31:1]}; // 右移 bit_cnt bit_cnt 1b1; end else begin // 宽度不符数据错误 bit_cnt 6d0; data_shift_reg 32d0; end // 检查是否收满32位 if (bit_cnt 6d31) begin // 注意这里是在收到第32位后bit_cnt会变成32 data_valid 1b1; // 产生一个时钟周期的有效脉冲 end end end endcase end end重要纠偏与技巧上面代码中data_shift_reg的拼接方式{1‘bX, data_shift_reg[31:1]}是错误的。这实现了右移但新位进入了最高位MSB而协议是低位在前。正确的低位在前接收方式有两种左移新位放在最低位data_shift_reg {data_shift_reg[30:0], new_bit};这样第一个收到的位最终会在data_shift_reg[0]符合直观。但显示时可能需要根据你的理解调整顺序。右移新位放在最高位最后整体反转data_shift_reg {new_bit, data_shift_reg[31:1]};接收完后data_shift_reg[0]是最后收到的位。为了得到正确的顺序可以最后执行一次data_shift_reg reverse_bits(data_shift_reg);。第一种左移方式更直观推荐使用。3.6 数码管动态扫描显示模块解码得到的32位数据data_shift_reg需要显示。我们将其分成4个8位字节分别对应用户反码、用户码、数据反码、数据码根据协议顺序调整。然后驱动四位数码管进行动态扫描显示。动态扫描的原理是利用人眼视觉暂留快速轮流点亮每一个数码管。只要扫描频率足够快60Hz看起来就像是同时点亮。reg [15:0] display_data; // 假设我们只显示用户码和数据码共16位4个十六进制数 reg [3:0] digit_sel; // 位选寄存器 reg [19:0] scan_cnt; // 扫描计数器 reg [7:0] seg_data; // 当前要显示的段码 // 将32位数据中需要显示的部分提取出来例如显示用户码(16-31位)和数据码(0-15位)的低8位 always (posedge clk or negedge rst_n) begin if (!rst_n) begin display_data 16h0000; end else if (data_valid) begin // 当解码完成时锁存显示数据 display_data {data_shift_reg[23:16], data_shift_reg[7:0]}; // 示例显示用户码低8位和数据码 end end // 扫描计数控制切换频率 always (posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt 20d0; else scan_cnt scan_cnt 1b1; end // 位选信号生成循环左移或右移 always (posedge clk or negedge rst_n) begin if (!rst_n) digit_sel 4b0001; // 从第一位开始 else if (scan_cnt 20hFFFFF) begin // 约每10ms切换一次根据时钟频率调整 digit_sel {digit_sel[2:0], digit_sel[3]}; // 循环左移 end end // 根据位选信号选择当前要显示的4位二进制数并转换为段码 always (*) begin case (digit_sel) 4b0001: seg_data hex_to_seg(display_data[3:0]); // 显示最低位 4b0010: seg_data hex_to_seg(display_data[7:4]); 4b0100: seg_data hex_to_seg(display_data[11:8]); 4b1000: seg_data hex_to_seg(display_data[15:12]); // 显示最高位 default: seg_data 8hFF; // 全灭 endcase end // 段码输出 assign led_db seg_data; // 位码输出注意共阳/共阴 assign led_cs digit_sel; // 假设共阴数码管低电平选中 // 十六进制到7段数码管译码函数共阴为例 function [7:0] hex_to_seg; input [3:0] hex; begin case (hex) 4h0: hex_to_seg 8h3F; // 0 4h1: hex_to_seg 8h06; // 1 4h2: hex_to_seg 8h5B; // 2 4h3: hex_to_seg 8h4F; // 3 4h4: hex_to_seg 8h66; // 4 4h5: hex_to_seg 8h6D; // 5 4h6: hex_to_seg 8h7D; // 6 4h7: hex_to_seg 8h07; // 7 4h8: hex_to_seg 8h7F; // 8 4h9: hex_to_seg 8h6F; // 9 4hA: hex_to_seg 8h77; // A 4hB: hex_to_seg 8h7C; // b 4hC: hex_to_seg 8h39; // C 4hD: hex_to_seg 8h5E; // d 4hE: hex_to_seg 8h79; // E 4hF: hex_to_seg 8h71; // F default: hex_to_seg 8h00; endcase end endfunction4. 系统集成、调试与深度优化将上述所有模块整合到顶层模块中并进行引脚分配、编译综合就可以下载到FPGA进行测试了。但第一次往往不会那么顺利调试是必不可少的环节。4.1 系统集成与引脚分配顶层模块就是例化各个子模块并连接信号。在Quartus II或Vivado中需要为clk、rst_n、IR、led_cs、led_db分配实际物理引脚。clk连接板载晶振引脚rst_n连接按键IR连接接收头输出脚led_cs和led_db连接数码管的位选和段选引脚。务必查阅开发板原理图进行正确分配。4.2 调试技巧与常见问题排查问题数码管无任何显示。排查首先检查硬件连接特别是红外接收头的VCC和GND是否接反。用示波器或逻辑分析仪探测IR引脚按下遥控器时应该能看到一串明显的脉冲波形。如果没有可能是接收头损坏或供电问题。如果有波形但解码失败进入下一步。问题按下遥控器数码管显示乱码或固定值不变。排查这通常是解码逻辑或时序问题。检查同步和边沿检测可以通过SignalTap II或ChipScope等嵌入式逻辑分析仪抓取IR_sync1、IR_falling_edge、IR_rising_edge信号。确保边沿检测脉冲在信号跳变时正确产生且只有一个时钟周期宽度。检查脉宽计数器抓取width_counter在IR_change时是否被清零在电平持续期间是否正常递增。在引导码低电平结束时它的值是否在预期的9ms范围内例如对于35μs单位9ms对应257左右。检查状态机抓取current_state信号观察其是否按照IDLE - LEADER_LOW - LEADER_HIGH - RECEIVE_DATA - IDLE的路径正确跳转。如果卡在某个状态检查该状态的转移条件。检查数据拼接在RECEIVE_DATA状态抓取bit_cnt和data_shift_reg。观察bit_cnt是否从0递增到31以及data_shift_reg是否随着每个IR_falling_edge正确移位。特别注意低位在前的拼接顺序是否正确。问题同一个按键每次显示的码值不稳定最后几位跳动。排查这是最常见的时序容错问题。我们的width_counter判断条件如(width_counter 240) (width_counter 270)范围设置得太窄。由于遥控器晶振误差、接收头响应偏差以及我们分频计数的误差实际脉宽会有波动。需要适当放宽判断范围。例如9ms的判断可以放宽到200~3000.56ms的判断可以放宽到10~251.68ms的判断可以放宽到35~60。具体范围需要通过实验观察正常信号下width_counter的实际分布来确定。问题遥控器必须离得很近才有效或者反应迟钝。排查检查红外接收头是否被环境光干扰尤其是日光灯和太阳光。可以尝试给接收头加一个简单的遮光罩。另外确保程序中没有因为错误标志导致频繁复位解码过程。4.3 深度优化与功能扩展基础功能实现后可以考虑以下优化和扩展让项目更完善增加连发码处理很多遥控器按住按键不放时会先发送一帧完整数据之后每隔约110ms发送一个简短的重复码通常为9ms低电平 2.25ms高电平 0.56ms低电平。可以在状态机中增加一个REPEAT状态来识别和处理这种重复码实现按住连续响应的效果。添加校验与错误恢复目前代码在遇到宽度不符时会直接复位。可以增加更健壮的机制比如连续错误计数超过阈值才复位。或者利用用户码/数据码的反码进行校验只有校验通过的数据才更新显示。显示格式优化将32位数据显示为8个十六进制数或者将用户码和数据码分开显示并增加一些标识符如小数点来区分。串口输出除了数码管显示还可以将解码出的数据通过UART发送到电脑串口助手方便记录和分析不同遥控器的编码。学习与存储功能向“红外学习机”迈进将解码得到的用户码和数据码存储到FPGA内部的ROM或外部的EEPROM中。然后设计一个发射电路用一个IO口驱动红外发射管将存储的编码按照NEC协议格式重新发射出去这样就可以模拟原遥控器实现万能遥控或智能联动的功能。这需要另一个状态机来精确生成38kHz载波和调制信号。4.4 参数计算与容差设计回顾最后我们系统性地回顾一下核心参数的计算与选择思路这是硬件调试的基石系统时钟T_clk 20ns (50MHz)。最小测量单位T_unit 0.56ms / 16 35μs。选择16次采样是经验值在分辨率和计数器位宽间取得平衡。分频系数N_div T_unit / T_clk 35μs / 20ns 1750。脉宽计数器理论值引导码低电平9ms9ms / 35μs ≈ 257引导码高电平4.5ms4.5ms / 35μs ≈ 129数据位起始低电平0.56ms0.56ms / 35μs ≈ 16数据位“0”高电平0.56ms16数据位“1”高电平1.68ms1.68ms / 35μs ≈ 48容差范围设计考虑到各方误差实际判断时不能使用精确值必须设置一个范围。例如check_9ms: (240 width_counter 270)// ±10%check_4ms: (115 width_counter 145)// ±10%check_low: (12 width_counter 20)// ±25%check_high: (40 width_counter 56)// ±15%范围的设计需要保守一点特别是对于引导码的判断可以严格一些范围窄对于数据位的判断可以宽松一些范围宽因为数据位更容易受到干扰。最佳的容差范围需要通过实际测试采集大量样本的width_counter值观察其分布情况后最终确定。红外解码是一个经典的时序数字电路设计案例它完美结合了同步设计、状态机、计数器、边沿检测等基础知识点。通过这个项目你不仅能收获一个看得见摸得着的成果更能深刻理解如何用硬件描述语言去“对话”真实的物理信号。当数码管上终于稳定地显示出遥控器按键对应的码值时之前调试的所有焦虑都会瞬间化为进阶的底气。