iOS13 一次Crash定位 - 被释放的NSURL.host

每年一次的iOS升级,都会给开发者带来一些适配工作,一些原本工作正常的代码可能就会发生崩溃。 本文讲到了一种 CoreFoundation 对象的内存管理方式在iOS13上遇到的问题。

1. 问题

iOS 13 Beta 版本上,手淘出现了一个必现的崩溃:

Thread 0 name:  Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0   libobjc.A.dylib                 0x00000001d6f9af20 objc_retain + 16
1   CFNetwork                       0x00000001d7843f60 0x1d77b0000 + 606048
2   CFNetwork                       0x00000001d780cec8 0x1d77b0000 + 380616
3   CFNetwork                       0x00000001d77dff24 _CFSocketStreamCreatePair + 56
4   xxxxxxxxxxxxxxxxx               0x000000010c2a44b4 0x10b46c000 + 14910644
5   xxxxxxxxxxxxxxxxx               0x000000010c2a6238 0x10b46c000 + 14918200
6   xxxxxxxxxxxxxxxxx               0x000000010c2a661c 0x10b46c000 + 14919196

崩溃在了 _CFSocketStreamCreatePair  方法里面, 然后崩溃在了 objc_retain  里面,推测是传入的某个ObjC的对象野指针了导致的。

通过追溯源码,发现调用的是 CFStreamCreatePairWithSocketToHost 这个方法,然后找到这个方法的定义:

void CFStreamCreatePairWithSocketToHost(
    CFAllocatorRef _Null_unspecified alloc, 
    CFStringRef _Null_unspecified host, 
    UInt32 port,
    CFReadStreamRef _Null_unspecified * _Null_unspecified readStream, 
    CFWriteStreamRef _Null_unspecified * _Null_unspecified writeStream
);

根据上下文判断,是第二个参数 CFStringRef _Null_unspecified host  野指针了。

然后找到这个 host 对象的初始化:

NSURL *serverUrl = [NSURL URLWithString:@"xxxxx"];
CFStringRef hostRef = (__bridge CFStringRef)serverUrl.host;

这段代码看起来好像并没有问题,怎么会导致野指针,然后Crash呢?

这要从iOS的内存管理上找答案。

2. 苹果的autorelease内存管理优化

我们都知道苹果使用 “引用计数” 技术来管理内存, 使用 “自动释放池AutoreleasePool” 技术来解决方法返回值的内存管理问题。 相关技术原理网上都有很多文章。但是本文中遇到的Crash是由苹果对使用 ARC 代码进行的编译优化从而引发的。所以先讲一下这个优化是什么。

考虑一个内存管理的最简单的case:
iOS13 一次Crash定位 - 被释放的NSURL.host

在最初的 ARC 机制下,上图中的左边代码会编译成右边这样的代码,从而保证了对象 b 的生命周期完整。

但是我们再详细分析下这个代码,是不是去掉 [b autorelease]  和 [b retain] 这两步操作的话,代码也是可以正常执行的呢? 答案是肯定的, 那么这个操作其实就是可以优化掉的。苹果考虑到了这一点。

那么要怎么样做到这个优化呢? 因为这个优化是需要同时考虑 被调用方funcB 和 调用方funcA 这两个方法配合来完成,因为需要根据调用方的内存管理代码才能决定我被调用方要不要真的去掉autorelease操作。 而且还要在ABI上向下适配。 苹果是这样做的:

iOS13 一次Crash定位 - 被释放的NSURL.host

代码:

// Prepare a value at +1 for return through a +0 autoreleasing convention.
id 
objc_autoreleaseReturnValue(id obj)
{
    // 判断是否需要优化, 如果可以,就直接return,不做autorelease
    if (prepareOptimizedReturn(ReturnAtPlus1)) return obj;

    return objc_autorelease(obj);
}

id
objc_retainAutoreleasedReturnValue(id obj)
{
    // 判断是否走了优化逻辑,如果走了就不用retain
    if (acceptOptimizedReturn() == ReturnAtPlus1) return obj;

    return objc_retain(obj);
}

static ALWAYS_INLINE bool 
prepareOptimizedReturn(ReturnDisposition disposition)
{
    assert(getReturnDisposition() == ReturnAtPlus0);
    // 判断方法返回地址是不是某个值,是的话就认为可以优化
    if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
        // 可以优化就把ReturnAtPlus1 存起来,存到了tls里面
        if (disposition) setReturnDisposition(disposition);
        return true;
    }

    return false;
}

