C语言编译过程 & ELF文件加载过程解析

C语言编译 - ELF文件加载过程解析

bin 文件通常用于嵌入式裸机程序的烧录,elf 可执行文件通常运行在操作系统之上。

  • bin 是扁平的二进制文件,没有任何说明,它假设加载它的环境(如嵌入式引导程序,BootRom)已经预先知道了代码存放的地址,代码的入口,数据段,代码段的地址。大家如果烧录过嵌入式裸机程序应该有所体会。

  • elf 则是带有详细说明和装配图的文件,因此 elf 可执行程序的运行是需要对其所包含的信息进行解析并建立执行环境的,这就决定了其不可能作为裸机程序去执行。

一、C语言执行需要的内存环境

一个可执行文件加载过程中,需要创建执行所需要的内存空间。对于操作系统而言,一般指的是每个进程的虚拟地址空间。一个进程的内存空间一般存在四个核心区域,代码段(.text),数据段(.data),堆(.heap),栈(.stack)。

  • 代码段,用于存放编译后的机器指令。
  • 数据段:
    • data 段,已初始化的非零全局变量和静态变量。
    • bss 段,未初始化或者初始化值为0的全局变量,静态变量。
    • rodata段,const 关键字修饰的常量,只读。
  • 堆,用于动态空间分配,C语言中需要手动分配和释放(malloc/free)。
  • 栈,存储局部变量、函数参数、返回地址,自动分配和释放,遵循 "先进后出" 规则。

需要注意的是,栈的地址是从高地址到低地址,堆的地址是从低地址到高地址。

二、C语言编译过程

