前言
最近遇到一些內(nèi)存相關(guān)crash,排查問題過程中產(chǎn)生對(duì)進(jìn)程內(nèi)整個(gè)地址空間分布的疑惑。搜查了一番資料,網(wǎng)上關(guān)于Linux進(jìn)程地址空間分布的介紹比較詳細(xì),但是iOS實(shí)際運(yùn)行效果的比較少。
本文基于網(wǎng)上相關(guān)文章,進(jìn)行實(shí)際測(cè)試,探究App實(shí)際運(yùn)行過程中的地址分布。
正文
32位的分布情況
32位的機(jī)器,每個(gè)進(jìn)程會(huì)有4G虛擬地址空間,較高的1G是從0xC0000000到0xFFFFFFFF的內(nèi)核空間(Kernel Space ),較低的3G是從0x00000000到0xBFFFFFFF用戶空間(User Space )。 內(nèi)核空間中存放的是內(nèi)核代碼和數(shù)據(jù),用戶空間中存放的是App進(jìn)程的代碼和數(shù)據(jù)。這里地址指的都是虛擬地址空間,由操作系統(tǒng)負(fù)責(zé)映射為物理地址。
把最常用的幾個(gè)概念堆、棧、數(shù)據(jù)段、代碼段做一個(gè)地址從大到小的排序:

- 棧:在函數(shù)調(diào)用過程中,每個(gè)函數(shù)都會(huì)有一個(gè)相關(guān)的區(qū)域來存儲(chǔ)函數(shù)的參數(shù)和局部變量,每次進(jìn)行函數(shù)調(diào)用的時(shí)候系統(tǒng)都會(huì)往棧壓入一個(gè)新的棧幀,在函數(shù)返回時(shí)清除。入棧和出棧的操作非??欤@個(gè)過程會(huì)用到兩個(gè)寄存器:fp和sp寄存器。
- 堆:在進(jìn)程運(yùn)行過程中,用于存儲(chǔ)局部變量之外的變量。工作中常用的malloc函數(shù)、new操作符等可以從堆中申請(qǐng)內(nèi)存。上面的棧很像數(shù)據(jù)結(jié)構(gòu)中的棧,但這里的堆并不像數(shù)據(jù)結(jié)構(gòu)的堆,其分配的方式是鏈表式,用brk()函數(shù)從操作系統(tǒng)批發(fā)內(nèi)存,再零售給用戶。
- 數(shù)據(jù)段:通常指的段和data段,bss段內(nèi)是未被初始化的靜態(tài)變量,data段是在代碼中已經(jīng)初始化的靜態(tài)變量。data段變大會(huì)導(dǎo)致啟動(dòng)速度變慢,bss段變大幾乎不影響。因?yàn)閎ss段只需要預(yù)留位置,并沒有真正的copy操作。相比data段增加的是具體的數(shù)據(jù),bss段增加的只是數(shù)據(jù)描述信息。
- 代碼段:程序運(yùn)行的機(jī)器指令,由代碼編譯產(chǎn)生。
64位的實(shí)際分布
對(duì)于一個(gè)iOS開發(fā)來說,目前大部分手機(jī)都是64位機(jī)器,還是需要對(duì)實(shí)際運(yùn)行結(jié)果進(jìn)行一些測(cè)試。
以下真機(jī)測(cè)試的機(jī)型是iPhone XS Max + iOS 14.5。
64位機(jī)器,進(jìn)程內(nèi)存地址從高到低分別是:
0xFFFF FFFF FFFF FFFF ??
內(nèi)核空間
用戶空間-保留區(qū)域
擴(kuò)展使用區(qū)域
系統(tǒng)共享庫
??臻g
內(nèi)存映射區(qū)域(mmap)
堆空間
BSS段
DATA段
Text段
0x0000 0000 0000 0000

