linux操作系统基础概念(一) 地址、空间

=======================================================

转自http://bbs.chinaunix.net/thread-2083672-1-1.html

 

我理解的逻辑地址、线性地址、物理地址和虚拟地址(补充完整了) 


        要过年了,发个年终总结贴,只是个人理解,不包正确哈。

        本贴涉及的硬件平台是X86,如果是其它平台,嘻嘻,不保证能一一对号入座,但是举一反三,我想是完全可行的。

一、概念

物理地址(physical address)
        用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
        这个概念应该是这几个概念中最好理解的一个,但是值得一提的是,虽然可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,但是事实上,这只是一个硬件提供给软件的抽像,内存的寻址方式并不是这样。所以,说它是“与地址总线相对应”,是更贴切一些,不过抛开对物理内存寻址方式的考虑,直接把物理地址与物理的内存一一对应,也是可以接受的。也许错误的理解更利于形而上的抽像。

虚拟内存(virtual memory)
        这是对整个内存(不要与机器上插那条对上号)的抽像描述。它是相对于物理内存来讲的,可以直接理解成“不直实的”,“假的”内存,例如,一个0x08000000内存地址,它并不对就物理地址上那个大数组中0x08000000 - 1那个地址元素;
        之所以是这样,是因为现代操作系统都提供了一种内存管理的抽像,即虚拟内存(virtual memory)。进程使用虚拟内存中的地址,由操作系统协助相关硬件,把它“转换”成真正的物理地址。这个“转换”,是所有问题讨论的关键。
        有了这样的抽像,一个程序,就可以使用比真实物理地址大得多的地址空间。(拆东墙,补西墙,银行也是这样子做的),甚至多个进程可以使用相同的地址。不奇怪,因为转        换后的物理地址并非相同的。
        可以把连接后的程序反编译看一下,发现连接器已经为程序分配了一个地址,例如,要调用某个函数A,代码不是call A,而是call 0x0811111111 ,也就是说,函数A的地址已经被定下来了。没有这样的“转换”,没有虚拟地址的概念,这样做是根本行不通的。
打住了,这个问题再说下去,就收不住了。

逻辑地址(logical address)
        Intel为了兼容,将远古时代的段式内存管理方式保留了下来。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。以上例,我们说的连接器为A分配的0x08111111这个地址就是逻辑地址。
        不过不好意思,这样说,好像又违背了Intel中段式管理中,对逻辑地址要求,“一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量],也就是说,上例中那个0x08111111,应该表示为[A的代码段标识符: 0x08111111],这样,才完整一些”

线性地址(linear address)或也叫虚拟地址(virtual address)
        跟逻辑地址类似,它也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。

-------------------------------------------------------------

        CPU将一个虚拟内存空间中的地址转换为物理地址,需要进行两步:首先将给定一个逻辑地址(其实是段内偏移量,这个一定要理解!!!),CPU要利用其段式内存管理单元,先将为个逻辑地址转换成一个线程地址,再利用其页式内存管理单元,转换为最终物理地址。

        这样做两次转换,的确是非常麻烦而且没有必要的,因为直接可以把线性地址抽像给进程。之所以这样冗余,Intel完全是为了兼容而已。

2、CPU段式内存管理,逻辑地址如何转换为线性地址

        一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:


        最后两位涉及权限检查,本贴中不包含。

        索引号,或者直接理解成数组下标——那它总要对应一个数组吧,它又是什么东东的索引呢?这个东东就是“段描述符(segment descriptor)”,呵呵,段描述符具体地址描述了一个段(对于“段”这个字眼的理解,我是把它想像成,拿了一把刀,把虚拟内存,砍成若干的截——段)。这样,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,我刚才对段的抽像不太准确,因为看看描述符里面究竟有什么东东——也就是它究竟是如何描述的,就理解段究竟有什么东东了,每一个段描述符由8个字节组成,如下图:


        这些东东很复杂,虽然可以利用一个数据结构来定义它,不过,我这里只关心一样,就是Base字段,它描述了一个段的开始位置的线性地址。

        Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。那究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。

        GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。

        好多概念,像绕口令一样。这张图看起来要直观些:

        首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
        1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
        2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
        3、把Base + offset,就是要转换的线性地址了。

        还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。OK,来看看Linux怎么做的。

3、Linux的段式管理

        Intel要求两次转换,这样虽说是兼容了,但是却是很冗余,呵呵,没办法,硬件要求这样做了,软件就只能照办,怎么着也得形式主义一样。
        另一方面,其它某些硬件平台,没有二次转换的概念,Linux也需要提供一个高层抽像,来提供一个统一的界面。所以,Linux的段式管理,事实上只是“哄骗”了一下硬件而已。

        按照Intel的本意,全局的用GDT,每个进程自己的用LDT——不过Linux则对所有的进程都使用了相同的段来对指令和数据寻址。即用户数据段,用户代码段,对应的,内核中的是内核数据段和内核代码段。这样做没有什么奇怪的,本来就是走形式嘛,像我们写年终总结一样。
include/asm-i386/segment.h
#define GDT_ENTRY_DEFAULT_USER_CS        14
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS * 8 + 3)

#define GDT_ENTRY_DEFAULT_USER_DS        15
#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS * 8 + 3)

#define GDT_ENTRY_KERNEL_BASE        12

#define GDT_ENTRY_KERNEL_CS                (GDT_ENTRY_KERNEL_BASE + 0)
#define __KERNEL_CS (GDT_ENTRY_KERNEL_CS * 8)

#define GDT_ENTRY_KERNEL_DS                (GDT_ENTRY_KERNEL_BASE + 1)
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS * 8)

