引言

操作系统通过系统调用向用户程序提供服务, 那么

  • 用户程序到底从何而来?
  • 系统调用具体如何提供服务?
  • 操作系统的运行时环境如何向用户程序提供更方便的功能?

 

本次课内容:

  • 加载用户程序
  • 操作系统服务
  • 文件系统
  • 库函数和用户程序
  • 例说仙剑奇侠传

加载用户程序

用户程序到底从何而来?

之前的NEMU/NPC - 由运行时环境加载

现在用户程序要运行在操作系统上, 也需要运行时环境来加载

  • 这时运行时环境 = 操作系统
    • 不同的操作系统提供的运行时环境不同: Windows vs. Linux
    • 大家通过实现简单的教学版OS Nanos-lite来认识计算机系统软件栈

 

加载用户程序 = 将用户程序可执行文件中的代码和数据放到M中正确位置

具体地, 加载程序需要解决如下问题

  1. 可执行文件在哪里?
  2. 代码和数据在可执行文件中的哪个位置?
  3. 代码和数据有多少?
  4. “正确的内存位置”在哪里?

操作系统之上的用户程序

  • Nanos-lite提供的运行时环境和AM不一样
    • 需要一个新项目来编译用户程序 - navy-apps

 

  • navy-apps/libs/libos/src/crt0/start/$ISA.S中的_start()开始执行
    • 调用navy-apps/libs/libos/src/crt0/crt0.c中的call_main()
      • crt = C RunTime, 0 = 最开始
    • 调用main()
    • 若从main()返回, 则调用exit()结束运行
      • exit()会执行SYS_exit系统调用, 请求操作系统结束程序
      • 和之前演示用gdb观察main()返回后的行为很类似

ramdisk - 基于内存的存储设备

需要把用户程序放在操作系统知道的地方

  • 解决方法 - 约定
    • 把编译出的用户程序包含到Nanos-lite的项目中
    • 编译Nanos-lite时, 用户程序作为Nanos-lite数据段的一部分

 

在Nanos-lite看来, 用户程序就像是存放在内存

  • 在真实场景中, 用户程序一般存放在类似磁盘/SSD的存储设备中
  • 现在存放在内存, 如果把这段内存看成一个设备, 就是ramdisk
    • 访问ramdisk = memcpy()

 

我们先假设ramdisk中只有一个文件 - 用户程序的ELF文件

  • 通过ramdisk_read(buf, 0, 1)即可访问用户程序ELF的首字节

通过加载器(loader)加载用户程序

ELF中还包含面向加载的segment(段)视角 - RTFM

      +-------+---------------+-----------------------+
      |       |...............|                       |
      |       |...............|                       |  ELF file
      |       |...............|                       |
      +-------+---------------+-----------------------+
      0       ^               |              
              |<------+------>|       
              |       |       |             
              |       |                            
              |       +----------------------------+       
              |                                    |       
   Type       |   Offset    VirtAddr    PhysAddr   |FileSiz  MemSiz   Flg  Align
   LOAD       +-- 0x001000  0x03000000  0x03000000 +0x1d600  0x27240  RWE  0x1000
                               |                       |       |     
                               |   +-------------------+       |     
                               |   |                           |     
                               |   |     |           |         |       
                               |   |     |           |         |      
                               |   |     +-----------+ ---     |     
                               |   |     |00000000000|  ^      |   
                               |   | --- |00000000000|  |      |    
                               |   |  ^  |...........|  |      |  
                               |   |  |  |...........|  +------+
                               |   +--+  |...........|  |      
                               |      |  |...........|  |     
                               |      v  |...........|  v    
                               +-------> +-----------+ ---  
                                         |           |     
                                         |           |    
                                            Memory
  • 很容易实现.bss
  • 跳转到ELF文件中指示的入口地址, 即可执行_start()的第一条指令!

操作系统的服务

回顾: 系统调用

用户程序通过发起系统调用请求提供服务

  • 唯一合法方式: 自陷类异常 - 执行一条无条件触发异常的指令
  • RISC-V提供ecall指令

 

