程序的编译、链接、装载与运行

在 Linux 操作系统中,一段 C 程序从被写下到最终被 CPU 执行,要经过一段漫长而又复杂的过程。下图展示了这个过程

目录

  1. 编译
  2. 目标文件的格式
  3. 链接
  4. 装载
  5. 运行

1. 编译

编译就是把程序员所写的高级语言代码转化为对应的目标文件的过程。一般来说高级语言的编译要经过预处理、编译和汇编这几个过程。

预处理

预编译过程对源代码做了如下的操作

  • 删除所有的注释信息
  • 删除所有的 #define 并展开所有宏定义
  • 插入所有 #include 文件注 1 的内容到源文件中的对应位置,include 过程是递归执行的

gcc 可以使用如下命令对 C 语言进行预编译并且把预编译的结果输出到 hello.i 文件中

gcc -E hello.c -o hello.i

编译

编译就是对预处理之后的文件进行词法分析、语法分析、语义分析并优化后生成相应的汇编文件。我们使用如下命令来编译预处理之后的文件

gcc -S hello.i -o hello.s

或者我们也可以把预处理和编译合为一步

gcc -S hello.c -o hello.s

汇编

汇编的目的是把汇编代码转化为机器指令,因为几乎每一条汇编指令都对应着一条机器指令,所以汇编的过程相对而言非常的简单。我们可以使用如下命令实现汇编

gcc -c hello.s -o hello.o

或者我们也可以直接把源代码文件编译为目标文件

gcc -c hello.c -o hello.o

汇编操作所生成的文件叫做目标文件(Object File),目标文件的结构与可执行文件是一致的,它们之间只存在着一些细微的差异。目标文件是无法被执行的,它还需要经过链接这一步操作,目标文件被链接之后才可以产生可执行文件。

下面我们了解一下目标文件的格式以及链接这一步具体做了哪些工作。

2. 目标文件的格式

Linux 下的目标文件格式叫做 ELF(Executable Linkable Format),ELF 的格式如下图所示:

ELF header 是 ELF 文件中最重要的一部分,header 中保存了如下的内容

  • ELF 的 magic number
  • 文件机器字节长度
  • ELF 版本
  • 操作系统平台
  • 硬件平台
  • 程序的入口地址
  • 段表的位置和长度
  • 段的数量

从 header 中我们可以得到很多有用的信息,其中的一个尤其重要,那就是段表的位置和长度,通过这一信息我们可以从 ELF 文件中获取到段表(Section Hedaer Table),在 ELF 文件中段表的重要性仅次于 header。

段表保存了 ELF 文件中所有的段的基本属性,包括每个段的段名、段在 ELF 文件中的偏移、段的长度以及段的读写权限等等,段表决定了整个 ELF 文件的结构。

既然段表决定了所有的段的属性,那么 ELF 文件中的段究竟是个什么东西呢?其实段只是对 ELF 文件内的不同类型的数据的一种分类。例如,我们把所有的代码(指令)放到一个段中,并且给这个段起名.text;把所有的已经初始化的数据放在.data 段;把所有的未初始化的数据放在.bss 段;把所有的只读数据放在.rodata 段,等等。

至于为什么要把数据(指令在 ELF 文件中也算是一种数据,它是 ELF 文件的数据之一)分为不同的类型,除了方便进行区分之外,还有以下几个原因

  • 便于给段设置读写权限,有些段只需要设置只读权限即可
  • 方便 CPU 缓存的生效
  • 有利于节省内存,例如程序有多个副本情况下,此时只需要一份代码段即可

既然分段有着诸多的好处,那么接下来我们就近距离的看一看 ELF 文件中的段信息。有如下的示例文件 hello.c

int printf(const char *format, ...);

int global_init_var = 84;
int global_uninit_var;

void func1(int i)
{
    printf("%d\n", i);
}

int main(void)
{
    static int static_var = 85;
    static int static_var2;

    int a = 1;
    int b;

    func1(static_var + static_var + a + b);

    return a;
}

使用如下命令把源代码编译成目标文件

gcc -c hello.c -o hello.o

接下来我们可以使用 objdump 命令查看 ELF 文件的内部结构,-h 表示显示 ELF 文件的头部信息

objdump -h hello.o

得到结果如下

