Linux应用编程基础09-高级I/O

65 阅读25分钟

介绍文件 I/O 当中的一些高级用法,以应对不同应用场合 的需求,主要包括:非阻塞 I/O、I/O 多路复用、异步 I/O以及存储映射 I/O

1、非阻塞 I/O

阻塞式 I/O 顾名思义就是对文件的 I/O 操作(读写操作)是阻塞式的,非阻塞式 I/O 同理就是对文件的 I/O 操作是非阻塞的。

  • 譬如对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒,这就是阻塞式 I/O 常见的一种表现
  • 如果是非阻塞式 I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误

普通文件是以非阻塞的方式进行 I/O 操作,这是普通文件本质上决定的;但是对于某些文件类型,譬如上面所介绍的管道文件、设备文件等,它们既可以使用阻塞式 I/O 操作,也可以使用非阻塞式 I/O进行操作。

1.1 阻塞和非阻塞方式读取文件

以读取鼠标为例,使用两种 I/O 方式进行读取,来进行对比,鼠标是一种输入设备,其对应的设备文件在/dev/input/目录下

以阻塞方式读取鼠标,调用open()函数打开鼠标设备文件"/dev/input/mouse0",只读方式

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

int main(void)
{
    char buf[100];
    int fd, ret;

    /* 只读方式打开鼠标文件,默认阻塞方式 */
    fd = open("/dev/input/mouse0", O_RDONLY);
    if (-1 == fd)
    {
        perror("open error");
        exit(-1);
    }
    
    /* 读文件 */
    memset(buf, 0, sizeof(buf));
    ret = read(fd, buf, sizeof(buf));
    if (ret < 0)
    {
        perror("read error");
        close(fd);
        exit(-1);
    }
    printf("成功读取<%d>个字节数据\n", ret);
    
    /* 关闭文件 */
    close(fd);
    exit(0);
}

执行程序之后,发现程序没有立即结束,而是一直占用了终端,没有输出信息,原因在于调用 read()之后进入了阻塞状态,因为当前鼠标没有数据可读;如果此时我们移动鼠标、或者按下鼠标上的任何一个按键,阻塞会结束,read()会成功读取到数据并返回

image.png

如果改为以非阻塞的方式来读取文件,程序立马就结束了,并且调用 read()返回错误,提示信息为"Resource temporarily unavailable",意思就是说资源暂时不可用;原因在于调用 read()时,如果鼠标并没有移动或者被按下(没有发生输入事件),是没有数据可读,故而导致失败返回,这就是非阻塞 I/O

fd = open("/dev/input/mouse0", O_RDONLY | O_NONBLOCK);

image.png

1.2 阻塞 I/O 的优点与缺点

  • 阻塞式 I/O 的优点在于能够提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态,交出 CPU资源,将 CPU 资源让给别人使用
  • 而非阻塞式则是抓紧利用 CPU 资源,譬如不断地去轮训,这样就会导致该程序占用了非常高的 CPU 使用率

1.3 非阻塞 I/O 实现并发读取

键盘也是一种输入类设备,但是键盘是标准输入设备stdin,进程会自动从父进程中继承标准输入、标准输出以及标准错误,标准输入设备对应的文件描述符为0,所以在程序当中直接使用即可,不需要再调用 open 打开

如果想要同时读取键盘和鼠标两个文件,需要使用到非阻塞I/O,如果是阻塞的方式没有数据可读将会一直被阻塞,后面的读取键盘或鼠标将得不到执行。

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

#define MOUSE "/dev/input/mouse0"

int main(void)
{
    char buf[100];
    int fd, ret, flag;

    /* 打开鼠标设备文件,非阻塞方式*/
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);

    /* 键盘对应文件描述符为0,将键盘设置为非阻塞方式*/
    flag = fcntl(0, F_GETFL); //先获取原来的flag, fcntl()函数可以对一个已经打开的文件描述符执行一系列控制操作
    flag |= O_NONBLOCK;       //将 O_NONBLOCK 标准添加到 flag
    fcntl(0, F_SETFL, flag);  //重新设置flag

    for (;;)
    {
        /* 读鼠标 */
        ret = read(fd, buf, sizeof(buf));
        if (0 < ret)
            printf("鼠标: 成功读取<%d>个字节数据\n", ret);
        /* 读键盘 */
        ret = read(0, buf, sizeof(buf));
        if (0 < ret)
            printf("键盘: 成功读取<%d>个字节数据\n", ret);
    }
    /* 关闭文件 */
    close(fd);
    exit(0);
}

