理解Linux可执行文件的内存布局

原文见此处。在CSDN也找到了一篇机翻的转载,此外就找不到了,所以自己来翻译一下= =水平有限,翻译质量不高,望指正


为了整内存这块的东西,需要先准备以下工具:(基本都自带,我的电脑上要另外装strace)

  • hexdump
  • objdump
  • readelf
  • xxd
  • gcore
  • strace
  • diff
  • cat

接下来我们来过一遍这两篇博客:Understanding glibc malloc、Anatomy of a program in memory。

实际上C语言有很多内存分配工具,不同的工具会按照不同的方式安排内存。目前在glibc里面的内存分配器是ptmalloc2(也就是我们平时用的malloc系列函数)。它是从dlmalloc fork的,但是在这个基础上添加了多线程支持,于2006年发布。在合并到glibc之后,原先的代码又做了许多改变,与最初的ptmalloc2已有颇多不同。

现在来细说glibc中的malloc。它的内部是通过brk或者mmap系统调用来向操作系统获得内存的。brk一般用来增长堆的空间,而mmap则用来加载共享库,创建新的线程空间,以及许多其他的。当申请的内存大小超过了MMAP_THRESHOLD时,malloc会选用mmap而不是brk。我们可以用strace来看到底调用了哪个函数。

以前在使用dlmalloc的时候,当两个线程同时调用malloc的时候,只有其中一个能进入临界区,而内存块可利用空间表是所有线程共用的。因此分配内存是一个全局锁定操作。

然而在ptmalloc2里面,当两个线程同时调用malloc的时候,内存能够被立刻分配,因为每个线程维护着各自的堆,以及它们各自的可利用空间表。

每个线程各自维护堆和可利用空间表的行为称为“线程竞技场(per-thread arena)”。

上回提到,我们是这样确定一个程序的内存排布的:

(高地址)
用户栈(向下增长)
用来映射共享库或者别的东西的内存
堆(向上增长)
未初始化的数据(.bss)
初始化了的数据(.data)
程序代码(.text)
0

问题在于我们不知道究竟发生了些什么。上边的那张图过于简单,无法让人理解其中的许多细节。

总之我们现在来写几个C程序,然后好好研究一下它们的内存结构吧。

注意,程序并不是通过编译或汇编直接产生的。最后一步由链接器完成。链接器把编译/汇编得到的许多.o文件整合到一起,解释它们中包含的符号和名字并产生最终的可执行程序。这一小段原文见此处

