在操作系统上运行Hello World

成功运行dummy程序后, 我们已经把系统调用的整个流程都摸清楚了.

标准输出

Navy-apps中提供了一个hello测试程序(navy-apps/tests/hello), 它首先通过write()来输出一句话, 然后通过printf()来不断输出. 为了运行它, 我们只需要再实现SYS_write系统调用即可. 根据write的函数声明(参考man 2 write), 在do_syscall()中识别出系统调用号是SYS_write之后, 检查fd的值, 如果fd12(分别代表stdoutstderr), 则将buf为首地址的len字节输出到串口(使用_putc()即可). 最后还要设置正确的返回值, 否则系统调用的调用者会认为write没有成功执行, 从而进行重试. 至于write系统调用的返回值是什么, 请查阅man 2 write. 另外不要忘记在navy-apps/libs/libos/src/nanos.c_write()中调用系统调用接口函数.

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

并不是所有的库函数都封装了系统调用, 例如strcpy()这类字符串处理函数就不需要使用系统调用. 从某种程度上来说, 库函数的抽象确实方便了程序员, 使得他们不必关心系统调用的细节.

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

在Nanos-lite上运行Hello world

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

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

堆区管理

如果你在Nanos-lite中的sys_write()中通过Log()观察write系统调用的调用情况, 你会发现用户程序通过printf()输出的时候是逐个字符地调用write来输出的. 事实上, 用户程序在第一次调用printf()的时候会尝试通过malloc()申请一片缓冲区, 来存放格式化的内容. 若申请失败, 就会逐个字符进行输出.

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

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

void* sbrk(intptr_t increment);

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

在Navy-apps的Newlib中, sbrk()最终会调用_sbrk(), 它在navy-apps/libs/libos/src/nanos.c中定义. 框架代码让_sbrk()总是返回-1, 表示堆区调整失败, 于是printf()会认为无法在堆区中申请用于格式化的缓冲区, 只好逐个字符地输出. 但如果堆区总是不可用, Newlib中很多库函数的功能将无法使用, 因此现在你需要实现_sbrk()了. 为了实现_sbrk()的功能, 我们还需要提供一个用于设置堆区大小的系统调用. 在GNU/Linux中, 这个系统调用是SYS_brk, 它接收一个参数addr, 用于指示新的program break的位置. _sbrk()通过记录的方式来对用户程序的program break位置进行管理, 其工作方式如下:

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

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

实现堆区管理

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

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

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

缓冲区与系统调用开销

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

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

简易文件系统

我们的ramdisk已经提供了读写接口, 使得我们可以很方便地访问某一个位置的数据. 目前ramdisk中只有一个文件, 使用起来没什么繁琐的地方. 但如果文件的数量增加之后, 我们就要知道哪个文件在ramdisk的什么位置. 这对Nanos-lite来说貌似没什么困难的地方, 但对用户程序来说, 它怎么知道文件位于ramdisk的哪一个位置呢? 更何况文件会动态地增删, 用户程序并不知情. 这说明, 把ramdisk的读写接口直接提供给用户程序来使用是不可行的. 操作系统还需要在存储介质的驱动程序之上为用户程序提供一种更高级的抽象, 那就是文件.

文件的本质就是字节序列, 另外还由一些额外的属性构成. 在这里, 我们先讨论普通意义上的文件. 这样, 那些额外的属性就维护了文件到ramdisk存储位置的映射. 为了管理这些映射, 同时向上层提供文件操作的接口, 我们需要在Nanos-lite中实现一个文件系统.

不要被"文件系统"四个字吓到了, 我们对文件系统的需求并不是那么复杂:

  • 每个文件的大小是固定的
  • 写文件时不允许超过原有文件的大小
  • 文件的数量是固定的, 不能创建新文件
  • 没有目录

既然文件的数量和大小都是固定的, 我们自然可以把每一个文件分别固定在ramdisk中的某一个位置. 这些简化的特性大大降低了文件系统的实现难度. 当然, 真实的文件系统远远比这个简易文件系统复杂.

