嵌入式Linux C应用编程入门——高级I/O

本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。

非阻塞 I/O

阻塞 I/O 是指进程在对文件(尤其是设备文件、管道、Socket 等特殊文件)进行读写操作时,如果数据未准备好(如无数据可读或缓冲区已满),进程会进入休眠状态(阻塞) ,交出 CPU 控制权,直到条件满足才被唤醒。非阻塞 I/O 则相反:当条件不满足时,系统调用不会休眠 ,而是立即返回错误 (通常为 EAGAINEWOULDBLOCK)。

普通文件的读写操作不会阻塞 ,因为内核总能立即完成对磁盘缓存区的读写(数据在内存中缓存)。阻塞/非阻塞行为主要针对管道文件、设备文件、网络套接字 等特殊文件类型。这些文件可以通过 open() 时指定 O_NONBLOCK 标志,或使用 fcntl() 动态设置,以控制其后续 I/O 操作的行为模式。

阻塞 I/O 与非阻塞 I/O 的对比(以读取鼠标为例)

  • 阻塞方式 :调用 open() 时不指定 O_NONBLOCKread() 调用会一直阻塞直到鼠标有数据可读(即有移动或按键事件)。CPU 占用率几乎为 0,因为进程处于休眠状态。
  • 非阻塞方式 :调用 open() 时指定 O_NONBLOCKread() 立即返回 -1,并设置 errno = EAGAIN("Resource temporarily unavailable"),即使鼠标没有数据也不会阻塞。

如果采用非阻塞方式,通常需要通过轮询(Polling) 循环不断尝试读取,直到成功。但这种轮询方式会导致 CPU 占用率接近 100% ,因为进程会持续占用 CPU 执行循环检查,这在多任务系统中是不可接受的资源浪费。

阻塞 I/O 的困境:并发读取多个设备

当程序需要同时读取两个设备(如鼠标和键盘)时,阻塞 I/O 会遇到以下困难:

假设代码先读取鼠标再读取键盘:如果先执行 read(mouse) 而鼠标没有数据,程序会永远阻塞在鼠标的读取上,键盘的读取代码永远没有机会执行。即使先读键盘也一样------一旦某个设备暂时无数据,整个程序就被卡死,无法处理其他设备的输入。

这种问题可以通过多线程多进程 来解决(每个设备分配一个线程),但这些方法会增加程序复杂度。更高效、更简洁的解决方案是使用非阻塞 I/O (配合轮询)或 I/O 多路复用select/poll)。

非阻塞 I/O 解决并发读取问题

使用非阻塞 I/O 可以解决阻塞问题:将鼠标和键盘都设置为非阻塞模式,然后在 for(;;) 循环中同时尝试读取两者。如果某个设备暂时无数据,read() 立即返回错误,循环继续尝试下一个设备,从而实现了"并发"读取的效果。

非阻塞 I/O 虽然解决了"互相阻塞"的问题,但代价是CPU 使用率极高 (接近 100%),因为程序在不停地轮询 (即使没有任何数据到来,也在不断执行空循环)。这种模式在小规模测试中尚可接受,但在实际产品中会严重影响系统性能。后续引入的 I/O 多路复用 可以解决这个"高 CPU 占用"问题------它允许进程同时监视多个文件描述符,并在有数据就绪时才被唤醒,从而实现"阻塞等待,多路监视"的理想模式。

注意事项

非阻塞 I/O 的 EAGAIN 错误 :当文件描述符被设置为非阻塞模式,且当前无可读数据(或缓冲区满无法写入)时,read() / write() 会返回 -1errno 被设置为 EAGAIN(或 EWOULDBLOCK)。应用程序必须检查这个错误,而不是将其视为致命的 I/O 错误。
通过 fcntl() 动态设置非阻塞标志 :对于标准输入(键盘)这类无法通过 open() 指定标志的文件描述符(因为它由父进程继承而来,fd=0),可以使用 fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) 动态设置非阻塞模式。
在等待数据时,阻塞 I/O 让进程进入休眠,内核将 CPU 调度给其他进程使用,因此 CPU 利用率极低。非阻塞 I/O 的轮询模式则持续占用 CPU,即使没有数据也在空转,导致资源浪费。因此,非阻塞 I/O 通常不会单独使用 ,而是配合 selectpoll 或异步 I/O(信号驱动)等机制,实现等待时不占 CPU,有数据时立即响应的目标。

I/O 多路复用

I/O 多路复用(I/O Multiplexing)通过一种机制,使进程能够同时监视多个文件描述符 。一旦其中某个文件描述符可以执行 I/O 操作(如数据可读、可写或发生异常),内核会通知应用程序。它解决了阻塞 I/O 在并发读取多个设备时的"互相阻塞"问题,同时避免了非阻塞 I/O 轮询带来的 CPU 空转------进程会阻塞在 select()poll() 系统调用上,直到至少一个被监视的文件描述符就绪,从而实现了"外部阻塞,内部多路监视"的高效模式。

select() 函数

select() 系统调用用于同时监视多个文件描述符的可读、可写或异常事件。它使用 fd_set 集合 来描述需要监视的文件描述符集合,并通过 timeout 参数控制阻塞行为。

c 复制代码
#include <sys/select.h>

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

/* fd_set 集合的操作宏 */
void FD_ZERO(fd_set *set);        /* 清空集合 */
void FD_SET(int fd, fd_set *set); /* 将 fd 加入集合 */
void FD_CLR(int fd, fd_set *set); /* 将 fd 从集合中移除 */
int FD_ISSET(int fd, fd_set *set); /* 检查 fd 是否在集合中 */
  • nfds:三个文件描述符集合中最大的文件描述符编号 + 1 。内核只需要检查从 0 到 nfds-1 的范围,避免遍历全部 1024 位。
  • readfds:需要监视"可读"事件的描述符集合。如果对该事件不感兴趣,可传入 NULL
  • writefds:需要监视"可写"事件的描述符集合。
  • exceptfds:需要监视"异常"事件的描述符集合。
  • timeout:控制 select() 的阻塞行为:
    • timeout = NULL:无限阻塞,直到至少一个描述符就绪。
    • timeout->tv_sec = 0timeout->tv_usec = 0:不阻塞,立即返回(轮询模式)。
    • 其他值:在指定的时间上限内等待。
  • 返回值
    • 成功:返回处于就绪态的文件描述符总数(三个集合中的并集)。
    • 超时 :返回 0
    • 失败 :返回 -1,并设置 errno(如 EINTR 表示被信号中断)。

