ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

库的制作与原理

库的制作与原理

在软件开发中,程序库(Library)是代码复用的基石。无论是 C 语言标准库,还是我们自己封装的业务模块,最终都会以静态库或动态库的形式存在。理解这两种库的工作原理、制作方法以及各自的优劣,是每个系统级开发者的必修课。

一,程序库是什么?

简单来说,程序库就是一组预编译的二进制代码集合,封装了可复用的函数和数据,可以在编译时或运行时被链接到应用程序中。库的存在让我们不必重复造轮子,也方便了模块的独立开发与升级。

在 Linux 系统中,库分为两大类:

静态库(Static Library):后缀为.a,Windows 下为.lib

动态库(Shared Library / Dynamic Library):后缀为.so,Windows 下为.dll

二者最核心的区别在于:代码在什么时候被整合进程序。静态库在编译链接阶段就被 "打包" 进可执行文件;动态库则直到程序运行时才被加载进内存。

准备好之前封装好的mFILE和String.h:

//mFILE.h #pragma once #include<string.h> #include<sys/types.h> #include<sys/stat.h> #include<fcntl.h> #include<unistd.h> #include<stdlib.h> //缓冲区的大小 #define SIZE 25 //默认为无缓冲区 #define NONE_FIUSH 1 //行刷新 #define LINE_FLUSH 2 //全刷新 #define FULL_FLUSH 4 struct _IO_mFILE { int _fileno; //文件描述符 int _flush_flag; //刷新方式,默认无 char _buffer[SIZE]; //缓冲区 int _bufferSize; //记录缓冲区的下标,元素个数 }; typedef struct _IO_mFILE mFILE; mFILE* mfopen(const char* filename,const char* mode); //打开文件 int mfputs(const char* st,mFILE* stream); //将st指向的内容,写入到流中 void mfflush(mFILE* stream); //强制刷新 void mfclose(mFILE* stream); //强制刷新并关闭文件
//mFILE.c #include"mFILE.h" mFILE* mfopen(const char* filename,const char* mode) //打开文件 { int fd=-1; if(strcmp(mode,"w") == 0) { fd=open(filename,O_WRONLY|O_CREAT|O_TRUNC,0666); } else if(strcmp(mode,"r") == 0) { fd=open(filename,O_RDONLY); } else if(strcmp(mode,"a") == 0) { fd=open(filename,O_WRONLY|O_CREAT|O_APPEND,0666); } else{} if(fd < 0){ return NULL; } mFILE* pmfile=(mFILE*)malloc(sizeof(mFILE)); if(pmfile == NULL) { close(fd); return NULL; } pmfile->_fileno=fd; pmfile->_flush_flag=LINE_FLUSH; pmfile->_bufferSize=0; return pmfile; } int mfputs(const char* st,mFILE* stream) //将st指向的内容,写入到流中 { size_t len=0; if(st != NULL) { len=strlen(st); //字符串st的长度 const char* start=st; while(*start != '\0') { stream->_buffer[stream->_bufferSize++] = *start; //缓冲区满了,将缓冲区里的元素全部拷贝到内核级缓冲区里 if(stream->_bufferSize == SIZE-1 || stream->_buffer[stream->_bufferSize-1] == '\n') { mfflush(stream); } ++start; } } return len; } void mfflush(mFILE* stream) //强制刷新 { if(stream->_bufferSize > 0) { write(stream->_fileno,stream->_buffer,stream->_bufferSize); stream->_bufferSize=0; } } void mfclose(mFILE* stream) //强制刷新并关闭文件 { mfflush(stream); close(stream->_fileno); if(stream) free(stream); }
//String.h #pragma once unsigned int mystrlen(const char* ch);
//String.c #include"String.h" unsigned int mystrlen(const char* ch) { unsigned int i=0; while(ch[i] != '\0'){ ++i; } return i; }
//test.c #include<stdio.h> #include"mFILE.h" #include"String.h" int main() { mFILE* fp=mfopen("log.txt","w"); if(fp == NULL) { perror("mfopen"); return 1; } const char* str="Hello Warld\n\nMy name is zsw!!!!\n 1234567890"; int ret=mfputs(str,fp); printf("ret = %d\n",ret); printf("len = %d\n",mystrlen(str)); mfclose(fp); return 0; }
#Makefile BIN=mFILE SRC=$(wildcard *.c) RM=rm -rf CCCC=gcc $(BIN):$(SRC) $(CCCC) -o $@ $^ .PHONY:clean clean: $(RM) $(BIN) *.txt

