ghidra-planc - decompiling ND PLANC-MC (MC68000) code in Ghidra¶
Tooling to make Ghidra's decompiler produce sane C from firmware compiled by Norsk Data's
PLANC-MC compiler for the MC68000 - for example the ENCOS Ethernet II controller firmware
(Installation\Communication\Ethernet\x\stripped\encos-ser-all-banks-68k.bin).
Out of the box Ghidra gets this code badly wrong, because PLANC-MC uses two conventions the stock 68000 compiler spec does not know about. Both are documented by ND and both are verified in the binary.
The two problems¶
1. The skip return¶
A PLANC routine that terminates normally returns to RETLINK + 2, not RETLINK. The two bytes immediately after every call are the error path, and are not executed on success.
"If the routine terminates normally (not ERRETURN), this address is incremented by two (bytes) when returning (also called skip return)." -
Reference-Manuals\500\ND-820026.1 EN DOMINO and NUCLEUS Software Guide.mdline 3432"an ERRETURN exit will return according to LINK (in stack upper part) while normal return jumps back to LINK + 2." -
Reference-Manuals\ND-60.117.5 EN PLANC Reference Manual.mdline 12390
So the compiled shape is:
bsr.w SomeRoutine
jmp (A5) ; 4E D5 - 2-byte ERROR slot, NOT executed on success
<normal return lands here>
The epilogue that performs it:
movea.l (SP)+,A6 ; 2C 5F restore caller frame pointer
movea.l (SP)+,A2 ; 24 5F pop the RETURN ADDRESS into A2
jmp (0x2,A2) ; 4E EA 00 02 -> retaddr + 2
Verified in the ENCOS image: 400+ of these epilogues, and zero routines that return to +0
except the runtime unwind routine #XRET itself.
A5 permanently holds the runtime error vector - #XRET (propagate the error up one frame) or
#ERET (dispatch to an ON ROUTINEERROR handler). In the ENCOS image A5 is loaded in exactly five
places: the three process roots, plus #XRET and #ERET re-arming it.
Consequence in Ghidra: it falls through into the error slot, so the real next instruction is
never disassembled, and it decompiles the epilogue as an indirect call -
(**(code **)(in_stack_00000000 + 2))() with "Could not recover jumptable" warnings - instead of a
return.
Note: leaf runtime routines do not do this. #IMU, #IDV, #APPD, #REMV and friends are
hand-written, take register arguments, and end in a plain rts. Calls to those must NOT be skipped.
The script distinguishes them automatically.
2. The calling convention¶
PLANC-MC does not pass arguments on A7. The leading argument arrives in D0 (scalars <= 32 bits)
or A0 (everything else, as a pointer); further arguments are written by the caller into the
callee's frame from offset 24B (octal) = 0x14 upward. Results come back in D0 (integers,
booleans, enums, pointers-as-values) or A0 (REAL8, POINTER, ARRAY/RECORD/SET addresses).
Frames are bump-allocated in a separate arena, not on A7, and are never popped. Only the (saved A6, return address) pair lives on the machine stack. A caller can therefore still read secondary results out of a callee's frame after the call returns - a real PLANC-MC idiom, and how multi-value returns work.
Files¶
| File | What it does |
|---|---|
PlancFixFlow.java |
Fixes problem 1. Ghidra script. Flow only - it renames nothing and sets no types. |
PlancUndoFixFlow.java |
Reverts PlancFixFlow. |
PlancApplyNdSymbols.java |
Applies ND's embedded vendor symbol table - creates and names functions from it. Run AFTER PlancFixFlow. |
PlancAnnotate.java |
Comments error slots, unwinds, prologues, ON ROUTINEERROR; adds tags. |
PlancSetupTypes.java |
Data types, namespaces, register signatures for the leaf runtime. |
PlancApplyConvention.java |
Sets __planc on every PLANC routine (needs the cspec snippet installed). |
PlancFrameTypes.java |
Retypes frame pointers to PlancFrame* (decides ERRCODE width per routine - see below). |
PlancDumpDecomp.java |
Read-only. Dumps the decompiled C of every function to one text file, so analysis can continue while the GUI is closed. Headless: -postScript PlancDumpDecomp.java <outFile> [timeoutSec]. |
M68kVectorTable.java |
Lays out the 68000 exception vector table at 0x000-0x3FF. |
planc-68000.cspec-snippet.xml |
Addresses problem 2. A __planc prototype model to paste into the 68000 compiler spec. |
PlancApplyNdSymbols.java and the stale-body trap¶
ND's firmware images carry a vendor symbol table of 32-byte records near the top of the image, giving ND's own name and address for every CODE and DRAM symbol. Mining it is the highest-value first move on any of these binaries.
+0x00 4 self/next pointer, increments by 0x20
+0x04 1 name length (1..12)
+0x06 1 0x02 = defined, 0xFF = undefined / marker
+0x07 1 segment: 0x10 = CODE, 0x16 = DRAM, 0x11 = other
+0x08 4 address, big-endian
+0x10 12 name (10 characters in practice)
In tcp-ser-all-banks-b05-68k.bin the table runs 0x7C3A0-0x7FD88: 463 slots, of which
436 are defined (317 CODE + 119 DRAM) and 27 are kind=0xFF NIL/NONE markers that are skipped.
Verify the base by the +0 pointer stride, never by eye. An earlier revision of this note (and
the script default) claimed the base was 0x7C3A4 ("4 bytes later than ENCOS"). That is WRONG: at
+4 the name-length byte reads 0 and the parser rejects EVERY record - a dry run on 2026-08-08
parsed 0. The correct base 0x7C3A0 is pinned because the +0 self/next pointer steps by exactly
0x20 across records. If a symbol pass reports "0 records parsed", the base is off - shift it and
re-check the stride; do not conclude the table is missing. The ENCOS table (encos-ser, @0x663E0)
uses a DIFFERENT, shorter record shape (name length at +0, no leading pointer) - the two images are
not interchangeable.
The trap this script exists to avoid: Ghidra does not recompute an existing function's body
when control flow changes underneath it, and createFunction() on an existing entry point returns
"already exists" without recomputing. So a function created before PlancFixFlow keeps its old -
often 1-byte - body, and naming it merely pins the damage. Applying ~20 names this way is how the
problem was found.
The script therefore does, per symbol: disassemble the entry point -> remove any existing function
there -> create it afresh -> name it. It uses setName rather than createLabel, so re-runs do
not stack duplicate labels.
Run PlancFixFlow first. Running this against unrepaired flow produces correct-looking functions
with truncated bodies.
Running headless¶
Both scripts take the apply/dry-run decision from a script argument when there is no GUI, so the
same file serves both modes - there is no separate *Headless.java copy to keep in sync.
analyzeHeadless.bat <projectDir> <projectName> -process <program> ^
-scriptPath C:\Users\ronny\ghidra_scripts ^
-postScript PlancFixFlow.java apply -noanalysis
Omit the apply argument for a dry run - that is the safe default, so a mistyped command cannot
modify the program. Ghidra must be fully closed or the project lock rejects the run.
If the scripts do not appear in the GUI Script Manager, do not debug the Java - check that
$USER_HOME/ghidra_scripts is still registered under Bundle Manager. Deleting the
AppData\Roaming\ghidra\<version>\osgi folder de-registers every script directory along with the
compiled-bundle cache. Delete only osgi\felixcache and osgi\compiled-bundles if a cache ever
needs clearing.
Installing and running the script¶
- In Ghidra: Window > Script Manager, then the "Manage Script Directories" button (top right).
- Add this folder:
E:\Dev\Ronny\NDInsight\tools\ghidra-planc - Refresh the script list. The scripts appear under category ND.PLANC.
- Open the program, then run PlancFixFlow.java.
Set DRY_RUN = true at the top of the script to see the report without changing anything. Run it
that way first.
What it does, in four passes¶
- Finds every function whose body contains the
4E EA 00 02epilogue - these are the skip-return callees. - For every call whose target is one of those, overrides the fallthrough to skip the 2-byte error slot.
- Marks every
4E EA 00 02as aRETURNviaFlowOverride. - Disassembles the code that was unreachable until now.
Safety¶
- Conservative. A fallthrough is only overridden when the callee is proven to use the +2
epilogue AND the bytes being skipped look like an error slot (
4E D5, or a shortBSR0x61xx). Anything else is reported, not changed. - Reversible. Run
PlancUndoFixFlow.java, or in the GUI clear the fallthrough override (right-click > Fallthrough > Auto) and set Flow Override back to None. - Idempotent. Re-running changes nothing further.
Read the warnings it prints. A call into a skip-return function whose following bytes are not an error slot is either a mis-identified callee or something genuinely unusual - worth a look either way.
Installing the calling convention¶
planc-68000.cspec-snippet.xml contains a <prototype name="__planc"> block. Paste it into the
<prototype> list of your Ghidra 68000 compiler spec:
<GHIDRA_INSTALL>\Ghidra\Processors\68000\data\languages\68000.cspec
Back the file up first, restart Ghidra, then set it per function via
right-click > Edit Function > Calling Convention > __planc, or with the
set_function_prototype MCP tool.
Honest limits. The convention gets the return value, the register preservation (A5 and A6 unaffected) and the leading argument right. It cannot express "second and subsequent arguments live in the callee's frame at 0x14 upward", because Ghidra's prototype model assumes a stack-pointer-relative argument area and PLANC frames are not on A7. Those arguments stay manual.
Reading the decompiler output¶
Even after both fixes, two idioms remain. They are not errors - learn to read them:
| You see | It means |
|---|---|
piVar1[n] where piVar1 = (int *)*unaff_A6 |
PLANC frame slot at byte offset n*4 from A6. [5] = (0x14,A6) = first parameter or first local. |
piVar1[2] = (int)&stack0x... |
the prologue parking SP at frame offset 0x8 |
*piVar1 = (int)(piVar1 + 7) |
the prologue publishing its own next-free cursor at frame offset 0; the 7 encodes the frame size (7*4 = 0x1C) |
jmp (A5) reached in the listing |
error unwind to #XRET/#ERET, not a jump table and not a coroutine yield |
Frame layout (A6-relative, from ND-820026.1 Figure 8, cross-checked against the binary)¶
| Offset | Field | Notes |
|---|---|---|
+0x00 |
STP |
next-free cursor; the prologue's movea.l (A6),A6 follows it |
+0x04 |
reserved / frame limit | overflow guard written by the frame allocator |
+0x08 |
SMAX / saved SP |
read by #XRET and by hand-written interrupt epilogues |
+0x0C |
SYST |
PLANC runtime use |
+0x10 |
(see caveat below) | |
+0x14 |
ERRCODE / first param |
see caveat |
+0x14 onward |
parameters then locals | packed by declared size |
@A7+0 |
PREV |
previous A6 |
@A7+4 |
RETLINK |
return address; normal return goes to +2 |
RESOLVED 2026-07-30: the first parameter is at 0x12 OR 0x14, per routine¶
The table above says the first parameter sits at 0x14. PlancFixFlow's own console note says
0x12. Both are correct - the offset is decided by the width of ERRCODE at +0x10, and a
single image can contain both conventions.
Measured in tcp-ser-all-banks-b05-68k.bin (211185 TCP/IP B05), zero overlap between the two:
| ERRCODE store | Width | Sites | Address range | First parameter |
|---|---|---|---|---|
move.w D0w,(0x10,A6) |
2 bytes | 4 | 0x023E0-0x03516 (PIOC-OS kernel) |
0x12 |
move.l D0,(0x10,A6) |
4 bytes | 49 | 0x07E9E-0x2082E (LANCE, AIP, TCP, SKP, XMSG, ports) |
0x14 |
Confirmed from both sides: callee 0x28E6 does lea (0x12,A6),A0 and its callers stage arguments
at 0x12/0x16/0x1A/0x1E (0x27E8, 0x285E, 0x2ED2, 0x31E8); PORTSEND reads 0x14/0x18/
0x1C/0x20 and its handler stores ERRCODE as a longword.
Cause: the documented version F word-size change (2 -> 4 bytes). This image is a pre-F PIOC-OS linked against a version-F-or-later TCP program - the same reason the PIOC-OS region is byte- identical to ENCOS.
Consequence for PlancFrameTypes: it must decide the ERRCODE width per routine, by looking
at the store into (0x10,A6), not apply one offset globally. A global choice is wrong for roughly
one region or the other in any mixed image.
Caveat, still unresolved: the two ND manuals disagree about slots 0B and 4B. ND-820026.1
(valid from compiler version H) says STP then Unused; ND-60.117.5 (version G era) says
PREVB then STP. Slots 10B/14B/20B/24B agree in both. The ENCOS binary matches the
ND-820026 reading. ND-820026.1:5792 documents a deliberate calling-sequence change between
versions G and H (OPTION 2), which is the likely cause.
Sources¶
Reference-Manuals\500\ND-820026.1 EN DOMINO and NUCLEUS Software Guide.md- Figure 8 (ordinary frame), Figure 9 (NATIVE frame), the skip-return statement at 3432,#XRET/A5 at 3436, SPECIAL / NATIVE / exception-handler rules at 5804 and 5846. The citation of record - the-1c-revision lost the<-- A6/<-- A7figure annotations.Reference-Manuals\ND-60.117.5 EN PLANC Reference Manual.md- appendix 0.4 "Interfacing with PLANC on the MC68000": frame table, out-value registers, the LINK+2 rule.Reference-Manuals\ND-20034-1-EN ND-Specific Programming & Advanced PLANC.md- section 4.4 stacks, 4.5 parameter transfer (arrays as {virtual origo, lower, upper} descriptors, records as pointers). Section 4.5.1 is ND-500 only - do not apply it to MC68000.Installation\Communication\Ethernet\x\stripped\docs\ENCOS-FIRMWARE-SYMBOL-TABLE-2026-07-26.md- the vendor symbol table, including the PLANC runtime routines#XRET,#ERET,#APPD,#REMV,#IMU,#IDV.
Do not apply the ND-500 or ND-100 PLANC conventions to MC68000 code. All three differ. ND-500
signals errors with a status bit (IF K RET after the call), ND-100 uses a skip return like the
68000 but via 5LEAV/5ERET, and only the MC68000 uses the A5/#XRET vector.