参考教材:
Operating Systems: Three Easy Pieces
Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau
在线阅读:
http://pages.cs.wisc.edu/~remzi/OSTEP/
University of Wisconsin Madison 教授 Remzi Arpaci-Dusseau 认为课本应该是免费的。
————————————————————————————————————————
这是专业必修课《操作系统原理》的复习指引。
在本文的最后附有复习指导的高清截图。需要掌握的概念在文档截图中以蓝色标识,并用可读性更好的字体显示 Linux 命令和代码。代码部分语法高亮。
操作系统原理不是语言课,本复习指导对用到的编程语言的语法的讲解也不会很细致。如果不知道代码中的一些关键字或函数的具体用法,你应该自行查找相关资料。
十三 文件和目录
1、文件(file)的本质是一个一维的字节数组,可以进行读写。每个文件都有一个低级名称(low-level name),通常是一个数字,用户一般不会注意到这个名称。由于历史原因,低级名称常被叫做索引节点号(inode number),这个将在未来的章节中学习。现在只需知道:每个文件都有一个索引节点号。
其实很多OS对文件的具体结构都不知道太多(比如这个文件是图片、文档还是C代码)。OS对文件的责任主要是存取数据到磁盘上,确保当你需要原先存储的数据时,能够将其正确读出来。
目录(directory)也是文件的一种,也称为目录文件(directory file)。目录包含了一系列有序对,有序对的两个元素分别是用户可读的名称(即文件名)和索引节点号。
2、可以用目录树(directory tree)来表示目录间的关系:目录中的目录在树上能够清晰地表示出来。
UNIX系统的目录树总是从根目录“/”开始,“/”符号也是目录树上相邻目录的分隔符。我们可以看到,从任意一个目录对应的节点向下走一步,就定位到了该目录的一个子目录或该目录包含的文件。设字符串的内容一开始为空,从目录树的根节点一路走下来,每向下走到一个非根节点就将该字符串先添加一个“/”,再添加走到的节点对应的目录名或文件名,直到找到所需的目录或文件。最后,这个字符串含有的内容就叫做找到的文件或目录的绝对路径名(absolute pathname),或绝对路径(absolute path)。
3、扩展名(extension)标示了一个文件的类型。但是标注扩展名并不是强制的。在UNIX中,访问磁盘、USB存储设备和光驱乃至许多其它设备都可以通过目录树。
4、运行下面的代码:
#include <fcntl.h>
int main() {
int fd = open(“foo”, O_CREAT);
return 0;
}
open()系统调用配合宏定义O_CREAT,在当前目录下创建一个文件。
open()返回的是文件描述符(file descriptor)。它是一个int型数据,为每个进程私有。文件描述符在UNIX系统中用于访问文件。一个文件打开后,可以用open()返回的文件描述符来对文件进行操作(前提是你有相应的权限)。文件描述符可以看作一个文件的指针。
UNIX系统内核中存在专门的结构来追踪每个进程已打开的文件。系统范围的已打开文件表(open file table)的每一项对应一个进程的这种结构。
5、在终端上依次输入:
echo hello>foo
sudo cat foo
sudo vim foo
可以看到,不但终端出现了输入的内容,文件中也出现了我们输入的内容。(提示输入管理员密码时,请输入超级用户的密码)
6、strace是一个很实用的工具。它可以追踪一个程序的系统调用,查看函数调用时传递的参数(实参)和返回值。在终端输入strace -h获得更多帮助。
7、打开文件后,使用系统调用lseek()可以重新定位文件中的偏移。在一个文件打开后,OS会追踪这个文件读写到什么位置。一次读取或写入n个字节后,这个偏移就增加n,下一次读写总是从这个偏移指向的位置进行。使用lseek可以将偏移定位到自己想要的位置。不过,lseek并不进行磁盘寻道,而只是修改OS追踪的偏移变量的值。只有在偏移指定的位置发起一次读写操作时,才会引发磁盘寻道(如果需要的话)。
lseek的参数如下:
__off_t lseek (int __fd, __off_t __offset, int __whence)
第一个参数是文件描述符,第二个参数是偏移,第三个参数有三种:
当设为SEEK_SET时,偏移设为第二个参数本身;当设为SEEK_CUR时,偏移设为当前的偏移加上第二个参数;当设为SEEK_END时,偏移设为文件的长度加上第二个参数。
8、系统调用和分别用于读写文件:
ssize_t read (int __fd, void *__buf, size_t __nbytes)
ssize_t write (int __fd, const void *__buf, size_t __n)
各个参数的作用已经非常明显了,这里不再赘述。
9、已打开文件表中,每一项都有一个成员:引用计数(reference count)。每多一个子进程使用同一文件,该计数就加1。
10、系统调用dup()(以及其变体dup2()、dup3())用于创建一个指向同一个文件的新的文件描述符。dup()在编写UNIX shell或者在执行输出重定向等操作时很有用。
11、调用write()后,数据不会被立刻写入到文件;但有时候要求立刻将还在缓冲区的待写入数据写入相应文件,这时候就要用到系统调用fsync()。待写入数据全部写入完毕后,fsync()才返回。
但是fsync不会写入对指向文件的目录项的修改,也就是说如果新创建了一个文件,要是确保下次能正确读出的话,就需要把所在目录也fsync一下。这一点常被忽略,从而引发了许多bug。
12、mv命令可以重命名或移动文件。这个操作是原子的。如果计算机在重命名期间断电了,那么被操作的文件要么被修改为新名称,要么仍然保持旧名称。这一点对要求原子更新操作的应用程序很有用。该命令调用系统调用rename()来完成重命名。但实际上rename()的执行过程是:将新文件写入指定位置,同时删除原文件。
13、除了访问文件以外,我们希望文件系统还能为每个文件记录一些有用的信息。这些信息称为元数据(metadata)。查看元数据可以使用系统调用stat()或fstat()。在命令行中也可以使用命令stat来查看元数据。
头文件<stat.h>中定义了stat和stat64结构体。篇幅所限,这里不予列出其定义。
不同的文件包括的元数据不一定是相同的。例如文档文件的元数据包含作者、标题、日期、关键字等;音乐文件的元数据包含标题、艺术家、专辑、流派、年代等。
我们在命令行中查看刚才创建的新文件的信息。输入:
stat foo
结果:
索引节点中,保存了一些文件信息;所有的索引节点都保存在硬盘上,当然有一些会被读入内存,以加快访问速率。
14、目录是不能直接写入的,因为目录的格式被认为是文件系统元数据的一部分。文件系统认为自己要对目录数据的完整性负责。如果要修改一个目录,只能间接通过创建文件、创建目录等方式。
创建目录使用系统调用mkdir()。终端亦可直接使用mkdir命令。
创建目录后,虽然这个目录包含了少量内容,但一般还是认为它是空的。用“.”代表当前目录,“…”代表上级目录。使用ls -a可以将当前目录下的文件连同目录本身及其上级目录一起列出。使用“ls -al”还可以将它们的常见信息也一并列写。
15、使用Linux时,一定要当心一些存在危险性的命令。例如:
rm *
会删除当前目录下的全部文件。
rm命令不会删除不为空的目录。但是如果要求递归删除(-r)、强制删除(-f),命令的危险性就比较大了。例如:
sudo rm -rf /*
将会删除整个计算机的全部文件和目录。
关于错误使用rm -rf的后果,大家可以在网络上查找相关的资料。
16、下面的代码展示了目录的常见系统调用的用法:
#include <dirent.h>
#include <unistd.h>
#include
#include
int main() {
DIR* dp = opendir(".");
assert(dp);
dirent* d;
while (d = readdir(dp)) {
printf("%lu %s\n", d->d_ino, d->d_name);
}
closedir(dp);
return 0;
}
结构体dirent和dirent64的定义如下:
struct dirent {
#ifndef __USE_FILE_OFFSET64
__ino_t d_ino;
__off_t d_off;
#else
__ino64_t d_ino;
__off64_t d_off;
#endif
unsigned short int d_reclen;
unsigned char d_type;
char d_name[256]; /* We must not include limits.h! */
};
#ifdef __USE_LARGEFILE64
struct dirent64 {
__ino64_t d_ino;
__off64_t d_off;
unsigned short int d_reclen;
unsigned char d_type;
char d_name[256]; /* We must not include limits.h! */
};
#endif
d_name、d_ino、d_off、d_reclen、d_type分别代表:
文件名、索引节点号、离下一个dirent的偏移、文件名长度、文件类型。
其实目录并不存储太多信息,而是提供一个到索引节点的映射。一个程序如需获得更多信息,就需要调用stat()。在使用ls命令的时候,-l参数就调用了这个系统调用。
17、rmdir系统调用(终端下为rmdir命令)用于删除目录。当然,如果目录非空,会拒绝删除。rm既可以删除文件也可以删除目录。
18、系统调用link()需要两个参数:旧路径和新路径。当你将新文件名链接到旧文件名时,就创建了另一个可以访问这个文件的方式。这样创建的链接称为硬链接(hard link)。
link()在你创建链接的目标目录创建一个新文件名,然后将其指向与原文件相同的索引节点号。被链接的文件不会被复制,不过现在可以通过两个文件名访问同一个文件了。对于文件系统来说,这两个文件名没有任何区别。
在命令行下执行:
ln foo foo2
ls -i foo foo2
可以看到foo和foo2的索引节点号都是一样的:
创建硬链接后,相应的索引节点号中的引用计数(链接数)会增加1。rm也可以移除硬链接,移除以后引用计数减去1。但是,除非引用计数降低到0,原文件不会删除。unlink也可以移除链接,但不能通过unlink删除目录。当删除文件时,rm和unlink的效果是完全一样的。
19、符号链接(symbolic link)也称软链接(soft link)。硬链接的使用有限制:不能为一个目录创建硬链接(否则目录树会出现回路);也不可以硬链接到位于其它磁盘分区的文件(因为索引节点号只在同一个文件系统的实例中有效,在其它文件系统的实例中无效)。而软链接不存在这些限制。
使用ln命令时,加上-s,就可以创建软链接。
但是软链接和硬链接不同。通过stat命令就可以看出来。在终端下输入:
touch test
ls
ln -s test test2
stat test
ls
stat test2
(touch命令用于修改文件或者目录的时间属性,包括存取时间和更改时间。若文件不存在,系统会建立一个新文件。)
结果:
硬链接是普通文件(regular file),但软链接与硬链接的文件类型是不同的。我们可以看到,空文件test的大小是0字节,但软链接test2的大小却是4字节。因为软链接保存了链接到的文件的路径。如果被链接的文件的路径(包括名称)很长,那么软链接的大小还会更大。
现在,我们将被链接到的文件test移除,然后再访问它的软链接test2。在终端下输入:
可以看到,原文件移除后,软链接就失效了。这称为悬挂引用(dangling reference)。
20、与内存不同,每个进程都具有自己单独的内存空间,但文件常常为大量程序所共享。因此,我们有必要使用新的机制来划定文件的访问权限。
通过ls -l可以查看文件的权限位(permission bits):
前面的“-rw-rw-r–”就是权限位的内容。第一位为“-”,代表其为普通文件(d为目录,l为软链接),与权限无关。剩下的9位分成3组:owner、group和other。每组的三位要么为“-”,要么为r(读)、w(写)或x(执行)。在本例中,后面的两个andy,第一个是所有者,第二个是组名,也就是group指代的名称。
通过命令chmod可以改变权限。输入:
chmod 600 demo.cpp
结果:
600是八进制数,转换成二进制是110 000 000,即rw-------。
如果一个可执行文件的执行位没有置位,那么试图执行该文件时将因为权限不足而被拒绝。对于目录,执行位的意义有一点不同:它允许用户(或组,或所有人)能够进行改变目录(cd)、在目录下创建文件等操作。关于权限位的作用,请大家多进行上机实验。
21、mount命令可以将其它文件系统挂载(mount)到指定的目录下。例如Android手机中,TF卡被挂载的位置为/storage/emulated/0。被挂载的位置称为挂载点(mount point)。通过挂载点可以访问被挂载的文件系统中的文件。
挂载的意义是使得不同的文件系统可以通过一个统一的目录树进行访问。
22、TOCTTOU(Time Of Check To Time Of Use)可以被用于攻击。举例:
设有一个邮件服务运行在root下。这个服务接收到了一封新邮件。经检查发现,这封邮件确实是属于收件人的普通文件,并不是重定向到邮件服务不应该更新的其它文件的链接,没有问题。所以,邮件服务决定将这封邮件正常投递至收件箱(即向对应的目录新增这个文件)。
但就在这时,攻击者设法获得了下一个时间片,CPU转而执行攻击者发出的rename()系统调用(或建立一个链接)。这个调用将收件箱指向了存放用户名及密码的文件夹(假设收件箱和保存密码的位置都已被攻击者确定)。如果邮件内容是一个新的具有最高权限的用户名及其密码,那么攻击者就获得了最高权限。他将可以通过这个权限用户执行特权级操作。
防范TOCTTOU攻击目前并没有普适的办法。常见的措施有:向open()传递一个参数O_NOFOLLOW,一旦尝试打开一个符号链接则立即拒绝。一些更激进的策略还有:使用事务型文件系统(transactional file system)、尽量减少拥有最高权限的程序数量。但事务型文件系统应用并不广泛,所以比较稳妥的办法大概也只有后者。
获得CPU时间片的方法主要有:文件系统迷惑(file system mazes)和算法复杂度攻击(algorithmic complexity attacks)等。文件系统迷惑是强制应用程序读取一个不在操作系统缓冲中的目录入口,于是操作系统可能将被攻击的应用程序置于睡眠状态(随后从磁盘中读取)。而算法复杂度攻击是强制应用程序浪费掉操作系统为它分配的CPU资源(使得攻击目标的优先级往下掉),例如攻击者可以创建一大堆hash值相同的文件,从而导致内核hash表的链非常长,然后让用户去索引相关的文件,耗费其资源(在拒绝服务的漏洞中也有这种攻击)。