计算机相关 · 2026年9月5日 0

一个优雅的页面管理机制——读CortenMM论文笔记

背景

最近拜读了田老师作为联合通讯作者的一篇论文,《CortenMM: Efficient Memory Management with Strong Correctness Guarantees》,感觉其中的内存管理机制非常优秀,遂想要展开讲讲。

传统上,Linux内核中,内存管理存在很多层抽象:

  1. 物理内存管理:伙伴系统、slab/slub;
  2. 虚拟内存:VMA和页表,其中VMA的存在是为了解决高级页面管理功能,例如换入/换出;
  3. 其他封装:malloc/页缓存/大页/……

CortenMM主要解决虚拟内存这一层的问题,通过简化这一层抽象来大幅提升性能,同时提升可靠性。

传统Linux内核页面管理机制的不足

Linux内核中,除了硬件用的硬件页表,还有一个内核用的VMA,可以理解为软件层的地址空间抽象。VMA是为了解决高级页面管理功能需要的硬件也表示不了的东西而存在的,但它把硬件页表已经能表示的东西(权限等)又表示了一遍。证据在include/linux/mm.h:

#define INIT_VM_FLAG(name) BIT((__force int) VMA_ ## name ## _BIT)
// 下面这一堆明显是页表权限,这在页表的元数据位中已经有了
#define VM_READ INIT_VM_FLAG(READ)
#define VM_WRITE INIT_VM_FLAG(WRITE)
#define VM_EXEC INIT_VM_FLAG(EXEC)
#define VM_SHARED INIT_VM_FLAG(SHARED)
// 下面这一堆就不是了
#define VM_MAYREAD INIT_VM_FLAG(MAYREAD)
#define VM_MAYWRITE INIT_VM_FLAG(MAYWRITE)
#define VM_MAYEXEC INIT_VM_FLAG(MAYEXEC)
#define VM_MAYSHARE INIT_VM_FLAG(MAYSHARE)
#define VM_GROWSDOWN INIT_VM_FLAG(GROWSDOWN)
// 其他

以及mm/vma.h:

struct vm_area_struct {
// ...
   // 下面这一行注释,我严重怀疑是不是因为锁机制太复杂才不让直接上手的
   /*
* Flags, see mm.h.
* To modify use vm_flags_{init|reset|set|clear|mod} functions.
* Preferably, use vma_flags_xxx() functions.
*/
union {
/* Temporary while VMA flags are being converted. */
const vm_flags_t vm_flags;
vma_flags_t flags;
};
   // ...
}

这导致了两个问题。首先,你在修改页表权限之类的时,必须同时在两个地方修改,而这就引入了数据竞争(另一个内核线程可能也在改,你改两个地方中间这段时间可能有一个地方又被改了)。为了避免数据竞争,Linux内核引入了一套我看晕了的锁机制,可以去看看中文文档的mm/process_addrs.rst,里面讲的很详细。但,这又导致了很严重的性能问题,CortenMM论文中说它是Linux上一个严重的瓶颈,例如对于Android App而言。现在Linux社区引入了细粒度锁处理这个问题,但是效果有限。

CortenMM的解决方案

CortenMM提出了一套解决方案:

  1. 减少一层抽象:硬件页表能存放的,绝不存进VMA;也不再存在一棵独立的、按区间索引的软件树,而是通过简单的计算直接算出地址。这使得无需同时锁多个地方,降低了复杂性的同时减少了竞态风险;
  2. 更高效的锁协议:无论如何,在具体的读写进行的时候仍然要加锁。CortenMM 提供两种锁协议:读写锁版和基于 RCU 的无锁遍历版;由于不重复存储数据,无需复杂的锁协调,真实负载下性能提升 1.2~26 倍,其中仅读写锁协议版本就达 15 倍。
  3. 定义一组事务性的原子操作处理页表。

下面逐条拆开说。

