学习《程序员的自我修养---链接、装载与库》

2017.11.09

1.动态库和可执行文件基本没什么区别,操作系统装载它们的步骤都一样;windows上有一种rundll32.exe的程序可以把动态库当做可执行文件运行;动态链接器既是一个可执行文件又是一个动态库,内核估计也是如此。


2.动态库运行时才手动加载,可以节省程序的启动时间和减少内存的使用,可以实现驱动、插件、脚本等不停止服务的情况下升级功能。


3.注意:共享对象不全等于动态库的概念,前者是连接器自动完成,后者是调用连接器的api完成链接。


2017.11.23

1.dlsym()返回的不仅可以是函数或者变量的地址,还可以是常量的值;


2.dlsym()查找符号的顺序分为两种,一种是装载序列,另一种以dlopen()装载的共享对象为根节点,然后广度优先进行查找;


3.c/c++不能从动态库中得到函数的类型,而java/C#可以,c/c++只能得到函数的地址;


4.栈数据结构,是先进后出,一般由函数的调用原理使用,存放函数参数和局部变量,主要功能是保持现场和断点,使用一级缓存,速度非常快;堆数据结构类似链表,使用二级缓存,速度相对慢一点,使用完后不能立即还给操作系统,由垃圾回收算法决定。


5.一般说的堆栈有两种含义,一种是内存的管理方式,栈空间和堆空间,另一种是只满足堆栈性质的数据结构,先进先出为堆,先进后出为栈;


6.因为堆空间容易产生内存碎片,就算程序占用的空间很小,也导致进程无法申请到空间;windows下有一种virtualMalloc()的申请空间速度比malloc快,它是在进程空间预留一块空间;栈空间不会产生内存碎片;


7.改善内存碎片的方式:a、尽量使内存块分割大一点;b、减少反复申请释放;c、每次申请最大内存;


8.内存碎片是否会影响其它进程?需要写一个程序测试一下!

内存碎片应该不会影响其它进程,因为内存碎片的来源是地址编号,而每一个进程的地址编号都是独立的,使用的是虚拟进程空间;而操作系统维护的内存是按照固定大小的内存页,比如4k,这样就不会产生大小不一的内存碎片;但我还没测试。


2017.11.27

1.破坏共享对象的ABI(application banary interface)兼容性很容易、比如不同的编译环境、操作系统、硬件平台都会导致ABI不兼容;尤其是导出C++的共享对象,兼容性更是十分复杂,而导出C语言的共享库情况就会好很多,所以在实际开发中一般共享库都是导出的c接口;


2.动态库的版本号是有标准的规范的,不能随意修改;标准格式是libname.so.x.y.z,以lib开头,name代表库名,x代表主版本号,y代表次版本号,z代表发布版本号;主办版代表更新升级是否兼容以前的程序,主办版不同就不兼容了;此版本号代表兼容性升级,只是新增了导出接口;发布版本就更兼容了,对接口完全没有动,只是修改内部bug或者性能提升等。


3.为了充分利用共享库兼容性升级,linux系统使用了一种SO-NAME的规则来编译链接运行共享对象,SO-NAMAE实际就是共享对象去掉此版本号和发布版本号后的名称,然后程序编译的时候就只保存这个SO-NAMAE,当共享库进行兼容性升级之后,可以直接删除原来的共享库,而SO-NAME依然不变,程序动态链接的时候还是能够找到这个最新 的动态库。


4.在使用GCC链接的时候,需要链接libxxxx.so.x.y.z,则只需要输入-l xxx 编译器就会自动查找最新版本的libxxxx.so.x.y.z就行编译,将SO-NAME填好到编译的程序中。

5.SO-NAMA机制不能解决此版本交会问题,增加了符号版本机制加以补全;但是我认为在动态链接程序所需要的符号时就可以发现问题并强制终止程序,这个机制是不是多此一举了?


6.原来符号版本控制机制可以让c语言的导出接口实现符号重载,以避免小的更改引起主办版号的变化!


7.原来并不是符号链接时就能保证程序正常运行,可能是符号能够找到,但是符号的意义功能发生了变化,这样就不能用动态链接是否成功来判断程序是否可以正常运行;因此引入了精确的符号控制机制,这是有必要的。


8.动态库的搜索顺序是:环境变量、ld.so.cache、默认库(/usr/lib,/lib);因此不要随便在环境变量里面加东西,这可能会影响到有些程序的运行,而且也会影响到编译时搜索库的路径,环境变量相当于自动加入-l xxxxxxxxx,这样的编译参数。


