操作系统系列(三)——编译和链接

往期地址:

  • 操作系统系列一 —— 操作系统概述
  • 操作系统系列二——进程

本期主题:

  • 编译和链接

文章目录

  • 1.被隐藏了的过程
    • 1.1 预编译
    • 1.2 编译
    • 1.3 汇编
    • 1.4 链接
      • 1.模块拼接——静态链接
      • 2.空间地址与分配
      • 3.符号解析和重定位(核心)
      • 4.重定位表(undefined reference to xxx的根本原因)
  • 2.链接脚本
    • 1.链接控制脚本
    • 2.ld链接脚本语法简介

1.被隐藏了的过程

在平常的应用程序开发中,我们很少关注编译和链接过程,这是因为通常的开发环境(IDE)都已经将这些给集成好了,我们只需要build即可,通常这种将编译和链接合并在一起的过程称为构建(build)
但是在这样的开发过程中,我们往往会被这些复杂的集成工具所提供的强大功能所迷惑,很多软件的运行机制和原理我们不是很理解,这样在遇到问题时,经常束手无策。
实际上一个简单的hello.c,在linux下使用gcc编译时,也可以分为4个步骤:

在这里插入图片描述
更为详细的过程如下图所示:
操作系统系列(三)——编译和链接_第1张图片

1.1 预编译

首先是源代码hello.c和相关的头文件,如stdio.h被预编译器cpp预编译成一个.i文件

$ gcc -E hello.c -o hello.i

  • 预编译过程目的:处理源代码中的以 “#” 开始的预编译指令,如"#include"、"#define"等
  • 预编译处理规则
    • 将所有#define删除,展开宏定义
    • 处理所有的条件预编译指令,比如 #if、#ifdef、#elif等
    • 处理#include预编译指令,将被包含的文件插入到预编译指令的位置
  • 预编译结果:经过预编译后生成的.i文件不含任何的宏定义,并且包含的文件也被插入到.i文件中,所以当我们无法判断宏展开的结果以及头文件包含是否正确时,可查看预编译后的文件来定位问题

1.2 编译

把预处理完的文件进行一系列词法分析、语法分析、语义分析后生成相应的汇编代码文件。

gcc -S hello.i -o hello.s

  • 编译目的:将预编译后的文件进行一系列语法分析后产生相应的汇编代码文件。
  • 所使用的程序:和预编译一样使用cc1

1.3 汇编

汇编器实际上是将汇编代码文件转变成可以被机器所执行的指令。

gcc -c hello.s -o hello.o

生成的.o文件是目标文件(object file),这一步生成的文件并不是一个可执行文件,需要将很多东西链接起来,才是可执行文件
文件属性:

jason@ubuntu:~/WorkSpace/0.Unix_AP/compile$ gcc -c hello.c -o test2
jason@ubuntu:~/WorkSpace/0.Unix_AP/compile$ file test2 
test2: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

1.4 链接

带着问题阅读本节,为什么在汇编这一步不直接生成一个可执行文件呢,而要再多这一步链接?

1.模块拼接——静态链接

程序设计的模块化一直是程序员所追求的目标,人们将每个模块的代码独立编译,然后按照某种规则将他们给组装起来,这个组装的过程就是链接

链接的过程主要包括:

  • 地址和空间分配
  • 符号决议
  • 重定位

操作系统系列(三)——编译和链接_第2张图片
基本的静态链接如上图所示:每个模块的源文件经过编译器编译生成目标文件(.o),目标文件和库一起链接形成最终可执行文件。
现在的链接器空间分配策略一般都采用两步链接的方式,整个链接过程被分为两步,分别是:

  1. 空间地址与分配
  2. 符号解析和定位

下面来详细说说。

2.空间地址与分配

过程:

  • 扫描所有输入目标文件,获取各个段的长度、属性和位置
  • 收集输入目标文件中的符号表,统一放到一个全局符号表中
  • 获得所有输入目标文件的段长度,将其合并,然后计算各个段合并之后的长度与位置,建立映射关系

3.符号解析和重定位(核心)

链接的第二步操作主要是,使用上一步收集到的信息,然后读取输入文件中的数据以及重定位信息,进行符号解析与重定位,调整代码中的地址
事实上,第二步是整个链接过程的核心,特别是重定位过程

这里来看一个实际例子,假设我们的代码中有a.c和b.c两个文件

//a.c
#include "stdio.h"

extern int glb_shared;

int main(void)
{
    int a = 100;
    swap(&a, &glb_shared);
}

//b.c
#include "stdio.h"

int glb_shared = 1;

void swap(int* a, int* b)
{
    *a = *b;
}

在链接前生成目标文件:
$ gcc -c -fno-stack-protector a.c b.c
//这里需要使用 -fno-stack-protector选项的原因是 fno-stack-protector是去除了stack的检测,我们直接是手动裸ld去链接,没有链接到“__stack_chk_fail"的所在库,不然会报错 ld: a.o: in function main': a.c:(.text+0x4f): undefined reference to__stack_chk_fail’

链接后生成目标文件
$ ld a.o b.o -e main -o ab

链接前后的目标文件对比:

读取连接前目标文件信息
$ objdump -h a.o 

a.o:     文件格式 elf64-x86-64

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000032  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000072  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000072  2**0
...

$ objdump -h b.o 

