01.理解 C++ 内核

本节内容分为三部分:

  1. 掌握进程虚拟地址空间区域划分
  2. 从指令角度掌握函数调用堆栈详细过程
  3. 从编译器角度理解C++代码的编译、链接

通过了解底层基础,了解 C++ 的如何运行。

1. 掌握进程虚拟地址空间区域划分

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

int gdata1 = 10;
int gdata2 = 0;
int gdata3;

static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;

int main()
{
int a = 12;
int b = 0;
int c;

static int d = 13;
static int e = 0;
static int f;

return 0;
}

根据上述代码,如何得知各个变量存储的位置?

1.1 虚拟进程视图

程序由磁盘加载到内存时是不可能直接加载到物理内存当中的,这里的原因以及物理内存和虚拟内存的区别与联系在本篇先不做讨论。

本篇接下来所讨论的范围在 x86 体系 32 位 Linux 环境下 Linux 系统会给当前每一个进程分配一个 2^32 位大小(4G)的一块空间,这块空间就叫做进程的虚拟地址空间。

这里附上IBM公司关于虚拟的解释:

1
2
3
4
它存在,你看得见,它是物理的
它存在,你看不见,它是透明的
它不存在,你看得见,它是虚拟的
它不存在,你看不见,它被删除

这块空间的内容如下图所示:

虚拟进程空间示例图 CN

虚拟进程空间示例图 EN

补充:
x86 32位体系下的4G虚拟地址空间:
Linux默认3:1来分配 user space : kernal space;
Windows默认2:2来分配 user space : kernal space。

Linux 中每一个运行的程序(进程),32 位操作系统都会为其分配一个 0 ~ 4GB 的进程虚拟地址空间,64 位操作系统会为其分配一个 0 ~ 16GB 的进程虚拟地址空间。

解释:

32 位操作系统下,一个指针的大小为 32 位即 4 个字节,它所能保存的地址范围为 [0, 2^32] ,所以它的寻址范围为 4GB 大小,所以 32 位操作系统下系统给进程分配的虚拟地址空间大小为 4 GB 。
64 位操作系统下,一个指针的大小为 64 位即 8 个字节,它所能保存的地址范围为 [0, 2^64] ,即 4GB * 4GB = 16GB,所以它的寻址范围为 16GB 大小,所以 64 位操作系统下系统给进程分配的虚拟地址空间大小为 16GB。

为什么是 4G 内存:

首先,我们研究的体系是:x86 32位Linux环境

Linux 操作系统会给当前进程分配一个 2^32 大小的空间,那么,2^32 换算过来就是 4G 了。

1.2 用户空间(User Space)

  1. 保留区:
    128M 大小,不可访问,不允许读写。任何普通程序对它的引用都是非法的,一般用来捕捉空指针和小整型值指针引用内存的异常情况。在定义指针时将其初始化为 “NULL”,它便不会被引用了,从而避免了野指针。

  2. 指令段【.text】、只读数据段【.rodata】:
    指令段存放指令,只能读,不能写;只读数据段中存放只读数据,比如字符串常量等,只能读,不能写。

    在C++中,不允许普通指针指向常量字符串,需要使用const
    示例

  3. 数据段【.data】:
    存放程序中已初始化且不为0的全局变量或静态变量

  4. 数据段【.bss】:
    存放程序中未初始化或者初始化为0的全局变量或静态变量。

  5. 堆【.heap】:
    存放动态数据,需要程序员手动开辟、释放空间,在程序刚开始运行时,此区域为空,等到程序运行到手动开辟空间的指令时,此区域动态扩张。自下向上增长。

    • 堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。
    • 堆中内容是匿名的,无法通过名字进行访问,只能通过指针进行间接访问。
    • 当进程调用malloc(C)/new(C++)等函数分配内存时,新分配的在堆上动态扩张;当调用free(C)/delete(C++)等函数释放内存时,被释放的内存从堆上动态缩减
    • 分配的堆内存时经过字节对齐的空间,以适合原子操作
    • 堆管理器通过链表管理每个申请的内存块
    • 由于堆内存块的申请与释放都是无序的,最终会产生许许多多内存碎片
    • 堆的末端由break指针标识,当堆管理器需要更多内存时,可通过系统调用brk和sbrk移动break指针以扩张堆,一般情况下由系统自动调用。
  6. 共享库【.dll、.so】:
    动态链接库,程序在运行的过程中,将一些标准库函数映射到这里,比如C标准库函数(fread、fwrite、fopen等)。

  7. 栈【.stack】:
    存放所有函数的活动空间,局部变量;根据程序的运行,调用函数,此区域动态地扩张和收缩。

    • 栈中存放非静态局部变量 函数形参 函数返回地址等。

    • 栈中内存空间由编译器(静态的)自动分配和释放,行为类似数据结构中的栈结构。

      主要用途:

      1. 为函数内部声明的非静态局部变量提供存储空间
      2. 记录函数调用过程相关的维护性信息,称为栈帧(stack frame)
      3. 作为临时存储区,用于暂时存放较长的算术表达式部分计算结果,或者运行时调用alloca函数动态分配栈内内存
    • 栈内存增长:栈能够增长到的最大内存容量为RLIMIT_STACK(通常是8M),如果此时栈的大小未达到RLIMIT_STACK,则栈会自动增长至程序运行所需的大小,如果此时栈的大小已经达到RLIMIT_STACK,若再向栈中不断压入数据,会触发页错误。栈的实时大小会在运行时由内核动态调整。

    • 查看栈大小:ulimit -s可查看和设置栈的最大值,当程序使用的栈大小超过该值,会发生segmentation fault

      • 栈的增长方向:既可以向高地址增长,也可以向低地址增长,这取决于具体实现,自上而下增长。
  8. 命令行参数:
    保存传递给 main 函数的参数,比如 argc 和 argv。

  9. 环境变量:
    用于存放当前的环境变量,在 Linux 下可以用 env 命令查看。