image.png

2、I/O 多路复用

虽然使用非阻塞式 I/O 解决了阻塞式 I/O 情况下并发读取文件所出现的问题,但依然不够完美,使得程序的 CPU 占用率特别高(一直轮询调用read/write)。解决这个问题,就要用到本小节将要介绍的 I/O 多路复用方法。

I/O 多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作。I/O 多路复用技术是为了解决:在并发式 I/O 场景中进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程不阻塞于某个特定的 I/O 系统调用

可以采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作,分别是系统调用 select()和 poll(),这两个函数基本是一样的,细节特征上存在些许差别

2.1 select函数

系统调用 select()可用于执行 I/O 多路复用操作,调用 select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

/*
fd_set 数据类型是一个文件描述符的集合体
- readfds 是用来检测读是否就绪(是否可读)的文件描述符集合;
- writefds 是用来检测写是否就绪(是否可写)的文件描述符集合;
- exceptfds 是用来检测异常情况是否发生的文件描述符集合。
- nfds 通常表示最大文件描述符编号值加 1,考虑 readfds、writefds 以及 exceptfds这三个文件描述符集合,在 3 个描述符集中找出最大描述符编号值,然后加 1,这就是参数 nfds
- timeout 可用于设定 select()阻塞的时间上限,控制 select 的阻塞行为,可将timeout 参数设置为 NULL,表示 select()将会一直阻塞、直到某一个或多个文件描述符成为就绪态;也可将其指向一个 struct timeval 结构体对象
*/

select()函数阻塞时有以下事情发生:

  1. readfds、writefds 或 exceptfds 指定的文件描述符中至少有一个称为就绪态
  2. 该调用被信号处理函数中断
  3. 参数 timeout 中指定的时间上限已经超时

文件描述符集合的所有操作都可以通过这四个宏来完成

#include <sys/select.h>

void FD_CLR(int fd, fd_set *set); // 将文件描述符 fd 从参数 set 所指向的集合中移除
int FD_ISSET(int fd, fd_set *set); // 如果文件描述符 fd 是参数 set 所指向的集合中的成员,则 FD_ISSET()返回 true,否则返回 false
void FD_SET(int fd, fd_set *set); // 将文件描述符 fd 添加到参数 set 所指向的集合中
void FD_ZERO(fd_set *set); // 将参数 set 所指向的集合初始化为空(每次调用select前,必须用 FD_ZERO()宏将其进行初始化操作)

在调用 select()函数之后,select()函数内部会修改 readfds、writefds、exceptfds 这些集合,当 select()函数返回时,它们包含的就是已处于就绪态的文件描述符集合了。譬如在调用 select()函数之前,readfds 所指向 的集合中包含了 3、4、5 这三个文件描述符,当调用 select()函数之后,假设 select()返回时,只有文件描述 符 4 已经处于就绪态了,那么此时 readfds 指向的集合中就只包含了文件描述符 4。所以由此可知,如果要在循环中重复调用 select(),我们必须保证每次都要重新初始化并设置 readfds、writefds、exceptfds 这些集合

select()函数的返回值

  • 返回-1 表示有错误发生,并且会设置 errno。可能的错误码包括 EBADF、EINTR、EINVAL、EINVAL以及 ENOMEM,EBADF 表示 readfds、writefds 或 exceptfds 中有一个文件描述符是非法的;EINTR表示该函数被信号处理函数中断了。
  • 返回 0 表示在任何文件描述符成为就绪态之前 select()调用已经超时,在这种情况下,readfds,writefds 以及 exceptfds 所指向的文件描述符集合都会被清空。
  • 返回一个正整数表示有一个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过 FD_ISSET()宏进行检查,以此找出发生的 I/O 事件是什么

下面使用 select()函数来实现 I/O 多路复用操作,同时读取键盘和鼠标。select()函数的参数 timeout 被设置为 NULL,并且我们只关心鼠标或键盘是否有数据可读,所以将参数 writefds 和 exceptfds 也设置为 NULL。执行 select()函数时,如果鼠标和键盘均无数据可读,则select()调用会陷入阻塞,直到发生输入事件(鼠标移动、键盘上的按键按下或松开)才会返回

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