static ALWAYS_INLINE bool 
callerAcceptsOptimizedReturn(const void *ra)
{
    // fd 03 1d aa    mov fp, fp
    // arm64 instructions are well-aligned
    // 判断return address是不是 0xaa1d03fd, 在arm64上就是 `mov fp, fp` 指令
    if (*(uint32_t *)ra == 0xaa1d03fd) {
        return true;
    }
    return false;
}

static ALWAYS_INLINE ReturnDisposition 
acceptOptimizedReturn()
{
    ReturnDisposition disposition = getReturnDisposition();
    setReturnDisposition(ReturnAtPlus0);  // reset to the unoptimized state
    return disposition;
}

// 存在当 tls中,当前线程相关的
static ALWAYS_INLINE ReturnDisposition 
getReturnDisposition()
{
    return (ReturnDisposition)(uintptr_t)tls_get_direct(RETURN_DISPOSITION_KEY);
}

static ALWAYS_INLINE void 
setReturnDisposition(ReturnDisposition disposition)
{
    tls_set_direct(RETURN_DISPOSITION_KEY, (void*)(uintptr_t)disposition);
}

从上面的分析中,我们可以得出,只要看到调用 objc_msgSend 之后的一条指令是 mov x29, x29 , 那么肯定就是开启了这个优化。

iOS13 一次Crash定位 - 被释放的NSURL.host

所以,大家汇编调试的时候看到这样一行指令,不要觉得奇怪 mov x29,x29 不是啥都没做么?其实是用于这里的优化。

3. Crash根因

了解了 ObjC的 autorelease优化之后,再回到我们遇到的crash问题。有理由怀疑 [NSURL host] 这个方法在旧版本系统上不会走这个优化,因此返回值被放入了 AutoreleasePool 所以后面继续使用是正常的。但是iOS13 上走到了这个优化逻辑,实际上返回的 host 是没有加入 AutoreleasePool 的。 而这个时候恰好又没有 objc 对象接收,直接用 __bridge 转移到了 CF对象上。导致这个 host 直接释放了。

通过查看 对 [NSURL host] 的调用代码证明了这个猜想:

iOS13 一次Crash定位 - 被释放的NSURL.host

  1. +312 行调用 [NSURL host] 获取host.
  2. 因为 +316的指令是 mov x29, x29  所以如果[NSURL host]  里的实现是类似上述 funcB 则会走到autorelease优化。也就是返回的 host 没有加入autoreleasePool
  3. +320 行中,因为开启优化,也捕获做retain
  4. +328 行,直接release,  这个时候 host就释放了
  5. 后续继续对它进行访问,就Crash了。

还需要证明的就是 [NSURL host]本身的实现了。于是对比了iOS12 和 iOS13 上的实现:

iOS12 上内部通过调用了 [NSURL _cfurl] 获取,已经加入了autoreleasePool。

iOS13 一次Crash定位 - 被释放的NSURL.host

在iOS13上,就是正常的取值做autorelease, 因此会走到优化逻辑:

iOS13 一次Crash定位 - 被释放的NSURL.host

4. 小结

慎用 __bridge 来进行 OC对象和 CF对象直接的强转。 因为Autorelease优化的存在,这种用法可能让你的代码不安全,因此尽可能使用 CFBridgeRetain  __bridge_retained 来转换管理CF对象,避免因为作用域不一致的情况导致对象呗提前释放的问题。

本文源码来自:
https://opensource.apple.com/tarballs/objc4/

本文作者:念纪,来自淘宝客户端iOS架构组
淘宝基础平台团队正在举行2019实习生(2020年毕业)和社招招聘,岗位有iOS Android客户端开发工程师、Java研发工程师、C/C++研发工程师、前端开发工程师、算法工程师,欢迎投递简历至junzhan.yzw@taobao.com
如果你想更详细了解淘宝基础平台团队,欢迎观看团队介绍视频
更多淘宝基础平台团队的技术分享,可关注淘宝技术微信公众号AlibabaMTT
iOS13 一次Crash定位 - 被释放的NSURL.host

上一篇:redis操作命令 3


下一篇:零基础容器技术实战-学习报告