1.3 进程空间(Kernal Space)

  1. 内存直接访问区【ZONE_DMA】:
    16M 大小,不需要经过 CPU 的寄存器,加快了磁盘和内存之间的数据交换。

  2. 常用区【ZONE_NORMAL】:
    892M 大小,内核中最重要的部分,存放页表、页面的映射、PCB。

  3. 高端内存区【ZONE_HIGHMEM】:
    128M 大小,存放大文件的映射,即内存中映射高于 1GB 的物理内存。64 位操作系统没有该段。

1.4 最终解释

image.png

注意:
对于a、b、c以及'{'、'}'来说,是存储在.text指令段的,因为他们生成的都是==指令==。
例如:
int a = 12:生成汇编指令如下:mov dword ptr[a], 0Ch

1.5 好处

数据代码指令分别开辟空间有以下好处:

  1. 当程序被装载后,数据和代码指令分别映射到两个虚拟内存区域。数据区对于进程而言可读可写代码指令区对于进程而言只读
  2. 现代CPU一般数据缓存指令缓存分离,故进程虚拟地址空间中数据与代码指令分离有助于提高CPU缓存命中率
  3. 若系统中运行多个该程序的副本时,其代码指令相同,故内存中只需要保存一份该程序的代码指令,大大减少了内存的开销,相同的程序的代码指令可以被多个副本进程所共享,但是数据是每个副本进程所独有的。

参考文章:

【1】Randal E. Bryant. 《深入理解计算机系统》.北京. 机械工业出版社,2016:1
【2】寻痴. 虚拟地址空间图解. CSDN. 2021-03-23
【3】聪聪菜的睡不着. 【C++】一、虚拟内存布局、编译链接原理等基础概念. CSDN. 2020-07-09
【4】https://blog.csdn.net/m0_46308273/article/details/115818195
【5】https://blog.csdn.net/weixin_45437022/article/details/115409679

2. 从指令角度掌握函数调用堆栈详细过程

image.png
栈空间是从高地址向低地址扩充,堆地址是从低地址向高地址扩充。

堆栈是一种具有一定规则的数据结构,可以按照一定的规则进行添加和删除数据。它使用的是后进先出的原则。在x86等汇编集合中堆栈与弹栈的操作指令分别为:

  • PUSH:将目标内存推入栈顶。
  • POP:从栈顶中移除目标。

image.png

当执行一个函数的时候,相关的参数以及局部变量等等都会被记录在ESP、EBP中间的区域。一旦函数执行完毕,相关的 栈帧 就会从堆栈中弹出,然后从预先保存好的上下文中进行恢复,以便保持堆栈平衡。CPU必须要知道函数调用完了之后要去哪里执行(pc寄存器指向)

