基于MIPS(LOOGSON)架构LINUX内核启动流程源代码分析(一)--kernel_entry

来源:互联网 发布:windows redcine 报错 编辑:程序博客网 时间:2024/05/20 20:56

系统加电启动后,MIPS处理器默认的程序入口是0xBFC00000(虚拟地址),此地址在KSEG1(无缓存)区域内,对应的物理地址是0x1FC00000高3位清零),所以CPU从物理地址0x1FC00000开始取第一条指令,这个地址在硬件上已经确定为FLASHBIOS)的位置,BIOS将Linux内核镜像文件拷贝到RAM中某个空闲地址(LOAD地址)处,然后一般有个内存移动的操作(Entry point(EP)的地址),最后BIOS跳转到EP指定的地址运行,此时开始运行Linux kernel。

关于LOAD地址的一些说明:

在我们编译完内核时,一般情况下会有俩个版本的内核vmlinuxvmlinuz。其中vmlinux为非压缩版内核,vmlinuz为压缩版内核(包含内核自解压程序)。
使用readelf -l vmlinux 命令可以读到LOAD地址,这个地址是由arch/mips/kernel/vmlinux.lds决定的:

OUTPUT_ARCH(mips) ENTRY(kernel_entry) jiffies = jiffies_64; SECTIONS { . = 0xFFFFFFFF80200000; /* read-only */ _text = .; /* Text and read-only data */ .text : { *(.text) …

关于Entry point(EP)的一些说明:

EP(ELF可以读到)地址是BIOS移动完内核后,直接跳转的地址(控制权由BIOS转移到KERNEL)。这个地址由ld写入ELF的头中,会依次用下面的方法尝试设置入口地址,当遇见成功时则停止:
a.命令行选项 -e entry;
b.脚本(vmlinux.lds)中的ENTRY(xxx);
c.如果有定义start符号,则使用start符号(xxx);
d.如果存在.text节,则使用第一个字节的地址;
e.地址0。

由于上述ld 脚本(vmlinux.lds)中,用ENTRY宏设置了内核的EP是kernel_entry (KE)函数的地址,所以内核取得控制权(BIOS跳转之后)后执行的第一条指令就是 KE函数。

注意:这种情况只是vmlinux(非压缩版的内核),对于vmlinuz(压缩版的内核),EP会被设置成内核自解压缩的程序代码的地址,这样固件就会跳转到内核自解压代码(此时的EP为解压程序的代码地址),最后还是会到KE函数去执行。

由以上分析可知无论是压缩版还是非压缩版的Linux内核,内核第一个执行的函数是KE。接下来就是对KE函数的分析,看看它到底都做了些什么事?

kernel_entry(KE)分析:

内核版本:3.10.X
源代码文件:arch/mips/kernel/head.S
KE函数是体系相关的汇编语言实现的,源代码中汇编指令的含义(64位指令)为:

PTR_LA          dlaLONG_S          sdPTR_ADDIU       daddiuMTC0            dmtc0PTR_LI          dliPTR_ADDU        dadduPTR_SUBU        dsubu

源代码:

NESTED(kernel_entry, 16, sp)            # KE函数定义,函数栈的大小为16字节    kernel_entry_setup          # 对CPU的配置,详情见kernel_entry_setup函数分析NOTE1    setup_c0_status_pri         #设置mips协处理器(cp0)中的寄存器,详情见NOTE2    PTR_LA  t0, 0f    jr  t00:#ifdef CONFIG_MIPS_MT_SMTC     #硬件多线程    mtc0    zero, CP0_TCCONTEXT    mfc0    t0, CP0_STATUS    ori t0, t0, 0xff1f    xori    t0, t0, 0x001e    mtc0    t0, CP0_STATUS#endif /* CONFIG_MIPS_MT_SMTC */    PTR_LA      t0, __bss_start     # 清除BSS段,详情见NOTE3    LONG_S      zero, (t0)    PTR_LA      t1, __bss_stop - LONGSIZE1:    PTR_ADDIU   t0, LONGSIZE    LONG_S      zero, (t0)    bne     t0, t1, 1b    LONG_S      a0, fw_arg0     # BIOS传参数,详情见NOTE4    LONG_S      a1, fw_arg1    LONG_S      a2, fw_arg2    LONG_S      a3, fw_arg3    MTC0        zero, CP0_CONTEXT   # NOTE5     PTR_LA      $28, init_thread_union    #为0号进程准备内核栈,详情见NOTE6    PTR_LI      sp, _THREAD_SIZE - 32 - PT_SIZE    PTR_ADDU    sp, $28    back_to_back_c0_hazard #NOTE7    set_saved_sp    sp, t0, t1 #NOTE6    PTR_SUBU    sp, 4 * SZREG       #NOTE8    j       start_kernel  #NOTE9    END(kernel_entry)    __CPUINIT

NOTE1(kernel_entry_setup函数分析):

Linux内核犹如一座巨大的迷宫,只有找到了正确的入口,才有可能找到出口。

之前的分析得出的结论是Linux内核第一个调用的函数是KE,而KE第一个调用函数则是kernel_entry_setup,这才是真正执行的第一个函数,那么我们就从它开始吧。

函数名称:kernel_entry_setup
源代码文件:arch/mips/include/asm/mach-loongson/kernel-entry-init.h
源代码:

#ifndef __ASM_MACH_LOONGSON_KERNEL_ENTRY_H#define __ASM_MACH_LOONGSON_KERNEL_ENTRY_H    .macro  kernel_entry_setup#ifdef CONFIG_CPU_LOONGSON3    .set    push    .set    mips64    /* Set LPA on LOONGSON3 config3 */    mfc0    t0, $16, 3    or  t0, (0x1 << 7)    mtc0    t0, $16, 3    /* Set ELPA on LOONGSON3 pagegrain */    mfc0    t0, $5, 1    or  t0, (0x1 << 29)    mtc0    t0, $5, 1#ifdef CONFIG_LOONGSON3_ENHANCEMENT    /* Enable STFill Buffer */    mfc0    t0, $16, 6    or  t0, 0x100    mtc0    t0, $16, 6#endif    _ehb    .set    pop#endif    .endm

通常情况下这个函数的实现与具体的CPU有关,阅读这段代码得结合LOONGSON CPU手册。
这个函数的作用是设置CPU,由于LOONGSON是基于MIPS架构架构的,所以对CPU的设置是对CPU协处理器(CP0)的寄存器进行设置来设置CPU。

对CP0寄存器操作的说明:

MIPS刚刚出现的时候,最多可以有32个CP0寄存器。但是MIPS32/64可以允许多达256个寄存器。为了保持指令向前兼容,这是通过在CP0号(实际上是指令中以前编码为0的域)后附加3位的select域来实现的。这样代码

mfc0    t0, $16, 3

解释为将寄存器号为16,查询号为3的寄存器的值读到t0寄存器中。

代码分析:

代码一:

mfc0    t0, $16, 3or  t0, (0x1 << 7)mtc0    t0, $16, 3

将Config3寄存器的第7位(LPA)置一。

代码二:

mfc0    t0, $5, 1or  t0, (0x1 << 29)mtc0    t0, $5, 1

将PageGrain寄存器的第29位(ELPA)置一。

代码三:

mfc0    t0, $16, 6or  t0, 0x100mtc0    t0, $16, 6

将GSConfig3寄存器的第8位(STFill)置一。

LOONGSON2000芯片手册:

说明

LPA:当支持大物理地址范围时为1,此时允许物理地址的范围超过236字节(此时LOONGSON物理地址的范围为248字节)。

ELPA:当有LPA支持的时候,还会有一个额外的寄存器(PageGrain),同时EntryLo0-1EntryHi中的域的布局也会变化(如上图芯片手册所示)。

STFill:GSConfig 寄存器用于对处理器核部分微结构相关的功能进行动态配置。自动写合并功能属于处理器核部分微结构的功能。

自动写合并功能介绍

对于现代CPU而言,性能瓶颈则是对于内存的访问。CPU的速度往往都比主存的高至少两个数量级。因此CPU都引入了L1_cache与L2_cache,更加高端的cpu还加入了L3_cache.很显然,这个技术引起了下一个问题:

如果一个CPU在执行的时候需要访问的内存都不在cache中,CPU必须要通过内存总线到主存中取,那么在数据返回到CPU这段时间内(这段时间大致为cpu执行成百上千条指令的时间,至少两个数据量级)干什么呢? 答案是CPU会继续执行其他的符合条件的指令。比如CPU有一个指令序列 指令1 指令2 指令3 …, 在指令1时需要访问主存,在数据返回前CPU会继续后续的和指令1在逻辑关系上没有依赖的”独立指令“,CPU一般是依赖指令间的内存引用关系来判断的指令间的”独立关系”,具体细节可参见各CPU的文档。这也是导致CPU乱序执行指令的根源之一。

以上方案是CPU对于读取数据延迟所做的性能补救的办法。对于写数据则会显得更加复杂一点:

当CPU执行存储指令时,它会首先试图将数据写到离CPU最近的L1_cache, 如果此时CPU出现L1未命中,则会访问下一级缓存。速度上L1_cache基本能和CPU持平,其他的均明显低于CPU,L2_cache的速度大约比CPU慢20-30倍,而且还存在L2_cache不命中的情况,又需要更多的周期去主存读取。其实在L1_cache未命中以后,CPU就会使用一个另外的缓冲区,叫做合并写存储缓冲区。这一技术称为合并写入技术。在请求L2_cache缓存行的所有权尚未完成时,CPU会把待写入的数据写入到合并写存储缓冲区,该缓冲区大小和一个Cache Line大小相同,一般都是64字节。这个缓冲区允许CPU在写入或者读取该缓冲区数据的同时继续执行其他指令,这就缓解了CPU写数据时Cache Miss时的性能影响。

当后续的写操作需要修改相同的缓存行时,这些缓冲区变得非常有趣。在将后续的写操作提交到L2缓存之前,可以进行缓冲区写合并。 这些64字节的缓冲区维护了一个64位的字段,每更新一个字节就会设置对应的位,来表示将缓冲区交换到外部缓存时哪些数据是有效的

经过上述步骤后,缓冲区的数据还是会在某个延时的时刻更新到外部的缓存(L2_cache)。如果我们能在缓冲区传输到缓存之前将其尽可能填满,这样的效果就会提高各级传输总线的效率,以提高程序性能。

也许你要问,如果程序要读取已被写入缓冲区的某些数据,会怎么样?我们的硬件工程师已经考虑到了这点,在读取缓存之前会先去读取缓冲区的。

这一切对我们的程序意味着什么?

如果我们能在缓冲区被传输到外部缓存之前将其填满,那么将大大提高各级传输总线的效率。如何才能做到这一点呢?好的程序将大部分时间花在循环处理任务上。

这些缓冲区的数量是有限的,且随CPU模型而异。在LOONGSON CPU中,同一时刻只能拿到4个。这意味着,在一个循环中,你不应该同时写超过4个不同的内存位置,否则你将不能体验到合并写的好处。

从下面这个具体的例子来看吧:

下面一段测试代码,从代码本身就能看出它的基本逻辑。

测试代码:

#include <unistd.h>#include <stdio.h>#include <sys/time.h>#include <stdlib.h>#include <limits.h>static const int iterations = 10000000;static const int items = 1<<24;static int mask;static int arrayA[1<<24];static int arrayB[1<<24];static int arrayC[1<<24];static int arrayD[1<<24];static int arrayE[1<<24];static int arrayF[1<<24];static int arrayG[1<<24];static int arrayH[1<<24];double run_one_case_for_8(){        double start_time;        double end_time;        struct timeval start;        struct timeval end;        int i = iterations;    gettimeofday(&start, NULL);    while(--i != 0){        int slot = i & mask;        int value = i;        arrayA[slot] = value;        arrayB[slot] = value;        arrayC[slot] = value;        arrayD[slot] = value;        arrayE[slot] = value;        arrayF[slot] = value;        arrayG[slot] = value;        arrayG[slot] = value;    }    gettimeofday(&end, NULL);        start_time = (double)start.tv_sec + (double)start.tv_usec/1000000.0;        end_time = (double)end.tv_sec + (double)end.tv_usec/1000000.0;        return end_time - start_time;}double run_two_case_for_4(){    double start_time;    double end_time;    struct timeval start;    struct timeval end;    int i = iterations;    gettimeofday(&start, NULL);    while(--i != 0){        int slot = i & mask;        int value = i;        arrayA[slot] = value;        arrayB[slot] = value;        arrayC[slot] = value;        arrayD[slot] = value;    }    i = iterations;    while(--i != 0){        int slot = i & mask;        int value = i;        arrayE[slot] = value;        arrayF[slot] = value;        arrayG[slot] = value;        arrayH[slot] = value;    }    gettimeofday(&end, NULL);    start_time = (double)start.tv_sec + (double)start.tv_usec/1000000.0;    end_time = (double)end.tv_sec + (double)end.tv_usec/1000000.0;    return end_time - start_time;}int main(){    mask = items -1;    int i;    printf("Test Begin---->\n");    for(i=0;i<3;i++){        printf("%d, run_one_case_for_8: %lf\n",i, run_one_case_for_8());        printf("%d, run_two_case_for_4: %lf\n",i, run_two_case_for_4());    }    printf("Test End\n");    return 0;}

测试环境:Fedora 21 64bits, 8G DDR3内存,LOONGSON-3A2000@999MHz
相信很多人会认为run_two_case_for_4 的运行时间肯定要比run_one_case_for_8的长,因为至少前者多了一遍循环的i++操作。但是事实却不是这样。
测试结果:
测试截图
原理:上面提到的合并写存入缓冲区离CPU很近,容量为64字节,很小了,估计很贵。数量也是有限的,个数是依赖CPU模型的,LOONGSON的CPU在同一时刻只能拿到4个(将上面的代码做改写可以证明)。

因此,run_one_case_for_8函数中连续写入8个不同位置的内存,那么当4个数据写满了合并写缓冲时,cpu就要等待合并写缓冲区更新到L2cache中,因此CPU就被强制暂停了。然而在run_two_case_for_4函数中是每次写入4个不同位置的内存,可以很好的利用合并写缓冲区,因合并写缓冲区满到引起的CPU暂停的次数会大大减少,当然如果每次写入的内存位置数目小于4,也是一样的。虽然多了一次循环的i++操作(实际上你可能会问,i++也是会写入内存的啊,其实i这个变量保存在了寄存器上), 但是它们之间的性能差距依然非常大。

从上面的例子可以看出,这些CPU底层特性对程序员并不是透明的。程序的稍微改变会带来显著的性能提升。对于存储密集型的程序,更应当考虑到此到特性。

小结:

kernel_entry_setup函数主要做了俩件事情:
(1)使LOONGSON CPU支持大物理地址;
(2)使LOONGSON CPU支持合并写功能。

NOTE2(setup_c0_status_pri函数分析):

内核版本:linux-3.10.X
源代码文件:arch/mips/kernel/head.S
源代码:

    mfc0    t0, CP0_STATUS    or  t0, ST0_CU0|\set|0x1f|\clr    xor t0, 0x1f|\clr    mtc0    t0, CP0_STATUS    .set    noreorder    sll zero,3              # ehb    .set    pop    .endm.macro  setup_c0_status_pri #ifdef CONFIG_64BIT#ifdef CONFIG_CPU_LOONGSON3    setup_c0_status ST0_KX|ST0_MM 0  #(1)#else    setup_c0_status ST0_KX 0#endif#else#ifdef CONFIG_CPU_LOONGSON3    setup_c0_status ST0_MM 0 #else    setup_c0_status 0 0#endif#endif    .endm

setup_c0_status_pri函数与具体的CPU有关的汇编实现的,所以必须参考LOONGSON CPU手册才能知道这个函数到底做了什么。初步可以看出来对于LOONGSON 3A-2000来说这个函数 调用setup_c0_status函数,这个函数有个CONFIG_MIPS_MT_SMTC宏,这个宏是个开关,决定LOONGSON CPU是否支持硬件多线程,而LOONGSON CPU不支持硬件多线程(关于MIPS的多线程,请看See MIPS Run Linux和MIPS硬件多线程介绍)。我们结合手册看看setup_c0_status函数到底做了啥事,依照代码初步可以看出来这个函数主要设置CP0 Status 寄存器。
芯片手册:
这里写图片描述

这里写图片描述

这里写图片描述

这里写图片描述

小结:

根据芯片手册及代码可以看出这个函数主要做了一下几个事情:
(1)使能XTLB Refill列外向量;
(2)使能协处理器2;
(3)使能协处理器0;
(4)关闭中断;
(5)还有一些其他位设置,详情请看LOONGSON 3A-2000用户手册下册(7.21)。

NOTE3(清除BBS段):

源代码:

    PTR_LA      t0, __bss_start         LONG_S      zero, (t0)    PTR_LA      t1, __bss_stop - LONGSIZE1:    PTR_ADDIU   t0, LONGSIZE    LONG_S      zero, (t0)    bne     t0, t1, 1b

这段代码很简单,以bss_start为起始地址,步调为LONGSIZE(LOONGSON 是64位处理器,所以LOONGSIZE为8),终点地址为 bss_stop-LONGSIZE做循环清零的事情。

小结:

这段代码做清除整个BSS段。

NOTE4(BIOS传参):

源代码:

    LONG_S      a0, fw_arg0         LONG_S      a1, fw_arg1    LONG_S      a2, fw_arg2    LONG_S      a3, fw_arg3

固件将要传递的参数的地址放在了a0,a1,a2,a3寄存器中,通过这段代码将地址赋予fw_arg*等变量。

小结:

这段代码通过传递地址间接做参数传递。

NOTE5:

MTC0        zero, CP0_CONTEXT   

小结:

清除CP0的Context寄存器,这个寄存器用来保存页表的起始地址(详情见芯片手册)。

NOTE6(为0号进程准备内核栈):

源代码:
代码片段1:

    PTR_LA      $28, init_thread_union        PTR_LI      sp, _THREAD_SIZE - 32 - PT_SIZE    PTR_ADDU    sp, $28    set_saved_sp    sp, t0, t1 

源文件:arch/mips/include/asm/stackframe.h
代码片段2:

    .macro  set_saved_sp stackp temp temp2    ASM_CPUID_MFC0  \temp, ASM_SMP_CPUID_REG    LONG_SRL    \temp, SMP_CPUID_PTRSHIFT    LONG_S  \stackp, kernelsp(\temp)    .endm

这段代码主要做了什么?
一图胜万语:
这里写图片描述
代码片段2将SP保存到kernelsp数组中去。
其中kernelsp数组定义在arch/mips/kernel/setup.c中。

unsigned long kernelsp[NR_CPUS]; #NR_CPUS CPU核的个数

注意:代码片段2将SP最终保存到kernelsp数组中,它是以CPUID号作为数组的偏移,而CPUID是存在CP0 Context寄存器中的,虽然前面已经清零,但是在这一刻,CPU将ID存到了这个寄存器中。
由上图引发的一些问题:
1、init_thread_union 是何物?它存在哪里?
2、为何要将它的地址保存到GP?
3、PT_SIZE作甚?
4、为何将最后的SP保存到kernelsp数组中去?
一图胜万语:
这里写图片描述

原创粉丝点击