Lua, exceptions and Unwind

A long time ago, I got interested in how exceptions work in compiled languages. This curiosity made me discover the world of the Itanium C++ ABI and how it shapes almost all exceptions runtime on a plethora of architecture as of 2026.

Seeing unwinding was extremely close to setjmp/longjmp, I thought of Lua.

Lua is a small interpreted language developped in PUC-Rio. Lua is well known for being simple, memory conscious and extremely small. The language has several implementations, but what makes the reference implementation stand out is the extensive use of setjmp/longjmp for control-flow transfer. It is used for error propagations but also in the implementation of Lua’s coroutine resume/yield.

This is when a non-problem, but a technical absurdity disguised as a curiosity came up into my head.

Can I replace the setjmp/longjmp control flow with the unwinding mechanism used for exceptions?

— A sleepless brain, A long time ago

Behind a pretty harmless technical challenge was the potential for a real-life feature: With this, Lua could catch any exception.

This meant Lua protected calls would effectively protect against critical errors.

Fast forward to the night of friday, 2nd of October 2026, where an insomnia worked the entire solution on how to do it.

How do exceptions work?

In C++, exceptions is like baseball. You have a batter and a catcher.

The catcher:

try {
	foobar();
} catch (const std::exception &e) {
	// Exception caught
}

The batter:

void
foobar(void) {
	throw std::runtime_error("foobar");
}

While the example shows the use of std::runtime_error and std::exception, anything can actually be thrown in C++. However, the representation of objects and their lifetime is a topic reserved for what the Itanium C++ ABI on Exception Handling calls the C++ ABI. What’s of interest to us lies in the Base ABI. More precisely: How the throw keyword transfers control-flow to catch.

The Base ABI defines the Unwind Library interface, the portable part of the exception runtime. It is used nowadays in Ada, Rust and even Swift. Making exceptions fairly interoperable between ABI boundaries.

The Unwind Library interface is defined in C. Which is great, as Lua is also written in C.

Raising an exception

The main interface of interest is the _Unwind_RaiseException. Which takes a pointer to a correctly-initialized _Unwind_Exception, and performs the stack unwinding and control-flow transfer if necessary. To continue the baseball analogy, we have a batter, _Unwind_RaiseException which hits our ball, an _Unwind_Exception. Now we need the ball to get caught.

Catching an exception

Once the exception is raised, the unwinding process starts phase one. This process walks the stack and tries to find a function which has a try-catch section capable of handling our exception.

This phase actually has to handle a lot, it has to query whether each frame has a try-catch block in the first place, and then, query whether the catch block is suitable for our exception.

Remember my C++ example, I threw a std::runtime_error, which is a std::exception and would be caught there. But had I thrown an int, the catch block wouldn’t match, and the exception would have to be propagated further.

To answer the question “Does this frame have a try-catch block?”, the compiler assigns the function a runtime metadata called a personality routine. The personality routine is implementation dependant, and even in the C++ world, each runtime has a different one. For example, the GNU C++ implementation is called __gxx_personality_v0.

Looking at this metadata, the unwinding process answered the first question. To answer the second question “Can this frame catch this exception?”, the unwind process calls the personality routine a first time. If the personality routine returns a favorable _URC_HANDLER_FOUND, the second phase starts.

In the second phase, also called “cleanup phase”, the personality routines are called once again, but this time their role is to cleanup their respective frames. Remember, in C++, objects are managed through Resource Acquisition Is Initialization (RAII). If an object goes out of scope, it must be destroyed. And exceptions bypass the normal control-flow. Thus, the unwinding process must run the frames’ destructors, or objects would be leaking everywhere.

Finally, when the unwind process reaches the previously-found handler’s personality routine, the same personality routine transfers control-flow one last time to the catcher. And control-flow can resume normally.

Can it replace setjmp/longjmp?

The Unwind Library is capable of control-flow transfer akin to the setjmp/longjmp API. Even more powerful, as it can temporarily transfer control to intermediate frames for cleanups. Which means that beyond catching exceptions, this implementation also keeps RAII in check.

The problem is now defining all required metadata from C.

