Linux内核工程导论——Linux的启动

存储设备

         现代的存储设备种类很多,但是性价比最高的是磁盘。其他的还有基于闪存的flash或ssd磁盘,还有光盘、磁带等介质。由于磁盘的最高性价比,所以决定了其最广泛的应用价值。磁盘是有多个盘面层叠而成,电机驱动盘面转动,每个盘面上有一个机械头,在每个盘的半径移动,用来定位每个盘的半径环。所有机械头都是同时移动的。

         如此,每个读取的值被柱面、磁道和扇区唯一确定。磁盘每次是读取一个块的,由柱面、磁道和扇区唯一确定一个块。

ROM系统

Legacy BIOS

         这就是传统的BIOS,传统的BIOS直到X86的64位架构还有在使用,原因是向下兼容。BIOS使用16位的汇编代码,寄存器参数调用,静态链接,在1MB以下内存固定编址。在各大BIOS厂商的努力下,BIOS扩展了很多功能,如PnP BIOS,ACPI、USB设备支持等,但根本性质没有变。

         BIOS下的设备驱动的执行方式是使用中断向量和固定大小的中断服务空间,典型的一个中断服务只有128KB的空间,也就是驱动大小不能超过128KB,否则无能为力。并且驱动也都是以16位的形式编写和存在。

EFI BIOS

         现在的主板上基本都是这种BIOS了,之所以不需要向下兼容的支持之前的BIOS,因为这种BIOS是在一个全新的架构(Itanium)下设计出来的,没有兼容问题。EFI的实现使用了C语言,所以就有堆栈、模块化、动态库等能力,软件工程带来的纠错性和可修改性,缩短了开发时间。并且不再是只有16位的寻址能力,拥有32或64的寻址能力,能够达到处理器的最大寻址,也就是可以使用很多的内存了。其他的BIOS厂商也曾经视图取代BIOS,但是由于力量分散,厂家太多,无法形成统一的标准,然而EFI由Intel主推,已经逐步掌握这个市场。

         与传统BIOS不同,EFI上的设备驱动不是由汇编写成的,但也不是C语言,而是EFI的虚拟指令集。如此就保证了驱动的CPU无关性。也即无论是志强还是安腾的CPU,同样的驱动代码都是可以检测到正常的设备,不需要重新编译。如此可复用的开发模式,甚至使得EFI可以在不启动操作系统前就访问网络,甚至浏览网页。其可扩展性和可复用性的结果是对EFI系统的任何一次开发,都可以长久的被复用。

         但是这不是EFI可以实现一个完整操作系统的理由。EFI的设计者故意的使EFI没有实现一个操作系统的可能性。例如,不支持中断,所有的硬件状态都是通过轮询完成,并且其驱动代码是解释执行的。EFI上可以实现程序,但是所有程序都具有所有硬件的完全访问权限。所以,EFI注定只是一个启动前的过度,启动完成后就会将主动权交给操作系统,自己大部分停止执行。

UEFI

         EFI是由Intel发起的,但UEFI是之后发展的产物,由国际组织:Unified EFI Form运行。这个组织除了intel还有很多其他的公司,所以有了前面的u字母。一般想要推动一个基础协议架构,没有多个巨头的参与,或者巨头参加了多个竞争格式,是一般推不动的。UEFIEFI的基础上提供了图形化的操作界面,使用鼠标操作。

