FPGA 32 位字拆成 8 位字节:keep、last 与 ready/valid 背压下的多拍序列化

32 位输入变成 8 位输出,看起来只要做一个移位寄存器。真正接入流接口以后,难点往往不在拆字节,而在三个问题:无效字节要不要占一个输出周期,末包标记到底贴在哪个字节上,下游暂停时内部状态能不能继续走。本文实现一个固定 32→8 的单时钟转换器,并用原生 Vivado XSim 验证这些交互。这里给出的是可复现的 RTL 仿真结果,不是板级实测,也不据此宣称某个器件的最高频率或资源数量。

一、先把接口约定写清楚

输入侧由 s_valid、s_ready、s_data31:0、s_keep3:0 和 s_last 组成。输出侧为 m_valid、m_ready、m_data7:0、m_last。双方只有在时钟上升沿同时满足 valid=1、ready=1,才完成一次传输。输入方一旦提出 valid,在握手前必须保持数据、keep 和 last 不变;输出方同样承担暂停期间保持负载稳定的责任。

s_keepi 对应 s_data8\*i +: 8,输出顺序按字节编号从小到大。这里明确接受任意非零 keep,包括 0101、1010 等稀疏形式;这个规则对末包字和非末包字完全相同,不要求非末包一定是 1111。因此,它是具有明确定义的自定义字节流接口,不应仅凭信号名字就声称符合某个系统的完整 AXI4-Stream 配置约束。

例如输入 32'hD3C2B1A0、keep=1010 时,只输出 B1、D3,两者之间没有"无效字节占位"。若 s_last=1,只有 D3 对应的输出拍携带 m_last=1;若 s_last=0,两拍都不带末包标记。满 keep=1111 则依次输出 A0、B1、C2、D3。这是字节通道的遍历顺序约定,与处理器的内存大小端规则没有隐含绑定。

keep=0000 另作规定:输入仍可在 ready=1 时被消费,但不产生任何输出字节,并在该次输入握手之后的一个周期给出 error=1。连续接收两个零 keep 时,error 可以连续两个周期为高,每个周期分别表示一次错误事件,并不强制在事件之间插入低电平。这个输入即使携带 last,也会被整体丢弃,不会生成无数据的末包标记。若上层必须保留空包或零长度结束语义,就不能直接采用本约定,而应增加专门的控制通道或另定协议。

二、只保存一个字,为什么还能边收边发

内部保存 data_q、keep_q、last_q。keep_q 不是原始输入掩码的永久副本,而是"尚未发送的字节集合"。组合逻辑找到其中最低位置的 1,以此选择输出字节;remaining 则把这个最低有效位清除。remaining=0 表示当前正在展示的字节是该字的最后一个有效字节。

输出发生真实握手时,才把 keep_q 更新为 remaining。下游 ready=0 时,不更新 data_q、keep_q、last_q,组合选择自然保持不变。这一点比"每拍把移位计数器加一"更关键:时钟经过了多少拍,不等于已经传输了多少字节。

输入 ready 有两个成立条件:缓存为空,或者当前字的最后一个有效字节正在被下游接收。第二种情形允许旧字最后一拍输出和新字输入发生在同一个上升沿。因此,时序块先判断输入握手,装载新字;只有没有新输入握手时,才处理单纯的输出推进。若把这两个分支写成互不关联的赋值并让旧字清空覆盖新字装载,就会在最需要提高吞吐率的交界处丢数据。

这种单字缓存结构不是四字节并行吞吐器。满 keep 的输入字至少需要四次输出握手,所以连续满字输入的长期平均速率最多是每四个输出周期一个字。它支持字边界无额外气泡,不代表输入 ready 始终为高。对单有效字节输入,持续有数据且下游始终就绪时,可以达到每拍替换一个字。

本实现存在 m_ready 到 s_ready 的组合路径。如果把很多类似模块串起来,需要检查 ready 链路时序,必要时加入 skid buffer 或寄存器切片,而不是简单把 ready 打一拍后仍沿用原来的存储容量。

三、复位与无效周期

rst 为高有效,内部寄存器在上升沿同步清零;握手信号另外用 rst 组合屏蔽,复位期间不对外表示可接收或可输出。使用时应让 rst 覆盖有效上升沿,不能把短暂脉冲当作可靠的状态清除。复位会放弃已经接收但尚未完全发出的字,测试平台也清空对应的期望队列,不把这些被显式取消的字节算作错误丢失。