hello.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
0 .text         00000055  0000000000000000  0000000000000000  00000040  2**0
                CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data         00000008  0000000000000000  0000000000000000  00000098  2**2
                CONTENTS, ALLOC, LOAD, DATA
2 .bss          00000004  0000000000000000  0000000000000000  000000a0  2**2
                ALLOC
3 .rodata       00000004  0000000000000000  0000000000000000  000000a0  2**0
                CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment      00000036  0000000000000000  0000000000000000  000000a4  2**0
                CONTENTS, READONLY
5 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000da  2**0
                CONTENTS, READONLY
6 .eh_frame     00000058  0000000000000000  0000000000000000  000000e0  2**3
                CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

可以看到上面的结果中显示了 7 个段,每个段都有一些属性信息,下面我们了解一下这些属性的含义

  • Size:段的大小
  • VMA:段的虚拟地址,因为目标文件尚未执行链接操作,所以虚拟地址为 0
  • LMA:段被加载的地址,同上原因为 0
  • File off:段在 ELF 文件中的偏移地址
  • CONTENTS:表示此段存在于 ELF 文件中

我们重点关注.text.data.bss 和.rodata 这几个段:

  • .text 段保存了程序中的所有指令信息,objdump 的 -s 参数表示将段的内容以十六进制的方式打印出来,而 -d 参数则会对所有包含指令的段进行反汇编,因此使用如下命令就可以获取代码段的详细信息
    objdump -s -d hello.o
    
  • .data 段保存已初始化的全局变量和局部静态变量
  • .bass 段保存未初始化的全局变量和局部静态变量注 3
  • .rodata 段保存只读数据,例如字符串常量、被 const 修饰的变量

ELF 还包含了很多其它类型的段,感兴趣的话可以查阅相关资料做进一步的了解。

3. (静态)链接

因为现在机器的内存和磁盘空间已经足够大,而动态链接对于内存和磁盘的节省十分有限,所以我们已经可以忽略动态链接带来的在节省使用空间上的优势。相反因为没有了动态链接库的依赖,不需要考虑动态链接库的不同的版本,静态链接的文件可以做到链接即可执行,减少了运维和部署上的复杂度,是非常的方便的,在有些新发明的语言(例如 golang)中链接过程默认已经开始使用静态链接。

静态链接过程分为两步

  1. 扫描所有的目标文件,获取它们的每个段的长度、位置和属性,并将每个目标文件中的符号表的符号定义和符号引用收集起来放在一个全局符号表中,建立起可执行文件到目标文件的段映射关系
  2. 读取目标文件中的段数据,并且解析符号表信息,根据符号表信息进行重定位、调整代码中的地址等操作

我们有如下的 a.c 和 b.c 两个源文件

// a.c
extern int shared;

int main()
{
    int a = 100;
    swap(&a, &shared);
}
// b.c
int shared = 1;

void swap(int *a, int *b)
{
    *a ^= *b ^= *a ^= *b;
}

编译源代码得到目标文件 a.o 和 b.o

gcc -c a.c b.c -zexecstack -fno-stack-protector -g

链接 a.o 和 b.o 目标文件得到可执行文件

ld a.o b.o -e main -o ab

在 ELF 文件中有两个叫做重定位表符号表的段我们之前没有介绍,它们对于链接过程起着及其重要的作用,接下来我们详细了解一下这两个段

重定位表

可以简单的认为是编译器把所有需要被重定位的数据存放在重定位表中,这样链接器就能够知道该目标文件中哪些数据是需要被重定位的。

我们可以使用 objdump -r a.o 来获取重定位表的信息

...
RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE
0000000000000014 R_X86_64_32       shared
0000000000000021 R_X86_64_PC32     swap-0x0000000000000004
...

我们也可以使用 readelf -S a.o 命令来详细的了解一个 ELF 文件

...
[ 1] .text             PROGBITS         0000000000000000  00000040
    000000000000002c  0000000000000000  AX       0     0     1
[ 2] .rela.text        RELA             0000000000000000  00000430
    0000000000000030  0000000000000018   I      18     1     8
...

其中以.rela 开头的就是重定位段,上面的.rela.text 就存放了需要被重定位的指令的信息,同样的如果是需要被重定位的数据则段名应该叫做.rela.data

上面的操作都是针对目标文件 a.o 进行的,我们对目标文件 b.o 执行以上命令可以发现其既不存在数据段的重定位表,也不存在代码段的重定位表。这是因为 b.c 中的变量 shared 和函数 swap 都已经明确的知道了其地址,所以不需要重定位。