这是我们的第一个程序:(编译选项:gcc -pthread memory_layout.c -o memory_layout

#include 
#include 
#include 
#include 
#include 

void *thread_func(void * arg) {
    printf("Before malloc in thread 1\n");
    getchar();
    char * addr = (char *) malloc(1000);
    printf("After malloc and before free in thread 1\n");
    getchar();
    free(addr);
    printf("After free in thread 1\n");
    getchar();
}

int main() {
    char * addr;
    printf("Welcome to per thread arena example::%d\n", getpid());
    printf("Before malloc in the main thread\n");
    getchar();
    addr = (char *) malloc(1000);
    printf("After malloc and before free in main thread\n");
    getchar();
    free(addr);
    printf("After free in main thread\n");
    getchar();

    // pointer to the thread 1
    pthread_t thread_1;
    // pthread_* functions return 0 upon succeeding, and other numbers upon failing
    int pthread_status;

    pthread_status = pthread_create(&thread_1, NULL, thread_func, NULL);
    
    if (pthread_status != 0) {
        printf("Thread creation error\n");
        return -1;
    }

    // returned status code from thread_1
    void * thread_1_status;

    pthread_status = pthread_join(thread_1, &thread_1_status);
    
    if (pthread_status != 0) {
        printf("Thread join error\n");
        return -1;
    }

    return 0;
}

getchar可以等待用户输入,起到暂停程序的作用。这样我们就能在分析内存排布的时候一步一步的执行这个程序。

pthread用来创建符合POSIX标准的线程——受Linux系统调度的内核线程。使用多线程是为了实验一下多线程下的内存安排方式。如前面所说的,每个线程维护自己的堆和栈。

上边的程序使用了许多refenrece error pattern(可能指的是参数里面传引用吧),与其同时返回很多个值(通过元组),我们可以用引用来存储更多的数据。(总之就是参数传引用来存储更多的返回值)

好了,现在我们来运行程序:./memory_layout(按Ctrl + Z可以挂起程序)

# notify @ npc in ~ [19:59:01] 
$ ./memory_layout 
Welcome to per thread arena example::45637
Before malloc in the main thread

在这个时候,程序暂停了,我们可以通过查看/proc/45637/maps来检查内存的内容。这个文件是内核提供的虚拟文件,显示了程序实际的内存安排。它概括地显示了每个内存部分,因此我们无需去确认每个字节的内存就能了解内存是如何安排的。

# notify @ npc in ~ [20:55:38] 
$ cat /proc/45637/maps
5559568c2000-5559568c3000 r--p 00000000 103:02 14591507                  /home/notify/memory_layout
5559568c3000-5559568c4000 r-xp 00001000 103:02 14591507                  /home/notify/memory_layout
5559568c4000-5559568c5000 r--p 00002000 103:02 14591507                  /home/notify/memory_layout
5559568c5000-5559568c6000 r--p 00002000 103:02 14591507                  /home/notify/memory_layout
5559568c6000-5559568c7000 rw-p 00003000 103:02 14591507                  /home/notify/memory_layout
5559573b5000-5559573d6000 rw-p 00000000 00:00 0                          [heap]
7fd9b4a4c000-7fd9b4a4f000 rw-p 00000000 00:00 0 
7fd9b4a4f000-7fd9b4a74000 r--p 00000000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fd9b4a74000-7fd9b4bbf000 r-xp 00025000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fd9b4bbf000-7fd9b4c09000 r--p 00170000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fd9b4c09000-7fd9b4c0a000 ---p 001ba000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fd9b4c0a000-7fd9b4c0d000 r--p 001ba000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fd9b4c0d000-7fd9b4c10000 rw-p 001bd000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fd9b4c10000-7fd9b4c14000 rw-p 00000000 00:00 0 
7fd9b4c14000-7fd9b4c1b000 r--p 00000000 103:02 4982957                   /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
7fd9b4c1b000-7fd9b4c2b000 r-xp 00007000 103:02 4982957                   /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
7fd9b4c2b000-7fd9b4c30000 r--p 00017000 103:02 4982957                   /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
7fd9b4c30000-7fd9b4c31000 r--p 0001b000 103:02 4982957                   /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
7fd9b4c31000-7fd9b4c32000 rw-p 0001c000 103:02 4982957                   /usr/lib/x86_64-linux-gnu/libpthread-2.31.so
7fd9b4c32000-7fd9b4c38000 rw-p 00000000 00:00 0 
7fd9b4c69000-7fd9b4c6a000 r--p 00000000 103:02 4982930                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fd9b4c6a000-7fd9b4c8a000 r-xp 00001000 103:02 4982930                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fd9b4c8a000-7fd9b4c92000 r--p 00021000 103:02 4982930                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fd9b4c93000-7fd9b4c94000 r--p 00029000 103:02 4982930                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fd9b4c94000-7fd9b4c95000 rw-p 0002a000 103:02 4982930                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fd9b4c95000-7fd9b4c96000 rw-p 00000000 00:00 0 
7fff58067000-7fff58088000 rw-p 00000000 00:00 0                          [stack]
7fff58156000-7fff5815a000 r--p 00000000 00:00 0                          [vvar]
7fff5815a000-7fff5815c000 r-xp 00000000 00:00 0                          [vdso]

/proc/$PID/maps中的每一行描述了进程中的一块连续内存。每一行拥有这几个部分:

  • address:这一块内存的起始和结束地址
  • perms:描述了这一块内存的访问权限,取值rwxp或者rwxss那一位表示这块内存是否为公有内存,rwx分别表示读取写入执行权限。如果一个进程试图访问权限不允许的内存,会引发一个段错误。
  • offset:如果这块内存是通过mmap由文件映射的,这个值就表示这块内存的起始点在那个文件中的偏移位置
  • dev:如果是文件映射的内存,这两个数字(16进制)表示着文件所在的硬盘(或者别的什么存储设备)的位置。可以在lsblk命令的输出中找到相应的数字并对应到存储设备。
  • inode:如果是文件映射的内存,这个数字就是那个文件的inode
  • pathname:如果是文件映射的内存,这个就是文件名。注意到这一块还有[heap],[stack],[vdso],[vdso]代表着虚拟共享库(Virtual Dynamic Shared Object),在系统调用进入内核态时会用到它。

某些内存空间在pathname那里既没有文件名也没有特殊名字,这些是匿名内存空间。匿名内存空间由mmap创建,但是不对应任何一个文件。这种内存被用来做各种各样的事,比如共享内存,不在堆上的缓冲区,pthread库也使用匿名空间作为线程的栈。

我们不能保证连续的虚拟内存意味着连续的物理内存,但是在硬件层面上,有个设备专门用来将虚拟内存转换为物理内存,所以虚拟内存的速度依然是很快的。

首先要明白的是,内存地址从低到高分布,但是你每次执行程序的时候,许多内存空间的地址会和上次不一样。这意味着对于某些内存空间,它们的地址并不是静态分布的。这是出于安全性的考虑,通过随机化特定空间的地址,能够让攻击者更难获取他们感兴趣的内存。然而也有一些空间的地址一直是固定的,因为你需要固定的地址这样才能知道怎么加载程序。我们可以看出data和可执行内存一直是与vsyscall对齐的。事实上我们可以通过“PIE”(position independent executable)来随机化data和代码,但默认情况下PIE是关闭的(其实是开启的,至少我的电脑是这样),而且它会阻止程序被静态编译
(https://sourceware.org/ml/binutils/2012-02/msg00249.html。同时PIE会导致一些问题(32位和64位都有)。关于这些详见 http://blog.fpmurphy.com/2008/06/position-independent-executables.html 和 http://eli.thegreenplace.net/2011/08/25/load-time-relocation-of-shared-libraries(顺便想要关闭PIE的话,在编译时加一条参数-no-pie。)

从基本上说,可执行文件在运行之前,需要先被加载到内存里面。可执行文件的入口点可以用readelf查看。但是又出现一个问题,为什么readelf得到的入口点不是0x400000。这是因为程序入口点位于操作系统真正应该执行的地方。在这个入口点和0x400000之间的区域用来放置EHDR和PHDR,分别代表ELF头(header)和程序头。关于这两个之后再说。

# notify @ npc in ~ [20:07:01] 
$ readelf --file-header memory_layout | grep '入口点地址' 
  入口点地址:              0x4010b0

那么让我们来看看各个空间的详情。记住现在还没调用过malloc,所以还没有[heap]。(其实有,再看看后面吧)

// 我用-no-pie重新编译了一下
     0 - 400000 - 4MB未分配
400000 - 401000 - 4KB
401000 - 402000 - 4KB
402000 - 403000 - 4KB
403000 - 404000 - 4KB
404000 - 405000 - 4KB
f46000 - f67000 - 132KB [heap]

这就是内存最开始的部分。我加了从0到0x400000的额外部分。每段内存地址都是左边闭区间,右边开区间,记住内存从0开始。其中0x400000之前的部分没有被分配,如果试图访问会引发段错误。

可以看出从memory_layout中加载的一共有5个段。(此处与原文不一致,不过我跑了一下objdump -s memory_layout大概得出了结论)这些段分别是:

  1. .init段
  2. .text段 —— 代码段
  3. .rodata段 —— 只读data段
  4. .bss段
  5. .data段

为什么每一段都是刚好4KB?因为每一的大小是默认4KB。这意味着最小的可寻址段大小是4KB。执行getconf PAGESIZE显示4096字节。这意味着对于不足4KB的段,系统会将其填充到4KB。

如我们所知,我们可以任意定义一个指针然后解引用之,来读取对应位置的值。但是与其一个字节一个字节的看,我们应该能猜到这些数据通过结构体被组织了起来。

是什么样的结构体呢?看看readelf的源代码,我们就能知道相关结构体的构成了。这些结构体不是C标准库的一部分,所以我们也没法通过包含头文件来得到它们。但是跟ELF有关的源码很简单,直接复制粘贴就行了

// compile with gcc -std=c99 -o elfheaders ./elfheaders.c
#include 
#include 

// from: http://rpm5.org/docs/api/readelf_8h-source.html
// here we're only concerned about 64 bit executables, the 32 bit executables have different sized headers

typedef uint64_t Elf64_Addr;
typedef uint64_t Elf64_Off;
typedef uint64_t Elf64_Xword;
typedef uint32_t Elf64_Word;
typedef uint16_t Elf64_Half;
typedef uint8_t  Elf64_Char;

#define EI_NIDENT 16

// this struct is exactly 64 bytes
// this means it goes from 0x400000 - 0x400040
typedef struct {
    Elf64_Char  e_ident[EI_NIDENT]; // 16 B
    Elf64_Half  e_type;             // 2 B
    Elf64_Half  e_machine;          // 2 B
    Elf64_Word  e_version;          // 4 B
    Elf64_Addr  e_entry;            // 8 B
    Elf64_Off   e_phoff;            // 8 B
    Elf64_Off   e_shoff;            // 8 B
    Elf64_Word  e_flags;            // 4 B
    Elf64_Half  e_ehsize;           // 2 B
    Elf64_Half  e_phentsize;        // 2 B
    Elf64_Half  e_phnum;            // 2 B
    Elf64_Half  e_shentsize;        // 2 B
    Elf64_Half  e_shnum;            // 2 B
    Elf64_Half  e_shstrndx;         // 2 B
} Elf64_Ehdr;

// this struct is exactly 56 bytes
// this means it goes from 0x400040 - 0x400078
typedef struct {
     Elf64_Word  p_type;   // 4 B
     Elf64_Word  p_flags;  // 4 B
     Elf64_Off   p_offset; // 8 B
     Elf64_Addr  p_vaddr;  // 8 B
     Elf64_Addr  p_paddr;  // 8 B
     Elf64_Xword p_filesz; // 8 B
     Elf64_Xword p_memsz;  // 8 B
     Elf64_Xword p_align;  // 8 B
} Elf64_Phdr;

int main(int argc, char *argv[]){

    // from examination of objdump and /proc/ID/maps, we can see that this is the first thing loaded into memory
    // earliest in the virtual memory address space, for a 64 bit ELF executable
    // %lx is required for 64 bit hex, while %x is just for 32 bit hex

    Elf64_Ehdr * ehdr_addr = (Elf64_Ehdr *) 0x400000;

    printf("Magic:                      0x");
    for (unsigned int i = 0; i < EI_NIDENT; ++i) {
        printf("%x", ehdr_addr->e_ident[i]);
    }
    printf("\n");
    printf("Type:                       0x%x\n", ehdr_addr->e_type);
    printf("Machine:                    0x%x\n", ehdr_addr->e_machine);
    printf("Version:                    0x%x\n", ehdr_addr->e_version);
    printf("Entry:                      %p\n", (void *) ehdr_addr->e_entry);
    printf("Phdr Offset:                0x%lx\n", ehdr_addr->e_phoff); 
    printf("Section Offset:             0x%lx\n", ehdr_addr->e_shoff);
    printf("Flags:                      0x%x\n", ehdr_addr->e_flags);
    printf("ELF Header Size:            0x%x\n", ehdr_addr->e_ehsize);
    printf("Phdr Header Size:           0x%x\n", ehdr_addr->e_phentsize);
    printf("Phdr Entry Count:           0x%x\n", ehdr_addr->e_phnum);
    printf("Section Header Size:        0x%x\n", ehdr_addr->e_shentsize);
    printf("Section Header Count:       0x%x\n", ehdr_addr->e_shnum);
    printf("Section Header Table Index: 0x%x\n", ehdr_addr->e_shstrndx);

    Elf64_Phdr * phdr_addr = (Elf64_Phdr *) 0x400040;

    printf("Type:                     %u\n", phdr_addr->p_type); // 6 - PT_PHDR - segment type
    printf("Flags:                    %u\n", phdr_addr->p_flags); // 5 - PF_R + PF_X - r-x permissions equal to chmod binary 101
    printf("Offset:                   0x%lx\n", phdr_addr->p_offset); // 0x40 - byte offset from the beginning of the file at which the first segment is located
    printf("Program Virtual Address:  %p\n", (void *) phdr_addr->p_vaddr); // 0x400040 - virtual address at which the first segment is located in memory
    printf("Program Physical Address: %p\n", (void *) phdr_addr->p_paddr); // 0x400040 - physical address at which the first segment is located in memory (irrelevant on Linux)
    printf("Loaded file size:         0x%lx\n", phdr_addr->p_filesz); // 504 - bytes loaded from the file for the PHDR
    printf("Loaded mem size:          0x%lx\n", phdr_addr->p_memsz); // 504 - bytes loaded into memory for the PHDR
    printf("Alignment:                %lu\n", phdr_addr->p_align); // 8 - alignment using modular arithmetic (mod p_vaddr palign)  === (mod p_offset p_align)
    
    return 0;

}

程序输出:

$ ./elfheaders
Magic:                      0x7f454c46211000000000
Type:                       0x2
Machine:                    0x3e
Version:                    0x1
Entry:                      0x400490
Phdr Offset:                0x40
Section Offset:             0x1178
Flags:                      0x0
ELF Header Size:            0x40
Phdr Header Size:           0x38
Phdr Entry Count:           0x9
Section Header Size:        0x40
Section Header Count:       0x1e
Section Header Table Index: 0x1b
Type:                     6
Flags:                    5
Offset:                   0x40
Program Virtual Address:  0x400040
Program Physical Address: 0x400040
Loaded file size:         0x1f8
Loaded mem size:          0x1f8
Alignment:                8

对比readelf的输出:

$ readelf --file-header ./elfheaders
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x400490
  Start of program headers:          64 (bytes into file)
  Start of section headers:          4472 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         30
  Section header string table index: 27

我们相当于自己写了一个简单的readelf程序。

接下来我们来好好看看0x400000-0x401000里面到底有啥。这部分含有ELF头以及许多其他有趣的重要信息,尤其是从0x400000到程序入口点(0x4010b0,或许还要自己研究一下那一段的内容)之间的存储的信息。还有许多程序头部值得学习,但目前知道这些就够了。See: http://www.ouah.org/RevEng/x430.htm

但是操作系统从哪里取得程序数据呢?在加载程序到内存中之前,系统要先取得需要加载的数据。答案很简单:程序数据在可执行文件中。

我们先用hexdump来看看程序的二进制内容,再用objdump来反汇编它。

$ hexdump -C -s 0x0 memory_layout       
00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  02 00 3e 00 01 00 00 00  b0 10 40 00 00 00 00 00  |..>.......@.....|
00000020  40 00 00 00 00 00 00 00  78 3a 00 00 00 00 00 00  |@.......x:......|
00000030  00 00 00 00 40 00 38 00  0b 00 40 00 1d 00 1c 00  |[email protected]...@.....|
00000040  06 00 00 00 04 00 00 00  40 00 00 00 00 00 00 00  |........@.......|
00000050  40 00 40 00 00 00 00 00  40 00 40 00 00 00 00 00  |@.@.....@.@.....|
00000060  68 02 00 00 00 00 00 00  68 02 00 00 00 00 00 00  |h.......h.......|
00000070  08 00 00 00 00 00 00 00  03 00 00 00 04 00 00 00  |................|
00000080  a8 02 00 00 00 00 00 00  a8 02 40 00 00 00 00 00  |..........@.....|
00000090  a8 02 40 00 00 00 00 00  1c 00 00 00 00 00 00 00  |..@.............|
000000a0  1c 00 00 00 00 00 00 00  01 00 00 00 00 00 00 00  |................|
000000b0  01 00 00 00 04 00 00 00  00 00 00 00 00 00 00 00  |................|
000000c0  00 00 40 00 00 00 00 00  00 00 40 00 00 00 00 00  |..@.......@.....|
000000d0  08 06 00 00 00 00 00 00  08 06 00 00 00 00 00 00  |................|
000000e0  00 10 00 00 00 00 00 00  01 00 00 00 05 00 00 00  |................|
000000f0  00 10 00 00 00 00 00 00  00 10 40 00 00 00 00 00  |..........@.....|
00000100  00 10 40 00 00 00 00 00  3d 03 00 00 00 00 00 00  |..@.....=.......|
00000110  3d 03 00 00 00 00 00 00  00 10 00 00 00 00 00 00  |=...............|
00000120  01 00 00 00 04 00 00 00  00 20 00 00 00 00 00 00  |......... ......|
00000130  00 20 40 00 00 00 00 00  00 20 40 00 00 00 00 00  |. @...... @.....|
00000140  98 02 00 00 00 00 00 00  98 02 00 00 00 00 00 00  |................|
00000150  00 10 00 00 00 00 00 00  01 00 00 00 06 00 00 00  |................|
00000160  00 2e 00 00 00 00 00 00  00 3e 40 00 00 00 00 00  |.........>@.....|
00000170  00 3e 40 00 00 00 00 00  68 02 00 00 00 00 00 00  |.>@.....h.......|
00000180  70 02 00 00 00 00 00 00  00 10 00 00 00 00 00 00  |p...............|
00000190  02 00 00 00 06 00 00 00  10 2e 00 00 00 00 00 00  |................|
000001a0  10 3e 40 00 00 00 00 00  10 3e 40 00 00 00 00 00  |.>@......>@.....|
000001b0  e0 01 00 00 00 00 00 00  e0 01 00 00 00 00 00 00  |................|
000001c0  08 00 00 00 00 00 00 00  04 00 00 00 04 00 00 00  |................|
000001d0  c4 02 00 00 00 00 00 00  c4 02 40 00 00 00 00 00  |..........@.....|
000001e0  c4 02 40 00 00 00 00 00  44 00 00 00 00 00 00 00  |[email protected].......|
000001f0  44 00 00 00 00 00 00 00  04 00 00 00 00 00 00 00  |D...............|
00000200  50 e5 74 64 04 00 00 00  30 21 00 00 00 00 00 00  |P.td....0!......|
00000210  30 21 40 00 00 00 00 00  30 21 40 00 00 00 00 00  |[email protected]!@.....|
00000220  44 00 00 00 00 00 00 00  44 00 00 00 00 00 00 00  |D.......D.......|
00000230  04 00 00 00 00 00 00 00  51 e5 74 64 06 00 00 00  |........Q.td....|
00000240  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*

输出内容很长,建议用less来查看。不过终端模拟器也可以直接滚动。注意*代表“同上”的意思。

首先看前16字节:7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00

注意到这就是readelf输出的魔数(Magic):

$ readelf --file-header memory_layout | grep Magic       
  Magic:  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 

事实上文件头会被加载到内存里。但是整个文件都会被装入内存吗?我们先来看看文件大小。

$ stat memory_layout | grep 大小
  大小:16824           块:40         IO 块:4096   普通文件

大小是16824字节,反正比16KB大,而内存分布显示从文件映射的内存为4KB * 5 = 20KB。20KB完全能容纳一整个文件。

然而为了更加严谨的证明文件被加载到内存里,我们得研究一下/proc/$PID/mem的内容。然而这个文件比较特殊,不能直接cat(提示输入/输出错误),好在我们可以用gdb附带的gcore将进程的整个内存内容全部提取到磁盘中。

$ gcore 64457
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
0x00007fca90795e8e in read () from /lib/x86_64-linux-gnu/libc.so.6
Saved corefile core.64457
[Inferior 1 (process 64457) detached]

产出一个名为core.64457的文件。这个文件是内存的转储,所以我们需要用十六进制查看器来看看。

$ hexdump -C ./core.64457 | less

注意到我们已经拥有了内存的全部内容。让我们试着把它和可执行文件进行比较。但是在此之前,我们需要先把二进制文件转换成文本文件,因为diff不支持比较二进制。这里用xxd进行转换,因为hexdump会产生|,影响我们后面对diff输出的分析。

$ xxd ./core.64457 > ./mem.hex
$ xxd ./memory_layout > ./file.hex

我们立刻就注意到这两个输出的大小不一样:./mem.hex ~ 2.4 MB比./file.hex ~ 70 KiB大的多。这是因为内存转储同时也包含着已经加载到内存中的共享库和匿名映射空间。不过我们也不期待这二者完全一致,只是想确信是否整个可执行文件都加载到内存里面了。

这两个文件现在可以用diff命令进行比较。

$ diff --side-by-side ./file.hex ./memory.hex # 建议加上 | less
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF......   00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF......
00000010: 0200 3e00 0100 0000 b010 4000 0000 0000  ..>....... | 00000010: 0400 3e00 0100 0000 0000 0000 0000 0000  ..>.......
00000020: 4000 0000 0000 0000 783a 0000 0000 0000  @.......x: | 00000020: 4000 0000 0000 0000 30d3 0800 0000 0000  @.......0.
00000030: 0000 0000 4000 3800 0b00 4000 1d00 1c00  [email protected]... | 00000030: 0000 0000 4000 3800 1400 4000 1600 1500  [email protected]...
00000040: 0600 0000 0400 0000 4000 0000 0000 0000  ........@. | 00000040: 0400 0000 0400 0000 a0c4 0800 0000 0000  ..........
00000050: 4000 4000 0000 0000 4000 4000 0000 0000  @.@.....@. | 00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ..........
00000060: 6802 0000 0000 0000 6802 0000 0000 0000  h.......h. | 00000060: 780e 0000 0000 0000 0000 0000 0000 0000  x.........
00000070: 0800 0000 0000 0000 0300 0000 0400 0000  .......... | 00000070: 0100 0000 0000 0000 0100 0000 0400 0000  ..........
00000080: a802 0000 0000 0000 a802 4000 0000 0000  .......... | 00000080: a004 0000 0000 0000 0000 4000 0000 0000  ..........
00000090: a802 4000 0000 0000 1c00 0000 0000 0000  ..@....... | 00000090: 0000 0000 0000 0000 0010 0000 0000 0000  ..........
000000a0: 1c00 0000 0000 0000 0100 0000 0000 0000  .......... | 000000a0: 0010 0000 0000 0000 0100 0000 0000 0000  ..........
000000b0: 0100 0000 0400 0000 0000 0000 0000 0000  .......... | 000000b0: 0100 0000 0400 0000 a014 0000 0000 0000  ..........
000000c0: 0000 4000 0000 0000 0000 4000 0000 0000  ..@....... | 000000c0: 0030 4000 0000 0000 0000 0000 0000 0000  .0@.......
000000d0: 0806 0000 0000 0000 0806 0000 0000 0000  .......... | 000000d0: 0010 0000 0000 0000 0010 0000 0000 0000  ..........
000000e0: 0010 0000 0000 0000 0100 0000 0500 0000  .......... | 000000e0: 0100 0000 0000 0000 0100 0000 0600 0000  ..........
000000f0: 0010 0000 0000 0000 0010 4000 0000 0000  .......... | 000000f0: a024 0000 0000 0000 0040 4000 0000 0000  .$.......@
00000100: 0010 4000 0000 0000 3d03 0000 0000 0000  ..@.....=. | 00000100: 0000 0000 0000 0000 0010 0000 0000 0000  ..........
00000110: 3d03 0000 0000 0000 0010 0000 0000 0000  =......... | 00000110: 0010 0000 0000 0000 0100 0000 0000 0000  ..........
00000120: 0100 0000 0400 0000 0020 0000 0000 0000  .........  | 00000120: 0100 0000 0600 0000 a034 0000 0000 0000  .........4
00000130: 0020 4000 0000 0000 0020 4000 0000 0000  . @......  | 00000130: 0060 f400 0000 0000 0000 0000 0000 0000  .`........
00000140: 9802 0000 0000 0000 9802 0000 0000 0000  .......... | 00000140: 0010 0200 0000 0000 0010 0200 0000 0000  ..........
00000150: 0010 0000 0000 0000 0100 0000 0600 0000  .......... | 00000150: 0100 0000 0000 0000 0100 0000 0600 0000  ..........
00000160: 002e 0000 0000 0000 003e 4000 0000 0000  .........> | 00000160: a044 0200 0000 0000 0040 6a90 ca7f 0000  .D.......@
00000170: 003e 4000 0000 0000 6802 0000 0000 0000  .>@.....h. | 00000170: 0000 0000 0000 0000 0030 0000 0000 0000  .........0
00000180: 7002 0000 0000 0000 0010 0000 0000 0000  p......... | 00000180: 0030 0000 0000 0000 0100 0000 0000 0000  .0........
00000190: 0200 0000 0600 0000 102e 0000 0000 0000  .......... | 00000190: 0100 0000 0400 0000 a074 0200 0000 0000  .........t
000001a0: 103e 4000 0000 0000 103e 4000 0000 0000  .>@......> | 000001a0: 0070 6a90 ca7f 0000 0000 0000 0000 0000  .pj.......
000001b0: e001 0000 0000 0000 e001 0000 0000 0000  .......... | 000001b0: 0050 0200 0000 0000 0050 0200 0000 0000  .P.......P
000001c0: 0800 0000 0000 0000 0400 0000 0400 0000  .......... | 000001c0: 0100 0000 0000 0000 0100 0000 0400 0000  ..........
000001d0: c402 0000 0000 0000 c402 4000 0000 0000  .......... | 000001d0: a0c4 0400 0000 0000 0020 8690 ca7f 0000  ......... 
000001e0: c402 4000 0000 0000 4400 0000 0000 0000  [email protected]. | 000001e0: 0000 0000 0000 0000 0030 0000 0000 0000  .........0
...

可以看出虽然二者很相似,但并不是完全一致的。事实上在第17字节那里就开始产生不同了,恰好就紧跟在ELF魔数之后。

这显示着即使这部分内存由文件映射,它们的实际内容并不完全相同。或者说在内存转储和文件格式转换的时候某些字节改变了?这很难说。

总之继续前进吧,我们也可以用objdump来反汇编这个可执行文件,看看文件中究竟是哪些汇编指令。有一点需要注意的是,objdump使用的是虚拟内存地址,也就是它将被执行的时候的内存地址。由于我们已经从/proc/$PID/maps里面知道了内存空间的有关信息,我们可以考察一下第一段空间 —— 400000 - 401000

$ objdump --disassemble-all --start-address=0x000000 --stop-address=0x401000 ./memory_layout # | less

memory_layout:     文件格式 elf64-x86-64


Disassembly of section .interp:

00000000004002a8 <.interp>:
  4002a8:       2f                      (bad)  
  4002a9:       6c                      insb   (%dx),%es:(%rdi)
  4002aa:       69 62 36 34 2f 6c 64    imul   $0x646c2f34,0x36(%rdx),%esp
  4002b1:       2d 6c 69 6e 75          sub    $0x756e696c,%eax
  4002b6:       78 2d                   js     4002e5 <_init-0xd1b>
  4002b8:       78 38                   js     4002f2 <_init-0xd0e>
  4002ba:       36 2d 36 34 2e 73       ss sub $0x732e3436,%eax
  4002c0:       6f                      outsl  %ds:(%rsi),(%dx)
  4002c1:       2e 32 00                xor    %cs:(%rax),%al

Disassembly of section .note.gnu.build-id:

00000000004002c4 <.note.gnu.build-id>:
  4002c4:       04 00                   add    $0x0,%al
  4002c6:       00 00                   add    %al,(%rax)
  4002c8:       14 00                   adc    $0x0,%al
  4002ca:       00 00                   add    %al,(%rax)
  4002cc:       03 00                   add    (%rax),%eax
  4002ce:       00 00                   add    %al,(%rax)
  4002d0:       47                      rex.RXB
  4002d1:       4e 55                   rex.WRX push %rbp
  4002d3:       00 cb                   add    %cl,%bl
...

不像gcore或者解引用任意指针那样,objdump并没有向我们展示400000 - 4002a8这段内存的内容。这是因为这段内存里面存放的并不是汇编指令,而是一些元数据,所以objdump不去管这些东西,毕竟作为一个反汇编的工具。另一点要说的是关于省略号...(指的是objdump输出的省略号,不要和文中别的省略号弄混淆了),这些表示空字节(00)。objdump显示了每个字节的机器码以及与其等价的汇编代码。这是个反汇编器,所以它输出的汇编并不一定是人类直接写出来的,因为最终产生的可执行文件可能会被优化一些东西,以及删除一堆语意相关的内容(比如汇编里面的标号之类的)。注意左边的十六进制地址,它表示着这个机器码的起始地址,如果右边有好几个字节的话,这意味着它们组成了一条汇编指令。所以4002b1-4002b5这段空间的内容是2d 6c 69 6e 75共五个字节。

让我们来看看一些有趣的地方,比如程序真正的入口点——0x4010b0,据readelf --file-header ./memory_layout所提供。

$ objdump --disassemble-all --start-address=0x401000 --stop-address=0x402000 ./memory_layout | less +/4010b0
...

Disassembly of section .text:

00000000004010b0 <_start>:
  4010b0:       31 ed                   xor    %ebp,%ebp
  4010b2:       49 89 d1                mov    %rdx,%r9
  4010b5:       5e                      pop    %rsi
  4010b6:       48 89 e2                mov    %rsp,%rdx
  4010b9:       48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
  4010bd:       50                      push   %rax
  4010be:       54                      push   %rsp
  4010bf:       49 c7 c0 30 13 40 00    mov    $0x401330,%r8
  4010c6:       48 c7 c1 d0 12 40 00    mov    $0x4012d0,%rcx
  4010cd:       48 c7 c7 ee 11 40 00    mov    $0x4011ee,%rdi
  4010d4:       ff 15 16 2f 00 00       callq  *0x2f16(%rip)        # 403ff0 <__libc_start_main@GLIBC_2.2.5>
  4010da:       f4                      hlt    
  4010db:       0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

...

往上滑一点,我们看到objdump告诉我们这里是.text段,并且4010b0是程序的入口点。在这里的是CPU真正执行的第一个“过程”,那个隐藏在主函数背后的函数。我认为如果你想要不依赖运行库来制作独立运行的C程序的话,可以把这些直接写在C程序里面。这里的汇编是x86_64格式的汇编,应该是只运行在Intel/AMD 64位 CPU上。关于这种汇编语言本身我不是很了解,待会去http://www.cs.virginia.edu/~evans/cs216/guides/x86.html好好学习一下吧。

关于剩下几个段(.init,.rodata,.bss,.data),同样可以通过考察objdump/proc/$PID/maps去了解,这里不再赘述。

回到前面,我们最初对内存布局的理解是这样的:

(高地址)
用户栈(向下增长)
用来映射共享库或者别的东西的内存
堆(向上增长)
未初始化的数据(.bss)
初始化了的数据(.data)
程序代码(.text)
0

现在我们知道了更加详细的布局方式:

(高地址)
用户栈(向下增长)
用来映射共享库或者别的东西的内存
堆(向上增长,0xF46000)
初始化了的数据(.data,0x404000)
未初始化数据(.bss,0x403000)
只读数据(.rodata,0x402000)
代码(.text,0x401000)
初始化和一些重要信息(.init,0x400000)
什么也没有(0x0)

好了我们继续看下去吧。在可执行文件映射的内存之后,有很大一段跳跃:F67000 - 7fd9b4a4c000

...
404000-405000 rw-p 00003000 103:02 14591507                  /home/notify/memory_layout
f46000-f67000 rw-p 00000000 00:00 0                          [heap]
# 这中间一段是什么呢?
7fd9b4a4c000-7fd9b4a4f000 rw-p 00000000 00:00 0 
7fd9b4a4f000-7fd9b4a74000 r--p 00000000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
7fd9b4a74000-7fd9b4bbf000 r-xp 00025000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
...

这一个跨度足足有127 TB。为什么在地址空间内有这么大的跳跃呢?emmm,这就开始跟malloc的实现有关了。我们先来看看Linux内核文档是怎么描述内存的结构的:(下面的摘自Linux 5.10.74的内核文档)

  ========================================================================================================================
      Start addr    |   Offset   |     End addr     |  Size   | VM area description
  ========================================================================================================================
                    |            |                  |         |
   0000000000000000 |    0       | 00007fffffffffff |  128 TB | user-space virtual memory, different per mm
  __________________|____________|__________________|_________|___________________________________________________________
                    |            |                  |         |
   0000800000000000 | +128    TB | ffff7fffffffffff | ~16M TB | ... huge, almost 64 bits wide hole of non-canonical
                    |            |                  |         |     virtual memory addresses up to the -128 TB
                    |            |                  |         |     starting offset of kernel mappings.
  __________________|____________|__________________|_________|___________________________________________________________
                                                              |
                                                              | Kernel-space virtual memory, shared between all processes:
  ____________________________________________________________|___________________________________________________________
                    |            |                  |         |
   ffff800000000000 | -128    TB | ffff87ffffffffff |    8 TB | ... guard hole, also reserved for hypervisor

# 后略,我们这里只关心用户空间内存是128TB

如文档所言,Linux内存映射将最开始的0000000000000000 - 00007ffffffffffff作为用户空间内存,一共使用47bit,可以表示128TB的内存(128TB内存......希望我有生之年见得到这么大的RAM)

我们再看看最开始的/proc/$PID/maps

7fd9b4c6a000-7fd9b4c8a000 r-xp 00001000 103:02 4982930                   /usr/lib/x86_64-linux-gnu/ld-2.31.so
...
7fff58067000-7fff58088000 rw-p 00000000 00:00 0                          [stack]
7fff58156000-7fff5815a000 r--p 00000000 00:00 0                          [vvar]
7fff5815a000-7fff5815c000 r-xp 00000000 00:00 0                          [vdso]

看起来这些空间都位于整个用户空间内存的底部。考虑到这中间有个127TB的空隙,这似乎意味着我们的malloc会使用用户空间0000000000000000 - 00007fffffffffff的两端。从低地址那一端开始,它将堆向高地址延伸;而在高地址那一端,它将栈向低地址延伸。

与此同时,我们可以看到栈空间已经划定了一块固定的内存,这意味着它不能像堆那样增长那么多。在高地址那一侧但是比栈区域低一些的地方,我们能看到许多内存空间,它们被用来加载共享库和用作匿名内存空间——很可能也是被共享库使用的内存。

我们也可以看到已经开始被可执行文件使用的共享库。(回忆一下,我们用printf输出了两行信息然后用getchar暂停了程序,此时用到了libc和用来动态链接的ld)记住,共享库是在运行时被动态链接的,链接器在生成可执行文件的时候没法预测到它们的具体地址。顺便ldd的意思的“列出依赖的共享库”(list dyanmic dependencies)。

$ ldd memory_layout 
        linux-vdso.so.1 (0x00007ffe88164000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f6424dfb000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6424c36000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f6424e50000)

如果你把ldd多运行几次,你会发现每次输出的共享库地址都不一样——但是低12位不变。同样的,如果多运行几次程序并且每次都查看/proc/$PID/maps的内容的话,共享库的地址也都一样。原因跟前面讨论的PIE是一样的。每次运行ldd的时候,它都会调用一次动态链接器,而动态链接器会随机化共享库的地址空间,这被称为ASLR。通过命令cat /proc/sys/kernel/randomize_va_space,你可以检测内核是否启用了ASLR。

我们可以看到实际上有四个共享库。其中vdso库不从文件系统加载,而是操作系统本身提供的。

好了好了,我们再次回到程序本身吧,然后分配我们的堆空间。(其实我这边一开始运行就已经有堆了,或许是printf内部调用了malloc?)

我们可以看到[heap]段的大小是132KB。记住我们一开始是分配了1000字节:addr = (char *) malloc(1000);。那么1000字节怎么变成了132KB呢?如我们在前面说过的,对于小于MMAP_THRESHOLD 的内存申请,malloc会使用brk系统调用。看上去调用brk/sbrk时会创建一块空间,这样子可以减少系统调用的次数进而减少上下文切换的次数。绝大多数需要的堆空间都大于1000字节,所以系统可能会用brk一次性多创造一些堆空间,直到你把132KB空间都用完了之后,新的brk或者mmap才会被调用。至于这一块堆空间的大小是这么计算的:

  /* Request enough space for nb + pad + overhead */
  size = nb + mp_.top_pad + MINSIZE;

这里mp_.top_pad默认被设置为128 * 1024 = 128 KB。不过跟132KB还差了4KB啊。想起来内存中每一页的大小是4KB,这意味着当我们向系统申请1000字节内存时,一个4KB大小的页先被系统分配了。然后brk又创造出128KB的空间,加起来就是132KB了,刚好就是我们现在堆的大小。这段空间并不是被设为一个固定值,只不过默认是128KB,每次通过brk/sbrk增加。这意味着每次不论你打算分配多少空间,只要可用空间不足,堆就会增加128KB。然而这种增加方式只由brk/sbrk生效,而不是mmap,如果要分配的空间超过的MMAP_THRESHOLD(查了一下默认也是128KB)的话,被调用的就是mmap而不是(s)brk了。这意味着堆也不会增长128KB了。

关于brk增长内存的数量可以用这个函数调整:mallopt(M_TOP_PAD, 1);,这把M_TOP_PAD设置成了1字节。这样一来malloc(1000)只会产生一个4KB页了。

For more information, see: http://stackoverflow.com/a/23951267/582917

在需要分配的空间超过了MMAP_THRESHOLD时,为什么资格更老的(s)brk会被新来的mmap代替呢?这个是因为(s)brk只允许连续增长堆空间。如果你只用malloc存一些小东西的话,所有的这些都能在堆上连续存储,当到达堆顶时,堆会动态增长而不会引发问题。但是对于更大的内存需求,则会使用mmap,这样堆就不用继续通过(s)brk增长了。因此mmap更加灵活。对于比较小的对象消耗的内存分段大小就可以因此减少。并且mmap更加灵活,所以(s)brk可以通过mmap实现,反过来就不行。(s)brk的限制在于,如果堆顶部的内存没有被释放,整个堆的大小就无法减少。

来写一个小程序看看分配了超过MMAP_THRESHOLD大小的内存的话系统该如何解决吧:

#include 
#include 

int main() {
    // 自己加的,怀疑printf会调用malloc
    getchar();
    printf("Look at /proc/%d/maps\n", getpid());

    // 分配 200 KiB,这样会使用mmap而不是brk
    char * addr = (char *) malloc(204800);
    getchar();
    free(addr);
    return 0;
}

(怪事,程序一开始就会产生默认的堆,怎么回事呢)

总之用strace运行一下上面这段程序看看:

$ strace ./a.out
$ strace ./a.out                
execve("./a.out", ["./a.out"], 0x7ffe712c25f0 /* 70 vars */) = 0
brk(NULL)                               = 0xd7a000
...
brk(NULL)                               = 0xd7a000
brk(0xd9b000)                           = 0xd9b000
write(1, "Look at /proc/335555/maps\n", 26Look at /proc/335555/maps
) = 26
mmap(NULL, 208896, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f316a48d000
fstat(0, {st_mode=S_IFCHR|0600, st_rdev=makedev(0x88, 0x2), ...}) = 0
read(0, 0xd7a6b0, 1024)                 = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
read(0, // 此处正getchar

(原来一开始运行程序就执行了brk啊,那没问题了)

在被我省略的部分有一堆mmap,怎么才能知道哪个mmap是我们的C程序调用的而不是装载共享库呢?让我们仔细看看这一行:

mmap(NULL, 208896, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f316a48d000

确确实实是 204800 B + 4096 B = 208896 B。(不对,200KB能被4KB整除啊,怎么又多了个4KB?或许是别的特性罢)再加上前面的write系统调用(我们的printf调用的),可以判断这个mmap就是我们要找的。strace告诉我们内存被映射到了0x7f316a48d000,那来看看/proc/$PID/maps吧:

...
00d7a000-00d9b000 rw-p 00000000 00:00 0                                  [heap]
7f316a48d000-7f316a4c0000 rw-p 00000000 00:00 0
7f316a4c0000-7f316a4e5000 r--p 00000000 103:02 4982934                   /usr/lib/x86_64-linux-gnu/libc-2.31.so
...

如你所见,当malloc选择mmap的时候,分配的空间并不是[heap]的一部分,毕竟[heap](s)brk特供的。而且他是匿名的。我们也可以看出这种堆和brk分配的堆不同,它位于共享库那个地方,也就是用户内存空间的较高的一端。再来看看这块内存的大小——208896 B——没什么奇怪的,刚好就是strace显示的分配数量。

到了这里,我们可以继续分析原来那个程序,看看多线程下的堆是怎么处理的。但是这篇文章现在得告一段落了。

未完待续......

(如果你对这些感兴趣,还望点个关注)

MallocInternals翻译

你可能感兴趣的:(理解Linux可执行文件的内存布局)