龙盟编程博客 | 无障碍搜索 | 云盘搜索神器
快速搜索
主页 > 软件开发 > 汇编开发 >

从X86指令RET和CALL的意义看进程的自由切换

时间:2011-05-21 16:24来源:未知 作者:admin 点击:
分享到:
1 概述 本文介绍了自然的进程切换和自由的进程切换,通过分析常规的自然(约定)进程调用和返回方式,给出了自己对OS进程调度中常用到的自由进程切换的理解。 本文为可以帮助操

1 概述

本文介绍了自然的进程切换和自由的进程切换,通过分析常规的自然(约定)进程调用和返回方式,给出了自己对OS进程调度中常用到的自由进程切换的理解。

本文为可以帮助操作系统进程调度机制的初学者了解进程调度的工作方式。由于本人知识基础的限制,本文所述的指令(集)仅限于x86体系处理器,各种代码也仅在x86处理器上调试通过。其他体系结构下的指令使用希望各位大侠可以不吝指教一下。

2 术语和缩略语

自然(Normal)调用和返回:相对于自由而言。自然的调用和返回操作是遵守函数调用约定的,可以规范被其他函数直接使用的。自由调用和返回操作是在研究自然操作的基础上为了减少自然调用和返回操作中的多余工作,或者函数返回时不期望回到该函数调用者时所使用的一种更灵活的调用和返回操作序列。

自由(Free)调用和返回:参见自然调用和返回。

X86:PC世界使用最多的一种处理器体系结构,最先由Intel公司设计出来。

进程:程序的一次执行,在没有线程的背景下,它也是CPU调度的基本单位。

指令:汇编级或者机器级代码的最小单位,在X86体系结构中还可以被CPU翻译成其内部执行的最小单位:微指令。指令是软件代码的最小单位。

3 CALL是什么意思?

3.1 现实世界的“CALL”

顾名思义,CALL就是呼叫的意思,人与人之间,如果不考虑别人是否愿意的因素,让别人做个什么事情,其实只需要给TA说一声就OK了,CALL就是这里的说一声的意思。比如老王对张三说“打扫一下B502的卫生”(为了模型简化,省去了人际交往中需要考虑的其他人性化因素,现实社会中你真要这样说别人心甘情愿去做的可是很有限的),这里其实就是一次函数调用:调用者老王,被调用者张三,调用函数打扫卫生,传递参数B502。OK,一次调用就完成了,不过这个模型还是有点复杂了,调用双方现在是一个分布式的关系,即调用双方都是独立的进行自己的活动。那就继续简化模型,例如你自己的工作计划中写着马上去打扫B502的卫生,那么按步骤下来你去打扫卫生相对这之前的其他活动而言就是一次函数调用了,就是一次“CALL”的过程。

3.2 计算机世界的“CALL”

计算机世界的CALL比上面提到的两种模型还要简化,CALL在这里只是一次比较复杂的JMP,一般的跳转指令只需要告诉处理器下一步应该在哪里执行就行了,CALL还需要告诉处理器下一步执行的代码还可能(只是可能,它不一定要遵守这个协定)有做完的时候,那时候“可能”就需要自然返回(所谓自然返回,也就是遵守函数调用约定的返回)现在的工作。那么问题就出来了,一个程序在CPU中正常执行需要的充要条件是什么?

3.3 CPU执行一条指令的充要条件

一般大家提到的会有很多,包括一大堆寄存器(图1中有39个寄存器,可能对标识寄存器存在重复计算的因素,不过即使除去这些重复计算的,至少也有30个)和内存,复杂的计算可能还需要协处理器或者其他IO设备。实际的对每条指令的执行都需要用到的资源只有两个寄存器和一定的内存,这三个寄存器就是CS、EIP和ESP(16位处理器以前称IP和SP,CS存储需要执行代码的段地址,EIP存储需要执行代码的偏移地址,ESP用来存储堆栈的站顶地址,ESP不是对所有指令都必须的寄存器,但对函数调用指令必须),内存只需要能够装下所执行的指令,以及指令执行所涉及的变量就OK了(最简单的指令,如JMP,参数如果是立即寻址的话,连额外的变量存储空间都不需要了)。

图 1 Pentium 4处理器的寄存器

3.4 CALL做了什么