fd_set 集合的大小限制fd_set 使用位掩码实现,最大容量由常量 FD_SETSIZE 定义,在 Linux 中通常为 1024。这意味着 select() 可监视的最大文件描述符编号不能超过 1023。对于需要监视大量文件描述符的应用程序(如高并发网络服务器),应使用 poll()epoll()
select() 会修改三个集合select() 返回时,readfdswritefdsexceptfds 集合会被内核修改为只包含已经就绪的文件描述符 。因此,在循环中重复调用 select() 时,必须每次都重新初始化并添加需要监视的文件描述符

poll() 函数

poll()select() 的功能增强版,解决了 fd_set 容量限制问题。它使用 struct pollfd 数组 来指定需要监视的文件描述符和事件,并且不会修改 events 字段(只填充 revents),避免了每次重新初始化集合的麻烦。

c 复制代码
#include <poll.h>

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

/* 每个被监视的描述符用一个 struct pollfd 表示 */
struct pollfd {
    int fd;         /* 文件描述符 */
    short events;   /* 需要监视的事件(输入) */
    short revents;  /* 实际发生的事件(输出) */
};
  • fds:指向 struct pollfd 数组的指针,每个元素描述一个被监视的文件描述符及其感兴趣的事件。
  • nfds:数组 fds 中元素的个数。
  • timeout:控制阻塞行为:
    • timeout = -1:无限阻塞。
    • timeout = 0:不阻塞,立即返回(轮询模式)。
    • timeout > 0:等待 timeout 毫秒。
  • 返回值 :与 select() 相同------就绪描述符个数、0(超时)或 -1(错误)。

eventsrevents 的常用标志events 字段由调用者设置,指定对哪些事件感兴趣;revents 字段由内核填充,指示实际发生了哪些事件。常用标志包括 POLLIN(数据可读)、POLLOUT(数据可写)、POLLERR(发生错误)、POLLHUP(挂起)、POLLNVAL(无效描述符)。其中 POLLERRPOLLHUPPOLLNVAL 只能出现在 revents 中,不能设置在 events 中。
poll() 的容量优势poll() 没有固定的文件描述符数量上限(仅受系统 ulimit -n 限制),只需分配足够的 struct pollfd 数组空间即可,适合监视大量描述符的场景。

使用示例(核心逻辑)

示例一:使用 select() 同时读取键盘和鼠标

c 复制代码
fd_set rdfds;
int loops = 5;

while (loops--) {
    FD_ZERO(&rdfds);
    FD_SET(0, &rdfds);          /* 添加键盘(fd=0) */
    FD_SET(fd, &rdfds);         /* 添加鼠标设备文件描述符 */

    ret = select(fd + 1, &rdfds, NULL, NULL, NULL);  /* 阻塞等待 */
    if (FD_ISSET(0, &rdfds))     /* 键盘有数据 */
        read(0, buf, sizeof(buf));
    if (FD_ISSET(fd, &rdfds))    /* 鼠标有数据 */
        read(fd, buf, sizeof(buf));
}

示例二:使用 poll() 同时读取键盘和鼠标

c 复制代码
struct pollfd fds[2];

fds[0].fd = 0;                  /* 键盘 */
fds[0].events = POLLIN;
fds[1].fd = fd;                 /* 鼠标 */
fds[1].events = POLLIN;

while (loops--) {
    ret = poll(fds, 2, -1);     /* 阻塞等待 */
    if (fds[0].revents & POLLIN)    /* 键盘有数据 */
        read(0, buf, sizeof(buf));
    if (fds[1].revents & POLLIN)    /* 鼠标有数据 */
        read(fd, buf, sizeof(buf));
}

注意事项

select()poll() 返回后必须立即执行 I/O 操作以清除就绪状态 :当一个文件描述符在 select()poll() 中被标记为就绪时,该状态会一直存在,直到应用程序对该描述符执行实际的 I/O 操作(如 read()write())将其清除。如果不执行 I/O 操作,下一次调用 select()poll() 时会立即再次返回该描述符,导致忙轮询效应。例如,监听到鼠标可读后,read() 读取数据既是在获取数据,也是在清除就绪标志。
select()timeout 参数会被修改timeout 指向的 struct timeval 结构体会被内核修改为剩余等待时间。因此,如果需要在循环中重复调用 select(),必须每次重新设置 timeout 的初值。
select()nfds 参数需要正确计算nfds 必须是三个集合中最大文件描述符编号 + 1。例如,如果监视的文件描述符是 035,则 nfds = 6。这个值不能随意设置,否则要么漏掉高位描述符,要么造成无谓的性能开销。
poll() 无需重新初始化 fds 数组poll() 不修改 events 字段,只修改 revents。因此,在循环中重复调用 poll() 时,只需清空 revents 即可,无需重新填充整个 fds 数组,这比 select() 更为方便。
select() 的性能瓶颈select() 每次调用都会将 fd_set 从用户空间拷贝到内核空间,同时内核需要线性遍历 整个位掩码(最大 1024 位)来检查哪些描述符就绪。当需要监视的 fd 值很大(如 fd = 500)时,即使只监视一个描述符,内核也要遍历所有小于该值的描述符,导致性能随最大 fd 值线性下降。poll() 也存在类似的问题,只是从位掩码遍历变成了数组遍历。在高并发场景下(如数千个连接),应使用 epoll

