用户程序和系统调用

通过yield test了解自陷指令的执行过程后, 我们就可以来理解一个最简单的操作系统如何工作了.

最简单的操作系统

眼见为实, 我们先来展示一下操作系统究竟可以多简单. 在PA中使用的操作系统叫Nanos-lite, 它是南京大学操作系统Nanos的裁剪版. 是一个为PA量身订造的操作系统. 通过编写Nanos-lite的代码, 你将会认识到操作系统是如何使用硬件提供的机制(也就是ISA和AM), 来支撑程序的运行, 这也符合PA的终极目标. 至于完整版的Nanos, 你将会在下学期的操作系统课程上见识到它的庐山真面目.

我们为大家准备了Nanos-lite的框架代码, 通过执行以下命令获取:

cd ics2024
bash init.sh nanos-lite

Nanos-lite包含了后续PA用到的所有模块, 但大部分模块的具体功能都没有实现. 由于硬件(NEMU)的功能是逐渐添加的, Nanos-lite也要配合这个过程, 你会通过nanos-lite/include/common.h中一些与实验进度相关的宏来控制Nanos-lite的功能. 随着实验进度的推进, 我们会逐渐讲解所有的模块, Nanos-lite做的工作也会越来越多. 因此在阅读Nanos-lite的代码时, 你只需要关心和当前进度相关的模块就可以了, 不要纠缠于和当前进度无关的代码.

nanos-lite
├── include
│   ├── common.h
│   ├── debug.h
│   ├── fs.h
│   ├── memory.h
│   └── proc.h
├── Makefile
├── README.md
├── resources
│   └── logo.txt    # Project-N logo文本
└── src
    ├── device.c    # 设备抽象
    ├── fs.c        # 文件系统
    ├── irq.c       # 中断异常处理
    ├── loader.c    # 加载器
    ├── main.c
    ├── mm.c        # 存储管理
    ├── proc.c      # 进程调度
    ├── ramdisk.c   # ramdisk驱动程序
    ├── resources.S # ramdisk内容和Project-N logo
    └── syscall.c   # 系统调用处理

需要提醒的是, Nanos-lite是运行在AM之上, AM的API在Nanos-lite中都是可用的. 虽然操作系统对我们来说是一个特殊的概念, 但在AM看来, 它只是一个调用AM API的普通C程序而已, 和超级玛丽没什么区别. 同时, 你会再次体会到AM的好处: Nanos-lite的实现可以是架构无关的, 这意味着, 无论你之前选择的是哪一款ISA, 都可以很容易地运行Nanos-lite, 甚至你可以像开发klib那样, 在native上调试你编写的Nanos-lite.

另外, 虽然不会引起明显的误解, 但在引入Nanos-lite之后, 我们还是会在某些地方使用"用户进程"的概念, 而不是"用户程序". 如果你现在不能理解什么是进程, 你只需要把进程作为"正在运行的程序"来理解就可以了. 还感觉不出这两者的区别? 举一个简单的例子吧, 如果你打开了记事本3次, 计算机上就会有3个记事本进程在运行, 但磁盘中的记事本程序只有一个. 进程是操作系统中一个重要的概念, 有关进程的详细知识会在操作系统课上进行介绍.

一开始, 在nanos-lite/include/common.h中所有与实验进度相关的宏都没有定义, 此时Nanos-lite的功能十分简单. 我们来简单梳理一下Nanos-lite目前的行为:

  1. 打印Project-N的logo, 并通过Log()输出hello信息和编译时间. 需要说明的是, Nanos-lite中定义的Log()宏并不是NEMU中定义的Log()宏. Nanos-lite和NEMU是两个独立的项目, 它们的代码不会相互影响, 你在阅读代码的时候需要注意这一点. 在Nanos-lite中, Log()宏通过你在klib中编写的printf()输出, 最终会调用TRM的putch().
  2. 调用init_device()对设备进行一些初始化操作. 目前init_device()会直接调用ioe_init().
  3. 初始化ramdisk. 一般来说, 程序应该存放在永久存储的介质中(比如磁盘). 但要在NEMU中对磁盘进行模拟是一个略显复杂工作, 因此先让Nanos-lite把其中的一段内存作为磁盘来使用. 这样的磁盘有一个专门的名字, 叫ramdisk.
  4. init_fs()init_proc(), 分别用于初始化文件系统和创建进程, 目前它们均未进行有意义的操作, 可以忽略它们.
  5. 调用panic()结束Nanos-lite的运行.

由于Nanos-lite本质上也是一个AM程序, 我们可以采用相同的方式来编译/运行Nanos-lite. 在nanos-lite/目录下执行

make ARCH=$ISA-nemu run

即可. 另外如前文所说, 你也可以将Nanos-lite编译到native上并运行, 来帮助你进行调试.

操作系统是个C程序

你也许有点不敢相信, 但框架代码的.c和.h文件无疑蕴含着这铁一般的事实, 甚至连编译方式也看不出什么特殊的地方. GNU/Linux也是这样: 如果你阅读它的源代码, 就会发现GNU/Linux只不过是个巨大的C程序而已.

那么和一般的C程序相比, 操作系统究竟有何特殊之处呢? 完成PA3之后, 相信你就会略知一二了.

