王道C++班级参考资料
——
Linux部分卷3文件系统编程
节2目录流

最新版本V4.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

系统编程简介

Gn!

我们仍然回到Linux课程的开端,在那里我们详细讲解了Linux系统的结构设计,如下图所示:

Linux内核结构-图

我们已经学习了利用shell命令来和内核进行交互,在C阶段我们也学习了大量的标准库函数,也可以实现与内核的交互。

那么从这节课开始,我们就学习一种直接与内核交互的方式——Linux系统调用。

稍微要注意的是:

  1. 标准库函数遵循C标准规范(ISO-C),任何使用标准C语言的平台都遵循这一规范,虽然不同编译器和平台的实现细节可能有所不同,但整体上是能够保证跨平台性的。

  2. Linux的系统调用一般遵循POSIX规范,这意味着Linux系统调用最多在类Unix平台上实现跨平台性(实际上还是有些兼容性问题)。总之基本可以认为Linux系统调用就只能在Linux内核上使用。关于这个问题,我们下面还会进一步详细讲解。

从文件系统编程开始

Gn!

Linux系统编程主要可以分成三大部分:文件系统编程,进程和线程管理以及网络系统编程。

我们在学习的时候,学习的顺序也是这样的, 那么为什么要这么学习呢?

  1. 类Unix系统的设计哲学中,"一切皆文件" 是一个核心概念。

    1. Linux系统下,把一切外部设备、进程线程资源以及网络通信等都视为文件,都可以使用类似文件操作的方式来进行访问和管理。

    2. 先学习文件系统编程有利于后续的学习,可以作为后续系统编程学习的基础。

  2. 文件系统编程直接操作各自文件,最简单且最直观。

  3. 实用性强,文件系统编程的操作,如文件的创建、读写、权限修改等,是系统编程中最常见的操作。

总之,我们就从现在开始来学习Linux文件系统编程。

如何学习呢?

Gn!

很多人可能听到系统编程四个字,就下意识的觉得会很难,实际上这是一个错误的认知。

虽然学习系统的调用的过程,会有很多模型、概念需要理解记忆,但最核心,展现在代码上的还是——函数,C语言的系统调用函数。

Linux的系统调用虽然是在内核中实现的,但还是会通过C语言风格的函数,将接口暴露给用户空间的程序去使用。

所以在学习系统调用的过程中,我们会涉及大量的C语言系统调用函数,那么应该如何学习这么多的系统调用函数呢?

答:记住系统调用的函数名,大致记住功能,在忘记的时候查询man手册寻找自己想要的答案。

系统调用的C语言函数,都放置在man手册的2号手册中,我们可以通过指令查询函数的使用方式,比如:

如何通过man手册查看一个系统调用函数如何使用呢?遵循以下步骤:

第一步:学习系统调用函数以及标准库函数,总是需要先记住函数的名字,否则就无法打开man手册了

第二步:使用指令打开man手册,在很多时候这需要你指定man手册的卷数。系统调用函数在第2卷,库函数在第3卷。

第三步:当你打开一个函数的man手册时,内容查看的顺序是:

  1. 先看NAME,NAME块描述了函数的基本信息,描述了函数的基本作用。

  2. 再看概述(SYNOPSIS)信息:

    1. 先看头文件,要知道使用此函数要包含什么头文件

    2. 接下来看函数的声明

怎么看函数的声明呢?

应该先看函数的名字,这是基本的

然后看函数的返回值:

因为C语言缺乏错误的检查机制,所以函数执行出错都需要依赖返回值来确定,返回值是什么决定了函数应该如何进行错误处理,这是非常重要的。比如:

  1. 返回值类型若是int或者ssize_t(跨平台的有符号整数),普遍来说如果此函数返回-1,就表示发生了错误。(尤其返回值是ssize_t时)

  2. 返回值类型若是一个指针类型,普遍上如果此函数返回NULL,就表示发生了错误。

在一众返回值中,我们尤其要特别注意指针类型的返回值:

  1. 返回值是指针类型,绝不可能返回此函数栈区的数据,因为这样的指针是一个悬空指针,不可能作为返回值。

  2. 返回值是指针类型代表申请了栈区外的额外内存空间或者资源,这往往意味着需要手动管理/关闭这些额外的内存空间或资源(但也不是必然)。