2017.11.28

1.动态库正常加载搜索之前,还会先加载LD_PRELOAD指定路径的动态库,由于动态库全局符号机制的介入,后面加载的同名符号都无法生效,这样就可以替换标准库中某些函数的功能。


2.gcc编译器用-share代表生成共享对象,并可以通过编译链接参数制定SO-NAME,动态库优先搜索路径,控制导出符号,清楚调试信息符号信息等。


3.共享库也可以有析构函数和构造函数,这个跟c++的析构和构造函数一样,都是在main函数之前或者之后执行。


2017.11.29

1.windows系统中动态库的后缀名有 dll 、ocx、cpl。可执行文件和动态库用文件结构中的一个 标志位 来 判断 。


2.windows下可以通过共享dll的数据段来实现进程间通讯。


3.windows动态库的符号导入导出需要显示说明,而elf共享对象默认导出所有符号;_declspec(dllexport)是支持windows编译器特有关键字。


4..def文件可以对导出符号进行重命名,比如WINAPI相当于_stdcall,这样的关键字的 修饰的导出名int Add(int a) 变成_Add@4,但是windows动态库中并没有发现这么怪的导出符号,肯定就是用了这种重命名_Add@4=Add。


2017.11.30

1.PE文件开头有一个DataDirectory,这个 目录 包含指向每个段的地址和段长度;与动态库相关的叫IMAGE_EXPORT_DIRECTORY,其中最重要的三个属性分别是函数地址、函数名、序号。


2.原来exp文件是连接器创建dll时的临时表,连接器链接需要两次扫描,第一次搜集所有目标文件中的导出符号保存到一个只有edata段的目标文件,这个目标文件以后缀名为exp命名,第二次扫描完成这些符号的链接,然后把报错到dll的edata段,不过现在都把合并到了rdata段。


3.PE文件中有一个IMAGE_IMPORT_DESCRIPTOR结构,专门记录导入的动态库信息,每一个结构对应一个动态库的导入信息;windows链接器会改写原来是导出符号的值,改写成真正的函数地址,链接器是内核的一部分,所以可以改写rdata段,改写完了之后会将页属性设置 成 只读 ,而elf文件却可以让应用程序修改got段,从 这点 来 ,windows的做法更安全;windows页使用了延迟加载技术,程序初始化的 时候 不会 出 链接 符号地址,第一次使用符号的时候,程序会自动调用链接器嵌入的桩代码,然后调用GetProAddr函数,将符号链接好,然后将权限交给应用程序,这个过程速度很快。


4.在使用导入的函数符号时,最好使用_declspec(dllimport),这样可以省掉一句汇编指令JMP DWORD PTR[xxxxxxxxx] xxxxxxxxx代表IAT中函数的真正地址。


2017.12.12

1.dll绑定就是在程序编译的时候指定 各个dll之间的加载顺序,指定解析符号的地址,这样加快程序的启动 。dll绑定是针对于可执行文件或者是使用者dll,因为在被使用的dll不更新的情况下,这些dll被加载到进程的地址是固定的,通过编译时或者安装程序对地址进行绑定来节约程序的运行速度;


2.一定不要使用c++作为导出接口,后果将会非常严重,导致更新不兼容;微软提出了COM方法来解决这个共享库不兼容问题;compnent object model;推荐阅读《COM本质论》


3.c++导出接口时应该遵循以下规则:

a、所有的接口都应该是抽象的;

b、所有全局符号都应该使用exten“C”关键字以防止符号修饰导致的不兼容;所有导出函数都应该是__stdcall修饰,这样就算用户使用的是__cedlcall也能够调用,如:int Add(int a,int b)stdcall @_Add8,cedlcall _Add 他们的修饰名有包含关系,cedlcall被stdcall包含;

c、不要使用C++ STL库;

d、不要使用虚析构,尽量提供一个destory接口来 释放 内存 ;

e、不要在dll申请内存,dll之外来释放内存;反过来也不能;

f、不要在接口中使用重载、多态方法,因为不同编译器对虚表的处理 处理不同;


4、dll hell 意思就是dll缺陷引起的噩梦;解决得方法是:a、使用静态库链接;b、防止dll覆盖 DLL Stomping;c、编标dll冲突,应用程序优先从自己的目录下搜索dll;


