【Linux】基础IO

目录

一、回顾C文件接口

写文件:fwrite

读文件:fread

stdin & stdout & stderr

二、系统文件I/O

open

close

write

read

三、文件描述符

四、文件描述符的分配规则

五、重定向

输出重定向

追加重定向

输入重定向

1号和2号文件描述符的区别

六、使用 dup2 系统调用

用dup2系统调用实现输出重定向

用dup2系统调用实现输入重定向

在minishell中添加重定向功能

七、FILE

FILE封装了文件描述符fd

FILE中的缓冲区

简单模拟实现c文件标准库

​编辑

 八、理解文件系统

inode

文件系统

磁盘的概念

磁盘分区与格式化

九、软硬链接

软连接

硬链接

inode的引用计数器

十、动态库和静态库

静态库与动态库      

静态库

生成静态库

使用静态库

动态库

生成动态库

使用动态库


一、回顾C文件接口

我们在Linux通过man指令来查看一下fwrite和fread这两个函数:

【Linux】基础IO_第1张图片

解释一下这两个函数的参数 

  • fwrite:第一个参数表示你要往输出流里面写的数据,第二个参数表示你每次要写多少个字节的数据,第三个参数你要写几次,第四个参数这是指向 FILE 对象的指针,该 FILE 对象指定了一个输出流,也是我们写数据的地方。
  • fread:第一个参数表示你要把从输入流中读到的数据放到哪里去,第二个参数表示你每次读多少个字节的数据,第三个参数表示你要多读几次,第四个参数这是指向 FILE 对象的指针,该 FILE 对象指定了一个输入流,同时也是我们读数据的地方。 

下面演示一下这两个函数的用法。

  • 写文件:fwrite

#include 
#include 
#include 

int main()
{
    //chdir("/home/GTY");
    // 打开文件的路径和文件名,默认在当前路径下新建一个文件?
    FILE *fp = fopen("log.txt", "a");
    if(fp == NULL){
        perror("fopen");
        return 1;
    }

    const char *message = "abcd\n";                                                   
    // strlen(message)需要+1码? 为什么? 不需要,字符串以\0结尾,是你C语言的规定,和我文件有什么关系?加了1后\0输入文件会显示成乱码。
    fwrite(message, strlen(message), 1, fp);
    fclose(fp);

    return 0;
}

 运行结果:

【Linux】基础IO_第2张图片

运行程序之后fopen就在当前目录下生成了log.txt文件,我们fopen这里设置了a模式(追加模式),所以继续运行会继续向文件中写入,如果是w模式就会清除之前的内容。

这里关于fopen有几个注意的点:

这个例子中生成的log.txt是在当前路径,那么当前路径是什么?就是进程的当前路径cwd——如果我们修改了当前进程的cwd,就可以把文件新建到其他目录。可以通过chdir函数改变进程当前的路径。

w模式:写入之前,都会对文件进行清空处理。    

w和a模式都是写入模式,w会清空从头写,a在文件的结尾写。    

  • 读文件:fread

#include
#include

int main()
{
    //chdir("/home/GTY");
    // 打开文件的路径和文件名,默认在当前路径下新建一个文件?
    FILE *fp = fopen("log.txt", "r");                                                  
    if(fp == NULL){
        perror("fopen");
        return 1;
    }

    const char *message = "abcd\n";
    char buf[32];
    // strlen(message) ? 为什么? 字符串以\0结尾,是你C语言的规定,和我文件有什么关系?
    ssize_t s = fread(buf,1, strlen(message), fp);
    
    if(s > 0)
    {
      buf[s-1] = 0;
      printf("%s\n",buf);
    }
  
    fclose(fp);
    return 0;
}

运行结果:

 

stdin & stdout & stderr

【Linux】基础IO_第3张图片

我们查看man手册可以发现stdin、stdout以及stderr他们三个都是FILE* 类型的指针,即我们之前说的文件指针。 

在Linux系统中,几乎所有的硬件设备都被抽象成文件,这也就是所谓的“Linux下一切皆文件”的概念。无论是我们的键盘、显示器,还是其他硬件设备,都在Linux中被视为文件。

当我们通过键盘输入字符时,操作系统会从“键盘文件”中读取数据。同理,当我们在显示器上看到数据时,那是因为我们向“显示器文件”写入了数据。

当我们运行一个程序时,操作系统会为这个程序创建三个默认的标准输入输出流:标准输入(stdin),标准输出(stdout)和标准错误(stderr)。在C语言中,这三个流分别对应着键盘(标准输入),显示器(标准输出和标准错误)。这就是为什么我们可以在没有显式打开这些流的情况下,能够从键盘读取数据并向显示器写入数据的原因。

这种特性并非特定于某种编程语言,而是由操作系统提供支持的通用特性。C++中的cin、cout和cerr与C语言中的stdin、stdout和stderr类似,其他语言中也有类似的概念。这种特性并非特定于某种编程语言,而是由操作系统所提供支持的。

那么我们用c语言输出信息到显示器,有哪些方法?

  1. 调用库函数printf将信息打印到显示器上面
  2. 由于在C语言中,标准输出和标准错误分别对应于stdout和stderr,并且这两个流都与显示器设备相关联,因此我们可以使用fprintf函数将信息分别输出到stdout和stderr。

示例:

#include
#include

int main()
{
    printf("hello printf\n");        
    fprintf(stdout,"hello stdout\n");
    fprintf(stderr,"hello stderr\n");

    return 0;
}

 运行结果: 

二、系统文件I/O

文件是在磁盘上的,所以我们对文件的访问其实是访问磁盘文件,而磁盘是文件,所以其实是访问硬件。而我们之前说过,操作系统不相信我们,不会让我们直接访问硬件,而是提供了系统调用。

我们说过的库函数和系统调用是上下级关系,库函数的底层是经过系统调用封装得到的,也就是说我们C/C++使用的文件操作的库函数底层都是通过系统调用接口进行了封装得到的。

了解了这些之后,下面我们来讲一下几个系统调用接口: open、write、read、close 

open

【Linux】基础IO_第4张图片

功能:打开一个文件

参数说明:

  • pathname: 要打开或创建的目标文件
  • flags:打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
  • mode:文件权限。尽管你指定了mode,但最终的权限值并非完全按照指定的 mode 来设置。这是因为文件权限要受到 umask(文件默认掩码)的影响。例如,如果将 mode 设置为 0666,但 umask 的值为 0002,那么实际创建出来的文件的权限将是 0644。umask 的作用是限制了文件权限的设置范围,只有 mode 和 umask 的位运算结果才会被实际应用到文件权限中。因此,设置文件的 mode 为 0666,但实际创建出来的文件权限为 0644 的原因就在于此。                                                                                          但我们可以在程序中设置掩码,如设置umask(0),这样掩码就会优先按照我们程序中设置的来,我们再在mode指定文件权限就会完全按照mode指定的来设置。(类似全局变量与局部变量的关系,在函数中优先使用局部变量)。

参数:

  • O_RDONLY: 只读打开
  • O_WRONLY: 只写打开
  • O_RDWR : 读,写打开        

这三个常量,必须指定一个且只能指定一个

  • O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
  • O_APPEND: 追加写

注意:这几个选项的二进制序列当中都只有一个比特位是1,其他比特位全为0,且为1的比特位是各不相同的,因此我们若是想将这些选项组合起来使用只需要通过或运算即可。

返回值
成功:新打开的文件描述符
失败:-1

注意:open 函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要open创建,则第三个参数表示创建文件的默认权限,否则,使用两个参数的open。

下面我们来使用一下这个系统调用接口:

int main()
{

    umask(0); // file descriptor: 文件描述符, fd , int
    int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);

    const char *message = "xxx";
    write(fd, message, strlen(message)); // +1?                   

    close(fd);
    return 0;
}

运行结果: 

【Linux】基础IO_第5张图片

我们看到O_TRUNC模式下每次运行程序都会把文件的内容清空。

下面我们来看看O_APPEND模式。

int main()
{

    umask(0); // file descriptor: 文件描述符, fd , int
    int fd1 = open("log1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);

    const char *message = "xxx";
    write(fd1, message, strlen(message)); // +1?                   

    close(fd1);
    return 0;
}

运行结果:

【Linux】基础IO_第6张图片

我们看到O_APPEND模式下每次运行程序不会把文件的内容清空,可以实现追加写入。

下面我们来看看open的返回值:

int main()
{

    umask(0); // file descriptor: 文件描述符, fd , int

    int fd1 = open("log1.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
    int fd2 = open("log2.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
    int fd3 = open("log3.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
    int fd4 = open("log4.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);

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

运行结果: 

【Linux】基础IO_第7张图片

我们可以看到open的返回值——文件描述符fd是从3开始分配的,且它是连续的,为什么文件描述符fd是从3开始分配的,0、1、2去哪了呢? 

我们可以将文件描述符视为数组的下标,而这个数组则用于管理已打开的文件。在Unix和类Unix系统中,每个进程都有一个文件描述符表,用于存储所有已打开文件的信息。文件描述符是一个非负整数,通常从3开始分配,因为0、1和2已经被系统默认分配给标准输入、标准输出和标准错误。

close

作用: 关闭文件

close函数原型如下:

int close(int fd);

函数参数:

fd: 文件描述符
返回值: 若关闭文件成功,返回0,关闭失败返回-1.

write

作用: 往一个文件里面写数据

函数原型如下:

ssize_t write(int fd,const void* buf,size_t count);

函数参数:

  • fd: 文件描述符
  • buf: 你要往这个文件里面写的数据
  • count: 从buf开始向后count个字节的数据写到这个文件描述符所对应的文件中
  • 返回值: 如果写入成功,返回实际写入数据的字节个数,写入失败返回-1.

 下面我们来使用一下这个系统调用接口:

#include 
#include 
#include 
#include 
#include 
#include 

int main()
{
    umask(0); // file descriptor: 文件描述符, fd , int
    int fd = open("bite.txt", O_WRONLY|O_CREAT, 0666);

    const char *message = "i like linux!";
    write(fd, message, strlen(message)); // +1?       

    close(fd);

    return 0;
}

运行结果:

【Linux】基础IO_第8张图片

read

作用: 从一个文件中读取信息

函数原型如下:

ssize_t read(int fd,void* buf,size_t count);

函数参数:

  • fd: 文件描述符
  • buf: 将读取到的信息放到buf中
  • count: 从这个文件描述符所对应的文件中读取count个字节的数据
  • 返回值: 读取成功,返回实际读取数据的字节个数,读取失败返回-1。

下面我们来使用一下这个系统调用接口:

#include 
#include 
#include 
#include 
#include 
#include 

int main()
{
    umask(0); // file descriptor: 文件描述符, fd , int
    int fd = open("bite.txt", O_RDONLY|O_CREAT, 0666);

    const char *message = "i like linux!";
    //write(fd, message, strlen(message)); // +1?       

    char* buf[64];
    ssize_t s = read(fd,buf,strlen(message));

    if(s > 0)
    {
      printf("%s\n",buf);                               
    }
    close(fd);

    return 0;
}

运行结果:

【Linux】基础IO_第9张图片

 成功在从文件当中读出文件当中的内容, 打印到标准输出当中。

三、文件描述符

操作系统中有着许多的进程,文件是在进程运行的时候打开的,一个进程是可以打开多个文件的。我们知道在操作系统中进程不断增加,管理它们变得非常重要。

如何管理?——先描述再组织!

操作系统会为这些已经被打开的文件分别创建一个struct file的结构体。对于这些文件的管理,就变成了对这些结构体的管理了。我们再将这些结构体以双链表的形式连接起来,之后操作系统对于文件的管理就变成了对链表的增删改查。

我们知道了文件是如何管理,现在又有一个问题:一个进程是可以打开多个文件的,因此它们之间的关系就是1:n,那这么多的文件我怎么知道哪些是我们进程的呢?

在进程打开文件时,操作系统会为该文件创建一个文件描述符。文件描述符是一个整数,它用于引用和操作该文件。当进程需要访问文件时,它使用文件描述符来引用文件。文件描述符也可以用于跟踪和管理打开的文件。

每个进程的task_struct中都有一个struct files_struct*的结构体指针,操作系统为了让进程和该进程打开的文件关联起来,在内核创建了一个struct file_struct的结构体,其中这个struct files_struct结构体中又包含了一个名为fd_array的结构体指针数组该数组的下标就是我们的文件描述符,数组的内容就是我们所打开文件的地址。如下图所示:

【Linux】基础IO_第10张图片

现在我们就知道,文件描述符是从0开始的数组下标。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件,于就是有了struct file结构体,表示一个已经打开的文件对象。当我们的进程执行read、write、open等系统调用时,为了让进程和文件关联起来,每个进程的task_struct都有一个files_struct*的指针,指向file_struct结构体,该结构体中最重要的部分就是包含一个指针数组,该指针数组每个元素都是一个指向已经打开文件的指针!因此每当我们进程执行系统调用打开一个文件时,就会将该文件的地址填到该指针数组中,然后对应一个文件描述符。

文件描述符的本质是内核中进程和文件产生关联的数组的下标!

因此,只要我们有该文件的文件描述符,我们就可以找到对应的文件,进而对该文件进行一系列操作。

四、文件描述符的分配规则

  • Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2.
  • 0,1,2对应的物理设备一般是:键盘,显示器,显示器

下面我们来进行验证一下:

直接看代码

#include 
#include 
#include 
#include 
#include 
#include 

int main()                                                      
{

    umask(0); // file descriptor: 文件描述符, fd , int
    int fd1 = open("log1.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
    int fd2 = open("log2.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
    int fd3 = open("log3.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
    int fd4 = open("log4.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);

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

    return 0;
}

运行结果: 

【Linux】基础IO_第11张图片

可以看到结果和我们的预期一样,因为上面说过创建一个进程时,会默认打开标准输入、标准输出、标准错误,fd_array给他们三个分配的文件描述符分别是0、1、2。因此我们后面创建文件的时候分配的描述符会从3开始。 

下面我们来验证一下标准输入、标准输出、标准错误,fd_array给他们三个分配的文件描述符分别是0、1、2。

从标准输入获取字符串,向标准输出,标准错误打印msg字符串

代码:

#include 
#include 
#include 
#include 
#include 
#include 

int main()
{
    char buffer[1024];
    ssize_t s = read(0, buffer, sizeof(buffer));//从标准输入(键盘)读取字符串
    if(s < 0) return 1;
    buffer[s] = '\0';
    printf("echo : %s\n", buffer);//打印

    const char *msg = "hello Linux\n";
    write(1, msg, strlen(msg));//向标准输出打印msg
    write(2, msg, strlen(msg));//向标准错误打印msg   

    return 0;
}                       

运行结果:

【Linux】基础IO_第12张图片

查看标准输入,标准输出,标准错误的文件描述符:

#include 
#include 
#include 

int main()
{
    //close(1);
    int n = printf("stdin->fd: %d\n", stdin->_fileno);
    printf("stdout->fd: %d\n", stdout->_fileno);
    printf("stderr->fd: %d\n", stderr->_fileno);
    
    return 0;
}

运行结果:

可以看到标准输入、标准输出、标准错误,fd_array给他们三个分配的文件描述符分别是0、1、2。

现在把标准输入0给关闭了,然后我再创建一个文件,这个时候给该文件分配的文件描述符会是多少呢?

#include 
#include 
#include 
#include 
int main()
{
    close(0);
    int fd = open("myfile", O_RDONLY);
    if(fd < 0){
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}

【Linux】基础IO_第13张图片

可以看到当我们把标准输入0给关闭后,我们去创建一个文件,分配给这个文件的描述符是0.

现在把标准错误2给关闭了,然后我再创建一个文件,这个时候给该文件分配的文件描述符会是多少呢? 

#include 
#include 
#include 
#include 
int main()
{
    close(2);
    int fd = open("myfile", O_RDONLY);
    if(fd < 0){
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}

运行结果:

【Linux】基础IO_第14张图片

可以看到当我们把标准错误2给关闭后,我们去创建一个文件,分配给这个文件的描述符是2. 

于是我们得出一个结论——文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。 

五、重定向

了解了上面介绍的文件描述符以及文件描述符的规则后,我们就可以更好的来理解重定向。

常见的重定向一般有以下三种:

  • 输出重定向:>
  • 追加重定向:>>
  • 输入重定向:<

下面我们就依次来讲这三种重定向的原理

输出重定向

我们先来看这段代码:

【Linux】基础IO_第15张图片

我们看到本该打印到显示器上的字符串,重定向之后输入到了myfile.txt文件中。

原理:输出重定向的原理就是本来应该输出到“显示器”文件里面的内容输出到了另外一个文件中。 

我们再来看这段代码:

#include 
#include 
#include 
#include 
#include 
#include 

#define filename "log.txt"

int main()
{
  close(1);

  int fd = open(filename,O_CREAT|O_WRONLY|O_TRUNC,0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  printf("fd: %d\n", fd);                               
  const char *msg = "hello Linux\n";
  int cnt = 5;
  while(cnt)
  {
    write(1, msg, strlen(msg));
    cnt--;
  }
  return 0;
}

运行结果:

【Linux】基础IO_第16张图片

我们看到,我们关闭了1号文件描述符后,再运行程序,显示器上并不会打印字符串。我们又发现,当我们打印log.txt上的内容的时候,发现本该打印到显示器到的内容都写到了log.txt中。

为什么会出现这种情况呢?

【Linux】基础IO_第17张图片

在上面讲文件描述符的分配规则的时候,我们得出结论——在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。所以当我们对1号文件描述符进行关闭的时候。重新生成一个log.txt文件,他分配到的文件描述符就是1。所以我们看到本该打印到显示器到的内容都写到了log.txt中。

注意: printf函数默认是向stdout标准输出中输出数据的,标准输出流stdout其实指向的是一个struct FILE类型的结构体,该结构体中有一个存储文件描述符的变量,stdout它指向的这个结构体中存储的文件描述符就是1,而1这个文件描述符一般是指向标准输出的。因此printf它实际就是向文件描述符为1的文件输出数据,这也就是为什么当1号文件描述符分配给标准输出的时候,我们printf输出的数据是打印到显示器上,当1号文件描述符分配给log.txt时,我们printf输出的数据就是打印到了文件中。

追加重定向

我们先来看这段代码:

【Linux】基础IO_第18张图片

我们看到,每次向log.txt写入的时候,之前的内容不会清除。

原理:输出重定向和追加重定向的原理基本类似,唯一的区别追加重定向在输出重定向的基础上还要加上以追加输入的方式打开该文件。如果说输出重定向是覆盖式的输出数据,那么追加重定向就是追加式的输出数据。

我们再来看下面这段代码:

#include 
#include 
#include 
#include 
#include 
#include 

#define filename "log.txt"

int main()
{
  close(1);

  int fd = open(filename,O_CREAT|O_WRONLY|O_APPEND,0666);
  if(fd < 0)                                             
  {
    perror("open");
    return 1;
  }

  printf("fd: %d\n", fd);
  const char *msg = "hello Linux\n";
  int cnt = 5;
  while(cnt)
  {
    write(1, msg, strlen(msg));
    cnt--;
  }
  return 0;
}

运行结果: 

【Linux】基础IO_第19张图片

可以看到我们字符串就以追加的方式输出到了log.txt文件中。

输入重定向

我们先来看这段代码:

【Linux】基础IO_第20张图片

本来应该从“标准输入”中读取数据,现在重定向到从另一个文件中读取数据。

【Linux】基础IO_第21张图片

了解了输入重定向的原理之后,下面我们来看下面这段输入重定向的代码: 

#include 
#include 
#include 
#include 
#include 
#include 

#define filename "log.txt"

int main()
{
  close(0);

  int fd = open(filename,O_RDONLY|O_CREAT,0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  char inbuffer[1024];

  ssize_t s = read(0, inbuffer, sizeof(inbuffer)-1);
  if(s>0)
  {
      inbuffer[s] = '\0';
      printf("echo# %s\n", inbuffer);
  }

  return 0;
}

运行结果: 

【Linux】基础IO_第22张图片

可以看到read本来是应该从0号文件描述符—标准输入(键盘)去读取数据,现在经过重定向之后,0号文件描述符分配给log.txt时,我们就从log.txt中读取数据。

1号和2号文件描述符的区别

我们来看这段代码

#include 

int main()
{
  fprintf(stdout, "hello normal message\n");
  fprintf(stdout, "hello normal message\n");
  fprintf(stdout, "hello normal message\n");
  fprintf(stdout, "hello normal message\n");
  fprintf(stdout, "hello normal message\n");

  fprintf(stderr, "hello error message\n");
  fprintf(stderr, "hello error message\n");
  fprintf(stderr, "hello error message\n");
  fprintf(stderr, "hello error message\n");
  fprintf(stderr, "hello error message\n");

  return 0;
}

运行示例:

【Linux】基础IO_第23张图片

【Linux】基础IO_第24张图片

我们看到我们直接打印时,正常和错误的信息都打印到了显示器上,而我们有时候运行代码出现错误的时候,只需要错误信息。我们将1号文件描述符的信息重定向到normal.log,2号文件描述符的信息重定向到err.log,可以看到分别打印了正常信息和错误信息。

所以他们的主要区别就是输出内容:stdout主要处理使用者输出,而stderr主要处理错误信息输出。

如何将标准输出和标准错误输出重定向至一个文件?

【Linux】基础IO_第25张图片

./mytest > all.log默认将1号文件描述符的内容重定向到all.log,2>&1之后2号描述符也重定向到了all.log,所以标准输出和标准错误输出都重定向至all.log。

六、使用 dup2 系统调用

我们要完成上面三个重定向,每次完成一个重定向,我们都得先关闭一个文件描述符。这样是不是太麻烦了。所以下面我们来介绍一个系统调用——dup2 ,使用这个系统调用我们可以在不关闭文件描述符的前提下,完成重定向。

上面我们讲了输入输出追加重定向的本质其实就是将其对应的文件描述符分配给我们新打开的文件,从而达到重定向。那么我们想要在不关闭文件描述符的前提下完成重定向,只需要让文件描述符表中对应的文件描述符(数组下标)的内容指向我们新打开的文件即可。

我们通过man来看一下dup2这个系统调用:

【Linux】基础IO_第26张图片

作用: 将新的文件描述符变成就旧的文件描述符的一份拷贝,即将fd_array数组中fd_array[oldfd]的内容拷到fd_array[newfd]中,其实也就是让newfd指向oldfd所指向的文件。如果在重定向前,如果newfd已分配给了某个文件,我们还需要先关闭newfd所指向的文件。

注意:

  • 如果oldfd不是一个有效的文件描述符,则dup2调用失败,那么此时newfd所指向的文件就不会被关闭。
  • 如果oldfd是一个有效的文件描述符,并且newfd和oldfd的值是一样的,则dup2不做任何操作,返回newfd。

下面我们通过图片来为大家分析一下系统调用接口dup2的原理:

【Linux】基础IO_第27张图片

用dup2系统调用实现输出重定向

#include 
#include 
#include 
#include 
#include 
#include 

#define filename "log.txt"

int main()
{
  //close(0);

  int fd = open(filename,O_WRONLY|O_CREAT,0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  dup2(fd,1);                       
  printf("fd: %d\n", fd);
  const char *msg = "hello Linux\n";
  int cnt = 5;
  while(cnt)
  {
    write(1, msg, strlen(msg));
    cnt--;
  }

  close(fd);
  return 0;
}

运行结果:

【Linux】基础IO_第28张图片

可以看到我们使用dup2完成了输出重定向,本来应该输出到显示器上的内容,写入到了文件上分配给log.txt的文件描述符是3,并且我们并没有关闭1号文件描述符

用dup2系统调用实现输入重定向

#include 
#include 
#include 
#include 
#include 
#include 

#define filename "log.txt"

int main()
{

  int fd = open(filename,O_RDONLY|O_CREAT,0666);            
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  char inbuffer[1024];
  dup2(fd,0);

  ssize_t s = read(0, inbuffer, sizeof(inbuffer)-1);
  if(s>0)
  {
      inbuffer[s] = '\0';
      printf("echo# %s\n", inbuffer);
  }

  return 0;
}

运行结果:

【Linux】基础IO_第29张图片

可以看到本来要向键盘获取的字符串却向log.txt获取了,且分配给log.txt的文件描述符是3,并且我们并没有关闭0号文件描述符

在minishell中添加重定向功能

我们在进程控制的时候实现了一个简易的shell,现在我们学了重定向,我们它的功能进行完善,增加重定向功能。

完整代码: 

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define LEFT "["
#define RIGHT "]"
#define LABLE "#"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define DELIM " \t"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define EXIT_CODE 44

//重定向内容,写入方式
#define NONE -1
#define IN_RDIR     0
#define OUT_RDIR    1
#define APPEND_RDIR 2

int lastcode = 0;
int quit = 0;
char commandline[LINE_SIZE];//存放命令行参数
char *argv[ARGC_SIZE];
char pwd[LINE_SIZE];

char *rdirfilename = NULL;//重定向文件
int rdir = NONE;//重定向方式                 

// 自定义环境变量表
char myenv[LINE_SIZE];

const char *getusername()//获取用户名
  return getenv("USER");
}

const char *gethostname()//获取主机名
{
  return getenv("HOSTNAME");
}

void getpwd()//获取当前路径
{
  getcwd(pwd, sizeof(pwd));
}

void check_redir(char *cmd)
{
  // ls -al -n
  // ls -al -n >/> filename.txt
  char *pos = cmd;
  while(*pos)
  {
    if(*pos == '>')
    {
        //追加重定向
        if(*(pos+1) == '>'){
            *pos++ = '\0';
            *pos++ = '\0';//将命令与文件名分开
            while(isspace(*pos)) pos++;
            rdirfilename = pos;//记录文件名
            rdir=APPEND_RDIR;//记录命令是追加重定向
            break;
        }
        else{
            *pos = '\0';
            pos++;
            while(isspace(*pos)) pos++;                                                      
            rdirfilename = pos;
            rdir=OUT_RDIR;//记录命令是输出重定向
            break;
        }
    }
    else if(*pos == '<')
    {
        *pos = '\0'; // ls -a -l -n < filename.txt
        pos++;
        while(isspace(*pos)) pos++;
        rdirfilename = pos;
        rdir=IN_RDIR;//记录命令是输入重定向
        break;
    }
    else{
        //do nothing
    }
    pos++;
  }
}

//获取命令行字符
void Interact(char *cline,int size)
{ 
  getpwd();
  printf(LEFT"%s@%s %s"RIGHT""LABLE" ",getusername(),gethostname(),pwd);
  char *s = fgets(cline, size, stdin);//用fgets从标准输入流获取命令行并存放到commanline数组中
  assert(s);
  (void)s;

  cline[strlen(cline)-1] = '\0';//把输入字符串后的回车(\n)去掉,改成\0

  //ls -a -l > myfile.txt
  check_redir(cline);//检查是否是重定向命令,是的话进行处理
}

//解析命令行字符
int splitstring(char cline[], char *_argv[])
{
  int i = 0;
  _argv[i++] = strtok(cline, DELIM);
  while(_argv[i++] = strtok(NULL, DELIM)); // 故意写的=
  return i - 1;
}

void NormalExcute(char* _argv[])
{

    pid_t id = fork();
    if(id < 0)
    {
      perror("fork");
      return;
    }
    else if(id == 0)
    {
      int fd = 0;
      // 后面我们做了重定向的工作,后面我们在进行程序替换的时候,难道不影响吗???
      if(rdir == IN_RDIR)
      {
          fd = open(rdirfilename, O_RDONLY);
          dup2(fd, 0);
      }
      else if(rdir == OUT_RDIR)
      {
          fd = open(rdirfilename, O_CREAT|O_WRONLY|O_TRUNC, 0666);
          dup2(fd, 1);
      }
      else if(rdir == APPEND_RDIR)                                        
      {
          fd = open(rdirfilename, O_CREAT|O_WRONLY|O_APPEND, 0666);
          dup2(fd, 1);
      }

      //子进程执行命令
      execvp(_argv[0], _argv);
      exit(EXIT_CODE);
    }
    else{
      int status = 0;
      pid_t rid = waitpid(id, &status, 0);
      if(rid == id)
      {
        lastcode = WEXITSTATUS(status);//获取进程退出码
      }
    }
}

//内建命令处理
int buildCommand(char *_argv[], int _argc)
{
    //cd目录
    if(_argc == 2 && strcmp(_argv[0], "cd") == 0){
      chdir(argv[1]);
      getpwd();
      sprintf(getenv("PWD"), "%s", pwd);
      return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0], "export") == 0){//新增环境变量
      strcpy(myenv, _argv[1]);
      putenv(myenv);
      return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0], "echo") == 0){
        if(strcmp(_argv[1], "$?") == 0)//打印进程退出码
        {
            printf("%d\n", lastcode);
            lastcode=0;
        }
        else if(*_argv[1] == '$'){//打印环境变量
            char *val = getenv(_argv[1]+1);               
            if(val) printf("%s\n", val);
        }
        else{//正常打印字符串
            printf("%s\n", _argv[1]);      
        }

      return 1;
    }

    // 特殊处理一下ls
    if(strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }

    return 0;
}

int main()
{
  while(!quit)//bash不退就可以一直输入命令
  {
    //1.
    rdirfilename = NULL;
    rdir = NONE;
    //2.交互问题,获取命令行
    Interact(commandline,sizeof(commandline));
    //commandline -> "ls -a -l -n\0" -> "ls" "-a" "-l"
    //3.子串分割问题,解析命令行
    int argc = splitstring(commandline,argv);
    if(argc == 0) continue;
    
    //4.指令的判断
    //debug
    //for(int i = 0;argv[i];++i) printf("[%d]: %s\n",i,argv[i]);
    //内键命令,本质就是一个shell内部的一个函数
    int n = buildCommand(argv, argc);

    //5.普通命令的执行
    if(!n) NormalExcute(argv);//n为0,说明不是内建命令,执行普通命令
  }
   
  return 0;
}

运行演示:

【Linux】基础IO_第30张图片

 下面我们来分析一下增加的核心代码:

  • 在获取键盘输入的命令后,增加check_redir指令判断是否是重定向指令,不是的话不对命令处理,是的话判断是什么重定向用rdir记录下来,并用变量rdirfilename将文件名记录下来。
  • 在执行正常命令的时候,利用rdir判断是否需要重定向,用dup2系统调用完成重定向功能。
void check_redir(char *cmd)
{
  // ls -al -n
  // ls -al -n >/> filename.txt
  char *pos = cmd;
  while(*pos)
  {
    if(*pos == '>')
    {
        //追加重定向
        if(*(pos+1) == '>'){
            *pos++ = '\0';
            *pos++ = '\0';//将命令与文件名分开
            while(isspace(*pos)) pos++;
            rdirfilename = pos;//记录文件名
            rdir=APPEND_RDIR;//记录命令是追加重定向
            break;
        }
        else{
            *pos = '\0';
            pos++;
            while(isspace(*pos)) pos++;
            rdirfilename = pos;
            rdir=OUT_RDIR;//记录命令是输出重定向
            break;
        }
    }
    else if(*pos == '<')
    {                                              
        *pos = '\0'; // ls -a -l -n < filename.txt
        pos++;
        while(isspace(*pos)) pos++;
        rdirfilename = pos;
        rdir=IN_RDIR;//记录命令是输入重定向
        break;
    }
    else{
        //do nothing
    }
    pos++;
  }
}

void NormalExcute(char* _argv[])
{

    pid_t id = fork();
    if(id < 0)
    {
      perror("fork");
      return;
    }
    else if(id == 0)
    {
      int fd = 0;
      // 后面我们做了重定向的工作,后面我们在进行程序替换的时候,难道不影响吗???
      if(rdir == IN_RDIR)
      {
          fd = open(rdirfilename, O_RDONLY);
          dup2(fd, 0);
      }
      else if(rdir == OUT_RDIR)
      {
          fd = open(rdirfilename, O_CREAT|O_WRONLY|O_TRUNC, 0666);
          dup2(fd, 1);
      }
      else if(rdir == APPEND_RDIR)                                        
      {
          fd = open(rdirfilename, O_CREAT|O_WRONLY|O_APPEND, 0666);
          dup2(fd, 1);
      }

      //子进程执行命令
      execvp(_argv[0], _argv);
      exit(EXIT_CODE);
    }
    else{
      int status = 0;
      pid_t rid = waitpid(id, &status, 0);
      if(rid == id)
      {
        lastcode = WEXITSTATUS(status);//获取进程退出码
      }
    }
}

看了上面代码我们有一个问题:在NormalExcute里,我们做了重定向的工作,后面我们在进行程序替换的时候,难道不影响吗???

答案是不影响的,我们来根据下面这张图进行解释。

前面我们学了每个进程的task_struct中都有一个struct files_struct*的结构体指针,操作系统为了让进程和该进程打开的文件关联起来,在内核创建了一个struct file_struct的结构体,其中这个struct files_struct结构体中又包含了一个名为fd_array的结构体指针数组该数组的下标就是我们的文件描述符,数组的内容就是我们所打开文件的地址。

无论是我们打开的文件struct file还是进程和文件产生关联关系的文件描述符数组fd_array以及task_struct,他们都属于内核数据结构。

而程序替换是把一段新的代码对旧的代码进行替换,然后修改页表数据,改一下部分的字段就可以了,也就是说程序替换改的是进程的代码和数据部分,而且我们之前学到,程序替换不会产生新的进程,也不会影响进程的内核数据结构,所以这两个操作并不会影响。这就是内存管理和文件操作之间的解耦关系。

所以我们得出结论:进程历史打开的文件与进行的各种重定向关系都和未来进行程序替换无关!程序替换,并不不影响文件访问!!

【Linux】基础IO_第31张图片

七、FILE

FILE封装了文件描述符fd

  • 因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。
  • 所以C库当中的FILE结构体内部,必定封装了fd。

我们通过/usr/include/stdio.h看到这段代码:

typedef struct _IO_FILE FILE; 

我们可以看到FILE其实就是struct _IO_FILE结构体的一个别名,那这个struct _IO_FILE结构体里面有什么呢?

下面我们进入/usr/include/libio.h这个头文件中查看一下struct _IO_FILE这个结构体的定义:

struct _IO_FILE {
	int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
	#define _IO_file_flags _flags
	//缓冲区相关
	/* The following pointers correspond to the C++ streambuf protocol. */
	/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
	char* _IO_read_ptr; /* Current read pointer */
	char* _IO_read_end; /* End of get area. */
	char* _IO_read_base; /* Start of putback+get area. */
	char* _IO_write_base; /* Start of put area. */
	char* _IO_write_ptr; /* Current put pointer. */
	char* _IO_write_end; /* End of put area. */
	char* _IO_buf_base; /* Start of reserve area. */
	char* _IO_buf_end; /* End of reserve area. */
	/* The following fields are used to support backing up and undo. */
	char *_IO_save_base; /* Pointer to start of non-current get area. */
	char *_IO_backup_base; /* Pointer to first valid character of backup area */
	char *_IO_save_end; /* Pointer to end of non-current get area. */
	struct _IO_marker *_markers;
	struct _IO_FILE *_chain;
	int _fileno; //封装的文件描述符->fd
#if 0
	int _blksize;
#else
	int _flags2;
#endif
	_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
	/* 1+column number of pbase(); 0 is unknown. */
	unsigned short _cur_column;
	signed char _vtable_offset;
	char _shortbuf[1];
	/* char* _save_gptr; char* _save_egptr; */
	_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

我们在struct _IO_FILE结构体中的成员当中,可以看到一个名为 _fileno的成员这个成员其实就是封装的文件描述符。

知道了上面的这些之后,下面我们再来理解一下C语言当中fopen函数究竟干了什么?

fopen函数在上层为用户申请FILE结构体,并返回该结构体的地址(FILE*),在底层通过系统调用接口open打开或者创建对应的文件,为其分配一个文件描述符fd,并将文件描述符fd填入到FILE结构体中的_fileno变量中,如以此来便完成了文件的打开操作。

我们看到FILE里面还有对应打开文件的缓冲区字段和信息。下面我们来谈一下FILE中的缓冲区。

FILE中的缓冲区

我们来看一段代码:

#include 
#include 
#include 

int main()
{
    const char *fstr = "hello fwrite\n";
    const char *str = "hello write\n";

    printf("hello printf\n"); // stdout -> 1
    fprintf(stdout, "hello fprintf\n"); // stdout -> 1
    fwrite(fstr, strlen(fstr), 1, stdout); // fread, stdout->1

    write(1, str, strlen(str)); // 1                           

    return 0;
}

运行结果:

【Linux】基础IO_第32张图片

可以直接运行程序和重定向到log.txt都看到write、printf、fprintf、fwirte函数都将其对应的内容输出到了显示器上。 

我们再来看一段带fork()版本的代码:

#include 
#include 
#include 

int main()
{
    const char *fstr = "hello fwrite\n";
    const char *str = "hello write\n";

    printf("hello printf\n"); // stdout -> 1
    fprintf(stdout, "hello fprintf\n"); // stdout -> 1
    fwrite(fstr, strlen(fstr), 1, stdout); // fread, stdout->1

    write(1, str, strlen(str)); // 1                           
    fork();
    
    return 0;
}

运行结果: 

【Linux】基础IO_第33张图片

我们发现这次经过输出重定向之后,c接口printf、fprintf和fwrite都输出了两次,而write只输出了一次。这一定和fork有关系。

上面的代码为什么经过输出重定向后,库函数的内容重定向到文件中都分别打印了两次,而系统调用接口却只打印了一次呢?

再回答上面的问题之前,我们先来讲一下缓冲区的刷新策略。

缓冲区有以下三种刷新策略:

  • 立即刷新(不缓冲)
  • 行刷新(行缓冲\n),比如:显示器打印
  • 缓冲区满了,才刷新(全缓冲),比如:往磁盘文件中写入数据。

知道了缓冲区的刷新策略之后,我们得明白一件事情,这个缓冲区一定不是操作系统提供的。

为什么不是操作系统提供的,那是谁提供的呢?我们来看下面的例子进行说明。

我们来看下面这段代码:

#include 
#include 
#include 

int main()
{

    const char *fstr = "hello fwrite";

    printf("hello printf"); // stdout -> 1
    fprintf(stdout, "hello fprintf"); // stdout -> 1
    fwrite(fstr, strlen(fstr), 1, stdout); // fread, stdout->1

    close(1);

    fork();                                                    
    return 0;
}

运行结果: 

我们把字符串的\n去掉,然后进行close(1),我们发现这次运行完没有打印结果。 

我们再来看下面这段代码:

#include 
#include 
#include 

int main()
{
    const char *str = "hello write";                          

    write(1, str, strlen(str)); // 1

    close(1);
    return 0;
}

运行结果:

我们发现同样的待遇,我们也把\n去掉然后进行close(1),而这次write的数据却打印出来了。

为什么会出现这样的现象?我们通过下面这张图来理解。

【Linux】基础IO_第34张图片

  • 我们知道printf/fprintf/fwrite/fputs等c接口库函数都是封装了系统调用write的,它们默认写入的缓冲区c语言提供的缓冲区,当我们加上\n时,printf采用刷新策略的是行刷新,就会调用write系统调用接口,将数据立即刷新。当我们去掉\n的时候,文件写入采用的刷新策略是全缓冲,实际上我们的数据全部写入到了c语言提供的缓冲区。这个时候我们还进行close(1),数据就不能写入到操作系统的缓冲区,所以就没有数据打印出来,这也说明文件写入采用的是全缓冲
  • 而write系统调用接口默认写入的缓冲区是操作系统的缓冲区,所以即便进行close(1)和去掉\n,依然能够打印出来。

通过上面这两段代码的运行结果我们就可以确定,这个缓冲区一定不是在操作系统的内部,而是c语言给我们提供的一个缓冲区。而且文件写入采用的是全缓冲。

通过上面的例子我们也知道,操作系统其实也是有缓冲区的。当我们刷新用户缓冲区的数据时,并不是直接就将用户的缓冲区就刷新到了磁盘或者显示器上,而是先将用户缓冲区上面的内容刷新到操作系统的缓冲区,最后再由操作系统将数据刷新到磁盘或者某种外设。(操作系统有自己的刷新机制,我们并不需要去关系操作系统的刷新机制) 

【Linux】基础IO_第35张图片

那么为什么要有用户缓冲区呢?

  • 解决效率问题--用户的效率问题(类似菜鸟驿站发货,一起发货效率更高,更经济)
  • 配合格式化

了解了这些背景知识以后,下面我们就来回答上面的问题: 为什么上面的代码为什么经过输出重定向后,库函数的内容重定向到文件中都分别打印了两次,而系统调用接口却只打印了一次呢?

  • 当我们没有进行输出重定向,然后执行可执行程序时,最终会将数据打印到显示上,此时采用的是行缓冲,因为每个字符串的后面都带有一个\n,所以当我们执行完write、printf以及fprintf函数就会立即将数据打印到显示器上。
  • 而当我们对文件进行重定向的时候,此时的刷新策略会由行缓冲变成全缓冲。对于全缓冲而言,它并不会将数据立即刷新,而是会等到缓冲区满了才进行刷新。因此我们使用printf与fprintf函数会将打印的数据都打印到C语言的缓冲区里,因为这些数据还并未刷新到磁盘或者显示器上面,所以这些数据还是父进程里的数据。
  • 当我们fork之后创建子进程,刚开始父子进程是共享这些数据的,但是后面当父进程或者子进程要刷新缓冲区的内容时,其本质就是对父子进程共享的数据进行了修改,因为进程之间是具有独立性的,所以这个时候就会发生写时拷贝,因此缓冲区里面的数据就由一份变成了两份。一份是子进程的,一份是父进程的,因此重定向到log.txt文件中,printf和fprinf函数打印的数据会有两份。
  • 但是对于系统调用write而言,它是没有所谓的缓冲的(没有用户级缓冲区),所以write函数打印的数据就只打印了一次。

综上: printf fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。那这个缓冲区谁提供呢? printf fwrite 是库函数, write 是系统调用,库函数在系统调用的“上层”, 是对系统调用的“封装”,但是 write 没有缓冲区,而 printf fwrite 有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供。

简单模拟实现c文件标准库

下面我们来模拟实现一下c文件标准库来帮助我们理解上面FILE的理论。

Mystdio.h:

#ifndef __MYSTDIO_H__
#define __MYSTDIO_H__

#include 

#define SIZE 1024//缓冲区存储空间

//三种刷新策略
#define FLUSH_NOW 1
#define FLUSH_LINE 2
#define FLUSH_ALL 4

typedef struct IO_FILE{
    int fileno;//文件描述符fd
    int flag;                                                                  
    //char inbuffer[SIZE];
    //int in_pos;
    char outbuffer[SIZE]; // 用一下这个
    int out_pos;//用来记录已经刷新的个数,pos左边是已经刷新的,右边是还没刷新的
}_FILE;


_FILE * _fopen(const char*filename, const char *flag);
int _fwrite(_FILE *fp, const char *s, int len);
void _fclose(_FILE *fp);



#endif

Mystdio.c:

#include "Mystdio.h"
#include 
#include 
#include 
#include 
#include 
#include 

#define FILE_MODE 0666

// "w", "a", "r"
_FILE * _fopen(const char*filename, const char *flag)
{
  assert(filename);
  assert(flag);

  int f = 0;
  int fd = -1;
  if(strcmp(flag,"w") == 0)
  {
    f = (O_CREAT|O_WRONLY|O_TRUNC);
    fd = open(filename,f,FILE_MODE);
  }
  else if(strcmp(flag, "a") == 0)
  {
    f = (O_CREAT|O_WRONLY|O_APPEND);
    fd = open(filename, f, FILE_MODE);
  }
  else if(strcmp(flag, "r") == 0) {
    f = O_RDONLY;
    fd = open(filename, f);
  }
  else{
    return NULL;                                     
  }

  if(fd == -1) return NULL;//文件打开失败

  _FILE *fp = (_FILE*)malloc(sizeof(_FILE));
  if(fp == NULL) return NULL;
  
  fp->fileno = fd;                                
  //fp->flag = FLUSH_LINE;
  fp->flag = FLUSH_ALL;
  fp->out_pos = 0;

  return fp;
}

int _fwrite(_FILE *fp, const char *s, int len)
{
  //写入缓冲区len个字符
  memcpy(&fp->outbuffer[fp->out_pos],s,len);
  fp->out_pos+=len;
  
  if(fp->flag&FLUSH_NOW)
  {
    write(fp->fileno,fp->outbuffer,fp->out_pos);
    fp->out_pos=0;
  }
  else if(fp->flag&FLUSH_LINE)
  {
    if(fp->outbuffer[fp->out_pos-1] == '\n')
    {
      write(fp->fileno,fp->outbuffer,fp->out_pos);
      fp->out_pos = 0;
    }
  }
  else if(fp->flag&FLUSH_ALL)
  {
    if(fp->out_pos == SIZE)
    {
      write(fp->fileno,fp->outbuffer,fp->out_pos);
      fp->out_pos=0;
    }
  }

  return len;
}

void _fflush(_FILE* fp)
{
  if(fp->out_pos > 0)
  {
    write(fp->fileno,fp->outbuffer,fp->out_pos);
    fp->out_pos=0;
  }
}

void _fclose(_FILE *fp)
{
  if(fp == NULL)
    return;

  _fflush(fp);
  close(fp->fileno);
  free(fp);
}

main.c 

#include "Mystdio.h"
#include 

#define myfile "test.txt"

int main()
{
  _FILE *fp = _fopen(myfile,"a");

  if(fp == NULL) return 1;

  const char *msg = "hello world\n";
  int cnt = 10;
  while(cnt)
  {
    _fwrite(fp, msg, strlen(msg));  
    // fflush(fp);
    sleep(1);
    cnt--;
  }

  _fclose(fp);
  return 0;
}

运行结果:

【Linux】基础IO_第36张图片

 八、理解文件系统

如果一个文件,没有被打开,这个文件在哪里呢?磁盘。如果创建一个空文件,这个文件要不要占磁盘空间?必须的!这是因为文件有内容和属性,它们也是数据。

因此磁盘里面的文件由两部分构成:文件内容和文件属性。其中文件内容又被称为数据块,文件属性被称为inode。

文件的内容就是该文件中存储的数据,文件的属性就是文件的一些基本信息,比如说:文件大小、文件的权限、文件的类型以及文件的创建时间等等。文件属性又被称为元数据。

我们使用ls -l的时候看到的除了看到文件名,还看到了文件元数据。

【Linux】基础IO_第37张图片

每行包含7列: 

  • 模式
  • 硬链接数
  • 文件所有者
  • 大小
  • 最后修改时间
  • 文件名

ls -l读取存储在磁盘上的文件信息,然后显示出来

【Linux】基础IO_第38张图片

 其实这个信息除了通过这种方式来读取,还有一个stat命令能够看到更多信息

【Linux】基础IO_第39张图片

上面的执行结果有几个信息需要解释清楚 

inode

Linux的文件在磁盘中存储,是将的属性和内容是分开存储的。因为系统中可能会存在大量的文件,而一个文件又存在着许多的信息,inode是保存这些属性信息的集合。所以为了区分各个文件的inode,我们为每个inode设置了inode编号。

我们可以通过ls -li来查看当前目录下各个文件的inode编号:

 【Linux】基础IO_第40张图片

为了能解释清楚inode我们先简单了解一下文件系统

文件系统

磁盘的概念

磁盘是我们计算机中的一个机械设备(例如:SSD,FLASH卡,usb),它是计算机主要的存储介质,可以存储大量的二进制数据,并且断电后也能保持数据不丢失。

知道了磁盘的概念之后,下面我们来看一下磁盘的内部结构是怎么样的:

【Linux】基础IO_第41张图片

 对磁盘进行读写操作时,一般有以下三个步骤:

  • 确定读写信息在磁盘的哪个盘面---Cylinder
  • 确定读写信息在磁盘的哪个柱面(磁道)---Header
  • 确定读写信息在磁盘的哪个扇区---Sector

经过以上的三个步骤——CHS寻址方式,我们就可以确定信息在磁盘中的读写位置。

磁盘分区与格式化

大家想一下磁带,当磁带卷起来的时候,它就像磁盘一样是圆形的。当我们把磁带给拉出来的时候它就是线性的。所以为了便于理解,我们可以将一个磁盘想象成一个线性的结构。

【Linux】基础IO_第42张图片

磁盘分区: 磁盘写入的基本单位是扇区,一个扇区的大小通常是512字节。因此如果以大小为1024G的磁盘为例,该磁盘就会被分为若干个扇区。站在操作系统的角度,我们认为磁盘是线性结构的。

【Linux】基础IO_第43张图片

因为磁盘它是很大的,管理成本比较高。因此计算机为了更好的管理磁盘,便对磁盘进行了分区。这就好比我们的国家的领土:我们的国家是很大的,因此为了便于管理又将我们的国家划分成了30多个省。 

格式化: 但是光有分区还是不够的,我们还需要对磁盘进行格式化,磁盘格式化就是对磁盘中的分区进行初始化的一种操作。简单来说,磁盘格式化就是对分区后各个区域写入对应的管理信息。这也就好比仅仅将我们国家划分成30多个省这是不够的,国家还会派一些领导班子去管理这些省份。

【Linux】基础IO_第44张图片

所以操作系统对磁盘的管理就是对这些分区的管理,理论上我们只要管理好一个分区,我们就可以以相同的方式管理好其他的分区。

【Linux】基础IO_第45张图片

上图为Linux ext2文件系统磁盘文件系统图,只是磁盘文件的一个分区图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的块组(Block group)。因此我们现在对于磁盘的管理就变成了对这些块组的管理,我们只需要一个块组,我们就可以以相同的方式管理好其他的块组。

一个block的大小是由格式化的时候确定的,并且不可以更改。例如mke2fs的-b选项可以设定block大小为1024、2048或4096字节。而上图中启动块(Boot Block)的大小是确定的,

  • Block Group:ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相同的结构组成。政府管理各区的例子
  • 超级块(Super Block):存放文件系统本身的结构信息。记录的信息主要有:bolck 和 inode的总量,未使用的block和inode的数量一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了
  • GDT,Group Descriptor Table:块组描述符,描述块组属性信息有兴趣的同学可以在了解一下
  • 块位图(Block Bitmap):Block Bitmap中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用
  • inode位图(inode Bitmap):每个bit表示一个inode是否空闲可用。
  • i节点表: 存放文件属性 如 文件大小,所有者,最近修改时间等
  • 数据区:存放文件内容 

一个文件的inode与 Data block之间的关系: 

【Linux】基础IO_第46张图片

因此我们只要知道一个文件的inode,那我们就可以拿到该文件的属性信息以及该文件的内容。如此一来便实现了将数据和属性分开存储。

将属性和数据分开存放的想法看起来很简单,但实际上是如何工作的呢?我们通过touch一个新文件来看看如何工作。

[root@localhost linux]# touch abc
[root@localhost linux]# ls -i abc
263466 abc

下面我们来看一张简图来帮我们更好的理解inode与Data Block的关系 

【Linux】基础IO_第47张图片

创建一个新文件主要有一下4个操作: 

1. 存储属性
内核先找到一个空闲的i节点(这里是263466)。内核把文件信息记录到其中。
2. 存储数据
该文件需要存储在三个磁盘块,内核找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推。
3. 记录分配情况
文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表。
4. 添加文件名到目录
新的文件名abc。linux如何在当前的目录中记录这个文件?内核将入口(263466,abc)添加到目录文件。文件名和inode之间的对应关系将文件名和文件的内容及属性连接起来。

下面我们来分析几个问题:

如何理解创建一个文件?

  • 首先,我们需要确定文件应被存储在哪个分区和块组中。在对应的分区和块组中,遍历inode位图,找到一个未被使用的inode,然后通过inode位图与inode Table的映射关系找到对应的inode,并将文件的属性信息填充到该inode结构体中。
  • 要进行文件写入操作,我们需要首先查找哪些数据块是未被使用的。这可以通过遍历Block位图并查找未使用的块来实现。找到这些块后,我们将它们与Data Blocks进行映射,并将文件数据写入这些块中。然后,我们将这些数据块的编号添加到inode的文件数据块列表中,从而建立了inode和数据块之间的映射关系。
  • 最后,我们将inode id和文件名的映射关系添加到目录的存储列表中,以便可以方便地通过文件名查找对应的inode id,以及通过inode id查找对应的数据块。

如何理解查找一个文件?

  • 我们可以根据文件名和inode id的映射关系来找到特定的inode。一旦找到对应的inode,我们就可以从中获取文件的属性信息,包括文件大小、创建时间、修改时间等。
  • 接着,通过inode的数据块列表,我们可以找到存储文件内容的数据块。这些数据块可能分布在磁盘的不同位置,但通过inode,我们可以找到所有这些数据块,并将它们组合起来,从而获取文件的完整内容。
  • 因此,通过这种方式,我们可以方便地获取文件的完整内容和属性信息。

如何理解删除一个文件?

  • 要删除一个文件,我们首先需要找到该文件。一旦找到文件,我们便获取其inode。然后,我们将inode位图中表示该文件inode的位从1改为0,同时还将数据块位图中与该文件相关的位也从1改为0。
  • 这种删除方式并非彻底删除文件信息,而是通过将inode号和数据块号在位图中标记为未使用,从而在系统层面达到“删除”的效果。实际上,文件的数据并没有被真正地清除,只是被系统标记为未使用,以便后续可以重新利用这些inode和数据块。

为什么下载文件的时候很慢,删除文件的时候很快?

  • 因为我们下载文件的时候首先需要创建文件,然后再将内容写入到文件中。该过程需要先申请inode号,将该文件的属性信息填入inode中,然后还需要申请数据块号,将文件的内容放到相应的数据块中,建立数据块与inode之间的映射关系,最终下载完成。而我们删除文件只需要把文件的inode号和申请的数据块号在位图中由1置0,并没有真正的删除文件,所以下载文件很慢,删除文件是很快的。
  • 这就好比建房子和拆房子一样:我们需要大量的人力、财力和物力并且需要花费很长的时间才能够建好一栋楼。但是拆房子的时候我们只需要在这栋楼上喷一个拆字就表示这栋楼要被拆除了。

如何理解目录?

  • 在Linux中,一切都被视为文件,包括目录。既然目录也是文件,它们也有自己的inode和数据块。
  • 目录的inode存储了其属性信息,就像任何其他文件一样。然而,目录的数据块内容与其他文件有所不同。目录的数据块主要存储了文件名和inode编号之间的映射关系。
  • 值得注意的是,文件的文件名并没有存储在inode中,而是存储在文件所在目录的文件内容中。计算机并不关心文件的文件名,只关心文件的inode编号。文件名主要是为了方便我们用户查看和使用。
  • 通过文件名和inode编号之间的映射关系,我们可以找到文件的inode。一旦找到inode,我们就可以获取文件的属性和内容。因此,目录的数据块只需要存储文件名和inode编号之间的映射关系,这样我们就可以通过这些信息找到文件。

九、软硬链接

软连接

概念:软链接它有自己独立的inode,软链接是一个独立的文件,有自己的inode属性也有自己的数据块(保存的是指向文件的所在路径+文件名)

我们可以通过ls -s指令来创建一个文件的软链接:

ln -s file.txt  soft-link

【Linux】基础IO_第48张图片

可以看到我们的软链接的inode编号与源文件的inode编号是不一样的,说明软链接是一个独立的文件,具有独立的inode,并且我们发现软链接的文件大小要比源文件的文件大小要小很多。

下面我们对两个文件的内容显示到显示器上:

【Linux】基础IO_第49张图片

我们可以看到这两个文件打印的结果是一样的。但是软链接的文件大小却要比源文件的大小小很多!!这就类似我们windows上的快捷方式!!说明软连接是一个独立的文件,有独立的inode也有独立的数据块,他的数据块里面保存的是指向的文件的路径+文件名,所以我们执行软链接的时候就相当于间接执行了这个源文件。

但是快捷方式它是不能够单独存在的,当我们的源文件被删除后,尽管有文件名,但是这个软链接就不能再执行了。

下面我们将软链接的源文件给删除掉之后再来执行一下这个软链接看看是什么结果:

【Linux】基础IO_第50张图片

可以看到,当我们把源文件删除之后,源文件不存在了,那么这个软链接找不到该源文件了,所以软链接也就打印不了了。

硬链接

概念:硬链接它不是一个独立的文件,而是一个文件名和inode编号的映射关系,因为它没有自己的inode,我们可以将它理解成源文件的别名。

我们可以通过以下指令来创建一个文件的硬链接:

ln test.txt hard-link

【Linux】基础IO_第51张图片

可以看到我们的硬链接并不是一个独立的文件,因为它的inode编号和源文件的inode编号是一样的,并且硬链接与源文件的文件大小也是一样的

下面我们再来通过代码来验证一下硬链接是不是就是源文件的一个别名:

我们将硬链接的源文件给删除掉之后再来打印这个硬链接看看是什么结果:

【Linux】基础IO_第52张图片

现在我们就可以确定了,硬链接就是源文件的一个别名,当源文件被删除后它依旧能够正常执行。

我们还看到创建硬链接后,两个文件都有一个2,这个其实是每个inode都有的引用计数器,代表有多少个文件名指向我!这也进一步说明硬链接就是源文件的一个别名。

inode的引用计数器

下面我们再来看新建一个目录的引用计数器现象:

【Linux】基础IO_第53张图片

我们发现一个问题——为什么刚刚创建的文件它的硬链接数是2呢?

这是因为每个目录创建后,该目录下还有两个隐藏文件.和..。它们分别表示当前目录和上级目录,因此我们刚刚创建的目录会有两个名字,一个是dir一个是.所以这个目录的硬链接数就是2。

【Linux】基础IO_第54张图片

可以看到我们dir目录的inode号和dir中. 的inode号是一样的,也就是说它们其实代表的是同一个文件。

小结

软硬链接的对比

  • 软连接是一个独立的文件,有独立的inode也有独立的数据块,他的数据块里面保存的是指向的文件的路径。相当于是windows下的快捷方式,所以必须要源文件存在的情况下才能正常运行。
  • 所谓的建立硬链接,本质其实就是在特定目录的数据块中新增 文件名和指向的文件的inode 编号的映射关系! !即使源文件被删除了它也能够正常运行。注意:不能对目录进行硬链接!
  • 软链接应用场景:可以链接一个路径很深的目录或文件等,相当于是windows下的快捷方式。
  • 硬链接应用场景:通常用来进行路径定位采用硬链接,可以进行目录间切换。(如cd ..)

十、动态库和静态库

静态库与动态库      

  • 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库。

makefile

【Linux】基础IO_第55张图片

生成静态库只需要在gcc后面加上-static 

【Linux】基础IO_第56张图片

静态库比较大,是拷贝库的一份代码。

  • 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。一般默认生成的可执行程序都是动态的,动态库体积小,运行时加载,只有一份。
  • 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
  • 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)。

【Linux】基础IO_第57张图片

  • 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。

【Linux】基础IO_第58张图片

我们看到这里动态生成的比静态生成的大小小了很多。

动态链接生成的可执行程序比静态链接生成的可执行程序小的原因?

【Linux】基础IO_第59张图片

下面我们先来看这段代码:

#include

int main()
{
    printf("hello Linux!\n");
    return 0;
}

 我们生成可执行程序后,我们可以通过ldd指令来查看一个可执行程序所依赖的库文件

【Linux】基础IO_第60张图片

这里的libc.so.6就是当前可执行程序所依赖的库文件,我们通过ls命令查看后发现libc.so.6它其实就是一个软链接。

那么我们怎么知道这个库是一个动态库还是静态库呢?

上面说了libc.so.6它是一个软链接,而该软链接的源文件就是libc-2.17.so。因此我们可以通过file+文件名指令来查看一下libc-2.17.so的文件类型

可以看到libc-2.17.so它其实是一个动态库。 

注意:

  • 在Linux下,以.so为后缀的文件是动态库,以.a为后缀的文件是静态库
  • 在Windows下,以.dll为后缀的文件是动态库,以.lib为后缀的文件是静态库

在Linux下,我们如何知道一个动静态库的名字呢?——去掉前缀,去掉后缀,剩下的就是该动静态库的库名了。

如:libc-2.17.so,去掉去掉前缀lib,去掉so,剩下的就是该动静态库的库名了

在Linux下,gcc/g++编译器默认采用的是动态链接,若是想采用静态链接的话,我们需要在gcc/g++后面带上一个-static选项即可。

大家在使用gcc/g++命令带这个选项的时候可能会由于当前云服务器上面没有下载静态库而导致出错,因此我们需要先使用下面这两条命令分别下载gcc/g++静态库之后再去进行静态链接,这样的话就不会出错了。

yum install glibc-static
yum install glibc-static libstdc++-static

下面我们就来生成一下静态链接的可执行程序吧 

静态库

生成静态库

 mymath.h代码

#pragma once

#include 

extern int myerrno;

int add(int x, int y);
int sub(int x, int y);
int mul(int x, int y);
int div(int x, int y);

 mymath.c代码

#include "mymath.h"

int myerrno = 0;//这里用myerrno来记录除法函数是否使用错误,除数不能为0
                                                                      
int add(int x, int y)
{
    return x + y;
}
int sub(int x, int y)
{
    return x - y;
}
int mul(int x, int y)
{
    return x * y;
}
int div(int x, int y)
{
    if(y == 0){
        myerrno = 1;
        return -1;
    }
    return x / y;
}

生成静态库第一步,先生成目标文件: gcc -c mymath.c -o mymath.o

【Linux】基础IO_第61张图片

第二步,用目标文件生成静态库:ar -rc mymath.a mymath.o
注意:ar命令是gnu的归档工具,它常用于将目标文件打包为静态库,ar命令中的-r选项与-c选项分别代表的是repalce和creat。

  • -r(replace):若静态库文件当中的目标文件有更新,则用新的目标文件替换旧的目标文件。
  • -c(creat):建立静态库文件。

【Linux】基础IO_第62张图片

我们还可以使用ar命令的-t选项和-v选项来查看静态库中的目录列表

  • -t:列出静态库重点文件
  • -v(verbose):详细信息

第三步:发布静态库

将生成的静态库文件与目标文件对应的头文件组织起来

当我们将我们自己的库给别人使用的时候,我们需要创建两个目录,一个用来存放所有的头文件,一个用来存放静态库文件。

因此,我们将mymath.h这个头文件放到include这个目录下。将静态库文件放到lib这个目录下。然后将这两个目录都放到mylib这个目录下,此时我们若是向把我们自己的库给别人使用,我们只需要将mylib这个文件发给别人就可以了。

【Linux】基础IO_第63张图片

使用静态库

静态库生成好了之后,我们下面来尝试着使用以上这个静态库。

我们首先创建一个main.c文件,然后写一段简单的代码用一下加法和除法函数。

main.c代码

#include "mymath.h"

int main()
{
  extern int myerrno;
  printf("1+1=%d\n", add(1,1));
  
  int n = div(10, 0);//这里先用n来接收,否则直接用div(10, 0)直接给形参赋值的话,形参是从右向左赋值的myerrno就按0先赋值了
  printf("10/0=%d, errno=%d\n", n, myerrno);

  return 0;
}

方法一:使用gcc选项 

 【Linux】基础IO_第64张图片

这里居然出错了,我们打包好的静态库和头文件不就在当前目录下嘛,为什么它这里却又说找不到呢?

这是因为编译器它并不知道你所包含的mymath.h头文件在哪里,所以我们需要指定头文件的搜索路径。

因此我们需要在gcc后面带一个I选项就可以指定头文件的搜索路径了

  • -I:指定头文件的搜索路径

【Linux】基础IO_第65张图片

可是看到我们这里又报错了,但是这次已经不是找不到头文件的报错了,那为什么又报错了呢? 

这是因为头文件myadd.h里面只有加法函数的声明,并没有函数的定义,因此我们还需要指定库文件的搜索路径。

因此我们只需要在gcc后面带一个选项-L就可以指定库文件的搜索路径了

  •  L:指定库文件搜索路径

我们发现又出错误了,咦不对呀,我明明该做的事情都已经做了,这里为什么还会报错呀?

这是因为你只是告诉了编译器库文件的搜索路径在哪里,但是你并没有告诉你要去链接哪一个库,假如说这个库文件里面有很多个库,那编译器它又怎么知道你想要去链接哪个库呢。

所以我们还需要指定一下我们需要链接库文件中的哪一个库。

因此我们需要在makefile里面的gcc后面再带一个选项去指明我们想要链接库文件中的哪一个库。

  • l:指明想要链接库文件中的哪一个库

【Linux】基础IO_第66张图片

最终我们得到了我们想要的运行结果。 

注意: 所有的库和头文件只有在当前路径下能够被直接找到,如果当前路径下还包了目录,那么你就需要指明你的搜索路径才行。库还要指定具体是哪个的库文件。

大家看了上面的那种静态库使用方式可能会觉得拿这种方式太麻烦了,下面就来告诉大家第二种方法。

方法二:库安装​​​​​​​——将头文件和库文件放到系统路径下

如果我们不指定搜索路径的话,编译器它就找不到我们的头文件和库文件,除了上面指定搜索路径的法子我们还可以将我们的头文件和库文件放到系统路径下,这样的话编译器就能够找到了。这种方法其实就是进行库安装,我们平时使用第三方库进行安装使用是一样的。

下面我们来演示一下:

【Linux】基础IO_第67张图片

编译运行:

【Linux】基础IO_第68张图片

注意:尽管使用这种方式我们不需要指定头文件的搜索路径和库文件的搜索路径,但是我们还是要告诉编译器我们链接库文件的哪个库。  

动态库

生成动态库

动静态库的打包基本类似。下面我们使用这四个文件来给大家展示动态库的生成和使用

mylog.h

#pragma once
#include 

void Log(const char*);

mylog.c

#include "mylog.h"

void Log(const char*info)
{
    printf("Warning: %s\n", info);
}

myprint.h

#pragma once
#include 

void Print();

myprint.c

#include "myprint.h"

void Print()
{
    printf("hello new world!\n");
    printf("hello new world!\n");
    printf("hello new world!\n");
    printf("hello new world!\n");
}

第一步:让所有的源文件生成对应的.o目标文件

【Linux】基础IO_第69张图片

注意: 我们这里让所有源文件生成对应的.o目标文件时,需要带上 -fPIC 这个选项

  • fPIC:产生位置无关码(position independent code)

第二步:将目标文件打包生成动态库

【Linux】基础IO_第70张图片

注意: 我们这里将目标文件打包生成动态库的时候,我们还需要带上-shared选项

然后我们来make一下生成对应的目标文件和动态库:

【Linux】基础IO_第71张图片

第三步:发布动态库—将头文件和我们生成的动态库组织起来

这次我们在Makefile里面编写一段代码,通过发布然后将他们给组织起来。

【Linux】基础IO_第72张图片

下面我们来make output一下,通过发布将头文件和动态库给组织起来:

此时我们的动静态库和头文件就已经打包完毕了,如果后面别人想要用我们的这个库,我们只需要把mylib这个文件给别人就可以了。

可以看到我们这次动态库和静态库进行链接了,说明如果系统需要链接多个库,则gcc可以链接多个库。

使用动态库

上面我们的动态库打包好之后,我们就来使用一下我们打包好的动态库吧,下面我们依然使用test.c文件来为大家演示动态库的使用

main.c:

#include "mylog.h"
#include "myprint.h"

int main()
{
    Print();
    Log("hello log function");
    return 0;
}

下面我们通过gcc生成可执行程序然后来执行一下吧

gcc mytest.c -I mylib/include/ -L mylib/lib/ -lmymethod

【Linux】基础IO_第73张图片

可以看到我们这里执行可执行程序的时候出错了,这个时候我就比较好奇了:我明明已经指定头文件的搜索路径,指定库文件的搜索路径,以及我们要链接库文件中的哪一个,为什么我们这里还是会报错呢?

这是因为你只是告诉了编译器,你并没有告诉操作系统。不要忘了链接动态库的时候是在程序运行的时候链接的。

可以看到当我们用ldd命令查看test可执行程序的时候,发现找不到这个动态库。

那么问题又来了:我们应该如何解决呢?

方法一:拷贝.so文件到系统共享库路径下
与静态库使用的第二种方法类似,我们将动态库拷到系统路径下。

cp ./mylib/lib/libmymethod.so /usr/lib64/

【Linux】基础IO_第74张图片

  • 方法二:更改LD_LIBRARY_PATH

LD_LIBRARY_PATH是程序运行时帮我们找动态库的路径的,因此我们将该动态库所在目录路径添加到LD_LIBRARY这个环境变量中就可以了

[root@hecs-202562 test]# export LD_LIBRARY_PATH=./mylib/lib

【Linux】基础IO_第75张图片

可以看到我们现在再使用ldd命令的时候就可以找到该动态库了。

下面我们来执行一下这个可执行程序:

【Linux】基础IO_第76张图片

还有两种方法:

  • 在系统默认的库路径/usr/lib64下建立软链接
  • 在/etc/ld.so.conf.d 建立自己的动态库路径的配置文件,然后重新Idconfig即可

但实际情况,我们用的库都是别人的成熟的库,都采用直接安装到系统的方式!(第一种方法)​​​​​​​

你可能感兴趣的:(Linux,linux,服务器,c语言)