0%

你好呀!

这里记录我的学习经历和内容

希望能够帮到你们

: )

标签对应使用的编程语言

分类中查看对应的板块

拖了大半个月,终于重新开始了一生一芯计划的学习。接下来要完成的部分是,一个有趣的项目

F6 功能完备的迷你RISC-V处理器

我们先前实现的简单的ISA只能实现一些简单的命令,但是并不具有完备性,导致很多功能我们难以正常实现,所以这一次我们要实现的是一个功能完备的RISC-V处理器,不过在此之前,我们需要更加深入的了解它。

迷你RISC-V指令集

在了解RISC-V指令集的细节之后,我们可以指定一个自己的minirv的ISA的规范

  • PC初值为0
  • GPR数量和RV32E一致(16)
  • 支持以下八条指令addaddiluilwlbuswsbjalr
  • 其他的ISA细节参考RV32I

F6.1.1 RTFM

查阅RISC-V手册的目录, 你发现RV32I在哪一章进行介绍? 尝试在该章节中查阅RV32I的相关内容, 回答下列问题:

  1. PC寄存器的位宽是多少?
  2. GPR共有多少个? 每个GPR的位宽是多少?
  3. R[0]和sISA的R[0]有什么不同之处?
  4. 指令编码的位宽是多少? 指令有多少种基本格式?
  5. 在指令的基本格式中, 需要多少位来表示一个GPR? 为什么?
  6. add指令的格式具体是什么?
  7. 还有一种基础指令集称为RV32E, 它和RV32I有什么不同?

关于RV32I的介绍在第二章中可以看到

image

接下来按顺序回答这些问题:

  1. 在RV32I中PC的位宽是32bit
  2. 共有32个GPR,每个位宽均为32bit
  3. R[0]中存放的数值恒为0,在我们先前的sISA中的R[0]只是我们的第一个寄存器
  4. 指令编码的位宽是32,一共有四种指令格式
  5. 在指令的基本格式中需要5位来表示一个GPR,因为一共有32个寄存器
  6. add指令是一种R-Type
  7. RV32E是RV32I的精简版本,例如寄存器的数量被减少到了16个

只有两条指令的minirv处理器

我们先从两条指令开始实现addijalr

F6.2.1 RTFM(2)

查阅RISC-V手册, 找到addi指令的编码和相应的功能描述. 在第34章RV32/64G Instruction Set Listings中有一些指令表, 可以帮助你查阅addi指令的编码.

image

我们可以在这里找到对addi的说明。

F6.2.2 RTFM(3)

为了了解RISC-V对存储器的若干约定, 你需要阅读RISC-V手册第1.4节的第一段, 从ISA的层面了解存储器的规格, 尤其是宽度的定义.

根据我们先前的ISA规则,我们的地址空间大小应该是一个按字节寻址地址空间,大小为232.

F6.2.3 RTFM(4)

查阅RISC-V手册, 找到jalr指令的编码和相应的功能描述.

image

F6.2.4 实现两条指令的minirv处理器

理解addijalr指令的功能后, 根据你之前设计sISA处理器的经验, 尝试设计一个支持这两条RISC-V指令的处理器.

由于GPR需要进行较多的连线工作, 为了减轻大家的负担, 我们准备了一个预先完成大量连线工作的GPR子模块. 下载后, 通过Logisim打开文件GPR.circ, 即可看到GPR的电路设计, 你可以整体选择这个电路后, 通过复制和粘贴将其加入到你工程中. 不过, 这个电路并没有实现GPR的完整功能, 你需要根据你对GPR的理解完善它.

为了帮助你对处理器进行简单的测试, 我们准备了如下测试程序. 在下面的汇编指令中, GPR采用了ABI助记符(mnemonic), 名称更能反映其功能, 例如, 用zero表示编号为0的GPR. 汇编指令中还有a0ra, 你可以通过解析相应的指令编码得知对应的GPR编号.

1
2
3
4
5
6
7
8
9
10
11
00000000 <_start>:
0: 01400513 addi a0,zero,20
4: 010000e7 jalr ra,16(zero) # 10 <fun>
8: 00c000e7 jalr ra,12(zero) # c <halt>

0000000c <halt>:
c: 00c00067 jalr zero,12(zero) # c <halt>

00000010 <fun>:
10: 00a50513 addi a0,a0,10
14: 00008067 jalr zero,0(ra)

尝试通过指令集的状态机理解这个程序的功能. 理解后, 将程序其放置在ROM中, 并尝试运行你的处理器, 然后检查处理器的运行结果是否符合预期.

首先,要找到两个指令的具体格式:

image

首先做出一个根据opcodefunc3判断当前指令的逻辑:

image

接着要明确这两个指令的用途:

  • addi rd,rs1,imm12: R[rd] = R[rs1] + extend(imm)
  • jalr rd,rs1,imm12: R[rd] = PC + 4; PC = R[rs1] + extend(imm);

然后实现相关的功能即可,实现如下:

image

这是执行指令后的寄存器状态,也符合我们的预期:

image

F6.2.5 测试addi指令

在上述测试程序中, addi指令的立即数比较小. 为了测试符号扩展的实现是否正确, 你需要让处理器执行一些立即数为负数的addi指令. 尝试编写若干条这种类型的addi指令, 并放置到ROM中, 检查你的实现是否正确.

我们只需要将上一次的测试指令改成:

1
2
3
4
5
6
7
8
9
10
11
00000000 <_start>:
0: 01400513 addi a0,zero,20
4: 010000e7 jalr ra,16(zero) # 10 <fun>
8: 00c000e7 jalr ra,12(zero) # c <halt>

0000000c <halt>:
c: 00c00067 jalr zero,12(zero) # c <halt>

00000010 <fun>:
10: ff650513 addi a0,a0,-10
14: 00008067 jalr zero,0(ra)

然后看运行的结果是否符合我们的期望:

确实没问题,x10从原来的20+10变成了20-10

实现完整的minirv处理器

接下来进一步实现其他的指令,由于我们还需要其他的内存空间用来存储数据,所以我们还需要引入RAM作为额外的存储空间

F6.3.1 实现完整的minirv处理器

实现addlui指令. 实现后, 尝试编写一些简单的指令序列放置到ROM中, 来初步检查你的实现是否正确.

首先找到相关的规范:

由于之后的取指会越来越复杂所以我们把取值的部分封装起来,不过到目前为止,我们也能注意到,无论是哪种指令格式,我们都需要func3rdopcode。所以我们首先需要一个根据opcodefunc3生成对应指令信号的选择器:

然后我们从指令中提取出我们需要的数据字段和信号:

接下来通过组合这些信号和数据,进而实现我们的简单处理器,现在我们需要明确两个指令的行为:

  • lui rd,imm20: R[rd] = imm20 << 12
  • add rd,rs1,rs2: R[rd] = R[rs1] + R[rs2]

我们添加一些线来实现这些功能:

然后我们可以编写一个测试用例来运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
00000000 <_start>:
0: 01400513 addi a0, zero, 20 # a0 = 20
4: 000015b7 lui a1, 0x1 # a1 = 0x1000
8: 00b50533 add a0, a0, a1 # a0 = 0x1014
c: 018000e7 jalr ra, 24(zero) # 跳转到 0x18 <fun>
10: 014000e7 jalr ra, 20(zero) # 跳转到 0x14 <halt>

00000014 <halt>:
14: 01400067 jalr zero, 20(zero) # 跳转到自身 0x14

00000018 <fun>:
18: ff650513 addi a0, a0, -10 # a0 = 0x100A
1c: 000015b7 lui a1, 0x1 # a1 = 0x1000
20: 00b50533 add a0, a0, a1 # a0 = 0x200A
24: 00008067 jalr zero, 0(ra) # 返回

得到期望的结果:

F6.3.2 RTFM(5)

查阅RISC-V手册, 找到lw, lbu, swsb这4条指令的编码和相应的功能描述. 手册中还介绍了EEI和不对齐访存的相关内容, 目前暂不使用, 因此你可以忽略这些内容.

