kernel space 与 user space 间如何实现 demand paging 共享内存?
结论
普通内核模块没有通用接口,能在预留一段 kernel virtual address 后,等 softirq 写入并触发 page fault 时再自动分配物理页。启用 PREEMPT_RT 也不会增加这种机制。
这个日志场景可以采用两种设计:
- 如果需要在 softirq 中直接写日志,就提前分配一个有容量上限的共享 ring buffer,让写入路径只访问已准备好的内存。
- 如果希望按需分配物理页,就用页数组管理共享内存,在允许睡眠的上下文中显式分配页面,再通过用户态 VMA 的
fault回调按需建立映射。
按需分配物理页、按需建立用户态映射,以及内核写入时触发缺页分配,是三种不同的机制。
为什么 PREEMPT_RT 不能直接解决
在普通内核中,softirq 不能执行可能睡眠的操作。GFP_KERNEL 分配可能触发直接内存回收,因此要求调用上下文允许睡眠。内存分配指南
PREEMPT_RT 会将 softirq 等执行路径线程化,但具体位置能否阻塞,仍取决于调用路径和持锁状态。例如,禁用中断、禁用抢占或持有 raw_spinlock_t 的临界区仍有严格限制,不能仅凭 CONFIG_PREEMPT_RT=y 就调用任意可能睡眠的函数。PREEMPT_RT 的执行上下文与分配限制
即使某条 RT 路径允许阻塞,模块也不会因此获得内核地址的缺页回调接口。vm_operations_struct.fault 属于进程的 VMA,用于处理该映射的缺页。内核通过另一个地址写入共享物理页时,不会调用用户态 VMA 的 fault 回调。VMA 回调规则
内核通过合法的 uaccess 接口访问用户地址时,可能触发用户映射的缺页处理,但这依赖正确的进程上下文等条件,不能据此在 softirq 中直接向某个进程的地址写日志。
get_vm_area() 和 vmalloc() 分别解决什么
如果目标内核没有向模块导出 get_vm_area(),模块就不能直接链接使用它。即使能预留内核虚拟地址,后续仍需实现物理页分配、页表建立及同步。预留地址本身不会注册缺页分配处理器。
vmalloc() 会分配物理页,并将它们映射到连续的内核虚拟地址,不提供“首次写入时才分配页面”的语义。
预分配共享缓冲区时,可以在进程上下文中使用:
buffer = vmalloc_user(size);
随后在设备的映射处理函数中调用 remap_vmalloc_range(),将缓冲区映射给用户态。vmalloc_user() 会清零内存,适合映射给用户态,但此时物理页已经分配。vmalloc 与用户态映射 API
softirq 直接写日志:先实现固定容量的共享环
如果目标是学习共享内存 IPC,建议先按以下流程实现:
- 用户程序打开字符设备,驱动在进程上下文中分配共享缓冲区。
- 用户程序通过
mmap(..., MAP_SHARED, ...)建立映射。 - softirq 将日志写入已分配的环形缓冲区。
- 用户程序根据生产位置读取日志,通过
poll()等方式等待新数据。
需要明确缓冲区满时的处理方式:丢弃新日志、覆盖旧日志,或记录丢失数量。softirq 写入路径不能依赖用户程序及时消费,也不应等待内存回收。
这种设计可以将数据直接写入用户态可读取的页面。它是否比现有 Netlink 实现更快,仍取决于日志大小、批量处理方式、通知频率及同步开销。如果没有实际性能问题,可以继续保留现有 Netlink 实现。
需要按需分配时:共享页数组,加用户态 fault
如果希望避免一次性分配整个缓冲区,可以先创建页面索引表,将每个条目初始化为 NULL。
内核写入方和用户态读取方使用同一张表:
- 内核 worker 写入某页前,显式检查该页是否存在,尚未分配时先分配。
- 用户态首次访问某页时,
fault回调查询对应条目,必要时分配页面,然后返回该页。 - 页面分配后,在共享对象存活期间保持有效。
这样无需在内核侧预留一整段连续虚拟地址。内核可以逐页访问,用户态仍能看到连续的虚拟地址范围。
如果日志产生于 softirq,可以先将记录放入预分配的暂存队列,再交给普通 threaded workqueue 或内核线程分配页面、写入共享区。WQ_BH 的回调仍在 softirq context 中运行,不能睡眠,因此不能承担这个任务。暂存队列也必须有容量上限和溢出策略。Workqueue 文档
核心代码示例
示例只包含页分配和用户态缺页处理的核心逻辑,不是完整模块。使用时需满足以下条件:
pages已分配并清零,lock已通过mutex_init()初始化;nr_pages在对象存活期间保持不变;- 页面不会被并发释放;
- 分配函数只从允许睡眠的上下文调用。
#include <linux/mm.h>
#include <linux/mutex.h>
struct log_pool {
struct mutex lock;
struct page **pages;
unsigned long nr_pages;
};
/* 返回借用的页面指针;页面的初始引用由 log_pool 持有。 */
static struct page *log_get_or_alloc_page(struct log_pool *pool,
unsigned long index)
{
struct page *page;
if (index >= pool->nr_pages)
return NULL;
mutex_lock(&pool->lock);
page = pool->pages[index];
if (!page) {
page = alloc_page(GFP_KERNEL_ACCOUNT | __GFP_ZERO);
if (page)
pool->pages[index] = page;
}
mutex_unlock(&pool->lock);
return page;
}
static vm_fault_t log_fault(struct vm_fault *vmf)
{
struct log_pool *pool = vmf->vma->vm_private_data;
struct page *page;
/* vmf->pgoff 已包含映射的文件偏移。 */
if (vmf->pgoff >= pool->nr_pages)
return VM_FAULT_SIGBUS;
page = log_get_or_alloc_page(pool, vmf->pgoff);
if (!page)
return VM_FAULT_OOM;
/* 为交给 VM 的页面增加引用。 */
get_page(page);
vmf->page = page;
return 0;
}
static const struct vm_operations_struct log_vm_ops = {
.fault = log_fault,
};
设备映射处理函数需要检查映射类型、长度和文件偏移,并设置:
vma->vm_private_data = pool;
vma->vm_ops = &log_vm_ops;
还应设置合适的 VMA 标志,例如 VM_DONTEXPAND。如果日志数据区只允许用户读取,应拒绝可写映射,并防止用户后续通过 mprotect() 增加写权限。修改 VMA 标志时,需要使用与目标内核版本相适配的接口。
内核 worker 写入时,先调用同一个 log_get_or_alloc_page(),再用 kmap_local_page() 获取临时内核映射,完成页内写入后调用 kunmap_local()。临时映射地址只能在获取它的上下文中使用,不能保存后交给其他 worker 或 softirq。临时页面映射规则
物理页可能由内核写入方先分配,也可能由用户态缺页处理先分配。内核写入方必须显式取得页面,不能直接解引用尚未映射的内核地址来触发上述回调。
实现时还要处理什么
页面存在,不代表日志已经写完
示例中的 mutex 只保护页面分配和页数组,不能同步日志内容。
写入方需要先写完整记录,再发布生产位置;读取方则需要使用匹配的 acquire 语义读取生产位置,然后读取记录。跨内核与用户态的共享协议还需要约定字段宽度、对齐和原子访问方式,单靠 volatile 无法完成同步。
单生产者、单消费者的 ring buffer 可以参考内核的内存屏障示例。如果多个 CPU 都会产生日志,需要增加生产者同步,或者使用 per-CPU 缓冲区。环形缓冲区文档
映射可能比文件描述符活得更久
用户关闭设备文件描述符后,已有映射仍可能存在。共享对象、页数组、页面和回调代码都必须保持有效。
完整实现需要管理 VMA 和生产者的引用,并处理 fork() 后的映射。只有停止日志来源、等待 worker 退出且所有相关引用释放后,才能回收资源,不能在文件的 release() 中无条件释放共享对象。
延迟分配不等于可回收
示例中的页面由驱动持有引用,不会因为用户暂时不读就自动换出或释放。按需分配节省的是尚未访问页面所需的内存,仍需设置分配上限并处理分配失败。
对于持续写入的日志环,页面通常最终都会被访问。预分配一个有界缓冲区,往往更容易控制写入延迟;按需分配更适合研究稀疏访问或允许延后处理的场景。
备注:内容仅供参考。
