Kernel 2.4.0 之 head.S 为何用两次 jmp 刷新 EIP 寄存器

在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中是__KERNEL_CS代码段selector,EIP中的虚拟地址值虽然需要经过分段机制才能当作物理地址,但是段基址为0,对数值没影响,物理地址和虚拟地址数值上相等。每次取指令后EIP自动增加一个数,这个数就是刚才取的指令的长度,靠这种方式EIP从虚拟地址0x100000开始递增,逐次取指令执行指令。

进入head.S后,从startup_32入口开始的执行过程如下:先将数据段选择子__KERNEL_DS置入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的跳转。

近跳转又分两种:

  1. 绝对跳转(absolute)和相对跳转(relative),绝对跳转在汇编里的写法是 jmp register/memory-location ,即跳转的目的地址存储在寄存器内或内存位置内,CPU直接把这个目的地址覆写到EIP中,EIP=absolute_address;
  2. 相对跳转的写法是 jmp label ,汇编语言中一般写作跳到某个标号label,在机器语言层面上**这个标号被汇编成一个叫做relative offset的立即数。**即jmp后面的数字是一个相对偏移量,CPU将这个偏移量加到EIP上去产生目的地址,EIP=EIP+offset。注意当CPU正在执行jmp指令时,EIP指向jmp的后一条指令,所以这个相对偏移就是jmp后一条指令的地址到目的地址之间的差值,(跳转的目的地址)-(jmp后一条指令的地址)= offset。

在机器语言层面上:

  1. 绝对跳转的机器码是ff,后面的操作数代表目的地址存放的位置,比如e0代表eax寄存器,那么ffe0就表示将eax中的目的地址数值取出来,直接覆写至EIP寄存器,下一次取指令就从目的地址取了。
  2. 相对跳转的机器码是eb,后面的操作数是相对偏移,在汇编器进行汇编操作时会自动进行运算:(跳转的目的地址)-(jmp后一条指令的地址)= offset,将这个offset放在eb后面作为操作数,CPU执行jmp跳转时EIP恰好指向jmp的后一条指令处,CPU将offset操作数加到EIP上恰好得到跳转的目的地址,然后EIP中就是目的地址了,下一次取指令就从目的地址取了。
  3. 在内核汇编完成的链接阶段,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。注意当movl指令执行完后,分页机制开启了,下一次取指令时EIP不能只进行分段变换,还要进行分页变换。对照着页目录和页表,EIP=0x100039这个虚拟地址经过分段+分页变换后的结果是物理地址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将映射到物理地址X,虚拟地址3G+X也将映射到的物理地址X,这里不展开讲。

从此之后EIP将从0xC0100049开始逐渐递增,经过分段+分页映射到物理地址,虚拟地址和物理地址之间差了3G,内核的内存管理初见雏形。

回想上面第一个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。再者,__KERNEL_CS=0x10,所以这个指令相当于 ljmp $0x10,$0xc0100172 。这条指令将0x10置入CS,将0xC0100172置入EIP,这样EIP在这里变成了0xC01000xx这种格式。即使之前两次都不跳转,EIP迟早会变成0xC01000xx这个样子。又如果前面真的发生了跳转,EIP在那时已经被置成0xC01000xx这个样子,那么到了此处EIP还是免不了被重新覆盖一次,反正这个地方CS和EIP必须被重新赋一次值,不管以前EIP是什么样子。

注:我将两个jmp都删了然后重新编译内核,系统启动完全正常。

3/7/2016 9:02:15 PM

Show Comments