看完函数的返回值,可以紧跟着看函数的形参列表,在一众形参列表中我们也需要特别重视指针类型的形参,尤其关注它们有没有被const修饰:

  1. const修饰的指针类型参数,意味着指针指向的内容,绝不会在函数体内部被修改。我们把这种指针类型参数称之为"传入参数",单纯的传入,不会修改指向内容。

  2. 没有被const修饰的指针类型参数,一般就意味着函数要通过传入的指针修改指向的内容,并且往往会在修改内容后再将原指针作为返回值返回,这种指针类型参数称之为"传入传出参数"。传入传出参数在传参时要特别小心空指针、野指针问题!!

这里举一个例子:

比如我们这样调用getcwd函数:

这样的调用显然会导致未定义行为:

  1. p是一个野指针,指向随机内存位置。若这个随机位置是不可访问区域,将导致程序报错崩溃,这其实是一件好事。

  2. 如果随机指向了一个可以访问修改的位置,发生了错误的数据修改,甚至程序还能短暂正常运行,那就太糟糕了。(这在C语言中其实稀松平常,所以C语言很坑)

C语言是一种非常不安全的语言,要想避免这种错误就需要程序自己的细心细致了,要确保指针指向的区域是已分配的。比如:

总之,要想写好C语言代码,成为优秀的C语言开发,需要注意的事情非常多,要仔细一些。

目录相关系统调用

Gn!

在C语言阶段,我们已经学习过文件处理相关的库函数了,现在我们就把目录处理相关的操作补上。注意:此小节我们将学习目录相关的系统调用,目录相关的库函数将在下一小节学习。

关于C语言的系统调用函数、标准库函数以及POSIX库函数的关系

操作系统内核为了实现对外开放系统功能,会主动对外暴露系统调用接口,这对于任何操作系统内核来说都是一样的。

而类Unix操作系统的设计与实现都深植根于 C 语言(C语言诞生的初衷就是为了写Unix系统),类 Unix 系统的系统调用都是以C语言风格暴露的,而且这些系统调用函数一般都遵循POSIX标准。

实际上,C语言由于其接近硬件的特性和高效率,在操作系统开发中被广泛使用,包括Windows和MacOS在内的系统内核在对外暴露系统功能时都会选择C语言风格。

总之:

  1. C语言的系统调用函数显然是不具有跨平台性,它植根于特定的系统内核,不同的系统内核会有不同的暴露方式

  2. C语言的系统调用函数也不是C语言标准的一部分,不属于标准库函数。

  3. 理论上来说,所有的类Unix系统都遵循POSIX标准,那么它们的系统调用函数应该是一致的。但实际上不同的类Unix系统内核,在系统调用上还是会有一些细微的差异。

  4. 一般来说,我们基于Linux内核学习系统调用,就意味着开发出来的系统调用代码仅能运行于Linux平台上。

但:

C语言的标准库函数显然不是这样的,标准库函数意味着遵循特定C语言规范(ISO-C)。由于标准库函数很多是对系统调用的封装,这使得标准库函数在不同平台上的实现方式是不同的,也就是实现的细节不同。

但从程序员的角度看,它们提供了一致的接口和行为。

所以:

  1. 标准库函数具有较好跨平台性,基本保证在任何遵循标准C语言规范的平台上,它们的调用方式和行为是一致的。

  2. 标准库函数可以在任何支持 C 标准的编译器和环境中使用,不受底层操作系统的限制。

那么最后,什么是POSIX库函数呢?

标准库函数指的是符合ISO-C官方C语言标准的库函数,这意味着它们在使用标准C语言的任意平台下使用起来都是一样的,具有最好的跨平台性。Windows、Linux、mac等平台下基本都是一样,使用起来没什么差异。

但POSIX库函数,指的是符合POSIX标准的C语言库函数。

POSIX标准一般所有的类Unix系统都遵守,所以你可以认为POSIX标准的库函数可以在类Unix系统平台中实现跨平台性。但非类Unix系统,比如Windows,那一般就不能直接移植代码了。

但POSIX和ISO-C库函数仍然具有相同点:同为库函数,它们都不是系统调用,但都有可能利用系统调用来实现功能。

改变文件的权限

Gn!

注意,从这里开始每学习一个新的函数,你都需要将此函数的头文件包含(此前没有写过的)加到公共头文件中!

在以往我们已经学过了shell指令当中,用于改变文件权限的一个shell指令:chmod

在C语言代码也是一样的,也可以使用同名函数chmod来改变文件的权限。需要注意的是:在Linux环境下,chmod既是一个系统调用的函数名,也是一个POSIX库函数名。

当我们在C语言代码中调用chmod函数时,调用的是库函数chmod,当然此库函数的底层实现依赖于系统调用函数chmod。

所以chmod这个字符序列,在man手册的1、2、3号三个手册中都存在。

