Archive for 2010年8月

接受调试器

2010/08/18

⋯⋯ 太多时候,用 printf 或者 log 就能发现问题。一些人于是发誓只用这种方法,而且如人所愿,只要时间充裕,力气下足,其实不用 debugger 也能干活。

—— Kernel Programming Guide

十年前刚毕业的时候,在公司里被分配了第一个像样的任务:用 Java 写一个 GUI editor 。准备开发环境的时候遇到一个问题: 对当时的我来说,和 Visual C++ 之类的工具相比,JDK 的 debugger 太难配置了。另一方面,System.out.println 倒是唾手可得的『利器』。最终我完全用后者调试完成了那个 5000 多行的 GUI editor 。之后的七年里我几乎再没有碰过任何 debugger 。对它的全部认识就是设置断点,运行程序 hit 断点之后单步(step in/over)。每次搭建开发环境的时候似乎都会因为因为这样那样的原因而放弃使用 debugger ,printfprintln 乃至 log 文件是不二的应对法宝。

直到三年前重新回到 Visual Studio 和 Xcode 的 IDE 环境之后,抱着反正不用费力气配置直接可以用的态度重新拾起 debugger 。下面是大致按照时间顺序的发现:

第一条:call stack

最初七年里无数次从 Java exception 的 stack trace 里获得解决问题的线索,也在研究 Linux 代码的过程中意识到能随时了解 call stack 的好处,可是我从把 call stack 和 debugger 联系上。重拾 debugger 之后的第一个新认识是它的作用不光是单步,更重要的是可以揭示 call stack 。如果没有 debugger 和 call stack ,阅读已有的 code base 的难度会提高一个数量级。

第二条:死锁

多线程问题和性能问题被我认为是 debugger 一无用处而非 log 不可解决的难题。直到两年前对着一个debug 状态下僵死的程序做了一个 pause 操作,debugger 显示的 call stack 稳稳当当的停在了死锁的位置。

第三条:UI 操作

UI 问题被认为是最容易应用 debugger 的,因为一般来说触发断点最直接,而且线程也不多。但是遇到 debugger 会干扰程序本身的 message queue 的时候就束手无策了。为了尽量避免手工加入 logging 代码,也找了各种土办法减少 debugger 对 message queue 的干扰。

比如,如果在窗口绘制代码中设置断点,就会让 debugger 在绘制窗口的过程中夺取焦点,continue 之后又会把焦点返回原来的被调试程序的窗口,在 Windows XP 上就会又触发窗口绘制。而 OS X 和 Windows Vista 的 buffered window 能让窗口绘制的代码不会像 Windows XP 里的窗口那样每次在 debugger 把焦点交还的时候都触发窗口绘制。尽量利用不同系统的消息处理差异来找到最合适解决问题的环境。

但是这些方法都要靠系统实现的差别和被调试程序逻辑的机缘巧合,并非每次都能成功使用。无法找到一定之规,有的情况也根本没有办法。比如,有些控件会处理失去焦点的事件,用如果在处理失去焦点的代码里加入断点,调试起来就很难分别哪些事件是正常情况下会发出的,哪些是受 debugger 的干扰发出的。

困惑了两年多之后终于发现处理 UI 调试的方法其实简单得令人发指。只要能找到纯命令行的 debugger(比如 gdb,Flash 的 fdb),用 ssh 或者 telnet 从另一台机器登录到被调试程序所在的机器,用命令行 debugger 调试就行。这时,任何断点的中断都是通过后台协议传递给 ssh/telnet client 上的终端而不会干扰被调试机器的 message queue 。回顾过去的十年,很惊讶被这个问题困扰了那么长时间的不光是我,还有我询问过的几乎所有同事,几乎每个人处理这种问题都是依靠 log(不得不说有些人,比如志岩还是提出了 remote debugging 的思路,但是很可惜只是一个灵感而不是整套解决方案)。

第四条:条件断点

