1. 项目缘起为什么要在FPGA上折腾增量式编码器最近在做一个运动控制相关的项目核心需求是实时、高精度地读取电机的速度和位置。市面上现成的运动控制卡或者带编码器接口的MCU比如STM32的定时器编码器模式当然能用但有两个痛点一直让我不太满意一是响应延迟尤其是在多轴协同、需要高速同步的场景下MCU的中断处理和软件滤波总会引入几个微秒甚至更长的抖动二是灵活性当编码器线数特别高或者需要实现一些特殊的滤波算法、位置补偿逻辑时通用芯片的固定硬件逻辑就显得捉襟见肘了。于是我把目光投向了FPGA。FPGA的并行处理能力和可定制的硬件逻辑让它天生就是处理这种高速、确定性时序信号的绝佳平台。一个典型的增量式编码器接口本质上就是两路A、B相或三路A、B、Z相正交方波信号的边沿检测、方向判断和脉冲计数。用FPGA来实现我们可以把整个解码逻辑做成一个纯粹的硬件模块响应速度是纳秒级的并且可以轻松集成到更大的运动控制系统中作为其中一个IP核来调用。这个项目就是要把这个想法落地。目标很明确设计一个在FPGA内部实现的、稳定可靠的增量式编码器解码接口。它不仅要能正确计数还要能应对实际工业环境中常见的信号抖动、噪声干扰并且输出易于后续处理的位置和速度信息。下面我就把自己从方案选型、Verilog编码、仿真验证到实际上板调试的完整过程以及踩过的那些坑详细拆解一遍。2. 核心原理与需求拆解增量式编码器信号到底在说什么在动手写代码之前我们必须彻底吃透增量式编码器的工作原理。这不是照搬教科书而是理解其电气特性和时序关系这是我们设计硬件逻辑的基石。一个增量式编码器通常输出两路相位差90度的方波信号A相和B相有些还会带一个每转一圈输出一个脉冲的零位信号Z相。A、B两相信号的相位关系直接指明了旋转方向正转顺时针A相领先B相90度。在A相的上升沿B相为低电平在A相的下降沿B相为高电平。反转逆时针B相领先A相90度。在A相的上升沿B相为高电平在A相的下降沿B相为低电平。基于这个特性最经典的解码方法就是4倍频解码。我们不仅检测A相或B相的边沿而是同时检测A、B两相的上升沿和下降沿。这样在一个完整的信号周期内A相或B相的一个周期我们可以得到4个计数点从而将编码器的分辨率即可识别的最小位置变化提高4倍。例如一个2500线PPR的编码器经过4倍频后每转能产生2500 * 4 10000个计数。我们的FPGA接口核心任务就是实现这个4倍频计数和方向判断。但需求远不止于此一个工业可用的模块还需要考虑去抖滤波机械振动或电气噪声可能导致编码器输出产生毛刺Glitch。我们的硬件逻辑必须能过滤掉这些短时间的脉冲防止误计数。计数器设计计数器位宽需要根据编码器线数和应用场景比如多圈绝对位置来确定。是使用简单的加减计数器还是需要32位甚至更宽的计数器速度测量除了位置速度信息往往更重要。如何在硬件中实时计算速度是采用M法固定时间测脉冲数还是T法测量固定脉冲数的时间或者更高级的M/T法接口标准化这个解码模块如何与FPGA内部的其他模块如微处理器软核、运动控制逻辑通信是提供一组存储器映射的寄存器还是一个标准的AXI4-Lite从机接口Z相信号处理如何利用Z相信号进行位置清零或圈数计数基于这些需求我决定将整个设计划分为几个层次清晰的子模块而不是写成一个巨大的always块。这样不仅代码可读性好也便于后续调试和功能扩展。3. 硬件逻辑设计从信号输入到可靠计数的全链路我的设计框图大致如下信号输入 - 同步化与去抖 - 边沿检测与状态机 - 方向判断与计数 - 速度计算 - 寄存器输出。我们一步步来看。3.1 信号同步化与数字滤波抵御亚稳态的第一道防线编码器信号来自FPGA芯片外部与FPGA内部的系统时钟是异步关系。直接使用异步信号作为时钟或触发条件是导致亚稳态Metastability的经典原因系统会变得极不稳定。注意任何来自异步时钟域的信号进入当前时钟域时必须至少经过两级寄存器进行同步处理。这是数字电路设计的铁律。因此我设计了一个输入处理模块。首先用两级D触发器对A、B、Z三相输入进行同步化。module encoder_input_sync ( input wire clk, // 系统时钟比如50MHz input wire rst_n, // 异步复位低有效 input wire a_raw, // 原始A相信号 input wire b_raw, // 原始B相信号 input wire z_raw, // 原始Z相信号 output reg a_sync, // 同步后的A相 output reg b_sync, // 同步后的B相 output reg z_sync // 同步后的Z相 ); reg a_meta, b_meta, z_meta; always (posedge clk or negedge rst_n) begin if (!rst_n) begin a_meta 1b0; a_sync 1b0; b_meta 1b0; b_sync 1b0; z_meta 1b0; z_sync 1b0; end else begin // 第一级捕捉亚稳态 a_meta a_raw; b_meta b_raw; z_meta z_raw; // 第二级获得稳定值 a_sync a_meta; b_sync b_meta; z_sync z_meta; end end endmodule同步化之后信号可能还有毛刺。我采用了一个简单的计数器滤波法只有当信号电平保持稳定超过N个系统时钟周期才认为该电平有效。这里N的大小需要根据系统时钟频率和可能出现的毛刺宽度来计算。例如系统时钟50MHz周期20ns假设毛刺宽度小于100ns那么设置滤波深度为5即100ns / 20ns或更大就能有效滤除。// 以A相为例的滤波逻辑片段 reg [2:0] a_filter_cnt; // 假设3位计数器深度0-7 always (posedge clk or negedge rst_n) begin if (!rst_n) begin a_filter_cnt 3‘d0; a_filtered 1’b0; end else begin if (a_sync a_filtered) begin // 信号与当前输出一致计数器清零 a_filter_cnt 3‘d0; end else begin // 信号与当前输出不一致开始计数 if (a_filter_cnt FILTER_THRESHOLD) begin // 计满阈值认为信号稳定变化 a_filtered a_sync; a_filter_cnt 3’d0; end else begin a_filter_cnt a_filter_cnt 1; end end end end3.2 状态机与4倍频解码核心判向逻辑得到干净、同步的A、B相信号a_filt,b_filt后就可以进行解码了。我采用的状态机是基于信号当前状态和上一次状态的比较。首先将a_filt和b_filt组合成一个2位的状态字state {a_filt, b_filt}。编码器的四个稳定状态分别是00,01,11,10。在理想的正转或反转过程中状态会按顺序循环变化。正转的状态顺序是00 - 01 - 11 - 10 - 00 ...反转的状态顺序是00 - 10 - 11 - 01 - 00 ...我们的逻辑就是检测状态的变化。每次时钟上升沿我们将当前状态state与上一次寄存的状态state_prev进行比较。如果变化符合正转序列则产生一个pos_pulse正向脉冲如果符合反转序列则产生一个neg_pulse反向脉冲。这里的关键是状态跳变表的设计。我们需要列出所有可能的{state_prev, state}组合并判断其是否代表有效的正转、反转或错误比如由于噪声导致的非法跳变如00直接跳到11。// 状态跳变判断逻辑片段 reg [1:0] state_prev; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state_prev 2‘b00; pos_pulse 1’b0; neg_pulse 1‘b0; end else begin pos_pulse 1’b0; // 默认无脉冲脉冲只持续一个时钟周期 neg_pulse 1‘b0; state_prev state; // 寄存当前状态 case ({state_prev, state}) // 正转序列00-01, 01-11, 11-10, 10-00 4‘b0001, 4’b0111, 4‘b1110, 4’b1000: begin pos_pulse 1‘b1; end // 反转序列00-10, 10-11, 11-01, 01-00 4’b0010, 4‘b1011, 4’b1101, 4‘b0100: begin neg_pulse 1’b1; end // 其他情况状态不变或非法跳变不产生脉冲 default: begin // 这里可以选择增加一个错误计数器用于诊断 end endcase end end3.3 可逆计数器与位置信息生成得到pos_pulse和neg_pulse后位置计数就很简单了。使用一个可逆计数器pos_pulse时加一neg_pulse时减一。reg [31:0] position_cnt; // 32位位置计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin position_cnt 32‘d0; end else begin case ({pos_pulse, neg_pulse}) 2’b10: position_cnt position_cnt 1; // 正转脉冲 2‘b01: position_cnt position_cnt - 1; // 反转脉冲 default: position_cnt position_cnt; // 无脉冲 endcase end end这里有一个细节计数器位宽。32位对于大多数场景足够了比如4倍频后10000线/转的编码器可以计数约42万圈。如果不够可以扩展到64位。这个position_cnt就是最核心的相对位置信息。对于Z相信号我通常用它来将位置计数器清零或者作为一个圈数计数器的触发条件。处理Z相时也要同步和滤波然后检测其上升沿。在检测到Z相上升沿时可以将一个32位的cycle_cnt圈数计数器加1同时可以选择将position_cnt的低位部分清零实现每圈清零模拟绝对编码器 within one turn。具体逻辑取决于应用需求。3.4 实时速度计算M法测速的硬件实现速度信息对于闭环控制至关重要。我选择了在硬件中实现M法测速即在固定的时间窗口内统计编码器脉冲数。这种方法在高速时精度高低速时误差大但实现简单资源消耗少。我设计了一个速度计算模块它包含一个固定的定时器和一个脉冲累加器。定时器用一个寄存器timer从预设值如TIMER_PERIOD向下计数到0。这个预设值决定了测速周期。例如系统时钟50MHz想让测速周期为1ms则TIMER_PERIOD 50_000_000 * 0.001 50_000。脉冲累加器在定时器计数的这段时间内另一个寄存器pulse_sum对pos_pulse和neg_pulse的净值进行累加正脉冲加1负脉冲减1。速度更新当定时器归零时将pulse_sum的值锁存到速度输出寄存器speed_reg中然后将pulse_sum清零并重启定时器。这样speed_reg的值就代表了上一个测速周期内的净脉冲数其单位是“脉冲数/测速周期”。上位机如CPU读取这个值再根据测速周期和编码器每脉冲对应的位移量就能换算出实际的速度如mm/s或RPM。// M法测速简化逻辑 reg [31:0] timer; reg signed [15:0] pulse_sum; // 有符号数累计正负脉冲净值 reg signed [15:0] speed_reg; // 输出速度值 localparam TIMER_PERIOD 32‘d50_000; // 1ms 50MHz always (posedge clk or negedge rst_n) begin if (!rst_n) begin timer TIMER_PERIOD; pulse_sum 16’d0; speed_reg 16‘d0; end else begin // 定时器逻辑 if (timer 32’d0) begin timer TIMER_PERIOD; speed_reg pulse_sum; // 更新速度值 pulse_sum 16‘d0; // 清零累加器 end else begin timer timer - 1; end // 脉冲累加逻辑 case ({pos_pulse, neg_pulse}) 2’b10: pulse_sum pulse_sum 1; 2‘b01: pulse_sum pulse_sum - 1; default: ; endcase end end实操心得pulse_sum和speed_reg的位宽需要仔细考虑。它取决于测速周期内可能出现的最大脉冲数。如果编码器转速很高测速周期又较长这个值可能很大。需要根据最大转速、编码器分辨率、系统时钟频率来估算并留有一定余量防止溢出。4. 系统集成与寄存器映射让CPU能够访问解码模块在FPGA内部独立运行但我们需要把位置、速度、状态等信息传递给处理器可能是片外的ARM也可能是FPGA内部的软核如MicroBlaze或Nios II。最通用的方式就是通过存储器映射寄存器。我设计了一个寄存器文件包含以下几个关键寄存器REG_POSITION(地址0x00): 32位只读当前位置计数值。REG_VELOCITY(地址0x04): 16位只读最新速度值有符号。REG_CONTROL(地址0x08): 16位读写。bit0: 使能位bit1: 位置计数器清零位写1清零自动归零bit2: Z相动作选择位0:忽略1:用于位置清零其他位保留。REG_STATUS(地址0x0C): 16位只读。bit0: 模块使能状态bit1: Z相触发标志上升沿触发读后清零bit8-15: 错误计数器如状态跳变错误计数。然后实现一个简单的Wishbone或AXI4-Lite从机接口。当CPU发起读操作时根据地址返回对应寄存器的值当CPU发起写操作时根据地址更新REG_CONTROL等可写寄存器。REG_POSITION和REG_VELOCITY的读取需要特别注意因为它们可能在CPU读取的瞬间正在变化。一个常见的做法是使用“双缓冲”或“快照”机制在CPU读地址匹配的时钟周期将计数器的瞬时值锁存到一个专门的输出寄存器中CPU实际读取的是这个锁存值而不是直接读取仍在变化的计数器。这避免了读取到不稳定的中间值。// 寄存器读逻辑示例简化 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rd_data 32‘d0; end else if (rd_en) begin // 读使能信号 case (rd_addr) 2’b00: rd_data position_cnt_snapshot; // 读取位置快照 2‘b01: rd_data {16’d0, speed_reg}; // 读取速度值 2‘b10: rd_data {16’d0, control_reg}; // 读取控制寄存器 2’b11: rd_data {16‘d0, status_reg}; // 读取状态寄存器 default: rd_data 32’d0; endcase end end // 位置快照逻辑在读取位置寄存器地址时锁存当前计数值 always (posedge clk or negedge rst_n) begin if (!rst_n) begin position_cnt_snapshot 32‘d0; end else if (rd_en rd_addr 2’b00) begin position_cnt_snapshot position_cnt; end end5. 仿真验证用ModelSim/QuestaSim确保逻辑正确硬件设计仿真先行。在烧录到板子之前必须用仿真工具进行充分验证。我编写了一个简单的测试平台Testbench主要验证以下几个场景基础功能生成理想的正转、反转A、B相信号观察pos_pulse、neg_pulse、position_cnt是否正确。4倍频验证让编码器缓慢转动比如每100个时钟周期变化一次状态检查在一个完整的电气周期内是否产生了4个计数脉冲。去抖滤波在A、B相信号中插入短时间的毛刺宽度小于滤波阈值验证计数器是否不受影响插入长时间的有效变化宽度大于滤波阈值验证变化能否被正确捕获。Z相处理模拟Z相信号验证位置清零或圈数计数功能是否按预期工作。速度计算用不同频率的脉冲序列模拟不同转速检查speed_reg在固定时间窗口后的值是否正确。寄存器读写模拟CPU的读写操作验证寄存器映射和接口逻辑。仿真的波形图是调试的利器。通过观察信号时序可以清晰地看到状态跳变、脉冲生成、计数器增减的每一个细节确保逻辑在时钟沿的驱动下严格按设计运行。踩坑实录在第一次仿真时我发现当A、B相同时变化时比如从01跳到10跳过了11我的状态机将其误判为一次正转加一次反转导致计数器原地踏步。这在实际中是由于信号偏斜或噪声可能出现的非法状态。我在状态跳变表的default分支里增加了一个错误计数器并在实际代码中对于这种非法跳变选择不产生任何脉冲保持计数器不变。这比产生错误脉冲要安全得多。6. 上板调试与实际问题排查仿真通过后就可以进行上板调试了。这里才是真正挑战的开始。我使用的是Xilinx的Artix-7开发板编码器连接到一个电机上。问题一计数不准偶尔跳变。现象电机匀速转动时读取的位置值增长大体线性但偶尔会回跳一下。排查首先用示波器观察连接到FPGA IO引脚上的A、B相信号。发现信号质量尚可边沿清晰但存在轻微的过冲和振铃。怀疑是输入去抖滤波的阈值设置不当。我最初设置的滤波深度是3个时钟周期60ns 50MHz。将阈值提高到10个周期200ns后问题依旧。回过头检查代码发现了一个低级错误我的去抖滤波逻辑是电平敏感的即信号稳定超过阈值后才变化。但对于编码器信号我们更关心的是边沿。在边沿附近如果存在抖动电平滤波可能会导致边沿检测延迟不一致甚至丢失边沿。对于编码器更好的方法是在同步化后直接对信号进行边沿检测然后对检测到的边沿脉冲进行“脉宽滤波”即只有脉冲宽度大于某个阈值的边沿才被认为是有效的。解决我修改了滤波策略。不再对A、B相信号电平滤波而是对pos_pulse和neg_pulse进行脉宽滤波。只有高电平宽度超过5个系统时钟周期的脉冲才被送到最终的计数器。修改后计数变得非常稳定。问题二高速时计数丢失。现象电机低速运转正常但当转速提高到一定程度后读取的位置增量明显小于实际值。排查这是典型的时序问题。首先检查系统时钟频率。我的设计运行在50MHz周期20ns。分析关键路径。从输入引脚到最终计数器中间经过了同步寄存器、组合逻辑状态判断、计数器累加。我用Vivado的时序分析工具看了一下最关键的路径是状态判断的组合逻辑部分。在高速编码器脉冲下如果A、B相变化很快组合逻辑的延迟可能成为瓶颈导致在下一个时钟沿到来时逻辑还未稳定从而错过一次状态跳变。另一个可能的原因是我的4倍频解码是在50MHz时钟下进行的。这意味着它能处理的最高编码器频率是50MHz / 4 12.5MHz因为每个编码器周期需要4个时钟沿来处理。对于2500线的编码器对应的最高转速是12.5e6 / 2500 / 4 1250 RPS即75000 RPM这看起来很高。但实际上由于前面提到的组合逻辑延迟实际能稳定工作的频率要低得多。解决流水线化将状态判断的组合逻辑拆开插入寄存器做成流水线。虽然这会引入几个时钟周期的延迟但大大提高了系统能运行的最高时钟频率。提高系统时钟如果FPGA资源允许将编码器解码模块用更高的时钟驱动例如100MHz或150MHz。优化状态判断将case语句中的状态判断用查找表LUT实现并检查综合报告确保关键路径被优化。 我采用了方法1和3。将状态判断和脉冲生成也用寄存器打拍形成了两级流水。修改后在100MHz时钟下能够稳定处理更高转速的编码器信号。问题三电源噪声导致误触发。现象电机启停的瞬间位置计数器有时会乱跳几下。排查用示波器观察电机电源线和编码器信号线发现在电机启动大电流时电源上有明显的毛刺这个噪声耦合到了编码器的信号线和地线上。解决这是硬件问题。采取了以下措施在编码器的电源入口处增加磁珠和去耦电容。使用双绞屏蔽线连接编码器并将屏蔽层单点接地到FPGA板子的地。在FPGA的IO引脚上配置使用施密特触发器Schmitt Trigger输入如果该芯片支持这可以提高噪声容限。 硬件优化后此现象基本消失。7. 性能优化与功能扩展思路一个基础可用的解码模块完成后还可以根据具体应用进行优化和扩展更高精度插值倍频对于极高精度的应用可以在FPGA内部利用PLL或DLL对A、B相进行相位插值实现更高的电子倍频如16倍频、32倍频但这需要信号质量非常好。速度观测器对于极低速或需要更平滑速度曲线的场合可以在FPGA内实现一个简单的速度观测器如滑动平均滤波、一阶低通滤波甚至是一个卡尔曼滤波器对M法测得的速度值进行滤波和预测。多通道支持将单通道解码模块参数化例化多个实例轻松支持多轴编码器读取资源消耗线性增加但并行处理能力是MCU无法比拟的。标准总线接口将寄存器接口完善为完整的AXI4-Lite或Avalon-MM接口方便集成到基于这些标准总线的SoC系统中如Xilinx的Zynq或Intel的SoC FPGA。错误诊断与健康监测增加更丰富的状态寄存器持续监控非法状态跳变的次数、信号丢失时间等便于系统进行预测性维护。通过这个项目我深刻体会到FPGA在实时信号处理方面的巨大优势。它将软件中复杂的、时序敏感的任务转化为确定性的硬件并行执行带来了极高的响应速度和可靠性。虽然前期设计和调试需要投入更多精力但一旦完成它就是一个“一劳永逸”的专用硬件性能远超通用处理器。对于有志于深入工业控制、机器人、精密测量等领域的朋友掌握FPGA实现传感器接口这项技能绝对是如虎添翼。