Troubleshooting

来自osdev
Zhang3讨论 | 贡献2022年3月13日 (日) 14:43的版本
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)
跳到导航 跳到搜索

本文的语气或风格 可能无法反映整个wiki使用的百科全书式语气。 有关建议,请参阅 Wikipedia's article on tone

提供基本的调试环境

异常处理程序

第一件事是实现一个可靠的 “异常处理程序”,它会告诉你哪里出了问题。 在像Bochs这样的仿真器下,如果没有这样的处理程序,将会导致‘3rd Exception without resolution’的死机消息(也称为Triple Failure)。 在真实硬件上,它会短促的 “bip”一声,然后简单地重置你的计算机。

每次CPU无法调用某个异常处理程序时,它都会尝试执行 Double Fault 异常处理程序。 如果它无法调用到异常处理程序,则会发生 Triple Fault。 还要记住,异常不能被掩盖,所以要么你的代码是完美的,要么你需要异常处理程序。 还请记住,当你运行应用程序 它们的 代码没有异常处理程序也必须是完美,所以最好让它们安静的快速运行。

让异常处理程序能显示:在尝试任何 “危险” 之前发生的异常,是非常值得的。 例如,在屏幕的一角显示 (十六进制) 异常号可以节省调试时间。 :)

exc_0d_handler:
    push gs
    mov gs,ZEROBASED_DATA_SELECTOR
    mov word [gs:0xb8000],'D '
    ;; 左上角的D表示我们正在处理
    ;; 一个GPF异常权ATM。

    ;; 你的 “正常” 处理来到这里
    pushad
    push ds
    push es
    mov ax,KERNEL_DATA_SELECTOR
    mov ds,ax
    mov es,ax

    call gpfExcHandler

    pop es
    pop ds
    popad

    mov dword [gs:0xb8000],'  D-'
    ;; “D”将一个字符向右移动,
    ;; 让我们知道异常已得到适当处理
    ;; 正常的运作仍在继续。
    pop gs
    iret

一旦你实现了这样的技术,最好先测试它,故意发出 “故障” 指令,看看是否显示了正确的代码。 在屏幕上的其它位置显示 “double fault” 异常 (08) 也可能是明智之举。

字符不能显示怎么办

例如,当你的GDT或分页表配置不当 (例如0xb8000不再指向视频存储器) 时,就会发生这种情况。 幸运的是,视频内存并不是你与内核的唯一通信技术:

  • 你可以使用 键盘 LED报告某些事件 (例如,当进入异常处理程序时点亮 “Scroll lock” LED,而在跳出异常处理时将其熄灭)。
  • 你可以使用内部 PC Speaker 发出类似莫尔斯电码的信号,报告早期错误。 (不过,如果编码太晚可能不太优雅。)
  • 你可以使用 VGA寄存器 更改背景颜色或overscan (屏幕边框) 颜色以报告内核的当前 “状态”。 (例如,黑色 = 正常操作,黄色 = 处理中断,红色 = 发生崩溃情况 (就在cli:hlt之前),蓝色 = 处理异常等...)

参考 VGA资源,看看你如何修改颜色。参考资源 页面应该包含LED闪烁和扬声器发出蜂鸣的所有文档。

  • 你可以通过 串口 输出字节。 大多数模拟器允许你将这些字符重定向到文件中,与屏幕不同,输出字符数不受限制。 串口的驱动程序也非常容易实现。

避免异常循环

所以我们知道什么时候发生异常,发生了什么异常。 这挺好,但有时候不是特别够用。 你的异常处理程序很可能会变得复杂,因为你的内核将持续发展,并且你会发现异常主要发生...异常处理程序本身中。

为了避免递归异常无休止地发生,你可以简单地维护一个 “嵌套异常计数器”,每次进入异常处理程序时都会递增,并在离开该处理程序之前递减。 如果计数器高于几个单位的某个阈值 (3应该给出足够有帮助的结果),考虑将内核中止尝试解决异常进入 “panic” 模式 (比如,红色背景,闪烁的LED)。

int nestexc = 0;

// 由stub调用
void gpfExcHandler(void) {
   if (nestexc > MAX_NESTED_EXCEPTIONS) panic();
   nestexc++;

   if (!fix_the_error()) {
     write_an_error_message();
   }
   nestexc--;
   return;
}

你需要知道,当然,有些异常不是 “可恢复的”。 如果你的内核发出了一个除零异常,则尝试回到 “div” 指令只会再触发一次异常 。 这样的循环不能通过 “nestexc” 计数器解决

显示堆栈内容

程序的大部分状态 (函数参数,返回地址,局部变量) 都存储在 上,尤其是在使用C/C++代码时。 一个完整的调试器 (如GDB) 将检查调试信息,为堆栈内容命名,提供调用列表等。 这对我们自己来说有点复杂,但是如果你的内核可以简单地 “显示” 堆栈的内容,如果你知道在代码中 “哪里” 这个过程停止了。以前你应该已经通过调试器的工作修复了相当多的错误,猜测哪个堆栈位置包含哪个变量,返回地址在哪里等。