而 a.c 中则不一样,因为在 a.c 中变量 shared 和函数 swap 都没有定义在当前的文件中,因此编译后产生的目标文件中不存在它们的地址信息,所以编译器需要把它们放在重定位表中,等到链接时再到其它目标文件中找到对应的符号信息之后对其进行重定位。

符号表(.symtab)

目标文件中的某些部分是需要在链接的时候被使用到的 “粘合剂”,这些部分我们可以把其称之为 “符号”,符号就保存在符号表中。符号表中保存的符号很多,其中最重要的就是定义在本目标文件中的可以被其它目标文件引用的符号和在本目标文件中引用的全局符号,这两个符号呈现互补的关系。

使用命令 readelf -s 可以查看符号表的内容

$ readelf -s a.o
...
 8: 0000000000000000    79 FUNC    GLOBAL DEFAULT    1 main
 9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
...

$ readelf -s b.o
...
8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    2 shared
9: 0000000000000000    75 FUNC    GLOBAL DEFAULT    1 swap
...

$ readelf -s ab
...
10: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS a.c
11: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS b.c
12: 0000000000400114    45 FUNC    GLOBAL DEFAULT    1 swap
13: 00000000006001a0     4 OBJECT  GLOBAL DEFAULT    3 shared
14: 00000000006001a4     0 NOTYPE  GLOBAL DEFAULT    3 __bss_start
15: 00000000004000e8    44 FUNC    GLOBAL DEFAULT    1 main
16: 00000000006001a4     0 NOTYPE  GLOBAL DEFAULT    3 _edata
17: 00000000006001a8     0 NOTYPE  GLOBAL DEFAULT    3 _end
...
  • 第一列是符号表数组中的坐标
  • 第二列是符号值
  • 第三列是 size
  • 第四列是符号类型
  • 第五列是绑定信息
  • 最后一列是符号的名称

命令 nm 也可实现对符号的查看操作

$ nm a.o
                 U __stack_chk_fail
0000000000000000 T main
                 U shared
                 U swap

$ nm b.o
0000000000000000 D shared
0000000000000000 T swap

$ nm ab
00000000006001a4 D __bss_start
00000000006001a4 D _edata
00000000006001a8 D _end
00000000004000e8 T main
00000000006001a0 D shared
0000000000400114 T swap

其中 D 代表该符号是已经初始化的变量,T 表示该符号是指令,U 代表该符号尚未定义。

从上面的结果我们可以看到,链接过程确实是对目标文件的符号做了 “粘合” 操作。

问:重定位表和符号表之间是什么关系?

答:它们之间是相互合作的关系,链接器首先要根据重定位表找到该目标文件中需要被重定位的符号,之后再根据符号表去其它的目标文件中找到可以相匹配的符号,最后对本目标文件中的符号进行重定位。

从上面的过程中我们可以看到链接器最终需要完成的工作有三个

  1. 合并不同目标文件中的同类型的段
  2. 对于目标文件中的符号引用,在其它的目标文件中找到可以引用的符号
  3. 对目标文件中的变量地址进行重定位

静态库的链接

操作系统一般都附带有一些库文件,Linux 最有名的就是 libc 静态库,其一般位于 /usr/lib/libc.a,libc.a 其实是个压缩文件,里面包含了 printf.o,scanf.o,malloc.o,read.o 等等的库文件。当使用到标准库中的内容时,链接器会对用户目标文件和标准库进行链接,得到最终的可执行文件。

链接过程的控制

链接默认情况下生成的是一个 ELF 文件,这在 Linux 操作系统上是符合我们的要求的。但是我们有的时候想要其它的目标文件格式,甚至我们有时候想自己写操作系统内核,此时 ELF 文件的格式就显然不能满足我们的要求了。事实上我们可以通过一些命令行参数或者直接使用配置文件的方式来控制链接的过程以及链接产生的结果,详细内容可以参考命令 ld 的相关文档,这里不再做介绍。

4. 装载

在上一节我们已经通过链接得到了可执行文件,在可执行文件中包含了很多的段(section),但是一旦这些段被加载到内存中之后,我们就不在乎他到底是什么类型的数据,而只在乎这份数据在内存中的读写权限。所以可执行文件被加载到内存中的数据可以分为两类:可读不可写和可读可写。