#define MOUSE "/dev/input/mouse0"

int main(void)
{
    char buf[100];
    int fd, ret = 0, flag;
    fd_set rdfds; 
    int loops = 5;
    /* 打开鼠标设备文件 */
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
    if (-1 == fd)
    {
        perror("open error");
        exit(-1);
    }
    /* 将键盘设置为非阻塞方式 */
    flag = fcntl(0, F_GETFL); //先获取原来的 flag
    flag |= O_NONBLOCK;       //将 O_NONBLOCK 标准添加到 flag
    fcntl(0, F_SETFL, flag);  //重新设置 flag


    /* 同时读取键盘和鼠标 */
    while (loops--)
    {
        FD_ZERO(&rdfds);
        FD_SET(0, &rdfds);  //添加键盘
        FD_SET(fd, &rdfds); //添加鼠标
        ret = select(fd + 1, &rdfds, NULL, NULL, NULL);
        if (0 > ret)
        {
            perror("select error");
            goto out;
        }
        else if (0 == ret)
        {
            fprintf(stderr, "select timeout.\n");
            continue;
        }
        /* 检查键盘是否为就绪态 */
        if (FD_ISSET(0, &rdfds))
        {
            ret = read(0, buf, sizeof(buf));
            if (0 < ret)
                printf("键盘: 成功读取<%d>个字节数据\n", ret);
        }
        /* 检查鼠标是否为就绪态 */
        if (FD_ISSET(fd, &rdfds))
        {
            ret = read(fd, buf, sizeof(buf));
            if (0 < ret)
                printf("鼠标: 成功读取<%d>个字节数据\n", ret);
        }
    }
out:
    /* 关闭文件 */
    close(fd);
    exit(ret);
}

2.2 poll函数

在 select()函数中,提供三个 fd_set 集合,在每个集合中添加我们关心的文件描述符;而在 poll()函数中,则需要构造一个 struct pollfd 类型的数组,每个数组元素指定一个文件描述符以及我们对该文件描述符所关心的条件(数据可读、可写或异常情况)

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

// fds:指向一个 struct pollfd 类型的数组,数组中的每个元素都会指定一个文件描述符以及我们对该文件描述符所关心的条件
// nfds:参数 nfds 指定了 fds 数组中的元素个数,数据类型 nfds_t 实际为无符号整形
// timeout:该参数与 select()函数的 timeout 参数相似,用于决定 poll()函数的阻塞行为
//   如果 timeout 等于-1,则 poll()会一直阻塞,直到 fds数组中列出的文件描述符有一个达到就绪态或者捕获到一个信号时返回
//   如果 timeout 等于 0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态
//   如果 timeout 大于 0,则表示设置 poll()函数阻塞时间的上限值
// poll()函数返回值含义与 select()函数的返回值是一样的

struct pollfd {
    int fd; /* file descriptor */
    short events; // 需要为文件描述符 fd 做检查的事件
    short revents; // 说明文件描述符 fd 发生了哪些事件
};
// struct pollfd 结构体中的 events 和 revents 都是位掩码

应将每个数组元素的 events 成员设置为表中所示的一个或几个标志,多个标志通过位或运算符|组合起来,通过这些值告诉内核我们关心的是该文件描述符的哪些事件。同样,返回时,revents 变量由内核设置为表中所示的一个或几个标志

image.png 如果我们对某个文件描述符上的事件不感兴趣,则可将 events 变量设置为 0;另外,将 fd 变量设置为 文件描述符的负值(取文件描述符 fd 的相反数-fd),将导致对应的 events 变量被 poll()忽略,并且 revents 变量将总是返回 0,这两种方法都可用来关闭对某个文件描述符的检查。

使用 poll()函数来实现 I/O 多路复用操作,同时读取键盘和鼠标

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

#define MOUSE "/dev/input/mouse0"

