Yes, I extensively unit test all my assembly code. I use the
pytest framework in Python because it's by far the best unit test framework of the many dozens I've seen and the several I've written in the last twenty years.
At the moment you can find all of what's discussed below in my
8bitdev repo.
In my system first he file is assembled with an assembler of choice. Currently my (rather horrible) top-level build script and the loaders support The Macroassembler AS and the ASxxxx assembler suite, but others would be easy enough to add. (The main work is in writing the code to read your assembler's symbol table output.) Then the unit test framework starts and, for each test, sets up a CPU simulator (currently available are py65 for 6502 and my own for 6800), loads the object file into it, loads the symbol table, and runs the test.
Here's a sample set of 10 unit tests for a 6502 routine called `bi_readdec`, which given a pointer to an ASCII representation of a hexadecimal number converts it to a "bigint" (arbitrary-precision) binary number and stores that in an output buffer.
Code:
# Buffers used for testing deliberately cross page boundaries.
INBUF = 0x6FFE
OUTBUF = 0x71FE
@pytest.mark.parametrize('input, output', [
(b'5', b'\x05'),
(b'67', b'\x67'),
(b'89A', b'\x08\x9A'),
(b'fedc', b'\xFE\xDC'),
(b'fedcb', b'\x0F\xED\xCB'),
(b'80000', b'\x08\x00\x00'),
(b'0', b'\x00'),
(b'00000000', b'\x00'),
(b'087', b'\x87'),
(b'00000087', b'\x87'),
])
def test_bi_readhex(m, R, S, input, output):
print('bi_readhex:', input, type(input), output)
m.deposit(INBUF, input)
m.depword(S.buf0ptr, INBUF)
m.depword(S.buf1ptr, OUTBUF)
size = len(output) + 2 # length byte + value + guard byte
m.deposit(OUTBUF, [222] * size) # 222 ensures any 0s really were written
m.call(S.bi_readhex, R(a=len(input)))
bvalue = m.bytes(OUTBUF+1, len(output))
assert (len(output), output, 222,) \
== (m.byte(OUTBUF), bvalue, m.byte(OUTBUF+size-1))
Some notes to help explain this:
1. The test is obviously parametrized, allowing me to use the same code body for many tests. The `input` and `output` parameters are obviously specified right there; the other three parameters are `m`, the simulated machine, `S` the symbol table loaded from the assembler output, and `R` a class allowing me to construct "register set" objects (there will be more on this below). All three of those are "fixtures"; simply adding an `m` to the parameter list tells pytest to go find the setup code for the simulated machine, run it, and pass in the object it produces.
2. The `print` statement prints to stdout; this is captured by pytest and won't be shown unless the test fails. (Though you can ask it to show output even from successful tests if you like.)
3. You can see that there are functions to deposit bytes and words into the simulator's memory. Here this is used to set up the input buffer and the pointers to the input and output buffers. `INBUF` and `OUTBUF` are just the constants defined earlier in the test code. `buf0ptr` and `buf1ptr` are symbols in the assembly code; `S.buf0ptr` returns the value of `buf0ptr`, which in this case is the address in memory where we store the pointer to that buffer.
4. `m.call()` starts executing code in the simulator; it starts at the given address (the `bi_readhex` symbol, here) and counts JSRs and RTSs until it finds the final RTS, where it stops and returns, unless it encounters a BRK instruction in which case a (Python) exception will be thrown. (The list of "stop" opcodes can be specified, as can a different limit on the number of instructions to execute before throwing an exception.) If your JSRs and RTSs don't match, there are other ways of calling the code and running it to a given point, exiting without an exception on encoutnering a given opcode, etc.
5. `m.call` also takes a register set (which includes flags); here you can see that we set only register A, loading it with the length of the input buffer.
6. After it returns, we fetch some bytes from the simulator's memory and then assert that various values are what we expect them to be. There's almost never any need to write your own assertion functions; simply `assert EXPRESSION` and if it fails pytest will take it apart and show you the pieces, even telling you things like which individual elements in a list (or in this case, a sequence of bytes) are different from what's expected. That's why I can combine all my values above into 3-tuples and compare them; pytest will tell me which individual values in the tuples did not match and drill down even further into those if they're structured values.
This test unfortunately doesn't demonstrate register/flag comparisons, but those are done with objects constructed with R(), which can have "don't care" values to be used in comparisons. So typically I'd do something like `assert R(x=0x33, Z=1) == m.regs` to test just the x register value and Z flag, and on failure it would give me back something like the following, where the hyphens indicate the "don't care" values in the expected result:
Code:
____________________________ test_bi_readhex[67-g] _____________________________
src/m65/bigint.pt:54: in test_bi_readhex
assert R(x=0x33, Z=1) == m.regs
E assert Unexpected Registers values:
E 6502 pc=---- a=-- x=33 y=-- sp=-- ------Z-
E 6502 pc=1069 a=FE x=00 y=FF sp=FF nv--diZC
----------------------------- Captured stdout call -----------------------------
bi_readhex: b'67' <class 'bytes'> b'g'
It's worth mentioning that this sort of testing can also replace using a debugger in many circumstances; it's not difficult (but should be made easier!) to have the simulator stop at specified addresses and print out the current values of whatever registers and memory are of interest, for example. I can also generate execution traces, but those too want more work (for example, they currently don't show what memory was changed at every step).
Right now this whole thing is not really "productized" for use by others; the framework should be in a separate repo, with documentation and tutorials, etc. etc. I'm planning to get around to that one day, but it's still under pretty heavy development at the moment. However, I'm happy to do support, pair programming sessions, whatever, to help anybody who's interested in getting up to speed on this stuff.
Quote:
I realize 99% of this would be an effort in higher level scripting, likely with Bash or Perl.
Yeah, as someone who's been using Bourne shell since the '80s, Perl since the '90s, Ruby from the early 2000s onwards, and, over the last few years, Python, I can say you definitely should simply start with Python. I frequently ignore my own advice and use Bash to get something started and most of the time I regret it. (My top-level `Test` script in that repo is an excellent example.) The difference isn't as vast with Perl or Ruby, but it's still there and hurts in some important areas. (For example, you can't get something like pytest in Ruby or Perl because they don't give you access to the compilation system; pytest actually compiles the Python code in your tests differently from normal in order to instrument it so it can take apart structured variables in the way mentioned above.)