【Linux系统】文件与基础IO

        本篇博客整理了文件与文件系统、文件与IO的相关知识,借由库函数、系统调用、硬件之间的交互、操作系统管理文件的手段等,旨在让读者更深刻地理解“Linux下一切皆文件”。

【Tips】文件的基本认识 

  1. 文件 = 内容 + 属性。文件在创建时就有基本属性,比如权限,文件名,文件的创建时间等基本信息。
  2. 文件可分为打开的文件与未被打开的文件,打开的文件由操作系统进行管理,未打开的文件要解决如何找的问题。
  3. 文件是通过先组织再描述的方式被操作系统间接管理的,具体方式是,通过其共有的属性被描述为 struct file 结构体对象,这些结构体之间通过链表的形式被操作系统组织,进而统一的进行管理。
  4. 一个进程可能会打开多个文件,多个进程可能会打开同一份文件。以IO的角度来看,显示器文件,每个进程都要打开这个文件(多对一)。一个进程创建多个文件进行打开(一对多)。因此每个进程都有属于自己打开的一个或多个文件,因此进程这里就抽象出了 struct files_struct进行管理属于自己打开的文件。

目录

一、文件相关操作

1.C语言的文件操作接口

1.1-文件的打开和关闭

1.2-文件的读取和写入

2.文件相关的系统调用

2.1-open()

2.2-write()

2.3-read()

3.文件描述符

补.Linux下一切皆文件

二、文件重定向

1.重定向的原理

2.重定向相关的系统调用:dup2()

补.stdout 与 stderr 的区别

补.添加重定向至模拟实现的shell

三、文件缓冲区

1.C语言的缓冲区

2.操作系统的缓冲区 

四、文件系统

1.inode

2.磁盘

2.1-磁盘的结构

2.2-分区与格式化

3.EXT2文件系统

4.软硬链接

4.1-软链接

4.2-硬链接

4.3-软链接与硬链接的区别

补.文件从磁盘加载到内存

五、动静态库

1.静态库的制作

1.1-打包

1.2-使用

2.动态库的制作

2.1-打包

2.2-使用

补- 动态库如何被多个程序共享


一、文件相关操作

1.C语言的文件操作接口

【补】C语言中涉及文件操作的库函数

库函数功能
fopen打开文件
fclose关闭文件
fputc写入一个字符
fgetc读取一个字符
fputs写入一个字符串
fgets读取一个字符串
fprintf格式化写入数据
fscanf格式化读取数据
fwrite向二进制文件写入数据
fread从二进制文件读取数据
fseek设置文件指针的位置
ftell计算当前文件指针相对于起始位置的偏移量
rewind设置文件指针到文件的起始位置
ferror判断文件操作过程中是否发生错误
feof判断文件指针是否读取到文件末尾

1.1-文件的打开和关闭

  • 打开文件:fopen()
#include <stdio.h>
FILE *fopen( const char *path, const char *mode );
功能:按指定方式打开指定文件
参数:1.path:要打开的文件路径或当前路径下的文件名
     2.mode:打开文件的方式
返回值:打开成功,返回指向该文件信息区的指针(FILE*);打开失败,返回 NULL

 【补】当前路径

        当前路径,或称工作路径,是由进程PCB维护的一个进程属性。一个可执行程序在被加载到内存成为进程时,它所对应的PCB对象中就维护了一个名为 cwd 的属性, cwd 就表示这个进程当前的工作路径。注意!当前路径具体不是指一个可执行程序所在的路径,而是指一个可执行程序运行成为进程时,用户所在的路径

【补】打开文件的方式

  • 关闭文件:fclose()
#include <stdio.h>
int fclose( FILE *stream );
功能:关闭一个指定的文件
参数:指向要关闭的文件的指针(FILE*)
返回值:关闭成功,返回 0 ;关闭失败,返回 EOF

 -------------------------

        打开和关闭文件的演示:

//file_oc.c
#include<stdio.h>
int main()
{
	FILE* pf = fopen("log.txt", "r");
	if (pf == NULL)
	{
		perror("fopen");
		return 1;
	}
    else
    {
        printf("打开成功!\n");
	}

	//读文件 
	//...

	//关闭文件
	fclose(pf);
	pf = NULL;
	return 0;
}

1.2-文件的读取和写入

  • 读取文件内容:fgets()
#include <stdio.h>
char * fgets ( char * str, int num, FILE * stream );
参数:1.str:把读取到的字符全部拷贝到str所指向的空间
    2.num:读取num个的字符(其中会包含一个’\0’,因此实际上只会读取num-1个字符)
    3.stream:待读取的文件,或支持读取的文件流
返回值:读取成功会返回str,读取结束会返回NULL
【ps】fgets是文本行输入函数,因此当一行没有读取完的时候,下一次读取会从上一次读取结束的位置继续读取当前行的文本。如果待读取的字符个数num大于当前文本行的字符个数,那此次读取只会把当前行的所有字符读取出来,不会去读取下一行的字符。

 【补】三个标准输入输出流

        程序读取数据的过程,是用户会通过敲击键盘的方式将数据写入到键盘文件中,然后程序从键盘文件中完成数据的读取。同理,数据的输出是将数据写入到显示器文件中,然后用户才能在显示器上看到相应的数据。

        C程序在启动时,默认会打开三个标准流文件:

  • stdin:标准输入流——键盘文件
  • stdout:标准输出流——显示器文件
  • stderr:标准错误流——显示器文件

        于是,向“显示器文件”写入数据和从“键盘文件”读取数据的时候,用户无须先自行进行打开“显示器文件”和“键盘文件”。当C程序运行起来的时候,操作系统就会默认使用C语言的相关接口将这三个输入输出流打开,之后才能调用类似于scanf() 和 printf() 之类的输入输出函数。

        而stdin、stdout、stderr这三个流实际上都是FILE*类型的,与用户在打开某一文件时,获取到的文件指针是同一个类型。

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

----------------------- 

        读取文件内容的演示: 

//file_read.c
#include <stdio.h>
int main()
{
    //打开文件
	FILE* fp = fopen("log.txt", "r");
	if (fp == NULL){
		perror("fopen");
		return 1;
	}
    //读取文件内容
	char buffer[64];
	fgets(buffer, sizeof(buffer), fp);
	printf("%s", buffer);	
    //关闭文件
	fclose(fp);
	return 0;
}

  • 写入文件内容:fputs()
#include <stdio.h>
int fputs ( const char * str, FILE * stream );
功能:将一段信息写入指定的文件
参数:1.str:待写入的字符串
    2.stream:待写入的文件,或支持写入的流
返回值:写入成功,返回一个非负数;写入失败,返回 EOF

-------------------------

        写入文件内容的演示: 

