这是<Linux内核内存管理>系列的第八篇:
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。
第四篇概览了初始化C代码中的内存处理
第五篇(上)和第五篇(下)介绍了Memblock和伙伴系统分配器
第六篇介绍了内存检测工具KFence工作原理
第七篇介绍了进程内存分配malloc的原理
第八篇介绍了MMAP映射和反向映射原理
Page Fault(缺页异常)大概是最为常见的异常,它发生在CPU访问不在内存的页时。本文以Intel IA32体系结构为例,介绍Linux对缺页异常的处理过程。
这是<Linux内核内存管理>系列的第八篇:
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。
第四篇概览了初始化C代码中的内存处理
第五篇(上)和第五篇(下)介绍了Memblock和伙伴系统分配器
第六篇介绍了内存检测工具KFence工作原理
第七篇介绍了进程内存分配malloc的原理
mmap()的主要作用是将文件(普通文件或者设备文件)映射到进程的内存地址空间中,让应用程序可以以读写内存的方式来访问文件。与之对应的操作是munmap()。
一段示例代码来自维基百科如下:
1 |
|
上述代码的输出是:
1 | PID 22475: anonymous string 1, zero-backed string 1 |
主要作用是创建了两个匿名映射,父进程和子进程可以通过匿名映射来访问共享的内存。
mmap和munmap的架构如图所示,与上篇文章中介绍的malloc()工作原理类似:
内核实现mmap的核心函数是do_mmap():
1 | unsigned long do_mmap(struct file *file, unsigned long addr, |
该函数主要就是根据输入参数做一系列检查,并根据参数配置vm_flags,最终传入mmap_region()函数开始创建映射。
1 | unsigned long mmap_region(struct file *file, unsigned long addr, |
mmap_region()函数的实现也比较简单,这里不做过多解释。值得注意的是:如果传入的文件为空,则表示创建匿名映射。若连共享标记VM_SHARED也未指定,则与使用malloc()分配内存相同,仅为对应虚拟地址创建内存映射。
munmap()的内核实现仅为移除对应VMA映射,本文也不再做分析。
反向映射的作用是给定物理页面,找到与其对应的所有进程的VMA。为什么会有这样的查找呢?这是因为所有进程的虚拟内存总大小往往远大于物理内存,为了支撑Linux系统的有效运作,内核在管理内存时,会将暂时不用的物理内存页换出到磁盘上,在有需要时再换入到内存中。
这种情况下,如何确定该物理内存有哪些进程正在使用?这便需要反向映射。
系统中内存页很多,在管理反向映射时,即使引入很小的数据结构,也会带来很大的额外内存开销。同时,因为反向映射使用比较频繁,也需要最优化查找效率,避免成为系统瓶颈。
回顾一下struct page,为了节省管理开销,其定义了很多联合体。其中与逆向映射有关的储存在mapping,_mapcount,index等成员中。
1 | struct page { |
内核文档对这种映射有个直观的描述如下图:
简单一点讲:物理页结构体struct page使用mapping成员查找所有该页对应的VMA,从而找到所有正在使用该物理页的虚拟页。
mapping成员查找VMA的方法并非如上图那样容易理解。实际需要考虑很多情况,因此内核设计了如下数据结构:
1 | /* |
事实上,数据结构定义有描述为什么需要这样的数据结构而不是直接由mapping指向vma_area_struct。即:vm_area_struct可能会被合并、拆分等。
下图描述了当fork一个新进程时,反向映射相关字段的变化状况。
这样的关联建立起来后,通过物理页结构体struct page就可以查找到所有的关联VM area。
本文概要介绍了mmap和反向映射的原理。
内存管理系统内容纷繁,也是内核工作者集体智慧的结晶,笔者在理解时不免有遗失或者偏差之处。如您有问题或者建议,请留言提出讨论。
]]>这是<Linux内核内存管理>系列的第七篇:
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。
第四篇概览了初始化C代码中的内存处理
第五篇(上)和第五篇(下)介绍了Memblock和伙伴系统分配器
第六篇介绍了内存检测工具KFence工作原理
malloc() 大概是在Linux平台上用户空间态编程,最常用的内存分配函数。大家可能会想,
这个函数是如何拿到内存的?内核如何为它做的映射?
另外,一个可执行程序有自己的代码和静态数据,内核如何将这个可执行程序代码加载到内存中执行?其对应的静态变量,全局变量等所需内存又是如何分配的?
以上问题是开发用户空间态程序时,容易被忽略的、甚至完全不会被注意到的问题。因为这些都是由程序所链接的C库和底层内核实现的,程序开发者往往无需在意这些细节。
一般情况下,这并不影响大家写出一个像样的程序。但是当面临一些疑难问题时,仅有如何使用C函数的知识,是无法胜任和处理的。
本文意在从以下几点剖析内核处理进程内存有关的过程:
下图简要描述了Linux内存管理架构:
当然除了系统调用之外,内核和用户空间态通信方式还有Netlink等。
内核为每个进程分配了一个数据结构task_struct,而其中管理内存的部分是mm_struct:
1 | struct mm_struct { |
其中所结构体栏位的意义标注如下图:
以上地址皆为虚拟地址,是内核进程启动的过程中,由内核所初始化。
您可能会想知道,前一节所提及的那些段地址,内核是如何确定的?其实这跟ELF格式有关。
如果没有明确指定,GCC会指定一个默认的Linker Script
一张图描述上述过程。
内核加载进程执行,也遵守ELF规范,在此期间为进程分配虚拟内存VMA。
使用musl Libc来对malloc()进行介绍。
没有选择Glibc分析的原因,是因为没有搞懂Systemtap的原理。使用musl Libc分析不会影响理解。
1 | static void *__simple_malloc(size_t n) |
这段代码比较容易理解,我们只关注其中__syscall(SYS_brk, ….)。它的作用就是使用brk这个系统调用向内核要内存。
weak_alias的定义如下:
1 | # |
其中Weak Alias的意义即给old symbol设置一个别名new。
内核空间态处理brk系统调用的代码如下:
RLIMIT_DATA
The maximum size of the process’s data segment (initialized data, uninitialized data, and heap). This limit affects calls to brk(2) and sbrk(2), which fail with the error ENOMEM upon encountering the soft limit of this resource.
1 | SYSCALL_DEFINE1(brk, unsigned long, brk) |
要更进一步理解以上过程,皆需理解VMA的管理方式。引用<深入理解Linux内核架构>一书的介绍:
如果一个新区域紧接着现存区域前后直接添加(因此也包括在两个现存区域之间的情况),内核将涉及的数据结构合并为一个。当然,前提是涉及的所有区域的访问权限相同,而且是从同一后备存储器映射的连续数据。
如果在区域的开始或结束处进行删除,则必须据此截断现存的数据结构。
如果删除两个区域之间的一个区域,那么一方面需要减小现存数据结构的长度,另一方面需
要为形成的新区域创建一个新的数据结构。
代码部分不做进一步分析,大家可以直接看内核源码或者找相关资料学习。
本文概要介绍了Linux内核对进程内存的管理方式。主要有:
进程内存管理还涉及到以下知识,将会在之后的文章中介绍:
注意:这只是一个Work Around方法,并非完全解决方案
MAC升级Big Sur后,每次运行Virtual Box都会有如下报错:
1 | Kernel driver not installed (rc=-1908) |
CSDN这篇文章介绍得很详细,但不够完整,这里做一下补充
到Virtual Box官网下载Extension Pack并安装,如下图:
下载安装时,如果被系统安全阻拦,可以在“安全与隐私”设置里信任相关程序的执行。
这里很奇怪,无论怎么安装重启,在我的电脑上,对应的Driver总不能开机自动运行(这也是前文说这是Workaround的原因),因此每次重新开机后还必须执行一次如下命令:
1 | sudo kextload -b org.virtualbox.kext.VBoxDrv |
接着再运行Virtual Box就不会报错了。
后续若找到自动加载的方法,可以再交流讨论更新
这里说明一下,会有以上报错的主要原因就是MAC High Sierra,开始逐渐淘汰运行在内核态的驱动,改推荐(甚至强制推行)厂商改用System Extension来开发驱动,于是乎,各种兼容性问题就来了。
这也是Apple强硬的一点。当然反之使用System Extension是有好处的,希望Oracle后续看如何更好地兼容新的MAC OS。
]]>这是<Linux内核内存管理>系列的第六篇
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。
第四篇概览了初始化C代码中的内存处理
Kernel Electric-Fence (KFENCE)是5.12版本内核新引入的内存使用错误检测机制。它可以检查的错误有:
显然,它可以检测的内存错误类型不如KASAN多。但与KASAN相比,它最大的优势是运行时小Overhead,可以直接用在生产环境中。因此在X86,ARM64,RISCV等平台上均默认开启。
在Arch对应的defconfig中使用CONFIG_HAVE_ARCH_KFENCE开启。
Kfence的原理比较简单,如下图:
1 |
这个值可以通过sysfs修改
大于一个Page(4K)的分配不会从KFENCE Pool中分配
解释一下为什么保护数据叫Canary。这是因为在19世纪,金丝雀在采矿业中常用的毒气检测方法,因为它们比人类对毒气更为敏感反应也更快。
在以下情况,会检测报错:
开源社区总能带来新的idea。KFENCE,克服了KASAN等工具需要占用大量内存且影响运行时性能的缺点,是一个有效地运行时内存访问错误检测工具。
当然,因为它所针对的内存区域仅仅是KFENCE内存池,且其是周期性进行采样,检测效果还不得而知。其又有可以动态开关、参数可调节等优点,这些劣势或许也不是问题。后续若有时间可以研究分析对比其和KASAN的检测效果。
]]>PlantUML可以算是最成功最知名的开源绘图工具了,它可以方便地将您输入文字型描述,转化成您想要的各种图(当然,要遵循其语法)。近日,在做流程图的时候,到网站上去翻语法,赫然发现其增加了一个新的主题功能。
用法也很简单, 在文件头部增加如下配置即可:
1 | !theme 主题名 |
例如,如下图采用内置的spacelab主题:
1 | @startuml |
效果如下:
除了内置主题,也支持本地主题:
1 | !theme 主题名 from /本地/摆放/主题的路径 |
互联网主题也支持,例如:
1 | !theme 主题名 from https://raw.githubusercontent.com/plantuml/plantuml/master/themes |
您可以使用如下代码查看PLANTUML支持哪些内置主题:
1 | @startuml |
目前plantuml支持如下内置主题,为了方便大家选用,我将所有的示例图显示出来(P.S. 个人比较喜欢sketchy):
本文介绍了PlantUML的主题,将内置主题的样式全部呈现出来供参考。这类开发实用工具其实很多,本人后续也会推荐一些优秀的工具给大家。本站的链接栏也有一些链接可以参考。
]]>这是<Linux内核内存管理>系列的第六篇
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。
第四篇概览了初始化C代码中的内存处理
为了避免晦涩难懂,本文及之后均主要使用图表+文字描述,尽量避免涉及过多代码。专注点会在:
网络上介绍SLAB/SLUB的文章很多,也都很详细,本文以当前内核版本(5.14.X)来介绍被广泛采用的SLAB内存管理,希望尽可能地做到详尽易理解。一些更多的参考资料见,文中不再另外标注引用:
kmalloc/kfree 大概是内核最常用的内存分配和释放函数,其背后的实现就是SLAB分配器。而SLUB是SLAB分配器的一种实现,另外的两种实现分别是SLAB和SLOB。从命名也可以看出SLAB是鼻祖,随着内核的发展,演进出了SLOB和SLUB分配器。
SLAB分配器解决的是什么问题?这个问题可以换种方式来问,为什么有了Buddy System,还要用SLAB分配器? 解释如下:
为什么这三种分配器又都是SLAB分配器的实现?这是因为这三种分配器采用一样的数据结构名称和内存分配/释放API(注意,仅仅是“名称”一样)。例,其管理结构体,都叫struct kmem_cache。
前言讲到,SLAB/SLOB/SLUB采用相同的API,相同的结构体,那么他们一定是相互排他的,这从内核定义KConfig也可以看出:
1 | choice |
从中默认选项就是SLUB。
KConfig相关知识可以参考KConfig Language
SLAB(下文中SLAB也统一代表SLUB)在系统中的位置如Figure 1所示.
简单说明如下:
下表介绍SLAB及SLUB相关内核源文件:
文件 | 描述 |
---|---|
slab.c | SLAB分配器(三个分配器之一)的实现 |
slab.h | 所有SLAB分配器的头文件定义 |
slob.c | SLOB分配器的实现 |
slub.c | SLUB分配器的实现 |
slab_common.c | 所有SLAB分配器公用的,与实现无关的函数。大部分都会调用到具体的某个分配器。 |
SLAB的重要的数据结构有三个,其内容和相互关系如下图:
其中:
SLUB的管理方式如下图:
简单描述如下:
这样处理可以保证总是优先从该cpu的cache区域分配,提升资源的访问速度。
场景 | 释放方式 |
---|---|
释放前该页上内存已经全部使用,per cpu partial链表上的空闲可用内存总数 > kmem_cache.cpu_partial | 1. 将kmem_cache_cpu的partial链表上的页挂到per node partial。 2.将该内存所在页放回kmem_cache_cpu的partial链表 |
释放前该页上内存已经全部使用,per cpu partial链表上的空闲可用内存总数 <= kmem_cache.cpu_partial | 将该内存所在页放回kmem_cache_cpu的partial链表 |
1. 该页在per node partial 2.释放后,该页呈未分配状态 3.kmem_cache_node.nr_partial > kmem_cache.min_partial | 将该页归还给伙伴系统 |
其他 | / |
设置阈值的主要目的是为了避免SLAB占用过多的内存页,导致系统中其他对象想要分配内存时拿不到内存。
本文介绍了SLAB内存分配器,其是整个系统运行中,起重要且主要作用的内存分配器。介绍了:
希望对您分析内核代码有所帮助。
]]>书接前文,本文介绍《Memblock和Buddy System》的第二篇,第一篇见前文
伙伴系统便是使用页为单位对内存进行管理的方法。伙伴系统接管前,处理建立mem_section结构,也必须先从Mem Block中释放出不再使用的内存交给伙伴系统管理。本文Figure 2中略有体现,实现这个过渡的函数是memblock_free_all:
1 | void __init memblock_free_all(void) |
free_unused_memmap 释放未使用mem_map内存。
reset_all_zones_managed_pages 作用是将所有节点所有区域的managed_pages自动设置为0(managed_pages表示被伙伴系统管理的页的数量)。
_free_low_memory_core_early_主要做两个动作:
将reserve类型的memblock和明确标记为Memory None的内存对应的页做标记为reserved(PG_reserved)
将Mem block类型为memory的区域free掉,并标记为Free页面
_totalram_pages_add_增加 _totalram_pages ,用于标记系统中可用总页数。
伙伴系统的管理方式可以参考<Understanding the Linux® Virtual Memory Manager>的图:
每个内存区域(zone),都有一个链表数组,数组元素用来存放 $2^{Order}$个页的链表。内存的分配和释放便围绕着这个表来管理。
数据结构一文,我们已经介绍的struct page/struct zone/struct pglist_data等数据结构。我们回顾其中部分字段:
1 | //include/linux/mmzone.h |
zone
1 | typedef struct pglist_data { |
pglist_data
内存分配使用alloc_pages*系列函数,其核心代码__alloc_pages代码如下:
1 | struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid, |
代码很多,但是核心部分就是下面三个函数:
1 | static inline bool prepare_alloc_pages(gfp_t gfp_mask, unsigned int order, |
1 | static struct page * |
内存释放最后会调用到*__free_one_page *:释放过程比较容易来讲,找到可以合并的Buddy页帧号向上一级Order合并直到不能合并,将合并好的页加入到对应Order的free_area。
1 | static inline void __free_one_page(struct page *page, |
见内存分配一节,_alloc_pages_slowpath会触发kswapd来回收内存。kswapd在每个内存节点都有一个,其定义和代码如下。其实这里就是调用了balance_pgdat进行内存回收。
1 | int kswapd_run(int nid) |
本文是自己学习Linux内存管理的简单梳理,介绍了:
希望也对您理解Linux的内存管理有一些帮助。
这是<Linux内核内存管理>系列的第四篇
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。
第四篇概览了初始化C代码中的内存处理
参考内核文档,系统初始化早期是不能使用我们常用的 kmalloc ,vmalloc 等函数,这是因为此时对应的功能还没初始化好。
尽管如此,早期初始化仍然需要分配内存。因此早期内核提供了基于bitmap的Bootmem分配器,后续逐渐演进成了现在的Memblock。
Memblock或者早期的Bootmem并不能适应系统运行时的各种复杂场景(多线程、碎片等)。因此在内核启动到一定阶段后,内存管理的工作会交由伙伴系统(Buddy System)接管。
当然也并非是仅由伙伴系统管理。除了伙伴系统来以页为单位进行内存分配,还会有SLAB系统的某种(一般是SLUB)来实现对小内存分配的管理。
Memblock将系统中的内存分为一系列不同类型的连续区域。主要有以下几个类型:
Memblock使用以上概念对内核启动早期内存分配进行管理。
Memblock的内存主要数据结构如下:
1 | struct memblock_region { |
一张图说明以上结构的关系:
Memblock分配和释放函数主要有以下:
以上API名称很像,但其实最终只是对其中某几个API的封装。
从如下alloc系函数调用关系可以看到,最终调用到memblock_add_range。
从不同分支进到分配函数memblock_add_range,差异仅在与分配时选取的NUMA Node ID、标记(Flag)、类型(Memory还是Reserved)等参数的不同。其具体代码如下:
1 | static int __init_memblock memblock_add_range(struct memblock_type *type, |
代码虽长,其实比较容易理解:
另外可以从如下free系函数调用关系的简图看到,memblock_free*最终调用到memblock_remove_region和memblock_free_pages。
memblock_remove_region的主要作用是移除对应内存区,其代码如下:
1 | static void __init_memblock memblock_remove_region(struct memblock_type *type, unsigned long r) |
这里的主要作用就是将内存区域从对应类型的区域数组中移除,修改对应类型区域的长度,同时将该区域索引之后的区域依次向前移动一位。
memblock_free_pages的作用则是将对应页释放会给Buddy System:
1 | void __init memblock_free_pages(struct page *page, unsigned long pfn, |
在讲解伙伴系统之前,我们先讲解物理内存模型(Physical Memory Model),这是向伙伴系统过度的基础。简单一点讲,伙伴系统是按页对内存进行管理的,物理内存模型解决的是:
Linux系统的物理内存管理模型有三种配置,通过KConfig选择:
也可以称为“非一致性内存访问”,但一致性内存往往会跟DMA一致性,Cache一致性等概念混淆。
从上图看出,mem_section的数据结构比较简单:
1 |
|
Sparse的每个mem_section管理一块连续的内存区域,它由多个物理页组成。mem_section和这些内存区域的映射关系在sparse_init函数建立。代码如下:
1 | void __init sparse_init(void) |
1 | if (!ms->section_mem_map) { |
“分配mem_section”并不准确,当Kconfig不是 _CONFIG_SPARSEMEM_EXTREME_时,mem_section数组是静态定义的。
1 | static unsigned long sparse_encode_mem_map(struct page *mem_map, unsigned long pnum) |
本文先介绍到这,后续篇幅接着介绍Buddy System。
]]>这是<Linux内核内存管理>系列的第四篇
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
第三篇介绍了从内核第一行代码加载到跳转到C代码前的内存处理。
前文我们从汇编代码跳转到了x86_64_start_kernel,该函数代码如下:
1 | asmlinkage __visible void __init x86_64_start_kernel(char * real_mode_data) |
首先几个BUILD_BUG_ON 用于检查潜在的配置错误,分别检查的是:
接着初始化CR4 Shadow,内核Check-in List给出其作用如下:
Context switches and TLB flushes can change individual bits of CR4. CR4 reads take several cycles, so store a shadow copy of CR4 in a per-cpu variable. To avoid wasting a cache line, I added the CR4 shadow to cpu_tlbstate, which is already touched during context switches.
也就是说,CR4读取是需要多个CPU时钟周期的,所以将CR4存在一个per-cpu变量内。CR4 Shadow放置于cpu_tlbstate,因为cpu_tlbstate在上下文切换时会被CPU加载到Cache,由此可以节省Cache line的使用。
reset_early_page_tables将early_top_pgt清除并重新加载其为内核页表。clear_bss清理BSS和init_top_pgtsme_early_init是初始化内存加密相关。
kasan_early_init作用是初始化KASAN功能,后续会再具体介绍KASAN,这里略过不表。
idt_setup_early_handler作用是加载IDT Handler,其代码如下:
1 | SYM_CODE_START_LOCAL(early_idt_handler_common) |
上述代码主要作用是寄存器状态保存,同时执行do_early_exception。
copy_bootdata的主要作用是检查初始化参数,并将它们复制boot_params和boot_command_line内。同时将early_top_pgt页表的第512项赋值给init_top_pgt对应项。
最后x86_64_start_reservations执行一些特定平台相关的”quirks”后,开始执行start_kernel。
start_kernel执行所有内核初始化代码。本文仅分析与内存管理相关的步骤如下图:
1 | static struct e820_table e820_table_init__initdata; |
注意 initdata和refdata修饰作用在内核代码有说明,其中init的作用是为了标记初始化使用的数据以便内核初始化结束后释放对应的内存。而refdata的用于引用__initdata标记的数据。
1 |
early_reserve_memory 作用是将已占用的内存区域标记为不可用。这样后续不允许被memblock或者伙伴系统分配器再分配。
memblock_set_bottom_up 标记memblock内存分配是从低地址到高地址
memblock是系统初始化初期,伙伴系统接管前的分配器,它取代了内核早期的bootmem分配器。
e820__reserve_setup_data 将Boot Loader扩展的数据区标记为内核保留区域,并为其分配内存映射。
e820__finish_early_params 更新e820表。用户可以通过Loader传入内核CMD line来自定义内存区域映射。下图是在QEMU中E820扫描到的内存映射。
probe_roms 为ROM的分配IO资源
insert_resource 将code、rodata、data和bss插入IOMEM资源
e820_add_kernel_range 将内核_text 到 _end区域加入到e820表。
trim_bios_range 处理一些BIOS识别内存的特殊情况
early_gart_iommu_check 针对早期的AMD处理器中基于GART IOMMU的支持。
e820__end_of_ram_pfn 从e820获取最大物理页帧号
init_cache_modes 待确定
kernel_randomize_memory 与KASLR相关,后续介绍,此处不表
early_alloc_pgt_buf 为初始化过程中分配PGT预留堆空间
reserve_brk 在Boot分配器Reserve堆空间
e820__memblock_setup 将e820内存分布表的数据读出,并填写到Boot分配器管理
关于memblock分配器memblock,系列后续文章专门介绍
e820__memblock_alloc_reserved_mpc_new 从Memblock为MPC Table分配内存。
reserve_real_mode 从Memblock为实模式的[0, 1MB]分配内存。
init_mem_mapping 待确定
memblock_set_current_limit 设置memblock.current_limit为membelcok管理的最大页帧号
initmem_init 初始化NUMA(如果开启对应Kconfig的话),为memblock的现有区域分配NUMA节点ID号
dma_contiguous_reserve 为DMA预留连续内存
reserve_crashkernel 为kernel crash分配内存
memblock_find_dma_reserve 计算DMA区域的大小
x86_init.paging.pagetable_init 调用 native_pagetable_init 来初始化paging 待确定
kasan_init 初始化KASAN
sync_initial_page_table 待确定
e820__reserve_resources 为e820表项分配IO resource (reserve标记的表项除外)
x86_init.resources.reserve_resources 使用reserve_standard_io_resources 为下面硬件端口分配ioport resource
1 | static struct resource standard_io_resources[] = { |
本文是对初始化C代码中内存管理的概览,并没有介绍到每个子部分的细节,后续将会在专门的章节进行具体介绍。
]]>Linux将内存从大到小依次划分为Node(节点)->Zone(内存域)->Page(页):
一张图说明Node、Zone和Page的关系如下:
1 | Node 1 Node 2 Node 3 |
构成上述三个内存划分的数据结构如下:
Node对应的结构为pglist_data_t,定义如下(为方便理解,省略部分结构体成员):
1 | typedef struct pglist_data { |
内存区域对应的结构体为struct zone,定义如下:
1 | struct zone { |
特别说明一下内存域的水位(Watermark),它表示几个阈值,用来管理内核线程kswapd唤起与休眠的。当域内可用内存水位较高时,kswapd不用起来工作,而水位较低时,kswapd需要唤起来回收内存。如下图(来自深入理解Linux虚拟内存管理):
系统中每个物理页面都有数据结构struct page与其关联,用于管理页面的使用。结构如下:
1 | struct page { |
页面结构体使用双字块来划分:
第一个双字
flags: 页面状态,脏页、上锁等院子标记
联合体
mapping:指向inode address_space
s_mem:slab首对象
compound_mapcount:
第二个双字:
联合体
index:页面偏移
freelist:slab/slob的首个可用对象
联合体:slab/slub/slob相关的记数(取决于编译内核时选择的管理方式)
第三个双字:
lru:换出页列表
pgmap:
rcu_head
结构体,用于slub管理
结构体,用于复合页管理
联合体(ptl/slab_cache): slab指针,或者PTE自旋锁
virtual: 内核虚拟地址。用于高端内存中的页,即无法直接映射到内核内存中的页
Linux进行内存寻址时,往往不会直接内存物理地址,需要经过虚拟地址到物理地址的转化。使用虚拟地址的好处是可以避免进程与进程间互踩内存(除非特别指定共享内存),同时虚拟内存的换出换入使得进程使用超过物理内存大小的内存范围。
CPU中内存管理单元(MMU)作用就是根据内存中特定的转化表格(不错,页表本身也是需要内存存储的),将虚拟地址转化为真正的物理地址。而这个表格就是我们所讲的页表。
取决于体系结构,Linux采用三级或者四级页表机制:
每级表项所占位数,取决于我们编译内核时的选择。一般情况下,取决于寻址宽度,以及CPU体系结构每级页表所占位数是有约定俗成的。
内核在arch/xxx/include/asm/page.h(其中xxx表示CPU体系结构)定义了一系列的类型、函数和宏来方便对每级页表进行操作。
如上图我们看到的几个SHIFT宏定义,是为了方便通过位移操作来快速获取对应等级页表。
在IA64中用来表示以上各级页表目录的数据结构定义如下:
1 | typedef struct { unsigned long pte; } pte_t; |
与页表相关的宏或者函数定义有pmd/pte/pgd_alloc/free()等等,具体可以参考include/linux/mm.h。
本文介绍了Linux内核内存管理的基本单位划分Node、Zone和Page及对应的数据结构,同时对页表的基本概念进行了介绍。将在下一文分析Linux初始化流程中对内存的管理。
]]>这是<Linux内核内存管理>系列的第三篇
第一篇为内核内存管理过程知识点的的简单梳理
第二篇介绍了内核的数据结构
以Intel X64 CPU为例,Linux的初始化可大致分为如下几个过程:
内存管理占据了以上过程的重要角色。包括了内存布局规划、分段管理、页表配置、内核移动等。
本文使用Qemu模拟,基于Linux v5.13.9版本,按顺序介绍以上过程中的内存管理。
使用如下命令启动编译好的64位内核:
1 | qemu-system-x86_64 -kernel arch/x86/boot/bzImage -nographic -append "console=ttyS0 nokaslr" -s -S |
其中:
执行上述命令后,便得到如下图的内核地址分布。
根据内核文档Linux/x86 Boot Protocol,任何Boot Loader(Grub/Lilo/…)加载X86内核,均要遵守该协议。内核发展至今,该协议版本已经发展到了2.15。图中X为Boot Loader加载内核的起始偏移,在Qemu平台上该偏移为 0x10000。 加载后,内核Boot Sector开始执行,执行入口点为 _start。参考Linker Script arch/x86/boot/setup.ld。
1 | OUTPUT_FORMAT("elf32-i386") |
这里会直接跳转到start_of_setup开始执行。
1 | #arch/x86/boot/header.S |
以上代码会为实模式代码执行清理方向位,并未C代码的执行分配堆空间和栈空间。接着跳转到6执行,检查内核代码加载的正确性。这里说明一下, lretw及之前两行汇编语句的作用是调用返回,之前两行是将返回地址保存在栈内,参考<Intel® 64 and IA-32 Architectures Software Developer’s Manual>。如注释,使用lret的目的是为了重置CS寄存器的值,确保与其他段寄存器一致。可参考Intel手册,ret指令的说明:
When executing a far return, the processor pops the return instruction pointer from the top of the stack into the EIP
register, then pops the segment selector from the top of the stack into the CS register. The processor then begins
program execution in the new code segment at the new instruction pointer.
接着清空BSS段后跳转到main函数执行。
1 | /* First, copy the boot header into the "zeropage" */ |
main函数的注释比较清楚,我们这里只讲一下copy_boot_param/detect_memory/go_to_protected_mode:
1 | //arch/x86/boot/pm.c |
protected_mode_jump是一段汇编代码,定义在arch/x86/boot/pmjump.S,这里不多过多分析。其主要就是修改CR0寄存器的PE(Protect Enable)位,并执行跳转指令跳转到32位代码(.Lin_pm32标号)处执行。
1 | #arch/x86/boot/pmjump.S |
32位代码伊始就是重建各个段寄存器为BOOT_DS。段寄存器内容为向GDT某项的段选择子,而BOOT_DS即为GDT的第三个表项。 此时GDT的表项可以到arch/x86/boot/pm.c查找,大概定义了Base为0大小为4G的段,这足以覆盖内核初始化32位代码执行的区域。有关GDT表及段选择相关知识,可以查阅<Intel® 64 and IA-32 Architectures Software Developer’s Manual>中Volume 3,CHAPTER 3 PROTECTED-MODE MEMORY MANAGEMENT一节。做一些寄存器内容的清理,就跳转到32位内核的起始地址执行。
该起始地址,是protected_mode_jump函数的第一个参数-boot_params.hdr.code32_start。在我们的QEMU环境中这个值为0x100000
为什么是存储在eax寄存器呢,这里就需要了解System V Application Binary Interface AMD64中有关calling convention的知识,Linux内核也是遵守System V ABI的。ABI指的是Application Binary Interface,根据程序运行的Arch不同而有不同的定义。
_
0x100000存放的是32位代码起始地址,具体布局可以参考链接脚本:vmlinux.lds
链接脚本,即Linker Script,这是告诉链接器目标文件该如何链接的脚本。一般GCC编译我们不会指定链接脚本,这是因为其有默认的链接脚本。
1 | #ifdef CONFIG_X86_64 |
经过ld链接、且qemu加载后,得到下图左侧的内存布局。从地址0x100000开始,首先是32位保护模式入口代码、解压缩代码等,之后摆放了压缩的内核。其后分别是解压后内核的代码段、只读数据段、数据段、未初始化数据段和32位代码页表。
从链接脚本可以看出:32位代码的入口地址是startup_32。代码首先清中断,加载新GDT表,同时重置各段寄存器,建立堆栈。
需要注意代码定义了一个宏rva,它的主要作用是为了计算段内相对地址,这样可以避免内核加载到不同位置时,同样的代码皆可执行。
1 | #arch/x86/boot/compressed/head_64.S |
加载IDT后,打开PAE模式。 然后会计算出将压缩内核摆放的位置放到ebx,用于原地(in-place)解压。上面代码中BP_kernel_alignment(%esi) 主要作用是从boot_param对应区域取出对应的值。我们再次打开Linux/x86 Boot Protocol和Boot Protocol附属栏位查看这些栏位的说明:
偏移/所占字节数 | 参数 | 描述 |
---|---|---|
0230/4 | kernel_alignment | Physical addr alignment required for kernel |
0260/4 | init_size | Linear memory required during initialization |
01E4/4 | scratch | Scratch field for the kernel setup code |
其中init_size存放的是内核初始化、解压所需要的空间,这是根据内核压缩In-place解压预留足够的空间。这部分大小的计算可以参考内核源码arch/x86/boot/header.S的说明(本人也还没吃透,待补充)。
紧接着内核为4GB大小的内存建立每页大小为2MB的内核页表(见Figure 2图右)并加载页表目录地址(pgtable)到CR3寄存器,并开启64位长模式。参考Wiki:
当处于长模式(Long mode)时,64位应用程序(或者是操作系统)可以使用64位指令和寄存器,而32位程序将以一种兼容子模式运行。
4GB大小足以执行内核解压等动作。接着内核将64位地址startup_64压入栈,开启分页,并执行lret指令跳转到startup_64处执行。
此处我们省略了SEV功能的检查,这是AMD CPU的特性。此处不做分析。
startup_64 的开始同样会清中断,清理各段寄存器。同时计算压缩内核要移动到的地址,即LOAD_PHYSICAL_ADDR + INIT_SIZE - 压缩内核的长度(rva(_end))。此处处理与startup_32相同
可能大家会疑惑,为什么这段代码在startup_32做了,此处还要做一遍。主要原因代码内有描述,内核可能会被64位Loader直接加载并从startup_64处执行。
接着内核加载空的IDT表,检查是否需要开启五级页表,并做对应处理。紧接着清除EFLAGS寄存器后,将压缩内核移动到In-place解压的位置(LOAD_PHYSICAL_ADDR + INIT_SIZE - 压缩内核的长度),紧接着重新加载移动过位置的GDT表。之后跳转到移动后的 .Lrelocated 地址处开始执行。
.Lrelocated 代码最主要的作用有三个:
解压完内核后跳转到加压后内核的入口地址,即arch/x86/kernel/head_64.S的startup_64标号处
startup_64 代码如下:
1 | SYM_CODE_START_NOALIGN(startup_64) |
以上代码会配置栈之后,调用startup_64_setup_env配置Startup GDT和IDT。GDT表的内容如下:
1 | static struct desc_struct startup_gdt[GDT_ENTRIES] = { |
Startup GDT中的段描述符,都是0地址开始的4GB大小。Startup IDT(也叫binrgup IDT)主要处理AMD 架构下VMM Communication异常,该异常与虚拟机有关。
之后内核继续执行到verify_cpu这个汇编函数,其定义在verify_cpu.S,其主要是使用cpuid指令得到CPU对长模式和SSE指令集的支持状况。
检查完后,内核跳转执行 __startup_64,其主要作用是重新建立内核早期4级或者5级页表,此时需要考虑KASLR产生的随机偏移,因此我们可以看到此函数调用了多次fixup_pointer函数进行页表项纠正。
页表定义在head_64.s,如下:
1 | SYM_DATA_START_PTI_ALIGNED(early_top_pgt) |
比较难理解,我们用图翻译一下:
图中为内核代码建立了早期映射,这样,就可以愉快地执行内核代码了。(当然,也并不一定是愉快执行内核代码,后面我们也会看到,内核需要注册IDT表项来处理Page Fault Trap)。
1 | /* Switch to new page-table */ |
__startup_64执行过后我们跳过一些SEV的处理,便开始使用新的内核页表。此后我们就跳转到__START_KERNEL_map开始的虚拟地址执行了。紧接着重新初始化GDT、设置段寄存器、建立初始化运营时的栈、建立IDT。这中间有一段代码:
1 | /* Set %gs. |
它的作用是为多处理器系统保存per CPU变量的地址,保存到64-bit model specific register (MSR)。接着跳转到初始化c代码, 即x86_64_start_kernel。
本文重点分析了从内核被Loader加载一直执行到C代码入口的内存管理。一些主要的步骤:
系列后续我们将分析执行到C代码入口之后的处理
]]>本系列是本人对Linux内核内存管理的学习持续总结。
内存系统是操作系统最复杂的子系统之一,内存管理穿插着内核的方方面面。做驱动开发有2年多了,之前写过Linux内核内存管理的博客。现在回头看,之前的理解并不到位,也不完整。希望用本系列对Linux内核内存管理的知识做重新梳理,增强自己的理解,也能给对这个复杂功能一头雾水的朋友提供一些思路。
为了阐述方便,本文(系列)会基于Intel 64位平台做讨论。所涉及内核代码主要位于如下目录:
内存是系统得以运行的最基本保证。为了将内存进行有效管理,内核需要做如下考虑:
在Boot Loader加载内核后,如何分别摆放16 Bit, 32 Bit代码区域。压缩内核,以及如何解压内核,解压还要考虑KASLR(内核地址随机化)等因素。同时,每个启动阶段使用的堆和栈如何划分。
通过虚拟地址访问外设IO端口或者MMIO端口,这需要内核为其建立对应的页表项,同时为了保证特定IO区域只能有一个主体来管理,内核需要以树状结构来管理IO区域。
同时,外设要访问的内存空间,需要考虑CPU和外设访问内存一致性问题(DMA一致性)。
内存分配和释放是操作系统内最为频繁的操作。保证内存分配和释放的同时,也需要考虑避免系统内存的碎片化,避免系统运行到一段时间后,程序需要一块大内存的的时候无法分配到。其中:
当系统物理内存紧张时,系统会将一些内存换出到硬盘上。而当系统访问该内存页产生Page Fault时,操作系统需要负责将该内存页的内容换回到内存上。
除了上述职责,内核也需要提供方法对内存使用进行检测和调试。例:
以上为本人对内核内存管理功能的梳理。因为内核内存管理功能复杂,以上理解并不一定准确,因此本文也需要持续更新。系列文章后续也将会对本文提及的内容进行具体的分析和介绍。
]]>至于为什么这么多对华内容,后来了解到这是一个台湾人。
在台湾公司呆了这么多年,从来不会也不可能表达政治观点。同事也都是客客气气的。
但是相信无论是我们或者台湾同事,也都能感受到对方的想法。例如,
相信看到作者这些政治宣言后,个人不会再去下载该软件,也会跟同事朋友提醒,远离这个软件!
]]>最近半年,工作异常忙碌,不止一次跟XH表示,这是我工作以来最为忙碌的时光。所幸所做之工作,又是个人认为比较前言且具有挑战的项目。虽然充实,但也持续迷茫着。
外面的世界,贸易战、洪水、新冠肆虐。家里却也非绝对宁静。
去年八月一年来,搬了三次家。上次博客更新时,还在20公里之外的出租房里。现在,也幸得终于可以回到自己的寒舍。房子虽小,也有雨季漏水的问题。却也增添了几分方便,更多了归属感。
预见还会有的动迁和变化,希望一切顺利。也希望一直以来的愿望还是可以达成。
努力!
]]>四川省最后还是刚起,仅仅发了一个不痛不痒的通知说企业可自行安排复工时间,成为遵守国务院命令2月3日准时上班的唯一一个省份。
看着最新的疫情地图,加上查到的1600多万的成都人口,心理慌得不要不要的。料医学博士的省长也没办法抑制得了新病毒的传播,希望成都不要学武汉的各种神操作。幸好公司人性化,允许在家办公。
仔细想想,如果可以解决公司信息泄露问题,IT行业在家办公其实挺好的。最起码有以下几点优势:
明天先这样工作第一天试试看,如果可行,强烈建议公司在疫情结束后也继续推广!
]]>强大的传染力,加上春节的人口流动速率,病毒感染的人口可能远非官方所报道那么多。
想起2013年的SARS,我高三,生活在小县城也能深切感受到这传染病的威力。
每天早读几个温度计班里传递量体温,诊所里板蓝根早早卖断货。
晚上自习新闻时间打开电视随时关心病情进展,看到又是几个全身防护的医务人员感染甚至病逝的消息。
目前能做的,就是相信国家,好好待在家里,不出去添乱。希望大家一切都好。
]]>实际使用发现Bug不少,软件不多。Bug可以等系统更新来解决。不过软件不多是生态链的问题,等生态链慢慢建起来实在太慢。Wear OS是Android的一个分支,理论上应该支持安装安卓应用才对。翻了下小米手表的设置,也是有看到可以做ADB调试,因此理应可以用ADB安装Android应用。
步骤:
1 | adb install qqmail_android_5.7.1.10141908.2480_0.apk |
注意:
最后,收集可支持手表的应用如下:
应用 | 版本 | APK下载链接 |
---|
Zephyr数据结构使用k_thread定义,如下所示:
1 | struct k_thread { |
参数说明:
1 | struct _thread_base { |
Zephyr定义了一系列的API来使用线程:
线程创建的函数定义如下:
1 | K_THREAD_DEFINE(my_tid, MY_STACK_SIZE, my_entry_point, NULL, NULL, NULL, MY_PRIORITY, 0, K_NO_WAIT); |
前者主要用于静态定义线程,而后者主要用于运行时创建线程。线程的静态定义主要就是定义如下结构体(参数明确,不做一一解释):
1 | struct _static_thread_data { |
k_thread_create的源码如下:
1 | k_tid_t z_impl_k_thread_create(struct k_thread *new_thread, //代码注解1 |
其中:
z_setup_new_thread定义如下(为方便分析,代码做一定简化):
1 | void z_setup_new_thread(struct k_thread *new_thread, k_thread_stack_t *stack, size_t stack_size, k_thread_entry_t entry, void *p1, void *p2, void *p3, int prio, u32_t options, const char *name) |
其中z_arch_new_thread代码如下(省去部分代码):
1 | void z_arch_new_thread(struct k_thread *thread, k_thread_stack_t *stack, size_t stackSize, k_thread_entry_t pEntry, void *parameter1, void *parameter2, void *parameter3, int priority, unsigned int options) |
以上初始化过程执行后,线程的栈空间如图所示:
以上为线程的初始化过程
线程开始执行调用z_impl_k_thread_start函数。该函数也会被k_thread_create–>schedule_new_thread间接调用,其源码如下:
1 | void z_impl_k_thread_start(struct k_thread *thread) |
线程挂起使用z_impl_k_thread_suspend,代码如下:
1 | void z_impl_k_thread_suspend(struct k_thread *thread) |
线程Resume仅做线程的标记,不再进行分析
线程取消使用API z_impl_k_thread_abort,代码如下:
1 | void z_impl_k_thread_abort(k_tid_t thread) |
以上为线程使用相关结构体、API及对应的分析。可以看出,线程相关函数的实现足够简洁明了,这也正应了Zephyr的设计思想。有任何问题,欢迎留言讨论。
]]>一直想写一些RTOS的技术资料,算作对自己之前一些相关技术调研的总结。无奈懒癌发作,一拖再拖。然今日灌上鸡血,笃定主意,从最基本的调度相关内容开始。
简单讲,Zephyr是一个开源实时操作系统。相较Linux,其对系统资源的使用量更小,当然也牺牲了许多复杂且完善的功能(如,系统Debug易用性,线程的堆栈保护)。与此同时,因其是开源社区开发,也多少继承和保留了许多Linux系统的优秀思想和功能(例如,Workqueue、设备树等)。其定位为万物互联时代各种各样的嵌入式设备,目光长远。
Zephyr里没有管程、进程、线程之分。除了中断响应例程,其余所有可执行调度单位皆是线程。系统中应用程序可以定义一个或者多个线程,这种情况下,每个线程也都有自己独立的调度信息和线程ID。
虽然也有区分用户空间和内核空间态,Zephyr线程却没有自己独立的地址转换表,皆使用相同的地址空间,这是由Zephyr的内存管理方式决定的。
线程状态即转换关系如下图所示:
可以看出,线程分为如下状态:
以上线程状态转换关系见图,后续进行代码分析将进一步介绍。
线程的优先级使用数字表示,数字越小,线程的优先级越高。Zephyr系统的线程可以分为两类:
Zephyr默认给Cooperative线程分配小于0的优先级数值,而可抢占线程分配为正值。用户可修改编译选项来更改这两种线程的优先级区间,当然要保证Cooperative线程的优先级数值区间小于可抢占线程。线程运行过程中,其优先级可以被更改。一张图表示线程优先级关系:
线程有一列属性,根据这些不同的属性,线程的执行方式也会有相应的差异。一些主要的属性如下:
属性 | 说明 |
---|---|
K_ESSENTIAL | 表示线程是核心线程,该类线程若有退出或者取消是系统不允许的,系统会断言严重错误 |
K_SSE_REGS | X86独有属性,表示是否使用CPU的SSE功能 |
K_FP_REGS | 表示是否使用CPU的浮点计算寄存器 |
K_USER | 表示用户空间态线程,只有当系统编译选项CONFIG_USERSPACE打开时才有效 |
K_INHERIT_PERMS | 对USERSPACE线程有效,表示是否继承父进程的权限属性 |
线程可以自定义数据,使得应用可以对线程功能做一定程度的扩展。
Zephyr内核初始化会创建的一些初始线程。主要分为主线程和空闲线程。
与Linux系统的Workqueue相似,主要用于中断下半部使用。
本文为Zephyr系统,线程的基本概念。主要介绍了线程的状态、分类、优先级等基础思想。
]]>