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)
  • fn2b1w means: function takes 2 bytes, then 1 word

Each function is named according to its signature:

  • fn1b — function accepts 1 byte (8 bit) argument
  • fn2w — function accepts 2 word (16 bit) arguments
  • fn1w1b — 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:

  • aA

fn1w

void fn1w(uint16_t a);

Passing:

  • aHL

fn2b

void fn2b(uint8_t a, uint8_t b);

Passing:

  • aA
  • bL

fn2w

void fn2w(uint16_t a, uint16_t b);

Passing:

  • aHL
  • bDE

fn1b1w

void fn1b1w(uint8_t a, uint16_t b);

Passing:

  • aL
  • bDE

Note: SDCC avoids using A when mixing 8-bit and 16-bit args. It uses L instead.


fn1w1b

void fn1w1b(uint16_t a, uint8_t b);

Passing:

  • aHL
  • bE

fn3b

void fn3b(uint8_t a, uint8_t b, uint8_t c);

Passing:

  • aA
  • bL
  • c → stack

fn2b1w

void fn2b1w(uint8_t a, uint8_t b, uint16_t c);

Passing:

  • aA
  • bL
  • c → stack (pushed right-to-left)

fn3w

void fn3w(uint16_t a, uint16_t b, uint16_t c);

Passing:

  • aHL
  • bDE
  • c → stack

fn5b

void fn5b(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e);

Passing:

  • aA
  • bL
  • c, d, e → stack

SDCC uses push af + inc sp to simulate push a (Z80 has no push a).

Return Values

Unlike the calling convention for function arguments, the ABI for return values has not changed in SDCC 4.2.0+. Return values have always been returned using registers, and the convention remains consistent:

  • 8-bit values are returned in the L register (low byte of HL)
  • 16-bit values are returned in the HL register
  • 32-bit values (e.g. long, float) are returned across HL:DE with HL holding the high word and DE the low word

This applies uniformly to both the old and the new ABI.

Return Type Return Method Registers Used Notes
uint8_t / char Register L Returned in low byte of HL
Only L is significant
uint16_t / int Register HL Full 16-bit result in HL
uint32_t / long Register pair DE (low), HL (high) 32-bit value returned in HL:DE
Big endian across registers
float Register pair DE (low), HL (high) IEEE 754 32-bit float
Returned same as long

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/E or HL/DE)
  • Reduced stack usage
  • Compatible with inlined or __naked functions (if ABI respected)
  • Stack is used only when arguments exceed available registers