16 实用化的RISC-V CPU
前面我们在FPGA开发板上实现了一个RISC-V的CPU,可以跑工具链编译出来的应用。然而感觉没有实用性,本次我们对CPU的设计做一些总结性的讨论,然后对这个RISC-V的CPU核进行改造,减少它的指令执行周期,增加一个外部设备,提供一个交互式的软件,让它可以用在一些FPGA的应用项目中,作为FPGA项目调试或者配置的一个手段,通过提供一个终端软件来读写FPGA内部的存储器和寄存器状态,甚至可以来设置某些寄存器的值,对FPGA应用进行调试,总比用JTAG来调试得简单。
16.1 CPU设计的一些问题
CPU,或者外推一下,包括DSP,GPU以及一些专用芯片中的处理器,其实称之为可编程通用状态机,可能更加贴切一些,后面还是用CPU来指称了。CPU设计其实是非常宽带的一件事情,下面列出我所了解的一些问题:
- 指令集:指令集关乎到软件的效率,指令集背后有工具链支撑,所谓效率一方面是指单位芯片面积和单位能耗下的每周期指令数,这个比较好统计,另一方面是指完成应用需要的指令数,这个比较模糊,具体跟应用相关,大概可以这么理解,如果指令比较简单,那么完成同样的事情需要执行的指令数目就多一些,反而则少一些。指令简单时,实现起来要容易,每周期指令数就多一些。可以看出所谓指令集的效率,跟应用场合紧密相关,离开应用背景谈指令集的优劣是没有意义的,因此不同的应用中可能采用的指令集就很不同。
- 精简指令集(RISC)与复杂指令集(CISC):精简指令集是指指令比较简单,一条指令只做一个单一的计算,基本可以与编译器中生成的四元组<op, dst, s1, s2>对应起来,可以表达二元计算,LOAD/STORE, 分支等功能,一元计算甚至可以用二元计算来模拟,s1和s2中可能包括立即数,寻模式也简单,支持与PC相对偏移寻址,也支持基于用寄存器的偏移寻址,RISC-V就是一种精简指令集,精简指令集电路和工具链实现相对简单一些。复杂指令集中一条指令可以表达几个计算,比如可以在一条指令中包括LOAD/STORE功能,多个计算功能(比如多分量矢量计算,包括分量预处理及后期处理等)和分支(条件)功能,这样指令的运行效率会比较高,当然一条指令的长度也比较长,工具链和电路实现比较复杂,一般在专用的DSP或者GPU处理器中使用,其工具链需要做非常专业化的优化,才能发挥出指令集的优势。我们举个例子说明这个问题,比如数据处理过程中经常使用的乘累加计算代码:
#define LEN 1024
unsigned int coeff[LEN];
unsigned int buf[LEN];
static void testmad()
{
int i;
int total;
total = 0;
clear_all_counter();
for (i = 0; i < LEN; i++)
total += coeff[i] * buf[i];
stop_all_counter();
liststatus();
clear_all_counter();
}
其中我们用CSR来对各类指令进行计数,首先各个计数器清零,然后做一个乘累加计算,然后显示各个计数器的计数值。用RISC-V的工具链编译后,得到的代码是:
000014bc <testmad>:
14bc: fe010113 addi sp,sp,-32
14c0: 00112e23 sw ra,28(sp)
14c4: 00812c23 sw s0,24(sp)
14c8: 02010413 addi s0,sp,32
14cc: fe042423 sw zero,-24(s0)
14d0: df9ff0ef jal ra,12c8 <clear_all_counter>
14d4: fe042623 sw zero,-20(s0)
14d8: 0500006f j 1528 <testmad+0x6c>
14dc: 000037b7 lui a5,0x3
14e0: 50878713 addi a4,a5,1288 # 3508 <coeff>
14e4: fec42783 lw a5,-20(s0)
14e8: 00279793 slli a5,a5,0x2
14ec: 00f707b3 add a5,a4,a5
14f0: 0007a703 lw a4,0(a5)
14f4: 000047b7 lui a5,0x4
14f8: 50878693 addi a3,a5,1288 # 4508 <buf>
14fc: fec42783 lw a5,-20(s0)
1500: 00279793 slli a5,a5,0x2
1504: 00f687b3 add a5,a3,a5
1508: 0007a783 lw a5,0(a5)
150c: 02f70733 mul a4,a4,a5
1510: fe842783 lw a5,-24(s0)
1514: 00f707b3 add a5,a4,a5
1518: fef42423 sw a5,-24(s0)
151c: fec42783 lw a5,-20(s0)
1520: 00178793 addi a5,a5,1
1524: fef42623 sw a5,-20(s0)
1528: fec42703 lw a4,-20(s0)
152c: 3ff00793 li a5,1023
1530: fae7d6e3 bge a5,a4,14dc <testmad+0x20>
1534: dcdff0ef jal ra,1300 <stop_all_counter>
1538: e01ff0ef jal ra,1338 <liststatus>
153c: d8dff0ef jal ra,12c8 <clear_all_counter>
1540: 00000013 nop
1544: 01c12083 lw ra,28(sp)
1548: 01812403 lw s0,24(sp)
154c: 02010113 addi sp,sp,32
1550: 00008067 ret
其中的计算乘累加的循环,在14dc到1530之间,22条指令,只完成了一个乘累加计算。测试结果如下(每行两个数字,前一个是执行的指令数量,后一个是占的百分比,由于是整数运算,百分比不是很准确):
total : 23057, 102
add/sub : 3072, 13
mul : 1024, 4
div : 0, 0
ld : 7172, 31
st : 2053, 9
jmp : 3, 0
j : 1025, 4
alui : 6149, 27
alu : 4096, 18
可以看出,执行过程中27%的指令是用来计算各种数组寻址以及循环变量等,40%的指令在执行load/store,只有17%的指令在执行我们期望的乘累加计算。注意这个统计与CPU核的实现无关。如果是复杂指令集,比如DSP中,乘累加包括地址递增和load/store,都表达在一条指令中,因此执行效率会高很多。同样的主频,同样流水线实现方式下无限接近平均每周期一条指令,DSP的执行效率可以达到RISC-V的十倍以上,当然,CPU现在轻松可以设计到2GHz主频,DSP似乎主频很难上去,再加上CPU采用一些比如硬件多线程的处理,可以提高ALU单元的执行效率,所以综合的执行效率,还真是难说得很呐。
我们稍微修改一下源代码,不采用数组操作,情况要好点:
static void testmad()
{
int i;
int total;
unsigned int* pcoeff = coeff;
unsigned int* pbuf = buf;
total = 0;
clear_all_counter();
for (i = 0; i < LEN; i++)
total += *pcoeff++ * *pbuf++;
stop_all_counter();
liststatus();
clear_all_counter();
}
编译的结果是:
00001488 <testmad>:
1488: fe010113 addi sp,sp,-32
148c: 00112e23 sw ra,28(sp)
1490: 00812c23 sw s0,24(sp)
1494: 02010413 addi s0,sp,32
1498: 000037b7 lui a5,0x3
149c: 50878793 addi a5,a5,1288 # 3508 <coeff>
14a0: fef42223 sw a5,-28(s0)
14a4: 000047b7 lui a5,0x4
14a8: 50878793 addi a5,a5,1288 # 4508 <buf>
14ac: fef42023 sw a5,-32(s0)
14b0: fe042423 sw zero,-24(s0)
14b4: e15ff0ef jal ra,12c8 <clear_all_counter>
14b8: fe042623 sw zero,-20(s0)
14bc: 0400006f j 14fc <testmad+0x74>
14c0: fe442783 lw a5,-28(s0)
14c4: 00478713 addi a4,a5,4
14c8: fee42223 sw a4,-28(s0)
14cc: 0007a703 lw a4,0(a5)
14d0: fe042783 lw a5,-32(s0)
14d4: 00478693 addi a3,a5,4
14d8: fed42023 sw a3,-32(s0)
14dc: 0007a783 lw a5,0(a5)
14e0: 02f70733 mul a4,a4,a5
14e4: fe842783 lw a5,-24(s0)
14e8: 00f707b3 add a5,a4,a5
14ec: fef42423 sw a5,-24(s0)
14f0: fec42783 lw a5,-20(s0)
14f4: 00178793 addi a5,a5,1
14f8: fef42623 sw a5,-20(s0)
14fc: fec42703 lw a4,-20(s0)
1500: 3ff00793 li a5,1023
1504: fae7dee3 bge a5,a4,14c0 <testmad+0x38>
1508: df9ff0ef jal ra,1300 <stop_all_counter>
150c: e2dff0ef jal ra,1338 <liststatus>
1510: db9ff0ef jal ra,12c8 <clear_all_counter>
1514: 00000013 nop
1518: 01c12083 lw ra,28(sp)
151c: 01812403 lw s0,24(sp)
1520: 02010113 addi sp,sp,32
1524: 00008067 ret
主循环只有18条指令,效率提高了20%,执行后的统计结果如下:
total : 18452, 100
add/sub : 1024, 5
mul : 1024, 5
div : 0, 0
ld : 7172, 38
st : 4101, 22
jmp : 3, 0
j : 1025, 5
alui : 4101, 22
alu : 2048, 11
当然这可能与编译器相关,有汇编高手来试试看用汇编程序优化一下这个过程不看看能否提高效率,个人认为,不管如何优化,每个循环过程中,两个LD指令,一个ST指令,一个乘法指令,一个加法指令,还有循环控制的一个比较,一个跳转,一个循环参数递增,两个地址递增指令应该是必要的,这样一次乘累加计算也得有10条指令,汇编的效率能提高两倍。DSP高手做核心算法实现时,往往也时直接写汇编的。
用x86的gcc编译器编译的结果是:
0000000000401c55 <testmad>:
401c55: 55 push %rbp
401c56: 48 89 e5 mov %rsp,%rbp
401c59: 48 83 ec 20 sub $0x20,%rsp
401c5d: 48 c7 45 f0 40 51 40 movq $0x405140,-0x10(%rbp)
401c64: 00
401c65: 48 c7 45 e8 40 61 40 movq $0x406140,-0x18(%rbp)
401c6c: 00
401c6d: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp)
401c74: b8 00 00 00 00 mov $0x0,%eax
401c79: e8 e1 fe ff ff callq 401b5f <clear_all_counter>
401c7e: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
401c85: eb 2d jmp 401cb4 <testmad+0x5f>
401c87: 48 8b 45 f0 mov -0x10(%rbp),%rax
401c8b: 48 8d 50 04 lea 0x4(%rax),%rdx
401c8f: 48 89 55 f0 mov %rdx,-0x10(%rbp)
401c93: 8b 08 mov (%rax),%ecx
401c95: 48 8b 45 e8 mov -0x18(%rbp),%rax
401c99: 48 8d 50 04 lea 0x4(%rax),%rdx
401c9d: 48 89 55 e8 mov %rdx,-0x18(%rbp)
401ca1: 8b 00 mov (%rax),%eax
401ca3: 0f af c8 imul %eax,%ecx
401ca6: 89 ca mov %ecx,%edx
401ca8: 8b 45 f8 mov -0x8(%rbp),%eax
401cab: 01 d0 add %edx,%eax
401cad: 89 45 f8 mov %eax,-0x8(%rbp)
401cb0: 83 45 fc 01 addl $0x1,-0x4(%rbp)
401cb4: 81 7d fc ff 03 00 00 cmpl $0x3ff,-0x4(%rbp)
401cbb: 7e ca jle 401c87 <testmad+0x32>
401cbd: b8 00 00 00 00 mov $0x0,%eax
401cc2: e8 9f fe ff ff callq 401b66 <stop_all_counter>
401cc7: b8 00 00 00 00 mov $0x0,%eax
401ccc: e8 9c fe ff ff callq 401b6d <liststatus>
401cd1: b8 00 00 00 00 mov $0x0,%eax
401cd6: e8 84 fe ff ff callq 401b5f <clear_all_counter>
401cdb: 90 nop
401cdc: c9 leaveq
401cdd: c3 retq
循环核心代码在401c87到401cbb之间,共16条指令,X86指令集不算是RISC指令集,因此似乎效果要好点,不过没有根本区别了,这点好处其实还不如RISC-V实现简单带来的好处呢(意味着同样的面积和功耗下预期的主频更容易提高)。主要得益于它的寻址方式中自己带了一个地址计算单元,在RISC-V中地址计算也是通过alu来做的。
当然,如果硬要RISC-V实现DSP的功能,干脆扩展一个DSP指令子集好了。不过这个就不是RISC-V指令集的问题范畴了。
3. 流水线实现方式,前面我们做的是用状态机是实现,一个指令分为取指令状态,读RS1状态,读RS2状态,执行状态,回写结果寄存器状态,读操作数(LD指令)状态,写结果(ST指令)状态,等待除法结果状态,最简单的指令需要执行其中前五个状态,也就是说至少五个周期才能执行一条指令。这当然是一种为了提高可读性的做法,实际的CPU实现不大可能采用这么低效的实现方式。实际上实现方法有两种:一种是单周期实现,一个周期内完成指令执行和结果修改,同时送出读下条指令的命令,这种方式有可能对运行频率有影响,也要求寄存器必须实现为当前拍能够读到结果的部件,对很多要求高频率运行的场合是有限制的。另一种方式就是所谓的流水线实现方式,一条指令可以在多个状态,但是不需要等前一条指令执行完成,才执行另一条指令,可以把取指,指令译码,执行,回写等操作流水起来,每一个周期都执行取指,译码,执行回写等操作,这样实现平均每个周期可期望达到1条指令。当然这样实现也存在存储器冲突,跳转指令带来的流水线损失(跳转指令执行时,后续指令的取值及译码的结果可能会失效),指令之间寄存器依赖(比如前面一条的目的寄存器是后面一条的源寄存器,此时两条指令就有寄存器依赖。)。这些问题的处理方式直接影响实际的运行效果。为了处理跳转指令,可能有分支预测的处理方式,为了处理寄存器依赖,可能有所谓的乱序执行的处理手段,减少对流水线造成的影响。设置为了处理存储器的速度匹配,还引入了高速缓冲,或者干脆引入硬件多线程,充分发挥CPU中各个组件的应用效率。
4. 中断处理和异常处理,这个需要CPU能够响应外部的信号,执行事先规定好的代码,此时需要有诸如保护现场等操作,比如可以支持时钟中断和软中断。
5. 多任务支持,能够在一个CPU中实现多个任务分时运行。
6. SIMD支持,多个核共享一个指令流,可以用在大规模数据处理的场合,比如GPU。
7. 高速缓存支持,这已经不是CPU核需要考虑的了,但是一般的CPU设计都会考虑这方面的支持。
总之,CPU设计涉及到很多方面的考虑和权衡,只有在指定的应用场合下提供最合适的实现方式,才是最优的方案,离开应用考核CPU实现都不合适。
16.2 CPU核的完善
前面一章实现的在FPGA上运行的版本,功能还不完全,由于是演示性质的,性能方面也没有过多追求,为了让这个核有一定的使用价值,我们做如下改进:
- 实现了不对齐情况下的LOAD/STORE指令,比如代码:
unsigned int test = 0;
const unsigned char testdata[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
*(unsigned int*)(&testdata[1]) = 0x99887766;
test = *(unsigned int*)(&testdata[2]);
对&testdata[1]地址进行32为整数写操作,就是一个非对齐地址的操作,我们内部增加了一个状态,如果遇上非对齐的写,就再写一个字,用写操作的字节使能实现部分写。对&testdata[2]的读则是一个非对齐地址的读操作,此时我们读两个字,然后拼装出一个正确的读结果出来。
-
实现了CSR指令和几个基本的寄存器。支持的CSR寄存器包括:
reg [31:0] misa; /0301/
reg [31:0] ucycle; /0c00/
reg [31:0] utime; /0c01/
reg [31:0] uinstret; /0c02/
reg [31:0] ucycleh; /0c80/
reg [31:0] utimeh; /0c81/
reg [31:0] uinstreth; /0c82/
reg [31:0] mcycle; /0b00/
reg [31:0] minstret; /0b02/
reg [31:0] mcycleh; /0b80/
reg [31:0] minstreth; /0b82/
后面是CSR地址,使用时如果需要更多的CSR寄存器,可以自行扩展。 -
减少了几个状态,主要是回写结果的状态合并在执行中,并将非存储访问指令的读指令周期合并在执行完成的状态中。注意到我们的实现中,寄存器是放在核外用一个ram实现的,指令存储器和数据存储器则共享一个存储读写接口,没有实现流水线模式,因此一条指令最少要两个周期,一个周期读指令,一个周期读寄存器,指令译码和执行则穿插在两个周期中间。如果是访问存储器(外设)指令,则还需要增加一个存储数据存储器周期,那就要三个周期,如果是非对齐地址访问,则要额外增加一个周期。如果是除法指令,我们采用12级流水实现,除法(取余数)指令需要12个周期才能执行一条指令。我们还把读两个源寄存器的两个状态合并到一个状态中,办法是用两个一模一样的RAM来实现寄存器文件,读的时候可以分别不同的地址从两个存储体中读,写的时候则两个存储体同时写一模一样的值,这样可以用一个单口的RAM来实现一写二读的接口(当然读写不能同时),代价是存储空间需要两倍。我们坚持使用单端口的RAM实现寄存器文件,主要是为了减少对FPGA的依赖,在某些FPGA中或AISC的工艺中,实际上是不提供双端口RAM的,当然也可以用寄存器来实现,但是代价要高很多。
这样处理的目的,主要是保持核的简单性,并保证主频,目前在CycloneV的器件上(DE1-SOC开发板)能够综合到60MHz。核也比较小, 下面是在DE1-SOC上综合的结果:
最坏情况的时序能达到60MHz,此时关键路径在乘法器上,如果把乘法器用4拍流水线实现,则可以综合到Worst Case 80MHz,实际上常温跑到100MHz是没有问题的,然而此时增加了寄存器占用,并增加了指令执行的周期数(跑调试程序应用的话,大概从3.18增加到3.36,可以根据应用场景衡量是否合算了):
-
尝试过将寄存器放在核内,单周期实现,但是此时主频下降很厉害,而且占用了宝贵的片内逻辑单元,所以还是放在核外来实现,将来如果想通过增加多组寄存器实现多个hart(硬件多线程),扩展起来也是 比较简单的。
-
软件支持方面,在编译过程中如果使用scanf, printf,sprintf等newlib的库,编译后的代码会增加很多,至少需要128KB的存储器,一般的FPGA项目有点用不起了。因此为了减少内存占用,我们干脆不用newlib中的相关的函数,自己做一些支持性的函数,这样做内存只需要16KB,就能做一个可以交互的调试终端出来。软件应用程序编译之后,作为一个数据文件直接综合到FPGA的烧录文件中(作为RAM的初始化数据文件),这样做简化了软件的存放处理。可以做成一个比较成熟通用的调试程序,编译成数据文件的方式在不同的项目中复用。
这么修改之后,代码量增加了不少,CPU核部分verilog已经有800多行了。主要是实现CSR以及非对齐访问。
16.3 增加串口设备
为了支持一个通过串口连接的调试应用,我们为这个应用提供了一个串口设备。串口的核直接采用Altera的Quartus II直接生成,生成的代码比较长,下面是它的接口信号:
module altera_uart (
// inputs:
address,
begintransfer,
chipselect,
clk,
read_n,
reset_n,
rxd,
write_n,
writedata,
// outputs:
dataavailable,
irq,
readdata,
readyfordata,
txd
);
这是一个通过读写内部寄存器能够实现收发以及控制的电路实现。它也提供了dataavailable核readyfordata信号来指示数据可读以及可写。由于我们的CPU没有支持中断,这样软件只能能用轮询的方式来访问这个核,为了不丢数据并提高发送效率,我们为这个核外面包装一层,并增加发送缓冲区和接收缓冲区(FIFO实现)。包装过程如下:
module uart_ctrl(
input wClk,
input nwReset,
input wRead,
input [31:0] bReadAddr,
input wWrite,
input [31:0] bWriteAddr,
input [31:0] bWriteData,
output [31:0] bReadData,
output uart_tx,
input uart_rx
);
wire [31:0] ctl_state;
reg [7:0] send_buf_data;
wire [7:0] send_buf_q;
wire send_buf_full;
wire send_buf_empty;
wire [9:0] send_buf_used;
reg send_buf_read;
reg send_buf_write;
uart_fifo uart_send_buf(
.clock(wClk),
.data(send_buf_data),
.rdreq(send_buf_read),
.wrreq(send_buf_write),
.almost_full(send_buf_full),
.empty(send_buf_empty),
.full(),
.q(send_buf_q),
.usedw(send_buf_used));
reg [7:0] recv_buf_data;
wire [7:0] recv_buf_q;
wire recv_buf_empty;
wire recv_buf_full;
wire [9:0] recv_buf_used;
reg recv_buf_read;
reg recv_buf_write;
uart_fifo uart_recv_buf(
.clock(wClk),
.data(recv_buf_data),
.rdreq(recv_buf_read),
.wrreq(recv_buf_write),
.almost_full(recv_buf_full),
.empty(recv_buf_empty),
.full(),
.q(recv_buf_q),
.usedw(recv_buf_used));
reg [2:0] uart_addr;
reg uart_read, uart_write;
reg [15:0] uart_write_data;
wire [15:0] uart_read_data;
wire uart_has_data;
wire uart_can_send;
altera_uart uart(
// inputs:
.address(uart_addr),
.begintransfer(1'b1),
.chipselect(1'b1),
.clk(wClk),
.read_n(~uart_read),
.reset_n(nwReset),
.rxd(uart_rx),
.write_n(~uart_write),
.writedata(uart_write_data),
// outputs:
.dataavailable(uart_has_data),
.irq(),
.readdata(uart_read_data),
.readyfordata(uart_can_send),
.txd(uart_tx)
);
reg [15:0] lastdiv;
reg [15:0] newdiv;
reg [7:0] ctrlstate;
reg [15:0] waitclk;
always @(posedge wClk)
if (~nwReset) begin
uart_read <= 1'b0;
uart_write <= 1'b0;
uart_addr <= 3'b0;
recv_buf_write <= 1'b0;
send_buf_read <= 1'b0;
recv_buf_data <= 8'b0;
uart_write_data <= 16'b0;
ctrlstate <= 0;
waitclk <= 0;
lastdiv <= 50000000 / 38400;
end else begin
uart_read <= 1'b0;
uart_write <= 1'b0;
recv_buf_write <= 1'b0;
send_buf_read <= 1'b0;
if (ctrlstate == 0) begin
if (uart_has_data && ~recv_buf_full) begin
uart_read <= 1'b1;
uart_addr <= 3'b0;
ctrlstate <= 2;
end else if (uart_can_send && ~send_buf_empty) begin
send_buf_read <= 1'b1;
uart_addr <= 3'b1;
uart_write_data <= {8'b0, send_buf_q};
uart_write <= 1'b1;
ctrlstate <= 7;
end else if (lastdiv != newdiv) begin
uart_addr <= 3'h4;
uart_write <= 1'b1;
uart_write_data <= newdiv;
lastdiv <= newdiv;
ctrlstate <= 1;
end
end else if (ctrlstate == 1) begin
ctrlstate <= 0;
end else if (ctrlstate == 6) begin
if (~uart_has_data)
ctrlstate <= 0;
end else if (ctrlstate == 7) begin
if (~uart_can_send)
ctrlstate <= 0;
end else if (ctrlstate == 2) begin
ctrlstate <= 3;
end else if (ctrlstate == 3) begin
recv_buf_data <= uart_read_data[7:0];
recv_buf_write <= 1'b1;
ctrlstate <= 6;
end
end
/*
地址 0 -- 读接收数据
1 -- 写发送数据
2 -- 得到状态
[0] -- send buffer full
[10:1] -- send buffer used
[16] -- recv buffer empty
[26:17] -- recv buffer used
*/
assign ctl_state = {5'b0, recv_buf_used, recv_buf_empty, 5'b0, send_buf_used, send_buf_full};
/* 读命令处理 */
reg [31:0] readdata;
assign bReadData = readdata;
wire [5:0] readaddr = bReadAddr[7:2];
always @(posedge wClk)
if (~nwReset) begin
readdata <= 32'h0;
recv_buf_read <= 1'b0;
end else begin
recv_buf_read <= 1'b0;
if (wRead) begin
if (readaddr == 2) begin /* state */
readdata <= ctl_state;
end else if (readaddr == 0) begin
readdata <= {recv_buf_empty, 23'b0, recv_buf_q};
recv_buf_read <= ~recv_buf_empty;
end
end
end
/* 写命令处理 */
wire [5:0] writeaddr = bWriteAddr[7:2];
always @(posedge wClk)
if (~nwReset) begin
send_buf_write <= 1'b0;
newdiv <= 50000000 / 38400;
end else begin
send_buf_write <= 1'b0;
if (wWrite && (writeaddr == 1)) begin
send_buf_write <= ~recv_buf_full;
send_buf_data <= bWriteData[7:0];
end else if (wWrite && (writeaddr == 4)) begin
newdiv <= bWriteData[15:0];
end
end
endmodule
这样我们就可以将它挂在CPU的外部总线上,我们设置访问地址是0xF0000100开始,偏移0为读接收数据,偏移1为写发送数据,偏移2为读状态,偏移4可以写波特率。状态的格式如下:
[0] – send buffer full
[10:1] – send buffer used
[16] – recv buffer empty
[26:17] – recv buffer used
软件实现时,发送之前你应该等待发送缓冲不在满状态,也就是等待状态中的[0]位为0,否则可能导致数据丢失。其中[10:1]表示发送缓冲已经占用的数目,目前我们的发送缓冲区能存储1024个数据因此可以计算出能够写入的数据,此时就可以连续写对应的数据个数,不会造成丢失。接收之前应该确保[16]为0,表示接收缓冲有数据,此时[26:17]中存储了接收缓冲区中数据的个数,可以连续读这么多个数据。
这个核的uart_tx和uart_rx信号就接到FPGA的管脚上,用来连接串口的收发线。在开发板上我们选用GPIO[5]和GPIO[7]作为收发信号连接。注意到FPGA的这两个管脚是TTL电平,不能直接连接到计算机的串口上,因此我们购买了USB转TTL的电缆,一段是USB接口,接到计算机上之后通过电缆中的CH340芯片生成一个串口设备,另一端有TTL电平的串口收发线,还有一个5V的电源和一个地线。这个用来做个设备比较方便,连5V电源都提供了啊。
注意连接的时候收发线应该交叉连接,并且需要把地线连接到开发板的地上,否则可能造成计算机和开发板的信号地不一致,增加误码率。
下面是顶层模型中的连接,我们把0xf0000100地址开始的256字节地址连接到串口设备上:
wire uart_tx;
wire uart_rx;
assign GPIO[5] = uart_tx;
assign GPIO[7] = 1'bz;
assign uart_rx = GPIO[7];
uart_ctrl uart_ctrl(
.wClk(wClk),
.nwReset(nwReset),
.wRead(((bReadAddr & 32'hffffff00) == 32'hf0000100)?wRead:1'b0),
.bReadAddr(bReadAddr),
.wWrite(((bWriteAddr & 32'hffffff00) == 32'hf0000100)?wWrite:1'b0),
.bWriteAddr(bWriteAddr),
.bWriteData(bWriteData),
.bReadData(bReadDataUart),
.uart_tx(uart_tx),
.uart_rx(uart_rx)
);
16.4 一个简单的串口终端软件
根据16.3中的设计,串口的读写代码如下:
#define UARTADDRESS (unsigned int *)0xf0000100
#define REFFREQ 50000000
volatile unsigned int* _uartaddr = UARTADDRESS;
volatile unsigned int _uartstate;
/*
地址 0 -- 读接收数据
1 -- 写发送数据
2 -- 得到状态
[0] -- send buffer ready
[10:1] -- send buffer used
[16] -- recv buffer ready
[26:17] -- recv buffer used
*/
int _canputchar()
{
_uartstate = _uartaddr[2];
return ((_uartstate & 1) == 0);
}
int _haschar()
{
_uartstate = _uartaddr[2];
return ((_uartstate & 0x10000) == 0);
}
int _putchar(int ch)
{
_uartstate = _uartaddr[2];
if ((_uartstate & 1) == 0) {
_uartaddr[1] = ch;
return 0;
}
return -1;
}
int _getchar()
{
_uartstate = _uartaddr[2];
if ((_uartstate & 0x10000) == 0) {
return _uartaddr[0];
}
return -1;
}
int _buadrateset(int baud)
{
_uartaddr[4] = REFFREQ / baud;
return 0;
}
有这几个基本的函数,增加了几个辅助性的函数,就可以写一个终端程序出来。目前终端只支持五条命令:
help
d <addr> -- display memory
b <baudrate> -- set baudrate
r <addr> <width> -- read memory word
w <addr> <value> <width> -- write memory word
width=1, 2 or 4
比如
w f0000014 3f3f0707 4 命令就可以把4字节字0x3f3f0707写到地址0xf0000014上,设置第4,5号数码管显示为77(DE2-115开发板上同时让第6,7号数码管显示00)。
w f0000015 5b 1 命令则写一个字节5b到地址0xf0000015位置,让5号数码管显示2。
w f0000104 66 1 命令写字节66到串口发送地址0xf0000104位置,让串口发送字符"f"。
w f0000104 67 1 命令写字节67到串口发送地址0xf0000104位置,让串口发送字符"g"。
10a358023c:53d62a1bc:317>>w f0000015 01 1
:w f0000015 01 1
10d41fe713:54ecb077d:317>>w f0000015 5b 1
:w f0000015 5b 1
12447bf891:5bd4f737b:318>>w f0000104 66 4
:w f0000104 66 4
f1403610ea7:645cac694:319>>w f0000104 67 4
:w f0000104 67 4
g145233f37d:65ed6ba11:318>>
其中的f1403610ea7前面的f就是串口收到的f字符。
r f0000000 4 命令则可以读到按键和拨码开关的状态。
为了方便掌握CPU的运行状态,我们在每个循环通过CSR指令读CSR寄存器mcycle和minstret,得到已经运行的周期数和执行的指令数,并算出它们的比值,显示在每条指令执行后的提示符之前,两个寄存器是通过嵌入式汇编使用CSR指令实现的:
unsigned long long cycle() {
unsigned long long ret;
unsigned int retl, reth;
asm volatile (
"csrrsi %0, %1, %2 " : "=r"(retl) : "i"(0xc00),"i"(0):"a0"
);
asm volatile (
"csrrsi %0, %1, %2 " : "=r"(reth) : "i"(0xc80), "i"(0) : "a0"
);
ret = reth;
ret <<= 32;
ret |= retl;
return ret;
}
unsigned long long instrcount() {
unsigned long long ret;
unsigned int retl, reth;
asm volatile (
"csrrsi %0, %1, %2 " : "=r"(retl) : "i"(0xc02), "i"(0) : "a0"
);
asm volatile (
"csrrsi %0, %1, %2 " : "=r"(reth) : "i"(0xc82), "i"(0) : "a0"
);
ret = reth;
ret <<= 32;
ret |= retl;
return ret;
}
返回的计数器值是64位的,用16进制方式显示在提示符号之前:12447bf891:5bd4f737b:318>> 其中12447bf891是运行的周期数,5bd4f737b是执行的指令个数,318是两个数字的比值(乘以100),表示平均每3.18个周期执行一条指令。
应用时可以自行扩展所需要的命令,也可以关闭一些不需要的特征,减少资源消耗,特别是应用程序的空间占用。
下面终端的主程序和运行结果:
static void printhelp()
{
_puts(" d <addr> -- display memory \n");
_puts(" b <baudrate> -- set baudrate \n");
_puts(" r <addr> <width> -- read memory word\n");
_puts(" w <addr> <value> <width> -- write memory word\n");
_puts(" width=1, 2 or 4\n");
}
int main(int argc, char* argv[])
{
volatile unsigned int* ledkey = (unsigned int*)0xF0000000;
volatile unsigned int* leddata = (unsigned int*)0xf0000010;
unsigned char ledd[20];
unsigned int count0;
unsigned int count1;
unsigned int ctemp;
_buadrateset(115200);
count0 = 0;
count1 = 0;
do {
char buf[256];
int rate;
rate = cycle() / (instrcount() / 100);
if (_canputchar()) {
_h2s(buf, cycle(), 8, '0');
_puts(buf);
_puts(":");
_h2s(buf, instrcount(), 8, '0');
_puts(buf);
_puts(":");
_d2s(buf, rate);
_puts(buf);
_puts(">>");
}
do {
if (_haschar()) {
_gets(buf, 255);
break;
}
count0++;
if (count0 > 10000) {
count1++;
count0 = 0;
ctemp = count1;
ledd[0] = num2seg(ctemp);
ledd[1] = num2seg(ctemp / 10);
ledd[2] = num2seg(ctemp / 100);
ledd[3] = num2seg(ctemp / 1000);
leddata[0] = *(unsigned int*)&ledd[0];
}
else {
ledd[0] = num2seg(count0);
ledd[1] = num2seg(count1 / 10);
ledd[2] = num2seg(count1 / 100);
ledd[3] = num2seg(count1 / 1000);
leddata[0] = *(unsigned int*)&ledd[0];
}
} while (1);
_puts("\n\r:");
_puts(buf);
_puts("\n\r");
if (_strncmp(buf, "help ", 4) == 0) {
printhelp();
}
else if (buf[0] == 'b') {
int baud = _s2d(buf+2, 0);
if (baud > 0) {
_buadrateset(baud);
}
else {
printhelp();
}
}
else if (buf[0] == 'd') {
int addr = _s2h(buf + 2, 0);
if (addr > 0) {
displayaddr = addr;
}
dispmem();
}
else if (buf[0] == 'w') {
char* next;
int addr = _s2h(buf + 2, &next);
int value = _s2h(next, &next);
int width = _s2h(next, &next);
if (width == 1) {
*(char*)addr = value;
}
else if (width == 2) {
*(short*)addr = value;
}
else if (width == 4) {
*(int*)addr = value;
}
else {
printhelp();
}
}
else if (buf[0] == 'r') {
char* next;
int value = 0;
int addr = _s2h(buf + 2, &next);
int width = _s2h(next, &next);
if (width == 1) {
value = *(char*)addr;
_puts("char @");
}
else if (width == 2) {
value = *(short*)addr;
_puts("short @");
}
else if (width == 4) {
value = *(int*)addr;
_puts("int @");
}
else {
printhelp();
}
if (width == 1 || width == 2 || width == 4) {
_h2s(buf, addr, 8, '0');
_puts(buf);
_puts(" = ");
_d2s(buf, value);
_puts(buf);
_puts("(");
_h2s(buf, value, width * 2, '0');
_puts(buf);
_puts(")\n\r");
}
}
} while (1);
return 1;
}
这个应用程序首先把串口波特率设置到115200,然后进入循环。每个循环中先检测是否有串口输入,如果有,则读入一条命令并执行,如果没有则进行周期计数,并把计数结果显示在数码管上。显示时还通过CSR指令读入当前的时钟计数和指令计数,并得到两者的比值。串口终端中显示运行结果如下(我们在计算机上用PuTTY软件选择串口5与开发板连接):
这其中的提示符号中,前面两个参数分别是CSR指令读出来的时钟周期数和指令计数,最后一个数是两个数的比值乘以100,可以看到跑这个应用时平均每三个时钟周期能执行一条指令,估计是读写存储器(包括串口)的指令比较多吧。
16.5 总结和展望
这个学习活动从五月初开始,历时四个月,基本上实现了一开始设定的几个小目标,目前剩下一个尾巴就是编译器还没有完成,这个活动可以告一段落了。
将来可能会进行更多的设计,比如将RISC-V CPU核设计更加实用,用流水线方式实现,或者实现硬件多线程MIMD控制,或者实现多核共享指令流的SIMD控制,增加浮点指令,增加中断和异常的实现,增加指令Cache和数据Cache等等,甚至使用这个架构来实现通用计算,支持GPU或者DSP或者一些特殊的计算需求。CPU核设计的工作空间非常广大,其实不管如何设计都是追求在同样的面积(或FPGA资源)下单位时间运行更多的指令,增加主频是一个办法,提高内部各个部件的使用效率,采用流水线方式实现让各个部件不要空转也是一个办法,总能为特别的应用设计出合适的电路出来。
用一个CPU核占用了FPGA10%左右的资源,只是提供了一个能够读写存储器的调试模块出来,对FPGA应用来说似乎有点不合算,但是FPGA开发就是很麻烦的一件事情,有时候为了知道实际的FPGA运行起来时内部到底发生了什么事情,可以各种办法想尽,此时有个灵活的可编程的模块来支持一下,似乎也是个解决方案呢。
【请参考】
01.HDL4SE:软件工程师学习Verilog语言(十五)
02.HDL4SE:软件工程师学习Verilog语言(十四)
03.HDL4SE:软件工程师学习Verilog语言(十三)
04.HDL4SE:软件工程师学习Verilog语言(十二)
05.HDL4SE:软件工程师学习Verilog语言(十一)
06.HDL4SE:软件工程师学习Verilog语言(十)
07.HDL4SE:软件工程师学习Verilog语言(九)
08.HDL4SE:软件工程师学习Verilog语言(八)
09.HDL4SE:软件工程师学习Verilog语言(七)
10.HDL4SE:软件工程师学习Verilog语言(六)
11.HDL4SE:软件工程师学习Verilog语言(五)
12.HDL4SE:软件工程师学习Verilog语言(四)
13.HDL4SE:软件工程师学习Verilog语言(三)
14.HDL4SE:软件工程师学习Verilog语言(二)
15.HDL4SE:软件工程师学习Verilog语言(一)
16.LCOM:轻量级组件对象模型
17.LCOM:带数据的接口
18.工具下载:在64位windows下的bison 3.7和flex 2.6.4
19.git: verilog-parser开源项目
20.git: HDL4SE项目
21.git: LCOM项目
22.git: GLFW项目
23.git: SystemC项目