Windows的Copycat及其他

Ubuntu 12.04发布以后,这个Linux发行版就彻底地让我失望了。臃肿的身躯加上人机交互混沌的令人发指的Unity桌面,已经跟我需要的Linux桌面系统已经相去甚远。沙扬挪拉,Ubuntu。作为一个从6.04开始使用Ubuntu的用户,我不得不跟这个桌面系统说一声再见了。不是因为它在Xeon(R) CPU E31240 + Dual-channel 8GB内存的PC上居然启动的比Windows 7还慢,也不是因为它让我连应用程序菜单在哪里都找不到,只是因为它越来越像一个Windows的Copycat。

也许Ubuntu深受很多从Windows系统迁移过来的超级用户喜爱,但是它却已经失去了很多真正的Linux Users。Linux用户与Windows用户最根本区别的在于,前者确切地知道自己真正需要什么并且知道如何获得和实现他们自身的需要——因为开源软件提供了这种自由,而后者则恰恰相反。Windows从诞生以来就是救世主,它给你一切,让你毫不怀疑的享受这种安逸,让你无法主动地寻找自己的真正需要。由此,它越来臃肿越来越庞大越来越多的消耗更多的包括硬盘,内存和计算能力在内的系统资源。其实我并不单独厌恶Windows的这种给与和扩张,因为几乎所有的商业软件都具有极强的扩张性——比如很多字处理软件都包含拼写检查这个重复的功能,而我真正厌恶的是它对用户自由的侵害。为什么这么说?因为它们将用户假设为无法学习的群体,与Windows交互的最宏观的过程可以总结为“You pay, you go”。所有的软件问题都是需要钱才能解决,而且最根本的这些问题只能由拥有源代码的私有公司解决。这是最基础的假设。如果说,对于一个家庭主妇或者某位非计算机行业/专业人士,这大概没有什么问题。但是,在这种环境之中成长起来的程序员却会慢慢失去学习的意愿和能力。如果你想用“我要将目光集中到我自己的业务上去”这样的话来辩驳的时候,你已经错了,如果你是一位程序员的话。因为那些用来调查和调整系统错误的时间——所谓的“浪费”——事实上一种必备的能力。因为你需要知道计算机的一切!

“……,我从来不把安逸和快乐看作是生活目的的本身——这种伦理基础我叫它猪栏理想。” ——爱因斯坦

对一个人来说,停止思考之日便是他的死亡之时。对程序员来说更是如此。闭源软件正在带着停止思考的你我渐渐死去。

Ubuntu有了软件商店,这是一件好事。它本来有机会开拓一个新的开源软件的开发模式。但不幸的是它却偏离的开放源代码软件的初衷。开源软件真正的意义在于“开放源代码”!而不是“免费”。假设,你买到Photoshop套件的同时可以获得它的源码,难道你不开心么,难道你不想要这样的软件么?我想有人会提到“盗版”。看上去会很复杂,但“盗版”和开源事实上不是同一个命题。闭源软件同样会被盗版。真正保护版权的是适当的法律和严格的执行。不要跟我提中国市场上猖獗的盗版,因为中国软件市场,软件人才事实上是被上述猪栏思想和猖獗的盗版所戕害的。

没有哪个商业操作系统或者Linux发行版能够提供给你那个你确切需要的软件平台。只有自己动手,对,就是自己动手,你才有可能重新控制并且掌握你的计算机,才能享受到开源软件提供给你的自由。Ubuntu 6.04推出的时候,它最成功的地方不是它的liveCD,而是那个有大约3000多行的HOWTO——一个简洁明快的Wikipage。它提供给用户一个运行良好的基础系统,并且提供了多达2万的可选软件包。它还写了一份详实的文档帮助你定制自己的系统。这就是最初的Ubuntu。如果它要模范Windows,或者像Mac OS那样仅仅将开源软件作为架子想“给用户一切”,那它就完全错了。