由于现代操作系统均采用分页的方式来管理内存,所以操作系统只需要读取可执行文件的文件头,之后建立起可执行文件到虚拟内存注 5 的映射关系,而不需要真正的将程序载入内存。在程序的运行过程中,CPU 发现有些内存页在物理内存中并不存在并因此触发缺页异常,此时 CPU 将控制权限转交给操作系统的异常处理函数,操作系统负责将此内存页的数据从磁盘上读取到物理内存中。数据读取完毕之后,操作系统让 CPU jmp 到触发了缺页异常的那条指令处继续执行,此时指令执行就不会再有缺页异常了。

忽略物理内存地址以及缺页异常的影响,一旦操作系统创建进程 (fork) 并载入了可执行文件 (exec),那么虚拟内存的分布应该如下图所示

可以看到在 ELF 文件中的多个 section 在内存中被合并为 3 个 segment

Segment nameData type
1BSS segment保存未初始化的数据
2Data segment保存已经初始化的数据
3Text segment保存程序的指令

上面的图片中除了三个保存了 ELF 文件的数据的 segment 之外,还有如下的几个部分

名称描述
Kernel space操作系统的内核空间,保存操作系统内核的数据,用户进程无权访问该地址
Stack(栈)用于实现程序中的函数调用,在下一节的程序运行中我们会详细了解栈的工作方式
Heap(堆)为了保存在程序运行时(而非编译时)产生的全局变量注 6
Memory Map磁盘空间到内存的映射,可以像操作内存中的数据一样操作磁盘中的数据

5. 运行

开始执行

操作系统 jmp 到进程的第一条指令并不是 main 方法,而是别的代码。这些代码负责初始化 main 方法执行所需要的环境并调用 main 方法执行,运行这些代码的函数被称为入口函数或者入口点(Entry Point)。

一个程序的执行过程如下:

  1. 操作系统在创建进程之后,jmp 到这个进程的入口函数
  2. 入口函数对程序运行环境进行初始化,包括堆、I/O、线程、全局变量的构造,等等
  3. 入口函数在完成初始化之后,调用 main 函数,开始执行程序的主体
  4. main 函数执行完毕之后返回到入口函数,入口函数进行清理工作,最后通过系统调用结束进程

函数调用

栈用于维护函数调用的上下文,函数调用是通过栈完成的。

栈本身是一个容器,它的特性是 FILO。通过上面的 Linux 内存分布图我们可以知道,内存中的栈是向下增长的。在 x86 中 esp 寄存器用于保存当前进程的栈顶的地址,push 元素到栈中,esp 中的值减小;从栈中 pop 元素,esp 中的值增大。

栈为每一个函数调用维护了其所需要的一些信息,为每个函数所维护的信息部分叫做栈帧(Stack Frame),栈被分割为很多个栈帧。每一个栈帧保存了一个函数的如下信息

  • 函数的参数和返回地址
  • 临时变量,包括非静态局部变量和编译生成的其它临时变量
  • 保存的上下文

一个函数被调用时将会有如下操作

  1. 把所有的参数压入栈中,有些参数也可以不压栈而通过寄存器进行传递
  2. 把当前指令的下一条指令的地址压入栈中
  3. 跳转到函数体执行

当一个函数被调用完毕之后,esp 减小到上面的步骤 2 中的数据的位置,从栈中 pop 该指令地址,jmp 到该指令继续执行。

堆(Heap)与内存管理

堆是一块巨大的内存,程序可以在堆中申请内存,这些内存在被程序主动放弃之前都可以随意使用。上图中黄色部分的堆我们在这里把它称为传统堆内存,Linux 的堆内存由传统堆和 Memory Map Segment 共同组成。

Linux 下的 brk() 和 mmap() 系统调用都可以用于申请堆内存,它们获取堆内存的方式分别如下

  • brk 是将 program brk 向高地址推,以此来获取新的传统堆内存空间
  • mmap 是在 Memory Map Segment 中找一块空闲的内存空间

但是我们一般不会直接使用系统调用,而是使用库函数来申请堆内存,我们一般使用 glibc 中的 malloc 函数来申请内存,它会根据申请内存的大小的不同而使用不同的实现

