内存映射
内核有两种类型的内存映射:共享型(shared)
和私有型(private)
。私有型是当进程为了只读文件,而不写文件时使用,这时,私有映射更加高效。但是,任何对私有映射页的写操作都会导致内核停止映射该文件中的页。所以,写操作既不会改变磁盘上的文件,对访问该文件的其它进程也是不可见的。
按需分页
一旦可执行映像被内存映射到虚拟内存后,它就可以被执行了。因为只将映像的开头部分物理的拉入到内存中,因此它将很快访问物理内存尚未存在的虚拟内存区域。当进程访问没有有效页表的虚拟地址时,操作系统会报告这项错误。
页面错误描述页面出错的虚拟地址和引起的内存访问(RAM)类型。
Linux 必须找到代表发生页面错误的内存区域的 vm_area_struct 结构。由于搜索 vm_area_struct 数据结构对于有效处理页面错误至关重要,因此它们以
AVL(Adelson-Velskii和Landis)
树结构链接在一起。如果引起故障的虚拟地址没有 vm_area_struct
结构,则此进程已经访问了非法地址,Linux 会向进程发出 SIGSEGV
信号,如果进程没有用于该信号的处理程序,那么进程将会终止。
然后,Linux 会针对此虚拟内存区域所允许的访问类型,检查发生的页面错误类型。如果该进程以非法方式访问内存,例如写入仅允许读的区域,则还会发出内存访问错误信号。
现在,Linux 已确定页面错误是合法的,因此必须对其进行处理。
文件系统
在 Linux 中,最直观、最可见的部分就是 文件系统(file system)
。下面我们就来一起探讨一下关于 Linux 中国的文件系统,系统调用以及文件系统实现背后的原理和思想。这些思想中有一些来源于 MULTICS,现在已经被 Windows 等其他操作系统使用。Linux 的设计理念就是 小的就是好的(Small is Beautiful)
。虽然 Linux 只是使用了最简单的机制和少量的系统调用,但是 Linux 却提供了强大而优雅的文件系统。
Linux 文件系统基本概念
Linux 在最初的设计是 MINIX1 文件系统,它只支持 14 字节的文件名,它的最大文件只支持到 64 MB。在 MINIX 1 之后的文件系统是 ext 文件系统。ext 系统相较于 MINIX 1 来说,在支持字节大小和文件大小上均有很大提升,但是 ext 的速度仍没有 MINIX 1 快,于是,ext 2 被开发出来,它能够支持长文件名和大文件,而且具有比 MINIX 1 更好的性能。这使他成为 Linux 的主要文件系统。只不过 Linux 会使用 VFS
曾支持多种文件系统。在 Linux 链接时,用户可以动态的将不同的文件系统挂载倒 VFS 上。
Linux 中的文件是一个任意长度的字节序列,Linux 中的文件可以包含任意信息,比如 ASCII 码、二进制文件和其他类型的文件是不加区分的。
为了方便起见,文件可以被组织在一个目录中,目录存储成文件的形式在很大程度上可以作为文件处理。目录可以有子目录,这样形成有层次的文件系统,Linux 系统下面的根目录是 /
,它通常包含了多个子目录。字符 /
还用于对目录名进行区分,例如 「/usr/cxuan」 表示的就是根目录下面的 usr 目录,其中有一个叫做 cxuan 的子目录。
下面我们介绍一下 Linux 系统根目录下面的目录名
/bin
,它是重要的二进制应用程序,包含二进制文件,系统的所有用户使用的命令都在这里/boot
,启动包含引导加载程序的相关文件/dev
,包含设备文件,终端文件,USB 或者连接到系统的任何设备/etc
,配置文件,启动脚本等,包含所有程序所需要的配置文件,也包含了启动/停止单个应用程序的启动和关闭 shell 脚本/home
,本地主要路径,所有用户用 home 目录存储个人信息/lib
,系统库文件,包含支持位于 /bin 和 /sbin 下的二进制库文件/lost+found
,在根目录下提供一个遗失+查找系统,必须在 root 用户下才能查看当前目录下的内容/media
,挂载可移动介质/mnt
,挂载文件系统/opt
,提供一个可选的应用程序安装目录/proc
,特殊的动态目录,用于维护系统信息和状态,包括当前运行中进程信息/root
,root 用户的主要目录文件夹/sbin
,重要的二进制系统文件/tmp
, 系统和用户创建的临时文件,系统重启时,这个目录下的文件都会被删除/usr
,包含绝大多数用户都能访问的应用程序和文件/var
,经常变化的文件,诸如日志文件或数据库等
在 Linux 中,有两种路径,一种是 绝对路径(absolute path)
,绝对路径告诉你从根目录下查找文件,绝对路径的缺点是太长而且不太方便。还有一种是 相对路径(relative path)
,相对路径所在的目录也叫做工作目录(working directory)
。
如果 /usr/local/books
是工作目录,那么 shell 命令
cp books books-replica
就表示的是相对路径,而
cp /usr/local/books/books /usr/local/books/books-replica
则表示的是绝对路径。
在 Linux 中经常出现一个用户使用另一个用户的文件或者使用文件树结构中的文件。两个用户共享同一个文件,这个文件位于某个用户的目录结构中,另一个用户需要使用这个文件时,必须通过绝对路径才能引用到他。如果绝对路径很长,那么每次输入起来会变的非常麻烦,所以 Linux 提供了一种 链接(link)
机制。
举个例子,下面是一个使用链接之前的图
以上所示,比如有两个工作账户 jianshe 和 cxuan,jianshe 想要使用 cxuan 账户下的 A 目录,那么它可能会输入 /usr/cxuan/A
,这是一种未使用链接之后的图。
使用链接后的示意如下
现在,jianshe 可以创建一个链接来使用 cxuan 下面的目录了。‘
当一个目录被创建出来后,有两个目录项也同时被创建出来,它们就是 .
和 ..
,前者代表工作目录自身,后者代表该目录的父目录,也就是该目录所在的目录。这样一来,在 /usr/jianshe 中访问 cxuan 中的目录就是 ../cxuan/xxx
Linux 文件系统不区分磁盘的,这是什么意思呢?一般来说,一个磁盘中的文件系统相互之间保持独立,如果一个文件系统目录想要访问另一个磁盘中的文件系统,在 Windows 中你可以像下面这样。
两个文件系统分别在不同的磁盘中,彼此保持独立。
而在 Linux 中,是支持挂载
的,它允许一个磁盘挂在到另外一个磁盘上,那么上面的关系会变成下面这样
挂在之后,两个文件系统就不再需要关心文件系统在哪个磁盘上了,两个文件系统彼此可见。
Linux 文件系统的另外一个特性是支持 加锁(locking)
。在一些应用中会出现两个或者更多的进程同时使用同一个文件的情况,这样很可能会导致竞争条件(race condition)
。一种解决方法是对其进行加不同粒度的锁,就是为了防止某一个进程只修改某一行记录从而导致整个文件都不能使用的情况。
POSIX 提供了一种灵活的、不同粒度级别的锁机制,允许一个进程使用一个不可分割的操作对一个字节或者整个文件进行加锁。加锁机制要求尝试加锁的进程指定其 「要加锁的文件,开始位置以及要加锁的字节」
Linux 系统提供了两种锁:「共享锁和互斥锁」。如果文件的一部分已经加上了共享锁,那么再加排他锁是不会成功的;如果文件系统的一部分已经被加了互斥锁,那么在互斥锁解除之前的任何加锁都不会成功。为了成功加锁、请求加锁的部分的所有字节都必须是可用的。
在加锁阶段,进程需要设计好加锁失败后的情况,也就是判断加锁失败后是否选择阻塞,如果选择阻塞式,那么当已经加锁的进程中的锁被删除时,这个进程会解除阻塞并替换锁。如果进程选择非阻塞式的,那么就不会替换这个锁,会立刻从系统调用中返回,标记状态码表示是否加锁成功,然后进程会选择下一个时间再次尝试。
加锁区域是可以重叠的。下面我们演示了三种不同条件的加锁区域。
Linux 文件系统调用
许多系统调用都会和文件与文件系统有关。我们首先先看一下对单个文件的系统调用,然后再来看一下对整个目录和文件的系统调用。
为了创建一个新的文件,会使用到 creat
方法,注意没有 e
。
这个系统调用的两个参数是文件名和保护模式
fd = creat("aaa",mode);
这段命令会创建一个名为 aaa 的文件,并根据 mode 设置文件的保护位。这些位决定了哪个用户可能访问文件、如何访问。
creat 系统调用不仅仅创建了一个名为 aaa 的文件,还会打开这个文件。为了允许后续的系统调用访问这个文件,这个 creat 系统调用会返回一个 非负整数
, 这个就叫做 文件描述符(file descriptor)
,也就是上面的 fd。
如果在已经存在的文件上调用了 creat 系统调用,那么该文件中的内容会被清除,从 0 开始。通过设置合适的参数,open
系统调用也能够创建文件。
下面让我们看一看主要的系统调用,如下表所示
系统调用 | 描述 |
fd = creat(name,mode) | 一种创建一个新文件的方式 |
fd = open(file, ...) | 打开文件读、写或者读写 |
s = close(fd) | 关闭一个打开的文件 |
n = read(fd, buffer, nbytes) | 从文件中向缓存中读入数据 |
n = write(fd, buffer, nbytes) | 从缓存中向文件中写入数据 |
position = lseek(fd, offset, whence) | 移动文件指针 |
s = stat(name, &buf) | 获取文件信息 |
s = fstat(fd, &buf) | 获取文件信息 |
s = pipe(&fd[0]) | 创建一个管道 |
s = fcntl(fd,...) | 文件加锁等其他操作 |
为了对一个文件进行读写的前提是先需要打开文件,必须使用 creat 或者 open 打开,参数是打开文件的方式,是只读、可读写还是只写。open 系统调用也会返回文件描述符。打开文件后,需要使用 close
系统调用进行关闭。close 和 open 返回的 fd 总是未被使用的最小数量。
当程序要求打开一个文件时,内核会进行如下操作
- 授予访问权限
- 在
全局文件表(global file table)
中创建一个条目(entry)
- 向软件提供条目的位置
文件描述符由唯一的非负整数组成,系统上每个打开的文件至少存在一个文件描述符。文件描述符最初在 Unix 中使用,并且被包括 Linux,macOS 和 BSD 在内的现代操作系统所使用。
当一个进程成功访问一个打开的文件时,内核会返回一个文件描述符,这个文件描述符指向全局文件表的 entry 项。这个文件表项包含文件的 inode 信息,字节位移,访问限制等。例如下图所示
默认情况下,前三个文件描述符为 STDIN(标准输入)
、STDOUT(标准输出)
、STDERR(标准错误)
。
标准输入的文件描述符是 0 ,在终端中,默认为用户的键盘输入
标准输出的文件描述符是 1 ,在终端中,默认为用户的屏幕
与错误有关的默认数据流是 2,在终端中,默认为用户的屏幕。
在简单聊了一下文件描述符后,我们继续回到文件系统调用的探讨。
在文件系统调用中,开销最大的就是 read 和 write 了。read 和 write 都有三个参数
文件描述符
:告诉需要对哪一个打开文件进行读取和写入缓冲区地址
:告诉数据需要从哪里读取和写入哪里统计
:告诉需要传输多少字节
这就是所有的参数了,这个设计非常简单轻巧。
虽然几乎所有程序都按顺序读取和写入文件,但是某些程序需要能够随机访问文件的任何部分。与每个文件相关联的是一个指针,该指针指示文件中的当前位置。顺序读取(或写入)时,它通常指向要读取(写入)的下一个字节。如果指针在读取 1024 个字节之前位于 4096 的位置,则它将在成功读取系统调用后自动移至 5120 的位置。
Lseek
系统调用会更改指针位置的值,以便后续对 read 或 write 的调用可以在文件中的任何位置开始,甚至可以超出文件末尾。
lseek = Lseek ,段首大写。
lseek 避免叫做 seek 的原因就是 seek 已经在之前 16 位的计算机上用于搜素功能了。
Lseek
有三个参数:第一个是文件的文件描述符,第二个是文件的位置;第三个告诉文件位置是相对于文件的开头,当前位置还是文件的结尾
lseek(int fildes, off_t offset, int whence);
lseek 的返回值是更改文件指针后文件中的绝对位置。lseek 是唯一从来不会造成真正磁盘查找的系统调用,它只是更新当前的文件位置,这个文件位置就是内存中的数字。
对于每个文件,Linux 都会跟踪文件模式(常规,目录,特殊文件),大小,最后修改时间以及其他信息。程序能够通过 stat
系统调用看到这些信息。第一个参数就是文件名,第二个是指向要放置请求信息结构的指针。这些结构的属性如下图所示。
存储文件的设备 |
存储文件的设备 |
i-node 编号 |
文件模式(包括保护位信息) |
文件链接的数量 |
文件所有者标识 |
文件所属的组 |
文件大小(字节) |
创建时间 |
最后一个修改/访问时间 |
fstat
调用和 stat
相同,只有一点区别,fstat 可以对打开文件进行操作,而 stat 只能对路径进行操作。
pipe
文件系统调用被用来创建 shell 管道。它会创建一系列的伪文件
,来缓冲和管道组件之间的数据,并且返回读取或者写入缓冲区的文件描述符。在管道中,像是如下操作
sort <in | head –40
sort 进程将会输出到文件描述符1,也就是标准输出,写入管道中,而 head 进程将从管道中读入。在这种方式中,sort 只是从文件描述符 0 中读取并写入到文件描述符 1 (管道)中,甚至不知道它们已经被重定向了。如果没有重定向的话,sort 会自动的从键盘读入并输出到屏幕中。
最后一个系统调用是 fcntl
,它用来锁定和解锁文件,应用共享锁和互斥锁,或者是执行一些文件相关的其他操作。
现在我们来关心一下和整体目录和文件系统相关的系统调用,而不是把精力放在单个的文件上,下面列出了这些系统调用,我们一起来看一下。
系统调用 | 描述 |
s = mkdir(path,mode) | 创建一个新的目录 |
s = rmdir(path) | 移除一个目录 |
s = link(oldpath,newpath) | 创建指向已有文件的链接 |
s = unlink(path) | 取消文件的链接 |
s = chdir(path) | 改变工作目录 |
dir = opendir(path) | 打开一个目录读取 |
s = closedir(dir) | 关闭一个目录 |
dirent = readdir(dir) | 读取一个目录项 |
rewinddir(dir) | 回转目录使其在此使用 |
可以使用 mkdir 和 rmdir 创建和删除目录。但是需要注意,只有目录为空时才可以删除。
创建一个指向已有文件的链接时会创建一个目录项(directory entry)
。系统调用 link 来创建链接,oldpath 代表已有的路径,newpath 代表需要链接的路径,使用 unlink
可以删除目录项。当文件的最后一个链接被删除时,这个文件会被自动删除。
使用 chdir
系统调用可以改变工作目录。
最后四个系统调用是用于读取目录的。和普通文件类似,他们可以被打开、关闭和读取。每次调用 readdir
都会以固定的格式返回一个目录项。用户不能对目录执行写操作,但是可以使用 creat 或者 link 在文件夹中创建一个目录,或使用 unlink 删除一个目录。用户不能在目录中查找某个特定文件,但是可以使用 rewindir
作用于一个打开的目录,使他能在此从头开始读取。
Linux 文件系统的实现
下面我们主要讨论一下 虚拟文件系统(Virtual File System)
。VFS 对高层进程和应用程序隐藏了 Linux 支持的所有文件系统的区别,以及文件系统是存储在本地设备,还是需要通过网络访问远程设备。设备和其他特殊文件和 VFS 层相关联。接下来,我们就会探讨一下第一个 Linux 广泛传播的文件系统:ext2
。随后,我们就会探讨 ext4
文件系统所做的改进。各种各样的其他文件系统也正在使用中。所有 Linux 系统都可以处理多个磁盘分区,每个磁盘分区上都有不同的文件系统。
Linux 虚拟文件系统
为了能够使应用程序能够在不同类型的本地或者远程设备上的文件系统进行交互,因为在 Linux 当中文件系统千奇百种,比较常见的有 EXT3、EXT4,还有基于内存的 ramfs、tmpfs 和基于网络的 nfs,和基于用户态的 fuse,当然 fuse 应该不能完全的文件系统,只能算是一个能把文件系统实现放到用户态的模块,满足了内核文件系统的接口,他们都是文件系统的一种实现。对于这些文件系统,Linux 做了一层抽象就是 VFS
虚拟文件系统,
下表总结了 VFS 支持的四个主要的文件系统结构。
对象 | 描述 |
超级块 | 特定的文件系统 |
Dentry | 目录项,路径的一个组成部分 |
I-node | 特定的文件 |
File | 跟一个进程相关联的打开文件 |
超级块(superblock)
包含了有关文件系统布局的重要信息,超级块如果遭到破坏那么就会导致整个文件系统不可读。
i-node
索引节点,包含了每一个文件的描述符。
超级块和索引块所在的文件系统都在磁盘上有对应的结构。
为了便于某些目录操作和路径遍历,比如 /usr/local/cxuan,VFS 支持一个 dentry
数据结构,该数据结构代表着目录项。这个 dentry 数据结构有很多东西(http://books.gigatux.nl/mirror/kerneldevelopment/0672327201/ch12lev1sec7.html)这个数据结构由文件系统动态创建。
目录项被缓存在 dentry_cache
缓存中。例如,缓存条目会缓存 /usr 、 /usr/local 等条目。如果多个进程通过硬连接访问相同的文件,他们的文件对象将指向此缓存中的相同条目。
最后,文件数据结构是代表着打开的文件,也代表着内存表示,它根据 open 系统调用创建。它支持 「read、write、sendfile、lock」 和其他在我们之前描述的系统调用中。
在 VFS 下实现的实际文件系统不需要在内部使用完全相同的抽象和操作。但是,它们必须在语义上实现与 VFS 对象指定的文件系统操作相同的文件系统操作。四个 VFS 对象中每个对象的操作数据结构的元素都是指向基础文件系统中功能的指针。
Linux Ext2 文件系统
现在我们一起看一下 Linux 中最流行的一个磁盘文件系统,那就是 ext2
。Linux 的第一个版本用于 MINIX1
文件系统,它的文件名大小被限制为最大 64 MB。MINIX 1 文件系统被永远的被它的扩展系统 ext 取代,因为 ext 允许更长的文件名和文件大小。由于 ext 的性能低下,ext 被其替代者 ext2 取代,ext2 目前仍在广泛使用。
一个 ext2 Linux 磁盘分区包含了一个文件系统,这个文件系统的布局如下所示
Boot 块也就是第 0 块不是让 Linux 使用的,而是用来加载和引导计算机启动代码的。在块 0 之后,磁盘分区被分成多个组,这些组与磁盘柱面边界所处的位置无关。
第一个块是 超级块(superblock)
。它包含有关文件系统布局的信息,包括 i-node、磁盘块数量和以及空闲磁盘块列表的开始。下一个是 组描述符(group descriptor)
,其中包含有关位图的位置,组中空闲块和 i-node 的数量以及组中的目录数量的信息。这些信息很重要,因为 ext2 会在磁盘上均匀分布目录。
图中的两个位图用来记录空闲块和空闲 i-node,这是从 MINIX 1文件系统继承的选择,大多数 UNIX 文件系统使用位图而不是空闲列表。每个位图的大小是一个块。如果一个块的大小是 1 KB,那么就限制了块组的数量是 8192 个块和 8192 个 i-node。块的大小是一个严格的限制,块组的数量不固定,在 4KB 的块中,块组的数量增大四倍。
在超级块之后分布的是 i-node
它们自己,i-node 取值范围是 1 - 某些最大值。每个 i-node 是 128 字节的 long
,这些字节恰好能够描述一个文件。i-node 包含了统计信息(包含了 stat
系统调用能获得的所有者信息,实际上 stat 就是从 i-node 中读取信息的),以及足够的信息来查找保存文件数据的所有磁盘块。
在 i-node 之后的是 数据块(data blocks)
。所有的文件和目录都保存在这。如果一个文件或者目录包含多个块,那么这些块在磁盘中的分布不一定是连续的,也有可能不连续。事实上,大文件块可能会被拆分成很多小块散布在整个磁盘上。
对应于目录的 i-node 分散在整个磁盘组上。如果有足够的空间,ext2 会把普通文件组织到与父目录相同的块组中,而把同一块上的数据文件组织成初始 i-node
节点。位图用来快速确定新文件系统数据的分配位置。在分配新的文件块时,ext2 也会给该文件预分配许多额外的数据块,这样可以减少将来向文件写入数据时产生的文件碎片。这种策略在整个磁盘上实现了文件系统的 负载
,后续还有对文件碎片的排列和整理,而且性能也比较好。
为了达到访问的目的,需要首先使用 Linux 系统调用,例如 open
,这个系统调用会确定打开文件的路径。路径分为两种,相对路径
和 绝对路径
。如果使用相对路径,那么
就会从当前目录开始查找,否则就会从根目录进行查找。
目录文件的文件名最高不能超过 255 个字符,它的分配如下图所示
每一个目录都由整数个磁盘块组成,这样目录就可以整体的写入磁盘。在一个目录中,文件和子目录的目录项都是未经排序的,并且一个挨着一个。目录项不能跨越磁盘块,所以通常在每个磁盘块的尾部会有部分未使用的字节。
上图中每个目录项都由四个固定长度的属性和一个长度可变的属性组成。第一个属性是 i-node
节点数量,文件 first 的 i-node 编号是 19 ,文件 second 的编号是 42,目录 third 的 i-node 编号是 88。紧随其后的是 rec_len
域,表明目录项大小是多少字节,名称后面会有一些扩展,当名字以未知长度填充时,这个域被用来寻找下一个目录项,直至最后的未使用。这也是图中箭头的含义。紧随其后的是 类型域
:F 表示的是文件,D 表示的是目录,最后是固定长度的文件名,上面的文件名的长度依次是 5、6、5,最后以文件名结束。
rec_len 域是如何扩展的呢?如下图所示
我们可以看到,中间的 second
被移除了,所以将其所在的域变为第一个目录项的填充。当然,这个填充可以作为后续的目录项。
由于目录是按照线性的顺序进行查找的,因此可能需要很长时间才能在大文件末尾找到目录项。因此,系统会为近期的访问目录维护一个缓存。这个缓存用文件名来查找,如果缓存命中,那么就会避免线程搜索这样昂贵的开销。组成路径的每个部分都在目录缓存中保存一个 dentry
对象,并且通过 i-node 找到后续的路径元素的目录项,直到找到真正的文件 i - node。
比如说要使用绝对路径来寻找一个文件,我们暂定这个路径是 /usr/local/file
,那么需要经过如下几个步骤:
- 首先,系统会确定根目录,它通常使用 2 号 i -node ,也就是索引 2 节点,因为索引节点 1 是 ext2 /3/4 文件系统上的
坏块
索引节点。系统会将一项放在 dentry 缓存中,以应对将来对根目录的查找。 - 然后,在根目录中查找字符串
usr
,得到 /usr 目录的 i - node 节点号。/usr 的 i - node 同样也进入 dentry 缓存。然后节点被取出,并从中解析出磁盘块,这样就可以读取 /usr 目录并查找字符串local
了。一旦找到这个目录项,目录/usr/local
的 i - node 节点就可以从中获得。有了 /usr/local 的 i - node 节点号,就可以读取 i - node 并确定目录所在的磁盘块。最后,从 /usr/local 目录查找 file 并确定其 i - node 节点呢号。
如果文件存在,那么系统会提取 i - node 节点号并把它作为索引在 i - node 节点表中定位相应的 i - node 节点并装入内存。i - node 被存放在 i - node 节点表(i-node table)
中,节点表是一个内核数据结构,它会持有当前打开文件和目录的 i - node 节点号。下面是一些 Linux 文件系统支持的 i - node 数据结构。
属性 | 字节 | 描述 |
Mode | 2 | 文件属性、保护位、setuid 和 setgid 位 |
Nlinks | 2 | 指向 i - node 节点目录项的数目 |
Uid | 2 | 文件所有者的 UID |
Gid | 2 | 文件所有者的 GID |
Size | 4 | 文件字节大小 |
Addr | 60 | 12 个磁盘块以及后面 3 个间接块的地址 |
Gen | 1 | 每次重复使用 i - node 时增加的代号 |
Atime | 4 | 最近访问文件的时间 |
Mtime | 4 | 最近修改文件的时间 |
Ctime | 4 | 最近更改 i - node 的时间 |
现在我们来一起探讨一下文件读取过程,还记得 read
函数是如何调用的吗?
n = read(fd,buffer,nbytes);
当内核接管后,它会从这三个参数以及内部表与用户有关的信息开始。内部表的其中一项是文件描述符数组。文件描述符数组用文件描述符
作为索引并为每一个打开文件保存一个表项。
文件是和 i - node 节点号相关的。那么如何通过一个文件描述符找到文件对应的 i - node 节点呢?
这里使用的一种设计思想是在文件描述符表和 i - node 节点表之间插入一个新的表,叫做 打开文件描述符(open-file-description table)
。文件的读写位置会在打开文件描述符表中存在,如下图所示
我们使用 shell 、P1 和 P2 来描述一下父进程、子进程、子进程的关系。Shell 首先生成 P1,P1 的数据结构就是 Shell 的一个副本,因此两者都指向相同的打开文件描述符的表项。当 P1 运行完成后,Shell 的文件描述符仍会指向 P1 文件位置的打开文件描述。然后 Shell 生成了 P2,新的子进程自动继承文件的读写位置,甚至 P2 和 Shell 都不知道文件具体的读写位置。
上面描述的是父进程和子进程这两个 相关
进程,如果是一个不相关进程打开文件时,它将得到自己的打开文件描述符表项,以及自己的文件读写位置,这是我们需要的。
因此,打开文件描述符相当于是给相关进程提供同一个读写位置,而给不相关进程供各自私有的位置。
i - node 包含三个间接块的磁盘地址,它们每个指向磁盘块的地址所能够存储的大小不一样。
Linux Ext4 文件系统
为了防止由于系统崩溃和电源故障造成的数据丢失,ext2 系统必须在每个数据块创建之后立即将其写入到磁盘上,磁盘磁头寻道操作导致的延迟是无法让人忍受的。为了增强文件系统的健壮性,Linux 依靠日志文件系统
,ext3 是一个日志文件系统,它在 ext2 文件系统的基础之上做了改进,ext4 也是 ext3 的改进,ext4 也是一个日志文件系统。ext4 改变了 ext3 的块寻址方案,从而支持更大的文件和更大的文件系统大小。下面我们就来描述一下 ext4 文件系统的特性。
具有记录的文件系统最基本的功能就是记录日志
,这个日志记录了按照顺序描述所有文件系统的操作。通过顺序写出文件系统数据或元数据的更改,操作不受磁盘访问期间磁盘头移动的开销。最终,这个变更会写入并提交到合适的磁盘位置上。如果这个变更在提交到磁盘前文件系统宕机了,那么在重启期间,系统会检测到文件系统未正确卸载,那么就会遍历日志并应用日志的记录来对文件系统进行更改。
Ext4 文件系统被设计用来高度匹配 ext2 和 ext3 文件系统的,尽管 ext4 文件系统在内核数据结构和磁盘布局上都做了变更。尽管如此,一个文件系统能够从 ext2 文件系统上卸载后成功的挂载到 ext4 文件系统上,并提供合适的日志记录。
日志是作为循环缓冲区管理的文件。日志可以存储在与主文件系统相同或者不同的设备上。日志记录的读写操作会由单独的 JBD(Journaling Block Device)
来扮演。
JBD 中有三个主要的数据结构,分别是 「log record(日志记录)、原子操作和事务」。
一个日志记录描述了一个低级别的文件系统操作,这个操作通常导致块内的变化。因为像是 write
这种系统调用会包含多个地方的改动 --- i - node 节点,现有的文件块,新的文件块和空闲列表等。相关的日志记录会以原子性的方式分组。ext4 会通知系统调用进程的开始和结束,以此使 JBD 能够确保原子操作的记录都能被应用,或者一个也不被应用。最后,主要从效率方面考虑,JBD 会视原子操作的集合为事务。一个事务中的日志记录是连续存储的。只有在所有的变更一起应用到磁盘后,日志记录才能够被丢弃。
由于为每个磁盘写出日志的开销会很大,所以 ext4 可以配置为保留所有磁盘更改的日志,或者仅仅保留与文件系统元数据相关的日志更改。仅仅记录元数据可以减少系统开销,提升性能,但不能保证不会损坏文件数据。其他的几个日志系统维护着一系列元数据操作的日志,例如 SGI 的 XFS。
/proc 文件系统
另外一个 Linux 文件系统是 /proc
(process) 文件系统
它的主要思想来源于贝尔实验室开发的第 8 版的 UNIX,后来被 BSD 和 System V 采用。
然而,Linux 在一些方面上对这个想法进行了扩充。它的基本概念是为系统中的每个进程在 /proc
中创建一个目录。目录的名字就是进程 PID,以十进制数进行表示。例如,/proc/1024
就是一个进程号为 1024 的目录。在该目录下是进程信息相关的文件,比如进程的命令行、环境变量和信号掩码等。事实上,这些文件在磁盘上并不存在磁盘中。当需要这些信息的时候,系统会按需从进程中读取,并以标准格式返回给用户。
许多 Linux 扩展与 /proc
中的其他文件和目录有关。它们包含各种各样的关于 CPU、磁盘分区、设备、中断向量、内核计数器、文件系统、已加载模块等信息。非特权用户可以读取很多这样的信息,于是就可以通过一种安全的方式了解系统情况。
NFS 网络文件系统
从一开始,网络就在 Linux 中扮演了很重要的作用。下面我们会探讨一下 NFS(Network File System)
网络文件系统,它在现代 Linux 操作系统的作用是将不同计算机上的不同文件系统链接成一个逻辑整体。
NFS 架构
NFS 最基本的思想是允许任意选定的一些客户端
和服务器
共享一个公共文件系统。在许多情况下,所有的客户端和服务器都会在同一个 LAN(Local Area Network)
局域网内共享,但是这并不是必须的。也可能是下面这样的情况:如果客户端和服务器距离较远,那么它们也可以在广域网上运行。客户端可以是服务器,服务器可以是客户端,但是为了简单起见,我们说的客户端就是消费服务,而服务器就是提供服务的角度来聊。
每一个 NFS 服务都会导出一个或者多个目录供远程客户端访问。当一个目录可用时,它的所有子目录也可用。因此,通常整个目录树都会作为一个整体导出。服务器导出的目录列表会用一个文件来维护,这个文件是 /etc/exports
,当服务器启动后,这些目录可以自动的被导出。客户端通过挂载这些导出的目录来访问它们。当一个客户端挂载了一个远程目录,这个目录就成为客户端目录层次的一部分,如下图所示。
在这个示例中,一号客户机挂载到服务器的 bin 目录下,因此它现在可以使用 shell 访问 /bin/cat 或者其他任何一个目录。同样,客户机 1 也可以挂载到 二号服务器上从而访问 /usr/local/projects/proj1 或者其他目录。二号客户机同样可以挂载到二号服务器上,访问路径是 /mnt/projects/proj2。
从上面可以看到,由于不同的客户端将文件挂载到各自目录树的不同位置,同一个文件在不同的客户端有不同的访问路径和不同的名字。挂载点一般通常在客户端本地,服务器不知道任何一个挂载点的存在。
NFS 协议
由于 NFS 的协议之一是支持 异构
系统,客户端和服务器可能在不同的硬件上运行不同的操作系统,因此有必要在服务器和客户端之间进行接口定义。这样才能让任何写一个新客户端能够和现有的服务器一起正常工作,反之亦然。
NFS 就通过定义两个客户端 - 服务器协议从而实现了这个目标。协议就是客户端发送给服务器的一连串的请求,以及服务器发送回客户端的相应答复。
第一个 NFS 协议是处理挂载。客户端可以向服务器发送路径名并且请求服务器是否能够将服务器的目录挂载到自己目录层次上。因为服务器不关心挂载到哪里,因此请求不会包含挂载地址。如果路径名是合法的并且指定的目录已经被导出,那么服务器会将文件 句柄
返回给客户端。
文件句柄包含唯一标识文件系统类型,磁盘,目录的i节点号和安全性信息的字段。
随后调用读取和写入已安装目录或其任何子目录中的文件,都将使用文件句柄。
当 Linux 启动时会在多用户之前运行 shell 脚本 /etc/rc 。可以将挂载远程文件系统的命令写入该脚本中,这样就可以在允许用户登陆之前自动挂载必要的远程文件系统。大部分 Linux 版本是支持自动挂载
的。这个特性会支持将远程目录和本地目录进行关联。
相对于手动挂载到 /etc/rc 目录下,自动挂载具有以下优势
- 如果列出的 /etc/rc 目录下出现了某种故障,那么客户端将无法启动,或者启动会很困难、延迟或者伴随一些出错信息,如果客户根本不需要这个服务器,那么手动做了这些工作就白费了。
- 允许客户端并行的尝试一组服务器,可以实现一定程度的容错率,并且性能也可以得到提高。
另一方面,我们默认在自动挂载时所有可选的文件系统都是相同的。由于 NFS 不提供对文件或目录复制的支持,用户需要自己确保这些所有的文件系统都是相同的。因此,大部分的自动挂载都只应用于二进制文件和很少改动的只读的文件系统。
第二个 NFS 协议是为文件和目录的访问而设计的。客户端能够通过向服务器发送消息来操作目录和读写文件。客户端也可以访问文件属性,比如文件模式、大小、上次修改时间。NFS 支持大多数的 Linux 系统调用,但是 open 和 close 系统调用却不支持。
不支持 open 和 close 并不是一种疏忽,而是一种刻意的设计,完全没有必要在读一个文件之前对其进行打开,也没有必要在读完时对其进行关闭。
NFS 使用了标准的 UNIX 保护机制,使用 rwx
位来标示所有者(owner)
、组(groups)
、其他用户
。最初,每个请求消息都会携带调用者的 groupId 和 userId,NFS 会对其进行验证。事实上,它会信任客户端不会发生欺骗行为。可以使用公钥密码来创建一个安全密钥,在每次请求和应答中使用它验证客户端和服务器。
NFS 实现
即使客户端和服务器的代码实现是独立于 NFS 协议的,大部分的 Linux 系统会使用一个下图的三层实现,顶层是系统调用层,系统调用层能够处理 open 、 read 、 close 这类的系统调用。在解析和参数检查结束后调用第二层,虚拟文件系统 (VFS)
层。
VFS 层的任务是维护一个表,每个已经打开的文件都在表中有一个表项。VFS 层为每一个打开的文件维护着一个虚拟i节点
,简称为 v - node。v 节点用来说明文件是本地文件还是远程文件。如果是远程文件的话,那么 v - node 会提供足够的信息使客户端能够访问它们。对于本地文件,会记录其所在的文件系统和文件的 i-node ,因为现代操作系统能够支持多文件系统。虽然 VFS 是为了支持 NFS 而设计的,但是现代操作系统都会使用 VFS,而不管有没有 NFS。
Linux IO
我们之前了解过了 Linux 的进程和线程、Linux 内存管理,那么下面我们就来认识一下 Linux 中的 I/O 管理。
Linux 系统和其他 UNIX 系统一样,IO 管理比较直接和简洁。所有 IO 设备都被当作文件
,通过在系统内部使用相同的 read 和 write 一样进行读写。
Linux IO 基本概念
Linux 中也有磁盘、打印机、网络等 I/O 设备,Linux 把这些设备当作一种 特殊文件
整合到文件系统中,一般通常位于 /dev
目录下。可以使用与普通文件相同的方式来对待这些特殊文件。
特殊文件一般分为两种:
块特殊文件是一个能存储固定大小块
信息的设备,它支持「以固定大小的块,扇区或群集读取和(可选)写入数据」。每个块都有自己的物理地址
。通常块的大小在 512 - 65536 之间。所有传输的信息都会以连续
的块为单位。块设备的基本特征是每个块都较为对立,能够独立的进行读写。常见的块设备有 「硬盘、蓝光光盘、USB 盘」与字符设备相比,块设备通常需要较少的引脚。
块特殊文件的缺点基于给定固态存储器的块设备比基于相同类型的存储器的字节寻址要慢一些,因为必须在块的开头开始读取或写入。所以,要读取该块的任何部分,必须寻找到该块的开始,读取整个块,如果不使用该块,则将其丢弃。要写入块的一部分,必须寻找到块的开始,将整个块读入内存,修改数据,再次寻找到块的开头处,然后将整个块写回设备。
另一类 I/O 设备是字符特殊文件
。字符设备以字符
为单位发送或接收一个字符流,而不考虑任何块结构。字符设备是不可寻址的,也没有任何寻道操作。常见的字符设备有 「打印机、网络设备、鼠标、以及大多数与磁盘不同的设备」。
每个设备特殊文件都会和 设备驱动
相关联。每个驱动程序都通过一个 主设备号
来标识。如果一个驱动支持多个设备的话,此时会在主设备的后面新加一个 次设备号
来标识。主设备号和次设备号共同确定了唯一的驱动设备。
我们知道,在计算机系统中,CPU 并不直接和设备打交道,它们中间有一个叫作 设备控制器(Device Control Unit)
的组件,例如硬盘有磁盘控制器、USB 有 USB 控制器、显示器有视频控制器等。这些控制器就像代理商一样,它们知道如何应对硬盘、鼠标、键盘、显示器的行为。
绝大多数字符特殊文件都不能随机访问,因为他们需要使用和块特殊文件不同的方式来控制。比如,你在键盘上输入了一些字符,但是你发现输错了一个,这时有一些人喜欢使用 backspace
来删除,有人喜欢用 del
来删除。为了中断正在运行的设备,一些系统使用 ctrl-u
来结束,但是现在一般使用 ctrl-c
来结束。
网络
I/O 的另外一个概念是网络
, 也是由 UNIX 引入,网络中一个很关键的概念就是 套接字(socket)
。套接字允许用户连接到网络,正如邮筒允许用户连接到邮政系统,套接字的示意图如下
套接字的位置如上图所示,套接字可以动态创建和销毁。成功创建一个套接字后,系统会返回一个文件描述符(file descriptor)
,在后面的创建链接、读数据、写数据、解除连接时都需要使用到这个文件描述符。每个套接字都支持一种特定类型的网络类型,在创建时指定。一般最常用的几种
- 可靠的面向连接的字节流
- 可靠的面向连接的数据包
- 不可靠的数据包传输
可靠的面向连接的字节流会使用管道
在两台机器之间建立连接。能够保证字节从一台机器按照顺序到达另一台机器,系统能够保证所有字节都能到达。
除了数据包之间的分界之外,第二种类型和第一种类型是类似的。如果发送了 3 次写操作,那么使用第一种方式的接受者会直接接收到所有字节;第二种方式的接受者会分 3 次接受所有字节。除此之外,用户还可以使用第三种即不可靠的数据包来传输,使用这种传输方式的优点在于高性能,有的时候它比可靠性更加重要,比如在流媒体中,性能就尤其重要。
以上涉及两种形式的传输协议,即 TCP
和 UDP
,TCP 是 传输控制协议
,它能够传输可靠的字节流。UDP
是 用户数据报协议
,它只能够传输不可靠的字节流。它们都属于 TCP/IP 协议簇中的协议,下面是网络协议分层
可以看到,TCP 、UDP 都位于网络层上,可见它们都把 IP 协议 即 互联网协议
作为基础。
一旦套接字在源计算机和目的计算机建立成功,那么两个计算机之间就可以建立一个链接。通信一方在本地套接字上使用 listen
系统调用,它就会创建一个缓冲区,然后阻塞直到数据到来。另一方使用 connect
系统调用,如果另一方接受 connect 系统调用后,则系统会在两个套接字之间建立连接。
socket 连接建立成功后就像是一个管道,一个进程可以使用本地套接字的文件描述符从中读写数据,当连接不再需要的时候使用 close
系统调用来关闭。
Linux I/O 系统调用
Linux 系统中的每个 I/O 设备都有一个特殊文件(special file)
与之关联,什么是特殊文件呢?
在操作系统中,特殊文件是一种在文件系统中与硬件设备相关联的文件。特殊文件也被称为
设备文件(device file)
。特殊文件的目的是将设备作为文件系统中的文件进行公开。特殊文件为硬件设备提供了借口,用于文件 I/O 的工具可以进行访问。因为设备有两种类型,同样特殊文件也有两种,即字符特殊文件和块特殊文件
对于大部分 I/O 操作来说,只用合适的文件就可以完成,并不需要特殊的系统调用。然后,有时需要一些设备专用的处理。在 POSIX 之前,大多数 UNIX 系统会有一个叫做 ioctl
的系统调用,它用于执行大量的系统调用。随着时间的发展,POSIX 对其进行了整理,把 ioctl 的功能划分为面向终端设备的独立功能调用,现在已经变成独立的系统调用了。
下面是几个管理终端的系统调用
系统调用 | 描述 |
tcgetattr | 获取属性 |
tcsetattr | 设置属性 |
cfgetispeed | 获取输入速率 |
cfgetospeed | 获取输出速率 |
cfsetispeed | 设置输入速率 |
cfsetospeed | 设置输出速率 |
Linux IO 实现
Linux 中的 IO 是通过一系列设备驱动实现的,每个设备类型对应一个设备驱动。设备驱动为操作系统和硬件分别预留接口,通过设备驱动来屏蔽操作系统和硬件的差异。
当用户访问一个特殊的文件时,由文件系统提供此特殊文件的主设备号和次设备号,并判断它是一个块特殊文件还是字符特殊文件。主设备号用于标识字符设备还是块设备,次设备号用于参数传递。
每个驱动程序
都有两部分:这两部分都是属于 Linux 内核,也都运行在内核态下。上半部分运行在调用者上下文并且与 Linux 其他部分交互。下半部分运行在内核上下文并且与设备进行交互。驱动程序可以调用内存分配、定时器管理、DMA 控制等内核过程。可被调用的内核功能都位于 驱动程序 - 内核接口
的文档中。
I/O 实现指的就是对字符设备和块设备的实现
块设备实现
系统中处理块特殊文件 I/O 部分的目标是为了使传输次数尽可能的小。为了实现这个目标,Linux 系统在磁盘驱动程序和文件系统之间设置了一个 高速缓存(cache)
,如下图所示
在 Linux 内核 2.2 之前,Linux 系统维护着两个缓存:页面缓存(page cache)
和 缓冲区缓存(buffer cache)
,因此,存储在一个磁盘块中的文件可能会在两个缓存中。2.2 版本以后 Linux 内核只有一个统一的缓存一个 通用数据块层(generic block layer)
把这些融合在一起,实现了磁盘、数据块、缓冲区和数据页之间必要的转换。那么什么是通用数据块层?
通用数据块层是一个内核的组成部分,用于处理对系统中所有块设备的请求。通用数据块主要有以下几个功能
将数据缓冲区放在内存高位处,当 CPU 访问数据时,页面才会映射到内核线性地址中,并且此后取消映射
实现
零拷贝
机制,磁盘数据可以直接放入用户模式的地址空间,而无需先复制到内核内存中管理磁盘卷,会把不同块设备上的多个磁盘分区视为一个分区。
利用最新的磁盘控制器的高级功能,例如 DMA 等。
cache 是提升性能的利器,不管以什么样的目的需要一个数据块,都会先从 cache 中查找,如果找到直接返回,避免一次磁盘访问,能够极大的提升系统性能。
如果页面 cache 中没有这个块,操作系统就会把页面从磁盘中调入内存,然后读入 cache 进行缓存。
cache 除了支持读操作外,也支持写操作,一个程序要写回一个块,首先把它写到 cache 中,而不是直接写入到磁盘中,等到磁盘中缓存达到一定数量值时再被写入到 cache 中。
Linux 系统中使用 IO 调度器
来保证减少磁头的反复移动从而减少损失。I/O 调度器的
作用是对块设备的读写操作进行排序,对读写请求进行合并。Linux 有许多调度器的变体,从而满足不同的工作需要。最基本的 Linux 调度器是基于传统的 Linux 电梯调度器(Linux elevator scheduler)
。Linux 电梯调度器的主要工作流程就是按照磁盘扇区的地址排序并存储在一个双向链表
中。新的请求将会以链表的形式插入。这种方法可以有效的防止磁头重复移动。因为电梯调度器会容易产生饥饿现象。因此,Linux 在原基础上进行了修改,维护了两个链表,在 最后日期(deadline)
内维护了排序后的读写操作。默认的读操作耗时 0.5s,默认写操作耗时 5s。如果在最后期限内等待时间最长的链表没有获得服务,那么它将优先获得服务。
字符设备实现
和字符设备的交互是比较简单的。由于字符设备会产生并使用字符流、字节数据,因此对随机访问的支持意义不大。一个例外是使用 行规则(line disciplines)
。一个行规可以和终端设备相关联,使用 tty_struct
结构来表示,它表示与终端设备交换数据的解释器,当然这也属于内核的一部分。例如:行规可以对行进行编辑,映射回车为换行等一系列其他操作。
什么是行规则?
行规是某些类 UNIX 系统中的一层,终端子系统通常由三层组成:上层提供字符设备接口,下层硬件驱动程序与硬件或伪终端进行交互,中层规则用于实现终端设备共有的行为。
网络设备实现
网络设备的交互是不一样的,虽然 网络设备(network devices)
也会产生字符流,因为它们的异步(asynchronous)
特性是他们不易与其他字符设备在同一接口下集成。网络设备驱动程序会产生很多数据包,经由网络协议到达用户应用程序中。
Linux 中的模块
UNIX 设备驱动程序是被静态加载
到内核中的。因此,只要系统启动后,设备驱动程序都会被加载到内存中。随着个人电脑 Linux 的出现,这种静态链接完成后会使用一段时间的模式被打破。相对于小型机上的 I/O 设备,PC 上可用的 I/O 设备有了数量级的增长。绝大多数用户没有能力去添加一个新的应用程序、更新设备驱动、重新连接内核,然后进行安装。
Linux 为了解决这个问题,引入了 可加载(loadable module)
机制。可加载是在系统运行时添加到内核中的代码块。
当一个模块被加载到内核时,会发生下面几件事情:第一,在加载的过程中,模块会被动态的重新部署。第二,系统会检查程序程序所需的资源是否可用。如果可用,则把这些资源标记为正在使用。第三步,设置所需的中断向量。第四,更新驱动转换表使其能够处理新的主设备类型。最后再来运行设备驱动程序。
在完成上述工作后,驱动程序就会安装完成,其他现代 UNIX 系统也支持可加载机制。
Linux 安全
Linux 作为 MINIX 和 UNIX 的衍生操作系统,从一开始就是一个多用户
系统。这意味着 Linux 从早期开始就建立了安全和信息访问控制机制。下面我们主要探讨的就是 Linux 安全性的一些内容
Linux 安全基本概念
一个 Linux 系统的用户群里由一系列注册用户组成,他们每一个都有一个唯一的 UID (User ID)。一个 UID 是一个位于 0 到 65535 之间的整数。文件(进程或者是其他资源)都标记了它的所有者的 UID。默认情况下,文件的所有者是创建文件的人,文件的所有者是创建文件的用户。
用户可以被分成许多组,每个组都会由一个 16 位的整数标记,这个组叫做 GID(组 ID)
。给用户分组是手动完成的,它由系统管理员执行,分组就是在数据库中添加一条记录指明哪个用户属于哪个组。一个用户可以属于不同组。
Linux 中的基本安全机制比较容易理解,每个进程都会记录它所有者的 UID 和 GID。当文件创建后,它会获取创建进程的 UID 和 GID。当一个文件被创建时,它的 UID 和 GID 就会被标记为进程的 UID 和 GID。这个文件同时会获取由该进程决定的一些权限。这些权限会指定所有者、所有者所在组的其他用户及其他用户对文件具有什么样的访问权限。对于这三类用户而言,潜在的访问权限是 「读、写和执行」,分别由 r、w 和 x 标记。当然,执行文件的权限仅当文件时可逆二进制程序时才有意义。试图执行一个拥有执行权限的非可执行文件,系统会报错。
「Linux 用户分为三种」
root(超级管理员)
,它的 UID 为 0,这个用户有极大的权限,可以直接无视很多的限制 ,包括读写执行的权限。系统用户
,UID 为 1~499。普通用户
,UID 范围一般是 500~65534。这类用户的权限会受到基本权限的限制,也会受到来自管理员的限制。不过要注意 nobody 这个特殊的帐号,UID 为 65534,这个用户的权限会进一步的受到限制,一般用于实现来宾帐号。
Linux 中的每类用户由 3 个比特为来标记,所以 9 个比特位就能够表示所有的权限。
下面来看一下一些基本的用户和权限例子
二进制 | 标记 | 准许的文件访问权限 |
111000000 | rwx------ | 所有者可读、写和执行 |
111111000 | rwxrwx--- | 所有者和组可以读、写和执行 |
111111111 | rwxrwxrwx | 所有人可以读、写和执行 |
000000000 | --------- | 任何人不拥有任何权限 |
000000111 | ------rwx | 只有组以外的其他用户拥有所有权 |
110100100 | rw-r--r-- | 所有者可以读和写,其他人可以读 |
110100100 | rw-r----- | 所有者可以读和写,组可以读 |
我们上面提到,UID 为 0 的是一个特殊用户,称为 超级用户(或者根用户)
。超级用户能够读和写系统中的任何文件,不管这个文件由谁所有,也不管这个文件的保护模式如何。UID 为 0 的进程还具有少数调用受保护系统调用的权限,而普通用户是不可能有这些功能的。通常情况下,只有系统管理员知道超级用户的密码。
在 Linux 系统下,目录也是一种文件,并且具有和普通文件一样的保护模式。不同的是,目录的 x 比特位表示查找权限而不是执行权限。因此,如果一个目录的保护模式是 rwxr-xr-x
,那么它允许所有者读、写和查找目录,而其他人只可以读和查找,而不允许从中添加或者删除目录中的文件。
与 I/O 有关的特殊文件拥有和普通文件一样的保护位。这种机制可以用来限制对 I/O 设备的访问权限。举个例子,打印机是特殊文件,它的目录是 /dev/lp
,它可以被根用户或者一个叫守护进程的特殊用户拥有,具有保护模式 rw-------,从而阻止其他所有人对打印机的访问。毕竟每个人都使用打印机的话会发生混乱。
当然,如果 /dev/lp 的保护模式是 rw-------,那就意味着其他任何人都不能使用打印机。
这个问题通过增加一个保护位 SETUID
到之前的 9 个比特位来解决。当一个进程的 SETUID 位打开,它的 有效 UID
将变成相应可执行文件的所有者 UID,而不是当前使用该进程的用户的 UID。将访问打印机的程序设置为守护进程所有,同时打开 SETUID 位,这样任何用户都可以执行此程序,而且拥有守护进程的权限。
除了 SETUID 之外,还有一个 SETGID 位,SETGID 的工作原理和 SETUID 类似。但是这个位一般很不常用。
Linux 安全相关的系统调用
Linux 中关于安全的系统调用不是很多,只有几个,如下列表所示
系统调用 | 描述 |
chmod | 改变文件的保护模式 |
access | 使用真实的 UID 和 GID 测试访问权限 |
chown | 改变所有者和组 |
setuid | 设置 UID |
setgid | 设置 GID |
getuid | 获取真实的 UID |
getgid | 获取真实的 GID |
geteuid | 获取有效的 UID |
getegid | 获取有效的 GID |
我们在日常开发中用到最多的就是 chmod
了,没想到我们日常开发过程中也能用到系统调用啊,chmod 之前我们一直认为是改变权限,现在专业一点是改变文件的保护模式。它的具体函数如下
s = chmod("路径名","值");
例如
s = chmod("/usr/local/cxuan",777);
他就是会把 /usr/local/cxuan
这个路径的保护模式改为 rwxrwxrwx,任何组和人都可以操作这个路径。只有该文件的所有者和超级用户才有权利更改保护模式。
access
系统调用用来检验实际的 UID 和 GID 对某文件是否拥有特定的权限。下面就是四个 getxxx 的系统调用,这些用来获取 uid 和 gid 的。
注意:其中的 chown、setuid 和 setgid 是超级用户才能使用,用来改变所有者进程的 UID 和 GID。
Linux 安全实现
当用户登录时,登录程序,也被称为 login
,会要求输入用户名和密码。它会对密码进行哈希处理,然后在 /etc/passwd
中进行查找,看看是否有匹配的项。使用哈希的原因是防止密码在系统中以非加密的方式存在。如果密码正确,登录程序会在 /etc/passwd 中读取用户选择的 shell 程序的名称,有可能是 bash
,有可能是 shell
或者其他的 csh
或 ksh
。然后登录程序使用 setuid 和 setgid 这两个系统调用来把自己的 UID 和 GID 变为用户的 UID 和 GID,然后它打开键盘作为标准输入、标准输入的文件描述符是 0 ,屏幕作为标准输出,文件描述符是 1 ,屏幕也作为标准错误输出,文件描述符为 2。最后,执行用户选择的 shell 程序,终止。
当任何进程想要打开一个文件,系统首先将文件的 i - node 所记录的保护位与用户有效 UID 和 有效 GID 进行对比,来检查访问是否允许。如果访问允许,就打开文件并返回文件描述符;否则不打开文件,返回 - 1。
Linux 安全模型和实现在本质上与大多数传统的 UNIX 系统相同。
后记
这篇文章从 Linux 进程线程、内存管理、文件系统、IO 管理和安全来为你呈现了一幅 Linux 蓝图,文中涉及大量的系统调用和解释,是你了解 Linux 操作系统需要仔细研读的一篇文章。
希望这篇文章能带你了解更多关于 Linux 的基本知识,如果对你有帮助,希望小伙伴们不要吝啬的为我
在看、点赞、留言、转发
拒绝白嫖、拒绝下次一定、这次一定要四连~