5
\$\begingroup\$

I'm learning OS development and have written a single stage bootloader that loads the kernel, enters to protected mode and finally jumps to the kernel.

I'll very appreciate any feedback and suggestion about the code. I'll be definitely glad to see any suggestions on my build system (Makefile and the linker scripts) and how I can improve it. Should I write two separate Makefiles for both the bootloader and the kernel?

You can take a look on the files in the Github repository if it will be more comfortable.

Thanks for your time!

src/bootloader/bootloader.s:

/*
===============================================================================

                         SECTION .text.bootentry

===============================================================================
*/

    .section .text.bootentry
    .code16

    .global bootentry

bootentry:
    /* When BIOS finds a boot sector, BIOS loads it into memory at
     * 0x0000:0x7C00, but some BIOSes loads it into memory at 0x07C0:0x0000,
     * so it is a good practice to enforce CS:IP at the very first part of
     * a boot sector.
     *
     * We can not manually set CS or IP, but we can long jump to the
     * memory address we need: 0x0000:$bootentry.start.
     */
    jmp $0x0000,$bootentry.start

bootentry.start:
    /* This early execution environment is highly implemetation defined,
     * meaning the implementation of your particular BIOS.
     *
     * So we do not ever make any assumptions on the content of the
     * registers, so we need to initialize at least the data segment
     * register because we use it anytime when we access the data in
     * our code.
     */
    xor %ax, %ax
    mov %ax, %ds

    /* We also have to initialize stack since we want to use
     * procedure calls.
     *
     * BIOS memory map description found on the OSDev:
     *
     *     <https://wiki.osdev.org/Memory_Map_(x86)>.
     *
     * states that memory area from 0x500 to 0x7BFF is guarranteed
     * free to use. So we will use it for our stack.
     *
     * Note that we have to set the SS:SP pair back-to-back (no other
     * instructions in between) because CPU's interruptions can harm
     * this pair. All x86 processors protect the instruction following
     * a write to SS from interruption, but some old 8086's had an error
     * in them requiring us to use CLI/STI as a protection.
     *
     * We do not target on old infamous 8086's, so we rely that the
     * processor does the protection itself.
     */
    mov %ax, %ss
    mov $0x7C00, %sp

    /* The only thing that is certain after the boot sector is loaded is
    * that the DL registers holds the drive code from where the boot
    * sector was loaded. We need to keep it for later usage.
    */
    mov %dl, boot_drive

    /* Read the second sector from the floopy disk that contains our
     * boot sector and place it into the memory address 0x0000:0x8000.
     *
     * TODO: check if reading failed.
     */
    xor %ax, %ax
    mov %ax, %es
    mov $0x8000, %bx
    mov (boot_drive), %dl
    mov $0x0, %dh
    mov $0x2, %cl
    mov $0x0, %ch
    mov $0x3, %al
    call read_sectors_from_floppy

    /* Check whenever A20 line is already enabled or not.
     *
     * TODO: enable A20 line if it is disabled.
     */
    call check_a20
    test %al, %al
    jne bootentry.a20_is_disabled

    /* Clear interrupts, load the Global Descriptor Table (GDT) and
     * switch to the protected mode by setting the first bit of the CR0 register.
     */
    cli
    lgdt (gdtr)
    mov %cr0, %eax
    or $0x1, %eax
    mov %eax, %cr0

    /* We are in protected mode now. We need to clear the instructions pipeline
     * that contains garbage 16-bit instructions. To clear the pipeline we
     * need to only make a far jump.
     *
     * $0x8 is the offset of our second descriptor in the GDT since the first
     * descriptor is the reserved null-descriptor. Thus, it is our code segment.
     */
    jmp $0x08, $bootentry.clear_pipe

    .code32

bootentry.clear_pipe:
    /* We have to setup the segment register with our data segment (the third
     * GDT's entry, in our case, with offset 0x10). */
    mov $0x10, %ax
    mov %ax, %ds
    mov %ax, %es
    mov %ax, %fs
    mov %ax, %gs

    /* Setup the stack: */
    mov %ax, %ss
    mov $0x7C000, %esp

    /* And jump to the kernel. We will never return from there. */
    call 0x8000

    /* But… if we actually somehow return from the kernel
     * halt the CPU.
     */
