谈谈程序启动那点事

本文主要是《程序员的自我修养》学习笔记,并着重阐述了程序启动的流程及Mac相关的知识点;

一、简介

程序内存通过分段形式来直接建立虚拟地址与实际物理地址之间的映射,若内存不足需要置换出段,导致效率低下;根据程序的局部性原理,可以将内存分割为更小粒度的内存,提高内存使用率,即“分页”;

“线程”共享进程的内存里的所有数据,甚至包括其他线程的堆栈,但实际运用中线程也拥有自己的私有存储空间,包括:

  • 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)
  • 线程局部存储(Thread Local Storage, TLS),线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量
  • 寄存器

一般把频繁等待的线程称之为“I/O密集型线程”,而很少等待的线程称之为“CPU密集型线程”;

“函数可重入”,表示函数没有执行完成,由于外部因素或内部因素,由一次进入该函数执行。一个函数要被冲入,只有两种情况:

  • 多个线程同时执行这个函数
  • 函数自身调用自身

编译器过度优化会导致不可预期的行为,如线程加锁后仍然行为异常,如下:

x = 0;
Thread1         Thread2
lock();         lock();
x++;                x++;
unlock();       unlock();

如果编译器为了提高变量x的访问速度,把x放到了某个寄存器里,因为不同线程的寄存器是各自独立的(可以理解为线程控制块存有私有的寄存器值,当线程切换时会保存私有的寄存器值,当线程切换回来会恢复寄存器的值),可能会出现如下情况:

  • [Thread1] 读取x的值到寄存器R1
  • [Thread1] R1++(由于之后可能还要访问x因此暂不会将R1寄存器的值写回内存变量x)
  • [Thread2] 读取x的值到某个寄存器R2
  • [Thread2] R2++并写回到内存中
  • [Thread1] 将寄存器R1的值写回内存中

上述情况会导致变量x的值不相同。

为防止编译器过度优化,可使用volatile关键字,其行为如下:

  • 阻止编译器为了提高速度将一个变量缓存到寄存器而不写回
  • 阻止编译器调整操作volatile变量的指令顺序;

Linux中使用clone(带有CLONE_VM参数)产生的用户态线程与内核态线程是一一对应得

二、静态链接

现在的继承开发环境(IDE),一般都将编译和链接的过程一步完成,称为“构建”;

程序构建过程:预处理(Prepressing)->编译(Compilation)->汇编(Assembly)->链接(Linking);


谈谈程序启动那点事_第1张图片
gcc编译过程分解

预编译

具体gcc预编译选项为-E

gcc -E hello.c -o hello.i

预编译文件的后缀为.i,主要处理规则如下:

  • 将所有#define删除并展开所有的宏定义;

  • 处理所有条件预编译指令,如#if#ifdef#elif#else#endif等;

  • 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置;

  • 删除所有的注释///* */

  • 添加行号和文件名标识,用于编译器产生调试用的行号信息以及产生编译错误或警告时能够显示行号;

  • 保留所有的#pragma编译器指令;

在所有的预处理指令中,#pragma 指令可能是最复杂的了,它的作用是设定编译器的状态或者是指示编译器完成一些特定的动作。如#pragma once是一个非标准但是被广泛支持的前置处理符号,会让所在的文件在一个单独的编译中只被包含一次。以此方式,#pragma once提供类似include防范的目的,但是拥有较少的代码且能避免名称的碰撞,如下头文件hello.h

#pragma once
struct data_t {
  xxx
}

编译

编译过程是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化生成相应的汇编代码文件,gcc选项为-S,命令如下:

gcc -S hello.i -o hello.s

具体编译的结果如下:


谈谈程序启动那点事_第2张图片
编译

汇编

汇编器是将汇编代码转换成机器可以执行的指令,汇编就是汇编语句翻译成对应的机器指令,因此不会涉及语义及指令优化,具体gcc命令如下:

gcc -c hello.s -o hello.o

经过预编译、编译、汇编后直接输出目标文件(Object File);


汇编

链接

使用ld命令工具将不同的目标文件链接称为可执行文件,后续将重点阐述。

编译器

编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。


谈谈程序启动那点事_第3张图片
编译器
词法分析

主要是将代码中的关键字、标识符、字面量(包括数字、字符串等)和特殊符号(如加号、等号)。

lex工具可以实现词法扫描

语法分析

语法分析就是对词法分析产生的记号通过上下文无关语法的分析手段进行分析,从而生成以表达式为节点的语法树。

语法分析也有一个现成的工具叫做yacc

语义分析

