震惊!try catch 语句竟然失效了?

5,047 阅读26分钟

导读

C++ 异常处理机制 try catch 在快手 App 内突然失效,引发大量未捕获异常导致的崩溃。本文介绍稳定性团队排查此次问题的过程,问题的根本原因以及修复规避方案,最后梳理异常处理流程,知其然,知其所以然,方能在问题出现时冷静应对。

本文首次发表在 快手大前端技术,请各位观众老爷在微信内打开并关注点赞,给您拜年啦。

背景

快手 App iOS 端在最近的一个版本上线前, 自动化测试流程上报了非常多 C++ 未捕获异常(invalid_argument 、out_of_range 等)导致的崩溃,崩溃堆栈在版本周期内并没有修改记录,并且在崩溃的代码路径上存在 try catch 语句,catch 语句声明的异常类型是 exception。

invalid_argument 和 out_of_range 都是 logic_error 的子类,logic_error 是 exception 的子类。

640.png

根据 try catch 的工作原理,catch 语句声明 exception 可以捕获子类 out_of_range 和 invalid_argument。

catch @ExcType This clause means that the landingpad block should be entered if the exception being thrown is of type @ExcType or a subtype of @ExcType. For C++, @ExcType is a pointer to the std::type_info object (an RTTI object) representing the C++ exception type.

以 mmkv 的崩溃堆栈为例,readString 方法抛出了 std::out_of_range ,这个异常应该在 decodeOneMap 方法内被捕获,而不应该触发崩溃。

640.png

mmkv::CodedInputData::readString 抛异常代码: 640 (1).png

mmkv::MiniPBCoder::decodeOneMap 中捕获异常代码: 640 (2).png debug 线上的版本,124 行可以正常输出错误日志,在 mmkv 没有任何改动的情况下,自动化测试版本 catch 语句不能正常捕获异常了。

二、排查过程

本人王者荣耀钻五选手,对这个游戏有着相当深刻的理解,接下来的排查过程就以一局游戏为例吧。(真实原因是起一个段落标题和函数命名一样困难!)

全军出击

同样深入理解游戏的人脑海中已经有了画面,游戏开局,小兵缓缓抵达了战场,小鲁班 A 了一下兵线,first blood 人没了……

mmkv 里面的崩溃堆栈简化后如下所示:

void throw_exception() {
    throw std::out_of_range("out_of_range");
}

int catch_exception() {
    auto block = []() {
        throw_exception();
    };

    try {
        block();
    } catch (std::exception& e) {
        std::cout << "Caught exception." << e.what() << std::endl;
    }
    return 0;
}

这段代码在 demo 工程里面运行,使用 exception 类型可以 catch 子类型 out_of_range。但是把相同的代码复制到快手 App,catch 语句不会执行。当时怀疑和快手 App 的编译选项改动有关,找到架构那边的同学,确认编译参数近期没有任何改动。

21 年年底处理过一次 try catch 失效导致的崩溃,这种超乎常理的问题总是令人印象深刻。上次的原因是 hook 系统方法 objc_msgSend 后没有添加 CFI 指令,导致 unwind 回溯到 objc_msgSend 后中断,无法继续向上查找调用栈中的 catch 语句。所以在排查这个问题时首先想到的是判断 unwind 流程是否正常。这个判断用代码比较容易实现,测试用例里面,新增一个 catch 语句,捕获具体的子类,然后在快手 App 运行。

try {
    block();
} catch (std::exception& e) {
    std::cout << "Caught exception" << e.what() << std::endl;
} catch (std::out_of_range& e) {
    std::cout << "Caught out_of_range" << e.what() << std::endl; << ---- 会执行
}

运行上述代码后,执行了第二个 catch 块,out_of_range 捕获了 out_of_range,说明 unwind 流程是正常的,可以回溯到 try catch 语句。

测试用例中运行结果同时表示:

  1. out_of_range 实例 is type of out_of_range 成立。
  2. out_of_range 实例 a subtype of exception 不成立。

第二条显然不符合预期,所以在快手 App 内异常没有被捕获的原因是判断 exception 和 out_of_range 的继承关系时存在错误。尝试使用 is_base_of 在快手 App 内判断 out_of_range 是否是 exception 的子类,返回的结果 rv 是 true。

