Skip to content
Christopher Aring edited this page Dec 12, 2017 · 34 revisions

Boot Loader: Where is Assembly relevant in 2017?

I researched the modern uses for assembly languages, sometimes called just assembly or ASM, and where they are still used/required. Focusing on bootloaders, I created a bootloader that loads an x86 instruction set architecture operating system kernel, written in C, that prints a simple line of text to the screen. Within my project, the kernel is only relevant to demonstrating how a bootloader would be used to boot an operating system. The kernel is the central core of a computer operating system and would handle the rest of the boot process.

I have always been interested in how assembly languages work and interact with processors. After researching the different areas where assembly languages are still used and/or required to complete tasks, I felt like a bootloader would be both interesting and valuable. Since bootloaders don't have access to operating system routines, frequently use low-level features, and need to be compact, they are normally built entirely in assembly. The bootloader that I made uses BIOS interrupts to access memory outside of the 512-byte boot sector and to print important text to the screen such as disk errors, and the different modes it enters. There were many different areas that needed to be addressed when creating the bootloader which gave me lots of practice with x86 assembly, general computer architecture, and logic. Also, all the bootloaders I found that were able to load a kernel, did not start on their own. They were booted from another premade bootloader such as GRUB, a boot loader package from the GNU project.

Assembly languages are still used and/or required in many areas. Assembly languages are needed in most of these areas because the tasks require precise timing, need to be done within specific time constraints, need to be ultra-optimized, or need to access instructions and operations that are not implemented in a compiler. Some of these areas are:

  • Device drivers
  • Interrupt handlers
  • Programs that make use of instructions not implemented in a compiler such as a bitwise rotation
  • Code that requires ultra-optimization
  • Medical equipment
  • Autopilot and various aircraft programs
  • Bootloaders
  • Computer Viruses
  • MORE

Background and Definitions

Assembly

Assembly is not a single language, it is a group of low-level programming languages that have a strong connection to their specific architecture's machine code instructions. Assembly languages were first used to eliminate the time consuming and error prone programming that was required with early computers. They were also used to increase the speed of and reduce the size of programs.

Modern compilers are able to render higher-level languages into code that runs just as fast as hand-written assembly and with increasing processor performance, many CPUs sit idle most of the time which means that most delays come from predictable sources. This means programmers generally have to focus on bottlenecks such as paging and cache misses for increased performance before dealing with raw code execution time.

Kernel

The kernel is a central component of a computer's operating system. It has complete control over everything in the system and is generally one of the first programs loaded on after the bootloader. The kernel is responsible for task, process, memory, and disk management and connects the system hardware and software.

Netwide Assembler (NASM)

The Netwide Assembler is an assembler and disassembler for the x86 architecture.

x86

x86 refers to a family of instruction set architectures based on the Intel 8086 CPU and its Intel 8088 variant.

MIPS

MIPS is a reduced instruction set computer instruction set architecture which means it only has some of the instructions. It can not work with every instruction.

Instruction Set Architecture (ISA)

Instruction set architectures (ISAs) are the part of a computer that provides commands to the processor to tell it what to do.

Global Descriptor Table (GDT)

The Global Descriptor Table is a data structure that defines characteristics of different memory areas such as the base address, size, and access privileges.

Guide / More Detail

Background

When a computer boots the BIOS has control but does not know how to load an operating system or where it is located. After it runs all of its checks and various operations, it hands over control to the boot sector which is located in the first sector of the disk. The boot sector is 512 bytes and ends with 0xAA55. The BIOS will check that bytes 511 and 512 are set to be 0xAA55 to make sure that the boot sector is actually bootable and working. It will then hand over control to the boot loader located in the 512-byte boot sector which can either hand over control to another bootloader or boot into the operating system. The bootloader that I made prints text to inform the user of any disk errors and what mode it is in, protected VS real.

My bootloader boots from scratch within QEMU, a generic machine emulator, and virtualizer. Since the bootloader is only 512 bytes, the kernel has to be located on a disk. The bootloader attempts to boot from a disk and read the kernel from that disk. It then switches to protected mode, which allows system software to access things such as paging and virtual memory, and hands over control to the kernel by calling C code from Assembly.

The Environment