语义分析,就是完成对表达式的语法层面的分析,分为“静态语义”和”动态语义“;所谓的“静态语义”是指在编译期可以确定的语义,与之对应的“动态语义”就是只有在运行期才能确定的语义。

”静态语义“通常包括声明和类型的匹配,类型的转换。“动态语义”,如运行期才能确定的0作为除数是一个运行期语义错误。

优化

主要包括源代码优化和目标代码优化,如去除中间变量,选择合适的寻址方式,使用位移来代替乘法运算,删除多余的指令等,但生成的目标代码文件缺少未知变量的地址?因此需要通过链接来完成。

链接

链接就是将多个目标代码文件之间相互引用的符号进行重定位找到其地址,主要包括地址和空间分配、符号决议(也称符号绑定或者名称绑定,甚至地址绑定、指令绑定)和重定位等步骤。

目标文件

格式

目标文件就是源代码编译后但未进行链接的中间文件(Linux下格式为.o),其可执行文件的内容与结构很相似;

可执行文件的格式:linux的格式为ELF(Executable Linkable Format),静态和动态链接库、核心转储文件(core dump file)都是以该格式存储,可通过file命令查看文件格式。

目标文件内存包含了:机器指令代码、数据,还包括链接时所需要的一些信息,如符号表、调试信息、字符串等,以“段”(Segment)的形式存储。

目标文件包含代码段、数据段(包括已初始全局变量和局部静态变量.data;只读数据段.rodata,一般是程序的只读变量,如const修饰的变量和字符串常量)、bss段(未初始化的全局变量和局部静态变量)、注释段、字符串表(.strtab或者.shstrtab,分别保存普通字符串和段表字符串表)、符号表(.symtab,包括符号名+符号值)、调试段(.debug)等;

可通过binutils中的objdump工具来查看目标文件内部结构,如-h显示各个段的基本信息,-d代码段反汇编;或者使用readelf工具来输出目标文件的内容,对于Mac平台为otool工具,通过size命令来查看ELF文件的代码段、数据段和BSS段的长度;

分段的原因:

  • 不同段可被映射到不同虚拟存储区域,便于读写权限管理;
  • 利用现代CPU缓存体系及程序的局部性原理,将指令和数据缓存分离有利用提升缓存命中率;
  • 指令或数据共享,有利于提升内存空间利用率;
    谈谈程序启动那点事_第4张图片
    image.png

    对如上代码gcc -c -g hello.c -o hello.o生成目标文件hello.o,并使用objdump -s -d hello.o查看段内容,其中常量数据段__const内容如下:
    谈谈程序启动那点事_第5张图片
    image.png

    上述__const段内容正是const int c = 100对应的内容,为0x64=100,因为涉及到字节序问题,Mac对应的为“小端模式”,验证如下:
int test1_endian() {
    int i = 1;
    char *a = (char *)&i;
    
    if (*a == 1)
        printf("小端\n");
    else
        printf("大端\n");

    return 0;
}

所谓小端模式是一个数据的低位字节内容存在在低地址处,高位字节内容存放在高地址处,即字节存储是顺序存储,因此int类型变量i的地址为低地址;若为大端模式,即字节存储是从高位往低位存储,则变量i的地址为高地址。

上述代码原理:int类型的1,在小端模式最低位为1,在大端模式下,最高位为1。所以可以通过判断最低位是否为0来确定该机器的字节序是什么;

llvm是一个完整的编译器架构,也可以认为是一个用于开发编译器、解释器相关的库;clang是一个c++编写基于llvb的编译器。

llvm工具链中的nm工具可显示目标文件的符号表,或者使用objdmp --syms xxx也可以显示符号表;

nm显示符号表的类型如下:

  • U,未定义符号
  • A,绝对符号,表示该符号的值的绝对的,在以后的链接过程中,不允许改变,尝尝出现在终端向量表中;
  • T,代码段的符号,其值表示该符号在整个文件当中所处的位置
  • D,定义在数据段__data区中的符号,表明该符号在初始化数据段中;
  • B,定义在数据段__bss区中的符号,表明该符号位于非初始化数据区中;
  • C,普通符号,定义在数据段__common区中的符号
  • I,间接符号,表明是另一个符号的间接引用
  • S,其他符号,如代码段__const区中的符号


    谈谈程序启动那点事_第6张图片
    image.png

自定义数据段数据区,gcc提供了扩展机制通过关键字__attribute__((section("__DATA,""name")))作为变量属性加在定义前面,如:

__attribute__((section("__DATA,""FOO"))) int global = 11;
谈谈程序启动那点事_第7张图片
image.png

mach-o内部结构如下图:

