4、vmalloc与ioremap:非连续内存分配、vmalloc原理、ioremap与MMIO

好,咱们来聊聊非连续内存分配。说实话,这是内核里一个挺有意思的话题。你想想看,物理内存就像一块大蛋糕,切着切着就碎了。碎片化是逃不掉的宿命。那怎么办?总不能每次都跟 buddy allocator 要一大块连续物理内存吧?

这时候,vmalloc 就派上用场了。它解决的核心问题很简单:虚拟地址连续,物理地址可以不连续。我当年刚接触内核时,总觉得这玩意儿有点“作弊”的感觉——明明物理内存东一块西一块,硬是让 CPU 觉得它们是挨着的。嗯,这其实就是虚拟内存的魅力所在。

vmalloc 的原理

vmalloc 的工作机制,说白了就是“页表魔术”。它会在内核的 vmalloc 区域(通常在 3GB 以上,具体看架构)分配一段连续的虚拟地址,然后去物理内存里找空闲页框,一页一页地映射过来。这些物理页框在内存里可能相隔十万八千里,但页表一改,CPU 就以为它们是邻居。

我画了一张图,帮你理解这个映射关系:

虚拟地址空间 (vmalloc 区域) 0xFFFF8000xxxx 连续虚拟地址 页表映射 物理内存 (碎片化) 页框A 页框C 页框E 页框B 页框D 页框F 虚拟地址连续,物理地址不连续

你看,虚拟地址是连续的 0xFFFF8000 开头,但物理页框 A、C、E 之间可能隔着大片的空闲或已用内存。这就是 vmalloc 的核心思想。

关键区别:kmalloc 分配的是物理连续内存,vmalloc 分配的是虚拟连续内存。kmalloc 快,但容易失败;vmalloc 慢,但抗碎片能力强。

vmalloc 的实现流程大致是这样的:

  1. 在 vmalloc 区域找到一块足够大的空闲虚拟地址
  2. 遍历物理内存,一页一页地分配(通过 alloc_page)
  3. 建立页表映射,把物理页框填进虚拟地址的页表项里
  4. 返回虚拟地址给调用者

代码层面,核心函数是 __vmalloc_node_range。我简化一下关键逻辑:

// 伪代码,展示 vmalloc 的核心流程
void *vmalloc(unsigned long size)
{
    struct vm_struct *area;
    struct page **pages;
    unsigned int nr_pages;

    // 1. 计算需要的页数
    nr_pages = PAGE_ALIGN(size) >> PAGE_SHIFT;

    // 2. 在 vmalloc 区域找空闲虚拟地址
    area = __get_vm_area_node(size, VM_ALLOC, VMALLOC_START, VMALLOC_END);

    // 3. 分配物理页框
    pages = kmalloc_array(nr_pages, sizeof(struct page *), GFP_KERNEL);
    for (i = 0; i < nr_pages; i++) {
        pages[i] = alloc_page(GFP_KERNEL | __GFP_HIGHMEM);
    }

    // 4. 建立页表映射
    map_vm_area(area, prot, pages);

    return area->addr;
}

这里有个细节:vmalloc 会睡眠。因为它可能触发内存回收,所以不能在中断上下文里调用。我曾经在写一个网络驱动时,不小心在软中断里用了 vmalloc,结果系统直接 oops 了。嗯,血的教训。

注意:vmalloc 分配的内存,访问速度比 kmalloc 慢。因为每次访问都可能触发 TLB 未命中,需要查页表。所以,性能敏感路径上别用 vmalloc。

ioremap 与 MMIO

聊完 vmalloc,咱们再来看 ioremap。这俩其实是一对兄弟——vmalloc 映射 RAM,ioremap 映射 I/O 内存。

什么是 I/O 内存?说白了,就是外设的寄存器或缓冲区。比如网卡的 DMA 缓冲区、显卡的显存、GPIO 的控制寄存器。这些物理地址不在 DRAM 里,而是映射到外设的地址空间上。CPU 想访问它们,就得通过 ioremap 建立映射。

我举个例子。假设你的板子上有个 UART 控制器,它的寄存器物理地址在 0x10000000。你想读写它,不能直接解引用这个物理地址——因为 CPU 跑在虚拟地址模式下。你需要:

// 把物理地址映射到内核虚拟地址空间
void __iomem *uart_base = ioremap(0x10000000, 0x1000);

// 然后通过 ioread/iowrite 访问
u32 status = ioread32(uart_base + 0x04);
iowrite32(0x01, uart_base + 0x00);

// 用完别忘了释放
iounmap(uart_base);

注意那个 __iomem 标记。这是内核用来做类型检查的,告诉编译器这个指针指向的是 I/O 内存,不是普通 RAM。别直接解引用,要用专门的访问函数。

我的习惯:每次写驱动时,我都会在 ioremap 之后立刻检查返回值。如果返回 NULL,说明映射失败,可能是物理地址不对,或者权限不够。我曾经因为设备树里配错了地址,排查了整整一个下午。

ioremap 和 vmalloc 在底层其实共享了同一套页表操作机制。它们都会修改内核的页表,把物理地址映射到 vmalloc 区域。区别在于:

特性 vmalloc ioremap
映射对象 物理 RAM(页框) I/O 内存(外设寄存器)
缓存策略 可缓存(Cacheable) 不可缓存(Uncacheable)
分配行为 分配物理页框 不分配,只映射已有物理地址
典型用途 大块内存分配、模块加载 驱动访问硬件寄存器

这里有个关键点:ioremap 映射的区域必须禁止 CPU 缓存。为什么?因为外设寄存器的值可能随时变化(比如状态位),如果 CPU 缓存了旧值,读到的就是错的。我见过一个新手写的驱动,直接解引用 ioremap 返回的指针,结果读状态寄存器永远返回 0,因为缓存没刷新。

正确的做法是:

// 错误:直接解引用
u32 val = *((volatile u32 *)uart_base);  // 可能读到缓存值

// 正确:使用 ioread32
u32 val = ioread32(uart_base);  // 保证从设备读取

避坑指南:我曾经在调试一个 PCIe 网卡时,发现中断状态寄存器读出来永远是 0。排查了半天,发现是 ioremap 时没有指定 ioremap_nocache,导致 CPU 缓存了寄存器值。从那以后,我每次 ioremap 都会检查缓存属性。

最后,总结一下这两个接口的适用场景:

  • vmalloc:适合分配大块内存(比如几百 KB 以上),不要求物理连续。典型场景:内核模块加载、网络协议栈的 sk_buff 分配。
  • ioremap:适合访问外设的 I/O 内存。典型场景:驱动初始化时映射寄存器、DMA 缓冲区。

嗯,这两个接口虽然简单,但用错了地方就会出大问题。记住一句话:RAM 用 vmalloc,I/O 用 ioremap,别混着来