堆栈内容仍在内存中。 错误处理的 EBP 值仍在内存中,并指向当前函数的堆栈帧开头。 从这个地址到现在的所有东西都是当前的堆栈。 现在,你可以使用ebp中的值作为源。 只需使用以下调用:

stack_dump:
  push ebp
  mov ebp, esp
  call dump_hex
  pop ebp
  ret ; note that this is not going to work, but it should be here for completion.

并使用一个void dump_hex(char *stack)来转储栈信息。

定位有故障的指令

在大多数情况下,当调用异常处理程序时,错误指令的地址在堆栈上的某个位置。 这里的第一步是打印出这个指令的地址。

完成此操作后,你 (作为人类) 可以检查 “linker map”,并找出问题出在哪个目标文件中。 你可以请求带有 ld <常用选项> -map <filename.Map> 的映射。

.text           0x0000000000005330      0x556 bin/init.o
                0x0000000000005370                kinit_dsp_buffer
                0x0000000000005380                kinit_glocal_tag
                0x0000000000005400                kinit
                0x0000000000005830                kreset
 *fill*         0x0000000000005886        0xa 00
 .text          0x0000000000005890      0x66d bin/kalloc.o
                0x0000000000005ac0                kfree
                0x0000000000005b70                kmRegister
                0x00000000000059b0                kealloc
                0x0000000000005e60                kmFindP
                0x0000000000005dd0                kmFindA
                0x0000000000005d10                kmSetFull
                0x0000000000005890                kalloc

以上是一个映射的例子。 如果错误地址为0x554f,则可以从中得知错误位于init.o中的某个位置, 并且很可能在kinit()函数中 (如果源文件中有一些 “静态” 函数,则可能仍处于其它函数中)。 我们只知道错误发生在文件的偏移量+21f处。

现在,我们可以使用objdump -drS bin/init.o查看分解后的输出。 请注意,只有当你启用了调试信息时,此步骤才能正常工作,在那些分离的 .o 文件中...

#ifdef __DEBUG__
  kprint("kernel in debug-mode(%x) press [SHIFT+SPACE] to bypass anykey()\n",
 216:   83 c4 f8                add    $0xfffffff8,%esp
 219:   a1 00 00 00 00          mov    0x0,%eax
                        21a: R_386_32   DbMsk
 21e:   50                      push   %eax
 21f:   68 a0 01 00 00          push   $0x1a0
                        220: R_386_32   .rodata
 224:   e8 fc ff ff ff          call   225 <kinit+0x155>
                        225: R_386_PC32 kprint
         DbMsk);
#endif

当然,当我拿到一个随机地址时,在+21F看起来没什么不对的,但我想你明白我的意思了。:)

查找源代码的违规行

在上一步中找到错误指令的地址后,可以通过运行

addr2line -e <your_kernel.elf> <address of faulty instruction>

增强调试技术

堆栈跟踪

通过分析创建堆栈帧的默认方式,你可以一次撕掉一个堆栈帧,从而引向导致故障的调用序列。 对于单个bonus point,还可以提取参数并将其转储。 对于多个bonus point,请使用C++名称mangling,并以正确的类型以可读形式导出参数。

每次调用函数时,它都会得到head/tail: (GCC 3.3.2)

push ebp
mov ebp, esp
...
leave
ret

在... 的地方...代码的其余部分已填写。 现在,如果你分析堆栈输出,它看起来像这样:

0000FFC0 0000FFD0 -> 这是推送EBP的结果 (在上一个函数开始时推送esp)
0000FFC4 001023A5 -> 这是旧的EIP,可以在函数表(映射文件)中查找
0000FFC8 01234567 -> 这是一个参数
0000FFCC 89ABCDEF -> 这是另一个参数
0000FFD0 0000FFF0 -> 这又是另一个EBP
0000FFD4 00105BC3 -> 这又是一个EIP
0000FFD8 001023A5 -> 这是一个参数 (可能是函数指针)
0000FFDC 01234567 -> 这是另一个参数
0000FFE0 89ABCDEF -> 这又是一个参数
0000FFE4 000B8000 -> 这是一个参数,但不是这个函数的
0000FFE8 FFC00000 -> 这也是另一个函数的参数
0000FFEC 00010000 -> 这又是另一个参数,但同样不是这个函数的。
0000FFF0 0000FFFC -> 这再次是以前的EBP (注意这是在这种情况下的顶部)
0000FFF4 0010002C -> 这是一个旧的EIP
0000FFF8 00000001 -> 这是一个参数
0000FFFC 00000000 -> 这是第一个函数开始时的EBP,不需要验证!

