内存与IO空间的访问

内存和IO的硬件机制

内存空间和IO空间

  • IO空间:x86架构CPU中的一个概念,代表了外设寄存器地址空间,通过特定指令访问

  • 内存空间:大多数嵌入式CPU并没有IO空间,仅存在内存空间。可以直接通过地址、指针访问

image-20250323191725859 image-20240909093623496 image-20240909093807702

不同CPU的地址空间一般都是从0x000000开始的,但是哪个外设对应哪一部分,一般都是不确定的,比如Imx6ull中,DDR的地址空间是从0x80000000开始的

内存管理单元

内存管理单元(MMU)是CPU的非常重要的一个组件,用于辅助OS进行内存管理,它的功能如下:

  • 提供虚拟地址到物理地址的映射
  • 内存访问权限保护
  • 缓存控制:通过PTE的某些字段来控制缓存的行为,比如是否允许缓存、缓存写回策略…

转换旁路缓存

转换旁路缓存(Translation Lookaside Buffer,TLB)是MMU的核心部件,它缓存少量的虚拟地址与物理地址的转换关系,是转换表的Cache,因此也经常被称为“快表”

转换表漫游

转换表漫游(Translation Table walk,TTW):当TLB中没有缓冲对应的地址转换关系时,需要通过对内存中页表进行遍历来获得对应关系

内存访问的完整流程

image-20250323192312974
  • 注意:最后是CPU进行的内存访问,MMU只负责查找映射关系,并且CPU会优先访问Cache,之后才访问物理内存

Linux的内存布局

在Linux系统中,每个进程的虚拟内存空间被分为2部分:用户空间和内核空间

image-20250323192656907

  • 对于一个进程,其用户空间和内核空间共用一个页表。但是CPU在用户态时只能访问用户空间范围的虚拟地址,不能直接访问内核空间(通过MMU结合PTE对应字段实现),而内核态可以访问任意空间的内存
  • 不同进程的虚拟内存空间的内核空间所映射的物理地址是相同的,故内核在物理内存中只需要加载1次。其布局在系统启动时初始化,并在整个系统运行期间保持不变
  • 对于内核线程,它只有内核态,所以它没有用户空间,体现在用户空间地址范围没有PTE

内核空间的布局

32位Linux在x86架构的内核地址空间通常被划分为以下几个区域:

image-20250323195608519
  • 1.直接映射区:将物理内存顺序映射到内核空间,是内核访问物理内存的主要方式
    • 物理地址和虚拟地址之间存在线性关系
    • 该区域可进一步分为2个区:
      • DMA区域
      • 常规区域
image-20250323200536731
  • 2.虚拟内存动态分配区
    • 为内核提供动态分配的虚拟内存,用于分配不连续的物理内存
    • 通过 vmalloc() 分配的内存位于此区域
  • 3.高端内存映射区:32位的Linux的内核空间只有1GB,其物理内存映射区最大长度为896MB,当物理内存的地址大于896MB时,超过物理内存映射区的那部分内存称为高端内存,需要使用kmap把他们映射到该区才能访问
  • 4.专用页面映射区
  • 5.系统保留映射区

用户空间的布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
高地址
------------------
| 内核空间 | ← 用户进程不可访问
|-----------------|
| 栈(THREAD 1)| ← 向下增长
|-----------------|
| 栈(THREAD 2)|
|-----------------|
| 栈(THREAD n)|
|-----------------|
| 共享库 | ← 动态链接库 (如 libc.so)
|-----------------|
| 内存映射区 | ← mmap 文件/匿名映射 (如 malloc 大内存)
|-----------------|
| 堆 (Heap) | ← 向上增长
|-----------------|
| .bss 段 | ← 未初始化的静态/全局变量(默认为0)
|-----------------|
| .data 段 | ← 已初始化的静态/全局变量
|-----------------|
| .rodata 段 | ← 已初始化的静态/全局变量
|-----------------|
| .text 段 | ← 代码 (只读, 如: 0x55...)
低地址
  • 代码段 (.text):存放编译后的机器指令
  • 只读数据段(.rodata):存放只读数据,比如字符串字面量、被 const修饰且已初始化的全局变量和静态变量、编译器生成的各种常量
  • 数据段 (.data, .bss):存放初始化和非初始化的全局/静态变量
  • 堆 (heap):用于动态内存分配(malloc/free
  • 栈 (stack):用于函数调用帧、局部变量
  • 内存映射段 (mmap segment):用于加载共享库、文件映射等

注意,上面的BSS段、Text段并不是ELF里的节区(.bss、.text之类的),而是Segment,是若干节区的合并

网上经常有一些说法,比如“C语言的内存模型/布局”

这个说法是有问题的,他实际说的是Linux进程虚拟内存空间的布局,语言本身是没有内存布局的

Linux的内存管理

用户空间动态申请内存

用户空间动态申请内存用的函数是 malloc()。该函数由==C库==提供,并不是系统调用,其内部其实封装了系统调用,根据申请的内存大小而使用brk()或者mmap()系统调用

这个函数在各种操作系统上的使用是一致的,对应的用户空间内存释放函数是 free()。注意:动态申请的内存使用完后必须要释放,否则会造成内存泄漏,如果内存泄漏发生在内核空间,则会造成 ==系统崩溃==

内核空间动态申请内存

Linux内存对于物理内存的管理最底层是以页为单位的而不是以字节,但是还是封装了一些以内存为单位申请/释放的函数

alloc_pages

1
2
3
4
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);

