前言
LLVM 作為 Apple 御用的編譯基礎設施其重要性不言而喻,Apple 從未停止對 LLVM 的維護和更新,並且幾乎在每年的 WWDC 中都有專門的 Session 來針對 LLVM 的新特性做介紹和講解,剛剛過去的 WWDC18 也不例外。
WWDC18 Session 409 What’s New in LLVM 中 Apple 的工程師們又為我們介紹了 LLVM 最新的特性,這篇文章將會結合 WWDC18 Session 409 給出的 官方簡報 分享一下 LLVM 的新特性並談談筆者自己個人對這些特性的拙見。
Note: 本文不會對官方簡報做逐字逐句的翻譯工作,亦不會去過多介紹 LLVM 的基本常識。
索引
- ARC 更新
- Xcode 10 新增診斷
- Clang 靜態分析
- 增加安全性
- 新指令集擴充套件
- 總結
ARC 更新
本次 ARC 更新的亮點在於 C struct 中允許使用 ARC Objective-C 物件。
在之前版本的 Xcode 中嘗試在 C struct 的定義中使用 Obj—C 物件,編譯器會丟擲 Error: ARC forbids Objective-C objects in struct,如下圖所示:
嘛~ 這是因為之前 LLVM 不支援,如果在 Xcode 10 中書寫同樣的程式碼則不會有任何 Warning 與 Error:
那麼直接在 C struct 中使用 Objective-C 物件的話難道就沒有記憶體上的問題嗎?Objective-C 所佔用的記憶體空間是何時被銷燬的呢?
// ARC Object Pointers in C Structs!
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void orderFreeFood(NSString *name) {
MenuItem item = {
name,
[NSNumber numberWithInt:0]
};
// [item.name retain];
// [item.price retain];
orderMenuItem(item);
// [item.name release];
// [item.price release];
}
複製程式碼
如上述程式碼所示,編譯器會在 C struct MenuItem
建立後 retain
其中的 ARC Objective-C 物件,並在 orderMenuItem(item);
語句之後,即其他使用 MenuItem item
的函式呼叫結束之後 release
掉相關 ARC Objective-C 物件。
思考,在動態記憶體管理時,ARC Objective-C 物件的記憶體管理會有什麼不同呢?
Note: 動態記憶體管理(Dynamic Memory Management),指非
int a[100];
或MenuItem item = {name, [NSNumber numberWithInt:0]};
這種在決定了使用哪一儲存結構之後,就自動決定了作用域和儲存時期的程式碼,這種程式碼必須服從預先制定的記憶體管理規則。
我們知道 C 語言中如果想要靈活的建立一個動態大小的陣列需要自己手動開闢、管理、釋放相關的記憶體,示例:
void foo() {
int max;
double *ptd;
puts("What is the maximum number of type double entries?");
scanf("%d", &max);
ptd = malloc(max * sizeof(double));
if (ptd == NULL) {
// memory allocation failed
...
}
// some logic
...
free(ptd);
}
複製程式碼
那麼 C struct 中 ARC Objective-C 的動態記憶體管理是否應該這麼寫呢?
// Structs with ARC Fields Need Care for Dynamic Memory Management
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void testMenuItems() {
// Allocate an array of 10 menu items
MenuItem *items = malloc(10 * sizeof(MenuItem));
orderMenuItems(items, 10);
free(items);
}
複製程式碼
答案是否定的!
可以看到通過 malloc
開闢記憶體初始化帶有 ARC Objective-C 的 C struct 中 ARC Objective-C 指標不會 zero-initialized
。
嘛~ 這個時候自然而然的會想起使用 calloc
^_^
Note:
calloc
和malloc
均可完成記憶體分配,不同之處在於calloc
會將分配過來的記憶體塊中全部位置都置 0(然而要注意,在某些硬體系統中,浮點值 0 不是全部位為 0 來表示的)。
另一個問題就是 free(items);
語句執行之前,ARC Objective-C 並沒有被清理。
Emmmmm... 官方推薦的寫法是在 free(items);
之前將 items
內的所有 struct 中使用到的 ARC Objective-C 指標手動職位 nil
...
所以在動態記憶體管理時,上面的程式碼應該這麼寫:
// Structs with ARC Fields Need Care for Dynamic Memory Management
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void testMenuItems() {
// Allocate an array of 10 menu items
MenuItem *items = calloc(10, sizeof(MenuItem));
orderMenuItems(items, 10);
// ARC Object Pointer Fields Must be Cleared Before Deallocation
for (size_t i = 0; i < 10; ++i) {
items[i].name = nil;
items[i].price = nil;
}
free(items);
}
複製程式碼
瞬間有種日了狗的感覺有木有?
個人觀點
嘛~ 在 C struct 中增加對 ARC Objective-C 物件欄位的支援意味著我們今後 Objective-C 可以構建跨語言模式的互動操作。
Note: 官方宣告為了統一 ARC 與 manual retain/release (MRR) 下部分 function 按值傳遞、返回 struct 對 Objective-C++ ABI 做出了些許調整。
值得一提的是 Swift 並不支援這一特性(2333~ 誰說 Objective-C 的更新都是為了迎合 Swift 的變化)。
Xcode 10 新增診斷
Swift 與 Objective-C 互通性
我們都知道 Swift 與 Objective-C 具有一定程度的互通性,即 Swift 與 Objective-C 可以混編,在混編時 Xcode 生成一個標頭檔案將 Swift 可以轉化為 Objective-C 的部分介面暴露出來。
不過由於 Swift 與 Objective-C 的相容性導致用 Swift 實現的部分程式碼無法轉換給 Objective-C 使用。
近些年來 LLVM 一致都在嘗試讓這兩種語言可以更好的互通(這也就是上文中提到 Objective-C 的更新都是為了迎合 Swift 說法的由來),本次 LLVM 支援將 Swift 中的閉包(Closures)匯入 Objective-C。
@objc protocol Executor {
func performOperation(handler: () -> Void)
}
複製程式碼
#import “Executor-Swift.h”
@interface DispatchExecutor : NSObject<Executor>
- (void)performOperation:(void (^)(void))handler;
@end
複製程式碼
Note: 在 Swift 中閉包預設都是非逃逸閉包(non-escaping closures),即閉包不應該在函式返回之後執行。
Objective-C 中與 Swift 閉包對應的就是 Block 了,但是 Objective-C 中的 Block 並沒有諸如 Swift 中逃逸與否的限制,那麼我們這樣將 Swift 的非逃逸閉包轉為 Objective-C 中無限制的 Block 豈不是會有問題?
別擔心,轉換過來的閉包(非逃逸)會有 Warnning 提示,而且我們說過一般這種情況下 Apple 的工程師都會在 LLVM 為 Objective-C 加一個巨集來迎合 Swift...
// Warning for Missing Noescape Annotations for Method Overrides
#import “Executor-Swift.h”
@interface DispatchExecutor : NSObject<Executor>
- (void)performOperation:(NS_NOESCAPE void (^)(void))handler;
@end
@implementation DispatchExecutor
- (void)performOperation:(NS_NOESCAPE void (^)(void))handler {
}
// Programmer must ensure that handler is not called after performOperation returns
@end
複製程式碼
個人觀點
如果 Swift 5 真的可以做到 ABI 穩定,那麼 Swift 與 Objective-C 混編的 App 包大小也應該回歸正常,相信很多公司的專案都會慢慢從 Objective-C 轉向 Swift。在 Swift 中閉包(Closures)作為一等公民的存在奠定了 Swift 作為函式式語言的根基,本次 LLVM 提供了將 Swift 中的 Closures 與 Objective-C 中的 Block 互通轉換的支援無疑是很有必要的。
使用 #pragma pack
打包 Struct 成員
Emmmmm... 老實說這一節的內容更底層,所以可能會比較晦澀,希望自己可以表述清楚吧。在 C 語言中 struct 有 記憶體佈局(memory layout) 的概念,C 語言允許編譯器為每個基本型別指定一些對齊方式,通常情況下是以型別的大小為標準對齊,但是它是特定於實現的。
嘛~ 還是舉個例子吧,就拿 WWDC18 官方簡報中的吧:
struct Struct {
uint8_t a, b;
// 2 byte padding
uint32_t c;
};
複製程式碼
在上述例子中,編譯器為了對齊記憶體佈局不得不在 Struct
的第二欄位與第三欄位之間插入 2 個 byte。
| 1 | 2 | 3 | 4 |
| a | b | pad.......... |
| c(1) | c(2) | c(3) | c(4) |
複製程式碼
這樣本該佔用 6 byte 的 struct 就佔用了 8 byte,儘管其中只有 6 byte 的資料。
C 語言允許每個遠端現代編譯器實現 #pragma pack
,它允許程式猿對填充進行控制來依從 ABI。
From C99 §6.7.2.1:
12 Each non-bit-field member of a structure or union object is aligned in an implementation- defined manner appropriate to its type.
13 Within a structure object, the non-bit-field members and the units in which bit-fields reside have addresses that increase in the order in which they are declared. A pointer to a structure object, suitably converted, points to its initial member (or if that member is a bit-field, then to the unit in which it resides), and vice versa. There may be unnamed padding within a structure object, but not at its beginning.
實際上關於 #pragma pack
的相關資訊可以在 MSDN page 中找到。
LLVM 本次也加入了對 #pragma pack
的支援,使用方式如下:
#pragma pack (push, 1)
struct PackedStruct {
uint8_t a, b;
uint32_t c;
};
#pragma pack (pop)
複製程式碼
經過 #pragma pack
之後我們的 struct 對齊方式如下:
| 1 |
| a |
| b |
| c(1) |
| c(2) |
| c(3) |
| c(4) |
複製程式碼
其實 #pragma pack (push, 1)
中的 1
就是對齊位元組數,如果設定為 4
那麼對齊方式又會變回到最初的狀態:
| 1 | 2 | 3 | 4 |
| a | b | pad.......... |
| c(1) | c(2) | c(3) | c(4) |
複製程式碼
值得一提的是,如果你使用了 #pragma pack (push, n)
之後忘記寫 #pragma pack (pop)
的話,Xcode 10 會丟擲 warning:
個人觀點
嘛~ 當在網路層面傳輸 struct 時,通過 #pragma pack
自定義記憶體佈局的對齊方式可以為使用者節約更多流量。
Clang 靜態分析
Xcode 一直都提供靜態分析器(Static Analyzer),使用 Clang Static Analyzer 可以幫助我們找出邊界情況以及難以發覺的 Bug。
點選 Product -> Analyze 或者使用快捷鍵 Shift+Command+B 就可以靜態分析當前構建的專案了,當然也可以在專案的 Build Settings 中設定構建專案時自動執行靜態分析(個人不推薦):
本地靜態分析器有以下提升:
- GCD 效能反模式
- 自動釋放變數超出自動釋放池
- 效能和視覺化報告的提升
GCD 效能反模式
在之前某些迫不得已的情況下,我們可能需要使用 GCD 訊號(dispatch_semaphore_t
)來阻塞某些非同步操作,並將阻塞後得到的最終的結果同步返回:
__block NSString *taskName = nil;
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
[self.connection.remoteObjectProxy requestCurrentTaskName:^(NSString *task) {
taskName = task;
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
return taskName;
複製程式碼
嘛~ 這樣寫有什麼問題呢?
上述程式碼存在通過使用非同步執行緒執行任務來阻塞當前執行緒,而 Task 佇列通常優先順序較低,所以會導致優先順序反轉。
那麼 Xcode 10 之後我們應該怎麼寫呢?
__block NSString *taskName = nil;
id remoteObjectProxy = [self.connection synchronousRemoteObjectProxyWithErrorHandler:
^(NSError *error) { NSLog(@"Error: %@", error); }];
[remoteObjectProxy requestCurrentTaskName:^(NSString *task) {
taskName = task;
}];
return taskName;
複製程式碼
如果可能的話,儘量使用 synchronous
版本的 API。或者,使用 asynchronous
方式的 API:
[self.connection.remoteObjectProxy requestCurrentTaskName:^(NSString *task) {
completionHandler(task);
}];
複製程式碼
可以在 build settings 下啟用 GCD 效能反模式的靜態分析檢查:
自動釋放變數超出自動釋放池
眾所周知,使用 __autoreleasing
修飾符修飾的變數會在自動釋放池離開時被釋放(release):
@autoreleasepool {
__autoreleasing NSError *err = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}
複製程式碼
這種看似不需要我們注意的點往往就是引起程式 Crash 的隱患:
- (void)findProblems:(NSArray *)arr error:(NSError **)error {
[arr enumerateObjectsUsingBlock:^(id value, NSUInteger idx, BOOL *stop) {
if ([value isEqualToString:@"problem"]) {
if (error) {
*error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}
}
}];
}
複製程式碼
嘛~ 上述程式碼是會引起 Crash 的,你可以指出為什麼嗎?
Objective-C 在 ARC(Automatic Reference Counting)下會隱式使用 __autoreleasing
修飾 error
,即 NSError *__autoreleasing*
。而 -enumerateObjectsUsingBlock:
內部會在迭代 block
時使用 @autoreleasepool
,在迭代邏輯中這樣做有助於減少記憶體峰值。
於是 *error
在 -enumerateObjectsUsingBlock:
中被提前 release 掉了,這樣在隨後讀取 *error
時會出現 crash。
Xcode 10 中會給出具有針對性的靜態分析警告:
正確的書寫方式應該是這樣的:
- (void)findProblems:(NSArray *)arr error:(NSError *__autoreleasing*)error {
__block NSError *localError;
[arr enumerateObjectsUsingBlock:^(id value, NSUInteger idx, BOOL *stop) {
if ([value isEqualToString:@"problem"]) {
localError = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}
}];
if (error) {
*error = localError;
}
}
複製程式碼
Note: 其實早在去年的 WWDC17 Session 411 What's New in LLVM 中 Xcode 9 就引入了一個需要顯示書寫
__autoreleasing
的警告。
效能和視覺化報告的提升
Xcode 10 中靜態分析器可以以更高效的方式工作,在相同的分析時間內平均可以發現比之前增加 15% 的 Bug 數量。
不僅僅是效能的提升,Xcode 10 在報告的視覺化方面也有所進步。在 Xcode 9 的靜態分析器報告頁面有著非必要且冗長的 Error Path:
Xcode 10 中則對其進行了優化:
個人觀點
嘛~ 對於 Xcode 的靜態分析,個人認為還是聊勝於無的。不過不建議每次構建專案時都去做靜態分析,這樣大大增加了構建專案的成本。
個人建議在開發流程中自測完畢提交程式碼給組內小夥伴們 Code Review 之前做靜態分析,可以避免一些 issue 的出現,也可以發現一些程式碼隱患。有些問題是可以使用靜態分析器在提交程式碼之前就暴露出來的,沒必要消耗組內 Code Review 的寶貴人力資源。
還可以在 CI 設定每隔固定是時間間隔去跑一次靜態分析,生成報表發到組內小群,根據問題指派責任人去檢查是否需要修復(靜態分析在比較複雜的程式碼結構下並不一定準確),這樣定期維護從某種角度講可以保持專案程式碼的健康狀況。
增加安全性
Stack Protector
Apple 工程師在介紹 Stack Protector 之前很貼心的帶領著在場的開發者們複習了一遍棧 Stack 相關的基礎知識:
如上圖,其實就是簡單的講了一下 Stack 的工作方式,如棧幀結構以及函式呼叫時棧的展開等。每一級的方法呼叫,都對應了一張相關的活動記錄,也被稱為活動幀。函式的呼叫棧是由一張張幀結構組成的,所以也稱之為棧幀。
我們可以看到,棧幀中包含著 Return Address,也就是當前活動記錄執行結束後要返回的地址。
那麼會有什麼安全性問題呢?Apple 工程師接著介紹了通過不正當手段修改棧幀 Return Address 從而實現的一些許可權提升。嘛~ 也就是歷史悠久的 緩衝區溢位攻擊。
當使用 C 語言中一些不太安全的函式時(比如上圖的 strcpy()
),就有可能造成緩衝區溢位。
Note:
strcpy()
函式將源字串複製到指定緩衝區中。但是丫沒有指定要複製字元的具體數目!如果源字串碰巧來自使用者輸入,且沒有專門限制其大小,則有可能會造成緩衝區溢位!
針對緩衝區溢位攻擊,LLVM 引入了一塊額外的區域(下圖綠色區域)來作為棧幀 Return Address 的護城河,叫做 Stack Canary,已預設啟用:
Note: Canary 譯為 “金絲雀”,Stack Canary 的命名源於早期煤礦工人下礦坑時會攜帶金絲雀來檢測礦坑內一氧化碳是否達到危險值,從而判斷是否需要逃生。
根據我們上面對緩衝區溢位攻擊的原理分析,大家應該很容易發現 Stack Canary 的防禦原理,即緩衝區溢位攻擊旨在利用緩衝區溢位來篡改棧幀的 Return Address,加入了 Stack Canary 之後想要篡改 Return Address 就必然會經過 Stack Canary,在當前棧幀執行結束後要使用 Return Address 回溯時先檢測 Stack Canary 是否有變動,如果有就呼叫 abort()
強制退出。
嘛~ 是不是和礦坑中的金絲雀很像呢?
不過 Stack Canary 存在一些侷限性:
- 可以在緩衝區溢位攻擊時計算 Canary 的區域並偽裝 Canary 區域的值,使得 Return Address 被篡改的同時 Canary 區域內容無變化,繞過檢測。
- 再粗暴一點的話,可以通過雙重
strcpy()
覆寫任意不受記憶體保護的資料,通過構建合適的溢位字串,可以達到修改 ELF(Executable and Linking Format)對映的 GOT(Global Offset Table),只要修改了 GOT 中的_exit()
入口,即便 Canary 檢測到了篡改,函式返回前呼叫abort()
退出還是會走已經被篡改了的_exit()
。
Stack Checking
Stack Protector 是 Xcode 既有的、且預設開啟的特性,而 Stack Checking 是 Xcode 10 引入的新特性,主要針對的是 Stack Clash 問題。
Stack Clash 問題的產生源於 Stack 和 Heap,Stack 是從上向下增長的,Heap 則是自下而上增長的,兩者相向擴充套件而記憶體又是有限的。
Stack Checking 的工作原理是在 Stack 區域規定合理的分界線(上圖紅線),在可變長度緩衝區的函式內部對將要分配的緩衝區大小做校驗,如果緩衝區超出分界線則呼叫 abort()
強制退出。
Note: LLVM 團隊在本次 WWDC18 加入 Stack Checking,大概率是因為去年年中 Qualys 公佈的一份 關於 Stack Clash 的報告。
新指令集擴充套件
Emmmmm... 這一節的內容是針對於 iMac Pro 以及 iPhone X 使用的 指令集架構(ISA - Instruction set architecture) 所做的擴充套件。坦白說,我對這塊並不是很感興趣,也沒有深入的研究,所以就不獻醜了...
總結
本文梳理了 WWDC18 Session 409 What’s New in LLVM 中的內容,並分享了我個人對這些內容的拙見,希望能夠對各位因為種種原因還沒有來得及看 WWDC18 Session 409 的同學有所幫助。
文章寫得比較用心(是我個人的原創文章,轉載請註明 lision.me/),如果發現錯誤會優先在我的個人部落格中更新。如果有任何問題歡迎在我的微博 @Lision 聯絡我~
希望我的文章可以為你帶來價值~
補充~ 我建了一個技術交流微信群,想在裡面認識更多的朋友!如果各位同學對文章有什麼疑問或者工作之中遇到一些小問題都可以在群裡找到我或者其他群友交流討論,期待你的加入喲~
Emmmmm..由於微信群人數過百導致不可以掃碼入群,所以請掃描上面的二維碼關注公眾號進群。