ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Block Copy与内存布局:从结构体到LLDB的完整拆解

Block Copy与内存布局:从结构体到LLDB的完整拆解 1. 为什么必须理解Block Copy与内存布局先抛一个我早年面试别人时最常问的问题在MRC时代把Block从函数里return出去毫无征兆地崩了在ARC时代同样的代码却活得好好的为什么如果你不能在三句话内讲清楚那这篇文章就是为你写的。Block Copy说白了就是对Block做一次拷贝操作。但“拷贝”这个词极具迷惑性它远不是像memcpy那样把一块内存原样复制一份那么简单。Block在内存里不是一张扁平的二极管图纸而是一个带指针、带描述符、带捕获变量列表的复合结构。拷贝时编译器要根据Block的类型、捕获变量的类型生成完全不同的处理逻辑。稍有不慎就是EXC_BAD_ACCESS或者更隐蔽的——malloc: double free。理解Block Copy的前提是先解锁Block的内存布局。这就像你修车之前得先知道发动机舱里每根管子通向哪里。网上讲Block原理的文章很多但绝大多数都在讲“什么是栈Block、堆Block、全局Block”讲得跟背课文一样。真正实操时你想在调试器里看到Block的结构体字段你想解释为什么__block变量被copy之后地址会变你想搞懂_Block_object_assign到底按什么规则替你把对象retain住——这些细节才是决定你排bug效率的分水岭。这篇文章我从底向上拆解先讲Block在C层面的真实结构再讲Copy动作发生前后内存里到底发生了什么接着用LLDB和一个Visual Studio的小技巧带你亲手验证内存布局最后是日常开发中最容易踩的坑。适合三类人想搞定iOS底层面试题的求职者、被Block内存问题折磨过的一线开发、以及所有想真正看懂编译器行为的程序员。2. Block的三种身份与内存归属2.1 Block不是函数指针是带着“行李”的结构体很多人第一次接触Block觉得它就是个匿名函数。这个直觉对了一半编译器确实把它编译成一个C函数但调用这个函数需要的不仅仅是函数地址还有它捕获的外部变量。于是编译器造出了一个结构体里面存着函数指针以及捕获变量的值。这个结构体在Objective-C的运行时头文件里有一个经典定义在Block.h的BlockLayout中体现得很清楚。它的头部是struct Block_layout { void *isa; volatile int32_t flags; int32_t reserved; void (*invoke)(void *, ...); struct Block_descriptor *descriptor; // 捕获变量从这里开始排列 };isa指针就是Block作为“对象”的身份证flags记录了这个Block到底属于什么类型invoke就是真正要被调用的函数指针descriptor里则保存着Block的大小、签名、copy/dispose函数指针。这些字段一个都不能少少了任何一个Block都无法被运行时正确管理。2.2 栈Block、堆Block、全局Block的判定规则在ARC和MRC下Block的初始归属不完全一样但大逻辑一致。不捕获任何外部变量的Block是全局Block它存在静态区直接复用同一个实例copy只是返回自身。捕获了外部变量但在栈上创建的Block是栈Block它的生命周期跟所在作用域强绑定。栈Block被copy之后才变成堆Block堆Block由运行时负责引用计数管理。我每次讲到这里都会画一个表方便视觉记忆Block类型存储区域copy操作结果是否持有捕获对象典型场景全局Block_NSConcreteGlobalBlock静态数据区原样返回自身不涉及不捕获外部变量、只使用常量栈Block_NSConcreteStackBlock函数栈帧拷贝到堆上并处理捕获变量不自动持有依赖作用域捕获局部变量的Block未copy堆Block_NSConcreteMallocBlock堆区引用计数1按需持有执行过copy、ARC下作为返回值等在LLDB里可以通过isa快速验证一个Block的类型。我记得有一次排查一个诡异崩溃就是因为在dispatch_after里使用了一个没有被copy到堆上的栈Block结果队列异步执行时栈内存早已失效crash日志指向了一个完全不相干的地址。从那以后我只要看到Block逃逸第一反应就是check它有没有被正确copy。3. Block结构体的细节解剖3.1 Block_descriptor里到底装了什么没有descriptorBlock就是一个空壳子。它记录的是Block实例的元信息。不同时代的运行时对这个结构有过调整现在稳定版本的核心字段大体如下struct Block_descriptor_1 { uintptr_t reserved; uintptr_t size; }; struct Block_descriptor_2 { // 仅在需要时存在 void (*copy)(void *dst, const void *src); void (*dispose)(const void *); }; struct Block_descriptor_3 { const char *signature; const char *layout; };size字段很重要它告诉运行时这个Block实例占多大内存copy时才知道该搬运多少字节。copy和dispose函数指针是Block处理捕获对象的核心入口signature是Block的类型编码方法签名layout则描述强引用对象的偏移位置这些配合起来才能做到精准的内存管理。有一点容易踩坑Block_descriptor_2和Block_descriptor_3不是所有Block都有的它们的存在由flags里的位决定。比如只有捕获了__strong对象或__block变量时flags才会标记BLOCK_HAS_COPY_DISPOSEdescriptor里才存在copy/dispose指针。如果你手动解析Block结构体忘记判断这个标志位轻则读到脏数据重则直接崩溃。3.2 捕获变量的内存排列规则捕获变量就存放在Block_layout结构体末尾按照声明顺序依次排列。这里有个容易搞混的点Block捕获的是“值”还是“引用”捕获基本类型变量把变量值拷贝一份存进结构体。捕获对象变量在MRC下默认不retain在ARC下按__strong语义retain。捕获__block变量不是把变量本身拷进去而是生成一个Block_byref结构体包装变量Block里存的是这个结构体的指针。__block变量的包装结构体也有自己的内存布局简化版本是这样struct Block_byref { void *isa; struct Block_byref *forwarding; int32_t flags; int32_t size; // 真正的变量数据从这里的偏移量开始 };这里面的forwarding指针是理解__block变量的关键。变量被copy到堆上后原来的栈上结构体的forwarding会指向堆上的新结构体堆上的forwarding则指向自己。这样无论从哪个Block里访问__block变量拿到最终值都走同一条路。这也解释了一个经典现象__block局部变量被copy之后你在Block内外打印它的地址前后不一样。因为这时的“地址”是整个Block_byref结构体的地址复制动作让这个结构体搬家了。4. Block Copy的完整过程拆解4.1 _Block_copy函数内部发生了什么当编译器看到[block copy]、Block_copy()或ARC下把一个栈Block赋值给强引用变量时最终都会调用运行时函数_Block_copy。它做的事可以概括为三步如果Block已经在堆上直接对引用计数加1并返回原地址。如果Block是全局Block直接返回原地址。如果是栈Block则在堆上分配一块新内存把原Block结构体按位复制过去然后重置引用计数再调用descriptor里的copy函数处理捕获的对象。在第三步里_Block_copy会修改新Block的isa为_NSConcreteMallocBlock并把flags里对应栈Block的标志位改掉。真正让捕获对象安全的不是这次“结构体搬运”而是后面紧跟着的Block_descriptor_2-copy函数。4.2 捕获对象的copy辅助函数干了什么很多人在这一步想不明白为什么搬运Block结构体就必须要处理外部对象因为外部对象的生命周期跟Block绑定了如果Block被搬到堆上而它retain的对象还在栈上比如捕获的__block结构体里引用的对象那同样会面临野指针问题。Block_descriptor_2里的copy函数编译器会为每个含有需要管理的捕获变量生成一个辅助函数。这个函数内部会遍历捕获变量列表对__strong对象调用objc_retain对__block变量调用_Block_object_assign。_Block_object_assign函数内部根据变量类型分发捕获变量类型_Block_object_assign的行为__strong对象对对象执行retain并把新引用存入新Block__block对象把Block_byref从栈搬到堆并处理其中的对象引用__block基本类型只搬运Block_byref结构体不涉及对象引用计数__weak对象不retain只存弱引用并注册到对象的weak表这个表格值得你截图。前些年面试总爱问“Block捕获__strong对象会不会retain”很多人张口就来“会”但如果不区分是栈Block还是堆Block、不区分ARC还是MRC这个回答就是错的。在MRC下Block捕获对象变量默认不retain只有append了.copy才有后续的retain行为。ARC下因为编译器默认对Block使用copy语义所以捕获__strong对象才必然retain。4.3 ARC对Block Copy语义的自动改写ARC下你很少写Block_copy或[block copy]不是因为不需要copy而是编译器替你做了。举一个最典型的例子typedef void (^MyBlock)(void); - (MyBlock)createBlock { int value 42; MyBlock block ^{ NSLog(%d, value); }; return block; }在ARC下局部变量block本身是强引用类型编译器知道这个Block要作为返回值逃逸出当前作用域于是自动插入copy操作。在MRC下你必须在return之前手动调用[[block copy] autorelease]否则返回栈Block后调用即崩溃。这两者的对比如今依然是老项目迁移和面试理解的难点。ARC的规则里有一条要背熟当Block被赋值给强引用属性或强引用变量时编译器按copy处理作为参数传入方法时编译器不自动copy。所以在dispatch_async里传入BlockGCD内部会负责copy如果你自己写一个方法把Block存到属性里那方法内部必须要自己copy一次这个动作编译器不会替你做。很多早期第三方库的Block属性写的是assign或unsafe_unretained其实就是没想清楚生命周期问题一大堆。5. 实操亲眼看Block的内存布局和Copy行为5.1 用Xcode和LLDB验证Block类型与内存地址先打开一个iOS或macOS的Command Line工程在main函数里写一段最简单的验证代码#import Foundation/Foundation.h int main(int argc, const char * argv[]) { autoreleasepool { // 全局Block void (^globalBlock)(void) ^{ NSLog(Hello, Block); }; // 栈Block捕获了外部局部变量 int number 100; void (^stackBlock)(void) ^{ NSLog(number %d, number); }; // 堆Block对栈Block执行copy void (^heapBlock)(void) [stackBlock copy]; NSLog(globalBlock: %, globalBlock); NSLog(stackBlock: %, stackBlock); NSLog(heapBlock: %, heapBlock); NSLog(globalBlock addr: %p, globalBlock); NSLog(stackBlock addr: %p, stackBlock); NSLog(heapBlock addr: %p, heapBlock); return 0; } }在NSLog里直接打印Block%会调用description输出“__NSGlobalBlock__: 0x...、__NSStackBlock__: 0x...、__NSMallocBlock__: 0x...”之类的字符串这就验证了三种类型的存在。我实测过输出globalBlock和heapBlock的地址看起来都在堆和静态区范畴stackBlock的地址会离栈指针很近。在Xcode断点处用LLDB命令再确认一下po globalBlock po stackBlock po heapBlock memory read stackBlockmemory read会把那段地址的原始字节打出来开头几个字节是isa指针再往后能看到flags。理解这些原始字节有助于排查问题时直接面向内存分析而不是对着抽象概念瞎猜。5.2 验证__block变量Copy之后的地址变化再写一段代码专门观察__block变量__block int counter 0; int *counterAddrBefore counter; void (^block1)(void) ^{ counter; }; void (^block1Copy)(void) [block1 copy]; int *counterAddrAfter counter; NSLog(before: %p, counterAddrBefore); NSLog(after: %p, counterAddrAfter);注意counter的写法__block int counter在编译期被包装成一个Block_byref结构体你取到的“地址”实际上是这个结构体内部数据的地址。执行[block1 copy]后如果打印出的两个地址不一致说明Block_byref确实从栈迁移到了堆这就是copy时发生“搬家”的最直观证据。我这里提醒一句Xcode里开启Address Sanitizer后观察这类地址变化会更安全毕竟直接操作栈上内存有风险。实测时如果不开ASan在Release优化模式下编译器可能连栈Block都不生成了直接优化成全局Block因为捕获的变量被内联成常量了。遇到这种情况别慌关掉优化再验证一次就好。5.3 用Visual Studio的/d1 reportAllClassLayout查看C结构体布局热词里有人提到“vc如何查看内存布局”很多iOS开发不熟悉Visual Studio但其实在纯C/C层面理解结构体布局VC的这个编译选项特别直观。它可以把结构体、类、联合体的内存布局打印到编译输出窗口。具体操作用Visual Studio打开一个C控制台工程把Block_layout、Block_descriptor、Block_byref等结构体按C语言结构定义出来然后在项目属性 - C/C - 命令行 - 附加选项里加上/d1 reportAllClassLayout重新编译后输出窗口会列出每个结构体的成员偏移量、大小、对齐方式。比如你定义一个极简的Block结构体struct MyBlockLayout { void *isa; int flags; int reserved; void (*invoke)(void *, ...); struct MyBlockDescriptor *descriptor; int capturedValue; };VC会告诉你capturedValue位于偏移量多少整个结构体是多少字节。这样你能直观地看到内存里字段的真实排列位置而不是光靠脑子想象。这招做跨平台工具开发时特别有用。我写过一个C和Objective-C混编的跨平台底层库为了把Objective-C的Block传进C层处理就得先精确算好结构体大小和偏移量。没有这种编译器辅助光靠人肉数偏移很容易出错。5.4 在一个临时工程里复现Block_copy的调用链如果你的好奇心拦不住想直接看_Block_copy和_Block_object_assign的真实调用可以用符号断点。在Xcode的Breakpoint Navigator里添加Symbolic Breakpoint符号名填_Block_copy或_Block_object_assign然后运行任意一段Block代码断点就会命中。命中断点后打开Debug - Debug Workflow - Always Show Disassembly再配合lldb的register read查看参数寄存器你甚至能看到传给_Block_object_assign的fla g值。有一次我为了确认某个闭包的flags是否包含BLOCK_HAS_COPY_DISPOSE就是这样一步步跟进去的。虽然调试过程有点枯燥但看懂了之后对Block Copy的认知基本就是“开过膛”级别代码中再遇到相关问题心里特别有底。6. 常见问题与排查技巧实录6.1 栈Block逃逸导致的野指针崩溃这是所有Block内存问题里最典型的一个。现象是一个Block在函数A里创建传给异步任务异步任务还没执行函数A已经返回了。如果Block原本在栈上那异步任务最终访问的是一块已经失效的栈内存。排查思路很简单看看目标对象的Block参数有没有被正确copy。dispatch_async、dispatch_after内部会copy但你自己写的API一定要在保存时copy。如果你的封装方法接收Block后存成属性请把属性声明为copy这样赋值时编译器会插入copy。调试时用po block看Block类型如果逃逸到异步任务里的栈Block类型是__NSStackBlock__基本可以断定漏写了copy。6.2 捕获__block对象时Block Copy导致的内存泄漏很多人写__block修饰对象时以为“既然是引用就不需要weak了”结果在Block内部持有它并捕获它自己形成了一个引用环Block持有对象对象的属性又持有Block。copy动作执行得越频繁引用环越难挣脱。遇到这种问题建议先画出“谁持有谁”的引用图。Block Copy只负责把捕获对象按语义retain它不管你是不是形成了环。环的破法要靠弱引用把对象改成__weak或者把Block属性改成weak总得打破一个点。6.3 Copy之后修改的是副本还是原变量这类问题经常出现在代码Review里。比如__block NSMutableArray *array [NSMutableArray array]; void (^block)(void) ^{ [array addObject:1]; }; [block copy]; array [NSMutableArray array];Block copy后Block内部持有的Block_byref和外部变量不再共享同一个结构体指针但forwarding会保证访问最终指向堆上的副本。如果你在copy后重新给外部变量赋一个新对象Block里看到的还是旧对象因为Block访问的是Block_byref-forwarding指向的旧存储。这个问题的根源在于对“共享”的误解。__block变量在Block里确实操作的是同一个数据容器但这个数据容器一旦被copy到堆上栈上的“外壳”就变成一个跳板。如果你想始终操作同一份数据就不要在copy之后重新给__block变量赋值而是通过forwarding指向的堆结构体去修改。6.4 ARC下Block属性误用assign导致僵尸对象老代码里有很多属性写的是property (nonatomic, assign) MyBlock callback;ARC下这基本属于定时炸弹。Block被赋给一个assign属性时编译器不会copy它只在栈上或全局区存一个指针。如果Block原本是栈Block而它所在的栈帧很快销毁之后你再调用这个属性就是读一块野内存。正确做法是声明为copy或strong。ARC下strong和copy对于Block来说都够用但为了语义清晰我依然推荐copy。它向阅读代码的人传递一个明确信号这个Block的属性不持有原始栈Block而是持有它的堆副本。6.5 手动解析Block结构体时的对齐踩坑有人拿到Block_layout结构体后喜欢手工算偏移然后从内存里掏出捕获变量。这种玩法要极其小心结构体对齐。编译器为了对齐性能会在字段之间插入填充字节。你不能想当然地认为descriptor之后紧接着就是第一个捕获变量中间可能会有padding。验证方法就是我前面说的/d1 reportAllClassLayout或者用offsetof宏在运行时打印字段偏移size_t offset offsetof(struct Block_layout, descriptor); NSLog(descriptor offset: %zu, offset);这条命令会告诉你descriptor在结构体里的准确偏移量。手动遍历Block捕获列表时建议先用offs etof确认所有偏移不要硬编码数字。7. 调试Block内存问题时的几点心得最后分享一点个人习惯。我在处理Block内存问题时顺序永远是这样的先打印Block类型。用%或LLDB的po看一眼它是全局、栈还是堆。如果这个Block是要逃逸的它必须是堆Block或者全局Block栈Block出现在那里就是红色警报。再检查捕获变量。把Block打印出来之后我会把所有捕获变量的内存地址打一遍对照着看它们到底是不是同一个实例。遇到__block变量重点看地址是否发生了变化这是判断有没有发生copy搬家的依据。最后查引用关系。如果一个Block引起了泄漏我很少第一时间去翻循环引用规则而是直接用Instruments的Leaks模板顺着对象图找到底是谁在强持有谁。Block Copy解决的是“生命周期延长”的问题不是“打破循环”的银弹这一点心里要时刻有数。当年我学Block内存布局时总觉得这是理论层面的东西离实际写代码太远。后来排查了太多次线上崩溃才发现所有诡异问题最后都能追溯到那几个结构体字段和一个copy操作上。学好内存布局不是炫技是给自己省时间。
返回列表