Microvium has try-catch!
TL;DR: Microvium now supports exception handling, with just 4 bytes of overhead per active try block. This post covers some of the details of the design, for those who are interested.
Note: if you’re new to try-catch, see the MDN article on the subject. Note however that Microvium does not yet support
finally, but in my experience
finally is a less-used feature of JS.
One of my objectives with Microvium is to allow users to write JS code that is stylistically similar to the kind of code they would write for a larger engine like V8/Node.js. I want it to be easy to write JS libraries that run on both Microvium and Node, and the public interface of these libraries especially should not be contorted to fit the Microvium feature subset.
A look at alternatives
Microvium is implemented in C. How does one implement exceptions in a language like C? In the journey of finding the best way to do exceptions, I thought of a number of different ideas.
The most obvious one to come to mind is something like setjmp/longjmp. This is a common way to implement exception-like behavior in a language like C, since it’s the only way to jump from one C function into another that’s earlier on the stack. At face value, this sounds sensible since both the engine and host are running on C.
longjmp approach. At each
try, XS mallocs a jump structure which is populated with the state of current virtual registers and physical registers, as well as a pointer to the
catch code that would handle the exception. Upon encountering a
throw, all the virtual and physical registers are restored to their saved state, which implicitly unwinds the stack, and then control is moved to the referenced catch block.
In my measurements1, one of these jump structures in XS is 120-140 bytes on an embedded ARM architecture and 256-312 bytes on my x64 machine2. Even the
jmp_buf on its own is 92 bytes on Cortex M0 (the architecture to which I’ve targetted all my size tests in Microvium). That’s quite heavy! Not to mention the processing overhead of doing a
malloc and populating this structure (each time a
try block is entered).
How it works in Microvium
After thinking about it for some time, the following is the solution I settled on. Consider the following code, where we have 2 variables on the stack, and 2 try-catch blocks:
If we were to throw an exception on line 5, what needs to happen?
- We need to pop the variable
yoff the stack (but not
x), since we’re exiting its containing scope. This is called unwinding the stack.
- We need to pass control to the
e1catch clause (line 7)
- We need to now record that the
catch(e2)on line 11 is the next outer catch block, in case there is another
Microvium does this by keeping a linked list of
try blocks on the stack. Each
try block in the linked list is 2 words (4 bytes): a pointer to the bytecode address of the corresponding
catch bytecode, plus a pointer to the next outer
try block in the linked list. For the previous example, there will be 2
try blocks on the stack when we’re at line 5, as shown in the following diagram:
Each of the smaller rectangles here represents a 16-bit word (aka slot). The arrows here represent pointers. The red blocks (with an extra border) each represent an active
try block consisting of 2 words (
nextTry). They form a linked list because each has a pointer to the next outer
topTry register points to the inner-most active
try block — the head of the linked list. Each time the program enters a
try statement, Microvium will push another
try block to the stack and record its stack address in the
topTry register serves 2 purposes simultaneously. For one, it points to the head of the linked list, which allows us to find the associated
catch block when an exception is thrown. But it also points to exactly the stack level we need to
unwind to when an exception is thrown. This is similarly true for each
When an exception is thrown, Microvium will:
- Take the current
topTryand unwind the stack to the level it points to.
- Transfer control (the program counter) to the bytecode address of the catch block which is now sitting at the top of the stack (i.e. pop the program counter off the stack).
- Save the
nextTrypointer as the new
topTry(i.e. it pops the
topTryregister off the stack).
Et voila! We’ve implemented
try-catch in Microvium with just 4 bytes of overhead per
This also works when
throwing from one function to a
catch in a caller function. The only difference is that the
unwind process in step 1 then needs to restore the registers of the caller (e.g. argument count, closure state, return address, etc). This information is already on the stack since it’s also used by the
return instruction — we don’t need to save it separately during a
try. This works for any level of nested calls if we just unwind repeatedly until reaching the frame containing the catch target.
I’m pretty satisfied with this design.
- Only one new virtual register (
- Only 4 bytes of RAM per active
- No heap overhead
- Conceptually simple
- Only makes the engine 156 bytes larger in ROM
The static analysis to implement this is quite hairy — it’s complicated by the interaction between
break, and closures, among other things. For those interested, see the test cases for examples of the kinds of scenarios that try-catch needs to deal with.
On a meta-level, this is a good reminder of how the first idea that comes to mind is often not the best one, and the benefit of thinking through a problem before jumping right in. I’ve been brainstorming ideas about how exceptions might work in Microvium since July 2020 — almost 2 years ago — and I kept going back to the ideas to revise and reconsider, going back and forth between different options. Sometimes, mulling over an idea over a long period of time can help you get to a much better solution in the end.
Another lesson is that simplicity is deceptively time-consuming. Looking only at this apparently-simple final design, you might not guess at the amount of work required to get there. This is a general tragedy of software engineering: it often takes more time to get to a simpler solution, which gives the appearance of achieving less while taking longer to do so.