TOC
Open TOC
ICS PA 3
最简单的操作系统
在 PA 中使用的操作系统叫 Nanos-lite,它是南京大学操作系统 Nanos 的裁剪版。
Nanos-lite 是运行在 AM 之上,AM 的 API 在 Nanos-lite 中都是可用的。虽然操作系统对我们来说是一个特殊的概念,但在 AM 看来,它只是一个调用 AM API 的普通 C 程序而已。
Nanos-lite 的实现可以是架构无关的。可以在 native
上调试你编写的 Nanos-lite。
用户进程与用户程序:
举一个简单的例子吧,如果你打开了记事本 3 次,计算机上就会有 3 个记事本进程在运行,但磁盘中的记事本程序只有一个。
由于 Nanos-lite 本质上也是一个 AM 程序,我们可以采用相同的方式来编译 / 运行 Nanos-lite:
回顾一下编译运行的过程:
得到 Nanos-lite 的 bin 镜像后,再调用解释器以 batch mode 运行该镜像:
类似在 NEMU 上运行 NEMU
来看一下操作系统的 main 函数:
其中 HAS_CTE 对应上下文扩展,目前尚未定义。
注意 Nanos-lite 中定义的 Log()
宏并不是 NEMU 中定义的 Log()
宏。在 Nanos-lite 中,Log()
宏通过你在 klib
中编写的 printf()
输出,最终会调用 TRM 的 putch()
。
所以需要扩展 printf 的修饰符:
64 位的实现参考:
来自操作系统的新需求
- 用户程序执行结束之后,可以跳转到操作系统的代码继续执行
- 操作系统可以加载一个新的用户程序来执行
需要一种可以限制入口的执行流切换方式。
为了阻止程序将执行流切换到操作系统的任意位置,硬件中逐渐出现保护机制相关的功能:在硬件中加入一些与特权级检查相关的门电路(例如比较器电路),如果发现了非法操作,就会抛出一个异常信号,让 CPU 跳转到一个约定好的目标位置,并进行后续处理。
以支持现代操作系统的 RISC-V 处理器为例,它们存在 M, S, U 三个特权模式,分别代表机器模式、监管者模式和用户模式。M 模式特权级最高,U 模式特权级最低,低特权级能访问的资源,高特权级也能访问。
通常来说,操作系统运行在 S 模式,因此有权限访问所有的代码和数据;而一般的程序运行在 U 模式,这就决定了它只能访问 U 模式的代码和数据。这样,只要操作系统将其私有代码和数据放 S 模式中,恶意程序就永远没有办法访问到它们。
PS:Meltdown 和 Spectre 这两个大名鼎鼎的硬件漏洞,打破了特权级的边界,恶意程序在特定的条件下可以以极高的速率窃取操作系统的信息。
根据 KISS 法则,我们并不打算在 NEMU 中加入保护机制。我们让所有用户进程都运行在最高特权级,虽然所有用户进程都有权限执行所有指令,不过由于 PA 中的用户程序都是我们自己编写的,一切还是在我们的控制范围之内。
异常响应机制
为了实现最简单的操作系统,硬件还需要提供一种可以限制入口的执行流切换方式。这种方式就是自陷指令,程序执行自陷指令之后,就会陷入到操作系统预先设置好的跳转目标。这个跳转目标也称为异常入口地址。
CSAPP Chapter 8: exception
- interrupt
- trap
- fault
- abort
The coordination between hardware and software (OS).
这一过程是 ISA 规范的一部分,以 riscv32 为例:
riscv32 提供 ecall
指令作为自陷指令,并提供一个 mtvec 寄存器来存放异常入口地址。为了保存程序当前的状态,riscv32 提供了一些特殊的系统寄存器,叫控制状态寄存器 (CSR 寄存器)。在 PA 中,我们只使用如下 3 个 CSR 寄存器:
- mepc 寄存器 - 存放触发异常的 PC
- mstatus 寄存器 - 存放处理器的状态
- mcause 寄存器 - 存放触发异常的原因
riscv32 触发异常后硬件的响应过程如下:
- 将当前 PC 值保存到 mepc 寄存器
- 在 mcause 寄存器中设置异常号
- 从 mtvec 寄存器中取出异常入口地址
- 跳转到异常入口地址
由于异常入口地址是硬件和操作系统约定好的,接下来的处理过程将会由操作系统来接管,操作系统将视情况决定是否终止当前程序的运行。若决定无需杀死当前程序,等到异常处理结束之后,就根据之前保存的信息恢复程序的状态,并从异常处理过程中返回到程序触发异常之前的状态。具体地:
- riscv32 通过
mret
指令从异常处理过程中返回,它将根据 mepc 寄存器恢复 PC
这些程序状态 (mepc, mstatus, mcause) 必须由硬件来保存吗?能否通过软件来保存?为什么?
状态机视角下的异常响应机制
程序是个 S = <R, M>
的状态机。
扩充之后的寄存器可以表示为 R = {GPR, PC, SR}
,其中 SR
为系统寄存器。
添加异常响应机制之后,我们允许一条指令的执行会失败。为了描述指令执行失败的行为,我们可以假设 CPU 有一条虚构的指令 raise_intr
,执行这条虚构指令的行为就是上文提到的异常响应过程:
如果一条指令执行成功,其行为和之前介绍的 TRM 与 IOE 相同;如果一条指令执行失败,其行为等价于执行了虚构的 raise_intr
指令。
事实上,我们可以把这些失败的条件表示成一个函数 fex: S -> {0, 1}
,给定状态机的任意状态 S
,fex(S)
都可以唯一表示当前 PC 指向的指令是否可以成功执行。
最后,异常响应机制的加入还伴随着一些系统指令的添加。这些指令除了用于专门对状态机中的 SR
进行操作之外,本质上和 TRM 的计算指令没有太大区别。
将上下文管理抽象成 CTE
硬件提供的上述在操作系统和用户程序之间切换执行流的功能,在操作系统看来,都可以划入上下文管理的一部分。
与 IOE 一样,上下文管理的具体实现也是架构相关的。为了遵循 AM 的精神,我们需要将不同架构的上下文管理功能抽象成统一的 API,需要的信息如下:
在处理过程中,操作系统可能会读出上下文中的一些寄存器,根据它们的信息来进行进一步的处理。例如操作系统读出 PC 所指向的非法指令,看看其是否能被模拟执行,如用软件模拟浮点指令的执行。如果无法处理,那就 UB 吧,如栈溢出。
不过,AM 究竟给程序提供了多大的栈空间呢?
通过追踪堆区的创建可知:
其中 RANGE
宏的定义为:
PMEM_END
的定义为:
而 _pmem_start
定义在链接选项 LDFLAGS
中,位于 abstract-machine/scripts/platform/nemu.mk
:
_heap_start
定义在链接脚本文件 abstract-machine/scripts/linker.ld
中:
从而可知堆区的大小与 _heap_start
相关,一直到 0x88000000
,而栈的大小为 0x8000
。
对于切换原因,我们只需要定义一种统一的描述方式即可。CTE 定义了名为事件的如下数据结构,见 abstract-machine/am/include/am.h
:
其中 event
表示事件编号,cause
和 ref
是一些描述事件的补充信息,msg
是事件信息字符串,我们在 PA 中只会用到 event
。
对于上下文,我们只能将描述上下文的结构体类型名统一成 Context
:
至于其中的具体内容,就无法进一步进行抽象了。这主要是因为不同架构之间上下文信息的差异过大。对于 riscv32 而言,其定义位于 abstract-machine/am/include/arch/riscv32-nemu.h
:
因此,在操作系统中对 Context
成员的直接引用,都属于架构相关的行为,会损坏操作系统的可移植性。不过大多数情况下,操作系统并不需要单独访问 Context
结构中的成员。CTE 也提供了一些的接口,来让操作系统在必要的时候访问它们,从而保证操作系统的相关代码与架构无关。
最后还有另外两个统一的 API:
穿越时空的旅程
硬件准备
RTFM
- Unprivileged ISA: Chapter 10
- Privileged Architecture: Chapter 2/3
首先我们需要实现如下指令:
由此衍生出两个伪指令:
上述指令均属于 RV32I Base Integer Instruction。
还有两个特殊的指令:
ecall
指令在 RV32I Base Integer Instruction 部分和 Machine-Mode Privileged Instruction 部分均有所介绍。而 mret
指令属于 Machine-Mode Privileged Instruction。
注意,ecall
指令将当前 PC 保存到 epc 中,而 mret
指令将当前 PC 设置为 epc:
译码部分略。
对于指令的实现,首先我们需要找到 CSR 寄存器在 NEMU 中对应的抽象,然而并没有找到,于是使用了 PA2 中未使用的 RTL 临时寄存器:
其对应如下:
下面是指令的实现,我们新建 system.h:
其中的核心为 CSR_dispatch,参考 manual 中的一段话:
也就是对于 I-type 的 csrr 系指令而言,其立即数中存放的是 CSR 寄存器在一段空间中的索引。于是我们 RTFM 找到索引,返回 NEMU 中的对应的寄存器即可。
这里包含头文件 isa.h
是为了调用 isa_raise_intr
:
设置异常入口地址
调用轨迹:
下面来看 cte_init 函数:
内联汇编语句将 __am_asm_trap
的地址(异常入口地址)存入 mtvec 寄存器中,并注册一个事件处理回调函数。
触发自陷操作
调用轨迹:
下面来看 yield
函数:
li a7, -1
不知道在干嘛……
详见后面的系统调用部分,用于区分异常事件号和系统调用事件号
ecall
指令让 NEMU 跳转到异常入口地址。注意 Exception Code 为 11,对应 Environment call from M-mode
。需要对应修改 abstract-machine/am/include/am.h
中的 Context
结构体。
为了让 DiffTest 机制正确工作,还需要将 mstatus 初始化为 0x1800
,目前由硬件实现(感觉不太妥)。
保存上下文
成功跳转到异常入口地址之后,我们就要在软件上开始真正的异常处理过程。这一过程由 __am_asm_trap
完成:
在调用 __am_irq_handle
之前,需要对上下文进行保存:
- 通用寄存器
- 触发异常时的 PC 和处理器状态,即 mepc 和 mstatus
- 异常号,即 mcause
- 地址空间,为 PA4 准备,riscv32 将地址空间信息与 0 号寄存器共用存储空间
下面的重点是上下文结构的组织,我们调整 Context 结构体使之与 __am_asm_trap
的行为一致:
大概思路是,先让栈指针 sp 减去 CONTEXT_SIZE,然后依次将上下文信息存入栈中:
其中 x2 寄存器为 sp。最后通过 mv a0, sp
指令准备好参数(第一个参数通过寄存器 a0 传递)。
事件分发
来到 __am_irq_handle
中,这里根据执行流切换的原因打包成事件,然后调用在 cte_init()
中注册的事件处理回调函数,将事件交给 Nanos-lite 来处理:
重点是 __am_irq_handle
如何读取上下文信息的,我们参考反汇编:
我们接着上面的栈:
寄存器 a0 中存放着 prev sp,三条 lw 语句为 printf 准备参数,a1 为偏移 34×4 处的 epc,a2 为偏移 32×4 处的 mcause,a3 为偏移 33×4 处的 mstatus,与 printf 一致。
恢复上下文
代码将会一路返回到 trap.S
的 __am_asm_trap()
中,__am_asm_trap()
将根据之前保存的上下文内容,恢复程序的状态,其过程与保存上下文基本上对应,不再赘述。
在之前的硬件实现中,mret
指令将当前 PC 设置为 epc,即 ecall 指令的地址。于是由软件在适当的地方对保存的 PC 加上 4,这里选择在 __am_asm_trap
中恢复上下文前对 PC 加上 4。
事实上,自陷只是其中一种异常类型。有一种故障类异常,它们返回的 PC 和触发异常的 PC 是同一个,例如缺页异常,在系统将故障排除后,将会重新执行相同的指令进行重试,因此异常返回的 PC 无需加 4。所以根据异常类型的不同,有时候需要加 4,有时候则不需要加。
CISC 都交给硬件来做,而 RISC 则交给软件来做。
代码最后会返回到 Nanos-lite 触发自陷的代码位置,然后继续执行。在它看来,这次时空之旅就好像没有发生过一样。
异常处理的踪迹 - etrace
你也许认为在 CTE 中通过 printf()
输出信息也可以达到类似的效果,但这一方案和在 NEMU 中实现 etrace 还是有如下区别:
- 打开 etrace 不改变程序的行为
- etrace 也不受程序行为的影响
实现略。
加载第一个用户程序
加载的过程就是把可执行文件中的代码和数据放置在正确的内存位置,然后跳转到程序入口。
为了实现 loader()
函数,我们需要解决以下问题:
- 可执行文件
- 可执行文件中的代码和数据
- 正确的内存位置
关于程序从何而来,可以参考一篇文章:COMPILER, ASSEMBLER, LINKER AND LOADER: A BRIEF STORY
可执行文件
用户程序运行在操作系统之上,由于运行时环境的差异,我们不能把编译到 AM 上的程序放到操作系统上运行。为此,我们准备了一个新的子项目 Navy-apps,专门用于编译出操作系统的用户程序。
Navy 的 Makefile
组织和 abstract-machine
非常类似。
navy-apps/libs/libc
中是一个名为 Newlib 的项目,它是一个专门为嵌入式系统提供的 C 库,库中的函数对运行时环境的要求极低。
用户程序的入口位于 navy-apps/libs/libos/src/crt0/start/riscv32.S
中的 _start()
函数:
_start()
函数会调用 navy-apps/libs/libos/src/crt0/crt0.c
中的 call_main()
函数:
然后调用用户程序的 main()
函数,从 main()
函数返回后会调用 exit()
结束运行。
我们要在 Nanos-lite 上运行的第一个用户程序是 navy-apps/tests/dummy/dummy.c
:
为了避免和 Nanos-lite 的内容产生冲突,我们约定目前用户程序需要被链接到内存位置 0x83000000
附近。Navy 已经设置好了相应的选项,见 navy-apps/scripts/riscv32.mk
中的 LDFLAGS
变量:
在 navy-apps/tests/dummy/
目录下执行:
编译成功后把 navy-apps/tests/dummy/build/dummy-riscv32
手动复制到 nanos-lite/build/ramdisk.img
。
使用 cat 命令重定向输出
然后在 nanos-lite/
目录下执行
会生成 Nanos-lite 的可执行文件,编译期间会把 ramdisk 镜像文件 nanos-lite/build/ramdisk.img
包含进 Nanos-lite 成为其中的一部分,在 nanos-lite/src/resources.S
中实现:
总结来说,可执行文件位于 ramdisk 偏移为 0 处,访问它就可以得到用户程序的第一个字节。
可执行文件中的代码和数据
可执行文件为 ELF 文件格式。
ELF 是 GNU/Linux 可执行文件的标准格式,这是因为 GNU/Linux 遵循 System V ABI (Application Binary Interface).
ELF 文件提供了两个视角来组织一个可执行文件:
- 一个是面向链接过程的 section 视角,这个视角提供了用于链接与重定位的信息,例如符号表
- 一个是面向执行的 segment 视角,这个视角提供了用于加载可执行文件的信息
我们现在关心的是如何加载程序,因此我们重点关注 segment 的视角。ELF 中采用 program header table 来管理 segment,program header table 的一个表项描述了一个 segment 的所有属性,包括类型、虚拟地址、标志、对齐方式、以及文件内偏移量和 segment 大小。
我们可以通过判断 segment 的 Type
属性是否为 PT_LOAD
来判断一个 segment 是否需要加载。
下面是对上述可执行文件的分析,通过 readelf 命令:
得到 ELF Header:
Program Headers:
我们研究一下映射关系:
- 机器架构偏移 0x0000012~0x0000013
- 程序入口偏移 0x0000018~0x000001b
- 程序头表起始位置偏移 0x000001c~0x000001f
- 程序头表大小偏移 0x000002a~0x000002b,注意这里是一项的大小
- 程序头表项数偏移 0x000002c~0x000002d
下面是每个 segment 的信息:
大概是每 32bits 记录一条信息。
正确的内存位置
注意到 naive_uload
将程序入口强制转换一个函数指针并调用:
所以只要对 segment 中的 VirtAddr 直接赋值就可以了。
可以参考下面的图示:
FileSiz
通常不会大于相应的 MemSiz
,MemSiz
多出的部分可能是 .bss
段,需要初始化为 0,也就是将 [VirtAddr + FileSiz, VirtAddr + MemSiz)
对应的物理区间清零。
实现
实现 nanos-lite/src/loader.c
中的 loader()
函数,参数目前不考虑:
这里的 ramdisk_read
的定义在 nanos-lite/src/ramdisk.c
中。
另外对 ELF 文件的魔数(只考虑了前 64 位,实际上应该是 128 位)和机器架构(AM 中定义的一些宏和 /usr/include/elf.h
中定义的机器架构)进行了检测。
只考虑了 32 位环境,所以无法在 native
上测试 Nanos-lite 实现,native
是 64 位环境。
实现通用 loader 可能需要参考 /usr/include/elf.h
中 ELF 文件结构的定义。
Navy 中还有一个叫 native
的 ISA,它与 AM 中名为 native
的 ARCH 机制有所不同,目前暂不使用。
梳理一下用户程序的加载过程:
操作系统的运行时环境
在 PA2 中,我们根据具体实现是否与 ISA 相关,将运行时环境划分为两部分。但对于运行在操作系统上的程序,它们就不需要直接与硬件交互了。
作为资源管理者管理着系统中的所有资源,操作系统还需要为用户程序提供相应的服务。这些服务需要以一种统一的接口来呈现,用户程序也只能通过这一接口来请求服务。
这一接口就是系统调用。
于是,系统调用把整个运行时环境分成两部分,一部分是操作系统内核区,另一部分是用户区。那些会访问系统资源的功能会放到内核区中实现,而用户区则保留一些无需使用系统资源的功能,比如 strcpy()
,以及用于请求系统资源相关服务的系统调用接口。
系统调用
用户程序通过自陷指令来触发系统调用,触发了事件 EVENT_SYSCALL
。
硬件与操作系统部分(实现)
既然我们通过自陷指令来触发系统调用,那么对用户程序来说,用来向操作系统描述需求的最方便手段就是使用通用寄存器,参见 abstract-machine/am/include/arch/riscv32-nemu.h
:
前四个为系统调用参数寄存器,第一个记录系统调用事件编号。最后一个寄存器 GPRx
为系统调用返回值寄存器。
其通用寄存器的选择参考 navy-apps/libs/libos/src/syscall.c
:
TODO: RISC-V Linux 为什么没有使用 a0
来传递系统调用号呢?
我们还需要修改下面的地方:
其中 gpr
定义在 nemu/src/isa/riscv32/local-include/reg.h
:
取出寄存器 a7
的值,若为 -1
,则为异常事件 EVENT_YIELD
,否则为异常事件 EVENT_SYSCALL
。
仍需要斟酌……
将异常事件编号 EVENT_YIELD
又改回来了,由此无法通过 DiffTest ……
发现 DiffTest 的 REF 中 EVENT_YIELD
和 EVENT_SYSCALL
的编号均为 11,那就无法区分了……
注意若为异常事件 EVENT_SYSCALL
,寄存器 a7
中的值即为系统调用事件编号!
针对异常事件 EVENT_SYSCALL
,调用 nanos-lite/src/syscall.c
中的 do_syscall
函数,该函数参数为上下文信息:
取出 GPR1
即寄存器 a7
,并进行事件分派。并将返回值存入 GPRx
中。
我们还需要将 navy-apps/libs/libos/src/syscall.h
中系统调用事件编号的定义复制到 nanos-lite/src/syscall.h
。
下面是相应的系统调用处理函数:
用户程序部分(接口)
Navy 已经为用户程序准备好了系统调用的接口了,即 navy-apps/libs/libos/src/syscall.c
中定义的 _syscall_()
函数:
上述代码会先把系统调用的参数依次放入寄存器中,然后执行自陷指令。由于寄存器和自陷指令都是 ISA 相关的,因此这里根据不同的 ISA 定义了不同的宏,来对它们进行抽象。CTE 会将这个自陷操作打包成一个系统调用事件 EVENT_SYSCALL
,并交由 Nanos-lite 继续处理。
执行过程分析
我们结合用户程序的反汇编:
和运行结果来分析其执行过程:
其执行过程大概如下:
对于 _syscall_(SYS_yield, 0, 0, 0)
这个系统调用处理,经历了如下过程:
也就是一个嵌套的 ecall
,注意外层的 mcause
并未恢复。
对于 _exit
系统调用处理,经历了如下过程:
Linux 系统调用的相关信息
man syscall
- 查阅不同架构的系统调用约定,包括参数传递和返回值
man syscalls
- 查阅 Linux 中已经实现的系统调用
系统调用的踪迹 - strace
Linux 上有一个叫 strace
的工具,它可以记录用户程序进行系统调用的踪迹。
例如你可以通过 strace ls
来了解 ls
的行为,你甚至可以看到 ls
是如何被加载的;如果你对 strace
本身感兴趣,你还可以通过 strace strace ls
来研究 strace
是如何实现的。
事实上,我们也可以在 Nanos-lite 中实现一个简单的 strace,Nanos-lite 可以得到系统调用的所有信息,包括名字、参数和返回值。这也是为什么我们选择在 Nanos-lite 中实现 strace:系统调用是携带高层的程序语义的,但 NEMU 中只能看到底层的状态机。
实现略。
无法在 NEMU 中定义宏来控制 strace 的开关了……
操作系统之上的 TRM
- 机器提供基本的运算指令:PA2 中已经实现的指令系统
- 能输出字符:
SYS_write
系统调用
- 有堆区可以动态申请内存
- 可以结束运行:
SYS_exit
系统调用
标准输出
输出是通过 SYS_write
系统调用来实现的,修改 navy-apps/libs/libos/src/syscall.c
:
添加系统调用处理函数:
其中的返回值通过查阅 man 2 write
可知:
再加一个 case 就可以了:
注意其中的一些强制类型转换
Navy 中提供了一个 hello
测试程序,它首先通过 write()
来输出一句话,然后通过 printf()
来不断输出(逐字符):
编译成功后,我们需要将其手动复制到 ramdisk.img
中:
调用轨迹为:
write -> _write_r -> _write
printf -> _vfprintf_r -> __sfputs_r -> _fputc_r -> _putc_r -> __swbuf_r -> __swsetup_r -> __smakebuf_r -> _malloc_r -> ...
printf
的调用轨迹较为复杂,上面仅给出了调用到 malloc
的部分
堆区管理
堆区的使用情况是由 libc 来进行管理的,但堆区的大小却需要通过系统调用向操作系统提出更改。
调整堆区大小是通过 sbrk()
库函数来实现的,它的原型是:
用于将用户程序的 program break 增长 increment
字节,其中 increment
可为负数。
所谓 program break,就是用户程序的数据段 (data segment) 结束的位置。
我们知道链接的时候 ld
会默认添加一个名为 _end
的符号,来指示程序的数据段结束的位置。用户程序开始运行的时候,program break 会位于 _end
所指示的位置,意味着此时堆区的大小为 0。
malloc()
被第一次调用的时候,会通过 sbrk(0)
来查询用户程序当前 program break 的位置,之后就可以通过后续的 sbrk()
调用来动态调整用户程序 program break 的位置了。
在 Navy 的 Newlib 中,sbrk()
最终会调用 _sbrk()
。
调用轨迹为 sbrk -> _sbrk_r -> _sbrk
它在 navy-apps/libs/libos/src/syscall.c
中定义。框架代码让 _sbrk()
总是返回 -1
,表示堆区调整失败,事实上,用户程序在第一次调用 printf()
的时候会尝试通过 malloc()
申请一片缓冲区,来存放格式化的内容。若申请失败,就会逐个字符进行输出。
为了实现 _sbrk()
的功能,我们还需要提供一个用于设置堆区大小的系统调用。在 GNU/Linux 中,这个系统调用是 SYS_brk
,它接收一个参数 addr
,用于指示新的 program break 的位置。_sbrk()
通过记录的方式来对用户程序的 program break 位置进行管理,其工作方式如下:
- program break 一开始的位置位于
_end
- 被调用时,根据记录的 program break 位置和参数
increment
,计算出新 program break
- 通过
SYS_brk
系统调用来让操作系统设置新 program break
- 若
SYS_brk
系统调用成功,该系统调用会返回 0
,此时更新之前记录的 program break 的位置,并将旧 program break 的位置作为 _sbrk()
的返回值返回
- 若该系统调用失败,
_sbrk()
会返回 -1
上述代码是在用户层的库函数中实现的,我们还需要在 Nanos-lite 中实现 SYS_brk
的功能。由于目前 Nanos-lite 还是一个单任务操作系统,空闲的内存都可以让用户程序自由使用,因此我们只需要让 SYS_brk
系统调用总是返回 0
即可,表示堆区大小的调整总是成功。
通过 man brk
可知,_sbrk()
对应
而 sys_brk
对应:
于是实现如下,修改 navy-apps/libs/libos/src/syscall.c
:
添加系统调用处理函数:
加一个 case:
此时,hello
测试程序中的 printf()
将格式化完毕的字符串通过一次 write()
系统调用进行输出。可以观察 strace:
缓冲区是批处理技术的核心,libc 中的 fread()
和 fwrite()
正是通过缓冲区来将数据累积起来,然后再通过一次系统调用进行处理。
这篇文章测试了 write()
系统调用的开销。
fwrite()
的实现中有缓冲区,printf()
打印的字符不一定会马上通过 write()
系统调用输出,但遇到 \n
时可以强行将缓冲区中的内容进行输出。
事实上,我们平时使用的 printf()
, cout
这些库函数和库类,对字符串进行格式化之后,最终也是通过系统调用进行输出。这些都是系统调用封装成库函数的例子。系统调用本身对操作系统的各种资源进行了抽象,但为了给上层的程序员提供更好的接口,库函数会再次对部分系统调用再次进行抽象。另一方面,系统调用依赖于具体的操作系统,因此库函数的封装也提高了程序的可移植性。
我们目前并没有严格按照 AM 的 API 来将相应的系统调用功能暴露给用户程序,毕竟与 AM 相比,对操作系统上运行的程序来说,libc 的接口更加广为人们所用,我们也就不必班门弄斧了。
也就是说 libc 的实现中使用了我们编写的系统调用功能
How Hello 1
我们知道 navy-apps/tests/hello/hello.c
只是一个 C 源文件,它会被编译链接成一个 ELF 文件。那么,hello 程序一开始在哪里?它是怎么出现内存中的?为什么会出现在目前的内存位置?它的第一条指令在哪里?究竟是怎么执行到它的第一条指令的?hello 程序在不断地打印字符串,每一个字符又是经历了什么才会最终出现在终端上?
可以扣除 C 库中 printf()
到 write()
转换的部分。
如果我们想了解 C 库中 printf()
到 write()
的过程,ftrace 将是一个很好的工具。
但我们知道,Nanos-lite 和它加载的用户程序是两个独立的 ELF 文件,这意味着,如果我们给 NEMU 的 ftrace 指定其中一方的 ELF 文件,那么 ftrace 就无法正确将另一方的地址翻译成正确的函数名。
事实上,我们可以让 NEMU 的 ftrace 支持多个 ELF,让 ftrace 同时追踪 Nanos-lite 和用户程序的函数调用。
TODO
前几个问题可以参考 加载第一个用户程序
部分。
主要关注 每一个字符又是经历了什么才会最终出现在终端上
这个问题:
- 对于用户层而言,使用
write
或 printf
,经过一系列过程,最终触发 _write
。
- 对于软件层(操作系统)而言,接收到异常事件
EVENT_SYSCALL
,进行系统调用事件分发,并最终调用系统调用处理函数 sys_write
。
- 对于硬件层(NEMU)而言,接收到
putch
请求。联系之前的 IOE 部分,可以得到最后一段调用轨迹 putch -> outb -> serial_io_handler -> serial_putc -> putc
。
简易文件系统
原理
要实现一个完整的批处理系统,我们还需要向系统提供多个程序。
操作系统还需要在存储介质的驱动程序之上为用户程序提供一种更高级的抽象,那就是文件。
在这里,我们先讨论普通意义上的文件。这样,那些额外的属性就维护了文件到 ramdisk 存储位置的映射。为了管理这些映射,同时向上层提供文件操作的接口,我们需要在 Nanos-lite 中实现一个文件系统。
我们可以定义一个简易文件系统 sfs (Simple File System):
- 每个文件的大小是固定的
- 写文件时不允许超过原有文件的大小
- 文件的数量是固定的,不能创建新文件
- 没有目录
既然文件的数量和大小都是固定的,我们自然可以把每一个文件分别固定在 ramdisk 中的某一个位置。
为了记录 ramdisk 中各个文件的名字和大小,我们还需要一张文件记录表。Nanos-lite 的 Makefile 已经提供了维护这些信息的脚本:
然后运行 make ARCH=riscv32-nemu update
就会自动编译 Navy 中的程序,并把 navy-apps/fsimg/
目录下的所有内容整合成 ramdisk 镜像 navy-apps/build/ramdisk.img
,同时生成这个 ramdisk 镜像的文件记录表 navy-apps/build/ramdisk.h
。
如果希望在镜像中添加一个应用程序,需要把它加到 navy-apps/Makefile
的 TESTS
变量中。
文件记录表其实是一个数组,数组的每个元素都是一个结构体:
一些注记:
- 由于 sfs 没有目录,我们把目录分隔符
/
也认为是文件名的一部分,例如 /bin/hello
是一个完整的文件名。
- 通过文件描述符对应一个正在打开的文件,我们可以简单地把文件记录表的下标作为相应文件的文件描述符返回给用户程序。
- 需要为每一个已经打开的文件引入偏移量属性
open_offset
,来记录目前文件操作的位置。
为了方便用户程序进行标准输入输出,操作系统准备了三个默认的文件描述符:
它们分别对应标准输入 stdin
,标准输出 stdout
和标准错误 stderr
。
根据以上信息,我们就可以在文件系统中实现以下的文件操作了:
这些文件操作实际上是相应的系统调用在内核中的实现。你可以通过 man
查阅它们的功能,例如:
其中 2
表示查阅和系统调用相关的 manual page。
软件层
我们需要修改 nanos-lite/src/fs.c
,首先定义文件描述符和偏移量属性 open_offset
的结构,并通过 get_open_file_index
获取给定文件描述符在 open_file_table
结构数组中的下标:
在此基础上,我们编写如下函数。
对文件不存在的情况需要直接 panic
,与 manual 不一致。
忽略 flags
和 mode
参数。
忽略对 stdin
, stdout
和 stderr
这三个特殊文件的打开操作。
同样忽略对 stdin
, stdout
和 stderr
这三个特殊文件的打开操作。
注意偏移量不要越过文件的边界。
最终调用了 ramdisk_read
实现读取操作。
注意该函数取代了之前的系统调用处理函数 sys_write
。
若写入 stdout
和 stderr
,则用 putch()
输出到串口。
其中 whence
的枚举值定义在 fs.h
中。
STFM
需要更新 open_file_table
。
下面需要修改系统调用事件分发:
最后,我们之前是让 loader 来直接调用 ramdisk_read()
来加载用户程序。ramdisk 中的文件数量增加之后,这种方式就不合适了,我们需要让 loader 享受到文件系统的便利。
为此,我们需要利用 naive_uload()
函数的 filename
参数,并将 loader
函数中的文件读取操作修改为如下范式:
以后更换用户程序只需要修改传入 naive_uload()
函数的文件名即可:
需要定义一个变量,直接传参有时候会无法识别全文件名……
用户层
添加相应的系统调用即可:
测试程序
file-test
提供了对文件操作相关的测试:
运行后,其系统调用如下:
观察到如下现象:
- 标准输入
stdin
,标准输出 stdout
和标准错误 stderr
在程序启动后已经被打开,程序结束后被自动关闭。
- 可以看到三次集中的
fs_read
和一次集中的 fs_write
,最后一次集中的 fs_read
为什么读取了 3072 个字节?
/share/files/num
中存放了 1000 个 int
类型的数字(文件位于 navy-apps/fsimg/share/files/num
,一个数字占 5 字节,是因为每个数字后面有个换行符),后 500 个为 501 ~ 1000。测试程序读取了后 500 个数字,并修改了前 500 个数字,并检测后 500 个数字未被修改,最后检查前 500 个数字。
fs_lseek
一些奇怪的调用……
可以把 strace 中的文件描述符直接翻译成文件名,得到可读性更好的 trace 信息:
然后在 syscall.c
中调用 get_file_name
就可以啦。
一些用户程序库函数的调用轨迹:
很怪的调用轨迹,中间有些链条找不到……
一切皆文件
想法
在 Nanos-lite 上,如果用户程序想访问设备,要怎么办呢?
我们需要有一种方式对设备的功能进行抽象,向用户程序提供统一的接口。
注意到文件的本质就是字节序列,而计算机系统中到处都是字节序列:
- 内存是以字节编址的,天然就是一个字节序列
- 管道是一种先进先出的字节序列
- 磁盘也可以看成一个字节序列
- socket (网络套接字) 也是一种字节序列
- 操作系统的一些信息可以以字节序列的方式暴露给用户,例如 CPU 的配置信息
- 操作系统提供的一些特殊的功能,如随机数生成器,也可以看成一个无穷长的字节序列
- 我们在键盘上按顺序敲入按键的编码形成了一个字节序列
- 显示器上每一个像素的内容按照其顺序也可以看做是字节序列
- ……
自然地,我们有了 Everything is a file 的想法,我们可以使用文件的接口来操作计算机上的一切,而不必对它们进行详细的区分。
会将 urandom 设备中的内容包含到源文件中:由于 urandom 设备是一个长度无穷的字节序列,提交一个包含上述内容的程序源文件将会令一些检测功能不强的 Online Judge 平台直接崩溃……
这其实体现了 Unix 哲学的部分内容:每个程序采用文本文件作为输入输出,这样可以使程序之间易于合作。
为了向用户程序提供统一的抽象,Nanos-lite 也尝试将 IOE 抽象成文件。
虚拟文件系统
我们对之前实现的文件操作 API 的语义进行扩展,让它们可以支持任意文件:
这组扩展语义之后的 API 叫 VFS(虚拟文件系统)。
而真实文件系统是指具体如何操作某一类文件,比如在 Nanos-lite 上,普通文件通过 ramdisk 的 API 进行操作。
一些真实文件系统:
- Windows
- GNU/Linux
- 普通文件 -> EXT4
- 特殊文件 ->
procfs
, tmpfs
, devfs
, sysfs
, initramfs
…
所以,VFS 其实是对不同种类的真实文件系统的抽象,它用一组 API 来描述了这些真实文件系统的抽象行为,屏蔽了真实文件系统之间的差异。
在 Nanos-lite 中,实现 VFS 的关键就是 Finfo
结构体中的两个读写函数指针:
我们约定,当上述的函数指针为 NULL
时,表示相应文件是一个普通文件,通过 ramdisk 的 API 来进行文件的读写。
VFS 的实现展示了如何用 C 语言来模拟面向对象编程的一些基本概念:例如通过结构体来实现类的定义,结构体中的普通变量可以看作类的成员,函数指针就可以看作类的方法,给函数指针设置不同的函数可以实现方法的重载…
Object-Oriented Programming With ANSI-C 这本书专门介绍了如何用 ANSI-C 来模拟 OOP 的各种概念和功能。
我们可以对字节序列进行分类:
- 静止的,具有位置概念,支持
lseek
操作,存储这些文件的设备称为块设备
- 流动的,只有顺序关系,不支持
lseek
操作,相应的设备称为字符设备
真实的操作系统还会对 lseek
操作进行抽象,我们在 Nanos-lite 中进行了简化,就不实现这一抽象了。
操作系统之上的 IOE
串口
我们只需要在 nanos-lite/src/device.c
中实现 serial_write()
:
然后在文件记录表中设置相应的写函数即可:
相应的修改 fs_read
和 fs_write
:
不需要对 fd
进行特判了。
由于串口是一个字符设备,offset
参数可以忽略。
时钟
时钟比较特殊,大部分操作系统并没有把它抽象成一个文件,而是直接提供一些和时钟相关的系统调用来给用户程序访问。在 Nanos-lite 中,我们也提供一个 SYS_gettimeofday
系统调用,用户程序可以通过它读出当前的系统时间。
实现如下。首先是软件层,参考 man 2 gettimeofday
先定义一些数据结构:
我们使用了 io_read
访问设备抽象寄存器。注意这里 sys_gettimeofday
并不处理参数 tz
:
obsolete -> 弃用
然后是事件分发:
下面是用户层:
我们写一个测试程序 timer-test
:
每过 0.5 秒输出一句话。注意这里不要出现浮点操作,否则就需要让 NEMU 实现浮点指令了……
NDL
为了更好地封装 IOE 的功能,我们在 Navy 中提供了一个叫 NDL (NJU DirectMedia Layer) 的多媒体库。这个库的代码位于 navy-apps/libs/libndl/NDL.c
中。
NDL 向用户提供了一个和时钟相关的 API:
你需要用 gettimeofday()
实现 NDL_GetTicks()
,然后修改 timer-test
测试,让它通过调用 NDL_GetTicks()
来获取当前时间。
我们约定程序在使用 NDL 库的功能之前必须先调用 NDL_Init()
。
实现如下:
修改 timer-test
测试,添加宏 HAS_NDL
进行测试控制:
注意需要修改对应的 Makefile
文件:
键盘
按键信息对系统来说本质上就是到来了一个事件。一种简单的方式是把事件以文本的形式表现出来,我们定义以下两种事件:
- 按下按键事件,如
kd RETURN
表示按下回车键
- 松开按键事件,如
ku A
表示松开 A
键
按键名称与 AM 中的定义的按键名相同,均为大写。此外,一个事件以换行符 \n
结束。
Nanos-lite 和 Navy 约定,上述事件抽象成一个特殊文件 /dev/events
,它需要支持读操作,用户程序可以从中读出按键事件,但它不必支持 lseek
,因为它是一个字符设备。
我们可以假设一次最多只会读出一个事件
软件层的实现如下,首先需要实现对特殊文件 /dev/events
的读操作,在 nanos-lite/src/device.c
中:
把事件写入到 buf
中,最长写入 len
字节,然后返回写入的实际长度。
这里本来应该使用 snprintf,但是 klib 中还没有实现……所以使用了 temp_buf
作为中转
需要小心各种参数和返回值中是否包含 terminating null character
修改 file_table
:
注意 FD_FB
可以用于对特殊文件的特判上,如:
然后是用户层,我们使用 NDL 封装 IOE 的功能:
注意区分 open
和 fopen
,前者是低级 IO,后者是高级 IO
最后是测试程序:
对应的系统调用如下:
VGA
程序为了更新屏幕,只需要将像素信息写入 VGA 的显存即可。
Nanos-lite 和 Navy 约定,把显存抽象成文件 /dev/fb
,它需要支持写操作和 lseek
,以便于把像素更新到屏幕的指定位置上。
NDL 向用户提供了两个和绘制屏幕相关的 API:
其中画布是一个面向程序的概念,程序绘图时的坐标都是针对画布来设定的,这样程序就无需关心系统屏幕的大小,以及需要将图像绘制到系统屏幕的哪一个位置。NDL 可以根据系统屏幕大小以及画布大小,来决定将画布贴到哪里。
Nanos-lite 和 Navy 约定,屏幕大小的信息通过 /proc/dispinfo
文件来获得,它需要支持读操作。navy-apps/README.md
中对这个文件内容的格式进行了约定:
procfs 文件系统:所有的文件都是 key-value pair,格式为 [key] : [value]
, 冒号左右可以有任意多 (0 个或多个) 的空白字符 (whitespace).
/proc/dispinfo
: 屏幕信息,包含的 keys: WIDTH
表示宽度,HEIGHT
表示高度。
/proc/cpuinfo
(可选): CPU 信息。
/proc/meminfo
(可选): 内存信息。
例如一个合法的 /proc/dispinfo
文件例子如下:
WIDTH : 640
HEIGHT : 480
下面是具体实现。
/proc/dispinfo
首先是软件层,实现对其的读操作,类似 events_read
:
这里 /proc/dispinfo
文件的格式只要满足上面的要求就行了,并不唯一。
在 file_table
中添加:
由于是字符设备,位于 FD_FB
之上。
下面是用户层,我们添加 init_dispinfo
函数用于解析 /proc/dispinfo
文件的内容,并写入 screen_w
和 screen_h
,作为屏幕大小:
在 NDL_Init
中调用它即可。
我们还需要记录画布的大小,修改 NDL_OpenCanvas
:
这里 canvas_relative_screen_w
和 canvas_relative_screen_h
是画布相对于屏幕左上角的坐标,我们将画布居中。
/dev/fb
首先是软件层,添加 fb_write
:
得到硬件层的屏幕大小后,对 offset
进行处理,得到正确的坐标。
这里假定 buf
中仅包含了一行的像素信息。
隔行似乎难以处理,对用户层而言……
在 file_table
中添加:
我们还需要修改 fs_read
和 fs_write
:
另外在 init_fs()
中对文件记录表中 /dev/fb
的大小进行初始化:
这里会有一个问题,FD_FB
之后的文件在 ramdisk.img
中的偏移会出错。
若不修改,ramdisk.img
中前 /dev/fb
大小的字节会被覆盖。
可能的修复方案,修改 navy-apps/Makefile
的 ramdisk
规则,让每个文件的偏移显式加上 /dev/fb
的大小:
从 sum=0
到 sum=480000
。
同时修改 nanos-lite/src/ramdisk.c
:
此时 ramdisk_end
所指示的位置失效……
还是不行……
注意到 ramdisk.img
前 RAMDISK_SIZE
个字节是固定的,所以我们需要将 FD_FB
放到最后……
然而似乎 resources.S
中已经固定了可以使用的空间为 ramdisk.img
,上面的方案屏幕无显示……
但是目前未观察到问题……
怪了……
下面是用户层,我们实现 NDL_DrawRect
:
建议画图梳理清楚系统屏幕,即 frame buffer,NDL_OpenCanvas()
打开的画布,以及 NDL_DrawRect()
指示的绘制区域之间的位置关系。
这里也是逐行写入 /dev/fb
的,对应之前 fb_write
的实现。
测试程序为 navy-apps/tests/bmp-test
:
可以得到图片的大小为 128×128
,屏幕的大小为 400×300
。
需要注意画布的大小不能超过图片的大小,否则图片会显示错误,原因详见 NDL_DrawRect
的实现。
下面是程序执行中的系统调用:
更丰富的运行时环境
多媒体库
在 Linux 中,有一批 GUI 程序是使用 SDL 库来开发的。在 Navy 中有一个 miniSDL 库,它可以提供一些兼容 SDL 的 API,这样这批 GUI 程序就可以很容易地移植到 Navy 中了:
timer.c
: 时钟管理
event.c
: 事件处理
video.c
: 绘图接口
file.c
: 文件抽象
audio.c
: 音频播放
general.c
: 常规功能,包括初始化,错误管理等
我们可以通过 NDL 来支撑 miniSDL 的底层实现。
miniSDL 中的 API 和 SDL 同名,一定要通过 RTFM 了解 SDL API 的行为。
未实现的部分已经手动 assert(0)
了……
定点算术
NEMU 没有 FPU,在 AM 中执行浮点操作是 UB,Nanos-lite 认为浮点寄存器不属于上下文的一部分,Navy 中也不提供浮点数相关的运行时环境,我们在编译 Newlib 的时候定义了宏 NO_FLOATING_POINT
。
我们可以通过整数运算指令来实现实数的逻辑,而无需在硬件上引入 FPU,这样的一个算术体系称为定点算术。
Navy 中提供了一个 fixedptc 的库,专门用于进行定点算术。fixedptc 库默认采用 32 位整数来表示实数,其具体格式为整数部分占 24 位,小数部分占 8 位。
库中定义了 fixedpt
的类型,用于表示定点数,可以看到它的本质是 int32_t
类型:
这样,对于一个实数 a
,它的 fixedpt
类型表示 A = a * 2 ^ 8
。
RTFSC 可知转换的过程有细微的处理:
另外 fixedpt_rconst
从表面上看带有非常明显的浮点操作,但从编译结果来看却没有任何浮点指令,可以使用 https://godbolt.org/ 观察。
这是因为 fixedpt
让编译器来负责了大部分的浮点处理。
参考了这篇博客……
对于负实数,我们用相应正数的相反数来表示。
我们需要在 fixedptc.h
中实现一些运算,较为简单:
写了一个测试程序,位于:
下面是 test.c
的内容:
误差确实不小……
Navy 作为基础设施
类似在 AM 中用 Linux native 的功能实现 AM 的 API,我们也可以用 Linux native 的功能来实现 Navy 应用程序所需的运行时环境,即操作系统相关的运行时环境:
- libos
- libc (Newlib)
- 一些特殊的文件
这样我们就实现了操作系统相关的运行时环境与 Navy 应用程序的解耦。
我们在 Navy 中提供了一个特殊的 ISA 叫 native
来实现上述的解耦,它和其它 ISA 的不同之处在于:
- 链接时绕开 libos 和 Newlib,让应用程序直接链接 Linux 的 glibc
- 通过一些 Linux native 的机制实现
/dev/events
, /dev/fb
等特殊文件的功能,见 navy-apps/libs/libos/src/native.cpp
- 编译到 Navy native 的应用程序可以直接运行,也可以用 gdb 来调试,见
navy-apps/scripts/native.mk
,而编译到其它 ISA 的应用程序只能在 Nanos-lite 的支撑下运行
可以在测试程序所在的目录下运行 make ISA=native run
,来把测试程序编译到 Navy native 上并直接运行。
一个例外是 Navy 中的 dummy,由于它通过 _syscall_()
直接触发系统调用,这样的代码并不能直接在 Linux native 上直接运行,因为 Linux 不存在这个系统调用,或者编号不同。
以 Navy 应用程序的视角,抽象层大概如下:
也许……
主要目的是为了在 Linux native 的环境下单独测试 NDL 和 miniSDL 的代码……
为了 event-test 在 native 上愉快的运行,删了一个 close 的 assertion
bmp-test 反映出一些 VGA 的一些问题。之前 NDL_DrawRect
的实现中没有考虑到 lseek
和 write
是以字节寻址的,而 pixels
的单位为 4
字节,所以正确的实现如下:
另外还需要修改 fb_write
,将 offset
和 len
变为原来的 ¼:
软件层和应用层都实现错了,负负得正……
有了 Navy native,就可以保证软件层和硬件层实现的正确性,控制变量……
另外,之前提到过 Nanos-lite 的 native
,注意到 Nanos-lite 实际上是一个 AM 程序,也就是 AM native,对应 Navy 中的 ISA=am_native
。
我们可以在测试程序目录下键入 make ISA=am_native
,得到 XXX-am_native
镜像,我们对比一下不同镜像的文件类型:
同样可以在 Nanos-lite 目录下键入 make ARCH=native update
,装载 XXX-am_native
形式的镜像,并键入 make ARCH=native run
运行。然而 loader 并未支持 64 位……
还有两处小问题:
- initializer element is not constant
navy-apps/libs/libos/src/syscall.c
中的:
在编译期是无法得到 _end
的值的,所以应该改成:
- cast to pointer from integer of different size
nanos-lite/src/loader.c
中的 loader
函数:
在将 uint32_t
类型的地址转换为 void *
前,应加上 uintptr_t
。因为整型和指针在一些机器上会有不同的大小(如在 64 位机器上),所以我们应该先转换成 uintptr_t
。
LD_PRELOAD
bmp-test
需要打开一个路径为 /share/pictures/projectn.bmp
的文件,但在 Linux native 中,这个路径对应的文件并不存在。
需要先了解一个叫 LD_PRELOAD
的环境变量:
The LD_PRELOAD
trick is a useful technique to influence the linkage of shared libraries and the resolution of symbols (functions) at runtime.
这里有一个简单的例子,展示了如何 override 掉 C 的库函数。
观察 navy-apps/scripts/native.mk
:
可以看到一个名为 native.so
的动态链接库会被优先链接。
我们可以观察程序的调试信息:
定位 navy-apps/libs/libos/src/native.cpp
可知:
我们发现程序会执行 Init
类的构造函数:
dlsym
函数从一个动态链接库或者可执行文件中获取到符号地址……
fsimg_path
即为 navy-apps/fsimg
。
我们继续 RTFSC:
结合上面的 LD_PRELOAD
,推测这里的 fopen
成功 override 掉了库函数,而原来的库函数通过重命名的方式变成了 glibc_fopen
。这里的 fopen
将原来的文件路径如 /share/pictures/projectn.bmp
替换成了 /home/vgalaxy/ics2021/navy-apps/fsimg/share/pictures/projectn.bmp
!
调用轨迹参考:
fopen
在底层会调用 open
,而 open
也以同样的方式被 override 了。
另外一个有趣的地方在对特殊文件的模拟,以 /dev/fb
为例:
open
函数(override 掉了系统调用)匹配到 /dev/fb
时会返回 fb_memfd
- 而
fb_memfd
在 open_display
中被创建:
memfd_create
是一个系统函数,我们 RTFM:
运行时环境兼容
实际上,navy-apps/libs/libos/src/native.cpp
使用了运行时环境兼容的技术。
即通过 Linux 的运行时环境实现 Navy 的运行时环境(API)
下面还有一些实际的例子:
- Wine:通过 Linux 的运行时环境实现 Windows 相关的 API
- WSL:通过 Windows 的运行时环境实现 Linux 相关的 API
但完整的 Linux 和 Windows 运行时环境太复杂了,因此一些对运行时环境依赖程度比较复杂的程序至今也很难在 Wine 或 WSL 上完美运行,以至于 WSL2 抛弃了”运行时环境兼容”的技术路线,转而采用虚拟机的方式来完美运行 Linux 系统。
Navy 中的应用程序
NSlider (NJU Slider)
PDF to BMP
https://imagemagick.org/
不要 Install from Source
,会变得不幸……
直接 sudo apt-get install imagemagick
,然后 sudo vi /etc/ImageMagick-6/policy.xml
,修改:
为
即可……
event
调用轨迹参考:
- for nanos-lite: fs_open / fs_read (events_read) / fs_close
- for native: (overrided) open / read / close
redirect to evt_fd
:
evt_fd = dup(dummy_fd)
dummy_fd = memfd_create("dummy", 0);
SDL
的相关实现如下:
https://wiki.libsdl.org/SDL_KeyboardEvent
https://wiki.libsdl.org/SDL_PollEvent
https://wiki.libsdl.org/SDL_WaitEvent
另外,需要在 NDL_PollEvent
的最前面对 buf
清空,这是因为 malloc
出的 buf
可能不是全空……
调试了半天……
native
中没有这个问题,只有在 nanos-lite
中才有这个问题……
这里让 NDL_PollEvent
处理一劳永逸,也可以让调用者 SDL_PollEvent
处理……
render
调用轨迹参考:
SDL_UpdateRect
的实现如下:
https://wiki.libsdl.org/SDL_Surface
https://www.libsdl.org/release/SDL-1.2.15/docs/html/sdlupdaterect.html
一个很怪的地方,若在 NDL_DrawRect
中 close
了,native
中无法翻页:
在 miniSDL 中实现两个绘图相关的 API:
SDL_FillRect()
: 往画布的指定矩形区域中填充指定的颜色
SDL_BlitSurface()
: 将一张画布中的指定矩形区域复制到另一张画布的指定位置
需要注意的是,这两个 API 不需要在内部调用 NDL_DrawRect
,只需要修改 dst->pixels
即可,初步实现如下:
https://wiki.libsdl.org/SDL_BlitSurface
https://wiki.libsdl.org/SDL_FillRect
https://wiki.libsdl.org/SDL_Rect
NTerm (NJU Terminal)
NTerm 是一个模拟终端,它实现了终端的基本功能,包括字符的键入和回退,以及命令的获取等。
终端一般会和 Shell 配合使用,从终端获取到的命令将会传递给 Shell 进行处理,Shell 又会把信息输出到终端。
NTerm 自带一个非常简单的內建 Shell,见 builtin-sh.cpp
,它默认忽略所有的命令。
API
需要实现 miniSDL 的两个 API:
SDL_GetTicks()
: 它和 NDL_GetTicks()
的功能完全一样
SDL_PollEvent()
: 它和 SDL_WaitEvent()
不同的是,如果当前没有任何事件,就会立即返回
manual 读的不仔细……
为此我们需要修改 SDL_WaitEvent
和 SDL_PollEvent
:
另外需要修改对键盘按键名的识别,原来的实现中前缀也会被错误的识别,举个栗子,由于 TAB
在 T
之前,T
会被识别成 TAB
……
SDL_UpdateRect
函数也需要修改一下:
analysis
下面我们来分析一下这个终端的实现。首先是项目结构:
nterm.h
定义了 Terminal
类,term.cpp
给出了其实现:
目前需要关注的有如下几点:
w
和 h
定义了终端的大小注意这里的单位是一个字符在屏幕上的大小,目前为 7×13
:
做除法,可得终端的大小为 48×16
。
指示当前输入的地方。
main.cpp
,主要看一下 main
函数:
这里的 SDL_SetVideoMode
打开了一个画布(并非全屏):
构造出 Terminal
类的一个实例后调用 builtin_sh_run
。
builtin_sh_run
定义在 builtin-sh.cpp
中:
我们需要理清两处地方:
考虑如下的调用轨迹:
write
成员函数以字符串和写入长度为参数:
遍历该字符串,对其中每个字符进行处理。先考虑一些特殊字符:
然后是普通字符,调用 putch
,并让光标相应移动:
在 refresh_terminal
方法中,会对 putch
过的地方进行屏幕的同步,大概思路是:
只要有一个 dirty,或者时间间隔超过了 0.5s,就会调用 SDL_UpdateRect
刷新屏幕。
而 draw_ch
调用了 SDL_BlitSurface
:
SDL_PollEvent
获取到键盘时间后,通过 term->keypress(handle_key(&ev))
得到键盘输入并显示在终端上。
handle_key
返回对应的字符,这里还考虑了大小写的问题:
SHIFT
结构数组自行 RTFSC
在 cook
模式下,keypress
会调用 write
方法在终端显示输入,并在键入换行符时将输入返回,交由 builtin_sh_run
继续处理:
返回的输入最后会有一个换行符,我们可以在 sh_handle_cmd
中处理最简单的 echo
命令:
Flappy Bird
基于 SDL1.2
,对于 Linux native
而言(不是 Navy native
),需要安装如下应用:
https://wiki.libsdl.org/FAQLinux
然而在链接的时候会报错 undefined reference to ...
不知道出了什么问题……
所以我们就自己利用 NDL 实现 SDL 吧……
需要实现 SDL_image
中的 IMG_Load()
,这个库是基于 stb 项目 中的图像解码库来实现的,用于把解码后的像素封装成 SDL 的 Surface
结构。
一种实现方式如下:
- 用 libc 中的文件操作打开文件,并获取文件大小 size
- 申请一段大小为 size 的内存区间 buf
- 将整个文件读取到 buf 中
- 将 buf 和 size 作为参数,调用
STBIMG_LoadFromMemory()
,它会返回一个 SDL_Surface
结构的指针
- 关闭文件,释放申请的内存
- 返回
SDL_Surface
结构指针
我们照葫芦画瓢,可得:
然后去掉 SDL 库中的一些 assert,并修改 SDL_BlitSurface
如下:
再次阅读 API 手册:
为了在 nanos-lite 上运行,还需要做两件事:
- 在 fsing/share 中新建文件夹 games,构建时会在其中建立到
/home/vgalaxy/ics2021/navy-apps/apps/bird/repo/res
的软连接,里面是一些素材文件
- 将
navy-apps/apps/bird/repo/include/Video.h
中的 SCREEN_HEIGHT
修改为 300
虽然在 nanos-lite 上没有可玩性……
只能在 Navy native
上玩了……
analysis
下面我们来分析一下这个游戏的实现。
首先是项目结构:
先看 BirdGame.cpp
里的 main
函数:
进行一些初始化工作,注意这里 atexit
的使用,注册一些析构函数,在程序终止时执行,用于释放资源。
然后进入 GameMain
,位于 BirdGame.cpp
:
1、构造函数
首先构造一个 CSprite
类的对象,三个参数依次为:
- SDL_Surface 类的指针
- 素材文件
- 素材文件的解释文件,包含了名称、大小和在素材文件中的坐标,如:
对应了:
构造函数调用轨迹:
hcreate
构造了一个哈希表。
Load
中调用了 IMG_Load
读取素材文件,并调用 LoadTxt
读取素材文件的解释文件,置入哈希表中。
2、ShowTitle
显示欢迎界面,等待一秒
GameMain
第二部分:
3、设置并更新时间
uiNextFrameTime -> prev
uiCurrentTime -> now
4、调用 UpdateEvents
在 Linux native
下可以使用鼠标……
只记录是按下按键还是释放按键。
5、更新时间
最高为 60FPS
GameMain
第三部分:
6、根据 g_GameState
进行逻辑处理
绘制初始画面,用户按下按键后设置 g_GameState
为 GAMESTATE_GAMESTART
绘制准备画面,用户按下按键后设置 g_GameState
为 GAMESTATE_GAME
绘制 pipe 和 bird,若 bird 碰到 pipe,设置 g_GameState
为 GAMESTATE_GAMEOVER
。期间记录分数
游戏失败的画面依阶段绘制:
并在 SHOWSCORE
阶段且按下按键后设置 g_GameState
为 GAMESTATE_GAME
似乎游戏的结束比较困难……
7、更新画面
SDL_UpdateRect
移植和测试
我们在移植游戏的时候,会按顺序在四种环境中运行游戏:
和 Project-N 的组件没有任何关系,用于保证游戏本身确实可以正确运行。在更换库的版本或者修改游戏代码之后,都会先在 Linux native 上进行测试
用 Navy 中的库替代 Linux native 的库,测试游戏是否能在 Navy 库的支撑下正确运行
用 Nanos-lite, libos 和 Newlib 替代 Linux 的系统调用和 glibc,测试游戏是否能在 Nanos-lite 及其运行时环境的支撑下正确运行
用 NEMU 替代真机硬件,测试游戏是否能在 NEMU 的支撑下正确运行
我们之所以能这样做,都是得益于计算机是个抽象层这个结论:我们可以把某个抽象层之下的部分替换成一个可靠的实现,先独立测试一个抽象层的不可靠实现,然后再把其它抽象层的不可靠实现逐个替换进来并测试。不过这要求你编写的代码都是可移植的,否则将无法支持抽象层的替换。
PAL (仙剑奇侠传)
数据文件下载地址:https://box.nju.edu.cn/f/73c08ca0a5164a94aaba/
implementation & debug
- ctags for jumping
- delete some assertions
- malloc
native:
SDL_PollEvent
和 SDL_WaitEvent
不再 malloc
出字符数组,使用 static
的字符数组。
nanos-lite:
报错的原因是因为不同格式的 pixels 所占的空间不同。需要对应修改 SDL_BlitSurface
、SDL_FillRect
和 SDL_UpdateRect
。调色板机制在 SDL_UpdateRect
中实现:
- PAL_ProcessEvent -> PAL_UpdateKeyboardState -> SDL_GetKeyboardState -> SDL_GetKeyState
https://wiki.libsdl.org/SDL_GetKeyboardState
脚本引擎
在 navy-apps/apps/pal/repo/src/game/script.c
中有一个 PAL_InterpretInstruction()
的函数,尝试大致了解这个函数的作用和行为。然后大胆猜测一下,仙剑奇侠传的开发者是如何开发这款游戏的?
am-kernels
在 Navy 中有一个 libam 的库,用来实现 AM 的 API。
我们需要使用 Navy 的运行时环境实现这些 API,这样我们就可以在 Navy 上运行各种 AM 应用了。
主要思想:
- AM 程序使用 libam 中实现的 API
- 这些 API 通过 Navy 的运行时环境实现
- Navy 的运行时环境又会依赖于系统调用,系统调用的实现中可能会使用 AM 的 API
举个栗子:
AM 程序并不知道自己调用的 API 绕了一个大圈……
实现
下面研究一下 libam 库:
先看一下 Makefile:
第一行的意思是匹配是否有 include/am-origin.h
这个文件,如果没有的话会返回空,也就是 ifeq
语句条件成立。我们刚开始没有这个文件,所以会进入第二行。
第一行的意思是匹配是否有 $(AM_HOME)/am/include/am.h
这个文件,显然是有的,跳过报错,并建立一下软链接,原来的 am.h
被链接到了 am-origin.h
。
而 include
目录下的 am.h
包含了这些头文件:
这里的 ARCH_H
会在 am-origin.h
中被包含。
navy.h
是一些要用到的 newlib
:
然后我们在 libam 中实现 TRM:
然后是 IOE:
仿 NDL_GetTicks
仿 SDL_PollEvent
仿 NDL_DrawRect
最后与 AM 一样加上回调函数表:
运行
实现之后,navy-apps/apps/am-kernels/Makefile
会把 libam 加入链接的列表。我们在 navy-apps/apps/am-kernels
目录下键入:
就可以在 navy-apps/fsimg/bin
下得到镜像文件。
am-kernels 对应的 build
文件夹下也会有变化
然后我们在 nanos-lite 中更新:
就可以运行了……
运行中一些奇怪的现象:
- 打字小游戏的屏幕是按列刷新……
- 跑分的时间是整秒……
关掉 trace,跑分似乎并不差,甚至比无 OS 还要好……
尝试把 microbench 编译到 Navy 并运行:
- 修改
navy-apps/apps/am-kernels/Makefile
- 似乎无法指定 mainargs
- 大部分测试都因为内存不足而跳过了……
FCEUX
似乎无法指定操作系统所加载程序的 main
函数的参数……
于是 main
的参数 romname
为 NULL
,导致解引用 NULL
调用轨迹参考:
理论上甚至可以在 Navy 上运行 Nanos-lite,开始套娃……
oslab0
学长学姐在他们的 OS 课上编写了一些基于 AM 的小游戏……
在 navy-apps/apps/oslab0/
目录下执行 make ISA=riscv32 ALL=XXX install
试了试推箱子、贪吃蛇、雷电……
有些似乎运行不了:
- ramdisk 镜像的大小不能超过 48MB,即 0x3000000 ……
- /dev/fb
__am_gpu_fbdraw
没有 close(fd)
反汇编代码:
发布代码所有的命名都是随机字符串,就像这样:
实现思路:对源文件进行词法和语法分析,找到标识符,并随机化……
基础设施
自由开关 DiffTest 模式
略,因为一直没用上 DiffTest
快照
程序是个 S = <R, M>
的状态机
寄存器可以表示为 R = {GPR, PC, SR}
,其中 SR
为系统寄存器
还是感觉没啥用,因为 NEMU 一直都是 batch mode
SYS_execve
我们需要一个新的系统调用 SYS_execve
。
操作系统层:
若成功执行其他程序,该系统调用处理函数是不返回的:
应用层:
目前暂时忽略 argv
和 envp
这两个参数。
有了这个系统调用之后,开机菜单就可以发挥全部功力了:
我们还可以修改 SYS_exit
的实现,让它调用 SYS_execve
来再次运行 /bin/menu
,而不是直接调用 halt()
来结束整个系统的运行。
然而使用 exit
结束的 Navy / AM 程序并不多……
我们可以选择一个跑分的程序……
随着应用程序数量的增加,使用开机菜单来运行程序就不是那么方便了。我们可以通过 NTerm 来运行这些程序,只要键入程序的路径,例如 /bin/pal
。
在 NTerm 的內建 Shell 中实现命令解析:
注意去掉 cmd
最后的换行符。
并在主循环前定义 PATH
这个环境变量,这样就不用键入命令的完整路径啦:
RTFM 了解 setenv()
和 execvp()
的行为……
似乎 /bin
或者 /bin/
都可以……
一些问题:
当然会报错……
- 若程序不存在,会在
fs_open
阶段 panic
于是开机动画(音乐)成了可能……
Report
How Hello 2
- 当你在终端键入
./hello
运行 Hello World 程序的时候,计算机究竟做了些什么?
构建了文件系统后,我们需要重新回答 Hello World 程序是如何出现在内存中的。
在 Navy-apps 的 Makefile
中添加相应的应用程序或测试程序后,在 nanos-lite 中执行 make ARCH=riscv32-nemu update
:
输出结果为:
其中的 Building
信息显示了名称和构建规则:
我们使用 make -nB
进一步观察可知:
1、基本流程:ramdisk -> fsimg -> install / …
对应 Navy-apps 的 Makefile
:
2、细化:fsimg
规则中对每个条目执行了 install
规则:
install
规则又依赖于 app
规则:
其中 OBJS
代表可重定位目标文件:
此时会编译得到 hello.c
和 hello.o
文件。
而 libs
规则如下:
其中 $(LIBS)
有 compiler-rt libc libos
。逐一执行 archive
规则:
得到各自的 .a
文件。
之后回到 $(APP)
进行链接,再回到 install
规则将得到的可执行文件复制到 fsing/bin
中。
3、install
之后的规则
fsing
:将 fsimg
中的文件全部重定向到 build/ramdisk.img
中,并生成文件索引表
update
:在 nanos-lite 中建立 ramdisk.img
,文件索引表和 syscall.h
的软连接
得到镜像文件之后,我们指定 naive_uload
初始加载的程序,这里为 /bin/nterm
。loader
方法会通过 fs_open
方法以二进制方式打开文件(程序),并将代码和数据加载到内存中。最后操作系统将控制转移到 nterm
程序。
在 nterm
键入 hello
后,nterm
程序会试图将输入解析成需要运行的程序名,在设置了环境变量后,通过 SYS_execve
系统调用加载 hello
程序。
之后的过程就与 How Hello 1 中一致了。
PAL Crane
- 仙剑奇侠传究竟如何运行?如启动动画,动画里仙鹤在群山中飞过。
这一动画是通过 navy-apps/apps/pal/repo/src/main.c
中的 PAL_SplashScreen()
函数播放的。阅读这一函数,可以得知仙鹤的像素信息存放在数据文件 mgo.mkf
中。
为了简化分析,我们主要讨论,我们的计算机系统是如何从 mgo.mkf
文件中读出仙鹤的像素信息,并且更新到屏幕上。
先说明一下 PAL 需要用到的库:
- compiler-rt
- libc
- libfixedptc
- libminiSDL
- libndl
- libos
回顾一下,libndl 是对 IOE 的封装,libminiSDL 的底层实现为 libndl,而 libfixedptc 就是我们之前实现的定点运算库
参考 PAL_SplashScreen()
函数的注释,可以大概分析出应用程序的行为:
-
分配空间
-
创建屏幕
- VIDEO_CreateCompatibleSurface -> VIDEO_CreateCompatibleSizedSurface -> SDL_CreateRGBSurface
- 本质上是分配空间,初始化屏幕
-
读取文件
- PAL_MKFReadChunk -> fseek -> fread
- 通过 libc 库,最终调用
_read
,触发系统调用
- 操作系统通过
fs_read
读取文件
-
生成仙鹤的位置坐标
-
播放音乐
-
清除事件
- PAL_ProcessEvent & PAL_ClearKeyState
-
读取当前时间
- SDL_GetTicks -> NDL_GetTicks
- 触发系统调用
SYS_gettimeofday
-
死循环
- 读取键盘事件
- 设置并更新调色板
- 更新屏幕
- 背景:VIDEO_CopySurface -> SDL_BlitSurface
- 仙鹤 & 标题:一些特殊的方法,最后都归结为更新像素信息
- VIDEO_UpdateScreen -> (SDL_SoftStretch) / SDL_FillRect / SDL_UpdateRect
- SDL_UpdateRect -> NDL_DrawRect -> open and write
/dev/fb
- 触发系统调用,下略
- 检查键盘,判断是否结束开始动画
-
释放资源,结束音乐