linux_api之文件操作 - 紫色年华

时间:2024-03-11 20:30:33

linux_api之文件操作

本篇索引:

1、引言

2、文件描述符

3open函数

4close函数

5read函数

6write函数

7lseek函数

8i/o效率问题

9、内核用以维护打开文件的相关数据结构

10O_APPEND标志

11dup函数(文件描述符重定位函数)

12、有关文件共享的问题

13fcntl函数

14ioctl函数

 

 

 

 

1、引言

1.1、文件io这个词的含义

实现对文件的数据输入(input)和输出(output),所以简称为文件io

1.2、什么需要文件io

程序的目的是为了处理信息,而信息在计算机中的表现形式就是数据,所以程序就是为了处理数据并输出数据,而所有的数据几乎都与文件有关(linux下一切皆是文件),所以程序就必须实现对文件的读写。

对于有OS的计算机来说,应用程序是无法直接读写文件的(隔离保护作用),文件基本是靠下层的机制,必须经过OS才能访问,所以我们就必须学习linux专门提供给应用程序,让其实现文件io操作的系统调用函数,利用这些专门的文件io接口实现对文件的访问。

1.3、文件io函数

常见的文件io函数有openreadwritecloselseek这五个,我们经过第二篇的学习,已经知道,相对于标准io来说文件io常被称为不带缓存的io。在前面我们也说过,这几个系统调用不是ANSI C的组成部分,但是这几个系统函数的函数原型确是ANSI C提供的。

1.4、原子操作

当多个进程共享同一资源,就比如当多个进程都想对同一文件操作,那么原子操作的概念是非常重要的。比如,A进程写xxx文件时,如果某个条件未发生,那么B进程无论如何都不能写该文件,直到A进程一直写到该条件发生,才轮到B进程写。B进程写时也是如此,这样就避免了AB之间互相串改对方写入文件的数据的可能,本片会通过一个O_APPEND标志给大家引入原子操作的概念,,后续课程我们还会再次接触到。

2、文件描述符

2.1、文件指针和文件描述符

我们学习标准io时知道,标准io实现对文件读写操作时,用的是文件指针FILE*fp。在第二篇中我们也说了,如果是在linux OS下,标准io向下继续调用时,实际调用的还是文件io,而文件IO则使用文件描述符来实现对文件的操作,该文件描述符就存在了文件指针fp指向的结构体中。

2.2、什么是文件描述符

每成功打开(打开文件用文件路径)一个文件,内核都会返回一个非负的整数,readwrite时就用此整数进行操作,该整数一般都是在调用opencreate函数时返回的,这个整数就是文件描述符。

linux下,一般来说每个进程可以使用的文件描述符都是在0~1023之间,总共1024个可用文件描述符,当然上限值是可以更改的。其中0与标准输入、1与标准输出、2与出错输出结合在了一起,因为系统启动时按顺序打开了标准输入,输出,出错输出三个文件,所以012也与这三个文件顺序的结合在了一起,之后每个运行的进程都将继承这三个文件描述符,所以之后的每个进程不必再次打开这三个文件,就可以直接使用。这就是为什么scanfprintf函数能够直接被使用的原因了。

这三个文件描述符对应STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO这三个宏,分别定义在了<unistd.h>中,鼓励使用宏而不是直接使用012数字,目的是为了提高程序的可辨识度和跨平台操作性。

3open函数

3.1、函数原型和所需头文件

 #include <sys/types.h>

 #include <sys/stat.h>

 #include <fcntl.h>

 int open(const char *pathname, int flags);

 int open(const char *pathname, int flags, mode_t mode);

3.2、函数功能

int open(const char *pathname, int flags);按照flags的要求打开已存在的文件,如果文件不存在则报错。

int open(const char *pathname, int flags, mode_t mode);如果文件已经存在就直接按照flags的要求打开文件。如 果文件事先不存在,则按照第三个参数的权限要求创建一个新的文件,然后再按照flags要求打开文件。

3.3、参数说明

3.3.1、第一个参数:const char *pathname

文件路径。

3.3.2、第二个参数:int flags,打开文件的方式

flags由如下宏选项中的一个或多个,通过|运算组成。

1)、O_RDONLY:只读方式打开文件     

2)、O_WRONLY:只写方式打开文件

3)、O_RDWR:可读可写方式打开文件

以上这三个只能指定其中一个,不可组合。

4)、O_APPEND:每次在文件末尾追加信息,此选项很重要,后面会详说。

5)、O_ASYNC:异步标志,后面学习异步通知时将会用到。

6)、O_CLOEXEC:如果该进程exec新程序后,打开的文件将自动关闭,该描述符与该文件的结合无效。

在后面学习进程控制时,我们会对此进行举例说明。

7)、O_CREAT:指定了该标志后,如果文件不存在则创建一个该名字的文件,但这需要用到第三个参数

来指定新创建文件的原始权限。