让用户程序通过通用寄存器指定请求何种服务及其参数传递

  • 操作系统的异常处理函数识别到系统调用请求后, 可从Context结构中读出系统调用的参数
  • RISC-V Linux约定采用a7寄存器传递系统调用号, a0, a1, …分别传递第1/2/…个参数

一个例子 - SYS_yield

void sys_yield() {
    asm volatile ("li a7, 1; ecall");
}
  • 执行ecall后跳转到AM CTE代码
  • CTE的__am_irq_handle()发现是系统调用, 封装成EVENT_SYSCALL事件, 并调用Nanos-lite注册的回调函数
  • 回调函数发现是EVENT_SYSCALL事件, 就调用系统调用处理函数do_syscall()
  • do_syscall()发现Context中的a7是1, 就进行SYS_yield的处理

 

SYS_yield只是走马观花, 操作系统真正需要提供哪些服务?

相当于问用户程序有哪些需求

这不就是AM吗!

  • AM的API某种程度上反映了AM程序的基本需求
    • 程序要(高效地)计算 -> (支持指令集的)图灵机 [TRM]
    • 程序要输入输出 -> 冯诺依曼机 [IOE]
    • 想依次运行多个程序 -> 批处理系统 [CTE]
    • 想并发运行多个程序 -> 分时多任务 [VME]
    • 想并行运行多个程序 -> 多处理器系统 [MPE]

 

只不过现在需要由操作系统实现这些API

操作系统之上的TRM

AM TRM提供的运行时环境:

  • 程序 “入口” - main(const char *args)
  • “退出”程序的方式 - halt()
  • 打印字符 - putch()
  • 可以用来自由计算的内存区间 - 堆区

 

我们需要在操作系统上提供类似的服务:

  • 程序 “入口” - ELF加载器
  • “退出”程序的方式 - SYS_exit, 识别后调用halt()
  • 打印字符 - SYS_write, 识别后调用putch()
  • 可以用来自由计算的内存区间 - SYS_brk, 目前总是返回0表示成功

操作系统之上的IOE

AM IOE提供的运行时环境:

  • 输入函数ioe_read()和输出函数ioe_write()
  • 还有一些约定的, 与系统无关的抽象设备
    • 时钟, 键盘, 2D GPU[, 串口, 声卡, 磁盘, 网卡]

 

我们需要在操作系统上提供类似的服务:

  • 输入函数ioe_read() - SYS_read
  • 输出函数ioe_write() - SYS_write
    • 和刚才的打印字符重名了???
  • 一些约定的设备 - ???
    • 我们刚才提到ramdisk - 用户程序访问磁盘, 也是合理的需求

应该如何设计一种全面的机制?

文件系统

回到用户程序的需求

  • 相比于磁盘, 用户程序访问的是磁盘上存储的对象
  • 相比于设备, 用户程序访问的是设备的信息

我们需要一些新的抽象层!

  • 先考虑前者: 存储对象的抽象 = 文件

 

文件又分两部分

  • 文件本身的数据
  • 描述文件属性的数据 - 元数据
    • 文件名, 文件大小, 文件在磁盘上的位置…

文件系统 = 组织文件的数据结构

不同的文件系统有不同的组织方式

  • 常见文件系统 - NTFS, ext4, FAT, …

 

Nanos-lite采用一种非常简单的文件系统 - sfs(Simple File System)

  • 每个文件的大小是固定的
  • 写文件时不允许超过原有文件的大小
  • 文件的数量是固定的, 不能创建新文件
  • 没有目录
  • 每一个文件分别固定存放在存储设备中的某一个位置
0
+-------------+---------+----------+-----------+--
|    file0    |  file1  |  ......  |   filen   |
+-------------+---------+----------+-----------+--
 \           / \       /            \         /
  +  size0  +   +size1+              + sizen +

sfs的数据结构

typedef struct {
  char *name;          // 文件名
  size_t size;         // 文件大小
  size_t disk_offset;  // 文件在ramdisk中的偏移
} Finfo;

