嵌入式linux基础教程第二版 第五章 内核初始化

2019-07-13 04:15发布

                                                                                            第五章      内核初始化         5.1合成内核镜像:Piggy 及其他         make ARCH=arm CROSS_COMPILE=xcale_be- zImage               LD                 vmlinux               SYSMAP       System.map               SYSMAP       .tmp_System.map               OBJCOPY     arch/arm/boot/Image               Kernel:           arch/arm/boot/Image is ready               AS                  arch/arm/boot/compressed/head.o               GZIP               arch/arm/boot/compressed/piggy.gz               AS                   arch/arm/boot/compressed/piggy.o               CC                  arch/arm/boot/compressed/misc.o               AS                   arch/arm/boot/compressed/head-xscale.o               AS                   arch/arm/boot/compressed/big-endian.o               LD                   arch/arm/boot/compressed/vmlinux               OBJCOPY      arch/arm/boot/zImage               Kernel:            arch/arm/boot/zImage is ready         构建系统产生了vmlinux镜像。之后,构建系统处理了很多其他对象模块。其中,包括head.o、piggy.o以及与具体架构相关的head-xscale.o等。AS 表示构建系统掉用了汇编器,GZIP表明在进行压缩。一般来说,这些对象模块都是和具体架构相关的,并且包含了一些底层函数。用于在特定架构上引导内核。
