深入iOS系統底層之程式中的彙編程式碼

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

合抱之木,生於毫末;九層之臺,起於壘土;千里之行,始於足下。--(老子·道德經 )

對於一個閉源系統來說如果想研究某些邏輯的內部實現就需要對組合語言進行掌握和了解、對於某些需要高效能實現的邏輯來說用匯編語言實現可能是最好的選擇、對於某些邏輯來說可能只能用匯編來實現。以最後一個能力來說:當我們要實現一個HOOK所有OC方法呼叫的邏輯時,因為HOOK的方法不能破壞原有函式的引數棧,而且還需要在適當的時候呼叫原始的函式而不關注原始函式的入參時就只能選擇用匯編語言來實現。

檢視程式的彙編程式碼

其實更多的時候我們不要求去編寫一段彙編程式碼或者機器指令,而是如果能夠讀懂簡單的彙編程式碼就能窺探一些系統底層的實現邏輯和原理。當然市面上也有很多的反彙編的工具軟體能夠將彙編程式碼轉化為高階語言的虛擬碼,缺點就是這些工具大多是靜態分析工具以及反彙編出來的程式碼不一定完全正確,有時候我們可能更加希望在執行時去除錯或者分析一些問題,這樣能夠閱讀彙編程式碼的話效果會更好一些。

檢視彙編程式碼的三種方法

Xcode提供了三種檢視程式彙編程式碼的方式:

  1. 在程式執行時的斷點處可以通過Debug選單->Debug Workflow->Always Show Disassembly來切換匯編程式碼模式和高階語言模式。
  2. 通過快捷鍵 alt + command + \ 可以對某個系統函式或者第三方庫函式或者類的方法設定符號斷點,這樣當程式出現相應的函式或者方法呼叫時就會切換到彙編程式碼模式。你可以通過這種方式來閱讀和了解函式或者方法的實現。
  3. 如果你想檢視某個高階語言檔案生成的偽彙編程式碼時,你需要在對應的檔案處通過Product選單->Perform Action->Assemble "xxxxx" 來檢視這個檔案生成的偽彙編程式碼。當你在模擬器模式下所看到的就是x64系統下的彙編程式碼,當你在裝置模式下時所看到的就是arm系統下的彙編程式碼。

clang命令的簡單介紹

通過上述的第三種方式檢視生成的彙編程式碼的方式其實是通過clang命令完成的。clang是一個C/C++/Objective-C語言的編譯器,它包含了預處理、語法分析、優化、程式碼生成、彙編裝配、連結等功能。我們通過選單來進行的構建程式的操作其實內部實現都是藉助clang來完成的。你可以在命令終端中鍵入man clang來檢視這個命令的所有引數和使用介紹,你還可以在Xcode工程中使用command + 9快捷鍵就可以看到你每次構建工程的詳細流程,這裡面有對程式使用clang命令的進行編譯和連結的具體實踐。

程式編譯連結命令流程圖

可以看出無論是原始碼編譯還是程式連結都是用clang命令來實現的,不要被命令中大量的編譯連結選項所嚇倒,其實這些引數都是我們在視覺化的工程的Build Settings裡面設定的

要想了解完整的編譯選項的設定和意義可以參考:pewpewthespells.com/blog/builds…

我們只介紹clang命令的幾個主要的引數選項:


  clang  [-arch <arm|arm64|x86_64>] [-x <objective-c|objective-c++|c|c++|assembler-with-cpp>] [-L<庫路徑>] [-I<標頭檔案路徑>] [-F<框架標頭檔案路徑>] [-isysroot 系統SDK路徑] [-fobjc-arc | -fno-objc-arc] [-lxxx] [-framework XXX] [-Xlinker option] [-Xlinker value] [-E 原始碼檔案] [-rewrite-objc 原始碼檔案] [-c 原始碼檔案] [-S 原始碼檔案] [-filelist LinkFileList檔案] [-o 輸出檔案]  

複製程式碼

1.常規引數

-arch <arm|arm64|x86_64|i386>: 生成的程式碼的體系結構,四選一。

-x <objective-c|objective-c++|c|c++|assembler-with-cpp: 指定編譯的檔案的語言,五選一,預設為objective-c。這個選項用在編譯階段。

-I<標頭檔案路徑>: 指定#import或者#include .h檔案的搜尋路徑。

-L<庫路徑>: 指定連結時的動態庫或者靜態庫檔案的搜尋路徑。這個選項用在連結階段。

-F<框架標頭檔案路徑>: 指定#import一個框架庫時的標頭檔案搜尋路徑。