8)、O_EXCL指定这个标志时,O_CREAT必须也被指定,这两个标志联合使用可实文件存在则报错 的功能。因为有时我们就是需要创建出一个不与现存任何文件同名的新文件,如果发现该文件是一 个已经存在的文件我们必须报错,然后重新命名创建文件。使用O_EXCL就能实现这样的功能,否

则的话,这个已经存在的文件会被直接打开并使用,这与我们的愿望相违背。

9)、O_TRUNC:打开时将文件内容全部清零空

10)、O_NOCTTY:如果打开的文件是一个终端备文件的话,不要将此终端指定为控制终端,比如以后我 们涉及打开一个串口的时候,就会用到这个标志。

11)、O_NONBLOCK:这个只能用在字符类设备或网络设备文件上,对于磁盘上的普通文件是无效的。打开字符类设备文件时,默认就是以阻塞方式打开的,但是我们可以将其改为非阻塞的。大家知道getcharscanf等函数实现键盘输入时会导致阻塞,就是因为这个原因。阻塞与非阻塞有时是由文件类型决定的,因为同一个函数操作A文件时是阻塞,但是操作B文件时却是非阻塞的。但是有时又是由函数本身的特性决定的,导致阻塞的原因要视情况而定,后面讲信号时我们将详细讨论此问题。

12)、O_SYNC:同步标志,write系统调用会一直等到,直到物理设备读写完毕后才会返回到应用层。如果不指定的话,write只需要将内容写到内核缓存中后就立即返回,剩下的事情就由内核定时将内核缓存中的数据分批写到物理设备上。

3.4、返回值

函数调用成功返回进程描述符集合中(0~1023)当前最小且未用的描述符,失败返回-1,并设置errno

3.5、简单用例

3.5.1、打开已有文件

vitouch出一个名叫“file”的文件。

int main(void){        
int fd = -1; 
        fd = open("file", O_RDWR);
        if(fd < 0)
        {           perror("open is fail");
                exit(-1); 
          }   
        return 0;
}

3.5.2、如果文件存在则直接打开,不存在则新建一个该名字的文件,然后再打开

int main(void){
int fd = -1; 
        fd = open("file", O_RDWR|O_CREAT, 0664);//指定文件的权限
        if(fd < 0)
        {           perror("open is fail");
                exit(-1);  
}   
        return 0;
} 

3.5.2、如果存在报错

 

int main(void)
{
int fd = -1; 
        fd = open("file", O_RDWR|O_CREATE|O_EXCL, 0664);
        if(fd < 0)
        {   
                perror("open is fail");
                exit(-1);
        }   

        return 0;
} 

我们可以在出错处理中重新创建一个名叫“file1”的文件,如该名字的文件也已经有了,那就再换一个名字,直到找到一个不冲突的名字为止。

理解O_EXEC标志对于我们理解一些其它系统调用的类似的xxx_EXEC标志是很有帮助的,因为基本思想是一致的。

3.6、注意点

打开文件时,打开方式必须符合文件创建时文件的创建权限。换句话说,创建文件时的权限不允许写,你却想要以写方式打开,这会导致函数调用失败,这里说的很笼统,下章会对此做详解。

4close函数

4.1、函数原型和头文件

#include <unistd.h>

#int close(int fd);   

4.2、函数功能说明

关闭打开的文件。

4.3、函数参数

int fd:文件描述符

4.4、返回值

调用成功返回0,失败返回-1errno被设置

4.5、测试用例:略

4.6、注意点

Close函数可不必显示调用,因为程序正常结束时会隐式的调用该函数,记住这里说的是程序正常结束,后面讲进程控制时会告诉大家什么是程序异常结束,这里只须简单记住,exitmain函数中return都可以正常退出。

5read函数

5.1、函数原型和头文件

#include <unistd.h>

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

5.2、函数功能说明

以字节单位,按块(一块包含很多字节)读取文件中的数据到用户缓存。

5.3、函数参数

5.3.1、第一个参数int fd文件描述符

5.3.2、第二个参数void *buf用户缓存

5.3.3、第三个参数size_t count指定读取一次的块大小,换句话说一块包含count字节

5.4、返回值

调用成功返回read函数实际读取到的字节数,如果失败,返回-1,并且errro被设置

5.5、测试用例:略

5.6、注意点

如果read调用成功,则返回实际读取的到字节数,0=<该字节数<=count,当读到文件末尾时,读取到的字节数很有可能实际小于count的要求,这是很正常的,如果返回0代表已经读取到文件末尾。

linux并不区分文本二进制和纯二进制,read函数都以字节为单位读取,至于拿到这些数据后,如何处理或解释那就是应用程序所要做的事情了,read并不关心读到的是文本二进制还是纯二进制。

6write函数

6.1、函数原型和头文件      

#include <unistd.h>

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

6.2、函数功能说明

以字节单位,按块将用户数据写入文件中。

6.3、函数参数