int main(void)
{
    char buf[100];
    int fd, ret = 0, flag;
    int loops = 5;
    struct pollfd fds[2];
    /* 打开鼠标设备文件 */
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
    if (-1 == fd)
    {
        perror("open error");
        exit(-1);
    }
    /* 将键盘设置为非阻塞方式 */
    flag = fcntl(0, F_GETFL); //先获取原来的 flag
    flag |= O_NONBLOCK;       //将 O_NONBLOCK 标准添加到 flag
    fcntl(0, F_SETFL, flag);  //重新设置 flag
    
    /* 同时读取键盘和鼠标 */
    fds[0].fd = 0;
    fds[0].events = POLLIN; //只关心数据可读
    fds[0].revents = 0;
    fds[1].fd = fd;
    fds[1].events = POLLIN; //只关心数据可读
    fds[1].revents = 0;
    while (loops--)
    {
        ret = poll(fds, 2, -1);
        if (0 > ret)
        {
            perror("poll error");
            goto out;
        }
        else if (0 == ret)
        {
            fprintf(stderr, "poll timeout.\n");
            continue;
        }
        /* 检查键盘是否为就绪态 */
        if (fds[0].revents & POLLIN)
        {
            ret = read(0, buf, sizeof(buf));
            if (0 < ret)
                printf("键盘: 成功读取<%d>个字节数据\n", ret);
        }
        /* 检查鼠标是否为就绪态 */
        if (fds[1].revents & POLLIN)
        {
            ret = read(fd, buf, sizeof(buf));
            if (0 < ret)
                printf("鼠标: 成功读取<%d>个字节数据\n", ret);
        }
    }
out:
    /* 关闭文件 */
    close(fd);
    exit(ret);
}

3、异步 I/O

在 I/O 多路复用中,进程通过系统调用 select()或 poll()来主动查询文件描述符上是否可以执行 I/O 操作。而在异步 I/O 中,当文件描述符上可以执行 I/O 操作时,进程可以请求内核为自己发送一个信号。之后进程就可以执行任何其它的任务直到文件描述符可以执行 I/O 操作为止,此时内核会发送信号给进程

3.1 SIGIO信号实现

实现步骤:

  1. 通过指定 O_NONBLOCK 标志使能非阻塞 I/O。
  2. 通过指定 O_ASYNC 标志使能异步 I/O。
  3. 设置异步 I/O 事件的接收进程。也就是当文件描述符上可执行 I/O 操作时会发送信号通知该进程,通常将调用进程设置为异步 I/O 事件的接收进程。
  4. 为内核发送的通知信号注册一个信号处理函数。默认情况下,异步 I/O 的通知信号是 SIGIO,所以内核会给进程发送信号 SIGIO。
  5. 以上步骤完成之后,进程就可以执行其它任务了,当 I/O 操作就绪时,内核会向进程发送一个 SIGIO信号,当进程接收到信号时,会执行预先注册好的信号处理函数,就可以在信号处理函数中进行 I/O 操作。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>

#define MOUSE "/dev/input/mouse0"

static int fd;

// 信号处理函数
static void sigio_handler(int sig)
{
    static int loops = 5;
    char buf[100] = {0};
    int ret;
    if (SIGIO != sig)
        return;
    ret = read(fd, buf, sizeof(buf));
    if (0 < ret)
        printf("鼠标: 成功读取<%d>个字节数据\n", ret);
    loops--;
    if (0 >= loops)
    {
        close(fd);
        exit(0);
    }
}

int main(void)
{
    int flag;
    /* 打开鼠标设备文件<使能非阻塞 I/O> */
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
    if (-1 == fd)
    {
        perror("open error");
        exit(-1);
    }
    /* 使能异步 I/O */
    // 在调用 open()时无法通过指定 O_ASYNC 标志来使能异步 I/O,但可以使用 fcntl()函数添加 O_ASYNC 标志使能异步 I/O
    // O_ASYNC 标志可用于使能文件描述符的异步 I/O 事件,当文件描述符可执行 I/O 操作时,内核会向异步 I/O 事件的接收进程发送 SIGIO 信号(默认情况下)
    flag = fcntl(fd, F_GETFL);
    flag |= O_ASYNC; 
    fcntl(fd, F_SETFL, flag);

    /* 设置异步 I/O 的所有者 */
    // fd可执行异步I/O操作,内核向当前进程发送SIGIO,当前进程就是该异步I/O的所有者
    fcntl(fd, F_SETOWN, getpid());
    /* 为 SIGIO 信号注册信号处理函数 */
    signal(SIGIO, sigio_handler);
    for (;;)
        sleep(1);
}