框架代码提供的这个操作系统还真的什么都没做! 别着急, 你需要在nanos-lite/include/common.h中定义宏HAS_CTE, 这样以后, Nanos-lite会多进行以下操作:

  • 初始化时调用init_irq()函数, 它将通过cte_init()函数初始化CTE
  • panic()前调用yield()来触发自陷操作

为Nanos-lite实现正确的事件分发

Nanos-lite的事件处理回调函数默认不处理所有事件, 你需要在其中识别出自陷事件EVENT_YIELD, 然后输出一句话即可, 目前无需进行其它操作.

重新运行Nanos-lite, 如果你的实现正确, 你会看到识别到自陷事件之后输出的信息, 并且最后仍然触发了main()函数末尾设置的panic().

确认Nanos-lite可以正确触发自陷操作后, 用户程序就可以将执行流切换到操作系统指定的入口了. 现在我们来解决实现批处理系统的第二个问题: 如何加载用户程序.

加载第一个用户程序

在操作系统中, 加载用户程序是由loader(加载器)模块负责的. 我们知道程序中包括代码和数据, 它们都是存储在可执行文件中. 加载的过程就是把可执行文件中的代码和数据放置在正确的内存位置, 然后跳转到程序入口, 程序就开始执行了. 更具体的, 为了实现loader()函数, 我们需要解决以下问题:

  • 可执行文件在哪里?
  • 代码和数据在可执行文件的哪个位置?
  • 代码和数据有多少?
  • "正确的内存位置"在哪里?

为了回答第一个问题, 我们还要先说明一下用户程序是从哪里来的. 用户程序运行在操作系统之上, 由于运行时环境的差异, 我们不能把编译到AM上的程序放到操作系统上运行. 为此, 我们准备了一个新的子项目Navy-apps, 专门用于编译出操作系统的用户程序. 通过执行以下命令获取Navy的框架代码:

cd ics2024
bash init.sh navy-apps

Navy子项目的结构组织如下, 更多的说明可以阅读README.md:

navy-apps
├── apps            # 用户程序
│   ├── am-kernels
│   ├── busybox
│   ├── fceux
│   ├── lua
│   ├── menu
│   ├── nplayer
│   ├── nslider
│   ├── nterm
│   ├── nwm
│   ├── onscripter
│   ├── oslab0
│   └── pal         # 仙剑奇侠传
├── fsimg           # 根文件系统
├── libs            # 运行库
│   ├── libc        # Newlib C库
│   ├── libam
│   ├── libbdf
│   ├── libbmp
│   ├── libfixedptc
│   ├── libminiSDL
│   ├── libndl
│   ├── libos       # 系统调用的用户层封装
│   ├── libSDL_image
│   ├── libSDL_mixer
│   ├── libSDL_ttf
│   └── libvorbis
├── Makefile
├── README.md
├── scripts
└── tests           # 一些测试

Navy的Makefile组织和abstract-machine非常类似, 你应该很容易理解它. 其中, navy-apps/libs/libc中是一个名为Newlib在新窗口中打开的项目, 它是一个专门为嵌入式系统提供的C库, 库中的函数对运行时环境的要求极低. 这对Nanos-lite来说是非常友好的, 我们不必为了配合C库而在Nanos-lite中实现额外的功能. 用户程序的入口位于navy-apps/libs/libos/src/crt0/start.S中的_start()函数, 这里的crtC RunTime的缩写, 0的含义表示最开始. _start()函数会调用navy-apps/libs/libos/src/crt0/crt0.c中的call_main()函数, 然后调用用户程序的main()函数, 从main()函数返回后会调用exit()结束运行.

C库的代码"总是"对的

有同学曾经在调bug时认为是C库的代码有bug, 对C库的代码进行修改之后, 果然就可以成功运行了. 事实上, PA的必做内容并不需要修改C库的代码. 修改C库代码使得程序运行成功, 说明有bug的还是你的代码. 修改C库的方式只是在绕开一个你已经发现的bug, 它并没有被解决, 而是回到潜伏的状态, 你很可能会在将来再次遇到它, 解决它所付出的代码也许会更大, 而且遇到的时候你还很难确定它们是不是同一个bug.

总之, 当你决定要投机取巧的时候, 你需要先冷静分析得失, 再做决定.

我们要在Nanos-lite上运行的第一个用户程序是navy-apps/tests/dummy/dummy.c. 为了避免和Nanos-lite的内容产生冲突, 我们约定目前用户程序需要被链接到内存位置0x3000000(x86)或0x83000000(mips32或riscv32)附近, Navy已经设置好了相应的选项(见navy-apps/scripts/$ISA.mk中的LDFLAGS变量). 为了编译dummy, 在navy-apps/tests/dummy/目录下执行

make ISA=$ISA

首次在Navy中编译时会从github上获取Newlib等项目并编译, 编译过程中会出现较多warning, 目前可以忽略它们. 编译成功后把navy-apps/tests/dummy/build/dummy-$ISA手动复制并重命名为nanos-lite/build/ramdisk.img, 然后在nanos-lite/目录下执行

make ARCH=$ISA-nemu

