The HRRG Assembler’s macro capability allows us to define new instruction mnemonics with which we can associate one or more of our real instructions.
Before we plunge headfirst into the fray with gusto and abandon, if you aren’t already au fait with our project to build a 4-Bit Computer from the ground up out of a mixed bag of implementation technologies — including, but not limited to, relays, vacuum tubes, transistors, and jelly bean integrated circuits, along with mechanical, magnetic, pneumatic, and fluidic logic — then you might want to quickly peruse and ponder my 4-Bit HRRG Computer Recap column to ensure we’re all playing our Scottish bagpipes to the same drumbeat.
Arrgggh! Why did I say that? Now I’ve set myself to thinking about the Red Hot Chilli Pipers playing Smoke on the Water, as depicted in this video on YouTube.
But we digress… In my most recent column, we introduced the HRRG Assembly Language and Assembler. Now it’s time to consider two things we’ve glossed over a little: (a) the way in which the HRRG assembler handles the various jump instructions and (b) the assembler’s macro capabilities.
The Jump Instructions
Most simple microprocessors have a suite of jump instructions. First, there’s the basic JMP (“unconditional jump”) to a specified location in memory. Also, there will be a selection of jumps based on the value of the N (negative), Z (zero), C (carry), and O (overflow) status bits/flags. These instructions typically have mnemonics like JN (“jump if negative”; i.e., if the N bit is 1), JNN (“jump if not negative”; i.e., if the N bit is 0), JZ (“jump if zero”; i.e., if the Z bit is 1), JNZ (“jump if not zero”; i.e., if the Z bit is 0), and so forth. Each of these jump instructions will have its own opcode.
It may not surprise you to discover that this is not the way things work with the HRRG. In an earlier column on the 4-Bit HRRG Computer’s Instruction Set, we saw two instructions called JMP (“jump to location”) and JSR (“jump to subroutine”). Let’s start with the format for the JMP instruction, which is as follows:
JMP <0/1 #sb> <tar-aop>
The 4-bit JMP opcode nybble is $C (or %1100 in binary). This is followed by a control nybble, which is itself followed by a 3-nybble target address.
Before we proceed further, we should perhaps remind ourselves that the HRRG has two 4-bit status registers, S0 and S1, where S0 comprises the N, Z, C, and O, flags, while S1 contains the I (interrupt mask) and H (halt) flags, along with a hard-wired 0 and a hard-wired 1.
Now let’s return to the JMP instruction’s control nybble. The least-significant three bits of the control nibble point to the bit to be tested in the 8-bit status register formed from S0 and S1; this will be in the range 000 to 111 (0 to 7). Assuming a 0 in the most-significant bit of the control nibble, then a value of 1 in the selected status bit will cause a jump; for example, JMP %0001 <target address> is equivalent to a “Jump if zero”. By comparison, a 1 in the most-significant bit of the control nibble will invert the operation of the jump; for example, JMP %1001 <target address> is equivalent to a “Jump if not zero”.
Observe that status bit 7 (bit 3 in S1) is a hard-wired 1, which — if this bit is selected — is equivalent to an unconditional jump. Similarly, status bit 6 (bit 2 in S1) is a hard-wired 0, which — if this bit is selected — is equivalent to saying, “don’t jump” (this can be useful when debugging one’s programs).
Now, there are a variety of ways in which we could have handled all of these instructions, but we opted for the simplest solution as seen by the end user, which is for the HRRG assembler to natively recognize the following mnemonics and to generate the appropriate machine code:
JMP (“unconditional jump”)
JMPNOT (jump not” or “don’t jump”)
JMPN (“jump if negative”)
JMPNN (“jump if not negative”)
JMPZ (“jump if zero”)
JMPNZ (“jump if not zero”)
JMPC (“jump if carry”)
JMPNC (“jump if not carry”)
JMPO (“jump if overflow”)
JMPNO (“jump if not overflow”)
So, for example, if we wanted to jump to address $400, all we would have to say is “JMP $400” and the assembler will take care of the control nybble for us.
Most simple microprocessors have only a single JSR (“jump to subroutine”) instruction. By comparison, the HRRG’s JSR instruction operates in the same way as its JMP instruction, which means the HRRG assembler natively recognizes the following mnemonics:
JSR (“jump to subroutine”)
JSRNOT (“jump to subroutine not” or “don’t jump to subroutine”)
JSRN (“jump to subroutine if negative”)
JSRNN (“jump to subroutine if not negative”)
JSRZ (“jump to subroutine if zero”)
JSRNZ (“jump to subroutine if not zero”)
JSRC (“jump to subroutine if carry”)
JSRNC (“jump to subroutine if not carry”)
JSRO (“jump to subroutine if overflow”)
JSRNO (“jump to subroutine if not overflow”)
Now, you may be wondering why there are no jumps associated with the I (interrupt mask) and H (halt) flags. The answer is that we always know the state of these flags because we set or clear them via our programs, so there’s no point in providing tests for them.
Since we only have a 4-bit data bus (along with a 12-bit address bus), we’ve opted to have only 2^4 = 16 instructions along with 2^4 = 16 CPU registers. As we discussed in an earlier Instruction Trade-Offs column, this meant we were obliged to make certain decisions.
For example, almost every processor on the planet provides RTS (“ReTurn from Subroutine”) and RTI (“ReTurn from Interrupt”) instructions. The HRRG doesn’t support this type of instruction, but if it did, their mnemonics would be as follows:
RTS (“ReTurn from Subroutine”)
RTI (“ReTurn from Interrupt”)
Happily, we can achieve the same effect by simply retrieving the return address off the top of the stack and loading it into the program counter (PC) using a POP instruction.
Most processors offer a suite of instructions that can be used to clear or set individual bits in the status register. The HRRG doesn’t support this type of instruction, but if it did, their mnemonics would be as follows:
CLRN (“clear negative flag”)
CLRZ (“clear zero flag”)
CLRC (“clear carry flag”)
CLRO (“clear overflow flag”)
CLRI (“clear interrupt mask flag”)
SETN (“set negative flag”)
SETZ (“set zero flag”)
SETC (“set carry flag”)
SETO (“set overflow flag”)
SETI (“set interrupt mask flag”)
SETH (“set halt flag”)
Observe that, although we have a SETH (“set half flag”), there is no CLRH (“clear halt flag”) instruction in this list. This is because (a) we don’t actually have any of these instructions and (b) once the halt flag has been set to 1, the CPU grinds at a halt, so the only way to reset this flag is to trigger an interrupt (assuming the interrupt mask flag is set to 1) or reset the machine.
Happily, we can implement all of these instructions using our AND and OR logical operations. Suppose we wanted to clear the Z (zero) flag (bit 1 in status register S0) to 0, for example, we could do this by ANDing the contents of S1 with %1101 (remember that we are using ‘%’ characters to indicate binary values). Similarly, if we wanted to set the Z flag to 1, we could do this by ORing the contents of status register S0 with %0010.
In the case of addition and subtraction, some microprocessors support the following instructions:
ADD (“add without carry”)
ADDC (“add with carry”)
SUB (“subtract without borrow”)
SUBB (“subtract with borrow”)
The HRRG only supports ADDC and SUBB, but we can implement the same functionality as an ADD by first clearing the C (carry) flag to 0 before performing an ADDC, and we can implement the same functionality as a SUB by first setting the C (carry) flag to 1 before performing a SUBB. What? Well, in the case of a SUB, we regard the carry flag as representing a borrow. This is too tricky to explain here, but worry not because we will be discussing all of this stuff in future columns.
Finally, for the moment, there are potentially eight shift and rotate operations. Some processors have special instructions for all of these, with mnemonics like the following:
LSHL (Logical shift left)
ASHL (Arithmetic shift left)
LSHR (logical shift right)
ASHR (Arithmetic shift right)
ROL (Rotate left)
ROR (Rotate right)
ROLC (Rotate left through the carry flag)
RORC (Rotate right through the carry flag)
The way in which these little rascals perform their magic is presented in excruciating detail (with pictures) in the aforementioned Instruction Trade-Offs column. The HRRG only supports ROLC and RORC, but we can implement the others using some cunning bit manipulation tricks.
Last, but not least, for the moment, some processors provide a HALT instruction, which sets the H (halt) status flag to 1, thereby placing the CPU in a waiting mode. As we noted earlier, once the halt flag has been set to 1, the only way to reset it is to trigger an interrupt (assuming the interrupt mask flag is set to 1) or reset the machine. Sad to relate, the HRRG doesn’t offer a HALT instruction, but turn that frown upside down into a smile because we can achieve the same effect by ORing status register S1 with %0010 (or by using a SETH instruction — as discussed above — if we had one).
The HRRG Macro Capability
Although we can use cunning tricks to live without the missing instructions discussed above, doing so can be a bit of a pain, not the least that using these instructions in our assembly programs helps clarify what we are trying to do.
All of which leads us to the HRRG Assembler’s macro capability, which allows us to define new instruction mnemonics with which we can associate one or more instructions. The easiest way to explain this is to describe it at a high level and then show some examples.
We use the .MACRO and .ENDMACRO directives to start and end a macro definition. The .MACRO directive is followed by the name of the macro, which is — in turn — followed by zero to nine parameters named @1 through @9.
If the macro uses parameters, they must be presented in sequence @1, @2, @3… @9 and separated by commas. Furthermore, we must inform the assembler how big each parameter will be; the only supported sizes are one or three nybbles. We do this by appending :1 or :3 to the end of the parameter name. For example, if a macro supports two parameters, where the first is one nybble and the second is three nybbles, then the parameters will be named @1:1, @2:3
Each macro can contain up to 999 lines of code. Last but not least, macros can be nested (i.e., one macro can call another), but their definitions can’t be nested (i.e., you can’t define a macro inside the definition of another macro).
Bearing all the above points in mind, we can declare macros in the main body of our code if we wish. Alternatively, and more typically, we might create one or more files containing macros and then use .INCLUDE directives to include these files into our program.
For example, we could create a file containing a suite of macros to support all the missing instructions discussed in the previous section (Click Here to see a text file containing these macros). Once we have created such a file, we would save it with some name like STDMACROS.ASM. Later, when we are writing our programs, we could use the statement .INCLUDE “STDMACROS.ASM” to include these macros and allow us to access them in our programs. These macros could be written as follows:
As we see (in this text file), the RTS and RTI macros are simple, because all they do is use a POP instruction to retrieve the 12-bit return address from the top of the stack and load it into the program counter (PC).
Similarly, the macros for clearing and setting the various status flags are easy to understand because we use AND or OR instructions to clear or set targeted bits, respectively.
Things get a bit more interesting when we reach the ADD and SUB macros, because these both require two 4-bit (1-nybble) parameters. Due to a combination of the way in which we’ve written these macros and the way in which our ADDC and SUBB instructions work, when calling these macros from the body of the program, the first parameter could be a 4-bit constant value, a 4-bit register, or a 4-bit memory location, while the second parameter can only be either a 4-bit register or a 4-bit memory location; for example:
ADD %0001, R0ADD %0001, [$400]ADD R0, R1ADD RO, [$400]ADD [$400], R1ADD [$400], [$401]
In a similar vein, the shift and rotate macros require only a single 4-bit parameter, which can be provided in the form of a 4-bit register or a 4-bit memory location. Most of the shift and rotate macros are relatively simple. See if you can figure out how they work. The interesting one here is the ASHR (“arithmetic shift right”), which involves shifting the 4-bit register or memory location one bit to the right and ensuring that its original sign bit — the original most-significant (MS) bit — is copied into its new MS bit.
The way I decided to do this is to use the O (overflow) status flag as a temporary storage location. I felt free to do this since this flag is only of interest following ADDC or SUBB instructions (or our ADD and SUB macros). First, I used a ROLC (“rotate left through carry”) instruction to rotate the 4-bit value one bit to the left to get it’s MS bit into the C flag, then I replicated the value in the C flag into the O flag, and then I used a RORC (“rotate right through carry”) instruction to return the value to be shifted to its original state. Next, I used another RORC instruction to rotate the 4-bit value one bit to the right, and then I replicated the value in the O flag into the MS bit of the rotated (shifted) value.
This is, of course, just one of many possible solutions to implementing the ASHR macro. Can you think of any alternatives that will require fewer instructions and clock cycles? As always, I appreciate your comments, questions, and suggestions.