NJU-ICS2021 | PA0&PA1实验小结

注意
本文最后更新于 2022-06-26,文中内容可能已过时。

这里简单记录一下自己做PA实验的体会,既是及时做个整理总结,也希望能够帮助到后续做PA实验的同学。

首先声明本人非南大学生,也并非科班学生,之前学过一点CSAPP,了解过部分汇编、计组相关内容,如果没学过相关课程的话,可以看一下中国大学 MOOC 上,南大的计算机系统基础课程,总共有 5 门课,前三门是基础知识,后面两门结合实践,我这里主要学了第 5 门课程,也就是计算机系统基础(五):x86模拟器编程实践

PA实验也是围绕这个课程开展的,不过这门课在中国大学 MOOC 上可能未开放(有时候因为学期结束了,不能上),所以另外推荐授课老师的B站:我是汪犬人的个人空间_哔哩哔哩_Bilibili,可以从老师主页看到相关录屏课程,然后就是PA实验代码和框架代码:

Guide的仓库及其镜像地址:

实验框架代码及其镜像地址:

实验指导上写的很详细,过多内容就不赘述,这里主要记录下自己做实验遇见的一些坑和一些思考

课程推荐的是 Debian ,如果从官网上可以比较难找课程推荐的版本(我最开始就没有找到,也是看了MOOC上讨论区才找到),具体地址如下:

https://www.debian.org/releases/buster/debian-installer/

这是课程对应的Debian 10 buster版本,其他的环境配置跟着实验指导一步一步来就好,暂时没遇见什么问题

PA1主要是实现数据在 NEMU 模拟器上的表示和运算,包括整数和浮点数的表示、存储和运算

PA1-1 需要需要数据在计算机内部存储形式,也就是实现寄存器,这里框架代码已经给出来了,利用 C 语言中的 union ,看一下这个答案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
typedef struct 
{
	union {
		union {
			union {
				uint32_t _32;
				uint16_t _16;
				uint8_t _8[2];
			};
			uint32_t val;
		} gpr[8];
		struct { // do not change the order of the registers
			uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi;
		};
	};
} CPU_STATE;

这里用到了 union 联合体,为什么用 union 就可以实现需求呢?

具体需求如下: 我们希望以cpu.eax形式访问的和以cpu.gpr[0]._32形式访问的是同一个模拟寄存器,同时cpu.gpr[0]._16能够访问到cpu.eax的低16位也就是ax寄存器cpu.gpr[0]._8[0]访问到cpu.eax的低8位也就是al寄存器等。

这里相当于是模拟了 i386 中寄存器的结构

https://silas-py-oss.oss-cn-chengdu.aliyuncs.com/img/20220516201641.png

要理解上面为什么可以实现,首先需要找到 union 联合体的在内存储存方式

union 相当于一个结构,它的所有成员相对于基地址的偏移量都为0,此结构空间要大到足够容纳最“宽”的成员 ——摘自K&R C语言p130

我的理解,一个 union 宽度是它的成员中最大的那个,其他的成员和它存放在同一个地址,相对于起始地址偏移量都为0

举个例子:加入现在一个union 有两个成员,如下:

1
2
3
4
5
union {
	char a;
	short b;
	int c;
}u;

我们可以知道这个 union 所需要的字节数为一个 int 也就是4个字节,假设现在c = 0x12345615,并且存放在0x80000000这个地址开始位置,那么具体存放内容如下(假设这里为小端存放)

https://silas-py-oss.oss-cn-chengdu.aliyuncs.com/img/20220516204024.png

此时,对于 u.a = 0x15,而 u.b = 0x5615,相比较起始地址偏移量都为0,char 占用一个字节,则 a 即为 0x15,short 占用两个字节,b 即为 0x5615 了,下面是C程序验证我们的结论

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

union {
	char a;
	short b;
	int c;
}u;

int main() {
	u.c = 0x12345615;
	
	printf("u.a = 0x%x\n", u.a);
	printf("u.b = 0x%x\n", u.b);
	printf("u.c = 0x%x\n", u.c);
	return 0;
}