2.1 ESP 和 EBP

(1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
(2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

根据上述的定义,在通常情况下ESP是可变的,随着栈的生产而逐渐变小(因为栈向低地址扩充,栈顶寄存器数值不断变小),而EBP寄存器是固定的,只有当函数的调用后,发生入栈操作而改变。

在上述的定义中使用ESP来标记栈的底部,他随着栈的变化而变化:

  • pop ebp;出栈 栈扩大4byte 因为ebp为32位
  • push ebp;入栈,栈减少4byte
  • add esp, 0Ch;表示栈减小12byte
  • sub esp, 0Ch;表示栈扩大12byte

ebp 寄存器的出现则是为了另一个目标,通过固定的地址与偏移量来寻找在栈参数与变量。而这个固定值者存放在 ebp 寄存器中。但是,这个值会在函数的调用过程发生改变。而在函数执行结束之后需要还原,因此,在函数的出栈入栈过程中进行保存。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<iostream>
using namespace std;

/*
问题1:main函数调用sum,sum执行完后,怎么知道回到哪个函数
问题2:sum执行完,回到main函数之后怎么知道从哪一行继续执行
*/

int sum(int a, int b) {
int temp = 0;
temp = a + b;
return temp;
}

int main() {
int a = 10;
int b = 20;

int ret = sum(10, 20);
cout << "ret:" << ret << endl;

return 0;
}

打断点,调试,查看反汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int main() {
// 00007FF637EE23D0 push rbp
// 00007FF637EE23D2 push rdi
// 00007FF637EE23D3 sub rsp,148h
// 00007FF637EE23DA lea rbp,[rsp+20h]
// 00007FF637EE23DF lea rcx,[__0DD03384_02@Assembly@cpp (07FF637EF3068h)]
// 00007FF637EE23E6 call __CheckForDebuggerJustMyCode (07FF637EE13FCh)
int a = 10;
// 00007FF637EE23EB mov dword ptr [a],0Ah
int b = 20;
// 00007FF637EE23F2 mov dword ptr [b],14h

int ret = sum(10, 20);
// 00007FF637EE23F9 mov edx,14h
// 00007FF637EE23FE mov ecx,0Ah
// 00007FF637EE2403 call sum (07FF637EE11E5h)
// 00007FF637EE2408 mov dword ptr [ret],eax
cout << "ret:" << ret << endl;
// 00007FF637EE240B lea rdx,[string "ret:" (07FF637EEAC24h)]
// 00007FF637EE2412 mov rcx,qword ptr [__imp_std::cout (07FF637EF1190h)]
// 00007FF637EE2419 call std::operator<<<std::char_traits<char> > (07FF637EE108Ch)
// 00007FF637EE241E mov edx,dword ptr [ret]
// 00007FF637EE2421 mov rcx,rax
// 00007FF637EE2424 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF637EF1168h)]
// 00007FF637EE242A lea rdx,[std::endl<char,std::char_traits<char> > (07FF637EE103Ch)]
// 00007FF637EE2431 mov rcx,rax
// 00007FF637EE2434 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF637EF1170h)]

return 0;
// 00007FF637EE243A xor eax,eax
}
// 00007FF637EE243C lea rsp,[rbp+128h]
// 00007FF637EE2443 pop rdi
// 00007FF637EE2444 pop rbp
// 00007FF637EE2445 ret

2.2 解析 main 函数

1. { 会进行入栈操作,} 进行出栈操作

image.png

上面两句话的意思是将 ebp 推入栈中,之后让 esp 等于 ebp。

为什么这么做呢?

因为 ebp 作为一个用于寻址的固定值是有时间周期的。只有在某个函数执行过程中才是固定的,在函数调用与函数执行完毕后会发生改变。

在函数调用之前,将调用者的函数(caller)的ebp存入栈,以便于在执行完毕后恢复现场是还原ebp的值。下一步,必须为它的局部变量分配空间,同时,也必须为它可能用到的一些临时变量分配空间。

sub esp, 148h;减去的值根据程序而定

之后会根据情况看是否保存某些特定的寄存器(EBX,ESI和EDI)

之后ebp的值会保持固定。此后局部变量和临时存储都可以通过基准指针EBP加偏移量找到了

在函数执行完毕,控制流返回到调用者的函数(caller)之前会进行下述操作:

image.png

所谓有始有终,这是会还原上面保存的寄存器值,之后还原esp的值(上一个函数调用之前的esp被保存在固定的ebp中)与ebp值。这一过程被称为还原现场之后通过ret返回上一个函数

2. 函数内部:

image.png

  1. int a = 10; 执行一条 mov 指令: 

    1
    mov         dword ptr [a],0Ah

    image.png

  2.  同理 int b = 20; 指令:

    1
    mov         dword ptr [b],14h

    image.png

  3. int ret = sum(a,b); 指令:

    1
    2
    3
    4
    5
    6
    7
    00F81896 8B 45 EC             mov         eax,dword ptr [b] 
    00F81899 50 push eax #压栈 b 的值
    00F8189A 8B 4D F8 mov ecx,dword ptr [a]
    00F8189D 51 push ecx #压栈 a 的值
    00F8189E E8 E9 F7 FF FF call sum (0F8108Ch) #执行call
    00F818A3 83 C4 08 add esp,8
    00F818A6 89 45 E0 mov dword ptr [ret],eax

2.3 sum 函数调用后

函数调用参数的压栈顺序:参数由右向左压入堆栈。

因此上面对应的是:

先将b的值压入堆栈,再将a的值压入堆栈

image.png
执行call        sum (0F8108Ch):

call函数首先会将下一行执行的地址入栈:假设下一行指令的地址位0x08124458

image.png

 第二步进入函数调用:sum

image.png

函数调用第一步: 将调用函数(main)函数的栈底指针ebp压栈

第二步:将新的栈底ebp指向原来的栈顶esp

第三步:将esp指向新的栈顶(开辟了函数的栈帧):大小:108h

image.png

接着执行 int temp = 0; 指令:

1
mov         dword ptr [temp],0

image.png

temp = a + b; 由于a,b的值之前入栈,可以通过 ebp+12 字节找到b的值,ebp+8 字节找到 a 的值,最后将运算结果赋值给 temp

image.png

接着运行return temp;

1
mov         eax,dword ptr [temp]

image.png

接着是函数的右括号“}”

(1)mov esp,ebp 回退栈帧 将栈顶指针指向栈底。
(2)pop ebp 栈顶出栈,并将出栈内容赋值给ebp,也是将main的栈底重新赋值给ebp。
(3) ret 栈顶出栈,并将出栈的内容赋值给pc寄存器,也就是将之前压榨的call sun的下一条指令赋值到pc寄存器执行。

image.png

2.4 返回 main 函数后

接着调用函数完毕,回到主函数:
利用了PC寄存器,使得程序知道退出sum后运行哪一条指令:

image.png

add         esp,8 ,将压栈的a b 形参空间回收

mov         dword ptr [ret],eax 在sum中,最后将temp赋值到eax寄存器,这里将eax赋值给ret

image.png
最后return 0,程序结束

2.5 栈溢出问题

出现栈内存溢出的常见原因有2个:

  1. 函数调用层次过深,每调用一次,函数的参数、局部变量等信息就压一次栈。
  2. 局部静态变量体积太大。

第一种情况不太常见,因为很多情况下我们都用其他方法来代替递归调用,所以只要不出现无限制的调用都应该是没有问题的,起码深度几十层我想是没问题的。
检查是否是此原因的方法为,在引起溢出的那个函数处设一个断点,然后执行程序使其停在断点处, 然后按下快捷键 Alt+7 调出 call stack 窗口,在窗口中可以看到函数调用的层次关系。

   第二种情况比较常见 在函数里定义了一个局部变量,是一个类对象,该类中有一个大数组

1
2
3
4
5
6
7
8
9
10
11
12
 即如果函数这样写:
void test_stack_overflow()
{
char* chdata = new[2*1024*1024];
delete []chdata;
}
是不会出现这个错误的,而这样写则不行:
void test_stack_overflow()
{
char chdata[2*1024*1024];
}
大多数情况下都会出现内存溢出的错误,

解决办法大致说来也有两种:

  1. 增加栈内存的数目
  2. 使用堆内存

3. 从编译器角度理解 C++ 代码的编译、链接

整个编译过程分为两大步:

1)编译 :把文本形式的源代码翻译成机器语言,并形成目标文件

