os-tutorial: Building an Operating System from Scratch

repository·master·Indexed 12 days ago

https://github.com/cfenollosa/os-tutorial

A step-by-step, code-driven tutorial for building an operating system, moving from Assembly to C. It covers low-level implementation of boot sectors, 16-bit real mode memory segmentation, BIOS interrupts (0x10 for video, 0x13 for disk), 32-bit mode, and interrupt handling using NASM and QEMU.

Tokens
11.5K
Snippets
48
Records
71
Agent score
98%

What's inside os-tutorial

  1. Overview of os-tutorial features and goals

    master

    The os-tutorial project is a hands-on, low-level computing course aimed at developers who want to understand OS design without reading massive kernels like Linux.

    Key Characteristics:

    • Code-First: Focuses on implementation rather than heavy theoretical lectures.
    • Granular Lessons: Each lesson is designed to be completed in 5-15 minutes.
    • Incremental Complexity: The project progresses from basic boot sectors to complex systems.

    Planned/Completed Roadmap:

    • Booting from scratch (without GRUB)
    • Entering 32-bit mode
    • Transitioning from Assembly to C
    • Interrupt handling
    • Screen output and keyboard input
    • Building a basic libc
    • Memory management
    • Filesystem implementation
    • Shell creation
    • User mode implementation
    • Process scheduling and multiple processes
    • (Advanced) BASIC interpreter, GUI, or Networking
  2. Use machine-dependent data types in `cpu/types.h`

    master

    To uncouple data structures from standard C types (like char or int) which may vary in size, use the specialized types defined in cpu/types.h. These are located in the cpu/ directory, which is reserved for machine-dependent code.

    Available types:

    • u8
    • u16
    • u32
  3. Use BEGIN_PM as the 32-bit entry point

    master
    Once the transition to 32-bit protected mode is complete, the execution should jump to the BEGIN_PM label. This label serves as the entry point for high-level 32-bit code, such as a kernel. The implementation of this entry point and the subsequent 32-bit logic can be found in 32bit-main.asm.
  4. Understand the keyboard input flow

    master

    The system handles keyboard input through a callback mechanism that populates a buffer and provides a high-level API for reading input:

    1. Interrupt/Callback: When a key is pressed, the callback in keyboard.c receives the ASCII code.
    2. Buffering: The character is appended to a buffer named key_buffer and printed to the screen.
    3. Backspace Handling: The system handles backspace by removing the last element from key_buffer and calling screen.c:kprint_backspace() to update the display.
    4. Reading Input: To retrieve user input, the kernel calls libc/io.c:readline().
    5. Completion: The keyboard callback checks for a newline character to signal that the user has finished their input.
  5. Call functions and manage parameters

    master

    While a simple jmp can act as a function call, it lacks a way to return to a variable location and can cause 'spaghetti code' if registers are modified unexpectedly.

    To implement proper subroutines:

    1. Use call and ret: Instead of jmp, use call <label> to jump to a function. The CPU automatically stores the return address, allowing the function to return to the caller using the ret instruction.
    2. Protect registers with pusha and popa: To ensure a function doesn't have side effects on the caller's state, use pusha at the start of the function to save all registers to the stack, and popa at the end to restore them.
    ; Example of a proper function structure
    print:
        pusha          ; Save all registers
        mov ah, 0x0e   ; BIOS TTY code
        int 0x10       ; Call BIOS interrupt
        popa           ; Restore all registers
        ret            ; Return to caller
  6. Understand stack behavior in boot sectors

    master

    When writing boot sectors, you must manage the stack using the bp (base pointer) and sp (stack pointer) registers.

    Key mechanics:

    • bp: Stores the base address (the bottom) of the stack.
    • sp: Stores the top of the stack.
    • Stack Growth: The stack grows downwards. This means as data is pushed onto the stack, the sp register is decremented.
  7. Program the Global Descriptor Table (GDT)

    master

    In 32-bit mode, segmentation uses the GDT to define memory segments. Each segment descriptor (SD) in the GDT specifies a 32-bit base address, a 20-bit size, and various flags (e.g., permissions, read-only status).

    To implement a basic GDT for booting, follow these requirements:

    1. Null Descriptor: The first entry in the GDT must be 0x00. This acts as a safety mechanism to catch errors in address management.
    2. Segment Definition: Define at least two segments: one for code and one for data. For initial booting, these segments can overlap (providing no memory protection), which simplifies the setup.
    3. GDT Descriptor: The CPU cannot load the GDT address directly. You must create a meta-structure called the "GDT descriptor" that contains the size (16 bits) and the base address (32 bits) of the actual GDT.
    4. Loading the GDT: Use the lgdt assembly instruction to load the GDT descriptor into the CPU.
  8. Implement control structures using jumps

    master

    Control flow in assembly is managed via jumps (jmp) and conditional jumps. Conditional jumps depend on the result of the previous instruction (e.g., a cmp instruction).

    Common pattern for if/else logic:

    1. Use cmp to compare values.
    2. Use a conditional jump (like je for 'jump if equal') to go to the 'if' block.
    3. Use an unconditional jmp to skip the 'if' block and reach the 'else' block.
    4. Ensure the 'if' block ends with a jmp to skip the 'else' block.
    cmp ax, 4      ; compare ax to 4
    je ax_is_four  ; jump to label if equal
    jmp else       ; jump to else if not equal
    jmp endif      ; jump to end of structure
    
    ax_is_four:
        ; ... code for if ...
        jmp endif
    
    else:
        ; ... code for else ...
        jmp endif
    
    endif:
  9. Understand 16-bit real mode memory segmentation

    master

    In 16-bit real mode, memory is addressed using segmentation. Instead of a single flat address, the CPU uses a segment register and an offset to compute a physical address.

    Segment Registers

    The CPU implicitly uses specific registers for different types of memory access:

    • cs: Code Segment
    • ds: Data Segment
    • ss: Stack Segment
    • es: Extra Segment (used for user-defined data)

    Warning: Because these registers are used implicitly, setting a value in a register like ds will change the base offset for all subsequent memory accesses.

    Address Calculation

    The physical address is calculated by shifting the segment value left by 4 bits (effectively multiplying by 16) and adding the offset: physical_address = (segment << 4) + offset

    Example: If ds is 0x4d and you access memory at offset [0x20], the physical address is 0x4d0 + 0x20 = 0x4f0.

  10. Handle errors using the carry bit

    master

    The carry bit is an extra bit in each register that indicates if an operation has overflowed its capacity. In BIOS disk operations, the carry bit is used to signal errors.

    You can use conditional jump instructions like jc (jump if the carry bit is set) to handle these error states.

    Example of how the carry bit is set during an overflow:

    mov ax, 0xFFFF
    add ax, 1 ; ax = 0x0000 and carry = 1
  11. Query the VGA cursor position using I/O ports

    master

    You can determine the screen cursor position by querying specific VGA I/O ports. The process involves a two-step query to the index port, which then stores the result in the data port:

    1. Query port 0x3d4 with value 14 to request the cursor position high byte.
    2. Query port 0x3d4 with value 15 to request the cursor position low byte.
    3. The resulting values are stored in port 0x3d5.

    An implementation example can be found in kernel/kernel.c.

    /* Logic summary:
    // Request high byte
    inb(0x3d4, 14);
    // Request low byte
    inb(0x3d4, 15);
    // Read result from data port
    result = inb(0x3d5);
    */
  12. Implement keyboard interrupt handling via scancodes

    master

    The keyboard driver handles interrupts by translating hardware scancodes into ASCII characters.

    Key components in drivers/keyboard.c:

    • Initialization: A function to configure the interrupt callback.
    • Callback: The interrupt handler that processes incoming scancodes.
    • Scancode Translation: A lookup table is used to map scancodes (received for both key-down and key-up events) to ASCII keys.

    Note: The current implementation supports a subset of the US keyboard layout. Definitions for these operations are located in drivers/keyboard.h.