bootentry.hang32:
    hlt
    jmp bootentry.hang32

    .code16

    /* If the A20 line is disabled, print a message about it and
     * halt the CPU.
     */
bootentry.a20_is_disabled:
    mov $a20_is_disabled, %si
    call print_string_in_teletype_mode

bootentry.hang:
    hlt
    jmp bootentry.hang

/*
===============================================================================

                              SECTION .data

===============================================================================
*/

    .section .data

    .global a20_is_disabled
    .global boot_drive
    .global gdt
    .global gdt_null_segment_descriptor
    .global gdt_code_segment_descriptor
    .global gdt_data_segment_descriptor
    .global gdt_end

a20_is_disabled:
    .asciz "A20 is disabled. Halt the CPU."

boot_drive:
    .byte 0x00

/* All the GDT descriptors was generated by a tool found at the OSDev Wiki:
 *
 *     <https://wiki.osdev.org/GDT_Tutorial>.
 *
 * The GDT_CODE_PL0 and GDT_DATA_PL0 presets was used.
 *
 * This is the simplest, «Basic Flat Model», memory model. Both segment
 * descriptors have the same base address value of 0 and the same segment
 * limit of 4 GByte space.
 *
 * This memory model is detaily described in Intel's manual: Volume 3,
 * Chapter 3.
 */
gdt:
gdt_null_segment_descriptor:
    .quad 0x0000000000000000
gdt_code_segment_descriptor:
    .quad 0x00CF9A000000FFFF
gdt_data_segment_descriptor:
    .quad 0x00CF92000000FFFF
gdt_end:

gdtr:
    .word gdt_end - gdt - 1
    .long gdt

/*
===============================================================================

                               SECTION .text

===============================================================================
*/

    .section .text
    .code16

    .global print_string_in_teletype_mode
    .global read_sectors_from_floppy
    .global check_a20

    .equ BIOS_VIDEO_TELETYPE_OUTPUT, 0x0E
    .equ BIOS_DISK_READ_SECTORS_INTO_MEMORY, 0x02

/*
=======================================
PROCEDURE print_string_in_teletype_mode(str)

Description:
    Prints a null-terminated string that is pointed to by str
    in BIOS teletype mode to the page #0.
Implementation details:
    The BIOS teletype output has the function code 0xE. It also
    requires the page number (BH), the color of the character (BL,
    only in graphic mode) and the character itself (AL).

    The function is called by calling the 0x13 interrupt while
    AH contains the function code (0xE).

    The documentation about this BIOS interrupt can be found
    at the address

        <http://www.ctyme.com/intr/rb-0106.htm>.
Inputs:
    - SI = pointer to the C-style string.
Returns:
    Nothing.
=======================================
*/
print_string_in_teletype_mode:
    push %ax
    push %bx

    cld

    xor %bh, %bh
    mov $BIOS_VIDEO_TELETYPE_OUTPUT, %ah

print_string_in_teletype_mode.loop:
    lodsb

    test %al, %al
    je print_string_in_teletype_mode.done

    int $0x10
    jmp print_string_in_teletype_mode.loop

print_string_in_teletype_mode.done:
    pop %bx
    pop %ax
    ret

/*
=======================================
PROCEDURE
read_sectors_from_floppy(segment, offset, number_of_sectors,
    cylinder, sector, head, drive)

Description:
    Read sectors from floopy disk to data buffer.
Implementation details:
    BIOS fuction 0x13/0x02 (read sectors into memory) allow to read
    from the hard drive, but this function can read sectors only from
    the floppy disks.

    The documentation for this BIOS function can be found at

        <http://www.ctyme.com/intr/rb-0607.htm>.

    This function also doesn't returns additional information if failed.
    You can use the 0x13/0x01 BIOS function (get status of the last operation)
    to see what caused the problem. Status codes can be found at

        <http://www.ctyme.com/intr/rb-0606.htm#Table234>.
Inputs:
    - ES:BX = data buffer.
    - AL = number_of_sectors (must be nonzero).
    - CH = cylinder number (0-based).
    - CL = sector to start reading from (1-based, in range from 1 to 63).
    - DH = head number (0-based).
    - DL = drive number.
Returns:
    Returns 0 in AX if successfull and a nonzero value otherwise.
=======================================
*/
read_sectors_from_floppy:
    mov $BIOS_DISK_READ_SECTORS_INTO_MEMORY, %ah
    int $0x13

    jc read_sectors_from_floppy.read_error

    xor %ax, %ax

