Loading

1 内存模型

当一个应用程序启动后,其相关数据就会被加载到内存(RAM)中,CPU会从内存中取出数据,配合从ROM中取出的指令执行相关操作,我们将数据在内存地址空间中的分布情况称为内存模型(Memory Model)。

假设现在有一个应用程序,它的函数调用顺序如下:
main() -> func1() -> func2() ,即:主函数main调用函数func1;函数func1调用函数func2。
当该程序被操作系统调入内存运行时,其应用数据在内存中的映射关系如下所示:

1.1 .text

  • .text段是个只读区,用于存放程序的二进制代码,任何对该段的写操作都会导致段错误
  • .text段在多个进程间是共享的

1.2 .rodata

  • “ro”表示read only,可见该段是个只读区,用于存放不可修改的常量数据,任何对该段的写操作都会导致段错误
  • 程序中的常量不一定全部放在.rodata段中,有些立即数也会被放在.text段中
  • 若程序中存在重复一致的字符串常量,某些编译器会保证其在.rodata段中只存在一个
  • .rodata段在多个进程间是共享的
  • 在某些嵌入式系统,.rodata被放在ROM或NOR FLASH中,以便程序在运行时直接读取而无需加载至RAM

1.3 .data

  • .data段是可读写区,用于存放初始化过的全局变量和静态变量
  • 程序编译时,编译器会为该段数据分配空间,数据保存在编译目标文件中

1.4 .bss

  • .bss段是可读写区,用于存放未初始化的全局变量和静态变量
  • .bss段只占运行时的内存空间而不占文件空间
  • 程序编译时,编译器并不会为该段数据分配空间,仅仅是记录数据所需空间的大小

1.5 heap

  • heap即堆区,是向高内存地址生长的数据结构,用于存放编程人员通过malloc手动申请的内存空间
  • heap的生命周期是整个程序运行周期,在其中申请的内存空间需要编程人员通过free手动释放,避免内存泄漏
  • heap是不连续的内存区域,会存在内存碎片
  • heap是完全由程序员控制,也是唯一由程序员完全控制的内存区域,其操作灵活,但分配效率比栈要低

在heap中申请动态内存原理:

操作系统有一个记录空闲内存地址的链表,当操作系统收到程序的动态内存申请时,会由低地址向高地址遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从链表中的空闲结点删除,并将该结点的空间分配给程序。对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样通过free才能正确的释放申请的动态内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

1.6 stack

  • stack即栈区,是向低地址生长的先进后出的数据结构,由操作系统进行管理,用于存放局部变量和函数参数
  • stack所占内存由操作系统在函数执行完成时自行释放
  • stack是连续的内存区域,两个紧密挨着定义的局部变量,他们所占的存储空间也是紧挨着的
  • stack属于线程私有
  • stack能分配的内存空间较少,若申请的内存空间超过栈的剩余空间时,将导致栈溢出错误
  • stack不够用的情况一般是程序中分配了大量数组或递归函数层次太深

当程序函数被调用时,操作系统会将函数参数、局部变量、返回地址等与函数相关的信息压入栈中,函数执行结束后,操作系统会将这些信息销毁,所以局部变量、函数参数只在当前函数中有效,不能传递到函数外部,因为它们的内存不在了。栈内存的具体分布情况如下图所示:

$ebp指针指向当前调用函数的栈底,$esp指针指向调用函数的栈顶,函数栈增加内存空间只要移动$esp即可,栈中的数据越多, $esp的值越小。每个函数栈都会保存自己的栈底指针,用于在下一个函数栈被回收之后,通过$ebp找到自己函数栈的栈底,以便恢复现场。

实际上,在程序启动时,会先为栈区分配一块大小适当的内存,对于一般的函数调用,进栈出栈只是$ebp、$esp指针的变换,或者是向已分配的内存中写入数据,不涉及内存的分配和释放,这也就是“栈内存的分配效率要高于堆”的原因。

1.7 小结

总体来说,程序源代码被编译之后主要分成两部分:程序指令和程序数据。.text段用于存放程序指令,具有只读属性,.rodata、.data、.bss、heap和stack用于存放程序数据,具有可读可写属性。.rodata、.data和.bss段在程序加载到内存后就分配好了,并且在程序运行期间一直存在,大小固定,只有等到程序运行结束后由操作系统回收,而heap和stack则在程序运行时动态开辟。

当系统中运行着多个程序且这些程序的某些程序指令是一样时,这些程序可以共享一份程序指令,因此只需在内存中保存一份程序指令即可,只是每一个程序运行中的程序数据不一样而已,这样可以节省大量的内存空间。

在函数调用时,第一个进栈的是主函数后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数(在大多数的C编译器中,参数是由右往左入栈的),然后是函数中的局部变量(注意静态变量是不入栈的)。 当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。

2 内存对齐

在单位时间内读取内存的次数叫做CPU的主频,CPU通过地址总线来访问内存数据,为了做到最快的寻址,即一次获取的数据尽量多,并且不要重复,CPU寻址采用步长进行寻址,并且只对编号为一定倍数的内存寻址,如下所示:

一般多少位的CPU一次能取到的数据就是多少位的,所以32位CPU的寻址步长为4个字节,假如寻址起始地址为0,则每次只对编号为 4 的倍数的内存进行寻址(即只对0、4、8、12······1000这样的地址寻址)。

所以对于程序来说,一个变量最好位于一个寻址步长的范围内,这样一次就可以读取到变量的值,如果跨步长存储,就需要读取两次,然后再拼接数据,显然效率降低了。为了提高数据读取效率,将一个数据尽量放在一个步长之内,避免跨步长存储,这称为内存对齐。

内存对齐不需要程序员亲自操作,编译器在编译程序时,会自动尽量将一个数据尽量放在一个步长之内,避免跨步长存储,实现内存对齐,这是一个编译机制。对齐位数取决于编译模式,在32位编译模式下,默认以4字节对齐;在64位编译模式下,默认以8字节对齐。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

👤本站访客数: 👁️本站访问量: