简易文件系统
要实现一个完整的批处理系统, 我们还需要向系统提供多个程序. 我们之前把程序以文件的形式存放在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
@@ -36,2 +36,2 @@
-update: update-ramdisk-single src/syscall.h
+update: update-ramdisk-fsimg src/syscall.h
@touch src/initrd.S
然后运行make clean
清除build/ramdisk.img
.
之后编译Nanos-lite就会自动编译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; // 文件大小
size_t disk_offset; // 文件在ramdisk中的偏移
} Finfo;
在我们的简易文件系统里面, 这三项信息都是固定不变的.
其中的文件名和我们平常使用的习惯不太一样:
由于我们的简易文件系统中没有目录, 我们把目录分隔符/
也认为是文件名的一部分,
例如/bin/hello
是一个完整的文件名.
这种做法其实也隐含了目录的层次结构,
对于文件数量不多的情况, 这种做法既简单又奏效.
有了这些信息, 就已经可以实现最基本的文件读写操作了:
size_t read(const char *filename, void *buf, size_t len);
size_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中, 由于简易文件系统中的文件数目是固定的, 我们可以简单地把文件记录表的下标作为相应文件的文件描述符返回给用户程序. 在这以后, 所有文件操作都通过文件描述符来标识文件:
size_t read(int fd, void *buf, size_t len);
size_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
@@ -6,4 +6,5 @@
typedef struct {
char *name; // 文件名
size_t size; // 文件大小
size_t disk_offset; // 文件在ramdisk中的偏移
+ size_t open_offset; // 文件被打开之后的读写指针
文件偏移量和用户程序
事实上在真正的操作系统中, 把偏移量放在文件记录表中维护会导致用户程序无法实现某些功能. 但解释这个问题需要理解一些超出课程范围的知识, 我们在此就不展开叙述了. 你可以在学习操作系统课程的时候再来思考这个问题.
由于Nanos-lite是一个精简版的操作系统, 上述问题暂时不会出现, 为了简化实现, 我们还是把偏移量放在文件记录表中进行维护.
偏移量可以通过lseek()
系统调用来调整:
size_t lseek(int fd, size_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
,
其中前面还有3个特殊的文件: stdin
, stdout
和stderr
的占位表项,
它们只是为了保证我们的简易文件系统和约定的标准输入输出的文件描述符保持一致,
例如根据约定stdout
的文件描述符是1
, 而我们添加了三个占位表项之后,
文件记录表中的1
号下标也就不会分配给其它的普通文件了.
根据以上信息, 我们就可以在文件系统中实现以下的文件操作了:
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. 实现这些文件操作的时候注意以下几点:
- 由于简易文件系统中每一个文件都是固定的, 不会产生新文件,
因此"
fs_open()
没有找到pathname
所指示的文件"属于异常情况, 你需要使用assertion终止程序运行. - 为了简化实现, 我们允许所有用户程序都可以对所有已存在的文件进行读写,
这样以后, 我们在实现
fs_open()
的时候就可以忽略flags
和mode
了. - 使用
ramdisk_read()
和ramdisk_write()
来进行文件的真正读写. - 由于文件的大小是固定的, 在实现
fs_read()
,fs_write()
和fs_lseek()
的时候, 注意偏移量不要越过文件的边界. - 除了写入
stdout
和stderr
之外(用_putc()
输出到串口), 其余对于stdin
,stdout
和stderr
这三个特殊文件的操作可以直接忽略. - 由于我们的简易文件系统没有维护文件打开的状态,
fs_close()
可以直接返回0
, 表示总是关闭成功.
最后你还需要在Nanos-lite和Navy-apps的libos中添加相应的系统调用, 来调用相应的文件操作.
让loader使用文件
我们之前是让loader来直接调用ramdisk_read()
来加载用户程序.
ramdisk中的文件数量增加之后, 这种方式就不合适了,
我们首先需要让loader享受到文件系统的便利.
你需要先实现fs_open()
, fs_read()
和fs_close()
,
这样就可以在loader中使用文件名来指定加载的程序了, 例如"/bin/hello".
实现之后, 以后更换用户程序只需要修改传入naive_uload()
函数的文件名即可.
实现完整的文件系统
实现fs_write()
和fs_lseek()
, 然后运行测试程序/bin/text
.
这个测试程序用于进行一些简单的文件读写和定位操作.
如果你的实现正确, 你将会看到程序输出PASS!!!
的信息.
一切皆文件
AM中的IOE向我们展现了程序进行输入输出的需求. 那么在Nanos-lite上, 如果用户程序想访问设备, 要怎么办呢? 一种最直接的方式, 就是让操作系统为每个设备单独提供一个系统调用, 用户程序通过这些系统调用, 就可以直接使用相应的功能了. 然而这种做法却存在不少问题:
- 首先, 设备的类型五花八门, 其功能更是数不胜数, 要为它们分别实现系统调用来给用户程序提供接口, 本身就已经缺乏可行性了;
- 此外, 由于设备的功能差别较大, 若提供的接口不能统一, 程序和设备之间的交互就会变得困难. 所以我们需要有一种方式对设备的功能进行抽象, 向用户程序提供统一的接口.
我们之前提到, 文件的本质就是字节序列. 事实上, 计算机系统中到处都是字节序列(如果只是无序的字节集合, 计算机要如何处理?), 我们可以轻松地举出很多例子:
- 内存是以字节编址的, 天然就是一个字节序列, 因而我们之前使用的ramdisk作为字节序列也更加显而易见了
- 管道(shell命令中的
|
)是一种先进先出的字节序列, 本质上它是内存中的一个队列缓冲区 - 磁盘也可以看成一个字节序列: 我们可以为磁盘上的每一个字节进行编号, 例如第x柱面第y磁头第z扇区中的第n字节, 把磁盘上的所有字节按照编号的大小进行排列, 便得到了一个字节序列
- socket(网络套接字)也是一种字节序列, 它有一个缓冲区, 负责存放接收到的网络数据包,
上层应用将socket中的内容看做是字节序列, 并通过一些特殊的文件操作来处理它们.
比如你之前使用的
qemu-diff
就是通过socket与QEMU进行通信的, 而操作socket的方式就是fgetc()
和fputc()
- 操作系统的一些信息可以以字节序列的方式暴露给用户, 例如CPU的配置信息
- 操作系统提供的一些特殊的功能, 如随机数生成器, 也可以看成一个无穷长的字节序列
- 甚至一些非存储类型的硬件也可以看成是字节序列: 我们在键盘上按顺序敲入按键的编码形成了一个字节序列, 显示器上每一个像素的内容按照其顺序也可以看做是字节序列...
既然文件就是字节序列, 那很自然地, 上面这些五花八门的字节序列应该都可以看成文件.
Unix就是这样做的, 因此有"一切皆文件"(Everything is a file)的说法.
这种做法最直观的好处就是为不同的事物提供了统一的接口:
我们可以使用文件的接口来操作计算机上的一切, 而不必对它们进行详细的区分: 例如
nanos-lite/Makefile
中通过管道把各个shell工具的输入输出连起来, 生成文件记录表
wc -c $(FSIMG_FILES) | grep -v 'total$$' | sed -e 's+ $(FSIMG_PATH)+ +' |
awk -v sum=0 '{print "\x7b\x22" $$2 "\x22\x2c " $$1 "\x2c " sum "\x7d\x2c";sum += $$1}' > src/files.h
以十六进制的方式查看磁盘上的内容
head -c 512 /dev/sda | hd
查看CPU是否有Meltdown漏洞
cat /proc/cpuinfo | grep 'meltdown'
而
#include "/dev/urandom"
则会将urandom设备中的内容包含到源文件中: 由于urandom设备是一个长度无穷的字节序列, 提交一个包含上述内容的程序源文件将会令一些检测功能不强的Online Judge平台直接崩溃.
"一切皆文件"的抽象使得我们可以通过标准工具很容易完成一些在Windows下不易完成的工作, 这其实体现了Unix哲学的部分内容: 每个程序采用文本文件作为输入输出, 这样可以使程序之间易于合作. GNU/Linux继承自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来描述了这些真实文件系统的抽象行为, 屏蔽了真实文件系统之间的差异, 上层模块(比如系统调用处理函数)不必关心当前操作的文件具体是什么类型, 只要调用这一组API即可完成相应的文件操作. 有了VFS的概念, 要添加一个真实文件系统就非常容易了: 只要把真实文件系统的访问方式包装成VFS的API, 上层模块无需修改任何代码, 就能支持一个新的真实文件系统了.
又来了
阅读上述文字的时候, 如果你想起了AM的概念, 这就对了, 因为VFS背后的思想, 也是抽象.
在Nanos-lite中, 实现VFS的关键就是Finfo
结构体中的两个读写函数指针:
typedef struct {
char *name; // 文件名
size_t size; // 文件大小
size_t disk_offset; // 文件在ramdisk中的偏移
size_t open_offset; // 文件被打开之后的读写指针
ReadFn read; // 读函数指针
WriteFn write; // 写函数指针
} Finfo;
其中ReadFn
和WriteFn
分别是两种函数指针, 它们用于指向真正进行读写的函数, 并返回成功读写的字节数.
有了这两个函数指针, 我们只需要在文件记录表中对不同的文件设置不同的读写函数,
就可以通过f->read()
和f->write()
的方式来调用具体的读写函数了.
用C语言模拟面向对象编程
VFS的实现展示了如何用C语言来模拟面向对象编程的一些基本概念: 例如通过结构体来实现类的定义, 结构体中的普通变量可以看作类的成员, 函数指针就可以看作类的方法, 给函数指针设置不同的函数可以实现方法的重载...
这说明, OOP中那些看似虚无缥缈的概念也没比C语言高级到哪里去, 只不过是OOP的编译器帮我们做了更多的事情, 编译成机器代码之后, OOP也就不存在了. Object-Oriented Programming With ANSI-C 这本书专门介绍了如何用ANSI-C来模拟OOP的各种概念和功能. 在GNU/Linux的内核代码中, 很多地方也有OOP的影子.
不过在Nanos-lite中, 由于特殊文件的数量很少, 我们约定, 当上述的函数指针为NULL
时,
表示相应文件是一个普通文件, 通过ramdisk的API来进行文件的读写,
这样我们就不需要为大多数的普通文件显式指定ramdisk的读写函数了.
操作系统之上的IOE
有了VFS, 要把IOE抽象成文件就非常简单了.
首先当然是来看最简单的输出设备: 串口.
在Nanos-lite中, stdout
和stderr
都会输出到串口.
之前你可能会通过判断fd
是否为1
或2
, 来决定sys_write()
是否写入到串口.
现在有了VFS, 我们就不需要让系统调用处理函数关心这些特殊文件的情况了:
我们只需要在nanos-lite/src/device.c
中实现serial_write()
,
然后在文件记录表中设置相应的写函数, 就可以实现上述功能了.
需要说明的是, serial_write()
中的offset
参数可以忽略,
因为对串口来说, offset
是没有意义的.
另外Nanos-lite也不打算支持stdin
的读入, 因此在文件记录表中设置相应的报错函数即可.
把串口抽象成文件
根据上述内容, 让VFS支持串口的写入.
输入设备有键盘和时钟, 它们对系统来说本质上就是到来了一个事件.
一种简单的方式是把事件以文本的形式表现出来, 我们定义以下事件, 一个事件以换行符\n
结束:
t 1234
: 返回系统启动后的时间, 单位为毫秒;kd RETURN
/ku A
: 按下/松开按键, 按键名称全部大写, 使用AM中定义的按键名
我们采用文本形式来描述事件有两个好处,
首先文本显然是一种字节序列, 这使得事件很容易抽象成文件;
此外文本方式使得用户程序可以容易可读地解析事件的内容.
Nanos-lite和Navy-apps约定, 上述事件抽象成文件/dev/events
,
它需要支持读操作, 用户程序可以从中一次读出一个输入事件.
需要注意的是, 由于时钟事件可以任意时刻进行读取,
我们需要优先处理按键事件, 当不存在按键事件的时候, 才返回时钟事件,
否则用户程序将永远无法读到按键事件.
把设备输入抽象成文件
你需要在Nanos-lite中
- 实现
events_read()
(在nanos-lite/src/device.c
中定义), 把事件写入到buf
中, 最长写入len
字节, 然后返回写入的实际长度. 其中按键名已经在字符串数组names
中定义好了. 你需要借助IOE的API来获得设备的输入. - 在VFS中添加对
/dev/events
的支持.
让Nanos-lite加载/bin/events
, 如果实现正确,
你会看到程序输出时间事件的信息, 敲击按键时会输出按键事件的信息.
最后是VGA, 程序为了更新屏幕, 只需要将像素信息写入VGA的显存即可.
于是, Nanos-lite需要做的, 便是把显存抽象成文件.
显存本身也是一段存储空间, 它以行优先的方式存储了将要在屏幕上显示的像素.
Nanos-lite和Navy-apps约定, 把显存抽象成文件/dev/fb
(fb为frame buffer之意),
它需要支持写操作和lseek
, 以便于用户程序把像素更新到屏幕的指定位置上.
除此之外, 我们还需要为用户程序提供刷新屏幕的接口.
Nanos-lite和Navy-apps约定, 这一刷新操作通过写入设备文件/dev/fbsync
来触发,
它只需要支持写操作, 每次写入时都会将frame buffer中的内容同步到屏幕.
最后, 用户程序还需要获得屏幕大小的信息, 然后才能决定如何更好地显示像素内容.
Nanos-lite和Navy-apps约定, 屏幕大小的信息通过/proc/dispinfo
文件来获得, 它需要支持读操作.
/proc/dispinfo
内容的一个例子如下:
WIDTH:640
HEIGHT:480
至于具体的屏幕大小, 你需要通过IOE的相应API来获取.
把VGA显存抽象成文件
你需要在Nanos-lite中:
- 在
init_fs()
(在nanos-lite/src/fs.c
中定义)中对文件记录表中/dev/fb
的大小进行初始化. - 实现
fb_write()
(在nanos-lite/src/device.c
中定义), 用于把buf
中的len
字节写到屏幕上offset
处. 你需要先从offset
计算出屏幕上的坐标, 然后调用IOE的draw_rect()
. - 实现
fbsync_write()
(在nanos-lite/src/device.c
中定义), 直接调用IOE的相应API即可. - 在
init_device()
(在nanos-lite/src/device.c
中定义)中将/proc/dispinfo
的内容提前写入到字符串dispinfo
中. - 实现
dispinfo_read()
(在nanos-lite/src/device.c
中定义), 用于把字符串dispinfo
中offset
开始的len
字节写到buf
中. - 在VFS中添加对
/dev/fb
,/dev/fbsync
和/proc/dispinfo
这三个特殊文件的支持.
让Nanos-lite加载/bin/bmptest
, 如果实现正确, 你将会看到屏幕上显示Project-N的logo.
为了方便用户程序的使用, Navy-apps把上述这些特殊文件的功能封装成NDL(NJU DirectMedia Layer)多媒体库,
具体的API请阅读navy-apps/libs/libndl/
目录下的代码.
有了NDL库, 用户程序就可以很方便地进行I/O操作了.