学习《程序员的自我修养》一书,进行记录总结。本文为第三章的主要内容。

真正了不起的程序员对自己的程序的每一个字节都了如指掌。
                                            ——佚名

目标文件

目标文件是指单个.c源文件(如果有include,则是包含include后)经过编译生成的文件。其格式为ELF(Executable Linkable Format),这是一种COFF(Comomon file format)格式的变种。Windows下是PE(Portable Executable),也是COFF文件。这里对COFF不在深究,主要描述ELF文件。

文件总体结构

对于如下的 a.c文件

int printf(const char *, ...);
int g_uninit;
int g_init = 2;
int funca()
{
    int l_int = 1;
    static int s_int;
    printf("g_unint %d, g_init %d, l_int %d, s_int %d\n", g_uninit, g_init, l_int, s_int);
    return 0;
}

使用shell命令

gcc -c a.c #编译输出目标文件
objdump -h a.o # -h 展示重要段的头部信息
readelf -S a.o # -S 展示所有段的头部信息

得到结果

$ objdump -h a.o
a.o:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000003f  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  00000080  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000004  0000000000000000  0000000000000000  00000084  2**2
                  ALLOC
  3 .rodata       0000002b  0000000000000000  0000000000000000  00000088  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .comment      00000036  0000000000000000  0000000000000000  000000b3  2**0
                  CONTENTS, READONLY
  5 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000e9  2**0
                  CONTENTS, READONLY
  6 .eh_frame     00000038  0000000000000000  0000000000000000  000000f0  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

$readelf -S a.o
There are 13 section headers, starting at offset 0x3a0:
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
       000000000000003f  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  000002a8
       0000000000000078  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000080
       0000000000000004  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  00000084
       0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  00000088
       000000000000002b  0000000000000000   A       0     0     8
  [ 6] .comment          PROGBITS         0000000000000000  000000b3
       0000000000000036  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000e9
       0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  000000f0
       0000000000000038  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000320
       0000000000000018  0000000000000018   I      11     8     8
  [10] .shstrtab         STRTAB           0000000000000000  00000338
       0000000000000061  0000000000000000           0     0     1
  [11] .symtab           SYMTAB           0000000000000000  00000128
       0000000000000150  0000000000000018          12    10     8
  [12] .strtab           STRTAB           0000000000000000  00000278
       000000000000002d  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

上面是objdump和readelf对于所有段表信息的输出结果。其中.text,.data,.bss,.rodata是比较重要的段。

  • .text 很简单就是保存的CPU指令的机器码。
  • .data 保存已经初始化了的全局静态变量和局部静态变量。代码中只有int g_init = 2;一个初始化了的全局静态变量,可以看到.data长度为4。
  • .rodata 保存的是只读数据,比如const变量和字符串常量(根据实现不同字符串常量可能被保存在.data段)。这个段存在的意义是可以让cpu将这段的数据映射到只读内存,能够在硬件层面上阻止此段数据的写。
  • .bss 保存未初始化的全局变量局部静态变量。其中未初始化的全局变量根据具体编译器的不同,有可能不会被放到任何段,只是在COMMON块中定义一个符号。但是局部静态变量肯定是在.bss里的。

ELF文件头

上面提到的是ELF文件主要内容,即段的分布情况,而这些信息都被记录在ELF文件中的文件头中,使用如下命令可以看到。

$ readelf -h a.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:          928 (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:         13
  Section header string table index: 10

上面是ELF文件本身的头,而在头部里有个Section Header Table既是段表。包含信息包括段名,类型,标志位,文件地址,加载时的进程内存空间地址,段长度等。
可以看到目前ELF文件的基本情况如下图。
Screenshot from 2019-04-17 09-13-10.png

重定位表

当链接器在处理目标文件时,需要对某些部位进行重定位,这些重定位信息都记录在重定位表里。对于每个需要重定位的代码段或者数据段,都会有一个相应的重定位表。重定位表也是ELF中的一个段,类型为SHT_REL,命名为.rel.xxxx,其中xxxx为需要被重定位的段的名字。

字符串表

保存着段名、符号名的一个段。字符串往往不定长,一般将它们拼起来放到一起,在其他段使用偏移来进行索引。

符号表

符号即链接的接口,链接器实际的工作其实就是将不同的目标文件的相同段拼到一起,之后修正没有定义的符号的内存空间地址。如果目标A用到了printf.o中的printf函数,那么称printf.o定义了printf函数,A引用了printf.o中的函数。
符号可能的类型如下:

  • 定义在目标文件中的全局符号如main, funca, g_init等
  • 引用却未定义的文件
  • 段名
  • 局部符号,即编译单元内部的符号,即只可在某个.c文件可见的符号。
  • 行号信息,不关心。
    使用如下命令可以看到符号表中所有的符号
$readelf -s a.o

Symbol table '.symtab' contains 14 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    5 
     6: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 s_int.1837
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
    10: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM g_uninit
    11: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 g_init
    12: 0000000000000000    63 FUNC    GLOBAL DEFAULT    1 funca
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf

其中第一列表示某个符号在符号表中的下标,第二轮为符号值,第三列为符号大小,第四、五列为符号类型和绑定信息。六未使用,七为符号所属段的下标,第八列为符号名称。需要关注的是Ndx列,UND即为未定义符号,COM为处理全局变量的一种策略中用到的一个块即COMMON块,以后会再接触它。
而使用如下命令可以看到所有代码中定义的符号。

$nm a.o              
0000000000000000 T funca
0000000000000000 D g_init
0000000000000004 C g_uninit
                 U printf
0000000000000000 b s_int.1837

特殊符号

有些程序中无需定义但是可以直接声明使用的符号,链接器在最终链接生成可执行文件时将其解析成正确的值。几个比较重要的符号如下:

  • __executable_start, 进程空间的程序最开始的地址
  • __etext或_etext或etext为代码段结束地址。
  • _edata或edata为数据段结束地址。
  • _end或end为程序结束地址。
  • 注意,这些地址都是程序在内存中的地址,而不是文件中的地址。

符号修饰与函数签名

如果一个大系统中,已经有很多符号,那么如果新程序需要使用到老系统中的目标文件,意味着不能重新再定义已有的符号,必须防止命名冲突,最初的处理办法是编译器在所有符号前加一个下划线(UNIX要求)。后来Linux舍弃了这种方法,但是在windows中依旧沿用。

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);
    }
}