- gfp_mask:分配标志(Get Free Page flags)
- order:分配的页数量为 2^order

此函数会根据gfp_mask选择合适的Zone,再由该Zone的伙伴系统来分配对应的内存页,且分配的内存页都是物理地址连续的。返回的是内存页的数据结构

gfp_mask和Zone区的对应关系:

典型 Zone 特点
GFP_KERNEL NORMAL 普通内核分配,可睡眠
GFP_ATOMIC NORMAL 中断上下文使用,不可睡眠
GFP_DMA DMA 分配低端内存 (<16MB)
GFP_DMA32 DMA32 分配 32位可访问内存 (<4GB)
GFP_HIGHUSER_MOVABLE HIGHMEM/MOVABLE 用户页(匿名 mmap/brk)
GFP_USER NORMAL/HIGHMEM 用户页,可回收
GFP_NOWAIT NORMAL 不可阻塞
__GFP_ZERO 任意 分配后清零内存

__get_free_pages

1
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);

此函数是对alloc_pages的封装,返回值是物理页对应的内核虚拟地址。它分配的内存页的物理和虚拟地址都是连续的

kmalloc

1
2
void *kmalloc(size_t size, gfp_t flags);
void kfree(const void *objp);

用于分配 物理上连续 + 虚拟上连续 的小块内存

  • size:申请的大小

  • flags:分配内存的方法,列举几个常用的

  • GFP_KERNEL: 在内核空间申请内存,如果不能分配,将阻塞进程,故不能在中断等非进程上下文使用

  • GFP_USER:在用户空间申请内存,可能阻塞

  • GFP_DMA: 从直接映射区的DMA区域分配内存

  • GFP_ATOMIC :分配内存的过程是一个原子过程,分配内存的过程不会被(高优先级进程或中断)打断

底层原理:

  • 小内存(通常 ≤ 8KB):底层是对SLAB的封装,它会根据size选择合适的kmem_cache对象,从中取出一块内存。SLAB不仅维护了对象池,也维护了不同大小的内存块(如8B、16B…8KB)不管是对象池,还是内存块,用的都是struct kmem_cache
  • 大内存(> 8KB):直接调用伙伴算法(alloc_pages()),绕过 SLAB

kzalloc

1
2
3
static inline void *kzalloc(size_t size, gfp_t flags){    
return kmalloc(size, flags | __GFP_ZERO);
}
  • kzalloc() 函数与 kmalloc() 非常相似,参数及返回值是一样的,可以说是前者是后者的一个变种,因为 kzalloc() 实际上只是额外附加了 __GFP_ZERO 标志。所以它除了申请内核内存外,还会对申请到的内存内容清零
  • kzalloc() 对应的内存释放函数也是 kfree()

vmalloc

1
2
void *vmalloc(unsigned long size);
void vfree(const void *addr);

用于分配 物理上不连续 + 虚拟上连续 的大块内存

  • 由于 vmalloc() 没有保证申请到的是连续的物理内存,因此对申请的内存大小没有限制,如果需要申请较大的内存空间就需要用此函数了

  • vmalloc()vfree() 可能阻塞,因此不能从中断上下文调用

底层原理:根据申请的内存大小,通过伙伴系统分配物理页(多次调用alloc_pages),之后通过vmap建立新的PTE,将这些物理不一定连续的内存页映射到一段连续的虚拟内存上

伙伴算法

