TL; DR一个小型CPU设计,可以派上用场,一个详细的代码演练,一个开始学习SILIC和RISC-V的好地方。
ICE-V是实现RISC-V RV32I规范的处理器。它简单且紧凑(〜100行减少,见下图),演示了Siliel的许多功能,可以是项目中的好伴侣。它专门用于从BRAM执行代码,其中代码在合成时被烘焙到BRAM中(可以是从其他来源加载的引导加载程序)。
它很容易隐藏,可扩展到从SPI启动,从RAM执行代码,并连接到各种外围设备。示例驱动器驱动器和外部SPI屏幕。
此处的版本在ICESTICK ICE40 1HK上运行开箱即用,并且可以适用于其他努力的其他板。
构建以两步执行,首先编译一些处理器运行的代码:
在ICESTICK上,LED将以旋转模式闪烁一个围绕中心。
可选地,您可以插入一个小OLED屏幕(我使用了128x128 RGB,使用SSD1351驱动程序)。
注意:处理器的代码符合RISC-V Toolchain。在Windows下,这包含在我的fpga-binutils repo的二进制包中。在MacOS和Linux下有预编译包,或者您可能更愿意从源编译。有关更详细的说明,请参阅“获取”。
现在我们已经测试了ICE-V LET'潜入代码!整个处理器适用于不到300行的SILICE代码(〜130没有评论)。
RISC-V处理器令人惊讶的是简单!这也是发现一些SILICE语法和功能的良好机会。
处理器处于文件ice -v.ice。对于演示,文件Ice-v-soc.ice中的INA INA INA INA INA MIMILASID SOC。
算法执行是负责分割从存储器(解码器)的其余部分使用的信息读取的32位指令,以及执行所有整数算术(ALU):添加,子,换档,按位运算符等。
算法RV32I_CPU是主处理器循环。它从内存中获取指令,读取寄存器,使用此数据设置解码器和ALU,根据需要执行其他内存加载/存储,并且存储寄存器的存储结果。
我们将在开始时跳过一切(我们' ll在需要时返回到那个),并专注于执行指令的无限循环。它具有以下结构:
虽然(1){// 1. - 刚刚获得的指令// - 设置寄存器读取++://等待要读取的寄存器(1个周期)// 2. - 寄存器数据可用// - 触发alu( 1){//解码+ alu在输入循环(1个周期)//来自解码器和Alu的结果(exec。load | exec。store){// 4 - 设置加载/存储RAM地址// - Enable内存存储?++://等待内存事务(1个循环)// 5. - 写入加载数据以注册? // - 恢复下一个指令地址中断; // done //循环后的下一个指令读取(1个循环)} else {// 6. - 在寄存器//中存储指令的结果/ - 设置下一个指令地址if(exec。工作== 0){// alu完成?休息; // done //循环读取的下一个指令(1 cycle)}}}}}}}}}}}}}}}
循环结构被构造成使得大多数指令需要三个周期,负载/存储需要额外的循环。它还允许等待有时需要多个周期的ALU(移位偏差一位).SILICE有关于如何在控制流中使用循环(何时/中/如果/ else / else)的准确规则,这使我们能够写入循环使浪费没有循环。
让&#39逐步完成这一步。第一个(1)是主处理器循环。在迭代开始(标记1.上面)中,从启动时的引导地址或从前一个迭代设置中可以从内存中获得指令。我们首先将从内存读取的数据复制到本地innorl变量中,这样佩戴自由做其他内存事务。我们还复制了指令从一个名为PC进行程序计数器的变量中的内存地址。
存储在instr中的指令还将更新从寄存器读取的值。在almains_after块中完成,该值指定在其他一切之后每种循环所做的事情。 always_after块包含从ortron中读取的这两个Linessetting寄存器:
// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv for nowxregsa.addr = / * xregsa.wenable? exc.write_rd:* / rtype(ortor).rs1; xregsb.addr = / * xregsa.wenable? exc.write_rd:* / rtype(ortor).rs2;
寄存器存储在两个糟糕,XREGSA和XREGSB中。通过设置其ADDR字段,我们已知寄存器的值将在下一个时钟周期处于其rdatafield。这就是我们等待一个周期的原因与++:标记1后。
//位菲尔德用于更容易解码的指南Bit菲尔德RTYPE {UINT1未使用1,UINT1标志,UINT5未使用2,UINT5 RS2,UINT5 RS1,UINT3 OP,UINT5 RD,UINT7 OPCODE}
写RTYPE(inror).rs1与instr [15,5](从位15的5位宽度)相同,但在readier中读取/修改格式。
我们使用两个框的原因是能够在单个周期中读取两个寄存器。因此,这两个框始终包含相同的值,但可以独立读取。
XREGSA和XREGSB始终将它们写在一起,因此它们保持相同的值。这也是在always_after块中完成的:
//将数据写入寄存器bramsxregsa.wdata = write_back; xregsb.wdata = write_back; // XREGSB编写XREGSA ISXREGSB.wenable = xregsa.wenable; //写入write_rd,else track指令registerxregsa.addr = xregsa.wenable? exc.write_rd:rtype(ortor).rs1; xregsb.addr = xregsa.wenable? exc.write_rd:RTYPE(inror).rs2;
BRAM WDATA字段都设置为相同的WRITE_BACK值和XREGSB.WENABLE TRACKS XREGSA.wenable。最后,当它们的字段Wenabled = 1时,它们都写入exec.write_rd给出的thesame addr。这确保了两个糟糕始终具有相同的值。
在此设置之后,我们等待一个循环(++ :)用于在BRAM输出上可用的寄存器值。一旦寄存器值可用(标记为2),解码器和ALU将从这些更新值开始刷新。两个解码器和ALU都在称为Execute的SyperalGorithm中分组。执行的输出与' dot'语法:exec.name_of_output。
//解码器+ ALU,执行指令并告诉处理器到doxecute exec(instr lt; :: instr,pc< xa< xa< xa< xb<< xb< x1;
该算法接收指令符号,程序计数器PC,在那里称为XREGSA.RDATA和XREGSB.RDATA。这些与带有接线操作员的输入符合脚音议论力< ::和<:。与定时有关的重要名因。运算符< ::表示viredifired,在循环期间,在其在循环期间,实例化算法执行它的值将其值逐渐变为rv32i_cpu。因此,执行确实可以注意到在标记1处分配了两个符号和PC时的变化,但只能看到下一个周期(++之后:)的变化。这会产生初始循环延迟,但也使电路更短,导致更高的设计的最大频率。这些是在您的设计中发挥的重要权衡。
回到标记2.,一旦寄存器数据是可用的信息,流程就会执行,我们没有任何特定的操作。但是,要告诉它的executeneed的Alu部分应该触发其在此特定周期的计算:
有关(重要!)算法绑定和时间主题的所有详细信息,请参阅专用页面。
然后我们进入第二个(1)循环。在许多情况下,我们将在仅一个周期之后突破第二个循环,但有时alu需要在多个周期上工作,因此循环允许等待。进入循环需要一个周期,因此在我们输入循环数据时,通过执行,当我们在循环中时,它的输出就绪。
在循环中,我们区分了两个案例:如果(exec.load | exec.store)或其他指令运行,则必须执行加载/存储。首先考虑第二个案例(标记6.)。非负载/商店指令通过解码器和ALU进行。
首先,我们考虑将指令结果写入寄存器。这是通过以下代码完成的:
召回XREGSA是一个框保持寄存器值。其棘手的字段指示我们是否编写(1)或读取(0)。在这里,如果解码器输出exec.no_rd低,则会启用它。但这似乎有点短暂吗?例如,我们在哪里讲述要写的内容?这实际上是在always_after块中完成的,正如我们之前见过的那样(请参阅上面的寄存器部分)。要写入的数据设置为:
这解释了为什么我们不需要在将指令写入寄存器时再次设置它。
但为什么这么做?为什么不简单地写下这个代码6.与其他人一起?这是为了效率,无论是在电路尺寸和频率方面。如果分配在6中。将生成更复杂的电路以确保仅在此特定状态下进行。这需要更复杂的多路复用器电路,因此最好盲目地将此值盲目地设置在almains_awter块中。只要我们没有设置Xregsa.wenable = 1无论如何都没有写。这是高效硬件设计的一个非常重要的方面,并且通过仔细避免非任务条件,您的设计将更有效。请参阅Siliue设计指南。
//我们在注册时写的是什么? (PC,ALU或VAL,负载分别处理)//'或欺骗和#39;从Femtorv32Int32 Write_back<:(exec.jump?(next_pc<<< 2):32b0)| (exec.storeaddr?exec.nn [0,$ addrw + 2 $]:32b0)| (exec.storeval?exec.val:32b0)| (exec.load?加载:32b0)| (exec.intop?exec.r:32b0);
write_back<:...:...定义一个表达式跟踪器:只读变量Write_Backis在其定义中给出的表达式的别名(Verilog术语中的电线)。 write_back提供基于解码器输出写回的值。 exec.storeaddr表示以exec.n(auipc)编写由ALU计算的地址。 Exec.StoreVal表示从解码器(LUI或RDCycle)中写下value exec.val。 exc.jump表示写回ide_pc<<<< 2(jal,jalr,条件分支)。将换档将32位指令指针转换为字节地址。
好的!寄存器已更新。返回标记6.接下来我们设置要获取和执行的下一个指令的地址:
这是从exec.jump指示的跳转/分支的情况下从alu计算的地址,或者是单独的pc + 1的next_pc的值:当前一个后的指令。
几乎完成了,但首先我们必须检查alu是否不在多个cossoperations中。这就是为什么我们只打破(exec.working == 0)。如果没有,循环再次迭代,等待Alu.note 6.将再次访问,所以我们' LL再次写入登记册。是的,如果Thealu尚未完成,我们之前所做的写作可能是一个错误的价值。但这很好:结果在最后一次迭代中会对正确,而且它的成本为我们这些写作。事实上,它的成本较少,因为没有这样做会再次重新浏览更多的多路复用电路!
休息后,返回循环的开始需要一个周期。在此期间,从MEM读取下一个指令,结果(如果有的话)是在XREGSA中的寄存器写入的。
您可能已经注意到我们在Wide_Addr中写下了下一个地址,而ThemeMory接口是MEM,所以我们应该写入MEM.ADDR?这是为了允许SOC看到更广泛的地址总线并执行内存映射。我们setIn _addr的地址分配给almains_after块中的mem.addr,它在每个周期的末尾屏蔽:mem.addr = wide_addr。它还从SOC的算法输出:输出! uint12 wide_addr(0)输出!意味着SoC立即看到vide_addr的更改。
它是非负载/商店说明的。现在让我们回到(exec.load | exec.store)并查看如何处理加载/存储。由于ICE-V专业为BRAM,因此我们知道所有内存事务都采取单个循环。虽然我们' ll必须占该循环,这是一个很大的奢侈品,而不得不等待外部存储器控制器的未知数。
达到标记时.4我们首先设置负载/存储的地址。来自ALU的这个地址:
两个筛选两个是由于计算的地址处于字节,而内存接口地址处于32位词。
然后,这是一个商店或负载。如果这是一个商店,我们需要为内存提供。存储器被称为MEM,并且是一个糟糕,给出CPU:算法Rv32i_cpu(bram_port mem,...)。 BRAM在每个地址占有32个字词。要启用WRITES我们设置其可拒绝的成员。然而,这个糟糕的特异性:它允许写入掩码。如此,Wenable不是单一的,而是四位,大概是在每个内存地址中选择性地写入四个字节中的任何一个。
我们需要那个! RISC-V RV32i规范包括负载/存储的字节,16位和32位字。这意味着,取决于指令(SB / SH / SW),我们需要适当地设置写掩码。这是通过此代码完成的:
// == store(如果if exc.store == 1)//构建写掩码,取决于SB,SH,SWMEM.wenable =({4 {exec。Stor}}& {2 {exec。op [ 0,2] == 2b10}},执行。Op [0,1] |执行。Op [1,1],1b1})<< exec.n [0,2];
这似乎有点密码,但这确实是通过exec.op产生形式4b0001,4b0010,4b0100,4b1000(sb)或4b1111,4b1100(sh)或4b1111(sw)的写掩模[0,2 ](加载类型)和exec.n [0,2](地址最低位).As,这可能不是一个商店,掩码和exec.store isapplied。语法{4 {exec.store}}表示位exec.store是复制的four次以获取uint4。
接下来我们等待一个周期为内存事务发生在BRAM中用++:。如果是我们刚写的商店,我们在达到标记5时完成。
如果这是一个我们刚从内存中读取的负载,现在必须将结果存储所选寄存器。这是由此代码完成的:
这足以触发寄存器更新,因为XREGSA.WDATA和XREGSA.ADDR在always_After块中正确设置,其中white_back.recall在exec.load时加载write_back。加载定义如下:
//解码从内存加载的值(exec.load == 1)uint32对齐<:mem.rdata>> {exec。 n [0,2],3b000};交换机(exec.op [0,2]){// lb / lbu,lh / lhu,lw case 2b00:{加载= {{24 {(〜exec。oc oc。op [2,1])&对齐[7, 1]}},对齐[0,8]}; }案例2b01:{加载= {{{{{{j {(〜oc。Op [2,1])&对齐[15,1]},对齐[0,16]};}案例2b10:{加载=对齐;默认值:{加载= {32 {1bx}}; } // Don' t care(不会发生)}
这取决于访问字节(LB / LBU),16位(LH / LHU)或32位(LW)是否被访问(U表示无符号),选择加载值。 mem.rdata是出于内存的值,ANDIT转移到与地址最低位Ecop.nn [0,2]选择的部分。
请注意,{exec.n [0,2],3b000}简单地执行了exec.n [0,2]< 3(左移三位等同于将三个0位连接到右侧)。
加载/存储完成后,我们还原下一个指令地址Next_PC,以便处理器在休息后准备好继续下一次迭代:
而且它'我们已经看到整个处理器逻辑。让'现在潜入其他组件。
解码器是执行算法的一部分。它是一个相对简单的事件。它通过解码所有可能的即时值来开始 - 这些是在不同类型中编码的常量:
//解码immediateSint32 imm_u&lt ;: {instr [12,20],12b0}; int32 imm_j<:{{12 {instr [31,1]},instr [12,8],instr [20,1] ,instr [21,10],1b0}; int32 imm_i<:{{20 {instr [31,13,1]},instr32 imm_b}; int32 imm_b}; {{20 {instr [31, 1]}},instr [7,1],instr [25,6],instr [8,4],1b0}; Int32 Imm_s<:{{20 {instr [31,13,1],orstr [25 ,7],instr [7,5]};
仅在匹配指令执行时使用这些值。例如,IMM_I用于注册即时整数操作。
UINT5 OPCODE&lt ;: instr [2,5]; UINT1 AUIPC&lt ;: OPCODE == 5B00101; UINT1 LUI&lt ;: OPCODE == 5B01101; UINT1 jal<:opcode == 5b11011; UINT1 JALR<:OPCODE == 5B11001; UINT1 INTIMM<:OPCODE == 5B00100; UINT1 Intreg<:OPCODE == 5B01100; UINT1循环<:OPCODE == 5B11100; UINT1分支<:OPCODE == 5B11000;
这些当然是相互排斥的,所以其中只有一个在Givences中。
最后,我们设置了解码器输出,告诉处理器如何处理该指令。
// ====设置解码器输出,具体取决于传入指令//加载/存储?Load:= OPCode == 5B00000;商店:= OPCODE == 5B01000; //负载/存储//注册的运算符写入?OP:= RTYPE(instr).op; write_rd:= RTYPE(instr).rd; //我们必须将结果写入寄存器吗?no_rd:= branch |商店| (RTYPE(instr).rd == 5b0); //整数操作//存储下一个地址?Intop:=(Intimm | Intrec); StoreAddr:= auipc; //直接存储//存储值?val:= lui? imm_u:循环; Storeval:= Lui |循环;
始终分配运算符:=在输出上使用意味着输出设置为每个循环的第一件物(这是正常分配=在always_before块中的短路等)。
例如,Write_rd:= RType(inror).rd是目的地用于指令的索引,而no_rd:= branch |商店| (RTYPE(instr).rd == 5b0)表示是否启用了写入寄存器。
注意no_rd中的条件RTYPE(ortr).rd == 5b0。根据RISC-V SPEC,这是零的零,应始终保持零。
ALU执行所有整数计算。它由三个部分组成。 TheItger操作,如添加,子,SLLI,SRLI和XOR(输出R);有条件分支的比较器(输出跳转);下一个地址加法器(输出n)。
由于数据流是设置的方式,我们可以使用一个很好的技巧。 Alu AS Wellas该比较器为其操作选择两个整数。冰相对的设置,使得两者都可以输入相同的整数,因此它们可以共享相同的电路执行类似操作。什么是常见的< =,>> =?他们一切都是用一个减法完成的!此技巧实现如下:
// ====允许使用Femtorv32 / Swapforth / J1Int33 A_Minus_B< {1b1,〜b} + {1b0,xa} + 33b1; uint1 a_lt_b< uint1 a_lt_b< ==== (XA [31,1] ^ B [31,1])? xa [31,1]:a_minus_b [32,1]; uint1 a_lt_b_u<:a_minus_b [32,1]; uint1 a_eq_b&lt ;: a_minus_b [0,32] == 0;
XA是第一个寄存器,而B在基于解码器的结果之前选择B:
// ====选择下一个地址加法器首先inputint32 addr_a&lt ;: pcorreg? __signed({PC [0,$ AddRW-2 $],2b0}):xa;
例如,指令Auipc,jal和branch将为addr_a选择ProgramCounter PC,如解码器中所示:
下一个地址计算中的第二个值是直接选择的正在基于运行指令:
// ====立即选择下一个地址计算int32 addr_imm&lt ;:(auipc?imm_u:32b0)| (jal?imm_j:32b0)| (分支?imm_b:32b0)| ((jalr | load)?imm_i:32b0)| (商店?imm_s:32b0);
然后,下一个地址只是添加的总和 ......