Thunk程序的實現原理以及在iOS中的應用(二)

本文導讀:虛擬內存以及虛擬內存的remap機制,以及通過remap機制來實現通過靜態指令來構造thunk代碼塊。

??Thunk程序的實現原理以及在iOS中的應用 入口處。

thunk程序其實就是一段代碼塊,這段代碼塊可以在運行時動態構造也可以在編譯時構造。thunk程序除了在第一篇文章中介紹的用途外還可以作為某些真實函數調用的跳板(trampoline)代碼,以及解決一些函數參數不一致的調用對接問題。從設計模式的角度來講thunk程序可以作為一個適配器(Adapter)。本文將重點介紹如何通過編譯時的靜態代碼來實現thunk程序的方法,以便解決上一篇文章對于iOS系統下指令動態構造的約束限制的問題。

虛擬內存實現的簡單介紹

在介紹靜態構造thunk程序之前,首先要熟悉一個知識點:虛擬內存。虛擬內存是現代操作系統對于內存管理的一個很重要的技術。通過虛擬內存的映射機制,使得每個進程都可以擁有非常大而且完全隔離和獨立的內存空間。操作系統對虛擬內存的分配和管理是以頁為單位,當將一個可執行文件或者動態庫加載到內存中執行時,操作系統會將文件中的代碼段部分和數據段部分的內容通過內存映射文件的形式映射到對應的虛擬內存區域中。程序執行的代碼所在的代碼段部分總是被分配在一片具有可執行權限的虛擬內存區域中,不同的操作系統對可執行代碼所處的內存區域要求的不同,就比如iOS系統來說,可執行代碼所在的虛擬內存區域的權限只能是可執行的,否則就會產生系統崩潰,這也就是說我們不可以在具有可讀寫權限的內存區域中(比如堆內存或者棧內存空間)動態的構造出指令來供CPU執行。也就是說在iOS系統中不支持將某段內存的保護機制先設置為讀寫以便填充好數據后再設置為可執行的保護機制來實現動態的指令構造(也就是所謂的JIT技術)。不過好在操作系統提供了虛擬內存的remap機制來解決這個問題。所謂虛擬內存的remap機制就是可以將新分配的虛擬內存頁重新映射到已經分配好的虛擬內存頁中,新分配的虛擬內存頁可以和已經存在的虛擬內存頁中的內容保持一致,并且可以繼承原始虛擬內存頁面的保護權限。虛擬內存的remap機制使得進程之間或者進程內中的虛擬內存共享相同的物理內存。

虛擬內存到物理內存之間的映射

從上面的圖中可以得出一些結論:

  1. 無論是物理內存還是虛擬內存的管理都是以頁為單位來進行管理的,并且一般情況下二者的尺寸保持一致。
  2. 操作系統為每個進程建立一張進程頁表,頁表記錄著虛擬內存頁到物理內存頁的映射關系以及相關的權限。并且頁表是保存在物理內存頁中的。因此所謂的虛擬內存分配其本質就是在頁表中建立一個從虛擬內存頁到物理內存頁的映射關系而已。而所謂的remap就是將不同的虛擬頁號映射到同一個物理頁號而已。就如例子中進程1的第1頁和第4頁都是映射在同一個6號物理頁中。
  3. 不同進程之間的不同虛擬頁號可以映射到相同的物理頁號。這樣的一個應用是解決動態庫的共享加載問題,比如UIKit這個框架庫在第一個進程運行時被加載到內存中,那么當第二個進程運行時并且需要UIKit庫時就不再需要重新從文件加載內存中而是共享已經加載到物理內存的UIKit動態庫。上面的例子中進程1的第5頁和進程2的第7頁共享相同的物理內存第9頁。
  4. 操作系統還會維持一個全局物理頁空閑信息表,用來記錄當前未被分配的物理內存。這樣一旦有進程需要分配虛擬內存空間時就從這個表中查找空閑的區域進行快速分配。

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的能力?,F在將上述的代碼改動一下:

     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塊的能力

  1. 首先在你的工程里面添加一個后綴為.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

  1. 然后我們在另外一個文件中實現排序的代碼:
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函數指針的實現方法。


歡迎大家訪問歐陽大哥2013的github地址簡書地址

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372

推薦閱讀更多精彩內容