现在,你可以沿着执行的路径遍历。 EBP的内容是EBP的旧值,即最后一个堆栈帧之一。 上面的值是旧指令指针 (指向当前函数内部),上面的值 (直到但不包括旧EBP所指向的值) 是参数。 请注意,参数不必属于此函数,GCC偶尔会通过不弹出值来将add保存到esp。 然后假装旧的EBP是当前的EBP,你可以解除另一个call。 这样做,直到你推演的足够,你就有了足够的输出或堆栈结束。 如果是最后一个,请注意不要产生双重故障(double fault)。

如果使用C++名称mangling,则参数将在函数名称中编码。 如果你可以阅读该内容,则可以解码堆栈上的值必须是什么,因此你实际上可以以普通函数调用的形式将其呈现给用户,并具有清晰的参数和所有内容。 这是堆栈转储方法的 “crème de la crème”(译者注:来自法语的成语-极品精粹),因此大多数人都不会这样做。

当我用C编程我的内核时,我实际上想到了编写一个脚本,该脚本将解析头文件的函数声明,使用 objdump 从编译的内核映像中提取调试符号,并编写一个系统映射将提供类型。 可是在爱上了 Bochs 的调试器后就忘记了。 类似地,可以创建结构化类型的类型映射(typemaps),这将允许GDB或 Visual Studio提供给你相同的数据浏览。 这才是 crème de la crème。

调试技术

如果函数x()仅在1000次调用后才造成严重破坏,那么在函数中放入panic()语句来查看函数在哪里中断可能是不够的。 你可能想知道哪个调用是恶性的。 要做到这一点,可以使用全局或静态变量来计算多少次调用后panic(),看看它是否处于崩溃状态。 如果没有,则尝试双倍数量,如果这时崩溃发声,则尝试折半以找到数量范围。

void scheduler_choose_task() {
    static uint32_t Z=0;
    Z++;
    uint32_t N = 1000;
    if (Z > N) panic();  // 查找不崩溃的最大整数N
    if (in_critical_section()) return;
    ...
}

...然后检查它运行了多少:

Z++;
uint32_t N = 1000;
                                       // 代码在这里,
if (in_critical_section()) return;
if (Z > N) panic();                    // 在崩溃前这里运行到panic()了吗?

但是,随着复杂性的增加或涉及到多线程,在每次调用相同数量的情况下,在相同的点上持续发生崩溃的可能性较小。 然后,将无法找到使其崩溃的 scheduler_choose_talk() 调用的次数 (因为该次数发生了变化)。 调试需要一定的想象力; 如果你知道,通过使用 print(__LINE__) 跟踪程序流,scheduler_choose_task() 仅在执行 fun1() 时崩溃? 你可以使用全局变量 uint32_t dbg 或各种 dbg 变量的数组 (uint32_t dbg[20]) (仅在调试代码中使用,该变量在程序员停止调试后被清除),例如:

void fun1() {
    dbg[3] = 1;
    ...
    if (x()) { dbg[3]=0; return; }
    ...
    dbg[3] = 0;
}

...以及:

void scheduler_choose_task() {
    //  if (dbg[3]==1) panic();         // 此处检查。使用panic函数,使其免于崩溃!
    if (in_critical_section()) return;
    if (dbg[3]==1) panic();             // 检查这里 ..它崩溃了
}

(或者将其与调用计数混合使用,Z++; if (Z>5 && dbg[3] == 1) panic()。)

使用__LINE__帮助跟踪程序流程:

print(__LINE__);

有关详细信息,请参阅用于调试的C语言预定义


外部援助

到目前为止,我们假设内核包含调试所需的所有信息 (如符号名称等)。 但是,在生产阶段,通常会从最终的二进制目标中删除此信息以及大多数 “调试打印”功能。

尽管如此,人们仍然可以考虑给内核配备串行通信代码并连接到另一台计算机,该计算机将具有所有 “被移除的” 信息 (比如符号映射,或者以中间二进制为特征的调试信息)。

如果发生紧急情况,内核可以例如通过串行发送 eip 的值,并期望辅助PC使用与该地址相对应的函数名称和行号进行回复 (或将其转储在辅助PC的屏幕上,看你的选择问题 :))。

调试界面

(译者注:这里原文中论坛摘录了3个问题,并在后面一起做了回答。)

现在我们有很多关于哪里出了问题的信息...我们能要求更多吗? Mobius的调试Shell和Clicker的信息面板告诉我们什么...

有人知道有操作系统允许高手在发生崩溃时以交互方式探测系统状态吗?

是的。猜猜。有: AmigaOS。;-) 它提供了一种模式,即使在系统进入Guru Meditation之后,也可以通过串行线进行调试。 这是可能的,因为AmigaOS拥有256/512kbyte的ROM映像,不会被破坏。 - MartinBaute

传统上,Unix系统将其状态写入/dev/core以进行离线guru meditation

另见

文章

de:Debugging