Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Week6: 链接 #6

Open
OhBonsai opened this issue Nov 1, 2020 · 0 comments
Open

Week6: 链接 #6

OhBonsai opened this issue Nov 1, 2020 · 0 comments
Labels
CSAPP 深入理解计算机系统

Comments

@OhBonsai
Copy link
Owner

OhBonsai commented Nov 1, 2020

为什么要理解链接?

  • 理解链接器将更加容易构造大型程序
  • 理解连接器可以避免一些错误
  • 理解链接器可以理解语言的的作用于规则是如何实现的
  • 理解链接器可以理解系统的一些重要的概念
  • 理解链接器使你能够利用共享库

编译驱动程序

使用gcc -Og -o prog main.c sum.c编译程序

    main.c                  sum.c        源代码
      |                      |
   翻译器,cpp,cll,as     翻译器,cpp,cll,as
      |                      |
    main.o                  sum.o        可重定位目标文件
      |                      |
	  |_______链接器LD_______|
	             |
				prog  完全可执行的目标
  1. cpp [other arg] main.c /tmp/main.i.这一步将源码变成ASCII中间文件
  2. ccl /tmp/main.i -Og [other arg] -o /tmp/main.s.这一步编译出汇编源文件
  3. as [other arg] -o /tmp/main.o /tmp/main.s 这一步搞出可重定位目标文件
  4. ld -o prog [other arg] /tmp/main.o /tmp/sum.o 这一步高出可执行目标文件
    这里会出现一些错误,必须要手动链接libc.so. 可是我不会啊...

静态链接

像Linux LD程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的,可以加载和运行的可执行文件作为输出.为了构造可执行文件,链接器必须完成两个任务

  • 符号解析: 目标文件定义和引用符号,每个符号对应于一个函数,一个全局变量和一个静态变量.符号解析的目的是将每个符号引用正好和一个符号定义关联起来
  • 重定位: 编译器和汇编器生成从地址0开始的代码和数据节.链接器通过把每个符号定义为一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得他们指向这个内存位置.链接器使用汇编器产生的重定位条目的详细指令.不加甄别的执行这样的重定位

目标文件

目标文件有三种形式

  • 可重定位目标文件: 包含二进制代码和数据,其形式可以再编译时与其他可重定位的目标文件合并起来,创建一个可执行目标文件
  • 可执行目标文件: 包含二进制代码和数据,其形式可以直接复制到内存并执行
  • 共享目标文件: 一种特殊类型的可重定位目标文件,可以再加载或者运行时被动态的加载进内存并链接

可重定位目标文件

ELF(Executable and Linkable Format).

 ___________________________0__
|         ELF头            |   |
|--------------------------|   |
|        .text             |   |
|--------------------------|   |
|        .rodata           |   |
|--------------------------|   |
|        .data             |   |
|--------------------------|   |
|        .bss              |   |
|--------------------------|   |
|        .symtab           |  节
|--------------------------|   |
|        .rel .text        |   |
|--------------------------|   |
|        .rel .data        |   |
|--------------------------|   |
|        .debug            |   |
|--------------------------|   |
|        .line             |   |
|--------------------------|   |
|        .strtab           |   |
|--------------------------| 描述
|        节头部表          | 目标
|__________________________|文件的节
  • .text: 已编译程序的机器代码
  • .rodata: 只读数据,比如printf语句中的格式串和开关语句的跳转表
  • .data: 已初始化的全局和静态C变量.局部C变量在运行时被保存在栈中,不出现在.data节中,也不出现在.bss节中
  • .bss: 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量.在目标文件中这个节不占据实际的空间,他仅仅只是一个占位符.目标文件格式区分已初始化和未初始化变量是为了空间效率
  • .symtab: 一个符号表,他存放在程序中定义和引用的函数和全局变量的信息.
  • .rel .text: 一个.text节中位置的列表.当链接器把这个目标文件和其他文件组合时,需要修改这些位置.
  • .rel .data: 被模块引用或定义的所有全局变量的重定位信息.
  • .debug: 一个调试符号表,条目式程序中定义的局部变量和类型定义,成功需中定义和引用的全局变量.以及原始的C源文件.
  • .line: 原始C源程序中的行号和.text节中机器指令之间的映射
  • .strtab: 一个字符串表,其内容包括.symtab和.debug节中的符号表.以及节头部中街节名字.