接着找然后,明确每个指令需要产生的信号和数据:

由于数据和信号的提取我们已经完成了,所以我们只需要简单的设计电路就好了。首先需要明确这几个指令的功能分别是什么:

  • lw rd,rs1,imm12: R[rd] = MEM[rs1 + offset][31:0]
  • lbu rd, offset(rs1): R[rd] = 零扩展( MEM[rs1 + offset][7:0] )
  • sb rs2, offset(rs1): MEM[rs1 + offset][7:0] = R[rs2][7:0]
  • sw rs2, offset(rs1): MEM[rs1 + offset][31:0] = R[rs2][31:0]

这里还应该注意到我们需要引入内存了

F6.3.3 实现完整的minirv处理器(2)

实现lwsw指令, 然后编写一些简单的指令序列放置到ROM中, 来初步检查你的实现是否正确. 特别地, 你可以用鼠标右键点击RAM组件, 然后通过Edit Contents在RAM中放置一些数据, 来帮助你测试访存指令的行为.

这里编写一个测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
00000000 <_start>:
0: 00100093 addi x1, zero, 1 # x1 = 1
4: 00200113 addi x2, zero, 2 # x2 = 2
8: 00300193 addi x3, zero, 3 # x3 = 3
#c: 00400213 addi x4, zero, 4 # x4 = 4

# 写入不同地址
10: 00102023 sw x1, 0(zero) # MEM[0] = 1
14: 00202223 sw x2, 4(zero) # MEM[4] = 2
18: 00302423 sw x3, 8(zero) # MEM[8] = 3
#1c: 00402623 sw x4, 12(zero) # MEM[12] = 4

# 读取验证
20: 00002503 lw x10, 0(zero) # x10 = MEM[0] = 1
24: 00402583 lw x11, 4(zero) # x11 = MEM[4] = 2
28: 00802603 lw x12, 8(zero) # x12 = MEM[8] = 3
#2c: 00c02683 lw x13, 12(zero) # x13 = MEM[12] = 4

# 跳转到 halt (使用 jalr)
30: 03400067 jalr zero, 52(zero) # 跳转到 0x34

00000034 <halt>:
34: 03400067 jalr zero, 52(zero) # 跳转到自身 0x34

可以看到最终的运行结果是符合我们的期望的:

F6.3.4 实现完整的minirv处理器(3)

实现lbu指令, 并通过一些指令序列来初步检查你的实现是否正确.

Hint: 你可以先在RAM中放置一个4字节的数据0x12345678, 并通过lw指令读出它(假设数据位于内存地址a), 确认读出结果为0x12345678. 然后通过若干条lbu指令分别从内存地址a, a+1, a+2, a+3中读出数据, 我们预期这些lbu指令分别读出0x78(对应地址a), 0x56, 0x34, 0x12(对应地址a+3)

接下来需要实现字节的内存写入功能,由于我先前实现写入字节控制的实现比较粗糙,所以这里我们需要重新修改这一部分以实现字节的写入/读取:

这一部分的信号处理需要更新以支持我们的字节读取。

我们的测试程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
00000000 <_start>:
# 在地址 0 存入测试数据 0x12345678
0: 123450b7 lui x1, 0x12345
4: 67808093 addi x1, x1, 0x678 # x1 = 0x12345678
8: 00102023 sw x1, 0(zero) # MEM[0] = 0x12345678

# 测试 lw (funct3 = 010)
c: 00002503 lw x10, 0(zero) # x10 = 0x12345678

# 测试 lbu (funct3 = 100)
10: 00004583 lbu x11, 0(zero) # x11 = 0x78
14: 00104603 lbu x12, 1(zero) # x12 = 0x56
18: 00204683 lbu x13, 2(zero) # x13 = 0x34
1c: 00304703 lbu x14, 3(zero) # x14 = 0x12

# 结束循环
20: 02400067 jalr zero, 36(zero) # 跳转到 0x24
00000024 <halt>:
24: 02400067 jalr zero, 36(zero) # 自循环

在实现的过程中,我发现lbu的字节选择是针对RAM输出的四字节来进行处理的。一开始总是读出双字而不是单字节,搜了之后才知道:

这个是程序执行的结果:

F6.3.5 实现完整的minirv处理器(4)

实现sb指令, 并通过一些指令序列来初步检查你的实现是否正确.

Hint: 你可以先在RAM中放置一个4字节的数据0x12345678, 并通过lw指令读出它(假设数据位于内存地址a), 确认读出结果为0x12345678. 然后通过若干条sb指令分别往内存地址a+3, a+2, a+1, a+0 中写入0x90(对应地址a+3), 0xab, 0xcd, 0xef(对应地址a); 写入之前, 可以通过addi指令配合零号寄存器, 来向目的寄存器写入一个立即数, 从而实现sISA中li指令的效果. 最后再次通过lw指令读出新数据, 我们预期读出结果为0x90abcdef.

有了刚刚的教训我们可以很快的实现我们的字节写入指令。

这个指令的处理最为复杂,sb需要先指定四字节中的偏移值,然后将要复写的数据移动到对应的数据位置上才能实现覆盖,可以通过多路复用器和移位器来实现。

细节如下:

我们可以使用以下程序进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
00000000 <_start>:
0: 123450b7 lui x1,0x12345 # x1 = 0x12345000
4: 67808093 addi x1,x1,0x678 # x1 = 0x12345678
8: 00102023 sw x1,0(zero) # MEM[0] = 0x12345678
c: 00002503 lw x10,0(zero) # x10 = 0x12345678 (验证)

10: 09000593 addi x11,zero,0x90 # x11 = 0x90
14: 0ab00613 addi x12,zero,0xab # x12 = 0xab
18: 0cd00693 addi x13,zero,0xcd # x13 = 0xcd
1c: 0ef00713 addi x14,zero,0xef # x14 = 0xef

20: 00b001a3 sb x11,3(zero) # MEM[3] = 0x90
24: 00c00123 sb x12,2(zero) # MEM[2] = 0xab
28: 00d000a3 sb x13,1(zero) # MEM[1] = 0xcd
2c: 00e00023 sb x14,0(zero) # MEM[0] = 0xef

30: 00002503 lw x10,0(zero) # x10 = 0x90abcdef

34: 03800067 jalr zero,56(zero) # 跳转到 0x38
38: 03800067 jalr zero,56(zero) # 无限循环

测试结果十分准确

F6.3.6 在minirv处理器上执行C程序

分别加载并运行mem.hexsum.hex. 运行指定时间后, 检查处理器的状态, 若PC位于halt函数附近, 且a0寄存器为0, 则说明程序运行正确. 两个程序的预期运行时间如下:

  • mem.hex - 6000周期
  • sum.hex - 6000周期

如果你发现运行指定时间后, PC位于其他位置, 或a0寄存器不为0, 则说明程序运行错误. 但由于这个过程中已经运行了上千条指令, 很难发现是哪一条指令执行出错, 因此我们还是推荐你做好上一道必做题的验证工作, 通过一些简单的指令序列来检查你的处理器实现是否正确.

已完成:

为minirv处理器添加图形显示功能

现在已经实现了基础的minirv处理器,现在我们尝试为它设置一个屏幕:

  • Cursor(光标) - No Cursor
  • Reset Behavior(重置行为) - Asynchronous
  • Color Model(颜色模式) - 888 RGB (24 bit)
  • Width(宽度) - 256
  • Height(高度) - 256

根据上述的配置,我们的一个像素需要三个字节来表示颜色,但是为了更好的对齐,这里我们使用四个字节来表示一个像素,所以整个屏幕我们需要256*256*4B = 256KB的空间。而这里,我们规定内存地址空间[0x20000000,0x20040000]映射我们的屏幕显存。由于我们的实际用到的地址空间并没有这么大,所以我们需要通过检测地址是否在这个空间,从而设置isVGA信号,同时我们还约定,只有store指令可以向屏幕中写入像素。

F6.4.1 为minirv处理器添加图形显示功能

在处理器的数据通路上添加RGB Video组件. 我们约定, 屏幕像素对应的存储区域是[0x20000000, 0x20040000).

