Inside SDCC: Part 1 – Mastering the New Z80 ABI
For the Benefit of Mr. Kite
As of SDCC 4.2.0+, the Z80 ABI (Application Binary Interface) has changed. The new calling convention improves performance by passing the first few function arguments in registers rather than on the stack, significantly reducing overhead in small or frequently called functions.
I discovered this the hard way when I recompiled my old Iskra Delta Partner code with SDCC 4.2.0+, only to find that everything broke — silently. The new ABI is now the default, and it’s not backward compatible with the previous stack-only model. I was both impressed by the optimization and frustrated by the fallout. My entire codebase — Z80 assembly wrappers, graphics drivers, and system libraries — suddenly needed revision.
I want to fully adopt the optimized ABI, so I started reviewing how arguments are passed by reading the documentation and compiling test cases. This article summarizes that information in a clearer, example-driven form — for the benefit of all fellow SDCC ABI martyrs now facing the same migration pain.
ABI overview
In the new Z80 calling convention, SDCC passes up to two arguments in registers. This table explains the basic rules of how SDCC passes function arguments under the default ABI introduced in SDCC 4.2.0 and later.
| Argument Type(s) | Register(s) Used | Notes |
|---|---|---|
First uint8_t |
A or L |
A is used if all args are 8-bit; otherwise L is used |
Second uint8_t |
L or E |
L if first was in A; E if first arg was 16-bit (HL) |
Third and later uint8_t |
Stack | No register usage; pushed right-to-left via push |
First uint16_t |
HL |
Full 16-bit register pair |
Second uint16_t |
DE |
Full 16-bit register pair |
Third and later uint16_t |
Stack | Pushed via push |
Mixed uint8_t, uint16_t |
L, DE |
A is skipped; byte goes into L, word into DE |
Mixed uint16_t, uint8_t |
HL, E |
Word in HL, byte in low byte of DE (E) |
Notes:
- For 8-bit arguments, only the low byte of the 16-bit register is used (
A,L). - Additional arguments are passed right to left on the stack.
- Structs and pointers follow the same rules based on size.
Function argument examples
The following table shows how SDCC passes function arguments under the new default ABI. Function names are abbreviated:
b= 8-bit (uint8_t)w= 16-bit (uint16_t)fn2b1wmeans: function takes 2 bytes, then 1 word
Each function is named according to its signature:
fn1b— function accepts 1 byte (8 bit) argumentfn2w— function accepts 2 word (16 bit) argumentsfn1w1b— function accepts 1 word followed by 1 byte arguments
Here is the mapping table:
| Function Signature | Name Meaning | Register Assignment | Stack Usage | Notes |
|---|---|---|---|---|
void fn1b(uint8_t a) |
1 byte | A |
— | Byte in A |
void fn1w(uint16_t a) |
1 word | HL |
— | Word in HL |
void fn2b(uint8_t a, uint8_t b) |
2 bytes | A, L |
— | First in A, second in L |
void fn2w(uint16_t a, uint16_t b) |
2 words | HL, DE |
— | First in HL, second in DE |
void fn1b1w(uint8_t a, uint16_t b) |
1 byte, 1 word | L, DE |
— | A is skipped, a in L, b in DE |
void fn1w1b(uint16_t a, uint8_t b) |
1 word, 1 byte | HL, E |
— | a in HL, b in low byte of DE |
void fn3b(uint8_t a, uint8_t b, uint8_t c) |
3 bytes | A, L; third on stack |
Yes | Only first two in registers |
void fn2b1w(uint8_t a, uint8_t b, uint16_t c) |
2 bytes + 1 word | A, L; word on stack |
Yes | Word c on stack |
void fn3w(uint16_t a, uint16_t b, uint16_t c) |
3 words | HL, DE; third on stack |
Yes | Third word always on stack |
void fn5b(uint8_t a, b, c, d, e) |
5 bytes | A, L; rest on stack |
Yes | Only two 8-bit args in registers |
fn1b
void fn1b(uint8_t a);
Passing:
a→A
fn1w
void fn1w(uint16_t a);
Passing:
a→HL
fn2b
void fn2b(uint8_t a, uint8_t b);
Passing:
a→Ab→L
fn2w
void fn2w(uint16_t a, uint16_t b);
Passing:
a→HLb→DE
fn1b1w
void fn1b1w(uint8_t a, uint16_t b);
Passing:
a→Lb→DE
Note: SDCC avoids using
Awhen mixing 8-bit and 16-bit args. It usesLinstead.
fn1w1b
void fn1w1b(uint16_t a, uint8_t b);
Passing:
a→HLb→E
fn3b
void fn3b(uint8_t a, uint8_t b, uint8_t c);
Passing:
a→Ab→Lc→ stack
fn2b1w
void fn2b1w(uint8_t a, uint8_t b, uint16_t c);
Passing:
a→Ab→Lc→ stack (pushed right-to-left)
fn3w
void fn3w(uint16_t a, uint16_t b, uint16_t c);
Passing:
a→HLb→DEc→ stack
fn5b
void fn5b(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e);
Passing:
a→Ab→Lc,d,e→ stack
SDCC uses
push af+inc spto simulatepush a(Z80 has nopush a).
Return Values
In older versions of SDCC using the sdcccall(0) ABI, function return values on the Z80 were placed in registers as follows:
uint8_t / charvalues were returned in registerLuint16_t / intvalues were returned in register pairHLuint32_t / longvalues were returned in register pairsDEHL,
whereDEcontained the high 16 bits andHLcontained the low 16 bitsfloatvalues were also returned inDEHL, using the same high/low
arrangement
The newer ABI changes this convention significantly:
| Return Type | Return Method | Registers Used | Notes |
|---|---|---|---|
uint8_t / char |
Register | A |
Returned in low byte of HLOnly L is significant |
uint16_t / int |
Register | DE |
Full 16-bit result in HL |
uint32_t / long |
Register pair | DE (low), HL (high) |
32-bit value returned in HL:DEBig endian across registers |
float |
Register pair | DE (low), HL (high) |
IEEE 754 32-bit float Returned same as long |
- A
uint8_treturn value is now placed directly intoA - A
uint16_treturn value is returned inDE - A
uint32_tvalue is split acrossHLDE, withHLholding the
high 16 bits andDEthe low 16 bits floatvalues use the sameHLDElayout
This means the new ABI effectively reverses the old 32-bit ordering and also changes the preferred registers for smaller return types. Existing assembly code written for sdcccall(0) must therefore be updated when switching to the newer ABI, especially for functions returning 8-, 16-, or 32-bit values.
Notes on push af trick
SDCC can push 8 bit value on the stack. But since Z80 has no push a, SDCC may emit:
push af ; pushes A and F
inc sp ; discard F, simulate push A
This ensures consistent behavior when passing extra 8-bit arguments on the stack.
Conclusion
The SDCC Z80 calling convention is now more efficient and modern:
- Arguments passed via registers (
A/L/EorHL/DE) - Reduced stack usage
- Compatible with inlined or
__nakedfunctions (if ABI respected) - Stack is used only when arguments exceed available registers