3.2 实时信号实现

以上代码的缺点:

  • 默认的异步 I/O 通知信号 SIGIO 是非排队信号。SIGIO 信号是标准信号(非实时信号、不可靠信号),所以它不支持信号排队机制,譬如当前正在执行 SIGIO 信号的处理函数,此时内核又发送多次 SIGIO 信号给进程,这些信号将会被阻塞,只有当信号处理函数执行完毕之后才会传递给进程,并且只能传递一次,而其它后续的信号都会丢失。
  • 无法得知文件描述符发生了什么事件。上面代码的信号处理函数 sigio_handler()中,直接调用了 read()函数读取鼠标,而并未判断文件描述符是否处于可读就绪态,事实上,这种异步 I/O 方式并未告知应用程序文件描述符上发生了什么事件,是可读取还是可写入亦或者发生异常等

改进方式:使用实时信号替换默认信号 SIGIO使用 sigaction()函数注册信号处理函数

在应用程序当中需要为实时信号注册信号处理函数,使用 sigaction 函数进行注册,并为 sa_flags 参数指定 SA_SIGINFO,表示使用 sa_sigaction 指向的函数作为信号处理函数,而不使用 sa_handler 指向的函数。因为 sa_sigaction 指向的函数作为信号处理函数提供了更多的参数,可以获取到更多信息。函数参数中包括一个siginfo_t 指针, siginfo_t 结构体中与之相关字段如下:

  • si_signo:引发处理函数被调用的信号。这个值与信号处理函数的第一个参数一致。
  • si_fd:表示发生异步 I/O 事件的文件描述符;
  • si_code:表示文件描述符 si_fd 发生了什么事件,读就绪态、写就绪态或者是异常事件等。
  • si_band:是一个位掩码,其中包含的值与系统调用 poll()中返回的 revents 字段中的值相同

siginfo_t 结构体中的 si_code 和 si_band 的可能值 image.png 可以在信号处理函数中通过对比 siginfo_t 结构体的 si_code 变量来检查文件描述符发生了什么事件,以采取相应的 I/O 操作

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

#define MOUSE "/dev/input/mouse0"

static int fd;

static void io_handler(int sig, siginfo_t *info, void *context)
{
    static int loops = 5;
    char buf[100] = {0};
    int ret;
    if (SIGRTMIN != sig)
        return;
    /* 判断鼠标是否可读 */
    if (POLL_IN == info->si_code) {
        ret = read(fd, buf, sizeof(buf));
        if (0 < ret)
            printf("鼠标: 成功读取<%d>个字节数据\n", ret);
        loops--;
        if (0 >= loops) {
            close(fd);
            exit(0);
        }
    }
}

int main(void)
{
    struct sigaction act;
    int flag;

    /* 打开鼠标设备文件<使能非阻塞 I/O> */
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
    if (-1 == fd) {
        perror("open error");
        exit(-1);
    }

    /* 使能异步 I/O */
    flag = fcntl(fd, F_GETFL);
    flag |= O_ASYNC;
    fcntl(fd, F_SETFL, flag);

    /* 设置异步 I/O 的所有者 */
    fcntl(fd, F_SETOWN, getpid());

    /* 指定实时信号 SIGRTMIN 作为异步 I/O 通知信号 */
    fcntl(fd, F_SETSIG, SIGRTMIN);

    /* 为实时信号 SIGRTMIN 注册信号处理函数 */
    act.sa_sigaction = io_handler;
    act.sa_flags = SA_SIGINFO; // 设置该标志则表示使用 sa_sigaction 作为信号处理函数、而不是 sa_handler
    sigemptyset(&act.sa_mask); // 初始化信号集,使其不包含任何信号
    sigaction(SIGRTMIN, &act, NULL);
    
    for (;;)
        sleep(1);
}

4、存储映射 I/O

一种基于内存区域的高级 I/O 操作,能将一个文件映射到进程地址空间中的一块内存区域中,当从这段内存中读数据时,就相当于读文件中的数据,将数据写入这段内存时,则相当于将数据直接写入文件中。这样就可以在不使用基本 I/O 操作函数 read()和 write()的情况下执行 I/O 操作。