5、dll比linux下的共享elf文件更为复杂,功能也更完善;windows系统接口有的就是dll,而linux对底层的调用最底层 是 系统调用 。


2017.12.13

第四部分 库与运行库

1.程序的运行环境:内存、运行库、系统调用;


2.内存区域分为几个块,首先将高地址的1G或2G分配给内核,叫做内核空间;剩余的叫用户空间;用户空间由分为几个区域:栈,用户空间的最高位置方向;堆,用户空间的最低地址为起点;可执行文件映像也是从低地址开始加载,一般都有指定的映像地址;保留区,禁止访问的,极小的地址都是禁止访问的,所以一般把无效地址设置为NULL就是这个道理。


3.内存不允许访问的情况如下:指向的地址还没有映射物理地址;指向的地址不允许读或者写;


4.栈的增长方向是向下的,因此esp减小就是栈增加,esp增加就是栈减小;


5.栈被成为堆栈帧,ebp被成为帧指针;


6.push 【寄存器】 就是把该寄存器的值压入栈顶,esp-4,pop【寄存器】 就是把栈顶中的数据放入指定的寄存器后esp+4,ret 就是 将eip设置成esp指定的地址,esp怎么恢复?应该是eip指向的代码让执行函数前压入的参数出栈;这刚好符先入后出的规则;


7.未初始化的变量编译器会初始化为0xCC,0xCCCC刚好是汉子烫,有时候编译器也会把初始化为0xcd,两个连着的0xcdcd 就是汉子屯,这两个数值一般是判断变量是否初始化的依据;


8.static修饰函数标志只能在本单元调用,还有确信没有其它编译单元调用的函数,这两种函数的出入栈方式跟标准模式不一样;


9.mov edi,edi 占两个字节;nop占一个字节,这种指令是专门占位用的;


10.函数的修饰词代表了一种函数的调用惯例,比如__cdecl 表示:a、参数传递从右到左;b、出栈方 为调用方;c、函数名修饰为在名字前加一个下划线;


11.pacscal居然是从左只有压栈;fastcall,先把头两个参数,这个参数类型所占字节数必须少于4字节或者4字节;还有叫nake call的,它不产生保护寄存器的代码;C++有叫thiscall的,专门调用成员函数;vc调用thiscall是有点特殊,但是gcc中thiscall和cdecl完全一样,它把this当做第一个参数处理;


12、反编译动态库的导出符号,然后查看符号调用前的压栈方式或者查看修饰名就可以反编译函数的类型,自己定义一个同名的dll,导出同样的函数名,就可以替换原程序的执行调用;


13、使用大尺寸的返回值,会导致增加一个临时变量的空间和拷贝两次数据;


14、返回大尺寸的数据都会产生两次拷贝;返回对象时还会使用构造函数和重载=运算;

所以最好不要使用大尺寸的返回对象;


15.原来堆空间并不是每次都向操作系统申请,而是一次批发一大堆空间,然后零售给应用程序,这个工作是由运行库来完成的,有固定的堆的分配算法。

10.3.2


2017.12.14

1.malloc并不是最底层的分配内存函数,最底层的是 void* mmap(),brk(),sbrk()等;这些底层函数申请内存必须时,返回的都是内存页大小的整数倍,所以很小的内存申请就不要使用这些函数申请内存了;操作系统就是这样来避免内存碎片的。


2.进程地址分布如下:0x08040000以下是驱动等使用的空间;0x080400000开始装载可执行文件,从可执行文件结束大约有1G的堆空间;到0x4000 0000开始是各个动态库的装载地址;剩下的又是堆空间;到栈空间截止;栈空间顶部是接着操作系统内核的地址0xC000 0000.不过linux2.6以后共享库的装载地址被移动到0xbf00 0000附近,所以导致对空间最大可以直接申请到3GB。内存的最大申请数=空闲内存数+空闲交换空间数;


3.在windows系统中,进程空间很杂乱,向操作系统批发内存的接口是VirtualMalloc(),每次批发必须是4096的整数被。


4.内存管理原来是使用操作系统接口mmap、virtualAlloc等接口向操作系统批发内存,然后以一种合理的方式零售给应用程序。内核、驱动等使用的堆是rtlAlloc开头的,和用户的堆都不在一个管理器中。


