【问题标题】:Running address of an application, followed by heap and stack expansions应用程序的运行地址,后跟堆和栈扩展
【发布时间】:2020-11-26 06:52:59
【问题描述】:

我有一个m.c

extern void a(char*);

int main(int ac, char **av){
    static char string [] = "Hello , world!\n";
    a(string);
}

还有一个a.c

#include <unistd.h>
#include <string.h>

void a(char* s){
    write(1, s, strlen(s));
}

我将这些编译和构建为:

g++ -c -g -std=c++14 -MMD -MP -MF "m.o.d" -o m.o m.c
g++ -c -g -std=c++14 -MMD -MP -MF "a.o.d" -o a.o a.c
g++ -o linux m.o a.o -lm -lpthread -ldl

然后,我检查可执行文件 linux,因此:

objdump -drwxCS -Mintel linux

我的Ubuntu 16.04.6 上的输出以:

start address 0x0000000000400540

然后是init 部分:

00000000004004c8 <_init>:
  4004c8:   48 83 ec 08             sub    rsp,0x8

最后是fini 部分:

0000000000400704 <_fini>:
  400704:   48 83 ec 08             sub    rsp,0x8
  400708:   48 83 c4 08             add    rsp,0x8
  40070c:   c3                      ret 

程序引用了通过命令获得的.data段中的字符串Hello , world!\n

objdump -sj .data linux

Contents of section .data:
 601030 00000000 00000000 00000000 00000000  ................
 601040 48656c6c 6f202c20 776f726c 64210a00  Hello , world!..

所有这些都告诉我,可执行文件已被创建,以便加载到从0x0000000000400540.init 的地址)开始的实际内存地址中,并且程序访问实际内存地址中的数据,直到至少 @987654341 @(.data的地址)

我基于"Linkers & Loaders" by John R Levine 的第 7 章,其中他说:

链接器将一组输入文件组合成一个输出文件 已准备好在特定地址加载。

我的问题是关于下一行的。

如果在加载程序时,该地址的存储不是 可用,加载程序必须重新定位加载的程序以反映 实际加载地址。

(1) 假设我的机器上正在运行的另一个可执行文件已经使用了400540601040 之间的内存空间,那么如何决定在哪里启动我的新可执行文件linux

(2) 与此相关,在第 4 章中说:

..ELF 对象...被加载在地址空间的中间,所以 堆栈可以向下增长到文本段下方,而堆可以增长 从数据末尾开始,保持总地址空间在使用中 比较紧凑。

假设以前运行的应用程序从200000 开始,现在linux 开始在400540 附近。内存地址没有冲突或重叠。但是随着程序的继续,假设前一个应用程序的堆上升到300000,而新启动的linux 的堆下降到310000。很快,内存地址就会发生冲突/重叠。当冲突最终发生时会发生什么?