首先,减少一层抽象。CortenMM在虚拟内存这一层开刀,删除了VMA也就是软件页表,一片数据只存储一个地方,要么存在硬件页表,要么存储在单独的元数据数组里。每个页表页配一个 page descriptor,存这把锁和指向 per-PTE metadata 数组的指针。descriptor 放在一个启动时分配的平坦数组里,按页表页的物理页号索引paddr >> 12)——和 Linux 用 mem_map 数组按 PFN 索引 struct page 是同一个手法。per-PTE metadata 数组按需分配,用 PTE 在页表页内的偏移索引。页表页本身保持纯硬件格式,一个字节不多。

其次,锁协议。论文中给出了两种锁协议:

  • CortenMMrw:给每一级页表加读锁,给最后要修改的那一级页表加写锁;
  • CortenMMadv:读取时不加锁,用原子读操作直接执行读取任务;然后在最后要修改的那一级和它的所有下级加写锁。

问题:遍历页表时不加读锁,遍历完的页表节点被改了怎么办?

首先,因为上级页表项的任务仅仅是提供下级页表项的位置,如果是元数据被修改,问题不大。可能出现问题的情况是对页表树本身的增删。这引出了第三点:对页表的读写是事务内的原子操作,这保证了不可能创建到一半然后被打断。

还可能出现问题的三种情况,我们分开说:

  1. 目标页表遍历到以后、加锁前整个被删除:读到新值 NULL → 停在父页加锁,排队等加锁成功,之后或是返回或是重建;读到旧值 → 走进一个正在被回收的页 → stale 救场
  2. 目标页表原本是NULL,遍历到前被创建:遍历读到旧值 NULL → 就地停下,把当前页当 covering page 加锁。这没问题,因为创建子页表的人必然持有父页的写锁(创建是事务内操作),你锁父页就会和他串行化——你要么等他建完再进,要么先拿到锁、在你的 DFS 阶段把新建的下级一起锁上;
  3. T2 原子读到了指向 z 的指针,正准备 lock(z) 的瞬间,T1 把 z 摘走、释放、内存被复用——T2 锁的是一块已经不是页表页的内存:这是最麻烦的一种情况。RCU 宽限期保证内存有效性,被摘的页表页在 monitor 里等到所有读者退出临界区才真正释放,所以你 lock 它、读它的 stray 标志是安全的访存;而且遍历、加锁、stray 检查全部发生在 RCU 读侧临界区内——这是刻意的。

我翻了翻星绽ostd的实现,找出了相关的代码:

/// A smart pointer to a page table node.
///
/// This smart pointer is an owner of a page table node. Thus creating and
/// dropping it will affect the reference count of the page table node. If
/// dropped it as the last reference, the page table node and subsequent
/// children will be freed.
///
/// [`PageTableNode`] is read-only. To modify the page table node, lock and use
/// [`PageTableGuard`].
pub(crate) type PageTableNode<C> = Frame<PageTablePageMeta<C>>;

impl<C: PageTableConfig> PageTableNode<C> {
   pub(super) fn level(&self) -> PagingLevel {
       self.meta().level
  }

   /// Allocates a new empty page table node.
   pub(super) fn alloc(level: PagingLevel) -> Self {
       let meta = PageTablePageMeta::new(level);
       FrameAllocOptions::new()
          .zeroed(true)
          .alloc_frame_with(meta)
          .expect("Failed to allocate a page table node")
  }
   // ...  
}

FrameAllocOptions::alloc_frame_with的实现:

impl FrameAllocOptions {
// ...
/// Allocates a single untyped frame without metadata.
   pub fn alloc_frame(&self) -> Result<Frame<()>> {
       self.alloc_frame_with(())
  }

   /// Allocates a single frame with additional metadata.
   pub fn alloc_frame_with<M: AnyFrameMeta>(&self, metadata: M) -> Result<Frame<M>> {
       let single_layout = Layout::from_size_align(PAGE_SIZE, PAGE_SIZE).unwrap();
       let frame = get_global_frame_allocator()
          .alloc(single_layout)
          .map(|paddr| Frame::from_unused(paddr, metadata).unwrap())
          .ok_or(Error::NoMemory)?;

       if self.zeroed {
           let addr = paddr_to_vaddr(frame.paddr()) as *mut u8;
           // SAFETY: The newly allocated frame is guaranteed to be valid.
           unsafe { core::ptr::write_bytes(addr, 0, PAGE_SIZE) }
      }

       Ok(frame)
  }
   // ...
}