把其中的宏替换成数值,则为:
#define __USER_CS 115        [00000000 1110  0  11]
#define __USER_DS 123        [00000000 1111  0  11]
#define __KERNEL_CS 96      [00000000 1100  0  00]
#define __KERNEL_DS 104    [00000000 1101  0  00]

方括号后是这四个段选择符的16位二制表示,它们的索引号和T1字段值也可以算出来了
 __USER_CS              index= 14   T1=0
__USER_DS               index= 15   T1=0
__KERNEL_CS           index=  12  T1=0
__KERNEL_DS           index= 13   T1=0

        T1均为0,则表示都使用了GDT,再来看初始化GDT的内容中相应的12-15项(arch/i386/head.S):
        .quad 0x00cf9a000000ffff        /* 0x60 kernel 4GB code at 0x00000000 */
        .quad 0x00cf92000000ffff        /* 0x68 kernel 4GB data at 0x00000000 */
        .quad 0x00cffa000000ffff        /* 0x73 user 4GB code at 0x00000000 */
        .quad 0x00cff2000000ffff        /* 0x7b user 4GB data at 0x00000000 */

        按照前面段描述符表中的描述,可以把它们展开,发现其16-31位全为0,即四个段的基地址全为0。

        这样,给定一个段内偏移地址,按照前面转换公式,0 + 段内偏移,转换为线性地址,可以得出重要的结论,“在Linux下,逻辑地址与线性地址总是一致(是一致,不是有些人说的相同)的,即逻辑地址的偏移量字段的值与线性地址的值总是相同的。!!!”

        忽略了太多的细节,例如段的权限检查。呵呵。

        Linux中,绝大部份进程并不例用LDT,除非使用Wine ,仿真Windows程序的时候。

4.CPU的页式内存管理

        CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。

        另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。

        这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。文字描述太累,看图直观一些:


如上图,
        1、分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。万里长征就从此长始了。
        2、每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。
        3、每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)

依据以下步骤进行转换:
        1、从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
        2、根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
        3、根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
        4、将页的起始地址与线性地址中最后12位相加,得到最终我们想要的葫芦;

        这个转换过程,应该说还是非常简单地。全部由硬件完成,虽然多了一道手续,但是节约了大量的内存,还是值得的。那么再简单地验证一下:
        1、这样的二级模式是否仍能够表示4G的地址;
                页目录共有:2^10项,也就是说有这么多个页表
                每个目表对应了:2^10页;
                每个页中可寻址:2^12个字节。
                还是2^32 = 4GB

        2、这样的二级模式是否真的节约了空间;
        也就是算一下页目录项和页表项共占空间 (2^10 * 4 + 2 ^10 *4) = 8KB。哎,……怎么说呢!!!
        红色错误,标注一下,后文贴中有此讨论。。。。。。
        按<深入理解计算机系统>中的解释,二级模式空间的节约是从两个方面实现的:
                A、如果一级页表中的一个页表条目为空,那么那所指的二级页表就根本不会存在。这表现出一种巨大的潜在节约,因为对于一个典型的程序,4GB虚拟地址空间的大                        部份都会是未分配的;
                B、只有一级页表才需要总是在主存中。虚拟存储器系统可以在需要时创建,并页面调入或调出二级页表,这就减少了主存的压力。只有最经常使用的二级页表才需要                        缓存在主存中。——不过Linux并没有完全享受这种福利,它的页表目录和与已分配页面相关的页表都是常驻内存的。

        值得一提的是,虽然页目录和页表中的项,都是4个字节,32位,但是它们都只用高20位,低12位屏蔽为0——把页表的低12屏蔽为0,是很好理解的,因为这样,它刚好和一个页面大小对应起来,大家都成整数增加。计算起来就方便多了。但是,为什么同时也要把页目录低12位屏蔽掉呢?因为按同样的道理,只要屏蔽其低10位就可以了,不过我想,因为12>10,这样,可以让页目录和页表使用相同的数据结构,方便。

        本贴只介绍一般性转换的原理,扩展分页、页的保护机制、PAE模式的分页这些麻烦点的东东就不啰嗦了……可以参考其它专业书籍。

5.Linux的页式内存管理

        原理上来讲,Linux只需要为每个进程分配好所需数据结构,放到内存中,然后在调度进程的时候,切换寄存器cr3,剩下的就交给硬件来完成了(呵呵,事实上要复杂得多,不过偶只分析最基本的流程)。

        前面说了i386的二级页管理架构,不过有些CPU,还有三级,甚至四级架构,Linux为了在更高层次提供抽像,为每个CPU提供统一的界面。提供了一个四层页管理架构,来兼容这些二级、三级、四级管理架构的CPU。这四级分别为:

        页全局目录PGD(对应刚才的页目录)
        页上级目录PUD(新引进的)
        页中间目录PMD(也就新引进的)
        页表PT(对应刚才的页表)。

        整个转换依据硬件转换原理,只是多了二次数组的索引罢了,如下图:


        那么,对于使用二级管理架构32位的硬件,现在又是四级转换了,它们怎么能够协调地工作起来呢?嗯,来看这种情况下,怎么来划分线性地址吧!
        从硬件的角度,32位地址被分成了三部份——也就是说,不管理软件怎么做,最终落实到硬件,也只认识这三位老大。
        从软件的角度,由于多引入了两部份,,也就是说,共有五部份。——要让二层架构的硬件认识五部份也很容易,在地址划分的时候,将页上级目录和页中间目录的长度设置为0就可以了。
        这样,操作系统见到的是五部份,硬件还是按它死板的三部份划分,也不会出错,也就是说大家共建了和谐计算机系统。

        这样,虽说是多此一举,但是考虑到64位地址,使用四层转换架构的CPU,我们就不再把中间两个设为0了,这样,软件与硬件再次和谐——抽像就是强大呀!!!

        例如,一个逻辑地址已经被转换成了线性地址,0x08147258,换成二制进,也就是:
                0000100000 0101000111 001001011000
        内核对这个地址进行划分
                PGD = 0000100000
                PUD = 0
                PMD = 0
                PT = 0101000111
                offset = 001001011000

        现在来理解Linux针对硬件的花招,因为硬件根本看不到所谓PUD,PMD,所以,本质上要求PGD索引,直接就对应了PT的地址。而不是再到PUD和PMD中去查数组(虽然它们两个在线性地址中,长度为0,2^0 =1,也就是说,它们都是有一个数组元素的数组),那么,内核如何合理安排地址呢?
        从软件的角度上来讲,因为它的项只有一个,32位,刚好可以存放与PGD中长度一样的地址指针。那么所谓先到PUD,到到PMD中做映射转换,就变成了保持原值不变,一一转手就可以了。这样,就实现了“逻辑上指向一个PUD,再指向一个PDM,但在物理上是直接指向相应的PT的这个抽像,因为硬件根本不知道有PUD、PMD这个东西”。

        然后交给硬件,硬件对这个地址进行划分,看到的是:
                页目录 = 0000100000
                PT = 0101000111
                offset = 001001011000
        嗯,先根据0000100000(32),在页目录数组中索引,找到其元素中的地址,取其高20位,找到页表的地址,页表的地址是由内核动态分配的,接着,再加一个offset,就是最终的物理地址了。




