4、MMU驱动开发基础:Linux内核中MMU相关数据结构、页表操作API、ioremap与mmap

好,咱们进入MMU驱动开发的核心环节。说实话,很多做驱动的兄弟一听到页表、ioremap就头大,觉得这是内核大佬才碰的东西。其实不然,你只要理解了几个关键数据结构和API,写起驱动来会顺手很多。

我个人习惯把MMU驱动开发分成三块:数据结构(你得知道内核怎么描述页表)、页表操作API(怎么改页表)、地址映射(ioremap和mmap怎么用)。咱们一个一个来。

4.1 内核中MMU相关的核心数据结构

先看数据结构。Linux内核用四级页表来管理虚拟地址到物理地址的映射。嗯,这里要注意,不同架构可能不一样,比如ARM64就是四级,x86_32是两级。但内核抽象得很好,你写驱动时不用关心具体几级。

关键结构体就这几个:

结构体 作用 我在项目中踩过的坑
pgd_t 页全局目录项 别直接操作它,用API
pud_t 页上级目录项 ARM64上可能为空
pmd_t 页中间目录项 大页映射时要注意
pte_t 页表项 权限位设置别搞错

你想想看,内核为什么要搞这么多层?说白了就是为了节省内存。如果只用一级页表,4GB地址空间需要4MB的页表内存,太浪费了。四级页表按需分配,省得很。

核心要点:驱动开发中你基本只跟 pte_t 打交道,其他三级内核帮你管好了。别自己去遍历pgd,那是内核的活。

4.2 页表操作API:你真正要用的函数

好,数据结构看完了,咱们看看怎么操作它们。我刚开始写驱动时,总想着直接读写页表内存,结果内核直接oops了。后来学乖了,用内核提供的API。

常用的页表操作API有这些:

  • 页表遍历类pgd_offset()pud_offset()pmd_offset()pte_offset_map()
  • 页表修改类set_pte()pte_clear()mk_pte()
  • 属性查询类pte_present()pte_write()pte_dirty()
  • 地址转换类virt_to_phys()phys_to_virt()__pa()__va()

举个例子,你想把某个虚拟地址的页表项改成只读:

// 获取当前进程的页表
pgd_t *pgd = pgd_offset(current->mm, vaddr);
pud_t *pud = pud_offset(pgd, vaddr);
pmd_t *pmd = pmd_offset(pud, vaddr);
pte_t *pte = pte_offset_map(pmd, vaddr);

// 修改页表项为只读
set_pte(pte, pte_wrprotect(*pte));

// 刷TLB,不然CPU还用旧缓存
flush_tlb_page(vma, vaddr);

pte_unmap(pte);

我曾经在项目里忘了刷TLB,结果调试了整整两天。你想想看,页表改了,但CPU的TLB里还存着旧的映射,读到的数据全是错的。所以记住:改完页表一定要刷TLB

避坑指南:我曾经在SMP系统上只刷了本地TLB,没刷其他核的,导致数据不一致。多核环境下请用 flush_tlb_all()flush_tlb_mm()

4.3 ioremap:把外设寄存器映射到内核空间

做驱动开发,ioremap是你最常用的函数之一。说白了,就是把物理地址的外设寄存器映射到内核虚拟地址空间,这样你就能用指针读写寄存器了。

用法很简单:

void __iomem *reg_base;

// 将物理地址0x01C20800开始的4KB映射到内核空间
reg_base = ioremap(0x01C20800, SZ_4K);
if (!reg_base) {
    pr_err("ioremap failed\n");
    return -ENOMEM;
}

// 读写寄存器
writel(0x1234, reg_base + 0x10);  // 写
val = readl(reg_base + 0x20);     // 读

// 使用完后取消映射
iounmap(reg_base);

这里有几个要点:

  • readl/writel 而不是直接解引用:直接解引用可能因为编译优化或乱序执行出问题
  • 映射大小要对齐:我建议至少映射4KB,有些硬件要求页对齐
  • 检查返回值:ioremap可能失败,别假设它一定成功

个人经验:如果你要映射的物理地址是RAM,别用ioremap,用 memremap()。ioremap会关掉缓存,适合寄存器;memremap保留缓存,适合内存。

4.4 mmap:把内核内存映射到用户空间

mmap是另一个重要话题。它让用户空间的程序能直接访问内核分配的内存,省去了read/write的系统调用开销。

驱动里实现mmap的典型代码:

static int my_mmap(struct file *filp, struct vm_area_struct *vma)
{
    unsigned long size = vma->vm_end - vma->vm_start;
    unsigned long pfn;

    // 获取物理页帧号
    pfn = virt_to_phys(my_buffer) >> PAGE_SHIFT;

    // 建立映射
    if (remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot)) {
        pr_err("remap_pfn_range failed\n");
        return -EAGAIN;
    }

    return 0;
}

嗯,这里要注意几个坑:

  • 只能映射物理连续的内存:如果你用kmalloc分配的内存,没问题。但vmalloc分配的是虚拟连续物理不连续,不能直接mmap
  • 权限控制:用户空间能读写什么,由vma->vm_page_prot决定
  • 大小限制:别映射太大,一般不超过几MB

我曾经遇到一个案例:用户程序mmap后直接写数据,驱动这边用DMA读数据,结果数据总是不对。查了半天发现是缓存一致性问题。解决办法是在mmap时设置 pgprot_noncached()

vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);

这样就能保证CPU和DMA看到的数据是一致的。

4.5 知识体系总览

说了这么多,咱们用一张图来总结一下MMU驱动开发的整体脉络:

MMU驱动开发知识体系 数据结构 页表操作API 地址映射 pgd_t / pud_t / pmd_t / pte_t 四级页表结构 页表项权限位 pgd_offset / pte_offset_map set_pte / pte_clear / mk_pte flush_tlb_* 刷TLB ioremap / iounmap mmap / remap_pfn_range 缓存一致性处理 核心原则 1. 用API操作页表,别直接读写内存 2. 改完页表必须刷TLB 3. 注意缓存一致性,该noncached时就noncached

这张图把咱们今天讲的内容串起来了。数据结构是基础,API是工具,地址映射是实战。三者缺一不可。

最后说一句,MMU驱动开发其实没那么神秘。你只要记住:别直接操作页表内存,用API;改完页表刷TLB;ioremap用readl/writel;mmap注意缓存一致性。做到这几点,基本不会出大问题。

调试小技巧:如果你怀疑页表有问题,可以在内核里打开 CONFIG_DEBUG_PAGEALLOCCONFIG_DEBUG_VM,内核会帮你检查页表操作是否合法。我每次调试新硬件都会开这两个选项。


专注资料整理