会生成Nanos-lite的可执行文件, 编译期间会把ramdisk镜像文件nanos-lite/build/ramdisk.img 包含进Nanos-lite成为其中的一部分(在nanos-lite/src/resources.S中实现). 现在的ramdisk十分简单, 它只有一个文件, 就是我们将要加载的用户程序dummy, 这其实已经回答了上述第一个问题: 可执行文件位于ramdisk偏移为0处, 访问它就可以得到用户程序的第一个字节.

为了回答剩下的问题, 我们首先需要了解可执行文件是如何组织的. 你应该已经在课堂上学习过ELF文件格式了, 它除了包含程序本身的代码和静态数据之外, 还包括一些用来描述它们的信息, 否则我们连代码和数据之间的分界线在哪里都不知道. 这些信息描述了可执行文件的组织形式, 不同组织形式形成了不同格式的可执行文件, 例如Windows主流的可执行文件是PE(Portable Executable)在新窗口中打开格式, 而GNU/Linux主要使用ELF(Executable and Linkable Format)在新窗口中打开格式. 因此一般情况下, 你不能在Windows下把一个可执行文件拷贝到GNU/Linux下执行, 反之亦然. ELF是GNU/Linux可执行文件的标准格式, 这是因为GNU/Linux遵循System V ABI(Application Binary Interface在新窗口中打开).

堆和栈在哪里?

我们提到了代码和数据都在可执行文件里面, 但却没有提到堆(heap)和栈(stack). 为什么堆和栈的内容没有放入可执行文件里面? 那程序运行时刻用到的堆和栈又是怎么来的? AM的代码是否能给你带来一些启发?

如何识别不同格式的可执行文件?

如果你在GNU/Linux下执行一个从Windows拷过来的可执行文件, 将会报告"格式错误". 思考一下, GNU/Linux是如何知道"格式错误"的?

ELF文件提供了两个视角来组织一个可执行文件, 一个是面向链接过程的section视角, 这个视角提供了用于链接与重定位的信息(例如符号表); 另一个是面向执行的segment视角, 这个视角提供了用于加载可执行文件的信息. 通过readelf命令, 我们还可以看到section和segment之间的映射关系: 一个segment可能由0个或多个section组成, 但一个section可能不被包含于任何segment中.

我们现在关心的是如何加载程序, 因此我们重点关注segment的视角. ELF中采用program header table来管理segment, program header table的一个表项描述了一个segment的所有属性, 包括类型, 虚拟地址, 标志, 对齐方式, 以及文件内偏移量和segment大小. 根据这些信息, 我们就可以知道需要加载可执行文件的哪些字节了, 同时我们也可以看到, 加载一个可执行文件并不是加载它所包含的所有内容, 只要加载那些与运行时刻相关的内容就可以了, 例如调试信息和符号表就不必加载. 我们可以通过判断segment的Type属性是否为PT_LOAD来判断一个segment是否需要加载.

冗余的属性?

使用readelf查看一个ELF文件的信息, 你会看到一个segment包含两个大小的属性, 分别是FileSizMemSiz, 这是为什么? 再仔细观察一下, 你会发现FileSiz通常不会大于相应的MemSiz, 这又是为什么?

我们通过下面的图来说明如何根据segment的属性来加载它:

   +-------+---------------+-----------------------+
   |       |...............|                       |
   |       |...............|                       |  ELF file
   |       |...............|                       |
   +-------+---------------+-----------------------+
   0       ^               |              
           |<------+------>|       
           |       |       |             
           |       |                            
           |       +----------------------------+       
           |                                    |       
Type       |   Offset    VirtAddr    PhysAddr   |FileSiz  MemSiz   Flg  Align
LOAD       +-- 0x001000  0x03000000  0x03000000 +0x1d600  0x27240  RWE  0x1000
                            |                       |       |     
                            |   +-------------------+       |     
                            |   |                           |     
                            |   |     |           |         |       
                            |   |     |           |         |      
                            |   |     +-----------+ ---     |     
                            |   |     |00000000000|  ^      |   
                            |   | --- |00000000000|  |      |    
                            |   |  ^  |...........|  |      |  
                            |   |  |  |...........|  +------+
                            |   +--+  |...........|  |      
                            |      |  |...........|  |     
                            |      v  |...........|  v    
                            +-------> +-----------+ ---  
                                      |           |     
                                      |           |    
                                         Memory  

你需要找出每一个需要加载的segment的Offset, VirtAddr, FileSizMemSiz这些参数. 其中相对文件偏移Offset指出相应segment的内容从ELF文件的第Offset字节开始, 在文件中的大小为FileSiz, 它需要被分配到以VirtAddr为首地址的虚拟内存位置, 在内存中它占用大小为MemSiz. 也就是说, 这个segment使用的内存就是[VirtAddr, VirtAddr + MemSiz)这一连续区间, 然后将segment的内容从ELF文件中读入到这一内存区间, 并将[VirtAddr + FileSiz, VirtAddr + MemSiz)对应的物理区间清零.

为什么要清零?

为什么需要将 [VirtAddr + FileSiz, VirtAddr + MemSiz) 对应的物理区间清零?