read_sectors_from_floppy.read_error:
    /* We don't have to explicitly store a nonzero value into AX since
     * BIOS cares that AH contains the status of the last operation and
     * it is already nonzero on failure.
     */
    ret

/*
=======================================
PROCEDURE
check_a20()

Description:
    The function tests whether the A20 line is enabled or not.
Implementations details:
    We can check if A20 line is enabled using a simple method:
    the logical addresses 0x0000:0x0510 and 0xFFFF:0x0500 points
    to the same 20-bit physical address. So if we place a magic
    byte into one of the address and get the same magic byte
    from the other address then A20 line is disable because
    memory wraps around. Otherwise A20 line is enabled.

    Additional information about A20 line can be found at
    the address:

        <https://wiki.osdev.org/A20>.
Inputs:
    Nothing.
Returns:
    Returns 0 (AX) if A20 is enabled and 1 (AX) otherwise.
=======================================
*/
check_a20:
    pushf
    push %ds
    push %di
    push %es
    push %si

    cli

    /* DS:DI = 0x0000:0x0510. */
    xor %ax, %ax
    mov %ax, %ds
    mov $0x510, %di

    /* ES:SI = 0xFFFF:0x500. */
    not %ax
    mov %ax, %es
    mov $0x500, %si

    /* Move bytes at the memory location 0xFFFF:0x500 and 0x0000:0x510
     * in order to restore it later.
     */

    movb %ds:(%di), %al
    push %ax

    movb %es:(%si), %al
    push %ax

    /* And now actually check if memory wraps around: */

    movb $0x00, %es:(%si)
    movb $0xBE, %ds:(%di)

    cmpb $0xBE, %es:(%si)

    /* Restore the original memory state: */

    pop %ax
    movb %al, %es:(%si)

    pop %ax
    movb %al, %ds:(%di)

    /* Return the result: */

    xor %ax, %ax
    je check_a20.done
    inc %ax

check_a20.done:
    pop %si
    pop %es
    pop %di
    pop %ds
    popf
    ret

src/bootloader/bootloader.ld:

OUTPUT_FORMAT("binary");
ENTRY(bootentry);

SECTIONS {
    /* BIOS loads the boot sector to the memory address 0x7C00,
     * so the beginning of our code should be at this position.
     */
    . = 0x7C00;

    /* The .text.bootentry section will be placed as the first
     * code of our bootloader.
     */
    .text : SUBALIGN(0) {
        *(.text.bootentry);
        *(.text)
    }

    .data : SUBALIGN(0) {
        *(.data)
    }

    /* The special bootloader signature is required by some old
     * BIOS, so we have to place the special 0xAA55 word at the
     * end of the boot sector. */
    .sig : AT(0x7DFE) {
        SHORT(0xAA55);
    }
}

src/kernel/kernel_entry.s:

    .section .text.kernel_entry
    .code32
    .global kernel_entry
    .extern kmain

kernel_entry:
    jmp kmain

src/kernel/kernel.c:

#include <stdint.h>
#include <stddef.h>

#define VGA_WIDTH 80
#define VGA_HEIGHT 25

#define VGA_COLOR(fg, bg) ((bg << 4) | fg)
#define VGA_CHAR(ch, fg, bg) ((uint16_t) ((VGA_COLOR(fg, bg) << 8) | ch))

enum {
    VGA_COLOR_BLACK = 0,
    VGA_COLOR_BLUE = 1,
    VGA_COLOR_GREEN = 2,
    VGA_COLOR_CYAN = 3,
    VGA_COLOR_RED = 4,
    VGA_COLOR_MAGENTA = 5,
    VGA_COLOR_BROWN = 6,
    VGA_COLOR_LIGHT_GREY = 7,
    VGA_COLOR_DARK_GREY = 8,
    VGA_COLOR_LIGHT_BLUE = 9,
    VGA_COLOR_LIGHT_GREEN = 10,
    VGA_COLOR_LIGHT_CYAN = 11,
    VGA_COLOR_LIGHT_RED = 12,
    VGA_COLOR_LIGHT_MAGENTA = 13,
    VGA_COLOR_LIGHT_BROWN = 14,
    VGA_COLOR_WHITE = 15,
};