=======================================================================

linux中的物理地址和虚拟地址

源地址: http://hujianjust.blog.163.com/blog/static/72455072201042795435529/

        在支持MMU的32位处理器平台上,Linux系统中的物理存储空间和虚拟存储空间的地址范围分别都是从0x00000000到0xFFFFFFFF,共4GB,但物理存储空间与虚拟存储空间布局完全不同。Linux运行在虚拟存储空间,并负责把系统中实际存在的远小于4GB的物理内存根据不同需求映射到整个4GB的虚拟存储空间中。

物理存储空间布局

        Linux的物理存储空间布局与处理器相关,详细情况可以从处理器用户手册的存储空间分布表(memory map)相关章节中查到,我们这里只列出嵌入式处理器平台Linux物理内存空间的一般布局,如图18-4所示。


图18-4  Linux物理内存空间一般布局示意图

说明:
        1)最大node号n不能大于MAX_NUMNODES-1。
        2)MAX_NUMNODES表示系统支持的最多node数。在ARM系统中,Sharp芯片最多支持16个nodes,其他芯片最多支持4个nodes。
        3)numnodes是当前系统中实际的内存node数。
        4)在不支持CONFIG_DISCONTIGMEM选项的系统中,只有一个内存node。
        5)最大bank号m不能大于NR_BANKS-1。
        6)NR_BANKS表示系统中支持的最大内存bank数,一般等于处理器的RAM片选数。在ARM系统中,Sharp芯片最多支持16个banks,其他芯片最多支持8个banks。
        7)mem_init()函数会将所有节点的页帧位码表所占空间、孔洞页描述符空间及空闲内存页都释放掉。

 虚拟存储空间布局

        在支持MMU的系统中,当系统做完硬件初始化后就使能MMU功能,这样整个系统就运行在虚拟存储空间中,实现虚拟存储空间到物理存储空间映射功能的是处理器的MMU,而虚拟存储空间与5路存储空间的映射关系则是由Linux内核来管理的。32位系统中物理存储空间占4GB空间,虚拟存储空间同样占4GB空间,Linux把物理空间中实际存在的远远小于4GB的内存空间映射到整个4GB虚拟存储空间中除映射I/O空间之外的全部空间,所以虚拟内存空间远远大于物理内存空间,这就说同一块物理内存可能映射到多处虚拟内存地址空间上,这正是Linux内存管理职责所在。图18-5列出了Linux内核中虚拟内存空间的一般布局(其实I/O空间也在其中,通常占用高端内存空间,在此未标出)。


