@TOC
目录
这篇文章会使用C语言来对内存地址空间进行讲解,篇幅很长。
程序在计算机中的运行方式
我们知道程序是保存在硬盘中的,要载入内存中才能运行,CPU也被设计为只能从内存中读取数据和指令。
对于CPU来说,内存仅仅是一个存放指令和数据的地方,并不能在内存中完成计算,例如要计算a + b = c ,必须将a, b, c 都读取到CPU内部才能进行加法运算,为了方便下面的理解,下面我们来说一下CPU的结构。
寄存器:CPU内部非常小,非常快速的存储部件,它的容量很有限,对于32位的CPU,每个寄存器一般可以存储32位 (4字节)的数据,对于64位的CPU,每个寄存器一般能存储64位(8个字节)的数据,为了完成各种复杂的功能,现代的CPU内置了几十甚至几百个寄存器,嵌入式系统的功能比较单一,,所以寄存器的数量也少。
寄存器在程序的执行过程中至关重要,他们可以用来实现数学运算,控制循环次数,控制程序的执行流程,以及标记CPU运行状态等。例如:EIP寄存器就是用来存储下一个指令的地址,CPU执行完当前指令后,会根据EIP的值找到下一条指令,改变EIP的值,就能改变程序的执行流程。CR3寄存器保存着当前进程页目录的物理地址,切换进程都会改变CR3的值。EBP和ESP寄存器用来指向栈的底部和顶部,函数调用会改变EBP和ESP的值。
因为CPU的运行速度个别快,即便内存的读取速度也很快,但是和 CPU的运行小效率仍然不在一个量级,如果每次从都从内存中读取数据,CPU的运行效率会因为内存读取的速度而产生巨大的影响,因为CPU的运行效率远高于内存读取速度所以CPU会经常处于无事可干的状态,因此,在上图中,在寄存器和内存之间有一个缓冲区,可以将使用频繁的数据暂时存放到缓冲区里面,需要调用同一块地址时,就不需要大老远再去访问内存,直接从缓冲中读取即可。
CPU的指令
上图就是一个很简单的加法代码,尽管是一个简单的加法代码但是汇编语言要比C语言复杂很多
int a = 1, b = 2;
mov dword ptr [a] 1 //将1移动到ptr[a]指向的内存
mov dword ptr [b] 2 //将2移动到ptr[b]指向的内存
int c = a + b;
mov eax dword ptr [b] //将b内存中的数据移动到寄存器eax
mov ecx dword ptr [a] //将a内存中的数据移动到寄存器ecx
add ecx,eax //ecx和eax相加,将值存进ecx中
mov eax,ecx //将ecx的值移动到eax
mov dwprd ptr [c] eax //将eax中的值移动到ptr[c]指向的内存
虚拟内存是什么?
在C语言中,指针变量的值就是一个内存地址,&运算符的作用也是取变量的内存地址
a, b都是全局变量,他们的内存地址在链接时就已经决定了,以后再也不能改变,该程序无论何时运行,结果都是一样的。
假如这两个地址被其他程序占用了?我们程序是不是就不能运行了?
所以我们可以把进程所使用的地址隔离开来,即让操作系统为每个进程分配独立的一套虚拟地址,人人都有,大家自己玩自己的空间,但是每个进程都不可以访问物理地址,至于虚拟地址最终怎么落在物理内存中,对进程来说是透明的,操作系统已经把这些都安排的明明白白的
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了
于是这里就引来了两种地址的概念:
我们程序说使用的内存地址称为虚拟内存空间
实际存在硬盘里面的空间地址叫做物理内存地址
CPU的数据处理能力
CPU是计算机的核心,决定计算机的处理能力和寻址能力,也就是决定了计算机的性能。CPU一次能处理的数据的大小由寄存器的位数和数据总线的宽度(也即有多少个数据总线)决定,我们经常说的多少位的CPU,除了可以理解为寄存器的位数,也可以理解为数据总线的宽度,通常情况下它们是相同的。
数据总线位于主板之上,不在CPU中,也不由CPU决定,严格来说,这里应该说CPU能够支持的数据总线的最大根数,也就是最大的数据处理能力。
数据总线和主频都是CPU的重要指标,数据总线决定了CPU单次的数据处理能力,主频决定了CPU单位时间内的数据处理次数,它们的乘积就是CPU单位时间内的数据处理量。
数据总线和地址总线的不是同一回事,数据总线用于在CPU和内存之间传输数据,地址总线用于在内存上定位数据,它们之间没有必然联系,宽度并不一定相同,实际情况是,地址总线的宽度往往随着数据总线的宽度而增长,以访问更大的内存。
(1)16位CPU
早期的CPU是16位的,一次能处理16bit(2个字节)的数据。
在计算机发展的早期阶段,16位CPU曾经是非常流行的,因为它们比8位CPU更快、更强大,同时又比32位CPU更便宜。这时候还没有提出虚拟地址的概念
16位CPU的应用范围主要是在嵌入式系统、控制器、小型计算机和游戏机等领域。由于其成本低廉、功耗低、体积小,因此在这些领域有着广泛的应用。
然而,随着计算机技术的不断进步,16位CPU已经逐渐被32位CPU所取代。32位CPU具有更高的性能、更大的内存访问能力和更好的扩展性,因此在现代计算机中得到了广泛应用。
经典的16位 处理器intel 8086的数据总线有16位,地址总线有20根,寻址能力2^20 = 1MB。
(2)32位CPU
32位CPU一次可以处理32bit (4个字节)的数据。这时候提出来了虚拟地址的概念,并被应用在CPU和操作系统中,由他们共同完成的虚拟地址和物理地址的映射,这使得程序编写更加容易,运行更加安全。
(3)64位CPU
现代的计算机使用的都是64位CPU,它们一次可以处理64bit (8个字节)的数据。
编译模式
为了兼容不同的平台,现代编译器大都提供两种编译器:32位模式和64位模式
如下是VS2109的模式转换
32位编译模式
在32位模式中,一个指针或地址占用4个字节的内存,共有32位,理论上能够访问的虚拟内存空间大小为2^32字节,即4GB,有效虚拟内存范围是0 ~ 0XFFFFFFFF。
也就是说,对于32位的编译模式,不管实际物理内存有多大,程序能够访问的有效虚拟地址空间范围就是0 ~ 0XFFFFFFFF,也即是虚拟地址空间的大小是4GB,换句话说,程序能够使用的最大内存为4GB,跟物理内存没有关系。
如果程序需要的内存大于物理内存,或者内存中剩余的空间不足以容纳当前程序,那么操作系统会将内存中暂时不用的一部分数据写入到磁盘中,等需要的时候在拿出来。
如果程序需要的内存小于物理内存,那么就没有什么问题了,程序他只能使用他的4GB。
64位编译模式
在64位编译模式中,一个指针或地址占用8个字节的内存,共有64位,理论上能够访问的虚拟内存空间大小为2^64,这是 一个很大的值,几乎是无限的,就目前的技术来说,物理内存和CPU的寻址能力都没有这么大, 实现64位长的虚拟地址空间完全没有意义,所以受到了特别多的限制。
实际可用的虚拟地址空间大小受多种因素限制。其中一个主要因素是操作系统的限制。不同的操作系统可能对每个进程可用的虚拟地址空间大小设置不同的限制。例如,在Windows操作系统中,64位进程通常可以使用约8 TB(8 × 2^40 字节)的虚拟地址空间。而在Linux操作系统中,64位进程通常可以使用更大的虚拟地址空间,最多可达到128 TB(128 × 2^40 字节)。
此外,还有其他因素会进一步减小可用的虚拟地址空间。例如,一些地址空间可能被保留给操作系统本身使用,或者用于映射设备、共享库等。因此,实际上程序可以使用的虚拟地址空间可能会比理论上的最大值小一些。
需要注意的是,虚拟地址空间的大小不等同于物理内存的大小。虚拟地址空间是程序视图中的地址空间,而物理内存是实际的硬件内存。操作系统通过使用页表和内存管理单元(MMU)将虚拟地址映射到物理内存中的实际位置。
总结起来,在64位编译模式下,程序可以使用的虚拟地址空间理论上非常大,但实际可用的大小会受到操作系统和其他因素的限制。
C语言内存对齐
计算机内存是以字节为单位划分的,理论上CPU可以访问访问任何编号的字节,但实际情况并非如此。
CPU通过地址总线来访问内存,一次能处理几个字节的数据,就命令地址总线读取几个字节的数据。32位的CPU一次可以处理4个字节的数据,那么每次就从内存读取4个字节的数据;少了浪费主频,多了没有用。64位的处理器也是这样的,每次读取8个字节。
以32位CPU为例,实际寻址的步长为4个字节,也就是说只对编号为4的倍数的内存寻址,如 0,4,8,12……
这样做可以以最快的速度寻址:不遗漏一个字节,不重复一个字节
对于程序来说,一条变量最好位于一个寻址步长的范围之内,这样一次就可以读到变量的值,如果跨步长存储就需要读两次,然后再拼接数据,效率就会变得变低。
例如一个int类型的数据是占4个字节,如果前面有一个short类型的变量占用了编号0-1的字节空间,那么如果要再插入一个int类型的变量,如果紧跟着其后面插入就得读取两次才能读取int的完整数据,比较直观的就是C语言中的结构体的内存对齐,还有C++里面的类和对象里面的内存对齐,都是为了避免跨步长存储,再32位编译模式中,默认以4字节对齐,在64位编译模式中,默认以8字节对齐。
struct Person
{
char name[10];// 这个字符串数组占10个字节
int age; //因为现在处于32位的编译环境之下,10之后4的倍数是12,所以在12处加入age变量
};
int main()
{
struct Person a = {"0000000000", 0};
printf("%d",sizeof(a));//12 + 4 = 16
return 0;
}
内存分页机制
虚拟地址和物理地址的映射主要有两种方式分别是内存分段和内存分页。
内存分段机制
程序是由若干个逻辑分段组成的,如可由代码分段,数据分段,栈段,堆段组成。不同的段是有不同的属性的,所以就用分段的形式把这些段分离出来。
分段机制下,虚拟地址和物理地址是如何映射的?
分段机制下的虚拟地址由两个部分组成,段选择因子和段内偏移量。
段选择因子和段内偏移量:
段选择子就保存在段寄存器里面,段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址,段的界限和特权等级等。
虚拟地址中的段内偏移量应该位于0和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
在上面我们知道,虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分为了4个段,每个段在段表中中都有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址。
如果要访问段3的物理内存,首先根据段号提取段基地址以及段界限,得到段3的范围是7000~8 000。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但他也有一些不足之处:
(1)内存碎片问题
(2)内存交换的效率低
为什么会产生内存碎片?
假如我们这边有1GB的物理内存空间,并且正好在运行好几个程序:
浏览器占了512MB,聊天软件占了128MB,办公软件占了256MB,这些程序加到一起1GB的内存就只剩下256MB了,如果这些程序所占的内存不是连续的,浏览器和聊天软件之间有100MB,办公软件和聊天软件之间有156MB的空隙,我工作累了,想玩会儿游戏,就直接打开游戏,发现内存不够了,游戏要200MB,我要想玩只能选择关掉其中一个程序。假如每个程序都是连续的,那游戏就可以正常打开。
内存分段会出现内存碎片吗?
内存碎片主要分为,内部内存碎片和外部内存碎片。
内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片。
但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所有会出现外部内存碎片的问题。
解决外部内存碎片的问题就是内存交换。
可以把聊天软件程序占用的128MB内存写到硬盘中,然后再从硬盘中读回去内存里,不过再读回去的时候,我们不能存到原来的位置,而是紧紧跟着那已经被占用了的512MB,这样就能空缺出来256MB空间,于是游戏程序就可以装载进去。
这个内存交换空间,在Linux系统中,也就是我们常看到的Swap空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
分段为什么会导致内存交换效率低的问题?
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得重新Swap内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘中。
所以如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的”外部内存碎片和内存交换效率低“ 的问题,就出现了内存分页。
内存分页机制
假如,当一个程序运行时,在某一个时间段内,它只能频繁地用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内都不会被用到,比较直观的例子就是,在我们玩游戏的时候,地图只是在主角所在的区域内加载出来,别的地方并没有被加载到内存中,也就是一片空白。
以整个程序位单位进行映射,不仅会暂时用不到的数据从磁盘中读取到内存,也会将过多的数据一次性读入磁盘,这会严重降低程序的运行效率。
现代计算机都使用分页的方式对虚拟地址空间和物理地址空间进行分割和映射,以减小换入换出的粒度,提高程序运行效率。
分页的思想是指把地址空间人为地分成大小相等并且固定的若干份,这样的一份称为一页,就像一本书里面有很多页组成,每个页面的大小相等,如此,就能够以页为单位对内存进行换入换出:
当程序运行时,只需要将必要的数据从磁盘读取到内存中,暂时用不到的数据先留在磁盘中,什么时候用到什么时候读取。
当物理内存不足时,只需要将原来程序的部分数据写入磁盘,腾出足够的空间即可。不用把整个程序都给写入磁盘
关于页的大小
页的大小是固定的,由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小,目前来说几乎所有的PC机上的操作系统都是用4kb大小的页,假如我们使用的PC机是32位的,那么虚拟地址空间总共有4GB,按照4KB每页分的话,总共有2^20的页。
根据页进行映射
程序1和程序2的虚拟地址空间都有8个页,假如这每个页的大小都是1KB,那么虚拟地址空间就是8KB,假如该计算机拥有32条地址总线,即拥有2^32次方的物理寻址能力,那么理论上物理空间可以多达8KB,但是出于种种原因,购买内存的资金不够,只买得起 6KB 的内存,所以物理空间真正有效的只是前 6KB。
当我们把程序的虚拟空间按页分隔后,把常见的数据和代码页加载到内存中,把不常见的暂时留在磁盘中,当需要用到的时候再从磁盘中读取,上图中,我们写了两个代码,,程序1的vp0,vp1,vp7分别对应物理内存中的pp0,pp2,pp3,而不用的部分就是被放入到磁盘之中还未读取,处于暂时未使用状态,比如vp2位于dp0中,vp3位于dp1中。
- 这里,我们把虚拟空间的页叫做虚拟页,把物理内存中的页叫做物理页,把磁盘中的页叫做磁盘页。
页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
内存分页如何解决分段“外部内存碎片和内存交换效率低”的问题
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。
但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
分页机制是如何实现的?
直接使用数组转换
和上面一部分画的一样,我们最容易想到的就是使用数组,数组的每个元素保存一个物理地址,而将虚拟地址作为数组下标,这样就能够容易地完成映射,并且效率不低。
但是这样的数组有 2^32 个元素,每个元素大小为4个字节,总共占用16GB的内存,显现是不现实的!
使用一级页表
既然内存是分页的,只要我们能够定位到数据所在的页,以及它在页内的偏移(也就是距离页开头的字节数),就能够转换为物理地址。例如,一个 int 类型的值保存在第 12 页,页内偏移为 240,那么对应的物理地址就是 2^12 * 12 + 240 = 49392。
2^12 为一个页的大小,也就是4K。
虚拟地址空间大小为 4GB,总共包含 2^32 / 2^12 = 2^20 = 1K * 1K = 1M = 1048576 个页面,我们可以定义一个这样的数组:它包含 2^20 = 1M 个元素,每个元素的值为页面编号(也就是位于第几个页面),长度为4字节,整个数组共占用4MB的内存空间。这样的数组就称为页表(Page Table),它记录了地址空间中所有页的编号。
虚拟地址长度为32位,我们不妨切割一下,将高20位作为页表数组的下标,低12位作为页内偏移
例如一个虚拟地址 0XA010BA01,它的高20位是 0XA010B,所以需要访问页表数组的第 0XA010B 个元素,才能找到数据所在的物理页面。假设页表数组第 0XA010B 个元素的值为 0X0F70AAA0,它的高20位为 0X0F70A,那么就可以确定数据位于第 0X0F70A 个物理页面。再来看虚拟地址,它的低12位是 0XA01,所以页内偏移也是 0XA01。有了页面索引和页内偏移,就可以算出物理地址了。经过计算,最终的物理地址为 0X0F70A * 2^12 + 0XA01 = 0X0F70A000 + 0XA01 = 0X0F70AA01。
这看起来没什么问题,但是放在实际中,这么肯定会出现一些问题:空间的缺陷
因为操作系统是可以同时运行非常多的进程的,那就是说页表会变得非常大。
比如在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB
的内存来存储页表。
这只是一个页表就需要这么大,如果有100个进程,就得400MB特别大·。
使用多级页表
要解决上面的问题,就需要采用一种叫作多级页表的解决方案。
在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB
的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024
个页表(二级页表),每个表(二级页表)中包含 1024
个「页表项」,形成二级分页。如下图所示:
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB
,这对比单级页表的 4MB
是不是一个巨大的节约?
Linux下C语言程序的内存布局
在编译模式中我们有讲到,32位环境下,理论上程序拥有4GB内存的空间, 那么我们的程序在整个地址中是如何分布的呢?这就是这小节的内容:
内核空间和用户空间
对于32位环境,理论上程序可以拥有 4GB 的虚拟地址空间,我们在C语言中使用到的变量、函数、字符串等都会对应内存中的一块区域。
但是,在这 4GB 的地址空间中,要拿出一部分给操作系统内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。
Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可以配置为1GB),而 Linux 默认情况下会将高地址的 1GB 空间分配给内核。也就是说,应用程序只能使用剩下的 2GB 或 3GB 的地址空间,称为用户空间。
Linux下用户空间内存分布情况
该图是在32位环境之下
对各个内存分区的说明:
内存分区 | 说明 |
程序代码区 | 存放函数体的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。 |
常量区 | 存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限,因此它们的值在程序运行期间不能改变。 |
堆区 | 一般由程序员分配和释放,若程序员不释放,程序运行结束时由操作系统回收。malloc()、calloc()、free() 等函数操作的就是这块内存,这也是本章要讲解的重点。 注意:这里所说的堆区与数据结构中的堆不是一个概念,堆区的分配方式倒是类似于链表。 |
动态链接库 | 用于在程序运行期间加载和卸载动态链接库。 |
栈区 | 存放函数的参数值、局部变量的值等,其操作方式类似于数据结构中的栈。 |
程序代码区用来保存指令。
常量区,全局数据区,堆,栈都用来保存数据。对内存的研究,重点是对数据分区的研究。
程序代码区,常量区,全局数据区在程序加载到内存后就分配好了,并且在程序中一直存在,不能销毁也不能增加(大小已被固定),只能等到程序运行结束后由操作系统收回,所以全局变量、字符串常量等在程序的任何地方都能访问,因为它们的内存一直都在。
常量区和全局数据区有时也被合称为静态数据区,意思是这段内存专门用来保存数据,在程序运行期间一直存在。
一说到静态数据区我们就能想到static int,没错static就是把一个局部变量转换成全局变量
函数被调用时,会将参数、局部变量、返回地址等与函数相关的信息压入栈中,函数执行结束后,这些信息都将被销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部,因为它们的内存不在了。
常量区、全局数据区、栈上的内存由系统自动分配和释放,不能由程序员控制。程序员唯一能控制的内存区域就是堆(Heap):它是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间中,程序可以申请一块内存,并自由地使用(放入任何数据)。堆内存在程序主动释放之前会一直存在,不随函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用。
下面是在Linux操作系统下,内存布局情况:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 100;
int g_unval;
int main(int argc, char *argv[], char *env[])
{
//int a = 10;
//字面常量
const char* str = "hello world";
//10
//'a'
int i = 0;
printf("code addr: %p\n", main);
printf("init global addr: %p\n",&g_val);
printf("uninit global addr:%p\n",&g_unval);
static int test = 10;
char* heap_mem = (char*)malloc(10);
char* heap_mem1 = (char*)malloc(10);
char* heap_mem2 = (char*)malloc(10);
char* heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem);
printf("heap addr: %p\n", heap_mem1);
printf("heap addr: %p\n", heap_mem2);
printf("heap addr: %p\n", heap_mem3);
printf("test stack addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
printf("read only string addr: %p\n", str);
for(i = 0 ;i < argc; i++)
{
printf("argv[%d]: %p\n", i, argv[i]);
}
for(i = 0; env[i]; i++)
{
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}
ps:上面我们把代码拆分,看了一下他在内存中是怎么存在的,那么有一个问题:程序在被编译的时候没有被加载到内存,那么程序内有没有地址呢?
答: 源代码再被编译的时候就是按照虚拟地址空间的方式将对应的代码和数据进行编制,编译器也会遵守虚拟地址的规则。
当我们把程序加载到内存,程序里保存的地址(虚拟地址,并不是程序本身在内存中的物理地址)就会被cpu读取,cpu通过虚拟地址找到对应的虚拟地址空间,然后虚拟地址空间又通过页表映射到物理内存中,这样就将程序的整个运转给联系起来了。