常見概念-堆、棧、數(shù)據(jù)段、代碼段
堆和棧
用一段簡(jiǎn)單的代碼,分別從堆和棧上面創(chuàng)建一塊內(nèi)存:
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,可以大概知道棧和堆所在區(qū)域,0x16F4...是棧地址的開始,0x100E...是堆地址的開始。
數(shù)據(jù)段
bss段內(nèi)是未被初始化的靜態(tài)變量,data段是在代碼中已經(jīng)初始化的靜態(tài)變量。
// 函數(shù)外-靜態(tài)變量
static int vcStaticInt = 1024;
static int vcStaticNotInit;
// 函數(shù)內(nèi)
NSLog(@"0x%lx => data 0x%lx => bss", (long)&vcStaticInt, (long)&vcStaticNotInit);
vcStaticNotInit代表bss段,最終的地址是0x100945788。
vcStaticInt代表data段,最終的地址是0x1009455f8。
代碼段
代碼段是代碼編譯后的機(jī)器指令,可以用一個(gè)類來定位:
NSLog(@"class_address: 0x%lx\n", (long)[ViewController class]);
最終輸出的class_address是0x100945500。
將這幾個(gè)地址的大小進(jìn)行排序,可以看到有:
0x16F4C 5AF7(棧地址)
0x100E0 D8A0(堆地址)
0x10094 5788(bss段)
0x10094 55F8(data段)
0x10094 5500(Text段)
系統(tǒng)共享庫
下面是兩個(gè)不同App(bundle id不一樣)在同手機(jī)上的運(yùn)行crash日志,對(duì)比可以發(fā)現(xiàn):在dyld之前的系統(tǒng)庫地址不一樣,在dyld之后的地址都是一樣的。
App中存在很多系統(tǒng)動(dòng)態(tài)庫,在啟動(dòng)時(shí)依賴dyld加載系統(tǒng)動(dòng)態(tài)庫到內(nèi)存中。App依賴的具體系統(tǒng)動(dòng)態(tài)庫可能不同,但是都是iOS系統(tǒng)提供的。自然可以采用一種優(yōu)化App啟動(dòng)速度方法:將所有的的系統(tǒng)依賴庫按照固定的地址寫在某個(gè)固定區(qū)域,這樣只需保證App運(yùn)行時(shí)這塊內(nèi)存不被使用,就能保證所有App啟動(dòng)時(shí)候不需要去裝載所有的動(dòng)態(tài)庫。
內(nèi)存映射區(qū)域
在??臻g的下方和堆空間的上方,有一塊區(qū)域是內(nèi)存映射區(qū)域。系統(tǒng)可以將文件的內(nèi)容直接映射到內(nèi)存,App可以通過mmap()方法請(qǐng)求將磁盤上文件的地址信息與進(jìn)程用的虛擬邏輯地址進(jìn)行映射。相比普通的讀寫文件,當(dāng)App讀取一個(gè)文件時(shí)有兩步:先將文件從磁盤讀取到物理內(nèi)存,再從內(nèi)核空間拷貝到用戶空間。內(nèi)存映射則可以減少操作系統(tǒng)的地址轉(zhuǎn)換帶來的消耗。
可以寫一段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,注意到這個(gè)地址是在上面的堆和棧之間。
用戶空間-保留區(qū)域
這一塊沒有查到相關(guān)信息,如有資料求分享。以下是實(shí)際運(yùn)行的分析。
@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;
}
}
}
當(dāng)進(jìn)程不斷從堆空間申請(qǐng)內(nèi)存,剛開始的時(shí)候從堆空間分配的地址是小于??臻g地址,但是隨著內(nèi)存不斷被使用,在14700次左右的時(shí)候,堆空間分配的地址就會(huì)超過棧空間的地址。
14703 stack_address => 0x16d751aef heap_address => 0x16d630000
14704 stack_address => 0x16d751aef heap_address => 0x16db28000
然后在17000次左右的時(shí)候,出現(xiàn)了一次大的地址變動(dòng):從0x1變成了0x2a開始。0x2a的地址空間是在系統(tǒng)共享庫地址(0x1a)上方。
之所以有這樣的現(xiàn)象,個(gè)人理解是為了兼容32位的情況。因?yàn)椴还苁窍到y(tǒng)共享庫,還是堆、棧地址空間的大小,初始地址都是在32位的地址空間內(nèi)。而后面地址從0x2a0000000開始,就已經(jīng)超過了32位的地址空間,屬于64位機(jī)器的地址空間。最終運(yùn)行到達(dá)到63000次左右,一次是100KB,可以計(jì)算得到63000*100KB/1024/1024=6G左右的空間。
這時(shí)候產(chǎn)生了一個(gè)疑問:為什么32位的情況下,堆空間只有1G多空間大???為什么64位的情況下,堆空間也只有6G多空間大小?(可以先暫停閱讀,思考后見最下面分析)
思維發(fā)散
經(jīng)過上面的分析,再來解析一下以前的問題:
普通對(duì)象和靜態(tài)變量有哪些區(qū)別?
對(duì)象存儲(chǔ)區(qū)域不同,普通對(duì)象一般是在棧、堆上,但是靜態(tài)變量會(huì)存儲(chǔ)在數(shù)據(jù)段,地址會(huì)有較大的差別。
對(duì)象實(shí)例和對(duì)象方法的關(guān)系?
一個(gè)OC對(duì)象的實(shí)例,其實(shí)就是一塊存儲(chǔ)數(shù)據(jù)的內(nèi)存。內(nèi)存中有指針,可以指向?qū)ο蟮念惖刂罚ùa段);訪問一個(gè)對(duì)象方法其實(shí)是通過內(nèi)存中的指針找到類地址,然后將對(duì)象的內(nèi)存地址和調(diào)用的方法名作為參數(shù)傳遞。也可以用一種形象但可能不太恰當(dāng)?shù)谋扔鳎簣?zhí)行一個(gè)方法就像帶著原料跑到加工廠進(jìn)行流水線的處理,原料就是對(duì)象的內(nèi)存地址和其他傳入方法的內(nèi)存地址,流水線編譯生成的固定機(jī)器指令。
??臻g地址從高到低增長(zhǎng)?
前面已經(jīng)提到,在函數(shù)調(diào)用過程中,會(huì)往棧壓入一個(gè)新的棧幀,在函數(shù)返回時(shí)清除。
那么只需要構(gòu)造一個(gè)遞歸調(diào)用,觀察每個(gè)函數(shù)局部變量的地址即可觀察到??臻g的地址變化:
- (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");
}
需要注意,同一個(gè)函數(shù)內(nèi),先后申請(qǐng)兩個(gè)局部變量A和B,觀察A和B的地址,并不能看出??臻g的地址變化。因?yàn)橥粋€(gè)函數(shù)內(nèi)的局部變量可能會(huì)受到編譯器的優(yōu)化,導(dǎo)致不符合預(yù)期。所以觀察不同棧幀間的局部變量地址變化更為準(zhǔn)確。
通過上面的代碼可以知道,??臻g地址確實(shí)是從高到低增長(zhǎng),隨著遞歸函數(shù)的不斷調(diào)用,局部變量的地址也在不斷變小。在真機(jī)測(cè)試的情況下,兩次運(yùn)行的stackSize分別為 0x16ce86868和0x16ce86408,地址差為0x000000460, 轉(zhuǎn)換成二進(jìn)制4(16^2)+616=1024+96, 其中1024是申請(qǐng)的char數(shù)組,96則是函數(shù)遞歸調(diào)用的其他開銷。這段遞歸代碼運(yùn)行994次會(huì)報(bào)錯(cuò),由此可以計(jì)算主線程的??臻g有1MB左右。(此部分為實(shí)際運(yùn)行效果推算,不同環(huán)境下可能結(jié)果各異)
堆空間地址從低到高增長(zhǎng)?
堆空間的內(nèi)存分配方式與??臻g不同,如果先后從堆上創(chuàng)建兩個(gè)對(duì)象A和B,再對(duì)比兩個(gè)對(duì)象的內(nèi)存地址,那么A和B的大小應(yīng)該沒有直接關(guān)系。因?yàn)槎芽臻g存在對(duì)象的創(chuàng)建和銷毀,當(dāng)對(duì)象A和B創(chuàng)建時(shí),都有可能用到前面某些對(duì)象銷毀時(shí)被回收的內(nèi)存地址。
常說的堆空間地址從低到高增長(zhǎng),是Linux系統(tǒng)堆空間初始分配之后,擴(kuò)大堆空間大小的時(shí)候,會(huì)往高地址增長(zhǎng)。iOS實(shí)際運(yùn)行過程中,有可能先申請(qǐng)到一個(gè)很大的內(nèi)存地址,比如說下面這代碼:
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是上文用到一個(gè)自定義OC類,當(dāng)代碼實(shí)際運(yùn)行的時(shí)候,可以會(huì)看到輸出
oc_object_address => 0x283d84cb0 oc_big_object_address => 0x1026b8000
其中oc_object的地址是0x283d84cb0,而oc_big_object的地址是0x1026b8000。
0x28開頭的地址也會(huì)被用于分配內(nèi)存,一般用于內(nèi)存較小的情況,而內(nèi)存比較大的時(shí)候仍然會(huì)從正常的堆地址空間開始。(這個(gè)不同地址取決于libsystem_malloc.dylib對(duì)申請(qǐng)內(nèi)存大小的不同處理)
為什么32位的情況下,堆空間只有1G多空間大?。繛槭裁?4位的情況下,堆空間也只有6G多空間大?。?/h5>
操作系統(tǒng)內(nèi)存是段頁式管理,App先分段再分頁,頁是內(nèi)存管理的基本單位。(32位是4096B=4KB,64位是16KB)
當(dāng)App訪問虛擬內(nèi)存時(shí),操作系統(tǒng)會(huì)檢查虛擬內(nèi)存對(duì)應(yīng)物理內(nèi)存是否存在,如果不存在則觸發(fā)一次缺頁中斷(Page Fault),將數(shù)據(jù)從磁盤加載到物理內(nèi)存中,并建立物理內(nèi)存和虛擬內(nèi)存的映射。
32位機(jī)器的虛擬空間最多只有4G,其中1G還要留給內(nèi)核空間,堆和棧之間能留下來的空間并不寬裕,即使加上棧空間到系統(tǒng)共享庫之間的區(qū)域,總共也只有1G多空間。而64位的機(jī)器用于充足的虛擬地址空間,虛擬內(nèi)存占用超過1G多之后,會(huì)從0x2a開始申請(qǐng)?zhí)摂M地址。但是由于有物理內(nèi)存的限制,過大的虛擬內(nèi)存占用會(huì)導(dǎo)致物理內(nèi)存快速消耗,當(dāng)物理內(nèi)存被消耗完成后,就需要釋放現(xiàn)有的內(nèi)存頁。所以App并不需要有非常大的虛擬內(nèi)存,因?yàn)槠款i往往出現(xiàn)在物理內(nèi)存上面。
另外這里為什么可以創(chuàng)建6G的虛擬內(nèi)存,這是因?yàn)闇y(cè)試代碼申請(qǐng)的內(nèi)存頁大都沒有寫入操作,當(dāng)內(nèi)存有壓力的時(shí)候,會(huì)被系統(tǒng)進(jìn)行壓縮成Compressed Memory。如果增加一個(gè)簡(jiǎn)單的寫入操作,那么這個(gè)內(nèi)存頁就變成了臟內(nèi)存,進(jìn)程在1G多占用的時(shí)候就會(huì)被操作系統(tǒng)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指令可以得到二進(jìn)制分布,比如說下面的objdump -d LearnMemoryAddress
總結(jié)
本文為實(shí)際運(yùn)行結(jié)果的分析,測(cè)試機(jī)型-iPhone XS Max + iOS 14.5。
實(shí)際運(yùn)行結(jié)果的解析部分可能存在錯(cuò)誤,如果發(fā)現(xiàn)請(qǐng)幫忙糾正。
知道各個(gè)地址空間的分布,能幫助我們更好理解iOS系統(tǒng)。在面對(duì)內(nèi)存相關(guān)crash的時(shí)候,看到地址就能大概判斷是屬于哪一個(gè)區(qū)域,也能更加清晰具體去解析錯(cuò)誤。