c++在编译时其实是将所有名称按照命名空间、输入参数进行重新命名,具体的命名方式不展开,大致提一下。所有符号以_Z开头,接下来如果有定义域,则加一个N,接下来为定义域名字长度+定义域名字,对于多层定义域进行重复。之后是符号长度+符号名字。对于采用参数列表不同进行的重载,使用E紧跟+参数列表信息组成的字符串来区别不同的重载版本的函数。下面贴一个图,感受一下。细节可能会有所不同,但是知道大意即可。
Screenshot from 2019-04-17 09-59-58.png

extern "C"

当使用extern "C"加上一对花括号,在花括号内定义符号时,内部的符号采用C语言的签名规则,也可以在extern C之后直接加符号名称。
这就形成了一个比较有意思的实验

#include <stdio.h>
namespace myname {
    int var = 42;
}
extern "C" double _ZN6myname3varE;
int main(){
    printf("%d\n",_ZN6myname3varE);
    return 0;
}

根据GCC的命名修饰规则,显式地使用C语言定义一个外部符号_ZN6myname3varE,这个符号就是myname::var。
目前,可以在C++代码中创建能够被C语言引用的符号了!但是,C++如何才能正确包含C语言的符号呢,那自然就是在头文件中对符号加上extern "C"来防止编译器自己对C语言符号签名。但是C语言是不支持extern "C"的,如果加上了extern "C",那么这个头文件只能被C++使用,难道对于C、C++要定义两个版本的头文件,区别仅仅在于是否添加extern "C"?显然不是,一般采用宏条件判断来进行兼容:

#ifdef __cplusplus
extern "C"{
#endif
xx//C语言的符号定义
#ifdef __cplusplus
}
#endif

这个技巧几乎在所有的系统头文件中被用到!也从一个侧面反应了,在底层C,C++是完全一种概念,只是在编译器层面加入了各种复杂机制使语言本身具有更大的能力。甚至可以说,所有可执行程序其实就是代码+数据构成的一个可执行文件,只要知道了符号、函数参数,都可以进行相互调用,不管是什么语言(静态)。

强符号和弱符号

在C/C++语言中,函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。也可以将定义语句加上__attribute__((weak))来定义一个强符号为弱符号。对于强弱符号,有一下处理规则。

  • 不允许强符号被多次定义,不同目标文件不能有同名的强符号。
  • 如果一个符号在某个文件中时强符号,其他文件中是弱符号,那么最终符号定义由强符号定义觉得。
  • 如果一个符号在所有文件中都为弱符号,选取占用空间最大的一个。
    对所有外部目标文件的符号引用在目标文件最终被链接成可执行文件时,都需要被正确决议,如果没有找到符号定义,则会报未定义错误,这种引用为强引用。

还有一种弱引用,即使没有找到也不会报错,其默认值被定义为0,如果对该符号进行引用显然会出现运行时内存访问错误。弱引用可以使程序拼接更加灵活。如,对于一个pthread_create的调用,声明其为弱引用,即在声明函数时参数列表括号外和分号间加上__attribute__((weak)),则程序的行为受控于在链接时是否加上了 -lpthread, 可以通过对pthread_create是否为0判断,执行不同的代码片段,进而使程序能够同时实现多线程/单线程版本。

调试信息

gcc加上-g就会在编译时加上调试信息,在目标文件和可执行文件生成时会产生很多debug相关的段。知道这个基本上够用。

标签: 编译链接, 程序员的自我修养

添加新评论