『operating system-3』runtime library

库与运行库

一、程序的内存布局

  • 应用程序的内存空间:从上往下依次是:

    • 栈:维护函数调用的上下文,从高地址向下扩展
    • 动态链接库映射区:装载依赖的动态链接库
    • 堆:容纳应用程序动态分配的内存区域(malloc or new),从低地址向上扩展
    • 可执行文件映像:可执行文件的可读可写区、只读区等段
    • 保留区:被禁止访问的低地址区域

    注意:“segment fault” 常发生在非法指针解引用,如试图写0地址,随机地址


  • 栈:遵循先入先出(FIFO)的动态内存区域

    • 栈帧:保存函数调用所需要维护的信息,又称活动记录,主要包括以下内容:

      • 函数接收的参数与返回地址
      • 临时变量:函数内的非静态局部变量,或编译器自动生成的其它临时变量
      • 保存上下文:函数调用前后需要保持不变的寄存器
    • “烫”与“屯”:Debug模式下,分配的栈空间(未初始化的局部变量)被初始化为0xCC或0xCD

    • 与栈相关的两个寄存器:esp和ebp

      • esp:始终指向栈顶,其位置随函数执行而不断变化
      • ebp:又称“帧指针”,其位置固定不变,指向栈中调用函数前的ebp旧值
    • i386标准函数调用流程:2、3两步由call指令一起执行

      1. 把所有或部分参数压栈,或使用特定寄存器传参

      2. 将当前调用指令的下一条指令的地址(即返回地址)压入栈中

      3. 保存好参数返回地址后,跳转到函数体:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      /* 将ebp寄存器的 旧值 保存在栈上 */
      push ebp
      /* 将ebp固定在当前栈顶位置 */
      mov ebp, esp
      /* 开辟x字节的栈空间 */
      sub esp, x
      /* 保存n个寄存器(可选) */
      push reg_1
      ...
      push reg_n
      /* ... FUNCTION BODY ... */
      /* 恢复n个寄存器(可选) */
      pop reg_n
      ...
      pop reg_1
      /* 回收开辟的栈空间 */
      mov esp, ebp
      /* 恢复ebp寄存器的旧值 */
      pop ebp
      /* 调用ret指令返回 */
      ret

      注意:对于声明为static(或仅在本编译单元被调用)的函数,可能不会按照上面的标准流程执行


    • 调用惯例:函数的调用方被调用方之间的约定

      • 函数参数的传递顺序和方式:规定参数的压栈顺序传参寄存器
      • 栈的维护方式:约定负责弹栈的一方,保持栈在函数调用前后保持一致
      • 名字修饰策略:通过对函数本身的名字进行修饰,以区分不同的调用惯例
    • 常见的调用惯例:

      • cdecl:默认调用惯例
        • 压栈顺序:从右向左将参数列表压栈
        • 负责出栈方:函数调用方
        • 名字修饰:下划线 + 函数名,如_foo
      • stdcall:
        • 压栈顺序:从右向左将参数列表压栈
        • 负责出栈方:函数本身
        • 名字修饰:下划线 + 函数名 + @ + 参数列表的总字节数,如_foo@12
      • fastcall:通过寄存器传参优化性能
        • 压栈顺序:头两个4字节(或更少)的参数通过寄存器传参,其余参数从右向左压栈
        • 负责出栈方:函数本身
        • @ + 函数名 + @ + 参数列表的总字节数,如@foo@12
    • 函数返回值的传递:

      • 返回值 \(\le\) 4字节:使用eax寄存器传值;4字节 \(\lt\) 返回值 \(\le\) 8字节:eax存低四字节、edx存高位
      • 返回值 \(\gt\) 8字节:在栈上开辟空间保存返回值对象,并将返回值的地址通过eax寄存器传出

  • 堆:占据绝大部分的虚拟空间

    • Linux 进程堆管理:两个系统调用,分别是brk( )和mmap( )

      • brk: 设置进程数据段的结束地址,其C函数原型为 int brk(void* end_data_segment);
      • mmap: C函数原型为 void* mmap(void* start, size_t length, int prot, int flags, int fd, ...);
        • start与length:指定需要申请的空间始址长度
        • prot:指定申请空间的权限(可读 or 可写 or 可执行)
        • flags:指定申请空间的映射类型(文件映射 or 匿名空间
        • fd:若为文件映射,则指定被映射文件的描述符
    • Windows 进程堆管理:

      • Windows 进程地址空间:

      Windows进程地址空间

      • EXE一般位于地址 0x00400000;运行库DLL一般位于地址 0x10000000

      • VirtualAlloc: Windows提供的API,用于向系统申请虚拟空间

      • 堆管理器:Windows中提供与堆相关的一套API

        • HeapCreate:创建一个堆(通过VirtualAlloc实现)
        • HeapAlloc:在一个堆里分配一块较小的内存
        • HeapFree:释放已经分配的内存
        • HeapDestroy:销毁一个堆

        注意:Windows中,当一个堆空间不够时,会创建更多的堆,故进程中可能存在多个堆


    • 堆分配算法:用于管理堆空间,做到按需分配与回收释放

      • 空闲链表:将堆中各空闲的块按链表串起来
        • header:每个空闲块的开头有一个头结构,记录上一个和下一个空闲块的地址
        • prev与next:分别指向上一个空闲块地址和下一个空闲块地址
        • 分配方案:在空闲链表中查找足够大的空闲块,并将其中一部分分配出去
      • 位图:将堆均分为大量的块,并使用数组管理块
        • 分配方案:每次分配整数个块,第一个块为Head,其余块为Body
        • 块状态:Head or Body or Free

二、运行库

  • 入口函数(Entry Point):一个程序初始化结束的部分

    • GLIBC入口函数:

      • 调用_start函数前:装载器依次将环境变量用户参数压入栈中

      • 调用_start函数:_start入口由ld链接器的链接脚本决定

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      libc\sysdeps\i386\elf\Start.S
      _start:
      /* 将ebp寄存器置0 */
      xorl %ebp, %ebp
      /* esi寄存器指向argc */
      popl %esi
      /* ecx寄存器指向argv */
      movl %esp, %ecx

      /* 下面向 __libc_start_main 函数传参 */
      /* 将 栈顶地址 压栈 */
      pushl %esp
      /* 将 rtld_fini函数地址 压栈 */
      pushl %edx
      /* 将 __libc_csu_fini函数地址 压栈 */
      pushl $__libc_csu_fini
      /* 将 __libc_csu_init函数地址 压栈 */
      pushl $__libc_csu_init
      /* 将 用户命令 压栈 */
      pushl %esi
      /* 将 main函数地址 压栈 */
      pushl main
      /* 跳转到 __libc_start_main 函数*/
      call __libc_start_main
      /* 若函数调用失败,hlt会强行把程序停下来 */
      hlt
      • __libc_start_main:包括全局对象的构造与析构、main函数的调用与退出
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      int __libc_start_main(
      int (*main) (int, char**, char**),
      int argc,
      /* 用户命令,即argv */
      char** ubp_av,
      /* init 负责 main 调用前的初始化工作 */
      __typeof (main) init,
      /* fini 负责 main 结束后的收尾工作 */
      void (*fini) (void),
      /* rtld_fini 负责和动态加载相关的收尾工作 */
      void (*rtld_fini) (void),
      void* stack_end
      ) {
      /* 取出环境变量 */
      char** ubp_ev = &ubp_av[argc + 1];
      __environ = ubp_ev, __libc_stact_end = stack_end;
      ...
      /* 若干初始化操作 */
      /* ⬇ 通过 __cxa_atexit 函数注册退出函数 ⬇ */
      /* __cxa_atexit(rtld_fini, NULL. NULL); */
      /* __cxa_atexit(fini, NULL, NULL); */
      ...
      int result = main(argc, argv, __environ);
      exit(result);
      }
      • exit与_exit:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      void exit(int status) {
      /* 遍历 __exit_funcs函数链表,依次调用由 __cxa_atexit 注册的函数 */
      while (__exit_funcs != NULL) {
      ...
      __exit_funcs = __exit_funcs -> next;
      }
      ...
      _exit(status);
      }

      _exit:
      movl 4(%esp), %ebx
      movl $__NR_exit, %eax
      /* 执行exit系统调用,结束程序 */
      int $0x80
      /* 若程序终止失败,hlt会强行把程序停下来 */
      hlt

      注意:程序可以在main函数返回后调用exit退出,也可以直接调用exit退出


    • MSVC CRT 入口函数:

      • mainCRTStartup:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      int mainCRTStartup(void) {
      ...
      /* 在堆初始化之前只能使用 _alloca 分配空间 */
      posvi = (OSVERSIONINFO *)_alloca(sizeof(OSVERSIONINFO));
      posvi -> dwOSVersionInfoSize = sizeof(OSVERSIONINFOA);
      /* 获取当前操作系统的版本信息,初始化系统全局变量 */
      GetVersionExA(posvi);
      /* 填写平台信息 */
      _osplatform = posvi -> dwPlatformId;
      /* 填写主版本号 */
      _winmajor = posvi -> dwMajorVersion;
      _winminor = posvi -> dwMinorVersion;
      /* 填写操作系统版本 */
      _osver = (posvi -> dwBuildNumber) & 0x07fff;
      ...
      /* 先通过_heap_init初始化堆 */
      if (!_heap_init(0)) {
      fast_error_exit(_RT_HEAPINIT);
      }

      __try {
      /* 通过_ioinit初始化IO */
      if (_ioinit() < 0) {
      _amsg_exit(_RT_LOWIOINIT);
      }
      /* 获取命令行与环境变量 */
      _acmdln = (char*)GetCommandLineA();
      _aenvptr = (char*)__crtGetEnvironmentStringA();
      if (_setargv() < 0) {
      _amsg_exit(_RT_SPACEARG);
      }
      if (_setenvp() < 0) {
      _amsg_exit(_RT_SPACEENV);
      }
      /* 设置其它C语言库 */
      initret = _cinit(TRUE);
      if (initret != 0) {
      _amsg_exit(initret);
      }
      _initenv = _environ;
      /* 调用 main 并退出 */
      mainert = main(__argc, __argv, _environ);
      _cexit();
      }
      __except {
      ...
      /* 异常处理 */
      }
      }

      注意:try-except块是Windows结构化异常处理机制SEH的一部分


    • 运行库(CRT)与I/O:

      • I/O:操作系统中代指程序与外界的交互,如文件、管道、网络,命令行等

      • 文件:操作系统中具有输入输出概念的设备实体

        • 文件操作:借助FILE结构体指针实现
        • 文件管理:在Linux中使用文件描述符(fd);在Windows中使用句柄
      • 打开文件表:指针数组,其中每个元素指向内核的打开文件对象

        操作系统中的文件

        注意:FILE结构与fd是一一对应

    • MSVC CRT 的入口函数初始化:

      • 堆初始化:_heap_init( )通过调用HeapCreate创建系统堆
      • I/O初始化:_ioinit( )
        1. 建立打开文件表
        2. 从父进程获取继承的文件句柄
        3. 初始化标准输入输出

  • C/C++ 运行库:

    • C语言运行库:主要包括启动与退出函数、标准库函数、I/O功能函数等运行时依赖

      注意:世界上第一个C语言标准由ANSI于1989年制定(即C89)

    • C语言标准库:主要包括文件操作、字符串处理、数学函数等C头文件,是运行库的主要部分

      • 变长参数:C语言中特殊参数形式,通过省略号追加任意数量、任意类型的参数

        1. 头文件:stdarg.h
        2. 实现方式:cdecl调用惯例,即从右向左压栈,且调用方负责弹栈
        3. va_list:指向各不定参数的指针类型,一般为 char* 类型
        4. va_start:将va_list定义的指针指向第一个不定参数
        5. va_arg:获取当前不定参数的值,并将va_list移向下一个参数
        6. va_end:将va_list指针清0

        注意:GCC和MSVC均支持定义宏时使用变长参数

      • 非局部跳转:

        • 头文件:setjmp.h
        • 实现方式:调用 longjmp( ) 跳转至 setjmp( ) 函数返回的时刻,并重新指定其返回值

    • glibc与MSVC CRT :C语言程序与操作系统平台之间的抽象层

      • glibc :GNU C Library,即Linux平台下的C标准库

        • 主要组成:标准头文件 + 库的二进制文件部分

        • glibc启动文件:包括crt1.o、crti.o、crtn.o等

          • crt1.o:包含入口函数_start
          • crti.o:包含_init( )和 _fini( )函数的开头代码
          • crtn.o:包含_init( )和_fini( )函数的结尾代码

          最终输出文件的“.init”段只包含函数_init( ),“.fini”段只包含函数 _fini( )

          注意:链接时实际顺序为 crti.o -> crtbegin.o -> someobjs.o -> crtend.o -> crtn.o

        • GCC平台相关目标文件:包括crtbegin.o、crtend.o等

          • crtbegin.o:实现C++的全局对象构造
          • crtend.o:实现C++的全局对象析构
          • libgcc.a:执行不同硬件平台下的数学运算函数
          • libgcc_eh.a:支持C++异常处理的相关函数

          注意:.init和.fini仅在main( )执行前后运行,实际上与全局对象的构造析构无关


      • MSVC CRT :Microsoft Visual C++ C Runtime,即Windows平台下的C标准库

        • 主要组成:不同分类指标下的多种子版本,如静态/动态链接版、单线程/多线程版等 不同属性之间可以互相组合 编译器cl根据传入参数选择对应的CRT,如选项 /MDd 对应选择msvcrtd.lib

        • 静态运行库的命名规则:libc [p] [mt] [d] .lib,其中:

          • p表示CPlusplus,即C++标准库
          • mt表示Multi-Thread,即表示支持多线程
          • d表示Debug,即表示调试版本
        • 动态运行库:含有用于链接的.lib文件、以及运行时使用的.dll动态链接库

          动态链接库的命名规则中增加了版本号,如多线程 + 动态链接的 msvcr90.dll

        注意:若DLL分别使用不同版本的CRT,则各DLL间难以传递共享资源(如堆空间、文件等)


    • 运行库与多线程

      • 线程的访问权限:线程可以访问以下数据

        • 进程内存中的所有公共数据,如全局变量、堆、静态变量
        • 线程的私有数据,如栈、寄存器、线程局部存储(TLS)
      • 多线程运行库:提供多线程操作的接口,并支持多线程环境下的正确运行

        • 多线程操作接口:用于线程的创建与退出
        • 多线程环境运行:确保使用运行库接口时的线程安全
      • 多线程安全问题的解决措施:

        • 使用TLS:将变量存储在各线程的私有环境中
        • 加锁:在线程不安全的函数中加锁(如malloc、printf 等)
        • 改进函数调用:如 strtok( ) \(\rightarrow\) strtok_s( )(C11引入) 注意:strtok( )通过在函数内部设置静态指针跟踪字符串地址,多线程调用会有冲突 strtok_s( )通过保存上次调用时静态指针的地址,保证了线程安全

      • 线程局部存储(TLS)

        • TLS的隐式定义:Linux使用关键字_thread、Windows使用_declspec(thread)

          注意:被定义为TLS的全局变量在每个线程中保存有独立的副本

        • Windows中TLS的实现:

          • .tls段:专门负责存放被声明为TLS的全局变量

          • TLS表:保存所有TLS变量的构造函数和析构函数的地址

            注意:TLS表的信息存于PE数据目录中的IMAGE_DIRECT_ENTRY_TLS项中

          • 线程环境块(TEB):保存线程ID、堆栈地址、TLS数组等信息

            注意:TLS数组在TEB中的偏移是0x20,可以通过该偏移量找到TLS数组

          • TLS数组:用于查找TLS变量在线程中的地址,其首元素存放.tls段的地址

        • TLS的显式定义:需借助库函数API手动申请与释放,不便使用(不推荐)

          • 实现方式:借助TLS数组保存TLS数据
          • 二级TLS数组:需额外申请,用于存放更多的TLS数据

    • C++全局构造与析构

      • glibc全局构造与析构

        • GLOBAL__I_Hw :负责本编译单元所有的全局与静态对象的构造与析构

        • .ctor段:每个目标文件含有一个.ctor段,存放指向本目标文件全局构造函数的指针

          注意:各目标文件的.ctor段合并成最终输出文件的.ctor段,形成构造函数指针数组

          • .crtbegin.o:其.ctor段定义符号__CTOR_LIST__,指向合并后.ctor段的始址
          • 目标文件:其.ctor段中存放一个全局构造函数的指针
          • crtend.o:其.ctor段定义符号__CTOR_END__,指向合并后.ctor段的末尾
        • __do_global_ctors_aux:由GCC提供(不属于glibc),在**__libc_csu_init**中被调用

          • __CTOR_LIST__:全局构造函数的指针数组,由 .crtbegin.o 定义
          • 初始化过程:依次执行__CTOR_LIST__列表中记录的各全局构造函数

          .ctor段的形成


        • __tcf_1:由GLOBAL__I_Hw中的_atexit注册,在最后的exit( )中负责全局对象的析构

          注意:全局对象的构造与析构的流程是类似的顺序是相反的


      • MSVC CRT的全局构造与析构

        • _initterm:由mainCRTStartup函数调用 借助全局指针__xc_a和__xc_c,依次遍历执行所有的全局构造函数

          • .__xc_a:被分配在段.CRT$XCA中,指向首个构造函数 (类似__CTOR_LIST__)
          • .__xc_c:被分配在段.CRT$XCZ中,指向构造函数的结束地址 (类似__CTOR_END__)

          注意:各.CRT$XC*段合并后被分配在.rdata段(只读),且按照字母*的字典序链接


        • dynamic atexit destructor *:由atexit( )注册,退出时负责全局对象*的析构


    三、系统调用与API

    • 系统调用:由操作系统提供的,用于访问临界资源的一套接口 如创建(退出)进程、访问系统资源(如文件、网络等)

    • Linux系统调用:由0x80中断完成,由各通用寄存器传递系统调用参数

    • 运行库与系统调用:运行库作为“中间层”,解决了系统调用跨平台不兼容的问题,简化了系统调用

      注意:运行库只能为各平台功能的交集提供中间层,无法完全维持各平台间的兼容性

    • 特权级与中断:

      • CPU的两种特权级:“用户模式”与“内核模式”,限制代码操作的权力

      • 中断:由用户态切换到内核态的桥梁

        • 终端类型:硬件中断(如外设)与软件中断(如执行异常)

        • 中断号:不同的中断拥有不同的中断号,用于查找对应的中断处理程序

        • 中断处理程序:由中断号索引,程序指针存于中断向量表

        注意:Linux下借助中断号0x80触发所有系统调用,并通过寄存器eax获取对应系统调用号


    • Linux下基于int指令的系统调用:

      1. 触发中断:执行内嵌汇编代码int $0x80保存现场,并切换到内核态,查找0x80号中断

      2. 切换堆栈:调用0x80中断,从用户栈切换至内核栈,再将esp等用户态的寄存器压入内核栈

      3. 执行中断处理程序:根据eax寄存器的值,在sys_call_table中查找对应的内核函数sys_*

        Linux系统调用流程图解

        注意:内核函数sys_*从内核栈上获取参数,其中各参数寄存器由宏SAVE_ALL压入内核栈中


    • Linux的新型系统调用

      • 系统调用的改进:
        • 改进前:基于int指令的系统调用在Pentium IV上表现不佳
        • 改进后:Pentium II开始使用专门针对系统调用的指令sysentersysexit
      • 虚拟动态共享库(VDSO):用于支持新型系统调用,总是被加载到 0xffffe000 上
        • __kernel_vsyscall:位于VDSO中,负责新型系统调用,其地址为 0xffffe400
        • sysenter:新型系统调用指令,位于函数 __kernel_vsyscall 中
          1. 可直接跳转到寄存器指定的函数并执行
          2. 可自动实现特权级切换堆栈切换等功能

    • Windows API :Windows系统提供的应用程序编程接口

      • 系统服务与API :程序员无法像使用Linux那样直接使用系统调用,只能使用上层API 注意:系统调用的一般流程为 Application -> CRT -> (API) -> Kernel

      • SDK :Windows API DLL导出函数声明的头文件、导出库、相关文件与工具的集合 头文件windows.h中包含了Windows API中的核心部分

      • 使用API的优势:

        • 屏蔽了不同平台下硬件结构的差异,确保了系统调用的正常执行
        • 为Windows各版本使用互不相同的内核提供了可能,极大程度实现了向后兼容

      • Windows API的版本演进:Win16 -> Win32 -> Win64,其中数字表示位数 Win32是应用最广泛最成熟的版本

      • Win32的主要功能类别:Windows API是以DLL导出函数的形式暴露给开发者的

        1. 基本服务(kernel32.dll):包括文件系统、进程与内存管理等Windows的基本功能
        2. 图形设备接口(gdi32.dll):包括绘图、打印等与图形设备相关的功能
        3. 用户接口(user32.dll):包括滚动条、按钮等与Windows窗口交互的操作
        4. 高级服务(advapi32.dll):包括注册表、系统关闭重启、账号管理等额外功能
        5. 通用对话框(comdlg32.dll):包括打印窗口、选择字体与颜色等功能
        6. 网络服务(ws2_32.dll):包括Winsock、NetDDE等网络相关服务 ...

        注意:在Windows NT系列平台上,上述DLL还会依赖更底层的NTDLL.DLL

      • “银弹”:通过在软件体系结构中增加中间层以解决兼容性问题,如Windows API


    • 子系统:Windows NT提供了其它操作系统的执行环境,以兼容它们的应用程序,如Win32子系统
      注意:API是架设在应用层和内核之间的中间层;子系统是架设在应用层和API之间的另一个中间层


『operating system-3』runtime library
http://larry0454.github.io/2023/08/01/operating_system/runtime-library/
Author
WangLe
Posted on
August 1, 2023
Licensed under