如果申请的内存小于 128k

  1. 首先查看传统堆内存中是否有足够的空闲内存,如果有足够的空闲内存则直接分配给用户程序而不需要经过系统调用
  2. 如果传统堆中空闲内存不足则调用 brk 系统调用增大传统堆以获取新的内存分配给用户程序
  3. 如果被 free 的内存位于 program brk 处,则调用 brk 系统调用减小堆大小
  4. 如果被 free 的内存位于传统堆内部,则库函数记录下被释放空间的位置和大小,之后再有新的内存申请时可以优先的从传统堆的空闲内存中分配空间,而不需要再次调用 brk 系统调用

如果申请的内存大于 128k

  1. 使用 mmap 系统调用申请内存
  2. mmap 所申请内存的释放使用 munmap 系统调用来实现

系统调用

操作系统负责实现对计算机系统资源的管理,用户程序无权直接使用系统资源。用户程序想要使用系统资源就必须调用操作系统所提供的接口,操作系统提供的接口叫做系统调用

x86 CPU 提供了 4 个特权级,Linux 用到了其中的两个特权级,在 Linux 中分别叫内核态和用户态,内核态的特权级比用户态高。操作系统的内核(上图中最高位的 kernel space)运行在内核态,用户程序无权访问内核态的数据,用户程序想要调用内核中的函数就必须要使用系统调用。

x86 下使用中断(interrupt)来发送信息给 CPU,一旦 CPU 收到了中断信息,就会停止执行当前任务转而根据中断编号去执行中断处理函数。中断处理函数由操作系统实现,一般来说每个中断编号都有自己的中断处理函数,这些中断处理函数组成了一个中断向量表,中断向量表由操作系统负责实现并管理。中断可以是由硬件产生的,例如键盘按下、鼠标点击等等;中断也可以由软件产生,x86 下 0x80 中断就是由软件触发的,0x80 中断是实现系统调用的核心。

用户程序调用系统调用的过程如下:

  1. 用户程序先根据调用惯例注 7 把中断处理函数所需要的参数保存在指定的寄存器中,例如 eax 寄存器就应该保存系统调用的编号,eax = 1 对应系统调用 exiteax = 2 对应 fork,等等
  2. 参数设置完毕之后,用户程序执行 int 0x80 指令,CPU 收到中断信息
  3. CPU 将控制权限交给操作系统内核,进程的栈从用户栈切换到内核栈注 8
  4. 中断向量表中 0x80 号中断的中断处理函数开始执行
  5. 中断处理函数从寄存器 eax 中获取到系统调用编号,根据系统调用编号找到指定的系统调用函数
  6. 系统调用函数从约定好的寄存器中获取所需参数,系统调用函数根据参数开始执行
  7. 系统调用执行完毕后,将系统调用的结果存放在用户程序有权访问的区域(寄存器或内存)
  8. 系统调用返回,将控制权重新交给用户程序
  9. 用户程序从指定区域获取系统调用的结果,系统调用结束

用户写 C 语言时并不会手动的调用系统调用,它们一般都被封装在库函数中。例如 printf 函数就是对系统调用 write 的封装,下面我们就手动的调用 write 系统调用来实现向标准输出打印字符的功能。

相较于 gcc 支持的 AT&T 和 Intel 格式的汇编,我更喜欢 NASM 汇编的语法,下面是使用 NASM 实现的向标准输出打印字符串的汇编代码

global _start   ; _start是一个符号(.symbol),链接器会把其作为entry point

; 数据段
section .data
    buffer db 'hello, system call', 10, 0   ; buffer,10是换行符,0是字符串结束
    length db 20                            ; buffer的长度

; 代码段
section .text
    _start:
        mov eax, 4          ; 4,write系统调用
        mov ebx, 1          ; fd(文件描述符),1为标准输出
        mov ecx, buffer     ; buffer的地址
        mov edx, [length]   ; 根据地址从数据段获取buffer的长度
        int 0x80            ; system call

        mov eax, 1          ; 1,exit系统调用
        mov ebx, 0          ; exit code
        int 0x80            ; system call

把汇编代码保存为 print.asm 文件,之后执行以下命令执行打印操作

$ nasm -f elf64 print.asm -o print.o
$ ld print.o -o print
$ ./print
hello, system call

总结

操作系统和编译器之间联系的非常的紧密,ELF 文件就是操作系统和编译器之间的一个纽带。除了操作系统和编译器之间的关系很紧密,操作系统和编译器与 CPU 和内存的关系也是十分的紧密:操作系统要负责内存的管理,而我们的程序的很大一部分操作也是与内存相关;至于 CPU 我们不仅要通过中断才能实现系统调用,操作系统本身也需要 CPU 的特权级来实现对内核的保护。