如果一个断点的第一次、第二次、⋯⋯ 第 n 次被 hit 的时候你都并不想单步或者检查状态怎么办?当然可以多点几次 debugger 的 continue 按钮,让真正期待的第 n+1 次 hit 到来。但是如果第一次的 hit 会干扰第二次呢?(我听见有人说可以用 ssh+命令行 debugger 调试。不过大多数情况没有必要祭出 remote debugging 。)又或者 n 是 10000 怎么办?为了对付这种情况用了不少土方法,比如临时给被调试的程序代码增加一个分支特地用来设置断点,或者在程序弹出 modal dialog 的时候设置断点。

其实 gdb 一类的 debugger 一直都有条件断点的功能。可以让一个断点并不是每次 hit 的时候,而是满足一些额外条件的时候才中断,比如第 n 次被 hit ,或者一个变量大于某个值,或者一个 C 函数的返回值符合条件的时候 ⋯⋯

第五条:监视变量

你是否曾经大声咒骂过:这个变量到底什么时候被改动的?然后不得不一步步的 step over 查看,发现值发生变化之后,停止调试,重启程序,重复刚才的 use case ,step over 到刚才的地方,step into ,然后再 step over ⋯⋯

其实 gdb 和很多 debugger 都能自动监视变量的变化,并且在变化符合某种条件的时候自动中断。条件的配置和条件断点一样灵活。而且 x86 CPU 为了支持这种功能特地设置了几个 debugging resigter (32 位下四个,64 位下更多),所以监视变量几乎不会影响调试速度。不用这个功能都对不起 x86 CPU 上那些专门用来 debug 的硅。

第六条:Log

最终,还是会有些问题确实需要在不间断的执行中监视某些状态。也就是 log 。可是 log 必须得是在代码里加入 printf 吗?gdb 的 commands 命令让断点在 hit 的时候可以执行一段命令,任何合法的 gdb 命令,比如打印某个变量之后 continue 。

结论

… In many cases, you can find the problem through careful use of printf or IOLog statements. Some people swear by this method, and indeed, given sufficient time and effort, any bug can be found and fixed without using a debugger.

—— Kernel Programming Guide

接受 debugger 是一个抛弃原有成见的漫长过程。一开始我们总认为自己的土方法是最有效的(因为当我们抱着进入尖端领域的心情开始软件开发的生涯时,周围的前辈总会教你几个土方法)。然后我们知道真的有先进的工具。我还远远未了解所有 debugger 的所有功能。但是配置一个 debugger 是将我在所有开发平台上的第一件事。

地址空间划分(一)

2010/08/09

研究过 32 位 Linux 内核的人都知道这个内核著名的 3G/1G 划分:低 3G 作为用户态空间,高 1G 作为内核态空间。内核态和用户态共享 4G 的 32 位地址空间。

这个划分成了 32 位内核的基本设计方式。Windows 内核也采用类似的划分(缺省为 2G/2G 划分,可以通过启动参数改为 3G/1G)。所以,可能很多人像我一样,很自然的把这种划分当成了内核的必要设计:内核态和用户态必须共处在同一个地址空间里;似乎如此内核才能管理用户态的内存。见到 Mac OS X 内核 XNU 的设计颠覆了我的观点。XNU 的内核态独占 4G 内核空间,而非与用户态共享。(如下左图显示。)

如果不是因为一个采用如此设计的内核就摆在面前(而且已经在上面工作了三年),我几乎不会想到内核的地址空间还能如此设计。看到这个设计的同时也豁然开朗:一开始认为『内核态和用户态必须共处在同一个地址空间』才能让内核『管理用户态的内存』的想法实在是短路。内核并不能了解用户态如何使用内存,更重要的是,内核并不能信任用户态内存的内容。所以,它根本不能通过和用户态『共处在同一个地址空间』这种方式来管理后者的内存空间。因为『共处在同一个地址空间』提供的唯一功能只是可以直接通过线性地址访问(也就是俗称的裸指针),而内核绝对不能碰用户态的裸指针。内核管理用户态内存的方式只能是通过设置页表和页目录来保证用户态的内存访问的合法性和一些特殊映射。

