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:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite$ make ARCH=riscv32-nemu run
回顾一下编译运行的过程:
# Building nanos-lite-run [riscv32-nemu]# Building am-archive [riscv32-nemu]# Building klib-archive [riscv32-nemu]# Creating image [riscv32-nemu]
得到 Nanos-lite 的 bin 镜像后,再调用解释器以 batch mode 运行该镜像:
make -C /home/vgalaxy/ics2021/nemu ISA=riscv32 run ARGS="-b -l /home/vgalaxy/ics2021/nanos-lite/build/nemu-log.txt" IMG=/home/vgalaxy/ics2021/nanos-lite/build/nanos-lite-riscv32-nemu.binmake[1]: Entering directory '/home/vgalaxy/ics2021/nemu'+ LD /home/vgalaxy/ics2021/nemu/build/riscv32-nemu-interpreter/home/vgalaxy/ics2021/nemu/build/riscv32-nemu-interpreter -b -l /home/vgalaxy/ics2021/nanos-lite/build/nemu-log.txt /home/vgalaxy/ics2021/nanos-lite/build/nanos-lite-riscv32-nemu.bin
类似在 NEMU 上运行 NEMU
来看一下操作系统的 main 函数:
int main() { extern const char logo[]; printf("%s", logo); Log("'Hello World!' from Nanos-lite"); Log("Build time: %s, %s", __TIME__, __DATE__);
init_mm();
init_device();
init_ramdisk();
#ifdef HAS_CTE init_irq();#endif
init_fs();
init_proc();
Log("Finish initialization");
#ifdef HAS_CTE yield();#endif
panic("Should not reach here");}
其中 HAS_CTE 对应上下文扩展,目前尚未定义。
注意 Nanos-lite 中定义的 Log()
宏并不是 NEMU 中定义的 Log()
宏。在 Nanos-lite 中,Log()
宏通过你在 klib
中编写的 printf()
输出,最终会调用 TRM 的 putch()
。
所以需要扩展 printf 的修饰符:
case 'p':sprint_s(out,"0x");sprint_x(out,va_arg(ap,unsigned int),-1);break;64 位的实现参考:
#include <stdio.h>int main() {int a = 1;int *b = &a;printf("%p %p %p\n", &a, b, &b);printf("0x%lx 0x%lx 0x%lx\n", (unsigned long)&a, (unsigned long)b, (unsigned long)&b);}
来自操作系统的新需求
- 用户程序执行结束之后,可以跳转到操作系统的代码继续执行
- 操作系统可以加载一个新的用户程序来执行
需要一种可以限制入口的执行流切换方式。
为了阻止程序将执行流切换到操作系统的任意位置,硬件中逐渐出现保护机制相关的功能:在硬件中加入一些与特权级检查相关的门电路(例如比较器电路),如果发现了非法操作,就会抛出一个异常信号,让 CPU 跳转到一个约定好的目标位置,并进行后续处理。
以支持现代操作系统的 RISC-V 处理器为例,它们存在 M, S, U 三个特权模式,分别代表机器模式、监管者模式和用户模式。M 模式特权级最高,U 模式特权级最低,低特权级能访问的资源,高特权级也能访问。
通常来说,操作系统运行在 S 模式,因此有权限访问所有的代码和数据;而一般的程序运行在 U 模式,这就决定了它只能访问 U 模式的代码和数据。这样,只要操作系统将其私有代码和数据放 S 模式中,恶意程序就永远没有办法访问到它们。
PS:Meltdown 和 Spectre 这两个大名鼎鼎的硬件漏洞,打破了特权级的边界,恶意程序在特定的条件下可以以极高的速率窃取操作系统的信息。
vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite$ sudo cat /proc/cpuinfo...model name : Intel(R) Core(TM) i7-10875H CPU @ 2.30GHz...bugs : spectre_v1 spectre_v2 spec_store_bypass swapgs itlb_multihit...
根据 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
,执行这条虚构指令的行为就是上文提到的异常响应过程:
SR[mepc] <- PCSR[mcause] <- 一个描述失败原因的号码PC <- SR[mtvec]
如果一条指令执行成功,其行为和之前介绍的 TRM 与 IOE 相同;如果一条指令执行失败,其行为等价于执行了虚构的 raise_intr
指令。
事实上,我们可以把这些失败的条件表示成一个函数 fex: S -> {0, 1}
,给定状态机的任意状态 S
,fex(S)
都可以唯一表示当前 PC 指向的指令是否可以成功执行。
最后,异常响应机制的加入还伴随着一些系统指令的添加。这些指令除了用于专门对状态机中的 SR
进行操作之外,本质上和 TRM 的计算指令没有太大区别。
将上下文管理抽象成 CTE
硬件提供的上述在操作系统和用户程序之间切换执行流的功能,在操作系统看来,都可以划入上下文管理的一部分。
与 IOE 一样,上下文管理的具体实现也是架构相关的。为了遵循 AM 的精神,我们需要将不同架构的上下文管理功能抽象成统一的 API,需要的信息如下:
- 引发这次执行流切换的原因
- 程序的上下文
在处理过程中,操作系统可能会读出上下文中的一些寄存器,根据它们的信息来进行进一步的处理。例如操作系统读出 PC 所指向的非法指令,看看其是否能被模拟执行,如用软件模拟浮点指令的执行。如果无法处理,那就 UB 吧,如栈溢出。
不过,AM 究竟给程序提供了多大的栈空间呢?
通过追踪堆区的创建可知:
Area heap = RANGE(&_heap_start, PMEM_END);其中
RANGE
宏的定义为:
#define RANGE(st, ed) (Area) { .start = (void *)(st), .end = (void *)(ed) }
PMEM_END
的定义为:
extern char _pmem_start;#define PMEM_SIZE (128 * 1024 * 1024)#define PMEM_END ((uintptr_t)&_pmem_start + PMEM_SIZE)而
_pmem_start
定义在链接选项LDFLAGS
中,位于abstract-machine/scripts/platform/nemu.mk
:
LDFLAGS += -T $(AM_HOME)/scripts/linker.ld \--defsym=_pmem_start=0x80000000 --defsym=_entry_offset=0x0
_heap_start
定义在链接脚本文件abstract-machine/scripts/linker.ld
中:
_stack_top = ALIGN(0x1000);. = _stack_top + 0x8000;_stack_pointer = .;end = .;_end = .;_heap_start = ALIGN(0x1000);从而可知堆区的大小与
_heap_start
相关,一直到0x88000000
,而栈的大小为0x8000
。
对于切换原因,我们只需要定义一种统一的描述方式即可。CTE 定义了名为事件的如下数据结构,见 abstract-machine/am/include/am.h
:
// An event of type @event, caused by @cause of pointer @reftypedef struct { enum { EVENT_NULL = 0, EVENT_YIELD, EVENT_SYSCALL, EVENT_PAGEFAULT, EVENT_ERROR, EVENT_IRQ_TIMER, EVENT_IRQ_IODEV, } event; uintptr_t cause, ref; const char *msg;} Event;
其中 event
表示事件编号,cause
和 ref
是一些描述事件的补充信息,msg
是事件信息字符串,我们在 PA 中只会用到 event
。
对于上下文,我们只能将描述上下文的结构体类型名统一成 Context
:
// Arch-dependent processor contexttypedef struct Context Context;
至于其中的具体内容,就无法进一步进行抽象了。这主要是因为不同架构之间上下文信息的差异过大。对于 riscv32 而言,其定义位于 abstract-machine/am/include/arch/riscv32-nemu.h
:
struct Context { // TODO: fix the order of these members to match trap.S uintptr_t mepc, mcause, gpr[32], mstatus; void *pdir;};
因此,在操作系统中对 Context
成员的直接引用,都属于架构相关的行为,会损坏操作系统的可移植性。不过大多数情况下,操作系统并不需要单独访问 Context
结构中的成员。CTE 也提供了一些的接口,来让操作系统在必要的时候访问它们,从而保证操作系统的相关代码与架构无关。
最后还有另外两个统一的 API:
bool cte_init (Context *(*handler)(Event ev, Context *ctx));void yield (void);
穿越时空的旅程
硬件准备
RTFM
- Unprivileged ISA: Chapter 10
- Privileged Architecture: Chapter 2/3
首先我们需要实现如下指令:
csrrw rd, csr, rs1t = CSRs[csr]; CSRs[csr] = x[rs1]; x[rd] = t
csrrs rd, csr, rs1t = CSRs[csr]; CSRs[csr] = t | x[rs1]; x[rd] = t
csrrc rd, csr, rs1t = CSRs[csr]; CSRs[csr] = t & ~x[rs1]; x[rd] = t
由此衍生出两个伪指令:
csrr rd, csrx[rd] = CSRs[csr]i.e. csrrs rd, csr, x0
csrw csr, rs1CSRs[csr] = x[rs1]i.e. csrrs x0, csr, rs1
上述指令均属于 RV32I Base Integer Instruction。
还有两个特殊的指令:
- ecall
- mret
ecall
指令在 RV32I Base Integer Instruction 部分和 Machine-Mode Privileged Instruction 部分均有所介绍。而 mret
指令属于 Machine-Mode Privileged Instruction。
注意,ecall
指令将当前 PC 保存到 epc 中,而 mret
指令将当前 PC 设置为 epc:
ECALL and EBREAK cause the receiving privilege mode's epc register to be set to the address of the ECALL or EBREAK instruction itself, not the address of the following instruction.
xRET sets the pc to the value stored in the xepc register.
译码部分略。
对于指令的实现,首先我们需要找到 CSR 寄存器在 NEMU 中对应的抽象,然而并没有找到,于是使用了 PA2 中未使用的 RTL 临时寄存器:
#define s0 (&tmp_reg[0])#define s1 (&tmp_reg[1])#define s2 (&tmp_reg[2])#define t0 (&tmp_reg[3])
其对应如下:
s0 - mstatust0 - mtvecs1 - mepcs2 - mcause
下面是指令的实现,我们新建 system.h:
#include <isa.h>
vaddr_t* CSR_dispatch(uint32_t index) { switch (index) { case 0x300: return s0; // mstatus case 0x305: return t0; // mtvec case 0x341: return s1; // mepc case 0x342: return s2; // mcause default: panic("Unsupported CSR"); }}
def_EHelper(mret) { rtl_li(s, &s->dnpc, *s1);}
def_EHelper(ecall) { word_t mtvec = isa_raise_intr(11, s->pc); // EVENT_YIELD rtl_li(s, &s->dnpc, mtvec);}
def_EHelper(csrrw) { uint32_t index = id_src2->imm; vaddr_t* csr = CSR_dispatch(index); vaddr_t t = *csr; rtlreg_t rs1 = *dsrc1; *csr = rs1; *ddest = t;}
def_EHelper(csrrs) { uint32_t index = id_src2->imm; vaddr_t* csr = CSR_dispatch(index); vaddr_t t = *csr; rtlreg_t rs1 = *dsrc1; *csr = t | rs1; *ddest = t;}
def_EHelper(csrrc) { uint32_t index = id_src2->imm; vaddr_t* csr = CSR_dispatch(index); vaddr_t t = *csr; rtlreg_t rs1 = *dsrc1; *csr = t & ~rs1; *ddest = t;}
其中的核心为 CSR_dispatch,参考 manual 中的一段话:
The standard RISC-V ISA sets aside a 12-bit encoding space (csr[11:0]) for up to 4,096 CSRs.
也就是对于 I-type 的 csrr 系指令而言,其立即数中存放的是 CSR 寄存器在一段空间中的索引。于是我们 RTFM 找到索引,返回 NEMU 中的对应的寄存器即可。
这里包含头文件 isa.h
是为了调用 isa_raise_intr
:
#include <rtl/rtl.h>
// s0 - mstatus// t0 - mtvec// s1 - mepc// s2 - mcause
// 1. 将当前 PC 值保存到 mepc 寄存器// 2. 在 mcause 寄存器中设置异常号// 3. 从 mtvec 寄存器中取出异常入口地址// 4. 跳转到异常入口地址word_t isa_raise_intr(word_t NO, vaddr_t epc) { /* TODO: Trigger an interrupt/exception with ``NO''. * Then return the address of the interrupt/exception vector. */ *s1 = epc; *s2 = NO;
return *t0;}
设置异常入口地址
调用轨迹:
main -> init_irq -> cte_init
下面来看 cte_init 函数:
bool cte_init(Context*(*handler)(Event, Context*)) { // initialize exception entry asm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap));
// register event handler user_handler = handler;
return true;}
内联汇编语句将 __am_asm_trap
的地址(异常入口地址)存入 mtvec 寄存器中,并注册一个事件处理回调函数。
触发自陷操作
调用轨迹:
main -> yield
下面来看 yield
函数:
void yield() { asm volatile("li a7, -1; ecall");}
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
完成:
#define CONTEXT_SIZE ((32 + 3 + 1) * XLEN)#define OFFSET_SP ( 2 * XLEN)#define OFFSET_CAUSE (32 * XLEN)#define OFFSET_STATUS (33 * XLEN)#define OFFSET_EPC (34 * XLEN)
.align 3.globl __am_asm_trap__am_asm_trap: addi sp, sp, -CONTEXT_SIZE
MAP(REGS, PUSH)
csrr t0, mcause csrr t1, mstatus csrr t2, mepc
STORE t0, OFFSET_CAUSE(sp) STORE t1, OFFSET_STATUS(sp) STORE t2, OFFSET_EPC(sp)
# set mstatus.MPRV to pass difftest li a0, (1 << 17) or t1, t1, a0 csrw mstatus, t1
mv a0, sp jal __am_irq_handle
LOAD t1, OFFSET_STATUS(sp) LOAD t2, OFFSET_EPC(sp) csrw mstatus, t1 csrw mepc, t2
MAP(REGS, POP)
addi sp, sp, CONTEXT_SIZE mret
在调用 __am_irq_handle
之前,需要对上下文进行保存:
- 通用寄存器
- 触发异常时的 PC 和处理器状态,即 mepc 和 mstatus
- 异常号,即 mcause
- 地址空间,为 PA4 准备,riscv32 将地址空间信息与 0 号寄存器共用存储空间
下面的重点是上下文结构的组织,我们调整 Context 结构体使之与 __am_asm_trap
的行为一致:
struct Context { // TODO: fix the order of these members to match trap.S uintptr_t gpr[32], mcause, mstatus, mepc; void *pdir;};
大概思路是,先让栈指针 sp 减去 CONTEXT_SIZE,然后依次将上下文信息存入栈中:
| | <-- prev sp| || mepc || mstatus || mcause || x31 || x30 |...| x3 || || x1 || | <-- sp
其中 x2 寄存器为 sp。最后通过 mv a0, sp
指令准备好参数(第一个参数通过寄存器 a0 传递)。
事件分发
Context* __am_irq_handle(Context *c) { printf("mepc: %x, mcause: %u, mstatus: %u\n", c->mepc, c->mcause, c->mstatus); if (user_handler) { Event ev = {0}; switch (c->mcause) { case EVENT_YIELD: ev.event = EVENT_YIELD; break; case EVENT_SYSCALL: ev.event = EVENT_SYSCALL; break; case EVENT_PAGEFAULT: ev.event = EVENT_PAGEFAULT; break; default: ev.event = EVENT_ERROR; break; }
c = user_handler(ev, c); assert(c != NULL); }
return c;}
来到 __am_irq_handle
中,这里根据执行流切换的原因打包成事件,然后调用在 cte_init()
中注册的事件处理回调函数,将事件交给 Nanos-lite 来处理:
static Context* do_event(Event e, Context* c) { switch (e.event) { case EVENT_YIELD: printf("EVENT_YIELD\n"); break; case EVENT_SYSCALL: printf("EVENT_SYSCALL\n"); break; case EVENT_PAGEFAULT: printf("EVENT_PAGEFAULT\n"); break; default: panic("Unhandled event ID = %d", e.event); }
return c;}
重点是 __am_irq_handle
如何读取上下文信息的,我们参考反汇编:
8000062c <__am_irq_handle>:8000062c: fd010113 addi sp,sp,-4880000630: 08452683 lw a3,132(a0)80000634: 08052603 lw a2,128(a0)80000638: 08852583 lw a1,136(a0)8000063c: 02812423 sw s0,40(sp)80000640: 00050413 mv s0,a080000644: 80002537 lui a0,0x8000280000648: 13850513 addi a0,a0,312 # 80002138 <_end+0xfffec138>8000064c: 02112623 sw ra,44(sp)80000650: 291000ef jal ra,800010e0 <printf>...
我们接着上面的栈:
| | <-- prev prev sp| || mepc || mstatus || mcause || x31 || x30 |...| x3 || || x1 || | <-- prev sp...| | <-- sp
寄存器 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()
函数:
.globl _start_start: move s0, zero jal call_main
_start()
函数会调用 navy-apps/libs/libos/src/crt0/crt0.c
中的 call_main()
函数:
void call_main(uintptr_t *args) { char *empty[] = {NULL }; environ = empty; exit(main(0, empty, empty)); assert(0);}
然后调用用户程序的 main()
函数,从 main()
函数返回后会调用 exit()
结束运行。
我们要在 Nanos-lite 上运行的第一个用户程序是 navy-apps/tests/dummy/dummy.c
:
int main() { return _syscall_(SYS_yield, 0, 0, 0);}
为了避免和 Nanos-lite 的内容产生冲突,我们约定目前用户程序需要被链接到内存位置 0x83000000
附近。Navy 已经设置好了相应的选项,见 navy-apps/scripts/riscv32.mk
中的 LDFLAGS
变量:
CROSS_COMPILE = riscv64-linux-gnu-LNK_ADDR = $(if $(VME), 0x40000000, 0x83000000)CFLAGS += -fno-pic -march=rv32g -mabi=ilp32LDFLAGS += -melf32lriscv --no-relax -Ttext-segment $(LNK_ADDR)
在 navy-apps/tests/dummy/
目录下执行:
make ISA=riscv32
编译成功后把 navy-apps/tests/dummy/build/dummy-riscv32
手动复制到 nanos-lite/build/ramdisk.img
。
使用 cat 命令重定向输出
然后在 nanos-lite/
目录下执行
make ARCH=riscv32-nemu
会生成 Nanos-lite 的可执行文件,编译期间会把 ramdisk 镜像文件 nanos-lite/build/ramdisk.img
包含进 Nanos-lite 成为其中的一部分,在 nanos-lite/src/resources.S
中实现:
.section .data.global ramdisk_start, ramdisk_endramdisk_start:.incbin "build/ramdisk.img"ramdisk_end:
总结来说,可执行文件位于 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 命令:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ readelf -a ramdisk.img
得到 ELF Header:
ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: RISC-V Version: 0x1 Entry point address: 0x830003dc Start of program headers: 52 (bytes into file) Start of section headers: 27772 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 3 Size of section headers: 40 (bytes) Number of section headers: 11 Section header string table index: 10
Program Headers:
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x83000000 0x83000000 0x04df8 0x04df8 R E 0x1000 LOAD 0x005000 0x83005000 0x83005000 0x00898 0x008d4 RW 0x1000 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
我们研究一下映射关系:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ hexdump ramdisk.img -n 520000000 457f 464c 0101 0001 0000 0000 0000 00000000010 0002 00f3 0001 0000 03dc 8300 0034 00000000020 6c7c 0000 0000 0000 0034 0020 0003 00280000030 000b 000a0000034
- 机器架构偏移 0x0000012~0x0000013
- 程序入口偏移 0x0000018~0x000001b
- 程序头表起始位置偏移 0x000001c~0x000001f
- 程序头表大小偏移 0x000002a~0x000002b,注意这里是一项的大小
- 程序头表项数偏移 0x000002c~0x000002d
下面是每个 segment 的信息:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ hexdump ramdisk.img -s 52 -n 960000034 0001 0000 0000 0000 0000 8300 0000 83000000044 4df8 0000 4df8 0000 0005 0000 1000 00000000054 0001 0000 5000 0000 5000 8300 5000 83000000064 0898 0000 08d4 0000 0006 0000 1000 00000000074 e551 6474 0000 0000 0000 0000 0000 00000000084 0000 0000 0000 0000 0006 0000 0010 00000000094
大概是每 32bits 记录一条信息。
正确的内存位置
注意到 naive_uload
将程序入口强制转换一个函数指针并调用:
void naive_uload(PCB *pcb, const char *filename) { uintptr_t entry = loader(pcb, filename); Log("Jump to entry = %p", entry); ((void(*)())entry) ();}
所以只要对 segment 中的 VirtAddr 直接赋值就可以了。
可以参考下面的图示:
+-------+---------------+-----------------------+ | |...............| | | |...............| | ELF file | |...............| | +-------+---------------+-----------------------+ 0 ^ | |<------+------>| | | | | | | +----------------------------+ | | Type | Offset VirtAddr PhysAddr |FileSiz MemSiz Flg Align LOAD +-- 0x001000 0x03000000 0x03000000 +0x1d600 0x27240 RWE 0x1000 | | | | +-------------------+ | | | | | | | | | | | | | | | | +-----------+ --- | | | |00000000000| ^ | | | --- |00000000000| | | | | ^ |...........| | | | | | |...........| +------+ | +--+ |...........| | | | |...........| | | v |...........| v +-------> +-----------+ --- | | | | Memory
FileSiz
通常不会大于相应的MemSiz
,MemSiz
多出的部分可能是.bss
段,需要初始化为 0,也就是将[VirtAddr + FileSiz, VirtAddr + MemSiz)
对应的物理区间清零。
实现
实现 nanos-lite/src/loader.c
中的 loader()
函数,参数目前不考虑:
// see /usr/include/elf.h to get the right type// #define EM_RISCV 243 /* RISC-V */// #define EM_X86_64 62 /* AMD x86-64 architecture */
#if defined(__ISA_AM_NATIVE__) || defined(__ISA_X86_64__)# define EXPECT_TYPE EM_X86_64#elif defined(__ISA_X86__)# define EXPECT_TYPE panic("Unknown type")#elif defined(__ISA_RISCV32__) || defined(__ISA_RISCV64__)# define EXPECT_TYPE EM_RISCV#elif defined(__ISA_MIPS32__)# define EXPECT_TYPE panic("Unknown type")#else# error Unsupported ISA#endif
size_t ramdisk_read(void *buf, size_t offset, size_t len);
static uintptr_t loader(PCB *pcb, const char *filename) { char buf[64];
// check magic number ramdisk_read(buf, 0, 8); uint64_t magic_number = 0; for (int i = 0; i < 8; ++i) magic_number = magic_number * 256 + buf[i]; assert(magic_number == 0x7f454c4601010100);
// check machine architecture ramdisk_read(buf, 18, 2); uint16_t type = 0; for (int i = 1; i >= 0; --i) type = type * 256 + buf[i]; EXPECT_TYPE; // trigger panic() assert(type == EXPECT_TYPE);
// get entry point address uintptr_t entry_point_address = 0; ramdisk_read(buf, 24, 4); for (int i = 3; i >= 0; --i) entry_point_address = entry_point_address * 256 + buf[i];
// get program header basic infos uint32_t program_header_index = 0; uint32_t program_header_length = 0; // the size of one section uint32_t program_header_number = 0;
ramdisk_read(buf, 28, 4); for (int i = 3; i >= 0; --i) program_header_index = program_header_index * 256 + buf[i]; ramdisk_read(buf, 42, 2); for (int i = 1; i >= 0; --i) program_header_length = program_header_length * 256 + buf[i]; ramdisk_read(buf, 44, 4); for (int i = 1; i >= 0; --i) program_header_number = program_header_number * 256 + buf[i]; // Log("%u %u %u", program_header_index, program_header_length, program_header_number);
// handle items of program header for (int i = 0; i < program_header_number; ++i) { uint32_t type = 0; uint32_t base = program_header_index + i * program_header_length; ramdisk_read(buf, base, 4); for (int i = 3; i >= 0; --i) type = type * 256 + buf[i]; if (type == 1) { // LOAD uint32_t offset = 0; uint32_t virtAddr = 0; uint32_t fileSiz = 0; uint32_t memSiz = 0;
ramdisk_read(buf, base + 4, 4); for (int i = 3; i >= 0; --i) offset = offset * 256 + buf[i]; ramdisk_read(buf, base + 8, 4); for (int i = 3; i >= 0; --i) virtAddr = virtAddr * 256 + buf[i]; ramdisk_read(buf, base + 16, 4); for (int i = 3; i >= 0; --i) fileSiz = fileSiz * 256 + buf[i]; ramdisk_read(buf, base + 20, 4); for (int i = 3; i >= 0; --i) memSiz = memSiz * 256 + buf[i];
// Log("0x%08x 0x%08x 0x%08x 0x%08x", offset, virtAddr, fileSiz, memSiz);
char * buf_malloc = (char *)malloc(fileSiz * sizeof(char) + 1); ramdisk_read(buf_malloc, offset, fileSiz); memcpy((void *)virtAddr, buf_malloc, fileSiz); memset((void *)(virtAddr + fileSiz), 0, memSiz - fileSiz); free(buf_malloc); } }
return entry_point_address;}
这里的 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 机制有所不同,目前暂不使用。
梳理一下用户程序的加载过程:
main -> init_proc -> naive_uload -> loader
操作系统的运行时环境
在 PA2 中,我们根据具体实现是否与 ISA 相关,将运行时环境划分为两部分。但对于运行在操作系统上的程序,它们就不需要直接与硬件交互了。
作为资源管理者管理着系统中的所有资源,操作系统还需要为用户程序提供相应的服务。这些服务需要以一种统一的接口来呈现,用户程序也只能通过这一接口来请求服务。
这一接口就是系统调用。
于是,系统调用把整个运行时环境分成两部分,一部分是操作系统内核区,另一部分是用户区。那些会访问系统资源的功能会放到内核区中实现,而用户区则保留一些无需使用系统资源的功能,比如 strcpy()
,以及用于请求系统资源相关服务的系统调用接口。
系统调用
用户程序通过自陷指令来触发系统调用,触发了事件 EVENT_SYSCALL
。
硬件与操作系统部分(实现)
既然我们通过自陷指令来触发系统调用,那么对用户程序来说,用来向操作系统描述需求的最方便手段就是使用通用寄存器,参见 abstract-machine/am/include/arch/riscv32-nemu.h
:
#define GPR1 gpr[17] // a7#define GPR2 gpr[10] // a0#define GPR3 gpr[11] // a1#define GPR4 gpr[12] // a2#define GPRx gpr[10] // a0
前四个为系统调用参数寄存器,第一个记录系统调用事件编号。最后一个寄存器 GPRx
为系统调用返回值寄存器。
其通用寄存器的选择参考 navy-apps/libs/libos/src/syscall.c
:
# define ARGS_ARRAY ("ecall", "a7", "a0", "a1", "a2", "a0")
TODO: RISC-V Linux 为什么没有使用
a0
来传递系统调用号呢?
我们还需要修改下面的地方:
- ecall 指令的实现
#define EVENT_YIELD 1#define EVENT_SYSCALL 2
def_EHelper(ecall) { rtlreg_t a7 = gpr(17); Log("GPR a7: %u", a7); word_t exception_code; switch (a7) { // void yield() { asm volatile("li a7, -1; ecall"); } case -1: exception_code = EVENT_YIELD; break; default: exception_code = EVENT_SYSCALL; break; } word_t mtvec = isa_raise_intr(exception_code, s->pc); rtl_li(s, &s->dnpc, mtvec);#ifdef CONFIG_ETRACE Log("ecall at " FMT_WORD ", mstatus: " FMT_WORD ", mepc: " FMT_WORD ", mcause: " FMT_WORD, s->pc, *s0, *s1, *s2); Log("The machine trap-handler base address is " FMT_WORD, mtvec);#endif}
其中 gpr
定义在 nemu/src/isa/riscv32/local-include/reg.h
:
#define gpr(idx) (cpu.gpr[check_reg_idx(idx)]._32)
取出寄存器 a7
的值,若为 -1
,则为异常事件 EVENT_YIELD
,否则为异常事件 EVENT_SYSCALL
。
仍需要斟酌……
将异常事件编号
EVENT_YIELD
又改回来了,由此无法通过 DiffTest ……发现 DiffTest 的 REF 中
EVENT_YIELD
和EVENT_SYSCALL
的编号均为 11,那就无法区分了……
注意若为异常事件 EVENT_SYSCALL
,寄存器 a7
中的值即为系统调用事件编号!
- 事件处理回调函数
do_event
:
static Context* do_event(Event e, Context* c) { switch (e.event) { case EVENT_YIELD: Log("EVENT_YIELD"); break; case EVENT_SYSCALL: Log("EVENT_SYSCALL"); do_syscall(c); break; case EVENT_PAGEFAULT: Log("EVENT_PAGEFAULT"); break; default: panic("Unhandled event ID = %d", e.event); } return c;}
针对异常事件 EVENT_SYSCALL
,调用 nanos-lite/src/syscall.c
中的 do_syscall
函数,该函数参数为上下文信息:
void do_syscall(Context *c) { uintptr_t a[4]; a[0] = c->GPR1;
intptr_t ret; switch (a[0]) { case SYS_exit: sys_exit(c->GPR2); break; case SYS_yield: ret = sys_yield(); break; default: panic("Unhandled syscall ID = %d", a[0]); }
c->GPRx = ret;}
取出 GPR1
即寄存器 a7
,并进行事件分派。并将返回值存入 GPRx
中。
我们还需要将 navy-apps/libs/libos/src/syscall.h
中系统调用事件编号的定义复制到 nanos-lite/src/syscall.h
。
下面是相应的系统调用处理函数:
int sys_yield() { yield(); return 0;}
void sys_exit(int status) { halt(status);}
用户程序部分(接口)
Navy 已经为用户程序准备好了系统调用的接口了,即 navy-apps/libs/libos/src/syscall.c
中定义的 _syscall_()
函数:
intptr_t _syscall_(intptr_t type, intptr_t a0, intptr_t a1, intptr_t a2) { register intptr_t _gpr1 asm (GPR1) = type; register intptr_t _gpr2 asm (GPR2) = a0; register intptr_t _gpr3 asm (GPR3) = a1; register intptr_t _gpr4 asm (GPR4) = a2; register intptr_t ret asm (GPRx); asm volatile (SYSCALL : "=r" (ret) : "r"(_gpr1), "r"(_gpr2), "r"(_gpr3), "r"(_gpr4)); return ret;}
void _exit(int status) { _syscall_(SYS_exit, status, 0, 0); while (1);}
上述代码会先把系统调用的参数依次放入寄存器中,然后执行自陷指令。由于寄存器和自陷指令都是 ISA 相关的,因此这里根据不同的 ISA 定义了不同的宏,来对它们进行抽象。CTE 会将这个自陷操作打包成一个系统调用事件 EVENT_SYSCALL
,并交由 Nanos-lite 继续处理。
执行过程分析
我们结合用户程序的反汇编:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ riscv64-linux-gnu-objdump -d ramdisk.img
ramdisk.img: file format elf32-littleriscv
Disassembly of section .text:
83000094 <main>:83000094: 00000693 li a3,083000098: 00000613 li a2,08300009c: 00000593 li a1,0830000a0: 00100513 li a0,1830000a4: 00000317 auipc t1,0x0830000a8: 00830067 jr 8(t1) # 830000ac <_syscall_>
830000ac <_syscall_>:830000ac: 00050893 mv a7,a0830000b0: 00058513 mv a0,a1830000b4: 00060593 mv a1,a2830000b8: 00068613 mv a2,a3830000bc: 00000073 ecall830000c0: 00008067 ret
830000c4 <_exit>:830000c4: 00000893 li a7,0830000c8: 00000593 li a1,0830000cc: 00000613 li a2,0830000d0: 00000073 ecall830000d4: 0000006f j 830000d4 <_exit+0x10>
...
830003dc <_start>:830003dc: 00000413 li s0,0830003e0: 004000ef jal ra,830003e4 <call_main>
830003e4 <call_main>:830003e4: fe010113 addi sp,sp,-32830003e8: 00c10613 addi a2,sp,12830003ec: 830067b7 lui a5,0x83006830003f0: 00060593 mv a1,a2830003f4: 00000513 li a0,0830003f8: 00112e23 sw ra,28(sp)830003fc: 88c7a423 sw a2,-1912(a5) # 83005888 <__BSS_END__+0xffffffb4>83000400: 00012623 sw zero,12(sp)83000404: 00000097 auipc ra,0x083000408: c90080e7 jalr -880(ra) # 83000094 <main>8300040c: 00000097 auipc ra,0x083000410: 080080e7 jalr 128(ra) # 8300048c <exit>
...
8300048c <exit>:8300048c: ff010113 addi sp,sp,-1683000490: 00000593 li a1,083000494: 00812423 sw s0,8(sp)83000498: 00112623 sw ra,12(sp)8300049c: 00050413 mv s0,a0830004a0: 00000097 auipc ra,0x0830004a4: 028080e7 jalr 40(ra) # 830004c8 <__call_exitprocs>830004a8: 830067b7 lui a5,0x83006830004ac: 8847a503 lw a0,-1916(a5) # 83005884 <__BSS_END__+0xffffffb0>830004b0: 03c52783 lw a5,60(a0)830004b4: 00078463 beqz a5,830004bc <exit+0x30>830004b8: 000780e7 jalr a5830004bc: 00040513 mv a0,s0830004c0: 00000097 auipc ra,0x0830004c4: c04080e7 jalr -1020(ra) # 830000c4 <_exit>
和运行结果来分析其执行过程:
[/home/vgalaxy/ics2021/nanos-lite/src/loader.c,100,naive_uload] Jump to entry = 0x830003dc[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:25 exec_ecall] GPR a7: 1[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:35 exec_ecall] ecall at 0x830000bc, mstatus: 0x00001800, mepc: 0x830000bc, mcause: 0x00000002[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:36 exec_ecall] The machine trap-handler base address is 0x80000b80[/home/vgalaxy/ics2021/nanos-lite/src/irq.c,8,do_event] EVENT_SYSCALL[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:25 exec_ecall] GPR a7: -1[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:35 exec_ecall] ecall at 0x80000b74, mstatus: 0x00021800, mepc: 0x80000b74, mcause: 0x00000001[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:36 exec_ecall] The machine trap-handler base address is 0x80000b80[/home/vgalaxy/ics2021/nanos-lite/src/irq.c,7,do_event] EVENT_YIELD[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:16 exec_mret] mret at 0x80000cb8, mstatus: 0x00021800, mepc: 0x80000b78, mcause: 0x00000001[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:16 exec_mret] mret at 0x80000cb8, mstatus: 0x00001800, mepc: 0x830000c0, mcause: 0x00000001[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:25 exec_ecall] GPR a7: 0[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:35 exec_ecall] ecall at 0x830000d0, mstatus: 0x00001800, mepc: 0x830000d0, mcause: 0x00000002[/home/vgalaxy/ics2021/nemu/src/isa/riscv32/include/../instr/system.h:36 exec_ecall] The machine trap-handler base address is 0x80000b80[/home/vgalaxy/ics2021/nanos-lite/src/irq.c,8,do_event] EVENT_SYSCALL[src/cpu/cpu-exec.c:152 cpu_exec] nemu: HIT GOOD TRAP at pc = 0x80000734
其执行过程大概如下:
_start -> call_main -> main -> _syscall_(SYS_yield, 0, 0, 0) -> exit -> _exit
对于 _syscall_(SYS_yield, 0, 0, 0)
这个系统调用处理,经历了如下过程:
异常事件 EVENT_SYSCALL -> 系统调用事件 SYS_yield
ecall at 0x830000bc, mstatus: 0x00001800, mepc: 0x830000bc, mcause: 0x00000002
异常事件 EVENT_YIELD
ecall at 0x80000b74, mstatus: 0x00021800, mepc: 0x80000b74, mcause: 0x00000001
mret at 0x80000cb8, mstatus: 0x00021800, mepc: 0x80000b78, mcause: 0x00000001
mret at 0x80000cb8, mstatus: 0x00001800, mepc: 0x830000c0, mcause: 0x00000001
也就是一个嵌套的 ecall
,注意外层的 mcause
并未恢复。
对于 _exit
系统调用处理,经历了如下过程:
异常事件 EVENT_SYSCALL -> 系统调用事件 sys_exit
ecall at 0x830000d0, mstatus: 0x00001800, mepc: 0x830000d0, mcause: 0x00000002
no mret
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
:
int _write(int fd, void *buf, size_t count) { return _syscall_(SYS_write, fd, (intptr_t)buf, count);}
添加系统调用处理函数:
int sys_write(int fd, void *buf, size_t count) { if (fd == 1 || fd == 2) { for (size_t i = 0; i < count; ++i) { putch(*((char *)buf + i)); } return count; } return -1;}
其中的返回值通过查阅 man 2 write
可知:
On success, the number of bytes written is returned. On error, -1 is returned, and errno is set to indicate the cause of the error.
再加一个 case 就可以了:
case SYS_write: ret = sys_write(c->GPR2, (void *)c->GPR3, (size_t)c->GPR4); Log("sys_write(%d, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret); break;
注意其中的一些强制类型转换
Navy 中提供了一个 hello
测试程序,它首先通过 write()
来输出一句话,然后通过 printf()
来不断输出(逐字符):
#include <unistd.h>#include <stdio.h>
int main() { write(1, "Hello World!\n", 13); int i = 2; volatile int j = 0; while (1) { j ++; if (j == 10000) { printf("Hello World from Navy-apps for the %dth time!\n", i ++); j = 0; } } return 0;}
编译成功后,我们需要将其手动复制到 ramdisk.img
中:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/tests/hello/build$ cat hello-riscv32 > ../../../../nanos-lite/build/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()
库函数来实现的,它的原型是:
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 的位置了。
在 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()
对应
void *sbrk(intptr_t increment);而
sys_brk
对应:
int brk(void *addr);
于是实现如下,修改 navy-apps/libs/libos/src/syscall.c
:
extern int _end;int program_break = (int)(&_end);
void *_sbrk(intptr_t increment) { int program_break_prev = program_break; if (_syscall_(SYS_brk, program_break + increment, 0, 0) == 0) { program_break = program_break + increment; return (void *)program_break_prev; } return (void *)-1;}
添加系统调用处理函数:
int sys_brk(void *addr) { return 0;}
加一个 case:
case SYS_brk: ret = sys_brk((void *)c->GPR2); Log("sys_brk(%p, %d, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret); break;
此时,hello
测试程序中的 printf()
将格式化完毕的字符串通过一次 write()
系统调用进行输出。可以观察 strace:
[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,43,do_syscall] sys_write(1, 0x8300567c, 13) = 13[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,47,do_syscall] sys_brk(0x83006cf0, 0, 0) = 0[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,47,do_syscall] sys_brk(0x83007000, 0, 0) = 0[/home/vgalaxy/ics2021/nanos-lite/src/syscall.c,43,do_syscall] sys_write(1, 0x830068e0, 45) = 45...
缓冲区是批处理技术的核心,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 已经提供了维护这些信息的脚本:
update: $(MAKE) -s -C $(NAVY_HOME) ISA=$(ISA) ramdisk @ln -sf $(NAVY_HOME)/build/ramdisk.img $(RAMDISK_FILE) @ln -sf $(NAVY_HOME)/build/ramdisk.h src/files.h @ln -sf $(NAVY_HOME)/libs/libos/src/syscall.h src/syscall.h
然后运行 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
变量中。
文件记录表其实是一个数组,数组的每个元素都是一个结构体:
typedef struct { char *name; // 文件名 size_t size; // 文件大小 size_t disk_offset; // 文件在 ramdisk 中的偏移} Finfo;
一些注记:
- 由于 sfs 没有目录,我们把目录分隔符
/
也认为是文件名的一部分,例如/bin/hello
是一个完整的文件名。 - 通过文件描述符对应一个正在打开的文件,我们可以简单地把文件记录表的下标作为相应文件的文件描述符返回给用户程序。
- 需要为每一个已经打开的文件引入偏移量属性
open_offset
,来记录目前文件操作的位置。
为了方便用户程序进行标准输入输出,操作系统准备了三个默认的文件描述符:
#define FD_STDIN 0#define FD_STDOUT 1#define FD_STDERR 2
它们分别对应标准输入 stdin
,标准输出 stdout
和标准错误 stderr
。
根据以上信息,我们就可以在文件系统中实现以下的文件操作了:
int fs_open(const char *pathname, int flags, int mode);size_t fs_read(int fd, void *buf, size_t len);size_t fs_write(int fd, const void *buf, size_t len);size_t fs_lseek(int fd, size_t offset, int whence);int fs_close(int fd);
这些文件操作实际上是相应的系统调用在内核中的实现。你可以通过 man
查阅它们的功能,例如:
man 2 open
其中 2
表示查阅和系统调用相关的 manual page。
软件层
我们需要修改 nanos-lite/src/fs.c
,首先定义文件描述符和偏移量属性 open_offset
的结构,并通过 get_open_file_index
获取给定文件描述符在 open_file_table
结构数组中的下标:
typedef struct { size_t fd; size_t open_offset; // relative to disk_offset} OFinfo;
static OFinfo open_file_table[LENGTH(file_table)];static size_t open_file_table_index = 0;static int get_open_file_index(int fd) { // return -1 on failure int target_index = -1; for (int i = 0; i < open_file_table_index; ++i) { if (open_file_table[i].fd == fd) { target_index = i; break; } } return target_index;}
在此基础上,我们编写如下函数。
fs_open
:
int fs_open(const char *pathname, int flags, int mode) { for (int i = 0; i < LENGTH(file_table); ++i) { if (strcmp(file_table[i].name, pathname) == 0) { if (i <= 2) { Log("ignore open %s", pathname); return i; } open_file_table[open_file_table_index].fd = i; open_file_table[open_file_table_index].open_offset = 0; open_file_table_index++; return i; } } panic("file %s not found", pathname); while (1);}
对文件不存在的情况需要直接 panic
,与 manual 不一致。
忽略 flags
和 mode
参数。
忽略对 stdin
, stdout
和 stderr
这三个特殊文件的打开操作。
fs_read
:
size_t fs_read(int fd, void *buf, size_t len) { if (fd <= 2) { Log("ignore read %s", file_table[fd].name); return 0; }
int target_index = get_open_file_index(fd); if (target_index == -1) { Log("file %s not open before read", file_table[fd].name); return -1; }
size_t read_len = len; size_t open_offset = open_file_table[target_index].open_offset; size_t size = file_table[fd].size; size_t disk_offset = file_table[fd].disk_offset;
if (open_offset > size) return 0;
if (open_offset + len > size) read_len = size - open_offset; ramdisk_read(buf, disk_offset + open_offset, read_len); open_file_table[target_index].open_offset += read_len; return read_len;}
同样忽略对 stdin
, stdout
和 stderr
这三个特殊文件的打开操作。
注意偏移量不要越过文件的边界。
最终调用了 ramdisk_read
实现读取操作。
fs_write
:
size_t fs_write(int fd, const void *buf, size_t len) { if (fd == 0) { Log("ignore write %s", file_table[fd].name); return 0; }
if (fd == 1 || fd == 2) { for (size_t i = 0; i < len; ++i) putch(*((char *)buf + i)); return len; }
int target_index = get_open_file_index(fd); if (target_index == -1) { Log("file %s not open before write", file_table[fd].name); return -1; }
size_t write_len = len; size_t open_offset = open_file_table[target_index].open_offset; size_t size = file_table[fd].size; size_t disk_offset = file_table[fd].disk_offset;
if (open_offset > size) return 0;
if (open_offset + len > size) write_len = size - open_offset; ramdisk_write(buf, disk_offset + open_offset, write_len); open_file_table[target_index].open_offset += write_len; return write_len;}
注意该函数取代了之前的系统调用处理函数 sys_write
。
若写入 stdout
和 stderr
,则用 putch()
输出到串口。
fs_lseek
:
size_t fs_lseek(int fd, size_t offset, int whence) { if (fd <= 2) { Log("ignore lseek %s", file_table[fd].name); return 0; }
int target_index = get_open_file_index(fd); if (target_index == -1) { Log("file %s not open before lseek", file_table[fd].name); return -1; }
size_t new_offset = -1; size_t size = file_table[fd].size; size_t open_offset = open_file_table[target_index].open_offset; switch (whence) { case SEEK_SET: if (offset > size) new_offset = size; new_offset = offset; break; case SEEK_CUR: if (offset + open_offset > size) new_offset = size; new_offset = offset + open_offset; break; case SEEK_END: if (offset + size > size) new_offset = size; new_offset = offset + size; break; default: Log("Unknown whence %d", whence); return -1; }
assert(new_offset >= 0); open_file_table[target_index].open_offset = new_offset; return new_offset;}
其中 whence
的枚举值定义在 fs.h
中。
STFM
fs_close
:
int fs_close(int fd) { if (fd <= 2) { Log("ignore close %s", file_table[fd].name); return 0; }
int target_index = get_open_file_index(fd);
if (target_index >= 0) { for (int i = target_index; i < open_file_table_index - 1; ++i) { open_file_table[i] = open_file_table[i + 1]; } open_file_table_index--; assert(open_file_table_index >= 0); return 0; }
Log("file %s not open before close", file_table[fd].name); return -1;}
需要更新 open_file_table
。
下面需要修改系统调用事件分发:
case SYS_open: ret = fs_open((const char *)c->GPR2, c->GPR3, c->GPR4); Log("fs_open(%s, %d, %d) = %d",(const char *)c->GPR2, c->GPR3, c->GPR4, ret); break; case SYS_read: ret = fs_read(c->GPR2, (void *)c->GPR3, (size_t)c->GPR4); Log("fs_read(%d, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret); break; case SYS_close: ret = fs_close(c->GPR2); Log("fs_close(%d, %d, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret); break; case SYS_write: ret = fs_write(c->GPR2, (void *)c->GPR3, (size_t)c->GPR4); Log("fs_write(%d, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret); break; case SYS_lseek: ret = fs_lseek(c->GPR2, (size_t)c->GPR3, c->GPR4); Log("fs_lseek(%d, %d, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret); break;
最后,我们之前是让 loader 来直接调用 ramdisk_read()
来加载用户程序。ramdisk 中的文件数量增加之后,这种方式就不合适了,我们需要让 loader 享受到文件系统的便利。
为此,我们需要利用 naive_uload()
函数的 filename
参数,并将 loader
函数中的文件读取操作修改为如下范式:
int fd = fs_open(filename, 0, 0);assert(fd >= 2); // ignore stdin, stdout, stderr
assert(fs_lseek(fd, offset, SEEK_SET) >= 0);assert(fs_read(fd, buf, len) >= 0);...
assert(fs_close(fd) == 0);
以后更换用户程序只需要修改传入 naive_uload()
函数的文件名即可:
// load program here const char filename[] = "/bin/file-test"; naive_uload(NULL, filename);
需要定义一个变量,直接传参有时候会无法识别全文件名……
用户层
添加相应的系统调用即可:
int _open(const char *path, int flags, mode_t mode) { return _syscall_(SYS_open, (intptr_t)path, flags, mode);}
int _read(int fd, void *buf, size_t count) { return _syscall_(SYS_read, fd, (intptr_t)buf, count);}
int _close(int fd) { return _syscall_(SYS_close, fd, 0, 0);}
int _write(int fd, void *buf, size_t count) { return _syscall_(SYS_write, fd, (intptr_t)buf, count);}
off_t _lseek(int fd, off_t offset, int whence) { return _syscall_(SYS_lseek, fd, offset, whence);}
测试程序
file-test
提供了对文件操作相关的测试:
int main() { FILE *fp = fopen("/share/files/num", "r+"); assert(fp);
fseek(fp, 0, SEEK_END); long size = ftell(fp); assert(size == 5000);
fseek(fp, 500 * 5, SEEK_SET); int i, n; for (i = 500; i < 1000; i ++) { fscanf(fp, "%d", &n); assert(n == i + 1); }
fseek(fp, 0, SEEK_SET); for (i = 0; i < 500; i ++) { fprintf(fp, "%4d\n", i + 1 + 1000); }
for (i = 500; i < 1000; i ++) { fscanf(fp, "%d", &n); assert(n == i + 1); }
fseek(fp, 0, SEEK_SET); for (i = 0; i < 500; i ++) { fscanf(fp, "%d", &n); assert(n == i + 1 + 1000); }
fclose(fp);
printf("PASS!!!\n");
return 0;}
运行后,其系统调用如下:
sys_brk(0x83009c10, 0, 0) = 0sys_brk(0x8300a000, 0, 0) = 0
fs_open(/share/files/num, 2, 438) = 22sys_brk(0x8300b000, 0, 0) = 0
fs_lseek(22, 0, 2) = 5000fs_lseek(22, 2500, 0) = 2500
fs_read(22, 0x83009c08, 1024) = 1024fs_read(22, 0x83009c08, 1024) = 1024fs_read(22, 0x83009c08, 1024) = 452
fs_lseek(22, 4999, 0) = 4999fs_lseek(22, 0, 0) = 0
fs_write(22, 0x83009c08, 1024) = 1024fs_write(22, 0x83009c08, 1024) = 1024fs_write(22, 0x83009c08, 452) = 452
fs_read(22, 0x83009c08, 1024) = 1024fs_read(22, 0x83009c08, 1024) = 1024fs_read(22, 0x83009c08, 1024) = 452
fs_lseek(22, 0, 1) = 5000fs_lseek(22, 4999, 0) = 4999fs_lseek(22, 0, 0) = 0
fs_read(22, 0x83009c08, 1024) = 1024fs_read(22, 0x83009c08, 1024) = 1024fs_read(22, 0x83009c08, 1024) = 1024
fs_lseek(22, 2499, 0) = 2499fs_close(22, 0, 0) = 0
fs_write(1, 0x83009c08, 8) = 8
fs_close(0, 0, 0) = 0fs_close(1, 0, 0) = 0fs_close(2, 0, 0) = 0
sys_exit(0, 0, 0)
观察到如下现象:
- 标准输入
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 信息:
char* get_file_name(int fd) { return file_table[fd].name;}
然后在 syscall.c
中调用 get_file_name
就可以啦。
一些用户程序库函数的调用轨迹:
fopen -> _fopen_r -> _open_r -> _open
fseek -> _fseeko_r -> _ftello_r -> ... -> _fflush_r -> ...
... -> __sseek -> _lseek_r -> _lseek
ftell -> _ftello_r -> ...
fscanf -> _vfscanf_r -> __svfscanf_r -> __srefill_r -> _fread_r -> memcpy -> ...
fprintf -> _vfprintf_r -> __sfputs_r -> _fputc_r -> _putc_r -> ...
fclose -> ...
很怪的调用轨迹,中间有些链条找不到……
一切皆文件
想法
在 Nanos-lite 上,如果用户程序想访问设备,要怎么办呢?
- 设备的类型五花八门
- 设备的功能差别较大
我们需要有一种方式对设备的功能进行抽象,向用户程序提供统一的接口。
注意到文件的本质就是字节序列,而计算机系统中到处都是字节序列:
- 内存是以字节编址的,天然就是一个字节序列
- 管道是一种先进先出的字节序列
- 磁盘也可以看成一个字节序列
- socket (网络套接字) 也是一种字节序列
- 操作系统的一些信息可以以字节序列的方式暴露给用户,例如 CPU 的配置信息
- 操作系统提供的一些特殊的功能,如随机数生成器,也可以看成一个无穷长的字节序列
- 我们在键盘上按顺序敲入按键的编码形成了一个字节序列
- 显示器上每一个像素的内容按照其顺序也可以看做是字节序列
- ……
自然地,我们有了 Everything is a file 的想法,我们可以使用文件的接口来操作计算机上的一切,而不必对它们进行详细的区分。
#include "/dev/urandom"会将 urandom 设备中的内容包含到源文件中:由于 urandom 设备是一个长度无穷的字节序列,提交一个包含上述内容的程序源文件将会令一些检测功能不强的 Online Judge 平台直接崩溃……
这其实体现了 Unix 哲学的部分内容:每个程序采用文本文件作为输入输出,这样可以使程序之间易于合作。
为了向用户程序提供统一的抽象,Nanos-lite 也尝试将 IOE 抽象成文件。
虚拟文件系统
我们对之前实现的文件操作 API 的语义进行扩展,让它们可以支持任意文件:
int fs_open(const char *pathname, int flags, int mode);size_t fs_read(int fd, void *buf, size_t len);size_t fs_write(int fd, const void *buf, size_t len);size_t fs_lseek(int fd, size_t offset, int whence);int fs_close(int fd);
这组扩展语义之后的 API 叫 VFS(虚拟文件系统)。
而真实文件系统是指具体如何操作某一类文件,比如在 Nanos-lite 上,普通文件通过 ramdisk 的 API 进行操作。
一些真实文件系统:
- Windows
- 普通文件 -> NTFS
- GNU/Linux
- 普通文件 -> EXT4
- 特殊文件 ->
procfs
,tmpfs
,devfs
,sysfs
,initramfs
…
所以,VFS 其实是对不同种类的真实文件系统的抽象,它用一组 API 来描述了这些真实文件系统的抽象行为,屏蔽了真实文件系统之间的差异。
在 Nanos-lite 中,实现 VFS 的关键就是 Finfo
结构体中的两个读写函数指针:
typedef struct { char *name; // 文件名 size_t size; // 文件大小 size_t disk_offset; // 文件在 ramdisk 中的偏移 ReadFn read; // 读函数指针 WriteFn write; // 写函数指针} 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()
:
size_t serial_write(const void *buf, size_t offset, size_t len) { for (size_t i = 0; i < len; ++i) putch(*((char *)buf + i)); return len;}
然后在文件记录表中设置相应的写函数即可:
static Finfo file_table[] __attribute__((used)) = { [FD_STDIN] = {"stdin", 0, 0, invalid_read, invalid_write}, [FD_STDOUT] = {"stdout", 0, 0, invalid_read, serial_write}, [FD_STDERR] = {"stderr", 0, 0, invalid_read, serial_write},#include "files.h"};
相应的修改 fs_read
和 fs_write
:
size_t fs_read(int fd, void *buf, size_t len) { ReadFn readFn = file_table[fd].read; if (readFn != NULL) { // TODO: prepare parameters return readFn(buf, 0, len); } ...}
size_t fs_write(int fd, const void *buf, size_t len) { WriteFn writeFn = file_table[fd].write; if (writeFn != NULL) { // TODO: prepare parameters return writeFn(buf, 0, len); } ...}
不需要对 fd
进行特判了。
由于串口是一个字符设备,offset
参数可以忽略。
时钟
时钟比较特殊,大部分操作系统并没有把它抽象成一个文件,而是直接提供一些和时钟相关的系统调用来给用户程序访问。在 Nanos-lite 中,我们也提供一个 SYS_gettimeofday
系统调用,用户程序可以通过它读出当前的系统时间。
实现如下。首先是软件层,参考 man 2 gettimeofday
先定义一些数据结构:
#define time_t uint64_t#define suseconds_t uint64_t
struct timeval { time_t tv_sec; /* seconds */ suseconds_t tv_usec; /* microseconds */};
struct timezone { int tz_minuteswest; /* minutes west of Greenwich */ int tz_dsttime; /* type of DST correction */};
int sys_gettimeofday(struct timeval *tv, struct timezone *tz) { uint64_t us = io_read(AM_TIMER_UPTIME).us; tv->tv_sec = us / 1000000; tv->tv_usec = us - us / 1000000 * 1000000; return 0;}
我们使用了 io_read
访问设备抽象寄存器。注意这里 sys_gettimeofday
并不处理参数 tz
:
The use of the timezone structure is obsolete; the tz argument should normally be specified as NULL.
obsolete -> 弃用
然后是事件分发:
case SYS_gettimeofday: ret = sys_gettimeofday((struct timeval *)c->GPR2, (struct timezone *)c->GPR3); Log("sys_gettimeofday(%p, %p, %d) = %d", c->GPR2, c->GPR3, c->GPR4, ret); break;
下面是用户层:
int _gettimeofday(struct timeval *tv, struct timezone *tz) { return _syscall_(SYS_gettimeofday, (intptr_t)tv, (intptr_t)tz, 0);}
我们写一个测试程序 timer-test
:
#include <stdio.h>#include <assert.h>#include <sys/time.h>
int main() { struct timeval init; struct timeval now;
assert(gettimeofday(&init, NULL) == 0); time_t init_sec = init.tv_sec; suseconds_t init_usec = init.tv_usec;
size_t times = 1;
while (1) { assert(gettimeofday(&now, NULL) == 0); time_t now_sec = now.tv_sec; suseconds_t now_usec = now.tv_usec; uint64_t time_gap = (now_sec - init_sec) * 1000000 + (now_usec - init_usec); // unit: us if (time_gap > 500000 * times) { printf("Half a second passed, %u time(s)\n", times); times++; } }}
每过 0.5 秒输出一句话。注意这里不要出现浮点操作,否则就需要让 NEMU 实现浮点指令了……
NDL
为了更好地封装 IOE 的功能,我们在 Navy 中提供了一个叫 NDL (NJU DirectMedia Layer) 的多媒体库。这个库的代码位于 navy-apps/libs/libndl/NDL.c
中。
NDL 向用户提供了一个和时钟相关的 API:
// 以毫秒为单位返回系统时间uint32_t NDL_GetTicks();
你需要用 gettimeofday()
实现 NDL_GetTicks()
,然后修改 timer-test
测试,让它通过调用 NDL_GetTicks()
来获取当前时间。
我们约定程序在使用 NDL 库的功能之前必须先调用 NDL_Init()
。
实现如下:
#include <sys/time.h>#include <assert.h>
uint32_t NDL_GetTicks() { struct timeval tv; assert(gettimeofday(&tv, NULL) == 0); return tv.tv_sec * 1000 + tv.tv_usec / 1000;}
修改 timer-test
测试,添加宏 HAS_NDL
进行测试控制:
#include <stdio.h>
#define HAS_NDL
#ifndef HAS_NDL#include <assert.h>#include <sys/time.h>
void gettimeofday_test() { printf("gettimeofday test start...\n");
struct timeval init; struct timeval now;
assert(gettimeofday(&init, NULL) == 0); time_t init_sec = init.tv_sec; suseconds_t init_usec = init.tv_usec;
size_t times = 1;
while (1) { assert(gettimeofday(&now, NULL) == 0); time_t now_sec = now.tv_sec; suseconds_t now_usec = now.tv_usec; uint64_t time_gap = (now_sec - init_sec) * 1000000 + (now_usec - init_usec); // unit: us if (time_gap > 500000 * times) { printf("Half a second passed, %u time(s)\n", times); times++; } }}#else#include <NDL.h>
void NDL_GetTicks_test() { NDL_Init(0); printf("NDL_GetTicks test start...\n");
uint32_t init = NDL_GetTicks(); size_t times = 1;
while (1) { uint32_t now = NDL_GetTicks(); uint32_t time_gap = now - init; if (time_gap > 500 * times) { printf("Half a second passed, %u time(s)\n", times); times++; } }}#endif
int main() {#ifndef HAS_NDL gettimeofday_test();#else NDL_GetTicks_test();#endif
注意需要修改对应的 Makefile
文件:
NAME = timer-testSRCS = main.c#ifdef HAS_NDLLIBS = libndl#endifinclude $(NAVY_HOME)/Makefile
键盘
按键信息对系统来说本质上就是到来了一个事件。一种简单的方式是把事件以文本的形式表现出来,我们定义以下两种事件:
- 按下按键事件,如
kd RETURN
表示按下回车键 - 松开按键事件,如
ku A
表示松开A
键
按键名称与 AM 中的定义的按键名相同,均为大写。此外,一个事件以换行符 \n
结束。
Nanos-lite 和 Navy 约定,上述事件抽象成一个特殊文件 /dev/events
,它需要支持读操作,用户程序可以从中读出按键事件,但它不必支持 lseek
,因为它是一个字符设备。
我们可以假设一次最多只会读出一个事件
软件层的实现如下,首先需要实现对特殊文件 /dev/events
的读操作,在 nanos-lite/src/device.c
中:
#define TEMP_BUF_SIZE 32static char temp_buf[TEMP_BUF_SIZE]; // for events_read
size_t events_read(void *buf, size_t offset, size_t len) { memset(temp_buf, 0, TEMP_BUF_SIZE); // reset
AM_INPUT_KEYBRD_T ev = io_read(AM_INPUT_KEYBRD); if (ev.keycode == AM_KEY_NONE) return 0; const char *name = keyname[ev.keycode]; int ret = ev.keydown ? sprintf(temp_buf, "kd %s\n", name) : sprintf(temp_buf, "ku %s\n", name); // ret -> exclude terminating null character if (ret >= len) { strncpy(buf, temp_buf, len - 1); ret = len; } else { strncpy(buf, temp_buf, ret); } return ret; // ret -> include terminating null character}
把事件写入到 buf
中,最长写入 len
字节,然后返回写入的实际长度。
这里本来应该使用 snprintf,但是 klib 中还没有实现……所以使用了
temp_buf
作为中转需要小心各种参数和返回值中是否包含
terminating null character
修改 file_table
:
enum {FD_STDIN, FD_STDOUT, FD_STDERR, DEV_EVENTS, FD_FB};
static Finfo file_table[] __attribute__((used)) = { [FD_STDIN] = {"stdin", 0, 0, invalid_read, invalid_write}, [FD_STDOUT] = {"stdout", 0, 0, invalid_read, serial_write}, [FD_STDERR] = {"stderr", 0, 0, invalid_read, serial_write}, [DEV_EVENTS] = {"/dev/events", 0, 0, events_read, invalid_write},#include "files.h"};
注意 FD_FB
可以用于对特殊文件的特判上,如:
int fs_open(const char *pathname, int flags, int mode) { for (int i = 0; i < LENGTH(file_table); ++i) { if (strcmp(file_table[i].name, pathname) == 0) { if (i < FD_FB) { Log("ignore open %s", pathname); return i; } open_file_table[open_file_table_index].fd = i; open_file_table[open_file_table_index].open_offset = 0; open_file_table_index++; return i; } } panic("file %s not found", pathname); while (1);}
然后是用户层,我们使用 NDL 封装 IOE 的功能:
#include <sys/types.h>#include <sys/stat.h>#include <fcntl.h>
int NDL_PollEvent(char *buf, int len) { int fd = open("/dev/events", 0, 0); int ret = read(fd, buf, len); assert(close(fd) == 0); return ret == 0 ? 0 : 1;}
注意区分
open
和fopen
,前者是低级 IO,后者是高级 IO
最后是测试程序:
#include <stdio.h>#include <NDL.h>
int main() { NDL_Init(0); while (1) { char buf[64]; if (NDL_PollEvent(buf, sizeof(buf))) { printf("receive event: %s\n", buf); } } return 0;}
对应的系统调用如下:
fs_open(/dev/events, 0, 0) = 3fs_read(/dev/events, 0x800a6f50, 64) = 0 -> missfs_close(/dev/events, 0, 0) = 0
fs_open(/dev/events, 0, 0) = 3fs_read(/dev/events, 0x800a6f50, 64) = 5 -> hitfs_close(/dev/events, 0, 0) = 0
sys_brk(0x83008cf0, 0, 0) = 0sys_brk(0x83009000, 0, 0) = 0
fs_write(stdout, 0x830088e0, 20) = 20 -> receive event: kd Zfs_write(stdout, 0x830088e0, 1) = 1 -> \n
fs_open(/dev/events, 0, 0) = 3fs_read(/dev/events, 0x800a6f50, 64) = 5 -> hitfs_close(/dev/events, 0, 0) = 0
fs_write(stdout, 0x830088e0, 20) = 20 -> receive event: ku Zfs_write(stdout, 0x830088e0, 1) = 1 -> \n
...
VGA
程序为了更新屏幕,只需要将像素信息写入 VGA 的显存即可。
Nanos-lite 和 Navy 约定,把显存抽象成文件 /dev/fb
,它需要支持写操作和 lseek
,以便于把像素更新到屏幕的指定位置上。
NDL 向用户提供了两个和绘制屏幕相关的 API:
// 打开一张 (*w) X (*h) 的画布// 如果 *w 和 *h 均为 0,则将系统全屏幕作为画布,并将 *w 和 *h 分别设为系统屏幕的大小void NDL_OpenCanvas(int *w, int *h);
// 向画布 `(x, y)` 坐标处绘制 `w*h` 的矩形图像,并将该绘制区域同步到屏幕上// 图像像素按行优先方式存储在 `pixels` 中,每个像素用 32 位整数以 `00RRGGBB` 的方式描述颜色void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h);
其中画布是一个面向程序的概念,程序绘图时的坐标都是针对画布来设定的,这样程序就无需关心系统屏幕的大小,以及需要将图像绘制到系统屏幕的哪一个位置。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
:
size_t dispinfo_read(void *buf, size_t offset, size_t len) { memset(temp_buf, 0, TEMP_BUF_SIZE); // reset
AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG); int width = ev.width; int height = ev.height;
int ret = sprintf(temp_buf, "WIDTH : %d\nHEIGHT : %d", width, height); // ret -> exclude terminating null character if (ret >= len) { strncpy(buf, temp_buf, len - 1); ret = len; } else { strncpy(buf, temp_buf, ret); } return ret; // ret -> include terminating null character}
这里 /proc/dispinfo
文件的格式只要满足上面的要求就行了,并不唯一。
在 file_table
中添加:
[PROC_DISPINFO] = {"/proc/dispinfo", 0, 0, dispinfo_read, invalid_write},
由于是字符设备,位于 FD_FB
之上。
下面是用户层,我们添加 init_dispinfo
函数用于解析 /proc/dispinfo
文件的内容,并写入 screen_w
和 screen_h
,作为屏幕大小:
static void init_dispinfo() { int buf_size = 1024; // TODO: may be insufficient char * buf = (char *)malloc(buf_size * sizeof(char)); int fd = open("/proc/dispinfo", 0, 0); int ret = read(fd, buf, buf_size); assert(ret < buf_size); // to be cautious... assert(close(fd) == 0);
int i = 0; int width = 0, height = 0;
assert(strncmp(buf + i, "WIDTH", 5) == 0); i += 5; for (; i < buf_size; ++i) { if (buf[i] == ':') { i++; break; } assert(buf[i] == ' '); } for (; i < buf_size; ++i) { if (buf[i] >= '0' && buf[i] <= '9') break; assert(buf[i] == ' '); } for (; i < buf_size; ++i) { if (buf[i] >= '0' && buf[i] <= '9') { width = width * 10 + buf[i] - '0'; } else { break; } } assert(buf[i++] == '\n');
assert(strncmp(buf + i, "HEIGHT", 6) == 0); i += 6; for (; i < buf_size; ++i) { if (buf[i] == ':') { i++; break; } assert(buf[i] == ' '); } for (; i < buf_size; ++i) { if (buf[i] >= '0' && buf[i] <= '9') break; assert(buf[i] == ' '); } for (; i < buf_size; ++i) { if (buf[i] >= '0' && buf[i] <= '9') { height = height * 10 + buf[i] - '0'; } else { break; } }
free(buf);
screen_w = width; screen_h = height;}
在 NDL_Init
中调用它即可。
我们还需要记录画布的大小,修改 NDL_OpenCanvas
:
if (*w == 0 && *h == 0) { *w = screen_w; *h = screen_h; }
canvas_w = *w; canvas_h = *h;
canvas_relative_screen_w = (screen_w - canvas_w) / 2; canvas_relative_screen_h = (screen_h - canvas_h) / 2;
assert(canvas_w + canvas_relative_screen_w <= screen_w && canvas_h + canvas_relative_screen_h <= screen_h);}
这里 canvas_relative_screen_w
和 canvas_relative_screen_h
是画布相对于屏幕左上角的坐标,我们将画布居中。
/dev/fb
首先是软件层,添加 fb_write
:
size_t fb_write(const void *buf, size_t offset, size_t len) { AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG); int width = ev.width;
int y = offset / width; int x = offset - y * width;
io_write(AM_GPU_FBDRAW, x, y, (void *)buf, len, 1, true);
return len;}
得到硬件层的屏幕大小后,对 offset
进行处理,得到正确的坐标。
这里假定 buf
中仅包含了一行的像素信息。
隔行似乎难以处理,对用户层而言……
在 file_table
中添加:
[FD_FB] = {"/dev/fb", 0, 0, invalid_read, fb_write},
我们还需要修改 fs_read
和 fs_write
:
size_t fs_write(int fd, const void *buf, size_t len) { WriteFn writeFn = file_table[fd].write; if (writeFn != NULL && fd < FD_FB) { // ignore offset return writeFn(buf, 0, len); }
...
writeFn ? writeFn (buf, disk_offset + open_offset, write_len): ramdisk_write(buf, disk_offset + open_offset, write_len); open_file_table[target_index].open_offset += write_len; return write_len;}
另外在 init_fs()
中对文件记录表中 /dev/fb
的大小进行初始化:
void init_fs() { // TODO: initialize the size of /dev/fb AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG); int width = ev.width; int height = ev.height; file_table[FD_FB].size = width * height * 4;}
这里会有一个问题,FD_FB
之后的文件在 ramdisk.img
中的偏移会出错。
若不修改,ramdisk.img
中前 /dev/fb
大小的字节会被覆盖。
可能的修复方案,修改 navy-apps/Makefile
的 ramdisk
规则,让每个文件的偏移显式加上 /dev/fb
的大小:
RAMDISK = build/ramdisk.imgRAMDISK_H = build/ramdisk.h$(RAMDISK): fsimg $(eval FSIMG_FILES := $(shell find -L ./fsimg -type f)) @mkdir -p $(@D) @cat $(FSIMG_FILES) > $@ @truncate -s \%512 $@ @echo "// file path, file size, offset in disk" > $(RAMDISK_H) @wc -c $(FSIMG_FILES) | grep -v 'total$$' | sed -e 's+ ./fsimg+ +' | awk -v sum=480000 '{print "\x7b\x22" $$2 "\x22\x2c " $$1 "\x2c " sum "\x7d\x2c";sum += $$1}' >> $(RAMDISK_H)
从 sum=0
到 sum=480000
。
同时修改 nanos-lite/src/ramdisk.c
:
#define RAMDISK_SIZE ((&ramdisk_end) - (&ramdisk_start) + 480000)
此时 ramdisk_end
所指示的位置失效……
还是不行……
注意到 ramdisk.img
前 RAMDISK_SIZE
个字节是固定的,所以我们需要将 FD_FB
放到最后……
然而似乎 resources.S
中已经固定了可以使用的空间为 ramdisk.img
,上面的方案屏幕无显示……
但是目前未观察到问题……
怪了……
下面是用户层,我们实现 NDL_DrawRect
:
void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h) { int fd = open("/dev/fb", 0, 0); for (int i = 0; i < h && y + i < canvas_h; ++i) { lseek(fd, (y + canvas_relative_screen_h + i) * screen_w + (x + canvas_relative_screen_w), SEEK_SET); write(fd, pixels + i * w, w < canvas_w - x ? w : canvas_w - x); } assert(close(fd) == 0);}
建议画图梳理清楚系统屏幕,即 frame buffer,NDL_OpenCanvas()
打开的画布,以及 NDL_DrawRect()
指示的绘制区域之间的位置关系。
这里也是逐行写入 /dev/fb
的,对应之前 fb_write
的实现。
测试程序为 navy-apps/tests/bmp-test
:
#include <stdio.h>#include <assert.h>#include <stdlib.h>#include <NDL.h>#include <BMP.h>
int main() { NDL_Init(0); int w, h; void *bmp = BMP_Load("/share/pictures/projectn.bmp", &w, &h); // printf("width: %d, height: %d\n", w, h); assert(bmp); NDL_OpenCanvas(&w, &h); // printf("width: %d, height: %d\n", w, h); NDL_DrawRect(bmp, 0, 0, w, h); free(bmp); NDL_Quit(); printf("Test ends! Spinning...\n"); while (1); return 0;}
可以得到图片的大小为 128×128
,屏幕的大小为 400×300
。
需要注意画布的大小不能超过图片的大小,否则图片会显示错误,原因详见 NDL_DrawRect
的实现。
下面是程序执行中的系统调用:
ramdisk info: start = 0x8000423d, end = 0x8009de3d, size = 629760 bytes
sys_brk(0x8300ad20, 0, 0) = 0sys_brk(0x8300b000, 0, 0) = 0
fs_open(/proc/dispinfo, 0, 0) = 4fs_read(/proc/dispinfo, 0x8300a910, 1024) = 24fs_close(/proc/dispinfo, 0, 0) = 0
fs_open(/share/pictures/projectn.bmp, 0, 438) = 29fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 1024sys_brk(0x8301c000, 0, 0) = 0fs_lseek(/share/pictures/projectn.bmp, 0, 1) = 1024fs_lseek(/share/pictures/projectn.bmp, 54, 0) = 54fs_lseek(/share/pictures/projectn.bmp, 48906, 0) = 48906fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 384fs_lseek(/share/pictures/projectn.bmp, 48522, 0) = 48522fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 768fs_lseek(/share/pictures/projectn.bmp, 48906, 0) = 48906fs_lseek(/share/pictures/projectn.bmp, 48138, 0) = 48138fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 1024fs_lseek(/share/pictures/projectn.bmp, 48522, 0) = 48522fs_lseek(/share/pictures/projectn.bmp, 47754, 0) = 47754fs_read(/share/pictures/projectn.bmp, 0x8300aac0, 1024) = 1024...fs_close(/share/pictures/projectn.bmp, 0, 0) = 0
fs_open(/dev/fb, 0, 0) = 5fs_lseek(/dev/fb, 34536, 0) = 34536fs_write(/dev/fb, 0x8300aec8, 128) = 128fs_lseek(/dev/fb, 34936, 0) = 34936fs_write(/dev/fb, 0x8300b0c8, 128) = 128fs_lseek(/dev/fb, 35336, 0) = 35336fs_write(/dev/fb, 0x8300b2c8, 128) = 128fs_lseek(/dev/fb, 35736, 0) = 35736fs_write(/dev/fb, 0x8300b4c8, 128) = 128...fs_lseek(/dev/fb, 85336, 0) = 85336fs_write(/dev/fb, 0x8301acc8, 128) = 128fs_close(/dev/fb, 0, 0) = 0
fs_write(stdout, 0x8300aac0, 23) = 23^C
更丰富的运行时环境
多媒体库
在 Linux 中,有一批 GUI 程序是使用 SDL 库来开发的。在 Navy 中有一个 miniSDL 库,它可以提供一些兼容 SDL 的 API,这样这批 GUI 程序就可以很容易地移植到 Navy 中了:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/libs/libminiSDL$ tree.├── include│ ├── sdl-audio.h│ ├── sdl-event.h│ ├── sdl-file.h│ ├── sdl-general.h│ ├── SDL.h│ ├── sdl-timer.h│ └── sdl-video.h├── Makefile└── src ├── audio.c ├── event.c ├── file.c ├── general.c ├── timer.c └── video.c
2 directories, 14 files
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
类型:
31 30 8 0+----+---------------------------+----------+|sign| integer | fraction |+----+---------------------------+----------+
这样,对于一个实数 a
,它的 fixedpt
类型表示 A = a * 2 ^ 8
。
RTFSC 可知转换的过程有细微的处理:
#define fixedpt_rconst(R) ((fixedpt)((R) * FIXEDPT_ONE + ((R) >= 0 ? 0.5 : -0.5)))#define FIXEDPT_ONE ((fixedpt)((fixedpt)1 << FIXEDPT_FBITS))另外
fixedpt_rconst
从表面上看带有非常明显的浮点操作,但从编译结果来看却没有任何浮点指令,可以使用 https://godbolt.org/ 观察。这是因为
fixedpt
让编译器来负责了大部分的浮点处理。参考了这篇博客……
对于负实数,我们用相应正数的相反数来表示。
我们需要在 fixedptc.h
中实现一些运算,较为简单:
/* Multiplies a fixedpt number with an integer, returns the result. */static inline fixedpt fixedpt_muli(fixedpt A, int B) { return A * B;}
/* Divides a fixedpt number with an integer, returns the result. */static inline fixedpt fixedpt_divi(fixedpt A, int B) { return A / B;}
/* Multiplies two fixedpt numbers, returns the result. */static inline fixedpt fixedpt_mul(fixedpt A, fixedpt B) { return A * B / FIXEDPT_ONE;}
/* Divides two fixedpt numbers, returns the result. */static inline fixedpt fixedpt_div(fixedpt A, fixedpt B) { return (fixedpt)(((fixedptd) A * (fixedptd) FIXEDPT_ONE) / (fixedptd) B);}
static inline fixedpt fixedpt_abs(fixedpt A) { return A >= 0 ? A : -A;}
static inline fixedpt fixedpt_floor(fixedpt A) { if (fixedpt_fracpart(A) == 0) return A; if (A > 0) return A & ~FIXEDPT_FMASK; else return -((-A & ~FIXEDPT_FMASK) + FIXEDPT_ONE);}
static inline fixedpt fixedpt_ceil(fixedpt A) { if (fixedpt_fracpart(A) == 0) return A; if (A > 0) return (A & ~FIXEDPT_FMASK) + FIXEDPT_ONE; else return -(-A & ~FIXEDPT_FMASK);}
写了一个测试程序,位于:
vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/libs/libfixedptc$ tree.├── fixedptc.c├── include│ └── fixedptc.h├── Makefile└── test ├── Makefile └── test.c
2 directories, 5 files
下面是 test.c
的内容:
#include "../include/fixedptc.h"#include <stdio.h>#include <math.h>
void fixedpt_DUT() { printf("*** demo:\n"); fixedpt a = fixedpt_rconst(1.2); fixedpt b = fixedpt_fromint(10); int c = 0; if (b > fixedpt_rconst(7.9)) { c = fixedpt_toint(fixedpt_div(fixedpt_mul(a + FIXEDPT_ONE, b), fixedpt_rconst(2.3))); } printf("%d\n", c); ...}
void float_REF() { printf("*** demo:\n"); float a = 1.2; float b = 10; int c = 0; if (b > 7.9) { c = (a + 1) * b / 2.3; } printf("%d\n", c); ...}
int main() { printf("--------- fixedpt_DUT ---------\n"); fixedpt_DUT(); printf("---------- float_REF ----------\n"); float_REF();}
误差确实不小……
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 应用程序的视角,抽象层大概如下:
|------|| NEMU ||------| <--- am_native for Navy, native for AM| AM || OS ||------| <--- native for Navy| Navy ||------|
也许……
主要目的是为了在 Linux native 的环境下单独测试 NDL 和 miniSDL 的代码……
为了 event-test 在 native 上愉快的运行,删了一个 close 的 assertion
bmp-test 反映出一些 VGA 的一些问题。之前 NDL_DrawRect
的实现中没有考虑到 lseek
和 write
是以字节寻址的,而 pixels
的单位为 4
字节,所以正确的实现如下:
void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h) { int fd = open("/dev/fb", 0, 0); for (int i = 0; i < h && y + i < canvas_h; ++i) { lseek(fd, ((y + canvas_relative_screen_h + i) * screen_w + (x + canvas_relative_screen_w)) * 4, SEEK_SET); write(fd, pixels + i * w, 4 * (w < canvas_w - x ? w : canvas_w - x)); } assert(close(fd) == 0);}
另外还需要修改 fb_write
,将 offset
和 len
变为原来的 ¼:
size_t fb_write(const void *buf, size_t offset, size_t len) { AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG); int width = ev.width;
offset /= 4; len /= 4;
int y = offset / width; int x = offset - y * width;
io_write(AM_GPU_FBDRAW, x, y, (void *)buf, len, 1, true);
return len;}
软件层和应用层都实现错了,负负得正……
有了 Navy native,就可以保证软件层和硬件层实现的正确性,控制变量……
另外,之前提到过 Nanos-lite 的 native
,注意到 Nanos-lite 实际上是一个 AM 程序,也就是 AM native,对应 Navy 中的 ISA=am_native
。
我们可以在测试程序目录下键入 make ISA=am_native
,得到 XXX-am_native
镜像,我们对比一下不同镜像的文件类型:
bmp-test-native: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=73fc7f8d1c78bc3994477bf41e3140fe225b0d40, for GNU/Linux 3.2.0, not stripped
bmp-test-am_native: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
bmp-test-riscv32: ELF 32-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, not stripped
同样可以在 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
中的:
int program_break = (int)(&_end);
在编译期是无法得到 _end
的值的,所以应该改成:
int program_break;int is_program_break_init = 0;
void *_sbrk(intptr_t increment) { if (!is_program_break_init) { program_break = (int)(&_end); is_program_break_init = 1; } ...}
- cast to pointer from integer of different size
nanos-lite/src/loader.c
中的 loader
函数:
memcpy((void *)(uintptr_t)virtAddr, buf_malloc, fileSiz);memset((void *)(uintptr_t)(virtAddr + fileSiz), 0, memSiz - fileSiz);
在将 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
:
LD = $(CXX)
### Run an application with $(ISA)=native
env: $(MAKE) -C $(NAVY_HOME)/libs/libos ISA=native
run: app env @LD_PRELOAD=$(NAVY_HOME)/libs/libos/build/native.so $(APP) $(mainargs)
gdb: app env @LD_PRELOAD=$(NAVY_HOME)/libs/libos/build/native.so gdb --args $(APP) $(mainargs)
.PHONY: env run gdb
可以看到一个名为 native.so
的动态链接库会被优先链接。
我们可以观察程序的调试信息:
Redirecting file open: /share/pictures/projectn.bmp -> /home/vgalaxy/ics2021/navy-apps/fsimg/share/pictures/projectn.bmp
定位 navy-apps/libs/libos/src/native.cpp
可知:
static inline void get_fsimg_path(char *newpath, const char *path) { sprintf(newpath, "%s%s", fsimg_path, path);}
static const char* redirect_path(char *newpath, const char *path) { get_fsimg_path(newpath, path); if (0 == access(newpath, 0)) { fprintf(stderr, "Redirecting file open: %s -> %s\n", path, newpath); return newpath; } return path;}
我们发现程序会执行 Init
类的构造函数:
struct Init { Init() { glibc_fopen = (FILE*(*)(const char*, const char*))dlsym(RTLD_NEXT, "fopen"); assert(glibc_fopen != NULL); glibc_open = (int(*)(const char*, int, ...))dlsym(RTLD_NEXT, "open"); assert(glibc_open != NULL); glibc_read = (ssize_t (*)(int fd, void *buf, size_t count))dlsym(RTLD_NEXT, "read"); assert(glibc_read != NULL); glibc_write = (ssize_t (*)(int fd, const void *buf, size_t count))dlsym(RTLD_NEXT, "write"); assert(glibc_write != NULL); glibc_execve = (int(*)(const char*, char *const [], char *const []))dlsym(RTLD_NEXT, "execve"); assert(glibc_execve != NULL);
dummy_fd = memfd_create("dummy", 0); assert(dummy_fd != -1); dispinfo_fd = dummy_fd;
char *navyhome = getenv("NAVY_HOME"); assert(navyhome); sprintf(fsimg_path, "%s/fsimg", navyhome);
char newpath[512]; get_fsimg_path(newpath, "/bin"); setenv("PATH", newpath, 1); // overwrite the current PATH
SDL_Init(0); if (!getenv("NWM_APP")) { open_display(); open_event(); } open_audio(); } ~Init() { }};
Init init;
dlsym
函数从一个动态链接库或者可执行文件中获取到符号地址……
fsimg_path
即为 navy-apps/fsimg
。
我们继续 RTFSC:
FILE *fopen(const char *path, const char *mode) { char newpath[512]; return glibc_fopen(redirect_path(newpath, path), mode);}
int open(const char *path, int flags, ...) { if (strcmp(path, "/proc/dispinfo") == 0) { return dispinfo_fd; } else if (strcmp(path, "/dev/events") == 0) { return evt_fd; } else if (strcmp(path, "/dev/fb") == 0) { return fb_memfd; } else if (strcmp(path, "/dev/sb") == 0) { return sb_fifo[1]; } else if (strcmp(path, "/dev/sbctl") == 0) { return sbctl_fd; } else { char newpath[512]; return glibc_open(redirect_path(newpath, path), flags); }}
结合上面的 LD_PRELOAD
,推测这里的 fopen
成功 override 掉了库函数,而原来的库函数通过重命名的方式变成了 glibc_fopen
。这里的 fopen
将原来的文件路径如 /share/pictures/projectn.bmp
替换成了 /home/vgalaxy/ics2021/navy-apps/fsimg/share/pictures/projectn.bmp
!
调用轨迹参考:
fopen -> redirect_path -> get_fsimg_path -> glibc_fopen
fopen
在底层会调用 open
,而 open
也以同样的方式被 override 了。
另外一个有趣的地方在对特殊文件的模拟,以 /dev/fb
为例:
open
函数(override 掉了系统调用)匹配到/dev/fb
时会返回fb_memfd
- 而
fb_memfd
在open_display
中被创建:
fb_memfd = memfd_create("fb", 0); assert(fb_memfd != -1); int ret = ftruncate(fb_memfd, FB_SIZE); assert(ret == 0); fb = (uint32_t *)mmap(NULL, FB_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fb_memfd, 0); assert(fb != (void *)-1); memset(fb, 0, FB_SIZE); lseek(fb_memfd, 0, SEEK_SET);
memfd_create
是一个系统函数,我们 RTFM:
memfd_create() creates an anonymous file and returns a file descriptor that refers to it. The file behaves like a regular file, and so can be modified, truncated, memory-mapped, and so on. However, unlike a regular file, it lives in RAM and has a volatile backing storage.
- ……
运行时环境兼容
实际上,navy-apps/libs/libos/src/native.cpp
使用了运行时环境兼容的技术。
即通过 Linux 的运行时环境实现 Navy 的运行时环境(API)
下面还有一些实际的例子:
但完整的 Linux 和 Windows 运行时环境太复杂了,因此一些对运行时环境依赖程度比较复杂的程序至今也很难在 Wine 或 WSL 上完美运行,以至于 WSL2 抛弃了”运行时环境兼容”的技术路线,转而采用虚拟机的方式来完美运行 Linux 系统。
Navy 中的应用程序
NSlider (NJU Slider)
PDF to BMP
不要 Install from Source
,会变得不幸……
直接 sudo apt-get install imagemagick
,然后 sudo vi /etc/ImageMagick-6/policy.xml
,修改:
<policy domain="coder" rights="none" pattern="PDF" />
为
<policy domain="coder" rights="read|write" pattern="PDF" />
即可……
event
调用轨迹参考:
SDL_WaitEvent -> SDL_PollEvent -> NDL_PollEvent -> open / read / close `/dev/events`
- 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
的相关实现如下:
int SDL_WaitEvent(SDL_Event *event) { SDL_PollEvent(event); return 1;}
int SDL_PollEvent(SDL_Event *ev) { unsigned buf_size = 32; char *buf = (char *)malloc(buf_size * sizeof(char)); if (NDL_PollEvent(buf, buf_size) == 1) { if (strncmp(buf, "kd", 2) == 0) { ev->key.type = SDL_KEYDOWN; } else { ev->key.type = SDL_KEYUP; }
for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) { if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0) { ev->key.keysym.sym = i; break; } }
free(buf); return 1; } else { ev->key.type = SDL_USEREVENT; // avoid too many `Redirecting file open ...` ev->key.keysym.sym = 0; }
free(buf); return 0;}
https://wiki.libsdl.org/SDL_KeyboardEvent
另外,需要在 NDL_PollEvent
的最前面对 buf
清空,这是因为 malloc
出的 buf
可能不是全空……
int NDL_PollEvent(char *buf, int len) { memset(buf, 0, len); int fd = open("/dev/events", 0, 0); int ret = read(fd, buf, len); close(fd); // for event-test on native, no assertion here return ret == 0 ? 0 : 1;}
调试了半天……
native
中没有这个问题,只有在nanos-lite
中才有这个问题……这里让
NDL_PollEvent
处理一劳永逸,也可以让调用者SDL_PollEvent
处理……
render
调用轨迹参考:
render -> SDL_LoadBMP SDL_UpdateRect -> NDL_DrawRect -> open / read / close `/dev/fb`
SDL_UpdateRect
的实现如下:
void SDL_UpdateRect(SDL_Surface *s, int x, int y, int w, int h) { if (x == 0 && y == 0 && w == 0 && h == 0) { NDL_DrawRect((uint32_t *)s->pixels, x, y, 400, 300); // assume the size of screen return; } NDL_DrawRect((uint32_t *)s->pixels, x, y, w, h);}
https://wiki.libsdl.org/SDL_Surface
https://www.libsdl.org/release/SDL-1.2.15/docs/html/sdlupdaterect.html
一个很怪的地方,若在 NDL_DrawRect
中 close
了,native
中无法翻页:
void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h) { int fd = open("/dev/fb", 0, 0); for (int i = 0; i < h && y + i < canvas_h; ++i) { lseek(fd, ((y + canvas_relative_screen_h + i) * screen_w + (x + canvas_relative_screen_w)) * 4, SEEK_SET); write(fd, pixels + i * w, 4 * (w < canvas_w - x ? w : canvas_w - x)); } // close(fd); // 1. for NSlider on native, no assertion here // 2. if close, the slide cannot turn pages on native}
MENU (开机菜单)
在 miniSDL 中实现两个绘图相关的 API:
SDL_FillRect()
: 往画布的指定矩形区域中填充指定的颜色SDL_BlitSurface()
: 将一张画布中的指定矩形区域复制到另一张画布的指定位置
需要注意的是,这两个 API 不需要在内部调用 NDL_DrawRect
,只需要修改 dst->pixels
即可,初步实现如下:
void SDL_FillRect(SDL_Surface *dst, SDL_Rect *dstrect, uint32_t color) { uint32_t * base = (uint32_t *)dst->pixels; if (dstrect == NULL) { for (int i = 0; i < dst->w * dst->h; ++i) base[i] = color; return; }
int rect_x = dstrect->x; int rect_y = dstrect->y; int rect_w = dstrect->w < (dst->w - dstrect->x) ? dstrect->w : (dst->w - dstrect->x); int rect_h = dstrect->h < (dst->h - dstrect->y) ? dstrect->h : (dst->h - dstrect->y);
for (int i = 0; i < rect_h; ++i) { for (int j = 0; j < rect_w; ++j) { base[(rect_y + i) * dst->w + rect_x + j] = color; } } return;}
void SDL_BlitSurface(SDL_Surface *src, SDL_Rect *srcrect, SDL_Surface *dst, SDL_Rect *dstrect) { assert(dst && src); assert(dst->format->BitsPerPixel == src->format->BitsPerPixel);
uint32_t * data = (uint32_t *)src->pixels; uint32_t * base = (uint32_t *)dst->pixels;
int src_w = src->w; int src_h = src->h; int dst_w = dst->w; int dst_h = dst->h; int dstrect_x = dstrect->x; int dstrect_y = dstrect->y;
if (srcrect == NULL) { assert(src_w <= (dst_w - dstrect_x)); assert(src_h <= (dst_h - dstrect_y));
// int width = src_w < (dst_w - dstrect_x) ? src_w : (dst_w - dstrect_x); // int height = src_h < (dst_h - dstrect_y) ? src_h : (dst_h - dstrect_y);
for (int i = 0; i < src_h; ++i) { for (int j = 0; j < src_w; ++j) { base[(dstrect_y + i) * dst_w + dstrect_x + j] = data[i * src_w + j]; } }
return; } else { assert(0); }}
https://wiki.libsdl.org/SDL_BlitSurface
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
:
int SDL_PollEvent(SDL_Event *ev) { unsigned buf_size = 32; char *buf = (char *)malloc(buf_size * sizeof(char)); if (NDL_PollEvent(buf, buf_size) == 1) { if (strncmp(buf, "kd", 2) == 0) { ev->key.type = SDL_KEYDOWN; } else { ev->key.type = SDL_KEYUP; }
int flag = 0; for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) { if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0 && strlen(keyname[i]) == strlen(buf) - 4) { flag = 1; ev->key.keysym.sym = i; break; } }
assert(flag == 1);
free(buf); return 1; } else { return 0; }}
int SDL_WaitEvent(SDL_Event *ev) { unsigned buf_size = 32; char *buf = (char *)malloc(buf_size * sizeof(char));
while (NDL_PollEvent(buf, buf_size) == 0); // wait ...
if (strncmp(buf, "kd", 2) == 0) ev->key.type = SDL_KEYDOWN; else ev->key.type = SDL_KEYUP;
int flag = 0; for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) { if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0 && strlen(keyname[i]) == strlen(buf) - 4) { flag = 1; ev->key.keysym.sym = i; break; } }
assert(flag == 1);
free(buf); return 1;}
另外需要修改对键盘按键名的识别,原来的实现中前缀也会被错误的识别,举个栗子,由于 TAB
在 T
之前,T
会被识别成 TAB
……
SDL_UpdateRect
函数也需要修改一下:
void SDL_UpdateRect(SDL_Surface *s, int x, int y, int w, int h) { if (x == 0 && y == 0 && w == 0 && h == 0) { NDL_DrawRect((uint32_t *)s->pixels, x, y, s->w, s->h); return; } NDL_DrawRect((uint32_t *)s->pixels, x, y, w, h);}
analysis
下面我们来分析一下这个终端的实现。首先是项目结构:
├── include│ └── nterm.h├── Makefile└── src ├── builtin-sh.cpp ├── extern-sh.cpp ├── main.cpp └── term.cpp
nterm.h
定义了 Terminal
类,term.cpp
给出了其实现:
目前需要关注的有如下几点:
- 终端的大小
w
和 h
定义了终端的大小注意这里的单位是一个字符在屏幕上的大小,目前为 7×13
:
font -> w: 7; h: 13win -> w: 336; h: 208
做除法,可得终端的大小为 48×16
。
- 三个
48×16
大小的数组:
buf -> 字符color -> 颜色dirty -> 是否被修改
- 光标
struct Cursor { int x, y; } cursor, saved;
指示当前输入的地方。
main.cpp
,主要看一下 main
函数:
int main(int argc, char *argv[]) { SDL_Init(0); font = new BDF_Font(font_fname);
// setup display int win_w = font->w * W; int win_h = font->h * H;
screen = SDL_SetVideoMode(win_w, win_h, 32, SDL_HWSURFACE);
term = new Terminal(W, H);
if (argc < 2) { builtin_sh_run(); } else { extern_app_run(argv[1]); }
// should not reach here assert(0);}
这里的 SDL_SetVideoMode
打开了一个画布(并非全屏):
SDL_Surface* SDL_SetVideoMode(int width, int height, int bpp, uint32_t flags) { if (flags & SDL_HWSURFACE) NDL_OpenCanvas(&width, &height); return SDL_CreateRGBSurface(flags, width, height, bpp, DEFAULT_RMASK, DEFAULT_GMASK, DEFAULT_BMASK, DEFAULT_AMASK);}
构造出 Terminal
类的一个实例后调用 builtin_sh_run
。
builtin_sh_run
定义在 builtin-sh.cpp
中:
static void sh_printf(const char *format, ...) { static char buf[256] = {}; va_list ap; va_start(ap, format); int len = vsnprintf(buf, 256, format, ap); va_end(ap); term->write(buf, len);}
static void sh_banner() { sh_printf("Built-in Shell in NTerm (NJU Terminal)\n\n");}
static void sh_prompt() { sh_printf("sh> ");}
void builtin_sh_run() { sh_banner(); sh_prompt();
while (1) { SDL_Event ev; if (SDL_PollEvent(&ev)) { if (ev.type == SDL_KEYUP || ev.type == SDL_KEYDOWN) { const char *res = term->keypress(handle_key(&ev)); if (res) { sh_handle_cmd(res); sh_prompt(); } } } refresh_terminal(); }}
我们需要理清两处地方:
- 在终端显示信息
考虑如下的调用轨迹:
sh_banner -> sh_printf -> write
write
成员函数以字符串和写入长度为参数:
void Terminal::write(const char *str, size_t count) { for (size_t i = 0; i != count && str[i]; ) { char ch = str[i]; if (ch == '\033') { i += write_escape(&str[i], count - i); } else { switch (ch) { case '\x07': break; case '\n': cursor.x = 0; cursor.y ++; if (cursor.y >= h) { scroll_up(); cursor.y --; } break; case '\t': // TODO: implement it. break; case '\r': cursor.x = 0; break; default: putch(cursor.x, cursor.y, ch); move_one(); } i ++; } }}
遍历该字符串,对其中每个字符进行处理。先考虑一些特殊字符:
\033 -> ???\t -> TAB?\n -> 换行\r -> 回车,即光标回到本行首位置
然后是普通字符,调用 putch
,并让光标相应移动:
void Terminal::putch(int x, int y, char ch) { buf[x + y * w] = ch; color[x + y * w] = (col_f << 4) | col_b; dirty[x + y * w] = true;}
在 refresh_terminal
方法中,会对 putch
过的地方进行屏幕的同步,大概思路是:
void refresh_terminal() { int needsync = 0; for (int i = 0; i < W; i ++) for (int j = 0; j < H; j ++) if (term->is_dirty(i, j)) { draw_ch(i * font->w, j * font->h, term->getch(i, j), term->foreground(i, j), term->background(i, j)); needsync = 1; } term->clear();
static uint32_t last = 0; static int flip = 0; uint32_t now = SDL_GetTicks(); if (now - last > 500 || needsync) { int x = term->cursor.x, y = term->cursor.y; uint32_t color = (flip ? term->foreground(x, y) : term->background(x, y)); draw_ch(x * font->w, y * font->h, ' ', 0, color); SDL_UpdateRect(screen, 0, 0, 0, 0); if (now - last > 500) { flip = !flip; last = now; } }}
只要有一个 dirty,或者时间间隔超过了 0.5s,就会调用 SDL_UpdateRect
刷新屏幕。
而 draw_ch
调用了 SDL_BlitSurface
:
static void draw_ch(int x, int y, char ch, uint32_t fg, uint32_t bg) { SDL_Surface *s = BDF_CreateSurface(font, ch, fg, bg); SDL_Rect dstrect = { .x = x, .y = y }; SDL_BlitSurface(s, NULL, screen, &dstrect); SDL_FreeSurface(s);}
- 向终端输入信息
SDL_PollEvent
获取到键盘时间后,通过 term->keypress(handle_key(&ev))
得到键盘输入并显示在终端上。
handle_key
返回对应的字符,这里还考虑了大小写的问题:
char handle_key(SDL_Event *ev) { static int shift = 0; int key = ev->key.keysym.sym; if (key == SDLK_LSHIFT || key == SDLK_RSHIFT) { shift ^= 1; return '\0'; }
if (ev->type == SDL_KEYDOWN) { for (auto item: SHIFT) { if (item.keycode == key) { if (shift) return item.shift; else return item.noshift; } } } return '\0';}
SHIFT
结构数组自行 RTFSC
在 cook
模式下,keypress
会调用 write
方法在终端显示输入,并在键入换行符时将输入返回,交由 builtin_sh_run
继续处理:
const char *Terminal::keypress(char ch) { if (ch == '\0') return nullptr; if (mode == Mode::raw) { input[0] = ch; input[1] = '\0'; return input; } else if (mode == Mode::cook) { const char *ret = nullptr; switch (ch) { case '\033': break; case '\n': strcpy(cooked, input); strcat(cooked, "\n"); ret = cooked; write("\n", 1); inp_len = 0; break; case '\b': if (inp_len > 0) { inp_len --; backspace(); } break; default: if (inp_len + 1 < sizeof(input)) { input[inp_len ++] = ch; write(&ch, 1); } } input[inp_len] = '\0'; return ret; } return nullptr;}
返回的输入最后会有一个换行符,我们可以在 sh_handle_cmd
中处理最简单的 echo
命令:
static void sh_handle_cmd(const char *cmd) { if (cmd == NULL) return; if (strncmp(cmd, "echo", 4) == 0) { if (strlen(cmd) == 5) sh_printf("\n"); else sh_printf("%s", cmd + 5); } else { sh_printf("command not found\n"); }}
Flappy Bird
基于 SDL1.2
,对于 Linux native
而言(不是 Navy native
),需要安装如下应用:
sudo apt-get install libsdl1.2-devsudo apt-get install libsdl-image1.2-dev
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_Surface* IMG_Load(const char *filename) { FILE * fp = fopen(filename, "r"); if (!fp) return NULL;
fseek(fp, 0L, SEEK_END); long size = ftell(fp);
rewind(fp); unsigned char * buf = (unsigned char *)malloc(size * sizeof(unsigned char)); assert(fread(buf, 1, size, fp) == size);
SDL_Surface * surface = STBIMG_LoadFromMemory(buf, size); assert(surface != NULL);
fclose(fp); free(buf);
return surface;}
然后去掉 SDL 库中的一些 assert,并修改 SDL_BlitSurface
如下:
void SDL_BlitSurface(SDL_Surface *src, SDL_Rect *srcrect, SDL_Surface *dst, SDL_Rect *dstrect) { assert(dst && src); assert(dst->format->BitsPerPixel == src->format->BitsPerPixel);
uint32_t * data = (uint32_t *)src->pixels; uint32_t * base = (uint32_t *)dst->pixels;
int src_w = src->w; int src_h = src->h; int dst_w = dst->w; int dst_h = dst->h; int dstrect_x = !dstrect ? 0 : dstrect->x; int dstrect_y = !dstrect ? 0 : dstrect->y;
if (srcrect == NULL) { int width = src_w < (dst_w - dstrect_x) ? src_w : (dst_w - dstrect_x); int height = src_h < (dst_h - dstrect_y) ? src_h : (dst_h - dstrect_y);
for (int i = 0; i < height; ++i) { for (int j = 0; j < width; ++j) { base[(dstrect_y + i) * dst_w + dstrect_x + j] = data[i * src_w + j]; } }
return; } else { int srcrect_x = srcrect->x; int srcrect_y = srcrect->y; int width = srcrect->w < (dst_w - dstrect_x) ? srcrect->w : (dst_w - dstrect_x); int height = srcrect->h < (dst_h - dstrect_y) ? srcrect->h : (dst_h - dstrect_y);
for (int i = 0; i < height; ++i) { for (int j = 0; j < width; ++j) { base[(dstrect_y + i) * dst_w + dstrect_x + j] = data[(srcrect_y + i) * src_w + srcrect_x + j]; } }
return; }}
再次阅读 API 手册:
The width and height in srcrect determine the size of the copied rectangle. Only the position is used in the dstrect (the width and height are ignored).If srcrect is NULL, the entire surface is copied. If dstrect is NULL, then the destination position (upper left corner) is (0, 0).
为了在 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
下面我们来分析一下这个游戏的实现。
首先是项目结构:
├── include│ ├── Audio.h│ ├── BirdGame.h│ ├── BirdMain.h│ ├── Sprite.h│ └── Video.h├── Makefile├── README.txt├── res│ ├── atlas.png│ ├── atlas.txt│ ├── sfx_die.wav│ ├── sfx_hit.wav│ ├── sfx_point.wav│ ├── sfx_swooshing.wav│ ├── sfx_wing.wav│ └── splash.png└── src ├── Audio.cpp ├── BirdGame.cpp ├── BirdMain.cpp ├── Sprite.cpp └── Video.cpp
先看 BirdGame.cpp
里的 main
函数:
int main(int argc, char *argv[]){ // initialize SDL if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_VIDEO) < 0) { fprintf(stderr, "SDL_Init() failed: %s\n", SDL_GetError()); return 255; } atexit(SDL_Quit);
// initialize video if (!VideoInit()) { fprintf(stderr, "VideoInit() failed: %s\n", SDL_GetError()); return 255; }
atexit(VideoDestroy);
// initialize audio if (SOUND_OpenAudio(44100, 2, 1024) < 0) { fprintf(stderr, "InitSound() failed: %s\n", SDL_GetError()); return 255; }
return GameMain();}
进行一些初始化工作,注意这里 atexit
的使用,注册一些析构函数,在程序终止时执行,用于释放资源。
然后进入 GameMain
,位于 BirdGame.cpp
:
int GameMain(){ srand((unsigned int)time(NULL));
gpSprite = new CSprite(gpRenderer, FILE_PATH("atlas.png"), FILE_PATH("atlas.txt"));
atexit([](void) { delete gpSprite; });
LoadWav();
atexit(FreeWav); atexit(SOUND_CloseAudio);
ShowTitle();
...
1、构造函数
首先构造一个 CSprite
类的对象,三个参数依次为:
- SDL_Surface 类的指针
- 素材文件
- 素材文件的解释文件,包含了名称、大小和在素材文件中的坐标,如:
bg_day 288 512 0 0bg_night 288 512 292 0...
对应了:
typedef struct tagSpritePart{ unsigned short usWidth; unsigned short usHeight; unsigned short X, Y; int hasAlpha;} SpritePart_t;
构造函数调用轨迹:
CSprite::CSprite(SDL_Surface *pRenderer, const char *szImageFileName, const char *szTxtFileName){ int ret = hcreate(512); assert(ret); Load(pRenderer, szImageFileName, szTxtFileName);}
hcreate
构造了一个哈希表。
Load
中调用了 IMG_Load
读取素材文件,并调用 LoadTxt
读取素材文件的解释文件,置入哈希表中。
2、ShowTitle
显示欢迎界面,等待一秒
GameMain
第二部分:
g_GameState = GAMESTATE_INITIAL;
unsigned int uiNextFrameTime = SDL_GetTicks(); unsigned int uiCurrentTime = SDL_GetTicks();
while (1) { // 60fps do { uiCurrentTime = SDL_GetTicks(); UpdateEvents(); SDL_Delay(1); } while (uiCurrentTime < uiNextFrameTime);
if ((int)(uiCurrentTime - uiNextFrameTime) > 1000) { uiNextFrameTime = uiCurrentTime + 1000 / 60; } else { uiNextFrameTime += 1000 / 60; }
3、设置并更新时间
uiNextFrameTime -> prev
uiCurrentTime -> now
4、调用 UpdateEvents
在 Linux native
下可以使用鼠标……
static void UpdateEvents(){ SDL_Event evt;
while (SDL_PollEvent(&evt)) { switch (evt.type) {#ifndef __NAVY__ case SDL_QUIT: exit(0); break;
case SDL_MOUSEBUTTONDOWN: g_bMouseDown = true; g_iMouseX = evt.button.x; g_iMouseY = evt.button.y; break;
case SDL_MOUSEBUTTONUP: g_bMouseDown = false; break;#endif
case SDL_KEYDOWN: g_bMouseDown = true; break;
case SDL_KEYUP: g_bMouseDown = false; break; } }}
只记录是按下按键还是释放按键。
5、更新时间
if now - prev > 1000: prev = now + 1000 / 60else: prev += 1000 / 60
最高为 60FPS
GameMain
第三部分:
switch (g_GameState) { case GAMESTATE_INITIAL: GameThink_Initial(); break;
case GAMESTATE_GAMESTART: GameThink_GameStart(); break;
case GAMESTATE_GAME: GameThink_Game(); break;
case GAMESTATE_GAMEOVER: GameThink_GameOver(); break;
default: fprintf(stderr, "invalid game state: %d\n", (int)g_GameState); exit(255); }
SDL_UpdateRect(gpRenderer, 0, 0, 0, 0); }
return 255; // shouldn't really reach here}
6、根据 g_GameState
进行逻辑处理
GameThink_Initial
绘制初始画面,用户按下按键后设置 g_GameState
为 GAMESTATE_GAMESTART
GameThink_GameStart
绘制准备画面,用户按下按键后设置 g_GameState
为 GAMESTATE_GAME
GameThink_Game
绘制 pipe 和 bird,若 bird 碰到 pipe,设置 g_GameState
为 GAMESTATE_GAMEOVER
。期间记录分数
GameThink_GameOver
游戏失败的画面依阶段绘制:
static enum { FLASH, DROP, SHOWTITLE, SHOWSCORE }
并在 SHOWSCORE
阶段且按下按键后设置 g_GameState
为 GAMESTATE_GAME
似乎游戏的结束比较困难……
7、更新画面
SDL_UpdateRect
移植和测试
我们在移植游戏的时候,会按顺序在四种环境中运行游戏:
- 纯粹的 Linux native
和 Project-N 的组件没有任何关系,用于保证游戏本身确实可以正确运行。在更换库的版本或者修改游戏代码之后,都会先在 Linux native 上进行测试
- Navy 中的 native
用 Navy 中的库替代 Linux native 的库,测试游戏是否能在 Navy 库的支撑下正确运行
- AM 中的 native
用 Nanos-lite, libos 和 Newlib 替代 Linux 的系统调用和 glibc,测试游戏是否能在 Nanos-lite 及其运行时环境的支撑下正确运行
- NEMU
用 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
的字符数组。
0x00007ffff7c3650f in unlink_chunk (p=p@entry=0x5555561d3cc0, av=0x7ffff7d83ba0 <main_arena>) at malloc.c:16071607 malloc.c: No such file or directory.(gdb) bt#0 0x00007ffff7c3650f in unlink_chunk (p=p@entry=0x5555561d3cc0, av=0x7ffff7d83ba0 <main_arena>) at malloc.c:1607#1 0x00007ffff7c3864c in _int_malloc (av=av@entry=0x7ffff7d83ba0 <main_arena>, bytes=bytes@entry=1696) at malloc.c:4266#2 0x00007ffff7c3a2f1 in __GI___libc_malloc (bytes=1696) at malloc.c:3237#3 0x0000555555575113 in YJ1_Decompress ()#4 0x000000000000010a in ?? ()#5 0x00005555561fb8e0 in ?? ()#6 0x00007ffff6a2b800 in ?? ()#7 0x00000000f7cca313 in ?? ()#8 0x0000000000000000 in ?? ()
nanos-lite:
address (0x44a1f9cb) is out of bound at pc = 0x8303421c
报错的原因是因为不同格式的 pixels 所占的空间不同。需要对应修改 SDL_BlitSurface
、SDL_FillRect
和 SDL_UpdateRect
。调色板机制在 SDL_UpdateRect
中实现:
void SDL_UpdateRect(SDL_Surface *s, int x, int y, int w, int h) { assert(s); assert(s->format); if (s->format->palette == NULL) { // 32-bits if (x == 0 && y == 0 && w == 0 && h == 0) { NDL_DrawRect((uint32_t *)s->pixels, x, y, s->w, s->h); return; } NDL_DrawRect((uint32_t *)s->pixels, x, y, w, h); } else { // 8-bits assert(s->format->palette->colors); int W, H; if (x == 0 && y == 0 && w == 0 && h == 0) { W = s->w; H = s->h; } else { W = w; H = h; } uint8_t * pixels_index = (uint8_t *)s->pixels; uint32_t * pixels = (uint32_t *)malloc(W * H * sizeof(uint32_t *)); for (int i = 0; i < W * H; ++i) { SDL_Color colors = s->format->palette->colors[pixels_index[i]]; uint32_t p = (colors.a << 24) | (colors.r << 16) | (colors.g << 8) | (colors.b << 0); pixels[i] = p; } NDL_DrawRect(pixels, x, y, W, H); free(pixels); }}
- PAL_ProcessEvent -> PAL_UpdateKeyboardState -> SDL_GetKeyboardState -> SDL_GetKeyState
https://wiki.libsdl.org/SDL_GetKeyboardState
static unsigned char keystate[sizeof(keyname) / sizeof(keyname[0])];
uint8_t* SDL_GetKeyState(int *numkeys) { SDL_Event ev; if (SDL_PollEvent(&ev) == 1 && ev.key.type == SDL_KEYDOWN) { keystate[ev.key.keysym.sym] = 1; } else { memset(keystate, 0, sizeof(keystate)); } return keystate;}
- 高亮画面显示异常
- 键盘时间反应迟钝
脚本引擎
在 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
举个栗子:
(Navy) __am_timer_uptime -> gettimeofday -> sys_gettimeofday -> io_read -> ioe_read -> (AM) _am_timer_uptime
AM 程序并不知道自己调用的 API 绕了一个大圈……
实现
下面研究一下 libam 库:
├── include│ ├── amdev.h -> /home/vgalaxy/ics2021/abstract-machine/am/include/amdev.h│ ├── am.h│ ├── am-origin.h -> /home/vgalaxy/ics2021/abstract-machine/am/include/am.h│ ├── klib.h -> /home/vgalaxy/ics2021/abstract-machine/klib/include/klib.h│ ├── klib-macros.h -> /home/vgalaxy/ics2021/abstract-machine/klib/include/klib-macros.h│ └── navy.h├── Makefile└── src ├── ioe.c └── trm.cpp
先看一下 Makefile:
ifeq ($(wildcard include/am-origin.h),)ifeq ($(wildcard $(AM_HOME)/am/include/am.h),) $(error $$AM_HOME/am/include/amdev.h will be used. Please set $$AM_HOME to an AbstractMachine repo)else $(info Setup link to header files) $(shell ln -sf -T $(AM_HOME)/am/include/am.h include/am-origin.h) $(shell ln -sf -T $(AM_HOME)/am/include/amdev.h include/amdev.h) $(shell ln -sf -T $(AM_HOME)/klib/include/klib.h include/klib.h) $(shell ln -sf -T $(AM_HOME)/klib/include/klib-macros.h include/klib-macros.h)endifendif
第一行的意思是匹配是否有 include/am-origin.h
这个文件,如果没有的话会返回空,也就是 ifeq
语句条件成立。我们刚开始没有这个文件,所以会进入第二行。
第一行的意思是匹配是否有 $(AM_HOME)/am/include/am.h
这个文件,显然是有的,跳过报错,并建立一下软链接,原来的 am.h
被链接到了 am-origin.h
。
而 include
目录下的 am.h
包含了这些头文件:
#ifndef __AM_H__#define __AM_H__
#define ARCH_H "navy.h"#include "am-origin.h"#include "amdev.h"#include "klib.h"#include "klib-macros.h"
#endif
这里的 ARCH_H
会在 am-origin.h
中被包含。
navy.h
是一些要用到的 newlib
:
#ifndef __NAVY_H__#define __NAVY_H__
#include <stdio.h>#include <stdlib.h>#include <sys/time.h>#include <sys/types.h>#include <sys/stat.h>#include <fcntl.h>#include <unistd.h>
#endif
然后我们在 libam 中实现 TRM:
#include <am.h>
Area heap;
#define nemu_trap(code) asm volatile("mv a0, %0; .word 0x0000006b" : :"r"(code))
void putch(char ch) { putchar(ch);}
void halt(int code) { nemu_trap(code);
// should not reach here while (1);}
然后是 IOE:
- 时钟:
static void __am_timer_config(AM_TIMER_CONFIG_T *cfg) { cfg->present = true; cfg->has_rtc = true;}
void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) { struct timeval tv; assert(gettimeofday(&tv, NULL) == 0); uptime->us = tv.tv_sec * 1000000 + tv.tv_usec;}
void __am_timer_rtc(AM_TIMER_RTC_T *rtc) { rtc->second = 0; rtc->minute = 0; rtc->hour = 0; rtc->day = 0; rtc->month = 0; rtc->year = 1900;}
仿
NDL_GetTicks
- 键盘:
#define _KEYS(_) \ _(ESCAPE) _(F1) _(F2) _(F3) _(F4) _(F5) _(F6) _(F7) _(F8) _(F9) _(F10) _(F11) _(F12) \ _(GRAVE) _(1) _(2) _(3) _(4) _(5) _(6) _(7) _(8) _(9) _(0) _(MINUS) _(EQUALS) _(BACKSPACE) \ _(TAB) _(Q) _(W) _(E) _(R) _(T) _(Y) _(U) _(I) _(O) _(P) _(LEFTBRACKET) _(RIGHTBRACKET) _(BACKSLASH) \ _(CAPSLOCK) _(A) _(S) _(D) _(F) _(G) _(H) _(J) _(K) _(L) _(SEMICOLON) _(APOSTROPHE) _(RETURN) \ _(LSHIFT) _(Z) _(X) _(C) _(V) _(B) _(N) _(M) _(COMMA) _(PERIOD) _(SLASH) _(RSHIFT) \ _(LCTRL) _(APPLICATION) _(LALT) _(SPACE) _(RALT) _(RCTRL) \ _(UP) _(DOWN) _(LEFT) _(RIGHT) _(INSERT) _(DELETE) _(HOME) _(END) _(PAGEUP) _(PAGEDOWN)
#define keyname(k) #k,
static const char *keyname[] = { "NONE", _KEYS(keyname)};
static void __am_input_config(AM_INPUT_CONFIG_T *cfg) { cfg->present = true;}
void __am_input_keybrd(AM_INPUT_KEYBRD_T *kbd) { unsigned buf_size = 32; char *buf = (char *)malloc(buf_size * sizeof(char)); memset(buf, 0, buf_size); int fd = open("/dev/events", 0, 0); int ret = read(fd, buf, buf_size); close(fd);
if (ret > 0) { if (strncmp(buf, "kd", 2) == 0) { kbd->keydown = 1; } else { kbd->keydown = 0; }
int flag = 0; for (unsigned i = 0; i < sizeof(keyname) / sizeof(keyname[0]); ++i) { if (strncmp(buf + 3, keyname[i], strlen(buf) - 4) == 0 && strlen(keyname[i]) == strlen(buf) - 4) { flag = 1; kbd->keycode = i; break; } }
assert(flag == 1); } else { kbd->keycode = 0; } free(buf);}
仿
SDL_PollEvent
- VGA
static int gpu_sync = false;void __am_gpu_config(AM_GPU_CONFIG_T *cfg) { uint16_t w = 400; uint16_t h = 300;
*cfg = (AM_GPU_CONFIG_T) { .present = true, .has_accel = false, .width = w, .height = h, .vmemsz = w * h * sizeof(uint32_t) };}
void __am_gpu_fbdraw(AM_GPU_FBDRAW_T *ctl) { int x = ctl->x; int y = ctl->y; int w = ctl->w; int h = ctl->h;
uint32_t * base = (uint32_t *) ctl->pixels;
int fd = open("/dev/fb", 0, 0); for (int i = 0; i < h && y + i < 300; ++i) { lseek(fd, ((y + i) * 400 + x) * 4, SEEK_SET); write(fd, base + i * w, 4 * (w < 400 - x ? w : 400 - x)); }
if (ctl->sync) { gpu_sync = true; } else { gpu_sync = false; }}
void __am_gpu_status(AM_GPU_STATUS_T *status) { status->ready = gpu_sync;}
仿
NDL_DrawRect
最后与 AM 一样加上回调函数表:
typedef void (*handler_t)(void *buf);static void *lut[128] = { [AM_TIMER_CONFIG] = __am_timer_config, [AM_TIMER_RTC ] = __am_timer_rtc, [AM_TIMER_UPTIME] = __am_timer_uptime, [AM_INPUT_CONFIG] = __am_input_config, [AM_INPUT_KEYBRD] = __am_input_keybrd, [AM_GPU_CONFIG ] = __am_gpu_config, [AM_GPU_FBDRAW ] = __am_gpu_fbdraw, [AM_GPU_STATUS ] = __am_gpu_status,};
static void fail(void *buf) { panic("access nonexist register"); }
bool ioe_init() { for (int i = 0; i < LENGTH(lut); i++) if (!lut[i]) lut[i] = fail; return true;}
void ioe_read (int reg, void *buf) { ((handler_t)lut[reg])(buf); }void ioe_write(int reg, void *buf) { ((handler_t)lut[reg])(buf); }
运行
实现之后,navy-apps/apps/am-kernels/Makefile
会把 libam 加入链接的列表。我们在 navy-apps/apps/am-kernels
目录下键入:
make ISA=riscv32 ALL=typing-game installmake ISA=riscv32 ALL=coremark installmake ISA=riscv32 ALL=dhrystone install
就可以在 navy-apps/fsimg/bin
下得到镜像文件。
am-kernels 对应的
build
文件夹下也会有变化
然后我们在 nanos-lite 中更新:
make ARCH=riscv32-nemu update
就可以运行了……
运行中一些奇怪的现象:
- 打字小游戏的屏幕是按列刷新……
- 跑分的时间是整秒……
关掉 trace,跑分似乎并不差,甚至比无 OS 还要好……
尝试把 microbench 编译到 Navy 并运行:
- 修改
navy-apps/apps/am-kernels/Makefile
- 似乎无法指定 mainargs
- 大部分测试都因为内存不足而跳过了……
FCEUX
vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/apps/fceux$ make ISA=riscv32 install
似乎无法指定操作系统所加载程序的 main
函数的参数……
于是 main
的参数 romname
为 NULL
,导致解引用 NULL
调用轨迹参考:
main -> LoadGame -> FCEUI_LoadGame -> FCEUI_LoadGameVirtual -> strcpy
理论上甚至可以在 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)
反汇编代码:
- nanos-lite
vgalaxy@vgalaxy-VirtualBox:~/ics2021/nanos-lite/build$ riscv64-linux-gnu-objdump -d nanos-lite-riscv32-nemu.elf | less
- navy-apps
vgalaxy@vgalaxy-VirtualBox:~/ics2021/navy-apps/fsimg/bin$ riscv64-linux-gnu-objdump -d 161220016 | less
发布代码所有的命名都是随机字符串,就像这样:
static void AlM0UT25fL(uint32_t vZkqLfCIMW) { for (int cZ5d65ts5U = kZ6hP_qvSg.eZelonvCFU; cZ5d65ts5U < kZ6hP_qvSg.hck0ckSSZL; ++cZ5d65ts5U) gZ5nzpxC8r(vZkqLfCIMW, kZ6hP_qvSg.XD7kFstGd3[cZ5d65ts5U] - kZ6hP_qvSg.UQRn26Bmz5[cZ5d65ts5U], RsBhhiFFvE.J_Sl6Dag5p / 2, kZ6hP_qvSg.UQRn26Bmz5[cZ5d65ts5U] * 2, 3);}
实现思路:对源文件进行词法和语法分析,找到标识符,并随机化……
基础设施
自由开关 DiffTest 模式
略,因为一直没用上 DiffTest
快照
程序是个 S = <R, M>
的状态机
寄存器可以表示为 R = {GPR, PC, SR}
,其中 SR
为系统寄存器
还是感觉没啥用,因为 NEMU 一直都是 batch mode
SYS_execve
我们需要一个新的系统调用 SYS_execve
。
操作系统层:
case SYS_execve:#ifdef CONFIG_STRACE Log("sys_execve(%s, %d, %d)", (const char *)c->GPR2, c->GPR3, c->GPR4);#endif sys_execve((const char *)c->GPR2); while(1);
若成功执行其他程序,该系统调用处理函数是不返回的:
int sys_execve(const char *fname) { naive_uload(NULL, fname); return -1;}
应用层:
int _execve(const char *fname, char * const argv[], char *const envp[]) { return _syscall_(SYS_execve, (intptr_t)fname, 0, 0);}
目前暂时忽略 argv
和 envp
这两个参数。
有了这个系统调用之后,开机菜单就可以发挥全部功力了:
if (i != -1 && i <= i_max) { i += page * 10; auto *item = &items[i]; const char *exec_argv[3]; exec_argv[0] = item->bin; exec_argv[1] = item->arg1; exec_argv[2] = NULL; clear_display(); SDL_UpdateRect(screen, 0, 0, 0, 0); execve(exec_argv[0], (char**)exec_argv, (char**)envp); fprintf(stderr, "\033[31m[ERROR]\033[0m Exec %s failed.\n\n", exec_argv[0]); } else { fprintf(stderr, "Choose a number between %d and %d\n\n", 0, i_max); }
我们还可以修改 SYS_exit
的实现,让它调用 SYS_execve
来再次运行 /bin/menu
,而不是直接调用 halt()
来结束整个系统的运行。
然而使用
exit
结束的 Navy / AM 程序并不多……我们可以选择一个跑分的程序……
随着应用程序数量的增加,使用开机菜单来运行程序就不是那么方便了。我们可以通过 NTerm 来运行这些程序,只要键入程序的路径,例如 /bin/pal
。
在 NTerm 的內建 Shell 中实现命令解析:
#define fname_buf_size 128static char fname[128];
static void sh_handle_cmd(const char *cmd) { if (cmd == NULL) return; if (strncmp(cmd, "echo", 4) == 0) { if (strlen(cmd) == 5) sh_printf("\n"); else sh_printf("%s", cmd + 5); } else { if (strlen(cmd) > fname_buf_size) { sh_printf("command too long\n"); return; } memset(fname, 0, fname_buf_size); strncpy(fname, cmd, strlen(cmd) - 1); // execve(fname, NULL, NULL); execvp(fname, NULL); }}
注意去掉 cmd
最后的换行符。
并在主循环前定义 PATH
这个环境变量,这样就不用键入命令的完整路径啦:
assert(setenv("PATH", "/bin", 0) == 0);
RTFM 了解
setenv()
和execvp()
的行为……似乎
/bin
或者/bin/
都可以……
一些问题:
- Navy native 会报错
当然会报错……
- 若程序不存在,会在
fs_open
阶段panic
于是开机动画(音乐)成了可能……
Report
How Hello 2
- 当你在终端键入
./hello
运行 Hello World 程序的时候,计算机究竟做了些什么?
构建了文件系统后,我们需要重新回答 Hello World 程序是如何出现在内存中的。
在 Navy-apps 的 Makefile
中添加相应的应用程序或测试程序后,在 nanos-lite 中执行 make ARCH=riscv32-nemu update
:
update: $(MAKE) -s -C $(NAVY_HOME) ISA=$(ISA) ramdisk @ln -sf $(NAVY_HOME)/build/ramdisk.img $(RAMDISK_FILE) @ln -sf $(NAVY_HOME)/build/ramdisk.h src/files.h @ln -sf $(NAVY_HOME)/libs/libos/src/syscall.h src/syscall.h
输出结果为:
# Building nanos-lite-update [riscv32-nemu]make -s -C /home/vgalaxy/ics2021/navy-apps ISA=riscv32 ramdisk# Building -ramdisk [riscv32]# Building hello-install [riscv32]# Building compiler-rt-archive [riscv32]+ AR -> build/compiler-rt-riscv32.a# Building libc-archive [riscv32]+ AR -> build/libc-riscv32.a# Building libos-archive [riscv32]+ AR -> build/libos-riscv32.a+ LD -> build/hello-riscv32+ INSTALL -> hello
其中的 Building
信息显示了名称和构建规则:
### Print build info message$(info # Building $(NAME)-$(MAKECMDGOALS) [$(ISA)])
我们使用 make -nB
进一步观察可知:
1、基本流程:ramdisk -> fsimg -> install / …
# Building nanos-lite-update [riscv32-nemu]make -s -C /home/vgalaxy/ics2021/navy-apps ISA=riscv32 ramdisk# Building -ramdisk [riscv32]for t in tests/hello; do make -s -C /home/vgalaxy/ics2021/navy-apps/$t install; done
对应 Navy-apps 的 Makefile
:
fsimg: $(addprefix apps/, $(APPS)) $(addprefix tests/, $(TESTS)) -for t in $^; do $(MAKE) -s -C $(NAVY_HOME)/$$t install; done
RAMDISK = build/ramdisk.imgRAMDISK_H = build/ramdisk.h$(RAMDISK): fsimg $(eval FSIMG_FILES := $(shell find -L ./fsimg -type f)) @mkdir -p $(@D) @cat $(FSIMG_FILES) > $@ @truncate -s \%512 $@ @echo "// file path, file size, offset in disk" > $(RAMDISK_H) @wc -c $(FSIMG_FILES) | grep -v 'total$$' | sed -e 's+ ./fsimg+ +' | awk -v sum=0 '{print "\x7b\x22" $$2 "\x22\x2c " $$1 "\x2c " sum "\x7d\x2c";sum += $$1}' >> $(RAMDISK_H)
ramdisk: $(RAMDISK)
2、细化:fsimg
规则中对每个条目执行了 install
规则:
install: app @echo + INSTALL "->" $(NAME) @mkdir -p $(NAVY_HOME)/fsimg/bin @cp $(APP) $(NAVY_HOME)/fsimg/bin/$(NAME)
install
规则又依赖于 app
规则:
app: $(APP)
$(APP): $(OBJS) libs @echo + LD "->" $(shell realpath $@ --relative-to .) @$(LD) $(LDFLAGS) -o $@ $(WL)--start-group $(LINKAGE) $(WL)--end-group
其中 OBJS
代表可重定位目标文件:
OBJS = $(addprefix $(DST_DIR)/, $(addsuffix .o, $(basename $(SRCS))))
此时会编译得到 hello.c
和 hello.o
文件。
而 libs
规则如下:
libs: @for t in $(LIBS); do $(MAKE) -s -C $(NAVY_HOME)/libs/$$t archive; done
其中 $(LIBS)
有 compiler-rt libc libos
。逐一执行 archive
规则:
archive: $(ARCHIVE)
...
$(ARCHIVE): $(OBJS) libs @echo + AR "->" $(shell realpath $@ --relative-to .) @ar rcsT $@ $(LINKAGE)
得到各自的 .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
-
死循环
- 读取键盘事件
- PAL_ProcessEvent
- 设置并更新调色板
- 更新屏幕
- 背景:VIDEO_CopySurface -> SDL_BlitSurface
- 仙鹤 & 标题:一些特殊的方法,最后都归结为更新像素信息
- VIDEO_UpdateScreen -> (SDL_SoftStretch) / SDL_FillRect / SDL_UpdateRect
- SDL_UpdateRect -> NDL_DrawRect -> open and write
/dev/fb
- 触发系统调用,下略
- 检查键盘,判断是否结束开始动画
- 读取键盘事件
-
释放资源,结束音乐