控制单元是整个CPU的核心,他控制着CPU内各个功能部件以及流水线的运作。
本系统中控制单元的功能需求也是随着设计实现工作的进展一点一点增加的,我先后在控制单元中添加了分支预测、多周期指令、cache同步和自修改代码、精确中断和异常等复杂的控制功能,希望在保持设计简洁的同时能够给程序员带来更多便利,最大限度的屏蔽微架构在程序层面的影响。
经过一个多月的工作,我所设计的控制单元初版已经完成(control_unit.v),我将在这篇文章中详细记述我的设计决策。

设计中最复杂的部分是实现对流水线正确地控制。分支预测失败、多周期指令、自修改代码、异常中断和流水线冲突很多是可能同时发生的,因此要想实现对流水线的正确控制,必须仔细分析这其中的关系,以确定当他们同时发生时处理器的行为。这项分析工作占据了我这个月大部分时间,最终我得以找到一套合理的处理逻辑来解决流水线的控制,这也将是这篇文章的重点内容。

设计目标

  • 硬线逻辑实现
  • 分支预测支持
  • 多周期指令支持(按字节,按半字写入内存)
  • cache同步和自修改代码支持
  • 精确中断和异常支持
  • 正确高效处理流水线冲突(流水线暂停和数据旁路,未来考虑增加对乱序指令的支持(初步的超标量))
  • 预留性能检测模块支持

实现

接口定义

依照惯例,先描述控制单元与其他部件的接口。

与IR(指令寄存器)的连接

1
2
input canceled;
input [31:0] instruction;

IR是IF级与ID级之间的流水线寄存器,如果该指令在预取阶段(IF)就被撤销,canceled位会被设置为高电平。
instruction会被拆分成几段供译码器使用:

1
2
3
4
5
6
7
wire [5:0] op = canceled ? 6'b0 : instruction [31:26];
output [4:0] rs ;
assign rs = canceled ? 5'b0 : instruction [25:21];
output [4:0] rt ;
assign rt = canceled ? 5'b0 : instruction [20:16];
wire [4:0] rd = canceled ? 5'b0 : instruction [15:11];
wire [5:0] func = canceled ? 6'b0 : instruction [5:0];

其中rs、rt用作两个寄存器号输出给寄存器堆做索引。

与寄存器堆的连接

除rs、rt输出给寄存器堆做索引外,CU还需要将寄存器堆的两个数据输出作为输入:

1
2
3
output [4:0] rs ;
output [4:0] rt ;
input [31:0] qa,qb;

向下一级流水线寄存器的信号输出

数据输出
1
2
output [31:0] da,db,imm;
output [4:0] TargetReg;

da,db和imm是三个操作数,其中imm根据sign信号进行了符号扩展。
TargetReg是当前指令的目标寄存器号。

控制信号

这些信号控制着ID级后的功能部件:

1
2
3
4
output RegWrite,M2Reg,MemReq,MemWrite,AluImm,ShiftImm,link,slt;
output sign;
output StoreMask,LoadMask,B_HW,LoadSign;
output reg [3:0] AluFunc;

RegWrite:当指令需要写入寄存器堆时,该信号为高电平。
M2Reg:当指令需要从内存读取数据并写入寄存器堆时(load指令),该信号为高电平。
MemReq:当指令需要请求内存时,该信号为高电平,这个信号在MEM级与Dcache的请求信号相连。
MemWrite:当指令需要写入内存时(store指令),该信号为高电平。
AluImm:该信号为高电平表示当前指令在使用ALU时,其中的一个操作数时立即数。该信号控制ALU数据输入接口B前的多路器。
ShiftImm:该信号为高电平表示当前的位移指令的位移量是立即数。该信号控制ALU数据输入接口A前的多路器。
link:该信号表示当前信号是一个jump and link指令,他将修改EXE级的输出:将ALU的输出设定为PC+4,并将目标寄存器号设定为31.
slt: 该信号为高点并表示当前指令是一个条件测试指令(stl等)。

sign:该信号用于指示EXE级计算是否有溢出异常,并且用于CU内部控制立即数的符号扩展。

StoreMask,LoadMask,B_HW,LoadSign四个信号用于实现按半字、按字节存取:
StoreMask:该信号为高电平表示需要MEM级在写入前使用mask。
LoadMask:该信号为高电平表示需要MEM级在读出后使用mask。
B_HW:该信号控制mask的长度是8位还是16位(字节或者半字)。
LoadSign:该信号控制按字节或按半字读取时的符号扩展。

AluFunc:AluFunc是根据指令生成的ALU function code,编码如下:

code func code func code func code func
0000 ADD 0100 AND 1000 SLL 1100 MULT
0001 SUB 0101 XOR 1001 LUI 1101 MULTU
0010 CLZ 0110 OR 1010 SRL 1110 DIV
0011 CLO 0111 NOR 1011 SRA 1111 DIVU

流水线控制

两个cache的ready信号输入,标志着处理器是否可以执行下一条指令:

1
input I_cache_R, D_cache_R;

根据这两个ready信号输入,可以生成CPU_stall信号,它既参与ID级内部的控制,也作为控制信号输出给其他各级:

1
2
output CPU_stall;
assign CPU_stall = ~(I_cache_R & D_cache_R);

对于IF和ID级,多周期指令和流水线数据相关冲突会引起额外的暂停,Stall_IF_ID与CPU_stall一起控制IF和ID级的流水线寄存器的写入:

1
output Stall_IF_ID;

控制单元与分支预测器的接口如下:

1
2
3
output BP_miss;
output is_branch,do_branch, V_target;
input [31:0] BP_target;

BP_miss为高电平标志当前分支预测结果错误,流水线需要退回。
is_branch为高电平表示当前ID级的有效指令是一个跳转指令。
do_branch为高电平表示若当前ID级的有效指令是一个跳转指令,则当前跳转指令的计算结果是跳转发生。
V_target为高电平表示若当前ID级的有效指令是一个跳转指令,则当前跳转指令的跳转目标是可变的(由寄存器读出)。
上述四个指令将指导分支预测器更新维护其跳转历史表。
BP_target为分支预测器的输出,是分支预测器的预测结果,该数据用于计算next PC。

1
output reg [31:0] next_PC;

PC寄存器中的数据在每一级流水线寄存器中都要寄存,用于追踪当前级执行的指令地址,以实现复杂的流水线控制:

1
input [31:0] PC, ID_PC, EXE_PC, MEM_PC;

除ID级异常异常号由CU生成外,其余各级的异常和异常号均由各级直接输入给CU:

1
2
input exc_IF, exc_EXE,exc_MEM;
input [2:0] IF_cause,EXE_cause,MEM_cause;

每级异常号均有3位,即每级支持7种异常。

由CU产生各级的废弃信号

1
2
output reg ban_IF,ban_EXE,ban_MEM;
output ban_ID;

中断控制器的连接:

1
2
3
input int_in;
output int_ack;
input [19:0] int_num;

SOC中,中断控制器负责控制外部中断的排队。它将排队成功的请求编码成20位的中断向量通过int_num送给CU,并将外部中断信号int_in置为高电平,等待CU决定接受中断请求时,CU会将int_ack置为高电平一个周期,中断控制器在收到int_ack后应立即撤掉当前中断请求。

每级的3位异常号和20位的中断向量一起编码,在响应异常或中断时存储在CAUSE寄存器中,编码格式如下:

31-29 28-26 25 -23 22-20 19 — 0
IF ID EXE MEM interrupt

处理流水线数据相关的暂停和数据旁路需要EXE级和MEM级的控制信号,因此要将下列信号从EXE级和MEM级抽出,输入CU:

1
2
3
input  M_m2reg,M_RegWrite,E_m2reg,E_RegWrite;
input [ 4:0] M_TargetReg,E_TargetReg;
input [31:0] E_AluOut,M_AluOut,M_MemOut;

M_m2reg,M_RegWrite,E_m2reg,E_RegWrite对应MEM级和EXE级的m2reg和RegWrite信号。
M_TargetReg和E_TargetReg用于和当前ID级指令的源寄存器号比较,以发现数据相关冒险。
E_AluOut,M_AluOut,M_MemOut用作数据旁路,将这些未执行完的指令的中间结果直接拿到ID级使用。

CU还支持对自修改代码的同步,因此需要当前MEM级正准备修改的内存地址:

1
2
input [31:0] M_MemAddr;
input M_MemWrite;

时钟和清零信号

1
input clk,clr;

功能实现

对于CU实现的描述我将按照先简单后复杂,先个体再整体的顺序描述,先说一说每个功能单元的设计,然后再说关于他们协同工作的设计。

指令译码

指令译码是CU中最容易实现的部分,它直接将分片过的指令与指令模板进行比对。如果未来要支持其他的指令,则需要直接修改这部分设计。
首先将Register类型的指令挑出来,便于后续指令译码:

1
2
3
4
5
//the instruction is R-type if the op code is 000000(SPECIAL), 011100(SPECIAL2)
//or 010000(CP0).
wire r_type = ((op == 6'b000000)? 1'b1:1'b0);
wire r_type_2 = ((op == 6'b011100)? 1'b1:1'b0);
wire r_type_cp0 = ((op == 6'b010000)? 1'b1:1'b0);

译码相关指令非常简单。R type指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//R_type
wire i_eret = (r_type_cp0 && rs==5'b10000 && func==6'b011000)?1'b1:1'b0;
wire i_mfc0 = (r_type_cp0 && rs==5'b00000) ? 1'b1:1'b0;
wire i_mtc0 = (r_type_cp0 && rs==5'b00100) ? 1'b1:1'b0;

wire i_syscall = (r_type && func ==6'b001100) ? 1'b1:1'b0;

wire i_sll = (r_type && func==6'b000000) ? 1'b1:1'b0;
wire i_srl = (r_type && func==6'b000010) ? 1'b1:1'b0;
wire i_sra = (r_type && func==6'b000011) ? 1'b1:1'b0;
wire i_sllv = (r_type && func==6'b000100) ? 1'b1:1'b0;
wire i_srlv = (r_type && func==6'b000110) ? 1'b1:1'b0;
wire i_srav = (r_type && func==6'b000111) ? 1'b1:1'b0;
wire i_jr = (r_type && func==6'b001000) ? 1'b1:1'b0;
wire i_jalr = (r_type && func==6'b001001) ? 1'b1:1'b0;
wire i_add = (r_type && func==6'b100000) ? 1'b1:1'b0;
wire i_addu = (r_type && func==6'b100001) ? 1'b1:1'b0;
wire i_sub = (r_type && func==6'b100010) ? 1'b1:1'b0;
wire i_subu = (r_type && func==6'b100011) ? 1'b1:1'b0;
wire i_and = (r_type && func==6'b100100) ? 1'b1:1'b0;
wire i_or = (r_type && func==6'b100101) ? 1'b1:1'b0;
wire i_xor = (r_type && func==6'b100110) ? 1'b1:1'b0;
wire i_nor = (r_type && func==6'b100111) ? 1'b1:1'b0;
wire i_slt = (r_type && func==6'b101010) ? 1'b1:1'b0;
wire i_sltu = (r_type && func==6'b101011) ? 1'b1:1'b0;

wire i_clz = (r_type_2 && func==6'b100000) ? 1'b1:1'b0;
wire i_clo = (r_type_2 && func==6'b100001) ? 1'b1:1'b0;

I type指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//I_type
wire i_bgez = (op==6'b000001 && rt==5'b00001) ? 1'b1:1'b0;
wire i_bgezal = (op==6'b000001 && rt==5'b10001) ? 1'b1:1'b0;
wire i_bltz = (op==6'b000001 && rt==5'b00000) ? 1'b1:1'b0;
wire i_bltzal = (op==6'b000001 && rt==5'b10000) ? 1'b1:1'b0;
wire i_beq = (op==6'b000100) ? 1'b1:1'b0;
wire i_bne = (op==6'b000101) ? 1'b1:1'b0;
wire i_blez = (op==6'b000110) ? 1'b1:1'b0;
wire i_bgtz = (op==6'b000111) ? 1'b1:1'b0;
wire i_addi = (op==6'b001000) ? 1'b1:1'b0;
wire i_addiu = (op==6'b001001) ? 1'b1:1'b0;
wire i_slti = (op==6'b001010) ? 1'b1:1'b0;
wire i_sltiu = (op==6'b001011) ? 1'b1:1'b0;
wire i_andi = (op==6'b001100) ? 1'b1:1'b0;
wire i_ori = (op==6'b001101) ? 1'b1:1'b0;
wire i_xori = (op==6'b001110) ? 1'b1:1'b0;
wire i_lui = (op==6'b001111) ? 1'b1:1'b0;
wire i_lb = (op==6'b100000) ? 1'b1:1'b0;
wire i_lh = (op==6'b100001) ? 1'b1:1'b0;
wire i_lw = (op==6'b100011) ? 1'b1:1'b0;
wire i_lbu = (op==6'b100100) ? 1'b1:1'b0;
wire i_lhu = (op==6'b100101) ? 1'b1:1'b0;
wire i_sb = (op==6'b101000) ? 1'b1:1'b0;
wire i_sh = (op==6'b101001) ? 1'b1:1'b0;
wire i_sw = (op==6'b101011) ? 1'b1:1'b0;

J type指令:

1
2
3
//J_type
wire i_j = (op==6'b000010) ? 1'b1:1'b0;
wire i_jal = (op==6'b000011) ? 1'b1:1'b0;

每个支持的指令对应一个i_xxx信号,这些信号同一时间只有可能有一个为高电平。若所有这些信号均为低电平则说明当前指令未尚不支持的指令,CU会产生ID级异常:

1
2
//for instructions which are not implemented. 
wire i_unimp =~(i_xxx|i_xxx......);

多周期指令支持

为什么要支持多周期指令?有些指令是无法在一个周期内执行完的,比如store byte和store half-word。我所设计的这个计算机系统内存是按字(4字节)索引的,因此要想实现SB和SHW指令,至少需要两个周期,第一个周期读取要写入的字,第二个周期修改要修改的字节或半字并写入内存。如果处理器不直接提供这种按字节或按半字存取的指令,就要使用汇编器产生的伪指令,这样很难保证两条伪指令之间的原子性,因此我决定在CU中增加对多周期指令的支持。具体的做法是设置一个微程序计数器(microprogram counter uPC),并且在译码单元中对每条指令所需要的周期数进行记录(cycle),当upc小于cycle时,说明指令尚未执行完,ID级和IF级的流水线寄存器会暂停,而CU内部根据当前指令和upc的值生成对应多周期指令某一周期所需要的控制信号。这个做法与传统的使用微程序的控制单元有一定的不同。

1
2
3
4
5
6
7
8
9
10
//cycle implies number of cycles for a instruction
//The width of this signal is variable. For now the maximum is 2 clock cycles,
//so the cycle is 1 bit long.
wire cycle = (i_sb|i_sh) ? 1'b1 : 1'b0;
//upc is the micro program counter for multi-cycle instructions.
//upc is cleared when a instruction is done.
//As mentioned above the width of this counter is variable.
reg upc;
//New instruction is fetched only when last instruction is done.
wire ins_done = (canceled | ban_ID_EXC) ? 1'b1 : upc == cycle;

微程序计数器在指令完成后清零,在CPU stall或由于数据相关引起的IFID级暂停时暂停更新,其他情况微程序计数器加一:

1
2
3
4
5
6
7
8
9
always @(posedge clk)
begin
if (clr)
upc <=0;
else begin
if (ins_done) upc <= 0;
else if (~Stall_RAW & ~CPU_stall) upc <= upc +1'b1;
end
end

CU中有多个stall信号,他们是整个系统可以协同工作的关键。我为数据相关引起的IF和ID级暂停引入了单独的stall信号(stall_RAW),为尚未完成的多周期指令引入了单独的stall信号(ins_done),为由于cache未准备好引起的整个CPU暂停引入信号CPU_stall。
IF和ID级的暂停信号Stall_IF_ID由stall_raw和is_done共同产生:

1
assign Stall_IF_ID = ~ins_done | Stall_RAW;

关于upc的更新的条件需要特别地说明。数据相关有可能是某一个微指令引起的,因此当发生数据相关流水线需要暂停时,当前的微指令(或指令)也被暂停,因此此时不应更新upc。当Cache尚未准备好时,整个CPU的状态不发生变化,因此CU内部的所有寄存器在CPU_stall时均不发生改变。

功能部件控制信号生成

CU将所有需要的控制信号直接生成,各级的功能模块的控制信号通过各级流水线寄存器将后续各级需要的信号向后传递。
对于在WB级需要写入寄存器的指令,RegWrite信号为高电平:

1
2
3
4
5
6
7
//RegWrite is High when instruction needs to write registers.
//This signal is used as write enable signal of the register file at WB stage.
assign RegWrite = (i_mfc0|(i_bgezal & (~rs_less_than_0))|(i_bltzal & rs_less_than_0)
|i_addi|i_addiu|i_slti|i_sltiu|i_andi|i_ori|i_xori|i_lui|i_lb|i_lh
|i_lw|i_lbu|i_lhu|i_jal|i_sll|i_srl|i_sra|i_sllv|i_srlv|i_srav|i_jalr
|i_add|i_addu|i_sub|i_subu|i_and|i_or|i_xor|i_nor|i_slt|i_sltu|i_clz
|i_clo) & ~ban_ID;

对于所有需要请求D cache(内存)的指令,无论读写,MemReq信号为高电平,若当前指令不需要读写D cache,MemReq为低电平,D cache可以根据该信号直接生成ready信号跳过对内存的访问。对于SB和SHW指令,他们的两个周期都需要访存。

1
2
3
4
5
//Memory request signal is active when the instruction needs to access the memory,
//This signal is used at MEM stage. If accessing the memory is not needed, MEM can
//be skipped based on this signal.
//Store byte and Store half-word request memory access at both two cycle.
assign MemReq = (i_lb|i_lh|i_lw|i_lbu|i_lhu|i_sb|i_sh|i_sw) & ~ban_ID;

MemWrite指示当前指令是否需要写入内存。对于SW指令和SB、SHW的第二个周期(按字节、半字写入第一个周期读取相应的存储字,第二个周期在mask后写入像一个的存储字),该信号为高电平。

1
2
3
4
//Memory Write is active when the instruction is a store operate.
//Store byte and Store half-word need two cycle, second cycle of these two instruction
//need to write the memory
assign MemWrite = (i_sb & (upc == 1)|i_sh & (upc ==1)|i_sw) & ~ban_ID;

AluImm控制ALU数据输入端口B前的多路器,当AluImm为高电平时,端口B选择经ID级符号扩展后的立即数作为ALU端口B的输入。对于所有使用立即数的算术指令,该信号为高电平。

1
2
3
4
5
6
7
8
//AluImm indicates that operand B of ALU is an immediate operand, it is used at EXE 
//stage.
//For load and store instructions, memory address is calculated by alu with an
//16-bit immediate offset and rs.
//Branch instructions have 16-bit immediate offset, but the target address is
//calculated at ID stage, so AluImm signal is zero for branch instructions.
assign AluImm = i_addi|i_addiu|i_slti|i_sltiu|i_andi|i_ori|i_xori|i_lui|i_lb|i_lh
|i_lw|i_lbu|i_lhu|i_sb|i_sh|i_sw;

ShiftImm控制ALU数据输入端口A前的多路器。对于位移指令,位移量的输入在ALU的端口A,被位移的操作数输入于ALU端口B,因此对于使用立即数的位移指令该信号为高电平。

1
2
3
4
5
//ShiftImm is used at EXE stage. For shift word operations, rs is 5-bit zeros, and
//the shift amount is after rd, ShiftImm selected the correct sa into operand A of
//ALU. For shift word variable operations, This signal is low, since the shift amount
//is rs, like a normal R type instruction.
assign ShiftImm = i_sll|i_srl|i_sra;

link信号用于跳转并连接指令,他作用与EXE级ALU输出端口后的多路器。当他为高电平时,ALU的输出被绕过,取而代之的是EXE_PC+4,并把目标寄存器号覆盖为31,在MEM和WB级完成连接的功能,将当前跳转指令的下一跳指令地址(返回地址)放入约定好的寄存器31。

1
2
3
4
5
//link signal is for branch and link operations. This signal is used at EXE stage,
//link means that save corresponding pc into register 31, so when this signal is
//active, EXE stage will override the output of ALU with corresponding PC and select
//31 as the target register number.
assign link = i_bgezal|i_bltzal|i_jalr|i_jal;

slt信号用于set less than指令,ALU中没有实现比较功能,因此EXE级引入了一个额外的比较器。当slt信号为高电平时,EXE级的输出选择比较器的输出。

1
2
3
4
//slt is for set less than operations. When this signal is HIGH, the corresponding
//comparator in EXE stage is active, and the output of EXE stage will be selected
//as the output of these comparators.
assign slt = i_slt|i_sltu|i_slti|i_sltiu;

sing信号控制指令中的立即数是否需要符号扩展。它不光在ID级用来计算跳转的目标地址,也用在EXE级控制是否产生溢出异常,为set less than指令选择正确的比较器。

1
2
assign sign = i_bgez|i_bgezal|i_bltz|i_bltzal|i_beq|i_bne|i_blez|i_bgtz|i_addi|i_slti
|i_lb|i_lh|i_lw|i_lbu|i_lhu|i_sb|i_sh|i_sw|i_add|i_sub|i_slt;

下面这组信号用于控制按字节、半字读写。
StoreMask通知MEM级使用mask处理将要写入的数据,因此当store byte/half-word处于第二周期时,StoreMask为高电平:

1
2
3
//StoreMask select the masked value as the input of memory. This signal is active
//at the second cycle of store byte and store half word.
assign StoreMask = (i_sb & upc==1) | (i_sh & upc==1);

LoadMask通知MEM级在读出数据时使用mask,load byte/half-word指令不需要第二个周期。

1
2
3
//LoadMask select the masked value as the output of MEM stage. This signal is active
//for Load byte and Load half word instruction.
assign LoadMask = i_lb|i_lbu|i_lh|i_lhu;

B_HW用于为load/store byte/half-word这些指令选择正确的mask长度。

1
2
3
//for load/store byte/half word, B_HW is HIGH for load/store half word, and LOW for
//load/store byte.
assign B_HW = i_lh|i_lhu|i_sh;

LoadSign为单独为load byte/half-word引入的符号扩展标志。

1
2
//This is for load byte/half word signed instructions.
assign LoadSign = i_lh|i_lb;

CU会根据译码结果选择正确的目标寄存器号。大部分指令使用rd作为目标寄存器,有些指令则使用rt作为目标寄存器号,对于这些指令TargetRegSel为高电平,这个信号作用于CU内部并控制一个多路器将结果用TargetReg输出:

1
2
3
4
5
6
//Most instructions use rd as target register, but some use rt as target. 
//TargetRegSel is active when a instruction uses rt as target register.
//This signal is used in ID stage.
wire TargetRegSelect = i_mfc0|i_addi|i_addiu|i_slti|i_sltiu|i_andi|i_ori|i_xori
|i_lui|i_lb|i_lh|i_lw|i_lbu|i_lhu;
assign TargetReg = TargetRegSelect ? rt : rd;

最后,CU还需要生成合适的ALU控制码AluFunc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
always @ (*) begin
if (i_subu|i_sub) AluFunc = 4'b0001;
else if (i_clz) AluFunc = 4'b0010;
else if (i_clo) AluFunc = 4'b0011;
else if (i_and|i_andi) AluFunc = 4'b0100;
else if (i_xor|i_xori) AluFunc = 4'b0101;
else if (i_or|i_ori) AluFunc = 4'b0110;
else if (i_nor) AluFunc = 4'b0111;
else if (i_sll|i_sllv) AluFunc = 4'b1000;
else if (i_lui) AluFunc = 4'b1001;
else if (i_srl|i_srlv) AluFunc = 4'b1010;
else if (i_sra|i_srav) AluFunc = 4'b1011;
//for all other situations alu performs add operation.
else AluFunc = 4'b0000;
end

操作数

立即数截取

从IR中获取的立即数要根据指令的不同进行符号扩展:

1
2
3
4
//sign extension controlled by sign signal. 
wire e = sign & instruction[15];
wire [15:0] ext16 = {16{e}};
assign imm = canceled ? 32'b0 : { ext16, instruction[15:0] };
数据旁路和流水线暂停

寄存器中存储的操作数可能会受到数据相关冒险的影响,因此为其设计数据旁路。
数据旁路由三种情况:

  1. 若当前ID级的指令所使用的源寄存器与当前EXE级中的目标寄存器相同,且EXE级不是load指令,则直接将EXE级的结果作为当前ID级的源操作数。
  2. 若当前ID级指令所使用的源寄存器与MEM级的目标寄存器相同,且MEM级不是load指令,则直接将从EXE级传来的ALU的结果作为当前ID级的源操作数。
  3. 若当前ID级指令所使用的源寄存器与EXE级中的目标寄存器相同,且EXE级的指令是一个load指令(E_m2reg为高电平),这意味着IF和ID级必须等待一个周期,并且当前的ID级指令必须被废弃,这样一个周期以后,就可以将MEM读出的结果作为ID级指令的源操作数。

数据旁路和由于数据相关引起的流水线暂停的相关逻辑如下:
指令的两个源寄存器均有可能发生数据相关冲突,所以首先要将可能受影响的指令列出来。need_rs为高电平表示当前ID级的指令需要rs作为源寄存器;need_rt为高电平表示当前ID级的指令需要rt作为源寄存器。

1
2
3
4
5
6
//need_rs is active when a instruction needs rs as operand. 
wire need_rs = ~(i_eret|i_mfc0|i_mtc0|i_syscall|i_lui|i_j|i_jal|i_sll|i_srl|i_sra)
& ~ban_ID_EXC;
//need_rt is active when a instruction needs rt as operand.
wire need_rt = ~(i_eret|i_syscall|i_bgez|i_bgezal|i_bltz|i_bltzal|i_blez|i_bgtz|i_j
|i_jal|i_jr|i_jalr) & ~ban_ID_EXC;

如果发生第三种情况,要暂停IF、ID级流水线一个周期。为此引入一个独立的stall信号:

1
2
wire Stall_RAW = E_RegWrite & E_m2reg & (E_TargetReg!=0) 
&(need_rs & (E_TargetReg==rs)| need_rt & (E_TargetReg==rt));

在流水线暂停的同时,还要将当前ID级的指令废弃,为此引入一个独立的废弃信号:

1
wire ban_ID_RAW = Stall_RAW;

正如前面所说,整个系统内有多个暂停和废弃信号,他们是系统各个部件可以协同工作的关键,我将在流水线控制部分详细介绍这些信号的层级和作用。
数据旁路主要实现了上述三种情况,在寄存器堆和下一级流水线寄存器间增加一个多路器,最终结果为regA、regB。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
reg [31:0] regA, regB;
//data forward for rs
always @(*) begin
//case 1:current instruction's first operand is the same as the target register
//of the EXE stage, and the EXE stage is not a load instruction, the pipeline
//doesn't need to stop.
if (need_rs & E_RegWrite & (E_TargetReg!=0) & (E_TargetReg == rs) & ~E_m2reg)
regA = E_AluOut;
//case 2: current instruction's first operand is the same as the target register
//of the MEM stage, and the MEM stage is not a load instruction, the pipeline
//doesn't need to stop.
else if (need_rs & M_RegWrite & (M_TargetReg !=0) & (M_TargetReg == rs) & ~M_m2reg)
regA = M_AluOut;
//case 3: current instruction's first operand is the same as the target register
//of the last instruction, and the last instruction is a load instruction.
//As mentioned above, the IF and ID stage have been stopped for one clock cycle,
//after the stop, the following if statement can become true, and the the memory
//read is done, the data can be cast on the current instruction in ID stage.
else if (need_rs & M_RegWrite & (M_TargetReg !=0) & (M_TargetReg == rs) & M_m2reg)
regA = M_MemOut;
//case 0: default situation, there's no data forward.
else regA = qa;
end

对于regB的数据旁路跟regA一样,这里不再赘述。

流水线控制

流水线的控制是CU要处理的核心问题。因为很多种特殊情况可能同时发生,因此这部分的控制需要深入的分析。
在这里我首先列举系统中可能出现的特殊情况:

  • IF级:分支预测失败、自修改代码同步、IF级异常(地址错误、以及引入分页后的TLB异常等)
  • ID级:数据相关冲突、多周期指令、自修改代码同步、IF级异常(未实现的指令、syscall)
  • EXE级: 自修改代码同步、EXE级异常(溢出)
  • MEM级: MEM级异常(地址错误、TLB异常等)
  • 外部中断

针对这些流水线的特殊情况,控制逻辑设计有一个原则 ,就是尽量想办法把复杂的问题简单化,并且能够尽可能的分析他们之间的关系和影响,然后确定处理器的行为。
根据这个原则,外部中断来自处理器外部,因此可以对外部中断的响应设置条件,让处理器在合适的时机响应中断,这样可以大大简化整个流水线控制逻辑。因此我的处理器响应中断的条件是处理器处在“正常”状态。所谓的正常状态,是指没有任何其他的异常、流水线不能有任何暂停、同时ID级当前处理的指令不能是mtc0指令。设置第一个条件是因为异常来自于CPU内部,之前的文章也有提到,如果不及时处理这些来自于内部的异常,处理器会丢失正确的顺序;设置第二个条件的原因是因为我所设计的中断控制器在收到中断响应信号后的下个周期就会撤销当前中断请求,如果发生中断响应时处理器整个或者部分处于暂停状态,在中断控制器收到中断响应信号并将中断请求撤销时,处理器因为停顿没有将中断处理程序的地址载入PC,这就导致错过了中断请求;设置第三个条件的原因是,mtc0可以修改status寄存器,而status寄存器中最低位作为中断屏蔽使用,为了确定处理器的行为,我设置这个条件,只有当mtc0执行结束、status寄存器的值稳定后,才可以响应中断。

1
2
3
4
5
6
7
8
9
10
//When to response to an interrupt request
wire int_mask = CP0_Reg[1][0];
wire int_allow = int_mask &~BP_miss & ~Stall_IF_ID & ~exc_ack & ~SMC_ack
&~i_mtc0 &~CPU_stall;
//When there is an external interrupt and the external interrupt is enabled,
//or a software interrupt
//or an exception occurs, the processor jumps to the base register
//int_ack is HIGH to indicate that CPU is going to jump to base register at
//next clock cycle.
assign int_ack = int_allow & int_in;

人为的对外部中断响应加以限制不会造成功能的缺失,因为我们永远无法预测外部中断到来的时机,早一会儿晚一会儿处理它不会对运行结果产生任何影响,但这样做可以简化控制逻辑的设计,在接下来的分析中可以完全撇开外部中断对CPU的影响。
对于其他的特殊情况的处理就没有那么幸运了,他们均产生于处理器内部,因此我们无法让处理器等待,一旦发生这些情况必须立即处理。问题是如果他们中的多个同时发生怎么办?他们可能同时发生吗?他们的确可能同时发生,例如数据相关冲突就可以和多周期指令同时发生,某个多周期指令的某一周期需要的源寄存器是上一指令的目标寄存器。再例如分支预测失败和数据相关也可以同时发生,ID级是一个jump register指令,而该指令的源寄存器是前面代码的目标寄存器。再例如由MEM级的写入请求产生的自修改代码可以和任何一种异常同时发生。
从对不同级的流水线异常来看,MEM级最简单而IF级和ID级最复杂。无论这些异常如何同时发生,有一点是需要明确的,就是一旦CPU决定处理这个异常,他所在的级以及这几级之前的流水线中的指令都要被废弃。比如说,如果MEM级发生了异常,CPU会将MEM级以及MEM级以前的所有级的指令都废弃掉,哪怕这些级中可能同时存在异常。换句话说,更深层级流水线产生的异常优先级更高,低层级的异常可以被高层级的异常覆盖。这一原则给控制逻辑的设计提供了思路,一种异常可以覆盖另一种异常,这种处理思路不但可以用在不同层级的异常中,也可以用在某一级可能同时发生的不同异常中。对于不同级可能同时发生的异常,为更深的流水线级异常赋予更高优先级是显而易见且正确的,因为这样做不会破坏处理异常的两个原则:

  1. 处理器不能丢失正确的处理顺序
  2. 处理器不能错过异常

优先处理高级别异常,会将当前级别的指令所在地址保存在EPC,异常处理完毕后会重新从这条指令继续执行,如果在高级别异常发生的同时还有其他的异常,并且当处理器从异常返回时异常仍然存在,那么在重新执行这串指令的时候处理器有机会处理这些异常。所以为更深的流水线异常赋予更高的优先级是一种正确且高效的做法。
整个流水线控制模块将各个控制信号整合于一个always块中,使用IF语句根据不同层级优先级处理异常。
正如上文所说,MEM级的异常最简单,只有一种类型,而MEM级又是可能发生异常的最深层级,因此其异常优先级最高。在处理异常和中断的always block中处理MEM级异常的IF语句最靠前。然后依次是处理EXE级、ID级和IF级异常的IF语句。最后,外部中断的优先级最低,因此处理他的IF语句在always块的最后。从IF语句的条件可以看到某一层级可能同时出现的异常的类型。具体针对异常的处理我将在后面的小节中详细描述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
always @(*) begin
...
//Default values of control signals
...
if (exc_MEM) begin
...
//processing MEM stage exceptions
...
end
else if (exc_EXE | EXE_SMC) begin
...
//processing EXE stage exceptions
...
end
else if (exc_ID | ID_SMC) begin
...
//processing ID stage exceptions
...
end
else if (exc_IF | IF_SMC | BP_miss)begin
...
//processing IF stage exceptions
...
end
//interrupt only answered when the system is ready
else if (int_ack) begin
...
//processing external interrupt
...
end
end
异常和中断

当异常和中断发生时,处理器进行的操作有:

  • 更新STATUS寄存器关中断
  • 更新CAUSE寄存器保存相应的异常号或中断号
  • 更新EPC寄存器保存返回地址
  • 选择正确的next PC地址
  • 生成作用于不同层级的撤销信号

STATUS、CAUSE、EPC寄存器属于CP0寄存器组,他们除了受异常中断的控制外mtc0指令也可以修改他们,因此他们的写入逻辑需要单独处理,异常和中断模块只是生成由于异常和中断引起的相应CP0寄存器的更新信号:

1
2
3
reg update_STATUS_exc;
reg update_CAUSE_exc;
reg update_EPC_exc;

并且为相应的CP0寄存器生成一个多路器用于选择异常中断引起的输入或是mtc0指令引起的输入:

1
2
3
reg [31:0] STATUS_exc_in,STATUS_bak_in;
reg [31:0] CAUSE_exc_in;
reg [31:0] EPC_exc_in;

由于ID级除了可能产生异常外,还能引起流水线的部分暂停,因此ID级的撤销信号需要特殊处理。这部分是CU较为复杂的逻辑之一。RAW冲突和多周期指令均可能和ID级的异常以及其他级的异常同时发生,RAW冲突和多周期指令也可能同时发生。正如上文所说,若不正确处理流水线暂停,会使处理器错过异常,这违反了异常处理的原则,因此必须正确分析它们之间的关系。我为由RAW引起的指令撤销和异常引起的指令撤销设置了独立的撤销信号:

1
2
//由异常引起的ID级撤销信号
reg ban_ID_EXC;

ID级向外输出的撤销信号是两个撤销信号的或:

1
2
3
//We have two ban signal at ID stage to avoid logic loop, ban_ID_RAW and ban_ID_EXC.
//ban_ID_RAW has lower priority, it can't mask out the Stall_RAW but ban_ID_EXC can.
assign ban_ID = ban_ID_RAW |ban_ID_EXC;

这两个撤销信号有优先级,ban_ID_EXC的优先级高,由异常产生的撤销信号可以撤销RAW和多周期引起的流水线暂停,而由RAW产生的撤销不能消除RAW和多周期引起的流水线暂停。RAW需要暂停并废弃ID级,多周期仅需要暂停ID级,二者同时发生时,需要暂停并废弃ID级。RAW和多周期引起的流水线暂停是必要的,RAW同时产生暂停和废弃信号,因此由RAW产生的废弃信号绝对不能撤销掉流水线暂停信号。而由异常产生的撤销信号则不同,异常直接废弃掉ID级的指令,也废弃了可能产生的RAW和流水线暂停。ban_ID_EXC作用于need_rs,need_rt和ins_done实现上述撤销功能。

1
2
3
4
5
wire ins_done = (canceled | ban_ID_EXC) ? 1'b1 : upc == cycle;
wire need_rs = ~(i_eret|i_mfc0|i_mtc0|i_syscall|i_lui|i_j|i_jal|i_sll|i_srl|i_sra)
& ~ban_ID_EXC;
wire need_rt = ~(i_eret|i_syscall|i_bgez|i_bgezal|i_bltz|i_bltzal|i_blez|i_bgtz|i_j
|i_jal|i_jr|i_jalr) & ~ban_ID_EXC;

next PC的值由一个多路器控制:

1
2
3
4
5
6
//select the right npc
//00 normal (let the branch predictor decide)
//01 base (normal exceptions)
//10 SMC_nPC (XPC for Self-modify code)
//11 br_target (active if there's a BP miss)
reg [1:0] nPC_sel;

在设置流水线控制信号的always块里首先要给这些控制信号设置默认值。默认情况下,流水线内的指令顺序的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Default values of control signals
//no need to update the STATUS register
update_STATUS_exc =0;
STATUS_exc_in =0;
STATUS_bak_in =0;
//no need to update the cause register
update_CAUSE_exc =0;
CAUSE_exc_in =0;
//no need to update the epc register
update_EPC_exc =0;
EPC_exc_in =0;
//select the next pc as normal (let the branch predictor decide)
nPC_sel =2'b00;
SMC_nPC = PC;
//set all cancel signal to zero
ban_IF =0;
ban_ID_EXC = 0;
ban_EXE = 0;
ban_MEM = 0;

当MEM级有异常发生时,就要上面所说的一些操作。将STATUS寄存器的值保存到STATUS_bak寄存器,并修改STATUS寄存器中最低位,屏蔽中断;将MEM级传来的异常号编码后送入CAUSE寄存器;将当前MEM级指令的地址保存在EPC作为返回地址;选择BASE寄存器作为next PC;将IF、ID、EXE、MEM级所有的指令均废弃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//processing MEM stage exceptions
//mask out the STATUS register to disable interrupt.
update_STATUS_exc =1;
STATUS_exc_in = CP0_Reg[1] | 32'b11111111111111111111111111111110;
STATUS_bak_in = CP0_Reg[1];
//select the MEM stage's cause
update_CAUSE_exc =1;
CAUSE_exc_in = {9'b0,MEM_cause,20'b0};
//save MEM_PC into epc
update_EPC_exc =1;
EPC_exc_in = MEM_PC;
//select the next pc as BASE
nPC_sel = 2'b01;
//generate cancel signals
//cancel IF ID EXE MEM here
ban_IF = 1;
ban_ID_EXC = 1;
ban_EXE =1;
ban_MEM=1;

如果MEM级没有异常,才轮到处理EXE级的异常。但是EXE级要比MEM级复杂一些。处理器的设计目标之一是实现自修改代码的同步,自修改代码是由于MEM级的指令对某一内存地址进行修改,而恰好该内存地址是流水线中已加载的某条指令所在的位置引起的特殊情况。考虑到流水线内已加载指令的先后顺序,这种特殊情况只可能在MEM级前的流水线级中发生,因此EXE级可能出现EXE级异常和自修改代码两种情况。EXE级异常信号exc_EXE由EXE级执行单元输入,自修改代码信号EXE_SMC由CU内部对MEM级写入指令的目标地址比对得出。
这二者有可能同时发生,当他们同时发生时如何确定处理器的行为?事实上,无论优先处理哪种情况,都不会产生错误,但不同的处理顺序影响系统的效率。处理SMC需要将当前级以及之前级的所有指令废弃,并将当前地址作为next PC,重新从内存中读取修改后的新值作为指令,同时不需要修改CP0寄存器组;而处理EXC除需要废弃指令外,还需要将next PC设置为BASE,并修改CP0寄存器组。先处理SMC会重新读取并执行该地址内的指令,若新指令不产生异常,则处理SMC后异常消失,无需再做处理;若新指令仍然产生异常则此时SMC已经处理完毕,处理器直接处理对应的异常就好了。优先处理异常也一样,当异常返回时会重新执行产生异常的指令,而由于cache的同步机制,返回时一定会从内存读取新值。因此当EXC和SMC同时发生的时候,无论先处理谁都不会产生错误。怎样处理系统的性能更好呢?经过考虑,我们发现优先处理SMC可以避免处理EXC,而处理一次EXC至少需要两次跳转;而优先处理EXC虽然可以避免处理SMC,而处理一次SMC只需要一次跳转。所以这里的决策是为SMC设置较高的优先级。所以在EXE级,条件为EXE_SMC的IF语句在前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if (EXE_SMC) begin
//no need to update the STATUS register
update_STATUS_exc =0;
//no need to update the cause register
update_CAUSE_exc =0;
//no need to update the epc register
update_EPC_exc =0;
//select the next pc as EXE_PC
nPC_sel = 2'b10;
SMC_nPC = EXE_PC;
//generate cancel signals
//cancel IF ID EXE here
ban_IF =1;
ban_ID_EXC =1;
ban_EXE =1;
ban_MEM = 0;
end
//only has exc in exe stage
else begin
//update the status register
update_STATUS_exc =1;
STATUS_exc_in = CP0_Reg[1] | 32'b11111111111111111111111111111110;
STATUS_bak_in = CP0_Reg[1];
//select the EXE stage's cause
update_CAUSE_exc =1;
CAUSE_exc_in = {6'b0,EXE_cause,23'b0};
//save EXE_PC into epc
update_EPC_exc =1;
EPC_exc_in = EXE_PC;
//select the next pc as BASE
nPC_sel = 2'b01;
//generate cancel signals
//cancel IF ID EXE here
ban_IF =1;
ban_ID_EXC =1;
ban_EXE =1;
ban_MEM = 0;
end

ID级的异常比较特殊,控制它的信号exc_ID并不是从其它级输入的,而是从指令译码的结果产生的。syscall指令和未实现的指令直接在ID级引发异常,并设定预设的异常号:

1
2
3
4
5
6
7
8
9
10
11
12
13
//system call and unimplemented instruction will cause exception in ID stage. 
wire exc_ID = i_syscall | i_unimp;
reg [2:0] ID_cause;
//Cause of exception in ID stage is generated in ID stage itself.
always @(*)
begin
if (i_syscall)
ID_cause = 3'b001;
else if (i_unimp)
ID_cause = 3'b010;
else
ID_cause = 3'b000;
end

由于ID级还可能产生多周期指令和RAW写后读冲突,因此需要额外分析这两者和SMC、EXC之间的关系。幸运的是,由于syscall不需要寄存器,也不是多周期指令,它不会引发多周期指令和写后读冲突,未实现的指令也是这样。因此当ID级异常发生时,不可能由多周期指令和写后读冲突。针对ID级异常的处理方式于EXE级很像,同样是SMC优先于EXC,只不过要将cause修改为ID_cause,将ID级指令的地址保存在EPC,并且只需要废弃ID和IF级的指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//processing ID stage exceptions   
if (ID_SMC) begin
//no need to update the STATUS register
update_STATUS_exc =0;
//no need to update the cause register
update_CAUSE_exc =0;
//no need to update the epc register
update_EPC_exc =0;
//select the next pc as ID_PC
nPC_sel = 2'b10;
SMC_nPC = ID_PC;
//generate cancel signals
//cancel IF ID here
ban_IF =1;
ban_ID_EXC =1;
ban_EXE = 0;
ban_MEM = 0;
end
//only has exc in ID stage
else begin
//update the status register
update_STATUS_exc =1;
STATUS_exc_in = CP0_Reg[1] | 32'b11111111111111111111111111111110;
STATUS_bak_in = CP0_Reg[1];
//select the ID stage's cause
update_CAUSE_exc =1;
CAUSE_exc_in = {3'b0,ID_cause,26'b0};
//save ID_PC into epc
update_EPC_exc = 1;
EPC_exc_in = ID_PC;
//select the next pc as BASE
nPC_sel = 2'b01;
//generate cancel signals
//cancel IF ID here
ban_IF =1;
ban_ID_EXC =1;
ban_EXE = 0;
ban_MEM = 0;
end

IF级就没有那么幸运了。ID级引发的多周期指令和写后读冲突会对IF级的异常处理产生影响,不仅如此IF级还可能产生分支预测失败异常BP_miss.
首先先分析BP_miss/SMC/EXC三者之间的优先级。如果不优先处理分支预测失败,而处理异常,由于当前PC已经被CU确定为是错误的分支,因此处理异常时向EPC中存放的是错误的地址。考虑到异常处理的原则,这样做会丢失正确的处理顺序,因此必须要优先处理BP_miss. SMC和EXC的优先级前面已经讨论过,优先处理SMC会带来更好的性能。因此IF级异常处理的优先级先确定了下来: BP_miss > SMC > EXC.
然后我们分析这三种异常和ID级可能发生的多周期指令、写后读冲突之间的关系:

  1. 对于BP_miss
    BP miss不会与多周期同时发生,因为当前的跳转指令均是单周期指令。但是他会和写后读同时发生,跳转指令中的源寄存器可能是前一条指令的运算结果,如果不正确处理,正确的跳转目标会因为写后读引起的ID IF级暂停被错过。因此这里在is branch信号中加入了&~ban_ID,只有当ID级的跳转指令是有效的,也就是说写后读被数据旁路和流水线暂停解决后,才可能触发BP miss,这样有效的目标就不会因为流水线部分暂停而错过。
  2. 对于SMC
    SMC会与多周期和写后读同时发生,SMC异常因为MEM级的写入请求产生,而多周期和写后读仅会暂停IF和ID级,因此当他们同时发生时,如果不进行特殊的处理IF级就会错过SMC,将受MEM级写入影响的旧指令送入下一级流水线。这里的处理是结合cache中引入的cancel机制。cache中引入cancel机制原本的目的是规避总线无法处理撤销请求的缺陷,但是考虑到SMC信号生成的时机恰好是新的请求写入MEM级的流水线寄存器的时刻,在这个时候CPU是没有Stall的,I cache的请求已经完成,根据cache的撤销机制,可以知道此时req_sent清零,在这个时刻,cache的请求尚未向总线发出,可以直接撤销。因此,撤销机制可以保证在IF和ID级寄存器写入受阻时,仍能撤销当前受到SMC影响的请求,而SMC请求将npc设置成当前pc,PC的值不需要改变,这和IF级暂停并不冲突。之前对于cache同步机制的要求是总线事务至少需要两个周期,这是因为需要一个周期比对总线上的请求是否在cache中,但是现在SMC由CU进行检测,当MEM级存在一个使当前IF级受影响的请求时,可以在下一个时钟上跳直接废弃改指令而与此同时cache内部也将监听到总线的请求在下一个时钟上跳即可进行valid bit清零操作。
  3. 对于EXC
    由于EXC只和当前IF级有关,因此,由于ID级产生的IF和ID级暂停不会修改IF级的内容,EXC也不会因为多周期和RAW被错过。

这些关系分析好后,处理器的行为就确定了。我把相应的处理加在了BP_miss信号的生成和Cache同步机制中,CU中IF级的代码不需要特殊处理,类似于ID和EXE级,区别是BP_miss的优先级高,并且CAUSE、EPC和撤销信号也有相应的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//processing IF stage exceptions
//If there's a branch prediction miss, handle it first
if (BP_miss) begin
//no need to update the STATUS register
update_STATUS_exc =0;
//no need to update the cause register
update_CAUSE_exc =0;
//no need to update the epc register
update_EPC_exc =0;
//select the next pc as correct branch target
nPC_sel = 2'b11;
//generate cancel signals
//only cancel IF stage
ban_IF =1;
ban_ID_EXC =0;
ban_EXE = 0;
ban_MEM = 0;
end
//otherwise, if there's a SMC in IF stage
else if (IF_SMC) begin
//no need to update the STATUS register
update_STATUS_exc =0;
//no need to update the cause register
update_CAUSE_exc =0;
//no need to update the epc register
update_EPC_exc =0;
//select the next pc as PC
nPC_sel = 2'b10;
SMC_nPC = PC;
//generate cancel signals
//only cancel IF stage
//ban_IF can work with the cancel mechanism within the cache
ban_IF =1;
ban_ID_EXC =0;
ban_EXE = 0;
ban_MEM = 0;
end
//only has exc in IF stage
else begin
//update the status register
update_STATUS_exc =1;
STATUS_exc_in = CP0_Reg[1] | 32'b11111111111111111111111111111110;
STATUS_bak_in = CP0_Reg[1];
//select the IF stage's cause
update_CAUSE_exc =1;
CAUSE_exc_in = {IF_cause,29'b0};
//save PC into epc
update_EPC_exc = 1;
EPC_exc_in = PC;
//select the next pc as BASE
nPC_sel = 2'b01;
//generate cancel signals
//only cancel IF here
ban_IF =1;
ban_ID_EXC = 0;
ban_EXE = 0;
ban_MEM = 0;
end

当上述这些情况都没有发生、且当前ID级指令不是mtc0指令时(即所谓的正常情况),处理器会相应外部中断。当中断响应发生时,处理器的行为于处理异常类似,只不过在EPC中保存被中断的指令(即当前ID级指令)的下一行的地址(即当前PC)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//processing external interrupt
//update the status register
update_STATUS_exc =1;
STATUS_exc_in = CP0_Reg[1] | 32'b11111111111111111111111111111110;
STATUS_bak_in = CP0_Reg[1];
//select interrupt number as cause
update_CAUSE_exc =1;
CAUSE_exc_in = {12'b0,int_num};
//when there's an external interrupt and the interrupt is allowed
//no need to discard instructions in ID stage to handle interrupts
//and when the system returns from interrupt, the difference from
//the exception is that the next instruction after the interrupted
//instruction is executed. So we save PC into epc and ban IF when
//the interrupt is processed.
//save PC into epc
update_EPC_exc = 1;
EPC_exc_in = PC;
//select the next pc as BASE
nPC_sel = 2'b01;
//generate cancel signals
//only cancel IF here
ban_IF =1;
ban_ID_EXC = 0;
ban_EXE = 0;
ban_MEM = 0;
end

流水线的控制逻辑是CU的核心,我花了很长时间才将其中的各种情况理清。

自修改代码和cache同步

各级自修改代码异常在CU内部根据MEM级写请求和地址信号进行判断。

1
2
3
4
5
//self modify code  has an impact on IF ID and EXE stage. 
wire IF_SMC, ID_SMC, EXE_SMC;
assign IF_SMC = (PC == M_MemAddr) & M_MemWrite;
assign ID_SMC = (ID_PC == M_MemAddr) & M_MemWrite;
assign EXE_SMC = (EXE_PC == M_MemAddr) & M_MemWrite;

设置一个多路器用于为不同的级的SMC异常产生正确的next_PC, SMC_nPC的值在流水线控制逻辑的always块中被修改。

1
reg [31:0] SMC_nPC;
跳转和分支预测

分支预测是处理器将要实现的目标之一。CU中仅仅实现与分支预测器的接口,并提供相应的流水线控制机制。CU中关于分支预测的部分独立于分支预测器的实现,因此在本系统内使用不同的分支预测器时不需要修改CU的内容。
关于分支预测的控制逻辑是这样实现的:当IF级总是按照分支预测的结果进行指令预取,当ID级指令是一个有效的跳转指令时,其跳转目标已经计算得到,此时比对跳转目标和当前预取的指令地址即可知道指令预取是否正确。
因此这部分逻辑分为三部分:

  1. 确定有效的跳转指令
1
2
3
4
5
6
7
//These signals are used for the branch predictor. 
//is_branch is HIGH if current instruction in ID stage is a branch instruction.
//This signal with ID_PC together, can let the branch predictor know that
//an address holds a branch instruction, then the BP can assign a slot for
//that address.
assign is_branch =(i_j|i_jal|i_jr|i_jalr|i_bgez|i_bgezal|i_bltz|i_bltzal
|i_blez|i_beq|i_bne|i_eret)&~ban_ID;
  1. 计算正确的跳转结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//Jump target for J/Jal instruction. Different with the original MIPS 32 instruction. 
//This CPU don't have delay slot, so upper bit of the target is the corresponding
//bits of the address of the branch instruction itself.
wire [31:0] jpc = {ID_PC[31:28],instruction[25:0],2'b00};

//Branch offset for conditional branch instructions.
wire [31:0] br_offset = {imm[29:0],2'b00};
//Target address for conditional branch instructions.
wire [31:0] bpc = br_offset + ID_PC;
//Target address for jump register instructions
wire [31:0] rpc = regA;
//Target address for eret instruction.
wire [31:0] epc = CP0_Reg[3];

//signals for conditional branch.
wire rs_equal_0,rs_rt_equal,rs_less_than_0;
assign rs_equal_0 = (regA == 32'b0);
assign rs_rt_equal = (regA == regB);
assign rs_less_than_0 = regA[31];

//select the correct branch target
reg [31:0] br_target;
always @(*) begin
//unconditional branch instructions
if (i_j|i_jal) br_target = jpc;
//unconditional branch register instructions
else if (i_jr|i_jalr) br_target = rpc;
//return from exception
else if (i_eret) br_target = epc;
//conditional branch instructions
else if (((i_bgez|i_bgezal)&(~rs_less_than_0))|((i_bltz|i_bltzal)&rs_less_than_0)
|(i_blez&(rs_less_than_0|rs_equal_0))|(i_beq&rs_rt_equal)|(i_bne&(~rs_rt_equal)))
br_target = bpc;
//normal target
else br_target = ID_PC + 4;
end
  1. 将计算得到的跳转结果与指令预取的地址(即当前PC)进行比较得出分支预测失败信号。
1
2
3
4
//If there's a branch, compare the branch target with the current PC, if the two
//are not equal, then there's a BP miss, pre-fetched instruction should be
//canceled.
assign BP_miss = is_branch? (PC != br_target) : 1'b0;

除了分支预测信号和有效的跳转指令信号,分支预测器还需要一些其他的信息才能正确维护跳转历史表,比如跳转计算的结果以及跳转的目标是否是可变的目标。因此专门为此生成两个信号:

1
2
3
4
5
6
7
8
9
10
11
//do_branch is HIGH if a branch takes place. 
//This signal together with is_branch and BP_miss, can let the branch predictor
//know when to update the history table.
assign do_branch = (i_j|i_jal|i_jr|i_jalr|((i_bgez|i_bgezal)&(~rs_less_than_0))
|((i_bltz|i_bltzal)&rs_less_than_0)|(i_blez&(rs_less_than_0|rs_equal_0))
|(i_beq&rs_rt_equal)|(i_bne&(~rs_rt_equal))|i_eret)&~ban_ID;

//Some of the branch instructions such as j and jal have fixed target, while
//some branch instructions such as jr and jalr have variable target.
//The branch predictor must understand these conditions when making predictions.
assign V_target = i_jr | i_jalr | i_eret;

CP0 寄存器组

CP0寄存器组是CU的重要部分,他们既和一些指令交互(mfc0,mtc0, eret)又直接参与处理器的控制。
首先考虑有哪些情况会向CP0写入数据。mtc0指令、异常中断、eret指令三种情况发生时会写入CP0寄存器。CP0寄存器组同其他寄存器组一样需要一个同步的清零信号。

1
reg [31:0] CP0_Reg[5];

CP0寄存器组的命名和编码如下:

  • CP0[0]是CAUSE寄存器,用作中断向量。其编码为
31 -29 28 -27 25 -23 22 -20 19 - 0
IF ID EXE MEM interrupt
  • CP0[1]是STATUS寄存器,它的最低位用作中断屏蔽位,其他位保留以后使用。
  • CP0[2]是BASE寄存器,他存储中断服务程序的入口。
  • CP0[3]是EPC寄存器,他存储中断或异常时的返回地址。
  • CP0[4]使STATUS的备份,当发生异常或中断时STATUS的值保存在这里,当从中断或异常返回时(eret),在用这个寄存器的值恢复STATUS寄存器。

清零信号由for循环实现。

1
2
3
4
5
6
7
8
always @(posedge clk)
begin
if (clr)
begin
integer i;
for (i = 0; i < 5; i = i + 1)
CP0_Reg[i] <= 0;
end

针对寄存器组中的每个寄存器,依照其写入条件和写入的值,产生写入逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
    else begin
//write the STATUS register CP0_Reg[1]
//when there's an exception or interrupt
//This has a higher priority than mtc0.
if (update_STATUS_exc) begin
CP0_Reg[1] <= STATUS_exc_in;
end
//when there's an eret instruction.
else if (i_eret & ~ban_ID) begin
//when there's an eret, status register recovers it's previous value
CP0_Reg[1] <= CP0_Reg[4];
end
//when there's a mtc0 instruction.
else if (i_mtc0 & ~ban_ID & rd == 1) begin
CP0_Reg[1] <= regB;
end

//write the CAUSE register CP0_Reg[0]
//when there's an exception or interrupt
if (update_CAUSE_exc) begin
CP0_Reg[0] <= CAUSE_exc_in;
end
//when there's an eret instruction.
else if (i_eret & ~ban_ID) begin
//when there's an eret, cause register is set to zeros.
CP0_Reg[0] <= 32'b0;
end
//when there's a mtc0 instruction.
else if (i_mtc0 & ~ban_ID & rd == 0) begin
CP0_Reg[0] <= regB;
end

//write the EPC register CP0_Reg[3]
//when there's an exception or interrupt
if (update_EPC_exc) begin
CP0_Reg[3] <= EPC_exc_in;
end
//when there's an eret instruction.
else if (i_eret & ~ban_ID) begin
//when there's an eret, EPC register is set to zeros.
CP0_Reg[3] <= 32'b0;
end
//when there's mtc0 instruction.
else if (i_mtc0 & ~ban_ID & rd == 3) begin
CP0_Reg[3] <= regB;
end

//write the BASE register CP0_Reg[2]
//BASE register is not changed by exceptions or interrupts
//mtc0 instruction can change the value of it.
if (i_mtc0 & ~ban_ID & rd == 2) begin
CP0_Reg[2] <= regB;
end

//write the STATUS_bak register CP0_Reg[4]
//when there's an exception or interrupt.
if (update_STATUS_exc) begin
CP0_Reg[4] <= STATUS_bak_in;
end
//when there's an eret instruction.
else if (i_eret & ~ban_ID) begin
//when there's an eret, the STATUS_bak register is set to zeros.
CP0_Reg[4] <= 32'b0;
end
//when there's mtc0 instruction.
else if (i_mtc0 & ~ban_ID & rd == 4) begin
CP0_Reg[4] <= regB;
end
end
end

可以看到mtc0的指令比异常的优先级低,当异常和mtc0同时发生时,mtc0向某些cp0寄存器中的写入会被忽略。因此mtc0指令在使用时必须要考虑其发生的时机,以免产生无法预测的结果。

mfc0指令是通过对cp0寄存器组的连续读出和ID级数据输出前的多路器实现的:

1
2
3
4
5
6
7
8
9
10
wire [31:0] CP0_reg_out = CP0_Reg[rd];

//implement of mfc0 and mtc0
//mtc0 is done in ID stage itself, The output of register file feed into
//CU's CP0_reg_in, rd field as index, i_mtc0 signal controls the write enable.
//mfc0 is done at WB stage. rd as index, the output of CP0 register group is
//selected by the I_MFC0; At EXE stage, the output of CP0 register add
//rs which is zero, then skip MEM stage, send to target register in WB stage.
assign db = i_mfc0 &~ban_ID ? CP0_reg_out : regB;
assign da = regA;

测试

CU需要与处理器内部全部功能部件配合才能发挥作用,因此目前来看测试工作在CPU内部功能部件尚未完全实现前无法进行。我会尽快实现其他级的功能部件,然后编写测试程序,来测试CU的功能是否达成设计目标。

小结

关于流水线的控制逻辑,我确实花了很长时间才想通。这也再次验证了延时槽并不是一个好设计,如果引入延时槽,流水线的控制逻辑会变得更加复杂,是引入超标量流水的巨大障碍。在设计过程中,我也考虑到了未来对超标量流水线的支持。但是路要一步一步走,我需要先把当前的目标实现,积累一套自测试流程和方法,甚至实现工具链的移植,能够方便的在修改设计后验证设计的正确性,在这种情况下才能去实现寄存器重命名等超标量流水线技术。

上篇文章详细描述了组相联cache的实现和测试,文中小结也提到了测试成功后笔者有点小膨胀,想实现更多的功能。本系统内使用的cache和总线已经完成测试一个多月,这一个多月里,我主要思考的是流水线的控制问题。而且我确实膨胀了,我准备修改我的设计目标,实现分支预测和硬件cache同步。这样的设计目标无疑使得流水线的控制变得复杂。因此我在这一个月的时间里,详细列举了在引入分支预测、cache同步、异常和中断以及多周期指令和写后读冲突后的流水线控制问题,一边思考这个问题,一边初步搞定了cache同步机制的设计。

这篇文章主要描述Cache同步机制的设计。

为什么要引入cache同步机制?

很多人认为在我这样一个单核心单线程的处理器内部是不需要引入cache同步机制的,实际上这种说法是错误的。我在上一篇文章中已经描述了在这样一个处理器引入同步机制的原因主要是Memory Mapped IO 和自修改代码(self-modified Code)。对于Memory Mapped IO 上一篇文章中所提出的组相联Cache的设计,已经使用了一段可自定义的地址空间来绕过Cache,将这段地址空间用于IO即可满足IO的cache同步问题。而对于自修改代码,之前的设计是准备引入一些控制cache同步和清零的指令,将cache同步的任务交给系统程序员。显然,这种处理方法只是权宜之计。普遍认为,将系统的微架构过多的暴露给程序员是一种很糟糕的选择,我自然倾向于像X86一样设计一套对同步的硬件。
本平台中的自修改代码会在两种情况发生:

  • DMA
  • MEM级的store指令

DMA设备绕过处理器直接通过系统总线修改内存,如果修改的地址正好在cache的缓存中,这就会引起cache的同步问题。发生在MEM级的store X 指令也是如此,程序员编写的程序有可能通过store指令修改代码段内的数据,这会引起cache的同步问题。

发生cache同步事件时各事件的顺序

要想弄清楚cache的同步过程,首先要明确各种事件谁先谁后的问题。这个问题十分关键,它决定处理器在cache同步发生时的行为。
首先明确两种顺序:一是程序员所写的代码的顺序,我将这种顺序定义为逻辑顺序;二是总线上请求发生的顺序,我将这种顺序定义为物理顺序。然后再针对发生自修改代码的两种情况分开讨论,何时遵从逻辑顺序何时遵从物理顺序。

先说DMA的情况。DMA何时会发生呢?在本系统内,系统总线的优先级由总线控制器上DMA0-7的顺序决定,I cache接在DMA0,有最高的优先级,D cache接在DMA1,优先级次之,然后才是各种DMA设备。因此DMA设备的优先级教两个cache要低,也就是说,DMA请求只有在两组cache均命中时才能抢占总线与内存通讯。因此DMA请求发生的时机实际上很难去推断,很难分析系统总线何时会空闲能够让DMA设备占用,这也就是说,我们无法确定DMA请求与当前cache中的请求之间的逻辑顺序。因此,系统在处理DMA请求引起的cache同步时,遵从的时物理顺序,只要监听到总线上有来自其他设备的写请求,就自动进行同步,在物理顺序上保证从cache中取出的数据时最新的。
再说说由Store X指令引起的自修改代码。在本系统内,有一条5级的流水线,MEM级在流水线更深的地方,这就意味着当MEM级执行着的store指令引起了I cache的同步,若I cache中正在取这个地址上的指令,这个指令逻辑上应该在store指令完成后再被预取。因此对于由store指令引起的自修改代码,本系统遵从逻辑顺序,即保证发生这种情况时,I cache中取出的指令是Store指令新存入的指令。

cache同步与其他流水线控制事件同时发生

这个问题是我这一个多月着重思考的问题,在这篇文章里我先说一下在IF指令预取这个阶段可能同时发生的流水线控制事件以及处理方法。
IF级可能发生分支预测失败、IF级异常(如TLB、地址错误)、自修改代码。他们如果同时发生,优先级如何确定,谁必须先处理?
经过缜密的思考,我这里提出在处理流水线控制事件时的两条原则:

  1. 绝对不能丢失正确的处理顺序
  2. 异常不能被错过

异常会保存当前引起异常的指令的地址,并跳转至base;分支预测失败需要跳转至正确的目标,不需要保存地址;自修改代码(SMC)则需要重新读取当前地址,也不需要保存地址。下面分析他们三个之间的优先级。对于第一条原则,我们要做到无论怎么去处理这些事件,都要保证正确的处理顺序不被丢失,例如如果在异常和分支预测失败同时发生时,选择处理异常,由于当前的异常并不是正确的将要处理的指令,因此处理异常会丢失当前ID级正在执行的跳转指令产生的正确的跳转目标,这应该是不允许的。对于第二条原则,由于处理器会暂停,尤其是写后读和多周期会只暂停IF和ID级,要保证在处理器暂停时,其他异常不能被错过,就要保证异常只和流水线寄存器有关,当流水线寄存器变化时,各种异常信号变化,当流水线寄存器不变时,各种异常信号保持。
根据这两条原则,我确定分支预测失败的优先级最高,当他发生时,npc设置为正确的跳转目标,不保存EPC,撤销当前IF。EXC的优先级紧随其后,npc设置为base,保存EPC并撤销当前IF。SMC的优先级最小,这是因为前两者发生时,IF级中的内容会被废弃,cache的同步在cache内部自动进行。

本平台使用的总线控制器是一个非常简单的装置,其内部的状态机也十分简单,并不能实现撤销已经向总线上的设备发送的请求的功能,因此要想实现这些优先级,我们还需要在cache同步机制中增加正确的撤销机制,保证已经送出的请求不被撤销,等待其执行完毕。

cache同步机制的设计与实现

同步机制

同步机制是由总线监听完成的。
首先,引入一组寄存器,持续不断地监听总线上的写请求。这里需要特殊说明的是,除了要监听写请求,还要监听自己的总线允许信号,这样就可以将自己的请求排除在外。如果不在监听时排除自己的请求,就会使系统陷入自己的写请求,需要同步,又有自己的写请求,又需要同步……这样的死循环。

1
2
3
4
5
6
7
//BUS_addr_reg registered the address bus
reg [31:0] BUS_addr_reg;
//BUS_RW_reg registered the write request of the BUS.
//BUS_grant_reg registered the grant signal for this cache. It is used to identify
//whether the currently monitored bus request is issued by itself. The bus request
//issued by itself does not need to be monitored.
reg BUS_RW_reg, BUS_grant_reg;

这些寄存器在clr为高电平时清零,无视系统的stall信号,在每个时钟上边沿寄存总线上的请求。这意味着cache对总线的监听持续不断,cache内部的更新持续不断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
always @ (posedge clk)
begin
if (clr)
begin
BUS_addr_reg <= 32'b0;
BUS_RW_reg <= 0;
BUS_grant_reg<= 0;
end
else
begin
BUS_addr_reg <= BUS_addr;
BUS_RW_reg <= BUS_RW;
BUS_grant_reg<= BUS_grant;
end
end

然后,从监听得的总线地址中切片出索引和标签,供比对使用。

1
2
3
4
5
6
//sync index from the BUS or BUS register
wire [INDEX-1:0] index_sync = BUS_addr [INDEX+1 : 2];
wire [INDEX-1:0] index_sync_reg = BUS_addr_reg [INDEX+1 : 2];
//sync tag from the BUS or BUS register
wire [tag_size-1:0] tag_sync = BUS_addr [31:INDEX+2];
wire [tag_size-1:0] tag_sync_reg = BUS_addr_reg[31:INDEX+2];

接下来需要修改TAG,为TAG增加一个读接口,和总线上监听得的请求对比,判断当前地址是否已经在cache中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Out put registers for bus sniffing.
reg [tag_size-1 : 0] TAG_A_sync_out, TAG_B_sync_out;

//Memory for TAG_A.
always @(posedge clk)
begin
if (WE_A)
TAG_A [index_reg] = tag_reg;
//This is a registered read out. since the index can change according to the
//state of CPU, use a continuous read (assign statement) could cause a
//combinational logic loop from CPU_stall to ready.
//if read and write at same time, get the new value.
TAG_A_out = TAG_A [index];

//read for cache sync is index by the bus directly
TAG_A_sync_out = TAG_A [index_sync];
end

这里值得说明的是,对于TAG_X_sync_out所使用的索引是直接从总线的地址线上接过来的而不是监听寄存器中的结果,这样做的原因是之前提到过的,altera FPGA板载的存储接口上由内置的寄存器。这样的设计使得在时钟上升沿到达后,TAG_X_sync_out中所保存的标签和监听寄存器中保存的标签对应着同一时刻。

在获得监听结果后,就要进行比对,判断总线上的写请求是否存在于cache内。

1
2
3
4
5
6
7
8
//cache_sync signal is HIGH when the sniffed request from the bus is cached. 
wire cache_sync_A, cache_sync_B;
//~BUS_grant_reg means that we don't sync the request that issued by itself
//BUS_RW_reg means that we only sync the write request
//(tag_sync_reg == TAG_A_sync_out) means that the sync mechanism is active when
//the sniffed request is cached.
assign cache_sync_A = ~BUS_grant_reg & BUS_RW_reg & (tag_sync_reg == TAG_A_sync_out);
assign cache_sync_B = ~BUS_grant_reg & BUS_RW_reg & (tag_sync_reg == TAG_B_sync_out);

可以看到,这里的同步信号cache_sync_X使用了前面所说,只在有其他设备的写入请求存在在cache中时产生高电平。

如果某一组同步信号为高电平,意味着cache将在下个时钟上边沿到来时清零对应的valid bit。
那么如果当前监听到的请求和当前cache的请求时同一地址,清零对应的valid bit要在下一个时钟上边沿到来时才发生,此时如果cache命中怎么办?这时我们依据上一节对自修改代码事件的顺序的讨论,确定这套机制的行为。如果监听到的请求是某一DMA设备发来的,则我们应当依照物理顺序,认为当前的cache的请求发生在DMA请求之后,即应当屏蔽掉当前的cache命中信号,保证cache读出的是新内容;如果监听到的请求时D cache发出的写请求,则我们应当依照逻辑顺序,认为当前的cache请求发生在监听到的写请求之后,即也应当屏蔽掉当前的cache命中信号,保证cache读出的是新内容。两种情况的处理方法一致,这是巧合吗?这与之前我们所定的原则有关,那两条原则确定了这样的行为。
因此我们要按照两种不同的情况产生对应的valid bit清零信号:

  1. 监听到的请求与当前cache的请求是不同地址
  2. 监听到的请求与当前cache的请求是相同地址且cache读命中

对于情况一,由于同步操作不影响cache当前的请求,因此直接依照同步信号生成对应的清零信号。
对于情况二,我们不仅要产生对应的清零信号,还要屏蔽当前的假的cache命中信号。因为产生的清零信号要在下一个时钟上边沿到来时才生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//VALID_X_clr is HIGH when we need to clear the valid bit. 
wire VALID_A_clr, VALID_B_clr;

//There are two situations here:
// 1. The monitored request is different from the request to the cache
// --Clear valid bit directly
// 2. The monitored request is the same as the request to the cache
// --Clear valid bit only when there's a read hit.
assign VALID_A_clr = (BUS_addr_reg == addr_reg) ?
(cache_sync_A & ~CPU_RW_reg & HIT_A) : cache_sync_A;
assign VALID_B_clr = (BUS_addr_reg == addr_reg) ?
(cache_sync_B & ~CPU_RW_reg & HIT_B) : cache_sync_B;
wire HIT_mask ;
assign HIT_mask = (BUS_addr_reg == addr_reg)? (VALID_A_clr|VALID_B_clr) :0;

同时将HIT_mask加入CACHE_HIT_R的条件

1
wire CACHE_HIT_R = NO_CACHE ? HIT_C : (HIT_A|HIT_B) & ~HIT_mask;

最后要修改valid bits,使其在相应的清零信号为高电平时清零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
always @(posedge clk)
begin
if (clr || cache_clr)
VALID_A = 32'b0;
else if (WE_A)
VALID_A[index_reg] =1;
else if (VALID_A_clr)
VALID_A[index_sync_reg] =0;
//This is a registered read out. since the index can change according to the
//state of CPU, use a continuous read (assign statement) could cause a
//combinational logic loop from CPU_stall to ready.
//If read and write at same time, get the new value.
VALID_A_out = VALID_A[index];
end

值得注意的是,清零信号使用的索引是经过寄存器的结果。

撤销机制

由于我所设计的总线和总线控制器的限制,无法撤销已经向总线上的设备送出的请求,而CU会向cache发送撤销请求,撤销请求在cache内部必须根据cache是否已经向总线发出请求来判断撤销信号的行为,因此引入了撤销机制。
首先,引入一个状态寄存器req_sent,用来记录当前指令周期内,向总线的请求是否已经发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//The bus controller we used here is a simple controller, it can't handle
//situations like cancelling the sent request. So when cancel signal is high
//we need to make sure that the sent request is still there.
//To achieve this we use a register to indicate whether the request has been
//sent during this cycle.
reg req_sent;
always @(posedge clk)
begin
if (clr)
req_sent <=0;
//req_sent resets when every new instruction is pre-fetched
else if (~CPU_stall)
req_sent <=0;
//req_sent goes high when the cache request the bus.
else if (BUS_grant)
req_sent <=1;
end

每当新指令到来时(~CPU_stall),该寄存器清零,使用BUS_grant作为向总线发送请求的标志。

然后修改BUS_req信号,使得在请求尚未在总线上发送时可以根据外部信号(cancel)撤销总线请求。

1
2
3
//BUS request can be canceled if the request hasn't been sent
assign BUS_req = CPU_req_reg &(~cancel | req_sent) & ((~CPU_RW_reg & ~CACHE_HIT_R )
|(CPU_RW_reg & ~ready_reg));

最后,修改cache的ready信号,当总线请求被成功撤销时(cancel & ~req_sent),cache直接准备好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
always @(*)
begin
//if there's no request to cache, cache ready is HIGH
if (~CPU_req_reg)
ready_o <= 1;
//if the request has been canceled, cache ready is HIGH
else if (cancel & ~req_sent)
ready_o <= 1;
//If the request is a write request. When and after the bus announced ready
//The cache is ready. Registered ready signal avoids missing ready signal when
//CPU is stalled.
//rw and req signal should be the registered one to avoid logic loop from
//CPU_stall to ready_o.
else if (CPU_RW_reg)
//use CPU_req_reg as a mask can filter the HIGH Z on bus ready.
ready_o <= (CPU_req_reg && ready_in) || ready_reg;
//IF the request is a read request. If there is a cache hit, or cache miss but
//BUS ready, the cache is ready. When cache isn't hit, cache hit signal will be
//HIGH at the next positive edge of clock when received ready signal from the bus.
else
ready_o <= (CPU_req_reg && ready_in) || CACHE_HIT_R;
end

cache同步机制的测试

由于同步和撤销机制是在原有设计的基础上添加的功能,因此在修改后,我首先运行了原有的测试程序,测试原有的功能有没有问题。经测试,原有的设计功能没有发生变化。然后我针对新功能进行了测试。测试的方法是修改测试程序cache_t.v将其中的请求地址序列进行相应修改。
将I cache的请求序列修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
begin
address[0]= 0 ;
//cache sync
address[1]= 0 ;

address[2]= 0 ;
//cache miss
address[3]= 4 ;
//cache hit
address[4]= 4 ;
address[5]= 16 ;
address[6]= 0 ;
//cache sync
address[7]= 4 ;
end

将D cache的请求序列修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
reg [31:0] exe_address [0:7];
initial
begin
exe_address[0]= 0 ;
//write at 0
exe_address[1]= 0;
exe_address[2]= 0 ;
exe_address[3]= 20;
exe_address[4]= 20;
//write at 4
exe_address[5]= 4;
exe_address[6]= 24;
exe_address[7]= 24;
end

reg [31:0] exe_req [0:7];
initial
begin
exe_req[0]=0;
exe_req[1]=1;
exe_req[2]=1;
exe_req[3]=1;
exe_req[4]=1;
exe_req[5]=1;
exe_req[6]=1;
exe_req[7]=1;
end

reg [31:0] data [0:7];
initial
begin
data[0]= 32'hab2112a ;
data[1]= 32'hab2112b ;
data[2]= 32'hab2112c ;
data[3]= 32'hab21123 ;
data[4]= 32'hab21124 ;
data[5]= 32'hab21128 ;
data[6]= 32'hab21129 ;
data[7]= 32'hab21121 ;
end

reg [7:0] rw;
initial
begin
rw[0]= 0 ;
rw[1]= 1 ;
rw[2]= 0 ;
rw[3]= 1 ;
rw[4]= 0 ;
rw[5]= 1 ;
rw[6]= 1 ;
rw[7]= 0 ;
end

可以看到分别测试了周期1 对地址0写入时引起的与当前I cache请求地址相同的同步事件,和周期5对地址4写入时引起的与当前I cache请求地址不同的同步事件。
测试结果如图:
test1
由图可见:
110ns,I cache读取完地址0,CPU准备完毕,进入下一请求。
130ns,由于I cache命中导致总线让给D cache写入地址0,I cache在下一时钟监听到该请求,撤销了命中信号,重新请求总线得到写入的新内容
test2
test3
由图可见:
430ns,由于I cache之前读过地址4,使得这次读取cache命中。
670ns,D cache向总线发起写请求,写地址4,这应当出发I cache的同步机制。
870ns,可见I cache再次读取地址4,没有命中,说明同步机制起作用了。

对于撤销机制的测试:
test4
在30-50ns引入cancel信号,可以看到由于当前对总线的请求尚未发送至各设备,因此该请求成功取消。
test5
而若是在50-60ns引入cancel信号,由于在50ns的时钟上边沿已经将总线请求发出,因此不能撤销当前的请求,必须等待当前请求执行完毕。

小结

当前的设计,只是一个初步的设计,因为后续的流水线控制还没有最终敲定。
在设计和验证的过程中出现了一个小问题,就是我使用的quartus lite版本,竟然在仿真时提示我脚本过长,超过了免费版本的限制,它因此故意将仿真速度进行了限制。

已知问题

由测试1可以看出,该同步机制,对于总线有要求,要求一个总线事务至少两个周期,第一个时钟上边沿各个设备接受请求,第二个上跳沿各个设备发送准备好信号。否则的话,不能完成所要求的同步与当前cache请求相同的地址的要求。

ID级的任务是对指令译码,根据不同的指令产生相应的控制信号,控制CPU内的其他部件按要求工作。
要设计ID级,首先要确定CPU支持的指令,然后根据指令,编制控制信号表,依照控制信号表进行硬件设计工作。ID级需要考虑跳转、中断、异常、分支预测的复杂任务,这些问题都需要在设计前提前确定。虽然第一阶段我的CPU不一定对复杂的功能进行支持,但是为了在以后进行扩展时不对系统进行大的改动,这些部分确实需要仔细考虑。其中一些问题是相互关联的,如果不提前考虑好,以后改动起来会比较麻烦。

分支预测还是延时槽?

这是首先要解决的问题。我最初的设想,是沿用早期RISC处理器的延时槽,认为其硬件实现简单,即便软件上需要一些优化措施,也可暂不实施,以空指令填充延时槽。但是现代处理器纷纷抛弃指令延时槽是有原因的(相关讨论),而我在设计过程中所遇到的问题合这些历史一样,这使我不得不重新权衡二者之间的利弊。

延时槽真的会简化硬件设计吗?

初步看,延时槽确实可以简化流水线的结构,尤其是取指阶段。但是引入延时槽会使得中断和异常的设计变得复杂。一旦引入延时槽,就需要分析发生中断或异常时,各级指令是否是延时槽中的指令。这是因为从中断或异常返回时要确定的返回地址与当前指令是否是延时槽中的指令有关。延时槽改变了流水线中原本的顺序结构,如果发生异常的指令是延时槽中的指令,按照处理异常的惯例,需要在异常处理完毕后返回发生异常的指令,这时候,若不做特殊处理,处理器就会丢弃异常指令前的一个跳转指令的跳转,使得处理器执行发生错误。这种由延时槽引入的复杂性,对于中断来说好处理一些,毕竟中断时来自外部设备,总可以让外部设备先等一等,等到CPU状态稳定一些在处理中断。但对于异常来说就没有那么幸运了,异常可以发生在CPU的任何一个阶段:取指阶段有缺页异常,译码阶段有为实现的指令异常,EXE级有溢出异常,WB级有缺页异常。单独分析这些阶段与延时槽的关系,显得得不偿失。

分支预测比延时槽复杂多少?

静态分支预测和简单的动态分支预测并不复杂。拿静态分支预测来说,我若总是预测跳转不发生,即总是预取跳转后的下一条指令,只需要在指令预取阶段增加一套流水线废弃机制,当ID级对跳转指令译码完毕时,即可判断当前预取的指令是否是正确的跳转目标,如果不是,废弃当前指令即可。这样做虽然性能差一点,但是对于我这样一个只有5级流水线的CPU来说,性能损失并不大,而且即使使用延时槽,我也没有打算完整的进行编译器的优化(lcc目前并不支持延时槽的优化,这个需要我手工完成)。但是使用静态分支预测,可以保证后续流水线中的有效指令均是正确的执行顺序,在分析异常时,不用再考虑跳转的影响,异常和中断处理就会大大简化。

我的设计决策

正如CPU设计的主流,我也倾向于使用分支预测替代延时槽。我设计这个CPU的目的一是用于教学实践,二是用于学术研究,显然研究各种分支预测方法的效率对比是很有价值的。一旦选择了分支预测这条设计路线,以后在这个平台上实现不同的分支预测方法会很方便。因此我选择分支预测,并不是因为性能,更多的是为了方便以后的设计。目前我决定采用静态分支预测,总是预测跳转不发生,即总是与去跳转后的下一条指令,然后在ID级进行跳转结果的比对,如果跳转后的预取不正确,就废弃预取的指令。以后的路线包括增加饱和计数器和二级分支预测器等动态指令预取。这样我在实现各种异常的时候就会轻松很多。

动态分支预测与自修改代码

如果实现了动态分支预测,还需要实现与自修改代码配合的机制。饱和计数器等动态分支预测器,往往采用指令的地址作为标签,因此可以在IF级就能知道某个指令是否是跳转指令和预测的跳转目标,但这与自修改代码有一定的冲突。试想某一条跳转指令已经存储在分支预测器中,这时如果发生自修改代码的情况,这一地址的指令被修改为其他的指令。而由于分支预测器是用地址作为标签的,与cache一样存在自修改代码的同步问题。这个问题在Intel的X86处理器手册上我没有找到相关的表述,倒是在analog devices的一个DSP的手册里看到了相关的描述(Tuning Dynamic Branch Prediction on ADSP-BF70x Processors)。

Finally, it is good programming practice to reset the BP if self-modifying code is used. This will clear invalid branch entries associated with previous execution from the affected memory space from the BP table and result in correct operation. For a detailed description of the procedure for clearing the BP table, see the ADSPBF70x Blackfin+ Processor Programming Reference.

我猜想X86处理器的分支预测器应该和其cache一样,有相关的硬件处理这个问题。

与cache的同步机制不同,分支预测器的同步机制逻辑较为简单,可以不涉及系统总线的监听。因为分支预测器内只记录跳转指令的地址,对分支预测器的同步只需要保证预取的地址正确即可,地址内的指令有cache的同步机制保证。因此,在每次执行到分支预测器中记录的地址时,ID级要检查该地址包含的指令是否是跳转指令,如果是跳转指令则分支预测器内的记录不动,依照跳转指令执行的结果相应的更新分支预测器内该记录的状态即可。如果该指令在分支预测器内但不是跳转指令,说明发生了自修改代码,此时应使分支预测器内的条目失效,并根据分支预测的结果判断是否将预取的指令废弃,若分支预测的结果是跳转,则需要将IF级预取的指令废弃并修改NextPC为PC+4,否则预取的仍是正确的指令,仅将分支预测器内的条目作废即可。

我在上一篇文章中,描述了我在设计cache过程中遇到的困难以及产生的想法,提到了fpga片上的RAM隐含的寄存器引起的设计困难,cache同步的问题以及本平台目前对cache同步机制的目标。经过半个月的设计与测试,本系统中将要使用的两路组相联cache(2-way set associative cache)设计完毕并通过测试。这篇文章主要介绍我的设计实现和测试过程,可以作为我所写的代码的文档使用。

cache 设计目标

  • 只需一个时钟周期即可计算出是否cache命中,命中后立刻通知CPU
  • 2路组相联设计
  • cache写策略为写透(每当有写请求,同时写入cache和内存)
  • cache替换策略为随机替换
  • 允许通过改写参数自定义无需缓存的地址空间供IO和DMA设备使用
  • 允许通过改写参数改变cache的规模
  • 指令和数据cache都是本模块的实例
  • 多个cache实例可以同时访问系统总线

组相联cache的设计与实现

组相联cache并不是高大上的技术,在许多参考书中都有介绍,并且在现代处理器中大量使用。但是互联网上有关组相联cache设计与实现的文章并不多,更多的是对其理论概念的描述。看起来组相联cache是很简单的,但这样一个简单的模块,实现起来也需要下点功夫。
关于组相联cache的理论,在这篇文章里我就不赘述了,直接切入正题。
在本平台中使用的cache原理图如下,图中的信号名称和代码中的信号名称一一对应。
2-way set associative cache

整个cache主要分为四部分,分别是与CPU和总线的接口、内部的存储器组以及控制单元。在这个平台上,指令cache要比数据cache简单,因为指令cache是只读的,因此如果为指令cache和数据cache涉及两个不同的模块会减少对FPGA资源的占用。但是我偷了个小懒,让两个cache共用一个模块,这样大大减少了我的工作量。

与CPU的接口

如图中所示,cache与cpu的接口包括:

  • CPU_stall 信号,该信号是由CPU ID级的CU根据两个cache的状态产生的反馈。
  • CPU_Addr 总线对于指令cache来说,应该直接与NextPC多路器的输出相连,对于数据cache则与EXE级的地址计算结果直接相连。直接与前一级输出而不是与流水线寄存器相连,是为了在使用板载RAM的基础上仍能满足一个周期得到cache命中结果的关键。板载RAM上有内置的寄存器,如果地址输入不与前级输出直接相连,需要至少两个周期才可以知道cache命中的结果,数据要先经过流水线寄存器,再经过板载RAM内部地址寄存器,才能输入结果。
  • CPU_data 总线对于指令Cache来说应当全部是零,因为指令cache是只读的;对于数据cache应直接连接前一级的输出。
  • CPU_req 信号,高电平表示需要从cache获取数据或指令。对于I cache而言,该信号应始终是高电平,因为CPU总是需要获取新的地址;而对于D cache,该信号应与前一级输出相连,由CPU的ID级给出的信号控制,因为不是每条指令都需要访问D cache。
  • CPU_RW 信号标志当前请求是读请求还是写请求。该信号对于I cache而言始终是低电平,因为其是只读的。对于D cache 应直接与前一级输出相连,由ID级控制。
  • CPU_clr 信号是控制cache内部的valid bits的清零信号,如果该信号为高电平,则下一时钟上跳时清除整个cache的valid bits。我会在ID级实现两个分别清零I cache 和D cache的指令,用这个指令提供软件控制的缓存同步机制。当存在自修改代码时,系统程序员应使用相应的cache清零指令。
  • data_o 是向CPU输出的准备好的数据。
  • ready_o 信号表示cache是否准备好。
    在这里值得说明的是,由于FPGA板载ram上有内置的register,地址输入必须直接与上一级相连,这在某种程度上会引起流水线寄存器设计的割裂。因此,我将所有上一级来的输入信号全部设计成与cache模块直接相连,在模块内部并行的增设寄存器,将与cache相关的流水线寄存器放置在cache内部,并设置了这些寄存器的输出接口如addr_reg_o,供下一级流水线使用。

与总线的接口

在总线控制器的设计时我使用了三态门,但是实际综合出的逻辑不能识别高阻态,因此在设计时要考虑对高阻态信号的屏蔽。总线接口包含:

  • BUS_addr 地址总线
  • BUS_data 数据总线
  • BUS_RW 读写标志
  • BUS_req 总线请求标志,该信号应连接总线控制器上的DMA[0:7],根据DMA编号不同实现不同的优先级。
  • BUS_grant 总线请求许可,该信号从总线控制器发来,当总线请求获得控制器的许可后该信号为高电平,该信号主要参与总线接口三态门的控制。
  • BUS_ready 信号,高电平标志总线请求结束。

系统总线接口这部分的代码主要是对总线上三态门的控制,逻辑设计要保证总线上其他设备的信号不能干扰本设备。
首先要保证当且仅当总线请求被控制器允许后才能向总线发送数据:

1
2
3
//BUS Interface
assign BUS_addr = BUS_grant ? addr_reg : 32'bz;
assign BUS_RW = BUS_grant ? CPU_RW_reg : 1'bz;

同时也要确保只收到发送给自己的ready信号,在自己没有总线请求时过滤掉总线的ready信号,保证ready信号线上的高阻态不会影响自己。

1
wire ready_in = BUS_grant ? BUS_ready : 1'b0;

总线的数据接口是双向接口,向总线发送写请求时,直接将数据送至数据总线;发起读请求时,应设置为高阻态,直接从数据总线读取数据。

1
2
wire [31:0] data_to_bus = CPU_RW_reg ? data_reg : 32'bz;
assign BUS_data = BUS_grant ? data_to_bus : 32'bz;

在读cache不命中、写入或读写不需要cache的地址时,才应像总线发起请求,其他情况让出总线的控制权。

1
2
assign BUS_req = CPU_req_reg && ((~CPU_RW_reg && ~CACHE_HIT_R )
||(CPU_RW_reg && ~ready_reg));

值得说明的一点是,由于模块的输入都是上一级的直接输入,寄存器在模块的内部,而且cache的ready信号与cpu的stall信号直接有关,因此控制信号的逻辑需要小心设计,避免出现组合逻辑环。也就是说cache的ready信号应该只和模块内的寄存器的值有关系,不应当与stall信号直接相关。可以看到总线接口的设计遵循了这样的原则。当总线ready时,cache应同时给cpu ready信号,因此cache的ready信号与总线的状态有关,总线的状态也应该仅与寄存器中的值相关。在总线接口部分,所有的控制信号均使用寄存器中的值而不是上一级的直接输入。

cache内部的存储器

如图所示,cache内部共有三类存储器,分别存储TAG、有效位(valid bits)和实际请求的数据(RAM)。
这个cache模块可以通过改写参数的方式改变cache的规模,cache的规模由索引的宽度INDEX表示,INDEX默认值是7,也就是说内存地址出去字内地址低两位,[INDEX+1:2]这个范围作为七位的索引,因此可索引cache_lines = 2<< (INDEX -1) 行,在TAG中存储的TAG尺寸为tag_size = 32-2- INDEX。

1
2
3
4
5
6
//Number of Lines in each group.
localparam cache_lines = 2<< (INDEX -1);
localparam tag_size = 32-2- INDEX;
//default size of the cache is two 512 bytes (128 lines) set, total size is 1KB.
parameter INDEX=7;
parameter WIDTH=32;

所谓2路组相联,意思是每个index可以索引两组内容,分别存放在A、B两组TAG、valid bits和RAM中,同时对比两组的TAG和Valid bits,得到cache命中结果。对比直接镜像的好处是自由度更大一些,当读取到两个index相同的内存地址时,这两个数据都可以存放在cache中,而在直接镜像的情况下,此时就会发生cache替换。
首先定义index和tag:

1
2
3
4
5
6
7
//vector index is for indexing the whole cache.
wire [31:0] addr = CPU_stall ? addr_reg : CPU_addr;
wire [INDEX-1:0] index = addr[INDEX+1 :2];
wire [INDEX-1:0] index_reg = addr_reg[INDEX+1 :2];
//vector tag is used to determined if there's a cache hit, if not, new tag is written
//in the tag storage.
wire [tag_size-1:0] tag_reg = addr_reg [31:INDEX+2];

然后定义TAG、Valid bits和RAM三个存储器。以其中A组为例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//Memory for TAG_A.
reg [tag_size-1 : 0] TAG_A [0 : cache_lines-1];
reg [tag_size-1 : 0] TAG_A_out;
always @(posedge clk)
begin
if (WE_A)
TAG_A [index_reg] = tag_reg;
TAG_A_out = TAG_A [index];
end

//valid bits for group A.
reg VALID_A_out ;
always @(posedge clk)
begin
if (clr || cache_clr)
VALID_A = 32'b0;
else if (WE_A)
VALID_A[index_reg] =1;
VALID_A_out = VALID_A[index];
end

//memory for group A.
reg [31:0] RAM_A [0 : cache_lines-1];
reg [31:0] RAM_A_out;
always @(posedge clk)
begin
if (WE_A)
RAM_A[index_reg] = RAM_in;

RAM_A_out = RAM_A[index];
end

这里有一些细节值得说明。index信号是对addr的截取,addr根据当前cpu的状态选择直接从上一级流水输入的地址或是内部寄存的地址。index_reg是从内部的地址寄存器截取的。同时使用两个信号是实现单个周期计算是否cache命中的关键。在上述的三个存储器中,可以看到所有的读接口的索引都使用index,这就保证了在第一个时钟周期上跳,存储器就已经将新读取结果。而对这些存储的修改仅当cache不命中或者写透时才发生,因此写入最早应该发生在下一周期上边沿(因为总线上的设备的响应时间不同,如果总线上的设备能够在当前周期内相应,就是所谓的最早的情况),而这个时候上一级流水输出的信号已经发生改变了,我们并不能阻止这种改变发生,因此所有的写入信号都应该使用内部寄存器的输出(这个内部寄存器就是流水线寄存器)。这里我们使用stall信号来区分cpu的状态,如果此时stall信号为低电平,这说明cpu在处理新指令,index的值是直接从上一级截取的,使用index提供的索引读取cache内部存储判断是否命中,若不命中,则等待总线响应,同时通过ready信号使cpu暂停,此时stall信号为高电平,此后的周期直到缓存准备好,读请求的索引index都被stall信号选择成内部的寄存器输出,可确保在上一请求没完成时继续读取上一索引。
这里使用了阻塞赋值语句,对于同时写入和读取同一个地址的情况,读取的结果是写入的新值,综合的结果会加入一些数据旁路来符合我们定义的行为。总是读取新值在我的设计中是必要的,因为当总线准备好时,当前cache也准备好,而此时另一个cache可能尚未准备好正在请求总线,因此cpu要继续等待。此时下一个时钟上边沿,当前cache将新的TAG、Valid bit和RAM写入cache,但是总线上的ready信号随即消失,我们需要在这个时钟上边沿直接取得cache命中信号,让cpu等待时不错过这次请求的cache信号。总是读取新值可以保证在总线准备好后的下一个时钟上边沿就能输出cache命中信号。
对于valid bits,由于存在着清零信号,由于altera的板载存储不支持清零信号,所以它会被综合成寄存器。而其他两块存储会被综合成存储器,使用片上的RAM资源。我所设想的设计目标是不用商业IP,与FPGA无关,使用标准的硬件描述语言设计,在没有片上RAM资源的FPGA上这段代码仍然能够综合,不过要消耗大量的寄存器资源。
valid bits的读出应该是寄存器而不是assign连续赋值,因为valid bit的输出直接关系到ready信号,而index与stall信号有关,如果使用连续赋值,会产生组合逻辑循环。

针对不需cache的地址范围的特殊设计

我所设计的CPU采用memory mapped IO设计,IO使用访存指令来控制。在不提供额外的硬件缓存同步机制的情况下,IO所使用的地址范围会产生cache同步问题。我采取MIPS的做法,在不需要cache的内存空间中绕过cache。
具体做法并不复杂。
首先使用一个寄存器NO_CACHE判断当前请求的地址是否在不需要cache的范围,该寄存器仅在获取新请求时更新。

1
2
3
4
5
6
7
8
reg NO_CACHE;
always@(posedge clk)
begin
if (clr)
NO_CACHE <= 0;
else if (~ CPU_stall)
NO_CACHE <= (addr <= no_cache_end) && (addr >=no_cache_start);
end

然后引入组C作为作为这no cache区域的缓存。设计这组缓存是必要的,原因上面已经介绍了。因为每次对这一地址范围的请求都是cache不命中,都需要从总线上获取数据。总线上的数据仅仅存在一个周期即总线ready的那个周期。如果CPU在这个周期由于其他的原因stall,CPU就会错过总线上的数据,因此我们在这引入额外的一组C,C组没有TAG,valid bits只有一位,RAM也只有一行,在总线ready后,同其他组一样,将总线来的数据写入到RAM同时更新valid bits,为CPU提供cache命中信号。
除此之外,绕过cache的机制也很简单,直接在每次新请求发生时,将C组的valid bit清零即可实现每次对no cache区域的访问都不命中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
reg VALID_C;
always @(posedge clk)
begin
if ((~CPU_stall) || clr )
VALID_C <= 0;
else if (WE_C)
VALID_C <= 1;
end

reg [31:0] RAM_C;
always @(posedge clk)
begin
if (WE_C)
RAM_C <= RAM_in;
end

引入C组的好处是在提供目标功能的同时,简化了控制单元的设计,控制单元只需要根据NO_CACHE寄存器的结果选择相应的信号即可。

各组的更新信号

WE_A, WE_B, WE_C为三个组的更新信号。该信号由cache控制器计算,每次更新只有其中一个有效。

cache控制器的设计与实现

cache控制器主要解决三方面的问题:

  1. 命中信号的逻辑
  2. 不命中时的cache替换
  3. 写入cache的内容和向CPU输出的内容的选择

cache命中信号ready_o

上文中我已提到,BUS上的数据只存在一个周期,但是CPU可能在这个周期因为其他原因暂停。因此我们必须在总线就绪时将数据保存在cache内部。这样的操作自然应该包含BUS的ready信号。
引入一个寄存器ready_reg保存从总线上接收的就绪信号, 该寄存器在新请求到来时清零。

1
2
3
4
5
6
7
8
reg ready_reg;
always @(posedge clk)
begin
if (~CPU_stall)
ready_reg <= 0;
else if (CPU_req_reg && ready_in)
ready_reg <= 1;
end

针对三个组,每个组都要有单独的命中信号。

1
2
3
wire HIT_A = tag_reg == TAG_A_out && VALID_A_out;
wire HIT_B = tag_reg == TAG_B_out && VALID_B_out;
wire HIT_C = VALID_C;

可以看到在AB两组的命中信号比对过程中,使用的全部是经过寄存器同步的值,这避免了从cpu_stall到ready_o之间的组合逻辑环。
有了各组的命中信号后,就可以为读请求设置命中信号。若请求在no cache范围,则以HIT_C为准,否则与AB的或为准。

1
wire CACHE_HIT_R = NO_CACHE ? HIT_C : (HIT_A || HIT_B);

最终的命中信号ready_o还与一些其他的信号相关:

  • 如果此时并没有请求cache(cpu_req_reg == 0),则应直接给出命中信号,通知CPU继续工作。
  • 如果是写请求,则命中信号应是总线就绪信号与ready_reg的逻辑或。
  • 如果是读请求,则命中信号应是总线就绪信号与读命中信号(CACHE_HIT_R)的逻辑或。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    always @(*)
    begin
    if (~CPU_req_reg)
    ready_o <= 1;
    else if (CPU_RW_reg)
    ready_o <= (CPU_req_reg && ready_in) || ready_reg;
    else
    ready_o <= (CPU_req_reg && ready_in) || CACHE_HIT_R;
    end
    总线就绪信号使用请求信号进行过滤,防止高阻态的ready干扰。

cache替换控制

当读不命中或有写请求时,要进行cache的更新。Cache的更新机制主要解决两个问题:

  1. 何时更新?
  2. 更新那一组?

第一个问题非常简单,当且仅当总线上数据准备好的时候更新cache,因为所有的不命中都需要请求总线。

1
wire need_update = BUS_grant && ready_in;

第二个问题稍微复杂一些。我们使用随机替换策略,首先要设计一个简单的随机数产生器。这里我使用一个计数器作为随机数产生器。

1
2
3
4
5
reg random;
always @(posedge clk)
begin
random <= random+ 1'b1;
end

对于不需要cache的地址范围,处理比较简单。因为组C只有一行,因此直接替换即可。

1
assign WE_C = NO_CACHE && need_update;

对于正常的情况,处理稍微麻烦一些,并不是很多读者想象的直接利用上面描述的随机数产生器。
考虑这样的几种情况:

  • 如果一行内的两组槽位,有一个是空的应该,可以直接往空的组填写数据吗?
    很遗憾答案是否定的。
  • 如果两组都是满的,可以直接使用随机替换吗?
    很遗憾答案还是否定的。

为什么不能直接使用随即替换呢?这里有一种特殊情况:
如果两组都是满的,A内存放地址0的缓存,B内存放地址8的缓存。此时我需要向地址0再次写入新值。这种情况下能使用随机替换吗?答案当然是否定的。我们应该优先替换TAG相同的组。这种情况下应当将对地址0写入的新值直接放入原来就存放地址0的组A,而不是随机替换组B。这样做除了有性能优势以外,还保证了cache不发生意外地错误。如果直接使用随即替换替换掉了B组,则cache内会有两条关于地址0的记录,在没有额外的措施的情况下,这就直接引起了错误!CPU不知道那个数据是正确的。
因此我们确定了使用随机替换时的策略:

  • 若cache内已经存放有该地址的记录,直接替换这一组,不使用随机替换。
  • 否则再看是否有空闲的组,如有则放入空闲组,否则使用随即替换。
    信号WE_A,WE_B实现了上述逻辑:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    reg [1:0] group_sel;
    always @(*)
    begin
    //replace group with the same tag first.
    if (TAG_A_out == tag_reg)
    group_sel <= 2'b01;
    else if (TAG_B_out ==tag_reg)
    group_sel <= 2'b10;
    //if both group is empty or valid, randomly choose one group.
    else if (VALID_A_out == VALID_B_out)
    group_sel <= random ? 2'b01 : 2'b10;
    //if only one group is empty, choose the empty group.
    else
    group_sel <= VALID_A_out ? 2'b10 : 2'b01;
    end
    //only when requested address is in cache range, WE_A or WE_B can be active.
    assign WE_A = (~NO_CACHE) && need_update && group_sel[0];
    assign WE_B = (~NO_CACHE) && need_update && group_sel[1];
    需要留意的是,这里使用的控制信号也都是经过寄存器同步过的,这样可以避免组合逻辑环。

向cache内写入的数据和对CPU输出的数据选择

我们需要根据读、写请求的不同以及总线的状态,选择向cache内写入的数据RAM_in和向CPU输出的信号data_o.
对于RAM_in。读请求时,应选择总线上的数据BUS_data;写请求时应选择CPU从上一级流水线输入的数据data_reg.

1
assign RAM_in = CPU_RW_reg ? data_reg : BUS_data;

对于data_o。若是写请求,则不需要向cpu输出数据;若是读请求,当总线准备就绪时,总是选择总线上的数据,若错过了总线的就绪信号,则按照各组的命中情况,选择各组的RAM作为输出。其实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
always@(*)
begin
if (CPU_RW_reg)
data_o <= 32'b0;
else if (ready_in)
data_o <= BUS_data;
else if (NO_CACHE)
data_o <= RAM_C_out;
else if (HIT_A)
data_o <= RAM_A_out;
else if (HIT_B)
data_o <= RAM_B_out;
else
data_o <= 32'b0;
end

至此这个符合上文所述的设计目标的cache模块设计完毕了。接下来是繁琐的测试工作。

组相联cache的仿真、测试

从这篇文章的篇幅来看,这绝不是一个简单的系统,因此其测试也需要仔细考量。本文开头所描述的设计目标应当全部包含在测试中。经过我的思考,确定了测试的流程:对于功能测试,均采用单个cache实例;在通过了功能测试后,使用多个cache实例测试其联合访问总线。这样做是合理的,确保单个cache功能正常,多个cache联合访存正常即可说明达到了设计目标。

针对单个cache的功能测试

首先准备测试代码。
先定义总线信号:

1
2
3
wire [31:0] BUS_addr, BUS_data;
wire BUS_req, BUS_ready, BUS_RW;
wire [7:0] DMA, grant;

然后实例化单个cache和模拟的内存控制器,实例化总线控制器,并将他们连接到总线上。

1
2
3
4
5
6
7
8
9
10
//hook up the bus controller
bus_control bus_control_0 (DMA,grant,BUS_req, BUS_ready,clk);

//hook up the simulated memory
dummy_slave memory(clk,BUS_addr,BUS_data,BUS_req,BUS_ready,BUS_RW);

//hook up the simulated instruction cache
cache I_cache (CPU_stall,next_pc , data[i] , 1'b1, rw[i], 1'b0, CPU_data, CPU_ready, PC,
BUS_addr, BUS_data, DMA[0], BUS_RW, grant[0], BUS_ready, clr, clk,
we_a,we_b,we_c,needupdate,tag,hitA,hitB,RAM_A_out);

可以注意到这个cache实例是连接在DMA[0]上的,因此他具有最高的总线优先级。
设置模拟的CPU_stall信号:

1
wire CPU_stall = clr ? 0: ~(CPU_ready && 1);

然后就可以使用不同的预设请求序列来测试cache的功能。

初步测试

测试了cache的基本功能:

  • 读不命中、读命中
  • 写不命中,连续写不命中,写后读命中
  • 读写no cache范围
    使用如下的测试序列
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    reg [31:0] address [0:7] ;
    initial
    begin
    address[0]= 0 ;
    address[1]= 4 ;
    address[2]= 0 ; //cache hit
    address[3]= 16 ;//write
    address[4]= 16 ;//read
    address[5]= 8 ; //read in no cache zone
    address[6]= 8 ; //write in no cache zone
    address[7]= 8 ; //read in no cache zone
    end

    reg [31:0] data [0:7];
    initial
    begin
    data[0]= 32'hab2112a ;
    data[1]= 32'hab2112b ;
    data[2]= 32'hab2112c ;
    data[3]= 32'hab21123 ;
    data[4]= 32'hab21124 ;
    data[5]= 32'hab21128 ;
    data[6]= 32'hab21129 ;
    data[7]= 32'hab21121 ;
    end

    reg [7:0] rw;
    initial
    begin
    rw[0]= 0 ;
    rw[1]= 0 ;
    rw[2]= 0 ;
    rw[3]= 1 ;
    rw[4]= 0 ;
    rw[5]= 0 ;
    rw[6]= 1 ;
    rw[7]= 0 ;
    end

测试结果如图:
单个cache测试1
从图中可见:
读取0,110ns数据准备完毕,130ns进入下个请求,此次请求未命中。
读取4,210ns数据准备完毕,230ns进入下个请求,此次请求未命中。
读取0,此次请求命中,数据在一周期内准备完毕,总线空闲,250ns进入下个请求。
写入16,写请求总是未命中,330ns写入完毕,350ns进入下一个请求。
读取16,写后读命中,数据在一周期内准备完毕,总线空闲,370ns进入下个请求。

单个cache测试2
从图中可见:
读取8,请求no cache范围,总是不命中,450ns数据准备好,470ns进入下个请求。
写入8,请求no cache范围,总是不命中,550ns数据准备好,570ns进入下个请求。
读取8,请求no cache范围,总是不命中,650ns数据准备好,670ns进入下个请求。
这一段测试了有限的缓存同步机制。
后续三个请求读取0,4,0均命中。
基础功能测试完毕。

替换策略测试

替换策略的测试稍微繁琐一些。经过思考我决定先将cache的规模缩小,缩小到最小。当cache规模缩到很小的时候,就很容易制造cache替换的模拟,然后进行测试。
将参数INDEX改为1,此时cache的规模为两组,每组两行。然后执行序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
reg [31:0] address [0:7] ;
initial
begin
address[0]= 0 ;
address[1]= 4 ;
address[2]= 28 ;
address[3]= 8 ;
address[4]= 16 ;
address[5]= 4 ;
address[6]= 4 ;
address[7]= 4 ;
end
reg [7:0] rw;
initial
begin
rw[0]= 0 ;
rw[1]= 0 ;
rw[2]= 0 ;
rw[3]= 0 ;
rw[4]= 0 ;
rw[5]= 1 ;
rw[6]= 1 ;
rw[7]= 0 ;
end

仿真结果如图:
替换策略测试1
替换策略测试2
替换策略测试3

分析上述序列的过程:

时刻 120ns 220ns 320ns 420ns 520ns 620ns 720ns
请求 0 4 28 8 16 4 4
TAG 0 0 11 1 10 0 0
INDEX 0 1 1 0 0 0 0
行/组 0A 1A 1B 0B 0B 1A 1A
策略 TAG相同组 TAG相同组 空的组 空的组 满,随机 满,随机 TAG相同组

由仿真结果可知,替换策略通过测试。

多个cache的联合访存测试

单个cache的功能测试完,达到设计目标后,就要测试多个cache联合访问总线的测试。这个测试不单测试cache功能,还要测试总线的功能。
首先将两个cache实例分别作为I cache 和D cache连接在总线和总线控制器上:

1
2
3
4
5
6
7
8
//hook up the simulated instruction cache
cache I_cache (CPU_stall,next_pc , 32'b0 , 1'b1, 1'b0, 1'b0, CPU_data, CPU_ready, PC,
BUS_addr, BUS_data, DMA[0], BUS_RW, grant[0], BUS_ready, clr, clk,
we_a,we_b,we_c,needupdate,tag,hitA,hitB,RAM_A_out);

//hook up the simulated data cache
cache D_cache (CPU_stall,exe_address[i],data[i], exe_req[i], rw[i],1'b0,mem_o,mem_ready,,
BUS_addr,BUS_data,DMA[1],BUS_RW, grant[1],BUS_ready ,clr,clk);

D_cache接在DMA[1]上,表明指令cache的优先级比数据cache的优先级要高。
然后设置合适的CPU_stall信号:

1
wire CPU_stall = clr ? 0: ~(CPU_ready && mem_ready);

最后加载模拟的测试请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//for I cache
reg [31:0] address [0:7] ;
initial
begin
address[0]= 0 ;
address[1]= 4 ;
address[2]= 0 ;
address[3]= 4 ;
address[4]= 8 ;
address[5]= 16 ;
address[6]= 0 ;
address[7]= 4 ;
end
//for D cache
reg [31:0] exe_address [0:7];
initial
begin
exe_address[0]= 0 ;
exe_address[1]= 20;
exe_address[2]= 0 ;
exe_address[3]= 20;
exe_address[4]= 20;
exe_address[5]= 24;
exe_address[6]= 24;
exe_address[7]= 24;
end
reg [31:0] exe_req [0:7];
initial
begin
exe_req[0]=0;
exe_req[1]=1;
exe_req[2]=0;
exe_req[3]=1;
exe_req[4]=1;
exe_req[5]=1;
exe_req[6]=1;
exe_req[7]=1;
end
reg [31:0] data [0:7];
initial
begin
data[0]= 32'hab2112a ;
data[1]= 32'hab2112b ;
data[2]= 32'hab2112c ;
data[3]= 32'hab21123 ;
data[4]= 32'hab21124 ;
data[5]= 32'hab21128 ;
data[6]= 32'hab21129 ;
data[7]= 32'hab21121 ;
end
reg [7:0] rw;
initial
begin
rw[0]= 0 ;
rw[1]= 1 ;
rw[2]= 0 ;
rw[3]= 1 ;
rw[4]= 0 ;
rw[5]= 0 ;
rw[6]= 1 ;
rw[7]= 0 ;
end

仿真结果如图:
联合访存测试1
0-130ns:
I cache未命中访问总线,D cache无请求,130nsCPU准备好接收下一个请求。
130ns-330ns:
I cache未命中优先访问总线,230ns访问完成轮到D cache访问总线,330ns CPU准备好接收下一个请求。
330ns-350ns:
I cache和Dcache均命中,总线空闲,350ns CPU准备好接收下一个请求。
联合访存测试2
350ns-450ns:
I cache未命中 访问总线,D cache命中,450ns CPU准备好接收下一个请求。
450ns-650ns:
I cache未命中优先访问总线,550ns访问完成轮到D cache访问总线,650ns CPU准备好接收下一个请求。
联合访存测试3
650ns-850ns:
I cache未命中优先访问总线,750ns访问完成轮到D cache访问总线,850ns CPU准备好接收下一个请求。
850ns-950ns:
I cache命中, D cache未命中,访问总线,950ns访问完成,CPU准备好。
950ns-970ns:
I cache、D cache均命中,CPU在一个周期内准备好。
970ns-990ns:
I cache、D cache均命中,CPU在一个周期内准备好。

由仿真结果可知,两个cache可以按照优先级共同访问系统总线,而且cache的存在对CPU的性能提升很有帮助。我们总线上挂的模拟内存控制器需要5个周期100ns事件才能将数据准备好。30-990ns的时间内,共48个时钟周期,共完成了9条指令。若cache不存在,每条指令需要10个周期才能完成,最多才能执行4.8条。

已知问题

我的总线设备和总线控制器不知道如何解决对设备中没有的地址的访问。
如果访问的地址不在设备的范围内,系统就会出现异常。
目前的解决方案:无,程序不应访问不在设备范围内的地址。

小结

我在实现过程中遇到的最大的问题莫过于组合逻辑环。因为FPGA片上存储的特性(隐含的寄存器),我必须在模块的某些输入上直接绕过流水线寄存器,并依据CPU_stall进行选择,这就非常容易引入从CPU_stall信号到ready_o信号之间的逻辑环。我在这个问题上花了很长时间进行调试,最后总结出,只在片上RAM的地址输入上使用这个由stall信号选择的输入,其余的控制电路均要使用经过寄存器同步的输入。因为片上ram读取结果是由寄存器同步的,因此这样绕过流水线寄存器不会引起组合逻辑环。
组相联cache在各种教科书中都有提及,但其篇幅往往不长,往往并不能描述其具体实现。我做这件事,写这些文章,一是满足自己的欲望,二是希望能够通过动手实践来填补理论和实践之间巨大的鸿沟,这篇文章的篇幅就很能说明问题。
组相联cache设计完之后,我的欲望再一次膨胀了。是否加入分支预测,撤掉有些过时的延时槽?是否可能实现简单的超标量流水线?这些还是等到整体设计完再说吧。

本以为cache的设计并不复杂,但实际设计过程中,我遇到了不少问题,哪怕稍微考虑一下性能问题,cache的设计的复杂度就会提高很多。cache的复杂与cpu指令解码级的复杂不同,CPU指令解码级的信号较多,但这些信号之间并没有太多的关联,更多的是一种纵向的逻辑关系,一条指令对应一种信号组合,因此在设计和分析过程中并没有太多复杂问题。但cache中的信号的逻辑关系更复杂,更难理清,因此我花费了大量的精力去设计。我引入cache的本意并不是单纯为了性能,最主要的原因是cpu内部采用类似哈弗架构,需要分离的指令和数据存储,引入cache可以在CPU视角构成分离的指令和数据存储。但人的野心总是越来越大,当你了解了简单的cache如何设计之后,总是想能不能再一次提高性能,哪怕提高一点点。于是我原本计划的直接映像的cache就变成了现在设计的2路组相联cache。Cache的设计是目前我在整个系统设计过程中遇到的最有挑战性的工作。

Cache 设计时遇到的问题

Altera片上存储接口上隐含的寄存器

我在cache设计中遇到的问题之一是关于FPGA片上资源的问题,Altera片上存储中几条输入的地址线和数据线都是必须registered的,这就意味着如果使用片上的存储实现cache,IF级PC寄存器实际上应该隐含在I-cache模块内部,EXE和MEM级的流水线寄存器中包含内存地址、数据和控制的部分也应该隐含在D-cache的内部,这实际上给模块化设计带来了困难。(Quartus II Handbook Version 9.1 Volume 1: Design and Synthesis, Chapter 6: Recommended HDL Coding Styles, Inferring Memory Function from HDL Code)如果在cpu设计的过程中忽略这隐含的寄存器,会额外引入至少一个周期的时延。但cache的设计目标是要求在命中的情况下,必须在一个周期给出数据和指令,所以隐含的寄存器必须加入考虑。

Cache的一致性问题

另外Altera的片上存储不支持清零(Quartus II Handbook Version 9.1 Volume 1: Design and Synthesis, Chapter 6: Recommended HDL Coding Styles, Inferring Memory Function from HDL Code),这给实现cache清零指令带来了困难:如果使用片上存储来实现cache,不能清零使得实现cache清零指令有困难,而如果没有cache清零指令,缓存同步的问题就需要完全由硬件解决。这又让我思考是否引入总线监听(bus snooping)。有人认为我这样一个单核心单级cache的系统不需要考虑cache一致性的问题,实际上这样的想法不完全正确。考虑这两种情况:一是自修改代码(Self-Modifying code)问题,设想有一操作系统在系统上运行,将某个程序指令从硬盘加载到内存,执行一系列LW操作。操作系统从硬盘读取一串指令,放入内存的某一位置,恰好覆盖了之前内存中的指令。此时若I-cache已经缓存过这一区域,I-cache根本不知道LW指令已经修改了这部分内存数据,这就会引起cache一致性的问题。当CPU跳转到这个区域,I-cache认为命中,实际执行的是旧指令而不是我们通过LW载入的新指令。二是DMA问题,当总线上有DMA请求发生时,DMA设备直接修改某些区域的内存,而Cache并不知道,这同样会引起缓存一致性问题。

对于DMA

第二种情况,MIPS处理器采取一种简单粗暴的办法来解决,就是在内存地址空间中单独划出来一部分不被cache索引,每当处理器试图获取这个地址范围内的数据或指令时,都绕过cache,从内存上请求。但是第一种情况要麻烦一些,因为操作系统要频繁的将程序的指令写入内存,我们不能总是把能自我修改的指令部分放入这个绕过cache的地址空间内,这样的话大部分程序都得不到cache的速度优势,还不如不引入cache。

自修改代码 (Self-Modifying Code)

如何搞定这第二种情况?是直接在硬件上提供缓存一致性的支持?还是加入一些缓存同步指令在必要的时候由系统软件编写者对cache进行管理,如在操作系统加载新进程的时候手动清空I-cache?我参考了一些资料。

显然不同平台对自修改代码带来的缓存不一致问题的处理方式不同。

X86平台的cache一致性机制比较复杂,从某种意义上讲,只要使用者正常使用处理器,cache对程序员完全透明。

osdev上有人讨论这一点:

Everything (including DMA) on 80x86 is cache coherent; except for the following cases >(where you have deliberately “weakened” cache coherency for performance reasons or broken cache coherency):

  • You’re using the “write-combining” caching type in the MTRRs and/or PAT. In this case writes that are sitting in a CPU’s write-combining buffer won’t be seen by other hardware.
  • You’re using non-temporal stores. In this case writes that are sitting in a CPU’s write-combining buffer won’t be seen by other hardware.
  • You’ve seriously messed things up (e.g. used the “INVD” instruction at CPL=0).

All hardware trickery (e.g. eDRAM caches, etc) is designed to uphold this.

在看minix代码的时候我就意识到了这一点。我看完整个启动器的代码,里面配合BIOS中断进行了大量DMA操作,从磁盘读取数据到内存,从来没有在代码上下文中出现管理cache的指令。自修改代码在Bootloader中也是随处可见,第一处就出现在master boot record的开头,将BIOS从MBR磁盘第一扇区的主启动记录(Master boot record)512字节的代码通过BIOS中断拷贝到内存后,这段代码将自身复制到其他的区域,并跳转到这里。这事很典型的自修改代码。

1
2
3
4
5
6
7
8
9
! Code from minix 3.1 master boot record: masterboot.s (line 46-53).
! Copy this code to safety, then jump to it.
mov si, sp ! si = start of this code
push si ! Also where we'll return to eventually
mov di, #BUFFER ! Buffer area
mov cx, #512/2 ! One sector
cld
rep movs
jmpf BUFFER+migrate, 0 ! To safety

可以清楚的看到,在编写X86汇编程序的时候,程序员不需要考虑这段代码是自修改代码而引起的缓存问题。可是事实上,在rep movs执行时,后续的指令已经加载进入流水线了。显然流水线中的旧指令需要被废弃。

查看X86系统编程手册(Intel software developer’s manual Volume 3A: System Programming Guide 11-18) 11.6 Self-Modifying Code , 可以看到从奔腾四开始,x86处理器已经在流水线和cache硬件上做了一致性机制,只要是使用同一物理地址访问的内存,硬件提供一致性支持。X86不仅在cache中实现了写请求监听,而且还在流水线中废弃已经预取的指令。

11.6 Self-Modifying Code

A write to a memory location in a code segment that is currently cached in the processor causes the associated cache line (or lines) to be invalidated. This check is based on the physical address of the instruction. In addition, the P6 family and Pentium processors check whether a write to a code segment may modify an instruction that has been prefetched for execution. If the write affects a prefetched instruction, the prefetch queue is invalidated. This latter check is based on the linear address of the instruction. For the Pentium 4 and Intel Xeon processors, a write or a snoop of an instruction in a code segment, where the target instruction is already decoded and resident in the trace cache, invalidates the entire trace cache. The latter behavior means that programs that self-modify code can cause severe degradation of performance when run on the Pentium 4 and Intel Xeon processors.

In practice, the check on linear addresses should not create compatibility problems among IA-32 processors. Appli- cations that include self-modifying code use the same linear address for modifying and fetching the instruction. Systems software, such as a debugger, that might possibly modify an instruction using a different linear address than that used to fetch the instruction, will execute a serializing operation, such as a CPUID instruction, before the modified instruction is executed, which will automatically resynchronize the instruction cache and prefetch queue. (See Section 8.1.3, “Handling Self- and Cross-Modifying Code,” for more information about the use of self-modifying code.)

各个平台对自修改代码的支持不同。

这篇博客对路由器中广泛使用的MIPS处理器进行了测试,发现其并没有实现自修改代码的缓存同步机制。由Stackoverflow上的讨论,PowerPC也没有实现这个机制,要求系统软件设计者从软件层面维护缓存的一致性。

3.3.1.2.1 Self-Modifying Code

When a processor modifies any memory location that can contain an instruction, software must ensure that the instruction cache is made consistent with data memory and that the modifications are made visible to the instruction fetching mechanism. This must be done even if the cache is disabled or if the page is marked caching-inhibited.

我打算如何对自修改代码的缓存进行处理?

如果不增加总线监听、流水线回退等机制,只引入清空cache的指令,那就需要系统程序员对自修改代码加以限制,如限定在关中断的情况下修改代码,并在修改代码后引入若干空指令填充流水线,然后使用指令cache清零指令清零cache。这样做降低了硬件设计的难度,但对所有的自修改代码无论其是否在缓存中都需要用控制令填充流水线、清空cache,带来了额外的性能开销。

总线监听实现起来不复杂,但是由于cache变化引起的流水线回退机制和中断的相互作用分析起来有点复杂。是否增加流水线回退机制,取决于能否联合中断,分析清楚处理器的工作状态。当然引入这个cache一致性机制会带来一些额外的好处,比如可以拓展DMA的地址空间,只把单纯的IO放在不经过cache的地址空间即可。

但是,当总线监听、中断、异常和流水线结合起来后,事情变得有点复杂。解决方案是要综合分析自修改代码引起的流水线和cache变化以及其他的一场和中断的组合,设计电路。

精确中断有两种实现方法,一是无论什么时刻中断发生,cpu立刻暂停当前工作,进入终端流程。这个要求较高,需要分析中断发生时ID级的三种情况:

  • ID级是转移指令–中断保存跳转指令的地址,中断返回重新执行
  • ID级是处在延时槽中的指令–中断保存当前PC,让延时槽中的指令执行完,当前PC就是之前的跳转计算出的地址。
  • 其他情况–中断保存当前PC,与上一种情况类似。

之所以需要考虑这些情况,是因为中断响应过程处理器取指的地址发生变化,而是否是转移指令、是否处在延时槽中对于CPU下一周期取指的地址有影响。

也可以选择等到CPU处在较为稳定的状态时在响应中断。上面可以看到,若发生中断时ID级是转移指令,则流水线中要取消两条指令,待中断返回后再次重新执行。这样做实际上造成了额外的性能开销,我更倾向于等待CPU进入稳定状态时响应中断请求,在ID级是转移指令或延时槽时不响应中断请求。这样做需要在中断控制器中增加一个中断响应机制,在收到响应后应立刻撤销中断请求。

异常的情况要比中断复杂,因为异常往往需要立即处理。一个复杂的CPU应当可以处理多种异常,例如保护模式引入的异常,在取指、读取、存储数据时都可能发生的超过范围,算数运算溢出,指令未实现、syscall指令、还有分页模式引入的异常如tlb缺失,缺页等。目前我尚未打算在我的CPU中实现保护模式和分页,因此目前支持的异常时算数运算溢出、指令未实现和syscall指令。第一个异常是发生在EXE级,后两个是发生在ID级。这三个异常EXE级的异常优先级最高,因为其发生在流水线内较深的位置,而异常处理往往需要重新执行造成异常的指令,所以当多种异常同时出现在流水线的不同阶段中,按照其发生的位置安排优先级处理时得当的方法。异常处理的硬件电路和中断一样,在设计时需要分析不同阶段的异常EPC中要保存的地址,流水线中需要废弃的指令,nPC中的下一个指令地址。具体分析的方法可以看李亚民的书。

其实这里我认为李的书中所描述的CPU工作起来应该有BUG。李并没有设计中断请求和响应的相应机制,默认中断到来就立刻进入中断处理程序。但在其代码中可以清楚看到,中断的优先级是低于异常的,因此当异常和中断同时发生时,由于缺乏中断请求和响应机制,CPU可能在处理异常时错过外部中断,造成IO设备数据丢失。另外,如上文所述,选择立刻处理中断会引入额外的性能开销,使流水线性能下降,因此我倾向于选择等待时机处理中断。

由此可以发现,在流水线系统里实现中断和异常处理并不是一个简单的任务。当你引入额外的硬件提供的cache同步支持时,事情就变得更加复杂。当MEM级发生一次cache写入,若这次写入的地址是已经预取、在流水线中执行着的指令,需要清除Icache中的某行,同时将被影响的指令及其之后的指令从流水线中废弃;若不影响已经预取的指令,则只需要在Icache中清除某行即可。可以将这样的事件当作异常处理,但与异常不同的是,某个引起自修改代码的LW指令,不会产生转移,只是需要在流水线中废弃某些指令。若采用李的书中做法,情况就会变得非常复杂。我若实现硬件cache同步,一定会选择等待时机(处理器无异常、ID无跳转、ID不是延时槽、cache不需要同步)响应中断,这样流水线控制设计只需要分析异常和cache同步即可。

现分析异常和cache同步:如果按照性能考虑,cache同步事件可以在EXE级就计算出来,可以使用旁路将lw指令写入的目标与IF ID 级指令的地址进行比较,判断是否废弃流水线中的指令。由于lw指令不是跳转指令,因此发生cache同步事件时,ID级一定不处在延时槽。如果不考虑性能,可以选择无论IF、ID那个指令需要废弃,只要有一个需要废弃,就直接将IF、ID中的指令作废,重新执行。如果考虑性能,事情就变得复杂了.如果此时ID级是一个跳转指令,那么延时槽中的指令受到cache同步的影响,如果只废弃IF级指令就会引起错误,因为处理器丢弃了ID级的跳转指令计算出的跳转结果,等于ID级跳转指令没有起到作用,所以如果Cache同步影响IF级中的指令,应当判断ID级是否是跳转指令(或syscall),如果是,则不仅要废弃IF还要废弃ID。

这样看来,硬件支持的cache同步机制配合等待时机的中断响应机制,以及立刻处理的异常机制在硬件上可以实现。但是我不打算实现它。为什么呢?

在之前的设想中,我将原本发生在MEM级的cache事件,前推到EXE级,这很大程度上避免了更多的流水线性能损失。但是这仅仅可以在无分页机制的情况下实现。如果实现分页,则在指令中的地址都是虚地址,EXE级的地址也是虚地址,不能用于前面级流水寄存器中指令地址的比较,而实地址实在查找MEM级中的TLB后才能得到。也就是说上述设计在实现TLB后,就失效了,而设计TLB是本项目的远期计划之一。因此不妨等到完成MMU设计之后,存储控制系统定型,再加入硬件的cache同步支持。

因此得出结论,目前的设计采用软件控制的cache同步,提供硬件Cache清空指令。系统软件编写者在编写自修改代码时,应先关闭中断,在修改完代码后手动清空cache,并使用紧随其后的若干空指令消除cache不同步引起的流水线错误。

总线设计

总线形式

CPU内cache通过总线与其他设备包括RAM、ROM及IO设备(Memory Mapped IO)相连。总线可以有多个主设备,能发起读写请求,这使得该系统拥有DMA能力,其他设备能在总线空闲时抢占直接访问存储设备。这一点是受到ZipCPU启发的,ZipCPU使用一种成熟的总线,在本项目中我计划设计一种简单的总线。
总线设计如下:

========address
========data
--------request
--------ready
--------r_w
--------clk

总线控制器

总线控制器设计如下:

          ------------
request<--|  state    |--<DMA 0
          |           |-->Grant 0
          |   BUS     |--<DMA 1
          | Controller|-->Grant 1
          |           | ...
          |           |--<DMA 7
    clk>--|           |-->Grant 7
          ------------

控制器负责对总线上所有的主设备请求进行排队,其内部指定的优先级由DMA 0 -> DMA 7递减。被允许的设备,会通过Grant X信号通知主设备,由该信号控制设备连接在总线上的三态门,允许其与总线通讯。当任意设备发起DMA请求时,request输出高电平(该信号与总线上的request连接),通知从设备进行通讯。控制器内部有一个状态机,当有请求发出时的下一个时钟上跳进入busy状态,收到任何一个从设备发送来的ready后的下一个时钟上跳进入idle状态。在busy状态其输出不变,等待该设备通讯结束,总线才空闲。该控制器由bus_control.v实现。总线上的请求发送和数据接收都在一个时钟内完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
module bus_control(
dma,grant,req,ready,clk
);
input [7:0] dma;
input ready, clk;
output [7:0] grant;
output req;

//-------------------------- Module implementation -------------------------

//registered grant value.
reg [7:0] grant_reg;

//internal state machine
reg state;
always @(posedge clk)
begin
case (state)

//Idle state, in this state, if has a req, jump to state busy
//and register the grant value, this device is chosen, and
//other devices' request can't change the output.
0: begin
if (req)
state <= 1;
grant_reg <= grant_inner;
end
//Busy state, in this state, if has a ready, jump to state idle
1: begin
if (ready)
state <= 0;
end
endcase
end

//dma request queue.
reg [7:0] grant_inner;
always @(*)
begin
casez (dma)
8'bzzzzzzz1 : grant_inner = 8'b00000001;
8'bzzzzzz10 : grant_inner = 8'b00000010;
8'bzzzzz100 : grant_inner = 8'b00000100;
8'bzzzz1000 : grant_inner = 8'b00001000;
8'bzzz10000 : grant_inner = 8'b00010000;
8'bzz100000 : grant_inner = 8'b00100000;
8'bz1000000 : grant_inner = 8'b01000000;
8'b10000000 : grant_inner = 8'b10000000;
default : grant_inner = 8'b00000000;
endcase
end

//When state == 0 (idle), grant is the instant output of code above
//When state == 1 (busy), grant is the registered value.
//This lets the grant output stable when one device has already been
//chosen.
assign grant = state ? grant_reg : grant_inner;

//The req signal will remain untill ready signal is received.
assign req = (|grant) ? 1 : 0;

endmodule //bus_control

总线控制器仿真如图:
总线控制器仿真

总线上的设备

总线上的主设备,连接总线上address、data、r_w和ready,连接控制器的DMA X和Grant X。发起请求时,将DMA x置高电平,排队成功后,由Grant X信号控制address、data、r_w和ready上的三态门。只有当设备被控制器选中,address、data、r_w才能在总线上输出或者输入,否则这些信号是高阻状态。主设备对其是否被选中是不可知的,当某个主设备发起了请求,他便将其请求的地址、数据、读写情况放在输出端口,而输出端口的三态门是由总线控制器传回的Grant X信号控制的。若其未被控制器选中,该设备无法收到其他设备发送的ready信号,因此处于等待状态。因此在总线空闲的时候,如CPU连续cache命中,其他设备即可使用总线进行DMA请求。

总线上的从设备,其接口应对地址线进行范围判定,地址线选定该设备内地址即认为对该设备发起请求。在被请求的数据准备好后,应将数据输出到总线,并将ready置高电平。

CPU内部的cache控制器是主设备,只能发起请求而不能被请求。大部分的IO设备都可以作为主/从设备,既可以发起请求,也可以作为请求的对象。这样的设备要设计分开的端口,负责接受请求的从设备使用从设备接口,负责发起请求的主设备使用主设备接口。

测试

如何设计并单独测试总线这一部分呢?需要设计主从设备接口,主从设备模拟器,并将模拟设备接入总线和控制器进行方针和测试。

从设备模拟

首先设计了一个模块dummy_slave来模拟从设备在总线上的行为。这个从设备可以看作一个内存设备,需要几个周期将数据准备好,可以读可以写。这个从设备也可以用作处理器的测试。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
//module for dummy slave devices.
module dummy_slave(
clk,address, data, request, ready_out, r_w
);
input clk, r_w, request;
input [31:0] address;
inout [31:0] data;
output ready_out;

//-------------------------- Module implementation -------------------------
//dummy memory
reg [31:0] mem [0:32-1];

//internal state machine
reg [ 2:0] state;

//address range
reg [31:0] entry_start, entry_end;

//internal registers for bus signal
reg [31:0] addr_reg,data_reg;
reg r_w_reg,selected_reg;

initial
begin
entry_start=32'b0;
entry_end =32'b11111;
state = 0;
//ready signal is Z when idle, ready line should have a tri0
//pulldown resistance. because there're other devices on the
//bus.
ready = 1'b0;
addr_reg =0;
data_reg=0;
r_w_reg=0;
selected_reg=0;
end


//selected if request in address range
reg selected;
always @(*)
begin
if ((address >= entry_start) &(address <=entry_end) &request )
selected = 1;
else selected =0;

end
//put the ready_out High Z when device is not selected.
reg ready;
assign ready_out = (selected | selected_reg) ? ready : 1'bz;

//implement inout data port.
//if device is selected and the request is a read request, this device
//will put data onto the bus. In any other condition, the output will be
//high Z.
assign data = (selected_reg & ~r_w_reg & ready) ? read :32'bz;

//read is the continuous read data out.
wire [31:0] read;
assign read = mem[addr_reg];

//the state machine implements the dummy wait cycles and ready signal.
//one dummy operation needs four cycles.
always @(posedge clk)
begin
//If device is in idle state and selected, register address, r_w
//and data.
if ((state == 2'b00)& selected) begin
state <= 2'b01;

//pull the ready line low.
ready <= 0;

//registered the request
addr_reg<= address;
r_w_reg <= r_w;
selected_reg<=selected;
if (r_w) begin
data_reg<= data;
end
end
//dummy write and read.
else if (state == 2'b01 ) begin
state <= 2'b10;
if (r_w_reg)
mem[addr_reg] <= data_reg;
end
//dummy wait.
else if (state == 2'b10) begin
state <= 2'b11;
end
//operation ready
else if (state == 2'b11) begin
state <= 3'b100;
//one cycle ready signal
ready <= 1;
end

//goto idle next cycle, ready for next request
else if (state ==3'b100) begin
state <= 00;
ready <= 1'b0;
selected_reg<= 0;
r_w_reg <=0;
data_reg <=0;
addr_reg<=0;
end
else begin
//if device is idle, and there's no request on this device
//clear all internal registers.
state <= 00;
ready <= 1'b0;
selected_reg<= 0;
r_w_reg <=0;
data_reg <=0;
addr_reg<=0;
end
end
endmodule //dummy_slave

这段代码,在quartus中综合生成了一个隐含的调用FPGA片上存储的模块,模块的类型是dual port/single clock RAM.其实我所设计的模型应当是一个单端口RAM,我查看了Altera的手册,也参考了quartus的代码片段,但是综合后的结果仍然是双端口RAM,这个问题让我想不明白。但是综合的结果满足我的设计需求。细想单端口和双端口RAM的区别,这里综合生成的双端口,应是读写端口共用地址线和数据线,但读端口的数据线用三态门控制,仅在写信号无效时才向总线输出。因为我并不清楚Quartus综合的细节,所以在这里我并不纠结为什么编译器没有按照我的要求综合成单端口RAM了,如果有哪位大佬知道原因,欢迎留言。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//Quaratus内置的单端口ram代码片段。
module single_port_RAM
#(parameter DATA_WIDTH=8, parameter ADDR_WIDTH=6)
(
input [(DATA_WIDTH-1):0] data,
input [(ADDR_WIDTH-1):0] addr,
input we, clk,
output [(DATA_WIDTH-1):0] q
);

// Declare the RAM variable
reg [DATA_WIDTH-1:0] ram[2**ADDR_WIDTH-1:0];

// Variable to hold the registered read address
reg [ADDR_WIDTH-1:0] addr_reg;

always @ (posedge clk)
begin
// Write
if (we)
ram[addr] <= data;

addr_reg <= addr;
end

// Continuous assignment implies read returns NEW data.
// This is the natural behavior of the TriMatrix memory
// blocks in Single Port mode.
assign q = ram[addr_reg];

dummy_slave的仿真结果:
dummy_slave仿真

主设备模拟

dummy master最终会被Cache、各种DMA设备替代,但是设计一个dummy master仍然有意义,它可以测试总线的功能,尤其是多个主设备同时请求的情况。

dummy master内部是一个请求队列,其将请求送至总线控制器,在控制器允许后将请求送至总线,等待被请求设备的ready信号,取走数据后重新排队,准备下一个请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
module dummy_master(
clk, request, ready, grant, address, data, r_w
);
input clk, ready, grant;
output request, r_w;
output [31:0] address;
inout [31:0] data;

//dummy requests.
reg [31:0] req_addr [0:32-1];
reg [31:0] req_r_w;
reg [31:0] req;
wire ready_inner;

//dummy memory operation.
reg [31:0] mem [0:32-1];

reg [4:0] req_num ;
//initial dummy operations.
initial
begin
//simulated operation is here.
end
//internal state machine.
reg state;
always @(posedge clk)
begin
case (state)
1'b0:begin
if (request) state <= 1;
else req_num<=req_num +1;
end
1'b1:begin
if (ready_inner)
begin
if (~req_r_w [req_num])
mem[req_num] <= data;
state <=0;
req_num<=req_num +1;
end
end
endcase
end

assign request = req [req_num];

//High z when not granted
assign address = grant ? req_addr[req_num] : 32'bz;
assign r_w = grant ? req_r_w [req_num] : 1'bz;
wire [31:0] data_out = req_r_w [req_num] ? mem [req_num] :32'bz;
assign data = grant ? data_out: 32'bz;

//mask out ready signal when not granted
assign ready_inner = grant? ready :1'b0;

endmodule //dummy_master

仿真情况如图:
dummy_master 仿真

总线事务测试

这里设计的test bench在总线上连接了两个DMA设备,分别接入控制器的DMA[0]和DMA[1]因此,DMA[0]有高优先级。总线上同时接入了两个从设备(内存设备),两个内存设备的地址空间分别是32’b0 -> 32’b11111 和 32’b100000->32’b111111。test bench很简单,将线网连接至各个模块的实例就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//This module is the test bench for bus
module bus_t(
clk,address_o,data_o,request_o,ready_o,rw_o,DMA_o,grant_o ,ready_inner
);
output [31:0] address_o, data_o;
output request_o, ready_o,rw_o,ready_inner;
output [7:0] DMA_o, grant_o;

input clk;

//These wires are internal bus signal
wire [31:0] address,data;
wire request, ready, r_w;
wire [7:0] DMA, grant;

//these ports are used by the simulator output
assign address_o =address;
assign data_o = data;
assign request_o = request;
assign ready_o = ready;
assign rw_o = r_w;
assign DMA_o = DMA;
assign grant_o = grant;

//instances of bus component
bus_control bus_control_0 (DMA,grant, request,ready,clk);
dummy_slave dummy_slave_a (clk,address,data,request,ready,r_w);
dummy_slave_1 dummy_slave_b (clk,address,data,request,ready,r_w);
dummy_master dummy_master_a (clk,DMA[0],ready, grant[0],address,data,r_w);
dummy_master_1 dummy_master_b (clk,DMA[1],ready, grant[1],address,data,r_w,ready_inner);
endmodule

仿真输出为:
总线事务仿真
从仿真结果可以看到模拟的四个设备8个总线事务功能正常。

小结

总线的设计,可以隔离CPU和外围设备,甚至可以隔离IO,因为我所要设计的CPU使用memory mapped IO。有了总线,IO设备只是总线上链接的一个设备罢了, 而且可以轻松实现不同的内存映射,连接ROM和RAM。所以总线设计完成,是本项目的一个里程碑,它标志着对开发设计流程的验证。

io和中断的设计定型,成了目前我的cpu设计过程中最亟待解决的问题。若不定型,可能影响接下来其他组件的设计和测试工作。

中断控制基本没啥可修改的,cpu端只有一个中断脚,cp0(协处理器0,即cu)中有四个寄存器,cause用于记录外部中断、内部中断和异常的种类;status中控制这一个中断的开关;base记录中断处理程序的地址,在中断处理程序中使用mfc0指令查询cause寄存器跳转到相应中断的处理程序地址;EPC用来保存中断前的指令地址。这一部分,我只需要额外实现三条指令mfc0,mtc0和eret。

io模块的输入输出部分应该是一个寄存器组,包括状态寄存器、指令寄存器、数据寄存器。状态寄存器标志忙闲,指令寄存器为从cpu获取指令,数据寄存器作为io控制器输入输出的通道。此外,io模块还要与cause寄存器相连,辅助一套中断屏蔽与优先级逻辑,完成中断向量。IO模块中的中断控制可以级联。

几乎所有人都不建议,在FPGA中使用多于一个时钟。在FPGA中使用不同的时钟,涉及到在不同的时钟域(clock domain)进行同步,数据的同步通常使用两个串联的触发器,flag(一个周期的信号)通常转换成电平转换,然后再到另一个域进行同步,详细的设计可以看这里

因此,我也决定在我的设计中撤销异步的清零、下边沿的清零。在需要使用不同速度的组件,如设计目标中的可以暂停的,可以调速的cpu,采用计数器激活的clock enabler,详细设计可以看这里。这样对我现有的设计的修改产生了一定的工作量。

现在lcc的代码已读完,下一步要做的是soc的设计和验证,以及lcc的移植、汇编器的移植。
对于汇编器,我准备使用customasm.
如果使用这个汇编器,可能在移植lcc时需要修改lcc模板中的一些伪指令如ld,lcomm等。

对于soc的设计,这些天一直在思考的问题是异步通讯的问题。我所希望的系统,cpu运行的速度应该是可变的,甚至可以是单步的。而我又希望能使用de0 nao板载的SDRAM,因此内存控制系统与cpu的通讯一定是异步的。异步通讯要有ready信号,由于我希望cpu运行的速度可变,因此我需要在cache控制器中设置一个双触发的触发器,在ready信号变化时记录并保持ready信号,直至cpu的下一个周期到来,用这个信号控制cpu中cache控制器和ram控制器的异步通讯。

系统地址空间的划分,也是一个需要确定的设计。目前我计划将地址空间按照| ROM | IO | RAM |划分,具体各区域尺寸尚未确定。系统cache只对RAM区域有效。目前我的想法是,根据指令和数据需求的不同,在cache控制器上增加片选、控制信号mux,以实现不同区域对cache的不同操作。对于指令,只需要读取ROM和RAM,所以I cache只需要在原有I cache上增加对ROM的多路复用(直通),由于这个ROM是用作bootloader的,因此不需要很大体积,可以使用同步的方式与I cache链接,因此I cache的改动很小。对于数据,则三个区域都需要读取。
显然,io与cpu之间的通讯也是异步的,但我尚未构思完毕整个io控制器的结构,因此io控制器的设计应该是接下来的工作之一。

IO控制器应该包括一些总线(I2C,SPI,etc),一些通讯端口(GPIO,Uart,etc),以及向量中断和异步通讯的支持。需要仔细考虑。

我目前已有cpu部分的Verilog代码,但是这部分代码缺乏测试,没有精确中断支持,没有与cache集成,而且一些指令还需要修改,例如:我打算删除乘法和除法的硬件支持,原因是原有的代码中对乘法和除法的硬件支持来自Altera的IP,这与本项目的设计目标冲突,而且在sparc平台的lcc后端中,有使用软件支持乘法除法的参考样例;我还需要考虑是否增加不同长度的取数操作,考虑如果在lcc移植过程中将所有整数类型的尺寸或者对齐都设置为4字节会怎样。

但是这些工作的顺序很难确定,这是我首先要考虑的问题。

目前我想到的合理的顺序是:

  • 考虑是否增加不同长度的取数操作
  • 回顾已有的cpu代码,分析那些代码可以与cache、IO、中断等独立,并对这些代码进行测试。
  • 定义存储控制和io控制的接口,与cpu对接,测试cpu的中断
  • 设计存储控制
  • 设计io控制
  • 工具链的移植

经过了几个月,终于完整的读完了lcc 3.6 的代码,这一阶段工作基本告一段落。对于我来说,lcc是一个十分复杂的软件,虽然我能感受到作者在解释其实现所用的极大努力,但理解起来仍然十分困难。但是当我完整的看完lcc的代码包括其后端后,得到的收获确实很大。通过阅读,基本可以确定对,于我来说,lcc的移植工作技术上是可行的。

LCC的结构

先谈一下lcc的结构。原书中的叙述方法,更多的是从功能模块的角度进行叙述的,详细的描述了每一个模块的实现,但是从纵深的角度来看,缺乏对模块之间相互调用相互配合的描述,因此需要阅读时特别留意整个编译程序运行过程中函数之间的调用,才能把握这个复杂的编译程序的整体脉络。

如果从传统的前端-后端角度去看这个编译器,很难去理解。我认为前端-后端这样的分法,实际上是从功能的逻辑层面去考虑的。虽然整个lcc确实在实现上区分了前端和后端,但是在运行过程中,前端和后端的代码始终在相互配合,难以在运行过程明确分离。在我尚未意识到这一点时,阅读代码基本是在关注某个模块的实现,比如语法分析。而这些模块如语法语法分析,实际上是运行过程中比较深得一层,但从这个角度,很难看到阅读编译器的代码如何带给人与仅上编译原理课程不同的好处,填补理论和实践之间的鸿沟。
因为这些一个一个的模块,尤其是语法分析,实际上与编译原理的课程内容很相似,事实上,编译原理课程与语法分析这部分的实现重叠很大。

从语法分析到语义分析,这部分内容就开始显现与计算机课程之间的区别了,编译器实现了ANSI C完整的语义,阅读这部分,更像是阅读C语言标准,这里面可以提炼出一些话题进行深入讨论,如C语言中的空指针定义,强制类型转换,函数调用,可变参数函数,参数的估值顺序,结构体中数据的对齐,C语言中复杂的声明等等。从编译器角度看这些问题,的确给人耳目一新的感觉,但是仅仅读到这里,仍然会有许多疑惑尚未被解释。例如函数调用、参数传递,这些都需要和计算平台配合,这些功能的具体实现,还要继续深入了解。我读到这里的时候,最大的疑惑就是程序中的数据是如何和各个标识符相关联的,内存的地址是如何和变量名相关联的。

继续深入下去,lcc的结构就渐渐浮出水面。按照c语言的文法,一个translation unit是c语言程序的基本处理单元,translation unit 包括声明,可以是变量或者是函数的声明,一个c语言文件可以包括若干translation unit。变量的声明,只是处理了标识符、类型、值在编译器内的内部表示。对于变量的初始化,全局变量不产生代码,而是直接将符号和值相互对应起来。只有进行函数定义的时候,才会生成代码。所以分析lcc的整体脉络,需要知道书中讨论的各种功能模块,实际上是以函数定义为单元,往复进行调用。所以分析应从函数定义开始,逐步深入。便可理解一个简单的c语言程序是如何被lcc处理的。

处理函数定义的函数funcdefn第一部分处理要定义的函数的类型,参数列表,处理局部变量和参数(主要是通过后端指定名字,将名字指定为相对于栈的offset),然后对函数定义的compound statement进行语法语义分析,一边分析,一边生成中间语言(森林形式的中间语言),生成中间语言的过程加以优化(如消除公共子式,常量折叠)。然后调用后端function函数,对中间语言进行处理。

function函数,首先根据funcdefn传递过来的参数和局部变量offset计算栈偏移,如果需要,则为生成复制参数的代码(如原来参数由寄存器传入,需要用另一个寄存器或放入栈中),为生成保存函数调用约定中要保存的寄存器的代码做准备,然后调用gencode,对中间语言进行处理。

gencode首先生成对参数进行复制的中间语言,然后根据中间代码森林中树的类型,调用相应的后端函数。Blockbeg对于compound statement中的局部变量以for循环的形式逐个调用local,分配寄存器变量或在栈中划分空间;Local,address节点专门处理临时生成的local变量。gen节点是重中之重,负责生成主要的代码。

gencode—>gen,处理树状的中间语言。这部分书中表13.1描述的较为清晰,prelabel处理已经确定了寄存器的节点的target,_label在树上用树文法与后端模板进行匹配,reduce选择最好的指令输出。prune删去一些不生成指令的子节点,linerize对指令进行排序,最后ralloc对需要寄存器的节点分配寄存器。

然后function 函数开始真正生成代码,首先先输出函数名作为label,作为函数的入口,然后计算framesize和sp,并将sp移动的指令输出(划分栈空间),然后输出保存寄存器的代码,接下来是移动参数的代码。这些工作做完,function调用emitcode函数,生成函数中的语句生成的代码(从已经被标记修减的中间语言树中,按模板生成汇编指令)。最后生成函数的出口,包括恢复保存的寄存器,栈弹出,跳转返回指令。

变量的标识符和值

我在阅读代码的时候,十分关注标识符和值是如何关联的。实际上lcc将于C语言的变量名,处理为在数据段中的一个标签。对于未初始化的全局变量,lcc将变量名标签放入bss段;对于初始化的全局变量,lcc根据情况将【变量名标签:值】对放入LIT(read only)或data段,实际的地址生成是汇编器的工作。对于局部变量,则全部表示为栈偏移的形式。这样做的原因是显而易见的:程序运行时的函数调用是动态的,很难确定局部变量的绝对地址,所以使用相对于栈的偏移来解决问题。不同于全局变量,局部变量的初始化是用生成的代码实现的。

对于一些特殊的变量,如数组、字符串的初始化,首先将常量的内容放入LIT段,然后生成一个临时变量保存这个常量内容的标签。

lcc中mips后端的帧结构

高地址
    --------------------------------
    | 调用者的帧                    |
    --------------------------------
    |调用者传递的参数               |
    --------------------------------        ——
    |局部变量                       |        |
    --------------------------------
    |被调用者保存的现场             |       Framesize
    -------------------------------
    |被调用者调用其他函数时传递的参数|         |
    -------------------------------         ——----->sp
    |另一个帧                       |
低地址        

这里值得一提的是,参数的偏移是相对于sp+framesize的正偏移,因此是从调用者的帧中获取参数的值(参数传递),而局部变量的偏移是相对于sp+framesize的负偏移。

另一处值得说明的是,被调用者如果调用了其他的函数,那么就需要在当前栈中开辟存放outgoing argument的空间,一个函数可以调用很多个其他的函数,这个空间如何确定?在生成函数的代码的过程中,对所有的子函数调用的参数进行统计,得到最大的outgoing argument数量,这里这个空间是这样确定的。

小结

了解了lcc的结构,理解了一个简单的c语言程序是如何由lcc一步一步生成汇编语言的,就可以进行移植工作了。

移植工作主要包括:修改指令模板,修改寄存器分配规则,修改函数调用规则,修改各个基本类型的长度和对齐,修改各种名称约定(如各数据段的名字)等。

对于我即将进行的工作,主要的修改应该包括修改类型的长度和对齐(由于cpu取数操作与mips不同),对于乘法除法指令模板应该为函数调用(cpu无乘法器、除法器,这里还需修改clobber函数),对于浮点操作,需要先用编译器编译生成计算函数,然后将指令模板修改为函数调用。修改个数据段名字与汇编器配合。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×