xym-ee
  • 计算机与嵌入式开发学习
  • 1-1.编程基础
    • C 语言
      • C 中的数据
      • C 语言基础
      • 字符输入输出
      • 函数
      • 数组和指针
      • 字符串处理
      • 存储类别
      • 文件 I/O
      • 复杂数据类型
      • 位操作
      • 预处理和 C 库
    • 数据结构和算法入门
    • leetcode 刷算法题
      • 递归与栈
      • 二叉树与递归
      • 回溯问题
      • 动态规划 1
    • 基本工具和使用
      • shell
      • shell 脚本
      • vim 编辑器
      • 命令行数据整理
      • 命令行环境和配置
  • 1-2.计算机系统基础(CSAPP)
    • 1.计算机基础
    • 2.数据的表示
    • 3.加减运算
    • 4.乘除运算
    • 5.程序的表示转换和链接
    • 6.IA32指令
    • 7.过程调用
    • 10.程序的链接
  • 1-3.数字电路、计算机组成
    • 1.数字电路、virtual circuit board
    • 2.计算机组成/steam:Turing Complete
    • 3.微机原理与接口技术(8086)
  • 1-4.计算机网络
    • 1.从浏览器开始
    • 2.协议栈和网卡
    • 3.网络设备
    • 4.运营商、接入网
    • 5.服务器
    • 6.数据返回浏览器
    • socket编程
  • 1-5.操作系统
    • 0.绪论
      • 1.应用视角的操作系统
      • 2.硬件视角的操作系统
      • 3.数学视角的操作系统
      • 4.状态机模型的应用
    • 1.并发
      • 1.并发 bug 的解决思路
      • 2.互斥
      • 3.同步
      • 4.信号量
      • 5.真实并发
      • 6.调试技巧
      • 7.os kernel 实现
    • 2.虚拟化
      • 1.操作系统上的进程
      • 2.进程的地址空间
      • 3.系统调用和unix shell
      • 4.C 标准库的实现
      • 5.linux 操作系统
      • 6.可执行文件和加载
      • 7.动态链接和加载
      • 8.内核的实现
      • 9.fork 的应用
    • 3.持久化
      • 1.存储设备的原理
      • 2.输入输出设备模型
      • 3.设备驱动程序
      • 4.文件系统 API
      • 5.fat 和 unix 文件系统
      • 6.持久数据的可靠性
    • 总结
  • 2-1.嵌入式裸机开发
    • 嵌入式系统通信接口与协议
    • cortex-m 内核芯片裸机开发
    • MPU
  • 2-2.中等规模系统开发
    • LVGL 图形库
    • 裸机开发的软件框架
    • 基于 rtos 开发
  • 2-3.armv7-m架构与 rtos 原理
    • armv7-m 架构
    • rt-thread 内核实现
    • rt-thread 驱动开发
  • 3-1.linux 应用开发基础
  • 3-2.linux 镜像构建
    • uboot 使用
    • uboot 适配
    • uboot 启动分析
    • uboot 自定义命令
    • linux 内核适配
    • linux 内核启动分析
    • busybox 根文件系统构建
  • 3-3.linux 驱动开发
    • 驱动开发基础
    • sysfs
    • led 驱动
    • 设备树
    • pinctrl 和 gpio 子系统
    • 并发控制
由 GitBook 提供支持
在本页
  • 1. 可执行文件
  • 2. 自己设计一个可执行文件
  • 加载
  • 3. 加载 elf 文件
  • 实现 ELF Loader
  • Boot Block Loader,
  • ELF 文件如何生成
  • 动态链接和加载
  • 拆解应用程序的需求
  • 设计一个二进制文件格式

这有帮助吗?

  1. 1-5.操作系统
  2. 2.虚拟化

6.可执行文件和加载

上一页5.linux 操作系统下一页7.动态链接和加载

最后更新于9个月前

这有帮助吗?

之前的内容,操作系统是对象+api,从最小的 initramfs,构建出了足够多的对象成为一个可用的操作系统。

然后在这个可用的操作系统上,基于系统调用,可以实现 freestanding 的 shell(有功能的程序),封装 libc,然后在 libc 之上,可以实现更多的程序。

我们已经见识了一个 “层层封装” 的应用生态世界,它们的 “底座” 是操作系统提供的对象和 API,以及封装它们的 C 标准库。之前,我们 “默认” 了编译器工具链可以帮助我们实现高级语言到可执行文件的翻译。今天是时候 “打开” 这部分内容了。

我们已经见识过在系统调用 API 和操作系统系统对象上层层封装得到的世界了。是时候实现一些 “真正” 的程序了——让我们看一看到底什么是可执行文件,以及它们是如何被操作系统加载的。

  • (静态链接)可执行文件

  • 概念、原理;链接器/加载器的设计与实现。