谈谈程序启动那点事_第8张图片
mach-o

  • Header,头部,用于快速确认该文件的CPU类型、文件类型、加载命令数量、总字节大小、标志位;


    mach-o header
  • LoadCommands,加载命令,如何设置并加载二进制数据


    谈谈程序启动那点事_第9张图片
    LoadCommands加载命令解析
  • Data,数据段,存放数据及段信息

  • Loader Info,链接信息,包含了动态加载器用来链接可执行文件或依赖所需的使用的符号表、字符串表等;


    谈谈程序启动那点事_第10张图片
    mach-o内部结构选项

数据段中包含.eh_frameseciont部分,解释如下:
When using languages that support exceptions, such as C++, additional information must be provided to the runtime environment that describes the call frames that much be unwound during the processing of an exception. This information is contained in the special sections .eh_frame and .eh_framehdr.

ELF中的.rel.text重定位表未在mach-o文件中看到,主要是对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置,都记录在该表中;

趣探 Mach-O:文件格式分析

MachO 文件结构详解

符号

对于不同目标文件之间相互“粘合”起来,需要将相互引用的符号链接起来,其中将函数和变量统称为“符号”,函数名和变量名称为“符号名”。

每个目标文件中都会有一个相应的符号表(Symbol Table),表中记录了目标文件中所用到的所有符号,每个符号都有一个对应的值,称为“符号值”,符号的类型如下:

  • 全局符号(包括引用外部的全局符号)
  • 段名,有编译器产生,其值为该段的起始地址;
  • 局部符号,目标文件内部使用,对链接器来说无用,因此忽略;
  • 行号信息,即目标文件指令与源代码中代码行的对应关系;

主要关注全局符号,对于段名、局部符号和行号信息对其他目标文件“不可见”

mac平台符号表如下:

谈谈程序启动那点事_第11张图片
syms

linux平台符号表如下:
谈谈程序启动那点事_第12张图片
linux syms

相比Mac平台,linux下的gcc编译器下的符号信息更加详细,包含了符号值、符号大小、符号类型和绑定信息、Vis(c/c++中未使用)、Ndx符号所属的段、符号名称;

其中_printf_incre_g_test_a是未定义的;

ld链接器在链接脚本中定义的“特殊符号”(这些符号也可以使用),如下:

  • __executable_start,该符号为程序起始地址,不是入口地址,是程序最开始的地址;
  • _etext或者 _etext或 _etext,代码段结束地址,即代码段最末尾的地址;
  • _edata或edata,数据段结束地址,即数据段最末尾的地址;
  • _end 或者 end,程序结束地址;

以上地址都为程序被装载是的虚拟地址;

为了防止符号名冲突,c语言所有全局变量和函数经过编译后,符号名前会加上下划线"_";

不过符号名加下划线也可以通过gcc编译器选项-fleading-underscore-fno-leading-underscore来禁用;

对于c++语言,由于其各种复杂的特性,如函数重载、类、继承、虚机制、命名空间等,会采专用符号修饰符号改编的机制,如函数签名,即包含了函数名、参数类型、所在的类和命名空间等;

谈谈程序启动那点事_第13张图片
函数签名

c++filt工具可以用来解析被修饰过的名称;

强符号和弱符号

对于c/c++语言来说,针对符号定义,编译器默认函数和初始化了的全局变量为“强符号”,未被初始化的全局变量为“弱符号”;可以通过gcc的__attribute__((weak))来定义一个强符号为弱符号;

针对强弱符号概念,链接器定义如下规则来处理强弱符号:

  • 不允许多次定义强符号,否则链接器报重复定义错误;
  • 如果一个符号在某个目标文件为强符号,其他为弱符号,则选择强符号;
  • 如果一个符号在所有目标文件都是弱符号,则选择占用空间最大的一个;
强引用和弱引用

引用主要用于库的链接过程,通过gcc中的关键字__attribute__((weakref))来定义弱引用,对于弱引用,符号若未被定义,则链接器也不会报错,但与之对应的强引用就会报”未定义错误“;

调试信息

gcc编译添加-g选项就可以为目标文件加上调试信息,现在的ELF文件都采用一个叫做DWARF(Debug With Arbitrary Record Format)的标准调试信息格式,可通过strip工具来去除ELF文件中的调试信息,具体如下:

strip -S xx.o

静态链接过程

链接器主要是将目标文件各个相同性质的段合并,并进行地址和空间分配成可执行文件的过程。

”地址和空间“是指:

  • 在输出的可执行文件中的空间
  • 装载后的虚拟地址中的虚拟地址空间

