|
|
Brute Force ASSERTs & Debug Breakpoints |
|
This article describes some techniques that I use in my kernel programming adventures
with regards to debug assertions and breakpoints. Because not everyone knows the
basics of kernel programming I will explain the bare essentials with regards to
this article:
- ASSERTs work the same as in user mode programs. In debug builds the assertion is
evaluated and if the result is FALSE a breakpoint is triggered. We usually call
these "hardcoded" breakpoints as opposed to normal breakpoints that you set with
your debugger. In release builds the assertions go away so there is no hardcoded
breakpoint in the code.
- What is drastically different between user and kernel mode is what happens when
the breakpoint is hit (triggered). A breakpoint is considered to be an exception.
And like all exceptions it has to be handled. The debugger is meant to be the handler.
Kernel debugging does not work like user mode debugging. It requires two PCs: One
runs the debugger and the other runs the code to be debugged. They are usually called
DebuggER and DebuggEE respectively but these two terms are very much alike and easy
to confuse so I use the term "Victim" for the "DebuggEE".
So we have the Debugger and the Victim.
- In order to debug kernel code on a Victim, the Victim has to boot in debug
mode. This is a boot mode, not the debug (checked) version of the OS. This is specified
by using the /DEBUG switch together with a
couple of other switches in a BOOT.INI
entry.
- If the Victim has not booted in DEBUG mode and a hardcoded breakpoint is hit what
happens is a kernel mode unhandled exception, which is short for Blue Screen of
Death (you see
Not all Blue Screens are Crashes).
- If the Victim has booted in DEBUG mode and a hardcoded breakpoint is hit then the
Victim freezes and waits indefinitel for a kernel debugger to do something with the breakpoint.
It is not necessary for a Debugger to be actually connected at the
time the breakpoint is hit. The debugger can connect after
the breakpoint was hit. The Victim looks frozen on the UI level (and almost all other levels)
but it is not dead; it is waiting for some special treatment by a Debugger.
Hopefully the above were clear enough. However through practical experience I found
two great shortcomings to the standard approach:
- If a client installs a debug build of my drivers and an assertion fails then she
will get a BSOD. Oooops... I want her to run the debug build so that I get the debug
messages, but as soon as she installs it she gets a BSOD. Normal people are really
scared of BSODs (unlike kernel mode psychos), so I end up with clients who refuse
to run the debug build or even worse with clients that think that our drivers are
buggy, since they cause BSODs all the time...
- If the client runs the release build of my drivers I don't know whether any assertions
are failing or not, because the assertions are not there in the release build.
So I came up with the following approach:
- Create a wrapper function for debug breakpoints. The wrapper function only raises
the breakpoint if a registry setting is set to the proper value. So by default the
debug build of my drivers do not trigger breakpoints. The client has to actively
do something in order to enable the breakpoints.
Moreover the wrapper function always writes a message to the kernel
debugger. Always means in both the debug and release builds. What the message says
we will see in the implementation section.
- Create a new ASSERT macro that calls the wrapper function.
This way everybody is happy. The debug driver does not cause any crashes and the
release driver is giving me diagnostic messages when assertions fail.
Needless to mention, kernel programmers cannot be as sloppy as their user mode counterparts.
For example, in a driver I cannot write:
ASSERT(a_BufferSize <= sizeof(TargetBuffer));
CopyMemory(TargetBuffer, InputBuffer, a_BufferSize);
I have to write:
ASSERT(a_BufferSize <= sizeof(TargetBuffer));
if (a_BufferSize > sizeof(TargetBuffer))
{
// Fail the operation somehow.
...
}
CopyMemory(TargetBuffer, InputBuffer, a_BufferSize);
You cannot leave things to chance in the kernel. An application crashing once every
week is a minor annoyance you can live with. Crashing the OS once a week causes
lots of turmoil, so I have to make sure it does not happen under any circumstances.
So now it's time to look at the implementation of the approach I'm currently using
in my projects.
#define STRINGIZE(a) _STRINGIZE(a)
#define _STRINGIZE(a) #a
#define BFSANITY(condition) \
INNERBFSANITY(condition, STRINGIZE(condition), __FILE__, __LINE__)
#define INNERBFSANITY(condition, condition_as_string, file, line) \
do \
{ \
if (!(condition)) \
C1394DebugBreak("SANITY(" condition_as_string ") FILE(" file ")", line); \
} \
while (0)
void C1394DebugBreak(const PCHAR a_szFileNameOrMessage, const ULONG a_LineNumber)
{
DbgPrint( "(C1394DebugBreak) Debug Breakpoint Triggered. %s - Line(%d)",
 
a_szFileNameOrMessage, a_LineNumber);
#if DBG
if (g_bEnableDebugBreakpoints)
DbgBreakPoint();
#endif
}
The implementation is common-sense simple (provided you are a bit of a macro-freak),
but I think it combines several concepts
in a pretty interesting way. Since assertions
are also known as "Sanity Checks" I used the term "Sanity" for my macro. But since
that is a pretty common term and may already be used by some, I added my initials
in the front. Thus you have the BFSANITY macro.
I have also prepared a small user mode console app that has all the required functionality
so that you can take a shot at this technique and see if it fits your needs for
your everyday applications.
Here is the code for that user mode application:
#include <windows.h>
#include <stdio.h>
#include <stdarg.h>
// Macro to convert #defined items into strings
#define STRINGIZE(a) _STRINGIZE(a)
#define _STRINGIZE(a) #a
#define BFSANITY(condition) \
INNERBFSANITY(condition, STRINGIZE(condition), __FILE__, __LINE__)
#define INNERBFSANITY(condition, condition_as_string, file, line) \
do \
{ \
if (!(condition)) \
BfDebugBreak( "SANITY(" condition_as_string ") File(" file ")", line); \
} \
while (0)
// Initialize this as appropriate
bool g_bEnableDebugBreakpoints;
void BfDebugPrint(char *format, ...)
{
char szMessage[512];
va_list VaList;
va_start( VaList, format);
vsprintf_s(szMessage, sizeof(szMessage), format, VaList);
OutputDebugString(szMessage);
va_end( VaList );
}
void BfDebugBreak(const char *a_szMessage, const ULONG a_LineNumber)
{
BfDebugPrint( "(BfDebugBreak) Debug Breakpoint Triggered: %s - Line (%d)",
a_szMessage, a_LineNumber);
#if defined(_DEBUG)
if (g_bEnableDebugBreakpoints)
DebugBreak();
#endif
}
int main(int argc, char* argv[])
{
BFSANITY(argc == 2);
BFSANITY(("Double Parenthesis and Comma operator -> ASSERT(FALSE) with a message ;-)",0));
return 0;
}
From the above you can easily understand why I LOVE macros and you can probably
imagine what is my #1 reason for disliking languages like C#.
You can download the sample project from
this location.
Have Fun! |
|
|