EXE文件的运行机制
EXE文件作为本地代码的程序,并没有指定变量及函数的实际内存地址。在类似于Windows操作系统这样的可以加载多个可执行程序的运行环境中,每次运行时,程序内的变量及函数被分配到的内存地址都是不同的。那么,EXE文件中,变量和函数的内存地址的值,是如何来表示的呢?
下面就让我们来揭晓答案。那就是EXE文件中给变量及函数分配了虚拟的内存地址。 在程序运行时,虚拟的内存地址回转换成实际的内存地址。链接器会在EXE文件的开头,追加转换内存地址所需的必要信息。这个信息称为再配置信息
EXE文件的再配置信息,就成为了变量和函数的相对地址。相对地址表示的是相对于基点地址的偏移量,也就是相对距离。实现相对地址,也是需要花费一番心思的。在源代码中,虽然变量及函数是在不同位置分散记述的,但在链接后的EXE文件中,变量及函数就会变成一个连续排列的组。
这样一来,各变量的内存地址就可以用相对于变量组起始位置(全局区,堆栈区)这一基点的偏移量来表示,同样,各个函数的内存地址也可以用相对于函数组起始地址(代码区)这一基点的偏移量来表示。而各组基点的内存地址则是在程序运行时被分配的。
程序加载时会生成栈和堆
EXE文件的内容分为在配置信息、变量组和函数组。不过,当程序加载到内存后,除此之外还会额外生成两个组,那就是栈和堆。
- 栈是用来存储函数内部临时使用的变量(局部变量),以及函数调用时所用的参数的内存区域。
- 堆时用来储存程序运行时的任意数据及对象的内存领域。
EXE文件中并不存在栈和堆的组。栈和堆需要的内存空间是在EXE文件加载到内存后开始运行时得到分配的。因而,内存中的程序,就是由用于变量的内存空间、用于函数的内存空间、用于栈的内存空间、用于堆的内存空间这4部分构成的。
通过汇编了解程序的实际构成
//返回两个参数值之和的函数
int AddNum(int a,int b)
{
return a+b;
}
//调用AddNum函数的函数
void MyFunc()
{
int c;
c=AddNum(123,456);
}
反汇编与反编译不同。由本地代码转换位汇编语言的源代码称为反汇编,而转为其他高级语言的源代码称为反编译。因为其他高级语言的编译器在编译时加入了很多优化操作,源代码与本地代码不是一一对应的(比如变量初始化了却没有使用,编译器将自动将其优化),所以反编译比反汇编困难很多。
_TEXT segment dword public user32 'CODE'
_TEXT ends
_DATA segment dword public user32 'DATA'
_DATA ends
_BSS segment dword public user32 'BSS'
_BSS ends
DGROUP group _BSS,_DATA
_TEXT segment dword public user32 'CODE'
_AddNum proc near
;
; int AddNum(int a,int b)
;
push ebp
move ebp,esp
;
; {
; return a+b;
;
move eax,dword ptr [ebp+8]
add eax,dword ptr [ebp+12]
;
; }
;
pop ebp
ret
_AddNum endp
_MyFunc proc near
;
; void MyFunc()
;
push ebp
mov ebp,esp
;
; {
; int c;
; c = AddNum(123,456);
push 456
push 123
call _AddNum
add esp,8
;
; }
;
pop ebp
ret
_MyFunc endp
_TEXT ends
end
由伪指令segment和ends围起来的部分,是给构成程序的命令和数据的集合体加上一个名字而得到的,称为段定义。
源代码开始的位置,定义了三个名称为_TEXT,_DATA,_BSS的段定义。_TEXT是指令的段定义,_DATA是被初始化(有初始值)的数据的段定义,_BSS是尚未初始化的数据的段定义。此外,栈和堆的内存空间会在程序运行时生成。
围起的_AddNum
和_MyFun
(两个函数的汇编头)的_TEXT segment
和 _TEXT ends,
表示_AddNum
和_MyFun
是属于_TEXT
这一段定义的。因此,即使在源代码中指令和数据是混杂编写的,经过编译或汇编后,也会转换成段定义划分为整齐的本地代码。
函数的调用机制
让我们一起分析下MyFunc函数的处理。
(3)
和(4)
表示的是将传递给AddNum函数
的参数通过push入栈。其对应AddNum(123,456)
,但是入栈会按照456,123这样的顺序,也就是位于后面的数值先入栈。(5)
的call指令,把程序流程跳转到了操作数中指定的AddNum函数所在的内存地址。AddNum函数处理完毕后,程序流程必须要返回到编号(6)
这一行。call
指令运行后,call指令的下一行((6)
这一行)的内存地址(调用函数完毕后要返回的内存地址)会自动地push
入栈。该值会在AddNum函数处理的最后通过ret指令pop出栈,然后程序流程就会返回到(6)
这一行。
(6)
部分会把栈中存储的连个参数(456
和123
)进行销毁处理。虽然通过使用两次pop指令也可以实现,不过采用esp寄存器
加8的方式会更有效率(处理一次即可)。对栈进行数值的输入输出时,数值的单位是4字节。因此,通过在负责栈地址管理的esp寄存器
中加上4的2倍8,就可以达到和运行 两次pop命令同样的效果。
在C语言的源码中,有一个处理是在变量c中存储AddNum函数的返回值。不过在汇编语言的源代码中,并没有与之对应的处理,这是因为编译器有最优化功能。由于存储着AddNum函数
的返回值变量c
在后面没有用到,因此编译器就会认位“该处理没有意义”。
接着我们看下AddNum函数的源代码部分。看下函数的接收、函数返回值机制。
ebp寄存器
的值在(1)
中入栈(储存函数调用之前的状态),在(5)
中出栈。这主要是为了把函数中用到的ebp寄存器
的内容恢复到函数调用之前的状态。
(2)
中把负责管理栈地址的esp寄存器
的值赋到了ebp寄存器
中。这是因为,在mov指令中方括号内的参数,是不允许指定esp寄存器
的。因此,这里就采用不直接通过esp
,而是用esp寄存器
来读写栈内容的方法。
(3)
是用[ebp+8]
指定栈中存储的第一个参数123,并将其读出到eax寄存器
。通过(4)
的add指令,把当前eax寄存器
的值同第二个参数相加的结果存储在eax寄存器
中。在C语言中,函数的返回值必须通过eax寄存器
返回,这是规定。
(6)
中的ret指令运行后,函数返回的目的地的内存地址就会自动出栈,据此,程序流程就会跳转到Call _AddNum的下一行。
接下来,我们来分析全局变量与局部变量在内存分配的不同。
//定义被初始化的全局变量
int a1=1; int a2=2; int a3=3;
int a4=4; int a5=5;
//定义没有初始化的全局变量
int b1,b2,b3,b4,b5;
void Func(){
//定义局部变量
int c1,c2,c3,c4,c5,c6,c7,c8,c9,c10;
//给局部变量赋值
c1=1; c2=2; c3=3; c4=4; c5=5;
c6=6; c7=7; c8=8; c9=9; c10=10;
//把局部变量的值赋给全局变量
a1=c1; a2=c2; a3=c3; a4=c4; a5=c5;
b1=c6; b2=c7; b3=c8; b4=c9; b10=c10;
}
全局变量的内存空间的分配
将上面代码转为汇编
编译后的程序,会被归类到名为段定义的组(包括_TEXT,_DATA,_BSS)。初始化的全局变量会像(1)
那样,会被汇总到_DATA的段定义,没有初始化的全局变量会像(2)
那样,被汇总到_BSS的段定义。指令则会像(3)
那样汇总到_TEXT的段定义中。
dd1
指的是,申请分配了4字节的内存空间,储存着1
这个初始值。dd表示dd
表示的是有两个长度为2的字节领域(word),也就是4个字节的意思。
(6)
的db 4dup(?)
表示的是申请分配了4字节的领域,但值尚未确定(这里用?来表示)的意思。db(define byte)表示有1个长度是1字节的内存空间。因而,db 4 dup(?)
的情况下就是4字节的内存空间。
这里大家要注意不要和dd 4
混淆了。db 4 dup(?)
表示的是4个长度是1字节的内存空间,而dd4
表示的则是双字节(=4字节)的内存空间中存储的值是4。
局部变量的内存空间的分配
为什么局部变量只能在定义该变量的函数内有效呢?这是因为,局部变量是临时保存在寄存器和栈中的。
因为函数内部利用的栈,在函数处理完毕后会恢复到初始状态。因此局部变量的值也被销毁了,而寄存器也可能会被用于其他目的。因此,局部变量只是在函数处理运行期间临时存储在寄存器和栈上。
在之前代码中定义了10个局部变量。这是为了表示存储局部变量不仅仅是栈,还有寄存器。为了确保c1~c10
所需的领域,寄存器空闲时就使用寄存器,寄存器空间不足的话就使用栈。
我们先看看_TEXT段定义
的内容。(7)
表示的是Myfunc函数
的范围。在MyFunc函数
中定于的局部变量需要的内存领域,会被尽可能地分配在寄存器中。大家可能会认为用高性能的寄存器来替代普通的内存是奢侈的事情。不过编译器不会这么认为,只要寄存器有空间,编译器就会使用它。因为与内存相比,使用寄存器时访问速度会高很多,这样就可以更快速的进行处理。
局部变量利用寄存器时编译器优化的结果,旧的编译器可能没有类似优化,局部变量就可能会仅仅使用栈。
代码(8)
表示的往寄存器中分配局部变量的部分。仅仅对局部变量进行定义时不够的,只有在给局部变量赋值时,才会被分配到寄存器的内存区域。(8)
就相当于给5个局部变量c1~c5
分别赋予数值1~5
这一处理。eax、edx、ecx、ebx、esi
是Pentium等X86系列32位CPU寄存器的名称。至于使用哪一个寄存器,则要由编译器决定。这种情况下,寄存器只是单纯用于存储变量的值,和其本身的角色没有任何关系。
x86系列CPU拥有的寄存器中,程序可以操作的有十几个。其中空闲的,最多也只有几个。因而,局部变量数目很多的时候,可分配的寄存器就不够了。这种情况下,局部变量就会申请栈的内存空间。虽然栈的内存空间也是作为一种存储数据的段定义来处理的,但是在程序各部分都可以共享并临时使用这一点是,它和_DATA段定义
及_BSS段定义
在性质上还是有些差异的。
例如,在函数入口处为变量申请分配栈的内存空间的话,就必须在函数出口处进行释放,否则经过多次调用函数后,栈的内存空间就会被用光了。
在(8)
这一部分中,给局部变量c1~c5
分配完寄存器后,可用的寄存器数量就不足了。于是,剩下的5个局部变量c6~c10
就被分配了栈的内存空间,如(9)
所示。函数的入口(10)
处的add esp,-20
指的是,对栈数据存储位置的esp寄存器(栈指针)的值做减20的处理。为了确保内部变量c6~c10
在栈中,就需要保留5个int
类型的局部变量(4字节x5=20字节)所需的空间。(11)
中的mov ebp,esp
这一处理,指的是把当前esp寄存器
的值复制到ebp寄存器
中。之所以需要(11)
这一处理,是为了通过函数出口处的(12)
这一move esp,ebp
的处理,把esp寄存器
的值还原到原始状态,从而对申请分配的占空间进行释放,这时栈中用到的局部变量就消失了。这也是栈的清理处理。
了解多线程加锁的原因
如果没有前面的知识,我们可能对多线程要加锁感到奇怪。
//定义全局变量
int count=100;
//定义MyFunc1函数
void MyFunc1(){
count *= 2;
}
//定义MyFunc2函数
void MyFunc2(){
count *= 2;
}
转为汇编:
mov eax,dword ptr[_count] ;将count的值读入eax寄存器
add eax,eax ;将eax寄存器的值扩大为原来的2倍
mov dword ptr[_count] ;将eax寄存器的值存入count中
参考资料:《程序是怎样跑起来的》 (日) 矢泽久雄