它的函数声明如下:

形式参数:

  1. pathname:文件或目录的路径字符串。

  2. mode:要设置的新权限,这里要使用权限数字表示法,即八进制数(C语言中八进制整数需要以"0"开头)

返回值:

  1. 成功时,chmod 返回 0。

  2. 失败时,返回 -1,并设置 errno 以指示错误原因。

一个使用示例:

需要注意的是:设置权限时需要传参权限的"数字表示法",而且设定的权限就是文件的最终权限,掩码只会影响新建文件,不会影响chmod函数。

扩展:如何查看类型别名的具体定义

Gn!

在上面的chmod函数中,我们看到了一个类型别名:mode_t,那么如何确定这个类型别名的具体类型呢?

有以下两种办法:

查阅预处理后的.i文件:

类型别名的具体定义肯定是包含在头文件里的,如果直接查看头文件中此别名的定义,可能会受到条件编译等预处理指令的干扰,所以最好的办法是查看预处理文件。

第一步可以随意创建一个.c源文件,只要确保包含目标类型别名的头文件即可,比如要查看类型别名mode_t,就需要确保包含:

然后直接利用指令:

生成对应的预处理.i文件。

然后可以选择使用vim编辑器进入这个.i文件,然后使用vim编辑器查询即可,比如我们查到mode_t类型别名的定义如下:

查看类型别名的具体定义-图1

或者也可以使用grep指令搜索这个.i文件,指令如下:

查看类型别名的具体定义-图2

于是我们就知道,在当前机器下,mode_t类型别名实际上就是无符号的int类型。

第二种方式,通过代码计算推导:

对于类型别名而言,大多数情况下,别名的具体类型都是有符号整数或无符号整数,我们可以通过下列代码来确定它们的长度以及有无符号:

以上

获取当前工作目录

Gn!

getcwd函数 即"get current working directory",它是一个POSIX标准的C语言库函数,用于获取当前工作目录的绝对路径名称。

这个库函数的作用,很类似于 pwd 这个shell指令。

其声明如下:

形式参数:

  1. buf: 指向存放当前工作目录字符串的字符数组

  2. size: 这个数组的大小

返回值:

  1. 成功时,getcwd 返回一个指向 buf 的指针,buf 中包含了当前工作目录的绝对路径。

  2. 失败时,返回 NULL,并设置 errno 以指示错误的原因。出错的原因普遍是buf数组过小,无法容纳整个工作目录绝对路径字符串。

一个演示示例如下:

注意:

  1. getcwd 不负责管理传入函数的buf空间的生命周期,buf生命周期的管理是程序员的职责。

  2. 分配空间不要过于抠门,内存空间在当前的开发环境下一般不是稀缺资源。如果分配空间较少,那么会导致报错。

getcwd 函数还有一种调用方式,如下:

注意:

  1. 这种调用方式意味着,由getcwd函数来动态分配堆空间,存储当前工作目录字符串,然后返回。

  2. 在这种情况下,getcwp仍然不负责管理它申请的空间,所以需要你自己手动free释放,否则会导致内存泄漏。

改变当前工作目录

Gn!

chdir函数是一个Linux系统调用函数,它的作用是改变当前工作目录。"chdir" 代表 "change directory",即改变(当前工作)目录。

该函数的声明如下:

形式参数:

  1. path: 是一个指向字符数组的指针,这个数组包含了新工作目录的路径。

  2. 路径可以是绝对的(从根目录开始,例如 "/usr/local/bin")或相对的(相对于当前工作目录,例如 "../docs")。

返回值:

  1. 成功时,chdir 返回 0。

  2. 失败时,返回 -1,并设置全局变量 errno 以指示错误的原因。比较常见的错误,比如目标目录不存在、权限问题、目标不是目录等。

一个使用的示例如下:

注意:

当前工作目录是进程的属性,也就是说每一个进程都有自己的当前工作目录。而且父进程创建子进程的时候,子进程会继承父进程的当前工作目录。

这里,我们有一个执行的进程是shell/bash进程,它是一个父进程,执行可执行程序就会创建一个子进程,子进程在一开始继承父进程的当前工作目录。

然后在这个子进程中将工作目录修改了,并不会影响父进程的工作目录。这个过程有点类似于值传递,如下图所示:

改变当前工作目录-图

所以很明显,chdir函数并不等同于cd指令,cd这个shell指令可以修改shell/bash进程的当前工作目录。

那么cd指令是什么原理呢?

这是因为cd并不是一个独立于bash程序的外部可执行程序,它是bash这个程序的一部分,是bash/shell的内部指令。这个cd指令的作用就是在进程内部,自己修改自己进程的当前工作目录。

