载入到内存中不就会把另一个程序的指令覆盖了吗

补充:是经过objdump以后,每条指令前面都有的那个地址,一般是0x400000左右,然后我还发现一些常数一般在0x600000左右,然后rsp栈顶指针一般是0x7fffffffff....


这要分两大类三种情况讨论。

第一大类是实模式下跑的各种应用,此时代码中的地址就是物理地址。

这个大类又分两种情况。

一是单片机那样,地址就是最终的物理地址,将来就要分毫不差的写进内存晶元对应的单元里。这种情况下自然不可能完成同时载入两个地址冲突的程序这样的任务。

二是DOS那样,程序中的地址并不是最终的物理地址,而是所谓的「相对地址」;OS载入程序时必须先做一个「重定位」,比如前200k的内存被别人占用了;那么当载入你的程序时,就从201K开始(同时把201K这个值载入基址寄存器)。当遇到跳转指令时,只需把里面写死的相对地址提取出来、加上基址寄存器的值(也就是201K)即可。这就是运行时的重定位(对初步编译出的.o/.obj文件来说,在链接时也需要做一次重定位,原理类似,也是给里面的所有涉及到的地址加上一个固定的偏移;但这次重定位是静态的,和载入执行时的动态重定位不一样)。

实际上,现在的内核代码仍然需要跑在实模式下,因此运行时的动态重定位仍然是必需的;另外相对定址也有很多种不同手法,这里仅仅是个原理性的简化说法。

第二大类是虚地址模式下跑的各种应用,也就是第三种情况。

这个模式下,OS一般会事先做一个约定,比如内存前1G(或更大空间)属于OS,其它空间属于应用。真正运行时,通过虚拟地址映射机制,OS内容就被映射到了约定好的空间、而用户应用则从约定好的起始点开始顺序载入(实践中也可能故意在每次载入时都偏移一个随机值,这样万一有缓冲区溢出之类漏洞,黑客们想完成攻击就没那么容易了。毕竟重定位是个早已被解决的问题,不用白不用)。

借助虚拟地址机制,每个应用都会「觉得」自己独占了全部内存空间,并不能意识到其它应用的存在。

所谓虚拟地址机制,说白了和实模式下的「基址相对定址」是一个原理;只是现在负责内存映射的寄存器对应用来说不再可见了而已——实模式下,应用知道自己被载入到了201K开始处,因此当它要访问内存地址1235时,实际访问的是201K+1235处,所有一切它都知道的清清楚楚;而虚模式下,应用以为自己就是从0开始载入的,访问1235就是1235,并不知道实际上OS「偷偷」替它加了个偏移值、使它访问到了正确的地方(这个偏移值被OS自动维护在「页表」里,每个进程都有独属于自己的一套单独的页表;当这仍然是个简化的、原理性的说法,实际上还有页式管理、段页式管理等不同分类,而且不同的CPU支持的管理方案也有所不同)。

有了虚拟地址机制,当系统资源不足时,某些应用的某些内存页面就可能被暂存于磁碟(对windows,默认是c:pagefile.sys;当然你可以通过配置改变位置),从而尽量腾出内存条空间给前台应用——当你在内存小于8G(甚至4G)的电脑上同时开N个巨耗内存的应用时,就会发现机器运行缓慢、硬碟灯不停的闪烁(同时伴随著咯吱咯吱的读盘声,假如你用的还是机械硬碟的话)。原因就是内存不足,OS不得不在内存和硬碟间不停倒腾,牺牲速度尽量保证任务完成。这个过程中,程序被换出/换入内存的那部分页面就必须更新它在页表中的索引信息,这才能随便搬哪都不妨碍程序的运行。

借助类似手法,一个运行中的应用甚至可以临时被储存到磁碟上、然后断电关机;等下次计算机重启后再从磁碟载入这个应用对应的内存数据(到随便什么地方)、让这个应用从上次关机的地方继续运行(比如你玩枪战游戏时,可以在枪榴弹出膛时休眠断电、等半个月后重新加电开机,你会看见画面上那颗枪榴弹继续飞向目标……)——当然,虽然原理相同,但这时候用的可能并不是交换文件(比如,如果你没有改过配置的话,Windows下就是C盘根目录下的hiberfil.sys)。


两位高赞答主说的非常全面了,我写一段省流量简化版。

题主已经发现了矛盾点,出现矛盾的原因是:「c语言程序经过编译后,每条指令都有一个内存地址」,这句问题描述是错误的。

编译的机会只有一次,如果在编译时直接指定代码的内存地址,也就是「指定代码放在内存的哪里」,显然是不可能的。

就像去学校澡堂洗澡。每次去洗澡,澡堂大叔会挑一个空衣柜给你钥匙,即动态给你分配一个编号。这个编号每次去都会变。

