*If the user program's parameters are pointing to a portion of memory in page 3 (last page), there is a conflict as the kernel will always map its RAM page inside this exact same page during a syscall. Thus, it will remap user's page 3 into page 2 (third page) to access the program's parameters. Of course, in case the parameters are pointers, they will be modified to let them point to the new virtual address (in other words, a pointer will be subtracted by 16KB to let it point to page 2).
To be able to port Zeal 8-bit OS to Z80-based computers that don't have an MMU/Memory mapper organized as shown above, the kernel has a new mode that can be chosen through the menuconfig: no-MMU.
In this mode, the OS code is still expected to be mapped in the first 16KB of the memory, from 0x0000 to 0x3FFF and the rest is expected to be RAM.
Ideally, 48KB of RAM should be mapped starting at 0x4000 and would go up to 0xFFFF, but in practice, it is possible to configure the kernel to expect less than that. To do so, two entries in the menuconfig must be configured appropriately:
KERNEL_STACK_ADDR: this marks the end of the kernel RAM area, and, as its name states, will be the bottom of the kernel stack.KERNEL_RAM_START: this marks the start address of the kernel RAM where the stack, all the variables used by the kernel AND drivers will be stored. Of course, it must be big enough to store all of these data. For information, the current kernel BSS section size is around 1KB. The stack depth depends on the target drivers' implementation. Allocating 1KB for the stack should be more than enough as long as no (big) buffers are stored on it. Overall allocating at least 3KB for the kernel RAM should be safe and future-proof.To sum up, here is a diagram to show the usage of the memory:
Regarding the user programs, the stack address will always be set to KERNEL_RAM_START - 1 by the kernel before execution. It also corresponds to the address of its last byte available in its usable address space. This means that a program can determine the size of the available RAM by performing SP - 0x4000, which gives, in assembly:
ld hl, 0
add hl, sp
ld bc, -0x4000
add hl, bc
; HL contains the size of the available RAM for the program, which includes the program's code and its stack.
Z80 presents multiple general-purpose registers, not all of them are used in the kernel, here is the scope of each of them:
| Register | Scope |
|---|---|
| AF, BC, DE, HL | System & application |
| AF', BC', DE', HL' | Interrupt handlers |
| IX, IY | Application (unused in the OS) |
This means that the OS won't alter IX and IY registers, so they can be used freely in the application.
The alternate registers (names followed by ') may only be used in the interrupt handlers1. An application should not use these registers. If for some reason, you still have to use them, please consider disabling the interrupts during the time they are used:
my_routine:
di ; disable interrupt
ex af, af' ; exchange af with alternate af' registers
[...] ; use af'
ex af, af' ; exchange them back
ei ; re-enable interrupts
Keep in mind that disabling the interrupts for too long can be harmful as the system won't receive any signal from hardware (timers, keyboard, GPIOs...)
The Z80 provides 8 distinct reset vectors, as the system is meant to always be stored in the first virtual page of memory, these are all reserved for the OS:
| Vector | Usage |
|---|---|
| $00 | Software reset |
| $08 | Syscall |
| $10 | Jumps to the address in HL (can be used for calling HL) |
| $18 | Unused |
| $20 | Unused |
| $28 | Unused |
| $30 | Unused |
| $38 | Reserved for Interrupt Mode 1, usable by the target implementation |
When a user program is executed, the kernel allocates 3 pages of RAM (48KB), reads the binary file to execute and loads it starting at virtual address 0x4000 by default. This entry point virtual address is configurable through the menuconfig with option KERNEL_INIT_EXECUTABLE_ADDR, but keep in mind that existing programs won't work anymore without being recompiled because they are not relocatable at runtime.
As described below, the exec syscall takes two parameters: a binary file name to execute and a parameter.
This parameter must be a NULL-terminated string that will be copied and transmitted to the binary to execute through registers DE and BC:
DE contains the address of the string. This string will be copied to the new program's memory space, usually on top of the stack.BC contains the length of that string (so, excluding the NULL-byte). If BC is 0, DE must be discarded by the user program.The system relies on syscalls to perform requests between the user program and the kernel. Thus, this shall be the way to perform operations on the hardware. The possible operations are listed in the table below.
| Num | Name | Param. 1 | Param. 2 | Param. 3 |
|---|---|---|---|---|
| 0 | read | u8 dev | u16 buf | u16 size |
| 1 | write | u8 dev | u16 buf | u16 size |
| 2 | open | u16 name | u8 flags | |
| 3 | close | u8 dev | ||
| 4 | dstat | u8 dev | u16 dst | |
| 5 | stat | u16 name | u16 dst | |
| 6 | seek | u8 dev | u32 offset | u8 whence |
| 7 | ioctl | u8 dev | u8 cmd | u16 arg |
| 8 | mkdir | u16 path | ||
| 9 | chdir | u16 path | ||
| 10 | curdir | u16 path | ||
| 11 | opendir | u16 path | ||
| 12 | readdir | u8 dev | u16 dst | |
| 13 | rm | u16 path | ||
| 14 | mount | u8 dev | u8 letter | u8 fs |
| 15 | exit | u8 code | ||
| 16 | exec | u16 name | u16 argv | |
| 17 | dup | u8 dev | u8 ndev | |
| 18 | msleep | u16 duration | ||
| 19 | settime | u8 id | u16 time | |
| 20 | gettime | u8 id | u16 time | |
| 21 | setdate | u16 date | ||
| 22 | getdate | u16 date | ||
| 23 | map | u16 dst | u24 src | |
| 24 | swap | u8 dev | u8 ndev |
Please check the section below for more information about each of these call and their parameters.
NOTE: Some syscalls may be unimplemented. For example, on computers where directories are not supported,directories-related syscalls may be omitted.
In order to perform a syscall, the operation number must be stored in register L, the parameters must be stored following these rules:
| Parameter name in API | Z80 Register |
|---|---|
| u8 dev | H |
| u8 ndev | E |
| u8 flags | H |
| u8 cmd | C |
| u8 letter | D |
| u8 code | H |
| u8 fs | E |
| u8 id | H |
| u8 whence | A |
| u16 buf | DE |
| u16 size | BC |
| u16 name | BC |
| u16 dst | DE |
| u16 arg | DE |
| u16 path | DE |
| u16 argv | DE |
| u16 duration | DE |
| u16 time | DE |
| u16 date | DE |
| u24 src | HBC |
| u32 offset | BCDE |
And finally, the code must perform an RST $08 instruction (please check Reset vectors).
The returned value is placed in A. The meaning of that value is specific to each call, please check the documentation of the concerned routines for more information.
To maximize user programs compatibility with Zeal 8-bit OS kernel, regardless of whether the kernel was compiled in MMU or no-MMU mode, the syscalls parameters constraints are the same:
Any buffer passed to a syscall shall not cross a 16KB virtual pages
In other words, if a buffer buf of size n is located in virtual page i, its last byte, pointed by buf + n - 1, must also be located on the exact same page i.
For example, if read syscall is called with:
DE = 0x4000 and BC = 0x1000, the parameters are correct, because the buffer pointed by DE fits into page 1 (from 0x4000 to 0x7FFF)DE = 0x4000 and BC = 0x4000, the parameters are correct, because the buffer pointed by DE fits into page 1 (from 0x4000 to 0x7FFF)DE = 0x7FFF and BC = 0x2, the parameters are incorrect, because the buffer pointed by DE is in-between page 1 and page2.execEven though Zeal 8-bit OS is a mono-tasking operating system, it can execute and keep several programs in memory. When a program A executes a program B thanks to the exec syscall, it shall provide a mode parameter that can be either EXEC_OVERRIDE_PROGRAM or EXEC_PRESERVE_PROGRAM:
EXEC_OVERRIDE_PROGRAM: this option tells the kernel that program A doesn't need to be executed anymore, so program B will be loaded in the same address space as program A. In other words, program B will be loaded inside the same RAM pages as program A, it will overwrite it.EXEC_PRESERVE_PROGRAM: this option tells the kernel that program A needs to be kept in RAM until program B finishes its execution and calls the exit syscall. To do so, the kernel will allocate 3 new memory pages (16KB * 3 = 48KB) in which it stores newly loaded program B. Once program B exits, the kernel frees the previously allocated pages for program B, remaps program A's memory pages, and gives back the hand to program A. If needed, A can retrieve B's exit value.The depth of the execution tree is defined in the menuconfig, thanks to option CONFIG_KERNEL_MAX_NESTED_PROGRAMS. It represents the maximum number of programs that can be stored in RAM at one time. For example, if the depth is 3, program A can call program B, program B can call program C, but program C cannot call any other program.
However, if a program invokes exec with EXEC_OVERRIDE_PROGRAM, the depth is not incremented as the new program to load will override the current one.
As such, if we take back the previous example, program C can call a program if and only if it invokes the exec syscall in EXEC_OVERRIDE_PROGRAM mode.
Be careful, when executing a sub-program, the whole opened device table, (including files, directories, and drivers), the current directory, and CPU registers will be shared.
This means that if program A opens a file with descriptor 3, program B will inherit this index, and thus, also be able to read, write, or even close that descriptor. Reciprocally, if B opens a file, directory, or driver and exits without closing it, program A will also have access to it. As such, the general guideline to follow is that before exiting, a program must always close the descriptors it opened. The only moment the table of opened devices and current directory are reset is when the initial program (program A in the previous example) exits. In that case, the kernel will close all the descriptors in the opened devices table, reopen the standard input and output, and reload the initial program.
This also means that when invoking the exec syscall in an assembly program, on success, all registers, except HL, must be considered altered because they will be used by the subprogram. So, if you wish to preserve AF, BC, DE, IX or IY, they must be pushed on the stack before invoking exec.
The syscalls are all documented in the header files provided for both assembly and C, you will find these header file in the kernel_headers/ directory, check its README file for more information.
A driver consists of a structure containing:
SER0, SER1, I2C0, etc. Non-ASCII characters are allowed but not advised.init routine, called when the kernel boots.read routine, where parameters and return address are the same as in the syscall table.write routine, same as above.open routine, same as above.close routine, same as above.seek routine, same as above.ioctl routine, same as above.deinit routine, called when unloading the driver.Here is the example of a simple driver registration:
my_driver0_init:
; Register itself to the VFS
; Do something
xor a ; Success
ret
my_driver0_read:
; Do something
ret
my_driver0_write:
; Do something
ret
my_driver0_open:
; Do something
ret
my_driver0_close:
; Do something
ret
my_driver0_seek:
; Do something
ret
my_driver0_ioctl:
; Do something
ret
my_driver0_deinit:
; Do something
ret
SECTION DRV_VECTORS
DEFB "DRV0"
DEFW my_driver0_init
DEFW my_driver0_read
DEFW my_driver0_write
DEFW my_driver0_open
DEFW my_driver0_close
DEFW my_driver0_seek
DEFW my_driver0_ioctl
DEFW my_driver0_deinitRegistering a driver consists in putting this information (structure) inside a section called DRV_VECTORS. The order is very important as any driver dependency shall be resolved at compile-time. For example, if driver A depends on driver B, then B's structure must be put before A in the section DRV_VECTORS.
At boot, the driver component will browse the whole DRV_VECTORS section and initialize the drivers one by one by calling their init routine. If this routine returns ERR_SUCCESS, the driver will be registered and user programs can open it, read, write, ioctl, etc...
A driver can be hidden to the programs, this is handy for disk drivers that must only be accessed by the kernel's file system layer. To do so, the init routine should return ERR_DRIVER_HIDDEN.
As the communication between applications and hardware is all done through the syscalls described above, we need a layer between the user application and the kernel that will determine whether we need to call a driver or a file system. Before showing the hierarchy of such architecture, let's talk about disks and drivers.
The different layers can be seen like this:
flowchart TD;
app(User program)
vfs(Virtual File System)
dsk(Disk module)
drv(Driver implementation: video, keyboard, serial, etc...)
fs(File System)
sysdis(Syscall dispatcher)
hw(Hardware)
time(Time & Date module)
mem(Memory module)
loader(Loader module)
app -- syscall/rst 8 --> sysdis;
sysdis --getdate/time--> time;
sysdis --mount--> dsk;
sysdis --> vfs;
sysdis --map--> mem;
sysdis -- exec/exit --> loader;
vfs --> dsk & drv;
dsk <--> fs;
fs --> drv;
drv --> hw;
Zeal 8-bit OS supports up to 26 disks at once. The disks are denoted by a letter, from A to Z. It's the disk driver's responsibility to decide where to mount the disk in the system.
The first drive, A, is special as it is the one where the system will look for preferences or configurations.
In an application, a path may be:
my_dir2/file1.txt/my_dir1/my_dir2/file1.txtB:/your_dir1/your_dir2/file2.txtEven though the OS is completely ROM-able and doesn't need any file system or disk to boot, as soon as it will try to load the initial program, called init.bin by default, it will check for the default disk and request that file. Thus, even the most basic storage needs a file system, or something similar.
The first "file system", which is already implemented, is called "rawtable". As its name states, it represents the succession of files, not directories, in a storage device, in no particular order. The file name size limit is the same as the kernel's: 16 characters, including the optional . and extension. If we want to compare it to C code, it would be an array of structures defining each file, followed by the file's content in the same order. A romdisk packer source code is available in the packer/ at the root of this repo. Check its README for more info about it.
The second file system, which is also implemented, is named ZealFS. Its main purpose is to be embedded in very small storages, from 8KB up to 64KB. It is readable and writable, it supports files and directories. More info about it in the dedicated repository.
The third file system that would be nice to have on Zeal 8-bit OS is FAT16. Very famous, already supported by almost all desktop operating systems, usable on CompactFlash and even SD cards, this is almost a must-have. It has not been implemented yet, but it's planned. FAT16 is not perfect though as it is not adapted for small storage, this is why ZealFS is needed.
The Zeal 8-bit OS is based on two main components: a kernel and a target code.
The kernel alone does nothing. The target needs to implement the drivers, some MMU macros used inside the kernel and a linker script. The linker script is fairly simple, it lists the sections in the order they must be linked in the final binary by z80asm assembler.
The kernel currently uses the following sections, which must be included in any linker script:
RST_VECTORS: contains the reset vectorsSYSCALL_TABLE: contains a table where syscall i routine address is stored at index i, must be aligned on 256SYSCALL_ROUTINES: contains the syscall dispatcher, called from a reset vectorKERNEL_TEXT: contains the kernel codeKERNEL_STRLIB: contains the string-related routines used in the kernelKERNEL_DRV_VECTORS: represents an array of drivers to initialize, check Driver section for more details.KERNEL_BSS: contains the data used by the kernel code, must be in RAMDRIVER_BSS: not used directly by the kernel, it shall be defined and used in the drivers. The kernel will set it to 0s on boot, it must be bigger than 2 bytesAs said previously, Zeal 8-bit Computer support is still partial but enough to have a command line program running. The romdisk is created before the kernel builds, this is done in the script.sh specified in the target/zeal8bit/unit.mk.
That script will compile the init.bin program and embed it inside a romdisk that will be concatenated to the compiled OS binary. The final binary can be directly flashed to the NOR Flash.
What still needs to be implemented, in no particular order:
A quick port to TRS-80 Model-I computer has been made to show how to port and configure Zeal 8-bit OS to targets that don't have an MMU.
This port is rather simple as it simply shows the boot banner on screen, nothing more. To do so, only a video driver for text mode is implemented.
To have a more interesting port, the following features would need to be implemented:
init.bin/romdisk, can be read-only, so can be stored on the ROMA port to the eZ80 powered Agon Light, written and maintained by Shawn Sijnstra. Feel free to use that fork for Agon specific bugs/requests. This uses the non-MMU kernel, and implements most of the features that the Zeal 8-bit computer implementation supports.
This port requires a loader for the binary to be stored and executed from the correct location. The binary is OSbootZ, available here.
Note that the port uses terminal mode to simplify keyboard I/O. This also means that the date function is not available.
Other notable features:
To port Zeal 8-bit OS MMU version to another machine, make sure you have a memory mapper first that divides the Z80's 64KB address space into 4 pages of 16KB for the MMU version.
To port no-MMU Zeal 8-bit OS, make sure RAM is available from virtual address 0x4000 and above. The most ideal case being having ROM is the first 16KB for the OS and RAM in the remaining 48KB for the user programs and kernel RAM.
If your target is compatible, follow the instructions:
Kconfig file at the root of this repo, and add an entry to the config TARGET and config COMPILATION_TARGET options. Take the ones already present as examples.target/ for your target, the name must be the same as the one specified in the new config TARGET option.unit.mk file. This is the file that shall contain all the source files to assemble or the ones to include.unit.mk file, to do so, you can populate the following make variables:
SRCS: list of the files to be assembled. Typically, these are the drivers (mandatory)INCLUDES: the directories containing header files that can be includedPRECMD: a bash command to be executed before the kernel starts buildingPOSTCMD: a bash command to be executed after the kernel finishes buildingmmu_h.asm file which will be included by the kernel to configure and use the MMU. Check the file target/zeal8bit/include/mmu_h.asm to see how it should look like.zos_disks_mount, containing an init.bin file, loaded and executed by the kernel on boot.zos_vfs_set_stdout.For the complete changelog, please check the release page.
Contributions are welcome! Feel free to fix any bug that you may see or encounter, or implement any feature that you find important.
To contribute:
(*) A good commit message is as follows:
Module: add/fix/remove a from b
Explanation on what/how/why
For example:
Disks: implement a get_default_disk routine
It is now possible to retrieve the default disk of the system.
Distributed under the Apache 2.0 License. See LICENSE file for more information.
You are free to use it for personal and commercial use, the boilerplate present in each file must not be removed.
For any suggestion or request, you can contact me at contact [at] zeal8bit [dot] com
For feature requests, you can also open an issue or a pull request.
They shall not be considered as non-volatile nonetheless. In other words, an interrupt handler shall not make the assumption that the data it wrote inside any alternate register will be kept until the next time it is called. ↩