二,静态库

静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。

一个可执行程序可能用到许多的库,这些库有的是静态库,有的是动态库,我们编译默认是动态库,只有在该库下找不到动态库(.so)的时候才会采用同名静态库。可以使用gcc的-static设置链接静态库。

2.1,静态库的制作

STA=libmystdc.a CC=gcc RM=rm -rf $(STA):mFILE.o String.o ar -rc $@ $^ %.o:%.c $(CC) -c $< .PHONY:clean clean: $(RM) $(BIN) $(STA) stdc .PHONY:create create: mkdir -p stdc/include mkdir -p stdc/lib cp -rf *.h stdc/include cp -rf $(STA) stdc/lib

ar是gnu归档工具,rc代表 replace and create

注:不能将main函数所在的源文件归档在一起。

t:列出静态库中的文件

v:verbose 详细信息

2.2,静态库的使用

可以将stdc打包发给friend,别人也可以使用自己制作的库。现在我是friend,应该怎么使用别人的静态库文件呢?

方法一:

安装库到系统路径下。(usr / include) (lib64)

sudo cp mFILE.h String.h /usr/include

sudo cp libmystdc.a /lib64/

拷贝好之后gcc main.c

由于libmystdc.a是自定义库(第三方库),必须要指定库名。-l:指定库名

方法二:

使用gcc的选项:-I(大写 i ):指定头文件搜索路径。

-L:指定库路径。

-l(小写 L):指定库名。

方法三:

软链接。

三,动态库

动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。

一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。

在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中。这个过程称为动态链接。

动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。

3.1,动态库的制作

#libmystdc.so DYNAMIC=libmystdc.so CC=gcc RM=rm -rf $(DYNAMIC):mFILE.o String.o $(CC) -o $@ $^ -shared %.o:%.c $(CC) -fPIC -c $< .PHONY:clean clean: $(RM) $(DYNAMIC) *.o stdc .PHONY:create create: mkdir -p stdc/include mkdir -p stdc/dll cp -rf *.h stdc/include cp -rf $(DYNAMIC) stdc/dll

shared:表示生成共享库格式。

fPIC:产生位置无关码。

3.2,动态库的使用

可以将stdc打包发给friend2,别人也可以使用自己制作的库。现在我是friend2,应该怎么使用别人的动态库文件呢?

方法一:

使用三个选项:

由于动态库是在运行的时候才去链接动态库的代码,运行的时候可执行程序会找不到库(OS加载库,加载器找不到库),还得将其拷贝到lib64。

方法二:

软连接。

方法三:

环境变量:LD_LIBRARY_PATH

由于环境变量是内存级的,所以,用户重新登录之后,程序还是找不到库。

方法四:

ldconfig方案:配置 / etc / ld.so.conf.d / ,ldconfig更新。

1,在 / etc / ld.so.conf.d / 目录下新建一个普通文件,将动态库的绝对路径拷贝到这个新建文件里。

2,ldconfig命令

让整个系统所有的动态库的配置文件全部生效,重新加载库搜索路径。

四,目标文件

可以用gcc -c 选项分别编译两个源文件,在编译之后会生成两个扩展名为 .o 的文件,被称为目标文件(可重定位目标文件)。如果修改了其中一个源文件,只需单独编译修改的这一个,不需要浪费时间编译整个工程。目标文件是一个二进制文件,文件的格式是ELF,是对二进制代码的一种封装。

五,ELF文件

