本篇文章主要简单介绍一些 Linux 系统编程中常用的函数,如open()
、close()
、read()
、write()
等,然后使用这些函数实现一些常见的 Linux 命令,如more
、who
、ls
等。这样一方面可以练习这些函数的使用,另一方面也能对这些 Linux 命令有更深的理解。
1 man 手册
在介绍函数之前,先介绍Linux man
手册的用法。在各种Unix\Linux
系统中都有各自的man
手册,里面包括了各种系统命令、系统调用、库函数等的文档,并且对应地分成不同章节,功能十分强大。
使用方法如下
1 | 2 是对应章节号 open是要查询的函数或命令 |
不同章节对应的是不同类型的内容,因为有些系统调用的函数名和系统命令或其他章节中的内容重名,如系统命令write
用于给另一个用户发送信息,而系统调用write()
则是向文件中写入数据,所以在使用的时候最好加上对应的章节名。
输入man man
可以查看各个章节内容对应如下
- 1 - Executable programs or shell commands:系统命令
- 2 - system calls(functions provided by the kernel):系统调用函数,
- 3 - library calls(functions within program libraries):库函数
- 4 - special file(usually found in /dev):特殊文件
- 5 - file formats and convertions e.g /etc/passwd:文件格式
- 6 - games for linux:由系统中的游戏来定义
- 7 - Miscellaneous (including macro packages and conventions), e.g man(7), groff(7):附件和一些变量
- 8 - System administration commands (usually only for root):系统管理的命令
- 9 - Kernel routines [Non standard]:内核例程,非标准
不同系统显示的结果会略有差异,但前 8 个几乎都是大同小异的。
如果不知道想用函数的具体名称和章节,还可以-k
选项通过关键字来查找相关函数。如我想查找可以读取目录的相关函数可以这样做
1 | man -k read | grep directory |
PS:其实大部分时候都是用man
来查看相应函数的头文件以及可选参数名称。
2 文件相关概念
在 Linux 系统中,一切皆文件,所以对于初学者来说,文件的打开关闭、读取写入应该是最基本、最常见的操作了。而在学习相关函数之前还需要对文件相关概念有个基本的认识。
2.1 文件的类型
Linux 下有七种文件类型,并用相应标识符来表示
文件类型标识 | 文件类型 |
---|---|
- | 普通文件 |
d | 目录 |
l | 符号链接 |
s(伪文件) | 套接字 |
b(伪文件) | 块设备 |
c(伪文件) | 字符设备 |
p(伪文件) | 管道 |
七种中仅有普通文件、目录、符号链接真正地占用磁盘空间,其余四种伪文件并不占用磁盘空间。
在使用ls -l
命令查看当前目录下文件信息时,每行第一个字符即代表文件类型
1 | ls -l |
2.2 文件描述符
文件描述符是用来帮助进程来对文件进行操作的,称为 File Descriptor,在代码中表现为一个int
类型整数。
在一个 Linux 进程启动后,会在内核空间中创建一个 PCB 控制块,PCB 内部有一个文件描述符表(File descriptor table),记录着当前进程所有可用的文件描述符,也即当前进程所有打开的文件。 这个 PCB 控制块在代码实现中表现为一个struct
。
除了文件描述符表,系统还需要维护另外两张表:
- 打开文件表(open file table)
- i-node 表(i-node table)
文件描述符表每个进程都有一个,打开文件表和 i-node 表整个系统只有一个,它们三者之间的关系如下图所示。
从本质上讲,这三种表都是结构体数组,0、1、2、73、1976 等都是数组下标。表头只是添加的注释,数组本身是没有的。实线箭头表示指针的指向,虚线箭头添加的注释。
所以文件描述符不过是一个数组的下标。
通过文件描述符,可以找到文件指针,从而进入打开文件表。该表存储了以下信息:
- 文件偏移量, 也就是文件内部指针偏移量。调用 read() 或者 write() 函数时,文件偏移量会自动更新,当然也可以使用 lseek() 直接修改。
- 状态标志,比如只读模式、读写模式、追加模式、覆盖模式等
- i-node 表指针。
然而要想真正读取文件,还需通过打开文件表的 i-node 指针进入 i-node 表,该表包含了诸如以下信息
- 文件类型
- 文件大小
- 时间戳,包括创建时间、更新时间
- 文件锁
对上图的进一步说明:
- 在进程 A 中,文件描述符 1 和 20 都指向了同一个打开文件表项,标号为 23(指向了打开文件表中下标为 23 的数组元素),这可能是通过调用 dup()、dup2()、fcntl() 或者对同一个文件多次调用了 open() 函数形成的。
- 进程 A 的文件描述符 2 和进程 B 的文件描述符 2 都指向了同一个文件,这可能是在调用 fork() 后出现的(即进程 A、B 是父子进程关系),或者是不同的进程独自去调用 open() 函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。
- 进程 A 的描述符 0 和进程 B 的描述符 3 分别指向不同的打开文件表项,但这些表项均指向 i-node 表的同一个条目(标号为 1976);换言之,它们指向了同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了 open() 调用。同一个进程两次打开同一个文件,也会发生类似情况。
对于实际编程中,我们需要特别关注两个点
- 默认情况下,文件描述符范围为 0~1023,并每次默认使用表中可用的最小值
- 0,1,2 默认被系统占用,0 对应标准输入
STDIN_FILENO
,1 对应标准输出STDOUT_FILENO
,2 对应标准错误输出STDERR_FILENO
。
3 文件操作相关函数
这里先将文件操作相关函数做一个总结式的罗列介绍,相关 demo 放到下一章和后续文章中。
3.1 open()、creat()和 close()
1 |
|
关于返回值, 成功返回对应的文件描述符,失败返回-1,并自动设置errno
,所以在使用时,一般都要判断返回值。注意open
的flag
参数中,读写方式是必选的,可选的参数包括了O_CREAT|O_APPEND|O_TRUNC|O_EXCL|O_NONBLOCK
,详细说明见man
手册。
打开的文件在程序结束前要使用close()
来关闭。
1 |
|
同样失败返回-1,成功的话返回 0。
3.2 read()和 write()
从文件中读取数据使用read()
1 |
|
成功的话返回实际读取的 byte 数,失败的话返回-1,并设置errno
,若errno = EAGIN
或 EWOULDBLOCK
, 说明不是read
失败,而是read
在以非阻塞方式读一个设备文件(网络文件),并且文件无数据。
阻塞与非阻塞是设备文件、网络文件的属性。在读取设备文件、网络文件时会产生阻塞,如终端文件/dev/tty
和网络套接字。
可以使用open("dev/tty", O_RDWR | O_NONBLOCK)
来设置非阻塞状态。
向文件中写入数据使用write()
1 |
|
成功返回实际写入的 byte 数,失败时返回-1。
3.3 fcntl()
该函数可以根据文件描述符来获取和改变文件属性。称为文件控制函数。
1 |
|
该函数主要有 5 种功能:
- 复制一个现有的描述符(cmd=F_DUPFD)
- 获得/设置文件描述符标记(cmd=F_GETFD 或 F_SETFD)
- 获得/设置文件状态标记(cmd=F_GETFL 或 F_SETFL)
- 获得/设置异步 I/O 所有权(cmd=F_GETOWN 或 F_SETOWN)
- 获得/设置记录锁(cmd=F_GETLK,F_SETLK 或 F_SETLKW)
参数比较复杂,后面用到时再做补充吧。
3.4 lseek()和 truncate()
lseek()
函数用以改变打开文件的当前偏移量。
1 |
|
成功的话返回较起始位置的偏移量,失败的话返回-1,设置errno
。
常用于获取、拓展文件大小,使用方法在 4.3 节。
注意两点
- 文件读写操作使用的是同一偏移量
- 要想真正拓展文件大小必须引起 IO 操作
而使用truncate()
函数可以直接拓展文件大小
1 |
|
两个版本作用相同,只是接受参数不一样。成功返回 0,失败返回-1。
注意:如果函数设定的长度小于文件原本长度,那么超出部分将会被丢弃,反之填充\0
。
3.5 stat()和 lstat()
该函数用于获取文件状态,如文件大小、类型、权限。
1 |
|
常使用struct stat
的st_size
、st_mode
来获取文件的大小、类型和权限,st_mode
的第一位就是文件类型,该成员使用位图的原理来存放类型和权限信息。后面有相关例子。
而stat()
和lstat()
的区别是一个会符号穿透,一个不会。即假如有一个符号链接file1
,其指向文件file2
,那么对file1
使用stat()
获取的是file2
的状态,而使用lstat()
的话获取的是符号链接file1
的状态。
3.6 link()和 ulink()
在struct stat
中有一个st_nlink
成员代表该文件的链接数。在文件刚被open()
或creat()
创建时,该文件会有 1 个链接数;而在使用close()
关闭打开文件时会检查文件的链接数,如果链接数为 0,则该文件将会被隐式删除,即在使用该文件的进程结束后,系统会根据自身算法对其择机删除。
而link()
可以建立文件的硬链接。硬链接相当于文件的命名,相对原文件来说只是目录项dir_entry
不一样,但其dir_entry
中的inode
是同一个,而inode
的成员又指向实际的数据块,所以实际内存位置是同一个;软链接即符号链接,则相当于文件的快捷方式,其拥有不同于原文件的dir_entry
、inode
,自然最后指向的数据块也不一致,但其数据块存放了原文件的路径。
1 |
|
unlink()
可以删除硬链接和软链接。如果是软链接直接删除,如果是硬链接,则删除后如果原文件的链接数变为 0,才会在像在上文提到的那样被隐式删除。
1 |
|
3.7 opendir()、closedir()和 readdir()
目录本质上也是文件,只是目录文件存放的是其他文件的dir_entry
结构体。
可以使用opendir()
和closedir()
来打开和关闭目录。注意这是两个库函数,而不是系统调用函数,在man
手册第 2 章。
1 |
|
需要注意的是,opendir()
的参数和返回值和open()
略有差别。参数无需多说,是相应目录的路径;在成功时返回一个DIR*
类型的目录流,失败时返回NULL
。
closedir()
成功返回 0,失败返回-1。
而readdir()
接受一个目录流类型的指针,从该目录下对逐个文件读取信息,并存放在一个struct dirent
中,并返回其指针。通过其返回的指针,我们就可以获取到目录中的信息。
4 系统编程实践
4.1 cp 命令的实现
cp
命名用于将文件拷贝,用法如下
1 | cp file1 file2 |
4.1.1 实现思路
其原理非常简单,流程如下
- 打开
file1
,新建文件file2
- 读取
file1
内容到缓冲区 - 将缓冲区内容写入
file2
- 重复 2、3 直到
file1
文件被全部读取、写入
4.1.2 实现代码
实现代码如下
1 |
|
编译完成以后执行一下,然后用 Linux 命令的cmp
命令比较一下原文件和拷贝文件是否有不同
1 | gcc cp01.c -o cp01 |
没有提示信息就是没有不同,说明程序正确。
4.2 ls 命令的实现
ls 可以说是 Linux 系统中最常用的命令之一了,用来查看当前或其他目录下的文件。但 ls 有许许多多的选项,全部实现自然不太可能,也没这个必要。这里我们仅实现最基础不带选项的功能版本,但要求其可以接受指定目录作为参数。
4.2.1 实现思路
首先 ls 既然是查看目录下文件,故而必须要打开目录,读取目录,返回目录信息,由于我们这里只实现不带选项的ls版本,所以只需返回目录下的文件名称即可。
- 判断是否带参数,若带参数,以参数作为目录名,否则默认当前目录
- 使用
opendir
打开目录 - 使用
readdir
循环读取目录中文件,过滤.
和..
- 输出循环读取到文件的名称
- 读取完毕后使用
closedir
关闭目录流
4.2.2 实现代码
1 |
|
4.3 more 命令的实现
more
用于分屏查看文件内容,和cat
直接打印文件内容有所不同,其可以通过接受键盘输入来逐行、下一屏、回退显示内容,如
1 | more cp01.c |
4.3.1 实现思路
其工作流程可以用伪代码描述如下
1 | open the file |
即先打开文件,以文件流作为输入,显示一屏幕内容,固定行数如 24 行的内容,然后显示提示“more?”,根据用户的输入来进行下一步操作:若用户键入回车,则获取一行内容,多显示一行;若用户键入空格,则再次获取 24 行内容来显示下一屏幕的内容;若用户键入“q”,则退出程序。
4.3.2 实现版本 1.0
在这个版本中,将显示内容和接受用户输入的操作作为两个函数do_more()
和see_more()
来实现。用法将满足第 1 节中的 3 种用法。
在主函数中,先判断是否给定文件参数,有的话,再打开文件,将文件描述符传入do_more()
,对每个文件参数执行结束后,关闭文件;do_more()
函数将会从文件中读取内容,将其显示,并调用see_more()
接受用户阅读内容中输入的参数。
实现代码如下
1 |
|
但目前该程序存在一个问题,使用其显示重定向的内容会出现问题,如下命令
1 | who | ./more1 |
此时在程序中,将通过标准输入stdin
来读取重定向的内容,但在see_more()
函数中,同样使用getchar()
来从标准输入中读取用户命令,这样显然起了冲突。
解决方法是,从标准输入中读取要显示的内容,然后直接从键盘读取用户输入的命令,见下一节的实现。
4.3.3 实现版本 2.0
类 Unix 系统中,文件/dev/tty
是键盘和显示器的设备描述文件,向这个文件写相当于显示在用户的屏幕上,读相当于从键盘获取用户的输入。即使程序的输入输出被重定向,程序还是可以通过这个文件与终端交换数据。
实现方式很简单,只需要改变see_more()
函数,使该函数接受/dev/tty
文件流而不是标准输入即可。
代码如下
1 |
|
4.4 lseek 的使用
下面这段展示了如何通过lseek()
改变偏移量来先将数据写入,再从头将写入数据读出,以及通过lseek()
来改变文件大小的方法
1 |
|
5 结束语
本篇文章在先介绍了man
手册的用法,这将是之后最常用的查询函数说明的方法;然后主要简单地总结了 Linux 中的文件类型,介绍了文件描述符的原理;接着对文件操作常用的相关函数做了简单记录,对一些要点做了说明;最后通过实现cp
和more
命令作为两个小 demo,这两个 demo 的代码参照了《Unix\Linux 编程实践教程》一书,这里还补充了lseek()
函数的例子。
还有stat()
等函数的使用实例将留待后续文章中补充。