异步 I/O(信号驱动 I/O)

异步 I/O(Asynchronous I/O),也称为信号驱动 I/O ,是一种让内核在文件描述符可以执行 I/O 操作时主动向进程发送信号进行通知的机制。进程无需阻塞等待,也无需主动轮询,而是可以继续执行其他任务,直到收到内核发来的信号后才去处理 I/O 操作。该机制通过 fcntl() 设置标志和所有者,结合信号处理函数实现。

c 复制代码
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>

/* 设置文件描述符的标志(非阻塞 + 异步) */
int fcntl(int fd, int cmd, ... /* arg */);

/* 设置异步 I/O 事件的所有者(接收信号的进程) */
fcntl(fd, F_SETOWN, pid_t pid);

/* 注册信号处理函数 */
sighandler_t signal(int signum, sighandler_t handler);

实现异步 I/O 的四个步骤

步骤 1:使能非阻塞 I/O

通过 fcntl() 为文件描述符添加 O_NONBLOCK 标志。虽然异步 I/O 本身并不强制要求非阻塞,以避免在信号处理函数中执行 I/O 操作时发生阻塞。

步骤 2:使能异步 I/O

通过 fcntl() 为文件描述符添加 O_ASYNC 标志。该标志告诉内核:当该描述符上可以执行 I/O 操作时,向进程发送信号。

步骤 3:设置异步 I/O 事件的接收进程

通过 fcntl(fd, F_SETOWN, getpid()) 指定哪个进程将接收通知信号。通常设置为当前进程的 PID。

步骤 4:为通知信号注册处理函数

默认情况下,内核使用 SIGIO 作为通知信号。应用程序需要通过 signal()sigaction()SIGIO 注册处理函数。当文件描述符就绪时,内核发送 SIGIO,进程跳转到处理函数执行 I/O 操作。

使用示例

以下代码通过异步 I/O 方式读取鼠标数据。当鼠标有数据可读时,内核发送 SIGIO 信号,触发 sigio_handler() 执行 read() 操作。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>

static int fd;

static void sigio_handler(int sig)
{
    char buf[100];
    int ret;

    if (SIGIO != sig)
        return;

    ret = read(fd, buf, sizeof(buf));   /* 在信号处理函数中读取数据 */
    if (ret > 0)
        printf("鼠标: 成功读取<%d>个字节数据\n", ret);
}

int main(void)
{
    int flag;

    fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
    if (-1 == fd) { ... }

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

    /* 2. 设置异步 I/O 的所有者为当前进程 */
    fcntl(fd, F_SETOWN, getpid());

    /* 3. 为 SIGIO 信号注册处理函数 */
    signal(SIGIO, sigio_handler);

    for ( ; ; )
        sleep(1);   /* 主程序可以执行其他任务,等待信号唤醒 */

    return 0;
}

注意事项

O_ASYNC 标志无法在 open() 时指定O_ASYNC 不能作为 open()flags 参数传入,必须在文件打开后通过 fcntl() 动态添加。这是因为异步 I/O 的配置需要依赖已打开的文件描述符和进程上下文。
接收进程必须与信号处理函数在同一进程中F_SETOWN 设置的 PID 必须与执行信号处理函数的进程相同。在单线程程序中通常设置为 getpid()。在多线程环境中,还需要考虑信号掩码的线程级属性。
异步 I/O 适用于检查大量文件描述符的场景 :与 select()/poll() 相比,异步 I/O 避免了"应用程序主动轮询所有描述符"的开销,内核会"记住"需要监视的描述符,仅在事件发生时发送信号。因此,在需要监视数千个文件描述符时,异步 I/O 的性能优于 select()/poll()

异步 I/O 的缺陷

SIGIO 作为默认通知信号,存在两个明显的缺陷:

  1. 信号可能丢失SIGIO标准信号(非实时信号) ,不支持排队。如果在处理函数执行期间,内核又发送了多次 SIGIO,这些信号会合并为一次递送,后续的信号会被丢弃(是不可靠信号)。
  2. 无法得知具体事件类型 :在信号处理函数中,应用程序不知道文件描述符上发生了什么事件------是数据可读、可写、还是发生了异常?SIGIO 信号本身不携带这些信息。

通过使用实时信号 (如 SIGRTMIN)和 sigaction() 配合 SA_SIGINFO 来优化这两个缺陷,使应用程序能够从 siginfo_t 中获取 si_fd(文件描述符)和 si_code(事件类型)等详细信息。

优化异步 I/O

默认的异步 I/O(使用 SIGIO 信号)虽然解决了"阻塞等待"的问题,但存在两个严重的缺陷:

  1. 信号可能丢失SIGIO 是标准信号(非实时信号),不支持排队。如果在处理函数执行期间发生了多次 I/O 事件,后续的信号会合并为一次递送,导致事件丢失。
  2. 无法识别事件类型SIGIO 信号只通知"有事件发生",但无法告知应用程序是"数据可读"、"数据可写"还是"发生错误"。在信号处理函数中无法区分这些情况。

本节通过实时信号sigaction() 机制对异步 I/O 进行优化,解决上述缺陷。

优化一:使用实时信号替换默认的 SIGIO

通过 fcntl()F_SETSIG 命令,可以为文件描述符指定一个实时信号 (如 SIGRTMIN)作为异步 I/O 的通知信号,替代默认的 SIGIO。实时信号支持排队,确保密集的 I/O 事件不会丢失。

c 复制代码
/* 指定实时信号 SIGRTMIN 作为异步 I/O 通知信号 */
fcntl(fd, F_SETSIG, SIGRTMIN);
  • SIGRTMIN 是编号最小的实时信号(通常为 34),实时信号支持排队,并且可以携带伴随数据。
  • 如果第三个参数设置为 0,则恢复到默认状态(使用 SIGIO)。

注意F_SETSIG 需要定义 _GNU_SOURCE 宏才能使用,否则编译时会提示未定义。

