Introducing... PUNIX! A Puny UNIX
Posted: Sun Jan 25, 2026 10:50 pm
Well, we are all snowed/iced up here in Texas, so I've finally gotten around to working on one of my dream/bucket list projects, which is creating a mini-UNIX for my 6502 hobby computers. I added a few new OSDEV books to my holy trinity. One of them (An Operating System Vade Mecum) hasn't actually been delivered yet - it's probably stuck in an ice bank somewhere.
Anyway, my new 4MHz S(sic)BC has it's VIA2 hooked up to NMI. So the first thing I did was set up a 100Hz RTC.
I then sketched out some process management data. This took a few tries, but it ended up like this (skipping over the I/O related stuff and just including stuff related to the kernel):I spent the next couple of days working on "_SYS_mon" which is it's core process monitor - i.e., scheduler. I don't have a way to actually *create* processes yet - no _SYS_fork or anything like that yet. So I hand coded a couple of test routines - process A just spams 'A's to the serial output, while process B just spams 'B's. Here it is working!
Task switches are about a 6% overhead, which could probably be better. But I think not bad for a first try! My partner in crime for all this was Claude.AI. Claude was indispensable, particularly for debugging. ChatGPT was also involved, but was less useful. However, ChatGPT did make this nifty flowchart for the scheduler:
Code: Select all
_RTC_init:
;-----------------------------------------------------------------------------
; _RTC_init - Initialize VIA2 Timer 1 for 100Hz NMI system tick
;-----------------------------------------------------------------------------
; Generates NMI interrupt every 10ms (100Hz) at 4MHz CPU clock
; Timer calculation: 4,000,000 / 100 = 40,000 cycles per tick
; Timer value: 40,000 - 2 = 39,998 = $9C3E
; Clobbers: .A
;-----------------------------------------------------------------------------
; Load timer with 39,998 ($9C3E)
LDA #$3E ; Low byte
STA VIA2_T1CL ; Write to counter low (loads latch)
LDA #$9C ; High byte
STA VIA2_T1CH ; Write to counter high (starts timer)
; Configure Timer 1 for continuous interrupts
LDA VIA2_ACR
AND #$7F ; Clear bit 7 (disable T1 output on PB7)
ORA #$40 ; Set bit 6 (T1 continuous mode)
STA VIA2_ACR
; Enable Timer 1 interrupt
LDA #$C0 ; Set enable (bit 7) + T1 interrupt (bit 6)
STA VIA2_IER
RTS ; _RTC_init
Code: Select all
;-----------------------------------------------------------------------------
; Process Control Block (PCB) Structure - 16 bytes
;-----------------------------------------------------------------------------
;
;-----------------------------------------------------------------------------
PID = $00 ; Process ID ($0 - $F)
PSTAT = $01 ; Process Status (BLOCK | READY | RUN)
QUANTUM = $02 ; Time slices remaining before context switch
STACKBASE = $03 ; Pointer to buffer for stack preservation
; $04 ; 16 bits
STACK_PTR = $05 ; Save stack pointer here
; $06 - $0F available for other needed fields
;-----------------------------------------------------------------------------
; Process States
;-----------------------------------------------------------------------------
NULL = %00000000 ; $00 - Only PID 0 should ever be NULL
FREE = %00000001 ; $01 - Indicates currently unused PCB
; = %00000010 ; $02
; = %00000100 ; $04
; = %00001000 ; $08
; = %00010000 ; $10
BLOCK = %00100000 ; $20 - Process waiting on I/O or other
READY = %01000000 ; $40 - Process ready to run
RUN = %10000000 ; $80 - Currently running process
; Zero Page
; $0000 - $007F Reserved for parameter stack
; Kernel data
sys_ticks = $80 ; 4 bytes - 32-bit system tick counter
; $81
; $82
; $83
current_pid = $84
; Pseudoregisters (16 bit)
R1 = $FC
; - $FD
R0 = $FE
; - $FF
; Process Table - statically located, contains 16 Process Control Blocks
PROCTAB = $0200
; - $02FF
Code: Select all
_SYS_mon:
;-----------------------------------------------------------------------------
; _SYS_mon - NMI interrupt handler (system tick from VIA2 Timer 1)
;-----------------------------------------------------------------------------
; Called every 10ms (100Hz) for preemptive multitasking
; Must be fast and non-reentrant safe
; Clobbers: None (all registers saved/restored)
;-----------------------------------------------------------------------------
; Save registers (PC and P already pushed by NMI)
PHA ; Save A
PHX ; Save X (65C02)
PHY ; Save Y (65C02)
; Clear VIA2 Timer 1 interrupt flag
BIT VIA2_T1CL ; Reading T1CL clears IFR bit 6
; Increment 32-bit system tick counter
INC sys_ticks
BNE .scheduler
INC sys_ticks + 1
BNE .scheduler
INC sys_ticks + 2
BNE .scheduler
INC sys_ticks + 3
.scheduler:
; Process Scheduler
LDA current_pid ; Get current PID
ASL A ; x2
ASL A ; x4
ASL A ; x8
ASL A ; x16
ORA #QUANTUM
TAX
LDA PROCTAB,X ; Check Quantum
BEQ .do_switch ; Is it zero?
DEC PROCTAB,X ; No, decrement it
JMP .exit ; And go back to what we were doing
; Otherwise, QUANTUM is 0, perform context switch
.do_switch: ; Step 1: update current process PSTAT and QUANTUM ---
LDA current_pid
ASL
ASL
ASL
ASL
ORA #PSTAT
TAX
LDA PROCTAB,X ; .A now contains PSTAT byte
BPL .not_run ; If Bit 7 is clear, we're NOT RUNNING
; If Bit 7 is set, we *are* RUNNING, so we want to change state to READY
LSR PROCTAB,X ; set PSTAT to READY
LDA #$04
STA PROCTAB+1,X ; reset QUANTUM (It's the next field up from PSTAT)
.not_run:
; Don't touch PSTAT, or QUANTUM, but do save stacks and switch.
; Save Stack Pointer at this point
LDA current_pid
ASL
ASL
ASL
ASL
ORA #STACK_PTR
TAY ; Y = PCB offset
TSX
TXA ; A = Stack Pointer
STA PROCTAB,Y
; Step 2 - Transfer STACKBASE into R0
LDA current_pid
ASL
ASL
ASL
ASL
ORA #STACKBASE
TAX
LDA PROCTAB,X ; Get low byte of STACKBASE
STA R0 ; Save it in pseudoregister
INX ; Get high byte of STACKBASE
LDA PROCTAB,X
STA R0+1 ; Save it in pseudoregister
; Step 3 - Save hardware stack
LDY #$00
.save_hw_stack: ; 256 Bytes
LDA $0100,Y
STA (R0),Y
INY
BNE .save_hw_stack
; Step 4 - Save data stack
INC R0+1 ; Data stack will be saved on next page
LDY #$7F
.save_data_stack: ; 128 Bytes
LDA $00,Y
STA (R0),Y
DEY
BPL .save_data_stack
; Step 4 - Locate next ready process
.next_proc: LDA current_pid
INA ; look at next process
AND #%00001111 ; Only 16 processes
STA current_pid
ASL
ASL
ASL
ASL
ORA #PSTAT
TAX
BIT PROCTAB,X ; Check status
BVC .next_proc ; If bit 6 = 0, process NOT READY
; DANGER - if no process is ready, we will sit here forever
; If the NULL process is always ready to run, this is no problem
; But if we don't check the NULL process last it might sometimes run
; when other processes are ready. Thought: we could always scan the
; process table from top to bottom. This would make the PID work with
; the quantum to provide a kind of priority.
; At this point, current_pid is a runnable process.
; Step 5 - Mark it runnable
LDA #RUN
STA PROCTAB,X ; PCSTAT is still selected
; Restore Stack Pointer here
LDA current_pid
ASL
ASL
ASL
ASL
ORA #STACK_PTR
TAX
LDA PROCTAB,X ; Get new processs's stack pointer
TAX
TXS ; Restore it
; Step 6 - Transfer STACKBASE into R0
LDA current_pid
ASL
ASL
ASL
ASL
ORA #STACKBASE
TAX
LDA PROCTAB,X ; Get low byte of STACKBASE
STA R0 ; Save it in pseudoregister
INX ; Get high byte of STACKBASE
LDA PROCTAB,X
STA R0+1 ; Save it in pseudoregister
; Step 7 - Restore hardware stack
LDY #$00
.restore_hw_stack: ; 256 Bytes
LDA (R0),Y
STA $0100,Y
INY
BNE .restore_hw_stack
; Step 8 - Restore data stack
INC R0+1 ; Data stack will be saved on next page
LDY #$7F
.restore_data_stack: ; 128 Bytes
LDA (R0),Y
STA $00,Y
DEY
BPL .restore_data_stack
; Restore registers
.exit:
PLY ; Restore Y
PLX ; Restore X
PLA ; Restore A
RTI ; _SYS_mon