一声看似潇洒的沙扬挪拉不会解决任何问题。作为一个Linux User我只有自己救自己。

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等等。

Linux内核源码阅读系列(7)-链接4

可执行目标文件

静态连接的目标输出是“可执行的目标文件”,它的基本形式当然也是ELF。只是,“可执行目标文件”与“可重定位目标文件”一些重要的区别。产生这种区别的原因在于这两种文件的目的不同。“可重定位目标文件”的目的是提供给链接器(静态链接的ld或者动态链接的ld.so)链接信息以便帮助可执行文件。而“可执行目标文件”的目的在于提供一种形式将程序的代码方便的加载到内存中以便执行。所以,“可执行的目标文件”中增加了一个叫做“段头表”(segment header table)的部分,这个表中描述了文件内容到主存的映射方法,在程序运行时,加载器(loader)将根据这个表的信息把“可执行目标文件”中的代码拷贝到主存中去。

内存布局

下面的图展示了一个典型的可执行目标文件的构成,以及在取其相应的内存“镜像”之间的简单关系(并不严格)。

 +----------------------+
 |    ELF header        |
 +----------------------+     +--------------------+ 0x00000000
 | segment header table |     |       NO USAGE     |
 +----------------------+     +--------------------+ 0x08048000
 |       .init          | - > |  read-only segment |
 +----------------------+     |                    |
 |       .text          | - > |  (size <= 4kb*n)   |
 +----------------------+     |                    |
 |       .rodata        | - > |  ......            |
 +----------------------+     +--------------------+
 |       .data          | - > | read-write segment |
 +----------------------+     |  (size <= 4kb*m)   |
 |        .bss          | - > |  .....             |
 x----------------------x     +--------------------+
 /        .symtab       /     |       heap         |
 x----------------------x     +--------------------+
 /      .rel.text       /     |        ↓           |
 x----------------------x     |                    |
 /      .rel.data       /     +--------------------+ 0x40000000
 x----------------------x     |   shared library   |
 /       .debug         /     | memory map area    |
 x----------------------x     +--------------------+
 /       .line          /     |        ↓           |
 x----------------------x     |        ↑           |
 /       .strtab        /     +--------------------+
 x----------------------x     |      stack         |
 / section header table /     +--------------------+ 0xbfffffff
 x----------------------x     |      kernel        |
                              +--------------------+

在IA32结构上,Linux采用虚拟内存(virtual memory)技术,所以,每个程序在内存布局的时候都好像已经拿到了所有的内存一样,而程序代码最开始的地方总是在虚拟地址0x08048000处。加载器从这里开始拷贝ELF中定义的只读代码,这些只读代码通常被包含在.init.text.rodate段;所谓段和节事实上同样的,只是在链接的时候,它被称为“节”,而加载时却被成为“段”。.init段是链接器给每个“可执行目标文件”添加的,在其中包含了程序的初始化代码的一部分;链接器在其中写入了一个叫做_init的函数,这个细节需要注意。只读代码要求4kb对齐,所以虽然它实际的大小为往往小于4kb*n,但是其后紧跟的读/写段却需要从4kb*n的虚拟内存处开始。同样,读/写段也需要4kb对齐(想想why?)。读写段之后紧跟的是堆(heap)的内存区域,众所周知,这个区域是为了malloc函数群动态分配的内存准备的;此外,这个区域将根据需要向上(向高地址区域)增长。ELF文件中的.symtab.debug等内容并不会被加载进入内存,上图中用斜线表示了。操作系统还会为这个程序在内存的0x40000000处准备了动态加载的共享库代码区域;0xbfffffff处准备了另一个重要的运行时数据结构“栈”,这个区域主要用于程序中的过程调用,并且区域大小向下增长。用于应用程序的空间到这里结束了,紧跟在运行时栈的栈底后面的区域,也就是从0xc0000000开始就是内核代码了。