关于程序从何而来, 可以参考一篇文章: COMPILER, ASSEMBLER, LINKER AND LOADER: A BRIEF STORY在新窗口中打开. 如果你希望查阅更多与ELF文件相关的信息, 请参考

man 5 elf

由于ELF文件在ramdisk中, 框架代码提供了一些ramdisk相关的函数(在nanos-lite/src/ramdisk.c中定义), 你可以使用它们来实现loader的功能:

// 从ramdisk中`offset`偏移处的`len`字节读入到`buf`中
size_t ramdisk_read(void *buf, size_t offset, size_t len);

// 把`buf`中的`len`字节写入到ramdisk中`offset`偏移处
size_t ramdisk_write(const void *buf, size_t offset, size_t len);

// 返回ramdisk的大小, 单位为字节
size_t get_ramdisk_size();

事实上, loader的工作向我们展现出了程序的最为原始的状态: 比特串! 加载程序其实就是把这一毫不起眼的比特串放置在正确的位置, 但这其中又折射出"存储程序"的划时代思想: 当操作系统将控制权交给它的时候, 计算机把它解释成指令并逐条执行. loader让计算机的生命周期突破程序的边界: 一个程序结束并不意味着计算机停止工作, 计算机将终其一生履行执行程序的使命.

实现loader

你需要在Nanos-lite中实现loader的功能, 来把用户程序加载到正确的内存位置, 然后执行用户程序. loader()函数在nanos-lite/src/loader.c中定义, 其中的pcb参数目前暂不使用, 可以忽略, 而因为ramdisk中目前只有一个文件, filename参数也可以忽略. 在下一个阶段实现文件系统之后, filename就派上用场了.

实现后, 在init_proc()中调用naive_uload(NULL, NULL), 它会调用你实现的loader来加载第一个用户程序, 然后跳转到用户程序中执行. 如果你的实现正确, 你会看到执行dummy程序时在Nanos-lite中触发了一个未处理的4号事件. 这说明loader已经成功加载dummy, 并且成功地跳转到dummy中执行了. 关于未处理的事件, 我们会在下文进行说明.

检查ELF文件的魔数

我们知道ELF文件的开头都有一个特殊的魔数, 为了防止loader加载了一个非ELF格式的文件, 我们可以在loader中对魔数进行检查:

assert(*(uint32_t *)elf->e_ident == 0xBadC0de);

你需要把上述的0xBadC0de换成正确的魔数.

别小看这个表面上很蠢的assert(), 当你哪天手抖不知道做了什么而又被它抓到的时候, 你就知道谢天谢地了.

检测ELF文件的ISA类型

你很有可能会因为疏忽, 从而让native的Nanos-lite来加载运行一个x86/mips32/riscv32的dummy. 从ISA规范的角度来说, 这种行为显然属于UB, 具体而言通常会发生一些难以理解的错误. 为了避免这种情况, 你可以在loader中检测ELF文件的ISA类型. 我们可以根据AM中定义的一些宏来筛选出预期的ISA类型:

#if defined(__ISA_AM_NATIVE__)
# define EXPECT_TYPE EM_X86_64
#elif defined(__ISA_X86__)
# define EXPECT_TYPE ...  // see /usr/include/elf.h to get the right type
...
#else
# error Unsupported ISA
#endif

然后和ELF信息中的某个域进行对比, 如果发现要加载的ELF文件的ISA类型和预期不一致, 就报错. 如果你不知道AM中的宏在哪里定义, RTFSC. 如果你不知道应该和ELF中的哪个域进行对比, RTFM.

将Nanos-lite编译到native

你可以在native上测试你的Nanos-lite实现是否正确.

由于native是64位环境, ELF的一些数据结构会与32位有所不同, 但总的来说, ELF的加载过程是一样的. 为了屏蔽数据结构的差异, nanos-lite/src/loader.c中定义了宏Elf_EhdrElf_Phdr, 你可以在loader的实现中使用它们.

另外为了编译可以在AM native的Nanos-lite上运行的dummy, 你需要在Navy中通过make ISA=am_native来编译. 之后的操作与在$ISA-nemu上运行的操作类似, 即:

  1. 使用ISA=xxx编译dummy
  2. 把编译出的dummy ELF文件作为nanos-lite的ramdisk
  3. 使用ARCH=xxx编译并运行nanos-lite

Navy中还有一个叫native的ISA, 它与AM中名为native的ARCH机制有所不同, 目前暂不使用.

操作系统的运行时环境

加载程序之后, 我们就来谈谈程序的运行. 回顾PA2, 我们已经知道, 程序的运行需要运行时环境的支撑. 而操作系统希望加载并运行程序, 自然有责任来提供运行时环境的功能. 在PA2中, 我们根据具体实现是否与ISA相关, 将运行时环境划分为两部分. 但对于运行在操作系统上的程序, 它们就不需要直接与硬件交互了. 那么在操作系统看来, 它应该从什么角度来看这些运行时环境呢?