对于.text.data来说,都需要在可执行文件和虚拟地址中分配空间;而对于.bss这样的段来说,只局限于虚拟地址空间,因为它们在文件中并没有内容。具体的链接过程如下:

  • 空间与地址分配,扫描所有的输入目标文件获取各个段的长度、属性和位置,并将符号表中所有的符号定义和符号引用收集统一放到全局符号表中,同时合并所有的同类型段并建立映射关系;
  • 符号解析与重定位,依据上一步收集的段数据及重定位信息,进行符号解析与重定位、调制代码中的地址等;

使用ld链接为可执行文件如下:

ld hello.o hello1.o -L/usr/local/lib -lSystem -o hello

其中-L为指定搜索的库路径,-l为指定库路径下库的名称;

ld默认的程序入口地址为_start,可通过-e来修改;默认的输出可执行文件名为a.out

谈谈程序启动那点事_第14张图片
ld链接

如上图所示,未链接前的目标文件代码段的VMA虚拟地址空间为0x00,因为虚拟空间还没被分配;待链接后,.text段被分配到了0x10000f00,所有的段都以此为基准地址;

对于段内符号地址,是段的虚拟地址确定后添加上符号在段的偏移,即段基址+段内偏移;

对于段外的符号地址,编译目标文件时采用临时地址的形式交由后续链接来确定实际虚拟地址,链接器根据符号的地址对每个需要重定位的指令进行地址修正。

具体链接器是根据目标文件中的需要重定位的每个段中的对应的重定位表,来调整目标文件中段外符号的地址;

谈谈程序启动那点事_第15张图片
image.png

谈谈程序启动那点事_第16张图片
image.png

上述符号的地址如何确定?其中之一就是指令修正,根据上述符号的重定位类型分为”相对寻址修正“和”绝对寻址修正“;

链接器处理多个相同”弱符号“但类型不同采用的COMMON块的机制(源于Fortan动态分配内存的机制),即采用类型占用空间大的符号为最终的符号;对于其他相同符号但类型不同的情况,如都是强符号会报错;一个强符号和弱符号,以强符号为准(若弱符号所占空间大于强符号,则警告提示);造成上述采用COMMON块机制的原因是链接器不支持符号类型

image.png

为防止不同类型的符号采用COMMON块的形式,则可以通过gcc的-fno-common选项或者__attribut__((nocommon))属性定义变量来禁用COMMON块的处理;

不是有-fno-common选项,则弱符号类型如下:

谈谈程序启动那点事_第17张图片
image.png

使用-fno-common选项,则弱符号类型如下:
谈谈程序启动那点事_第18张图片
image.png

静态库

静态库是一组目标文件的集合,即很多目标文件经过压缩打包(使用ar压缩工具)后形成的一个文件。如linux中最常用的c语言函数库libc位于/usr/lib/libc.a,属于glibc项目的一部分。

gcc -v xxx //使用--verbose选项可打印出详细的编译过程,简写-v

链接过程控制

绝大部分情况下,使用链接器默认的链接规则就可以完成链接任务,但对于一些特殊要求的程序,如操作系统、BIOS或嵌入式系统程序,以及一些内核驱动程序等,往往受限于一些特殊的条件,如须要指定输出文件的各个段虚拟地址、段名称、段存放顺序等,因此需要链接控制来控制链接过程,一般有如下三种方式:

  • 使用命令行指定链接器参数,如输出指定目标文件名-o、程序启动入口-e
  • 将链接指令存放在目标文件中,如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段来传递参数;
  • 使用链接控制脚本,这种方式也是最为灵活、强大的链接控制方法;

linux平台下可使用如下指定链接控制脚本:

ld -T link.script

具体的详细链接脚本控制这里不详细讨论,有兴趣的自行查阅相关资料。

三、装载与动态链接

linux32位系统虚拟内存空间布局如下图:

谈谈程序启动那点事_第19张图片
image.png

可执行文件中存在诸多段,且很多段占用空间不足一个页,程序运行装载是为了有效减少虚拟内存地址空间的占用,链接过程时就将相同权限的section节(如只读、可读可执行、可读可写)合并为一个segment段,便于后续装载。

但上述相同权限的合并为一个segment段,但段的长度有时不足以一个页的长度,仍然会造成内存碎片,unix的虚拟内存管理通过邻近段共享一个物理页面。因此,一个物理页面可能包含了两个段的数据,甚至可能多于两个段(多个段长度加起来未超过页的长度),段的虚拟内存起始地址就可能不是系统页面长度的整数倍了,但里面会涉及到段地址对齐,两个相邻段虚拟内存起始地址有可能不完全等于起始地址+段长度大小。

