Skip to content

Multitasking and Executables

TilekarOS supports Preemptive Multitasking for both Ring 0 (Kernel) and Ring 3 (User) tasks. This guide details how tasks are created, scheduled, and how ELF binaries are loaded.

1. Task Management

Source Files: task.c, task.h

Each process in TilekarOS is represented by a task_t structure, also known as a Task Control Block (TCB).

Task State Machine

stateDiagram-v2
    [*] --> CREATED: task_create()
    CREATED --> READY: Added to Queue
    READY --> RUNNING: Scheduler Picks
    RUNNING --> READY: Timer Interrupt (Preemption)
    RUNNING --> READY: task_yield()
    RUNNING --> ZOMBIE: task_exit()
    ZOMBIE --> [*]: Cleanup by Next Task

Task Control Block (TCB) Structure

classDiagram
    class task_t {
        +uint32_t id
        +uint32_t esp
        +uint32_t ebp
        +uint32_t eip
        +page_directory_t* page_dir
        +void* stack_base
        +task_state_t state
        +int priority
        +struct task_t* next
    }

Task Lifecycle:

  1. Creation: task_create() allocates a task_t and a 4KB kernel stack.
  2. Ready: The task is added to a Round-Robin circular queue.
  3. Running: The scheduler picks the next task and performs a context switch.
  4. Exiting: task_exit() marks the task as a ZOMBIE.
  5. Cleanup: The next task to run cleans up the zombie's memory.

See task.c for the full state management logic.


2. Context Switching

Source File: task.asm

Context switching is the "magic" that allows multiple tasks to share one CPU. It happens in three scenarios: - Timer Interrupt: Preempts a task after 100ms. - task_yield(): A task voluntarily gives up the CPU. - task_exit(): A task finishes its work.

Assembly Breakdown (context_switch):

The context_switch function saves the current CPU state onto the task's stack and restores the next task's state.

Code Preview: task.asm
[bits 32]

struc registers_t
    .gs:      resd 1
    .fs:      resd 1
    .es:      resd 1
    .ds:      resd 1
    .edi:     resd 1
    .esi:     resd 1
    .ebp:     resd 1
    .esp:     resd 1
    .ebx:     resd 1
    .edx:     resd 1
    .ecx:     resd 1
    .eax:     resd 1
    .eip:     resd 1
    .cs:      resd 1
    .eflags:  resd 1
endstruc

section .text

global context_switch
global get_cr3

; get_cr3() -> returns the value of CR3 register
get_cr3:
    mov eax, cr3
    ret

; context_switch(uint32_t** current_esp, uint32_t* next_esp, uint32_t next_cr3, uint32_t intr_num)
context_switch:
    ; The stack initially has the return EIP at [esp].
    ; We need to simulate an interrupt frame so we can return via iret.
    ; Pop the return EIP so we can push it properly with CS and EFLAGS.
    pop eax            ; EAX = Return EIP

    ; Push standard interrupt frame (EFLAGS, CS, EIP)
    pushfd             ; Push EFLAGS
    push cs            ; Push CS
    push eax           ; Push EIP

    ; Save general purpose registers (pushad)
    pushad

    ; Save segment registers
    push ds
    push es
    push fs
    push gs

    ; Stack layout analysis:
    ; Before pop eax:
    ;   [esp+0] = Return EIP
    ;   [esp+4] = current_esp (arg1)
    ;   [esp+8] = next_esp (arg2)
    ;   [esp+12] = next_cr3 (arg3)
    ;   [esp+16] = intr_num (arg4)
    ; After pop eax:
    ;   [esp+0] = current_esp (arg1)
    ;   [esp+4] = next_esp (arg2)
    ;   [esp+8] = next_cr3 (arg3)
    ;   [esp+12] = intr_num (arg4)
    ; After pushing 15 dwords (60 bytes) - EFLAGS(1), CS(1), EIP(1), pushad(8), segs(4):
    ;   [esp+registers_t_size] = current_esp (arg1)
    ;   [esp+registers_t_size+4] = next_esp (arg2)
    ;   [esp+registers_t_size+8] = next_cr3 (arg3)
    ;   [esp+registers_t_size+12] = intr_num (arg4)

    mov eax, [esp + registers_t_size] ; current_esp pointer (arg1)

    ; If the current_esp pointer is NULL (dummy), don't save ESP
    test eax, eax
    jz .skip_save
    mov [eax], esp      ; Save current ESP into current task's kernel_stack
