Pseudo-ops Considered Harmful
Published . Estimated reading time: 7 minutes.
Assembly programmers have been drawn, ever since macros were invented, to macros that encapsulate several instructions. This essay attempts to capture why, in spite of their attractiveness, I have consistently argued against them.
First, let’s make clear what we are talking about.
What is a pseudo-op
A “pseudo-op” is a macro that generates one or more assembly instructions. The name stems from it pretending to be an assembly instruction, or “operation”.
Why they are attractive
Assembly is as close to the machine as we can get, short of writing raw bytes directly; thus, it has very little structure, and overall very little for humans to latch onto.
In particular, assembly is very stingy with names. In all (sane) programming languages, every manipulation of a variable is done using that variable’s name, and thus it helps keep in memory what manipulation is being made:
input_index += 1;
By contrast, assembly forces us to write with non-descript names a lot of the time:
inc c
Pseudo-ops allow giving names to some sequences of instructions. Here is a real-world example:
This grouping aspect is also important: it helps the eye not get lost in the absolute stream of instructions.
And, pseudo-ops are also very attractive because they enable reuse of small snippets of code (like these and those), without copy-pasting them.
Pitfalls and footguns
And yet, I’d argue that for all the good they bring, pseudo-ops do overall more harm than good. We’ve seen the good, let’s explore the bad.
The pseudo-op side of assembly is a pathway to many behaviours, some consider unnatural1
First, pseudo-ops don’t behave in natural ways. For example:
jr c,
On it face, this seems logical: we check the carry flag after an instruction. And, indeed, with the following implementation, it works:
But, with the implementation we saw some time earlier (which I’ve reproduced below), the jr c, .overflow
is wrong!
The first problem of pseudo-ops is that they are leaky abstractions. Depending on how they are implemented, they can or cannot be used in ways that seem to make sense.
Which brings us to…
This is a mutation of a line Palpatine says in Star Wars Ⅲ.
Write-only programming
Writing code is easy; reading and understanding code, however, is the hard part. (Debugging involves re-reading code, even if you’ve previously written it.)
Pseudo-ops need care taken depending on how they are written; this makes code using them require, counter-intuitively, more brainpower; because you need to remember what the side effects of each pseudo-op is. This is also true of regular instructions, but there is only a single set of them, so it’s possible to learn; whereas pseudo-ops are anything but standardised across projects (and sometimes within).
For example, let’s take this innocent-looking loop:
ld hl, 0
.multiply
dec b
jr nz,
With both of the macros shown above, this breaks:
With this implementation, the
b
counter gets overwritten by the macro; and thus, the loop is infinite. This can be fixed by preserving the register:ld hl, 0 .multiply push bc pop bc dec b jr nz,
With this implementation, the
a
register gets destroyed by the macro; and thus, the result is incorrect. This can be fixed by preserving the register:ld hl, 0 .multiply push af pop af dec b jr nz,
…but note how the correct solution isn’t the same depending on how the macro is written!
And, further, adding these push
es and pop
s makes the loop roughly twice as slow. Maybe this isn’t much if done once, but pseudo-ops tend to get used pervasively, and thus I fear that performance would die a death by a thousand cuts—and thus without a clear fix.
Also, pseudo-ops don’t show up when debugging; so then, you would be staring at code that’s become alien to you (since it doesn’t match what your editor is showing), and thus you’d have to think extra hard about what the code is doing.
I would also like to point out that even if the macros make sense to you right now, they wouldn’t to anyone else you might ask for help (for wvatever reason), to yourself in a month or two, or to anyone who looks at your code to learn from it. When I first started doing assembly, this felt easy to brush aside as “well I don’t need anyone else to work on this”, but later on I bit my fingers hard because of it.
Over-engineering
Let’s pick back up from a macro we had above:
a common change is to make the macro accept any 16-bit register as its destination:
This is well and good, but it’s also a slippery slope, the end-game of which is macros like the following:;;;
; Loads byte from anywhere to anywhere else that takes a byte.
;
; ldAny [n16], 0
; Cycles: 5
; Bytes: 4
; Flags: Z=1 N=0 H=0 C=0
;
; ldAny [r16], 0
; Cycles: 3
; Bytes: 2
; Flags: Z=1 N=0 H=0 C=0
;
; ldAny r8, [n16]
; Cycles: 5
; Bytes: 4
; Flags: None
;
; ldAny r8, [r16]
; Cycles: 3
; Bytes: 2
; Flags: None
;
; ldAny [n16], r8
; Cycles: 5
; Bytes: 4
; Flags: None
;
; ldAny [r16], r8
; Cycles: 3
; Bytes: 2
; Flags: None
;
; ldAny [r16], [r16]
; Cycles: 4
; Bytes: 2
; Flags: None
;
; ldAny [r16], [n16]
; Cycles: 6
; Bytes: 4
; Flags: None
;
; ldAny [n16], [r16]
; Cycles: 6
; Bytes: 4
; Flags: None
;
; ldAny [n16], [n16]
; Cycles: 8
; Bytes: 6
; Flags: None
;
; ldAny [n16], n8
; Cycles:
; Bytes:
; Flags: None
;
; ldAny r16, SP
; Cycles:
; Bytes:
; Flags:
; Affects: HL
;
; ldAny SP, r16
; Cycles:
; Bytes:
; Flags:
; Affects: HL
;;;
…and I don’t think this is a good idea.
I think, going back on the topic of pseudo-ops being write-only, that this macro probably made sense while it was being written incrementally. However, as an outside observer, I cannot grasp how it works, nor grok the various usage kinds and constraints of each.
To me at least, this macro has grown so complex, and with so many “classes” of usage, that it doesn’t reduce the cognitive load over just writing the instructions. It is terser, but at what cost?
Acceptable pseudo-ops
I don’t think all pseudo-ops are bad. For example, here is one I enjoy using:
This macro is simple and straightforward, while being clearer than the instruction that it’s wrapping, so I think it’s a good pseudo-op to use.
Based on that, feel free to write other pseudo-ops if you think they are simple yet clearer than what they generate.
Alternatives
Instead of pseudo-ops, I would suggest using the following to bring the same benefits without their drawbacks:
Are you yearning for names attached to your operations? Comment all the things!
xor a ; Set a to 0.
Are you yearning for operations to be grouped? Use RGBASM’s new
::
syntax!; Add a to HL. add a, l :: ld l, a adc a, h :: sub a, l :: ld h, a
“Training wheels”
In conclusion, I think pseudo-ops can be thought of like bicycle training wheels. If you want to learn assembly, then maybe they can help you with some of its aspects while learning other tricky bits; and in that regard, it seems fair to use them.
But I also think that they are something that should be grown past eventually; or, in other words: don’t get used to pseudo-ops.
And if you’re struggling even with them… then maybe assembly isn’t made for you, and you can try using a compiled language instead? I have seen several people trying their hand at assembly, and dropping it; it is, after all, a very different paradigm, so there is nothing wrong with it “not sticking” with you.