1. 课程导入

做嵌入式驱动开发,内存管理是绕不开的坎。我见过不少新手,上来就写驱动,结果系统跑着跑着就挂了——查到最后,全是内存访问越界或者地址映射搞错了。所以咱们先从最基础的东西聊起。

说白了,内存管理就是解决一个问题:怎么让多个程序安全、高效地共享有限的物理内存。你想想看,如果每个程序都能直接操作物理地址,那一个野指针就能把整个系统干崩掉。这显然不行。

1.1 物理内存与虚拟内存

物理内存就是板上焊的那些DDR颗粒,4GB就是4GB,8GB就是8GB。驱动里直接操作物理地址的场景其实不多,一般也就DMA、寄存器访问、或者某些实时性要求极高的路径才会这么干。

虚拟内存就不一样了。每个进程都以为自己独占整个地址空间——32位下是4GB,64位下更是大到离谱。实际上,这些地址都是假的,需要MMU翻译成物理地址才能用。

核心观点:虚拟内存不是“内存”,它是一张地址映射表。这张表让每个进程都活在“独立世界”里,互不干扰。

我在项目中遇到过一个问题:某款IoT设备只有256MB内存,但跑着三个进程,每个进程都映射了512MB的虚拟空间。这怎么可能?其实大部分页面根本没分配物理内存,只有真正访问时才触发缺页中断去分配。这就是虚拟内存的妙处——按需分配,按需加载。

特性 物理内存 虚拟内存
本质 硬件存储介质 地址映射抽象
大小 固定(板载容量) 由CPU地址线宽度决定
访问方式 直接物理地址 通过MMU翻译
驱动常用场景 DMA、寄存器、中断向量 用户态缓冲区、mmap
安全性 无保护 进程隔离、权限控制

1.2 MMU工作原理

MMU(Memory Management Unit)是CPU内部的一个硬件模块。它的工作很简单:CPU发来一个虚拟地址,MMU查表,输出一个物理地址。如果查不到,就触发缺页异常,让内核去处理。

嗯,这里要注意:MMU不是软件,它是硬件。但它的行为受软件控制——你写页表,它照着查。所以驱动开发者必须理解MMU的翻译流程,否则你配的DMA缓冲区可能根本不在物理内存里。

我个人习惯把MMU的工作分成三步:

  1. 拆分地址:虚拟地址被拆成页目录索引、页表索引、页内偏移
  2. 查表翻译:逐级查找页表,找到对应的物理页框号
  3. 权限检查:检查当前访问是否合法(读/写/执行、用户态/内核态)

避坑指南:我曾经在调试一个USB驱动时,发现DMA传输总是超时。查了两天才发现,DMA缓冲区用的是vmalloc分配的虚拟地址,物理地址不连续。MMU翻译没问题,但DMA控制器直接访问物理总线,它不认识页表。所以驱动里用DMA,必须保证物理地址连续——要么用kmalloc,要么用dma_alloc_coherent。

1.3 页表与TLB

页表就是MMU查的那张表。每个进程都有自己的页表,里面记录了虚拟页到物理页的映射关系。页表项(PTE)里不光有物理地址,还有权限位、脏位、访问位等控制信息。

但页表有个问题——它存在内存里。每次地址翻译都要去内存查表,太慢了。所以CPU搞了个缓存叫TLB(Translation Lookaside Buffer),专门缓存最近用过的页表项。

TLB命中,一次翻译只要几个时钟周期。TLB未命中,就得走一遍完整的页表查询,几十甚至上百个周期。所以驱动开发中,TLB的刷新策略直接影响性能。

注意:在SMP(多核)系统上,每个核都有自己的TLB。当你修改了某个进程的页表(比如解除映射),必须通过IPI(核间中断)通知其他核刷新对应的TLB项。否则,其他核可能还在用旧的映射访问已经释放的内存——这就是典型的use-after-free bug。

我记得有一次优化一个网络驱动的收包路径,发现每次中断处理都要做一次ioremap和iounmap。这会导致TLB频繁刷新,性能惨不忍睹。后来改成在probe时一次性映射好,中断里直接用虚拟地址访问寄存器,吞吐量直接翻倍。

下面这张图展示了整个地址翻译的流程:

虚拟地址到物理地址翻译流程 虚拟地址 TLB 缓存 命中 物理地址 未命中 页表查询 查到 更新 TLB 缺页 缺页异常处理 关键说明: 1. CPU每次访问内存都先查TLB,命中则直接得到物理地址 2. TLB未命中时,MMU硬件自动遍历页表(多级页表逐级查找) 3. 页表中找不到映射 → 触发缺页异常,由内核软件处理 4. 缺页处理完成后,更新TLB,重新执行原指令

1.4 多级页表与地址翻译

32位系统用两级页表,64位系统用四级甚至五级页表。为什么搞这么复杂?说白了就是为了省内存。如果每个进程都维护一张完整的页表,4GB虚拟空间需要4MB页表(每页4KB,每个PTE 4字节)。100个进程就是400MB——太浪费了。

多级页表的好处是:只映射实际使用的虚拟地址区域。大部分进程只用了一小段地址空间,中间的页目录项直接标记为无效,不分配下级页表。

我举个例子:一个嵌入式设备上跑着10个进程,每个进程只用了底部64MB虚拟空间。如果用一级页表,每个进程需要4MB页表,总共40MB。用两级页表,每个进程只需要一个页目录(4KB)加上64MB对应的64个页表(每个4KB),总共约2.6MB。10个进程才26MB——省了将近一半。

架构 页表级数 典型页大小 地址宽度
ARMv7(32位) 2级 4KB / 64KB 32位
ARMv8(64位) 3~4级 4KB / 16KB / 64KB 48位
x86_64 4级 4KB / 2MB / 1GB 48位
RISC-V(Sv39) 3级 4KB 39位

实战技巧:在驱动中使用大页(Huge Pages)可以减少TLB miss。比如一个2MB的大页只需要一个TLB项,而同样的区域如果用4KB小页需要512个TLB项。我在做视频编解码驱动时,把帧缓冲区映射成大页,TLB miss率从12%降到了0.3%——效果立竿见影。

1.5 驱动开发中的内存管理要点

最后,我总结几个驱动开发中必须记住的内存管理要点:

  • 物理地址 vs 虚拟地址:驱动里大部分API用的是虚拟地址,但DMA、寄存器、中断控制器这些硬件模块只认物理地址。搞混了就是死机。
  • 内存屏障:MMU可能会重排内存访问顺序(特别是写操作)。驱动里操作硬件寄存器时,必须用wmb()/rmb()/mb()保证顺序。
  • 缓存一致性:CPU有L1/L2 cache,DMA直接读写内存。如果cache里的数据和内存不一致,数据就乱了。要么用dma_alloc_coherent(禁止cache),要么手动flush cache。
  • TLB刷新代价高:频繁的unmap/remap会导致TLB shootdown,在多核系统上尤其昂贵。尽量在初始化阶段完成映射,运行时少动。

血的教训:我曾经在调试一个SPI驱动时,发现读回来的数据总是错位。查了三天,最后发现是ioremap返回的虚拟地址被编译器优化成了寄存器变量,每次读都直接从cache里取旧值。加上volatile关键字后问题解决。记住:访问硬件寄存器,一定要用volatile。

好了,这一章的内容就到这里。内存管理是嵌入式驱动开发的基石,理解物理内存、虚拟内存、MMU、页表和TLB之间的关系,后面讲DMA、CMA、内存池、slab分配器时你才能游刃有余。