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的工作分成三步:
- 拆分地址:虚拟地址被拆成页目录索引、页表索引、页内偏移
- 查表翻译:逐级查找页表,找到对应的物理页框号
- 权限检查:检查当前访问是否合法(读/写/执行、用户态/内核态)
避坑指南:我曾经在调试一个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时一次性映射好,中断里直接用虚拟地址访问寄存器,吞吐量直接翻倍。
下面这张图展示了整个地址翻译的流程:
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分配器时你才能游刃有余。