添加组件后, 加载并运行vga.hex程序. 这个程序的预期运行时间是628000周期, 你可能需要等待1~2分钟. 如果你的实现正确, 你将看到程序运行结束时, RGB Video组件中显示”一生一芯”logo.

寒假很快就过去了。农历的新年也过去了几天,还有一周多就要开学了。这段时间发生了很多事情,我感觉自己的心情每天都在成长,都在有所变化。

首先从放假初期定下的几个目标开始说起,意料之外的一个也没做到。我以为自己至少会做点什么,可是我什么都没做到,放假了每天就是玩,看电视,然后接着玩,偶尔运动,但也不多。我想为什么会这样呢,原因就在于我一开始给自己的底线和期望都太低了。在定下目标的初期,我就认为只要做到其中的几点就好了,可是为什么不想着全部做到呢。我的拖延和懒惰又一次成功的阻挠了我。这让我明白们永远不要给自己找借口,对自己的要求一定不要留下退路。

然后是我的心情吧,我看了很多的电视和动漫,感觉心底很开心。让我想起以前初中和高中的时候,追着自己喜欢的动漫,玩着自己想玩的游戏,好怀念这种感觉。我感觉自己是不是好久没有这样放纵的做自己想做的事情了,好怀念呀。我寒假也玩了各种各样的游戏,也不像以前只是玩单机了,我在网上认识了一个和我一起打怪猎的好兄弟。我还学了很多新的招式,我也会花好多时间去钻研各种技术和配装,好久没有这么认真的玩一场游戏了。

去年很长一段时间都因为失恋而迷茫吧,一直不知道自己接下来该怎么办。可是时间果然是最好的解药,我也好不容易放下了,我丢弃了很多幻想,这段时间也是,一直做着自己想做的事情,重新找回了实感。接下来的时间我想为自己而活,去争取我想要做到的事情,去体验我想要的感受。

从小到大都是游戏和动漫陪着我,但是现在不一样,我还有很多的事情可以去做。我想试试各种各样的事情,我也不能停止不前,我也想做出各种改变,遇见各种各样的人和事。这就是我这段时间得到的感受吧。回归于自己。就像Linus说的一切都是Just for fun.

2026年也要加油,不能气馁。

F5 支持数列求和的简单处理器

我们已经学会了基本的数字电路逻辑和状态机的思想,现在我们要着手实现一个简单的处理器。首先我们需要明确接下来要实现的指令集sLSA和处理器的细节:

  • PC位宽为4位, 初值为0
  • GPR有4个, 位宽均为8位
  • 支持如下3条指令:
1
2
3
4
5
6
7
8
 7  6 5  4 3   2 1   0
+----+----+-----+-----+
| 00 | rd | rs1 | rs2 | R[rd]=R[rs1]+R[rs2] add指令, 寄存器相加
+----+----+-----+-----+
| 10 | rd | imm | R[rd]=imm li指令, 装入立即数, 高位补0
+----+----+-----+-----+
| 11 | addr | rs2 | if(R[0]!=R[rs2]) PC=addr bner0指令, 若不等于R[0]则跳转
+----+----------+-----+

接下来完成实验的任务:

F5.1.1 实现取指功能

通过多路选择器实现一个ROM, 并在其中存放数列求和的指令序列, 然后通过PC寄存器取出指令. 你需要根据你的理解来确定ROM的规格.

先把数列求和的指令翻译成机器码,以1+2+3+...+10为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0: li r0, 10   
1: li r1, 0
2: li r2, 0
3: li r3, 1
4: add r1, r1, r3
5: add r2, r2, r1
6: bner0 r1, 4
7: bner0 r3, 7

10001010
10010000
10100000
10110001
00010111
00101001
11010001
11011111

然后将数据硬编码到我们的ROM中,并通过PC进行取值:

图中左上是PC寄存器,负责提供地址。右边这一堆是编码的ROM存储电路,可以将我们的数列求和程序读取出来

image.png

F5.1.2 实现GPR及其写入功能

在寄存器的基础上搭建一个RAM, 从而实现GPR的写入功能. 你需要根据你的理解来确定RAM的规格.

我们的GPR有4个寄存器,每个寄存器存储八位的数据。可以简单设计如下:

image.png

F5.1.3 实现仅支持li指令的sCPU

根据上文, 用数字电路实现li的指令周期涉及的各个部件, 并将它们连接起来. 实现后, 尝试让sCPU执行数列求和程序中的前几条li指令, 并观察电路中GPR的状态是否与ISA的状态一致.

成功的从ROM中向寄存器写入了值:

image.png

F5.2.1 添加add指令

根据上文, 在sCPU中添加add指令. 实现后, 尝试让sCPU继续执行数列求和程序中的几条add指令, 并观察电路中GPR的状态是否与ISA的状态一致.

经过上一个实验,现在我们的寄存器已经能够成功的接受写入了。但是为了实现进一步的指令支持,我们需要将我们的GPR优化并封装起来,我们需要实现以下端口:

  • 第1个读端口: raddr1(读地址), rdata1(读数据)
  • 第2个读端口: raddr2, rdata2
  • 写端口: waddr(写地址), wdata(写数据), wen(写使能), clk(时钟)
image.png

这样我们就能满足add指令,同时读取两个源操作数和写入一个寄存器的操作了。

但是再次之前我们还需要进一步实现译码功能,从而将我们的addli逻辑分离,不同的指令需要不同的信号:

  • li:
    • 需要waddrimm作为输入,需要写使能信号开启
  • add:
    • 需要raddr1raddr2作为输入
    • 接受rdata1rdata2,做加法输入到wdata
    • 根据wdatawaddr和写使能信号,更新寄存器

综上,我们可以规划出我们的电路图,以完成前6条指令的执行:

image.png

以下是执行一轮前六条指令后的寄存器状态:

image.png

F5.2.2 添加bner0指令

根据上文, 在sCPU中添加bner0指令. 实现后, 尝试让sCPU执行完整的数列求和程序, 如果你的实现正确, 你应该能看到PC最终为7, 且在某GPR中存放求和结果55.

添加bner0我们首先分析他需要哪些输入和信号:

  • 首先需要addr用于改变PC的值,也就是说我们现在需要一个机制能支持我们改变PC的内容
  • 同时需要读取r0r2的值进行比对,如果不同则跳转

这两个问题都可以简单的通过多路复用器的思路来解决:

image.png

我们的求和程序运行的最终情况如下。可以看到r2 = 0x37 = 55符合我们的预期:

image.png

F5.2.3 和数列求和电路进行对比

在学习数字电路时, 有一道必做题要求你通过寄存器和加法器, 计算出1+2+...+10的结果. 现在你用sCPU完成了同样的计算, 尝试对比两个方案各有什么优点和缺点.

先前的数字求和电路,是通过设计电路的模式来实现”程序”的运行,那种行为类似于程序中的硬编码,比较死板,而且不方便改动。我们每想运行一个程序都需要重新设计一个电路。

但是想这种通过操控寄存器的方法,明显为我们带来了更好的可拓展性,我们可以通过现有的三个指令,运行各种加法程序。我们只需要对指令的内容进行改动就好了,这给我们带来了更好的可拓展性。所以我认为这是一种进步。不过相应的,为了满足电路能够运行指令,我们的设计也会更加的复杂,无法像先前的电路一样轻便。

F5.3.1 计算10以内的奇数之和

编写一段指令序列, 计算10以内的奇数之和, 即1+3+5+7+9. 然后尝试用你设计的sCPU指令这段指令序列, 检查运行结果是否符合预期

首先我们需要为其编写相应的指令:

1
2
3
4
5
6
7
8
0: li r0, 11   
1: li r1, 1
2: li r2, 0
3: li r3, 2
4: add r2, r2, r1
5: add r1, r1, r3
6: bner0 r1, 4
7: bner0 r3, 7

然后将程序编写进我们的ROM中,执行得到结果如下(R2 = 0x19 = 25):

image.png

F5.3.2. 添加新指令

