【问题标题】:What happens when a computer program runs?计算机程序运行时会发生什么?
【发布时间】:2011-07-06 23:06:06
【问题描述】:

我知道一般理论,但我无法融入细节。

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

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

我不明白这两件事是如何联系在一起的。堆栈在什么时候用于执行指令?指令从 RAM 到堆栈,再到寄存器?

【问题讨论】:

  • +1 用于提出基本问题!
  • 嗯......你知道,他们写了关于那个的书。你真的想在 SO 的帮助下研究这部分 OS 架构吗?
  • 我根据问题的内存相关性质和对 C++ 的引用添加了几个标签,尽管我认为一个好的答案也可能来自 Java 或 C# 知识渊博的人!)
  • 点赞和收藏。我一直不敢问...
  • “将它们放入寄存器”这个术语不太正确。在大多数处理器上,寄存器用于保存中间值,而不是可执行代码。

标签: c++ memory operating-system x86 computer-architecture


【解决方案1】:

这确实取决于系统,但带有virtual memory 的现代操作系统倾向于加载它们的进程映像并分配内存,如下所示:

+---------+
|  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 保存在只读内存中的系统(最初由程序员设置)等。但这是一般要点。


你说:

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

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

stack 只是一个后进先出的数据结构。在 x86 架构中,它实际上可以通过使用距离末尾的偏移量来随机寻址,但最常见的功能是 PUSH 和 POP 分别用于添加和删除项目。它通常用于函数局部变量(所谓的“自动存储”)、函数参数、返回地址等(更多见下文)

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

在 x86 架构上,堆栈和堆都物理地驻留在您的系统内存 (RAM) 中,并通过虚拟内存分配映射到如上所述的进程地址空间。

