深入理解Symbol

前言

符號(Symbol)是日常開發中經常接觸的一個概念,雖然日常開發中直接應用的場景比較少,但符號編譯期和運行時都扮演了重要的角色。

符號是什麼

維基百科的定義

A symbol in computer programming is a primitive data type whose instances have a unique human-readable form.

直觀理解,符號是一個數據結構,包含了名稱(String)和類型等元數據,符號對應一個函數或者數據的地址。

Symbol Table

符號表存儲了當前文件的符號信息,靜態鏈接器(ld)和動態鏈接器(dyld)在鏈接的過程中都會讀取符號表,另外調試器也會用符號表來把符號映射到源文件。

如果把調試符號裁剪掉(Deployment Postprocessing選擇爲YES),那麼文件裏的斷點會失效:

在這裏插入圖片描述

Release模式下是可以裁剪掉符號的,因爲release模式下默認有dsym文件,調試器仍然可以從中獲取到信息正常工作。

符號表中存儲符號的數據結構如下:

struct nlist_64 {
    union {
        uint32_t  n_strx; /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
};

字符串存儲在String Table裏,String Table的格式很簡單,就是一個個字符串拼接而成。符號的n_strx字段存儲了符號的名字在String Table的下標。

在這裏插入圖片描述

Dynamic Symbol Table

Dynamic Symbol Table是動態鏈接器(dyld)需要的符號表,是符號表的子集,對應的數據結構很簡單,只存儲了符號位於Symbol Table的下標:

➜ otool -I main 
main:
...
Indirect symbols for (__DATA,__la_symbol_ptr) 1 entries
address            index
0x000000010000c000     4 //對應符號表的idx爲4的符號
....

在這裏插入圖片描述

感興趣的同學可能會問,既然Dynamic Symbol Table只存儲了下標,這裏otool是如何知道這個Indirect symbol屬於__DATA,__la_symbol_ptr

答案是用section_64的reserved字段:如果一個section是__DATA,__la_symbol_ptr,那麼它的reserved1字段會存儲一個Dynamic Symbol Table下標。

struct section_64 { /* for 64-bit architectures */
  char    sectname[16]; /* name of this section */
  char    segname[16];  /* segment this section goes in */
  uint64_t  addr;   /* memory address of this section */
  uint64_t  size;   /* size in bytes of this section */
  uint32_t  offset;   /* file offset of this section */
  uint32_t  align;    /* section alignment (power of 2) */
  uint32_t  reloff;   /* file offset of relocation entries */
  uint32_t  nreloc;   /* number of relocation entries */
  uint32_t  flags;    /* flags (section type and attributes)*/
  uint32_t  reserved1;  /* reserved (for offset or index) */
  uint32_t  reserved2;  /* reserved (for count or sizeof) */
  uint32_t  reserved3;  /* reserved */
};

所以,對於位於__la_symbol_ptr的指針,我們可以通過如下的方式來獲取它的符號名:

  1. 遍歷load command,如果發現是__DATA,__la_symbol_ptr,那麼讀取reserved1,即__la_symbol_ptr的符號位於Dynamic Symbol Table的起始地址。
  2. 遍歷__DATA,__la_symbol_ptr處的指針,當前遍歷的下標爲idx,加上reserved1就是該指針對應的Dynamic Symbol Table下標
  3. 通過Dynamic Symbol Table,讀取Symbol Table的下標
  4. 讀取Symbol Table,找到String Table的Index
  5. 找到符號名稱

一張圖回顧整個過程,可以看到MachO中各種下標的利用很巧妙:

在這裏插入圖片描述

fishhook就是利用類似的原理,遍歷__la_symbol_ptr,比較指針背後的函數符號名稱,如果只指定的字符串,就替換指針的指向。

DWARF vs DSYM

DWARF(debugging with attributed record formats)是一種調試信息的存儲格式,用在Object File裏,用來支持源代碼級別的調試。

用Xcode編譯的中間產物ViewController.o,用MachOView打開後,可以看到很多DWARF的section:

在這裏插入圖片描述

打包上線的時候會把調試符號等裁剪掉,但是線上統計到的堆棧我們仍然要能夠知道對應的源代碼,這時候就需要把符號寫到另外一個單獨的文件裏,這個文件就是DSYM。

可以通過命令dwarfdump來查詢dsym文件的內容,比如查找一個地址

dwarfdump --lookup 0x0007434d  -arch arm64 DemoApp.app.dsym

crash堆棧還可以直接通過Xcode內置的命令來反符號化

export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
alias symbolicatecrash='/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash'
symbolicatecrash demo.crash DemoApp.app.dsym > result.crash

裁剪

符號包含的信息太多,處於安全考慮,往往會進行最高級別的裁剪。對於.app,選擇裁掉All Symbol,而動態庫只能選擇Non-Global Symbol,因爲動態庫需要把Global Symbol保留給外部鏈接用。

在這裏插入圖片描述
背後裁減的實際命令是strip,比如裁減local符號的指令是strip -x

符號生成規則

C的符號生成規則比較簡單,一般的符號都是在函數名上加上下劃線,比如main.c裏包含mian和mylog兩個C函數,對應符號如下:

➜ nm main.o
0000000000000000 T _main
                 U _mylog

C++因爲支持命名空間,函數重載等高級特性,爲了避免符號衝突,所以編譯器對C++符號做了Symbol Mangling(不同編譯器的規則不一樣)。

舉個例子:

namespace MyNameSpace {
    class MyClass{
    public:
        static int myFunc(int);
        static double myFunc(double);
    };
}

編譯後,分別對應符號

➜  DemoApp nm DemoCpp.o 
0000000000000008 T __ZN11MyNameSpace7MyClass6myFuncEd
0000000000000000 T __ZN11MyNameSpace7MyClass6myFuncEi

其實,Symbol Mangling規則並不難,剛剛的兩個符號是按照如下規則生成的:

  • _Z開頭
  • 跟着C語言的保留字符串N
  • 對於namespace等嵌套的名稱,接下依次拼接名稱長度,名稱
  • 然後是結束字符E
  • 最後是參數的類型,比如int是i,double是d

Objective C的符號更簡單一些,比如方法的符號是+-[Class_name(category_name) method:name:],除了這些,Objective C還會生成一些Runtime元數據的符號

➜  DemoApp nm ViewController-arm64.o 
                 U _OBJC_CLASS_$_BTDRouteBuilder
                 U _OBJC_CLASS_$_BTDRouter
                 U _OBJC_CLASS_$_UIViewController
0000000000000458 S _OBJC_CLASS_$_ViewController
                 U _OBJC_METACLASS_$_NSObject
                 U _OBJC_METACLASS_$_UIViewController
0000000000000480 S _OBJC_METACLASS_$_ViewController

所以當鏈接的時候類找不到了,會報錯符號_OBJC_CLASS_$_CLASSNAME找不到

在這裏插入圖片描述
當然,如果類的符號沒有被裁減掉,運行時就用_OBJC_CLASS_$_CLASSNAME作爲參數,通過dlsym來獲取類指針。

符號的種類

按照不同的方式可以對符號進行不同的分類,比如按照可見性劃分

  • 全局符號(Global Symbol) 對其他編譯單元可見
  • 本地符號(Local Symbol) 只對當前編譯單元可見

按照位置劃分:

  • 外部符號,符號不在當前文件,需要ld或者dyld在鏈接的時候解決
  • 非外部符號,即當前文件內的符號

nm命令裏的小寫字母對應着本地符號,大寫字母表示全局符號;U表示undefined,即未定義的外部符號

在這裏插入圖片描述

可見性

有個很常見的case,就是你有1000個函數,但只有10個函數是公開的,希望最後生成的動態庫裏不包含其他990個函數的符號,這時候就可以用clang的attribute來實現:

//符號可被外部鏈接
__attribute__((visibility("default")))
//符號不會被放到Dynamic Symbol Table裏,意味着不可以再被其他編譯單元鏈接
__attribute__((visibility("hidden")))

clang來提供了一個全局的開關,用來設置符號的默認可見性:

在這裏插入圖片描述

如果動態庫的Target把這個開關打開,會發現動態庫仍然能編譯通過,但是App會報一堆鏈接錯誤,因爲符號變成了hidden。

但這是一種常見的編譯方式:讓符號默認是Hidden的,即-fvisibility=hidden,然後手動爲每個接口加上__attribute__((visibility("default")))

//頭文件
#define AWE_EXPORT __attribute__((visibility("default")))
AWE_EXPORT void method_1(void);

//實現文件
AWE_EXPORT void method_1(){
    NSLog(@"1");
}

ld

剛剛提到了,鏈接的時候ld會解決重定位符號的問題,所以ld提供了很多與符號相關的選項。

-ObjC, -all_load, -force_load

ld鏈接靜態庫的時候,只有.a中的某個.o符號被引用的時候,這個.o纔會被鏈接器寫到最後的二進制文件裏,否則會被丟掉,這三個鏈接選項都是解決保留代碼的問題。

  • -ObjC 保留所有Objective C的代碼
  • -force_load 保留某一個靜態庫的全部代碼
  • -all_load 保留參與鏈接的全部的靜態庫代碼

這就是爲什麼一些SDK在集成進來的時候,都要求在other link flags裏添加-ObjC

reexport

假設我有個動態庫A,A會鏈接B,我希望其他鏈接A動態庫也能直接訪問到B的符號,從而隱藏B的實現,應該怎麼做呢?

答案就是:reexport。

這點在libSystem上體現的尤爲明顯,libSystem.dylib reexport了像malloc,dyld,macho等更底層動態庫的符號。

在這裏插入圖片描述

exported_symbol

動態庫因爲不知道外面是如何使用的,所以最好的方式是所有頭文件暴露出的符號全部導出來。從包大小的角度考慮,肯定是用到哪些符號,保留哪些符號對應的代碼,ld提供了這樣一個方案,通過exported_symbol來只保留特定的符號。

tbd

鏈接的過程中,只要知道哪個動態庫包括哪些符號即可,其實不需要一個完整的動態庫Mach-O。於是Xcode 7開始引入了tbd的概念,即Text Based Stub Library,裏面包含了動態庫對外提供的符號,能大幅度減少Xcode的下載大小

可以在以下目錄下找到tbd文件,文件格式就是普通的文本文件:

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks

以Account.framework爲例,在內部可以找到Account.tbd:

在這裏插入圖片描述
除了包括一些基本信息,如架構,uuid,類,符號等,還有個信息是install-name,這個字段存儲了告訴鏈接器,動態庫在運行時位於系統的位置

另外,Xcode裏還提供了TBD相關的編譯選項:

在這裏插入圖片描述

flat_namespace

ld默認採用二級命名空間,也就是除了會記錄符號名稱,還會記錄符號屬於哪個動態庫的,比如會記錄下來printf來自libSystem

➜ xcrun dyldinfo -lazy_bind main
segment section          address    index  dylib            symbol
__DATA  __la_symbol_ptr  0x10000C000 0x0000 libSystem        _printf

可以強制讓ld使用flat_namespace,使用一級命名空間,就是隻記錄下來符號的名稱,運行時的時候dyld動態查找符號所處的位置。

flat_namespace容易發生符號衝突,比如運行時兩個動態庫有一樣的符號;另外效率也要比二級命名空間低一些。

但flat_namespace可以實現動態庫依賴主二進制這種野路子

運行時

bind

應用會訪問很多外部的符號,編譯的時候是不知道這些符號的運行時地址的,所以需要在運行時綁定。

➜ xcrun dyldinfo -bind main
bind information:
segment section          address        type    addend dylib            symbol
__DATA_CONST __got            0x100008000    pointer      0 libSystem        dyld_stub_binder

啓動的時候,dyld會讀取LINKEDIT中的opcode做綁定:

➜ xcrun dyldinfo -opcodes main
binding opcodes:
0x0000 BIND_OPCODE_SET_DYLIB_ORDINAL_IMM(1)
0x0001 BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM(0x00, dyld_stub_binder)
0x0013 BIND_OPCODE_SET_TYPE_IMM(1)
0x0014 BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB(0x02, 0x00000000)
0x0016 BIND_OPCODE_DO_BIND()
0x0017 BIND_OPCODE_DONE

Lazy Symbol

多數符號在應用的生命週期內是用不到的,於是ld會盡可能的讓符號lazy_bind,即第一次訪問的時候纔會綁定。比如log.c裏面調用的printf就是lazy符號。

➜ xcrun dyldinfo -lazy_bind main
lazy binding information (from lazy_bind part of dyld info):
segment section          address    index  dylib            symbol
__DATA  __la_symbol_ptr  0x10000C000 0x0000 libSystem        _printf

爲了支持lazy_bind,首先會在__DATA, __la_symbol_ptr創建一個指針,這個指針編譯期會指向__TEXT,__stub_helper,第一次調用的時候,會通過dyld_stub_binder把指針綁定到函數實現,下一次調用的時候就不需要再綁定了。

而彙編代碼調用printf的時候,直接是調用__DATA, __la_symbol_ptr指針指向的地址。

在這裏插入圖片描述

Weak Symbol

默認情況下Symbol是strong的,weak symbol在鏈接的時候行爲比較特殊:

  • strong symbol必須有實現,否則會報錯
  • 不可以存在兩個名稱一樣的strong symbol
  • strong symbol可以覆蓋weak symbol的實現

應用場景:用weak symbol提供默認實現,外部可以提供strong symbol把實現注入進來,可以用來做依賴注入。

此外還有個概念叫weak linking,這個在做版本兼容的時候很有用:比如一個動態庫的某些特性只有iOS 10以上支持,那麼這個符號在iOS 9上訪問的時候就是NULL的,這種情況就可以用就可以用weak linking。

可以針對單個符號,符號引用加上weak_import即可

extern void demo(void) __attribute__((weak_import));
if (demo) {
    printf("Demo is not implemented");
}else{
    printf("Demo is implemented");
}

實際開發中,更多的場景是整個動態庫都被弱鏈接,對應Xcode中的optional framework:

在這裏插入圖片描述
設置成optional後,鏈接的命令會變成-weak_framework Dynamic,對應在dyld bind的時候,符號也會標記爲weak import,即允許符號運行時不存在

➜  Desktop xcrun dyldinfo -bind /Demo.app/Demo
bind information:
segment section          address        type    addend dylib            symbol
__DATA  __got            0x100003010    pointer      0 Dynamic          _demo (weak import)

dlsym & dlopen

dlopen/dlsym是底層提供一組API,可以在運行時加載動態庫和動態的獲取符號:

extern NSString * effect_sdk_version(void);

加載動態庫並調用C方法

void *handle = dlopen("path to framework", RTLD_LAZY);
NSString *(*func)(void) = dlsym(RTLD_DEFAULT,"effect_sdk_version");
NSString * text = func();

符號斷點

可以在指定的符號上打斷點
在這裏插入圖片描述
Xcode的GUI能設置的斷點,都可以用lldb的命令行設置

(lldb) breakpoint set -F "-[UIViewController viewDidAppear:]"
Breakpoint 2: where = UIKitCore`-[UIViewController viewDidAppear:], address = 0x00007fff46b03dab

lldb

運行時,還可以用lldb去查詢符號相關的信息,常見的case有兩個

查看某個符號的定義

(lldb) image lookup -t ViewController
1 match found in /Users/huangwenchen/.../SymbolDemo.app/SymbolDemo:
id = {0xffffffff00046811},
name = "ViewController",
byte-size = 8, 
decl = ViewController.h:11, 
compiler_type = "@interface ViewController : UIViewController @end"

查看符號的位置

(lldb) image lookup -s ViewController
2 symbols match 'ViewController' in /Users/huangwenchen/.../SymbolDemo.app/SymbolDemo:
        Address: SymbolDemo[0x0000000100005358] (SymbolDemo.__DATA.__objc_data + 0)
        Summary: (void *)0x000000010e74c380: ViewController        
        Address: SymbolDemo[0x0000000100005380] (SymbolDemo.__DATA.__objc_data + 40)
        Summary: (void *)0x00007fff89aec158: NSObject

基於dyld的hook

都知道C函數hook可以用fishhook來實現,但其實dyld內置了符號hook,像malloc history等Xcode分析工具的實現,就是通過dyld hook和malloc/free等函數實現的。

這裏通過dyld來hook NSClassFromString,注意dyld hook有個優點是被hook的函數仍然指向原始的實現,所以可以直接調用

#define DYLD_INTERPOSE(_replacement,_replacee) \
__attribute__((used)) static struct{\
    const void* replacement;\
    const void* replacee;\
} _interpose_##_replacee \
__attribute__ ((section ("__DATA,__interpose"))) = {\
    (const void*)(unsigned long)&_replacement,\
    (const void*)(unsigned long)&_replacee\
};

Class _Nullable hooked_NSClassFromString(NSString *aClassName){
    NSLog(@"hello world");
    return NSClassFromString(aClassName);
}
DYLD_INTERPOSE(hooked_NSClassFromString, NSClassFromString);

但iOS上被禁用了,只能用於MacOS或者模擬器。

總結

平時寫代碼的時候符號應用的場景並不多,但瞭解符號、符號表等概念,有助於理解問題的本質,也能夠在做程序架構的時候多一些思路。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章