先看结论与判断条件
- Java 主线程、pthread 自建线程和线程池工作线程来自不同创建路径,默认栈、属性继承和调用链都不能相互代替。
- 栈预算应来自同一候选产物在目标设备上的受控测量,不能用头文件常量、桌面环境或另一 ABI 的结果推算。
- 回归用例要穿过真实递归或深调用链,同时限制输入上限,避免把故意制造进程崩溃当作日常验证方法。
- 每个样本必须绑定候选 SO 哈希、ABI、线程类别、调用深度、剩余预算、终态和符号包身份。
- Native 崩溃、ANR 和普通业务失败需要分开归因,符号化只还原位置,不自动证明栈溢出是根因。
- 加固、编译器、链接参数、异常处理或日志插桩变化都会改变栈帧,候选哈希变化后要重跑关键深度矩阵。
先按创建路径拆开线程栈条件
同一进程中的线程不共享一份固定栈预算。Java 主线程由 Android 运行环境创建,JNI 进入时已经带着框架调用链;pthread 自建线程由 Native 代码配置属性并创建;线程池工作线程可能由运行库、游戏引擎或第三方 SDK 管理。三者的初始栈、守护页、已占用帧和生命周期不同,单测一个线程不能替其他线程签字。
pthread_attr_setstacksize 一类配置只描述创建请求,最终可用空间还受到平台约束、对齐、守护区域和启动封装影响。代码若没有显式设置,也不能把某个设备观察到的默认值写成跨平台保证。回归清单要记录线程的实际创建入口、属性来源和目标用途,并明确它是否会进入 JNI、异常处理、日志、序列化或深层算法。
线程池更容易被忽略,因为业务代码只提交任务,看不到线程创建处。加固或库升级可能改变任务包装层数、局部对象和异常清理路径,栈占用随之增加。团队应把 threadType 作为事件必填字段,至少区分 main、pthread_owned 和 pool_managed;无法确认来源的样本标记 unknown,不纳入通过结论。
| 线程类别 | 创建者 | 主要变量 | 不能复用的结论 |
|---|---|---|---|
| Java 主线程 | Android 运行环境 | 框架调用链与 JNI 入口 | pthread 默认栈 |
| pthread 自建线程 | 应用 Native 代码 | attr、入口封装与守护区 | 主线程深度 |
| 线程池工作线程 | 运行库或 SDK | 池配置与任务包装 | 单次新建线程结果 |
| 回调线程 | 系统或外部库 | 回调契约与附加帧 | 业务自建线程预算 |
| 来源未知线程 | 未确认 | 创建属性不可追溯 | 任何兼容通过结论 |
预算要看真实调用链的余量,不只看栈总量
栈总量只是上界,业务真正关心的是某条路径执行到指定深度时还剩多少安全余量。入口之前已经存在启动封装、线程局部初始化和 JNI 桥接帧;递归过程中还会分配局部对象、保存寄存器并建立异常清理信息。只计算递归函数里一个结构体的大小,会漏掉编译器生成帧和被调用函数的开销。
项目预算应定义可接受的最大业务深度和最低剩余预算,并说明两者来自哪一个候选、ABI、编译模式和设备。测量点要稳定,最好在同一递归位置采集当前栈地址与已知边界的差值,再使用聚合事件输出;不要在紧张路径中大量格式化日志,否则测量工具本身会改变栈帧并放大误差。
安全余量不是追求刚好不崩溃。异常分支、信号到达、诊断代码和未来小改动都可能追加帧,因此发布上限应低于观察到的极限。具体余量不能从通用文章给出,需由项目按风险、调用链波动和设备矩阵决定。没有同候选数据时,应报告预算未建立,而不是引用别的 App 数字。
| 字段 | 含义 | 采集时机 | 缺失处理 |
|---|---|---|---|
| candidateSha | 目标 SO 身份 | 装载后固定 | 拒绝样本 |
| threadType | 线程创建类别 | 进入用例前 | 标记 unknown 并拒绝 |
| depth | 受控业务深度 | 每个检查点 | 拒绝无界值 |
| remainingBytes | 测量点剩余预算 | 真实调用链内 | 不得推算 |
| terminal | 完成、失败或退出 | 用例结束 | 拒绝无终态 |
| symbolSet | 同构建符号身份 | 回执归档 | 崩溃样本不可归因 |
用受控深度穿过真实路径,而不是盲目压垮进程
回归入口应接受受控深度,先检查项目上限,再调用真实业务路径中的核心帧结构。若生产逻辑是树遍历、解析器、解释器或跨语言回调,测试需要保留相同的函数边界、局部状态和错误处理;另写一个空递归函数只能验证测试函数自身,无法代表加固后的实际调用链。
深度点要从正常业务、接近预算和拒绝上限三个角色选择。正常点验证功能与结果,接近预算点确认剩余空间仍高于项目阈值,拒绝点确认输入在进入递归前被阻止。日常 CI 不应故意越过守护页制造不可恢复崩溃;崩溃复现放入隔离设备任务,并预先准备候选身份、符号和退出信息采集。
递归之外还要测试深调用链的组合变化。JNI 异常翻译、日志格式化、锁等待诊断和回调适配可能在失败路径叠加更多帧。测试数据应包含成功、可恢复错误和异常返回,比较相同深度的剩余预算分布。若某条错误路径明显更紧,应为它建立独立上限,而不是与成功路径平均。
| 用例角色 | 输入策略 | 期望终态 | 主要证据 |
|---|---|---|---|
| 正常深度 | 常见业务输入 | 功能成功 | 结果与预算事件 |
| 预算边界 | 接近项目上限 | 余量高于阈值 | 最小 remainingBytes |
| 拒绝上限 | 超过允许深度 | 递归前拒绝 | 拒绝回执 |
| 错误路径 | 触发可恢复错误 | 明确错误返回 | 异常帧预算 |
| 隔离崩溃 | 受控设备专用 | 退出被采集 | tombstone 与符号 |
候选身份和符号必须跟着每个崩溃样本
栈回归对构建身份高度敏感。编译器版本、优化等级、内联、LTO、栈保护、异常表、加固转换和日志宏都会改变帧布局。样本如果只有应用版本名,没有目标 SO 哈希和 ABI,就无法确认它是否来自正在评审的候选,也不能与源码或符号目录可靠对应。
Native tombstone 的地址需要同一构建的未剥离符号和正确架构才能还原。符号化后看到重复函数帧,可以支持递归深度判断,但仍要结合信号、故障地址、线程名称、入口参数和预算事件确认根因。若栈已严重破坏,回溯可能截断或包含不可靠帧,不能把最后一个可见函数自动定为责任点。
推荐为每个候选生成不可变证据包:最终 so 哈希、构建标识、未剥离符号目录摘要、测试配置和事件 schema。回执只引用这些身份,不把私有符号或完整崩溃内容公开。生产调查需要按访问权限读取原始数据,公开技术说明保持方法和边界,不暴露客户路径或内部地址。
区分栈溢出、ANR 与普通业务超时
线程栈问题通常表现为 Native 崩溃或异常退出,而主线程长时间阻塞更可能形成 ANR。递归算法也可能在尚未耗尽栈时消耗大量 CPU,导致调度延迟或组件超时。团队不能看到深调用链就统一标注 stack overflow,应分别检查退出原因、信号、线程状态、主线程 trace 和业务时间线。
ApplicationExitInfo 能提供进程退出原因、时间信息,并在相应平台版本提供诊断数据入口。使用时要按候选版本、时间窗和用户路径关联,避免把之前安装版本的退出记录归到当前测试。旧系统或厂商环境可能提供的信息不同,缺少记录时继续依赖 tombstone、测试回执和可控日志,不编造退出原因。
ANR trace 中显示某个 Native 方法并不说明该方法最初造成阻塞。锁竞争、Binder、I/O 或线程间等待可能让多个线程停在表象位置。栈预算事件只回答深度和余量,不承担 ANR 根因结论;性能时间线、锁关系与组件超时需要单独证据。这样可以避免为了修复不存在的栈问题盲目增大线程栈。
| 现象 | 首要证据 | 栈预算作用 | 不能直接下结论 |
|---|---|---|---|
| Native 崩溃 | 信号、tombstone、符号 | 关联深度与余量 | 最后一帧就是根因 |
| ANR | 主线程 trace 与时间线 | 排除深调用高耗时 | Native 帧等于栈溢出 |
| 业务超时 | 请求和任务终态 | 观察线程路径 | 一定是系统 ANR |
| 进程被杀 | 退出原因与内存状态 | 仅作辅助 | 属于递归崩溃 |
| 无记录退出 | 候选、时间窗、外部回执 | 标记证据缺口 | 推测具体信号 |
设备矩阵要覆盖 ABI、线程来源和失败路径
栈行为受 ABI、编译产物和运行环境影响,设备矩阵至少要覆盖业务交付的每个 ABI、最低系统、主流系统和关键厂商。每个环境不是只跑一次正常深度,而是按 threadType、调用路径和终态组合执行。线程来源无法在某设备触发时,应记录未覆盖原因,不用其他线程结果代签。
instrumented test 适合从应用上下文触发 JNI 入口、切换 Activity 生命周期、观察线程池任务和读取测试回执。它也会引入测试框架帧,因此测量点应位于目标 Native 调用链内部,并保持生产入口一致。测试 APK、目标 SO 和符号包的身份都要被记录,避免调试构建结果被拿来代表发布候选。
矩阵结果应按最小余量聚合,同时保留原始样本计数和拒绝原因。一个设备通过不代表全部设备,某次失败也不能自动推广为某 ABI 必然失败。只有同一候选的多环境回执才能支持项目范围内的工程判断;文章没有这些回执,因此不提供兼容通过或性能数字。
- 每个交付 ABI 至少绑定一份同候选设备回执。
- 主线程、自建线程和线程池任务分别触发目标入口。
- 正常、预算边界、拒绝上限和错误路径分别留存终态。
- 最低系统与关键厂商设备记录实际型号和系统构建。
- 调试 APK、发布候选与符号包身份不得混用。
- 未覆盖的线程类别、ABI 或设备角色明确标记为证据缺口。
- 同一设备重复样本保留最小余量与原始样本数量。
- 生命周期重建后再次进入目标线程路径并核对线程来源。
- 隔离崩溃任务预先验证退出信息和符号采集链。
- 线程属性读取失败时不得用计划配置代替实际记录。
- 错误路径与成功路径分别比较调用深度和剩余预算。
- 矩阵变更需要记录新增、删除设备角色及其工程理由。
用事件门禁拒绝身份缺失和预算越界样本
测试流程可以输出脱敏 JSON 事件,每条样本包含候选哈希、线程类别、深度、剩余字节、项目最大深度和最低余量。门禁先确认顶层样本数组,再检查候选身份格式、允许的线程类别、数值类型与终态。它不读取真实栈地址,也不包含递归压测代码,适合公开说明证据结构。
下面的 Python 校验器从命令行读取事件文件。输入文件不存在时在条件分支内直接非零退出,随后逐条拒绝候选身份缺失、深度超过项目上限、剩余预算低于阈值或终态不完整的样本。所有阈值来自事件携带的项目配置,脚本不提供通用安全数字,也不尝试掩盖失败。
结构门禁通过只说明回执字段和预算关系自洽。采集端是否真的位于目标调用链、remainingBytes 如何计算、候选哈希是否来自已装载 SO,仍需项目实现和设备证据确认。门禁版本要随采集算法变化,并在报告中记录,避免新旧语义相同字段被混在一起比较。
import json
import re
import sys
from pathlib import Path
SHA256 = re.compile(r"^[a-f0-9]{64}$")
THREAD_TYPES = {"main", "pthread_owned", "pool_managed"}
TERMINALS = {"completed", "rejected", "crashed"}
def stop(message):
print(f"stack budget rejected: {message}", file=sys.stderr)
raise SystemExit(2)
def load_events(path_text):
path = Path(path_text)
if not path.is_file():
print("stack budget rejected: event file is missing", file=sys.stderr)
raise SystemExit(2)
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
stop(f"cannot parse event file: {exc}")
def validate(data):
events = data.get("events")
if not isinstance(events, list) or not events:
stop("events must be a non-empty list")
for index, event in enumerate(events):
candidate = event.get("candidateSha", "")
if not SHA256.fullmatch(candidate):
stop(f"event {index} has no valid candidate identity")
if event.get("threadType") not in THREAD_TYPES:
stop(f"event {index} has an unknown thread type")
depth = event.get("depth")
maximum = event.get("maxDepth")
remaining = event.get("remainingBytes")
minimum = event.get("minRemainingBytes")
values = (depth, maximum, remaining, minimum)
if any(type(value) is not int or value < 0 for value in values):
stop(f"event {index} has invalid numeric fields")
if depth > maximum:
stop(f"event {index} exceeds the project depth limit")
if remaining < minimum:
stop(f"event {index} falls below the project stack budget")
if event.get("terminal") not in TERMINALS:
stop(f"event {index} has no valid terminal state")
print(f"stack budget accepted: {len(events)} events")
if __name__ == "__main__":
if len(sys.argv) != 2:
stop("usage: check_stack_events.py events.json")
validate(load_events(sys.argv[1]))把栈回归放进候选发布门禁
发布前先核对候选身份、ABI 清单、线程创建配置和符号包,再运行正常深度、预算边界、拒绝上限与错误路径。任何样本缺少候选哈希、threadType、剩余预算或终态都应阻止登记完成。隔离崩溃任务若没有退出记录和同构建符号,也只能标记证据不足,不能用进程确实退出替代根因判断。
当加固策略、编译器、链接参数、异常翻译或第三方 Native 库变化时,重新生成候选哈希并重跑关键矩阵。团队可以复用用例和设备池,但不得把旧候选回执复制给新 SO。若最小余量下降,先比较调用链和帧变化,再决定收紧输入、消除递归、拆分任务或调整自建线程配置。
站内的 Native 崩溃符号化文章可继续检查同构建地址还原;准备评估商业 App 的 SO 加固与线程栈风险时,可通过页面行动按钮进入御盾中央平台提交候选包、目标 ABI 和线程清单。提交只启动评估,实际栈预算和兼容结论仍以同一候选的设备回执为准。
- 按线程创建来源分别登记栈配置和调用入口。
- 让受控深度用例穿过真实 Native 调用链。
- 为成功与错误路径分别记录最小剩余预算。
- 绑定候选 SO 哈希、ABI、符号包和设备环境。
- 区分 Native 崩溃、ANR、业务超时和进程被杀。
- 候选机器码变化后重跑关键深度与设备矩阵。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Native 兼容问题可能来自 API level、符号、运行库和依赖装载,栈回归不能脱离完整候选环境。 | Android NDK common problems 汇总这些常见故障类型。 | 故障清单只帮助分类,不证明目标 ABI 的实际线程栈已经通过。 |
| Native 调试必须匹配正确架构、构建产物与符号。 | Debug Android native code 说明调试 Native 代码所需的构建与符号条件。 | 能够调试不等于根因已确认,也不能公开形成攻击复现链。 |
| 崩溃地址还原需要同一构建的未剥离符号目录。 | ndk-stack 说明使用符号目录和崩溃地址还原 Native 调用栈。 | 符号化成功只恢复位置,仍需信号、预算事件和路径证据判断栈溢出。 |
| 进程退出信息必须与候选版本、时间窗和用户路径关联。 | Android ApplicationExitInfo 提供退出原因及相应平台版本的诊断数据入口。 | 旧系统和厂商环境可用信息不同,退出记录也不能自动指向具体递归路径。 |
| 深 Native 帧与 ANR 根因不能画等号。 | Diagnose Android ANRs 按主线程、锁、Binder、I/O 和组件超时分层诊断。 | trace 表象位置可能是等待结果,不一定是最初阻塞来源。 |
| 目标应用中的线程与 JNI 行为应在 Android 设备环境回归。 | Android instrumented tests 说明设备端测试可访问应用上下文和平台 API。 | 单一设备或调试构建不能代表完整 API、ABI、厂商和发布候选。 |
| 超过项目深度或低于项目余量的事件应阻止候选通过。 | 工程判断:发布门禁必须在观察到资源预算越界时失败关闭。 | 具体阈值需要项目同候选测量,公开文章不提供通用安全数字。 |
| 当前没有足够证据声称任何目标 App 的 pthread 栈回归已经通过。 | 项目证据尚未接入;需要候选 SO、符号包、设备矩阵、预算事件和退出回执。 | 文章只提供方法、字段和判定边界,不替代实际执行结论。 |
工程常见问题
pthread 默认栈大小能不能直接作为项目安全预算?
不能。默认值受平台和创建路径影响,业务可用余量还要扣除启动封装、真实调用链、异常处理和诊断帧,应在同一候选与目标设备测量。
为什么空递归函数跑得很深仍不能证明业务安全?
空函数的帧远小于真实解析、JNI、日志和错误处理路径。回归用例必须保留目标函数边界和局部状态,否则只证明测试函数自己。
看到 tombstone 中有重复帧就能判定栈溢出吗?
只能作为线索。还需匹配同构建符号、信号、线程、输入深度、剩余预算和故障地址,严重损坏的栈还可能产生截断或不可靠回溯。
增大 pthread 栈是不是最简单的修复?
它可能推迟失败,却增加每线程地址空间成本并掩盖无界递归。应先确认输入上限、调用链变化和错误路径,再决定消除递归或调整自建线程配置。
ANR trace 停在 Native 递归函数是否说明栈快满了?
不一定。线程可能在计算、锁等待、Binder 或 I/O 中停留,ANR 需要结合主线程时间线和等待关系,栈预算事件只能提供深度与余量。
SO 加固后为什么必须重跑栈深度矩阵?
加固、重链接、内联和异常处理会改变最终机器码与帧布局。候选哈希变化后,旧余量和崩溃回执不能证明新 SO 的实际行为。