static Finfo file_table[] = {
  // ...
  {"/share/music/little-star.ogg", 140946, 3539914},
  {"/share/games/pal/sdlpal.cfg", 70, 3680860},
  {"/share/games/bird/sfx_hit.wav", 96020, 31510057},
  {"/share/games/nes/mario.nes", 40976, 31745613},
  {"/share/pictures/projectn.bmp", 49290, 39308528},
  {"/bin/menu", 98184, 39357818},
  {"/bin/busybox", 158632, 39456002},
  {"/bin/dummy", 33592, 39773266},
  {"/bin/pal", 459096, 39806858},
  {"/bin/cat", 158632, 40265954},
  {"/bin/nterm", 114304, 40424586},
  {"/bin/bird", 180784, 40697522},
  {"/bin/hello", 37560, 40878306},
  // ...
};

/也是文件名的一部分 - 用户程序以为sfs提供了目录功能

用户程序对文件的访问需求

最基本的读写操作:

size_t fs_read(const char *filename, void *buf, size_t len);
size_t fs_write(const char *filename, const void *buf, size_t len);

但是操作系统中有一些没有名字的文件

cat file | less   # cat工具把文件内容输出到哪里? less工具从哪里读入内容?

为了统一管理它们, 操作系统一般通过一个编号来表示文件

  • 文件描述符(file descriptor)
  • 一个文件描述符对应一个正在打开的文件
  • 由操作系统来维护文件描述符到具体文件的映射
    • 在sfs中, 可以直接用file_table的下标作为文件描述符
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);
int fs_close(int fd);
size_t fs_lseek(int fd, size_t offset, int whence); // 调整读写偏移量, 从而支持随机访问

标准输入输出

回顾: Linux程序运行时默认打开3个文件, 通过 “文件描述符”来编号

  • 0号文件 - 标准输入(默认为当前终端)
  • 1号文件 - 标准输出(默认为当前终端)
  • 2号文件 - 标准错误(默认为当前终端)

 

在sfs中实现标准输入输出

#define FD_STDIN 0
#define FD_STDOUT 1
#define FD_STDERR 2

static Finfo file_table[] = {
  [FD_STDIN]  = {"stdin", 0, 0},
  [FD_STDOUT] = {"stdout", 0, 0},
  [FD_STDERR] = {"stderr", 0, 0},
  // ...
};

写入1号文件时, 通过putch()输出, 否则调用ramdisk_write()

设备的抽象

虽然系统调用是用户程序访问资源的唯一方式, 但为不同设备提供专门的系统调用并不是一个好方案

  • 设备类型五花八门, 功能数不胜数, 将会引入大量系统调用
  • 这些系统调用接口不同, 编程不方便

希望对设备功能进行抽象, 向用户程序提供统一的接口

 

观察 - 设备的功能虽然很多, 但交互的信息都是字节序列!

  • 键盘 -> 键盘码字节流
  • VGA -> 像素矩阵
  • 串口 -> 字符流
  • 声卡 -> 音频流
  • 磁盘 -> 字节序列
  • 网卡 -> 网络流

字节序列 -> 文件 -> 文件操作

  • Unix - Everything is a file.

虚拟文件系统(VFS, Virtual File System)

对文件系统的概念进行抽象和扩展

  • 文件名和文件描述符可以指示设备等特殊文件
  • 文件操作API的语义可以支持任意文件

实现起来并不难: 为每个文件添加一组读写函数指针即可

typedef struct {
  // ...
  ReadFn read;         // 读函数指针
  WriteFn write;       // 写函数指针
} Finfo;

static Finfo file_table[] = {
  // ...
  [FD_STDOUT] = {"stdout", 0, 0, invalid_read, serial_write},
  {"/dev/fb", 0, 0, invalid_read, fb_write},
  {"/share/pictures/projectn.bmp", 49290, 39308528, ramdisk_read, ramdisk_write},
  // ...
};

size_t fs_write(int fd, const void *buf, size_t len) {
  return file_table[fd].write(dest, buf, len);
}

// 向用户程序提供若干特殊文件: /dev/events, /proc/dispinfo, /dev/fb, /dev/sb, /dev/sbctl

