diff --git a/tool.py b/tool.py deleted file mode 100644 index ac6541d..0000000 --- a/tool.py +++ /dev/null @@ -1,70 +0,0 @@ -import re -import argparse - -def transform_markdown(markdown: str) -> str: - # Define a mapping from original keywords to new formats - mapping = { - 'hint' : 'tip', - 'flag': 'tip', - 'comment': 'info', - 'question': 'info', - 'option': 'info', - 'todo': 'warning', - 'danger': 'danger', - 'caution': 'danger' - } - - # Define the regex pattern for matching the entire block - # This pattern captures three groups: - # 1. (\w+): Matches any word (keyword) following '####' and captures it as the block type. - # 2. (.*?): Matches the title after the keyword, using non-greedy matching to stop at the first newline. - # 3. ((?:>.*?\n|>\s*\n)+): Matches the content block, which consists of lines starting with '> '. - # - (?:>.*?\n): Matches lines starting with '>', followed by any characters, ending in newline. - # - (?:>\s*\n): Matches lines that contain only '>' followed by optional whitespace and a newline. - # - +: Ensures that one or more such lines are matched. - pattern = r'> #### (\w+)::(.*?)\n((?:>.*?\n|>\s*\n)+)' - - # Function to replace matched patterns with the desired format - def replace_block(match): - block_type = match.group(1) # Extract block type (any keyword) - title = match.group(2).strip() # Extract the title - new_format = mapping.get(block_type, 'info') # Look up the new format based on the block type using the mapping - content = match.group(3).replace('>\n', '\n').replace('> ', '').strip() # Clean content lines - return f':::{new_format}[{title}]\n{content}\n:::' # Format block - - transformed_markdown = re.sub(pattern, replace_block, markdown, flags=re.DOTALL) - return transformed_markdown - -def main(input_file, output_file=None, inplace=False): - # Read the content of the input markdown file - with open(input_file, 'r', encoding='utf-8') as infile: - markdown_content = infile.read() - - # Transform the content using the transform_markdown function - transformed_content = transform_markdown(markdown_content) - - # Check if inplace editing is enabled - if inplace: - # Write the transformed content back to the input file - with open(input_file, 'w', encoding='utf-8') as outfile: - outfile.write(transformed_content) - elif output_file: - # Write the transformed content to the specified output file - with open(output_file, 'w', encoding='utf-8') as outfile: - outfile.write(transformed_content) - else: - # Print the transformed content to stdout - print(transformed_content) # Print to stdout if no output file is specified - -if __name__ == '__main__': - # Set up command line argument parsing - parser = argparse.ArgumentParser(description='Transform markdown sections into a new format.') - parser.add_argument('input_file', type=str, help='Path to the input markdown file') - parser.add_argument('-o', '--output_file', type=str, help='Path to the output markdown file') - parser.add_argument('-i', '--inplace', action='store_true', help='Edit the input file in place') # Short flag for in-place editing - - # Parse the command line arguments - args = parser.parse_args() - - # Call the main function with parsed arguments - main(args.input_file, args.output_file, args.inplace) diff --git a/zh/basic/PerfOptimizationAndSimpleCache.md b/zh/basic/1.9.md similarity index 99% rename from zh/basic/PerfOptimizationAndSimpleCache.md rename to zh/basic/1.9.md index 4699070..ba78c8a 100644 --- a/zh/basic/PerfOptimizationAndSimpleCache.md +++ b/zh/basic/1.9.md @@ -1,6 +1,3 @@ ---- -sidebar_position: 9 ---- # 性能优化和简易缓存 | IDU | -+-----+ +-----+ -``` - -在这个简单的场景其实就隐含了总线协议: -一般称主动发起通信的模块为master(主设备), 称响应通信的模块为slave(从设备). -在上述的简单交互场景中, 作为master的IFU向作为slave的IDU发送信息, 发送的信息为当前的指令. -作为单周期处理器, 显然, 二者的通信实际上隐含了如下约定: -* 每个周期master都向slave发送有效信息 -* master一旦发送有效信息, slave能够立刻收到 - -### 异步总线 - -但如果IFU不能保证每个周期都能取到指令, 此时IDU需要等待IFU完成取指. -这种情况下, 就需要在通信内容中增加一个有效信号`valid`, 来指示IFU何时向IDU发送有效的指令. -通信协议也需要更新如下: -* 只有在`valid`信号有效时才认为信息有效 -* master一旦发送有效信息, slave能够立刻收到 - -``` -+-----+ inst ---> +-----+ -| IFU | valid ---> | IDU | -+-----+ +-----+ -``` - -那么, 如何避免让IDU执行无效指令呢? -回想一下处理器的状态机模型, 我们只要在指令无效时让处理器的状态保持不变即可. -在电路层次, 状态就是时序逻辑元件, 因此, 只需在指令无效时将时序逻辑元件的写使能置为无效即可. - -再进一步, 假设有些指令的译码操作过于复杂, IDU需要多个周期才能译码一条指令. -在这种假设下, 当IFU成功取到一条指令时, IDU可能还未完成上条指令的译码, -这时IFU应该先等待IDU完成当前的译码工作, 再向IDU发送下一条指令. -为了实现让IFU等待的功能, 就需要在通信内容中增加一个就绪信号`ready`, 来指示IDU何时能接收下一条指令. -通信协议也需要更新如下: -* 只有在`valid`信号有效时才认为信息有效 -* master发送的信息, 只有在`ready`信号有效时才认为slave收到 - -``` -+-----+ inst ---> +-----+ -| IFU | valid ---> | IDU | -+-----+ <--- ready +-----+ -``` - -这实际上就是异步总线, 两个模块之间何时发生通信是无法提前预知的, 只有在`valid`和`ready`均有效时才发生. -`valid`和`ready`均有效, 也称为"握手", 表示master和slave对信息的成功传递达成共识. -显然, 这种通信协议更加灵活, master和slave将根据自身的工作情况决定何时发送或接收消息. -在`valid`信号有效, 而`ready`信号无效时, IFU需要等待IDU就绪, 此时IFU应暂存将要发送的信息, 以防止信息丢失. - -### 异步总线的RTL实现 - -异步总线通过握手协议确定何时通信, 在RTL层次, 它主要由接口信号和通信逻辑这两部分组成. - -接口信号即需要在模块间传递的信号, 如上述例子中的`inst`, `valid`和`ready`信号. -Chisel提供了Decoupled模板, 自带`valid`和`ready`, 通过元编程可以轻松实现异步总线接口: - -```scala -class Message extends Bundle { - val inst = Output(UInt(32.W)) -} - -class IFU extends Module { - val io = IO(new Bundle { val out = Decoupled(new Message) }) - // ... -} -class IDU extends Module { - val io = IO(new Bundle { val in = Flipped(Decoupled(new Message)) }) - // ... -} -``` - -需要传递更多信息时, 也能很方便地增加信号, 这就是抽象带来的好处! - -```diff - class Message extends Bundle { - val inst = Output(UInt(32.W)) -+ val pc = Output(UInt(32.W)) - } -``` - -接下来是通信逻辑, 也即如何用电路实现上述总线协议. -协议是master和slave双方之间的约定, 而在电路上真正需要实现的, 是双方在遵守这一约定之下的行为. -也即, 双方根据握手信号的不同情况, 进入不同的状态, 从而采取不同的操作. 这就是状态机! -对于master而言, 我们可以很容易画出它的状态转移图. - -``` - +-+ valid = 0 - | v valid = 1 -1. idle ----------------> 2. wait_ready <-+ - ^ | | | ready = 0 - +--------------------------+ +----+ - ready = 1 -``` - -具体地: -1. 一开始处于空闲状态`idle`, 将`valid`置为无效 - 1. 如果不需要发送消息, 则一直处于`idle`状态 - 1. 如果需要发送消息, 则将`valid`置为有效, 并进入`wait_ready`状态, 等待slave就绪 -1. 在`wait_ready`状态中, 同时检测slave的`ready`信号 - 1. 如果`ready`信号有效, 则握手成功, 返回`idle`状态 - 1. 如果`ready`信号无效, 则继续处于`wait_ready`状态等待 - -根据状态转移图, 我们就可以很容易写出相应的RTL代码了. -这里也给出了一段简单的Chisel代码供大家参考: - -```scala -class IFU extends Module { - val io = IO(new Bundle { val out = Decoupled(new Message) }) - - val s_idle :: s_wait_ready :: Nil = Enum(2) - val state = RegInit(s_idle) - state := MuxLookup(state, s_idle)(List( - s_idle -> Mux(io.out.valid, s_wait_ready, s_idle), - s_wait_ready -> Mux(io.out.ready, s_idle, s_wait_ready) - )) - - io.out.valid := 有指令需要发送 - // ... -} -``` - -:::info[用RTL实现IDU的通信] -理解IFU通信的实现过程之后, 尝试分析并画出IDU的状态转移图, 并写出IDU通信的RTL代码. -这只是一个总线的练习, 目前你不必修改NPC中IDU的代码. -::: -当我们完整实现IFU和IDU的通信逻辑后, 上述总线协议也就实现好了. - -## 总线视角下的处理器设计 - -我们可以从总线角度来重新审视处理器设计本身. -假设处理器包含4个模块, 各模块之间需要进行通信: -IFU向IDU发送指令, IDU将译码结果发送给EXU, EXU将计算结果发送给WBU. - -``` -+-----+ inst ---> +-----+ ... ---> +-----+ ... ---> +-----+ -| IFU | valid ---> | IDU | valid ---> | EXU | valid ---> | WBU | -+-----+ <--- ready +-----+ <--- ready +-----+ <--- ready +-----+ -``` - -在这个视角下, 不同微结构的处理器实际上只是模块间的通信协议不同: -* 对于单周期处理器, 相当于每个周期上游发送的消息都有效, 同时下游处于就绪状态来接收新消息 -* 对于多周期处理器, 上游模块空闲时消息无效, 下游模块忙碌时不接收新消息; IFU收到WBU的完成信号后再取下一条指令 - * 和传统课本不同, 这是一个基于消息控制的分布式多周期处理器, - 其中的"分布式"体现在: 两个模块能否互相通信只取决于二者的状态, 和其他模块无关 - -:::info[课本上的多周期处理器] -在多周期处理器中, 一条指令被划分为不同的阶段, 并在不同的周期中执行, 因此每条指令都需要花费多个时钟周期: -* 在第1个周期, IFU取指令并将指令传递到IDU -* 在第2个周期, IDU将译码结果传递给EXU -* 在第3个周期, EXU将计算结果传递给WBU -* 在第4个周期, WBU将结果写回寄存器堆 -* 在下一个周期, IFU再取出下一条指令... -::: -* 对于流水线处理器, 则可以让IFU一直取指, 同时每个模块只要有消息处理完成, 就在每个周期都尝试向下游发送消息 -* 对于乱序执行处理器, 则可以看成在流水线的基础上进行很小的扩展: - 每一个下游模块中都有一个队列, 上游模块只需把消息发送到队列中, 就可以不用关心下游队列的状态继续工作 - -通常, 集中式控制需要有一个全局的控制器, 负责收集所有模块的状态, 再根据这些状态来控制各个模块的下一步工作. -传统课本中介绍的多周期处理器, 通常是一个基于大状态机的集中式多周期处理器. -它通过全局的大状态机来收集每个模块的状态, 从而控制每个模块之间的通信, 并确定下一个状态应该是什么. -例如在当前周期IFU取指完成, 那下一个周期就应该进入译码状态, 并控制IDU进行译码. - -``` - +--------------+ - +-------------> | Controller | <--------------+ - | +--------------+ | - | ^ ^ | - v v v v -+-----+ inst +-----+ ... +-----+ ... +-----+ -| IFU | ------> | IDU | ------> | EXU | ------> | WBU | -+-----+ +-----+ +-----+ +-----+ -``` - -课本上通常只通过少数几条指令来介绍多周期状态机的基本原理, 这时问题并不大. -但集中式控制的可扩展性比较低, 随着模块的数量还有复杂度的提升, 控制器的设计会越来越复杂: -* 随着指令数量增加, 指令的类别也会增加, 这个集中式状态机需要考虑每一类指令执行的所有阶段 -* 如果想在这个处理器中插入一个新阶段, 就需要重新设计控制器 -* 在真实的处理器中, 每个模块的工作时间可能各不相同 - * IFU取指可能存在延迟 - * 而IDU可能只需要一个周期就能完成译码 - * 在EXU中, 不同指令的执行时间可能各不相同 - * RVI中的整数运算指令通常只需要一个周期就能完成计算 - * 除法通常需要执行很久 - * 乘法可能更快一些, 但可能也需要数个周期 - * 访存指令需要等待的时间无法提前预知 -* 一些指令的执行可能会触发异常, 中断也可能会随时到来 - -在这种复杂场景中, 需要考虑每个模块不同状态的组合, 统一的决策是很困难的. - -而上文提到的分布式控制中, 每个模块的行为只取决于自身和下游模块的状态, 每个模块可以独立工作. -例如在乱序执行处理器中, 上游模块可以一直工作, 直到下游模块中的队列满为止. -在分布式控制中, 能够非常容易插入一个新模块, 只需要修改该模块的上下游模块的接口实现即可. -因此, 分布式控制具有更好的可扩展性. - -采用这种基于握手的分布式控制, 可以统一不同微结构的处理器的设计. -此外, 乱序执行处理器天生就是分布式控制的, 这是因为乱序执行处理器中的模块和每个模块的状态都非常多, -且随时可能到来一些不同的事件(如中断到来, 流水线阻塞等), -如果采用集中式控制, 我们几乎无法保证控制器能在每个模块发生不同事件时都做出正确的决策. - -:::info[总线在系统设计中的好处] -你应该可以体会到总线在系统设计中的好处之一: -通过划分模块并在模块之间进行通信, 降低整个系统的设计和维护的复杂度. -集中式控制器需要和每一个模块进行通信, 因此它的设计和维护是最困难的; -通过总线协议将全局通信分解到上下游模块之间, 就消除了集中式控制器, -从而降低了整个处理器的设计复杂度. - -事实上, 计算机领域中的很多例子都是通过消息通信降低系统的复杂度, -例如操作系统中的微内核, 分布式系统中的MPI编程框架, -软件架构中的C/S模型(客户端-服务器模型), 甚至整个互联网都是通过网络包进行通信. - -处理器设计的对象固然是硬件, 但设计模式并不仅仅是一个硬件问题, -我们还是可以从软件领域借鉴一些有用的经验帮助我们把处理器设计这件事做得更好. -::: -利用Chisel中的函数抽象和元编程功能, 我们可以将不同微结构处理器的设计模式统一起来, -并且很容易对处理器微结构进行"升级". - -```scala -class NPC extends Module { - val io = // ... - - val ifu = Module(new IFU) - val idu = Module(new IDU) - val exu = Module(new EXU) - val wbu = Module(new WBU) - - StageConnect(ifu.io.out, idu.io.in) - StageConnect(idu.io.out, exu.io.in) - StageConnect(exu.io.out, wbu.io.in) - // ... -} - -object StageConnect { - def apply[T <: Data](left: DecoupledIO[T], right: DecoupledIO[T]) = { - val arch = "single" - // 为展示抽象的思想, 此处代码省略了若干细节 - if (arch == "single") { right.bits := left.bits } - else if (arch == "multi") { right <> left } - else if (arch == "pipeline") { right <> RegEnable(left, left.fire) } - else if (arch == "ooo") { right <> Queue(left, 16) } - } -} -``` - -:::info[重构NPC] -尝试采用上面的总线思想, 重构NPC. -尽管这并不是强制性的, 但如果你使用Chisel开发, 我们强烈建议你进行重构, -这也是为接下来接入SoC, 以及将来实现流水线提前做好准备. - -如果你使用Verilog开发, 你可能会感到重构工作有一些繁琐. -不过, 我们想强调的是, 设计模式和RTL实现是不同的层面, -尽管你可能会遇到一些困难, 但我们还是鼓励你思考如何通过Verilog实现上述正确的设计模式. -::: -## 系统总线 - -除了上文介绍的处理器内各模块的连接之外, 处理器如何连接到存储器和设备也是至关重要的, -毕竟真实的处理器无法脱离存储器和外设进行工作. -连接处理器和存储器以及设备之间的总线通常称为系统总线. -下面我们以处理器和存储器之间的连接为例, 介绍系统总线应该如何设计. - -### 访问只读存储器 - -由于从存储器中读出数据是最基本的需要, 我们首先考虑处理器如何通过系统总线完成读操作. -假设处理器规格固定为Nx32, 即存储器包含N个字, 每个字为32位. -同时假设该存储器的读延迟固定为1周期, 这实际上是一种同步存储器, -即从收到读请求到返回数据之间的延迟是固定的, SRAM的访问特性正是如此. -事实上, NPC仿真环境提供的`pmem_read()`没有读延迟, 收到读请求的当前周期就可以返回读数据, -但它只是用于方便我们实现单周期处理器, 实际上并不存在这样的存储器器件. - -如果不考虑写操作, 我们只需要一个只读存储器(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` -* 上述行为每周期都发生 - -:::warning[评估单周期NPC的主频和程序性能] -在进一步修改NPC之前, 尝试通过你在预学习阶段中使用的`yosys-sta`项目来评估当前NPC的主频. -不过在评估之前, 你需要进行以下工作: -1. 先运行microbench的train规模测试, 记录其运行结束所需的周期数 -1. 在RTL中注释通过DPI-C调用`pmem_read()`和`pmem_write()`的代码, - 然后为取指和访存各自实例化一个存储器. - 为了保持单周期的特性, 我们需要实例化的存储器需要当前周期就能返回读数据, - 因此我们可以像寄存器堆那样通过触发器实现它. - 如果你使用Verilog, 可以直接实例化`RegisterFile`模块, 当然你需要把端口正确连上. - 为了统一测试结果, 我们约定实例化的存储器大小为256x32b, 即1KB, - 共实例化两个这样的存储器, 总大小为2KB. - -我们之所以这样修改, 是因为单周期NPC要求每个周期都完成一条指令完整的生命周期, -因此无法连接任何现实中的存储器, 只能连带两个类似寄存器堆的存储器一同评估主频. -修改后, 你就可以评估单周期NPC的主频了. - -根据评估的主频和刚才记录的microbench执行的周期数, -就可以估算出将来NPC运行microbench需要多久了. -注意这并非仿真的耗时, 而是假设NPC在上述主频下运行程序的时间. -例如, yzh某个版本的NPC在`yosys-sta`项目默认提供的nangate45工艺下主频为51.491MHz, -因此可以算出microbench需要运行3.870s, 但仿真花费了19.148s. - -当然, 这个估算结果其实并不准确, 而且还可以说是非常乐观的: -* 这个单周期NPC距离可流片的配置还差很远, 例如我们刚才修改存储器的时候, 其实把I/O相关的部分都忽略了 -* 上述主频是综合后的主频, 布局布线之后引入的线延迟会进一步把主频拉低 -* 取指单元对应的存储器因为没有写操作, 被yosys优化掉了 -* 访存单元对应的存储器其实也远远装不下microbench. - 要成功把train规模的测试运行起来, 数据需要占用1MB内存. - 这个大小都已经远远超过实际处理器芯片设计中可以容纳的触发器数量了, - 先不考虑EDA工具的处理时间, 光是在芯片上摆满这么多触发器, - 从占用面积来估算线延迟就已经大得不得了了. - -所以, 这个评估结果的参考意义其实很小, 就当作是给后续的评估练练手吧. -::: - -:::warning[将IFU访问的存储器改造成SRAM] -1. 用RTL为IFU编写一个SRAM模块, 地址位宽为32bit, 数据位宽为32bit -1. 收到读请求后, SRAM模块通过DPI-C调用`pmem_read()`读出数据, - 并延迟1周期返回读出的数据, 来实现一个更真实的存储器 - -不过由于这个SRAM需要经过1周期才能拿到读出的数据, -这时候NPC已经不是一个严格意义上的单周期处理器了, 而是一个简单的多周期处理器: -1. 在第1个周期, IFU发出取指请求 -1. 在第2个周期, IFU拿到指令, 并交给后续的模块译码并执行 - -如果你按照前文的建议重构了NPC, 你会发现将NPC改造成多周期处理器并不难实现. -::: - -:::info[让DiffTest适配多周期处理器] -修改成多周期处理器后, NPC就并非每个周期都执行一条指令了. -为了让DiffTest机制可以正确工作, 你需要对检查的时机稍作调整. -为此, 你可能需要从仿真环境中读取RTL的一些状态, -来帮助你判断应该什么时候进行DiffTest的检查. -::: -### 访问可读可写存储器 - -如果要支持写操作, 就需要在总线中添加新的信号: -* 首先自然需要写地址`waddr`和写数据`wdata` -* 由于写操作并非每个周期都发生, 因此还需要添加写使能信号`wen` - * 虽然读操作也并非每个周期都发生, 例如对于改成多周期处理器的NPC, 第2个周期并不需要取指, - 但由于读操作不会改变电路状态, 因此理论上读使能并非必须 - * 不过实际中一般还是有读使能`ren`, 如果没有读请求, 存储器就无需进行读操作, 从而节省能耗 -* 写操作可能只写入一个字当中的若干字节(例如`sb`指令只写入1字节), - 因此还需要添加写掩码信号`wmask`, 用于指定写入数据中的哪些字节 - -``` -+-----+ raddr[log2(N)-1:0] ---> +-----+ -| | ren ---> | | -| | <--- rdata[31:0] | | -| | waddr[log2(N)-1:0] ---> | | -| CPU | wdata[31:0] ---> | MEM | -| | wen ---> | | -| | wmask[3:0] ---> | | -+-----+ +-----+ -``` - -同时, 通信协议需要增加写操作行为的定义, 我们用伪代码来表示: -```c -if (wen) { - // wmask_full为wmask按比特展开的结果 - M[waddr] = (wdata & wmask_full) | M[waddr] & ~wmask_full; -} -``` - -:::warning[将LSU(访存单元)访问的存储器改造成SRAM] -1. 用RTL为LSU编写一个SRAM模块, 地址位宽为32bit, 数据位宽为32bit -1. 收到读写请求后, 通过DPI-C调用`pmem_read()`/`pmem_write()`, - 并延迟1周期返回读出的数据 - * 此时可以保留`pmem_read()`/`pmem_write()`中的设备访问功能, - 我们将在后续讲义中介绍如何通过总线访问外设 - -实现后, 对于`load`指令, NPC需要3个周期才能完成: -1. 在第1个周期, IFU发出取指请求 -1. 在第2个周期, IFU拿到指令, 并交给后续的模块译码, - 发现是`load`指令后, 则通过LSU发出访存请求 -1. 在第3个周期, LSU拿到数据, 并交给WBU写回寄存器 -::: - -:::info[同时读写同一个地址 - RTFM] -如果同时读写同一个地址, 存储器究竟读出哪个数据呢? -事实上, 具体的行为需要RTFM: 器件的手册规定了该操作的行为, 在部分器件中, 读结果是未定义的. -SRAM和FPGA中的Block RAM大都是这种特性. - -因此, master端尽量不要同时读写同一个地址. -如果实在无法避免, 可以在master端加入检测逻辑, 在检测到读写同相同地址时, -可以延迟写操作, 先读出旧数据; 或者延迟读操作, 先写入数据, 再读出写入后的新数据. -这样, 至少可以保证读结果是有定义的. - -不过, 当前NPC的IFU只会发出读请求, 而LSU则不会同时发出读请求和写请求 -(取决于正在执行的是`load`指令还是`store`指令), 因此当前你无需在NPC中考虑这个问题. -::: -### 更普遍的存储器 - -事实上, 读延迟为1周期的特性通常只有SRAM能够满足, -因为它能够使用与处理器制造相同的工艺进行生产, 但SRAM的价格十分昂贵. -为了实现更低成本的存储器, 通常会采用其他存储密度更大的工艺来制造存储器, 例如DRAM. -但由于电气特性, 这些存储器的读延迟通常大于处理器的1周期. - -这时处理器不能一直发送读请求, 否则由于处理器的请求速率大于存储器的服务速率, -导致存储器会一直被无用的请求占据, 严重降低整个系统的工作效率. -为了解决这个问题, master需要告诉slave何时发送有效的请求, 同时也需要识别slave何时能接收请求. -这可以通过之前介绍的握手实现: 只要在master和slave之间的读协议添加一对握手信号即可, -也即, 添加`rvalid`指示处理器发送的读请求`raddr`有效, 添加`rready`指示存储器可以接收读请求. -这里的`rvalid`实际上也充当了读使能`ren`的作用. - -``` -+-----+ raddr[log2(N)-1:0] ---> +-----+ -| | rvalid ---> | | -| | <--- rready | | -| | <--- rdata[31:0] | | -| CPU | waddr[log2(N)-1:0] ---> | MEM | -| | wdata[31:0] ---> | | -| | wen ---> | | -| | wmask[3:0] ---> | | -+-----+ +-----+ -``` - -事实上, 存储器何时完成读操作也是无法提前得知的, -例如DRAM会周期性地对存储单元的电容进行充电刷新, 如果此时收到读请求, 将会在充电刷新结束之后才会真正读出数据. -另一方面, 处理器也可能因为上一次读出的数据还没用完, 而没有准备好接收存储器返回的数据. -这些问题也都需要通过握手信号来解决: 添加`rvalid`信号表示存储器已经读出有效的数据, -添加`rready`信号表示处理器可以接收存储器返回的数据. -不过这两个信号和上文读请求的握手信号重名了, -为了区分, 我们为读地址相关的握手信号添加前缀`a`表示地址, 即`arvalid`和`arready`. - -``` -+-----+ araddr[log2(N)-1:0] ---> +-----+ -| | arvalid ---> | | -| | <--- arready | | -| | <--- rdata[31:0] | | -| | <--- rvalid | | -| CPU | rready ---> | MEM | -| | waddr[log2(N)-1:0] ---> | | -| | wdata[31:0] ---> | | -| | wen ---> | | -| | wmask[3:0] ---> | | -+-----+ +-----+ -``` - -因此, 在一次读事务的完成过程中, master和slave都需要经历两次握手: -master先等`arready`, 确保slave接收读地址后, 再等`rvalid`接收读数据; -而slave则先等`arvalid`接收读地址, 再等`rready`, 确保master接收读数据. -当然, 在RTL实现层面, 这些都是状态机. - -同样地, 写事务也需要握手, 因此需要添加`wvalid`和`wready`信号: - -``` -+-----+ araddr[log2(N)-1:0] ---> +-----+ -| | arvalid ---> | | -| | <--- arready | | -| | <--- rdata[31:0] | | -| | <--- rvalid | | -| CPU | rready ---> | MEM | -| | waddr[log2(N)-1:0] ---> | | -| | wdata[31:0] ---> | | -| | wmask[3:0] ---> | | -| | wvalid ---> | | -+-----+ <--- wready +-----+ -``` - -:::info[握手信号的微结构设计意义 - 解耦] -从微结构设计来看, 握手信号最重要的意义就是屏蔽通信双方的处理延迟. -例如, DRAM何时读出数据受很多因素的影响, 包括刷新时机, DRAM控制器的请求调度, -DRAM颗粒中的row buffer是否命中, 甚至颗粒的电气特性等. -而CPU何时发送请求并接收数据, 同样也受很多因素的影响, -包括程序何时执行访存指令, 流水线的堵塞情况, 缓存的状态等. -如果通信的时候其中一方要考虑另一方的这些情况, 这样的设计是不可能实现的: -我们没有办法知道另一方处于什么状态. - -握手信号成功地把双方的状态解耦开来, -有了握手信号之后, 双方都无需关心对方模块的状态, 只需要等待握手即可. -因此, 只要模块遵循同一套总线协议, 接入总线后就可以顺利工作. -::: -### 错误处理和异常 - -在部分情况下, 存储器处理读写事务的时候可能会发生错误, -例如读写地址超过了存储区间的范围, 或者通过校验码发现读写的存储单元发生损坏. -在这些情况下, slave通常应该告诉master发生了错误, 让master决定如何处理. -因此, 我们需要在存储器返回读出数据时额外传输一个`rresp`信号, -来指示读操作是否成功, 若不成功, 读数据`rdata`是无效的. -同理, 存储器在处理写操作后也需要返回一个写回复信号`bresp`(这里的`b`表示`backward`), -当然, 这个信号也需要握手. - -``` -+-----+ araddr[log2(N)-1:0] ---> +-----+ -| | arvalid ---> | | -| | <--- arready | | -| | <--- rdata[31:0] | | -| | <--- rresp[1:0] | | -| | <--- rvalid | | -| | rready ---> | | -| CPU | waddr[log2(N)-1:0] ---> | MEM | -| | wdata[31:0] ---> | | -| | wmask[3:0] ---> | | -| | wvalid ---> | | -| | <--- wready | | -| | <--- bresp[1:0] | | -| | <--- bvalid | | -+-----+ bready ---> +-----+ -``` - -在处理器中, 如果读写操作出错, 可以进一步抛出异常, 通知软件进行处理. -例如, RISC-V设置了3种`Access Fault`异常, 分别代表在存储器中取指令, 读数据, 写数据时出错. -这样, 存储器内部的错误就可以通过总线协议传递到处理器, -再通过处理器的异常处理机制告知软件了. - -## 业界中广泛使用的总线 - AXI协议家族 - -我们对上文的总线协议稍作变换: -1. 将写地址和写数据分开, 写地址采用`aw`前缀, 写数据采用`w`前缀 -1. 将`wmask`改名为`wstrb`, 并将信号按功能分成5组 - -``` -araddr ---> araddr ---> araddr ---> -+ -arvalid ---> arvalid ---> arvalid ---> AR -<--- arready <--- arready <--- arready -+ -<--- rdata <--- rdata -<--- rresp <--- rresp <--- rdata -+ -<--- rvalid <--- rvalid <--- rresp | -rready ---> 1 rready ---> 2 <--- rvalid R -waddr ---> ===> awaddr ---> ===> rready ---> -+ -wdata ---> awvalid ---> * -wmask ---> <--- awready * awaddr ---> -+ -wvalid ---> wdata ---> awvalid ---> AW -<--- wready wmask ---> <--- awready -+ -<--- bresp wvalid ---> -<--- bvalid <--- wready wdata ---> -+ -bready ---> <--- bresp wstrb ---> | - <--- bvalid wvalid ---> W - bready ---> <--- wready -+ - - <--- bresp -+ - <--- bvalid B - bready ---> -+ -``` - -这样我们就得到了AMBA AXI手册中的的AXI-Lite总线规范了! -AXI-Lite有5个事务通道, 分别是读地址(AR), 读数据(R), 写地址(AW), 写数据(W)和写回复(B), -它们的工作状态只取决与各自的握手信号, 因此它们可以独立工作, -例如读请求和写请求可以在同一个周期中同时发送. - -在实际使用中, 我们还需要避免和握手信号相关的两种情况: -1. master和slave互相都在等待对方先将握手信号置1: - master在等slave将`ready`置1后, 才将`valid`置1; - 而slave则在等master将`valid`置1后, 才将`ready`置1. - 结果就是双方无限制地等待, 造成[死锁(dead lock)][deadlock]. -1. master和slave都在试探性地握手, 但试探失败后又都取消握手: - * 在第1周期, master将`valid`置1, 但此时`ready`置0, 握手失败 - * 在第2周期, slave发现上一个周期master将`valid`置1, 因此这个周期将`ready`置1; - 但因为上一个周期握手失败, master在这个周期将`valid`置0, 于是这个周期握手仍然失败 - * 在第3周期, master发现上一个周期slave将`ready`置1, 因此这个周期将`valid`置1; - 但因为上一个周期握手失败, slave在这个周期将`ready`置0, 于是这个周期握手仍然失败 - * 结果双方仍然无限制地等待, 造成[活锁(live lock)][livelock]. - -[deadlock]: https://en.wikipedia.org/wiki/Deadlock -[livelock]: https://en.wikipedia.org/wiki/Deadlock#Livelock - -``` - +---+ +---+ +---+ +---+ - clk | | | | | | | | - + +---+ +---+ +---+ +---+ - +-------+ +-------+ -valid | | | - +-------+ +-------- - +-------+ +-------- -ready | | | - +-------+ +-------+ -``` - -:::warning[避免握手的死锁和活锁] -为了避免上述问题, AXI标准规范对握手信号的行为添加了一些约束. -你需要RTFM找到这些约束, 并正确理解它们. - -注意你务必要查阅官方手册, 如果你参考了一些来源不够正规的资料, -你将会在接入SoC的时候陷入痛苦的调试黑洞. -::: -### 让NPC支持AXI-Lite - -至此, 你已经知道了AXI-Lite总线中的每一个信号因何加入, 并从需求角度理解了该总线规范的设计思想. -现在就可以把AXI-Lite加入到NPC中, 让NPC通过AXI-Lite来访问存储器了. - -:::warning[将IFU和LSU的访存接口改造成AXI-Lite] -1. 将IFU和LSU的访存接口改造成AXI-Lite -1. 将IFU和LSU访问的SRAM模块用AXI-Lite进行封装, 但内部还是通过DPI-C来读写数据 - -由于IFU只会对存储器进行读操作, 不会写入存储器, -因此可以将IFU的`AW`, `W`和`B`三个通道的握手信号均置为0. -当然, 更好的做法是在握手信号的另一端通过`assert()`确保它们一直为0. -此外, 虽然目前SRAM模块的访问延迟还是固定的1周期, -但你需要在master和slave两端都正确地用握手来实现AXI-Lite总线协议. -::: - -:::danger[思考后消化的知识才是你真正掌握的] -传统课本上对总线介绍通常只停留在协议层次, 并没有明确介绍如何从RTL层次实现总线, -因此如果大家只阅读课本, 总线仍然是一个相对抽象的概念. -"一生一芯"是一个侧重动手实践的学习项目, 我们在讲义中已经给大家介绍了如何在RTL层次实现总线了. - -不过, 如果你发现在实现的过程中还是存在一些难以克服的困难, -那就要给自己敲响警钟了: 你很可能缺乏将需求一步步转换为代码的能力. -你很可能需要反思你过去的学习方式, 例如: -* 是否过分依赖框图, 导致你缺乏微结构设计能力, 在没有框图的情况下感觉无从下手 -* 更糟糕地, 是否过分依赖那些充满示例代码的资料和书籍, - 导致你不仅缺乏微结构设计能力, 也缺乏将设计转换为代码的能力. - 我们强烈不推荐初学者在自己的第一版代码能工作之前阅读这类资料和书籍! - -我们希望你可以把"独立思考"放在学习的首位, 而不是当代码的搬运工. -如果你发现从现在开始独立思考是一件很困难的事情, -很有可能是因为你在之前的学习中错过了太多, 使得你没有能力独自完成接下来的任务了. -如果真是这样, 我们建议你带着正确的心态重新开始学习"一生一芯". -::: -事实上, 我们现在实现的AXI-Lite只是AXI的简化版, -完整的AXI总线规范有更多的信号和特性, 如果大家感兴趣, 也可以通过RTFM了解相关细节. -我们会在后续讲义中逐渐介绍更多我们需要使用的特性. - -:::danger[端正心态, 在迭代开发和调试中逐渐理解所有细节] -不过, 作为一个业界中广泛使用的总线协议, AXI的细节还是不少, -导致大部分初学者其实很难在首次接触AXI的时候就可以理解得十分透彻. -因此, 大家也需要端正心态, "一次把总线代码全部写完, 以后就不用改"的想法是不现实的. - -事实上, 所有人都是在迭代开发和调试中逐渐理解总线的所有细节. -资深工程师学习新知识的时候尚且如此, 初学者想一步登天的想法并不符合学习的客观规律. -::: - -:::warning[测试总线的实现] -在SRAM模块中添加随机延迟的功能, 来测试总线实现是否能在任意延迟下正确工作. -当然, 你可以按照从简单到复杂的顺序添加访存延迟: -1. 将SRAM的访问延迟依次修改成5, 10, 20等 -1. 在SRAM模块中添加一个LFSR, 通过它来决定当前请求的延迟 -1. 在IFU和LSU中也添加LFSR, 通过它来决定`AR`/`AW`/`W`通道中`valid`信号的延迟, - 以及`R`/`B`通道中`ready`信号的延迟 - -如果NPC在充满LFSR的随机延迟下仍然能正确启动RT-Thread, 就能大大增强你对代码的信心. -::: -### 总线的仲裁 - -经过上述改动, 我们实例化了两个SRAM模块. -但在真实的硬件中, 这两个SRAM模块应该都对应到同一个存储器, -这一点目前是通过`pmem_read()`/`pmem_write()`访问仿真环境中唯一的存储器来实现的. -但在真实的硬件中不存在`pmem_read()`/`pmem_write()`这样的仿真环境功能, -因此需要考虑如何在硬件实现上让IFU和LSU访问同一个存储器. - -这其实是一个多master的问题, 既然slave同时只能接收一个请求, 那就通过一个仲裁器(Arbiter)来决策: -当多个master同时访问同一个slave时, 获得访问权的master将得到放行, 可以成功访问slave; -其他master的请求将阻塞在仲裁器, 等待获得访问权的master访问结束后, 它们才能获得接下来的访问权. - -简单整理一下, 仲裁器需要实现如下功能: -1. 调度: 选择一个正在发送有效请求的master -1. 阻塞: 阻塞其他master的访问 -1. 转发: 将获得访问权的master的请求转发给slave, 并在slave的请求到达时, 将其转发给之前的master - -多个master同时访问时具体如何选择, 其实是一个调度策略的问题. -在复杂系统中, 调度策略还需要考虑更多问题: -首先要避免饥饿, 即任一个master都能在有限次仲裁后获得访问权; -其次还要避免死锁, 即仲裁器造成的阻塞不应使整个系统出现循环等待的现象. -不过目前NPC是一个多周期处理器, IFU和LSU的master不会同时发送请求, -因此你也不必考虑复杂的调度策略, 选择任意简单的调度策略即可. - -:::warning[实现AXI-Lite仲裁器] -保留一个AXI-Lite的SRAM模块, 编写一个AXI-Lite仲裁器, 从IFU和LSU中选择一个master与SRAM模块通信. - -Hint: 仲裁器本质上也是一个状态机, 而阻塞和转发的功能本质上是通过操作握手信号来实现的. -::: - -:::warning[评估NPC的主频和程序性能] -实现了AXI-Lite之后, NPC就可以外接实际的SRAM了, -我们将要评估的对象是带有一个AXI-Lite接口的NPC, 其中包含刚才实现的AXI-Lite仲裁器, -而通过DPI-C实现的AXI-Lite接口的SRAM模块则不在评估范围内. - -按照同样的评估方式, yzh另一个版本的NPC在`yosys-sta`项目默认提供的nangate45工艺下主频为297.711MHz, -因此可以算出microbench需要运行1.394s, 但仿真花费了29.861s. -可以看到仿真时间增加了, 这是因为多周期NPC的IPC小于单周期NPC, 需要花费更多的周期数来执行程序. -虽然IPC下降了, 但因为主频大幅提升, 因此程序反而执行得更快了. - -别忘了, 上面的单周期NPC评估结果是非常乐观的, 甚至是乐观到实际中不可行的程度. -但这个多周期NPC的评估结果就真实多了, 至少1MB SRAM是可以实现的. -不过这还是和我们将要流片的配置差别很大, 毕竟1MB SRAM的流片成本仍然很高. -接下来我们会接入SoC, 使得评估结果更接近流片场景. -::: - -:::danger[为什么要先实现总线?] -过去有很多同学都对这个问题感到十分困惑, 甚至有同学认为这是讲义给大家挖的坑, -最大的原因是他们认为将来实现流水线的时候所有代码都需要推翻重写. -另一种声音是, 如果先写单周期, 后面也是要推翻重写. - -之所以会让这些同学觉得后面需要推翻重写, 一方面有来自传统课本的原因: -传统课本确实把处理器的设计原理介绍得很清楚, 但却几乎不会考虑如何在不同微结构的处理器之间平滑过渡的问题, -毕竟这属于工程实践的范畴, 传统课本并不会将它作为一个知识点来讲解. -所以, 另一方面的原因就是这部分同学过分依赖传统课本: -他们仅仅按照课本上的内容去实现, 没有思考过如何采用合适的设计模式实现上述过渡. - -对于初学者来说, 这其实不算一个大问题, 毕竟设计模式需要在充分了解各种细节之后才能进行抽象总结, -我们不应该要求初学者第一次学习就能思考出一个很好的设计模式. -但如果你想学得更多, 就不应该把课本上的内容看作是处理器设计的全部, -不应该把推翻重写看作是浪费时间. -作为还没有深入思考的初学者, 推翻重写的背后其实蕴含着极大的成长机会: -为什么前后两版设计的差别这么大? 能不能从中抽象总结出一些共性特征? -如果要重新来做这件事, 怎么样才能做得更好? - -事实上, 好的经验和创新的机会都是从问题中总结出来的, 大家将来走向工作岗位, -肯定还会遇到更多不同的问题, 这些问题都不再像教科书那样有标准答案. -课本中的知识是有限的, 仅仅靠课本, 我们能成长的高度也是有限的, -但能够进一步帮助我们解决将来未知问题的是我们的思维习惯: -你在多次思考的过程中所形成的思维习惯, 要比课本中的知识重要得多. - -回到处理器设计模式这个问题, 我们在2017年5月就已经思考并总结出相关经验, -并且在2017年和2018年南京大学参加的两届龙芯杯比赛, -以及2019年中国科学院大学发起的第一期"一生一芯"计划中都实践过, 并没有造成过多的推翻重写. -不少同学并不了解其中的情况, 因此也还是会根据自己的经验来下判断. - -具体地, 通过使用Chisel的合适特性, -从单周期修改成多周期, 只需要改动30行代码, 占800行总代码的3.75%; -从多周期改成不带转发功能的流水线, 只需要改动50行代码, 占1000行总代码的5.00%; -为流水线添加转发功能, 只需要改动20行代码, 占1100行总代码的1.82%. -即使你使用Verilog开发, 如果使用正确的设计模式, 改动代码的占比也应该大致相同. -从比例上来看, 还远远不到需要推翻重写的程度. - -我们想说的是, 作为初学者, 你需要保持一颗好奇的心, 以及"绝知此事要躬行"的理念. -当别人跟你说这样你需要推翻重写时, 你不应该仅仅听从这些"忠告", -而是应该思考为什么, 并通过自己的实践去钻研, 去回答这个问题. -因为在学习当中, 你才是主角. -::: - -:::danger[所以为什么要先实现总线?] -这是为了践行"先完成, 后完美"的设计法则. - -如果我们把单周期处理器作为起点, 那流水线就应该属于"后完美"的部分. -因此, 我们先实现总线, 其实是先将处理器往可流片的方向靠拢, 实现"先完成", -即去掉任何一个功能, 要么处理器不能运行RT-Thread, 要么存储器和外设与可流片设计差别很大. - -在"先完成"的基础上, 我们再通过量化评估方法理解每一种优化措施带来的性能收益. -这也是希望初学者将来可以量化地理解流水线设计对系统带来的性能提升, -而不仅仅是像传统课本那样, 将流水线设计作为一个将框图翻译成RTL代码的作业. -::: -### 多个设备的系统 - -到目前为止, 我们的系统中还只有存储器. -显然, 真实的计算机系统中并不仅仅只有存储器, 还有其他设备. -因此我们需要考虑, 如何让NPC访问其他设备. - -之前学习设备的时候, 我们介绍过内存映射I/O, 这种机制通过不同的内存地址来指示CPU访问的设备. -我们之前在仿真环境中通过`pmem_read()`和`pmem_write()`, -按照访存地址来选择需要访问的设备, 从而实现内存映射I/O的功能. -但真实的硬件并不存在`pmem_read()`和`pmem_write()`这两个仿真环境的函数. - -实际上, 硬件是通过crossbar(有时也写作Xbar)模块来实现内存映射I/O. -Xbar是一个总线的多路开关模块, 可以根据输入端的总线请求的地址, -将请求转发到不同的输出端, 从而传递给不同的下游模块. -这些下游模块可能是设备, 也可能是另一个Xbar. -例如, 下图中的Arbiter用于从IFU和LSU中选择一个请求转发给下游, -下游的Xbar收到请求后, 根据请求中的地址将其转发给下游设备. - -``` -+-----+ +---------+ +------+ +-----+ -| IFU | ---> | | | | ---> | UART| [0x1000_0000, 0x1000_0fff) -+-----+ | | | | +-----+ - | Arbiter | ---> | Xbar | -+-----+ | | | | +-----+ -| LSU | ---> | | | | ---> | SRAM| [0x8000_0000, 0x80ff_ffff) -+-----+ +---------+ +------+ +-----+ -``` - -如果请求的地址位于串口设备UART的地址空间范围, 那么Xbar会将请求转发给UART; -如果请求的地址位于SRAM的地址空间范围, 则将请求转发给SRAM. -如果Xbar发现请求的地址不属于任何一个下游的地址空间, 例如`0x0400_0000`, -就会通过AXI-Lite的resp信号返回一个错误`decerr`, 表示地址译码错. -这里的“地址译码”表示将请求的地址转换为下游总线通道的编号, -而Xbar中的地址译码器可以看作是内存映射I/O在硬件实现中的核心模块. - -Arbiter和Xbar也可以合并成一个多进多出的Xbar, 视场合也称为Interconnect或总线桥. -例如, 下图即为一个2输入2输出的Xbar. 它可以连接多个master和多个slave, -首先通过Arbiter记录当前请求来自哪个master, 再依据该请求的地址决定将其转发给哪个slave. - -``` -+-----+ +------+ +-----+ -| IFU | ---> | | ---> | UART| [0x1000_0000, 0x1000_0fff) -+-----+ | | +-----+ - | Xbar | -+-----+ | | +-----+ -| LSU | ---> | | ---> | SRAM| [0x8000_0000, 0x80ff_ffff) -+-----+ +------+ +-----+ -``` - -:::info[物理内存属性(PMA)和裸机程序开发] -上面的拓扑连接方式关系也可能会引起一些特殊的问题. -由于IFU可以通过Xbar和UART相连, 说明CPU也可以从UART中取指, -但显然UART设备不能存放程序和指令. - -因此, 如果程序错误地跳转到`0x1000_0000`, CPU将向UART发送读请求, -UART依据设备的行为返回一个数据, 它可能表示UART的内部状态的编码, 或者是UART接收到的字符. -一方面, CPU会错误地将UART返回的结果当作指令来执行, 从而造成严重的错误; -另一方面, IFU对UART设备的访问可能会改变设备的状态, 导致UART进入不可预测的状态. - -为了避免引发这种问题, 一般会在硬件中添加一些检查机制, -为每段地址空间添加若干权限属性, 如可读标志, 可写标志, 可执行标志等 -在IFU发出取指请求前, 先检查请求的地址所属的地址空间是否可执行, -若不可执行, 则直接抛出异常. -在RISC-V中, 如果系统中设备的地址空间是固定的, -则可以通过PMA(Physical Memory Attribute)机制来实现权限检查; -而对于地址空间在操作系统初始化时动态分配的现代PCI-e设备, -则可以通过PMP(Physical Memory Protection)机制或虚拟内存机制来实现权限检查. -如果你对这两种机制感兴趣, 可以参考RISC-V手册中的相关内容. - -当然, 如果CPU中没有实现这些检查机制, 在开发裸机程序的时候就要特别小心了: -如果裸机程序跑飞了, 后果可能是难以想象的. -::: -有了Xbar后, 我们就可以把之前在仿真环境中实现的外设功能移动到RTL中了. - -:::warning[实现AXI-Lite接口的UART功能] -编写一个AXI-Lite接口的slave模块, 其中包含一个设备寄存器. -当往这个设备寄存器发送写请求时, 则将写入数据的低8位作为字符, 通过`$write()`或`printf()`输出. -为了方便测试, 这个设备寄存器的地址可以设置成与之前仿真环境中串口的地址相同. -实现后, 你还需要自己编写一个Xbar模块, 来将这个具备UART功能的模块接入系统中. - -事实上, 我们并没有完整地用RTL来实现一个UART, -因为`$write()`或`printf()`仍然需要依赖仿真环境来实现字符的输出. -但作为一个总线的练习, 这已经足够了, 毕竟UART的实现还需要考虑很多电气细节. -不过我们很快就会接入SoC, 其中包含一个真实的UART控制器. -现在通过这个练习来测试总线的实现, 将来接入SoC的时候也会更顺利. -::: - -:::warning[实现AXI-Lite接口的CLINT] -[CLINT(Core Local INTerrupt controller)][clint]是RISC-V系统中较通用的中断控制器, -是一个用于维护时钟中断和软件中断的模块. -不过目前我们的系统还不需要中断功能, 因此我们先考虑时钟相关的功能即可. - -你需要实现一个AXI-Lite接口的CLINT模块, 并将其接入系统. -CLINT包含一个只读的设备寄存器`mtime`, 它会以一定的速率增长, 最简单的实现是每周期加1. -同样地, 为了方便测试, 其地址可以设置成与之前仿真环境中时钟的地址相同. - -不过, `mtime`的流逝还不能直接反映时间的流逝, 它们之间相差一个系数, 需要由软件读出后进行处理. -在真实的处理器芯片中, 一般这个系数等于CLINT模块中的时钟频率, 从而可以让软件测量出真实的时间. -不过仿真环境中没有主频的概念, 如果这个系数等于仿真速率, -我们就可以在仿真环境中通过`mtime`的流逝计算出真实时间的流逝. -具体地, 你还需要修改IOE的相关代码, 让`AM_TIMER_UPTIME`返回的时间接近真实时间. - -最后, 你还需要考虑`mtime`寄存器的位宽. -上述手册中定义的`mtime`是64位的, 这是为了避免在实际使用中发生溢出. -但目前NPC是32位的, 如果我们只读出`mtime`的低32位, -在一段时间之后, `mtime`将会发生溢出, 从而使系统的时间功能发生错误. -尽管你不太容易在仿真环境中运行到`mtime`溢出的时刻, -但如果NPC将来运行在500MHz的频率下, 将大概率会发生溢出. -因此, 运行在32位NPC上的软件需要依次读出`mtime`的低32位和高32位, -将其组合成一个64位的值, 供上层应用使用. -::: -[clint]: https://chromitem-soc.readthedocs.io/en/latest/clint.html diff --git a/zh/basic/ProcessorWithPipeline.md b/zh/basic/ProcessorWithPipeline.md deleted file mode 100644 index 4eda24d..0000000 --- a/zh/basic/ProcessorWithPipeline.md +++ /dev/null @@ -1,1261 +0,0 @@ ---- -sidebar_position: 10 ---- - -# 流水线处理器 - -我们已经通过icache提升了NPC的指令供给能力, 虽然面积预算很有限, -但添加icache还是明显提升了NPC的总体性能表现. -剩下的优化方向包括提升数据供给能力和提升计算效率. - -:::warning[评估dcache的理想收益] -提升数据供给能力的有效方法是添加dcache. -我们在上一节已经要求你通过性能计数器来估算dcache的理想收益了, -在对icache进行优化后, dcache在理想情况下带来的加速比可能会发生变化. -尝试重新评估dcache的理想收益. -::: - -:::warning[通过cachesim评估dcache的预期性能表现] -尝试改进你的cachesim, 让其可以读入mtrace, -然后评估一定大小的dcache的预期性能表现. -::: -你应该会发现, 在剩余的面积预算下, 我们很难通过dcache来有效提升NPC的数据供给能力. -因此, 把剩余的面积用在提升计算效率, 是更科学的决策. -目前的NPC是多周期的, 这意味着NPC每经过若干个周期才能执行一条指令. -如果能提升NPC执行指令的吞吐, 就可以提升其计算效率. -流水线作为一种指令级并行技术, 可以有效提升NPC执行指令的吞吐. - -:::warning[评估提升计算效率的理想收益] -我们还没有介绍流水线的具体实现, -但你已经可以根据性能计数器来估算流水线技术的理想收益了. -假设除访存指令之外, NPC每个周期都可以执行一条指令, -尝试估算在当前icache缺失情况的条件下, 此时NPC的理想加速比能到多少? -::: - -:::danger[从全系统角度评估性能收益] -这个估算结果可能会出乎你的意料. - -还记得Amdahl's law那个加速5000倍的例子吗? -只看一项技术自身带来的性能收益, 和放到全系统场景下带来的性能收益, 可能是完全不同的. -如果你实现了乱序多发射, 但指令供给和数据供给跟不上, -在真实芯片中它们只是一堆占面积占功耗但对程序的运行性能提升不大的门电路. -::: -## 流水线的基本原理 - -### 工厂流水线 - -在我们的生活中也存在流水线的思想, 最常见的就是工厂中的流水线. -例如, 某工厂生产产品需要经过组装, 贴纸, 装入包装袋, 装入包装盒, 外检这5道工序. -如果分别用1~5来标识这些工序, 用A, B, C......标识不同的产品, -那么, 不采用流水线方式的时空图如下: -``` - ----> 时间 - | 产品 - | +---+---+---+---+---+ - V |A.1|A.2|A.3|A.4|A.5| - +---+---+---+---+---+ - +---+---+---+---+---+ - |B.1|B.2|B.3|B.4|B.5| - +---+---+---+---+---+ - +---+---+---+---+---+ - |C.1|C.2|C.3|C.4|C.5| - +---+---+---+---+---+ -================================================================ - ----> 时间 - | 员工 - | +---+ +---+ +---+ - V |A.1| |B.1| |C.1| - +---+ +---+ +---+ - +---+ +---+ +---+ - |A.2| |B.2| |C.2| - +---+ +---+ +---+ - +---+ +---+ +---+ - |A.3| |B.3| |C.3| - +---+ +---+ +---+ - +---+ +---+ +---+ - |A.4| |B.4| |C.4| - +---+ +---+ +---+ - +---+ +---+ +---+ - |A.5| |B.5| |C.5| - +---+ +---+ +---+ -``` - -而采用流水线方式的时空图如下: - -``` - ----> 时间 - | 产品 - | +---+---+---+---+---+ - V |A.1|A.2|A.3|A.4|A.5| - +---+---+---+---+---+ - +---+---+---+---+---+ - |B.1|B.2|B.3|B.4|B.5| - +---+---+---+---+---+ - +---+---+---+---+---+ - |C.1|C.2|C.3|C.4|C.5| - +---+---+---+---+---+ - +---+---+---+---+---+ - |D.1|D.2|D.3|D.4|D.5| - +---+---+---+---+---+ - +---+---+---+---+---+ - |E.1|E.2|E.3|E.4|E.5| - +---+---+---+---+---+ -================================================================ - ----> 时间 - | 员工 - | +---+---+---+---+---+ - V |A.1|B.1|C.1|D.1|E.1| - +---+---+---+---+---+ - +---+---+---+---+---+ - |A.2|B.2|C.2|D.2|E.2| - +---+---+---+---+---+ - +---+---+---+---+---+ - |A.3|B.3|C.3|D.3|E.3| - +---+---+---+---+---+ - +---+---+---+---+---+ - |A.4|B.4|C.4|D.4|E.4| - +---+---+---+---+---+ - +---+---+---+---+---+ - |A.5|B.5|C.5|D.5|E.5| - +---+---+---+---+---+ -``` - -可以看到, 在流水线方式中, 虽然每个产品的生产时间没有减少, -但由于每位员工都能一直保持工作状态, 它们能连续处理不同产品的同一道工序, -使得从总体上来看, 每一时刻都能有一件产品完成生产, 从而提升产线的吞吐. - -### 指令流水线 - -类比工厂流水线, 处理器也可以用流水线方式来执行指令. -我们把指令执行的过程分为若干个阶段, 让每个部件处理其中一个阶段, -并让这些部件保持工作状态, 可以连续处理不同指令的同一个阶段, -使得从总体上来看, 每个周期都能有一条指令完成执行, 从而提升处理器的吞吐. - -在学习总线的时候, 我们已经要求大家把NPC升级成一个分布式控制的多周期处理器. -多周期处理器已经有阶段的概念了, 其工作过程和上述工厂场景中的非流水线方式非常类似. -因此, 指令流水线的工作方式也不难理解了. - -我们可以对几种处理器的性能进行简单的分析评估. -假设将处理器的工作分为取指, 译码, 执行, 访存, 写回这5个阶段, -它们的逻辑延迟都是1ns, 且先不考虑取指和访存的延迟. - -1. 单周期处理器: 阶段间无寄存器, 因此关键路径为5ns, 频率为200MHz. - 其中, 一条指令需要执行1周期, 即5ns; - 每1周期执行1条指令, 即`IPC = 1`. -1. 多周期处理器: 阶段间有寄存器, 因此关键路径为1ns, 频率为1000MHz. - 其中, 一条指令需要执行5周期, 即5ns; - 每5周期执行1条指令, 即`IPC = 0.2`. -1. 流水线处理器: 阶段间有寄存器, 因此关键路径为1ns, 频率为1000MHz. - 其中, 一条指令需要执行5周期, 即5ns; - 每1周期执行1条指令, 即`IPC = 1`. - -| 处理器 | 频率 | 指令执行延迟 | IPC | -| :-: | :-: | :-: | :-: | -| 单周期 | 200MHz | 5ns | 1 | -| 多周期 | 1000MHz | 5ns | 0.2 | -| 流水线 | 1000MHz | 5ns | 1 | - -可以看到, 虽然指令执行的延迟仍然是5ns, 但流水线具有频率高和IPC高的优势, -这些优势本质上是由指令级并行技术带来的: -流水线处理器的每个周期都在处理5条不同的指令. - -当然, 上面只是从理想情况下分析得到的数据. -如果考虑SoC中的访存, IPC就远远没有这么高了; -此外流水线处理器也并不是总能在每个周期都执行5条指令, 我们会在下文进一步分析. - -:::info[更长的流水线] -上面的例子只将流水线划分成5个阶段. 事实上, 我们可以将流水线划分成更多的阶段, -使得每一个阶段的逻辑更简单, 从而提升处理器整体的频率. 这种流水线称为"超流水"(superpipeline). -例如, 如果能将流水线划分成30级, 按照上面的估算, 理论上频率可以达到6GHz. - -但目前的主流高性能处理器一般只会将流水线划分成15级左右, -你认为有可能是哪些因素导致不宜将流水线划分成过多的阶段? -::: -## 流水线的简单实现 - -基于握手机制的多周期处理器来实现流水线并不困难, -对于每个阶段的输入`in`和输出`out`, 我们只需要正确处理以下信号 -(`bits`指代阶段之间需要传输的负载): -* `out.bits`, 由当前阶段生成 -* `out.valid`, 由当前阶段生成, 通常还与`in.valid`有关 -* `in.ready`, 由当前阶段生成, 忙碌时置为无效, 处理完当前指令时置为有效 -* `out.ready`, 与下一阶段的`in.ready`相同 -* `in.bits`, 当当前阶段的`in.ready`和上一阶段的`out.ready`同时有效时, - 更新成上一阶段的`out.bits` -* `in.valid`, 作为作业留给大家 - -根据上述内容, 我们可以将后三个信号的处理包装成一个函数, -然后用它来连接各个阶段即可: - -```scala -def pipelineConnect[T <: Data, T2 <: Data](prevOut: DecoupledIO[T], - thisIn: DecoupledIO[T], thisOut: DecoupledIO[T2]) = { - prevOut.ready := thisIn.ready - thisIn.bits := RegEnable(prevOut.bits, prevOut.valid && thisIn.ready) - thisIn.valid := ??? - } - -pipelineConnect(ifu.io.out, idu.io.in, idu.io.out) -pipelineConnect(idu.io.out, exu.io.in, exu.io.out) -pipelineConnect(exu.io.out, lsu.io.in, lsu.io.out) -// ... -``` - -特别地, IFU无需等待当前指令执行结束, 即可马上取出下一条指令. -上述的`RegEnable`起到了传统教科书中"流水段寄存器"的作用, -但我们也可以从总线的视角将它理解为下游模块接收消息的缓冲区: -上下游握手成功后, 上游模块认为下游模块已经成功接收到消息, -因此它不再保存该消息, 故下游模块需要将收到的消息记录到缓冲区中, 防止消息丢失. -而对于前三个信号, 由于其具体逻辑与当前阶段的行为有关, -因此需要在当前阶段对应的模块中实现. - -特别地, 流水线处理器中会存在一些无法继续执行当前指令的情况, 称为"冒险"(Hazard). -冒险主要分为3类: 结构冒险, 数据冒险和控制冒险. -如果无视冒险强行执行, 将会导致CPU状态机的转移结果与ISA状态机不一致, -表现为指令的执行结果不符合其语义. -因此在流水线设计中, 我们需要检测出冒险, -要么通过硬件设计消除它们, 要么从时间上等待冒险不再发生. -对于后者, 可以通过对`in.ready`和`out.valid`添加等待条件来实现. - -### 结构冒险 - -结构冒险是指流水线中的不同阶段需要同时访问同一个部件, -但该部件无法支持被多个阶段同时访问. -例如, 在下图所示的指令序列中, -在T4时刻, I1正在LSU中读数据, I4正在IFU中取指令, 两者都需要读内存; -在T5时刻, I1正在WBU中写寄存器, I4正在IDU中读寄存器, 两者都需要访问寄存器堆. - -``` - T1 T2 T3 T4 T5 T6 T7 T8 - +----+----+----+----+----+ -I1: ld | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I2: add | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I3: sub | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I4: xor | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -部分结构冒险可以从硬件设计上完全避免, 使其不会在CPU执行过程中发生: -我们只需要在硬件设计上让这些部件支持同时被多个阶段访问即可. -具体地: -* 对于寄存器堆, 我们只需要独立实现其读口和写口, - 让IDU通过读口访问寄存器堆, 让WBU通过写口访问寄存器堆 -* 对于内存, 则有多种解决方案 - * 像寄存器堆那样将读口和写口分开, 实现真双口的内存 - * 将内存分为指令存储器和数据存储器, 两者独立工作 - * 引入cache, 如果在cache中命中, 则无需访问内存 - -:::danger[教科书和真实系统的差异] -教科书上的解决方案大多基于一些简化过的假设, -这些假设在真实的处理器中很可能不再成立, -因此它们不一定适合在"一生一芯"的场景中使用. - -例如, SDRAM的存储颗粒无法将读口跟写口分开, -无论是READ命令还是WRITE命令, 都是通过SDRAM的存储器总线传递给SDRAM颗粒. -而将内存分为指令存储器和数据存储器, 将会使得指令和数据位于不同的地址空间, -这违反了ISA规范对指令和数据采用统一地址空间的内存模型. -一方面, 现代编译器无法编译出适应上述方案的程序; -另一方面, 即使采用汇编语言来开发程序, -类似bootloader的加载程序功能也无法正确运行. - -事实上, "一生一芯"对大家提出了更高的要求: 学会从全系统的角度评价一个方案. -教科书上的简化假设能帮助大家聚焦到当前知识点的学习中, -但你将来要面对的是真实的项目, 只有学会将系统中的各个因素关联起来, -你才能在将来的工作中做出合理有效的决策. -::: -还有一些结构冒险还是无法完全避免, 例如: -* cache缺失时, IFU和LSU还是要同时访问内存 -* SDRAM控制器的队列满了, 无法继续接收请求 -* 除法器的计算需要花数十个周期, 在一次计算结束之前无法开始另一次 - -为了应对上述情况, 一种简单的处理方式是等待: -如果IFU和LSU同时访存, 就让一个等待另一个; -等SDRAM控制器的队列有空闲位置; -等除法器完成当前计算. -一个好消息是, 总线天生就具备等待的功能, -因此只要从设备, 下游模块或仲裁器把ready置为无效, -就能把结构冒险的检测和处理归约到总线状态机, -从而无需实现专门的结构冒险检测和处理逻辑. - -:::info[谁等谁?] -在等待的时候, 是让IFU等待LSU, 还是让LSU等待IFU? -还是两种方案都可以? 为什么? -::: -### 数据冒险 - -数据冒险是指不同阶段的指令依赖同一个寄存器数据, 且至少有一条指令写入该寄存器. -例如, 在下图所示的指令序列中, -I1要写a0寄存器, 但要在T5时刻结束时才完成写入, 在这之前, -I2在T3时刻读到a0的旧值, I3在T4时刻读到a0的旧值, -I4在T5时刻读到a0的旧值, I5在T6时刻才能读到a0的新值. - -``` - T1 T2 T3 T4 T5 T6 T7 T8 T9 - +----+----+----+----+----+ -I1: add a0,t0,s0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I2: sub a1,a0,t0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I3: and a2,a0,s0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I4: xor a3,a0,t1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I5: sll a4,a0,1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -上述数据冒险称为写后读(Read After Write, RAW)冒险, RAW冒险的特征是, -一条指令需要写入某寄存器, 而另一条更年轻的指令需要读出该寄存器. -显然, 如果不处理这种数据冒险, 指令I2, I3和I4将会因为读到a0的旧值而计算出错误的结果, -违反指令执行的语义. - -:::info[指令之间的顺序关系] -中文中的"前"和"后"存在歧义, 可以指代"以前"/"前方"/"以后"/"后方", -在动态指令序列中一般采用"年老"和"年轻"来描述不同指令之间的先后关系. -例如, "I1比I2年老"(I1 is older than I2), 表示I1在时间上比I2先执行; -"I2比I1年轻"(I2 is yonger than I1), 表示I2在时间上比I1后执行. -::: -解决RAW冒险有多种方式. -从软件上来看, 指令是由编译器生成的, -因此一种方式是让编译器来检测RAW冒险, 并插入空指令, -来等待写入寄存器的指令完成写入, 如下图所示: - -``` - T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 - +----+----+----+----+----+ -I1: add a0,t0,s0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ - nop | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ - nop | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ - nop | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I2: sub a1,a0,t0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I3: and a2,a0,s0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I4: xor a3,a0,t1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I5: sll a4,a0,1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -插入空指令的本质还是等待, 但其实编译器还可以做得更好: -与其等待, 还不如执行一些有意义的指令. -这可以通过让编译器进行指令调度的工作来实现, -编译器可以尝试寻找一些没有数据依赖关系的指令, 在不影响程序行为的情况下调整其顺序. -一个例子如下, 其中I6, I7和I8均与I1无数据依赖关系, -因此可以调度到I1之后执行: - -``` -I1: add a0,t0,s0 I1: add a0,t0,s0 -I2: sub a1,a0,t0 I6: add t5,t4,t3 -I3: and a2,a0,s0 I7: add s5,s4,s3 -I4: xor a3,a0,t1 ---> I8: sub s6,t4,t2 -I5: sll a4,a0,1 I2: sub a1,a0,t0 -I6: add t5,t4,t3 I3: and a2,a0,s0 -I7: add s5,s4,s3 I4: xor a3,a0,t1 -I8: sub s6,t4,t2 I5: sll a4,a0,1 -``` - -:::info[编译器和乱序执行处理器] -如果把编译器的指令调度工作下放到硬件来完成, 我们就得到了一个乱序执行处理器. -当然, 为了在硬件上完成指令调度的工作, 我们还需要额外添加不少硬件模块. -但从本质上来说, 这两项技术都是为了提升处理器执行指令的效率. -::: -不过对于指令调度的工作, 编译器只能尽力而为, 并非总能找到合适的指令. -例如, 除法指令需要执行数十个周期, 编译器通常很难找到这么多合适的指令. -在这些情况下, 如果要让编译器来处理RAW冒险, 还是只能插入空指令. - -一个更糟糕的消息是, 有的RAW光靠编译器是无法解决的. -考虑被依赖的指令是一条load指令, 这种RAW冒险称为load-use冒险: - -``` - T1 T2 T3 .... T? T? T? T? T? T? T? - +----+----+----+--------------+----+ -I1: ld a0,t0,s0 | IF | ID | EX | LS | WB | - +----+----+----+--------------+----+ - +----+----+----+----+----+ - nop X ? | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I2: sub a1,a0,t0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -事实上, 在真实的SoC中, 软件几乎无法预测一条访存指令在将来执行时的延迟: -* 如果cache命中, 数据可能3个周期后返回 -* 如果cache缺失, 要访问SDRAM, 数据可能30个周期后返回 -* 如果正好碰上SDRAM充电刷新, 数据可能30+?个周期后返回 -* 如果CPU频率从500MHz提升到600MHz, 数据返回所需的周期数更多 - -也因为这样, 现代处理器几乎都采用硬件检测并处理RAW冒险的方案. -由于寄存器的写入操作发生在WBU中, 因此需要写入的寄存器编号会也会随着流水线传播到WBU, -也即, 我们可以从每个阶段中找到相应指令将要写入哪一个寄存器. -如果位于IDU的指令要读出的寄存器与后续某阶段中将要写入的寄存器相同, 则发生RAW冒险: - -```scala -def conflict(rs: UInt, rd: UInt) = (rs === rd) -def conflictWithStage[T <: Stage](rs1: UInt, rs2: UInt, stage: T) = { - conflict(rs1, stage.rd) || conflict(rs2, stage.rd) -} -val isRAW = conflictWithStage(IDU.rs1, IDU.rs2, EXU) || - conflictWithStage(IDU.rs1, IDU.rs2, LSU) || - conflictWithStage(IDU.rs1, IDU.rs2, WBU) -``` - -上面的伪代码只是一个大致的思路, 实际上你还需要考虑更多的问题: -并非所有指令都需要写入寄存器, 并非所有阶段都正在执行指令, -并非所有指令都需要读出rs2(如U型指令), 零寄存器的值恒为`0`等. -如何写出正确的RAW检测代码, 就交给你来思考了. - - - -检测到RAW冒险后, 最简单的处理方式还是等待: 只要把`ready`置为无效即可. -可见, 这种硬件检测和处理RAW冒险的方案, 无需提前得知指令执行何时结束, -这是因为指令执行过程中的各种等待都会通过总线的握手信号传递到流水线当中, -因此适用性比上述的软件方案更强. - -``` - T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 - +----+----+----+----+----+ -I1: add a0,t0,s0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+-------------------+----+----+----+ -I2: sub a1,a0,t0 | IF | ID | EX | LS | WB | - +----+-------------------+----+----+----+ - +-------------------+----+----+----+----+ -I3: and a2,a0,s0 | IF | ID | EX | LS | WB | - +-------------------+----+----+----+----+ - +----+----+----+----+----+ -I4 xor a3,a0,t1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I5: sll a4,a0,1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -### 控制冒险 - -控制冒险是指跳转指令会改变指令执行顺序, 导致IFU可能会取到不该执行的指令. -例如, 在下图所示的指令序列中, T4的IFU具体应该取出哪条指令, -需要等到I3在T5时刻计算出跳转结果后才能得知. - -``` - T1 T2 T3 T4 T5 T6 T7 - +----+----+----+----+----+ -I1: 100 add | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I2: 104 ld | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I3: 108 beq 200 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I4: ??? ??? | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -除了上述分支指令, `jal`和`jalr`也会造成类似问题. -假设上图的I3为跳转指令, 我们期望在T4时刻就取出跳转目标处的指令, -而在T4时刻IDU正好在对I3进行译码, 按道理是可以赶上的, -但现代处理器一般会认为还是赶不上, 从而将其作为控制冒险来处理. - -:::info[为什么现代处理器要这样处理?] -事实上, 有一些教科书确实会通过上述方式来处理控制冒险. -你认为有哪些因素使得上述的教科书方案无法在真实的处理器设计中采用? -::: -甚至CPU抛出异常也会导致控制冒险. -抛出异常时, 需要马上从`mtvec`所指的内存位置重新取指, 但通常来说, -处理器无法在取指时刻就得知这条指令的执行是否会发生异常. - -上述问题都是因为在取指阶段无法确定接下来真正需要取出哪条指令. -如果选择等待, 就要等待上一条指令几乎执行完成, 才能得知下一条指令的真正地址. -例如, 访存指令要等到访存结束后, 通过总线的`resp`信号才能确定访存过程没有抛出异常. -显然, 这个方案会使得指令流水线流不起来, 大幅降低处理器执行指令的吞吐. -而如果选择不等待, 就有可能取出了一部分不该执行的指令, -如果不采取进一步的处理措施, 处理器的状态转移就会与ISA状态机不一致, 从而导致执行结果不正确. - -为了应对控制冒险, 现代处理器通常采用"推测执行"(speculative execution)的技术. -推测执行本质上是一种预测技术, 其基本思想是, 在等待的同时尝试推测一个选择, -如果猜对了, 就相当于提前做出了正确的选择, 从而节省了等待的开销. -推测执行具体由三部分组成: -* 选择策略 - 得到正确结果之前, 通过一定的策略推测一个选择 -* 检查机制 - 得到正确结果时, 检查之前推测的选择是否与正确结果一致 -* 错误恢复 - 如果检查后发现不一致, 则需要回滚到选择策略时的状态, - 并根据得到的正确结果做出正确的选择 - -针对控制冒险, 一种最简单的推测执行策略是"总是推测接下来执行下一条静态指令". -从上述三部分考虑这种策略的实现, 具体如下: -* 选择策略 - 非常简单, 只需要让IFU一直取出`PC + 4`处的指令即可. -* 检查机制 - 根据指令的语义, 只有在执行分支和跳转指令, 以及抛出异常时, - CPU才有可能改变执行流, 其他情况下都是顺序执行. - 因而在其他情况下, 上述推测的选择总是正确的, 无需额外检查. - 故只需要在执行分支和跳转指令, 以及抛出异常时, 才需要检查跳转结果与推测的选择是否一致, - 也即, 检查跳转结果是否为`PC + 4`. -* 错误恢复 - 如果发现上述跳转结果不为`PC + 4`, 则说明之前的推测是错误的, - 基于这一推测所取出的指令都不应该被执行, 应该将其从流水线上消除, 这一动作称为"冲刷"; - 同时还需要让IFU从正确的跳转结果处取指. - -推测执行所带来的性能提升与推测的准确率有关, 如果推测的准确率高, -则IFU能以高概率提前取到正确的指令, 从而节省等待的开销; -如果推测的准确率低, 则IFU经常取到不该执行的指令, 这些指令后续又被冲刷, -在这段时间内, 流水线的行为等价于未执行任何有效指令, 从而降低了执行指令的吞吐. -具体地: -* 由于异常属于处理器执行过程中的小概率事件, 绝大部分指令的执行都不会抛出异常, - 因此针对异常, 上述策略的准确率接近100% -* 分支指令的执行结果只有"跳转"(taken)和"不跳转"(not taken), - 上述策略相当于总是预测"不跳转", - 因此从概率上来说, 针对分支指令, 上述策略的准确率接近50% -* 跳转指令的行为是无条件跳转到目标地址, 但目标地址的可能性有很多, - 正好跳转到`PC + 4`的概率非常低, - 因此针对跳转指令, 上述策略的准确率接近0% - -根据上述分析, 推测执行一方面可以正确处理控制冒险, -另一方面, 相对于消极等待的方式, 推测执行还可以带来一定的性能提升. -但针对分支指令和跳转指令, 上述的推测执行方案还有较大的提升空间, 我们会在下文继续讨论. - -关于推测执行的实现, 还有一些需要注意的细节: -* 从需求的角度来看, 冲刷是为了将处理器的状态恢复成发生控制冒险之前的时刻, - 因此, 我们可以从状态机的视角推导出应该如何处理相关的实现细节. - 状态机视角告诉我们, 处理器的状态由时序逻辑电路决定, - 而处理器的状态更新又受到控制信号的控制, 因此, 要实现冲刷的效果, - 我们只需要考虑将相关的控制信号置为无效即可. - 例如, 通过将`in.valid`置为无效, 可以将大部分部件正在执行的指令直接冲刷掉. -* 但如果部件中还存在一些影响控制信号的状态, 你还需要进行额外的考量, - 例如icache中的状态机, 尤其是发出的AXI请求无法撤回, 因此需要等待请求完成. -* 推测执行意味着当前执行的操作不一定是将来真正需要的, - 如果推测错误, 就应该取消相关操作. - 但有一些操作很难取消, 包括更新寄存器堆, 更新CSR, 写内存, 访问外设等, - 这些模块的状态一旦发生改变, 就很难恢复到旧状态. - 因此, 需要在确认推测正确后, 才能更新这些模块的状态. - -:::warning[实现简单的流水线处理器] -用最简单的方式处理各种冒险, 从而实现最基本的流水线结构. -实现后, 尝试运行microbench, 从而检查你的实现是否正确. - -Hint: -* 为了让DiffTest正确工作, 你可能需要修改向仿真环境传递的信号 -* 目前你可以先忽略异常处理相关的实现, 我们接下来再实现它 -::: - -:::info[嵌套的推测执行] -思考一下, 如果遇到了连续的分支指令或者跳转指令, 你的设计还能正确工作吗? -::: -对于流水线处理器中异常的实现, 我们还需要考虑如下细节: -* 抛出异常时, 需要将`mepc`设置为发生异常的指令的PC值, 这种特性称为"精确异常". - 如果不满足这一特性, 从异常处理通过`mret`返回时, - 就无法精确返回到之前发生异常的指令处, 从而使得发生异常前后的状态不一致. - 这可能会导致系统软件无法利用异常机制实现现代操作系统的某些关键机制, - 例如进程切换, 请页调度等. - 但在流水线处理器中, IFU的PC会不断变化, 等到某条指令在执行过程中抛出异常时, - IFU的PC已经与这条指令不匹配了. - 为了获取与该指令匹配的PC, 我们需要在IFU取指时将相应的PC一同传递到下游. -* 指令可能会在推测执行时抛出异常, 但如果推测错误, - 那实际上这条指令不应该被执行, 所抛出的异常也不应该被处理. - 因此, 在异常处理过程中需要更新处理器状态的操作, - 都需要等到确认推测正确后, 才能真正进行, - 包括写入`mepc`, `mcause`, 跳转到`mtvec`所指示的位置等. -* `mcause`的更新取决于异常的类型, 在RISC-V处理器中, 不同的异常号由不同的部件产生. - 产生的异常号也需要传递到流水线的下游, - 等到确认推测正确后, 才能真正写入`mcause`. - -| 异常号 | 异常描述 | 产生部件 | -| --- | --- | --- | -| 0 | Instruction address misaligned | IFU | -| 1 | Instruction access fault | IFU | -| 2 | Illegal Instruction | IDU | -| 3 | Breakpoint | IDU | -| 4 | Load address misaligned | LSU | -| 5 | Load access fault | LSU | -| 6 | Store/AMO address misaligned | LSU | -| 7 | Store/AMO access fault | LSU | -| 8 | Environment call from U-mode | IDU | -| 9 | Environment call from S-mode | IDU | -| 11 | Environment call from M-mode | IDU | -| 12 | Instruction page fault | IFU | -| 13 | Load page fault | LSU | -| 15 | Store/AMO page fault | LSU | - -:::warning[实现支持异常处理的流水线] -按照上述内容, 实现支持异常处理的流水线. -实现后, 运行一些异常处理相关的测试, 来检查你的实现是否正确. -::: -:::info[发生多个异常] -在流水线处理器中, 不同阶段会执行不同的指令, -这意味着不同阶段可能会同时产生不同的异常. -例如, 在IFU发现指令地址不对齐的同时, IDU发现非法指令, LSU发现读访问错误. -这时应该如何处理? - -尽管目前我们不要求你实现所有异常, 但你仍然可以思考, 你的设计可以正确处理这一情况吗? -::: -:::info[Intel的超流水架构] -在2005年之前的一段时间, Intel曾经使用超流水技术追求极致的主频. -在2004年2月, Intel发布了一款架构代号为[Prescott][prescott wiki]的处理器, -其流水线深度达到前所未有的31级, 即使在当时的90nm工艺节点上, 主频也达到了3.8GHz. -但在实测时发现, 和上一代20级流水线的Northwood相比, -这款处理器的性能[并没有得到太大提升][prescott stackexchange], -反而成为了x86史上运行最烫和功耗最高的单核处理器. -事实上, 在如果不是散热和功耗问题, 这款处理器的主频还能比3.8GHz更高: -[在发布的一年前, Intel宣称这款处理器的主频最高可以达到5GHz][prescott anandtech]. - -从微结构层面来看, 31级流水线的执行效率也并不尽如人意. -一方面, 流水线中的指令充斥着数据冒险, 很多指令因为RAW冒险, 只能在流水线中等待. -另一方面, 分支指令因推测执行错误而冲刷流水线的代价却很高. -假设处理器在第26级计算出分支指令是否跳转, 在推测执行错误的情况下, -前面的25个周期所取出的指令需要全部冲刷. -特别地, Prescott是一个多发射处理器, [每周期可进行4次简单ALU操作][prescott anandtech2], -估算其为4发射, 因此在推测执行错误的情况下, 需要冲刷`25 * 4 = 100`条指令! -尽管Prescott采用了一些先进的技术提升推测的准确率, -但根据实测结果, [仍然有不少程序的性能因为过长的流水线而下降][prescott wiki2]. - -后来, Intel放弃了这种激进超流水的技术路线, -[后续架构的流水线深度普遍在15级左右, 最多不超过20级][intel cpu wiki]. -::: -[prescott wiki]: https://en.wikipedia.org/wiki/NetBurst#Revisions -[prescott stackexchange]: https://softwareengineering.stackexchange.com/questions/210818/how-long-is-a-typical-modern-microprocessor-pipeline -[prescott anandtech]: https://www.anandtech.com/show/1073 -[prescott anandtech2]: https://www.anandtech.com/show/1230/7 -[prescott wiki2]: https://en.wikipedia.org/wiki/Pentium_4#Prescott -[intel cpu wiki]: https://en.wikipedia.org/wiki/List_of_Intel_CPU_microarchitectures - -## 流水线处理器的测试验证 - -流水线的处理对象是指令, 这意味着, 不同的指令序列将对流水线的行为产生不同的影响. -因此, 流水线的验证过程应将指令序列作为测试输入, 并尽可能覆盖各种不同的情况. -根据指令在流水线中的行为, 可粗略分为以下10个种类: -* 加/减/逻辑/移位等ALU可在一周期内完成计算的指令 -* 控制流转移指令, 分3种: 条件分支, `jal`, `jalr` -* 访存指令, 分2种: load, store -* CSR指令 -* `ecall`, `mret` -* `fence.i` - -仅仅考虑上述10类指令在传统5级流水线中的组合情况, 就已经有$10^5=100000$种可能. -事实上, 还需要考虑上文提到的各种冒险: 访存可能需要等待, -指令之间存在数据依赖, 控制流转移指令会导致流水线冲刷...... -总得来说, 不同情况组合而成的指令序列实在太多, -即使是开发一个指令生成器, 也很难保证覆盖上述所有情况, -如果是人工设计那么多测试用例, 就更困难了. - -:::info[通过传统的指令测试集无法证明处理器的正确性] -如果你了解类似`riscv-tests`的指令测试集, 你需要明白, -通过`riscv-tests`并不代表流水线NPC是正确的. -事实上, `riscv-tests`中提供的测试用例数量远远没到上文粗略计算的100000, -这就已经说明了, 必定存在某些指令序列, 是`riscv-tests`无法覆盖的. -更本质地, `riscv-tests`测试的是单条指令的行为, -而充分测试流水线处理器, 需要的是遍历各种指令序列. -因此, 如果你的流水线NPC仅仅通过了`riscv-tests`这些传统的指令测试集, -你不应该对你的NPC实现感到100%放心. -::: -既然人工参与测试用例的设计很困难, 那就想办法交给工具吧! -回顾我们在验证缓存的时候用到的形式化验证工具, -它可以自动帮助我们找到违反`assert`的测试用例, -如果找不到, 就证明了设计的正确性. -如果能够将形式化验证应用到流水线NPC, -就可以让工具帮助我们自动寻找错误的指令序列了! - -要使用形式化验证, 我们还需要一个REF, 以及编写合适的验证条件(即`assert`). -对于REF, 我们需要可以正确执行指令序列的另一个实现. -由于我们使用的形式化验证工具只能在RTL层次进行验证, -因此暂时无法接入类似NEMU和Spike的指令集模拟器. -不过我们可以考虑之前开发的单周期NPC, -它同样作为RISC-V ISA的微结构实现, 必然能正确执行指令序列. - -至于验证条件, 一个想法是像DiffTest那样, 在DUT和REF执行完相同的指令后, -检查两者GPR的状态是否一致. -在C代码中, 检查所有GPR是否一致只是一次`memcmp()`的调用, 开销并不大; -但在形式化验证中, 检查所有GPR是否一致确是作为"解方程"的约束条件, -会对BMC的求解过程带来不小的开销. -为此, 我们有必要寻找一种较为简单的对比方法. - -回顾状态机模型, 新状态取决于当前状态以及状态的转移. -因此, 除了直接对比新状态, 我们还可以从另一个角度来进行对比: -在当前状态一致的情况下, 如果状态的转移也一致, 那么新状态也应当一致. -通常来首, 描述一次状态转移比描述状态本身(即状态空间)要小很多, -因此对比状态的转移是否一致, 通常是一种更简单的对比方式. -以上述GPR的状态为例, 虽然GPR的状态空间有$16\times 32=512$位, -但一条RISC-V指令最多写入一个GPR, -我们只需要记录这条指令将哪个GPR更新成哪个值, -并对比DUT和REF的这条记录是否一致, 就不必直接对比512位的GPR状态空间是否一致, -从而大幅简化对比的开销. - -根据上文的分析, 我们很容易编写出验证顶层模块的伪代码. -这里我们用Chisel作为伪代码, 如果你使用Verilog开发, 你仍然可以借鉴相关思路来编写验证顶层模块. -```scala -class PipelineTest extends Module { - val io = IO(new Bundle { - val inst = Input(UInt(32.W)) - val radata = Input(UInt(XLEN.W)) - }) - - val dut = Module(new PipelineNPC) - val ref = Module(new SingleCycleNPC) - - dut.io.imem.inst := io.inst - dut.io.imem.valid := ... - dut.io.dmem.rdata := io.rdata - dut.io.dmem.valid := ... - // ... - - ref.io.imem.inst := dut.io.wb.inst - // ... - - when (dut.io.wb.valid) { - assert(dut.io.wb.rd === ref.io.wb.rd) - assert(dut.io.wb.res === ref.io.wb.res) - } -} -``` - -上述伪代码只给出了大致的框架, 还有不少细节需要你注意并补充: -* 为了减小BMC的求解空间, DUT只包含流水线本身, 而不包含缓存和各种外设模块, - 但可以通过握手信号实现缓存缺失带来的访存延迟 -* 指令是验证顶层模块的输入之一, 这意味着BMC将会遍历各种指令, - 在界的作用下, BMC将会遍历各种指令在不同周期下的组合, - 也即实现了在给定长度下所有指令序列的遍历. -* 生成的指令序列将会依次输入DUT的IFU, 但由于对一条指令来说, - 在流水线NPC中需要等待若干周期才能完成执行, 而在单周期NPC中只需要1周期即可完成执行, - 因此需要在DUT和REF之间进行同步, 使得DUT执行完一条指令后, - 才将该指令输入到REF中, 故需要在DUT的WBU中获得当前完成执行的指令. -* 除了对比写入的GPR编号`rd`及其值`res`外, 还需要对比PC, - 以检查控制流的转移是否正确. - 不过我们可以选择对比PC的新值, 这样能提早一个周期捕捉PC不一致的错误. -* 有的指令不写入GPR, 对于这些指令, 我们无需对比`rd`和`res`. -* 对于load指令, 其访存结果也作为流水线的输入之一, 故需要在验证顶层模块的端口处体现. - 为了保证DUT和REF在执行同一条load指令后状态一致, 还需要保证它们读出相同的数据. -* 对于store指令的执行结果, 我们不进行专门的对比, 主要有两点考虑: - 1. 写数据和写地址都来源于GPR, 如果它们有错, - 那么一定存在一条比store指令年老的指令向GPR写入了错误的值, - 这种情况可以通过上述机制检查出来. - 2. 如果写数据和写地址没错, 但AXI写通道的信号错了, - 这个问题属于AXI总线实现的范畴, 不属于流水线的验证范畴. - 原则上也可以在流水线的验证框架中加入对AXI信号的检查, - 但这将会增加BMC的约束条件, 从而增加求解的开销. -* 需要正确处理IFU和LSU的握手信号. -* 对于CSR的写入, 我们也不进行专门的对比, 因为若对某CSR的写入实现错误, - 则可以通过额外一条指令将该CSR的值读入GPR中, 将错误的值暴露出来, - 从而将CSR写入的对比归约为GPR写入的对比. -* 还需要令GPR和CSR在DUT和REF中都有相同的初始状态, - 因此需要对GPR和CSR初始化. - 注意, 这只是形式化验证的要求, 在仿真和流片时, 并非所有GPR和CSR都需要初始化. -* 由于指令序列是BMC由生成的, 如果不加任何限制, - 生成的指令序列中可能会包含非法指令. - 如果NPC不支持非法指令异常的处理, 则NPC执行非法指令的行为是未定义的, - 这将不利于对比指令序列的执行结果. - 为了解决这个问题, 一种思路是只允许BMC生成合法的指令, - 这可以通过Chisel和SystemVerilog皆提供的`assume`语句来实现, 它用于表示验证的前提条件. - 例如, 若`isIllegal`表示"指令译码结果为非法指令", - 则`assume(!isIllegal)`表示将"指令译码结果不为非法指令"作为验证的前提条件, - 此时, BMC将在此前提条件下进行求解, 从而排除指令序列中的非法指令. -* CSR指令中的CSR寄存器地址也需要考虑, 同样可以通过`assume`语句, - 将CSR指令中的CSR寄存器地址限制在已实现的CSR范围内. -* 另一个需要考虑的是不对齐访存, 如果不加任何限制, - 生成的访存指令中计算得到的地址可能是不对齐的. - 这个问题同样可以通过`assume`语句来解决. - -:::info[更换效率更高的模型检测工具] -我们在2024/08/20 02:30:00修改了调用形式化验证工具的方式, -将对Z3求解器的调用更换成对BtorMC模型检测器的调用, 以此提升形式化验证的效率. -如果你使用Chisel进行开发, 请重新参考缓存部分"形式化验证的简单示例"小节的内容. -::: - -:::info[通过形式化验证测试流水线的实现] -尽管这不是必须的, 我们还是强烈建议你通过形式化验证来测试你的流水线. -不过你需要仔细思考`assert`和`assume`这些验证条件如何编写, -如果它们的编写不恰当, 可能会导致误报或漏报: -误报可以在调试时发现并修复相应的验证条件, 但漏报就难以发现了. -因此, 这个任务本质上也是考察大家对流水线细节的理解是否有足够深入. - -此外, 关于BMC的界, 你可以挑选一个合适的参数, -使形式化验证工具能遍历足够多的指令序列, 并覆盖各种冒险组合的情况. -通常来说, 界可能需要达到10以上, 不过这要求求解过程花费数小时甚至数十小时的时间, -但从工具的便利性来说, 这还是非常值得的, -因为如果通过人工方式编写测试用例, 即使花费数天的时间, -也不一定能够编写出可以覆盖一些极端情况的测试用例. -不过为了快速找到一些反例, 你可以从一个小的界开始测试, 然后逐渐测试更大的界. -::: - -:::info[用形式化验证测试更复杂的处理器] -中科院软件所的研究团队开发了一套[基于形式化验证的RISC-V处理器测试框架][iscas formal], -通过它甚至找到了可启动Linux的[NutShell处理器][nutshell]中一些非常隐蔽的bug, -这个案例也反应出形式化验证技术的优势. -不过由于目前NPC的功能经过了诸多简化, 因此不一定适合接入到这个测试框架中. -如果你感兴趣, 可以阅读项目中的README来了解其使用方法, -并通过阅读相关代码了解测试框架的相关细节. -::: -[iscas formal]: https://github.com/iscas-tis/riscv-spec-core: -[nutshell]: https://github.com/OSCPU/NutShell - -## 让流水线流起来 - -实现简单的流水线处理器后, 我们来讨论如何提升流水线的效率. - -:::warning[评估简单流水线的性能] -在优化之前, 你需要先评估一下上述简单流水线的性能. -和实现流水线之前的多周期相比, 你发现实现流水线后性能有多少提升? -如果你发现性能反而倒退了, 我们建议你通过性能计数器深入理解其原因. -::: -流水线的理想情况是每周期都可以完成一条指令的执行, -但一般情况下流水线的吞吐无法达到理想情况, 主要原因如下: -* 指令供给能力不足, 无法向流水线提供足够的指令 -* 数据供给能力不足, 访存指令的执行被阻塞 -* 计算效率不足, 由于上述三种冒险的存在, 流水线需要阻塞 - -:::warning[定位性能瓶颈] -要提升流水线的效率, 我们首先要定位性能瓶颈. -尝试在流水线处理器中添加更多性能计数器, 从各种阻塞原因中分析出当前的性能瓶颈. -::: - -:::danger[根据你的设计自行决定是否采用以下优化方案] -下面介绍一些可能有用的优化方案, 具体是否在RTL设计中采用, 取决于很多因素, -包括你之前的设计, 对当前性能计数器的分析, 以及剩余的可用面积等. -但我们还是要求你在进行RTL实现之前对相应技术的预期性能收益进行评估, -这对体系结构设计能力来说是非常重要的训练: -你可以选择不实现某个优化技术, 但你需要用量化分析方法得出的数据佐证你的决策. - -不过"减少数据冒险的阻塞"对应优化方案是一个重要的知识点, 我们将其作为必做题. - -总之, 如果之前的面积优化工作做得比较好, 现在你就有更多的机会来进行优化了. -::: -### 提升指令供给能力 - -在之前的多周期处理器中, 假设每条指令执行5个周期, -这时, 只要指令供给能力达到0.2条指令/周期, 就能满足多周期处理器的指令消费需求. -但在流水线处理器中, 理想情况下的指令消费需求提升到了1条指令/周期, -如果指令供给能力无法达到相应水平, 就无法发挥流水线的优势. -不过对于icache来说, 缺失时需要访问内存, 此时必定无法达到上述的指令供给能力, -因此我们重点考虑icache在命中时的指令供给能力. -根据icache设计的不同, 是否需要在此处对icache进行优化, 也会有不同的决策. - -具体地, 如果你的icache可以在1周期内判断是否命中, 并在命中的情况下向IFU返回指令, -则icache的指令供给能力已经接近1条指令/周期, 基本满足流水线处理器的指令消费需求. -这种情况下, 你基本上无需进一步提升指令供给能力, -但你可能需要付出频率不高的代价, 毕竟icache需要在一个周期内完成较多的操作, -这也会导致流水线无法在较高频率下工作. - -如果你的icache在命中的情况下也需要多个周期才能向IFU返回指令, -则icache的指令供给能力最大为0.5条指令/周期, 甚至更低. -在这样的指令供给能力之下, 流水线的性能会受到明显的约束. -假设icache在命中的情况下需要3个周期才能向IFU返回指令, 则有以下的指令时空图. -由于`cache`的发音和`cash`相同, 因此一些英文文献也用`$`来指代`cache`, -下图为了简化, 也采用`I$`来指代`icache`, 替换之前的`IF`, -因为此时`IF`需要等待的时间与icache的访问时间一致: - -``` - T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 T12 T13 - +--------------+----+----+----+----+ - I1 | I$ | ID | EX | LS | WB | - +--------------+----+----+----+----+ - +--------------+----+----+----+----+ - I2 | I$ | ID | EX | LS | WB | - +--------------+----+----+----+----+ - +--------------+----+----+----+----+ - I3 | I$ | ID | EX | LS | WB | - +--------------+----+----+----+----+ -``` - -要提升icache的指令供给能力, 某种程度上也是要提升icache的吞吐. -这个需求和上文中提到的"提升指令执行的吞吐"非常类似, -很自然地, 我们也可以尝试将icache的访问流水化! - -``` - T1 T2 T3 T4 T5 T6 T7 T8 T9 - +----+----+----+----+----+----+----+ - I1 | I$1| I$2| I$3| ID | EX | LS | WB | - +----+----+----+----+----+----+----+ - +----+----+----+----+----+----+----+ - I2 | I$1| I$2| I$3| ID | EX | LS | WB | - +----+----+----+----+----+----+----+ - +----+----+----+----+----+----+----+ - I3 | I$1| I$2| I$3| ID | EX | LS | WB | - +----+----+----+----+----+----+----+ -``` - -:::warning[估算icache流水化带来的性能收益] -尝试根据性能计数器, 粗略估算icache流水化带来的性能收益. -::: -上图将icache的访问进一步划分成3个阶段, 并通过流水线技术将这3个阶段在时间上重叠起来, -从而让icache在命中时的吞吐接近1条指令/周期. -为了实现icache的流水化, 你可以根据icache命中时的状态转移过程来设计上述阶段, -这和我们将处理器改成流水线是非常类似的, 甚至比处理器流水线更简单, -因为icache的工作过程不存在控制冒险的概念. -当然, 如果icache发成缺失, 则还是需要阻塞icache流水线, -并通过状态机控制内存的访问和icache的更新. -由于缺失会导致icache的更新, 这可能会导致类似数据冒险的问题, -因此你需要考虑如何正确相应的情况, 具体如何解决, 还是取决于你的实现. - -:::info[实现icache的流水化] -如果你的icache在命中时需要多个周期才能向IFU返回指令, -尝试借鉴指令流水线的思想, 将icache的访问流水化, 从而提升其指令供给能力. -实现后, 尝试通过性能计数器和benchmark, 分别评估指令供给能力的提升是否符合预期. - -上文将icache的访问划分成3个阶段, 仅仅是一个示例, -你应该根据你的具体设计决定阶段如何划分. -如果你之前使用了类似`StageConnect()`的方式来实现流水线, -你会发现icache的流水化是很容易实现的: -你只需要定义好阶段之间需要传递的信息, 就已经实现了接近90%的内容了. - -实现后, 尝试与之前估算的性能提升进行对比, 来检查你的实现是否符合预期. -::: -### 减少数据冒险的阻塞 - -在上述的简单流水线中, 我们是通过阻塞等待的方式来处理RAW数据冒险的, -这一方案需要等待被依赖的寄存器写入新值后, 被阻塞的指令才能继续执行, -显然这样的等待会降低流水线的吞吐. - -一个观察是, 被依赖寄存器的新值是在WB阶段写入, -但其实这个值最早在EX阶段就已经被计算出来了, 并会随着流水线传递给LS阶段和WB阶段. -因此, 我们可以考虑提前从这些阶段中将计算出的新值拿过来给后续指令使用, -让它们不必等待被依赖寄存器完成更新, 即可拿到正确的源操作数开始执行, -这种技术称为"转发"(forward)或"旁路"(bypass). - -``` - T1 T2 T3 T4 T5 T6 T7 T8 - +----+----+----+----+----+ -I1: add a0,t0,s0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - | | | - V | | - +----+----+----+----+----+ -I2: sub a1,a0,t0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - | | - V | - +----+----+----+----+----+ -I3: and a2,a0,s0 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - | - V - +----+----+----+----+----+ -I4 xor a3,a0,t1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -:::warning[估算转发技术的理想性能提升] -可以看到, 转发技术可以很好地消除load-use冒险以外的RAW阻塞. -至于load-use冒险, 因为load指令需要等待数据读出后才能转发, -在此之前仍然需要阻塞流水线. - -尝试根据性能计数器, 估算转发技术能带来的理想性能提升. -::: -我们先来讨论转发技术对数据通路的修改. -在上述流水线中, 转发源有3个, 分别是EX阶段, LS阶段和WB阶段, -它们都有可能携带可以转发的数据. -但转发并不能无条件进行, 需要转发源满足以下条件: -将要写入寄存器, 待写入的寄存器编号和被依赖寄存器编号一致, 并且数据已经就绪. -前两个条件和上文提到的RAW冒险检测条件一致, 因此可以复用RAW冒险的检测逻辑; -最后一个条件和指令的行为有关, 大部分计算类指令都可以在EX阶段计算出结果, -因此在EX阶段得到的结果即可转发到ID阶段; -但load指令在EX阶段只能计算出访存地址, 因此不应该在EX阶段进行转发, -而且在LS阶段也需要在总线的R通道握手后才能得到返回的数据, 在这之前也不应该进行转发. - -除了数据通路的变化, 我们还需要考虑控制通路的修改. -之前在简单流水线处理器中, 一旦检测到RAW冒险, 就会阻塞流水线. -而使用转发技术后, 阻塞流水线的条件将有所变化: -* 如果ID阶段未检测到RAW冒险, 则无需阻塞流水线, 这与上文的简单流水线一致 -* 如果ID阶段检测到了RAW冒险, 并且存在某个阶段满足转发条件, - 则无需阻塞流水线, 同时将转发的数据作为ID阶段的输出 -* 如果ID阶段检测到了RAW冒险, 但不存在满足转发条件的阶段, 则需要阻塞流水线 - -特别地, 如果存在多条指令同时满足转发条件, 则需要仔细考量. -考虑以下指令序列: -``` - T1 T2 T3 T4 T5 T6 T7 T8 - +----+----+----+----+----+ -I1: add a0, a0, a1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I2: add a0, a0, a1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I3: add a0, a0, a1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I4: add a0, a0, a1 | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -假设I1不依赖于比它更老的指令, 则不必阻塞I1的执行; -对于I2, 它依赖于I1的结果, 但通过转发技术, -可以在T3时刻将I1在EX阶段的结果转发给位于ID阶段的I2, 因此也不必阻塞I2的执行; -对于I3, 在T4时刻发现, 位于EX阶段的I2和位于LS阶段的I1均满足转发条件, -但从ISA状态机的视角考虑, 指令是串行执行的, 因此I3读出的`a0`应该是最近一次写入`a0`的结果, -因此应该选择由位于EX阶段的I2进行转发; -同理, 对于I4, 在T5时刻, 位于EX阶段的I3, 位于LS阶段的I2和位于WB阶段的I1均满足转发条件, -但从ISA状态机的视角考虑, 应该选择由位于EX阶段的I3进行转发. -也即, 存在多条指令同时满足转发条件时, 应该选择最年轻的指令进行转发. - -:::warning[实现转发技术] -根据上述内容, 在流水线中实现转发技术, 从而消除大部分RAW冒险. -实现后, 尝试与之前估算的性能提升进行对比, 来检查你的实现是否符合预期. -::: - -:::info[教科书上的转发方案] -上述转发方案是将其他阶段的计算结果转发到ID阶段后, -再选出正确的操作数送入流水段寄存器, -而大部分教科书上的转发方案则是转发到EX和LS阶段, -然后在计算之前选出正确的操作数. - -尝试对比这两种方案, 如果你想不清楚, 可以分别实现这两种方案, -然后从IPC, 频率, 面积等各个方面对比它们. -::: -### 减少控制冒险相关的阻塞 - -在上述简单流水线中, 我们用推测执行来应对控制冒险. -具体地, 我们推测"接下来总是执行下一条静态指令", -如果推测正确, 就能消除控制冒险带来的阻塞. -但事实上, 上述推测并非总是正确, 此时需要冲刷流水线, 从而浪费了若干周期. -要提升流水线的执行效率, 一个角度是降低冲刷流水线带来的负面影响. - -:::warning[估算优化控制冒险相关阻塞带来的性能收益] -尝试根据性能计数器, 粗略估算完全消除控制冒险相关阻塞时的性能, -从而相应优化技术的理想性能收益. -::: -要降低冲刷流水线对处理器执行效率带来的负面影响, 可以从以下两个方向考虑: -1. 降低单次冲刷流水线的代价. 这需要尽快计算出分支指令的结果. - 有的教科书中介绍了在ID阶段计算分支结果的方案, 这显然可以提升流水线的IPC, - 但还需要从频率和面积的角度综合评估这一方案. -1. 降低冲刷流水线的次数. 这需要提升推测的准确率. - 根据上文的分析, "推测异常不发生"的准确率已经接近100%, - 因此主要考虑分支指令和跳转指令的推测准确率. - -通常通过"分支预测"(branch prediction)技术来提升分支指令的推测准确率, -进行分支预测的模块称为分支预测器(branch predictor). -分支指令的执行结果只有"跳转"(taken)和"不跳转"(not taken), -因此分支预测技术只需要在两个选择中预测一个即可, -具体如何给出一个预测结果, 称为分支预测算法. -考虑是否参考运行时刻的信息, 分支预测算法分为静态预测和动态预测两种. -这里我们先介绍静态预测算法, 我们将在A阶段介绍动态预测算法. - -:::warning[体会现代处理器中分支预测器的重要性] -尝试统计动态指令数中分支指令的占比, -即, 假设平均每x条指令中包含一条分支指令, 求x. - -假设在某理想的五级流水线处理器中, 指令供给能力为1条/周期, -无结构冒险和数据冒险, 访存延迟均为0周期, 跳转指令总能预测正确, -但分支指令可能会预测错误, 且分支指令在EX阶段计算出跳转结果. -根据上述求得的x, 分别计算处理器在不同分支预测准确率下的IPC: -100%, 99.5%, 99%, 95%, 90%, 80%. - -将上述处理器改进为乱序单发射15级流水, 其中分支指令在第13级计算出跳转结果, -其他假设不变, 重新计算处理器在不同分支预测准确率下的IPC. - -继续将处理器改进为乱序四发射15级流水, 其他假设不变, -重新计算处理器在不同分支预测准确率下的IPC. -::: -静态预测只根据分支指令本身来进行预测. -由于分支指令本身在程序执行过程中不会发生变化, -因此对于给定的一条分支指令和给定的一种静态预测算法, 其预测结果总是相同的. -上文的"总是推测接下来执行下一条静态指令", 站在分支预测技术的角度, -就是"总是不跳转", 是一种静态预测算法. -另一种静态预测算法是"总是跳转". - -事实上, 根据分支跳转方向的不同, 分支指令的执行结果是否跳转, 是有偏向性的(bias). -这其实和程序中循环的行为有关, 例如, -当分支的跳转目标位于前方(年轻指令)时, 可能是一个循环的出口, 因此偏向不跳转; -而当分支的跳转目标位于后方(年老指令)时, 可能是要重新执行循环体, 因此偏向跳转. -一种利用这一特性的静态预测算法称为BTFN(Backward Taken, Forward Not-taken): -若分支的跳转目标位于后方, 则预测跳转, 反之则预测不跳转. -实现时, 只需要根据B型指令offset的符号位, 即可得到预测结果. -事实上, RISC-V手册也建议编译器按照BTFN的模式生成代码: -``` -Software should also assume that backward branches will be predicted taken and -forward branches as not taken, at least the first time they are encountered. -``` - -评价一个分支预测算法的重要指标就是预测准确率. -和之前的icache类似, 对于给定的程序, 需要执行哪些分支指令, -以及每条分支指令的执行结果, 都是固定的. 只要获得程序运行的itrace, -我们就能快速统计一个分支预测算法的准确率, 没有必要进行RTL层次的仿真. -更进一步地, itrace已经包含了完整的指令流, -除分支以外其他指令的trace, 对分支指令的执行结果没有影响, -因此我们真正需要的, 只有分支指令的trace, 我们称为btrace(branch trace). - -根据上述分析, 我们只需要实现一个分支预测器的功能模拟器, 我们称它为branchsim. -branchsim接收btrace, 并根据分支预测算法预测出每一条分支指令是否跳转, -然后与btrace中记录的执行结果进行对比, 统计该算法的预测准确率. -至于btrace, 我们可以通过NEMU来快速生成. - -:::warning[实现branchsim] -根据上文的介绍, 实现一个简单的分支预测模拟器branchsim, -然后分别评估上述静态预测算法的准确率, -并根据预测准确略评估该预测算法带来的性能收益. - -如果你对动态预测算法感兴趣, 可以先学习相关资料, -然后在branchsim中实现相应的算法并评估其准确率. - -和cachesim类似, branchsim也可以作为分支预测性能表现的REF. -例如, 上文的简单流水线中采用了"总是不跳转"的静态预测算法, -相关的性能计数器应该与branchsim给出的统计结果完全一致. -::: -通过branchsim选出一个表现较好的分支预测算法后, -就可以考虑如何在处理器中实现一个分支预测器了. -通常来说, 分支预测器的预测结果需要提供给IFU使用: -如果预测跳转, 则让IFU从分支指令的跳转目标处取指, -否则, 则让IFU从`PC + 4`处取指. -但实际上, 我们在ID阶段才能得知一条指令是否为分支指令, -如果为分支指令, 也需要在ID阶段才能得知其跳转目标. -而在IF阶段中, 我们只有PC值, 难以获得上述信息来进行分支预测. - -解决上述问题的方法是通过一张表来维护PC和分支跳转目标的对应关系, -这张表称为BTB(Branch Target Buffer). -BTB可以看作一个特殊的cache, 它通过PC值进行索引, -若命中, 表示该PC对应一条分支指令, 可从中读出跳转目标; -若缺失, 表示该PC对应的指令不是分支指令, 此时让IFU从`PC + 4`处取指即可. -此外, BTB需要在处理器执行过程中进行填充更新, -原则上最早可以在ID阶段中译码出分支指令时更新BTB. -通常BTB的项数有限, 若更新时发现无空闲表项, 根据局部性原理, 此时应该覆盖旧表项. -特别地, 处理器复位时, BTB中的表项均无效, 此时无法读出正确的跳转目标. -但由于分支预测属于推测执行技术, 预测错误也不影响处理器执行程序的正确性, -等到往BTB中写入正确的表项后, 即可进行有效的预测. - -:::info[实现分支预测器] -由于不同分支的信息可能会在项数有限的BTB中互相替换, 因此在进行分支预测时, -当前PC对应的分支指令的跳转目标并非总是可获得的. -这种情况将会影响分支预测的准确率, -因此你需要在branchsim中添加一个BTB, 来校准其预测准确率. - -具体地, 先在RTL中实现一个简单的BTB, 组织方式不限, -你可以根据需要选择采用直接映射, 全相联还是组相联. -你也可以根据你的需要, 在BTB中添加新的字段. -实现后, 评估其面积, 根据剩余的面积选择合适的BTB项数, -然后根据这一项数调整branchsim, 重新评估分支预测算法的准确率. - -重新评估后, 再综合所有因素决定如何实现分支预测器. -实现后, 将RTL的分支预测准确率与branchsim中统计的分支预测准确率进行对比. -::: -上文介绍的是分支指令的预测, 对于跳转指令, 也需要进行推测执行. -跳转指令都是无条件的, 但跳转结果有多种, 因此对跳转指令的预测主要是预期其跳转目标. -跳转指令又分直接跳转`jal`和间接跳转`jalr`. -对于给定的`jal`指令, 其跳转目标是确定的, -因此只要将`jal`指令的跳转目标记录到BTB中, 只要相应表项未被替换, -下次遇到该`jal`指令时就能以100%的准确率取到正确的跳转目标处的指令. -至于`jalr`指令, 其跳转目标由执行该指令时的源寄存器的值决定, -仅靠静态预测算法难以预测成功, 需要考虑相应的动态预测算法. -为简单起见, 目前可对`jalr`指令采用"总是不跳转"的预测算法. - -:::warning[估算跳转指令推测执行正确的性能收益] -尝试根据性能计数器, 分别估算`jal`指令和`jalr`指令推测执行正确时的理想性能收益. -::: -如果要对`jal`指令的跳转目标进行预测, 你可以让`jal`指令与分支指令共享同一个BTB, -也可以让`jal`指令单独使用另一个BTB. -前者可以节约面积, 但指令之间的表项可能会互相覆盖, 从而影响预测准确率; 后者则相反. -你可以根据branchsim的评估情况决定如何设计. - -:::info[实现jal指令的跳转目标预测] -根据上述方案, 实现`jal`指令的跳转目标预测. -::: - -:::info[实现ret指令的跳转目标预测] -一般来说, `jalr`指令的跳转目标比较难预测正确, -但作为一类特殊的`jalr`指令, `ret`指令是比较容易预测正确的. -如果你感兴趣, 并且有较为充足的剩余面积, -可以查阅"返回地址栈"(Return Address Stack)的相关资料, -并在处理器中实现`ret`指令的跳转目标预测. -::: -:::warning[优化流水线] -根据分析出的性能瓶颈, 在满足面积约束的前提下, -尝试将有限的面积资源投入到最值得投入的优化技术中, 尽可能提升处理器的性能. -::: - -:::danger[重新审视处理器的性能优化] -我们能预料到, 在完成这一部分内容的时候, 你并不会感到十分愉快: -要么可用面积非常紧张, 难以添加优化技术; -要么添加优化技术后处理器频率有所下降, 抵消了该技术对IPC带来的提升; -要么投入很大精力之后, 终于使得面积和频率的表现都不错, 但发现IPC的提升很小...... - -更重要的是, 你应该会在心中隐隐感觉到一种无奈: -流水线作为组成原理教科书上的技术巅峰, 它的性能表现就这? -事实上, 这种技术巅峰的幻觉来源于教科书上对指令供给和数据供给的大幅削弱, -让你误以为计算效率就是处理器设计的一切, -让你觉得乱序多发射就是处理器设计的终极追求. -你需要从"学会流水线, 就具备了体系结构设计能力"的幻想中清醒过来, -认识到性能计数器, Amdahl's law, 模拟器等等对体系结构设计的意义, -学会在面积, 主频和IPC之间作出合理的取舍, 甚至能找到一个各方面表现都不错的方案, -这才是一名合格的架构师需要具备的能力. - -而上述的这些能力, 是无法仅仅通过将书上的架构图翻译成RTL代码来获得的. -如果你只是参考一些书籍的代码, 就更别想了. -相反, 我们之所以按照当前的顺序来组织讲义内容, -就是希望大家早日从上面的幻觉中醒悟过来, 在SoC中面对残酷的现实, -然后学会用科学的方法一点一点探索出合理的处理器优化方案. - -从结果上看, 你设计的处理器性能可能还很弱, -但如果你完成了我们设置的训练, 你就已经锻炼出真正的体系结构设计能力: -你学会分析性能瓶颈, 学会从中思考出新方案, 学会去估算其性能收益, -学会在各种约束下将这个方案实现出来, 学会去评测你的实现是否符合预期...... -这些比你学会用RTL写一个流水线处理器重要得多. - -因此, 你也可以通过以下方法检验自己是否具备体系结构设计能力: -如果你脱离了参考书就不知道应该做什么, 或者需要询问别人才知道一个方案的优劣, -那就是不具备体系结构设计能力. -::: -## `fence.i`的处理 - -最后, 我们还需要对`fence.i`指令在流水线中的执行进行额外的处理. -回顾`fence.i`的语义, 其行为是让在其之后的取指操作都能看到在其之前的store指令修改的数据. -我们已经在实现icache的时候, 通过对icache进行特殊处理, -来保证后续取指操作不会通过icache取到过时的指令. - -在流水线处理器中, 过时的指令可能会存在于流水线中. -考虑如下示例: 假设`fence.i`在EX阶段生效, 但作为比`fence.i`年轻的指令, -I3和I4已经取出并在流水线中, 它们有可能是过时的, -继续执行它们可能会导致CPU状态机的转移结果与ISA状态机不一致, 从而造成错误. - -``` - T1 T2 T3 T4 T5 T6 T7 - +----+----+----+----+----+ -I1: add | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+----+----+----+ -I2: fence.i | IF | ID | EX | LS | WB | - +----+----+----+----+----+ - +----+----+ -I3: ??? may be stale | IF | ID | - +----+----+ - +----+ -I4: ??? may be stale | IF | - +----+ - +----+----+----+----+----+ -I5: sub | IF | ID | EX | LS | WB | - +----+----+----+----+----+ -``` - -:::warning[设计反例] -根据上述分析, 设计一个`fence.i`相关的测试用例, -使得该测试用例在之前的多周期处理器中运行正确, 但在流水线处理器中运行出错. -::: -解决上述问题的方案也很简单: 既然上述指令是不应该被执行的, 将其冲刷掉即可. -实现时, 可以复用推测执行错误时的冲刷逻辑. - -:::warning[在流水线中正确实现fence.i] -在执行fence.i时冲刷流水线, 然后重新运行上述测试用例. -如果你的实现正确, 测试用例将运行成功. -::: diff --git a/zh/basic/SoCComputerSystem.md b/zh/basic/SoCComputerSystem.md index f51fbca..f4e628d 100644 --- a/zh/basic/SoCComputerSystem.md +++ b/zh/basic/SoCComputerSystem.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 --- # SoC计算机系统 @@ -63,7 +63,7 @@ TODO: 加个总线和设备的框图 ::: -:::warning[获取ysyxSoC的代码] +:::todo[获取ysyxSoC的代码] 你需要克隆[ysyxSoC](https://github.com/OSCPU/ysyxSoC)项目: ```bash cd ysyx-workbench @@ -174,7 +174,7 @@ index dd84776c..758fb8d1 100644 有了`arsize`/`awsize`信号, 设备就可以得知软件需要访问的实际数据位宽, 从而在若干设备寄存器的地址紧密排布时, 亦可仅访问其中的某个寄存器, 避免意外改变设备的状态. -:::warning[生成ysyxSoC的Verilog代码] +:::todo[生成ysyxSoC的Verilog代码] 首先你需要进行一些配置和初始化工作: 1. 根据[mill的文档介绍][mill doc]安装mill * 可通过`mill --version`检查安装是否成功; @@ -189,7 +189,7 @@ index dd84776c..758fb8d1 100644 [mill doc]: https://mill-build.com/mill/Intro_to_Mill.html -:::warning[接入ysyxSoC] +:::todo[接入ysyxSoC] 依次按照以下步骤将NPC接入ysyxSoC: 1. 依照`ysyxSoC/spec/cpu-interface.md`中的`master`总线, 将之前实现的AXI4Lite协议扩展到完整的AXI4 2. 调整NPC顶层接口, 使其与`ysyxSoC/spec/cpu-interface.md`中的接口命名规范**完全一致**, @@ -260,7 +260,7 @@ ROM的实现有很多种, 总体上都是通过某种方式来将信息(在这 但MROM作为ysyxSoC中的第一个简单的非易失存储器来存放程序, 对我们测试ysyxSoC的接入还是非常合适的. 我们已经在ysyxSoC中添加了一个AXI4接口的MROM控制器, 其地址空间是`0x2000_0000~0x2000_0fff`. -:::warning[测试MROM的访问] +:::todo[测试MROM的访问] 修改NPC的复位PC值, 使其从MROM中取出第一条指令, 并修改`mrom_read()`函数, 使其总是返回一条`ebreak`指令. 如果你的实现正确, NPC取到的第一条指令即是`ebreak`指令, 从而结束仿真. @@ -289,7 +289,7 @@ void _start() { } ``` -:::warning[在ysyxSoC中输出第一个字符] +:::todo[在ysyxSoC中输出第一个字符] 你需要: 1. 根据ysyxSoC中的设备地址空间约定, 以及UART手册(在`ysyxSoC/perip/`下的相关子目录中)中输出寄存器的地址, 来填写上述C代码中的`?`处, 使代码可以正确访问输出寄存器来输出一个字符 @@ -305,7 +305,7 @@ Hint: 如果你不知道如何通过`gcc`和`objcopy`命令实现上述功能, ::: -:::tip[RTFM理解总线协议] +:::hint[RTFM理解总线协议] 如果你发现仿真过程中发现总线的行为难以理解, 先尝试RTFM尽可能理解手册中的所有细节. 随着项目复杂度的增加, 你将要为不仔细RTFM付出越来越大的代价. ::: @@ -324,7 +324,7 @@ Hint: 如果你不知道如何通过`gcc`和`objcopy`命令实现上述功能, 但作为前期测试, 这可以方便我们快速检查字符是否被正确写入串口发送队列. 我们将在成功运行足够多程序后, 再添加除数的设置, 使得代码可以在真实的硬件场景中工作. -:::warning[去掉换行也能输出] +:::todo[去掉换行也能输出] 上述`char-test`在输出字符`A`之后, 还输出一个换行符. 尝试仅仅输出字符`A`而不输出换行符, 你应该会观察到仿真过程连字符`A`都不输出了. 但如果每次输出一个字符之后都紧接着输出一个换行符, 打印出的信息将很难阅读. @@ -377,7 +377,7 @@ SRAM能够使用与处理器制造相同的工艺进行生产, 同时读写延 1. 程序中不能包含对全局变量的写入操作 1. 栈区需要分配在可写的SRAM中 -:::warning[为ysyxSoC添加AM运行时环境] +:::todo[为ysyxSoC添加AM运行时环境] 添加一个`riscv32e-ysyxsoc`的新AM, 并按照上述方式提供TRM的API. 添加后, 将`cpu-tests`中的`dummy`测试编译到`riscv32e-ysyxsoc`, 并尝试在ysyxSoC的仿真环境中运行它. @@ -387,13 +387,13 @@ Hint: 为了完成这个任务, 你需要一些链接的知识. ::: -:::info[无法运行的测试] +:::option[无法运行的测试] 尝试在ysyxSoC中运行`cpu-tests`中的`fib`, 你将发现运行失败. 尝试阅读提示信息, 你觉得应该如何解决这个问题? ::: -:::warning[重新添加DiffTest] +:::todo[重新添加DiffTest] 我们新增了MROM和SRAM, 接下来一段时间我们都会在MROM和SRAM上运行程序. 但目前NEMU并没有MROM和SRAM, 如果我们在DiffTest的时候跳过MROM和SRAM和访问, 将会跳过所有指令的执行, 使得DiffTest将无法起到预期的作用. @@ -407,7 +407,7 @@ Hint: 为了完成这个任务, 你需要一些链接的知识. ::: -:::info[让NPC抛出Access Fault异常] +:::option[让NPC抛出Access Fault异常] 尽管这不是必须的, 我们建议你在NPC中添加Access Fault的实现. 在系统运行发生意外导致访问了未分配的地址空间, 或者设备返回错误时, ysyxSoC可以通过AXI的`resp`信号传递相关的错误信息. @@ -451,3 +451,4 @@ ysyxSoC可以通过AXI的`resp`信号传递相关的错误信息. 测试分两步, 第一步是依次向各个内存区间写入相应数据, 第二步是依次从各个内存区间中读出数据, 并检查是否与之前写入的数据一致. 可分别通过8位, 16位, 32位, 64位的写入方式重复上述过程. +