当计算机程序运行时会发生什么?

190

我了解这个概念的基本理论,但无法理解其中的细节。

我知道程序存储在计算机的二级存储器中。一旦程序开始执行,它就被完全复制到 RAM 中。然后,处理器每次检索几条指令(取决于总线的大小),将它们放在寄存器中并执行。

我也知道计算机程序使用两种类型的内存:栈和堆,它们也是计算机主存的一部分。栈用于非动态内存,堆用于动态内存(例如C++中与new运算符相关的所有内容)。

我不明白的是这两件事情如何联系起来。在何时使用栈来执行这些指令?指令从 RAM 到栈再到寄存器中吗?


47
谢谢您提出这个基本问题! - mkelley33
24
嗯...你知道,他们有关于那方面的书。你真的想通过SO的帮助来学习操作系统架构的这一部分吗? - Andrey
1
我根据问题与内存相关的性质以及对C++的引用添加了一些标签,尽管我认为一个好的答案也可能来自于精通Java或C#的人! - mkelley33
15
点赞并收藏了。我一直太害怕问… - Maxpm
2
术语“将它们放入寄存器”并不完全正确。在大多数处理器上,寄存器用于保存中间值,而不是可执行代码。 - user122299
显示剩余5条评论
4个回答

165

这实际上取决于操作系统,但是现代操作系统通常使用虚拟内存加载其进程镜像并分配内存,大致如下:

+---------+
|  stack  |  function-local variables, return addresses, return values, etc.
|         |  often grows downward, commonly accessed via "push" and "pop" (but can be
|         |  accessed randomly, as well; disassemble a program to see)
+---------+
| shared  |  mapped shared libraries (C libraries, math libs, etc.)
|  libs   |
+---------+
|  hole   |  unused memory allocated between the heap and stack "chunks", spans the
|         |  difference between your max and min memory, minus the other totals
+---------+
|  heap   |  dynamic, random-access storage, allocated with 'malloc' and the like.
+---------+
|   bss   |  Uninitialized global variables; must be in read-write memory area
+---------+
|  data   |  data segment, for globals and static variables that are initialized
|         |  (can further be split up into read-only and read-write areas, with
|         |  read-only areas being stored elsewhere in ROM on some systems)
+---------+
|  text   |  program code, this is the actual executable code that is running.
+---------+

这是许多常见虚拟内存系统上的一般进程地址空间。 "空洞"是您的总内存大小减去所有其他区域占用的空间;这为堆提供了大量空间来扩展。这也是“虚拟”的,意思是它通过翻译表映射到您的实际内存,并且实际上可以存储在任何实际内存位置。以此方式完成以保护一个进程不会访问另一个进程的内存,并使每个进程认为它正在完整的系统上运行。
请注意,例如栈和堆的位置在某些系统上可能不同(有关Win32的更多详细信息,请参见Billy O'Neal's answer)。
其他系统可能非常不同。例如,DOS在real mode下运行,当运行程序时其内存分配看起来非常不同:
+-----------+ top of memory
| extended  | above the high memory area, and up to your total memory; needed drivers to
|           | be able to access it.
+-----------+ 0x110000
|  high     | just over 1MB->1MB+64KB, used by 286s and above.
+-----------+ 0x100000
|  upper    | upper memory area, from 640kb->1MB, had mapped memory for video devices, the
|           | DOS "transient" area, etc. some was often free, and could be used for drivers
+-----------+ 0xA0000
| USER PROC | user process address space, from the end of DOS up to 640KB
+-----------+
|command.com| DOS command interpreter
+-----------+ 
|    DOS    | DOS permanent area, kept as small as possible, provided routines for display,
|  kernel   | *basic* hardware access, etc.
+-----------+ 0x600
| BIOS data | BIOS data area, contained simple hardware descriptions, etc.
+-----------+ 0x400
| interrupt | the interrupt vector table, starting from 0 and going to 1k, contained 
|  vector   | the addresses of routines called when interrupts occurred.  e.g.
|  table    | interrupt 0x21 checked the address at 0x21*4 and far-jumped to that 
|           | location to service the interrupt.
+-----------+ 0x0

