第九章和第十三章不是很感兴趣,特别是Windows系统下的一些知识点并没有做相关的笔记。
”计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决“。确实挺名言的,试想计算机网络的层次结构,编译原理的前后端,以及操作系统的分层。
运行库使用操作系统提供的系统调用接口,系统调用接口在实现中往往以软件中断的方式提供,比如Linux使用0x80号中断作为系统调用接口,Windows使用0x2E号中断作为系统调用接口。
操作系统做什么?作者这个总结得很好:操作系统的一个功能是提供抽象的接口,另一个主要功能是管理硬件资源。
在x86平台上,共有65535个硬件端口寄存器,不同的硬件被分配到了不同的IO端口地址。CPU提供了两条专门的指令,in和out来实现对硬件端口的读写
进程的总体目标是:希望每个进程从逻辑上来看都可以独占计算机资源
对于前期那种直接在物理地址上编程的程序存在一些问题:
因此产生了分段和分页的解决方案。其中,分段解决了第一个和第三个问题,分页解决了第二个问题。
通常一个页表4KB,在Intel Pentium还可以选择4MB,但没人这么干。
线程有时候被称为轻量级进程(LWP),是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。线程模型有三种:一对一模型(一个内核线程对应一个用户态线程,存在上下文切换开销大,线程数有限制的问题);多对一模型(多个用户态线程对应一个内核态线程,存在一个用户态线程阻塞,其他线程无法执行的问题);多对多模型(综合解决上述存在的问题)。
为什么要把线程模型放在这里说?因为wwk被问到过面试题:线程和轻量级进程有什么区别一听到问题,很是迷惑,可是看到线程模型的时候好像有这么一点新理解:所谓轻量级线程,那就不应该是一对一的线程模型,否则和进程有什么区别?所以,个人觉得LWP应该更多的指的是多对一的线程模型中的线程,因为它没有内核态来参与线程调度,比进程调度更轻量。当然了,这只是目前的想法
昨天笔试还碰到线程的等待状态,一下没理解过来。等待状态就相当于是阻塞状态。什么时候被阻塞?当然是在运行条件不满足时被阻塞,那它不就陷入等待状态,等待条件满足?
抢占:线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程就叫抢占,即之后执行的线程抢占了当前线程的执行时间片
写时复制(COW):两个任务同时自由地读取内存,但任意一个任务试图对内存进行修改的时候,内存就会复制一份提供给修改方单独使用,以免影响到其他任务的使用。
原子性:单指令操作(汇编角度)被称为原子的。因为,无论如何,单条指令的执行都不会被打断。i386有inc来完成+1的原子操作。
过度优化的问题:我们知道volatile关键字可以阻止过度优化,因为它可以完成两件事:
然而,在优化这一块,不仅编译器会做优化,CPU也会做优化。volatile就管不着了CPU了。
经典的例子当然是单例模式。单例模式有一种常规的解决方案是DCL,也就是双重检查锁,但是在C++中new的步骤有是分为三个步骤:分配内存,调用构造函数,将内存地址用指针保存下来。CPU就要来搞怪,将第二步第三步乱个序。
一种解决方案是:调用CPU提供的barrier指令阻止将barrier指令之前的代码交换到barrier之后。但是这种方案不具有可移植性。部分实现代码如下:
if(!pInst){
lock();
if(!pInst){
T* temp = new T;
barrier();
pInst = temp;
}
unlock();
}
一个程序从代码到运行起来的步骤:预处理、编译、汇编、链接、执行,对应到C++代码的过程:.c -> .i -> .s ->.o -> .exe
预编译做的事:
编译总体过程:词法分析、语法分析、语义分析、源代码优化、代码生成、目标代码优化:
gcc这个命令只是这些后台程序的包装,他会根据不同的参数去调用预编译程序cc1、汇编器as、连接器ld
编译器前端负责生成和机器无关的中间代码;后端负责将中间代码转化为机器目标代码。这样对于一些可跨平台的编译器而言,他们可以针对不同的平台使用相同的前端,而针对不同的机器平台有数个后端。
汇编干了什么事?汇编将编译产生的汇编代码转换为机器码。
链接是这本书的主题。语言的发展是从编写一个代码文件到多个模块文件编写的,因此要让各个模块协同工作,就需要使用链接器。重要的一点是:在未链接之前,目标文件中的一些变量、函数地址是未决的(或者说可以这么理解:编译一个文件,可能有些变量、函数的地址确定不了,是个待定值),链接器就是来干这个事的:把一些指令对其他符号地址的引用加以修正。主要包括:地址和空间分配、符号决议和重定位。
可执行文件:Windows下的PE、Linux下的ELF
动态链接库:Windows的.dll,Linux的.os;静态链接库:Windows的.lib,Linux的.a
ELF文件类型分类:1.可重定位文件;2.可执行文件;3.共享目标文件;4.核心转储文件
已初始化的全局变量和局部变量数据经常放在数据段.data,未初始化的全局变量和局部静态变量放在.bss段;编译后的机器指令经常放在代码段.code或.text;.rodata段存放的是只读数据,一般是程序里面的只读变量和字符串常量。.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,int a=0会放在.bss段。
ELF文件头包含:ELF魔数、文件机器字节长度、段表的位置、程序头入口和长度、段的数量、文件是否可执行、目标硬件、目标操作系统等
数据和指令分段的好处:
段表:描述文件中各个段的数组,放置段的名称、段的长度、段类型、段在文件中的偏移位置、读写权限、段的属性等
重定位表:在处理目标文件时,必须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位信息都记录在ELF文件的重定位表里面。比如说数据的重定位表对应.rel.data
字符串表:因为字符串长度往往是不定的,固定结构存储较为困难。所以字符串表就是解决这个问题,使用字符串在表中的偏移来引用字符串。
链接过程的本质就是把多个不同的目标文件之间相互”粘“到一起,实际上拼合的是目标文件之间对地址的引用,即对函数和变量的地址的引用。我们可以将符号看作是链接中的粘合剂,整个连接过程正式基于符号才能够正确完成。
对符号进行分类:全局符号、局部符号、外部符号、段名、行号。链接只关注全局符号。
符号表中存放的内容有:编号、值、内存大小、类型、作用域、符号所属段、符号名字
特殊符号:一些内置的符号,比如_executable_start表示程序起始地址
函数签名:函数名、参数类型、所在类和名称空间。GCC的名称修饰方法就是:_Z + N(如果是嵌套)+n(后面的字符串长度)+str+E(结尾),比如N::C::func(int)经过名字修饰之后变成:_ZN1N1C4funcEi。由于不同编译器采用不同的名字修饰方法,必然导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。
对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。
针对强弱符号的概念,编译器就会按如下规则处理与选择多次定义的全局符号:
对于多个输入目标文件,链接器是如何将他们的各个段合并到输出文件?或者说,输出文件中的空间是如何分配给输入文件?
最蠢的做法是顺序叠加多个目标文件的段,但是会存在内存对齐导致输出文件过大的情况;所以,大多数做法是采用相似段合并的策略。
两步链接策略:
在未链接之前,目标文件中使用的其他编译单元的变量的地址未定义,会用0来给他占位,并且将这个未定义变量放进重定位表(专门用来保存与重定位相关的信息)中。
目前的链接器本身并不支持符号的类型,即变量类型对于链接器来说是透明的,它只知道一个符号的名字,并不知道类型是否一致。这就会导致由多个弱符号出现的时候的一些问题。COMMOM块来解决这些问题:当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。
C++中模板导致的问题:一个模板在一个编译单元内进行了实例化,在另外一个单元可能也进行了实例化,当这两个单元链接的时候就会出现重复代码,进而导致空间浪费、地址出错、指令运行效率低的问题。一个比较有效的做法是将每个模板的实例化代码都单独存放在一个段里,每个段包含一个模板实例。同理,对于虚函数,内联函数,默认构造函数,默认拷贝构造函数也会存在这样的问题,解决方案都差不多。
main函数之前执行全局构造,对应汇编代码.init段;main函数之后执行全局析构,对应汇编代码.fint段
ABI:Application Binary Interfacd,对应API,层次不同,ABI主要内容是符号修饰标准、变量内存布局、函数调用方式等内容。C++一直被人诟病的一大原因是它的二进制兼容性不好。
一个静态库可以简单地看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。但事实上也是这样,比如libc.a就是由许多.o文件打包而成的。
链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有以下三种方法:
BFD库是一个GNU项目,它的目标是希望通过一种统一的接口来处理不同的目标文件格式。
感觉这一章的东西对我没有吸引力啊~
映像:因为PE文件在装载时被直接映射到进程的虚拟空间中运行,它是进程的虚拟空间的映像。所以衍生出自己对映像的理解:编译链接好的程序,装进内存就能运行的文件
PE文件的段属性:段名、物理地址、虚拟地址、原始数据大小、段在文件中的位置、该段的重定位表在文件中的位置、该段的行号表在文件中的位置。总的来说和ELF文件大同小异。
PE文件和ELF文件的一些差异:.drectve段、.debug$S段。drectve段是编译器告诉链接器应该怎么链接这个目标文件的信息存放地。
很多时候把动态库叫做运行时;从程序角度看,我们可以通过C语言中的指针所占的空间来判断计算机虚拟地址空间。
在Linux中,用户态3G,内核态1G;Windows中默认用户态2G,内核态2G,但是可以通过修改配置来更改
装载方式有:
从操作系统角度看可执行文件的装载:
当操作系统捕获缺页错误的时候,她应该知道程序当前所需要的页在可执行的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度看,这一步是整个装载过程中最重要的一步,也是传统意义上”装载“的过程。
ELF文件中,段的权限往往只有为数不多的几种组合:
Segment和Section是从不同角度来划分同一个ELF文件。从Section角度来看,ELF文件就是链接视图,从Segment角度来看就是执行视图。
虚拟空间的内存分布模型就不多说了;堆中最大内存申请数量会受操作系统、程序本身大小、用到的动态/共享库数量大小、程序栈数量的影响;段地址之前也存在内存对齐的情况;进程栈初始化,最主要是将程序的参数以及环境变量放进栈空间中。
Linux内核装载ELF过程,拿fork举例:首先在用户从层面,bash进程会调用fork()系统调用创建一个新锦成,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。再详细一点,我就把书上的文字copy一份了:
每种可执行文件的格式的开头几个字节都很特殊,特别是开头4字节,常常被称为魔数,通过对魔数的判断可以确定文件的格式和类型。对于一些脚本文件,Shell、perl、python分别对应的#!/bin/sh、#!/usr/bin/perl、#!/usr/bin/python中的#!就是他们对应的魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
为什么要使用动态链接?静态链接存在一些问题:浪费内存和磁盘空间、模块更新困难的问题。比如,我们有一个第三方库,使用静态链接,那么我们要组建链接可执行文件的时候,会把第三方库链接的内容和目标文件放在一起,重定位之类的工作。如果有两个可执行文件都要使用这个第三方库,那么装载后,内存中会有两个这个第三方库的内容,这就是浪费内存。同样情况下,要更新第三方库,那么我们给出的目标文件要重新编译啥的,耗时。动态链接的提出,就是为了解决上述两个问题。动态链接的主要思想是:把链接的这个过程推迟到了运行时(装载)在进行。 引入动态链接,能够让程序具有可扩展性和兼容性,但是如果新旧模块之间接口不兼容还是会导致一系列问题。
在Linux中,ELF动态链接文件被称为动态共享对象,简称共享对象,“.so"为拓展名;Windows中,动态链接文件被称为动态链接库,”.dll"为拓展名。
动态库会导致程序在性能上的一些损失,每次装载时都需要重新链接,但是是存在优化策略的,比如延迟绑定。经验表明,动态链接比静态链接相比,性能损失大约在5%,这是可接受的。(时间换空间)
动态链接在链接的时候,如果一个符号是在本模块找不到,但是是在其他动态共享对象中,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
libc-2.6.1.so,C语言运行库;ld-2.6.so,Linux的动态链接器,动态链接器与普通共享对象一样被映射到进程的空间地址中,在系统开始执行一个可执行文件之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交回原来的可执行文件,然后再开始执行。
共享对象的最终装载地址在编译时是不确定的,而是再装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
装载时重定位:GCC使用的-shared命令就是让程序装载的时候重定位。具体做法是将链接步骤做的事情放在装载步骤来,因为在链接的时候并不知道动态库的内存地址,装载时候我们可以确定共享对象的一些信息,将需要重定位的信息填入即可。
地址无关代码:GCC使用的-fPIC指令就是让链接器生成地址无关的代码(Position-independent Code)。ELF的做法是在数据段中建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT),当代码需要引用该全局变量或函数的时候,可以通过GOT中相对应的项间接引用。装载的时候可以确定GOT表的信息。
延迟绑定PLT(Procedure Linkage Table)思想:当函数第一次被用到时才进行绑定(查找符号、重定位),如果没有用到则不进行绑定。使用的函数是_dl_runtime_resolve()。
got段用来保存全局变量引用的地址,got.plt用来保存函数引用的地址;interp段保存一个字符串,字符串就是可执行文件需要的动态链接器的路径;dynamic段保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表
动态链接时进程堆栈初始化,会比静态连接时多一些动态链接的信息,即动态链接辅助信息数组。
动态链接的步骤和实现:
支持动态链接的系统往往支持一种更加灵活的模块加载方式----显示运行时链接,最主要是下面4个API:
// 用于打开一个动态库,并将其加载到进程的空间地址,完成初始化过程
// 全局符号表包括程序的可执行文件本身,被动态链接器加载到进程中的所有共享模块
// 以及在运行时通过dlopen打开并且使用了RTLD_GLOBAL方式的模块中的符号
// filenmae:顾名思义,想要打开的动态库的名称
// flag:函数符号的解析方式
// RTLD_LAZY:延迟绑定PLT
// RTLD_NOW:当模块被加载时及完成所有函数的绑定工作
// RTLD_GLOBAL:将动态库的符号放进全局符号表中
// RTLD_NEXT:第一个找到的函数指针,常用在dlsym()中
// 成功,返回被加载模块的句柄;失败,返回NULL
void dlopen(const char* filename, int flag);
// 运行时装载的核心,通过这个函数可以找到所需要的符号,不管是变量还是函数
// handle:由dlopen返回的动态库句柄
// symbol:想要查找符号的名字
// 如果是函数,返回函数的地址;如果是变量,返回变量的地址;如果是常量,返回常量的值
void* dlsym(void* handle, char* symbol);
// 判断上一次其他三个函数的调用是否成功,返回NULL表示调用成功;如果不是,则返回相应的错误消息
dlerror();
// 与dlopen()正好相反,是将一个已经加载的模块卸载,并执行finit段的代码
dlclose();
再强调一遍:ABI对于不同语言来说,主要包括一些诸如函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等方面的规则。
Linux有一套规则来命名系统中的每一个共享库,他规定共享库的文件名规则必须如下:libname.so.x.y.z
SO-NAME:共享库的文件名去掉次版本号和发布版本号,保留主版本号,比如:libc.so.2。在Linux系统中,系统会为每个共享库在它所在的目录创建一个跟”SO-NAME“相同的并且指向它的软链接,实际上这个软链接会指向目录中版本号相同、此版本号和发布版本号最新的共享库。建立SO-NAME为名的软链接的目的是,是的所有依赖某个共享库的模块,在编译、链接和运行时,都是用共享库的SO-NAME,而不使用详细的版本号。
FHS规定,一个系统中主要有三个存放共享库的位置:
环境变量有:
动态链接库映射区:这个区域用于映射装载的动态链接库。在Linux中,如果可执行文件依赖其他共享库,那么系统就会为它在从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。
造成指针使用错误的两点主要原因:
栈保存了一个函数调用所需要的维护信息,这常被称为堆栈帧或活动记录。堆栈帧一般包括如下几个方面的内容:
活动记录里面的顺序背下来可能是最好的,有助于理解很多东西。从高地址到低地址(栈顶地址小)分别是:参数,返回地址(被调函数在主调函数中的下一条指令),Old EBP,保存的寄存器,局部变量,其他数据。其中EBP指向Old EBP的开始地址,ESP指向栈顶。比如说,一个函数要被调用,在主调函数中就会做一下操作,比如开辟一块存放返回值的空间用于存放返回值对应的临时变量,然后做拷贝实参到形参,指定返回地址,然后再陷入被调函数中,执行完,将返回值拷贝到事先准备好的临时变量,然后将临时变量拷贝到接收参数返回的实际变量中,重置EBP,返回到返回地址。
”烫“是因为分配出的栈空间每个字节都初始化为0xCC,0xCCCC是”烫“的编码;有的编译器初始化为0xCDCD,对应的汉字编码为”屯“。
Hook技术是怎么实现的?
nop
nop
nop
nop
nop
FUNCTION:
mov edi, edi
push ebp
move ebp, esp
想要被Hook的函数汇编代码可能长这个样,当我们要去Hook时,替换一些占位符:
LABEL:
jmp REPLACEMENT_FUNCTION
FUNCTION:
jmp LABEL
push ebp
move ebp, esp
总的来说,想要被Hook的函数,就要有相应的占位符,nop和mov edi, edi。
调用惯例:函数的调用方和被调用方对于函数如何调用必须有一个明确的约定,只要双方都遵守同样的约定,函数才能被正确的调用,这种约定就是调用惯例。一般调用惯例会有如下几方面的内容:函数参数的船体顺序和方式、栈的维护方式、名字修饰的策略等。常见的调用惯例有:cdecl、stdcall、fastcall、pascal、thiscall。这些调用惯例可放置在函数返回类型和函数签名之间,例如int _cdecl fun(){...}。需要说明一点是thiscall是C++的特殊调用惯例,thiscall 专门用于类成员函数的调用,对于一些编译器,this指针都当第一个参数压入栈中,但是对于VC的编译器使用ecx寄存器来存放this指针。
小于等于4字节的返回值直接通过eax寄存器返回;介于4字节到8字节的返回值,组合使用eax、edx返回返回值;大于8字节的返回值,会有一个临时对象产生,eax指向这个临时对象的首地址,使用eax返回指针。
Linux提供两种对空间分配的方式,即两种系统调用:一个是brk()系统调用,另外一个是mmap():
// 实际上是设置进程数据段的结束地址,他可以扩大或者缩小数据段
int brk(void* end_data_segment);
// 箱操作系统申请一段虚拟地址空间,这块虚拟地址空间可以映射到某个文件
// start为指定起始空间,需要申请length长度的空间,port用于设置空间的读取权限,flags用于设置映射类型(可以是文件映射和匿名空间)
// fd和offset用于mmap作为文件映射的时候指定文件描述符和文件偏移
void* mmap(void* start, size_t length, int port, int flags, int fd, off_t offset);
glibc的malloc函数是这样处理用户空间请求的:对于小于128kb的请求来说,他会在现有的对空间里面,按照堆分配算法为它分配一块空间并且返回;对于大于128kb的请求来说,他会使用mmap()函数为它分配一块匿名空间,然后再这个匿名空间中为用户分配空间。
堆分配算法:
程序真的是从main函数开始的吗?不是的,还有一些在main之前需要干的事,这些代码的函数称为入口函数或入口点,一个典型的程序运行步骤大致如下:
glibc入口函数执行流程如下:_start -> __libc_start_main -> exit -> _exit。其中,__libc_start_main的调用为:__libc_start_main(main, argc, argv, __libc_csu_init, __libc_csu_fini, edx, top of stack),各个参数含义如下:
Linux中IO初始化:实际上的文件描述符对应内核态中FILE结构数组的下标,0、1、2分别对应标准输入、标准输出、标准错误,IO初始化做的事就是在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。
堆初始化工作:初始化堆的起始地址、初始化空闲链表
任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其依赖的函数所构成的函数集合。这个代码集合称之为运行时库,CRT。一个C语言运行库大致包含如下功能:
变长参数,之前的博客其实提到过这个点,这里再记录一遍,加深印象,变长参数的实现得益于C语言默认的cdecl调用惯例的自右向左参数压栈的方式:
#define va_list char*
#define va_start(ap, arg) (ap=(va_list)&arg+sizeof(arg))
#define va_arg(ap, t) (*(t*))((ap+=sizeof(t))-sizeof(t)))
#define va_end(ap) (ap=(va_lst)0)
对于C/C++标准库来说,现成相关的部分是不属于标准库的内容的,它跟网络、图形图像等一样,属于标准库之外的系统相关库。
线程局部变量TLS:在Windows中的实现,是通过把TLS变量放进tls段中。当系统启动一个新的线程时,他会从进程的堆中分配一块足够大小的空间,然后把tls段中的内容复制到这块空间中,于是每个线程都有自己独立的tls副本。
觉得书上这段讲缓冲的话很助于理解:缓冲最为常见于IO系统重,设想一下,当希望向屏幕输出数据的时候,由于程序逻辑的关系,可能要多次调用pringf函数,并且每次写入的数据只有几个字符,如果每次写数据都要进行一个系统调用,让内核向屏幕写数据,就明显过于低效,因为系统调用的开销是很大的,他要进行上下文切换、内核参数检查、复制等,如果频繁进行系统调用,将会严重影响程序和系统的性能。不管是读还是写,都可以用缓冲
C语言提供几种缓冲模式:
Windows中fread的执行流程:fread -> fread_s -> _fread_nonlock_s -> _read -> ReadFile
系统调用是应用程序(运行库也是应用程序的一部分)与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。系统调用接口是通过中断来实现的,Linux使用0x80号中断作为系统调用的入口,Windows采用0x2E号中断作为系统调用入口。为什么会有系统调用?因为用户态的代码有很多权限没有,必须通过内核态的代码才能完成任务。
系统调用得满足:明确的定义、保持稳定和向后兼容。
系统调用时,EAX寄存器用于表示系统调用的接口号(中断有编号,系统调用也有编号)。
中断具有两个属性,一个称为中断号(从0开始),一个称为中断处理程序。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了指向第n号中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用他。中断程序在执行完成后,CPU会继续执行之前的代码。
基于int的Linux的经典系统调用实现: