可靠的堆栈跟踪
本文档概述了关于可靠的堆栈跟踪的基本信息。
1. 介绍
内核热补丁一致性模型依赖于准确识别可能具有活动状态的函数,因此可能不安全进行补丁。识别哪些函数是活动的一种方法是使用堆栈跟踪。
现有的堆栈跟踪代码可能并不总是准确反映所有具有活动状态的函数,而对于调试有帮助的尽力而为的方法对于热补丁是不可靠的。热补丁依赖于体系结构提供可靠的堆栈跟踪,以确保它从跟踪中永远不会省略任何活动函数。
2. 要求
体系结构必须实现可靠的堆栈跟踪函数之一。使用 CONFIG_ARCH_STACKWALK 的体系结构必须实现 'arch_stack_walk_reliable',其他体系结构必须实现 'save_stack_trace_tsk_reliable'。
主要地,可靠的堆栈跟踪函数必须确保:
- 跟踪包括任务可能返回到的所有函数,并且返回代码为零以指示跟踪是可靠的。
- 返回代码为非零以指示跟踪不可靠。
注意: 在某些情况下,省略特定函数是合法的,但必须报告所有其他函数。这些情况在下面进一步详细描述。
其次,可靠的堆栈跟踪函数必须能够应对堆栈或其他展开状态损坏或不可靠的情况。该函数应尝试检测这种情况并返回非零错误代码,并且不应陷入无限循环或以不安全的方式访问内存。具体情况在下面进一步详细描述。
3. 编译时分析
为了确保内核代码在所有情况下都能正确展开,体系结构可能需要验证代码是否按照展开器所期望的方式进行了编译。例如,展开器可能期望函数以有限的方式操作堆栈指针,或者所有函数使用特定的序言和结语序列。具有这些要求的体系结构应使用 objtool 验证内核编译。
在某些情况下,展开器可能需要元数据才能正确展开。必要时,应在构建时使用 objtool 生成这些元数据。
4. 考虑因素
展开过程在各种体系结构、它们各自的过程调用标准和内核配置中有所不同。本节描述了体系结构应考虑的常见细节。
4.1. 识别成功终止
展开可能因多种原因提前终止,包括:
- 堆栈或帧指针损坏。
- 对于不常见情况缺少展开支持,或者展开器中的错误。
为确保这不会导致函数被从跟踪中省略,即使未被其他检查捕获,强烈建议体系结构验证堆栈跟踪是否在预期位置结束,例如:
- 在作为内核入口点的特定函数内。
- 在作为内核入口点的预期堆栈上的特定位置。
- 在作为内核入口点的预期堆栈上的特定堆栈上(例如,如果体系结构具有单独的任务和中断请求(IRQ)堆栈)。
4.2. 识别可展开的代码
展开通常依赖于遵循特定约定的代码(例如,操作帧指针),但可能存在不遵循这些约定的代码,可能需要展开器进行特殊处理,例如:
- 异常向量和入口汇编。
- 过程链接表(PLT)条目和装饰函数。
- 跳板汇编(例如,ftrace,kprobes)。
- 动态生成的代码(例如,eBPF,optprobe 跳板)。
- 外部代码(例如,EFI 运行时服务)。
为确保这些情况不会导致函数被从跟踪中省略,强烈建议体系结构积极识别已知可靠展开的代码,并拒绝从所有其他代码进行展开。
内核代码包括模块和 eBPF 可以使用 '__kernel_text_address()' 区分外部代码。检查这一点还有助于检测堆栈损坏。
体系结构可以通过几种方式识别被视为不可靠展开的内核代码,例如:
- 将此类代码放入特殊的链接器部分,并拒绝从这些部分中的任何代码进行展开。
- 使用边界信息识别代码的特定部分。
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)
当被跟踪的函数运行时,其返回地址指向返回跳板的开始,并且原始返回地址存储在任务的 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. 返回地址的遮蔽
一些跳板不会重写返回地址以拦截返回,但会瞬时破坏返回地址或其他展开状态。
例如,optprobes 的 x86_64 实现会使用 JMP 指令对被探测的函数进行修补,该指令将目标定向到相关的 optprobe 跳板。当触发探测时,CPU 将分支到 optprobe 跳板,并且被探测函数的地址不会保存在任何寄存器或堆栈中。
类似地,DYNAMIC_FTRACE_WITH_REGS 的 arm64 实现会对被跟踪的函数进行以下修补:
MOV X9, X30 BL <trampoline>
MOV 将链接寄存器(X30)保存到 X9 中以保存返回地址,然后 BL 会破坏链接寄存器并跳转到跳板。在跳板的开始处,被跟踪函数的地址在 X9 中,而不是通常情况下的链接寄存器中。
体系结构必须确保展开器能够可靠展开这些情况,或者将展开报告为不可靠。
4.6. 链接寄存器的不可靠性
在一些其他体系结构上,'call' 指令将返回地址放入链接寄存器,并且 'return' 指令从链接寄存器中获取返回地址而不修改寄存器。在这些体系结构上,软件必须在进行函数调用之前将返回地址保存到堆栈中。在函数调用的持续时间内,返回地址可能仅保存在链接寄存器中、仅保存在堆栈中,或者同时保存在这两个位置。
展开器通常假定链接寄存器始终是活动的,但这一假设可能导致不可靠的堆栈跟踪。例如,考虑以下简单函数的 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 生成的元数据)。
以上是关于可靠的堆栈跟踪的基本信息和要求,如果需要更多信息或其他帮助,请随时告诉我。