Hold on! Before you call me crazy, please let me clarify. Yes, I am going to tell you how to handle C++ exceptions in C code, but don't worry, I know this is a terrible idea, and (hopefully) no one is actually going to do that.

But why, you might ask, would anyone even begin to ponder such a silly idea? Well, poking things and making them do things they aren't supposed to do is a excellent way to figure out how they work. And exception handling is certainly a very interesting , and perhaps quite important, practical computer algorithm. So it's quite natural for someone to have curiosity about how it works.

Alright. But where do we even start? If you search for information on exception handling online, what you would find is usually cryptic and incomplete documentation about ELF, Dwarf, and C++ ABI. Often they are difficult to understand, and it is usually hard for someone to see how they all fit together.

So instead, let's work in reverse. Let's inspect something that actually does exception handling.

int throw_exception() {
    throw nullptr;
}
int main() {
    try {
        throw_exception();
    } catch (void *e){
    }
}

This is a baseline program that throws an exception and catches it. Let's try compiling it and see what comes out:

g++ code.cc -S

If you are wondering what -S does, it tells the compiler to stop after producing the assembly, without assembling or linking the code. You should try this step yourself, and look at the assembly produced. Here, I picked out a few relevant snippets, and simplified them to make them easier to read.

Let's look at the throwing side first:

throw_exception():
.LFB0:
	push	rbp
	mov	rbp, rsp
	mov	edi, 8
	call	__cxa_allocate_exception@PLT ; <----
	mov	QWORD PTR [rax], 0
	mov	edx, 0
	lea	rsi, _ZTIDn[rip]
	mov	rdi, rax
	call	__cxa_throw@PLT              ; <----

If you are not used to looking at assembly, this might look quite daunting. But luckily, you don't have to know any assembly at all to pick out the interesting bits here: __cxa_allocate_exception and __cxa_throw.

As their names suggest, __cxa_allocate_exception allocates space for the thrown exception, and __cxa_throw actually commences the throwing. (the __cxa_ bit probably means "cxx abi", I am not sure.) You don't have to take my words, you can find this information here.

If you are curious, you can find the code of __cxa_throw here. For the rest of us, we will move on to the catching side:

main:
.LFB1:
	push	rbp
	mov	rbp, rsp
	sub	rsp, 16
.LEHB0:
	call	throw_exception()
.LEHE0:
.L6:
	mov	eax, 0
	jmp	.L8 ; <- this will jump to the label below
.L7:
	cmp	rdx, 1
	je	.L5
	mov	rdi, rax
.LEHB1:
	call	_Unwind_Resume@PLT
.LEHE1:
.L5:
	mov	rdi, rax
	call	__cxa_begin_catch@PLT
	mov	QWORD PTR -8[rbp], rax
	call	__cxa_end_catch@PLT
	jmp	.L6
.L8: ; <- this is a label
	leave
	ret ; <- this means "return"

(If you don't know how to read this assembly, you just need to know this: instructions which start with letter j mean jump. The thing which follows the jump instruction is a "label", which is also what the .Lxxx: you see in the code are. The jump instruction will jump to the matching label when executed)

OK, here we spot __cxa_begin_catch and __cxa_end_catch, which seems to match the catch block in the source code. However, they don't seem to be reachable. If you follow the call to throw_exception(), you can see after it returns, the code immediately jumps to .L8, which returns to the caller. So who is going to trigger the catch block?

Let's look a bit further into the assembly:

.LFE1:
        .globl  __gxx_personality_v0
        .section        .gcc_except_table,"a",@progbits
        .align 4
.LLSDA1:
        .byte   0xff
        .byte   0x9b
        .uleb128 .LLSDATT1-.LLSDATTD1
.LLSDATTD1:
        .byte   0x1
        .uleb128 .LLSDACSE1-.LLSDACSB1
.LLSDACSB1:
        .uleb128 .LEHB0-.LFB1
        .uleb128 .LEHE0-.LEHB0
        .uleb128 .L7-.LFB1 ; <- ha
        .uleb128 0x1
        .uleb128 .LEHB1-.LFB1
        .uleb128 .LEHE1-.LEHB1
        .uleb128 0
        .uleb128 0

This assembly block defines data. After being assembled and linked, they become data bytes stored verbatim in the executable. And in there, we find a reference to .L7. Look back at the code, we can see .L7 contains a jump to .L5, which is the catch block.

From this, we can infer that, there is some information stored in the executable ("metadata"), which, when an exception is thrown, the throwers will read to figure out the code to run.

Alright, now we have a rough picture of how exception handling works:

  • The thrower prepares the exception, then call __cxa_throw
  • __cxa_throw looks through the metadata of the callers in the function call stack, to find a suitable handler
  • The program jumps to the handler

Looks simple, right? If we can somehow make the metadata of a C function declare: "hey, look here, I have an exception handler!", we will be done.

But, as you might know, there is no such thing as exceptions in the C language. There can't possibly be a way to declare a function as having an exception handler, right?

Well, there is not a standard way. But there is a way. There is this extension to C, implemented by the GCC compiler (and later Clang), which allows you to attach a "cleanup" function to a local variable. This cleanup function will be called when the local variable goes out of scope.

Because of the excellent interoperability between C and C++, you can have the situation where an C++ exception is thrown "through" a C function. Normally, the cleanup functions aren't going to run in that case. However, this makes people unhappy. So, now, there is a way to make them run.

Passing the -fexceptions option to GCC, makes it generate exception-aware code even when compiling C code. And in that mode, the cleanup functions will be registered as exception handlers! Just like the local variable destructors in C++.

Here is an example:

// file1.cc
extern "C" void throw_exception() {
    throw nullptr;
}
extern  "C" void might_throw();
int main() {
    try {
        // OK, we have to have a try/catch in main,
        // because if C++ cannot find an actual `catch` block,
        // it will just abort(), without going through the throw
        // process at all.
        might_throw();
    } catch (void *e) {
    }
}
// file2.c
#include <stdio.h>
extern void throw_exception();
void the_cleanup_function(void *x) {
    fprintf(stderr, "cleanup called\n");
}
void might_throw() {
    // Call the_cleanup_function when `x` goes out of scope
    __attribute__((cleanup(the_cleanup_function))) int x;
    throw_exception();
    fprintf(stderr, "do more stuff\n"); // we don't get to this point :'(
}

Compile and run:

g++ file1.cc -c
gcc file2.c -fexceptions -c
g++ file1.o file2.o
./a.out

You will see it says:

cleanup called

If you look at the assembly of file2.c, you will see structures very similar to the try/catch block in file1.cc.

OK, we are able to get a C function called during the handling of a C++ exception. But this exception still propagated through the C function, which is normal, given we didn't actually catch the signal. But what exactly is going on here? How did the control flow slip away from our hands?

Let's look at the assembly again:

might_throw:
; ... skipped ...
.L7:
	mov	rbp, rax
	jmp	.L5
.L5:
	lea	rdi, 4[rsp]
.LEHB2:
	call	the_cleanup_function
	mov	rdi, rbp
	call	_Unwind_Resume@PLT
; ... more skipped ...

You can easily see the call to the_cleanup_function, right after that, is a call to _Unwind_Resume. What does that do?

First of all, what does "unwind" mean here? You might have already known, the program needs to "unwind its stack" when an exception is thrown. Basically it has to remove entries on its call stack, until a handler is found. Which is the process we already described above.

So, inferring from the function name again, we can guess that _Unwind_Resume resumes an interrupted stack unwinding (which is, by the way, correct). And our cleanup function does interrupt the unwind, so after the cleanup function returns, the program calls _Unwind_Resume to resume the unwinding.

Alright, if, we can stop that function from being called. If we can do that, we would have "caught" the exception.

Basically, we want the_cleanup_function to skip over everything in might_throw that is after the call to the_cleanup_function. Clearly, we need something that could interrupt the normal execution flow of the program. goto is not enough, because our skip is cross-function. We need something stronger.

And there is indeed something stronger. setjmp and longjmp! A call to longjmp allows you to jump to a point in the program where you have previously called setjmp (the man page explains these functions very well). So we just need to use setjmp to put an anchor in the normal return path of might_throw, then have the_cleanup_function jump to there with longjmp, and we would have skipped the _Unwind_Resume.

Here is a version of might_throw that does this:

#include <setjmp.h>
struct Catch {
	jmp_buf env;

	// The cleanup function will be called when the exception is thrown,
	// but then it will be called again when we return from `might_throw`.
	// We don't want it to perform the longjmp when `might_throw` is returning
	// normally, so we use a flag to indicate whether a longjmp should be
	// performed.
	int do_jump;
};
void the_cleanup_function(struct Catch *env) {
	if (env->do_jump) {
		longjmp(env->env, 1);
	}
}
void might_throw() {
	__attribute__((cleanup(the_cleanup_function))) struct Catch env;
	if (setjmp(env.env) == 0) {
		env.do_jump = 1;
		throw_exception();
	} else {
		fprintf(stderr, "exception caught\n");
		env.do_jump = 0;
	}
	fprintf(stderr, "do more stuff after having caught the exception\n");
}

And running it, we get:

exception caught
do more stuff after having caught the exception

Voilà! We have caught a C++ exception in C.

OK, are we done?

No! This result might be good enough, but I am still not satisfied. We have successfully stopped an exception in C code, but we don't actually get the thing that was thrown. All we know in our "exception handler" is an exception was thrown, but not what the exception is. This is quite useless.

So, can we retrieve the thrown exception?

Maybe. We will continue in part 2.