What is the canonical solution for this type of problem?
There isn't any canonical solution, but many variants, all to be found usable.
The only one that comes to my mind is to create a "jump table" at the beginning
Which is a perfect good one. Except, usually one would use jumps instead of calls to reduce code length, speed up execution, and reduce stack load.
PRINT JP _I_PRINT ; First Function
READC JP _I_READC ; Second Function
But this seems pretty hackish...
No, many 8080 and Z80 systems work like that.
The main step ahead is that all entry points are at a single defined location and sequence.
Is it possible to instruct the z80asm assembler to place the routine's first instruction at a memory address decided by me?
Sure, use an ORG to put it at whatever address you want it (*1). But that would be hackish or at least not very forward looking. Having such a jump table at a defined address is a great start. Of course it eats up some space. Three bytes per entry, but only two being the address. Wouldn't it be better to just make an address table? Like:
DW _I_PRINT ; First Function
DW _I_READC ; Second Function
Calling a function would be like
LD HL, (SYS_TABLE+0) ; Load Address of First Function - PRINT
JP (HL) ; Do it
This can easy be combined with kind of a function selector:
Now even the jump table can be moved around in ROM (or RAM) as needed.
Calling it would be by using a function number - like many OS have - simply put the function number in A and call the default system entry point (SYS_ENTRY).
LD A,0 ; Print
Of course it gets more readable if the OS provides a set of equates for the function numbers :)
So far the program loaded does still need to know either the table address (SYS_TABLE) or the entry point for the selector (SYS_ENTRY). The next level of abstraction would move their address into a defined location, like 0100h, best maybe in form of a JP, so any user program always calls that fixed address (0100h) no matter if your OS is in ROM or RAM or wherever.
And yes, if this seems familiar, it is, as it's the same way CP/M handles system calls, or MS-DOS does.
Speaking of MS-DOS, it provides an additional (and more common known way) to call an OS function, so called software-interrupts, like the well known INT 21h. And there's something quite similar the Z80 (and 8080 before) offers: A set of eight distinct ReSTart vectors (0/8/16/...). Restart 0 is reserved for reset, all others can be used. So why not using the second (RST 8h) for your OS? Function calls then would look like:
LD A,0 ; Print
Now user program code is as much separated from OS structure and memory layout as possible - without the need of any relocator or whatsoever. The best part is, with a little fiddling, the whole selector fits into the 8 bytes available, making it optimal coding.
A little suggestion:
If you go for any of these models, make sure that the first function (0) of your OS will be a call providing information about the OS, so programs can check for compatibility. At least two basic values should be returned:
- ABI release number
- Maximum supported function number.
The ABI release number may or may not be the same as a version number, but does not have to. It must be increased with every API change. Together with the maximum supported function number this information can be used by a user program to quit graceful in case of incompatibility - instead of crashing midway. For luxury the function may as well return a pointer to a
- Structure containing further information about OS like
- readable name/version
- Addresses of various sources
- 'special' entry points
- Machine information like RAM size
- available interfaces, etc.
*1 - And no, other than some may assume, ORG should never ever add padding or alike on its own. Assemblers doing so are a bad choice. Org should only change the address level, not define what is in any area 'jumped over'. Doing so adds may levels of potential errors - at least as soon as some advanced ORG usage is done - believe me, ORG is a very versatile tool when doing complex structures.
In addition filling 'void' areas with some padding will result in this padding being part of the program instead of untouched memory, taking away a main tool for later patches: uninitialized EPROM space. By simply not defining and not loading these areas, they will stay in whatever the cleared state is (all ones in case of EPROM) and can be later programmed - for example to hold some code during debugging, or to apply a hot fix without the need of programming new devices.
So undefined memory should be just that, undefined. And that's why even the earliest assembler output/loader formats (think Motorola SREC or Intel HEX) used for program delivery to anything from ROM fabrication all the way to user programs supported a way to leave out areas.
Long story short: If one want's to have it filled, it has to be done expcit. z80asm does it right.