bool rv = std::is_base_of<std::logic_error, std::out_of_range>::value
&& std::is_base_of<std::exception, std::logic_error>::value
&& std::is_base_of<std::exception, std::out_of_range>::value;
if (rv) {
  abort(); << ---- 会执行
}

然而,在异常处理流程中,判断 catch 的 exception 类型是否能匹配抛出子类型 out_of_range 异常时是通过 is_base_of 方法吗?这个问题现在来看比较低级,但是在当时缺少对整个异常处理流程的认知,不知从何处开始调试,只能暂时放下这个问题,开始其它方向的排查。

请求打野支援

一顿操作猛如虎,一看战绩 0-5。打野,速速来 gank!

这是一个新增并且可以稳定复现的崩溃,因此一定能够查找到是哪个 MR 引入的。稳定性组的两个同事,从出现崩溃的 commit 开始二分查找之前一天的 MR,最终锁定了动态库改静态库这个提交 ,这个提交 merge 之后构造的测试用例可以复现崩溃。之后根据自动化流程上报的堆栈,修改 mmkv readString 方法,调用即抛出 out_of_range 异常,在 decodeOneMap 方法内异常没有被 catch,实锤了是这个 MR 引入的问题。

这个 MR 并不复杂,修改点不多,将部分动态库改为静态库集成到快手 App。里面删除了一些动态库的编译选项,和 C++ 相关的只有一个 CLANG_CXX_LANGUAGE_STANDARD,用于指定 Clang 编译器在编译 C++ 代码时所使用的语言标准。 640 (3).png 640 (4).png

虽然定位了问题引入的 MR,但是此时根据代码 diff 还是看不出具体的原因。

集合准备团战

不是一个人的王者,而是团队的荣耀!

动态库改静态库是最近一次活动必须要上的需求,否则会存在 ANR 影响活动效果,所以定位到的 MR 不能被直接回滚。周四就要提审,这个问题不被解决一定会阻塞提审流程,影响到活动版本的覆盖率。

周三晚上,稳定性组的负责人开始组织整个组同学参与进来一起讨论解决方案。在这次讨论中,首先排除了一个方向: 代码 diff 中删除动态库的 C++ 版本对快手 App编译环境无影响。之后初步梳理了 C++ exception handling 的逻辑,明确崩溃场景下使用 __gxx_personality_v0 routine 方法来判断栈帧是否能 catch 异常。之后 debug __gxx_personality_v0 得出了一个非常关键的信息,导致 try catch 失效的直接原因是快手 App 内多了一份 exception 的 type_info:

std::exception 的 type info 对不上,name 都是一样的,但是一个在 libc++abi.dylib, 一个在快手 App内,正常应该都会在 libc++abi.dylib,也就是说快手 App多了一份 std::exception 信息。

在定位到直接原因后,接下来开始查找 std::exception 的 type_info 是被哪个编译 target 引入快手 App的。image lookup 可以查看 type_info 指针地址详细的信息:

(lldb) image lookup -a 0x000000010396c970
      Address: Example[0x0000000101498970] (Example.__DATA.__const + 39952)
      Summary: Example`typeinfo for std::exception

0x000000010396c970 存储在 __DATA.__const 段, __DATA.__const 是一个特殊的 section,用于存储只读的常量数据,在一般情况下,__const section 中存储常量的顺序是按照它们在源代码中出现的顺序来排列的。尝试查看 0x000000010396c970 这个地址附近存储的信息。

(lldb) image lookup -a 0x000000010396c978
      Address: Example[0x0000000101498978] (Example.__DATA.__const + 39960)
      Summary: Example`typeinfo for std::exception + 8
(lldb) image lookup -a 0x000000010396c980
      Address: Example[0x0000000101498980] (Example.__DATA.__const + 39968)
      Summary: Example`typeinfo for std::bad_alloc
(lldb) image lookup -a 0x000000010396c960
      Address: Example[0x0000000101498960] (Example.__DATA.__const + 39936)
      Summary: Example`vtable for std::__1::__function::__base<bool (Runtime::JSState&)> + 56
