news 2026/9/16 12:34:28

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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.hBlockLayout中体现得很清楚。它的头部是:

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里到底装了什么

没有descriptor,Block就是一个空壳子。它记录的是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时才知道该搬运多少字节。copydispose函数指针是Block处理捕获对象的核心入口,signature是Block的类型编码(方法签名),layout则描述强引用对象的偏移位置,这些配合起来才能做到精准的内存管理。

有一点容易踩坑:Block_descriptor_2Block_descriptor_3不是所有Block都有的,它们的存在由flags里的位决定。比如只有捕获了__strong对象或__block变量时,flags才会标记BLOCK_HAS_COPY_DISPOSE,descriptor里才存在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。它做的事可以概括为三步:

  1. 如果Block已经在堆上,直接对引用计数加1并返回原地址。
  2. 如果Block是全局Block,直接返回原地址。
  3. 如果是栈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里传入Block,GCD内部会负责copy;如果你自己写一个方法,把Block存到属性里,那方法内部必须要自己copy一次,这个动作编译器不会替你做。很多早期第三方库的Block属性写的是assignunsafe_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...>”之类的字符串,这就验证了三种类型的存在。

我实测过输出,globalBlockheapBlock的地址看起来都在堆和静态区范畴,stackBlock的地址会离栈指针很近。在Xcode断点处用LLDB命令再确认一下:

po globalBlock po stackBlock po heapBlock memory read stackBlock

memory 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,再配合lldbregister 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_asyncdispatch_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,而它所在的栈帧很快销毁,之后你再调用这个属性就是读一块野内存。

正确做法是声明为copystrong。ARC下strongcopy对于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操作上。学好内存布局不是炫技,是给自己省时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 12:34:26

Swift字符串扩展实战:12类高效开发工具集

1. Swift字符串扩展全解析&#xff1a;提升开发效率的实用工具集在日常iOS开发中&#xff0c;字符串操作几乎无处不在。作为Swift开发者&#xff0c;我们经常需要处理各种字符串相关的任务&#xff0c;从简单的长度检查到复杂的正则匹配。虽然Swift标准库提供了基本的字符串处理…

作者头像 李华
网站建设 2026/9/16 12:33:19

Grok 4.20智能对话系统:中文优化与多模态交互解析

1. 项目概述Grok 4.20作为新一代智能对话系统&#xff0c;近期已在MetaChat平台完成部署上线。这个版本在语义理解、多轮对话和知识检索等方面都有显著提升&#xff0c;特别针对中文语境进行了深度优化。不同于以往需要复杂配置的AI系统&#xff0c;这次更新最引人注目的特点就…

作者头像 李华
网站建设 2026/9/16 12:29:49

Python控制流与函数编程实战指南

1. Python控制流与函数入门精要作为一名有五年Python开发经验的工程师&#xff0c;我经常被问到如何系统掌握控制流和函数这两个基础但至关重要的概念。今天我就用实际项目中的经验&#xff0c;带大家深入理解这些知识点。控制流和函数是构建任何Python程序的基石。就像乐高积木…

作者头像 李华
网站建设 2026/9/16 12:29:47

AI生成内容检测与优化:千笔降AI率助手技术解析

1. 项目背景与核心价值在内容创作领域&#xff0c;AI生成内容&#xff08;AIGC&#xff09;的检测正成为刚需。去年某头部内容平台数据显示&#xff0c;约38%的投稿因AI痕迹过重被退回&#xff0c;创作者平均需要花费2.7小时人工修改才能通过审核。这就是"千笔降AI率助手&…

作者头像 李华
网站建设 2026/9/16 12:28:16

基于C++Qt的超市管理系统:从数据库建模到完整项目交付

简介&#xff1a;基于C与QT开发的超市管理系统完整源码包&#xff0c;面向高校C课程设计、期末大作业及毕业设计场景&#xff0c;功能覆盖商品资料、销售管理、日常管理等多个业务模块&#xff0c;界面采用QT绘制&#xff0c;美观且交互友好&#xff0c;代码结构清晰并附有注释…

作者头像 李华