目录
程序的加载
程序的内存空间
程序入口地址
BSS段初始化
程序运行过程中的堆栈管理
栈内存管理
变量的作用域:
栈溢出攻击原理
Linux堆内存管理
查看进程内存布局
内存分配器
内存块合并
top chunk
程序的运行分两种情况:一种是在有操作系统的环境下执行一个应用程序;另一种是在无操作系统的环境下执行一个裸机程序。
在Linux环境下,可执行文件是ELF格式,而在裸机环境下执行的程序一般是BIN/HEX格式。BIN/HEX文件是纯指令文件,没有其他的辅助信息。
虽然运行环境不同,文件格式也有所差异,但原理都是将指令加载到内存中的指定位置。而这个指定位置往往又与可执行文件链接时的链接地址有关。
程序的加载
拥有操作系统的计算机系统执行一个应用程序时,首先会运行一个叫作加载器的程序。加载器会根据软件的安装路径信息,将可执行文件从ROM中加载到内存,然后进行一些与初始化、动态库重定位相关的操作,最后才跳转到程序的入口运行。
程序的内存空间
程序是安装在磁盘上某个路径下的二进制文件,而进程则是一个程序运行的实例。一个进程实例不仅包括汇编指令代码、数据,还包括进程上下文环境、CPU寄存器状态、打开的文件描述符、信号、分配的物理内存等相关资源。
在Linux环境下运行的程序,在编译时链接的起始地址都是相同的,而且是一个虚拟地址。Linux操作系统需要CPU内存管理单元的支持才能运行,Linux内核通过页表和MMU硬件来管理内存,完成虚拟地址到物理地址的转换、内存读写权限管理等功能。每个进程都有各自的页表,用来记录各自进程中虚拟地址到物理地址的映射关系。
每一个应用程序进程都有4GB大小的虚拟地址空间。为了系统的安全稳定,0~4GB的虚拟地址空间一般分为两部分:用户空间和内核空间。0~3GB地址空间给应用程序使用,而操作系统一般运行在3~4GB内核空间。通过内存权限管理,应用程序没有权限访问内核空间,只能通过中断或系统调用来访问内核空间,这在一定程度上保障了操作系统核心代码的稳定运行。现在很多高端的SoC芯片,随着集成的IP模块越来越多,导致Linux内核镜像运行时需要的地址空间也越来越大。在很多处理器平台下,大家也经常看到如图5-3所示的划分:0~2GB的地址空间为用户空间,2~4GB的地址空间为内核空间。所有用户进程共享内核地址空间,但独享各自的用户地址空间。
程序入口地址
在Linux环境下运行的程序一般都会被封装成进程,参与操作系统的统一调度和运行。若用Shell终端环境运行一个程序,此时shell终端就充当加载器的角色,Shell终端程序一般会先fork一个子进程,创建一个独立的虚拟进程地址空间,接着调用execve函数将要运行的程序加载到进程空间:通过可执行文件的文件头,找到程序的入口地址,建立进程虚拟地址空间与可执行文件的映射关系,将PC指针设置为可执行文件的入口地址,即可启动运行。
可执行文件的文件头提供了文件类型、运行平台、程序的入口地址等基本信息,加载器在加载程序之前会首先根据文件头的信息做一些判断,如果发现程序的运行平台和当前的环境不符,则会报出错处理。除此之外,可执行文件中还有一个叫作段头表(program header table)的section,记录着如何将可执行文件加载到内存的相关信息,包括可执行文件中要加载到内存中的段、入口地址等信息。可重定位目标文件因为是不可执行的,不需要加载到内存中,所以段头表这个section在目标文件中不是必须存在的,是可选的。可以使用readelf命令查看可执行文件的段头表。
arm-linux-gnueabi-readelf -l a.out
程序的入口地址可通过下面的计算公式得到:
程序的入口地址=编译时的链接地址+一定偏移(程序头等会占用一部分空间)
编译器在编译一个工程时,默认的程序入口是_start符号,而不是main。符号main是一个约定符号,它用来告诉编译器在一个项目中哪里是程序的入口点。程序员在开发一个项目时,也会遵守这个约定,使用main()函数作为项目的入口函数。
在main()函数运行之前,已经有“先头部队”代码提前运行了:它们主要完成运行main()函数之前的一些初始化工作:
- C语言运行的基本堆栈环境、进程环境
- 初始化data段的内容,初始化static静态变量和global全局变量,并给BSS段的变量赋初值
- 动态库的加载、释放、初始化、清理等工作
- 将用户传入的参数传递给main
最后才跳入main()函数运行
这部分初始化代码是在程序编译阶段,由编译器自动添加到可执行文件中的。这部分代码属于C运行库(C Running Time,CRT)中的代码,编译器厂商在开发编译器时,除了实现C语言标准中规定的printf、fopen、fread等标准函数,还会实现这部分初始化代码。在ARM交叉编译器安装路径下的lib目录下,你会看到一个叫作crt1.o的目标文件,这个文件其实就是由汇编初始化代码编译生成的,是CRT的一部分。在链接过程中,链接器会将crt1.o这个目标文件和项目中的目标文件组装在一起,生成最终的可执行文件。
main只是编译器和程序员约定好的默认入口点,并不是一成不变的,程序员也可以自定义程序入口。可改变项目的入口地址
#arm-linux-gnueabi-gcc -nostartfiles -e <入口函数> xx.c
BSS段初始化
对于未初始化的全局变量和静态局部变量,编译器将其放置在BSS段中。BSS段是不占用可执行文件存储空间的,但是当程序加载到内存运行时,加载器会在内存中给BSS段开辟一段存储空间。在section header table中会记录BSS段的大小,在符号表中会记录每个变量的地址和大小。加载器会根据这些信息,在数据段的后面分配指定大小的内存空间并清零,根据符号表中各个变量的地址,在这片内存中给各个未初始化的全局变量、静态变量分配存储空间。int类型的全部初始化为0,布尔型的变量初始化为FALSE,指针型的变量初始化为NULL
程序运行过程中的堆栈管理
在一个进程的地址空间中,代码段、数据段、BSS段在程序加载运行后,地址就已经固定了,在整个程序运行期间不再发生变化,这部分内存一般也称为静态内存。而在程序中使用malloc申请的内存、函数调用过程中的栈在程序运行期间则是不断变化的,这部分内存一般也称为动态内存。用户使用malloc申请的内存一般被称为堆内存(heap),函数调用过程中使用的内存一般被称为栈内存(stack)。
栈内存管理
栈的初始化其实就是栈指针SP的初始化。在系统启动过程中,内存初始化后,将栈指针指向内存中的一段空间,就完成了栈的初始化,栈指针指向的这片内存空间被称为栈空间。不同的处理器一般都会使用专门的寄存器来保存栈的起始地址,X86处理器一般使用ESP(栈顶指针)和EBP(栈底指针)来管理堆栈,而ARM处理器则使用R13寄存器(SP)和R11寄存器(FP)来管理堆栈。
ARM处理器使用的是满递减栈,在Linux环境下,栈的起始地址一般就是进程用户空间的最高地址,紧挨着内核空间,栈指针从高地址往低地址增长。为了防止黑客栈溢出攻击,新版本的Linux内核一般会将栈的起始地址设置成随机的,如图5-9所示,每次程序运行,栈的初始化起始地址都会基于用户空间的最高地址有一个随机的偏移,每次栈的起始地址都不一样。
栈初始化后,栈指针就指向了这片栈空间的栈顶,当需要入栈、出栈操作时,栈指针SP就会随着栈顶的变化上下移动。在一个满递减栈中,栈指针SP总是指向栈顶元素。
在栈的初始化过程中,除了指定栈的起始地址,我们还需要指定栈空间的大小。在Linux环境下,我们可以通过下面的命令来查看和设置栈的大小。
#ulimit -s //查看栈大小 单位 KB
#ulimit -s 4096 //设置栈空间大小
Linux默认给每一个用户进程栈分配8MB大小的空间。栈的容量如果设置得过大,则会增加内存开销和启动时间;如果设置得过小,则程序超出栈设置的内存空间又容易发生栈溢出(Stack Overflow),产生段错误(Segmentation fault (core dumped))。
一个函数内定义的局部变量、传递的实参都是保存在栈中的。每一个函数都会有自己专门的栈空间来保存这些数据,每个函数的栈空间都被称为栈帧(Frame Pointer,FP)。每一个栈帧都使用两个寄存器FP和SP来维护,FP指向栈帧的底部,SP指向栈帧的顶部。无论函数调用运行到哪一级,SP总是指向当前正在运行函数栈帧的栈顶,而FP总是指向当前运行函数的栈底。在每一个函数栈帧中,除了要保存局部变量、函数实参、函数调用者的返回地址,有时候编译过程中的一些临时变量也会保存到函数的栈帧中,为了简化分析,我们暂不考虑这些。除此之外,上一级函数栈帧的起始地址,即栈底也会保存到当前函数的栈帧中,多个栈帧通过FP构成一个链,这个链就是某个进程的函数调用栈。很多调试器支持回溯功能,其实就是基于这个调用链来分析函数的调用关系的。
示例:
局部变量:
根据fp的偏移来压入局部变量
参数传递:
根据sp的偏移压入函数参数
函数调用过程中的参数传递,一般都是通过栈来完成的。ARM处理器为了提高程序运行效率,会使用寄存器来传参。根据ATPCS规则,在函数调用过程中,当要传递的参数个数小于4时,直接使用R0~R3寄存器传递即可;当要传递的参数个数大于4时,前4个参数使用寄存器传递,剩余的参数则压入堆栈保存。
在参数传递过程中,各个参数压栈、出栈的顺序也要有一个约定,是从左往右依次压入堆栈的呢?还是从右往左呢?我们一般把不同的约定方式称为调用惯例。
语言默认使用cdecl调用惯例。参数传递时按照从右到左的顺序依次压入堆栈,栈的清理方则由函数调用者caller管理。使用cdecl调用惯例的好处是可以预先知道参数和返回值大小,而且可以支持变参函数的调用。
对应的汇编
变量的作用域:
全局变量定义在函数体外,全局变量的作用域如下。
● 全局变量的作用域由文件来限定。
● 可使用extern进行扩展,被其他文件引用。
● 也可以使用static进行限制,只能在本文件中被引用。
局部变量定义在函数内,局部变量的作用域如下。
● 局部变量的作用域由{}限定。
● 可以使用static修饰局部变量来改变它们的存储属性(生命周期),但不能改变其作用域。
栈溢出攻击原理
在一个函数的栈帧中一般都会保存上一级函数的返回地址LR,当函数运行结束时就会根据这个返回地址跳到上一级函数继续执行。黑客如果发现你实现的某个函数有漏洞,就可以利用漏洞修改栈的返回地址LR,植入自己的指令代码。
虽然C语言标准并没有规定数组的越界访问会报错,但是大多数编译器为了安全考虑,会对数组的边界进行自行检查:当发现数组越界访问时,会产生一个错误信息来提醒开发者。
Linux堆内存管理
堆内存管理,不同的嵌入式开发环境,不同的操作系统实现也不完全相同
动态申请/释放的内存就属于堆内存,跟内存申请相关的函数:
#include <stdlib.h>
void *malloc(size_t size); //堆内存空间中申请一块用户指定大小的内存
void free(void *ptr);
void *calloc(size_t nmemb, size_t size); //在堆内存中申请nmemb个单位长度为size的连续空间,并将这块内存初始化为0
void *realloc(void *ptr, size_t size); //当申请的内存不够用时,我们可以使用realloc()函数动态调整内存块的大小,realloc()函数会新申请一块大小为200字节的空间,并将原来内存上的数据复制过来,返回给用户新申请空间的指针。
malloc()/free()函数的底层实现,其实就是通过系统调用brk向内核的内存管理系统申请内存。内核批准后,就会在BSS段的后面留出一片内存空间,允许用户进行读写操作。申请的内存使用完毕后要通过free()函数释放,free()函数的底层实现也是通过系统调用来归还这块内存的。
当用户要申请的内存比较大时,如大于128KB,一般会通过mmap系统调用直接映射一片内存,使用结束后再通过ummap系统调用归还这块内存。mmap区域是Linux进程中比较特殊的一块区域,主要用于程序运行时动态共享库的加载和mmap文件映射。早期的Linux内核将该区域设置在0x40000000附近,Linux 2.6以后的内核将该区域移到了栈附近,打印mmap映射区域的地址,你会发现大部分地址都在0xBxxxxxxx范围内,紧挨着进程的用户栈。
查看进程内存布局
# ps |grep xxx 找进程id
# cat /proc/pid/maps
栈的起始地址并不紧挨着内核空间0xc0000000,而是从0xbf9a2000作为起始地址,中间有一个大约6MB的偏移。heap区也不紧挨着.bss段,它们之间也有一个offset;mmap区也是如此,它和stack区之间也有一个offset。这些随机偏移由内核支持的可选配置选项/proc/sys/kernel/randomized_va_space控制,也可以关闭这个功能。将randomize_va_space赋值为0,可以关掉这个随机偏移功能。关闭这个功能后再去运行a.out,进程栈的起始地址就紧挨着内核空间0xc0000000存放,heap区和mmap区也是。
当程序加载到内存运行时,加载器会根据可执行文件的代码段、数据段(.data和.bss)的size大小在内存中开辟同等大小的地址空间。代码段和数据段的大小在编译时就已经确定,代码段具有只读和执行的权限,而数据段则有读写的权限。代码段和栈之间的一片茫茫内存虽然都是空闲的,但是要先申请才能使用。brk()系统调用通过扩展数据段的终止边界来扩大进程中可读写内存的空间,并把扩展的这部分内存作为堆区,使用start_brk和brk来标注堆区的起始和终止地址。在程序运行期间,随着用户申请的动态内存不断变化,brk的终止地址也随之不断地变化
内存分配器
大量的系统调用会让处理器和操作系统在不同的工作模式之间来回切换:操作系统要在用户态和内核态之间来回切换,CPU要在普通模式和特权模式之间来回切换,每一次切换都意味着各种上下文环境的保存和恢复,频繁地系统调用会降低系统的性能。系统调用还有一个不人性化的地方是不支持任意大小的内存分配,有的平台甚至只支持一个或数倍物理页大小的内存申请,这在一定程度上会造成内存的浪费。为了提高内存申请效率,减少系统调用带来的开销,在用户空间层面对堆内存介入管理。如在glibc中实现的内存分配器(allocator)可以直接对堆内存进行维护和管理。
内存分配器通过系统调用brk()/mmap()向Linux内存管理子系统“批发”内存,同时实现了malloc()/free()等API函数给用户使用,满足用户动态内存的申请与释放请求。当用户使用free()释放内存时,释放的内存并不会立即返回给内核,而是被内存分配器接收,缓存在用户空间。内存分配器将这些内存块通过链表收集起来,等下次有用户再去申请内存时,可以直接从链表上查找合适大小的内存块给用户使用,如果缓存的内存不够用再通过brk()系统调用去内核“批发”内存。内存分配器相当于一个内存池缓存,通过这种操作方式,大大减少了系统调用的次数,从而提升了程序申请内存的效率,提高了系统的整体性能。
Linux环境下的C标准库glibc使用ptmalloc/ptmalloc2作为默认的内存分配器,具体的实现源码在glibc-2.xx/malloc目录下。为了方便对内存块进行跟踪和管理,对于每一个用户申请的内存块,ptmalloc都使用一个malloc_chunk结构体来表示,每一个内存块被称为chunk。
用户程序调用free()释放掉的内存块并不会立即归还给操作系统,而是被用户空间的ptmalloc接收并添加到一个空闲链表中。malloc_chunk结构体中的fd和bk指针成员将每个内存块链成一个双链表,不同大小的内存块链接在不同的链表上,每个链表都被我们称作bin,ptmalloc内存分配器共有128个bin,使用一个数组来保存这些bin的起始地址。每一个bin都是由不同大小的内存块链接而成的链表,根据内存块大小的不同,我们可以对这些bins进行分类:
unsorted bin: 用户释放掉的内存块不会立即放到bins中,而是先放到unsorted bin中
small bins:内存数据块的大小范围为[16,504]
large bins:内存数据块的大于504字节
每个bin在数组中的索引和内存块大小之间的关系如表5-2所示。
除了数组中的这些bins,还有一些特殊的bins,如:
fast bins:用户释放掉的小于M_MXFAST(32位系统下默认是64字节)的内存块会首先被放到fast bins中。fast bins由单链表构成,FILO栈式操作,运行效率高,相当于small bins的缓存。
当用户申请一块内存时:
- 如果申请的内存块小于M_MXFAST时,ptmalloc分配器会首先到fast bins中去看看有没有合适的内存块,如果没有找到,则再到small bins中查找。
- 如果申请的内存块大于512字节,则到unsorted bin中查找,再到lar个bins中查找
- 如果在large bins中还没有找到合适的内存块,这时候就要到top chunk上去分配内存了。
内存块合并
在适当的时机,fast bins会将物理相邻的空闲内存块合并,存放到unsorted bin中。内存分配器如果在unsorted bin中没有找到合适大小的内存块,则会将unsorted bins中物理相邻的内存块合并,根据合并后的内存块大小再迁移到small bins或large bins中。
top chunk
top chunk是堆内存区顶部的一个独立chunk,它比较特殊,不属于任何bins。若用户申请的内存小于top chunk,则top chunk会被分割成两部分:一部分返回给用户使用,剩余部分则作为新的top chunk。若用户申请的内存大于top chunk,则内存分配器会通过系统调用sbrk()/mmap()扩展top chunk的大小。用户第一次调用malloc()申请内存时,ptmalloc会申请一块比较大的内存,切割一部分给用户使用,剩下部分作为top chunk。当用户申请的内存大于M_MMAP_THRESHOLD(默认128KB)时,内存分配器会通过系统调用mmap()申请内存。使用mmap映射的内存区域是一种特殊的chunk,这种chunk叫作mmap chunk。当用户通过free()函数释放掉这块内存时,内存分配器再通过munmap()系统调用将其归还给操作系统,而不是将其放到bin中。