深入iOS系統底層之映像檔案操作API介紹
iOS系統生成的可執行程式或者動態庫檔案的儲存佈局格式被稱之為mach-o格式。檔案中存放著程式的程式碼和資料,而程式執行時系統會為其建立一個程序,以及分配虛擬記憶體空間。同時會把程式檔案中的內容載入到虛擬記憶體地址空間中去,這種載入的方法一般採用記憶體對映檔案的技術來實現。所謂的映像可以理解為將一個程式檔案的內容載入到程序虛擬記憶體中的內容,也就是說 程序的映像就是程式磁碟檔案在記憶體中的一個副本。 一般來說一個程序中映像的內容和記憶體佈局結構會和程式檔案的內容以及儲存佈局結構一致,映像的首地址是一個 struct mach_header
的結構體指標。映像中內容的排列布局和程式檔案都是以段(Segment)為單位進行排列的。但是有一些情況映像的記憶體佈局和內容可能會和程式檔案的記憶體佈局和內容不一致:
-
映像中的資料段部分,因為資料段部分大多是可以被讀寫訪問的,也就是說可以在執行時被修改,或者某些資訊會進行rebase處理。因此資料段不能被程序之間共享,而是每個程序單獨維護一份。當然為了效率和效能系統會採用一種稱之為Copy on write的技術來實現單獨副本的拷貝的。通常只有不可變的程式碼段部分才會是記憶體和檔案中的內容保持一致,並且多程序共享。一個很常見的例子就是程序中載入的動態庫和框架中的程式碼段部分通常都是所有程序共享。
-
即使是程式碼段也有可能映像中的內容和程式檔案中的內容不一致。有一些映像中的某些段的內容會是系統中快取的段,而不是程式檔案對應的段。一個很有代表性的例子就是CoreLocation這個庫,當這個庫被載入時你就會發現其映像中的有一些程式碼段的內容其實是系統快取的內容而不是程式檔案中的內容。
所以說程式檔案和程式被載入後在記憶體中映像之間並不是一一對應的。程式檔案和映像之間的關係就如程式和程序之間的關係是一樣的。在程式執行後對其在程序中所有的mach-o資料結構的訪問都是基於映像而不是基於程式檔案的。
Slide機制
構建一個程式時為了方便計算和處理會為這個程式設定一個預設在記憶體中載入的基地址。這樣在程式中所有涉及到地址儲存的程式碼中的地址變數都是以這個基地址為標準的。比如我們在程式碼中有變數儲存一個函式的地址或者在rumtime中的OC類的方法結構體: struct method_t
中的imp儲存的函式的地址等等。正常情況下如果我們的程式載入時也是按照程式中指定的基地址載入到虛擬記憶體中對應的地址時則一切都正常而且也不需要做任何的改變。但實際情況則不同:
- 任何一個庫或者可執行程式在構建時都會指定一個載入的基地址,但是卻無法保證這個基地址的唯一性。和無法保證程式映像的地址區間不產生重疊。因此有可能出現多個庫載入到記憶體時的重疊覆蓋的情況。
- iOS系統為保證的應用安全採用了一種稱之為**ASLR(Address space layout randomization)**的技術。這種技術會使得每個程式或者庫每次執行載入到記憶體中時的基地址都不是固定而是隨機的,這種機制會增加黑客的破解難度。
上面的兩種情況表明一個程式或者庫載入到記憶體時的真實的基地址和程式構建時指定的基地址是不一樣的。系統會為可執行程式和每個庫選擇不重疊的區域進行載入。但是這樣就會出現在程式中所有以構建時基地址為標準的那些地址指標出現訪問異常,因為這些地址值並不是真實在記憶體中的地址值。
為了解決這個問題系統會在構建的程式或庫中新增一個特殊的load command命令:LC_DYLD_INFO或者LC_DYLD_INFO_ONLY。這部分資訊用來記錄所有需要進行地址調整的位置。這樣當程式被載入到記憶體時,載入器就會將需要調整的地址分別進行調整處理,以便轉化為真實的記憶體地址。這個過程稱之為基地址重定向(rebase)。
假設程式構建時指定的基地址為A,程式中某處儲存的一個函式指標地址為x,而程式被載入到記憶體時的真實基地址為B。也就是說真實的基地址和構建時的基地址的偏移差就是B-A。我們稱這個偏移差值為Slide值。因此真實的地址x被調整後應該是: x + (B - A)了。
一個程式在構建時的基地址值可以在程式的第一個名為__TEXT的程式碼段描述結構體 struct segment_command
中的vmaddr資料成員中獲取,而程式被載入後的得到的映像的mach-o頭部結構體 struct mach_header
指標則是映像被載入的真實的基地址,因此:
映像的Slide值 = 映像的mach_header結構體指標 - 映像的第一個__TEXT程式碼段描述結構體 struct segmeng_command
中的vmaddr資料成員的值。
當然系統也提供了介面API來獲取可執行程式或者庫的映像的Slide值。這個將會在下面介紹。
段(Segment)和節(Section)
mach-o檔案由諸多的load command組成,每個load command所代表的是一種資料型別。比如有的load command是用來存放程式程式碼和全域性變數資料,有的load command是用來存放符號表,有的load command是用來存放程式碼簽名信息等。每種load command都是結構體 struct load_command
的擴充套件結構體。其中的cmd欄位用來描述這種load command的型別。
型別為LC_SEGMENT或者為LC_SEGMENT_64的load command被稱之為段(Segment)。一個可執行程式中的程式碼和全域性變數資料都儲存在段中。描述段的資訊是一個 struct segment_command
結構體。一個程式中可以存在著很多的段,每個段有一個唯一的段名(segment name)。比如一個可執行程式中所有的程式碼都儲存在名字為:__TEXT的程式碼段中,而所有的資料都儲存在名字為:__DATA的資料段中。段以頁為邊界進行對齊。
每個段則由多個節(Section)組成。節是內容分類的最小管理單元。每個節的描述資訊是一個稱之為: struct section
的結構體。每個節有一個唯一的名稱用來標識這個節。比如程式碼段中有一個名為:__text的節用來儲存程式中使用者編寫的原始碼對應的機器指令,而一個名為:__stub_helper的節則儲存所有呼叫的外部函式的樁程式碼。下面的一張圖展示的就是程式中的段和節的結構佈局:

