-
Log #2: Stacks and subroutines
07/13/2022 at 13:54 • 0 commentsWhen writing assembly, there are no rules about how to pass data to and from subroutines. Each subroutine can do it in a different way, for maximum efficiency. If there are just a few parameters, it probably makes sense to pass them in registers. Extra ones can go on the stack, although this can require a bit of juggling if the same stack is used for return addresses. Alternatively, data can be passed via a "parameter block" in-line with the code - check out this useful resource to see how that was done on the 6502.
Likewise, return values (and there can be more than one) can go in registers, on the stack, in fixed memory locations, or even in the form of the carry and zero status flags in the case of boolean values.
Here's an example of a hand-written subroutine in assembly. As the comment says, the inputs are the x and y registers, and the output is returned in b. It modifies the c register, so the caller would need to save its value on the stack, if it was important.
The "call" instruction is actually a macro that pushes the return address (the one after the call) and jumps to the given address. So it's really two instructions - 6 bytes in total. The "ret" instruction is a real instruction that pops a 16-bit value into the program counter.
The routine uses a "local" variable, mulbit, which is stored in a fixed location in memory.
mov x, #56 mov y, #39 call mulxy ; b == 2184 ;mulitply (8bit x 8bit = 16bit result) ; xy inputs ; b output ; c clobbered mulxy mov b, #0 mov cl, x mov ch, #0 stl #1, mulbit mloop mov x, y ; add if y & bit and x, [mulbit] jz mnoadd add bl, cl ; b += c adc bh, ch mnoadd add cl, cl ; c *= 2 adc ch, ch adr mulbit ; bit *= 2 ldx add x, x stx jcc mloop ret mulbit .byte 1
From assembly to a higher level
Eventually I'd like to implement a high-level language though (at least higher-level than assembly), and then we do need some rules - a calling convention. This will make heavy use of the stack, for arguments, return values, and each function's local variables. A stack-relative addressing mode, with which we can read and write values on the stack without pushing and popping them, is key, and that's why I have sp+imm8 and sp+imm16 addressing modes in the ISA.
Because the modes can only add a positive offset to the stack pointer, it makes more sense for the stack to grow down. If it grew up, you would need a negative offset to access a function's parameters.
So here's what the output might look like from a high-level compiler:; call myfunc with 2 1-byte parameters push #12 push #34 push #return_addr ; call jmp myfunc ; call ... myfunc push x ; callee saves x and y push y sub sp, #32 ; allocate space for locals ... ldx sp+37 ; load parameter stx sp+3 ; store local variable ... call func2 ; call child function ... add sp, #32 ; deallocate locals pop y ; restore x/y pop x pop b add sp, #2 ; deallocate parameters jmp b ; return
This is all very preliminary, but it shows that a high level language could be implemented fairly efficiently.
-
Log #1: New start
07/06/2022 at 19:33 • 0 commentsI redesigned everything so here's another overview.
- 8-bit computer with a 16-bit address bus, probably built on Eurocards
- Simplicity > performance, but I want it to be a target for a high-level language - it's not "minimal"
- Starting off with just a UART interface - would like to add other peripherals later (floppy disk, maybe graphics)
- Clock speed in the few MHz range
There are eight 8-bit registers, six of which can be combined into three logical 16-bit registers - B, C and SP (stack pointer). T is a temporary register containing the second operand for any ALU operations.
Instruction set
To keep instruction decoding simple, all instructions consist of 1 byte of opcode, followed by 0, 1 or 2 bytes of immediate data. This forces some trade-offs in the ISA. Some simple operations require multiple short instructions, but this fact is largely hidden by the assembler.
For example, ALU operations either add an immediate value to a register, or add the contents of T:
add x, #32 ; this is a single instruction add x, y ; this is accepted by the assembler ; and turned into: mov t, y ; 69 add x, t ; 70
Memory access requires a separate instruction to load the address buffer A:
ldx sp+31 ; becomes: adr sp+31 ; 06 1f ldx ; 20
There are no interrupts to worry about on this system, so the values in A and T don't need to be saved, or given much thought to. A smart assembler/compiler (or human) might be able to keep track of them to avoid redundant writes.
Not super performant, but I quite like the simplicity of it.
Addressing modes
The ISA design allows for many addressing modes. The adr instruction loads the address buffer, and there is one version of adr for each addressing mode - 20 in total:
adr ADDR ; absolute 16-bit address adr b ; addr = contents of B register adr c ; addr = C adr sp ; addr = SP adr b/c/sp + #L ; B or C or SP, plus 8-bit literal offset adr b/c/sp + #LL ; B/C/SP plus 16-bit offset adr b/c/sp + x/y ; B/C/SP plus X or Y adr sp + b/c ; SP plus B or C adr b + c ; B plus C adra ; indirect: A = mem[A]
As above, when writing assembly the adr instruction would not normally be used directly - instead these addresses are used as operands in other instructions:
ldb sp+4 ; load b from stack pointer + 4 (3 byte instruction: adr #4 ldb) add x, [c+y] ; x = x + mem[c+y] (3 bytes: adr ldt add)
ALU
The usual stuff - add and subtract with or without carry, inc/dec, compare, and three logic operations (and, or, xor). The ALU will be implemented with two EEPROMs.
Assembly example
The assembler has has native support for Pascal-like strings - no null-termination here!
mov b, #string1 call prints halt string1 .string "Hello, world!" UART = $f000 ;print a length-prefixed string (max len 255) ; b string to print ; xyc clobbered prints mov c, #UART ldx b ; x = string length (8 bits) psloop inc b ldy b ; read char from string sty c ; write char to uart dec x jnz psloop ret