2

I am currently trying to make the second stage of a bootloader in order to enable 32-bit protected mode. I have written some x86 assembly for the NASM assembler to do so, but when I compiled and ran the code (in a disk image using QEMU emulator), it does not work. When the computer executes the jmp 0x08:protected_mode instruction, it crashes and resets my emulator. I am very new to low-level programming like this, so please bare with me.

EDIT: This code is not from the bootloader; rather, it the the second stage of it and is properly loaded into memory through BIOS interrupts at physical address 0x10000

Here is the code for the bootloader (bootloader.asm):

org 0x7c00

[bits 16]

start:
    jmp boot

load_msg db "bootloader: loaded", 0ah, 0dh, 0h
diskerr_msg db "bootloader: disk read failed", 0ah, 0dh, 0h
jmperr_msg db "bootloader: kernel jump failed", 0ah, 0dh, 0h

CursorX db 0
CursorY db 0

boot:
    cli
    cld

    call clear_screen
    mov si, load_msg
    call print

    mov ax, 0x1000
    mov es, ax
    xor bx, bx

    mov al, 17
    mov ch, 0
    mov cl, 2
    mov dh, 0
    mov dl, 0
    mov ah, 0x02
    int 0x13

    jc disk_error

    mov ax, 0x1000
    mov es, ax
    mov bx, 17 * 512

    mov ah, 0x02
    mov al, 8
    mov ch, 0
    mov cl, 1
    mov dh, 1
    mov dl, 0
    int 0x13

    jc disk_error

    ; mov ax, 0x3000
    ; mov es, ax
    ; xor bx, bx

    ; mov ah, 0x02
    ; mov al, 31
    ; mov ch, 0
    ; mov cl, 9
    ; mov dh, 1
    ; mov dl, 0
    ; int 0x13

    ; jc disk_error

    jmp 0x1000:0x0000

disk_error:
    mov si, diskerr_msg
    call print

    hlt


clear_screen:
    mov ah, 06h
    mov al, 0
    mov bh, 07h
    mov cx, 0
    mov dx, 184fh
    int 10h

    mov byte [CursorX], 0
    mov byte [CursorY], 0
    call mov_cursor

    ret

mov_cursor:
    mov ah, 02h
    mov bh, 0
    mov dl, [CursorX]
    mov dh, [CursorY]
    int 10h

    ret

put_char:
    mov ah, 0eh
    mov bh, 0
    int 10h
    inc byte [CursorX]
    call mov_cursor

    ret

print:
.loop:
    lodsb
    or al, al
    jz .done
    call put_char
    jmp .loop

.done:
    ret

times 510 - ($ - $$) db 0
dw 0xAA55

Here is the code for the second stage of the bootloader (bootloader2.asm):

org 0x10000

[bits 16]
start:
    jmp real_mode

gdt32_start:
    dd 0x00000000
    dd 0x00000000
    dd 0x0000FFFF
    dd 0x00CF9A00
    dd 0x0000FFFF
    dd 0x00CF9200
gdt32_end:

align 4
gdt32_location:
    dw gdt32_end - gdt32_start - 1
    dd gdt32_start

real_mode:
    cli

        lgdt [gdt32_location]

        in al, 0x92
        or al, 0x02
        out 0x92, al

        mov eax, cr0
        or eax, 0x1
        mov cr0, eax

        jmp 0x08:protected_mode ; jump error right here

[bits 32]
protected_mode:
    hlt
    jmp $ 

Here are the commands to compile the assembly:

nasm -f bin bootloader.asm -o bootloader.o
nasm -f bin bootloader2.asm -o bootloader2.o

Here are the commands to build a disk image:

dd if=/dev/zero of=disk.img bs=512 count=2880
dd conv=notrunc if=bootloader.o of=disk.img bs=512 count=1 seek=0
dd conv=notrunc if=bootloader2.o of=disk.img bs=512 count=25 seek=1

Here is the command to run the OS (with QEMU emulator):

qemu-system-i386 -machine q35 -fda disk.img -gdb tcp::26000 -S

I do not even know where to start trying to fix this; that is why I am asking my question here. I have checked most of my code and everything is working (registers are correct values, etc.), but the jump instructions seems to crash the emulator every time. I feel that the only thing it could be is a bad GDT, but I think I made it properly.

I have already tried using the org directive, which did not work.