尝试为sISA添加一条新指令out rs, 执行该指令后, 会将R[rs]以十六进制的形式输出到七段数码管. 你可以自行决定这条指令的编码.

然后, 在sCPU中实现out指令, 并修改数列求和程序, 使得在计算出结果后, 能在七段数码管中显示计算结果.

首先向我们的ISA中添加out的格式

1
2
3
4
5
6
7
8
9
10
 7  6 5  4 3   2 1   0
+----+----+-----+-----+
| 00 | rd | rs1 | rs2 | R[rd]=R[rs1]+R[rs2] add指令, 寄存器相加
+----+----+-----+-----+
| 10 | rd | imm | R[rd]=imm li指令, 装入立即数, 高位补0
+----+----+-----+-----+
| 11 | addr | rs2 | if(R[0]!=R[rs2]) PC=addr bner0指令, 若不等于R[0]则跳转
+----+----------+-----+
| 01 | none | rs2 | R[rs2] -> 七段数码管 out指令, 将R[rs2]的值输出到七段数码管
+----+----------+-----+

这个指令只需要读取rs2作为输入就可以了,所以我们添加一个显示内容的电路,就可以完成啦:

image.png

F4 计算机系统的状态机模型

处理器的组成和工作原理

F4.1.1 继续执行上述指令

尝试继续执行指令, 记录寄存器的状态变化过程. 你发现执行到最后时, 处理器处于什么样的状态? 上述数列的求和结果在哪个寄存器中?

1
2
3
4
5
6
7
8
0: li r0, 10   # 这里是十进制的10
1: li r1, 0
2: li r2, 0
3: li r3, 1
4: add r1, r1, r3
5: add r2, r2, r1
6: bner0 r1, 4
7: bner0 r3, 7

状态机视角的寄存器状态变化:

1
2
3
4
5
6
7
8
9
10
11
12
PC r0 r1 r2 r3
(0, 0, 0, 0, 0) # 初始状态
(1, 10, 0, 0, 0) # 执行PC为0的指令后, r0更新为10, PC更新为下一条指令的位置
(2, 10, 0, 0, 0) # 执行PC为1的指令后, r1更新为0, PC更新为下一条指令的位置
(3, 10, 0, 0, 0) # 执行PC为2的指令后, r2更新为0, PC更新为下一条指令的位置
(4, 10, 0, 0, 1) # 执行PC为3的指令后, r3更新为1, PC更新为下一条指令的位置
(5, 10, 1, 0, 1) # 执行PC为4的指令后, r1更新为r1+r3, PC更新为下一条指令的位置
(6, 10, 1, 1, 1) # 执行PC为5的指令后, r2更新为r2+r1, PC更新为下一条指令的位置
(4, 10, 1, 1, 1) # 执行PC为6的指令后, 因r1不等于r0, 故PC更新为4
(5, 10, 2, 1, 1) # 执行PC为4的指令后, r1更新为r1+r3, PC更新为下一条指令的位置
....
(7, 10, 10, 55, 1) # 结果存储在r2

F4.1.2 计算10以内的奇数之和

尝试用上述指令编写一个程序, 求出10以内的奇数之和, 即计算1+3+5+7+9. 编写后, 尝试列出处理器状态的变化过程, 以此来检查你编写的程序是否正确.

编写程序如下:

1
2
3
4
5
6
7
8
0: li r0, 11
1: li r1, 1
2: li r2, 0
3: li r3, 2
4: add r2, r2, r1
5: add r1, r1, r3
6: bner0 r1, 4
7: bner0 r3, 7

处理器状态如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PC r0 r1 r2 r3
(0, 0, 0, 0, 0)
(1, 11, 0, 0, 0)
(2, 11, 1, 0, 0)
(3, 11, 1, 0, 0)
(4, 11, 1, 0, 2)
(5, 11, 1, 1, 2)
(6, 11, 3, 1, 2)
(4, 11, 3, 1, 2)
(5, 11, 3, 4, 2)
(6, 11, 5, 4, 2)
(4, 11, 5, 4, 2)
(5, 11, 5, 9, 2)
(6, 11, 7, 9, 2)
(4, 11, 7, 9, 2)
(5, 11, 7, 16, 2)
(6, 11, 9, 16, 2)
(4, 11, 9, 16, 2)
(5, 11, 9, 25, 2)
(6, 11, 11, 25, 2)
(7, 11, 9, 16, 2) # End

指令集架构的状态机模型

见课件

C程序入门

F4.3.1 在线运行C程序

你可以在Compiler Explorer这个在线网站中运行上述C程序. 具体地, 在代码编辑器中输入上述C代码后, 点击编辑器上方工具栏的Add new..., 选择Execution Only, 即可弹出执行结果的终端窗口. 如果你的操作正确, 你将看到终端窗口输出z = 3.

你可以尝试按照你的理解修改C代码, 并观察程序的输出.

image.png

F4.3.2 继续执行上述程序

尝试继续执行上述代码, 记录状态的变化过程. 程序执行结束时, 程序处于什么样的状态?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 待执行的程序
#include <stdio.h>

/* 1 */ int main() {
/* 2 */ int sum = 0;
/* 3 */ int i = 1;
/* 4 */ do {
/* 5 */ sum = sum + i;
/* 6 */ i = i + 1;
/* 7 */ } while (i <= 10);
/* 8 */ printf("sum = %d\n", sum);
/* 9 */ return 0;
/* 10*/ }

状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
PC sum i
(2, ?, ?) # 初始状态
(3, 0, ?) # 执行PC为2的语句后, sum更新为0, PC更新为下一条语句的位置
(5, 0, 1) # 执行PC为3的语句后, i更新为1, PC更新为下一条语句的位置(第4行无有效操作, 跳过)
(6, 1, 1) # 执行PC为5的语句后, sum更新为sum + i, PC更新为下一条语句的位置
(7, 1, 2) # 执行PC为6的语句后, i更新为i + 1, PC更新为下一条语句的位置
(5, 1, 2) # 执行PC为7的语句后, 由于循环条件i <= 10成立, 因此重新进入循环体
(6, 3, 2)
(7, 3, 3)
(5, 3, 3)
(6, 7, 3)
...
(10, 55, 11) # End

数字电路的状态机模型

F4.4.1 从状态机视角理解数列求和电路的工作过程

在上一小节中, 你已经通过寄存器和加法器搭建出一个简单数列求和电路, 用于计算1+2+...+10. 尝试列出电路状态的变化过程.

这个是上一节中搭建的用于数列求和的电路

image.png

我们将左边的寄存器视作状态A 右边的寄存器视作状态B,那么有状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
 A  B
(0, 0)
(1, 0)
(2, 1)
(3, 3)
(4, 6)
(5, 10)
(6, 15)
(7, 21)
(8, 28)
(9, 36)
(10, 45)
(11, 55)

在计算机上执行C程序

F4.5.1 结合数列求和的例子理解编译器的工作

我们已经给出了数列求和的C程序和相应的指令序列, 尝试从状态机的视角理解它们之间的联系.

对比先前的C程序和指令序列的状态迁移过程,我们可以发现他们的在逻辑上等价的:

  • 他们有着相同的初始状态
  • 指令序列的寄存器状态 = C程序的变量状态
  • 指令序列的PC值 = C程序的执行的行号
  • 指令序列的转移规则 = C程序的语句

结合老师上课讲的内容,他们可以被视作是等价的。

F4.5.2 结合数列求和的例子理解编译器的工作

具体地, 点击编辑器上方工具栏的Add new..., 选择Compiler, 即可弹出相应汇编代码的窗口. 汇编代码默认采用x86指令集, 你不一定能理解每一条指令的具体含义, 但通过背景颜色的高亮和鼠标移动, 你可以看到C程序片段和汇编代码片段之间的对应关系.

image.png

接着学习F3的内容

整数的机器级表示

主要讲了原码、补码、反码,这些是计算机组成的基本知识,就不做过多赘述。

F3.6.1 搭建4位减法器

