Linux内核工程导论——进程:ELF文件执行原理(2)

ELF

强符号与弱符号(本小节是转别人的)

我们经常在编程中碰到一种情况叫符号重复定义。多个目标文件中含有相同名字全局符号的定义,那么这些目标文件链接的时候将会出现符号重复定义的错误。比如我们在目标文件A和目标文件B都定义了一个全局整形变量global,并将它们都初始化,那么链接器将A和B进行链接时会报错:

1 b.o:(.data+0x0): multiple definition of `global‘
2 a.o:(.data+0x0): first defined here

这种符号的定义可以被称为强符号(Strong Symbol)。有些符号的定义可以被称为弱符号(Weak Symbol)

对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们也可以通过GCC的"__attribute__((weak))"来定义任何一个强符号为弱符号。注意,强符号和弱符号都是针对定义来说的,不是针对符号的引用。比如我们有下面这段程序:

extern int ext;
int weak;
int strong = 1;
__attribute__((weak)) weak2 = 2;
int main()
{
        return 0;
}

上面这段程序中,"weak"和"weak2"是弱符号,"strong"和"main"是强符号,而"ext"既非强符号也非弱符号,因为它是一个外部变量的引用。

针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:

· 规则1:不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。

· 规则2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。

· 规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。

弱引用和强引用  

目前我们所看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。与之相对应还有一种弱引用(Weak Reference),在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误。一般对于未定义的弱引用,链接器默认其为0,或者是一个特殊的值,以便于程序代码能够识别。

在GCC中,我们可以通过使用"__attribute__((weakref))"这个扩展关键字来声明对一个外部函数的引用为弱引用,比如下面这段代码:

1 __attribute__ ((weakref)) void foo();
2 int main()
3 {
4         foo();
5 }
6

我们可以将它编译成一个可执行文件,GCC并不会报链接错误。但是当我们运行这个可执行文件时,会发生运行错误。因为当main函数试图调用foo函数时,foo函数的地址为0,于是发生了非法地址访问的错误。一个改进的例子是:

1 __attribute__ ((weakref)) void foo();
2 int main()
3 {
4         if (foo)
5                foo();
6 }
7

      这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。

在Linux程序的设计中,如果一个程序被设计成可以支持单线程或多线程的模式,就可以通过弱引用的方法来判断当前的程序是链接到了单线程的Glibc库还是多线程的Glibc库(是否在编译时有-lpthread选项),从而执行单线程版本的程序或多线程版本的程序。我们可以在程序中定义一个pthread_create函数的弱引用,然后程序在运行时动态判断是否链接到pthread库从而决定执行多线程版本还是单线程版本。

Segment和section

Elf文件里面有segment,有section。我们知道执行的时候,所有的应用程序都是首先通过加载器ld.so加载到内存然后执行的(内部集成了加载器的除外),而所有的elf文件也都是经过了链接的过程形成的。Segment就是提供信息给ld.so这个加载器信息,告诉他怎么加载的,而section就是提供给ld程序,告诉他怎么链接的。其实section本质上就是个记录的作用,elf没有它也完全可以正常工作,因为segment已经提供了工作所需要的信息。但是section是ld链接的时候的工作过程,记录了将不同的内容放到不同的文件位置的分布,section表就是这个分布的一个总体描述。section的最大意义在于让elf有语义的意义,没有section只有segment的话,elf就只是个可以执行的文件,别人没办法分析他的组成,分析他的二进制格式。缺少了二进制层次上的语义,也正因为反正都决定要在二进制文件中保留section了,那么有的segment也就可以用section来组织。毕竟可执行文件的物理组装是通过section完成的,而segment也要告诉ld.so怎么使用这个物理文件,所以两者发生交互就会方便很多。而现在的很多section在运行期间也是有用的,例如.text, .got可以用来找到动态库的符号。

连接器常见的c级是gnu linker,默认是集成在gcc里面的,但是谷歌觉得它太慢,又开发了一个新的链接器:gold。Gnu linker使用的bfd库,而gold只是部分的支持elf的全部特性,去除了谷歌认为不需要的,并且没有使用bfd库,所以可以做到很快。Gold现在也被加入到binutilies包里面了。

生成时从首到尾写,生成完写入section table。而运行是从头到尾读,所以segment table要放在开头。

不固定位置编译

ASLR可以把动态库加载到随机的内存地址,这样就可以增加攻击者的调试难度。但是可执行文件自己在大部分情况却是有固定的开始执行地址的,这就给攻击者提供了方便。但是仍然有办法让这个地址随机,就是PIE(Position Independent Executable),这个可以把二进制编译成位置无关的文件,而是由内核来完成这个位置无关的随机化过程。所以这个特性需要内核支持。而还有一个需求是要求位置无关的,就是动态库和.o这种编译生成的中间代码,这几种所用的技术和思想都是类似的。

如果我们使用-fpic参数,就可以生成位置无关的动态库,而如果我们使用-fpie参数,就可以生成位置无关的可执行文件。这两者在使用上的差别很大,一个是用来给别人加载的,一个是用来直接执行的,但是这两者是技术上差别很小,有两个主要的差别:-fpic生成的文件加上一个PT_INTERP段和一些启动代码,就比较像-fpie生成位置无关进程。而两者甚至可以用同样的启动代码。-fpic由于用来生成动态链接库,所以符号不能直接解析到找到的符号,甚至可以允许找不到符号,动态库本身允许引用外部的库,所以在编译自己的时候不需要链接外部的库,只需要把它使用到的外部的库函数放入PLT表,链接的时候或者加载的时候解析就好,而可执行程序要求所有的符号立即解析,并且不允许有解析不了的符号。

上面的程序是非pie的,可以看到所有的地址都是绝对地址,第一个LOAD指明在二进制文件的开头到0x16f88字节要加载到内存的0x08048000地址,而二进制文件偏移的0x016f88往后的0x01543字节要加载到内存的0x0805ff88位置。所以,这是一个位置固定的可执行程序。

动态库

动态库的核心包含了两个层次的代码共享:编译成的二进制可以不用每个二进制文件都包含一份动态库的拷贝;在执行的时候,动态库的代码段只需要加载一次,后面再有人用到同一个动态库,内核就可以把动态库代码段加载到的页面直接映射到其他需要的进程,这样代码段也不用加载多次(代码段是只读的)。

而加载到内存,由于CPU执行的时候必须要使用相对或者绝对地址,代码段的每个函数虽然在物理地址是一样的,但是他们映射到每个进程内存空间的地址都是不一样的。所以动态库需要有一份符号表,记录了其在代码段的偏移,然后还需要一个全局偏移,意味着其整个符号表在进程地址空间中的偏移。

而一个使用了动态库的可执行文件,其内部有调用这些符号的代码,这种代码是无法在链接期解析出具体的地址偏移的(因为链接器根本没有实际的链接他们),所以他们在二进制文件中只是一个占位符,其地址需要在动态库加载了之后再填充。而这种调用是分散在整个程序中的,所以在加载后就得去搜索找到所有的未解析符号去解析,这样肯定是不合适的,所以就需要有一个表,记录了所有这些没有解析的符号。

动态库在内存中只要执行到任何一行动态库的代码,动态库就可以通过偏移找到本库内的其他符号,因为同一个库的符号偏移,本库内都是知道的。但是可惜的是i386不支持通过当前执行指令(PC)偏移的寻址方式(如果支持就简单了,根本什么重分配都不需要,只需要在执行到的代码中使用偏移就好了)。但是x64是支持的,所以elf在x64时代慢慢的可能要有点变化。

上面分别说了可执行文件和动态库的需求,两者的衔接就是通过.got段和.plt段.got是数据外部解析,.plt是函数外部解析。Elf文件链接完成,将调用动态库的符号放到这两个表里,当动态库加载的时候,加载器要负责查找这个表,将加载的动态库的对应的符号所在内存的地址填充到可执行文件的这两个表,如此完成加载时候的符号绑定。同时解决了动态库位置不固定的问题。

可执行栈

Gcc对c支持一个扩展,就是函数的嵌套定义。而这个定义是通过将嵌套的函数代码放在栈中执行的,这就要求这种栈是有可执行权限的。而如果代码中没有使用这个功能,栈的可执行权限就会开启,所以如果代码的安全性要求比较高,就不要使用嵌套函数。

Elf构造函数与析构函数

编程语言的main函数是程序逻辑的开始,但是一个程序只有逻辑是无法运行的,程序的开头部分需要有一些在main函数执行之前执行的函数,结束的时候也需要有一些在程序逻辑执行完之后执行的内容。比如全局变量的初始化和回收。

这个扩展是gcc添加的(由于加载器也是gcc自带的,所以它认识gcc自己添加的扩展),这是通过在elf文件中添加一个段来实现的:.ctors和.dtors(一个构造,一个是析构)。这两个段里面放的是函数列表,启动和结束的时候会顺序调用。至于里面是什么,那就跟不同的变成语言相关了,有的甚至可以做到不使用这两个段。

除了.ctors和.dtors,完成同样功能的还有.init和.finit两个段,有的系统两种都支持,有的只支持一种。如果两种都支持.init会在.ctors执行之前执行。.init和.finit比较老,格式组织也比较混乱,而.ctors和.dtors只单纯的函数表,在使用上.ctors系列是.init系类的后继改良版本,但是.init系类仍然是elf的标准结构。

函数调用栈

X86与x64提供了栈的寄存器指针,但是并不规定怎么使用这个栈,例如参数入栈的先后顺序,返回值放在哪里,两个调用之间是否要空点空间。

在x86时代,常用的调用栈有:stdcall, thiscall, fastcall, cdecl,这几种在对栈的使用上有区别。在x64时代,只剩下fastcall一种。例如stdcall的调用约定意味着:1)参数从右向左压入堆栈,2)函数自身修改堆栈 3)函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。Stdcall因为早期用在pascal有此殊荣。c的默认是cdel,cdecl调用约定的参数压栈顺序是和stdcall是一样的,参数首先由右向左压入堆栈。所不同的是,函数本身不清理堆栈,调用者负责清理堆栈。由于这种变化,C调用约定允许函数的参数的个数是不固定的,这也是C语言的一大特色。thiscall是为了解决面向对象的函数调用要默认传输this指针,所以是C++的默认调用方式,参数从右向左入栈。

而fastcall使用寄存器来传递参数,因为在x64环境,寄存器很多,所以规定了fastcall的前4个整数和浮点都放入寄存器中,超过的部分才放入栈中。所以,使用fastcall可以显著的加快调用速度。也是因此,在写代码的时候,尽量使用4个以下的函数参数。fastcall也保留了cdel的灵活,由调用者清理栈,所以也可以做到参数不固定。但是你看你的栈可能会发现有一块额外的空间,x64会默认的在站上分配一个备份空间,用来core dump分析的时候方便。这个空间保存了每次发生函数调用的寄存器情况。如果你开了编译器优化,这个空间一般就不会保留了。

时间: 2024-10-31 16:46:55

Linux内核工程导论——进程:ELF文件执行原理(2)的相关文章

Linux内核工程导论——进程

进程 进程调度 概要 linux是个多进程的环境,不但用户空间可以有多个进程,而且内核内部也可以有内核进程.linux内核中线程与进程没有区别,因此叫线程和进程都是一样的.调度器调度的是CPU资源,按照特定的规则分配给特定的进程.然后占有CPU资源的资源去申请或使用硬件或资源.因此这里面涉及到的几个问题: 对于调度器来说: l  调度程序在运行时,如何确定哪一个程序将被调度来使用CPU资源? n  如何不让任何一个进程饥饿? n  如何更快的定位和响应交互式进程? l  单个CPU只有一个流水线

Linux内核工程导论——用户空间进程使用内核资源

本文大部分转载和组装,只是觉得这些知识应该放到一起比较好. 进程系统资源的使用原理 大部分进程通过glibc申请使用内存,但是glibc也是一个应用程序库,它最终也是要调用操作系统的内存管理接口来使用内存.大部分情况下,glibc对用户和操作系统是透明的,所以直接观察操作系统记录的进程对内存的使用情况有很大的帮助.但是glibc自己的实现也是有问题的,所以太特殊情况下追究进程的内存使用也要考虑glibc的因素.其他操作系统资源使用情况则可以直接通过proc文件系统查看. 进程所需要的系统资源种类

Linux内核工程导论——基础架构

基础功能元素 workqueue linux下的工作队列时一种将工作推后执行的方式,其可以被睡眠.调度,与内核线程表现基本一致,但又比内核线程使用简单,一般用来处理任务内容比较动态的任务链.workqueue有个特点是自动的根据CPU不同生成不同数目的队列.每个workqueue都可以添加多个work(使用queue_work函数). 模块支持 模块概述 可访问地址空间,可使用资源, 模块参数 用户空间通过"echo-n ${value} > /sys/module/${modulenam

Linux内核工程导论——用户空间设备管理

用户空间设备管理 用户空间所能见到的所有设备都放在/dev目录下(当然,只是一个目录,是可以变化的),文件系统所在的分区被当成一个单独的设备也放在该目录下.以前的2.4版本的曾经出现过devfs,这个思路非常好,在内核态实现对磁盘设备的动态管理.可以做到当用户访问一个设备的设备的时候,devfs驱动才会去加载该设备的驱动.甚至每个节点的设备号都是动态获得的.但是该机制的作者不再维护他的代码,linux成员经过讨论,使用用户态的udev代替内核态的devfs,所以现在的devfs已经废弃了.用户态

Linux内核工程导论——内存管理(一)

Linux内存管理 概要 物理地址管理 很多小型操作系统,例如eCos,vxworks等嵌入式系统,程序中所采用的地址就是实际的物理地址.这里所说的物理地址是CPU所能见到的地址,至于这个地址如何映射到CPU的物理空间的,映射到哪里的,这取决于CPU的种类(例如mips或arm),一般是由硬件完成的.对于软件来说,启动时CPU就能看到一片物理地址.但是一般比嵌入式大一点的系统,刚启动时看到的已经映射到CPU空间的地址并不是全部的可用地址,需要用软件去想办法映射可用的物理存储资源到CPU地址空间.

Linux内核工程导论——网络:Netfilter概览

简介 最早的内核包过滤机制是ipfwadm,后来是ipchains,再后来就是iptables/netfilter了.再往后,也就是现在是nftables.不过nftables与iptables还处于争雄阶段,谁能胜出目前还没有定论.但是他们都属于netfilter项目的子成员. 钩子 netfilter基于钩子,在内核网络协议栈的几个固定的位置由netfilter的钩子.我们知道数据包有两种流向,一种是给本机的:驱动接收-->路由表-->本机协议栈-->驱动发送.一种是要转发给别人的:

Linux内核工程导论——网络:Filter(LSF、BPF)

数据包过滤 LSF(Linux socket filter)起源于BPF(Berkeley Packet Filter),基础从架构一致,但使用更简单.其核心原理是对用户提供了一种SOCKET选项:SO_ATTACH_FILTER.允许用户在某个sokcet上添加一个自定义的filter,只有满足该filter指定条件的数据包才会上发到用户空间.因为sokket有很多种,你可以在各个维度的socket添加这种filter,如果添加在raw socket,就可以实现基于全部IP数据包的过滤(tcp

Linux内核工程导论——UIO

要开启hugepages文件系统,这个文件系统要使用mmap来映射页,可以显著的减少缺页中断. UIO介绍 UIO是一个在用户端实现内核驱动的机制.其在内核中有一个模块支持uio模块.现在这个模块只支持字符设备.用户可以添加多个uio设备(用户端的设备驱动),每个设备在/dev/uioX,X为数字,第一个为0,依次类推.我们知道设备都是靠中断来响应的,响应uio设备中断的方法是读取/dev/uioX文件,没有中断的时候读取会阻塞,来中断的时候会读取到整数值,代表已经发生的中断的次数. 但是这只是

Linux内核工程导论——内存管理(三)

用户端内核内存参数调整 /proc/sys/vm/ (需要根据内核版本调整) 交换相关 swap_token_timeout Thisfile contains valid hold time of swap out protection token. The Linux VM hastoken based thrashing control mechanism and uses the token to preventunnecessary page faults in thrashing s