那么一条CALL指令做了什么事情呢?它做的就是对CPU执行指令所需要的充要条件相关因素进行处理,从而保证下一条指令能够正确执行。CALL指令执行需要知道下一步调用的函数的地址(最简单跳转指令JMP需要知道的东东),而在它将CPU执行点给下一步需要执行的函数之前,需要先保存现有执行点的一些信息,最简单的就是CS、EIP和ESP寄存器(自然的,也就是遵守调用约定的,函数调用ESP由调用函数自动计算,可以不存储)。

图 2 执行CALL之前的寄存器和内存使用情况

假定CPU现有代码执行至004014A0时各寄存器的值如下:ESP(008B0010),EIP(004014A0),CS(0000)。如图2所示。为了方便描述,这里假定各条指令的长度都是4字节(远调用为8字节)。

那么执行CALL 004032A0后的各寄存器的值如下:ESP(008B000C),EIP(004032A0),CS(0000)。如图3所示。执行一次近调用后CALL指令会将EIP入栈,并同时更新ESP和EIP的值,由于是近调用,CS寄存器的值不变。

图 3 执行near CALL之后的寄存器和内存使用情况

同理,执行完CALL 0300: 004032A0后的各寄存器值如下:ESP(008B0008),EIP(004032A0),CS(0300)。如图4所示。执行一次远调用后CALL指令会将CS和EIP依次入栈,并同时更新ESP、EIP和CS的值,下一条指令的执行地址由最新的EIP和CS计算出来。

图 4 执行FAR CALL之后的寄存器和内存使用情况

4 RET干了些啥?

图 5 执行RET之后的寄存器和内存使用情况

与CALL指令相对的,RET指令主要进行出栈操作,并更新相应的寄存器。出栈指令RET有四种形式。

(1) RET。可能是近返回也可能是远返回。

(2) RETN。显式指定近返回。

(3) RETF。显式指定远返回。

(4) RET N。同(1),不过ESP另外减去N字节。

图5中给出了最简单的一种调用RET且为近返回调用后寄存器和内存的使用情况。

5 灵活的进程切换需要考虑哪些因素?

5.1 自然的进程切换

通过上面的分析,已经知道如何让CPU在各个进程间进行简单的切换,但这还不够。复杂的进程调用都要处理好寄存器的存储和恢复。在进行自然调用和返回时,一般约定被调用函数考虑自己需要使用到哪些寄存器,对它们分别进行入栈操作;并在函数返回之前进行出栈操作来保证自然调用返回原执行点后上下文完全一致。

如果函数的调用和返回携带参数,则相应流程会更复杂一点,即在CALL之前将相应参数入栈(X86指令中还需要另外增加8个字节的栈空间用来存放参数个数和被调用函数的首地址),被调用函数开始执行其他指令之前先将参数出栈。

5.2 自由的进程切换

自由进程间切换的原理和自然进程间切换是一致的,但由于涉及到堆栈的复杂处理,而且又需要和原有的代码无缝的结合在一起,有几个需要重点注意的。

(1)堆栈切换。自然进程切换各堆栈直接的差距一般相隔几个字节(如果需要传入参数的话)或者基本可以不用考虑堆栈的差距(没有入参,直接返回情况下)。自由的切换则不一样,可能两个进程根本不在同一个代码段内,这时就需要保存现在堆栈的栈顶地址,返回的还需要恢复现在的栈顶地址。

(2)寄存器上下文的保存。要保证自由的切换到另外一个进程后还能自由的回到现在的场景,需要对使原有环境的上下文完整的保存起来。另外对被调用函数来说,调用函数使用哪些寄存器是无法知道的,所以只能将自己需要使用到的寄存器入栈,等调用结束前出栈这些寄存器,以达到和原有进程无缝结合的目的。

6 实际的进程切换汇编代码分析

6.1 SAVE_CONTEXT和LOAD_CONTEXT

下面结合SAVE_CONTEXT和LOAD_CONTEXT的C嵌入汇编代码来解释自由的进程间切换的过程。

#define SAVE_CONTEXT(Stack) \

{ \

__asm push eax /*****************************/ \

__asm push ebx /* */ \

__asm push ecx /* */ \

__asm push edx /* 保存通用寄存器 */ \

__asm push esi /* */ \

__asm push edi /* */ \

__asm push ebp /*****************************/ \

__asm lea eax, SUFFIX_ADDR \

__asm push eax /*保存返回地址 */ \

__asm mov Stack, esp /*保存进程栈*/ \

}