程序映像(Image)操作API
對映像進行操作的API都在 <mach-o/dyld.h>
中宣告。你可以import這個標頭檔案來使用裡面定義的函式。下面我會分別介紹這些函式。
1.獲取當前程序中載入的映像的數量
//函式返回當前程序中載入的映像的數量 uint32_t_dyld_image_count(void) 複製程式碼
2.獲取某個映像的mach-o頭部資訊結構體指標
const struct mach_header*_dyld_get_image_header(uint32_t image_index) 複製程式碼
函式的入參為映像在程序當中的索引號,函式返回的值是一個映像的mach-o頭部資訊 struct mach_header
結構體指標,如果是64位系統則返回的是 struct mach_header_64
結構體指標。你可以通過這個函式返回的映像的頭部結構體來遍歷和訪問映像中的所有資訊和資料。
一個映像的頭部資訊結構體指標其實就是映像在記憶體中載入的基地址。
一般情況下索引為0的映像是dyld庫的映像,而索引為1的映像就是當前程序的可執行程式映像。
系統還提供一個沒有在標頭檔案中宣告的函式:
const struct mach_header* _NSGetMachExecuteHeader() 複製程式碼
這個函式返回當前程序的可執行程式映像的頭部資訊結構體指標。因為這個函式沒有在某個具體的標頭檔案中被宣告,所以當你要使用這個函式時需要在原始碼檔案的開頭進行宣告處理:
extern const struct mach_header* _NSGetMachExecuteHeader(); 複製程式碼
3.獲取程序中某個映像載入的Slide值
intptr_t_dyld_get_image_vmaddr_slide(uint32_t image_index) 複製程式碼
函式的入參為映像在程序當中的索引號,函式的返回值是映像載入的Slide值。關於Slide值的介紹已經在上面有詳細說明。在mach-o格式程式中的結構體描述資訊中凡是涉及到指標欄位都應該加上這個值才是真實的記憶體地址。
4.獲取程序中某個映像的名稱
const char*_dyld_get_image_name(uint32_t image_index) 複製程式碼
函式的入參為映像在程序當中的索引號,函式的返回值是映像對應庫的全路徑名稱,返回的字串我們不能修改也不必去銷燬它。
5.註冊映像載入和解除安裝的回撥通知函式
void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide)) void _dyld_register_func_for_remove_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide)) 複製程式碼
如果你通過函式 _dyld_register_func_for_add_image
註冊了一個映像被載入時的回撥函式時,那麼每當後續一個新的映像被載入但未初始化前就會呼叫註冊的回撥函式,回撥函式的兩個入參分別表示載入的映像的頭結構和對應的Slide值。如果在呼叫 _dyld_register_func_for_add_image
時系統已經載入了某些映像,則會分別對這些載入完畢的每個映像呼叫註冊的回撥函式。
如果你通過函式 _dyld_register_func_for_remove_image
註冊了一個映像被解除安裝時的回撥函式時,那麼每當一個映像被解除安裝前都會呼叫註冊的回撥函式,回撥函式的兩個入參分別表示解除安裝的映像的頭結構和對應的Slide值。
這兩個函式的作用通常用來做程式載入映像的監控以及一些統計處理。
6.獲取某個庫連結時和執行時的版本號
//獲取庫執行時的版本號 int32_t NSVersionOfRunTimeLibrary(const char* libraryName) //獲取庫連結時的版本號 int32_t NSVersionOfLinkTimeLibrary(const char* libraryName) 複製程式碼
我們在XCODE工程中連結一些系統動態庫時,有時候會選擇某個具體版本的動態庫,但是有些作業系統可能不一定會提供對應版本的動態庫,這樣就會導致程式執行時載入的動態庫版本和連結時指定的動態庫的版本不一致。還有一種場景就是工程中並沒有連結對應的動態庫,但是因為其他庫會連結對應的動態庫,就會出現雖然沒有直接連結對應的動態庫但是還是會載入對應的動態庫的情況。 因此係統提供了這兩個API可以獲取某個動態庫連結和載入執行時的版本號。這兩個函式的入參都是動態庫的名稱,這個名稱是不帶路徑和副檔名以及不帶lib字首的庫名稱。函式返回庫對應的版本號,如果庫不存在或者沒有被載入或者沒有被連結則返回-1。比如下面的程式碼:
//這裡的名稱c++其實是指的libc++.dylib這個庫。 uint32_t v1 =NSVersionOfRunTimeLibrary("c++"); uint32_t v2 =NSVersionOfLinkTimeLibrary("c++"); 複製程式碼
如果我們的程式並沒有顯示的連結libc++.dylib則後者函式會返回-1。而前者則一般都會返回一個對應的libc++的版本號。
這兩個函式的主要用來做一些庫分析和執行監測等功能,比如可以檢測某個庫是否是一個在執行時被載入而不是顯示連結進來的動態庫。
7.獲取當前程序可執行程式的路徑檔名
int _NSGetExecutablePath(char* buf, uint32_t* bufsize) 複製程式碼
函式的入參buf和bufsize指明儲存可執行檔案路徑名的快取和快取的尺寸,其中的bufsize是要指明快取的尺寸,並且會輸出可執行檔案路徑名稱的真實尺寸。如果函式呼叫返回正確則返回0,否則返回-1。就比如下面的例子:
char buf[256]; uint32_t bufsize = sizeof(buf)/sizeof(char); _NSGetExecutablePath(buf, &bufsize); 複製程式碼
8.註冊當前執行緒結束時的回撥函式
void _tlv_atexit(void (*termFunc)(void* objAddr), void* objAddr) 複製程式碼
有時候我們想監控執行緒的結束事件,那麼就可以用這個函式來實現。這個函式用來監控當前執行緒的結束,當執行緒結束或者終止時就會呼叫註冊的回撥函式, _tlv_atexit
函式有兩個引數:第一個是一個回撥函式指標,第二個是一個擴充套件引數,作為回撥函式的入參來使用。
不明白為什麼這個函式會放在<mach-o/dyld.h>中宣告,完全不搭界!
段(Segment)和節(Section)操作API
對段和節進行操作的API都在 import <mach-o/getsect.h>
中宣告。你可以import這個標頭檔案來使用裡面定義的函式。當然如果你瞭解mach-o的檔案格式的話可以不用這些API,而是直接根據映像的頭部結構體 struct mach_header
來遍歷和訪問這些段和節。不過既然系統已經提供相關的API,那麼還是優先考慮用它們最合適了。下面我會分別介紹這些函式。
段和節操作的API在系統的libmacho.dylib庫中實現,這個庫暫時還沒有開源出來。
1. 獲取程序中映像的某段中某個節的非Slide的資料指標和尺寸
//獲取程序中可執行程式映像的某個段中某個節的資料指標和尺寸。 char *getsectdata(const char *segname, const char *sectname, unsigned long *size) //獲取程序載入的庫的segname段和sectname節的資料指標和尺寸。 char *getsectdatafromFramework(const char *FrameworkName, const char *segname, const char *sectname, unsigned long *size); 複製程式碼
這兩個函式返回程序中可執行程式映像或者某個載入的動態庫中的某個段中某個節的資料指標和尺寸。這兩個函式其實就是返回對應的節描述資訊結構 struct section
中的addr和size兩個資料成員的值。需要注意的是返回的地址值是沒有加上Slide值的指標,因此當我們要在程序中訪問真實的地址時需要加上對應的Slide值,下面就是一個例項程式碼:
//一般索引為1的都是可執行檔案映像 intptr_tslide = _dyld_get_image_vmaddr_slide(1); unsigned long size = 0; char *paddr = getsectdata("__TEXT", "__text", &size); char *prealaddr = paddr + slide;//這才是真實要訪問的地址。 複製程式碼
getsectdata函式的程式碼實現如下:
//假設是64位的系統 char *getsectdata(const char *segname, const char *sectname, unsigned long *size) { const struct mach_header_64 *mhp =_NSGetMachExecuteHeader(); //這個函式會在下面介紹到。 returngetsectdatafromheader_64(mhp, segname, sectname, size); } 複製程式碼
個人不建議用這個函式而是用下面會介紹到的getsectiondata函式更合適。
2.獲取段和節的邊界資訊
//獲取當前程序可執行程式映像的最後一個段的資料後面的開始地址。 unsigned long get_end(void); //獲取當前程序可執行程式映像的第一個__TEXT段的__text節的資料後面的開始地址。 unsigned long get_etext(void); //獲取獲取當前程序可執行程式映像的第一個_DATA段的__data節的資料後面的開始地址 unsigned long get_edata(void); 複製程式碼
這幾個函式主要用來獲取指定段和節的結束位置,以及用來確定某個地址是否在指定的邊界內。需要注意的是這幾個函式返回的邊界值是並未加Slide值的邊界值。下面是這幾個函式的內部實現:
unsigned long get_end() { unsigned long end = 0; const struct mach_header_64 *mhp =_NSGetMachExecuteHeader(); struct segment_command_64 *psegcmd = mhp + 1; for (int i = 0; i < mhp->ncmds; i++) { if (psegcmd->cmd != LC_SEGMENT_64) break; end = psegcmd->vmaddr + psegcmd->vmsize; psegcmd += 1; } return end; } unsigned long get_etext() { const struct section_64 *sec = getsectbyname("__TEXT","__text"); return psection->addr + psection->size; } unsigned long get_edata() { const struct section_64 *sec = getsectbyname("__DATA","__data"); return psection->addr + psection->size; } 複製程式碼
3.獲取程序中可執行程式映像的段描述資訊
//獲取程序中可執行程式映像的指定段名的段描述資訊 const struct segment_command *getsegbyname(const char *segname) //上面函式的64位版本 const struct segment_command_64 *getsegbyname(const char *segname) 複製程式碼
這兩個函式返回程序中可執行程式映像的某個段的段描述資訊。段描述資訊是一個 struct segment_command
或者 struct segment_command_64
結構體。
比如下面程式碼返回程序中可執行程式映像程式碼段__TEXT的段資訊。
const struct segment_command_64 *psegment = getsegbyname("__TEXT"); 複製程式碼
4.獲取程序中可執行程式映像的某個段中某個節的描述資訊
//獲取程序中可執行程式映像的某個段中某個節的描述資訊。 const struct section *getsectbyname(const char *segname,const char *sectname) //上面對應函式的64位系統版本 const struct section_64 *getsectbyname(const char *segname, const char *sectname) 複製程式碼
這兩個函式分別返回32位系統和64位系統中的程序中可執行程式映像的segname段中的sectname節的描述資訊。節的描述資訊是一個 struct section
或者 struct section_64
的結構體。比如下面的程式碼返回程式碼段__TEXT中的程式碼節__text的描述資訊:
struct section_64 *psection = getsectbyname("__TEXT","__text"); 複製程式碼
5.獲取程序中映像的段的資料
//獲取指定映像的指定段的資料。 uint8_t *getsegmentdata(const struct mach_header *mhp, const char *segname, unsigned long *size) //上面函式的64位版本 uint8_t *getsegmentdata(const struct mach_header_64 *mhp, const char *segname, unsigned long *size) 複製程式碼
函式返回程序內指定映像mhp中的段segname中內容的地址指標,而整個段的尺寸則返回到size所指的指標當中。這個函式的內部實現就是返回段描述資訊結構 struct segment_command
中的vmaddr資料成員的值加上映像mhp的slide值。而size中返回的就是段描述資訊結構中的vmsize資料成員。
因為在前面講過因為映像載入時的slide值的緣故,所以映像中的各種mach-o結構體中涉及到地址的資料成員的值都需要加上slide值才能得到映像在記憶體中的真實載入地址。
程序中每個映像中的第一個__TEXT段的資料的地址其實就是這個映像的mach_header頭結構的地址。這是一個比較特殊的情況。
下面的程式碼演示的是獲取程序中第0個索引位置映像的__DATA段的資料。
struct mach_header_64 *mhp = _dyld_get_image_header(0); unsigned long size = 0; uint8_t *pdata = getsegmentdata(mhp,"__DATA", &size); 複製程式碼
6.獲取程序映像的某段中某節的資料
//獲取程序映像中的某段中某節的資料地址和尺寸。 uint8_t *getsectiondata(const struct mach_header *mhp, const char *segname, const char *sectname, unsigned long *size) //上面函式的64位版本 uint8_t *getsectiondata(const struct mach_header_64 *mhp, const char *segname, const char *sectname, unsigned long *size) 複製程式碼
函式返回程序內指定映像mhp中的段segname中sectname節中內容的地址指標,而整個節的尺寸則返回到size所指的指標當中。這個函式的內部實現就是返回節描述資訊結構 struct section
中的addr資料成員的值加上映像mhp的slide值。而size中返回的就是段描述資訊結構中的size資料成員的值。
因為在前面講過因為映像載入時的slide值的緣故,所以映像中的各種mach-o結構體中涉及到地址的資料成員的值都需要加上slide值才能得到映像在記憶體中的真實載入地址。
下面的例子獲取程序中第0個映像的"__TEXT"段中的"__text"節的資料地址指標和尺寸:
struct mach_header_64 *mhp = _dyld_get_image_header(0); unsigned long size = 0; uint8_t *pdata = getsectiondata(mhp,"__TEXT", "__text", &size); 複製程式碼
7.獲取mach-O檔案中的某個段中某個節的描述資訊
//獲取指定mach-o檔案中的某個段中某個節中的描述資訊 const struct section *getsectbynamefromheader(const struct mach_header *mhp, const char *segname, const char *sectname) //獲取指定mach-o檔案中的某個段中某個節中的描述資訊。fSwap傳NXByteOrder列舉值。 const struct section *getsectbynamefromheaderwithswap(struct mach_header *mhp, const char *segname, const char *sectname, int fSwap) //上面對應函式的64位系統版本 const struct section_64 *getsectbynamefromheader_64(const struct mach_header_64 *mhp, const char *segname, const char *sectname) //上面對應函式的64位系統版本 const struct section *getsectbynamefromheaderwithswap_64(struct mach_header_64 *mhp, const char *segname, const char *sectname, int fSwap) 複製程式碼
這一系列函式分別返回32位系統和64位系統的mach-o檔案的節的描述資訊。每個函式都有segname和sectname分別指明要獲取的段名和節名。引數mhp則表明mach-o檔案的頭部結構指標。對於有一些系統或者mach-o檔案中的數值採用big-endian來編碼,因此對於這些採用big-endian編碼的結構來說就需要傳遞fSwap來確定是否交換這些編碼。
這一系列函式中的mhp結構不侷限於程序中的映像的頭部結構,針對mach-o檔案的頭部結構也適用,如果你不瞭解映像和檔案的區別則請看文章中的開頭的介紹。
因為不管是程序中的映像的Section的排列以及mach-o檔案中的Section的排列都是一致的,因此其實上述的getsectbyname的實現就是藉助本節提供的函式實現的,其實現的程式碼如下:
const struct section_64 *getsectbyname( const char *segname, const char *sectname) { const struct mach_header_64 *mhp =_NSGetMachExecuteHeader(); return getsectbynamefromheader_64(mhp, segname, sectname); } 複製程式碼
8.獲取mach-o檔案中的某段中的某個節的資料指標和尺寸
//獲取指定mach-o檔案中的某個段中的某個節的資料指標和尺寸 char *getsectdatafromheader(const struct mach_header *mhp, const char *segname, const char *sectname, uint32_t *size) //64位系統函式 char *getsectdatafromheader_64(const struct mach_header_64 *mhp, const char *segname, const char *sectname, uint64_t *size) 複製程式碼
這兩個函式返回32位系統或者64位系統中的某個mach-o檔案中的某個段中某個節的資料指標和尺寸。這兩個函式其實就是返回對應的節描述資訊結構struct section中的addr值和size值。因為這兩個函式是針對mach-o檔案的,但是也可以用在對應的庫映像中,當應用在庫映像中時就要記得對返回的結果加上對應的slide值才是真實的節資料所對應的地址!
一個非常有用的DEMO
iOS系統提供了所謂方法交換(method swizzling)的黑魔法機制。它可以在執行時替換掉某個類的某個方法的預設實現。然而技術有兩面性,對於越獄系統來說,惡意開發人員可以通過動態庫注入並利用方法交換的技巧來改變程式執行的原有邏輯,從而可以跨過一些常規檢測而謀取非法利益。
凡事有攻就有守,通過本文中介紹的API函式就可以在一定程度上檢測某個類中的某個方法是否被非法HOOK。以可執行程式中的某個類的例項方法為例。可執行程式中定義的類的例項方法的實現地址總是在可執行程式映像的地址區間範圍內,即使是這個方法被可執行程式中的其他方法HOOK了,這個HOOK的方法地址仍然是在可執行程式的映像地址區間範圍內,我們仍然認為這是一個合法的HOOK。如果可執行程式中的類的例項方法被惡意攻擊者通過動態庫注入並以方法交換的形式來HOOK原有方法的實現時,因為HOOK的方法地址是在惡意注入的動態庫映像的地址區間範圍內,所以我們就可以通過檢測這個類的例項方法的實現地址是否在可執行程式的映像的地址區間範圍內來判斷這個方法是否被惡意HOOK了。下面就是這種檢測的具體實現程式碼,建議檢測的程式碼用C函式來實現而不是用OC類的方法來實現,否則這個檢測邏輯也有可能被HOOK。
//Author by 歐陽大哥 #import <mach-o/dyld.h> #import <mach-o/getsect.h> BOOL checkMethodBeHooked(Class class, SEL selector) { //你也可以藉助runtime中的C函式來獲取方法的實現地址 IMP imp = [class instanceMethodForSelector:selector]; if (imp == NULL) return NO; //假設第1個索引的位置是可執行程式的映像,你也可以用_NSGetMachExecuteHeader函式返回可執行程式映像的頭部。 struct mach_header *mhp = _dyld_get_image_header(1); intptr_t slide =_dyld_get_image_vmaddr_slide(1); unsigned long startpos = (unsigned long)mhp; unsigned long endpos = get_end() + slide; unsigned long imppos = (unsigned long)imp; return (imppos < startpos) || (imppos > endpos); } 複製程式碼
目錄
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系統底層之動態庫介紹
19.深入iOS系統底層之常用工具和命令介紹