Overview

复习

  • 可执行文件:一个描述了状态机的初始状态 (迁移) 的 数据结构

本次课回答的问题

  • Q1: 可执行文件是如何被操作系统加载的?
  • Q2: 什么是动态链接/动态加载?

本次课主要内容

  • 若干真正的静态 ELF 加载器
  • 动态链接和加载

静态 ELF 加载器:实现

在操作系统上实现 ELF Loader

可执行文件

  • 一个描述了状态机的初始状态 (迁移) 的 数据结构
    • 不同于内存里的数据结构,“指针” 都被 “偏移量” 代替
    • 数据结构各个部分定义:/usr/include/elf.h

加载器 (loader)

  • 解析数据结构 + 复制到内存 + 跳转
  • 创建进程运行时初始状态 (argv, envp, ...)

Boot Block Loader

加载操作系统内核?

  • 也是一个 ELF 文件
  • 解析数据结构 + 复制到内存 + 跳转

bootmain.c (i386/x86-64 通用)

  • 之前给大家调试过
    • 不花时间调试了
    • 马上有重磅主角登场

Linux 内核闪亮登场

loader-static.c, bootmain.c 和 Linux 有本质区别吗?没有!

  • 解压缩源码包
  • make menuconfig (生成 .config 文件)
  • make bzImage -j8
    • 顺便给 Kernel 个补丁 (kernel/exit.c)

编译结果

  • vmlinux (ELF 格式的内核二进制代码)
  • vmlinuz (压缩的镜像,可以直接被 QEMU 加载)
  • readelf 入口地址 0x1000000 (物理内存 16M 位置)
    • __startup_64: RTFSC; 调试起来!
    • 时刻告诉自己:不要怕,就是状态机 (和你们的 lab 完全一样)

调试 Linux Kernel ELF Loader

fs/binfmt_elf.c: load_elf_binary

  • 这里我们看到了 Linux Kernel 里的面向对象 (同我们的 oslab)

让我们愉快地打个断点……

  • 当然是使用正确的工具了
    • script/gen_compile_commands.py
      • 思考题: 如何实现 “自动” 获得所有编译选项的工具?
    • vscode 快捷键
      • ⌃/⌘ + P (@, #)
      • ⌃/⌘ + ⇧ + P - 任何你不知道按什么键的时候,搜索!
    • Linux Kernel 也不过如此!
      • 你们需要一个 “跨过一道坎” 的过程

动态链接和加载

“拆解应用程序” 的需求

随着库函数越来越大,希望项目能够 “运行时链接”。

减少库函数的磁盘和内存拷贝

  • 每个可执行文件里都有所有库函数的拷贝那也太浪费了
  • 只要大家遵守基本约定,不挑库函数的版本

大型项目的分解

  • 编译一部分,不用重新链接
  • libjvm.so, libart.so, ...
    • NEMU: “把 CPU 插上 board”

动态链接:今天不讲 ELF

和 ELF battle 的每一年:讲着讲着就讲不下去了

  • 其实不是讲不清楚,是大家跟不上
    • 根本原因:概念上紧密相关的东西在数据结构中被强行 “拆散” 了
      • GOT[0], GOT[1], ... ???

换一种方法

  • 如果编译器、链接器、加载器都受你控制
  • 你怎么设计、实现一个 “最直观” 的动态链接格式?
    • 再去考虑怎么改进它,你就得到了 ELF!
  • 假设编译器可以为你生成位置无关代码 (PIC)

设计一个新的二进制文件格式

动态链接的符号查表就行了嘛

DL_HEAD

LOAD("libc.dl") # 加载动态库
IMPORT(putchar) # 加载外部符号
EXPORT(hello)   # 为动态库导出符号

DL_CODE

hello:
  ...
  call DSYM(putchar) # 动态链接符号
  ...

DL_END

用最小代价为 .dl 文件配齐全套工具链

编译器

  • 开局一条狗,出门全靠偷 (GCC, GNU as)

binutils

  • ld = objcopy (偷来的)
  • as = GNU as (偷来的)
  • 剩下的就需要自己动手了
    • readdl (readelf)
    • objdump
    • 你同样可以山寨 addr2line, nm, objcopy, ...

和最重要的加载器

  • 这个也得自己动手了

动态链接:实现

头文件

  • dl.h (数据结构定义)

“全家桶” 工具集

  • dlbox.c (gcc, readdl, objdump, interp)

示例代码

  • libc.S - 提供 putchar 和 exit
  • libhello.S - 调用 putchar, 提供 hello
  • main.S - 调用 hello, 提供 main
    • (假装你的高级语言编译器可以生成这样的汇编代码)

重新回到 ELF

解决 dl 文件的设计缺陷

存储保护和加载位置

  • 允许将 .dl 中的一部分以某个指定的权限映射到内存的某个位置 (program header table)

允许自由指定加载器 (而不是 dlbox)

  • 加入 INTERP

空间浪费

  • 字符串存储在常量池,统一通过 “指针” 访问
    • 这是带来 ELF 文件难读的最根本原因

其他:不那么重要

  • 按需 RTFM/RTFSC

另一个重要的缺陷

#define DSYM(sym)   *sym(%rip)

DSYM 是间接内存访问

extern void foo();
foo();

一种写法,两种情况

  • 来自其他编译单元 (静态链接)
    • 直接 PC 相对跳转即可
  • 动态链接库
    • 必须查表 (编译时不能决定)

“发明” GOT & PLT

我们的 “符号表” 就是 Global Offset Table (GOT)

  • 这下你不会理解不了 GOT 的概念了!
    • 概念和名字都不重要,发明的过程才重要

统一静态/动态链接:都用静态!

  • 增加一层 indirection: Procedure Linkage Table (PLT)
  • 所有未解析的符号都统一翻译成 call
    • 现代处理器都对这种跳转做了一定的优化 (e.g., BTB)
putchar@PLT:
  call DSYM(putchar) # in ELF: jmp *GOT[n]

main:
  call putchar@PLT

再次回到 printf

你会发现和我们的 “最小” 二进制文件几乎完全一样!

  • ELF 还有一些额外的 hack (比如可以 lazy binding)

00000000000010c0 <printf@plt>:
    10c0:  endbr64 
    10c4:  bnd jmpq *0x2efd(%rip) # DSYM(printf)
    10cb:  nopl 0x0(%rax,%rax,1)

00000000000011c9 <main>:
    ...
    1246:  callq  10c0 <printf@plt>

最后还一个问题:数据

如果我们想要引用动态链接库里的数据?

  • 数据不能增加一层 indirection

stdout/errno/environ 的麻烦

  • 多个库都会用;但应该只有一个副本!

当然是做实验了!

  • readelf 看看 stdout 有没有不同
  • 再用 gdb 设一个 watch point
    • 原来被特殊对待了
    • 算是某种 “摆烂” (workaround) 了

总结


总结

本次课回答的问题

  • Q1: 可执行文件是如何被操作系统加载的?
  • Q2: 什么是动态链接/动态加载?

Take-away messages

  • 加载器
    • 借助底层机制把数据结构按 specification “搬运”
  • 动态链接/加载
    • GOT, PLT 和最小二进制文件
  • 啪的一下!很快啊!我们就进入了操作系统内核
    • 没什么难的,就是个普通 C 程序

End.