图18-5  Linux系统虚拟内存空间一般布局示意图
说明:
        1)线性地址空间:是指Linux系统中从0x00000000到0xFFFFFFFF整个4GB虚拟存储空间。

        2)内核空间:内核空间表示运行在处理器最高级别的超级用户模式(supervisor mode)下的代码或数据,内核空间占用从0xC0000000到0xFFFFFFFF的1GB线性地址空间,内核线性地址空间由所有进程共享,但只有运行在内核态的进程才能访问,用户进程可以通过系统调用切换到内核态访问内核空间,进程运行在内核态时所产生的地址都属于内核空间。

        3)用户空间:用户空间占用从0x00000000到0xBFFFFFFF共3GB的线性地址空间,每个进程都有一个独立的3GB用户空间,所以用户空间由每个进程独有,但是内核线程没有用户空间,因为它不产生用户空间地址。另外子进程共享(继承)父进程的用户空间只是使用与父进程相同的用户线性地址到物理内存地址的映射关系,而不是共享父进程用户空间。运行在用户态和内核态的进程都可以访问用户空间。

        4)内核逻辑地址空间:是指从PAGE_OFFSET到high_memory之间的线性地址空间,是系统物理内存映射区,它映射了全部或部分(如果系统包含高端内存)物理内存。内核逻辑地址空间与图18-4中的系统RAM内存物理地址空间是一一对应的(包括内存孔洞也是一一对应的),内核逻辑地址空间中的地址与RAM内存物理地址空间中对应的地址只差一个固定偏移量,如果RAM内存物理地址空间从0x00000000地址编址,那么这个偏移量就是PAGE_OFFSET。

        5)低端内存:内核逻辑地址空间所映射物理内存就是低端内存,低端内存在Linux线性地址空间中始终有永久的一一对应的内核逻辑地址,系统初始化过程中将低端内存永久映射到了内核逻辑地址空间,为低端内存建立了虚拟映射页表。低端内存内物理内存的物理地址与线性地址之间的转换可以通过__pa(x)和__va(x)两个宏来进行,__pa(x)将内核逻辑地址空间的地址x转换成对应的物理地址,相当于__virt_to_phys((unsigned long)(x)),__va(x)则相反,把低端物理内存空间的地址转换成对应的内核逻辑地址,相当于((void *)__phys_to_virt((unsigned long)(x)))。

        6)高端内存:低端内存地址之上的物理内存是高端内存,高端内存在Linux线性地址空间中没有没有固定的一一对应的内核逻辑地址,系统初始化过程中不会为这些内存建立映射页表将其固定映射到Linux线性地址空间,而是需要使用高端内存的时候才为分配的高端物理内存建立映射页表,使其能够被内核使用,否则不能被使用。高端内存的物理地址于现行地址之间的转换不能使用上面的__pa(x)和__va(x)宏。

        7)高端内存概念的由来:如上所述,Linux将4GB的线性地址空间划分成两部分,从0x00000000到0xBFFFFFFF共3GB空间作为用户空间由用户进程独占,这部分线性地址空间并没有固定映射到物理内存空间上;从0xC0000000到0xFFFFFFFF的第4GB线性地址空间作为内核空间,在嵌入式系统中,这部分线性地址空间除了映射物理内存空间之外还要映射处理器内部外设寄存器空间等I/O空间。0xC0000000~high_memory之间的内核逻辑地址空间专用来固定映射系统中的物理内存,也就是说0xC0000000~high_memory之间空间大小与系统的物理内存空间大小是相同的(当然在配置了CONFIG_DISCONTIGMEMD选项的非连续内存系统中,内核逻辑地址空间和物理内存空间一样可能存在内存孔洞),如果系统中的物理内存容量远小于1GB,那么内核现行地址空间中内核逻辑地址空间之上的high_memory~0xFFFFFFFF之间还有足够的空间来固定映射一些I/O空间。可是,如果系统中的物理内存容量(包括内存孔洞)大于1GB,那么就没有足够的内核线性地址空间来固定映射系统全部物理内存以及一些I/O空间了,为了解决这个问题,在x86处理器平台设置了一个经验值:896MB,就是说,如果系统中的物理内存(包括内存孔洞)大于896MB,那么将前896MB物理内存固定映射到内核逻辑地址空间0xC0000000~0xC0000000+896MB(=high_memory)上,而896MB之后的物理内存则不建立到内核线性地址空间的固定映射,这部分内存就叫高端物理内存。此时内核线性地址空间high_memory~0xFFFFFFFF之间的128MB空间就称为高端内存线性地址空间,用来映射高端物理内存和I/O空间。896MB是x86处理器平台的经验值,留了128MB线性地址空间来映射高端内存以及I/O地址空间,我们在嵌入式系统中可以根据具体情况修改这个阈值,比如,MIPS中将这个值设置为0x20000000B(512MB),那么只有当系统中的物理内存空间容量大于0x20000000B时,内核才需要配置CONFIG_HIGHMEM选项,使能内核对高端内存的分配和映射功能。什么情况需要划分出高端物理内存以及高端物理内存阈值的设置原则见上面的内存页区(zone)概念说明。

        8)高端线性地址空间:从high_memory到0xFFFFFFFF之间的线性地址空间属于高端线性地址空间,其中VMALLOC_START~VMALLOC_END之间线性地址被vmalloc()函数用来分配物理上不连续但线性地址空间连续的高端物理内存,或者被vmap()函数用来映射高端或低端物理内存,或者由ioremap()函数来重新映射I/O物理空间。PKMAP_BASE开始的LAST_PKMAP(一般等于1024)页线性地址空间被kmap()函数用来永久映射高端物理内存。FIXADDR_START开始的KM_TYPE_NR*NR_CPUS页线性地址空间被kmap_atomic()函数用来临时映射高端物理内存,其他未用高端线性地址空间可以用来在系统初始化期间永久映射I/O地址空间。
Linux 2.6.10内核中的ARM处理器平台部分没有对高端内存的支持,图18-6和图18-7分别列出了SA1100和IXP4XX处理器平台的Linux线性地址空间布局。

在嵌入式系统中如何访问I/O资源呢?

        几乎每一种外设都是通过读写设备上的寄存器来进行的,通常包括控制寄存器、状态寄存器和数据寄存器三大类,外设的寄存器通常被连续地编址。根据CPU体系结构的不同,CPU对IO端口的编址方式有两种:
  (1)I/O映射方式(I/O-mapped)
  典型地,如X86处理器为外设专门实现了一个单独的地址空间,称为"I/O地址空间"或者"I/O端口空间",CPU通过专门的I/O指令(如X86的IN和OUT指令)来访问这一空间中的地址单元。
  (2)内存映射方式(Memory-mapped)
  RISC指令系统的CPU(如ARM、PowerPC等)通常只实现一个物理地址空间,外设I/O端口成为内存的一部分。此时,CPU可以象访问一个内存单元那样访问外设I/O端口,而不需要设立专门的外设I/O指令。
  但是,这两者在硬件实现上的差异对于软件来说是完全透明的,驱动程序开发人员可以将内存映射方式的I/O端口和外设内存统一看作是"I/O内存"资源。
  一般来说,在系统运行时,外设的I/O内存资源的物理地址是已知的,由硬件的设计决定。但是CPU通常并没有为这些已知的外设I/O内存资源的物理地址预定义虚拟地址范围,驱动程序并不能直接通过物理地址访问I/O内存资源,而必须将它们映射到核心虚地址空间内(通过页表),然后才能根据映射所得到的核心虚地址范围,通过访内指令访问这些I/O内存资源。Linux在io.h头文件中声明了函数ioremap(),用来将I/O内存资源的物理地址映射到核心虚地址空间(3GB-4GB)中,原型如下:
void * ioremap(unsigned long phys_addr, unsigned long size, unsigned long flags);

  iounmap函数用于取消ioremap()所做的映射,原型如下:
void iounmap(void * addr);

  这两个函数都是实现在mm/ioremap.c文件中。
  在将I/O内存资源的物理地址映射成核心虚地址后,理论上讲我们就可以象读写RAM那样直接读写I/O内存资源了。为了保证驱动程序的跨平台的可移植性,我们应该使用Linux中特定的函数来访问I/O内存资源,而不应该通过指向核心虚地址的指针来访问。如在x86平台上,读写I/O的函数如下所示:
#define readb(addr) (*(volatile unsigned char *) __io_virt(addr))
#define readw(addr) (*(volatile unsigned short *) __io_virt(addr))
#define readl(addr) (*(volatile unsigned int *) __io_virt(addr))
#define writeb(b,addr) (*(volatile unsigned char *) __io_virt(addr) = (b))
#define writew(b,addr) (*(volatile unsigned short *) __io_virt(addr) = (b))
#define writel(b,addr) (*(volatile unsigned int *) __io_virt(addr) = (b))
#define memset_io(a,b,c) memset(__io_virt(a),(b),(c))
#define memcpy_fromio(a,b,c) memcpy((a),__io_virt(b),(c))
#define memcpy_toio(a,b,c) memcpy(__io_virt(a),(b),(c))
  最后,我们要特别强调驱动程序中mmap函数的实现方法。用mmap映射一个设备,意味着使用户空间的一段地址关联到设备内存上,这使得只要程序在分配的地址范围内进行读取或者写入,实际上就是对设备的访问。

参考地址: http://blog.csdn.net/do2jiang/archive/2010/04/05/5450839.aspx
                    http://book.chinaunix.net/showart.php?id=3266


===================================================================
转自  http://bbs.chinaunix.net/thread-1918366-1-1.html

一幅图让你彻底明白虚拟地址与物理地址的映射关系






==================================================================


转自  http://www.kerneltravel.net/jiaoliu/005.htm

linux内核空间与用户空间信息交互方法

本文作者

          康华:计算机硕士,主要从事Linux操作系统内核、Linux技术标准、计算机安全、软件测试等领域的研究与开发工作,现就职于信息产业部软件与集成电路促进中心所属的MII-HP Linux软件实验室。如果需要可以联系通过[email protected]联系他。

 

          摘要:在进行设备驱动程序,内核功能模块等系统级开发时,通常需要在内核和用户程序之间交换信息。Linux提供了多种方法可以用来完成这些任务。本文总结了各种常用的信息交换方法,并用简单的例子演示这些方法各自的特点及用法。其中有大家非常熟悉的方法,也有特殊条件下方可使用的手段。通过对比明确这些方法,可以加深我们对Linux内核的认识,更重要的是,可以让我们更熟练驾御linux内核级的应用开发技术。

 

内核空间(kernel-space) VS 用户空间(user-space)

          作为一个Linux开发者,首先应该清楚内核空间和用户空间的区别。关于这个话题,已经有很多相关资料,我们在这里简单描述如下:

          现代的计算机体系结构中存储管理通常都包含保护机制。提供保护的目的,是要避免系统中的一个任务访问属于另外的或属于操作系统的存储区域。如在IntelX86体系中,就提供了特权级这种保护机制,通过特权级别的区别来限制对存储区域的访问。 基于这种构架,Linux操作系统对自身进行了划分:一部分核心软件独立于普通应用程序,运行在较高的特权级别上,(Linux使用Intel体系的特权级3来运行内核。)它们驻留在被保护的内存空间上,拥有访问硬件设备的所有权限,Linux将此称为内核空间。

          相对的,其它部分被作为应用程序在用户空间执行。它们只能看到允许它们使用的部分系统资源,并且不能使用某些特定的系统功能,不能直接访问硬件,不能直接访问内核空间,当然还有其他一些具体的使用限制。(Linux使用Intel体系的特权级0来运行用户程序。)

          从安全角度讲将用户空间和内核空间置于这种非对称访问机制下是很有效的,它能抵御恶意用户的窥探,也能防止质量低劣的用户程序的侵害,从而使系统运行得更稳定可靠。但是,如果像这样完全不允许用户程序访问和使用内核空间的资源,那么我们的系统就无法提供任何有意义的功能了。为了方便用户程序使用在内核空间才能完全控制的资源,而又不违反上述的特权规定,从硬件体系结构本身到操作系统,都定义了标准的访问界面。关于X86系统的细节,请查阅参考资料1

          一般的硬件体系机构都提供一种“门”机制。“门”的含义是指在发生了特定事件的时候低特权的应用程序可以通过这些“门”进入高特权的内核空间。对于IntelX86体系来说,Linux操作系统正是利用了“系统门”这个硬件界面(通过调用int $0x80机器指令),构造了形形色色的系统调用作为软件界面,为应用程序从用户态陷入到内核态提供了通道。通过“系统调用”使用“系统门”并不需要特别的权限,但陷入到内核的具体位置却不是随意的,这个位置由“系统调用”来指定,有这样的限制才能保证内核安全无虞。我们可以形象地描述这种机制:作为一个游客,你可以买票要求进入野生动物园,但你必须老老实实的坐在观光车上,按照规定的路线观光游览。当然,不准下车,因为那样太危险,不是让你丢掉小命,就是让你吓坏了野生动物。

          出于效率和代码大小的考虑,内核程序不能使用标准库函数(当然还有其它的顾虑,详细原因请查阅参考资料2)因此内核开发不如用户程序开发那么方便。而且由于目前(linux2.6还没正式发布)的内核是“非抢占”的,因此正在内核空间运行的进程是不会被其他进程取代的(除非该进程主动放弃CPU的控制,比如调用sleep(),schedule()等),所以无论是在进程上下文中(比如正在运行read系统调用),还是在中断上下文(正在中断服务程序中),内核程序都不能长时间占用CPU,否则其它程序将无法执行,只能等待。

