SpectNet IDE

Visual Studio 2017/2019 integrated ZX Spectrum IDE for the Community

Z80 Assembler » Macros

The SpectNetIDE assembler provides you a powerful way to declare macros, and apply them in the code. While in most programming languages and assemblers the macros are preprocessor constructs and use simple text replacement, the SpectNetIDE implementation is different.

Unlike in C, C++ (and some Z80 Assemblers), SpectNetIDE macros emit only code (through instructions and pragmas), they cannot be used as user-defined functions. When you pass parameters to macros, any expression in the parameters is evaluated instantly, so you cannot use unknown symbols or variables — ones that will get their values only somewhere later in the code.

Getting Started with Macros

The best way to show you what macros can do is real code. Let’s start with a simple parameterless macro:

Delay: 
    .macro()    
    DelayLoop:
        djnz DelayLoop
    .endm

You can easily use this macro in your code:

ld b,#24
Delay()
; ...and later
ld b,#44
Delay()

The assembler will emit the code like this:

ld b,#24
DelayLoop_1: djnz DelayLoop_1
; ...and later
ld b,#44
DelayLoop_2: djnz DelayLoop_2

As you expect, it takes care that the DelayLoop label remains local within the scope of the macro; otherwise it would lead to a duplicated label name.

This macro is named Delay, and it uses the value of the B register to create a djnz loop. You can easily apply this macro

Now, let’s enhance this macro with an argument:

Delay: 
    .macro(wait)    
        ld b,{{wait}}
    DelayLoop:
        djnz DelayLoop
    .endm

As the body of the macro suggests, `` is a placeholder for the wait argument. While other assemblers do not use a separate markup for a placeholder — they’d just use waitSpectNetIde applies this markup for two reasons: first, it is visually better and more eye-catching; second, it allows the compiler to provide better performance.

You can use this macro passing an argument value for wait:

Delay(#24)
Delay(d)

As you expect, the compiler now emits this code:

ld b,#24
DelayLoop_1: djnz DelayLoop_1
; ...and later
ld b,d
DelayLoop_2: djnz DelayLoop_2

Macros allow you to pass anything that could be an operand in a Z80 instruction, so this is entirelly valid:

Delay((ix+23))

As you can imagine, this macro invocation results as if you wrote this:

ld b,(ix+23)
DelayLoop: djnz DelayLoop

SpectNetIDE macros do not stop here. You can define macros that recveive an entire Z80 instruction as an argument:

RepeatLight: 
    .macro(count, body)    
        ld b,{{count}}
    DelayLoop:
        {{body}}
        djnz DelayLoop
    .endm

This macro is to repeat the body in count number of times. This is how you can invoke it:

RepeatLight(4, "add a,c")

Observe, the second argument of the macro is a string that names the add a,c operation. The result of this macro is this set of instructions:

ld b,4
DelayLoop_1: 
  add a,c
djnz DelayLoop_1

Instead of a run time loop, you can apply a compile time loop within the macro:

RepeatLight: 
    .macro(count, body)
      .loop {{count}}
          {{body}}
      .endl
    .endm

The RepeatLight(3, "add a,c") line invokes the macro and the macro’s body translates to this:

.loop 3
    add a,c
.endl

As you already learned, the compiler handles this as if you wrote:

add a,c
add a,c
add a,c

SpectNetIDE allows you to pass a set of lines as a macro argument. You can invoke RepeatLight like this:

RepeatLigth(3, "add a,c" & "add a,10")

Or, you can make it with variables:

FirstOp = "add a,c"
SecondOp = "add a,10"
RepeatLight(3, FirstOp & SecondOp)

The & operator between the two string value concatenates them with a next line character set (#0A and #0D). If you’d apply the + operator, the above code would fail: the assembler accepts only a single instruction in a text line, and would reject multiple instructions.

In the context of macros, you can use several special functions, such as lreg() and hreg(). These work during parse time, and retrieve the lower register, and higher register of an 16-bit register pair:

LdHl: 
    .macro(reg16)
        ld h,hreg({{reg16}})
        ld l,lreg({{reg16}})
    .endm

Here, you can apply the LdHl macro like this:

LdHl(de)
LdHl(bc)

The compiler translates these macro invocations into these Z80 instructions:

ld h,d
ld l,e
ld h,b
ld l,c

When you invoke a macro, you can pass less parameters than the macro declares. Within the macro body, you can use the def() function to check whether the specified parameter has been passed:

Push:
    .macro(r1, r2, r3, r4)
      .if def({{r1}})
        push {{r1}}
      .endif
      .if def({{r2}})
        push {{r2}}
      .endif
      .if def({{r3}})
        push {{r3}}
      .endif
      .if def({{r4}})
        push {{r4}}
      .endif
    .endm

The Push macro in this code snippet allows you to create a push instruction for up to 4 register pairs. Look at these usages:

Push(af)
; ... and later
Push(bc, de, ix)

This is what the compiler generates:

push af
; ... and later
push bc
push de
push ix

You can opt to not pass a macro parameter for a specific argument. Look at this macro declaration:

LdBcDeHl:
    .macro(bcVal, deVal, hlVal)
      .if def({{bcVal}})
        ld bc,{{bcVal}}
      .endif
      .if def({{deVal}})
        ld de,{{deVal}}
      .endif
      .if def({{hlVal}})
        ld hl,{{hlVal}}
      .endif
    .endm

You can invoke this macro in these ways, leaving a parameter empty to sign that you do not intend to use it:

LdBcDeHl(,#1000,#2000)
; ... and later
LdBcDeHl(#3000,,#4000)

The compiler understands your intention and generates this output:

ld de,#1000
ld hl,#2000
; ... and later
ld bc,#3000
ld hl,#4000

It’s time to deep into the nitty-gritty details of creating and using macros in SpectNetIDE.

Macro Declaration

Macros must have a name. Each macro is named according to the label preceding its declaration either in the same line as the .macro token, or before it as a hanging label. Macros can have zero, one, or more named arguments separated with a comma. The macro declaration is closed with the .endm token:

MacroWithHangingLabel:
    .macro(myParam, otherParam)
    ; Macro body
    .endm

MyMacro: macro()
    ; Macro body
    .endm

Even if a macro does not have arguments, its declaration must contain the parentheses.

The macro body can contain Z80 instructions, pragmas, or statements. The only exception are the ENT and XENT pragmas.

As you already experienced, the assembler supports syntax variants for the macro-related keywords. The compiler accepts these tokens: .macro, macro, .MACRO, MACRO, .endm, endm, .ENDM, ENDM, .mend, mend, .MEND, and MEND.

Within the macro’s body, you can refer to the arguments of the macros wrapping them into double curly braces:

Mul10:
    .macro(reg8)
    push af
    ld a,{{reg8}}
    add a,a
    push bc
    ld b,a
    add a,a
    add a,a
    add a,b
    pop bc
    ld {{reg8}},a
    .endm

Arguments are identifiers, thus the corresponding naming rules are applied to them. You cannot use a reserved word (for example a mnemonic like ldir or a register name like hl) as a macro argument.

Macro Parameters

You can invoke a macro with as many parameters as many argument its declaration has, or even with less parameters. If the macro invocation has more parameters than arguments, the compiler raises an error.

Let’s assume, you’ve created this macro declaration:

MyMacro: .macro(arg1, arg2, arg2)
; Macro body
.endm

All of these usages are valid:

MyMacro()
MyMacro(a)
MyMacro(a, b)
MyMacro(a, b, c)

Nonetheless, these usage is invalid since it passes more than three parameters:

MyMacro(a, b, c, d) ; ERROR: To many parameters

Sometimes it is convenient to omit not the last parameters but one in the beginning or the middle of the parameter list. You can do that: an empy comma separator signs that the preceeding parameter is empty. Using this notation, all these invocations of MyMacro is valid:

MyMacro(,b)
MyMacro(a,,c)
MyMacro(,,)

Within the macro declaration, you can use the def() function to check if a particular argument has a value.

LdBcDeHl:
    .macro(bcVal, deVal, hlVal)
      .if def({{bcVal}})
        ld bc,{{bcVal}}
      .endif
      .if def({{deVal}})
        ld de,{{deVal}}
      .endif
      .if def({{hlVal}})
        ld hl,{{hlVal}}
      .endif
    .endm

The def() function accepts only a macro argument reference (the name of the argument wrapped in double curlay braces). This function evaluates to true only when the macro argument is not empty.

You can use the logical NOT operator (!) combined to def() to check if an argument is empty.

MyMacro: .macro(arg)
  .if !def({{arg}})
    ; generate something for empty arg
  .endif
.endm

Passing Parameters to Macros

You can pass anything as a macro parameter that is a valid operand of a Z80 instruction. This means the following options:

  • Names of 8-bit registers and 16-bit register pairs (e.g. a, b, ixl, hl sp, af, etc.)
  • Names of conditions (e.g. z, nz, pe, m, etc.)
  • Memory address indirection (e.g. (#4000), (#4000+#20))
  • Register pair indirection (e.g. (bc), (de), (hl), etc.)
  • Indexed indirection (e.g. (ix+#20), (iy-12), etc.)
  • C-port ((c))
  • Expression (e.g. (MyId << 1) + 23, #4000, 12*sin(pi()/4), "ld " + "a,b", etc.)

You should be careful when you use parentheses in expressions. Let’s assume, you declare this macro:

SetHlValue:
    .macro(value)
        ld hl,{{value}}
    .endm

When you use it, the first invocation uses an expression, the second has a memory address indirection:

SetHlValue(#4000+#20)
SetHlValue((#4000+#20))

The compiler translates them to these instructions:

ld hl,#4020
ld hl,(#4020)

To avoid such issues, you can use the square brackets to group parts of expressions. When you invoke the SetHlValue macro with this way, both usage with generate a ld hl,#4020 statement:

SetHlValue(#4000+#20)
SetHlValue([#4000+#20])

When you pass parameters to macros, any expression in the parameters is evaluated instantly, so you cannot use unknown symbols or variables — ones that will get their values only somewhere later in the code.

The compiler replaces the macro argument references to their current values passed in parameters. Whenever you use an expression, its value is converted into a string and put into the place of the macro argument.

Passing Instructions in a Macro Parameter

Within a macro declaration, you can use macro argument reference in stead of an entire Z80 instruction. Take a look at this macro:

ShortDi:
    .macro(body)
        di
        {{body}}
        ei
    .endm

Here, the body argument is expected to get something that the compiler can understand as an entire instruction. When you invoke the macro, you need to pass a string expression so that the compiler can replace the `` reference. Here is an example:

ShortDi("in a,(#fe)")

As you expect, the compiler generates this output:

di
in a,(#fe)
ei

You are not obliged to use Z80 instructions, the compiler accepts pragmas, too:

ShortDi(".db #00")

Well, the output is not pretty useful, nonetheless, the compiler generates this:

di
.db #00
ei

Passing Multiple Lines in a Macro Parameter

If you can pass multiple lines in a macro parameter where the corresponding argument reference is used in stead of an entire instruction line, the compiler will apply all those lines. To do that, the individual lines should be separated by new line characters (\r\n). The & operator, when applied for two strings, does this step for you, as it concatenates the two strings with \r\n between them. Let’s assume, you invoke the ShortDi macro with this code:

ShortDi("ld a,#7f" & "in a,(#fe)")

Now, the compiler will generate this output:

di
ld a,#7f
in a,(#fe)
ei

Because you can pass expressions as macro parameters, you can invoke the macro in this way, too:

FirstOp = "ld a,#7f"
SecondOp = "in a,(#fe)"
ShortDi(FirstOp & SecondOp)

You can pass not only instructions and pragmas to macros, but also statements:

LoopOp = ".loop 3" & "nop" & ".endl"
ShortDi(LoopOp)

The compiler will emit this code:

di
nop
nop
nop
ei

Labels, Symbols, and Variables in Macros

Macros have a local scope for all labels, symbols, and variables created within their body, including the label attached to the .endm statement. The name of the macro is a label that also represents the start of the macro.

Take a look at this macro definition:

GetBoundaries:
    .macro(instr)
        {{instr}}
        ld de,EndLabel
        ld hl,GetBoundaries
EndLabel:
    .endm

Here, the ld hl,GetBoundaries instruction fills HL with the start address of the macro, while the ld de,EndLabel instruction puts the address of the next instruction following the macro int DE

Let’s assume, you use the macro this way:

.org #8000
GetBoundaries("nop")
GetBoundaries("ld ix,#ABCD")

The compiler will create this output:

#8000 GetBoundaries_1 nop
#8001                 ld de,#8007 ; EndLabel_1
#8004                 ld hl,#8000 ; GetBoundaries_1
#8007 EndLabel_1
      GetBoundaries_2 ld ix,#ABCD
#800B                 ld de,#8011 ; Endlabel_2
#800E                 ld hl,#8007 ; GetBoundaries_2
#8011 EndLabel_2

Symbols and variables within the context work exactly as they do with loops. Do not forget: Symbols are constant values, while variables may change!

Invoking Macros from Macros

SpectNetIde allows you to invoke a macro from another macro, too. Here is a short sample:

Delay:
    .macro(wait)
        ld b,{{wait}}
        WaitLoop: djnz WaitLoop
    .endm

BorderPulse:
    .macro(col1, wait1, col2, wait2)
        ld a,{{col1}}
        out (#fe),a
        Delay({{wait1}})
        ld a,{{col2}}
        out (#fe),a
        Delay({{wait2}})
    .endm

Here, the BorderPulse macro uses Delay as a helper macro. The BorderPulse(2, 10, 3, 20) invocation produces this output:

ld a,2
out (#fe),a
ld b,10
WaitLoop_1: djnz WaitLoop_1
ld a,3
out (#fe),a
ld b,20
WaitLoop_2: djnz WaitLoop_2

The SpectNetIde Assembler allows using several parse-time functions with macro arguments the similar way as you can use the def() function to check whether a macro argument has been passed to the macro invocation.

These functions check if the argument is an operand the name of the function suggest. Each of them returns true, provided the function recognizes the operand; otherwise, false.

The assembler support these functions:

Name Description
isreg8std() The operand is an 8-bit register, one of these: a, b, c, d, e, h, l, i, r, xh (ixh), xl (ixl), yh (iyh), or yl (iyl)
isreg8std() The operand is a standard 8-bit register, one of these: a, b, c, d, e, h, or l
isreg8spec() The operand is a special 8-bit register, i, or r
isreg8idx() One of these 8-bit index registers: xh (ixh), xl (ixl), yh (iyh), or yl (iyl)
isreg16() Any of these 16-bit registers: af, bc, de, hl, sp, ix or iy
isreg16std() Any of the standard 16-bit registers: bc, de, hl, or sp
isreg16idx() Any of the ix or iy registers
isregindirect() The operand is one of these: (bc), (de), (hl), or (sp)
isindexedaddr() The operand is an indexed address like (ix), (iy), (ix+#12), (iy-#23), and so on
iscport() The operand is (c) (e.g. in the out (c),a instruction)
iscondition() The operand is one of these conditions: z, nz, c, nc, po, pe, p, or m
isexpr() The operand is an expression, for example: 1 + 2, #1000, myvalue + 23, etc.
isreg<reg>() The operand is the register as given in <reg>. You can use these names: a, af, b, c, bc, d, e, de, h, l, hl, i, r, xh, xl, ix, yh, yl, iy, and sp. For example, isrega() tests if the specified register is A.

When you pass 'c' as a macro argument, both the isreg8() and iscondition() parse-time functions accept it, as the 'c' token can be either an 8-bit register or a condition (carry flag is set).

Here is a short sample:

MyRegMacro: .macro(arg)
    .if isreg8({{arg}})
        ld a,{{arg}}
    .else
        .error "Only 8-bit registers are allowed"
    .endif
.endm

MyRegMacro allows using only an 8-bit register as its argument. If you provide another type of parameter, the macro raises an error.