理解上,可执行文件是个状态机。这部分看到底是什么。

1. 可执行文件

学习操作系统前,可执行文件是那个 “双击可以弹出窗口的东西”。

学习操作系统后,可执行文件是

  • 一个操作系统中的对象 (文件)

  • 一个字节序列 (我们可以把它当文本编辑)

  • 一个描述了状态机初始状态的数据结构

回顾计算机系统基础,ABI,只规定了部分寄存器和栈,其他状态(主要是内存)由可执行文件指定。

磁盘上的一个可执行文件是操作系统的对象,everything is a file,即 文件 是对 everything 的一个抽象。计算机里 everything 的抽象是 数据。硬盘是个 file,硬盘是个字节数组,这就抽象出来了。我们可以看一个文件的数据,比如 `/bin/ls` 是个文件,我们可以运行,同时我们也可以编辑 `vim /bin/ls`。

我们可以用二进制的编辑器来编辑。`vim /bin/ls` 后 `:%!xxd` 。vim 的设计符合 unix 哲学,`:%` 代表整个当前文件,把全体内容管道给 xxd,然后得到的结果粘贴回来。

我们修改二进制内容后,可以 `:%!xxd -r` ,这就又变成了二进制文件。更多用法,查看 xxd。这个过程中,vim 什么功能也没实现,vim 甚至不知道这是个二进制文件。在 vim 里通过这种方式,集成了 unix 所有的工具,当然用插件可以做的更好。这也很有意思。

这个小例子,我们看到了可执行文件就是个字节序列,我们甚至可以修改。所以是不是 ELF 不重要,重要的是数据结构里要包含足够的信息,能够创建进程的初始状态,所以世界上的可执行文件的格式是不唯一的。

对可执行文件的初步感觉。一个 `hello world` 程序,用 `gcc` 静态链接。使用 `file a.out` 可以看到这是一个 elf 的可执行文件。

`execve()` 里调用可执行文件,这个系统调用是重置当前状态机的。重置为这个可执行文件的初始状态。因此可执行文件是状态机初始状态的描述,并且描述了状态机如何迁移。

因此可执行文件是一个描述了状态机初始状态+迁移的数据结构。

这是对可执行文件的正确理解。

可执行文件就是个数据结构,作为 “数据结构” 的可执行文件必须要描述好状态机初始状态

  • 内存中的各段的位置和权限

  • 初始的 PC 在 ELF Header 的 entry

  • 寄存器和栈由操作系统决定

    • 上节课的 env.c

这个数据结构的文档在就是 ABI 的手册。

我们用 readelf 来解析二进制文件,可以看到许多状态机的初始信息。

状态机有初始状态,状态迁移就是代码,状态机一旦启动,完全只依赖于初始状态和系统调用。后面所有的状态转换都已经决定好了,即,一个程序如果没有数据输入,那么不管执行多少遍,最后的结果都是一样的。

binutils 中的工具能让我们看到状态机里的信息。一个数据结构,人眼看是不友善的,对机器可读,但是对人基本上是完全可不读的,但是信息都蕴含在里面。现在我们有了一个更有力的工具 chatgpt。

有没有一种工具,把手册调整成一种容易看的样子呢?

手册读不懂是因为有些概念不知道。

这部分的知识和计算机系统基础有重叠,关于链接一章,可执行文件的生成。

现在的 ELF,不是一个人类友好的 “状态机数据结构描述”,为了性能彻底违背了可读原则。

现在的 dore dump 是个 ELF 文件。

2. 自己设计一个可执行文件

即设计一个数据结构,对人直接可读,直接面对链接和加载中的核心概念,在 linux 上可以运行。

  • 代码:一个二进制文件最核心的就是要有代码和数据

  • 符号:多个文件的链接

  • 重定位

代码 (🔢)、符号 (📤)、重定位 (❓),让人类更好读。

一个 ELF

🔢: ff ff ff ff ff ff ff
🔢: ff ff ff ff ff ff ff
📤: _start
🔢: 48 c7 c0 3c 00 00 00
🔢: 48 c7 c7 2a 00 00 00
             ^
             |
          This byte is return code (42).
🔢: 0f 05 ff ff ff ff ff
🔢: ff ff ff ff ff ff ff
❓: i32(unresolved_symbol - 0x4 - 📍)

加载

如何实现一个加载器呢?loader,我们已经有了一段可执行文件,一段代码,我们现在想把这段可执行代码加载到内存里运行。

一个最简单的加载器

// Generated by GPT-4; unmodified

