Let’s Build an Own Operating System (PrimitiveOS)

Sachin Tharaka
7 min readJul 23, 2021

Part 2- Implement with C

Welcome again!.

This is the second part of Implement Your Own Operating System. Please read Part 01 if you haven’t already done so

Why C language over Assembly?

This article will show you how to use C instead of assembly code as the programming language for the OS. C is easier to program in, compared to Assembly. There are obvious reasons not worth rehashing. Being easier to use, C allows you to write programs faster. Generally, these programs are also easier to debug and easier to maintain. C is a considerably more user-friendly language. As a result, we’d want to utilize C as much as feasible and assembly code only when necessary. On the other hand, Assembly is excellent for interfacing with the CPU and provides complete control over all aspects of the code.

Let’s go. You can follow the steps below

Step 1 — Setting Up a Stack

One prerequisite for using C is a stack since all non-trivial C programs use a stack. Setting up a stack is not harder than making the esp register point to the end of an area of free memory (remember that the stack grows towards lower addresses on the x86) that is correctly aligned (alignment on 4 bytes is recommended from a performance perspective).

Reserving a piece of uninitialized memory in the bss section in the ELF file of the kernel will be a solution. And also, this will reduce the OS executable size.

Add this section to loader.s the file We made earlier in week 1.

KERNEL_STACK_SIZE equ 4096                  ; size of stack in bytes    section .bss
align 4 ; align at 4 bytes
kernel_stack: ; label points to beginning of memory
resb KERNEL_STACK_SIZE ; reserve stack for the kernel

And add the below code to set up the stack pointer. This is done by pointing esp to the end of the kernel_stack memory.

mov esp, kernel_stack + KERNEL_STACK_SIZE   ; point esp to the start of the
; stack (end of memory area)

Now your loader.s file should look like below.

Step 2 — Calling C Code From Assembly

The next step is to call a C function from the assembly code. There are many different conventions for how to call C code from assembly code [25]. This book uses the cdecl calling convention since that is the one used by GCC. The cdecl calling convention states that arguments to a function should be passed via the stack (on x86). The arguments of the function should be pushed on the stack in right-to-left order, that is, you push the rightmost argument first. The return value of the function is placed in the eax register. The following code shows an example:

/* The C function */
int sum_of_three(int arg1, int arg2, int arg3)
{
return arg1 + arg2 + arg3;
}

Just writing a C function is not enough. We need to call it using assembly.

According to the cdecl calling convention, arguments to functions should be passed through the stack.

To do this place the following code after the esp instruction in the loader label of the loader.s.

external sum_of_three   ; the function sum_of_three is defined elsewhere    push dword 3            ; arg3
push dword 2 ; arg2
push dword 1 ; arg1
call sum_of_three ; call the function, the result will be in eax

In your project directory, create an empty file called kmain.c. You can do it with touch kmain.c command. You can keep this file empty for now.

Step 3 — Packing struct

Many flags to GCC must be used while compiling the C code for the OS. This is because the C code should not assume the presence of a standard library. After all, our operating system does not have one.

A flag is a C preprocessor, which is run on each C source file before real compilation, is controlled by these parameters.

You’ll see “configuration bytes” a lot in the rest of this book, which is a collection of bits in a specified sequence. The following is a 32-bit example:

Bit:     | 31     24 | 23          8 | 7     0 |
Content: | index | address | config |

It is considerably more convenient to utilize “packed structures” instead of an unsigned integer, unsigned int, for handling such configurations:

struct example {
unsigned char config; /* bit 0 - 7 */
unsigned short address; /* bit 8 - 23 */
unsigned char index; /* bit 24 - 31 */
};

When using the struct in the previous example, there is no guarantee that the size of the struct will be exactly 32 bits - the compiler can add some padding between elements for various reasons, for example, to speed up element access or due to requirements set by the hardware and/or compiler. When using a struct to represent configuration bytes, the compiler mustn't add any padding because the struct will eventually be treated as a 32-bit unsigned integer by the hardware. The attribute packed can be used to force GCC to not add any padding:

struct example {
unsigned char config; /* bit 0 - 7 */
unsigned short address; /* bit 8 - 23 */
unsigned char index; /* bit 24 - 31 */
} __attribute__((packed));