【问题讨论】:

    标签: c linux assembly memory linker


    【解决方案1】:

    如果在加载程序时,该地址的存储不可用,加载程序必须重新定位加载的程序以反映实际加载地址。

    并非所有文件格式都支持:

    32 位 Windows 的 GCC 将在动态库 (.dll) 的情况下添加加载程序所需的信息。但是,该信息不会添加到可执行文件(.exe)中,因此必须将这样的可执行文件加载到固定地址。

    在 Linux 下会稍微复杂一些;但是,也无法将许多(通常是较旧的 32 位)可执行文件加载到不同的地址,而动态库 (.so) 可以加载到不同的地址。

    假设我有另一个可执行文件当前正在我的机器上运行,它已经使用了400540601040 之间的内存空间...

    现代计算机(所有 x86 32 位计算机)都有一个分页 MMU,大多数现代操作系统都使用该 MMU。这是一些电路(通常在 CPU 中),它将软件看到的地址转换为 RAM 看到的地址。在您的示例中,400540 可以转换为 1234000,因此访问地址 400540 将实际访问 RAM 中的地址 1234000

    重点是:现代操作系统为不同的任务使用不同的 MMU 配置。因此,如果您再次启动程序,将使用不同的 MMU 配置,将软件看到的地址 400540 转换为 RAM 中的地址地址 2345000。使用地址400540的两个程序可以同时运行,因为当程序访问地址400540时,一个程序将实际访问地址1234000,而另一个程序将访问RAM中的地址2345000

    这意味着加载可执行文件时,某些地址(例如400540)永远不会“已在使用中”。

    加载动态库 (.so/.dll) 时,该地址可能已在使用中,因为这些库与可执行文件共享内存。

    ...它是如何决定从哪里启动我的新可执行 linux 的?

    在 Linux 下,如果可执行文件以无法移动到另一个地址的方式链接,它将被加载到固定地址。 (如前所述:这对于较旧的 32 位文件很典型。)在您的示例中,“Hello world”字符串将位于地址 0x601040if 您的编译器和链接器以这种方式创建了可执行文件.

    但是,大多数 64 位可执行文件可以加载到不同的地址。由于安全原因,Linux 会将它们加载到某个随机地址,从而使病毒或其他恶意软件更难以攻击程序。

    ...所以堆栈可以向下增长到文本段以下...

    我从未在任何操作系统中见过这种内存布局:

    在 Linux 和 Solaris 下,堆栈都位于地址空间的末尾(在0xBFFFFF00 附近的某个位置),而文本段的加载位置非常接近内存的开头(可能是地址 0x401000)。

    ...而且堆可以从数据的末尾开始长大,...

    假设前一个应用程序的堆爬起来......

    自 1990 年代后期以来的许多实现不再使用堆。相反,他们使用mmap() 来保留新内存。

    根据brk() 的手册页,堆在 2001 年被声明为“遗留功能”,因此不应再被新程序使用。

    (然而,根据 Peter Cordes malloc() 的说法,在某些情况下似乎仍然使用堆。)

    与 MS-DOS 等“简单”操作系统不同,Linux 不允许您“简单”使用堆,但您必须调用函数 brk() 来告诉 Linux 您要使用多少堆。

    如果程序使用堆并且它使用的堆比可用的多,brk() 函数会返回一些错误代码,malloc() 函数只会返回 NULL

    但是,这种情况的发生通常是因为没有更多可用的 RAM,而不是因为堆与其他一些内存区域重叠。

    ...而新推出的linux的堆栈已经向下增长到...

    很快,内存地址就会发生冲突/重叠。当冲突最终发生时会发生什么?

    确实,栈的大小是有限的。

    如果你使用了太多的堆栈,就会出现“堆栈溢出”。

    这个程序会故意使用过多的堆栈——只是为了看看会发生什么:

    .globl _start
    _start:
        sub $0x100000, %rsp
        push %rax
        push %rax
        jmp _start
    

    对于带有 MMU 的操作系统(例如 Linux),您的程序将崩溃并显示错误消息:

    ~$ ./example_program
    Segmentation fault (core dumped)
    ~$
    

    编辑/补充

    所有正在运行的程序的堆栈都位于“末尾”吗?

    在较旧的 Linux 版本中,堆栈位于(但不完全位于)程序可访问的 虚拟 内存的末尾附近:程序可以访问从 0 到 @987654352 的地址范围@ 在那些 Linux 版本中。初始堆栈指针位于0xBFFFFE00 附近。 (命令行参数和环境变量在堆栈之后。)

    这是实际物理内存的终结吗?不同运行程序的堆栈不会混在一起吗?我的印象是程序的所有堆栈和内存在实际物理内存中保持连续,...

    在使用 MMU 的计算机上,程序永远不会看到物理内存:

    当程序加载时,操作系统会搜索一些空闲的 RAM 区域——也许它会在物理地址0xABC000 找到一些。然后它以虚拟地址0xBFFFF000-0xBFFFFFFF 转换为物理地址0xABC000-0xABCFFF 的方式配置MMU。

    这意味着:每当程序访问地址0xBFFFFE20(例如使用push操作)时,实际访问的是RAM中的物理地址0xABCE20

    程序根本不可能访问某个物理地址。

    如果您有另一个程序正在运行,则 MMU 的配置方式是在另一个程序运行时将地址 0xBFFFF000-0xBFFFFFFF 转换为地址 0x345000-0x345FFF

    因此,如果两个程序之一将执行push 操作并且堆栈指针为0xBFFFFE20,则将访问RAM 中的地址0xABCE20;如果其他程序执行push 操作(具有相同的堆栈指针值),则地址0x345E20 将被访问。

    因此,堆栈不会混淆。

    不使用 MMU 但支持多任务处理的操作系统(例如 Amiga 500 或早期的 Apple Macintoshes)当然不会以这种方式工作。此类操作系统使用特殊的文件格式(而不是 ELF),这些格式针对在没有 MMU 的情况下运行多个程序进行了优化。为此类操作系统编译程序比为 Linux 或 Windows 编译程序复杂得多。甚至对软件开发者也有限制(例如:函数和数组不能太长)。

    另外,每个程序是否都有自己的堆栈指针、基指针、寄存器等?还是操作系统只有一组这些寄存器供所有程序共享?

    (假设是单核CPU),CPU有一组寄存器;并且只能同时运行一个程序。

    当您启动多个程序时,操作系统会在程序之间切换。这意味着程序 A 运行(例如)1/50 秒,然后程序 B 运行 1/50 秒,然后程序 A 运行 1/50 秒,依此类推。在您看来,这些程序似乎在同一时间运行。

    当操作系统从程序 A 切换到程序 B 时,它必须首先保存(程序 A)寄存器的值。然后它必须更改 MMU 配置。最后它必须恢复程序 B 的寄存器值。

    【讨论】:

    • 现代 Linux 发行版制作 32 位 PIE。您说 64 位 Linux 可执行文件通常是可重定位的,但大多数 32 位可执行文件不是。这只是考虑到历史的分量吗?在 PIE 可执行文件开始流行之前,x86-64 已经普及多年;例如OP 的 Ubuntu 16.04 默认使非 PIE 可执行;那些不能被ASLRed。 GCC 将使用像 mov edi, offset .LC0 这样的指令将静态地址放入寄存器,因为默认的非 PIE 代码模型保证静态代码/数据位于地址空间的低 31 位。
    • 另外,您在与 32 位相同的段落中谈论 OP 的程序。它是 64 位的,我们可以从反汇编中确定。此外,binutils ld 在 32 位模式下为 .text 具有不同的默认基地址。 OP 的程序是非 PIE(ELF 类型 EXEC)x86-64 可执行文件。不可重定位:没有重定位元数据来对静态数据或代码应用修正,也没有位置独立的要求。
    • 当前 glibc malloc 仍然使用 brk 进行小分配,mmap 用于大分配(因此它绝对可以将页面返回给操作系统,而不是卡在空闲列表中)。有一个调整启发式,IIRC 截止是几页,甚至可能是 64k。 strace ls 并看到它使用了一些 brk 系统调用。 (当然,您的答案的总体 point 是正确的;虚拟内存使其成为问题。但不幸的是,某些具体细节不正确。)
    • @PeterCordes 我认为大多数现代 x86 Linux 发行版都是 64 位的,如果不安装额外的软件包,通常甚至不支持 32 位程序。因此,在编写 32 位程序时,我指的是旧程序。因此,“典型”一词指的是 1995-2018 年的“平均程序”,而不是仍然存在的少数 32 位发行版之一中的“平均程序”。
    • @PeterCordes 我在我的答案中更新了关于 32 位可执行文件的句子。我还添加了关于如何在 Linux 中使用堆以及如果没有更多堆会发生什么的说明。
    【解决方案2】:

    是的,这个可执行文件上的 objdump 显示了它的段将被映射的地址。 (链接将部分收集到段中:What's the difference of section and segment in ELF file format.data.text 链接到具有不同权限的不同部分(读+写与读+执行)。

    如果在加载程序时,该地址的存储不可用

    只有在加载动态库时才会发生这种情况,而不是可执行文件本身。虚拟内存意味着每个进程都有自己的私有虚拟地址空间,即使它们是从同一个可执行文件启动的。 (这也是为什么ld 总是可以为textdata 段选择相同的默认基地址,而不是试图将系统上的每个可执行文件和库插入到单个地址空间中的不同位置。)

    当它被操作系统的 ELF 程序加载器加载/映射时,可执行文件是首先声明部分地址空间的东西。这就是传统(非 PIE)ELF 可执行文件不可重定位的原因,这与 /lib/libc.so.6 等 ELF 共享对象不同

    如果您使用调试器单步执行程序,或者包含睡眠,您将有时间查看less /proc/&lt;PID&gt;/maps。或cat /proc/self/maps 让猫给你看它自己的地图。 (也可以/proc/self/smaps 了解每个映射的更多详细信息,例如其中有多少是脏的、使用大页面等)

    (较新的 GNU/Linux 发行版默认配置 GCC 以生成 PIE 可执行文件:32-bit absolute addresses no longer allowed in x86-64 Linux?。在这种情况下,objdump 只会看到与 01000 或其他东西的基数相关的地址。以及编译器生成的 asm会使用 PC 相对寻址,而不是绝对寻址。)

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2023-03-20
      • 2010-09-13
      • 1970-01-01
      • 2011-09-06
      • 2014-10-10
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多