一、项目概述
本项目使用硬禾课堂基于ICE40UP5K FPGA与STM32G031设计的电赛训练平台,以及配套的扩展版,实现了开源RISCV软核FemtoRV的移植。移植的软核能够运行示例的流水灯程序,以及自己使用汇编代码编写的ADC-DAC直通程序。
二、项目思路与结构
本项目在Ubuntu20.04(WSL2)环境下开发,使用了包含yosys,nextpnr-ice40在内的icestorm开源项目工具链,以及bin2rbt,来完成Verilog源码的编译和烧录文件的生成;使用了FemtoRV项目配套的RISCV交叉编译工具链对代码进行交叉编译,并最终放在FPGA的BRAM中运行。
本项目移植的软核源码是FemtoRV的step20.v,采用状态机实现,支持RV32I指令集;本项目的测试程序使用汇编语言编写。
三、实现过程
1.开源工具链安装
yosys,nextpnr-ice40,icestorm的安装都很简单,只需要从github上拷贝源码,在本地编译之后就可以使用。bin2rbt是群里的moo同学编写的将bin文件转换为rbt文件的工具。由于WSL2在连接USB时问题比较多,且核心板的设计是通过上位机连接虚拟U盘,而不是上位机直接连接FPGA芯片的方式来下载的,因此不能使用iceprog工具来下载。
生成rbt文件的方式有两种,一种是在IDE下直接配置生成rbt文件的选项,一种是开源工具链生成bin文件之后,再转为rbt文件。
2.开源项目
FemtoRV的作者团队为RISCV的入门级爱好者(像我)提供了一套非常完善且通俗易懂的教程。该教程除了指导读者如何做移植和平台适配,还提供了一整套使我震惊的“FROM_BLINKER_TO_RISCV”讲义,作者团队在其中以最易懂和风趣的方式(p.s. 这样的英文读着是真爽),讲述了他们从一个流水灯模块开始逐步写成一个RISCV软核的过程。
因此,本次选择该开源项目进行移植,一方面是看中其教程的完整,另一方面也是想要从一个“最小系统”的实例入手,对RISCV软核的设计有一个整体的认识。
3.具体实现
该项目可以直接适配到IceStick和IceBreaker等开发板,但在本平台上需要做些适配。
第一,先从教程提供的开发步骤入手,明确项目结构。
以流水灯例程为例,最简单的编译命令(在该项目提供的环境下)为:
cd FROM_BINKER_TO_RISCV/FIRMWARE
make blinker.bram.hex
cd ..
BOARDS/run_xxx.sh step20.v
其中FIRMWARE文件夹下的Makefile文件提供了对riscv交叉编译工具链的封装,可以直接对blink.S文件进行修改,而编译命令不用变。
再观察BOARDS文件夹下的run_xxx.sh文件(以icebreaker为例,它采用的是与本平台一样的芯片):
PROJECTNAME=SOC
BOARD=icebreaker
BOARD_FREQ=12
CPU_FREQ=20
FPGA_VARIANT=up5k
FPGA_PACKAGE=sg48
VERILOGS=$1
yosys -q -DICE_BREAKER -DNEGATIVE_RESET -DBOARD_FREQ=$BOARD_FREQ -DCPU_FREQ=$CPU_FREQ -p "synth_ice40 -abc9 -device u -dsp -top $PROJECTNAME -json $PROJECTNAME.json" $VERILOGS || exit
nextpnr-ice40 --force --json $PROJECTNAME.json --pcf BOARDS/$BOARD.pcf --asc $PROJECTNAME.asc --freq $BOARD_FREQ --$FPGA_VARIANT --package $FPGA_PACKAGE --pcf-allow-unconstrained || exit
icetime -p BOARDS/$BOARD.pcf -P $FPGA_PACKAGE -r $PROJECTNAME.timings -d up5k -t $PROJECTNAME.asc
icepack $PROJECTNAME.asc $PROJECTNAME.bin || exit
iceprog $PROJECTNAME.bin || exit
echo DONE.
这里面其实是对开源工具的顺序调用,唯一存在变数的宏(变量)只有BOARD。进一步讲,只有$BOARD.pcf是因开发板而异的。除此之外,-DICE_BREAKER参数也是需要改变的。由此可见:
第二,我们需要做的就是编写.pcf文件,该文件里包含了顶层模块输入输出引脚的约束。为此,要关注我们所需用到的引脚的连接关系。
根据原理图可以找出ADC和DAC与FPGA连接的22个引脚(20数据线+2时钟线)。通过查阅数据手册并观察ADC和DAC模块,可以确定数据引脚的LSB与MSB。
在直通程序(ADC采样直接bypass到DAC输出)中,还需启用核心板上的RGB_LED,通过它的定时闪烁作为系统正常运行的标志。
RST复位线可以连接到板子上任意一个带上拉的按键。
适配后的.pcf文件如下:
set_io CLK 44
set_io SYS_LED[0] 39
set_io SYS_LED[1] 40
set_io ADC[0] 26
set_io ADC[1] 27
set_io ADC[2] 28
set_io ADC[3] 31
set_io ADC[4] 32
set_io ADC[5] 34
set_io ADC[6] 2
set_io ADC[7] 36
set_io ADC[8] 25
set_io ADC[9] 48
set_io ADC_CLK 47
set_io DAC[0] 46
set_io DAC[1] 3
set_io DAC[2] 4
set_io DAC[3] 6
set_io DAC[4] 9
set_io DAC[5] 10
set_io DAC[6] 11
set_io DAC[7] 12
set_io DAC[8] 13
set_io DAC[9] 18
set_io DAC_CLK 45
set_io RESET 43
至此,已经完成了硬件连接上的适配。
第三,需要对锁相环进行配置。刚才run_xxx.sh里的-DICE_BREAKER参数实际上在femtoPLL.v文件中生效:
`ifdef PASSTHROUGH_PLL
module femtoPLL #(
parameter freq = 60
) (
input pclk,
output clk
);
assign clk = pclk;
endmodule
`else
`ifdef ICE_STICK
`include "pll_icestick.v"
`elsif ICE_BREAKER
`include "pll_icebreaker.v"
//......
`endif
`endif
显然我们也需要生成一个适用于本平台的.v文件,并添加一个对应的宏定义(该宏可以在文件中定义,也可以在命令行中以-D的方式像-DICE_BREAKER这样子传入)。如果不想用锁相环的话也很简单,只需要在命令行中传入参数-DPASSTHROUGH_PLL。FemtoRV项目中就包含一个自动为我们生成对应型号FPGA锁相环.v文件的脚本,但在生成之后我们需要把SB_PLL40_PAD改回SB_PLL40_CORE,把PACKAGEPIN改回REFERENCECLK——而不是像项目中附带的教程中说的那样。
并且,如果启用了锁相环,则需要将-DCPU_FREQ参数(也就是CPU_FREQ变量)改成锁相环的实际输出频率,否则至少在使用uart模块时会遇到错误。
第四,总线分析。
在实现外设挂载时,很重要的一点就是:要去适应和理解作者的思路,这样就能很快摸清代码中信号的层次。并且,应该先理解作者的思路,再去顺着这个思路写自己的模块。
在step20.v中,作者已经为我们提供了一个挂载uart外设的例子,在该SOC上挂在外设所需了解的一切,在教程和下面这段代码中全都讲清楚了:
assign mem_wstrb = |mem_wmask;
//......
localparam IO_UART_DAT_bit = 1; // W data to send (8 bits)
localparam IO_UART_CNTL_bit = 2; // R status. bit 9: busy sending
//......
wire uart_valid = isIO & mem_wstrb & mem_wordaddr[IO_UART_DAT_bit];
wire uart_ready;
corescore_emitter_uart #(
.clk_freq_hz(`CPU_FREQ*1000000),
// .baud_rate(115200)
.baud_rate(1000000)
) UART(
.i_clk(clk),
.i_rst(!resetn),
.i_data(mem_wdata[7:0]),
.i_valid(uart_valid),
.o_ready(uart_ready),
.o_uart_tx(TXD)
);
wire [31:0] IO_rdata =
mem_wordaddr[IO_UART_CNTL_bit] ? { 22'b0, !uart_ready, 9'b0}
: 32'b0;
assign mem_rdata = isRAM ? RAM_rdata :
IO_rdata ;
在我看来,讨论如何挂载一个外设,本质上就是在讨论CPU如何经总线寻址并将数据发到该外设模块。
在这里,mem_wdata[7:0]显然是32位数据总线的低8位(因为串口就是以Byte为单位的),而valid信号指示着写操作是否对该模块(从机)有效。而valid信号取决于三点(就是assign的那三个信号):当前指令是否在外设区域寻址?当前写数据线是否有效?当前写地址是否针对该外设?(作者在教程中提到,为了避免使用大位宽的比较器,采用独热编码对外设区域0x400000~0x4fffff内的外设进行编址)
至于读取,作者的逻辑是先判断当前寻址区域是内存还是外设,如果是外设,再看是具体那个外设,并把这个外设的数据route到“外设数据总线”上。
至此,作者设计的整个总线协议已经非常清晰了。
这里需要说一句,作者把uart的busy信号(也就是ready)单独引出来,并给他独立安排了一个独热编码地址,这在我看来不够简洁——既然32位的数据线远远没有占满,那么在他设计的这个总线读写协议下,他完全可以赋予一个地址以完全独立的读写行为(就像我挂载ADC外设时做的那样),从而省下一个地址。
第五,外设挂载。
在了解了作者设计总线的思路之后,就可以挂载我们自己的ADC和DAC驱动模块:
module adc_3pa1030(
input wire i_clk,
input wire i_rst,
input wire [10:0] i_data,
input wire i_valid,
output wire o_clk,
output reg [9:0] o_data
);
wire en;
reg en_r = 1'b0;
assign en = i_rst & i_data[10];
//enable logic
//write 0x01 to enable adc block
always@(posedge i_clk)begin
en_r <= i_valid ? en : en_r;
end
//o_clk feeds directly to adc chip
assign o_clk = en_r ? i_clk : 1'b0;
always@(negedge i_clk)begin
o_data <= i_data[9:0];
end
endmodule
在ADC模块中,我采用了同一个地址具有独立读写行为的设计:一方面,i_data[10]连接到mem_wdata[0],这样子的话,向ADC地址写0x01就表示使能ADC;i_data[9:0]连接到3PA1030的数据引脚,这10个引脚和CPU没什么关系。另一方面,o_data[9:0]则被route到外设数据总线,只需在作者原先的代码上做一些修改:
wire [31:0] IO_rdata =
mem_wordaddr[IO_UART_CNTL_bit] ? { 22'b0, !uart_ready, 9'b0}
:mem_wordaddr[IO_ADC_bit] ? {22'b0,adc_rdata}
: 32'b0;
此外,由于ADC和DAC的数据均为正沿同步,因此最好在负沿存储和更新数据。
DAC模块的设计与ADC类似:
module dac_3pd5651(
input wire i_clk,
input wire i_rst,
input wire [10:0] i_data,
input wire i_valid,
output wire o_clk,
output reg[9:0] o_data
);
wire en;
reg en_r = 1'b0;
assign en = i_rst & i_data[10];
//write 0x0400 to enable dac block
always@(posedge i_clk)begin
en_r <= i_valid ? en : en_r;
end
//o_clk feeds directly to dac chip
assign o_clk = en_r ? i_clk : 1'b0;
always@(negedge i_clk)begin
o_data <= i_valid ? i_data[9:0] : o_data;
end
endmodule
唯一不同的是:由于对DAC的操作全是“写”,因此需要写入0x400才能对DAC使能。
第六,firmware编写
直通测试程序的main函数如下:
.equ IO_BASE, 0x400000
.equ IO_LEDS, 4
.equ IO_ADC, 32
.equ IO_DAC, 64
.section .text
.globl main
main:
li t2, 0x01
sw t2, IO_LEDS(gp)
li t0, 0x01
sw t0, IO_ADC(gp)
li t0, 0x400
sw t0, IO_DAC(gp)
li t1, 0
.L0:
bnez t1, .L1
li t1, 1
slli t1, t1, 18
xori t2, t2, 0x03
sw t2, IO_LEDS(gp)
.L1:
lhu t0, IO_ADC(gp)
ori t0, t0, 0x400
sh t0, IO_DAC(gp)
addi t1, t1, -1
j .L0
其中,start.S已经规定gp为0x400000,并禁止编译器将gp用作他用。
IO_LEDS(gp)的意思就是gp + IO_LEDS,即带偏移的寻址。在主循环中,CPU不断从ADC的地址读取数据,然后与0x400(DAC的使能位)相或,并写入DAC的地址。与此同时,寄存器t1(这里的代码都是按照ABI标准写的,寄存器的“真名”不会出现)作为一个计数器,使得RGB_LED的R和G引脚以“01,10,01,10……”的规律更新,表现为RGB_LED以“红,绿,红,绿……”的规律交替。
最后,执行修改之后的编译脚本:
PROJECTNAME=SOC
BOARD=ice40stepv2
BOARD_FREQ=12
CPU_FREQ=40
FPGA_VARIANT=up5k
FPGA_PACKAGE=sg48
VERILOGS=$1
yosys -q -DSTEP_FPGA -DNEGATIVE_RESET -DBOARD_FREQ=$BOARD_FREQ -DCPU_FREQ=$CPU_FREQ -p "synth_ice40 -abc9 -device u -dsp -top $PROJECTNAME -json $PROJECTNAME.json" $VERILOGS || exit
nextpnr-ice40 --force --json $PROJECTNAME.json --pcf BOARDS/$BOARD.pcf --asc $PROJECTNAME.asc --freq $BOARD_FREQ --$FPGA_VARIANT --package $FPGA_PACKAGE --pcf-allow-unconstrained || exit
icetime -p BOARDS/$BOARD.pcf -P $FPGA_PACKAGE -r $PROJECTNAME.timings -d up5k -t $PROJECTNAME.asc
icepack $PROJECTNAME.asc $PROJECTNAME.bin || exit
#iceprog $PROJECTNAME.bin || exit
python3 bin2rbt.py --binfile $PROJECTNAME.bin --rbtfile $PROJECTNAME.rbt
cp -f $PROJECTNAME.rbt /mnt/c/<保密>/Documents
echo DONE.
然后通过虚拟U盘界面烧录即可。
附录:开源工具链输出的资源占用报告。
Info: Packing constants..
Info: Packing IOs..
Info: Packing LUT-FFs..
Info: 1078 LCs used as LUT4 only
Info: 67 LCs used as LUT4 and DFF
Info: Packing non-LUT FFs..
Info: 59 LCs used as DFF only
Info: Packing carries..
Info: 10 LCs used as CARRY only
Info: Packing indirect carry+LUT pairs...
Info: 0 LUTs merged into carry LCs
Info: Packing RAMs..
Info: Placing PLLs..
Info: constrained PLL 'CW.genblk1.pll.pll' to X12/Y31/pll_3
Info: Packing special functions..
Info: Packing PLLs..
Info: Promoting globals..
Info: promoting clk (fanout 158)
Info: promoting RESET_SB_LUT4_I3_O_SB_DFFR_R_Q_SB_LUT4_I0_O_SB_LUT4_I0_O[1] [reset] (fanout 34)
Info: promoting RESET_SB_LUT4_I3_O [reset] (fanout 16)
Info: promoting CPU.instr_SB_DFFE_Q_E [cen] (fanout 32)
Info: promoting CPU.state_SB_DFFESR_Q_D_SB_LUT4_O_1_I3_SB_LUT4_O_I2_SB_LUT4_I1_O[2] [cen] (fanout 30)
Info: Constraining chains...
Info: 3 LCs used to legalise carry chains.
Info: Checksum: 0x76026bc2
Info: Annotating ports with timing budgets for target frequency 12.00 MHz
Info: Checksum: 0xe5901ee4
Info: Device utilisation:
Info: ICESTORM_LC: 1219/ 5280 23%
Info: ICESTORM_RAM: 16/ 30 53%
Info: SB_IO: 28/ 96 29%
Info: SB_GB: 5/ 8 62%
Info: ICESTORM_PLL: 1/ 1 100%
Info: SB_WARMBOOT: 0/ 1 0%
Info: ICESTORM_DSP: 0/ 8 0%
Info: ICESTORM_HFOSC: 0/ 1 0%
Info: ICESTORM_LFOSC: 0/ 1 0%
Info: SB_I2C: 0/ 2 0%
Info: SB_SPI: 0/ 2 0%
Info: IO_I3C: 0/ 2 0%
Info: SB_LEDDA_IP: 0/ 1 0%
Info: SB_RGBA_DRV: 0/ 1 0%
Info: ICESTORM_SPRAM: 0/ 4 0%
四、项目总结
这次项目的经历是充满交易的。因为寒假的时候刚刚经历过蜂鸟E203的“折磨”,使我这个初学者对RISCV开源CPU多少产生了一些阴影和恐惧。但FemtoRV项目作者却在生动形象、风趣幽默的讲解中,不知不觉就把他的blinky变成了一个实实在在的RISCV core。FROM_BLINKER_TO_RISCV的讲义显然很对我”从最小系统出发“的胃口,它在使我明白RISCV CPU”也就是那么回事儿“的同时,也使我明白,即使是像我这样初涉RISCV(但又满脑子骚操作)的初学者,也完全有能力去进行自己的开发和尝试。
五、附录
硬件框图:
软件框图:
注:演示阶段选择了比流水灯难度更高的ADC-DAC直通程序进行演示,因为流水灯程序是这个开源项目自带的,直接抄过来就能跑。而由于ADC和DAC的引脚和板载LED的引脚冲突,因此只能以RGB-LED交替亮起“红”“绿”的效果(见B站视频)来代替项目要求中的“流水灯”。RGB-LED驱动和流水灯LED驱动事实上是一模一样的,而既然ADC-DAC直通都能完成,流水灯必不在话下。
演示效果1:正弦波
演示效果2:三角波
演示效果3:方波