volatile uint16_t * const VGA_VIDEO_MEMORY = (volatile uint16_t *) 0xB8000;

static void clear_screen(void);

void
kmain(void)
{
    clear_screen();

    const char greeting[] = "Hello, World!";

    for (size_t i = 0; i < sizeof(greeting) - 1; ++i)
        VGA_VIDEO_MEMORY[i] = VGA_CHAR(greeting[i], VGA_COLOR_WHITE, VGA_COLOR_BLACK);
}

void
clear_screen(void)
{
    for (size_t i = 0; i < VGA_WIDTH * VGA_HEIGHT; ++i)
        VGA_VIDEO_MEMORY[i] = VGA_CHAR(' ', VGA_COLOR_WHITE, VGA_COLOR_BLACK);
}

src/kernel/kernel.ld:

OUTPUT_FORMAT("binary");
ENTRY(kernel_entry);

SECTIONS {
    /* Our kernel will be loaded by the bootloader into the
     * memory address 0x8000.
     */
    . = 0x8000;

    .text : SUBALIGN(0) {
        *(.text.kernel_entry);
        *(.text)
    }

    .data : {
        *(.data)
    }

    .rodata : {
        *(.rodata)
    }

    .bss : {
        *(.bss)
    }
}

Makefile:

BUILDDIR = build

$(BUILDDIR)/os.bin: $(BUILDDIR)/bootloader.bin $(BUILDDIR)/kernel.bin
    cat $^ > $@

$(BUILDDIR)/kernel.bin: $(BUILDDIR)/kernel_entry.o $(BUILDDIR)/kernel.o
    ld -T src/kernel/kernel.ld -melf_i386 -o $@ $^

$(BUILDDIR)/kernel_entry.o:
    as --32 -o $@ src/kernel/kernel_entry.s

$(BUILDDIR)/kernel.o:
    gcc -ffreestanding -c -m32 -fno-pie -fno-stack-protector -o $@ src/kernel/kernel.c

$(BUILDDIR)/bootloader.bin: $(BUILDDIR)/bootloader.o
    ld -T src/bootloader/bootloader.ld -melf_i386 -o $@ $<

$(BUILDDIR)/bootloader.o:
    as --32 -o $@ src/bootloader/bootloader.s

.PHONY = run clean

run: $(BUILDDIR)/os.bin
    qemu-system-i386 -boot order=a -drive file=$<,index=0,if=floppy,format=raw

clean:
    rm -f $(BUILDDIR)/kernel.o $(BUILDDIR)/bootloader.o

\$\endgroup\$
4
+100
\$\begingroup\$

check_a20 has two bugs in it. First, you not comparing with the correct memory addresses. The physical address for the real mode FFFF:0500 address is

 FFFF0
  0500
 -----
1004F0

which, if A20 is off, will wrap around to 0000:04F0, not 0000:0510. The correct addresses to use are 0000:0500 and FFFF:0510.

The second error is with how you get the result of the test back to the caller. You have a comparison instruction, cmpb $0xBE, %es:(%si), which will set the Z flag (among others). You have several other instructions (pop, mov, pop, mov) then an xor %ax, %ax, which will set the Z flag. The je on the next line will then always jump, so your check_a20 will always return 0.

| improve this answer | |
\$\endgroup\$
3
\$\begingroup\$

Review of the Makefile:

  • There's no .DELETE_ON_ERROR target. I know of no good reason to write a Makefile without that.

  • Put the makefile in the target directory (build) and find sources using VPATH. Then we don't need to hand-write rules.

  • We can make better use of built-in rules: adding ASFLAGS := --32 means we don't need to write rules for bootloader.o and kernel_entry.o. Similarly, add CFLAGS += -ffreestanding -c -m32 -fno-pie -fno-stack-protector to let us default kernel.o.

  • With suitable LDFLAGS, we can write the *.bin commands as $(LINK.c) $^ $(LDLIBS) -o $@.

  • rm -f can be written more portably as $(RM)

  • clean target leaves some *.bin files lying around.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Thank you very much for your answer! \$\endgroup\$ – eanmos Mar 4 at 9:06

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.