
先问一个直击灵魂的问题你在 iOS 或者 macOS 开发里写了这么多年 Block有没有想过一个NSLog(hello)的 Block 和一个捕获了局部变量的 Block在内存里长得完全不一样我猜多数人答不上来。因为日常开发中 Block 基本被 ARC 包办你只需要写self-block ^ { ... }该 copy 的时候编译器帮你 copy该释放的时候帮你释放你根本看不到底层发生了什么。但一旦你开始碰组件化、C 层接口回调、或者写跨语言桥接就会发现 Block Copy 不是“按一下”这么简单而是实打实的一场结构体搬迁。这篇文章就围着 Block Copy 的内存布局来讲Block 到底长什么样Copy 前后内存里多了什么、少了什么__block变量为什么会有“两个自己”以及怎么用 lldb、Visual Studio 这些调试器把布局一点点看穿。适合被 retain cycle 坑过的人、想搞懂闭包原理的人、以及准备面 iOS 底层岗的人看完基本能形成一套完整的地基认知。1. Block 是什么先搞明白 Copy 在操作什么样的对象1.1 Block 在语言层面的存在感Block 是 C 语言家族的扩展Objective-C 和 C 里都能用。语法上它长得像函数指针但行为上它是个“携带环境的函数”——除了代码入口它还能把定义时刻的局部变量一起带走。这个“携带环境”的能力才是它跟普通函数指针最本质的差别。用一个最最典型的例子来说int multiplier 6; int (^theBlock)(int) ^(int num) { return num * multiplier; }; theBlock(2); // 结果是 12这段代码里theBlock并不只是一个“入参 int 出参 int 的函数地址”。它在执行num * multiplier时需要读取外层的multiplier这个变量原本生活在栈上在theBlock被调用的那一刻可能已经离开作用域了。那 Block 是怎么保证multiplier还能被读到的答案就在内存布局Block 把捕获的变量复制到了自己的结构体里等于给变量提前“打包带走”。这就解释了为什么 Block Copy 是一个绕不开的问题变量要进 Block 结构体就涉及内存从哪来、复制还是引用、生命周期归谁管这一连串问题全部落到“拷贝”这个动作上。1.2 Block 本质上是一个结构体实例不是普通函数指针很多人第一次用 clang 把 OC 代码改写rewrite成 C 之后都会愣一下原来 Block 不是指针是一个结构体对象。在编译器眼里Block 内存由这样几个关键字段组成isa指向 Block 所属“类”__NSGlobalBlock__、__NSStackBlock__、__NSMallocBlock__三种之一用于向运行时说明 Block 的出身flags位掩码字段标记 Block 是否需要拷贝、是否包含__block变量、是否有人描述信息等reserved保留字段一般占位留给运行时或后续版本扩展invoke真正的函数指针指向 Block 体的代码入口descriptor指向结构体描述信息里面包含 Block 大小、签名、以及 copy/dispose 函数指针。除了这些固定头后面还跟着一个“变量捕获列表”——你把外层哪些变量写进去了它们就在这个列表里按顺序排列。所以 Block 的结构体大小不是编译期写死的它取决于捕获了多少变量、每个变量多大这也是为什么descriptor里会有一个reserved大小字段就是为了让 Block Ivar 能通过基地址加偏移量正确定位到捕获变量。一句话总结Block 是一块会走代码、还带行李的结构体。Copy 的作用就是把这块结构体从短期栈上搬到长期堆上并把手里的“行李”按规则整理好。2. Block 的内存布局一张结构体解剖图2.1 基础字段逐个拆解别被汇编名词吓到用 clang 的-rewrite-objc可以把 OC 的 Block 改写成一个 C 结构体常见长这样struct __block_impl { void *isa; int Flags; int Reserved; void *FuncPtr; };这是 Block 的“公共头部”三种 Block 都复用这一份。然后每个具体的 Block 会再包一层把捕获的变量挂在后面struct __theBlock_block_impl_0 { struct __block_impl impl; struct __theBlock_block_desc_0* Desc; int multiplier; // 捕获变量挂在后面 };逐个看字段isa是运行时用来识别 Block 类型的。说它是“类”不太严谨本质是一个Class指针指向_NSConcreteGlobalBlock、_NSConcreteStackBlock或_NSConcreteMallocBlock。你可以把 Block 理解成一个“私有类”的实例只要 isa 被正确填充运行时就可以像处理普通对象一样对它做 retain/release/copy。初学的时候不用深究 isa 的完整细节把它当成“身份证”就够了。Flags这一位很有用。它里面有多 bit 标记位其中比较高位的 bit 表示这个 Block 是否需要 copy/dispose 的辅助函数也就是 Block 是否捕获了需要特殊内存管理的对象。比如捕获了__block NSMutableArray时 flags 里会对应置位告诉 Block_copy 函数“复制我的时候别忘记同时处理内部对象”。Reserved就是对齐用的。由于 Block 结构体需要与系统内存对齐规范保持一致这个字段用来把头部凑整一般就是 0不用太关心。FuncPtr也叫invoke它是 Block 函数体编译后的入口地址。block(10)这种调用语法在底层就是找到FuncPtr然后跳转参数通过寄存器或栈传给invoke。砍掉外壳后你会发现Block 的结构跟一个“对象”几乎没区别有 isa、有方法入口、有属性捕获变量只是它的方法实现不在咱平时写的类 Method 列表里而是编译期就定死的函数指针。这个认知会帮下面理解“Copy”为什么是一种对象搬迁而不是简单的 memcpy。2.2 descriptor 是 Block 的户口本与 impl 相邻的Desc字段指向 Block descriptor原始定义如下struct __theBlock_block_desc_0 { size_t reserved; size_t Block_size; void (*copy)(void); void (*dispose)(void); };reserved保留一般置 0。Block_size整个 Block 结构体占用的字节数。复制时Block_copy必须知道要开多大内存、memcpy 多少字节靠的就是这个字段。copy函数仅当 Block 捕获了__strong对象、__block对象或需要特殊持有语义的变量时才会生成。它的职责是逐一对捕获变量调用 retain 或者对__block对象做 forward 指针迁移。dispose函数与 copy 对应释放 Block 时逐个释放捕获变量帮系统做清理。我每次调试都会把 descriptor 打印出来基本只看Block_size因为它在“这个 Block 多大”的问题上是最直接的答案。如果我在调试器里发现某个 Block 怪怪的第一步永远都是看 size 对不对一个捕获 3 个对象的 Block 和一个普通无捕获 Blocksize 能差出几十个字节一眼就能知道是不是搞混了类型。2.3 变长结构体捕获变量为什么排在固定头之后Block 的捕获变量不是零零散散乱排的。编译期会按照“声明顺序”把它们依次排列在Desc之后最终形成的结构就是struct __theBlock_block_impl_0 { struct __block_impl impl; // 头部固定 struct __theBlock_block_desc_0* Desc; // 指向描述信息 int capturedVar1; char *capturedVar2; id capturedObject3; };数组或结构体类型的捕获变量也一样按值塞进来。这里有个比较容易踩坑的点如果捕获的是 C 数组Block 会把它原样按值拷贝到结构体里如果捕获的是指针则是拷贝指针本身指针指向的内容不会被拷贝。官网文档和无数博客都在强调这个区别因为很多人想当然地认为捕获一个NSString *就是抓住了那个字符串其实抓住的只是指针值字符串对象本身是否被 retain 是另外一套规则。这种布局设计其实和数据包格式很像固定头 载荷。isa、Flags、FuncPtr是包头捕获变量是载荷descriptor 是包尾注释。读内存时只要拿到 Block 基地址跳过头部和描述符指针就能按偏移踩到每一个捕获变量这就是内存布局给到开发和调试的“地图”。3. 三种 Block 与它们的真实出生地3.1 全局 Block根本不用 Copy 的类型不捕获任何变量或者只捕获全局变量/静态变量时Block 的内存会在编译期落入全局数据区类型是__NSGlobalBlock__。这种 Block 的特点是不存在栈/堆问题生命周期是“从进程启动到进程结束”Copy 不 Copy 对它没有意义因为Block_copy往往直接返回它自己。判断标准很简单Block的引用计数对它无效retain/release 操作是 no-op打印 isa 可以看到__NSGlobalBlock__。日常开发中如dispatch_once里写的那种纯函数式 Block基本都属于这个类型。来个示例帮助记忆void (^globalBlock)(void) ^{ printf(hello\n); }; NSLog(%, [globalBlock class]); // __NSGlobalBlock__3.2 栈 Block默认出生地生命周期最危险只要 Block 捕获了栈上的自动变量它就不再是 Global Block而是默认分配在栈上的__NSStackBlock__。栈上内存的规则大家都知道作用域结束就失效。所以在一个函数里创建、不做 copy 直接返回给外部等于是把一包随时可能被覆盖的数据交给别人。MRC 时代这是常见内存崩溃来源ARC 下编译器会默默帮你处理后面第 6 节细说。但如果你在写纯 C 函数或者用__bridge跳来跳去这段栈生命周期的问题会原形毕露。栈 Block 有一个很有意思的特征它的 isa 指向_NSConcreteStackBlock但它在“结构体”上的数据布局和非全局 Block 一模一样唯一差异是内存位置在栈帧中而没有经过堆分配。这也是为什么很多人在看内存布局时初期根本分不清 Stack Block 和 Malloc Block直到用malloc/free的边界检查才发现一个根本没走堆分配。3.3 堆 BlockBlock_copy 的产物当你对栈 Block 调用Block_copy()或把它赋值给copy修饰的属性时运行时会从栈上把结构体内容搬到堆上新分配的内存类型就是__NSMallocBlock__。之后引用计数由运行时管理跟普通 OC 对象差不太多。这里有几个容易被忽略的点堆 Block 的 isa 是_NSConcreteMallocBlock但它复制而来的invoke函数指针仍然指向代码段不会被复制成“代码副本”原栈 Block 不会被自动释放它依然活在栈上直到作用域结束。如果你对栈 Block 做了 copy后续应该使用堆上的新地址Block_copy是手动方式ARC 下通常不需要显式调用编译器在需要时自动插入。我见过很多新手误以为“copy 一个 Block 会连函数体也复制一份”这是不存在的。函数代码在编译后是只读的复制的是“壳”壳里放入的是捕获变量的值或引用。3.4 三种 Block 的布局差异对比类型isa 指向所在区域生命周期copy 行为Global Block_NSConcreteGlobalBlock全局数据段与进程相同无操作直接返回自身Stack Block_NSConcreteStackBlock当前栈帧随作用域结束分配堆内存复制结构体与变量Malloc Block_NSConcreteMallocBlock堆区引用计数为 0 时释放增加引用计数返回自身这张表建议存下来面试时候画一遍基本能顶上一个小时的底层题。真正遇到“为什么 Block 不 copy 就崩溃”的问题时第一反应改成“看一下它的 isa 和所在区域”排查速度会快很多。4. Block Copy 到底复制了什么从栈到堆的迁移全过程4.1 触发时机谁在调用 copyBlock_copy是底层的 C 函数等价于对象语义上的[block copy]。ARC 下系统自动帮我们祈使的场景主要有三种Block 被赋值给strong/copy修饰的 OC 属性时Block 作为返回值返回时Block 被放入NSArray、NSDictionary等容器时GCD 的dispatch_async、dispatch_after等 API 基本都自带 copy拿到 Block 后会复制到堆上再执行。MRC 时代这四种场景需要手动Block_copy非常容易漏漏一次就崩一次。现在 ARC 省事了很多但理解“编译器在哪里偷偷帮你做了 copy”仍然重要因为一旦涉及 C 函数指针回调、底层 C 接口ARC 不会帮忙必须自己调用Block_copy或者用__bridge_transfer/__bridge_retained等桥接手段。4.2 复制过程拆解变量是值拷贝还是引用拷贝Block Copy 不是简单memcpy。底层执行逻辑大致如下读取原栈 Block 的descriptor-Block_size得到结构体总大小在堆上分配同样大小的内存把原结构体内容整体 memcpy 到新内存遍历捕获变量对__strong对象类型的捕获变量调用objc_retain确保堆 Block 持有它对__block变量执行“forward 指针重定向”保持栈和堆的 Block 都能访问到同一个变量对普通int、struct等按值捕获的保持值拷贝不做 retain将 isa 修改为_NSConcreteMallocBlock如果原本是 Global Block直接 return 自身不会走堆分配。第 3 步容易让人以为 Block copy 是“浅拷贝”因为它先把整个结构体直接搬过来。关键在于第 4 步的变量级处理对象类型的捕获变量会被 retain所以是“带所有权语义的浅拷贝”。字符数组这种 C 类型则真的是纯字节拷贝Block 内部拿到的是一个副本。对于没有__block修饰的普通对象变量Block 捕获的是“对象指针 一份 retain”。比如NSObject *obj [NSObject new]; void (^block)(void) ^{ NSLog(%, obj); };Copy 之后堆 Block 里的obj字段仍然指向同一个 NSObject但它的引用计数 1保证了即使原始作用域释放Block 依然能用它。这种设计语言上叫“捕获 持有”和 C lambda 的[]行为很像只是细节上 OC 搞得更加自动。4.3__block变量的特殊处理一个变量两个结构体的问题__block是 Block 相关的另一个大考点。它和普通捕获变量最大区别在于__block变量允许在 Block 内部被修改并且修改能反映到 Block 外部。底层实现上__block变量会被包装成一个结构体我们把它叫做 byref 结构体struct __Block_byref_counter_0 { void *__isa; struct __Block_byref_counter_0 *__forwarding; int __flags; int __size; int counter; // 原来的变量来住在这里 };看着眼熟吧它又是一个“对象头部 数据载荷”的格式。Block 捕获的其实不是 counter 本身而是这个 byref 结构体的指针。当 Block 被 Copy 时byref 结构体会跟着被复制。复制过程中最关键的是__forwarding指针的处理原本栈上的 byref 结构体把自己的forwarding指向新堆上的 byref 结构体。之后无论是通过栈上的 Block 还是堆上的 Block 访问counter都会通过forwarding指针跳到堆上的那一份。这就是为什么__block变量在 Block 内外看起来是“同一份”——底层其实是 forward 指针帮你导流。调试时打印__block变量的地址会让很多人困惑Block 内部访问它的地址和外部拿到的地址不一样。但这个不一样是正常的只要经过了 Copy外部和内部各自手里的是两个不同的 byref 结构体而这两个结构体的forwarding指针共同指向同一份真实数据。理解这一层很多__block的诡异行为就都能解释了。4.4 手动 copy 的适用场景手动 copy 主要出现在两种场景写 C 函数的回调时把一个 OC Block 作为void *传给 C APIC 层不认 ARC你得自己Block_copy保证它活到回调执行时把 Block 存进 C 结构体或者跨线程传递 BlockC 结构体不会自动管理持有关系。如果这两个场景都避开那日常开发中基本碰不到手动Block_copy。但你要看得懂因为系统底层、第三方库源码、以及崩溃堆栈里经常会冒出来这些函数名不认识它们就像读别人的日记缺了半页。5. 实战工具在调试器里把 Block 的内存布局看穿5.1 方法一clang 的 -rewrite-objc想把 OC 的 Block 翻译成 C 语言结构体最直接的办法是clang -rewrite-objc。写一个最简单的 demo 文件int main() { int multiplier 6; __block int counter 0; int (^theBlock)(int) ^(int num) { counter; return num * multiplier; }; theBlock(2); return 0; }然后执行clang -rewrite-objc demo.m -o demo.cpp终端里生成的文件会包含大量运行时源码直接搜索__theBlock_block_impl_0和__Block_byref_counter_0就能看到编译期生成的结构体定义和 copy 函数。我建议配合grep使用grep -n __theBlock_block_impl_0 demo.cpp grep -n __Block_byref_counter_0 demo.cpp这样能看到几件重要事情Block 结构体里捕获变量的顺序、descriptor 里 copy/dispose 函数在哪、以及__block变量的 forwarding 迁移逻辑。这比任何讲解都直观因为这就是编译器此刻在你机器上生成的真相。5.2 方法二lldb 里直接读内存如果你在 mac 上跑 Xcode可以在断点处用 lldb 查看 Block 结构体(lldb) frame variable theBlock (lldb) memory read --size 8 --format x --count 8 theBlock第一行能看到 Block 变量本身一个指针第二行把 Block 指向的内存按十六进制打出来。对照结构体定义前 8 字节是 isa接着 4 字节 flags、4 字节 reserved、8 字节 invoke再往后 8 字节是 descriptor 指针后面才是捕获的变量。格式大概长这样0x0000600000xxxxxx: 0x000000010xxxxxxx 0x0000000000000000 0x0000600000xxxxxx: 0x000000010xxxxxxx 0x0000000300000008 ...这些值不需要记死重点是知道“哪一段是 isa、哪一段是 invoke、哪一段是捕获变量”的偏移关系。亲自读一次比背书深刻得多。po [theBlock class]也很有效它会打出来__NSStackBlock__还是__NSMallocBlock__直接告诉你这个 Block 现在到底在栈上还是堆上。5.3 方法三Visual Studio 下怎么查类型布局很多人以为 Block 是苹果私有Windows 上完全碰不到其实用 clang 也可以把 OC 程序编译到 Windows或者用纯 C 结构体模拟 Block 的内存布局在 VS 里验证结构体偏移。VS 里查结构体布局有个很给力的编译选项/d1reportAllClassLayout。在“项目属性 - C/C - 命令行 - 其他选项”里加上这个开关重新编译后输出窗口会打印出所有类和结构体的成员布局每个成员的偏移量、占用大小、总大小、对齐位数。对于 C 结构体照样有效。比如我为了验证 Block 头部 捕获变量的偏移会定义这样的 C 结构体struct Block_layout_impl { void *isa; int flags; int reserved; void (*invoke)(void); struct Block_layout_desc *descriptor; int capturedValue; };在 VS 里开/d1reportAllClassLayout编译输出窗口就会显示capturedValue位于偏移 40、整个结构体大小 48 之类的信息。对照 clang 生成的 Block 结构体几乎完全一致。这个方法尤其适合 Windows 上没法跑 Xcode、又想理解 Block 内存布局的人用一个 C 结构体就能把偏移量搞得明明白白。5.4 内存窗口实操十六进制怎么读VS 的“调试 - 窗口 - 内存”里可以直接看指定内存地址的内容。把断点停在某个结构体变量上在内存窗口地址栏输入theStruct能看到一行行十六进制字节。前 8 个字节是 isa 指针会是一个很大的地址指向类对象的代码段接着 4 字节 flags、4 字节保留、8 字节函数指针、再往后就是捕获变量。对着/d1reportAllClassLayout打印的偏移表来读相当于拿着一份地图在内存里找房间。我第一次这么做的时候发现capturedValue的值和结构体成员窗口中显示的完全一样那一下才真正把“内存布局”四个字从抽象变成了具象。如果你也喜欢这种“亲眼看到”的爽感强烈建议在 VS 里试一试。6. 常见问题与排查技巧实录6.1 栈 Block 过早释放导致野指针崩溃场景描述MRC 时代或者跨 C 层传 Block 时Block 在栈上创建作用域结束后被外部继续调用直接崩在非法内存访问。排查思路先打po [block class]看是不是__NSStackBlock__如果是再检查 Block 是否被 copy 过确认调用时机是否在原始作用域内。如果是自己调用 C 接口写成这样就能避免void (^block)(void) ^ { ... }; my_c_function(Block_copy(block), my_dispose_callback);或者更稳妥一点在 C 接口内部执行时再做一次 copy。重点在于传给 C 层的 Block 必须保证生命周期尽在掌握否则就是定时炸弹。6.2 循环引用Block Copy 与 retain cycle 的关系Block 持有外部对象外部对象持有 Block就形成一个环。比如self.myBlock ^{ self.value 10; };如果self持有了myBlock而 Block 内部又捕获selfBlock Copy 时会对self做 retain那么self的引用计数永远不会归零dealloc 永远不会执行。解决办法是改成弱引用捕获__weak typeof(self) weakSelf self; self.myBlock ^{ __strong typeof(self) strongSelf weakSelf; if (strongSelf) { strongSelf.value 10; } };这里面有个容易忽略的点__weak捕获的变量在 Copy 时不会被 retain或者更准确地说弱引用捕获被编译器处理成弱引用所以weakSelf可能为 nil。一般在 Block 内部转成 strong 再使用保证执行过程中对象不闪退。排查循环引用时除了 Xcode 自带的 Instruments Leak 工具还可以在 dealloc 里打日志看对象有没有释放。内存布局角度来说循环引用的本质就是堆 Block 的对象指针反过来指向了持有它的对象两个对象互相“抱住”谁也没法走。6.3 为什么__blockNSString 和__blockNSObject 布局不一样__block变量如果修饰基本类型或普通 C 类型byref 结构体相对简单struct __Block_byref_counter_0 { void *__isa; struct __Block_byref_counter_0 *__forwarding; int __flags; int __size; int counter; };如果修饰的是对象类型比如__block NSString *str那么 byref 结构体的最后一个成员会是一个对象指针并且会多出 copy/dispose 函数来处理这个对象内存descriptor 里也要增加相应的描述。差异的核心在于“内部对象是否需要 retain/release”基础类型直接搬值对象类型要管理所有权。理解这一点你在看崩溃堆栈时就不会再奇怪为什么_Block_object_assign里面会调用objc_retain说明 Block 正在处理一个带所有权的捕获对象。反过来说如果你的__block变量是intBlock copy 时根本不会走到对象管理那一步。6.4 ARC 下为什么感觉不到 copy 的存在ARC 会把“需要在堆上长期存活的 Block”自动 copy。编译器的启发式规则大致是Block 作为参数传给函数非noescape标注时ARC 自动调用Block_copyBlock 被赋值给 strong 属性时自动 copyBlock 被放入集合类时自动 copy。所以你写self.block ^ { ... }表面没有copy关键字编译器生成的代码里已经帮你调用了objc_retainBlock内部就是 copy。这就导致很多人一直学不到 Block Copy因为 ARC 把它藏起来了。想看到自动 copy 的痕迹可以看汇编Xcode - Debug - Debug Workflow - Always Show Disassembly搜索objc_retainBlock或_Block_copy符号你会惊讶地发现编译器悄咪咪加了很多你没写过的代码。看懂这一步之前所有疑惑基本都能串起来。7. 一点实操心得你自己写 Block 时建议顺手养成三个习惯。第一凡是把一个 Block 往属性、容器、SDK 回调里塞的时候先问一句这个 Block 会不会被跨出当前作用域使用会就确保 ARC 自动 copy 或者手动 copy。第二遇到 Block 相关崩溃第一步永远是po [block class]确认类型再决定往栈还是堆方向排查不要一头扎进汇编。第三__block变量用多了容易绕晕能用参数传值就尽量用参数传值只有在真正需要修改变量时才上__block。我多年前遇到过一个特别隐蔽的问题一个dispatch_async里捕获了一个较大的 C 结构体结果结构体被按值拷贝到了堆上 Block 里重复派发几百次后内存暴涨。当时还没搞懂内存布局以为泄漏后来用Block_size一打印才发现每次拷贝都把整个大结构体带上了排出问题是修改方案——把大结构体改成指针捕获加手动管理内存立马降下来。这种定位速度就是建立在理解内存布局基础上的。最后再分享一个实用小技巧调试 Block 时在 lldb 里对 Block 指针做一次memory read --size 8 --format x --count (Block_size/8)直接对照结构体偏移基本可以定位绝大多数“Block 内存错乱”的问题。用习惯了之后你会觉得 Block 的内存布局就像自己家的户型图哪里有墙、哪里有门心里门儿清。