Linux内核代码阅读序列(10)-内存模型 之二

内存区段

x86结构CPU上采用了一种叫内存区段的方式来寻址,这样做最大的好处就是可以保护内存中的内容不被非法访问,在这种内存模型中某个地址可以被表示成两部分,线性地址由这两部分计算得出。

LinearAddress = BaseAddress of Segmentation + Offset

其中,段基值存储在段基值寄存器当中,其值总是指向当前正在使用的内存区域的基地址;在x86处理器的32位保护模式中,一个区段的大小时4GB;这是因为32位CPU上,段基值寄存器的位宽是32位(2^32=4G)。x86的原始设计包含三个段基址寄存器,

  • CS Register:CodeSeg 指令段基址寄存器
  • DS Register:DataSeg 数据段基址寄存器
  • SS Register:StackSeg 栈段基址寄存器

同时,x86结构上将一个被叫做段描述符(Segmentation Descriptor)的东西存储在两个表(GDT和LDT)之中,需要使用时从表中取出段描述符来找到线性地址;因为Linux操作系统最初的设计目标就是支持x86结构的计算机,所以,Linux内核在构建内存模型的时候也采取了内存区段的方式,但是,为了简化设计和提高可以执行,内核的做法是只保留一个存储段描述符的表(GDT)和一个段基址寄存器,从而让所有的段都开始于同一个位置,而段的大小也正好成为4GB,也就是一般情况下的整个线性地址空间的大小。正因为如此,在大多数情况下,逻辑地址就可以直接被看作线性地址来使用,而逻辑地址到线性地址的转换事实上时内核或者CPU在对用户透明的情况下完成的,这个转换过程不需要特别注意。需要注意的是这里提到的这些名词,以及那个小学算数一般的地址计算方法。此外,Linux内核代码使用的内存区段和若干个宏也最好记住,因为你知道,好记性对一个程序员来说真tmd重要。

Name Macro Base Limit G S Type DPL
KernelCodeSeg __KERNEL_CS 0x00000000 0xffffffff 1 1 0xa 0
KernelDataSeg __KERNEL_DS 0x00000000 0xffffffff 1 1 0x10 0
UserCodeSeg __USER_DS 0x00000000 0xffffffff 1 1 0xa 3
UserDataSeg __USER_DS 0x00000000 0xffffffff 1 1 0x10 3

线性地址在使用上的划分

普通的x86结构计算机上,从用户的角度看,线性地址是一个连续内存区域。它的最小地址是0x0000 0000,而最大地址是0xffff ffff。这个区域在概念上被分成两个部分,一部分是会根据不同的进程切换而改变的userspace区域,而另一部分,则在系统启动后一直不变的kernel space区域。这个位置的分界点可在内核代码中通过PAGE_OFFSET宏来定义,而PAGE_OFFSET却来自于内核编译的配置文件.config中的CONFIG_PAGE_OFFSET。在x86结构上,它被定义为0xC000 0000;这意味着,用户空间的大小时3GB,而内核仅有1GB可以使用。

 |<---          3GB          ---->|<----  1GB ---->|
 +--------------------------------+----------------+
 |     userspace                  | kernel space   |
 +--------------------------------+----------------+
                            PAGE_OFFSET
                           (0xC000 0000)

内核在系统启动时被Bootloader——x86结构上常用的是GRUB/2——装载到线性地址的0xC000 0000位置以后,而这个线性地址通常被映射到物理地址的0x0010 0000,也就是物理地址最初的1M以后,关于这里点的证明此后详述。在线性地址空间中,紧随内核装载位置之后,Linux会利用一段内存区域来加载物理内存管理需要使用的数据结构,这个区域的大小一般根据系统上可以使用的RAM的大小而改变,而其装载在物理内存中的位置会根据体系结构的不同而改变,但通常会避开前16M,因为最初的16M被用来给ISA设备提供DMA支持,这是物理内存管理中另外一个恼人的地方,且听下下回分解吧。此前提到的这两个区域都时直接的物理内存映射区域,具体的位置用内核宏定义来说的话应该时开始于PAGE_OFFSET,而结束于(VMALLOC_START-VMALLOC_OFFSET)。-_-!!! 抱歉,又说火星语了,但是除此以外还真没有好的描述语言。

                             VMALLOC_OFFSET(0x0080 0000)
                                  ¥ | ¥
     +-----------+----------------+---+-------------------+
     |KernelImage|PhysicalMMStruct|Gap| vmalloc Add Space |
     +-----------+----------------+---+-------------------+
     ^                                |
     |                           VMALLOC_START
  PAGE_OFFSET
(0xC000 0000)

