E7 简易总线和SoC
你已经设计了NPC, 也了解了设备如何工作. 不过我们之前是让仿真环境来提供设备的行为模型功能, 但在真实的硬件中, 设备控制器是RTL模块, 处理器应该通过一种纯硬件的方式和它们通信. 这种方式就是总线.
总线的基本概念
总线的本质是一种通信协议, 它可以存在于各种模块之间的通信之中. 例如, 处理器要访问存储器, 它们之间也存在一种通信协议.
+-----+ +-----+
| CPU | <-----> | MEM |
+-----+ +-----+
一般称主动发起通信的模块为master(主设备), 称响应通信的模块为slave(从设备). 例如, 在上述例子中, 处理器是master, 而存储器则是slave.
事实上, 总线的设计有两层含义:
- 在协议层次上, master和slave之间需要在"如何互相通信"这个问题上达成一致
- 在实现层次上, 当我们设计master和slave的接口时, 需要使用电路来将协议的内容实现出来
系统总线
连接处理器和存储器以及设备之间的总线通常称为系统总线. 事实上, 我们之前是用DPI-C机制来模拟系统总线的功能: NPC通过DPI-C机制和仿真环境提供的pmem_read()等接口访问存储器, 这个过程没有读延迟, 收到读请求的当前周期就可以返回读数据. 但这只是用于方便我们实现NPC的通信, 实际上并不存在这样的存储器器件.
与DPI-C机制相比, 在真实的芯片中, 处理器对存储器的访问的最大不同, 是访问过程存在延迟. 换句话说, 处理器从发起一个读请求, 到收到存储器回复的数据, 中间需要等待若干周期. 这要求处理器额外实现以下机制:
- 需要识别存储器回复的数据何时达到
- 在存储器回复的数据到达前, 处理器需要等待, 直到回复的数据到达后, 处理器才能继续执行当前指令, 这意味着, 处理器将无法满足"每周期都执行一条指令"的属性
- 进一步地, 处理器的取指操作并非每个周期都需要进行, 例如等待存储器回复的过程中就不应该取指, 因此需要告知存储器何时真正进行取指
不过, 真实芯片中通常采用类似AXI的工业级总线协议, 细节繁多, 例如AXI总线协议总计约30多个信号. 这意味着, 如果要让真实的芯片和设备进行通信, 原则上就需要在处理器中实现这些总线协议. 为了减轻大家的负担, 我们先从一套最简单的自定义总线协议SimpleBus开始.
在实现总线之前, 我们先来简单讨论处理器的性能评估.
计算处理器的IPC
IPC是Instruction Per Cycle, 表示处理器平均每个周期执行多少条指令, 用于反应处理器执行指令的能力, 是评价处理器性能表现的一个重要指标.
为了计算IPC, 你首先需要统计处理器运行一个程序时, 经过了多少个周期, 以及执行了多少条指令. 得到这两个数据之后, 将它们相除即可.
目前IPC应该为1. 这种每周期都执行一条指令的处理器, 称为单周期处理器. 你会在实现总线之后重新计算IPC.
访问只读存储器
由于从存储器中读出数据是最基本的需要, 我们首先考虑处理器如何通过系统总线完成读操作. 假设处理器规格固定为Nx32, 即存储器包含N个字, 每个字为32位. 同时假设该存储器的读延迟固定为1周期, 这实际上是一种同步存储器, 即从收到读请求到返回数据之间的延迟是固定的, SRAM的访问特性正是如此.
如果不考虑写操作, 我们只需要一个只读存储器(ROM, Read-Only Memory)即可. 为了从ROM中读出数据, 相应的总线只需要两个信号, 分别为地址和数据, 二者的位宽分别为log2(N)和32.
+-----+ raddr[log2(N)-1:0] ---> +-----+
| CPU | <--- rdata[31:0] | MEM |
+-----+ +-----+
其通信协议为:
- master(CPU)向slave(MEM)发送读地址
raddr - 下个周期slave向master回复数据
rdata - 上述行为每周期都发生
由于指令存放在存储器中, 因此IFU在进行取指操作时, 也需要访问存储器. 同时, 取指过程不会向存储器中写入数据, 因此一个只读存储器就可以支撑IFU进行工作. 为了模拟访存过程存在延迟的现象, 存储器接收到取指请求后, 不能马上返回读出的指令, 而是需要延迟一个周期后才返回读出的指令.
根据上文, 对IFU来说, SimpleBus涉及如下信号:
output [31:0] ifu_raddr,
input [31:0] ifu_rdata,
这些信号的时序如下图所示:
--\ /----------\ /----------\ /----------\ /----------\ /--
ifu_raddr X 0x80000000 X (1) X 0x80000004 X X
--/ \----------/ \----------/ \----------/ \----------/ \--
--\ /----------\ /----------\ /----------\ /----------\ /--
ifu_rdata X X 0x00000413 X (2) X 0x80051137 X
--/ \----------/ \----------/ \----------/ \----------/ \--
为了让NPC实现"等待存储器读出指令"的功能, 我们首先要让IFU得知当前处于取指令的哪个阶段, 并在不同的阶段采取不同的策略. 这种"在不同时候做不同事情"的功能, 可以通过数字电路的状态机来实现! 具体地, 我们可以为IFU实现idle和wait两种状态:
- 在
idle状态下, 需要将ifu_raddr设置为pc, 并跳转到wait状态 - 在
wait状态下, 将ifu_rdata作为有效指令继续执行, 并跳转到idle状态
根据前文的总线协议, 由于通信每周期都发生, 因此向存储器发送地址请求的同时, 存储器也会返回前一周期对应的数据. 例如, 在上图中, NPC发送地址为0x80000004的取指请求时, 存储器同时也返回了记号为(2)的数据, 这个数据对应前一个周期中记号为(1)的地址请求. 如果NPC将(2)作为一条有效指令来执行, 它要么重复执行了地址为0x80000000对应的指令, 要么执行了一条其他位置的指令, 无论是何者, NPC的行为将不符合ISA的约定. 因此, 在idle状态下, NPC不应该将ifu_rdata视为有效的指令来执行, 而是应该不执行任何指令.
之前我们默认NPC每周期都能执行一条指令, 但实现SimpleBus后, NPC在等待指令返回前, 并没有有效的指令可以执行, 因此我们需要考虑"如何让NPC不执行指令". 回顾计算机系统的状态机模型, 处理器执行指令的过程就是改变处理器状态的过程, 换句话说, 我们只要想办法让处理器的状态保持不变, 就能达到"不执行指令"的效果. 聪明的你应该想到解决方案了: 处理器的状态就是时序逻辑元件, 只要让它们的写使能信号无效, 时序逻辑元件的值就不会发生变化. 因此, 你还需要在合适的状态下, 正确地设置NPC中各种时序逻辑元件的写使能信号.
支持SimpleBus的IFU
根据上文, 让IFU支持SimpleBus协议. 对于存储器的取指部分, 你可以参考如下代码:
always @(posedge clock) begin
ifu_rdata <= pmem_read(ifu_raddr);
end
对于LSU的数据访问部分, 目前无需修改, 我们接下来再让它支持SimpleBus.
实现后, 尝试运行一些测试程序, 同时通过查看波形来确认NPC和存储器之间的通信过程是否符合预期. 原则上来说, 总线协议对上层程序是透明的, 因此之前能成功运行的程序, 实现SimpleBus后也应同样能成功运行.
不过由于此时存储器需要经过1周期才能读出数据, 这时候NPC已经不是一个严格意义上的单周期处理器了, 而是一个简单的多周期处理器:
- 在第1个周期, IFU发出取指请求
- 在第2个周期, IFU拿到指令, 并交给后续的模块译码并执行
让DiffTest适配多周期处理器
修改成多周期处理器后, NPC就并非每个周期都执行一条指令了. 为了让DiffTest机制可以正确工作, 你需要对检查的时机稍作调整: 只有当一条指令执行结束时, 才进行DiffTest的检查. 为此, 你可能需要从RTL中通过DPI-C读取一些状态, 来帮助你判断应该什么时候进行DiffTest的检查.
计算处理器的IPC(2)
让IFU支持SimpleBus后, 重新计算IPC.
访问可读可写存储器
由于LSU需要执行store指令, 因此需要在总线中添加新的信号来支持写操作:
- 首先自然需要写地址
waddr和写数据wdata - 由于写操作并非每个周期都发生, 因此还需要添加写使能信号
wen- 虽然读操作也并非每个周期都发生, 例如对于上述改成多周期处理器的NPC, 在
wait状态下并不需要向存储器发送取指请求; 但由于读操作不会改变电路状态, 因此理论上读使能并非必须 - 不过实际中一般还是有读使能
ren, 如果没有读请求, 存储器就无需进行读操作, 从而节省能耗
- 虽然读操作也并非每个周期都发生, 例如对于上述改成多周期处理器的NPC, 在
- 写操作可能只写入一个字当中的若干字节(例如
sb指令只写入1字节), 因此还需要添加写掩码信号wmask, 用于指定写入数据中的哪些字节
+-----+ addr[log2(N)-1:0] ---> +-----+
| | wen ---> | |
| CPU | wdata[31:0] ---> | MEM |
| | wmask[3:0] ---> | |
| | <--- rdata[31:0] | |
+-----+ +-----+
由于LSU不会同时执行load指令和store指令, 因此raddr和waddr不会被同时使用, 故我们可以将raddr和waddr合并为addr.
同时, 通信协议需要增加写操作行为的定义, 我们用伪代码来表示:
if (wen) {
// wmask_full为wmask按比特展开的结果
M[waddr] = (wdata & wmask_full) | M[waddr] & ~wmask_full;
}
和IFU类似, 我们也来让LSU按照SimpleBus协议的约定来访问存储器. 对LSU来说, 由于store指令需要写入存储器, 故SimpleBus涉及如下信号:
output [31:0] lsu_addr,
output lsu_wen,
output [31:0] lsu_wdata,
output [ 3:0] lsu_wmask,
input [31:0] lsu_rdata,
对于读操作, wdata和wmask可取任意值, 相关信号的时序如下图所示:
--\ /----------\ /------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------
---+ +-------------------------
lsu_wen | |
+------------+
------------------------------------------
lsu_wdata
------------------------------------------
------------------------------------------
lsu_wmask
------------------------------------------
---------------\ /----------\ /-----------
lsu_rdata X 0x12345678 X
---------------/ \----------/ \-----------
对于写操作, rdata为无关信号, 相关信号的时序如下图所示:
--\ /----------\ /------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------
+------------+
lsu_wen | |
---+ +-------------------------
--\ /----------\ /------------------------
lsu_wdata X 0x12345678 X
--/ \----------/ \------------------------
--\ /----------\ /------------------------
lsu_wmask X 0xf X
--/ \----------/ \------------------------
------------------------------------------
lsu_rdata
------------------------------------------
支持SimpleBus的LSU
根据上文, 让LSU支持SimpleBus协议. 对于存储器的数据访问部分, 你可以参考如下代码:
always @(posedge clock) begin
lsu_rdata <= (!lsu_wen) ? pmem_read(lsu_addr) : 32'b0;
if (lsu_wen) begin
pmem_write(lsu_addr, lsu_wdata, lsu_wmask);
end
end
此时可以保留pmem_read()/pmem_write()中的设备访问功能, 我们将在后续讲义中介绍如何通过总线访问外设.
让LSU支持SimpleBus后, 对于load指令, NPC需要3个周期才能完成:
- 在第1个周期, IFU发出取指请求
- 在第2个周期, IFU拿到指令, 并交给后续的模块译码, 发现是load指令后, 则通过LSU发出访存请求
- 在第3个周期, LSU拿到数据, 并交给WBU写回寄存器
因此, 你还需要对IFU进行修改, 让load指令执行结束后, 再取出下一条指令.
实现后, 尝试运行一些测试程序, 同时通过查看波形来确认NPC和存储器之间的通信过程是否符合预期. 同样地, 之前能成功运行的程序, 实现SimpleBus后也应同样能成功运行.
计算处理器的IPC(3)
让LSU支持SimpleBus后, 重新计算IPC.
更普遍的存储器
事实上, 读延迟为1周期的特性通常只有SRAM能够满足, 因为它能够使用与处理器制造相同的工艺进行生产, 但SRAM的价格十分昂贵. 为了实现更低成本的存储器, 通常会采用其他存储密度更大的工艺来制造存储器, 例如DRAM. 但由于电气特性, 这些存储器的读延迟通常大于处理器的1周期.
这时处理器不能一直发送读请求, 否则由于处理器的请求速率大于存储器的服务速率, 导致存储器会一直被无用的请求占据, 严重降低整个系统的工作效率. 为了解决这个问题, 处理器需要告诉存储器何时发送有效的请求. 为此, 我们可以在处理器往存储器发送的信号中添加一个新信号reqValid, 让存储器据这个信号来判断何时有真正的请求. 当处理器需要访存时, 就设置好addr和其他信号, 并将reqValid设置为有效; 当处理器不需要访存时, 就将reqValid设置为无效. 而对于存储器来说, 当reqValid信号有效时, 才需要访问addr对应的数据; 当reqValid信号无效时, 存储器将不进行访问操作.
另一方面, 存储器何时能读出数据也是无法提前得知的. 例如DRAM会周期性地对存储单元的电容进行充电刷新, 如果此时收到读请求, 将会在充电刷新结束之后才会真正读出数据. 因此, 存储器也需要告诉处理器何时能返回有效的数据. 类似地, 为了识别存储器的回复何时达到, 我们可以在处理器接收存储器回复的信号中添加一个新信号respValid, 让处理器根据这个信号来判断存储器的回复何时有效. 以读操作为例, 当存储器读出数据时, 就设置好rdata, 并将respValid设置为有效; 当存储器还未读出数据时, 就将respValid设置为无效. 而对于处理器来说, 当respValid信号有效时, 才认为rdata为有效的数据; 当respValid信号无效时, 处理器将认为数据还没有返回, 需要继续等待.
+-----+ reqValid ---> +-----+
| | addr[log2(N)-1:0] ---> | |
| | wen ---> | |
| CPU | wdata[31:0] ---> | MEM |
| | wmask[3:0] ---> | |
| | <--- respValid | |
| | <--- rdata[31:0] | |
+-----+ +-----+
以LSU为例, 扩展后的SimpleBus涉及以下信号:
output lsu_reqValid,
output [31:0] lsu_addr,
output lsu_wen,
output [31:0] lsu_wdata,
output [ 3:0] lsu_wmask,
input lsu_respValid,
input [31:0] lsu_rdata,
对于读操作, wdata和wmask可取任意值, 相关信号的时序如下图所示:
+------------+
lsu_reqValid | |
---+ +-------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------------------------------
--\ /------------------------------------------------
lsu_wen X X
--/ ------------ \------------------------------------------------
------------------------------------------------------------------
lsu_wdata
------------------------------------------------------------------
------------------------------------------------------------------
lsu_wmask
------------------------------------------------------------------
+------------+
lsu_respValid | |
----------------------------------------+ +------------
---------------------------------------\ /----------\ /-----------
lsu_rdata X 0x12345678 X
---------------------------------------/ \----------/ \-----------
对于写操作, 虽然rdata为无关信号, 但SimpleBus约定仍然通过respValid指示写操作已完成. 相关信号的时序如下图所示:
+------------+
lsu_reqValid | |
---+ +-------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------------------------------
--\ ------------ /------------------------------------------------
lsu_wen X X
--/ \------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_wdata X 0x12345678 X
--/ \----------/ \------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_wmask X 0xf X
--/ \----------/ \------------------------------------------------
+------------+
lsu_respValid | |
----------------------------------------+ +------------
------------------------------------------------------------------
lsu_rdata
------------------------------------------------------------------
为SimpleBus添加有效信号后, 我们还需要对状态机进行扩展:
- 在
idle状态下, 如果当前需要访存, 就设置好的reqValid和addr等信号, 并跳转到wait状态; 如果当前不需要访存, 就停留在idle状态 - 在
wait状态下, 如果respValid有效, 就将rdata作为读出的数据继续执行, 并跳转到idle状态; 如果respValid无效, 就停留在wait状态, 从而实现等待的效果 - 上述过程描述的是读操作, 对于写操作来说, 过程是类似的
支持有效信号的SimpleBus协议
根据上文, 让IFU和LSU根据有效信号来访问存储器. 对于存储器的数据访问部分, 你可以参考如下代码:
always @(posedge clock) begin
lsu_rdata <= (lsu_reqValid && !lsu_wen) ? pmem_read(lsu_addr) : 32'b0;
if (lsu_reqValid && lsu_wen) begin
pmem_write(lsu_addr, lsu_wdata, lsu_wmask);
end
lsu_respValid <= lsu_reqValid;
end
可通过类似方式对存储器的取指部分进行修改.
实现后, 尝试运行一些测试程序, 同时通过查看波形来确认NPC和存储器之间的通信过程是否符合预期. 同样地, 之前能成功运行的程序, 在扩展SimpleBus后也应同样能成功运行.
计算处理器的IPC(4)
实现支持有效信号的SimpleBus后, 重新计算IPC.
测试SimpleBus的实现
在存储器中添加随机延迟的功能, 来测试总线实现是否能在任意延迟下正确工作. 你可以按照从简单到复杂的顺序添加访存延迟:
- 将存储器的访问延迟依次修改成5, 10, 20等
- 在存储器模块中添加一个LFSR, 通过它生成的伪随机数来决定当前请求的延迟
- 在IFU和LSU中也添加LFSR, 通过它来决定相应
valid信号的延迟
如果NPC在充满LFSR的随机延迟下仍然能正确运行程序, 就能大大增强你对代码的信心.
接入SoC
你已经在NPC中实现了SimpleBus总线协议, 但设备的功能仍然需要通过仿真环境提供的行为模型来实现. 有了SimpleBus, 我们就可以让NPC接入SoC, 来跟真正的设备控制器进行通信.
获取ysyxSoC的代码
你需要克隆ysyxSoC项目:
cd ysyx-workbench
bash init.sh ysyxSoC
需要注意的是, ysyxSoC与最终流片使用的SoC仍有一定差异. 因此, 通过ysyxSoC的测试并不代表最终也能通过流片SoC仿真环境的测试. 但即使这样, 也可以借助ysyxSoC项目提前暴露一部分问题.
ysyxSoC项目中包含较多细节, 你会在后续学习中再深入了解SoC的相关内容. 目前, 我们专门准备了一份经过简化的SoC代码. 这个SoC的架构图如下(部分设备未列出):
+------+
+-------+ +-> | UART |
| +---+ | +--------+ | +------+
| |IFU| | <-> | | +-----+ +------+ |
| +---+ | | Memory | | AXI | | APB | <-+ +-----+ +-------+
| CPU | | | <-> | to | <-> | | <---> | SPI | <-> | Flash |
| +---+ | | Bridge | AXI | APB | APB | XBar | <-+ +-----+ +-------+
| |LSU| | <-> | | +-----+ +------+ |
| +---+ | +--------+ | +-------+
+-------+ +-> | PSRAM |
SimpleBus +-------+
这份简化后的SoC代码中还包含一个转接桥模块, 来将SimpleBus转换成AXI. 有了这个转接桥, 就可以将NPC接入到AXI接口的SoC中, 并与各种设备进行通信. 目前, 你不需要了解AXI, APB和各种设备的细节.
目前SoC包含如下关键设备(部分设备未列出):
| 设备 | 地址空间 |
|---|---|
| UART16550 | 0x1000_0000~0x1000_0fff |
| SPI master | 0x1000_1000~0x1000_1fff |
| Flash | 0x3000_0000~0x3fff_ffff |
| PSRAM | 0x8000_0000~0x807f_ffff |
| Reserve | 其他 |
接入ysyxSoC
依次按照以下步骤将NPC接入ysyxSoC:
- 调整NPC顶层接口, 使其与
ysyxSoC/ready-to-run/minirv/cpu-interface.md中的接口命名规范完全一致, 包括信号方向, 命名和数据位宽- 其中
io_lsu_size用于与外设进行通信, 由LSU根据访存指令的数据位宽来设置: 位宽为1字节时设置为2'b00, 为2字节时设置为2'b01, 为4字节时设置为2'b10
- 其中
- 将NPC中的PC复位值修改为
0x3000_0000, 即复位后从Flash取指令 - 将
ysyxSoC/perip目录及其子目录下的所有.v文件和.sv文件加入verilator的Verilog文件列表 - 将
ysyxSoC/perip/uart16550/rtl和ysyxSoC/perip/spi/rtl两个目录加入verilator的include搜索路径中- 具体如何加入, 请RTFM(
man verilator或verilator的官方手册)- 如果你从来没有查阅过verilator有哪些选项, 我们建议你趁这次机会认真阅读一下手册中的
argument summary, 你很可能会发现一些新的宝藏
- 如果你从来没有查阅过verilator有哪些选项, 我们建议你趁这次机会认真阅读一下手册中的
- 具体如何加入, 请RTFM(
- 在verilator编译选项中添加如下选项:
--timescale "1ns/1ns"--no-timing-D__VERILOG__-DPDK_BEHAV-D__UART_TO_CONSOLE__
- 将
ysyxSoC/ready-to-run/minirv/ElaborateTop.v加入verilator的Verilog文件列表 - 将
SimTop模块(在ysyxSoC/ready-to-run/minirv/ElaborateTop.v中定义)设置为verilator仿真的顶层模块 - 将
ysyxSoC/ready-to-run/minirv/ElaborateTop.v中的ysyx_00000000模块名修改为你的处理器的模块名 - 在仿真的cpp文件中加入如下内容, 用于解决链接时找不到
flash_read的问题extern "C" void flash_read(int32_t addr, int32_t *data) { assert(0); } - 仿真顶层的复位信号需要维持至少10个周期
- 通过verilator编译出仿真可执行文件
- 如果你遇到了组合回环的错误, 请自行修改你的RTL代码
- 尝试开始仿真, 你将观察到代码触发了
flash_read()中的assert(0)错误, 我们接下来再解决这个问题- 如果没有触发这个错误, 请检查总线的实现
在SoC上运行第一个程序
针对ysyxSoC, 我们提供了一个hello程序, 它位于ysyxSoC/ready-to-run/minirv/hello-minirv-ysyxsoc.bin.
在ysyxSoC上运行hello程序
为了运行这个hello程序, 你需要让仿真环境将这个程序放到Flash存储器中. 具体地, 你需要在仿真的cpp文件中定义一个16MB的数组作为Flash存储器, 然后实现flash_read()函数, 根据地址addr返回Flash存储器中的相应数据. 然后, 将需要运行的.bin文件读入到Flash存储器中, 从而让NPC从Flash中取出程序的第一条指令.
按照上述方式让NPC运行这个hello程序. 这个程序需要运行大约30秒, 如果你的实现正确, 你将看到程序输出Hello信息后陷入死循环.
上面的hello程序的.bin文件是我们直接提供的, 接下来你需要自己将程序编译到ysyxSoC上并运行.
在真实的计算机中, 一般的存储器是易失存储器(volatile memory), 例如SRAM和DRAM, 它们在上电时并没有存放有效数据. 如果上电后CPU直接从内存中读取指令执行, 存储器读出什么数据是未定义的, 因此整个系统的行为也是未定义的, 从而无法让CPU执行预期的程序. 因此, 需要使用一种非易失存储器(non-volatile memory)来存放最初的程序, 使其内容能在断电时保持, 并在上电时能让CPU马上从中取出指令.
在ysyxSoC中, 非易失存储器由Flash充当, 易失存储器由PSRAM充当. 其中, PSRAM是一种特殊的DRAM. 接入ysyxSoC后, NPC复位后将从位于0x30000000的Flash中取出第一条指令. 但由于对处理器来说, Flash无法直接通过访存指令写入, 因此通常需要将程序从Flash中加载到可写入的非易失存储器(如PSRAM)后再执行.
上述的hello程序已经包含了这个过程. 事实上, 上述.bin文件中还包含一个加载器(loader)程序, 它位于地址0x30000000的位置. NPC执行这个.bin文件时, 首先执行的是加载器. 加载器的工作是将真正的hello程序从Flash中加载到位于0x80000000的PSRAM中, 因此你会看到程序通过串口输出类似loading to memory region [0x80000000, 0x8004896c)的信息. 加载完成后, NPC将会跳转到hello程序的入口, 即0x80000000, 然后取出hello程序的指令并执行.
由于加载器在Flash中运行, 而Flash对NPC来说无法通过访存指令写入, 因此在加载器进行链接的时候需要进行一些额外的处理. 为了减轻大家的负担, 目前我们不要求大家实现加载器, 而是复用上述.bin文件中的加载器. 我们提供了一个脚本, 位于ysyxSoC/ready-to-run/minirv/gen.sh, 用于将其他程序和这个加载器拼接成一个新的.bin文件. 通过这种方式, 你就可以在ysyxSoC中加载并运行其他程序了.
脚本的用法示例如下:
cd am-kernels/tests/cpu-tests
make ARCH=minirv-npc ALL=dummy
cd ysyxSoC/ready-to-run/minirv
bash gen.sh am-kernels/tests/cpu-tests/build/dummy-minirv-npc.elf new.bin
ls new.bin
在SoC上运行自己编译的程序
根据上文的介绍, 尝试在ysyxSoC中运行dummy程序. 由于dummy程序没有输出, 加载器跳转到dummy程序后将会输出HIT GOOD TRAP的信息.
在SoC上运行自己编译的程序(2)
尝试在ysyxSoC中运行cpu-tests和riscv-tests. 你可能需要思考如何高效地运行每一个测试.
访问真实的串口控制器
由于我们接入了ysyxSoC, 其中带有真实的UART控制器UART16550, 而这个UART控制器的一些细节与我们之前实现的简单UART行为模型并不相同, 导致之前编写的putch()无法正确访问真实的UART16550. 这意味着, 如果我们想要自己编译并运行可以输出字符的程序, 我们就需要进一步了解UART16550的细节, 并修改putch()来适配UART16550.
在真实的芯片中, 程序在通过串口输出之前, 还需要对串口进行初始化. 具体地, 初始化过程需要设置串口的传输参数, 包括波特率, 字符长度, 是否带校验位, 停止位的位宽等. 其中, 波特率指每秒传送的字符数. 串口收发端的参数配置要完全一致, 才能正确发送和接收字符. 通常用形如115200 8N1等方式来描述一组参数配置, 它表示波特率是115200, 字符长度是8位, 不带校验位, 1位停止位. 8N1的数据传输示意图如下:
stop ---+
V
--------+ +---+---+---+---+---+---+---+---+---+--------
idle | | D0| D1| D2| D3| D4| D5| D6| D7| idle
+---+---+---+---+---+---+---+---+---+
^
start ---+
其中, 空闲时传输高电平. 有字符需要传输时, 首先传输1位低电平的起始位, 然后依次传输字符的每一位(图中的D0~D7位), 最后传输1位高电平的停止位. 也可将串口配置成9600 7E2, 它表示波特率是9600, 字符长度是7位, 带1位偶校验位, 2位停止位. 若校验位存在, 则位于字符位和停止位之间.
受到电气特性的影响, 在传输距离长, 噪声干扰高的工作环境中, 字符传输成功的概率会降低. 为了提升传输过程的抗干扰能力, UART传输协议通常采用过采样的技术. 例如, 使用16倍过采样技术来识别起始位时, 需要对信号连续采用16次, 当连续8次或以上的采样结果均为低电平时, 才认为识别到了起始位.
波特率的高低也会影响字符传输成功的概率. 波特率越大, 单位时间内传输的字符越多, 一个字符的传输时间越短, 对其进行采样的时间窗口也越短, 因此误码率也越高; 相反, 波特率越小, 误码率越低, 但软件传输一个字符需要等待的时间也越长.
由于UART控制器无法预测将来的工作频率, 一般由软件根据实际的工作频率来设置波特率. 这两者之间存在一个比例, 它反应了一个字符需要占用多少个周期. 假设UART控制器工作在50MHz, 如果波特率采用115200, 则一个字符需要占用50*1000000/115200=434个周期; 如果波特率采用9600, 则一个字符需要占用50*1000000/9600=5208个周期.
考虑到UART采用过采样技术, UART控制器需要确定采样时钟的频率. 这个采样时钟通常由UART控制器的时钟通过分频得到, 因此需要软件配置这个分频系数(或除数), 来间接地设置波特率. 例如, 当UART控制器工作在50MHz, 并采用16倍过采样技术时, 要将波特率设置为115200, 就需要将UART控制器中的除数寄存器配置为50*1000000/115200/16=27.13, 表示每27.13个周期进行一次采样. 你可以RTFM进一步了解除数和波特率之间的关系. 串口的手册位于ysyxSoC/perip/uart16550/doc/UART_spec.pdf.
不过在硬件实现中, 一般只能往除数寄存器配置一个整数, 例如, 上述情况通常是往除数寄存器中写入27, 实际上是每过27/50MHz=540ns进行一次采样.` 当收发端的串口处于不同的工作频率时, 这可能会引入一些传输误差. 这时, 更换波特率可能可以消除这种误差.
UART除数的计算
假设发送端的串口采用上文的配置, 接收端的UART控制器工作在25MHz, 并采用16倍过采样技术. 接收端的除数寄存器应该设置为多少? 实际上接收端是每过多久进行一次采样? 此时两端的传输是否存在误差? 如果将波特率设置为57600呢?
正确实现串口的初始化和传输
你需要修改abstract-machine/am/src/riscv/npc/trm.c中的代码, 实现以下功能:
- 在
trm_init()调用main()函数之前, 设置串口的除数寄存器. 由于ysyxSoC本质上还是一个仿真环境, 没有串口接收端, 也没有电气特性的概念, 因此目前可随意设置上述除数, 不必担心误码率的问题. 当然, 在真实的芯片中, 除数寄存器的设置是需要仔细考量的. - 修改
putch()的代码, 在输出字符前先查询串口发送队列的情况, 当发送队列未满, 才将字符写入到发送队列中.
具体如何设置除数, 如何查询发送队列的情况, 如何往发送队列中写入字符, 你可以RTFM了解这个UART控制器的功能; 也可以RTFSC, 结合UART16550寄存器的RTL实现, 帮助你理解相关的功能.
实现后, 尝试在ysyxSoC上运行自己编译的hello程序, 你会发现hello程序可以正确输出所有字符.
接入NVBoard
ysyxSoC的中的asicTop模块定义了芯片的顶层, 而在真实芯片中, 芯片的引脚需要通过板卡连接到其他设备. 虽然目前你还没有得到真实的芯片, 但我们可以通过之前的NVBoard来帮助你理解这个过程.
UART
我们已经在通过UART16550控制器的帮助下测试了串口的输出功能, 但之前串口的发送端仅仅是通过UART16550控制器代码中的$write系统任务来输出, 并没有涉及将字符进行编码并通过线缆串行传输到接收端的过程. NVBoard集成了一个串口终端, 有了NVBoard, 我们就可以来体会这个过程了!
NVBoard中的串口终端很简单, 它只支持8N1的串口传输配置. 至于波特率, 因为NVBoard中没有时钟频率的概念, 因此采用除数的方式来描述, 也即, 每隔多少个周期对一个比特进行采样. 由于NVBoard是一款软件, 不会涉及由于电气特性导致的误码率, 因此也没有像真实的UART那样采用过采样技术. NVBoard中的除数不支持运行时配置, 但可以通过修改代码来调整. 你可以在nvboard/src/uart.cpp的UART构造函数中进行修改, 具体有两种方式:
- 修改
divisor成员的初值 - 调用
set_divisor()函数来设置
将串口的TX引脚接入NVBoard
你只需要修改NVBoard约束文件, 即可将串口的TX引脚绑定到NVBoard的串口终端上. 关于如何绑定, 你可以参考NVBoard提供的示例. 由于串口控制器已经接入ysyxSoC了, 你无需再修改RTL代码.
成功绑定引脚后, 根据实际情况配置NVBoard中UART的除数. 注意NVBoard中UART的除数和UART16550中除数寄存器中的除数不完全相同, 它们之间存在一定的关系, 你需要通过RTFSC或者RTFM梳理清楚. 这也是对你是否了解串口工作原理的一种考察.
然后重新运行hello程序, 你将看到串口输出的内容不仅出现在命令行终端中, 还会出现在NVBoard右上角的串口终端中.
GPIO
NVBoard上还有LED, 拨码开关等我们在F阶段中接触过的设备, 现在我们也可以将它们使用起来. 由于这些设备的状态可以通过一根信号线来传输, 因此功能十分简单. 一般可以使用芯片上的通用引脚来连接它们, 这些引脚称为GPIO. 通过GPIO, 芯片可以将内部的信号直接输出到芯片外部, 用来驱动一些简单的设备, 例如板卡上的LED灯; 芯片也可以通过GPIO获取外部的一些简单状态, 如板卡上的拨码开关, 按钮等状态.
ysyxSoC集成了一个APB总线接口的GPIO控制器, 并将其映射到CPU的地址空间0x2000_1000~0x2000_100f. 我们只给GPIO控制器分配了16字节的地址空间, 最多支持128个引脚, 通常来说已经足够使用了. 考虑到NVBoard中提供的外设, 适合GPIO使用的包括16个LED灯, 16个拨码开关, 以及8个七段数码管. 因此, 我们对GPIO控制器的寄存器空间作如下分配:
| 地址 | 作用 |
|---|---|
0x0 | 16位数据, 分别驱动16个LED灯 |
0x4 | 16位数据, 分别获得16个拨码开关的状态 |
0x8 | 32位数据, 其中每4位驱动1个七段数码管 |
0xc | 保留 |
ysyxSoC没有提供GPIO控制器内部的具体实现, 我们将它作为作业留给大家. 不过为了实现GPIO控制器, 你需要先了解APB总线协议. APB总线和我们在上文介绍的SimpleBus总线很相似, 但通信的细节有所不同, 你可以通过查阅相关手册来了解这些细节.
通过程序实现流水灯
你需要进行以下工作:
- 在GPIO控制器中实现用于驱动LED的寄存器. 具体地, 如果你选择Verilog, 你需要在
ysyxSoC/perip/gpio/mygpio_top_apb.v中实现相应代码; 如果你选择Chisel, 你可以将Chisel生成的Verilog代码粘贴到mygpio_top_apb.v中. - 接入NVBoard, 将顶层模块
SimTop中的GPIO输出引脚绑定到LED灯 - 编写测试程序, 隔一段时间往上述寄存器写入数据, 从而实现流水灯的效果
- 需要注意的是, 虽然驱动LED的寄存器只有16位, 但由于在minirv中
lh指令会被展开为多条lb指令, 我们还是建议你使用32位数据的指针来读出这个寄存器
- 需要注意的是, 虽然驱动LED的寄存器只有16位, 但由于在minirv中
实现密码锁
与LED类似, 让程序读出读出拨码开关的状态. 你可以在程序中设置一个16位二进制的密码, 程序开始执行时会不断查询拨码开关的状态, 只有当拨码开关的状态与上述密码一致, 程序才继续执行.
通过程序在七段数码管上展示报名号
将你的报名号转化成8个十六进制数, 分别用于驱动8个七段数码管. 假设你的报名号是123456789, 将它转换为十六进制, 得到0x75bcd15, 可令七段数码管显示这个十六进制数.
你可以考虑显示其他数字, 如生日等.
添加简单的控制状态寄存器
在RISC-V中, 有一类用于标识处理器状态的系统寄存器, 称为控制状态寄存器(Control Status Register, CSR). 我们可以通过CSR在硬件中添加一些自定义的信息, 并让程序在运行过程中读出这些信息.
和通用寄存器不同, 一般的计算类指令无法直接访问CSR. 因此, RISC-V还提供了用于在CSR和通用寄存器之间交换数据的CSR指令. 和一般的指令不同, CSR指令会原子地读写同一个CSR寄存器.
CSR指令采用I-型指令格式, 因此CSR的地址空间有12位, 即4096个. 你可以通过查阅RISC-V特权架构手册来了解所有CSR的细节. 不过, 我们不必实现所有的CSR, 我们只需要实现需要用到的CSR, 然后根据CSR地址对它们进行读写即可. 因此你也不必了解手册中每一个CSR的细节, 只需要按需查阅手册即可.
学号CSR
为了标识不同同学的NPC, 我们可以利用CSR中的标识寄存器. 具体地, RISC-V中定义了mvendorid和marchid这两个CSR, 我们可以利用它们存放一些标识信息. 之后, 程序在运行时刻可以通过CSR指令将这些标识信息读到通用寄存器中, 并进一步打印这些信息.
添加学号CSR并输出
具体地, 你需要在NPC中实现csrrs指令, 并添加如下两个CSR:
mvendorid- 从中读出ysyx的ASCII码, 即0x79737978.marchid- 从中读出学号数字部分的十进制表示, 假设你的学号为ysyx_22068888, 若将读出的信息解释为整数, 则应为22068888, 即0x150be98. 不过, "一生一芯"的学号需要通过入学答辩后才能获得, 此时你可以先采用"一生一芯"的报名号, 或其他自定义的ID信息, 如生日等. 等到你获得"一生一芯"的学号后, 再回过头来修改它.
你需要阅读RISC-V特权架构手册, 从中找到这两个CSR的编号等信息.
实现后, 在程序中通过内联汇编读出上述两个CSR的值, 然后通过printf()输出它们. 特别地, 你还可以将学号输出到NVBoard的七段数码管.
Hint: 你可以通过内联汇编asm volatile ("csrr %0, mvendorid" : "=r"(val)); 来将mvendorid中的值读到C语言变量val中. 其中的csrr是一条伪指令, 编译器在生成指令序列时, 会将它展开成你所实现的csrrs指令.
周期数CSR
我们之前是通过行为模型来实现时钟的功能, 但在真实的芯片中并不存在行为模型. RISC-V中定义了mcycle这个CSR, 它是一个每周期增加1的计数器. 有了它, 我们就可以根据处理器的频率将周期数换算成时间, 从而在真实的芯片上实现时钟相关的功能.
根据RISC-V手册的定义, mcycle本身是一个64位的计数器, 在RV32中, 由于通用寄存器的位宽是32位的, 因此需要将这个计数器拆成mcycle和mcycleh这两个32位的CSR进行访问.
添加mcycle
具体地, 你需要在NPC中添加mcycle和mcycleh. 为此, 你需要从手册中找到mcycle和mcycleh的编号等信息.
实现后, 尝试通过内联汇编多次读出mcycle寄存器, 检查其值是否自动递增.
最后, 你还需要修改AM中时钟的实现. 具体地, 你需要修改abstract-machine/am/src/riscv/npc/timer.c中的__am_timer_uptime()函数, 让它读出mcycle, 并通过某个系数将计数器的值换算成时间. 将来在真实的处理器芯片上运行程序时, 这个系数和处理器的工作频率相关. 不过, 在仿真环境中并没有频率的概念, 而且仿真环境中时间流逝的速率和真实时间并不一致, 因此你可以选择一个合适的系数, 让程序读出的时钟流逝速率接近真实时间. 由于这个换算过程是在软件中完成的, 将来在真实的处理器芯片上运行程序时, 可以根据处理器的工作频率调整系数并重新编译程序.
运行时钟测试
修改__am_timer_uptime()函数的实现, 并运行am-tests的real-time clock test测试, 使得测试程序输出信息的间隔接近1秒.
在SoC上进行性能评测
建设中
运行更多程序
尝试在ysyxSoC中运行更多程序. 有的程序需要较长的运行时间, 你可以挑选适合的程序来运行.
你应该能感受到, 对于规模稍大一些的程序, NPC就需要运行很长时间. 事实上, 虽然minirv原则上可以实现RV32I指令集的所有功能, 但为了这一点, 我们需要将那些不属于minirv中的RV32I指令, 翻译成行为等价的几条甚至几十条minirv指令. 也即, 对于一个程序, 相比于将其编译到RV32I, 将其编译到minirv的执行效率将会降低数倍甚至数十倍. 因此, 即使minirv具备运行游戏的潜能, 游戏体验和RV32I相比也会降低数十倍.
我们将会在D阶段中将NPC从minirv的8条指令扩展成RV32I的数十条指令, 通过在硬件中添加更多的功能, 来提升软件运行的效率.
