两个代码只有定义数组打乱顺序的顺序不一样,请问这两个代码有什么区别? 最终跑出来的结果不一样

ICS的课程实验,作为一个非科班的学生,要完成这个实验属实不易,但是在这个过程中我收获到的知识和经验无疑是我求学路上无比宝贵的财富。为了记录下这宝贵的经历,故写此博客记录下我的苦恼与感悟。在这篇博客里我会简单叙述我的思路和踩过的坑。另外特此声明,如果您本身需要独自完成PA实验,为了学术诚信,请自行完成后再参考本博客,今日偷的懒,在未来会加倍偿还!

在PA1中, 我们已经见识到最简单的计算机TRM的工作方式:

对于大部分指令来说, 执行它们都可以抽象成 取指-译码-执行 的指令周期。对于TRM来说,只要内存无限,这个循环就可以一直进行下去,是真正意义上的不停计算的机器。

让CPU执行当前PC指向的一条指令, 然后更新PC.

如果你跳转到 fetch_decode(s, cpu.pc) 的定义里查看,就会发现这个函数的工作就是把当前的 PC 保存到 s 的成员 pc

函数来完成。instr_fetch() 会根据你传入的 pc 和期望的字节长度,经过一层转发最终调用 paddr_read() 函数来访问物理内存中的内容。因此取值操作就是一个简单的内存访问而已。并且 s 指向的结构体就持有了该条指令的全部信息了,接下来的工作就是解析出该指令的信息。

总结下来,取指工作完成了两件事情:

  • s 指向的 Decode 结构体获得当前 pc 所指向的指令

另外很有必要的一件事是清楚 Decode 结构体的内容,具体地:

译码的目的是得到指令的操作和操作对象, 这主要是通过查看指令的 opcode(操作码) 来决定的。当然不同的 ISAopcode 的位置和组织方式不同,我们需要通过相应的手册来获取这些信息。而我所选择的 ISARISCV-32 , 这是一款年轻开源并且极富潜力的 ISA, 他的所有指令都是32位定长的,这对于我们的译码来说将会提供很大的帮助。

NEMU使用一种抽象层次更高的译码方式: 模式匹配, 可以通过一个模式字符串来指定指令中的 opcode。以我所选的 RISCV-32 举例子:

模式字符串中只允许出现4种字符:

  • 0表示相应的位只能匹配0
  • 1表示相应的位只能匹配1
  • ?表示相应的位可以匹配0或1
  • (空格)是分隔符, 只用于提升模式字符串的可读性, 不参与匹配

其中 def_INSTR_IDTAB 是一个宏,对其进行展开后可以得到:

对于 pattern_decode 具体如何工作你可以跳转到他的定义去了解,但在这里其实不是重点,重点是经过一系列转换后我们的一段32位的定长指令可以精确匹配到 RISCV-32 的的六种基本指令格式: R、I、S、B、U、J。

此时指令满足上述宏展开的if语句, 表示匹配到 lui指令 的编码, 因此将会进行进一步的译码操作。

刚才我们只知道了指令的具体操作(比如 lui指令 是读入一个立即数到寄存器的高位), 但我们还是不知道操作对象(比如立即数是多少, 读入到哪个寄存器)。为了解决这个问题, 代码需要进行进一步的译码工作, 这是通过调用相应的 译码辅助函数(decode helper function)

请注意,这其实只是为了方便生成多个函数签名,其实是没有函数体的,因为每种指令的的操作对象有可能是立即数也可能是寄存器,关于如何定义函数体是要根据每种指令的类型来定制的。

每个 译码辅助函数 负责进行一种类型的操作数译码, 把指令中的操作数信息分别记录在译码信息 sdest成员 , src1成员src2成员 中, 它们分别代表目的操作数和两个源操作数。

我们会发现, 类似寄存器和立即数这些操作数, 其实是非常常见的操作数类型. 为了进一步实现 操作数译码指令译码 的解耦, 框架代码对这些操作数的译码进行了抽象封装, 指令译码过程由若干 译码操作数辅助函数(decode operand

mipsriscv 中, 0号寄存器的值恒为0, 因此往0号寄存器的写入操作都会被硬件忽略. 为了实现这个功能, 上述代码会在译码的时候检查指令是否尝试往0号寄存器进行写入, 若是, 则把写入的目的修改成一个 “黑洞变量” , 这个变量不会被其它代码读取, 从而实现”往0号寄存器的写入操作都会被硬件忽略”的效果。

的译码过程可以通过如下代码实现:

table_lui() 的定义方式比较特殊, 我们在这里先给出部分宏展开后的定义:

g_exec_table数组 其实就是一个存放指令执行函数指针的数组,通过正确的ID能映射到正确的指令执行函数,并将其赋值给 s 指向的 Decode 结构体。

事实上, NEMU把译码时的如下情况都看作是查表过程:

  • 在译码过程中分别匹配指令中的每一个域
  • 译码出最终的指令时认为是一种特殊的查表操作, 直接返回标识该指令的唯一ID
  • 如果所有模式匹配规则都无法成功匹配, 代码将会返回一个标识非法指令的ID

踩坑点以及感悟!!!!:

  • 忽视了指令中 funct3 以及 funct7 的重要性,由于 RISCV-32 指令的高度统一性,如果你没有把手册中的所有有效信息在模式匹配的时候完全匹配上,就有可能会出现 指鹿为马 的坑爹情况。例如 oriandi 这两个指令,他们的 opcode 完全一样,唯一的差别是前者的 funct3110,而后者的为 111,仅一位之差,就有可能是完全不同的语义,请务必特别小心!
  • 辅助函数太多,宏转发看的头晕,呆坐半天都无法理解是如何完成一条指令的译码的。原讲义中关于各种辅助函数的宏命名实在太过相像且没有突出显示,所以在我的笔记中我着重突出了辅助函数的名字,方便各位查阅。关于宏转发看的头晕的问题,最好的解决办法是画一张连线图,毕竟人脑无法模拟太复杂的状态机,把转发的过程画下来将更有助于你理解这整个过程。
  • 这里我画了一张 lui指令 的译码流程图,供大家参考:
  • 经过这些转发我们得到了什么?答案是我们得到了该指令所需要的 目的操作数和两个源操作数(部分是只有一个源操作数),并将信息存储在了 s 所指向的 Decode 结构体中,以及返回了 指令的唯一ID指令的唯一ID 将为我们索引 g_exec_table数组 并得到该指令唯一对应的指令执行函数,在这为我们后续执行指令的操作提供了必要的原材料。

它们的名字是指令操作本身. 执行辅助函数 通过 RTL指令 来描述指令真正的执行功能 (RTL指令 将在下文介绍) 。

每个 执行辅助函数 都需要有一个标识该指令的 ID 以及一个 表格辅助函数 与之相对应, 这一点是通过一系列宏定义来实现的。

表示指令列表的宏 INSTR_LIST , 它定义了NEMU支持的所有指令。具体的宏展开行为,亲自行 RTSFC

更新PC的操作非常简单, 只需要把 s->dnpc 赋值给 cpu.pc 即可。我们之前提到了 snpcdnpc , 现在来说明一下它们的区别。

在程序分析领域中, 静态指令是指程序代码中的指令, 动态指令是指程序运行过程中的指令。例如对于以下指令序列:

jmp指令的下一条静态指令是add指令, 而下一条动态指令则是xor指令。

有了静态指令和动态指令这两个概念之后, 我们就可以说明 snpcdnpc 的区别了: snpc 是指代码中的下一条指令, 而 dnpc 是指程序运行过程中的下一条指令。

NEMU 中, 我们使用 RTL(寄存器传输语言) 来描述我们指令的具体行为。其实你可以把 RTL 看作微指令,每一条具体指令的行为都是由微指令拼凑而成的。

下面我们对 NEMU 中使用的 RTL 进行一些说明, 首先是 RTL寄存器 的定义. 在NEMU中, RTL寄存器 统一使用

有了 RTL寄存器 , 我们就可以定义 RTL指令 对它们进行的操作了. 在 NEMU 中, RTL指令 有两种。

    RTL基本指令, 因此它们属于 ISA无关的代码。
  • 第二种是 RTL伪指令, 它们是通过 RTL基本指令 或者已经实现的 RTL伪指令 来实现的。

其中大部分 RTL伪指令 还没有实现, 必要的时候你需要实现它们。

对译码, 执行和操作数宽度的解耦实现以及 RTL 的引入, 对在 NEMU 中实现客户指令提供了很大的便利, 为了实现一条新指令, 你只需要:

  • RTL 实现正确的 执行辅助函数

万事开头难,如果这是你第一次实现 RISCV-32 指令,想必会有许多困惑。比如这条指令的具体行为,这条指令属于什么类型,哪些是伪指令,哪些是基础指令等等这些问题,会像洪水一样向你怒涛袭来,而缓解恐惧的最佳方法就是阅读手册。当你阅读手册之后,你才能弄懂一切,你才能取回掌握一切的那种感觉,你会再一次坚信,计算机的世界里没有魔法 。掌握感和确定性,这是编程带给我最美妙的感觉。

我这里就讲解一条最简单的 li指令 的实现。首先看 RV32I 基础指令集中有没有 li指令


我们仔细查阅了指令集表后,发现并没有所谓的 li指令。那么有没有可能是在别的基础指令集中呢?有这个可能,当你问出这个问题的时候,你已经按耐不住自己查阅手册了吧。这是一个很好的开始,请去仔细查阅后找到你想要的答案。

在这里我们给出答案,li指令 是一个伪指令,它只是基础指令的一种特殊情况而已。

它的行为也很简单,就是读取一个立即数到目的寄存器而已。并且官方也没有给出 li指令 的明确实现方式,不像其他伪指令一样,可以看到一般是有对应的基础指令的特殊情况的。这主要是因为 li指令 的实现太过灵活,官方就不打算全部列举出来了。

而我的实现也很简单,就是利用 addi指令 来实现 li指令。关于 addi指令 的具体行为,就交给你自己去阅读手册了,在这里我只提醒一点,关于 addi指令 的源操作数中的 imm立即数 ,需要进行什么特殊处理吗?如果你忽略了这点,我可以肯定 bug 很快就会找上门来。

一方面, 应用程序的运行都需要运行时环境的支持; 另一方面, 只进行纯粹计算任务的程序在 TRM 上就可以运行, 更复杂的应用程序对运行时环境必定还有其它的需求: 例如你之前玩的超级玛丽需要和用户进行交互, 至少需要运行时环境提供输入输出的支持. 要运行一个现代操作系统, 还要在此基础上加入更高级的功能。

如果我们把这些需求都收集起来, 将它们抽象成统一的API提供给程序, 这样我们就得到了一个可以支撑各种程序运行在各种架构上的库了! 具体地, 每个架构都按照它们的特性实现这组API; 应用程序只需要直接调用这组API即可, 无需关心自己将来运行在哪个架构上。由于这组统一抽象的API代表了程序运行对计算机的需求, 所以我们把这组API称为抽象计算机。

AM(Abstract machine) 项目就是这样诞生的。作为一个向程序提供运行时环境的库, AM 根据程序的需求把库划分成以下模块:

在这一部分其实就是要不断不断地实现更多的指令,来使我们的硬件的能力更加强大。如果你之前已经独立实现过指令了,那么这里对你其实已经不存在理解上的困难了,而更多的是工程上的问题了。

这里贴一下我的 cpu-tests 全通过的截图:

在这里我再抛出几个问题,都是我认为的非常值得思考的:

  1. 我不想在测试的时候一下就结束了,完全没有使用 sdb 的机会,请问有什么选项可以关闭批处理模式吗?
    (提示:关键在于弄清楚 makefile 如何向 NEMU 传参)

重新认识计算机: 计算机是个抽象层

大家在做实验的时候也可以多多思考: 我现在写的代码究竟位于哪一个抽象层? 代码的具体行为究竟是什么?

先来讨论在 TRM 上运行的程序, 我们对这些程序的需求进行分类, 来看看我们的计算机系统是如何支撑这些需求的:

这一部分主要是用来完善各种基础设施的,相信我,你值得为了这些基础设施付出精力,这将会为你后面诊断 bug 省下无数宝贵的时间。

因为 ftrace 到后面实现 loader 的时候还会有涉及,就暂且先搁置下来后面再一起讲。

这里我就主要讲我最看重的设施之一:difftest

在前面的部分中我们已经实现了很多指令了,并且已经顺利地通过了 cpu-tests。然而,我们就能完全放心了吗?我们的指令真的已经完全合乎手册的要求了吗?我看未必。bug 的种子或许已经埋下,只是在目前还没有发芽而已。随着我们测试的程序越来越复杂,汇编代码的行数也会暴增,如果我们的指令实现出现错误,其恶果或许将经过一段很长的调用链后才展露在我们面前。这对我们来找 bug 的难度来说可以说是噩梦级别的。

如果有一种方法能够表达指令的正确行为, 我们就可以基于这种方法来进行类似 assert() 的检查了。听起来很熟悉对不对,这其实就是我们高中就学过的对照实验的思想方法。我们可以让在 NEMU 中执行的每条指令也在真机中执行一次, 然后对比 NEMU 和真机的状态, 如果 NEMU 和真机的状态不一致, 我们就捕捉到 error 了!

Under Test, 测试对象) 功能相同但实现方式不同的 REF(Reference, 参考实现) , 然后让它们接受相同的有定义的输入, 观测它们的行为是否相同。