内核空间和用户空间的相互作用

          现在,越来越多的应用程序需要编写内核级和用户级的程序来一起完成具体的任务,通常采用以下模式:首先,编写内核服务程序利用内核空间提供的权限和服务来接收、处理和缓存数据;然后编写用户程序来和先前完成的内核服务程序交互,具体来说,可以利用用户程序来配置内核服务程序的参数,提取内核服务程序提供的数据,当然,也可以向内核服务程序输入待处理数据。

          比较典型的应用包括: Netfilter(内核服务程序:防火墙)VS Iptable(用户级程序:规则设置程序);IPSEC(内核服务程序:VPN协议部分)VS IKE(用户级程序:密钥协商处理);当然还包括大量的设备驱动程序及相应的应用软件。这些应用都是由内核级和用户级程序通过相互交换信息来一起完成特定任务的。

信息交互方法

          用户程序和内核的信息交换是双向的,也就是说既可以主动从用户空间向内核空间发送信息,也可以从内核空间向用户空间提交数据。当然,用户程序也可以主动地从内核提取数据。下面我们就针对内核和用户交互数据的方法做一总结、归纳。

          信息交互按信息传输发起方可以分为用户向内核传送/提取数据和内核向用户空间提交请求两大类,先来说说:由用户级程序主动发起的信息交互。


用户级程序主动发起的信息交互

A。编写自己的系统调用

          从前文可以看出,系统调用是用户级程序访问内核最基本的方法。目前linux大致提供了二百多个标准的系统调用(参见内核代码树中的include/ asm-i386/unistd.h和arch/i386/kernel/entry.S文件),并且允许我们添加自己的系统调用来实现和内核的信息交换。比如我们希望建立一个系统调用日志系统,将所有的系统调用动作记录下来,以便进行入侵检测。此时,我们可以编写一个内核服务程序。该程序负责收集所有的系统调用请求,并将这些调用信息记录到在内核中自建的缓冲里。我们无法在内核里实现复杂的入侵检测程序,因此必须将该缓冲里的记录提取到用户空间。最直截了当的方法是自己编写一个新系统调用实现这种提取缓冲数据的功能。当内核服务程序和新系统调用都实现后,我们就可以在用户空间里编写用户程序进行入侵检测任务了,入侵检测程序可以定时、轮训或在需要的时候调用新系统调用从内核提取数据,然后进行入侵检测了。

B。编写驱动程序

          Linux/UNIX的一个特点就是把所有的东西都看作是文件(every thing is a file)。系统定义了简洁完善的驱动程序界面,客户程序可以用统一的方法透过这个界面和内核驱动程序交互。而大部分系统的使用者和开发者已经非常熟悉这种界面以及相应的开发流程了。

          驱动程序运行于内核空间,用户空间的应用程序通过文件系统中/dev/目录下的一个文件来和它交互。这就是我们熟悉的那个文件操作流程:open() —— read() ——write() —— ioctl() —— close()。(需要注意的是也不是所有的内核驱动程序都是这个界面,网络驱动程序和各种协议栈的使用就不大一致,比如说套接口编程虽然也有open()close()等概念,但它的内核实现以及外部使用方式都和普通驱动程序有很大差异。)关于这部分的编程细节,请查阅参考资料3、4。

          设备驱动程序在内核中要做的中断响应、设备管理、数据处理等等各种工作这篇文章不去关心,我们把注意力集中在它与用户级程序交互这一部分。操作系统为此定义了一种统一的交互界面,就是前面所说的open(), read(), write(), ioctl()和close()等等。每个驱动程序按照自己的需要做独立实现,把自己提供的功能和服务隐藏在这个统一界面下。客户级程序选择需要的驱动程序或服务(其实就是选择/dev/目录下的文件),按照上述界面和文件操作流程,就可以跟内核中的驱动交互了。其实用面向对象的概念会更容易解释,系统定义了一个抽象的界面(abstract interface),每个具体的驱动程序都是这个界面的实现(implementation)。

          所以驱动程序也是用户空间和内核信息交互的重要方式之一。其实ioctl, read, write本质上讲也是通过系统调用去完成的,只是这些调用已被内核进行了标准封装,统一定义。因此用户不必向填加新系统调用那样必须修改内核代码,重新编译新内核,使用虚拟设备只需要通过模块方法将新的虚拟设备安装到内核中(insmod上)就能方便使用。关于此方面设计细节请查阅参考资料5,编程细节请查阅参考资料6。

          在linux中,设备大致可分为:字符设备,块设备,和网络接口(字符设备包括那些必须以顺序方式,像字节流一样被访问的设备;如字符终端,串口等。块设备是指那些可以用随机方式,以整块数据为单位来访问的设备,如硬盘等;网络接口,就指通常网卡和协议栈等复杂的网络输入输出服务)。如果将我们的系统调用日志系统用字符型驱动程序的方式实现,也是一件轻松惬意地工作。我们可以将内核中收集和记录信息的那一部分编写成一个字符设备驱动程序。虽然没有实际对应的物理设备,但这并没什么问题:Linux的设备驱动程序本来就是一个软件抽象,它可以结合硬件提供服务,也完全可以作为纯软件提供服务(当然,内存的使用我们是无法避免的)。在驱动程序中,我们可以用open来启动服务,用read()返回处理好的记录,用ioctl()设置记录格式等,用close()停止服务,write()没有用到,那么我们可以不去实现它。然后在/dev/目录下建立一个设备文件对应我们新加入内核的系统调用日志系统驱动程序。

