程序执行中的地址分配

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)部分会把栈中存储的连个参数(456123)进行销毁处理。虽然通过使用两次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中

在这里插入图片描述



参考资料:《程序是怎样跑起来的》 (日) 矢泽久雄

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值