注意到运行时环境的部分功能是需要使用资源的, 比如申请内存需要使用物理内存, 更新屏幕需要使用帧缓冲. 在PA2中, 我们的计算机系统是被一个程序独占的, 它可以想怎么玩就怎么玩, 玩坏了也是它一个程序的事情. 而在现代的计算机系统中, 可能会有多个程序并发甚至同时使用计算机系统中的资源. 如果每个程序都直接使用这些资源, 各自都不知道对方的使用情况, 很快整个系统就会乱套了: 比如我覆盖了你的画面, 你覆盖了我的内存空间...

所以需要有一个角色来对系统中的资源进行统一的管理: 程序不能擅自使用资源了, 使用的时候需要向资源管理者提出申请. 既然操作系统位于高特权级, 享受着至高无上的权利, 自然地它也需要履行相应的义务: 作为资源管理者管理着系统中的所有资源, 操作系统还需要为用户程序提供相应的服务. 这些服务需要以一种统一的接口来呈现, 用户程序也只能通过这一接口来请求服务.

这一接口就是系统调用. 这是操作系统从诞生那一刻就被赋予的使命: 我们之前提到GM-NAA I/O的一个主要任务就是加载新程序, 而它的另一个主要功能, 就是为程序提供输入输出的公共接口. GM-NAA I/O所提供的公共接口, 可以认为是系统调用的初原形态.

系统调用的必要性

对于批处理系统来说, 系统调用是必须的吗? 如果直接把AM的API暴露给批处理系统中的程序, 会不会有问题呢?

于是, 系统调用把整个运行时环境分成两部分, 一部分是操作系统内核区, 另一部分是用户区. 那些会访问系统资源的功能会放到内核区中实现, 而用户区则保留一些无需使用系统资源的功能(比如strcpy()), 以及用于请求系统资源相关服务的系统调用接口.

在这个模型之下, 用户程序只能在用户区安分守己地"计算", 任何超越纯粹计算能力之外的任务, 都需要通过系统调用向操作系统请求服务. 如果用户程序尝试进行任何非法操作, CPU就会向操作系统抛出一个异常信号, 让非法操作的指令执行"失败", 并交由操作系统进行处理. 对, 这就是之前介绍的硬件保护机制, 操作系统需要借助这一天然屏障来阻挡程序的恶意行为.

虽然操作系统需要为用户程序服务, 但这并不意味着操作系统需要把所有信息都暴露给用户程序. 有些信息是用户进程没有必要知道的, 也永远不应该知道, 例如一些与内存管理相关的数据结构. 如果一个恶意程序获得了这些信息, 可能会为恶意攻击提供了信息基础. 因此, 通常不存在一个系统调用来获取这些操作系统的私有数据.

系统调用

那么, 触发一个系统调用的具体过程是怎么样的呢?

现实生活中的经验可以给我们一些启发: 我们到银行办理业务的时候, 需要告诉工作人员要办理什么业务, 账号是什么, 交易金额是多少, 这无非是希望工作人员知道我们具体想做什么. 用户程序执行系统调用的时候也是类似的情况, 要通过一种方法描述自己的需求, 然后告诉操作系统.

说起"告诉操作系统", 你应该马上想起来, 这是通过自陷指令来实现的. 在GNU/Linux中, 用户程序通过自陷指令来触发系统调用, Nanos-lite也沿用这个约定. CTE中的yield()也是通过自陷指令来实现, 虽然它们触发了不同的事件, 但从上下文保存到事件分发, 它们的过程都是非常相似的. 既然我们通过自陷指令来触发系统调用, 那么对用户程序来说, 用来向操作系统描述需求的最方便手段就是使用通用寄存器了, 因为执行自陷指令之后, 执行流就会马上切换到事先设置好的入口, 通用寄存器也会作为上下文的一部分被保存起来. 系统调用处理函数只需要从上下文中获取必要的信息, 就能知道用户程序发出的服务请求是什么了.

Navy已经为用户程序准备好了系统调用的接口了. navy-apps/libs/libos/src/syscall.c中定义的_syscall_()函数已经蕴含着上述过程:

intptr_t _syscall_(intptr_t type, intptr_t a0, intptr_t a1, intptr_t a2) {
  // ...
  asm volatile (SYSCALL : "=r" (ret) : "r"(_gpr1), "r"(_gpr2), "r"(_gpr3), "r"(_gpr4));
  return ret;
}

上述代码会先把系统调用的参数依次放入寄存器中, 然后执行自陷指令. 由于寄存器和自陷指令都是ISA相关的, 因此这里根据不同的ISA定义了不同的宏, 来对它们进行抽象. CTE会将这个自陷操作打包成一个系统调用事件EVENT_SYSCALL, 并交由Nanos-lite继续处理.

识别系统调用

目前dummy已经通过_syscall_()直接触发系统调用, 你需要让Nanos-lite识别出系统调用事件EVENT_SYSCALL.

处理器通常只会提供一条自陷指令, 这时EVENT_SYSCALLEVENT_YIELD 都通过相同的自陷指令来实现, 因此CTE需要额外的方式区分它们. 如果自陷指令本身可以携带参数, 就可以用不同的参数指示不同的事件, 例如x86和mips32都可以采用这种方式; 如果自陷指令本身不能携带参数, 那就需要通过其他状态来区分, 一种方式是通过某个寄存器的值来区分, riscv32采用这种方式.