优化二:使用 sigaction() 配合 SA_SIGINFO 获取详细信息

实时信号必须配合 sigaction()SA_SIGINFO 标志使用,以获取包含丰富信息的 siginfo_t 结构体。通过 siginfo_t,应用程序可以精确知道是哪个文件描述符发生了事件,以及发生了哪种类型的事件。

c 复制代码
#include <signal.h>

struct sigaction act;

act.sa_sigaction = io_handler;   /* 使用 sa_sigaction 替代 sa_handler */
act.sa_flags = SA_SIGINFO;       /* 必须设置该标志 */
sigemptyset(&act.sa_mask);
sigaction(SIGRTMIN, &act, NULL);

siginfo_t 中与异步 I/O 相关的关键字段

  • si_fd:发生 I/O 事件的文件描述符编号。
  • si_code:事件类型标识码,可取值:
    • POLL_IN:数据可读取(对应 poll()POLLIN | POLLRDNORM)。
    • POLL_OUT:数据可写入(对应 poll()POLLOUT | POLLWRNORM)。
    • POLL_ERR:发生 I/O 错误。
    • POLL_HUP:连接挂起(如对端关闭 Socket)。
    • POLL_PRI:有高优先级数据可读取。
  • si_band:包含与 poll()revents 相同的位掩码值,与 si_code 一一对应。

使用示例

以下代码使用实时信号 + sigaction 优化后的异步 I/O 读取鼠标数据,能够区分事件类型并支持信号排队:

c 复制代码
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>

static int fd;

static void io_handler(int sig, siginfo_t *info, void *context)
{
    char buf[100];
    int ret;

    if (SIGRTMIN != sig)
        return;

    /* 检查事件类型:是否为可读事件 */
    if (POLL_IN == info->si_code) {
        ret = read(fd, buf, sizeof(buf));   /* si_fd 没有直接用于 read,
                                              因为 fd 是已知的;但在多描述符场景
                                              下可通过 info->si_fd 区分 */
        if (ret > 0)
            printf("鼠标: 成功读取<%d>个字节数据\n", ret);
    }

    /* 其他事件类型可在此扩展处理 */
}

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

    fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
    if (-1 == fd) { ... }

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

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

    /* 3. 指定实时信号 SIGRTMIN 作为通知信号(替代默认 SIGIO) */
    fcntl(fd, F_SETSIG, SIGRTMIN);

    /* 4. 为实时信号 SIGRTMIN 注册处理函数(使用 sa_sigaction) */
    act.sa_sigaction = io_handler;
    act.sa_flags = SA_SIGINFO;
    sigemptyset(&act.sa_mask);
    sigaction(SIGRTMIN, &act, NULL);

    for ( ; ; )
        sleep(1);

    return 0;
}

注意事项

必须定义 _GNU_SOURCEF_SETSIG 是 GNU 扩展,使用前必须在源文件开头(或在编译时通过 -D_GNU_SOURCE 选项)定义该宏,否则编译器会报"未定义"错误。
si_codepoll() 事件的对应关系si_code 中的 POLL_INPOLL_OUT 等值,与 poll() 系统调用中 revents 字段的标志一一对应。这使开发者能够以熟悉的方式处理 I/O 事件,无需学习新的标志体系。
实时信号的优势体现 :使用实时信号后,即使信号处理函数正在执行,新到达的 I/O 事件也会被加入队列,不会丢失。在 siginfo_t 中,si_fd 字段帮助区分是哪个文件描述符就绪,si_code 字段帮助区分事件类型,解决了 SIGIO 的两个致命缺陷。
适用范围 :优化后的异步 I/O 适用于需要监视大量文件描述符且对事件响应要求较高的场景(如网络服务器)。对于监视数量较少的情况,select()/poll() 可能更简单,且性能足够。

存储映射 I/O

存储映射 I/O(Memory-mapped I/O,简称 mmap)是一种将磁盘文件映射到进程地址空间中的一块内存区域 的高级 I/O 操作。映射成功后,进程可以直接通过指针读写这片内存区域,而无需调用 read()write() 系统调用------对映射区的读取操作会自动从文件中读取数据,对映射区的写入操作会自动将数据写回文件(取决于映射标志)。

适用场景 :存储映射 I/O 特别适合处理大文件 (如图像、视频数据),因为它减少了数据在内核空间和用户空间之间的复制次数,提高了 I/O 效率。在嵌入式 Linux 开发中,最典型的应用就是 Framebuffer(LCD 显示)编程 ------通过 mmap() 将 LCD 显存映射到用户空间,应用程序直接写入映射区即可更新屏幕显示,避免了普通 I/O 方式的大量数据拷贝开销。

mmap()munmap() 函数

mmap() 系统调用用于建立文件到内存的映射,munmap() 用于解除映射。

c 复制代码
#include <sys/mman.h>

/* 建立映射 */
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

