kernel version | arch |
---|---|
v5.4.0 | arm32 |
CPU 与内存,I/O
内存空间与I/O空间的区别
I/O 空间的概念是存在于 X86 架构中的,与内存空间做区分,它通过特定的指令in
,out
来访问外设寄存器的地址。
但是在实际使用时,我们也可以将外设设计在 X86 架构的内存空间中,直接访问寄存器地址,所以 I/O空间是可选的 。
在大多数嵌入式微控制器中没有I/O空间,仅有内存空间。内存空间可以直接通过地址,指针来访问,程序以及其他数据都是存在于内存空间中的。
1 | 再次强调:无论是在内核态还是在用户态,CPU 看到的都是虚拟地址! |
内存管理单元MMU
MMU提供虚拟地址和物理地址的映射,内存访问权限保护和 Cache 缓存控制等硬件支持,
用户在编写实际程序时不用考虑实际物理地址有多大,以及是否会与其他程序地址冲突等等。
- 具体MMU工作参考 MMU基本原理
MMU操作原理
MMU中比较重要的两个概念:
- TLB(Translation Lookaside Buffer)
- 转换旁路缓存,TLB 是 MMU 的核心部件,它缓存少量的虚拟地址与物理地址的转换关系,是转换表的Cache,因此也经常被称为”快表”。
- TTW(Translation Table wale)
- 转换表漫游,当TLB中没有缓冲对应的地址转换对应关系时,需要通过对内存中转换表的访问来获得虚拟地址和物理地址的对应关系。TTW成功后,结果应写入TLB中。
MMU操作的原则都是以最快的速度来读写 CPU 所需要的数据或指令:
- 所以它会首先访问 TLB 以保证最快的速度找到映射关系然后进行存取,如果此时打开了Cache并且Cache命中,那也会直接取Cache的数据否则取内存的数据并且更新Cache
- 如果TLB没有命中那么就会访问 TTW 找到映射关系并反过来更新 TLB。
MMU的权限管理
MMU的权限管理主要包含以下两个方面:
- 这段内存是否具有RWX权限(比如代码段只有RX权限,避免被改写)
- 这段内存是仅有内核才可访问,还是内核和用户都可访问
- 仅有内核可访问的内存,避免用户获取到内核的数据
权限管理使用下面程序进行体验:
1 | //main.c |
meltdown漏洞
meltdown漏洞使得用户空间可以访问内核空间中的内容,详细解释参考 格友 。
简单解释就是:
- 用户空间先申请一个大数组,这个大数组的每个元素的大小即为内存页表的大小,这是为了每个页可以覆盖整个 cache,便于后期测试不被干扰
- 用户空间发送读取内核空间中 一个字节的请求 ,一个字节的值为 0~255,假设该值为 N
- 由于CPU的分支预测执行功能,将用户空间大数组的第 N 个块进行读取操作(此时 N 的值依然存在于寄存器中)
- 虽然MMU进行了权限检查,但此时用户空间中数组的第 N 个块的部分数据已经存在于 cache 中了,此时 cache hit
- 由于读取Cache的速度要远远快于读取内存的速度,用户通过依次扫描 0~255 块的读取速度,识别出读取最快的那个块,便知道这第 N 个块代表内核地址的值为 N
解决方案:
1 | 由于这个漏洞是由硬件造成的,而执行的入口是用户空间和内核空间共用了一个页表(这样用户空间才可以通过虚拟地址去访问内核)。 |
实例体验:实际代码及操作位于 宋宝华老师github
Linux内存管理
- 在Linux系统中,进程的 虚拟4GB内存空间 被分为两个部分—用户空间和内核空间.
- 用户空间的地址一般分布为0
3GB(即PAGE_OFFSET),剩下的34GB为内核空间.- 用户进程只有通过系统调用(代表用户进程在内核态执行)等方式才可以访问到内核空间.
- 每个进程的用户空间都是完全独立,互不相干的。**用户进程各自有不同的页表。而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。
- *内核空间的虚拟地址到物理地址的映射是被所有进程共享的,内核虚拟空间独立于其他程序***。
在menuconfig中 Kernel Features -> Memory split(..)
可以选择设置 CONFIG_PAGE_OFFSET
,默认内核空间就是位于3G~4G空间的。
1 | //file:arch/arm/include/asm/memory.h |
- 由上面代码也可以知道内核中可以使用
PAGE_OFFSET
宏来判断内核虚拟空间的起始地址
对物理内存条的分配
- 请注意: 这里说的是物理内存条,不是内存空间
Linux一般将内存条分为DMA_ZONE, NORMAL_ZONE, HIGH_ZONE3个区,
阅码场 上有清晰的说明,
quora 上对此也有解释。
DMA_ZONE
DMA_ZONE 是为特定 DMA 划分的区域,某些芯片的 DMA 控制器无法访问全部内存条(有些仅能访问有限的十几兆空间),所以 Linux 为此类 DMA 规划一片内存.
当实际编写内核代码时,申请 DMA 缓存时使用 GFP_DMA
标记,以告知 Linux 在那片固定区域申请。
在内核代码中也有关于此标记的注释(提到了还可以作为紧急后备内存来使用):
1 | GFP_DMA exists for historical reasons and should be avoided where possible. |
DMA_ZONE 的设置一般在构架目录下的Kconfig中设置,比如 arch/arm/Kconfig
具有其使能标记,但在设置前一定要搞清楚具体硬件!
NORMAL_ZONE
前面说过,在虚拟地址中34G为内核空间。 **Linux将物理内存的01G线性映射到3G~4G虚拟地址空间** ,而这1G的空间减去 DMA_ZONE 剩下的部分就是 NORMAL_ZONE。
所谓的线性映射指的就是页表的简单映射关系,一般这种情况下仅仅是一个简单的偏移即可转换,内核提供了函数以相互转换:
1 | /** |
注意: 线性映射并不是内核已经占用了内存,而是提前映射好以便后面操作,而无需使用时再来映射。
HIGH_ZONE
当实际的物理内存大于1G时,多于的部分就是HIGH_ZONE.
当内核空间要使用此段内存时,由于没有提前映射,则需要经过以下步骤使用:
- 映射HIGH_ZONE到 高端页面映射区
- 使用
- 释放映射
注意: 内核对HIGH_ZONE 不能使用 virt_to_phys,phys_to_virt
来转换,因为它们不是简单的线性映射!
对于用户空间而言,用户申请内存时,Linux搜寻内存的路径为: HIGH_ZONE -> NORMAL_ZONE -> DMA_ZONE.
对内核虚拟空间的分配
x86-32 架构下的分配
Linux中1GB的虚拟内核地址空间又被划分为:
区域名称 | 虚拟地址位置 | 相关代码 |
---|---|---|
保留区 | FIXADDR_TOP ~ 4GB | 搜索宏 FIXADDR_TOP |
专用页面映射区 | FIXADDR_START ~ FIXADDR_TOP | 搜索宏 FIXADDR_START |
高端内存映射区 | PKMAP_BASE ~ FIXADDR_START | 搜索宏 PKMAP_BASE |
隔离区 | ||
vmalloc虚拟内存分配区 | VMALLOC_START ~ VMALLOC_END | 搜索宏 VMALLOC_START |
隔离区 | ||
物理内存映射区 | 3GB起始最大长度896M(对应物理内存的896M) |
1 | 直接映射的最大896M物理内存分为两个区域: |
- 当系统物理内存超过4GB时,必须使用CPU的扩展分页(PAE)模式所提供的64位页目录才能取到4GB以上的物理内存。
由上表可以看出:此片虚拟区域一共1G,但实际物理内存映射区不足1G(还有其他区域占用了地址空间)。
如果我们将vmalloc分配区设置得大一点,那么对应物理内存映射区就会小一点。对应的反应到物理内存上,那就是可映射的低端内存区变小了,相应的高端内存区就变大了。
arm32 linux 下的分配
区域名称 | 虚拟地址位置 | 相关代码 |
---|---|---|
向量表 | 0xfff0000~0xfff0fff | 文档 Documentation/arm/memory.txt |
隔离区 | ||
vmalloc和ioremap区域 | VMALLOC_START ~ VMALLOC_END -1 | 宏 VMALLOC_START |
隔离区 | ||
DMA和常规区域映射区 | PAGE_OFFSET ~ high_memory -1 | 宏 PAGE_OFFSET 以及变量 high_memory |
高端内存映射区 | PKMAP_BASE ~ PAGE_OFFSET -1 | 宏 PKMAP_BASE |
内核模块 | MODULES_VADDR ~ MODULES_END -1 | 宏 MODULES_VADDR |
由上表可以看出:
- 对于arm32 来说, 从内核模块开始的地方就已经是内核空间了!
- 此片虚拟区域一共1G,但实际物理内存映射区不足1G(还有其他区域占用了地址空间)。
- 如果我们将vmalloc分配区设置得大一点,那么对应物理内存映射区就会小一点。对应的反应到物理内存上,那就是可映射的低端内存区变小了,相应的高端内存区就变大了。
1 | 在编译内核的时候可以选择: |
DMA、常规、高端内存分布
有以下4种可能的情况分布(地址由低到高):
- DMA区域 | 常规区域 | 高端内存区域
- 内存较大,硬件DMA只能访问一部分地址,并且内核映射不完所有的物理内存,剩下的部分就是高端内存区域
- DMA区域(常规区域) | 高端内存区域
- 内存较大,硬件DMA可以访问全部地址,但内核映射不完所有的物理内存,剩下的部分就是高端内存区域
- DMA区域 | 常规区域
- 内存较小,硬件DMA只能访问一部分地址,且内核可以完全映射物理内存
- DMA区域(常规区域)
- 内存较小,硬件DMA可以访问全部地址,且内核可以完全映射物理内存
buddy 算法
DMA、常规、高端内存分布区 最底层 使用的是 buddy
算法进行管理,它将空闲 页 面以 2 的 n次方进行分配,而内存申请也是也 2 的 n 次方申请。
- buddy 在不断的拆分和合并,其空闲页面以 1,2,4,8,16… 这种形式组织起来
- 从16个页面中取出一页后,buddy会拆分为 1,2,4,8 空闲页
- 如果原来是1,2,8的空闲,现在又释放了2页, 如果这2页和原来空闲的2页内存连续 ,buddy会合并为1,4,8空闲页
- 与此同时, 用户每次申请也只能是2的n次方!
1 | 在 /proc/buddyinfo 会显示这些区域的空闲页面分布情况,依次从左到右显示 1,2,4,8,16 空闲页数量 |
在内核编程时,可以使用以下函数来申请buddy页(一般不会直接使用):
1 | /** |
内存申请实际操作
用户空间内存动态申请
用户空间的内存申请和释放使用标准的c库即可:
1 |
|
Linux内核总是采用按需调页(Demand Paging),因此当malloc()返回的时候,虽然是成功返回,但是内核并没有真正给这个进程内存。这个时候如果去读申请的内存,内容全部是0,这个页面的映射是只读的。只有当写到某个页面的时候,内核才在页错误后,真正把这个页面给这个进程。
内核空间内存动态申请
物理内存连续申请
函数 kmalloc() 和 __get_free_pages()以及类似函数
申请的区域位于 DMA和常规区域的映射区
,在物理上是连续的,与真实物理地址只有一个固定的偏移。
- kmalloc() 底层依赖于
__get_free_pages()
1 | /** |
物理内存不一定连续申请
函数 vmalloc()
申请区域位于 vmalloc区域
,在物理上不一定是连续的,与真实物理地址转换关系也不简单。
- vmalloc() 一般只为存在于软件中的(没有对应硬件访问)较大的内存分配
- vmalloc() 效率没有 kmalloc() 高,不适合用来分配小内存
- 在申请时会内存映射并修改页表
- vmalloc() 不能用在原子上下文中 ,因为它内存实现使用了标志为
GFP_KERNEL
的 kmalloc,可能会导致睡眠
1 | void *vmalloc(unsigned long size); |
slab机制提高少量字节申请效率
slab机制使得内核中的小对象在前后两次被使用时分配在同一块内存或同一类内存空间且保留了基本的数据结构,大大提高分配效率。
- kmalloc() 就是使用 slab 机制实现的
- 使用 slab机制申请的内存与物理内存之间也是简单的线性偏移关系
- 查看
/proc/slabinfo
可以得到当前 slab 分配和使用情况
1 | /** |
使用例子:
1 | static kmem_cache_t *xxx_cachep; |
内存池技术也是用于分配大量小对象的后备缓存技术。
1 | mempool_t *mempool_create(int min_nr, mempool_alloc_t *alloc_fn, |
设备 I/O 端口和 I/O 内存的访问
设备通常会提供一组寄存器来控制设备,读写设备和获取设备状态,这些寄存器可能位于 I/O 空间中,也可能位于内存空间中.
- 当位于I/O 空间时,通常被称为 I/O端口;
- 当位于内存空间时,对应的内存空间被称为 I/O内存.
- 在使用I/O区域时,需要 申请该区域 ,以表明驱动要访问这片区域.
I/O 端口
I/O 端口的具体操作流程为:
- 申请I/O端口资源
- 使用读写函数操作I/O端口
- 释放I/O端口资源
申请与释放
1 | //! 向内核申请 n 个端口,这些端口从 start开始,name 参数为设备的名称 |
读写操作
1 | //!读写一字节端口 |
I/O 内存
I/O内存的操作流程为:
- 申请I/O内存资源
- 将资源地址映射到内核虚拟空间
- 使用读写函数操作
- 释放I/O内存资源
申请与释放
1 | //! 申请以start为开始的,n字节的I/O内存区域,名字为name |
映射
1 | /** |
读写操作函数
1 | /** |
将设备地址映射到用户空间
驱动可以通过mmap()函数来给用户空间提供设备的虚拟地址,以达到间接访问的目的。
mmap()实现这样一个映射的过程:将用户空间的一段内存与设备内存关联,当用户访问用户空间的这段地址范围时,
实际上会转化为对设备的访问。
1 | 一般这样做的目的并不是为了用户空间来直接控制寄存器,因为这就破坏了分层的原则。 |
内存映射与VMA
1 | //! 内核 file_operatoins 中的 mmap() |
当用户调用 mmap()的时候,内核会进行如下处理.
- 在进程的虚拟空间查找一块 VMA
- 将这块VMA进行映射
- 如果设备驱动程序或者文件系统的file_operations定义了mmap()操作,则调用它
- 将这个VMA插入进程的VMA链表中
驱动中的mmap()的实现机制是建立页表,并填充VMA结构体中 vm_operations_struct
指针.
1 | //! 用于描述一个虚拟内存区域 |
VMA结构体描述的虚拟地址介于 vm_start 和 vm_end之间,其 vm_ops 成员指向这个VMA的操作集, 针对VMA的操作都被包含在 vm_operations_struct 结构体中.
操作范例:
1 | static int xxx_mmap(stuct file *filp, struct vm_area_struct *vma) |
fault() 函数
fault() 函数可以为设备提供更加灵活的内存映射途径。
当访问的页不在内存时,fault()会被内核自动调用。
当发生缺页时,流程为:
- 找到缺页的虚拟地址所在的VMA
- 如果必要分配中间页目录表和页表
- 如果页表项对应的物理页面不存在,则调用 fault() 函数,它返回物理页面的页描述符
- 将物理页面地址填充到页表中
I/O内存静态映射
在将linux移植到目标电路板的过程中,有的会建立外设I/O内存物理地址到虚拟地址的静态映射,这个映射通过在与电路板对应的 map_desc 结构体数组中添加新的成员完成.
1 | struct map_desc{ |
DMA内存
DMA与硬件Cache一致性
- 在DMA不工作的情况下或者DMA与Cache相对应的主存没有重叠区, 那么Cache 与主存中的数据具有一致性特点.二者并不会起冲突.
- *当DMA与Cache相对应的主存有重叠区时,当DMA更新了重叠区的内容,而Cache并没有对应的更新.此时CPU仍然使用的是陈旧的cache的数据,就会发生Cache与内存之间数据”不一致性”的错误!
- 当CPU向内存写数据时,此时也是先写到了cache,DMA传输数据到外设依然是原来陈旧的数据
- 在发生Cache与内存不一致性错误后,驱动将无法正常运行.
- Cache的不一致问题并不是只发生在DMA的情况下,实际上,它还存在于Cache使能和关闭的时刻.例如,对于带MMU功能的ARM处理器,在开启 MMU之前需要先置Cache无效,否则在开启MMU之后,Cache里面有可能保存的还是之前的物理地址,这也会造成不一致性的错误!.
Linux 下的DMA编程(DMA只是一种外设与内存的交互方式)
内存中用于外设交互数据的一块区域称为 DMA 缓冲区, 在设备不支持scatter/gather操作的情况下,DMA缓冲区在物理上必须上连续的.
- 当硬件支持
IOMMU
时,缓冲区也可以不连续
DMA区域
对于大多数现代嵌入式处理器而言,DMA操作可以在整个常规内存区域进行,因此DMA区域就直接覆盖了常规内存.
虚拟地址,物理地址,总线地址
- 总线地址: 基于DMA硬件使用的是总线地址而不是物理地址,是从设备角度上看到的内存地址
- 物理地址:是从CPU MMU 控制器外围角度上看到的内存地址
- 虚拟地址:CPU看到的是MMU反映给它的地址
DMA地址掩码
设备不一定能在所有的内存地址上执行DMA操作,在这种情况下需要设置DMA能够操作的地址总线宽度.
1 | int dma_set_mask(struct device *dev, u64 mask) |
如果DMA只能操作24位地址,那么就应该调用 dma_set_mask(dev,0xffffff)
此时内核会为申请增加
GFP_DMA
标记,以从 DMA_ZONE 中申请内存一致性DMA缓冲区
为了能够避免 DMA与Cache一致性问题,使用如下函数分配一个DMA一致性的内存区域:
操作此函数的过程是不用关心CMA区域设置,这个是内核底层完成的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26/*
申请一致性DMA缓冲区(一般不带cache, 但如果有 cache coherent interconnect 硬件支持,则就可以带cache)
note: 这段缓存区一般是连续的,但如果硬件带IOMMU,则也可以是不连续的
,*/
//返回申请到的DMA缓冲区的虚拟地址
//handle 代表总线地址
void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *handle, gfp_t gfp);
//释放申请的内存
void dma_free_coherent(struct device *dev,size_t size, void *cpu_addr, dma_addr_t handle);
/*
分配一个写合并(writecombining)的DMA缓冲区
,*/
void *dma_alloc_writecombine(struct device *dev, size_t size, dma_addr_t *handle, gfp_t gfp);
//释放
void dma_free_writecombine(struct device *dev,size_t size, void *cpu_addr, dma_addr_t handle);
/*
PCI设备申请缓冲区
,*/
void *pci_alloc_consistent(struct pci_dev *pdev, size_t size, dma_addr_t *dma_addrp);
//释放
void pci_free_consisten(struct pci_dev *pdev, size_t size, void *cpu_addr, dma_addr_t dma_addr);
注意:
dma_alloc_xxx()
函数虽然是以 dma_alloc_开头, 但是其申请的区域不一定在DMA区域里面.以32位ARM处理器为例,当conherent_dma_mask小于0xffffffff时,才会设置GFP_DMA标记,并从DMA区域去申请内存.
流式DMA映射
在许多情况下缓冲区来自内核的较上层,上层很可能以普通的 kmalloc() 等方式申请内存,
也就是说这段内存是具有硬件cache的,这时就需要使用流式DMA。
流式DMA操作在本质上大多就是进行flush或invalidate Cache操作,以解决一致性问题。
- flush 是指将cache内容写入内存,invalidate是指让CPU再次从内存读取数据来刷新一次cache
- 如果有
cache coherent interconnect
硬件,则不需要关闭cache,从应用编程的角度来讲,只要按照规矩来操作即可。
操作步骤为:
- 进行流式DMA映射
- 执行DMA操作
- 取消映射
1 | //一片内存操作 |