这也是which cd找不到可执行程序cd的原因,因为它不是一个独立的可执行程序,只是bash这个可执行程序的一部分。

这里涉及到了一些进程的知识,大家先了解一下,后续进程课程会详细了解。

创建目录

Gn!

mkdir(make directory)系统调用函数用于创建一个新目录。

函数声明如下:

形式参数:

  1. pathname: 要创建的目录的路径名。可以是绝对路径也可以是相对路径名。

  2. mode: 设置新目录的权限。这是一个八进制数,类似于 chmod 的权限设置(例如,0775)。注意,最终的权限还会受到进程的 umask 设置的影响。

返回值:

  1. 成功时,mkdir 返回 0。

  2. 失败时,返回 -1,并设置 errno 以指示错误的原因。常见错误比如目录已存在等。

一个演示的代码如下:

注意:

新建一个目录设定权限为777,你可能会发现最终生成的目录权限为775

  1. 这是因为文件掩码 umask 的影响

  2. 文件掩码 umask 是一个三位的八进制数,用于在创建新文件或者目录时移除一些权限

  3. 比如umask若是0002,指定权限为777,最终结果就会是775,相当于"新权限 - 掩码"才得到新权限

  4. 可以通过指令umask来查看当前掩码值,也可以用umask n来设定一个新的掩码值。

  5. 掩码的存在是为了系统安全,避免新文件目录拥有过多的权限。较低的 umask 值会导致更开放的权限,而较高的 umask 值会导致更严格的权限。

更加详细的关于umask文件权限掩码的概念和计算,可以参考之前的文档:文件权限掩码

删除目录

Gn!

rmdir(remove directory)系统调用函数用于删除一个空目录,注意只能删除空目录。

函数声明如下:

参数:pathname: 要删除的空目录的路径名。

返回值:

  1. 成功时,rmdir 返回 0。

  2. 失败时,返回 -1,并设置 errno 以指示错误的原因。常见的错误原因有目录非空,不存在或无权限等。

一个演示示例如下:

该函数比较简单,没有什么值得注意的。

目录相关POSIX库函数/目录流

Gn!

在上面我们已经学会了目录的初级操作:创建、删除等。那么这一小节,我们将使用目录流,来查看目录中的内容。

在具体讲解之前,我们还是要解释一下标题:

我们这里使用的目录流是符合POSIX标准的C语言库函数,这意味着它不能直接用于Windows系统,只能类Unix系统平台上使用。

首先,既然是目录流,那我们就需要回忆一下流的模型,类似水流或者流水线:

流模型-图

而且我们已经知道,目录中存储的是子目录或文件的目录项,它们近似以链表的形式链接。

那么一个目录流就可以用下图来理解:

目录流-模型图

目录流指针,完全可以和文件流指针做类似的理解,它一开始指向目录文件的第一个目录项,并且可以继续向后移动。

目录流和文件流的API对比如下,你可以发现它们非常的类型:

目录流 VS 文件流

文件流目录流
fopenopendir
fcloseclosedir
freadreaddir
fwrite×
ftelltelldir
fseekseekdir
rewindrewinddir

但还是要再强调一下,Linux目录流是符合POSIX标准的API,一般仅兼容类Unix平台,在Windows平台下都是不可以直接使用的。

注意:目录流是没有写操作的,只能读。这是因为如果开放写权限非常容易破坏文件系统的目录结构,只提供读是为了保障文件系统的安全。

打开目录流

Gn!

opendir函数,用于打开一个目录流以读取其中的目录项。

当你使用 opendir 打开一个目录时,系统会创建一个目录流,并返回一个指向该流的DIR指针。

该目录流指针一开始指向目录项第一个,然后程序可以通过函数来移动这个目录项指针,从而实现对目录的遍历和管理。

从使用上来看,它的特点非常类似于文件流的fopen函数以及FILE指针,只不过文件流是"ISO-C"官方标准C语言的库函数,而我们今天讲的目录流是POSIX标准的库函数,不是官方C标准库函数。

该函数的声明如下:

形式参数:name: 字符串,代表要打开的目录的路径。

返回值:

  1. 成功时,返回指向 DIR 类型的指针,即目录流的指针

  2. 失败时,返回 NULL 并设置 errno 以指示错误的原因。常见的失败原因,比如目录没有权限打开、目录不存在等。

关闭目录流

Gn!

既然可以打开目录流,那么就可以关闭目录流。这样做可以释放分配给目录流的资源,避免内存泄漏。

