Linux系统编程(二):文件 I/O(中)

参考引用

  • UNIX 环境高级编程 (第3版)
  • 嵌入式Linux C应用编程-正点原子

1. 一个简单的文件 IO 示例

  • 只读方式打开一个已经存在的文件(src_file),然后只写方式打开一个新建文件(dest_file),权限设置如下
    • 文件所属者拥有读、写、执行权限
    • 同组用户与其他用户只有读权限
    • 从 src_file 文件偏移头部 500 个字节位置开始读取 1KB 字节数据,然后将读取出来的数据写入到 dest_file 文件中,从文件开头处开始写入,1KB 字节大小,操作完成之后使用 close 显式关闭所有文件,然后退出程序
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        char buffer[1024];
        int fd1, fd2;
        int ret;
    
        /* 打开 src_file 文件 */
        fd1 = open("./src_file", O_RDONLY);
        if (-1 == fd1) {
            printf("Error: open src_file failed!\n");
            return -1;
        }    
    
        /* 新建 dest_file 文件并打开 */
        fd2 = open("./dest_file", O_WRONLY | O_CREAT | O_EXCL, S_IRWXU | S_IRGRP | S_IROTH);
        if (-1 == fd2) {
            printf("Error: open dest_file failed!\n");
            ret = -1;
            goto err1;
        }
    
        /* 将 src_file 文件读写位置移动到偏移文件头 500 个字节处 */
        ret = lseek(fd1, 500, SEEK_SET);
        if (-1 == ret)
            goto err2;
    
        /* 读取 src_file 文件数据,大小 1KByte */
        ret = read(fd1, buffer, sizeof(buffer));
        if (-1 == ret) {
            printf("Error: read src_file filed!\n");
            goto err2;
        }
    
        /* 将 dest_file 文件读写位置移动到文件头 */
        ret = lseek(fd2, 0, SEEK_SET);
        if (-1 == ret)
            goto err2;
    
        /* 将 buffer 中的数据写入 dest_file 文件,大小 1KByte */
        ret = write(fd2, buffer, sizeof(buffer));
        if (-1 == ret) {
            printf("Error: write dest_file failed!\n");
            goto err2;
        }
        printf("OK: test successful\n");
        ret = 0;
    err2:
        close(fd2);
    
    err1:
        close(fd1);
        return ret;
    }
    

2. 文件描述符

  • 上述代码调用 open 函数会有一个返回值,如:fd1(源文件 src_file 被打开时所对应的文件描述符) 和 fd2(目标文件 dest_file 被打开时所对应的文件描述符),这是一个 int 类型的数据,在 open 函数执行成功的情况下,会返回一个非负整数,该返回值就是一个文件描述符(file descriptor)

    • 文件描述符是一个非负整数,对于 Linux 内核而言,所有打开的文件都会通过文件描述符进行索引
  • 一个进程可以打开多个文件,但可以打开的文件数是有限制,在 Linux 系统下,可以通过 ulimit 命令来查看进程可打开的最大文件数,最大值默认情况下是 1024

    • 对一个进程来说,文件描述符是一种有限资源,从 0 开始分配,文件描述符数字最大值为 1023(0~1023),其中 0、1、2 这三个文件描述符已经默认被系统占用:标准输入(0)、标准输出(1)以及标准错误(2)
    • 每一个被打开的文件在同一个进程中都有一个唯一的文件描述符,不会重复,如果文件被关闭后,它对应的文件描述符将会被释放,然后可以再次分配给其它打开的文件
    $ ulimit -n
    1024
    
  • 每一个硬件设备都对应于 Linux 下的某一个文件,把这类文件称为设备文件。所以设备文件对应的其实是某一硬件设备,应用程序通过对设备文件进行读写等操作,来使用、操控硬件设备,如:LCD 显示器、串口、音频、键盘等

    • 标准输入一般对应的是键盘,标准输出和标准错误一般指的是 LCD 显示器

3. man 手册-以 open 为例

  • 通过 man 命令(也叫 man 手册)来查看某一个 Linux 系统调用的帮助信息,man 命令可以将该系统调用的详细信息显示出来,譬如函数功能介绍、函数原型、参数、返回值以及使用该函数所需包含的头文件等信息
  • man 命令后面跟着两个参数
    • 数字 2 表示系统调用,man 命令除了可以查看系统调用的帮助信息外
    • 还可以查看 Linux 命令(对应数字 1)以及标准 C 库函数(对应数字 3)所对应的帮助信息
    • 最后一个参数 open 表示需要查看的系统调用函数名
    $ man 2 open
    
    OPEN(2)                         Linux Programmer's Manual                        OPEN(2)
    
    NAME
           open, openat, creat - open and possibly create a file
    
    SYNOPSIS
           #include 
           #include 
           #include 
    
           int open(const char *pathname, int flags);
           int open(const char *pathname, int flags, mode_t mode);
    
           int creat(const char *pathname, mode_t mode);
    
           int openat(int dirfd, const char *pathname, int flags);
           int openat(int dirfd, const char *pathname, int flags, mode_t mode);
    
  • open 函数用于打开文件,还可以创建一个新的文件
    • open 函数有两种原型(可变参函数
      • 可传入 2 个参数(pathname、flags)
      • 也可传入 3 个参数(pathname、flags、mode),参数 mode 需要在 flags 满足条件时才会有效
      • 如果 pathname 是一个符号链接,会对其进行解引用
  • 使用 open 函数打开一个指定的文件,如果该文件不存在则创建该文件,创建该文件时,将文件权限设置如下
    • 文件所属者拥有读、写、执行权限
    • 同组用户与其他用户只有读权限
    • 使用可读可写方式打开
    int fd = open("/home/dengtao/hello", O_RDWR | O_CREAT, S_IRWXU | S_IRGRP | S_IROTH);
    if (-1 == fd)
    return fd;
    

    只有用户对该文件具有相应权限时,才可以使用对应的标志去打开文件,否则会打开失败

  • open 函数文件权限宏
    Linux系统编程(二):文件 I/O(中)_第1张图片

4. Linux 系统如何管理文件

4.1 静态文件

  • 文件在没有被打开的情况下一般都是存放在磁盘中的,如:电脑硬盘、移动硬盘、U 盘等外部存储设备,文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,称为静态文件
    • 文件储存在硬盘上,硬盘的最小存储单位叫做 “扇区”(Sector),每个扇区储存 512 字节(相当于 0.5KB),操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)
    • 这种由多个扇区组成的 “块”,是文件存取的最小单位
    • “块” 的大小,最常见的是 4KB,即连续八个 sector 组成一个 block

