Troubleshooting

来自osdev
Zhang3讨论 | 贡献2021年12月29日 (三) 05:36的版本 (创建页面,内容为“{{Tone}} == 提供基本的调试环境 == === 异常处理程序 === 第一件事是实现一个可靠的 “异常处理程序”,它会告诉你哪里出了问题。在像 Bochs 这样的仿真器下,如果仿真器被配置为这样做,则缺少这样的处理程序将导致 “没有解决的第三个异常” 紧急消息 (也就是 Triple Fault)。 在裸硬件上,它会简单地用短促的 “bip” 重置你的计算机。 每次CPU…”)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)
跳到导航 跳到搜索

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

提供基本的调试环境

异常处理程序

第一件事是实现一个可靠的 “异常处理程序”,它会告诉你哪里出了问题。在像 Bochs 这样的仿真器下,如果仿真器被配置为这样做,则缺少这样的处理程序将导致 “没有解决的第三个异常” 紧急消息 (也就是 Triple Fault)。 在裸硬件上,它会简单地用短促的 “bip” 重置你的计算机。

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

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

exc_0d_handler:
    push gs
    mov gs,ZEROBASED_DATA_SELECTOR
    mov word [gs:0xb8000],'D '
    ;; D in the top-left corner means we're handling
    ;;  a GPF exception right 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-'
    ;; the 'D' moved one character to the right, letting
    ;; us know that the exception has been handled properly
    ;; and that normal operations continues.
    pop gs
    iret

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

字符不能显示怎么办

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

  • 您可以使用 键盘 LED报告某些事件 (例如,当您是处理程序时启用 “滚动锁定” LED,而在外出时将其禁用)。
  • 您可以使用内部 PC扬声器 发出类似莫尔斯电码的信号,报告早期错误。(不过,如果编码较晚,可能会可耻。)
  • 您可以使用 VGA寄存器 更改背景颜色或过扫描 (屏幕边框) 颜色以报告内核的当前 “状态”。(例如,黑色 = 正常操作,黄色 = 处理中断,红色 = 发生崩溃情况 (就在cli:hlt之前),蓝色 = 处理异常等...)

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

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

避免异常循环

所以我们知道什么时候发生异常,什么时候发生异常。 那更好,但仍然不是特别有用。 您的异常处理程序很可能会变得复杂,因为您的内核将发展,并且您会发现异常主要发生...在异常处理程序中。

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

int nestexc = 0;

// called by the stub
void gpfExcHandler(void) {
   if (nestexc > MAX_NESTED_EXCEPTIONS) panic();
   nestexc++;

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

你需要知道,当然,有些例外不是 “可恢复的”。 如果您的内核发出了一个零除法,则尝试返回 “div” 指令只会再触发一次异常 (是的!现在,现在 :)。 这样的循环不能通过 “嵌套” 计数器解决

显示堆栈内容

程序的大部分状态 (函数参数,返回地址,局部变量) 都存储在 堆栈 上,尤其是在使用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.

and use void dump_hex(char *stack).

定位有故障的指令

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

完成此操作后,您 (作为人类) 可以检查 “链接器地图”,并找出问题出在哪个对象文件中。 You can request a map with ld <usual options> -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中的某个位置, and most likely in kinit() function (it may still be in some other function if there are some static functions in the source file). All we know is that the error occurred at offset +21f in the file.

Now, we can use objdump -drS bin/init.o to get a look at the disassembled output. 请注意,只有当您在那些分离的信息中启用了调试信息时,此步骤才能正常工作 .o files...

#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

Of course, as I picked up a random address, there's nothing wrong to see at +21f, but I guess you got my point. :)

查找源代码的违规行

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

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

增强的调试技术

堆栈跟踪

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

每次调用函数时,它都会得到以下头/尾: (GCC 3.3.2)

push ebp
mov ebp, esp
...
leave
ret

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

0000FFC0 0000FFD0 -> this is the result of a push EBP (which pushes the esp at the start of the previous function)
0000FFC4 001023A5 -> this was the old EIP, which can be looked up in the function table (map file)
0000FFC8 01234567 -> this is an argument
0000FFCC 89ABCDEF -> this is another argument
0000FFD0 0000FFF0 -> this is again another EBP
0000FFD4 00105BC3 -> this is again an EIP
0000FFD8 001023A5 -> this is an argument (could be a function pointer)
0000FFDC 01234567 -> this is another argument
0000FFE0 89ABCDEF -> this is again an argument
0000FFE4 000B8000 -> this is an argument, but not to this function
0000FFE8 FFC00000 -> this is again an argument to a different function
0000FFEC 00010000 -> this is again another argument, but again not to this function.
0000FFF0 0000FFFC -> this is the previous EBP again (note this is in this case the top)
0000FFF4 0010002C -> this is an old EIP
0000FFF8 00000001 -> this is an argument
0000FFFC 00000000 -> this is the EBP at the start of the first function, not necessarily valid!

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

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

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

调试技术

If your function x() wreaks havoc only after 1000 calls it may not suffice to put a panic() statement inside the functions to see where the functions breaks. 你可能想知道哪个调用是恶性的。 要做到这一点,可以使用全局或静态var来计算调用和panic() 之后的数额,看看它是否设法崩溃。 如果没有,则尝试两次该金额; 如果确实崩溃,则尝试平分以找到金额。

void scheduler_choose_task() {
    static uint32_t Z=0;
    Z++;
    uint32_t N = 1000;
    if (Z > N) panic();  //find the largest integer N for which it crashes not
    if (in_critical_section()) return;
    ...
}

...and then check how far does it go:

Z++;
uint32_t N = 1000;
                                       //we get here,
if (in_critical_section()) return;
if (Z > N) panic();                    //do we get here to panic() before a crash?

However as complexity rises or multithreading is involved, it is less probable that a crash would be consistently occurring at the same point, after the same amount of calls every time. Then it would not be possible to find the number of the call to scheduler_choose_talk() that crashes it (because that number changes). Debugging needs some imagination; what if you knew, by tracing the program flow with print(__LINE__) that scheduler_choose_task() crashes only when a call to fun1() is in progress? You might use a global var uint32_t dbg or an array (uint32_t dbg[20]) of various dbg vars (which are used only in debugging code which is cleaned after the programmer ceases to debug) in a manner such as:

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

...and:

void scheduler_choose_task() {
    //  if (dbg[3]==1) panic();         //check here.. a panic saves the day from crashing!
    if (in_critical_section()) return;
    if (dbg[3]==1) panic();             //check here.. it crashes
}

(Or mix it with a call count, Z++; if (Z>5 && dbg[3] == 1) panic().)

Using the __LINE__ aids tracing the program flow:

print(__LINE__);

See Uses for debugging for more info.


外部援助

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

尽管如此,人们仍然可以想象一个内核将配备串行通信代码并连接到另一台计算机,该计算机将具有所有 “删除” 信息 (like the symbols map, or the debugging-info featured intermediate binaries).

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

调试界面

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

其他人是否知道允许黑客在发生崩溃时以交互方式探测系统状态的操作系统?

Yes. Guess. Correct: AmigaOS. ;-) 它提供了一种模式,即使在系统进入Guru Meditation之后,也可以通过串行线进行调试。 That was possible because AmigaOS enjoyed a 256 / 512 kByte ROM image that could not get corrupted. - MartinBaute

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

另见

文章

de:Debugging