可靠的堆栈回溯¶
本文档概述了有关可靠堆栈回溯的基本信息。
1. 引言¶
内核热补丁一致性模型依赖于准确识别哪些函数可能具有活动状态,因此可能不适合修补。识别哪些函数处于活动状态的一种方法是使用堆栈回溯。
现有的堆栈回溯代码可能并不总是能准确地反映所有具有活动状态的函数,并且对调试有帮助的最佳努力方法对于热补丁是不健全的。热补丁依赖于架构来提供可靠的堆栈回溯,以确保它永远不会从跟踪中遗漏任何活动函数。
2. 要求¶
架构必须实现可靠的堆栈回溯函数之一。使用 CONFIG_ARCH_STACKWALK 的架构必须实现 'arch_stack_walk_reliable',其他架构必须实现 'save_stack_trace_tsk_reliable'。
原则上,可靠的堆栈回溯函数必须确保以下之一:
跟踪包括任务可能返回的所有函数,并且返回代码为零,表示跟踪是可靠的。
返回代码为非零,表示跟踪不可靠。
注意
在某些情况下,可以合法地从跟踪中省略特定函数,但必须报告所有其他函数。这些情况将在下面进一步详细描述。
其次,可靠的堆栈回溯函数必须能够应对堆栈或其他展开状态损坏或不可靠的情况。该函数应尝试检测此类情况并返回非零错误代码,并且不应陷入无限循环或以不安全的方式访问内存。具体情况将在下面进一步详细描述。
3. 编译时分析¶
为了确保在所有情况下都可以正确展开内核代码,架构可能需要验证代码的编译方式是否符合展开器的预期。例如,展开器可能期望函数以有限的方式操作堆栈指针,或者所有函数都使用特定的序言和尾声序列。具有此类要求的架构应使用 objtool 验证内核编译。
在某些情况下,展开器可能需要元数据才能正确展开。必要时,应使用 objtool 在构建时生成此元数据。
4. 注意事项¶
展开过程因架构、其各自的过程调用标准和内核配置而异。本节介绍架构应考虑的常见细节。
4.1 识别成功终止¶
展开可能会因多种原因而提前终止,包括:
堆栈或帧指针损坏。
缺少对不常见场景的展开支持,或者展开器中存在错误。
动态生成的代码(例如 eBPF)或外部代码(例如 EFI 运行时服务)不遵循展开器预期的约定。
为了确保这不会导致函数从跟踪中省略,即使没有被其他检查捕获,强烈建议架构验证堆栈回溯在预期的位置结束,例如:
在作为内核入口点的特定函数内。
在内核入口点预期的堆栈上的特定位置。
在内核入口点预期的特定堆栈上(例如,如果架构具有单独的任务堆栈和 IRQ 堆栈)。
4.2 识别可展开的代码¶
展开通常依赖于代码遵循特定的约定(例如,操作帧指针),但是可能存在不遵循这些约定并且可能需要在展开器中进行特殊处理的代码,例如:
异常向量和入口汇编。
过程链接表 (PLT) 条目和辅助函数。
蹦床汇编(例如 ftrace、kprobes)。
动态生成的代码(例如 eBPF、optprobe 蹦床)。
外部代码(例如 EFI 运行时服务)。
为了确保这种情况不会导致函数从跟踪中省略,强烈建议架构积极识别已知可以可靠展开的代码,并拒绝从所有其他代码展开。
可以使用 '__kernel_text_address()' 将包括模块和 eBPF 在内的内核代码与外部代码区分开来。检查此项也有助于检测堆栈损坏。
架构可以通过多种方式识别被认为不可靠展开的内核代码,例如:
将此类代码放入特殊的链接器段中,并拒绝从这些段中的任何代码展开。
使用边界信息识别代码的特定部分。
4.3 跨中断和异常展开¶
在函数调用边界,堆栈和其他展开状态预计处于适合可靠展开的一致状态,但这可能不是函数中途的情况。例如,在函数序言或尾声期间,帧指针可能暂时无效,或者在函数体期间,返回地址可能保存在任意通用寄存器中。对于某些架构,这可能会因动态检测而在运行时发生变化。
如果在堆栈或其他展开状态处于不一致状态时发生中断或其他异常,则可能无法可靠地展开,并且可能无法确定此类展开是否可靠。有关示例,请参见下文。
无法识别何时可靠展开此类情况(或永远不可靠)的架构必须拒绝跨异常边界展开。请注意,跨某些异常(例如 IRQ)展开可能是可靠的,但跨其他异常(例如 NMI)展开可能不可靠。
可以识别何时可靠展开此类情况(或没有此类情况)的架构应尝试跨异常边界展开,因为这样做可以防止不必要地阻止热补丁一致性检查,并允许热补丁转换更快地完成。
4.4 重写返回地址¶
一些蹦床会临时修改函数的返回地址,以便在函数通过返回蹦床返回时进行拦截,例如:
ftrace 蹦床可能会修改返回地址,以便函数图跟踪可以拦截返回。
kprobes(或 optprobes)蹦床可能会修改返回地址,以便 kretprobes 可以拦截返回。
发生这种情况时,原始返回地址将不在其通常的位置。对于不受热补丁影响的蹦床,如果展开器可以可靠地确定原始返回地址并且展开状态没有被蹦床更改,则展开器可以报告原始返回地址而不是蹦床,并将其报告为可靠。否则,展开器必须将这些情况报告为不可靠。
在识别原始返回地址时需要特别注意,因为此信息在入口蹦床或返回蹦床的持续时间内不在一致的位置。例如,考虑 x86_64 'return_to_handler' 返回蹦床
SYM_CODE_START(return_to_handler)
UNWIND_HINT_UNDEFINED
subq $24, %rsp
/* Save the return values */
movq %rax, (%rsp)
movq %rdx, 8(%rsp)
movq %rbp, %rdi
call ftrace_return_to_handler
movq %rax, %rdi
movq 8(%rsp), %rdx
movq (%rsp), %rax
addq $24, %rsp
JMP_NOSPEC rdi
SYM_CODE_END(return_to_handler)
当被跟踪的函数运行时,其在堆栈上的返回地址指向 return_to_handler 的开头,并且原始返回地址存储在任务的 cur_ret_stack 中。在此期间,展开器可以使用 ftrace_graph_ret_addr() 找到返回地址。
当被跟踪的函数返回到 return_to_handler 时,堆栈上不再有返回地址,尽管原始返回地址仍存储在任务的 cur_ret_stack 中。在 ftrace_return_to_handler() 中,原始返回地址从 cur_ret_stack 中删除,并在通过编译器返回到 rax 之前临时任意移动。return_to_handler 蹦床将其移动到 rdi,然后跳转到它。
架构可能并不总是能够展开此类序列,例如,当 ftrace_return_to_handler() 已从 cur_ret_stack 中删除地址时,并且无法可靠地确定返回地址的位置。
建议架构在 `return_to_handler` 尚未返回的情况下进行回溯,但架构不需要从 `return_to_handler` 的中间进行回溯,并且可以将此报告为不可靠。架构不需要从其他修改返回地址的跳转例程中回溯。
4.5 返回地址的模糊处理¶
一些跳转例程不会重写返回地址以拦截返回,但会临时破坏返回地址或其他回溯状态。
例如,x86_64 上的 optprobes 实现会使用 JMP 指令修补被探测的函数,该指令指向关联的 optprobe 跳转例程。当命中探针时,CPU 将跳转到 optprobe 跳转例程,并且被探测函数的地址不会保存在任何寄存器或堆栈上。
类似地,arm64 上 DYNAMIC_FTRACE_WITH_REGS 的实现会使用以下指令修补跟踪的函数:
MOV X9, X30
BL <trampoline>
MOV 指令将链接寄存器 (X30) 保存到 X9 中,以保留返回地址,然后再由 BL 指令破坏链接寄存器并跳转到跳转例程。在跳转例程开始时,被跟踪函数的地址位于 X9 中,而不是像通常情况下位于链接寄存器中。
架构必须确保回溯程序能够可靠地回溯此类情况,或者将回溯报告为不可靠。
4.6 链接寄存器的不可靠性¶
在其他一些架构上,“调用”指令将返回地址放入链接寄存器中,而“返回”指令从链接寄存器中消耗返回地址,而无需修改该寄存器。在这些架构上,软件必须在进行函数调用之前将返回地址保存到堆栈中。在函数调用的过程中,返回地址可能仅保存在链接寄存器中、仅保存在堆栈中或同时保存在这两个位置。
回溯程序通常假设链接寄存器始终处于活动状态,但这种假设可能导致不可靠的堆栈跟踪。例如,考虑以下用于简单函数的 arm64 汇编代码:
function:
STP X29, X30, [SP, -16]!
MOV X29, SP
BL <other_function>
LDP X29, X30, [SP], #16
RET
在函数入口处,链接寄存器 (x30) 指向调用者,帧指针 (X29) 指向调用者的帧,包括调用者的返回地址。前两条指令创建一个新的堆栈帧并更新帧指针,此时链接寄存器和帧指针都描述了该函数的返回地址。此时的跟踪可能会两次描述该函数,并且如果正在跟踪函数返回,则回溯程序可能会从 fgraph 返回堆栈中消耗两个条目,而不是一个条目。
BL 调用 'other_function',链接寄存器指向该函数的 LDR,帧指针指向该函数的堆栈帧。当 'other_function' 返回时,链接寄存器仍然指向 BL,因此此时的跟踪可能导致 'function' 在回溯中出现两次。
类似地,一个函数可能会故意破坏 LR,例如:
caller:
STP X29, X30, [SP, -16]!
MOV X29, SP
ADR LR, <callee>
BLR LR
LDP X29, X30, [SP], #16
RET
ADR 将 'callee' 的地址放入 LR 中,然后在 BLR 跳转到该地址之前。如果紧接在 ADR 之后进行跟踪,则 'callee' 将被视为 'caller' 的父级,而不是子级。
由于上述情况,可能只有在函数调用边界处才能可靠地消耗链接寄存器值。如果出现这种情况,架构必须拒绝跨异常边界回溯,除非它们可以可靠地识别何时应使用 LR 或堆栈值(例如,使用 objtool 生成的元数据)。