5.应用进程的堆可能有好几个,一次性能申请的最大连续空间取决于最大的堆空间;release模式下,如果你平白无辜malloc很大块空间,后来一直不用,发现程序占用的空间没有你申请的大?这是因为堆算法的功劳,这么多没怎么用的内存悄悄退还给操作系统了,因为程序知道你后来一直没用,或者你偶尔用的时候在悄悄弄回来。所以要霸占内存还是得使用VirtualAlloc


6。原来windows 的堆增长还不完全是向上增长的;


7.发现圆钢采集卡驱动进程结束后,其它进程还是不能使用这个卡,说明有些驱动的堆空间进程结束并不能将其释放掉?

10.3.4


2017.12.16

1.堆的分配算法有很多种,比如比较简单的有:空闲链表、位图、对象池、buddy。没怎么看懂,书上就概述了分配原理,没有讲清楚细节。


2.对于程序的运行需要运行库默默地奉献。程序的入口并不是main函数,而是运行库的入口函数,运行库入口负责处理程序的相关初始化和清理工作,运行库初始化完之后才调用main函数,这里main函数相当于一个运行库的回调函数指针,运行库入口又相当于操作系统的回调函数指针。可以使用atexit安装一个main函数之后的函数。


3.还有一种指针叫bunded指针,长度是普通指针的三倍。但是2003年之后这种指针已经被废弃。


2017.12.19

1.windows 中可以使用alloca动态分配空间,而且这种空间不使用堆,可以像局部变量一样函数退出的时候自动释放;


2.采用文件句柄的方法可以让文件地址向用户隐藏,具有堆文件的保护作用;句柄一般是内核对象数组下标;windows中的句柄不是文件FILE结构的下标,而是下标经过线性变化的结果;


3.windows的对初始化调用了HeapCreate,所以malooc肯定是调用了VirtualAlloc,所以windows进程堆的管理是交给了操作系统;


4.IO 初始化会把标准输入输出、标准错误和从父进程继承来的文件句柄保存到自己进程的文件表中;


5.运行库就是保证程序正常运行的代码集合,至少包含入口函数和标准库函数的实现;


6.最早的c标准是c89,后来有c95、c99,c99使用最普遍;


11.2

1.不定参数是c99标准的新加内容,主要是利用了cedcl从右到左的调用惯例和被调用方清楚缓存;然后利用第一个参数解析出后面跟着的所有参数地址和大小;只要前面任何一个参数解析错误,后面的输出全是错误。


2.局部跳转机制违反了结构化编程,然程序的时光倒流。


3.线程和操作系统的权限控制都不是c标准的内容,所以这两部分的接口属于平台相关;但是这两部分内容在glibc和msvcrt中都有实现,所以可以使用条件编译来写跨平台的代码;


4.crt0.o是程序启动和结束的核心代码;crti.o crtn.o是专门为了支持C++全局构造和全局析构产生的.init .finit段而编写的,同时crt1.o也是为此而对crt0.o的升级版;

p343

2017.12.24

1.在嵌入式开发环境中,可能不需要编译器加入crt、glibc等运行库,这个要求可以通过编译参数取消;也可以把一些普通函数放入到init段,但是必须使用汇编,必须避免产生ret指令,ret指令会导致init函数提前返回。

2.真正的析构和构造函数并不在意crti和crtn中实现,因为它们是glibc的部分,glibc是c语言的标准实现,而析构和构造都是输入c++的内容;对此GCC提供了crtbeginT、crtend两个目标文件来实现全局构造和析构;


3.某些c标准库中默认函数重定义的错误原因是不同模块使用了不同的 crt 库,可以通过忽略某些特定的crt库来让链接 顺利 进行


4.当不同的dll使用了不同的crt时,应该注意以下两点:a、一个动态库申请的内存不能到另一个动态库里面释放;因为它们属于不同的堆;b、不能共享打开的文件句柄,因为它们有不同的文件表;c、可能还会发生莫名其妙的错误;d、最好的 解决 让 同一个程序中所有模块依赖的运行库版本相同;


5.有时候发现自己在vs2010上编译的程序在别的机器上 运行 ,这是因为别人的机器上没有对应版本的crt;有两种方法可以解决:a、使用静态crt编译;b、发布程序的时候连同本版本的运行库一起发布;

6.TLS变量看起来跟局部变量差不多;

7.编译器会自动给每个目标文件生成 全局析构和构造函数,然后把每一个模块的全局构造和析构函数搜集起来放到对应的段里面,程序启动时在init函数中调用这些函数完成全局构造函数的调用;所有当存在全局构造和析构的函数时,不能去掉init段,否则全局构造不会被调用;

