本文導(dǎo)讀:虛擬內(nèi)存以及虛擬內(nèi)存的remap機制备畦,以及通過remap機制來實現(xiàn)通過靜態(tài)指令來構(gòu)造thunk代碼塊抖剿。
??Thunk程序的實現(xiàn)原理以及在iOS中的應(yīng)用 入口處苦蒿。
thunk程序其實就是一段代碼塊预伺,這段代碼塊可以在運行時動態(tài)構(gòu)造也可以在編譯時構(gòu)造茬故。thunk程序除了在第一篇文章中介紹的用途外還可以作為某些真實函數(shù)調(diào)用的跳板(trampoline)代碼职祷,以及解決一些函數(shù)參數(shù)不一致的調(diào)用對接問題氏涩。從設(shè)計模式的角度來講thunk程序可以作為一個適配器(Adapter)届囚。本文將重點介紹如何通過編譯時的靜態(tài)代碼來實現(xiàn)thunk程序的方法,以便解決上一篇文章對于iOS系統(tǒng)下指令動態(tài)構(gòu)造的約束限制的問題是尖。
虛擬內(nèi)存實現(xiàn)的簡單介紹
在介紹靜態(tài)構(gòu)造thunk程序之前意系,首先要熟悉一個知識點:虛擬內(nèi)存。虛擬內(nèi)存是現(xiàn)代操作系統(tǒng)對于內(nèi)存管理的一個很重要的技術(shù)饺汹。通過虛擬內(nèi)存的映射機制蛔添,使得每個進程都可以擁有非常大而且完全隔離和獨立的內(nèi)存空間。操作系統(tǒng)對虛擬內(nèi)存的分配和管理是以頁為單位兜辞,當(dāng)將一個可執(zhí)行文件或者動態(tài)庫加載到內(nèi)存中執(zhí)行時迎瞧,操作系統(tǒng)會將文件中的代碼段部分和數(shù)據(jù)段部分的內(nèi)容通過內(nèi)存映射文件的形式映射到對應(yīng)的虛擬內(nèi)存區(qū)域中。程序執(zhí)行的代碼所在的代碼段部分總是被分配在一片具有可執(zhí)行權(quán)限的虛擬內(nèi)存區(qū)域中逸吵,不同的操作系統(tǒng)對可執(zhí)行代碼所處的內(nèi)存區(qū)域要求的不同凶硅,就比如iOS系統(tǒng)來說,可執(zhí)行代碼所在的虛擬內(nèi)存區(qū)域的權(quán)限只能是可執(zhí)行的胁塞,否則就會產(chǎn)生系統(tǒng)崩潰咏尝,這也就是說我們不可以在具有可讀寫權(quán)限的內(nèi)存區(qū)域中(比如堆內(nèi)存或者棧內(nèi)存空間)動態(tài)的構(gòu)造出指令來供CPU執(zhí)行。也就是說在iOS系統(tǒng)中不支持將某段內(nèi)存的保護機制先設(shè)置為讀寫以便填充好數(shù)據(jù)后再設(shè)置為可執(zhí)行的保護機制來實現(xiàn)動態(tài)的指令構(gòu)造(也就是所謂的JIT技術(shù))啸罢。不過好在操作系統(tǒng)提供了虛擬內(nèi)存的remap機制來解決這個問題编检。所謂虛擬內(nèi)存的remap機制就是可以將新分配的虛擬內(nèi)存頁重新映射到已經(jīng)分配好的虛擬內(nèi)存頁中,新分配的虛擬內(nèi)存頁可以和已經(jīng)存在的虛擬內(nèi)存頁中的內(nèi)容保持一致扰才,并且可以繼承原始虛擬內(nèi)存頁面的保護權(quán)限允懂。虛擬內(nèi)存的remap機制使得進程之間或者進程內(nèi)中的虛擬內(nèi)存共享相同的物理內(nèi)存。
從上面的圖中可以得出一些結(jié)論:
- 無論是物理內(nèi)存還是虛擬內(nèi)存的管理都是以頁為單位來進行管理的衩匣,并且一般情況下二者的尺寸保持一致蕾总。
- 操作系統(tǒng)為每個進程建立一張進程頁表,頁表記錄著虛擬內(nèi)存頁到物理內(nèi)存頁的映射關(guān)系以及相關(guān)的權(quán)限琅捏。并且頁表是保存在物理內(nèi)存頁中的生百。因此所謂的虛擬內(nèi)存分配其本質(zhì)就是在頁表中建立一個從虛擬內(nèi)存頁到物理內(nèi)存頁的映射關(guān)系而已。而所謂的remap就是將不同的虛擬頁號映射到同一個物理頁號而已柄延。就如例子中進程1的第1頁和第4頁都是映射在同一個6號物理頁中蚀浆。
- 不同進程之間的不同虛擬頁號可以映射到相同的物理頁號。這樣的一個應(yīng)用是解決動態(tài)庫的共享加載問題搜吧,比如UIKit這個框架庫在第一個進程運行時被加載到內(nèi)存中市俊,那么當(dāng)?shù)诙€進程運行時并且需要UIKit庫時就不再需要重新從文件加載內(nèi)存中而是共享已經(jīng)加載到物理內(nèi)存的UIKit動態(tài)庫。上面的例子中進程1的第5頁和進程2的第7頁共享相同的物理內(nèi)存第9頁滤奈。
- 操作系統(tǒng)還會維持一個全局物理頁空閑信息表摆昧,用來記錄當(dāng)前未被分配的物理內(nèi)存。這樣一旦有進程需要分配虛擬內(nèi)存空間時就從這個表中查找空閑的區(qū)域進行快速分配蜒程。
iOS的內(nèi)核系統(tǒng)中有一層Mach子系統(tǒng)绅你,Mach子系統(tǒng)是內(nèi)核中的內(nèi)核伺帘,它是一種微內(nèi)核。Mach子系統(tǒng)中將進程(task)勇吊、線程曼追、內(nèi)存的管理都稱之為一個對象窍仰,并且為每個對象都會分配一個被稱之為port的端口號汉规,所有對象之間的通信和功能調(diào)用都是通過port為標識的mach message來進行通信的。
虛擬內(nèi)存的remap機制
下面的代碼將展示虛擬內(nèi)存分配銷毀以及虛擬內(nèi)存的remap機制驹吮。例子里面演示了通過remap機制來實現(xiàn)同一個函數(shù)實現(xiàn)的兩個不同的入口地址的調(diào)用實現(xiàn):
#import <mach/mach.h>
//因為新分配的虛擬內(nèi)存是以頁為單位的针史,所以要被映射的內(nèi)存也要頁對齊,所以這里的函數(shù)起始地址是以頁為單位對齊的碟狞。
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以頁為單位分配出一塊虛擬內(nèi)存啄枕。
vm_size_t page_size = 0;
host_page_size(mach_host_self(), &page_size); //獲取一頁虛擬內(nèi)存的尺寸
vm_address_t addr = 0;
//在當(dāng)前進程內(nèi)的空閑區(qū)域中分配出一頁虛擬內(nèi)存出來,addr指向虛擬內(nèi)存的開始位置。
kern_return_t ret = vm_allocate(mach_task_self(), &addr, page_size, VM_FLAGS_ANYWHERE);
if (ret == KERN_SUCCESS)
{
//addr被分配出來后族沃,我們可以對這塊內(nèi)存進行讀寫操作
memcpy((void*)addr, "Hello World!\n", 14);
printf((const char*)addr);
//執(zhí)行上述代碼后频祝,這時候內(nèi)存addr的內(nèi)容除了最開始有“Hello World!\n“其他區(qū)域是一篇空白,而且并不是可執(zhí)行的代碼區(qū)域脆淹。
//虛擬內(nèi)存的remap重映射常空。執(zhí)行完vm_remap函數(shù)后addr的內(nèi)存將被重新映射到testfn函數(shù)所在的內(nèi)存頁中,這時候addr所指的內(nèi)容將不在是Hello world!了盖溺,而是和函數(shù)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); //執(zhí)行testfn函數(shù)
int c2 = ((int (*)(int,int))addr)(10,20); //addr重新映射后將和testfn函數(shù)具有相同內(nèi)容,所以這里可以將addr當(dāng)做是testfn函數(shù)一樣被調(diào)用烘嘱。
NSAssert(c1 == c2, @"oops!");
}
vm_deallocate(mach_task_self(), addr, page_size);
}
return 0;
}
首先我們用vm_allocate函數(shù)以頁的尺寸大小為單位在空閑區(qū)域分配出一頁虛擬內(nèi)存出來并由addr指向內(nèi)存的首地址昆禽。當(dāng)分配成功后我們就可以像操作普通內(nèi)存一樣任意對這塊內(nèi)存進行讀寫處理。這里對addr分別進行了memcpy的寫操作蝇庭,以及printf函數(shù)對addr進行讀操作醉鳖。這時候addr所指的內(nèi)存具有讀寫屬性。addr內(nèi)存中存儲的信息如下:
接下來我們又通過vm_remp函數(shù)來對addr內(nèi)存地址進行重新映射哮内,vm_remap函數(shù)中分別有兩個port參數(shù)分別用來指定目標進程和原進程盗棵,也就是說vm_remap函數(shù)可以將任何兩個進程中的內(nèi)存地址進行相互映射。這種內(nèi)存映射的支持其實也可以用來實現(xiàn)進程之間的通信處理牍蜂,當(dāng)然在iOS系統(tǒng)中是無法實現(xiàn)跨進程的內(nèi)存映射的漾根,因此目標進程和原進程必須具有相同的port。除了指定源進程和目標進程端口外鲫竞,還需要指定目標地址和源地址辐怕,也就是vm_remap函數(shù)使得目標地址映射到源地址上,使得目標地址所指的內(nèi)存和源地址保持一致从绘。而上面的目標地址是addr,而源地址則是函數(shù)testfn的起始地址寄疏。經(jīng)過映射操作后的結(jié)果是addr所指的內(nèi)存和testfn所指的內(nèi)容將保持一致是牢,而且addr還會繼承源地址testfn的保護權(quán)限。因為testfn是編譯時的代碼陕截,最終會存放在代碼段中并只具有可執(zhí)行權(quán)限驳棱, 這樣最終的結(jié)果是addr也變成只具有可執(zhí)行權(quán)限的內(nèi)存區(qū)域了,而且它所指向的內(nèi)容就是和函數(shù)testfn所指向的內(nèi)容都一樣了农曲,都是一段可執(zhí)行的代碼社搅。而后續(xù)的兩個函數(shù)調(diào)用的結(jié)果保持一致,也證明了結(jié)果是正確的乳规。我們可以看出addr和testfn所指向的內(nèi)容已經(jīng)完全一致了:
通過vm_remap函數(shù)我們能夠?qū)崿F(xiàn)兩個不同的虛擬內(nèi)存地址所指向的物理地址保持一致形葬。
一個很有意思的說法是,在面向?qū)ο笙到y(tǒng)中一個對象的唯一標識是對象所處的內(nèi)存地址暮的,包括一些系統(tǒng)中的基類的equal函數(shù)的實現(xiàn)往往是比較對象的地址是否相等笙以。那如果在有vm_remap的處理下,這個結(jié)論將被打破冻辩,因此通過vm_remap我們就能實現(xiàn)一個對象可以通過多個不同的地址來進行訪問猖腕,這里我們也可以思考一下是否可以用這種技術(shù)來解決一些目前的一些問題呢?
vm_allocate可以用來實現(xiàn)虛擬內(nèi)存的分配恨闪,malloc也可以用來實現(xiàn)堆內(nèi)存的分配倘感,這兩者之間有什么關(guān)系呢?前者其實是更加底層的內(nèi)存管理API凛剥,而且分配的內(nèi)存的尺寸都是以頁的倍數(shù)作為邊界的侠仇;而后者中的堆內(nèi)存是高級內(nèi)存管理API,一個進程的堆內(nèi)存區(qū)域在實現(xiàn)中其實是先通過vm_allocate分配出來一大片內(nèi)存區(qū)域(包括棧內(nèi)存也如此)犁珠。然后再在這塊大的內(nèi)存區(qū)域上進行分割管理以及空閑復(fù)用等等高級操作來實現(xiàn)一些零碎和范圍內(nèi)存分配操作逻炊。但是不管如何最終我們都可以借助這些函數(shù)來對分配出來的內(nèi)存進行讀寫處理。
上面的addr對testfn的映射后addr 能夠和testfn具有相同的能力犁享,但是這種能力其實是需要對testfn的函數(shù)體所有約束的余素,這個約束就是testfn中不能出現(xiàn)一些常量以及全局變量以及不能再出現(xiàn)函數(shù)調(diào)用,原因是這些操作在編譯為機器指令后訪問這些數(shù)據(jù)都是通過相對偏移來實現(xiàn)的炊昆,因此如果addr映射成功后因為函數(shù)實現(xiàn)的基地址有變化桨吊,如果通過addr進行訪問時,那么指令中的相對偏移值將是一個錯誤的結(jié)果凤巨,從而造成函數(shù)調(diào)用時的崩潰發(fā)生视乐。
靜態(tài)構(gòu)造thunk程序
上一篇文章中實現(xiàn)了通過在內(nèi)存中動態(tài)的構(gòu)造機器指令來實現(xiàn)一段thunk代碼,但是這種機制在iOS系統(tǒng)中是無法在發(fā)布版證書打包的程序中運行的敢茁。仔細考察手動構(gòu)造thunk代碼指令:
mov x2, x1
mov x1, x0
ldr x0, #0x0c
ldr x3, #0x10
br x3
arg0:
.quad 0
realfn:
.quad 0
就可以看出佑淀,指令塊的重點是在第3條和第4條指令。這兩條指令通過讀取距離當(dāng)前指令偏移0x0c和0x10處的數(shù)據(jù)來賦值給特定的寄存器彰檬,而我們又可以在內(nèi)存構(gòu)造時動態(tài)的調(diào)整和設(shè)置這部分內(nèi)存的值伸刃,從而實現(xiàn)運行時的thunk的能力』牙現(xiàn)在將上述的代碼改動一下:
mov x2, x1
mov x1, x0
ldr x0, PAGE_MAX_SIZE - 8
ldr x3, PAGE_MAX_SIZE - 4
br x3
可以看出第3條和第4條指令的偏移變?yōu)榱薖AGE_MAX_SIZE也就是變?yōu)橐粋€虛擬內(nèi)存頁尺寸的值,指令取數(shù)據(jù)的偏移位置被放大了捧颅【巴迹可問題是如果只動態(tài)構(gòu)造了很小一部分內(nèi)存來存儲指令,并沒有多分配一頁內(nèi)存來存儲數(shù)據(jù)碉哑,那這樣有什么意義呢挚币?
想象一下如果上面的那部分指令并不是被動態(tài)構(gòu)造,而是靜態(tài)編譯時就存在的代碼呢谭梗?這樣這部分代碼就不會因為簽名問題而無法在iOS系統(tǒng)上運行忘晤。進一步來說宛蚓,我們可以在運行時分配2頁虛擬內(nèi)存激捏,當(dāng)分配完成后,將第1頁虛擬內(nèi)存地址remap到上述那部分代碼所在的內(nèi)存地址凄吏,而將第2頁分配的虛擬內(nèi)存用來存放指令中所指定偏移的數(shù)據(jù)远舅。根據(jù)上面對remap機制的描述可以得出當(dāng)進行remap后所分配的第1頁虛擬內(nèi)存具備了可執(zhí)行代碼的能力,而又因為代碼中第3痕钢、4條指令所取的數(shù)據(jù)是對應(yīng)的第2頁虛擬內(nèi)存的數(shù)據(jù)图柏,這樣就可以實現(xiàn)在不動態(tài)構(gòu)造指令的情況下來解決生成thunk程序的問題了。整個實現(xiàn)的原理如下:
從上面的流程圖中可以很清楚的了解到通過對虛擬內(nèi)存進行remap就可以不用動態(tài)構(gòu)造指令來完成構(gòu)建一個thunk程序塊的能力任连,下面我們就結(jié)合第一篇文章中的快速排序蚤吹,以及本文的remap機制來實現(xiàn)靜態(tài)構(gòu)造thunk塊的能力
- 首先在你的工程里面添加一個后綴為.s的匯編代碼文件(new file -> assembly file)。本文件中的代碼只實現(xiàn)對arm64位系統(tǒng)的支持
//
// 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
- 然后我們在另外一個文件中實現(xiàn)排序的代碼:
extern void *thunktemplate; //聲明使用thunk模板符號拱她,注意不要帶下劃線
typedef struct
{
int age;
char *name;
}student_t;
//按年齡升序排列的函數(shù)
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頁虛擬內(nèi)存二驰,
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};
//第二頁的對應(yīng)位置填充數(shù)據(jù)秉沼。
void **p = (void**)(thunkaddr + page_size);
p[0] = students;
p[1] = ageidxcomparfn;
//將thunkaddr作為回調(diào)函數(shù)的地址桶雀。
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機制可以創(chuàng)造性的解決了動態(tài)構(gòu)造內(nèi)存指令來實現(xiàn)thunk程序的缺陷問題,整個過程不需要我們構(gòu)造指令唬复,而是借用現(xiàn)有已經(jīng)存在的指令來構(gòu)造thunk程序矗积,而且這樣的代碼不存在簽名的問題,也可以在iOS的任何簽名下被安全運行敞咧。當(dāng)然這個技巧也是可以使用在linux/unix系統(tǒng)之上的棘捣。
后記
本文中所介紹的技術(shù)和技巧參考自開源庫libffi中對閉包的支持以及iOS的runtime中通過一個block對象來得到IMP函數(shù)指針的實現(xiàn)方法。