5.3.1、第一个参数int fd文件描述符

5.3.2、第二个参数void *buf用户缓存或直接用户数据

5.3.3、第三个参数size_t count指定写入一次块的大小(以字节为单位计算)

6.4、返回值

调用成功,返回write函数实际成功写入的字节数,如果返回0表示无数据写入文件。如果失败,返回-1并且errro被设置。

6.5、测试用例:略

6.6、注意点:暂无

7lseek函数

7.1、函数原型和头文件

#include <sys/types.h>

#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);

7.2、函数功能说明

定位对文件的操作位置,就像在纸上写字时调动笔尖到某处一样。功能同标准io中的fseek函数,可认为fseek函数就是对lseek函数做的一个封装。其实标准io中的fseekftell函数向下调用的都是是lseek,因为这个函数兼具这两个方面的功能,即能调动对文件的操作位置,又能返回文件的当前位移量。

7.3、函数参数

7.3.1、第一个参数:int fd

文件描述符

7.3.2、第二个参数:off_t offset

精确定位,负数代表从现在的位置向前移动offset字节,正数代表从当前位置向后移动offset字节,当对文件的操作位置定位在了文件的起始位置时,那么再向前移动的话没有意义,函数会报错返回。

7.3.3、第三个参数:int whence

粗定位,选项有SEEK_SET:调到文件起始位置,SEEK_CUR:调到文件当前位置,SEEK_END:调到文件末尾的位置。

7.4、返回值

成功,返回文件的当前位移量,失败返回-1errno被设置。

7.5、测试用例: 略

7.6、注意点

此函数只能对普通文件进行操作,不能对字符设备,管道等其它文件操作,因为这些文件在磁盘上只有属性信息,并没有真实的数据存放,lseek定位毫无意义。

Lseek可以用来实现空洞文件,但是lseek的调动后,必须使用write函数向文件里写点数据,该调动结果才能被记录下来。

一般来说按照正常情况打开一个文件时,文件的“当前位移量”为0(笔尖放在了文件在开始的位置),读写数据时从文件的最开始处进行,但是如果我们打开指定了O_APPEND标志的话,笔尖回调到文件的末尾,当前文件位移量为文件长度,这一点我们需要注意。

7.7、函数一些特殊用法

lseek函数可以用来构建空动文件,空洞文件一种很有用的文件,这可实现多线程并发地同时向文件不同区域写数据。先看lseek如何被用来构建空洞文件的。

int main(void)
{                    
        int fd = -1; 
        off_t f_len = -1; 
            
        fd = open("file", O_CREAT|O_RDWR, 0664) ;
        if(fd < 0)
        {   
                perror("open is fail");
                exit(-1);
        }    
                    
        /* 返回新打开文件的文件长度,其结果肯定时0 */
        f_len = lseek(fd, 0, SEEK_END);
        printf("f_len = %d\n", f_len);

        /* 文件指针向后移动十个字节 */
        f_len = lseek(fd, 10, SEEK_SET);
        printf("f_len = %d\n", f_len);  
        /* lseek只是调动文件操作位置,不会去修改文件,需要人为的调
         * 用write函数去修改下文件,否则无法固定下lseek的修改结果 */
        write(fd, "a", 1);    
            
        return 0;
}   

查看文件大小

[linux@localhost 1402]$ ls -al file 

-rw-rw-r--. 1 linux linux 11 Apr 11 15:04 file

查看文件内容

[linux@localhost 1402]$ od file -c

0000000  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0   a

空洞文件中,前面空的部分为0

8i/o效率问题

先看下面的例子。

#definen BUFSIZE m; //m是正整数

char buf(BUFSIZE);

read(fd, buf, sizeof(buf));

如果read函数读1000000字节的文件,每次读出的内容都会先复制到用户缓存buf中。实际上这一过程存在着一个效率问题,那就是如果用户缓存过小会导致读文件的效率很低。打个比方,如果把一盘满满的花生米从一个地方运到另一个地方,但是我每次只允许你运一颗,估计你会觉得十分恶心,如果每次运十颗,每次运送一百颗,相对会好很多,当然一次就将整盘端过去那是最好的。

所以read读数据就类似于将底层的花生米分批次的运送到上层去,每次运送的量由用户缓存的大小决定的,所以用户缓存的大小直接决定着read系统调用的次数的多少,次数越多效率越低。当然缓存也不可能无限制的大,测试表明用户缓存开到8192时,效率基本趋于稳定,再大已经没有任何意义,就类似于千万富翁和亿万富翁的物质生活水平其实是一样的,从满足好的生活需求的角度来说,钱再多已经没有太大意义了。

虽然理论上8192是库缓存的最佳长度,但是我们库缓存的默认长度一般是位5121024的长度,这个长度是内核按块操作的块长度。实际上1024的效率比8192的效率只差一点点而已,但是却省了很多的空间。