#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
  if (argc != 2) {
    printf("Usage: %s <binary_file>\n", argv[0]);
    return 1;
  }

  // Open the binary file
  int fd = open(argv[1], O_RDONLY);
  if (fd < 0) {
    perror("open");
    return 1;
  }

  // Get the file size
  off_t file_size = lseek(fd, 0, SEEK_END);
  lseek(fd, 0, SEEK_SET);

  // Allocate memory for the binary
  void *mem = mmap(NULL, file_size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE, fd, 0);
  if (mem == MAP_FAILED) {
    perror("mmap");
    close(fd);
    return 1;
  }

  // Close the file
  close(fd);

  // Cast the memory address to a function pointer and call it
  void (*binary_func)() = (void (*)())mem;
  binary_func();

  // Clean up
  munmap(mem, file_size);

  return 0;
}

这是个最简单的加载器,但是操作系统里的加载都是这么个原理,把进程需要的内存搬到地址空间里,然后给正确的权限,然后配置好初始状态,就这样。

3. 加载 elf 文件

实现 ELF Loader

知道了什么是可执行文件,我们要来看看稍微复杂一点的链接和加载。

在操作系统上实现一个 ELF loader。这个会稍稍困难一点。所有我们需要参考的东西都在 System V ABI 里。

  • 加载器 (loader) 的职责

    • 解析数据结构

    • 创建进程初始状态

    • argv, envp, ...

  • 跳转执行

这个 loader 本身是动态链接的,它可以加载一个静态链接的可执行文件到内存里并执行。

只要操作系统可以加载一个指令序列,我们可以把 execve 放在用户层实现,即用 mmap 系统调用实现,所以严格来讲,execve 系统调用在今天的操作系统来讲,完全实现成库函数。

这个例子里我们相当于重写了 execve,即 execve_ ,我们也使用了参数,但是平移了一个,因为第一个参数是加载器本身。