要理解编译链链接的细节,我们不得不了解一下 ELF 文件。其实有以下四种文件其实都是 ELF 文件:(.o文件,可执行文件,动静态库都是二进制文件)。

1,可重定位文件(Relocatable File) :即 xxx.o 文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。

2,可执行文件(Executable File) :即可执行程序。

3,共享目标文件(Shared Object File) :即 xxx.so 文件。

4,内核转储 (core dumps) ,存放当前进程的执行上下文,用于 dump 信号触发。

一个 ELF 文件由以下四部分组成:

1,ELF 头 (ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。

2,程序头表 (Program header table) :列举了所有有效的段 (segments) 和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。

3,节头表 (Section header table) :包含对节 (sections) 的描述。

4,节(Section ):ELF 文件中的基本组成单位,包含了特定类型的数据。ELF 文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。 最常见的节:

代码节(.text):用于保存机器指令,是程序的主要执行部分。

数据节(.data):保存已初始化的全局变量和局部静态变量。

ELF形成可执行:将多份 c/c++ 源代码,翻译成目标文件( .o ) + 动静态库。

将多份 .o 文件Section(节)进行合并。

为什么 .o 可以形成 .so 或者可执行程序?

都是ELF格式,相同类型的地方可以合并。

5.1,通过指令查看可执行文件(ELF文件)的四个部分

1,ELF header(ELF头)

(1),文件标识与基础属性

字段值说明
Magic7f 45 4c 46 02 01 01 00 ...ELF 文件的 “魔数”:前 4 字节7f 45 4c 46是 ELF 格式的固定标识;第 5 字节02表示 64 位;第 6 字节01表示小端字节序;第 7 字节01表示 ELF 版本 1
ClassELF6464 位 ELF 格式,对应 32 位为 ELF32
Data2's complement, little endian数据采用二进制补码、小端字节序存储,是 x86 架构的默认字节序
Version1 (current)ELF 规范的版本号,当前标准为 1
OS/ABIUNIX - System V操作系统二进制接口类型,Linux 默认为 System V 通用 ABI

(2),文件类型与目标架构

字段值说明
TypeEXEC (Executable file)文件类型为普通可执行文件。ELF 常见类型还有:REL(可重定位目标文件 .o)、DYN(共享库 / PIE 可执行文件)、CORE(核心转储文件)
MachineAdvanced Micro Devices X86-64目标指令集架构为 x86-64(AMD64),即 64 位 x86 平台

(3),地址与偏移信息

字段值说明
Entry point address0x400680程序入口的虚拟地址,程序加载后 CPU 从该地址开始执行。非 PIE 的传统可执行文件入口通常在 0x400000 附近
Start of program headers64 字节程序头表(Program Header Table)在文件内的偏移量,描述运行时加载的段(Segment)信息
Start of section headers6648 字节节头表(Section Header Table)在文件内的偏移量,描述编译链接用的节(Section)信息

(4),头部尺寸与表项数量

字段值说明
Size of this header64 字节ELF 头部本身的大小,64 位 ELF 固定为 64 字节(32 位为 52 字节)
Size of program headers56 字节单个程序头表项的大小
Number of program headers9程序头表项总数,即该程序共有 9 个加载段
Size of section headers64 字节单个节头表项的大小
Number of section headers30节头表项总数,即该文件共包含 30 个节(0--29)
Section header string table index29节名字符串表(.shstrtab)在节头表中的索引,用于存储所有节的名称

2,Program header table(程序头表)

(1),Program Headers(程序头 / 段表)详解

描述程序运行时的内存段(Segment)布局。

表头字段含义:

Type:段的类型 。

Offset:该段在文件内的字节偏移。

VirtAddr:加载到内存后的虚拟地址。

PhysAddr:物理地址(用户态程序通常与虚拟地址一致,无实际作用)。

FileSiz:该段在文件中占用的字节大小。

MemSiz:该段在内存中占用的字节大小。

Flags:内存权限:R= 读、W= 写、E= 执行。

Align:内存对齐要求,值为 2 的 N 次幂。

类型核心作用与说明
PHDR程序头表自身的描述段,记录程序头表本身在文件和内存中的位置,权限R E。作用是让系统加载器可以定位到程序头表自身。
INTERP解释器段,指定动态链接程序所需的加载器。当前程序请求的解释器为/lib64/ld-linux-x86-64.so.2,这是 Linux x86_64 平台标准的动态链接器。只有动态链接的程序才会有该段,静态编译程序无此字段。
LOAD(第 1 个)代码段(Text Segment),是程序的核心可执行段。- 从文件偏移 0 处开始,映射到虚拟地址0x400000- 权限R E(可读、可执行)- 对齐 2MB(0x200000),属于大页对齐优化- 包含所有代码、只读常量、符号表等只读内容
LOAD(第 2 个)数据段(Data Segment),存放可读写数据。- 权限RW(可读、可写)- 关键特征:MemSiz > FileSiz,差值就是.bss段的大小 —— 未初始化的全局变量不占用磁盘空间,程序加载时由系统自动清零分配。
DYNAMIC动态段,存储动态链接所需的全部元信息:依赖的共享库、符号表位置、重定位表地址等。只有动态链接程序存在该段,对应.dynamic节。
NOTE备注段,存放系统标注信息,比如 ABI 版本标签、程序构建 ID 等,只读属性。
GNU_EH_FRAMEGNU 异常处理帧段,为 C++ 等语言的异常处理、栈展开提供元数据,只读属性。
GNU_STACK栈属性标记段(不占实际文件空间),用于定义栈的内存权限。此处权限为RW(无执行权限),说明开启了NX 栈保护(数据执行保护),是系统安全机制;如果包含E权限则表示栈可执行,存在安全风险。
GNU_RELRO重定位只读保护段,对应RELRO 安全机制:动态重定位完成后,将 GOT 表、动态段等区域设为只读,防止 GOT 表劫持攻击。

(2),Section to Segment mapping(节到段的映射)

【编译链接用的节(Section)】到【运行时段】的映射关系。

这部分解释了【编译期的节】如何被归类打包进【运行期的段】:

段(Segment):运行时概念,面向操作系统加载器,按权限 / 用途分组。

节(Section):编译链接概念,面向编译器 / 链接器 / 调试器,按功能细分。

各段包含的节:

段 00(PHDR):无对应节,描述程序头自身。

段 01(INTERP):仅包含.interp节,存储动态链接器路径。

段 02(第一个 LOAD,RX 权限):代码 / 只读段,包含所有只读、可执行节:.interp、.note.ABI-tag、.note.gnu.build-id、.gnu.hash、.dynsym、.dynstr、.gnu.version、.gnu.version_r、.rela.dyn、.rela.plt、.init、.plt、.text、.fini、.rodata、.eh_frame_hdr、.eh_frame

段 03(第二个 LOAD,RW 权限):数据段,包含所有可读写节:.init_array、.fini_array、.jcr、.dynamic、.got、.got.plt、.data、.bss

段 04(DYNAMIC):仅对应.dynamic节。

段 05(NOTE):对应.note.ABI-tag、.note.gnu.build-id两个备注节。

段 06(GNU_EH_FRAME):对应.eh_frame_hdr异常处理表头节。

段 07(GNU_STACK):无对应节,仅为栈属性标记。

段 08(GNU_RELRO):包含重定位后会被设为只读的节:.init_array、.fini_array、.jcr、.dynamic、.got

不同的节大小不一,权限可能有相同的,多个相同权限的节,加载的时候,由加载器帮我们进行节合并(段)。

加载运行可执行程序的时候,先看Program header table。加载---->内存。

补充知识:

  1. 两个 LOAD 段是 ELF 加载的核心:操作系统加载程序时,本质就是把这两个段按权限映射到进程虚拟地址空间。
  2. .bss不占磁盘空间:数据段中MemSiz - FileSiz的差值就是.bss的大小,加载时由内核自动填零。
  3. 安全特征:该程序同时开启了NX 栈保护和RELRO 重定位保护,是现代 GCC 编译的默认安全配置。

为什么要将Section合并成为segment?

Section 合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为 4096 字节(内存块基本大小,加载,管理的基本单位),如果.text 部分为 4097 字节,.init 部分为 512 字节,那么它们将占用 3 个页面,而合并后,它们只需 2 个页面。

此外,操作系统在加载程序时,会将具有相同属性的 section 合并成一个大的 segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。

3,Section(节)

objdump -S a.out > section.txt 反汇编。

只截取部分:

这两张图验证了ELF 程序入口点与_start启动函数的对应关系,并展示了程序最开始执行的启动代码。下方readelf -h输出中,Entry point address: 0x400680是 ELF 文件头声明的程序入 口地址 —— 操作系统加载程序后,CPU 会从这个地址开始执行第一条指令。上方objdump -S反汇编结果中,<_start>函数的起始地址恰好就是0x400680。

结论:C 程序真正的入口不是main函数,而是_start。main只是 C 语言层面的入口,_start是操作系统层面的真实入口,由 glibc 运行时库提供,负责初始化运行环境后再调用main。

4,Section header table(节头表)

这是 readelf -S a.out的输出结果,用于展示 ELF 文件的节头表(Section Header Table),该可执行文件共包含 30 个节。节是编译链接层面的功能划分,面向编译器、链接器和调试器;程序运行时,属性相近的节会被打包为【段(Segment)】加载到内存。

六,ELF加载与进程地址空间

6.1,可执行程序的加载

一个ELF程序,在没有被加载到内存的时候,是有地址的,被称为逻辑地址(虚拟地址),当代计算机工作的时候,采用“平坦模式”(0+偏移量)对我们的可执行程序中的“每一行”代码都进行编址,从一个地址处开始编址(原则上从0号地址开始,若是32位系统:全0----全1)。所以也要求ELF对自己的代码和数据进行统一编址。使用objdump -S 对可执行文件进行反汇编。

400开头的就是ELF的逻辑地址(虚拟地址),也就是起始地址+偏移量,但是我们认为起始地址是0。所以,虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了。

创建一个进程,是先创建内核进程相关的数据结构,再加载ELF格式的二进制文件。创建内核数据结构,肯定还要初始化这个结构,初始化数据从哪里来呢?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的 [ start ,end ] 等数据范围,另外再用详细地址,填充页表。

6.1.1,重新理解进程虚拟地址空间

ELF再被编译好之后,会把自己未来的程序的入口地址记录在ELF header的

字段中。

上图描述了Linux 下 ELF 程序从磁盘文件到 CPU 执行的地址映射与加载全流程,核心围绕【虚拟地址空间】和【虚实地址转换】展开,可总结为以下 4 个核心部分:

1、虚拟地址空间的来源

程序运行时的虚拟地址空间由操作系统 + 编译器(ELF 文件)共同决定:

编译链接阶段,链接器就确定了程序各段的虚拟地址、大小、权限,全部记录在 ELF 的程序头表(Program Headers)中,例如程序入口_start的虚拟地址0x400680是编译期就确定的。

内核加载程序时,严格按照程序头表的描述,在进程的虚拟地址空间中分配对应区域。

2、内核的进程内存管理结构

操作系统通过两层结构体管理进程的内存:

task_struct:进程控制块,代表一个进程,内部包含指向内存描述符的指针。

mm_struct:进程虚拟地址空间的核心管理结构体,里面保存了代码段起止、数据段起止、栈 / 堆起始位置、参数环境区等全部内存区域信息;这些字段的初始值,正是从 ELF 程序头表中读取并初始化的。

3,虚实地址转换机制(MMU + 页表)

CPU 执行指令的全过程基于虚拟地址,物理地址转换在 CPU 内部完成,对程序完全透明:

CPU 的指令指针(EIP)中存储的是虚拟地址(例如0x400680),CPU 不直接访问物理内存。

CPU 内部的 MMU(内存管理单元)负责地址转换:

CR3 寄存器保存当前进程的页表基地址,进程切换时会切换 CR3 的值。

MMU 通过 CR3 找到内存中的页表,查表将虚拟地址映射为对应的物理地址(例如虚拟地址 0x400680 → 物理地址 0xf50020)。

核心结论:进入 CPU 的都是虚拟地址,输出到内存总线的是物理地址,转换过程由硬件自动完成。

4,程序加载的完整流程

(1),静态阶段:磁盘上的 ELF 文件是静态可执行文件,包含段定义、机器指令、入口地址等全部信息。

(2),加载阶段:操作系统读取 ELF 程序头,在物理内存中分配空间,将文件内容加载到物理内存页。

(3),映射阶段:内核为进程建立独立页表,把虚拟地址和分配好的物理地址建立映射关系。

(4),运行阶段:进程调度后,CPU 从 ELF 指定的入口虚拟地址开始取指,通过 MMU 转换地址后访问实际物理内存,程序开始执行。

6.2,静态链接

无论是自己的 .o ,还是静态库中的 .o ,本质都是把 .o 文件进行链接的过程。所以,研究静态链接,本质就是研究 .o 是如何链接的。

将一下两段代码编译成 .o 文件。 (gcc -c *.c)再将编译好的两个 .o 文件(ar -rc)归档成静态库文件libmystdc.a 。 在查看两个 .o 文件代码段的反汇编,并重定向到stdc.txt里(objdump -d libmystdc.a > stdc.txt)。

//hello.c #include<stdio.h> void func(); int main() { printf("Hello Warld!!!!\n"); func(); return 0; }
//func.c #include<stdio.h> void func() { printf("func()...\n"); }

以上文件创建完之后,就可以查看静态库文件libmystdc.a文件中的所有 .o 文件代码段的反汇编。

call后面的地址为全0,所以hello.o中的main函数不认识printf和func函数,func.o中的func函数不认识printf。

可以看到这里的call指令,它们分别对应之前调用的printf函数和func函数,但是它们的跳转地址都被设成了0。其实就是在编译hello.c的时候,编译器是完全不知道printf和func函数的存在的,比如它们位于内存的哪个区块,代码是什么样都是不知道的。因此,编译器只能将这两个函数的跳转地址暂时设为0。这个地址会在链接的时候被修正,为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。

结论:多个 .o 文件彼此都不知道对方。

读取两个 .o 文件的符号表:

使用(readelf -s libmystdc.a > sym.txt)命令显示完整符号表(.symtab):包含函数、全局变量、局部符号的名称、地址、大小、绑定属性(local/global/weak)、类型等。

puts:就是printf的实现。

UND:undefine,表示未定义,找不到跳转地址。

读取可执行文件main.exe文件的符号表:

gcc libmystdc.a -o main.txt

readelf -s main.exe > main.txt

可执行文件main.exe就不能存在未定义符号。

在可执行程序中,找到了func。 000000000040052d :是一个逻辑地址。FUNC:表示func符号类型是个函数。 13:就是func函数所在的section(两个 .o 文件合并之后的section),13就是下标。

读取可执行文件main.exe所有的section清单:

hello.o和func.o的 .text被合并了,是main.exe的下标为13的section。

hello.o和func.o,call后面的 00 00 00 00有没有被修改为具体的最终函数地址呢?

查看main.exe代码段的反汇编:

objdump -d main.exe > obj.txt

最终:

两个 .o 文件的代码段合并到了一起,合并方法实现到一个可执行文件,并进行了统一的编址。链接的时候,会修改 .o 中没有确定的函数地址,在合并完成之后,进行相关call地址,完成代码调用。

静态链接就是把库中的 .o 进行合并。链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括之前提到的地址修正,当所有模块组合到一起之后,链接器会根据我们的 .o 文件或者静态库中的重定位表找到那些需要被重定位的全局变量,从而修正它们的地址。这就是静态链接的过程。修改call后面地址的过程,就是地址重定位。
6.3,动态链接与动态库加载

后续更新......

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进