-isysroot 系統SDK路徑: 指定程式使用的系統框架SDK的路徑。比如: -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.1.sdk 表明使用真機版的iOS12.1版本的SDK來編譯或者連結當前程式。

-fobjc-arc | -fno-objc-arc: 表明當前程式是使用arc編譯還是mrc來編譯。

-lxxx: 只在連結時使用,表明將名字為libxxx的庫連結到程式中來。

-framework XXX: 只在連結時使用,表明將名字為XXX的framework庫連結到程式中來。

-Xlinker option -Xlinker value: 設定連結的選項,這裡必須要成對出現,其意義表示: option = value。

2.預處理

-E 原始碼檔案 -o 輸出檔案: 對原始碼進行預處理。也就是將所有#include和#import的標頭檔案展開、將所有巨集定義展開、將所有列舉值轉化為常量值的處理。你可以藉助**Product選單->Perform Action->Preprocess "xxxxx"**來檢視一個原始碼檔案的預處理結果。

3.生成C++程式碼

-rewrite-objc 原始碼檔案: 將OC程式碼轉化為對應的C++語言實現。並在原始碼檔案的當前目錄下生成一個對應的字尾為.cpp的C++程式碼。你可以通過這種方法來詳細瞭解arc的實現原理、block的實現以及呼叫原理、各種OC關鍵字的實現邏輯原理、OC類屬性和方法的實現邏輯、類方法的定義以及runtime的機制等等邏輯。因此用這個引數可以幫助我們窺探很多iOS系統的祕密。在使用這個命令時可能會遇到一個常見的錯誤:

In file included from xxxx.m:9:
xxxx.h:9:29: fatal error: module 'UIKit' not found
#pragma clang module import UIKit /* clang -E: implicit import for #import <UIKit/UIKit.h> */
                     ~~~~~~~^~~~~
1 warning and 1 error generated.

複製程式碼

這個主要是因為找不到系統SDK的路徑檔案所致,因此可以帶上-isysroot引數來同時指定系統SDK路徑。下面就是一個使用的示例:


clang -rewrite-objc -arch arm64  -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.1.sdk xxxx.m

複製程式碼

這裡的-isysroot後面的路徑要確保是對應系統SDK的路徑,同時-arch中的值要和路徑中的SDK要是相同的結構體系。

4.生成彙編程式碼

-S 原始碼檔案 -o 輸出檔案: 要將某個原始碼檔案生成彙編程式碼時需要在 -S 引數後面指定原始碼檔案。而-o 後面的輸出檔案就是對應的彙編程式碼檔案,一般這個輸出檔案以.s為副檔名。這裡要注意同時使用-arch引數指定輸出的體系架構。

5.編譯

-c 原始碼檔案 -o 輸出檔案:要編譯某個原始碼檔案時使用這兩個引數選項,其中-c後面跟著的是要編譯的原始碼檔案,而-o後面輸出的是.o為副檔名的目標檔案。

6.連結

-filelist LinkFileList檔案 -o 輸出檔案: 執行連結時要把所有目標.o檔案作為輸入引數,但是為了管理方便可以將這些.o檔案的路徑儲存到一個副檔名為.LinkFileList的檔案中,然後再使用-filelist 引數後面跟隨對應的.LinkFileList檔案來指定目標檔案集合。而-o後面的輸出檔案就是對應的可執行程式檔案。

工程中引入彙編程式碼

你也可以在xcode工程中直接引入彙編程式碼或者使用匯編程式碼來編寫程式和函式,新增彙編檔案的方法是:File選單->New->File...->在列表中選擇:Assembly File即可。一般情況下彙編程式碼都是以.s為副檔名,生成的檔案是一個空檔案,然後你就可以在檔案裡面編寫對應的彙編程式碼了。系統也支援在彙編程式碼中設定斷點進行除錯。因為iOS系統支援多種體系結構,所以可以在彙編程式碼中使用幾個巨集來區分程式碼是x86_64的還是arm或者arm64的, 就比如下面的程式碼:

//你可以像高階語言一樣通過#include引入標頭檔案。
#include <xxx.h>

//arm體系
#ifdef __arm__

//指令和資料定義

//arm64體系
#elif __arm64__

//指令和資料定義

//x86 32位體系
#elif __i386__

//指令和資料定義

//x86_64位體系
#elif __x86_64__

//指令和資料定義

//其他體系
#else

#endif

複製程式碼

當你在專案中新增了一個彙編檔案時,就需要掌握和了解彙編程式碼的編寫。關於彙編指令的詳細描述由於太過龐大這裡就不介紹了,這裡主要介紹一些常用的彙編關鍵字,以便幫助大家能更好的閱讀和編寫程式。

常見的彙編語法

在Xcode中無論是AT&T還是arm組合語言的關鍵字都以.開頭。編寫彙編程式碼主要就是資料的定義以及程式碼指令。一個組合語言檔案中還可以使用和C語言類似的檔案引入以及各種預編譯指令,還可以引用高階語言中定義的變數和符號以及函式。

1.註釋

彙編指令中註釋和C/C++/OC相同。arm體系下的彙編程式碼特有的行註釋是程式碼後面的 ;號註釋,而x86_64體系下的彙編程式碼的特有的行註釋是##。

2.節

無論是指令還是資料管理的單位都是節(Section)。因為在iOS系統的mach-o檔案格式中的資料和指令的儲存都是以段(Segment)和節為單位劃分的。任何程式碼和資料總是在某個節內被定義。每個節都歸屬於某個段,每個節有一個唯一的名字。節定義的關鍵字和語法如下:

.section <段名>,<節名>,<節屬性>

複製程式碼

相同的段名和節名可以出現在多出,資料和程式碼都是定義在由.section指定的節下開始,並結束於下一個節的定義開始處。系統最終在生成程式碼時會將相同的段名和節名的內容統一彙總到一起儲存。一般情況下所有的指令程式碼都是在__TEXT段下的節中被定義,而資料定義則是在__DATA段下的節中被定義。如果彙編程式碼中不指定節名則資料和程式碼預設是在__TEXT,__text下。系統還提供了兩個簡化程式碼段和資料段的節定義關鍵字。

//程式碼段的定義,等價於 .section __TEXT,__text
.text

//資料段的定義,等價於 .section __DATA,__data
.data
複製程式碼

在反彙編程式碼中的節定義中除了指定名稱外你還會看到一些比如:regular,pure_instructions,no_dead_strip,cstring_literals等等節定義的屬性。這些屬性所代表的意義和mach-o檔案格式中的結構體struct section_64中的flags欄位所表示的意義一致。flags可設定的值就是<mach-o/loader.h>中那些以S_開頭的巨集定義值。

3.標籤和符號

標籤是一個可被理解的地址偏移表示,是一個地址的別名。使用標籤的目標是為了讓程式程式碼更具有可讀性。標籤定義後可以在其他指令中引用,也可以在資料變數中被引用。標籤的定義規則為:

標籤名1:
//程式碼和資料
標籤名2:
//程式碼和資料
複製程式碼

標籤只是檔案內地址偏移的別名,只能在定義的檔案內部引用。要想讓這個標籤被外部引用和訪問就需要將標籤宣告為符號。高階語言檔案中定義的能被外部訪問的函式和全域性變數其實都是一個符號,不管是函式地址還是全域性變數的記憶體地址,其實都是一個地址位置,而地址的別名則是可以用標籤表示,因此要想將一個標籤定義為外部可訪問,就需要將標籤名宣告為符號。就如高階語言中的靜態函式和靜態變數以及全域性函式和全域性變數一樣,組合語言中的符號宣告也有兩種:

//對外可見的全域性符號,可以被外部程式引用和訪問。
.global  全域性符號名
全域性符號名:

//私有外部符號,只在程式內可引用和訪問。
.private_extern  私有外部符號名
私有外部符號名:
複製程式碼

符號名要和標籤名匹配。因為C語言的函式名稱以及全域性變數等符號在編譯時生成的符號前面新增一個下劃線_。所以在高階語言中的名稱對應的真實符號都是帶一個下劃線字首的,因此一般情況下我們在組合語言中宣告的符號和標籤名最好帶一個下劃線。並且在其他高階語言的宣告中不要使用這個下化線,就比如下面的例子:

//xxx.s

//在資料段中定義一個全域性變數符號_testSymbol。
.data
.global _testSymbol
_testSymbol:
.int 10

.............................................
//xxx.m

//高階語言中宣告使用這個符號。
extern int testSymbol;

int main(int argc, char *argv[])
{
   printf("testSymbol = %d",testSymbol);
   return 0;
}


複製程式碼

同時在彙編程式碼中引用高階語言定義的符號時,也要多帶上一個下劃線字首。

4.對齊

因為記憶體定址訪問的一些特性,要求我們的某些程式碼或者資料的存放地址必須是某個數字的倍數,也就是所謂的對齊。設定對齊的關鍵字如下:

//表明此處的地址是(2^3)8的倍數。這裡面p2align貌似和align所表達的意義相似,不知道為什麼會有兩個關鍵字。
.align 3
.p2align 3
複製程式碼

5.巨集定義