First, the environment has to be set up. I installed QEMU on OSX to emulate an x86 system. You will also need the Netwide Assembler to assemble the code. While QEMU and NASM are relatively simple to install, the other element can be difficult depending on your operating system. We will need a GCC Cross-Compiler because it is the simplest way to compile our C code without having to pass many flags and without the headaches because your system will probably make a lot of assumptions when compiling that may be incorrect and cause errors. I recommend looking up information on creating a GCC Cross-Compiler on your specific system. For Linux systems, it should not be difficult, for OSX it can get confusing and difficult quickly.

Tips / Instructions for OSX

I found that the best way was to use Homebrew and run the following commands:

brew tap nativeos/i386-elf-toolchain
brew install i386-elf-binutils i386-elf-gcc

This is a tap that contains the tools we will need because the options that come with homebrew will not work. Nor will the XCode utilities. DO NOT use the XCode utilities or clang because it will fail or throw errors. Once you have installed the utilities as stated above, you need to make sure you're using the right GCC tools. Find where it is located and add it to your bash profile. Instructions are below: Type the command into a terminal window:

nano .bash_profile

Then type (With your path, maybe the same):

export PATH="/usr/local/Cellar/i386-elf-gcc/7.2.0/bin:$PATH"
export PATH="/usr/local/Cellar/i386-elf-binutils/2.29/bin:$PATH"

Now you should be good to go!

The Bootloader

I will be going over the basic code used to create parts of the bootloader. The final product can be found in the main repository spread out into files. I will not be talking about every file specifically. Just how the important parts are done and how you may put it together.

The next step is to create a simple bootloader in x86 assembly. I created a simple Assembly file that would write a blank 512-byte boot sector that with the last two bytes set to be 0xAA55. In the following code, times and $/$$ are NASM commands not x86 assembly. $ points to the current address, $$ to the base address, and time just tells the compiler to run an instruction multiple times.

loop:
    jmp loop

times 510-($-$$) db 0
dw 0xaa55 ; word 16 bits

I then tested my environment and the simple boot sector by compiling the code with NASM and running it with QEMU.

nasm -f bin bootTest.asm -o bootTest.bin
qemu-system-i386 -fda bootTest.bin

To print text to the screen we can set register ah to 0x0e which tells it we want to run the Display Character function. We then set register al to the letter we want to type and call BIOS interrupt 0x10 for video services. We can keep updating the register and recalling the interrupt for as many characters that we want to type. I moved this to its own file and created a simple loop to loop through strings. This way I could reuse the function to type multiple different characters and strings in the window. The main part of the print loop looks like this:

start:
    mov al, [bx]	; Base address of the string
    cmp al, 0		; If '0' we are at end of string
    je done		; Break loop
    mov ah, 0x0e	; BIOS interrupt
    int 0x10		; Interrupt vector (Video Services)
    add bx, 1		; Add 1
    jmp start		; Loop

The next step is to make sure that our bootloader can access memory outside of the boot sector's 512 bytes. To do this we can just set a few registers to hold information about the disk like the cylinder, sector, and head locations. We raise BIOS interrupt 0x13 which is a code for low-level disk services. The main part of the code dedicated to loading a disk looks like this:

disk_load:
    pusha		; Push all general purpose registers onto the stack
    push dx 		; Save dx
    mov ah, 0x02 	; Read Flag
                        ; We set these to required values.
    mov al, dh   	; Number of sectors to read
    mov cl, 0x02 	; Sector
    mov ch, 0x00	; Cylinder
    mov dh, 0x00	; Head
    int 0x13 		; Interrupt vector (Low-Level Disk Services)
    jc disk_error 	; Jump
    pop dx		; Load dx from stack
    cmp al, dh    	; Compare with BIOS
    jne sector_error	; Jump
    popa		; Pop all general purpose registers off the stack
    ret

The next step is to enter the main operating mode of the processor, 32-bit protected mode. so that we can execute 32-bit code and load our kernel. In this mode we don't have access to the BIOS interrupts but we have access to many more features such as paging and virtual memory. We need to create a Global Descriptor Table to handle 32-bit mode. The GDT was the most confusing part of the project because of how it is split up. Based on research I decided to split it into a data and code section. The GDT descriptor was also confusing as it is the middleman between the processor and GDT since the processor cant directly load GDT addresses. The code for the GDT looks like this (I included the full code here, not just the main part because it is so confusing. I thought it would be helpful to see it in its entirety here.

