Thunk程序的实现原理以及在iOS中的应用(二)
本文导读:虚拟内存以及虚拟内存的remap机制,以及通过remap机制来实现通过静态指令来构造thunk代码块。
👉Thunk程序的实现原理以及在iOS中的应用 入口处。
thunk程序其实就是一段代码块,这段代码块可以在运行时动态构造也可以在编译时构造。thunk程序除了在第一篇文章中介绍的用途外还可以作为某些真实函数调用的跳板(trampoline)代码,以及解决一些函数参数不一致的调用对接问题。从设计模式的角度来讲thunk程序可以作为一个适配器(Adapter)。本文将重点介绍如何通过编译时的静态代码来实现thunk程序的方法,以便解决上一篇文章对于iOS系统下指令动态构造的约束限制的问题。
虚拟内存实现的简单介绍
在介绍静态构造thunk程序之前,首先要熟悉一个知识点:虚拟内存。虚拟内存是现代操作系统对于内存管理的一个很重要的技术。通过虚拟内存的映射机制,使得每个进程都可以拥有非常大而且完全隔离和独立的内存空间。操作系统对虚拟内存的分配和管理是以页为单位,当将一个可执行文件或者动态库加载到内存中执行时,操作系统会将文件中的代码段部分和数据段部分的内容通过内存映射文件的形式映射到对应的虚拟内存区域中。程序执行的代码所在的代码段部分总是被分配在一片具有可执行权限的虚拟内存区域中,不同的操作系统对可执行代码所处的内存区域要求的不同,就比如iOS系统来说,可执行代码所在的虚拟内存区域的权限只能是可执行的,否则就会产生系统崩溃,这也就是说我们不可以在具有可读写权限的内存区域中(比如堆内存或者栈内存空间)动态的构造出指令来供CPU执行。也就是说在iOS系统中不支持将某段内存的保护机制先设置为读写以便填充好数据后再设置为可执行的保护机制来实现动态的指令构造(也就是所谓的JIT技术)。不过好在操作系统提供了虚拟内存的remap机制来解决这个问题。所谓虚拟内存的remap机制就是可以将新分配的虚拟内存页重新映射到已经分配好的虚拟内存页中,新分配的虚拟内存页可以和已经存在的虚拟内存页中的内容保持一致,并且可以继承原始虚拟内存页面的保护权限。虚拟内存的remap机制使得进程之间或者进程内中的虚拟内存共享相同的物理内存。
虚拟内存到物理内存之间的映射从上面的图中可以得出一些结论:
- 无论是物理内存还是虚拟内存的管理都是以页为单位来进行管理的,并且一般情况下二者的尺寸保持一致。
- 操作系统为每个进程建立一张进程页表,页表记录着虚拟内存页到物理内存页的映射关系以及相关的权限。并且页表是保存在物理内存页中的。因此所谓的虚拟内存分配其本质就是在页表中建立一个从虚拟内存页到物理内存页的映射关系而已。而所谓的remap就是将不同的虚拟页号映射到同一个物理页号而已。就如例子中进程1的第1页和第4页都是映射在同一个6号物理页中。
- 不同进程之间的不同虚拟页号可以映射到相同的物理页号。这样的一个应用是解决动态库的共享加载问题,比如UIKit这个框架库在第一个进程运行时被加载到内存中,那么当第二个进程运行时并且需要UIKit库时就不再需要重新从文件加载内存中而是共享已经加载到物理内存的UIKit动态库。上面的例子中进程1的第5页和进程2的第7页共享相同的物理内存第9页。
- 操作系统还会维持一个全局物理页空闲信息表,用来记录当前未被分配的物理内存。这样一旦有进程需要分配虚拟内存空间时就从这个表中查找空闲的区域进行快速分配。
iOS的内核系统中有一层Mach子系统,Mach子系统是内核中的内核,它是一种微内核。Mach子系统中将进程(task)、线程、内存的管理都称之为一个对象,并且为每个对象都会分配一个被称之为port的端口号,所有对象之间的通信和功能调用都是通过port为标识的mach message来进行通信的。
虚拟内存的remap机制
下面的代码将展示虚拟内存分配销毁以及虚拟内存的remap机制。例子里面演示了通过remap机制来实现同一个函数实现的两个不同的入口地址的调用实现:
#import <mach/mach.h>
//因为新分配的虚拟内存是以页为单位的,所以要被映射的内存也要页对齐,所以这里的函数起始地址是以页为单位对齐的。
int __attribute__ ((aligned (PAGE_MAX_SIZE))) testfn(int a, int b)
{
int c = a + b;
return c;
}
int main(int argc, char *argv[])
{
//通过vm_alloc以页为单位分配出一块虚拟内存。
vm_size_t page_size = 0;
host_page_size(mach_host_self(), &page_size); //获取一页虚拟内存的尺寸
vm_address_t addr = 0;
//在当前进程内的空闲区域中分配出一页虚拟内存出来,addr指向虚拟内存的开始位置。
kern_return_t ret = vm_allocate(mach_task_self(), &addr, page_size, VM_FLAGS_ANYWHERE);
if (ret == KERN_SUCCESS)
{
//addr被分配出来后,我们可以对这块内存进行读写操作
memcpy((void*)addr, "Hello World!\n", 14);
printf((const char*)addr);
//执行上述代码后,这时候内存addr的内容除了最开始有“Hello World!\n“其他区域是一篇空白,而且并不是可执行的代码区域。
//虚拟内存的remap重映射。执行完vm_remap函数后addr的内存将被重新映射到testfn函数所在的内存页中,这时候addr所指的内容将不在是Hello world!了,而是和函数testfn的代码保持一致。
vm_prot_t cur,max;
ret = vm_remap(mach_task_self(), &addr, page_size, 0, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, mach_task_self(), (vm_address_t)testfn, false, &cur, &max, VM_INHERIT_SHARE);
if (ret == KERN_SUCCESS)
{
int c1 = testfn(10, 20); //执行testfn函数
int c2 = ((int (*)(int,int))addr)(10,20); //addr重新映射后将和testfn函数具有相同内容,所以这里可以将addr当做是testfn函数一样被调用。
NSAssert(c1 == c2, @"oops!");
}
vm_deallocate(mach_task_self(), addr, page_size);
}
return 0;
}
首先我们用vm_allocate函数以页的尺寸大小为单位在空闲区域分配出一页虚拟内存出来并由addr指向内存的首地址。当分配成功后我们就可以像操作普通内存一样任意对这块内存进行读写处理。这里对addr分别进行了memcpy的写操作,以及printf函数对addr进行读操作。这时候addr所指的内存具有读写属性。addr内存中存储的信息如下:
addr地址的内存布局
接下来我们又通过vm_remp函数来对addr内存地址进行重新映射,vm_remap函数中分别有两个port参数分别用来指定目标进程和原进程,也就是说vm_remap函数可以将任何两个进程中的内存地址进行相互映射。这种内存映射的支持其实也可以用来实现进程之间的通信处理,当然在iOS系统中是无法实现跨进程的内存映射的,因此目标进程和原进程必须具有相同的port。除了指定源进程和目标进程端口外,还需要指定目标地址和源地址,也就是vm_remap函数使得目标地址映射到源地址上,使得目标地址所指的内存和源地址保持一致。而上面的目标地址是addr,而源地址则是函数testfn的起始地址。经过映射操作后的结果是addr所指的内存和testfn所指的内容将保持一致,而且addr还会继承源地址testfn的保护权限。因为testfn是编译时的代码,最终会存放在代码段中并只具有可执行权限, 这样最终的结果是addr也变成只具有可执行权限的内存区域了,而且它所指向的内容就是和函数testfn所指向的内容都一样了,都是一段可执行的代码。而后续的两个函数调用的结果保持一致,也证明了结果是正确的。我们可以看出addr和testfn所指向的内容已经完全一致了:
addr地址被remap后的内存布局
通过vm_remap函数我们能够实现两个不同的虚拟内存地址所指向的物理地址保持一致。
一个很有意思的说法是,在面向对象系统中一个对象的唯一标识是对象所处的内存地址,包括一些系统中的基类的equal函数的实现往往是比较对象的地址是否相等。那如果在有vm_remap的处理下,这个结论将被打破,因此通过vm_remap我们就能实现一个对象可以通过多个不同的地址来进行访问,这里我们也可以思考一下是否可以用这种技术来解决一些目前的一些问题呢?
vm_allocate可以用来实现虚拟内存的分配,malloc也可以用来实现堆内存的分配,这两者之间有什么关系呢?前者其实是更加底层的内存管理API,而且分配的内存的尺寸都是以页的倍数作为边界的;而后者中的堆内存是高级内存管理API,一个进程的堆内存区域在实现中其实是先通过vm_allocate分配出来一大片内存区域(包括栈内存也如此)。然后再在这块大的内存区域上进行分割管理以及空闲复用等等高级操作来实现一些零碎和范围内存分配操作。但是不管如何最终我们都可以借助这些函数来对分配出来的内存进行读写处理。
上面的addr对testfn的映射后addr 能够和testfn具有相同的能力,但是这种能力其实是需要对testfn的函数体所有约束的,这个约束就是testfn中不能出现一些常量以及全局变量以及不能再出现函数调用,原因是这些操作在编译为机器指令后访问这些数据都是通过相对偏移来实现的,因此如果addr映射成功后因为函数实现的基地址有变化,如果通过addr进行访问时,那么指令中的相对偏移值将是一个错误的结果,从而造成函数调用时的崩溃发生。
静态构造thunk程序
上一篇文章中实现了通过在内存中动态的构造机器指令来实现一段thunk代码,但是这种机制在iOS系统中是无法在发布版证书打包的程序中运行的。仔细考察手动构造thunk代码指令:
mov x2, x1
mov x1, x0
ldr x0, #0x0c
ldr x3, #0x10
br x3
arg0:
.quad 0
realfn:
.quad 0
就可以看出,指令块的重点是在第3条和第4条指令。这两条指令通过读取距离当前指令偏移0x0c和0x10处的数据来赋值给特定的寄存器,而我们又可以在内存构造时动态的调整和设置这部分内存的值,从而实现运行时的thunk的能力。现在将上述的代码改动一下:
mov x2, x1
mov x1, x0
ldr x0, PAGE_MAX_SIZE - 8
ldr x3, PAGE_MAX_SIZE - 4
br x3
可以看出第3条和第4条指令的偏移变为了PAGE_MAX_SIZE也就是变为一个虚拟内存页尺寸的值,指令取数据的偏移位置被放大了。可问题是如果只动态构造了很小一部分内存来存储指令,并没有多分配一页内存来存储数据,那这样有什么意义呢?
想象一下如果上面的那部分指令并不是被动态构造,而是静态编译时就存在的代码呢?这样这部分代码就不会因为签名问题而无法在iOS系统上运行。进一步来说,我们可以在运行时分配2页虚拟内存,当分配完成后,将第1页虚拟内存地址remap到上述那部分代码所在的内存地址,而将第2页分配的虚拟内存用来存放指令中所指定偏移的数据。根据上面对remap机制的描述可以得出当进行remap后所分配的第1页虚拟内存具备了可执行代码的能力,而又因为代码中第3、4条指令所取的数据是对应的第2页虚拟内存的数据,这样就可以实现在不动态构造指令的情况下来解决生成thunk程序的问题了。整个实现的原理如下:
静态指令来实现thunk程序的流程从上面的流程图中可以很清楚的了解到通过对虚拟内存进行remap就可以不用动态构造指令来完成构建一个thunk程序块的能力,下面我们就结合第一篇文章中的快速排序,以及本文的remap机制来实现静态构造thunk块的能力
- 首先在你的工程里面添加一个后缀为.s的汇编代码文件(new file -> assembly file)。本文件中的代码只实现对arm64位系统的支持
//
// thunktemplate.s
// thunktest
//
// Created by youngsoft on 2019/1/30.
// Copyright © 2019年 youngsoft. All rights reserved.
//
#if __arm64__
#include <mach/vm_param.h>
/*
指令在代码段中,声明外部符号_thunktemplate,并且指令地址按页的大小对齐!
*/
.text
.private_extern _thunktemplate
.align PAGE_MAX_SHIFT
_thunktemplate:
mov x2, x1
mov x1, x0
ldr x0, PAGE_MAX_SIZE - 8
ldr x3, PAGE_MAX_SIZE - 4
br x3
#endif
- 然后我们在另外一个文件中实现排序的代码:
extern void *thunktemplate; //声明使用thunk模板符号,注意不要带下划线
typedef struct
{
int age;
char *name;
}student_t;
//按年龄升序排列的函数
int ageidxcomparfn(student_t students[], const int *idx1ptr, const int *idx2ptr)
{
return students[*idx1ptr].age - students[*idx2ptr].age;
}
int main(int argc, const char *argv[])
{
vm_address_t thunkaddr = 0;
vm_size_t page_size = 0;
host_page_size(mach_host_self(), &page_size);
//分配2页虚拟内存,
kern_return_t ret = vm_allocate(mach_task_self(), &thunkaddr, page_size * 2, VM_FLAGS_ANYWHERE);
if (ret == KERN_SUCCESS)
{
//第一页用来重映射到thunktemplate地址处。
vm_prot_t cur,max;
ret = vm_remap(mach_task_self(), &thunkaddr, page_size, 0, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, mach_task_self(), (vm_address_t)&thunktemplate, false, &cur, &max, VM_INHERIT_SHARE);
if (ret == KERN_SUCCESS)
{
student_t students[5] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
int idxs[5] = {0,1,2,3,4};
//第二页的对应位置填充数据。
void **p = (void**)(thunkaddr + page_size);
p[0] = students;
p[1] = ageidxcomparfn;
//将thunkaddr作为回调函数的地址。
qsort(idxs, 5, sizeof(int), (int (*)(const void*, const void*))thunkaddr);
for (int i = 0; i < 5; i++)
{
printf("student:[age:%d, name:%s]\n", students[idxs[i]].age, students[idxs[i]].name);
}
}
vm_deallocate(mach_task_self(), thunkaddr, page_size * 2);
}
return 0;
}
可以看出通过remap机制可以创造性的解决了动态构造内存指令来实现thunk程序的缺陷问题,整个过程不需要我们构造指令,而是借用现有已经存在的指令来构造thunk程序,而且这样的代码不存在签名的问题,也可以在iOS的任何签名下被安全运行。当然这个技巧也是可以使用在linux/unix系统之上的。
后记
本文中所介绍的技术和技巧参考自开源库libffi中对闭包的支持以及iOS的runtime中通过一个block对象来得到IMP函数指针的实现方法。