磁盘分区管理

         ROM系统和bootloader之间需要一种约定的调用规则。因为固件放在ROM中,是开机会固定执行的,但bootloader的代码却是放在磁盘(或其他存储设备)中,需要从磁盘中加载。而在磁盘中组织数据的方式是文件系统,bootloader或者是内核的主体应该放在文件系统中。

         当固件看到一个磁盘,发现了其拥有该磁盘的驱动,也就是拥有了读写该磁盘的能力后,其接下来应该获取的信息是本磁盘的分区情况。磁盘的第一个字节开始就是可执行代码,然而这部分代码并不位于文件系统中。在传统的MBR磁盘格式中,前512字节存储了一些启动代码和整个磁盘的分区表。但是限于大小原因一个MBR只能支持4个分区。随着技术的发展,人们在单个分区的开头部分又实现了级联的分区表,启动软件也都陆续能够识别。但是存放启动文件的根目录必须位于主MBR上的分区。主MBR上的分区叫做Primary Partition(主分区),级联分区叫做逻辑分区(Logical Partition)。

         然而MBR的这种定义,包括其较小的表示容量的位数,限制了每个分区的大小和可以支持的分区总数。随着EFI的推出,同时新的替代方案GPT也出现了。传统MBR信息存储于LBA 0(LBA是逻辑块的意思),GPT头存储于LBA 1,接下来才是分区表本身。64位Windows操作系统使用16,384字节(或32扇区)作为GPT分区表,接下来的LBA 34是硬盘上第一个分区的开始。GPT这样空出LBA0就给MBR的兼容性留出了可能。所以,现代的操作系统是两种都支持的。

一个GPT分区表项的前16字节是分区类型GUID。例如,EFI系统分区(EFI用来存放模块代码的地方)的GUID类型是{C12A7328-F81F-11D2-BA4B-00A0C93EC93B}。接下来的16字节是该分区唯一的GUID(这个GUID指的是该分区本身,而之前的GUID指的是该分区的类型)。再接下来是分区起始和末尾的64位LBA编号,以及分区的名字和属性。

MBR分区表又叫MSDOS分区表,这是出于历史原因。还有两种不太常用的分区表是sgi和sun的分区表。一般只用在对应的平台上。

Bootloader

         Bootloader是BIOS启动后,首先执行的磁盘程序。这个程序负责加载真正的操作系统。由于它的这个职能,它可以为内核传递参数,可以管理多个操作系统的启动,可以查看基本的硬件信息,可以识别分区,可以操作磁盘,还可以提供更多。目前常见的Bootloader有grub和uboot,例如现在的grub2已经是模块化的了,除了提供基本的加载操作系统的功能外,每一个模块都是单独存在的,要使用该模块所实现的命令,grub需要首先加载这个模块。

         Uboot常用在sparc或mips系统,x86中grub用的最多。

         要注意的是grub-install之前要确认安装的grub是grub-bios还是grub-efi。两个是不同的软件包。

Linux内核的生成

内核的编译都是先进入各个目录,生成built-in.o,然后在上层根据一定规则组合生成vmlinux(例如arch/arm/kernel/vmlinux.lds),然后经过处理压缩得到最终文件。

l  例如使用arm-linux-gnu-ld-o vmlinux -T arch/arm/kernel/vmlinux.lds 生成的vmlinux是未压缩,带调试信息、符号表的最初的内核vmlinux文件。

l  接下来要将vmlinux文件去掉调试信息、注释和符号表,生成arch/arm/boot/Image。arm-linux-gnu-objcopy -O binary -S vmlinux arch/arm/boot/Image

l  然后再对这个文件进行压缩(自动时压缩算法由make menuconfig时指定),用gzip -9 压缩生成arch/arm/boot/compressed/piggy.gz大小约1.5MB。命令:gzip -f-9 < arch/arm/boot/compressed/../Image >arch/arm/boot/compressed/piggy.gz

l  编译arch/arm/boot/compressed/piggy.S生成arch/arm/boot/compressed/piggy.o,这里实际上是将piggy.gz通过piggy.S编译进piggy.o文件中。而piggy.S文件仅有6行,只是包含了文件piggy.gz;命令:arm-linux-gnu-gcc-o arch/arm/boot/compressed/piggy.o arch/arm/boot/compressed/piggy.S

l  依据arch/arm/boot/compressed/vmlinux.lds将arch/arm/boot/compressed/目录下的文件head.o 、piggy.o 、misc.o链接生成arch/arm/boot/compressed/vmlinux,这个vmlinux是经过压缩且含有自解压代码的内核;

命令:arm-linux-gnu-ld zreladdr=0x30008000 params_phys=0x30000100

-T arch/arm/boot/compressed/vmlinux.ldsarch/arm/boot/compressed/head.o arch/arm/boot/compressed/piggy.oarch/arm/boot/compressed/misc.o -o arch/arm/boot/compressed/vmlinux