其实以上就是为什么在文件io的上面再次封装出一个标准io的原因了,因为它会帮我们选取一个最佳的缓存大小,这个缓存其实就是我们习惯上称呼的库缓存,并且这个缓存还是分类型(无,行,全),根据类型的不同执行不同的缓存刷新操作。

当然我们说标准io还有一个目的,就是为了实现c语言程序能够夸系统的运行,不同OS向下调用不同的文件io

9、内核用以维护打开文件的相关数据结构

为了很好的理解这些结构,我们事先必须清楚如下概念。

9.1、进程表

内核为每个运行的进程生成了一张进程表,这张表很重要,因为其中记录了所有与进程相关的信息,内核需要利用它来管理进程。

内核中的表要么用结构体,要么用数组,要么就是用链表来实现,这里的进程表就是一个名叫task_struct的结构体,它是我们linux内核中最大的结构体,大概有240多个成员项。

9.2、文件描述符表

我们可以简单的认为,这张表直接存在了进程表中。该表记录了该进程打开的所有文件的文件描述符信息,

文件描述表中的每一项记录了如下信息。

9.2.1具体使用的文件描述符,我们对它已经很熟了。

9.2.2一个指向文件表的指针。

9.3、文件表

该表记录了被打开文件的状态信息等。

9.3.1文件状态标志(O_RDONLY, O_RDWR, O_NONBLOCK, O_TRUNC, O_APPEND等)

9.3.2当前文件位移量(前面学过lseek函数,它可以改变这个当前位移量)

9.3.3指向v节点(也是一个结构)的指针,描述对打开文件的具体操作信息。

9.4v节点

v节点包含的具体操作文件的信息如下。

9.4.1对各类不同文件的具体操作函数的函数指针。

9.4.2包含了索引节点(i节点:记录文件属性)信息,这些信息是在open一个文件时直接从磁盘的i节点中读出,然后写入v节点中(v数据结构开在了内存中)。将磁盘上的i节点写入内存的目的是为了实现快速使

用(因为读磁盘的速度太慢)。

文件属性包含,文件类型、文件大小、文件所有者、文件的创建权限等等,后面的章节会详细讲解。

9.5、进程表,文件描述符表,文件表,v节点之间的基本关系

 

9.5.1、注意点

a)前面也说过,正常情况下每个进的012三个文件描述符是系统一早就打开好了的,直接使用即可,

b)当前位移量和当前文件长度是两个完全不同的概念,请严格区分。并且它们各自存放的位置是不同的,

因为这对于实现文件共享和原子操作来说是十分重要的,后面会对此进行详细讲解。

9.6、有关v节点的进一步说明

V节点实际上是一个与虚拟文件系统相关的东西,它是virtual的缩写。虚拟文件系统是文件系统分层思想的体现,对于现代计算机而言,分层是一个十分重要的思想,这一概念会在后续的课程中反复接触到。

虚拟文件系统就是对各种文件系统中相同的部分做一个统一的抽象,不种类同的文件系统就拥有了相同的接口,这对于不同种类的文件系统的兼容是异常重要的。

上层操作文件时,调用的都是统一的接口,如openreadwritecloselseek等等,但是真正底层在操作文件时,肯定会根据文件类型的不同,实际对文件进行操作的函数也会不同,那么相同的接口走到下面时具体的操作会发生分流,大致的图示如下,这里以read为例进行说明。

 

 10O_APPEND标志

10.1A_PPEND标志的作用

我们前面学习open函数的时候,说过这个标志,说它的作用是追加,这个描述还不是很准确,准确说是利

用文件长度去修改文件的当前位移量,然后再对做各种操作。图示如下:

 

此标志对于利用多个文件描述符实现对同一文件共享来说很重要,这些描述符是多次调用open函数打开同一文件取得的,而且不管这些open操作是由单进程还是多进程实现的。没有它的话,在利用各个描述符对文件操作时会导致这些操作相互覆盖,特别是对于写操作来说(读没有太大关系),O_APPEND标志显得尤为重要,比如下面的这个例子:

int main(void)
{
        int fd1 = -1, fd2 = -1, ret = -1; 
        fd1 = open("file", O_CREAT|O_RDWR|O_TRUNC, 0664);
        fd2 = open("file", O_CREAT|O_RDWR|O_TRUNC, 0664);
        printf("fd1 = %d, fd2 = %d\n", fd1, fd2); 
        while(1)
{   
                write(fd1, "hello\n", 6); 
                sleep(1);
                write(fd2, "world\n", 6); 
        }   
        return 0;
}

在这个代码中,file文件被打开了两次,所以fd1fd2都结合上了file文件。但是打开文件看到,期望的结果和实际的结果确是不一样的:

1 hello                      1 world
2 world                        2 world
3 hello                      3 world
4 world                      4 hello                      
5 hello      
6 world
7 hello
8 world
9 hello
期望的结果                    实际的结果

