用户程序和系统调用

有了自陷指令, 用户程序就可以将执行流切换到操作系统指定的入口了. 现在我们来解决如何加载用户程序的问题.

加载第一个用户程序

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

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

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

navy-apps
├── apps            # 用户程序
│   ├── init
│   ├── litenes
│   ├── lua
│   ├── nterm
│   ├── nwm
│   └── pal         # 仙剑奇侠传
├── fsimg           # 根文件系统
├── libs            # 库
│   ├── libc        # Newlib C库
│   ├── libfont
│   ├── libndl
│   └── libos       # 系统调用的用户层封装
├── Makefile
├── Makefile.app
├── Makefile.check
├── Makefile.compile
├── Makefile.lib
├── README.md
└── tests           # 一些测试

其中, navy-apps/libs/libc中是一个名为Newlib的项目, 它是一个专门为嵌入式系统提供的C库, 库中的函数对运行时环境的要求极低. 这对Nanos-lite来说是非常友好的, 我们不需要为了配合C库而在Nanos-lite中实现额外的功能. 用户程序的入口位于navy-apps/libs/libc/src/platform/crt0.c中的_start()函数, 它会调用用户程序的main()函数, 从main()函数返回后会调用exit()结束运行.

我们要在Nanos-lite上运行的第一个用户程序是navy-apps/tests/dummy/dummy.c. 为了避免和Nanos-lite的内容产生冲突, 我们约定目前用户程序需要被链接到内存位置0x4000000处, Navy-apps已经设置好了相应的选项(见navy-apps/Makefile.compile中的LDFLAGS变量).

nanos-lite/目录下执行

make ARCH=x86-nemu update

将会生成ramdisk镜像文件ramdisk.img, 并包含进Nanos-lite成为其中的一部分(在nanos-lite/src/initrd.S中实现). 执行上述命令时会编译Navy-apps项目中的Newlib, 过程中会出现较多warning, 我们可以忽略它们. 现在的ramdisk十分简单, 它只有一个文件, 就是我们将要加载的用户程序dummy, 这其实已经回答了上述第一个问题: 可执行文件位于ramdisk偏移为0处, 访问它就可以得到用户程序的第一个字节.

为了回答剩下的问题, 我们首先需要了解可执行文件是如何组织的. 你应该已经在课堂上学习过ELF文件格式了, 它除了包含程序本身的代码和静态数据之外, 还包括一些用来描述它们的组织信息. 事实上, 我们的loader目前并没有必要去解析并加载ELF文件. 为了简化, nanos-lite/Makefile中已经通过objcopy工具, 把用户程序运行所需要的代码和静态数据从ELF文件中抽取出来了, 整个ramdisk本身就已经存放了loader所需要加载的内容. 最后, "正确的内存位置", 也就是我们上文提到的约定好的0x4000000.

所以, 目前的loader只需要做一件事情: 将ramdisk中从0开始的所有内容放置在0x4000000, 并把这个地址作为程序的入口返回即可. 我们把这个简化了的loader称为raw program loader. 我们通过内存布局来理解loader目前需要做的事情:

                ramdisk_start   ramdisk_end
                     |               |
                     +--+         +--+
0  0x100000             v         v           0x4000000
+------+------------------------------+-----------+-------+-------
|      |                +---------+   |           |       |
|      | Nanos-lite     | ramdisk |   |           | dummy |
|      |                +---------+   |           |       |
+------+------------------------------+-----------+-------+-------
                        |                         ^
                        +-------------------------+
                                   loader

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

实现loader

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

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

需要注意的是, 每当ramdisk中的内容需要更新时, 你都需要在nanos-lite/目录下手动执行

make ARCH=x86-nemu update

来更新Nanos-lite中的ramdisk内容, 然后重新编译Nanos-lite来使用最新的ramdisk.

将Nanos-lite编译到native

你可以在native上测试你的Nanos-lite实现是否正确. 但是需要注意, 你需要同时生成一份与native相对应的ramdisk:

make ARCH=native update
make ARCH=native run

x86-nemu版本的Nanos-lite使用native版本的ramdisk, 或者让native版本的Nanos-lite使用x86-nemu版本的ramdisk, 都会使得用户程序无法正确运行在Nanos-lite上.

操作系统的运行时环境

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

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

所以需要有一个角色来对系统中的资源进行统一的管理: 程序不能擅自使用资源了, 使用的时候需要向资源管理者提出申请. 既然操作系统位于ring 0享受着至高无上的权利, 自然地它也需要履行相应的义务: 作为资源管理者管理着系统中的所有资源, 操作系统还需要为用户程序提供相应的服务. 这是操作系统从诞生那一刻就被赋予的使命: 我们之前提到GM-NAA I/O的一个主要任务就是加载新程序, 而它的另一个主要功能, 就是为程序提供输入输出的公共接口.