By a stroke of luck, clang 23 very recently introduced a new personality attribute for functions, allowing a runtime to set an explicit personality routine for a frame. So even if I keep to C, the compiler has to be clang version at least 23. But then, better rely on a portable compiler than rely on non-portable hacks.

The last problems to solve are: Where and how to land? Where? C++ uses Language Specific Data to record the set of all try-catch blocks, and their addresses in a frame. Defining these in C is not possible, so another mechanism is required. How? The Base ABI references the Processor Specific ABI (psABI) and DWARF definitions on which register(s) should hold what data.

This is where this section of the Base ABI gave me an idea. The registers used to pass the exception pointer are, on x86, ARM and RISC-V, the same as the 1st return register for a function. And I know of a C builtin which can give me the return address of a frame.

__builtin_return_address

Fake a function return with the exception as a return value.

What a coincidence that a replacement for setjmp would be solved using the same fake-return-from-function trick.

The real challenge: The landing pad

The moment of triumph was the landing. We thought that was the real challenge. That was the difficult thing to do.

— Neil Armstrong, Press Conference of July 20 1979

The landing pad is the instruction pointer where the personality routine transfers control during the cleanup phase.

The only place knowing this address in our implementation is the function whose’ return is faked when an exception is caught. Because it is the only one capable of calling __builtin_return_address, and return a valid address in our catching-function.

So this function has two roles: Giving us the landing pad, and returning a sentinel to detect it isn’t an exception-catching return.

static _Unwind_Ptr landingPad;

struct _Unwind_Exception *luaEH_setlandingpad (void) {
  landingPad = __builtin_extend_pointer(__builtin_return_address(0));
  return NULL;
}

Sadly, as our personality routine needs to access landingPad, it means we have no choice but to make it a global variable.

It could have been implemented as a stack, allowing nested landing pads, but lua has only one function calling it, luaD_rawrunprotected. So I chose to make it a single global variable and ensure no other function called it. This simplifies the implementation and makes it multi-thread safe, as only one valid address can be put in landingPad, removing coherency issues.

The compiler is your worst best friend

At this point, the implementation worked, as long as Lua was compiled -O0. When you are hacking the fine prints between C and its ABIs, you encounter issues related to assumptions the compiler make for optimizations.

The first implementation had two major bugs, happening only when compiled -O2.

Originally, luaEH_setlandingpad was defined in ldo.c, the same translation unit where luaD_rawrunprotected is defined. The compiler, assuming our function always returned NULL, pruned the if (e != NULL) branch from luaD_rawrunprotected. Which meant we never caught exceptions, and ran the throwing function again and again. So I introduced a lexcept.c where I put the exception implementation, which is a better practice anyway. Note this fix is only valid when Link-Time-Optimization is not enabled. So DO NOT compile this version of Lua with LTO.

The second bug only happened when trying to pass the lua test suite. It happened during coroutines test, which also heavily use this control-flow. However, the issue was absolutely not related to coroutines. The issue rose during the lua_resume function. And this is the kind of problem you do not see with layout src in gdb but only with layout asm. A call to luaD_rawrunprotected in lua_resume was inlined. All this because they were, once again, defined in the same translation unit. Adding a noinline beside the personality attribute of luaD_rawrunprotected fixed the issue. And the whole test suite passed immediately.

Conclusion

I implemented a long time idea which lived rent-free in my head for a long time. Yet, in this post, I omitted other questions for which the answers are crucial on why it works and doesn’t fall apart. Just the multi-thread safety. I evoked it, yet why didn’t I put the landingPad atomic? Other questions and topic which I didn’t feel had a place in a Lua and Unwind dedicated topic.

This implementation relies on many assumptions: clang-23, no LTO, even the exception runtime reliance on the Unwind Library. Because yes, for some systems, the exceptions are implemented using setjmp/longjmp.

I believe this solution can be the base of a dynamic C exception runtime. But for it to be usable, adding the personality attribute in GCC is crucial.

Even though it has very few source code lines, it has heavy implications. I will keep it as a fork, for which I already added support for Apple’s Blocks. I also added a LUA_ERREXCEPT error, which breaks binary compatibility with the reference implementation. But I think this implementation and its exception-awareness may be of interest for some niche projects.