上图为合成内核镜像的结构         Image对象:当内核ELF文件构建成功之后,内核构建系统继续处理其他的目标。Image对象是由vmlinux对象生成的。去掉ELF文件中的冗余段,并去掉所有可能存在的调试符号,就是Image了。下面这条命令用于该用途         xscale_be-objcopy -O binary -R .note -R .note.gnu.build-id -R .comment -S vmlinux arch/arm/boot/Image         -O选项指示objcopy生成一个二进制文件,-R删除ELF文件中的.note、.note.gnu.build.id和.comment这三个段。-S选项用于去除调试符号。objcopy以ELF的镜像vmlinux为输入,生成名为Image的目标二进制文件。Image将vmlinux从ELF转换成二进制形式,并去除了调试信息和前面的.note*和,comment段        与具体架构相关的对象:由几个汇编源文件编译的对象(head.o和head-xscale.o),他们完成与底层具体架构及处理器相关的一些任务。创建piggy.o对象,用gzip命令对Image文件进行压缩生成piggy.o。
           cat  Image   |    gzip  -f  -9 > piggy.gz        汇编器汇编名为piggy.S的汇编语言文件,而这个文件包含了一个对压缩文件piggy.gz的引用。从本质上说,二进制内核镜像以负载的形式依附在了一个底层的启动加载程序之上,采用汇编语言编写。启动加载程序先初始化处理器和必须的内存区域,然后解压二进制内核镜像(piggy.gz)并将解压后的内核镜像(Image)加载到系统内存的合适位置,最后将控制权转交给它。        piggy.s        .section    .piggydata,  #alloc        .global      input_data    input_data        .incbin   "arch/arm/boot/compressed/piggy.gz"        .global      end_data    input_data_end:        汇编器汇编这个文件并生成一个ELF格式的镜像piggy.o,该镜像包含一个名为.piggydata的段,这个文件的作用是将压缩后的二进制内核镜像(piggy.gz)放到这个段中,成为其内容。该文件通过汇编器的预处理指令.incbin将piggy.gz包含进来,incbin类似于include只是它包含的是二进制数据。总之,该汇编文件的作用是将压缩的二进制内核镜像(piggy.gz)放在另一个镜像(piggy.o)中。启动加载程序利用input_data和input_data_end来确定包含的内核镜像的边界        启动加载程序:启动加载程序将linux内核镜像加载到内存中。有些启动加载程序会对内核镜像进行校验和检查,而大多数启动加载程序会解压并重新部署内核镜像。当硬件单板加电时,引导加载程序获得其控制权,根本不依赖于内核。引导加载程序将内核镜像从工作站加载到开发板的物理内存上而启动加载程序将内核镜像从开发板的物理存储加载到内存中。启动加载程序负责提供合适的上下文让内核运行于其中,并且执行必要的步骤以解压和重新部署内核二进制镜像。        启动加载程序和内核镜像拼接在一起用于加载                针对ARM XScale的合成内核镜像        在我们研究的这个例子中,启动加载程序包含了上图显示的二进制镜像,这个启动加载程序完成以下功能        (1)底层的用汇编语言编实现的处理器初始化,这包括支持处理器内部指令和数据缓存、禁止中断并建立C语言运行环境。这部分功能由head.o和head-xscale.o完成        (2)解压并重新部署镜像,这部分功能由misc.o完成        (3)其他与处理器相关的初始化,比如big-endian.o,将特定处理器的字节设置为大端字节序         引导消息:         在PC上引导某种linux发行版,在PC自身的BIOS消息之后,你会看到很多由linux输出控制台消息,表明它正在初始化各个内核子系统。在嵌入式系统中启动linux时的情况与PC工作站类似。          Using  base  address  0x01000000  and  length  0x001ce114          Uncompressing  Linux.....done,  booting  the  kernel          Linux  version  2.6.32-07500-g8bea867 (chris@brutus2)  (gcc  version  4.2.0  20070126(prerelease)(MontaVista  4.2.0-3.0.0.0702771  2007-03-10))  #12  Wed  Dec  16  23:07:01  EST  2009           .
          .           .           .        第一行是由板卡上的引导加载程序Redboot产生的,第二行是启动加载程序产生的。这行消息由.../arch/arm/boot/compressed/misc.c中的函数decompress_kernel()产生的。第三行是内核版本字符串这是内核本身输出的第一行消息,内核进入函数start_kernel()(这个函数在源文件.../init/main.c中)之后,首先执行的几行代码中包括下面这行          printk(KERN_NOTICE:"%s",linux_banner);        这些内核版本字符串包括了内核版本、内核编译时侯所使用的用户名/机器名、工具连信息、构建号、编译内核镜像时的日期和时间        产品开发人员一般使用构建号来自动跟踪构建轨迹。构建号保存在.version的隐藏文件中,这个文件位于内核源码的顶层目录,构建号会由构建脚本.../scripts/mkversion自动构建。它是一个数字的字符串标签,当内核代码有重大变化并重新编译时自动递增。
        5.2初始化时的控制流        我们来研究一个完整的启动周期(从引导加载程序到内核)中的控制流。引导加载程序是一种底层软件,存储在系统的非易失性内存(闪存或ROM)中,系统加电时他立即获得控制权。它通常体积很小,包含一些简单函数,主要用于底层初始化、操作系统镜像的加载和系统诊断。它可能会包含读写内存的函数,以检查和修改内存的内容。它包含底层的板卡自检程序,包括检测内存和I/O设备。引导加载程序还包含了一些处理逻辑,用于将控制权转交给另一个程序,一般是操作系统,比如linux。         基于ARM  XScale平台;名为Redboot的引导加载程序,当系统第一次加电时这个引导加载程序开始执行,然后会加载操作系统。当引导加载程序部署并加载了操作系统镜像(这个镜像可能存储在本地的闪存,硬盘驱动器中,或通过局域网或其他设备)之后,就将控制权转交给那个镜像。         对于特定的ARM  XScale平台,引导加载程序将控制权转交给启动加载程序(第二阶段引导装入程序)的head.o模块,       
     
       处于内核镜像之前的启动加载程序有个很重要的任务:创建合适的环境,解压并重新部署内核镜像,并将控制权转交给它。启动加载程序将控制权转交给内核主体中的一个模块,通常这个模块的名字都是head.o。        当启动加载程序完成它的工作后,将控制权转交给内核主体的head.o,之后再转到文件main.c中的函数start_kernel()        内核入口:head.o        内核开发人员的目的是让head.o这个与架构相关的模块通用化,不依赖于任何机器类型。这个模块由head.S生成,它的具体路径是.../arch//kernel/head.s,为具体的架构。
       head.o模块完成与架构和CPU相关的初始化,为内核主体的执行做好准备。与CPU相关的初始化工作尽可能的做到了在同系列处理器中通用。与机器相关的初始化工作是在别处完成的。head.o还要执行下列底层任务。       (1)检查处理器和架构的有效性       (2)创建初始的页表表项       (3)启用处理器的内存管理单元(MMU)       (4)进行错误检测并报告       (5)调转到内核主体的执行位置,也就是文件main.c中的函数start_kernel()        嵌入式新手企图单步调试这些代码,但最终发现调试器并不能派上用场。当启动加载程序第一次将控制权交给内核的head.o时,处理器运行于我们过去常说的实地址模式。处理器的程序计数器或其他类似寄存器中所包含的值成为逻辑地址,而处理器内存地址引脚上的电信号地址成为物理地址。在实地址模式下,这两者相等。为了启用内存地址转换,需要先初始化相关的寄存器和内核数据结构,当这些初始化完成后,就会开启处理器的MMU。在MMU开启的一瞬间,处理器看到的地址空间被替换成了一个虚拟地址空间,而这个空间的结构和形式由内核开发者决定。当MMU功能开启的一瞬间物理地址被替换成了逻辑地址,这就是为什么不能像调试普通代码那样调试这段代码。        在内核引导过程的早期阶段,地址的映射范围有限。很多开发者试图修改head.o以适应特定平台,但因为这个限制而犯错。假设你有一个硬件设备,你需要在系统引导的早期阶段加载一个固件。一种方法是将必须的固件静态编译到内核镜像中,然后使用一个指针引用它,并将他下载到你的设备中。然而由于在内核引导的早期阶段,其地址映射存在限制,很有可能固件镜像所处的位置超出了这个范围,当代吗执行时,它产生一个页面错误,因为这时你想访问一个内存区域,但处理器内部还没有建立起对这块区域的有效映射。在早期阶段页面错误处理程序还没有安装到位,所以最终结果会莫名其妙的系统崩溃。在系统引导早期阶段有一点非常确定,不会有任何错误消息能够帮你找到问题所在。        明智的做法是尽可能推迟所有硬件的初始化工作,直到内核完成引导之后。用这种方式,你可以使用众所周知的设备驱动程序模型来访问定制硬件,而不是去修改复杂很多的汇编语言代码。这一层及的代码使用了很多技巧,而他们都没有说明文档可供参考。这方面最常见的一个例子就是解决硬件上的一些错误,而这些错误可能有说明文档也可能没有。如果你必须修改语言早期启动代码,你需要开发时间、费用和复杂度等方面付出更高的代价。硬件和软件开发工程师需要在硬件开发早期阶段讨论这些问题。此时往往硬件设计的一个小小改变可以大大减少软件开发时间。        嵌入式开发人员必须对虚拟内存环境熟悉。
       

       内核启动:main.c        内核自身的head.o模块完成的最后一个任务就是将控制权交给一个由C语言编写的,负责内核启动的源文件。我们后续主要介绍这个文件,        控制权从内核的第一个对象模块(head.o)转交至C语言函数start_kernel( ),这个函数位于文件.../init/main.c中,内核从这里开始了它新的生命旅程。任何想要深入学习linux内核的人都要仔细研究main.c文件,研究它由哪些部分组成的以及这些成员是如何初始化和是实例化的。汇编语言之后的大部分linux内核启动工作是由main.c来完成的,从初始化第一个内核线程开始,直到挂载根文件系统并执行最初的用户空间linux应用程序。        函数start_kernel()目前是main.c中最大的一个函数。大多数linux内核初始化工作都是在这个函数中完成的。此处的目的是要突出那些在嵌入式系统开发环境中由用的部分,再说一遍如果你想更系统的理解linux内核,花时间研究以下main.c是个很好的方法。       架构设置        .../init/main.c中的函数start_kernel()在其执行的开始阶段会调用setup_arch(),而这个函数是在文件.../arch/arm/kernel/setup.c中定义的。该函数接受一个参数——一个指向内核命令行的指针
               setup_arch(&command_line);
       该语句调用一个与具体架构相关的设置函数,linux支持的每种架构都提供了这个函数,它负责完成那些对某种具体架构通用的初始化工作。函数setup_arch()会调用其他具体识别CPU的函数,并提供了一种机制,用于调用高层特定CPU的初始化函数。例如setup_arch()直接调用setup_processor(),它位于.../arch/arm/kernel/setup.c中。这个函数会验证CPU的ID和版本,并调用特定CPU的初始化函数,同时会在系统引导时向控制台打印几行相关信息。        4 CPU:XScale-IXP42x  Family  [690541c1]  version  1  (ARMv5TE),  cr = 000039ff        5 CPU:  VIVT  data  cache,  VIVT instruction  cache        6 MACHINE: ADI  Engineering  Coyote        在这里你可以看到CPU类型、ID字符串和版本,这些信息都是从处理器核心直接读取的。接着是处理器缓存和机器类型的详细信息。        架构设置的最后的工作中有一项是完成那些依赖于机器类型的初始化。不同架构采用的机制有所不同。对于ARM架构,可以在.../arch/arm/mach-*系列目录中找到与具体机器相关的初始化代码文件,具体的文件取决于具体的机器类型。        内核命令行的处理        在设置了架构之后,main.c开始执行一些通用的早期的初始化工作,并显示内核命令行。        linux一般由一个引导加载程序(或启动加载程序)启动的,它会向内核传递一系列参数,这些参数称为内核命令行。虽然你不会真正的使用shell命令来启动内核,但很多引导加载程序都可以使用这种大家熟悉的方式向内核传递参数。在有些平台上,引导加载程序不能识别Linux,这时可以在编译的时候定义内核命令行,并硬编码到内核的二进制镜像中。在其他平台上,用户可以修改命令行的内容,而不需要重新编译内核。这时,bootsrap loader从一个配置文件生成内核命令行,并在系统引导时将它传递给内核。这些内核命令行参数相当于一种引导机制用于设置一些必须的初始设置,以正确引导特定的机器。        linux内核中定义了大量的命令行参数。内核源码的.../Documentation子目录中有一个名为kernel-parameters.txt的文件,其中按字典顺序列出了所有的内核命令行参数。内核的变化远远快于它的文档,使用这个文档作为指南而不是权威参考。虽然这个文件中记录了上百个不同的内核命令行参数,但这个列表不一定完整,因此,你必须参考源代码。        内核命令行的参数使用语法很简单,                Kernel  command  line:  console=ttyS0, 115200, root = /dev/nfs ip = dhcp        可以是单个单词,一个建/值对,或是key = value1, value2...这种一个键和多个值的形式。命令行是全局可访问的,并且可以由很多模块处理。我么前面说的,main.c中的start_kernel()函数在掉用函数setup_arch()时会传入内核命令行作为参数,这也是唯一的参数。通过这个调用,与架构相关的参数和配置就被传递给了那些与架构和机器代码相关的代码。        设备驱动程序的编写者和内核开发人员都可以添加额外的内核命令行参数,以满足自身的具体需求。遗憾的是,在使用和处理命令行的过程中会涉及一些复杂的内容。首先,原来的机制已弃用,取而代之的是一个更加健壮的实现机制。另外,为了完全理解这个机制,你需要理解复杂的连接脚本文件。        __setup宏        考虑以下如何指定控制台设备,这可以作为一个使用内核命令行参数的例子。        我们希望在系统引导的早期阶段初始化控制台,以便我们可以在引导过程中将消息输出到这个目的的设备上。初始化工作由一个名为printk.o的内核对象完成的。这个模块的C语言源文件位于.../kernel/printk.c中。控制台设备的初始化函数名为console_setup().并且这个函数只有一个参数就是内核命令行。        现在面临的问题是如何以一种模块化和通用的方式传递配置参数,我们在内核命令行中指定了控制台相关的参数,但要将他们传递给需要此数据的相关设置函数和驱动程序。问题更复杂一些,因为一般情况下命令行参数在较早的时候就会用到,在模块使用他们之前或者就在此时。内核命令行主要时在main.c中处理的,但其中的启动代码不可能知道对应于每个内核命令行参数的目标处理函数,因为这样的参数有上百个。我们需要一种灵活和通用的机制,用于将内核命令行参数传递给它的使用者。        文件.../include/linux/init.h中定义了一个特殊的宏,用于将内核命令行的字符串的一部分同某个函数关联起来,而这个函数会处理字符串的那个部分。下面举一个例子说明__setup宏是如何使用的。        下面这个字符串是传给内核的一个完整的命令行参数,                   console=ttyS0, 115200         下列代码清单是一个.../kernel/printk.c中的一个代码片段,我们省略了函数体的内容,因为这个和我们的讨论无关。代码清单中最后以行,调用__setup宏,这个宏需两个参数,在这里我们传入了一个字符串字面量和一个函数指针。传递给__setup宏的字符串字面量是console=,这正好是内核命令行中有关控制台参数的前八个字节,而这并非巧合。         设置控制台列表,有init/main.c调用         static  int  __init  console_setup(char *str)         {               char buf[sizeof(console_cmdline[0].name) + 4];               char *s, *options,*brl_options = NULL;               ...               ....               return 1;
        }
        __setup("console=", console_setup);       
        可以将__setup宏看作一个注册函数,即为内核命令行中的控制台相关参数注册的处理函数。实际上指,当在内核命令中碰到console=字符串时,就调用__setup宏的第二个参数所指定的函数,这里就是调用函数console_setup()。但这个信息是如何传递给早期的设置代码的?这些代码在模块之外也不了解控制台相关的函数。这个机制非常巧妙,并且依赖于编译连接时生成的列表         具体的细节隐藏在一组宏之中,这些宏设计用于省去繁琐的语法规则,方便将段属性(或其他属性)添加到一部分目标代码中。这里的目标是建立一个静态列表,其中的每一项包含一个字符串字面量及关联的函数指针。这个列表由编译器生成,存放在一个单独命名的ELF段中,而该段是最终的ELF镜像vmlinux的一部分。理解这个技术很重要,内核中很多地方都使用这个技术来完成一些特殊的处理。         init.h中定义了__setup系列宏(.../include/linux/init.h)         ...         #define  __setup_param(str, unique_id, fn, early)                     
                static  const char __setup_str_##unique_id[ ] __initconst          
                        __aligned(1) = str;                      static  struct  obs_kernel_param  __setup_##unique_id                               __used  __section(.init.setup)                                    __attribute__((aligned((sizeof(long)))))                                = {__setup_str_##unique_id, fn, early}        
       #define  __setup(str, fn)               __setup_param(str, fn, fn, 0)        ...        宏调用时这样使用的                __setup("console=", console_setup);        编译器预处理后是下面的样子        static const  cahr  __setup_str_console_setup[ ]  __initconst        __aligned(1) = "console = ";        static  struct  obs_kernel_param  __setup_console_setup __used         __section(.init.setup)  __attribute__ ((aligned((sizeof(long)))))             = { __setup_str_console_setup, console_setup, early};         
       __used宏指示编译器生成函数或变量。__attribute__((aligned))宏指示编译器将结构体对齐到一个特定的内存边界上--在这里是指按sizeof(long)的长度进行对齐。将这两个宏去掉以简化后:        static  struct  obs_kernel_param  __setup_console_setup         __section(.init.setup) = {__setup_str_console_setup, console_setup, early};        首先编译器生成一个字符数组,__setup_str_console_setup[ ], 并将其内容初始化为console=。接着编译器生成一个结构体其中包含三个成员:一个指向内核命令行字符串(就是刚刚声明的字符数组)的指针,一个指向设置函数本身的指针,和一个简单的标志。这里最关键的一点就是为结构体指定了一个段属性(__section)。段属性指示编译器将结构体放在ELF对象模块的一个特殊的段中,名字为.init.setup。在连接阶段所有使用__setup宏定义的结构体都会汇集在一起,并放到.init.setup段中,实际的结果是生成了一个结构体数组。下面代码是../init/main.c中的一个代码片段,其中显示了如何访问和使用这块数据。        extern  struct  obs_kernel_param  __setup_start[], __setup_end[ ];        
       static  int  __init  obsolete_checksetup(char *line)        {               struct  obs_kernel_param *p;               int hard_early_param = 0;              
              p = __setup_start;               do{                     int n = strlen(p->str)                     if(!strcmp(line,p->str,n)){                          if(p->early) {                                if(line[n] == '