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的翻译流程大致是这样的:

  1. CPU发出虚拟地址VA
  2. MMU从VA中提取页号(VPN)和页内偏移
  3. 查TLB(快表),看有没有缓存
  4. TLB miss则查页表(多级页表,逐级查找)
  5. 找到物理页框号(PFN),拼接偏移得到物理地址PA
  6. 检查权限(读/写/执行),通过则放行

这里有个关键点:多级页表。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一致性的时候,咱们再细聊。

MMU地址翻译流程 CPU 虚拟地址 VA TLB 快表 TLB 命中 物理地址 PA 页表(内存) 说明: TLB命中时,1-2个时钟周期完成翻译;TLB未命中时,需查内存页表,耗时数十个周期。 多级页表按需分配,节省内存;TLB刷新操作在上下文切换时开销较大。

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一致性、内存优化的时候,咱们会反复用到这些概念。先把基础打牢,后面才能走得稳。