C语言从源代码到可执行文件一般需要四个过程,即预处理,编译,汇编,链接

  • 预处理,处理源代码中的预处理指令(以#开头),生成纯 C 代码(.i文件)。

    • 宏替换:展开#define定义的宏(如#define PI 3.14替换为实际值)。
    • 文件包含:将#include指令指向的头文件(如stdio.h)内容插入当前文件。
    • 条件编译:根据#if#ifdef等指令保留或删除部分代码(如调试代码#ifdef DEBUG ... #endif)。
    • 删除注释:移除///* */注释,不影响代码逻辑。
  • 编译,将预处理后的纯 C 代码(.i文件)转换为汇编代码(.s文件)。

    • 语法分析:检查代码语法是否符合 C 语言规则(如括号匹配、关键字使用等),若出错则终止编译。
    • 语义分析:验证代码逻辑合理性(如变量未声明就使用、类型不匹配等)。
    • 中间代码生成:将合法代码转换为中间表示(如三地址码)。
    • 优化:对中间代码进行优化(如常量折叠、循环展开),提升执行效率。
    • 汇编生成:将优化后的中间代码转换为对应 CPU 架构的汇编指令。
  • 汇编,将汇编代码(.s文件)转换为机器指令(二进制目标文件,.o.obj)。

    • 把汇编指令一一对应为 CPU 可识别的二进制 opcode(如call printf转换为对应的机器码),生成的目标文件包含:
    • 二进制指令(代码段)
    • 变量数据(数据段)
    • 符号表(记录函数、变量的地址信息,供后续链接使用)。

    目标文件是 "部分编译" 的结果,可能包含未解析的外部符号(如printf函数的地址尚未确定)。

  • 链接,将多个目标文件(.o)和库文件(如libc.so)合并,生成可执行文件。

    • 符号解析:找到所有外部符号的实际地址(如printf在 C 标准库中的地址)。
    • 重定位:调整目标文件中指令的地址(因为多个文件合并后,原地址可能偏移)。
    • 合并段:将多个目标文件的代码段、数据段等合并为统一的内存布局。

链接又分为静态链接和动态链接。Linux 环境下使用 gcc 进行编译,默认使用动态链接。

  • 静态链接将程序依赖的库函数代码(如printfmalloc)直接复制到可执行文件中 ,形成一个独立的二进制文件。
    • 依赖的库称为 "静态库"(Windows 下为.lib,Linux 下为.a)。
  • 动态链接仅在可执行文件中记录库函数的引用信息(如函数名、库路径) ,不复制库代码。程序运行时,由操作系统的动态链接器(如 Linux 的ld.so,Windows 的ntdll.dll 加载依赖的库文件到内存,并解析引用,后面会展开讲。
    • 依赖的库称为 "动态库"(Windows 下为.dll,Linux 下为.so,macOS 下为.dylib)。
    • Linux默认的动态库链接是/lib/usr/lib
sh 复制代码
gcc -E main.c -o main.i				# 预处理
gcc -S main.i -o main.s 			# 编译
gcc -c main.s -o mian.o 			# 汇编
gcc -static main.o -o main 			# 静态链接
gcc main.o -o main 					# 默认动态链接

将自己的代码编译为动态库:

sh 复制代码
# -fPIC为位置无关代码;-shared指示gcc生成一个共享库而不是一个可执行文件,共享库可以被多个程序同时使用节省了内存和磁盘空间。
gcc hello.o -fPIC -shared -o libxxx.so
gcc main.o -L ./ -lhello -o main2	# main 中用到 hello.c 的库函数
# -lhello -l + 库的名称。上面的命令只是告诉动态链接库是谁,并没有指定动态库所在的路径,因此需要添加要使用的动态库文件路径,然后执行。
# 或者将库文件所在路径添加进环境变量 LD_LIBRARY_PATH

将自己的代码编译为静态库:

sh 复制代码
ar crv libhello.a hello.o
gcc main.o -L ./ -lhello -o main3	# 注意:当同一个目录下既有静态库又有动态库,默认链接动态库。

三、elf 可执行程序

3.1 elf 文件的组成

elf 文件的主要包括ELF头部,程序头表,节区,节头表几个部分。

  1. ELF头部

    • 标识这是一个ELF文件,文件类型可重定位文件还是共享库文件,ARM架构。
    • e_entry:程序的入口地址,第一条指令的虚拟地址。对于动态链接的程序,这个地址通常不是 main 函数,而是动态链接器 _start 的入口,后面会讲。
    • 程序头表和节头表的偏移。
  2. 程序头表

    程序头表相当于一个加载说明书,告诉操作系统如何将 elf 文件的内容映射到内存中,以创建一个进程。程序头表在实现上是一个结构体数组,每个结构体(segment)描述了文件中的一块区域应该如何被映射到内存中。

    1. PT_LOAD代码段,数据段等需要被加载到内存中的段。
    2. PT_DYNAMIC:包含动态链接信息的段。
    3. PT_INTERP:动态链接器的路径(lib/ld-linux.so.2)。

    每个程序头都包含了该段在文件中的偏移,在内存中的虚拟地址,大小,执行权限等。

  3. 节区

    保存着不同节的具体内容。

    1. .text:存放程序指令(代码)。
    2. .data:存放已初始化的全局变量和静态变量。
    3. .bss:存放未初始化的全局变量和静态变量(此节在文件中不占空间,只在运行时在内存中分配)。
    4. .rodata:存放只读数据(如常量字符串)。
    5. .symtab:符号表,记录了函数和变量的名称及其地址。
    6. .symtab / .dynsym:符号表,.symtab 包含所有符号(包括本地符号),.dynsym 仅包含动态链接所需的符号(如外部库函数)。
    7. .rel.text / .rel.data:重定位信息,用于链接时修正地址。
  4. 节头表

    所有节的索引目录。存放一个数组,数组中的每个元素对应一个节,描述了该节的名称(在.strtab中的索引),类型,在文件中的偏移,大小,链接信息等。

3.2 elf 文件的加载过程

  1. shell 调用,shell 会 fork 一个子进程,然后 execve 跳转到 elf 可执行文件中。
  2. 跳转到内核**(分配各区域虚拟地址空间,创建C语言进程运行环境)**
    1. 读取 elf 头部。验证是不是 elf 文件,读取头部信息,找到程序头表。
    2. 解析程序头表 。寻找 PT_LOAD 段,这是唯一需要被实际加载到内存中的段,通常一个 ELF 文件至少有两个PT_LOAD 段,即代码段和数据段
    3. 在找到 PT_LOAD 段之后,加载器会为当前进程创建一个新的虚拟内存区域 ,起始地址和大小 PT_LOAD->p_vaddrPT_LOAD->p_memsz 决定。设置权限。
    4. 上面的步骤建立了进程的虚拟内存区域,现在需要完成虚拟内存区域到物理内存的映射。需要注意的是这个映射并不是把磁盘的所有内容都直接复制到内存里面,而是在 MMU 触发缺页中断的时候才从磁盘中把需要的数据放入内存。
    5. 处理 .bss 段 ,其在二进制文件中不占用空间,但是需要在内存中为其分配 p_memsz 的空间。
    6. 寻找程序头表中的 PT_INTERP 段,其存放着动态链接器的路径,加载器会把这个动态链接器也放到进程的虚拟内存中。
    7. 设置栈。内核会在进程空间地址顶端创建一个栈区域,压入一些数据,传入参数个数,传入参数指针,指向各环境变量的字符串等和一些辅助向量。
  3. 从内核跳转到用户空间**(加载动态链接库,重定位)**
    1. 内核将指令指针 PC 设置为动态链接器的入口。栈指针指向刚刚的栈顶,切换到用户模式。
    2. 动态链接器 _start 函数开始运行
    3. 读取主程序的 .dynamic 节区,找到程序依赖的共享库列表,加载这些库。
    4. 上一步动态库加载了之后就有了地址,此时就需要对库函数地址(占位符)进行替换为真实的虚拟地址。
    5. 执行主程序和各个共享库的初始化代码。
    6. 跳转main函数执行。

3.3 符号表,字符串表和重定位

符号表,字符串表,重定位信息都属于节区。

静态链接

静态链接情况下不存在动态库,根据上一节所讲的 elf 文件加载过程,需要动态库的可执行文件是运行时加载,然后进行重定位的,因此静态链接的可执行文件在编译完成的时候,重定位就已经完成,节区中的重定位信息被删除或者为空,符号表通常会保留,用于 GDB 调试。

符号表 .symtab 与字符串 strtab 表结合作用,符号表中保存着程序中变量,函数 ,(文件名,节区名)等的名称 (索引 st_name,指向 strtab 中的对应位置),地址st_value,通常是函数,变量的地址或者偏移量),大小(st_size,一个数组或者函数的字节大小),类型(st_info,如 STT_FUNC 函数, STT_OBJECT 对象,绑定属性:STB_LOCAL:局部符号;STB_GLOBAL:全局符号;STB_WEAK:弱符号;st_shndx:一个索引,指明该符号位于哪个节区。)

为什么不直接把字符串存在 .symtab 里呢?因为这样做效率低下且浪费空间。使用索引的方式,多个符号可以共享同一个字符串(例如,多个文件都引用 printf),并且符号表条目可以保持固定大小,便于快速查找。

重定位表(rel.text/rel.data)要解决的问题:当存在多个 .c 文件时,一个 c 文件使用到另一个 c 文件的函数,在编译单个 c 文件时,编译器并不知道调用的这个函数的最终地址,也不知道自己定义的函数或者变量最终会被链接到可执行文件的哪个地址。这时汇编器 会生成一个重定位条目,并留下一个占位符,表示这个位置的代码需要被修正。重定位条目生成在汇编阶段,最终地址的确定发生在链接阶段 。每个重定位条目(通常是一个结构体变量,Elf_Rel或Elf_Rela)包含需要被修正的地址在节点中的偏移量 r_offset,r_info,存放符号索引和重定位类型

对某个函数和变量进行重定位,首先是要知道这个需要被重定位的函数变量在哪(r_offset),其次是要知道要填充进这个占位符的地址是什么(从符号表中获得r_info)

两种重定位表的介绍:

  • .rel.text :包含了对代码节区 (.text) 的重定位信息。例如,call printf 指令中的 printf 函数地址,在编译时是未知的,这里就会生成一个重定位条目。
  • .rel.data :包含了对已初始化数据节区 (.data) 的重定位信息。例如,int *ptr = &global_var; 这行代码,ptr 变量在 .data 节区,但它存储的 global_var 的地址在编译时也是未知的,这里也会生成一个重定位条目。

对于一个工程文件的编译流程,预处理,编译,汇编,链接。假设一个简单的工程文件包括 a.cb.c 包括静态库文件 .a。在汇编阶段,汇编器会对各个 .c 文件进行汇编,由于这时各个文件中的函数变量在可执行文件中的地址并没有被确定,会生成很多重定位条目。在链接阶段 ,链接器 ld 会读取所有的 .o 文件和静态库文件 .a,把所有同类型的节区合并 ,读取各个 .o 文件的符号表,创建一个全局符号表,并且在这个过程中进行符号解析。此时整个可执行文件的地址,符号表基本确定,需要根据重定位条目对一些占位符进行重定位处理。

  1. 根据 r_info 找到符号表中的对应位置,获取该符号的最终地址。
  2. 分析 r_info 中的重定位类型,计算出需要写入的值。
  3. 找到 r_offset 指定的需要重定位的占位符的位置,执行重定位。

链接完成之后,.text.data 中的地址引用都是完整的、可以直接运行的。.rel.text.rel.data 节区通常会被丢弃 ,因为所有重定位工作已经完成,不再需要它们了。.symtab.strtab 可能会被保留(用于调试),或者被 strip 工具移除以减小文件大小。

动态链接

首先,根据 elf 加载流程,动态链接的地址重定位是在可执行文件执行过程中在内核分配完内存空间和栈空间之后,调用 ld.so 动态连接器,转到用户空间加载依赖的共享库,然后进行运行时地址重定位。最终跳转程序入口,开始执行程序。

链接过程:

  1. 处理内部符号的重定位。
  2. 对于外部符号,因为地址并不确定,所以链接器并不会解析它的最终地址。
  3. 链接器为外部符号生成动态重定位信息,保存在 .rela.plt(函数)和 .rela.dyn(数据)节区中,对应的动态符号表 .dynsym.symtab 的一个子集,只包含用于动态链接的全局符号。动态字符串表为.dynstr。

程序加载过程:

  1. 调用动态链接器 ld.so,并执行。
  2. 根据程序头表中的 PT_DYNAMIC 找到 .dynamic 节区,遍历所有的 DT_NEEDED 条目,加载所有依赖的共享库文件,此时主程序所用的库文件都有了自己的地址。然后根据 DT_RELADT_RELASZDT_JMPREL告诉动态链接器 .rela.dyn.rela.plt 的位置。
  3. 处理数据重定位 .rela.dyn
  4. 处理函数重定位 .rela.plt 。使用延迟绑定 策略,当函数第一次被调用时,控制权会转到 PLT 中,PLT 代码会触发 ld.so 解析真正的函数地址。解析完成后写入 PLT,后续调用查表即可。

结语

对整个 elf 可执行文件的加载过程,大多数人其实只需要了解即可,知道它的流程是怎么样的,重定位和链接的关系和设计思想。以及,elf 文件加载和 bin 文件的区别。

总结的未免凌乱,不足之处欢迎讨论!


Steady progress!

相关推荐
_OP_CHEN9 小时前
基于瑞萨 RA6M5 开发板的声源定位系统设计与实现
电赛·c/c++·嵌入式开发·瑞萨mcu·声源定位·嵌入式应用·嵌赛
大聪明-PLUS21 小时前
Linux 中 timeout、watch 和 at 的指南:管理命令执行时间
linux·嵌入式·arm·smarc
阿源-2 天前
嵌入式面试中常见的一些编程题目
嵌入式·c/c++
Evan_ZGYF丶2 天前
深入解析CFS虚拟运行时间:Linux公平调度的核心引擎
linux·驱动开发·嵌入式·bsp
大聪明-PLUS2 天前
Linux 上的 GitOps:使用 Git 进行无缝基础设施管理
linux·嵌入式·arm·smarc
大聪明-PLUS3 天前
嵌入式 Linux 初学者指南 – 第 2 部分
linux·嵌入式·arm·smarc
计算衎3 天前
.c .o .a .elf .a2l hex map 这些后缀文件的互相之间的联系和作用
开发语言·elf·gcc·c/c++·a2l
一枝小雨3 天前
【OTA专题】2 初级bootloader架构和基础工程移植
stm32·单片机·嵌入式·ota·bootloader·固件升级·加密升级
橘颂TA3 天前
【剑斩OFFER】算法的暴力美学——二分查找
算法·leetcode·面试·职场和发展·c/c++