l  将arch/arm/boot/compressed/vmlinux去除调试信息、注释、符号表等内容,生成arch/arm/boot/zImage这已经是一个可以使用的linux内核映像文件了;命令:arm-linux-gnu-objcopy -O binary -S arch/arm/boot/compressed/vmlinux arch/arm/boot/zImage

l  将arch/arm/boot/zImage添加64Bytes的相关信息打包为arch/arm/boot/uImage;命令: ./mkimage -A arm -O linux -T kernel -C none -a 0x30008000 -e0x30008000 -n 'Linux-2.6.35.7' -d arch/arm/boot/zImage arch/arm/boot/uImage

Linux内核工程导论——Linux的启动_第1张图片

 

linux的启动

         一个linux系统永远由bootloader、kernel、文件系统三元素组成。有的嵌入式linux经过适当的调整可以不需要bootloader,但最新的内核如此的代价较大,所以以后的系统都会有bootloader。内核不必多说,文件系统至少要有一个,一般的要有两个。一个是initrd,另外一个实际运行的根文件系统,当然你还可以挂载无数的文件系统。

         值得注意的是,由于EFI的出现,其功能可以越来越强大,导致其可能会慢慢实现bootloader的功能,直到完全实现。因此,bootloader不会消失,但是可能转移到与EFI合并。

linux的最小系统制作

         思考linux的启动最好的方法就是自己做一个最小linux系统。其实方法很简单,只是中间会遇到很多问题,只会方法是无法解决他们的,需要更多的研究。但是说出方法就可以让整个过程一目了然。

l  dd if=/dev/zero of=osImage ibs= count=

l  mount osImage

l  fdisk /dev/loop1

l  mkfs /dev/loop1,然后创建文件系统目录,放入你要放入的程序

l  grub-install /dev/loop1

l  内核目录:make,拷贝内核bzImage(arch/x86/boot/下)到/boot,生成initrd

l  启动,指定grub内核和initrd,传递适当的参数,boot

最小系统的启动

就这么简单的逻辑。有些linux经验的人都可以很容易的看懂。这里面重要的不是制作过程,而是启动过程。我们看到安装了grub之后该磁盘就能确保启动了,因为grub是不需要操作系统的,它就是单独的引导程序,只要磁盘可用,它就能工作。grub启动后会给你grub的命令行界面。grub1和grub2的命令已经有了不小的变化,我们说grub 2的。用linux命令指定内核所在的目录(如果你发现没有root,你还需要先set root命令,但grub一般能自动发现),然后使用initrd命令指定initrd文件系统,然后输入boot。系统就会启动了。

这里面有几个问题需要深究。就是为何要先挂载一个initrd,然后在挂载根文件系统?可以省略这一步吗?

理论上是可以的,实际中也是可以的。但是越现代的内核和发行版,想要去掉它越难。也就是说linux工业界越来越倾向于认可其作为系统启动过程中不可或缺的一部分。这是为何?

initrd文件系统

         我这里直接使用initrd其实是不准确的。因为启动过程中的过度根文件系统可以是initrd也可以是initramfs这两种格式的文件体系。这两个根文件系统的内容是一样的,只是组织方式不一样。

         首先建立块(可以使用dd命令),格式化(ext2等),将所有的需要的文件和根文件的目录都建立好,复制了必须的程序进去。这一点两者是一样的。接下来生成initramfs的做法是cpio命令打包,然后zip压缩。而生成initrd的方法则是不用cpio直接zip压缩。但是值得注意的是你不要被各个发行版的命名给弄混了。现代的都是initramfs,之前的是initrd,但是由于历史原因现代有的发现版还是会命名为initrd。两者的不同是修改的方式不同。

         我们知道修改一个无格式的img文件系统文件使用的方法是mount,该文件会以loop设备的形式存在。而修改一个cpio文件的方式是cpio命令的归档也解归档成目录,不需要mount。直接修改目录后再cpio归档后就又是可以用的文件系统了。后者之所以能取代前者,很大的原因是mount是个root程序,是系统级的操作。而cpio只是一个文件的操作。既方便又安全。

         linux下的lsinitrd命令可以不用mount或解压就可以查看initrd里的内容。

