Linux系统编程:基础IO 上简单复习C语言文件接口 | 学习系统文件接口 | 认识文件描述符 | Linux下,一切皆文件 | 重定向原理

Posted 跳动的bit

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux系统编程:基础IO 上简单复习C语言文件接口 | 学习系统文件接口 | 认识文件描述符 | Linux下,一切皆文件 | 重定向原理相关的知识,希望对你有一定的参考价值。

文章目录

写在前面

关于进程,我们还要再往下学习进程间通信、进程信号,但是在这之前,我们先学习基础 IO,这篇文章穿插在进程中并不奇怪,因为它有着承上启下的作用。

  1. 文件的宏观理解

    那么文件在哪呢 ?—— 广义上理解,键盘、显示器等都是文件,因为我们说过 “ Linux 下,一切皆文件 ”,当然我们现在对于这句话的理解是片面的;狭义上理解,文件在磁盘上,磁盘是一种永久存储介质,不会受断电的影响,磁盘也是外设之一,所以对文件的所有操作,都是对外设的输入输出,简称 IO(Input、Output)。

  2. 文件的组成

    当我们在 Windows 下新建一个文本文件,它是否占用磁盘空间 ?—— 虽然它是一个空的文本文件,并且这里显示是 0KB,但是它依旧会占用磁盘空间,因为一个文件新建出来,它有很多数据信息都需要维护,包括文件名、修改日期、类型、大小、权限等。

    而当我们对空文本写入字符时,这里可以直观的看到文本的大小由 0KB 到 1KB。

    所以说一个文件 = 属性(元数据) + 内容,也就是说我们要学习的所有的文件操作,无外乎就是对文件的属性和内容操作。比如说之前所学的 fread、fwrite、fgets、fputs、fgetc、fputc 是对文件的内容操作;fseek、ftell、rewind 是对文件的属性操作;

  3. 系统看待文件操作

    我们以前写的 fread、fwrite 等对文件操作的 C 程序 ➡ 经过编译形成可执行程序 ➡ 双击或 ./ 运行程序,把程序加载到内存。所以对文件的操作本质就是进程对文件的操作。

    我们在操作文件时所使用到的接口,如 fread、fwrite,这是 C 语言提供的接口,而要操作的文件是在磁盘这个硬件上,同时我们很明确磁盘的管理者是操作系统,用户不可能直接去访问硬件,在计算机体系结构中我们知道用户是通过 C 语言所提供的这些接口来贯穿式的访问硬件(用户 ➡ 库函数 ➡ 系统调用接口 ➡ 操作系统 ➡ 驱动程序 ➡ 硬件)。所以本质并不是 C 语言帮我们把数据写到磁盘文件中,C 语言只提供方便用户使用的接口,真正干活的是操作系统所提供的文件相关的系统调用接口。

所以基础 IO 系列文章中主要学的是进程和系统调用接口这两个角度看待文件的方式。

一、简单复习文件操作

💦 写文件

#include<stdio.h>

int main()

	FILE* fp = fopen("./log.txt", "w");//以写的方式打开当前目录下的log.txt文件,没有就新建文件,如果目标文件存在,w写时会清空目标文件	
	//FILE* fp = fopen("log.txt", "w");//没有./,它默认是在当前路径下新建文件
    if(fp == NULL)
    
        perror("fopen");
        return 1;
    

    int count = 0;
    while(count < 10)
    
        fputs("hello DanceBit\\n", fp);//往log.txt文件中写数据
        count++;                      
    

    fclose(fp);//关闭文件

    return 0;

  • FILE* fp = fopen(“log.txt”, “w”);

    虽然没有 ./ 指定路径,但是它还是在当前路径下新建文件了,因为每个进程都有一个内置的属性 cwd(可以在 /proc 目录下查找对应进程的属性信息),cwd 可以让进程知道自己当前所处的路径,这也解释了在 VS 中不指明路径,它也能新建对应的文件在对应的路径,换言之,进程在哪个路径运行,文件的新建就哪个路径。

    这里把 ctrl_file 可执行程序移动到上级路径,此时在上级路径下 ./ctrl_file 后,就可以看到新建的文件夹与可执行程序在同一路径。

