1. 课程导入
各位同学,咱们今天聊聊嵌入式系统里最绕不开的话题——内存管理。我做了十几年底层驱动,说句实话,很多系统崩溃、性能瓶颈,根子都在内存上。你想想看,CPU跑得再快,内存跟不上,一切都是白搭。
这一章,咱们把地基打牢。物理内存、虚拟内存、MMU、页表、TLB、内存映射I/O,这些概念搞不清楚,后面写驱动、调优、debug,你连问题出在哪都找不到。
1.1 物理内存 vs 虚拟内存
先问个问题:为什么要有虚拟内存?
直接操作物理内存不行吗?嗯,早期单片机确实这么干。但到了Linux这种多任务系统,问题就来了——多个进程同时跑,每个进程都以为自己独占整个内存空间,这怎么搞?
物理内存就是板上实实在在的DRAM颗粒,地址从0x80000000开始(不同平台不一样)。而虚拟内存是给每个进程画的“大饼”——32位系统下,每个进程看到4GB的连续地址空间。
核心区别一句话:物理内存是硬件资源,虚拟内存是软件抽象。MMU负责把虚拟地址翻译成物理地址。
我在一个ARM Cortex-A7的项目上遇到过这么个事:一个新手同事直接往物理地址0x20000000写数据,结果系统立马挂掉。为什么?因为那个地址被内核占用了,用户态根本不能碰。这就是虚拟内存的保护机制在起作用。
| 特性 | 物理内存 | 虚拟内存 |
|---|---|---|
| 地址空间 | 受限于硬件容量(如512MB) | 每个进程独立,32位下4GB |
| 访问权限 | 无保护,任何代码都能访问 | 有用户态/内核态隔离 |
| 连续性 | 物理上可能不连续 | 虚拟地址连续,物理页可分散 |
| 管理粒度 | 页框(4KB) | 页(4KB) |
1.2 MMU工作原理
MMU(Memory Management Unit)是CPU内部的一个硬件模块。它的工作说白了就两件事:地址翻译和权限检查。
每次CPU发出一个虚拟地址,MMU就会截获它,然后查页表,找到对应的物理地址。如果页表里没有这个映射,或者权限不对,MMU就会触发一个异常——这就是我们常说的“段错误”(Segmentation Fault)的根源。
我的习惯:调试驱动时,第一件事就是检查MMU是否开启。很多裸机程序跑飞了,就是因为忘了开MMU,直接操作了物理地址。
MMU的翻译流程大致是这样的:
- CPU发出虚拟地址VA
- MMU从VA中提取页号(VPN)和页内偏移
- 查TLB(快表),看有没有缓存
- TLB miss则查页表(多级页表,逐级查找)
- 找到物理页框号(PFN),拼接偏移得到物理地址PA
- 检查权限(读/写/执行),通过则放行
这里有个关键点:多级页表。32位ARM Linux用的是两级页表,64位ARM(AArch64)用四级页表。为什么搞这么复杂?为了节省内存。如果只用一级页表,4GB空间需要4MB的页表空间(每个页表项4字节,共1M项)。而多级页表可以按需分配,只映射实际使用的地址范围。
1.3 页表与TLB
页表是MMU的“字典”,存在内存里。TLB是页表的“缓存”,存在CPU内部。
为什么需要TLB?因为每次地址翻译都去内存查页表,太慢了。TLB是SRAM,访问速度比DRAM快一个数量级。TLB命中时,翻译只需要1-2个时钟周期;TLB miss时,需要几十甚至上百个周期去查页表。
我曾经踩过的坑:在一个视频处理驱动里,频繁映射/取消映射用户态缓冲区,导致TLB不断刷新,性能直接腰斩。后来改成用DMA缓冲区池,提前映射好,TLB miss率从30%降到了2%。
页表项(PTE)里除了物理地址,还包含一堆标志位:
- Present:该页是否在物理内存中
- Read/Write:读写权限
- User/Supervisor:用户态还是内核态可访问
- Access/Dirty:是否被访问过/写过(用于页面置换)
- Cacheable/Bufferable:是否允许Cache缓存(这个在驱动里特别重要)
说到Cacheable标志,我得多说两句。驱动开发中,如果你把一段内存映射成Cacheable,但同时又用DMA去访问它,那就等着数据不一致的bug吧。后面讲Cache一致性的时候,咱们再细聊。
1.4 内存映射I/O
嵌入式系统里,外设寄存器怎么访问?答案就是内存映射I/O(MMIO)。
说白了,就是把外设的寄存器映射到一段物理地址空间里。CPU读写这些地址,就等于读写外设的寄存器。比如UART的数据寄存器、GPIO的输出寄存器,都是这么操作的。
在Linux驱动里,我们通常用ioremap()把物理地址映射到内核虚拟地址空间,然后用readl()/writel()来访问。为什么不用普通的指针解引用?因为MMIO需要保证:
- 不能Cache:外设寄存器必须每次直接访问硬件,不能缓存
- 访问顺序不能乱:编译器或CPU不能重排MMIO访问的顺序
- 访问宽度要匹配:32位寄存器必须用32位访问,不能拆成两个16位
关键点:MMIO的页表项必须设置成非Cacheable和非Bufferable。否则,你写了一个寄存器,数据可能还留在Cache里,外设根本没收到。
来看一个典型的MMIO操作代码:
/* 将GPIO控制器的物理地址映射到内核虚拟地址 */
#define GPIO_BASE 0x50000000
#define GPIO_SIZE 0x1000
static void __iomem *gpio_base;
gpio_base = ioremap(GPIO_BASE, GPIO_SIZE);
if (!gpio_base) {
pr_err("ioremap failed\n");
return -ENOMEM;
}
/* 设置GPIO输出高电平 */
writel(0x01, gpio_base + 0x00); // 数据寄存器
writel(0x01, gpio_base + 0x04); // 方向寄存器
/* 使用完毕,解除映射 */
iounmap(gpio_base);
这里有个细节:ioremap()返回的地址是void __iomem *类型,这是内核特有的“I/O内存”标记。你不能对它做直接解引用,必须用readl()/writel()这些访问函数。这些函数内部会插入内存屏障,保证访问顺序正确。
我建议:写驱动时,把所有MMIO基地址和偏移定义成宏,放在头文件里。别在代码里到处写魔法数字。我见过一个项目,因为地址写错一个bit,查了三天才找到问题。
最后说一个容易忽略的点:MMIO的地址范围。在设备树里,每个外设的reg属性就定义了它的MMIO地址和大小。内核启动时,会把这些信息传递给驱动。所以,写驱动前,先看设备树,确认地址对不对。
我曾经犯过的错:在一个SPI控制器驱动里,我直接用ioremap映射了0x1000字节,但实际寄存器只占0x100字节。结果访问到后面的地址时,触发了总线错误。后来查手册才发现,那个地址范围属于另一个外设。所以,映射大小一定要精确,别贪多。
好了,这一章的内容就这些。物理内存和虚拟内存的区别、MMU怎么翻译地址、页表和TLB的作用、MMIO的正确用法——这些都是嵌入式驱动开发的基石。后面讲DMA、Cache一致性、内存优化的时候,咱们会反复用到这些概念。先把基础打牢,后面才能走得稳。