initrd里有什么

         initrd是一个文件系统。既然是过度文件系统,其里面就有程序和数据。而所有的程序和数据都是为了其存在的目的服务的。其存在的目的是什么呢?我们可以追踪一下内核启动了这里面的什么程序得出结论。

         内核启动的initrd里的第一个程序是/init,这是内核写死的。想要改变也可以,修改内核代码。我们说一种典型情况。这个init程序是一个脚本文件,任务流程如下:

l  建立一个sysroot根目录。该目录用于挂载之后要启动的真实文件系统

l  设置一下命令的环境变量,如此后面的命令都可以在环境变量指定的目录中搜索到了

l  mount proc文件系统打破/proc,这是linux内核动态状态的一个表现和设置入口

l  mount sysfs 到/sys,这是linux内核资源的有组织的表现和设置入口

l  mount devtmpfs到/dev,这是临时表征当前设备节点的文件系统,可以加速系统的启动(/dev目录对当前用户程序的运行至关重要)

l  准备一下/dev目录中的一些节点(例如stdin、stdout、stderr等fd,这里的fd是文件描述符的意思。对内核中已有的静态节点进行创建)

l  提供为内核输入额外参数的机会

l  解析内核启动参数,并执行(例如在udev开始之前执行一些准备),由此可以看出内核参数并不一定都是给内核来解析的

l  启动systemd-udevd,并配置其要响应的行为

l  然后循环处理$hookdir/initqueue下的任务

l  mount根文件系统

l  找到根文件系统的init程序

l  停止systemd-udevd服务程序

l  做一些清理操作

l  切换到root的init程序,启动完成

 

可以看出整个initrd文件系统存在的目的就是挂载根文件系统。所以当根文件系统内核可以直接挂载的时候就不需要了。但是现在越来越复杂的网络和设备,已经让这个initrd的存在是必要的了。但是可以看出,嵌入式系统是万万不需要这个东西的。典型的,scsi内核就很难识别,但是现在的存储设备已经很多是scsi了。但是也可以将scsi编译进内核来让内核可以直接识别。

EFI启动桩

         由于EFI的强大功能,现在的Linux越来越倾向于不使用Bootloader,EFI启动桩就是一个大胆的尝试,其在压缩后的linux内核的前面加上了一段可执行程序,完成Bootloader的工作。但是目前该功能还比较弱,天然不支持多操作系统。对linux的磁盘的识别和设备驱动的加载都有很大问题。因此,这代表一个方向,但还不至于快速淘汰bootloader。

内核的编译与准备

         Linux内核的制作一般在某个发型版下完成。也就是说你要制作一个linux,首先要有一个linux(或unix)。通常如果在本机运行,不必调用make menuconfig,而是make localmodconfig,如此内核代码的脚本会自动检测当前系统中使用的模块和当前的内核配置用来配置新内核,最后自动生成.config文件。如果内核版本差异过大,接下来的make会有很多询问需要一一回答。然后make modules_install , make install完成本机的安装。

         内核的核心文件是vmlinuxz,这是个压缩后的文件,位于arch/x86/boot下,压缩前的文件在

         除了内核文件外还需要模块文件,模块文件并不是单独存在的。因为各个模块之间有依赖关系或者是要记录哪些启动时挂载哪些不挂,这些相关的文件连同模块本身通常放在/lib/modules/4.1.2/ 下。但是这并不是绝对的,就连内核代码的存放位置也不是绝对的。只要grub能够指定即可。

启动时模块的加载

         内核编译完成安装模块的时候会同时生成modules.dep和很多map文件。这些文件我们也可以平时手动生成(内核编译也无非是执行了一些列的命令)。这些map文件(例如modules.alias)定义的是什么样的硬件应该加载本模块,而modules.dep文件定义的是各个模块之间的依赖关系,即要加载本模块哪些模块要预先加载。

         生成modules.dep文件的命令是moddep,而开机启动时一次性加载所有需要的模块的代码是modprobe。这个命令可以根据modules.dep文件的内容加载尽可能多的模块。有的发型版认为这是不对的,于是他们在/etc下建立了目录结构,启动时只使用insmod逐个加载目录结构中定义的模块。

         总的来说,什么模块应该加载,什么不需要加载到目前为止还没有很好的解决。