显而易见,VMALLOC_START = SizeOf(PhysicalMMStruct) + VMALLOC_OFFSET。后续的线性地址空间被用以支持内核函数vmalloc(),使用这个函数可以申请在线性地址空间连续的却事实上在物理地址上可能不连续的内存区域;如果在支持HIGH_MEM的系统上,在两个PAGE的间隔以后,会有一块区域来支持将高地址转化到低地址的函数kmap(),这个区域的开始位置是PKMAP_BASE。为什么要转换呢?下下下下回分解…再接下来,还需要划分一片区域来支持编译时就需要定位的内存模块,比如APIC,这段区域采用固定映射(Fixed Mapping)的方式,将线性地址映射到物理地址,所以,利用链接器脚本或者其他方法可以在编译时确认代码被装载的位置;这片内存区域的开始位置是FIXADDR_START,而结束位置时FIXADDR_TOPFIXADDR_TOP0xffff f000,而FIXADDR_START会根据宏__FIXADDR_START计算得出。

     +--------------------+---+--------------+-------------------------+---+
     | vmalloc Add Space  |Gap|kmap Add space|Fixed Virtual Add Mapping|Gap|
     +--------------------+---+--------------+-------------------------+---+
     |           VMALLOC_END  |              |                         |
     |                  PKMAP_BASE           |                  FIXEADDR_TOP
VMALLOC_START                         FIXADDR_START

正是因为前面提到vmalloc()kmap()和固定映射地址,内核物理内存区域管理中用到的ZONE_NORMAL的大小才受到限制,而不能占据整个1GB空间。这个限制通过宏定义VMALLOC_RESERVE体现出来,这个值在不同的体系结构上有所不同,但是在x86结构上,它被定义为128MB,理所当然的,ZONE_NORMAL可以使用的线性地址空间大小就变成了1GB - 128MB = 896MB

Linux内核代码阅读序列(9)-内存模型 之一

重新开始

2009年的时候信誓旦旦的说要将这个系列坚持写下去,结果未能如愿。抱歉的很。去年前半年奔波劳碌得到处跑,9月以后自己的人生发生了180度的大转弯,自己给自己的shock就已经够大了,更加没有心思写blog。还好还好,上帝给了我一个此前从未有的幸福的开端,也许我可以牵着她的手,安心下来好好补足功课了。愿上帝保佑2010一切都好。愿上帝保佑我能将这个系列按照原来的计划写完。

Linux的内存模型

理解Linux内核所采用的内存模型是弄懂Linux内存管理的第一步。很多介绍Linux源码阅读的书和文章都会建议新手应该从内存管理部分入手。可是,在我看来这并不是一个非常好的建议,因为如果搞不懂Linux内核中内存管理的模型直接阅读代码的话会迷失其中,一个重要原因就是这个部分很多实装方法都牵扯到了不同体系结构的差别。而Linux为了提高可移植性又对某些体系结构上普遍应用的内存模型进行了抽象。在看代码的时候,应该时刻注意区分那些是抽象了的部分,哪些代码是不同体系结构上的实现。

内存管理需要解决的问题

  • 虚拟内存管理(Virtual Memory Management)
    • 虚拟内存是现代操作系统的一个重要特征;采用虚拟内存的方法可以获得很多有点,比如通过这种抽象可以简化应用程序的开发,可以防止内存非法访问提高安全性等等;
  • 物理内存管理(Physical Memory Management)
    • 操作系统的主要功能之一就是进行资源管理,而内存恰恰就是最重要的系统资源;
  • 内核的虚拟内存管理,内核内存分配器(Allocator)
    • 应用程序或者内核内部模块需要内存时需要向内核管理模块申请,分配器帮助这些组件得到自己想要的内存;
  • 虚拟地址空间管理(Virtual Memory Space)
  • 交换(swap)和缓存(cache)
    • 交换,也是计算机系统中利用局部性(locality)特征的一个重要功能,内存管理模块可以通过交换的方式将暂时用不到的内存页“交”给低速的硬盘保存,而需要时又将这些内存内容“换”回到系统主存之中;缓存也是局部性的一个应用,现代的CPU一般都会有高速缓存,高速缓存在CPU和主存之间充当缓冲,为CPU快速得访问数据和指令提供支持。

x86结构上的地址种类

x86结构上,内存地址被分成3类,理论地址,线性地址和物理地址;

  1. 逻辑地址,是从正在运行的应用程序的角度来看,某个数据或者指令出现的位置;这个地址有可能直接就是物理地址,也有可能不是;一般来说,各种控制器,比如DMA控制器,PCI控制器对内核提出内存申请的时候所给参数都是逻辑地址;
  2. 线性地址,或者被称为平面地址空间(Flat Memory Address)的地址,事实上就是在程序员脑子里的地址,它就是从0开始每个存储单元顺序增加标号的地址。这也是最原始最简单的地址编排方式,除了Intel之外的很多CPU都采用这种地址编排方式;而Intel体系结构的CPU上采用了分段的地址空间,这种方式将线性地址按照64KB(286结构)或者4GB(386以后)为单位进行分段,而且,段地址寄存器中总是存储这当前要使用的那段内存的基址(base address)。虽然这种方式在32位结构上也可以被看作是一整个平面的地址空间,但事实上它却是分段的;
  3. 物理地址就是在总线上表示的那个地址。当物理地址和逻辑地址不一致的时候,通过内存管理单元(MMU),可以将路基地址转换为物理地址。

CPU使用内存区段单元和分页单元可以将逻辑地址转化成为物理地址;

