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

xiaoxiao2021-02-28  85

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

关于LOAD地址的一些说明:

在我们编译完内核时,一般情况下会有俩个版本的内核vmlinux与vmlinuz。其中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 dla LONG_S sd PTR_ADDIU daddiu MTC0 dmtc0 PTR_LI dli PTR_ADDU daddu PTR_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 t0 0: #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 - LONGSIZE 1: 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, 3 or t0, (0x1 << 7) mtc0 t0, $16, 3

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

代码二:

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

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

代码三:

mfc0 t0, $16, 6 or t0, 0x100 mtc0 t0, $16, 6

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

LOONGSON2000芯片手册:

说明:

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

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

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 - LONGSIZE 1: 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数组中去? 一图胜万语:

转载请注明原文地址: https://www.6miu.com/read-49552.html

最新回复(0)