4.2 inode

  • 磁盘在进行分区、格式化的时候会将其分为两个区域
    • 一个是数据区,用于存储文件中的数据
    • 另一个是 inode 区,用于存放 inode table(inode 表)
  • inode table 中存放的是一个一个的 inode(也称 inode 节点),不同的 inode 表示不同的文件,每一个文件都必须对应一个 inode,inode 实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件不同信息,如:文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、文件类型、文件数据存储的 block(块)位置等信息,如下图所示(文件名并不记录在 inode 中

Linux系统编程(二):文件 I/O(中)_第2张图片

  • 由此可知,inode table 表本身也需要占用磁盘的存储空间。每一个文件都有唯一的一个 inode,每一个 inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到 inode table 中所对应的 inode,查看文件的 inode 编号:
    $ ls -il
    total 84
    # 每行第一个数字代表 inode 号,如 2494267
    2494267 drwxrwxr-x 2 yue yue 4096 12月 10 18:23 mydir
    2490502 -rwxrwxr-x 1 yue yue 8384 12月 10 18:08 open
    2490536 -rwxrwxr-x 1 yue yue 8384 12月 10 18:09 open2
    2494210 -rw-rw-r-- 1 yue yue  209 12月 10 18:09 open2.c
    ...
    
    # 还可以使用 stat 命令查看 inode 号
    $ stat test.c
    stat open
      File: open
      Size: 8384      	Blocks: 24         IO Block: 4096   regular file
    Device: 801h/2049d	Inode: 2490502     Links: 1
    Access: (0775/-rwxrwxr-x)  Uid: ( 1000/     yue)   Gid: ( 1000/     yue)
    Access: 2023-12-10 18:08:10.828318773 +0800
    Modify: 2023-12-10 18:08:09.131470772 +0800
    Change: 2023-12-10 18:26:07.894582897 +0800
     Birth: -
    

打开一个文件,系统内部会将这个过程分为三步

  • 系统找到这个文件名所对应的 inode 编号
  • 通过 inode 编号从 inode table 中找到对应的 inode 结构体
  • 根据 inode 结构体中记录的信息,确定文件数据所在的 block(块)位置,并读出数据

4.3 文件打开时的状态

  • 当调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并将静态文件的数据内容从存储设备(磁盘)中读取到内存中进行管理、缓存
    • 把内存中的这份文件数据叫做动态文件、内核缓冲区
    • 以后对这个文件的读写操作,都是针对内存中这一份动态文件,而不是磁盘中存放的静态文件
  • 当对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了,数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中
    • 打开一个大文件的时候会比较慢
    • 文档写了一半忘记保存,此时突然停电直接关机了,当重启电脑打开文档,发现之前写的内容已经丢失

为什么需要内存中的动态文件?

  • 因为磁盘、硬盘、U 盘等存储设备基本都是 Flash 块设备,而块设备硬件有读写限制等特征:块设备是以一块一块为单位进行读写的(一个块包含多个扇区,而一个扇区包含多个字节),一个字节的改动也需要将该字节所在的 block 全部读取出来进行修改,修改完成之后再写入块设备中,所以对块设备的读写操作非常不灵活
  • 而内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常灵活,所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存,读写操作都是针对这份动态文件,而不是直接去操作磁盘中的静态文件,内存的读写速率远比磁盘读写快得多

4.4 进程控制块

  • Linux 系统中,内核会为每个进程设置一个专门的数据结构用于管理该进程,如:记录进程的状态信息、运行特征等,把这个称为进程控制块(Process control block,缩写 PCB)
    • PCB 数据结构体中有一个指针指向了文件描述符表,文件描述符表中的每一个元素索引到对应的文件表
    • 文件表也是一个数据结构体,其中记录了很多文件相关的信息,如:文件状态标志、引用计数、当前文件的读写偏移量以及 i-node 指针(指向该文件对应的 inode)等
    • 进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表,其示意图如下所示

Linux系统编程(二):文件 I/O(中)_第3张图片

5. 返回错误处理与 errno

  • Linux 系统对常见的错误做了一个编号,每一个编号都代表着每一种不同的错误类型,当函数执行发生错误的时候,操作系统会将这个错误所对应的编号赋值给 errno 变量,每一个进程(程序)都维护了自己的 errno 变量,它是程序中的全局变量,在 头文件中申明
  • errno 本质上是一个 int 类型的变量,用于存储错误编号,但是需要注意的是,并不是执行所有的系统调用或 C 库函数出错时,操作系统都会设置 errno

5.1 strerror 函数

  • C 库函数 strerror() 可以将对应的 errno 转换成适合查看的字符串信息
    // man 3 strerror 查看帮助手册
    #include 
    
    char *strerror(int errnum);
    
  • 测试案例
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        int fd;
        /* 打开文件,test_file 不存在 */
        fd = open("./test_file", O_RDONLY);
        if (-1 == fd) {
            printf("Error: %s\n", strerror(errno));
            return -1;
        }
        close(fd);
        return 0;
    }
    
    $ gcc test.c -o test
    $ ./test
    Error: No such file or directory  # strerror() 返回的字符串
    