C。使用proc 文件系统

          proc是Linux提供的一种特殊的文件系统,推出它的目的就是提供一种便捷的用户和内核间的交互方式。它以文件系统作为使用界面,使应用程序可以以文件操作的方式安全、方便的获取系统当前运行的状态和其它一些内核数据信息。

          proc文件系统多用于监视、管理和调试系统,我们使用的很多管理工具如ps,top等,都是利用proc来读取内核信息的。除了读取内核信息,proc文件系统还提供了写入功能。所以我们也就可以利用它来向内核输入信息。比如,通过修改proc文件系统下的系统参数配置文件(/proc/sys),我们可以直接在运行时动态更改内核参数;再如,通过下面这条指令:

echo 1 > /proc/sys/net/ip_v4/ip_forward

          开启内核中控制IP转发的开关,我们就可以让运行中的Linux系统启用路由功能。类似的,还有许多内核选项可以直接通过proc文件系统进行查询和调整。

          除了系统已经提供的文件条目,proc还为我们留有接口,允许我们在内核中创建新的条目从而与用户程序共享信息数据。比如,我们可以为系统调用日志程序(不管是作为驱动程序也好,还是作为单纯的内核模块也好)在proc文件系统中创建新的文件条目,在此条目中显示系统调用的使用次数,每个单独系统调用的使用频率等等。我们也可以增加另外的条目,用于设置日志记录规则,比如说不记录open系统调用的使用情况等。关于proc文件系统得使用细节,请查阅参考资料7。

D。 使用虚拟文件系统

          有些内核开发者认为利用ioctl()系统调用往往会似的系统调用意义不明确,而且难控制。而将信息放入到proc文件系统中会使信息组织混乱,因此也不赞成过多使用。他们建议实现一种孤立的虚拟文件系统来代替ioctl()和/proc,因为文件系统接口清楚,而且便于用户空间访问,同时利用虚拟文件系统使得利用脚本执行系统管理任务更家方便、有效。

          我们举例来说如何通过虚拟文件系统修改内核信息。我们可以实现一个名为sagafs的虚拟文件系统,其中文件log对应内核存储的系统调用日志。我们可以通过文件访问特普遍方法获得日志信息:如

# cat /sagafs/log

          使用虚拟文件系统——VFS实现信息交互使得系统管理更加方便、清晰。但有些编程者也许会说VFS 的API 接口复杂不容易掌握,不要担心2.5内核开始就提供了一种叫做libfs的例程序帮助不熟悉文件系统的用户封装了实现VFS的通用操作。有关利用VFS实现交互的方法看参考资料。

E。 使用内存映像

          Linux通过内存映像机制来提供用户程序对内存直接访问的能力。内存映像的意思是把内核中特定部分的内存空间映射到用户级程序的内存空间去。也就是说,用户空间和内核空间共享一块相同的内存。这样做的直观效果显而易见:内核在这块地址内存储变更的任何数据,用户可以立即发现和使用,根本无须数据拷贝。而在使用系统调用交互信息时,在整个操作过程中必须有一步数据拷贝的工作——或者是把内核数据拷贝到用户缓冲区,或只是把用户数据拷贝到内核缓冲区——这对于许多数据传输量大、时间要求高的应用,这无疑是致命的一击:许多应用根本就无法忍受数据拷贝所耗费的时间和资源。

          我们曾经为一块高速采样设备开发过驱动程序,该设备要求在20兆采样率下以1KHz的重复频率进行16位实时采样,每毫秒需要采样、DMA和处理的数据量惊人,如果要使用数据拷贝的方法,根本无法达成要求。此时,内存映像成为唯一的选择:我们在内存中保留了一块空间,将其配置成环形队列供采样设备DMA输出数据。再把这块内存空间映射到在用户空间运行的数据处理程序上,于是,采样设备刚刚得到并传送到主机上的数据,马上就可以被用户空间的程序处理。

          实际上,内存影射方式通常也正是应用在那些内核和用户空间需要快速大量交互数据的情况下,特别是那些对实时性要求较强的应用。X window系统的服务器的虚拟内存区域,就可以被看做是内存映像用法的一个典型例子:X服务器需要对视频内存进行大量的数据交换,相对于lseek/write来说,将图形显示内存直接影射到用户空间可以显著提高效能。

          并不是任何类型的应用都适合mmap,比如像串口和鼠标这些基于流数据的字符设备,mmap就没有太大的用武之地。并且,这种共享内存的方式存在不好同步的问题。由于没有专门的同步机制可以让用户程序和内核程序共享,所以在读取和写入数据时要有非常谨慎的设计以保证不会产生干绕。

          mmap完全是基于共享内存的观念了,也正因为此,它能提供额外的便利,但也特别难以控制。


由内核主动发起的信息交互

          在内核发起的交互中,我们最关心和感兴趣的应该是内核如何向用户程序发消息,用户程序又是怎样接收这些消息的,具体问题通常集中在下面这几个方面:内核可否调用用户程序?是否可以通过向用户进程发信号来告知用户进程事件发生?

          前面介绍的交互方法最大的不同在于这些方式是由内核采取主动,而不是等系统调用来被动的返回信息的。

