RTFSC

拿到框架代码之后, 第一件事就是RTFSC. 不过框架代码内容众多, 其中包含了很多在后续阶段中才使用的代码, 随着实验进度的推进, 我们会逐渐解释所有的代码. 因此在阅读代码的时候, 你只需要关心和当前进度相关的模块就可以了, 不要纠缠于和当前进度无关的代码, 否则将会给你的心灵带来不必要的恐惧.

ics2015
├── config            # 包含Makefile的一些配置
├── game            # 包含打字小游戏和仙剑奇侠传两个游戏
├── kernel            # 微型操作系统内核
├── lib-common        # 公用的库
├── Makefile        # 提供工程的构建, 运行, 测试, 打包等功能
├── nemu            # NEMU
├── testcase        # 测试用例
└── test.sh            # 测试脚本

目前我们只需要关心NEMU的内容, 其它内容会在将来进行介绍. 下图给出了NEMU的结构.

PA-architecture

NEMU主要由4个模块构成: monitor, CPU, 存储管理, 设备, 它们依次作为4个PA关注的主题. 其中, CPU, 存储管理, 设备这3个模块共同组成一个虚拟的计算机系统, 程序可以在其上运行; monitor位于这个虚拟计算机系统之外, 主要用于监视这个虚拟计算机系统是否正确运行. monitor虽然不属于虚拟计算机系统, 但对PA来说, 它是必要的. 它除了负责与GNU/Linux进行交互(例如读写文件)之外, 还带有调试器的功能, 为NEMU的调试提供了方便的途径. 缺少monitor模块, 对NEMU的调试将会变得十分困难.

代码中 nemu 目录下的源文件组织如下(部分目录下的文件并未列出):

nemu
├── include                            # 存放全局使用的头文件
│   ├── common.h                    # 公用的头文件
│   ├── cpu    
│   │   ├── decode                    # 译码相关
│   │   ├── exec                    # 执行相关
│   │   └── reg.h                    # 寄存器结构体的定义
│   ├── debug.h                        # 一些方便调试用的宏
│   ├── device                        # 设备相关
│   ├── macro.h                        # 一些方便的宏定义
│   ├── memory
│   │   └── memory.h                # 访问内存相关
│   ├── misc.h                        # 杂项
│   ├── monitor
│   │   ├── monitor.h
│   │   └── watchpoint.h            # 监视点相关
│   └── nemu.h
├── Makefile.part                    # 指示NEMU的编译和链接
└── src                                # 源文件
    ├── cpu
    │   ├── decode                    # 译码相关
    │   ├── exec                    # 执行相关
    │   └── reg.c                    # 寄存器相关
    ├── device                        # 设备相关
    ├── lib
    │   └── logo.c                    # "i386"的logo
    ├── main.c                        # 你知道的...
    ├── memory
    │   ├── burst.h
    │   ├── dram.c                    # DRAM工作方式的模拟
    │   └── memory.c                # 访问内存的接口函数
    └── monitor
        ├── cpu-exec.c                # 指令执行的主循环
        ├── debug                    # 简易调试器相关
        │   ├── elf.c                # ELF文件格式的解析
        │   ├── expr.c                # 表达式求值的实现
        │   ├── ui.c                # 用户界面相关
        │   └── watchpoint.c        # 监视点的实现
        └── monitor.c

为了给出一份可以运行的框架代码, 代码中完整实现了 mov 指令的功能(部分特殊的 mov 指令并未实现, 例如 mov %eax, %cr3 ), 并附带一个 mov 指令的用户程序( testcase/src/mov.S ). 另外, 部分代码中会涉及一些硬件细节(例如 nemu/src/cpu/decode/modrm.c )和文件格式(例如 nemu/src/monitor/debug/elf.c ). 在你第一次阅读代码的时候, 你需要尽快掌握NEMU的框架, 而不要纠缠于这些细节. 随着PA的进行, 你会反复回过头来探究这些细节.

大致了解上述的目录树之后, 你就可以开始阅读代码了, 至于从哪里开始, 就不用多费口舌了吧.

需要多费口舌吗?

嗯... 如果你觉得提示还不够, 那就来一个劲爆的: 回忆程序设计课的内容, 一个程序从哪里开始执行呢?