回顾历史我们就会发现,C 语言就是为了 Unix 而被发明的,它们之间在发展的过程中也不断的互补与完善,这才有了我们今天所看到的联系的十分紧密的类 Unix 操作系统和 C 语言编译器。

注:

  1. include 文件有如下两种语法
    1. #include <filename.h>:编译器会优先到一些默认的文件夹注 2 中去寻找该头文件,如果未能找到则在当前目录下继续查找
    2. #include "filename.h":编译器优先在当前目录下查找头文件,找不到再去默认的文件夹中进行查找
  2. 一般来说 /usr/include 或 /usr/local/include 会被当做默认文件夹,在 gcc 中你也可以使用 -I $include_path 来指定被 include 文件的目录
  3. static 对于全局变量只影响其可见性(默认其它文件可见,加了 static 就只有当前文件可见;function 也是一样),对于局部变量只影响其保存区域。则我们可以得到如下的规则
    • 未初始化的全局变量:.bss
    • 已初始化的全局变量:.data
    • 未初始化的局部静态变量:.bss
    • 已初始化的局部静态变量:.data
    • 未初始化的局部普通变量:stack
    • 已初始化的局部普通变量:stack
  4. 要查看一个可执行文件所引用的动态链接库可以使用命令 ldd
  5. 虚拟地址通过 MMU 的映射转化为物理地址,操作系统负责 MMU 的初始化,用户进程使用的都是虚拟地址。

    如果把内存比作一个旅店,旅店共有 100 个房间,而操作系统就是旅店的老板。旅店不断地有旅行团来旅店住宿,旅店老板对每一个旅行团都宣称我们有 100 个房间,其中 10 个是员工宿舍,所以每个旅行团都有 90 个房间可以用。

    旅行团的成员无法自己找到房间,必须要使用旅店提供的地图才能找到对应的房间,但其实每个旅行团手中的地图都是不一样的,这个地图保证客人绝对不会找到一个已经被别人使用的房间。一旦客人在房间已经睡着了,旅店老板就可能会把这个客人偷偷地运到旅店的一个隐蔽的仓库中去,这样这个房间又被空了出来,旅店老板可以继续把这个房间租给别人。(真是奸商啊)

    一旦旅行团中有人开始找某个旅客,但是这个旅客已经被移到了仓库中,旅店老板就会赶紧把这个人从仓库中移回到某个房间中,然后改变地图,让同一个旅行团的人能成功的在房间中找到这个旅客。

  6. 为什么要有堆?为了保存程序在运行时产生的全局变量
    • 数据段:只能保存在编译时产生的变量
    • 栈:只能在当前方法内部保存变量
  7. 系统调用的调用惯例和函数调用有些类似,但是系统调用使用寄存器而不是栈作为参数传递的载体
  8. 因为系统调用本质上也是函数,在 x86 下既然是函数就需要用到栈。不过系统调用作为内核中的函数,为了防止用户程序访问,不应该使用用户空间的栈。因此我们需要在内核中也建立一个栈,这个栈专门用于系统调用函数的执行。每个进程都有一套栈,即用户栈和内核栈,在系统调用函数执行之前首先需要做用户栈到内核栈的切换,栈的切换很简单,只需要修改 esp 的值即可。

参考:

程序员的自我修养
https://github.com/1184893257/simplelinux
高级语言的编译:链接及装载过程介绍
golang 语言编译的二进制可执行文件为什么比 C 语言大
Linux 内存分配的原理–malloc/brk/mmap

