SEGFAULT, also known under Windows as an Access Violation Exception, is probably one of the first hard errors any computer science student encounters. Forget to assign a pointer? Deleted something but still have a pointer to it? Didn’t bounds check that array? Goodbye program, hello cryptic issue that takes at least an hour to find the cause of.
But what if I told you we could keep running even after?
In theory
To better understand it, let’s take a look at what happens when we dereference a bad pointer.
Modern OSes implement memory virtualization: memory addresses that software programs see are not the same as the corresponding physical memory addresses. This is for a variety of reasons, among them security and resource-sharing. If a malicious program were to know its relative position to the memory of a privileged process (such as the kernel), there are tricks that allow changing that memory without elevating permissions. If you were to run the same application multiple times (or more likely, multiple applications use the same DLL) the OS can have them share the code loaded into memory, and they differ only in the data they manipulate at runtime.
Since the memory controller controls read/write access as well as virtual memory mapping, it is the component that raises SEGFAULT when memory is write-protected or simply unmapped. This is a hardware exception, entirely different from std::exception and therefore cannot be handled by a try-catch block. Instead, the control unit fetches the kernel’s hardware exception handling callback from the interrupt table. Interrupt routines are similar to bare functions: all registers are exactly as left when exiting the previous code. They will push registers to the stack so those values can be restored when the interrupt exits, before taking whatever action it needs to handle the exception1.
The kernel will then decide how to proceed in order to avoid a total system crash. The C signal() function lets programs register a handler, which is typically useful for any special cleanup (closing ports, freeing shared memory, deleting files) when encountering a crash, similar to atexit(). However, we could theoretically also return back to the right piece of user code.
In practice
We could unwind the stack and use return-oriented programming here, but we have a better tool. Those sharp among you may have recognized the similarity between interrupt handling and setjmp(). This was conceived to quickly exit multiple function levels and report an error, and so it is reliable and architecture-agnostic. It can also be used to implement coroutines.
We’re going to use it well outside that intended spec.
jmp_buf escapeBuf;
extern "C" void __cdecl SegfaultEscape(int sig)
{
longjmp(escapeBuf, true);
}
int main()
{
//Snapshot signal handler and set escaper
auto prevSignalHandler = signal(SIGSEGV, SegfaultEscape);
if (!setjmp(escapeBuf))
{
//Cause segfault
char* invalid = reinterpret_cast<char*>(0xDEADBEEF);
*invalid = 0;
}
else
{
printf("Caught segfault\n");
}
//Restore signal handler
signal(SIGSEGV, prevSignalHandler);
}Drawbacks
This technique is far from ideal, largely because we’re using setjmp. We may introduce instability by skipping cleanup code2 like C++ destructors, creating subtle memory leaks or inducing invalid state leading to unrecoverable crashes. Although it’s unlikely, this could even affect the kernel. Additionally, the C++ std::signal documentation specifies that almost all standard library functions are undefined behavior, which it’s highly likely user code will call.
Depending on the system, it may be entirely impossible to escape multiple SEGFAULTs or other hardware faults. A single fault may return safely to user code, but if that code faults again almost all OSes catch the double fault and kill the process to prevent a triple fault from occurring, which would cause the CPU to panic and crash immediately.
An alternate method
On UNIX-based systems, fork() offers an intriguing alternative by duplicating the process itself. Thanks to memory virtualization, from the program’s perspective its address space remains unchanged. The only difference is that for one process fork() returns 0, and the other gets the child process’ ID. Most importantly, any hard faults in the child process don’t terminate the parent.
This is what the unit test framework Check does: every test case runs in a forked child, and the parent collates the results. In the event of a hardware exception, it instead collects their return codes. Pass, fail, or hardfault, execution is allowed to continue.
The challenge with this method is not all resources may be duplicated properly, such as ports, windows, files, and other OS objects. Additionally, you may find yourself fighting the destructors of C++ that once made it safe, in order to prevent a double-free or reference-after-free as the child exiting may affect the validity of the parent’s handles.
Footnotes
- On x86 systems, this will be a page fault with Present=1 and User=0. The kernel can also see what type of behavior was blocked: Write=1 (can’t write), Write=0 (can’t read), or Instruction Fetch=0 (can’t execute) if R^X is set. ↩︎
- MSVC offers the ability to call destructors as longjmp unwinds the stack by passing /EH options, but it also lets us directly catch hardware faults with SEH which makes our custom signal handler unnecessary. ↩︎