如果你不屑于回答这个问题, 不妨先冷静下来. 其实这是一个值得探究的问题, 你会在将来重新审视它.

对vim的使用感到困难?

在PA0的强迫之下, 你不得不开始学习使用vim. 如果现在你已经不再认为vim是个到处是bug的编辑器, 就像简明vim练级攻略里面说的, 你已经通过了存活阶段. 接下来就是漫长的修行阶段了, 每天学习一两个vim中的功能, 累积经验值, 很快你就会发现自己已经连升几级. 不过最重要的还是坚持, 只要你在PA1中坚持使用vim, PA1结束之后, 你就会发现vim的熟练度已经大幅提升! 你还可以搜一搜vim的键盘图, 像英雄联盟中满满的快捷键, 说不定能激发起你学习vim的兴趣.

NEMU开始执行的时候, 会进行一些和monitor相关的初始化工作, 包括打开日志文件, 读入ELF文件的符号表和字符串表, 编译正则表达式, 初始化监视点结构池. 这些初始化工作你几乎一个也看不懂, 但不要紧, 因为你现在根本不必关心它们的细节, 因此可以继续阅读代码. 之后代码会对寄存器结构的实现进行测试, 测试通过后会调用 restart() 函数(在 nemu/src/monitor/monitor.c 中定义), 它模拟了"计算机启动"的功能, 主要是进行一些和"计算机启动"相关的初始化工作, 包括

  • 初始化ramdisk
  • 读入入口代码entry
  • 设置 %eip 的初值
  • 初始化DRAM的模拟(目前不必关心)

在一个完整的计算机中, 程序的可执行文件应该存放在磁盘里, 但目前我们并没有实现磁盘的模拟, 因此NEMU先把内存开始的位置附近的一段区间作为磁盘来使用, 这样的磁盘有一个专门的名称, 叫ramdisk. 目前的ramdisk只用于存放将要在NEMU中运行的程序的可执行文件, 这个文件是运行NEMU的一个参数, 在运行NEMU的命令中指定, init_ramdisk() 函数把这个文件从真实磁盘读入到ramdisk.

入口代码entry的引入其实是一种简化. 我们知道内存是一种RAM, 是一种易失性的存储介质, 这意味着计算机刚启动的时候, 内存中的数据都是无意义的; 而BIOS是固化在ROM中的, 它是一种非易失性的存储介质, BIOS中的内容不会因为断电而丢失. 因此在真实的计算机系统中, 计算机启动后首先会把控制权交给BIOS, BIOS经过一系列初始化工作之后, 再从磁盘中将有意义的程序读入内存中执行. 对这个过程的模拟需要了解很多超出本课程范围的细节, 我们在这里做了简化, 让monitor直接把一个有意义的程序entry读入到一个固定的内存位置 0x100000 , 并把这个内存位置作为 %eip 的初值. 这时内存的布局如下:

0                                   0x100000
-----------------------------------------------------------------------------
|                  |                    |                  |
|      ramdisk     |                    |      entry       |
|     (unused)     |                    |                  |
-----------------------------------------------------------------------------

从0开始的一段物理内存被当作ramdisk来使用, 但这一阶段在NEMU中运行的程序并不需要使用ramdisk, 因此这段区间目前暂时不使用. 从 0x100000 开始的物理内存用于存放entry, 现在entry的内容就是将要在NEMU中运行的程序, NEMU的模拟执行将从这里开始. 在PA2中, 我们将会把kernel作为entry, kernel负责从ramdisk中读出将要运行的程序, 并把它加载到正确的内存位置.

restart() 函数执行完毕后, NEMU会进入用户界面主循环 ui_mainloop() (在 nemu/src/monitor/debug/ui.c 中定义), 代码已经实现了几个简单的命令, 它们的功能和GDB是很类似的. 键入 c 之后, NEMU开始进入指令执行的主循环 cpu_exec() (在 nemu/src/monitor/cpu-exec.c 中定义).

cpu_exec() 模拟了CPU的工作方式: 不断执行指令. exec() 函数(在 nemu/src/cpu/exec/exec.c 中定义)的功能是让CPU执行一条指令. 已经执行的指令会输出到日志文件 log.txt 中, 你可以打开 log.txt 来查看它们.

