上一篇文章记录了GDB调试从入门到熟练掌握的学习全过程。点击链接查看:【软件开发底层知识修炼】十九 GDB调试从入门到熟练掌握超级详细实战教程学习目录

本篇文章开始学习可执行程序的结构。也就是我们平时说的可执行文件的结构。本文不会像《程序员的自我修养》那样详细解释可执行文件的每一个细节,我希望通过这次学习能够对程序的结构有一个永久性的认知。当然,还是建议要把《程序员的自我修养》仔细阅读完。

1 程序是由不同的段构成的

  • 至于什么是段,如果看了《X86汇编语言-从实模式到保护模式》应该会非常清楚。段不过就是一段内存结构。把类似的指令放到连续的内存区域就是代码段(.text段),把初始化了的数据放到连续的内存区域就是数据段(.data段),把未初始化的数据放在一起就是(.bss段)
  • 程序的静态特征是指令和数据:实际上就是一堆二进制放在那里(磁盘)不动。
  • 程序的动态特征就是执行指令来处理数据:实际上就是把磁盘上的二进制文件加载到内存,让指令来处理数据。

那么你用C语言写一个代码,它与可执行文件的内部结构是如何对应的?

  • 代码段(.text):
  1. 源代码中的可执行语句编译后进入代码段
  2. 代码段在内存管理单元的系统属性中具有只读属性,它是不可写的。
  3. 代码段的大小在编译结束后,大小就直接确定了,运行的过程中不会被改变
  4. 代码段中可以包含常量数据,(如字符串常量)
  • 数据段(.data , .bss, .rodata)

  • 数据段中用于存放源代码中具有全局生命周期的变量。不具有全局生命期的局部非静态变量不在数据段中,而是在栈中。栈后面会讲。

  • .bss

    • .bss是存储未初始化的变量。或者说初始化为0的变量
  • .data

    • .data存储的是具有非0初始值的变量
  • .rodata

    • .rodata存储的是const修饰的变量

    为什么同是全局变量和静态局部变量,为什么初始化的和未初始化的变量放在不同的段中?

    可以这样想,有初始化值的变量在可执行文件中就直接将它的值保存到文件中,加载到内存的时候,直接将变量对应的值也加载到内存中。而未初始化的变量(或者本来就赋值为0的变量),在可执行文件中不用保存初始值,这减小了可执行文件的体积,将其加载到内存中时啥也不管直接全部赋值为0,这也也可以提高加载的效率。总结来说就是以下两点:

  • .bss段在可执行文件中不赋初值。在加载到内存中时直接全部初始化为0。这样减少了可执行文件的体积也提高额加载的效率

  • .data段中,在可执行文件中直接将变量对应的初始值保存,加载到内存中时直接将文件中对应的值加载到内存中即可。这也提高了程序的加载效率。

  • 文件头(File header)

文件头并不是今天的重点。简单来说文件头中保存了程序的各个段的信息,操作系统加载程序的时候,首先要读取这个文件头,先计算出各个段的大小,才能从磁盘中准确的读取相应的执行与数据。

并且在文件头中也记录了类似于符号,符号变之类的信息。这些不再多讲。

1.1 代码示例

下面我们写一个代码来使用一些具体的工具,查看各个段。

test.c

char g_no_val;   // .bss 1byte
int g_value=1;  //.data 4byte
char g_str[]="D.T.SoftWare_lyy";  // .data 17byte
const int g_const=3;  //.rodata 4byte

int dt_main(){
    static char c_no_value;  // .bss 1byte
    static int c_value=2;   // .data 4byte

    return 0;
}
  • 可以看到上述程序没有main函数,但是我们可以指定dt_main()函数为入口函数。使用以下方式进行编译:
  • gcc -e dt_main -nostartfiles test.c -o test.out
  • 得到可执行代码文件test.out,使用下面的命令查看它的各个段的信息:
  • objdump -h test.out
  • 由上图可以看到各个段的大小,起始段的地址等
  • .data段大小0x1c=28字节:我们由上述代码可以看到,.data段的变量一共是25字节,由于对其,最终是28字节,也就是16进制的1c。.data段的起始地址是:08049ff4
  • .bss段大小0x4=4字节:这个很明显。.bss段的起始地址是:0804a010
  • .rodata段大小:4字节:这个也很明显。起始地址为:0804819c
  • 使用nm test.out 查看各个符号的属性:
  • 因为符号g_const所在的.rodata段只有它一个,所以它的地址自然就是.rodata段的起始地址。如上图
    其他的信息也很容易看懂。这里不再赘述。
  • 我们还可以使用命令:objdump -s -j .rodata test.out 查看某一个段的信息:

2 程序中的栈结构

在最开始的那张图:

我们始终没有说a,b这两个变量在哪里。它们不在上述的那些段中。当然,它们肯定也是在某一块内存中的。这块内存,我们叫做:栈。

对于栈,我们这也不说特别详细值说明栈的基本用处。

  • 栈的本质是一块连续的内存结构。它与数据结构中的栈不是一个概念,但是操作很像。
  • 其中SP寄存器(当然这是最基本的16位的,32位的叫ESP)存的是栈顶的指针。它用于栈的入栈操作与出栈操作。
  • 栈的增长方向一般是向下。这与下面即将要说的堆内存正好相反。

栈一般有什么用处呢?

除了保存类似于上述的a,b这种局部变量以外。还有以下几种用途。

  • 中断发生时,栈用于保存一些寄存器的值。
  • 函数调用时,栈用于保存函数的上下文信息(活动记录,依然是一些寄存器的值等)
  • 并发编程时,每一个线程都有一个自己独立的栈。(说是独立有点不恰当,其实它们都是在一个大的独立的进程空间中。)

在本文,就不打算再详细说栈这种结构与作用。可以参考《程序员的自我修养》

知道了栈结构,理应还要知道堆结构。它们总是放到一起做对比。

3 堆(Heap)的简要概述

在这里我们知道堆是用于以下用途即可,具体的后面还会学习。

  • 堆,是一片闲置的内存空间,用于程序在运行的时候动态分配的
  • 堆空间的分配需要函数的支持,比如malloc。C++的new关键字底层也是调用相应的函数
  • 堆空间的使用后需要显示的释放(栈就不需要),一般是用free。C++中为delete。不过更加高级的语言具有垃圾回收机制比如java

4 内存映射段(mmap)

上述学习了各个段以及堆结构与栈结构。

还有一种内存段,叫做内存映射段。它是用来做什么的呢?

如果了解动态链接的过程,应该知道如果程序加载时需要动态链接相关动态库的话,操作系统内核会将相应的动态库的文件直接映射到内存中。映射的位置可以称为内存映射段。

还有一种情况是如果想要读取一个文件的内容,一点一点的读,开销总是相当大。如果操作系统内核直接将文件映射到某一块内存,再来读取文件的内容,将会块的多。

还有一种是程序执行时可以创建匿名映射区来存放程序数据。什么是匿名映射区?比如一个程序生产了很多数据,要将它最终存到一个文件中。那么不可能说生产一个数据就存放到文件中,更加高效的做法是,在内存中创建一个赋值全为0的区域,将生产的数据暂时先存放到这里,生产完或者生产了足够多的数据后再将数据写到磁盘上的文件。这就是匿名映射区。毕竟磁盘的读写都是以扇区为单位,一个扇区大小为512字节,一次写多一点数据总是比你一次写1字节的数据更加高效。

  • 有一点要明白,将文件的内容映射到内存,实际上是映射到进程的虚拟地址空间,而且,映射的过程,是没有数据的迁移的,也就是没有数据的拷贝。

  • 其实上面我们并没有说明白为什么将文件映射到内存后再读写会快一些。

  • 如果使用正常的read函数进行读文件,是需要两次的数据拷贝:一次是内核从磁盘将文件的内容拷贝到内核地址空间,然后再从内核地址空间拷贝到用户地址空。这里进行了两次数据的拷贝。开销比较大

但是如果是使用内存映段的话,就不一样了

如下图:

  • 首先将硬盘上的文件数据从逻辑上映射到内存中,这没有数据拷贝,零耗时。
  • 当用户程序读数据的时候,从虚拟地址空间读,通过缺页中断进行文件数据的实际载入(这里就是真正的数据的拷贝,从文件中拷贝到真实的物理内存)
  • 这里要注意一点:映射后的内存(虚拟内存)的读写,就是对文件数据的读写。

4 总结

其实这些内容以前都见过学过。下面给一个大的进程的虚拟地址空间的内存分配图:

  • 当然在Linux系统中内核与用户空间的比例是1:3,但是在windows系统中就是2:2了.
  • 本文章参考狄泰软件学院相关课程 想学习的可以加狄泰软件学院群, 群聊号码:199546072

  • 学习探讨加个人(可以免费帮忙下载CSDN资源):

  • qq:1126137994

  • 微信:liu1126137994

  • 学习交流资源分享qq群:962535112