函数声明如下:

形式参数:dirp: 由 opendir 返回的目录流指针。

返回值:

  1. 成功:返回0

  2. 失败时,返回 -1 并设置 errno 以指示错误的原因。常见的错误原因就是乱传指针,传入的指针不是打开的目录流指针。

综合打开目录流和关闭目录流,我们可以写出一个目录流操作的标准格式写法:

这一整套先打开,最后关闭,中间进行目录流操作的流程完全类似于我们之前学过的文件流,可以视为目录流操作的一套惯用法。

读目录流

Gn!

当你使用 opendir 打开一个目录流后,可以使用 readdir 依次读取目录中的每个条目,直到目录中没有更多条目为止。

其函数的声明如下:

形式参数:dirp: 由 opendir 返回的目录流指针。

返回值:

  1. 返回值: 成功时,返回指向 struct dirent 结构体对象的指针,这个结构体当中包含了目录下文件和子目录的信息(如文件名等)。

  2. 当目录中没有更多条目时返回 NULL

  3. 当函数出错时该函数也会返回NULL。但只要传给函数的指针是一个打开的目录流指针,该函数一般不会出错,所以可以把返回NULL作为读完目录流的标记,而不需要做错误处理。

聪明的同学,已经在思考一个问题了:

该函数返回一个指向结构体对象的指针,说明函数内部肯定分配了额外的内存空间,那么这片空间需不需要程序员来手动释放呢?

不需要,在man手册中,官方开发者明确提出了:返回值指针指向的结构体对象是静态分配的内存,这意味着这个结构体对象的存储方式是由系统管理的,而不需要由程序员手动管理。

man手册的原文如下:

On success, readdir() returns a pointer to a dirent struc‐ture. (This structure may be statically allocated; do not attempt to free(3) it.)

实际上只需要使用closedir函数关闭目录流,目录项相应的结构体对象就会被释放销毁,不需要再手动free这个结构体了。

dirent结构体

Gn!

要想真正理解和掌握 opendir 函数,最需要理解的就是dirent结构体,这个结构体到底是什么呢?

首先dirent不是一个单词,它是词组directory entry的缩写,也就是目录项的意思。所以dirent结构体,就是当前目录下的某个文件/子目录的目录项结构体,这个结构体中会存储当前目录下的某个文件/子目录的信息。

通过查询man手册,我们知道dirent结构体的定义如下:

其中文件类型d_type的可选值如下(使用宏常量定义的整数):

所以我们这么理解readdir函数:

readdir函数需要传入一个目录流指针,用于从打开的目录流中读取下一个目录项,并返回一个存储该目录项数据的 struct dirent 结构体指针。

这个结构体对象由readdir函数在内部申请空间创建,也由目录流库自动管理生命周期,程序不需要考虑它的内存管理问题。

扩展:如何查询结构体类型的具体定义

Gn!

上述代码中出现了一个结构体类型,那么如何查询结构体类型的具体定义呢?

除了查阅man手册外,我们也可以直接自己搜索预处理后的.i文件,如下图所示:

查询结构体类型的具体定义-图1

当然你也可以通过grep指令来查询:

其中-C10选项表示显示匹配行及其周围的10行。

包括这些类型的别名,也都可以用这种方式来查询:

查询结构体类型的具体定义-图2

以上。

破产版ls指令

Gn!

以上,我们就可以写出一个用于打印目录下所有文件的:inode编号、目录项大小、文件类型、文件名信息的类似ls的工具(纯破产版,完全不一样好吧)。

参考代码如下:

这段代码你应该非常熟悉,因为我们在使用文件流读文件内容时,也是一模一样的操作!实际上它们的原理也就是差不多的!

移动目录流指针的位置

Gn!

和文件流FILE指针视为指示文件当前读写位置的文件指针一样,目录流的DIR指针也用于指示当前读写目录项的位置。

在上面的代码中,DIR指针遍历完了所有的目录项,也就是该指针指向了目录文件的末尾,那么有没有办法将这个指针再移动回去或者重置它么?

当然可以,和文件流类似,这里需要涉及三个函数:seekdirtelldirrewinddir

下面我们先来看一下这三个函数的作用,再来具体讲解一下其中的细节问题。

seekdir和telldir函数

Gn!

seekdir 函数用于设置目录流指针的当前位置,但和文件流的fseek函数不同的是:该函数没有参照点的概念,只能通过 telldir 函数提供的返回值来进行指针的移动。

即:必须先用telldir函数记录指针的位置,然后才能够使用seekdir返回记录的位置!