原文链接: http://www.nosuchfield.com/2018/11/23/Program-compilation-linking-loading-and-running/

  • 0
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
一、前言 其他资源: web报表工具 http://download.csdn.net/source/2881508 1.1 什么是Jocky? 我们知道,Java是一种跨平台的编程语言,其源码(.java文件)被编译成与平台无关的字节码(.class文件),然后在运行期动态链接。这样,编译后的类文件中将包含有符号表,从而使得Java程序很容易被反编译。相信每一个Java开发人员,都曾经用过诸如Jad之类的反编译器,对Java的class 文件进行反编译,从而观察程序的结构与实现细节。如此一来,对于那些需要严格进行知识产权保护的Java应用,如何有效的保护客户的商业投资,是开发人员经常需要面对的问题。 于是就出现了Java混淆编译器,它的作用是打乱class文件中的符号信息,从而使反向工程变得非常困难。 Jocky就是这样一款优秀的Java混淆编译器。 1.2 为什么需要Jocky? 目前业界有不少商业的甚或是开源的混淆编译器,但它们普遍存在一些这样或者那样的问题。一般而言,现有的混淆器都是对编译好的 class文件进行混淆,这样就需要编译和混淆两个步骤。而事实上,并不是所有的符号都需要混淆。如果你开发的是一个类库,或者某些类需要动态装载,那些公共API(或者说:那些被publish出来的API)就必须保留符号不变,只有这样,别人才能使用你的类库。现有的混淆器提供了GUI或脚本的方式来对那些需要保留的符号名称进行配置,但如果程序较大时,配置工作将变得很复杂,而程序一旦修改,配置工作又要重新进行。某些混淆器能够调整字节码的顺序,使反编译更加困难,但笔者经历过混淆之后的程序运行出错的情况。 而Jocky与其它混淆编译器最大的不同之处在于:它是直接从源码上做文章,也就是说编译过程本身就是一个混淆过程。 1.3 Jocky是如何工作的? Jocky混淆编译器是在Sun JDK中提供的Java编译器(javac)的基础上完成的,修改了其中的代码生成过程,对编译器生成的中间代码进行混淆,最后再生成class文件,这样编译和混淆只需要一个步骤就可以完成。另外可以在源程序中插入 符号保留指令 来控制哪些符号需要保留,将混淆过程与开发过程融合在一起,不需要单独的配置。 1.4 Jocky的作用 1.4.1代码混淆 如前文所述,混淆编译是Jocky的首要用途。我们举一个最简单的例子,下面的SimpleBean是未经混淆的class文件通过Jad反编译以后获得的源文件: public class SimpleBean implements Serializable { private String name = "myname"; private List myList = null; public void SimpleBean() { myList = new ArrayList(10); } public void foo1() { myList.add("name"); } private void foo2() { } private void writeObject(java.io.ObjectOutputStream out) throws IOException { } } 下面是经Jocky混淆过的类文件,通过Jad反编译后产生的源文件: public class SimpleBean implements Serializable { private String _$2; private List _$1; public SimpleBean() { _$2 = "myname"; this; JVM INSTR new #4 ; JVM INSTR dup ; JVM INSTR swap ; 10; ArrayList(); _$1; } public void foo1() { _$1.add("name"); } private void _$1() { } private void writeObject(ObjectOutputStream objectoutputstream){ throws IOException { } } <Jock
程序员的自我修养:链接,装载与库》是一本由林锐、郭晓东、郑蕾等人合著的计算机技术书籍,在该书中,作者从程序员的视角出发,对链接装载与库等概念进行了深入的阐述和解析。 在计算机编程中,链接是指将各个源文件中的代码模块组合成一个可执行的程序的过程。链接可以分为静态链接和动态链接两种方式。静态链接是在编译时将所有代码模块合并成一个独立的可执行文件,而动态链接是在运行时根据需要加载相应的代码模块。 装载是指将一个程序从磁盘上加载到内存中准备执行的过程。在装载过程中,操作系统会为程序分配内存空间,并将程序中的各个模块加载到相应的内存地址上。装载过程中还包括解析模块之间的引用关系,以及进行地址重定位等操作。 库是指一组可重用的代码模块,通过链接装载的方式被程序调用。库可以分为静态库和动态库。静态库是在编译时将库的代码链接程序中,使程序与库的代码合并为一个可执行文件。动态库则是在运行时通过动态链接的方式加载并调用。 《程序员的自我修养:链接,装载与库》对于理解链接装载和库的原理和机制具有极大的帮助。通过学习这些概念,程序员可以更好地优化代码结构和组织,提高程序的性能和可维护性。同时,了解链接装载和库的工作原理也对于进行调试和故障排除具有重要意义。 总之,链接装载与库是计算机编程中的重要概念,对于程序员来说掌握这些知识是非常必要的。《程序员的自我修养:链接,装载与库》这本书提供了深入浅出的解释和实例,对于想要学习和掌握这些知识的程序员来说是一本非常有价值的参考书籍。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值