19
  • 4
    Crashes how? QEMU should log the exception details. Bochs's build-in debugger can do even more, pretty-printing the GDT so you can see if your entries mean what you want them to, for example. Commented Oct 13 at 3:37
  • 5
    You're lacking an ORG directive, and have not initialized the segment registers. So any attempt to access data by absolute address is going to fail, since the addresses generated by the assembler won't be right. Try ORG 0x7c00 and initializing DS to zero when you start in real mode. Commented Oct 13 at 3:55
  • 4
    Also, assuming this is being booted as a legacy BIOS boot sector, only the first 512 bytes of your disk image is loaded into memory by the BIOS. But you've got many KB of page tables preceding the boot code, so your actual real mode code won't be loaded, and your initial jmp real_mode will jump to uninitialized memory. It's your responsibility to ensure the first 512 bytes contains code to read from the disk and load everything else. Commented Oct 13 at 4:05
  • 3
    You need to give us a minimal reproducible example, with all the code needed to boot to the point of the problem, as well as the specific commands used to build and run it, such that we can actually test it for ourselves. Commented Oct 13 at 13:21
  • 5
    You should first reduce it to a minimal example. Delete or stub out any code that is not reached or not needed. What is left should still build, run, boot to the point of the problem, and trigger it, without needing anyone to add or change a single line. Then post it here. Commented Oct 13 at 14:09

1 Answer 1

4

Trying to place your mode switch code outside the low 64K of memory makes things complicated. It means that, to run it in 16-bit mode, you need a nonzero segment (you're using 0x1000). But when you switch to 32-bit mode, you want "flat" segments with a base address of 0. So accessing the same place in memory needs a different offset depending on which mode you're in, and the assembler doesn't know that.

You've actually almost worked around that with the unorthodox org 0x10000. That looks weird because for 16-bit code, org should normally specify the initial offset within the segment, and so would never be above 64K. (For 16-bit code, NASM only manages the offset parts of addresses for you; segments are up to you.) But it almost works because it means that, for instance, the label gdt32_start gets a value of 0x10002, which is its linear address. When used as a 32-bit value, such as in dd gdt32_start, you get dd 0x10002 as desired. And when used as a 16-bit value, such as in lgdt [gdt32_start], the assembler truncates the high bits, so you get lgdt [0x0002] which is correct for real-mode segment 0x1000. (If you enable -wall you get warnings, which would have helped identify the problem.)

There are two problems, though:

  1. The address [0x0002] is not accessed relative to real-mode segment 0x1000, because you forgot to initialize DS to 0x1000. If you add that, or use a segment override with CS or ES (which are already set to 0x1000), e.g. lgdt [cs:gdt32_start], then lgdt would work correctly.

  2. jmp 0x08:protected_mode is also a 16-bit instruction (you are in 16-bit protected mode at this point) and uses a 16-bit offset by default. Your label protected_mode has a value of 0x10040, which is its correct linear address, and would be its correct offset in a segment with base 0. But when used in a 16-bit jump instruction, the assembler truncates the high bits as above, and you get jmp 0x08:0x0040. This would be right if protected-mode segment 0x08 had a base of 0x10000; but it does not, it's 0.

    You can fix this by telling NASM to emit a jump instruction with a 32-bit offset, using an address size prefix so it executes correctly in 16-bit mode: jmp 0x08:dword protected_mode. Then you get jmp 0x08:0x00010040, which is the correct offset relative to protected-mode segment 0x08 whose base is 0.


Keep in mind this only works because the real-mode segment you chose was a multiple of 64K. If for instance you had decided to load your code at linear address 0x18000, then using org 0x18000 would work with real-mode segment 0x1000, but not 0x1800. With this approach, you're effectively forced to think of that address as 0x1000:0x8000 and not 0x1800:0x0000.


All of this complexity could be avoided if you are willing to load your mode switch code into the low 64K of memory, below linear address 0xffff. Then you can initialize all the segment registers to 0 at the outset, and keep them that way for the duration of the 16-bit code. All ORG directives will use 16-bit addresses which are both linear addresses and offsets into real-mode segment 0. And those addresses will also be valid as offsets into a protected-mode segment with a base of 0, and they fit in 16 bits so ordinary 16-bit jump instructions will work.


Unrelated bug: with org 0x7c00 you are assuming everything is relative to real-mode segment 0. So all your memory accesses like mov byte [CursorX], 0 are implicitly assuming that DS = 0. QEMU's BIOS happens to initialize all segment registers to 0, so your code works. But BIOSes on real hardware don't always do that, so to be safe, you have to assume the contents of all segment registers are undefined, and initialize them yourself. (This even includes CS: even though the BIOS jumps to your boot sector at linear address 0x7c00, this doesn't guarantee that you have CS = 0 and IP = 0x7c00. There are real BIOSes in the wild that will start the boot sector with CS = 0x07c0 and IP = 0x0000, and in theory other combinations are possible as well. So if you rely on having CS = 0, you will need to do a far jump.)

See also Default registers and segments value on booting x86 machine, and Boot loader doesn't jump to kernel code which has a valuable collection of tips for writing bootloaders.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.