/* 解除映射 */
int munmap(void *addr, size_t length);
  • addr:指定映射区的起始地址。通常设置为 NULL,由系统自动选择合适的地址。如果指定了非 NULL 值且未设置 MAP_FIXED,系统会将其视为"建议"而非强制要求。
  • length:映射长度(以字节为单位)。注意 :内核实际分配的映射区大小通常是页大小(通常 4096 字节)的整数倍,但访问超出 length 但仍在页内的区域是安全的(会被填充为 0);访问超出页边界的区域会引发 SIGBUS 信号。
  • prot:映射区的保护要求(权限),可取以下值的组合(按位或 |):
    • PROT_READ:映射区可读。
    • PROT_WRITE:映射区可写。
    • PROT_EXEC:映射区可执行。
    • PROT_NONE:映射区不可访问。
      该权限不能超过 open() 文件时的访问权限(例如文件以只读打开时,不能指定 PROT_WRITE)。
  • flags:控制映射行为的标志,必须 指定以下两者之一:
    • MAP_SHARED:共享映射。对映射区的修改会写回文件 (从用户空间映射区写到磁盘设备中),并且对其他映射了该文件的进程可见。适合多进程共享数据或需要将修改持久化到文件的场景。
    • MAP_PRIVATE:私有映射。对映射区的修改不会写回文件 ,而是创建一个副本(写时复制,Copy-on-Write),其他进程不可见。适合读取配置文件而不希望修改原文件的场景。
      其他可选标志(可通过 | 组合)包括:
    • MAP_FIXED:强制使用 addr 指定的地址作为起始地址(如果无法映射则失败)。不推荐使用,因为它降低了可移植性。
    • MAP_ANONYMOUS(或 MAP_ANON):建立匿名映射,忽略 fdoffset 参数,不涉及文件。通常用于父子进程间共享内存。
  • fd:需要映射的文件描述符(由 open() 返回)。如果使用了 MAP_ANONYMOUS,此参数可设为 -1
  • offset:文件映射的起始偏移量(以字节为单位)。该值必须为页大小(通常 4096 字节)的整数倍 ,通常设置为 0(从文件头开始映射)。
  • 返回值(mmap :成功返回映射区的起始地址(void * 类型);失败返回 MAP_FAILED(即 (void *)-1),并设置 errno
  • 返回值(munmap :成功返回 0;失败返回 -1,并设置 errno

重要约束 :参数 addroffset 必须与系统页大小对齐(通常为 4096 字节的整数倍)。如果 offset 不符合对齐要求,mmap() 会失败。参数 length 虽无对齐要求,但若文件大小小于 length,访问超出文件大小但仍在页内的区域不会出错(填充 0),访问超出页的区域会触发 SIGBUS
SIGBUSSIGSEGV 的关系

  • 如果映射区被设置为只读(PROT_READ),进程尝试写入会触发 SIGSEGV(段错误)。
  • 如果访问的映射区部分在访问时已不存在(如文件被其他进程截断),会触发 SIGBUS 信号。
    munmap() 与进程终止的关系 :进程终止时,内核会自动解除所有映射,即使程序未显式调用 munmap()。但调用 close(fd) 不会 解除映射------映射的生命周期与文件描述符独立,解除映射必须调用 munmap()
    offset 参数的作用就是指定文件映射的起始位置 。如果设置成中间值,那么从文件开头(0)到该中间值之前的所有数据,不会被映射到这片内存区域中

为什么要把 offset 取中间值?

  • 处理超大文件 :如果有一个 10GB 的日志文件,物理内存只有 4GB,无法一次性映射整个文件。这时可以将文件分成多段(比如每段 1GB),通过改变 offsetlength 来逐段映射并处理。
  • 文件头部信息与数据分离 :很多文件格式(如 BMP 图片、ELF 可执行文件)头部是元数据(文件头),后面是实际的数据块。可以单独映射数据部分,让 mmap 返回的指针直接指向数据区域的起始位置,而不必处理头部那些杂乱的字节。

核心代码(文件拷贝)

c 复制代码
#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, *dstaddr;
    struct stat sbuf;

    if (3 != argc) { ... } /* 校验参数:./程序 源文件 目标文件 */

    /* 1. 打开源文件(只读)和目标文件(读写,不存在则创建) */
    srcfd = open(argv[1], O_RDONLY);
    dstfd = open(argv[2], O_RDWR | O_CREAT | O_TRUNC, 0664);

    /* 2. 获取源文件大小,并设置目标文件大小与之相同 */
    fstat(srcfd, &sbuf);
    ftruncate(dstfd, sbuf.st_size);

    /* 3. 将源文件映射为只读(PROT_READ),目标文件映射为可写(PROT_WRITE) */
    srcaddr = mmap(NULL, sbuf.st_size, PROT_READ, MAP_SHARED, srcfd, 0);
    dstaddr = mmap(NULL, sbuf.st_size, PROT_WRITE, MAP_SHARED, dstfd, 0);

    /* 4. 直接在映射区完成数据复制(等效于文件拷贝) */
    memcpy(dstaddr, srcaddr, sbuf.st_size);

    /* 5. 解除映射 */
    munmap(dstaddr, sbuf.st_size);
    munmap(srcaddr, sbuf.st_size);

    close(dstfd);
    close(srcfd);
    return 0;
}

要点说明

  • ftruncate(dstfd, sbuf.st_size) 用于将目标文件扩展至源文件大小,因为 mmap 不能自动扩展文件。
  • MAP_SHARED 确保对 dstaddr 的写入会最终同步到磁盘文件 dstfd
  • memcpy 是直接在用户态内存中操作,不经过 read/write 系统调用,因此效率较高。
  • 映射完成后,munmapclose 的顺序不会影响数据写回(MAP_SHARED 保证数据会在 munmap 或进程退出时写回)。

mprotect() 函数

mprotect() 用于更改一个现有映射区的保护权限。它允许应用程序在运行时动态调整映射区的读写、执行权限。

c 复制代码
#include <sys/mman.h>

int mprotect(void *addr, size_t len, int prot);
  • addr:指定映射区的起始地址,必须是页大小的整数倍(通常为 4096 的倍数)。
  • len:需要更改保护权限的区域大小(以字节为单位)。
  • prot:新的保护权限,取值与 mmap()prot 参数相同(PROT_READPROT_WRITEPROT_EXECPROT_NONE 的组合)。
  • 返回值 :成功返回 0;失败返回 -1,并设置 errno

典型用途 :在程序运行过程中动态调整映射区的访问权限。例如,初始时映射为只读(PROT_READ)用于安全读取,在特定条件下通过 mprotect() 临时改为可写(PROT_READ | PROT_WRITE)进行修改,修改完成后再次改回只读以防止意外写入。
权限更改的限制mprotect() 只能修改已建立映射区 的权限,不能扩大文件的访问权限。如果文件本身是通过只读方式打开的,即使 mprotect() 指定了 PROT_WRITE,写入操作依然会触发段错误。
mmap() 的配合mprotect() 通常在 mmap() 建立映射之后调用,用于为同一个映射区的不同部分设置不同的权限(如果分段设置,需确保每段起始地址页对齐)。

msync() 函数(同步映射区)

msync() 用于将 MAP_SHARED 映射区中修改过的数据强制同步(刷新)到磁盘文件 中。其作用类似于 fsync(),但后者作用于文件描述符,而 msync() 作用于映射区域。

c 复制代码
#include <sys/mman.h>

int msync(void *addr, size_t length, int flags);
  • addr:映射区的起始地址,必须与页大小对齐 (通常为 mmap() 的返回值)。
  • length:需要同步的内存区域大小(以字节为单位)。通常设置为 mmap() 时的 length 参数。
  • flags:控制同步行为,必须 指定以下两者之一:
    • MS_ASYNC异步同步。调用后立即返回,不等待数据实际写入磁盘。内核会在稍后的某个时间点将脏页写入磁盘。
    • MS_SYNC同步 同步。调用会阻塞,直到所有数据都成功写入磁盘后才返回。
  • 可选标志(可通过 | 与上述任一组合):
    • MS_INVALIDATE:使该文件的其他映射缓存失效,以便在同步后,其他进程能立即读取到最新数据(而非陈旧缓存)。
  • 返回值 :成功返回 0;失败返回 -1,并设置 errno

munmap() 不会 自动将映射区的修改写回磁盘。对于 MAP_SHARED 映射,脏页的写回由内核根据虚拟存储算法(如内存压力、定期回写)自动触发,时机不确定。因此,如果应用程序需要保证数据在某个时间点之前已持久化到磁盘(例如防止断电丢失),必须显式调用 msync(MS_SYNC)
MS_ASYNCMS_SYNC 的区别:前者是"告诉内核有空记得写",调用立即返回;后者是"我必须等数据写完才继续",类似于 fsync() 的同步行为。在需要确保数据可靠落盘的关键路径上(如数据库日志写入),必须使用 MS_SYNC

普通 I/O 与存储映射 I/O 比较

普通 I/O(read/write)的数据路径(两次复制)

使用 read()write() 读写文件时,数据需要经过两次复制

  1. 内核缓冲区 → 用户应用程序缓冲区(read)或用户缓冲区 → 内核缓冲区(write)。
  2. 内核缓冲区 → 磁盘(由内核在后台完成)。
    整个过程涉及多次系统调用和数据拷贝,对于大文件操作,效率较低。

存储映射 I/O(mmap)的数据路径(零额外复制)

mmap() 将磁盘文件直接映射到用户进程的地址空间,本质上映射的是内核的页缓存(Page Cache) 。应用程序对映射区的读写直接操作内核缓存,无需在用户空间再分配额外的缓冲区 ,也无需显式调用 read()/write()。数据在内核和磁盘之间流动(由内核管理),但省去了"用户空间缓冲区 ↔ 内核缓冲区"的复制环节

对比总结

对比项 普通 I/O(read/write 存储映射 I/O(mmap
数据复制次数 至少两次(用户缓存 ↔ 内核缓存 ↔ 磁盘) 零额外复制(直接操作内核缓存)
系统调用开销 每次 read/write 都是系统调用,频繁时开销大 mmap/munmap 外,读写映射区不触发系统调用
适用数据量 适合任意大小数据,小数据量更便捷 适合大数据量(如视频、图像),能显著提升效率
文件大小灵活性 不受限制,可随意追加写入 受限制 :文件大小在 mmap 时固定,不能动态增长
内存占用 需额外分配用户空间缓冲区 共享内核页缓存,内存利用率更高
典型应用场景 常规文件读写、日志记录、小文件处理 Framebuffer(LCD 显存) 、视频编解码、数据库缓存

存储映射 I/O 的"零复制"是相对于用户空间而言的。数据在磁盘和内核缓存之间依然存在传输,但用户层无需再维护一份中间缓冲区,减少了内存占用和 CPU 拷贝时间。这正是 mmap 在处理大文件时性能优于 read/write 的根本原因。
文件映射长度固定的约束mmap 在调用时通过 length 指定了映射大小,文件无法通过映射区直接扩展大小。如果需要在映射过程中追加数据,必须先通过 ftruncate() 扩展文件,再重新 mmap(或调整映射范围)。这一限制使得 mmap 不适合写日志或动态增长的文件。
存储映射 I/O 的典型应用 :在嵌入式 Linux 中,最常见的应用是 Framebuffer(LCD 显存)编程 。LCD 控制器通过硬件 DMA 不断读取显存中的数据刷新屏幕,应用程序通过 mmap 将显存映射到用户空间,直接写入像素数据即可更新画面,而无需经过 read/write 系统调用,实现了高效显示。此外,mmap 也常用于大文件拷贝,通过 memcpy 在两个映射区之间复制数据,避免了多次系统调用。

文件锁

多个进程同时操作同一文件时,容易产生竞争状态,导致文件内容与预期不一致。文件锁机制允许进程在操作文件前先对文件上锁,在操作完成后再解锁,从而确保在锁持有期间,只有该进程能对文件进行 I/O 操作,其他进程无法干扰。(对应线程中的读写锁,文件锁是针对进程的)

Linux 系统的文件锁分为两类:建议性锁(Advisory Lock)强制性锁(Mandatory Lock)

建议性锁 是一种非强制的协同工作模式。内核只负责提供锁机制,但不会强制阻止进程对文件的访问。如果一个进程在读写文件前不检查锁(即不上锁),内核依然允许其进行读写操作。只有当所有相关进程都遵循"先上锁,后访问"的约定时,建议性锁才能有效工作,防止数据混乱。这与交通信号灯类似------它只约束遵守交通规则的人。

强制性锁 则由内核进行强制拦截 。如果一个进程对文件上了强制性锁,那么其他进程对该文件进行的任何 I/O 操作(如 read()write())都会被内核检查并阻塞(或返回错误),直到锁被释放。但强制性锁对系统性能影响较大(每次读写都需要检查锁状态),且在现代 Linux 系统中通常不建议使用,实际开发中绝大部分场景使用的是建议性锁。

Linux 系统提供了三个函数用于对文件上锁:flock()fcntl()lockf() 。其中,flock() 只能产生建议性锁;而 fcntl() 既可以产生建议性锁,也可以产生强制性锁(需额外设置文件权限)。lockf() 是基于 fcntl() 实现的一个简化封装。

flock()fcntl() 的核心区别flock() 仅支持对整个文件加锁/解锁,操作简单;fcntl() 功能更强大,允许对文件的指定区域(部分字节范围) 进行加锁,更适用于多线程或多进程协同操作同一文件的不同部分。

flock() 函数加锁

flock() 是一个较简单、古老的文件锁接口,它产生的锁是建议性锁(Advisory Lock) 。这意味着内核只负责协调那些"主动遵守规则"的进程------如果一个进程在访问文件前不检查锁,内核并不会阻止它。因此,flock() 的生效依赖于所有参与进程共同遵循"先上锁,后访问"的协议。

c 复制代码
#include <sys/file.h>

int flock(int fd, int operation);
  • fd:指向已打开文件的文件描述符。
  • operation:指定锁操作类型:
    • LOCK_SH共享锁(读锁) 。多个进程可以同时持有。
    • LOCK_EX排他/互斥锁(写锁) 。一次只能有一个进程持有。
    • LOCK_UN解锁。释放当前进程持有的锁。
    • LOCK_NB非阻塞标志 。与 LOCK_SHLOCK_EX 按位或使用。默认情况下,如果无法获取锁,flock() 会阻塞直到锁可用;加上 LOCK_NB 后,若无法立即获取锁,则返回 -1,并设置 errnoEWOULDBLOCK
  • 返回值 :成功返回 0;失败返回 -1

关于建议性锁flock() 不能阻止任何进程对文件的读写,只能阻止那些同样使用 flock() 尝试加锁的进程。

核心代码

以下代码常用于保证程序(如守护进程)单例运行。如果锁已被占用,程序立即退出。

c 复制代码
#include <sys/file.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

int main(void) {
    int fd;
    
    /* 打开锁文件 */
    fd = open("/tmp/myapp.pid", O_WRONLY | O_CREAT, 0666);
    if (-1 == fd) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    /* 尝试获取排他锁(非阻塞) */
    if (-1 == flock(fd, LOCK_EX | LOCK_NB)) {
        if (EWOULDBLOCK == errno) {
            printf("程序已在运行,退出。\n");
            close(fd);
            exit(EXIT_FAILURE);
        }
        perror("flock");
        exit(EXIT_FAILURE);
    }

    /* 成功获取锁,可以安全写入 PID 并执行任务 */
    printf("成功获取锁,程序运行中...\n");
    // ftruncate(fd, 0); dprintf(fd, "%d\n", getpid()); // 可选:写入 PID

    /* 模拟主循环:持有锁直到进程结束 */
    for ( ; ; ) {
        sleep(10); 
    }

    /* 进程终止时,内核会自动释放锁并关闭 fd */
    close(fd); 
    return 0;
}

注意事项

1. 自动释放机制

锁与文件描述符(fd)绑定。当进程终止或显式调用 close(fd) 时,系统会自动解锁。这使得即使程序崩溃或被 SIGKILL 杀死,锁也会被内核清理,不会残留。

2. 同一进程多次加锁的行为

如果同一进程对同一个文件描述符 (或通过 dup 复制的描述符)再次调用 flock()旧的锁会被新的锁替代,不会发生死锁。例如,进程先加共享锁,再加排他锁,最终文件会被替换为排他锁。

3. fork() 子进程不继承锁

使用 fork() 创建的子进程,尽管会复制文件描述符,但不会继承父进程持有的文件锁 。子进程被视为独立的实体,它需要重新调用 flock() 来获取自己的锁(通常与父进程冲突)。这确保了锁的独立性,防止父子进程同时操作同一文件。

4. 文件描述符复制(dup/dup2/F_DUPFD)的行为

复制得到的文件描述符(如 new_fd = dup(fd)指向同一个锁

  • 使用 new_fd 调用 flock(fd, LOCK_UN) 可以成功解锁。
  • 如果没有显式调用 flock(LOCK_UN)只有当所有指向该锁的文件描述符(原始 fd 和所有复制的 fd)都被关闭后 ,锁才会被自动释放。如果只关闭了原始 fd 而保留了 new_fd,锁依然会被持有。

fcntl() 函数加锁 (比较复杂,此处大致讲解)

fcntl() 是 Linux 下功能更强大的文件锁接口,与 flock() 相比,它具备两个关键优势:

  • 支持对文件的指定区域(字节范围) 进行加锁,而非只能锁定整个文件;
  • 既支持建议性锁 ,也可通过设置文件权限位开启强制性锁(虽然在实际应用中极少使用)。

其锁操作通过 struct flock 结构体描述锁定区域,配合不同的 cmd 命令完成加锁、解锁和查询操作。

c 复制代码
#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
  • fd:已打开的文件描述符。
  • cmd:锁操作命令:
    • F_SETLK:尝试加锁(或解锁)。如果锁已被其他进程持有,立即返回错误errnoEACCESEAGAIN)。
    • F_SETLKW:尝试加锁(或解锁)。如果锁已被其他进程持有,阻塞等待 直到锁可用(W 表示 Wait)。
    • F_GETLK测试锁 。检查 flockptr 描述的锁是否会被现有锁阻塞。如果会被阻塞,则现有锁的信息会覆盖 flockptr;如果不会被阻塞,则 l_type 被设为 F_UNLCK注意:该命令只是测试,不实际加锁,且存在竞争条件(测试通过后实际加锁时仍可能被阻塞),因此实际应用较少。
  • flockptr:指向 struct flock 结构体的指针,描述待操作的锁区域和类型。

struct flock 结构体

c 复制代码
struct flock {
    short l_type;      /* 锁类型: F_RDLCK(共享读锁), F_WRLCK(独占写锁), F_UNLCK(解锁) */
    short l_whence;    /* 偏移基准: SEEK_SET, SEEK_CUR, SEEK_END (与 lseek 相同) */
    off_t l_start;     /* 起始偏移量 */
    off_t l_len;       /* 锁定区域的字节长度 (0 表示锁定到文件末尾) */
    pid_t l_pid;       /* 持有锁的进程 PID (由 F_GETLK 返回) */
};
  • l_len = 0 的特殊含义 :锁定区域从 l_start 开始,一直延伸到文件的最大偏移量(即文件末尾),并且是动态的------如果文件随后追加了数据,新数据也会自动落入锁区域内。
  • 加锁区域的规则 :起始位置不能超过文件末尾;如果 l_len 为 0,锁定范围覆盖从起始位置到文件末尾的所有数据。

核心代码示例

示例一:对整个文件加写锁(独占锁)

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main(int argc, char *argv[]) {
    struct flock lock = {0};
    int fd;

    fd = open("./test.txt", O_WRONLY);
    if (-1 == fd) { perror("open"); exit(EXIT_FAILURE); }

    /* 配置锁:对整个文件加写锁(独占) */
    lock.l_type = F_WRLCK;
    lock.l_whence = SEEK_SET;
    lock.l_start = 0;
    lock.l_len = 0;      /* 0 表示锁定整个文件,包括未来追加的数据 */

    if (-1 == fcntl(fd, F_SETLK, &lock)) {
        perror("fcntl 加锁失败"); /* 锁已被占用返回 EAGAIN 或 EACCES */
        close(fd);
        exit(EXIT_FAILURE);
    }

    printf("成功获取文件锁,开始写入数据...\n");
    // write(fd, "data", 4);

    /* 解锁 */
    lock.l_type = F_UNLCK;
    fcntl(fd, F_SETLK, &lock);
    close(fd);
    return 0;
}

示例二:对文件的特定区域加锁

c 复制代码
/* 对 100~200 字节区间加写锁,对 400~500 字节区间加读锁 */
lock_w.l_type = F_WRLCK;
lock_w.l_whence = SEEK_SET;
lock_w.l_start = 100;
lock_w.l_len = 100;      /* 锁定 100~200 字节(len=100,不包含 200) */
fcntl(fd, F_SETLK, &lock_w);

lock_r.l_type = F_RDLCK;
lock_r.l_whence = SEEK_SET;
lock_r.l_start = 400;
lock_r.l_len = 100;
fcntl(fd, F_SETLK, &lock_r);

关键注意事项(与 flock 的核心差异)

1. 锁的释放机制(与 flock 不同)

fcntl 锁在文件描述符被关闭时立即自动释放 。但 dup()/dup2() 复制出的描述符与原始描述符共享同一个锁引用。与 flock 不同的是,fcntl只需关闭任何一个指向该文件的描述符,锁就会被释放 (而 flock 需要关闭所有指向该文件的描述符)。这意味着如果你 dup 了一个 fd,然后关闭原始 fd,锁会立即释放,即使复制的 fd 仍然打开。这一行为需要特别注意。

2. 子进程不继承锁

fork() 创建的子进程不会继承 父进程的 fcntl 锁。即使子进程复制了文件描述符,它在内核中仍被视为独立的实体,需要重新尝试加锁(通常由于父进程持有锁而失败)。

3. 同一进程内的锁替换规则

如果同一进程对文件的同一区域(或重叠区域)再次加锁,新锁会替换旧锁 。例如,先对 100 ~ 200 字节加写锁,再对 150 ~ 250 字节加读锁,则 150 ~ 200 重叠部分的锁类型会变为读锁。这与 flock 的"替换锁"行为类似。

4. 强制性锁的启用(极少使用)

fcntl 理论上支持强制性锁,但需要:

  • 文件系统挂载时指定 mand 选项(mount -o mand);
  • 设置文件的 set-group-ID 位(S_ISGID)为 1,并清除组执行权限S_IXGRP)。一旦启用,内核会强制拦截其他进程的 read/write 操作。

但强制性锁会带来巨大的性能开销,且大多数 Linux 发行版(如 Ubuntu)并不支持。在实际开发中,应完全依赖建议性锁,而非强制性锁。

5. F_GETLK 的局限性

F_GETLK 仅用于测试,不执行加锁。由于"测试"和"加锁"之间存在时间窗口,测试结果为"可加锁"并不意味着后续的 F_SETLK 一定会成功(其他进程可能在这个间隙抢占了锁)。因此,在常规编程中直接使用 F_SETLKF_SETLKW 即可,无需先用 F_GETLK 查询。

相关推荐
ljs6482739511 小时前
Linux运维实操:vi编辑器永久配置静态IP(CentOS系列)
linux·运维·编辑器
piaoyiren1 小时前
普中A6 静态数码管显示 数字
单片机·嵌入式·普中a6
Waay2 小时前
Linux 三个核心环境变量配置文件、作用域、生效方式完整梳理
linux·运维·学习·云原生·容器
星空语2 小时前
音频001_Android+Linux车载/手机音频全链路分层架构图
android·linux·音视频
携程DBA3 小时前
数据库软限制相关研究
linux·mysql·性能
huy1n93 小时前
Ubuntu 22.04部署Claude Code并接入DeepSeek-V4模型完整教程
linux·ubuntu·deepseek·claude code
ICECREAM3 小时前
I/O 多路复用完全指南 —— select、poll、epoll
linux
进阶的猪4 小时前
Linux 内核移植过程学习
linux·学习
问简5 小时前
【共享盘】ubuntu、windows
linux·windows·ubuntu