m_valid=0 时,m_data 不承诺业务含义;接收方只能在输出握手时读取它。m_last 额外用 m_valid 限定,无有效输出时为零。error 是独立的输入异常指示,不接受 m_ready 背压;如果软件需要读取累计次数,应在外部另加计数器,不能指望软件直接捕获一个时钟周期的指示。

四、完整 RTL

保存为 unpack32.sv。以下代码直接由本次冻结的源文件拼入。

systemverilog 复制代码
`timescale 1ns/1ps
module unpack32 (
  input wire clk, input wire rst,
  input wire s_valid, output wire s_ready,
  input wire [31:0] s_data, input wire [3:0] s_keep,
  input wire s_last,
  output wire m_valid, input wire m_ready,
  output wire [7:0] m_data, output wire m_last,
  output reg error
);
  reg [31:0] data_q;
  reg [3:0] keep_q;
  reg last_q;
  reg [1:0] index;
  wire [3:0] remaining;
  wire final_byte;
  always @* begin
    if      (keep_q[0]) index=2'd0;
    else if (keep_q[1]) index=2'd1;
    else if (keep_q[2]) index=2'd2;
    else               index=2'd3;
  end
  assign remaining=keep_q & ~(4'b0001 << index);
  assign final_byte=(remaining==0);
  assign m_valid=!rst && (keep_q!=0);
  assign m_data=data_q[index*8 +: 8];
  assign m_last=m_valid && last_q && final_byte;
  // A retiring word can be replaced on the same rising edge.
  assign s_ready=!rst && ((keep_q==0) || (m_ready && final_byte));
  always @(posedge clk) begin
    if(rst) begin
      data_q<=0; keep_q<=0; last_q<=0; error<=0;
    end else begin
      error<=0;
      if(s_valid && s_ready) begin
        data_q<=s_data;
        keep_q<=s_keep;
        last_q<=s_last;
        error<=(s_keep==0);
      end else if(m_valid && m_ready) begin
        keep_q<=remaining;
      end
    end
  end
endmodule

五、测试平台如何避免"看起来跑过了"

参考模型没有照抄 DUT 的最低位清除方法,而是在每次输入握手时,把 keep 选择的所有字节展开到期望队列中,再逐拍核对输出数据和 last。输出检查先于本拍输入入队,符合这个有寄存器缓存、没有空缓存组合直通的实现。error 在上升沿之后等待非阻塞赋值生效,再与本拍零 keep 输入事件核对。

定向场景先检查满字与连续背压,然后枚举 0~15 的全部 keep,每种分别搭配 last=0 和 last=1;其中自然包含单字节、连续尾部以及稀疏掩码。接着在一个满字只发出第一个字节后暂停并复位,验证剩余部分被取消。随机段使用固定初值的 xorshift32 产生数据、掩码、last 和 ready,运行 2000 个驱动周期;被阻塞的输入保持原负载,直到实际握手,不靠违反协议来制造伪随机压力。

除了逐字节队列核对,还专门检查上一拍被阻塞的输出是否保持 valid、data、last。末尾主动排空并检查队列为空,防止"只核对已经出现的输出,却没有检查应当出现但永远没出现的输出"。测试还要求至少出现背压、输入输出同拍握手、错误输入和足够多的输出,避免一段无有效活动的仿真也打印 PASS。

保存为 tb_unpack32.sv:

systemverilog 复制代码
`timescale 1ns/1ps
module tb_unpack32;
  reg clk=0; always #5 clk=~clk;
  reg rst=1, s_valid=0, s_last=0, m_ready=0;
  reg [31:0] s_data=0;
  reg [3:0] s_keep=0;
  wire s_ready, m_valid, m_last, error;
  wire [7:0] m_data;
  unpack32 dut(.*);
  reg [7:0] expected_data[0:20000];
  reg expected_last[0:20000];
  integer head=0, tail=0, inputs=0, outputs=0, errors=0;
  integer stalls=0, simultaneous=0, resets=0, k, high;
  reg stalled=0, prev_last;
  reg [7:0] prev_data;
  reg want_error;
  // Reference model uses a queue of bytes, not the DUT mask algorithm.
  always @(posedge clk) begin
    if (rst) begin
      head=0; tail=0; stalled=0; want_error=0; resets=resets+1;
    end else begin
      if (stalled && (!m_valid || m_data!==prev_data || m_last!==prev_last))
        $fatal(1,"STALL_CHANGED");
      if (m_valid && m_ready) begin
        if (head==tail) $fatal(1,"UNEXPECTED_OUTPUT");
        if (m_data!==expected_data[head] || m_last!==expected_last[head])
          $fatal(1,"BYTE_OR_LAST head=%0d got=%02x/%b expected=%02x/%b",
            head,m_data,m_last,expected_data[head],expected_last[head]);
        head=head+1; outputs=outputs+1;
      end
      want_error=s_valid && s_ready && s_keep==0;
      if (s_valid && s_ready) begin
        inputs=inputs+1;
        if (m_valid && m_ready) simultaneous=simultaneous+1;
        if (s_keep==0) errors=errors+1;
        high=-1;
        for(k=0;k<4;k=k+1) if(s_keep[k]) high=k;
        for(k=0;k<4;k=k+1) if(s_keep[k]) begin
          expected_data[tail]=s_data[k*8 +: 8];
          expected_last[tail]=s_last && k==high;
          tail=tail+1;
        end
      end
      stalled=m_valid && !m_ready;
      if(stalled) stalls=stalls+1;
      prev_data=m_data; prev_last=m_last;
    end
    #1;
    if(error!==want_error) $fatal(1,"ERROR_PULSE");
    if(rst && m_valid!==0) $fatal(1,"RESET_VALID");
  end
  task cycle(input reg v,input reg [31:0] d,input reg [3:0] keep,
             input reg last,input reg ready,input reg reset);
    begin
      @(negedge clk); s_valid=v; s_data=d; s_keep=keep;
      s_last=last; m_ready=ready; rst=reset;
      @(posedge clk); #2;
    end
  endtask
  task send(input reg [31:0] d,input reg [3:0] keep,input reg last);
    reg accepted;
    begin
      accepted=0;
      while(!accepted) begin
        @(negedge clk); rst=0; s_valid=1; s_data=d; s_keep=keep;
        s_last=last; m_ready=1;
        @(posedge clk); accepted=s_ready; #2;
      end
    end
  endtask
  integer i, n;
  reg pending=0, accepted;
  reg [31:0] rd, rng=32'h6e71a203;
  reg [3:0] rk;
  reg rl;
  function [31:0] next_random(input [31:0] x);
    reg [31:0] t;
    begin t=x^(x<<13); t=t^(t>>17); next_random=t^(t<<5); end
  endfunction
  initial begin
    cycle(0,0,0,0,0,1);
    send(32'h44332211,4'b1111,1);
    repeat(6) cycle(0,0,0,0,0,0);
    repeat(5) cycle(0,0,0,0,1,0);
    if(head!=tail) $fatal(1,"MISSING_OUTPUT directed");
    for(n=0;n<16;n=n+1) begin
      send(32'hD3C2B1A0,n[3:0],0);
      send(32'h77665544,n[3:0],1);
    end
    repeat(8) cycle(0,0,0,0,1,0);
    send(32'hFEDCBA98,15,1);
    cycle(0,0,0,0,1,0); // consume first byte
    cycle(0,0,0,0,0,0); // stall the remaining packet
    cycle(0,0,0,0,0,1); // discard the in-flight remainder
    repeat(4) cycle(0,0,0,0,1,0);
    for(i=0;i<2000;i=i+1) begin
      @(negedge clk);
      rng=next_random(rng);
      if(!pending && rng[0]) begin
        pending=1; rd=rng; rk=rng[7:4]; rl=rng[8];
      end
      s_valid=pending; s_data=rd; s_keep=rk; s_last=rl;
      m_ready=rng[2] || rng[3]; rst=0;
      @(posedge clk); accepted=s_valid && s_ready; #2;
      if(accepted) pending=0;
    end
    if(pending) send(rd,rk,rl);
    repeat(12) cycle(0,0,0,0,1,0);
    if(head!=tail) $fatal(1,"MISSING_OUTPUT final");
    if(stalls==0 || simultaneous==0 || errors<2 || outputs<100)
      $fatal(1,"COVERAGE_MISSING");
    $display("PASS inputs=%0d outputs=%0d errors=%0d stalls=%0d simultaneous=%0d reset_edges=%0d",
      inputs,outputs,errors,stalls,simultaneous,resets);
    $finish;
  end
  initial begin #100000; $fatal(1,"TIMEOUT"); end
endmodule

六、运行命令与真实日志

本次使用 Windows 上的 Vivado 2022.1。将文件置于同一工作目录,使用 Vivado 命令环境执行:

powershell 复制代码
xvlog -sv unpack32.sv tb_unpack32.sv
xelab tb_unpack32 -s sim_normal
xsim sim_normal -runall

若命令不在 PATH 中,可以从自己的安装目录调用对应的 xvlog.bat、xelab.bat、xsim.bat。三条命令必须在同一工作目录下执行,每一步都应检查退出码和日志。下面是本次正常运行的摘要:

text 复制代码
compile=0 elaborate=0 simulate=0
PASS inputs=661 outputs=1305 errors=40 stalls=410 simultaneous=485 reset_edges=3

outputs 是本次真正握手的输出字节总数;inputs 包括零 keep 和后来被复位取消的已接收字,不能简单用两者相除判断丢包。reset_edges=3 表示观察到三个有效复位上升沿,并不是三次相互独立的复位操作。

这里特别提醒:本次 XSim 在 $fatal 终止仿真时,进程也返回了 0。因此,仅记录"模拟器退出码为零"并不能证明测试通过。最终判据同时要求编译、展开、仿真退出码为零,三个阶段日志没有 ERROR/Fatal,且仿真存在预期 PASS 标记。

七、两个故意写错的版本,是否真的能抓住

开发顺序先写上述测试,再使用只接收、不输出的空实现运行。空实现通过编译和展开,但在 137 ns 触发 MISSING_OUTPUT directed,说明测试确实要求功能发生,而不是看到程序结束就算成功。之后才编写并验证正式 RTL。

第一种突变把正式源码中的 else if(m_valid && m_ready) 改为 else if(m_valid)。这样即使下游暂停,掩码仍会推进。此版本编译、展开、仿真的退出码也都是零,但在 45 ns 触发 STALL_CHANGED,被日志判据明确判为失败。

第二种突变把 m_last 的条件由 m_valid && last_q && final_byte 改为 m_valid && last_q。它会把一个末包字的 last 错贴到所有有效字节上。在 95 ns,第一个输出字节 11 被标为 last=1,而期望为 0,触发 BYTE_OR_LAST。该版本同样完成编译和展开,所以这不是故意破坏语法得到的无意义"负例"。

这些测试证明了本次覆盖到的协议行为,并不能代替形式验证、CDC 检查或目标器件的时序收敛。本模块没有跨时钟能力;也没有做综合利用率、静态时序或开发板验证。实际集成前,需要核对系统对稀疏 keep、空包和复位取消包的约定。如果业务约定与本文不同,应先修改参考模型和边界用例,再改 RTL,而不要让未说明的协议差异潜伏在正常数据流中。

相关推荐
花月mmc1 天前
【FPGA】神经网络内容简介预告
嵌入式硬件·深度学习·神经网络·fpga开发
LabVIEW开发3 天前
LabVIEW FPGA 编程:少编译几次的办法(系列 04/05)
fpga开发·labview
cmc10284 天前
318.关于在ip内部封装PLL,MMCM的注意事项
fpga开发
LabVIEW开发4 天前
LabVIEW CompactRIO 编程模式怎么选(系列 02/05)
fpga开发·labview
FPGA工程笔记4 天前
Xilinx Divider Generator IP 实战:64位有符号除法、Fractional 输出解析与定点数转换
verilog·fpga·vivado·xilinx·定点数·divider gen·fractional
FPGA小迷弟4 天前
FPGA 与千兆以太网校验和:从原理到 Verilog 实现
嵌入式硬件·fpga开发·无人机·ai编程·fpga
码农小韩5 天前
FPGA学习(二)——LED闪烁
fpga开发·verilog·fpga·led·组合逻辑·分频设计
9527华安6 天前
FPGA纯verilog实现DP视频接口,支持2K60帧分辨率,提供3套工程源码和技术支持
fpga开发·音视频·dp
ChaseVLSI6 天前
FPGA与ASIC的区别详解:内部结构、开发成本与适用场景,项目选型到底该选哪个
嵌入式硬件·fpga开发·硬件架构·硬件工程