根据4位加法器的设计思路, 尝试在Logisim中通过门电路搭建一个4位减法器, 用七段数码管按十六进制显示减法器的两个输入和结果, 并用一个LED灯指示减法结果是否产生借位. 搭建后, 通过仿真检查你的方案是否正确.

这里的话我们先从一位减法器开始实现,实现的原理实际上和全加器是一样的。只不过这里需要注意借位的条件,全加器和减法器的区别就在于进位和借位信号的实现,以下是一个简单的一位减法器实现:

image.png

在此基础之上,我们实现四位的减法器:(实现同加法器)

image.png

F3.6.2 搭建4位原码加法器

理解原码加法器的工作原理后, 尝试用加法器, 减法器和多路选择器等部件, 在Logisim中搭建一个4位原码加法器. 为了显示符号位, 你可以额外实例化一个七段数码管, 结果为负数时显示负号-, 否则不显示. 搭建后, 通过仿真检查你的方案是否正确.

实现原码加法器需要进行分类讨论的情况:

  • A B同号,对数值位进行加法即可,符号位不变
  • A B异号,对数值位的绝对值进行减法运算,根据借位结果来判断符号位。(这里我用了两个减法器来处理这一部分,对于符号的判断,则用到了互斥的原理实现)
image.png

F3.6.3 搭建4位反码加法器

尝试按照上述思路, 在Logisim中搭建一个4位反码加法器. 搭建后, 通过仿真检查你的方案是否正确.

我们只需要对输入进行预处理,将反码转换为原码作为输入就可以了。转换电路的实现也很简单,如果反码是正数则保持不变,如果是负数,则翻转数值位,保持符号位。

image.png

F3.6.4 检测补码加法是否发生溢出

将上述真值表补充完整, 尝试列出溢出条件的逻辑表达式. 然后在Logisim中在4位加法器的基础上添加溢出判断逻辑. 添加后, 通过仿真检查你的方案是否正确.

image.png

首先完成表格:

As Bs Cin Cout Ss 溢出
0 0 0 0 0
0 0 1 0 1
0 1 0 0 1
0 1 1 1 0
1 0 0 0 1
1 0 1 1 0
1 1 0 1 0
1 1 1 1 1

我们注意到只有CinCout不同时,才会有溢出现象。所以我们可以通过简单的异或来判断溢出的现象:

image.png

时序电路

之前所设计的模块有一个特点,就是当前的电路状态完全由当前的输入状态决定。但只是这样子,我们并没办法实现大多数的功能。所以时序电路的设计就显得尤为重要。所以我们需要设计一种新的电路满足以下的两个要求:

  • 可以读出电路的旧状态
  • 可以更新电路的状态

具备上述特性的电路称为时序逻辑电路, 它可以存储状态, 其输出由当前输入和旧状态共同决定;而先前的电路我们成为组合逻辑电路。

F3.7.1 搭建SR锁存器

尝试在Logisim中通过门电路搭建一个SR锁存器. 搭建后, 通过仿真检查你的方案是否正确.

由于手工操作时, 无法通过一次点击直接将两个拨码开关从11变成00. 为了触发亚稳态, 你可以在SR锁存器前额外增加若干与门, 让另一个拨码开关同时控制这些与门的其中一个输入端, 这样就可以通过这一个拨码开关来让SR锁存器的两个输入端同时变成0了. 如果你成功触发了亚稳态, Logisim会在窗口底部显示Oscillation apparent的信息. 此时仿真将无法继续, 你需要通过Logisim的菜单重置仿真.

这里可以通过在模拟暂停的后设置输入的行为来触发亚稳态,可以看到电路中发生了振荡状态。

image.png

F3.7.3 分析D锁存器的行为

尝试根据电路结构图列出真值表, 分析D锁存器的行为.

image.png

真值表如下:

D WE Q
0 0 X
0 1 0
1 0 X
1 1 1

F3.7.4 搭建D锁存器

尝试在Logisim中通过门电路搭建一个D锁存器. 搭建后, 通过仿真检查你的方案是否正确.

image.png

F3.7.5 搭建带复位功能的D锁存器

尝试为D锁存器添加一个用于复位的输入端和复位功能. 当复位信号有效时, D锁存器中存放的值将变为0.

设置一个Reset信号,当Reset信号触发时,写入数据0就好了。

image.png

F3.7.6 用D锁存器实现位翻转功能

实例化一个带复位功能的D锁存器, 并将其输出取反后作为输入. 我们预期看到D锁存器的输出将在01之间反复变化, 但你应该在仿真过程中看到Oscillation apparent的信息, 请分析原因.

由于循环依赖的问题输入数据D和输出Q此时应该是高频闪动的现象,但是仿真器无法模拟这个环节,所以会报错明显的震荡

image.png

F3.7.7 搭建D触发器

尝试在Logisim中通过门电路搭建一个D触发器. 搭建后, 将时钟端口与一个按钮相连, 按钮的按下和释放分别会产生高低电平, 因此点击一次按钮可产生一个脉冲, 以此来充当时钟信号. 尝试长按按钮, 来观察主从式D触发器的工作过程.

image.png

F3.7.7 搭建带复位功能的D触发器

尝试为D触发器添加一个用于复位的输入端和复位功能. 当复位信号有效时, D触发器中存放的值将变为0.

image.png

F3.7.8 用D触发器实现位翻转功能

实例化一个带复位功能的D触发器, 并将其输出取反后作为输入. 我们预期看到D触发器的输出将在01之间反复变化. 尝试和上文D锁存器的结果进行对比.

和D锁存器的结果不同,由于D触发器的存储只在时钟的上升沿发生改变,所以形成了有规律的振荡。

image.png

F3.7.11 搭建带使能端的D触发器

尝试在Logisim中通过D触发器和若干电路, 搭建一个带使能端的D触发器. 搭建后, 通过仿真检查你的方案是否正确.

也就是需要一个WE信号控制时钟的值是否有效,和reset的实现差不多,加个与门就好了:

image.png

F3.7.12 搭建4位寄存器

尝试在Logisim中通过D触发器搭建一个4位的寄存器, 具备复位功能. 搭建后, 尝试从拨码开关向寄存器写入4位数据, 并将寄存器的输出接到七段数码管进行显示.

image.png

F3.7.13 搭建4位计数器

通过上述4位寄存器和之前搭建的加法器, 实现一个4位计数器, 每次时钟到来时, 寄存器中的值加1, 加到最大值时重新从0开始. 在Logisim中, 你可以通过元件库中的Wiring(线路)类别下的Constant(常数)元件实例化一个常数, 具体使用方式请RTFM.

有点像D触发器的反转,只不过每次需要自增一次在输出值:

image.png

F3.7.14 设计数列求和电路

尝试通过寄存器和加法器, 计算出1+2+...+10的结果. 为了容纳计算结果, 你可以考虑实现8位的寄存器和加法器.

使用两个寄存器,一个寄存器自增提供加数;另一个寄存器累加作为累加器:

image.png

F3.7.15 实现电子时钟

利用寄存器和七段数码管, 实现一个电子时钟, 具备”分”和”秒”的功能.

因为时序问题卡了好久

image.png

寒假报名了一生一芯的训练,作为对我这个学期的知识的一个补全,顺便学一些新的知识。为下个学期研究二进制安全做一点准备。这一部分就记录我在学习过程中的一些心得体会。就不再像往常一样讲的那么详细了,因为知识点在文档中给的很清楚,没必要再敲一遍。

通过晶体管实现0和1

主要认识两种类晶体管:

  • nMOS:当G为高电压时,电信号由S->D;反之则阻塞信号
  • pMOS:当G为低电压时,电信号由S->D;反之则阻塞信号
  • 注意两者输入输出的方向,pMOS图带圈
image.png

通过晶体管搭建门电路

F3.2.1 分析门电路

尝试分析以下门电路的行为和功能.

基于mos管,我们可以实现基础的门电路,我们分析下面的门电路功能:

image.png

只有A和B均为低电压时,才能输出高电平信号。当A或B任一处于高电压时,输出低电平信号。分析为——NOR门