我们约定文件从ramdisk的最开始一个挨着一个地存放:

0
+-------------+---------+----------+-----------+--
|    file0    |  file1  |  ......  |   filen   |
+-------------+---------+----------+-----------+--
 \           / \       /            \         /
  +  size0  +   +size1+              + sizen +

为了记录ramdisk中各个文件的名字和大小, 我们还需要一张"文件记录表". Nanos-lite的Makefile已经提供了维护这些信息的脚本, 先对nanos-lite/Makefile作如下修改:

--- nanos-lite/Makefile
+++ nanos-lite/Makefile
@@ -34,2 +34,2 @@
-update: update-ramdisk-objcopy src/syscall.h
+update: update-ramdisk-fsimg src/syscall.h
  @touch src/initrd.S

然后运行make update就会自动编译Navy-apps里面的所有程序, 并把navy-apps/fsimg/目录下的所有内容整合成ramdisk镜像, 同时生成这个ramdisk镜像的文件记录表nanos-lite/src/files.h. 需要注意的是, 并不是Navy-apps里面的所有程序都能在Nanos-lite上运行, 有些程序需要更多系统调用的支持才能运行, 例如NWM和NTerm, 我们并不打算在PA中运行这些程序.

"文件记录表"其实是一个数组, 数组的每个元素都是一个结构体:

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

在我们的简易文件系统里面, 这三项信息都是固定不变的. 其中的文件名和我们平常使用的习惯不太一样: 由于我们的简易文件系统中没有目录, 我们把目录分隔符/也认为是文件名的一部分, 例如/bin/hello是一个完整的文件名. 这种做法其实也隐含了目录的层次结构, 对于文件数量不多的情况, 这种做法既简单又奏效.

有了这些信息, 就已经可以实现最基本的文件读写操作了:

ssize_t read(const char *filename, void *buf, size_t len);
ssize_t write(const char *filename, void *buf, size_t len);

但在真实的操作系统中, 这种直接用文件名来作为读写操作参数的做法却所有缺陷. 例如, 我们在用less工具浏览文件的时候:

cat file | less

cat工具希望把文件内容写到less工具的标准输入中, 但我们却无法用文件名来标识less工具的标准输入! 实际上, 操作系统中确实存在不少"没有名字"的文件. 为了统一管理它们, 我们希望通过一个编号来表示文件, 这个编号就是文件描述符(file descriptor). 一个文件描述符对应一个正在打开的文件, 由操作系统来维护文件描述符到具体文件的映射. 于是我们很自然地通过open()系统调用来打开一个文件, 并返回相应的文件描述符

int open(const char *pathname, int flags, int mode);

在Nanos-lite中, 由于简易文件系统中的文件数目是固定的, 我们可以简单地把文件记录表的下标作为相应文件的文件描述符返回给用户程序. 在这以后, 所有文件操作都通过文件描述符来标识文件:

ssize_t read(int fd, void *buf, size_t len);
ssize_t write(int fd, const void *buf, size_t len);
int close(int fd);

另外, 我们也不希望每次读写操作都需要从头开始. 于是我们需要为每一个已经打开的文件引入偏移量属性open_offset, 来记录目前文件操作的位置. 每次对文件读写了多少个字节, 偏移量就前进多少.

--- nanos-lite/src/fs.c
+++ nanos-lite/src/fs.c
@@ -3,5 +3,6 @@
 typedef struct {
   char *name;         // 文件名
   size_t size;        // 文件大小
   off_t disk_offset;  // 文件在ramdisk中的偏移
+  off_t open_offset;  // 文件被打开之后的读写指针
 } Finfo;