库函数和用户程序

系统调用接口

内联汇编:

asm volatile (
  "li a0, 1\n"
  "mv a1, %0\n"
  "mv a2, %1\n"
  "li a7, %2\n"
  "ecall\n"
  : : "r"("Hello World!\n"), "r"(13), "i"(SYS_write));

 

navy-apps/libs/libos/src/syscall.c为用户程序提供了更好的系统调用封装函数

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

I/O库函数

系统级I/O库函数:

size_t write(int fd, void *buf, size_t count) {
  return _syscall_(...);
}

size_t read(int fd, void *buf, size_t count) {
  return _syscall_(...);
}

int close(int fd) {
  return _syscall_(...);
}

C标准I/O库函数:

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
int fclose(FILE *stream);

 

navy-apps采用Newlib项目作为C标准库的实现

自制窗口管理器

  • 在自制窗口管理器的管理下, 可分时运行
    • 自制终端
    • 自制幻灯片播放器
    • 自制打字游戏
    • 自制音频播放器
    • 上古游戏红白机超级玛丽
    • 近代游戏仙剑奇侠传
    • 现代游戏ONS版Clannad

 

期待有一天能运行在自己设计的芯片上!

例说仙剑奇侠传

仙剑奇侠传

  • 暴露年龄的经典RPG游戏

 

  • 网友自行编写了游戏引擎(太强了)并开源
    • 并不是游戏发行版本的源码

 

  • 看上去游戏很庞大
    • 正常通关需要约20小时
    • 游戏引擎约3w行代码
      • 去除两个字体文件

 

  • 但你已经具备了理解这个游戏引擎的能力了!

初始化

  1. 各种子系统的初始化(src/main.c: PAL_Init())
    • 包括载入各种资源文件 (src/global/global.c: PAL_InitGlobals())
  • fbp.mkf - 战斗背景图
  • mgo.mkf - 场景中的sprite(精灵)
    • 相对背景可变化的对象
  • ball.mkf - 物品位图
  • data.mkf - 杂项数据
  • f.mkf - 战斗中我方角色sprite
  • fire.mkf - 法术效果sprite
  • rgm.mkf - 角色头像位图
  • sss.mkf - 脚本及相关数据

  1. 播放商标动画
  2. 播放片头动画
  3. 游戏启动菜单 - 新的故事/旧的回忆

游戏主循环

  • src/game/game.c: PAL_GameMain()
    • 是个熟悉的while (1)

 

  • 处理按键 - PAL_ProcessEvent()
  • 等待下一帧 - SDL_GetTicks()
  • PAL_StartFrame()
  • 更新游戏逻辑 = 计算
  • 更新画面 - VIDEO_UpdateScreen()

 

做完PA2就可以理解

简单的常用功能 - 角色移动

  • src/device/input.c: PAL_KeyPressHandler()记录按键状态
  • src/scene/scene.c: PAL_UpdateParty()处理移动的逻辑
    • 检测方向键 -> 计算新位置 -> 检测障碍物 -> 移动视点 -> 更新位姿
  • 和打字游戏的按键处理非常类似

 

 

 

 

 

  • 另一个常用功能 - 调查(按空格键)
    • 与NPC对话, 开宝箱, 开门, 触发机关, 摘鼠儿果…
  • 调查后触发的逻辑种类太多了, 如果你是游戏开发者, 你会如何实现?

需求分析

  • 实现调查功能, 调查不同对象会触发不同的操作
  • 一个想法: 借鉴VFS, 把操作抽象成函数, 把函数指针存放在对象的结构体中

 

  • 不同的操作仍然有共性部分, 如何提取并复用?
    • 调查NPC -> 输出文字, 调查宝箱 -> 获得物品, 调查关键物品 -> 剧情推进
  • 能否将操作的实现独立于游戏引擎之外?
    • 打补丁就不需要更新游戏引擎了

 

  • 我们需要一种新的抽象, 将游戏引擎和触发的具体操作解耦开来
    • 具体操作通过API的组合来实现, 游戏引擎来实现这些API