栈初始化,程序启动后会将环境变量及传入的参数依次压入栈,用于后续main函数使用;

程序启动过程

创建新进场并执行新的程序流程如下:

  • 调用系统调用函数fork()创建新进程,其中包含了创建进程描述符结构及其内部子结构(如内存管理结构mm、文件管理结构、信号等)并复制父进程的描述符结构,创建进程的内核栈并初始化进程描述符thread.esp指向新的内核栈基地址,更新进程状态字段及pid,加入进程调度队列等;Linux进程描述符task_struct结构体详解--Linux进程的管理与调度(一) 、《深入理解Linux内核》

  • 调用execve()系统调用,该系统调用是glicexecvp()的包装,实际系统调用的入口是sys_execve()对参数进行检查复制,然后调用do_execve()并传递环境变量及输入参数指针(这些变量位于用户空间),该内核函数会查找被执行文件并读取文件的前128个字节,具体是确定文件的类型(通过文件开头的魔数magic),根据文件的类型调用相应的处理函数,如魔数(占用4个字节)为0x7Felf,则为ELF可执行文件;若为解释型语言,则为#!开头的解释器名称;

  • 根据文件类型调用相应的装载函数,对于ELF为load_elf_binary(),该函数的具体逻辑如下:

    • 检查ELF可执行文件格式的有效性,比如魔数、ELF-Header中段的数量;
    • 创建用户态堆栈并设置命令行参数及环境变量到用户堆栈;
    • 创建内存管理结构并根据ELF可执行文件的头部表描述,对ELF文件进行映射,比如代码段、数据段等;
    • 寻找动态链接.interp段,若使用了动态链接库,就能获取动态链接器路径,并使用load_elf_interp()加载其映像;
    • 调用start_thread()函数修改保存在内核态堆栈但属于用户态寄存器的eipesp,使其分别指向动态链接程序的入口点和新的用户堆栈栈顶;
  • 进程系统调用执行iret指令返回时从内核态转向用户态,其中会根据上一步的eip esp寄存器执行动态链接程序;

    • 动态链接程序在用户堆栈建立执行上下文,并检查被执行程序以识别哪些共享库必须装载及每个共享库中哪个函数被有效的请求;
    • 解释器调用mmap()系统调用创建线性区,并将程序实际使用的库函数(正文和数据)的页进行映射;
    • 解释器根据线性区的线性地址更新对共享库符号的所有引用;
    • 动态链接程序跳转到被执行程序的主入口函数_start开始执行;

    Linux进程启动过程分析do_execve(可执行程序的加载和运行)---Linux进程的管理与调度(十一)

动态链接

动态链接的目的是减少重复模块内存占用,并便于升级动态库。动态链接的过程发生在程序装载时,而不是像静态链接一样在程序装载前;动态链接增加了程序运行的灵活性,不过也导致程序在性能上的一些损失(相比静态链接,大约在5%以下)。可执行文件的动态符号是未定义的,如下图:


谈谈程序启动那点事_第20张图片
image.png

对于共享对象,linux和gcc支持“装载时重定位”,即程序装载时对程序模块中目标地址不确定的对象进行重定位;但由于动态库是共享的,尤其指令部分,重定位时需要修改指令,因此通过对于动态库可修改数据部分复制一份副本来解决,即“地址无关代码”技术。

gcc通过-shared-fPIC选项来支持动态链接,如果只使用-shared,那输出的共享对象就是使用装载时重定位方法,-fPIC实现地址无关代码;

对于需要装载时确定的变量和函数访问,采用了全局偏移表(Global Offset Table, GOT),即在数据段中建立一个指向这些变量或目标函数的指针数组,当需要引用变量或调用目标函数时,可以通过GOT中相对应的项间接引用,如下图所示:

谈谈程序启动那点事_第21张图片
image.png

谈谈程序启动那点事_第22张图片
image.png

动态链接性能优化

动态链接比静态链接慢的原因是需要对共享对象进行复杂的GOT定位,然后间接寻址,并且是程序启动装载是进行,会影响程序的启动速度。

动态链接下,存在大量的动态库的函数应用(其中动态库的全局变量较少),因此程序启动时需要进行函数引用的符号查找及重定位,势必会增加动态链接的运行时间。其实动态库中的很多函数在程序执行时都不会用到(如一些错误处理函数及功能模块等),重定位所有目标函数实际上是一种浪费,因此出现了延迟绑定的技术,基本思想是当函数第一次被用到是才进行绑定(符号查找及重定位等),等到需要绑定时由动态链接器来负责绑定;并且提供了运行时加载的API,如打开动态库dlopen()、查找符号dlsym()、错误处理dlerror()及关闭动态库dlclose()

在linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样会通过映射的方式将其加载到进程的地址空间,并执行共享对象的入口地址;动态链接器取得控制权后会对自身进程初始化操作,然后根据当前的环境参数,开始对可执行文件链接工作。链接完成后,会将控制权交到可执行文件的入口地址,程序开始执行。

动态链接器具体的路径是在ELF可执行文件的.interp段,该段中保存的动态链接器路径字符串;

谈谈程序启动那点事_第23张图片
.interp

.dynamic段保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置(.dynsym段)、动态链接重定位表的位置、共享对象初始化代码的地址等;
谈谈程序启动那点事_第24张图片
.dynamic

对于Mac,通过otool -l hello查看加载命令如下:
谈谈程序启动那点事_第25张图片
image.png

dyld加载时,为了优化程序启动,启用了共享缓存(shared cache)技术。共享缓存会在进程启动时被dyld映射到内存中,之后,当任何Mach-O映像加载时,dyld首先会检查该Mach-O映像与所需的动态库是否在共享缓存中,如果存在,则直接将它在共享内存中的内存地址映射到进程的内存地址空间。在程序依赖的系统动态库很多的情况下,这种做法对程序启动性能是有明显提升的,共享缓存是以文件形式存放在/var/db/dyld/目录下。

dyld是开源的,地址为Source Browser

深入理解iOS App的启动过程

dylib动态库加载过程分析

dyld详解

linux可通过ldd命令工具查看程序依赖的共享库,Mac可通过otool -L xx来查看;

共享库

linux共享库命名规则必须为libname.so.x.y.z,其中x表示主版本号,y表示次版本号,z表示发布版本号。

主版本号表示库的重大升级,不同版本库是不兼容的,而次版本号是对主版本的增量升级,如增加了新的接口符号,且保持原来的符号不变;发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不会接口进行修改。

共享库的路径如下:

  • /lib,主要存放系统最关键和基础的共享库,如动态链接器、c语言运行库、数学库等;
  • /usr/lib,主要保存非系统运行时关键的共享库,如开始时用到的库,这些库一般不会被用户程序或shell脚本直接使用;
  • /usr/local/lib,与操作系统无关的库,主要是第三方程序的库;

动态链接器查找共享库的路径依据.dynamic段中保存的DT_NEED路径,来决定是绝对路径还是相对路径,若为相对路径,则去/lib、/usr/lib、/etc/ld.so.conf配置文件来查找。为加快查找速度,在/etc/ld.so.cache文件中缓存了共享库;

linux可使用环境变量改变动态链接器装载共享库路径的方法,如下:

  • LD_LIBRARY_PATH,临时改变程序的共享库查找路径,其会改变查找的顺序:
    • 环境变量指定路径
    • /etc/ld.so.cache指定路径
    • 默认共享库路径,先/usr/lib,后/lib;
  • LD_PRELOAD,指定预先装载的一些共享库或目标文件(比指定目录装载的还要优先),可用于覆盖后续加载的同名全局符号来改写库的某些函数,对程序调试或测试非常有用;
  • LD_DEBUG,打开动态链接器调试功能,动态链接器会打印各种有用的信息;

内存

linux进程地址空间布局如下图:


谈谈程序启动那点事_第26张图片
linux进程地址空间布局

栈保存了一个函数调用所需要的维护信息,称为“堆栈帧”或“活动记录”,一般包括:

  • 函数的返回地址和参数

  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量

  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器


    谈谈程序启动那点事_第27张图片

    函数体的标准开头一般是这样的:

  • push ebp,把ebp压入栈中

  • mov ebp, espebp = esp,这时ebp指向栈顶;

  • sub esp, xxx在栈上分配xxx字节的临时空间

  • push xxx,保存寄存器(主要是函数返回时恢复以前的状态)

函数返回时:

  • pop xxx,恢复保存过的寄存器
  • mov esp, ebp,恢复esp同时回收局部变量空间
  • pop ebp,从栈中恢复保存的ebp
  • ret,从栈中取得返回地址,并跳转到该位置;

进程的堆空间管理完全交由操作系统来管理,会造成每次都需要进行系统调用,严重影响程序性能;而是采用程序向操作系统申请一块适当大小的对空间,然后由程序自己管理这块空间,因此涉及到堆的分配算法。

linux堆管理如下:

谈谈程序启动那点事_第28张图片
linux堆管理