你可能需要对多处代码进行修改, 当你为代码无法实现正确而感到疑惑时, 请检查这个过程中的每一个细节. 我们已经强调了很多次, 理解细节是很重要的.

Nanos-lite收到系统调用事件之后, 就会调出系统调用处理函数do_syscall()进行处理. do_syscall()首先通过宏GPR1从上下文c中获取用户进程之前设置好的系统调用参数, 通过第一个参数 - 系统调用号 - 进行分发. 但目前Nanos-lite没有实现任何系统调用, 因此触发了panic.

添加一个系统调用比你想象中要简单, 所有信息都已经准备好了. 我们只需要在分发的过程中添加相应的系统调用号, 并编写相应的系统调用处理函数sys_xxx(), 然后调用它即可. 回过头来看dummy程序, 它触发了一个SYS_yield系统调用. 我们约定, 这个系统调用直接调用CTE的yield()即可, 然后返回0.

处理系统调用的最后一件事就是设置系统调用的返回值. 对于不同的ISA, 系统调用的返回值存放在不同的寄存器中, 宏GPRx用于实现这一抽象, 所以我们通过GPRx来进行设置系统调用返回值即可.

经过CTE, 执行流会从do_syscall()一路返回到用户程序的_syscall_()函数中. 代码最后会从相应寄存器中取出系统调用的返回值, 并返回给_syscall_()的调用者, 告知其系统调用执行的情况(如是否成功等).

实现SYS_yield系统调用

你需要:

  1. abstract-machine/am/include/arch/目录下的相应头文件中实现正确的GPR?宏, 让它们从上下文c中获得正确的系统调用参数寄存器.
  2. 添加SYS_yield系统调用.
  3. 设置系统调用的返回值.

重新运行dummy程序, 如果你的实现正确, 你会看到dummy程序又触发了一个号码为0的系统调用. 查看nanos-lite/src/syscall.h, 你会发现它是一个SYS_exit系统调用. 这说明之前的SYS_yield已经成功返回, 触发SYS_exit是因为dummy已经执行完毕, 准备退出了.

实现SYS_exit系统调用

你需要实现SYS_exit系统调用, 它会接收一个退出状态的参数. 为了方便测试, 我们目前先直接使用这个参数调用halt(). 实现成功后, 再次运行dummy程序, 你会看到HIT GOOD TRAP的信息.

RISC-V系统调用号的传递

如果你选择的是RISC-V, 你会发现它并不是通过a0来传递系统调用号. 事实上, 我们参考了RISC-V Linux的系统调用参数传递的约定: 即在RISC-V Linux上也是通过这个寄存器来传递系统调用号的. 你觉得RISC-V Linux为什么没有使用a0来传递系统调用号呢?

Linux的系统调用

你可以通过以下命令查阅Linux系统调用的相关信息:

  • man syscall - 查阅不同架构的系统调用约定, 包括参数传递和返回值
  • man syscalls - 查阅Linux中已经实现的系统调用. 噢好多啊, 不过我们在PA中只需要几个系统调用就可以了

系统调用的踪迹 - strace

我们已经知道, 在操作系统上运行的用户程序只能做两件事, 一件是进行本地计算, 另一件就是通过系统调用请求操作系统来完成那些本地计算无法完成的工作. 这意味着, 如果我们可以观察系统调用的踪迹(syscall trace), 我们就能对程序的行为有更深入的了解了. Linux上有一个叫strace的工具(可通过apt-get安装), 它可以记录用户程序进行系统调用的踪迹, 我们强烈推荐你安装并尝试使用它. 例如你可以通过strace ls来了解ls的行为, 你甚至可以看到ls是如何被加载的; 如果你对strace本身感兴趣, 你还可以通过strace strace ls来研究strace是如何实现的.

事实上, 我们也可以在Nanos-lite中实现一个简单的strace: Nanos-lite可以得到系统调用的所有信息, 包括名字, 参数和返回值. 这也是为什么我们选择在Nanos-lite中实现strace: 系统调用是携带高层的程序语义的, 但NEMU中只能看到底层的状态机.

实现strace

在Nanos-lite中实现strace是一个很简单的任务.

操作系统之上的TRM

我们已经实现了两个很简单的系统调用了, 那么在当前的Nanos-lite上, 用户程序还可以做什么呢? 你也许想起我们在PA2中是如何对程序的需求分类的了, 那就是AM! 最基本的, TRM向我们展示了, 为了满足程序的基本计算能力, 需要有哪些条件:

  • 机器提供基本的运算指令
  • 能输出字符
  • 有堆区可以动态申请内存
  • 可以结束运行

基本的运算指令还是得靠机器提供, 也就是你在PA2中已经实现的指令系统. 至于结束运行, SYS_exit系统调用也已经提供了. 为了向用户程序提供输出字符和内存动态申请的功能, 我们需要实现更多的系统调用.

标准输出

