条件编译不一致引发的内存布局问题及崩溃复盘

241 阅读14分钟

〇、摘要

—— emigservice native exception 问题简单总结

在跨进程通信中,emigservice 进程中创建的 Transaction 对象与 clientA 进程传递来的 Transaction 对象存在差异,其根本原因在于条件编译选项的不一致,导致 Transaction 中的成员对象 layer_state_t 的结构体定义不一致,进而导致各自创建的 layer_state_t 对象的内存布局不一致。这一差异引发了 emigservice Native Exception:因内存布局不匹配,在随后的资源释放阶段,emigservice 试图访问非法内存地址,最终导致崩溃。

一、引言

条件编译指令(如 #ifdef, #if, #elif 等),通常用于适配不同的硬件平台、系统配置或功能特性。条件编译可以裁剪掉不需要编译的代码,提高代码执行效率,灵活配置和扩展代码功能。反之,若不能规范地使用条件编译指令,则可能会降低代码可读性,增加调试难度,以及条件编译选项的一致性问题。一致性问题通常十分隐蔽,需要在运行时特定条件下才能暴露。本文即将复盘的案例,是由于在头文件中使用条件编译指令进行功能裁剪,但未能确保跨模块的条件编译选项一致性,导致结构体内存布局出现差异,最终引发进程崩溃。

二、问题分析描述

2.1 问题背景描述

在 Android 系统中,emigserviceclientA 是两个独立的进程,它们都依赖于共享动态链接库 libgui.so。三者关系如下图所示。libgui.so 中提供了 class Transactionstruct layer_state_t 的定义(.h)和实现(.cpp)。Transaction 通过 unordered_map 管理多个 layer_state_t

在本案例的业务场景中,每隔 400ms 会执行一次任务 P。在单次任务 P 的执行过程中,clientA 进程创建了一个 Transaction*,申请内存后获取相关信息保存在其中。然后通过 IPC(跨进程通信)传递给 emigserviceemigservice 本地创建有一个 Transaction Object,用来接收来自 clientA 的信息。(注意这里一个是指针,一个是对象)

image-20241118212742226.png|450

2.2 问题分析定位

2.2.1 崩溃直接原因:fault addr 0xffffffffffffffff

下图为 crash log 截图。crash 日志表明:在某个动效对象结束后释放资源时,访问了非法内存地址 0xffffffffffffffff,操作系统发出异常信息 SIGSEGV,导致进程崩溃。

image-20241118214949514.png

通过 addr2line 工具,定位到代码位置为下图箭头所指处。

image-20241119164934688.png

(结合本文标题,聪明的你看到这里可能敏锐地发现问题所在了!o( ̄▽ ̄)d

由上面的堆栈可知,问题发生在进程 emigservice 释放 Transaction 对象时,执行成员对象 layer_state_t 的析构函数时,发现 xxxx 非空(0x0)。在尝试访问该地址时,由于地址非法(0xffffffffffffffff),最终导致程序崩溃。

2.2.2 析构时访问非法内存地址

1、为什么会存在访问非法内存地址的情况?

在 Android 系统中,Transaction 继承 Parcel,序列化后通过 Binder 机制跨进程传递到对端。对端通过反序列化得到 TransactionTransaction 中的成员对象,顺序递归完成序列化和反序列化。如下图所示。

image-20241119103605594.png

正常的构造和析构一般是没有问题的。那么在本案例中,最大的可能就是在跨进程通信过程中,传递 Transaction 对象时出现问题。这个猜测经过 debug 得到了证实。

  1. layer_state_t::read() 中打印 sp<XXXX> xxxx 的值:0x0,正常,因为没有使用,初始化值为 nullptr

  2. emigservice 读取 Transaction 之后,打印 sp<XXXX> xxxx 的值:0xffffffffffffffff。(贴脸了这是)

image-20241119105610745.png

2、通常什么时候会访问非法内存地址
  1. 空指针问题:未初始化、重复访问已释放内存、多线程并发访问同一块内存。
  2. 数组越界。
  3. 内存布局不一致:条件编译选项不一致导致内存布局不一致、动态库和模块内存布局不一致
  4. 解引用未对齐的内存。
  5. 类型转换错误,导致指针指向未授权区域。

image-20241119101443678.png

分析到这里,已经确定了问题原因是:动态库 libgui.so 和模块 emigservice 因为条件编译选项不一致,导致 layer_state_t 结构体定义不一致,进而导致各自创建的 layer_state_t 对象的内存布局不一致。该结论很容易得到证实,检查构建 libgui.soemigservice 的 Android.bp 即可。

2.2.3 问题复现

本案例中 layer_state_t 中的条件编译选项不一致,在代码中存在有一段时间了,为什么这个问题是在近期被发现,并且是低概率事件?经过分析,问题暴露需要满足两个条件。

1、emigservice 中的 Transaction Object 没有被消费并重置

emigserviceclientA 获取 Transaction Object 后,在正常的业务逻辑中,会被消费并重置。重置后便不会再继续持有因为内存布局不一致而错误获取到的 值或地址,因此析构时不会出现问题。

2、layer_state_t 结构体中成员变量的情况

通过代码审查,layer_state_t 结构体定义中的条件编译指令存在已久,直至某天结构体中新增了一些成员变量(即下图中的 sp<XXXX> xxxx)。在同时满足条件 1 时,本案例问题才得以暴露出来。

image-20241119164852753.png

在引入 sp<XXXX> xxxx 之前,GxData 之后也存在其它的成员变量,但都是一些内置类型或对内置类型的封装,不涉及指针类型成员变量。因此,可以大胆猜测,正是因为涉及指针运算,才超级放大了本案例问题。

2.3 根因分析-理解编译

本案例问题的本质是:两端对同一结构体的生成的内存布局不一致。问题出现后,没有立即意识到这一点,也说明了笔者对编译链接的知识掌握不到位。

2.3.1 条件编译指令如何导致内存布局不一致

上面分析到由于条件编译选项不一致导致 layer_state_t 对象的内存布局不一致。那么条件编译指令是如何导致内存布局不一致的?

在预编译阶段,条件编译指令会被处理,根据编译选项保留或忽略被条件编译指令包裹的代码。

在本案例中 emigservice 是一个可执行目标文件。libgui.so 是一个共享目标文件,是一个可重定位目标文件。它们俩在编译构建过程中,在预编译阶段处理条件编译指令时,所能见到的 layer_state_t 结构体定义已经存在不同,如下图所示。

image-20241119112747757.png

因此,在编译阶段,编译器为会 libgui.soemigservice 生成内存布局不同的 layer_state_t,即,在 libgui.so 代码中构建的 layer_state_t 对象内存布局和在 emigservice 代码中构建的 layer_state_t对象内存布局是不同的。于本案例而言,前者内存布局中存在GxData 成员,而后者不存在。

2.3.2 两端对同一结构体的“理解”不同引发访问非法内存

libgui.so 中给定了 layer_state_t 的构造函数,因此,emigservice 通过 IPC 读取到的 layer_state_t 对象,实际上是通过调用在 libgui.so 中定义的 layer_state_t 构造函数创建的,其内存布局包含 GxData 成员。最终在 emigservice 在释放 Transaction 对象,执行到 layer_state_t 析构函数时,因其可见的内存布局没有 GxData 成员,从而引发了访问非法内存问题。

但需要注意的是:正如 2.2.3 小节分析的那样,内存布局是否有 GxData 成员,并也不一定会导致访问非法内存的问题。而是取决于缺少 GxData 成员,析构时访问后续成员,是否存在访问非法内存地址的情况。

2.3.3 layer_state_t 析构函数的版本

libgui.so 提供的 layer_state_t 定义中,没有显式定义析构函数,但由于其成员对象包含显式定义的析构函数,编译器会为 layer_state_t 自动生成一个默认析构函数,并将其作为一个 LOCAL Symbol 存在于 libgui.so 中。

readelf -Wa symbols/system/lib64/libgui.so | c++filt | grep "~layer_state_t"
2524: 00000000000d13e0   748 FUNC    LOCAL  HIDDEN    15 android::layer_state_t::~layer_state_t()

在上述前提下,依赖 libgui.so 的其他模块,以 emigservice 为例:其依赖 libgui.so 中的 Transactionlayer_state_t 等类,在没有显式定义 ~layer_state_t() 的情况下,编译器在构建 emigservice 时,会为 emigservice 生成一个本地版本的 layer_state_t 的默认析构函数,并将其作为一个 LOCAL Symbol 存在于 emigservice 中。

readelf -Wa symbols/system/bin/emigservice | c++filt | grep "~layer_state_t"
501: 0000000000016200   652 FUNC    LOCAL  HIDDEN    16 android::layer_state_t::~layer_state_t()

 emigservice 运行时调用 ~layer_state_t(),不会重定位到 libgui.so 中的析构函数地址,而是会执行其本地(LOCAL)版本的 ~layer_state_t()

通过 readelf 工具可以发现,这种编译器自动生成的 Symbol 是 HIDDEN 的,即仅对本模块可用,对外不可见。

2.3.4 为什么 clientA 析构 layer_state_t 时没有发生这样的问题

由于崩溃进程为 emigservice,因此之前都围绕 emigservice 进行讨论。问题原因确认后,我们自然而然地会去想为什么 clientA 在析构 layer_state_t 时没有发生崩溃过呢?

  1. 首先确认,编译构建 clientA 模块时,条件编译选项与编译构建 emigservice 是相同的。

  2. 发现与 emigservice 的区别:emigservice 是创建了 Transaction Object 接受 clientA 传递来的 Transaction 信息,但 clientA 侧是通过 Transaction* 指针和 new 操作符创建的。

这个问题咨询了一些专家,找了很多资料,都没有获得解释得通的答案。后来,向 GPT 大模型请教了一下:

1、GPT 大模型给出的解答

【场景1】mTransaction 作为指针并被 delete

Transaction* mTransaction 是一个指针且使用 delete mTransaction 时,析构函数的解析通常遵循以下逻辑:

  1. 析构函数的符号选择编译器会优先链接到 layer_state_t 在 libgui.so 中的符号,因为 Transaction 本身在 libgui.so 中定义。即使 layer_state_t 析构函数是 inline 的,但跨模块时编译器更倾向于选择原模块(libgui.so)的符号以避免不一致。

  2. 析构调用路径:在 delete 语句中,mTransaction 的指针会解析到 Transaction 类在 libgui.so 中的定义,而 Transaction 里的 std::unordered_map 成员包含了 layer_state_t。最终,delete mTransaction 会调用 libgui.solayer_state_t 的析构函数符号。

因此,在指针场景下,编译器偏向于认为 mTransaction 绑定了 libgui.so 的析构符号,调用 libgui.so 中的析构函数版本。

【场景2】mTransaction 作为成员对象的析构

Transaction mTransaction 作为成员对象时,情况有所不同:

  1. 本地编译单元的内联优化:在 emigservice 中,layer_state_tinline 析构函数被直接内联。这种情况下,编译器会使用 emigservice 本地的 inline 实现,而不会调用 libgui.so 中的符号。

  2. 析构调用路径:因为 mTransactionemigservice 中是作为成员对象存在,编译器认为其内存布局由本地 emigservice 编译单元掌控,所以会直接使用在 emigservice 中的 layer_state_tinline 析构版本。

【总结】析构函数的版本选择是基于对象管理和符号链接规则的差异:

  • 指针对象:使用 delete 时,跨模块会选择指向定义模块的析构符号,即 libgui.so 中的 layer_state_t 析构函数。

  • 成员对象:在本地编译单元中定义的成员对象,编译器会优先使用当前编译单元的 inline 版本(emigservice 本地版本)。

2、问 GPT 大模型它参考的文献是什么

1、Itanium C++ ABI - Destructors

2、GCC Documentation on Inline Functions

3、C++ Standard - Basic Linkage

4、LLVM Documentation

实际上,并没有文献直接了当的表示有上述结论,GPT 大模型也很痛快的承认这是它根据文献推理得到的。

3、验证 GPT 大模型的结论

对待 GPT 大模型的结论需要谨慎,下面通过代码验证一下。

(1)测试代码

t1Transaction* 指针类型,t2Transaction 成员对象。

VLOGD(" ****** begin ******");
{
    VLOGD("↓↓↓↓↓ create a Transaction pointer ↓↓↓↓↓");
    Transaction* t1 = new Transaction;
    t1->setAlpha(mTestObject, 1.0f);
    VLOGD("↑↑↑↑↑ create a Transaction pointer ↑↑↑↑↑, delete ↓↓↓↓↓");
    delete t1;
    VLOGD("↑↑↑↑↑ delete ↑↑↑↑↑");
}

{
    VLOGD("↓↓↓↓↓ construct a Transaction object ↓↓↓↓↓");
    Transaction t2;
    t2.setAlpha(mTestObject, 1.0f);
    VLOGD("↑↑↑↑↑ construct a Transaction object ↑↑↑↑↑, ready to destruct ↓↓↓↓↓");
}
VLOGD("↑↑↑↑↑ destruct ↑↑↑↑↑");
VLOGD(" ****** end ******");

在构造函数中增加 log。

#ifdef WKC_FEATURE
    ALOGD("WKC_FEATURE construct:this=%p", this);
#else
    ALOGD("NO WKC_FEATURE construct:this=%p", this);
#endif

layer_state_t 并没有定义析构函数,但因为其成员对象拥有析构函数,因此编译器也会为其默认生成一个。这里我们为了加 log,通过 inline 内置到类的定义中,并通过条件编译指令让编译器生成不同版本的析构函数,用来在运行时进行区分。

#ifdef WKC_FEATURE
    ALOGD("WKC_FEATURE ~layer_state_t:this=%p", this);
#else
    ALOGD("without WKC_FEATURE ~layer_state_t:this=%p", this);
#endif
(2)输出结果

代码执行到 line7 时,delete t1,代码正常执行。 代码执行到 line16 时,调用 Transaction 析构函数,代码执行到 ~layer_state_t() 崩溃。

image-20241119160723552.png

(3)验证结论

构造时,由于 layer_state_t 构造函数显式定义在 libgui.so 中,无论是指针场景还是成员对象场景,都是使用编译器为 libgui.so 生成的内存布局版本创建的 layer_state_t 对象。

析构时,在指针场景,析构时,编译器选择了 libgui.so 中的 ~layer_state_t() 版本。在成员对象场景,析构时,编译器选择了 emigservice 中的 ~layer_state_t() 版本。

因此,在成员对象场景下,析构时 emigservice 可见的 layer_state_t 对象的内存布局,与创建时的不一致,最终发生了非法访问内存的错误。

在 GPT 大模型的帮助下,勉强找到了一种能够解释本小节问题的答案。

三、问题的影响

通过前文的分析,我们可以确定一个关键事实:条件编译选项的不一致可能导致同一类型对象在多个模块间的内存布局出现差异。然而,这种问题并非一定会直接导致进程崩溃等严重后果。

在本案例中,问题的复现需要满足多个特定条件,因而具备一定的隐蔽性。这类问题的触发场景复杂,通常难以预见,属于典型的难发现但后果严重的问题类型,调试成本极高。

幸运的是,本案例通过业务逻辑代码中的详细日志记录得以快速定位和解决,减少了调试成本,但这种幸运并不总能复制。此类问题的潜在风险对复杂系统开发提出了更高的稳定性要求。

四、解决方案

4.1 短期解决方案

本案例问题本身的解决方案很简单,考虑到 libgui.so 是核心动态库,以及短期内无法统一管控各个模块的条件编译选项,优先选择移除 layer_state_t 结构体定义中的条件编译指令。

4.2 长期解决方案——人因管理

1、规范系统内条件编译指令的使用

  • 明确哪些条件编译选项需要在不同模块中保持一致。
  • 重要头文件中减少或避免滥用条件编译指令。

2、加强跨模块/进程的接口约定

  • 利用版本控制,确保头文件在各模块间同步。
  • 制定严格的接口约定,保证内存布局一致。

3、代码优化

  • 将易受条件编译影响的字段单独拆分或通过显式版本管理。

五、教训与总结

不是我引入的,嘿嘿,别人的教训,我的总结。

1、条件编译指令,在头文件中应该慎用。非用不可时,必须要检查一致性!

2、本案例问题分析过程中,暴露了自己对于编译链接的理解不足,需要补课!

3、本案例问题分析过程中,暴露了自己的代码能力不足,无法一眼看到问题所在,需要加强!

六、使用到的工具

1、 readelf,用来分析目标文件中的符号信息,确认编译结果和库依赖关系等。

2、 addr2line,将崩溃日志或堆栈信息中的地址转换为源代码中的文件名和行号,便于定位问题。