💦 读文件

#include<stdio.h>

int main()

    FILE* fp = fopen("./log.txt", "r");//以读的方式打开当前目录下的log.txt文件,没有就报错
    if(fp == NULL)
    
        perror("fopen");
        return 1;
    

    int count = 0;
    char buffer[128];
    while(count < 10)
    
        fgets(buffer, 128, fp);//从log.txt文件中读128个字符到buffer,\\n会使fgets停止读取
        printf("%s\\n", buffer);
        count++;
    

    fclose(fp);//关闭文件

    return 0;

💦 追加文件

#include<stdio.h>
#include<string.h>

int main()

    FILE* fp = fopen("./log.txt", "a");//以追加的打开当前目录下的log.txt文件,没有就新建,如果目标文件存在,a写时不会清空目标文件,在文件内容最后写入
    if(fp == NULL)
    
        perror("fopen");
        return 1;
    

    const char* msg = "Hello DanceBit\\n";
    //fwrite(msg, strlen(msg) + 1, 1, fp);//乱码
    fwrite(msg, strlen(msg), 1, fp);                                                                               

    fclose(fp);

    return 0;

  • size_t fwrite ( const void* ptr, size_t size, size_t count, FILE* stream );

    size 表示你要写入的基本单元是多大(以字节为单位),count 表示你要写入几个这样的基本单元。换言之,最终往文件中写的字节数是 = size * count,比如要写入 10 个字节,那么 size = 1 && count = 10、size = 2 && count = 5,不过一般建议把 size 写大点,count 写小点。

    fwrite 的返回值是成功写入元素的个数,也就是期望写 count 个,每个是 size,那么实际返回的是你实际写入成功了几组 size,比如你期望 size 是 10,count 是 1,大部分情况下都会把这 1 个单元都写入的,写入成功,返回值是 1;这里的你期望写入多少和实际写入多少,好比:
    你:爸,我要 10 块钱。(这是你期望的)
    爸:我只有 5 块钱,给你 3 块钱吧。(这是实际的)
    当然,这里是网络部分才会涉及到的,目前在往磁盘文件中写入时,大部分情况下,硬件设备是能满足你的要求的,所以我们这里不关心 fwrite 的返回值。

  • fwrite(msg, strlen(msg) + 1, 1, fp);

    strlen(msg) + 1 -> 乱码,也就是把 \\0 也追加会造成,因为 \\0 是 C 的规定,和文件无关。这里 cat log.txt 并没有看到乱码的原因是 \\0 是不可见的,所以这里 vim log.txt 才可以看到乱码。

💦 理解 “ Linux 下,一切皆文件 ”

  • 这里我们就要校准一个概念,不是任何 C 程序运行会默认打开,而是进程在启动时,会默认三个 “ 文件 ”,分别是标准输入(stdin)、标准输出(stdout)、标准错误(stderr)。这里可以看到它们三个的类型与用于接收 fopen 的返回值类型是一样的。

  • 默认情况下,标准输入是键盘文件,标准输出是显示器文件,标准错误是显示器文件。而这三个本身是硬件,如何理解 Linux 中,一切皆文件

    所有的外设硬件,本质对应的核心操作无外乎是 read 或 write。对于键盘文件,它的读方法就是从键盘读取数据到内存;它的写方法设置为空,因为没有把数据写到键盘上这种说法。

    对于显示器文件,如调用 printf 函数时,操作系统是要往显示器上写入的;理论上操作系统是不会往显示器上读数据的,所以设置为空?不对呀,我们在命令行输入命令显示在 Xshell 上,系统要执行命令时不就是往显示器上读吗?—— 如果是往显示器上读,那么你在输入密码时,密码是不显示的,系统也能往显示器上读吗。其实你输入的命令是你通过键盘输入的,所以系统应该是往键盘读数据。至于用户能看到输入的命令,仅仅是为了方便用户,操作系统把从键盘输入的数据,一方面给了系统读取,一方面给显示器方便用户。

    所以不同的硬件,对应的读写方式肯定是不一样的,但是它们都有 read 和 write 方法,换言之,这里的硬件可以统一看作一种特殊的文件。比如这里设计一种结构叫做 struct file,它包括文件的属性、文件的操作或方法等,Linux 说一切皆文件,Linux 操作系统就必须要保证这点。在 C 语言中,怎么让一个结构体既有属性,也有方法呢?—— 函数指针。此时每一个硬件都对应这样一个结构,硬件一多,操作系统要对它们进行管理 —— 六字箴言,先描述,在组织。所谓的描述就是 struct file;组织就是要把每一个硬件对应的结构体关联起来,并用 file header 指向。所以在操作系统的角度,它看到的就是一切皆文件,也就是说所有硬件的差异,经过描述,就变成了同一种东西,只不过当具体访问某种设备时,使用函数指针执行不同的方法,就达到了不同的行为。现在就能理解为什么可以把键盘、显示器当作文件,因为本质不同设备的读写方法是有差异的,但我们可以通过函数指针让不同的硬件包括普通文件在操作系统看来是同样的方法、同样的文件。