F3.2.2 或门的晶体管结构

尝试画出或门的晶体管结构.

画出或门的晶体管结构,就是在上面的基础上加上一个非门的晶体管结构就好了:

image.png

F3.2.3 对比两种实现的晶体管所需要的数量

不难分析, 上述晶体管结构同样实现了三输入与非门的功能. 尝试对比两种实现方式中所需晶体管的数量.

image.png

分别对两种方案进行计算:

1
2
3
4
// 门电路方案
#T(NAND3) = #T(AND) + #T(NAND) = 8 + 6 = 14
// 晶体管方案
#T(NAND3) = 3*#T(P) + 3*#T(N)*3 = 3 + 9 = 12

F3.3.1 用其他门电路搭建异或门

尝试在Logisim中用上文提到的门电路搭建一个异或门. 搭建后, 通过仿真检查你的方案是否正确.

实现正确后, 计算你的方案使用了多少个晶体管.

异或门的真值表,也可以看作是或门和与非门的交集。根据这个特征我们可以通过以下形式实现异或门:

image.png

F3.3.2 设计同或门

还有另一种操作是”同或”操作, 当输入A和B相同时, 结果为1, 否则为0. 同或操作可以认为是异或操作结果的取反.

尝试在Logisim中用上文提到的门电路搭建一个同或门. 搭建后, 通过仿真检查你的方案是否正确.

在上面的基础上加一个非门就行了:

image.png

进位计数法

主要讲了2,10,16进制间的转换,这个比较简单,暂时略过。

通过门电路搭建基本组合逻辑电路

我们基于晶体管实现了门电路,现在我们将忽略晶体管的电气特性,将视角聚焦到门电路层面。在此基础之上,搭建各种基础的基础电路模块。

F3.5.1 搭建2-4译码器

尝试在Logisim中用门电路搭建一个2-4译码器, 它有2位输入, 4位输出. 搭建后, 通过仿真检查你的方案是否正确.

Logisim中也直接提供了译码器等现成的元件, 但我们还是要求大家使用门电路来搭建它们, 从而更好地学习数字电路的基本原理.

image.png

F3.5.2 Logisim中的子电路功能

译码器在后续的数字电路设计中会经常用到, 为了避免用户重复设计相同的电路, Logisim提供了子电路功能, 相应电路只需要设计一次, 后续即可反复实例化. 具体操作方式请阅读官方手册中的Subcircuits(子电路)部分.

学习如何使用Logisim的子电路功能后, 尝试将你设计的译码器封装成子电路.

可以通过添加电路,然后设置好IO引脚,这样就可以打包成一个子电路,方便之后调用:

image.png

F3.5.3 译码器的扩展

3-8译码器有3位输入, 8位输出. 尝试实例化若干个2-4译码器(具体数量交给你的思考), 并添加少量门电路, 从而实现3-8译码器的功能. 搭建后, 通过仿真检查你的方案是否正确.

通过第三个信号和原有的2-4译码器,实现3-8译码器

image.png

F3.5.4 搭建七段数码管译码器

尝试在Logisim中通过门电路搭建一个七段数码管译码器, 它有4位输入和8位输出, 分别与拨码开关和七段数码管相连. 七段数码管译码器支持十进制数字的显示, 即当输入对应0-9时, 七段数码管显示对应的数字; 对于其他输入, 七段数码管只显示小数点. 搭建后, 通过仿真检查你的实现是否正确.

这里用了隧道和解码器,来简化实现,对着文档搓就行了。RTFM!!!

image.png

F3.5.5 搭建七段数码管译码器(2)

尝试在Logisim中通过门电路搭建一个支持十六进制数字的七段数码管译码器. 和上述的十进制数字相比, 当输入对应10-15时, 七段数码管分别显示A, b, C, d, E, F. 搭建后, 通过仿真检查你的实现是否正确.

在上一个的基础之上添加对A,b,C,d,E,F的数码管支持就好了。就是有点麻烦。

image.png

F3.5.6 编码器

尝试在Logisim中通过门电路搭建一个16-4编码器, 它有16位输入和4位输出, 分别与拨码开关和七段数码管译码器相连, 使得编码器的输出结果通过十六进制数字显示在七段数码管中. 搭建后, 通过仿真检查你的实现是否正确.

每个Y的输出只关注当前的二进制对应的位数是否需要Y,然后使用或门进行合并:

image.png

使用封装的译码器驱动我们的七段管:

image.png

F3.5.7 搭建4-2优先编码器

根据上述真值表, 尝试列出每一位输出的逻辑表达式. 然后尝试在Logisim中通过门电路搭建一个4-2优先编码器. 搭建后, 通过仿真检查你的方案是否正确.

实现后, 对比4-2编码器和4-2优先编码器所需的门电路数量.

image.png

实现的过程中发现只有当A2为1时,后面的A1信号会对结果造成影响。设置一个与门作为开关就可以抑制这个现象了:

image.png

门电路数量比较:

1
2
3
4
// 4-2
#T(4-2) = 2*#T(OR) = 2*8 = 16
// 优先4-2
#T(P4-2) = 2*#T(OR) + #T(AND) + #T(NOT) = 2*8 + 8 + 2 = 26

F3.5.10 搭建1位2选1选择器

尝试在Logisim中通过门电路搭建一个1位2选1选择器. 搭建后, 通过仿真检查你的方案是否正确.

image.png

F3.5.11 搭建3位4选1选择器

尝试画出3位4选1选择器的电路结构图, 然后在Logisim中通过门电路搭建一个3位4选1选择器. 搭建后, 通过仿真检查你的方案是否正确.

这个比较复杂,但是原理一样:

image.png

F3.5.12 搭建可切换进位计数制的七段数码管

通过5个拨码开关和1个七段数码管, 实现如下功能: 其中4个拨码开关当作数据输入, 剩下1个拨码开关作为进位计数制的选择, 当选择信号为0时, 七段数码管以十进制方式显示数据; 当选择信号为1时, 七段数码管以十六进制方式显示数据. 在输入数据为10-15时, 两种显示方式有所不同.

使用系统封装好的多路复用器就很容易实现

image.png

F3.5.13 搭建比较器

尝试在Logisim中通过门电路搭建一个4位比较器, 然后通过两组拨码开关对比两组数据是否相等, 若相等, 则点亮一个LED灯. 搭建后, 通过仿真检查你的方案是否正确.

四个同或门比较信号,然后使用与门收集信号,若全为真则各位相同:

image.png

F3.5.14 搭建1位全加器

尝试列出1位全加器的真值表, 并在Logisim中通过门电路搭建一个1位全加器. 搭建后, 通过仿真检查你的方案是否正确.

本质上就是在半加器的基础上,对一个新的进位进行额外的判断:

image.png

F3.5.15 搭建1位全加器(2)

尝试实例化若干个半加器, 并添加少量门电路, 从而实现一个1位全加器. 搭建后, 通过仿真检查你的方案是否正确.

一样的原理,只不过封装了起来

image.png

F3.5.16 搭建4位加法器

尝试在Logisim中通过门电路搭建一个4位加法器, 用七段数码管按十六进制显示加法器的两个输入和结果, 并用一个LED灯指示加法结果是否产生进位. 搭建后, 通过仿真检查你的方案是否正确.

使用全加器串联起来:

image.png

终于考完试了,也要做好打算琢磨一下接下来该做什么了。总体来说,这个寒假会好忙,但是也是一个很好的机遇。我打算用这段时间好好提升一下自己,无论是学习上还是生活上。

先对我的一月份做个小小的反思吧,这个月主要是期末周,导致我都没有更新博客,也没有学什么新的技术,寒假我会好好补起来的。至于期末开始,只能说考的一般吧,自己还是抱有侥幸心理,偷了很多懒。导致复习的过程很赶,很多知识都是一知半解。尤其是概率论,唉,基本都不会,我考前还一直在打游戏,也不知道怎么想的。