导致上面结果的原因很简单,就是world覆盖了hello,最后一个hello没有被覆盖的原因是因为sleep休眠延缓了这一覆盖的发生。想对结果进行分析,依赖于我们对于内核维护打开file的数据结构的理解。

10.1.1、同一进程内,多次open打开同一文件后的情况

上面的例子程序运行起来后就是一个进程。 这个进程里面对file文件打开了两次,fd1fd2都指向了file文件,那么内核维护打开文件的数据结构如下:

 

看到了上面这个图,我想很多同学已经明白导致覆盖的原因了,其实很简单,因为fd1fd2分别指向了两个不同的文件表,所以都拥有自己的文件位移量。开始各自的文件位移量都为0。但是为什么拥有相同的v节点呢,毕竟它们对应的都是同一个文件file,否则就不能说fd1fd2结合上了同一个文件file

比如用fd1写“hello\n”,它的位移量从0增到了6。但是fd2的初始文件位移量也为0,用fd2写时,fd2的初始文件位移量也为0,也从文件的最开始位置写“world\n”,所以”world\n”会覆盖”hello\n”,以后的依次类推,这就是导致覆盖的原因了。

为了解决这个问题,我们打开时加入了O_APPEND标志,修改后的open函数如下,着重看红色部分:

fd1 = open("file", O_CREAT|O_RDWR|O_TRUNC|O_APPEND, 0664);

fd2 = open("file", O_CREAT|O_RDWR|O_TRUNC|O_APPEND, 0664);

运行结果如下:

1 hello
2 world
3 hello
4 world
5 hello
6 world
7 hello
8 world
实现了期望的结果

因为每次操作前必须先用文件长度修改当前位移量。以前面的例子进行说明,在用fd1进行向file文件写 “hello’前,先用文件长度(开始为0)更新fd1的文件位移量(开始也为0),写完“hello\n”后,这样fd1的文件位移量从0变为了6,同时file的文件长度也变为了6,但是fd2的文件位移量却任然是0,但是在指定了O_APPEND标志后,在write前会用文件长度修改fd2的文件位移量,这样fd2的文件位移量变为了6,用fd2写时则会从位移量6开始写起,如此一来就不会导致相互覆盖了。

10.1.1、多个上进程分别open同一文件后进行文件操作的情况

上面讲的都是在一个进程里面的操作,多个进程分别打开同一个文件,多个进程各自对同一个文件操作也涉及到相互覆盖的问题,其实导致的原因与上述情况的原因基本一致,但是维护打开文件的结构却略有不同的,看如下例子,两个程序AB,分别打开同一个文件file,各自都对file进行写。

int main(void)
{
        int fd1 = -1, fd2 = -1, ret = -1;
        ret = fork();
        if(ret > 0) // 第一个进程
        {
                fd1 = open("file", O_CREAT|O_RDWR|O_TRUNC, 0664);
                while(1)
                {
                        write(fd1, "hello\n", 6);
                        sleep(1);
                }
        }
        else if(0 == ret)//第二个进程
        {
                fd2 = open("file", O_CREAT|O_RDWR|O_TRUNC, 0664);
                while(1)
                {
                        sleep(1);
                        write(fd2, "world\n", 6);
                }
        }
        return 0;
}

上面例子涉及fork函数的使用,实际上我们在讲后面多进程时才会涉及到该函数,但是我们这里先接触下也是很有好处的。这个例子会运行两个进程,我们可以简单地认为红色部分代码运行起来为一个进程,蓝色部分代码运行起来为一个进程。这两个进程再各自运行的时间片到了后会相互切换(简单认为就是A的时间片到则B运行,B的时间片到则A运行,直到两个进程运行结束),时间片到可以简单认为就是定时的时间到了,由于时间片非常短,从而实现了两个进程同时向前运行的错觉。

以上两个进程各自都会独立的打开file,这是两个不同的进程,分别使用的是自己的文件描述符,然后分别向文件file里面写东西,为了很好的演示出效果,分别休眠了一秒。但是vi file后的结果也是如下:

1 hello                    1、world
2 world                    2、world
3 hello                    3、hello
4 world
5 hello
6 world
期望的结果                  实际的结果

对于上面结果的分析,也需要借助于内核维护的被打开文件的数据结构,这个结构与之前的类似但却有很大的区别,看下图:

 

 

看得出来上面的和之前的结构很像,每个描述符都指向了各自独立的文件表,但毕竟共享的是同一个文件,所以他们拥有相同的V结点。

但是不同的是,这里毕竟涉及的是两个不同的进程,那么每个进程将会有自己的进程表,从上图中我们其实已经很清楚覆盖的原因了,和之前的原因基本是一致的,虽然结构上略有些区别。

解决的办法仍然是在各自的open函数里是加入O_APPEND标志,着重看红色部分,如下:

fd1 = open("file", O_CREAT|O_RDWR|O_TRUNC|O_APPEND, 0664);

fd2 = open("file", O_CREAT|O_RDWR|O_TRUNC|O_APPEND, 0664);

 

