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驱动开发的整体脉络:
这张图把咱们今天讲的内容串起来了。数据结构是基础,API是工具,地址映射是实战。三者缺一不可。
最后说一句,MMU驱动开发其实没那么神秘。你只要记住:别直接操作页表内存,用API;改完页表刷TLB;ioremap用readl/writel;mmap注意缓存一致性。做到这几点,基本不会出大问题。
调试小技巧:如果你怀疑页表有问题,可以在内核里打开 CONFIG_DEBUG_PAGEALLOC 和 CONFIG_DEBUG_VM,内核会帮你检查页表操作是否合法。我每次调试新硬件都会开这两个选项。