再往下翻,Frame::from_unused

impl<M: AnyFrameMeta> Frame<M> {
   /// Gets a [`Frame`] with a specific usage from a raw, unused page.
   ///
   /// The caller should provide the initial metadata of the page.
   ///
   /// If the provided frame is not truly unused at the moment, it will return
   /// an error. If wanting to acquire a frame that is already in use, use
   /// [`Frame::from_in_use`] instead.
   pub fn from_unused(paddr: Paddr, metadata: M) -> Result<Self, GetFrameError> {
       Ok(Self {
           ptr: MetaSlot::get_from_unused(paddr, metadata, false)?,
           _marker: PhantomData,
      })
  }

   /// Gets the metadata of this page.
   pub fn meta(&self) -> &M {
       // SAFETY: The type is tracked by the type system.
       unsafe { &*self.slot().as_meta_ptr::<M>() }
  }
}

再往下:

impl MetaSlot {
   /// Initializes the metadata slot of a frame assuming it is unused.
   ///
   /// If successful, the function returns a pointer to the metadata slot.
   /// And the slot is initialized with the given metadata.
   ///
   /// The resulting reference count held by the returned pointer is
   /// [`REF_COUNT_UNIQUE`] if `as_unique_ptr` is `true`, otherwise `1`.
   pub(super) fn get_from_unused<M: AnyFrameMeta>(
       paddr: Paddr,
       metadata: M,
       as_unique_ptr: bool,
  ) -> Result<*const Self, GetFrameError> {
       let slot = get_slot(paddr)?;

       // `Acquire` pairs with the `Release` in `drop_last_in_place` and ensures the metadata
       // initialization won't be reordered before this memory compare-and-exchange.
       slot.ref_count
          .compare_exchange(REF_COUNT_UNUSED, 0, Ordering::Acquire, Ordering::Relaxed)
          .map_err(|val| match val {
               REF_COUNT_UNIQUE => GetFrameError::Unique,
               0 => GetFrameError::Busy,
               _ => GetFrameError::InUse,
          })?;

       // SAFETY: The slot now has a reference count of `0`, other threads will
       // not access the metadata slot so it is safe to have a mutable reference.
       unsafe { slot.write_meta(metadata) };

       if as_unique_ptr {
           // No one can create a `Frame` instance directly from the page
           // address, so `Relaxed` is fine here.
           slot.ref_count.store(REF_COUNT_UNIQUE, Ordering::Relaxed);
      } else {
           // `Release` is used to ensure that the metadata initialization
           // won't be reordered after this memory store.
           slot.ref_count.store(1, Ordering::Release);
      }

       Ok(slot as *const MetaSlot)
  }
   // ...
}

一长串的调用很清晰了:通过物理地址查出slot(get_slot),然后写入元数据,读的时候也可以用相同的方式读取。

下面是元数据结构体:

/// The metadata of any kinds of page table pages.
/// Make sure the the generic parameters don't effect the memory layout.
#[derive(Debug)]
pub(crate) struct PageTablePageMeta<C: PageTableConfig> {
   /// The number of valid PTEs. It is mutable if the lock is held.
   nr_children: SyncUnsafeCell<u16>,
   /// If the page table is detached from its parent.
   ///
   /// A page table can be detached from its parent while still being accessed,
   /// since we use a RCU scheme to recycle page tables. If this flag is set,
   /// it means that the parent is recycling the page table.
   stray: SyncUnsafeCell<bool>,
   /// The level of the page table page. A page table page cannot be
   /// referenced by page tables of different levels.
   level: PagingLevel,
   /// The lock for the page table page.
   lock: AtomicU8,
   _phantom: PhantomData<C>,
}

对照了一下论文里的实现:

字段说明
lock: AtomicU8一个自旋锁,就是前面说的那个锁。
stray: SyncUnsafeCell<bool>这就是论文 Figure 6/7 里的 stale 标记!论文代码里写 page.stale = True、锁到之后检查 stale 就重试的那个机制,主线里改名成了 stray
nr_children: SyncUnsafeCell<u16>有效 PTE 计数——页表页什么时候空了可以被回收,靠它判断
level: PagingLevel层级不变量(”一个页表页不能被不同层级的页表引用”)——这就是论文里被形式化验证的 well-formedness invariant 的一部分
_phantom: PhantomData<C>泛型只承载 ISA 配置(PageTableConfig 区分 x86/ARM/RISC-V),零大小、不影响内存布局——论文 Figure 9 那个”用 Rust trait 隐藏 ISA 差异”的思路在这落地

事务逻辑:第二个核心贡献

回忆 Linux 的困境:page fault 处理程序的作者必须同时思考两件事——业务逻辑(这页该不该换入、要不要 COW)和并发控制(先拿哪把锁、要不要升级降级、expand VMA 时怎么办)。这两件事缠在一起,就是 CVE 的培养基。

CortenMM 的事务接口把这两件事劈开

  • 锁协议(CortenMMrw/adv)只负责一件事:给你一个区间,把该锁的页表页全锁好;
  • 基本操作只负责另一件事:在锁好的区间里读写状态;
  • 接口本身就是唯一入口——调用者感觉不到锁的存在,锁协议也感觉不到操作的存在。

观察一下所有内存管理操作的本质,你会发现它们无非是对”虚拟页状态机”的四种动作:

  • query:这页现在是什么状态?(Invalid?已映射?被换出?)
  • map:把一个物理页装进来;
  • mark:只改元数据不动物理页——mmap 挂个”已虚拟分配”的牌子、mprotect 改权限,都是这个;
  • unmap:撤掉一个区间。

任何复杂的完整操作(page fault、mprotect、madvise、页迁移)都是这四个操作的组合。论文的原话是这四个操作的组合”封装了操作 MMU 的所有方式”。

论文是这样实现的事务操作:

pub enum Status {
   Invalid,
   Mapped(PhysPage, Perm),
   PrivateAnon(Perm),
   PrivateFileMapped(File, Offset, Perm),
   Swapped(BlockDev, BlockNum, Perm),
   /* ...共享匿名页等 */
}

impl AddrSpace {
   pub fn lock(&self, r: Range<Vaddr>) -> RCursor;
}

impl RCursor {
   pub fn query(&mut self, addr: Vaddr) -> Status;              // 查状态
   pub fn map(&mut self, addr: Vaddr, page: PhysPage);          // 装映射
   pub fn mark(&mut self, range: Range<Vaddr>, status: Status); // 改状态(不动物理页)
   pub fn unmap(&mut self, range: Range<Vaddr>);                // 删区间
}

impl Drop for RCursor { fn drop(&mut self) { /*...*/ } }

因此我们可以这样调用:

{
let addr_space = AddrSpace::new();
let cursor = addr_space.lock(range); // 上锁
// 执行操作
}
// 离开作用域,锁自动释放

在这里,“事务”不是像数据库那样靠回滚实现,而是靠锁实现:如果可能出现竞态,根本加不上锁。这仍然是事务:要么全部执行,要么都不执行,没有中间态。

“在 VMA 层检查合法、在页表层行动”中间的窗口在这里不存在,因为所有操作在同一个事务里,无人能插进来。

事务也让形式化验证可行。锁协议和操作分开证——证明锁协议保证互斥,再证明每个基本操作的功能正确性,组合起来就是事务的正确性。如果像 Linux 那样缠在一起,证明状态空间会爆炸;外加语言层的兜底:其他所有代码 #![deny(unsafe_code)],safe Rust 没有内联汇编,想绕过事务接口直接改页表在类型系统里就编译不过。”唯一入口”不是团队公约,是编译器强制。

总结

这篇论文用最简洁、最不易出错的方法解决了Linux内核里的那个复杂的绕晕人的锁协议。“Less is more”,越简单的方式越难以出现问题。Linux内核的内存管理机制2023 年引入细粒度锁之后的两年内,10 个 CVE,其中 5 个可提权/信息泄露,跟它的复杂是分不开的。

当然这么做也有坏处:对于不是多级页表的MMU无能为力。没有银弹,所有的工程决策必然伴随着牺牲。