我们直接把静态链接的可执行文件的前 4KB 映射到内存,指针强制转换为 Elf64_Ehdr* 类型,按这个类型去解读内存里的数据,(读elf也是一个比较常用的需求。

读出了 elf 里面前面文件的信息,我们就可以做很多事了,比如程序的 entry point,思路和上面的最简单加载器差不多。

这个数据结构里还有更多的信息,这个数据结构里总有一些地方定义了把那些东西加载到那些位置,。总之,只要把 elf 里标记了 LOAD 的位置搬到内存的位置,当然也有细节上的东西,比如搬动位置对齐问题,看手册就好了。

  • 细节不重要,当我们谈论概念的时候,不需要太多关注细节

  • 细节又很重要,差一点点程序都运行不起来

到底重不重要取决于我们在干什么,学习原理的时候,不用太关注细节,当实际实现一个东西,写代码的时候,有需要关注手册和细节。

算好各种地址,然后 mmap 就可以了。

把所有的数据结构里的东西搬动到内存里几乎就差不多了,此外还要准备好栈的东西,这个也是看手册来解决。直接在 a.out 里定义栈也没问题。

这段代码实际上就是手册的体现。

手册难读。有大量的前置概念。初学者并不是跟着技术发展一路过来的,一个新手第一次拿到了手册,就会非常痛苦。

我们需要在一个简化的系统上,把基本概念搞清楚,然后就可以再回来看手册,就会知道手册上那些是最重要的,那些可以跳过,然后再顺着最重要的,慢慢往外看。往前后左右去看,随着积累越来越多,就可以慢慢看下来了。

Boot Block Loader,

操作系统也是个程序,如何实现加载操作系统内核的加载器呢?操作系统内核也是个 ELF 可执行文件,因此差不多,也是解析数据结构,复制到内存,然后跳转。做的事情与动态加载器完全一样,但是这时候没有 mmap 系统调用可以用,但是可以用 IO 指令直接把数据从磁盘搬动到内存,实现起来还更容易一点。

ELF 文件如何生成

现在知道什么是一个 ELF 文件了,编译器生成了 ELF 文件。事实上,这是编译器一个字节一个字节“写出来”的。

这也有很好玩的问题,最初小程序只有一个文件,指令在编译时就能确定。程序变大,会存在链接过程,因此有些指令的跳转地址是无法确定的。

call 和 jmp 后面跟个相对地址,一个问题,offset 0 跳转到哪里了。直接执行下一条指令了,顺序执行,不会卡死在这里。

ICS 课的链接和加载,

ELF 不是一个好的“描述状态机数据结构”的格式。以至于 readelf 做了一个翻译之后还是不怎么友好。基本上约等于直接去读一个内存数据结构的 core dump。

动态链接和加载

为什么需要动态链接?

拆解应用程序的需求

拆解应用程序,实现运行库和应用代码分离。

静态链接和静态链接出来的二进制文件大小是不一样的。动态链接不仅仅省磁盘上的空间,也省内存里的空间。

此外,还有重要应用,补丁和升级。运行库出现漏洞,可以方便的升级,如果都是静态链接,那升级就需要重新编译。

任何程序都不会只有一个依赖库,因为总会有些需求是别人需要过一万次的,这时候就一定有一个库来做这个事情。

linux 里有很多系统工具都是动态链接的,这样系统里可以只有一份 libc。库升级,保持接口的向后兼容,补丁发布后不再需要重编译所有依赖的应用。

所以现实的一个事情是任何的一个软件生态系统里都有依赖性。相应的社区的解决方法是 [Semantic Versioning](https://semver.org/lang/zh-CN/),所有的软件都有版本,版本号是有讲究的。

主版本号,允许删掉 API,不做 API 上的兼容。次版本号,只增不减,向后兼容来新增功能。修订号则是向下兼容的问题修正。

我们把 library 和 application 分开,这对打安全补丁是一个非常好的特性。事实上,“向前兼容”并不是一个明确定义的一个东西,软件的行为确定了,任何后一版本上软件行为的不同都叫不兼容。这是个微妙的定义。涉及到软件生态系统。

此外,大型项目的内部也可以内部分解。编译一部分,不需要重新链接。

设计一个二进制文件格式

ELF 是个数据结构,当我们对着一个数据结构的内存表示学习时,是不太容易的。我们应该从数据结构的角度出发。

同样的,对于动态链接,我们应该把这个数据结构设计成什么样,使得支持动态链接。然后再找性能缺陷去改进。

如果编译器、链接器、加载器都可以控制

  • 如何设计、实现一个 “最直观” 的动态链接格式?

    • 再去考虑怎么改进它,就能得到 ELF!

  • 假设编译器可以生成位置无关代码 (PIC)

假设编译器可以生成这样的二进制文件

DL_HEAD

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

DL_CODE

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

DL_END

即,有一个 main.c 经过编译器生成了 main.S,即这段程序

#include "dl.h"

DL_HEAD

LOAD("libc.dl")
LOAD("libhello.dl")
IMPORT(hello)
EXPORT(main)

DL_CODE

main:
  call DSYM(hello)
  call DSYM(hello)
  call DSYM(hello)
  call DSYM(hello)
  movq $0, %rax
  ret

DL_END

这里会调用一个外部函数,hello,但是 hello 的地址是不知道的,在链接的时候都不知道 hello 的地址,也不知道 hello 的实现。只知道在程序运行时,hello 的代码才确定,有个指针会指着 hello。

这个时候,我们需要去考虑的一个设计:在编译器把 .c 翻译成 .S 的时候,该如何去翻译呢?这个 .S 也是一个数据结构,里面应该要有一个表,现在是空的,运行的时候要填上,表里存放 hello 的入口地址。

这样编译器可以把这样一个 hello() 的调用翻译成一个间接查表的跳转即 call(hello_addr),这就初步解决了动态调用的问题。此外我们还需要加载一些动态库。

再看 libhello

#include "dl.h"

DL_HEAD

LOAD("libc.dl")
IMPORT(putchar)
EXPORT(hello)

DL_CODE

hello:
  lea str(%rip), %rdi
  mov count(%rip), %eax
  push %rbx
  mov %rdi, %rbx
  inc %eax
  mov %eax, count(%rip)
  add $0x30, %eax
  movb %al, 0x6(%rdi)
loop:
  movsbl (%rbx),%edi
  test %dil,%dil
  je out
  call DSYM(putchar)
  inc  %rbx
  jmp loop
out:
  pop %rbx
  ret

str:
  .asciz "Hello X\n"

count:
  .int 0

DL_END

这个库需要一个 libc 的动态链接库,需要一个 putchar 函数,也没有,因此也被翻译成一个动态的 call DSYM(putchar)

以上是自己设计的一个数据结构。

如何实现 DSYM() 呢?,需要在可执行文件这个数据结构里放这么一张表。

#define REC_SZ 32
#define DL_MAGIC "\x01\x14\x05\x14"

#ifdef __ASSEMBLER__
  #define DL_HEAD     __hdr: \
                      /* magic */    .ascii DL_MAGIC; \
                      /* file_sz */  .4byte (__end - __hdr); \
                      /* code_off */ .4byte (__code - __hdr)
  #define DL_CODE     .fill REC_SZ - 1, 1, 0; \
                      .align REC_SZ, 0; \
                      __code:
  #define DL_END      __end:

  #define RECORD(sym, off, name) \
    .align REC_SZ, 0; \
    sym .8byte (off); .ascii name

  #define IMPORT(sym) RECORD(sym:,           0, "?" #sym "\0")
  #define EXPORT(sym) RECORD(    , sym - __hdr, "#" #sym "\0")
  #define LOAD(lib)   RECORD(    ,           0, "+" lib  "\0")
  #define DSYM(sym)   *sym(%rip)
#else
  #include <stdint.h>

  struct dl_hdr {
    char magic[4];
    uint32_t file_sz, code_off;
  };

  struct symbol {
    int64_t offset;
    char type, name[REC_SZ - sizeof(int64_t) - 1];
  };
#endif