
*链接到本文末尾的库和演示视频。 为了了解正在发生的事情以及所有这些人是谁,我建议阅读上一篇文章 。
在上一篇文章中,我们熟悉了一种允许热重载c ++代码的方法。 在这种情况下,“代码”是功能,数据及其相互协调的工作。 函数没有特殊问题,我们将执行流程从旧函数重定向到新函数,一切正常。 问题在于数据(静态和全局变量),即它们在新旧代码中的同步策略。 在第一个实现中,此策略非常笨拙:我们只需将所有静态变量的值从旧代码复制到新代码中,以便引用新变量的新代码可以与旧代码中的值一起使用。 当然,这是不正确的,今天,我们将尝试通过同时解决许多小而有趣的问题来纠正此缺陷。
本文省略了有关机械工作的详细信息,例如从elf和mach-o文件读取字符和重定位。 重点是我在实现过程中遇到的细微之处,这对于像我这样最近正在寻找答案的人可能有用。
精华液
假设我们有一个类(综合示例,请不要在其中寻找含义,只有代码很重要):
除了静态变量外没有什么特别的。 现在假设我们想将printDescription()
方法更改为:
void Entity::printDescription() { std::cout << "DESCRIPTION: " << m_description << std::endl; }
重新加载代码后会发生什么? 除了Entity
类的方法之外,静态变量m_livingEntitiesCount
也随新代码一起进入库。 如果我们简单地将此变量的值从旧代码复制到新代码,然后继续使用新变量,而忘记旧变量,则不会有任何不好的事情,因为直接使用此变量的所有方法都在新代码库中。
C ++非常灵活和丰富。 尽管可以很好地解决恶臭代码中c ++边界的某些问题,但我还是喜欢这种语言。 例如,假设您的项目不使用rtti。 同时,您需要使用某种类型安全的接口来实现Any
类:
class Any { public: template <typename T> explicit Any(T&& value) { ... } template <typename T> bool is() const { ... } template <typename T> T& as() { ... } };
我们不会详细介绍此类的实现。 对我们来说重要的是,对于实现而言,我们需要某种类型的机制,用于将类型(编译时实体)明确映射到变量的值,例如uint64_t
(运行时实体),即“枚举”类型。 使用rtti时,像type_info
东西(更适合我们使用type_index
东西)对我们可用。 但是我们没有rtti。 在这种情况下,一个相当普遍的hack(或优雅的解决方案?)是这个功能:
template <typename T> uint64_t typeId() { static char someVar; return reinterpret_cast<uint64_t>(&someVar); }
然后, Any
类的实现将如下所示:
class Any { public: template <typename T> explicit Any(T&& value) : m_typeId(typeId<std::decay<T>::type>())
对于每种类型,该函数将分别精确地实例化1次,该函数的每个版本将具有其自己的静态变量,显然具有其自己的唯一地址。 当我们使用此函数重新加载代码时会发生什么? 对该函数的旧版本的调用将被重定向到新版本。 新的变量将具有自己的静态变量,该变量已经初始化(我们复制了value和guard变量)。 但是我们对含义不感兴趣,我们仅使用地址。 并且新变量的地址将不同。 因此,数据变得不一致:在Any
类的已经创建的实例中,将存储旧的静态变量的地址,并且is()
方法将其与新的静态变量的地址进行比较,并且“ this Any
不再是相同的Any
”。
计划
要解决此问题,您需要的不仅仅是复制功能。 在Google上度过了两个晚上,阅读了文档,源代码和系统api之后,我脑中建立了以下计划:
- 构建新代码后,我们进行重定位 。
- 从这些重定位中,我们获得了使用静态(有时是全局)变量的代码中的所有位置。
- 我们将旧版本的地址替换为重定位位置,而不是变量的新版本的地址。
在这种情况下,将没有指向新数据的链接,整个应用程序将继续使用直到该地址的旧版本的变量。 那应该工作。 这不能失败。
搬迁
当编译器生成机器代码时,它将在此位置插入几个字节,足以将变量或函数的实际地址写入该位置,在此位置调用函数或加载变量的地址,并生成重定位。 他无法立即记录真实地址,因为在此阶段他不知道该地址。 链接后的函数和变量可以位于不同的部分,不同的部分位置,最后的部分可以在运行时加载到不同的地址。
搬迁包含以下信息:
- 您需要在哪个地址写函数或变量的地址
- 要写入哪个函数或变量的地址
- 该地址的计算公式
- 该地址保留了多少字节
在不同的操作系统中,重定位的表示方式有所不同,但最终它们都以相同的原理工作。 例如,在elf(Linux)中,重定位位于特殊的.rela
节中(在32位版本中,此文件为.rel
),该节指的是需要固定地址的部分(例如.rela.text
重定位所在的部分, (应用于.text
部分),并且每个条目都存储有关您要在重定位站点中插入其地址的符号的信息。 在mach-o(macOS)中,情况恰好相反;没有用于重定位的单独部分;相反,每个部分都包含一个指向应该应用于此部分的重定位表的指针,并且该表中的每个记录都有对关系符号的引用。
例如,对于这样的代码(带有-fPIC
选项):
int globalVariable = 10; int veryUsefulFunction() { static int functionLocalVariable = 0; functionLocalVariable++; return globalVariable + functionLocalVariable; }
编译器将在Linux上使用重定位创建这样的部分:
Relocation section '.rela.text' at offset 0x1a0 contains 4 entries: Offset Info Type Symbol's Value Symbol's Name + Addend 0000000000000007 0000000600000009 R_X86_64_GOTPCREL 0000000000000000 globalVariable - 4 000000000000000d 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 0000000000000016 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 000000000000001e 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4
以及macOS上的此类重定位表:
RELOCATION RECORDS FOR [__text]: 000000000000001b X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000015 X86_64_RELOC_SIGNED _globalVariable 000000000000000f X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000006 X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable
这是veryUsefulFunction()
函数(在Linux上):
0000000000000000 <_Z18veryUsefulFunctionv>: 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: 48 8b 05 00 00 00 00 mov rax,QWORD PTR [rip+0x0] b: 8b 0d 00 00 00 00 mov ecx,DWORD PTR [rip+0x0] 11: 83 c1 01 add ecx,0x1 14: 89 0d 00 00 00 00 mov DWORD PTR [rip+0x0],ecx 1a: 8b 08 mov ecx,DWORD PTR [rax] 1c: 03 0d 00 00 00 00 add ecx,DWORD PTR [rip+0x0] 22: 89 c8 mov eax,ecx 24: 5d pop rbp 25: c3 ret
并将对象链接到动态库后:
00000000000010e0 <_Z18veryUsefulFunctionv>: 10e0: 55 push rbp 10e1: 48 89 e5 mov rbp,rsp 10e4: 48 8b 05 05 21 00 00 mov rax,QWORD PTR [rip+0x2105] 10eb: 8b 0d 13 2f 00 00 mov ecx,DWORD PTR [rip+0x2f13] 10f1: 83 c1 01 add ecx,0x1 10f4: 89 0d 0a 2f 00 00 mov DWORD PTR [rip+0x2f0a],ecx 10fa: 8b 08 mov ecx,DWORD PTR [rax] 10fc: 03 0d 02 2f 00 00 add ecx,DWORD PTR [rip+0x2f02] 1102: 89 c8 mov eax,ecx 1104: 5d pop rbp 1105: c3 ret
在4个地方中,保留4个字节作为实数变量的地址。
在不同的系统上,可能的重定位集是您自己的。 在x86-64的Linux上,多达40种重定位类型 。 在x86-64的macOS上只有9个 。 所有类型的重定位都可以有条件地分为2组:
- 链接时重定位-在将目标文件链接到可执行文件或动态库的过程中使用的重定位
- 加载时重定位-在将动态库加载到进程内存中时应用的重定位
第二组包括导出的函数和变量的重定位。 当将动态库加载到过程存储器中时,对于所有动态重定位(包括全局变量的重定位),链接器都会在所有已加载的库中(包括程序本身)搜索符号的定义,并且将第一个合适的符号的地址用于重定位。 因此,这些重定位不需要做任何事情;链接器将从我们的应用程序本身中找到该变量,因为它会更早地落入他已加载的库和程序的列表中,并在新代码中替换其地址,而忽略该变量的新版本。
有一个与macOS及其动态链接器相关的微妙之处。 MacOS实现了所谓的两级名称空间机制。 如果很粗鲁,则在加载动态库时,链接器将首先在该库中搜索字符,如果找不到,则将在其他库中搜索。 这样做是出于性能目的,因此重定位可以快速解决,这通常是合乎逻辑的。 但这打破了我们关于全局变量的流程。 幸运的是,在macOS上的ld中,有一个特殊的标志-flat_namespace
,如果您使用此标志构建库,则字符搜索算法将与Linux中的相同。
第一组包括静态变量的重定位-正是我们所需要的。 唯一的问题是这些重定位不在编译的库中,因为链接器已经解决了它们。 因此,我们将从组装该库的目标文件中读取它们。
重定位的可能类型也受汇编代码是否与位置相关的限制。 由于我们以PIC模式收集代码(与位置无关的代码),因此重定位仅用于相对位置。 我们感兴趣的总搬迁地点是:
- Linux上
.rela.text
部分的重定位以及macOS上__text
部分引用的重定位,以及 - 它使用Linux上
.data
和.bss
部分中的__bss
以及macOS上的__data
, __bss
和__common
,以及 - 在Linux上,
X86_64_RELOC_SIGNED
定位的类型为R_X86_64_PC32
和X86_64_RELOC_SIGNED_1
,在macOS上, X86_64_RELOC_SIGNED_2
定位的类型为R_X86_64_PC32
, X86_64_RELOC_SIGNED_1
, X86_64_RELOC_SIGNED_2
和X86_64_RELOC_SIGNED_4
与__common
节关联的微妙点。 Linux也有类似的*COM*
部分。 全局变量可能属于此部分 。 但是,当我测试并编译了大量代码片段时,在Linux上, *COM*
部分中的字符重定位始终是动态的,就像常规的全局变量一样。 同时,在macOS上,如果函数和字符位于同一文件中,则有时在链接期间会重新分配这些字符。 因此,在macOS上,读取字符和重定位时应考虑此部分。
好了,现在我们有了一组所需的所有重定位,如何处理它们? 这里的逻辑很简单。 当链接器链接库时,它会将由特定公式计算出的符号地址写入重定位地址。 对于我们在两个平台上的重定位,此公式均包含符号的地址作为术语。 因此,已经记录在函数主体中的计算出的地址具有以下形式:
resultAddr = newVarAddr + addend - relocAddr
同时,我们知道两个版本的变量的地址-旧的(已存在于应用程序中)和新的。 我们仍然需要根据以下公式进行更改:
resultAddr = resultAddr - newVarAddr + oldVarAddr
并将其写入重定位地址。 之后,新代码中的所有函数将使用变量的现有版本,而新变量将简单地说谎并且不执行任何操作。 你需要什么! 但是有一个微妙的地方。
用新代码加载库
当系统将动态库加载到进程内存中时,可以将其自由放置在虚拟地址空间中的任何位置。 在Ubuntu 18.04上,我的应用程序加载为0x00400000
,并且动态库在ld-2.27.so
之后立即ld-2.27.so
到0x7fd3829bd000
区域中的地址。 程序的下载地址和库之间的距离远大于有符号的32位整数所能容纳的数字。 在链接时重定位中,仅4个字节保留给目标字符的地址。
在-mcmodel=large
了有关编译器和链接器的文档后,我决定尝试使用-mcmodel=large
选项。 它强制编译器生成代码,而无需假设字符之间的距离,因此所有地址均假定为64位。 但是此选项不是PIC友好的,因为-mcmodel=large
不能与-fPIC
一起-fPIC
,至少在macOS上。 我仍然不明白问题是什么,也许在macOS上没有适合这种情况的重定位。
在Windows下的库中,此问题如下解决。 双手在应用程序下载位置附近分配了一块虚拟内存,足以容纳该库的必要部分。 然后将部分手动装入其中,将必要的权限设置到具有相应部分的内存页,用手解压缩所有重定位,然后修补所有其他内容。 我很懒 我真的不想用加载时重定位来完成所有这些工作,尤其是在Linux上。 为什么动态链接程序已经知道该怎么做? 毕竟,写它的人比我了解得多。
幸运的是,文档找到了必要的选项来指示将动态库下载到何处:
- 苹果ld:
-image_base 0xADDRESS
- LLVM lld:
--image-base=0xADDRESS
- GNU ld:
-Ttext-segment=0xADDRESS
这些选项应在链接动态库时传递给链接器。 有两个困难。
第一个与GNU ld有关。 为了使这些选项起作用,您需要:
- 在加载库时,我们要加载的区域是空闲的
- 在选项中指定的地址必须是页面大小的倍数(在x86-64 Linux和macOS上,它是
0x1000
) - 至少在Linux上,该选项中指定的地址必须是
PT_LOAD
段的对齐方式的PT_LOAD
也就是说,如果链接器将对齐方式设置为0x10000000
,则即使该地址与页面大小对齐,也无法在地址0x10001000
处加载该库。 如果不满足这些条件之一,则库将“照常”加载。 我的系统上有GNU ld 2.30,并且与LLVM lld不同,默认情况下,它将PT_LOAD
段的对齐方式设置为0x20000
,这与图片完全不同。 要解决此问题,除了-Ttext-segment=...
选项之外, -Ttext-segment=...
指定-z max-page-size=0x1000
。 我花了一天的时间,直到意识到为什么图书馆没有在需要的地方加载。
第二个困难-在库的链接阶段应该知道下载地址。 组织起来不是很困难。 在Linux中,足以解析伪文件/proc/<pid>/maps
,找到与程序最接近的空闲空间(库适合该空间),并在链接时使用该空间开头的地址。 通过查看目标文件的大小,或者通过解析它们并计算所有节的大小,可以大致估计将来库的大小。 最后,我们不需要一个确切的数字,而是一个带有边距的近似大小。
MacOS没有/proc/*
;相反,建议您使用vmmap
实用程序。 vmmap -interleaved <pid>
命令的输出包含与proc/<pid>/maps
相同的信息。 但是,这里又出现了另一个困难。 如果应用程序创建了执行该命令的子进程,并且当前进程的标识符指定为<pid>
,则程序将挂起。 据我了解, vmmap
停止了进程读取其内存映射,并且显然,如果这是调用进程,则出了点问题。 在这种情况下,您需要指定附加标志-forkCorpse
以便vmmap
从我们的进程中创建一个空的子进程,从中删除映射并杀死它,从而不会中断程序。
这基本上就是我们需要知道的。
全部放在一起
经过这些修改,最终的代码重载算法如下所示:
- 将新代码编译为目标文件
- 对于目标文件,我们估计未来库的大小
- 读取重定位目标文件
- 我们正在该应用程序旁边寻找一块免费的虚拟内存
- 我们用必要的选项构建一个动态库,通过
dlopen
- 根据链接时重定位的补丁代码
- 补丁功能
- 复制不参与步骤6的静态变量
只有静态变量的保护变量才进入步骤8,因此可以安全地复制它们(从而保留静态变量本身的“初始化”)。
结论
由于这仅是开发工具,不适合任何生产,因此,如果下一个包含新代码的库无法放入内存或意外加载到其他地址,则可能会发生最坏的事情,即重新启动已调试的应用程序。 运行测试时,将31个具有更新代码的库依次加载到内存中。
为了完整起见,在实现中缺少了3个较重要的部分:
- 现在,具有新代码的库将被加载到程序旁边的内存中,尽管可以从已加载很远的另一个动态库中获取代码。 要进行修复,您需要跟踪翻译单元对一个或另一个库和程序的所有权,并在必要时用新代码拆分库。
- 在多线程应用程序中重新加载代码仍然不可靠(可以确定的是,您只能重新加载与runloop库在同一线程中运行的代码)。 为了进行修复,有必要将实现的一部分移到一个单独的程序中,并且该程序在修补之前必须停止所有线程的进程,修补并使其恢复工作。 我不知道如何在没有外部程序的情况下执行此操作。
- 防止代码重新加载后应用程序意外崩溃。 修复代码后,您可能会意外地取消引用新代码中的无效指针,然后必须重新启动应用程序。 没错,但仍然如此。 听起来像黑魔法,我仍然在想。
但是目前的实现已经开始使我个人受益,这足以在我的主要工作中使用。 这需要一点时间来适应,但是飞行是正常的。
如果我了解了这三点并在其实现中发现了很多有趣的东西,我一定会分享的。
演示版
由于该实现允许您即时添加新的广播单元,因此我决定录制一段简短的视频,其中我从头开始写了一个淫秽的简单游戏,内容涉及宇宙飞船在宇宙中耕作并射击方形小行星。 我尝试不以“所有文件合并”的方式编写文件,但是,如果可能的话,将所有内容都放在书架上,从而生成许多小文件(因此,杂文太多了)。 当然,该框架用于绘图,输入,窗口和其他事物,但是游戏本身的代码是从头开始编写的。
主要功能-我只运行过3次该应用程序:一开始,它只有一个空的场景,而由于我的疏忽,在运行后仅运行了2次。 整个游戏逐渐投入到编写代码的过程中。 实时-大约40分钟。 一般来说,不客气。
一如既往,我将很高兴收到任何批评,谢谢!
链接到实施