/**
silas@Silas-PC:~/ics4$ gcc -g -o union union.c && ./union
u.a = 0x15
u.b = 0x5615
u.c = 0x12345615
**/

上面提到一个小端存放,这个在实验指导书上提到:

x86采用小端方式存储数据,它规定了超过一个字节的数据的存储规则:低有效字节放在低地址,高有效字节放在高地址。

这样就比较好理解,为什么下面 union 这个可以模拟出i386 对应寄存器的结构

1
2
3
4
5
union {
	uint32_t _32;
	uint16_t _16;
	uint8_t _8[2];
};

_32 在内存中宽度最大,也对应32位的寄存器,例如 eax,而_16 为16为,由于是小端存放,最低有效字节放在低地址,取出16位即为这个值的后16位,也就相当于是寄存器的后16位,同理可以理解 _8[2] 对应的过程

注意另外一点,这里将_32 放在了前面,而不是 _8[2] 根据前面的分析,这里应该也是可以的,但是 union 另外一个特点就是初始化的时候,只能用第一个成员类型的值进行初始化,所以将最宽的成员放在第一位,防止初始化时丢失了后面的精度

下面是代码验证:

 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
#include <stdio.h>

union u_tag {
	char a;
	short b;
	int c;
}u;

int main() {
	u.c = 0x12345615;
	
	printf("u.a = 0x%x\n", u.a);
	printf("u.b = 0x%x\n", u.b);
	printf("u.c = 0x%x\n", u.c);

	union u_tag u2 = {0x12345615};
	printf("u.a = 0x%x\n", u2.a);
	printf("u.b = 0x%x\n", u2.b);
	printf("u.c = 0x%x\n", u2.c);

	return 0;
}
/* 
silas@Silas-PC:~/ics4$ gcc -g -o union union.c && ./union
union.c: In function ‘main’:
union.c:16:20: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘305419797’ to ‘21’ [-Woverflow]
   16 |  union u_tag u2 = {0x12345615};
      |                    ^~~~~~~~~~
u.a = 0x15
u.b = 0x5615
u.c = 0x12345615
u.a = 0x15
u.b = 0x15
u.c = 0x15
*/

可以看到由于 union 第一个成员位 char a,只能存放 1 个字节,初始化后会损失精度

至此,差不多解释了为什么使用 union 可以实现模拟寄存器的功能

这里需要对整数的各个运算来进行模拟,其实实现中比较好实现,直接用 C 语言中对应操作即可,比如说 alu_add 的实现,结果 res = src + dest,包括后续的加减、移位、逻辑和乘除运算基本上都是可以这样来实现

