支持作者新书,点击京东购买《Yocto项目实战教程:高效定制嵌入式Linux系统》
用户空间与内核空间:小内存访问背后的完整真相
❓几个常见但容易误解的问题
在日常开发中,我们经常写下这样朴素的一行代码:
int *p = malloc(8);
*p = 42;
这一行简单的内存分配与写操作,在用户空间是再普通不过的行为。但你是否真正理解了背后发生了什么?
- 这 8 字节来自哪里?物理内存是怎么分配的?
- 是什么时候分配的?访问它需要系统调用吗?
- glibc
malloc()
能直接操作内存吗? - 为什么我们说“只有 Page Fault 才会进入内核”?
- 内核空间的
kmalloc(8)
跟malloc(8)
一样吗? - MMU/TLB/页表在这个过程中到底扮演了什么角色?
本文将带你全面梳理用户空间与内核空间中小内存访问的完整流程,结合代码路径、硬件机制、内核细节,为你打通表象与本质的屏障。
🧭 目录导航
- 用户空间内存访问流程(以
malloc(8)
为例) - 内核空间小内存分配(以
kmalloc(8)
为例) - glibc 分配器策略解析
- Page Fault、系统调用、页表建立全过程
- MMU 与 TLB 的硬件行为详解
- 对比总结与流程图展示
1️⃣ 用户空间:malloc(8)
到访问的完整路径
我们以最常见的场景为例:
int *p = malloc(8);
*p = 42;
你以为它只是用户空间的简单调用,实际背后流程如下:
✅ 分配阶段
- 调用
malloc(8)
→glibc
实现的分配器(默认是ptmalloc2
)处理 - 分配器会尝试从现有小块内存池中找一块合适区域
- 如果没有,就通过
brk()
或mmap()
向内核申请更多虚拟地址空间
// 示例调用栈(glibc)
__libc_malloc()
├── _int_malloc()
│ ├── sysmalloc()
│ │ ├── __mmap()
│ │ └── __sbrk()
✅ 虚拟地址空间建立
glibc 使用 syscall(SYS_brk)
或 syscall(SYS_mmap)
进入内核:
- 调用路径:
sys_brk() → do_brk()
sys_mmap() → do_mmap()
- 内核创建对应
struct vm_area_struct
(VMA) - 注意:此时页表尚未建立,物理页帧尚未分配!
✅ 首次访问:触发缺页异常
当你执行 *p = 42;
时:
- CPU 执行
store
指令 - MMU 查找该虚拟地址的页表项
- 如果页表不存在 → Page Fault 异常(缺页中断)
- 内核陷入 →
do_page_fault()
→handle_mm_fault()
→__handle_mm_fault()
→do_anonymous_page()
- 分配物理页帧(通过
alloc_page()
→ buddy 分配器),更新页表 - 返回用户空间 → 重新执行
store
✅ 后续访问
- 页表已经存在
- TLB 命中 → 虚拟地址直接转为物理地址 → 不再陷入内核
2️⃣ 内核空间:kmalloc(8)
背后的流程
void *p = kmalloc(8, GFP_KERNEL);
调用路径:
kmalloc() →
kmalloc_slab()
→ slab_alloc()
→ new_slab_objects() if needed
→ alloc_pages() → __alloc_pages() → buddy system
- 内核使用 slab 分配器管理小对象(如 task_struct)
- 内部按对象大小(如 kmalloc-8、kmalloc-32)维护缓存池
- 每类对象使用多个页帧作为缓存池
- slab 拆页 → 构建对象链表 → 分配对象
- 使用的是内核虚拟地址(直接映射区)
3️⃣ glibc 分配器策略解析(用户空间)
glibc 的 malloc()
实现并不直接与物理内存交互,它依赖内核提供的虚拟内存机制:
- 小块内存(<128KB):使用
brk()
扩展堆顶 - 大块内存(≥128KB):使用
mmap()
申请匿名页 - 内部维护多个 free list,支持 bin 机制、小块复用、延迟释放等
✅ glibc 分配器不关心页表/物理内存/页帧,它只管理用户空间的分配策略。
4️⃣ Page Fault 与页表建立全过程
当访问未映射地址时:
-
CPU 捕获异常 → trap handler →
do_page_fault()
-
根据地址所在 VMA,确认可访问性
-
如果是匿名页:
- 创建 PTE
- 调用
alloc_page()
分配物理页帧 - 设置 PTE 权限位(RW/U)
- 刷新 TLB(如
flush_tlb_page()
)
-
用户态恢复 → 重新执行失败的指令
页表结构示意(以 x86_64 为例):
PGD → P4D → PUD → PMD → PTE → PFN
每一级页表的索引宽度:9 bits,PTE 最终映射 4KB 页帧。
5️⃣ MMU 与 TLB 的硬件行为
🔹 MMU(Memory Management Unit)
- 位于 CPU 内部,接管所有虚拟地址访问
- 自动根据页表翻译虚拟地址为物理地址
- 不执行任何代码,只是“硬件查表 + 组合地址”
🔹 TLB(Translation Lookaside Buffer)
- 页表缓存,提升地址翻译性能
- CPU 自动维护(替换/失效/刷新)
- 如果 TLB 命中,虚拟地址翻译只需 1 cycle
- TLB miss 时,硬件自动翻页表 → Page Walker
🔹 是否需要进入内核?
场景 | 是否内核代码? | 是否系统调用? |
---|---|---|
TLB 命中 | ❌ 否 | ❌ 否 |
页表命中(TLB miss) | ❌ 否 | ❌ 否 |
页表缺失(Page Fault) | ✅ 是 | ✅ 是(陷入) |
6️⃣ 图示总结:用户空间 malloc 与访问的完整路径
┌──────────────┐
│ malloc(8) │
└──────┬───────┘
↓
┌──────────────────────┐
│ glibc: 找空闲小块或扩展 │
└──────┬────────────────┘
↓
┌────────────────────────────────────────┐
│ syscall: brk() 或 mmap() │
│ → 内核建立 VMA(尚无页表/PTE) │
└──────┬─────────────────────────────────┘
↓
┌────────────────────────────┐
│ 首次访问 p → MMU 查 TLB │
└──────┬────────────┬────────┘
│ │
↓ ↓
TLB miss TLB hit
│ │
↓ ↓
查页表(无) 完成访问(无需内核)
↓
Page Fault → handle_mm_fault() → 建页表 + 分配页帧
↓
返回用户态 → 重新执行 → 访问成功
✅ 最终总结:核心差异对比
项目 | 用户空间 malloc(8) | 内核空间 kmalloc(8) |
---|---|---|
使用 API | malloc() (glibc) | kmalloc() (内核) |
是否进入内核? | 首次访问时可能会 Page Fault | kmalloc 直接在内核 |
分配策略 | glibc 分配器 + brk/mmap | slab 分配器 + buddy |
是否直接访问页表? | ❌ 否(硬件 MMU 自动查) | ✅ 内核直接操作页表 |
物理页帧由谁分配 | handle_mm_fault() → alloc_page() | slab 向 alloc_pages() 请求页 |
是否使用 TLB | ✅ 是 | ✅ 是 |
📌 建议延伸阅读
支持作者新书,点击京东购买《Yocto项目实战教程:高效定制嵌入式Linux系统》