伙伴算法(Buddy System)是 Linux 内核中用于管理物理内存页(Page)的分配与回收的核心算法,属于管理==外部碎片==的一种策略。它的核心思想是:

  • 以2^n为单位将空闲内存划分为不同大小的块,并通过 “伙伴合并” 机制减少内存碎片
  • 主要用于页级内存分配(如 alloc_pages()),是 kmallocvmalloc 等底层依赖的基础

核心机制:

(1)内存块的分组

  • 将内存页划分为不同order的块,每个块包含2^order个连续物理页
  • 内核维护11个(0~10order)的空闲链表free_area,每个链表存储对应大小的空闲块

(2)内存页的分配过程

1.请求分配:

  • 若申请k个page,由2^n>=k计算出需要块的order:n

2.查找空闲块:

  • free_area[n]链表中查找空闲块:
    • 如果找到,直接分配
    • 如果未找到,向更高阶(n+1)链表拆分:
      • 将一块 2^(n+1) 的块拆分为两个 2^n 的“伙伴块”
      • 一块用于分配,另一块加入 free_area[n] 链表

(3)释放与合并

  • 将释放的块放回对应的 free_area 链表
  • 若该块的“伙伴块”也是空闲的,则合并为更大的块(2^(n+1)),并递归向上合并

(4)“伙伴”的定义

  1. 大小相同(同为 2^k 页)
  2. 物理地址连续
  3. 起始地址必须能被 2^(n+1) 整除(即对齐)

slab算法

背景:内核中有大量频繁创建/销毁的小对象,如果每次都用 alloc_pages() 去分配一个整页,就太浪费

SLAB 是 Linux 内核中用于高效管理小内存对象(通常小于 1 页)的内存分配器,底层基于伙伴算法:通过伙伴算法拿到一组物理页,然后在slab内部划分为固定大小的小对象

它的核心目标是:

  • 减少内存碎片:通过对象缓存机制避免频繁分配/释放小内存导致的==内部碎片==
  • 提升性能:重用已初始化的对象,减少内存初始化和销毁的开销

SLAB分配器使用struct kmem_cache来管理一类大小固定的对象,可以把他看成一种对象池

1
2
3
4
5
struct kmem_cache {
unsigned int object_size; // 对象大小
const char *name; // 名称,如 "inode_cache"
struct list_head slabs_full, slabs_partial, slabs_free;
};

SLAB 的工作流程:

(1)对象缓存

  • 内核初始化时,为每种常用对象(如 task_structinodekmalloc-16)建立专用缓存struct kmem_cache,避免频繁从伙伴系统申请内存
1
2
3
struct kmem_cache *task_cachep;
task_cachep = kmem_cache_create("task_struct", sizeof(struct task_struct),
0, SLAB_PANIC, NULL);
  • 每个kmem_cache管理一组SLAB,每个 SLAB又被划分为多个相同大小的对象

(2)分配对象

1
struct task_struct *task = kmem_cache_alloc(task_cachep, GFP_KERNEL);
  1. 从对应缓存中查找 Partial 或 Empty SLAB
  2. 若找到空闲对象:直接标记为已分配并返回
  3. 若无空闲 SLAB:从伙伴系统申请新内存页,创建新的kmem_cache

(3)释放对象

1
kmem_cache_free(task_cachep, task);
  1. 将对象标记为空闲,并检查其所属 SLAB 状态:
    • 若 SLAB 从 Full → Partial:加入缓存的 Partial 链表
    • 若 SLAB 从 Partial → Empty:加入 Empty 链表(可能被释放回伙伴系统)

(3)销毁缓存

当缓存不再需要时(如模块卸载),释放所有 SLAB 到伙伴系统

IO端口和IO内存的访问

  • IO端口:设备的寄存器位于IO空间时,被称为IO端口
  • IO内存:设备的寄存器位于内存空间时,被称为IO内存

由于IO端口和IO内存都是寄存器,所以对其不能随便访问,需要特定的操作

IO端口的访问

Linux内核提供了一些API来进行IO端口的访问:

  • 看Linux设备驱动开发的11.4
image-20250323205035308

IO内存的访问

由于这些寄存器位于内存空间,对他们的访问实际上就是对某些物理内存的访问。但是Linux中对于物理内存的访问都要经过MMU,所以我们需要在页表中建立新的PTE,实现将这些寄存器的物理地址映射到虚拟地址空间。Linux内核中具体的API如下:

1
2
3
4
// 建立映射(返回一个虚拟地址)
void *ioremap(unsigned long offset, unsigned long size);
// 取消映射
void iounmap(void * addr);
  • 在完成映射后,尽管可以直接通过指针访问这些地址,但是Linux内核推荐用一组标准的API来完成设备内存映射的虚拟地址的读写,具体看书
image-20250323205047397

将设备内存映射到用户空间

通过ioremap我们可以获得将寄存器的物理地址转换成虚拟地址,从而在内核态访问,但是在用户态还是没办法直接访问这些设备的寄存器,此时我们需要用到mmap,来把寄存器的虚拟地址映射到用户空间,当用户访问用户空间的这段地址范围时,实际上会转化 为对设备的访问

为啥要在用户空间访问设备寄存器?

  • 一般用在帧缓冲设备上,这样就可以直接在应用层操作显存而避免数据的拷贝

DMA

DMA是一种无须CPU的参与就可以让外设与系统内存之间进行双向数据传输的硬件机制。使用DMA可以使系统CPU从实际的I/O数据传输过程中摆脱出来,从而大大提高系统的吞吐率

DMA方式的数据传输由DMA控制器(DMAC)控制,在传输期间,CPU可以并发地执行其他任务。 当DMA结束后,DMAC通过中断通知CPU数据传输已经结束,然后由CPU执行相应的中断服务程序进行后处理。

DMA与Cache一致性

Cache一致性的定义:同常指的是Cache和内存的一致性,有的时候物理内存被改变了,但是Cache没有同步,但是CPU一般优先访问Cache再访问物理内存,这就发生了Cache和内存的不一致问题

在使用DMA时,可能会造成Cache一致性问题,这个问题出现在DMA的目的地址与Cache所缓存的内存地址访问有重叠的情况

image-20250323211001434

DMA编程

DMA是一种外设与内存交互数据的方式,内存中用于与外设交互数据的一块区域称为DMA缓冲区,它在物理内存中必须连续

内核数据结构

image-20251030210527021

内存描述符

每个进程都有一个虚拟地址空间,那么在内核中,是否有数据结构来表示这个虚拟地址空间的一些信息呢?

  • 有的,每个进程的PCB(task_struct)都有一个内存描述符的成员变量mm_struct描述一个进程的用户空间的虚拟内存布局
1
2
3
4
5
6
7
8
9
struct mm_struct {
struct vm_area_struct *mmap; // VMA链表,描述各个内存区域
pgd_t *pgd; // 指向页全局目录(page global directory),也就是页表根
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 数据段
unsigned long start_brk, brk; // 堆
unsigned long start_stack; // 栈
...
};

VMA

VMA(vm_area_struct)是内存子系统中另一个非常重要的数据结构,它是虚拟内存管理的基本单位,表示虚拟地址空间中一段连续的虚拟内存区域,并且有相同的权限属性

例如一个进程的内存空间:

  • 0x08048000-0x08049000 → 代码段(只读、可执行)
  • 0x08049000-0x0804A000 → 数据段(可读可写)
  • 0xBFFFF000-0xC0000000 → 栈(可读可写,向下增长)

这些区间在内核里就对应不同的VMA

1
2
3
4
5
6
7
8
9
10
11
12
struct vm_area_struct {
unsigned long vm_start; // 区域起始虚拟地址
unsigned long vm_end; // 区域结束虚拟地址
struct vm_area_struct *vm_next;
struct rb_node vm_rb; // 挂到红黑树
struct mm_struct *vm_mm; // 属于哪个进程

pgprot_t vm_page_prot; // 页保护属性
unsigned long vm_flags; // 权限标志 (读/写/执行/共享)
struct file *vm_file; // 如果是文件映射,这里指向对应文件
...
};

特点:

1.每个VMA描述一段连续的虚拟内存区间,并且有统一的权限和属性(r/w/x,私有还是共享)

2.一个进程可能有几十、上百个 VMA(不仅仅是代码段、堆、栈,还包括 mmap 的 so 库、文件映射、匿名页等)

3.mm_struct 通过红黑树/链表组织所有 VMA

为什么在mm_strcut中已经有了各内存段的起始地址,比如unsigned long start_stack; 还需要vm_area_struct

  • mm_struct:进程的“全局内存描述”,粗粒度,告诉我们这个进程的虚拟内存大致布局
  • vm_area_struct:进程的“内存区间表”,精细化,负责具体的内存访问权限、缺页处理