跳转至

编译、链接、加载与运行

约 1908 个字 预计阅读时间 6 分钟

编译

  • 编译的主要目的是将人类可读的源代码,翻译成计算机能理解的、但是尚未封装好的半成品——目标文件(.o文件)

  • 这个阶段通常分为3个小步骤:

  1. 预处理(Preprocessing)

    1. 拷贝标准库里的文件代码

    2. 宏展开

    3. 条件判断保留或删除代码块

    • 输入:hello.c,输出:hello.i
  2. 编译(Compilation proper)

    • 编译器进行词法分析、语法分析、语义分析和优化,最终将C代码转换成特定CPU架构的汇编代码

    • 输入:hello.i,输出:hello.s

  3. 汇编(Assembly)

    • 汇编器将汇编指令翻译器二进制的机器码

    • 输入:hello.s,输出:hello.o

  • 此时已经是二进制文件了,包含了所有函数的机器码,但是仍是一个半成品。比如,在调用printf()的时候,hello.o文件里其实printf地址是未知的,只有一个占位符,表示需要用到这个printf函数,要将地址告诉它

链接

  • 将多个.o文件和所需的库文件组合在一起,得到完整的可执行文件

  • 链接器主要执行两个核心任务:

  1. 符号解析(Symbol Resolution)

    • 检查所有.o文件,确保每一个占位符(比如对printf的引用)都能在其他的.o文件里找到对应的实体,如果找不到,就会报错undefined reference to...
  2. 重定位(Relocation)

    • 计算出所有函数和变量在最终可执行文件里确切的虚拟地址,然后回去修改那些占位符(.text中的指令),把计算出的正确地址(不是物理地址)填进去

    • 链接器会将所有.o文件中性质相同的部分合并在一起,形成最终可执行文件的不同段(Segments/Sections)

段的划分

  1. text段(代码段
  • 内容:程序的二进制机器码

  • 特点只读且可执行,这是重要的安全机制,防止程序意外或恶意修改自己的指令

  1. .rdata段(只读数据段
  • 内容:只读数据,比如字符串常量、const型全局变量

  • 特点:只读

  1. .data段(已初始化数据段
  • 内容:已经被初始化的全局变量和静态变量(e.g. int global_var = 100;

  • 特点:可读可写

  1. .bss段(未初始化数据段
  • 内容未被初始化的全局变量和静态变量(e.g. static int global_array[1024];

  • 特点:可读可写。一个巧妙的优化是,在可执行文件中,.bss几乎不占用实际空间,只记录需要多少字节大小的内存。当程序加载时,由加载器分配一块填满0的内存给它,大大减小了可执行文件在磁盘上的体积


链接方式

  • 把静态库(如libc.a)的内容完整地复制一份,装订在可执行文件中

  • 优点:得到的可执行文件是完全独立的,不依赖任何的外部库文件,可以随便拷贝到兼容的操作系统上执行

  • 缺点:体积大更新难

  • 链接器在可执行文件里会记录一个信息:“我需要一个名为libc.so的共享库”

  • 优点:体积小易更新节省内存(多个程序在运行时可以共享内存中同一份库的副本)

  • 缺点:有依赖性,如果没有对应的.so文件,或者版本不对,程序就无法运行(所谓的依赖地狱)

加载与运行

  • 当我们从shell中敲下./hello并回车时,操作系统内核的加载器开始工作

  • 不过我们首先要知道,通过编译、链接得到的可执行文件里包含了什么?

可执行文件的组成

  • 可执行里不是只包含了机器码,而是一个高度结构化的容器
  1. 文件头(ELF Header)
  • 这标识了文件的各种信息:文件的类型(可执行文件、库文件),目标硬件架构(x86,ARM等),入口点地址(程序从哪里开始第一行执行)等关键信息
  1. 程序头表(Program Header Table)
  • 这是给操作系统加载器看的蓝图。它表述了文件中哪些部分(如.text节)应该被加载到内存的什么位置,以及加载后这块内存区域应该设置成什么权限(读、写、执行),它定义了内存“段”的布局
  1. 节头表(Section Header Table)
  • 这是给链接器和调试器看的材料清单。它详细描述了文件里所有的Section(这与内存中的段 Segment 密切相关),比如.text, .data, .bss, .symbol(符号表),.debug_info(调试信息)等,以及它们在文件中的位置和大小
  1. 其他信息
  • 符号表:记录了函数名、变量名和他们地址的对应关系,是调试和动态链接的基础

  • 重定位信息:在生成动态链接库(.so)或者位置无关代码时需要

  • 调试信息:如果编译时开启了-g选项,会包含源代码行号、变量类型等丰富的调试信息

  1. 创建虚拟空间
  • 加载器做的第一件事,不是把整个文件读进内存,而是为新程序创建一个独立的、全新的虚拟地址空间(Virtual Address Space)。这块地址从0x0000...0xFFFF...都属于这个新进程
  1. 内存映射(Memory Mapping)
  • 接下来是最关键的一步,加载器会读取ELF文件的程序头表 (Program Header Table),这里面记录了哪些段需要被加载到内存,以及它们的权限。

  • 加载器使用 mmap 系统调用,将文件的不同段映射到虚拟地址空间中,而不是真的“复制”过去。

    • .text 段 映射到一块内存,并设置权限为 Read-Execute。

    • .rodata 段 映射到一块内存,并设置权限为 Read-Only。

    • .data 段 映射到一块内存,并设置权限为 Read-Write。

    • .bss 段 申请一块匿名的、全为0的内存,并设置权限为 Read-Write。

  • 这种“映射”是一种延迟加载技术。只有当程序真正访问到某一块代码或数据时,内核才会触发一个缺页中断(Page Fault),然后才去从磁盘文件中读取那一页的数据到物理内存中。这大大加快了程序的启动速度。

  1. 处理动态链接
  • 如果程序是动态链接的(绝大多数程序都是),加载器会看到ELF头中指定了一个程序解释器,通常是 /lib/ld-linux-x86-64.so.2 (在x86上) 或 /lib/ld-linux-riscv64-lp64d.so.1 (在RISC-V上)。

  • 此时,内核会先加载并启动这个动态链接器。然后,动态链接器会接管后续工作:

  • 读取程序所需的共享库列表(如libc.so.6)。

  • 在系统中查找并加载这些 .so 文件到进程的地址空间。

  • 进行运行时的重定位,修复所有对共享库函数的调用地址(这通常通过修改GOT/PLT表来完成)。

  1. 移交控制权
  • 一切准备就绪后,内核会为程序设置好堆栈(Stack),将命令行参数(argc, argv)和环境变量压栈。

  • 最后,加载器将CPU的指令指针 (Instruction Pointer / Program Counter) 设置为ELF文件中记录的入口点地址 (_start 符号),然后执行一条跳转指令。

  • 至此,CPU的控制权正式从内核空间移交到了用户空间的程序手中。程序开始从 _start 执行,然后调用 main,一个鲜活的进程就此诞生。