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中是_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的跳转。

近跳转又分两种:

  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,只经过分段机制变换后得到物理地址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都删了然后重新编译内核,系统启动完全正常。

时间: 2024-10-22 09:14:20

Kernel 2.4.0 之 head.S 为何用两次 jmp 刷新 EIP 寄存器的相关文章

mysql kernel: nf_conntrack version 0.5.0

今天要检查mysql数据库时messages日志中有大量mysql kernel: nf_conntrack version 0.5.0连接信息,现在将解决方法记录下来,希望能帮助需要的人,注:操作系统是centos 6.4,具体参数和操作系统的内核有关. 日志信息: Oct 30 22:23:02 mysql kernel: ip_tables: (C) 2000-2006 Netfilter Core TeamOct 30 22:23:02 mysql kernel: nf_conntrac

php在数字前面补0得到固定长度数字的两种方法

比较基础,其实两个内置函数都能实现. 1  sprintf 语法: string sprintf(string format, mixed [args]...); 返回值: 字符串 函数种类: 资料处理 本函数用来将字符串格式化.参数 format 是转换的格式,以百分比符号 % 开始到转换字符为止.而在转换的格式间依序包括了 填空字符.0 的话表示空格填 0:空格是默认值,表示空格就放着. 对齐方式.默认值为向右对齐,负号表向左对齐. 字段宽度.为最小宽度. 精确度.指在小数点后的浮点数位数.

[2013.7.5新鲜出炉] Ubuntu12.04下载Android4.0.1源码全过程----------------折腾两天,终于下好,附若干问题解决

本文转至 http://blog.csdn.net/yanzi1225627/article/details/9255457 下载源码这一步折腾了我整整两天,期间遇到很多问题,哎,记录于此,希望日后再下源码的人不要再走无谓的弯路了.事实上可以在这里http://zhu.im/Android/下载源码,但是google推荐用repo来下载,为了多学东西就学下repo吧,毕竟下现成的也太么含量了.最初我参考的是老罗的博客http://blog.csdn.net/luoshengyang/articl

WebStorm10.0.4 Live Edit与Google浏览器实时无刷新自动加载页面

[1]打开webstorm→Ctrl + Alt + S→在搜索框输入:live edit,并且进行如下设置,设置完成点击右下角“OK”按钮: [2]在Google浏览器中安装 JetBrains IDE Suport 扩展程序,具体为: http://jingyan.baidu.com/article/bad08e1eef45fa09c85121c2.html [3]在WebStorm中,点击“选中”你需要在浏览器中实现实时刷新的页面(例如,我需要在浏览器中实时刷新gallery.html页面

React(0.13) 服务端渲染的两个函数

1.React.renderToString 函数,  参数是组件,返回一个字符串 <!DOCTYPE html> <html> <head> <title>React JS</title> <script src="../build_0.13/react.js"></script> <script src="../build_0.13/JSXTransformer.js"&g

python manage.py runserver 127.0.0.1:8000 启动后台有两个启动进程

是因为django设置自动加载配置文件的原因.在运行命令后面可以加--noreload这样就只会显示一个进程,但是修改文件后,django不会主动去加载配置文件. 如果settings.py中DEBUG=False时,django不会处理静态文件,这就可以使用--insecure参数强制django处理静态文件. 原文地址:https://www.cnblogs.com/st12345/p/9597171.html

《linux内核分析》 第一周

20135130  王川东 计算机是如何工作的? 计算机的基本原理是存储程序和程序控制.预先要把指挥计算机如何进行操作的指令序列(称为程序)和原始数据通过输入设备输送到计算机内存贮器中.每一条指令中明确规定了计算机从哪个地址取数,进行什么操作,然后送到什么地址去等步骤.计算机在运行时,先从内存中取出第一条指令,通过控制器的译码,按指令的要求,从存储器中取出数据进行指定的运算和逻辑操作等加工,然后再按地址把结果送到内存中去.接下来,再取出第二条指令,在控制器的指挥下完成规定操作.依此进行下去.直至

栈的应用——对栈排序、用栈实现队列的功能

一:写一个算法将栈里的元素升序排列.栈的实现未知,算法只能借助栈完成,可使用的函数有push.pop.top.empty等. 思路:可借助另外一个栈来完成排序. 1.从原始栈里依次弹出元素放入辅助栈: 2.每当将要压入的元素是得辅助栈不是升序排列,就将辅助栈里面的元素重新压入原始栈中: 3.直到辅助栈里面的元素都小于当前要压入的元素: 4.压入当前的元素. 代码如下: 1 #include <iostream> 2 #include <string> 3 #include <

Linux Kernel - Debug Guide (Linux内核调试指南 )

http://blog.csdn.net/blizmax6/article/details/6747601 linux内核调试指南 一些前言 作者前言 知识从哪里来 为什么撰写本文档 为什么需要汇编级调试 ***第一部分:基础知识*** 总纲:内核世界的陷阱 源码阅读的陷阱 代码调试的陷阱 原理理解的陷阱 建立调试环境 发行版的选择和安装 安装交叉编译工具 bin工具集的使用 qemu的使用 initrd.img的原理与制作 x86虚拟调试环境的建立 arm虚拟调试环境的建立 arm开发板调试环