本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。
非阻塞 I/O
阻塞 I/O 是指进程在对文件(尤其是设备文件、管道、Socket 等特殊文件)进行读写操作时,如果数据未准备好(如无数据可读或缓冲区已满),进程会进入休眠状态(阻塞) ,交出 CPU 控制权,直到条件满足才被唤醒。非阻塞 I/O 则相反:当条件不满足时,系统调用不会休眠 ,而是立即返回错误 (通常为 EAGAIN 或 EWOULDBLOCK)。
普通文件的读写操作不会阻塞 ,因为内核总能立即完成对磁盘缓存区的读写(数据在内存中缓存)。阻塞/非阻塞行为主要针对管道文件、设备文件、网络套接字 等特殊文件类型。这些文件可以通过
open()时指定O_NONBLOCK标志,或使用fcntl()动态设置,以控制其后续 I/O 操作的行为模式。
阻塞 I/O 与非阻塞 I/O 的对比(以读取鼠标为例)
- 阻塞方式 :调用
open()时不指定O_NONBLOCK,read()调用会一直阻塞直到鼠标有数据可读(即有移动或按键事件)。CPU 占用率几乎为 0,因为进程处于休眠状态。 - 非阻塞方式 :调用
open()时指定O_NONBLOCK,read()立即返回-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()会返回-1,errno被设置为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 通常不会单独使用 ,而是配合select、poll或异步 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 = 0且timeout->tv_usec = 0:不阻塞,立即返回(轮询模式)。- 其他值:在指定的时间上限内等待。
- 返回值 :
- 成功:返回处于就绪态的文件描述符总数(三个集合中的并集)。
- 超时 :返回
0。 - 失败 :返回
-1,并设置errno(如EINTR表示被信号中断)。
fd_set集合的大小限制 :fd_set使用位掩码实现,最大容量由常量FD_SETSIZE定义,在 Linux 中通常为1024。这意味着select()可监视的最大文件描述符编号不能超过1023。对于需要监视大量文件描述符的应用程序(如高并发网络服务器),应使用poll()或epoll()。
select()会修改三个集合 :select()返回时,readfds、writefds和exceptfds集合会被内核修改为只包含已经就绪的文件描述符 。因此,在循环中重复调用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(错误)。
events和revents的常用标志 :events字段由调用者设置,指定对哪些事件感兴趣;revents字段由内核填充,指示实际发生了哪些事件。常用标志包括POLLIN(数据可读)、POLLOUT(数据可写)、POLLERR(发生错误)、POLLHUP(挂起)、POLLNVAL(无效描述符)。其中POLLERR、POLLHUP、POLLNVAL只能出现在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。例如,如果监视的文件描述符是0、3、5,则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 作为默认通知信号,存在两个明显的缺陷:
- 信号可能丢失 :
SIGIO是标准信号(非实时信号) ,不支持排队。如果在处理函数执行期间,内核又发送了多次SIGIO,这些信号会合并为一次递送,后续的信号会被丢弃(是不可靠信号)。 - 无法得知具体事件类型 :在信号处理函数中,应用程序不知道文件描述符上发生了什么事件------是数据可读、可写、还是发生了异常?
SIGIO信号本身不携带这些信息。
通过使用实时信号 (如 SIGRTMIN)和 sigaction() 配合 SA_SIGINFO 来优化这两个缺陷,使应用程序能够从 siginfo_t 中获取 si_fd(文件描述符)和 si_code(事件类型)等详细信息。
优化异步 I/O
默认的异步 I/O(使用 SIGIO 信号)虽然解决了"阻塞等待"的问题,但存在两个严重的缺陷:
- 信号可能丢失 :
SIGIO是标准信号(非实时信号),不支持排队。如果在处理函数执行期间发生了多次 I/O 事件,后续的信号会合并为一次递送,导致事件丢失。 - 无法识别事件类型 :
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_SOURCE宏 :F_SETSIG是 GNU 扩展,使用前必须在源文件开头(或在编译时通过-D_GNU_SOURCE选项)定义该宏,否则编译器会报"未定义"错误。
si_code与poll()事件的对应关系 :si_code中的POLL_IN、POLL_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):建立匿名映射,忽略fd和offset参数,不涉及文件。通常用于父子进程间共享内存。
fd:需要映射的文件描述符(由open()返回)。如果使用了MAP_ANONYMOUS,此参数可设为-1。offset:文件映射的起始偏移量(以字节为单位)。该值必须为页大小(通常 4096 字节)的整数倍 ,通常设置为0(从文件头开始映射)。- 返回值(
mmap) :成功返回映射区的起始地址(void *类型);失败返回MAP_FAILED(即(void *)-1),并设置errno。 - 返回值(
munmap) :成功返回0;失败返回-1,并设置errno。
重要约束 :参数
addr和offset必须与系统页大小对齐(通常为 4096 字节的整数倍)。如果offset不符合对齐要求,mmap()会失败。参数length虽无对齐要求,但若文件大小小于length,访问超出文件大小但仍在页内的区域不会出错(填充 0),访问超出页的区域会触发SIGBUS。
与SIGBUS和SIGSEGV的关系:
- 如果映射区被设置为只读(
PROT_READ),进程尝试写入会触发SIGSEGV(段错误)。- 如果访问的映射区部分在访问时已不存在(如文件被其他进程截断),会触发
SIGBUS信号。
munmap()与进程终止的关系 :进程终止时,内核会自动解除所有映射,即使程序未显式调用munmap()。但调用close(fd)不会 解除映射------映射的生命周期与文件描述符独立,解除映射必须调用munmap()。
offset参数的作用就是指定文件映射的起始位置 。如果设置成中间值,那么从文件开头(0)到该中间值之前的所有数据,不会被映射到这片内存区域中。为什么要把
offset取中间值?
- 处理超大文件 :如果有一个 10GB 的日志文件,物理内存只有 4GB,无法一次性映射整个文件。这时可以将文件分成多段(比如每段 1GB),通过改变
offset和length来逐段映射并处理。- 文件头部信息与数据分离 :很多文件格式(如 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系统调用,因此效率较高。- 映射完成后,
munmap和close的顺序不会影响数据写回(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_READ、PROT_WRITE、PROT_EXEC、PROT_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_ASYNC与MS_SYNC的区别:前者是"告诉内核有空记得写",调用立即返回;后者是"我必须等数据写完才继续",类似于fsync()的同步行为。在需要确保数据可靠落盘的关键路径上(如数据库日志写入),必须使用MS_SYNC。
普通 I/O 与存储映射 I/O 比较
普通 I/O(read/write)的数据路径(两次复制)
使用 read() 和 write() 读写文件时,数据需要经过两次复制:
- 内核缓冲区 → 用户应用程序缓冲区(
read)或用户缓冲区 → 内核缓冲区(write)。 - 内核缓冲区 → 磁盘(由内核在后台完成)。
整个过程涉及多次系统调用和数据拷贝,对于大文件操作,效率较低。
存储映射 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_SH或LOCK_EX按位或使用。默认情况下,如果无法获取锁,flock()会阻塞直到锁可用;加上LOCK_NB后,若无法立即获取锁,则返回-1,并设置errno为EWOULDBLOCK。
- 返回值 :成功返回
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:尝试加锁(或解锁)。如果锁已被其他进程持有,立即返回错误 (errno为EACCES或EAGAIN)。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_SETLK 或 F_SETLKW 即可,无需先用 F_GETLK 查询。