Home 计组实验1:汇编语言与监控程序
Post
Cancel

计组实验1:汇编语言与监控程序

实验报告

实验1:RISC-V监控程序与Term命令

在模拟器中运行 RISC-V 监控程序的基本步骤如下:

  1. 启动监控程序

    Screen Shot 2022-09-14 at 2.07.46 PM

  2. 通过终端连接监控程序

    Screen Shot 2022-09-14 at 2.08.16 PM

Term 中几个命令的使用方法(以下对每个命令的介绍均来自supervisor-rv/文件夹中的README.md文件):

  1. R:按照 x1 至 x31 的顺序返回用户程序寄存器值。

    Screen Shot 2022-09-14 at 2.11.48 PM

  2. D:显示从指定地址开始的一段内存区域中的数据。

    Screen Shot 2022-09-14 at 2.12.15 PM

  3. A:用户输入汇编指令,并放置到指定地址上

    Screen Shot 2022-09-14 at 2.14.14 PM

  4. F:从文件读入汇编指令并放置到指定地址上,格式与 A 命令相同。

    Screen Shot 2022-09-14 at 2.16.40 PM

  5. U:从指定地址读取一定长度的数据,并显示反汇编结果。

    Screen Shot 2022-09-14 at 2.17.30 PM

  6. G:执行指定地址的用户程序。

    Screen Shot 2022-09-14 at 2.17.54 PM

  7. T:查看页表内容,仅在启用页表时有效。

    Screen Shot 2022-09-14 at 2.18.30 PM

    加上flagEN_PAGING=y来启动监控程序,以启用页表(以下是部分页表内容截图):

    Screen Shot 2022-09-15 at 10.19.30 AM

    Screen Shot 2022-09-15 at 10.18.38 AM

  8. Q:退出 Term。

    Screen Shot 2022-09-14 at 2.19.51 PM

实验2:求前10个Fibonacci数

我的代码参考了asmcode/文件夹中sum.sfib-mem.s代码。其中sum.s中采用循环的方式计算了$1 + 2 + … + 10$,在我的代码中也参考了这一循环loop的书写方式,通过一个计数器t3来控制是否完成了t4 = 10次的计算循环(事实上只需要进行8次循环即可,因为前2项Fib数是既定已给出的,无需计算)。fib-mem.s中比较关键的则是Fib数递推的步骤:根据递推式计算下一个Fib数add t2, t0, t1 # t2 = t0+t1,以及为下一轮递推做准备ori t0, t1, 0x0 # t0 = t1, ori t1, t2, 0x0 # t1 = t2,在我的代码中也参考了这一书写规范来进行Fib数的递推计算。

代码的总体思路分为以下几步:

  1. 初始化加数(第一个Fib数t0,第二个Fib数t1),并设置好计数器相关变量(已计算次数t3、总共计算次数t4)、待写入地址t5
  2. 将前2个已知Fib数写入当前地址,并更新下一待写入地址;
  3. 通过循环,以计算Fib递推式计算下一个Fib数t2 = t0 + t1,并在进入下一轮循环之前更新计算次数t3、待写入地址t5、新一轮递推的加数t0, t1,其中循环的终止条件为t3 == t4(即完成了t4次计算);
  4. 达到循环的终止条件(或不再满足循环的执行条件t3 != t4),返回,程序终止。
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
    .global _start
    .text

_start:

    li t0, 0x1   # t0 = 1,加数(第1个Fib数)
    li t1, 0x1   # t1 = 1,加数(第2个Fib数)
    li t3, 0x2   # 已计算次数,用于计数(前2项已给出)
    li t4, 0xA   # t4 = 10,一共需计算前10个Fib数
    li t5, 0x80400000  # 待写入的地址

    sw  t0, 0x0(t5)  # 将第1个Fib数写入地址
    sw  t1, 0x4(t5)  # 将第2个Fib数写入地址
    addi t5, t5, 0x8  # 待写入地址自增

loop:

    add t2, t0, t1   # t2 = t0 + t1,Fib数递推式
    addi t3, t3, 0x1  # t3++,已计算次数自增
    sw  t2, 0x0(t5)  # 将第t3个Fib数写入地址
    addi t5, t5, 0x4  # 待写入地址自增

    ori t0, t1, 0x0  # t0 = t1,为新一轮Fib数递推计算做准备
    ori t1, t2, 0x0  # t1 = t2,同上
    bne t3, t4, loop # 若没算完10个Fib数(t3 != t4),则通过loop继续计算
    nop
    jr ra            # 否则(t3 == t4)返回,程序终止
    nop

supervisor-rv/kernel/目录下执行make sim启动监控程序,并在supervisor-rv/term/目录下执行python3 term.py -t 127.0.0.1:6666通过终端连接监控程序,并用>> F命令在监控程序中运行以上代码;通过>> D命令查看内存地址0x80400000~0x80400024,结果如下:

Screen Shot 2022-09-14 at 11.32.32 AM

验证了存入起始地址为0x80400000的10个字中的数(16进制),与前10个Fibonacci数$1, 1, 2, 3, 5, 8, 13, 21, 34, 55$(十进制)一一对应,结果正确。

实验3:输出ASCII可见字符

我的代码参考了实验说明文档中提供的WRITE_SERIAL.TESTW.WSERIAL函数,以实现从终端输出字符。至于输出由0x21~0x7E共94个ASCII可见字符,采用循环的方式(94次循环)来进行输出,其中a0寄存器所存储的既是输出的对象(ASCII可见字符的十六进制编码)、又是计数器(由0x21开始计数到0x7E)。

代码的总体思路分为以下几步:

  1. 初始化循环开始字符a0、结束字符t2,注意因为执行循环的边界条件判断为“不等”(bne a0, t2, WRITE_SERIAL # a0 != t2 )而非“小于等于”(a0 <= t2),因此结束字符t2需要额外加一;
  2. 通过循环,向终端(0x10000000)输出a0寄存器中的最低字节,并在进入下一轮循环前更新a0的值(a0++,切换到下一顺位ASCII可见字符),其中循环的终止条件为a0 == t2(即完成了由0x21~0x7E所有ASCII可见字符的输出);
  3. 达到循环的终止条件(或不再满足循环的执行条件a0 != t2),返回,程序终止。
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
.section .text
.globl _start

_start:
    li  a0, 0x21      # 循环开始字符
    li  t2, 0x7E      # 循环结束字符
    addi t2, t2, 0x1  # .WSERIAL处bne边界条件

WRITE_SERIAL:
    # 串口地址是 0x10000000
    li t0, 0x10000000

    # 轮询串口状态(0x10000005)

.TESTW:
    lb t1, 5(t0)
    # 判断是否可写
    andi t1, t1, 0x20
    beq t1, zero, .TESTW

.WSERIAL:
    # 向终端(0x10000000)输出 a0 寄存器中的最低字节
    sb a0, 0(t0)

    addi a0, a0, 0x1          # a0++,切换到下一字符以输出
    bne a0, t2, WRITE_SERIAL  # 若未达到边界条件(a0 != t2)则继续输出ascii字符
    nop
    jr ra                     # 否则(a0 == t2)返回,终止程序
    nop

同样启动监控程序,并通过终端连接监控程序,在监控程序中运行以上代码,得到结果:

Screen Shot 2022-09-15 at 2.08.50 PM

成功向终端输出了ASCII 可见字符:

1
!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~

实验4:求第60个Fibonacci数

与实验2基本一致,但由于第60个Fibonacci数已经超过了32位整型,因此需要在实验2的基础上作出一些修改,主要区别如下:

  1. 原先存储Fib数的3个32位寄存器t0t1t2,改由6个32位寄存器存储,其中每个64位的整数由2个32位寄存器储存,即{a3, a2}储存第一个Fib数、{a5, a4}储存第二个Fib数、{a1, a0}用来储存由Fib递推式计算得到的下一个Fib数(高位为a3a5a1,低位为a2a4a0);
  2. 计数器相关变量已计算次数t3保持一致,而总共计算次数t4由10(0xA)改为60(0x3C);
  3. 根据Fib递推式进行的加法运算,由原来的32位整型加法改为64位整型加法,此处参考了实验说明文档中提供的大位宽数据加法的实现方法;
  4. 为新一轮Fib数递推计算做准备,赋值时注意要把两个Fib加数的高位、低位寄存器都赋值(因此由原本的两个语句变为四个语句,每个加数各有两个待更新的寄存器需要被赋值);
  5. 只需将第60个Fib数写入制定内存地址即可,因此不再需要每次循环都写入一次地址,而只需要在退出循环之后,执行一次第60个Fib数{a1, a0}的写入即可(注意低位a0写入0x80400000、高位a1写入0x80400004)。
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
    .global _start
    .text
    
_start:

    li a1, 0x0   # {a1, a0} = {0, 0},初始化Fib数和
    li a0, 0x0
    li a3, 0x0   # {a3, a2} = {0, 1},初始化Fib数加数(第1个Fib数为1)
    li a2, 0x1
    li a5, 0x0   # {a5, a4} = {0, 1},初始化Fib数加数(第2个Fib数为1)
    li a4, 0x1

    li t3, 0x2   # 已计算次数,用于计数(前2项已给出)
    li t4, 0x3C  # t4 = 60,需计算到第60个Fib数
    li t5, 0x80400000  # 待写入的地址

loop:
    # {a3, a2} 即把低位保存在 a2,高位保存在 a3 的 64 位整数
    # {a3, a2} + {a5, a4} = {a1, a0}
    add a0, a2, a4
    sltu a2, a0, a2
    add a1, a3, a5
    add a1, a2, a1

    addi t3, t3, 0x1  # t3++,已计算次数自增

    ori a3, a5, 0x0   # {a3, a2} = {a5, a4},为新一轮Fib数递推计算做准备
    ori a2, a4, 0x0
    ori a5, a1, 0x0   # {a5, a4} = {a1, a0},同上
    ori a4, a0, 0x0

    bne t3, t4, loop  # 若没算到第60个Fib数(t3 != t4),则通过loop继续计算
    nop

    sw a0, 0x0(t5)    # 否则(t3 == t4)将第60个Fib数{a1, a0}写入地址
    sw a1, 0x4(t5)

    jr ra             # 返回,程序终止
    nop

同样启动监控程序,并通过终端连接监控程序,在监控程序中运行以上代码(方法与实验2、3均一致,此处省略),得到结果:

Screen Shot 2022-09-14 at 1.03.52 PM

存入内存的结果0x1686c8312d0(十六进制)与第60个Fib数1548008755920(十进制)相同,结果正确。

代码分析报告

虽然在supervisor-rv/README.md文件中的“介绍”章节中写到“监控程序分为两个部分,KernelTerm”,但鉴于实验说明文档中的要求是“撰写监控程序以及终端程序的代码分析报告”,以下均以实验说明文档中的说法为准,即监控程序代表Kernel终端程序代表Term,而README.md文档中的“监控程序Supervisor”本质为一个简化的“操作系统”(即操作系统Supervisor分为监控程序Kernel和终端程序Term两个部分)。

在本次实验中,监控程序Kernel被运行在模拟环境QEMU中(QEMU模拟了RISC-V指令与系统,在后续实验中模拟环境QEMU将被物理实验平台替代),而我们需要通过终端程序Term与模拟环境QEMU中的监控程序Kernel进行交互,具体交互流程将在后续交互流程一节中阐释。以下将先分别给出监控程序Kernel与终端程序Term的代码分析。

监控程序Kernel

监控程序Kernel的代码在supervisor-rv/kernal/文件夹中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.
├── Makefile(编译规则)
├── debug(调试指令)
├── include(头文件)
│   ├── common.h(基本信息如使用32位或64位架构使用的访存指令与地址位数、用户栈初始化、各信号相关常量等)
│   ├── exception.h(异常相关的常量)
│   └── serial.h(串口相关的常量)
├── kern(监控程序Kernel核心代码!!!)
│   ├── evec.S(监控程序入口点)
│   ├── init.S(监控程序初始化)
│   ├── kernel32.ld(32位RISC-V的链接文件)
│   ├── kernel64.ld(64位RISV-V的链接文件)
│   ├── shell.S(监控程序的交互功能实现)
│   ├── test.S(监控程序的性能测试程序)
│   ├── trap.S(监控程序中涉及中断相关代码)
│   └── utils.S(通过串口进行读、写数据相关代码)
├── kernel.asm(make编译Kernel程序后生成的文件)
├── kernel.bin(make编译Kernel程序后生成的文件)
├── kernel.elf(make编译Kernel程序后生成的文件)
└── obj/(内含make编译Kernel程序后生成的文件)

核心代码均在kern/文件夹中,其中涉及到本次实验基础版本Kernel的文件为 evec.Sinit.Sshell.Sutils.S 这4个文件。程序的结构为:

graph TD

		subgraph evec.S
				subgraph INITLOCATE
						A[Kernel入口, 跳转到init.S: START]
				end
		end
		A --> C
		subgraph init.S
				subgraph START
            C[清空BSS段, 设置内核栈+用户栈+用户态程序sp和fp寄存器] --> D[配置串口等]
            D --> D1[配置中断帧]
				end
				subgraph WELCOME
						D1 --> D2[装入启动信息并打印]
						D3[跳转到shell.S: SHELL, 开始交互]
						
				end
		end
		
		D3 --> E
		
		subgraph utils.S
				subgraph WRITE_SERIAL_STRING
						D2 --> H[写字符串: 将a0地址开始处的字符串写入串口]
						H --> H1[调用串口写函数]	
						G[打印循环至 0 结束符]
				end
				
				subgraph WRITE_SERIAL
						H1 --> H2[写串口: 将a0的低八位写入串口]
						H2 --> H3[检测验证: 截取并查看串口状态的写状态位]
						H3 -->|非零可写, 进入写|H3_1[写入寄存器a0中的值]
						H3 -->|为零不可写, 忙等待|H3
						H3_1 --> G
						G --> D3
				end
				
								
				subgraph READ_SERIAL
						F[读串口: 将读到的数据写入a0低八位]
						F --> F1[检测验证: 截取并查看串口状态的读状态位]
						F1 -->|非零可读, 进入读|F1_1[将串口中的值读取到寄存器a0]
						F1 -->|为零不可读, 忙等待|F1
				end
		end
		
				
		subgraph shell.S
				subgraph SHELL
						E[调用串口读函数, 读取操作符] --> F
						F1_1 --> I[根据操作符进行不同的操作]
						
						I -->|'R'|OP_R
						I -->|'D'|OP_D
						I -->|'A'|OP_A
						I -->|'G'|OP_G
						I -->|'T'|OP_T
						I -->|其他, 主要指'W'|OP_OTHER
						
						subgraph .OP_R
								OP_R[打印用户空间寄存器]
						end
						
						subgraph .OP_D
								OP_D[打印内存num个字节]
						end
						
						subgraph .OP_A
								OP_A[写入内存num字节, num为4的倍数]
						end
						
						subgraph .OP_G
								OP_G[跳转到用户程序执行]
						end
						
						subgraph .OP_T
								OP_T[打印页表] 
						end
						
						subgraph OTHER[错误反馈]
								OP_OTHER[把XLEN写给term] 
						end
						
						OP_R --> E
						OP_D --> E
						OP_A --> E
						OP_G --> E
						OP_T --> E
						OP_OTHER --> E
						
				end
		end		

由于shell.S中的具体操作(RDAGT)繁多,在上图中不一一分析具体实现方法,只取比较核心的G运行用户代码操作做详细分析,其代码结构如下(所有的循环i = 0, 1, 2, 3均是为了报告中书写方便,在实际代码中采用了直接书写四次代码实现):

graph TD
		subgraph shell.S
				subgraph .OP_G
						OP_G1[获取用户程序地址]
						OP_G2[从a0保存用户地址到s10]
						OP_G2 --> OP_G3[写开始计时信号SIG_TIMERSET到串口, 通知Term开始运行]
						OP_G4[定位用户空间寄存器备份地址] --> OP_G5[恢复用户程序调用前的寄存器数据]
						OP_G5 --> OP_G6[重新获得当前监控程序栈顶指针]
						OP_G6 --> OP_G7[写停止计时信号SIG_TIMETOKEN到串口, 通知Term结束计时, 调用WRITE_SERIAL过程与前述写SIG_TIMERSET一致, 此处省略]
				end
		end
		
		OP_G1 --> READ1
		OP_G3 --> WRITE
		
		subgraph utils.S
				subgraph READ_SERIAL_XLEN
						READ1[调用READ_SERIAL_WORD函数]
				end
				
				READ1 --> READ2
				
				subgraph READ_SERIAL_WORD
						READ2[保存ra, s0-s3]
						READ4[结果存入si, i++]
						READ5[截取s0-s3的低8位] -->|第i次, i=0,1,2,3|READ6[存si的高8位到a0中, 再左移8位, i++]
						READ6 -->|i<4|READ6
						READ6 -->|i>=4|READ7[通过ra保存的返回地址返回]
				end
				
				READ2 -->|调用第i次, i=0,1,2,3|READ3
				
				subgraph READ_SERIAL
						READ3[读串口获得8位数据]
				end
				
				READ3 --> READ4
				READ4 -->|i<4|READ3
				READ4 -->|i>=4|READ5
				READ7 --> OP_G2
				
				subgraph WRITE_SERIAL
						WRITE[检测验证+写串口, 同前述过程, 此处省略]
				end
				
				WRITE --> OP_G4
				
		end
		
		

终端程序Term

终端程序的代码在supervisor-rv/term/term.py文件中,程序的结构为:

graph TD
		subgraph main
				main2[设置命令行参数] 
				main3[初始化失败, 程序终止]
				main4[初始化成功, 进入Main函数]
		end
		
		main2 -->|检测tcp|TCP0.5
		main4 --> Main1
		
		subgraph InitializeTCP
				TCP0[创建tcp_wrapper类的服务器ser]-->TCP0.5[连接kernel, 设置inp, outp]
		end
		
		TCP0.5 --> TCP1
		
		subgraph tcp_wrapper
				subgraph connect
						TCP1[通过python的stdlib中的socket模块connect函数, 连接TCP服务器kernel的host+port]
				end
				
				subgraph read
						TCP_read[通过stdlib中的socket模块recv函数, 8位8位读取串口数据]
				end
				
				subgraph write
						TCP_write[通过stdlib中的socket模块send函数, 8位8位写入串口数据]
				end
		end
		
		TCP1 -->|连接成功|main3
    TCP1 -->|连接失败|main4
		
		Main0 --> TCP_read
		TCP_read --> Main1
		subgraph Main
				Main0[通过inp.read读取串口, 获得kernel发来的欢迎信息与xlen信息]
				Main1[输出, 并进入MainLoop函数]
		end
		
		Main1 --> ML1
		subgraph Mainloop
				ML1[根据用户在命令行中的指令cmd, 执行不同操作]
				
        ML1-->|'A'|A
        ML1-->|'F'|F
        ML1-->|'R'|R
        ML1-->|'D'|D
        ML1-->|'U'|U
        ML1-->|'G'|G
        ML1-->|'T'|T
        ML1-->|'Q'|Q
        subgraph run_A
            A[用户输入汇编指令, 并放置到指定地址上]
        end

        subgraph run_F
            F[从文件读入汇编指令并放置到指定地址上]
        end

        subgraph run_R
            R[返回用户程序寄存器值]
        end

        subgraph run_D
            D[显示从指定地址开始的一段内存区域中的数据]
        end

        subgraph run_U
            U[从指定地址读取一定长度的数据, 并显示反汇编结果]
        end

        subgraph run_G
            G[执行指定地址的用户程序]
        end

        subgraph run_T
            T[查看页表内容, 仅在启用页表时有效]
        end

        Q[退出Term]
        
        A-->ML2
        F-->ML2
        R-->ML2
        D-->ML2
        U-->ML2
        G-->ML2
        T-->ML2
        
        ML2[通过outp.write写入串口, 通知kernel执行该命令]-->TCP_write
        TCP_write --> ML1
		end

交互流程

Kernel和Term通过串口进行交互,即用户在Term中输入的命令、代码在经过 Term 处理后通过串口传输给 Kernel,而Kernel需要输出的信息也会通过串口传输到Term并展示给用户。在这里是使用QEMU模拟串口,其地址如README.md文件所描述的:

设置了一个内存以外的地址区域,用于串口收发。串口控制器按照 16550 UART 的寄存器 的子集实现,访问的代码位于 kern/utils.S ,其部分数据格式为:

地址说明
COM1 = 0x10000000[7:0]串口数据,读、写地址分别表示串口接收、发送一个字节
COM1 + COM_LSR_OFFSET = 0x10000005[5]只读,为1时表示串口空闲,可发送数据
COM1 + COM_LSR_OFFSET = 0x10000005[0]只读,为1时表示串口收到数据

意思是不论是Kernel要读写数据(从/给Term读/写),还是Term要读写数据(从/给Kernel读/写),二者均需要在判断0x10000005地址处的相关检验位(可写位是第5位,可读位是第0位)为1时,从0x10000000读/写8位的数据。一个重要的例子是,在前面针对监控程序Kernel的代码分析中,分析过的Term向Kernel发送用户代码的32位地址数据时(Term写、Kernel读),二者均需在判断可写/读时每次仅进行8位的数据交互,共4次,Kernel读到这4个8位的数据还需通过依次放入目标寄存器再左移的方法复原32位数据,实现完整的32位地址数据交互。

在Kernel中交互主要的代码块在utils.S中,包含向串口写入数据的 WRITE_SERIALWRITE_SERIAL_WORDWRITE_SERIAL_XLENWRITE_SERIAL_STRING 函数,以及从串口读入数据的 READ_SERIALREAD_SERIAL_WORDREAD_SERIAL_XLEN 函数。其中 WRITE_SERIAL_WORDWRITE_SERIAL_XLENWRITE_SERIAL_STRING 均是基于 WRITE_SERIAL 函数(内部根据写出的数据大小,选择调用对应次数的 WRITE_SERIAL 函数),READ_SERIAL_WORDREAD_SERIAL_XLEN 均是基于 READ_SERIAL 函数(内部根据读入的数据大小,选择调用对应次数的 WRITE_SERIAL 函数)。对于串口地址、串口检验地址等常量均定义在include/serial.h中。

WRITE_SERIALREAD_SERIAL 的核心内容,总结如下:

  1. 判断检验位:将判断地址0x10000005处的数据通过lb指令读到t1寄存器中,再通过andi运算(WRITE_SERIAL中为t1 = t1 & COM_LSR_THRE = t1 & 0x20t1 = t1 & 0b00100000截取第5位检验位是否可写;READ_SERIAL中为t1 = t1 & COM_LSR_DR = t1 & 0x01t1 = t1 & 0b00000001截取第1为检验位是否可读)截取检验位并判断是否可写/可读(可写/可读计算为1,不可写/不可读为0);
  2. 根据检验位的合法性决定下一步执行动作(条件跳转bne):若检验位不合法(为0)则通过循环判断检验位代码的方法实现“忙等待”,不断“轮询串口状态”直到合法(为1);若检验位合法(为1)则直接顺序执行后面的代码(真正执行读/写到串口的代码);
  3. 读/写:通过sbWRITE_SERIAL,写)、lbREAD_SERIAL,读)向/从串口地址0x10000000读/写数据。