A。从内核空间调用用户程序

          即使在内核中,我们有时也需要执行一些在用户级才提供的操作:如打开某个文件以读取特定数据,执行某个用户程序从而完成某个功能。因为许多数据和功能在用户空间是现有的或者已经被实现了,那么没有必要耗费大量的资源去重复。此外,内核在设计时,为了拥有更好的弹性或者性能以支持未知但有可能发生的变化,本身就要求使用用户空间的资源来配合完成任务。比如内核中动态加载模块的部分需要调用kmod。但在编译kmod的时候不可能把所有的内核模块都订下来(要是这样的话动态加载模块就没有存在意义了),所以它不可能知道在它以后才出现的那些模块的位置和加载方法。因此,模块的动态加载就采用了如下策略:加载任务实际上由位于用户空间的modprobe程序帮助完成——最简单的情形是modprobe用内核传过来的模块名字作为参数调用insmod。用这种方法来加载所需要的模块。

          内核中启动用户程序还是要通过execve这个系统调用原形,只是此时的调用发生在内核空间,而一般的系统调用则在用户空间进行。如果系统调用带参数,那将会碰到一个问题:因为在系统调用的具体实现代码中要检查参数合法性,该检查要求所有的参数必须位于用户空间——地址处于0x0000000——0xC0000000之间,所以如果我们从内核传递参数(地址大于0xC0000000),那么检查就会拒绝我们的调用请求。为了解决这个问题,我们可以利用set_fs宏来修改检查策略,使得允许参数地址为内核地址。这样内核就可以直接使用该系统调用了。

          例如:在kmod通过调用execve来执行modprobe的代码前需要有set_fs(KERNEL_DS):

......
set_fs(KERNEL_DS);

/* Go, go, go... */
if (execve(program_path, argv, envp) < 0)
return -errno;

上述代码中program_path 为"/sbin/modprobe",argv为{ modprobe_path, "-s", "-k", "--", (char*)module_name, NULL },envp为{ "HOME=/", "TERM=linux", "PATH=/sbin:/usr/sbin:/bin:/usr/bin", NULL }。

          从内核中打开文件同样使用带参数的open系统调用,所需的仍是要先调用set_fs宏。

 

B。利用brk系统调用来导出内核数据

          内核和用户空间传递数据主要是用get_user(ptr)和put_user(datum,ptr)例程。所以在大部分需要传递数据的系统调用中都可以找到它们的身影。可是,如果我们不是通过用户程序发起的系统调用——也就是说,没有明确的提供用户空间内的缓冲区位置——的情况下,如何向用户空间传递内核数据呢?

          显然,我们不能再直接使用put_user()了,因为我们没有办法给它指定目的缓冲区。所以,我们要借用brk系统调用和当前进程空间:brk用于给进程设置堆空间的大小。每个进程拥有一个独立的堆空间,malloc等动态内存分配函数其实就是进程的堆空间中获取内存的。我们将利用brk在当前进程(current process)的堆空间上扩展一块新的临时缓冲区,再用put_user将内核数据导出到这个确定的用户空间去。

          还记得刚才我们在内核中调用用户程序的过程吗?在那里,我们有一个跳过参数检查的操作,现在有了这种方法,可以另辟蹊径了:我们在当前进程的堆上扩展一块空间,把系统调用要用到的参数通过put_user()拷贝到新扩展得到的用户空间里,然后在调用execve的时候以这个新开辟空间地址作为参数,于是,参数检查的障碍不复存在了。

char * program_path = "/bin/ls" ;

/* 找到当前堆顶的位置*/ 
mmm=current->mm->brk;
/* 用brk在堆顶上原扩展出一块256字节的新缓冲区*/
ret = brk(*(void)(mmm+256));
/* 把execve需要用到的参数拷贝到新缓冲区上去*/
put_user((void*)2,program_path,strlen(program_path)+1);
/* 成功执行/bin/ls程序!*/ 
execve((char*)(mmm+2));
/* 恢复现场*/
tmp = brk((void*)mmm);

          这种方法没有一般性(具体的说,这种方法有负面效应吗),只能作为一种技巧,但我们不难发现:如果你熟悉内核结构,就可以做到很多意想不到的事情!

C。使用信号

          信号在内核里的用途主要集中在通知用户程序出现重大错误,强行杀死当前进程,这时内核通过发送SIGKILL信号通知进程终止,内核发送信号使用send_sign(pid,sig)例程,可以看到信号发送必须要事先知道进程序号(pid),所以要想从内核中通过发信号的方式异步通知用户进程执行某项任务,那么必须事先知道用户进程的进程号才可。而内核运行时搜索到特定进程的进程号是个费事的工作,可能要遍历整个进程控制块链表。所以用信号通知特定用户进程的方法很糟糕,一般在内核不会使用。内核中使用信号的情形只出现在通知当前进程(可以从current变量中方便获得pid)做某些通用操作,如终止操作等。因此对内核开发者该方法用处不大。

          类似情况还有消息操作。这里不罗嗦了。

          总结  由用户级程序主动发起的信息交互,无论是采用标准的调用方式还是透过驱动程序界面,一般都要用到系统调用。而由内核主动发起信息交互的情况不多。也没有标准的界面,操作大不方便。所以一般情况下,尽可能用本文描述的前几种方法进行信息交互。毕竟,在设计的根源上,相对于客户级程序,内核就被定义为一个被动的服务提供者。因此,我们自己的开发也应该尽量遵循这种设计原则。


参考资料

周明德,保护方式下的80386及其编程,清华大学出版社,1993

2 Robert Love, Linux Kernel Development,Sams Publishing,2003

3 W.Richard Stevens, Advanced Programming in the UNIX Environment,Addision Wesley,1992

4 W.Richard Stevens, UNIX Network Programming, Prentic Hall, 1998

5 Maurice J. Bach, The Design of the UNIX Operating System, Prentic Hall, 1990

6 Linux Device Driver, O’Reilly

7 Ori Pomerantz ,Linux Kernel Module Programming Guide, 1999





你可能感兴趣的:(Linux)