首页
Search
1
[CTF/Reverse] 2022DASCTF X SU 三月 逆向部分
135 阅读
2
[CTF/Reverse] [HWS2022硬件安全 x DAS Jan] EasyVM + BabyVM
125 阅读
3
[CTF/Reverse] [XMAN2018排位赛]easyvm
92 阅读
4
[CTF/Reverse] [GWCTF 2019]babyvm
88 阅读
5
[应用/笔记]Typecho博客搭建
76 阅读
原创
笔记
程序员的自我修养
CTF
Reverse
Pwn
登录
Search
Yirannn
累计撰写
65
篇文章
累计收到
3
条评论
首页
栏目
原创
笔记
程序员的自我修养
CTF
Reverse
Pwn
页面
搜索到
6
篇与
的结果
2022-03-01
[学习/笔记] 程序员的自我修养-第六章
4.可执行文件的装载与进程第七章终于看完了,趁着上操作系统课,把第六章整理一下。我就是大鸽子呜呜呜可执行文件的装载是一个比较复杂的过程,曾经的装载只是把程序从外部存储器读取到内存中。但是多进程、多用户、虚拟存储使得装载过程变得复杂。本章通过介绍ELF在Linux下的装载过程来介绍装载——从装载是什么、虚地址空间是什么、为什么有。再探讨一下装载方式、虚空间分布情况6.1 进程虚拟地址空间对于n位的CPU(现在通常是64位)从硬件上说理论上可以寻址0到2^64-1,由于成书较早,书里主要以32位举例32位平台下,从0到2^32-1我们总共有4GB的虚拟空间。但是这4GB的虚拟空间并不完全是允许用户使用的,Linux默认把其中0x0到0xC0000000的部分(1GB)留给操作系统,而剩下的3GB才留给进程使用(当然,实际情况下程序不会拥有这3GB,还有一部分是留给其他用途的),而32位Windows默认给系统留下2G的空间3G的空间,是不太够用的,于是我们有了PAE。对虚拟地址空间来讲,CPU只能访问32位指针,只能寻址0-4G,但是从Pentium Pro开始采用36位地址线,最高可以理论访问64G内存,Intel修改了页映射方式,使得多出来的物理内存可以被访问(这种扩展方式就叫PAE-Physical Address Extension)。这里的被访问也只是操作系统可以访问。应用程序若想访问这些扩展的空间,一般是通过操作系统提供一个窗口映射,比如在0x10000000-0x20000000申请一段256M的虚拟空间做窗口,然后在高于4G的物理内存空间中申请多段长256M的地址A、B等,根据需要把这段地址映射到A、用到B的时候再映射到B,这种内存访问方式在Win下叫AWE,Linux下通过mmap系统调用实现由于事实上我们现在已经采用64位,对这部分只做技术上的了解了。6.2 装载方式在三级存储系统中,中间的内存往往是比外存更昂贵、空间更少的。我们把最常用的部分留在内存中,把不太常用的地方放外存里,这就是动态装入的基本原理。两种典型的方法是覆盖装入和页映射。其实从缓存、内存、动态装载,都是利用了程序的局部性原理,这也是三级存储能实现接近外存容量、缓存速度的基础理论。在硬件课上老师的一个理论深得我心(当然这看上去可能是一句废话):当从软件算法的层面无法提升速度,就要深入到硬件里去配合硬件的工作以提高速度。6.2.1 覆盖装入 覆盖装入几乎被淘汰了,但是我们可以吸取其中的思想,在嵌入式等受限环境下,可能还存在用武之地。 覆盖装入要求程序员主动将代码分割成若干块,然后写一个辅助代码管理模块间的驻留和替换关系,称为“覆盖管理器(Overlay manager)”。我们把不会相互调用的代码模块(如A、B)放在相同的内存位置,调用哪部分的时候就把这部分装载进来,管理器往往很小,常驻内存,这时候我们需要的内存就从(A+B)变成了(OM+max(A, B))了。思想大体如此,但是实际上模块依赖可能复杂的多,也远不止两块,就需要程序员手动的把模块调用组织成树形结构。显然,这是一种时间换空间的方法6.2.2 页映射 随着虚拟存储的发明,我们有了更好用的页映射方式。它把内存和磁盘中的数据和指令按页划分,并把其作为转载的单位,现在一个页一般都是4K。如果你学过缓存的控制机制,其实会发现页映射的装载方式和缓存的控制方式都几乎是相通的:寻找,若没有则装载,否则使用,对于装载:如果有空内存则直接装载,否则选一个页替换。这其实就是缓存的命中与替换,根据装载管理器的策略来确定装载策略(全相联、组相联)与替换策略(FIFO、LUR)等——这个装载管理器其实就是操作系统6.3 从操作系统角度看可执行文件的装载按照刚刚描述的装载方法,假设程序有 P0-P7 8个页,内存有 M0 - M3 四个页,如果那么每次替换页都要进行重定位,这当然是不可接受的。在虚拟存储中,硬件MMU都提供了地址转换的功能,也正是通过转换和页映射,使得动态加载可执行文件的方式和静态加载有很大区别6.3.1 进程建立 进程间的不同的最关键特征是有独立的虚拟地址空间,我们从一个最典型的例子开始:创建进程,装载文件并执行。 - 创建一个独立的虚拟地址空间 - 读取可执行文件头、建立文件与虚拟空间映射 - 把IP放在入口,启动6.3.1.1 创建虚拟地址空间 虚拟空间由页映射函数将页映射到相应的物理空间,实际上创建虚拟空间就是创建这个映射的数据结构,对于Linux,只需要分配一个页目录,甚至不需要设置映射关系,等到后面有页错误的时候再设置6.3.1.2 读取文件头、建立映射 第一步的映射是虚拟空间到物理空间,而这部分则是虚拟空间和可执行文件的映射。当发生页错误的时候,系统从物理内存中分配一个页,然后从磁盘读到内存中,再设置这个缺页和物理页的映射关系。当系统捕获页错误的时候,它需要程序缺的页在可执行文件的哪一个位置,这就是虚拟空间和可执行文件的映射,也是装载中最重要的一步 Linux把虚拟空间的一个段叫虚拟内存区域(VMA)而Windows叫虚拟段(Virtual Section)。举个例子,把某ELF从0x10000到0x1000e0(长度对齐到0x1000)的一段text映射到虚拟存储空间的0x08048000 - 0x08049000,这个进程的数据结构中就有了一个text段的VMA,在虚拟空间的地址就是48000-49000,对应ELF中0x10000.6.3.1.3 设置IP、启动这一步看似简单,实际上涉及到内核堆栈和用户堆栈的切换、CPU权限的切换,但是对于进程来说,可以简单的认为操作系统执行了一步跳转6.3.2 页错误 以上三步执行之后,任何指令数据还都没有装入内存,只是建立了一个映射关系。CPU会在执行当前IP的指令时,发现当前这个页面是一个空页面,于是触发一个页错误,再将控制权还给操作系统,操作系统有例程处理这种情况,操作系统查询建立的数据结构,找到对应VMA,计算其在ELF中的偏移、在内存中分配一个物理页,再把虚拟页和物理页建立映射,再把控制权还给进程,进程继续执行。 页错误不断产生,操作系统不断分配。如果所需内存超过可用,就需要操作系统分配回收物理内存。不再展开
2022年03月01日
20 阅读
0 评论
0 点赞
2022-01-19
[学习/笔记] 程序员的自我修养-第五章
本书中,这部分内容很短,其实也没什么好说的,和ELF大同小异,即使是不同的地方我觉得也完全没有必要在笔记中细细区分,更重要的是能理解这种分段文件结构的思想 第五章很早就看完了,一直没有上传笔记,本章核心内容不多,只做了解即可 COFF 文件也有文件头和段表、段表后紧跟各种段,文件最末尾是符号表。而 COFF 中特有的 drectve 段和 debug$ 段将在下文记录5 PE/COFF5.3 链接指示信息 drective段储存编译器给链接器的信息,除了基础的段属性(段名,大小,标志位)之外,RAW Data部分提供编译器希望传递给链接器的参数,书中举例为"/DEFAULTLIB:LIBCMT" 表示该文件需要 LIBCMT 这个默认库,链接器会自动的把它添加到链接参数中。5.4 调试信息 .debug$x相关段均包含着调试信息,x = S 表示 Symbol信息, x = P 表示 precompiled 信息, T 表示 Type 信息。具体定义在 PE 格式文件标准中5.6 PE PE 基于 COFF,它的文件头不是 COFF 头,而是 DOS MZ 头和桩代码,COFF 头的映像头扩展成了PE头,增加了 “PE扩展头部结构” (PE Optional Header)虽然 DOS 和 Win 使用相同的可执行文件扩展名.exe,但是DOS下的可执行文件是MZ格式,这与 Win 下的 PE 完全不同,但是 PE 文件设计之初要考虑到对 DOS 的兼容性,PE 中的IMAGE_DOS_HEADER和 DOS Stub就是为此设计。 DOS Stub的唯一作用是输出cannot run in DOS,PE中DOS Header的“e_cs, e_ip”(这两项为DOS文件中的程序入口)直接指向了DOS Stub而非真正的程序入口PE扩展头的字面意思是可选,但是它事实上是不可选的,在本章只简单的介绍一下与静态链接相关的数据成员5.6.1 PE数据目录 为了让系统可以很快的找到装载相关数据结构,如导入表、导出表、重定位表等,这些数据的位置和长度都保存在了“数据目录”中,大小为16,每一个元素对应一个包含一个东西的表,具体含义和下标的对应情况包含在IMAGE_DIRECTORY_MY_TABLE 这一系列宏中
2022年01月19日
14 阅读
0 评论
0 点赞
2021-09-26
[学习/笔记] 程序员的自我修养-第四章
不得不说这本书太难啃了,我是先看完一章,在看下一章的同时写前一章的笔记,这样可以补充前一章没仔细看的东西,但是进度好慢。汗,第五章已经看完了,第四章的笔记还没写完....4. 静态链接4.1 空间与地址分配 将目标文件合成链接为可执行文件时,输出文件的空间如何分配给输入文件。4.1.1 按序叠加 最简单的做法就是把各个目标文件直接合并,如左图所示。 这样带来的问题也是显然的。输出文件的段结构过于零散。当目标文件结构过于庞大时,这样的做法浪费空间,因为每个段都有地址和空间对齐要求,所以这样的方案不够好4.1.2 相似段合并 将相同性质的段合并到一起,比如将data段、text段、bss段分别合成到一起,如上右所示。链接器所分配的空间不只是在输出的可执行文件中的空间,另一个是程序在装载后的虚拟地址空间。对于有数据的段,比如text和data,既需要在文件中分配空间、又需要在虚拟地址中分配空间。对于bss,只需要分配虚拟地址空间,因为在文件中并没有实际内容。我们更多关注虚拟地址的分配。可执行文件的空间分配和链接过程关系不大。 当前链接方式基本都采用4.1.2,一般采用“两步链接”的方法。第一步是空间与地址分配。扫描各个文件,获得各个段长度、属性、位置,收集符号表的定义与引用。创立一个全局符号表。之后通过第一步中的信息,将段数据、重定位信息解析,然后进行符号解析与重定位、调整代码地址。其中重定位过程是整个链接过程的核心下图展示了未链接前的a和b两个目标文件,以及链接之后的ab 其中VMA是虚拟地址,LMA是加载地址,我们一般只需要关注VMA即可。链接之前,目标文件中所有段的VMA都是0,因为虚拟空间暂未分配。而ab中各段就被分配了相应的虚拟空间。这里解释一下为什么ab的text段在8048094、data在8049108而不是从0开始分配,在Linux下,ELF可执行文件默认从8048000开始分配4.1.3 符号地址的确定 在空间分配后,各段的虚拟地址就已经确定了。之后,链接器计算确定各个符号的虚拟地址,因为符号在段内的相对位置是固定的,所以各个符号的地址已经确定了,但是链接器需要给每个符号加上一个偏移量、使得它们能够调整到正确的虚拟地址。 比如若main对a.o的text偏移是X,text在链接之后位于虚拟地址8048094,那么main在链接之后的地址应当是8048094+x。4.2 符号解析与重定位4.2.1 重定位 完成空间和地址的分配步骤之后,就要进行静态链接的核心:符号解析与重定位。这里有必要贴一下a.o的反汇编,以便我们来看一下它是如何访问变量和函数的。 我们可以暂时简单的将c7 44 24 04理解成mov的机器码,而src的地址0x00则是shared变量(这是一个a.c中定义的东西,swap同)的地址,而e8则是call指令的机器码(call near)0xFFFFFFFC是小端序表示下-4的补码,实际上,对于0x2b-4=0x27,它并非swap的地址,只是一个临时的假地址。在完成空间分配后,链接器已经可以确定所有符号的虚拟地址,进而对每个需要重定位的指令地址修正。4.2.2 重定位表 链接器通过重定位表来获取有哪些指令需要被调整的信息。对于每个要被重定位的ELF段,都会有一个对应的重定位表,而一个重定位表一般就是ELF的一个段,也叫重定位段,比如用.rel.text/.rel.data来表示text段/data段的重定位信息,使用objdump -r来查看目标文件的重定位表。 在重定位表中,可以看到引用的所有外部符号的地址。每个要被重定位的地方叫一个重定位入口,Offset表示对应符号在段中的位置。一个Elf32_Rel数组中每个元素拥有r_offset和r_info两个变量,一个表示重定位入口的偏移,一个表示重定位入口的类型和符号,其中info用低8位表示类型,高24位表示其符号在符号表中的下标。4.2.3 符号解析 链接器需要对某个符号的引用进行重定位时,需要确定符号的目标地址,去查询全局符号表之后进行重定位。对于目标文件,它的符号表中存在表项有UND类型的情况,这种未定义的符号都是因为这个目标文件中有他们的重定位项,在链接器扫描完所有的输入文件后,所有的UND符号都须在全局符号表中找到4.2.4 指令修正方式 不同指令对地址处理的格式、方式都不一样。大体上有这些区别: • 近址寻址或远址寻址 • 绝对寻址或相对寻址 • 寻址长度为8/16/32/64位 这里只讨论对于32位x86ELF重定位的寻址方式:绝对/相对近址32位寻址。它们被修正的位置长度均为32位,使用r_info的低8位控制。 假设a.o,b.o链接后,main的虚拟地址为0x1000,swap为0x2000,shared为0x3000,借此例子来看一下链接器的修正方式。 绝对寻址,对于share的修正方式是R_386_32,修正结果应当是S+A(实际地址+修正位置的值),修正之后应当为0x3000,也就是c7 44 24 04 00 30 00 00 。 相对寻址,swap这条call的修正方式是R_386_PC32,相对寻址,应当是S+A-P,(P是被修正位置的虚拟地址)也就是0x2000+(-4)- (0x1000+0x27)= 0xFD5,而调用时调用的是下一条指令的地址加偏移,0x102B+0xFD5,正是swap的地址0x2000.4.3 COMMON块 若一个弱符号定义在多个目标文件中,而链接器并不能识别符号类型,应当如何处理呢?多个符号定义类型不一致主要有三种情况: • 两个或两个以上强符号类型不同 • 一个强符号,其他为弱符号,类型不同 • 两个或两个以上弱符号类型不同。 第一种情况显然会直接报错,主要讨论后两种情况。 编译器支持COMMON块,当不同目标文件需要的COMMON块大小不一致时,以最大的那块为准,当然,这种情况说的是都是弱符号的情况,如果有一个强符号,编译器会选择强符号的大小分配空间,如果链接过程存在弱符号大于强符号,链接器会给出警告。 如果一个未初始化的全局变量不是以COMMON块的形式存在,那么它就相当于一个强符号,如果其他目标文件中还有同一个变量的强符号,就会报符号重复定义错误。4.4 C++相关 主要讨论重复代码消除、全局构造析构以及C++的二进制兼容性4.4.1 重复代码消除 由于C++的特性,在多种情况下都可能在不同的编译单元中生成相同的代码。比如当一个模版在不同编译单元被实例化为相同类型的时候,就会产生重复代码。如果把这些代码都保留下来,会产生如下几个问题: • 空间浪费 • 地址出错:两个指向同一函数的指针不相等 • Cache命中率降低,直接导致运行效率低 目前主流编译器通常采用将每个模版的实例代码都单独存放在一个段里,GNU GCC称之为Link Once,命名为gnu.linkonce.name,name是模版函数修饰名称,VSC++则把这种类型的段叫做COMDAT,链接的时候将重复的段丢弃。 对于虚函数表等的做法也类似。这样的做法基本上解决了代码重复的问题,但是仍然存在一些问题,比如同名代码段可能拥有不同内容,在这种情况下,编译器将会随意选择一个副本链接,并抛出一个警告。4.4.1.1 函数级别链接 为了减少空间浪费,VSC++提供了一个函数级别链接的编译选项,将所有函数都单独保存到一个段里,需要用到某函数时,就将它合并到输出中,否则抛弃。但是它会减慢编译链接的过程,段的增加也导致了目标文件变大。4.4.2 全局构造与析构 main执行之前,为了程序能正常执行,往往要初始化执行环境,比如堆分配。C++的全局对象构造也是在此时被执行,同时main执行之后也会有一些清理工作。所以ELF定义了两种特殊的段.init和.fini。Glibc会分别在main前和main后执行对应的代码4.4.3 C++与ABI 不同编译器编译出来的目标文件往往不能相互链接。我们把符号修饰标准、变量内存布局等与二进制兼容性相关的内容称为ABI。因为不同编译器的ABI标准不同,所以无法相互链接。ABI与API非常类似,但是API一般指源码级别的接口,ABI是二进制层面的接口,往往比API要严格。比如C++的对象内存分布,是C++的ABI的一部分,API则更关注源码,比如POSIX规定printf的定义,但是不要求printf的具体实现,比如压栈方式、参数分布等问题。 硬件、语言、编译、链接器、操作系统都会影响ABI,这里通过C语言来看编程语言如何影响的ABI。 • 类型的大小与在储存器中的放置方式 (int/float), (MSB/LSB) • 组合类型(Struct)的存储方式和内存分布 • 外部符号、用户定义符号的命名和解析方式 • 函数调用方式 • 堆栈分布约定 • 寄存器使用约定 到了C++时代,还有更多的内容使得ABI必须有所调整,二进制兼容也更为不易 • 继承类的内存分布 • 成员函数的指针的内存分布 • 虚函数、虚函数表的内容与分布 • 模型的实例化 • 外部符号的修饰 • ...等等,更多的列举在笔记中意义不大,如果想进一步了解请直接看书吧 C++的二进制兼容性没有C好,甚至同一个编译器的不同版本都可能存在二进制不兼容的情况。现存的C++ ABI主要分为VSC++和GCC两大阵营,仍未能统一标准4.5 静态库链接。 一个静态库可以看成是一组目标文件的集合。将庞大的库通过ar压缩到一起,形成如libc.a这样的静态库文件(GCC,Linux下),Win下也有相应的工具lib.exe。通过objdump或者readelf加上grep就可以查看libc.a中目标符号的情况。ld链接器将会自动在链接库中寻找需要的目标文件,最后链接成可执行文件。当然,目标文件间也有一些依赖关系,ld会自动的处理好这些东西。在实际的编译过程中,一个libc.a往往是不够的,还有一些其他的库和目标文件会被链接进来。4.6 链接过程控制 当面向某些特殊使用场景编程时(比如内核驱动),我们可能需要指定段的名称、地址、顺序等,这时候就需要控制链接过程。4.6.1 链接控制脚本 通过命令参数、存放指令在目标文件中的控制方式都是控制链接的方式,但是我们需要一个更灵活强大的控制方式:链接控制脚本。这部分暂时不打算深入了解,对我目前的学习用处不大,日后出题的时候可能深入了解一下。4.6.2 最小的程序 通过编写一个不利用C库、程序入口非main、仅一个段的小程序,了解链接的控制过程 还是用了书上的代码,希望读这个的还是能买本原书支持一下作者。 代码内容不解释了,熟悉 Linux 系统调用很容易就能读懂。直接编译的话,需要为 ld 指定入口函数与连接方式,我们选择 -fno_builtin/-static 链接,它能够正常运行并拥有四个段,为了把它们放在一个段中,我们需要使用 ld 链接脚本。4.6.3 使用 ld 链接脚本 ld链接脚本并不复杂,ld以库文件、目标文件为输入、结果为输出。控制链接无非是指定输入如何变成输出:比如哪些段要合并,哪些段要丢弃,并指定输出段的名字、装载地址、属性等。书上的lds指定了程序入口,SECTION中包含一个赋值以及两条转换规则。通过ld的-T参数来使用这样的脚本。4.6.4 ld链接脚本语法 语法没什么可以记录的,需要的时候查书就好了4.7 BFD库 BFD作为GNU的一个项目,意在希望通过统一的接口处理不同的目标文件格式。这样,gcc、binutils这种工具可以专注于实现自己的功能,只需要使用BFD的接口就可以了,当需要支持一种新的文件格式,只需要在BFD中添加一种格式即可。
2021年09月26日
23 阅读
0 评论
0 点赞
2021-09-14
[学习/笔记] 程序员的自我修养-第三章
三、目标文件3.1 目标文件的格式 实际上,目标文件(.obj/.o)和可执行文件的文件组织结构并没有太大差别,可能有些符号或地址还没有调整 常见的可执行文件格式:Win-PE、ELF,它们都是COFF格式的变种。不太常见的格式在此不做记录 除可执行文件外,动态链接库和静态链接库也都按照可执行文件格式。静态链接库稍有不同:可以理解为一个有目录索引的库包。 • Relocatable file : 可以用来链接成可执行文件 .o/.obj/.a/.lib • Executable file : 可以直接执行的程序,ELF往往没有扩展名, PE为 .exe • Shared Object file : 链接器可以用共享目标文件和其他Relocatable File或者 SO file链接,或者ldd一类的动态链接器将其与可执行文件结合,作为进程的一部分 .so/.dll • Core Dump : 核心转储文件3.2 ELF文件 目标文件主要包含机器码、数据,此外还包括一些链接信息如符号表、调试信息、字符串等 信息按不同类型放在不同的节(Section)或者段(Segment)中 • 机器码一般被放在代码段(Code Section)中,常被记为.text/.code • 全局变量、静态变量一般被放在数据段(Data Section)中,而未初始化的全局、静态一般被放在BSS段中,目的是为了节省空间,因为为大量的0分配空间是没有必要的 • ELF文件头描述了ELF的文件属性,以及一个段表(Section Table),关于文件属性会在后面提到。把程序的指令和数据分开,有这么几个优点 • 数据和指令被映射到两个虚拟内存,指令地址可以被设置成只读,防止程序指令被改写 • 有助于提高程序的局部性,进而提高CPU的缓存命中率,提高程序的运行效率 • 运行多个某程序时,只需要保存一份指令,只将非只读的数据复制变为进程私有资源,这样可以节省大量的内存3.3 以 SimpleSection.o 为例挖掘 ELF 内容 为保护版权,本文并不会将文件内容贴在页面上,如有需求请购买本书。 利用gcc编译文件:gcc -c SimpleSection.c 利用objdump查看目标文件结构和内容。:objdum -h SimpleSection.o 可以发现有:.text .data .bss. rodata .comment .note.GNU-stack段。 段属性: Size 代表段长,File Offset代表段的偏移,CONTENTS代表该段在文件中存在 这张图展示了SimpleSection.o的结构,接下来我们逐步分析几个段。 使用size命令查看ELF的代码段、数据段等的长度size SimpleSection.o3.3.1 代码段 一般来讲,代码段可以直接翻译回汇编代码(在没有干扰反编译器的花指令的情况下),.text从第一个十六进制位到最后一个十六进制位,直接翻译即可3.3.2 数据段与只读数据段 对于字符串常量,比如printf中的参数"%d\n",这类只读数据往往会存放在rodata中,rodata在语义上支持了C++的const关键字,而且操作系统将rodata映射为只读,这使得程序的安全性得到保障。 有的时候某些编译器会把字符串常量扔在.data段,根据书上的描述,可以认为某种MSVC编译器会出现这种情况。 在data段,介绍了一个关于大小端序的问题:对于0x12345678,大端序在储存是是按照0x12放在低地址,在传输时是0x12首先传输,而小端序则正好相反。也就是说,在一般的大端序情况下,0x12345678在内存中的样子是0x12 0x34 0x56 0x78,而LSB是0x78 0x56 0x34 0x12,Intel主机主要采用小端序3.3.3 BSS段 前文提到过,全局的未初始化变量和局部静态的未初始化变量往往被存放在bss段,但是在某些编译器的视线中,全局的未初始化变量可能不会存放在bss段,只是预留一个未定义的全局变量,在链接时再为其在bss段分配空间,未来还会深入分析这个问题。3.3.4 其他段 ELF文件还会包含一些其他段,我们列举记录一些常用段及其默认功能 .rodata1 同rodata .comment 存放编译器版本信息,如(GCC:(GNU)4.2.0) .debug 调试信息 .dynamic 动态链接信息 .hash 哈希表 .line 行号表,指令与源代码的对应 .note 其他编译器信息,如版本号、公司名 .strtab String Table 字符串表 .symtab 符号表 .shstrtab 段名表 .plt.got 动态链接的跳转表和入口表 .init.fini 初始化与终结代码段 这些段都是用'.'作为前缀,表示系统保留。我们也可以用一些自定义的段。不过不能使用'.'作为前缀。此外,ELF可能存在两个或者两个以上叫.text的段。以及还可能出现一些已经弃用的保留字 可以通过objcopy将某些文件转换为目标文件的一个段 可以通过指定attribute使GCC将某些代码放在我们指定的段3.4 ELF文件描述 接下来看看ELF文件的结构格式,下图省去了一些繁琐的结构,只留下了一个最基本的结构 ELF目标文件的最前部是文件头(Header),描述了整个文件的属性,如文件版本、机器型号、程序入口地址。后面是各个段,段表(Section Header Table)描述了ELF文件中所包含的段的信息,如段名、偏移、权限以及其他属性。本节主要分析文件头、段表,其他结构将在其他章节展开3.4.1 文件头 我们使用 readelf命令查看elf文件。 ELF文件头定义了ELF魔数、机器字节长度、数据储存方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置长度以及段的数量 ELF文件结构在elf.h中定义了一套变量,以便在ELF相关的结构中使用。如果看过一遍,其实类型名和原始类型的对应是很明显的,这里列举一下便于查阅:3.4.1.1 ELF变量类型 • Elf32_Addr -> uint32_t 32位地址 • Elf32_Half -> uint16_t 32位有符号短整形 • Elf32_Off -> uint32_t 32位偏移地址 • Elf32_Sword -> uint32_t 32位有符号整形 • Elf32_Word -> int32_t 32位无符号整形 • Elf64_Addr -> uint64_t 64位地址 • Elf64_Half -> uint16_t 64位有符号短整形 • Elf64_Off -> uint64_t 64位偏移地址 • Elf64_Sword -> uint32_t 64位有符号整形 • Elf64_Word -> int32_t 64位无符号整形 继续看ELF头,ELF头结构位Elf32_Ehdr,下面是它的定义:typedef struct { unsigned char e_indent[16]; Elf32_Half e_type; Elf32_Half e_machine; Elf32_Word e_version; Elf32_Addr e_entry; Elf32_Off e_phoff; Elf32_Off e_shoff; Elf32_Word e_flags; Elf32_Half e_ehsize; Elf32_Half e_phentsize; Elf32_Half e_phnum; Elf32_Half e_shentsize; Elf32_Half e_shnum Elf32_Half e_shstrndx; } 额,看起来就让人学不下去,但是还是要一个个的了解一下。不知道笔记里面做什么好,就单纯的把书上的表格贴出来吧,这种定义性的东西可能更多还是要背, 只能记录一下sh代表section headers,ph代表program headers了3.4.1.2 ELF魔数 e_ident共16个字节,标识ELF文件属性,前四个字节为 0x7F, 'E', 'L', 'F'。可执行文件的开头几户都是魔数,a.out为0x01 0x07,PE为'M', 'Z'。系统加载可执行文件的第一步就是检查魔数是否正确。 回到e_ident,第五个字节标识ELF文件类,0x01为32bit,0x02位64bit。第六个字节为字节序,规定文件大端序或小端序。第七个字节为ELF的主版本号,一般是1,因为ELF自1.2之后再也没有更新。后面九个字节往往填充0,某些平台也会利用这九位做其他标识3.4.1.3 ELF文件类型 e_type中存放ELF文件类型(.o/NONE/.so),常量标识符以ET_开头3.4.1.4 机器类型 ELF设计成跨平台的文件类型,但是这不代表一个ELF可以跨平台使用,e_machine代表了ELF的平台属性,常量标识符以EM_开头,3代表Intel x863.4.2 段表 段表记录了段的结构:名称、长度、偏移、权限等属性。段表的偏移在e_shoff字段。通过readelf命令可以查看段表结构。而objdump只是将重要的段显示出来。 段表是一个以'Elf32_Shdr'为元素的数组,Also known as ‘Section Descriptor’。第一个元素是无效的描述符,Elf32_Shdr结构如下z。 所有的这些属性都会在readelf -s中有所体现。我认为这部分结构已经体现的没有特别重要的内容,诸如类型、标志位会在后文记录 观察程序ELF结构,可以发现Section Table的起始地址为0x0118,这正好是Header中 e_shoff 的值,而Section Table的前驱段 .shstrtab 结束地址为0x117,这应当是因为sh_addralign使得Section Table对对齐到2的指数倍。3.4.2.1 段的类型 尽管段名字对编译、链接过程有意义,编译器和链接器读取到的决定性属性是段的类型(sh_type)和段的标志位(sh_flags)。段的相关类型以SHT_做前缀,从0-11共12个常量,无效段,程序、代码、数据段,符号表,字符串表,重定位表,符号哈希表,动态链接信息,无内容,重定位信息,保留以及动态链接符号表等。这种东西我觉得往往是有需要的时候利用好搜索引擎即可。3.4.2.2 段的标志位 表示段在进程虚拟地址空间的属性,是一个三位2进制码,类似rwx,不过这个是xaw,a指是否需要分配空间,某些段包含的是指示/控制信息,不需要分配空间,但代码段、数据段等需要分配空间。 系统保留段的属性一般是预先规定好的,如data段是-aw,text段是xa-,也有一些根据实际情况而不同的flag3.4.2.3 段的链接信息 sh_link与sh_info只对链接相关的段有意义,link往往存放下标与系统相关信息之类的。3.4.3 重定位表 .rel.text段的type为"SHT_REL",是一个重定位表。.rel.text 就是对.text段的重定位表,因为.text端至少有一个绝对地址的引用:printf的调用,而.data并没有相关引用,所以在这份文件中没有出现.rel.data, 在这里,link提示下标,info代表它作用于哪个段,因为.text的下标为一,所以.rel.text的sh_info是13.4.4 字符串表 段名、变量名等字符串的长度往往不定,难以使用固定结构存放,往往通过放在字符串表中字符串的偏移来引用,ELF引用字符串只需要引用一个下标.strtab/.shstrtab分布代表字符串表和段表字符串表,前者保存普通的字符串,后者保存段表中用到的字符串。 回头看一下e_shstrndx,它代表了段表字符串表shstrtab在在段表中的下标。这意味着我们获取ELF文件头后,即可获取段表位置和段表字符串表偏移,进而解析整个ELF文件。3.5 符号 符号就是链接这个拼图过程中,拼图的凹凸部分。我们将函数和变量称为符号,函数名、变量名称为符号名。目标文件在符号表中记录了这些符号,每个符号有一个符号值,对于变量、函数,这个符号值就是他们的地址。在这一部分,我们主要关注能被其他目标文件引用的全局符号,以及在本目标文件中引用,但是没有在本目标文件中定义的外部符号(External Symbol),比如printf函数 此外,还有如下三类符号: • 段名,往往由编译器产生,值就是段地址 • 局部符号,只在编译单元内课间。 • 行号信息 readelf、objdump以及nm都可以用来查看ELF的符号表。3.5.1 ELF符号表结构 ELF文件中有一个段一般叫".symtab",是一个Elf32_sym数组,其中第一个元素为无效符号,它的定义如下:3.5.1.1 符号类型和绑定信息 书上声称st_info的低四位代表符号类型,高28位表示符号绑定信息。但是st_info是一个uchar,只有8位,加之符号绑定信息常量只有0、1、2共三种,我怀疑绑定信息也应当只有四位,本着追根溯源的想法,去找了找相关资料,发现了一个读取符号类型和绑定信息的宏#define ELF_ST_BIND(info) ((info) >> 4) #define ELF_ST_TYPE(info) ((info) & 0xF) 好吧,没有什么有用的信息,但是发现宏值不止书上给的3/4种,还有13-loproc、15-hiproc,进一步读了读感觉这两个没啥用,但是最高为15进一步印证了我的想法。 前去查询一下ELF文档试试。文档中并没有描述具体是多少位,由此,我认为我的看法应该是对的。根据ELF文档给的宏定义,如果info确实为uint8,那么右移四位后必然只能读取到高四位。如果对此有一些其他想法的师傅,也欢迎指出我的错误。 言归正传,符号绑定声明了符号对外部是否可见,或是否是弱引用。而符号类型定义了这个符号的类型(废话。。。)数据/代码/段/文件名等3.5.1.2 符号所在段、符号值 st_shndx分两种情况,如果符号定义在本目标文件中,它表示符号在段表中的下标,否则会有三种情况:SHN_ABS\SHN_COMMON\SHN_UNDEF,ABS表示符号包含了一个绝对的值,COMMON表示是一个COMMON块的符号、UNDEF代表定义在其他文件中,并且在本文件中引用了。 st_value在一般情况下,即如果这个符号是一个函数或者变量的定义,那么符号值就是函数/变量的地址,具体有以下几种情况 • 若某符号不属于COMMON块,且在目标文件中定义,则value代表该符号在段中的偏移 • 若符号属于COMMON块,value代表其对齐属性 • 在可执行文件中,value表示符号的虚拟地址,动态链接器将使用这个虚拟地址。 通过readelf -s,可以分析各个符号在符号表中的状态 • 对于func1和main,他们在代码段.text中,所以Ndx项为1,Size表示函数指令占的字节数而Value项对代码段起始位置的偏移。 • printf被引用,但是没有被定义,所以Ndx为SHN_UNDEF • global_init_var在bss段,下标为3 • global_uninit_var未初始化,是一个COMMON类型的符号 • static_var.1533/1534是两个静态变量,只在编译单元内部可见 • 对于SECTION类型的符号,表示第Ndx段的段名,比如某符号的Ndx为1,那么他就是.text段的段名,也即应该就是".text"3.5.2 特殊符号 ld在链接的时候,会处理许多特殊的符号,可以在未定义的前提下直接声明或使用。它们实际上被定义在ld的链接脚本中。只有在产生可执行文件的时候,这些符号才会存在。下文列举几个特殊符号: • __executable_start : 程序起始地址 • __etext/\_etext/etext : 代码段结束地址 • \_edata/edata : 数据段结束地址 • \_end/end : 程序结束地址 • 这些都是装载时的虚拟地址 在经过extern引用后,我们可以直接使用这些符号。3.5.3 符号修饰与函数签名 在最开始,产生目标文件的符号名与变量、函数名是一致的。但是后期由于汇编库越来越庞大,如果还如此操作,会使得编写的程序与库产生冲突。于是UNIX规定,C源代码经过编译后,相应的符号名前加上下划线"_", 而Fortran在前后分别加上一个"_"。但是这种方式没有从根本上解决问题,当不同模块由不同单位开发时,若命名规范不同,则有可能导致冲突。C++中namespace的出现就是考虑到这个问题,解决多模块的符号冲突。 但是后来,操作系统和编译器被完全重写过数次,GCC已经去掉了加"_"的方式,但是Windows还在保留这样的传统,gcc可以通过参数选项来选择是否在C语言符号上加下划线。3.5.3.1 符号修饰 C++拥有类、继承、虚机制、重载等特性,使得符号管理更复杂,如func(int)/func(double),尽管函数名相同,但是参数列表不同。为了支持C++的特性,采用符号修饰或符号改编机制。 C++允许同名函数不同参数列表,也就是函数重载,同时允许不同命名空间中有多个同样名字的符号。书上给了一个奇特的实例代码:int func(int); float func(float); class C { int func(int); class C2 { int func(int); }; }; namespace N { int func(int); class C { int func(int); }; } 函数签名包含了函数的信息,函数名、参数类型、所在的类、命名空间等信息,函数签名用来识别不同的函数。在编译器、链接器处理符号的时候,使用某种名称修饰的方法,使得函数签名对应一个修饰后名称。编译成目标文件时,将函数、变量的名字进行修饰,也即目标文件中存放的Symbol是修饰后的名称,因为Symbol不同,也就不会产生冲突问题。GCC下基本修饰方法如下: • 所有的符号以"_Z"开头 • 如果在namespace或者class中,后面跟着N • 然后是一系列namespace或class,先是字符串长度,然后是字符串名字 • 之后以E结尾 • 之后是参数列表,比如int会用i表示int C::C2::func(int) -> _ZN1C2C24funcEi 同样,变量也会被用类似的方法修饰,但是修饰名称中并不包含变量的类型 同时,名称修饰也需要防止静态变量的明智冲突,比如main和func中都有静态变量foo,GCC会分别修饰成_ZZ4mainE3foo, _ZZ4funcvE3foo.(?)v真的存在么? VC++的修饰方法和GCC并不相同,比如int C::C2::func(int) -> ?func@C2@C@@AAEHH@Z • 由?开头 • 然后是由@结尾的函数名 • 然后是嵌套上一层的C2,以@结束,直到嵌套结束,再一个@表示名称空间结束。 • 然后是参数类型与返回值,由@结束 • 然后由Z结尾 Windows API中有UnDecorateSymbolName(),可以将修饰后名称转换为函数签名。 由于不同编译器在实现上,比如名称修饰方法上不同,所以不同编译器的目标文件不能相互链接。3.5.4 extern "C" C++为了兼容C,拥有一个用来声明/定义C符号的 extern "C"关键字。C++编译器会将其打括号内的代码当作C语言代码处理,除了兼容性之外,还有一个很有趣的现象:对于#include <cstdio> namespace myname{ int var = 42; } extern "C" double _ZN6myname3varE; int main() { printf("%d\n", _ZN6myname3varE); } 输出的结果是42,因为var被修饰后的名称正好是ZN6... 在C语言中,如果include string.h, 编译器会正确的处理memset,但是在C++中,编译器会将其修饰成_Z6memsetPvii, 导致无法与C库中的memset链接,所以memset必须要通过extern C声明,但是C语言又不支持extern C,所以我们需要使用cplusplus这个宏#ifdef __cplusplus extern "C" { #endif ... #ifdef __cplusplus } #endif3.5.5 弱符号与强符号 前文已经多次提到过强弱符号的问题。在这里解释一下。当多个目标文件中有多个同名符号,链接的时候可能会出现符号重复定义的错误。这种符号的定义被称为强符号。编译器认为函数和被初始化的全局变量为强符号,未初始化的为弱符号。同时可以通过GCC的attribute定义强符号为弱符号,符号是针对定义而不是引用。 • 强符号不允许被多次定义 • 如果某文件中A为强符号,其他文件中为弱符号,那么选择强符号 • 如果所有文件中A都为强符号,选择占用空间最大的那个3.5.5.1 弱引用呢与强引用 对外部文件引用在链接时,如果没有找到定义,就会报未定义错误,这就是强引用。与之对应的还有弱引用,如果该符号在引用时找到了定义,则链接器引用,否则链接器不宝座,而是一般默认其为0或者某特殊值,便于程序识别,它们主要用于库的链接过程。当调用未被定义的弱引用时,会触发程序错误,因为意图访问0这个地址。库中的弱符号可以被用户定义的强符号覆盖,即使弱引用的模块被去掉,程序也可以正常链接。 3.6 调试信息 目标文件里还可以保存调试信息,将一些源代码信息存在目标文件中,ELF文件采用DWARF格式保存调试信息。
2021年09月14日
16 阅读
0 评论
0 点赞
2021-09-08
[学习/笔记] 程序员的自我修养-第二章
编译和链接2.1 编译 编译可以分为四个步骤:预处理(Prepressing),编译(Compilation),汇编(Assembly),链接(Linking)2.1.1 预编译(cc1)(以gcc为例) 预编译负责展开#开始的预编译指令,如include、define、ifdef等,对于define直接展开,对于include递归引用。删除注释、添加行号,保留#pragma2.1.2 编译 (cc1) 经过词法分析、语法分析、语义分析、优化后产生汇编代码文件2.1.3 汇编(as) 将汇编代码转换成机器码,只是根据汇编和指令的对照一一翻译即可2.1.4 链接(ld) 将目标文件链接到一起,得到可执行文件2.2 编译器做了什么 书上以 array[index] = (index + 4) * (2 + 6) 举例2.2.1 词法分析 源代码被输入到扫描器,扫描器负责词法分析,将字符串分割成一系列的记号(Token)如 array、[、index、]等共16个记号将会被分类,array、index等标识符将会被送到符号表,4、2、6等常量将会被送到文字表2.2.2 语法分析 语法分析器对扫描器产生的符号进行语法分析、产生语法树,以表达式为节点,例子的语法树如下: 此时也将确定一些符号的优先级和含义,同时如果发现不合法也会在此阶段报告语法分析的错误2.2.3 语义分析 语义分析由语义分析器完成,语法分析无法判断一个表达式是否真正的有意义,比如两个指针的乘法运算尽管是在语法上合法的,但是显然是没有意义的。动态语义只有运行期间确定,而语义分析负责分析静态语义,语义分析会在语法树上标识类型,隐式转换也是在这个期间完成的2.2.4 中间语言生成 在这里,源码级优化器将会进行一次优化,如(2+6)这个在编译阶段就可以获得固定值的表达式将会直接优化成8,具体的优化过程日后编译原理再学,现在只做简单了解。 编译器可以被分为前端和后端。前端负责产生中间代码,中间代码是和运行硬件无关的,后端将中间代码转化成机器码。我认为LLVM就是一个比较典型和广泛的前后端分离编译器。用同一个优化前端生成IR码,再通过不同的后端翻译成不同平台的机器码。2.2.5 目标代码的生成和优化 这个阶段,编译器后端(主要由代码生成器和目标代码优化器组成)将会把中间代码转换成目标机器码。这部分的优化类似选择合适的寻址方式、利用位移代替乘法、删除多余指令等 经过了以上的步骤,源代码已经被编译成了目标代码,但是这时候index和array的地址还没有确定,由此我们可以引入链接的问题:若index和array的地址在其他编译单元中,就需要链接器出马把它们翻译成相应的地址2.3 链接 由于软件的规模日益增长,庞大的代码量使得人们为了修改和复用构建了模块化的组织形式,不同的模块需要按照层次结构或者约定的其他结构组织。如何将模块复合到一起,实现模块间的函数调用以及模块间的变量访问(这两个问题可以归结为一个问题:如何跨模块引用符号(Symbol)(符号往往代表着一个地址,可能是函数也可能是变量的起始地址))书上用了一个形象化的方式解释:定义符号的模块相当于多出一块区域的拼图版,而引用对应符号的模块相当于刚好少这样一块区域的拼图版。这样的拼接过程也就是链接(Linking)2.4 静态链接 链接主要包括了地址空间分配、符号决议和重定位的步骤。每个模块的源代码经过编译器生成目标文件,目标文件和库一起链接成可执行文件。当我们在main.c中引用func.c中的函数foo(),main的每一处foo调用都必须要知道foo的地址,在编译时,编译器并不知道foo的地址,所以暂时不处理这样的地址。在链接时,链接器会自动的去相应的func中找到foo的地址并填会main。这个过程也称为重定位,使得对绝对地址引用的位置指向正确的地址
2021年09月08日
29 阅读
0 评论
0 点赞
1
2