程序员的自我修养:链接、装载与库
程序员的自我修养:链接、装载与库
本书将详细描述现在流行的Windows和Linux操作系统下各自的可执行文件、目标文件格式;
普通C/C++程序代码如何被编译成目标文件及程序在目标文件中如何存储;
目标文件如何被链接器链接到一起,并且形成可执行文件;
目标文件在链接时符号处理、重定位和地址分配如何进行;
可执行文件如何被装载并且执行;
可执行文件与进程的虚拟空间之间如何映射;
什么是动态链接,为什么要进行动态链接;
Windows和Linux如何进行动态链接及动态链接时的相关问题;
什么是堆,什么是栈;
函数调用惯例;
运行库,Glibc和MSVC CRT的实现分析;
系统调用与API;
最后我们自己还实现了一个Mini CRT。
第1部分 简介
第1章 温故而知新
介绍基本的背景知识,包括硬件、操作系统、线程等。
1.1 从Hello World说起
- 程序为什么要被编译器编译了之后才可以运行?
- 编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎么做的?
- 最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们怎么存放的,怎么组织的?
- #include <stdio.h>是什么意思?把stdio.h包含进来意味着什么?C语言库又是什么?它怎么实现的?
- 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
- Hello World程序是怎么运行起来的?操作系统是怎么装载它的?它从哪儿开始执行,到哪儿结束?main函数之前发生了什么?main函数结束以后又发生了什么?
- 如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作系统的机器上运行Hello World需要什么?应该怎么实现?
- printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?
- Hello World程序在运行时,它在内存中是什么样子的?
1.2 万变不离其宗
冯诺依曼计算机结构。
对于系统程序开发者来说,计算机多如牛毛的硬件设备中,有三个部件最为关键,它们分别是中央处理器CPU、内存和I/O控制芯片,这三个部件几乎就是计算机的核心了。
对于普通应用程序开发者来说,他们似乎除了要关心CPU以外,其他的硬件细节基本不用关心。
对于一些高级平台的开发者来说(如Java、.NET或脚本语言开发者),连CPU都不需要关心,因为这些平台为它们提供了一个通用的抽象的计算机,他们只要关心这个抽象的计算机就可以了。
1.3 站得高,望得远
系统软件可以分成两块:
- 一块是平台性的,比如操作系统内核、驱动程序、运行库和数以千计的系统工具
- 另外一块是用于程序开发的,比如编译器、汇编器、链接器等开发工具和开发库。
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”
这句话几乎概括了计算机系统软件体系结构的设计要点,整个体系结构从上到下都是按照严格的层次结构设计的。
不仅是计算机系统软件整个体系是这样的,体系里面的每个组件比如操作系统本身,很多应用程序、软件系统甚至很多硬件结构都是按照这种层次的结构组织和设计的。
从整个层次结构上来看,开发工具与应用程序是属于同一个层次的,因为它们都使用一个接口,那就是操作系统应用程序编程接口(Application Programming Interface)。
应用程序接口的提供者是运行库,什么样的运行库提供什么样的API,比如Linux下的Glibc库提供POSIX的API;Windows的运行库提供Windows API,最常见的32位Windows提供的API又被称为Win32。
运行库使用操作系统提供的系统调用接口(System call Interface),系统调用接口在实现中往往以软件中断(Software Interrupt)的方式提供,比如Linux使用0x80号中断作为系统调用接口,Windows使用0x2E号中断作为系统调用接口(从Windows XP Sp2开始,Windows开始采用一种新的系统调用方式)。
操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件是接口的定义者,硬件的接口定义决定了操作系统内核,具体来讲就是驱动程序如何操作硬件,如何与硬件进行通信。这种接口往往被叫做硬件规格(Hardware Specification),硬件的生产厂商负责提供硬件规格,操作系统和驱动程序的开发者通过阅读硬件规格文档所规定的各种硬件编程接口标准来编写操作系统和驱动程序。
1.4 操作系统做什么
操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件资源。
一个计算机中的资源主要分CPU、存储器(包括内存和磁盘)和I/O设备,我们分别从这三个方面来看看如何挖掘它们的潜力。
1.4.1 不要让CPU打盹
这种模式就是我们现在很熟悉的多任务(Multi-tasking)系统,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。
所有的应用程序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。
CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程。这种CPU的分配方式即所谓的抢占式(Preemptive),操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。
如果操作系统分配给每个进程的时间都很短,即CPU在多个进程间快速地切换,从而造成了很多进程都在同时运行的假象。目前几乎所有现代的操作系统都是采用这种方式,比如我们熟悉的UNIX、Linux、Windows NT,以及Mac OS X等流行的操作系统。
1.4.2 设备驱动
操作系统作为硬件层的上层,它是对硬件的管理和抽象。
当成熟的操作系统出现以后,硬件逐渐被抽象成了一系列概念。
- 在UNIX中,硬件设备的访问形式跟访问普通的文件形式一样。
- 在Windows系统中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象成了DirectX对象;磁盘被抽象成了普通文件系统。
这些繁琐的硬件细节全都交给了操作系统,具体地讲是操作系统中的硬件驱动(Device Driver)程序来完成。驱动程序可以看作是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较好的灵活性。
因为PC的硬件多如牛毛,操作系统开发者不可能为每个硬件开发一个驱动程序,这些驱动程序的开发工作通常由硬件生产厂商完成。操作系统开发者为硬件生产厂商提供了一系列接口和框架,凡是按照这个接口和框架开发的驱动程序都可以在该操作系统上使用。
关于硬盘的结构介绍,关于硬盘结构可能很多读者已经有一个大概的了解,那就是硬盘基本存储单位为扇区(Sector),每个扇区一般为512字节。
现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的方式,即整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号。逻辑扇区号抛弃了所有复杂的磁道、盘面之类的概念。当我们给出一个逻辑的扇区号时,硬盘的电子设备会将其转换成实际的盘面、磁道等这些位置。
1.5 内存不够怎么办
进程的总体目标是希望每个进程从逻辑上来看都可以独占计算机的资源。
我们把程序给出的地址看作是一种虚拟地址(Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域跟另外一个程序相互不重叠,以达到地址空间隔离的效果。
1.5.1 关于隔离
所谓的地址空间是个比较抽象的概念,你可以把它想象成一个很大的数组,每个数组的元素是一个字节,而这个数组大小由地址空间的地址长度决定,比如32位的地址空间的大小为 2^32 = 4 294 967 296 字节,即4GB,地址空间有效的地址是 0~4 294 967 295,用十六进制表示就是0x00000000~0xFFFFFFFF。
地址空间分两种:虚拟地址空间(Virtual Address Space)和物理地址空间(Physical Address Space)。
物理地址空间是实实在在存在的,存在于计算机中,而且对于每一台计算机来说只有唯一的一个,你可以把物理空间想象成物理内存。
虚拟地址空间是指虚拟的、人们想象出来的地址空间,其实它并不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效地做到了进程的隔离。
1.5.2 分段(Segmentation)
事实上,根据程序的局部性原理,当一个程序在运行时,在某个时间段内,它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内都是不会被用到的。
人们很自然地想到了更小粒度的内存分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高了内存的使用率。这种方法就是分页(Paging)。
1.5.3 分页(Paging)
分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。
目前几乎所有的PC上的操作系统都使用4KB大小的页。我们使用的PC机是32位的虚拟地址空间,也就是4GB,那么按4KB每页分的话,总共有1 048 576个页。物理空间也是同样的分法。
我们把虚拟空间的页就叫虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page)。
虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的。但是几乎所有的硬件都采用一个叫MMU(Memory Management Unit)的部件来进行页映射。
1.6 众人拾柴火焰高
1.6.1 线程基础
什么是线程
线程(Thread),有时被称为轻量级进程(Lightweight Process, LWP),是程序执行流的最小单元。
一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。
通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。
线程的访问权限
线程调度与优先级
不论是在多处理器的计算机上还是在单处理器的计算机上,线程总是“并发”执行的。
当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此之间互不相干。
但对于线程数量大于处理器数量的情况,线程的并发会受到一些阻碍,因为此时至少有一个处理器会运行多个线程。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。
操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间(通常是几十到几百毫秒),这样每个线程就“看起来”在同时执行。这样的一个不断在处理器上切换不同的线程的行为称之为线程调度(Thread Schedule)。
在线程调度中,线程通常拥有至少三种状态,分别是:
- 运行(Running):此时线程正在执行。
- 就绪(Ready):此时线程可以立刻运行,但CPU已经被占用。
- 等待(Waiting):此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行。
处于运行中线程拥有一段可以执行的时间,这段时间称为时间片(Time Slice),当时间片用尽的时候,该进程将进入就绪状态。
如果在时间片用尽之前进程就开始等待某事件,那么它将进入等待状态。
每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。
在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态。
线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算法。
现在主流的调度方式尽管各不相同,但都带有优先级调度(Priority Schedule)和轮转法(Round Robin)的痕迹。
Linux的多线程
Linux对多线程的支持颇为贫乏,事实上,在Linux内核中并不存在真正意义上的线程概念。
Linux将所有的执行实体(无论是线程还是进程)都称为任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。
不过,Linux下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。
fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从fork函数里返回。
在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返回。但不同的是本任务的fork将返回新任务pid,而新任务的fork将返回0。
fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制(Copy on Write, COW)的内存空间。
所谓写时复制,指的是两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。
fork只能够产生本任务的镜像,因此须要使用exec配合才能够启动别的新任务。exec可以用新的可执行映像替换当前的可执行映像,因此在fork产生了一个新任务之后,新任务可以调用exec来执行新的可执行文件。fork和exec通常用于产生新任务,而如果要产生新线程,则可以使用clone。
使用clone可以产生一个新的任务,从指定的位置开始执行,并且(可选的)共享当前进程的内存空间和文件等。如此就可以在实际效果上产生一个线程。
1.6.2 线程安全
竞争与原子操作
我们把单指令的操作称为原子的(Atomic),因为无论如何,单条指令的执行是不会被打断的。
同步与锁
所谓同步,既是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。
可重入(Reentrant)与线程安全
一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况: (1)多个线程同时执行这个函数。 (2)函数自身(可能是经过多层调用之后)调用自身。 一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。
原因在于早在几十年前,CPU就发展出了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。
编译器在进行优化的时候,也可能为了效率而交换毫不相干的两条相邻指令(如x=1和r1=y)的执行顺序。
我们可以使用volatile关键字试图阻止过度优化,volatile基本可以做到两件事情: (1)阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。 (2)阻止编译器调整操作volatile变量的指令顺序。
三种线程模型
- 一对一模型
- 多对一模型
- 多对多模型
1.7 本章小结
第2部分 静态链接
第2章 编译和链接
介绍编译和链接的基本概念和步骤。
- 通常将这种编译和链接合并到一起的过程称为构建(Build)
2.1 被隐藏了的过程
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
GCC编译过程分解, 分别是预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking)
2.1.1 预编译
预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。比如“#include”、“#define”等,主要处理规则如下:
- 将所有的“#define”删除,并且展开所有的宏定义。
- 处理所有条件预编译指令,比如“#if”、“#ifdef”、“#elif”、“#else”、“#endif”
- 处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
- 删除所有的注释“//”和“/* */”。
- 添加行号和文件名标识,比如#2“hello.c”2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的#pragma编译器指令,因为编译器须要使用它们。
2.1.2 编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。
gcc –S hello.i –o hello.s
2.1.3 汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
as hello.s –o hello.o
gcc –c hello.c –o hello.o
2.1.4 链接
2.2 编译器做了什么
编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
2.2.1 词法分析
词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号)。
2.2.2 语法分析
语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。
整个分析过程采用了上下文无关语法(Context-free Grammar)的分析手段。由语法分析器生成的语法树就是以表达式(Expression)为节点的树。
在语法分析的同时,很多运算符号的优先级和含义也被确定下来了。
2.2.3 语义分析
语义分析,由语义分析器(Semantic Analyzer)来完成。
语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。
编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译期可以确定的语义,与之对应的动态语义(Dynamic Semantic)就是只有在运行期才能确定的语义。
静态语义通常包括声明和类型的匹配,类型的转换。
经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。
2.2.4 中间语言生成
现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过程。我们这里所描述的源码级优化器(Source Code Optimizer)在不同编译器中可能会有不同的定义或有一些其他的差异。
其实直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等。
中间代码有很多种类型,在不同的编译器中有着不同的形式,比较常见的有:三地址码(Three-address Code)和P-代码(P-Code)
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。
2.2.5 目标代码生成与优化
编译器后端主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)
代码生成器可能会生成下面的代码序列(我们用x86的汇编语言来表示,并且假设index的类型为int型,array的类型为int型数组):
movl index, %ecx ; value of index to ecx
addl $4, %ecx ; ecx = ecx + 4
mull $8, %ecx ; ecx = ecx * 8
movl index, %eax ; value of index to eax
movl %ecx, array(,eax,4) ; array[index] = ecx
最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。
movl index, %edx
leal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
现代的编译器有着异常复杂的结构,这是因为现代高级编程语言本身非常地复杂,比如C++语言的定义就极为复杂,至今没有一个编译器能够完整支持C++语言标准所规定的所有语言特性。
另外现代的计算机CPU相当地复杂,CPU本身采用了诸如流水线、多发射、超标量等诸多复杂的特性,为了支持这些特性,编译器的机器指令优化过程也变得十分复杂
使得编译过程更为复杂的是有些编译器支持多种硬件平台,即允许编译器编译出多种目标CPU的代码。
经过这些扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化,编译器忙活了这么多个步骤以后,源代码终于被编译成了目标代码。但是这个目标代码中有一个问题是:index和array的地址还没有确定。如果我们要把目标代码使用汇编器编译成真正能够在机器上执行的指令,那么index和array的地址应该从哪儿得到呢?
如果index和array定义在跟上面的源代码同一个编译单元里面,那么编译器可以为index和array分配空间,确定它们的地址;那如果是定义在其他的程序模块呢?
这个看似简单的问题引出了我们一个很大的话题:目标代码中有变量定义在其他模块,该怎么办?
事实上,定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代的编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。让我们带着这个问题,走进链接的世界。
2.3 链接器年龄比编译器长
在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是须解决的问题。模块之间如何组合的问题可以归结为模块之间如何通信的问题,最常见的属于静态语言的C/C++模块之间通信有两种方式,一种是模块间的函数调用,另外一种是模块间的变量访问。
函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用。
模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合。
这个模块的拼接过程就是本书的一个主题:链接(Linking)。
这种基于符号的模块化的一个直接结果是链接过程在整个程序开发中变得十分重要和突出。
2.4 模块拼装——静态链接
程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂的时候,我们不得不将一个复杂的系统逐步分割成小的系统以达到各个突破的目的。
一个复杂的软件也如此,人们把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接(Linking)。
链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。
链接过程主要包括了地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution)和重定位(Relocation)等这些步骤。
最常见的库就是运行时库(Runtime Library),它是支持程序运行的基本函数的集合。库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。
这个地址修正的过程也被叫做重定位(Relocation),每个要被修正的地方叫一个重定位入口(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址引用的位置“打补丁”,使它们指向正确的地址。
2.5 本章小结
程序源代码到最终可执行文件的4个步骤:预编译、编译、汇编、链接,分析了它们的作用及相互之间的联系,IDE集成开发工具和编译器默认的命令通常将这些步骤合并成一步,使得我们通常很少关注这些步骤。
第3章 目标文件里有什么
介绍COFF目标文件格式和源代码编译后如何在目标文件中存储。
目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同。
可执行文件格式涵盖了程序的编译、链接、装载和执行的各个方面。了解它的结构并深入剖析它对于认识系统、了解背后的机理大有好处。
3.1 目标文件的格式
现在PC平台流行的可执行文件格式(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它们都是COFF(Common file format)格式的变种。
目标文件就是源代码编译后但未进行链接的那些中间文件(Windows的.obj和Linux下的.o),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。
从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件,在Windows下,我们可以统称它们为PE-COFF文件格式。在Linux下,我们可以将它们统称为ELF文件。
不光是可执行文件(Windows的.exe和Linux下的ELF可执行文件)按照可执行文件格式存储。
动态链接库(DLL,Dynamic Linking Library)(Windows的.dll和Linux的.so)及静态链接库(Static Linking Library)(Windows的.lib和Linux的.a)文件都按照可执行文件格式存储。它们在Windows下都按照PE-COFF格式存储,Linux下按照ELF格式存储。
静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引,你可以简单地把它理解为一个包含有很多目标文件的文件包。
GCC来编译这个文件(参数 -c 表示只编译不链接)
gcc –c SimpleSection.c
gcc –c -g SimpleSection.c
objdump -h SimpleSection.o
size SimpleSection.o
objdump -s -d SimpleSection.o
objdump -x -s -d SimpleSection.o
readelf –h SimpleSection.o
readelf -S SimpleSection.o
nm SimpleSection.o
readelf -s SimpleSection.o
3.2 目标文件是什么样的
一般目标文件将这些信息按不同的属性,以“节”(Section)的形式存储,有时候也叫“段”(Segment)。
程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有“.code”或“.text”;
全局变量和局部静态变量数据经常放在数据段(Data Section),数据段的一般名字都叫“.data”。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。
很多人可能会有疑问:为什么要那么麻烦,把程序的指令和数据的存放分开?混杂地放在一个段里面不是更加简单?其实数据和指令分段的好处有很多。主要有如下几个方面。
- 一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写。
- 另外一方面是对于现代的CPU来说,它们有着极为强大的缓存(Cache)体系。由于缓存在现代的计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
- 第三个原因,其实也是最重要的原因,就是当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其他的只读数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程私有的。不要小看这个共享指令的概念,它在现代的操作系统里面占据了极为重要的地位,特别是在有动态链接的系统中,可以节省大量的内存。
3.3 挖掘SimpleSection.o
- .text 代码段
- objdump -s -d
- .data段保存的是那些已经初始化了的全局静态变量和局部静态变量
- .rodata段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。
- .bss段存放的是未初始化的全局变量和局部静态变量
- .text:代码段
- .data:已初始化的数据
- .bss:未初始化的数据
- .rodata:只读数据
- .symtab:符号表
- .strtab:字符串表
- .shstrtab: 段名表
- .rel.*:重定位信息
- .dynamic:动态链接信息
- .init和.fini:初始化和终止代码
- .debug:调试信息
- .line
- .plt和.got:动态链接相关
- .comment:注释信息
- .note:系统备注
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering> dumpbin /All .\SimpleSection.obj
Microsoft (R) COFF/PE Dumper Version 14.43.34808.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\SimpleSection.obj
File Type: COFF OBJECT
FILE HEADER VALUES
8664 machine (x64)
8 number of sections
67D3E860 time date stamp Fri Mar 14 16:27:12 2025
35A file pointer to symbol table
21 number of symbols
0 size of optional header
0 characteristics
SECTION HEADER #1
.drectve name
0 physical address
0 virtual address
18 size of raw data
154 file pointer to raw data (00000154 to 0000016B)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
100A00 flags
Info
Remove
1 byte align
RAW DATA #1
00000000: 20 20 20 2F 44 45 46 41 55 4C 54 4C 49 42 3A 22 /DEFAULTLIB:"
00000010: 4C 49 42 43 4D 54 22 20 LIBCMT"
Linker Directives
-----------------
/DEFAULTLIB:LIBCMT
SECTION HEADER #2
.debug$S name
0 physical address
0 virtual address
A8 size of raw data
16C file pointer to raw data (0000016C to 00000213)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
42100040 flags
Initialized Data
Discardable
1 byte align
Read Only
RAW DATA #2
00000000: 04 00 00 00 F1 00 00 00 9B 00 00 00 5D 00 01 11 ....?......]...
00000010: 00 00 00 00 45 3A 5C 50 72 6F 6A 65 63 74 73 5C ....E:\Projects\
00000020: 67 69 74 68 75 62 5C 62 69 65 7A 68 69 68 75 61 github\biezhihua
00000030: 2E 67 69 74 68 75 62 2E 69 6F 5C 73 72 63 5C 6C .github.io\src\l
00000040: 65 61 72 6E 2D 72 65 76 65 72 73 65 2D 65 6E 67 earn-reverse-eng
00000050: 69 6E 65 65 72 69 6E 67 5C 53 69 6D 70 6C 65 53 ineering\SimpleS
00000060: 65 63 74 69 6F 6E 2E 6F 62 6A 00 3A 00 3C 11 00 ection.obj.:.<..
00000070: 62 00 00 D0 00 13 00 2B 00 F8 87 00 00 13 00 2B b..?..+.?....+
00000080: 00 F8 87 00 00 4D 69 63 72 6F 73 6F 66 74 20 28 .?..Microsoft (
00000090: 52 29 20 4F 70 74 69 6D 69 7A 69 6E 67 20 43 6F R) Optimizing Co
000000A0: 6D 70 69 6C 65 72 00 00 mpiler..
SECTION HEADER #3
.data name
0 physical address
0 virtual address
C size of raw data
214 file pointer to raw data (00000214 to 0000021F)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
C0300040 flags
Initialized Data
4 byte align
Read Write
RAW DATA #3
00000000: 54 00 00 00 25 64 0A 00 55 00 00 00 T...%d..U...
SECTION HEADER #4
.text$mn name
0 physical address
0 virtual address
64 size of raw data
220 file pointer to raw data (00000220 to 00000283)
284 file pointer to relocation table
0 file pointer to line numbers
5 number of relocations
0 number of line numbers
60500020 flags
Code
16 byte align
Execute Read
RAW DATA #4
00000000: 89 4C 24 08 48 83 EC 28 8B 54 24 30 48 8D 0D 00 .L$.H.?.T$0H...
00000010: 00 00 00 E8 00 00 00 00 90 48 83 C4 28 C3 CC CC ...?....H.?锰
00000020: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC 烫烫烫烫烫烫烫烫
00000030: 48 83 EC 38 C7 44 24 20 01 00 00 00 8B 05 00 00 蘃.?荄$ ........
00000040: 00 00 8B 0D 00 00 00 00 03 C8 8B C1 03 44 24 20 .........??D$
00000050: 03 44 24 24 8B C8 E8 00 00 00 00 8B 44 24 20 48 .D$$.辱.....D$ H
00000060: 83 C4 38 C3 .?
RELOCATIONS #4
Symbol Symbol
Offset Type Applied To Index Name
-------- ---------------- ----------------- -------- ------
0000000F REL32 00000000 1A $SG7537
00000014 REL32 00000000 D printf
0000003E REL32 00000000 1E ?static_var2@?1??main@@9@9 (`main'::`2'::static_var2)
00000044 REL32 00000000 1B ?static_var@?1??main@@9@9 (`main'::`2'::static_var)
00000057 REL32 00000000 E func1
SECTION HEADER #5
.xdata name
0 physical address
0 virtual address
10 size of raw data
2B6 file pointer to raw data (000002B6 to 000002C5)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
40300040 flags
Initialized Data
4 byte align
Read Only
RAW DATA #5
00000000: 01 08 01 00 08 42 00 00 01 04 01 00 04 62 00 00 ?....B.......b..
SECTION HEADER #6
.pdata name
0 physical address
0 virtual address
18 size of raw data
2C6 file pointer to raw data (000002C6 to 000002DD)
2DE file pointer to relocation table
0 file pointer to line numbers
6 number of relocations
0 number of line numbers
40300040 flags
Initialized Data
4 byte align
Read Only
RAW DATA #6
00000000: 00 00 00 00 1E 00 00 00 00 00 00 00 00 00 00 00 ................
00000010: 34 00 00 00 00 00 00 00 4.......
Function Table #6 (2)
Begin End Info
00000000 00000000 0000001E 00000000
0000000C 00000000 00000034 00000000
RELOCATIONS #6
Symbol Symbol
Offset Type Applied To Index Name
-------- ---------------- ----------------- -------- ------
00000000 ADDR32NB 00000000 10 $LN3
00000004 ADDR32NB 0000001E 10 $LN3
00000008 ADDR32NB 00000000 14 $unwind$func1
0000000C ADDR32NB 00000000 11 $LN3
00000010 ADDR32NB 00000034 11 $LN3
00000014 ADDR32NB 00000000 18 $unwind$main
SECTION HEADER #7
.bss name
0 physical address
0 virtual address
4 size of raw data
0 file pointer to raw data
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
C0300080 flags
Uninitialized Data
4 byte align
Read Write
SECTION HEADER #8
.chks64 name
0 physical address
0 virtual address
40 size of raw data
31A file pointer to raw data (0000031A to 00000359)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
A00 flags
Info
Remove
(no align specified)
RAW DATA #8
00000000: 27 88 7E BB A5 BD F8 78 B8 28 1D 11 8D 7F 88 4E '.~互进x?.....N
00000010: 54 E7 F0 7A A1 2E 3E 48 87 8C 1A 2F D9 BA 52 4D T琊z?>H.../俸RM
00000020: D7 CF 0B E0 EB F3 AE B8 26 BC 66 0F E2 F9 78 5D 紫.嚯螽?糵.怡x]
00000030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
COFF SYMBOL TABLE
000 010487F8 ABS notype Static | @comp.id
001 80010190 ABS notype Static | @feat.00
002 00000003 ABS notype Static | @vol.md
003 00000000 SECT1 notype Static | .drectve
Section length 18, #relocs 0, #linenums 0, checksum 0
005 00000000 SECT2 notype Static | .debug$S
Section length A8, #relocs 0, #linenums 0, checksum 0
007 00000000 SECT3 notype Static | .data
Section length C, #relocs 0, #linenums 0, checksum AC5AB941
009 00000000 SECT3 notype External | global_init_var
00A 00000004 UNDEF notype External | global_uninit_var
00B 00000000 SECT4 notype Static | .text$mn
Section length 64, #relocs 5, #linenums 0, checksum B955F775
00D 00000000 UNDEF notype () External | printf
00E 00000000 SECT4 notype () External | func1
00F 00000030 SECT4 notype () External | main
010 00000000 SECT4 notype Label | $LN3
011 00000030 SECT4 notype Label | $LN3
012 00000000 SECT5 notype Static | .xdata
Section length 10, #relocs 0, #linenums 0, checksum 434E1581
014 00000000 SECT5 notype Static | $unwind$func1
015 00000000 SECT6 notype Static | .pdata
Section length 18, #relocs 6, #linenums 0, checksum F8B9BDC5
017 00000000 SECT6 notype Static | $pdata$func1
018 00000008 SECT5 notype Static | $unwind$main
019 0000000C SECT6 notype Static | $pdata$main
01A 00000004 SECT3 notype Static | $SG7537
01B 00000008 SECT3 notype Static | ?static_var@?1??main@@9@9 (`main'::`2'::static_var)
01C 00000000 SECT7 notype Static | .bss
Section length 4, #relocs 0, #linenums 0, checksum 0
01E 00000000 SECT7 notype Static | ?static_var2@?1??main@@9@9 (`main'::`2'::static_var2)
01F 00000000 SECT8 notype Static | .chks64
Section length 40, #relocs 0, #linenums 0, checksum 0
String Table Size = 0x8F bytes
Summary
4 .bss
40 .chks64
C .data
A8 .debug$S
18 .drectve
18 .pdata
64 .text$mn
10 .xdata
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering>
3.3.1 代码段
https://learn.microsoft.com/zh-cn/cpp/build/reference/dumpbin-command-line?view=msvc-170
3.3.2 数据段和只读数据段
3.3.3 BSS段
3.4 ELF文件结构描述
ELF目标文件格式的最前部是ELF文件头(ELF Header),它包含了描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等。紧接着是ELF文件各个段。其中ELF文件中与段有关的重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。
3.4.1 文件头
ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。
3.4.2 段表
我们知道ELF文件中有很多各种各样的段,这个段表(Section Header Table)就是保存这些段的基本属性的结构。段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。
3.4.4 字符串表
3.5 链接的接口——符号
链接过程的本质就是要把多个不同的目标文件之间相互“粘”到一起,或者说像玩具积木一样,可以拼装形成一个整体。
在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。
每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。
链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。
我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种:
- 定义在本目标文件的全局符号,可以被其他目标文件引用。
- 在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号(External Symbol),也就是我们前面所讲的符号引用。
- 段名,这种符号往往由编译器产生,它的值就是该段的起始地址。
- 局部符号,这类符号只在编译单元内部可见。
- 行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的。
3.5.1 ELF符号表结构
typedef struct {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;
nm simplesection.o
0000000000000000 T func1
0000000000000000 D global_init_var
0000000000000000 B global_uninit_var
000000000000002b T main
U printlf
0000000000000004 d static_var.1
0000000000000004 b static_var2.0
readelf -s ./simplesection.o
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS simplesection.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3 .data
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .bss
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 static_var2.0
8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
9: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 global_uninit_var
10: 0000000000000000 43 FUNC GLOBAL DEFAULT 1 func1
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printlf
12: 000000000000002b 55 FUNC GLOBAL DEFAULT 1 main
3.5.2 特殊符号
3.5.3 符号修饰与函数签名
C++符号修饰,人们发明了符号修饰(Name Decoration)或符号改编(Name Mangling)的机制。
术语叫做函数签名(Function Signature),函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。
在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)。编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名,也就是说,C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称。C++编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。
3.5.4 extern “C”
3.5.5 弱符号与强符号
这种符号的定义可以被称为强符号(Strong Symbol)。有些符号的定义可以被称为弱符号(Weak Symbol)。对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。
针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:
规则1:不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。
规则2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。
3.6 调试信息
3.7 本章小结
第4章 静态链接
介绍静态链接与静态库链接的过程和步骤。
ld a.o b.o -e main -o ab
编译源码到目标文件时,一定要加“-fno-stack-protector”,不然默认会调函数“__stack_chk_fail”进行栈相关检查,然而是手动裸ld去链接,没有链接到“__stack_chk_fail”所在库文件,所以在链接过程一定会报错: undefined reference to `__stack_chk_fail'。解决办法不是在链接过程中,而是在编译时加此参数,强制gcc不进行栈检查,从而解决。此外,ld 的时候加上参数"-e main"就可以了,意思是将main函数作为程序入口,ld 的默认程序入口为_start。即:gcc -c a.c -fno-stack-protector -o a.old a.o b.o -e main -o ab
- 关于undefined reference to `__stack_chk_fail'踩坑 : https://www.cnblogs.com/chenglixue/p/16848546.html
objdump -h a.o
a.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000030 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000070 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000070 2**0
ALLOC
3 .comment 0000002c 0000000000000000 0000000000000000 00000070 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 0000009c 2**0
CONTENTS, READONLY
5 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000a0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .eh_frame 00000038 0000000000000000 0000000000000000 000000c0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
objdump -h b.o
b.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000004f 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 00000090 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000094 2**0
ALLOC
3 .comment 0000002c 0000000000000000 0000000000000000 00000094 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000c0 2**0
CONTENTS, READONLY
5 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000c0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .eh_frame 00000038 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
objdump -h ab
ab: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .note.gnu.property 00000020 00000000004001c8 00000000004001c8 000001c8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .text 0000007f 0000000000401000 0000000000401000 00001000 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
2 .eh_frame 00000058 0000000000402000 0000000000402000 00002000 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .data 00000004 0000000000404000 0000000000404000 00003000 2**2
CONTENTS, ALLOC, LOAD, DATA
4 .comment 0000002b 0000000000000000 0000000000000000 00003004 2**0
CONTENTS, READONLY
4.1 空间与地址分配
4.1.1 按序叠加
4.1.2 相似段合并
链接器为目标文件分配地址和空间”这句话中的“地址和空间”其实有两个含义:
- 第一个是在输出的可执行文件中的空间; 第二个是在装载后的虚拟地址中的虚拟地址空间。
4.1.3 符号地址的确定
4.2 符号解析与重定位
extern int shared;
int main() {
int a = 100;
swap(&a, &shared);
return 0;
}
int shared = 1;
void swap(int* a, int* b) {
*a ^= *b ^= *a ^= *b;
}
objdump -d a.o
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 10 sub $0x10,%rsp
c: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
13: 48 8d 45 fc lea -0x4(%rbp),%rax
17: 48 8d 15 00 00 00 00 lea 0x0(%rip),%rdx # 1e <main+0x1e>
1e: 48 89 d6 mov %rdx,%rsi
21: 48 89 c7 mov %rax,%rdi
24: b8 00 00 00 00 mov $0x0,%eax
29: e8 00 00 00 00 call 2e <main+0x2e>
2e: b8 00 00 00 00 mov $0x0,%eax
33: c9 leave
34: c3 ret
0000000000000000 <main>:
c: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp) ; 局部变量 int a = 100(0x64)
13: 48 8d 45 fc lea -0x4(%rbp),%rax ; 将a的地址存入rax
17: 48 8d 15 00 00 00 00 lea 0x0(%rip),%rdx ; 加载shared地址(未解析,占位0)
24: e8 00 00 00 00 call 2e <main+0x2e> ; 调用swap(未解析,占位0)
➜ Documents objdump -d b.o
b.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <swap>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 89 7d f8 mov %rdi,-0x8(%rbp)
c: 48 89 75 f0 mov %rsi,-0x10(%rbp)
10: 48 8b 45 f8 mov -0x8(%rbp),%rax
14: 8b 10 mov (%rax),%edx
16: 48 8b 45 f0 mov -0x10(%rbp),%rax
1a: 8b 00 mov (%rax),%eax
1c: 31 c2 xor %eax,%edx
1e: 48 8b 45 f8 mov -0x8(%rbp),%rax
22: 89 10 mov %edx,(%rax)
24: 48 8b 45 f8 mov -0x8(%rbp),%rax
28: 8b 10 mov (%rax),%edx
2a: 48 8b 45 f0 mov -0x10(%rbp),%rax
2e: 8b 00 mov (%rax),%eax
30: 31 c2 xor %eax,%edx
32: 48 8b 45 f0 mov -0x10(%rbp),%rax
36: 89 10 mov %edx,(%rax)
38: 48 8b 45 f0 mov -0x10(%rbp),%rax
3c: 8b 10 mov (%rax),%edx
3e: 48 8b 45 f8 mov -0x8(%rbp),%rax
42: 8b 00 mov (%rax),%eax
44: 31 c2 xor %eax,%edx
46: 48 8b 45 f8 mov -0x8(%rbp),%rax
4a: 89 10 mov %edx,(%rax)
4c: 90 nop
4d: 5d pop %rbp
4e: c3 ret
0000000000000000 <swap>:
10: 48 8b 45 f8 mov -0x8(%rbp),%rax ; 取第一个参数指针(int *x)
14: 8b 10 mov (%rax),%edx ; 读取x的值到edx
16: 48 8b 45 f0 mov -0x10(%rbp),%rax ; 取第二个参数指针(int *y)
1a: 8b 00 mov (%rax),%eax ; 读取y的值到eax
1c: 31 c2 xor %eax,%edx ; edx = x ^ y
22: 89 10 mov %edx,(%rax) ; *x = x ^ y(异或交换法)
➜ Documents objdump -d ab
ab: file format elf64-x86-64
Disassembly of section .text:
0000000000401000 <main>:
401000: f3 0f 1e fa endbr64
401004: 55 push %rbp
401005: 48 89 e5 mov %rsp,%rbp
401008: 48 83 ec 10 sub $0x10,%rsp
40100c: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
401013: 48 8d 45 fc lea -0x4(%rbp),%rax
401017: 48 8d 15 e2 2f 00 00 lea 0x2fe2(%rip),%rdx # 404000 <shared>
40101e: 48 89 d6 mov %rdx,%rsi
401021: 48 89 c7 mov %rax,%rdi
401024: b8 00 00 00 00 mov $0x0,%eax
401029: e8 07 00 00 00 call 401035 <swap>
40102e: b8 00 00 00 00 mov $0x0,%eax
401033: c9 leave
401034: c3 ret
0000000000401035 <swap>:
401035: f3 0f 1e fa endbr64
401039: 55 push %rbp
40103a: 48 89 e5 mov %rsp,%rbp
40103d: 48 89 7d f8 mov %rdi,-0x8(%rbp)
401041: 48 89 75 f0 mov %rsi,-0x10(%rbp)
401045: 48 8b 45 f8 mov -0x8(%rbp),%rax
401049: 8b 10 mov (%rax),%edx
40104b: 48 8b 45 f0 mov -0x10(%rbp),%rax
40104f: 8b 00 mov (%rax),%eax
401051: 31 c2 xor %eax,%edx
401053: 48 8b 45 f8 mov -0x8(%rbp),%rax
401057: 89 10 mov %edx,(%rax)
401059: 48 8b 45 f8 mov -0x8(%rbp),%rax
40105d: 8b 10 mov (%rax),%edx
40105f: 48 8b 45 f0 mov -0x10(%rbp),%rax
401063: 8b 00 mov (%rax),%eax
401065: 31 c2 xor %eax,%edx
401067: 48 8b 45 f0 mov -0x10(%rbp),%rax
40106b: 89 10 mov %edx,(%rax)
40106d: 48 8b 45 f0 mov -0x10(%rbp),%rax
401071: 8b 10 mov (%rax),%edx
401073: 48 8b 45 f8 mov -0x8(%rbp),%rax
401077: 8b 00 mov (%rax),%eax
401079: 31 c2 xor %eax,%edx
40107b: 48 8b 45 f8 mov -0x8(%rbp),%rax
40107f: 89 10 mov %edx,(%rax)
401081: 90 nop
401082: 5d pop %rbp
401083: c3 ret
0000000000401000 <main>:
401017: 48 8d 15 e2 2f 00 00 lea 0x2fe2(%rip),%rdx ; shared地址 = rip+0x2fe2 → 0x404000
401029: e8 07 00 00 00 call 401035 <swap> ; 调用swap(地址已解析)
➜ Documents objdump -r a.o
a.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
000000000000001a R_X86_64_PC32 shared-0x0000000000000004
000000000000002a R_X86_64_PLT32 swap-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
➜ Documents objdump -r b.o
b.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
➜ Documents
➜ Documents readelf -s a.o
Symbol table '.symtab' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS a.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 53 FUNC GLOBAL DEFAULT 1 main
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND shared
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
4.2.1 重定位
4.2.2 重定位表
4.2.3 符号解析
4.2.4 指令修正方式
4.3 COMMON块
4.4 C++相关问题
4.4.1 重复代码消除
4.4.2 全局构造与析构
4.4.3 C++与ABI
把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)。
4.5 静态库链接
一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。
4.6 链接过程控制
4.6.1 链接控制脚本
一般链接器有如下三种方法。
使用命令行来给链接器指定参数,我们前面所使用的ld的-o、-e参数就属于这类。这种方法我们已经在前面使用很多次了。
将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令。
使用链接控制脚本,使用链接控制脚本方法就是本节要介绍的,也是最为灵活、最为强大的链接控制方法。
➜ Documents ld -verbose
GNU ld (GNU Binutils for Ubuntu) 2.38
Supported emulations:
elf_x86_64
elf32_x86_64
elf_i386
elf_iamcu
elf_l1om
elf_k1om
i386pep
i386pe
using internal linker script:
==================================================
/* Script for -z combreloc -z separate-code */
/* Copyright (C) 2014-2022 Free Software Foundation, Inc.
Copying and distribution of this script, with or without modification,
are permitted in any medium without royalty provided the copyright
notice and this notice are preserved. */
OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64",
"elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)
SEARCH_DIR("=/usr/local/lib/x86_64-linux-gnu"); SEARCH_DIR("=/lib/x86_64-linux-gnu"); SEARCH_DIR("=/usr/lib/x86_64-linux-gnu"); SEARCH_DIR("=/usr/lib/x86_64-linux-gnu64"); SEARCH_DIR("=/usr/local/lib64"); SEARCH_DIR("=/lib64"); SEARCH_DIR("=/usr/lib64"); SEARCH_DIR("=/usr/local/lib"); SEARCH_DIR("=/lib"); SEARCH_DIR("=/usr/lib"); SEARCH_DIR("=/usr/x86_64-linux-gnu/lib64"); SEARCH_DIR("=/usr/x86_64-linux-gnu/lib");
SECTIONS
{
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
.interp : { *(.interp) }
.note.gnu.build-id : { *(.note.gnu.build-id) }
.hash : { *(.hash) }
.gnu.hash : { *(.gnu.hash) }
.dynsym : { *(.dynsym) }
.dynstr : { *(.dynstr) }
.gnu.version : { *(.gnu.version) }
.gnu.version_d : { *(.gnu.version_d) }
.gnu.version_r : { *(.gnu.version_r) }
.rela.dyn :
{
*(.rela.init)
*(.rela.text .rela.text.* .rela.gnu.linkonce.t.*)
*(.rela.fini)
*(.rela.rodata .rela.rodata.* .rela.gnu.linkonce.r.*)
*(.rela.data .rela.data.* .rela.gnu.linkonce.d.*)
*(.rela.tdata .rela.tdata.* .rela.gnu.linkonce.td.*)
*(.rela.tbss .rela.tbss.* .rela.gnu.linkonce.tb.*)
*(.rela.ctors)
*(.rela.dtors)
*(.rela.got)
*(.rela.bss .rela.bss.* .rela.gnu.linkonce.b.*)
*(.rela.ldata .rela.ldata.* .rela.gnu.linkonce.l.*)
*(.rela.lbss .rela.lbss.* .rela.gnu.linkonce.lb.*)
*(.rela.lrodata .rela.lrodata.* .rela.gnu.linkonce.lr.*)
*(.rela.ifunc)
}
.rela.plt :
{
*(.rela.plt)
PROVIDE_HIDDEN (__rela_iplt_start = .);
*(.rela.iplt)
PROVIDE_HIDDEN (__rela_iplt_end = .);
}
.relr.dyn : { *(.relr.dyn) }
. = ALIGN(CONSTANT (MAXPAGESIZE));
.init :
{
KEEP (*(SORT_NONE(.init)))
}
.plt : { *(.plt) *(.iplt) }
.plt.got : { *(.plt.got) }
.plt.sec : { *(.plt.sec) }
.text :
{
*(.text.unlikely .text.*_unlikely .text.unlikely.*)
*(.text.exit .text.exit.*)
*(.text.startup .text.startup.*)
*(.text.hot .text.hot.*)
*(SORT(.text.sorted.*))
*(.text .stub .text.* .gnu.linkonce.t.*)
/* .gnu.warning sections are handled specially by elf.em. */
*(.gnu.warning)
}
.fini :
{
KEEP (*(SORT_NONE(.fini)))
}
PROVIDE (__etext = .);
PROVIDE (_etext = .);
PROVIDE (etext = .);
. = ALIGN(CONSTANT (MAXPAGESIZE));
/* Adjust the address for the rodata segment. We want to adjust up to
the same address within the page on the next page up. */
. = SEGMENT_START("rodata-segment", ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)));
.rodata : { *(.rodata .rodata.* .gnu.linkonce.r.*) }
.rodata1 : { *(.rodata1) }
.eh_frame_hdr : { *(.eh_frame_hdr) *(.eh_frame_entry .eh_frame_entry.*) }
.eh_frame : ONLY_IF_RO { KEEP (*(.eh_frame)) *(.eh_frame.*) }
.gcc_except_table : ONLY_IF_RO { *(.gcc_except_table .gcc_except_table.*) }
.gnu_extab : ONLY_IF_RO { *(.gnu_extab*) }
/* These sections are generated by the Sun/Oracle C++ compiler. */
.exception_ranges : ONLY_IF_RO { *(.exception_ranges*) }
/* Adjust the address for the data segment. We want to adjust up to
the same address within the page on the next page up. */
. = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE));
/* Exception handling */
.eh_frame : ONLY_IF_RW { KEEP (*(.eh_frame)) *(.eh_frame.*) }
.gnu_extab : ONLY_IF_RW { *(.gnu_extab) }
.gcc_except_table : ONLY_IF_RW { *(.gcc_except_table .gcc_except_table.*) }
.exception_ranges : ONLY_IF_RW { *(.exception_ranges*) }
/* Thread Local Storage sections */
.tdata :
{
PROVIDE_HIDDEN (__tdata_start = .);
*(.tdata .tdata.* .gnu.linkonce.td.*)
}
.tbss : { *(.tbss .tbss.* .gnu.linkonce.tb.*) *(.tcommon) }
.preinit_array :
{
PROVIDE_HIDDEN (__preinit_array_start = .);
KEEP (*(.preinit_array))
PROVIDE_HIDDEN (__preinit_array_end = .);
}
.init_array :
{
PROVIDE_HIDDEN (__init_array_start = .);
KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*)))
KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors))
PROVIDE_HIDDEN (__init_array_end = .);
}
.fini_array :
{
PROVIDE_HIDDEN (__fini_array_start = .);
KEEP (*(SORT_BY_INIT_PRIORITY(.fini_array.*) SORT_BY_INIT_PRIORITY(.dtors.*)))
KEEP (*(.fini_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .dtors))
PROVIDE_HIDDEN (__fini_array_end = .);
}
.ctors :
{
/* gcc uses crtbegin.o to find the start of
the constructors, so we make sure it is
first. Because this is a wildcard, it
doesn't matter if the user does not
actually link against crtbegin.o; the
linker won't look for a file to match a
wildcard. The wildcard also means that it
doesn't matter which directory crtbegin.o
is in. */
KEEP (*crtbegin.o(.ctors))
KEEP (*crtbegin?.o(.ctors))
/* We don't want to include the .ctor section from
the crtend.o file until after the sorted ctors.
The .ctor section from the crtend file contains the
end of ctors marker and it must be last */
KEEP (*(EXCLUDE_FILE (*crtend.o *crtend?.o ) .ctors))
KEEP (*(SORT(.ctors.*)))
KEEP (*(.ctors))
}
.dtors :
{
KEEP (*crtbegin.o(.dtors))
KEEP (*crtbegin?.o(.dtors))
KEEP (*(EXCLUDE_FILE (*crtend.o *crtend?.o ) .dtors))
KEEP (*(SORT(.dtors.*)))
KEEP (*(.dtors))
}
.jcr : { KEEP (*(.jcr)) }
.data.rel.ro : { *(.data.rel.ro.local* .gnu.linkonce.d.rel.ro.local.*) *(.data.rel.ro .data.rel.ro.* .gnu.linkonce.d.rel.ro.*) }
.dynamic : { *(.dynamic) }
.got : { *(.got) *(.igot) }
. = DATA_SEGMENT_RELRO_END (SIZEOF (.got.plt) >= 24 ? 24 : 0, .);
.got.plt : { *(.got.plt) *(.igot.plt) }
.data :
{
*(.data .data.* .gnu.linkonce.d.*)
SORT(CONSTRUCTORS)
}
.data1 : { *(.data1) }
_edata = .; PROVIDE (edata = .);
. = .;
__bss_start = .;
.bss :
{
*(.dynbss)
*(.bss .bss.* .gnu.linkonce.b.*)
*(COMMON)
/* Align here to ensure that the .bss section occupies space up to
_end. Align after .bss to ensure correct alignment even if the
.bss section disappears because there are no input sections.
FIXME: Why do we need it? When there is no .bss section, we do not
pad the .data section. */
. = ALIGN(. != 0 ? 64 / 8 : 1);
}
.lbss :
{
*(.dynlbss)
*(.lbss .lbss.* .gnu.linkonce.lb.*)
*(LARGE_COMMON)
}
. = ALIGN(64 / 8);
. = SEGMENT_START("ldata-segment", .);
.lrodata ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)) :
{
*(.lrodata .lrodata.* .gnu.linkonce.lr.*)
}
.ldata ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)) :
{
*(.ldata .ldata.* .gnu.linkonce.l.*)
. = ALIGN(. != 0 ? 64 / 8 : 1);
}
. = ALIGN(64 / 8);
_end = .; PROVIDE (end = .);
. = DATA_SEGMENT_END (.);
/* Stabs debugging sections. */
.stab 0 : { *(.stab) }
.stabstr 0 : { *(.stabstr) }
.stab.excl 0 : { *(.stab.excl) }
.stab.exclstr 0 : { *(.stab.exclstr) }
.stab.index 0 : { *(.stab.index) }
.stab.indexstr 0 : { *(.stab.indexstr) }
.comment 0 : { *(.comment) }
.gnu.build.attributes : { *(.gnu.build.attributes .gnu.build.attributes.*) }
/* DWARF debug sections.
Symbols in the DWARF debugging sections are relative to the beginning
of the section so we begin them at 0. */
/* DWARF 1. */
.debug 0 : { *(.debug) }
.line 0 : { *(.line) }
/* GNU DWARF 1 extensions. */
.debug_srcinfo 0 : { *(.debug_srcinfo) }
.debug_sfnames 0 : { *(.debug_sfnames) }
/* DWARF 1.1 and DWARF 2. */
.debug_aranges 0 : { *(.debug_aranges) }
.debug_pubnames 0 : { *(.debug_pubnames) }
/* DWARF 2. */
.debug_info 0 : { *(.debug_info .gnu.linkonce.wi.*) }
.debug_abbrev 0 : { *(.debug_abbrev) }
.debug_line 0 : { *(.debug_line .debug_line.* .debug_line_end) }
.debug_frame 0 : { *(.debug_frame) }
.debug_str 0 : { *(.debug_str) }
.debug_loc 0 : { *(.debug_loc) }
.debug_macinfo 0 : { *(.debug_macinfo) }
/* SGI/MIPS DWARF 2 extensions. */
.debug_weaknames 0 : { *(.debug_weaknames) }
.debug_funcnames 0 : { *(.debug_funcnames) }
.debug_typenames 0 : { *(.debug_typenames) }
.debug_varnames 0 : { *(.debug_varnames) }
/* DWARF 3. */
.debug_pubtypes 0 : { *(.debug_pubtypes) }
.debug_ranges 0 : { *(.debug_ranges) }
/* DWARF 5. */
.debug_addr 0 : { *(.debug_addr) }
.debug_line_str 0 : { *(.debug_line_str) }
.debug_loclists 0 : { *(.debug_loclists) }
.debug_macro 0 : { *(.debug_macro) }
.debug_names 0 : { *(.debug_names) }
.debug_rnglists 0 : { *(.debug_rnglists) }
.debug_str_offsets 0 : { *(.debug_str_offsets) }
.debug_sup 0 : { *(.debug_sup) }
.gnu.attributes 0 : { KEEP (*(.gnu.attributes)) }
/DISCARD/ : { *(.note.GNU-stack) *(.gnu_debuglink) *(.gnu.lto_*) }
}
==================================================
4.6.2 最“小”的程序
https://github.com/chunhuajiang/arm-gcc-inline-assembler
4.6.3 使用ld链接脚本
4.6.4 ld链接脚本语法简介
链接脚本由一系列语句组成,语句分两种,一种是命令语句,另外一种是赋值语句。
4.7 BFD库
4.8 本章小结
第5章 Windows PE/COFF
介绍Windows平台的目标文件和可执行文件格式。
- https://darkcybe.github.io/posts/Windows_PE_File_Format/
- https://www.openrce.org/reference_library/files/reference/PE%20Format.pdf
- https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
5.1 Windows的二进制文件格式PE/COFF
一个段可以包含代码、数据或其他信息,在PE/COFF文件中,至少包含一个代码段,这个代码段的名字往往叫做“.code”,数据段叫做“.data”。
5.2 PE的前身——COFF
在这个过程中,我们将用到“Microsoft Visual C++”的编译环境。包括编译器“cl”,链接器“link”,可执行文件查看器“dumpbin”等。
cl /c /Za SimpleSection.c
“cl”是VISUAL C++的编译器,即“Compiler”的缩写。/c参数表示只编译,不链接,即将.c文件编译成.obj文件,而不调用链接器生成.exe文件。如果不加这个参数,cl会在编译“SimpleSection.c”文件以后,再调用link链接器将该产生的SimpleSection.obj文件与默认的C运行库链接,产生可执行文件SimpleSection.exe。
/Za”参数禁用这些扩展,使得我们的程序跟标准的C/C++兼容,这样可以尽量地看到问题的本质。
dumpbin /ALL SimpleSection.obj > SimpleSection.txt
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering> dumpbin .\SimpleSection.obj /SUMMARY
Microsoft (R) COFF/PE Dumper Version 14.43.34808.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\SimpleSection.obj
File Type: COFF OBJECT
Summary
4 .bss
40 .chks64
C .data
A8 .debug$S
18 .drectve
18 .pdata
64 .text$mn
10 .xdata
COFF文件结构
几乎跟ELF文件一样,COFF也是由文件头及后面的若干个段组成,再加上文件末尾的符号表、调试信息的内容,就构成了COFF文件的基本结构,我们在COFF文件中几乎都可以找到与ELF文件结构相对应的地方。
COFF文件的文件头部包括了两部分,一个是描述文件总体结构和属性的映像头(Image Header),另外一个是描述该文件中包含的段属性的段表(Section Table)。文件头后面紧跟着的就是文件的段,包括代码段、数据段等,最后还有符号表等。
映像(Image):因为PE文件在装载时被直接映射到进程的虚拟空间中运行,它是进程的虚拟空间的映像。所以PE可执行文件很多时候被叫做映像文件(Image File)。
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
映像头后面紧跟着的就是COFF文件的段表,它是一个类型为“IMAGE_SECTION_ HEADER”结构的数组,数组里面每个元素代表一个段,这个结构跟ELF文件中的“Elf32_Shdr”很相似。很明显,这个数组元素的个数刚好是该COFF文件所包含的段的数量,也就是映像头里面的“NumberOfSections”。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[8]; // 段名(Section Name)
union {
DWORD PhysicalAddress; // 物理地址(Physical address)
DWORD VirtualSize; // 该段被加载至内存后的大小
} Misc;
DWORD VirtualAddress; // 虚拟地址(Virtual address) 该段被加载至内存后的虚拟地址
DWORD SizeOfRawData; // 原始数据大小(Size of raw data) 该段在文件中的大小。
DWORD PointerToRawData; // 段在文件中的位置(File pointer to raw data)
DWORD PointerToRelocations; // 该段的重定位表在文件中的位置(File pointer to relocation table)
DWORD PointerToLinenumbers; // 该段的行号表在文件中的位置(File pointer to line numbers)
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; // 标志位(Characteristics)
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
D:\Windows Kits\10\Include\10.0.26100.0\um\winnt.h
//
// Flag (bit) fields
//
typedef BYTE FCHAR;
typedef WORD FSHORT;
typedef DWORD FLONG;
//
// Image Format
//
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
//
// File header format.
//
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
// Directory Entries
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor
//
// Directory format.
//
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
//
// Optional header format.
//
typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
//
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
//
// NT additional fields.
//
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
//
// Section header format.
//
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize; // 该段被加载至内存后的大小
} Misc;
DWORD VirtualAddress; // 该段被加载至内存后的虚拟地址
DWORD SizeOfRawData; // 该段在文件中的大小
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; // 段属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
Microsoft (R) COFF/PE Dumper Version 14.42.34436.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\SimpleSection.obj
File Type: COFF OBJECT
FILE HEADER VALUES
8664 machine (x64)
8 number of sections
67B6EE8C time date stamp Thu Feb 20 16:57:48 2025
322 file pointer to symbol table
21 number of symbols
0 size of optional header
0 characteristics
SECTION HEADER #1
.drectve name
0 physical address
0 virtual address
18 size of raw data
154 file pointer to raw data (00000154 to 0000016B)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
100A00 flags
Info
Remove
1 byte align
RAW DATA #1
00000000: 20 20 20 2F 44 45 46 41 55 4C 54 4C 49 42 3A 22 /DEFAULTLIB:"
00000010: 4C 49 42 43 4D 54 22 20 LIBCMT"
Linker Directives
-----------------
/DEFAULTLIB:LIBCMT
SECTION HEADER #2
.debug$S name
0 physical address
0 virtual address
70 size of raw data
16C file pointer to raw data (0000016C to 000001DB)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
42100040 flags
Initialized Data
Discardable
1 byte align
Read Only
RAW DATA #2
00000000: 04 00 00 00 F1 00 00 00 62 00 00 00 24 00 01 11 ....?..b...$...
00000010: 00 00 00 00 45 3A 5C 50 72 6F 6A 65 63 74 73 5C ....E:\Projects\
00000020: 53 69 6D 70 6C 65 53 65 63 74 69 6F 6E 2E 6F 62 SimpleSection.ob
00000030: 6A 00 3A 00 3C 11 00 62 00 00 D0 00 13 00 2A 00 j.:.<..b..?..*.
00000040: 84 86 00 00 13 00 2A 00 84 86 00 00 4D 69 63 72 ......*.....Micr
00000050: 6F 73 6F 66 74 20 28 52 29 20 4F 70 74 69 6D 69 osoft (R) Optimi
00000060: 7A 69 6E 67 20 43 6F 6D 70 69 6C 65 72 00 00 00 zing Compiler...
SECTION HEADER #3
.data name
0 physical address
0 virtual address
C size of raw data
1DC file pointer to raw data (000001DC to 000001E7)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
C0300040 flags
Initialized Data
4 byte align
Read Write
RAW DATA #3
00000000: 54 00 00 00 25 64 0A 00 55 00 00 00 T...%d..U...
SECTION HEADER #4
.text$mn name
0 physical address
0 virtual address
64 size of raw data
1E8 file pointer to raw data (000001E8 to 0000024B)
24C file pointer to relocation table
0 file pointer to line numbers
5 number of relocations
0 number of line numbers
60500020 flags
Code
16 byte align
Execute Read
RAW DATA #4
00000000: 89 4C 24 08 48 83 EC 28 8B 54 24 30 48 8D 0D 00 .L$.H.?.T$0H...
00000010: 00 00 00 E8 00 00 00 00 90 48 83 C4 28 C3 CC CC ...?....H.?锰?
00000020: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC 烫烫烫烫烫烫烫烫
00000030: 48 83 EC 38 C7 44 24 20 01 00 00 00 8B 05 00 00 H.?荄$ ........
00000040: 00 00 8B 0D 00 00 00 00 03 C8 8B C1 03 44 24 20 .........??D$
00000050: 03 44 24 24 8B C8 E8 00 00 00 00 8B 44 24 20 48 .D$$.辱.....D$ H
00000060: 83 C4 38 C3 .??
RELOCATIONS #4
Symbol Symbol
Offset Type Applied To Index Name
-------- ---------------- ----------------- -------- ------
0000000F REL32 00000000 1A $SG7562
00000014 REL32 00000000 D printf
0000003E REL32 00000000 1E ?static_var2@?1??main@@9@9 (`main'::`2'::static_var2)
00000044 REL32 00000000 1B ?static_var@?1??main@@9@9 (`main'::`2'::static_var)
00000057 REL32 00000000 E func1
SECTION HEADER #5
.xdata name
0 physical address
0 virtual address
10 size of raw data
27E file pointer to raw data (0000027E to 0000028D)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
40300040 flags
Initialized Data
4 byte align
Read Only
RAW DATA #5
00000000: 01 08 01 00 08 42 00 00 01 04 01 00 04 62 00 00 .....B.......b..
SECTION HEADER #6
.pdata name
0 physical address
0 virtual address
18 size of raw data
28E file pointer to raw data (0000028E to 000002A5)
2A6 file pointer to relocation table
0 file pointer to line numbers
6 number of relocations
0 number of line numbers
40300040 flags
Initialized Data
4 byte align
Read Only
RAW DATA #6
00000000: 00 00 00 00 1E 00 00 00 00 00 00 00 00 00 00 00 ................
00000010: 34 00 00 00 00 00 00 00 4.......
Function Table #6 (2)
Begin End Info
00000000 00000000 0000001E 00000000
0000000C 00000000 00000034 00000000
RELOCATIONS #6
Symbol Symbol
Offset Type Applied To Index Name
-------- ---------------- ----------------- -------- ------
00000000 ADDR32NB 00000000 10 $LN3
00000004 ADDR32NB 0000001E 10 $LN3
00000008 ADDR32NB 00000000 14 $unwind$func1
0000000C ADDR32NB 00000000 11 $LN3
00000010 ADDR32NB 00000034 11 $LN3
00000014 ADDR32NB 00000000 18 $unwind$main
SECTION HEADER #7
.bss name
0 physical address
0 virtual address
4 size of raw data
0 file pointer to raw data
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
C0300080 flags
Uninitialized Data
4 byte align
Read Write
SECTION HEADER #8
.chks64 name
0 physical address
0 virtual address
40 size of raw data
2E2 file pointer to raw data (000002E2 to 00000321)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
A00 flags
Info
Remove
(no align specified)
RAW DATA #8
00000000: 27 88 7E BB A5 BD F8 78 1B 7B AB 80 02 43 07 03 '.~互进x.{?.C..
00000010: 54 E7 F0 7A A1 2E 3E 48 87 8C 1A 2F D9 BA 52 4D T琊z?>H.../俸RM
00000020: D7 CF 0B E0 EB F3 AE B8 26 BC 66 0F E2 F9 78 5D 紫.嚯螽?糵.怡x]
00000030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
COFF SYMBOL TABLE
000 01048684 ABS notype Static | @comp.id
001 80010190 ABS notype Static | @feat.00
002 00000003 ABS notype Static | @vol.md
003 00000000 SECT1 notype Static | .drectve
Section length 18, #relocs 0, #linenums 0, checksum 0
005 00000000 SECT2 notype Static | .debug$S
Section length 70, #relocs 0, #linenums 0, checksum 0
007 00000000 SECT3 notype Static | .data
Section length C, #relocs 0, #linenums 0, checksum AC5AB941
009 00000000 SECT3 notype External | global_init_var
00A 00000004 UNDEF notype External | global_uninit_var
00B 00000000 SECT4 notype Static | .text$mn
Section length 64, #relocs 5, #linenums 0, checksum B955F775
00D 00000000 UNDEF notype () External | printf
00E 00000000 SECT4 notype () External | func1
00F 00000030 SECT4 notype () External | main
010 00000000 SECT4 notype Label | $LN3
011 00000030 SECT4 notype Label | $LN3
012 00000000 SECT5 notype Static | .xdata
Section length 10, #relocs 0, #linenums 0, checksum 434E1581
014 00000000 SECT5 notype Static | $unwind$func1
015 00000000 SECT6 notype Static | .pdata
Section length 18, #relocs 6, #linenums 0, checksum F8B9BDC5
017 00000000 SECT6 notype Static | $pdata$func1
018 00000008 SECT5 notype Static | $unwind$main
019 0000000C SECT6 notype Static | $pdata$main
01A 00000004 SECT3 notype Static | $SG7562
01B 00000008 SECT3 notype Static | ?static_var@?1??main@@9@9 (`main'::`2'::static_var)
01C 00000000 SECT7 notype Static | .bss
Section length 4, #relocs 0, #linenums 0, checksum 0
01E 00000000 SECT7 notype Static | ?static_var2@?1??main@@9@9 (`main'::`2'::static_var2)
01F 00000000 SECT8 notype Static | .chks64
Section length 40, #relocs 0, #linenums 0, checksum 0
String Table Size = 0x8F bytes
Summary
4 .bss
40 .chks64
C .data
70 .debug$S
18 .drectve
18 .pdata
64 .text$mn
10 .xdata
5.3 链接指示信息
SECTION HEADER #1
.drectve name
0 physical address
0 virtual address
18 size of raw data
154 file pointer to raw data (00000154 to 0000016B)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
100A00 flags
Info // 该段时注释或者其他信息
Remove // 最终链接成映像文件时抛弃该段
1 byte align // 1字节对齐
RAW DATA #1
00000000: 20 20 20 2F 44 45 46 41 55 4C 54 4C 49 42 3A 22 /DEFAULTLIB:"
00000010: 4C 49 42 43 4D 54 22 20 LIBCMT"
Linker Directives
-----------------
/DEFAULTLIB:LIBCMT
.drectve段”实际上是“Directive”的缩写,它的内容是编译器传递给链接器的指令(Directive),即编译器希望告诉链接器应该怎样链接这个目标文件。
“dumpbin”知道该段是个“.drectve”段,并且对段的内容进行了解析,解析结果为一个“/DEFAULTLIB:‘LIBCMT’”的链接指令(Linker Directives),实际上它就是“cl”编译器希望传给“link”链接器的参数。这个参数表示编译器希望告诉链接器,该目标文件须要LIBCMT这个默认库。
5.4 调试信息
COFF文件中所有以“.debug”开始的段都包含着调试信息。 “.debug$S”表示包含的是符号(Symbol)相关的调试信息段; “.debug$P”表示包含预编译头文件(Precompiled Header Files)相关的调试信息段; “.debug$T”表示包含类型(Type)相关的调试信息段。
SECTION HEADER #2
.debug$S name
0 physical address
0 virtual address
70 size of raw data
16C file pointer to raw data (0000016C to 000001DB)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
42100040 flags
Initialized Data
Discardable
1 byte align
Read Only
RAW DATA #2
00000000: 04 00 00 00 F1 00 00 00 62 00 00 00 24 00 01 11 ....?..b...$...
00000010: 00 00 00 00 45 3A 5C 50 72 6F 6A 65 63 74 73 5C ....E:\Projects\
00000020: 53 69 6D 70 6C 65 53 65 63 74 69 6F 6E 2E 6F 62 SimpleSection.ob
00000030: 6A 00 3A 00 3C 11 00 62 00 00 D0 00 13 00 2A 00 j.:.<..b..?..*.
00000040: 84 86 00 00 13 00 2A 00 84 86 00 00 4D 69 63 72 ......*.....Micr
00000050: 6F 73 6F 66 74 20 28 52 29 20 4F 70 74 69 6D 69 osoft (R) Optimi
00000060: 7A 69 6E 67 20 43 6F 6D 70 69 6C 65 72 00 00 00 zing Compiler...
.debug$S表示包含的是符号(Symbol)相关的调试信息段; .debug$P表示包含预编译头文件(Precompiled Header Files)相关的调试信息段; .debug$T表示包含类型(Type)相关的调试信息段。
在“SimpleSection.obj”中,我们只看到了“.debug$S”段,也就是只有调试时的相关信息。我们可以从该段的文本信息中看到目标文件的原始路径,编译器信息等。
5.5 大家都有符号表
SimpleSection.txt”的最后部分是COFF符号表(Symbol table),COFF文件的符号表包含的内容几乎跟ELF文件的符号表一样,主要就是符号名、符号的类型、所在的位置。
COFF SYMBOL TABLE
000 01048684 ABS notype Static | @comp.id
001 80010190 ABS notype Static | @feat.00
002 00000003 ABS notype Static | @vol.md
003 00000000 SECT1 notype Static | .drectve
Section length 18, #relocs 0, #linenums 0, checksum 0
005 00000000 SECT2 notype Static | .debug$S
Section length 70, #relocs 0, #linenums 0, checksum 0
007 00000000 SECT3 notype Static | .data
Section length C, #relocs 0, #linenums 0, checksum AC5AB941
009 00000000 SECT3 notype External | global_init_var
00A 00000004 UNDEF notype External | global_uninit_var
00B 00000000 SECT4 notype Static | .text$mn
Section length 64, #relocs 5, #linenums 0, checksum B955F775
00D 00000000 UNDEF notype () External | printf
00E 00000000 SECT4 notype () External | func1
00F 00000030 SECT4 notype () External | main
010 00000000 SECT4 notype Label | $LN3
011 00000030 SECT4 notype Label | $LN3
012 00000000 SECT5 notype Static | .xdata
Section length 10, #relocs 0, #linenums 0, checksum 434E1581
014 00000000 SECT5 notype Static | $unwind$func1
015 00000000 SECT6 notype Static | .pdata
Section length 18, #relocs 6, #linenums 0, checksum F8B9BDC5
017 00000000 SECT6 notype Static | $pdata$func1
018 00000008 SECT5 notype Static | $unwind$main
019 0000000C SECT6 notype Static | $pdata$main
01A 00000004 SECT3 notype Static | $SG7562
01B 00000008 SECT3 notype Static | ?static_var@?1??main@@9@9 (`main'::`2'::static_var)
01C 00000000 SECT7 notype Static | .bss
Section length 4, #relocs 0, #linenums 0, checksum 0
01E 00000000 SECT7 notype Static | ?static_var2@?1??main@@9@9 (`main'::`2'::static_var2)
01F 00000000 SECT8 notype Static | .chks64
Section length 40, #relocs 0, #linenums 0, checksum 0
5.6 Windows下的ELF——PE
PE文件是基于COFF的扩展,它比COFF文件多了几个结构。最主要的变化有两个:第一个是文件最开始的部分不是COFF文件头,而是DOS MZ可执行文件格式的文件头和桩代码(DOS MZ File Header and Stub);第二个变化是原来的COFF文件头中的“IMAGE_FILE_HEADER”部分扩展成了PE文件文件头结构“IMAGE_NT_HEADERS”,这个结构包括了原来的“Image Header”及新增的PE扩展头部结构(PE Optional Header)。
“IMAGE_DOS_HEADER”结构也被定义在WinNT.h里面,该结构的大多数成员我们都不关心,唯一值得关心的是“e_lfanew”成员,这个成员表明了PE文件头(IMAGE_NT_HEADERS)在PE文件中的偏移,我们须要使用这个值来定位PE文件头。这个成员在DOS的“MZ”文件格式中它的值永远为0,所以当Windows开始执行一个后缀名为“.exe”的文件时,它会判断“e_lfanew”成员是否为0。如果为0,则该“.exe”文件是一个DOS“MZ”可执行文件,Windows会启动DOS子系统来执行它;如果不为0,那么它就是一个Windows的PE可执行文件,“e_lfanew”的值表示“IMAGE_NT_HEADERS”在文件中的偏移。
“IMAGE_NT_HEADERS”是PE真正的文件头,它包含了一个标记(Signature)和两个结构体。
文件头包含的两个结构分别是映像头(Image Header)、PE扩展头部结构(Image Optional Header)。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER OptionalHeader;
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;
typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
//
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
//
// NT additional fields.
//
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
5.6.1 PE 数据目录
在Windows系统装载PE可执行文件时,往往须要很快地找到一些装载所须要的数据结构,比如导入表、导出表、资源、重定位表等。
这些常用的数据的位置和长度都被保存在了一个叫数据目录(Data Directory)的结构里面,其实它就是前面“IMAGE_OPTIONAL_ HEADER”结构里面的“DataDirectory”成员。
这个成员是一个“IMAGE_DATA_DIRECTORY”的结构数组,相关的定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
IMAGE_DATA_DIRECTORY结构有两个成员,分别是虚拟地址以及长度。
5.7 本章小结
第3部分 装载与动态链接
可执行文件只有装载到内存以后才能被CPU执行。
第6章 可执行文件的装载与进程
介绍进程的概念、进程地址空间的分布和可执行文件映射装载过程。
6.1 进程虚拟地址空间
程序和进程有什么区别
程序(或者狭义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件; 进程则是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库叫做运行时(Runtime)也有一定的含义。
我们知道每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。
硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小。
- 32位的硬件平台决定了虚拟地址空间的地址为 0 到 2^32-1,即0x00000000~0xFFFFFFFF,也就是我们常说的4 GB虚拟空间大小
- 64位的硬件平台具有64位寻址能力,它的虚拟地址空间达到了264字节,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,总共17 179 869 184 GB
那么32位平台下的4 GB虚拟空间,我们的程序是否可以任意使用呢?很遗憾,不行。因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。
对于Windows操作系统来说,它的进程虚拟地址空间划分是操作系统占用2 GB,那么进程只剩下2 GB空间。
32位的CPU下,程序使用的空间能不能超过4 GB呢?这个问题其实应该从两个角度来看,首先,问题里面的“空间”如果是指虚拟地址空间,那么答案是“否”。因为32位的CPU只能使用32位的指针,它最大的寻址范围是0 到4 GB;如果问题里面的“空间”指计算机的内存空间,那么答案为“是”。Intel自从1995年的Pentium Pro CPU开始采用了36位的物理地址,也就是可以访问高达64 GB的物理内存。
从硬件层面上来讲,原先的32位地址线只能访问最多4 GB的物理内存。但是自从扩展至36位地址线之后,Intel修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存。Intel 把这个地址扩展方式叫做PAE(Physical Address Extension)。
6.2 装载的方式
程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。
页映射是将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。
6.2.1 覆盖装入
6.2.2 页映射
页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。
6.3 从操作系统角度看可执行文件的装载
本节我们将站在操作系统的角度来阐述一个可执行文件如何被装载,并且同时在进程中执行。
将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。
6.3.1 进程的建立
从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。
很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么我们就来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
- 创建一个独立的虚拟地址空间。
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
首先是创建虚拟地址空间。回忆第1章的页映射机制,我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386 的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image)。
可执行文件与执行该可执行文件进程的虚拟空间的映射关系,很明显,这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area);在Windows中将这个叫做虚拟段(Virtual Section),其实它们都是同一个概念。
将CPU指令寄存器设置成可执行文件入口,启动运行。第三步其实也是最简单的一步,操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。
6.3.2 页错误
上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。
当CPU开始打算执行这个地址的指令时,发现页面0x08048000~0x08049000是个空页面,于是它就认为这是一个页错误(Page Fault)。CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。
这时候我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统将查询这个数据结构,然后找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位置重新开始执行。
6.4 进程虚存空间分布
6.4.1 ELF文件链接视图和执行视图
ELF文件中,段的权限往往只有为数不多的几种组合,基本上是三种:
- 以代码段为代表的权限为可读可执行的段。
- 以数据段和BSS段为代表的权限为可读可写的段。
- 以只读数据段为代表的权限为只读的段。
ELF可执行文件引入了一个概念叫做“Segment”,一个“Segment”包含一个或多个属性类似的“Section”。
“Segment”的概念实际上是从装载的角度重新划分了ELF的各个段。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。
6.4.2 堆和栈
栈一般也叫做堆栈,我们知道每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。
我们小结关于进程虚拟地址空间的概念:
- 操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;
- 基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;
一个进程基本上可以分为如下几种VMA区域:
- 代码VMA,权限只读、可执行;有映像文件。
- 数据VMA,权限可读写、可执行;有映像文件。
- 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
- 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。
当我们在讨论进程虚拟空间的“Segment”的时候,基本上就是指上面的几种VMA。
6.4.3 堆的最大申请数量
那么malloc的最大申请数量会受到哪些因素的影响呢?实际上,具体的数值会受到操作系统版本、程序本身大小、用到的动态/共享库数量、大小、程序栈数量、大小等,甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。
6.4.4 段地址对齐
6.4.5 进程栈初始化
6.5 Linux内核装载ELF过程简介
每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。
https://github.com/torvalds/linux/blob/master/fs/binfmt_elf.c
这里我们只关心ELF可执行文件的装载,load_elf_binary()被定义在fs/Binfmt_elf.c,这个函数的代码比较长,它的主要步骤是:
- (1)检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
- (2)寻找动态链接的“.interp”段,设置动态链接器路径(与动态链接有关,具体请参考第9章)。
- (3)根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
- (4)初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址(参照动态链接)。
- (5)将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。
6.6 Windows PE的装载
PE文件的装载跟ELF有所不同,由于PE文件中,所有段的起始地址都是页的倍数,段的长度如果不是页的整数倍,那么在映射时向上补齐到页的整数倍,我们也可以简单地认为在32位的PE文件中,段的起始地址和长度都是4 096字节的整数倍。
PE可执行文件的段的数量一般很少,不像ELF中经常有十多个“Section”,最后不得不使用“Segment”的概念把它们合并到一起装载,PE文件中,链接器在生产可执行文件时,往往将所有的段尽可能地合并,所以一般只有代码段、数据段、只读数据段和BSS等为数不多的几个段。
在讨论结构的具体装载过程之前,我们要先引入一个PE里面很常见的术语叫做RVA(Relative Virtual Address),它表示一个相对虚拟地址。这个术语看起来比较晦涩难懂,其实它的概念很简单,就是相当于文件中的偏移量的东西。它是相对于PE文件的装载基地址的一个偏移地址。
每个PE文件在装载时都会有一个装载目标地址(Target Address),这个地址就是所谓的基地址(Base Address)。由于PE文件被设计成可以装载到任何地址,所以这个基地址并不是固定的,每次装载时都可能会变化。
装载一个PE可执行文件并且装载它,是个比ELF文件相对简单的过程:
先读取文件的第一个页,在这个页中,包含了DOS头、PE文件头和段表。
检查进程地址空间中,目标地址是否可用,如果不可用,则另外选一个装载地址。这个问题对于可执行文件来说基本不存在,因为它往往是进程第一个装入的模块,所以目标地址不太可能被占用。主要是针对DLL文件的装载而言的,我们在后面的“Rebasing”这一节还会具体介绍这个问题。
使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置。
如果装载地址不是目标地址,则进行Rebasing。
装载所有PE文件所需要的DLL文件。
对PE文件中的所有导入符号进行解析。
根据PE头中指定的参数,建立初始化栈和堆。
建立主线程并且启动进程。
PE文件中,与装载相关的主要信息都包含在PE扩展头(PE Optional Header)和段表。
Image Base: PE 文件的优先装载地址。比如,如果该值是0x00400000,PE装载器将尝试把文件装到虚拟地址空间的0x00400000处。若该地址区域已被其他目标文件占用,那PE装载器会选用其他空闲地址。对于可执行文件来说,它一般是0x00400000,对于DLL来说它一般是 0x10000000。 AddressOfEntryPoint:PE 装载器准备运行的 PE 文件的第一个指合的 RVA。如果我们需要改变整个执行的流程,可以将该值指定到新的RVA,这样当PE 文件被开始执行时,会从新RVA 处的指合首先被执行。这经常是一些病毒感染 PE 文件后所做的第一件事。 SectionAlignment:内存中段对齐的粒度。默认情况下一般是系统页面的大小,x86下是4096字节 FileAlignment:文件中段对齐的粒度。这个值必须是2的指数倍从512到64KB。默认一般是 512 字节。 MajorSubsystemVersion:程序运行所需要的 Wn32 子系统版本。 SizeOflmage:内存中整个 PE 映像体的尺寸。它是所有头和节经过节对齐处理后的大小 SizeOfCode:代码段的长度 SizeOfnitializedData:初始化了的数据段长度 SizeOfUninitializedData:未初始化的数据段长度 BaseOfCode:代码段起始 RVA BaseOfData:数据段起始 RVA
6.7 本章小结
在这一章中,我们探讨了程序运行时如何使用内存空间的问题,即进程虚拟地址空间问题。接着我们围绕程序如何被操作系统装载到内存中进行运行,介绍了覆盖装入和页映射的模式,分析了为什么要以页映射的方式将程序映射至进程地址空间,这样做的好处是什么,并从操作系统的角度观察进程如何被建立,当程序开始运行时发生页错误该如何处理等。
我们还详细介绍了进程虚拟地址空间的分布,操作系统如何为程序的代码、数据、堆、栈在进程地址空间中分配,它们是如何分布的。最后两个章节我们分别深入介绍了Linux和Windows程序如何装载并且运行ELF和PE程序。在这一章中,我们假设程序都是静态链接的,那么它们都只有一个单独的可执行文件模块。下一章中我们将介绍一种与静态链接程序不同的概念,即一个单一的可执行文件模块被拆分成若干个模块,在程序运行时进行链接的一种方式。
第7章 动态链接
以Linux下的.so共享库为基础详细分析了动态链接的过程。
7.1 为什么要动态链接
静态链接的诸多缺点也逐步暴露出来,比如浪费内存和磁盘空间、模块更新困难等问题,使得人们不得不寻找一种更好的方式来组织程序的模块。
- 内存和磁盘空间
- 程序开发和发布
- 动态链接
- 把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。
- 程序可扩展性和兼容性
- 动态链接也有诸多的问题及令人烦恼和费解的地方。
- 很常见的一个问题是,当程序所依赖的某个模块更新后,由于新的模块与旧的模块之间接口不兼容,导致了原有的程序无法运行。
- 动态链接也有诸多的问题及令人烦恼和费解的地方。
- 动态链接的基本实现
- 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分。
- 在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,它们一般都是以“.so”为扩展名的一些文件;
- 在Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它们通常就是我们平时很常见的以“.dll”为扩展名的文件。
- 从本质上讲,普通可执行程序和动态链接库中都包含指令和数据,这一点没有区别。
- 当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
7.2 简单的动态链接例子
关于模块(Module)
动态链接程序运行时地址空间分布
7.3 地址无关代码
7.3.1 固定装载地址的困扰
7.3.2 装载时重定位
静态链接中的重定位,这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。
我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)。
装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题。
- 指令部分:动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。
- 数据部分:动态连接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
7.3.3 地址无关代码
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。
我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。
我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。
这里我们把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问。
- 第一种是模块内部的函数调用、跳转等。
- 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
- 第三种是模块外部的函数调用、跳转等。
- ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。
- 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。
7.3.4 共享模块的全局变量问题
7.3.5 数据段地址无关性
7.4 延迟绑定(PLT)
延迟绑定实现
7.5 动态链接相关结构
7.5.1 “.interp”段
7.5.2 “.dynamic”段
7.5.3 动态符号表
为了表示动态链接这些模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息,这个段的段名通常叫做“.dynsym”(Dynamic Symbol)。
7.5.4 动态链接重定位表
对于使用PIC技术的可执行文件或共享对象来说,虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中绝对地址相关的部分被分离了出来,变成了GOT,而GOT实际上是数据段的一部分。
在静态链接中,目标文件里面包含有专门用于表示重定位信息的重定位表,比如“.rel.text”表示是代码段的重定位表,“.rel.data”是数据段的重定位表。
在动态链接中,也有类似的重定位表分别叫做“.rel.dyn”和“.rel.plt”,它们分别相当于 “.rel.text”和“.rel.data”。
“.rel.dyn”实际上是对数据引用的修正,它所修正的位置位于“.got”以及数据段;而“.rel.plt”是对函数引用的修正,它所修正的位置位于“.got.plt”。
7.5.5 动态链接时进程堆栈初始化信息
7.6 动态链接的步骤和实现
动态链接的步骤基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。
7.6.1 动态链接器自举
首先是,动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。对于第一个条件我们可以人为地控制,在编写动态链接器时保证不使用任何系统库、运行库;对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)。
7.6.2 装载共享对象
完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。
这种一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象全局符号介入(Global Symbol Interpose)。
关于全局符号介入这个问题,实际上Linux下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
7.6.3 重定位和初始化
7.6.4 Linux动态链接器实现
7.7 显式运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接(Explicit Run-time Linking),有时候也叫做运行时加载。
一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往被叫做动态装载库(Dynamic Loading Library),其实本质上它跟一般的共享对象没什么区别,只是程序开发者使用它的角度不同。
在Linux中,从文件本身的格式上来看,动态库实际上跟一般的共享对象没有区别,正如我们前面讨论过的。主要的区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态连接器自动完成,对于程序本身是透明的;而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)以及关闭动态库(dlclose),程序可以通过这几个API对动态库进行操作。
7.7.1 dlopen()
7.7.2 dlsym()
当多个同名符号冲突时,先装入的符号优先,我们把这种优先级方式称为装载序列(Load Ordering)。
7.7.3 dlerror()
7.7.4 dlclose()
7.7.5 运行时装载的演示程序
7.8 本章小结
第8章 Linux共享库的组织
介绍Linux下共享库文件的分布和组织。
8.1 共享库版本
8.1.1 共享库兼容性
最简单的情况下,共享库的更新可以被分为两类。
- 兼容更新。所有的更新只是在原有的共享库基础上添加一些内容,所有原有的接口都保持不变。
- 不兼容更新。共享库更新改变了原有的接口,使用该共享库原有接口的程序可能不能运行或运行不正常。
二进制接口,即ABI(Application Binary Interface)。
导致C语言的共享库ABI改变的行为主要有如下4个:
- 导出函数的行为发生改变,也就是说调用这个函数以后产生的结果与以前不一样,不再满足旧版本规定的函数行为准则。
- 导出函数被删除。
- 导出数据的结构发生变化。
- 导出函数的接口发生变化,如函数返回值、参数被更改。
如果能够保证上述4种情况不发生,那么绝大部分情况下,C语言的共享库将会保持ABI兼容。
对于Linux来说,如果你要开发一个导出接口为C++的共享库(当然我十分不推荐这么做,使用C的接口会让事情变得简单得多),需要注意以下事项,以防止ABI不兼容(完全遵循以下准则还是不能保证ABI完全兼容):
- 不要在接口类中使用虚函数,万不得已要使用虚函数时,不要随意删除、添加或在子类中添加新的实现函数,这样会导致类的虚函数表结构发生变化。
- 不要改变类中任何成员变量的位置和类型。
- 不要删除非内嵌的public或protected成员函数。
- 不要将非内嵌的成员函数改变成内嵌成员函数。
- 不要改变成员函数的访问权限。
- 不要在接口中使用模板。
- 最重要的是,不要改变接口的任何部分或干脆不要使用C++作为共享库接口!
8.1.2 共享库版本命名
Linux有一套规则来命名系统中的每一个共享库,它规定共享库的文件名规则必须如下:
- libname.so.x.y.z
8.1.3 SO-NAME
8.2 符号版本
8.2.2 Solaris中的符号版本机制
8.3 共享库系统路径
FHS规定,一个系统中主要有两个存放共享库的位置,它们分别如下:
- /lib,这个位置主要存放系统最关键和基础的共享库,比如动态链接器、C语言运行库、数学库等,这些库主要是那些/bin和/sbin下的程序所需要用到的库,还有系统启动时需要的库。
- /usr/lib,这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序或shell脚本直接用到。这个目录下面还包含了开发时可能会用到的静态库、目标文件等。
- /usr/local/lib,这个目录用来放置一些跟操作系统本身并不十分相关的库,主要是一些第三方的应用程序的库。比如我们在系统中安装了python语言的解释器,那么与它相关的共享库可能会被放到/usr/local/lib/python,而它的可执行文件可能被放到/usr/local/bin下。GNU的标准推荐第三方的程序应该默认将库安装到/usr/local/lib下。
所以总体来看,/lib和/usr/lib是一些很常用的、成熟的,一般是系统本身所需要的库;而/usr/local/lib是非系统所需的第三方程序的共享库。
8.4 共享库查找过程
8.5 环境变量
8.6 共享库的创建和安装
8.7 本章小结
第9章 Windows下的动态链接
介绍Windows系统下DLL动态链接机制。
9.1 DLL 简介
DLL即动态链接库(Dynamic-Link Library)的缩写,它相当于Linux下的共享对象。
Windows下的DLL文件和EXE文件实际上是一个概念,它们都是有PE格式的二进制文件,稍微有些不同的是PE文件头部中有个符号位表示该文件是EXE或是DLL,而DLL文件的扩展名不一定是.dll,也有可能是别的比如.ocx(OCX控件)或是.CPL(控制面板程序)。
DLL的设计目的与共享对象有些出入,DLL更加强调模块化,即微软希望通过DLL机制加强软件的模块化设计,使得各种模块之间能够松散地组合、重用和升级。所以我们在Windows平台上看到大量的大型软件都通过升级DLL的形式进行自我完善,微软经常将这些升级补丁积累到一定程度以后形成一个软件更新包(Service Packs)。
9.1.1 进程地址空间和内存管理
9.1.2 基地址和RVA
PE里面有两个很常用的概念就是基地址(Base Address)和相对地址(RVA,Relative Virtual Address)。
当一个PE文件被装载时,其进程地址空间中的起始地址就是基地址。对于任何一个PE文件来说,它都有一个优先装载的基地址,这个值就是PE文件头中的Image Base。
对于一个可执行EXE文件来说,Image Base一般值是0x400000,对于DLL文件来说,这个值一般是0x10000000。Windows在装载DLL时,会先尝试把它装载到由Image Base指定的虚拟地址;若该地址区域已被其他模块占用,那PE装载器会选用其他空闲地址。而相对地址就是一个地址相对于基地址的偏移,比如一个PE文件被装载到0x10000000,即基地址为0x10000000,那么RVA为0x1000的地址为0x10001000。
9.1.3 DLL共享数据段
9.1.4 DLL的简单例子
9.1.5 创建DLL
cl /LDd /Fe:math.dll math.c
参数/LDd表示生产Debug版的DLL,不加任何参数则表示生产EXE可执行文件;我们可以使用/LD来编译生成Release版的DLL
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering> dumpbin /EXPORTS .\math.dll
Microsoft (R) COFF/PE Dumper Version 14.43.34808.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\math.dll
File Type: DLL
Section contains the following exports for math.dll
00000000 characteristics
FFFFFFFF time date stamp
0.00 version
1 ordinal base
3 number of functions
3 number of names
ordinal hint RVA name
1 0 00001000 Add
2 1 00001040 Mul
3 2 00001020 Sub
Summary
3000 .data
1000 .fptable
4000 .pdata
14000 .rdata
1000 .reloc
3E000 .text
9.1.6 使用DLL
程序使用DLL的过程其实是引用DLL中的导出函数和符号的过程,即导入过程。对于从其他DLL导入的符号,我们需要使用“__declspec(dllimport)”显式地声明某个符号为导入符号。
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering> cl /c .\TestMath.c
用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.43.34808 版
版权所有(C) Microsoft Corporation。保留所有权利。
TestMath.c
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering> link TestMath.obj .\math.lib
Microsoft (R) Incremental Linker Version 14.43.34808.0
Copyright (C) Microsoft Corporation. All rights reserved.
在最终链接时,我们必须把与DLL一起产生的“Math.lib”与“TestMath.o”链接起来,形成最终的可执行文件。在静态链接的时候,我们介绍过“.lib”文件是一组目标文件的集合,在动态链接里面这一点仍然没有错,但是“Math.lib”里面的目标文件是什么呢?“Math.lib”中并不真正包含“Math.c”的代码和数据,它用来描述“Math.dll”的导出符号,它包含了TestMath.o链接Math.dll时所需要的导入符号以及一部分“桩”代码,又被称作“胶水”代码,以便于将程序与DLL粘在一起。像“Math.lib”这样的文件又被称为导入库(Import Library),我们在后面介绍导入导出表的时候还会再详细分析。
9.1.7 使用模块定义文件
除了C++程序以外,C语言的符号也有可能被修饰,比如MSVC支持几种函数的调用规范“__cdecl”、“__stdcall”、“__fastcall”(我们在本书的第4章还会详细介绍各种函数调用规范之间的区别),默认情况下MSVC把C语言的函数当作“_cdecl”类型,这种情况下它对该函数不进行任何符号修饰。
9.1.8 DLL显式运行时链接
与ELF类似,DLL也支持运行时链接,即运行时加载。Windows提供了3个API为:
- LoadLibrary(或者LoadLibraryEx),这个函数用来装载一个DLL到进程的地址空间,它的功能跟dlopen类似。
- GetProcAddress,用来查找某个符号的地址,与dlsym类似。
- FreeLibrary,用来卸载某个已加载的模块,与dlclose类似。
9.2 符号导出导入表
9.2.1 导出表
当一个PE需要将一些函数或变量提供给其他PE文件使用时,我们把这种行为叫做符号导出(Symbol Exporting),最典型的情况就是一个DLL将符号导出给EXE文件使用。
在Windows PE中,符号导出的概念也是类似,所有导出的符号被集中存放在了被称作导出表(Export Table)的结构中。
事实上导出表从最简单的结构上来看,它提供了一个符号名与符号地址的映射关系,即可以通过某个符号查找相应的地址。
基本上这些每个符号都是一个ASCII字符串,我们知道符号名可能跟相应的函数名或者变量名相同,也可能不同,因为有符号修饰这个机制存在。
很多时候,在讨论到PE的导入导出时,经常把函数和符号混淆在一起,因为PE在绝大部分时候只导入导出函数,而很少导入导出变量,所以类似于导出符号和导出函数这种叫法很多时候可以相互替换使用。
PE文件头中有一个叫做DataDirectory的结构数组,这个数组共有16个元素,每个元素中保存的是一个地址和一个长度。其中第一个元素就是导出表的结构的地址和长度。导出表是一个IMAGE_EXPORT_DIRECTORY的结构体,它被定义在“Winnt.h”中:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY
导出表结构中,最后的3个成员指向的是3个数组,这3个数组是导出表中最重要的结构,它们是导出地址表(EAT, Export Address Table)、符号名表(Name Table)和名字序号对应表(Name-Ordinal Table)。
第一个叫做导出地址表EAT,它存放的是各个导出函数的RVA,比如第一项是0x1000,它是Add函数的RVA; 第二个表是函数名表,它保存的是导出函数的名字,这个表中,所有的函数名是按照ASCII顺序排序的,以便于动态链接器在查找函数名字时可以速度更快(可以使用二分法查找),那么函数名表和EAT之间有什么关系呢?是不是一一对应呢?在上面的例子中似乎是这样的,比如Add对应0x1000,Mul对应0x1020,Sub对应0x1010,这样看起来很简单,但实际上并非如此,因为还有一个叫做序号的概念夹在这两个表之间; 第三个名字序号对应表就有点另类了,导出一个函数除了函数名和函数地址不就够了吗?为什么要有序号?什么是序号?
其实序号的概念很简单,一个导出函数的序号就是函数在EAT中的地址下标加上一个Base值(也就是IMAGE_EXPORT_DIRECTORY中的Base,默认情况下它的值是1)。
虽然现在的DLL导出方式基本都是使用符号名,但是实际上序号的导出方式仍然没有被抛弃。为了保持向后兼容性,序号导出方式仍然被保留,相反,符号名作为导出方式是可选的。一个DLL中的每一个导出函数都有一个对应唯一的序号值,而导出函数名却是可选的,也就是说一个导出函数肯定有一个序号值(序号值是肯定有的,因为函数在EAT的下标加上Base就是序号值),但是可以没有函数名。
那么使用函数名作为导入导出方式,动态链接器如何查找函数的RVA呢?假设模块A导入了Math.dll中的Add函数,那么A的导入表中就保存了“Add”这个函数名。当进行动态链接时,动态链接器在Math.dll的函数名表中进行二分查找,找到“Add”函数,然后在名字序号对应表中找到“Add”所对应的序号,即1,减去Math.dll的Base值1,结果为0,然后在EAT中找到下标0的元素,即“Add”的RVA为0x1000。
对于链接器来说,它在链接输出DLL时需要知道哪些函数和变量是要被导出的,因为对于PE来说,默认情况下,全局函数和变量是不导出的。
link链接器提供了了一个“/EXPORT”的参数可以指定导出符号,就表示在产生math.dll时导出符号_Add。 另外一种导出符号的方法是使用MSVC的__declspec(dllexport)扩展,它实际上是通过目标文件的编译器指示来实现的(还记得前面关于PE/COFF目标文件的“.drectve”段的描述吗?)。
对于前面例子中的math.obj来说,它实际上在“.drectve”段中保存了4个“/EXPORT”参数,用于传递给链接器,告知链接器导出相应的函数。
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering> dumpbin /DIRECTIVES .\math.obj
Microsoft (R) COFF/PE Dumper Version 14.43.34808.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\math.obj
File Type: COFF OBJECT
Linker Directives
-----------------
/DEFAULTLIB:LIBCMTD
/DEFAULTLIB:OLDNAMES
/EXPORT:Add
/EXPORT:Sub
/EXPORT:Mul
Summary
20 .chks64
A0 .debug$S
54 .drectve
59 .text$mn
9.2.2 EXP文件
在创建DLL的同时也会得到一个EXP文件,这个文件实际上是链接器在创建DLL时的临时文件。链接器在创建DLL时与静态链接时一样采用两遍扫描过程,DLL一般都有导出符号,链接器在第一遍时会遍历所有的目标文件并且收集所有导出符号信息并且创建DLL的导出表。为了方便起见,链接器把这个导出表放到一个临时的目标文件叫做“.edata”的段中,这个目标文件就是EXP文件,EXP文件实际上是一个标准的PE/COFF目标文件,只不过它的扩展名不是.obj而是.exp。在第二遍时,链接器就把这个EXP文件当作普通目标文件一样,与其他输入的目标文件链接在一起并且输出DLL。这时候EXP文件中的“.edata”段也就会被输出到DLL文件中并且成为导出表。不过一般现在链接器很少会在DLL中单独保留“.edata”段,而是把它合并到只读数据段“.rdata”中。
9.2.3 导出重定向
9.2.4 导入表
如果我们在某个程序中使用到了来自DLL的函数或者变量,那么我们就把这种行为叫做符号导入(Symbol Importing)。
在ELF中,“.rel.dyn”和“.rel.plt”两个段中分别保存了该模块所需要导入的变量和函数的符号以及所在的模块等信息,而“.got”和“.got.plt”则保存着这些变量和函数的真正地址。 在Windows中也有类似的机制,它的名称更为直接,叫做导入表(Import Table)。
当某个PE文件被加载时,Windows加载器的其中一个任务就是将所有需要导入的函数地址确定并且将导入表中的元素调整到正确的地址,以实现动态链接的过程。
(base) PS E:\Projects\github\biezhihua.github.io\src\learn-reverse-engineering> dumpbin /IMPORTS .\math.dll
Microsoft (R) COFF/PE Dumper Version 14.43.34808.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\math.dll
File Type: DLL
Section contains the following imports:
KERNEL32.dll
18003F000 Import Address Table
180051F38 Import Name Table
0 time date stamp
0 Index of first forwarder reference
47B QueryPerformanceCounter
23C GetCurrentProcessId
240 GetCurrentThreadId
314 GetSystemTimeAsFileTime
394 InitializeSListHead
501 RtlCaptureContext
509 RtlLookupFunctionEntry
510 RtlVirtualUnwind
3AA IsDebuggerPresent
5F3 UnhandledExceptionFilter
5B0 SetUnhandledExceptionFilter
2FB GetStartupInfoW
3B2 IsProcessorFeaturePresent
29F GetModuleHandleW
23B GetCurrentProcess
5D0 TerminateProcess
E2 CreateFileW
50F RtlUnwindEx
398 InterlockedFlushSList
287 GetLastError
570 SetLastError
151 EnterCriticalSection
3EA LeaveCriticalSection
12B DeleteCriticalSection
390 InitializeCriticalSectionAndSpinCount
5E2 TlsAlloc
5E4 TlsGetValue
5E6 TlsSetValue
5E3 TlsFree
1CE FreeLibrary
2D7 GetProcAddress
3F0 LoadLibraryExW
14D EncodePointer
492 RaiseException
50B RtlPcToFileHeader
180 ExitProcess
29E GetModuleHandleExW
29B GetModuleFileNameW
376 HeapAlloc
37F HeapSize
382 HeapValidate
30E GetSystemInfo
197 FindClose
19D FindFirstFileExW
1AE FindNextFileW
3B8 IsValidCodePage
1D5 GetACP
2C0 GetOEMCP
1E4 GetCPInfo
1F9 GetCommandLineA
1FA GetCommandLineW
41D MultiByteToWideChar
644 WideCharToMultiByte
25C GetEnvironmentStringsW
1CD FreeEnvironmentStringsW
1BC FlsAlloc
1BE FlsGetValue
1C0 FlsSetValue
1BD FlsFree
391 InitializeCriticalSectionEx
612 VirtualProtect
3DE LCMapStringW
2DE GetProcessHeap
2FD GetStdHandle
274 GetFileType
37A HeapFree
37D HeapReAlloc
37C HeapQueryInformation
658 WriteFile
445 OutputDebugStringW
657 WriteConsoleW
302 GetStringTypeW
58B SetStdHandle
561 SetFilePointerEx
1C2 FlushFileBuffers
223 GetConsoleOutputCP
21F GetConsoleMode
9C CloseHandle
Summary
3000 .data
1000 .fptable
4000 .pdata
14000 .rdata
1000 .reloc
3E000 .text
这是由于我们在构建Windows DLL时,还链接了支持DLL运行的基本运行库,这个基本运行库需要用到Kernel32.dll,所以就有了这些导入函数。
在Windows中,系统的装载器会确保任何一个模块的依赖条件都得到满足,即每个PE文件所依赖的文件都将被装载。比如一般Windows程序都会依赖于KERNEL32.DLL,而KERNEL32.DLL又会导入NTDLL.DLL,即依赖于NTDLL.DLL,那么Windows在加载该程序时确保这两个DLL都被加载。如果程序用到了Windows GDI,那么就会需要从GDI32.DLL中导入函数,而GDI32.DLL又依赖于USER32.DLL、ADVAPI32.DLL、NTDLL.DLL和KERNEL32.DLL,Windows将会保证这些依赖关系的正确,并且保证所有的导入符号都被正确地解析。
在这个动态链接过程中,如果某个被依赖的模块无法正确加载,那么系统将会提示错误(我们经常会看到那种“缺少某个DLL”之类的错误),并且终止运行该进程。
在PE文件中,导入表是一个IMAGE_IMPORT_DESCRIPTOR的结构体数组,每一个IMAGE_IMPORT_DESCRIPTOR结构对应一个被导入的DLL。这个结构体被定义在“Winnt.h”中:
typedef struct {
DWORD OriginalFirstThunk;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
结构体中的FirstThunk指向一个导入地址数组(Import Address Table),IAT是导入表中最重要的结构,IAT中每个元素对应一个被导入的符号,元素的值在不同的情况下有不同的含义。
在动态链接器刚完成映射还没有开始重定位和符号解析时,IAT中的元素值表示相对应的导入符号的序号或者是符号名;当Windows的动态链接器在完成该模块的链接时,元素值会被动态链接器改写成该符号的真正地址,从这一点看,导入地址数组与ELF中的GOT非常类似。
那么我们如何判断导入地址数组的元素中包含的是导入符号的序号还是符号的名字?事实上我们可以看这个元素的最高位,对于32位的PE来说,如果最高位被置1,那么低31位值就是导入符号的序号值;如果没有,那么元素的值是指向一个叫做IMAGE_IMPORT_BY_NAME结构的RVA。IMAGE_IMPORT_BY_NAME是由一个WORD和一个字符串组成,那个WORD值表示“Hint”值,即导入符号最有可能的序号值,后面的字符串是符号名。
当使用符号名导入时,动态链接器会先使用“Hint”值的提示去定位该符号在目标导出表中的位置,如果刚好是所需要的符号,那么就命中;如果没有命中,那么就按照正常的二分查找方式进行符号查找。
在IMAGE_IMPORT_DESCRIPTOR结构中,还有一个指针OriginalFirstThrunk指向一个数组叫做导入名称表(Import Name Table),简称INT。这个数组跟IAT一摸一样,里面的数值也一样。那么为什么要多保存一份IAT的副本呢?
DLL的绑定实现也比较简单,editbin对被绑定的程序的导入符号进行遍历查找,找到以后就把符号的运行时的目标地址写入到被绑定程序的导入表内。还记得前面介绍PE的导入表中有个与IAT一样的数组叫做,INT这个数组就是用来保存绑定符号的地址的。
9.2.5 导入函数的调用
PE DLL的代码段并不是地址无关的。
那么PE是如何解决装载时模块在进程空间中地址冲突的问题的呢?事实上它使用了一种叫做重定基地址的方法,我们在后面将会详细介绍。
PE采用上面的这个方法实现导入函数的调用,但是与ELF一样存在一个问题:对于编译器来说,它无法判断一个函数是本模块内部的,还是从外部导入的。
为了使得编译器能够区分函数是从外部导入的还是模块内部定义的,MSVC引入了我们前面用过的扩展属性“__declspec(dllimport)”,一旦一个函数被声明为“__declspec(dllimport)”,那么编译器就知道它是外部导入的,以便于产生相应的指令形式。
在“__declspec”关键字引入之前,微软还提供了另外一个方法来解决这个问题。在这种情况下,对于导入函数的调用,编译器并不区分导入函数和导出函数,它统一地产生直接调用的指令。但是链接器在链接时会将导入函数的目标地址导向一小段桩代码(Stub),由 这个桩代码再将控制权交给IAT中的真正目标地址,实现如下:
CALL 0x0040100C
...
0x0040100C:
JMP DWORD PTR [0x0040D11C]
即对于调用函数来说,它只是产生一般形式的指令“CALL XXXXXXXX”,然后在链接时,链接器把这个XXXXXXXX地址重定位到一段桩代码,即那条JMP指令处,然后这条JMP指令才通过IAT间接跳转到导入函数。
我们知道,链接器一般情况下是不会产生指令的,那么这段包含JMP指令的桩代码来自何处呢?答案是来自产生DLL文件时伴随的那个LIB文件,即导入库。
编译器在产生导入库时,同一个导出函数会产生两个符号的定义,比如对于函数foo来说,它在导入库中有两个符号,一个是foo,另外一个是__imp__foo。
这两个符号的区别是,foo这个符号指向foo函数的桩代码,而__imp__foo指向foo函数在IAT中的位置。
所以当我们通过“__declspec(dllimport)”来声明foo导入函数时,编译器在编译时会在该导入函数前加上前缀“imp”,以确保跟导入库中的“__imp__foo”能够正确链接;如果不使用“__declspec(dllimport)”,那么编译器将产生一个正常的foo符号引用,以便于跟导入库中的foo符号定义相链接。
现在的MSVC编译器对于以上两种导入方式都支持,即程序员可以通过“__declspec(dllimport)”来声明导入函数,也可以不使用。但我们还是推荐使用“__declspec(dllimport)”,毕竟从性能上来讲,它比不使用该声明少了一条跳转指令。当然它还有其他的好处,我们到后面用到时还会提起。
9.3 DLL优化
我们在前面经过对DLL的分析得知,DLL的代码段和数据段本身并不是地址无关的,也就是说它默认需要被装载到由ImageBase指定的目标地址中。如果目标地址被占用,那么就需要装载到其他地址,便会引起整个DLL的Rebase。
9.3.1 重定基地址(Rebasing)
PE的DLL中的代码段并不是地址无关的,也就是说它在被装载时有一个固定的目标地址,这个地址也就是PE里面所谓的基地址(Base Address)。
默认情况下,PE文件将被装载到这个基地址。一般来说,EXE文件的基地址默认为0x00400000,而DLL文件基地址默认为0x10000000。
Windows PE采用了一种与ELF不同的办法,它采用的是装载时重定位的方法。
在DLL模块装载时,如果目标地址被占用,那么操作系统就会为它分配一块新的空间,并且将DLL装载到该地址。
DLL中所有涉及到绝对地址的引用该怎么办呢?答案是对于每个绝对地址引用都进行重定位。
事实上,由于DLL内部的地址都是基于基地址的,或者是相对于基地址的RVA。
PE里面把这种特殊的重定位过程又被叫做重定基地址(Rebasing)。
PE文件的重定位信息都放在了“.reloc”段,我们可以从PE文件头中的DataDirectory里面得到重定位段的信息。
对于EXE文件来说,MSVC编译器默认不会产生重定位段,也就是默认情况下,EXE是不可以重定位的,不过这也没有问题,因为EXE文件是进程运行时第一个装入到虚拟空间的,所以它的地址不会被人抢占。而DLL则没那么幸运了,它们被装载的时间是不确定的,所以一般情况下,编译器都会给DLL文件产生重定位信息。当然你也可以使用“/FIXED”参数来禁止DLL产生重定位信息,不过那样可能会造成DLL的装载失败。
前面的重定基地址过程实际上是在DLL文件装载时进行的,所以又叫做装载时重定位。
由于Windows系统本身自带了很多系统的DLL,比如kernel32.dll、ntdll.dll、shell32.dll、user32.dll、msvcrt.dll等,这些DLL基本上是Windows的应用程序运行时都要用到的。Windows系统就在进程空间中专门划出一块0x70000000~0x80000000区域,用于映射这些常用的系统DLL。Windows在安装时就把这块地址分配给这些DLL,调整这些DLL的基地址使得它们相互之间不冲突,从而在装载时就不需要进行重定基址了。
9.3.2 序号
一个DLL中每一个导出的函数都有一个对应的序号(Ordinal Number)。一个导出函数甚至可以没有函数名,但它必须有一个唯一的序号。另一方面,当我们从一个DLL导入一个函数时,可以使用函数名,也可以使用序号。序号标示被导出函数地址在DLL导出表中的位置。
9.3.3 导入函数绑定
试想一下,每一次当一个程序运行时,所有被依赖的DLL都会被装载,并且一系列的导入导出符号依赖关系都会被重新解析。
在大多数情况下,这些DLL都会以同样的顺序被装载到同样的内存地址,所以它们的导出符号的地址都是不变的。既然它们的地址都不变,每次程序运行时都要重新进行符号的查找、解析和重定位,是不是有些浪费呢?如果把这些导出函数的地址保存到模块的导入表中,不就可以省去每次启动时符号解析的过程吗?
这个思路是合理的,这种DLL性能优化方式被叫做DLL绑定(DLL Binding)。
DLL绑定方法很简单,我们可以使用editbin(之前的MSVC提供一个额外的bind.exe用于DLL绑定)这个工具对EXE或DLL进行绑定:
DLL的绑定实现也比较简单,editbin对被绑定的程序的导入符号进行遍历查找,找到以后就把符号的运行时的目标地址写入到被绑定程序的导入表内。还记得前面介绍PE的导入表中有个与IAT一样的数组叫做,INT这个数组就是用来保存绑定符号的地址的。
那么什么情况会导致DLL绑定的那些地址失效呢?一种情况是,被依赖的DLL更新导致DLL的导出函数地址发生变化;另外一种情况是,被依赖的DLL在装载时发生重定基址,导致DLL的装载地址与被绑定时不一致。那么如果地址失效,而被绑定的EXE或者DLL还使用失效了的地址的话,必然会导致程序运行错误。Windows必须提供相应的机制来保证绑定地址失效时,程序还能够正确运行。
对于第一种情况的失效,PE的做法是这样的,当对程序进行绑定时,对于每个导入的DLL,链接器把DLL的时间戳(Timestamp)和校验和(Checksum,比如MD5)保存到被绑定的PE文件的导入表中。在运行时,Windows会核对将要被装载的DLL与绑定时的DLL版本是否相同,并且确认该DLL没有发生重定基址,如果一切正常,那么Windows就不需要再进行符号解析过程了,因为被装载的DLL与绑定时一样,没有发生变化;否则Windows就忽略绑定的符号地址,按照正常的符号解析过程对DLL的符号进行解析。
9.4 C++与动态链接
在Windows平台下(有些意见对Linux/ELF也有效),要尽量遵循以下几个指导意见:
- 所有的接口函数都应该是抽象的。所有的方法都应该是纯虚的。(或者inline的方法也可以)。
- 所有的全局函数都应该使用extern“C”来防止名字修饰的不兼容。并且导出函数的都应该是__stdcall调用规范的(COM的DLL都使用这样的规范)。这样即使用户本身的程序是默认以__cdecl方式编译的,对于DLL的调用也能够正确。
- 不要使用C++标准库STL。
- 不要使用异常。
- 不要使用虚析构函数。可以创建一个destroy()方法并且重载delete操作符并且调用destroy()。
- 不要在DLL里面申请内存,而且在DLL外释放(或者相反)。不同的DLL和可执行文件可能使用不同的堆,在一个堆里面申请内存而在另外一个堆里面释放会导致错误。
- 不要在接口中使用重载方法(Overloaded Methods,一个方法多重参数)。因为不同的编译器对于vtable的安排可能不同。
9.5 DLL HELL
9.6 本章小结
动态链接机制对于Windows操作系统来说极其重要,整个Windows系统本身即基于动态链接机制,Windows的API也以DLL的形式提供给程序开发者,而不像Linux等系统是以系统调用作为操作系统的最终入口。DLL比Linux下的ELF共享库更加复杂,提供的功能也更为完善。
第4部分 库与运行库
malloc是如何分配出内存的?局部变量存放在哪里?为什么一个编译好的简单的HelloWorld程序也需要占据好几KB的空间?为什么程序一启动就有堆、I/O或异常系统可用?在这一部分里,我们将详细剖析在程序运行时,隐藏于背后的各种秘密:为什么程序能够执行,它是如何执行的,这些问题将在本部分一一得到解答。首先让我们对程序的运行环境有一个总览,下图描述了一个典型的程序环境。
由此可以看到,程序的环境由以下三个部分组成:
- 内存。
- 运行库。
- 系统调用。
此外,内核也可算作运行环境的一部分,但实际上系统调用部分充当了程序与内核交互的中介,因此在这里不把内核算作运行环境。在接下来的几章里,我们会对这几部分一一进行介绍。
第10章 内存
主要介绍堆与栈,堆的分配算法,函数调用栈分布。
10.1 程序的内存布局
栈:栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。在10.2节中将对栈作详细的介绍。栈通常在用户空间的最高地址处分配,通常有数兆字节的大小。
堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。 堆通常存在于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量。
可执行文件映像:这里存储着可执行文件在内存里的映像,在第6章已经提到过,由装载器在装载时将可执行文件的内存读取或映射到这里。
保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。通常C语言将无效指针赋值为0也是出于这个考虑,因为0地址上正常情况下不可能有有效的可访问数据。
栈向低地址增长,堆向高地址增长。
10.2 栈与调用惯例
10.2.1 什么是栈
在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使栈减小。
在经典的操作系统里,栈总是向下增长的。在i386下,栈顶由称为esp的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。
在栈上压入数据会导致esp减小,弹出数据使得esp增大。相反,直接减小esp的值也等效于在栈上开辟空间,直接增大esp的值等效于在栈上回收空间。
最重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)或活动记录(Activate Record)。
堆栈帧一般包括如下几方面内容:
- 函数的返回地址和参数。
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
在i386中,一个函数的活动记录用ebp和esp这两个寄存器划定范围。
- esp寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。
- ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针(Frame Pointer)。
之所以会出现“烫”这么一个奇怪的字,就是因为Debug模式在第4步里,将所有的分配出来的栈空间的每一个字节都初始化为0xCC。0xCCCC(即两个连续排列的0xCC)的汉字编码就是烫,所以0xCCCC如果被当作文本就是“烫”。
10.2.2 调用惯例
毫无疑问函数的调用方和被调用方对于函数如何调用须要有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确地调用,这样的约定就称为调用惯例(Calling Convention)。一个调用惯例一般会规定如下几个方面的内容。
- 函数参数的传递顺序和方式
- 栈的维护方式
- 名字修饰(Name-mangling)的策略
- 在C语言里,存在着多个调用惯例,而默认的调用惯例是cdecl。
- cdecl
- stdcall
- fastcall
- pascal
- thiscall
10.2.3 函数返回值传递
10.3 堆与内存管理
10.3.1 什么是堆(Heap)
堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效。
比较好的做法就是程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理着堆空间分配的往往是程序的运行库。
10.3.2 Linux进程堆管理
Linux下的进程堆管理稍微有些复杂,因为它提供了两种堆空间分配的方式,即两个系统调用:一个是brk()系统调用,另外一个是mmap()。
10.3.3 Windows进程堆管理
10.3.4 堆分配算法
空闲链表 位图 对象池
10.4 本章小结
第11章 运行库
主要介绍运行库的概念、C/C++运行库、Glibc和MSVC CRT、运行库如何实现C++全局构造和析构及以fread()库函数为例对运行库进行剖析。
11.1 入口函数和程序初始化
11.1.1 程序从main开始吗
一个典型的程序运行步骤大致如下:
- 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
- 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
- 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
- main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。
11.1.2 入口函数如何实现
本节我们将深入剖析glibc和MSVC的入口函数实现。
GLIBC入口函数
在glibc的代码中,它位于csu/libc-start.c文件里。__libc_start_main的定义如下 https://zhuanlan.zhihu.com/p/658734980
D:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.43.34808\crt\src\vcruntime\exe_main.cpp
//
// exe_main.cpp
//
// Copyright (c) Microsoft Corporation. All rights reserved.
//
// The mainCRTStartup() entry point, linked into client executables that
// uses main().
//
#define _SCRT_STARTUP_MAIN
#include "exe_common.inl"
extern "C" DWORD mainCRTStartup(LPVOID)
{
return __scrt_common_main();
}
D:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.43.34808\crt\src\vcruntime\exe_main.cpp
extern "C" DWORD mainCRTStartup(LPVOID)
{
return __scrt_common_main();
}
D:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.43.34808\crt\src\vcruntime\exe_common.inl
// This is the common main implementation to which all of the CRT main functions
// delegate (for executables; DLLs are handled separately).
static __forceinline int __cdecl __scrt_common_main()
{
// The /GS security cookie must be initialized before any exception handling
// targeting the current image is registered. No function using exception
// handling can be called in the current image until after this call:
__security_init_cookie();
return __scrt_common_main_seh();
}
11.1.3 运行库与I/O
一个程序的I/O指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等。更广义地讲,I/O指代任何操作系统理解为“文件”的事务。 许多操作系统,包括Linux和Windows,都将各种具有输入和输出概念的实体——包括设备、磁盘文件、命令行等——统称为文件,因此这里所说的文件是一个广义的概念。
在操作系统层面上,文件操作也有类似于FILE的一个概念,在Linux里,这叫做文件描述符(File Descriptor),而在Windows里,叫做句柄(Handle)(以下在没有歧义的时候统称为句柄)。
在Linux中,值为0、1、2的fd分别代表标准输入、标准输出和标准错误输出。 在程序中打开文件得到的fd从3开始增长。fd具体是什么呢?在内核中,每一个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而fd,就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为fd。由于这个表处于内核,并且用户无法访问到,因此用户即使拥有fd,也无法得到打开文件对象的地址,只能够通过系统提供的函数来操作。
对于Windows中的句柄,与Linux中的fd大同小异,不过Windows的句柄并不是打开文件表的下标,而是其下标经过某种线性变换之后的结果。
在大致了解了I/O为何物之后,我们就能知道I/O初始化的职责是什么了。首先I/O初始化函数需要在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。
11.1.4 MSVC CRT的入口函数初始化
11.2 C/C++运行库
11.2.1 C语言运行库
任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。
这样的一个代码集合称之为运行时库(Runtime Library)。而C语言的运行库,即被称为C运行库(CRT)。
一个C语言运行库大致包含了如下功能:
- 启动与退出:包括入口函数及入口函数所依赖的其他函数等。
- 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。
- I/O:I/O功能的封装和实现,参见上一节中I/O初始化部分。
- 堆:堆的封装和实现,参见上一节中堆初始化部分。
- 语言实现:语言中一些特殊功能的实现。
- 调试:实现调试功能的代码。
在这些运行库的组成成分中,C语言标准库占据了主要地位并且大有来头。
11.2.2 C语言标准库
ANSI C的标准库由24个C头文件组成。与许多其他语言(如Java)的标准库不同,C语言的标准库非常轻量,它仅仅包含了数学函数、字符/字符串处理,I/O等基本方面。
- 标准输入输出(stdio.h)。
- 文件操作(stdio.h)。
- 字符操作(ctype.h)。
- 字符串操作(string.h)。
- 数学函数(math.h)。
- 资源管理(stdlib.h)。
- 格式转换(stdlib.h)。
- 时间/日期(time.h)。
- 断言(assert.h)。
11.2.3 glibc与MSVC CRT
运行库是平台相关的,因为它与操作系统结合得非常紧密。C语言的运行库从某种程度上来讲是C语言的程序和不同操作系统平台之间的抽象层,它将不同的操作系统API抽象成相同的库函数。
Linux和Windows平台下的两个主要C语言运行库分别为glibc(GNU C Library)和MSVCRT(Microsoft Visual C Run-time)。
值得注意的是,像线程操作这样的功能并不是标准的C语言运行库的一部分,但是glibc和MSVCRT都包含了线程操作的库函数。比如glibc有一个可选的pthread库中的pthread_create()函数可以用来创建线程;而MSVCRT中可以使用_beginthread()函数来创建线程。所以glibc和MSVCRT事实上是标准C语言运行库的超集,它们各自对C标准库进行了一些扩展。
glibc
glibc即GNU C Library,是GNU旗下的C标准库。
https://www.gnu.org/software/libc/
2025-01-30 GLIBC 2.41
POSIX 是 可移植操作系统接口 的缩写,是一个 IEEE 1003.1 标准,定义了应用程序和 UNIX 操作系统之间的语言接口。遵守该标准可以确保 UNIX 程序在不同平台之间的兼容性。
glibc的发布版本主要由两部分组成,一部分是头文件,比如stdio.h、stdlib.h等,它们往往位于/usr/include;另外一部分则是库的二进制文件部分。二进制部分主要的就是C语言标准库,它有静态和动态两个版本。动态的标准库我们及在本书的前面章节中碰到过了,它位于/lib/libc.so.6;而静态标准库位于/usr/lib/libc.a。
操作系统装载程序之后,首先运行的代码并不是main的第一行,而是某些别的代码,这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数,这时候你才可以在main函数里放心大胆地写各种代码:申请内存、使用系统调用、触发异常、访问I/O。在main返回之后,它会记录main函数的返回值,调用atexit注册的函数,然后结束进程。
运行这些代码的函数称为入口函数或入口点(Entry Point),视平台的不同而有不同的名字。程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。一个典型的程序运行步骤大致如下:
操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。
E:\Projects\github\glibc\sysdeps\i386\start.S
/* This is the canonical entry point, usually the first thing in the text
segment. The SVR4/i386 ABI (pages 3-31, 3-32) says that when the entry
point runs, most registers' values are unspecified, except for:
%edx Contains a function pointer to be registered with `atexit'.
This is how the dynamic linker arranges to have DT_FINI
functions called for shared libraries that have been loaded
before this code runs.
%esp The stack contains the arguments and environment:
0(%esp) argc
4(%esp) argv[0]
...
(4*argc)(%esp) NULL
(4*(argc+1))(%esp) envp[0]
...
NULL
*/
#include <sysdep.h>
ENTRY (_start)
/* Clearing frame pointer is insufficient, use CFI. */
cfi_undefined (eip)
/* Clear the frame pointer. The ABI suggests this be done, to mark
the outermost frame obviously. */
xorl %ebp, %ebp
/* Extract the arguments as encoded on the stack and set up
the arguments for `main': argc, argv. envp will be determined
later in __libc_start_main. */
popl %esi /* Pop the argument count. */
movl %esp, %ecx /* argv starts just at the current stack top.*/
/* Before pushing the arguments align the stack to a 16-byte
(SSE needs 16-byte alignment) boundary to avoid penalties from
misaligned accesses. Thanks to Edward Seidl <seidl@janed.com>
for pointing this out. */
andl $0xfffffff0, %esp
pushl %eax /* Push garbage because we allocate
28 more bytes. */
/* Provide the highest stack address to the user code (for stacks
which grow downwards). */
pushl %esp
pushl %edx /* Push address of the shared library
termination function. */
#ifdef PIC
/* Load PIC register. */
call 1f
addl $_GLOBAL_OFFSET_TABLE_, %ebx
/* This used to be the addresses of .fini and .init. */
pushl $0
pushl $0
pushl %ecx /* Push second argument: argv. */
pushl %esi /* Push first argument: argc. */
# ifdef SHARED
pushl main@GOT(%ebx)
# else
/* Avoid relocation in static PIE since _start is called before
it is relocated. This also avoid rely on linker optimization to
transform 'movl main@GOT(%ebx), %eax' to 'leal main@GOTOFF(%ebx)'
if main is defined locally. */
leal __wrap_main@GOTOFF(%ebx), %eax
pushl %eax
# endif
/* Call the user's main function, and exit with its value.
But let the libc call main. */
call __libc_start_main@PLT
#else
/* This used to be the addresses of .fini and .init. */
pushl $0
pushl $0
pushl %ecx /* Push second argument: argv. */
pushl %esi /* Push first argument: argc. */
pushl $main
/* Call the user's main function, and exit with its value.
But let the libc call main. */
call __libc_start_main
#endif
hlt /* Crash if somehow `exit' does return. */
#ifdef PIC
1: movl (%esp), %ebx
ret
#endif
#if defined PIC && !defined SHARED
__wrap_main:
jmp main@PLT
#endif
END (_start)
/* To fulfill the System V/i386 ABI we need this symbol. Yuck, it's so
meaningless since we don't support machines < 80386. */
.section .rodata
.globl _fp_hw
_fp_hw: .long 3
.size _fp_hw, 4
.type _fp_hw,@object
/* Define a symbol for the first piece of initialized data. */
.data
.globl __data_start
__data_start:
.long 0
.weak data_start
data_start = __data_start
E:\Projects\github\glibc\sysdeps\x86_64\start.S
ENTRY (_start)
/* Clearing frame pointer is insufficient, use CFI. */
cfi_undefined (rip)
/* Clear the frame pointer. The ABI suggests this be done, to mark
the outermost frame obviously. */
xorl %ebp, %ebp
/* Extract the arguments as encoded on the stack and set up
the arguments for __libc_start_main (int (*main) (int, char **, char **),
int argc, char *argv,
void (*init) (void), void (*fini) (void),
void (*rtld_fini) (void), void *stack_end).
The arguments are passed via registers and on the stack:
main: %rdi
argc: %rsi
argv: %rdx
init: %rcx
fini: %r8
rtld_fini: %r9
stack_end: stack. */
mov %RDX_LP, %R9_LP /* Address of the shared library termination
function. */
#ifdef __ILP32__
mov (%rsp), %esi /* Simulate popping 4-byte argument count. */
add $4, %esp
#else
popq %rsi /* Pop the argument count. */
#endif
/* argv starts just at the current stack top. */
mov %RSP_LP, %RDX_LP
/* Align the stack to a 16 byte boundary to follow the ABI. */
and $~15, %RSP_LP
/* Push garbage because we push 8 more bytes. */
pushq %rax
/* Provide the highest stack address to the user code (for stacks
which grow downwards). */
pushq %rsp
/* These used to be the addresses of .fini and .init. */
xorl %r8d, %r8d
xorl %ecx, %ecx
#ifdef PIC
mov main@GOTPCREL(%rip), %RDI_LP
#else
mov $main, %RDI_LP
#endif
/* Call the user's main function, and exit with its value.
But let the libc call main. Since __libc_start_main in
libc.so is called very early, lazy binding isn't relevant
here. Use indirect branch via GOT to avoid extra branch
to PLT slot. In case of static executable, ld in binutils
2.26 or above can convert indirect branch into direct
branch. */
call *__libc_start_main@GOTPCREL(%rip)
hlt /* Crash if somehow `exit' does return. */
END (_start)
E:\Projects\github\glibc\csu\libc-start.c
#ifdef LIBC_START_MAIN
# ifdef LIBC_START_DISABLE_INLINE
# define STATIC static
# else
# define STATIC static inline __attribute__ ((always_inline))
# endif
# define DO_DEFINE_LIBC_START_MAIN_VERSION 0
#else
# define STATIC
# define LIBC_START_MAIN __libc_start_main_impl
# define DO_DEFINE_LIBC_START_MAIN_VERSION 1
#endif
E:\Projects\github\glibc\csu\libc-start.c
#include <libc-start.h>
STATIC int LIBC_START_MAIN (int (*main) (int, char **, char **
MAIN_AUXVEC_DECL),
int argc,
char **argv,
#ifdef LIBC_START_MAIN_AUXVEC_ARG
ElfW(auxv_t) *auxvec,
#endif
__typeof (main) init,
void (*fini) (void),
void (*rtld_fini) (void),
void *stack_end)
__attribute__ ((noreturn));
E:\Projects\github\glibc\sysdeps\generic\libc_start_call_main.h
_Noreturn static __always_inline void
__libc_start_call_main (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
int argc, char **argv MAIN_AUXVEC_DECL)
{
exit (main (argc, argv, __environ MAIN_AUXVEC_PARAM));
}
MSVC CRT
同一个版本的MSVC CRT根据不同的属性提供了多种子版本,以供不同需求的开发者使用。按照静态/动态链接,可以分为静态版和动态版;按照单线程/多线程,可以分为单线程版和多线程版;按照调试/发布,可分为调试版和发布版;按照是否支持C++分为纯C运行库版和支持C++版;按照是否支持托管代码分为支持本地代码/托管代码和纯托管代码版。这
https://learn.microsoft.com/zh-cn/cpp/c-runtime-library/crt-library-features?view=msvc-170
11.3 运行库与多线程
11.3.1 CRT的多线程困扰
线程的访问权限
线程的访问能力非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址,然而这是很少见的情况),但实际运用中线程也拥有自己的私有存储空间,包括:
- 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。
- 线程局部存储(Thread Local Storage, TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的尺寸。
- 寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。
多线程运行库
这里我们所说的“多线程相关”主要有两个方面,
- 一方面是提供那些多线程操作的接口,比如创建线程、退出线程、设置线程优先级等函数接口;
- 另外一方面是C运行库本身要能够在多线程的环境下正确运行。
对于第一方面,主流的CRT都会有相应的功能。比如Windows下,MSVC CRT提供了诸如_beginthread()、_endthread()等函数用于线程的创建和退出;而Linux下,glibc也提供了一个可选的线程库pthread(POSIX Thread),它提供了诸如pthread_create()、pthread_exit()等函数用于线程的创建和退出。很明显,这些函数都不属于标准的运行库,它们都是平台相关的。
11.3.2 CRT改进
使用TLS
加锁
改进函数调用方式
11.3.3 线程局部存储实现
TLS的用法很简单,如果要定义一个全局变量为TLS类型的,只需要在它定义前加上相应的关键字即可。
对于GCC来说,这个关键字就是__thread,比如我们定义一个TLS的全局整型变量: __thread int number;
对于MSVC来说,相应的关键字为__declspec(thread): __declspec(thread) int number;
11.4 C++全局构造与析构
11.4.1 glibc全局构造与析构
E:\Projects\github\glibc\csu\libc-start.c
E:\Projects\github\gcc\libgcc\crtstuff.c
static void
call_init (int argc, char **argv, char **envp)
{
/* For static executables, preinit happens right before init. */
{
const size_t size = __preinit_array_end - __preinit_array_start;
size_t i;
for (i = 0; i < size; i++)
(*__preinit_array_start [i]) (argc, argv, envp);
}
# if ELF_INITFINI
_init ();
# endif
const size_t size = __init_array_end - __init_array_start;
for (size_t i = 0; i < size; i++)
(*__init_array_start [i]) (argc, argv, envp);
}
/* Likewise for the destructor. */
static void
call_fini (void *unused)
{
size_t i = __fini_array_end - __fini_array_start;
while (i-- > 0)
(*__fini_array_start [i]) ();
# if ELF_INITFINI
_fini ();
# endif
}
static void __attribute__((used))
__do_global_ctors_aux (void)
{
func_ptr *p;
for (p = __CTOR_END__ - 1; *p != (func_ptr) -1; p--)
(*p) ();
}
void
__do_global_dtors (void)
{
func_ptr *p, f;
for (p = __DTOR_LIST__ + 1; (f = *p); p++)
f ();
#if USE_TM_CLONE_REGISTRY
deregister_tm_clones ();
#endif /* USE_TM_CLONE_REGISTRY */
#ifdef USE_EH_FRAME_REGISTRY
if (__deregister_frame_info)
__deregister_frame_info (__EH_FRAME_BEGIN__);
#endif
}
11.4.2 MSVC CRT的全局构造和析构
D:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.43.34808\crt\src\vcruntime\exe_common.inl
D:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.43.34808\crt\src\vcruntime\internal_shared.h
//-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//
// "Special" Data
//
//-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
#ifndef _M_CEE
typedef void (__cdecl* _PVFV)(void);
typedef int (__cdecl* _PIFV)(void);
extern _CRTALLOC(".CRT$XIA") _PIFV __xi_a[]; // First C Initializer
extern _CRTALLOC(".CRT$XIZ") _PIFV __xi_z[]; // Last C Initializer
extern _CRTALLOC(".CRT$XCA") _PVFV __xc_a[]; // First C++ Initializer
extern _CRTALLOC(".CRT$XCZ") _PVFV __xc_z[]; // Last C++ Initializer
extern _CRTALLOC(".CRT$XPA") _PVFV __xp_a[]; // First Pre-Terminator
extern _CRTALLOC(".CRT$XPZ") _PVFV __xp_z[]; // Last Pre-Terminator
extern _CRTALLOC(".CRT$XTA") _PVFV __xt_a[]; // First Terminator
extern _CRTALLOC(".CRT$XTZ") _PVFV __xt_z[]; // Last Terminator
#endif
// This is the common main implementation to which all of the CRT main functions
// delegate (for executables; DLLs are handled separately).
static __forceinline int __cdecl __scrt_common_main()
{
// The /GS security cookie must be initialized before any exception handling
// targeting the current image is registered. No function using exception
// handling can be called in the current image until after this call:
__security_init_cookie();
return __scrt_common_main_seh();
}
void __clrcall _initterm (
_PVFV * pfbegin,
_PVFV * pfend
)
{
/*
* walk the table of function pointers from the bottom up, until
* the end is encountered. Do not skip the first entry. The initial
* value of pfbegin points to the first valid entry. Do not try to
* execute what pfend points to. Only entries before pfend are valid.
*/
while ( pfbegin < pfend )
{
/*
* if current table entry is non-NULL, call thru it.
*/
if ( *pfbegin != NULL )
(**pfbegin)();
++pfbegin;
}
}
static __declspec(noinline) int __cdecl __scrt_common_main_seh()
{
_initterm(__xc_a, __xc_z);
}
D:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.43.34808\crt\src\vcruntime\internal_shared.h
//-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//
// Section Attributes
//
//-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
#pragma section(".CRT$XCA", long, read) // First C++ Initializer
#pragma section(".CRT$XCAA", long, read) // Startup C++ Initializer
#pragma section(".CRT$XCZ", long, read) // Last C++ Initializer
#pragma section(".CRT$XDA", long, read) // First Dynamic TLS Initializer
#pragma section(".CRT$XDZ", long, read) // Last Dynamic TLS Initializer
#pragma section(".CRT$XIA", long, read) // First C Initializer
#pragma section(".CRT$XIAA", long, read) // Startup C Initializer
#pragma section(".CRT$XIAB", long, read) // PGO C Initializer
#pragma section(".CRT$XIAC", long, read) // Post-PGO C Initializer
#pragma section(".CRT$XIC", long, read) // CRT C Initializers
#pragma section(".CRT$XIYA", long, read) // VCCorLib Threading Model Initializer
#pragma section(".CRT$XIYAA", long, read) // XAML Designer Threading Model Override Initializer
#pragma section(".CRT$XIYB", long, read) // VCCorLib Main Initializer
#pragma section(".CRT$XIZ", long, read) // Last C Initializer
#pragma section(".CRT$XLA", long, read) // First Loader TLS Callback
#pragma section(".CRT$XLC", long, read) // CRT TLS Constructor
#pragma section(".CRT$XLD", long, read) // CRT TLS Terminator
#pragma section(".CRT$XLZ", long, read) // Last Loader TLS Callback
#pragma section(".CRT$XPA", long, read) // First Pre-Terminator
#pragma section(".CRT$XPB", long, read) // CRT ConcRT Pre-Terminator
#pragma section(".CRT$XPX", long, read) // CRT Pre-Terminators
#pragma section(".CRT$XPXA", long, read) // CRT stdio Pre-Terminator
#pragma section(".CRT$XPZ", long, read) // Last Pre-Terminator
#pragma section(".CRT$XTA", long, read) // First Terminator
#pragma section(".CRT$XTZ", long, read) // Last Terminator
#pragma section(".CRTMA$XCA", long, read) // First Managed C++ Initializer
#pragma section(".CRTMA$XCZ", long, read) // Last Managed C++ Initializer
#pragma section(".CRTVT$XCA", long, read) // First Managed VTable Initializer
#pragma section(".CRTVT$XCZ", long, read) // Last Managed VTable Initializer
#pragma section(".rdata$T", long, read)
#pragma section(".rtc$IAA", long, read) // First RTC Initializer
#pragma section(".rtc$IZZ", long, read) // Last RTC Initializer
#pragma section(".rtc$TAA", long, read) // First RTC Terminator
#pragma section(".rtc$TZZ", long, read) // Last RTC Terminator
#define _CRTALLOC(x) __declspec(allocate(x))
HelloWorld Hw;
00007FF6397E18B0 push rbp
00007FF6397E18B2 push rdi
00007FF6397E18B3 sub rsp,0E8h
00007FF6397E18BA lea rbp,[rsp+20h]
00007FF6397E18BF lea rcx,[__FE69D46F_ConsoleApplication2@cpp (07FF6397F6076h)]
00007FF6397E18C6 call __CheckForDebuggerJustMyCode (07FF6397E13EDh)
00007FF6397E18CB nop
00007FF6397E18CC lea rcx,[Hw (07FF6397F0204h)]
00007FF6397E18D3 call HelloWorld::HelloWorld (07FF6397E12B7h)
00007FF6397E18D8 nop
00007FF6397E18D9 lea rcx,[`dynamic atexit destructor for 'Hw'' (07FF6397EADC0h)]
00007FF6397E18E0 call atexit (07FF6397E119Fh)
00007FF6397E18E5 nop
00007FF6397E18E6 lea rsp,[rbp+0C8h]
00007FF6397E18ED pop rdi
00007FF6397E18EE pop rbp
00007FF6397E18EF ret
INL文件是什么 INL文件是用于存放内联函数定义的源文件。在C++编程中,内联函数通常在头文件中实现,但当内联函数较多时,头文件可能会变得过长和混乱。为了使头文件更加简洁,可以将内联函数的定义移到一个单独的INL文件中,然后在头文件的末尾使用#include指令将其包含进来
11.5 fread实现
11.5.1 缓冲
11.5.2 fread_s
11.5.4 _read
11.5.5 文本换行
11.6 本章小结
第12章 系统调用与API
主要介绍Linux和Windows的系统调用及Windows 的API。
12.1 系统调用介绍
12.1.1 什么是系统调用
为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用。这些接口往往通过中断来实现,比如Linux使用0x80号中断作为系统调用的入口,Windows采用0x2E号中断作为系统调用入口。
12.1.2 Linux系统调用
下面让我们来看看Linux系统调用的定义,已有一个比较直观的概念。在x86下,系统调用由0x80中断完成,各个通用寄存器用于传递参数,EAX寄存器用于表示系统调用的接口号,比如EAX = 1表示退出进程(exit);EAX = 2表示创建进程(fork);EAX = 3表示读取文件或IO(read);EAX = 4表示写文件或IO(write)等,每个系统调用都对应于内核源代码中的一个函数,它们都是以“sys_”开头的,比如exit调用对应内核中的sys_exit函数。当系统调用返回时,EAX又作为调用结果的返回值。
12.1.3 系统调用的弊端
事实上,包括Linux,大部分操作系统的系统调用都有两个特点:
- 使用不便。操作系统提供的系统调用接口往往过于原始,程序员须要了解很多与操作系统相关的细节。如果没有进行很好的包装,使用起来不方便。
- 各个操作系统之间系统调用不兼容。首先Windows系统和Linux系统之间的系统调用就基本上完全不同。
“解决计算机的问题可以通过增加层来实现”,于是运行库挺身而出,它作为系统调用与程序之间的一个抽象层可以保持着这样的特点:
- 使用简便。因为运行库本身就是语言级别的,它一般都设计相对比较友好。
- 形式统一。运行库有它的标准,叫做标准库,凡是所有遵循这个标准的运行库理论上都是相互兼容的,不会随着操作系统或编译器的变化而变化。
运行时库将不同的操作系统的系统调用包装为统一固定的接口,使得同样的代码,在不同的操作系统下都可以直接编译,并产生一致的效果。 这就是源代码级上的可移植性。
12.2 系统调用原理
12.2.1 特权级与中断
现代的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有两种特权级别,分别为用户模式(User Mode)和内核模式(Kernel Mode),也被称为用户态和内核态。
操作系统一般是通过中断(Interrupt)来从用户态切换到内核态。
什么是中断呢?中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。
中断一般具有两个属性,一个称为中断号(从0开始),一个称为中断处理程序(Interrupt Service Routine, ISR)。
在内核中,有一个数组称为中断向量表(Interrupt Vector Table),这个数组的第n项包含了指向第n号中断的中断处理程序的指针。
当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。
通常意义上,中断有两种类型、
- 一种称为硬件中断,这种中断来自于硬件的异常或其他事件的发生,如电源掉电、键盘被按下等。
- 另一种称为软件中断,软件中断通常是一条指令(i386下是int),带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行其中断处理程序。
- 例如在i386下,int 0x80这条指令会调用第0x80号中断的处理程序。
由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于用一个或少数几个中断号来对应所有的系统调用。
如,i386下Windows里绝大多数系统调用都是由int 0x2e来触发的,而Linux则使用int 0x80来触发所有的系统调用。
以Linux的int 0x80为例,系统调用号是由eax来传入的。用户将系统调用号放入eax,然后使用int 0x80调用中断,中断服务程序就可以从eax里取得系统调用号,进而调用对应的函数。
12.2.2 基于int的Linux的经典系统调用实现
- 触发中断
#ifndef __NR_fork
#define __NR_fork 2
#endif
/*
* pid_t fork(void);
*/
#ifndef sys_fork
static __attribute__((unused))
pid_t sys_fork(void)
{
#ifdef __NR_clone
/* note: some archs only have clone() and not fork(). Different archs
* have a different API, but most archs have the flags on first arg and
* will not use the rest with no other flag.
*/
return my_syscall5(__NR_clone, SIGCHLD, 0, 0, 0, 0);
#elif defined(__NR_fork)
return my_syscall0(__NR_fork);
#else
return __nolibc_enosys(__func__);
#endif
}
#endif
static __attribute__((unused))
pid_t fork(void)
{
return __sysret(sys_fork());
}
#define my_syscall0(num) \
({ \
long _ret; \
register long _num __asm__ ("eax") = (num); \
\
__asm__ volatile ( \
"int $0x80\n" \
: "=a" (_ret) \
: "0"(_num) \
: "memory", "cc" \
); \
_ret; \
})
/* Syscall return helper: takes the syscall value in argument and checks for an
* error in it. This may only be used with signed returns (int or long), but
* not with pointers. An error is any value < 0. When an error is encountered,
* -ret is set into errno and -1 is returned. Otherwise the returned value is
* passed as-is with its type preserved.
*/
#define __sysret(arg) \
({ \
__typeof__(arg) __sysret_arg = (arg); \
(__sysret_arg < 0) /* error ? */ \
? (({ SET_ERRNO(-__sysret_arg); }), -1) /* ret -1 with errno = -arg */ \
: __sysret_arg; /* return original value */ \
})
#ifndef NOLIBC_IGNORE_ERRNO
#define SET_ERRNO(v) do { errno = (v); } while (0)
int errno __attribute__((weak));
#else
#define SET_ERRNO(v) do { } while (0)
#endif
- 切换堆栈
当0x80号中断发生的时候,CPU除了切入内核态之外,还会自动完成下列几件事:
(1)找到当前进程的内核栈(每一个进程都有自己的内核栈)。
(2)在内核栈中依次压入用户态的寄存器SS、ESP、EFLAGS、CS、EIP。
而当内核从系统调用中返回的时候,须要调用iret指令来回到用户态,iret指令则会从内核栈里弹出寄存器SS、ESP、EFLAGS、CS、EIP的值,使得栈恢复到用户态的状态。
- 中断处理程序
12.2.3 Linux的新型系统调用机制
12.3 Windows API
API的全称为Application Programming Interface,即应用程序编程接口。因此API不是一个专门的事物,而是一系列事物的总称。但是我们通常在Windows下提到API时,一般就是指Windows 系统提供给应用程序的接口,即Windows API。
12.3.1 Windows API概览
Windows API是以DLL导出函数的形式暴露给应用程序开发者的。它被包含在诸多的系统DLL内,规模上非常庞大,所有的导出函数大约有数千个(以Windows XP为例)。微软把这些Windows API DLL导出函数的声明的头文件、导出库、相关文件和工具一起提供给开发者,并让它们成为Software Development Kit(SDK)。
12.3.2 为什么要使用Windows API
Windows API以DLL导出函数的形式存在也自然是水到渠成,我们知道DLL作为Windows系统的最基本的模块组织形式,它有着良好的接口定义和灵活的组合方式。DLL基本上是Windows系统上很多高级接口和程序设计方法的基石,包括内核与驱动程序、COM、OLE、ActiveX等都是基于DLL技术的
12.4 本章小结
第13章 运行库实现
本章主要实现了一个支持堆、基本文件操作、格式化字符串、基本输入输出、C++ new/delete、C++ string、C++全局构造和析构的Mini CRT。”
兼容x86指令集的32位CPU的个人计算机,CPU的核心频率是指什么?
在兼容 x86 指令集的 32 位 CPU 个人计算机中,CPU 核心频率(Core Clock Frequency)是衡量 CPU 运算速度的核心物理指标,它直接反映了 CPU 内部时钟信号的周期性振荡速率。以下是分层次的解析:
一、核心频率的本质
1. 定义
- 时钟周期:CPU 的所有操作(如取指令、执行运算、读写内存)均以 时钟周期(Clock Cycle)为基本时间单位进行同步。
- 核心频率:即 CPU 的时钟信号频率(单位:Hz),表示每秒完成的时钟周期数。例如:
- 1 GHz = 每秒 10 亿次时钟周期。
- 若某指令需 4 个周期完成,则在 3 GHz CPU 上每秒可执行约 7.5 亿次该指令(3×10⁹ / 4)。
2. 硬件实现
- 晶体振荡器:CPU 内部或主板上的晶振生成基准时钟信号。
- 倍频技术:现代 CPU 通过 倍频器(Multiplier)将外部总线频率(如 100 MHz)倍增到更高核心频率(如 3 GHz = 100 MHz × 30)。
二、核心频率的作用
1. 性能基准
- 单线程性能:在相同架构下,频率越高,单位时间内完成的指令越多。
- 例如:同代 x86 CPU(如 Intel Pentium 4),3.8 GHz 的版本比 3.0 GHz 快约 26%。
- 流水线效率:高频 CPU 可更快推进流水线阶段(取指、解码、执行等),但需平衡流水线深度与效率。
2. 物理限制
- 散热与功耗:频率提升会导致发热量指数级增长(公式:
P ∝ f × V²
),受制于芯片制程和散热能力。- 典型例子:早期 Pentium 4(NetBurst 架构)追求高频(>3 GHz),但发热过高导致性能瓶颈。
三、x86 32 位 CPU 的典型频率
CPU 型号 | 核心频率范围 | 技术背景 |
---|---|---|
Intel 80386 | 12–40 MHz | 初代 32 位 x86 CPU(1985 年) |
Intel Pentium III | 450 MHz–1.4 GHz | 末代纯 32 位主流 CPU(1999 年) |
AMD Athlon XP | 1.4–2.2 GHz | 高频竞争时代(2001–2003 年) |
四、核心频率的误区
1. 频率≠实际性能
- 架构效率:现代 CPU(如 ARM Cortex-A)可能以 2 GHz 超越旧 x86 的 3 GHz,因指令集和微架构优化。
- 例如:Intel Pentium 4(3.8 GHz)的实际性能低于 Core 2 Duo(2.4 GHz)。
- 并行化:多核、超线程(Hyper-Threading)通过并行提升吞吐量,而非依赖单核高频。
2. 超频(Overclocking)
- 手动提升频率:通过 BIOS 或软件强制提高倍频/外频,突破厂商设定。
- 风险:过热、电压不稳导致硬件损坏(如电容爆浆)。
- 典型场景:游戏玩家将 Core i7 从 3.6 GHz 超至 5.0 GHz。
五、技术影响
1. 软件优化
- 实时系统:工业控制设备依赖固定频率保证时序确定性。
- 节能调频:现代 OS(如 Windows)通过动态调频(DVFS)平衡性能与功耗。
2. 硬件设计
- 制程工艺:7nm 制程比 14nm 制程可在同频下降低功耗 40% 以上。
- 频率墙:物理极限(量子隧穿效应)使硅基 CPU 频率难以突破 5-6 GHz。
六、总结
- 核心频率是 CPU 的“心跳”,决定了基础运算节奏,但性能需结合架构、缓存、并行度等综合评估。
- 在 x86 32 位 CPU 时代,高频曾是性能竞争的核心指标,而现代计算更依赖多核、能效比和异构计算(如 GPU 加速)。
理解核心频率的物理意义和局限性,有助于合理选择硬件(如高频 CPU 适合单线程任务,多核 CPU 适合并行负载)并优化软件开发。
Linux系统架构图和Windows系统架构图
以下是 Linux 和 Windows 系统架构的对比及简化架构图描述。由于无法直接绘制图像,我将通过文字详细说明两者的架构层次和核心差异。
1. Linux 系统架构图(分层模型)
+-----------------------+
| 用户空间 |
| 应用程序 (Apps) | → 用户进程(如浏览器、文本编辑器等)
|-----------------------|
| 系统工具/Shell | → Bash、Python脚本等
|-----------------------|
| 系统库 (Glibc, libc) | → C库、数学库、线程库等
|-----------------------|
| 系统调用接口 (Syscall) | → 用户态与内核态的分界线
+-----------------------+
| 内核空间 |
|-----------------------|
| 内核子系统 |
| - 进程管理 | → 进程调度、信号处理
| - 内存管理 | → 虚拟内存、分页机制
| - 文件系统 | → Ext4、Btrfs、虚拟文件系统(VFS)
| - 网络堆栈 | → TCP/IP协议栈、套接字
| - 设备驱动 | → 硬件抽象层(HAL)
|-----------------------|
| 硬件抽象层 (HAL) | → 直接与硬件交互
+-----------------------+
| 硬件 (CPU、内存、I/O) |
+-----------------------+
关键特性:
- 单体内核(Monolithic Kernel):核心功能(进程、内存、文件系统等)直接运行在内核空间。
- 模块化驱动:驱动程序可以动态加载/卸载(
.ko
文件)。 - 权限隔离:用户进程通过系统调用访问内核功能,受权限控制(如
root
vs 普通用户)。
2. Windows 系统架构图(混合模型)
+-----------------------+
| 用户空间 |
| 应用程序 | → Win32应用、UWP应用、子系统(WSL/POSIX)
|-----------------------|
| 子系统环境 |
| - Win32子系统 | → 传统Windows程序入口
| - 其他子系统 | → WSL(Linux兼容层)、Hyper-V等
|-----------------------|
| 系统库 (DLL) | → Kernel32.dll、User32.dll、NTDLL.dll等
|-----------------------|
| 系统调用接口 (WinAPI) | → 用户态与内核态的分界线
+-----------------------+
| 内核空间 |
|-----------------------|
| 执行体 (Executive) |
| - 对象管理器 | → 管理内核对象(进程、线程、事件)
| - 内存管理器 | → 虚拟内存、分页文件
| - I/O管理器 | → 设备驱动、文件系统(NTFS)
| - 安全监视器 | → 访问控制(ACL、令牌)
| - 进程管理器 | → 进程/线程调度
|-----------------------|
| 微内核 (Kernel) | → 核心功能(线程调度、中断处理)
|-----------------------|
| 硬件抽象层 (HAL) | → 硬件差异屏蔽层
+-----------------------+
| 硬件 (CPU、内存、I/O) |
+-----------------------+
关键特性:
- 混合内核(Hybrid Kernel):核心功能分为“执行体”和微内核,部分模块化。
- 子系统兼容性:通过 Win32 子系统支持传统应用,通过 WSL 兼容 Linux。
- 安全模型:强制完整性控制(MIC)、用户账户控制(UAC)等。
3. 核心差异对比
特性 | Linux | Windows |
---|---|---|
内核类型 | 单体内核 | 混合内核 |
权限管理 | 基于用户/组(POSIX) | 访问控制列表(ACL) |
驱动模型 | 内核模块动态加载 | 驱动程序需签名认证 |
文件系统 | Ext4/XFS/Btrfs 等 | NTFS/ReFS |
网络堆栈 | 开源协议栈(TCP/IP) | 私有协议栈(NDIS) |
设计哲学 | 模块化、可定制 | 兼容性、商业生态集成 |
4. 典型应用场景
- Linux:服务器、嵌入式系统、高性能计算、开发环境。
- Windows:桌面办公、企业应用、游戏、商业软件生态。
如果需要更具体的细节(例如内核模块交互、子系统工作原理),可以进一步探讨!
原因在于早在几十年前,CPU就发展出了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。同样,编译器在进行优化的时候,也可能为了效率而交换毫不相干的两条相邻指令(如x=1和r1=y)的执行顺序。
你提到的内容涉及计算机体系结构中的两个关键优化技术:CPU动态指令调度(乱序执行)和编译器指令重排序。这些优化在单线程环境下是安全的,但在多线程并发场景中可能引发内存可见性问题,需要开发者显式处理。以下从原理、影响及解决方案三个维度展开分析:
一、指令重排序的本质与动机
1. CPU动态调度(乱序执行)
- 目标:最大化利用流水线,减少空闲时钟周期。
- 机制:
- 流水线技术:将指令分解为取指、解码、执行、访存、写回等阶段,并行处理不同阶段。
- 乱序执行(OoOE):当某条指令因数据依赖或资源冲突无法立即执行时,CPU动态调度后续独立指令提前执行。
- 重排序缓冲区(ROB):记录指令间的依赖关系,确保乱序执行的最终结果与程序顺序一致。
- 示例:
; 原始顺序 ; 实际执行顺序(假设LOAD y较慢) MOV [x], 1 → MOV R1, [y] MOV R1, [y] → MOV [x], 1
2. 编译器指令重排
- 目标:减少指令级并行(ILP)限制,优化寄存器分配和缓存局部性。
- 机制:
- 编译器优化阶段:在生成机器码前,对中间表示(IR)进行指令重排。
- 依赖分析:仅对无数据依赖的指令重排(如
x=1
和r1=y
若无关则可交换)。
- 示例:
// 源代码 // 优化后代码 x = 1; → r1 = y; r1 = y; → x = 1;
二、多线程环境下的风险
1. 内存可见性问题
- 现象:线程A写入变量
x
后读取y
,线程B可能先观察到y
的读取结果,后观察到x
的写入。 - 示例:
// 线程A // 线程B x = 1; → if (x == 1) { r1 = y; // 可能看到x=1但r1仍为旧值y }
2. 违反直觉的执行顺序
- 案例:单例模式的双重检查锁定(DCLP)失效:
if (instance == null) { // 第一次检查 synchronized (lock) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 可能因重排序导致其他线程访问到未初始化对象 } } }
三、解决方案:内存屏障与内存模型
1. 内存屏障(Memory Barrier)
- 作用:禁止特定类型的指令重排,确保内存操作的可见性。
- 类型:
- 写屏障(Store Barrier):确保屏障前的所有写操作在屏障后的写操作之前完成。
- 读屏障(Load Barrier):确保屏障后的读操作在屏障前的读操作之后执行。
- 全屏障(Full Barrier):同时约束读写操作顺序。
2. 高级语言中的同步机制
C/C++:
volatile
关键字(有限作用,不保证原子性):volatile int x; // 禁止编译器重排对x的访问,但CPU仍可能乱序执行
- 原子操作与内存序(C11/C++11):
std::atomic<int> x; x.store(1, std::memory_order_release); // 写操作,禁止重排到后续操作之后
- 内存屏障指令(平台相关):
__asm__ __volatile__("mfence" ::: "memory"); // x86全屏障
Java:
volatile
变量:禁止指令重排,保证可见性。synchronized
块:隐式插入屏障,确保锁内操作的顺序。java.util.concurrent
原子类:基于CAS实现无锁同步。
3. 内存模型(Memory Model)
- 强顺序模型(如x86 TSO):
- 保证写操作的全局顺序,但仍需屏障处理StoreLoad重排序。
- 弱顺序模型(如ARM/Power):
- 允许更多类型的重排,需显式使用屏障指令。
四、开发者应对策略
- 避免过度优化:在并发代码中谨慎使用低层级优化。
- 依赖高级抽象:优先使用线程安全的数据结构(如
ConcurrentHashMap
)。 - 理解语言内存模型:明确不同同步原语的语义(如C++的
memory_order
)。 - 测试与验证:通过工具(如TSan、JConsole)检测数据竞争和内存问题。
五、示例:DCLP问题的修正
// 修正后的双重检查锁定(Java)
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // volatile禁止初始化与对象引用的重排序
}
}
}
return instance;
}
总结
指令重排序是计算机系统提升性能的核心手段,但在多线程编程中需通过内存屏障、原子操作和同步机制约束其影响。理解底层原理与高层抽象的结合,是编写高效、正确并发代码的关键。
字节序(Byte Order)
在不同的计算机体系结构中,对于数据(比特、字节、字)等的存储和传输机制有所不同,因而引发了计算机领域中一个潜在但是又很重要的问题,即通信双方交流的信息单元应该以什么样的顺序进行传送。如果达不成一致的规则,计算机的通信与存储将会无法进行。目前在各种体系的计算机中通常采用的字节存储机制主要有两种:大端(Big-endian)和小端(Little-endian)。
首先让我们来定义两个概念:
MSB是Most Significant Bit/Byte的首字母缩写,通常译为最重要的位或最重要的字节。它通常用来表明在一个bit序列(如一个byte是8个bit组成的一个序列)或一个byte序列(如word是两个byte组成的一个序列)中对整个序列取值影响最大的那个bit/byte。
LSB是Least Significant Bit/Byte的首字母缩写,通常译为最不重要的位或最不重要的字节。它通常用来表明在一个bit序列(如一个byte是8个bit组成的一个序列)或一个byte序列(如word是两个byte组成的一个序列)中对整个序列取值影响最小的那个bit/byte。
比如一个十六进制的整数0x12345678里面:
0x12就是MSB(Most Significant Byte),0x78就是LSB(Least Significant Byte)。而对于0x78这个字节而言,它的二进制是01111000,那么最左边的那个0就是MSB(Most Significant Bit),最右边的那个0就是LSB(Least Significant)。
Big-endian 和 little-endian的区别就是bit-endian规定MSB在存储时放在低地址,在传输时MSB放在流的开始;LSB存储时放在高地址,在传输时放在流的末尾。little-endian则相反。
Little-Endian主要用于我们现在的PC的CPU中,即Intel的x86系列兼容机;Big-Endian则主要应用在目前的Mac机器中,一般指PowerPC系列处理器。
另外值得一提的是,目前的TCP/IP网络及Java虚拟机的字节序都是Big-endian的。这意味着如果通过网络传输0x12345678这个整形变量,首先被发送的应该是0x12,接着是0x34,然后是0x56,最后是0x78。所以我们的程序在处理网络流的时候,必须注意字节序的问题。
字节序
字节序(Byte Order)是指在存储或传输多字节数据时,字节的排列顺序。它决定了数据的高位字节和低位字节在内存中的存放位置。字节序主要分为两种:大端序(Big-Endian)和小端序(Little-Endian)。以下是它们的区别及实际应用中的区分方法:
一、字节序的定义
1. 大端序(Big-Endian)
- 高位字节在前,低位字节在后。
- 数据的高位字节(Most Significant Byte, MSB)存储在低地址,低位字节(Least Significant Byte, LSB)存储在高地址。
- 示例:
32 位整数0x12345678
的存储方式(地址从低到高):0x12
|0x34
|0x56
|0x78
2. 小端序(Little-Endian)
- 低位字节在前,高位字节在后。
- 数据的低位字节(LSB)存储在低地址,高位字节(MSB)存储在高地址。
- 示例:
32 位整数0x12345678
的存储方式(地址从低到高):0x78
|0x56
|0x34
|0x12
二、字节序的常见应用场景
场景 | 常用字节序 | 说明 |
---|---|---|
Intel/AMD x86 CPU | 小端序 | 现代个人计算机的主流架构。 |
网络传输(TCP/IP) | 大端序(网络字节序) | 统一使用大端序确保跨平台兼容性,需用 htonl /ntohl 转换。 |
ARM CPU | 可配置(通常小端序) | 默认小端序,但支持切换为大端序。 |
Java 虚拟机 | 大端序 | 数据存储和网络通信均采用大端序。 |
三、如何区分字节序?
方法 1:编程检测(C 语言示例)
通过检查多字节数据在内存中的存储顺序:
#include <stdio.h>
int main() {
int num = 0x12345678;
unsigned char *byte = (unsigned char *)#
printf("字节序测试(0x%x):\n", num);
for (int i = 0; i < sizeof(num); i++) {
printf("地址 %p: 0x%02x\n", &byte[i], byte[i]);
}
// 判断逻辑
if (byte[0] == 0x78) {
printf("小端序(Little-Endian)\n");
} else if (byte[0] == 0x12) {
printf("大端序(Big-Endian)\n");
}
return 0;
}
输出示例(小端序系统):
字节序测试(0x12345678):
地址 0x7ffeefbcd5cc: 0x78
地址 0x7ffeefbcd5cd: 0x56
地址 0x7ffeefbcd5ce: 0x34
地址 0x7ffeefbcd5cf: 0x12
小端序(Little-Endian)
方法 2:使用联合体(Union)
利用联合体共享内存的特性:
#include <stdio.h>
union EndianTest {
int num;
char bytes[4];
};
int main() {
union EndianTest test;
test.num = 0x12345678;
if (test.bytes[0] == 0x78) {
printf("小端序\n");
} else {
printf("大端序\n");
}
return 0;
}
四、字节序的实际影响
文件与网络通信
跨平台传输数据时,需统一字节序(如网络协议使用大端序)。
工具函数:htonl()
/htons()
:将主机字节序转为网络字节序(大端序)。ntohl()
/ntohs()
:将网络字节序转回主机字节序。
二进制文件解析
读取二进制文件(如图像、音频)时,需按文件的字节序解析数据。硬件寄存器操作
嵌入式开发中,外设寄存器可能需要特定字节序的读写。
五、总结
- 大端序:人类可读性强(类似书写顺序),多用于网络协议和部分处理器(如 PowerPC)。
- 小端序:硬件实现简单,适合逐字节处理(如 Intel/AMD CPU)。
- 区分方法:通过编程检查内存布局或使用联合体快速判断。
- 应对策略:
- 网络通信时强制使用大端序。
- 跨平台数据传输时显式声明字节序。
- 使用标准库函数(如
memcpy
)避免直接操作字节。
理解字节序是处理底层数据、网络协议和跨平台兼容性的基础,尤其在逆向工程和嵌入式开发中至关重要。
列举ELF文件中的常用段
ELF(Executable and Linkable Format)文件是Unix/Linux系统中可执行文件、共享库和目标代码的标准格式。其核心结构由多个段(Sections)组成,每个段承载特定类型的数据。以下是ELF文件中常用段及其作用的详细分类说明:
一、代码与数据段
段名 | 存储内容 | 权限 | 典型场景 |
---|---|---|---|
.text | 可执行代码(机器指令) | r-x | 函数实现、程序逻辑 |
.data | 已初始化的全局变量/静态变量 | rw- | 全局计数器、配置参数 |
.bss | 未初始化的全局变量/静态变量 | rw- | 缓冲区、动态分配前的占位空间 |
.rodata | 只读数据(常量字符串、const全局变量) | r-- | 错误消息、格式化字符串 |
示例:
.text
:存放main()
函数的指令序列。.data
:int initialized_var = 42;
。.bss
:char buffer[1024];
(未显式初始化)。.rodata
:const char* GREETING = "Hello";
。
二、符号与链接信息段
段名 | 作用 | 工具依赖 |
---|---|---|
.symtab | 符号表(函数/变量名、地址、类型) | readelf -s |
.strtab | 符号名称字符串表(供.symtab使用) | objdump -x |
.dynsym | 动态链接符号表(导出/导入符号) | ldd |
.dynamic | 动态链接信息(依赖库、PLT/GOT地址) | readelf -d |
.rel.text | 代码段重定位信息(地址修正) | 链接器内部使用 |
.rel.data | 数据段重定位信息(地址修正) | 链接器内部使用 |
示例:
.symtab
:记录printf
的函数地址和类型。.dynamic
:列出依赖的共享库(如libc.so.6
)。
三、动态链接相关段
段名 | 功能描述 | 内存映射 |
---|---|---|
.plt | 过程链接表(Procedure Linkage Table) | 延迟绑定跳板 |
.got | 全局偏移表(Global Offset Table) | 动态地址重定向 |
.got.plt | PLT专用的GOT(存储动态库函数实际地址) | 配合PLT使用 |
动态链接流程:
- 调用
printf@plt
→ 2. 跳转至.got.plt
中的地址 → 3. 首次调用触发动态解析 → 4. 更新.got.plt
为真实地址。
四、调试与元信息段
段名 | 用途 | 工具示例 |
---|---|---|
.debug | DWARF格式调试信息(行号、变量类型) | gdb |
.line | 源码行号与机器码的映射 | addr2line |
.comment | 编译器/链接器版本信息 | readelf -p |
.note | 系统或工具特定的元数据(如ABI版本) | 内核加载器使用 |
示例:
.debug
:允许GDB显示变量值和源码上下文。.comment
:记录GCC: (Ubuntu 11.4.0) 11.4.0
。
五、初始化与终止段
段名 | 执行时机 | 内容示例 |
---|---|---|
.init | 程序启动时(main之前) | 全局构造函数、C++静态初始化 |
.fini | 程序退出时(main之后) | 全局析构函数、资源清理 |
.ctors | C++全局对象构造函数表 | __attribute__((constructor)) |
.dtors | C++全局对象析构函数表 | __attribute__((destructor)) |
执行顺序:
.init
→ 2.main()
→ 3..fini
。
六、其他实用段
段名 | 功能 | 查看命令 |
---|---|---|
.eh_frame | 异常处理帧信息(用于栈展开) | readelf -wf |
.interp | 动态链接器路径(如 /lib64/ld-linux-x86-64.so.2 ) | readelf -p .interp |
.hash | 符号哈希表(加速符号查找) | 链接优化 |
七、工具操作示例
- 查看所有段头信息:
readelf -S a.out
- 提取特定段内容:
objcopy --dump-section .text=text.bin a.out
- 分析动态链接信息:
ldd a.out # 显示依赖库 readelf -d a.out | grep NEEDED # 直接查看.dynamic段
总结
- 核心段:
.text
(代码)、.data
(初始化数据)、.bss
(未初始化数据)构成程序基础。 - 动态链接:
.plt
、.got
、.dynamic
支撑共享库的高效加载。 - 调试与元数据:
.debug
、.symtab
助力逆向工程和问题排查。
通过理解ELF段结构,可深入掌握程序的内存布局、链接过程及性能优化(如通过 -ffunction-sections
减少代码体积)。
simplesection.o
readelf -h simplesection.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 1048 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 14
Section header string table index: 13
readelf -S simplesection.o
There are 14 section headers, starting at offset 0x418:
Section Headers:
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040 0000000000000062 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 000002f8 0000000000000078 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 000000a4 0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000ac 0000000000000008 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000ac 0000000000000004 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000b0 000000000000002c 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000dc 0000000000000000 0000000000000000 0 0 1
[ 8] .note.gnu.pr[...] NOTE 0000000000000000 000000e0 0000000000000020 0000000000000000 A 0 0 8
[ 9] .eh_frame PROGBITS 0000000000000000 00000100 000000000000058 0000000000000000 A 0 0 8
[10] .rela.eh_frame RELA 0000000000000000 00000370 0000000000000030 0000000000000018 I 11 9 8
[11] .symtab SYMTAB 0000000000000000 00000158 0000000000000138 0000000000000018 12 8 8
[12] .strtab STRTAB 0000000000000000 00000290 0000000000000061 0000000000000000 0 0 1
[13] .shstrtab STRTAB 0000000000000000 000003a0 0000000000000074 0000000000000000 0 0 1
objdump -h simplesection.o
simplesection.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000062 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 000000a4 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000008 0000000000000000 0000000000000000 000000ac 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000ac 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002c 0000000000000000 0000000000000000 000000b0 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000dc 2**0
CONTENTS, READONLY
6 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .eh_frame 00000058 0000000000000000 0000000000000000 00000100 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
上面是ubuntu 64位操作系统的目标文件和链接文件,帮我详细解释里面的汇编命令。
以下是针对你提供的目标文件和可执行文件汇编代码的详细解析,我会从关键指令、内存操作和链接过程三个维度进行分析:
一、核心代码段分析(.text
)
1. a.o (未链接)
0000000000000000 <main>:
c: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp) ; 局部变量 int a = 100(0x64)
13: 48 8d 45 fc lea -0x4(%rbp),%rax ; 将a的地址存入rax
17: 48 8d 15 00 00 00 00 lea 0x0(%rip),%rdx ; 加载shared地址(未解析,占位0)
24: e8 00 00 00 00 call 2e <main+0x2e> ; 调用swap(未解析,占位0)
2. b.o (未链接)
0000000000000000 <swap>:
10: 48 8b 45 f8 mov -0x8(%rbp),%rax ; 取第一个参数指针(int *x)
14: 8b 10 mov (%rax),%edx ; 读取x的值到edx
16: 48 8b 45 f0 mov -0x10(%rbp),%rax ; 取第二个参数指针(int *y)
1a: 8b 00 mov (%rax),%eax ; 读取y的值到eax
1c: 31 c2 xor %eax,%edx ; edx = x ^ y
22: 89 10 mov %edx,(%rax) ; *x = x ^ y(异或交换法)
3. ab (已链接)
0000000000401000 <main>:
401017: 48 8d 15 e2 2f 00 00 lea 0x2fe2(%rip),%rdx ; shared地址 = rip+0x2fe2 → 0x404000
401029: e8 07 00 00 00 call 401035 <swap> ; 调用swap(地址已解析)
二、关键指令详解
1. 栈帧管理
push %rbp ; 保存旧的栈基址
mov %rsp,%rbp ; 新栈基址 = 当前栈顶
sub $0x10,%rsp ; 在栈上分配 16 字节空间(局部变量)
leave ; 等价于 mov %rbp,%rsp; pop %rbp(清理栈帧)
2. 数据存取
movl $0x64,-0x4(%rbp)
将立即数 100 存入rbp-4
地址(4字节,对应局部变量int a
)lea -0x4(%rbp),%rax
计算rbp-4
的有效地址(即变量a
的地址),存入rax
3. 函数调用
call 401035 <swap>
跳转到地址0x401035
(swap函数入口),同时将返回地址(0x40102e
)压栈
4. 异或交换算法
; 输入:x在edx,y在eax
xor %eax,%edx ; edx = x ^ y
mov %edx,(%rax) ; *x = x ^ y(此时 *x 存的是 x^y,*y 仍为原始值)
xor %eax,%edx ; edx = (x^y) ^ y = x
mov %edx,(%rax) ; *y = x
xor %eax,%edx ; edx = x ^ x = 0(此代码块可能有冗余)
三、重定位与符号解析
1. 重定位类型
- R_X86_64_PC32
用于shared
变量的引用,计算方式:目标地址 - 下一条指令地址
- R_X86_64_PLT32
用于swap
函数调用,PLT(Procedure Linkage Table)实现延迟绑定
2. 符号表解析
Symbol table '.symtab' contains 6 entries:
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
UND
表示swap
在 a.o 中是未定义符号,需在链接时解析- 在最终可执行文件
ab
中,swap
被定位到0x401035
3. 地址计算示例
lea 0x2fe2(%rip),%rdx ; 运行时 rip = 0x40101e → 0x40101e + 0x2fe2 + 7 = 0x404000
rip
相对寻址实现位置无关代码(PIC)shared
变量被分配到数据段地址0x404000
四、内存布局总结
地址范围 | 内容 | 说明 |
---|---|---|
0x401000 | main函数代码 | .text 段起始 |
0x401035 | swap函数代码 | 紧接 main 函数 |
0x404000 | shared变量 | .data 或 .bss 段 |
五、完整执行流程
main函数
- 在栈上分配空间 → 初始化局部变量
a = 100
- 获取
a
和shared
的地址作为参数 - 调用
swap
函数
- 在栈上分配空间 → 初始化局部变量
swap函数
- 通过三次异或操作交换
*x
和*y
的值 - 返回 main 函数
- 通过三次异或操作交换
退出
- main 返回 0(
mov $0x0,%eax
) - 恢复栈帧并退出(
leave; ret
)
- main 返回 0(
六、调试技巧
- 查看内存内容
gdb -batch -ex "x/xw 0x404000" ab # 查看shared变量值
- 单步跟踪
gdb ab (gdb) break main (gdb) run (gdb) stepi
- 反汇编验证
objdump -d -M intel ab | grep -A20 "<main>:"
掌握这些细节后,你可以更深入理解编译链接过程、函数调用约定和底层内存操作机制。
链接器提供的默认链接规则是指什么? 有什么作用?
好的,我现在需要理解链接器提供的默认链接规则是什么以及它们的作用。首先,链接器是做什么的?链接器的主要任务是将多个目标文件(.o文件)和库文件合并成一个可执行文件或共享库。在链接过程中,链接器需要处理符号解析、地址分配、重定位等任务。
用户的问题是关于“默认链接规则”,也就是链接器在没有用户指定特定指令的情况下,按照哪些规则来链接目标文件。这些规则的作用是什么?
首先,我应该回忆一下链接过程的基本步骤。链接器需要将各个目标文件中的段(如.text、.data、.bss)合并,解析各个目标文件中的符号引用,确定每个符号的最终地址,处理重定位条目,解决符号之间的依赖关系,最终生成可执行文件或共享库。
默认链接规则可能包括如何处理这些段,如何解析未定义的符号,如何处理库文件的链接顺序,如何处理重复符号等等。例如,链接器可能会按照输入文件在命令行中的顺序来处理符号解析,先出现的文件中的符号可能会覆盖后面的?或者可能有一些默认的库搜索路径?
另外,需要考虑不同操作系统和平台下的链接器可能有不同的默认规则。例如,GNU的ld链接器和Windows的链接器可能有所不同。但用户的问题没有特别指定操作系统,可能默认是类Unix系统,比如使用GNU工具链的情况。
默认链接规则的作用可能是为了简化用户的操作,让用户在大多数情况下不需要手动指定复杂的链接选项,链接器能够自动处理常见的链接任务。例如,自动包含标准的C库,自动合并相同类型的段,处理静态库和动态库的依赖关系等。
可能的默认规则包括:
输入文件的顺序影响符号解析:命令行中先出现的库或目标文件中的符号会被优先使用。
自动包含标准库:例如,在链接C程序时,自动链接libc.so。
段合并规则:将各个目标文件的.text段合并到可执行文件的.text段,.data段合并到.data段等。
处理未定义符号:默认情况下,未解析的符号会导致链接错误,除非在共享库或静态库中找到定义。
静态库和共享库的链接顺序:链接器可能会在静态库和共享库之间有不同的处理方式,比如优先使用共享库。
入口点设置:默认入口点可能是_start或main,取决于运行时库。
动态链接信息:生成动态段,以便在运行时加载共享库。
需要验证这些规则是否正确。例如,输入文件的顺序确实会影响符号解析,因为链接器在遇到未解析的符号时,会向后查找后续文件中的定义。如果先链接的目标文件中有未定义的符号,链接器会在之后的目标文件和库中查找。而库通常需要按照依赖顺序排列,被依赖的库放在后面。
另外,默认情况下,GNU链接器会将所有目标文件中的未解析符号进行解析,如果找不到定义则报错。而库文件(.a或.so)中的符号只有在被引用时才会被包含进来,这涉及到链接器如何处理库的链接。
关于段合并,链接器通常会将同名段合并,例如将所有目标文件的.text段合并到输出文件的.text段,并按顺序排列各输入文件的段内容。
默认的库搜索路径也是链接器默认规则的一部分,例如在Linux下,链接器会搜索/usr/lib、/lib等目录中的库文件。
总结来说,链接器的默认链接规则包括如何处理输入文件中的段、符号解析的顺序、库的链接方式、默认包含的库、入口点设置等,这些规则的作用是让链接过程在大多数情况下无需用户过多干预即可正确生成可执行文件,同时保证代码和数据的正确布局和符号解析。
可能的错误点:
是否默认包含标准库?例如,使用gcc链接时会自动链接libc,但直接使用ld可能需要用户指定。
输入文件顺序的具体影响,比如库和目标文件的顺序是否不同?
入口点的默认设置是否由链接器决定,还是由运行时库提供?
需要确认这些细节,以确保回答准确。
链接器的默认链接规则是指在未指定详细链接选项时,链接器自动遵循的一组标准处理逻辑,用于合并代码段、解析符号、处理库依赖和生成可执行文件。这些规则的作用是简化开发流程,确保代码和数据的正确布局。以下是其核心规则和作用:
一、默认链接规则详解
1. 输入文件顺序影响符号解析
- 规则:
链接器按命令行中文件的出现顺序处理目标文件和库。- 目标文件(.o):始终被完整链接到输出文件。
- 库文件(.a/.so):仅包含被引用符号所在的模块(
.a
为静态库,.so
为动态库)。
- 示例:
# 若 main.o 调用 libmath.a 的函数,库需放在 main.o 之后 ld main.o -lmath -o app # ✅ 正确 ld -lmath main.o -o app # ❌ 错误:libmath 在 main.o 前,无法解析符号
2. 段(Section)合并规则
- 同名段合并:
所有输入文件的.text
、.data
、.bss
等段按顺序合并到输出文件的对应段。- 静态变量:不同文件中的同名静态变量不会冲突(因作用域隔离)。
- 全局变量/函数:同名符号会导致重复定义错误(需
static
或extern
修饰)。
3. 符号解析规则
- 强符号优先:
- 强符号:已初始化的全局变量(如
int x = 5;
)和函数。 - 弱符号:未初始化的全局变量(如
int x;
)。
强符号覆盖弱符号,多个强符号冲突时报错。
- 强符号:已初始化的全局变量(如
- 动态库覆盖静态库:
若同时存在静态库(.a
)和动态库(.so
),默认优先链接动态库。
4. 默认包含标准库
- C 标准库:
使用gcc
链接时自动包含libc.so
(动态)或libc.a
(静态),无需手动指定。- 直接调用
ld
:需显式指定-lc
或路径(如/usr/lib/x86_64-linux-gnu/libc.so
)。
- 直接调用
5. 入口点(Entry Point)
- 默认入口:
通常为_start
(由运行时库提供,如glibc
的crt0.o
),最终调用main
函数。- 自定义入口:可通过
-e
参数指定(如-e my_entry
)。
- 自定义入口:可通过
6. 动态链接信息生成
- 动态段(.dynamic):
记录依赖的共享库(如libc.so.6
)和重定位信息,供加载器在运行时解析。
二、默认规则的作用
1. 简化开发流程
- 自动处理依赖:无需手动指定所有依赖库(如
libc
自动链接)。 - 段合并透明化:开发者无需关注代码/数据的内存布局细节。
2. 保证正确性
- 符号冲突检测:防止重复定义全局变量或函数。
- 未定义符号报错:避免运行时因缺失符号导致的崩溃。
3. 优化输出文件
- 消除未使用代码:静态库中未被引用的模块不链接到最终文件,减小体积。
- 地址空间布局随机化(ASLR):动态库加载时随机化基址,增强安全性。
4. 跨平台兼容
- ABI 一致性:默认规则适配目标平台的调用约定(如 x86-64 的 System V ABI)。
- 库搜索路径:自动搜索标准目录(如
/usr/lib
、/lib
)。
三、默认规则的覆盖与调试
1. 自定义链接脚本
- 修改段布局:
通过编写链接脚本(.ld
文件)控制内存地址、段顺序和符号位置。ld -T custom.lds main.o -o app
2. 显式指定库和符号
- 强制静态链接:
gcc main.c -static -o app # 静态链接所有库(包括 libc)
- 忽略未定义符号(危险!):
ld --unresolved-symbols=ignore-all main.o -o app
3. 调试工具
- 查看链接过程:
gcc -v main.c -o app # 显示详细的链接命令和库路径
- 分析符号依赖:
ldd app # 查看动态库依赖 nm app | grep ' main' # 查看 main 符号状态
四、总结
链接器的默认规则通过标准化处理输入文件、符号和段,平衡了易用性与正确性,使开发者能专注于业务逻辑。理解这些规则有助于解决链接错误(如未定义符号)、优化程序体积(裁剪未用代码)和定制高级内存布局(如嵌入式系统开发)。
汇编代码
这段代码是一个直接通过Linux系统调用(不使用C标准库)在控制台输出字符串“Hello world!\n”并退出的程序。其核心逻辑通过内联汇编实现,以下是逐部分解析:
一、代码结构与功能概览
- 全局变量定义:
char* str = "Hello world!\n"; // 定义字符串常量(存储在.rodata只读数据段)
- 函数定义:
print()
:通过write
系统调用输出字符串。exit()
:通过exit
系统调用终止程序,返回状态码42
。nomain()
:替代传统main
函数,作为程序入口调用print
和exit
。
二、内联汇编详解
1. print()
函数
asm(
"movl $13,%%edx \n\t" // 字符串长度:13字节("Hello world!\n"含换行符和终止符)
"movl %0,%%ecx \n\t" // 字符串地址(通过%0占位符传递str的地址)
"movl $0,%%ebx \n\t" // 文件描述符:0表示标准输出(stdout)
"movl $4,%%eax \n\t" // 系统调用号:4(sys_write)
"int $0x80 \n\t" // 触发软中断执行系统调用
::"r"(str) // 输入约束:将str的地址存入任意通用寄存器(如EAX)
:"edx","ecx","ebax" // Clobber列表:告知编译器这些寄存器会被修改
);
系统调用参数:
寄存器 值 作用 EAX
4
系统调用号(write) EBX
0
文件描述符(stdout) ECX
str
地址字符串指针 EDX
13
字符串长度 关键细节:
- 字符串长度:
"Hello world!\n"
包含12个可见字符和1个换行符(\n
),总计13字节。 - 输入约束
"r"(str)
:编译器自动选择一个寄存器(如EAX
)存储str
的地址,%0
引用该寄存器。 - Clobber列表:声明
edx
、ecx
、ebx
被修改,防止编译器错误优化。
- 字符串长度:
2. exit()
函数
asm(
"movl $42,%ebx \n\t" // 退出状态码:42
"movl $1,%eax \n\t" // 系统调用号:1(sys_exit)
"int $0x80 \n\t" // 触发软中断执行系统调用
);
- 系统调用参数:
寄存器 值 作用 EAX
1
系统调用号(exit) EBX
42
程序退出状态码
三、入口函数nomain()
void nomain() { // 替代传统main函数作为入口
print();
exit();
}
- 入口点问题:
- 默认情况下,C程序入口是
main
函数,但此代码使用nomain
。 - 解决方法:需在链接时指定入口点(如
gcc -e nomain
或修改链接脚本)。
- 默认情况下,C程序入口是
四、编译与运行
1. 编译命令
gcc -nostdlib -e nomain -o hello hello.c
-nostdlib
:禁用标准库(避免链接libc
的初始化代码)。-e nomain
:指定入口函数为nomain
。
2. 运行结果
$ ./hello
Hello world!
$ echo $? # 查看退出状态码
42
五、潜在问题与改进
- 字符串长度硬编码:
- 若修改字符串内容需同步调整
movl $13,%%edx
,否则输出错误。 - 改进:用
strlen
计算长度(需链接标准库或自行实现)。
- 若修改字符串内容需同步调整
- 可移植性:
- 直接使用系统调用和软中断
int $0x80
仅适用于32位Linux。 - 64位系统适配:需改用
syscall
指令及64位寄存器(如RAX
、RDI
等)。// 64位write系统调用示例 asm( "mov $1,%%rax \n\t" // syscall号1(write) "mov $1,%%rdi \n\t" // fd=1(stdout) "mov %0,%%rsi \n\t" // 字符串地址 "mov $13,%%rdx \n\t" // 长度 "syscall \n\t" ::"r"(str):"rax","rdi","rsi","rdx" );
- 直接使用系统调用和软中断
- 入口点兼容性:
- 更规范的做法是使用
_start
作为入口,链接时自动识别。
- 更规范的做法是使用
六、总结
- 直接系统调用:绕过C标准库,减少开销。
- 内联汇编注意事项:
- 正确传递参数和约束。
- 明确声明被修改的寄存器(Clobber List)。
- 入口点定制:需显式指定链接器行为。
- 适用场景:嵌入式开发、内核模块、极简程序等对体积或性能敏感的场景。
Windows11下 WinNT.h 文件在哪里存放?
在 Windows 11 系统中,WinNT.h
是 Windows SDK(软件开发工具包) 的一部分,通常用于底层系统编程(如驱动开发或系统级 API 调用)。以下是该文件的存放位置及管理方法:
一、默认存放路径
WinNT.h
的路径取决于你安装的 Windows SDK 版本,典型路径如下:
C:\Program Files (x86)\Windows Kits\10\Include\<SDK版本号>\um\WinNT.h
- 示例:
如果安装的 SDK 版本是10.0.19041.0
,则完整路径为:C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\um\WinNT.h
二、如何确认是否安装了 SDK?
通过文件资源管理器直接检查:
打开上述路径,确认是否存在Windows Kits
文件夹及子目录。通过 Visual Studio Installer 查看:
- 打开 Visual Studio Installer(开始菜单搜索即可)。
- 选择已安装的 Visual Studio 版本 → 点击 修改。
- 在 “单个组件” 选项卡中搜索
Windows SDK
,确认是否勾选。
通过命令行工具验证(管理员权限):
dir "C:\Program Files (x86)\Windows Kits\10\Include" /s/b | findstr WinNT.h
三、如何安装/修复 Windows SDK?
1. 通过 Visual Studio Installer 安装
- 打开 Visual Studio Installer → 选择你的 VS 版本 → 修改。
- 在 “工作负载” 选项卡中勾选:
- 使用 C++ 的桌面开发(包含基本 SDK)。
- 或 Windows 10/11 SDK(根据需要选择版本)。
- 点击 修改 完成安装。
2. 单独下载 Windows SDK
- 访问 Microsoft Windows SDK 官方页面 → 下载最新版本。
- 安装时勾选 Debugging Tools 和 Windows Headers and Libraries。
四、开发环境中的路径配置
如果已安装 SDK 但编译器仍找不到 WinNT.h
,需检查项目配置:
Visual Studio:
- 右键项目 → 属性 → VC++ 目录 → 包含目录 → 添加路径:
C:\Program Files (x86)\Windows Kits\10\Include\<SDK版本号>\um
- 附加依赖项:添加
kernel32.lib
、user32.lib
等(在\Lib\<SDK版本号>\um\<架构>
目录下)。
- 右键项目 → 属性 → VC++ 目录 → 包含目录 → 添加路径:
CMake 项目:
在CMakeLists.txt
中指定包含路径:include_directories("C:/Program Files (x86)/Windows Kits/10/Include/10.0.19041.0/um")
五、常见问题解决
1. “无法打开源文件 WinNT.h”
- 原因:SDK 未安装或路径未正确配置。
- 解决:按上述步骤安装 SDK 并配置包含路径。
2. 版本冲突
- 现象:项目要求特定 SDK 版本,但系统安装了多个版本。
- 解决:在项目属性中指定正确的 SDK 版本号(如
10.0.19041.0
)。
3. 权限问题
- 现象:访问 SDK 目录时被拒绝。
- 解决:以管理员身份运行 IDE 或调整文件夹权限。
六、总结
WinNT.h
是 Windows 系统编程的核心头文件,需通过 Windows SDK 安装获取。- 开发前务必确认 SDK 版本与项目要求一致,并正确配置编译环境。
- 若需跨版本兼容,可使用条件编译指令(如
#if (NTDDI_VERSION >= NTDDI_WIN10_RS2)
)。
编译与链接中,未决议的符号是指什么?
在编译与链接过程中,未决议的符号(Unresolved Symbol) 是指代码中引用了某个函数、变量或其他标识符(如类名、全局变量等),但链接器在提供的所有目标文件(.o
、.obj
)和库文件(.a
、.lib
、.so
、.dll
)中 找不到其定义。这类问题会导致链接失败,生成的可执行文件或库无法构建。
一、未决议符号的常见表现
链接器会报错提示未定义的引用(具体错误信息因编译器而异):
# 示例(Linux/gcc)
undefined reference to `function_name()`
# 示例(Windows/MSVC)
error LNK2001: unresolved external symbol "void __cdecl function_name(void)"
二、未决议符号的常见原因
1. 声明但未定义
- 函数声明了但未实现:
// 声明但未定义 void my_function(); int main() { my_function(); // 链接时报错:未定义符号 }
- 变量声明为
extern
但未定义:extern int global_var; // 声明在别处定义,但实际未定义
2. 目标文件或库未链接
- 未将包含符号定义的文件加入链接过程:
# 假设函数定义在 utils.c 中,但未链接 gcc main.c -o app # ❌ 报错:未找到 utils.c 中的符号
3. 库依赖缺失或顺序错误
- 静态库链接顺序不当:
链接器按顺序解析符号,若库在被引用前处理,会导致未决议。# ❌ 错误顺序(假设 main.o 依赖 libmath.a) gcc -lmath main.o -o app # ✅ 正确顺序 gcc main.o -lmath -o app
4. 名称修饰(Name Mangling)问题(C++特有)
- C 与 C++ 混编时未用
extern "C"
:
C++ 编译器会修改函数名(如func
→_Z4funcv
),导致 C 代码无法识别。// C++ 文件未正确声明 C 函数 extern "C" void c_function(); // ✅ 正确方式
5. 动态库路径或版本问题
- 运行时未找到动态库(
.so
/.dll
):
编译时指定了库,但运行时环境变量(如LD_LIBRARY_PATH
)未包含库路径。
三、解决未决议符号的方法
1. 检查代码完整性
- 确保所有声明的函数、变量有对应的定义。
- 检查
extern
变量是否在某个源文件中定义(如int global_var = 42;
)。
2. 确认链接文件包含符号定义
- 添加缺失的目标文件:
gcc main.c utils.c -o app # ✅ 链接 main.c 和 utils.c
- 链接所需静态库:
gcc main.o -L/path/to/libs -lmath -o app
3. 调整库链接顺序
- 按依赖顺序排列库:被依赖的库放在后面。
gcc main.o -luser32 -lgdi32 -o app
4. 处理名称修饰(C++)
- 使用
extern "C"
包裹 C 函数声明:#ifdef __cplusplus extern "C" { #endif void c_function(); #ifdef __cplusplus } #endif
5. 配置动态库路径
- Linux:
设置LD_LIBRARY_PATH
或修改/etc/ld.so.conf
。export LD_LIBRARY_PATH=/path/to/libs:$LD_LIBRARY_PATH
- Windows:
将动态库(.dll
)放在可执行文件目录或系统路径(如C:\Windows\System32
)。
四、调试工具辅助定位
1. 查看目标文件符号表
# Linux (nm 命令)
nm -C my_object.o | grep my_function
# Windows (dumpbin 命令)
dumpbin /SYMBOLS my_object.obj
2. 检查可执行文件依赖
# Linux(查看动态库依赖)
ldd my_app
# Windows(Dependency Walker 或 PowerShell)
(Get-Item my_app.exe).VersionInfo.FileDescription
3. 静态库内容分析
# Linux(ar 命令)
ar -t libmath.a # 查看库中包含的目标文件
nm libmath.a # 查看库中的符号
# Windows(lib.exe)
lib /LIST libmath.lib
五、总结
未决议的符号本质是 符号声明与定义之间的断链。解决方法围绕以下核心:
- 完整性检查:确保每个被引用的符号都有定义。
- 链接配置:正确包含目标文件、静态库,并调整顺序。
- 环境适配:处理动态库路径和跨语言符号修饰。
通过系统性的排查和工具辅助,可以高效定位并解决此类问题,确保编译链接流程顺利完成。
编译和汇编中, 链接时重定位和装载时重定位有什么区别? 各自应用场景是什么?
在编译和链接过程中,链接时重定位和装载时重定位是两种不同阶段的内存地址修正机制,它们的核心区别在于 地址修正的时机 和 灵活性。以下是两者的详细对比及其应用场景:
一、链接时重定位(Link-Time Relocation)
定义
在 链接阶段(生成可执行文件时),由链接器(如 ld
)直接修改目标文件中的地址引用,使所有符号的地址指向最终确定的绝对地址。
技术特点
- 静态地址分配:所有符号(函数、全局变量)的地址在链接时确定,写入最终可执行文件。
- 目标文件依赖:需要所有目标文件和静态库(
.a
/.lib
)在链接时提供完整符号定义。 - 地址固定:生成的可执行文件中的地址是硬编码的,无法在运行时改变。
重定位过程示例
假设两个目标文件 main.o
和 utils.o
:
main.o
中调用utils.o
中的函数foo()
,在链接前其地址为占位符(如0x00000000
)。- 链接器合并代码段后,确定
foo()
的实际地址为0x401000
。 - 将
main.o
中对foo()
的调用地址修正为0x401000
,写入最终可执行文件。
应用场景
- 静态链接的可执行文件:直接嵌入所有依赖代码(如 Linux 的静态二进制文件)。
- 嵌入式开发:需要确定性的内存布局(如固件程序)。
- 无动态库依赖的场景:避免运行时加载外部库的开销。
优缺点
优点 | 缺点 |
---|---|
运行效率高(无运行时地址解析) | 可执行文件体积大(包含所有代码) |
内存布局确定,调试方便 | 无法共享代码(多进程重复占用内存) |
无需动态链接器支持 | 更新依赖库需重新编译链接 |
二、装载时重定位(Load-Time Relocation)
定义
在 程序加载到内存时(由操作系统加载器或动态链接器处理),根据实际加载基址修正代码和数据中的地址引用。
技术特点
- 动态地址分配:程序加载到内存时的基址可随机化(ASLR),符号地址在加载时计算。
- 位置无关代码(PIC):代码段通过相对偏移(如
RIP-relative
寻址)引用数据/函数,避免绝对地址硬编码。 - 动态库支持:用于共享库(
.so
/.dll
),允许多进程共享同一物理内存中的代码段。
重定位过程示例
- 动态库
libfoo.so
被编译为位置无关代码(-fPIC
),其内部函数地址使用相对偏移。 - 程序启动时,动态链接器(如
ld-linux.so
)将libfoo.so
加载到随机基址(如0x7f8a20000000
)。 - 修正程序中对
libfoo.so
函数的调用地址:原相对偏移 + 动态库基址 → 实际内存地址。
应用场景
- 动态链接库(共享库):如 Linux 的
.so
或 Windows 的.dll
。 - 地址空间布局随机化(ASLR):增强安全性,防止攻击者预测内存布局。
- 热更新:替换动态库文件后,重启进程即可加载新版本。
优缺点
优点 | 缺点 |
---|---|
节省内存(多进程共享代码段) | 初次加载有解析开销(影响启动速度) |
支持灵活的库更新和 ASLR | 代码需编译为位置无关(可能轻微性能损失) |
减少可执行文件体积 | 依赖动态链接器和环境配置 |
三、核心对比总结
维度 | 链接时重定位 | 装载时重定位 |
---|---|---|
时机 | 链接阶段(生成可执行文件时) | 程序加载到内存时 |
地址确定性 | 绝对地址硬编码 | 基址 + 相对偏移(动态计算) |
代码要求 | 无需特殊编译选项 | 需编译为位置无关代码(-fPIC ) |
适用对象 | 静态链接的可执行文件、静态库 | 动态链接库(共享库) |
内存共享 | 不支持(代码段每进程独立) | 支持(多进程共享同一物理代码段) |
安全性 | 低(地址固定易预测) | 高(支持 ASLR) |
四、实际开发中的选择建议
选择链接时重定位:
- 需要极致性能(如高频交易系统)。
- 目标环境无动态链接器(如嵌入式设备)。
- 依赖库极少变动,且代码体积不敏感。
选择装载时重定位:
- 开发共享库供多进程使用(如系统 API 库)。
- 需要支持安全特性(如 ASLR)。
- 频繁更新库但不想重新编译主程序。
五、技术演进与混合模式
现代操作系统和编译器常结合两种方式:
- 延迟绑定(Lazy Binding):动态库函数在首次调用时解析地址(通过
.plt
/.got
),平衡启动速度和灵活性。 - 部分静态链接:主程序静态链接核心库,动态链接非关键库(如
gcc -static-libgcc
)。
理解这两种重定位机制,有助于优化程序性能、安全性和维护性,尤其在系统级开发和安全性要求高的场景中至关重要。
操作系统中的内核空间和用户空间
在操作系统中,**内核空间(Kernel Space)和用户空间(User Space)**是两种不同权限级别的内存区域,用于隔离操作系统内核与用户应用程序,确保系统的安全性和稳定性。以下是它们的核心区别、作用及实际影响的详细解析:
一、内核空间与用户空间的核心区别
维度 | 内核空间 | 用户空间 |
---|---|---|
权限级别 | 最高权限(如x86的Ring 0) | 低权限(如x86的Ring 3) |
内存访问 | 可直接访问所有硬件和物理内存 | 只能通过虚拟地址访问受限制的内存区域 |
运行代码 | 操作系统内核、驱动程序、中断处理程序 | 普通应用程序(如浏览器、文本编辑器) |
稳定性影响 | 错误可能导致系统崩溃(如内核恐慌) | 错误仅影响当前进程,系统可终止进程恢复 |
切换代价 | 用户态→内核态需通过系统调用/中断,伴随上下文切换 | 用户态内执行无额外开销 |
二、内存隔离的实现原理
1. 虚拟内存划分
- 用户空间:
每个进程拥有独立的虚拟地址空间(如32位系统中0x00000000–0x7FFFFFFF)。- 进程无法直接访问其他进程或内核的内存。
- 内核空间:
所有进程共享内核的虚拟地址空间(如32位系统中0x80000000–0xFFFFFFFF)。- 仅在CPU处于内核态时可访问。
2. 硬件支持
- MMU(内存管理单元):
通过页表(Page Table)将虚拟地址映射到物理地址,并检查访问权限。- 用户态进程访问内核页表项会触发缺页异常(Page Fault)。
三、用户空间与内核空间的交互
1. 系统调用(System Call)
- 作用:用户程序请求内核服务的唯一入口(如文件操作、网络通信)。
- 流程:
- 用户程序触发软中断(如x86的
int 0x80
或syscall
指令)。 - CPU切换至内核态,执行中断处理程序。
- 内核验证请求合法性并执行操作。
- 返回结果并切换回用户态。
- 用户程序触发软中断(如x86的
示例:
// 用户程序调用write()系统调用
ssize_t write(int fd, const void *buf, size_t count);
内核执行流程:
- 检查
fd
是否有效。 - 从用户空间复制
buf
数据到内核缓冲区(安全隔离)。 - 调用驱动程序将数据写入设备。
2. 上下文切换开销
- 代价:每次系统调用需保存/恢复寄存器状态,通常消耗数百CPU周期。
- 优化:
- vsyscall/vDSO:将部分频繁调用(如
gettimeofday
)映射到用户空间,避免切换。 - 批处理系统调用:合并多个请求减少切换次数。
- vsyscall/vDSO:将部分频繁调用(如
四、内核空间的职责
1. 硬件抽象与管理
- 设备驱动:提供统一接口(如字符设备、块设备)屏蔽硬件差异。
- 中断处理:响应硬件事件(如键盘输入、网络包到达)。
2. 资源分配与调度
- 进程调度:通过调度算法(如CFS、O(1))分配CPU时间片。
- 内存管理:管理物理内存、虚拟内存和交换空间(Swap)。
3. 安全与隔离
- 权限检查:验证系统调用参数,防止越权访问(如修改其他进程内存)。
- 命名空间:隔离进程视图(如PID、网络、文件系统)。
五、用户空间的限制与优势
1. 限制
- 无法直接访问硬件:必须通过系统调用或设备文件(如
/dev/mem
)。 - 内存隔离:进程崩溃不会影响系统或其他进程。
2. 优势
- 开发便捷性:应用程序无需处理底层硬件细节。
- 安全性:恶意程序难以破坏系统核心。
六、实际场景与影响
1. 驱动程序开发
- 内核模块:需在内核空间实现,直接操作硬件寄存器。
- 风险:代码错误易导致内核崩溃(如空指针解引用)。
- 用户态驱动:通过UIO(Userspace I/O)框架减少风险,但性能较低。
2. 性能敏感应用
- 零拷贝技术:减少用户态与内核态数据复制(如
sendfile
、mmap
)。 - DPDK/SPDK:绕过内核网络/存储栈,直接在用户态处理数据包/块设备。
3. 容器与虚拟化
- 容器:共享宿主机内核,通过命名空间/Cgroups隔离用户空间资源。
- 虚拟机:运行独立内核,通过虚拟化层(如KVM)模拟硬件。
七、总结
- 内核空间是操作系统的核心,拥有最高权限,负责资源管理和硬件交互。
- 用户空间为应用程序提供安全的执行环境,通过系统调用与内核协作。
- 隔离机制通过硬件和软件结合,确保系统稳定性与安全性,是现代操作系统的基石。
理解两者的区别与交互机制,有助于开发高效、安全的应用程序,并深入排查系统级问题(如性能瓶颈、权限错误)。
Microsoft Visual Studio 中的crt0.c文件在哪个目录?
在 Microsoft Visual Studio 中,crt0.c
是 C 运行时库(CRT)的初始化代码文件,用于定义程序的入口函数(如 mainCRTStartup
),负责全局变量初始化、命令行参数解析等任务。其具体位置取决于 Visual Studio 的版本和安装配置。以下是相关信息:
1. 默认路径
crt0.c
通常位于 Visual Studio 安装目录的 VC\crt\src
子目录下。以常见版本为例:
- Visual Studio 2010:
C:\Program Files (x86)\Microsoft Visual Studio 10.0\VC\crt\src\crt0.c
- Visual Studio 2015:
C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\crt\src\crt0.c
- Visual Studio 2017/2019/2022:
路径类似,但需根据具体版本号调整,例如:C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\crt\src\vcruntime\crt0.c
。
2. 注意事项
- 需要安装 CRT 源码:默认安装可能不包含 CRT 源代码。需在 Visual Studio 安装时勾选 “C++ 桌面开发” 中的 “MSVC vXXX 的 C++ 工具和 Windows SDK”(
XXX
为版本号)。 - 版本差异:不同版本的 CRT 可能包含多个变体文件(如
wincrt0.c
用于 Windows 应用入口、wcrt0.c
为宽字符版本)。
3. 验证与调试
若未找到文件,可通过以下方式确认:
- 在 Visual Studio 安装目录下搜索
crt0.c
。 - 检查是否安装了对应版本的 Windows SDK 或 C++ 开发工具。
4. 相关文件
除了 crt0.c
,CRT 的初始化代码还涉及其他文件:
wcrt0.c
:宽字符版本的入口。wincrt0.c
:Windows 应用程序的入口函数。wwincrt0.c
:宽字符版的 Windows 入口。
总结
crt0.c
的路径依赖于 Visual Studio 版本和安装选项,通常位于 VC\crt\src
目录或其子目录下。如需进一步调试或查看源码,建议通过 Visual Studio 安装器确认是否已包含 CRT 源码组件。
i386、x32 与 x64 的区别及演化过程
i386、x32 与 x64 的区别及演化过程
一、i386 的定义
i386 是指 Intel 80386 处理器,它是 Intel 在 1985 年推出的 32 位 x86 架构 的 CPU,标志着 x86 架构从 16 位向 32 位的跨越。
- 核心特性:
- 支持 32 位寄存器(如 EAX、EBX)和内存寻址(最大 4GB 物理内存)。
- 引入保护模式(Protected Mode),支持多任务和虚拟内存。
- 影响:
- 成为现代 32 位 x86 架构的基础,后续的 32 位 CPU(如 Pentium、Core 系列)均兼容 i386 指令集。
- 操作系统(如 Linux、Windows)中常以
i386
代指 32 位 x86 兼容架构。
二、x32、x64 与 i386 的区别
1. x32(32 位 x86 架构)
- 定义:泛指所有兼容 i386 的 32 位 x86 架构(如 i486、Pentium、Core 等)。
- 特点:
- 寄存器宽度:32 位(EAX、EBX 等)。
- 内存寻址:最大 4GB(受限于 32 位地址总线)。
- 文件/系统标识:Linux 中称为
i386
或x86
,Windows 中称为x86
。
2. x64(64 位 x86 架构)
- 定义:基于 x86-64(AMD64/Intel 64)的 64 位扩展架构,由 AMD 在 2003 年率先推出。
- 特点:
- 寄存器宽度:64 位(RAX、RBX 等),并新增 8 个通用寄存器(R8-R15)。
- 内存寻址:理论最大 16EB(2⁶⁴ Bytes),实际受操作系统限制(如 Windows 家庭版限制为 128GB)。
- 兼容性:完全兼容 32 位 x86 指令(可运行 32 位程序)。
- 文件/系统标识:Linux 中称为
x86_64
,Windows 中称为x64
。
3. x32 ABI(特殊模式)
- 定义:一种 在 64 位 CPU 上运行 32 位指针的优化模式(Linux 特有),旨在减少内存占用同时保留 64 位性能。
- 特点:
- 使用 32 位指针,但寄存器仍为 64 位。
- 应用场景:对内存敏感但需 64 位指令的程序(如高频计算)。
- 限制:不兼容传统 32 位程序,需专门编译。
三、架构演化过程
1. 16 位时代(1978–1985)
- 代表 CPU:Intel 8086、80286。
- 特点:
- 16 位寄存器(AX、BX 等),段地址+偏移地址的寻址方式。
- 最大内存:1MB(实模式)。
2. 32 位时代(1985–2003)
- 里程碑:i386(1985)首次支持 32 位保护模式。
- 发展:
- i486(1989):集成浮点运算单元(FPU)。
- Pentium(1993):超标量架构,支持多指令并行。
- Pentium Pro(1995):引入乱序执行。
3. 64 位时代(2003 至今)
- 里程碑:AMD 推出 Opteron(2003),首次实现 x86-64 架构。
- 关键事件:
- Intel 响应:推出兼容的 EM64T(2004,后改称 Intel 64)。
- 操作系统支持:
- Windows XP x64 Edition(2005)。
- Linux 内核全面支持(2.6.x 版本后)。
- 优势:
- 更大内存支持(突破 4GB 限制)。
- 更多寄存器提升性能(尤其科学计算、游戏)。
四、总结与对比
架构 | 位数 | 代表 CPU | 内存上限 | 典型应用场景 |
---|---|---|---|---|
i386 | 32 位 | Intel 80386 | 4GB | 老旧系统、嵌入式设备 |
x32 | 32 位 | Pentium、Core | 4GB | 轻量级应用、兼容模式 |
x64 | 64 位 | AMD Ryzen、Intel i9 | 16EB(理论) | 现代桌面、服务器、高性能计算 |
x32 ABI | 混合模式 | 64 位 CPU + 32 位指针 | 4GB(指针限制) | Linux 专用优化场景 |
五、实际应用建议
- 开发选择:
- 新项目优先使用 x64(性能高、内存支持大)。
- 仅维护老旧硬件或依赖 32 位库时选择 x32。
- 系统安装:
- 现代 PC 和服务器应安装 x64 系统(如 Windows 11 x64、Ubuntu x86_64)。
- 虚拟机或嵌入式设备可考虑轻量级 x32 系统。
- 兼容性:
- x64 系统可运行 x32 程序(Windows 通过
WoW64
,Linux 通过multiarch
)。 - x32 系统无法运行 x64 程序。
- x64 系统可运行 x32 程序(Windows 通过
通过理解这些架构的差异和演化,可以更好地选择软硬件方案,平衡性能、兼容性与资源占用。
在linux环境下GLIBC 2.41运行库是如何运行一个用户编写的main函数的?上面是从书中摘抄的一些描述,但是有些老旧了,更新局最新的linux和glibc版本,为我解释main函数执行的全生命周期过程,并给出关键点的源码摘要。
在 Linux 环境下,用户编写的 main
函数的全生命周期由 GNU C 运行库(GLIBC) 的入口函数和运行时逻辑控制。以 GLIBC 2.39(截至 2023 年最新稳定版)为例,整个过程分为 初始化、执行主体、清理退出 三个阶段,以下是详细流程和关键源码解析。
一、程序启动的全生命周期
1. 内核加载程序
- 内核接管:
当用户通过execve
系统调用启动程序时,内核完成以下操作:- 解析可执行文件格式(ELF)。
- 创建虚拟内存映射,加载代码段(
.text
)、数据段(.data
、.bss
)。 - 设置初始堆栈(包含
argc
、argv
、envp
等信息)。 - 将控制权交给用户态入口点
_start
(ELF 头中定义的入口地址)。
2. 入口函数 _start
- 汇编入口:
_start
是 GLIBC 的初始入口点,用汇编编写,负责调用运行库的初始化逻辑。
源码位置:sysdeps/x86_64/start.S
(x86-64 架构为例).globl _start _start: xor %rbp, %rbp ; 清空基址指针(标记栈帧结束) mov %rdx, %r9 ; 保存动态链接器析构函数(若有) pop %rsi ; argc → rsi mov %rsp, %rcx ; argv → rcx and $-16, %rsp ; 对齐栈指针(SSE 要求) push %rax ; 栈上填充(对齐用) push %rsp ; 栈指针作为参数传递 lea __libc_csu_fini(%rip), %r8 ; 全局析构函数 lea __libc_csu_init(%rip), %rdi ; 全局构造函数 lea main(%rip), %rsi ; main 函数地址 call __libc_start_main ; 核心初始化逻辑 hlt ; 理论上不会执行至此
3. 核心初始化函数 __libc_start_main
- C 语言入口:
源码位置:csu/libc-start.c
STATIC int LIBC_START_MAIN ( int (*main) (int, char **, char **), // main 函数指针 int argc, char **argv, __typeof (main) init, // 全局构造函数(__libc_csu_init) void (*fini) (void), // 全局析构函数(__libc_csu_fini) void (*rtld_fini) (void), // 动态链接器析构函数 void *stack_end // 栈顶地址 ) { // 关键步骤: __pthread_initialize_minimal (); // 初始化线程局部存储(TLS) __cxa_atexit (rtld_fini, NULL, NULL); // 注册动态链接器析构函数 __libc_init_first (argc, argv, __environ); // 初始化运行库基础设施 __cxa_atexit (fini, NULL, NULL); // 注册全局析构函数 (*init) (argc, argv, __environ); // 调用全局构造函数(C++ 全局对象构造) int result = main (argc, argv, __environ); // 调用用户 main 函数 exit (result); // 清理并退出 }
4. 用户 main
函数执行
- 用户代码:
用户编写的main
函数在此阶段执行,可安全使用堆内存、系统调用等。
5. 清理与退出
exit
函数链:
无论main
是正常返回还是调用exit()
,最终会触发清理逻辑:- 调用通过
atexit()
或__cxa_atexit()
注册的函数(逆序执行)。 - 执行全局析构函数(如 C++ 全局对象析构)。
- 刷新并关闭所有 I/O 流(
stdio
缓冲区)。 - 释放堆内存(通过
__libc_freeres()
)。 - 调用
_exit
系统调用终止进程(状态码由main
返回值决定)。
- 调用通过
二、关键源码逻辑解析
1. 全局构造与析构
- C++ 全局对象:
__libc_csu_init
和__libc_csu_fini
负责调用全局构造函数(.ctors
段)和析构函数(.dtors
段)。// csu/elf-init.c void __libc_csu_init (int argc, char **argv, char **envp) { _init (); // 调用 .init 段代码(编译器生成) // 遍历 .ctors 段调用构造函数 const size_t size = __init_array_end - __init_array_start; for (size_t i = 0; i < size; i++) (*__init_array_start [i]) (argc, argv, envp); }
2. 线程局部存储(TLS)初始化
__pthread_initialize_minimal
:
初始化线程本地存储和栈保护(Stack Guard),源码位于nptl/csu/libc-tls.c
。
3. 退出处理
exit
函数链:exit()
最终调用__run_exit_handlers
(stdlib/exit.c
):void exit (int status) { __run_exit_handlers (status, &__exit_funcs, true, true); } static void __run_exit_handlers (int status, struct exit_function_list **listp, bool run_dtors, bool call_fini) { // 逆序调用 atexit 注册的函数 while (*listp != NULL) { struct exit_function_list *cur = *listp; for (int i = cur->idx; i-- > 0; ) { void (*fn) (void) = cur->fns[i].func.cxa.fn; fn (); } *listp = cur->next; } if (call_fini) _dl_fini (); // 动态链接器清理 _exit (status); // 最终系统调用 }
三、现代 Linux/GLIBC 的变化
相较于旧版本,GLIBC 2.39 的主要改进:
- 强化安全性:
- 栈溢出保护(SSP)默认启用。
- 指针完整性检查(如
-fstack-protector-strong
)。
- 线程本地存储优化:
- 更高效的 TLS 模型(Initial Exec vs. Local Dynamic)。
- 动态链接器改进:
ld.so
支持惰性符号绑定(Lazy Binding)的优化策略。
- C11/C17 标准支持:
quick_exit()
、at_quick_exit()
等新函数。
四、生命周期流程图
execve → 内核加载 → _start (汇编) → __libc_start_main →
├─ 初始化 TLS、堆、IO
├─ 调用全局构造函数(C++ 全局对象)
├─ main(argc, argv, envp)
└─ exit() →
├─ 调用 atexit 函数(逆序)
├─ 调用全局析构函数
├─ 关闭 IO、释放堆
└─ _exit 系统调用
总结
从内核加载到用户 main
函数的执行,GLIBC 的入口函数通过精细的初始化和清理逻辑,为程序提供了安全的运行时环境。现代 GLIBC 在兼容性、安全性和性能上的持续改进,使得这一过程在保持稳定的同时,能适应新的硬件特性和编程标准。
在Windows环境下MSVC CRT是如何运行一个用户编写的main函数的?为我解释main函数执行的全生命周期过程,并给出关键点的源码摘要
在 Windows 环境下,MSVC(Microsoft Visual C++)的 C 运行时库(CRT)负责管理用户编写的 main
函数的全生命周期。这一过程涉及程序初始化、执行主逻辑和清理退出三个阶段。以下以 Visual Studio 2022 的 MSVC CRT 实现(部分源码参考开源代码)为例,详细解析其流程并提供关键源码摘要。
一、程序启动的全生命周期
1. 内核加载程序
- 内核行为:
Windows 内核通过CreateProcess
加载可执行文件(PE 格式),完成以下操作:- 映射 PE 文件到内存,解析导入表(IAT)和重定位表。
- 初始化进程环境块(PEB)和线程环境块(TEB)。
- 将控制权交给 CRT 的入口函数(如
mainCRTStartup
)。
2. 入口函数 mainCRTStartup
- 入口点定义:
控制台程序的入口函数为mainCRTStartup
,GUI 程序为WinMainCRTStartup
。
源码位置:exe_common.inl
(Visual Studio 2022 CRT 源码)// exe_main.cpp extern "C" int mainCRTStartup() { return __scrt_common_main(); }
3. 核心初始化函数 __scrt_common_main
- 初始化流程:
源码位置:exe_common.inl
static __forceinline int __cdecl __scrt_common_main() { // 1. 初始化 CRT 和全局安全机制 if (!__scrt_initialize_crt()) __fastfail(FAST_FAIL_FATAL_APP_EXIT); // 2. 初始化 C/C++ 运行时(全局对象构造、线程局部存储等) __try { if (_initterm_e(__xi_a, __xi_z) != 0) return 255; // 全局 C 初始化 _initterm(__xc_a, __xc_z); // 全局 C++ 构造函数 } __except (__scrt_exception_filter()) { return __scrt_fastfail(GetExceptionCode()); } // 3. 调用用户 main 函数 int const result = invoke_main(); // 4. 清理并退出 __scrt_uninitialize_crt(true /* exit */, true /* return */); return result; }
4. 调用用户 main
函数
- 参数处理:
invoke_main
解析命令行参数和环境变量,传递给用户的main
函数:static int __cdecl invoke_main() { return main(__argc, __argv, _get_initial_environment()); }
5. 清理与退出
- 退出处理链:
void __cdecl __scrt_uninitialize_crt(bool const exit, bool const return_to_caller) { // 1. 调用 atexit 注册的函数(逆序) _execute_onexit_table(&__acrt_atexit_table); // 2. 调用全局析构函数(C++ 全局对象) _initterm(__xp_a, __xp_z); // .CRT$XPA 到 .CRT$XPZ 的析构函数指针 // 3. 释放 CRT 分配的堆内存 __acrt_cleanup_heap(); // 4. 调用 ExitProcess 或返回 if (!return_to_caller && exit) ExitProcess(exit_code); }
二、关键源码逻辑解析
1. 全局构造与析构
_initterm
函数:
遍历函数指针表(.CRT$XCA
到.CRT$XCZ
段),调用全局构造函数(C++ 全局对象):// crt0dat.c void __cdecl _initterm(_PVFV* const begin, _PVFV* const end) { for (_PVFV* it = begin; it != end; ++it) if (*it != nullptr) (**it)(); }
2. 安全机制初始化
- 缓冲区安全检查(/GS):
CRT 初始化时设置安全 Cookie(防止栈溢出攻击):// gs_support.c void __cdecl __security_init_cookie() { __security_cookie = __security_generate_cookie(); }
3. 命令行参数解析
__p___argv
实现:
通过 Windows APIGetCommandLineW
和CommandLineToArgvW
解析命令行参数:// argv.cpp wchar_t** __cdecl __p___wargv() { LPWSTR cmd_line = GetCommandLineW(); int argc; LPWSTR* argv = CommandLineToArgvW(cmd_line, &argc); return argv; }
三、现代 MSVC CRT 的变化
相较于旧版本(如 VS2010),MSVC 2022 的 CRT 主要改进:
- 安全性增强:
- 默认启用 Control Flow Guard(CFG)和 Spectre 缓解措施。
- 强化异常处理(SEH)和堆完整性检查。
- 模块化设计:
- CRT 拆分为更细粒度的 DLL(如
vcruntime140.dll
、ucrtbase.dll
)。
- CRT 拆分为更细粒度的 DLL(如
- C++20/C17 支持:
- 支持
std::format
、<charconv>
等新特性。
- 支持
- UTF-8 代码页支持:
- 可通过
/utf-8
编译选项启用原生 UTF-8 处理。
- 可通过
四、生命周期流程图
CreateProcess → 内核加载 → mainCRTStartup → __scrt_common_main →
├─ 初始化安全 Cookie、堆、环境变量
├─ 调用全局构造函数(C++ 对象)
├─ invoke_main → main(argc, argv, envp)
└─ __scrt_uninitialize_crt →
├─ 调用 atexit 函数(逆序)
├─ 调用全局析构函数
├─ 释放堆内存、关闭 I/O
└─ ExitProcess 或返回
总结
MSVC CRT 通过 mainCRTStartup
入口函数完成复杂的初始化工作,确保用户 main
函数能在安全、稳定的环境中运行。其生命周期管理涵盖从参数解析、全局对象构造到退出清理的全流程,并与 Windows 系统深度集成(如 SEH 异常处理、安全机制)。开发者无需关心底层细节,即可编写符合标准的 C/C++ 程序。
帮我解释windows中的PE结构,每个细节都要有说明和解释,并使用最新的头文件。
Windows 中的 PE(Portable Executable)结构 是可执行文件(如 .exe
、.dll
、.sys
)的标准格式,支持 32 位和 64 位程序。它定义了文件如何被加载到内存并执行。以下基于最新 Windows SDK(WinNT.h)的详细解析,涵盖所有核心部分。
一、PE 结构全景图
PE 文件由多个层次化的头部和数据块组成,结构如下:
┌──────────────────────┐
│ DOS 头部 │
├──────────────────────┤
│ DOS Stub │
├──────────────────────┤
│ PE 签名 ("PE\0\0") │
├──────────────────────┤
│ COFF 文件头 │
├──────────────────────┤
│ 可选头(32/64) │
├──────────────────────┤
│ 节表(Section表) │
├──────────────────────┤
│ 节数据 │
│ .text, .rdata, │
│ .data, .rsrc等 │
└──────────────────────┘
二、PE 结构逐层解析
1. DOS 头部(IMAGE_DOS_HEADER)
- 作用:兼容 DOS 系统,提供存根程序(Stub)和指向 PE 头的偏移。
- 源码定义(WinNT.h):
typedef struct _IMAGE_DOS_HEADER { WORD e_magic; // "MZ" 签名(0x5A4D) // ... 其他 DOS 字段(略)... LONG e_lfanew; // PE 头的偏移(关键字段) } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
2. PE 签名
- 作用:标识文件为 PE 格式。
- 内容:4 字节的
PE\0\0
(0x50450000)。
3. COFF 文件头(IMAGE_FILE_HEADER)
- 作用:描述文件的基本信息。
- 源码定义:
typedef struct _IMAGE_FILE_HEADER { WORD Machine; // 目标架构(如 IMAGE_FILE_MACHINE_AMD64) WORD NumberOfSections; // 节的数量 DWORD TimeDateStamp; // 编译时间戳 DWORD PointerToSymbolTable; // 符号表偏移(调试用) DWORD NumberOfSymbols; // 符号数量 WORD SizeOfOptionalHeader; // 可选头大小 WORD Characteristics; // 文件属性(如可执行、DLL) } IMAGE_FILE_HEADER;
- 关键标志:
IMAGE_FILE_EXECUTABLE_IMAGE
:可执行文件。IMAGE_FILE_DLL
:动态链接库。
4. 可选头(IMAGE_OPTIONAL_HEADER)
- 作用:提供程序加载和执行的关键信息,分为 32 位和 64 位版本。
- 源码定义(64 位):
typedef struct _IMAGE_OPTIONAL_HEADER64 { WORD Magic; // PE32+(0x20B) // ... 通用字段 ... DWORD AddressOfEntryPoint; // 入口点 RVA(如 main 函数) DWORD ImageBase; // 首选加载基址(64 位为 ULONGLONG) DWORD SectionAlignment; // 内存对齐粒度(通常 0x1000) DWORD FileAlignment; // 文件对齐粒度(通常 0x200) // ... 子系统、栈/堆大小 ... DWORD NumberOfRvaAndSizes; // 数据目录数量(固定 16) IMAGE_DATA_DIRECTORY DataDirectory[16]; // 数据目录表 } IMAGE_OPTIONAL_HEADER64;
- 关键数据目录:
索引 用途 IMAGE_DIRECTORY_ENTRY_EXPORT
导出函数表(.edata) IMAGE_DIRECTORY_ENTRY_IMPORT
导入函数表(.idata) IMAGE_DIRECTORY_ENTRY_RESOURCE
资源数据(.rsrc) IMAGE_DIRECTORY_ENTRY_BASERELOC
重定位表(.reloc) IMAGE_DIRECTORY_ENTRY_DEBUG
调试信息(如 PDB 路径) IMAGE_DIRECTORY_ENTRY_TLS
TLS(线程局部存储)数据 IMAGE_DIRECTORY_ENTRY_SECURITY
数字签名(不在节内)
5. 节表(Section Table)
- 作用:描述每个节(如代码、数据)在文件和内存中的布局。
- 源码定义:
typedef struct _IMAGE_SECTION_HEADER { BYTE Name[8]; // 节名称(如 ".text") union { DWORD PhysicalAddress; DWORD VirtualSize; // 内存中的实际大小 } Misc; DWORD VirtualAddress; // 内存中的 RVA(相对虚拟地址) DWORD SizeOfRawData; // 文件中的大小 DWORD PointerToRawData; // 文件中的偏移 DWORD Characteristics; // 节属性(可读、可写、可执行) } IMAGE_SECTION_HEADER;
- 关键属性:
IMAGE_SCN_MEM_EXECUTE
:代码节(可执行)。IMAGE_SCN_MEM_READ
:数据节(可读)。IMAGE_SCN_MEM_WRITE
:可写数据节(如 .data)。
三、核心数据目录详解
1. 导入表(IMAGE_IMPORT_DESCRIPTOR)
- 作用:记录程序依赖的 DLL 及其导入函数。
- 结构:
typedef struct _IMAGE_IMPORT_DESCRIPTOR { union { DWORD Characteristics; DWORD OriginalFirstThunk; // 导入名称表(INT)RVA }; DWORD TimeDateStamp; // 时间戳(未绑定为0) DWORD ForwarderChain; DWORD Name; // DLL 名称的 RVA DWORD FirstThunk; // 导入地址表(IAT)RVA } IMAGE_IMPORT_DESCRIPTOR;
2. 导出表(IMAGE_EXPORT_DIRECTORY)
- 作用:定义 DLL 导出的函数供其他模块调用。
- 结构:
typedef struct _IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; DWORD TimeDateStamp; WORD MajorVersion; WORD MinorVersion; DWORD Name; // DLL 名称的 RVA DWORD Base; // 导出函数起始序号 DWORD NumberOfFunctions;// 导出函数数量 DWORD NumberOfNames; // 按名称导出的函数数量 DWORD AddressOfFunctions; // 函数地址数组 RVA DWORD AddressOfNames; // 函数名称数组 RVA DWORD AddressOfNameOrdinals;// 序号数组 RVA } IMAGE_EXPORT_DIRECTORY;
3. 资源目录(IMAGE_RESOURCE_DIRECTORY)
- 作用:管理图标、字符串、版本信息等资源。
- 层次结构:
- 资源类型(如
RT_ICON
、RT_VERSION
)。 - 资源名称或 ID。
- 语言 ID(如 0x0409 表示英语)。
typedef struct _IMAGE_RESOURCE_DIRECTORY { DWORD Characteristics; DWORD TimeDateStamp; WORD MajorVersion; WORD MinorVersion; WORD NumberOfNamedEntries; WORD NumberOfIdEntries; } IMAGE_RESOURCE_DIRECTORY;
- 资源类型(如
4. 重定位表(IMAGE_BASE_RELOCATION)
- 作用:当文件无法加载到
ImageBase
时,修正代码中的绝对地址。 - 结构:
typedef struct _IMAGE_BASE_RELOCATION { DWORD VirtualAddress; // 需重定位的页 RVA DWORD SizeOfBlock; // 当前块的总大小 // 跟随 WORD 数组,每个 WORD 包含偏移和类型 } IMAGE_BASE_RELOCATION;
四、PE 加载与执行流程
- 内存映射:
- 根据
SectionAlignment
将各节加载到内存。 - 初始化 IAT(导入地址表),解析依赖的 DLL 函数地址。
- 根据
- 地址重定位:
- 若实际加载地址与
ImageBase
不同,应用.reloc
节中的重定位项。
- 若实际加载地址与
- 执行入口点:
- 跳转到
AddressOfEntryPoint
指定的地址(通常是 CRT 入口函数)。
- 跳转到
- 资源访问:
- 通过
FindResource
、LoadResource
API 访问.rsrc
节中的资源。
- 通过
五、现代 PE 特性扩展
- 控制流防护(CFG):
在DllCharacteristics
中设置IMAGE_DLLCHARACTERISTICS_GUARD_CF
,防止代码注入攻击。 - 动态基址(ASLR):
通过DllCharacteristics
的IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
启用地址空间随机化。 - 数字签名:
安全目录(IMAGE_DIRECTORY_ENTRY_SECURITY
)存放 Authenticode 签名,验证文件完整性。
六、工具与调试
- 工具:
- PEView:查看 PE 结构。
- CFF Explorer:编辑 PE 头。
- WinDbg:调试加载的 PE 文件。
- 调试信息:
.debug$S
节存储 CodeView 记录,指向 PDB 文件路径。
总结
PE 结构通过精细的头部和节定义,实现了代码、数据、资源的模块化管理,并支持安全特性(如 CFG、ASLR)。理解其细节有助于逆向工程、性能优化和安全分析。结合最新的 Windows SDK 头文件(如 WinNT.h
),开发者可深入控制程序的行为和内存布局。
链接过程详解:地址分配与重定位
链接过程是程序构建中的关键阶段,负责将多个目标文件和库合并为可执行文件或库。其核心步骤包括地址和空间分配、符号决议和重定位。以下是详细说明:
1. 地址和空间分配(Address and Storage Allocation)
此步骤的目标是合并所有输入文件的段,并为其分配运行时内存地址。
主要操作:
段合并
将各目标文件的同类段(如.text
、.data
、.bss
)合并为输出文件的对应段。例如,所有.text
段合并为输出文件的代码段。确定段布局
链接器根据目标平台的内存模型或链接脚本(Linker Script)确定各段的起始地址和长度。例如:.text
(代码段)通常位于只读内存区域。.data
(已初始化数据)和.bss
(未初始化数据)位于可读写内存区域。
符号地址计算
每个符号(如函数、全局变量)的最终地址由段基址加上其在段内的偏移量确定。例如,若.text
段基址为0x08048000
,函数foo
在段内偏移0x200
,则其运行时地址为0x08048200
。对齐与填充
为确保内存对齐要求,链接器可能在段间插入填充(Padding),例如将段起始地址对齐到 4KB 边界。
示例:
假设两个目标文件 a.o
和 b.o
的 .text
段大小分别为 0x100
和 0x200
。合并后输出文件的 .text
段总大小为 0x300
,基址为 0x08048000
。此时,a.o
中的符号 func1
(偏移 0x50
)的运行时地址为 0x08048050
,而 b.o
中的 func2
(偏移 0x150
)地址为 0x08048150
。
2. 符号决议(Symbol Resolution)
符号决议的目的是将符号的引用与其定义关联,确保程序中所有符号均有明确定义。
主要操作:
构建全局符号表
链接器收集所有输入文件(包括静态库)的符号表,建立全局符号表,记录每个符号的类型(强/弱)和位置。解析符号引用
对每个未定义符号(如extern
函数或变量),在全局符号表中查找其定义。若未找到,链接器报错(如undefined reference to 'foo'
)。处理重复定义
- 强符号(如已初始化的全局变量)不允许重复定义,链接器直接报错。
- 弱符号(如未初始化的全局变量)允许重复,链接器选择占用空间最大的定义(常见于
COMMON
块处理)。
静态库处理
链接器按输入顺序扫描目标文件和库。若遇到未解析符号,会从后续库中提取包含该符号定义的目标文件。例如,gcc main.o -lm
会从libm.a
中提取包含sqrt
定义的目标文件。
示例:
- 目标文件
main.o
引用了未定义的printf
。 - 链接器在
libc.a
中找到printf
的定义,将main.o
对printf
的引用指向该定义。
3. 重定位(Relocation)
重定位是修正代码和数据中的地址引用,使其指向正确的运行时地址。
主要操作:
重定位条目解析
目标文件中的重定位表(如.rel.text
和.rel.data
)记录了需要修改的位置及类型。例如:Offset: 0x1234, Type: R_X86_64_PC32, Symbol: foo
表示偏移
0x1234
处的 32 位值需替换为foo
的地址。地址计算
根据符号的最终地址和重定位类型修正引用:- 绝对地址:直接替换为符号的虚拟地址(如
R_X86_64_32
)。 - PC 相对地址:计算符号地址与下一条指令地址的差值(如
R_X86_64_PC32
)。
- 绝对地址:直接替换为符号的虚拟地址(如
指令修正
修改目标文件中的占位符(如0x00000000
)为计算后的地址值。
示例:
假设函数 foo
的运行时地址为 0x08048400
,且某 call
指令的偏移为 0x1000
,使用 R_X86_64_PC32
重定位:
- 下一条指令地址为
0x1000 + 4 = 0x1004
。 - 偏移量 =
0x08048400 - 0x1004 = 0x080473FC
。 - 指令中的
0x00000000
被替换为0x080473FC
,实现正确跳转。
总结
链接过程通过三个关键步骤协同工作:
- 地址分配:合并段并确定内存布局。
- 符号决议:确保所有引用均有定义。
- 重定位:修正地址引用以匹配运行时环境。
这一过程使分散编译的目标文件最终形成一个地址正确、符号一致的可执行程序。理解链接机制对调试复杂构建问题(如符号冲突、内存布局错误)至关重要。
windows dumpbin
在 Windows 平台下,原生的 objdump
(GNU Binutils 工具)并不直接存在,但可以通过以下方法实现类似功能:
1. 使用 Visual Studio 的 dumpbin
工具
dumpbin
是 Microsoft Visual Studio 自带的二进制文件分析工具,功能与 objdump
类似。
安装与配置
- 安装 Visual Studio(社区版即可),确保勾选 C++ 开发工具。
- 从 开始菜单 打开 Developer Command Prompt 或 Developer PowerShell(已配置环境变量)。
常用命令
功能 | 命令 | 示例 |
---|---|---|
查看可执行文件头信息 | dumpbin /HEADERS <file> | dumpbin /HEADERS myapp.exe |
显示符号表 | dumpbin /SYMBOLS <file> | dumpbin /SYMBOLS mylib.lib |
查看段(Section)信息 | dumpbin /SECTION:<section> <file> | dumpbin /SECTION:.text myapp.exe |
反汇编代码 | dumpbin /DISASM <file> | dumpbin /DISASM myapp.exe |
查看依赖的 DLL | dumpbin /DEPENDENTS <file> | dumpbin /DEPENDENTS myapp.exe |
示例输出
dumpbin /SYMBOLS myapp.exe
输出符号表时会显示符号地址、类型(全局/局部)和所属段:
...
00B 00000000 SECT4 notype External | _main
00C 00000000 SECT3 notype External | _printf
...
2. 使用 MinGW 或 Cygwin 的 objdump
若在 Windows 使用 MinGW 或 Cygwin,可直接通过包管理器安装 GNU Binutils 中的 objdump
。
安装步骤
MinGW:通过 MSYS2 安装:
pacman -S mingw-w64-x86_64-binutils
安装后,工具位于
mingw64\bin\objdump.exe
。Cygwin:在安装时勾选
binutils
包。
常用命令
# 查看文件头
objdump -f myapp.exe
# 反汇编代码段
objdump -d myapp.exe
# 显示符号表
objdump -t myapp.exe
# 查看段信息
objdump -h myapp.exe
3. 使用 LLVM 的 llvm-objdump
若已安装 LLVM 工具链(如通过 LLVM Windows 发行版),可使用 llvm-objdump
。
常用命令
# 反汇编代码
llvm-objdump -d myapp.exe
# 显示符号表
llvm-objdump -t myapp.exe
4. 其他工具
- Ghidra:免费开源的逆向工程工具,支持反汇编和符号分析。
- IDA Pro:商业逆向工具,功能强大。
- CFF Explorer:PE 文件结构分析工具,适合查看 Windows 可执行文件格式。
对比与选择
工具 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
dumpbin | 分析 MSVC 编译的二进制文件 | 原生支持,无需额外安装 | 命令选项与 objdump 不同 |
MinGW/Cygwin objdump | 分析 GCC/MinGW 编译的二进制文件 | 与 Linux 行为一致 | 需要配置环境 |
llvm-objdump | 分析 LLVM 生成的二进制文件 | 支持多架构 | 安装 LLVM 较复杂 |
常见问题
Q1: 如何查看某个符号是否被正确链接?
- 使用
dumpbin /SYMBOLS
或objdump -t
检查符号是否存在。
若符号为UNDEF
(未定义),需检查库链接是否正确。
Q2: 如何分析 PE 文件结构?
- 使用
dumpbin /HEADERS
查看 PE 头、段表、导入/导出表。
Q3: 如何反汇编特定函数?
- 使用
dumpbin /DISASM
或objdump -d
反汇编整个文件,再结合文本搜索(如findstr
)定位函数。
总结
在 Windows 平台分析二进制文件,推荐:
- MSVC 用户:优先使用
dumpbin
(与 Visual Studio 深度集成)。 - MinGW/GCC 用户:安装 MinGW 的
objdump
。 - 逆向分析:使用 Ghidra 或 IDA Pro。
掌握这些工具可帮助调试链接错误、分析二进制结构及逆向工程。