Implementing x86 Boot Sequences and Kernel Stack Inspection

Build Environment and Emulator Configuration

The development environment relies on a headless Debian 11 guest running inside VMware, utilizing GCC 10 and the x86_64 instruction set. For emulation, only QEMU is required since hardware-assisted virtualization is not mandatory for this course material.

When launching the emulator on a non-graphical host, the default Makefile configuration attempts to initialize a GTK display server, which fails. Adjust the QEMU invocation flags in the project's build configuration to enforce text-only output and maintain the remote debugging socket:


-QEMUOPTS = -drive file=$(OBJDIR)/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::$(GDBPORT)
+QEMUOPTS = -drive file=$(OBJDIR)/kern/kernel.img,index=0,media=disk,format=raw -nographic -gdb tcp::$(GDBPORT)

The -nographic directive suppresses the GUI window, routing serial output to the terminal. The -gdb tcp::$(GDBPORT) flag opens a listening socket for remote debugging sessions. When invoking make qemu-gdb, the emulator additional receives the -S flag, which halts the virtual CPU immediately after startup, allowing a debugger to attach before any instructions execute.

Physical Memory Architecture and BIOS Handoff

Early x86 processors supported a maximum physical address space of 1 MB (0xFFFFF). Legacy constraints reserve the upper regions for firmware and memory-mapped I/O, leaving the lower 640 KB (0x00000000 to 0x000A0000) available for conventional RAM.


+------------------+  <- 0xFFFFFFFF (4GB)
|      32-bit      |
|  memory mapped   |
|     devices      |
|                  |
|                  |
|      Unused      |
|                  |
+------------------+  <- varies by RAM size
|                  |
| Extended Memory  |
|                  |
+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000

Upon power-on, the processor begins execution at a fixed reset vector. Using a segmented address calculation (segment \* 16) + offset, the initial instruction pointer points to 0xFFFF0 (CS:IP = 0xF000:0xFFF0). This location is hardcoded by the architecture to contain a jump instruction that transfers control to the BIOS firmware.


[f000:fff0]    0xffff0: ljmp   $0x3630,$0xf000e05b

Bootloader Mechanics and Kernel Transfer

The bootloader consists of boot/boot.S and boot/main.c. It operates in 16-bit real mode initially and handles the transition to 32-bit protected mode. Key execution points include:

  • Mode Switch: The transition to 32-bit code occurs when a far jump (ljmp) is executed to clear the instruction prefetch queue and load a 32-bit code segment selector. The instruction immediately following the jump executes in protected mode.
  • Handoff to Kernel: The final bootloader instruction is a function pointer call ((void (\*)(void)) (ELFHDR-&gt;e\_entry))();, which corresponds to call \*0x10018 in assembly. The kernel's first executed instruction typically reads from the disk controller into memory.
  • ELF Loading Logic: The bootloader reads the first sector, then parses the ELF header to locate the program header table (e\_phoff). It iterates through each segment descriptor to determine the file offset, destination physical address, and size, copying each segment into RAM before jumping to e\_entry.

Virtual Addressing and Kernel Initialization

The kernel image is loaded into physical memory starting at 0x100000. However, the linker script (kern/kernel.ld) asssigns virtual addresses beginning at 0xF0100000 to reserve lower memory for user-space processes. The entry.S file establishes this mapping using the RELOC macro:


#define _start RELOC(entry)

Running readelf -h obj/kern/kernel reveals three program headers and an entry point at 0x10000c. The readelf -l output confirms the physical vs. virtual address translation offsets. Before executing C code, the bootloader enables paging by loading the page directory base address into CR3 and setting the PG bit in CR0, as mandated by Intel architecture manuals.

Stack Tracing and Frame Walking

To simplify assembly-to-C mapping during debugging, compilation flags -no-pie -fno-pic are applied to eliminate position-independent code generation, and optimization is set to -O0. The x86 calling convention maintains a linked list of stack frames using the base pointer (ebp). Each frame stores the previous frame's base pointer, followed by the return address and function arguments.

The backtrace implementation walks this chain by dereferencing the current frame pointer to locate the return address and the next frame. Here is a refactored version of the inspection routine:

static int print_stack_frames(int argc, char **argv, struct Trapframe *tf)
{
    cprintf("Execution Stack Trace:\n");
    uint32_t current_frame = *(uint32_t *)read_frame_ptr();
    
    while (current_frame != 0x0) {
        uint32_t ret_address = *(uint32_t *)(current_frame + 4);
        cprintf("  Frame: %08x  Return: %08x  Args:", current_frame, ret_address);
        
        struct Eipdebuginfo sym_info;
        resolve_debug_symbols(ret_address, &sym_info);
        
        for (int arg_idx = 0; arg_idx < 5; arg_idx++) {
            cprintf(" %08x", *(uint32_t *)(current_frame + 8 + arg_idx * sizeof(uint32_t)));
        }
        cprintf("\n         %s:%d: %.*s+%d\n", sym_info.eip_file, sym_info.eip_line,
                sym_info.eip_fn_namelen, sym_info.eip_fn_name, 
                ret_address - sym_info.eip_fn_addr);
        
        current_frame = *(uint32_t *)current_frame;
    }
    return 0;
}

Execution output demonstrates the frame pointer chain and argument extraction:


Stack backtrace:
  Frame: f010ff18  Return: f01000a1  Args: 00000000 00000000 00000000 f010004a f0111308
  Frame: f010ff38  Return: f0100076  Args: 00000000 00000001 f010ff78 f010004a f0111308
  Frame: f010ff58  Return: f0100076  Args: 00000001 00000002 f010ff98 f010004a f0111308
  Frame: f010ff78  Return: f0100076  Args: 00000002 00000003 f010ffb8 f010004a f0111308
  Frame: f010ff98  Return: f0100076  Args: 00000003 00000004 00000000 f010004a f0111308
  Frame: f010ffb8  Return: f0100076  Args: 00000004 00000005 00000000 f010004a f0111308
  Frame: f010ffd8  Return: f0100102  Args: 00000005 00001aac 00000660 00000000 00000000
  Frame: f010fff8  Return: f010003e  Args: 00000003 00001003 00002003 00003003 00004003

Symbol Table Parsing for Debug Information

Raw addresses lack readability. The kernel embeds debugging data in the .stab section, structured as an array of struct Stab entries. Relevant types include:

  • N_SO: Marks the start of a source file.
  • N_FUN: Denotes a function symbol and its address range.
  • N_SLINE: Records source line numbers for specific instruction offsets.
  • N_PSYM: Describes function parameters.

A binary search strategy maps an instruction pointer to its corresponding source file, function, and line number. First, locate the N\_SO entry covering the address. Within that file's scope, find the N\_FUN entry. Finally, scan forward for N\_SLINE entries to pinpoint the exact line. Parameter counting involves iterating between the function start and the first line entry, tallying N\_PSYM occurrences:

uint32_t param_counter = 0;
for (uint32_t iter = func_bound_low + 1; iter < func_bound_high && stabs[iter].n_type != N_SLINE; iter++) {
    if (stabs[iter].n_type == N_PSYM) {
        param_counter++;
    }
}
info->eip_fn_narg = param_counter;
info->eip_line = stabs[func_bound_high].n_desc;

With resolved symbols, the backtrace output gains contextual meaning:


K> backtrace
Stack backtrace:
  Frame: f0110f38  Return: f0100f2b  Args: 00000001 f0110f58
         kern/monitor.c:96: runcmd+323
  Frame: f0110fa8  Return: f0100fbb  Args: f01142c9
         kern/monitor.c:135: monitor+95
  Frame: f0110fd8  Return: f010012d  Args:
         kern/init.c:24: i386_init+128
  Frame: f0110ff8  Return: f010003e  Args:
         kern/entry.S:44: <unknown>+0

Consolidated Boot Memory Map

The complete boot sequence distributes code and data across physical and virtual boundaries as follows:


              +------------------+  <- 0xFFFFFFFF (4GB)
              |      32-bit      |
              |  memory mapped   |
              |     devices      |
              |                  |
              +------------------+  <- (2GB+Kernel Program Size)
              |   (JOS) Kernel   |
   +--------> +------------------+  <- 0xF0100000 (2GB+1MB)
   |          |                  |
   |          +------------------+  <- 0xF0000000 (2GB)
   |          /\/\/\/\/\/\/\/\/\/\
   |          
   |          /\/\/\/\/\/\/\/\/\/\
   |          |                  |
   3          |      Unused      |
   |          |                  |
   |          ------------------+  <- varies by RAM size
   |          |                  |
   |          |                  |
   |          | Extended Memory  |
   |          |                  |
   |          +------------------+  <- 0x00100000 (1MB+4KB)
   +--------- | (JOS) 1st Page   |
      +-----> +------------------+  <- 0x00100000 (1MB)
      |   +-- |     BIOS ROM     |
      |   |   +------------------+  <- 0x000F0000 (960KB)
      |   |   |  16-bit devices, |
      2   |   |  expansion ROMs  |
      |   1   +------------------+  <- 0x000C0000 (768KB)
      |   |   |   VGA Display    |
      |   |   +------------------+  <- 0x000A0000 (640KB)
      +-- v - |  (JOS) 1st Sec   |
          +-> +------------------+  <- 0x00007C00 (31KB)
              |                  |
              |    Low Memory    |
              |                  |
              +------------------+  <- 0x00000000

1. The BIOS loads the first disk sector into physical address 0x7C00 and executes a jump to transfer control. 2. The primary bootloader reads subsequent sectors containing the ELF kernel image, places segments at 0x100000, maps them to the high virtual address 0xF0100000, and jumps to the kernel entry point. 3. The kernel initializes its memory management structures, activates paging, and establishes the runtime stack environment.

Tags: x86-architecture bootloader kernel-development gdb-debugging virtual-memory

Posted on Fri, 15 May 2026 08:15:49 +0000 by Imad