RSS Feed

Posts Tagged ‘kernel’

  1. 虚拟化的两种方案

    April 3, 2013 by xudifsd

    虚拟化是云计算催生的技术之一,它的目的就是让运行在其上的应用程序觉得自己独占所有的资源。与当年的虚拟内存的出现的原因一样:在出现虚拟内存之前,所有的进程都能知道本机上还有哪些其他的进程,需要注意不要踩到别的进程。但是出现虚拟内存之后所有进程都觉得自己独占了4G内存(32位机器)。而现在的虚拟化技术只是将这个虚拟范围扩展了而已(扩展到了文件系统,进程和设备领域)。

    解释现在正在进行的虚拟化潮流的入门概念最清楚的就是这篇文章了,它将虚拟化的两种方案做了比较,并且指出现在正在进行的Container虚拟化的缘由。

    虚拟化的目标

    简单来说虚拟化的目标就是这两个:

    • 资源控制和隔离
    • 名字空间的隔离

    所谓的资源控制就是记录用户(这个用户不是指人类,而是指进程或进程组,之后也使用这个概念)消耗了多少资源,并给与每个用户一定的,而不是无限制的资源。而隔离则将虚拟化进行地更加彻底:防止一个用户的资源需求影响到其他用户的资源需求(例如在内存资源欠缺的系统中,一个进程的的内存需求可能会将另一个进程的内存页swap到外存而减缓其完成速度)。

    名字空间的隔离就是允许在同一个硬件上的不同用户看到不同的UNIX资源(进程号或网络接口等),例如一个名字空间的进程号和另一个名字空间的进程号指的不是同一个进程,这样这两个名字空间就不会相互影响,因此也就让作业觉得自己独占了所有资源。

    虚拟化的两种实现

    虚拟化有两种实现,如果你只是想要了解如何进行选择,可以看这篇文章

    Hypervisor

    Hypervisor就是在操作系统上再运行一个或多个操作系统,这样底层的操作系统就叫做hypervisor,它来管理和分配上层操作系统所需要的资源。可以看到这样的虚拟化方案多么的无奈:为了实现资源和名字空间的控制和隔离而运行多个操作系统实体,这样的开销实在是有点大。

    根据维基的解释,这种虚拟化也分两种类型:

    • 原生(native),直接将需要虚拟的OS运行在裸机上,如kvm,Xen。这些方案都需要特殊的硬件支持
    • 宿生(hosted),在软件层面虚拟化,如Virtualbox。

    和上面第一个链接中提到的那样,hypervisor只是人们在OS实现真正操作系统层面虚拟化之前这个过渡时期所应用的方法而已,因为要让OS直接支持虚拟化还需要很多路要走,但是如果只是增加一些硬件支持就能使用虚拟化,那么这条路就会被当作临时的解决方案。最终在container实现之后这个技术终将成为历史(当然可能也不会消失,因为最近为了在linux上也能上12306.cn查票,我还在virtualbox里装了个Win。。这种需求不是container能解决的)。

    Container

    Container的虚拟化开销与Hypervisor相比就要少很多,因为就只是运行了一个内核而已,但是它有个问题就是实现并没有成熟,可能并不适合在生产环境中使用。

    Container也称operating system level virtualization。也就是说它在操作系统层面实现了前面提到的虚拟化目标,由于内核已经有了很多这些资源控制的API,所以主要问题已经不是实现这些API,而是将这些API统一起来,形成一个统一的模型。可以看这篇07年写的论文来了解一些进展。

    内核中实现资源控制的是cgroup,而实现名字空间隔离的就是namespace,LWN上已经有一系列的文章来介绍一些namespace相关的内核接口。

    结论

    如果只是想要在云计算中使用虚拟化来最大化利用硬件资源,那么Container是未来最好的选择,但由于仍然不成熟所以目前不太可能广泛部署,所以Hypervisor在Container成熟之前仍然是唯一选择。不过不知道你们怎么样,反正我是不会花时间去学它了。而现在也是学习Container虚拟化的最好时期,因为最近大量相关patch进入mainline kernel。似乎要开始跟着patch学虚拟化了。


  2. 内核的物理页分配器

    January 17, 2013 by xudifsd

    注意,本文主要内容来自《Understanding the Linux Virtual Memory Manager》,基于2.4内核,和最新内核有所不同。并且本人是内核新手,请轻拍。

    伙伴系统的结构和水位

    linux内核使用伙伴系统来分配内存页,分配内存的API是alloc_pages(),但是主函数是__alloc_pages(),这个函数分为三个部分:

    1. 看是否能在不达到zone->pages_low水位的情况下满足分配要求,如能则直接分配。
    2. 否则zone需要balance,唤醒kswapd,并看能否在不达到zone->pages_min水位的情况下满足分配要求,如能则分配。
    3. 否则进入slow path,先看自己是否是即将被OOM killer杀掉的进程,如是则不管水位直接分配,否则自己同步地做kswapd该做的事。

    每个zone都有三个水位线:pages_min,pages_low,pages_high,这三个水位线用于描述系统的内存压力大小的,当zone内空闲页的数目达到pages_low时__alloc_pages()会唤醒kswapd进程,当到了pages_min时__alloc_pages()也会自己干kswapd该干的活,也就是同步的kswapd,直到空闲页数目达到pages_high。

    不过,物理页分配器最难理解的就是伙伴系统的结构和操作过程了。伙伴系统使用下面的结构体的数组描述空闲页:

    typedef struct free_area_struct {
            struct list_head free_list;
            unsigned long *map;
    } free_area_t;

    由于伙伴系统的最基本单位是页,而且只能分配2order页数的连续内存块,所以order就作为结构体数组的索引,结构体中的free_list链接的是连续内存块中的第一个struct page,而map则是表示页是否分配的位图。

    需要注意的是内核用一种比较特殊的方式操作位图:只用1位了表示两个伙伴的状态,即每分配或释放一个伙伴就会对相应的位取反。所以0表示两个伙伴都可用或都不可用,1表示有一个可用。内核使用下面宏来操作位图:

    #define MARK_USED(index, order, area) \
    		__change_bit((index) >> (1 + (order)), (area)->map)

    注意其中的1,就是因为内核使用1位表示两个伙伴的状态,才会有中间的1。其中index是struct page在全局mem_map中的索引,area即是struct free_area_struct。

    分配内存

    考虑一个有16页的系统,如果一开始都是空闲那么free_area数组应该如下图,其中我用page表示结构体中的free_list,page的值就是相应struct page在全局mem_map中的偏移:

    free_area初始状态

    free_area初始状态

    其中order为4的page有个0表示mem_map的0偏移开始的24页都是空闲,其他page为NULL表示没有对应order大小的空闲页,而由于总共才16(24)页,所以order 4没有伙伴,而内核用位图的1位来描述伙伴,所以order为4的map为NULL,而3有一位,以此类推。

    假设现在需要分配order为2的连续内存块,先在free_area[2]中找,由于order为2的page为NULL,即表示没有order 2的连续空间,这样就要往order更高的地方找,3中也没有,只有4中有,这样就要进行分裂,将分裂出来的前一个伙伴放在order为3的page中,而后一个伙伴继续分裂一直到得到想要的order,本例中就是2,所以分配完这次order 2和3都会有一个空闲的块,如下图:

    分配内存块后的free_area状态

    分配内存块后的free_area状态

    现在的物理页应该有最后4页被占用,前面12页仍然空闲,上面的结构页表示正确(order为2的page有8表示从第8~11页为空闲,而12~15页已经被分配)。

    释放内存

    再看看释放的过程,内存块使用者必须记得所使用内存块的order,因为在释放时需要用到,这也是伙伴系统不方便的地方,分配的API是alloc_pages()而释放的API是__free_pages(),它的主要函数是__free_pages_ok(),由其做主要的释放工作:检查刚释放的内存块是否能与相应伙伴合并,这只要通过查看相应的位图是否为1即可完成,如果能合并,那么合并后会往更高的order里添加空闲页,并继续查看是否能此合并,直到MAX_ORDER,这个常数常常是10。本例合并后又变成原来的状态了。

    碎片

    碎片分为两种:

    • 外部碎片是无法满足大的分配要求,因为只存在小的内存块。
    • 内部碎片是对于小的请求也只能分配大的内存块。

    Linux的外部碎片问题并不严重,因为大的请求可以通过vmalloc()分配一个物理地址上不连续但是虚拟地址上连续的内存块。只是伙伴系统的内部碎片问题非常严重,因为伙伴系统的最小单位是页,这个单位对于直接分配结构体太大,所以内核还使用了slab分配器来解决这个问题。


  3. 给内核添加系统调用

    November 16, 2012 by xudifsd

    注意:本文所使用的系统为ubuntu12.04,使用i386体系,如果使用不同系统及体系很多地方需要修改,另外在安装新内核后请保持至少一个可用的旧内核,以防止新内核无法使用。

    一直想直接给内核添加一些基本的东西作为内核开发的下手点,于是就想到了给内核添加一个新的系统调用。这样的添加非常简单而且可以直接通过应用程序看是否成功,前些天就着《Linux Kernel Development》给内核加了个系统调用,没想到一次就成功了。

    系统调用

    应用程序都是通过系统调用来取得操作系统提供的服务,但是除了汇编之外,这些系统调用在所有语言里都是对程序员不可见的,也就是说你不知道调用的是一个普通函数还是系统调用。

    先从开始用汇编进行系统调用来了解整个过程吧。
    把以下代码保存在t1.s中:

    .section .data
    word:
            .ascii "Hello from assembly\n"
    word_end:
    .equ len, word_end - word	#取得字符串长度
    
    .section .text
    .global _start	#程序起点
    _start:
            movl $4, %eax	#write系统调用的调用号
            movl $1, %ebx	#stdout
            movl $word, %ecx	#输出字符串的地址
            movl $len, %edx	#字符串长度
            int $0x80	#通过中断进行系统调用
    
            movl $1, %eax	#exit的调用号
            movl $3, %ebx	#退出码
            int $0x80

    使用如下命令编译,链接:

    $ as t1.s -o t1.o && ld t1.o -o t1

    使用

    $ ./t1

    运行后可以看到程序输出“Hello from assembly”,通过

    $ echo $?

    可以看到程序的退出码为3。这里调用了两个系统调用,write和exit,从上面的程序也可以看出系统调用的参数是直接通过寄存器传递的,而不是像普通函数一样使用堆栈。而且参数按照在C函数原型中的顺序放在ebx,ecx,edx里,eax中放系统调用号。
    关于使用汇编在linux下进行编程请看《Programming From The Ground Up》。

    添加系统调用

    现在开始添加新的系统调用,首先添加系统调用代码,将下列代码加入到linux目录下的kernel/sys.c的末尾:

    asmlinkage void sys_copy(long *src, long *dst)
    {
            long buf;
    	buf = *src;
    	*dst = buf;
    }
    
    asmlinkage long sys_add(long a, long b)
    {
            return a + b;
    }

    这里我们定义了两个系统调用,一个将用户的数据从一个地方拷贝到另一个地方,这和swap的功能一样,只是在内核态中运行,不过需要注意的是这个函数没有进行任何安全性检查,这样应用程序可以传入一个指向内核地址的指针,去读取或修改内核的数据,这是一个巨大的安全问题,在真正开发中绝对不能这么干。另一个系统调用只是简单地将两个参数相加。

    之后要做的就是把系统调用的原型加到include/linux/syscalls.h头文件的末尾:

    asmlinkage void sys_copy(long *src, long *dst);
    
    asmlinkage long sys_add(long a, long b);

    最后一步是最重要的,这一步也和网上的很多资料不同,以前版本的内核添加系统调用还需要修改总的系统调用数,而现在只需要在一个表中添加对应的项即可,总调用数由编译过程自动生成。由于我是x86系统,而且是32位,所以只需要在arch/x86/syscalls/syscall_32.tbl的末尾加入以下两项即可:

    350     i386    copy                    sys_copy
    351     i386    add                     sys_add

    当然前面的350和351是按照你的表加入的,在我加入系统调用的时候最大的系统调用号还只是349,所以我用了350和351号。以上三个文件修改完后两个系统调用就加入了,重新编译及运行之前先试试在旧系统中是否可以使用,使用以下汇编程序:

    .section .data
    num1: .long 55	#copy的源地址
    
    .section .bss
    .lcomm num2, 4	#copy的目的地址
    
    .section .text
    .global _start
    _start:
            movl $350, %eax	#copy系统调用
            movl $num1, %ebx	#源地址
            movl $num2, %ecx	#目的地址
            int $0x80
    
            movl $351, %eax #add系统调用
            movl num2, %ebx	#拷贝后的55
            movl $22, %ecx	#55加22
            int $0x80
    
            movl %eax, %ebx	#将返回值作为退出码
            movl $1, %eax
            int $0x80

    一样需要注意的是程序中的350和351是我注册的系统调用号,如果你的系统调用号不同应该换成你的号码,同样使用上面的方法编译链接,运行后使用

    $ echo $?

    查看退出码,可以看到并非想要的77,因为在旧内核中并没有350和351这样的系统调用。

    编译内核

    修改源码后就要编译新内核,如果你从未编译过内核,你需要一个.config文件,直接通过make defconfig生成一个默认的,不过需要注意的是这个默认的不包括几乎所有的驱动,只是让你基本上能用而已,如果还想要驱动就通过make menuconfig自己去找吧,或者你也可以从/boot/里直接拷贝一个.config到当前目录下:

    $ cp /boot/config-version .config

    再编译,不过这样会几乎编译所有驱动,大部分还用不上,而且浪费很长时间编译。

    有了.config文件就使用

    $ make -j4

    开始编译,-j4是告诉make使用4个线程编译。之后使用

    # make modules_install

    安装模块,注意最后显示的版本号,之后就是安装了

    # cp arch/x86/boot/bzImage /boot/vmlinuz-$version
    # cp System.map /boot/System.map-$version
    # cp .config /boot/config-$version
    # mkinitramfs -k -o /boot/initrd.img-$version $version
    # update-grub

    其中命令中的$version需要替换成安装模块后出现的版本号。前三个命令分别拷贝内核,System map和config文件,第四个命令创建一个initrd,最后一个更新grub。都成功后就可以重启再运行前面的汇编程序了,运行后再使用:

    $ echo $?

    得到77就证明系统调用已经好用了。

    为什么不实现一个系统调用

    从上面的步骤也可以看出在内核中加入一个新的系统调用十分简单,但是并不是因为简单就要去做,很多情况下最好别加一个新的系统调用,因为一旦加入一个新的系统调用,你就必须一直维护它,同时系统调用的语义也不能改变。而且删除一个系统调用也不是那么简单,为了保持向后兼容,删除了的系统调用的调用号也不能分配给其他系统调用。所以很多情况下是不到完不得已才添加一个新的系统调用。