前言
最近遇到一些內(nèi)存相關(guān)crash攘烛,排查問題過程中產(chǎn)生對進(jìn)程內(nèi)整個(gè)地址空間分布的疑惑坟漱。搜查了一番資料靖秩,網(wǎng)上關(guān)于Linux進(jìn)程地址空間分布的介紹比較詳細(xì)竖瘾,但是iOS實(shí)際運(yùn)行效果的比較少捕传。
本文基于網(wǎng)上相關(guān)文章,進(jìn)行實(shí)際測試职辅,探究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操作符等可以從堆中申請內(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í)際分布
對于一個(gè)iOS開發(fā)來說崖媚,目前大部分手機(jī)都是64位機(jī)器至扰,還是需要對實(shí)際運(yùn)行結(jié)果進(jìn)行一些測試资锰。
以下真機(jī)測試的機(jī)型是iPhone XS Max + iOS 14.5绷杜。
64位機(jī)器鞭盟,進(jìn)程內(nèi)存地址從高到低分別是:
0xFFFF FFFF FFFF FFFF ??
內(nèi)核空間
用戶空間-保留區(qū)域
擴(kuò)展使用區(qū)域
系統(tǒng)共享庫
棾菟撸空間
內(nèi)存映射區(qū)域(mmap)
堆空間
BSS段
DATA段
Text段
0x0000 0000 0000 0000
常見概念-堆粤剧、棧抵恋、數(shù)據(jù)段弧关、代碼段
堆和棧
用一段簡單的代碼世囊,分別從堆和棧上面創(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日志碧聪,對比可以發(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ū)域
在棾空間的下方和堆空間的上方谊却,有一塊區(qū)域是內(nèi)存映射區(qū)域炎辨。系統(tǒng)可以將文件的內(nèi)容直接映射到內(nèi)存碴萧,App可以通過mmap()方法請求將磁盤上文件的地址信息與進(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)程不斷從堆空間申請內(nèi)存章蚣,剛開始的時(shí)候從堆空間分配的地址是小于棧空間地址洒忧,但是隨著內(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)過上面的分析脐湾,再來解析一下以前的問題:
普通對象和靜態(tài)變量有哪些區(qū)別鹰霍?
對象存儲(chǔ)區(qū)域不同孟岛,普通對象一般是在棧、堆上荧恍,但是靜態(tài)變量會(huì)存儲(chǔ)在數(shù)據(jù)段雌芽,地址會(huì)有較大的差別。
對象實(shí)例和對象方法的關(guān)系?
一個(gè)OC對象的實(shí)例,其實(shí)就是一塊存儲(chǔ)數(shù)據(jù)的內(nèi)存。內(nèi)存中有指針跃须,可以指向?qū)ο蟮念惖刂罚ùa段)投储;訪問一個(gè)對象方法其實(shí)是通過內(nèi)存中的指針找到類地址勋眯,然后將對象的內(nèi)存地址和調(diào)用的方法名作為參數(shù)傳遞志秃。也可以用一種形象但可能不太恰當(dāng)?shù)谋扔鳎簣?zhí)行一個(gè)方法就像帶著原料跑到加工廠進(jìn)行流水線的處理钧舌,原料就是對象的內(nèi)存地址和其他傳入方法的內(nèi)存地址,流水線編譯生成的固定機(jī)器指令。
椢荼耄空間地址從高到低增長蟹但?
前面已經(jīng)提到口糕,在函數(shù)調(diào)用過程中,會(huì)往棧壓入一個(gè)新的棧幀,在函數(shù)返回時(shí)清除再扭。
那么只需要構(gòu)造一個(gè)遞歸調(diào)用泛范,觀察每個(gè)函數(shù)局部變量的地址即可觀察到椙裕空間的地址變化:
- (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)昌渤,先后申請兩個(gè)局部變量A和B潜支,觀察A和B的地址裁替,并不能看出椬锻铮空間的地址變化。因?yàn)橥粋€(gè)函數(shù)內(nèi)的局部變量可能會(huì)受到編譯器的優(yōu)化,導(dǎo)致不符合預(yù)期。所以觀察不同棧幀間的局部變量地址變化更為準(zhǔn)確粮彤。
通過上面的代碼可以知道,棾揪澹空間地址確實(shí)是從高到低增長贰逾,隨著遞歸函數(shù)的不斷調(diào)用言缤,局部變量的地址也在不斷變小哮独。在真機(jī)測試的情況下悴务,兩次運(yùn)行的stackSize分別為 0x16ce86868和0x16ce86408别洪,地址差為0x000000460痢毒, 轉(zhuǎn)換成二進(jìn)制4(16^2)+616=1024+96凭舶, 其中1024是申請的char數(shù)組义屏,96則是函數(shù)遞歸調(diào)用的其他開銷兄墅。這段遞歸代碼運(yùn)行994次會(huì)報(bào)錯(cuò),由此可以計(jì)算主線程的棧空間有1MB左右。(此部分為實(shí)際運(yùn)行效果推算,不同環(huán)境下可能結(jié)果各異)
堆空間地址從低到高增長灌具?
堆空間的內(nèi)存分配方式與棧空間不同,如果先后從堆上創(chuàng)建兩個(gè)對象A和B,再對比兩個(gè)對象的內(nèi)存地址帖努,那么A和B的大小應(yīng)該沒有直接關(guān)系匙监。因?yàn)槎芽臻g存在對象的創(chuàng)建和銷毀,當(dāng)對象A和B創(chuàng)建時(shí),都有可能用到前面某些對象銷毀時(shí)被回收的內(nèi)存地址击儡。
常說的堆空間地址從低到高增長鸽疾,是Linux系統(tǒng)堆空間初始分配之后,擴(kuò)大堆空間大小的時(shí)候款慨,會(huì)往高地址增長圣猎。iOS實(shí)際運(yùn)行過程中,有可能先申請到一個(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對申請內(nèi)存大小的不同處理)
為什么32位的情況下,堆空間只有1G多空間大刑芤邸巩梢?為什么64位的情況下,堆空間也只有6G多空間大欣业狻募谎?
操作系統(tǒng)內(nèi)存是段頁式管理,App先分段再分頁,頁是內(nèi)存管理的基本單位缭黔。(32位是4096B=4KB,64位是16KB)
當(dāng)App訪問虛擬內(nèi)存時(shí)管宵,操作系統(tǒng)會(huì)檢查虛擬內(nèi)存對應(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開始申請?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試代碼申請的內(nèi)存頁大都沒有寫入操作,當(dāng)內(nèi)存有壓力的時(shí)候吱七,會(huì)被系統(tǒng)進(jìn)行壓縮成Compressed Memory鹤竭。如果增加一個(gè)簡單的寫入操作踊餐,那么這個(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é)果的分析,測試機(jī)型-iPhone XS Max + iOS 14.5吧寺。
實(shí)際運(yùn)行結(jié)果的解析部分可能存在錯(cuò)誤窜管,如果發(fā)現(xiàn)請幫忙糾正。
知道各個(gè)地址空間的分布稚机,能幫助我們更好理解iOS系統(tǒng)幕帆。在面對內(nèi)存相關(guān)crash的時(shí)候,看到地址就能大概判斷是屬于哪一個(gè)區(qū)域赖条,也能更加清晰具體去解析錯(cuò)誤失乾。