加载器(loader)通常是shell呼出的,但是任何应用程序都可以通过系统调用execve()调用加载器。

内存访问越界的时候通常你会被警告很奇怪的消息”segment fault”,并且程序终了。相信这个会帮助你,让你对“段”的记忆更加深刻些了,哈哈哈。这是一句很笼统的提示,但他说的就是你的指针在乱跳,可能对只读内存区域进行了写操作。

启动代码

加载器运行时,首先构造一个上面提到的那样的内存映像,然后根据“段头表”的指引,将程序代码拷贝到内存中。接着,他会跳转到程序的入口开始执行程序。提到c语言的程序入口,那可不就是大名鼎鼎的main函数么?这个说法没错,但是也不全对。真正的程序入口是一个叫做_start的函数,这个函数被包含在crtl.o文件中。这个目标文件是C语言运行时环境的一部分。它的大致的示意代码如下所示:

0x080480c0 <_start>        /*  .text段的入口点                 */
  call _libc_init_first     /* 启动.text节的代码通常是初始化c的库  */
  call _init               /* 启动_init代码,也就是在_init段中   */
  call atexit              /* 注册一些在程序结束时需要作的动作     */
  call main                /* 应用程序的入口点                  */
  call _exit               /* 结束应用程序,将控制权返还给操作系统  */

很明显,c语言的main函数是约定好的,如果没有这个函数程序将不能被执行。关于ctrl.o这个事情,让我想起面试国内某家公司的时候,曾被面试官问到这个问题;他问c程序在调用main之前需要做哪些动作,当时刚刚毕业,我的回答是现编的……,当然是错的很离谱,恩,往事不堪回首。如果你经常看到编译时或者运行时提示找不到crtl.o文件,恭喜你,你可以记住它了。嘿嘿。

动态链接和PIC代码

顾名思义,动态链接就是将链接过程从编译时挪到了运行时。这个内容写起来会有如“懒婆娘的裹脚”,各位看官可以参照IBM developerWorks的文章。但是动态链接对于立志做个好程序员的有痔青年是非常重要的内容,所以,如果有时间还是要认真研究的。简单的过程应该是像这样的

生成.so


gcc -shared -fPIC -o libvector.so x.c y.c z.c

这个过程就是将源代码文件编译成为目标文件,然后在用PIC指定它进行特殊的链接重定位定位信息,其中比较重要的就是添加PLT(procedure linkage table)和GOT(global offset table)。PLT被添加到.text节,而GOT被添加到.data节。PIC代码有个缺陷,就是因为对GOT的存储器引用造成的,具有大量寄存器堆的机器上没有太大问题,但是,寄存器不足的机器上却会造成严重缺陷。比如,MIPS结构的GOT问题就由来已久。对于外部过程的调用,PIC代码中采用一种叫做“延迟绑定”(lazy binding)技术,这个也是需要好好学习一下的。

链接共享库


gcc -o prog main.c libvector.so

这个链接过程不像静态链接过程,链接器并不真正的拷贝共享库中的.text.data节到可执行文件之中。相反,链接器会拷贝一些重定位和符号表信息,以便运行时可以解析对共享库代码和数据的引用。如你说知道的,这个过程就是在那个著名的$LD_LIBRARY_PATH之中去寻找共享库文件。

动态加载动态链接共享库


gcc -rdynamic -o prog main.c -ldl

通过dlopen()等函数可以在程序中动态地加载和链接共享库,在编译该程序时,只要连接libdl就可以了。而运行时,这个动态的加载和链接过程需要在被称为动态链接器的ld.so帮助下完成。这种方法特别灵活,因此被在各种各样的系统中广泛得应用,比如Java中的JNI(Java Native Interface),通过它可以让Java程序调用本地的C或者C++函数库。

这篇文章的目的在于说明一个程序是怎样形成,怎样加载,最终怎样在Linux中执行的,目的已经达到了,我就不再罗嗦了。