浅入浅出CPU总线通信

syan 发布于 2019-12-17 4241 次阅读


——关于怎么杀数字系统与处理器实验大作业

笔者由于近三日说话过多和长期感冒导致了很大程度的嘶哑,遂决定针对CPU一事谢绝营业。为了使更多的受难者逃离操作考试的噩梦以及黄姓魔鬼的压迫,遂尝试出品此篇文档,一是对近日自习结果进行一个简单的总结,也希望能或多或少起到一点帮助。

首先从我们需要做什么开始√

按黄继业的原话,我们需要制作一个以RISC-V指令集为基础的,经由CPU与汇编代码对寄存器/储存器的操作进行中转,通过可变/恒定的输入,在硬件上完成任意输出的系统。其中恒定输入可以直接由汇编代码的顺序逻辑完成,而可变输入则需要通过CPU对外设进行读取,并搭配汇编中循环/顺序/分支等逻辑结构对输入变量进行读取,从而影响输出。在这个过程中有两个比较容易造成阻力的难点,一是对总线功能和CPU读取逻辑的不理解,二则是对汇编功能的不熟悉,程序思维一时难以从熟悉的编程语言到汇编进行转化。虽然归结到底都是因为黄老师的备课不利,低估或高估了学生的理解能力和算法基础,以及忽略了Verilog HDL到System Verilog在部分模块上的代码理解阻力,希望黄老师在下个学期的授课中能多加注意,善待我们可爱的学弟学妹们√

由于输出的代码已经给出,遂不详细讲解输出部分的逻辑,而是针对这两天发现的重度困扰问题既如何对CPU进行输入进行着手。

硬件输入逻辑

以群文件中哪个沙雕按钮控制.zip文件中的代码为例

我最恨那些开源代码不注释还溜了一大堆代码冗余的二货了,看在他刚刚请了我两颗生煎的面子上原谅他并做出部分补充