这就是游戏引擎中的脚本指令

  • 游戏引擎 = 硬件/NEMU, 具体操作 = 程序
  • 很自然地, API = 指令
// include/global.h
typedef struct {
  WORD wOperation;
  WORD rgwOperand[3];
} SCRIPTENTRY, *LPSCRIPTENTRY;
  • 指令执行(src/game/script.c: PAL_InterpretInstruction())
    • 第一个参数wScriptEntry = PC
    • 通过一个大switch-case对指令的wOperation进行译码
    • 执行 = 改变对象/游戏的状态
      • 移动一步, 更新位姿, 佩戴/卸下装备, 添加/移除道具, 加/减金钱…
      • 扣血, 下毒, 治疗, 吸血, 分身, 召唤, 变身, 投掷武器, 使用法术…
      • 还有条件跳转(若未持有某物品/若某角色在队中/若未中毒/…)
    • 返回新PC

两种类型的脚本

  1. 触发脚本(src/game/script.c: PAL_RunTriggerScript())
    • 满足某条件时开始执行, 一次调用会执行多条指令, 直到脚本结束
      • while (wScriptEntry != 0 && !fEnded) { ... }
      • 如调查NPC时显示对话, 踩到机关时打开暗道, 使用道具/法术, 佩戴装备
  2. 自动脚本(src/game/script.c: PAL_RunAutoScript())
    • 每帧重复执行, 一次调用大多数只执行一条指令(也可调用触发脚本)
      • 在主循环中被调用(src/game/play.c: PAL_GameUpdate())
        • 类似时钟中断的处理程序
      • 如NPC随机移动, 敌方在我方进入视野时追逐我方

 

所有脚本存放在sss.mkf文件中, 游戏引擎初始化时载入

嵌入在各种对象中的脚本

  • 一种特殊的函数指针
    • 指示脚本入口(在sss.mkf中的位置)

 

  • 事件对象
    • 触发脚本(如调查时触发)
    • 自动脚本(唯一使用自动脚本的地方)
  • 场景
    • 进入场景时触发
    • 传送离开场景时触发(使用土灵珠[回城卷轴])
  • 装备道具
    • 使用时触发/装备时触发/投掷时触发
typedef struct {
  SHORT sVanishTime;
  WORD x, y;
  SHORT sLayer;
  WORD wTriggerScript;
  WORD wAutoScript;
  // ...
} EVENTOBJECT;

typedef struct {
  WORD wMapNum;
  WORD wScriptOnEnter;
  WORD wScriptOnTeleport;
  // ...
} SCENE;

typedef struct {
  WORD wBitmap;
  WORD wPrice;
  WORD wScriptOnUse;
  WORD wScriptOnEquip;
  WORD wScriptOnThrow;
  // ...
} OBJECT_ITEM;

从计算机系统视角看仙剑奇侠传

原来游戏内部也是一个层次化的系统!

花絮 - 民间版本

  • 2005年, 网友为纪念仙剑发行10年, 在原版的基础上进行修改
    • 包括剧情, 物品, 法术, 怪物等
    • 可玩性大幅提高, 吸引了不少老玩家
  • 不难理解, 只需修改资源文件和脚本即可实现
    • 另类的 “编程”, 无需修改游戏引擎

 

  • SDLPAL开源后, 也有网友对引擎本身进行改进
    • 战斗中显示数据, 为主角添加不同特性…
    • 修改队伍人数上限, 修改等级和属性上限…

 

  • 有玩家因为引擎, 资源文件, 存档三者版本不统一, 游戏无法正常进行
    • 引擎中的结构体多了一个成员, 数据就全乱了

总结

实践出真知

  • 说得再多, 还不如RTFSC
  • RTFSC再多, 还不如动手做PA3

 

做完PA3, 你就会对操作系统内部有一个简单的认识

 

仙剑其实也没那么神秘

  • 类似打字游戏的框架 + 类似NEMU的指令解释器

 

我们正是从这些 “类似”, 开始逐渐理解一个复杂系统