启动管理程序

         我们知道系统第一个启动的进程是init进程,但init进程不但是位于用户空间也是位于内核代码的进程。确切的说,系统启动过程中内核中的init进程被用户空间的init进程替换执行。之后该init执行完操作后,一般会调用初始化系统来初始化整个系统的应用程序或者服务器。这个初始化系统最原始的是linuxrc脚本,当然,包括这个脚本之后的所有脚本都是linux操作系统的用法,并不是内核的规定。也就是说你可以自由的指定任何的脚本的执行顺序和定义不同的脚本的意义,而不用修改内核代码。

         常用的linux启动脚本一般有/linuxrc、/etc/rcS、/etc/rc.local、/etc/profile。这些脚本都是可有可无的,如果有需要一个接一个的手动调用。必须要注意的是这些脚本连同名字和路径都是可以随意定制的,不同的发行版可能会选择不同的位置和顺序,但是这几个名字的通用性是长期的linux操作系统演化的结果。

         只是脚本是无法满足健壮的系统对于启动和服务的管理需求的。因为你不能要求所有对服务的管理都要通过写脚本完成。为此,linux的一般做法是生成一个专门的目录,想要启动的服务就生成一个规定格式的文件放到目录中,linux发行版一般都有写脚本去遍历执行整个目录的服务。这些被定义的服务,甚至通过复杂的语法限制还可以实现精细的启动顺序控制(systemd)。

Sys V init:runlevel

         系统中有很多服务,人们在linux出现的时候就在想如何管理这些服务了。问题是有些服务需要启动,有些不需要。登陆图形界面需要某一些服务,但不登陆不需要,单用户登陆可能需要的更少,如此就需要差别化的启动需求的服务。

针对网络服务

由于大部分linux上跑的后台服务进程都是在监听某一个端口,windows的做法一直是要使用什么就打开什么进程,在内存里一直监听睡眠等待。Linux的人们认为既然都是监听网络服务,找一个超级进程监听全部的端口,哪个端口有数据来再启动哪个程序岂不是更好?如此节省内存的思想诞生了xinetd守护进程。

xinetd管理监听所有的端口,导致的结果是初次响应变慢。而且启动了对应的服务之后没有请求是不是要关闭该进程?否则只要启动了就永远启动。一旦关闭又打开岂不是更耗费系统资源?由于大部分系统管理员清楚的知道自己的系统要提供什么服务,所以xinetd越来越少用了。

开机启动所有需要启动的进程

         Linux从unix演化而来,所以最初的启动管理程序是在unix的init基础上创新的。定义的启动服务程序是runlevel机制,定义6种(或更多)runlevel,每一种会启动不同的程序。例如1是只启动单用户无图形界面,3是多用户无图形界面,5是多用户图形界面,0是关机,6是重新启动。通常的默认的启动level值放在/etc/inittab文件中。init进程通过读取这个文件获得runlevel值,然后去对应的/etc/rc3.d等不同的目录下去执行在该目录定义的属于这个运行级别的程序。

         启动之后可以通过/sbin/telinit程序改变运行级别。

upstart

         为了克服init的同步顺序启动带来的效率低下,upstart实现了异步的事件驱动的启动模式,在某些情况下提高了系统的启动速度。由于其对init的提升和异步的机制,使得开机启动有更多的想象空间,有很多发行版采用了。但是由于systemd的迅速崛起,采用upstart的系统迅速切换到systemd,upstart作为一个过渡版本的启动管理程序基本退出舞台。

systemd

        Linux内核工程导论——Linux的启动_第2张图片

         Systemd起源于Tizen(由intel和三星研发的linux),经过完善和丰富形成了现在的程序集。