Note that __attribute__((packed)) is not part of the C standard - it might not work with all C compilers.

Step 4 — Compiling C Code

Many flags to GCC must be used while building the C code for the OS. This is because the C code should not presume the availability of a standard library. After all, our operating system does not have one. The GCC handbook has further information on the flags.

The flags used for compiling the C code are:

-m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector -nostartfiles
-nodefaultlibs

As always when writing C programs we recommend turning on all warnings and treat warnings as errors:

-Wall -Wextra -Werror

You can now create a function kmain in a file called kmain.c that you call from loader.s. At this point, kmain probably won’t need any arguments (but in later chapters it will).

After adding the above code snippets loader.s will look like this

global loader                  ; the entry symbol for ELF
extern kmain
MAGIC_NUMBER equ 0x1BADB002 ; define the magic number constant
; https://github.com/nirs/littleosbook/commit/04a694cdcbca458e30212ca5e9b562d5954db386
FLAGS equ 0x0 ; multiboot flags
CHECKSUM equ -MAGIC_NUMBER ; calculate the checksum (magic number + checksum should equal 0)
;; Reserve memory for kernel stack
KERNEL_STACK_SIZE equ 4096 ; size of stack in bytes
section .bss
align 4 ; align at 4 bytes
kernel_stack: ; label points to beginning of memory
resb KERNEL_STACK_SIZE ; reserve stack for the kernel
section .text: ; start of the text (code) section
align 4 ; the code must be 4 byte aligned
dd MAGIC_NUMBER ; write the magic number to the machine code
dd FLAGS ; the flags,
dd CHECKSUM ; and the checksum
loader: ; the loader label (defined as entry point in linker script)
mov eax, 0xCAFEBABE ; place the number 0xCAFEBABE in the register eax
mov esp, kernel_stack + KERNEL_STACK_SIZE ; point esp to the start of the
; stack (end of memory area)
call kmain
.loop:
jmp .loop ; loop forever

Step 5 — Build Tools

Now is also probably a good time to set up some build tools to make it easier to compile and test-run the OS. We recommend using make [13], but there are plenty of other build systems available

Create a file named Makefile with the code given below.

OBJECTS = loader.o kmain.o
CC = gcc
CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector \
-nostartfiles -nodefaultlibs -Wall -Wextra -Werror -c
LDFLAGS = -T link.ld -melf_i386
AS = nasm
ASFLAGS = -f elf

all: kernel.elf

kernel.elf: $(OBJECTS)
ld $(LDFLAGS) $(OBJECTS) -o kernel.elf

os.iso: kernel.elf
cp kernel.elf iso/boot/kernel.elf
genisoimage -R \
-b boot/grub/stage2_eltorito \
-no-emul-boot \
-boot-load-size 4 \
-A os \
-input-charset utf8 \
-quiet \
-boot-info-table \
-o os.iso \
iso

run: os.iso
bochs -f bochsrc.txt -q

%.o: %.c
$(CC) $(CFLAGS) $< -o $@

%.o: %.s
$(AS) $(ASFLAGS) $< -o $@

clean:
rm -rf *.o kernel.elf os.iso

Now you have the following files.

The contents of your working directory should now look like the following figure:

.
|-- bochsrc.txt
|-- iso
| |-- boot
| |-- grub
| |-- menu.lst
| |-- stage2_eltorito
|-- kmain.c
|-- loader.s
|-- Makefile

You should now be able to start the OS with the simple command make run, which will compile the kernel and boot it up in Bochs (as defined in the Makefile above).

You can see kernel and boch like follows.

Then run continue command in the interactive start menu.

then check the bochslog.txt to find RAX=00000000CAFEBABE or EAX=CAFEBABE to make sure that your OS has successfully booted.

For any issue with making files, you can refer the following repository….

https://github.com/Sachin-Tharaka/PrimitiveOS -Branch 2(Implement_with_C)

Hope to see you soon in the next article as well!

Thank you! #Staysafe #Stayconnected

Reference: https://littleosbook.github.io/

--

--

Sachin Tharaka

Software Engineering, University of Kelaniya, Sri Lanka