【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端

文章目录

  • 前言
  • 介绍
    • 内存
        • 内存布局
        • 栈与调用惯例
        • 堆与内存管理
    • 运行库
        • 入口函数和程序初始化
        • C/C++运行库
        • 运行库与多线程
        • C++全局构造与析构
        • fread 实现
    • 系统调用与API
        • 系统调用介绍
        • 系统调用原理
            • 特权级与中断:
            • 基于int 的linux 经典系统调用实现:
            • linux 新型系统调用机制:
        • Windows API
    • 运行库实现
        • c语言运行库
            • 堆的实现:
            • IO与文件操作:
            • 字符串相关操作:
            • 格式化字符串:
            • CRT 库的使用:
        • c++ 运行库实现
  • 总结
  • 参考

前言

上接:

  • 【读书笔记】【程序员的自我修养 – 链接、装载与库(一)】线程模型(多对多);目标文件格式;静态链接;
  • 【读书笔记】【程序员的自我修养 – 链接、装载与库(二)】进程虚拟地址空间、装载与动态链接、GOT、全局符号表、DLL、C++与动态链接

介绍

  • ESP:(extended stack pointer,栈指针寄存器);EBP:(extended base pointer,基址指针寄存器)

  • glibc:(GNU C library,是GNU发布的libc库,即c运行库。glibc是linux系统中最底层的api,几乎其它任何运行库都会依赖于glibc。

  • MSVC:微软(MS)的VC运行库。

  • MSVC CRT:Microsoft Visual C++ C Runtime Library,是Windows下的C运行库。

  • TLS:(Thread Local Storage,线程局部存储)

  • SDK:(software developmet kit,软件开发包)

  • 堆空间并不是向上增长的,这是源于类unix系统使用类似brk()的方法分配堆空间,brk()是向上分配的。但是windows的HeapCreate() 就不是。

  • 大小端:

    • 大端:高字节存低地址、低字节存高地址;高低低高;用于MAC、TCP/IP
    • 小端:高字节存高地址、低字节存低地址;高高低低;用于X86 PC

内存

内存布局

  • 内存的用户空间主要包含了栈、堆、可执行文件映像、保留区、动态链接库映射区。
【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第1张图片
  • segment fault非法操作,该内存地址不能read/write 是典型的非法指针解引用造成的错误。

栈与调用惯例

  • 栈:

    • 通常的OS中,栈向下增长。栈顶由esp寄存器定位。
    • 栈通常保存了一个函数调用所需维护的信息,常称为栈帧
      • 栈帧:保存了函数的返回地址和参数、临时变量(非静态局部变量和临时变量)、上下文(函数调用前后的寄存器)。
      • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第2张图片
      • 函数调用过程:
        1. 参数压栈。(到时候使用类似ebp-4 相关地址访问参数)
        2. 下一条指令压栈。(用于函数返回时知道返回地址)
        3. 跳转到函数体。(进行函数调用)
        4. ebp压栈。(保存上一次的ebp,以供函数返回时修正栈帧)
        5. mov ebp, esp; 将栈顶指针赋给栈底指针。(开始新函数的栈帧)
        6. 分配临时空间【可选】。
        7. 寄存器入栈【可选】(以供函数返回时恢复寄存器(上下文))
      • 函数返回过程:
        1. pop 寄存器; 【可选】。恢复寄存器(上下文)
        2. mov esp,ebp; 恢复esp同时回收函数局部空间
        3. pop ebp; 恢复ebp,回到调用函数之前的栈帧。
        4. ret 从栈中取返回地址,跳转到该地址。(pop 和这一步 esp都将发生相应变化
    • 钩子(HOOK)技术
      • 很多指令例如mov edi edinop 这种占位符的存在。用于函数在运行时可以被其他函数替换掉, 通过将其替换成对其他函数的jmp
      • 这种替换机制可以用来实现 钩子(HOOK)技术,允许用户在某情况下截获特定函数的调用。
  • 调用惯例:

    • 函数调用方和被调用方需要有个调用惯例,保证函数正确调用。
      • 主要包括:参数传递顺序和方式(栈传递还是寄存器传递、压栈顺序)、栈的维护方式(由调用者弹栈还是由被调用者弹栈)、名字修饰策略。
      • C语言中默认cdecl 调用惯例。(参数从右到左压栈、由调用方出栈、名字修饰在函数前加_
    • 不同的编译器支持的调用惯例不一定相同。
    • C++的名字修饰策略更加复杂, 因为重载、命名空间、成员函数等,使得C++函数名可以对应多个函数定义
      • VC对C++的this指针存放与ecx 寄存器传递 。而gcc、thiscall、cedcl只是将this看作函数的第一个参数传递。
  • 函数返回值的传递:

    • 通常的返回值,4字节由eax 寄存器传递,5~8字节由eaxedx 寄存器组合传递。
    • 当返回值类型尺寸太大,C语言会在函数返回时,使用一个临时的栈上空间作为中转,这样使得返回值对象会被拷贝两次。
      • 其中临时的栈上空间由进入函数之前分配好,并将其地址作为隐含参数传递进函数。
      • 最终返回值被拷贝到临时空间后,还是由eax将这个空间的地址作为返回值传递出去,函数返回后,调用者将eax 指向的临时对象再拷贝给需要的地方。造成返回值对象会被拷贝两次
      • VC和gcc 平台下的C/C++ 思路大同小异,基本都是这样处理。

堆与内存管理

  • 堆:
    • 由于栈上数据在函数返回后被释放,无法将数据传递至函数外部。全局变量又没法动态的产生,于是堆成了唯一选择。
    • 堆空间管理
      • 通常由运行库向OS申请一块适当堆空间 ,然后由运行库管理这块空间。(因为如果由OS管理,那么每次的堆空间申请与释放都需要进行系统调用,开销较大。)
    • Linux进程的堆管理:
      • 进程地址空间中,除了可执行文件映射区域、共享库、栈,其余空间都可以作为堆空间,Linux提供了两个堆空间分配方式,分别的系统调用为:brk()mmap()
        • glic 的malloc:小于128K会在现有堆空间按照堆分配算法分配一块空间并返回;大于128K将会使用 mmap()分配一块匿名空间,在匿名空间中为用户分配空间。
        • brk() :用于设置进程的数据段介结束地址。 Linux将数据段和BSS合并为数据段,将数据段结束地址扩大,数据段中扩大的部分就可以作为堆空间之一int brk(void* end_data_segment)
        • mmap():向OS申请一段虚拟地址空间,这个空间可以映射到某个文件(这个系统调用最初的作用),将这个空间不映射文件时,就是匿名空间,用于作为堆空间void *mmap(void* start,size_t length, int prot,int falgs,int fd,off_t offset)(需要设置起始、长度、权限、映射类型(文件/匿名空间)、文件描述符、文件偏移)
    • Windows进程的堆管理:
      • windows进程虚拟地址空间分布: 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第3张图片
      • 每个线程的栈都是独立的,所以一个进程有多少线程,就有多少对应的栈。
      • Windows默认的栈大小为1MB,线程启动时,OS分配相应空间作为栈。
      • windows进程空间支离破碎,OS提供了VirtualAlloc()API 申请空间,要求申请的空间需是页的整数倍
      • Windows提供了堆管理器(Heap Manager)实现了分配算法。提供了 创建HeapCreate、分配HeapAlloc、释放HeapFree和销毁HeapDestroy 堆空间的API。
        • NTDLL.DLLNtoskrnl.exe都有一份堆管理器,前者负责Windows子系统DLL与windows内核之间的接口,所有应用程序、运行时库、子系统的堆分配都是使用这部分代码;后者负责内核中堆空间的分配。
    • malloc 分配的是连续的虚拟空间,但是不一定是连续的物理页面。
    • 堆分配算法: 管理一大块连续内存空间,能够按照需求分配、释放其中的空间。
      1. 空闲链表:链表头记录pre、next、大小,链接着每块未被使用的内存块, 分配完后将这块断掉。释放后再将其加入链表。
        1. 链表头被破坏整个堆就无法工作,因为容易越界读写到链表头。
      2. 位图(堆策略):将堆分配为大量的的,使用数组记录使用情况,2bits即可记录头、主体、空闲状态。
        1. 优点在于:空闲信息存储集中,cache容易命中;稳定性好,备份位图即可;不需要额外信息,便于管理。
        2. 缺点在于:易产生碎片;对很大或者块很小容易造成位图很大,使得浪费空间和降低cache命中率。(可以使用多级位图)
      3. 对象池(池策略):内存分配对象基本都是固定的一些值,按这些值分配小块,每次只取一个块使用。
        1. 易于管理、无需查找很大的空间。
      • 堆策略会产生外部碎片,而池策略会产生内部碎片。(堆策略使用多个块,造成外部块很多碎片;池策略使用比自己申请空间稍大的一个块,造成块内部碎片

运行库

入口函数和程序初始化

  • 入口函数:

    • 入口函数入则准备好main函数执行需要的环境,并调用main;在main返回之后,会记录main返回值,调用atexit注册的函数,然后结束进程。(入口函数通常给运行库的一部分
      1. OS创建进程,控制权交给程序的入口,往往是运行库中的某个入口函数。
      2. 入口函数对运行库和程序运行环境初始化。(堆栈、IO、线程、全局变量构造等)
      3. 调用main。
      4. mian执行完毕后,返回入口函数,入口函数开始清理工作(全局变量析构、堆销毁、关闭IO等),然后进行系统调用结束进程。
  • 入口函数实现: glibc & MSVC

    • glibc 入口函数: 以静态glibc用于可执行文件为例
      • glibc 程序入口为_start,由ld 链接器默认的连接脚本指定。
        • 此时栈中存储了环境变量envargvargc。(环境变量包括系统搜索路径、OS版本等。)
      • _start 最终调用_libc_start_main ,并传入mainargvargcinit(初始化函数)、fini(收尾工作)、rtld_fini(动态链接收尾工作)、栈底指针。
      • _libc_start_main :保存env 的栈中地址、栈底地址。
        • _libc_start_main首先调用了一系列初始化函数和atexit 注册main之后的函数。
        • _libc_start_main 的末尾调用了 main函数 ,在main后面跟着 exit 函数
          • exit 函数while 循环执行atexit__cxa_atexit注册的函数链表。
      • 程序正常结束有两种情况:一是main函数正常返回、二是程序中调用exit 函数退出。
        • 即使main函数正常退出,exit 函数也会被调用。
        • exit 是进程正常退出的必经之路,因此将atexit注册的函数任务交给exit 函数执行则万无一失。
    • MSVC CRT入口函数:
      • MSVC 的CRT默认入口函数名为mianCRTStartup
        • 先堆一些预定义的全局变量赋值。(包括OS版本、主版本号等)
          • 这里因为没有初始化heap,所以没法使用malloc分配内存,则使用alloc动态的分配栈上内存。(最终还是调整esp释放这些栈上内存)
        • 随后初始化堆。
        • 在try-except块中初始化I/O、初始化main函数argv、设置环境变量、配置其他C库。
        • 最后调用main函数,然后except负责最后的清理,最终返回main 的返回值。
  • 运行库与I/O:

    • IO初始化首先在用户空间建立stdinstdoutstderr及其对应的FILE结构,使得main之后可以直接使用printfscanf等函数。
    • 对于程序来说IO指代:程序与外界的交互。(包括了文件、管道、网络、命令行、信号等)
    • 对于任意文件,OS提供一组函数,包括打开、读写、移动文件指针等。
      • 在OS层面上,文件操作也有类似FILE的概念,Linux中的fd(file descriptor,文件描述符)、windows中的句柄(Handle)

      • 用户通过函数打开文件获得句柄,随后通过句柄操作文件。

      • 句柄:用于防止用户随意读写OS内核的文件对象,文件句柄总和内核的文件对象相关联

      • Linux中的fd,0、1、2代表了:标准输入、标准输入、标准错误输出。

        • 内核中,每个进程都有一个私有的打开文件表, 这个表存是个指针数组,每个元素指向一个内核的打开文件对象fd就是这个表的下标
        • 当用户打开一个文件,内核在内部生成一个打开文件对象,并在表中找到一个空项,指向生成的打开文件对象,并返回这项的下标作为fd
        • 打开文件表存在于内核,用户只能通过OS提供的函数进行操作。
        • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第4张图片
        • 内核中有个指针p指向该表,只要有fd,就可以通过p+fd访问文件表中的一个项。
  • MSVC CRT的入口函数初始化:

    • MSVC的入口函数初始化主要包括:堆初始化、IO初始化。
      • MSVC的堆初始化:
        • 32为编译环境下,MSVC的堆初始化只是调用了HeapCreateAPI创建了一个系统堆。
        • 即MSVC的malloc函数是调用了HeapAllocAPI,将堆的管理交给了OS。(不是由运行库进行堆的管理?)
      • MSVC的I/O初始化主要做了:
        • 建立打开文件表
        • 如果能够继承自父进程,就从父进程获取继承的句柄。
        • 初始化标准输入输出。
      • FILE结构中重要的一个字段是_file,是一个整数,通过_file访问内部文件句柄表的项。
        • windows中,用户态使用句柄访问内核文件对象,句柄本身是个32位数据类型, 不同场合有用int、指针来表示。
      • MSVC的CRT中,已经打开的文件句柄的信息使用ioinfo数据结构来表示。(包含打开文件的句柄、文件打开属性、管道的单字符缓冲)
      • crt\src\ioinit.c 中有个二维数组__pioinfo表示用户态的打开文件表
        • 通过FILE结构中的_file字段中的0~ 4bits和5 ~ 10bits来表示:用户态打开文件表的二维坐标。
【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第5张图片
  • 入口函数只是庞大代码集合的一部分,这个代码集合就是运行库。

C/C++运行库

  • C语言运行库:C语言的运行时库

    • 包括了:
      • 入口函数以及所依赖的其他函数
      • 标准函数:C语言标准库的函数实现
      • IO封装和实现
      • 堆的封装和实现
      • 一些语言的特殊功能实现
      • 调试功能的代码
    • 其中标准库占据了主要地位,标准库由1983年建立ANSI C的完整标准C89,一直到C99。
  • C语言标准库

    • c语言标准库十分轻量,仅包含了数学函数、字符/字符串处理、IO等基本方面。
    • 还有些特殊库,如变长参数stdarg.h、非局部跳转setjmp.h
      • stdarg.h里多个宏访问各个额外的参数。(用lastarg 记录函数最后一个具名参数,根据参数数或最后一个参数一次指向可变参数)
      • 变长参数的实现:
        • 参数从右向左压栈,printf函数一个%d就从栈里取一个整数,再把指针向下即可。
        • 变长参数的实现得益于C语言默认的cdecl调用惯例,即自右向左压栈传递参数。
          • cdecl是调用方负责清楚堆栈,所以知道传递参数的多少,可以完整清除。
            【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第6张图片
  • glibc与MSVC CRT

    • c语言运行库像是C语言程序与不同OS之间的抽象层,将不同OS 的API抽象成相同库函数。

      • 运行库功能有限,如用户权限控制、OS线程创建等不属于标准的C运行库。
      • glibc与MSVC CRT是标准c运行库的超集,各自对c标准库进行了扩展。(如glibc 有可选pthread库中的pthread_create、MSVCRT有_beginthread 来创建线程。
    • glibc:

      • glibc的发布版本包括头文件和库二进制文件,二进制部分主要是C语言标准库,有静态libc.a、动态libc.so.6两个版本。
      • 同时除了C标准库意外,还有些辅助程序运行运行库ctr1.octri.octrn.o
        • ctr1.o中包含了_start程序入口函数,它负责调用__libc_start_main()初始化libc并调用main进入真正程序主体。 ctr1.o同时向libc 启动函数__libc_start_main()传递了两个函数指针_libc_csu_init_libc_csu_fini,这来给你个函数负责调用_init()_finit()函数。
        • ctri.octrn.o用于帮助实现初始化函数,这两个目标文件中包含的代码实际上是_init()_finit()函数的开始和结尾部分,这两个文件和其他目标文件按照顺序链接起来以后,形成了完整的_init()_finit()函数。
        • 最终的._init 段如图所示, 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第7张图片
        • .init 段、.fini 段还包含了录入C++全局对象的构造/析构函数的调用函数、用户监控程序性能、调试工具等。我们也可以用__attribute((section(".init"))) 将函数放入._init 段。(但是要用汇编,否则编译器产生ret指令,使得._init 段提前返回了)
    • GCC 平台相关目标文件

      • 还有crtbeginT.ocrtend.olibgcc.alibgcc_eh.a。这些并不属于glibc,是GCC的一部分。
        • glibc 只是一个c语言运行库,对C++实现并不了解,GCC是C++的真正实现者,对C++全局构造和析构了如指掌。
        • crtbeginT.ocrtend.o用于配合glibc实现C++的全局构造与析构
          • ctri.octrn.o中的.init 段、.fini 段只是提供了一个在main()之前/之后运行代码的机制,真正的全局构造/析构由crtbeginT.ocrtend.o来实现。(即上图中的中间部分)
        • GCC还需要处理不同平台之间的差异性。libgcc.a包含了很多函数的运算(不同CPU对浮点数等运算方法不同);libgcc_eh.a则包含了C++的异常处理的平台相关函数。
    • MSVC CRT:

      • 同一版本的MSVC CRT 根据不同属性提供了多种子版本,以供不同需求的开发者使用:静态/动态版本、单/多线程版、调试/发布版、C/C++版、本地&托管/纯托管版。

      • 动态版的CRT 每个版本都有两对应文件一个用于链接的.lib、一个用于运行时用的.dll动态链接库

      • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第8张图片
      • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第9张图片
    • C++CRT:

      • C++程序需要额外链接相应的C++标准库。这些C++标准库里包含的仅仅是C++的内容,不含C的标准库,如iostreamstringmap
      • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第10张图片
    • 当程序里包含了某个C++标准库头文件,MSVC编译器认为该源代码文件是一个C++源代码程序,在编译时根据选项在目标文件的.drectve 段添加相应C++标准库链接信息。链接时会加上相应的.lib

    • 使用不同版本的CRT:

      • 静态链接:目标文件对静态库的引用只是在目标文件的符号表中保留一个记号, 并不进行实际的链接,也没有静态库的版本信息,所以没关系。
      • 动态链接:动态链接不版本的CRT可能引起符号重定义报错。
        • 程序依赖的DLL 使用了不同的CRT:则导致程序运行时有多分CRT的副本,一般能正常运行。但是当两个DLL A和B,分别使用不同DLL:
          • A中申请的内存不能在B中释放,因为属于不同的CRT,拥有不同的堆。(CRT堆的管理一般是向OS申请一块空间作为堆空间来管理,两个CRT则会有两个不同的堆空间)
          • A中打开的文件不能用于B,FILE*等类型依赖于CRT的文件操作。
          • 当我们使用第三方的.lib或DLL文件但是又没有源代码时就很难办。
    • 用一个版本的编译器编译的程序无法在别的机器上运行?

      • 因为一般编译程序使用了manifest 机制,需要依赖相对应版本的运行库
      • 可以使用静态链接,不需要依赖于CRT的DLL了。
      • 或者将相应版本的运行库和程序一起发布给用户。

运行库与多线程

  • CRT的多线程困扰:
    • 线程的访问权限:
      • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第11张图片
    • 多线程运行库:
      • 对于C/C++标准库来说,线程相关部分不属于标准库的内容,同网络、图形图像一样属于标准库之外的系统相关库。
        • 对于多线程的操作接口,windows 的MSVC CRT提供了_beginthread()_endthread(),Linux 的glibc 提供了可选的线程库pthread(POSIX thread) 包括了pthread_create()pthread_exit()。这些都不属于标准的运行库,他们都是平台相关的
        • 对于C/C++运行库支持多线程环境,:有很多函数是不可重入的,如errno(全局变量作为错误代码 )、strtok()(分解字符串函数)(局部静态变量在多线程中混乱)、malloc/new、free/delete、printf、信号等都是线程不安全的。
          • 但也有些是可重入的:如字符/串处理、数学函数、获取环境变量等。
      • 为了解决标准库多线程问题,许多编译器附带了多线程版本的运行库。MSVC中使用/MT/MTd等参数指定使用多线程运行库
  • CRT的改进:
    • TLS: 例如errno等问题,使用宏定义,在单线程版本中返回全局变量errno的地址,在多线程中返回得到地址不同,即使用TLS使其成为了线程的私有成员。
    • 加锁:多线程运行库中,线程不安全的函数内部都会自动加锁,包括malloc、printf等。
    • 改进函数调用方式: 例如将局部静态变量存储的东西,放入参数列表,空间由调用方申请好,那么就是线程私有的了。
  • 线程局部存储实现 TLS:
    • 经常有线程私有数据的需求,但是属于每个线程的私有数据包括了线程栈、当前寄存器。
      • GCC__thread int number与MSVC__declspec(thread) int number都提供了 TLS机制。
      • 一旦定义一个全局变量为TLS类型,那么每个线程都会拥有这个变量的一个副本,互不影响。
    • Windows TLS 实现:
      • Windows将全局变量和静态变量存在.data.bss段中, 当使用__declspec(thread)定义一个线程私有变量时,编译器将其放在.tls段中
        • 当OS启动新的线程,会从进程堆中分配一块空间将.tls段中内容复制进去,使得每个线程都有独立的.tls副本。
      • 对于C++的TLS对象,还需要每次创建/销毁线程后,对其进行构造/析构。
        • PE文件中有个数据目录的结构,有个元素中保存了TLS表的地址与长度
        • TLS表中存了所有TLS元素的构造/析构函数的地址,OS根据TLS表在创建/销毁线程时,对TLS变量进行构造/析构
        • TLS表存在PE的.rdata段中。
      • OS会有一个TEB(thread environment block,线程环境块),用于保存线程的:堆栈地址、线程ID等信息。其中有一个地方保存了TLS数组。
        • 访问TLS变量地址: 首先通过寄存器找到TLS数组地址、根据TLS数组地址得到.tls段副本第hi、然后加上变量偏移得到TLS变量在线程中的地址。
    • 显式TLS:
      • 通过关键字定义全局变量为TLS变量的方法为隐式TLS,程序员无需关心TLS变量的申请、分配赋值和释放,由编译器、运行库和OS处理了。
      • 显式TLS需要程序员手动申请、查询地址、设置与释放。
        • Windows 提供了TlsAlloc()TlsGetValue()TlsSetValue()TlsFree();Linux提供了pthread_key_create()pthread_getspecific()pthread_setspecific()pthread_key_delete()
    • 线程创建尽量使用包装好的。(用OS API创建删除线程,又用CRT提供的库函数,可能造成CRT以为是自己库生成的线程,有些特殊的内存被申请,但是OS的退出线程又不会释放这个内存,就会产生内存泄漏
    • CRT的这个接口也是对 windows API的封装。
      • 即:当使用CRT时(基本所有程序都使用CRT),尽量使用CRT提供的函数创建和销毁线程
      • 同样在MFC中,也尽量使用MFC提供的线程包装函数以保证程序运行正确,因为MFC层面包装线程函数,会维护线程与MFC相关结构。
        • Windows提供的接口:CreateThread()ExitThread()
        • CRT提供的接口:_beginthread()_beginthreadex()_endthread()_endthreadex()(推荐使用带ex的);
        • MFC提供的接口:AfxBeginThread()AfxEndThread()

C++全局构造与析构

  • C++中,入口函数还需要在main 的前后完成全局变量的构造与析构。

  • glibc 全局构造与析构:

    • glibc启动文件介绍的.init.finit段,这两个段最后拼成_init()_finit()函数,分别在main()之前/之后执行。

    • 构造:

      • _init()的调用关系:_start()->__libc_start_main()->__libc_csu_init()->_init()
        • _init()没有源文件,是拼出来的段编译成的。通过反汇编可以看到它调用了GCC的crtbegin.o提供的__od_global_ctors_aux()函数,用于构造全局变量。函数源文件位于gcc/Crtstuff.c
          • 该函数通过一个全局构造函数列表,将需要全局构造的对象构造。
      • 对于每个 .cpp,GCC编译器会遍历所有全局变量, 生成一个_GLOBAL__I_Hw()函数,用于对本编译单元所有全局函数的初始化。 并在这个目标文件的.ctors段中放置这个函数的指针。
        • 编译器编译每个单元时,生成这个特殊函数,然后链接器链接时,会将同名段合并。 这样拼成的.ctors段的每个元素都指向一个目标文件的全局构造函数。 同时在链接时会修改.ctors段中第一个四字节用作该列表的长度,同时尾部添加NULL(0)。
          • 这个数组就是需要的全局构造函数的地址列表。
    • _main函数:

      • 有些OS上,汇编器和链接器并不支持.init.ctors机制,于是有 Collect2 对ld链接器的封装。
      • Collect2 程序还收集了所有输目标文件中,需要在main()之前执行的特殊符号,将其生成一个临时.c文件,编译后与其他目标文件一起链接到输出文件中。
      • 在这些平台上,GCC编译器也会产生_main()函数,负责Collect2 收集起来的那些函数_main()函数也是GCC提供的目标文件之一。
    • 析构过程与构造过程基本类似,在生成构造函数的时候,对应的通过__cxa_atexit()注册析构函数。

  • MSVC CRT的全局构造与析构:

    • 和 glibc 全局构造与析构 机制一致,只是名称略有不同。
    • MSVC的入口函数是mainCRTStartup(),他调用了_initterm(),其参数为两个函数指针的指针
      • 第一个指针指向的说就是全局构造函数的地址列表中的第一个函数,第二个指向末尾。
      • _initterm()依次遍历函数指针并且调用他们,

fread 实现

  • 运行库中真正复杂的是软件与外部通信部分,即IO部分。

  • fread()是对Windows API ReadFile() 的封装。 fread()的参数有 buffer、count、size、stream。

  • 缓冲机制:用于避免大量实际文件的访问。减少了系统调用的开销。

    • flush机制(将缓冲的数据写入文件) 常在写缓冲中使用,因为写传冲使得文件处于一种不同步状态,有些写入文件了但是还存在缓冲当中,用于防止异常时缓冲里的数据还有机会写入文件。
    • C语言支持全缓冲和行缓冲。
  • fread_s:

    • fread 调用了fread_s(S ->safe)fread_s多了一个buffersize 的参数。
    • fread_s首先检查参数,然后使用_lock_str 对文件加锁,然后调用_fread_nolock_s
  • _fread_nolock_s: 进行实际工作的函数;(利用缓冲的读取,减少了系统调用的开销)

    • 首先进行了缓冲模式判断、streambuffersize设置
    • 然后循环拷贝读数据+减少count
      • memcpy_s 将文件stream_ptr 所指向的缓冲内容复制到data指向的位置。
      • 同时还要修复FILE 结构和局部变量的各种数据。
    • 当缓冲为空:
      • 要读的数据大于缓冲大小:
        • 使用_read 函数真正的从文件中读取数据,读取尽可能多的证书个缓冲的数据直接进入输出位置。
      • 要读的数据不大于缓冲大小:
        • 仅需要重新填充缓冲即可,使用_filbuf 函数负责填充缓冲,_filbuf 最终也调用了_read 函数
          • _read 函数主要:从文本中读取数据、对文本模式打开的文件,转换回车符。
  • _read`:

    • 源码位于crt/src/read.c,调用关系: fread -> fread_s-> _fread_nolock_s-> _read
    • 首先处理一个单字节缓冲,仅对设备和管道文件有效,检查pipech ,以免漏掉一个字节
    • 然后进行实际的文件读取部分,调用了ReadFlie()函数。
      • ReadFlie()函数是Windows API。
    • 最后将Windows 返回值翻译为CRT所使用的版本。
  • 文本换行:

    • _read后续还要为以文本模式打开的文件 转换回车符。
      • windows 中的回车符存储的是:0X0D 0X0A 用CR LF表示;用C语言中的字符串表示为:\r\n
        • linux是\n;macos是\r ;windows 是\r\n
      • C语言中的回车只是\n,所以遇到除了linux之外的os,都需要转换。
        • 一般使用的是双指针的算法来实现\r\n转换为\n
  • fread 回顾

    • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第12张图片

系统调用与API

系统调用介绍

  • 系统调用:
    • 是用户层与内核层的界限,系统调用是应用程序(运行库也是应用程序)与OS内核之间的接口
      • windows 完全基于DLL机制,通过DLL对系统调用进行包装,形成了windows API。
        • linux使用0x08号中断作为系统调用的入口,windows使用0x2E号中断作为系统调用入口。
      • 系统调用覆盖了程序运行必须的支持(创建/推出进程/线程、进程内存管理、资源访问(文件、网络、进程间通信、硬件设备访问)、图形界面的操作支持等。
  • linux 系统调用:
    • x86下,系统调用由0x08中断完成,各个通用寄存器用于传递参数
      • EXA寄存器用于表示系统调用的接口号,系统调用返回时,EAX作为调用结果的返回值。
      • 每个系统调用都对应于内核源代码中的一个函数。
      • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第13张图片
    • 系统调用都可以在程序中直接使用,他的c语言形式被定义在usr/include/unistd.h。(可以绕过glibc 的fopenfreadfclose,直接使用openreadclose 实现文件操作,使用write向屏幕输出字符串,句柄为0)
  • 系统调用的弊端:
    • 系统调用完成了应用程序与内核的交流
    • 系统调用的两个特点:
      • 使用不便。系统调用接口过于原始,没有很好的包装并不方便。
      • 各OS之间不兼容。windows 、linux、unix的系统调用基本完全不一致,即使内容一致,定义与实现也不一致。
    • 这时候运行库作为:系统调用与程序之间的一个抽象层。特点:
      • 使用简便,运行库本身就是语言级别,设计友好。
      • 形式统一,运行库的标准为标准库,这个标准的运行库相互兼容,不随着OS、编译器的变化而变化。
        • 这样使得无论什么平台,都可以使用c语言运行库的fread来读取文件。
        • 这样使得不同OS下可以直接编译与运行,即源代码上的可移植性。(和跨平台不同,跨平台更复杂)
    • 但是运行库是保证了多个平台通用,于是只有各个平台功能的交集,使得程序使用了CRT之外的接口,就很难保持平台间的兼容了。(这里才涉及到了跨平台,因为要利用不同平台间的特性和使用接口)

系统调用原理

  • 系统调用也是个中断,中断还包括了很多溢出、缺页等。
  • 系统调用函数有很多,如forkreadwrite等。
  • 一般通过中断号进入系统调用中断,在寄存器传参决定系统调用函数
特权级与中断:
  • 现代OS通常有两个特权级别:用户态、内核态
    • 系统调用运行在内核态,应用程序运行在用户态,OS一般通过中断将用户态切换到内核态。
      • 中断是 一个硬件或软件 发出的请求。

      • 中断通常有两个属性:中断号、中断处理程序(ISR,intertupt service routine)

        • 不同中断有不同的终端号,一个中断处理程序一一对应一个中断号。
        • 内核中有中断向量表,存着中断处理函数的地址,中断号指向中断向量表的表项。
        • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第14张图片
      • 软件中断带一个参数标记中断号。使用该指令,用户可以手动触发某个中断并执行其中中断处理程序。

      • 中断号有限,所以通常所有系统调用占用一个中断号。linux使用0x08号,windows使用0x2E号。

        • 和中断一致,系统调用也有一个系统调用号,标明是哪一个系统调用。
        • 系统调用号也指向系统调用表中的表项,其中存着对应系统调用函数的指针。
基于int 的linux 经典系统调用实现:
  • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第15张图片
  • 触发中断:
    • 通过内嵌汇编,cpu读到int 0x80,会保存现场,将特权状态切换到内核态,然后查询中断向量表中0x80 号元素。
      • __asm__是gcc关键字,表示下面要嵌入汇编代码。
      • volatile 关键字表示GCC对这段代码不进行任何优化。
      • int $0x80 为调用0x80号中断。
  • 切换堆栈:
    • 进入中断函数之前,CPU还需要进行栈的切换
      • 用户态和内核态使用不同的栈每个进程都有自己的内核栈
        • 当前栈为ESP所指的栈空间。
      • int中断发生时,CPU切入内核态,还需要找到当前进程的内核栈,并在内核栈中压入用户态的SSESPEFLAGSCSEIP。(都是int做的)
        • 当系统调用返回时,调用iret回到用户态,并将内核栈中弹出寄存器的值,恢复用户态的栈。(都是iret做的)
  • 中断处理程序:
    • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第16张图片
    • i386的中断向量表在linux源代码的Linux/arch/i386/kernel/traps.c 中看到一部分,包括了算数异常(处0、溢出)、页缺失、无效指令、系统调用等中断。
    • linux 的i386 的系统调用表Linux/arch/i386/kernel/syscall_table.S 里,每个元素都是记录着系统调用函数地址。
    • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第17张图片
  • int 中断后,通过SAVE_ALL汇编将相关寄存器压入栈中,这里就包括了EAX~EDI。
    • 这也就是调用系统调用时,用这些寄存器传递参数的原因。
    • 参数被压入栈后,系统调用函数使用的是内核栈的参数。
linux 新型系统调用机制:
  • Linux 2.5版本后开始使用新型的系统调用机制,有了新的专门针对系统调用的指令:sysentersysexit
    • 调用sysenter后,系统直接跳转到由某个寄存器(eax)指定的函数执行,并自动完成特权级别转换、堆栈切换

Windows API

  • Windows下,CRT是建立在windows API之上的,MFC也是一种C++形式封装的库。

  • Windows没有将系统调用公开,而是在系统调用上建立了一个API层,让程序只能调用API层的函数。

    • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第18张图片
  • windows API概述:

    • Windows API 以DLL导出函数的形式暴露给应用程序开发者。经典的 API为Win32
      • win32 的三个核心:kernel32.dlluser32.dllgdi32.dll
    • SDK:微软将windows API DLL 导出函数的 :声明的头文件、导出库、相关文件和工具一起提供给开发者,作为SDK(software developmet kit,软件开发包)
    • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第19张图片
    • windows NT平台上,有个NTDLL.DLL 将内核系统调用包装了起来,是windows用户层面的最底层,所有DLL都通过 调用NTDLL.DLL ,由他进行系统调用。
    • windows API较为原始,windows 还在API上建立了很多应用模块,用于对Windwos API功能的扩展。
      • 如对HTTP/FTP等协议包装的 Internet模块(wininet.dll)、OPENGL模块、ODBC(统一数据库接口)、WIA(数字图像设备接口)等。
  • 为何要用windows API:

    • 系统调用是十分依赖硬件结构的一种接口。 兼容性差。
      • 收到硬件限制,如寄存器 数量、参数传递、中断号、堆栈切换等。
      • 硬件结构变化,大量应用程序会出现问题(尤其是和CRT静态链接的应用)
      • windows 将系统调用包装起来,使用DLL导出函数作为应用程序唯一接口暴露出来,可以让内核随版本改变而改变系统调用接口。
    • windows 很多高级接口和程序设计都基于DLL,如内核、COM、OLE、ActiveX等。
    • windwos NT和windows 9X合并为windows 2000的顺利进行离不开windows API
      • 所以不管内核接口如何变,只要维持API层接口不变,理论上所有应用程序不需要重新编译就可以正常运行因为API层是DLL,不变接口换了DLL即可不用重新编译),这就是windows API的意义。
  • API与子系统:

    • 又名windows 环境子系统。随着windows地位的巩固,子系统已经被抛弃了。
      • 子系统是为了在windows 系统上跑其他系统的应用软件,即兼容其他OS。
        • 通过模拟UNIX的fork(),使得应用程序对OS看起来和UNIX没区别。
      • 二进制级别兼容很困难,所以目标是源代码级别的兼容,也就是每个子系统实现目标OS的所有接口。(c语言层面的接口)
        • 32位 windwos程序运行于64位 windwos也是通过类似子系统的模式。

运行库实现

c语言运行库

  • 实现一个mini CRT的功能起码需要:入口函数、初始化(堆、IO)、堆管理、基本IO。还有一些C++的new/delete、stream、string 的支持
    • 为了考虑对windows、linux 的跨平台,常采用条件编译#ifdef WIN32来根据编译平台确定代码部分。
      • 通常将CRT的各函数声明放在不同的头文件中。如IO:stdio.h、字符串与堆:stdlib.h
  • 运行库的入口函数主要负责:
    • 准备程序运行环境及初始化运行库、调用main函数主体、清理程序运行后的各种资源。
      • 运行库为所有程序提供的入口函数应该相同,在链接程序时需要指定入口函数名
    • 对于启动进程的命令行参数
      • linux进程启动时,栈中保存着环境变量和传递给main函数的参数
      • windows提供相应API获取命令行参数字符串:GetCommandLine
    • 对于进程的结束
      • linux通过调用1号系统调用实现进程结束,ebx表示进程退出码。
      • windows通过ExitProcessAPI 结束进程
        • 通常在结束进程前,需要调用由atexit()注册的退出回调函数
堆的实现:
  • 实现mallocfree
    • linux 下使用brk()系统调用,将数据段结束地址向后调整*MB,作为堆空间。
    • windows 下使用virtualAlloc API,向系统申请*MB空间作为堆空间。
      • 并自己使用双链表实现动态分配的 malloc,不使用windows 的HeapAlloc等API来分配空间。
IO与文件操作:
  • IO对于任何软件来说都是复杂的。
    • 对于windows,由文件的基本操作API可以使用:CreateFileReadFileWriteFileCloseHandleSetFilePointer
      • windows下,标准输入输出并不是文件描述符0、1、2,需要通过GetStdHandle 的API获得。
    • Linux 则没有API,需要使用内嵌汇编实现openreadwritecloseseek系统调用。
      • FILE* 类型在windows中实际是内核句柄 ,在linux中是文件描述符,并不是FILE 的指针。
字符串相关操作:
  • 字符串相关操作无需涉及和内核交互,纯粹的用户态计算,相对简单。
    • 包括了字符串长度计算、字符串比较、字符串与整数转换等。
格式化字符串:
  • 有了基本的:堆管理、文件操作、基本字符串操作等,就要有printf的实现了。
    • printf是个典型的变长参数函数,参数数量不确定。
      • 可变参数,利用了c语言中参数从右向左压栈的特性,将参数从栈中取出。
      • 通过几个宏来实现可变参数的:参数起始地址查询、下一个参数查询等。
      • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第20张图片
CRT 库的使用:
  • 首先是头文件:

    • 头文件需要包含常数定义、宏定义、以及函数声明。
  • 编译库文件:(采用静态库的形式,动态库更加复杂)

    • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第21张图片

    • linux下的编译:

      • 需要-fno-bulitin关闭GCC内置函数功能,否则GCC默认将strlen 等常用函数展开成GCC内部实现。
      • 需要-nostdlib :不使用来着glibc、GCC的库文件和启动文件。
      • 需要-fno-stack-protector:关闭堆栈保护功能。
    • windows 下的编译:

      • /DWIN32:表示定义WIN32这个宏。用于区分平台
      • /GS- :表示关闭堆栈保护功能。
  • 使用:

    • 【读书笔记】【程序员的自我修养 -- 链接、装载与库(三)】函数调用与栈(this指针、返回值传递&临时对象构建&栈、运行库与多线程、_main函数、系统调用与中断向量表、Win32、可变参数、大小端_第22张图片
      • Linux 下-e mini_crt_entry 和windows 下的/entry:mini_crt_entry都是:用于指定入口函数。
  • 保证了整个程序只依赖OS内核。绕过了运行库。

    • windows 下仅依赖Kernel32.DLL,绕过了MSVC CRT的运行库msvcr90.dll

c++ 运行库实现

  • 通常C++运行库都独立于C语言运行库,一般C++运行库依赖于C语言运行库的。

    • 通常C++运行库仅包含C++的一些特性,如new/delete、STL、异常处理、流(stream)等。
      • C语言运行库已经将入口函数、堆管理、基本文件操作等特性实现过了。
    • C++运行库除了全局构造、析构等,基本没有和OS相关的部分,只需要调用C运行库的接口即可。
  • new/delete:

    • new 的调用,实际上是一个operator new操作符函数的调用。
      • 除了 new、delete、还有:+、-、*、% 等都是操作符,都有相应的操作符函数。
    • 可以重载全局new/delete 操作符,也可以重载某个类的new/delete(可以实现 placement new(指定对象申请地址))。
  • C++全局构造与析构:

    • 构造函数主要实现的是:依靠特殊的段合并后形成构造函数数组,析构函数依赖于atexit()函数。
      • MSVC:全局构造主要实现两个段:.CRT$XCA.CRT$XCZ,然后定义两个函数指针分别指向他们。
      • GCC:全局构造需要定义.crtor段的起始和结束部分,然后定义两个函数指针分别指向他们。
        • 真正的构造部分只需要循环将构造函数数组都遍历执行一遍。
  • atexit实现:

    • 由atexit注册的函数:在进程退出之前,在exit()函数中被调用。
      • atexitexitc语言运行库的一部分。
    • 所有全局对象的析构函数都是通过 atexit或类似的函数 注册的。(包括windows、linux)从而让其在程序退出时执行。
      • 实现:
        • 用一个链表将注册的函数存储起来,到exit时遍历执行一遍回调函数即可。
        • 链表从头插入,从头遍历,使得后构造的对象先被析构。
  • 入口函数修改: 在入口函数中加上do_global_crtors,在exit函数中加上mini_ccrt_call_exit_routine,用于全局构造和析构

  • 生成静态库:

  • 使用:

    • linux 下链接库文件时,将crtbegin.ocrtend.o放在用户目标文件的最开始和最后端,保证链接的正确性。

总结

参考

  • 程序员的自我修养 – 链接、装载与库;

你可能感兴趣的:(读书笔记,函数调用与栈,运行库,系统调用,中断向量表,win32,API)