#include<stdio.h>
#include<string.h>

int main()

	const char* msg = "Hello DanceBit\\n";
	fwrite(msg, strlen(msg), 1, stdout);

  	char buffer[64];
  	fread(buffer, 1, 10, stdin);//你输入时没有写\\0,fread时也不会加,所以一旦超过10,就会出现乱码
  	buffer[10] = '\\0';
  	printf("%s\\n", buffer);

    return 0;

  • 这里可以直接使用 fwrite 这样的接口,向显示器写数据的原因是因为 C 程序一运行,stdout 就默认打开了。同理 fread 能从键盘读数据的原因是 C 程序一运行,stdin 就默认打开了。

  • 也就是说 C 接口除了对普通文件进行读写之外(需要打开),还可以对 stdin、stdout、stderr 进行读写(不需要打开)。

    为什么 C 程序运行,就会默认打开 stdin、stdout、stderr?仅仅是 C 吗

    scanf -> 键盘、printf -> 显示器、perror -> 显示器,换言之,如果不打开,那么作为程序员是不能直接调用这些接口的,所以默认打开的原因是便于程序员直接上手,且大部分编码都会有输入输出的需求。也就是说 scanf、printf、perror 这样的库函数,底层一定使用了 stdin、stdout、stderr 文件指针来完成不同的功能。此外还有些接口和 printf、scanf 很像,它本身是把使用的过程暴露出来,比如 fprintf(stdout, “%d, %d, %c\\n”, 12, 24, b)。

    这里可以否定的,C 程序运行才会打开 stdin、stdout、stderr。而几乎所有语言都是这样的,C++ 中是 cin、cout、cerr,所以你会发现一个现象,不管是学习啥语言,第一个程序永远是 Hello World!。这里说几乎所有语言都这样的,意味着不仅仅是语言层提供的功能了,比如一条人山人海的路从头到尾只有个别商贩在摆摊,那么我们认为这是商贩的个人行为,当地的管理者是排斥这种行为的;但如果一条人山人海的路从头到尾都有商贩在摆摊,那么我们认为当地的管理者是支持这种行为的;同样不同语言彼此之间没有进行过任何商量,而最终都会默认打开,所以这不仅仅是语言支持的,也一定是操作系统支持的,一会再细谈。

二、系统文件I/O

💦 为什么要学习文件系统接口

根据之前所说,在 C 语言中要访问硬件,必须贯穿计算机体系结构,而 fopen、fclose 等系列的库函数,其底层都要调用系统接口,这里它们对应的系统接口也很好记 —— 去掉 " f " 即为系统接口。不同语言所提供的接口本质是对系统接口封装,学习封装的接口本质学的就是语言级别的接口,换言之要学习不同的语言,就得会不同语言操作文件的方法,但实际上对特定的操作系统,最终都会调用系统提供的接口。

所以接下来我们当然是要学习系统接口,我们要学习它的原因主要有两点:其一,只要把它学懂了,以后学习其它语言上的文件操作,只是学习它的语法,底层就不用学习了;其二,这些系统接口,更贴近于系统,所以我们就能通过接口,来学习文件的若干特性。

💦 测试用例一