//file_write.c
#include <stdio.h>
int main()
{
    //打开文件
	FILE* fp = fopen("log.txt", "a"); //以w方式打开会覆写文件内容,这里以a(追加)方式打开
	if (fp == NULL){
		perror("fopen");
		return 1;
	}
    //写入文件内容
	int count = 5;
	while (count){
		fputs("hello world\n", fp);
		count--;
	}
    //关闭文件
	fclose(fp);
	return 0;
}

2.文件相关的系统调用

        通常来说,文件是保存在磁盘上的,磁盘是外部设备,访问磁盘其实访问的是磁盘文件。

        在计算机层状结构中,硬件是处于最底层的,操作系统会把这些硬件管理起来。由于操作系统并不相信用户,因此,操作系统不允许用户直接访问硬件,而是向上层的用户提供了系统调用接口,让用户间接去访问硬件。几乎所有的库函数,只要是有关于访问硬件设备的,它们的底层一定会封装系统调用,也就是说,C语言里面的 fopen()、fgets()、printf()等,底层都一定封装了系统调用。

2.1-open()

//open()
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags, mode_t mode);
功能:打开文件
参数:1. pathname,文件创建的路径。
     2. flags,文件打开的方式,具体常见的方式有以下五种:
                       1) O_CREAT,文件没有就创建。
                       2) O_TRUNC,文件打开即清空。
                       3) O_APPEND,文件以追加形式打开。
                       4) O_WRONLY,文件以只写方式打开。
                       5) O_RDONLY,文件以只读方式打开。
                       6) O_RDWR ,文件以读和写的方式打开。
       除此之外,如果想要多种功能可以以 | 进行相连,除此之外若要追加,还得打开写权限。
       例如,以只写方式打开一个尚未创建的文件:O_WRONLY | O_CREAT
     3.mode,文件的默认打开权限,一般文件设置为0666,目录设置为0777。新创建文件的默认权限,
       要考虑权限掩码,可以配合 umask 系统调用接口来设置自己想要的效果。
       umask 系统调用产生的效果就只对当前进程创建的文件有关。
返回值:打开成功,返回新打开的文件描述符;打开失败,返回-1

//close()
#include<unistd.h>
int close(int fd);
参数:待关闭文件的文件描述符
返回值:关闭成功,返回0;关闭失败,返回-1,并设置合适的错误码

【补】 open() 的第二个参数 flags 实际上是一个有32比特位的位图。

        若将一个比特位作为一个标志位,则理论上 flags 可以传递32种不同的标志位。实际上,传入flags的每一个选项在系统当中都是以宏的方式进行定义的:

// 在/usr/include/bits/fcntl-linux.h文件中:

#define O_RDONLY         00 //O_RDONLY选项的二进制序列为全0,表示O_RDONLY选项为默认选项
#define O_WRONLY         01
#define O_RDWR           02
#define O_CREAT        0100
//...

        这些宏定义选项的共同点就是,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的,如此,open()内部可以通过&(与)运算来判断是否设置了某一选项:

int open(arg1, arg2, arg3){

    //...

	if (arg2&O_RDONLY){
		//设置了O_RDONLY选项
	}
	if (arg2&O_WRONLY){
		//设置了O_WRONLY选项
	}
	if (arg2&O_RDWR){
		//设置了O_RDWR选项
	}
	if (arg2&O_CREAT){
		//设置了O_CREAT选项
	}

	//...

}

        open()的使用示例:

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
  umask(0);//设置权限掩码为0。
  int fd = open("test.txt",O_CREAT|O_TRUNC|O_WRONLY,0666);
  //这里是在当前路径下,以如果没有就创建,并且清空只写的方式进行打开。
  //文件设置的权限为0666(八进制数),切记不能设置为666(十进制数),
  if(fd < 0)
  {
    perror("open");
    return -1;
  }
  else
  {
    printf("打开成功!\n");
  }
  close(fd);//close()是关闭文件的系统调用
  return 0;
}

2.2-write()

#include<unistd.h>
ssize_t write(int fd,const void* buf,size_t count);
功能:向文件写入内容
参数:1. fd,待写入文件的文件描述符
     2. buf,指向待写入的文件内容
     3. count,待写入内容的大小(单位是字节)
返回值:
写入成功,返回写入文件的字节数(0表示没有写入内容);写入失败,返回-1,并设置合适的错误码

        write() 的使用示例:

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
  umask(0);
  int fd = open("test.txt",O_CREAT|O_TRUNC|O_WRONLY,0666);
  if(fd < 0)
  {
    perror("open");
    return -1;
  }
  const char* message = "hello write\n";
  write(fd,message,strlen(message));
  close(fd);
  return 0;
}

2.3-read()

#include<unistd.h>
ssize_t write(int fd,const void* buf,size_t count);
功能:读取文件内容
参数:1. fd,待读取文件的文件描述符。
     2. buf,指向一段空间,该空间用来存储读取到的内容
     3. count,限定参数二指向空间的大小
返回值:读取成功,返回文件的字节数(0表示没有内容);读取失败,返回-1,并设置合适的错误码

【补】调整文件指针的接口:lseek()

        文件的读取是从文件指针所指的位置开始向后读取的。假设在读取前,创建一个新的文件并向其中写入了“ hello write ” ,那么文件指针会指向 “ hello write ”之后的位置,此时进行读取操作,是无法读取到任何内容的,而要读取到文件内容,需将文件指针调整到文件开头再进行读取操作。

#include<unistd.h>
#include<sys/type.h>
off_t lseek(int fd,off_t offset,int whence);
功能:调整文件指针的位置
参数:1. fd,待调整文件的文件描述符
     2. offset,移动到相对于whence偏移量offset的位置。
     3. whence, 移动的起点位置,常见的有SEEK_SET(文件开头),
        SEEK_CUR(文件的当前位置),SEEK_END(文件末尾位置)。
        常见用法:
        1) lseek(fd,0,SEEK_SET); //移动文件指针到开始
        2) lseek(fd,0,SEEK_CUR); //移动文件指针到当前位置
        3) lseek(fd,0,SEEK_END); //移动文件指针到结束位置
返回值:调整成功,返回距离文件开头的字节数;调整失败,返回-1,并设置合适的错误码。

        read()的使用示例:

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
#define MAX_SIZE (1024)
int main()
{
  int fd = open("test.txt",O_CREAT|O_TRUNC|O_RDWR,0666);
  if(fd < 0)
  {
    perror("open");
    return -1;
  }
  //向文件写入hello write\n
  const char* message = "hello write";
  write(fd,message,strlen(message));

  lseek(fd,0,SEEK_SET);//移动文件指针到开始

  //向文件中读入hello write\n
  char buf[MAX_SIZE] = {0};
  read(fd,buf,strlen(message));
  printf("read:%s\n",buf);
  close(fd);
  return 0;
}

3.文件描述符

        文件是在进程运行时,由进程打开的。一个进程可以打开多个文件,而系统中又存在着大量进程,那么在系统中,任何时刻都可能存在大量已打开的文件,因此,操作系统务必要管理好这些已经打开的文件。

        具体的方式还是先描述再组织——操作系统会为每个已经打开的文件,创建各自的 struct file 结构体对象,然后将这些结构体对象构建成一个双向链表,将文件的管理转化成双向链表的增删查改。而为了区分已经打开的文件是属于哪个进程的,就还需要建立进程和文件之间的映射关系。

        那么,进程和文件之间的映射关系是如何建立的呢?

        当一个进程被创建的时候,操作系统会为其创建相应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等相关的数据结构,并通过页表建立起虚拟内存和物理内存之间的映射关系。

        在 task_struct 中有一个指针,指向一个名为 files_struct 的结构体,而在 files_struct 结构体中又有一个名为 fd_array 、类型为 struct file* 的指针数组,这个指针数组其实就叫做文件描述符表,而这个指针数组的下标就叫做文件描述符(因此文件描述符一定大于等于0)。
        通常来说,文件是保存在磁盘上的。当有一个进程打开了一个文件时,这个文件会从磁盘加载到内存,操作系统会为其创建相应的 struct file*类型的结构体对象,然后将这些结构体对象也构建成一个双向链表,并保存到 fd_array 指针数组下标为3的位置上,最终返回这个下标(该文件的文件描述符)给打开文件的进程。

        因此,只要有一个文件的文件描述符,就可以找到这个文件的相关信息,进而对这个文件进行文件操作

【Tips】文件描述符本质是一个负责维护文件信息的指针数组的下标

【Tips】文件描述符对应的分配规则:从 0 号下标开始,寻找下标最小的、没有使用过的位置

        程序在运行起来的时候,操作系统会默认打开标准输入流 stdin(下标0)、标准输出流 stdout(下标1)、标准错误流 stderr(下标2),它们也是 struct file*类型的结构体对象,对应着键盘文件和显示器文件。而之后由进程新打开的文件,它们的文件描述符只能从 3 开始,而不能再是0、1、2。

【补】磁盘文件与内存文件

        文件可由文件的存储位置分为磁盘文件与内存文件。当文件存储在磁盘当中时,就称之为磁盘文件;当磁盘中的文件被加载到内存中后,就称之为内存文件。

        磁盘文件和内存文件的关系类似于程序和进程的关系,程序被加载到内存运行起来后便成了进程,而磁盘文件加载到内存后便成了内存文件。
        日常口头所说的文件,通常是指磁盘文件。磁盘文件由两部分构成,分别是文件内容和文件属性。文件内容就是文件当中存储的数据,文件属性就是文件的元信息,例如文件名、文件大小、文件创建时间等。
        在磁盘文件被加载到内存的过程中,它的文件属性一般会先被加载,当需要对文件内容进行操作时,再加载文件内容。

【补】C语言的 FILE 类型

        FILE 其实是 C 语言库中封装了文件描述符的一个结构体,其中, _fileno 属性就是文件描述符。

【补】文件关闭与引用计数

        一个文件可以被多个进程同时打开,例如程序运行时默认打开的 stdout 和 stderr 都会打开显示器文件。对于一个被多个进程打开的文件,要怎样合理地关闭它呢?

        在管理文件的 struct file 对象中有一个 f_count 字段,含义是当前文件的引用计数,能够记录当前文件被多少个进程打开。

        进程要关闭一个文件,是通过调用 close() 来完成的,close() 会将文件在指针数组fd_array 中的相应下标(文件描述符)处置为 NULL,然后操作系统会调整这个文件相应的 struct file 对象中的 f_count 字段。每个打开这个文件的进程进行一次关闭操作,f_count都会自减1,直到 f_count 为0 时,操作系统才将这个文件的 struct file 对象回收。

补.Linux下一切皆文件

        计算机为用户提供的服务,都是由进程去完成的,所以,用户关于文件的操作,也都是由进程去完成的,换句话说,所有对文件的操作都依赖于进程。

        所有的外设都经过“先描述再组织”的思想被抽象成了文件,每个外设都有自己的读写方法。

        对于不同的外设,它们的读写方法一定不同,但用户在进行文件操作的时候,用户的行为本身十分统一,来来回回都是在调用open()、close()、read()、write()等系统调用接口。这是因为操作系统为用户提供了一个接口集合 file_operations 结构体对象,其中封装的都是函数指针,可以指向不同外设的不同方法,而这种设计思想其实就是多态。

        总之,所谓的“Linux下一切皆文件”,其实就是操作系统通过封装的一层文件对象,将进程对各种外设的操作,转化成了进程对各种文件的操作。对操作系统来说,不论管理的对象如何变化,管理的方法始终还是那不变的六字真言——“先描述再组织”(面向对象)。

二、文件重定向

        重定向有输出重定向、输入重定向、追加重定向。

        最典型的重定向例子是,“echo + 字符串”默认是将字符串写入显示器文件并打印到屏幕上,加入输出重定向后,就可以将字符串写入到任一文件中。

        追加重定向是一种特殊的输出重定向,它和输出重定向的区别是,输出重定向是覆盖式输出数据,而追加重定向是追加式输出数据。

1.重定向的原理

  • 输出重定向的原理

        输出重定向就是,将本应该输出到一个文件的数据,重定向输出到另一个文件中

        为了方便演示输出重定向的原理,此处引入以下代码:

//test_p.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
    //关闭1号文件
	close(1);
    //打开一个新文件
	int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
    //打印一段内容
    printf("hello world\n");
	fflush(stdout);

    //关闭刚刚打开的文件	
	close(fd);

	return 0;
}

        按理来说,printf()会将“hello world”打印在屏幕上,为什么在程序运行时,屏幕上并没有出现任何内容,而新创建的文件 log.txt 中出现了要打印的“hello world”呢?

        上文已提及,文件描述符的分配规则,即从 fd_array 的 0 号下标开始,寻找最小的、没有被使用(所存为 NULL,没有指向任何一个 struct file 对象)的下标位置。

        以上代码中,先调用 close() 将 1 号下标对应的显示器文件关闭,又调用 open() 打开了一个文件 log.txt 。

        由文件描述符的分配规则,新打开的这个文件的文件描述符应该为 1,换句话说,原本 fd_array 的 1 号下标存的是标准输出流 stdout /显示器文件,但现在存的是新打开文件的struct file对象的地址。

        printf() 的功能是打印内容到显示器上,具体的实现方式是,将要打印的内容写入1号文件。原本的1号文件是 stdout ,但此时的1号文件其实是刚刚打开的 log.txt ,所以printf() 会将打印的内容写入到 log.txt 中,而非显示器文件。于是在程序运行时,屏幕上并没有出现任何内容,而 log.txt 中出现了要打印的“hello world”。

  • 输入重定向的原理

        输入重定向就是,将本应该从一个文件读取数据,重定向为从另一个文件读取数据。

        为了方便演示输入重定向的原理,此处引入以下代码:

//test_s.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	close(0);
	int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	char str[40];
	while (scanf("%s", str) != EOF){
		printf("%s\n", str);
	}
	close(fd);
	return 0;
}

         按理来说,scanf() 会从键盘读取数据并存入字符数组 str 中,然后 printf() 会将 str 中的数据打印在屏幕上。但在程序运行起来的时候,并没有等待从键盘输入,而是屏幕上直接出现了“Hello Linux”。这是为什么呢?

        以上代码中,先调用 close() 将 0 号下标对应的键盘文件关闭,又调用 open() 打开了一个文件 log.txt 。log.txt 中原本写入有“Hello Linux”。

        由于关闭了 0 号下标原本的键盘文件,又由文件描述符分配规则,因此这个新打开的文件的文件描述符应该为 0,代替键盘文件成为新的 0 号文件。

        scanf()的功能是从键盘读取数据并写入到一个变量,具体的实现方式是,在 0 号文件中读取数据并将数据写入一个变量。原本的 0 号文件是 stdin ,但此时的 0 号文件其实是刚刚打开的 log.txt,所以 scanf() 会从 log.txt 中读取数据并写入到 str 中,而非从键盘文件读取。于是,在程序运行起来的时候,并没有等待从键盘输入,而是屏幕上直接出现了“Hello Linux”。

【Tips】重定向的原理:本质其实是修改了文件描述符表中指针元素的指向

【ps】尽管 stdout 和 stderr 都对应显示器文件,但在使用输出重定向时,只会重定向 1 号文件 stdout,而不会重定向 2 号文件 stderr。

2.重定向相关的系统调用:dup2()

#include<unistd.h>
int dup2(int oldfd,int newfd);
功能:重定向,将oldfd下标指向的文件指针覆盖newfd下标的文件指针
参数:1.oldfd,是被重定向的文件的文件描述符。
     2.newfd,是重定向的目标文件的的文件描述符。
返回值:成功,返回newfd;失败,返回-1,并设置合适的错误码。

【Tips】dup2() 的工作细节

        dup2() 并不会先将一个文件关闭,再接着打开一个文件,而是将参数 oldfd 对应的 struct file 指针覆盖了 newfd 对应的原先的struct file 指针,以此完成重定向。

        假设现要将一个新打开的文件重定向为1号文件——

        在 dup2() 被调用之前,正常打开了一个文件,且不将显示器文件关闭,此时显示器文件的文件描述符就是 1,这个新打开文件的文件描述符就是 3。接下来,在调用 dup2() 的时候,这个新打开文件的文件描述符(3)将会作为参数 oldfd,而显示器文件的文件描述符(1)将作为参数 newfd。dup2() 的工作,就是将显示器文件对应的1号下标的 struct file 指针,指向新打开文件的 struct file。

         也就是说,dup2() 在调用后,显示器文件不再是1号文件了,而尽管新打开的文件还是3号文件,但它同时也是1号文件了。

         dup2() 的使用示例:

//test_dup2.c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
  int fd = open("test.txt",O_TRUNC | O_CREAT | O_RDWR,0666);
  //重定向
  dup2(fd,1);//将fd指向的文件指针覆盖1号下标的文件指针
  printf("hello world\n");
  close(fd);
  return 0;
}

补.stdout 与 stderr 的区别

        标准输出流 stdout 和标准错误流 stderr 都对应显示器文件,但它们有什么区别呢?

        为了方便演示,此处引入以下代码:

#include <stdio.h>
int main()
{
	printf("hello printf\n"); //stdout
	perror("perror");         //stderr

	fprintf(stdout, "stdout:hello fprintf\n"); //stdout
	fprintf(stderr, "stderr:hello fprintf\n"); //stderr
	return 0;
}

        printf() 的功能是默认使用 stdout 打印,perror() 的功能是默认使用 stderr 打印,fprintf()可以指定一个流来打印。

        以上代码的结果,stdout 和 stderr 的信息都成功打印了。

        stdout 和 stderr 似乎没有什么区别。

        但如果将以上代码的运行结果重定向到一个文件中去,stdout 和 stderr 的区别就很明显了:

        由图易知,stdout 的信息重定向到了 log.txt 中,而 stderr 的信息没有成功重定向。

【Tips】文件描述符为1的标准输出流 stdout 默认可以进行重定向,但文件描述符为2的标准错误流 stderr 默认不会进行重定向

【补】指定对 stderr 进行重定向

  • 文件描述符 +  >  +  目标文件名:

  • 同时对 stdout 和 stderr 进行重定向:

  • 将 stdout 和 stderr 重定向到同一个文件:

补.添加重定向至模拟实现的shell

(模拟实现shell详见:【Linux系统】进程控制-CSDN博客)

        重定向的实现步骤:

  1. 处理输入的命令(字符串),若包含>、>>、<则分别按不同类型的重定向处理(设置一个type变量,type为0表示命令当中包含输出重定向,type为1表示命令当中包含追加重定向,type为2表示命令当中包含输入重定向);
  2. 打开目标文件(重定向符号后面的字段为重定向的目标文件名,若type值为0则以写的方式打开目标文件,若type值为1则以追加的方式打开目标文件,若type值为2则以读的方式打开目标文件);
  3. 使用 dup2() 完成重定向(若type值为0或1,则使用dup2接口实现目标文件与标准输出流的重定向,若type值为2,则使用dup2接口实现目标文件与标准输入流的重定向)。