5.2 perror 函数

  • 除了 strerror 函数之外,还可以使用 perror 函数来查看错误信息,一般用的最多的还是这个函数,调用此函数不需要传入 errno,函数内部会自己去获取 errno 变量的值
    // man 3 perror 查看帮助手册
    #include 
    
    // s :在错误提示字符串信息之前,可加入自己的打印信息,不加则传入空字符串即可
    void perror(const char *s);
    
  • 测试案例
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        int fd;
        /* 打开文件,test_file 不存在 */
        fd = open("./test_file", O_RDONLY);
        if (-1 == fd) {
            perror("open error");
            return -1;
        }
        close(fd);
        return 0;
    }
    
    $ gcc test.c -o test
    $ ./test
    open error: No such file or directory  # open error 便是自己附加的打印信息
    

6. exit 、_exit 、_Exit

  • 在 Linux 系统下,进程(程序)退出可以分为正常退出和异常退出,这里说的异常并不是执行函数出现了错误这种情况,往往是一种不可预料的系统异常,可能是执行了某个函数时发生的、也有可能是收到了某种信号等
  • 在 Linux 系统下,进程正常退出除了可以使用 return 之外,还可以使用 exit()、_exit() 以及_Exit()

6.1 _exit() 和 _Exit() 函数

  • main 函数中使用 return 后返回,return 执行后把控制权交给调用函数,结束该进程
  • 调用 _exit()(_Exit() 与其等价)函数会清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程,将控制权交给操作系统
    #include 
    void _exit(int status);
    
    #include 
    void _Exit(int status);
    
    ...
    fd = open("./test_file", O_RDONLY);
    if (-1 == fd) {
        perror("open error");
        _exit(-1);
    }
    ...
    

6.2 exit() 函数

  • exit() 函数 和 _exit() 函数都是用来终止进程的
    • exit() 是一个标准 C 库函数
    • _exit() 和 _Exit() 是系统调用
    #include 
    
    void exit(int status);
    

7. 空洞文件

  • lseek() 系统调用允许文件偏移量超出文件长度,如:有一个 test_file,文件大小 4K(4096 个字节),若通过 lseek() 系统调用将该文件的读写偏移量移动到偏移文件头部 6000 个字节处,接下来使用 write()函数对文件进行写入操作
    • 此时将是从偏移文件头部 6000 个字节处开始写入数据,意味着 4096~6000 字节之间出现了一个空洞,因为这部分空间并没有写入任何数据,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件
    • 文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分大小的

空洞文件有什么用呢?

  • 空洞文件对多线程共同操作文件是有用的,有时候创建一个很大的文件,如果单个线程从头开始依次构建该文件需要很长时间,有一种思路就是将文件分为多段,然后使用多线程来操作,每个线程负责其中一段数据的写入,最后将他们连接起来

8. O_APPEND 标志

  • 如果 open() 函数带了 O_APPEND 标志,打开文件,当每次使用 write() 函数对文件进行写操作时,都会自动把文件当前位置偏移量移动到文件末尾,从文件末尾开始写入数据,也就是意味着每次写入数据都是从文件末尾开始

    • O_APPEND 标志并不会影响读文件,当读取文件时,O_APPEND 标志并不会影响读位置偏移量,读文件位置偏移量默认情况下依然是文件头
    • 使用 O_APPEND 标志,即使是通过 lseek() 函数也无法修改写文件时对应的位置偏移量(并不包括读),写入数据依然是从文件末尾开始,lseek() 并不会该变写位置偏移量
  • 案例演示

    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        char buff[16];
        int fd;
        int ret;
    
        /* 打开文件 */
        fd = open("./test.txt", O_RDWR | O_APPEND);
        if (-1 == fd) {
            perror("open error");
            exit(-1);
        }
    
        /* 初始化 buffer 中的数据 */
        memset(buff, 0x55, sizeof(buff));
    
        /* 写入数据: 写入 4 个字节数据 */
        ret = write(fd, buff, 4);
        if (-1 == ret) {
            perror("write error");
            goto err;
        }
    
        /* 将 buffer 缓冲区中的数据全部清 0 */
        memset(buff, 0x00, sizeof(buff));
    
        /* 将位置偏移量移动到距离文件末尾 4 个字节处 */
        ret = lseek(fd, -4, SEEK_END);
        if (-1 == ret) {
            perror("lseek error");
            goto err;
        }
    
        /* 读取数据 */
        ret = read(fd, buff, 4);
        if (-1 == ret) {
            perror("read error");
            goto err;
        }
    
        printf("0x%x 0x%x 0x%x 0x%x\n", buff[0], buff[1], buff[2], buff[3]);
        ret = 0;
    
    err:
        close(fd);
        exit(ret);
    }
    
    $ gcc append.c -o append
    $ ./append 
    0x55 0x55 0x55 0x55
    

