跳转至

集成、编译与运行

本节介绍如何把 NPU 接入你的 SoC、编译 C 驱动,并在 ThinPAD 或云平台上运行。

接入 Wishbone 总线

NPU 占用 0x2000_0000 开始的 64KB 地址空间。你的 SoC 需要:

  1. 在 Wishbone 多路选择器中增加一个从机端口,地址 0x2000_0000,掩码 0xFFFF_0000;
  2. 把这个端口接到 npu_top 的 wb_* 信号;
  3. 保证 NPU 与 CPU 使用同一个时钟,复位极性一致(npu_top 高有效)。

以课程实验工程的 wb_mux 为例,需要增加一组 wbs3_* 连线:

// Slave 3:NPU @ 0x2000_0000
.wbs3_addr    (32'h2000_0000),
.wbs3_addr_msk(32'hFFFF_0000),
.wbs3_adr_o   (wbs3_adr_o),
.wbs3_dat_i   (wbs3_dat_i),
.wbs3_dat_o   (wbs3_dat_o),
.wbs3_we_o    (wbs3_we_o),
.wbs3_sel_o   (wbs3_sel_o),
.wbs3_stb_o   (wbs3_stb_o),
.wbs3_ack_i   (wbs3_ack_i),
.wbs3_err_i   ('0),
.wbs3_rty_i   ('0),
.wbs3_cyc_o   (wbs3_cyc_o)

然后例化 npu_top:

npu_top u_npu (
    .clk_i   (sys_clk),
    .rst_i   (sys_rst),

    .wb_adr_i(wbs3_adr_o),
    .wb_dat_i(wbs3_dat_o),
    .wb_dat_o(wbs3_dat_i),
    .wb_we_i (wbs3_we_o),
    .wb_sel_i(wbs3_sel_o),
    .wb_stb_i(wbs3_stb_o),
    .wb_ack_o(wbs3_ack_i),
    .wb_cyc_i(wbs3_cyc_o),
    .wb_err_o(),
    .wb_rty_o()
);

地址窗口不要和已有外设重叠

BaseRAM 通常占 0x8000_0000 起 4MB,ExtRAM 在 0x8040_0000,UART 在 0x1000_0000。NPU 的 0x2000_0000 窗口不要与它们重叠。如果你的地址译码只比较高位,请确认掩码设置正确。

编译软件

NPU 驱动是普通的裸机 C 代码,可以和你的监控程序/测试程序一起编译。如果你的大实验工程已经能编译、加载 C 程序,直接把 npu.c 和 npu.h 加入工程即可;如果还没有,下面的最小示例说明了链接脚本、启动代码和编译流程。

裸机 C 语言编程

下面介绍如何在裸机 RISC-V CPU 上进行 C 语言编程。与运行在操作系统内的编程不同,裸机编程没有标准库,所有事情都需要亲力亲为,因此需要做一些额外的操作来让 C 代码能在裸机上运行起来。

考虑到程序会在 0x80000000 即 BaseRAM 的基地址上加载并运行,首先需要编写一个 linker script link.ld,告诉链接器这件事情:

/* link.ld - 程序加载到 BaseRAM(0x8000_0000) */
ENTRY(_start)
MEMORY {
  ram (rwx) : ORIGIN = 0x80000000, LENGTH = 4M
}
SECTIONS {
  .text : { *(.text.start) *(.text*) } > ram
  .rodata : { *(.rodata*) } > ram
  .data : { *(.data*) } > ram
  .bss : { *(.bss*) } > ram
}

这样,编译器就知道,要把 .text.start、.text、.rodata、.data、.bss 段依次摆放到 0x80000000 开始的一段地址空间。此外,还需要编写一段汇编 crt0.S,用于初始化栈地址:

# crt0.S - RISC-V 启动代码(无操作系统)
    .section .text.start
    .globl _start
_start:
    li   sp, 0x80400000      # 栈顶(SRAM 高端,够用即可)
    call main
1:  j    1b                  # main 返回后死循环

这里设置 sp 为 0x80400000 后就直接调用 C 代码内的 main 函数,如果 main 函数返回了,它就会进入一个死循环。指定 .text.start 段,是为了保证这段代码出现在最开头。

实践中,如果 C 代码涉及到了 .bss 段的使用,还需要在汇编内清空 .bss 段,否则 C 代码会假设 .bss 段已清空,但实际上并没有,就会出现问题。清空方法:

    # 清零 .bss
    la      t0, __bss_start
    la      t1, __bss_end
1:
    bgeu    t0, t1, 2f
    sw      zero, 0(t0)
    addi    t0, t0, 4
    j       1b
2:

同步修改 linker script,来定义 __bss_start 与 __bss_end 符号为 .bss 段的开始和结尾:

.bss : {
    __bss_start = .;
    *(.bss*)
    __bss_end = .;
} > ram

剩下,就可以正常编写 C 代码了,主要的区别就是,没有标准库,很多东西都要从头写,例如为了打印内容到串口,需要用 MMIO:

#define UART_BASE   0x10000000ul
#define UART_DATA   (UART_BASE + 0x00)
#define UART_STATUS (UART_BASE + 0x04)
#define UART_TX_READY (1u << 5)

static void uart_putc(char c) {
  while ((*(volatile uint8_t *)(uintptr_t)UART_STATUS & UART_TX_READY) == 0) {
  }
  *(volatile uint8_t *)(uintptr_t)UART_DATA = (uint8_t)c;
}

有了基础的打印函数以后,可以引入 mpaland/printf 等简化 printf 实现来打印各种数据,注意如果 CPU 没有实现浮点,需要关闭对应的支持以避免编译错误。

接下来,怎么从这些源码编译出,可以直接加载到 BaseRAM 中的二进制文件呢?需要如下步骤:

  1. 编译和链接:riscv64-unknown-elf-gcc -march=rv32i_zicsr -mabi=ilp32 -nostdlib -nostartfiles -T link.ld -O2 -fno-builtin crt0.S main.c npu.c -o npu.elf,参数解释:
    1. 编译器用的是 riscv64-unknown-elf-gcc,这里的前缀可能是其他的,比如 riscv64-linux-gnu-gcc,这些都可以;
    2. -march=rv32i_zicsr 编译目标是 RISC-V 32 位,启用 I 扩展,zicsr 表示支持 CSR,用于 rdcycle 读取周期计数器,如果实现了其他扩展(比如 M 扩展用于乘法和除法)也可以打开;
    3. -mabi=ilp32:使用 ILP32 的 ABI,即 int/long/pointer 都是 32 位;
    4. -nostdlib:表示没有标准库,不链接 libc;
    5. -nostartfiles:表示没有启动文件,由我们提供的 crt0.S 代替;
    6. -T link.ld:指定 link.ld 文件作为 linker script;
    7. -fno-builtin:表示没有内置函数,避免一些 libc 函数调用的生成;
    8. crt0.S main.c npu.c:要编译的程序,混合了汇编和 C 代码;
    9. -o npu.elf:指定输出路径。
  2. 导出 elf 里的指令和数据:riscv64-unknown-elf-objcopy -O binary npu.elf npu.bin;
  3. 此时 npu.bin 的内容,就是需要加载到 BaseRAM 0x80000000 的二进制内容。

在云平台上运行

  1. 用 Vivado 综合、实现并生成比特流,下载到 ThinPAD;
  2. 把编译得到的 npu.bin 加载到 BaseRAM,即 0x8000_0000;
  3. 打开串口终端,复位 CPU;
  4. 程序应当打印每个矩阵乘的结果,并与 npu_matmul_sw 的参考结果比较,最后输出类似 PASS / FAIL 的结论。

建议在程序里至少覆盖:

  • 4x4x4 固定数据(可以先手算或用 Python 生成期望值);
  • 8x8x8、16x16x16、32x32x32 随机数据;
  • 连续两次矩阵乘(检查 NPU 能否正确进行多次计算)。

调试顺序

先确认 CPU 能正确读写 NPU 的寄存器,再确认能写入/读回 L0A/L0B,最后再跑完整的矩阵乘。这样可以把总线问题和 NPU 内部问题分开。

作者:Jiajie Chen