#include <stdio.h>
#include <fcntl.h>
#include <ctype.h>
#include <pwd.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#define LEN 1024   //命令最大长度
#define NUM 32     //命令拆分后的最大个数
int main()
{
	int type = 0;      //0:> | 1:>> | 2:<
	char cmd[LEN];     //存储命令
	char* myargv[NUM]; //存储命令拆分后的结果
	char hostname[32]; //主机名
	char pwd[128];     //当前目录
	while (1){
		//获取命令提示信息
		struct passwd* pass = getpwuid(getuid());
		gethostname(hostname, sizeof(hostname)-1);
		getcwd(pwd, sizeof(pwd)-1);
		int len = strlen(pwd);
		char* p = pwd + len - 1;
		while (*p != '/'){
			p--;
		}
		p++;
		//打印命令提示信息
		printf("[%s@%s %s]$ ", pass->pw_name, hostname, p);
		//读取命令
		fgets(cmd, LEN, stdin);
		cmd[strlen(cmd) - 1] = '\0';

		//实现重定向功能
		char* start = cmd;
		while (*start != '\0'){
			if (*start == '>'){
				type = 0;     //遇到一个'>',输出重定向
				*start = '\0';
				start++;
				if (*start == '>'){
					type = 1; //遇到第二个'>',追加重定向
					start++;
				}
				break;
			}
			if (*start == '<'){
				type = 2;     //遇到'<',输入重定向
				*start = '\0';
				start++;
				break;
			}
			start++;
		}
		if (*start != '\0'){        //start位置不为'\0',说明命令包含重定向内容
			while (isspace(*start)) //则跳过重定向符号后面的空格
				start++;
		}
		else{
			start = NULL;           //start设置为NULL,标识命令当中不含重定向内容
		}

		//拆分命令
		myargv[0] = strtok(cmd, " ");
		int i = 1;
		while (myargv[i] = strtok(NULL, " ")){
			i++;
		}
		pid_t id = fork();      //创建子进程执行命令
		if (id == 0){
			//child
			if (start != NULL){
				if (type == 0){ //输出重定向
					int fd = open(start, O_WRONLY | O_CREAT | O_TRUNC, 0664); //以写的方式打开文件(清空原文件内容)
					if (fd < 0){
						error("open");
						exit(2);
					}
					close(1);
					dup2(fd, 1);     
				}
				else if (type == 1){ //追加重定向
					int fd = open(start, O_WRONLY | O_APPEND | O_CREAT, 0664); //以追加的方式打开文件
					if (fd < 0){
						perror("open");
						exit(2);
					}
					close(1);
					dup2(fd, 1); 
				}
				else{ //输入重定向
					int fd = open(start, O_RDONLY); //以读的方式打开文件
					if (fd < 0){
						perror("open");
						exit(2);
					}
					close(0);
					dup2(fd, 0);
				}
			}

			execvp(myargv[0], myargv); //child进行程序替换
			exit(1);                   //替换失败则将退出码设为1
		}

		//shell
		int status = 0;
		pid_t ret = waitpid(id, &status, 0); //shell等待child退出
		if (ret > 0){
			printf("exit code:%d\n", WEXITSTATUS(status)); //持续打印child的退出码
		}
	}
	return 0;
}

三、文件缓冲区

1.C语言的缓冲区

        FILE是C语言当中与文件相关的一个结构体类型,其中封装了文件描述符、C语言自带的缓冲区等相关信息。

//【补】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; //封装的文件描述符
#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
};

        为了验证C语言缓冲区的存在,此处引入以下代码:

#include <stdio.h>
#include <unistd.h>
int main()
{
    //库函数
	printf("hello printf\n");      //打印结果到显示器上
	fputs("hello fputs\n", stdout);//向标准输入流写入
    //系统调用
	write(1, "hello write\n", 12); //向1号文件写入
	fork();
	return 0;
}

         运行由以上代码生成的程序后,printf()、fputs()、write() 都成功将预期内容输出到了显示器上:

        但如果将程序的结果重定向到一个 test.txt 文件后,文件中的内容与程序运行的结果却有所不同,write()如预期一样正常写入到显示器文件了一次,而 printf()、fputs() 分别打印了两次:

        为什么库函数打印的结果,重定向到文件后就变成了两份,而系统接口打印的结果还和原来一样?

        要打印数据被写入的时候,并不是直接存到显示器文件中的,而是先存在缓冲区中的。

        在C语言中,要将数据打印到显示器,一般会以行缓冲的方式(这是因为代码中有换行符 \n)将数据从缓冲区刷新到显示器文件中,从而打印在显示器上。而将数据重定向到一个文件的操作,使数据的去向从显示器文件变成了磁盘文件,换句话说,数据的刷新策略从行缓冲变为了全缓冲

        在以上代码中,C语言的库函数 printf() 和 fputs() 所打印的数据都会先存到C语言自带的缓冲区中。在代码的下文,又调用了fork()创建了一个子进程,此时由于进程间的独立性,会发生写时拷贝(因为父子进程起先是共享一份父进程的数据,之后不论谁要刷新缓冲区,都会涉及数据的修改),于是,缓冲区中的数据就从一份变成了两份(一份父进程的,一份子进程的)。所以,将程序的结果重定向到 test.txt 文件中后,printf() 和 puts() 就打印了两份数据。而write() 是系统调用,是直接将数据写入到显示器文件的,相当于没有缓冲区,因此,write() 还是只打印一份数据。

【Tips】C语言缓冲区的刷新方式

  1. 无缓冲(直接刷新;fflush(stdout))
  2. 行缓冲(换行就刷新;换行符 \n)——常见于对显示器进行刷新数据
  3. 全缓冲(等缓冲区满了再刷新;输出重定向>)——常见于对磁盘文件写入数据

2.操作系统的缓冲区 

        操作系统也是有缓冲区的。

        在用户写入数据的过程中,这些被用户正在写入数据的数据并不是直接存到磁盘或显示器中的,而先被存到了用户缓冲区中。而当用户刷新用户缓冲区时,其实也不会将用户缓冲区的数据直接存入磁盘或显示器中,而是先将数据存到操作系统的缓冲区中,最终由操作系统将这些数据存入磁盘或显示器。至于是以上面方式刷新缓冲区,操作系统有自己的刷新机制,用户不必关心。

四、文件系统

1.inode

        根据文件所在的位置,文件可以被分为磁盘文件和内存文件,日常称呼的文件都指的是磁盘文件。磁盘文件由两部分构成,分别是文件内容和文件属性,文件内容就是文件当中存储的数据,文件属性或称文件的元信息,例如文件名、文件大小、文件创建时间等。

        指令 ll (ls -l)可以查看当前目录下文件的各种元信息:

        在Linux下,文件的属性和内容虽然都存储在磁盘中,但它们其实是分离存储的。用于保存文件各种属性的集合的结构被称之为 inode 结构,由于系统当中可能存在大量的文件,因此为了便于管理,每个文件的属性集合都有一个唯一的 inode号。

        指令 ls -i,可显示当前目录下各文件的 inode 号:

2.磁盘

        磁盘是一种永久性存储介质,与之相对应的是内存,内存是一种掉电易失存储介质。在计算机中,磁盘几乎是唯一的机械设备,主要用于存储所有的普通文件;在冯诺依曼体系结构中,磁盘既可以充当输入设备,又可以充当输出设备。

        在当代,文件一般都存储在硬盘中。普通人的计算机通常搭载的是固态硬盘(SSD),而企业为了降低存储大型数据的成本,一般使用的是机械硬盘,也就是磁盘。