#define LOAD_CONTEXT(Stack) \

{ \

__asm mov esp, Stack \

__asm ret \

__asm SUFFIX_ADDR: \

__asm pop ebp /*****************************/ \

__asm pop edi /* */ \

__asm pop esi /* */ \

__asm pop edx /* 恢复通用寄存器 */ \

__asm pop ecx /* */ \

__asm pop ebx /* */ \

__asm pop eax /*****************************/ \

}

SAVE_CONTEXT用来保存现有进程的上下文。行2~8在现有堆栈中保存所需要用到的通用寄存器值。行9、10将标号SUFFIX_ADDR的地址压入现有堆栈方便上下文恢复时能够得到完整的现有进程的上下文。行11将现有堆栈的站顶指针保存到本地变量中。执行过上述操作前后的堆栈如图6所示。

图 6 执行SAVE_CONTEXT前后的现有进程堆栈变化

LOAD_CONTEXT用来装载需要切换到进程的上下文,此上下文必须是用SAVE_CONTEXT保存或者是用与SAVE_CONTEXT兼容的方式保存的上下文。图7给出了行17的RET指令执行前后进程堆栈的变化。行16将需要切换到进程的栈顶指针复制到ESP中,促成了堆栈的先切换。行17的RET指令导致EIP的修改,即下一条指令执行SUFFIX_ADDR指向地址的代码。行18~25定义了SUFFIX_ADDR标号指向的代码。SUFFIX_ADDR类似与一个子程序,但其调用和返回同自然调用和返回存在很大差异:调用时需要自己更新ESP并RET来实现;返回时并不造成堆栈的切换,只是安装流程继续向下执行,没有进行跳转和上下文切换。

图 7 执行LOAD_CONTEXT RET指令前后需要切换到进程堆栈的变化

6.2 AbnormalReturnToTask和NormalReturnToTask

VOID NormalReturnToTask(T_PCB *ptPCB)

{

WORD32 dwTaskSP;

dwTaskSP = ptPCB->dwTaskSP;

__asm mov esp, dwTaskSP

__asm ret

}

VOID AbnormalReturnToTask(T_PCB *ptPCB)

{

WORD32 dwTaskSP;

WORD32 dwProcSP;

SAVE_CONTEXT(dwProcSP);

ptPCB->dwProcSP = dwProcSP; /*保存进程栈*/

dwTaskSP = ptPCB->dwTaskSP; /*切换到任务栈*/

LOAD_CONTEXT(dwTaskSP);

}

读懂了嵌入汇编比较多的SAVE_CONTEXT宏和LOAD_CONTEXT宏之后,理解下面的正常和异常返回任务的函数就比较容易理解了。

对NormalReturnToTask函数,行31将该T_PCB对象下的dwTaskSP成员的值赋给一个本地变量。行33将此本地变量的值mov到esp寄存器中,实现了堆栈的切换。由于时正常切换,也就是说自然进程切换需要做的操作都已经准备完成,所以这里不另外进行寄存器恢复的工作。行34主动调用ret命令是为了不让编译器给我们产生新的不需要的返回指令(这些指令一般都需要和编译器产生的函数调用相关指令配套使用),从而直接告诉编译器应该如何返回。

有点疑问:如果ptPCB优化成寄存器变量,则行29和行31产生的指令都是多余的指令。假定ptPCB的值存储在eax中,行29~行33的代码可以简化成:

__asm mov esp, [eax,16]

即带偏移的间址直接赋予寄存器,X86是支持这种取址模式的。

对AbnormalReturnToTask,行42调用SAVE_CONTEXT保存现在的进程堆栈地址;行44将该进程堆栈地址存储到ptPCB结构中;行45将ptPCB结构中的任务堆栈地址加载到本地变量dwTaskSP中;行47调用LOAD_CONTEXT恢复相应的堆栈和寄存器上下文。

异常返回堆栈一般是在中途Delay进程时使用,这时需要处理一些进程调用的上下文信息。和异常返回任务所处理的工作相对的,正常返回任务函数一般在函数执行结束后调用,只需要将进程的CPU控制权还给任务即可,所以不需要保存现有的断点信息。


精彩图集

赞助商链接