[WinAPI] OutputDebugString 的原理与 DebugView 的实现
之前写了一个基于 ETW(Event Tracing for Windows) 的日志记录与查看器。于是想把 DebugView 的功能也集成进来进来,达到在同一个日志查看器下使用的目的。遂研究了下 DebugView 的原理。
DebugView 捕捉到的来自 Win32 应用层的日志是 OutputDebugString() 输出的。这个函数的输出能够被所有调试器所捕捉到,也包括 WinDbg, Visual Studio 在内。于是猜想,肯定有公共的接口或协议来操作该日志。而且,这个函数的使用方式非常的简单:它只接收一个普通字符串参数。于是只需要传入一个普通字符串即可,长度大概在 4096 个字符左右。说它简单的原因是其只能简单地原样输出参数指向的字符串,既不能期待它有格式化输出的功能,也不能期待它能帮忙打印出线程、文件、行号、函数等信息。要实现这些功能的话,就得对其进行包装一下。用宏就可以了,实现方式不麻烦,就不详述了。
好在已经有网友通过反汇编 OutputDebugString 弄清楚了它的工作原理,Steve Friedl 就是其中之一。接下来我就简单介绍一下通信机制。以下用术语“应用程序”代表日志输出者,“调试器”代表日志接收者。
应用程序和调试器之间是通过一块 4KB 的共享内存来传递数据的。同一时刻只能有一个应用程序能够访问该共享内存,于是需要一个互斥体,只有获得了该互斥体,才应该继续操作该共享内存。为了读写不冲突,同时还需要两个事件对象,一个用于读(数据准备好了),一个用于写(空间空闲)。于是,共有 4 个内核对象:
内核对象名字 | 内核对象类型 |
---|---|
DBWinMutex | 互斥体 |
DBWIN_BUFFER | 共享内存 |
DBWIN_BUFFER_READY | 事件 |
DBWIN_DATA_READY | 事件 |
互斥体 DBWinMutex 是始终存在的,但其它三个只有在调试器存在并准备接收消息(日志)的时候才会存在。于是,如果后面这三个对象之一已经存在,其它的调试器将无法再接收由 OutputDebugString 而来的日志消息。
共享内存块有着下面的固定格式结构:
struct dbwin_buffer
{
DWORD dwProcessId;
char data[4096 - sizeof(DWORD)];
};
dwProcessId 代表打印此日志的进程ID。这 4KB 中剩余的部分则是日志数据。该日志总是以 NUL('\0')结尾。
并且值得注意的是:日志字符串的字符类型是 char,不管你调用的是 OutputDebugStringA 还是 OutputDebugStringW。Windows 内核是 Unicode 为基础的,多数提供 A/W 版本的函数都是 W 实现,然后 A 版本通过编码转换后调用 W 版本的实现。但 OutputDebugString 是个例外:OutputDebugStringW 是后来才提供的,它的功能是转换编码并调用 A 版本。由于 A 版本的字符集是系统相关的,于是可能导致 W 版本不能正确地输出日志中某些字符。
当应用程序调用 OutputDebugString() 时,OutputDebugString() 会完成以下步骤的操作。若其中一步操作失败,则该日志将丢失。
- 打开 DBWinMutex 互斥体并等待,直到能够互斥地访问它;
- 映射 DBWIN_BUFFER 共享内存到内存(如果没找到,则当前可能没有调试器,放弃操作);
- 打开 DBWIN_BUFFER_READY 和 DBWIN_DATA_READY 事件。同上面的共享内存一样,必须要两者同时拿到才应继续操作;
- 等待 DBWIN_BUFFER_READY 事件成为激发状态(Signaled)。这意味者:该共享内存此时处于空闲状态。多数时候该事件是处于激发状态的,但如果不是,OutputDebugString 等待的时间不会超过 10s。若超时,放弃操作;
- 复制最多 4KB(实际是 4096-sizeof(DWORD)) 数据到共享内存(数据总是以 NUL('\0')结尾的),并且设置好进程ID。
- 通过设置事件 DBWIN_DATA_READY 来告诉调试器数据已经准备好了。
- 释放互斥体(为了后续操作可不用关闭),关闭事件,取消映射共享内存。
在调试器端,就要简单一些了。互斥体始终不会用到。并且如果这两个事件(或之一)、共享内存存在的话,就意味着当前有调试器正在接收输出。不应该继续了。因为同一时刻应当只存在一个调试器正在接收来自 OutputDebugString 的输出。
- 创建好上述共享内存和两个事件(互斥体不需要)(其中之一失败就放弃);
- 设置 DBWIN_BUFFER_READY 事件。于是应用程序知道有调试器开始工作了,数据缓冲区已备好;
- 等待 DBWIN_DATA_READY 事件变成激发状态;
- 处理日志数据:进程ID、日志内容;
- 回到第 2 步,循环接收。
整体来说,工作方式很简单。完整简要地实现了一个多进程通信模型。不过,不停地映射共享内存感觉性能比较差。
另外,由于后来 Windows 系统 UIPI 机制的加入,有时会出现一些权限问题。我就暂时不深究了,感兴趣的可以继续深入。
关于 OutputDebugString 的原理和实现可以参考:http://www.unixwiz.net/techtips/outputdebugstring.html。
我自己的日志查看器中的实现:https://github.com/movsb/taoetw/blob/master/src/log/dbgview.cpp。