2.1-磁盘的结构

        磁盘的结构主要有盘面、磁头、主轴等。其中,盘面是存储数据的主力,磁头是读取数据的主力。

        每一个盘面都由多个磁道构成,而一个磁道又有多个扇区构成,所以,磁盘可以看成是由无数个扇区构成的存储介质。磁盘被访问的基本单元是扇区,可存储的大小一般为 512 字节(有的是 4KB)。要修改磁盘中 1 字节的数据,就需要把这 1 字节所在扇区的数据都加载到内存中。要把数据存储到磁盘,就需要定位一个扇区:

  1. 先定位一个盘面,其实也就是确定一个磁头,因为磁头在盘面上,每一个磁头都对应一个盘面;
  2. 接下来在这个盘面上定位一个磁道;
  3. 最后在这个磁道中定位一个扇区。

        所有的磁头都是同步运动的,在某一时刻,从从上向下看去,以磁头所在点为半径的不同盘面上的磁道就会形成一个叫做柱面的结构。磁头的运动由硬件电路进行控制,运动的目的是去定位磁道。盘面旋转的目的是去定位扇区。

        磁盘的读取效率取决于磁头、盘面的运动速度和运动次数,总得来说,运动越少,效率越高;运动越多,效率越低。因此,在软件设计上要求设计者一定要有意识的将相关数据放在一起。

【Tips】磁盘的结构

  • 盘面:用于存储文件信息,二进制信号,一般有多个磁盘。
  • 磁头:用于寻找文件,其与磁盘的距离很小,一般有多个磁头,与磁盘对应。
  • 主轴:方便磁头进行定位。

【Tips】磁盘读写的一般流程

  1. 确定待读写信息在磁盘的哪个盘面;
  2. 确定待读写信息在磁盘的哪个磁道;
  3. 确定待读写信息在磁盘的哪个扇区。

【Tips】磁盘的特点

  • 磁盘与磁头一一对应,且不接触,通过空气与电进行写入信息。
  • 造价低,寿命长。
  • 制作环境要求高。
  • 马达与磁盘转动的噪音可能较大。

2.2-分区与格式化

        由于磁盘是由无数个扇区构成的,因此磁盘本身可以被抽象成一个线性的存储介质,而这个线性的存储介质可以看作是一个一维数组,每个扇区都可以看作是一维数组中的一个元素,每一个元素都有一个对应的下标来对它进行唯一地标识。

        如此,就可以方便地从逻辑上对磁盘进行分区,从而更好地管理磁盘。

        磁盘分区是通过分区编辑器在磁盘上划分的几个逻辑部分。每个盘面一旦有了分区,就可以对文件进行更细致的管理,使不同的目录与文件按其属性或其他标准存储进相应的分区。

        磁盘分区最常见的例子就是,Windows下的C盘、D盘等。

        当磁盘完成分区后,就可以对磁盘进行格式化(初始化),以便对分区后的各个区域写入相应的管理信息。

        写入的管理信息的具体内容由文件系统来决定,不同的文件系统在格式化时写入的管理信息是不同的。常见的文件系统有EXT2、EXT3、XFS、NTFS等。

3.EXT2文件系统

        EXT2 是Linux下的一个磁盘文件系统。

        EXT2 的每一个磁盘分区,头部都会包含一个启动块(Boot Block),其余区域是根据分区大小来划分的一个个的块组(Block Group)。其中,启动块的大小是一定的,而块组的大小是在格式化时确定的,一旦确定不可以更改。

        每个组块都有着相同的组成结构,都由超级块(Super Block)、块组描述符表(Group Descriptor Table)、块位图(Block Bitmap)、inode位图(inode Bitmap)、inode表(inode Table)以及数据表(Data Block)组成。

【Tips】组块的组成结构

  • 超级块(Super Block): 存放文件系统本身的结构信息。记录的信息主要有:Data Block和inode的总量、未使用的 Data Block 和 inode 的数量、一个 Data Block 和 inode 的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block 的信息被破坏,可以说整个文件系统结构就被破坏了,但其他块组中可能会存在冗余的 Super Block,当某一块组的 Super Block 被破坏,还可以通过其他块组的 Super Block 来恢复。
  • 块组描述符表(Group Descriptor Table): 描述该分区当中块组的属性信息。
  • 块位图(Block Bitmap): 记录着 Data Block 中哪个数据块已经被占用,哪个数据块没有被占用。
  • inode 位图(inode Bitmap):记录着每个 inode 号是否空闲可用。
  • inode 表(inode Table): 存放每个文件的 inode 结构, inode 结构中是文件的属性信息。
  • 数据表(Data Block): 存放每个文件内容。

【补】文件系统的运作细节

 (1)一个空文件是怎么被创建的?

  1. 遍历 inode 位图,找到一个空闲的 inode号。
  2. 在 inode 表中找出对应的 inode 结构,然后将新文件的属性信息写入这个 inode 结构中。
  3. 将这个新文件的文件名和 inode 指针添加到它所属目录的数据块中。
  4. 【ps】每个文件的文件名并没有存储在自己的 inode 结构中,而是存储在它所属的目录文件的数据块中,这是因为操作系统只关注 inode 号而不关注文件名。文件名和文件的inode 指针存储在它所属的目录文件的数据块中, 只需在目录下通过文件的 inode 号即可找到文件的文件名、文件属性和文件内容。

(2)文件是怎么写入信息的?

  1. 通过文件的 inode 号,在 inode 表中找到对应的 inode 结构;
  2. 通过 inode 结构找到存储这个文件内容的数据块,并将数据写入数据块中;
  3. 若数据块还未申请(意味着这是个空文件),或数据块已被写满,则遍历块位图,找出一个空闲的块号,然后在数据区中找到这个空闲的数据块,再将数据写入,最终建立数据块和 inode 结构的映射。

(3)如何删除一个文件?

  1. 将这个文件的 inode 号在 inode 位图中置为无效;
  2. 将这个文件已申请的数据块在块位图中置为无效。

(4)为什么一般拷贝文件很慢,但删除文件很快?

        拷贝一个文件,通常是要拷贝文件的数据,要先创建一个新文件,再对这个文件通过写入操作来拷贝源文件的数据。这个过程需要先申请 inode 号和在 inode 结构中文件的属性信息,再申请数据块号,然后才能进行文件内容的数据拷贝。

        删除一个文件,并不是要将文件真正地从磁盘上抹除,只需将这个文件的 inode 号和数据块号置为无效即可。

        相比之下,拷贝文件的步骤更繁琐,删除文件的步骤更简单,所以拷贝文件很慢,删除文件很快。

(5)目录

        目录也是文件,也有自己的属性信息和自己的内容。

        目录的属性信息,如目录的大小、目录的拥有者等,也存储在目录的 inode 结构中;目录的内容就是目录下的文件名和它们的 inode 指针,也存储在目录的数据块中。

4.软硬链接