4.1 mmap()和 munmap()

为了实现存储映射 I/O ,需要告诉内核将一个给定的文件映射到进程地址空间中的一块内存区域中,这由系统调用 mmap()来实现

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
// addr:参数 addr 用于指定映射到内存区域的起始地址。通常将其设置为 NULL,这表示由系统选择该映射区的起始地址,这是最常见的设置方式;如果参数 addr 不为 NULL,则表示由自己指定映射区的起始地址,此函数的返回值是该映射区的起始地址。
// length:参数 length 指定映射长度,表示将文件中的多大部分映射到内存区域中,以字节为单位,譬如length=1024 * 4,表示将文件的 4K 字节大小映射到内存区域中。 注意:length 的值不能大于文件大小
// offset:文件映射的偏移量,通常将其设置为 0,表示从文件头部开始映射;所以参数 offset 和参数 length就确定了文件的起始位置和长度,将文件的这部分映射到内存区域中。
// fd:文件描述符,指定要映射到内存区域中的文件。
// prot:参数 prot 指定了映射区的保护要求,可取值如下:
//     PROT_EXEC:映射区可执行;
//     PROT_READ:映射区可读;
//     PROT_WRITE:映射区可写;
//     PROT_NONE:映射区不可访问。
// flags:参数 flags 可影响映射区的多种属性,参数 flags 必须要指定以下两种标志之一:(其他参数可通过 man 手册进行查看)
//     MAP_SHARED:此标志指定当对映射区写入数据时,数据会写入到文件中,也就是会将写入到映射区中的数据更新到文件中,并且允许其它进程共享。
//     MAP_PRIVATE:此标志指定当对映射区写入数据时,会创建映射文件的一个私人副本(copy-on-write),对映射区的任何操作都不会更新到文件中,仅仅只是对文件副本进行读写。
// 返回值:成功情况下,函数的返回值便是映射区的起始地址;发生错误时,返回(void *)-1,通常使用MAP_FAILED 来表示,并且会设置 errno 来指示错误原因。

参数 addr 和 offset 在不为 NULL 和 0 的情况下,addr 和 offset 的值通常被要求是系统页大小的整数倍,可通过 sysconf()函数获取页大小(单位为字节) image.png

与映射区相关的两个信号

  1. SIGSEGV:如果映射区被 mmap()指定成了只读的,那么进程试图将数据写入到该映射区时,将会产生 SIGSEGV 信号,此信号由内核发送给进程。该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。
  2. SIGBUS:如果映射区的某个部分在访问时已不存在,则会产生 SIGBUS 信号。例如,调用 mmap()进行映射时,将参数 length 设置为文件长度,但在访问映射区之前,另一个进程已将该文件截断(譬如调用 ftruncate()函数进行截断),此时如果进程试图访问对应于该文件已截去部分的映射区,进程将会受到内核发送过来的 SIGBUS 信号,同样,该信号的系统默认操作是终止进程、并生成核心可用于调试的核心转储文件。

munmap()解除映射

通过 open()打开文件,需要使用 close()将将其关闭;同理,通过 mmap()将文件映射到进程地址空间中的一块内存区域中,当不再需要时,必须解除映射,使用 munmap()解除映射关系

#include <sys/mman.h>

int munmap(void *addr, size_t length);

munmap()系统调用解除指定地址范围内的映射,参数 addr 指定待解除映射地址范围的起始地址,它必须是系统页大小的整数倍;参数 length 是一个非负整数,指定了待解除映射区域的大小(字节数),被解除映射的区域对应的大小也必须是系统页大小的整数倍,即使参数 length 并不等于系统页大小的整数倍,与mmap()函数相似。

4.2 存储映射 I/O 进行文件复制

使用存储映射 I/O 实现文件复制操作,将源文件中的内容全部复制到另一个目标文件中,其效果类似于 cp 命令

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

