摘要
蘋果對 iOS App 大小有嚴格限制:下載大小超限會阻礙用戶在蜂窩網路下載 App ,直接影響新用戶轉化;可執行檔案超限將導致 App 審核被拒,直接影響上架,今日頭條探索實踐 __TEXT 段遷移技術,成功減小下載大小 32%,并且解決了可執行檔案大小受限問題,
一、背景知識
1. 下載大小限制
App 大小有下載大小和安裝大小的概念,
下載大小是指 App 壓縮包(也就是 .ipa 檔案)所占的空間,用戶在下載 App 時,下載的是壓縮包,這樣做可以節省流量;當壓縮包下載完成后,就會自動解壓,解壓程序也就是通常所說的安裝程序;安裝大小就是指壓縮包解壓后所占用的空間,
安裝大小在 App Store 上就可以看見 ,通常它會影響用戶的下載意愿:
而下載大小只有研發人員在 App Store Connect 后臺才可以看,用戶看不見,它影響的是下載消耗的流量和時長:
若下載大小超過限制,將無法使用蜂窩網路下載 App( iOS 13 之前),會收到檔案容量太大的提示,需通過 Wi-Fi 網路下載,如下,為蘋果歷年來對 App 下載大小限制的變化情況:
-
2008 年 7 月,搭載了 App Store 的 iPhone 3G 正式發售,下載限制僅為 10 MB
-
2010 年 2 月,蘋果將 iPhone 3G 的下載限制從 10 MB 提升到 20 MB
-
2012 年 3 月,iOS 5.1 正式版后,下載限制從 20 MB 提升到 50 MB
-
2013 年 9 月,iOS 7 正式版后,下載限制從 50 MB 提升至 100 MB
-
2017 年 9 月,iOS 11 正式版后,下載限制從 100 MB 提升至 150 MB
-
2019 年 5 月,下載限制從 150 MB 提升至 200 MB
-
2019 年 9 月,iOS 13 正式版后,若下載大小超過 200 MB,用戶可選擇是否使用蜂窩網路下載
如今,App 下載大小超出 200 MB 時 ,會出現兩種情況:
-
iOS 13 以下的用戶,無法通過蜂窩資料下載 App
-
iOS 13 及以上的用戶,需要手動設定才可以使用蜂窩網路下載 App
2. 可執行檔案大小限制
根據最大構建版本檔案大小[1]描述,蘋果對可執行檔案大小亦有明確限制,超過該限制會導致 App 審核被拒:
ERROR: ERROR ITMS-90122: "Invalid ExecutaBe Size. The size of your app's executaBe file 'News.app/News' is 68534272 bytes for architecture 'arm64', which exceeds the maximum allowed size of 60 MB."
具體限制如下:
-
iOS 7 之前,二進制檔案中所有的
__TEXT段總和不得超過 80 MB
-
iOS 7.X 至 iOS 8.X ,二進制檔案中,每個特定架構中的
__TEXT段不得超過 60 MB
-
iOS 9.0 之后,二進制檔案中所有的
__TEXT段總和不得超過 500 MB
二、面臨問題
隨著網路普及、流量費用降低,蘋果已經放寬了限制,但下載大小若超出 200 MB,可以肯定對新增仍會有一定影響,這對上億級用戶的 App 來說是巨大的損失,并且本著追求極致、在競品中拔得頭籌的理念,我們認為下載大小 200 MB 是包大小的一根紅線,
今日頭條 App 的下載大小已經接近 180 MB,而經過了多年的極致優化(包括但不限于代碼/圖片/其它資源的優化、編譯/鏈接引數的優化、推進無用業務下線、準入卡口等),已經很難再有較大幅度的減少,為此平臺和各方都投入了極大的人力、甚至犧牲了業務的迭代空間來優化/抑制下載大小,
2020 年下半年,我們另辟蹊徑探索實踐了 __TEXT 段遷移的方法:將可執行檔案的 __TEXT 段中的部分節移動到其它的段,避開蘋果的加密機制,提高了可執行檔案的壓縮效率,使 App 的下載大小減少了 60 MB,
該方案徹底解決了下載大小限制的問題,同時還解決了仍在支持 iOS 8.X 的 App 面臨的可執行檔案大小限制問題,
三、技術原理
1. Mach-O 檔案格式簡介
iOS 可執行檔案是 Mach-O 格式,主要由 Header、Load Commands、Data 三部分,
可以簡單認為:
-
Header描述了檔案的大概資訊,
-
Load Commands由多條Load Command組成,它們描述了Data在二進制檔案和虛擬記憶體中的布局資訊,有了這個布局資訊就能夠知道Data在二進制檔案中和虛擬記憶體中是怎樣排布的,它相當于修房子時的圖紙一樣,
-
Data存盤了實際的內容,主要是程式的指令和資料,它們的排布完全依照Load Commands的描述,
Mach-O 檔案中的 Data 部分主要是以 Segment(中文翻譯為段)和 Section (中文翻譯為節)的方式來組織內容的,好比學校中有年級和班級、公司中有部門和小組一樣,把有共同特點的內容組織到一塊,可以方便管理,提高效率,
使用 $ xcrun size -lm <binary-path> 指令可以查看 Mach-O 檔案 Data 部分的結構和各 Segment/Section 的大小資訊(該 Mach-O 檔案由 Xcode 的 iOS App 模板工程構建而來),在不需要更詳細的資訊時,這條命令很方便,
上圖就展示了 Data 中的內容排布的基本資訊,
由該圖可知,在該檔案中:
-
Data部分中有 5 個 Segment,依次是:-
__PAGEZERO -
__TEXT -
__DATA_CONST -
__DATA -
__LINKEDIT
-
-
除
__PAGEZERO和__LINKEDIT外,每個段中有多個Section,
注意:
Data與__DATA是不同的兩個概念,Data是 Mach-O 檔案中的一部分,包含多個段,__DATA只是Data中的一個段,
__PAGEZERO 的大小是 4 GB,但并不是它在 Mach-O 檔案中的真實大小,這 4 GB 是 Mach-O 加載進記憶體后, __PAGEZERO 在記憶體中占中的大小,它不可讀,不可寫,主要用來捕捉 NULL 指標的參考,如果訪問 __PAGEZERO 段,會引起 EXC_BAD_ACCESS 錯誤,__PAGEZERO 在 Mach-O 中實際上并不占用 Data 部分的空間,
__TEXT、__DATA_CONST、__DATA 用于保存程式的代碼指令和資料,
__LINKEDIT 包含啟動 App 需要的資訊,比如 bind & rebase 的地址,代碼簽名,符號表等,
2. __TEXT 段遷移的原理
程式的構建程序包含 預處理 -> 編譯 -> 匯編 -> 鏈接 等 4 個主要階段,完成之后就會得到 Mach-O 可執行檔案,
通過 $ man ld ,可以發現聯結器有一個引數: -rename_p orgSegment orgSection newSegment newSection,使用該引數可以將orgSegment/orgSection的名稱修改為newSegment/newSection,
可以在 Other Linker Flags 中傳遞該引數,如:
-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text
-Wl,-segprot,__BD_TEXT,rx,rx
其中 -Wl 的作用是告訴 Xcode 它后面的引數是添加給 Ld 聯結器的,這些引數將在鏈接階段生效,
第一行引數會新創建一個 __BD_TEXT 段,并把 __TEXT,__text 移動到 __BD_TEXT,__text,
第二行引數是給 __BD_TEXT 賦予可讀和可執行權限,
構建完成后再來看一下移動 __TEXT,__text 后的 Mach-O 檔案:
可以看到 __TEXT,__text 已經被移動到了 __BD_TEXT 中去了,它的地址也由起始的 0x100005e5c 變為了 0x100010000 ,此時程式仍可以正常的運行,這是因為作業系統只關心段的讀/寫/執行權限,并不關心段或節的名稱,即便是使用了 -rename_p 移動 Segment/Section,各符號的地址也會由聯結器修正好,因此段移動后程式也可以正常運行,
在最低支持 iOS 8 的時代,很多大型 App 都遇到過可執行檔案中 __TEXT 段超 60 MB 的問題,facebook[2] 當時采用了 -rename_p 的技術來避免該問題,他們使用的鏈接引數為:
-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring
-Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab
-Wl,-rename_p,__TEXT,__const,__RODATA,__const
-Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname
-Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname
-Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype
引數的作用是將 __TEXT 中的 __cstring、__gcc_except_tab、__const、__objc_methname、__objc_classname、__objc_methtype 等 6 個節移動到 __RODATA去,由于這 6 個節是只讀的,所以他們將新段取名為 __RODATA,意為只讀段,這樣做之后,__TEXT 的大小就會被減小,而蘋果只會掃描 __TEXT 段,所以當 __TEXT 段減小到 60 MB 以下時,就避免了 __TEXT 段超過 60 MB 的問題,該方案當時在國內大型 App 上也很常見,
今日頭條 App 在 2018 年 5 月遇到此問題后也采取了該方案,當時是為了避免 __TEXT 段超 60 MB 的問題,現在測驗發現,以上引數也對下載大小有 12 MB 的優化,
為什么移動 __TEXT 段會減少下載大小?下一小節會給出詳細的解釋,
注意,使用 -rename_p 需要關閉 Bitcode ,
3. 下載大小減少的原理
摘自蘋果官方檔案[3]:
When your app is approved for the App Store, it is encrypted with DRM and recompressed. The added encryption and DRM affects the ability to compress your binary, and as a result you may see a larger App Store file size for your binary than the binary you uploaded on App Store Connect. The exact final size for your app cannot be determined in advance to the accuracy of a single byte.
對專案工程進行 Archive 后會生成 .xcarchive 檔案,該檔案中包含了 App、dsYMS 以及其它資訊,如圖所示為 .xcarchive 檔案中包含的內容:
將 .xcarchive 檔案上傳到 App Store Connect 后,蘋果會對 App 中的可執行檔案進行 DRM 加密,然后將 App 壓縮成 ipa 檔案,才發布到 App Store,加密對可執行檔案的大小本身影響很小(對今日頭條 App 的影響為 2 MB),但是它會嚴重影響可執行檔案的壓縮效率,導致壓縮后的 ipa 大小增加,也就是下載大小增大,
實際上,這種加密幾乎沒有用,只要有越獄手機,使用市面上的脫殼工具就可以很容易地進行解密,
Mach-O 檔案代碼的解密發生在 Mach-O 檔案被加載的時候,由 Mach Loader 進行,Mach Loader 會讀取 Mach-O 中的 LC_ENCRYPTION_INFO 這條 Load Command 來判斷可執行檔案是否加密,
所以,也可以通過 otool -l <binary-path> 的命令來查看 Mach-O 是否被加密過,
Load command 13
cmd LC_ENCRYPTION_INFO_64
cmdsize 24
cryptoff 16384
cryptsize 101695488
cryptid 1
pad 0
其中 cryptoff 表示加密欄位位于檔案中偏移 16384 個位元組;cryptsize 表示加密內容長度 101695488 個位元組;cryptid 表示加密方法為 1,如果為 0 表示不加密,
查看 LC_SEGMENT_64 中 __TEXT 段的范圍
Load command 1
cmd LC_SEGMENT_64
cmdsize 1432
segname __TEXT
vmaddr 0x0000000100000000 4294967296
vmsize 0x0000000006100000 101711872
fileoff 0
filesize 101711872
依據上述結果可以算出加密的內容實際上都位于 __TEXT 中,
可以認為蘋果只會對 Mach-O 檔案中的 __TEXT 段加密,而不會對其它段加密,只要能把 __TEXT 段中的節移到其它段,就能減少蘋果的加密范圍,從而使壓縮效率提升,減小下載大小,這也解答上個小節提出的問題,
一般來講,在 App 中可執行檔案占 80% 的大小,而加密部分占可執行檔案中的 70%,加密會影響 60% 的壓縮率,因此移走該加密部分,會提升 34% 的下載大小,根據我們在多個 App 的實踐,本方案可以減少 32~34% 的下載大小,
需要注意的是:
蘋果在 iOS 13 已經對下載大小做了優化,所以本方案無法再對 iOS 13 的設備的下載大小進一步優化,
即,若用戶的設備 < iOS 13,那么本方案可以減少該設備上 App 32~34%的下載大小;
若用戶的設備 >= iOS 13,本方案不會對該設備的 App 的下載大小有進一步優化,也不會有負面影響,
因此,如果你看到 App Store Connect 后臺展示的下載大小從 iPhone 11 開始大幅減小,不要驚訝,這是因為 iPhone 11 開始默認搭載的是 iOS 13+ 的系統,
目前推測蘋果在 iOS 13 也是在針對壓縮做了優化,可能是移除了加密或者是先壓縮后加密,
蘋果在 iOS 13 的更新日志[4]中描述到它們對包大小做了優化,如圖:
四、實踐
照著上面的思路來看,只要將 __TEXT 段中所有節都移走,就能夠最大限度的減少下載大小,
這么簡單就可以了嗎?實際上并非如此,在小型 App 上,這么做沒有任何問題,但在較大型 App 上,這并不是一件輕松的事情,
今日頭條 App 在實踐程序中解決了 Crash 和一個極為難纏的鏈接失敗的問題,
1. Crash
Crash 的原因是執行代碼時找不到指定的節,
在原理中說到:作業系統只關心段的讀/寫/執行權限,并不關心段或節的名稱,即便是使用了-rename_p 移動 Segment/Section,各符號的地址也會由聯結器修正好,因此段移動后程式也可以正常運行,
但是如果代碼指明了要讀取 __TEXT 中的某個 Section ,那么這個 Section 就不能夠被移動,否則代碼就無法讀取到它,就會導致出錯,
首先,dyld[5] 在啟動階段會檢查 __unwind_info 和 __eh_frame 這兩個 Section,如果移動這兩個 Section,在啟動后程式就會 Crash,
第二,Swift 相關的 Section 不能移動,否則會引起 Crash,
在使用 Swift 之后,二進制中會有一些 Swift 相關的 Section:
它們都不能夠被移動,一共有下面這些 Section:
__TEXT,__swift5_typeref
__TEXT,__swift5_reflstr
__TEXT,__swift5_fieldmd
__TEXT,__swift5_types
__TEXT,__swift5_capture
__TEXT,__swift5_assocty
__TEXT,__swift5_proto
__TEXT,__swift5_protos
__TEXT,__swift5_builtin
第三,自己在代碼中指明要讀取的 Section,目前我們的代碼中沒有這種 Crash 情況,但是我們的某些腳本中有檢測 __TEXT,__text 的代碼,在 __TEXT 段遷移后,腳本受到了影響,因此需要重新適配這類腳本,
2. 鏈接失敗
__TEXT 段遷移最難解決的問題是鏈接失敗問題,是由 CPU 對尋址范圍的限制以及 ld64 聯結器的缺陷導致,
2.1 現象及原因概述
如果 Mach-O 檔案足夠大,貿然移動 Segment/Section 很容易引發 ld64 聯結器例外,
想要讓 CPU 作業就必須向它提供指令和資料,程式運行時指令和資料存放在記憶體中,CPU 通過地址總線來指定記憶體單元的的地址,地址總線的寬度決定了 CPU 的尋址能力,因此 CPU 對尋址范圍有一定的限制,而不同 CPU 的地址總線寬度不同以及它們所采用的指令模式[6]也不一樣,所以不同 CPU 的尋址范圍也有差異,
B、BL 指令是 ARM 處理器中的跳轉指令,可以讓處理器跳轉到指定的目標地址,從那里繼續執行,由于尋址范圍是受限的,所以跳轉距離不能超出這個限制,ld64 聯結器在最終 Output(寫可執行檔案)時,會對所有的跳轉指令進行檢查,若發現跳轉距離超出限制就會立即拋出 ld: b(l) ARM64 branch out of range例外,從而鏈接失敗,就會出現了圖上所示的現象,
在蘋果開源的 ld64-530 OutputFile.cpp 檔案[7] 中總結出來,常見 CPU 具體限制尋址范圍如下:
2.2 ld64 聯結器所做的事情
按照上面的描述,隨著業務的擴張,代碼的膨脹,Mach-O 檔案會越來越大,那是不是 Mach-O 檔案過大時程式就無法鏈接成功了?
當然不是!實際上 ld64 聯結器知道會出現跳轉距離超出限制的情況,所以它在鏈接程序中會做 Branch Island[8] 演算法,對超限制的跳轉指令加以保護,
// PowerPC can do PC relative branches as far as +/-16MB. (+/-16MB 可能是因為注釋比較老)
// If a branch target is >16MB then we insert one or more
// "branch islands" between the branch and its target that
// allows island hopping to the target.
// Branch Island Algorithm
//
// If the __TEXT Segment < 16MB, then no branch islands needed
// Otherwise, every 14MB into the __TEXT Segment a region is
// added which can contain branch islands. Every out-of-range
// B instruction is checked. If it crosses a region, an island
// is added to that region with the same target and the B is
// adjusted to target the island instead.
//
// In theory, if too many islands are added to one region, it
// could grow the __TEXT enough that other previously in-range
// B branches could be pushed out of range. We reduce the
// probability this could happen by placing the ranges every
// 14MB which means the region would have to be 2MB (512,000 islands)
// before any branches could be pushed out of range.
從原理部分我們知道了 Mach-O 的 Data 部分有很多 Segment/Section,實際上 ld64 聯結器還給每個 Section 歸了類,歸類的代碼可以在蘋果開源的 ld64-530 中的 ld.hpp 檔案的第 547 行找到:
每個 Section 都屬于其中一種型別,Branch Island 演算法會對型別是 typeCode 的 Section 中的跳轉指令做檢查,如果跳轉的距離超出限制,則會在它們之間插入 "branch islands",跳轉指令會先跳到一個 branch island ,再從這個 branch island 跳到目標地址,以此來保證其跳轉距離不超過限制,此部分的代碼在 branch_island.cpp 檔案中可以找到,
__TEXT,__text 的型別是 typeCode,因此,__TEXT,__text 中超出范圍跳轉指令都會被保護,在最后 Output 檢查時,就不會出現 branch out of range 的例外,所以,正常構建的 App,即使很大也不會出現鏈接失敗的問題,這都是歸功于 Branch Island 演算法,
在 Mach-O 檔案中,只有 __TEXT,__text的型別是 typeCode(在使用-rename_p 移動 Segment/Section 之后,Section 的型別不會發生改變),源地址在 __text 中的 跳轉指令跳轉的情況只有兩種:__text -> __text 和 __text -> __stubs,
所以 Branch Island 保護的 跳轉指令的所在 Section ,與目標地址所在的 Section, 只有兩種情況:
但實際上 Output 時 ld64 聯結器會檢查檔案中所有的跳轉指令,不僅限于源地址在__text 中的跳轉指令,這意味會檢查多種情況:
小結:Branch Island 演算法僅會保護
__text中超出限制的跳轉指令,
Output 時,ld64 聯結器會檢查檔案中所有的跳轉指令是否超出限制,
2.3 Branch Island 演算法的缺陷
既然 Branch Island 演算法會保護型別是 typeCode 的 Section 中超限制的跳轉指令,并且-rename_p 不會改變 Section 的型別,那為何會-rename_p 后會導致 branch out of range 的例外?
主要是兩個原因:
1. Branch Island 演算法的檢查邏輯沒有適配到 Section 被移動的情況,
在分析 Mach-O 檔案時只介紹了 Segment/Section,實際聯結器認為在 Section 中還存在 atom(鏈接的基本單元),在 atom 中還存在 fixup(用于描述不同 atom 之間的參考關系,)
如圖所示為 ld64-530 的 branch_island.cpp 檔案中 Branch Island 演算法中的一部分代碼,該片段是要判斷跳轉指令跳轉的距離是否超出限制,如果超過限制就會對該跳轉指令做保護,否則就不做,
srcAddr 為跳轉指令所在的源地址,dstAddr 為目標地址,displacement 為目標地址與源地址的距離,
然而該代碼在計算 srcAddr 和 dstAddr 時,用的都是 offset,是相對距離:
-
atom->pOffset()和target->pOffset()都是atom相對于各自 Section 起始地址的距離,
-
fit->offsetInAtom和addend都是fixup相對于各自atom的距離,
因此,算出來的 srcAddr 和 dstAddr 都是 fixup 相對于各自所在 Section 起始地址的距離,而 displacement 又是根據 dstAddr 和 srcAddr 相減計算出來的,它的本意是要計算 dstAddr 與 srcAddr 之間的距離,在沒有 -rename_p 的情況下,這種計算方式沒有問題;在使用-rename_p 的情況下,會導致計算出來的距離 displacement 不準確,會使在預期對跳轉指令做保護的場景實際沒做保護,
2. Branch Island 演算法不會保護自定義 Section,
Branch Island 演算法只會對 typeCode 的 Section 做保護,而自定義 Section 的型別是 typeUnclassified,如果自定義 Section 中的代碼使用了跳轉指令,并且該跳轉指令的跳轉距離超出范圍,那么無論是否-rename_p 都會出現鏈接失敗的問題,
下面結合 3 個場景,來詳細分析 Branch Island 演算法的缺陷,
2.3.1 場景一
__TEXT,__text 移不干凈導致鏈接失敗,
__text 節在 __TEXT 段中所占比例巨大,要想達到優化效果,必須把它移走,否則幾乎沒有任何優化效果,頭條最開始時,使用-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text 來嘗試遷移 __TEXT,__text,但無論如何也移不干凈,總有一小部分還留在 __TEXT,__text 中,
導致的問題就是,頂部的 __TEXT,__text 與底部的 __BD_TEXT,__text 中的跳轉指令出現了跳轉距離超出限制情況,ld64 聯結器在 Output 的時候發現了這個錯誤,拋出例外,鏈接失敗,
前面我們已經知道了 Branch Island 演算法會對__text 中的跳轉指令做保護,會在跳轉距離超出限制時候插入 branch island,那為什么還會出現這種錯誤?
畫圖分析,假設在 Mach-O 檔案中, __TEXT,__text 的總大小為 110 ,其中有 A、B 兩個符號,跳轉指令會從 A 跳轉到 B,它們距離 Section __TEXT,__text 的 offset 分別是 30 和 90,它們的實際距離為 60,Branch Island 演算法會對跳轉指令進行保護,計算出 A、B 的間距 displacement 為 60,不會插入 branch island,在 Output 時,ld64 聯結器檢查出來它們的距離為 60,小于 128,不會拋出例外,鏈接成功,
在移走了其中 90 大小的 __TEXT,__text 后,__TEXT,__text 的大小變為了 20,B 被移到了 __BD_TEXT,__text, A、B 相對于各自 Section 的 offset 大概也會發生變化(這個不重要),假設分別變成了 5 和 80,
此刻,A 和 B 的實際距離是 80 + 40 + 15 = 135,但是,Branch Island 演算法在對跳轉指令做保護時,還是依照它們相對各自 Section 的距離來計算,計算出來它們的距離是 80 - 5 = 75,沒有插入 branch island,而實際 135 的大小在 arm64 和 armv7 的實際跳轉時是會出錯的,
在最后 Output 時,ld64 按照 A 和 B 在檔案中的絕對地址來計算距離,算出來它們的距離是 135,超出了 128,檢查出了這種由移動 Section 以及 Branch Island 演算法缺陷導致的錯誤,拋出了 branch out of range 的例外,鏈接失敗,
因此,若要移動 __TEXT,__text,就必須保證把 __TEXT,__text 全都移走,否則就可能出現鏈接失敗的問題,(如果你的 App 可執行檔案比較小,跳轉距離始終不會超過 128M 的話,則不會出現這種問題)
在 ld64-530 的 ld.cpp 檔案中發現,__TEXT,_text 移不干凈,是由 __TEXT, __textcoal_nt 和 __TEXT,__StaticInit 這兩個 Section 導致的,在原始碼中有如下片段:
這段代碼會把 __TEXT, __textcoal_nt 和 __TEXT,__StaticInit 都改名(merge)成 __TEXT,__text,還留在 __TEXT,__text 中的部分就是它們,
在網上查詢到 __textcoal_nt 是 gcc 產生的 Section,至少在 16 年的時候就已經廢棄,但目前還是有不少庫中攜帶的有這個 Section;__StaticInit 并沒有查到更多資訊,
我在蘋果的 ld 更新日志[9]找到這兩個 Section 的蹤跡,蘋果在 07、08 年的時候就已經會將這兩個 Section merge 到 __text 中去,
2008-07-15 Nick Kledzik <kledzik@apple.com>
<rdar://proBem/6061904> automatically order initializers to start of __TEXT
* src/MachOReaderRelocataBe.hpp: merge __StaticInit into __text
2007-04-30 Nick Kledzik <kledzik@apple.com>
<rdar://proBem/5065659> unaBe to link VTK because __textcoal_nt too large
* src/MachOReaderRelocataBe.hpp: when doing a final link map __textcoal_nt to __text
但蘋果的 merge 操作發生在我們-rename_p 之后,因此我們使用-rename_p,__TEXT,__text,__BD_TEXT,__text 沒有將它倆移走,
要讓 __TEXT,_text 移干凈,只需要把它倆也-rename_p,使用如下配置就可以了:
-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text,
-Wl,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text,
-Wl,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text
注:字串
__BD_TEXT中的 BD 是 ByteDance 的縮寫,__BD_TEXT只是一個名稱,可以隨意更改,
如果你的 App 中使用-rename_p,__TEXT,__text,__BD_TEXT,__text本身就能移干凈的話,那說明它不包含__TEXT,__textcoal_nt和__TEXT,__StaticInit,可以不添加該配置,
2.3.2 場景二
不移動 __stubs 導致鏈接失敗,
__TEXT 段遷移減少包大小的核心就是移走 __TEXT,__text,但是由于存在__TEXT,__text -> __TEXT,__stubs 的這種跳轉指令,所以如果只移動 __TEXT,__text 而不移動 __TEXT,__stubs ,就會出現和問題一中描述的類似的情況:Branch Island 演算法檢查的是__text 中的符號相對于 __BD_TEXT 的距離,__stubs 是相對于 __TEXT 的距離,該方式計算出來的 displacement 與它們的實際距離不符,在該插入 branch island 的地方沒有插入,Output 時檢查到錯誤,拋出例外,
但 __TEXT,__stubs 還有點不一樣的地方:
根據原始碼的邏輯,已知圖中框選分部中的 totalTextSize 是 __TEXT,__text 和 __TEXT,__stubs 的總大小,
代碼邏輯描述的是:如果 Section 的型別是 typeStub(arm64 中的__stubs,armv7 中的__picsymbolstub4),Branch Island 演算法會令跳轉指令的目標地址 dstAddr 為 totalTextSize,然后以此來計算間距 displacement,
這需要畫圖來分析:
如圖,在一個正常的 Mach-O 檔案中,__TEXT,__text 的大小是 110,__TEXT,__stubs 的大小是 20,A 符號存在于 __TEXT,__text 中,B 符號存在于 __TEXT,__stubs 中,A 距離 __TEXT,__text 的 offset 為 90,B 距離 __TEXT,__stubs 的 offset 為 10,A、B 的實際距離是 30,
在檢查時,Branch Island 演算法發現 B 位于 __TEXT,__stubs,于是直接令 dstAddr = 130(110 + 20 = 130),然后計算出它們的距離 displacement = 130 - 90 = 40,不插入 branch island,在最終 Output 時檢查出它們的實際距離為 30,小于 128 ,不會拋出例外,鏈接成功,
在移動 __TEXT,__text 之后,A 被移動到了 __BD_TEXT,A、B 的實際距離變成了 10 + 40 + 90 = 140,但 Branch Island 演算法計算方式 displacement = 130 - 90 = 40,沒有插入 branch island,這會導致實際的跳轉出錯,ld64 聯結器在最后的 Output 階段檢查出了這種錯誤,拋出例外,鏈接失敗,
ld64 聯結器知道 B 符號肯定位于__stubs內,所以 Branch Island 演算法的這種 dstAddr = totalSize; 的做法只會令 dstAddr 比實際的大,這樣可以保證__stubs 中距離超出的跳轉指令都會被插入 branch island,但由于 dstAddr 偏大了一些,所以實際上也多保護了一部分 實際上并沒有超出限制的跳轉指令,
Branch Island 演算法采用這種相對距離的計算方式,是因為在這個階段它拿不到 A 和 B 符號的絕對地址(絕對地址是在 Output 前才確定的),所以它采用了取巧的辦法,使用 A 和 B 相對于各自 Section 的 offset 來計算它們的距離,它默認了二進制檔案中只有一個型別是typeCode 的 Section, 并且這個 p 在 __TEXT,__stubs 的前面,這種演算法在正常的 Mach-O 檔案中是完全可行的,但我們如果移動了 Segment/Section,就不符合它的設定了,就會導致問題,
因此需要添加如下引數,將 __TEXT,__stubs 也移走:
-Wl,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs,
-Wl,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4
在 arm64 中,該 Section 的名稱叫做 __stubs,在 armv7 中,該 Section 的名稱叫做 __picsymbolstub4,為了適配不同的架構,可以將這個 Section 同時-rename,-rename 不存在的 Section 不會有問題,所以這種寫法是可以的,
這種做法對型別是 typeStub 的 Section(arm64 中的__stubs,armv7 中的__picsymbolstub4) 有另一種限制,就是在移動后, __text 和 __stubs 或 __picsymbolstub4 之間不能有別的 Section,否則可能會出現錯誤,如圖:
在正常 Mach-O 檔案中,A 符號 相對于 __text 的 offset 為 0,B 符號相對于 __stubs 的 offset 為 17.5,在移動 __text 和 __stubs 后,如果我們還移動了其它的 Section,那么這個 Section 有可能會出現在 __BD_TEXT,__text 與 __BD_TEXT,__stubs 之間,這將導致錯誤:
Branch Island 演算法的檢查方式判斷出 A 和 B 的距離為 (110 + 17.9) - 0 = 127.9,小于 128,因此沒有插入 branch island ,但移動后它們的實際距離是 110 + 0.5 + 17.5 = 128 ,是會導致跳轉出錯的,所以 ld64 聯結器會拋出例外,鏈接失敗,
不過這種鏈接失敗的情況比較苛刻,如果 A 的 offset 為 0, 那么它目標地址必須要落在 __stubs 中 [17.5, 17.90] 范圍,才會出現鏈接失敗,其余情況都不會出現,因為小于 17.50 的話,移動后 A 和 B 的實際距離也不會超出 128,并且 A 的 offset 必須要在 [0, 0.4] 范圍內才會出現這種情況,A 如果大于 0.4 的話,那 __text 移動后,A 跳轉到 B 的任意位置也不會超過 128M,
基于這一點,我們在移動 __cstring、__gcc_except_tab、__const、__objc_methname、__objc_classname、__objc_methtype 這幾個只讀 Section 的時候,不能把它們移到 __BD_TEXT 段中去,否則它們會出現在 __BD_TEXT,__text 與 __BD_TEXT,__stubs 之間導致錯誤,
解決的辦法就是使用原有的鏈接引數,將它們移動到另一個 Segment :__RODATA,這樣就可以避免這個問題:
-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring -Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab -Wl,-rename_p,__TEXT,__const,__RODATA,__const -Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname -Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname -Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype
2.3.3 場景三
自定義 Section 的問題,
在 「2.2 ld64 聯結器所做的事情」 中說到,跳轉指令共有四種跳轉情況,rangeCheck 檢查這四種情況;但是 Branch Island 演算法只會檢查兩種跳轉情況,它只會保護 __text中的跳轉指令,
跳轉指令的所有跳轉情況:
第 4 種情況只在存在自定義 Section,并且自定義 Section 中有跳轉指令時才會出現,
Branch Island 會保護的情況:
有兩種情況, Branch Island 演算法不會保護:
__TEXT,__stub_helper -> __TEXT,__stub_helper
__TEXT,__custom_p -> __TEXT,__text
原因是 Branch Island 只會對型別是 typeCode 的 Section 中的跳轉指令做檢查 ,而只有 __TEXT,__text 的型別是 typeCode,
那么,這兩種情況的跳轉指令,在實際跳轉中是否會出錯?
-
__TEXT,__stub_helper -> __TEXT,__stub_helper不會,因為__stub_helper的大小只有 28kb(在頭條中),遠小于 128M,所以它內部的指令再怎么跳都不會超出限制,
-
__TEXT,__custom_p -> __TEXT,__text,是有可能失敗的,
關于自定義 Section ,我們遇到過兩種情況,
A. 在一款 App 中有 __dof_RACSignal 和 __dof_RACCompou 兩個 Section,
這兩個 Section 是由 RAC 引入的,但是它們的 Number of Relocations 是 0,不涉及跳轉指令,它們不用處理,不會有鏈接失敗的問題,
B. 頭條中有一個 __u_selector Section:
它是依賴的某靜態庫引入的,__u_selector中包含一個重定位符號 ___Symbol_A,跳轉指令會從它跳轉到 __text 中的 ___Symbol_B,
除錯發現正常可執行檔案中,它們之間的距離是 10M 左右,不會出現鏈接失敗的,
可以推測___Symbol_B其實位于__text的底部, 而 __text 很大,如果把__text 移動到到 __u__selector 的下邊去,那么這兩個指令之間的距離就會增大,超過 128 MB 就會鏈接失敗,如圖:
所以在移動 __text 后,__custom_p (含跳轉指令的自定義 Section)也必須跟著移動,讓它保持在 __text 的下面,保持它們原有的相對位置,
照此分析,
__TEXT中的自定義 Section 不被 Branch Island 保護,如果二進制檔案足夠大,而這個 Section 又有跳轉指令,當跳轉距離超過 128 MB 時,也會鏈接失敗,與是否移動__text無關,
要移走自定義 Section,需要再添加如下配置:
-Wl,-rename_p,__TEXT,__custom_p,__CUSTOM_TEXT,__custom_p
這里必須要使用新的段 __CUSTOM_TEXT,而不能把自定義 Section 放到 __BD_TEXT 中,否則自定義 Section 會出現在__text 與 __stubs 之間,導致出現 "場景二" 后半部分中描述的問題,
3. 設定段的權限
由于將可執行代碼移動到了新的段 __BD_TEXT 和 __CUSTOM_TEXT 中,所以需要給這兩個段添加可讀和可執行權限,否則程式將無法運行:
-Wl,-segprot,__CUSTOM_TEXT,rx,rx
-Wl,-segprot,__BD_TEXT,rx,rx
五、一行代碼
在今日頭條 App 中是使用 xcconfig[10] 來管理構建引數的,如果你也使用該方式,那么使用下面這一行代碼就能完成配置:
APP_THIN_LINK_FLAGS = -Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab,-rename_p,__TEXT,__const,__RODATA,__const,-rename_p,__TEXT,__text,__BD_TEXT,__text,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4,-segprot,__BD_TEXT,rx,rx
如果你是沒有使用這種方式,在 Other Linker Flags 中逐行添加以下配置即可:
-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring
-Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname
-Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname
-Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype
-Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab
-Wl,-rename_p,__TEXT,__const,__RODATA,__const
-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text
-Wl,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text
-Wl,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text
-Wl,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs
-Wl,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4,
-Wl,-segprot,__BD_TEXT,rx,rx
如果你的二進制檔案中存在自定義 Section 的話,比如使用了類似__attribute__((p("__TEXT,__custom_p")))的方式創建了自定義 Section,則可能需要做如下的配置以移走自定義 Section,具體見 「2.3.3 場景三」 的詳細分析,
APP_THIN_LINK_FLAGS = -Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab,-rename_p,__TEXT,__const,__RODATA,__const,-rename_p,__TEXT,__text,__BD_TEXT,__text,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs,-segprot,__BD_TEXT,rx,rx,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4,-rename_p,__TEXT, __custom_p,__CUSTOM_TEXT,__text,-segprot, __CUSTOM_TEXT,rx,rx
六、答疑
1. 為什么不把 __TEXT 段中的所有 Section 都移走,這樣不是更好嗎?
并不是移走的段越多,壓縮就越有效,而是得看移走的大小,例如下面雖然有 15 個 Section,但是它們的大小加起來 578 KB,移走它們對壓縮后的下載大小幾乎零提升,
Section __stubs: 28488 (addr 0x105f21644 offset 99751492)
Section __stub_helper: 28428 (addr 0x105f2858c offset 99779980)
Section __swift5_typeref: 2216 (addr 0x105f2f498 offset 99808408)
Section __swift5_fieldmd: 1272 (addr 0x105f2fd40 offset 99810624)
Section __swift5_types: 120 (addr 0x105f30238 offset 99811896)
Section __const: 64184 (addr 0x105f302b0 offset 99812016)
Section __ustring: 281012 (addr 0x105f3fd68 offset 99876200)
Section __swift5_reflstr: 796 (addr 0x105f84720 offset 100157216)
Section __swift5_capture: 376 (addr 0x105f84a3c offset 100158012)
Section __swift5_builtin: 120 (addr 0x105f84bb4 offset 100158388)
Section __swift5_assocty: 312 (addr 0x105f84c2c offset 100158508)
Section __swift5_proto: 308 (addr 0x105f84d64 offset 100158820)
Section __swift5_protos: 40 (addr 0x105f84e98 offset 100159128)
Section __u__selector: 36 (addr 0x105f84ec0 offset 100159168)
Section __eh_frame: 184708 (addr 0x1060cf018 offset 101511192)
并且,有的 Section 是不能移走的,會引起 crash,有興趣的讀者可以自行嘗試,
2. 出現 Crash.log 決議不了的情況怎么辦?
在上線后,我們發現 Crash report 中的 Crash.log 中有一部分符號無法決議,如圖中的 ???,
出現這個問題的原因是,Crash.log 在分析主二進制鏡像時,把它在虛擬記憶體中的地址范圍取錯了,
如圖 0x100010000 - 0x100203fff 的范圍只有 2047999(2.0 MB),這明顯遠小于主二進制檔案中__text 原本的大小 100 MB,這個 2.0 MB 的大小基本與 __TEXT 段被遷移后剩余的大小相符,因此猜測 Crash.log 在分析時取的是 __TEXT 段的大小,而我們把大部分 __TEXT 段都移走了,
所以當遇到一個符號落在 (2.0M, 100M] 的區間中時,Crash.log 就無法知道這個地址它到底是屬于哪個鏡像,它就會顯示 ??? ,無法決議,
解決辦法:這種 Crash.log 使用 atos 工具[11]手動決議,將主鏡像名稱當做引數傳入即可,
七、總結
本文從背景知識和面臨的實際問題出發,介紹了 __TEXT 段遷移及減少下載大小的原理,描述了我們在實踐程序中遇到的問題,并從原始碼的角度詳細分析了問題產生的根本原因以及解決方式,解答了相關疑問和上線后遇到的問題,
目前,該方案已經在位元組跳動多個大型 App 中應用,均對下載大小有 30% 以上的優化,且運行穩定,
八、加入我們
我們是位元組跳動 General Information Platform - 客戶端平臺架構 iOS 團隊,在性能優化、基礎組件、業務架構、研發體系、安全合規、線下質量基礎設施、線上問題定位歸因平臺等方向深耕,負責保障和提升今日頭條、西瓜視頻和番茄小說的產品質量與開發效率,聚焦于此的同時向外延伸,
如果你對技術充滿熱情,喜歡追求極致,渴望用自己的代碼改變數億用戶的體驗,歡迎加入我們,我們期待你與我們共同成長,目前我們在北京、深圳均有招聘需求,簡歷投遞郵箱: tech@bytedance.com ;郵件標題: 姓名 - 作業年限 - GIP - 客戶端平臺架構 - iOS/Android ,
參考文獻
[1] 最大版本構建大小
https://help.apple.com/app-store-connect/#/dev611e0a21f
[2] 分析 facebook App
https://blog.timac.org/2016/1018-analysis-of-the-facebook-app-for-ios/
[3] App Store Connect Help
https://help.apple.com/app-store-connect/en.lproj/static.html
[4] iOS 13 更新日志
https://support.apple.com/en-us/HT210393#13
[5] dyld 檢查 Section 的原始碼
https://opensource.apple.com/source/dyld/dyld-750.6/src/dyldExceptions.c.auto.html
[6] ARM 架構
https://en.wikipedia.org/wiki/ARM_architecture#Thumb
[7] ld64-530 OutputFile 原始碼
https://opensource.apple.com/source/ld64/ld64-530/src/ld/OutputFile.cpp
[8] Branch Island 演算法
https://opensource.apple.com/source/ld64/ld64-133.3/src/ld/passes/branch_island.cpp.auto.html
[9] Ld 更新日志
https://opensource.apple.com/source/ld64/ld64-97.2/ChangeLog
[10] xcconfig 介紹
https://nshipster.com/xcconfig/
[11] atos man page
https://www.manpagez.com/man/1/atos/
歡迎關注「 位元組跳動技術團隊 」
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/242428.html
標籤:其他
上一篇:無線傳感器網路