Linux内核工程导论——Linux的启动_第3张图片

         期初systemd设计只是用来取代init和startup的,但是做着做着,其功能越来越丰富,丰富到没有一个发行版不愿意接受如此大的,质量优秀的开源代码。传统的init开机启动进程是顺序的,并且由shell脚本执行很多开机指令来完成系统的初始化。systemd将启动尽可能的并行化,并且将很多的本由shell执行的逻辑移到systemd程序中来,提高执行速度。

         目前的systemd除了启动的管理,还对用户端封装了几乎所有的系统服务。例如原来的cron被systemd的调度执行部分取代,udev被其device hotplugging取代。为了向上提供ipc,systemd还封装提供了unix domain socket和D-Bus给其它服务程序。其用统一的原语实现了几乎整个linux系统服务,并且提供对其他外置服务的支持(其本身就是作为服务管理程序存在的)。

         systemd进程是系统的第一个启动进程,也是系统的最后一个结束进程。是所有用户端进程的根进程。其比传统的init在处理子进程上有很多改进,例如可以支持进程关闭后自动重启(用init的respawn语义也可以),不产生僵尸进程等优点。

         为了启动的并行化,systemd定义了一整套脚本语义。所有要启动的进程服务都要使用其规定的语义完成unit文件。在init系统中,每个进程都是由各自的独立脚本完成启动的,在systemd中unit文件中service, socket, device, mount, automount, swap, target, path, timer(替代cron), snapshot, slice and scope等语义可以定义丰富的启动信息。

         systemd是守护进程,systemctl用来定义systemd的服务和行为。systemd-analyze用来分析启动的效率。systemd目前完全使用内核的cgroup接口进行开发,所以cgroup也变成了现代linux的标配。

         比较重要的systemd服务有:

l  Consoled:取代传统的虚拟终端

l  journald:取代传统的syslog、syslog-ng、rsyslog

l  logind:取代传统的用户登录服务(ConsoleKit、gnome-session)

l  networkd:取代传统的网络配置(如Network Manager)

l  timedated:所有与时间有关的操作都将在此集成

l  udevd:udev的代码被systemd完全吸收合并

 

基于Systemd的成功,很多人希望在之上创新试图更好的使用或者取代systemd,但都无法撼动其地位了。比较好的工作有endev、uselessd、systemdbsd、console kit2。

 

Linux内核启动顺序

         bootloader将内核加载到内核后,需要将控制权交给内核。第一步是要解压内核,由于内核是自解压的,解压的入口相关文件是arch/arm/boot/compressed/head.S。完成解压之后就是搬运,因为解压并不把内核放在其最终执行的位置,移动之前可能还要做一些保存可能被其覆盖的代码的搬运工作。

         第二阶段从\arch\arm\kernel\head.S开始,是内核的实际功能地点。主要完成的工作有:cpu ID检查,machine ID(也就是开发板ID)检查,创建初始化页表,设置C代码运行环境,跳转到内核第一个真正的C函数startkernel开始执行。

         第三阶段从start_kernel开始,完全是C语言了。其首先用大内核锁锁住内核保证独占,然后调用平台相关的初始化操作(arch/arm/kernel/setup.c里的setup_arch()),在这里内核启动后可使用的所有内存被初始化,所以如果要预留不被内核使用的内存空间,应该在这里预留。还被初始化的有页表结构、MMU、中断、内存区域、计时器、slab、vfs等。然后跳到init内核进程(不是用户空间的init进程)

         init_task是进程0使用的进程描述符,也是Linux系统中第一个进程描述符,该进程的描述符在arch/arm/kernel/init_task.c。init_task是Linux内核中的第一个线程(0号进程),它贯穿于整个Linux系统的初始化过程中,该进程也是Linux系统中唯一一个没有用kernel_thread()函数创建的进程!在init_task进程执行后期,它会调用kernel_thread()函数创建第一个核心进程kernel_init,同时init_task进程继续对Linux系统初始化。在完成初始化后,init_task会退化为cpu_idle进程,当Core 0的就绪队列中没有其它进程时,该进程将会获得CPU运行。新创建的1号进程kernel_init将会逐个启动次CPU,最后寻找文件系统中的init程序,将其替换为自身,变成用户态的第一个进程。

你可能感兴趣的:(linux,linux内核原理)