int main(int argc, char *argv[])
{
    int srcfd, dstfd;
    void *srcaddr; // 源文件映射到内存区域地址
    void *dstaddr; // 目标文件映射到内存区域地址
    int ret;
    struct stat sbuf;
    if (3 != argc) {
        fprintf(stderr, "usage: %s <srcfile> <dstfile>\n", argv[0]);
        exit(-1);
    }
    /* 打开源文件 */
    srcfd = open(argv[1], O_RDONLY);
    if (-1 == srcfd) {
        perror("open error");
        exit(-1);
    }
    /* 打开目标文件 */
    dstfd = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, 0664);
    if (-1 == dstfd) {
        perror("open error");
        ret = -1;
        goto out1;
    }
    /* 获取源文件的大小 */
    fstat(srcfd, &sbuf);
    /* 设置目标文件的大小 */
    ftruncate(dstfd, sbuf.st_size); // 设置目标文件的大小与源文件大小保持一致
    /* 将源文件映射到内存区域中 */
    srcaddr = mmap(NULL, sbuf.st_size,
                   PROT_READ, MAP_SHARED, srcfd, 0); //参数 prot 指定为 PROT_READ,表示对它的映射区会进行读取操作
    if (MAP_FAILED == srcaddr) {
        perror("mmap error");
        ret = -1;
        goto out2;
    }
    /* 将目标文件映射到内存区域中 */
    dstaddr = mmap(NULL, sbuf.st_size,
                   PROT_WRITE, MAP_SHARED, dstfd, 0); // 参数 port指定为 PROT_WRITE,表示对它的映射区会进行写入操作
    if (MAP_FAILED == dstaddr) {
        perror("mmap error");
        ret = -1;
        goto out3;
    }
    /* 将源文件中的内容复制到目标文件中 */
    memcpy(dstaddr, srcaddr, sbuf.st_size); // 从源头指向的内存块拷贝固定字节数的数据到目标指向的内存块
/* 程序退出前清理工作 */
out4:
    /* 解除目标文件映射 */
    munmap(dstaddr, sbuf.st_size);
out3:
    /* 解除源文件映射 */
    munmap(srcaddr, sbuf.st_size);
out2:存储映射 I/O 的实质其实是共享,与 IPC 之内存共享很相似。譬如执行一个文件复制操作来说,对于普
通 I/O 方式,首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写
入到目标文件中
    close(dstfd);
out1:
    /* 关闭源文件并退出 */
    close(srcfd);
    exit(ret);
}

4.3 存储映射 I/O 的优缺点

存储映射 I/O 的实质其实是共享,与 IPC 之内存共享很相似。譬如执行一个文件复制操作来说,对于普通 I/O 方式,首先需要将源文件中的数据读取出来存放在一个应用层缓冲区中,接着再将缓冲区中的数据写入到目标文件中

image.png

而对于存储映射 I/O 来说,由于源文件和目标文件都已映射到了应用层的内存区域中,所以直接操作映射区来实现文件复制

image.png

使用存储映射 I/O 减少了数据的复制操作,所以在效率上会比普通 I/O 要 高,其次上面也讲了,普通 I/O 中间涉及到了很多的函数调用过程,这些都会导致普通 I/O 在效率上会比存 储映射 I/O 要低。

如何理解共享呢?

其实非常简单,我们知道,应用层与内核层是不能直接进行交互的,必须要通过操作系统提供的系统调用或库函数来与内核进行数据交互,包括操作硬件。通过存储映射 I/O 将文件直接映射到应用程序地址空间中的一块内存区域中,也就是映射区;直接将磁盘文件直接与映射区关联起来,不用调用 read()、write()系统调用,直接对映射区进行读写操作即可操作磁盘上的文件,而磁盘文件中的数据也可反应到映射区中,这就是一种共享,可以认为映射区就是应用层与内核层之间的共享内存。

存储映射 I/O 的不足

存储映射 I/O 方式并不是完美的,它所映射的文件只能是固定大小,因为文件所映射的区域已经在调用mmap()函数时通过 length 参数指定了。另外,文件映射的内存区域的大小必须是系统页大小的整数倍,譬如映射文件的大小为 96 字节,假定系统页大小为 4096 字节,那么剩余的 4000 字节全部填充为 0,虽然可以通过映射地址访问剩余的这些字节数据,但不能在映射文件中反应出来,由此可知,使用存储映射 I/O 在进行大数据量操作时比较有效;对于少量数据,使用普通 I/O 方式更加方便!

通常来说,存储映射 I/O 会在视频图像处理方面用的比较多。