9. 多次打开同一个文件

  • 同一个文件可以被多次打开,如:在一个进程中多次打开同一个文件、在多个不同的进程中打开同一个文件

9.1 案例 1

  • 结论 1:一个进程内多次 open 打开同一个文件,那么会得到多个不同的文件描述符 fd,同理在关闭文件的时候也需要调用 close 依次关闭各个文件描述符
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        int fd1, fd2, fd3;
        int ret;
    
        /* 第一次打开文件 */
        fd1 = open("./test.txt", O_RDWR);
        if (-1 == fd1) {
            perror("open error");
            exit(-1);
        }   
    
        /* 第二次打开文件 */
        fd2 = open("./test.txt", O_RDWR);
        if (-1 == fd2) {
            perror("open error");
            ret = -1; 
            goto err1;
        }   
    
        /* 第三次打开文件 */
        fd3 = open("./test.txt", O_RDWR);
        if (-1 == fd3) {
            perror("open error");
            ret = -1; 
            goto err2;
        }   
    
        /* 打印出 3 个文件描述符 */
        printf("%d %d %d\n", fd1, fd2, fd3);
    
        close(fd3);
        ret = 0;
    
    err2:
        close(fd2);
    
    err1:
        close(fd1);
        exit(ret);
    }
    
    $ gcc openmul.c -o openmul
    $ ./openmul 
    3 4 5  # 得到相同数量的文件描述符(程序中分配得到的最小文件描述符一般是 3)
    

9.2 案例 2

  • 结论 2:一个进程内多次 open 打开同一个文件,在内存中并不会存在多份动态文件
    • 当调用 open 函数的时候,会将文件数据(文件内容)从磁盘等块设备读取到内存中,将文件数据在内存中进行维护,内存中的这份文件数据称为动态文件

如果同一个文件被多次打开,那么该文件所对应的动态文件是否在内存中也存在多份?也就是说,多次打开同一个文件是否会将其文件数据多次拷贝到内存中进行维护? 答:不会

  • 假如,内存中只有一份动态文件,那么读取得到的数据应该就是 0x11、0x22、0x33、0x44
  • 如果存在多份动态文件,那么通过 fd2 读取的是与它对应的动态文件中的数据,那就不是 0x11、0x22、0x33、0x44,而是读取出 0 个字节数据,因为它的文件大小是 0
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main(void) {
    char buffer[4];
    int fd1, fd2;
    int ret;
    
    /* 创建新文件 test_file 并打开 */
    fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL,
            S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
    if (-1 == fd1) {
        perror("open error");
        exit(-1);
    }
    
    /* 再次打开 test_file 文件 */
    fd2 = open("./test_file", O_RDWR);
    if (-1 == fd2) {
        perror("open error");
        ret = -1;
        goto err1;
    }
    
    /* 通过文件描述符 fd1 写入 4 个字节数据 */
    buffer[0] = 0x11;
    buffer[1] = 0x22;
    buffer[2] = 0x33;
    buffer[3] = 0x44;
    ret = write(fd1, buffer, 4);
    if (-1 == ret) {
        perror("write error");
        goto err2;
    }
    
    /* 将读写位置偏移量移动到文件头 */
    ret = lseek(fd2, 0, SEEK_SET);
    if (-1 == ret) {
        perror("lseek error");
        goto err2;
    }
    
    /* 通过文件描述符 fd2 读取数据 */
    memset(buffer, 0x00, sizeof(buffer));
    ret = read(fd2, buffer, 4);
    if (-1 == ret) {
        perror("read error");
        goto err2;
    }
    printf("0x%x 0x%x 0x%x 0x%x\n", buffer[0], buffer[1], buffer[2], buffer[3]);
    ret = 0;

err2:
    close(fd2);

err1:
    /* 关闭文件 */
    close(fd1);
    exit(ret);
}
  • 结论 3:一个进程内多次 open 打开同一个文件,不同文件描述符所对应的读写位置偏移量是相互独立的
    • 同一个文件被多次打开,会得到多个不同的文件描述符,也就意味着会有多个不同的文件表,而文件读写偏移量信息就记录在文件表数据结构中,所以从这里可以推测不同的文件描述符所对应的读写位置偏移量是相互独立的,并没有关联在一起,并且文件表中 i-node 指针指向的都是同一个 inode,如下图所示
      Linux系统编程(二):文件 I/O(中)_第4张图片

多个不同的进程中调用 open() 打开磁盘中的同一个文件,同样在内存中也只是维护了一份动态文件,多个进程间共享,它们有各自独立的文件读写位置偏移量

