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,而不要让未说明的协议差异潜伏在正常数据流中。