事实上在真正的操作系统中, 把偏移量放在文件记录表中维护会导致用户程序无法实现某些功能. 但解释这个问题需要理解一些超出课程范围的知识, 我们在此就不展开叙述了. 而且由于Nanos-lite是一个精简版的操作系统, 上述问题暂时不会出现, 为了简化实现, 我们还是把偏移量放在文件记录表中进行维护.

偏移量可以通过lseek()系统调用来调整:

off_t lseek(int fd, off_t offset, int whence);

为了方便用户程序进行标准输入输出, 操作系统准备了三个默认的文件描述符:

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

它们分别对应标准输入stdin, 标准输出stdout和标准错误stderr. 我们经常使用的printf, 最终会调用write(FD_STDOUT, buf, len)进行输出; 而scanf将会通过调用read(FD_STDIN, buf, len)进行读入.

nanos-lite/src/fs.c中定义的file_table会包含nanos-lite/src/files.h, 其中前面还有6个特殊的文件, 前三个分别是stdin, stdoutstderr的占位表项, 它们只是为了保证我们的简易文件系统和约定的标准输入输出的文件描述符保持一致, 例如根据约定stdout的文件描述符是1, 而我们添加了三个占位表项之后, 文件记录表中的1号下标也就不会分配给其它的普通文件了. 后面三个是特殊的文件, 我们会在后面来介绍它们, 目前可以先忽略它们.

根据以上信息, 我们就可以在文件系统中实现以下的文件操作了:

int fs_open(const char *pathname, int flags, int mode);
ssize_t fs_read(int fd, void *buf, size_t len);
ssize_t fs_write(int fd, const void *buf, size_t len);
off_t fs_lseek(int fd, off_t offset, int whence);
int fs_close(int fd);

这些文件操作实际上是相应的系统调用在内核中的实现. 你可以通过 man 查阅它们的功能, 例如

man 2 open

其中2表示查阅和系统调用相关的manual page. 实现这些文件操作的时候注意以下几点:

  • 由于简易文件系统中每一个文件都是固定的, 不会产生新文件, 因此"fs_open()没有找到pathname所指示的文件"属于异常情况, 你需要使用assertion终止程序运行.
  • 为了简化实现, 我们允许所有用户程序都可以对所有已存在的文件进行读写, 这样以后, 我们在实现fs_open()的时候就可以忽略flagsmode了.
  • 使用ramdisk_read()ramdisk_write()来进行文件的真正读写.
  • 由于文件的大小是固定的, 在实现fs_read(), fs_write()fs_lseek()的时候, 注意偏移量不要越过文件的边界.
  • 除了写入stdoutstderr之外(用_putc()输出到串口), 其余对于stdin, stdoutstderr这三个特殊文件的操作可以直接忽略.
  • 由于我们的简易文件系统没有维护文件打开的状态, fs_close()可以直接返回0, 表示总是关闭成功.

最后你还需要在Nanos-lite和Navy-apps的libos中添加相应的系统调用, 来调用相应的文件操作.

让loader使用文件

我们之前是让loader来直接调用ramdisk_read()来加载用户程序. ramdisk中的文件数量增加之后, 这种方式就不合适了, 我们首先需要让loader享受到文件系统的便利.

你需要先实现fs_open(), fs_read()fs_close(), 这样就可以在loader中使用文件名来指定加载的程序了, 例如"/bin/hello". 我们还需要让fs_read()知道文件的大小, 我们可以在文件系统中添加一个辅助函数

size_t fs_filesz(int fd);

它用于返回文件描述符fd所描述的文件的大小.

实现之后, 以后更换用户程序只需要修改传入loader()函数的文件名即可, 无需更新ramdisk的内容(除非ramdisk上的内容确实需要更新, 例如重新编译了Navy-apps的程序).

实现完整的文件系统

实现fs_write()fs_lseek(), 然后运行测试程序/bin/text. 这个测试程序用于进行一些简单的文件读写和定位操作. 如果你的实现正确, 你将会看到程序输出PASS!!!的信息.

温馨提示

PA3阶段2到此结束.

results matching ""

    No results matching ""