9.3 案例 3

  • 一个进程中两次调用 open 函数打开同一个文件,分别得到两个文件描述符 fd1 和 fd2,使用这两个文件描述符对文件进行写入操作,那么它们是分别写(各从各的位置偏移量开始写)还是接续写(一个写完,另一个接着后面写)

    • 因为这两个文件描述符所对应的读写位置偏移量是相互独立的,所以是分别写
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        unsigned char buffer1[4], buffer2[4];
        int fd1, fd2;
        int ret;
        int i;
    
        /* 创建新文件 test_file 并打开 */
        fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL,
                S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
        if (-1 == fd1) {
            perror("open error");
            exit(-1);
        }
    
        /* 再次打开 test_file 文件 */
        fd2 = open("./test_file", O_RDWR);
        if (-1 == fd2) {
            perror("open error");
            ret = -1;
            goto err1;
        }
    
        /* buffer 数据初始化 */
        buffer1[0] = 0x11;
        buffer1[1] = 0x22;
        buffer1[2] = 0x33;
        buffer1[3] = 0x44;
    
        buffer2[0] = 0xAA;
        buffer2[1] = 0xBB;
        buffer2[2] = 0xCC;
        buffer2[3] = 0xDD;
    
        /* 循环写入数据 */
        for (i = 0; i < 4; i++) {
            ret = write(fd1, buffer1, sizeof(buffer1));
            if (-1 == ret) {
                perror("write error");
                goto err2;
            }
            ret = write(fd2, buffer2, sizeof(buffer2));
            if (-1 == ret) {
                perror("write error");
                goto err2;
            }
        }
    
        /* 将读写位置偏移量移动到文件头 */
        ret = lseek(fd1, 0, SEEK_SET);
        if (-1 == ret) {
            perror("lseek error");
            goto err2;
        }
    
        /* 读取数据 */
        for (i = 0; i < 8; i++) {
            ret = read(fd1, buffer1, sizeof(buffer1));
            if (-1 == ret) {
                perror("read error");
                goto err2;
            }
            printf("%x%x%x%x", buffer1[0], buffer1[1],
                    buffer1[2], buffer1[3]);
        }
    
        printf("\n");
        ret = 0;
    
    err2:
        close(fd2);
    
    err1:
        /* 关闭文件 */
        close(fd1);
        exit(ret);
    }
    
    $ gcc open.c -o open
    $ ./open 
    # 这两个文件描述符对文件是分别写,通过 fd1 写入的数据被 fd2 写入的数据给覆盖了
    aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
    
  • 将分别写更改为接续写

    • 当 open 函数使用 O_APPEND 标志,在使用 write 函数进行写入操作时,会自动将偏移量移动到文件末尾,也就是每次写入都是从文件末尾开始,这样便可实现接续写文件
    // 修改上述代码的下两行,添加 O_APPEND 标志即可
    fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL | O_APPEND, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
    fd2 = open("./test_file", O_RDWR | O_APPEND);
    
    $ gcc open.c -o open
    $ ./open 
    11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd
    

10. 复制文件描述符

  • Linux 系统中,open 返回得到的文件描述符 fd 可以进行复制,复制成功之后可以得到一个新的文件描述符

    • 使用新的文件描述符和旧的文件描述符都可以对文件进行 IO 操作
    • 复制得到的文件描述符和旧的文件描述符拥有相同的权限
  • 复制得到的文件描述符与旧的文件描述符都指向了同一个文件表

    • 假设 fd1 为原文件描述符,fd2 为复制得到的文件描述符,如下图所示
    • 两个文件描述符的属性是一样的,如:对文件的读写权限、文件状态标志、文件偏移量等
    • “复制” 的含义实则是复制文件表
      Linux系统编程(二):文件 I/O(中)_第5张图片

10.1 dup() 函数

  • Linux 系统下,可以使用 dup 或 dup2 这两个系统调用对文件描述符进行复制
    #include 
    
    int dup(int oldfd);  // oldfd :需要被复制的文件描述符
    
  • 测试案例
    // 其余代码同 9.3 案例 3
    ...
    /* 复制文件描述符 fd1 得到新的文件描述符 fd2 */
    fd2 = dup(fd1);
    if (-1 == fd2) {
        perror("dup error");
        ret = -1;
        goto err1;
    }
    
    printf("fd1: %d\nfd2: %d\n", fd1, fd2);
    ...
    
    $ gcc test.c -o test
    $ ./test 
    fd1: 3  # 程序中分配得到的最小文件描述符一般是 3
    fd2: 4
    11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd
    

10.2 dup2() 函数

  • dup 分配的文件描述符是由系统分配的,遵循文件描述符分配原则,并不能自己指定一个文件描述符,这是 dup 系统调用的缺陷;而 dup2 系统调用修复了这个缺陷,可以手动指定文件描述符,而不需要遵循文件描述符分配原则

    • 文件描述符并不是只能复制一次,实际上可以对同一个文件描述符 fd 调用 dup 或 dup2 函数复制多次,得到多个不同的文件描述符
    #include 
    
    // oldfd :需要被复制的文件描述符
    // newfd :指定一个文件描述符(需要指定一个当前进程没有使用到的文件描述符)
    int dup2(int oldfd, int newfd);
    
  • 测试案例

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        int fd1, fd2;
        int ret;
        /* 创建新文件 test_file 并打开 */
        fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
        if (fd1 == -1) {
            perror("open error");
            exit(-1);
        }
    
        fd2 = dup2(fd1, 100);  // 使用 dup2 函数复制文件描述符 fd1,指定新的文件描述符为 100
        if (fd2 == -1) {
            perror("dup error");
            ret = -1;
            goto err1;
        }
    
        printf("fd1: %d\nfd2: %d\n", fd1, fd2);
        ret = 0;
    
        close(fd2);
    
    err1:
        close(fd1);
        exit(ret);
    }
    
    $ gcc tmp2.c -o tmp2
    $ ./tmp2 
    fd1: 3
    fd2: 100
    

11. 文件共享

11.1 定义

  • 文件共享指的是同一个文件(如:磁盘上的同一个文件,对应同一个 inode)被多个独立的读写体同时进行 IO 操作
    • 多个独立的读写体:可以将其简单地理解为对应于同一个文件的多个不同的文件描述符。如:多次打开同一个文件所得到的多个不同的 fd,或使用 dup()(或 dup2)函数复制得到的多个不同的 fd 等
    • 同时进行 IO 操作:指的是一个读写体操作文件尚未调用 close 关闭的情况下,另一个读写体去操作文件

    例:同一个文件对应两个不同的文件描述符 fd1 和 fd2,当使用 fd1 对文件进行写操作之后,并没有关闭 fd1,而此时使用 fd2 对文件再进行写操作,这其实就是一种文件共享

11.2 意义 & 核心

  • 文件共享的意义:多用于多进程或多线程编程环境中,如:可以通过文件共享的方式来实现多个线程同时操作同一个大文件,以减少文件读写时间、提升效率
    • 隐患:文件共享存在着竞争冒险
  • 文件共享的核心:如何制造出多个不同的文件描述符来指向同一个文件,如:多次调用 open 函数重复打开同一个文件得到多个不同的文件描述符、使用 dup() 或 dup2() 函数对文件描述符进行复制以得到多个不同的文件描述符

11.3 三种实现方式

  • 1. 同一个进程中多次调用 open 函数打开同一个文件

    • 多次调用 open 函数打开同一个文件会得到多个不同的文件描述符,并且多个文件描述符对应多个不同的文件表,所有的文件表都索引到了同一个 inode 节点,也就是磁盘上的同一个文件
      Linux系统编程(二):文件 I/O(中)_第6张图片
  • 2. 不同进程中分别使用 open 函数打开同一个文件

    • 进程 1 和进程 2 分别是运行在 Linux 系统上两个独立的进程 (程序),在他们各自的程序中分别调用 open 函数打开同一个文件,进程 1 对应的文件描述符为 fd1,进程 2 对应的文件描述符为 fd2,fd1 指向了进程 1 的文件表 1,fd2 指向了进程 2 的文件表 2;各自的文件表都索引到了同一个 inode 节点,从而实现共享文件
      Linux系统编程(二):文件 I/O(中)_第7张图片
  • 3. 同一个进程中通过 dup(dup2)函数对文件描述符进行复制
    Linux系统编程(二):文件 I/O(中)_第8张图片

12. 竞争冒险与原子操作

12.1 竞争冒险

  • 操作共享资源的两个进程(或线程),其操作之后所得到的结果往往是不可预期的,因为每个进程(或线程)去操作文件的顺序是不可预期的,即这些进程获得 CPU 使用权的先后顺序是不可预期的,完全由操作系统调配,这就是所谓的竞争状态(也称为竞争冒险)
    • 裸机程序中不存在进程、多任务这种概念,而在 Linux 中,必须要留意到多进程环境下可能导致的竞争冒险
  • 示例
    • 假设有两个独立的进程 A 和进程 B 都对同一个文件进行追加写操作(也就是在文件末尾写入数据),每一个进程都调用了 open 函数打开了该文件,但未使用 O_APPEND 标志
    • 假定此时进程 A 处于运行状态,B 未处于等待运行状态,进程 A 调用了 lseek 函数,它将进程 A 的该文件当前位置偏移量设置为 1500 字节处(假设这里是文件末尾),刚好此时进程 A 的时间片耗尽,然后内核切换到了进程 B,进程 B 执行 lseek 函数,也将其对该文件的当前位置偏移量设置为 1500 个字节处(文件末尾)。然后进程 B 调用 write 函数,写入了 100 个字节数据,那么此时在进程 B 中,该文件的当前位置偏移量已经移动到了 1600 字节处。B 进程时间片耗尽,内核又切换到了进程 A,使进程 A 恢复运行,当进程 A 调用 write 函数时,是从进程 A 的该文件当前位置偏移量(1500 字节处)开始写入,此时文件 1500 字节处已经不再是文件末尾了,如果还从 1500 字节处写入就会覆盖进程 B 刚才写入到该文件中的数据
      Linux系统编程(二):文件 I/O(中)_第9张图片

12.2 原子操作