执行指令的相关代码在 nemu/src/cpu/exec 目录下, 其中一个重要的部分是定义在 nemu/src/cpu/exec/exec.c 文件中的 opcode_table 数组, 在这个数组中, 你可以看到框架代码中都已经实现了哪些指令, 其中inv的含义是invalid, 代表对应的指令还没有实现(也可能是x86中不存在该指令). 在以后的PA中, 随着你实现越来越多的指令, 这个数组会逐渐被它们代替. 关于指令执行的详细解释和 exec() 相关的内容需要涉及很多细节, 目前你不必关心, 我们将会在PA2中进行解释.

温故而知新

opcode_table 到底是个什么类型的数组? 如果你感到困惑, 你需要马上复习程序设计的知识了. 这里有一份十分优秀的C语言教程, 事实上, 我们已经在PA0中提到过这份教程了, 如果你觉得你的程序设计知识比较生疏, 而又没有在PA0中阅读这份教程, 请你务必阅读它.

NEMU将不断执行指令, 直到遇到以下情况之一, 才会退出指令执行的循环:

  • 达到要求的循环次数.
  • 用户程序执行了 nemu_trap 指令. 这是一条特殊的指令, 机器码为 0xd6 . x86中并没有这条指令, 它是为了指示程序的结束而加入的. 在后续的实验中, 我们还会使用这条指令实现一些无法通过程序本身完成的, 需要NEMU帮助的功能.

退出 cpu_exec() 之后, NEMU将返回到 ui_mainloop() , 等待用户输入命令. 但为了再次运行程序, 你需要退出NEMU, 然后重新运行.

究竟要执行多久?

cmd_c() 函数中, 调用 cpu_exec() 的时候传入了参数 -1 , 你知道这是什么意思吗?

谁来指示程序的结束?

在程序设计课上老师告诉你, 当程序执行到 main() 函数返回处的时候, 程序就退出了, 你对此深信不疑. 但你是否怀疑过, 凭什么程序执行到 main() 函数的返回处就结束了? 如果有人告诉你, 程序设计课上老师的说法是错的, 你有办法来证明/反驳吗? 如果你对此感兴趣, 请在互联网上搜索相关内容.

最后我们聊聊代码中一些值得注意的地方.

  • 三个对调试有用的宏(在 nemu/include/debug.h 中定义)

    • Log()printf() 的升级版, 专门用来输出调试信息, 同时还会输出使用 Log() 所在的源文件, 行号和函数, 当输出的调试信息过多的时候, 可以很方便地定位到代码中的相关位置
    • Assert()assert() 的升级版, 当测试条件为假时, 在assertion fail之前可以输出一些信息
    • panic() 用于输出信息并结束程序, 相当于无条件的assertion fail

    代码中已经给出了使用这三个宏的例子, 如果你不知道如何使用它们, RTFSC.

  • 访问模拟的内存
    • 在程序运行的过程中, 总是使用 swaddr_read()swaddr_write() 访问模拟的内存. swaddr, lnaddr, hwaddr分别代表虚拟地址, 线性地址, 物理地址, 这些概念将在PA3中用到, 但从现在开始保持接口的一致性可以在将来避免一些不必要的麻烦.

大致弄清楚NEMU的工作方式之后, 你就可以开始做PA1了. 需要注意的是, 上面描述的只是一个十分大概的过程, 如果你对这个过程有疑问, RTFSC.

理解框架代码

你需要结合上述文字理解NEMU的框架代码. 需要注意的是, 阅读代码也是有技巧的, 如果你分开阅读框架代码和上述文字, 你可能会觉得阅读之后没有任何效果, 因此, 你需要一边阅读上述文字, 一边阅读相应的框架代码.

如果你不知道"怎么才算是看懂了框架代码", 你可以先尝试进行后面的任务, 如果发现不知道如何下手, 再回来仔细阅读这一页面. 理解框架代码是一个螺旋上升的过程, 不同的阶段有不同的重点, 你不必因为看不懂某些细节而感到沮丧, 更不要试图一次把所有代码全部看明白.

results matching ""

    No results matching ""