Logical                   Linear                  Physical
Address  +--------------+ Address +-------------+ Address
-------->|Segmented Unit|-------->| Paging Unit |--------->
         +--------------+         +-------------+


参考文献
http://www.ibm.com/developerworks/jp/linux/library/l-memmod/index.html

Linux内核源码阅读系列(8)-内核的构成 之 二

进程管理

进程

进程是正在运行的程序的实体。它们是Linux用以完成各种应用程序的核心。”链接“系列文章中说明了一个应用程序如何从源代码变成可执行文件,以及如何将这个可执行文件加载进入内存从而使程序运行的过程。可执行的目标文件被加载后就行成了进程的基本组成部分。操作系统本身还会为每一个进程添加一些附加的信息用以对进程进行调度管理和创建/消灭等过程。另一方面,为了方便用户的使用,Linux向用户应用程序提供了一些系统调用帮助用户应用程序进行进程的管理。进程有自己的生命周期,它可以被创建,消灭,使其进入活动状态等等,这些状态之间可以通过系统调用或者进程调度的机制进行转换。下面的图展示了从交互式shell中启动’yes’这个应用程序的过程。

从shell运行yes

从shell运行yes

bash在使用fork()系统调用之后,只是在虚拟内存空间上复制一个和自己完全相同的拷贝,那么这时我们就可以得到两个bash,其中早先的那个bash进程通常被称作父进程,而其后被创建的那个进程被称作子进程;因为现在用户需要运行的是yes这个程序,其中原来的bash需要使用wait()进入等待状态以便腾出CPU占用时间来运行’yes’。在接下来的过程中,刚刚被创建的那个新的bash的副本,会使用exec()系统调用将yes这个应用程序的可执行文件映射并拷贝到内存中,通过这个方式,操作系统可以创建一个信的进程。yes这个程序比较特殊,他的作用事实上就是不断的输出’yes’这个字符串,但事实上任何应用程序都可能退出,比如我们这个时候按一下Ctrl-C,其后的动作是通过系统系统调用exit()结束进程。父进程bash在接收到子进程结束的消息后,可能又进入活动状态。

线程

进程管理部分的代码,着重可以看看进程的创建,消灭以及其他状态之间的转换是怎样实现的,此外,还有“线程”需要注意。线程在Linux中的实现和进程非常相似,可以说他是一种特殊的进程。线程的特殊之处在于多个线程之间共享相同的“进程空间”——这一点其实逻辑上很容易想清楚,就是多线程通常用来相应高并发的任务,而这些线程事实上完成的功能是一致的,他们之间不需要有区别。从“调度器”的角度来看,线程和进程是一致的。

信号

进程管理的另外一个重要内容是所谓的“信号”(signal),它是一个简单的向进程传递非同期时间的功能。收到信号的进程可以选择通过指定的signal handler做一个动作,或者忽略这个信号,等等。收到信号的进程的行为于收到中断的内核非常相似。:)说了等于没说……

内存管理

关于内存管理的内容的文章简直可以说在互联网上泛滥成灾。写文章的人从不同的角度对应该怎样管理内存的问题做了很多讨论。内存管理的策略也是多如牛毛,比如C++标准模板库中用free list实现的内存池等等。所以,看内存管理的话,很多大仙可以拍拍胸脯满怀信心的就把书翻过去了。但是,Linux作为一个经过实战考验的开源操作系统——事实上开源创造了更加安全可靠的操作系统,研究发现Linux2.6版本共5.7million行的源代码中,仅仅存在985个Bug;而如果将总代码量于工业界的平均水平相比,Linux中存在114,000~171,000个Bug都可以被评价为“质量不错”——它的内存管理实现可以被认为在很多地方都具有参考和借鉴的意义。就凭这一点,内存管理简直可以说是内核代码中最值得关注的部分。

Linux内存管理可以被分为两大块。第一是实内存管理,另外一个是虚拟内存的管理方法。

实内存管理

实际的内存分配策略往往区分大块内存划分和小块内存划分以提高内存分配算法的效率。Linux的实内存分配测律也不例外,她采用了以Page为单位的Buddy方法来划分大块内存区域的同时,对于小内存区块却采用了一个叫做Slab的小内存划分方法。这两者的实现都非常精巧,值得仔细研究。

虚拟内存管理

虚拟内存(Virtual Memory)技术可以说是现代计算机系统中非常重要的组成部分。它不但关系到硬件的设计,而且还关系到很多重要的软件技术的实现,比如以前文章中提到的共享库和动态链接技术。MMU(Memory Management Unit)是在计算机体系结构发展这中产生的,这个硬件组件是现代虚拟内存技术的硬件基础。Linux中采用了多重虚拟地址的虚拟内存空间,所以,它让操作系统本身获得了更加强大的能力。所以,虚拟内存管理的部分应该硬件结合软件一起来看才能看个通通透透。虚拟内存同时和内核的其他部分,比如,进程管理有很多关联的地方——比如前文提到的程序的内存映像的生成等,所以,在讨论进程的过程中也不能忘记虚拟内存。

这个部分的关键词有:Demand paging,Swapping, Page fault等等。