6. 内核堆栈

6.1. x86-64 位上的内核堆栈

大部分文本来自 Keith Owens,由 AK 修改

x86_64 页大小 (PAGE_SIZE) 为 4K。

像所有其他架构一样,x86_64 为每个活动线程都有一个内核堆栈。 这些线程堆栈的大小为 THREAD_SIZE (4*PAGE_SIZE)。 只要线程处于活动状态或僵尸状态,这些堆栈就包含有用的数据。 当线程在用户空间中时,内核堆栈是空的,除了底部的 thread_info 结构。

除了每个线程的堆栈之外,还有与每个 CPU 关联的专用堆栈。 这些堆栈仅在内核控制该 CPU 时使用;当 CPU 返回到用户空间时,专用堆栈不包含任何有用的数据。 主要的 CPU 堆栈是

  • 中断堆栈。 IRQ_STACK_SIZE

    用于外部硬件中断。 如果这是第一个外部硬件中断(即,不是嵌套的硬件中断),则内核从当前任务切换到中断堆栈。 就像 i386 上的分离线程和中断堆栈一样,这为内核中断处理提供了更多空间,而无需增加每个线程堆栈的大小。

    中断堆栈也用于处理软中断。

基于每个 CPU 中断嵌套计数器,由软件完成切换到内核中断堆栈。 这是必要的,因为 x86-64 “IST” 硬件堆栈不能在没有竞争的情况下嵌套。

x86_64 还具有 i386 上不可用的功能,即为指定事件(如双重错误或 NMI)自动切换到新堆栈的能力,这使得在 x86_64 上更容易处理这些不寻常的事件。 此功能称为中断堆栈表 (IST)。 每个 CPU 最多可以有 7 个 IST 条目。 IST 代码是任务状态段 (TSS) 的索引。 TSS 中的 IST 条目指向专用堆栈;每个堆栈的大小可能不同。

IST 由中断门描述符的 IST 字段中的非零值选择。 当发生中断并且硬件加载这样的描述符时,硬件会自动基于 IST 值设置新的堆栈指针,然后调用中断处理程序。 如果中断来自用户模式,则中断处理程序序言将切换回每个线程的堆栈。 如果软件希望允许嵌套的 IST 中断,则处理程序必须在进入和退出中断处理程序时调整 IST 值。(这偶尔会完成,例如,用于调试异常。)

具有不同 IST 代码(即具有不同堆栈)的事件可以嵌套。 例如,调试中断可以安全地被 NMI 中断。 arch/x86_64/kernel/entry.S::paranoidentry 调整进入和退出所有 IST 事件时的堆栈指针,理论上允许嵌套具有相同代码的 IST 事件。 但是,在大多数情况下,分配给 IST 的堆栈大小假设同一代码没有嵌套。 如果该假设被打破,则堆栈将变得损坏。

当前分配的 IST 堆栈是

  • ESTACK_DF。 EXCEPTION_STKSZ (PAGE_SIZE)。

    用于中断 8 - 双重错误异常 (#DF)。

    当处理一个异常导致另一个异常时调用。 当内核非常混乱时发生(例如,内核堆栈指针损坏)。 使用单独的堆栈允许内核在许多情况下足以从中恢复,仍然可以输出 oops。

  • ESTACK_NMI。 EXCEPTION_STKSZ (PAGE_SIZE)。

    用于不可屏蔽中断 (NMI)。

    NMI 可以随时传递,包括当内核正在切换堆栈时。 对 NMI 事件使用 IST 可以避免对内核堆栈的先前状态做出假设。

  • ESTACK_DB。 EXCEPTION_STKSZ (PAGE_SIZE)。

    用于硬件调试中断(中断 1)和软件调试中断 (INT3)。

    在调试内核时,调试中断(包括硬件和软件)可以随时发生。 对这些中断使用 IST 可以避免对内核堆栈的先前状态做出假设。

    为了正确处理嵌套的 #DB,存在两个 DB 堆栈实例。 在 #DB 进入时,#DB 的 IST 堆栈指针切换到第二个实例,因此嵌套的 #DB 从一个干净的堆栈开始。 嵌套的 #DB 将 IST 堆栈指针切换到一个保护孔,以捕获三重嵌套。

  • ESTACK_MCE。 EXCEPTION_STKSZ (PAGE_SIZE)。

    用于中断 18 - 机器检查异常 (#MC)。

    MCE 可以随时传递,包括当内核正在切换堆栈时。 对 MCE 事件使用 IST 可以避免对内核堆栈的先前状态做出假设。

有关更多详细信息,请参阅英特尔 IA32 或 AMD AMD64 架构手册。

6.2. 在 x86 上打印回溯

关于 x86 堆栈跟踪中函数名称之前的 ‘?’ 的问题不断出现,这里有一个深入的解释。 如果读者盯着 print_context_stack() 以及 arch/x86/kernel/dumpstack.c 中及其周围的整个机制,这将有所帮助。

改编自 Ingo 的邮件,消息 ID:<20150521101614.GA10889@gmail.com>

我们总是扫描内核堆栈中存储的返回地址的完整内核堆栈[1],从堆栈顶部到堆栈底部,并打印出任何“看起来像”内核文本地址的内容。

如果它适合帧指针链,我们会打印它,而不带问号,因为我们知道它是真实回溯的一部分。

如果地址不适合我们预期的帧指针链,我们仍然会打印它,但我们会打印一个 ‘?’。 这可能意味着两件事

  • 要么该地址不是调用链的一部分:它只是内核堆栈上的陈旧值,来自较早的函数调用。 这是常见的情况。

  • 或者它是调用链的一部分,但帧指针在函数中没有正确设置,所以我们无法识别它。

这样,我们总是会打印出真实的调用链(加上一些额外的条目),无论帧指针是否正确设置 - 但在大多数情况下,我们也会正确获取调用链。 打印的条目严格按照堆栈顺序排列,因此您也可以从中推断出更多信息。

此方法最重要的特性是我们 _永远_ 不会丢失信息:我们总是努力打印堆栈上 _所有_ 看起来像内核文本地址的地址,因此如果调试信息不正确,我们仍然会打印出真实的调用链 - 只是比理想情况有更多的问号。