但是最为关键的是需要对标志位进行设置,这就需要结合 i386 手册了(实验指导上给了相应链接 i386 手册:

PA中需要设置的是CF、PF、ZF、SF 和 OF (AF不需要设置)

  • CF:进位标志符,这个主要看运算结果是否超过了最高的有效位,相当于是进位了,或者是看运算是否向高位借位了

这里可以类比十进制,比如说89+83,相加的时候 3 + 9 = 12 > 10 这里要进位,而CF标志位则是看最高位是否发生了进位,或者是两个数相减的时候 如91-87,个位上 1 - 7 不够减,所以需要向十位借位,这种情况 CF 会被设置为 1

  • PF:奇偶标志位,看结果中所有bit 位中 1 的个数是否为偶数,偶数 PF = 1,奇数 PF = 0
  • ZF:零标志位,看结果是否为 0,如果结果为0 则ZF = 1,否则 ZF = 0
  • SF:符号标志位,看结果是否为负,如果为负,SF = 1,否则 SF = 0 (PA中由于模拟的是无符号数,所以看最高有效位是否为 1 来判断结果是否为负数)
  • OF:溢出标志位,计算中如果发生溢出则设置为1,溢出包括两种,一个是结果值太大,超过了能表示的最大范围就发生了溢出,另外一个是结果值太小,小于能表示的最小值就发生了溢出

这里注意 CF 和 OF 的区别: 在王爽汇编语言P218书中写到,CF 是针对无符号运算的标志位,OF 是针对有符号的标志位,同样位数情况下无符号的范围,这样就会在解释两个数值相加的时候,如果解释为无符号可能不会产生溢出,而解释为有符号会产生溢出

在PA中模拟的是无符号数,所以两者可能会有区别?其实我自己之前实现的时候,很多时候两个标志位实现逻辑就很类似

另外就是 PF、ZF 和 SF 这三个其实对于每个运算都是一样的,可以直接代码复用

这个主要实现IEEE 754 单精度浮点数(float)的运算,框架代码已经给出大部分内容了,只需要根据实验手册进行补充即可,最重要的应该就是实现 internal_normalize() 规格化函数

其实这部分代码框架也都设计好了,不过开始没反应过来,自己花了不少时间没用在刀刃上,下面自己在做实验的时候遇见几个坑点:

  1. 第一个是粘位的处理

最开始我就是判断每次移位之前,判断(sig_grs » 1) 是否大于 1,开始看错了以为是舍入位左边有非零数字,粘位就为1,后来仔细看了代码框架,发现原来的粘位处理操作是 sticky = stickt | (sig_grs & 0x1); 这才发现问题,另外就是注意粘位判断粘位是否为1,要在移位之前进行判断。

  1. 出现溢出或零的情况 最开始处理溢出或者零的时候,我把sig_grs 直接设置为 P_ZERO_F 或者 P_INF_F 等等这些,然后发现不对,这里应该直接返回,同时要根据符号位,判断正负的情况

  2. 处理舍入的时候 最开始没弄懂 GRS 位是怎么舍入的,直接将 sig_grs += 0x4 了,我以为只要 GRS 大于0x4 然后就直接进位,后面发现不是怎么回事,这里应该是浮点数默认的向偶数位舍入(这里参考了CSAPP p83),其实就是我们经常听到的四舍六入五成双,举个例子:

对于十进制(这里保留一位):

  • 10.04 -> 最后一位是4,直接丢弃,最后结果是 10.0
  • 10.06 -> 最后一位是6,大于5直接进位,结果是10.1
  • 10.05 -> 最后一位是5,要让进位的成为偶数,5前面的0已经是偶数了,所以这里直接丢弃5,结果是10.0
  • 10.15 -> 这里和上面就不一样了,5前面是奇数,所以要进位,结果是10.2

对于二进制,这里 GRS 是3位,所以取半即 0x4(0b100)

  • 如果GRS 大于 0x4 直接进位
  • 如果 GRS 小于 0x4 直接丢弃后面尾数
  • 如果 GRS 等于 0x4,这里需要判断前面一位是否为偶数,偶数直接丢弃 GRS,奇数进行进位

舍入想清楚就基本上很容易解决了 (重新看了MOOC视频,发现老师这里讲的挺清楚的,不过自己最开始看了下视频,如何根据实验手册做的,就把视频内容给忘记了,看来还是需要好好看一下视频的内容)

在规格化过程中,遇见这种情况,两个非规格化数运算后得到了一个规格化数,指数需要加 1,即 exp = exp + 1

简单分析下为什么要加1,首先 exp == 0,表示是两个非规格化数相加减(因为加减过程中会对阶,两个数应该同时为规格化或者非规格化数),非规格化数基本形式是 0.bbbb…(小数点后面26个位),两个非规格化数相加为规格化数的情况就是相当于,第一位小数都为1,即 0.1bbb… 和 0.1bbb… 相加的情况,此时进位,结果为 1.bbbb…,结果上是一个规格化数,exp 应该等于 1,相当于是右规了一位

PA1的实验大概花了13.5h,感觉这部分内容在框架代码的基础上,查阅i386手册基本上都能实现相应的功能,最大的感受是在浮点数运算的实现时候,对浮点数规格化、舍入等有了更深入的了解