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

歐陽大哥2013發表於2019-02-27

本文導讀:虛擬記憶體以及虛擬記憶體的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的能力。現在將上述的程式碼改動一下:

     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地址

相關文章