非常类似。我们可以信赖它的实现是正确的。

实现 difftest 也非常简单,框架代码已经帮我们完成了大部分工作。我只需要阅读源码,保证寄存器按照某种顺序排列,然后在 isa_difftest_checkregs() 中添加非常少量的的代码就可以得到一款无比强大的工具。天底下大概没有比这更赚的事情了。

你可以 RTFSC 或者直接去读 Spike 的手册,你很容易就能发现我们的 pa 框架本身就已经按照特定顺序排好了寄存器(如果你之前没有更改过寄存器顺序的话)。

在得到了 difftest 这个强大的工具后,赶紧再去测一下你的 cpu-tests 吧。如果你的程序被 difftest 拒绝了,那么你应该庆幸才对。我们在 bug 还未成气候之前就把它扼杀掉了,真是不幸中的万幸啊。

这部分可谓是我做 PA 到现在最为震撼的部分,在这里面我们很快就能领略到软件层(AM)和硬件(NEMU)是如何紧密配合的,并且不得不感叹抽象是多么伟大且美丽的思想。

串口是最简单的输出设备. nemu/src/device/serial.c 模拟了串口的功能. 其大部分功能也被简化, 只保留了数据寄存器. 串口初始化时会分别注册 0x3F8 处长度为 8个字节 的端口, 以及 0xa00003F8 处长度为 8字节MMIO 空间, 它们都会映射到串口的数据寄存器。