linux 内核维护一个break指针,这个指针指向堆空间的某个地址。从堆起始地址(Heap’s Start)到break之间的地址空间为映射好的(虚拟地址与物理地址的映射,通过MMU实现),可以供进程访问;而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。所以,如果Mapped Region 空间不够时,会调整break指针,扩大映射空间,重新分配内存。

linux分配堆空间的分配方式:brk()系统调用,mmap()内存映射;

int brk(void* end_data_segment)

brk()作用实际上是设置进程数据段的结束地址,即它可以扩大或者缩小数据段(linux下数据段和bss合并一起统称为“数据段”);glibc还有一个函数sbrk(),其功能与brk()类似,只不过参数和返回值略有不同,实际上是brk()的包装。

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)

mmap作用是向操作系统申请一段虚拟内存地址空间,该空间可以映射到某个文件,或者不映射作为“匿名空间”使用。

glibc中的malloc函数具体处理用户的空间请求如下:

  • 对于小于128KB的请求,会从现有堆空间按照堆分配算法分配;
  • 对于大于128KB的请求,会使用mmap函数分配一块匿名空间,然后再这个匿名空间为用户分配空间;

C语言内存管理:malloc、calloc、free的实现

linux-malloc底层实现原理

对于如何管理一大块连续的内存空间,涉及到堆分配算法,主要是如下:

  • 空闲链表,将各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个链表,知道找到合适大小的块并拆分;当释放时将其合并到空闲链表中;

    空闲链表损坏导致整个堆无法使用,且搜索链表效率较低;

  • 位图,将内存空间划分为大量块(block),每个块大小相同,第一个块称为已分配区域的头(Head),其余的称为已分配的主体(Body),每个块存在头/主体/空闲三种状态,并用整数数组来记录块的使用情况;

    速度快,稳定性好(为避免用户越界破坏数据,可简单备份下位图),易于管理。但若块设置大小太大,会造成内存碎片;若太小,会导致位图占用空间大,可采用多级位图缓解。

  • 内存池,针对实际使用中被分配对象的大小是较为固定的几个值,因此可以按照每次请求分配的大小作为一个单位来划分堆空间,块的管理可以采用空闲链表或者位图;C++ 内存池介绍与经典内存池的实现

运行库

典型的程序运行步骤如下:

  • 操作系统创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数;
  • 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等;
  • 入口函数完成初始化后,调用main函数,正式执行程序主体部分;
  • main函数执行完毕后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。

上文中“程序启动流程”中已经阐述动态链接程序会最终执行被执行程序的主入口函数,其为_start,具体原因是ld链接时被链接控制脚本链接到程序.text段的起始位置;

谈谈程序启动那点事_第29张图片
程序入口

谈谈程序启动那点事_第30张图片
image.png

链接控制脚本中设置_start入口函数代码:
谈谈程序启动那点事_第31张图片
链接器脚本

具体的_start函数执行流如下:

_start -> __libc_start_main -> main -> exit

__libc_start_main()函数定义如下:

int LIBC_START_MAIN
    (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
    int argc,
    char **argv,
    __typeof (main) init,
    void (*fini) (void),
    void (*rtld_fini) (void),
    void *stack_end)

其实现关键函数调用如下:

__pthread_initialize_minimal();
__cxa_atexit(rtld_fini, NULL, NULL);
__libc_init_first(argc, argv, __environ);
__cxa_atexit(fini, NULL, NULL);
(*init)(argc, argv, __environ);
result = main(argc, argv, __environ);
exit(result);

具体的工作就是建立运行环境并设置一系列的main函数执行前后的处理函数指针,包括argc/argv入栈,初始化线程环境,注册main函数的退出处理函数指针,初始化libc

init初始化(该函数位于.init段),执行main函数,main函数结束后调用exit函数,该函数会调用注册的退出处理函数,进程正常退出;

其中_start汇编代码中末尾指令为hlt,该指令表示强行停止程序,为了防止程序未调用exit函数(该函数执行不会返回,直接进程退出,因此正常情况下不会执行hlt指令)。

用户空间的程序启动过程

Mac dyld进程启动

首先明确一个概念:”进程地址空间布局随机化(Address Space Layout Randomization, ASLR)“,是一种避免APP被攻击的有效保护。

采用ASLR技术,进程每次启动时,地址空间都会被简单地随机化——只是偏移,不是搅乱。实现方式是通过内核将Mach-O的“平移”某个随机数。

iOS上我们可以直接使用dyld.h中的方法_dyld_get_image_vmaddr_slide来获取image虚拟地址的偏移量。