如何规避或消除竞争状态?

  • 问题所在:上述的问题出在逻辑操作 “先定位到文件末尾,然后再写”,它使用了两个分开的函数调用,首先使用 lseek 函数将文件当前位置偏移量移动到文件末尾,然后再使用 write 函数将数据写入到文件
  • 解决办法将这两个操作步骤合并成一个原子操作,所谓原子操作,是由多步操作组成的一个操作,原子操作要么一步也不执行,一旦执行,必须要执行完所有步骤,不可能只执行所有步骤中的一个子集(即 lseek 和 write 要么同时执行完,要么一个都不执行
实现原子操作的 2 种方法
  • 1. O_APPEND 实现原子操作

    • 当 open 函数的 flags 参数中包含了 O_APPEND 标志,每次执行 write 写入操作时都会将文件当前写位置偏移量移动到文件末尾,然后再写入数据,“移动当前写位置偏移量到文件末尾、写入数据” 这两个操作步骤就组成了一个原子操作,加入 O_APPEND 标志后,不管怎么写入数据都会是从文件末尾写
  • 2. pread() 和 pwrite()

    • pread() 和 pwrite() 都是系统调用,与 read()、write()函数的作用一样,用于读取和写入数据。区别在于,pread() 和 pwrite() 可用于实现原子操作,调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数,用于指定文件当前读或写的位置偏移量,所以调用 pread 相当于调用 lseek 后再调用 read;同理,调用 pwrite 相当于调用 lseek 后再调用 write
    #include 
    
    // offset:表示当前需要进行读或写的位置偏移量
    // 其余参数和返回值同 read、write
    ssize_t pread(int fd, void *buf, size_t count, off_t offset);
    ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
    

虽然 pread(或 pwrite)函数相当于 lseek 与 read(或 write)函数的集合,但还是有下列区别

  • 1、调用 pread 函数时,无法中断其定位和读操作(也就是原子操作)
  • 2、不更新文件表中的当前位置偏移量
  • 案例验证上述第 2 点
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        unsigned char buffer[100];
        int fd;
        int ret;
    
        /* 打开文件 test_file */
        fd = open("./test_file", O_RDWR);
        if (-1 == fd) {
            perror("open error");
            exit(-1);
        }
    
        /* 使用 pread 函数读取 100 字节数据(从偏移文件头 1024 字节处开始读取) */
        ret = pread(fd, buffer, sizeof(buffer), 1024);
        if (-1 == ret) {
            perror("pread error");
            goto err;
        }
    
        /* 获取当前位置偏移量 */
        ret = lseek(fd, 0, SEEK_CUR);
        if (-1 == ret) {
            perror("lseek error");
            goto err;
        }
        printf("Current Offset: %d\n", ret);
        ret = 0;
    
    err:
        /* 关闭文件 */
        close(fd);
        exit(ret);
    }
    
    $ gcc pread.c -o pread
    $ ./pread
    # 假如 pread 函数会改变文件表中记录的当前位置偏移量,则打印出来的数据应该是 1024 + 100 = 1124
    # 如果 pread 函数不改变文件表中记录的当前位置偏移量,则打印出来的数据应该是 0
    # 如果把 pread 换成 read,那么打印出来的数据就是 100,因为读取了 100 个字节数据,相应的当前位置偏移量会向后移动 100
    Current Offset: 0
    
创建文件中存在的竞争状态
  • 假设:进程 A 和进程 B 要打开同一个文件并且此文件不存在
    • 进程 A 当前正在运行状态、进程 B 处于等待状态,进程 A 首先调用 open(“./file”, O_RDWR) 函数尝试去打开文件,结果返回错误,也就是调用 open 失败
    • 接着进程 A 时间片耗尽、进程 B 运行,同样进程 B 调用 open(“./file”, O_RDWR) 尝试打开文件,结果也失败,接着进程 B 再次调用 open(“./file”, O_RDWR | O_CREAT, …) 创建此文件,这一次 open 执行成功,文件创建成功
    • 接着进程 B 时间片耗尽,进程 A 继续运行,进程 A 也调用 open(“./file”, O_RDWR | O_CREAT, …) 创建文件,函数执行成功
    • 上述步骤,进程 A 和进程 B 都会创建出同一个文件,同一个文件被创建两次是不允许的

Linux系统编程(二):文件 I/O(中)_第10张图片

  • 可以通过使用 O_EXCL 标志规避上述问题
    • O_EXCL 可用于测试一个文件是否存在,如果不存在则创建此文件,如果存在则返回错误
    • 当 open 函数中同时指定了 O_EXCL 和 O_CREAT 标志,如果要打开的文件已经存在,则 open 返回错误,如果指定的文件不存在,则创建这个文件
    • 这里提供了一种机制,保证进程是打开文件的创建者,将 “判断文件是否存在、创建文件” 这两个步骤合成为一个原子操作

13. fcntl

  • fcntl() 函数可以对一个已经打开的文件描述符执行一系列控制操作,如:复制一个文件描述符(与 dup、dup2 作用相同)、获取/设置文件描述符标志、获取/设置文件状态标志等,类似于一个多功能文件描述符管理工具箱
    #include 
    #include 
    
    // fd:文件描述符
    // cmd:操作命令
    // …:fcntl 函数是一个可变参函数,第三个参数需要根据不同的 cmd 来传入对应的实参,配合 cmd 来使用
    int fcntl(int fd, int cmd, ... /* arg */ )
    

    cmd 操作命令大致可以分为以下 5 种功能

    • 复制文件描述符(cmd=F_DUPFD 或 cmd=F_DUPFD_CLOEXEC)
    • 获取/设置文件描述符标志(cmd=F_GETFD 或 cmd=F_SETFD)
    • 获取/设置文件状态标志(cmd=F_GETFL 或 cmd=F_SETFL)
    • 获取/设置异步 IO 所有权(cmd=F_GETOWN 或 cmd=F_SETOWN)
    • 获取/设置记录锁(cmd=F_GETLK 或 cmd=F_SETLK)