如此一来每个进程写之前都会用共享的文件长度去修改自己的文件位移量,那么自然就会得到我们想要的结果,如下:

1 hello
2 world
3 hello
4 world
5 hello
6 world
实现了期望的结果

10.2O_APPEND一个很重要的特性,原子操作

我们看,不管是同一个进程还是多个进程实现多次open同一个文件,并且进行共享的操作(特别涉及写时,读无所谓),如果不希望出现相互覆盖的情况的话,O_APPEND标志的指定是非常重要的,但是你本来就希望它们之间相互覆盖的话,那么这个不说,一般这种情况很少见就是。

还是以前面的例子来说,O_APPEND作用是将文件位移量修改为文件长度,然后再实现对文件的写操作,其实功能基本等价下面两句话:

lseek(fd1, 0, SEEK_END);

write(fd1, "hello\n", 6);

但是利用O_APPEND标志来实现时,有一个好处,那就是将文件位移量的调动和具体操作(这里是写)组合为一个原子操作,如果这两种方式真的要等价的话,上面的写法需要再做修改,如下:

加锁;

lseek(fd1, 0, SEEK_END);

write(fd1, "hello\n", 6);

解锁;

通过锁机制将lseekwrite操作变成一个原子操作,所谓原子操作,就是这两个操作捆绑在了一起,当执行了lseek操作后,就必须等到write操作完,这两个操作之间是没有缝隙的,所谓原子就意味着不可分割。

所以对于多进程的这种情况,虽然O_APPEND的追加特性基本能够保证相互不覆盖,但是如果没有原子性的话,就不可能完全的避免相互覆盖的情况的发生。假如O_APPEND没有原子性的话,对于上面2个进程的例子就会出现如下情况:

1、进程1:执行lseek,将文件位移量设置为文件长度n

2、从进程1切换到进程2,进程2同样执行lseek,因为共享的是同一个文件,所以进程lseek后的文件位 移量也为文件长度n

3、进程2n的位置写”hello\n”,file文件长度变为n+6

4、从进程2切换回到金成1,进程1之前已经将fd1的文件位移量设置为了n,进程1就会在文件位置n处写”world\n”,这样一来进程1在文件n位置处写的”world”就会覆盖掉进程2n位置处写的”hello”,这样也会导致相互覆盖,尽管出现的几率很小,但是这也是不允许的。

对于多进程实现对文件共享时,O_APPEND标志带有的原子性是很重要的,我们在后续的课程中还会陆续接触到有关原子操作的概念,这里暂时先借助O_APPEND标志的原子性先给大家引入这一概念,后面再次讲到原子性时,大家接受起来会容易得多。

10.3、注意点

单个进程多次打开同一文件,和多个进程各自打开同一文件并实现文件共享时,维护打开文件的数据结构基本一致,但单进程只有一个进程表,多个进程的每个进程都有自己的进程表,所以单进程里面多次open返回的文件描述符绝对不可能相同,同一进程里面的某文件描述符不可能同时指向几个文件,但是多个进程中每个进程open返回的文件描述符可能相同,也可能不同,因为它们用的是各自进程表中0~1023的文件描述集。

从这个例子中大家很清楚的了解到,文件位移量和文件长度分开存放是很有好处的。

11dup函数(文件描述符重定位函数)

11.1、函数原型和头文件      

#include <unistd.h>

int dup(int oldfd);

int dup2(int oldfd, int newfd);

11.2、函数功能说明

用来复制一个现存的文件描述符,其实就是让多个文件描述符指向同一个文件(不用多次open实现),比如可以让456这三个文件描述符指向了同一个文件file biao。

11.2.1dup:对于复制后的用到的文件描述符,选择的是该进程中0~1023中目前最小并且未用的那个,比如最小的2这个描述符未用,2就会和oldfd一起指向同一个文件,实现文件共享

11.2.2dup2:复制后用到的文件描述符newfd由我们自己指定,如果该描述符已经被用,那么这个描述符将会先被关闭,然后再复制,并且关闭和复制是一个原子操作。

11.3、函数参数

11.3.1第一个参数int oldfd:需要被复制的文件描述符

11.3.2第二个参数int newfd:复制后的用到的文件描述符

11.4、返回值

调用成功,返回新复制的文件描述符,失败,返回-1,并且errno设置。

11.5、测试用例:略

从前面一系列的课程的学习中,大家已经基本清楚,printf函数其实是fprintf(stdout, “”, ...);的特殊形式,stdout对应着标准输出,是个库函数,继续向下调用时,库的内部实际上调用的还是read函数,由于是标准输出,所以read用的1这个文件描述符,所以基本上printf函数调用read(1, “”, ....);函数时,文件描述符被写死为了1,那么printf只能打印到标准输出上。