系统调用的必要性

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

既然受到硬件保护的操作系统要提供服务, 那必定会有相应的接口提供给用户程序. 用户程序只能通过这一接口来请求服务, 这一接口就是系统调用. 系统调用把整个运行时环境分成两部分, 一部分是操作系统内核区, 另一部分是用户区. 那些会访问系统资源的功能会放到内核区中实现, 而用户区则保留一些无需使用系统资源的功能(比如strcpy()), 以及用于请求系统资源相关服务的系统调用接口.

在这个模型之下, 用户程序只能在用户区安分守己地"计算", 任何超越纯粹计算能力之外的任务, 都需要通过系统调用向操作系统请求服务. 如果用户程序尝试进行任何非法操作, CPU就会向操作系统抛出一个异常信号, 并交由操作系统进行处理.

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

系统调用

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

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

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

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

intptr_t _syscall_(int type, intptr_t a0, intptr_t a1, intptr_t a2) {
  int ret;
  asm volatile("int $0x80": "=a"(ret): "a"(type), "b"(a0), "c"(a1), "d"(a2));
  return ret;
}

上述内联汇编会先把系统调用的参数依次放入%eax, %ebx, %ecx, %edx四个寄存器中, 然后执行自陷指令int $0x80. x86-nemu的CTE会将这个自陷操作打包成一个系统调用事件_EVENT_SYSCALL, 并交由Nanos-lite继续处理.

识别系统调用

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

你可能需要进行多处的代码修改, 当你为你的代码无法实现正确 而感到疑惑时, 请检查这个过程中的每一个细节.

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

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

处理系统调用的最后一件事就是设置系统调用的返回值. 我们约定系统调用的返回值存放在系统调用号所在的寄存器中, 所以我们只需要通过GPRx来进行设置就可以了.

经过CTE, 执行流会从do_syscall()一路返回到用户程序的上述内联汇编中. 内联汇编最后从%eax寄存器中取出系统调用的返回值, 并返回给_syscall()的调用者, 告知其系统调用执行的情况(如是否成功等).

实现SYS_yield系统调用

你需要:

  1. nexus-am/am/arch/x86-nemu/include/arch.h中实现正确的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程序, 你会看到GOOD TRAP的信息.

操作系统之上的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字节输出到串口(使用_putc()即可). 最后还要设置正确的返回值, 否则系统调用的调用者会认为write没有成功执行, 从而进行重试. 至于write系统调用的返回值是什么, 请查阅man 2 write. 另外不要忘记在navy-apps/libs/libos/src/nanos.c_write()中调用系统调用接口函数.

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

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

在Nanos-lite上运行Hello world

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

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

  • 修改nanos-lite/Makefile中ramdisk的生成规则, 把ramdisk中的唯一的文件换成hello程序:
    --- nanos-lite/Makefile
    +++ nanos-lite/Makefile
    @@ -13,3 +13,3 @@
    OBJCOPY_FLAG = -S --set-section-flags .bss=alloc,contents -O binary
    -OBJCOPY_APP = $(NAVY_HOME)/tests/dummy
    +OBJCOPY_APP = $(NAVY_HOME)/tests/hello
    OBJCOPY_FILE = $(OBJCOPY_APP)/build/$(notdir $(OBJCOPY_APP))-$(ISA)
    
  • nanos-lite/目录下执行make ARCH=x86-nemu update更新ramdisk
  • 重新编译Nanos-lite并运行

堆区管理

你应该已经使用过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-apps的Newlib中, sbrk()最终会调用_sbrk(), 它在navy-apps/libs/libos/src/nanos.c中定义. 框架代码让_sbrk()总是返回-1, 表示堆区调整失败, 事实上, 用户程序在第一次调用printf()的时候会尝试通过malloc()申请一片缓冲区, 来存放格式化的内容. 若申请失败, 就会逐个字符进行输出. 如果你在Nanos-lite中的sys_write()中通过Log()观察其调用情况, 你会发现用户程序通过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即可, 表示堆区大小的调整总是成功.

实现堆区管理

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

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

如果你的实现正确, 你将会在Nanos-lite中看到printf()将格式化完毕的字符串通过一次write()系统调用进行输出, 而不是逐个字符地进行输出.

缓冲区与系统调用开销

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

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

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

温馨提示

PA3阶段2到此结束.

results matching ""

    No results matching ""