然后就是主要讲一讲寒假的安排吧,和往常一样,我想做的事情比较多,但是不一定能做到。但是目标还是要定下来的:

  • 科研训练任务(这个是重中之重,如果寒假拿不出成果,就真要寄了)
  • 然后是完成一生一芯的预学习阶段,通过答辩,成为正式学员。
  • 再是要提升一下国际象棋技术,争取冲到1300-1500分,要多和真人对练
  • 然后平时要坚持锻炼身体,不能三天打鱼两天晒网,而且感觉自己的身体素质也越来越差了
  • 接着就是学习一下游戏引擎吧,我想试着做一做游戏。

这些是要做的事情,我还想立几个比较具体的任务,我打算看完三本书:

  • 计算机组成设计与接口:Risc-V
  • 计算机网络:自顶向下
  • OSTEP:操作系统,顺便把xv6的内核源代码给看一看

所以综上所述,要做的事情还是很多的。所以每天要早睡早起,少刷视频。我发现这个学期我刷视频的时间明显变多了很多。

今年的寒假很长,希望能通过这个寒假给自己带来一点改变。不能止步不前,Keep on going。

2025年马上就过去了,这一年发生了很多事情,我也不怎么记得了。仔细一想感觉一年好长,但是时间却又过的好快。最近很多软件都公布了年度报告,不知道为什么我甚至不敢点开,我好害怕去回想这些事情。可能是因为最近过的不是很好吧,我不想去回忆以前很多很开心的瞬间。我怕自己看到了会难过。

但我还是点开看了,我最先意识到的就是,原来我有这么多时间。我仔细算了一下,人的一天有24个小时,去掉12个小时吃饭睡觉,我还剩下12个小时。一年下来就是 4380个小时,我的B站视频时长是930个小时,今年的游戏时长1500个小时。我将近一半的时间都在娱乐,我推测学习时长也就1000个小时。我的每一天无形之中浪费了很多时间。如果我能注意到这些时间就好了。

我总是觉得自己好忙好累,我为什么不拿这些多余的时间去陪陪自己的女朋友,或者锻炼培养下自己的爱好。

我觉得很自责,心里。我明明可以做得更好,但是我什么也没做,我感觉自己是个无聊的人,可能每天就是宅着,过完一天又一天。我总是觉得自己毫无长进,我现在知道这份感受的来源了。我希望新的一年自己能做的更好吧,心里非常难过,也不知道说什么好,我觉得还是挺后悔的,我十二月有一段时间什么也不想做。每天就是打游戏,看动漫,尽量让自己不去思考吧。我玩蔚蓝,把BC面全部通关了。我一直死一直重来,我心里是有幻想的,就好像我通关了之后我就能大声的宣告,自己从失恋的阴影中走了出来。但我还是没能做到,我不仅没有通关,也没有从失恋的阴影中走出来。

我看网上很多人说,走出失恋的痛苦就需要你把你的注意力转移到自己身上,去打破现有的僵局,去改正自己的不足。我觉得很有道理,但是太难了,失恋在我看来就是一种精神病,让失恋的人走出来,就好比让抑郁症开心一点。我也不知道该怎么做吧,只是尽量的让自己忙一点。最近开始着手准备期末和大作业的项目,所以心情好一点,暂时不会去想了。零散的时间我会看看国际象棋,看看视频。最近这几天稍微心情好一点。所以写下这篇反思。

但是这样的日子还要多久呢,我要一直让自己忙碌下去,什么时候才能停下来呢。-

尽管如此,我还是想像往常一样定下几个目标,尽力去做吧:

  • 照顾好自己,要正常的饮食和作息和生活习惯
  • 从现在开始到期末结束,不玩游戏。累的时候可以看看书
  • 每周要锻炼,锻炼会让短时间心情变好
  • 全力开始期末的准备,这个学期学的很糟糕,所以要万分准备
  • 振作起来

我觉得放弃确实狼狈,但是也不能因此丢失了再来的勇气,否则我也不再是我。通过这段时间我对自己的观察,我发现我有一个特点。无论是打游戏还是生活中,只要有一件我想做到的事情我就会没日没夜的去完成它,大部分时候我都能做到。少部分时候,我会遇到无论如何也无法解决的问题,我会情绪很失控,很自责,很消极。我不知道这样是好还是坏,但是我不愿意放弃,当下我应该整顿好自己,然后再来一次。不能止步不前。

也许这也是一次转机吧,这段时间我重新认识了自己,想了很多以前不会想的事情,希望我能做到越来越好。如果可以的话,我还是想去北京,去看北方的初雪。冬天如约而至,可是你成了我永远迟到的季节。我要完成我的遗憾。

找到一个比较好的项目Byterun,这里进行一下复现。

Byterun

Byterun 是一个基于Python实现的Python字节码解释器,简化了Cpython的实现,展示了Python解释器在底层上的实现原理,以及相关的组织过程。

搭建一个解释器

解释器本质上就是一个虚拟机,他会对你产生的字节码信息进行解释,然后基于栈运行,实现运算、分支条件、循环结构等功能。

Python解释器实际上就是一个字节码解释器,它接受字节码,然后输出运行结果。当你写下一段Python代码时,会被词法分析器解析为token流,然后通过语法分析和编译器,作为字节码(code object)进行输出。然后字节码再由Python解释器进行运行。这里字节码的作用有点像汇编语言在C语言编译过程。

微型解释器

我们最简单的解释器开始,它只能识别以下三个指令,我们基于一点一点拓展实现我们的整个项目:

  • LOAD_VALUE:加载值
  • ADD_TWO_VALUES:相加
  • PRINT_ANSWER:输出值

我们对7+5生成以下指令集:

1
2
3
4
5
6
7
8
9
byte2exec = {
"inst": [
("LOAD_VALUE",0),
("LOAD_VALUE",1),
("ADD_TWO_VALUES",None),
("PRINT_ANSWER",None)
],
"num": [7,5]
}

之所以通过这种单指令和零指令的方式进行计算,是因为我们的Python解释器实际上是一个基于栈的虚拟机。

我们将指令和数据分开存放,这样保证了指令是定长的。因此,我们也需要向指令指出数据存放的位置,所以一个完整的指令集有两部分,指令和参数位置。对于不需要参数的零指令,我们用None表示。

现在我们可以基于栈来实现一个简单的解释器结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Interpreter:
def __init__(self):
self.stack = []

def LOAD_VALUES(self,number):
self.stack.append(number)

def ADD_TWO_VALUES(self):
first = self.stack.pop()
second = self.stack.pop()
total = first + second
self.stack.append(total)

def PRINT_ANSWER(self):
answer = self.stack.pop()
print(answer)

现在我们已经实现了一个虚拟机的基本结构和支持的指令,我们需要一个方法来驱动我们的虚拟机进行对指令的解析与执行:

1
2
3
4
5
6
7
8
9
10
11
12
def run(self, code):
insts = code["inst"]
nums = code["num"]
for i in insts:
inst ,arg = i
if inst == "LOAD_VALUES":
num = nums[arg]
self.LOAD_VALUES(num)
elif inst == "ADD_TWO_VALUES":
self.ADD_TWO_VALUES()
elif inst == "PRINT_ANSWER":
self.PRINT_ANSWER()

现在我们可以尝试使用它们进行简单的计算3+4+5:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
code = {
"insts":[
("LOAD_VALUES",0),
("LOAD_VALUES",1),
("LOAD_VALUES",2),
("ADD_TWO_VALUES",None),
("ADD_TWO_VALUES",None),
("PRINT_ANSWER",None),
],
"nums":[3,4,5]
}

interpreter = Interpreter()
interpreter.run(code)
# 12

可以看到虚拟机按照预期输出了我们想要的结果。

变量

现在我们向虚拟机添加存储功能,我们可以将数值存入变量中,实现变量和值的映射关系,我们将实现以下两个指令:

  • STORE_NAME:存入变量
  • LOAD_NAME:取变量值

为了实现这个功能,我们需要再初始化一个内存空间(这里使用字典比较直观),用于存放变量名和值的绑定关系,同时需要在我们的code中添加变量名。我们的实现如下:

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
class Interpreter:
def __init__(self):
self.stack = []
self.mem = {} # 添加内存空间

