系统加电启动后,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)
…
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
关于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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
源代码:
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
通常情况下这个函数的实现与具体的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
- 1
解释为将寄存器号为16,查询号为3的寄存器的值读到t0寄存器中。
代码分析:
代码一:
mfc0 t0, $16, 3
or t0, (0x1 << 7)
mtc0 t0, $16, 3
- 1
- 2
- 3
将Config3寄存器的第7位(LPA)置一。
代码二:
mfc0 t0, $5, 1
or t0, (0x1 << 29)
mtc0 t0, $5, 1
- 1
- 2
- 3
将PageGrain寄存器的第29位(ELPA)置一。
代码三:
mfc0 t0, $16, 6
or t0, 0x100
mtc0 t0, $16, 6
- 1
- 2
- 3
将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;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
测试环境: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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
这段代码很简单,以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
- 1
- 2
- 3
- 4
固件将要传递的参数的地址放在了a0,a1,a2,a3寄存器中,通过这段代码将地址赋予fw_arg*等变量。
小结:
这段代码通过传递地址间接做参数传递。
NOTE5:
MTC0 zero, CP0_CONTEXT
- 1
小结:
清除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
- 1
- 2
- 3
- 4
源文件: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
- 1
- 2
- 3
- 4
- 5
这段代码主要做了什么?
一图胜万语:
代码片段2将SP保存到kernelsp数组中去。
其中kernelsp数组定义在arch/mips/kernel/setup.c中。
unsigned long kernelsp[NR_CPUS]; #NR_CPUS CPU核的个数
- 1
注意:代码片段2将SP最终保存到kernelsp数组中,它是以CPUID号作为数组的偏移,而CPUID是存在CP0 Context寄存器中的,虽然前面已经清零,但是在这一刻,CPU将ID存到了这个寄存器中。
由上图引发的一些问题:
1、init_thread_union 是何物?它存在哪里?
2、为何要将它的地址保存到GP?
3、PT_SIZE作甚?
4、为何将最后的SP保存到kernelsp数组中去?
一图胜万语: