回到go_to_protected_mode()函数中,最后一行调用:
protected_mode_jump(boot_params.hdr.code32_start,(u32)&boot_params + (ds() << 4));
这个函数接受两个参数,第一个参数是grub传过来的保护模式的第一条代码。这个值就是前面说到了的,0x100000。后面这个就是给内核传递的参数,由于切换到了保护模式,所以要给出参数的线性地址,而不是有效地址,ds()函数就是ds寄存器的值。
来看看这个函数,它是由汇编语言实现的,代码在arch/x86/boot/pmjump.S中:
23/* 24 * void protected_mode_jump(u32 entrypoint, u32 bootparams); 25 */ 26GLOBAL(protected_mode_jump) 27 movl %edx, %esi # Pointer to boot_params table 28 29 xorl %ebx, %ebx 30 movw %cs, %bx 31 shll $4, %ebx 32 addl %ebx, 2f 33 jmp 1f # Short jump to serialize on 386/486 341: 35 36 movw $__BOOT_DS, %cx 37 movw $__BOOT_TSS, %di 38 39 movl %cr0, %edx 40 orb $X86_CR0_PE, %dl # Protected mode 41 movl %edx, %cr0 42 43 # Transition to 32-bit mode 44 .byte 0x66, 0xea # ljmpl opcode 452: .long in_pm32 # offset 46 .word __BOOT_CS # segment 47ENDPROC(protected_mode_jump) |
要看懂上面的代码,需要用到一个C语言背景知识。在C语言中,假设咱们有这样的一个函数:
int function(int a, int b){
return a+b;
}
调用时只要用result = function(1, 2)那样的方法就能够应用那个参数。但是,当高级语言被编译成电脑能够识别的机器码时,有一个疑问目就出现了:在CPU中,计算机没有办法清楚一个参数调用需求多少个、什么样的参数,也没有硬件能够保存这一些参数。也就是说,编译器不清楚怎么给那个参数传递参数,传递参数的任务必需由参数调用者和参数本身来协调。为此,使用栈来支持参数传递。
学过《数据结构》这么课程的童鞋都知道,栈是一种先进后出的Data框架,栈有一个存储区、一个栈顶指针。参数调用时,调用者依次把参数压栈,然后调用参数,参数被调用以后,在堆栈中取得Data,并停止计算。参数计算结束以后,或者调用者、或者参数本身改正堆栈,使堆栈还原原装。
在参数传递中,有两个很重要的东西目必需得到明确说明:
l 当参数个数多于1个时,按照什么顺序把参数压入堆栈
l 参数调用后,由谁来把堆栈还原原装
在编译器中,通过参数调用约定来说明这两个疑问,大多数编译器日常的调用约定有:stdcall、cdecl、fastcall、thiscall、naked call。
过去,stdcall是gcc的默认方式,不过如今要显式地约定声明了:
int __stdcall function(int a, int b)
stdcall的调用约定意味着:1)参数从右向左压入堆栈,2)参数自身改正堆栈 3)参数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。
以上述那个参数为例,参数a首先被压栈,然后是参数b,参数调用function(1, 2)。调用处汇编语言将变成:
push 2 第二个参数入栈
push 1 第一个参数入栈
call function 调用参数,留意此时自动把cs:eip入栈
被调用参数_function处:
push ebp 保存ebp寄存器,该寄存器将用来保存堆栈的栈顶指针,能够在出参数时还原
mov ebp,esp 保存堆栈指针
mov eax,[ebp+8H] 堆栈中ebp指向位置之前依次保存有ebp、cs:eip、a、b,
所以ebp +8指向a
add eax,[ebp + 0CH] 堆栈中ebp + 12处保存了b
mov esp,ebp 还原esp
pop ebp
ret 8
而在编译时,那个参数的姓名被中英对译成_function@8。从参数调用看,2和1依次被push进堆栈,而在参数中又经过相对于ebp(即刚进参数时的堆栈指针)的偏移量存取参数。参数结束后,ret 8意思是清理8个字节的堆栈,参数本人还原了堆栈。
fastcall调用约定和stdcall类似,它意味着:
参数的第一个和第二个DWORD参数(或者尺寸更小的)分别经过eax和edx传递,更多参数则经过从右向左的顺序压栈。过去的内核还有一个Gcc属性宏定义:
#define FASTCALL __attribute__((regparm(3)))
不过目前我看的2.6.34.1版本的内核的x86体系以及没有这个宏定义了,由此我推断,新的内核依赖的gcc新版本为了提升针对x86的性能,已经不再使用stdcall或cdecl方案了,所有函数都是fastcall。为什么能提升了性能呢,你想想啊,x86中eax和edx用于存放传入参数,作为高水平内核级程序员,函数的参数不应该超过2个。那么有人就会反过来问:超过2个的那些参数又何去何从?我的回答是:传入参数如果超过2个,多余的参数还是被存放到局部栈中,表面上没什么不好,但是不管是系统调用,还是系统陷阱都会引起用户空间陷入内核空间。我们知道,系统空间的权限级是0,用户空间的权限级为3,系统调用从权限级为3的用户空间陷到权限级为0的内核空间,必然引起堆栈切换,linux系统将从全局任务状态栈TSS中找到一个合适的内核栈信息保存覆盖当前SP、SS两个寄存器的内容,以完成堆栈切换,此时处于内核空间所看到的栈已不是用户空间那个栈,所以在调用的时候压入用户栈的数据就在陷入内核的那个瞬间,被滞留在用户空间栈,内核根本不知道它的存在了,所以作为安全考虑或者作为高水平程序员的切身修养出发,都不应该向系统调用级函数传入过多的参数。
一边分析内核,一边学习大量的计算机与编程知识,这就是内核给我们带来的巨大乐趣,何乐而不为呢?我们继续。刚才介绍了带参数函数调用的参数传递的C语言方面的知识,现在我们知道了,来到protected_mode_jump后eax存放的是code32_start,其值是header.S中的152行定义的hdr传递过来的0x100000;edx存放的是bootparams参数32位的地址。
27行:
movl %edx, %esi # Pointer to boot_params table
执行此指令后esi寄存器存放着boot_params结构的首地址,随后29~32行:
xorl %ebx, %ebx
movw %cs, %bx
shll $4, %ebx
addl %ebx, 2f
jmp 1f # Short jump to serialize on 386/486
执行上述指令后,ebx寄存器存放的就是2f程序段对应的地址值。为了学习,这里不得不多说几句。cs是16位的,而我们马上要进入保护模式了,不管是代码寻址还是数据寻址,都是32位的了,此时此刻的这个cs所指向的段没有什么意义了,所以上面的动作就是将进入保护模式代码段了之前的cs:eip的值保存在ebx中,保存来干嘛呢,马上会用到。
继续走:34~44行:
1:
movw $__BOOT_DS, %cx
movw $__BOOT_TSS, %di
movl %cr0, %edx
orb $X86_CR0_PE, %dl # Protected mode
movl %edx, %cr0
# Transition to 32-bit mode
.byte 0x66, 0xea # ljmpl opcode
这段代码就是protected_mode_jump的核心,即打开cr0的PE位,打开保护模式。由于:
#define GDT_ENTRY_BOOT_CS 2
#define __BOOT_CS (GDT_ENTRY_BOOT_CS * 8)
#define GDT_ENTRY_BOOT_DS (GDT_ENTRY_BOOT_CS + 1)
#define __BOOT_DS (GDT_ENTRY_BOOT_DS * 8)
#define GDT_ENTRY_BOOT_TSS (GDT_ENTRY_BOOT_CS + 2)
#define __BOOT_TSS (GDT_ENTRY_BOOT_TSS * 8)
所以16位cx的值是(2*8+1)*8即136,16进制为0x88;di为(2*8+2)*8即144,16进制为144;eax存放的0x100000,edx和esi存放了boot_params的首址;还有一个ebx存放这向保护模式切换前cs:eip指令地址。所以即将进入保护模式前夕,这几个寄存器的值就是这个样子滴。
在执行完第40行代码后,内核从此告别实模式,开始了x86的保护模式之旅。注意,从系统启动到此,并不是第一次进入保护模式,前面bootloader阶段,grub已经执行过一下保护模式的命令了,不然怎么会把vmlinuz第三部分的代码拷贝到内存0x100000之后呢。随后立即跳到45行标号2,开始执行保护模式下的代码:
2: .long in_pm32 # offset
.word __BOOT_CS # segment
在函数main的最后,实际上就是已关中断,准备进入保护模式,设置最初始的gdt,idt等。当前面执行.byte 0x66, 0xea指令时,cs寄存器的值被设置为__BOOT_CS,即代码段选择子,偏移量就是子程序名in_pm32。至于如何的到对应的物理地址,请查看“保护模式编程”
来看子程序in_pm32:
49 .code32 50 .section ".text32","ax" 51GLOBAL(in_pm32) 52 # Set up data segments for flat 32-bit mode 53 movl %ecx, %ds 54 movl %ecx, %es 55 movl %ecx, %fs 56 movl %ecx, %gs 57 movl %ecx, %ss 58 # The 32-bit code sets up its own stack, but this way we do have 59 # a valid stack if some debugging hack wants to use it. 60 addl %ebx, %esp 61 62 # Set up TR to make Intel VT happy 63 ltr %di 64 65 # Clear registers to allow for future extensions to the 66 # 32-bit boot protocol 67 xorl %ecx, %ecx 68 xorl %edx, %edx 69 xorl %ebx, %ebx 70 xorl %ebp, %ebp 71 xorl %edi, %edi 72 73 # Set up LDTR to make Intel VT happy 74 lldt %cx 75 76 jmpl *%eax # Jump to the 32-bit entrypoint 77ENDPROC(in_pm32) |
53~57行首先把ds、es、fs、gs、ss设置成同样的值,即__BOOT_DS,其值为0x88。为什么是0x88呢?注意,我们分析代码的目的是学习,是研究,不是其他的,所以来研究一下。我们知道,进入保护模式后,段寄存器的值就不再是存放段基址了,而是存放段选择子。
选择子的格式如下:(来自博客“Intel 80286工作模式”)
0x88正好就是二进制的10001000,其中10001对应选择子的偏移量D15~D3,即所要访问的描述子在描述子表中的偏移量。RPL为00,对应最高特权级(初始化阶段,特权级当然最高),TI为0,表示全局描述符。
好,继续研究。那么偏移量为啥要设置成10001呢?回顾一下,我们前面是如何安装全局描述符表的。setup_gdt()函数中,我们建立了一个全局描述符表boot_gdt,然后把这个表的首地址和长度加载到GDTR寄存器中。这个表的每个成员是64位,即8个字节。10001换算成十进制就是17,也就是经历了2个8字节后的偏移位置。我为什么说是2个8字节,不错,看看那个表的定义,第3个8字节,正是从GDT_ENTRY_BOOT_DS下标开始的。所以,经过这样的一系列指令后,ds、es、fs、gs、ss这五个寄存器的内容都是指向全局描述符表boot_gdt的数据段了。
继续走,60行对进入保护模式后的堆栈进行调整,将原来的栈顶指针esp加上刚才保存在ebx的代码段偏移。注意,这个地方注释上写得很清楚了,32位保护模式有自己的堆栈,这里调整堆栈的目的是供某些黑客娱乐的。
63行,执行ltr指令,目的在于设置TSS相关的TR寄存器。至于想学习TSS内容的朋友,请参考博客“Intel 80286工作模式”。67到71行清空刚才使用的那些通用寄存器。最后74行加载LDTR寄存器设置局部描述符表。此时ecx的值为空,所以这步操作是一个没有意义的操作。
最后一行,76行执行jmpl *%eax,开始执行由函数参数方式传递进来的code32_start,即0x100000处的代码,我们著名的“文艺复兴时期”。前面讲过,在解压缩vmlinuz之前,这代码在arch/x86/boot/compressed/head_32.S中,入口是ENTRY(startup_32)。
由于段的基地址为0(可以参考go_to_protected_mode()中的setup_gdt()函数),所以线性地址等于有效地址,因为目前还没有分页,所以线性地址也其实就是物理地址,物理地址1M后正是保护模式代码所在地)