-
Notifications
You must be signed in to change notification settings - Fork 12
Home
In this Wiki I will highlight the project structure of the bootloader and the game. There will also be a list of all the resources I used to get started.
While assembly looks incredibly intimidating at first sight, getting started is surprisingly easy. Its syntax is very straight forward and there is basically one single rule that applies to all of the instructions. I certainly benefited a lot from this project and can only recommend diving into assembly yourself if you are interested in understanding what is going on "under the hood".
The project is written in x86 Assembly and can be build using the NASM assembler. I will not go into detail about how exactly assembly works. Check out these sources if you want to get an introduction to assembly:
- PCASM - An awesome book about x86 Assembly that will give you a really good overview
- Assembly Programming Tutorial from tutorialspoint.com
The set of instructions I used is very small. Here is a short list of concepts and instructions necessary for this project:
- First of all, you should be familiar with registers
- The difference between physical memory addresses and their
[segment:offset]
representation - General instructions:
mov
,cmp
,int
,call
,ret
,jmp
as well as conditional jumps - Arithmetic instructions:
inc
,dec
,add
,sub
,sar
That is basically it. The int
instruction is by far the most complex in this list. It let's you call existing BIOS functionality in order to
access the display, the drives and IO devices. Here you can read more about interrupts
in general.
Besides beeing able to read assembly there is also some knowledge required about how the booting process works. I will try to briefly explain the parts necessary for this project. If you are interested in this topic and want to read more, check out OSDev.org.
One of the most significant differences to higher level languages is the way of how to pass arguments to functions and how to return their results. Since the registers are the fastest option and are used by the interrupts as well, they naturally were my first choice.
Very early on I ran into serious problems, though. The set of registers is very limited and I ended up overwriting important data within nested function calls. As it turns out there already exist calling conventions to avoid exactly this problem. Sadly, I did only learn about those afterwards and ended up with my own hacky solution. But at least for this small scale project it worked pretty well.
For this I stayed consistent with the interrupts by passing arguments and the return value in registers. I tried to follow some general conventions:
-
SI
,DI
as source and destination pointers respectively -
DX
for positions -
AX
for return values
To not accidentally invalidate registers I use the stack to preserve all registers I will modify within a function. You will find patterns like this throughout the project:
some_function:
push ax
push dx
[modify ax, dx]
.done:
pop dx
pop ax
ret
The bootloader is the very first program started by the BIOS after a bootable device is available and has been selected.
Here is a more detailed description of this process.
This first program is always loaded to the memory address 0x7c00
and is excatly 512 bytes long.
To be considered bootable it has to have a fixed 2 byte signature
at the very end:
000: 0x??
.
.
.
509: 0x??
510: 0x55
511: 0xaa
This leaves exactly 510 bytes for the actual bootloader code and all of its variables.
Its only purpose is to load the game into the RAM and then pass execution to it.
The loader is implemented in bootloader.asm
. There are three essential parts for this to work.
; the segment of the game
mov ax, 0x07e0
; setup the file-source
mov ch, 0x00 ; cylinder 0
mov cl, 0x02 ; sector 2 (skip first sector, which is the bootloader)
mov dh, 0x00 ; head 0
mov dl, 0x00 ; drive 0 (floppy disk)
; setup the destination
mov es, ax ; segment starts directly after the bootloader (7c00 - 7dff)
mov bx, 0x0000
; copy data into RAM
read:
mov al, 0x04 ; read four sectors
mov ah, 0x02 ; int 13h subfunction 2 -> read sectors (512 bytes) from disk
int 0x13 ; copy sectors to ES:BX
jc read ; carry-flag is set -> there was a read-error, retry
This loads 2 kb of data from the floppy disk and saves it to 0x7e00
. The game is therefore placed into RAM immediately
after the code of the bootloader. Here is a more detailed description
of the used interrupt 0x13
. Apparently floppy reads have a chance of random failures. The loader will therefore retry
after a failed loading attempt. This solution is far from perfect, but for now I am just happy it works at all.
; rebase segments for game execution
mov ax, 0x07e0
mov ds, ax ; data segment
mov es, ax ; additional segments
mov fs, ax
mov gs, ax
mov ss, ax ; stack segment
; enter the game code -> set CS:IP
jmp 0x07e0:0x0000
After the game is successfully loaded the segment pointers are redefined to point to the game's base address. The bootloader then calls the game's first instruction at the very beginning of the file.
; spacing and signature
times 510 - ($ - $$) db 0
dw 0xaa55
In order to be interpreted as bootable the bootloader includes the necessary signature in the last two bytes.
The main file of the game is space-invaders.asm
. It includes all additional files and contains the
program's main loop. Also all the variables are still located here.
I used the NASM pre-processor to %include
the files located in /src
.
Notice, that the pre-processor replaces every %include
with the included files content. The resulting, single file
is then interpreted by the assembler. In contrast to e.g. multiple C files, this does not require any additional linking.
After doing some initializations, like calculating the position of the centered game screen, the program jumps to its main function.
main:
mov ah, [program_state]
cmp ah, 1
je .game
cmp ah, 2
je .end
.intro:
call intro
jmp main
.game:
call game
jmp main
.end:
call end
jmp main
This function will never return and just calls the subroutine that corresponds to the current program state.
The game is always in one of the three states intro
, game
or end
. Each state function only returns if the state
has been changed.
This is the initial state that is only entered after the game started. Once left, the program alternates between the
states game
and end
. It just prints some strings and waits for a key to be pressed.
intro:
call clear_screen
mov ax, intro_string_t
mov bx, intro_string_o
call print_window
.wait:
call get_key
mov al, [key_pressed]
cmp al, ' '
je .game
jmp .wait
.game:
mov byte [program_state], 1
ret
After the message is presented the function waits for SPACE
to be pressed. The state is then changed to game
and
the function returns.
This state does exactly the same as the intro state besides that it changes the presented string corresponding to the winner of the previous game.
This state needs to reset the game at the start and implement a nonblocking loop in order to move and render the game. The general structure looks like this:
game:
call init_game
.loop:
; check whether a key has been pressed
; check whether player or invaders have won
.execute:
; move the game
; render the game
; sleep
; repeat loop
.done:
mov byte [program_state], 2
ret
All the necessary logic for these steps is implemented in the files in /src
.
This class of files contains keyboard.asm
and display.asm
.
The purpose of these is to wrap most of the explicit interrupt calls and provide some general purpose features.
The file game.asm
contains the logic to reset the game state and determine whether the game is still running.
Additionaly, the sleep
and move
functions are defined in here.
invaders.asm
contains move_invaders
and render_invaders
.
Within move_invaders
an internal counter is increased on every call.
When this counter is equal to INVADERS_MOVE_CYCLES
, the invaders are moved and the counter is reset.
Within the function an additional counter is compared to INVADERS_SHOOT_CYCLE
.
The general structure is as follows:
move_invaders:
; check move counter
.move:
; loop over all invaders
.loop:
; skip destroyed invaders
; move invader
; check for bullet collisions
; check shoot counter
.shoot:
; invader shoots a bullet
.done:
ret
The function render_invaders
essentially just loops over all invaders and prints them to the screen.
The file player.asm
basically has the same logic and structere as invaders.asm
.
bullets.asm
contains the logic to create, remove, move and render bullets as well as to check for collisions.
It contains a special help function _iterate_bullets
which expects a pointer to a loop function at DI
.
This function is then called in every iteration with SI
pointing to the current invader.
_iterate_bullets:
push si
mov si, bullet_list
.loop:
cmp si, [bullet_list_end]
je .done
call di
add si, 3
jmp .loop
.done:
pop si
ret
The list of bullets starts at the very end of the binary:
; space-invaders.asm
bullet_list resw 1
bullet_list_start resb 1
The pointer bullet_list_end
points to the first byte after the list. The list itself starts at the last byte
of the binary and then grows into uninitialized RAM. This implementation is still from the time when I tried to
put everything into the bootloader.
- PCASM - An awesome book about x86 Assembly
- Assembly Programming Tutorial from tutorialspoint.com