4.1-软链接

        软链接或称符号链接,类似于 window 下的快捷方式,可以让用户快速链接到目标文件或目录。软链接文件可以通过源文件名,找到源文件的数据。

        软链接文件与源文件的 inode 号是不同的,所拥有的权限也是不相同的。对于源文件来说,软链接文件是一个独立的文件,拥有自己的 inode 号,只包含了源文件的路径名,所以,软链接文件要比源文件小得多。当源文件被删除后,软链接文件不能独立存在,虽然它的文件名仍会保留,但不能执行或查看软链接的内容了。

        以下指令可以创建一个软链接文件:

ln -s 源文件名 软链接文件名

4.2-硬链接

        硬链接文件就是源文件的一个别名,可以使一个文件名无论在不在同一个目录下,这个文件都能被修改甚至被同时修改,只要通过其中一个文件名修改了文件,所有与这个文件存在硬链接的文件都会被一起修改。

        通过源文件的 inode 值,产生一个新的文件名而非新的文件,相当于源文件取了一个别名,这个别名文件和源文件都拥有相同的 inode号。

        一个源文件有多少个相关的文件名,这个源文件的硬链接数就是多少。当硬链接的源文件被删除后,硬链接文件仍能正常执行和查看,只是这个文件的链接数会减 1 。

        以下指令可以创建一个硬链接文件:

ln  源文件名 硬链接文件名

【ps】目录文件不能进行硬链接!如果一个目录可以进行硬链接,进入这个目录时,同时也进入了硬链接的目录,由于目录是树形结构,目录的查找是递归操作,因此一旦在这个有硬链接的目录下查找文件,就会陷入递归死循环。

【补】为什么新创建的目录的硬链接数是 2 ?

        创建一个新的普通文件,它的硬链接数一定是 1 ,这很好理解,因为这个新创建的普通文件目前只有一个文件名。但为什么创建一个新的目录,这个新的目录的硬链接数是 2 呢?

        这是因为,一个目录文件在创建后,在这个目录下会默认创建两个隐含文件 . .. ,它们分别代表当前目录和上级目录。而当前目录 . 其实就是这个新创建的目录本身,它们的 inode 号也是相同的,也就是说,存在两个文件名指向这同一个目录文件,于是新创建的目录的硬链接数就是 2 了。

【Tips】在一个目录下,相邻子目录数 = 这个目录的硬链接数 - 2 。

4.3-软链接与硬链接的区别

  1. 指令 ln -s 创建软链接,指令 ln 创建硬链接。
  2. 目录不能创建硬链接,且不能跨分区系统创建。
  3. 软链接支持文件和目录,且可以跨分区系统创建。
  4. 硬链接文件与源文件的 inode 相同,软链接与源文件的 inode 不同。
  5. 删除软链接文件和删除硬链接文件,都对源文件没有任何影响。
  6. 删除源文件,软链接会失效,但硬链接没有影响。
  7. 删除源文件和源文件的硬链接,这个文件就会被真正删除。

【补】删除链接的指令:

unlink 链接文件名

补.文件从磁盘加载到内存

        由于磁盘读取数据的速度较慢,而内存读取数据的速度要远快于磁盘,因此访问文件时一般会先将文件从磁盘加载到内存中。

        内存与磁盘之间的数据交换,一般默认以 4KB(大小可更改) 为单位进行。其中,内存中一个 4KB 大小的空间叫做页框,在 4KB 空间中填入的内容叫做页帧。

【Tips】文件默认以 4KB 为单位,从磁盘加载到内存

  1. 这样可以减少 IO (CPU 访问外设)的次数。一次访问 4KB 和分四次访问 1KB 相比,显然前者效率更高。按前者的方案, CPU 只访问了一次磁盘,磁头和盘面只需要进行一次定位就可以读取出 4KB 的内容;而后者的方案, CPU 要访问四次磁盘,如果这四次访问并不连续,那么磁头和盘面就得分别定位四次,产生大量的机械运动,既影响效率也影响硬件寿命。
  2. 默认以 4KB 为单位,也是由于局部性原理的预加载机制。即使当前 CPU 只需要访问100 字节的内容,但磁盘上的数据还是以 4KB 为单位加载到内存。这也可以支持, CPU 在访问磁盘中的代码和数据时,接下来也有较大可能访问附近页框的代码和数据。
  3. 4KB 是相关科学家经过大量实验,从而确定的一个较为合理的值。

【Tips】页框与页帧

  1. 页框(Page Frame):通常指的是在内存管理单元(MMU)中用于存储页面表的一组连续条目,包含了用于地址转换的页表项,以便将虚拟地址映射到物理地址上的对应页帧。
  2. 页帧(Page Frame):是物理内存(RAM)中的一个固定大小的区域,用于存储从辅存(如硬盘)中调入的页面内容,操作系统使用页帧来管理物理内存,将页面映射到这些页帧上。
  3. 页框负责虚拟地址与物理地址的映射。,页帧负责管理从硬盘加载的内容

【Tips】文件页缓冲区

        在磁盘上,文件的属性用 inode 结构来存储,文件的内容用 block 数据块来存储;而在内核中,文件属性用 struct inode 来存储,文件的内容文件页缓冲区来存储。

        在每个文件的 struct file 结构中,有一个指向 struct address_space 结构体的指针, 而在 struct address_space 结构体中,有一个 struct radix_tree_root 结构体,它本质上是一个树状结构,树中的每个节点都是 struct radix_tree_node 类型,在该类型中,又有一个名为 slots 的 void * 类型的指针数组,存储了 struct page 结构体对象的地址。

        总而言之,在每个文件的 struct file 结构体中都有一个指向某块物理内存的struct page结构体,而 struct page 结构体中封装的一块物理内存,就叫文件页缓冲区。

五、动静态库

        如果想要别人也使用自己写的代码,一般有两种方案——第一种方案就是,将自己的源代码直接拷贝一份给别人使用,但拷贝的工作可能很繁琐,在自己的代码中也可能有不想让别人知道细节;第二种方案就是,将自己的的源代码打包成库,将打包的库和相应的头文件提供给别人使用。而由源代码打包成的库,又可以分为静态库和动态库。

(关于动静态库的基本介绍和功能,详见:【Linux入门】基础开发工具-CSDN博客

1.静态库的制作

1.1-打包

        为了方便演示,此处以简单的加法和减法函数为例,并引入下面四个文件:

//add.h
#pragma once

extern int my_add(int x, int y);
//add.c
#include "add.h"

int my_add(int x, int y)
{
	return x + y;
}
//sub.h
#pragma once

extern int my_sub(int x, int y);
//sub.c
#include "sub.h"

int my_sub(int x, int y)
{
	return x - y;
}

  • 方案一:使用指令打包

        step1:先让所有源文件生成对应的目标文件

        step2:使用以下指令,将所有目标文件打包成文件后缀为 .a 的静态库:

ar -rc 静态库名 目标文件名1 目标文件名2 ...

//【ps】ar命令是gnu的归档工具,常用于将目标文件打包为静态库
//参数 -r(replace):若静态库文件当中的目标文件有更新,则用新的目标文件替换旧的目标文件。
//参数 -c(create):建立静态库文件。
//参数 -t:列出静态库中的文件。
//参数 -v:显示文件的详细信息。

        step3:将源文件相应的头文件与刚生成的静态库组织起来,具体的方式是,将两者放到同一个目录下。

  • 方案二:通过 make 和 Makefile 一键打包 

        将方案一的指令全部写到 Makefile 文件中,通过 make 一键打包。

        step1:编辑 Makefile 文件。

        step2:通过指令 make 一键生成目标文件和相应的静态库。

        step3:通过指令 make output 一键组织头文件和静态库。

1.2-使用

        由于源文件和库在进行链接时,库必须是能够找到的,因此在使用自己打包的库时,最好指定库的路径或将库拷贝到系统路径下,以防链接失败。 

        为了方便演示,此处以使用上文的加法函数和减法函数为例,引入以下代码:

//main.c
#include <stdio.h>
#include <add.h>
#include <sub.h>
int main()
{
	int a = 20;
	int b = 10;
	int c = my_add(a, b);
	int d = my_sub(a, b);
	printf("%d + %d = %d\n", a, b, c);
	printf("%d - %d = %d\n", a, b, d);

	return 0;
}
//【ps】包含头文件的方式:
//     <> :表示到系统路径下去查找头文件
//     "" :表示在当前源文件的统计目录下查找头文件,找到了就用,没找到就去系统路径下找
  • 方案一:通过 gcc 编译器的参数指定要使用的库

        输入以下指令,可指定要链接的库:

gcc 源文件名 -I 头文件的路径 -L 库文件的路径 -l 库文件路径下的库名

//参数 -I:指定头文件搜索路径。
//参数 -L:指定库文件搜索路径。
//参数 -l:指明需要链接库文件路径下的哪一个库。注:库真实的名字为,去掉后缀.a 与前缀 lib之后

  • 方案二:将头文件和库文件拷贝到系统路径下 

        输入以下指令,可将头文件和库文件拷贝到系统路径下 :

sudo cp 头文件的路径/*  /usr/include/
sudo cp 库文件的路径  /lib64/

 【Tips】库的安装,其实就是把头文件和库文件拷贝到系统路径下。

2.动态库的制作

2.1-打包

        此处仍以上文中演示静态库的代码为例:

//add.h
#pragma once

extern int my_add(int x, int y);
//add.c
#include "add.h"

int my_add(int x, int y)
{
	return x + y;
}
//sub.h
#pragma once

extern int my_sub(int x, int y);
//sub.c
#include "sub.h"

int my_sub(int x, int y)
{
	return x - y;
}
  • 方案一:使用指令打包

        step1:使用 gcc 编译器的参数 -fPIC ,将所有源文件生成对应的目标文件

        【ps】参数 -fPIC(position independent code):产生位置无关码。

  1. -fPIC作用于编译阶段,告诉编译器产生与位置无关的代码,此时产生的代码中没有绝对地址,全部都使用相对地址,从而代码可以被加载器加载到内存的任意位置都可以正确的执行。这正是共享库所要求的,共享库被加载时,在内存的位置不是固定的。
  2. 如果不加-fPIC选项,则加载 .so 动态库文件的代码段时,代码段引用的数据对象需要重定位,重定位会修改代码段的内容,这就造成每个使用这个.so文件代码段的进程在内核里都会生成这个 .so 文件代码段的拷贝,并且每个拷贝都不一样,取决于这个 .so文件代码段和数据段内存映射的位置。
  3. 不加-fPIC编译出来的 .so 是要在加载时根据加载到的位置再次重定位的,因为它里面的代码BBS位置无关代码。如果该.so文件被多个应用程序共同使用,那么它们必须每个程序维护一份.so的代码副本(因为.so被每个程序加载的位置都不同,显然这些重定位后的代码也不同,当然不能共享)。
  4. 我们总是用 -fPIC 来生成 .so 动态库文件,但从来不用 -fPIC 来生成 .a 静态库文件。但 .so 一样可以不用-fPIC选项进行编译,只是这样的.so必须要在加载到用户程序的地址空间时重定向所有表目。

        step2:使用 gcc 编译器的参数 -shared ,将刚生成的所有目标文件打包成以 .so 为后缀的动态库:

gcc -shared 动态库文件名 目标文件名1 目标文件名2 ...

        step3:将源文件相应的头文件与刚生成的动态库组织起来,具体的方式是,将两者放到同一个目录下。

  • 方案二:通过 make 和 Makefile 一键打包 

        将方案一的指令全部写到 Makefile 文件中,通过 make 一键打包。

        step1:编辑 Makefile 文件。

        step2:通过指令 make 一键生成目标文件和相应的静态库。

        step3:通过指令 make output 一键组织头文件和静态库。

2.2-使用

      此处仍以上文中演示静态库的代码为例:


//main.c
#include <stdio.h>
#include <add.h>
#include <sub.h>
int main()
{
	int a = 20;
	int b = 10;
	int c = my_add(a, b);
	int d = my_sub(a, b);
	printf("%d + %d = %d\n", a, b, c);
	printf("%d - %d = %d\n", a, b, d);

	return 0;
}
//【ps】包含头文件的方式:
//     <> :表示到系统路径下去查找头文件
//     "" :表示在当前源文件的统计目录下查找头文件,找到了就用,没找到就去系统路径下找
  • 方案一:拷贝 .so 动态库文件到系统共享库路径下 
sudo cp 动态库文件的路径  /lib64

  • 方案二:更改环境变量 LD_LIBRARY_PATH

        环境变量 LD_LIBRARY_PATH 中有程序链接动态库时所要搜索的路径,只需将自己的动态库路径添加到 LD_LIBRARY_PATH 中,即可链接自己的动态库。

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH: 动态库文件所在的目录

  • 方案三:配置 /etc/ld.so.conf.d/

        /etc/ld.so.conf.d/ 路径下存放的全部都是以 .conf 为后缀的配置文件,这些配置文件中存放的都是路径,操作系统会自动在 /etc/ld.so.conf.d/ 下找所有配置文件中的路径,以链接程序所需要的库,所以,只需将自己的动态库路径添加 /etc/ld.so.conf.d/ 下,即可链接自己的动态库。

        step1:将动态库文件所在的目录路径,存入一个以 .conf 为后缀的文件中。

        step2:将含有动态库文件目录路径的 .conf 文件拷贝到 /etc/ld.so.conf.d/ 目录下。

        step3:使用指令 ldconfig 更新配置文件。之后就可以正常链接动态库、正常运行程序了。

 

补- 动态库如何被多个程序共享

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值