13.1 fcntl 使用示例

  • 1. 复制文件描述符

    • 前面介绍了 dup 和 dup2 用于复制文件描述符,还可通过 fcntl 函数复制文件描述符
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        int fd1, fd2;
        int ret;
    
        fd1 = open("./test_file", O_RDONLY);
        if (-1 == fd1) {
            perror("open error");
            exit(-1);
        }   
        
        // 0 表示指定复制得到的新文件描述符必须要大于或等于 0
        // 如果传入的第三个参数是 100,那么 fd2 就会等于 100
        fd2 = fcntl(fd1, F_DUPFD, 0); 
        if (-1 == fd1) {
            perror("fcntl error");
            ret = -1; 
            goto err;
        }   
    
        printf("fd1: %d\nfd2: %d\n", fd1, fd2);
    
        ret = 0;
        close(fd2);
    
    err:
        close(fd1);
        exit(ret);
    
    }
    
    $ gcc fcntl.c -o fcntl
    $ ./fcntl 
    fd1: 3
    fd2: 4
    
  • 2. 获取/设置文件状态标志

    • Linux 中,只有 O_APPEND、O_ASYNC、O_DIRECT、O_NOATIME 以及 O_NONBLOCK 这些标志可被修改
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        int fd; 
        int ret;
        int flag;
    
        /* 打开文件 test_file */
        fd = open("./test_file", O_RDWR);
        if (-1 == fd) {
            perror("open error");
            exit(-1);
        }   
    
        /* 获取文件状态标志并打印出来 */
        flag = fcntl(fd, F_GETFL);
        if (-1 == flag) {
            perror("fcntl F_GETFL error");
            ret = -1; 
            goto err;
        }   
        printf("flags: 0x%x\n", flag);
    
        /* 设置文件状态标志,在原标志的基础上添加 O_APPEND 标志 */
        // 第三个参数表示需要设置的文件状态标志,
        ret = fcntl(fd, F_SETFL, flag | O_APPEND);
        if (-1 == ret) {
            perror("fcntl F_SETFL error");
            goto err;
        }   
        ret = 0;
    
    err:
        /* 关闭文件 */
        close(fd);
        exit(ret);
    }
    
    $ gcc fcntl2.c -o fcntl2
    $ ./fcntl2
    flags: 0x8002
    

14. 截断文件

  • 使用系统调用 truncate() 或 ftruncate() 可将普通文件截断为指定字节长度
    #include 
    #include 
    
    // ftruncate() 使用文件描述符 fd 来指定目标文件
    // truncate() 直接使用文件路径 path 来指定目标文件
    int truncate(const char *path, off_t length);
    int ftruncate(int fd, off_t length);
    
  • 什么是截断?
    • 如果文件目前的大小 > 参数 length 所指定大小,则多余的数据将被丢失
    • 如果文件目前的大小 < 参数 length 所指定大小,则将其扩展,对扩展部分进行读取将得到空字节 “\0”
  • 使用 ftruncate() 函数进行文件截断操作之前,必须调用 open() 函数打开该文件得到文件描述符,并且必须要具有可写权限,也就是调用 open() 打开文件时需要指定 O_WRONLY 或 O_RDWR
  • 调用这两个函数并不会导致文件读写位置偏移量发生改变
    • 所以截断后一般需要重新设置文件当前的读写位置偏移量,以免由于之前所指向的位置已经不存在而发生错误(如:文件长度变短了,文件当前所指向的读写位置已不存在)
  • 使用案例
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void) {
        int fd;
    
        /* 打开 file1 文件 */
        if ((fd = open("./file1", O_RDWR)) < 0) {
            perror("open error");
            exit(-1);
        }
    
        /* 使用 ftruncate 将 file1 文件截断为长度 0 字节 */
        if (ftruncate(fd, 0) < 0) {
            perror("ftruncate error");
            exit(-1);
        }
    
        /* 使用 truncate 将 file2 文件截断为长度 1024 字节 */
        if (truncate("./file2", 1024) < 0) {
            perror("truncate error");
            exit(-1);
        }
    
        /* 关闭 file1 退出程序 */
        close(fd);
        exit(0);
    }
    
    $ ls -l
    total 12
    -rw-rw-r-- 1 yxd yxd  13 12月 14 15:50 file1
    -rw-rw-r-- 1 yxd yxd  13 12月 14 15:51 file2
    -rw-rw-r-- 1 yxd yxd 414 12月 14 15:49 trunc.c
    
    $ gcc trunc.c -o trunc
    $ ./trunc 
    
    $ ls -l
    total 20
    -rw-rw-r-- 1 yxd yxd    0 12月 14 15:52 file1
    -rw-rw-r-- 1 yxd yxd 1024 12月 14 15:52 file2
    -rwxrwxr-x 1 yxd yxd 8504 12月 14 15:52 trunc
    -rw-rw-r-- 1 yxd yxd  414 12月 14 15:49 trunc.c
    

你可能感兴趣的:(Linux系统编程,linux,运维,服务器,exit,竞争状态,原子操作,进程控制块)