module key_write(
    input clk,
    input rst_n,
    //输入外部输入信号
    input [3:0] key,
    //总线连接信号,应该可以随缘定义i/o
    input rd_req,
    input [31:0] rd_addr,
    input [31:0] rd_data,         
    input rd_gnt
    );
    //无关输入中间变量
    reg [7:0] key_sig = 0;
    reg [3:0] key_state = 0;
    //输入判断变量预处理
    wire cell_rd_addr;
    assign rd_gnt = rd_req;
    assign cell_rd_addr = (rd_addr[31:2] == 30'h0);
    //输入信号变量预处理
    reg [31:0] rdata;
    assign rd_data = rdata;
    //输入逻辑主体,是这个部分的重点
    always @ (posedge clk or negedge rst_n)
        if(~rst_n)
            rdata <= 0;
        else if(rd_req && cell_rd_addr)
            rdata <= {{24'b0},key_sig};
    //输入信息处理,硬件操作到信号的转化
    always@(posedge clk or negedge rst_n) begin
        if(~rst_n) begin
            key_sig<=0;
            key_state<=0;
        end else begin
            key_state <= key;
        end
        case(key_state)
            4'b0001:  key_sig<=8'b0000_0001;
            4'b0010:  key_sig<=8'b0000_0010;
            4'b0100:  key_sig<=8'b0000_0100;
            4'b1000:  key_sig<=8'b0000_1000;
            default:  key_sig<=8'b1111_0000;
        endcase
    end
endmodule

这是我们给出的硬件读入模块的代码,针对不同的输入信息处理模块自然都是不同的,希望各路使用者自行补充,我们仅仅点出硬件输入的主体逻辑,进行一个简单的代码解释。即

wire cell_rd_addr;
assign rd_gnt = rd_req;
assign cell_rd_addr = rd_addr[31:2];

reg [31:0] rdata;
assign rd_data = rdata;

always @ (posedge clk or negedge rst_n)
    if(~rst_n)
        rdata <= 0;
    else if(rd_req && cell_rd_addr == 30'b0)
        rdata <= {/*你要传给CPU的任意信号*/};

首先是第一部分

wire cell_rd_addr;
assign cell_rd_addr = rd_addr[31:2];

在这里,我们定义了一个网线型变量,用于表示总线中的地址信号,其中选取cell_rd_addr为rd_addr,即整个总线地址变量的32到3位,这个值表示的是CPU对该总线的寻址地址,即CPU访问地址相较于总线基地址的偏移量。在开源文件中,key_state模块被定义在了总线的0x00032000—0x0003200f的位置,这里取cell_rd_addr == 30'b0,即表示总线基地址偏移0的地址:0x00032000。

接下来,我们可以简单解释一下CPU与总线进行通信的原理

CPU总线通信

这是我昨天临时画的图片,起初是配合文字加以解释的,但是碍于语言通顺度就没有发出来。

当CPU访问总线上的地址(可以理解为外设,既汇编代码执行器之外的任何部分)时,CPU会向外设发送一个访问请求request,和一个访问地址address,以确定外设是否准备好接受CPU的访问。当外设赋予CPU访问权限grant的同时,会在总线上的data位置给出一个返回值。当CPU获得了批准grant时,便会自动读入总线上的data信号,将其视为外设的返回值。而这也就是我们进行所谓“偷天换日”的CPU逻辑基础。

接下来我们看到第二行

assign rd_gnt = rd_req;

我们在硬件上将请求与批准联系了起来。因为我们建立的不是一个复杂的,拥有完整安全性的模块,所以并不具有批复CPU请求的功能,遂我们使硬件自动反馈,成为骄傲的公交车(大雾。即CPU在进行请求后将立即读回反馈信息。接下来我们给到代码最主要的部分

reg [31:0] rdata;
assign rd_data = rdata;
always @ (posedge clk or negedge rst_n)
    if(~rst_n)
        rdata <= 0;
    else if(rd_req && cell_rd_addr == 30'b0)
        rdata <= {/*你要传给CPU的任意信号*/};

其它都不算重点,重中之重只有第六行的条件,我们可以轻松的理解到,这个判断条件为,CPU对总线的基地址进行访问。而在这个时候,我们将早已准备好的反馈信号发送到了本应存在外设反馈信号的总线data位置上,从而让CPU误读了我们提供的信息。起到了偷天换日的效果。

即,我们一开始定义定义的总线地址其实均是空地址,CPU对空地址的访问无法起到任何有效回应,而在这个时候我们将自己的模块偷换到了相应的位置,在总线地址上表现出一个投影,使CPU将外部模块视为存在于总线√

习题⭐

在老师给出的pout_seg模块中,是基于什么样的逻辑读取信息的

提示:同前文给出的原理完全一致

总线

虽然前面说得天花乱坠,但相信由于同学们对总线的理解未必足够深入遂未必能起到较好的解释效果。这里也结合简易的总线模块在USTC核上的表现,仅就slaves通道来做一个简单的说明。

先做一个小定义:总线是将储存器以及各种功能相对独立的I/O接口电路与CPU连接起来,实现内部各功能模板见信息的传送,以构成功能完整计算机的元素。

计算机的操作系统的位数通常也是表示能索引总线的宽度,也表示了能通过总线索引内存空间的最大范围。即32位操作系统最多能在2^32^地址范围内寻址,给出了最高3.5G的内存使用范围,相比之下,六十四位操作系统可以索引的范围已经是个天文数字了。

简单的总线就相当于一条导线。

在复杂的计算机系统中,总线被分为了地址总线、数据总线和控制总线。其中地址总线反映了地址索引关系,数据总线表现了数据传输关系,控制总线调整了访问时序关系。而在USTC核中,通过一条简易总线同时传输了信息。即rd_data,wr_data对应数据总线,rd_addr,wr_addr对应地址总线,rd_req,wr_req,rd_gnt,wr_gnt对应控制总线,共同起到了确保模块之间有效交流的用途。

有模块定义

interface naive_bus();
    // read interface
    logic  rd_req, rd_gnt;
    logic  [3:0]  rd_be;
    logic  [31:0] rd_addr, rd_data;
    // write interface
    logic  wr_req, wr_gnt;
    logic  [3:0]  wr_be;
    logic  [31:0] wr_addr, wr_data;

    modport master(
        output rd_req, rd_be, rd_addr,
        input  rd_data, rd_gnt,
        output wr_req, wr_be, wr_addr, wr_data,
        input  wr_gnt
    );

    modport slave(
        input  rd_req, rd_be, rd_addr,
        output rd_data, rd_gnt,
        input  wr_req, wr_be, wr_addr, wr_data,
        output wr_gnt
    );

endinterface

其实其中还有be线路,但该信号是总线命令和字节允许复用信号,主要由CPU导引,遂于我们并没有什么实际的关系,不使用亦不提及

总线在SoC(system on chip)顶层的定义是这样的

naive_bus  bus_masters[3]();
naive_bus  bus_slaves[5]();

// 主(越靠前优先级越高):
//   0. UART Debugger?
//   1. Core Data Master
//   2. Core Instruction  Master
// 从:
//   1. 指令ROM  地址空间 00000000~00000fff
//   2. 指令RAM  地址空间 00008000~00008fff
//   3. 数据RAM  地址空间 00010000~00010fff
//   4. 显存RAM  地址空间 00020000~00020fff
//   5. 用户UART 地址空间 00030000~00030003
naive_bus_router #(
    .N_MASTER          ( 3 ),
    .N_SLAVE           ( 5 ),
    .SLAVES_MASK       ( {32'h0000_0003 , 32'h0000_0fff , 32'h0000_0fff , 32'h0000_0fff  , 32'h0000_0fff } ),
    .SLAVES_BASE       ( {32'h0003_0000 , 32'h0002_0000 , 32'h0001_0000 , 32'h0000_8000  , 32'h0000_0000 } )
) soc_bus_router_inst (
    .clk               ( clk          ),
    .rst_n             ( rst_n        ),
    .masters           ( bus_masters  ),
    .slaves            ( bus_slaves   )
);

其中定义有两个主要的问题,即SLAVES_MASK和SLAVES_BASE的含义,这里给出一个解释,base对应的是空间的基地址,而mask分配空间大小或长度,以用户UART为例,32'h0003_0000表示空间的基地址为0x00030000,32'h0000_0003表示分配空间大小为4,即从0x00030000到0x00030003的地址空间均为用户UART所有,而当我们需要在总线上添加一个模块/外设时,只需要将前部bus_slaves定义处的slaves数指定为当前slaves数,并且在线路路由router上为新增的总线定义地址空间,以老师给出的pout_seg为例,在总线0x00031000到0x0003100f上分配了内存空间,有修改

naive_bus  bus_masters[3]();
naive_bus  bus_slaves[6]();
naive_bus_router #(
    .N_MASTER          ( 3 ),
    .N_SLAVE           ( 6 ),
    .SLAVES_MASK       ( {32'h0000_000f , 32'h0000_0003 , 32'h0000_0fff , 32'h0000_0fff , 32'h0000_0fff  , 32'h0000_0fff } ),
    .SLAVES_BASE       ( {32'h0003_1000 , 32'h0003_0000 , 32'h0002_0000 , 32'h0001_0000 , 32'h0000_8000  , 32'h0000_0000 } )
) soc_bus_router_inst (
    .clk               ( clk          ),
    .rst_n             ( rst_n        ),
    .masters           ( bus_masters  ),
    .slaves            ( bus_slaves   )
);

在System Verilog中,bus可以作为一个类型直接在模块间传输,而Verilog HDL相比之下就比较丢人了,需要手动将类桥接到网线上进行操作(其实有可能是因为我们还不知道怎么实现)。但总之在黄老师给出的代码中,有这样的操作

logic        rd_req     ;
logic [31:0] rd_addr    ;
logic [31:0] rd_data    ;
logic        rd_gnt     ;
logic        wr_req     ;
logic [31:0] wr_addr    ;
logic [31:0] wr_data    ;
logic        wr_gnt     ;

assign rd_req     =  bus_slaves[6].rd_req   ;
assign rd_addr    =  bus_slaves[6].rd_addr  ;
assign rd_data    =  bus_slaves[6].rd_data  ;
assign rd_gnt     =  bus_slaves[6].rd_gnt   ;
assign wr_req     =  bus_slaves[6].wr_req   ;
assign wr_addr    =  bus_slaves[6].wr_addr  ;
assign wr_data    =  bus_slaves[6].wr_data  ;
assign wr_gnt     =  bus_slaves[6].wr_gnt   ;

这样,就完成了总线的定义,之后将其用于例化的输入即可

汇编代码逻辑

在讲完实现的基本原理之后,我们可以通过汇编程序来解释具体如何实现这些操作

读取硬件输入

这个部分是和硬件输入模块相匹配的。我们之前说过,让数据传入CPU的条件是CPU对指定地址进行了访问,那么要如何做到这一点呢,其实非常简单,只要让CPU从对应的位置load资源就行了。反映在汇编代码上就是这样

.org 0x0
    .global _start
_start:
    or      t0, zero, zero      #初始化t0寄存器(下文t寄存器均储存地址)
    lui     t0, 0x00032         #t0高位赋值,即将t0指向输入总线基地址
    or      s0, zero, zero      #初始化s0寄存器(下文s寄存器均储存变量)
loop:
    lw      s0, (t0)            #将t0位置的值读入s0寄存器
    jal     zero, loop          #循环循环循环%……&(¥……¥%*&*¥

对的,就是这么简单,这样就能一直将输入信息在CPU中的寄存器上保存了!

调试trick

调试输入

为了方便调试,我们可以试着使用UART用户端口对系统进行调试,虽然说是调试,其实就是将需要的信息发送到屏幕上,前面有定义UART端口的地址为0x00030000

.org 0x0
    .global _start
_start:
    or      t0, zero, zero      #初始化t0寄存器
    lui     t0, 0x00032         #t0高位赋值,即将t0指向输入总线基地址
    or      s0, zero, zero      #初始化s0寄存器
    or      t1, zero, zero      #初始化t1寄存器
    lui     t1, 0x00030         #t1高位赋值,即将t1指向用户UART发送口
loop:
    lw      s0, (t0)            #将t0位置的值读入s0寄存器
    sw      s0, (t1)            #将s0寄存器的信息写入UART发送端口
    jal     zero, loop          #循环循环循环%……&(¥……¥%*&*¥

这里的sw指令表示的是store储存,即将寄存器中的值储存到UART地址,这里有一点可以注意的,即UART端口地址的首位为发送位,当你向该位置写入信息时,UART会自动将信息从UART输出,即打印到了我们的屏幕上

调试输出

当同时进行输入和输出调试的时候,总是会遇到一些不可预见性的bug,这时我们其实不必过度依赖自己的智力,可以尝试使用工具,有USTC-RVSoC-master \ tools \ UartSession.exe这个工具,可以直接通过串口向CPU通信(可以注意到UART是鲜有的连有master总线的模块),使CPU直接向地址进行写入,有直接输入

$ 0
$ 0x00031000 0x666

可以使默认的计数器当场置666

$是控制台命令的标准前缀,不需要输入

以及要注意的是,当这个工具占用着端口时,是无法进行烧写的,请及时退出

让外设读取CPU的输出?

这一步其实跟让读取硬件输入一样简单,即只需要把外设Verilog中的cell_wr_addr与基地址和汇编中的寄存器地址对上,然后向地址中写入数据即可

module pout_seg (
    input  clk, 
    input  rst_n,
    output [7:0]  SEL,
    output [7:0]  DIG,   

    output wr_req,       
    output [31:0] wr_addr,    
    output [31:0] wr_data,    
    input  wr_gnt  
);
reg [31:0] seg_data;

wire [31:0] cell_wr_addr;
wire [31:0] wdata;

assign wr_gnt = wr_req;
assign cell_wr_addr = wr_addr[31:2];
assign wdata = wr_data;

always @ (posedge clk or negedge rst_n)
    if(~rst_n)
        seg_data <= 0;
    else if(wr_req && cell_wr_addr == 0)
        seg_data <= wdata;

seg seg0(
            .clk           (clk     ),
            .seg_data      (seg_data),
            .DIG           (DIG     ),
            .SEL           (SEL     )
    );

endmodule

可以直接这样子√

.org 0x0
    .global _start
_start:
    or      t0, zero, zero      #初始化t0寄存器
    lui     t0, 0x00032         #t0高位赋值,即将t0指向输入总线基地址
    or      s0, zero, zero      #初始化s0寄存器
    or      t1, zero, zero      #初始化t1寄存器
    lui     t1, 0x00031         #t1高位赋值,即将t1指向输出总线基地址
loop:
    lw      s0, (t0)            #将t0位置的值读入s0寄存器
    sw      s0, (t1)            #将s0寄存器的信息传给输出模块
    jal     zero, loop          #循环循环循环%……&(¥……¥%*&*¥

而当我们需要在相较基地址偏移的位置上访问时,可以这样操作,修改

cell_wr_addr == /*偏移*/

addi    t1,{偏移数}

这样就能成功进行数据交互了

其实内置的VGA显示模块组织形式就与之非常相近,有每个字符为8*16点阵,一个地址储存一个字符。模块在输出时逐行进行扫描,有地址相较首地址偏移的同时,读取位置也相较基地址偏移。

一些常用编程思路的实现

分支

分支在汇编语言中主要的实现方法是通过bne和beq指令,这两个指令都是跳转类型的,处理两个寄存器的值,并进行判断,根据判断结果进行跳转,有参数表

bne/beq 寄存器1,寄存器2,跳转语句块

其中ne为not equal,至于eq是啥就显而易见了,我们可以通过这个指令来完成C语言中的if、case等语句,例如

if ( wtf == cpu )
    {fuck eda;}
else
    {fuck my life;}
//***
switch(wtf)
    case cpu:
        fuck eda;
    case hjy:
        wehhz,wehnb;

可以等价于

fuck_eda:
fml:
a_simple_case:
    beq s0,s1,fuck_eda
    bne s0,s1,fml

仅仅作为示范x

另外有BLT,BGE分别表示<和≥,可以尝试使用

循环

此外还有while(1)和for语句此类在单片机编程中常用的语句,可以这样用汇编表示

while:
    # do
    jal     zero, while         #跳转回自己,无限循环
#########################
cnt_init:
    lui     s0, 0x1             #计数器初始赋值,要在循环体外部
for:
    addi    s0, s0, -1          #计数器自减
    # do
    bne     s0, zero, for       #条件循环

此处jal表示无条件跳转

汇编小总结与其它trick

这样看起来,一切是不是都和我们熟悉的程序世界挂上钩了呢?

而大家其实要做的就是不断通过组织这些逻辑来通过汇编指令操纵CPU和外设中的数据,从而达到自己需要的输出,举个例子,与按钮打字模块配套的汇编代码,虽然完成度极低当还是可以摆出来作为参考的

.org 0x0
    .global _start
_start:
    or    t0, zero,zero
    lui   t0, 0x00020           #设置t0为显存位置,vga自动打印显存数据
    or    t1, zero,zero
    lui   t1, 0x00032           #设置t1为按钮输入位置
    or    s0, zero,zero         
    lb    s0, (t1)              #读入按钮默认状态值
cnt_init:
    lui   s1, 0x000f0           #计数器s1赋初值,用于延时防止输出过快
loop:
    addi  s1, s1, -1            #计数器自减
    bne   t2, zero, loop        #计数器非零时持续自减,仅0时执行后续
    or    s2, zero,zero                 
    lb    s2, (t1)              #按键输入读入s2
    beq   s2, s0, cnt_init      #判断输入是否为稳态时输入,是则跳过
    addi  s2, s2, 0x041         #输入数据加到有ASCII码的范围方便显示
    sb    s2, (t0)              #将显示数据写入显存
    addi  t0, t0, 0x001         #显存进位,顺序输入

这仅仅是一个简单的顺序打印,通过更多复杂的逻辑,比如说将显存进位128可以达到向下一行输出的效果,以及通过模块的拆分可以达到仅在显示器上移动光标而不直接进行写入,这样复杂合成便能起到控制字符移动、控制画笔、时序显示等等等功能,其实都是在地址和数据操纵上的小小变化,在给定VGA输出模块的前提下显得极为简单了,非常有趣的是王杰辉同学的共享,通过自己生成点阵篡改原有ascii对应信息,从而使得图片等复杂输出都成为了可能,在这个基础上可以发挥出的创意有太多了。

最后总结

这次的作业虽然听起来稍显恐怖,但其实是非常简单有趣且让人收获颇丰的一次实践。难点也只有想一想自己想做什么,机器能做什么,我该怎么让机器去做。一旦想通了,其实一切苦难都能迎刃而解。现在有多数同学可能都还在焦虑之中,害怕自己难以完成这项作业而被迫面临操作考试,但还请务必好好享受学习的过程。出于各种意义,我还是希望我的嗓子能用在为什么的讨论上,而不是怎么办的解释上。因为前者能巩固我的知识促进我的理解,而后者只会让我发出为啥自己这也说不清楚的悲鸣(草

以及对于后续的点子,其实有很多很简单的创意可以实现,比如说我今晚见到的通过外接温度传感器在数码管上进行显示。各位可以擅用真正意义上的“外设”类似压敏传感器,声纳,红外测距模块之类的小物件,去完成一点简单的工作,并试着在其中切实学到什么东西。虽然我们天天喊着黄老贼,但是我相信老师也在诚挚地这么想吧。总之祝各位CPU实验愉快?!

​ 此致

​ ——因被迫营业而奕言难尽的廖老先生