但是如果我希望printf()的内容不输出到标准输出,而是输出的某个普通文件中,怎么办呢,当然最好的办法就是将库函数中调用的read(1, “”, ....);中的1改为fd就好了,但是1基本被写死了,那么这个愿望不太好实现,其实有一种方法,那就是将1关闭,然后立即open普通文件,这样的话1就应该指向了普通文件,也可实现我们的目的,但是我们这里不讨论这种方法,而是采用刚刚学的dup函数来实现。

但是实际上这两种方法是有很大的区别的,我们后面在文件共享的时候将为大家分析这种两种区别。

11.5.1、用dup函数例子:

int main(void){
        int fd1 = -1, fd2 = -1, ret = -1; 

/* 由于0、1、2三个文件描述符已经被用,当前最小未用的肯定是3,所以fd1 = = 3 */
        fd1 = open("file", O_CREAT|O_RDWR, 0664);
        if(fd1 < 0){   
                perror("open file is fail");
                exit(-1);  
}   
        close(1);//关闭1
/* 当1被关闭后,当前最小且未用的文件描述符肯定是1,所以dup会将
*  fd1复制到当前最小未用的1上,这样一来1和fd1都指向了文件file */
        ret = dup(fd1);//新复制的文件描述符会被返回
printf("fd1 = %d\n", fd1);
        printf("ret = %d\n", ret);
        printf("hello world\n");    
        return 0;         
}

 

我的目的是让1结合上新打开的文件file,这样的话1fd1= =3)同时指向同一个文件file,这样的话,printf输出的内容就到了file里面,vi  file,内容如下:

  1 fd1 = 3  

  2 ret = 1

  3 hello world

从以上答案看出,printf打印的hello world信息,已经输入到了file文件中。但是这段代码实际上存在一个问题,就是当涉及多线程的时候,多个线程共享本进程所有的文件描述符,close(1)后,如果还没来得及执行dup函数时,就切换到了其它线程的话,其它线程可能就会抢先用掉1这个描述符。实际上出现这个问题的原因就是closedup不是原子操作,针对这个问题我们可以用dup2函数解决,因为它的描述符关闭和赋值是原子操作。

当然我们如果不关心复制后用的是什么数值的描述符,只是简单复制的话,那么用dup就可以了。

11.5.1、用dup2函数例子:

int main(void)
{
        int fd1 = -1, fd2 = -1, ret = -1; 
        fd1 = open("file", O_CREAT|O_RDWR, 0664);
        if(fd1 < 0){   
                perror("open file is fail");
                exit(-1);     
}   
        Fd2 = dup2(fd1, 1);
        printf("fd1 = %d\n", fd1);
        printf("fd2 = %d\n", fd2);
        printf("hello world\n");    
        return 0;
}

dup2函数的第二个参数,用于指定新的描述符,如果这个描述符已经打开了,那就先关闭它,然后再复制,只是这个关闭和复制两个动作是一个原子操作,所以dup2就避免了dup存在的问题。

11.5.1dup后的内核维护打开文件的数据结构

 

int main(void)
{
        int fd1 = -1, fd2 = -1, ret = -1; 
        fd1 = open("file", O_CREAT|O_RDWR, 0664);
        if(fd1 < 0){   
                perror("open file is fail");
                exit(-1);     
}   
        fd2 = dup2(fd1, 1);
while(1)
{
write(fd1, "hello\n", 6 );
write(fd2, "world\n", 6 );
sleep(1);
}

        return 0;
}

vi file后结果如下:

1 hello
2 world
3 hello
4 world
5 hello
6 world

 

看到这个结果,大家一定很奇怪,这里open时我并没有指定所谓的O_APPEND标志啊,这是因为赋值后的内核维护未打开文件的数据结构又是不同的。

 

从上图中,大家可以看出,dup后的多个文件描述符,直接共享同一个文件表,所以也就共享同一个文件位移量,这么一来大家对于文件位移量的修改在相互之间都是可见和共享的,这样一来根本就不需要指定所谓的O_APPEND标志

11.6、注意点

其实我们学的 命令操作符,就涉及上面的dup系统调用,文件描述符的重定位其实就是复制。之前的例子中,文件描述符1原来是和标准输出结合在一起,经过复制后(重定位后)就和普通文件file结合在了一起。

使用重定位函数时,也需要注意有关原子操作的问题。

12、有关文件共享的问题

12.1、各种不同的文件共享方式

实际上通过前面陆续的了解,我们发现有如下几种文件共享上方式:

1、情况一:单个进程多次open同一文件

2、情况二:多个进程各自open同一个文件

3、情况三:通过dup函数重定位一个新的描述符,这些描述符指向同一个文件

以上三种方式都能实现文件的共享,但是它们之间又各有区别,本质的区别就在于各自的内核维护打开文件的数据结构是不同的。

12.2、各种情况所对用的内核维护打开文件的数据结构

下面都是以两个文件描述符为例进行说明。

11.2.1第一种情况:多次open同一文件实现共享