2)连接 :把目标文件 操作系统的启动代码和库文件组织起来形成可执行程序

3.1 编译

细分为3个阶段:

1.1)编译预处理

预处理又称为预编译,是做些代码文本替换工作。编译器执行预处理指令(以#开头,例如 #include),这个过程会得到不包含#指令的 .i 文件。这个过程会拷贝 #include 包含的文件代码,进行 #define 宏定义的替换 , 处理条件编译指令 (#ifndef#ifdef#endif)等。

预编译过程相当于如下命令:

1
gcc -E main.c -o main.i

主要规则如下:

  1. 将所有的 #define 删除,并且展开所有的宏定义;
  2. 处理所有条件预编译指令,比如#if#ifdef#elif#else#endif;
  3. 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件;
  4. 删除所有的注释:///**/
  5. 添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号;
  6. 保留所有的 #pragma 编译器指令,因为编译器要使用它们。

注:
#pragma libpragma link
等命令是在链接过程处理的。

预编译后得到的文件为:.i文件。

1.2)编译

通过预编译输出的.i文件中,只有常量:数字、字符串、变量的定义,以及c语言的关键字:main、if、else、for、while等。这阶段要做的工作主要是,通过语法分析和词法分析,确定所有指令是否符合规则,之后翻译成汇编代码。

