我知道一般理论,但我无法适应细节。
我知道程序驻留在计算机的辅助存储器中。一旦程序开始执行,它就会完全复制到 RAM 中。然后处理器一次检索几条指令(这取决于总线的大小),将它们放入寄存器并执行它们。
我也知道一个计算机程序使用两种内存:栈和堆,它们也是计算机主内存的一部分。栈用于非动态内存,堆用于动态内存(例如,与new
C++ 中的运算符相关的所有内容)
我无法理解的是这两件事是如何联系起来的。堆栈在什么时候用于执行指令?指令从 RAM 到堆栈,再到寄存器?
我知道一般理论,但我无法适应细节。
我知道程序驻留在计算机的辅助存储器中。一旦程序开始执行,它就会完全复制到 RAM 中。然后处理器一次检索几条指令(这取决于总线的大小),将它们放入寄存器并执行它们。
我也知道一个计算机程序使用两种内存:栈和堆,它们也是计算机主内存的一部分。栈用于非动态内存,堆用于动态内存(例如,与new
C++ 中的运算符相关的所有内容)
我无法理解的是这两件事是如何联系起来的。堆栈在什么时候用于执行指令?指令从 RAM 到堆栈,再到寄存器?
这确实取决于系统,但是具有虚拟内存的现代操作系统倾向于加载其进程映像并分配内存,如下所示:
+---------+
| 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 的更多详细信息,请参见下面的 比利奥尼尔的回答)。
其他系统可能非常不同。例如,DOS 以实模式运行,它在运行程序时的内存分配看起来大不相同:
+-----------+ 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++ 中,在 C 中使用new
and delete
,and and malloc
and 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 -S foo.c
如果你有 GCC 的话),看看。组装很容易理解。您可以看到堆栈用于函数局部变量,以及调用函数,存储它们的参数和返回值。这也是为什么当您执行以下操作时:
f( g( h( i ) ) );
所有这些都被依次调用。它实际上是在构建一堆函数调用及其参数,执行它们,然后在它回退(或向上;)时将它们弹出。但是,如上所述,堆栈(在 x86 上)实际上驻留在您的进程内存空间(在虚拟内存中),因此可以直接对其进行操作;它不是执行期间的单独步骤(或至少与流程正交)。
仅供参考,以上是C 调用约定,也被 C++ 使用。其他语言/系统可能会以不同的顺序将参数推送到堆栈上,有些语言/平台甚至不使用堆栈,而是以不同的方式进行处理。
另请注意,这些不是实际执行的 C 代码行。编译器已将它们转换为可执行文件中的机器语言指令。 然后(通常)将它们从 TEXT 区域复制到 CPU 管道中,然后复制到 CPU 寄存器中,并从那里执行。 [这是不正确的。请参阅下面Ben Voigt 的更正。]
Sdaz 在很短的时间内就获得了相当多的支持,但遗憾的是,它对指令如何通过 CPU 一直存在误解。
问题问:
指令从 RAM 到堆栈,再到寄存器?
斯达兹 说:
另请注意,这些不是实际执行的 C 代码行。编译器已将它们转换为可执行文件中的机器语言指令。然后(通常)将它们从 TEXT 区域复制到 CPU 管道中,然后复制到 CPU 寄存器中,并从那里执行。
但这是错误的。除了自修改代码的特殊情况外,指令永远不会进入数据路径。而且它们不是,也不能从数据路径中执行。
通用寄存器 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
。指令的位通过一些组合逻辑为处理器中的不同多路复用器创建所有控制信号。并且在一些周期之后,算术逻辑单元到达一个结果,该结果被计时到目的地。然后获取下一条指令。
在现代处理器上,事情的工作方式略有不同。每条传入的指令都被翻译成一系列的微码指令。这启用了流水线,因为稍后不需要第一条微指令使用的资源,因此它们可以从下一条指令开始处理第一条微指令。
最重要的是,术语有点混乱,因为寄存器是 D 触发器集合的电气工程术语。指令(或特别是微指令)可以很好地临时存储在这样的 D 触发器集合中。但这不是计算机科学家、软件工程师或普通开发人员使用术语寄存器时的意思。它们表示上面列出的数据路径寄存器,它们不用于传输代码。
数据路径寄存器的名称和数量因其他 CPU 架构而异,例如 ARM、MIPS、Alpha、PowerPC,但它们都执行指令而不通过 ALU。
进程执行时内存的确切布局完全取决于您使用的平台。考虑以下测试程序:
#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 盒子上,它会说:
栈在堆之上
@Sdaz MacSkibbons 在这里很好地解释了 UNIX 内存模型,所以我不会在这里重申。但这不是唯一的内存模型。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 相比,内存不能被回收)。这个过程很慢(IIRC 要求您分配物理页面大小的块;通常为 4kb 或更多)。Windows 还提供它自己的堆函数(HeapAlloc、HeapFree 等)作为称为 RtlHeap 的库的一部分,该库作为 Windows 本身的一部分包含在内,通常在其上实现 C 运行时(即malloc
和朋友)。
从不得不处理旧的 80386 的日子开始,Windows 也有不少遗留的内存分配 API,这些函数现在构建在 RtlHeap 之上。有关在 Windows 中控制内存管理的各种 API 的详细信息,请参阅此 MSDN 文章:http: //msdn.microsoft.com/en-us/library/ms810627。
另请注意,这意味着在 Windows 上,一个进程(通常确实)有多个堆。(通常,每个共享库都会创建自己的堆。)
(大部分信息来自 Robert Seacord 的“C 和 C++ 中的安全编码”)
堆栈
在 X86 架构中,CPU 使用寄存器执行操作。堆栈仅出于方便的原因使用。您可以在调用子程序或系统函数之前将寄存器的内容保存到堆栈中,然后将它们加载回来以继续您离开的操作。(您可以在没有堆栈的情况下手动访问它,但它是一个经常使用的函数,因此它具有 CPU 支持)。但是你几乎可以在没有 PC 的堆栈的情况下做任何事情。
例如整数乘法:
MUL BX
将 AX 寄存器与 BX 寄存器相乘。(结果将在 DX 和 AX 中,DX 包含高位)。
基于堆栈的机器(如 JAVA VM)使用堆栈进行基本操作。上面的乘法:
DMUL
这会从堆栈顶部弹出两个值并将 tem 相乘,然后将结果推回堆栈。堆栈对于这种机器是必不可少的。
一些高级编程语言(如 C 和 Pascal)使用后一种方法将参数传递给函数:参数按从左到右的顺序压入堆栈并由函数体弹出,返回值被压回。(这是编译器制造商做出的选择,有点滥用 X86 使用堆栈的方式)。
堆
堆是另一个仅存在于编译器领域的概念。它消除了处理变量背后的内存的痛苦,但这不是 CPU 或操作系统的功能,它只是对操作系统提供的内存块进行管理的一种选择。如果你愿意,你可以多次这样做。
访问系统资源
操作系统有一个公共接口,您可以访问它的功能。在 DOS 中,参数在 CPU 的寄存器中传递。Windows 使用堆栈为操作系统函数(Windows API)传递参数。