内核深入学习3——分析ARM32和ARM64体系架构下的Linux内存区域示意图与页表的建立流程

内核深入学习3------ARM32/ARM64在Linux内核中的实现(2)

​ 今天我们来讨论的是一个硬核的内容,也是一个老生常谈的话题------那就是分析ARM32和ARM64体系架构下的Linux内存区域示意图的内容。对于ARM64的部分,我们早就知道一个基本的事实------那就是我们的使用的是实际分配虚拟地址位之外的部分,全部置0和全部置1来区分这个地址的映射需要走的是哪个页表基地址寄存器。这个是我们上一个博客就谈论过的。

​ 因此,我们需要做一个最基本的分析:那就是从0x 0000 0000 0000 0000到0x 0000 FFFF FFFF FFFF 的地方都是属于走用户态的页表基地址寄存器做映射的,反之,剩下的从0x FFFF 0000 0000 0000到0x FFFF FFFF FFFF FFFF的部分那就一定属于内核的部分。下面,我们就来仔细的讨论和分析。

​ 需要注意的是------一个基本的事实是我们由于开放了内存的虚拟化,因此,我们能做到的是分析一个进程虚拟内存视图下的内存分配视图。用户态的部分肯定跑的就是自己的应用程序,我们的进程帮助我们维护一个非常经典的堆与栈环境,包括一些动态库文件加载啊等等地区。

复制代码
+----------------------------+ 0x0000FFFFFFFFFFFF
|   User Space (shared libs) |
|   Heap, Stack, mmap()      |
|   Program text / data      |
+----------------------------+ 0x0000000000400000
|   NULL (reserved)          |
+----------------------------+ 0x0000000000000000

​ 当然这里的NULL就是一种检测践踏的地址,比如说,当我们访问空指针的时候,我们的操作系统会向进程发送非法地址访问的异常问题。这里就是这样规划的。当然,更加具体的,那就是栈放在上面,堆放在下面。这样的规划。

​ 但是我们有时候一般更关心的是内核的一些事情,比如说我们的内核的地址是如何排布的。注意到的是------内核的排布中,我们的页表映射是完全线性的。这样的映射是简单的------那就是直接对我们的虚拟地址访问加上偏移量直接得到真实的物理地址

​ 我们现在就得到了最完备的视图。

复制代码
+----------------------------+ 	0xFFFFFFFFFFFFFFFF
|     Linear mapping         |
+----------------------------+ 	0xFFFFFF8000000000 <-- PAGE_OFFSET
|     vmemmap area           |
+----------------------------+
|    PCI IO Area             | 	
|                            |
+----------------------------+ 
|    Fixmap (Fixed mappings) |
+----------------------------+ <-- VMALLOC_END
|    Other VMALLOC           |
|    KImage Area             |
+----------------------------+ <-- VMALLOC_START, KIMAGE_START
|    Modules Area  (128MB)   |
+----------------------------+
|                            |
|   Linear Mapping (DRAM)    | 
|   Direct physical mapping  |

ARM32的分析

​ ARM32的分布上,则是遵循着默认的比例用户态内存3G:内核内存1G的划分,由于分析上完全镜像,这里直接给出视图

复制代码
ARM32 Linux Kernel Virtual Address Layout (i.MX6ULL, 3G/1G split)

+----------------------------+ 0xFFFF0000
|  CPU vectors (vectors page)|
+----------------------------+ 0xFFFE0000
|     Fixmap (cpu/mmio/etc)  |
+----------------------------+
|   ioremap / consistent mem |
|   Dynamically mapped I/O   |
+----------------------------+
|      vmalloc area          |
|   kmalloc large objects    |
+----------------------------+ 
|   Module code/data (rare)  |
+----------------------------+
|                            |
|   Lowmem (Linear mapping)  | <-- 线性映射物理内存
|   phys 0x00000000 - ~DDR   |
|                            |
+----------------------------+ 0xC0000000 (PAGE_OFFSET)

        ↑ 内核空间 (1GB) ↑
--------------------------------------------------
        ↓ 用户空间 (3GB) ↓

+----------------------------+ 0xBFFFFFFF
|                            |
|   User stack, mmap, heap   |
|   Shared libs, ELF .text   |
|                            |
+----------------------------+ 0x00000000

从运行初始到桥接给Linux的Memory Management 子系统的流程

​ 我们后面就会知道,Linux的运行的时候的环境还是主要交给我们的Memory Management子系统来管理的,为此,为了建立起来内存管理子系统,我们首先需要一个保证可用的页表。这个页表的建立将会保证我们平滑的从开机到内核运行的流程。让我们看看这是如何做到的。

