在arch\i386\kernel\head.S文件中,自line 100开始有这么几行:
movl %cr0,%eax orl $0x80000000,%eax movl %eax,%cr0 /* ..and set paging (PG) bit */ jmp 1f /* flush the prefetch-queue */ 1: movl $1f,%eax jmp *%eax /* make sure eip is relocated */ 1: /* Set up the stack pointer */ lss stack_start,%esp
我看了很久都不明白第一次跳转到底是为了什么,情景分析那本书上说这是为了刷新指令预取队列,我把Intel手册翻了个遍也没找到关于预取队列的详细信息,维基百科上介绍的也不够详细。
在我终于弄明白之后,写一写我的分析过程,这份博客写写改改,用时一下午加一晚上才完工,一边写一边发现了很多自己得过且过的问题,写博客的过程也是查资料的过程,还是比较累的,如果写的有错误,欢迎指出。下面分析的过程也是思考的过程。
首先从setup.S看起,在arch\i386\boot\setup.S中line 113处有这么几行代码:
code32_start: # here loaders can put a different # start address for 32-bit code. #ifndef __BIG_KERNEL__ .long 0x1000 # 0x1000 = default for zImage #else .long 0x100000 # 0x100000 = default for big kernel #endif
因为我们编译的是bzImage,所以code32_start标号处的数值为0x100000,占用四字节。
再看line 532处的几行代码:
# we get the code32 start address and modify the below ‘jmpi‘ # (loader may have changed it) movl %cs:code32_start, %eax movl %eax, %cs:code32
在执行这些代码时CPU还处于实模式,所以CS里面是段基址,不是selector!第一句是把code32_start处的一个双字(四字节)装入eax,这个双字的值就是0x100000;然后第二句把eax即0x100000赋值到code32标号所指的内存位置里。那么这个位置在哪呢?请继续看下面line 719的代码:
# NOTE: For high loaded big kernels we need a # jmpi 0x100000,__KERNEL_CS # # but we yet haven‘t reloaded the CS register, so the default size # of the target offset still is 16 bit. # However, using an operant prefix (0x66), the CPU will properly # take our 48 bit far pointer. (INTeL 80386 Programmer‘s Reference # Manual, Mixing 16-bit and 32-bit code, page 16-6) .byte 0x66, 0xea # prefix + jmpi-opcode code32: .long 0x1000 # will be set to 0x100000 # for big kernels .word __KERNEL_CS #这个数字是0x10
0x100000这个数字最终被写到了code32这个标号处,覆盖了原来的0x1000。那么当执行到line 719时会发生什么呢?
0xea这个数字其实是jmpi指令的机器码,而0x66则告诉处理器jmpi要按照保护模式的方式来取操作数,即先取出一个4字节的双字操作数置入EIP,然后继续取出一个2字节的字操作数置入CS。如果不加0x66前缀那么jmpi指令只会取2字节的操作数置入EIP,显然这是不对的。至于为什么这个前缀是0x66,这个问题要去问Intel了。注意当代码执行到此,code32处的值早已经被覆盖成了这个样子:
code32: .long 0x100000 .word __KERNEL_CS #这个数字是0x10
所以jmpi指令会先后取出0x100000和0x10分别置入EIP和CS。如果将这几行用伪代码来表示,既然0xea是jmpi的机器码,0x66是前缀,我们姑且创造一条新的汇编指令pjmpi,那么上面几行表示出来就是这样的:
pjmpi 0x100000,0x10
这样就很清晰了,0x100000置入EIP,0x10置入CS。 到此为止,CS里面的数值0x10就是selector,对应的描述符中指明该代码段的基地址为0,又因为EIP=0x100000,所以经过分段机制后可得线性地址为0x100000,数值上没变。此时尚未开启分页机制,该线性地址当作物理地址,它被送上地址总线准备从此处取指令。那么0x100000这个地址处能取出什么指令呢?
物理地址0x100000这个数值其实是1MB处。那里就是内核的主代码,也就是head.S的入口点startup_32。于是CPU会取出head.S中的第一条指令开始执行,往后就是继续执行head.S剩余的部分了。
看到这里必须明确:CS中是_KERNELCS代码段selector,EIP中的虚拟地址值虽然需要经过分段机制才能当作物理地址,但是段基址为0,对数值没影响,物理地址和虚拟地址数值上相等。每次取指令后EIP自动增加一个数,这个数就是刚才取的指令的长度,靠这种方式EIP从虚拟地址0x100000开始递增,逐次取指令执行指令。
进入head.S后,从startup32入口开始的执行过程如下:先将数据段选择子KERNELDS置入ds等寄存器,然后设置好页表的内容,而页目录表的内容是直接写到head.S文件中的,这样页目录表和页表都具备了,再然后就是将页目录表的物理地址置入cr3寄存器,再将cr0的PG标志位置1,从此分页机制开启了!
紧接着就是刷新指令预取队列的代码了,自line 103开始就是这几行令人费解的代码了:
jmp 1f /* flush the prefetch-queue */ 1: movl $1f,%eax jmp *%eax /* make sure eip is relocated */ 1: /* Set up the stack pointer */ lss stack_start,%esp
这里为何要跳转两次?情景分析里说的理由太过牵强,书中解释也是令人费解。我在ChinaUnix找到了同样提出此问题的帖子:http://bbs.chinaunix.net/thread-1926314-1-1.html 正当大家越讨论越糊涂时,第16楼出现了正确的答案:进行两次jmp纯粹是多余的,仅靠其中一次跳转就能完成任务。而经过我的实验与研究,我发现这两次跳转完全可以全部删掉,根本不影响系统的启动。我的回帖在18楼。
为什么这么说呢? 在讲解原因之前,必须先说点Intel处理器的规定,因为待会儿要看汇编语言和机器语言的代码才能彻底弄明白一切。
jmp跳转分为远跳转(far)和近跳转(near and short),远跳转是指覆写CS的跳转,近跳转是指不重写CS的跳转。
近跳转又分两种:
- 绝对跳转(absolute)和相对跳转(relative),绝对跳转在汇编里的写法是
jmp register/memory-location
,即跳转的目的地址存储在寄存器内或内存位置内,CPU直接把这个目的地址覆写到EIP中,EIP=absolute_address; - 相对跳转的写法是
jmp label
,汇编语言中一般写作跳到某个标号label,在机器语言层面上这个标号被汇编成一个叫做relative offset的立即数。即jmp后面的数字是一个相对偏移量,CPU将这个偏移量加到EIP上去产生目的地址,EIP=EIP+offset。注意当CPU正在执行jmp指令时,EIP指向jmp的后一条指令,所以这个相对偏移就是jmp后一条指令的地址到目的地址之间的差值,(跳转的目的地址)-(jmp后一条指令的地址)= offset。
在机器语言层面上:
- 绝对跳转的机器码是ff,后面的操作数代表目的地址存放的位置,比如e0代表eax寄存器,那么ffe0就表示将eax中的目的地址数值取出来,直接覆写至EIP寄存器,下一次取指令就从目的地址取了。
- 相对跳转的机器码是eb,后面的操作数是相对偏移,在汇编器进行汇编操作时会自动进行运算:(跳转的目的地址)-(jmp后一条指令的地址)= offset,将这个offset放在eb后面作为操作数,CPU执行jmp跳转时EIP恰好指向jmp的后一条指令处,CPU将offset操作数加到EIP上恰好得到跳转的目的地址,然后EIP中就是目的地址了,下一次取指令就从目的地址取了。
- 在内核汇编完成的链接阶段,arch\i386\Vmlinux.lds文件第9行
. = 0xC0000000 + 0x100000;
说明在ld链接时给最终的vmlinux文件里面所有的符号地址都加上0xC0000000 + 0x100000,也就是都加上0xC0100000。这个操作对相对跳转没有任何影响,因为相对跳转在机器码层面的操作数是相对偏移,不管目的地址和jmp后一条指令的地址被链接器改成了多少,这俩地址的差是不变的,也就是说相对偏移不会被链接器所影响,它永远是个差。转而看绝对跳转就不一样了,绝对跳转的目的地址存储在寄存器或内存里,那在jmp *%eax
之前必然要mov label,%eax
,这个label不是相对偏移了,它切切实实是某条指令的绝对地址,因为这里并不是jmp相对跳转指令! 那它既然是一个绝对地址,链接器就会给它统一加上一个值0xC0100000,这必然会影响jmp指令,如果原来label代表地址0x42,即jmp是往0x42跳的话,那现在label变成了0xC0100042,jmp就是往0xC0100042跳转。记住,只有jmp相对跳转指令后面的数字才是相对偏移,链接器无法将之修改,其他指令中的标号全部是绝对地址,是可以被链接器修改的!
下面终于要开始看这两条jmp指令的作用了。源汇编代码如下:
movl %cr0,%eax orl $0x80000000,%eax movl %eax,%cr0 /* ..and set paging (PG) bit */ jmp 1f /* flush the prefetch-queue */ 1: movl $1f,%eax jmp *%eax /* make sure eip is relocated */ 1: /* Set up the stack pointer */ lss stack_start,%esp
我们再来看看内核的反汇编代码。在顶层Makefile里讲 CFLAGS_KERNEL =
改为 CFLAGS_KERNEL = -g
给内核加入调试信息,然后 objdump -d vmlinux | less
反编译内核镜像vmlinux的结果如下:
虚拟地址: 物理地址: c010002e: 10002e 0f 20 c0 mov %cr0,%eax c0100031: 100031 0d 00 00 00 80 or $0x80000000,%eax c0100036: 100036 0f 22 c0 mov %eax,%cr0 c0100039: 100039 eb 00 jmp c010003b <_text+0x3b> c010003b: 10003b b8 42 00 10 c0 mov $0xc0100042,%eax c0100040: 100040 ff e0 jmp *%eax c0100042: 100042 0f b2 25 e4 01 10 c0 lss 0xc01001e4,%esp
可以看到在内核编译链接完成后所有符号的地址都变成了0xC0100000之后的数,数值上讲都大于3GB,毕竟内核空间的范围是虚拟地址空间的3G-4G。我为了方便,把物理地址也标上了。
CPU内部正在执行当前指令的同时,EIP指向的是下一条指令。回忆上文所讲内容,开始进入head.S时EIP=0x100000。下面按照CPU取指令->执行指令的过程来分步讲解。
- 当CPU取指令并执行
movl %eax,%cr0
时,EIP指向jmp 1f
,此时EIP=0x100039,只经过分段机制变换后得到物理地址0x100039。
(这个地址和上面反汇编的结果不太一样,暂时不用理会,只看EIP的实际内容就行)
一旦
movl %eax,%cr0
指令指行完,下一次取指令时出现了一点不同,因为分页机制开启了。EIP中的值不会因为分页开启而改变,仍旧是虚拟地址0x100039,CPU将之进行分段+分页变换后得到物理地址0x100039,这是下一次取指令的地方。 可以看到这里页表和页目录的设置非常巧妙,0x100039这个虚拟地址不管是只经过分段变换还是经过分段+分页变换,得到的物理地址是一样的,并且从数值上讲物理地址和虚拟地址相等。
- 接下来,CPU取指令并执行
jmp 1f
,同时EIP继续自增指向movl $1f,%eax
,即EIP=0x10003b。仔细看机器码,jmp 1f
这条语句被汇编成了eb00,eb表示相对跳转,相对偏移量为00。jmp相对跳转指令将相对偏移量00加到EIP上得到跳转的目的地址0x10003b,EIP数值上不变(EIP=0x10003b),所以这个jmp没什么作用。EIP经过分段+分页后得到物理地址0x10003b,这是下一次取指令的地方。 - 接下来,CPU取指令并执行
movl $1f,%eax
,EIP指向jmp *%eax
,EIP=0x100040。语句中1f是个地址标号,代表一个绝对地址,一开始汇编后它的值为0x42,链接后加上0xC0100000变成0xC0100042,把0xC0100042这个数置入eax寄存器。movl指令执行后eax=0xC0100042。EIP虚拟地址化成物理地址是0x100040,这是下一次取指令的地方。 - 接下来,CPU取指令并执行
jmp *%eax
,EIP指向lss stack_start,%esp
,EIP=0x100042。jmp指令的机器码是ff,代表绝对跳转,将eax中目的地址的值直接覆写到EIP。从此EIP=0xC0100042。 - 接下来,CPU要去EIP处取指令,它把EIP=0xC0100042经过分段+分页变换,根据页表和页目录的设置,得到物理地址0x100042,取得指令
lss stack_start,%esp
开始执行,同时EIP自动增加指令长度的数值变为EIP=0xC0100049。页表和页目录表都设置的非常巧妙,地址X和地址3G+X映射到的物理地址相同,这里不展开讲。
从此之后EIP将从0xC0100049开始逐渐递增,并经过分段+分页映射到物理地址,内核的内存管理初见雏形。
回想上面第一个jmp,它的没有任何作用,不产生任何影响,可以删掉。 如果也把第二个jmp删掉会如何呢?那就可以预见,EIP将会继续保持从0x1000xx这样的模式递增,不会变成0xC01000xx这样。因为页目录和页表都设置的非常巧妙,0x1000xx和0xC01000xx会换算成同一个物理地址,所以这两种虚拟地址等效,可以互相替代。即使一直按照0x1000xx的格式取指令也不会出现任何问题,因为这和用0xC01000xx取到的指令是完全一样的,毕竟两者都能换算成同样的物理地址。所以这个地方不跳转也是可以的,即第二个jmp也可以删掉。 所以即使把两个jmp全删了,都不会产生影响。在后面的代码中,自然会有别的代码替它们完成将EIP置成0xC01000xx的任务。
内核在执行到head.S line 252时会执行下面的指令:
ljmp $(__KERNEL_CS),$1f 1: movl $(__KERNEL_DS),%eax
ljmp后面跟上两个操作数,这是绝对跳转的写法,并且是远跳转,CS和EIP都将被覆写。在汇编器汇编时,1f这个标号不是相对偏移,而是绝对地址,既然是绝对地址,那必然会被链接器修改,它原先是0x172,在链接时被改成了0xc0100172。再者,_KERNELCS=0x10,所以这个指令相当于ljmp $0x10,$0xc0100172
。这条指令将0x10置入CS,将0xC0100172置入EIP,这样EIP在这里变成了0xC01000xx这种格式。即使之前两次都不跳转,EIP迟早会变成0xC01000xx这个样子。又如果前面真的发生了跳转,EIP在那时已经被置成0xC01000xx这个样子,那么到了此处EIP还是免不了被重新覆盖一次,反正这个地方CS和EIP必须被重新赋一次值,不管以前EIP是什么样子。
注:我将两个jmp都删了然后重新编译内核,系统启动完全正常。