telldir函数的声明如下:

形式参数:dirp 是由 opendir 返回的目录流指针。

返回值:

  1. 返回值是目录流指针当前读取位置的标记,是一个长整型值。该返回值需要给seekdir函数使用,一般不作其它用途。

  2. 该函数如果出错,会返回-1。但只要正确传递已打开的目录流指针,该函数一般不会出错,所以一般不做错误处理。

seekdir函数的声明如下:

形式参数:

  1. dirp 是由 opendir 返回的目录流指针。

  2. loc 是由 telldir 返回的位置标记。

返回值:

  1. 该函数没有返回值,这说明函数的设计者认为该函数不需要错误处理。

  2. 该函数必须传入由telldir函数获取的位置,在移动指针的过程中是纯粹的内存操作,也不涉及内存数据的读写操作,本身是非常安全的,不会出错,也确实不需要错误处理。

演示代码如下:

按照上面代码的思路,我们记录了file1目录项的位置,然后在下面回到这个位置,那么打印的结果是什么呢?

打印的结果却不是file1,而是.,这是为什么呢?

这其实是由目录流指针DIR*的行为引起的:

readdir 函数在读取当前目录项并返回dirent结构体指针后,会自动移动到目录流中的下一个条目。这意味着每次调用 readdir 后,目录流的内部指针都会向前移动到下一个条目。

你可以参考下图来理解这个原理:

readdir函数的行为-图

所以,对于上图的目录项来说:

  1. 如果希望记录.这个目录项以待返回,需要在readdir函数的返回值为file1时记录。

  2. 如果希望记录file1这个目录项以待返回,需要在readdir函数的返回值为..时记录。

  3. 如果希望记录..这个目录项以待返回,需要在readdir函数的返回值为dir2时记录。

也就是说,需要在readdir函数的返回值为上一个目录项时,记录下一个目录项的位置。

那我们就有一个问题了:

如果我希望记录第一个目录项dir2的位置,咋办呢?

很简单,不用记录,因为有专门的函数来实现这个功能,也就是rewinddir,倒带目录流,回到第一个目录项。

倒带目录流

Gn!

rewinddir 函数重置目录流的位置到开始处,类似于文件流中的 rewind 函数。

其函数的声明如下:

形式参数:dirp 是由 opendir 返回的目录流指针。

返回值:没有返回值。说明函数的设计者认为,该函数不会出错,也不需要进行错误的处理。

使用 rewinddir 可以使得目录流回到初始位置,这样下一次调用 readdir 将返回目录中的第一个条目。

实现无排序的ls -al指令

Gn!

以上,我们就已经学完了目录流的内容,那么下面我们将做一个练习,自己来手动实现一个简化版本的ls - al指令。

简化,主要是简化了将目录项结果排序的过程,但-l选项所有的信息都要求大家打印出来。

在上面我们已经实现了打印一个目录下,所有文件子目录的:inode编号、目录项大小、文件类型以及文件名。

这和ls -al还有哪些差距呢?

差距太大了:

  1. 文件类型应该用db-等形式来代表,而不是一个整数值。

  2. 权限信息,完全没有

  3. 硬链接数,完全没有

  4. 拥有者名和拥有组名,完全没有

  5. 文件大小,没有(目录项大小不是文件大小)

  6. 最后修改时间,没有

看起来我们现在只有一个文件名,所以为了打印这些信息,我们还需要一个学习一个非常重要的函数——stat系统调用函数

stat系统调用函数

Gn!

stat是一个系统调用函数,stat 用于获取指定文件的相关信息。

它的函数声明如下:

形式参数:

  1. path:这是一个指向字符数组的指针,代表要获取信息的文件的路径名。

  2. buf:这是一个指向 struct stat 结构的指针,用于存储获取到的path文件的信息。

返回值:

  1. 成功:返回 0

  2. 失败:返回 -1,并且 errno 被设置以指示错误的原因。常见的出错原因有文件不存在、没有权限等。

关于这个函数的调用,我们需要注意以下几个细节问题。

path参数的问题

Gn!

该函数的调用,首先就有一个小细节需要注意:

利用dirent结构体我们可以获取某个目录下所有文件的文件名,但这里的文件名就是stat系统调用函数的第一个参数吗?

并不一定是:

  1. 如果进程的当前工作目录就是dirent结构体所表示的目录(就是opendir打开的目录),那么文件名就是路径名(相对路径).

  2. 如果进程的当前工作目录不是dirent结构体所表示的目录(不是opendir打开的目录),那么"dirent结构体目录路径/文件名"才是路径名(绝对路径)。

此时基于dirent结构体,来获取stat函数形参的文件路径名就有了两种办法:

  1. 在子l进程中使用chdir函数切换进程的当前工作目录为dirent结构体所表示的目录,此时文件名就是路径名。

  2. 利用dirent结构体中获取的文件名,结合dirent结构体目录路径名,拼接得到一个绝对路径。

两种方式都可以,具体用哪个看自己的需求。

buf参数的问题

Gn!

函数的第二个形参是*buf指针,需要传入一个指向stat结构体对象的指针。该形参没有被const修饰,传入stat函数后,显然函数内部会修改指向的内容,也就是在函数内部会修改stat结构体对象。

实际上这个函数的主要作用就是给stat结构体对象赋值,这非常类似scanf函数,*buf是一个非常典型的传入传出参数。

你可能非常好奇,stat结构体到底是个什么东西呢?

确实,理解这个结构体,是理解stat系统调用函数最重要的部分。但不着急,我们先来思考一个问题:

这个传入函数的结构体对象,它的内存由谁进行管理呢?

当然是由程序员自己管理了,推荐传入函数一个局部变量结构体指针,这样内存管理可以交给栈自动管理。

stat结构体(重点)

Gn!

通过man 2 stat查看stat函数的说明手册,我们可以找到stat结构体类型的定义,这里我们仅列出对我们实现ls -l功能有用的字段:

这些字段的类型都是使用别名来定义的,在64位Linux操作系统上,这些别名的类型一般是:

  1. mode_t:一般是一个32位无符号整数。

  2. nlink_t:一般是一个64位无符号整数。

  3. uid_tuid_t:一般是一个32位无符号整数。

  4. off_t:一般是一个64位无符号整数。

这些字段中,最后修改时间st_mtim字段的类型稍微麻烦一些,是一个结构体timespec类型,代码如下:

综上所述,这里给出一个stat函数的简单调用:

可以把这段代码视为一个惯用方式。

关于stat的成员"st_mtim"以及结构体"timespec"有一个细节需要注意:

如果想要获取文件的最后修改时间戳的秒数,可以使用下面代码:

这样做是可以的,但是两个成员运算符"."的连用未免有些麻烦,于是在stat结构体的类型定义外,还有一个宏定义

这就是man手册中显示的内容:

stat结构体外的宏定义-图

基于这个宏定义,下面两条代码就是完全等价的:

注意:

  1. 一个是"st_mtim",这是一个结构体类型,它是stat结构体的成员。

  2. 一个是宏定义"st_time",它是一个"long int"类型的表示文件最后修改时间的时间戳秒数。

实现青春版ls -al指令

Gn!

以上内容学完后,我们就可以利用stat函数实现一个青春版的ls -al指令。

参考代码如下所示:

只需要注意stat函数调用时需要传入的两个参数就可以了:

  1. 第一个参数必须是文件的路径名,而文件名并不一定直接就是路径名。

  2. 第二个参数必须是一个已申请内存的stat结构体指针,整个stat函数相当于给stat结构体对象初始化。

处理文件类型和权限

Gn!

首先,我们要把st_mode成员的数字表示转换成"文件类型 + rwx权限"字符串,这要如何去实现呢?

首先,我们还是要理解st_mode这个成员是如何存储文件类型和权限信息的。

上面已经讲过了,st_mode在64位Ubuntu下一般是一个32位无符号整数,但实际上它也并不需要这么多位。它实际利用上的位只有:

  1. 前4个(或前5个)二进制位,用于存储数据表示该文件的类型。

  2. 最后9个二进制位,用于存储数据表示该文件的权限。

既然涉及到位,那我们应该立刻想要位运算,实际上要想通过st_mode处理转换文件类型和权限,整个过程都需要依赖位运算。

文件类型转换的实现是比较简单的,因为man手册关于这个操作,给出了一个示例,如下:

其中:

  1. sb.st_mode就是结构体的st_mode成员

  2. S_IFMT 是一个位掩码,将st_mode成员与 S_IFMT 进行位与操作,就能够得到代表文件类型的值。

  3. 随后再用这个代表文件类型的值,进行switch选择判断,从而实现根据st_mode来获取对应文件类型。

于是我们就完美的获取了文件类型。

那么接下来,如何转换权限数字,变为一个字符串呢?

st_mode成员的低9位用于存储权限信息,我们只需要从低9位开始,向后一直判断到最低位,判断每一位数字是否是1,从而决定该位置的权限字符。

