Unix之文件与目录(四)
本文主体内容来自《UNIX环境高级编程第三版》。
本章将描述文件系统的其他特征和文件的性质。将从stat函数开始,逐个说明stat结构的每一个成员以了解文件的所有属性。在此过程中,将说明修改这些属性的各个函数(更改所有者、更改权限等),还将更详细地说明UNIX文件系统的结构以及符号链接。本章最后介绍对目录进行操作的各个函数,并且开发了一个以降序遍历目录层次结构的函数。
函数stat、fstat、fstatat和lstat
本章主要讨论4个stat函数以及它们的返回信息。
1 |
|
一旦给出pathname,stat函数将返回与此命名文件有关的信息结构。fstat函数获得已在描述符fd上打开文件的有关信息。lstat函数类似于stat,但是当命名的文件是一个符号链接时,lstat返回该符号链接的有关信息,而不是由该符号链接引用的文件的信息。
fstatat函数为一个相对于当前打开目录(由fd参数指向)的路径名返回文件统计信息。flag参数控制着是否跟随着一个符号链接。当AT_SYMLINK_NOFOLLOW
标志被设置时,fstatat不会跟随符号链接,而是返回符号链接本身的信息。否则,在默认情况下,返回的是符号链接所指向的实际文件的信息。如果fd参数的值是AT_FDCWD
,并且pathname参数是一个相对路径名,
fstatat会计算相对于当前目录的pathname参数。如果pathname是一个绝对路径,fd参数就会被忽略。
第2个参数buf是一个指针,它指向一个我们必须提供的结构。函数来填充由buf指向的结构。结构的实际定义可能随具体实现有所不同,但其基本形式是:
1 | struct stat { |
timespec
结构类型按照秒和纳秒定义了时间,至少包括下面两个字段:
1 | time_t tv_sec; |
在2008年版以前的标准中,时间字段定义成st_atime、st_mtime以及st_ctime,它们都是time_t类型的(以秒来表示)。timespec结构提供了更高精度的时间戳。为了保持兼容性,旧的名字可以定义成tv_sec成员。例如,st_atime可以定义成st_atim.tv_sec。
注意,stat结构中的大多数成员都是基本系统数据类型,将说明此结构的每个成员以了解文件属性。
使用 stat 函数最多的地方可能就是 ls -l
命令,用其可以获得有关一个文件的所有信息。
文件类型
文件类型包括如下几种:
(1)普通文件(regular file)。
(2)目录文件(directory file)。这种文件包含了其他文件的名字以及指向与这些文件有关信息的指针。对一个目录文件具有读权限的任一进程都可以读该目录的内容,但只有内核可以直接写目录文件。进程必须使用本章介绍的函数才能更改目录。
(3)块特殊文件(block special file)。这种类型的文件提供对设备(如磁盘)带缓冲的访问,每次访问以固定长度为单位进行。
(4)字符特殊文件(character special file)。这种类型的文件提供对设备不带缓冲的访问,每次访问长度可变。系统中的所有设备要么是字符特殊文件,要么是块特殊文件。
(5)FIFO。这种类型的文件用于进程间通信,有时也称为命名管道(named pipe)。
(6)套接字(socket)。这种类型的文件用于进程间的网络通信。套接字也可用于在一台宿主机上进程之间的非网络通信。
(7)符号链接(symbolic link)。这种类型的文件指向另一个文件。
文件类型信息包含在stat结构的st_mode成员中。可以用图4-1中的宏确定文件类型。这些宏的参数都是stat结构中的st_mode成员。
图4-1 在<sys/stat.h>
中的文件类型宏
设置用户ID和设置组ID
与一个进程相关联的ID有6个或更多,如图所示。
实际用户ID和实际组ID标识我们究竟是谁。这两个字段在登录时取自口令文件中的登录项。通常,在一个登录会话期间这些值并不改变,但是超级用户进程有方法改变它们。
有效用户ID、有效组ID以及附属组ID决定了我们的文件访问权限。
保存的设置用户ID和保存的设置组ID在执行一个程序时包含了有效用户ID和有效组ID的副本。
通常,有效用户ID等于实际用户ID,有效组ID等于实际组ID。
每个文件有一个所有者和组所有者,所有者由stat结构中的st_uid指定,组所有者则由st_gid指定。
当执行一个程序文件时,进程的有效用户ID通常就是实际用户ID,有效组ID通常是实际组ID。但是可以在文件模式字(st_mode)中设置一个特殊标志,其含义是“当执行此文件时,将进程的有效用户ID设置为文件所有者的用户ID(st_uid)”。与此相类似,在文件模式字中可以设置另一位,它将执行此文件的进程的有效组ID设置为文件的组所有者ID(st_gid)。在文件模式字中的这两位被称为设置用户ID(set-user-ID)位和设置组ID(set-group-ID)位。
例如,若文件所有者是超级用户,而且设置了该文件的设置用户 ID 位,那么当该程序文件由一个进程执行时,该进程具有超级用户权限。不管执行此文件的进程的实际用户 ID 是什么,都会是这样。例如,UNIX 系统程序passwd(1)允许任一用户改变其口令,该程序是一个设置用户 ID 程序。因为该程序应能将用户的新口令写入口令文件中(一般是/etc/passwd 或/etc/shadow),而只有超级用户才具有对该文件的写权限,所以需要使用设置用户 ID 功能。因为运行设置用户ID 程序的进程通常会得到额外的权限,所以编写这种程序时要特别谨慎。
再回到stat函数,设置用户ID位及设置组ID位都包含在文件的st_mode值中。这两位可分别用常量S_ISUID和S_ISGID测试。
文件访问权限
st_mode值也包含了对文件的访问权限位。所有文件类型(目录、字符特别文件等)都有访问权限(access permission)。
每个文件有9个访问权限位,可将它们分成3类,见图4-6。
图4-6 9个访问权限位,取自<sys/stat.h>
在图4-6前3行中,术语用户指的是文件所有者(owner)。chmod(1)命令用于修改这9个权限位。该命令允许我们用u表示用户(所有者),用g表示组,用o表示其他。
图4-6中的3类访问权限(即读、写及执行)以各种方式由不同的函数使用。我们将这些不同的使用方式汇总在下面。
第一个规则是,用名字打开任一类型的文件时,对包含该名字的每一个目录,包括它可能隐含的当前工作目录都应具有执行权限。例如,为了打开文件
/usr/include/stdio.h
,需要对目录/
、/usr
和/usr/include
具有执行权限。然后,需要具有对文件本身的适当权限,这取决于以何种模式打开它(只读、读-写等)。注意,对于目录的读权限和执行权限的意义是不相同的。读权限允许读目录,获得在该目录中所有文件名的列表。当一个目录是我们要访问文件的路径名的一个组成部分时,对该目录的执行权限使我们可通过该目录(也就是搜索该目录,寻找一个特定的文件名)。
对于一个文件的读权限决定了是否能够打开现有文件进行读操作。这与open函数的O_RDONLY和O_RDWR标志相关。
对于一个文件的写权限决定了是否能够打开现有文件进行写操作。这与open函数的O_WRONLY和O_RDWR标志相关。
为了在open函数中对一个文件指定O_TRUNC标志,必须对该文件具有写权限。
为了在一个目录中创建一个新文件,必须对该目录具有写权限和执行权限。
为了删除一个现有文件,必须对包含该文件的目录具有写权限和执行权限。对该文件本身则不需要有读、写权限。
如果用7个exec函数中的任何一个执行某个文件,都必须对该文件具有执行权限。该文件还必须是一个普通文件。
进程每次打开、创建或删除一个文件时,内核就进行文件访问权限测试,而这种测试可能涉及文件的所有者(st_uid和st_gid)、进程的有效ID(有效用户ID和有效组ID)以及进程的附属组ID(若支持的话)。两个所有者ID是文件的性质,而两个有效ID和附属组ID则是进程的性质。内核进行的测试具体如下。
(1)若进程的有效用户ID是0(超级用户),则允许访问。这给予了超级用户对整个文件系统进行处理的最充分的自由。
(2)若进程的有效用户ID等于文件的所有者ID(也就是进程拥有此文件),那么如果所有者适当的访问权限位被设置,则允许访问;否则拒绝访问。适当的访问权限位指的是,若进程为读而打开该文件,则用户读位应为1;若进程为写而打开该文件,则用户写位应为1;若进程将执行该文件,则用户执行位应为1。
(3)若进程的有效组ID或进程的附属组ID之一等于文件的组ID,那么如果组适当的访问权限位被设置,则允许访问;否则拒绝访问。
(4)若其他用户适当的访问权限位被设置,则允许访问;否则拒绝访问。
按顺序执行这 4 步。注意,如果进程拥有此文件(第 2 步),则按用户访问权限批准或拒绝该进程对文件的访问—不查看组访问权限。类似地,若进程并不拥有该文件,但进程属于某个适当的组,则按组访问权限批准或拒绝该进程对文件的访问—不查看其他用户的访问权限。
新文件和目录的所有权
新文件的用户ID设置为进程的有效用户ID。关于组ID,POSIX.1允许实现选择下列之一作为新文件的组ID。
(1)新文件的组ID可以是进程的有效组ID。
(2)新文件的组ID可以是它所在目录的组ID。
函数access和faccessat
正如前面所说,当用 open 函数打开一个文件时,内核以进程的有效用户 ID 和有效组 ID为基础执行其访问权限测试。有时,进程也希望按其实际用户ID和实际组ID来测试其访问能力。access和faccessat函数是按实际用户ID和实际组ID进行访问权限测试的。(该测试也分成4步,这与4.4节中所述的一样,但将有效改为实际。)
1 |
|
函数umask
umask 函数为进程设置文件模式创建屏蔽字,并返回之前的值。(这是少数几个没有出错返回函数中的一个。)
1 |
|
其中,参数cmask是由图4-6中列出的9个常量(S_IRUSR、S_IWUSR等)中的若干个按位“或”构成的。
在进程创建一个新文件或新目录时,就一定会使用文件模式创建屏蔽字。
用户可以设置umask值以控制他们所创建文件的默认权限。该值表示成八进制数。设置了相应位后,它所对应的权限就会被拒绝。常用的几种 umask 值是 002、022 和 027。002 阻止其他用户写入你的文件,022 阻止同组成员和其他用户写入你的文件,027阻止同组成员写你的文件以及其他用户读、写或执行你的文件。
函数chmod、fchmod和fchmodat
chmod、fchmod和fchmodat这3个函数可以更改现有文件的访问权限。
1 |
|
chmod 函数在指定的文件上进行操作,而 fchmod
函数则对已打开的文件进行操作。fchmodat函数与chmod函数在下面两种情况下是相同的:一种是pathname参数为绝对路径,另一种是fd参数取值为AT_FDCWD
而pathname参数为相对路径。否则,fchmodat计算相对于打开目录(由fd参数指向)的pathname。flag参数可以用于改变fchmodat的行为,当设置了AT_SYMLINK_NOFOLLOW
标志时,fchmodat并不会跟随符号链接。
为了改变一个文件的权限位,进程的有效用户ID必须等于文件的所有者ID,或者该进程必须具有超级用户权限。
参数mode是图4-11中所示常量的按位或。
图4-11 chmod函数的mode常量,取自<sys/stat.h>
注意,在图4-11中,有9项是取自图4-6中的9个文件访问权限位。另外加了6个,它们是两个设置ID常量(S_ISUID和S_ISGID)、保存正文常量(S_ISVTX)以及3个组合常量(S_IRWXU、S_IRWXG和S_IRWXO)。
函数chown、fchown、fchownat和lchown
下面几个chown函数可用于更改文件的用户ID和组ID。如果两个参数owner或group中的任意一个是-1,则对应的ID不变。
1 |
|
文件截断
1 |
|
这两个函数将一个现有文件长度截断为 length。如果该文件以前的长度大于 length,则超过length以外的数据就不再能访问。如果以前的长度小于 length,文件长度将增加,在以前的文件尾端和新的文件尾端之间的数据将读作0(也就是可能在文件中创建了一个空洞)。
函数link、linkat、unlink、unlinkat和remove
创建一个指向现有文件的链接的方法是使用link函数或linkat函数。
1 |
|
这两个函数创建一个新目录项newpath,它引用现有文件existingpath。如果newpath已经存在,则返回出错。只创建newpath中的最后一个分量,路径中的其他部分应当已经存在。
为了删除一个现有的目录项,可以调用unlink函数。
1 |
|
这两个函数删除目录项,并将由pathname所引用文件的链接计数减1。如果对该文件还有其他链接,则仍可通过其他链接访问该文件的数据。如果出错,则不对该文件做任何更改。
也可以用 remove 函数解除对一个文件或目录的链接。对于文件,remove 的功能与unlink相同。对于目录,remove的功能与rmdir相同。
1 |
|
函数rename和renameat
文件或目录可以用rename函数或者renameat函数进行重命名。
1 |
|
创建和读取符号链接
可以用symlink或symlinkat函数创建一个符号链接。
1 |
|
因为open函数跟随符号链接,所以需要有一种方法打开该链接本身,并读该链接中的名字。readlink和readlinkat函数提供了这种功能。
1 |
|
两个函数组合了 open、read 和 close 的所有操作。如果函数成功执行,则返回读入buf的字节数。在buf中返回的符号链接的内容不以null字节终止。
文件的时间
注意,修改时间(st_mtim)和状态更改时间(st_ctim)之间的区别。修改时间是文件内容最后一次被修改的时间。状态更改时间是该文件的i节点最后一次被修改的时间。
函数futimens、utimensat和utimes
一个文件的访问和修改时间可以用以下几个函数更改。futimens和utimensat函数可以指定纳秒级精度的时间戳。用到的数据结构是与stat函数族相同的timespec结构。
1 |
|
这两个函数的times数组参数的第一个元素包含访问时间,第二元素包含修改时间。这两个时间值是日历时间,这是自特定时间(1970年1月1日00:00:00)以来所经过的秒数。不足秒的部分用纳秒表示。
时间戳可以按下列4种方式之一进行指定。
(1)如果times参数是一个空指针,则访问时间和修改时间两者都设置为当前时间。
(2)如果times参数指向两个timespec结构的数组,任一数组元素的tv_nsec字段的值为UTIME_NOW,相应的时间戳就设置为当前时间,忽略相应的tv_sec字段。
(3)如果times参数指向两个timespec结构的数组,任一数组元素的tv_nsec字段的值为UTIME_OMIT,相应的时间戳保持不变,忽略相应的tv_sec字段。
(4)如果 times 参数指向两个 timespec 结构的数组,且 tv_nsec 字段的值为既不是UTIME_NOW 也不是 UTIME_OMIT,在这种情况下,相应的时间戳设置为相应的 tv_sec 和tv_nsec字段的值。
futimens 函数需要打开文件来更改它的时间,utimensat
函数提供了一种使用文件名更改文件时间的方法。pathname参数是相对于fd参数进行计算的,fd要么是打开目录的文件描述符,要么设置为特殊值
AT_FDCWD
(强制通过相对于调用进程的当前目录计算pathname)。如果pathname指定了绝对路径,那么fd参数被忽略。
utimensat的flag参数可用于进一步修改默认行为。如果设置了AT_SYMLINK_NOFOLLOW
标志,则符号链接本身的时间就会被修改(如果路径名指向符号链接)。默认的行为是跟随符号链接,并把文件的时间改成符号链接的时间。
futimens 和utimensat 函数都包含在POSIX.1 中,第3 个函数utimes 包含在Single UNIX Specification的XSI扩展选项中。
1 |
|
utimes函数对路径名进行操作。times参数是指向包含两个时间戳(访问时间和修改时间)元素的数组的指针,两个时间戳是用秒和微妙表示的。
1 | struct timeval { |
注意,不能对状态更改时间st_ctim(i节点最近被修改的时间)指定一个值,因为调用utimes函数时,此字段会被自动更新。
函数mkdir、mkdirat和rmdir
用mkdir和mkdirat函数创建目录,用rmdir函数删除目录。
1 |
|
这两个函数创建一个新的空目录。其中,.和..目录项是自动创建的。所指定的文件访问权限mode由进程的文件模式创建屏蔽字修改。
常见的错误是指定与文件相同的mode(只指定读、写权限)。但是,对于目录通常至少要设置一个执行权限位,以允许访问该目录中的文件名。
用rmdir函数可以删除一个空目录。空目录是只包含.和..这两项的目录。
1 |
|
读目录
1 |
|
由opendir和fdopendir返回的指向DIR结构的指针由另外5个函数使用。opendir执行初始化操作,使第一个readdir返回目录中的第一个目录项。DIR结构由fdopendir创建时,readdir返回的第一项取决于传给fdopendir函数的文件描述符相关联的文件偏移量。注意,目录中各目录项的顺序与实现有关。它们通常并不按字母顺序排列。
函数chdir、fchdir和getcwd
每个进程都有一个当前工作目录,此目录是搜索所有相对路径名的起点(不以斜线开始的路径名为相对路径名)。当用户登录到 UNIX 系统时,其当前工作目录通常是口令文件(/etc/passwd)中该用户登录项的第6个字段—用户的起始目录(home directory)。当前工作目录是进程的一个属性,起始目录则是登录名的一个属性。
进程调用chdir或fchdir函数可以更改当前工作目录。
1 |
|
在这两个函数中,分别用pathname或打开文件描述符来指定新的当前工作目录。
1 |
|
必须向此函数传递两个参数,一个是缓冲区地址buf,另一个是缓冲区的长度size(以字节为单位)。该缓冲区必须有足够的长度以容纳绝对路径名再加上一个终止 null 字节,否则返回出错。
习题
4.1 用stat函数替换lstat函数,如若命令行参数之一是符号链接,会发生什么变化?
stat函数总是跟随符号链接,所以该程序决不会显示文件类型是“符号链接”。
例如,正如本书正文中所示,
/dev/cdrom
是/dev/sr0
的一个符号链接,但是stat函数的结果只显示/dev/cdrom
是一个块特殊文件,而不报告它是一个符号链接。若符号链接指向一个不存在的文件,stat会出错返回。
4.2 如果文件模式创建屏蔽字是777(八进制),结果会怎样?用shell的umask命令验证该结果。
将关闭该文件的所有访问权限。
1
2
3
4 $ umask 777
$ date > temp.foo
$ ls -l temp.foo
---------- 1 sar 29 Feb 5 14:06 temp.foo
4.3 关闭一个你所拥有文件的用户读权限,将导致拒绝你访问自己的文件,对此进行验证。
1
2
3
4
5
6 $ data > foo
$ chmod u-r foo 关闭用户读权限
$ ls -l foo 验证文件的权限
--w-r--r-- 1 sar 29 Feb 5 14:21 foo
$ cat foo 读文件
cat: foo: Permission denied
4.5 4.12节中讲到一个普通文件的大小可以为0,同时我们又知道st_size字段是为目录或符号链接定义的,那么目录和符号链接的长度是否可以为0?
目录的长度从来不会是0,因为它总是包含.和..两项。符号链接的长度指其路径名包含的字符数,由于路径名中至少有一个字符,所以长度也不为0。
4.6 编写一个类似cp的程序,它复制包含空洞的文件,但不将字节0写到输出文件中去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// 复制文件,跳过空洞
int copy_file_skip_holes(const char *src, const char *dst) {
int src_fd = open(src, O_RDONLY);
if (src_fd == -1) {
perror("打开源文件失败");
return -1;
}
int dst_fd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (dst_fd == -1) {
perror("打开目标文件失败");
close(src_fd);
return -1;
}
char buf[BUF_SIZE];
ssize_t bytes_read, bytes_written;
while ((bytes_read = read(src_fd, buf, BUF_SIZE)) > 0) {
// 跳过全为零的部分(空洞)
for (ssize_t i = 0; i < bytes_read; ++i) {
if (buf[i] != 0) {
// 只写非零字节
bytes_written = write(dst_fd, &buf[i], 1);
if (bytes_written != 1) {
perror("写入目标文件失败");
close(src_fd);
close(dst_fd);
return -1;
}
}
}
}
if (bytes_read == -1) {
perror("读取源文件失败");
close(src_fd);
close(dst_fd);
return -1;
}
close(src_fd);
close(dst_fd);
return 0;
}
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "用法: %s 源文件 目标文件\n", argv[0]);
return 1;
}
if (copy_file_skip_holes(argv[1], argv[2]) == -1) {
return 1;
}
printf("文件复制完成\n");
return 0;
}
// 生成空洞文件
int main() {
// 打开文件并准备写入
int fd = open("hole_file", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("打开文件失败");
return 1;
}
// 写入 "hello world"
if (write(fd, "hello world", 11) != 11) {
perror("写入文件失败");
close(fd);
return 1;
}
// 创建空洞,通过 lseek 跳到 1MB 位置
if (lseek(fd, 1024 * 1024, SEEK_SET) == -1) {
perror("lseek 失败");
close(fd);
return 1;
}
// 在空洞之后写入 "hello again"
if (write(fd, "hello again", 12) != 12) {
perror("写入文件失败");
close(fd);
return 1;
}
// 关闭文件
close(fd);
printf("成功创建空洞文件:hole_file\n");
return 0;
}