正文
pipe_buffer
{
struct
page
*page;
unsigned
int
offset, len;
const
struct
pipe_buf_operations
*ops;
unsigned
int
flags;
unsigned
long
private
;
};
管道数据存储在离散的物理内存页中,通过
struct pipe_buffer
数组(
bufs
)管理:
◆
bufs
数组
:数组中的每个元素对应一个内存页(struct page),通过page字段直接指向物理页帧。这样,内核可以直接定位到存储管道数据的物理内存位置。
◆
非连续内存管理
:页之间无需连续,内核通过数组索引实现逻辑上的环形链表。这种非连续内存管理方式,充分利用了内存空间,避免了因连续内存分配困难而导致的资源浪费。在进行数据读写时,内核根据head和tail指针在bufs数组中的索引,找到对应的缓冲区进行操作,同时通过环形链表的逻辑,实现数据的循环读写。例如,当head指针到达数组末尾时,下一次写入会回到数组开头,继续填充缓冲区。
管道本质是一个由内核维护的环形缓冲区,通过
head
和
tail
指针实现高效的数据读写:
可以看一个Pipe缓冲区的实际示意图:
这张图片展示了一个
pipe
的基本数据结构,具体是如何通过循环缓冲区(circular buffer)来管理数据传输。
或者参考一下这个结构图:
◆
pipe->
bufs[0]
** 到 pipe->**
bufs[15]
:这是管道的 16 个缓冲区,每个缓冲区对应一个
pipe_buffer
结构体。
◆
pipe->tail 和 pipe->head
:
pipe->tail
指向当前读取位置,
pipe->head
指向当前写入位置。缓冲区中的黄色区域表示当前正在被使用的缓冲区(
inuse
),即当前正在读取或写入的部分。
◆
页面管理
:每个
pipe_buffer
结构体对应一个 4KB 的页面,图中显示了这些页面的分布情况,并标记了哪些部分是正在被使用的。
讲解Linux管道Pipe如何进行数据写入和读取
当我们使用read和write向pipe进行数据写入和读取的时候,read和write会寻找到pipe_write和pipe_read进行数据写入和读取!
根据前面的管道结构体的讲解可知,pipe_write和pipe_read进行数据操作的时候实际都是对pipe->buf的内容进行写入和读取!
pipe_write写入流程
数据写入管道的操作由内核中的pipe_write函数负责。在数据写入过程中,pipe_write会调用copy_page_from_iter函数来完成从用户空间到内核管道缓冲区的实际数据复制。下面对pipe_write函数的执行流程进行详细拆解:
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
struct file *filp = iocb->ki_filp;
struct pipe_inode_info *pipe = filp->private_data;
...
head = pipe->head;
...
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
...
ret = copy_page_from_iter(buf->page, offset, chars, from);
...
struct pipe_buffer *buf = &pipe->bufs[head & mask];
...
pipe->head = head + 1;
...
buf = &pipe->bufs[head & mask];
buf->page = page;
buf->ops = &anon_pipe_buf_ops;
buf->offset = 0;
buf->len = 0;
...
if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
pipe->tmp_page = NULL;
...
copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
...
return ret;
}
写入流程
:数据按页写入
bufs[head]
,更新
head
指针;若缓冲区满,写进程进入睡眠。
在
pipe_write
函数写入数据过程中,获取管道的写指针head,通过head & mask的运算,在pipe->bufs数组中定位当前用于写入的缓冲区buf。这里的mask是根据管道缓冲区总数计算得出的掩码,用于实现环形缓冲区的循环访问。最后调用copy_page_from_iter函数,将用户空间的数据从from迭代器中复制到内核分配的页面中,完成数据写入操作。
写入标记
:
if (is_packetized(filp))
buf->flags = PIPE_BUF_FLAG_PACKET;
else
buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
可以发现这里当第一次向管道写入数据的时候会将
pipe->bufs[i]->flags
字段赋值为PIPE_BUF_FLAG_CAN_MERGE,如果是网络数据通过pipe传输的话就会赋值PIPE_BUF_FLAG_PACKET;
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
...
ret = copy_page_from_iter(buf->page, offset, chars, from);
如果想继续在管道写入数据会首先检查buf->flags字段和buf->page是否有剩余空间,再次调用pipe_write可以继续向这个buf->page写入数据!
pipe_read输出流程
数据从管道中读取的操作由内核中的pipe_read函数负责。在读取过程中,pipe_read会调用copy_page_to_iter函数来完成从内核管道缓冲区到用户空间的实际数据复制。下面对pipe_read函数的执行流程进行详细拆解:
static ssize_t
pipe_read(struct kiocb *iocb, struct iov_iter *to)
{
size_t total_len = iov_iter_count(to);
struct file *filp = iocb->ki_filp;
struct pipe_inode_info *pipe = filp->private_data;
...
unsigned int head = pipe->head;
unsigned int tail = pipe->tail;
unsigned int mask = pipe->ring_size - 1
;
if (!pipe_empty(head, tail)) {
struct pipe_buffer *buf = &pipe->bufs[tail & mask];
...
written = copy_page_to_iter(buf->page, buf->offset, chars, to);
...
ret += chars;
buf->offset += chars;
buf->len -= chars;
...
tail++;
pipe->tail = tail;
...
return ret;
}
读取流程
:从
bufs[tail]
读取数据,更新
tail
指针;若缓冲区空,读进程阻塞。
获取管道的读指针tail,通过tail & mask的运算,在pipe->bufs数组中定位当前用于读取的缓冲区buf。再调用copy_page_to_iter函数,将缓冲区buf中的数据从指定偏移量buf->offset开始,复制chars字节到用户空间的目标迭代器to中。最后将缓冲区的偏移量buf->offset向后移动已读取的字节数,减少缓冲区中剩余的有效数据长度buf->len。将读指针tail向后移动一位,并更新管道的读指针pipe->tail。
读取操作的通俗作用
:可以将管道的内容读取出来,并且每次读取都可以算作清理管道数据!
Page cahce : Linux内核page cache机制
Linux内核的
Page Cache机制
是操作系统中用于提升磁盘I/O性能的核心组件,它通过将磁盘数据缓存在内存中,减少对慢速磁盘的直接访问。以下是对其工作原理和关键特性的详细解释:
什么是Page Cache?
◆
定义
:Page Cache是内核管理的一块内存区域,用于缓存磁盘上的文件数据块(以内存页为单位,通常4KB)。
◆
目标
:通过内存缓存加速对磁盘数据的读写操作,利用内存的高速特性弥补磁盘的延迟缺陷。
◆
缓存内容
:普通文件、目录、块设备文件等。
Page Cache的工作原理
读操作
1.
缓存命中
: 当应用程序读取文件时,内核首先检查数据是否在Page Cache中。若存在(缓存命中),直接返回内存中的数据,
无需访问磁盘
。
2.
缓存未命中
: 若数据不在缓存中,内核从磁盘读取数据,存入Page Cache,再拷贝到用户空间。后续访问同一数据时可直接使用缓存。
写操作
1. 缓冲写入(Writeback)
:
当一个文件已经被打开过,那么应用程序的写操作默认修改的是Page Cache中的缓存页,而非直接写入磁盘。
只在特定情况下,内核通过**延迟写入(Deferred Write)策略,将脏页(被修改的页)异步刷回磁盘(由
pdflush
或
flusher
线程触发)。
优点
:合并多次小写入,减少磁盘I/O次数。
风险
:系统崩溃可能导致数据丢失(需通过
fsync()
或
sync()
强制刷盘)。
2. 直写(Writethrough)
: 某些场景(如要求强一致性)会同步写入磁盘,但性能较低(较少使用)。
相关资料:
◆Linux内核Page Cache和Buffer Cache关系及演化历史 - CharyGao - 博客园
◆深入理解Linux 的Page Cache-page cache
◆一文看懂 | 什么是页缓存(Page Cache)_pagecache-CSDN博客
syscall splice : Linux中的零拷贝机制源码讲解
1. 零拷贝机制概述
传统的文件拷贝过程(open()→read()→write())需要在用户态和内核态之间多次切换,并进行 CPU 和 DMA 之间的数据拷贝,开销较大。而利用 splice 系统调用可以实现内核态内的“零拷贝”,只进行少量的上下文切换,从而极大提高数据传输效率。
传统拷贝:
4次上下文切换、2次 CPU 拷贝、2次 DMA 拷贝
最简单的,就是open()两个文件,然后申请一个buffer,然后使用read()/write()来进行拷贝。但这样效率太低,原因是一对read()和write()涉及到4次上下文切换,2次CPU拷贝,2次DMA拷贝。
splice 零拷贝:
只需2次上下文切换
再dirty_pipe使用splice进行0拷贝的话就可以实现极高的效率,只需要两次上下文切换即可完成拷贝!
2. splice 系统调用实现流程
为了理解 splice 零拷贝的内部实现,我们可以通过动态调试定位到关键函数
copy_page_to_iter_pipe
。在该函数设置断点,并使用 gdb 查看调用栈,可以看到整个 splice 的调用链条。调用栈大致分为以下几个层次:
可以很快发现整个splice的调用链!
在
SYSCALL_DEFINE6(splice, ...)
中,主要完成文件描述符转换、参数合法性检查,并调用
do_splice
进行实际的数据处理。
SYSCALL_DEFINE6(splice, int, fd_in, loff_t __user *, off_in,
int, fd_out, loff_t __user *, off_out,
size_t, len, unsignedint, flags)
{
struct fd in, out;
...
if (in.file) {
...
if (out.file) {
error = do_splice(in.file, off_in, out.file, off_out,
len, flags);
...
}
2.1 do_splice 函数
longdo_splice(struct file *in, loff_t __user *off_in,
struct file *out, loff_t __user *off_out,
size_t len, unsignedint flags)
{
struct pipe_inode_info *ipipe;
struct pipe_inode_info *opipe;
...
ipipe = get_pipe_info(in, true);
opipe = get_pipe_info(out, true);
if (ipipe && opipe) {
...
}
if (ipipe) {
....
}
if (opipe) {
....
ret = wait_for_space(opipe, flags);
if (!ret) {
...
ret = do_splice_to(in, &offset, opipe, len, flags);
}
...
else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
ret = -EFAULT;
...
}
根据输入和输出的文件是否与 pipe 相关,选择不同的处理分支:
◆
pipe → pipe:
直接调用
splice_pipe_to_pipe
进行管道间数据传递。
◆
pipe → 文件:
走
do_splice_from
,处理从 pipe 写入文件的情况。
◆
文件 → pipe:
走
do_splice_to
,处理从文件读取数据填充到 pipe。
在 dirty_pipe 漏洞中,重点就在文件 → pipe 的场景,因为利用了 splice 复制过程中对管道内部管理机制的不足,才使得漏洞得以被利用。
2.2 do_splice_to 函数
该函数验证读取权限,检查长度,之后调用文件操作中实现的
splice_read
。如果文件操作没有自定义该接口,则使用
default_file_splice_read
。
staticlongdo_splice_to(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsignedint flags){
int ret;
if (unlikely(!(in->f_mode & FMODE_READ)))
return -EBADF;
ret = rw_verify_area(READ, in, ppos, len);
if (unlikely(ret 0))
return ret;
if (unlikely(len > MAX_RW_COUNT))
len = MAX_RW_COUNT;
if (in->f_op->splice_read)
return in->f_op->splice_read(in, ppos, pipe, len, flags);
return default_file_splice_read(in, ppos, pipe, len, flags);
}
这里的关键是
in->f_op->splice_read
,此处调用的
generic_file_splice_read
来从文件中读取页面,并填充到管道中。
也可以通过动态调试来定位
in->f_op->splice_read
调用的是什么函数:
p *((struct file *) in->f_op->splice_read)
如何通过动态调试定位源码:
pwndbg> p in->f_op->splice_read
$1 = (ssize_t (*)(struct file *, loff_t *, struct pipe_inode_info *, size_t,
unsignedint)) 0xffffffff8120fd20
pwndbg> p in->f_op
$2 = (conststruct file_operations *) 0xffffffff82027600
2.3 generic_file_splice_read 函数
ssize_tgeneric_file_splice_read(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsignedint flags){
struct iov_iter to;
struct kiocb kiocb;
unsignedint i_head;
...
iov_iter_pipe(&to, READ, pipe, len);
i_head = to.head;
...
ret = call_read_iter(in, &kiocb, &to);
if (ret > 0) {
*ppos = kiocb.ki_pos;
file_accessed(in);
...
return ret;
}
EXPORT_SYMBOL(generic_file_splice_read);
该函数内部构造了一个 pipe 的迭代器
iov_iter
,然后通过调用
call_read_iter
实际执行数据读取操作。读取成功后会更新文件位置并调用
file_accessed
更新访问时间。
staticinline ssize_tcall_read_iter(struct file *file, struct kiocb *kio,
struct iov_iter *iter){
return file->f_op->read_iter(kio, iter);
}
可以发现调用了call_read_iter函数最后也可以通过动态调试定位到函数generic_file_read_iter.
2.4 generic_file_read_iter
/**
* generic_file_read_iter - 通用文件系统读取例程
* @iocb: 内核I/O控制块
* @iter: 数据读取的目标迭代器
*
* 这是所有能直接使用页缓存的文件系统的"read_iter()"例程
*
* iocb->ki_flags中的IOCB_NOWAIT标志表示当无法立即读取数据时应返回-EAGAIN
* 但它不会阻止预读操作
*
* iocb->ki_flags中的IOCB_NOIO标志表示不应为读取或预读发起新I/O请求
* 当没有数据可读时返回-EAGAIN。当会触发预读时,返回可能为空的部分读取结果
*
* 返回值:
* * 复制的字节数(即使是部分读取)
* * 如果没有读取任何数据则返回负错误码(如果设置了IOCB_NOIO则可能返回0)
*/
ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
size_t count = iov_iter_count(iter); /* 获取要读取的总字节数 */
ssize_t retval = 0; /* 初始化返回值 */
...
retval = generic_file_buffered_read(iocb, iter, retval); /* 执行缓冲读取 */
...
}
EXPORT_SYMBOL(generic_file_read_iter); /* 导出符号供内核模块使用 */
generic_file_read_iter
是所有能够直接利用页缓存的文件系统的通用读取例程。该函数处理直接 I/O 与缓冲读取的场景,确保在非阻塞或阻塞模式下都能正确返回数据或错误码。
2.5 generic_file_buffered_read