同样的原理能否用在程序载入时呢?最简单的办法就是——编译器仅初步指定代码的内存位置,假设代码从0开始,那么第一句代码在地址1,第2句代码在地址2,第一个跳转去100。这样做,把相对位置先定下来。

实际载入程序运行的时候,操作系统会挑一块能放下程序的空闲空间给你,比如给了你22222起始地址。那么前面的1、2、100就顺势改为22223、22224、22322,即可。

以上说的就是纯粹的原理了,无论在什么系统上,其本质原理不会变。

至于实用化的操作系统,会将上述过程封装再封装,形成像「虚拟地址」这样的高级概念,让你误以为每次栈顶地址都是一样的。

虚拟地址就相当于:老大爷每次给你的柜子编号都是1,让你觉得好像受到了优待。但实际上每次1对应的都是不同的柜子。到底哪个编号对应哪个柜子,只有老大爷知道。

无论操作系统如何复杂,演算法的本质不会变,也没法改变。

更有意思的是,当C/C++载入动态链接库时,情况会变得更加扑朔迷离。如果对载入的原理感兴趣,推荐《程序员的自我修养》一书。


很意外好像没有人答对(在写本回答的时候好像只有一个人回答沾边),大部分回答都在谈内存地址的虚拟化,那么在CPU有保护模式(内存地址的虚拟化)之前,难道就只能同时载入一个程序?

(编辑:说虚拟内存的错在逻辑上本末倒置。就好像吊葡萄糖可以补充能量,吊的时候不吃饭也行,然而说是吊葡萄糖解决了吃饭问题这就是逻辑上的本末倒置。)

欢迎来到凯叔讲故事。

其实这个问题的关键和解决的方法并不是内存虚拟地址,虽然内存虚拟地址的确貌似可以解决这个问题(其实并不能,最后有提到),但是历史上这个问题的出现和解决远早于保护模式(内存虚拟地址)的出现。

这个问题的秘密在于EXE/ELF等可执行文件格式上。这些可执行文件格式的发明和制定,就是为了解决这个问题。

在很久很久以前(其实也就是30多年前),计算机程序的确是只能载入到内存当中的固定位置的。比如,BOOTLOADER、以及DOS启动时所需的那3个著名文件(这三个模块是输入输出模块(IO.SYS)、文件管理模块(MSDOS.SYS)及命令解释模块)就是这样。

然后,就有了题主所担心的问题,很不方便。

于是,就有了. COM的格式(可能还不是最早的,只是比较著名的)。. COM格式允许将程序放在任意一个内存分段当中,但是程序在段当中的偏移地址是固定的,也不能有另外独立的数据段。整个程序的大小(含数据)不能超过64K,因为这是一个内存段的大小。

内存需要分段,以及一个段是64K的原因是,那个时候的CPU本身是16bit的(e,忽略更老的8bit CPU不谈),就是所有寄存器都是16bit的,但是地址汇流排是20bit的。这样的话,就需要将一个被称为段寄存器的内容左移4bit然后加上另外一个寄存器(称为偏移寄存器)的内容,才能得到20bit的内存地址。16bit正好64K,也就是在不修改段寄存器的情况下,能够直接定址的范围就是64K。超过这个范围就需要修改段寄存器。这在以前也叫做长跳转。

但是后来随著程序越来越大,64K已经放不下了。首先想到的是将程序当中的数据部分分离出去放在单独的段当中,这就是数据段。然后程序本身也可能需要跨多个段。

但是这样还是不是很方便。由于数据段分离出去了,有的程序段可能很小。但是如果所有程序都是从一个固定的偏移地址开始,那么这些程序至少不能放在同一个段内。这就比较浪费内存,特别是在没有保护模式(虚拟地址)的年代,每个段可就是实打实的64K物理内存。那个时候,总共物理内存大部分也就256K,1M已经算高端机了(1M是20bit地址汇流排的定址上限),所以并没有那么多段可用。

所以就有了浮动二进位可执行方式,这就是EXE或者ELF等。这种可执行文件当中的地址只是对于程序当中某个位置(比如main函数开始位置)的一个偏移量,操作系统可以将其载入到内存任何位置,然后根据实际main函数入口所处地址和这些偏移量,去更新机器码当中所有的地址参照,将其改为实际地址。也就是,在程序实际被执行之前,操作系统会对其进行一次修改/更新。

而题主用objdump所看到的「地址」,其实就是这个相对于程序自身的偏移地址,并不是内存地址,即便是保护模式下,也不是按照这个地址执行的。当然,更加准确地来说,还要看被objdump的是什么文件。如果是. obj/.a文件,也就是链接之前的文件,那么这个偏移只是对于. obj/.a自身入口的偏移,在C语言链接的这一步,linker会将这些. obj/.a组织到可执行文件当中,并且更新这些偏移。(然后在可执行文件被载入到内存之后,操作系统还会再次更新。是的,这个过程和linker很像)

后来,随著32bit/64bit CPU的出现,CPU定址范围大大增加,才出现所谓的flat模式内存空间,也就是不分段(整个内存空间只有一段)。但是即便这样,在使用动态库等的时候,依然存在动态库载入到哪里的问题,因为不同的宿主程序长度不同,动态库载入数量和顺序不同,即便有虚拟地址空间,同一个动态库在不同程序的虚拟地址空间当中也不可能永远都载入到同一个位置。

此外还有安全方面的考虑。如果程序每次都载入到同样的地方,那么程序当中的每个变数也很可能每次都出现在同样的位置,尤其是全局变数。这使得程序非常容易被监视及hack。所以每次让操作系统将其载入到不同位置,可以提高一点儿安全性。

总之,这个问题其实与虚拟地址空间并没有什么关系。虽然虚拟地址空间的确可以避免程序之间的内存访问冲突,但是历史上并不是用虚拟地址空间解决的这个问题,而且它解决不了动态库的载入问题。


你说的是裸机,确实会有这种情况。

但是进入操作系统之后,你的程序看到的内存全是假的,甚至有可能对应的地址根本就不在内存,而是在硬碟上。

因为操作系统会为每个进程虚拟出内存,让每个进程都觉得整个系统中只有自己一个程序在运行。


这是个好问题。这个问题涉及到虚拟内存的概念。(虚拟内存可能有歧义,在此处应该就是逻辑地址的意思)

对于每个进程来说,他们都有一个独立的地址空间。比如A进程的0x400000和B进程的0x400000,都是合理的,但是他们不指向同一个位置。

你可能会疑惑,为什么地址一样,却不在同一个位置。这就是虚拟地址的概念了,我们之前提到的0x400000是一个虚拟的地址,要通过转换才能变成真正的地址,叫做物理地址。这个转换我们可以让他根据进程不同而不同,就实现了每个进程有他自己的虚拟空间。

至于这个转换是怎么发生的,现在一般通过页表,早期通过段寄存器。当然再早一点是没有虚拟内存这个概念的,直接就是物理地址,A和B的0x400000就是同一个地方,就会出现题主的问题。

如果有人看我再进一步讲讲页表吧。


关于从虚拟地址到物理地址的问题,不同的操作系统有不同的实现。我讲一个简化的模型(我只是个大二的学生,讲的不对的地方还请多多指教)。

首先有两个东西,一个是物理内存,他是实实在在的,你可以认为就是你的电脑的内存,给他物理地址,他就能拿到东西。还有一个虚拟地址空间,他是每个进程都有的,操作系统提供给每个进程的。进程自己不在乎给他用地址是啥,他只要能正确的从地址里拿到想要的内容就行了。

比如A进程想拿0x400000这个地址里的内容。我们前面说进程只知道虚拟地址,但是内容保存在物理内存里呀,我们要用物理地址到内存里去找内容。所以问题来了,怎么把虚拟地址变成物理地址呢?我们可以用页表。

假设我们的物理地址的范围是0x00~0xFF,一共256个。简单起见,假设我们的虚拟地址的范围也是0x00~0xFF。(这两个的空间大小没有必然的联系)

我们还有一个表,这张表一共有16项,我们把他叫做页表。我们把我们的虚拟地址分成两部分,第一位和第二位。比如0x13这个地址就分成0x1和0x3。用前一个0x1到页表里去找内容,找到0x2对吧。再和0x3拼起来,就得到了0x23,最后我们找到的内容就是物理地址

那么这个是怎么解决题主的问题的呢?我们再来看个例子,我们有两个进程,他们的页表是下面这个样子,左进程找地址0x11,按照上面的找法是不是变成0x21,右进程找地址0x11,算不算找到了0xE1。

你可能还会问,这个页表在哪里?页表的值是怎么写的?物理地址不够了怎么办?这些问题都很有意思,等进一步的学习就会明白啦。另外,真实的情况复杂的多,我这个只是非常简易的版本啦,感受一下思想就好了。


看到楼上越讲越深入了,我也加几句。不然要被diss没了(逃

  1. 段寄存器的概念在我这里完全忽略,因为我主要学的是riscv架构,至于大家电脑上普遍的intel架构,段寄存器仍是绕不过去的历史。
  2. 上面讲的页表,都是操作系统创建的。也就是说在操作系统启动前是没有的。虚拟地址空间是操作系统赋予用户进程,至于他自己怎么装在进去启动运行那就是另一个故事了。
  3. pe/elf格式中有segment和section的概念,这个是操作系统能装载程序进入内存的重要帮助,楼上的答主讲的比我好。


推荐阅读:
相关文章