b.o:     文件格式 elf64-x86-64

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000001f  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  00000060  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000064  2**0

...


读取链接后目标文件:
$ objdump -h ab.o 

ab.o:     文件格式 elf64-x86-64

节:
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         00000051  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

可以看到的信息:

  • VMA表示virtual memory address,表示虚拟地址,LMA代表load memory,运行地址,这里我们只关系虚拟地址;
  • 链接后的text段发生了重定位,从原来的0地址变为了 401000地址;
  • 在链接之前,所有目标文件的所有段VMA都是0,因为虚拟空间还没有被分配,在链接之后,ab中的各个段被分配到了相应的虚拟地址;

4.重定位表(undefined reference to xxx的根本原因)

前面说到了,链接之后ab中的各个段被分配到了相应的虚拟地址,但是想一下,链接器是怎么知道哪个变量被调整呢?
这是因为在ELF文件中,有一个重定位表(relocation table),这个表专门用来保存与重定位相关的信息,我们可以使用 objdump 来查看目标文件的重定位表:

$ objdump -r a.o 

a.o:     文件格式 elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE 
000000000000001a R_X86_64_PC32     glb_shared-0x0000000000000004
0000000000000027 R_X86_64_PLT32    swap-0x0000000000000004


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE 
0000000000000020 R_X86_64_PC32     .text

能看到"a.o"中需要进行重定位的地方,即a.o所引用到的所有外部符号都有一个重定位地址。

也可使用 readelf命令 来查看各个目标文件的符号表:

//链接前
$ readelf -s a.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 a.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     9: 0000000000000000    50 FUNC    GLOBAL DEFAULT    1 main
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND glb_shared
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
//链接后
$ readelf -s ab.o

Symbol table '.symtab' contains 14 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000004001c8     0 SECTION LOCAL  DEFAULT    1 
     2: 0000000000401000     0 SECTION LOCAL  DEFAULT    2 
     3: 0000000000402000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000404000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS a.c
     7: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS b.c
     8: 0000000000401032    31 FUNC    GLOBAL DEFAULT    2 swap
     9: 0000000000404004     0 NOTYPE  GLOBAL DEFAULT    4 __bss_start
    10: 0000000000401000    50 FUNC    GLOBAL DEFAULT    2 main
    11: 0000000000404000     4 OBJECT  GLOBAL DEFAULT    4 glb_shared
    12: 0000000000404004     0 NOTYPE  GLOBAL DEFAULT    4 _edata
    13: 0000000000404008     0 NOTYPE  GLOBAL DEFAULT    4 _end

能看到在链接前,a.o的符号表中提示引用符号 glb_shared以及swap都是undefined,所以链接时也需要其他的目标文件来定义这些外部符号,而链接后的ab.o文件就有了这些符号的定义。
所以像我们常见的错误(undefined reference to xxxxx ):

$ ld a.o
ld: 警告: 无法找到项目符号 _start; 缺省为 0000000000401000
ld: a.o: in function `main':
a.c:(.text+0x1a): undefined reference to `glb_shared'
ld: a.c:(.text+0x27): undefined reference to `swap'

这些错误的根本原因是:
链接器会查找所有输入目标文件的符号表,组成一个全局符号表,然后进行重定位。上述错误在于没找到符号表

2.链接脚本

1.链接控制脚本

链接器一般都提供多种可以用来控制整个链接过程的方法,一般有以下几种方式:

  1. 使用命令行来给链接器指定参数,例如前面用的 ld -o -e等参数就属于这类;
  2. 集成好的IDE环境经常会将链接指令存放在目标文件里,让用户不用关心它;
  3. 使用链接脚本,这是最为灵活的链接控制方式;

当我们不指定链接脚本时,也会使用到默认的链接脚本,可以使用如下命令来查询默认链接脚本:

$ ld -verbose
GNU ld (GNU Binutils for Ubuntu) 2.34
 支持的仿真:
  elf_x86_64
  elf32_x86_64
  elf_i386
  elf_iamcu
  elf_l1om
  elf_k1om
  i386pep
  i386pe
使用内部链接脚本:
==================================================
/* Script for -z combreloc -z separate-code */
/* Copyright (C) 2014-2020 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");
......

2.ld链接脚本语法简介

常见的命令语句如下:

命令语句 说明
ENTRY(symbol) 指定符号symbol为入口地址,ld有多种方法指定入口地址,优先级关系如下:ld命令行的 -e 选项 > 链接脚本的ENTRY(symbol)命令 > _start符号值 > .text段的第一字节地址 > 使用值0
STARTUP(filename) 将文件filename作为链接过程的第一个输入文件
SEARCH_DIR(path) 将路径path加入到链接器的库查找目录里
INPUT(file,file,…) 将指定文件作为链接过程的输入文件

SECTION命令最基本格式为:

SECTIONS
{
	....
	secname : { contents }
	...
}

contents规则,条件写法如下:

filename(sections)

其中,filename表示输入文件名,section表示段名,看几个具体例子:

  • file1.o(.data) :表示输入文件名为file1.o的文件中叫 .data段 的符合条件;
  • file1.o(.data .rodata) :表示输入文件名为file1.o的文件中叫 .data段.rodata段 的符合条件;
  • file1.o:表示file1.o的所有段都符合条件;
  • *(.data):所有输入文件中的.data文件符合条件;

你可能感兴趣的:(计算机操作系统,linux)