#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()

    int fd = open("log.txt", O_WRONLY|O_CREAT, 0644);//打开
    if(fd < 0)
    
        perror("open");
        return 1;
    

    //操作
    const char* msg = "Hello System Call!\\n";
    write(fd, msg, strlen(msg));
    write(fd, msg, strlen(msg));
    write(fd, msg, strlen(msg));
    write(fd, msg, strlen(msg));
                                                           
    close(fd);//关闭 

    return 0;

  • 使用 open 需要包含三个头文件,它有两个版本。版本一:以 flags 方式打开 pathname;版本二:以 flags 方式打开 pathname,并设置 mode 权限。

    flags 可以是 O_RDONLY(read-only)、O_WRONLY(write-only)、O_RDWR(read/write),且必须包含以上访问模式之一。此外访问模式还可以带上 |标志位,下面会介绍一两个标志位,实际还要看场景使用。

  • open(“log.txt”, O_WRONLY);

    以写的方式打开一个存在的文件,它同 fopen 一样,如果没有写操作,原文件的内容不会被覆盖;如果写操作,原文件的内容会被覆盖成写的内容。

  • open(“log.txt”, O_WRONLY, 0644);

    以写的方式打开不存在的文件,权限是 644,运行程序发现没有新建文件 。

    这里我们看到概要,我们发现 open 的返回值不是 FILE*,而是 int,接着我们再看下返回值说明,它说 open 会返回一个新的文件描述符(file descriptor),如果打开失败,返回 -1。在 C 语言中我们把 FILE* 称为文件指针,FILE* 和 file descriptor 必然有联系,下面再谈。

    我们发现它的返回值是 -1,所以这里打开文件失败了。

    O_CREATE 发现文件不存在,将会新建文件,且必须指定 mode 权限(如果没有指定,那么新建的文件会变成可执行程序),如果没有 O_CREATE,说明文件是存在的,则可忽略 mode 权限(就算写了权限也不会对原来的文件更改权限)。

  • open(“log.txt”, O_WRONLY|O_CREAT, 0644);

    很明显,它和 fopen 不一样,fopen 如果以写的方式打开,文件不存在,则会新建文件,而这里的 open 想做到类似的效果,需要带上 O_CREAT 标志位。这里我们运行程序,发现文件描述符是 3,说明文件打开成功,而且权限被设置成了 644。

    C 接口对比系统接口

    fopen 的底层一定调用 open 的,系统是怎么保证这里如果没有目标文件,会自动新建呢?—— w 的底层就是 O_WRONLY|O_CREAT;另外 fopen 默认创建文件的权限是系统默认的;关于 fopen 和 open 的返回值类型为什么是 FILE* 和 int 一会再谈。

  • 使用 close 关闭文件,需要包含 unistd 头文件。fd 是 open 的返回值。

  • 使用 write 写入文件,需要包含 unistd 头文件。write 向 fd 文件描述符中写入 buf,写 count 个字节,返回值是写了多少个。

💦 测试用例二

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()

    int fd = open("log.txt", O_RDONLY);//打开
    if(fd < 0)
    
        perror("open");
        return 1;
    

    //操作
    char buffer[1024];
    ssize_t sz = read(fd, buffer, sizeof(buffer) - 1);//期望读1023个,但实际可能只有100个,是从文件读,文件并不遵守字符串\\0的规则,所以要主动\\0
    if(sz > 0)
    
    	buffer[sz] = '\\0';//利用read的返回值,实际读到的个数就是该被\\0的位置
    	printf("%s\\n", buffer);
                                                           

    close(fd);//关闭 

    return 0;

  • 要使用 read 读文件,需要包含 unistd 头文件。read 从 fd 文件描述符中读数据到 buf,读 count 个字节,返回值是实际读到的数据。

💦 测试用例三

#include<string.h>  
#include<stdio.h>  
#include<sys/types.h>  
#include<sys/stat.h>  
#include<fcntl.h>  
#include<unistd.h>