(lldb) image lookup -a 0x000000010396c950
      Address: Example[0x0000000101498950] (Example.__DATA.__const + 39920)
      Summary: Example`vtable for std::__1::__function::__base<bool (Runtime::JSState&)> + 40

在 0x000000010396c970 - 0x10 的位置找到了 Runtime::JSState。Runtime 这个符号在组件 dummy 内定义。取 dummy 的编译产物 libdummy.a,发现 .a 里面 const 段,存在 exception 的 type info。

00000000000070b0 (__DATA,__const) weak private external __ZTISt9exception
00000000000070c0 (__DATA,__const) weak private external __ZTISt9bad_alloc
00000000000070d8 (__DATA,__const) weak private external __ZTISt20bad_array_new_length
➜  Exception git:(main) ✗ c++filt __ZTISt9exception
typeinfo for std::exception

测试 demo 工程依赖组件 dummy 后编译,使用 exception 无法 catch out_of_range ,实锤了是这个组件引入的问题。在 podspec 里面查看 dummy 的编译选项,发现禁用了 RTTI,在 Xcode 里面将这个选项修改为 YES 之后,try catch 失效导致的未捕获异常崩溃不再复现。

dummy 是这次动态库改静态库的需求中,改动的动态库间接依赖的静态库,主可执行文件之前不会直接依赖 dummy。宿主动态库以静态库方式集成到快手 App后,dummy 同样以静态库的方式集成到快手 App,导致 std::exception 的 type_info 被引入主可执行文件。定位到了引入的子库 dummy 和 try catch 失效的原因后,接下来就是查找对应的解决方案。

VICTORY

敌方水晶已被击破!

方案 1

最快速的修改是将 dummy 编译选项 GCC_ENABLE_CPP_RTTI 修改为 YES,但是因为其特殊的业务场景不允许被修改。

方案 2

dummy 删除 std::exception 的依赖。最终以失败告终,libdummy.a 仍然存在 exception type_info,当时应该是没有删除干净,仍然存在 exception 的子类。

方案 3

这个方案和方案 2 在同步进行。崩溃的直接原因是动改静之后,将 dummy 集成到了快手 App,导致主可执行文件多出了一份 std::exception。虽然不能将全量的动态库回滚,单独将 dummy 回滚为动态库也能解决问题。

方案 4

反向修改。在查看 libdummy.a 符号时,发现这个库同时存在 exception 的子类 std::bad_alloc 的 type_info,在快手 App内使用 exception 可以 catch bad_alloc,说明父类和子类都在主可执行文件时,try catch exception 也可以捕获子类。如果 dummy 包含了所有的子类,try catch 失效的问题也能解决。这个方案虽然能修复我们遇到的问题,但是我们无法评估这样的修改是否会产生额外的影响。

方案 5

事后我手同事又提供一个解决方案,添加如下cflags:set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_LIBCPP_TYPEINFO_COMPARISON_IMPLEMENTATION=2") ,添加之后,即使存在两份 type_info,type_info 的判等可以走到 strcmp 的逻辑里面。

最终选择了方案 3,风险最低,不会影响业务逻辑,并且修改的时间成本较低。方案 3 并不是把 try catch 失效的影响范围缩小到了dummy 里面,根据方案 4 的原理,方案 3 并不会导致 dummy 内的 try catch 失效。最终由架构组同学负责修改,修改后验证可行。

三、复盘

段位在钻五之上的人一定是懂复盘的,要从之前胜利的对局中吸取经验,这样在以后的对局中才能一直赢,一直赢,守住钻五的牌面。

try catch 失效的问题虽然被解决了,但是排查过程中遇到的一些问题还没有找到答案。从稳定性的角度出发,首要问题是理清 C++ 异常处理机制,exception handing 如何查找 catch 块以及判断 catch 块是否匹配正在抛出的异常?在问题解决后,查阅了一些资料,对 C++ 异常处理机制有了一些基本的了解。

在函数调用栈帧中每个函数都对应 1 个或者 0 个 exception table,里面包含了这个函数内不同的 PC 区间可以 catch 的异常类型,以及 catch 后的跳转地址,在异常抛出时,查找调用栈中可以捕获抛出异常的栈帧时,会依次使用调用栈中不同的栈帧对应的 exception table,根据栈帧跳转时的 PC,匹配栈帧 exception table 内记录的地址区间,找到这个区间可以 catch 的异常类型,使用这个类型来判断是否能 catch 抛出的异常,如果可以 catch 则跳转到区间对应的跳转地址,继续执行 catch 块的代码,否则继续查找上一个栈帧。

接下来将结合具体的 demo 用例、编译产物、源码和大家一起分享下异常处理流程。

throw

从 throw 说起,编译时 throw 会被替换为 __cxa_throw, __cxa_throw 会调用 _Unwind_RaiseException, 如果_Unwind_RaiseException 未找到捕获当前 exception 的 landing pad 或者在查找过程中出现错误,会 return。return 后 __cxa_throw 方法继续执行 failed_throw,failed_throw 内执行__terminate。查找到可以捕获异常的 landing pad 之后会跳转到对应的地址,不会 return 也就不会触发崩溃。

640 (5).png

这里的 landing pad 可以理解为当异常捕获时继续执行的函数调用入口,这个函数接收两个参数 exception structure 和 exception type_info 在 typetable 中的索引。在 landing pad 函数内会根据 type_info 的索引值来决定具体执行的 catch 块。landing pad 的另一个语义是 cleanup 调用入口。

_Unwind_RaiseException

_Unwind_RaiseException 包含了异常处理的两个核心流程 phase1 和 phase2,对应 search 和 cleanup。

在异常抛出时,需要遍历栈帧,查找可以捕获异常的 catch 语句。search 阶段使用 libunwind 依次回退栈帧,恢复寄存器信息,并根据 PC 二分查找 __unwind_info,获取栈帧对应的 personality 函数,以及执行 personality 函数依赖的 exception table -- LSDA(Language Specific Data Area)。之后调用 personality 解析 LSDA 来判断当前栈帧是否能 catch 异常,如果可以会记录栈帧相应的信息。

search 阶段如果没有查找到可以处理异常的栈帧,会返回到 __cxa_throw 方法内,执行 terminate,查找成功会继续执行 cleanup 阶段。当异常发生时,从 throw 到 catch 之间的函数执行中断,cleanup 等价于在函数退出时执行的清理操作。以下面的代码为例,A 调用 B,B 调用 C,A catch C 抛出的异常,在 B 调用 C 之前定义了 m_cls。未发生异常时 m_cls 在函数末尾触发析构方法,异常发生时 B 函数执行中断,m_cls 在 cleanup 阶段执行析构方法。

void funcC() {
    // 抛出异常
}

void funcB() {
    MyClass m_cls;
    funcC();
}

void funcA() {
    // catch 异常
}

cleanup 会再次回退栈帧,并执行局部变量的清理,保证资源可以正常释放,当回退到 search 阶段记录的栈帧,会使用 search 缓存的跳转地址,执行 resume,实现 throw 到 catch 块的跳转。

异常处理流程为什么会拆分为 search 和 cleanup 呢?官方给出的解释如下:

A two-phase exception-handling model is not strictly necessary to implement C++ language semantics, but it does provide some benefits. For example, the first phase allows an exception-handling mechanism to dismiss an exception before stack unwinding begins, which allows resumptive exception handling (correcting the exceptional condition and resuming execution at the point where it was raised). While C++ does not support resumptive exception handling, other languages do, and the two-phase model allows C++ to coexist with those languages on the stack.

两个阶段的异常处理模型对于 C++ 并不是严格必需的,但是它可以带来一些好处。比如,第一阶段允许异常处理机制在栈帧展开之前消除异常,这样可以进行恢复式异常处理(对异常情况进行修复,然后在抛出异常的地方继续执行),虽然 C++ 不支持恢复式的异常处理,但其它语言支持,两阶段模型允许 C++ 与那些语言在堆栈上共存。

在 search 和 cleanup 阶段,都需要依赖 LSDA 判断当前栈帧是否能 catch 异常,了解 LSDA 的数据结构对于理解异常处理流程至关重要。

LSDA

LSDA 包含 header, call site table, action table 和一个可选的 type table。

判断当前栈帧是否能 catch 异常时,涉及到三次查表的过程,

  1. 根据当前栈帧的 PC 查找 call site table,获取地址区间匹配的的 call site。

  2. 根据 call site 记录的 action 索引值在 action table 取 action。

  3. 根据 action 中 type_info 的索引值在 type table 中取 type_info。

之后根据 type_info 判断是否能 catch 异常,是则记录(phase1)或者跳转(phase2)到 call site 中 lpad 字段记录的的 landing pad address。否则继续向上回溯栈帧,并重复上述过程。

LSDA 的数据结构如下图所示:

640 (6).png

header

LPStart: 默认是函数的起始位置。

TTBase: 记录 type table 的相对位置。

call site record

start & len: 记录了可能会抛出异常的 PC 区间,这个区间是相对于函数起始位置的偏移量。

lpad: 记录匹配之后跳转的“函数地址” landing pad address 的相对位置。

action_offset: 记录 action table 中的索引值,action 用于查找 call site 能够捕获的异常类型。

action record

filter:记录了 catch 块中异常类型的 typeinfo 在 type table 中的索引。

next_ar:指向下一个 action 或者 end,如果 filter 类型不匹配,则继续查找 next_ar。遍历到 end 未找到匹配的 filter 表示当前 call site 所包含的 catch 块都不能处理 exception。

type table

编译器会将 catch 块异常类型的 typeinfo 信息存储在 type table 中。typeinfo是 C++标准库提供的类,它包含了与类型相关的运行时信息。

举个例子

以下面的代码为例,抛出异常并在当前函数内捕获异常:

640 (7).png

简化后判断是否能 catch 异常的过程:

catch_exception 将函数的地址区间拆分为不同的 call site,21 行 try 块所在的 call site ,记录了这个区间能 catch 的异常类型 out_of_range 和 exception,以及 catch 后的跳转地址 22 行,在 22 行根据抛异常的类型判断具体执行的 catch 语句。try 块内抛出的异常类型 out_of_range 和 call site 记录的异常类型匹配,异常被捕获,根据匹配的类型继续执行第一个 catch 语句。如果不匹配,会继续在调用栈向上查找,如果上一个栈帧跳转到 catch_exception 的地址也在 try 块内,则继续使用对应的 call site 判断,如果仍然不能匹配或者跳转地址本身就不在 try 块内则继续查找上一个栈帧。

接下来根据 catch_exception 方法生成的实际数据推演运行时捕获 exception 的流程。

catch_exception 方法内 catch 两种类型的异常 out_of_range 和 exception, 对应的 type table 如下所示, 其中 0 表示会 catch 所有的异常:

Catch TypeInfostype_info
TypeInfo 30
TypeInfo 2__ZTISt12out_of_range@GOT-Ltmp28
TypeInfo 1__ZTISt9exception@GOT-Ltmp29

action table 如下所示,action table entry 中 filter 表示上述 type table 中的索引值,以 Action Record 4 为例表示使用 type table 中索引 1 对应的 std::exception 判断是否能 catch 异常,判断执行的逻辑是 std::exception 是否和抛出的异常类型是同一个类型或者有相同的基类。而 Action Record 5 ,next_ar 指向 action 4,表示的是一个链表,会先使用 action 5 中的索引值 2 对应的 std::out_of_range 判断,如果不能 catch 异常,会继续执行 action 4 的判断逻辑,两者任意一个类型匹配抛出异常的类型都表示异常可以被捕获。

Action RecordTypeInfo(索引)Next Action
Action Record 10(Cleanup)No further actions
Action Record 21Continue to action 1
Action Record 32Continue to action 2
Action Record 41No further actions
Action Record 52Continue to action 4

catch_exception 方法在编译时生成的 call site table 如下,其中的 Lfunc_beginX,LtmpX 表示汇编代码的标签,可以理解为代码段中地址的别名。

以 Call Site 3 为例,表示当 PC 处于 Ltmp3 和 Ltmp4 地址区间内,会根据上述 action table 中的 action 5 判断是否能 catch 异常,是则会跳转到 call site 记录的 landing pad 地址 Ltmp5 处,执行 catch 语句处理异常。

Call Site 1 2 和 4 记录的 action 都是 0, 0 表示需要执行 cleanup,cleanup 只会在 phase2 阶段触发,在 phase1 命中 cleanup,表示当前栈帧无法 catch 异常,会继续执行 unwind。1 和 4 的 lpad 也是 0 表示不存在执行 cleanup 的函数入口,2 不为 0,实际上也只有 Call Site 2 会在阶段 2 跳转到 Ltmp2 地址处执行 cleanup。

Call Sitestart(代码标签)lenlpadaction(索引)
Call Site 1Lfunc_begin0Ltmp0-Lfunc_begin0no landing pad0(cleanup)
Call Site 2Ltmp0Ltmp1-Ltmp0jumps to Ltmp20
Call Site 3Ltmp3Ltmp4-Ltmp3jumps to Ltmp55
Call Site 4Ltmp4Ltmp11-Ltmp4no landing pad0

抛出异常代码 throw std::out_of_range("out of range") 对应代码标签 Ltmp3:

Ltmp3:
  .loc  1 0 15                          ; CPPException/test.cpp:0:15
  ldr  x0, [sp, #24]                   ; 8-byte Folded Reload
  adrp  x1, __ZTISt12out_of_range@GOTPAGE
  ldr  x1, [x1, __ZTISt12out_of_range@GOTPAGEOFF]
  adrp  x2, __ZNSt12out_of_rangeD1Ev@GOTPAGE
  ldr  x2, [x2, __ZNSt12out_of_rangeD1Ev@GOTPAGEOFF]
  .loc  1 21 9                          ; CPPException/test.cpp:21:9
  bl  ___cxa_throw          // <<<<<<<< 在这里抛出异常

Ltmp3 在 Call Site 3 范围内 。

Call Site 3Ltmp3Ltmp4-Ltmp3jumps to Ltmp55

Call Site 3 的 action 字段值为 5, 对应 Action Record 5:

Action Record 52Continue to action 4

Action Record 5 使用 out_of_range 判断是否能 catch 异常。抛出异常类型为 out_of_range,action 5 可以 catch 异常,search 阶段执行完成。

Call Site 3 对应的 landing pad address 为 Ltmp5,在 phase2 跳转到 Ltmp5 根据 type_info 判断具体执行的 catch 语句,Ltmp5 标签处的代码:

Ltmp5:
  .loc  1 27 1                          ; CPPException/test.cpp:27:1
  stur  x0, [x29, #-8]
  mov  x8, x1
  stur  w8, [x29, #-12]
  b  LBB0_4
LBB0_4:
  .loc  1 22 5                          ; CPPException/test.cpp:22:5
  ldur  w8, [x29, #-12]
  str  w8, [sp, #12]                   ; 4-byte Folded Spill
  subs  w8, w8, #2
  cset  w8, ne
  tbnz  w8, #0, LBB0_8
  b  LBB0_5

LBB0_8 最终会执行第一个 catch 语句:

  .loc  1 25 9                          ; CPPException/test.cpp:25:9
  bl  _printf

LBB0_5 最终会执行第二个 catch 语句:

  .loc  1 23 9                          ; CPPException/test.cpp:23:9
  bl  _printf

源码分析

对 LSDA 的内存布局和异常处理流程有一定了解之后,再去阅读异常处理流程的源码,相对就比较容易了。接下来回到问题本身,从源码的角度分析一下在快手 App 内为什么 exception 无法 catch out_of_range。

异常处理流程会先获取 catch 语句中 exception 的 type_info:

const __shim_type_info* catchType =
  get_shim_type_info(static_cast<uint64_t>(ttypeIndex),
                     classInfo, ttypeEncoding,
                     native_exception, unwind_exception,
                     base);

catchType == 0 表示 catch (...) 会捕获所有异常。不为 0 时调用 can_catch 方法判断 catch 块中声明的类型和抛出异常的类型是否匹配。

if (catchType->can_catch(excpType, adjustedPtr)) {

}

can_catch 有两个判断,其中任意一个成立都表示可以 catch 异常。

  1. 调用 is_equal 判断 catch 块类型是否和抛异常类型相等。

  2. 调用 has_unambiguous_public_base 判断两者是否有相同的 base,判断 base 是否相同时也会调用 is_equal 方法。(unambiguous: 明确的)。

bool
__class_type_info::can_catch(const __shim_type_info* thrown_type,
                             void*& adjustedPtr) const
{
    // bullet 1
    if (is_equal(this, thrown_type, false))
        return true;
    const __class_type_info* thrown_class_type =
        dynamic_cast<const __class_type_info*>(thrown_type);
    if (thrown_class_type == 0)
        return false;
    // bullet 2
    __dynamic_cast_info info = {thrown_class_type, 0, this, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,};
    info.number_of_dst_type = 1;
    thrown_class_type->has_unambiguous_public_base(&info, adjustedPtr, public_path);
    if (info.path_dst_ptr_to_static_ptr == public_path)
    {
        adjustedPtr = const_cast<void*>(info.dst_ptr_leading_to_static_ptr);
        return true;
    }
    return false;
}

is_equal 的判断逻辑如下,use_strcmp 传入的是 false,执行第 8 行:

static inline
bool
is_equal(const std::type_info* x, const std::type_info* y, bool use_strcmp)
{
    // Use std::type_info's default comparison unless we've explicitly asked
    // for strcmp.
    if (!use_strcmp) //    
        return *x == *y;
    // Still allow pointer equality to short circut.
    return x == y || strcmp(x->name(), y->name()) == 0;
}

std::type_info 重载了 == 方法:

bool operator==(const type_info& __arg) const _NOEXCEPT
{
  return __impl::__eq(__type_name, __arg.__type_name);
}

默认 __impl 中 __eq 实现如下:

static bool __eq(__type_name_t __lhs, __type_name_t __rhs) _NOEXCEPT {
  if (__lhs == __rhs)
    return true;
  if (__is_type_name_unique(__lhs) || __is_type_name_unique(__rhs))
  // Either both are unique and have a different address, or one of them
  // is unique and the other one isn't. In both cases they are unequal.
    return false;
  return __builtin_strcmp(__type_name_to_string(__lhs), __type_name_to_string(__rhs)) == 0;
}

结合源码信息, 再次回顾下我们这次遇到的问题,直接原因是 out_of_range 遍历到 base std::exception 时和 catch 语句声明的 std::exception 在执行 is_equal 时返回了 false,便于区分我们把前者称之为 thrown_exception, 后者称之为 catch_exception。

第一个判断条件 ==:

== 判断的是 type_info 中 __type_name 的地址,__type_name 的类型是 const char *。 thrown_exception 的 __type_name 存储在 libc++abi.dylib 的 __TEXT.__const 段。catch_exception 的 __type_name 存储在主可执行文件的 __TEXT.__const 段。所以地址判等为 false。

第二个判断 is_unique:

thrown_exception 的 type_info 是 unique 类型的,unique 表示 type_info 在程序中只存在一份副本,因此对于地址不同的 type_info 一定是不相等的,不需要判断 name 是否相等, 所以在第二个 if 语句返回了 false。

第三个判断 strcmp:

虽然两个 type_info 的 name 都是 St9exception,但在第二个判断返回 false ,并没有走到 strcmp 的逻辑里面。

对于 type_info 的判等,实际上存在三种方式:

1. __unique_impl::__eq

static bool __eq(__type_name_t __lhs, __type_name_t __rhs) _NOEXCEPT {
      return __lhs == __rhs;
}

在遵循 Itanium ABI 的编译器中,对于给定类型的 RTTI,只有一个唯一的副本存在,因此可以通过比较类型名称的地址来判断 type_info 是否相等,无需使用字符串,可以提高性能并简化代码。

2. __non_unique_impl::__eq

static bool __eq(__type_name_t __lhs, __type_name_t __rhs) _NOEXCEPT {
  return __lhs == __rhs || __builtin_strcmp(__lhs, __rhs) == 0;
}

由于各种原因,链接器可能没有合并所有类型的 RTTI(例如:-Bsymbolic 或 llvm.org/PR37398)。在这种情况下,如果两个 type_info 的地址相等或者它们的名称字符串相等,这两个 type_info 被认为是相等的。

修复方案中的方案 5 通过设置 cflag 把 __impl 类型修改为 __no_unique_impl,使用 strcmp 方法判断主可执行文件中的 type_info 和 libc++abi 中的相等。

3.__non_unique_arm_rtti_bit_impl::__eq 默认实现

这种方式是 Apple ARM64 的特定实现,给定类型的 RTTI 可能存在多个副本。在构造 type_info 时,编译器将类型名称的指针存储在 uintptr_t 类型中,指针的最高位表示 non_unique 默认为 0(false)。如果最高位被设置为 1,表示 type_info 在程序中不是唯一的。如果最高位没有被设置,表示 type_info 是唯一的。

这个设计的目的是为了避免使用 weak 符号。它将原本会被作为弱符号生成的默认可见性的 type_info,转而使用隐藏可见性的 type_info,并把 non_unique bit 位设置为 1 ,表示非唯一。这样做的好处是,在链接镜像内,hidden 可见性的 type_info 仍然可以认为是唯一的,可以继续通过 linker 进行去重,而在不同的镜像间,会被视为不同的类型,避免了 weak 符号被重定向,导致 RTTI 类型信息混乱。

EH & -fno-rtti

这次问题的直接原因是主可执行文件多了一份 type_info, 那为什么禁用 RTTI 之后会重新生成一份 type_info 呢?

异常处理流程依赖 type_info 实现 can catch 的判断逻辑,在禁用 RTTI 之后,为了能继续获取异常类型的 type_info 信息,编译器会重新生成一份。

llvm ItaniumCXXABI.cpp 文件 BuildTypeInfo 方法内可以查看生成 type_info 的逻辑。

其中判断是否使用外部的 type_info 代码如下:

 // Check if there is already an external RTTI descriptor for this type.
  if (IsStandardLibraryRTTIDescriptor(Ty) ||
      ShouldUseExternalRTTIDescriptor(CGM, Ty))
    return GetAddrOfExternalRTTIDescriptor(Ty);

条件1: IsStandardLibraryRTTIDescriptor

判断是否是基础类型,比如 int bool float double。

条件2: ShouldUseExternalRTTIDescriptor

判断 type_info 是否已经存在于其他位置,如果是在当前的编译单元中就不需要再生成 type_info。

在 ShouldUseExternalRTTIDescriptor 方法内判断了 RTTI 的状态,禁用后直接返回了 false。

  // If RTTI is disabled, assume it might be disabled in the
  // translation unit that defines any potential key function, too.
  if (!Context.getLangOpts().RTTI) return false;

禁用 RTTI 后上述两个条件都不满足,会继续执行生成异常类型的 type_info,同时也会生成 base 的 type_info。

  ///Record 表示 Structure/Class descriptor
  case Type::Record: {
    const CXXRecordDecl *RD =
      cast<CXXRecordDecl>(cast<RecordType>(Ty)->getDecl());
    if (!RD->hasDefinition() || !RD->getNumBases()) {
      // We don't need to emit any fields.
      break;
    }

    if (CanUseSingleInheritance(RD))
      BuildSIClassTypeInfo(RD);
    else
      BuildVMIClassTypeInfo(RD);

    break;
  }

四、总结

开局 3 分钟,战至二塔下猥琐发育的鲁班自言自语到: 有人需要技术支持吗? 鲁班大师,智商二百五,膜拜,极度膜拜。

查看 mmkv 的 issue 列表,发现我们并不孤单。iOS 工程通常使用 cocoapods 集成不同的组件,这些组件在编译时会作为一个独立的 target,任意一个 target 的编译选项禁用 RTTI 后都会影响到宿主 App 的异常处理流程,继而可能引发 try catch 失效。 640 (8).png

为了解决此类问题,给大家提供两个规避方案:

  1. 禁用 RTTI 的同时禁用 exception handling,即 -fno-rtti 和 -fno-exceptions 一起使用,这样单独的 target 不会影响到宿主 App 的 exception handing。

  2. 如果禁用 RTTI 后想保留 exception handing,添加 cflag -D_LIBCPP_TYPEINFO_COMPARISON_IMPLEMENTATION=2,在损耗一点性能的前提下保证 exception handling 正常的处理流程。

各位看官老爷,如果同样也遇到过不同的编译选项引发的问题,欢迎在评论区留言讨论~

参考资料

[1] llvm.org/docs/Except…

[2] itanium-cxx-abi.github.io/cxx-abi/abi…

[3] itanium-cxx-abi.github.io/cxx-abi/exc…

[4] www.hexblog.com/wp-content/…

[5] github.com/Tencent/MMK…