再者,Linux 和 Windows 下与内核共享地址空间的用户态只能是当前运行的进程。如果是通过系统调用进入内核,还多多少少可以说这时的当前进程和内核的关系比其它进程密切一些。如果是通过中断(时钟或者I/O)进入的内核,这时哪个进程是当前进程对内核来说完全是巧合。更何况无论如何内核也不可能只管理当前进程的内存空间。所以从这个角度说共享地址空间对内核管理用户态内存也没有帮助。

退回到 Linux 和 Windows 的设计,为什么还要让内核与用户态共享地址空间呢。其实是因为尽管每次进入内核态都可能发生进程切换,但是大多数情况下并非一定发生这样的切换。因此,共享地址空间可以避免进出内核态的时候进行地址空间(cr3)的切换。在 x86 构架下,切换地址空间要导致所有 TLB 失效,CPU 必须访问主存更新 TLB。所以,共享地址空间是一个性能 hack,仅此而已。这个性能 hack 如此历史悠久,以至于有些人如我一般完全无法想像还能有其它方式。

不过,XNU 的内核地址空间也并非和用户态空间绝对分离。在每个用户态地址空间的最高几兆也被保留为内核空间(如上右图所示)。这是因为从用户态切换到内核态(通过软中断或者比较新的 x86 syscall 指令)时,并不能同时切换地址空间,所以必须保留这段短短的共享空间来完成地址空间的切换。其实,我们可以把这个设计想像成一个处于 micro-kernel 和 monolithic kernel 之间的中间设计。和 micro-kernel 类似,内核也享有独立空间,但是和 monolithic kernel 类似,整个内核都是处在特权级,没有从微内核到内核服务的二次切换。

64 位 Windows 的 32 位用户态

2010/08/05

上周做了一个 64 位 Windows 的培训。其中一部分是讲 WOW64 。用户态的 32 位代码在即将进入内核态之前会从 x86-64 的 compatibility mode 切换到 long mode(刚刚从内核态返回之后会进行相反的切换)。因为切换是在用户态完成的(通过从 32 位代码段直接 jmp 到 64 位代码段),所以不可能修改 cr3(首级页表的首地址)。

培训的时候没有深究这个问题,后来发觉理解得很模糊。32 位保护模式的 MMU 内存映射是两级,每级页表是 1024 项,每项 32 位。64 位 long mode 的 MMU 映射是四级,每级页表 512 项,每项 64 位。如此如何不修改 cr3 就能做 64 位和 32 位的切换?是在进入内核之后又修改了一次 cr3 ?还是 compatibility mode 可以直接使用 64 位的四级页表?

这次我发现 Google 还是有局限性的。两天里 Google 了十几次也没找到答案,可能是做内核的人觉得这个问题太简单不值一提吧。最后还是老老实实的查了 Intel 的 Architectures Software Developer’s Manual, System Programming Guide :(9.8.5.3 64-bit Mode and Compatibility Mode Operation)

In compatibility mode, the following system-level mechanisms continue to operate using the IA-32e-mode architectural semantics:

  • Linear-to-physical address translation uses the 64-bit mode extended page-translation mechanism.
  • Interrupts and exceptions are handled using the 64-bit mode mechanisms.
  • System calls (calls through call gates and SYSENTER/SYSEXIT) are handled using the IA-32e mode mechanisms.

这里的 64-bit 模式也就是 AMD 术语里的 long mode ,而 IA-32e mode 是指 CPU 处于 long mode 或者 compatibility 之中任何一种模式。

所以,compatibility mode 的执行环境有很大一部分是借用 long mode 的模式,因此第一次进入 compatibility mode 之前必须按照 long mode 进行必要的设置(这个设置是在纯 32 位保护模式下关闭 paging 的时候完成的,所以首次进入 compatibility mode 的四级页表只能在低 4G 内存,因为这时只能操作 cr3 的低 32 位)。因为第一次进入 IA-32e 模式一定是在 32 位代码段里进行的,所以 CPU 总是会首先进入 compatibility mode 。