int main()

    int fd = open("log.txt", O_WRONLY|O_APPEND);//打开
    if(fd < 0)
    
        perror("open");
        return 1;
    
    
    //操作
    const char* msg = "Hello System Call!\\n";
    write(fd, msg, strlen(msg));
    write(fd, msg, strlen(msg));                    
                                                      
    close(fd);//关闭                                  
                                                      
    return 0;                                         
                                                     
  • open(“log.txt”, O_WRONLY|O_APPEND);

    以写的方式追加一个存在的文件。

💦 标志位

为什么传两个标志位时需要使用 ‘ | ’ 操作符

O_WRONLY、 O_RDONLY、O_CREATE、O_APPEND 都是标志位。如果我们自己在设计 open 接口时,这里通常是使用整数,0 表示不要,1 表示要。而系统是怎么做的呢?—— 一个整数有 32 个比特位,所以一个标志位传一个整数是有点浪费的,所以这里可以用最低比特位表示是否读、第二低比特位表示是否写、第三低比特位表示是否追加等等,之后这里我们可以定义一些宏来,将来传入了 flags,系统要检测是什么标志位,它只需要 falgs & O_RDONLY,这也解释了为什么上面需要两个标志位时是 O_WRONLY|O_APPEND。

grep -ER 'O_CREAT|O_RDONLY' /usr/include/筛选标志位。

接着我们 vim 标志位所在路径,发现默认是只读,而 O_CREAT 以下是使用了八进制,不管如何,它们经过转换后,最终只有一个唯一比特位。我们也可以通过组合标志位,传入多个选项。

这里语言都要对系统接口做封装,本质是对兼容自身语法特性,系统调用使用成本较高,而且不具备可移植性,如果所有语言都用 open 这一套接口, 那么这套接口在 windows 下是不能运行的,所以你写的程序是不具备可移植性的,而 fopen 能在 Windows 和 Linux 下运行的原因是 C 语言对 open 进行了封装,也就是说这些接口会自动根据平台,选择底层对应的文件接口, 同样的 fopen,它在 Windows 和 Linux 中头文件的实现是不同的 。

💦 open的返回值

#include<stdio.h>  
#include<sys/types.h>  
#include<sys/stat.h>  
#include<fcntl.h>  
  