gdt_init:
    dd 0x0		; dd double word 32 bits.
    dd 0x0 		; dd double word 32 bits.

gdt_info: 
    dw 0xffff		; Segment length. dw word 16 bits
    dw 0x0      	; Segment base
    db 0x0       	; Segment base. db byte 8 bits
    db 10011010b 	; Flags
    db 11001111b 	; Flags & segment length
    db 0x0       	; Segment base

gdt_data:
    dw 0xffff
    dw 0x0
    db 0x0
    db 10010010b
    db 11001111b
    db 0x0

gdt_end:

gdt_desc:
    dw gdt_end - gdt_init - 1
    dd gdt_init

CODE_SEGMENT equ gdt_info - gdt_init
DATA_SEGMENT equ gdt_data - gdt_init

Now we are ready to load into 32-bit protected mode. We first disable interrupts using the cli flag, load the GDT, set the CPU control register, jumping far to flush the processor's pipeline, update a few registers, update the stack and move to our first code. The code is listed below:

[bits 16]			; 16-bit Real Mode
switch_pmode:
    cli				; Disable interrupts
    lgdt [gdt_desc]		; Load GDT
    mov eax, cr0
    or eax, 0x1			; Set 32-bit mode bit high
    mov cr0, eax
    jmp CODE_SEGMENT:init_pmode

[bits 32]			; 32-bit Protected Mode
init_pmode:
    mov ax, DATA_SEGMENT	; Set data segment registers
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    mov ebp, 0x90000
    mov esp, ebp

    call BEGIN_PMODE

Once in 32-bit protected mode, we can make a new function to print strings that don't require a BIOS interrupt. We can directly interact with VGA video memory. VGA video memory starts at address 0xb8000. The main loop code is below:

start:
    mov al, [bx]	; Base address of the string
    cmp al, 0		; If '0' we are at end of string
    je done		; Break loop
    mov ah, 0x0e	; BIOS interrupt
    int 0x10		; Interrupt vector (Video Services)
    add bx, 1		; Add 1
    jmp start		; Loop

Kernel

Now that the bootloader is finished we can begin creating the simple kernel. This kernel will be extremely simple and only print a line to the screen. The kernel that I made just clears the top line of text and writes a string to the screen. The code is here:

void main(void)
{
	// Message we want to print
	const char *string = "Welcome Kernel!";
	// Pointer to start of video memory
	char *videoMemory = (char*)0xb8000;
	// Loop index	
	unsigned int i = 0;
	unsigned int j = 0;
	// Clear screen
	while(j < 80 * 25 * 2) {
		videoMemory[j] = ' ';

		// Color
		videoMemory[j+1] = 0x07; 		
		j = j + 2;
	}
	// Reset Counter
	j = 0;
	// Print string to screen
	while(string[j] != '\0') {
		videoMemory[i] = string[j];

		// Color
		videoMemory[i+1] = 0x0f;
		++j;
		i = i + 2;
	}
	return;
}

Now that our kernel is done, we can test it. Testing it is simple run this code to compile it:

i386-elf-gcc -ffreestanding -c kernel.c -o kernel.o

Now the kernel is compiled but we need to link and call it from our assembly code. To do this we create an Assembly file that will call an external C function. This code is found here:

[bits 32]
[extern main]	; Calling point
call main	; Call 'main' in Kernel.c
jmp $

It can be compiled with:

nasm kernelEntry.asm -f elf -o kernelEntry.o

Now we can link the files into one binary using the following commands:

i386-elf-ld -o kernel.bin -Ttext 0x1000 kernelEntry.o kernel.o --oformat binary
nasm bootSector.asm -f bin -o bootSector.bin
cat bootSector.bin kernel.bin > final.bin

And test it with:

qemu-system-i386 -fda final.bin

We can create a makefile to do all of this for us so that we don't have to type every command every time. The makefile:

all: run
kernel.bin: kernelEntry.o kernel.o
	i386-elf-ld -o $@ -Ttext 0x1000 $^ --oformat binary

kernelEntry.o: kernelEntry.asm
	nasm $< -f elf -o $@

kernel.o: kernel.c
	i386-elf-gcc -ffreestanding -c $< -o $@

bootSector.bin: bootSector.asm
	nasm $< -f bin -o $@