编译过程相当于如下命令:

1
gcc -S main.i -o main.s

编译后得到的文件为:.s文件。

1.3) 汇编

汇编过程就是把汇编语言翻译成目标机器指令的过程,生成二进制可重定位的目标文件(.obj .o等)。目标文件中存放的也就是与源程序等效的目标的机器语言代码。

目标文件由段组成,通常至少有两个段:

  1. .text:包换主要程序的指令。该段是可读和可执行的,一般不可写
  2. .data .rodata:存放程序用到的全局变量或静态数据。可读、可写、可执行。

汇编过程我们可以调用汇编器as来完成:

1
2
3
as main.s -o main.o  
# 或者:
gcc -c main.s -o mian.o

这个过程将.s文件转化成.o文件。

3.2 链接过程

链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(货被拷贝)到存储器并执行。

链接的时机

  • 编译时,也就是在源代码被翻译成机器代码时
  • 加载时,也就是在程序被加载器加载到存储器并执行时
  • 运行时,由应用程序执行

1. 静态链接

静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节(section)组成。指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另外一个节中。

为了构造可执行文件,链接器必须完成两个任务:符号解析,重定位

  1. 符号解析: 目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来。
  2. 重定位: 编译器和汇编器生成从地址0开始的饿代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。

链接器的一些基本事实:目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些则包含程序数据,而其他的则包含指导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器和汇编器已经完成了大部分工作。

image.png

目标文件纯粹是字节快的集合。这些块中,有些包含程序代码,有些则包含程序数据,而其他的则包括指导链接器和加载器的数据结构。链接器将这些块链接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。

2. 目标文件

三种形式:

  1. 可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
  2. 可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行。
  3. 共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行地被动态地加载到存储器并链接。

编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是一个存放在磁盘文件中的目标模块。

3. 可重定位目标文件

一个典型的 ELF 可重定位目标文件的格式。ELF头(ELF header)以一个 16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。 ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括 ELF 头的大小、目标文件的类型(如可重定位、可执行或是共享的)、机器类型(如IA32)、节头部表的文件偏移,以及节头部表中的条目大小和数量。不同的节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。

ELF可重定位目标文件

夹在 ELF 头和节头部表之间的都是节。一个典型的 ELF 可重定位目标文件包含下面几个节:

  • .text: 已编译程序的机器代码。
  • .rodata: 只读数据,比如 printf 语句中的格式串和开关语句的跳转表。
  • .data: 已初始化的全局和静态 C 变量。局部 C 变量在运行时被保存在栈中,既不岀现在 .data 节中,也不岀现在 .bss 节中。
  • .bss: 未初始化的全局和静态 C 变量,以及所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为 0。
  • .symtab: 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过 -g 选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在 .symtab 中都有一张符号表(除非程序员特意用 STRIP 命令去掉它)。然而,和编译器中的符号表不同,.symtab 符号表不包含局部变量的条目。
  • .rel.text: 一个 .text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
  • .rel.data: 被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
  • .debug: 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的 C 源文件。只有以 - g 选项调用编译器驱动程序时,才 会得到这张表。
  • .line: 原始 C 源程序中的行号和 .text 节中机器指令之间的映射。只有以 -g 选项调用编译器驱动程序时,才会得到这张表。
  • .strtab: 一个字符串表,其内容包括 .symtab 和 .debug 节中的符号表,以及节头部中的节名字。字符串表就是以 null 结尾的字符串的序列。