int main()  
  
    int fd1 = open("log1.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
    int fd2 = open("log2.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
    int fd3 = open("log3.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
    int fd4 = open("log4.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
    int fd5 = open("log5.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);

    printf("fd1: %d\\n", fd1);                                   
    printf("fd2: %d\\n", fd2);  
    printf("fd3: %d\\n", fd3);  
    printf("fd4: %d\\n", fd4);  
    printf("fd5: %d\\n", fd5);  
    
    return 0;  
                                    

我们说过返回小于 0 的数,则代表 open 失败,显示这里 open 都成功了。但是这里为什么不从 0 开始依次返回?—— 上面我们说过 C 程序运行起来,默认会打开三个文件(stdin、stdout、stderr),所以 0, 1, 2 分别与之对应。

为什么这里每打开一个文件所返回的文件描述符是类似数组下标的呢?—— 这里返回的文件描述符就是数组下标。一个进程是可以打开多个文件的,且系统内被打开的文件,一定是有多个的,那么这些多个被打开的文件,操作系统使用 “ 先描述,后组织 ” 的方式管理起来,描述一个打开文件的数据结构叫做 struct file,组织一堆 struct file 就是在 task_struct 中有一个 struct files_struct* files 指针,指向 struct files_struct,它的作用就是构建进程和文件之间的对应关系,其中包含了一个指针数组,这里我们可以理解为定长数组,struct file* fd_array[NR_OPEN_DEFAULT] ➡ #define NR_OPEN_DEFAULT BITS_PER_LONG ➡ #define BITS_PER_LONG 32。所以用户层看到的 fd 返回值,本质是系统中维护进程和文件对应关系的数组的下标。比如创建一个文件,会多一个 struct file,再把地址存储于指针数组中最小的且没有使用过的数组中,这里对应是 6 下标,然后把 6 作为返回值,返回给用户,所以当用户后续要对文件进行操作时就可以使用 fd 返回值作为参数,比如 read(fd) ,当前进程就会拿着 fd 去 struct files_struct* 指向的指针数组中找 fd 下标,根据 fd 下标对应的地址找到对应的文件,再在文件中找到对应的 read 方法,对 disk 中的数据进行读取。

Linux 2.6 内核源码验证:

对于 file_operations,不同硬件是有不同的方法的,大部分情况方法是和你的硬件驱动匹配的,虽然如此,但是最终文件通过函数指针实现你要打开的是磁盘,那就让所有的方法指向磁盘的方法,你要打开的是其它硬件,那就让所有的方法指向其它硬件的方法,而这里底层的差异,在上层看来,已经被完全屏蔽了。所以对进程来讲,对所有的文件进行操作,统一使用一套接口(现在我们明确了它是一组函数指针),换言之,对进程来说,你的操作和属性接口统一使用 struct file 来描述,所以在进程看来,就是 “ 一切皆文件 ”。

#include<stdio.h>  
#include<sys/types.h>  
#include<sys/stat.h>  
#include<fcntl.h>  
#include<unistd.h>  
  
int main()

    //close(0);
    //close(1);                                       
                                                      
    int fd1 = open("log.txt", O_CREAT|O_WRONLY, 0644);
    int fd2 = open("log.txt", O_CREAT|O_WRONLY, 0644);
                                      
    printf("hello bit!: %d\\n", fd1);  
    printf("hello bit!: %d\\n", fd2);  

	//fflush(stdout);
                        
    close(fd1);         
    close(fd2);         
                        
    return 0;           
                       
  • 毫无疑问,这里 open 一个文件后的返回值是从 3 开始,但是这里进程一运行,close 0,此时再 open 一个文件后的返回值是 0,再 open 一个文件后的返回值是 3,从这里我们就可以知道,系统中,分配文件描述符的规则是按最小的,且没有被使用的下标进行分配。

  • 当我们 close 1,1 就不再指向显示器文件,此时 fd1 应该是 1,fd2 应该是 3,确实如此,然后再 printf 时,本来应该往显示器里写入,现在却往普通文件里写入,这种技术叫做输出重定向(echo “hello bit!” > temp.txt)。这里它输出重定向到了新打开的文件?—— 并没有,这里要先搞明白 fopen 和 open 之间的耦合关联。

    这里可以看下 FILE 结构体是被 _IO_FILE typedef 的(typedef struct_IO_FILE FILE),_IO_FILE 在 /usr/include/libio.h 下,在 _IO_FILE 结构中包含两个重要的成员:

      其一,底层对应的文件描述符下标 int _fileno,它是封装的文件描述符。换言之,在 C 的文件接口中,一定是使用 fileno 来调用系统接口 read(fp->fileno),所以 fopen 和 open 是通过 C 语言结构体内的文件描述符耦合的。

      其二,应用层 C 语言提供的缓冲区。记得曾经写进度条时,没有 \\n,数据在缓冲区中不显示,必须以 fflush 强制刷新,其中数据所处的缓冲区就是由 __IO_FILE 维护的。

    这里 close 1 后,1 下标就不再指向显示器文件,而是指向 log1.txt,FILE* stdout 当然还在,stdout 依然认为它的文件描述符值是 1,这里 printf 时会先把数据放到 C 语言提供的 __IO_FILE 缓冲区中,还没来得及刷新,已经把 fd1 关闭了,所以操作系统是没有办法由用户语言层刷新到操作系统底层的,所以自然也没看到结果。咦!这里不是有 \\n 吗,为什么没有往操作系统刷新,因为此时 1 指向的是磁盘文件,磁盘文件是全缓冲,必须等待缓冲区满了再刷新,或者 fflush 强制刷新。显示器文件,无论是用户层还是内核层,都是行刷新,因为它无论怎样,最终都会往显示器上刷新的。

💦 重定向原理

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()

    //close(0);
    close(1);

    int fd1 = open("log1.txt", O_CREAT|O_WRONLY, 0644);
    int fd2 = open("log2.txt", O_CREAT|O_WRONLY, 0644);

    printf("hello bit!: %d\\n", fd1);
    printf("hello bit!: %d\\n", fd2);

    fflush(stdout);
                                                       
    close(fd1);
    close(fd2);

    return 0;