​ 我们先从 ARM64 架构说起。在 Linux 内核启动时,BootLoader 会将内核镜像加载到内存中某个偏移位置,例如 0x80000,然后跳转到内核的启动入口。早期初始化代码(位于 arch/arm64/kernel/head.S)会完成最初的页表搭建。这一过程包括为线性映射区域建立页表,也就是我们提到的 PAGE_OFFSET 开始处映射物理地址0的那一大片虚拟空间。除此之外,还包括设置内核本身所占用的代码段、数据段以及设备树、堆栈等的映射。这时 MMU 尚未打开,因此这张最早的页表是通过一段静态结构预先构建出来的,结构清晰,从顶层的 PGD 开始(在ARM64中为4级页表:PGD → PUD → PMD → PTE),每一级都是标准的512项表项,页大小通常是4KB。Linux 内核用 __create_pgd_mappingcreate_mapping 函数将物理内存与虚拟地址建立映射,最终通过 enable_mmu 指令将 MMU 打开。之后,整个世界就不再是线性的物理世界,而是由虚拟地址主导的内存空间。

​ 而在 ARM32 下,情况要略为复杂也更"传统"一些。ARM32 架构支持两级页表,也就是 PGD 和 PTE,没有中间的 PUD 或 PMD 层。每个进程有自己的一套页目录,但内核部分是共享的。在启动初期,同样由 BootLoader 加载内核到内存,然后跳转到 __start 函数(通常位于 head.S 中)。早期初始化代码中,通过对页表结构的手工设置,构造出一张最小页表,这张表只映射几个关键区域:内核的物理地址、页表本身所在位置、异常向量表位置、早期的串口输出地址(用于 earlyprintk)等。ARM32 的页表结构可以是 coarse 页表(分页大小4KB)或 section(1MB)映射。在 Linux 内核中,为了加快 early boot 的速度,通常采用 1MB 的 section 映射将内核整体映射进虚拟地址空间。这些页表使用内核提供的 create_mappingsetup_pagetables 等函数来初始化,在 MMU 打开后,就从 __mmap_switched 跳入真正的 C 语言环境,开始调用 start_kernel

在内核镜像执行的早期,也就是 head.S 阶段,Linux 还处于纯物理地址运行状态。此时会调用 __create_page_tables,它是 Linux ARM64 平台用来构建初始页表的关键函数,定义在 arch/arm64/mm/mmu.c 中。该函数会为以下几个区域建立映射:内核镜像本身所在的 .text.data.bss 段;设备树 blob 所在的物理地址;早期内核堆栈;内核页表自己本身所占用的空间。它调用的底层函数就是 create_mapping,而 create_mapping 则调用 __create_pgd_mapping

__create_pgd_mapping 的逻辑是:从顶层的 PGD 开始,递归分配每一层页表,并填入合适的描述符,最终走到最低层(PTE 或 PMD),填入物理地址并设置页面属性。ARM64 支持 section(大页)映射,如果映射的地址范围是对齐的并且足够大(例如 2MB),就会使用 block 映射以节省页表层级。这种映射结构使得在启动初期建立映射的页表更紧凑、更高效,且符合硬件 MMU 格式。

​ ARM64 的早期页表是静态分配的,这些页表空间在 idmap_pg_direarly_pg_dir 等变量中定义为静态数组,位于 bss 区域,并放在链接脚本指定的位置上。它们使用 __attribute__((aligned(4096))) 保证页表对齐,之后在启用 MMU 之前就已经准备完毕。完成初步映射后,enable_mmu() 函数会写入 MAIR、TCR、TTBR 等寄存器,正式打开 MMU。此时代码运行空间就从物理地址切换到了虚拟地址,内核开始使用它设置好的虚拟内存空间继续运行。

​ 相比之下,ARM32 下的页表建立逻辑显得更加朴素也略显散乱。它的入口是 setup_pagetables,定义在 arch/arm/mm/mmu.c 中。这个函数的作用是为 0xC0000000 开始的内核虚拟地址映射一段线性空间,用于映射内核映像和其它早期内存区域。它采用的是一级页表 section 映射(每项映射 1MB),通过一个宏 SECTION_ENTRY 来填充页表项:这类表项结构简单,只要填入物理地址与页面属性即可。ARM32 没有复杂的页表层级,页表项直接从 PGD 指向 PTE 或者 section entry,所以建立页表的函数只需要一次映射,不需要递归分配子表。

setup_pagetables 中,会依次映射:

  1. 0xC0000000 起始的 lowmem 区,对应物理地址 0x00000000
  2. 内核映像所在地址;
  3. 页表本身所在的物理地址,保证页表可访问;
  4. 异常向量表地址(可能在 0xFFFF0000);
  5. 一些 I/O 设备的 MMIO 区,如串口或调试接口。

​ ARM32 页表是以一个 16KB 的 swapper_pg_dir 作为根目录构建的,这个数组位于内核的 .bss 区域。在 paging_init()mem_init() 中,这些早期页表会被完善,最终转交给内核内存子系统去接管。

​ 和 ARM64 一样,ARM32 也需要调用类似 set_cr() 的函数设置 MMU 寄存器,启用页表后,CPU 就从使用物理地址跳转到使用虚拟地址运行。后续的所有访问,包括函数调用、堆栈操作、C 函数执行等,都会经过刚刚建立好的页表。

