Here's a small bunch of snippets that are ready for you to try as a basis
Each of these can be assembled and imaged (no copying, and you lose everything on there!) straight onto a floppy disk if you want to test it on real PC hardware. You can try the same with an USB stick but not all computers like this method.
example1.asm: just do something
Use the firmware to print an 'X' to the screen. Interrupt 0x10 handles graphics, AH=0x0e selects printing a character, AL='X' is the character to print.
BITS 16 MOV AH, 0x0e MOV AL, 'X' INT 0x10 JMP $ TIMES 510-($-$$) DB 0 DB 0x55, 0xAA
example2.asm: Print a string
The bios loads the first sector at 0x00007C00, so we need to inform the assembler of that so that it uses the right addresses. Segments are set to zero so that whatever number we use as an address matches the final address.
Using name anywhere results in it's address being put there during assembly. Adding [braces] around something means that that address is used to access memory. For example text returns the address of our text, [text] returns something that's stored at that address, and [SI] returns something stored at the address specified by register SI.
You can't use everything between [brackets], You can pick one from a reduced set of registers, a constant address, or the sum of these two. Registers that can hold an address are SI, DI, and BX. Using SP and BP is also possible this way, but these registers have special meanings and corresponding restrictions on their use.
BITS 16 ORG 0x7C00 XOR AX, AX MOV DS, AX MOV ES, AX MOV SI, text printloop: MOV AL, [SI] ; DS:SI CMP AL, 0 JZ printdone MOV AH, 0x0e INT 0x10 INC SI JMP printloop printdone: JMP $ text: DB "Hello world!", 13, 10, 0 TIMES 510-($-$$) DB 0 DB 0x55, 0xAA
example3.asm: printing numbers
Notice SP, the stack pointer. The stack grows down, so to use the memory immediately before our code for the stack, we set SP to the end of it.
BITS 16 ORG 0x7C00 XOR AX, AX MOV SS, AX MOV SP, 0x7C00 MOV DS, AX MOV ES, AX MOV AX, 0x1000 CALL printhex MOV AX, 0x0E20 INT 0x10 MOV AX, 1234 CALL printdec JMP $
CALL<tt>s put the location of the next instruction on the stack before jumping, which can be used by <tt>RET to get back to where you were and implement a function call. Registers can be saved with PUSH reg and restored with POP reg. You can save all of them at once using PUSHA and POPA. Remember what gets used and what gets saved so that you don't break things of other functions.
Printing a number typically works by dividing the number by its base - 10 for decimal and 16 for hexadecimal - which leaves the digit in the remainder and a smaller number as the result of the division. Problem here is that the first digit you get is also the last digit, so here we save each of the digits and first print the smaller number - if it isn't 0 already. The function also adds the mandatory "0x" prefix that's typical of a hexidecimal number.
In decimal, multiplying a number by 10 is done by simply adding a zero, and dividing by removing a zero. Doing that with 100 adds/removes two zeroes. A computer is binary however, so adding or removing a zero only multiplies/divides by two. Printing in hexadecimal becomes easy this way by removing 4 binary digits each time (2*2*2*2 = 16). Since a computer is as fast in stripping numbers compared to performing a full division as a human, a good assembler programmer should use the opportunity when it appears.
One other new instruction is XLATB. The processor has numerous short forms for "common" memory accesses. XLATB basically performs MOV AL, [BX + AL], something that you can't do with normal instructions as neither AX or AL can otherwise be used as an address.
printhex: PUSHA MOV BX, digits MOV CX, AX MOV AX, 0x0E00 + '0' INT 0x10 MOV AX, 0x0E00 + 'x' INT 0x10 CALL .printdigit POPA RET .printdigit: PUSH AX MOV AX, CX SHR CX, 4 JZ .nomoredigits CALL .printdigit .nomoredigits: AND AL, 0xF XLATB MOV AH, 0x0E INT 0x10 POP AX RET digits: DB "0123456789ABCDEF"
Same method for decimal, but with the now mandatory DIV. DIV takes a larger input by concatenating DX and AX, so you can divide 32-bit numbers with 16-bit code. We're not using 32-bit numbers, so DX should be zero otherwise we'll suddenly see large numbers appearing as results. DIV puts the result in AX, and the remainder in DX.
Since in ASCII, the ten digits are consecutive characters, we don't need a list to look up the proper character to print. Instead we add the character value of '0' to the result to convert the digit into it's corresponding character.
printdec: PUSHA CALL .printdigit POPA RET .printdigit: XOR DX, DX MOV CX, 10 DIV CX PUSH DX OR AX, AX JZ .nomoredigits CALL .printdigit .nomoredigits: POP DX ADD DL, '0' MOV AL, DL MOV AH, 0x0E INT 0x10 RET TIMES 510 - ($-$$) DB 0 DB 0x55, 0xAA
example4.asm: keyboard input
The world is nothing without input. Therefore we use interrupt 0x16 which concerns all keyboard-related activity. setting AH=0 will enter a function that waits for a key, and then returns it - AL will have the character, and AH will have the raw key number on the keyboard. The character returned is not always a "character" though. This example simply prints whatever key you typed, so try using keys like backspace and enter and see what they do.
BITS 16 start: XOR AX, AX INT 0x16 MOV AH, 0x0E INT 0x10 JMP start TIMES 510 - ($-$$) DB 0 DB 0x55, 0xAA
example5.asm: clearing the screen
So, you've got a screen full of garbage and you want to get rid of it. Interrupt 0x10 with AH=0 is the "set graphics mode funtion", and AL is the mode number. We're already using mode 3 in this example, but calling it anyway basically resets the mode to a clean state - and a clear screen.
BITS 16 MOV AX, 0x03 INT 0x10 MOV AH, 0x0e MOV AL, 'X' INT 0x10 JMP $ TIMES 510-($-$$) DB 0 DB 0x55, 0xAA
example6.asm: More than 16 bits - video hardware
While 16-bit implies 64K in addresses, Bill gates had to mention 640K. I daresay it will always be enough during this workshop, but I'd like to be proven wrong.
At any rate, we have previously been setting registers like DS, ES and SS to 0. These are called segment registers, and all memory accesses have an implicit segment register they use. Most code uses DS, but everything that involves either the stack, or SP or BP directly uses SS. Some specific instructions use ES by default, but in general ES is the most free one to use. When you write out a memory access, you can specify [ES:address] or even [SS:address] to use a different segment register for that access.
To get past 64K, the way segment registers work is by multiplying their value by 16 and adding it to the address. In this example, video hardware can be found between 0xA0000 and 0xC8000, with the particular bit we need at 0xB8000. To get there we set ES to 0xB800 (count the zeroes) and add DI. The result is an final address of ES*16 + DI. with for example ES=0xB800 and DI=0x1004 the address is 0xB9004
In our current mode, the video card stores characters with additional data in groups of two bytes. One byte is the character, the other is how it should look like. Going back to our original text printing example, we just grab the characters, add the attribute byte with increasing values and write it to the video card. Note that SI is incremented once and DI incremented twice to make the addresses match up. The final result of this is a rainbow of colours. Of course you can change this by setting AH to something perhaps less psychedelic.
BITS 16 ORG 0x7C00 MOV AX, 0x0003 INT 0x10 XOR AX, AX MOV DS, AX MOV AX, 0xB800 MOV ES, AX MOV SI, text XOR DI, DI XOR AX, AX printloop: MOV AL, [SI] ; DS:SI CMP AL, 0 JZ printdone INC AH MOV [ES:DI], AX INC SI INC DI INC DI JMP printloop printdone: JMP $ text: DB 1, "-Colourful-Colouring-Colours!-", 2, 0 TIMES 510-($-$$) DB 0 DB 0x55, 0xAA
Some scripts that might save you some typing:
floppya: 1_44="./floppy.img", status=inserted boot: floppy
Makefile (Mind the TABs and put them back)
%: %.asm yasm -f bin -o floppy.img $< -Worphan-labels -Werror bochs -q
then run make example1 to assemble and run