final.bin: bootSector.bin kernel.bin
	cat $^ > final.bin

run: final.bin
	qemu-system-i386 -fda $<

clean:
	rm -rf *.bin *.o

It can be run with:

make -f Makefile

The code for the boot sector needs to be modified because the kernel will now be placed at 0x1000, not 0x0000. This is a simple addition to our code.

You are now done! The final code for the main part of our boot loader is:

[org 0x7c00]

KERNEL_OFFSET equ 0x1000 

    mov [BOOT_DRIVE], dl	; BIOS sets boot drive
    mov bp, 0x9000
    mov sp, bp

    mov bx, REAL_MODE 
    call print
    call print_nl		; Print

    call load_kernel		; Read kernel from disk
    call switch_pmode 		; Begin protected mode
    jmp $ 			; Never run but holds

%include "bootSectorPrint.asm"
%include "bootSectorPrintHex.asm"
%include "bootSectorDisk.asm"
%include "gdt.asm"
%include "print.asm"
%include "enter32.asm"

[bits 16]
load_kernel:
    mov bx, KERNEL_OFFSET	; Read from disk
    mov dh, 2
    mov dl, [BOOT_DRIVE]
    call disk_load
    ret

[bits 32]
BEGIN_PMODE:
    call KERNEL_OFFSET		; Give kernel control
    jmp $ 			; Gain control from Kernel

BOOT_DRIVE db 0
REAL_MODE db "Real Mode", 0
PROTECTED_MODE db "Protected Mode", 0
LOAD_KERNEL db "Loading Kernel into Memory", 0

times 510 - ($-$$) db 0		; Padding
dw 0xaa55

It builds on the things I discussed above and includes all the files I made to create a full bootloader! Take a look at all the files in the repository to see how I set up the printing text based on the method discussed above and the other pieces I built that were not fully covered here.

Reflection

I had a lot of fun working on this project because it involved a large amount of research and a basic understanding of new topics just to get started. While we have worked with MIPS before, working with x86 assembly was different in a lot of ways. It has more/different instructions and a different syntax. What I was accomplishing was the same, just the language was different. I still needed to access registers, move data around and use similar logic as with MIPS which made it go smoothly. Once I understood the format of a bootloader, how it works, and what its duties are, I was able to quickly build a basic bootloader in x86 assembly that printed text to the screen. While basic, I knew that I was on the right track, that I was able to call functions from other assembly files, and had my basic environment set up correctly for testing.

Overall I was happy with my project and accomplished more than I thought. I learned a lot about assembly languages, kernels, bootloaders, terminology, and computer architecture in general. I did not realize how many intermediate steps there were going to be when creating the bootloader. It wasn't as simple as, for example, "print to the screen." I had to create additional assembly files (mainly for code organization) and use more lines than I thought to get the job done. I was able to meet all of the requirements I set for myself in my project proposal. I did not complete my stretch deliverables but I plan to extend this project beyond class, finish my stretch goals and continue even further to create a basic shell for my kernel and hopefully even more interesting features.

Minimum Deliverables

  • x86 assembly that runs in a processor emulator.
  • Print a single line of text.
  • Describe areas where assembly languages are still used/required.

Planned Deliverables

  • Loads an x86 operating system kernel.
  • Create a simple kernel in C that prints a line of text to the window.

Stretch Deliverables

  • Create a muli bootloader where the user can select from at least two different kernels to boot from.

I learned a lot from past projects in Computer Architecture especially when it came to scoping and planning my project. I feel like I did a good job at creating my proposal and my work plan. It was easy and simple to stick to my work plan because I put in a lot of thought before writing it. Since I had to think about each part of the project and all the steps I would have to take to complete my goals, the work plan helped keep me on track before any code was even written.

References

http://www.cs.virginia.edu/~evans/cs216/guides/x86.html https://software.intel.com/en-us/articles/introduction-to-x64-assembly https://www.qemu.org/ http://www.nasm.us/ http://www.linfo.org/kernel.html http://mikeos.sourceforge.net/write-your-own-os.html#firstos http://www.ctyme.com/rbrown.htm https://techterms.com/definition/kernel http://blog.ackx.net/asm-hello-world-bootloader.html http://www.osdever.net/tutorials/view/writing-a-simple-c-kernel