Thursday, 13 February 2025

RISC-V Bare Metal OS : 2

The Story So Far

We have made an excellent start to our OS, we have loaded SBI, initialised harder, booted the processor and communicated with SBI to print a Hello World message.  In this installment we will do some consolidation making our environment useable.

Kernel Panic

If our kernel crashes, we want it to print some diagnostic information.  We define a short macro called PANIC which prints out an error message.
Because PANIC is a macro the text is inserted inline before compilation and the values for source file and line numberfilled in so they can be printed if PANIC is called.
The while(1) specifies that the program loops once it has printed the message.  do...while(0) specifies that the block is only executed once.

When the PANIC macro is included in our kernel it causes the "booted!" message to be printed out 

followed by the program source file name and number.  This simple mechanism will be very helpful in debugging our programs if we put PANIC calls wherever necessary.

Exceptions

An exception will occur if our processor encounters serious errors such as invalid instruction, invalid memory address (page fault) or a system call.  When an exception occurs the values in various communication and status registers (CSRs) provide details to help resolve the issue.
User mode applications are not able to access CSRs but if the kernel operates in supervisor mode it can read and write CSRs to deal with problems.  Thankfully openSBI initialises our machine in supervisor mode so our kernel can process exceptions.


Risc-v also incorporates machine mode which deals with hardware level issues.  We are not concerned with these as we building a kernel and openSBI has already configured the platform for us.  I may return to M-mode if/when I look at bare metal programming on a real risc-v processor, for example the VisionFire2.

We will configure our kernel to print CSRs and stop when it encounters an exception.

First we can define a couple of macros with inline assembly language so that we can use privileged instructions CSRR and CSRW to respectively read and write CSRs.






Next we define a handle_trap function which obtains the values of three CSRs, prints them and uses a PANIC macros to stop processing.


We also setup a function called kernel_entry which saves registers then calls handle_trap. Near the start of our kernel we setup the exception handler by setting the stvec register to point to kernel_entry.

The kernel then executes "unimp", a pseudo instruction which triggers an illegal instruction exception.

When our kernel runs it encounters the illegal instruction, which triggers an exception.
Processing jumps to the address in stvec, which is the kernel_entry function.

kernel_entry saves registers then calls handle_trap to retrieve CSR values and display them using the PANIC function.  These values can be used to determine what caused the exception error.  In the example above we can see that the program counter sepc was at address 80200134 when the exception occured.  scause=2 indicates an illegal instruction. Using the llvm-addr2line utility we discover that address 80200134 corresponds to line 135 in kernel.c.  Looking at kernel.c we see that the "unimp" instruction is indeed at line 135!  MagicπŸ˜€

Allocate Memory

We use a very simple allocation system which allocates 4096 bytes (hex 0x1000) of physical memory for each request and never frees up memory after use.  Each time alloc_pages is called it returns the memory address __free_ram and increases __free_ram by 4096.
Our test program calls alloc_pages twice, the first time two pages are allocated starting at 8022-1000 and then a page is allocated at 8022-3000







Physical memory starts at 8020-0000 and we start allocating physical memory at the initial value of __free_ram 8022-1000 which follows programs, data, variables and the stack.











Process

Now we need the concept of a process.  The  kernel needs to be able to run more than one application at a time.  Each process will have its own execution context (registers etc) and resources (address space etc).  We need the ability to setup a process for each application and to switch between the contexts to execute different applications.
A Process Control Block (PCB) structure is defined which contains pid, state, stack pointer and an 8KB stack.



Our kernel has PROCS_MAX =8 so it can run 8 processes.
A PCB structure array procs contains 8 entries.
The create_process function is quite simple.  First it checks whether there is a free process slot.

If there is a free process slot it initialises the stack pointer and saves (initialises) 0s to the stack for register values.
These values will be restored to registers the first time the process is started.





The context switch is also very simple.  First it saves "callee registers" (ra, s0-s11) next it switches to the stack pointer for the new process and restores its callee registers.



Now comes the real magic.  We define two functions proc_a_entry and proc_b_entry which will do the work.  We keep this first test simple, each process prints a character then switches to the other process.  A delay is incorporated to stop characters coming out too quickly.
Next, processes proc_a and proc_b are created and the functions start address is passed over.  This will become the program counter (PC).
Finally proc_a_entry is invoked.  It prints 'A' and context switches to proc_b.  The switch includes restoring the return  register for proc_b so the context_switch returns into proc_b  which prints 'B' then switches back to proc_a.
The processes continue to swap and a string of A s and Bs is printed.
I think this is awesome.  
The actual C programming is a little complicated for me because it uses pointers so much, but the result is wonderful.😁😁

Scheduler



Our first approach to context switching was awesome but we certainly dont want each process to specify which process will run next.  Instead, at a convenient time we tell the function to yield. The yield function is responsible for scheduling another task to run.  Note that functions are still running within the kernel and the programmer has responsibilty to pass control to the scheduler.  I believe (not sure) that you can use multiple yield functions at various points in a function.

The yield function contains the scheduler.  It steps through the Program Control Blocks until it finds another process that is free to run and then does a context_switch to that process.  If it doesn't find another process it continues with the current process.

This is beautifully simple 😁

There is a little extra work required in the exception handler to keep track of the current stack pointers but it isn't a major change (well I don't really understand it) so I wont include it here.






The Story So Far

We have made awesome progress on our kernel; we can boot it up, write output to a console, use standard library functions (ours are somewhat simplified).  We can also deal with crashes, allocate memory and run / schedule multiple processes.
So far everything is running within the kernel which is running in supervisor mode  Next we must prepare the ability to run user mode applications and prevent them adversely affecting the kernel or other user programs.

No comments:

Post a Comment