.skip_save:

    ; Load next CR3
    mov edx, [esp + registers_t_size + 8] ; next_cr3 (arg3)

    ; Read current cr3 to see if we actually need to change it
    ; Avoiding unnecessary CR3 loads prevents TLB flushes
    mov eax, cr3
    cmp eax, edx
    je .skip_cr3_load
    mov cr3, edx        ; Load new page directory
.skip_cr3_load:

    ; Read intr_num (arg4) before switching the stack pointer
    mov ebx, [esp + registers_t_size + 12]

    ; Load next task's kernel_stack into ESP
    mov esp, [esp + registers_t_size + 4] 

    ; Send EOI if intr_num >= 32
    cmp ebx, 32
    jl .no_eoi

    ; We are safe to call pic_send_eoi because popad hasn't happened yet!
    ; Any clobbered EAX, ECX, EDX will be safely overwritten by the upcoming popad.
    push ebx ; Argument intr_num
    extern pic_send_eoi
    call pic_send_eoi
    add esp, 4

.no_eoi:

    ; Restore segment registers for the next task
    pop gs
    pop fs
    pop es
    pop ds

    ; Restore general purpose registers for the next task
    popad

    ; Return to the next task using iret!
    ; This will pop EIP, CS, and EFLAGS from the next task's stack.
    iret
Stack Level Saved Data
High Memory EFLAGS, CS, EIP (Interrupt Frame)
General Purpose (EAX, EBX...)
Low Memory Segment Registers (DS, ES, FS, GS)

3. ELF Loader

Source Files: elf.c, elf.h

TilekarOS can load and execute ELF32 (Executable and Linkable Format) binaries.

How it works:

  1. Header Check: elf_check_supported() verifies the magic number (0x7F 'E' 'L' 'F') and architecture.
  2. Segment Mapping: elf_load_segments() iterates through the Program Headers and maps PT_LOAD segments into the task's address space.
  3. Execution: The kernel jumps to the e_entry address specified in the ELF header.

Refer to elf.c for the loading implementation.

Security: Ring 3 Transition

When launching a user-mode task, the kernel uses the iret instruction to "fake" an interrupt return, dropping the CPU's privilege from Ring 0 to Ring 3.


4. Test/Example: Creating a Background Task

This C snippet shows how to spawn a kernel task that runs in the background while the main kernel continues:

void background_worker() {
    while (1) {
        printf("[Worker] Doing periodic maintenance...\n");
        task_yield(NULL); // Give other tasks a turn
    }
}

void start_demo() {
    task_create(background_worker, 0); // 0 = Kernel Privilege
}

5. Guide: ELF User Task Integration

This guide explains how to compile a test user process from assembly to an ELF file, embed it into the kernel, and load it.

Step 1: Create the User Task Assembly (user_task.asm.ignore)

[bits 32]
section .text
global _start

_start:
    ; A simple infinite loop for the test user process
    mov eax, 0xCAFEBABE
.loop:
    jmp .loop
nasm -f elf32 kernel/user_task.asm.ignore -o kernel/user_task.o
clang -target i386-pc-none-elf -march=i386 -nostdlib -static -Wl,-e,_start kernel/user_task.o -o kernel/user_task.elf

Step 3: Embed the ELF in the Kernel (user_process.asm)

Create a bridge assembly file to include the ELF binary.

[bits 32]
section .rodata

global _start_elf_user_task
global _end_elf_user_task

_start_elf_user_task:
    incbin "/path/to/TilekarOS/kernel/user_task.elf"
_end_elf_user_task:

Step 4: Load the ELF Task in the Kernel (kernel.c)

extern char _start_elf_user_task;
extern char _end_elf_user_task;

// Inside kernel_main
uint32_t elf_size = (uint32_t)(&_end_elf_user_task - &_start_elf_user_task);
task_create_elf(&_start_elf_user_task, 3); // 3 for user mode, 0 for kernel mode

References