在GNU/Linux中, 输出是通过SYS_write系统调用来实现的. 根据write的函数声明(参考man 2 write), 你需要在do_syscall()中识别出系统调用号是SYS_write之后, 检查fd的值, 如果fd12(分别代表stdoutstderr), 则将buf为首地址的len字节输出到串口(使用putch()即可). 最后还要设置正确的返回值, 否则系统调用的调用者会认为write没有成功执行, 从而进行重试. 至于write系统调用的返回值是什么, 请查阅man 2 write. 另外不要忘记在navy-apps/libs/libos/src/syscall.c_write()中调用系统调用接口函数.

事实上, 我们平时使用的printf(), cout这些库函数和库类, 对字符串进行格式化之后, 最终也是通过系统调用进行输出. 这些都是"系统调用封装成库函数"的例子. 系统调用本身对操作系统的各种资源进行了抽象, 但为了给上层的程序员提供更好的接口(beautiful interface), 库函数会再次对部分系统调用再次进行抽象. 例如fwrite()这个库函数用于往文件中写入数据, 在GNU/Linux中, 它封装了write()系统调用. 另一方面, 系统调用依赖于具体的操作系统, 因此库函数的封装也提高了程序的可移植性: 在Windows中, fwrite()封装了WriteFile()系统调用, 如果在代码中直接使用WriteFile()系统调用, 把代码放到GNU/Linux下编译就会产生链接错误. 从某种程度上来说, 库函数的抽象确实方便了程序员, 使得他们不必关心系统调用的细节.

实现SYS_write系统调用之后, 我们已经为"使用printf()"扫除了最大的障碍了, 因为printf()进行字符串格式化之后, 最终会通过write()系统调用进行输出. 这些工作, Navy中的Newlib库已经为我们准备好了.

在Nanos-lite上运行Hello world

Navy中提供了一个hello测试程序(navy-apps/tests/hello), 它首先通过write()来输出一句话, 然后通过printf()来不断输出.

你需要实现write()系统调用, 然后把Nanos-lite上运行的用户程序切换成hello程序来运行.

堆区管理

你应该已经在程序设计课上使用过malloc()/free()库函数, 它们的作用是在用户程序的堆区中申请/释放一块内存区域. 堆区的使用情况是由libc来进行管理的, 但堆区的大小却需要通过系统调用向操作系统提出更改. 这是因为, 堆区的本质是一片内存区域, 当需要调整堆区大小的时候, 实际上是在调整用户程序可用的内存区域. 事实上, 一个用户程序可用的内存区域是需要经过操作系统的分配和管理的. 想象一下, 如果一个恶意程序可以不经过操作系统的同意, 就随意使用其它程序的内存区域, 将会引起灾难性的后果. 当然, 目前Nanos-lite只是个单任务操作系统, 不存在多个程序的概念. 在PA4中, 你将会对这个问题有更深刻的认识.

调整堆区大小是通过sbrk()库函数来实现的, 它的原型是

void* sbrk(intptr_t increment);

用于将用户程序的program break增长increment字节, 其中increment可为负数. 所谓program break, 就是用户程序的数据段(data segment)结束的位置. 我们知道可执行文件里面有代码段和数据段, 链接的时候ld会默认添加一个名为_end的符号, 来指示程序的数据段结束的位置. 用户程序开始运行的时候, program break会位于_end所指示的位置, 意味着此时堆区的大小为0. malloc()被第一次调用的时候, 会通过sbrk(0)来查询用户程序当前program break的位置, 之后就可以通过后续的sbrk()调用来动态调整用户程序program break的位置了. 当前program break和和其初始值之间的区间就可以作为用户程序的堆区, 由malloc()/free()进行管理. 注意用户程序不应该直接使用sbrk(), 否则将会扰乱malloc()/free()对堆区的管理记录.

在Navy的Newlib中, sbrk()最终会调用_sbrk(), 它在navy-apps/libs/libos/src/syscall.c中定义. 框架代码让_sbrk()总是返回-1, 表示堆区调整失败, 事实上, 用户程序在第一次调用printf()的时候会尝试通过malloc()申请一片缓冲区, 来存放格式化的内容. 若申请失败, 就会逐个字符进行输出. 如果你在Nanos-lite中打开strace, 你会发现用户程序通过printf()输出的时候, 确实是逐个字符地调用write()来输出的.

但如果堆区总是不可用, Newlib中很多库函数的功能将无法使用, 因此现在你需要实现_sbrk()了. 为了实现_sbrk()的功能, 我们还需要提供一个用于设置堆区大小的系统调用. 在GNU/Linux中, 这个系统调用是SYS_brk, 它接收一个参数addr, 用于指示新的program break的位置. _sbrk()通过记录的方式来对用户程序的program break位置进行管理, 其工作方式如下:

  1. program break一开始的位置位于_end
  2. 被调用时, 根据记录的program break位置和参数increment, 计算出新program break
  3. 通过SYS_brk系统调用来让操作系统设置新program break
  4. SYS_brk系统调用成功, 该系统调用会返回0, 此时更新之前记录的program break的位置, 并将旧program break的位置作为_sbrk()的返回值返回
  5. 若该系统调用失败, _sbrk()会返回-1

上述代码是在用户层的库函数中实现的, 我们还需要在Nanos-lite中实现SYS_brk的功能. 由于目前Nanos-lite还是一个单任务操作系统, 空闲的内存都可以让用户程序自由使用, 因此我们只需要让SYS_brk系统调用总是返回0即可, 表示堆区大小的调整总是成功. 在PA4中, 我们会对这一系统调用进行修改, 实现真正的内存分配.