整个判断的过程,仍然要使用按位与&运算符,举例:

  1. st_mode & 100 000 000(0400) ,若结果不为0说明拥有者有读权限,若为0表示拥有者没有读权限。

  2. st_mode & 010 000 000(0200) ,若结果不为0说明拥有者有写权限,若为0表示拥有者没有写权限。

  3. st_mode & 001 000 000(0100) ,若结果不为0说明拥有者有执行权限,若为0表示拥有者没有执行权限。

  4. ...

于是我们可以写出这样的代码:

这样,我们就完成了文件类型和文件权限的处理。

处理拥有者名和组名

Gn!

现在我们已经有用户UID以及组ID了,可以直接通过以下两个POSIX标准库函数将uid和gid成员转换成对应字符串。

其中passwd和group这两个结构体类型,如下所示:

这个转换的过程非常简单,不再赘述。

需要注意的是:

getpwuid()getgrgid()函数都是POSIX标准库函数,不是标准库函数,更不是系统调用。

它们实际上是直接从系统的用户数据库(通常是/etc/passwd文件)和组数据库(通常是/etc/group文件)中获取用户和组的信息。

处理最后修改时间戳

Gn!

现在我们只剩下一件事情就可以完成简化版ls -al的编写了——将时间戳修改为年月日的日期。

我们可以使用一个C语言标准库的库函数——localtime

这个函数的目的是将一个时间戳(time_t 类型)转换为表示本地时间的 struct tm 结构体。时间戳是自 1970 年 1 月 1 日(UTC)以来的秒数。

该函数的声明如下:

形式参数:

  1. timer:需要传入一个指向 time_t 类型变量的指针,也就是指向时间戳变量的指针。

  2. 注意:结构体stat的成员st_mtim是一个结构体类型,不能直接传参给localtime函数。st_mtim.tv_sec的类型则刚好匹配,可以传参。(time_t一般就是long int)

  3. 当然,你还可以直接传参stat_buf.st_mtime,其中st_mtimest_mtim.tv_sec的宏定义,可以简化代码。

返回值:返回值是指向 struct tm 结构体的指针,该结构体包含了分解后的时间元素,例如年、月、日、小时、分钟、秒等。

这个 struct tm 结构体的定义如下:

注意,这些数值的取值范围,不是正常的生活中的表示方式。比如月份不是[1, 12],而是[0, 11]

最后,你不需要手动释放 localtime 返回的 struct tm 结构体对象。localtime 函数返回的是一个指向静态分配的结构体的指针。这片内存空间由C系统自动管理和重用,程序员无需进行管理。

获取这个结构体后,就可以对照ls -al指令,选择出需要的数据项,然后拼接处理得到一个表示最后修改时间的字符串。

最终实现

Gn!

在最终实现时,我们要求大家多做一步处理:

  1. 允许启动可执行程序时,只输入可执行程序名这个命令行参数,此时表示打印当前目录下的信息。实际上ls -al也是这么设计的。

  2. 允许输入第二个命令参数,表示要打印信息的目录,ls -al也同样具有这个功能。

参考代码如下:

实现这个代码最需要理解的地方就是——文件名和路径名的区别。具体的描述可以参考下图:

文件名和路径名-解释图

以上。

扩展:实现排序功能

Gn!

shell指令ls -al的显示效果,实际上还附带一个排序效果,若想要实现这个排序效果也并非难事,我们现在学习过的知识点就已经足够了——使用qsort函数给dirent结构体数组排好序,然后再遍历打印详细信息即可。

参考的实现代码如下(有部分细节问题,可以直接查看注释解决):

以上。

实现青春版tree指令

Gn!

写一个小程序,实现青春版 tree 命令。大体效果如下:

怎么实现呢?

我们可以把整个打印分为三个部分:

  1. 打印根目录的名字

  2. 带缩进格式的,递归打印根目录下每一个目录项的信息(文件名)

  3. 最下面打印统计文件和文件数量的信息

提示:

  1. 这两个符号可以直接从tree指令执行结果里抄过来。

  2. 递归打印根目录的每一个目录项的信息,其实就是深度优先(DFS)遍历树形结构,可以采用递归的方式来实现。(而且总是优先打印出父目录的名字,才深入打印目录里的内容,这是一个先序深度优先遍历策略)

拼接绝对路径的实现方式:

你当然也可以选择使用chdir切换目录来实现这个功能,但是在递归的过程中频繁切换工作目录,是一件比较麻烦的事情,思考难度比较大,代码容易摸不到头脑,建议优先考虑使用上面的拼接绝对路径的方式。

参考代码如下:

这两种实现都是可以的,从效果上来说,都是OK的。

The End