​ 前面提到,__create_pgd_mapping 构建的是早期静态页表。这些页表只能覆盖有限的地址范围,比如内核代码段、早期堆栈和设备树所需空间。接下来,内核会进入 C 函数 start_kernel(),正式开始 Linux 的各个子系统初始化。在这个函数中,setup_arch() 会负责调用 paging_init(),完成整个内核空间页表的建立。

paging_init() 位于 arch/arm64/mm/mmu.c 中,它的职责是"真正地初始化整个内核所需的页表结构",包括 vmalloc 区、fixmap 区、module 区、线性映射区等。在这个函数中,Linux 会依赖一个叫 memblock 的框架,它是启动阶段使用的"临时内存分配器",以简易链表方式记录哪些物理内存是保留的,哪些是空闲的。内核使用 memblock_alloc() 从早期页表所映射的空间中分配内存,用来动态分配更多页表结构(例如 PGD、PUD、PMD、PTE 的页),扩展页表覆盖的范围。这里会用到 map_kernel_range()create_mapping() 等函数,在多个区域建立新的虚拟地址到物理地址的映射。

​ 比如,vmalloc 区的起始地址是 VMALLOC_START,它不具备连续的物理页,因此不能用 section 映射,只能用标准 4KB 页和 PTE 映射,因此页表必须分层构建。Fixmap 区则用于映射早期调试设备,如 earlyprintk 的 UART。还有 vmemmap 区,它用于为系统中每一页 RAM 创建一个 struct page 结构,这些结构本身也需页表映射才能使用。整个过程依赖 early_pgtable_alloc() 从 memblock 中临时分配内存页,创建这些表项。

​ 一旦页表建完,paging_init() 的尾声就会调用 mem_init()mem_init_print_info(),这些函数会清理早期的临时映射(如 identity map)、释放保留内存,并初始化伙伴系统。到此,系统便从"硬编码的页表"过渡到了"完全运行时可分配的动态页表"。

​ ARM32 的路径和逻辑虽然相似,但细节上有不少不同。它同样在 start_kernel() 中通过 setup_arch()paging_init() 开始切换到正式的内存管理模式。在 ARM32 中,页表仍是 2 级结构,但 Linux 需要对 0xC0000000 开始的内核虚拟地址空间建立映射,并补全早期 section 映射未覆盖的区域。比如早期的页表只映射了内核镜像,那么此时就会补上 vmalloc 区、模块区、pkmap 区(用于高端内存映射)等页表项。这一过程主要通过 map_lowmem()mem_init()alloc_init_pte() 等函数完成。ARM32 也使用 memblock,但 memblock 的用法和粒度比 ARM64 更"紧凑",ARM32 的页表使用空间更小,所以内存分配压力相对较低。

​ 一个非常关键的转折点是:伙伴系统(buddy system)的初始化。无论 ARM64 还是 ARM32,在完成页表后,mem_init() 会将早期 memblock 中标记为"可用"的物理内存,正式释放给伙伴系统,让它成为 __get_free_pages()kmalloc() 可使用的内存池。从这时起,整个系统的内存空间便不再是"只读映射"或"静态页表",而是完全动态可分配的现代虚拟内存空间。值得一提的是:ARM64 的页表一旦建好,未来不会再频繁修改,而 ARM32 有些设备的页表仍需要在运行时动态更新,比如高端内存(highmem)页面就需要不断更新 kmap 区域的页表项。此外,两者都在页表项中设置内存属性,如缓存策略、可执行位、只读位等,以确保数据段和代码段的正确访问权限。

相关推荐
chennalC#c.h.JA Ptho1 分钟前
lubuntu 系统详解
linux·经验分享·笔记·系统架构·系统安全
冼紫菜2 分钟前
解决 CentOS 7 镜像源无法访问的问题
linux·运维·服务器·centos
几道之旅4 分钟前
分别在windows和linux上使用curl,有啥区别?
linux·运维·windows
季柳东4 分钟前
在虚拟机Ubuntu18.04中安装NS2教程及应用
linux·运维·ubuntu
冼紫菜7 分钟前
如何在 CentOS 7 虚拟机上配置静态 IP 地址并保持重启后 SSH 连接
linux·开发语言·centos·ssh
海尔辛19 分钟前
学习黑客BitLocker与TPM详解
stm32·单片机·学习
oioihoii39 分钟前
C++23 views::slide (P2442R1) 深入解析
linux·算法·c++23
邓永豪1 小时前
笔记本电脑升级实战手册[3]:扩展内存与硬盘
学习·电脑·硬件·diy·3c硬件
小虎卫远程打卡app1 小时前
视频编解码学习十一之视频原始数据
学习·视频编解码
Jerry&Louis1 小时前
【Ubuntu】neovim & Lazyvim安装与卸载
linux·ubuntu