前言
最近遇到一些內存相關crash,排查問題過程中產生對進程內整個地址空間分布的疑惑。搜查了一番資料,網上關于Linux進程地址空間分布的介紹比較詳細,但是iOS實際運行效果的比較少。
本文基于網上相關文章,進行實際測試,探究App實際運行過程中的地址分布。
正文
32位的分布情況
32位的機器,每個進程會有4G虛擬地址空間,較高的1G是從0xC0000000到0xFFFFFFFF的內核空間(Kernel Space ),較低的3G是從0x00000000到0xBFFFFFFF用戶空間(User Space )。 內核空間中存放的是內核代碼和數據,用戶空間中存放的是App進程的代碼和數據。這里地址指的都是虛擬地址空間,由操作系統負責映射為物理地址。
把最常用的幾個概念堆、棧、數據段、代碼段做一個地址從大到小的排序:
棧:在函數調用過程中,每個函數都會有一個相關的區域來存儲函數的參數和局部變量,每次進行函數調用的時候系統都會往棧壓入一個新的棧幀,在函數返回時清除。入棧和出棧的操作非常快,這個過程會用到兩個寄存器:fp和sp寄存器。
堆:在進程運行過程中,用于存儲局部變量之外的變量。工作中常用的malloc函數、new操作符等可以從堆中申請內存。上面的棧很像數據結構中的棧,但這里的堆并不像數據結構的堆,其分配的方式是鏈表式,用brk()函數從操作系統批發內存,再零售給用戶。
數據段:通常指的段和data段,bss段內是未被初始化的靜態變量,data段是在代碼中已經初始化的靜態變量。data段變大會導致啟動速度變慢,bss段變大幾乎不影響。因為bss段只需要預留位置,并沒有真正的copy操作。相比data段增加的是具體的數據,bss段增加的只是數據描述信息。
代碼段:程序運行的機器指令,由代碼編譯產生。
64位的實際分布
對于一個iOS開發來說,目前大部分手機都是64位機器,還是需要對實際運行結果進行一些測試。
以下真機測試的機型是iPhone XS Max + iOS 14.5。
64位機器,進程內存地址從高到低分別是:
0xFFFF FFFF FFFF FFFF ??
內核空間
用戶空間-保留區域
擴展使用區域
系統共享庫
棧空間
內存映射區域(mmap)
堆空間
BSS段
DATA段
Text段
0x0000 0000 0000 0000
常見概念-堆、棧、數據段、代碼段
堆和棧
用一段簡單的代碼,分別從堆和棧上面創建一塊內存:
char stack_address;
UIView *heap_view_address = [[UIView alloc] init];
NSLog(@"0x%016lx => stack 0x%016lx => heap", (long)&stack_address, (long)heap_view_address);
輸出 0x16f4c5af7 => stack 0x100e0d8a0 => heap
,可以大概知道棧和堆所在區域,0x16F4...是棧地址的開始,0x100E...是堆地址的開始。
數據段
bss段內是未被初始化的靜態變量,data段是在代碼中已經初始化的靜態變量。
// 函數外-靜態變量
static int vcStaticInt = 1024;
static int vcStaticNotInit;
// 函數內
NSLog(@"0x%lx => data 0x%lx => bss", (long)&vcStaticInt, (long)&vcStaticNotInit);
vcStaticNotInit代表bss段,最終的地址是0x100945788。
vcStaticInt代表data段,最終的地址是0x1009455f8。
代碼段
代碼段是代碼編譯后的機器指令,可以用一個類來定位:
NSLog(@"class_address: 0x%lx\n", (long)[ViewController class]);
最終輸出的class_address是0x100945500。
將這幾個地址的大小進行排序,可以看到有:
0x16F4C 5AF7(棧地址)
0x100E0 D8A0(堆地址)
0x10094 5788(bss段)
0x10094 55F8(data段)
0x10094 5500(Text段)
系統共享庫
下面是兩個不同App(bundle id不一樣)在同手機上的運行crash日志,對比可以發現:在dyld之前的系統庫地址不一樣,在dyld之后的地址都是一樣的。
App中存在很多系統動態庫,在啟動時依賴dyld加載系統動態庫到內存中。App依賴的具體系統動態庫可能不同,但是都是iOS系統提供的。自然可以采用一種優化App啟動速度方法:將所有的的系統依賴庫按照固定的地址寫在某個固定區域,這樣只需保證App運行時這塊內存不被使用,就能保證所有App啟動時候不需要去裝載所有的動態庫。
內存映射區域
在棧空間的下方和堆空間的上方,有一塊區域是內存映射區域。系統可以將文件的內容直接映射到內存,App可以通過mmap()方法請求將磁盤上文件的地址信息與進程用的虛擬邏輯地址進行映射。相比普通的讀寫文件,當App讀取一個文件時有兩步:先將文件從磁盤讀取到物理內存,再從內核空間拷貝到用戶空間。內存映射則可以減少操作系統的地址轉換帶來的消耗。
可以寫一段mmap的代碼來觀察生成的地址
- (void)testMmap {
NSString *imagePathStr = [[NSBundle mainBundle] pathForResource:@"abc" ofType:@"png"];
size_t dataLength;
void *dataPtr;
// MapFile是自己寫的mmap方法
int errorCode = MapFile([imagePathStr cStringUsingEncoding:NSUTF8StringEncoding], &dataPtr, &dataLength);
NSLog(@"mmapData:0x%lx, bytes_address:0x%lx, size:%d, error:%d", (long)dataPtr, (long)dataPtr, (long)dataLength, errorCode);
}
最終輸出的dataLength地址是0x1026b8000,size是18432,注意到這個地址是在上面的堆和棧之間。
用戶空間-保留區域
這一塊沒有查到相關信息,如有資料求分享。以下是實際運行的分析。
@interface TestOCObject : NSObject
@property (nonatomic, readonly, assign) char *name_buffer;
@end
@implementation TestOCObject {
char name[102400];
}
- (char *)name_buffer {
return name;
}
@end
- (void)testHeapSize:(int)count {
NSMutableArray<TestOCObject *> *arr = [NSMutableArray new];
while (true) {
char stackSize;
TestOCObject *obj = [[TestOCObject alloc] init];
++count;
if (obj) {
NSLog(@"%05d stack_address => 0x%lx heap_address => 0x%lx chars => 0x%lx", count, (long)&stackSize, (long)obj, (long)obj.name_buffer);
[arr addObject:obj];
}
else {
break;
}
}
}
當進程不斷從堆空間申請內存,剛開始的時候從堆空間分配的地址是小于棧空間地址,但是隨著內存不斷被使用,在14700次左右的時候,堆空間分配的地址就會超過棧空間的地址。
14703 stack_address => 0x16d751aef heap_address => 0x16d630000
14704 stack_address => 0x16d751aef heap_address => 0x16db28000
然后在17000次左右的時候,出現了一次大的地址變動:從0x1變成了0x2a開始。0x2a的地址空間是在系統共享庫地址(0x1a)上方。
之所以有這樣的現象,個人理解是為了兼容32位的情況。因為不管是系統共享庫,還是堆、棧地址空間的大小,初始地址都是在32位的地址空間內。而后面地址從0x2a0000000開始,就已經超過了32位的地址空間,屬于64位機器的地址空間。最終運行到達到63000次左右,一次是100KB,可以計算得到63000*100KB/1024/1024=6G左右的空間。
這時候產生了一個疑問:為什么32位的情況下,堆空間只有1G多空間大小?為什么64位的情況下,堆空間也只有6G多空間大小?(可以先暫停閱讀,思考后見最下面分析)
思維發散
經過上面的分析,再來解析一下以前的問題:
普通對象和靜態變量有哪些區別?
對象存儲區域不同,普通對象一般是在棧、堆上,但是靜態變量會存儲在數據段,地址會有較大的差別。
對象實例和對象方法的關系?
一個OC對象的實例,其實就是一塊存儲數據的內存。內存中有指針,可以指向對象的類地址(代碼段);訪問一個對象方法其實是通過內存中的指針找到類地址,然后將對象的內存地址和調用的方法名作為參數傳遞。也可以用一種形象但可能不太恰當的比喻:執行一個方法就像帶著原料跑到加工廠進行流水線的處理,原料就是對象的內存地址和其他傳入方法的內存地址,流水線編譯生成的固定機器指令。
棧空間地址從高到低增長?
前面已經提到,在函數調用過程中,會往棧壓入一個新的棧幀,在函數返回時清除。
那么只需要構造一個遞歸調用,觀察每個函數局部變量的地址即可觀察到棧空間的地址變化:
- (void)testStackSize:(int)count {
char stackSize[1024];
NSLog(@"%05d stack_address => 0x%lx ", count, (long)&stackSize);
if (count < 1000) {
++count;
[self testStackSize:count];
}
else {
NSLog(@"end");
}
需要注意,同一個函數內,先后申請兩個局部變量A和B,觀察A和B的地址,并不能看出棧空間的地址變化。因為同一個函數內的局部變量可能會受到編譯器的優化,導致不符合預期。所以觀察不同棧幀間的局部變量地址變化更為準確。
通過上面的代碼可以知道,棧空間地址確實是從高到低增長,隨著遞歸函數的不斷調用,局部變量的地址也在不斷變小。在真機測試的情況下,兩次運行的stackSize分別為 0x16ce86868和0x16ce86408,地址差為0x000000460, 轉換成二進制4(16^2)+616=1024+96, 其中1024是申請的char數組,96則是函數遞歸調用的其他開銷。這段遞歸代碼運行994次會報錯,由此可以計算主線程的棧空間有1MB左右。(此部分為實際運行效果推算,不同環境下可能結果各異)
堆空間地址從低到高增長?
堆空間的內存分配方式與棧空間不同,如果先后從堆上創建兩個對象A和B,再對比兩個對象的內存地址,那么A和B的大小應該沒有直接關系。因為堆空間存在對象的創建和銷毀,當對象A和B創建時,都有可能用到前面某些對象銷毀時被回收的內存地址。
常說的堆空間地址從低到高增長,是Linux系統堆空間初始分配之后,擴大堆空間大小的時候,會往高地址增長。iOS實際運行過程中,有可能先申請到一個很大的內存地址,比如說下面這代碼:
NSObject *oc_object = [[NSObject alloc] init];
TestOCObject *oc_big_object = [[TestOCObject alloc] init];
NSLog(@"oc_object_address => 0x%lx oc_big_object_address => 0x%lx", (long)oc_object, (long)oc_big_object);
TestOCObject是上文用到一個自定義OC類,當代碼實際運行的時候,可以會看到輸出
oc_object_address => 0x283d84cb0 oc_big_object_address => 0x1026b8000
其中oc_object的地址是0x283d84cb0,而oc_big_object的地址是0x1026b8000。
0x28開頭的地址也會被用于分配內存,一般用于內存較小的情況,而內存比較大的時候仍然會從正常的堆地址空間開始。(這個不同地址取決于libsystem_malloc.dylib對申請內存大小的不同處理)
為什么32位的情況下,堆空間只有1G多空間大小?為什么64位的情況下,堆空間也只有6G多空間大小?
操作系統內存是段頁式管理,App先分段再分頁,頁是內存管理的基本單位。(32位是4096B=4KB,64位是16KB)
當App訪問虛擬內存時,操作系統會檢查虛擬內存對應物理內存是否存在,如果不存在則觸發一次缺頁中斷(Page Fault),將數據從磁盤加載到物理內存中,并建立物理內存和虛擬內存的映射。
32位機器的虛擬空間最多只有4G,其中1G還要留給內核空間,堆和棧之間能留下來的空間并不寬裕,即使加上棧空間到系統共享庫之間的區域,總共也只有1G多空間。而64位的機器用于充足的虛擬地址空間,虛擬內存占用超過1G多之后,會從0x2a開始申請虛擬地址。但是由于有物理內存的限制,過大的虛擬內存占用會導致物理內存快速消耗,當物理內存被消耗完成后,就需要釋放現有的內存頁。所以App并不需要有非常大的虛擬內存,因為瓶頸往往出現在物理內存上面。
另外這里為什么可以創建6G的虛擬內存,這是因為測試代碼申請的內存頁大都沒有寫入操作,當內存有壓力的時候,會被系統進行壓縮成Compressed Memory。如果增加一個簡單的寫入操作,那么這個內存頁就變成了臟內存,進程在1G多占用的時候就會被操作系統kill。
- (void)testHeapSize:(int)count {
NSMutableArray<TestOCObject *> *arr = [NSMutableArray new];
while (true) {
char stackSize;
TestOCObject *obj = [[TestOCObject alloc] init];
++count;
if (obj) {
NSLog(@"%05d stack_address => 0x%lx heap_address => 0x%lx chars => 0x%lx", count, (long)&stackSize, (long)obj, (long)obj.name_buffer);
// 增加write操作
for (int i = 0; i < 100; ++i) {
memcpy(obj.name_buffer + (i * 1024), "hello", 6);
}
[arr addObject:obj];
}
else {
break;
}
}
}
輔助工具
objdump指令可以得到二進制分布,比如說下面的objdump -d LearnMemoryAddress
總結
本文為實際運行結果的分析,測試機型-iPhone XS Max + iOS 14.5。
實際運行結果的解析部分可能存在錯誤,如果發現請幫忙糾正。
知道各個地址空間的分布,能幫助我們更好理解iOS系統。在面對內存相關crash的時候,看到地址就能大概判斷是屬于哪一個區域,也能更加清晰具體去解析錯誤。