Wishbone 接口与顶层
本节介绍如何把 NPU 挂到 Wishbone 总线上,以及 npu_top 的接口和地址映射。NPU 对 CPU 来说就是一个普通的 Wishbone Slave:CPU 通过 MMIO 读写寄存器,向 L0A/L0B 写数据,从 L0C 读结果,并通过写 CMD 寄存器触发 LDTILE/TMUL 指令。
Wishbone 总线
NPU 的核心控制逻辑已经实现完成,只剩下最后一步:让 CPU 访问 NPU 上的 L0A/L0B/L0C,同时可以让 NPU 执行 LDTILE/TMUL 指令。上面提到,这可以通过 MMIO 实现,由于在先前的实验里,CPU 采用了 Wishbone 总线,因此这里就可以把 NPU 作为一个 Wishbone 的 Slave,挂到 Wishbone 总线下,供 CPU 访问。由于 Wishbone 上都是读和写请求,因此需要设计一个约定,即地址和对应的功能,约定如下:
- NPU 对应的 MMIO 地址空间从 0x2000_0000 到 0x2000_FFFF,定义了这些功能:
- 0x2000_0000 CMD:仅支持写,写入时会在 NPU 上执行对应的指令,写入 1 代表执行 LDTILE 指令,写入 2 代表执行 TMUL 指令且 acc=0,写入 6 代表执行 TMUL 指令且 acc=1(即 CMD[0] 代表 LDTILE,CMD[1] 代表 TMUL,CMD[2] 代表 acc)
- 0x2000_0004 STATUS:读取时,最低位 STATUS[0] 为 1 代表 NPU 正在执行指令,STATUS[1] 为 1 代表 NPU 完成了一条指令的执行;写入时,STATUS[1] 会被清零,代表 CPU 已经知晓 NPU 指令完成执行
- 0x2000_0008 A_BASE:仅支持写,为 TMUL 指令所采用的 L0A 基址,单位是 4 字节
- 0x2000_000C B_BASE:仅支持写,为 LDTILE 指令所采用的 L0B 基址,单位是 4 字节
- 0x2000_0010 C_BASE:仅支持写,为 TMUL 指令所采用的 L0C 基址,单位是 16 字节
- 0x2000_0014 M_M1:仅支持写,为 TMUL 指令所计算的 A/C 矩阵的行数 M 减一
- 0x2000_0100 到 0x2000_01FF L0A:仅支持写,即访问 L0A 内的数据
- 0x2000_0200 到 0x2000_020F L0B:仅支持写,即访问 L0B 内的数据
- 0x2000_0300 到 0x2000_06FF L0C:仅支持读,即访问 L0C 内的数据
下面是 cod-npu 仓库根目录下的 rtl/sv/npu_wb.sv 中对应的硬件实现,首先是判断何时来了一个新的 Wishbone 请求:
// ============ Wishbone 握手 ============
reg stb_prev;
wire req = wb_cyc_i & wb_stb_i & ~stb_prev;
always_ff @(posedge clk_i) begin
if (rst_i) stb_prev <= 1'b0;
else stb_prev <= wb_stb_i;
end
接着,实现各参数寄存器的写入(代码里 W1C 是 Write 1 Clear 的缩写,意思是写入 1 会把对应的位清零):
// 指令参数寄存器
reg [L0A_AW-1:0] a_base;
reg [L0B_AW-1:0] b_base;
reg [L0C_AW-1:0] c_base;
reg [MM1_W-1:0] m_m1;
always_ff @(posedge clk_i) begin
if (rst_i) begin
a_base <= '0; b_base <= '0; c_base <= '0; m_m1 <= '0;
end else if (req && wb_we_i) begin
case (wb_adr_i[15:0])
16'h0008: if (wb_sel_i[0]) a_base <= wb_dat_i[L0A_AW-1:0];
16'h000C: if (wb_sel_i[0]) b_base <= wb_dat_i[L0B_AW-1:0];
16'h0010: if (wb_sel_i[0]) c_base <= wb_dat_i[L0C_AW-1:0];
16'h0014: if (wb_sel_i[0]) m_m1 <= wb_dat_i[MM1_W-1:0];
default: ;
endcase
end
end
// done 状态寄存器:core 完成时置位,软件 W1C 清除
reg status_done;
always_ff @(posedge clk_i) begin
if (rst_i) begin
status_done <= 1'b0;
end else begin
if (core_done) status_done <= 1'b1;
if (req && wb_we_i) begin
case (wb_adr_i[15:0])
16'h0004: if (wb_dat_i[1]) status_done <= 1'b0; // STATUS W1C
default: ;
endcase
end
end
end
处理针对 CMD 的写入,启动 NPU 对应指令的执行:
// ============ 指令译码 ============
assign ldtile_valid = req & wb_we_i & (wb_adr_i[15:0] == 16'h0000) & wb_dat_i[0];
assign tmul_valid = req & wb_we_i & (wb_adr_i[15:0] == 16'h0000) & wb_dat_i[1];
assign tmul_acc = req & wb_we_i & (wb_adr_i[15:0] == 16'h0000) & wb_dat_i[2];
assign ldtile_base = b_base;
assign tmul_a_base = a_base;
assign tmul_m_m1 = m_m1;
assign tmul_c_base = c_base;
接下来,处理 L0A/L0B 的写入,把 Wishbone 写入的地址和数据转给 npu_core 模块:
// L0 地址译码
wire [15:0] l0a_off = wb_adr_i[15:0] - L0A_BASE;
wire [15:0] l0b_off = wb_adr_i[15:0] - L0B_BASE;
wire [15:0] l0c_off = wb_adr_i[15:0] - L0C_BASE;
wire in_l0a = (wb_adr_i[15:0] >= L0A_BASE) & (wb_adr_i[15:0] < L0A_END);
wire in_l0b = (wb_adr_i[15:0] >= L0B_BASE) & (wb_adr_i[15:0] < L0B_END);
wire in_l0c = (wb_adr_i[15:0] >= L0C_BASE) & (wb_adr_i[15:0] < L0C_END);
// L0A/L0B 写入
// 注:此处的实现假设了 PE_N 等于 4;
// 当 PE_N 大于 4 时,一次只能写入 A/B 一行的一部分,这里的逻辑需要修改
assign aw_valid = req & wb_we_i & in_l0a;
assign aw_addr = l0a_off[L0A_AW+1:2];
assign aw_data = wb_dat_i;
assign bw_valid = req & wb_we_i & in_l0b;
assign bw_addr = l0b_off[L0B_AW+1:2];
assign bw_data = wb_dat_i;
L0C 的读取要更加复杂:首先,L0C 读取的时候,TMUL 可能正在进行累加,此时就需要等待;其次,Wishbone 总线是 32 位的数据宽度,而 L0C 一次读取出来是 128 位,需要截取其中的 32 位作为读取结果。
// CPU 读 L0C 时,若 core 正在 acc 读改写(占用读端口),请求挂起;
// core 空闲拍才发读,wishbone 的 ACK 同步延迟(CPU 忙等)。
// L0C 每字 = PE_N*4 字节:字地址 = 字节偏移 >> (2+clog2(PE_N)),lane = 偏移的低位
wire [L0C_AW-1:0] l0c_word_off = l0c_off[$clog2(PE_N)+L0C_AW+1 : $clog2(PE_N)+2];
wire [$clog2(PE_N)-1:0] l0c_lane = l0c_off[$clog2(PE_N)+1 : 2];
reg l0c_rd_pend;
reg [L0C_AW-1:0] l0c_rd_addr; // 挂起时的字地址
reg [$clog2(PE_N)-1:0] l0c_rd_lane; // 挂起/发读时的 lane(读数据取 32 位用)
// ready-valid 协议:cr_valid 独立(请求即置位并保持,直到握手),不依赖 cr_ready;
// 握手 = cr_valid & cr_ready(core 空闲)才真正发读。未握手期间 core 的
// acc 读改写照常使用读端口(core 内仲裁,见 npu_core)。
wire l0c_rd_req = req & ~wb_we_i & in_l0c;
assign cr_valid = l0c_rd_pend || l0c_rd_req; // valid 独立(请求即有效,挂起时保持)
wire l0c_rd_hs = cr_valid && core_cr_ready; // ready-valid 握手(同时为 1 才生效)
always_ff @(posedge clk_i) begin
if (rst_i) begin
l0c_rd_pend <= 1'b0;
l0c_rd_addr <= '0;
l0c_rd_lane <= '0;
end else if (l0c_rd_req) begin
l0c_rd_addr <= l0c_word_off; // 锁存字地址(挂起或握手都用它)
l0c_rd_lane <= l0c_lane; // 锁存 lane(读数据取 32 位)
if (!core_cr_ready) l0c_rd_pend <= 1'b1; // 未握手:挂起(valid 保持)
end else if (l0c_rd_hs) begin
l0c_rd_pend <= 1'b0; // 握手完成
end
end
assign cr_addr = l0c_rd_pend ? l0c_rd_addr : l0c_word_off;
接下来,处理 Wishbone 总线的其余信号,例如 ACK 要在请求完成的那个周期拉高:
// ============ ACK ============
// 读执行拍:STATUS 读 = req;L0C 读 = l0c_rd_hs(可能因仲裁挂起而延迟)
wire is_status_rd = req & ~wb_we_i & (wb_adr_i[15:0] == 16'h0004);
wire rd_exec = wb_we_i ? 1'b0 : (is_status_rd ? req : l0c_rd_hs); // L0C 读 = 握手拍
reg rd_exec_reg;
always_ff @(posedge clk_i) begin
if (rst_i) rd_exec_reg <= 1'b0;
else rd_exec_reg <= rd_exec;
end
reg ack_reg;
always_ff @(posedge clk_i) begin
if (rst_i) ack_reg <= 1'b0;
else if (wb_we_i) ack_reg <= wb_cyc_i & wb_stb_i; // 写事务寄存 1 拍
else ack_reg <= rd_exec_reg; // 读事务(L0C 读含仲裁延迟)
end
assign wb_ack_o = ack_reg;
assign wb_err_o = 1'b0;
assign wb_rty_o = 1'b0;
最后,根据地址,把 L0C 读取的数据或 STATUS 的值通过 wb_dat_o 信号返回给 CPU:
// ============ 读数据 ============
// L0C 读结果取 32 位 lane
wire [L0C_DW-1:0] cr_lane_shift = cr_data >> ({l0c_rd_lane,5'b0});
reg [DATA_WIDTH-1:0] dat_reg;
always_ff @(posedge clk_i) begin
if (rd_exec_reg) begin
if (wb_adr_i[15:0] == 16'h0004)
dat_reg <= {30'b0, status_done, core_busy};
else
dat_reg <= cr_lane_shift[31:0]; // L0C 读结果(发读后 1 拍有效)
end
end
assign wb_dat_o = dat_reg;
这样就完成了把 NPU 控制逻辑挂载在 Wishbone 总线上的功能。
顶层模块
最后,顶层模块 npu_top 做的事情就是例化 npu_core 和 npu_wb,顶层接口只剩下了 Wishbone,用于接到 Wishbone 总线上:
module npu_top #(
parameter ADDR_WIDTH = 32,
parameter DATA_WIDTH = 32,
parameter PE_N = 4, // 脉动阵列大小
parameter L0A_DEPTH = 64,
parameter L0B_DEPTH = 4,
parameter L0C_DEPTH = 64,
localparam L0A_AW = $clog2(L0A_DEPTH),
localparam L0B_AW = $clog2(L0B_DEPTH),
localparam L0C_AW = $clog2(L0C_DEPTH),
localparam M_MAX = (L0A_DEPTH < L0C_DEPTH) ? L0A_DEPTH : L0C_DEPTH,
localparam MM1_W = $clog2(M_MAX) // 行数 M-1 位宽
) (
input wire [ADDR_WIDTH-1:0] wb_adr_i,
input wire [DATA_WIDTH-1:0] wb_dat_i,
output wire [DATA_WIDTH-1:0] wb_dat_o,
input wire wb_we_i,
input wire [DATA_WIDTH/8-1:0] wb_sel_i,
input wire wb_stb_i,
output wire wb_ack_o,
input wire wb_cyc_i,
output wire wb_err_o,
output wire wb_rty_o,
input wire clk_i,
input wire rst_i
);
顶层参数
| 参数 | 默认值 | 说明 |
|---|---|---|
ADDR_WIDTH |
32 | Wishbone 地址位宽 |
DATA_WIDTH |
32 | Wishbone 数据位宽 |
PE_N |
4 | 脉动阵列大小 |
L0A_DEPTH |
64 | L0A 深度(字) |
L0B_DEPTH |
4 | L0B 深度(字) |
L0C_DEPTH |
64 | L0C 深度(字) |
PE_N 与 npu_wb 的假设
npu_core 和 systolic_array 是参数化的,但 npu_wb 中 L0A/L0B 的写入逻辑按“一次 32 位写入一行”实现,隐含假设 PE_N=4。如果把阵列扩大到 PE_N=8 或更大,需要修改 npu_wb 的写入逻辑(例如把一行拆成多次 32 位写),否则 A/B 的一行会写不全。
顶层例化
npu_top 内部例化 npu_wb 与 npu_core,并把两者的指令、L0 访问和状态信号连接起来。核心连接如下(完整代码见 cod-npu 仓库根目录下的 rtl/sv/npu_top.sv):
npu_wb #(...) u_npu_wb (
.clk_i (clk_i),
.rst_i (rst_i),
.wb_adr_i(wb_adr_i), .wb_dat_i(wb_dat_i), .wb_dat_o(wb_dat_o),
.wb_we_i (wb_we_i), .wb_sel_i(wb_sel_i), .wb_stb_i(wb_stb_i),
.wb_ack_o(wb_ack_o), .wb_cyc_i(wb_cyc_i), .wb_err_o(wb_err_o), .wb_rty_o(wb_rty_o),
.ldtile_valid(ldtile_valid), .ldtile_base(ldtile_base),
.tmul_valid(tmul_valid), .tmul_a_base(tmul_a_base), .tmul_m_m1(tmul_m_m1),
.tmul_c_base(tmul_c_base), .tmul_acc(tmul_acc),
.core_busy(core_busy), .core_done(core_done), .core_cr_ready(core_cr_ready),
.aw_valid(aw_valid), .aw_addr(aw_addr), .aw_data(aw_data),
.bw_valid(bw_valid), .bw_addr(bw_addr), .bw_data(bw_data),
.cr_valid(cr_valid), .cr_addr(cr_addr), .cr_data(cr_data)
);
npu_core #(...) u_npu_core (
.clk(clk_i), .rst(rst_i),
.ldtile_valid(ldtile_valid), .ldtile_base(ldtile_base),
.tmul_valid(tmul_valid), .tmul_a_base(tmul_a_base), .tmul_m_m1(tmul_m_m1),
.tmul_c_base(tmul_c_base), .tmul_acc(tmul_acc),
.cmd_ready(), .busy(core_busy), .done(core_done), .cr_ready(core_cr_ready),
.aw_valid(aw_valid), .aw_addr(aw_addr), .aw_data(aw_data),
.bw_valid(bw_valid), .bw_addr(bw_addr), .bw_data(bw_data),
.cr_valid(cr_valid), .cr_addr(cr_addr), .cr_data(cr_data)
);
复位极性
npu_top 的复位 rst_i 是高有效。如果你的 SoC 使用低有效复位,请在例化时取反。
至此,NPU 的硬件部分就完成了。接下来需要在 CPU 上运行软件来操作它,见 NPU 软件实现。