This document describes the native stack-unwinding exception handling
implementation added for the Darwin ports and AMD64_LINUX, explains
the changes made to the compiler and runtime, and documents the
bootstrap process required to transition from the old ex_frame
(setjmp/longjmp) implementation to the new ex_stack (C++
throw/catch) implementation.
CM3 has historically used a portable "frame-linked" exception handling
mechanism (ex_frame) in which:
- Each
TRYblock pushes ajmp_bufonto a per-thread singly-linked list. RAISEwalks that list, callinglongjmpto jump to the matching handler.m3frontcompilesTRY/EXCEPTviaCompile2, which emitssetjmpcalls and the associated frame setup/teardown.
This approach is fully portable but has non-trivial overhead on every
TRY entry (a setjmp call) and does not interact with C++
destructors or other language runtimes sharing the same stack.
The ex_stack implementation replaces this with C++ zero-cost
exception handling:
RAISEcallsRTStack__ThrowM3Exc, a C++ function that executesthrow _M3Exc{act}, putting the M3 activation record into a C++ exception.- The C++ runtime unwinds the stack using platform DWARF/EH tables,
landing in
catch (_M3Exc& _m3exc)clauses thatM3C.m3generates around every call site inside aTRYblock. m3frontcompilesTRY/EXCEPTviaCompile1, which emitsCG.Start_try()/CG.End_try()/CG.Invoke_direct()/CG.Landing_pad()instead of setjmp.
The result is that TRY blocks have zero overhead when no exception
is raised; cost is only incurred during unwinding.
The ex_stack EH model requires the C backend.
Compile1 in m3front emits CG.Start_try(), CG.End_try(),
CG.Invoke_direct(), and CG.Landing_pad(). These operations are
implemented only in M3C.m3 (the C code generator). They cause
M3C.m3 to emit C++ try { … } catch (_M3Exc& _m3exc) { … } blocks,
which the platform C++ compiler (clang/g++) then lowers to DWARF EH
tables.
The native x86 assembly backend (M3_BACKEND_MODE = "3", used by
M3x86) does not implement these operations. Enabling
M3_USE_STACK_WALKER with a native backend would crash the compiler
at code-generation time on any TRY statement.
Always pair M3_USE_STACK_WALKER = TRUE with M3_BACKEND_MODE = "C".
Both Darwin targets discussed here use M3_BACKEND_MODE = "C", as
does AMD64_LINUX.
Declares Has_stack_walker = TRUE and the extended FrameInfo record
that holds libunwind cursor state (cursor, start_ip, end_ip,
lsda, persFn, landingPad, excRef, tTypeIndex). The
Has_stack_walker constant is hardcoded TRUE for ARM64_DARWIN,
meaning the runtime uses C++ throw/catch support when ex_stack is
selected.
Implements all stack-walking primitives using Apple's built-in
libunwind (part of libSystem.B.dylib; no extra link flags needed):
-
RTStack__CurFrame— captures the current unwinder cursor. -
RTStack__PrevFrame— steps one frame up the stack. -
RTStack__Unwind— resumes execution at a specific landing pad (used in the manual-unwind path; not used by the C++ throw path). -
RTStack__ThrowM3Exc(void* act)— the key entry point. Defined outsideextern "C"so the C++ compiler's full EH machinery is engaged:struct _M3Exc { void* act; }; extern "C" void RTStack__ThrowM3Exc(void* act) { throw _M3Exc{act}; }
The
_M3Excstruct must be declared in the same translation unit that contains thecatch (_M3Exc& …)clauses.M3C.m3emits the struct definition at the top of every generated.cfile.
The AMD64_DARWIN port adds the corresponding x86_64 macOS runtime
description. Like the ARM64 version, it declares Has_stack_walker = TRUE and uses the same FrameInfo layout for libunwind state.
Implements the same stack-walking primitives and RTStack__ThrowM3Exc
entry point for AMD64_DARWIN.
The implementation is effectively identical to the ARM64 file: Apple's
libunwind API on x86_64 macOS is the same interface used on arm64, and
the exception throw path is the same C++ throw _M3Exc{act} model.
All five verification tests pass under Rosetta 2.
Implements RTException.Raise and RTException.ResumeRaise for the
ex_stack runtime. Key points:
-
The
RaiseActivationrecord must be heap-allocated before throwing, because the C++ unwinder destroys the original stack frame before landing in the catch clause. A stack-allocatedactwould dangle:excRef := NEW(REF RaiseActivation); excRef^ := act; RTStack.ThrowM3Exc(ADR(excRef^)); -
RTStack.ThrowM3Excnever returns; the call toInvokeBackstopbelow it is an unreachable safety net only. -
The assertion
<*ASSERT RTStack.Has_walker*>at module initialisation verifies thatex_stackis never accidentally linked into a build that hasHas_stack_walker = FALSE.
Previously had the libunwind frame-walking primitives but was missing
the C++ throw entry point. Added struct _M3Exc { void* act; }
(outside the extern "C" block) and RTStack__ThrowM3Exc (after the
closing extern "C" brace), identical in structure to the Darwin
versions.
The HAS_STACK_WALKER quake table controls which targets can use
ex_stack:
readonly HAS_STACK_WALKER = {
"AMD64_LINUX" : TRUE,
"AMD64_DARWIN" : TRUE,
"ARM64_DARWIN" : TRUE,
}When M3_USE_STACK_WALKER = TRUE is set in the platform config
and the target appears in HAS_STACK_WALKER,
include_dir("ex_stack") and include_dir(TARGET) are used instead
of include_dir("ex_frame").
The Compile1 code path (enabled by Target.Has_stack_walker, set
from the M3_USE_STACK_WALKER config variable) required three
correctness fixes.
Problem: The original Compile1 passed last := TRUE to
CompileHandler1 for the final handler in a TRY/EXCEPT chain. With
last = TRUE the type check was skipped, so the catch clause accepted
any M3 exception — silently routing unmatched exceptions (e.g.,
OSError.E) to the wrong handler (e.g., Thread.Alerted).
Fix:
- Always pass
last := FALSEtoCompileHandler1, ensuring every handler emits an explicit type check. - Add a mandatory
ELSEbranch at the end of the handler chain. When no handler matches, theELSEbranch callsRTHooks.ResumeRaiseExto re-throw the exception so enclosingTRYblocks or the module-body backstop can handle it.
Without this fix, misrouted exceptions produced "Fatal Error: interrupted" instead of the correct runtime error.
Problem: CG.End_try() was placed after CG.Set_label(l+1) —
putting the FINALLY body inside the try region. Any exception
raised in the FINALLY block (including the re-raise of the original
exception) would be caught by the same catch clause, causing
infinite recursion.
Fix: Move CG.End_try() to just before CG.Set_label(l+1) so
the FINALLY body executes outside the try region.
Problem: The same End_try placement issue in the LOCK
statement's Compile1 path. The mutex-release code and any exception
handler ran inside the try region.
Fix: Move CG.End_try() before CG.Set_label(lab+1) so the
lock-release and handler code are outside the try region.
Problem: RTStack.ThrowM3Exc is declared <*EXTERNAL*> with C
calling convention and returns void. Without special treatment the C
code generator did not mark it as [[noreturn]], so the C compiler
emitted spurious "control reaches end of non-void function" warnings
and potentially generated incorrect code for the callers of
RTExStack.Raise.
Fix: Detect RTStack__ThrowM3Exc by name in import_procedure and set
proc.no_return := TRUE, causing M3C.m3 to emit the
__attribute__((noreturn)) (or [[noreturn]]) annotation in the generated
C prototype.
The relevant fields and code added to M3C.m3:
(* in the T record fields, ~line 96 *)
RTStack_ThrowM3Exc_id : M3ID.T;
(* in begin_unit, ~line 3155 *)
self.RTStack_ThrowM3Exc_id := M3ID.Add("RTStack__ThrowM3Exc");
(* in import_procedure, ~line 6096 *)
ELSIF name = self.RTStack_ThrowM3Exc_id THEN
proc.no_return := TRUE;
no_return(self);
The start_try, end_try, invoke_direct, invoke_indirect, and
landing_pad procedures in M3C.m3 emit the C++ try/catch structure.
invoke_direct replaces call_direct inside a try region: instead of
a plain C function call it emits a C++ try { f(…); goto next; } catch (_M3Exc& _m3exc) { goto handler; } pattern.
Both Darwin targets use the same config requirements for ex_stack:
M3_BACKEND_MODE = "C" % must be before includes
M3_USE_STACK_WALKER = TRUE % must be before includes
% ... include target/common config here ...
% Keep Rosetta-based bootstrap builds single-threaded.
M3_PARALLEL_BACK = 1
% libunwind is part of libSystem.B.dylib on macOS — no extra link flags.
SYSTEM_LIBS{"UNWIND"} = []M3_BACKEND_MODE and M3_USE_STACK_WALKER must be set before the
target config includes so the guards in the common Darwin config files
see the correct values.
SYSTEM_LIBS{"UNWIND"} = [] must be set unconditionally (outside any
if defined("M3_USE_STACK_WALKER") guard) so that the import_sys_lib ("UNWIND") call in m3core/src/runtime/m3makefile does not fail with
a "does not define a value for UNWIND" error.
On AMD64_DARWIN, M3_PARALLEL_BACK = 1 must come after the
includes, because AMD64.common resets it to 20.
Transitioning from ex_frame to ex_stack cannot be done in a single
step because the old compiler binary (statically linked with the old
m3back) cannot compile the new RTExStack.m3. A two-phase bootstrap
is required.
Goal: produce a new cm3 binary whose statically-linked m3back
contains all our fixes (noreturn detection, start_try/end_try
generation), but is still linked against the safe ex_frame runtime.
-
Temporarily disable
M3_USE_STACK_WALKERin the platform config. This causesm3core/src/runtime/m3makefileto selectex_frame; the newRTExStack.m3(which importsRTStack.ThrowM3Exc) is never compiled, avoiding the assertion in the old binary's m3back. -
Using the Stage 3 backup binary:
cd m3-libs/m3core && cm3 -clean && cm3 -build && cm3 -ship cd m3-libs/libm3 && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3middle && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3linker && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3front && cm3 -clean && cm3 -build && cm3 -ship # includes TryStmt/LockStmt/TryFinStmt fixes cd m3-sys/m3quake && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3objfile && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3back && cm3 -clean && cm3 -build && cm3 -ship # includes noreturn fix cd m3-sys/cm3 && cm3 -clean && cm3 -build && cm3 -shipThe resulting
cm3binary has all fixes compiled in and links against theex_frameruntime — it is fully functional for Phase 2. -
Install the new binary as the active
cm3.
The Stage 3 backup binary has an old m3back statically linked that
contains an assertion:
<* ASSERT NOT proc.is_RTException_Raise *>
This fires because RTException.Raise (declared in RTExStack.m3) is
detected as is_RTException_Raise in declare_procedure. In the old
ex_frame implementation, Raise had a setjmp-based body that the
old m3back knew how to handle. The new ex_stack body — which calls
RTStack.ThrowM3Exc inside a RAISES ANY scope — triggers a
different code-generation path in old m3back that trips the assertion.
The Phase 1 bootstrap sidesteps this by never asking the old m3back to
compile RTExStack.m3 with M3_USE_STACK_WALKER active. Once the
Phase 1 binary is built, its internal m3back has the fixed code and
does not contain the problematic assertion.
Goal: produce a fully ex_stack cm3 binary.
-
Re-enable
M3_USE_STACK_WALKER = TRUEin the platform config. -
Using the Phase 1 binary (built above), rebuild in the same order:
cd m3-libs/m3core && cm3 -clean && cm3 -build && cm3 -ship # ex_stack runtime cd m3-libs/libm3 && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3middle && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3linker && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3front && cm3 -clean && cm3 -build && cm3 -ship # Compile1 path active cd m3-sys/m3quake && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3objfile && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/m3back && cm3 -clean && cm3 -build && cm3 -ship cd m3-sys/cm3 && cm3 -clean && cm3 -build && cm3 -shipBecause the Phase 1 binary has the fixed m3back (with noreturn detection), it compiles
RTExStack.m3and theRTStack.ThrowM3Excimport without any assertion failures. -
Install the resulting
cm3binary. It now uses C++ zero-cost EH throughout.
A simple sanity test after Phase 2:
MODULE Main;
IMPORT IO, OSError, FileRd, Thread;
EXCEPTION LocalExc(TEXT);
BEGIN
(* Basic raise/catch *)
TRY RAISE LocalExc("hello") EXCEPT LocalExc(m) => IO.Put("caught: " & m & "\n") END;
(* Cross-exception dispatch — must NOT catch wrong type *)
TRY
TRY RAISE LocalExc("x") EXCEPT OSError.E => IO.Put("FAIL\n") END;
EXCEPT LocalExc => IO.Put("reraise ok\n") END;
(* FINALLY must run even when an exception propagates through *)
VAR ran := FALSE;
BEGIN
TRY TRY RAISE LocalExc("f") FINALLY ran := TRUE END EXCEPT LocalExc => END;
IF ran THEN IO.Put("finally ok\n") ELSE IO.Put("FAIL\n") END;
END;
(* LOCK must release the mutex when an exception escapes *)
VAR mu := NEW(MUTEX);
BEGIN
TRY LOCK mu DO RAISE LocalExc("lock") END EXCEPT LocalExc => IO.Put("lock ok\n") END;
END;
(* Thread.Alerted must be caught by the correct handler *)
TRY
Thread.Alert(Thread.Self()); Thread.AlertPause(0.0d0);
EXCEPT Thread.Alerted => IO.Put("alerted ok\n") END;
END Main.
These five checks all pass on ARM64_DARWIN, and also on
AMD64_DARWIN under Rosetta 2.
AMD64_LINUX is now fully ported.
Previously it had Has_stack_walker = TRUE in its RTMachine.i3 and
was listed in HAS_STACK_WALKER, but was missing
RTStack__ThrowM3Exc and the config wiring needed to activate
ex_stack.
The completed work:
- Added
struct _M3Exc { void* act; }(outsideextern "C") andRTStack__ThrowM3Exc(after the closingextern "C"brace) tosrc/runtime/AMD64_LINUX/RTStackC.c - In
m3-sys/cminstall/src/config/AMD64_LINUX: enabledM3_USE_STACK_WALKER = TRUE, added a guard that setsM3_BACKEND_MODE = "C"before the includes, and a post-include guard that overridesSYSTEM_CCtog++ -x c++and setsSYSTEM_LIBS{"UNWIND"}
Prerequisite: libunwind-dev must be installed:
apt-get install libunwind-dev # Debian/Ubuntu
dnf install libunwind-devel # Fedora/RHELThe config structure uses a single toggle:
% Comment out to revert to the native GCC backend (mode "3").
M3_USE_STACK_WALKER = TRUE
if defined("M3_USE_STACK_WALKER")
M3_BACKEND_MODE = "C" % must be before includes
end
include("AMD64.common")
include("Linux.common")
if defined("M3_USE_STACK_WALKER")
% M3C.m3-generated files and RTStackC.c both use C++ EH constructs.
SYSTEM_CC = "g++ -g -m64 -fPIC -x c++"
SYSTEM_LIBS{"UNWIND"} = [ "-lunwind-x86_64", "-lunwind" ]
endNote that SYSTEM_LIBS{"UNWIND"} does not need to be set outside
the guard on Linux (unlike Darwin) because import_sys_lib("UNWIND")
in m3makefile is itself inside the if M3_USE_STACK_WALKER block;
the UNWIND entry is only looked up when ex_stack is active.
AMD64_DARWIN is now fully ported.
The completed work includes:
m3-libs/m3core/src/runtime/AMD64_DARWIN/RTMachine.i3m3-libs/m3core/src/runtime/AMD64_DARWIN/RTStackC.cm3-libs/m3core/src/runtime/AMD64_DARWIN/m3makefile- adding
"AMD64_DARWIN" : TRUEtoHAS_STACK_WALKER - setting
M3_BACKEND_MODE = "C"andM3_USE_STACK_WALKER = TRUEin theAMD64_DARWINconfig
All five verification tests pass under Rosetta 2.
Any target using M3_BACKEND_MODE = "C" with a C++ compiler that
supports DWARF EH is a candidate. The native x86 backend
(M3_BACKEND_MODE = "3") cannot be used.