实现堆区管理

根据上述内容在Nanos-lite中实现SYS_brk系统调用, 然后在用户层实现_sbrk(). 你可以通过man 2 sbrk来查阅libc中brk()sbrk()的行为, 另外通过man 3 end来查阅如何使用_end符号.

需要注意的是, 调试的时候不要在_sbrk()中通过printf()进行输出, 这是因为printf()还是会尝试通过malloc()来申请缓冲区, 最终会再次调用_sbrk(), 造成死递归. 你可以通过sprintf()先把调试信息输出到一个字符串缓冲区中, 然后通过_write()进行输出.

如果你的实现正确, 你可以借助strace看到printf()不再是逐个字符地通过write()进行输出, 而是将格式化完毕的字符串通过一次性进行输出.

缓冲区与系统调用开销

你已经了解系统调用的过程了. 事实上, 如果通过系统调用千辛万苦地陷入操作系统只是为了输出区区一个字符, 那就太不划算了. 于是有了批处理(batching)的技术: 将一些简单的任务累积起来, 然后再一次性进行处理. 缓冲区是批处理技术的核心, libc中的fread()fwrite()正是通过缓冲区来将数据累积起来, 然后再通过一次系统调用进行处理. 例如通过一个1024字节的缓冲区, 就可以通过一次系统调用直接输出1024个字符, 而不需要通过1024次系统调用来逐个字符地输出. 显然, 后者的开销比前者大得多.

有兴趣的同学可以在GNU/Linux上编写相应的程序, 来粗略测试一下一次write()系统调用的开销, 然后和这篇文章在新窗口中打开对比一下.

printf和换行

我们在PA1的时候提示过大家, 使用printf()调试时需要添加\n, 现在终于可以解释为什么了: fwrite()的实现中有缓冲区, printf()打印的字符不一定会马上通过write()系统调用输出, 但遇到\n时可以强行将缓冲区中的内容进行输出. 有兴趣的同学可以阅读navy-apps/libs/libc/src/stdio/wbuf.c, 这个文件实现了缓冲区的功能.

实现了这两个系统调用之后, 原则上所有TRM上能运行的程序, 现在都能在Nanos-lite上运行了. 不过我们目前并没有严格按照AM的API来将相应的系统调用功能暴露给用户程序, 毕竟与AM相比, 对操作系统上运行的程序来说, libc的接口更加广为人们所用, 我们也就不必班门弄斧了.

必答题(需要在实验报告中回答) - hello程序是什么, 它从而何来, 要到哪里去

到此为止, PA中的所有组件已经全部亮相, 整个计算机系统也开始趋于完整. 你也已经在这个自己创造的计算机系统上跑起了hello这个第一个还说得过去的用户程序 (dummy是给大家热身用的, 不算), 好消息是, 我们已经距离运行仙剑奇侠传不远了(下一个阶段就是啦).

不过按照PA的传统, 光是跑起来还是不够的, 你还要明白它究竟怎么跑起来才行. 于是来回答这道必答题吧:

我们知道navy-apps/tests/hello/hello.c只是一个C源文件, 它会被编译链接成一个ELF文件. 那么, hello程序一开始在哪里? 它是怎么出现内存中的? 为什么会出现在目前的内存位置? 它的第一条指令在哪里? 究竟是怎么执行到它的第一条指令的? hello程序在不断地打印字符串, 每一个字符又是经历了什么才会最终出现在终端上?

上面一口气问了很多问题, 我们想说的是, 这其中蕴含着非常多需要你理解的细节. 我们希望你能够认真整理其中涉及的每一行代码, 然后用自己的语言融会贯通地把这个过程的理解描述清楚, 而不是机械地分点回答这几个问题.

同样地, 上一阶段的必答题"理解穿越时空的旅程"也已经涵盖了一部分内容, 你可以把它的回答包含进来, 但需要描述清楚有差异的地方. 另外, C库中printf()write()的过程比较繁琐, 而且也不属于PA的主线内容, 这一部分不必展开回答. 而且你也已经在PA2中实现了自己的printf()了, 相信你也不难理解字符串格式化的过程. 如果你对Newlib的实现感兴趣, 你也可以RTFSC.

总之, 扣除C库中printf()write()转换的部分, 剩下的代码就是你应该理解透彻的了. 于是, 努力去理解每一行代码吧!

支持多个ELF的ftrace

如果我们想了解C库中printf()write()的过程, ftrace将是一个很好的工具. 但我们知道, Nanos-lite和它加载的用户程序是两个独立的ELF文件, 这意味着, 如果我们给NEMU的ftrace指定其中一方的ELF文件, 那么ftrace就无法正确将另一方的地址翻译成正确的函数名. 事实上, 我们可以让NEMU的ftrace支持多个ELF: 如果一个地址不属于某个ELF中的任何一个函数, 那就尝试下一个ELF. 通过这种方式, ftrace就可以同时追踪Nanos-lite和用户程序的函数调用了.

温馨提示

PA3阶段2到此结束.