Writing OS kernels in high-level operating systems is a bit tough. These languages are tailored for developing user-space applications and as such make a bunch of assumptions about the target by default. As such, there are always some adjustments we need to make in order to compile and run our kernel.
In this article I want to talk a bit about what adjustments need to be done for Odin and while at it explain a bit about how the Odin runtime works.
A quick peek at core:runtime
By default the entry points of Odin are located in core:runtime
package. Here’s an example of an entry point (and accompanying functions) that is invoked when compiling for unix platforms with CRT:
// IMPORTANT NOTE(bill): Do not call this unless you want
// to explicitly set up the entry point and how it gets called
// This is probably only useful for freestanding targets
foreign {
@(link_name="__$startup_runtime")
_startup_runtime :: proc "odin" () ---
@(link_name="__$cleanup_runtime")
_cleanup_runtime :: proc "odin" () ---
}
default_context :: proc "contextless" () -> Context {
c: Context
// Sets up allocators and assertion failure procedure
__init_context(&c)
return c
}
@(link_name="main", linkage="strong", require)
main :: proc "c" (argc: i32, argv: [^]cstring) -> i32 {
args__ = argv[:argc]
context = default_context()
#force_no_inline _startup_runtime()
intrinsics.__entry_point()
#force_no_inline _cleanup_runtime()
return 0
}
The arguments are passed to us by the C runtime library (libc), and we quickly stash them into a global for use by the other modules.
Then we see that the default context is initialized functions without an explicit calling convention pass context as a parameter, so we’ll need the context to call into the entry point.
Then we see calls to __$startup_runtime
and __$cleanup_runtime
. The first function initializes some global variables and calls @(init)
functions in our code.
So the first step in our kernel we need to do is to create a context and initialize runtime. Let’s keep that in mind
SSE
If you call the following line:
context = {}
And look at the disassembly, you’ll see this:
xorps xmm0,xmm0
movaps XMMWORD PTR [rsp],xmm0
movaps XMMWORD PTR [rsp+0x90],xmm0
movaps XMMWORD PTR [rsp+0x80],xmm0
movaps XMMWORD PTR [rsp+0x70],xmm0
movaps XMMWORD PTR [rsp+0x60],xmm0
movaps XMMWORD PTR [rsp+0x50],xmm0
movaps XMMWORD PTR [rsp+0x40],xmm0
lea rdi,[rsp+0x40]
mov QWORD PTR [rsp+0x18],rdi
call runtime.__init_context-900
movaps xmm0,XMMWORD PTR [rsp]
mov rdi,QWORD PTR [rsp+0x18]
movaps XMMWORD PTR [rsp+0x90],xmm0
movaps XMMWORD PTR [rsp+0x80],xmm0
movaps XMMWORD PTR [rsp+0x70],xmm0
movaps XMMWORD PTR [rsp+0x60],xmm0
movaps XMMWORD PTR [rsp+0x50],xmm0
movaps XMMWORD PTR [rsp+0x40],xmm0
context
is a ~196-byte struct, and the way it’s being initialized is with SSE instructions. Ignore the ugly code, this is an old issue where the compiler will zero-fill the struct, initialize the context and then zero-fill it again (efficiency!).
The issue here is that SSE is not enabled on processors by default. And we cannot stop Odin from generating SSE instructions, so we’ll have to enable SSE in our kernel. Let’s keep that in mind too.
Writing the kernel
For writing the kernel I’ll be using the limine bootloader. I’m using the binary release from v3 branch, because that seemed like it was stable enough for me to not have to edit the code after I post this article.
Limine bootloader uses the limine boot protocol (actually it supports more, but I’ll use this one), the bindings for which you can find here.
If you’re considering writing a kernel without a bootloader consider different ways to suffer instead.
You can find the full code in my repository.
The entry point
Our kernel’s entry point will look something like this:
foreign import cpu "cpu/cpu.asm"
foreign cpu {
enable_sse :: proc "sysv" () ---
halt_catch_fire :: proc "sysv" () -> ! ---
}
foreign {
@(link_name="__$startup_runtime")
_startup_runtime :: proc "odin" () ---
@(link_name="__$cleanup_runtime")
_cleanup_runtime :: proc "odin" () ---
}
@(export, link_name="_start")
kmain :: proc "sysv" () {
enable_sse()
context = {}
#force_no_inline _startup_runtime()
halt_catch_fire()
}
We do a foreign import of an asm file, but note that this doesn’t actually do anything — Odin will compile the nasm file, throw it away and not do anything. Basically just think about that line as documenting where the procedures are coming from, but the actual compiling and linking is done somewhere else. foreign import “.asm”
actually does nothing if your target is an object file.
Then we bind startup runtime functions, just like in the core:runtime
package. We’re a kernel so we don’t care about cleaning anything up. We just need the startup. These functions are explicitly specified as having the odin
calling convention for demonstrative purposes. These functions use the context (probably?). Anyway they take it as a parameter.
The start function is exported so that the bootloader can find it, and we’ll give it an appropriate name so that the linker will make this our entry point. The functions enable_sse
and halt_catch_fire
are be implemented in assembly, and here’s their source code:
cpu x86-64
bits 64
global enable_sse
global halt_catch_fire
section .text
enable_sse:
;; Clear CR0.EM and set CR0.MP
mov rax, cr0
and ax, 0xfffb
or ax, 0x0002
mov cr0, rax
;; Set CR4.OSFXSR and CR4.OSXMMEXCPT
mov rax, cr4
or ax, 3<<9
mov cr4, rax
ret
halt_catch_fire:
cli
.loop:
hlt
jmp .loop
Compiling flags
This is how I’m compiling the kernel
odin build kernel \
-out:bin/kernel \
-collection:kernel=kernel \
-debug \
-build-mode:obj \
-target:freestanding_amd64_sysv \
-no-crt \
-no-thread-local \
-no-entry-point \
-reloc-mode:pic \
-disable-red-zone \
-default-to-nil-allocator \
-foreign-error-procedures \
-vet \
-strict-style \
-disallow-do \
-no-threaded-checker \
-max-error-count:5
if [ $? -ne 0 ]; then
exit 1
fi
nasm kernel/cpu/cpu.asm \
-o bin/cpu.o \
-f elf64
ld bin/kernel.o bin/cpu.o \
-o bin/kernel.elf \
-m elf_x86_64 \
-nostdlib \
-static \
-pie \
--no-dynamic-linker \
-z text \
-z max-page-size=0x1000 \
-T kernel/link.ld
We set a few options on the Odin compiler:
target:freestanding_amd64_sysv
— tells the compiler that we’re compiling to a target that can’t call into the OS. I forgot the scope of this option, like what it actually does, pretty sure it disables some of the core library packages, but other than that idk.-no-crt
— tells the compiler to not link to the libc library. Freestanding target probably implies that, but doesn’t hurt to put that in anyway-no-thread-local
— disables@(thread_local)
attribute. This is one of the options I added into the Odin compiler in case you want to call into core library packages. Some of the packages define thread_local variables which crashes your kernel, I didn’t do much investigation, probably one of the initialization procedures accessed the TLS before it was initialized.-reloc-mode:pic
— not sure what’s the default on this, but this tells the compiler to use RIP-relative addressing (we’re loading the kernel at a fixed address anyway so it could bestatic
as well.-disable-red-zone
— makes it so that functions don’t use stack space below RSP without allocating that space-default-to-nil-allocator
— Default context allocator is a nil allocator (we’ll initialize our own)-foreign-error-procedures
— The procedures that are called for performing bounds checking are foreign, we can use this option to define custom bounds checking procedures.
The rest of the compiler options are totally optional and you don’t have to use them if you have no need/don’t want to.
After we’ve compiled our object file, we’ll compile the assembly file, and pass both objects to the linker. This is what I’ve been talking earlier about with foreign imports not doing anything if the target is obj. The linking takes place in a linker, not the compiler.
The linker flags are nothing to comment on, just use man pages for the linker to see what the options do. The linker script is copy-pasted from Limine Bare Bones article on osdev.org wiki, there’s nothing specific we need to do.
Foreign error procedures
(TODO)
Conclusion
I will not comment every single line of code, since after some point osdev in Odin is exactly the same. If you clone and run the code from my repository, you should see the following:
Have fun developing the kernels!