你可以看到DOS允许直接访问操作系统内存,没有保护措施,这意味着用户空间程序通常可以直接访问或覆盖任何它们喜欢的东西。
然而,在进程地址空间中,程序倾向于看起来相似,只是被描述为代码段、数据段、堆栈段等,并且映射方式略有不同。但大部分一般区域仍然存在。
在将程序和必要的共享库加载到内存中,并将程序的各个部分分配到正确的区域后,操作系统开始执行进程的主方法所在的位置,然后您的程序从那里接管,需要时进行系统调用。
不同的系统(嵌入式等)可能具有非常不同的体系结构,如无堆栈系统、哈佛体系结构系统(代码和数据存储在单独的物理内存中)、实际将BSS保留在只读内存中(最初由程序员设置)等。但这是一般情况。

你说:

我也知道计算机程序使用两种内存:堆栈和堆。它们也是计算机主内存的一部分。

"堆栈"和"堆"只是抽象概念,而不是(必然)物理上不同的“种类”内存。

堆栈只是一个后进先出的数据结构。在x86架构中,可以通过从结尾偏移来随机寻址,但最常见的功能是PUSH和POP,用于添加和从中删除项目。它通常用于函数本地变量(所谓的“自动存储”),函数参数,返回地址等。(更多细节在下面)

"堆"只是一个昵称,用于按需分配并随机寻址的内存块(这意味着您可以直接访问其中的任何位置)。它通常用于在运行时分配的数据结构(在C ++中使用newdelete,在C中使用malloc和friends等)。

在x86体系结构上,堆栈和堆都实际存在于系统内存(RAM)中,并通过虚拟内存分配映射到进程地址空间中,如上所述。

寄存器(在x86上仍然存在),实际上位于处理器内部(与RAM相对),并由处理器从TEXT区加载(也可以从内存中的其他位置或其他地方加载,具体取决于实际执行的CPU指令)。它们本质上只是非常小、非常快的芯片内存位置,用于许多不同的目的。

寄存器布局高度依赖于架构(实际上,寄存器、指令集和内存布局/设计正是“架构”的含义),因此我不会详细介绍,但建议您参加汇编语言课程以更好地理解它们。


您的问题:

指令执行时何时使用堆栈?指令从RAM到堆栈再到寄存器?

堆栈(在有并使用堆栈的系统/语言中)通常是这样使用的:

int mul( int x, int y ) {
    return x * y;       // this stores the result of MULtiplying the two variables 
                        // from the stack into the return value address previously 
                        // allocated, then issues a RET, which resets the stack frame
                        // based on the arg list, and returns to the address set by
                        // the CALLer.
}

int main() {
    int x = 2, y = 3;   // these variables are stored on the stack
    mul( x, y );        // this pushes y onto the stack, then x, then a return address,
                        // allocates space on the stack for a return value, 
                        // then issues an assembly CALL instruction.
}

编写一个简单的程序,就像这样,然后将其编译成汇编语言(如果您可以访问GCC,则使用gcc -S foo.c),并查看它。汇编语言非常容易理解。您可以看到堆栈用于函数局部变量,并用于调用函数,存储它们的参数和返回值。这也是为什么当您执行以下操作时:
f( g( h( i ) ) ); 

所有这些都会被依次调用。它实际上是在建立一堆函数调用及其参数,执行它们,然后在向下(或向上)回溯时将它们弹出。但是,正如上面提到的那样,在x86上,堆栈实际上位于进程内存空间(虚拟内存)中,因此可以直接操作它;它不是执行过程中的一个单独步骤(或至少与该过程正交)。
FYI,上述是C调用约定,也被C++使用。其他语言/系统可能以不同的顺序将参数推送到堆栈上,有些语言/平台甚至不使用堆栈,而是以不同的方式进行操作。
还要注意,这些不是实际的C代码行在执行。编译器已将它们转换为可执行文件中的机器语言指令。然后(通常)从TEXT区域复制到CPU流水线,然后进入CPU寄存器,并从那里执行。 [这是不正确的。请参见Ben Voigt的更正。]