因此实现 printf() 其实很简单,我们可以调用我们之前实现过的 vsprintf() 来实现解耦,然后把 buf 里的字节用 putch 输出即可。

这一部分的实验代码设计的非常巧妙,这里我就不再介绍前情,直接进入正题。

时钟的更新逻辑是什么?

如果你仔细地 RTFSC 了之后就会知道,时钟更新的逻辑其实是由一个回调函数来控制的。而针对时钟来说,这个回调函数具体的就是 rtc_io_handler() 。当且仅当我们读取 RTC_ADDR 这个地址的高位的时候,我们才会触发时钟的更新逻辑。如果你没看懂我在说什么,建议回去再仔细研究一下源码,因为这对于我们读取到正确的时间非常重要。不然当后面做 benchmarks 的时候,你很可能无法得到预期的结果。PA 代码之所以这样设置,就是强迫我们去阅读源码,如果你没有弄清楚更新逻辑而胡乱读取一通,会有很大概率被坑到不行。

inl() 的魔力是如何实现的?

难道你就没有疑惑过吗?当我们写下这行代码的时候,我们到底是如何调用到时钟的回调函数的呢?明明我追踪到 inl() 的定义,他不过只是一个简单的解引用的包装罢了,根本就没有调用 rtc_io_handler() 的痕迹,何其怪哉。在揭晓答案之前,请让我们先赞美抽象,正是抽象把这一切都串联了起来。

让我们先回想一下对于 软件(AM) 来说一条解引用语句的执行过程。一条解引用的 C代码 被翻译成汇编后其实就是一条 load 指令,load 汇编指令有其对应的机器码,机器码会被装载进 硬件(NEMU) 中,进行译码和执行。。。 等等!这不是前不久我们一直在做的事吗?是的,就是你想的那样。那么 load 指令会调用什么呢?你应该去找指令执行函数了。到最后的最后,我们会追踪到一个叫