符号和符号表

每个可重定位目标模块m都有一个符号表,它包含M定义和引用的符号的信息.在链接器的上下文中,有三种不同的符号:

  • 由模块M定义并能被其他模块引用的全局符号.全局链接器符号对应于非静态C函数和全局变量
  • 由其他模块定义并被模块m引用的全局符号,这些符号被称之为外部符号,对应于其他模块的定义的非静态C函数和全局变量
  • 只被模块m定义和引用的局部符号.它们对应于带static属性的C函数和全局变量.这些符号在模块m中的任何位置都可见,但是不能被其他模块引用
    认识到本地链接器符号和本地程序变量不同是非常重要的. .symtab中的符号表不包含对应于本地非静态程序变量的任何符号.这些符号在运行时在栈中被管理.符号根本不敢兴趣.

符号解析

链接器如何解析多重定义的全局符号

全局符号分为强和弱,比如

/* foo1.c */
int main()
{
		return 0;
}

/* bar1.c */
int main()
{
		return 0;
}
linux > gcc foo1.c bar1.c
/tmp/cc5kGGIR.o:在函数‘main’中:
bar1.c:(.text+0x0): multiple definition of `main'
/tmp/ccNctMlP.o:foo1.c:(.text+0x0):第一次在此定义
collect2: 错误:ld 返回 1

与静态库链接

如果不使用静态库,编译器开发人员会使用什么方法来向用户提供这些预定义豪的函数.一种方法是让编译器辨认出对标准函数的调用.并直接生成相应的代码.但是这种方法对C是不合适的.因为C标准定义了大量的标准函数.
gcc main.c /user/lib/libc.o
将所有的C函数都放在一个单独的可重定位目标模块中.这个方法的有点事将编译器的实现和标准函数实现分离开来.但是有一个不好的地方就是浪费空间.另外一个大的缺点是,对任何标准函数的任何改变,无论多么小的改变,都要求库的开发人员重新编译整个源文件.这是一个非常耗时的操作.

gcc main.c /usr/lib/printf.o /usr/lib/scanf.o ...
我们可以为每个标准函数创建一个独立的可重定位文件,把它们存放在一个大家都知道的目录来解决这个问题.然而,这种方法要求应用程序员显示的链接合适的目标模块到它们的可执行文件中.这是一个容易出错而且耗时的过程:
所以静态库的概念被提出来了.以解决这些不同方法的缺点.相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件.然后应用程序可以通过在命令行制定单独的文件名字来使用这些在库中定义的函数,比如,使用C标准库和数学库中函数的程序可以用形式一下的命令行来编译和链接:
gcc main.c /usr/lib.libm.a /usr/lib/libc.a
在链接的时候,链接器支付至被程序引用的目标模块.这就减少了可执行文件在磁盘和内存中的大小.另一方面,应用程序员只要包含较少的库文件的名字.实际上C编译器驱动程序总是传送libc.a给链接器.
在linux系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中, 存档文件是一组连接起来的可重定位目标文件的集合.有一个头部来描述每个成员目标文件的大小和位置.存档文件由后缀.a标识.
来,我们自己搞一个静态库.

gcc -c addvec.c multvec.c
ar rcs libvector.a addvec.o multvec.
gcc -c main2.c
gcc -static -o prog2c main2.o ./libvector.a
# 或者 -L.参数告诉链接器在当前目录查找libvector.a
gcc -static -o prog2c main2.o -L. -lvector
     main2.c                     vector.h
	   |                            |
   翻译器(cpp,cc1, as)	        libvector.a           libc.a     静态库
       |                            |                   |
	 main2.o                      addvec.o           printf.o和其他调用printf.o调用的模块
	   |____________________________|___________________|
	                            |链接器(ld)|
								------------
								    |
								  prog2c  完全连接的可执行目标文件
		

链接器如何使用静态库来解析引用

重定位条目

当一个汇编器生成一个目标模块时,它并不知道数据和代码最终会在内存中的什么位置.他也不知道这个模块引用的任何外部定义的函数或者全局变量的位置,所以,无论何时汇编器遇到对最终为止未知的目标引用.他就会生成一个重定位条目,告诉链接器在将目标文件合并成为可执行文件时如何修改者饮用.代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.dat中.

可执行目标文件

最后的最后变成一个可执行的二进制文件,一个典型的ELF可执行文件的信息如下图

 ___________________________0__
|         ELF头            |   |
|--------------------------|   |
|        段头部表          |   |-> 将连续的文件节映射到运行时内存段 
|--------------------------|   |
|        .init             |   |
|--------------------------| 只读内存段(代码段)
|        .text             |   |
|--------------------------|   |
|        .rodata           |   |
|------------------------------|
|        .data             |   |
|--------------------------| 读写内存段(数据段)
|        .bss              |   |
|------------------------------|
|        .symtab           |   |
|--------------------------| 不加载到内存
|        .debug            | 符号表和调试信息
|--------------------------|   |
|        .line             |   |
|--------------------------|   |
|        .strtab           |   |
|-------------------------------
|        节头部表          | 描述目标
|__________________________| 文件的节

可执行目标文件的格式类似于可重定位目标文件的格式.ELF头描述文件的总体格式,它还包括程序的入口点.也就是当程序运行时要执行的第一条指令的地址..init节定义了一个小函数,叫做_init,程序的初始化代码会调用它,因为可执行文件是完全链接的.所以他不在需要.rel节.

ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片被映射到连续的内存段.程序头部表描述了这种映射关系

加载可执行文件

要运行可执行目标文件prog,我们可以在Linux shell的命令行输入它的名字: linux> ./prog
因为prog不是一个内置的shell命令,所以shell会认为prog是一个可执行目标文件.通过调用某个驻留在存储器中成为加载器的操作系统代码来运行它.任何Linux程序都可以通过调用execve函数来调用加载器,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行改程序.这个将程序复制到内存运行的过程叫做加载.

----------------------------------------
|         内核内存                     | 
---------------------------------------- 2^48-1 对用户代码不可见的内存
|         用户栈                       |
|       (运行时创建)                   |
---------------------------------------- <- %rsp(栈指针)
|           ^                          |
|           |                          |
---------------------------------------- 
|           |                          |
|      共享库的内存映射区域            |
----------------------------------------
|           ^                          |
|           |                          |
---------------------------------------- <- brk
|         运行时堆                     |
|      (由malloc创建)                  |
--------------------------------------------
|         读/写段                      |   |
|        (.data, .bss)                 |   |
---------------------------------------- 从可执行文件中加载
|         只读代码段                   |   |
|        (.init,.text,.rodata)         |   |
--------------------------------------- 0x400000
|                                      |
|                                      |
----------------------------------------0

每个Linux程序都有一个运行时内存印象,在linux x86-64系统中,代码段总是从地址0x400000处开始,后面是数据段.运行时堆在数据段之后,通过调用malloc库往上增长.堆后面的区域为共享模块保留的.用户栈总是从最大的合法地址(2^48-1)开始的,向较小内存地址增长.栈上的区域,从地址2^48开始,是为内核中的代码和数据表刘德.
为了简洁,堆/数据/代码段放在一起,实际由于.data有对齐要求,代码段和数据段是有间隙的.虽然每次程序运行时区域的地址都会改变,但是相对位置是不变的.当加载器运行时,它创建类似于内存印象,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段.接下来,加载器跳转到程序的入口点.也就是_start函数的地址.这个函数是在系统目标文件ctrl.o中定义的,对所有的C程序都是一样的._start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中,它初始化执行环境,调用用户层的main函数.处理main函数的返回值,并且在需要的时候需要控制返回内核.

动态链接共享库

静态链接库解决了许多关于如何让大量相关函数对应用程序可用的问题,然而静态库仍然有一些明显的缺点,静态库和所有的软件一样需要定期维护和更新,如果应用程序员想要使用一个库的最新版本,它们必须以某种方式了解到该库的更新情况,然后显示的将他们程序更新了的库重新链接,另外一个问题是几乎每个程序都使用标准的IO函数,比如printer,scan f在运行时,这些函数代码会被复制到每个运行进程的文本段中,在一个月上百个进程的典型系统上,这将是对稀缺内存系统资源的极大浪费
共享库是致力于解决静态库缺陷的一个现代创新产物,共享库是一个目标模块,在运行或加载是可以加载到任意的内存地址,任何一个内存中的程序链接起来,这个过程称之为动态连接,有一个动态链接器来执行共享库也称为共享目标,通常用来表示微软的操作系统,大量的使用了共享库,他们称之为DLL动态链接库,共享库是以两种不同方式来共享,

main2.c     vector.h
  |           |
 ---------------
 |   翻译器    |
 ---------------
        |                          libc.so  libvector.so
可重定位目标文件main2.o            ---------------------重定位和符号表信息        
        |                                |
      ----------------------------------------
	  |            链接器LD                  |
	  ----------------------------------------
	    |
	   prog21 (部分可执行的目标文件)
	    |
	  -----------------
      | 加载器(execve)|
      -----------------            libc.so, libvector.so  
        |                          ---------------------代码和数据
        |                                |
      -----------------------------------------
      | 动态链接器(ld-linux.so)               |
      -----------------------------------------	  

使用gcc -shared -fpic -o libvector.so addvec.c multvec.c
-fpicc选项指示编译器生成与位置无关的代码,shell的选项只是链接器创建一个共享的目标文件,一旦创建这个库,随后就要将它链接程序中
使用gcc -o prog21 main2.c ./libvector.so
这样就创建了一个可执行目标文件,此文件的形式使它在运行时可以和基本的思路是创建可执行文件是静态执行一些链接,然后在程序加载时动态完成这一点是非常重要的,只是没有任何vector.so的代码和数据集真的被复制到可执行文件中,反正链接去复制一些重定位和符号表信息,它是运行时可以解析对内部点so中代码和数据的应用

  • 重定位libc.so的文本和数据到某个内存段
  • 重定位libvector.so的文本和数据到另一个内存段
  • 重定位prog21中所有对由libc.so和libvector.so定义符号的引用.

从应用程序中加载和链接共享库

到目前为止,我们已经讨论了在应用程序被加载后执行前10动态链接器加载和链接共享库的情景,然后应用程序还可能在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中,动态链接是一项强大有用的技术下面有一些现实世界中的励志

  1. 分发软件,微软windows应用的开发者常常利用共享库来分发软件更新,他们生成一个新的共享库的新版本,然后用户可以下载并用它替代当前的版本,下一次他们运行应用程序时,应用将自动链接和加载新的共享库
  2. 构建高性能web服务器,许多web服务器生成动态内容,比如个性化的web页面账户余额和广告标语,早期的web服务器通过使用fork和execve创建一个指定成并在该进程的上下文中运行c程序来生成动态内容,然而现代高性能的web服务器可以使用基于动态链接的更有效和完善的方法来生成动态内容,其思路是将每个生成动态内容的函数打包在共享库中,当一个wap浏览器的请求到达时,服务器动态的加载和链接适当的函数,然后直接调用它,而不是使用fork和execve在子进程城上下文中运行函数函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用的开销就可以处理随后的请求了,这对一个繁忙的网站来说有很大的影响,更进一步说在运行时无需停止服务器就可以更新已存在的函数以及添加新的函数
    linux系统为动态链接提供了一个简单的借口允许应用程序在运行时加载和链接共享库
#include <dlfcn.h>

void *dlopen(const char *filename, int flag);
// 若成功返回指向句柄的指针,否者为null

dlopen函数加载和链接共享库filename,用已带RTLD_GLOBAL选项打开库解析filename的外部符号.如果可执行文件使用-rdynamic选项编译的,那么对于符号解析来说,他的 全局符号也是可用的.

#include <dlfcn.h>

void *dlsym(void *handle, char *symbol);
// 若成功返回指向符号的指针,否者为null

dlsym函数的输入是一个指向前面已经带开了共享库和句柄和一个symbol的名字,如果该符号存在,就返回符号的地址,否则NUll

#include <dlfcn.h>

void *dlclose(void *handle);
// 若成功返回0,否者为-1

如果没有其他共享库还在使用该共享库,dlclose函数会卸载该共享库

#include <dlfcn.h>

const char *dlerror(void);
// 如果上面三个函数失败,则为错误消息,如果成功则为null

位置无关代码

共享库的一个主要的目的就是允许多个运行的进程,共享内存中的相同的库代码,因而节约宝贵的内存资源那么多个进程是如何共享程序的一个副本呢,一种方法是给每个共享库分配一个事先预备的专

@OhBonsai OhBonsai added this to the 读书会:CSAPP milestone Nov 1, 2020
@OhBonsai OhBonsai added the CSAPP 深入理解计算机系统 label Nov 1, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CSAPP 深入理解计算机系统
Projects
None yet
Development

No branches or pull requests

1 participant