12.2.2、第二种情况:两个进程共享同一文件

 

12.2.3、第三种情况:dup实现共享同一文件

 

 13fcntl函数

13.1、函数原型和头文件

#include <unistd.h>

#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

13.2、函数功能

fcntl函数其实是FileControl的缩写,利用描述符fd来改变已打开文件的性质(那些open时没有指定,或open时没办法指定的性质),实现文件性质的控制。

13.3、函数参数

13.3.1、第一个参数int fd文件描述符

13.3.2、第二个参数 int cmd控制命令选项,用来控制修改什么样的性质,对于cmd的设置选择如下:

a)、F_DUPFD:复制现存的描述符

可用来用来模拟dupdup2,后面会有例子对此用法进行说明。

b)、F_GETFDF_SETFD:获取或设置文件描述符状态

这种设置只有一种情况,我们再将exec函数时将为大家举例说明。

c)、F_GETFLF_SETFL:获取或设置文件状态

我们在open时如果没有指定某些状态(如O_NONBLOCK,O_RDWR,O_APPEND等),我们可以利用fcntl 函数补上。所以后续我们在讲阻塞与非阻塞,异步通知,poll机制等时会为大家举例说明。

d)、F_GETOWNF_SETOWN:获取或设置异步io信号接收的所有权

我们在讲异步通知或poll机制是将会为大家举例。

e)、F_GETLKF_SETLKF_SETLKW:获取或设置记录所属性用

我们将高级io的记录锁再为大家举例子

13.3.3、第三个参数

到底有没有,有又如何设置,这需要视具体情况而定,我们后面会陆续为大家举各种例子,到时再分别做说明。

13.4、函数返回值

如果调用成功,那么具体的返回值视cmd的设置而定,具体如下:

a)、F_DUPFD:返回复制后的新的文件描述符

b)、F_GETFD:返回文件描述符状态

c)、F_GETFL:返回文件的文件状态

d)、F_GETOWN:返回文件描述符所有者的进程id

e)、除了F_GETKEASEF_GETSIG外,其余的设置全部返回0

如果调用失败,不管cmd怎么设置的,一律返回-1errno被设置

13.5、测试用例

对于例子,我们这里只说明cmd设置为F_DUPFD时用来模拟dupdup2函数的用法,因为我们刚讲完描述符复制的系列函数,这里再给大家做个复习,同时也初步的了解下fcntl函数如何使用。

12.5.1fcntl模拟dup函数

int main(void)
{
  int fd1 = -1, fd2 = -1;

  fd1=open("file", O_CREAT|O_RDWR, 0664);

  close(1);


  fd2 = fcntl(fd1, F_DUPFD, 0);
  printf("fd1 = %d\n", fd1);
  printf("fd2 = %d\n", fd2);
  printf("hello world\n");
  return 0;
}

fcntl函数模拟dup函数时,着重看fcntl参数是如何设置的,也即右边代码的红色部分。关闭文件描述符操作和文件描述符的复制操作任然是无法做到原子操作的。

13.5.1fcntl模拟dup2函数

int main(void)
{
   int fd1 = -1, fd2 = -1, ret = -1;

  fd1 = open("file", O_CREAT|O_RDWR, 0664);

  close(1);  
  /*也可以这么写:
  *fd2 = 1;
  *ret = fcntl(fd1, F_DUPFD, fd2);  */
  fd2 = fcntl(fd1, F_DUPFD, 1);

  printf("fd1 = %d\n", fd1);
  printf("fd2 = %d\n", fd2);
  printf("hello world\n");
  return 0;
}

dup2进行复制时,如果新的文件描述符已经被用,dup2函数会关闭它,然后再复制它,并且是原子操作,但是fcntl模拟dup2时却必须自己手动的调用close函数进行关闭,并且关闭和复制并不是不是原子操作,利用fcntl模拟dup2时需要注意。

13.6、注意点

a)、fcntl函数是一个杂物箱,可以实现很多功能的操作

b)、模拟dup2函数时,描述符关闭和描述符复制不是原子操作

c)、不管是利用dupdup2还是fcntl复制出来的文件描述符,他么虽然共享相同内核维护打开文件的数据结构,但是它们却拥有不同的文件描述符状态标志(可以通过F_GETFDF_SETFD选项进行设置),原 有文件描述符的状态标志会被dupdup2fcntl(利用cmd 设置 F_DUPFD进行设置)刷新。

对于有关XXX_CLOEXEC标志,后续课程会讲到的,这里了解即可,可后面的学习大点基础。

d)、利用fcntl函数进行文件性质修改有个好处,那就是只需要知道文件描述符,并不需要知道文件的具体路径是什么。

14ioctl函数

这个函数也是一个杂物箱,但是一个很强大的函数,后面学习驱动时用得较多,具体的例子我们留到学习网络编程时再讲,到时我们会用ioctl函数来获取本机的ip地址,这里暂时略去对这个函数的讲解。