你最终会发现,通过一个简单的 inl() 函数,是真的能调用到 rtc_io_handler() 的!这简直是太酷了!而实现这一切的原理并不复杂,其实就是 MMIO,将设备映射到内存上,我们对设备的访存也就被抽象成了对内存的访存,就跟我们访问其他普通内存看起来一样,让用户完全感觉不到差异。

让我们再回过头来看一遍我们是如何读取时钟这个设备的:inl(RTC_ADDR) ;

有了时钟之后, 我们就可以测试一个程序跑多快, 从而测试计算机的性能。这里我统一只展示在 ref测试集 下所获得的分数。

如果你真正弄懂了时钟,这个对你来说应该不是问题。如果你束手无策,那么你也不应该继续往下做了,好好回去把时钟弄懂吧。

VGA 的第一部分跟时钟其实是如出一辙的。重点是找到那两个隐藏起来的寄存器到底在哪里,至于是在哪里找,反正我是知道肯定是去 硬件(NEMU) 里找,而不是在 软件(AM) 里抓瞎,等着他们自己出现。这里再提示一个关键词

VGA 的第二部分才是重量级。关于图形绘制这块的逻辑非常之重要,一定要彻底掌握。不然到后面的 PA3 将无比痛苦。讲义已经告诉我们了,在第一部分看到的青绿色条纹画面其实并不是正确的 display test 期望的输出画面。

原因是 AM_GPU_FBDRAW 没能正确实现,也就是说我们还没有正确把 帧缓冲寄存器 中的数据更新到 显存 中。具体是在哪里更新,只要你认真地读了 gpu.c 中的函数的名字,应该就非常显然了。俗话说好的取名就是最佳的注释,这句话可不是嘴上说说的。

别忘了讲义中提醒我要好好看看 display test 的源码,别偷懒,这对我们理解软件是如何更新画面的逻辑是有很大帮助的。如果你读了源码之后就会发现,其实 display test 的更新逻辑并非一次全部写入的,而是将整个 400 * 300 的屏幕分成了很多个小矩形块,每次写入一个小矩形块到显存中进行更新的。

请注意这里的 400 * 300 ,这里是以像素为单位的屏幕长宽,我之前就是理解错了这里,才没能明白 display testW / N 的含义。什么是像素,这里你可以理解为一个32位大小的描述颜色的字节块,这个字节块的大小为 个字节。后面我在绘制的时候,默认的单位都是像素,而不是字节。

阅读 display test 就能知道,当一个矩形块的数据准备好后,用户程序就会调用 AM 提供的 io_write() 接口,只要你一路追踪,最后就会来到正确更新显存的地方。现在我们的 帧缓冲寄存器 中已经有了用户程序传过来的矩形块的颜色信息了,我们要做的就是把这个矩形块绘制到屏幕上正确的坐标上。其实你已经注意到了,ctl 中的 pixels 是一个一维数组,而我们要根据用户程序传过来的 wh 将它还原成二维数组,这样我们才能正确地完成绘画。

这一部分光说还是有点太抽象了,我这里画了一张图,希望能帮助大家更好理解:

对着图说就爽多了。所以我们在用一维数组 pixels 绘制的时候不只要考虑矩形块本身的行优先问题,还要考虑屏幕本身的行优先问题,还要考虑 (x,y) 起始坐标的初始 offset 问题。计算机绘图,很奇妙吧(笑)。如果你开始没考虑清楚这其中的逻辑关系,没有关系,多想想这张图,相信你自己一定能调对的。

偶对了,说了半天,还没说到底怎么把 帧缓冲寄存器 中的数据更新到 显存 中。讲义第一部分给出的测试代码已经给出了解答:

这是什么意思捏?我相信这肯定难不倒你了。

这里再提一嘴我遇到的一个小坑,gpu.c 中的每个函数都有其存在的意义,请确保自己弄明白了每个函数的使命是什么,以及他们是否被设置在了正确的状态。

我要回帖

更多关于 数组打乱顺序 的文章

 

随机推荐