title: 高级IO tags: [嵌入式Linux, Linux应用编程, 高级IO, 非阻塞IO, IO多路复用, select, poll, epoll, 异步IO, SIGIO, mmap, 存储映射IO, 文件锁, flock, fcntl, IMX6ULL] created: 2026-09-18 updated: 2026-09-18
💡 关联知识:[[03-外设与高级IO编程/02-GPIO与LED应用编程]];延伸阅读:[[Linux+C+C++技术体系梳理/2. Linux系统编程/17. IO多路复用]]、[[Linux+C+C++技术体系梳理/2. Linux系统编程/20. 共享内存与mmap]]、[[Linux+C+C++技术体系梳理/2. Linux系统编程/6. 信号机制]]
前面章节讨论的文件 I/O 都是"打开→read/write→close"的直线流程。本篇汇总文件 I/O 的五类高级用法,用来应对不同应用场景:非阻塞 I/O、I/O 多路复用、异步 I/O、存储映射 I/O、文件锁。它们是后面提高篇(LCD、输入设备、网络)反复用到的底层工具。
约定:本篇出现的设备路径
/dev/input/event3是正点原子教程所用 Ubuntu 主机上鼠标对应的设备文件,在自己的机器上要先用sudo od -x /dev/input/eventX确认,不要照抄编号。
"阻塞"就是进入了休眠状态,交出了 CPU 控制权。前面学过的 wait()、pause()、sleep() 都会阻塞。I/O 的阻塞则特指读写操作:
errno = EAGAIN,即 "Resource temporarily unavailable")。一个关键前提:普通文件的读写永远不会阻塞,不管读写多少字节,read()/write() 一定会在有限时间内返回。这是普通文件本质决定的。只有管道文件、网络设备文件、字符设备文件等才区分阻塞/非阻塞。
| 方式 | 做法 | 说明 |
|---|---|---|
| 默认(阻塞) | open(path, O_RDONLY) |
不指定 O_NONBLOCK 即为阻塞 |
| 非阻塞 | open(path, O_RDONLY \| O_NONBLOCK) |
后续 I/O 以非阻塞方式进行 |
| 改造已有 fd | fcntl(fd, F_GETFL) → flag \|= O_NONBLOCK → fcntl(fd, F_SETFL, flag) |
用于别人打开、自己继承来的 fd(如标准输入 0) |
open 时无法指定 O_ASYNC,但 O_NONBLOCK 可以在 open 时直接指定。普通文件设置与否没有影响。
程序若先 read 鼠标、再 read 键盘,只要鼠标没数据,进程就卡在鼠标的 read 上,后面的键盘读取永远得不到执行——无法实现并发读取。用多线程/多进程可以绕开,但不是本节重点。非阻塞 + 轮询能解决"卡死",却带来新问题。
| 维度 | 阻塞 I/O | 非阻塞 I/O |
|---|---|---|
| 无数据时 | 挂起休眠,交出 CPU | 立即返回错误 |
| CPU 占用 | 几乎为 0 | 轮询时接近 100% |
| 并发读取 | 单线程做不到(会被第一个 I/O 卡住) | 轮询可以做到 |
| 适用场景 | 单设备、愿意等待 | 多设备非阻塞 + 配合多路复用 |
flowchart LR
subgraph B[阻塞 I/O]
B1[read 鼠标] --> B2{有数据?}
B2 -- 否 --> B3[进程休眠 交出CPU]
B3 --> B2
B2 -- 是 --> B4[返回数据]
end
subgraph N[非阻塞 I/O]
N1[read 鼠标] --> N2{有数据?}
N2 -- 否 --> N3[立即返回 EAGAIN]
N3 --> N1
N2 -- 是 --> N4[返回数据]
end
这是把"阻塞困境"改造成"轮询并发"的完整程序。键盘是标准输入 fd=0,需要 fcntl 手动改成非阻塞。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define MOUSE "/dev/input/event3"
int main(void)
{
char buf[100];
int fd, ret, flag;
/* 打开鼠标设备文件 */
fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将键盘(标准输入 fd=0)设置为非阻塞方式 */
flag = fcntl(0, F_GETFL); //先获取原来的 flag
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);
}
逐段说明:
O_NONBLOCK 让鼠标 read 无数据时立刻返回,循环不会被卡死。F_GETFL/F_SETFL 两步加标志,不能重新 open。I/O 多路复用(IO multiplexing)用一个机制同时监视多个文件描述符,一旦其中某个可以执行 I/O,就通知应用程序去读写。它解决"进程阻塞在某个特定 I/O 系统调用上"的问题,是外部阻塞、内部监视多路 I/O:调用本身会阻塞,但阻塞期间内核替你盯着多个 fd。两个功能几乎相同的系统调用是 select() 和 poll()。
flowchart TD
A[用户进程] --> B{select/poll 阻塞等待}
B -->|鼠标就绪| C[返回, 可用集合中标记鼠标]
B -->|键盘就绪| D[返回, 可用集合中标记键盘]
B -->|超时/无就绪| E[返回 0 或错误]
C --> F[对就绪fd执行 read/write 清除就绪态]
D --> F
F --> B
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
| 参数 | 含义 |
|---|---|
nfds |
三个集合中最大文件描述符编号值 + 1 |
readfds |
关心"是否可读"的 fd 集合,不关心传 NULL |
writefds |
关心"是否可写"的 fd 集合,不关心传 NULL |
exceptfds |
关心"异常情况"的 fd 集合(不是出错),不关心传 NULL |
timeout |
NULL:一直阻塞;0(两成员为 0):不阻塞只轮询一次;否则为等待上限 |
fd_set 是位掩码集合,全部操作由四个宏完成:
| 宏 | 作用 |
| -- | ---- |
| FD_ZERO(fd_set *set) | 将集合初始化为空 |
| FD_SET(int fd, fd_set *set) | 把 fd 加入集合 |
| FD_CLR(int fd, fd_set *set) | 把 fd 从集合移除 |
| FD_ISSET(int fd, fd_set *set) | fd 是集合成员则返回真 |
集合容量由 FD_SETSIZE 决定,Linux 下为 1024。使用前必须 FD_ZERO 初始化。
返回值(三种情况):
| 返回值 | 含义 |
|---|---|
-1 |
出错,设置 errno(EBADF fd 非法、EINTR 被信号打断等) |
0 |
超时,没有任何 fd 就绪,三个集合被清空 |
> 0 |
处于就绪态的 fd 总数(同一 fd 在多个集合就绪会被重复统计) |
关键特性(值-结果参数):select() 返回时会修改传入的集合,使其只保留就绪的 fd。因此若在循环中重复调用,每次调用前都必须重新 FD_ZERO 并重新 FD_SET。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
| 参数 | 含义 |
|---|---|
fds |
struct pollfd 数组,每个元素指定一个 fd 及关心的事件 |
nfds |
数组元素个数(nfds_t 实际为无符号整型) |
timeout |
-1:一直阻塞;0:不阻塞只检查一次;> 0:最多阻塞 timeout 毫秒 |
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events (输入,调用者填写) */
short revents; /* returned events (输出,内核填写) */
};
events 是调用者填写(关心的条件),revents 由内核在返回时填写,poll() 不会改动 events。常用事件标志:
| 标志 | events 可填 | revents 可返回 | 说明 |
|---|---|---|---|
POLLIN |
● | ● | 有数据可读 |
POLLRDNORM |
● | ● | 等价于 POLLIN |
POLLRDBAND |
● | ● | 可读优先级数据(Linux 通常不用) |
POLLPRI |
● | ● | 可读高优先级数据(GPIO 中断用它) |
POLLRDHUP |
● | ● | 对端套接字关闭 |
POLLOUT |
● | ● | 可写入数据 |
POLLWRNORM |
● | ● | 等价于 POLLOUT |
POLLWRBAND |
● | ● | 优先级数据可写 |
POLLERR |
— | ● | 有错误发生 |
POLLHUP |
— | ● | 出现挂断 |
POLLNVAL |
— | ● | 文件描述符未打开 |
不关心某 fd 时,可把 events 设为 0,或把 fd 设为负数(-fd),后者会让该元素被忽略且 revents 恒为 0。实际编程中最常用的是 POLLIN、POLLOUT;GPIO 中断场景用 POLLPRI。
返回值与 select() 一致:-1 出错;0 超时;> 0 表示 revents 非 0 的元素个数。
#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/event3"
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 |= O_NONBLOCK;
fcntl(0, F_SETFL, 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);
}
编译运行(主机上,非开发板):gcc select_test.c -o select_test && sudo ./select_test。先动鼠标或先按键都能正确读取。
把 select 换成 poll,逻辑完全一致:
#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/event3"
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 |= O_NONBLOCK;
fcntl(0, F_SETFL, flag);
/* 填充 pollfd 数组:只关心数据可读 */
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);
}
使用 select/poll 的共同注意事项:监测到某个 fd 就绪后,必须对该 fd 执行 I/O 以清除就绪状态,否则该状态会一直存在,下一次调用会立刻返回。
⚠️ 来源说明:epoll 部分不属于《I.MX6U嵌入式Linux C应用编程指南》内容,为扩展知识。原书明确说明"本书并不会介绍 epoll 相关内容",仅提示当需要检查大量文件描述符时可用 epoll 解决 select/poll 性能低的问题,且 epoll 在性能上与异步 I/O 相似、并有一些胜过异步 I/O 的优点。
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图 |
pollfd 数组 |
内核红黑树 + 就绪链表 |
| fd 上限 | FD_SETSIZE=1024 |
无硬上限(受内存限制) | 无硬上限 |
| 每次调用开销 | O(n),且需重建/拷贝集合 | O(n) 轮询 | O(1) 取就绪链表(epoll_wait) |
| 是否修改入参 | 是(值-结果),需重复初始化 | 否(revents 与 events 分离) |
否 |
| 触发模式 | 只有水平触发 | 只有水平触发 | 支持 LT 与 ET |
| 大量 fd 场景 | 差 | 差 | 优 |
| 少量 fd 场景 | 够用、可移植性好 | 够用 | 略重 |
epoll 三个核心函数:
#include <sys/epoll.h>
int epoll_create(int size); // 创建 epoll 实例,返回 epoll fd
int epoll_create1(int flags); // 推荐,flags 可传 EPOLL_CLOEXEC
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
epoll_ctl 的 op 取值:EPOLL_CTL_ADD(注册)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(注销)。struct epoll_event 中 events 常用 EPOLLIN / EPOLLOUT,并可或上 EPOLLET(边沿触发)、EPOLLONESHOT(只通知一次)。epoll_wait 返回就绪事件个数,timeout 单位毫秒(-1 永久阻塞,0 立即返回)。
LT(水平触发,默认):只要 fd 上还有数据可读,每次 epoll_wait 都会通知。编程简单,不易丢事件。
ET(边沿触发):仅在状态发生变化时通知一次。若一次没把数据读干净,后续不再通知,事件就丢了。ET 必须把 fd 设为非阻塞,并用循环 read 到返回 EAGAIN 为止。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#define MAX_EVENTS 10
int main(int argc, char *argv[])
{
int epfd, nfds, fd, i;
struct epoll_event ev, events[MAX_EVENTS];
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 以非阻塞方式打开目标文件/设备 */
fd = open(argv[1], O_RDONLY | O_NONBLOCK);
if (0 > fd) {
perror("open error");
exit(-1);
}
/* 创建 epoll 实例 */
epfd = epoll_create1(0);
if (0 > epfd) {
perror("epoll_create1 error");
exit(-1);
}
/* 注册可读事件;若要边沿触发,改成 EPOLLIN | EPOLLET */
ev.events = EPOLLIN;
ev.data.fd = fd;
if (0 > epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev)) {
perror("epoll_ctl error");
exit(-1);
}
for ( ; ; ) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (0 > nfds) {
perror("epoll_wait error");
exit(-1);
}
for (i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
char buf[100];
/* LT:读一次即可;ET:必须循环读到 EAGAIN */
int ret = read(events[i].data.fd, buf, sizeof(buf));
if (0 < ret)
printf("读到 %d 字节\n", ret);
}
}
}
close(fd);
close(epfd);
return 0;
}
⚠️ 来源说明:以下为 POSIX 异步 I/O 扩展知识,原书未涉及。原书的"异步 I/O"指
O_ASYNC+SIGIO的信号驱动 I/O(见第 3 节)。
POSIX 异步 I/O(<aio.h>)把"发起 I/O"与"等结果"真正分离,可在数据就绪或完成时通过信号/回调通知:
#include <aio.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
int fd = open("test.txt", O_RDONLY);
char buf[256] = {0};
struct aiocb cb;
struct sigevent sev;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = sizeof(buf);
cb.aio_offset = 0;
/* 用信号通知:内核在 I/O 完成时发送 SIGRTMIN + 1 */
sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = SIGRTMIN + 1;
cb.aio_sigevent = sev;
aio_read(&cb); // 异步发起,立即返回
/* ... 主线程可继续做别的事 ... */
while (aio_error(&cb) == EINPROGRESS)
; // 演示用忙等;实战应 aio_suspend 或等信号
int n = aio_return(&cb); // 取真正的返回字节数
(void)n;
close(fd);
return 0;
}
要点:aio_read/aio_write 只负责提交请求;结果用 aio_error()(EINPROGRESS 表示未完成)与 aio_return() 获取;通知方式由 aiocb.aio_sigevent 决定——SIGEV_SIGNAL 发信号,SIGEV_THREAD 由线程回调。部分环境下需链接 -lrt(老 glibc)。
I/O 多路复用是进程主动查询"谁能读写了";异步 I/O 反过来:文件描述符可以执行 I/O 时,内核主动给进程发一个信号。进程可以先做别的任务,收到信号再处理。因为绑定信号,异步 I/O 也常被称为信号驱动 I/O。
使用步骤:
O_NONBLOCK 使能非阻塞 I/O;O_ASYNC 使能异步 I/O(open 时无法指定,必须用 fcntl);F_SETOWN);SIGIO)注册信号处理函数;进程执行其它任务,I/O 就绪时内核发 SIGIO,在信号处理函数里做 I/O。
int flag = fcntl(fd, F_GETFL);
flag |= O_ASYNC; // 使能异步 I/O
fcntl(fd, F_SETFL, flag);
fcntl(fd, F_SETOWN, getpid()); // 把当前进程设为异步 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/event3"
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 */
flag = fcntl(fd, F_GETFL);
flag |= O_ASYNC;
fcntl(fd, F_SETFL, flag);
/* 设置异步 I/O 的所有者 */
fcntl(fd, F_SETOWN, getpid());
/* 为 SIGIO 信号注册信号处理函数 */
signal(SIGIO, sigio_handler);
for ( ; ; )
sleep(1);
}
| 缺陷 | 说明 |
|---|---|
| 信号可能丢失 | SIGIO 是标准(非实时、不可靠)信号,不支持排队。处理函数执行期间又来了多次 SIGIO,只会传递一次,其余丢失 |
| 无法得知发生了什么事件 | 处理函数里直接 read(),并不知道 fd 到底可读、可写还是异常 |
优化方法:
fcntl(fd, F_SETSIG, SIGRTMIN);(arg=0 则恢复默认 SIGIO)。使用 F_SETSIG 需要先定义 _GNU_SOURCE 宏。sigaction() + SA_SIGINFO 注册处理函数,通过 siginfo_t 获取更多信息:| 字段 | 含义 |
|---|---|
si_signo |
引发处理函数的信号值 |
si_fd |
发生异步 I/O 事件的 fd |
si_code |
发生了什么事件(见下表) |
si_band |
位掩码,值与 poll() 的 revents 相同 |
si_code |
si_band 掩码值 |
说明 |
|---|---|---|
POLL_IN |
POLLIN \| POLLRDNORM |
可读取数据 |
POLL_OUT |
POLLOUT \| POLLWRNORM \| POLLWRBAND |
可写入数据 |
POLL_MSG |
POLLIN \| POLLRDNORM \| POLLMSG |
不使用 |
POLL_ERR |
POLLERR |
I/O 错误 |
POLL_PRI |
POLLPRI \| POLLRDNORM |
可读高优先级数据 |
POLL_HUP |
POLLHUP \| POLLERR |
出现宕机 |
#define _GNU_SOURCE //使用 F_SETSIG 需要定义该宏
#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/event3"
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;
sigemptyset(&act.sa_mask);
sigaction(SIGRTMIN, &act, NULL);
for ( ; ; )
sleep(1);
}
异步 I/O 与 epoll 在需要同时检查大量(如数千个)文件描述符时,性能相比 select/poll 有显著优势。原因:异步 I/O 中内核"记住"要检查的 fd,仅在可 I/O 时才发信号;select/poll 内部却是轮询检查所有 fd。fd 数量不多时,select/poll 依然是简单可靠的方案。
存储映射 I/O(memory-mapped I/O)把一个文件映射到进程地址空间的一块内存:读这块内存等于读文件,写这块内存等于写文件,无需调用 read()/write()。其实质是共享——磁盘文件直接与映射区关联,映射区可看作应用层与内核层之间的共享内存,与 IPC 的共享内存很相似。
flowchart LR
A[普通 I/O] --> A1[磁盘文件] -->|read 拷贝| A2[内核缓冲区] -->|拷贝| A3[用户缓冲区]
A3 -->|write 拷贝| A2 -->|拷贝| A1
B[存储映射 I/O] --> B1[磁盘文件] -->|mmap 建立映射| B2[进程映射区]
B2 -->|直接读写, 无需read/write| B1
#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 由系统选择 |
length |
映射长度(字节),length 不能大于文件大小 |
prot |
保护要求:PROT_EXEC/PROT_READ/PROT_WRITE/PROT_NONE,可位或组合 |
flags |
必须二选一:MAP_SHARED/MAP_PRIVATE,可再或其它标志 |
fd |
要映射的文件描述符 |
offset |
文件映射偏移量,通常为 0,必须为系统页大小整数倍 |
flags 的关键取值:
| 标志 | 说明 |
|---|---|
MAP_SHARED |
写入映射区会更新到文件,并允许其它进程共享 |
MAP_PRIVATE |
写时复制一份私有副本,对映射区的修改不写回文件;解除映射后修改丢弃 |
MAP_FIXED |
强制使用 addr 指定的地址,失败才放弃,不利于移植,通常不用 |
MAP_ANONYMOUS |
匿名映射,忽略 fd 和 offset,不涉及文件,无法与其它进程共享 |
MAP_LOCKED |
对映射区上锁 |
返回值:成功返回映射区起始地址;失败返回 (void *)-1,即 MAP_FAILED,并设置 errno。
页对齐:addr 和 offset 通常要求是系统页大小整数倍,可用 sysconf(_SC_PAGE_SIZE) 或 sysconf(_SC_PAGESIZE) 获取页大小。length 无对齐要求:映射区实际大小是页大小的整数倍,尾部多余字节被置 0,可访问但不反映到文件;访问映射区末尾之后的内存会触发 SIGBUS。
与映射区相关的两个信号:
| 信号 | 触发条件 |
|---|---|
SIGSEGV |
映射区被指定为只读,进程却试图写入 |
SIGBUS |
映射区某部分在访问时已不存在(如另一进程把文件截断) |
解除映射:munmap(addr, length),addr 必须是页大小整数倍。进程终止时自动解除映射,但close() 关闭文件不会解除映射。
int mprotect(void *addr, size_t len, int prot); // 修改已有映射区的保护要求
int msync(void *addr, size_t length, int flags); // 把映射区数据刷回磁盘
msync 的 flags:
| 标志 | 说明 |
|---|---|
MS_ASYNC |
异步同步,调用后不等数据写完磁盘即返回 |
MS_SYNC |
同步同步,等数据全部写入磁盘后才返回 |
MS_INVALIDATE |
可选,使同一文件的其它映射失效 |
注意:munmap() 不会把映射区内容写到磁盘;MAP_SHARED 下由内核在某个时刻自动更新文件;MAP_PRIVATE 下解除映射后修改全部丢弃。
#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);
if (MAP_FAILED == srcaddr) {
perror("mmap error");
ret = -1;
goto out2;
}
/* 将目标文件映射到内存区域中 */
dstaddr = mmap(NULL, sbuf.st_size, PROT_WRITE, MAP_SHARED, dstfd, 0);
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:
close(dstfd); //关闭目标文件
out1:
close(srcfd); //关闭源文件并退出
exit(ret);
}
测试:echo "hello" > srcfile && ./testApp srcfile dstfile && cat dstfile,内容与源文件一致。
⚠️ 来源说明:本节不属于《I.MX6U嵌入式Linux C应用编程指南》内容,为扩展知识。原书在
mmap的flags中介绍了MAP_ANONYMOUS标志("不涉及文件,映射区域无法和其它进程共享"),本节在此基础上补充其典型用法。这里说的"无法共享"指无亲缘关系的进程无法访问匿名映射(没有文件/路径可供其它进程引用);但MAP_SHARED | MAP_ANONYMOUS建立的内存会随fork()被子进程继承,因此父子进程之间可以共享。
不涉及文件时使用 MAP_ANONYMOUS(与 MAP_SHARED 搭配,用于 fork 出的父子进程共享内存):
#include <sys/types.h>
#include <sys/mman.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
/* 匿名映射一块 4096 字节、父子进程共享的内存,忽略 fd,fd 传 -1 */
char *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (MAP_FAILED == p)
return -1;
pid_t pid = fork();
if (0 == pid) { // 子进程
strcpy(p, "hello from child");
}
else if (0 < pid) { // 父进程
wait(NULL);
/* 读到的正是子进程写入的数据,说明映射区被共享 */
write(STDOUT_FILENO, p, strlen(p));
}
munmap(p, 4096);
return 0;
}
| 维度 | 普通 I/O(read/write) | 存储映射 I/O(mmap) |
|---|---|---|
| 数据拷贝 | 用户缓冲区↔内核缓冲区多次拷贝 | 直接操作映射区,减少拷贝 |
| 函数调用链 | 层层调用,开销较大 | 直接内存访问 |
| 大数据量 | 效率低 | 效率高 |
| 小数据量 | 方便、划算 | 不划算(页对齐、映射开销) |
| 文件大小 | 无限制 | 映射时长度固定,且不能大于文件 |
| 典型场景 | 小文件、普通读写 | 视频/图像处理、Framebuffer(LCD)编程 |
多个进程同时操作同一文件容易产生竞争,导致内容混乱。文件锁是内核提供的锁机制,用来对"文件"这一共享资源加锁保护。它与互斥锁/自旋锁/读写锁的区别是:后者主要用在多线程环境做线程同步,文件锁用在对同一文件的多个进程间。
| 类型 | 特点 |
|---|---|
| 建议性锁 | 本质是协议。程序访问前先加锁,成功了再访问;但不加锁也能直接访问,对不遵守协议的程序没有约束力 |
| 强制性锁 | 内核会检查每次 I/O,非锁拥有者无法访问。对性能影响大,一般不建议使用 |
Linux 提供 flock()、fcntl()、lockf() 三个加锁函数。
#include <sys/file.h>
int flock(int fd, int operation);
| 参数/标志 | 说明 |
|---|---|
LOCK_SH |
共享锁,可被多个进程同时拥有 |
LOCK_EX |
排它锁(互斥锁),同时只能被一个进程拥有 |
LOCK_UN |
解锁 |
LOCK_NB |
非阻塞方式获取锁,无法获取立即返回,errno=EWOULDBLOCK(可与上面位或组合) |
返回值:成功 0;失败 -1 并设置 errno。同一文件不会同时具有共享锁和互斥锁。
flock 规则:
fork() 创建的子进程不继承父进程的锁;dup()/dup2()/F_DUPFD 复制出的 fd 共享同一把锁,用其中任意一个都能解锁;但若不显式解锁,只有所有 fd 都关闭后锁才释放。程序 1:持有排它锁,收到 SIGINT 后解锁退出。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <signal.h>
static int fd = -1; //文件描述符
/* 信号处理函数 */
static void sigint_handler(int sig)
{
if (SIGINT != sig)
return;
flock(fd, LOCK_UN); //解锁
close(fd);
printf("进程 1: 文件已解锁!\n");
}
int main(int argc, char *argv[])
{
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_WRONLY);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 以非阻塞方式对文件加锁(排它锁) */
if (-1 == flock(fd, LOCK_EX | LOCK_NB)) {
perror("进程 1: 文件加锁失败");
exit(-1);
}
printf("进程 1: 文件加锁成功!\n");
/* 为 SIGINT 信号注册处理函数 */
signal(SIGINT, sigint_handler);
for ( ; ; )
sleep(1);
}
程序 2:加锁失败也继续读写,用来验证"建议性锁"不阻止 I/O。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <string.h>
int main(int argc, char *argv[])
{
char buf[100] = "Hello World!";
int fd;
int len;
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 以非阻塞方式对文件加锁(排它锁) */
if (-1 == flock(fd, LOCK_EX | LOCK_NB))
perror("进程 2: 文件加锁失败");
else
printf("进程 2: 文件加锁成功!\n");
/* 写文件 */
len = strlen(buf);
if (0 > write(fd, buf, len)) {
perror("write error");
exit(-1);
}
printf("进程 2: 写入到文件的字符串<%s>\n", buf);
/* 将文件读写位置移动到文件头 */
if (0 > lseek(fd, 0x0, SEEK_SET)) {
perror("lseek error");
exit(-1);
}
/* 读文件 */
memset(buf, 0x0, sizeof(buf)); //清理 buf
if (0 > read(fd, buf, len)) {
perror("read error");
exit(-1);
}
printf("进程 2: 从文件读取的字符串<%s>\n", buf);
/* 解锁、退出 */
flock(fd, LOCK_UN);
close(fd);
exit(0);
}
测试:touch infile,先后台跑 testApp1 infile &(打印加锁成功并一直持锁),再跑 testApp2 infile,会看到进程 2 加锁失败但读写仍然成功——这就是建议性锁。kill -2 <pid> 给进程 1 发 SIGINT 解锁后,再跑进程 2 就能加锁成功。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* struct flock *flockptr */ );
与锁相关的 cmd:
| cmd | 作用 |
|---|---|
F_GETLK |
测试某把锁能否加上;若会被现有锁阻塞,则把现有锁信息写回 flockptr,否则把 l_type 改为 F_UNLCK |
F_SETLK |
加锁/解锁,失败立即返回,errno 为 EACCES 或 EAGAIN |
F_SETLKW |
F_SETLK 的阻塞版本(W=wait),等待锁可用 |
struct flock {
short l_type; /* F_RDLCK / F_WRLCK / F_UNLCK */
short l_whence; /* SEEK_SET / SEEK_CUR / SEEK_END */
off_t l_start; /* 加锁区域起始偏移 */
off_t l_len; /* 加锁区域长度 */
pid_t l_pid; /* 阻塞当前进程的锁的持有者PID,F_GETLK 时有效 */
};
l_type |
含义 |
|---|---|
F_RDLCK |
共享读锁 |
F_WRLCK |
独占写锁 |
F_UNLCK |
解锁一个区域 |
fcntl 与 flock 的两大区别:flock 只能锁整个文件,fcntl 可锁文件的某个区域(精确到字节);flock 只支持建议性锁,fcntl 支持建议性与强制性锁。
区域规则:
l_len = 0 表示锁区域从起始位置一直延伸到文件最大偏移处,且是动态的(后续追加的数据也在锁范围内);l_whence = SEEK_SET、l_start = 0、l_len = 0。读锁/写锁兼容性(与线程读写锁类似):多个进程可在同一字节上持有共享读锁;同一字节上只能有一个写锁。已有读锁的字节上加不了写锁;已有写锁的字节上加不了任何锁。
| 当前锁 \ 请求锁 | 读锁 F_RDLCK | 写锁 F_WRLCK |
|---|---|---|
| 无锁 | 可 | 可 |
| 读锁 | 可(共享) | 不可(阻塞/失败) |
| 写锁 | 不可(阻塞/失败) | 不可(阻塞/失败) |
加锁权限要求:加读锁需对文件有读权限,加写锁需有写权限。
#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(int argc, char *argv[])
{
struct flock lock = {0};
int fd = -1;
char buf[] = "Hello World!";
/* 校验传参 */
if (2 != argc) {
fprintf(stderr, "usage: %s <file>\n", argv[0]);
exit(-1);
}
/* 打开文件 */
fd = open(argv[1], O_WRONLY);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 对整个文件加独占性写锁 */
lock.l_type = F_WRLCK; //独占性写锁
lock.l_whence = SEEK_SET; //文件头部
lock.l_start = 0; //偏移量为 0
lock.l_len = 0; //长度 0 表示到文件末尾
if (-1 == fcntl(fd, F_SETLK, &lock)) {
perror("加锁失败");
exit(-1);
}
printf("对文件加锁成功!\n");
/* 对文件进行写操作 */
if (0 > write(fd, buf, strlen(buf))) {
perror("write error");
exit(-1);
}
/* 解锁 */
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock);
close(fd);
exit(0);
}
同一进程也可对不同区域分别加锁,例如对 100~200 字节加写锁、400~500 字节加读锁,只要区域不重叠:
struct flock wr_lock = {0}, rd_lock = {0};
ftruncate(fd, 1024);
wr_lock.l_type = F_WRLCK; wr_lock.l_whence = SEEK_SET;
wr_lock.l_start = 100; wr_lock.l_len = 100;
fcntl(fd, F_SETLK, &wr_lock); //对 100~200 加写锁
rd_lock.l_type = F_RDLCK; rd_lock.l_whence = SEEK_SET;
rd_lock.l_start = 400; rd_lock.l_len = 100;
fcntl(fd, F_SETLK, &rd_lock); //对 400~500 加读锁
若两个区域重叠,新锁会替换旧锁涉及的公共部分。
开启强制性锁机制:设置文件的 Set-Group-ID(S_ISGID)位为 1,并清除组执行权限(S_IXGRP):
fstat(fd, &sbuf);
fchmod(fd, (sbuf.st_mode & ~S_IXGRP) | S_ISGID);
并非所有发行版都支持强制性锁,可写测试程序验证:父进程加写锁,子进程对被写的文件加读锁——若系统支持强制性锁,子进程 read() 会失败;若不支持,read() 会成功。需要注意:在强制性锁机制下,文件被加写锁会阻塞其它进程的读写,因此测试时应把文件显式设为非阻塞。
lockf() 是一个库函数,内部基于 fcntl() 实现,是对 fcntl 锁的封装,用法不再展开。
所有示例在主机上即可验证,不需要开发板:
# 编译(主机 gcc)
gcc select_test.c -o select_test
gcc poll_test.c -o poll_test
gcc async_test.c -o async_test
gcc mmap_copy.c -o mmap_copy
gcc flock1.c -o testApp1
gcc flock2.c -o testApp2
gcc fcntl_lock.c -o fcntl_lock
gcc epoll_demo.c -o epoll_demo
# 读设备文件需要 root 权限
sudo ./select_test
若要在 ALPHA/Mini I.MX6U 开发板上运行,先设置交叉编译工具环境,再用 arm-linux-gnueabihf-gcc 编译:
source /opt/fsl-imx-x11/4.1.15-2.1.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi
arm-linux-gnueabihf-gcc select_test.c -o testApp
file testApp # 应是 32 位 ARM 可执行文件
scp testApp root@<开发板IP>:/home/root/
调试要点:
read 返回 -1 且 errno=EAGAIN 属正常,用 perror 会打印 "Resource temporarily unavailable"。sudo od -x /dev/input/eventX,移动/点击鼠标有输出即为目标设备。select 每次循环都要重新 FD_ZERO/FD_SET,否则第二次就绪集合会残留。poll 判断就绪用 revents & POLLIN,不要用 ==。mmap 失败要拿返回值与 MAP_FAILED 比较,不能与 NULL 比较。| 维度 | IMX6ULL(Cortex-A7 + Linux) | STM32(Cortex-M) | RK3568(Cortex-A55 + Linux) |
|---|---|---|---|
| 运行环境 | 完整 Linux,进程/文件抽象 | 裸机或 RTOS,无文件系统抽象 | 完整 Linux,性能更强 |
| I/O 模型 | select/poll/epoll、信号驱动、mmap、文件锁 | 靠中断 + DMA + 状态机轮询 | 同 IMX6ULL,且 fd 数量/吞吐更高 |
| "文件"概念 | 一切皆文件,设备即 /dev 节点 |
无(寄存器直接读写) | 一切皆文件 |
| 并发读取多设备 | I/O 多路复用,单线程即可 | 多中断/DMA,或 RTOS 多任务 | 多路复用 + 多线程 |
| 大数据量传输 | mmap/Framebuffer 直接映射显存 | DMA 搬运 | mmap/DMA,带宽更高 |
| 多进程互斥 | flock/fcntl 文件锁 | RTOS 信号量/互斥量(无文件锁) | 同 IMX6ULL |
| 适用 | 工业 HMI、网关、中低端嵌入式 Linux | 实时控制、低成本节点 | 边缘计算、多路视频、AI 网关 |
结论:Linux 应用层的高级 I/O 能力是"Cortex-A + Linux"相对于 MCU 的核心差异之一;同一套 select/poll/epoll/mmap 代码在 IMX6ULL 与 RK3568 上基本可移植,仅性能与 fd 规模不同。
答:三者的目标都是"同时监视多个 fd",但实现和复杂度不同。
select 用 fd_set 位图,最大 fd 受 FD_SETSIZE(1024)限制;每次调用都要把集合从用户态拷到内核态,返回时还会修改集合,所以循环中必须重新初始化;内核内部轮询检查,复杂度 O(n)。poll 用 struct pollfd 数组,没有 1024 上限,events 与 revents 分离,不用重建集合,但内核仍是轮询,O(n)。epoll 在内核维护红黑树 + 就绪链表,epoll_ctl 注册一次,epoll_wait 直接取就绪链表,复杂度 O(1)(就绪数),无 fd 上限,且支持 LT/ET。适用:fd 少且要求可移植——select/poll 足够;fd 多(成百上千,如高并发服务器)——epoll 明显更优。三者本质都是"同步 I/O 多路复用",仍需自己读写数据。
答:因为 readfds/writefds/exceptfds 是值-结果参数。调用前,集合表示"我关心哪些 fd";返回后,内核把它们改写成"哪些 fd 已就绪",未就绪的位被清除。如果下次不重新初始化,集合里残留的只是上一轮的就绪 fd,会漏掉其它 fd、语义也错误。poll 不存在这个问题,因为它把输入 events 和输出 revents 分开存放。
答:LT(水平触发,默认):只要 fd 上还有未处理的数据,每次 epoll_wait 都会通知,直到数据被读完。编程简单,不容易丢事件。ET(边沿触发,EPOLLET):仅在 fd 状态发生变化(由不可读变可读、可写变可写)时通知一次;如果这次没把数据读干净,内核不会再通知,剩余数据可能长期得不到处理。
ET 编程要点:① 必须把 fd 设为非阻塞,否则循环读到没数据时会阻塞住;② 收到事件后要循环 read/write 直到返回 EAGAIN/EWOULDBLOCK,确保把内核缓冲区读空;③ 通常配合 EPOLLONESHOT 避免多线程重复处理同一个 fd。ET 的好处是减少无效唤醒、提升高并发性能。
答:优势:① 减少数据在内核缓冲区与用户缓冲区之间的拷贝,直接访问映射区;② 省去层层函数调用,大数据量下效率高;③ 可用于进程间共享(MAP_SHARED)、映射设备显存(Framebuffer)。陷阱:① 映射长度固定,且 length 不能大于文件大小;② 映射区大小是页大小整数倍,尾部多余字节写不进文件;③ addr/offset 要求页对齐;④ 写入只读映射区触发 SIGSEGV,文件被截断后再访问映射区触发 SIGBUS;⑤ munmap 不会主动写回磁盘,需要时用 msync;⑥ 小数据量下不如 read/write 划算。
答:区别:① 粒度——flock 只能锁整个文件,fcntl 可锁文件的任意字节区域;② 类型——flock 只能建议性锁,fcntl 支持建议性与强制性锁(强制性锁需文件设置 S_ISGID 且清除 S_IXGRP);③ fcntl 提供 F_GETLK 测试、F_SETLKW 阻塞等待,功能更丰富。
fork/dup 表现:① fork() 出的子进程不继承父进程的文件锁(否则父子可同时写同一文件,锁就失去意义);② dup()/dup2() 复制出的 fd 与原 fd 引用同一把锁,用任意一个都能解锁;③ 锁在文件描述符关闭时自动释放,且进程终止时全部释放;④ flock 下要所有引用该锁的 fd 都关闭才释放,而 fcntl 下任一相关 fd 关闭即可释放。
内容来源:《I.MX6U嵌入式Linux C应用编程指南》第十三章 高级I/O