进程拥有私有的内存地址空间,传统的方式,进程启动加载都是按照固定可预见的方式,即进程镜像在虚拟内存地址的固定的,且进程运行生命周期内,大部分内存分配的操作都是按照相同的方式,因此进程在内存的地址分布具有非常强的可预测性。

这给黑客提供了更大的施展空间。黑客主要采用的方法是代码注入:通过重写内存中的函数指针,黑客就可以将程序的执行路径转到自己的代码,将程序的输入变为自己的输入。重写内存最常用的方法是采用缓冲区溢出(即利用未经保护的内存复制操作越过栈上数组的边界),将函数的返回地址重写为自己的指针。此外,任何用户指针甚至结构化的异常处理程序都可以导致代码注入。这里的关键问题在于判断重写哪些指针,也就是说,可靠地判断注入的代码应该在内存中的什么位置

Mach-O文件介绍之ASLR(进程地址空间布局随机化)

image.png

dyld动态链接器的入口为__dyld_start,其中存在bl函数跳转指令跳转到dyldbootstrap::start(),该函数主要工作是:

  • rebaseDyld(),内核ASLR偏移来重定位dyld
  • mach_init()mach消息初始化,以使用mach消息
  • __guard_setup,栈溢出保护
  • dyld::main(),执行dyld主体函数

具体main主体函数流程如下:

  • 设置运行环境

    主要是设置运行参数、环境变量等,如mach-o头部结构体及ASLR偏移量,并调用setContext()设置上下文,包括dyld的回调函数、参数(如argc、argv、envp等)及一些标志信息;配置进程的受限模式(默认开启受限模式并代码签名);检测并设置环境变量;获取架构信息;

  • 加载共享缓存

    iOS是必须要开启共享缓存,其共享缓存路径为/System/Library/Caches/com.apple.dyld/dyld_shared_cache_armX,通过mmap进行映射并重定位;

  • 实例化主程序

    主要是将主程序的mach-o加载进内存,并实例化一个ImageLoader对象;

  • 加载插入的动态库

    加载环境变量DYLD_INSERT_LIBRARIES设置的动态库;

  • 链接主程序

    调用link()函数将实例化的主程序进行动态修正,让二进制变为可正常执行的状态;

  • 链接插入的动态库

  • 执行弱符号绑定

  • 执行初始化方法

    其中包含了类加载+load()以及全局c++/c对象的构造函数;

  • 查找主程序的入口并返回

    从加载命令LC_MAIN读取入口,如无则使用LC_UNIXTHREAD读取入口,并返回入口函数地址;

可以设置DYLD_PRINT_STATISTICS_DETAILS环境变量来打印应用的启动时间,如下图:

谈谈程序启动那点事_第32张图片
程序启动时间统计

谈谈程序启动那点事_第33张图片
dyld调试选项

具体详细的关于dyld的使用,可通过man dyld查看。

系统调用

linux系统调用是通过0x80中断完成,各个通用寄存器用于传递参数,其中eax寄存器用于系统调用的接口号,如eax=1表示退出进程(exit),eax=2表示创建进程(fork),eax=3表示读取文件或I/O(read),eax=4表示写文件或I/O(write)等,每个系统调用都对应内核代码中的函数,以sys_开头。

具体的系统调用实现是通过函数库封装的_syscall()中的int $0x80汇编指令执行,并通过eax及其他通用寄存器来传递参数;中断发生后,CPU根据操作系统建立的系统调用中断描述符转入内核态,并切换到内核栈并保存用户态进程的寄存器到内核栈,同时跳转到具体的系统调用函数执行,执行系统调用服务后,会执行IRET指令,该指令会回到用户态并恢复内核栈保存的用户态寄存器转入用户态,此时继续执行eip执行的int $0x80指令后的指令继续执行,直至系统调用完成。

实用工具

  • vmmap,用于显示进程的虚拟内存使用

  • vm_stat,用于显示内核内部的虚拟内存统计;

    其中涉及到虚拟内存的一些概念:


    谈谈程序启动那点事_第34张图片
    页面状态

附录测试程序

hello.c

#include 
#include "hello1.h"

int g_int_a = 1;
static float s_float_a;
const int c = 100;
const char *name __attribute__((section("__DATA," "FOO"))) = "hello";

extern int incre_g_int_a(void);

int main(int argc, char **argv) {
    int a = 1;
    incre_g_int_a();
    printf("g_int_a:%d, a:%d", g_int_a, a);

    return 0;
}

hello1.h

int incre_g_int_a(void);

hello1.c

#include "hello1.h"

extern int g_int_a;

int incre_g_int_a(void) {
    g_int_a++;
}

你可能感兴趣的:(谈谈程序启动那点事)