registers(仍然在 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 calling convention,也被 C++ 使用。其他语言/系统可能会以不同的顺序将参数推送到堆栈上,有些语言/平台甚至不使用堆栈,而是以不同的方式进行。

另外请注意,这些不是实际执行的 C 代码行。编译器已将它们转换为可执行文件中的机器语言指令。 然后(通常)将它们从 TEXT 区域复制到 CPU 管道中,然后复制到 CPU 寄存器中,然后从那里执行。 [这是不正确的。请参阅下面的Ben Voigt's correction。]

【讨论】:

  • 抱歉,推荐一本好书会是更好的答案,IMO
  • 是的,“RTFM”总是更好。
  • @Andrey:也许您应该将该评论更改为“另外,您可能想阅读 your-good-book-recommendation” 我知道这类问题更值得调查,但每当您必须以“对不起,但是...”开始评论时,也许您真的应该考虑标记帖子以引起版主的注意,或者至少解释一下为什么您的意见对无论如何,任何人。
  • 优秀的答案。它确实为我清除了一些东西!
  • @Mikael:根据实现,您可能有强制缓存,在这种情况下,任何时候从内存中读取数据,都会读取整个缓存行并填充缓存。或者可能会提示缓存管理器只需要一次数据,因此将其复制到缓存中没有帮助。那是为了阅读。对于写入,有回写和直写缓存,它们会影响 DMA 控制器何时可以读取数据,然后还有一整套用于处理多个处理器的缓存一致性协议,每个处理器都有自己的缓存。这确实值得拥有自己的 Q。
【解决方案2】:

Sdaz 在很短的时间内就获得了大量的支持,但遗憾的是,它对指令如何通过 CPU 的传输方式一直存在误解。

问的问题:

指令从 RAM 到堆栈,再到寄存器?

斯达兹说:

另外请注意,这些不是实际执行的 C 代码行。编译器已将它们转换为可执行文件中的机器语言指令。然后(通常)将它们从 TEXT 区域复制到 CPU 管道中,然后复制到 CPU 寄存器中,并从那里执行。

但这是错误的。除了自修改代码的特殊情况外,指令永远不会进入数据路径。而且它们不是,也不能从数据路径执行。

x86 CPU registers 是:

  • 通用寄存器 EAX EBX ECX EDX

  • 段寄存器 CS DS ES FS GS SS

  • 索引和指针 ESI EDI EBP EIP ESP

  • 指标 标志

还有一些浮点和 SIMD 寄存器,但为了讨论的目的,我们将它们归类为协处理器的一部分,而不是 CPU。 CPU 内部的内存管理单元也有自己的一些寄存器,我们将再次将其视为一个单独的处理单元。

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

指令在 CPU 中经过与数据完全不同的路径(哈佛架构)。当前所有机器的 CPU 内部都是哈佛架构。这些天大多也是哈佛架构的缓存。 x86(您的普通台式机)是主内存中的冯诺依曼架构,这意味着数据和代码混合在 RAM 中。这不是重点,因为我们谈论的是 CPU 内部发生的事情。

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

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

最重要的是,术语有点混乱,因为 register 是 D 触发器集合的电气工程术语。并且指令(或特别是微指令)可以很好地临时存储在这样的 D 触发器集合中。但这不是计算机科学家、软件工程师或普通开发人员使用术语注册时的意思。它们表示上面列出的数据路径寄存器,它们不用于传输代码。

数据路径寄存器的名称和数量因其他 CPU 架构而异,例如 ARM、MIPS、Alpha、PowerPC,但它们都在不通过 ALU 的情况下执行指令。

【讨论】:

  • 感谢您的澄清。我犹豫要不要补充,因为我不是很熟悉它,而是应其他人的要求这样做的。
  • s/ARM/RAM/ 在“意味着数据和代码在 ARM 中混合”。对吗?
  • @bjarkef:第一次是,但不是第二次。我会解决的。
【解决方案3】:

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

#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 加载程序必须 rebase 库/可执行文件 - 基本上,它将位置相关代码移动到新位置 - 这不会'不需要查找表并且不能被利用,因为没有要覆盖的查找表。不幸的是,这需要在 Windows 加载程序中实现非常复杂的实现,并且如果需要重新设置映像的基础,则确实会产生相当大的启动时间开销。大型商业软件包经常修改它们的库,以便有目的地从不同的地址开始,以避免变基; windows 本身使用它自己的库(例如 ntdll.dll、kernel32.dll、psapi.dll 等——默认情况下都有不同的起始地址)来执行此操作

在 Windows 上,通过调用VirtualAlloc 从系统获取虚拟内存,并通过VirtualFree 将其返回给系统(好吧,从技术上讲,VirtualAlloc 转而使用 NtAllocateVirtualMemory,但这是一个实现细节)(对比这到 POSIX,内存无法回收)。这个过程很慢(IIRC 要求您分配物理页面大小的块;通常为 4kb 或更多)。 Windows 还提供了它自己的堆函数(HeapAlloc、HeapFree 等)作为称为 RtlHeap 的库的一部分,该库作为 Windows 本身的一部分包含在内,C 运行时(即malloc 和朋友)在其上通常实现。

Windows 还拥有相当多的旧版内存分配 API,当时它不得不处理旧的 80386,这些函数现在构建在 RtlHeap 之上。有关在 Windows 中控制内存管理的各种 API 的详细信息,请参阅此 MSDN 文章:http://msdn.microsoft.com/en-us/library/ms810627

还请注意,这意味着在 Windows 上,单个进程(并且通常确实)有多个堆。 (通常,每个共享库都会创建自己的堆。)

(大部分信息来自 Robert Seacord 的“Secure Coding in C and C++”)

【讨论】:

  • 很棒的信息,谢谢!希望“user487117”最终真正回来。 :-)
【解决方案4】:

堆栈

在 X86 架构中,CPU 使用寄存器执行操作。堆栈仅出于方便的原因使用。您可以在调用子例程或系统函数之前将寄存器的内容保存到堆栈中,然后将它们加载回以继续您离开的操作。 (您可以在没有堆栈的情况下手动访问它,但它是一个经常使用的函数,因此它具有 CPU 支持)。但是你几乎可以在没有 PC 的堆栈的情况下做任何事情。

例如整数乘法:

MUL BX

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

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

DMUL

这会从栈顶弹出两个值并将 tem 相乘,然后将结果推回栈中。堆栈对于这种机器是必不可少的。

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

堆是另一个仅存在于编译器领域的概念。它消除了处理变量背后的内存的痛苦,但这不是 CPU 或操作系统的功能,它只是对操作系统给出的内存块进行管理的一种选择。如果您愿意,您可以多次执行此操作。

访问系统资源

操作系统有一个公共接口,您可以访问它的功能。在 DOS 中,参数在 CPU 的寄存器中传递。 Windows 使用堆栈为操作系统函数(Windows API)传递参数。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2010-11-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-03-06
    • 1970-01-01
    • 2014-04-06
    相关资源
    最近更新 更多