4. 符号和符号表

每个可重定位目标模块m都有一个符号表,包含m所定义和引用的符号的信息。符号表产生在汇编阶段,符号表生成虚拟地址在链接阶段

在链接器的上下文中,有三种不同的符号:

由m定义并能被其他模块引用的全局符号
由其他模块定义并被模块m引用的全局符号
只被模块m引用的本地符号

例如:

main.cpp 内容 和 sum.cpp 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.cpp
#include <iostream>
using namespace std;

extern int gdata;
int sum(int, int);

int data = 20;

int main()
{
int a = gdata;
int b = data;

int ret = sum(a, b);

return 0;
}
1
2
3
4
5
6
7
8
9
// sum.cpp
#include <iostream>
using namespace std;

int gdata = 10;
int sum(int a, int b)
{
return a + b;
}

g++ -c 只编译不链接,只生成目标文件

image.png

 objdump -t main.o // 输出目标文件的符号表:

image.png

  • 第一列:段内偏移;
  • 第二列:符号作用域  : local /global;
  • 第三列:符号类型;
  • 第四列:符号所在段(UND外部链接符号,未在本目标文件定义);
  • 第五列:符号对应的对象占据的内存空间大小,没有实体对象大小为0,未定义的为0;
  • 第六列:符号名;

其中 main 定义在 .text

data 是全局变量,且初始化定义在 .data ,也就是 m 定义并能被其他模块引用的全局符号。
gdata 和 sum 函数是声明,因此是UNG,也就是由其他模块定义并被模块 m 引用的全局符号。

第一列都是 0x0 没有为符号分配虚拟地址,在链接阶段分配

sum.o 中:

image.png

gdata 是出刷的全局变量 在 .data 中;sum 函数在 .text

readelf -h 查看 elf 文件的头文件信息
可见目标文件的elf文件,其类型为REL(可重定位文件)。

objdump -s 显示全部 Heade r信息,还显示他们对应的十六进制文件代码:

image.png

有调试信息的:

image.png

可以看到符号地址未分配,用0填充;这也是obj文件无法运行的原因之一。

5. 符号解析

链接的步骤一:所有.o文件段的合并(.text .data .bss合并),符号表合并后,进行符号解析,所有对符号的引用(UNG)都要找到该符号定义的地方。经常见的报错:符号重定义(存在多个相同的)、符号未定义(找不到)

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

在编译是,编译器向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量时强符号,未初始化的全局变量是弱符号。
根据强弱符号的定义,Unix链接器使用下面的规则来处理多重定义的符号:

规则1:不允许有多个强符号。
规则2:如果有一个强符号和多个弱符号,那么选择强符号。
规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个。

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

在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维持一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U和D都是空的。

  1. 对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器吧f添加到E, 修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。
  2. 如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都反复进行这个过程,直到U和D都不再发生变化。在此时,任何不包含在E中的目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
  3. 如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就好输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。

这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。关于库的一般准则是将它们放在命令行的 结尾。

另一方面,如果库不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义实在对s的引用之后的。

如果需要满足依赖需求,可以在命令行上重复库。

6. 重定向

一旦链接器完成了符号解析这一步,它就是把代码中的每个符号引用和确定的一个符号定义(即它的一个输入目标模块中的一个符号表条目)联系起来。在此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。

重定位有两步组成:

  1. 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
  2. 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位条目的可重定位目标模块中的数据结构。

image.png

链接后:所有的符号都有虚拟地址

image.png

汇编中,全局变量和函数都有了地址。

7. 可执行目标文件

可执行目标文件的格式类似于可重定位目标文件的格式。ELF头部描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text.rodata.data 节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位了),所以它不再需要.rel节。

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

image.png


01.理解 C++ 内核
http://example.com/2023/09/21/02.C++ 基础部分/01.理解 C++ 内核/
Author
Yakumo
Posted on
September 21, 2023
Licensed under