8.MSVCRT的析构函数


2017.12.25

1.运行库的复杂部分并不在程序 启动 、多线程、全局构造等,而是IO部分的实现;

2.缓冲机制在IO和硬件设备交互中 是一个很高效的机制,能够大大 降低 系统 调用 开销 ;在文件IO中使用缓冲机制可以降低磁盘读写开销,在绘制图形的时候使用双缓冲机制可以避免闪烁;

3.系统调用的功能:保护 有限 资源,如磁盘、网络、设备;实现应用程序无法实现的功能,如定时器;

4.系统调用是通过中断机制来实现的,linux下是0x80,windows下是0x2E;

5.系统调用通常是用寄存器来传递参数,函数调用是使用栈数据结构;

6.解决程序代码不兼容、使用繁琐的问题,可以通过增加一层的方法来实现;这种 增加 层 思想 可以 解决程序中很多问题,运行库就是应用程序与系统调用 抽象 层;

12.2


2017.12.28

1.#define syscall(type name) type name(){......NR_##name;.....},在宏替换中##后面的内容代表将会使用宏参数来替换,这样就可以部分替代字符串中的内容,如果没有##NR_name中的name将不会被替换;

2.汇编指令中int代表使用中断指令号的意思,而不是整型数据;

3.系统调用的思路是:用一个编号来作为中断入口,如linux是0x08,把中断对应的函数号(不是中断号0x80)存入到寄存器eax中;使用其它寄存器来传递参数,分别是ebx、ecx、edx、esi、edi、ebp;将用户栈空间(0-2G)切换到内核栈空间(2-4G),为了系统调用完可以恢复现场,需要将用户栈使用的现场esp、ebp、ss、eip、CS、EFLAGS; 内核中有一个中断表,收到终端号之后回去中断表中查询终端号对应的服务0x00-》除以 0,0x14-》缺页,0x02-》硬件驱动,0x08-》systemcall(eax=1-》sys_exit,eax=2->sys_fork,eax=3->sys_read);

4.每个进程 都有 的 内核 栈 来执行系统调用,系统 调用 表 估计 操作 系统 启动 时候 就 已经 初始化 ;

5.原来int指令的调用效率不高,现在都改用sysenter、sysexit;

6.还有虚拟动态库这么一个 东西 这个 动态库 没有指定的文件,这个动态库不存在,但是每个进程却都使用了它,它 为了 新的系统调用指令sysenter、sysexit;

7.print就是向stdout也就是文件描述符1里面写入参数,只要使用系统终端命令,传递syswrite的函数号4,并把要输入的参数即 字符串的地址写入ebp,就可以实现printf的功能;

12.3


2017.12.30

1.windows系统 中 ,它并没有 直接 把 系统 调用 接口 提供给应用程序,而是在系统调用之上增加了一个层叫windows api,把这个层封装成动态库给应用程序;因此在windows系统中,应用程序能接触到最底层的接口是windows api,而不是系统调用;在windows系统中,系统调用是一个可执行文件,它是一个服务程序,有数百个这样的可执行文件来提供不同的服务,类似于socket通信一样的服务器程序?最终调用系统调用的是NTDLL.dll,使用0x20中断;

2.windows 服务 是 可执行文件,但是同时导出了函数接口,让windows api可以直接调用接口实现功能,而不是像可执行文件发送客户请求;

3.在此强调增加一个层是解决软件各个版本兼容性的万灵药,这是《人月神话》中提到的思想;

4.windows2000及以后的版本都是基于 ntdll 执行系统调用的,而ntdll是支持原生unicode,也就是所有 字符串 操作都需要使用双字节编码,否则就会出问题;所有现在那些ansi版本即多字节版本程序都会在执行调用ntdll接口之前执行字符编码的转换,从这点来看多字节版本程序的运行效率较低,而且字符编码容易混乱;

5.在windows64中, win32程序实际运行在win32子系统上,这个子系统通过一定转换后才真正使用系统调用,实现与文件的接触,因此子系统上运行的程序效率较低;

6.字节序 是根据不同的cpu而不同;intelx86主要是小端,奔腾 系列 主要 是大端 ;在网络传输中都是大端;big-endian,little-endian这两个名词来自格列佛游记中征战双方的名字;

终于看完 !

编辑于 2017-12-30 16:56