4
抱歉,但我认为一个好的书籍推荐会是一个更好的答案建议。 - Andrey
13
是的,“RTFM”总是更好。 - Sdaz MacSkibbons
58
@Andrey: 或许你应该把那个评论改成“另外,你可能想读一下你推荐的好书”。我了解这种问题需要更多调查,但每当你不得不以“抱歉,但是……”开始一条评论时,也许你真的应该考虑将帖子标记为需要管理员注意或者至少提供一个说明,解释为什么你的意见对任何人都很重要。 - mkelley33
2
非常好的答案。它确实为我解决了一些问题! - Maxpm
2
@Mikael:根据实现方式的不同,你可能需要强制缓存,这种情况下,每次从内存中读取数据时,都会读取整个缓存行并填充缓存。或者,可以向缓存管理器提示数据只会被使用一次,因此将其复制到缓存中并没有帮助。这是针对读操作而言的。对于写操作,有写回和写直通缓存,它们影响DMA控制器何时读取数据,以及一系列缓存一致性协议来处理多个处理器各自拥有其自己的缓存的情况。这确实值得单独提问。 - Ben Voigt
显示剩余15条评论

62
Sdaz在短时间内获得了显着数量的赞,但遗憾的是他在传播有关指令如何通过CPU移动的误解。问题问道:“指令从RAM到堆栈再到寄存器?” Sdaz说:“还要注意的是,这些不是实际的C代码行在执行。编译器已将它们转换为可执行文件中的机器语言指令。然后(通常)从TEXT区域复制到CPU流水线中,然后进入CPU寄存器,并从那里执行。” 但这是错误的。除了自修改代码的特殊情况外,指令永远不会进入数据路径。它们也不可能从数据路径执行。 x86 CPU registers包括:通用寄存器EAX EBX ECX EDX、段寄存器CS DS ES FS GS SS、索引和指针ESI EDI EBP EIP ESP、标志EFLAGS。还有一些浮点和SIMD寄存器,但出于本讨论的目的,我们将把它们归类为协处理器的一部分而不是CPU。CPU内部的内存管理单元也有一些寄存器,我们将再次将其视为单独的处理单元。

这些寄存器都不用于可执行代码。 EIP 包含正在执行的指令的地址,而不是指令本身。

指令和数据在CPU中通过完全不同的路径进行(哈佛架构)。目前所有的机器在CPU内部都是哈佛架构。现在大多数机器在缓存中也是哈佛架构。 x86(普通桌面机器)在主内存中是冯·诺伊曼架构,这意味着数据和代码在RAM中交织在一起。但这并不重要,因为我们谈论的是CPU内部发生的事情。

计算机体系结构中教授的经典序列是取指-译码-执行。内存控制器查找存储在地址EIP处的指令。指令的位通过一些组合逻辑来创建处理器中不同复用器的所有控制信号。经过一些周期后,算术逻辑单元得出一个结果,该结果被时钟输入到目标中。然后获取下一条指令。

在现代处理器上,工作方式略有不同。每个传入的指令都会被翻译成一整个系列的微代码指令。这使得流水线成为可能,因为第一个微指令使用的资源后来不再需要,因此它们可以开始处理下一条指令的第一个微指令。

最后,术语有些混淆,因为“register”是电气工程术语,指的是D触发器集合。指令(尤其是微指令)很可能会暂时存储在这样的D触发器集合中。但当计算机科学家、软件工程师或普通开发人员使用“register”这个术语时,他们的意思并不是这个。他们指的是如上所列的数据通路寄存器,而这些寄存器不用于传输代码。
数据通路寄存器的名称和数量因其他CPU架构而异,例如ARM、MIPS、Alpha、PowerPC,但所有这些架构都在不通过ALU传递指令的情况下执行指令。

感谢澄清。我对此并不是非常熟悉,所以一开始有些犹豫,但最终还是按照别人的要求添加了它。 - Sdaz MacSkibbons

17

当进程执行时,内存的确切布局完全取决于您使用的平台。考虑以下测试程序:

#include <stdlib.h>
#include <stdio.h>

int main()
{
    int stackValue = 0;
    int *addressOnStack = &stackValue;
    int *addressOnHeap = malloc(sizeof(int));
    if (addressOnStack > addressOnHeap)
    {
        puts("The stack is above the heap.");
    }
    else
    {
        puts("The heap is above the stack.");
    }
}

在Windows NT及其子系统上,该程序通常会产生以下结果:
堆栈以上
在POSIX系统上,它会显示:
堆栈以下
UNIX内存模型由@Sdaz MacSkibbons在此处有很好的解释,因此我不会在这里重复。但这不是唯一的内存模型。POSIX要求这种模型的原因是sbrk系统调用。基本上,在POSIX系统上,为了获得更多的内存,进程仅需告诉内核将“空洞”和“堆”之间的分隔符向“空洞”区域移动。没有办法将内存返回给操作系统,操作系统本身也不管理您的堆。您的C运行时库必须提供它(通过malloc)。
这也对POSIX二进制文件中实际使用的代码类型产生影响。 POSIX系统(几乎普遍)使用ELF文件格式。在这种格式中,操作系统负责在不同的ELF文件之间进行库之间的通信。因此,所有的库都使用位置无关代码(即,代码本身可以加载到不同的内存地址并仍然运行),所有库之间的调用都通过查找表传递,以找出跨库函数调用所需跳转的位置。这增加了一些开销,如果其中一个库更改了查找表,则可能会被利用。
Windows的内存模型不同,因为它使用的代码类型不同。Windows使用PE文件格式,该格式以位置相关格式留下代码。也就是说,代码取决于代码加载到虚拟内存中的确切位置。在PE规范中有一个标志,告诉操作系统库或可执行文件希望在程序运行时映射到内存中的确切位置。如果程序或库无法加载到其首选地址,则Windows加载器必须重新定位库/可执行文件--基本上,它将位置相关代码移动到指向新位置的位置--这不需要查找表,并且不能被利用,因为没有查找表可以覆盖。不幸的是,这需要在Windows加载器中进行非常复杂的实现,并且如果图像需要重新定位,则具有相当大的启动时间开销。大型商业软件包通常会修改其库以从不同的地址开始,以避免重新定位;Windows自己使用其自己的库进行此操作(例如ntdll.dll,kernel32.dll,psapi.dll等--默认情况下都具有不同的起始地址)。
在Windows系统中,虚拟内存是通过调用VirtualAlloc从系统中获取的,并且通过VirtualFree返回给系统(好吧,技术上来说,VirtualAlloc会派遣给NtAllocateVirtualMemory,但这是一些实现细节)(与POSIX相比,内存无法收回)。这个过程很慢(而且如果我没记错的话,需要以物理页大小的块进行分配;通常为4kb或更多)。Windows还提供了自己的堆函数(HeapAlloc、HeapFree等),作为名为RtlHeap的库的一部分,该库作为Windows本身的一部分包含在其中,C运行时(即malloc和相关函数)通常是在其上实现的。
Windows还具有许多传统的内存分配API,这些API是建立在RtlHeap之上的,那些功能是为了应对旧的80386处理器而设计的。有关Windows中控制内存管理的各种API的更多信息,请参见此MSDN文章:http://msdn.microsoft.com/en-us/library/ms810627
还要注意的是,在Windows上,一个进程可以(并且通常会)有多个堆。(通常,每个共享库都会创建自己的堆。)
(这些信息大多来自Robert Seacord的《C和C++安全编码》)

非常好的信息,谢谢!希望“user487117”最终能够回来。 :-) - Sdaz MacSkibbons

5

X86架构中,CPU使用寄存器执行操作。栈仅用于方便的原因。在调用子程序或系统函数之前,您可以将寄存器的内容保存到堆栈中,然后再将它们加载回来,以便在离开时继续操作。(您可以手动完成无需使用堆栈的操作,但是由于该功能经常使用,因此CPU提供了支持)。但在PC上,您几乎可以不使用堆栈进行任何操作。

例如,整数乘法:

MUL BX

将AX寄存器与BX寄存器相乘(结果将存在DX和AX中,其中DX包含高位)。

基于堆栈的机器(如JAVA VM)使用堆栈进行其基本操作。上述乘法:

DMUL

这个操作会从栈顶弹出两个值,相乘后再将结果推回栈中。对于这种类型的机器来说,栈是必不可少的。

一些高级编程语言(例如C和Pascal)使用这种方法来传递函数参数:按从左到右的顺序将参数推入栈中,并由函数体弹出,返回值再被推回。这是编译器制造商做出的选择,有点滥用X86使用栈的方式。

堆是编译器领域中的概念,它帮助处理变量背后的内存管理,但它并不是CPU或操作系统的功能,只是OS给出的内存块的一种清理选择。如果你愿意,你也可以手动完成这个过程。

访问系统资源

操作系统有一个公共接口,可以让你访问它的函数。在DOS中,参数以CPU寄存器的形式传递。Windows使用栈来传递操作系统函数的参数(Windows API)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接