組合語言也可以和C語言一樣使用巨集定義,來做一些程式碼複用處理。巨集定義的語法如下:

//巨集的開始
.macro 巨集名稱

//這裡面可以編寫任何其他的彙編程式碼和關鍵字
// 巨集可以帶引數,巨集內使用引數總是從$0開始。
//巨集的結束
.endmacro

複製程式碼

在使用定義的巨集時就直接在相應的地方插入巨集的名字即可,如果巨集有引數則引數跟在巨集名稱後面並且引數之間以逗號分隔。下面就是一個巨集定義和使用的例子:

//巨集定義
.macro Test

mov x0, $0
mov x1, $1

.endmacro

//巨集使用
Test 10,20

複製程式碼

6.資料的定義

資料的定義類似C語言中變數的定義,彙編程式碼中也支援多種型別的資料定義。定義一個資料的語法如下:

.<資料型別>  值
複製程式碼

一共有如下的資料型別:

型別 描述 舉例
.byte 單個位元組 .byte 0x10
.long 長整型4位元組 .long 0x10
.quad 4倍型別,8位元組長度 .quad 0x10
.asciz 以0結尾的字串 .asciz "Hello world!"
.ascii 不以0結尾的字串 .ascii "Hello world!"
.space 空位元組數,後面跟數量 .space 4
.short 短整型2位元組 .short 0x10

資料型別的值可以是一個常量也可是一個表示式,也可以是一個標籤符號。如果我們想給某個資料定義指定一個類似於變數的名稱,則可以和標籤來結合。比如:

name:
.asciz "歐陽大哥"
age:
.long 13
nickname:
.quad name   //這裡的暱稱變數是一個指標表明和name是相同的。

複製程式碼

如果要想在程式碼塊中訪問上面定義了標籤名的變數,則可以採用如下指令:

//x86體系的指令訪問符號變數
leaq name(%rip), %rax
movl age(%rip), %ebx
movq nickname(%rip), %rcx

//arm64體系的指令訪問符號變數
adrp x0, name@PAGE
add x0, x0, name@PAGEOFF
adrp x1, age@PAGE
add x1, x1, age@PAGEOFF
ldr x1, [x1]
adrp x2, nickname@PAGE
add x2, x2, nickname@PAGEOFF

複製程式碼

7.函式的定義

組合語言中並沒有專門用於函式定義的關鍵字,組合語言中只有程式碼塊的定義,所有可執行的程式碼塊都存放在程式碼段中。所謂函式呼叫其實就是呼叫函式程式碼對應的首地址。因此對於檔案內的函式呼叫其實可以藉助標籤來完成,而其他檔案對函式的呼叫則可以藉助符號來完成。對於函式中的引數部分的處理則是按照函式呼叫引數傳遞的ABI規則來指定,具體詳情可以參考我的深入iOS系統底層之CPU暫存器介紹中的介紹。

下面就是一個求兩個引數和的加法函式在x86_64位體系結構下的實現:

//x86_64位下的函式實現
.text
.global _add
.align 3
_add:
movq  %rdi,%rbx
movq  %rsi,%rax
addq  %rbx,%rax
ret
LExit_add:

複製程式碼

8.指令的編寫

關於在組合語言中編寫指令這裡就不贅述了,否則一本書也說不完,大家可以參考相關的彙編程式碼的書籍即可,最好的方法是閱讀CPU體系結構手冊:

9.偽條件語句

組合語言有相應的進行比較和跳轉的指令,但是我們仍然可以藉助偽條件語句來使得我們的程式碼更加具有可讀性。偽條件語句的語法如下:

.if 邏輯表示式
.elseif 邏輯表示式
.else
.endif

複製程式碼

10.CFI: 呼叫框架指令

這部分偽指令以.cfi開頭。主要用來記錄函式的幀棧資訊和用於異常處理。具體的指令介紹請參考:blog.csdn.net/permike/art…

引用匯編程式碼檔案中的符號

因為彙編程式碼原始檔沒有所謂的.h標頭檔案宣告。所以當你在其他檔案中要想使用匯編語言中定義的函式或者全域性變數時,可以在你的原始碼檔案的頂部進行符號使用的宣告:

//xxxxx.m

//函式宣告
extern void 不帶下劃線的函式符號(引數列表);

//變數使用宣告
extern 型別 不帶下劃線的變數符號;

複製程式碼

在高階語言中嵌入彙編程式碼

我們還可以在高階語言中嵌入彙編程式碼,嵌入的主要目的是為了優化程式碼的效能,還有一些高階語言完成不了能力比如獲取當前執行指令的地址以及讀取一些狀態暫存器和特殊暫存器的值,還有一些場景甚至可以用匯編程式碼來解決高階語言需要用鎖來解決的多執行緒的問題等等。具體的嵌入方法和規則我這裡就偷一下懶,直接訪問這個連結:

blog.csdn.net/pbymw8iwm/a…

就可以很清楚的知道嵌入的規則了,這篇文章已經介紹得很仔細了。下面我將舉3個具體的例子:

  • 高階語言的變數作為嵌入彙編程式碼的輸入輸出
//計算兩個數相加
long add(long a, long b)
{
    long c = 0;
#if __arm64__
     __asm__(
             "ldr x11, %1\n"
             "ldr x12, %2\n"
             "add %0, x11, x12\n"
             :"=r"(c)
             :"m"(a),"m"(b)
             );
    
#elif __x86_64__
    
    __asm__(
            "movq %1,%%rdi\n"
            "movq %2,%%rsi\n"
            "addq %%rdi,%%rsi\n"
            "movq %%rsi,%0\n"
            :"=r"(c)
            :"m"(a),"m"(b)
            );
    
#else
        c = a + b;
#endif
    
    return c;
}

複製程式碼
  • 系統的特殊暫存器的值輸出給高階語言的變數
//列印當前指令的地址以及當前執行緒ID
void foo()
{
    unsigned long pc = 0;
    unsigned long threadid = 0;
    
#if __arm64__

      //arm64限制了直接讀寫PC暫存器的方式,而是改動相對偏移
      //TPIDRRO_EL0是指核心中的執行緒ID,用專門的指令mrs來讀取
      __asm__(
              "adr x0, #0\n"
              "stur x0, %0\n"
              "mrs %1,TPIDRRO_EL0\n"
              :"=m"(pc),"=r"(threadid)
              );
    
#elif __x86_64__
    //x86體系的CPU沒有專門的暫存器儲存執行緒ID
    __asm__(
            "leaq (%%rip), %%rdi\n"
            "movq %%rdi, %0\n"
            :"=m"(pc)
            );
#else
    NSAssert(0, @"oops!");
#endif
    
   
    NSLog(@"pc=%ld, threadid=%ld",pc, threadid);
    
}

複製程式碼
  • 無鎖多執行緒變數訪問 假設程式中定義了兩個變數x和y,現在A執行緒負責讀取這兩個變數的值進行處理,而B執行緒則負責寫入這兩個變數的最新值,這兩個變數具有關聯絡,必須同時寫入和讀取。如果是用高階語言來實現為了保證同步則需要在兩個執行緒的讀寫兩個變數的地方進行加鎖處理。而在arm體系結構下則可以藉助ldp,stp兩個條指令來實現指令級別上的原子操作,因為無需加鎖從而達到最佳的效能。
//假設x,y變數儲存在全域性變數critical陣列中。
long critical[2];

void read(long *px, long *py)
{
#if __arm64__
    __asm__(
            "ldp x9, x10, %2\n"
            "stur x9,%0\n"
            "stur x10,%1\n"
            :"=m"(*px),"=m"(*py):"m"(critical)
           );  
#else
    //其他體系結構在讀取時必須要加鎖處理。
    *px = critical[0];
    *py = critical[1];
#endif
}

void write(long x, long y)
{
#if __arm64__
    __asm__(
            "stp %1, %2, %0":"=m"(critical):"r"(x),"r"(y)
           );
#else
    //其他體系結構在寫入兩個變數時必須要加鎖處理。
    critical[0] = x;
    critical[1] = y;
#endif
}



複製程式碼

目錄

1.深入iOS系統底層之組合語言

2.深入iOS系統底層之指令集介紹

3.深入iOS系統底層之XCODE對彙編的支援介紹

4.深入iOS系統底層之CPU暫存器介紹

5.深入iOS系統底層之程式中的彙編程式碼

6.深入iOS系統底層之賦值指令介紹

7.深入iOS系統底層之函式幀棧

8.深入iOS系統底層之常見的彙編程式碼片段

9.深入iOS系統底層之ARC記憶體管理

10.深入iOS系統底層之異常實現和處理

11.深入iOS系統底層之執行緒實現原理

12.深入iOS系統底層之編譯連結過程介紹

13.深入iOS系統底層之MACH-O檔案格式介紹

14.深入iOS系統底層之程式載入過程介紹

15.深入iOS系統底層之映像檔案操作API介紹

16.深入iOS系統底層之靜態庫介紹

17.深入iOS系統底層之動態庫介紹

18.深入iOS系統底層之crash解決方法介紹

19.深入iOS系統底層之常用工具和命令介紹


歡迎大家訪問我的github地址

相關文章