def LOAD_VALUE(self,number):
self.stack.append(number)

def ADD_TWO_VALUES(self):
x = self.stack.pop()
y = self.stack.pop()
self.stack.append(x+y)

def PRINT_ANSWER(self):
answer = self.stack.pop()
print(answer)

def STORE_NAME(self,name):
val = self.stack.pop()
self.mem[name] = val

def LOAD_NAME(self,name):
val = self.mem[name]
self.stack.append(val)

# 参数索引可能是访问数字也可能是变量 需要进行判断
def parse_arg(self,inst,arg,code):
nums = ["LOAD_VALUE"]
vars = ["STORE_NAMES", "LOAD_NAMES"]
if inst in nums:
arg = code["nums"][arg]
elif inst in vars:
arg = code["vars"][arg]
return arg

def run(self, code):
insts = code["insts"]
for step in insts:
inst ,arg = step
# 根据指令内容解析对应的参数
arg = self.parse_arg(inst,arg,code)
if inst == "LOAD_VALUE":
self.LOAD_VALUE(arg)
elif inst == "STORE_NAME":
self.STORE_NAME(arg)
elif inst == "LOAD_NAME":
self.LOAD_NAME(arg)
elif inst == "ADD_TWO_VALUES":
self.ADD_TWO_VALUES()
elif inst == "PRINT_ANSWER":
self.PRINT_ANSWER()

这里我们可以试着运行一下x = 3; y = 7; print(x+y):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
code = {
"insts":[
("LOAD_VALUE",0),
("STORE_NAME",0),
("LOAD_VALUE",1),
("STORE_NAME",1),
("LOAD_NAME",0),
("LOAD_NAME",1),
("ADD_TWO_VALUES",None),
("PRINT_ANSWER",None),
],
"nums":[3,7],
"vars":["x","y"]
}

interpreter = Interpreter()
interpreter.run(code)
# 10

成功的运行了我们的函数。

程序结构优化

随着我们支持的指令增多,我们需要不断的通过if-else结构来进行对指令的执行。在我们的实现中,类的方法名和字节码中的指令是相同的,所以我们通过getattr方法来对run()进行优化:

1
2
3
4
5
6
7
8
9
10
def execute(self, code):
insts = code["insts"]
for step in insts:
inst ,arg = step
arg = self.parse_arg(inst,arg,code)
inst = getattr(self,inst) # 查找和inst同名的方法
if arg is None:
inst()
else:
inst(arg)

Python字节码

现在我们可以尝试解析一下真实的Python字节码,我们可以以下面的这个函数为例:

1
2
3
4
5
6
def cond():
x = 3
if(x < 5):
return 'yes'
else:
return 'no'

我们可以通过特殊方法__code__获取函数的字节码和元信息,具体的用法如下:

1
2
3
4
5
6
7
8
9
10
11
函数对象 (function)

└── __code__ (code对象 - 包含完整元数据)

├── co_code (字节码 - 仅指令)
├── co_consts (常量元组)
├── co_varnames (变量名元组)
├── co_names (全局名元组)
├── co_argcount (参数数量)
├── co_nlocals (局部变量数量)
└── ... 其他属性

这里我们可以通过这个方式得到我们的字节码指令

1
2
3
4
codebyte = cond.__code__.co_code
codebyte = list(codebyte)
print(codebyte)
# [151, 0, 100, 1, 125, 0, 124, 0, 100, 2, 107, 2, 0, 0, 114, 1, 121, 3, 121, 4]

我们得到了这个函数的字节码,但是我们无法阅读它。所以我们需要用到Python中的字节码反汇编器dis,将字节码进行翻译并以可读的形式输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import dis
codebyte = cond.__code__.co_code
dis.dis(codebyte)
'''
0 RESUME 0
2 LOAD_CONST 1
4 STORE_FAST 0
6 LOAD_FAST 0
8 LOAD_CONST 2
10 COMPARE_OP 2 (<)
14 POP_JUMP_IF_FALSE 1 (to 18)
16 RETURN_CONST 3
>> 18 RETURN_CONST 4
'''

其中第一列是该指令对应的字节码索引,第二列是该字节码的可读形式,第三列是指令的参数索引(第4列可能会指出使用的是什么参数)

同时我们也可以使用dis库中的opname方法,将对应的字节码翻译成可读的指令:

1
2
3
4
5
6
7
8
codebyte = cond.__code__.co_code
codebyte = list(codebyte)
print(dis.opname[codebyte[0]])
print(dis.opname[codebyte[4]])
'''
RESUME
STORE_FAST
'''

条件与循环

一个语言中条件分支和循环结构的能力也很重要,我们可以借这一段字节码深入的理解Python运行的实现。在我们的例子中if x>5被翻译成了:

1
2
3
4
 6 LOAD_FAST                0
8 LOAD_CONST 2
10 COMPARE_OP 2 (<)
14 POP_JUMP_IF_FALSE 1 (to 18)

其中LOAD_FAST将局部变量加载到栈上,LOAD_CONST将常数5加载到栈上,然后通过COMPARE_OP指定的比较类型,对栈顶的两个数值进行比较,然后将比较结果放回栈上。最终POP_JUMP_IF_FALSE,根据比较的结果,跳转到指定的指令。跳转后要被加载的指令我们称之为跳转目标,作为POP_JUMP的参数,dis会用>>指出跳转目标。

有了条件判断和跳转之后,我们就可以实现最基本的循环结构,我们对下面这个循环结构进行分析:

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
def cond():
x = 1
while x<5:
x += 1
return x

'''
0 RESUME 0
2 LOAD_CONST 1
4 STORE_FAST 0
6 LOAD_FAST 0
8 LOAD_CONST 2
10 COMPARE_OP 2 (<)
14 POP_JUMP_IF_FALSE 11 (to 38)
>> 16 LOAD_FAST 0
18 LOAD_CONST 1
20 BINARY_OP 13 (+=)
24 STORE_FAST 0
26 LOAD_FAST 0
28 LOAD_CONST 2
30 COMPARE_OP 2 (<)
34 POP_JUMP_IF_FALSE 1 (to 38)
36 JUMP_BACKWARD 11 (to 16)
>> 38 LOAD_FAST 0
40 RETURN_VALUE
'''

要理解这个循环结构,我们需要从几个跳转指令入手。首先是第一个POP_JUMP,如果判断为真,就进入循环体结构,不为真就直接退出。第二个POP_JUMP则是判断是否退出循环结构。第三个比较特殊JUMP_BACKWARD,它将无条件跳转到循环体的起点。通过跳转和条件分支的功能,实现了循环结构。

其他的语法结构也是类似的,如if ... elif, for ,...,可以通过dis慢慢探索

栈帧

实现了数值的计算和控制转移之后,现在我们需要进一步认识Python 函数调用的过程。正如上面的那个例子,我们看到RETURN_VALUES,那么结束这个函数之后会返回到哪里呢?

如果我们在函数中,我们会返回到调用者。如果是在模块顶层,我们可能会直接结束程序。我们将上一层的信息返回给下一层,例如在递归调用时,我们还需要保存每一层的局部状态。这就引出了Frame的概念,frame是函数调用的一次执行的上下文。它在python运行的过程中不断的被创建和销毁。

对于一个code object,我们可能会有多个frame。但是对于一个frame,我们有且仅对应一个code object

frame存在于调用栈之中(和C一样)。Python中有三种类型的栈结构:

  • 调用栈:用来管理程序运行的函数调用状态
  • 数据栈:用于存放数据
  • 块栈:用于特定的控制流块,如异常和循环结构…

在调用栈的每个frame都有它自己的数据栈和块栈。以下面这个程序为例,它在调用栈中的状态大概就是这样的:

1
2
3
4
5
6
7
8
9
10
11
>>> def bar(y):
... z = y + 3
... return z
...
>>> def foo():
... a = 1
... b = 2
... return a + bar(b)
...
>>> foo()
3
image.png

至此,我们对Python字节码的基本分析就结束了