这种通过 sblb 等内存访问指令来实现的对于串口的访问,实际是访问的外设寄存器,这种访问方式叫做MMIO(内存映射 IO)。

在Term中交互主要的代码块在term.py文件里class tcp_wrapper中定义的write函数与read函数,函数实现是基于Python的stdlib库中socket模块的sendrecv函数。虽然不确定socket模块的sendrecv函数具体是如何实现的,但根据Kernel在utils.S中的读 READ_SERIAL /写 WRITE_SERIAL 函数每次只读取/写入0x10000000地址8位数据,推测Term中调用的socket模块的sendrecv函数也需要判断检验位后每次向串口地址0x10000000读/写8位数据。

思考题目

  1. 比较 RISC-V 指令寻址方法与 x86 指令寻址方法的异同。

    RISC-V指令寻址方法只有一种(立即数与存放基址的寄存器相加,即基址+偏移量寻址),而x86指令寻址方法繁多(包含立即数寻址、寄存器寻址、绝对寻址、间接寻址、基址+偏移量寻址、2种变址寻址、4种比例变址寻址)。

    RISC-V只有load, store访存指令(lb, lw, sb, sw等)可以访问内存,而x86基本所有指令(如移动指令mov、算术指令add等,以及x86本身的访存指令)均可以访问内存。

  2. 阅读监控程序,列出监控程序的 19 条指令,请根据自己的理解对用到的指令进行分类,并说明分类原因。

    19条指令(参考了《RISC-V手册》):

    指令描述具体含义
    add rd, rs1, rs2x[rd] = x[rs1] + x[rs2]把寄存器 x[rs2]加到寄存器x[rs1]上,结果写入x[rd](忽略算术溢出)
    addi rd, rs, immx[rd] = x[rs] + sext(imm)把符号位扩展的立即数加到寄存器x[rs]上,结果写入x[rd](忽略算术溢出)
    and rd, rs1, rs2x[rd] = x[rs1] & x[rs2]将寄存器 x[rs1]和寄存器x[rs2]位与的结果写入x[rd]
    andi rd, rs, immx[rd] = x[rs] & sext(imm)把符号位扩展的立即数和寄存器x[rs]上的值进行位与,结果写入x[rd]
    auipc rd, immx[rd] = pc + sext(imm[31:12] « 12)把符号位扩展的20位(左移12位)立即数加到pc上,结果写入x[rd]
    beq rs1, rs2, offsetif (x[rs1] == x[rs2]) pc += sext(offset)若寄存器x[rs1]和寄存器x[rs2]的值相等,把pc的值设为当前值加上符号位扩展的偏移offset
    bne rs1, rs2, offsetif (x[rs1] ≠ x[rs2]) pc += sext(offset)若寄存器x[rs1]和寄存器x[rs2]的值不相等,把pc的值设为当前值加上符号位扩展的偏移offset
    jal rd, offsetx[rd] = pc+4; pc += sext(offset)把下一条指令的地址(pc+4)写入x[rd],然后把pc设置为当前值加上符号位扩展的offset
    jalr rd, offset(rs)t=pc+4; pc=(x[rs]+sext(offset))&~1; x[rd]=t把pc设置为x[rs]+sext(offset),把计算出的地址的最低有效位设为 0,并将原pc+4的值写入x[rd](rd默认为x1)
    lb rd, offset(rs)x[rd] = sext(M[x[rs] + sext(offset)][7:0])从地址x[rs]+sext(offset)读取一个字节(8位),经符号位扩展后写入x[rd]
    lui rd, immx[rd] = sext(imm[31:12] « 12)将符号位扩展的20位立即数imm左移12位,写入x[rd]中(低12位置零)
    lw rd, offset(rs)x[rd] = sext(M[x[rs] + sext(offset)][31:0])从地址x[rs]+sext(offset)读取四个字节,写入x[rd]
    or rd, rs1, rs2x[rd] = x[rs1] | x[rs2]把寄存器x[rs1]和寄存器x[rs2]按位取或,结果写入x[rd]
    ori rd, rs, immx[rd] = x[rs] | sext(imm)把寄存器x[rs]和符号扩展的立即数imm按位取或,结果写入x[rd]
    sb rs2, offset(rs1)M[x[rs1] + sext(offset)] = x[rs2][7:0]将x[rs2]的低8位存入内存地址x[rs1]+sext(offset)
    slli rd, rs, shamtx[rd] = x[rs] « shamt把寄存器x[rs]逻辑左移shamt位,空出的位置填入0,结果写入x[rd](对于RV32I,仅当shamt[5]=0时,指令才有效)
    srli rd, rs, shamtx[rd] = x[rs] » shamt把寄存器x[rs]逻辑右移shamt位,空出的位置填入0,结果写入x[rd](对于RV32I,仅当shamt[5]=0时,指令才有效)
    sw rs2, offset(rs1)M[x[rs1] + sext(offset)] = x[rs2][31:0]将x[rs2]的低位4个字节存入内存地址x[rs1]+sext(offset)
    xor rd, rs1, rs2x[rd] = x[rs1] ^ x[rs2]x[rs1]和x[rs2]按位异或,结果写入x[rd]

    x[id]表示编号为id的寄存器,rd表示destination register目标寄存器的编号,rs表示source register源寄存器的编号,sext表示sign-extend符号位扩展,imm表示immediate立即数,pc表示program counter程序计数器寄存器,offset为立即数形式的偏移量,M[address]表示地址为address的内存空间,shamt为立即数形式的位移量。

    分类:

    分类指令原因
    整数计算指令ADD, ADDI, AND, ANDI, SLLI, SRLI, OR, ORI, XOR, LUI, AUIPC需要调用ALU,包含算术指令(ADD, ADDI)、逻辑指令(AND, ANDI, OR, ORI, XOR)、移位指令(SLLI, SRLI)、其他(向PC高位加上立即数的AUIPC, 加载立即数到高位的LUI)
    条件分支指令BEQ, BNE根据比较结果进行分支跳转
    无条件跳转指令JAL, JALR实现无条件的跳转并链接
    访存指令LB, LW, SB, SW从/向内存加载、存储数据
  3. 结合 term 源代码和 kernel 源代码说明 term 是如何实现用户程序计时的。

    主要思路是,Kernel获得用户程序地址后通过串口发送开始计时信号SIG_TIMERSET来通知Term用户程序开始运行,Term处开始计时(记录开始时间),Kernel执行完用户程序并复原调用用户代码前的寄存器、栈顶指针后通过串口发送停止计时信号SIG_TIMETOKEN来通知Term用户程序结束运行,Term处停止计时并获得用户程序运行时间(计算结束时间与先前记录的开始时间之差)。

    具体流程为:

    1. Term在term.py文件中的run_G函数中,将指令G与用户程序代码所在的8位内存地址通过串口写给Kernel;
    2. Kernel在shell.S文件的SHELL函数中从串口中读入Term发来的G指令并跳转到.OP_G子函数中,再从串口中读入Term发来的用户程序代码所在的8位内存地址并保存到s10寄存器,然后在执行用户程序(跳转到s10所存内存地址处)之前,通过串口向Term写入开始计时信号SIG_TIMERSET(即0x06,在ASCII中表示ACK,为不可见ASCII字符);
    3. Term通过串口读入Kernel发来的数据,并判断是否为约定好的开始计时信号SIG_TIMERSET(即0x06),若是则通过time_start = timer()开始计时(记录开始时间);
    4. Kernel此时写入返回地址la ra, .USERRET2ra后开始执行用户程序,即jr s10跳转到用户程序所在的内存地址s10处,同时Term通过while True:循环不断判断从串口处读入的数据是否是约定好的停止计时信号SIG_TIMETOKEN(即0x07,在ASCII中表示BEL,为不可见ASCII字符)——
    5. 若Term读入的不是停止计时信号SIG_TIMETOKEN(且也不是超时信号SIG_TIMEOUT0x81 )则默认为Kernel向Term发送的待输出到终端的可见字符,调用output_binary函数将其输出到终端;
    6. 直到Kernel执行完用户程序(进入.USERRET2子函数并完成调用用户程序之前的寄存器、栈顶指针复原),通过串口向Term写入停止计时信号SIG_TIMETOKEN
    7. 因此此时Term再通过while循环从串口读入的就是停止计时信号SIG_TIMETOKEN(即0x07),终止while循环并通过elapse = timer() - time_start停止计时并获得用户程序运行时间(计算结束时间与先前记录的开始时间之差)。
  4. 说明 kernel 是如何使用串口的(在源代码中,这部分有针对 FPGA 与 QEMU 两个版本的代码,任选其一进行分析即可)。

    supervisor-rv/kernel/include/serial.h文件中定义的,FPGA与QEMU的串口地址常量均由COM1表示、串口的寄存器地址间隔均由COM_MULTIPLY表示、串口是否可读/可写的判断地址相对于串口地址的偏移量均由COM_LSR_OFFSET表示(即判断可读/可写状态位的判断地址实际为COM1 + COM_LSR_OFFSET)、写状态位均由COM_LSR_THRE表示、读状态位均由COM_LSR_DR表示。

    详见上一章代码分析报告中交互流程一节,以QEMU为例(即COM1 = 0x10000000, COM_MULTIPLY = 1, ` COM_LSR_OFFSET = 5, COM_LSR_THRE = 0x20, COM_LSR_DR = 0x01)分析了Kernel如何使用串。(FPGA相应的各常量为COM1 = AXI Uart16550的基地址 + 0x10000, COM_MULTIPLY = 4, COM_LSR_OFFSET = 20, COM_LSR_THRE = 0x20, COM_LSR_DR = 0x01`)。

  5. 请问 term 如何检查 kernel 已经正确连入,并分别指出检查代码在 term 与 kernel 源码中的位置。

    检查代码在Term源码中的term.py文件main函数调用InitializeTCP函数中ser.connect(host, int(port))语句,其中ser是类tcp_wrapper的对象,tcp_wrapper的成员函数connect内部实际上调用了Python的stdlib库中socket模块所定义的connect函数。

    检查代码在Kernel源码中的shell.S文件SHELL函数li a0, XLENjal WRITE_SERIAL指令(后续又跳转到utils.S文件WRITE_SERIAL函数以将XLEN写入串口给Term)。

    Term如何检查Kernel已经正确连入,分析如下——

    观察Term连接Kernel的过程:

    1
    2
    3
    4
    
    ➜  kernel git:(master) ✗ make sim
    riscv64-unknown-elf-ld  obj/evec.o  obj/init.o  obj/shell.o  obj/test.o  obj/trap.o  obj/utils.o -Tkern/kernel32.ld
    qemu-system-riscv32 -M virt -m 32M -kernel kernel.elf -nographic -monitor none -serial tcp::6666,server -s -bios none
    qemu-system-riscv32: -serial tcp::6666,server: info: QEMU waiting for connection on: disconnected:tcp::::6666,server=on
    
    1
    2
    3
    4
    5
    
    ➜  term git:(master) ✗ python3 term.py -t 127.0.0.1:6666
    connecting to 127.0.0.1:6666...connected
    MONITOR for RISC-V - initialized.
    running in 32bit, xlen = 4
    >>
    

    完整连接过程(包含检查是否正确连入)分为以下几步:

    1. 一旦启动Kernel(执行make sim指令),Kernel就已经完成evec.Sinit.S中代码的运行(此处包含①各种必要的初始化,②通过模拟器QEMU设置好的host为本机、端口为6666的TCP服务器以等待Term的连接并配置好串口,③以及向串口写入定义为monitor_version的文本数据"MONITOR for RISC-V - initialized."——注意此处只是向串口写入,Term并没有读取该数据,Term甚至还没有连接上Kernel呢),并进入shell.SSHELL函数中,第一行的jal READ_SERIAL跳转到utils.S的读操作符函数中,忙等待Term在连接上Kernel之后通过串口向Kernel发送操作符W,好让Kernel通过串口把XLEN写给Term(注意此处Kernel一直卡在函数READ_SERIAL中的子函数.TESTR进行轮询检测验证,因为Term此时还没给Kernel发送任何数据,因此串口检测地址的可读位始终为0,Term甚至还没连接上Kernel呢);
    2. 反观Term,由用户在终端敲入python3 term.py -t 127.0.0.1:6666命令,Term在term.pymain函数中通过args = parser.parse_args()从命令行读入args.tcp = 127.0.0.1:6666 ,并作为参数host_port传入InitializeTCP(host_port)函数;
    3. InitializeTCP函数中,若用户输入的host_port不满足hostport的正则表达式,则直接返回到main函数中输出print('Failed to establish TCP connection')退出程序,也就是完全没有执行任何连接相关操作;
    4. 若用户输入的host_port在语法上是合法的,则Term创建tcp_wrapper类的ser = tcp_wrapper()对象,在终端输出"connecting to 127.0.0.1:6666...",并调用socket模块(Python的stdlib库自带模块)的ser.connect(host, int(port))连接函数进行连接与检查是否正确连入的操作(若连接成功则顺序执行print("connected")输出连接成功,若连接失败则由socket库的connect函数提供报错信息如ConnectionRefusedError: [Errno 61] Connection refused并退出程序);
    5. 连接成功后(终端输出"connected"),Term跳转到Main函数,通过inp.read从串口中读入步骤1.中Kernel往串口中写入的monitor_version文本数据,并打印在终端;
    6. 然后Term再向串口outp.write(b'W')写入操作符W,如步骤1.所提到的,Kernel在READ_SERIAL函数中的子函数.TESTR轮询进行检测验证,终于验证到了可读位为1(因为Term终于向Kernel发送了数据W,串口检测地址的可读位从0变成1),顺序执行读取操作符W,并返回到SHELL函数中,执行li a0, XLENjal WRITE_SERIAL,即将机器字长XLEN(版本号)通过串口写给Term,该功能作为检查是否正确连入的标志,之后Kernel将等待Term从串口发来的Term命令;
    7. Term读取串口xlen = ord(inp.read(1))获得机器字长并输出到终端"running in 32bit, xlen = 4",随后进入MainLoop函数由用户输入Term命令,后续再与Kernel交互,不再阐释。
This post is licensed under CC BY 4.0 by the author.
Trending Tags