A few days ago a thought popped into my head. “Hey, let’s learn how to write assembly!”
While I know the basics of how Assembly works, and even though I’ve read some Assembly code over the years while debugging or analyzing applications, I’ve never written an actual program in any Assembly language. That obviously had to change.
How hard could it be, right?
Assemblers and Linkers
As it turns out, getting started really isn’t all that bad. You only need two things: An assembler, to turn your code into machine code, and a linker, which turns it into an executable. This part isn’t so different from other languages, like C.
The first question then is which assembler and linker to use. While pretty much any linker will do, as they should generally be able to work with whatever the assemblers generate, which assembler to use borders on the question of what programming language to use. They have different styles and come with different built-in functions. Some focus on Windows development, others are more general-purpose.
The two big names you see a lot are MASM (Microsoft Assembler) and NASM (Netwide Assembler). Since I usually focus on Windows development, I first looked at MASM, but the Hello Worlds I found really weren’t what I was expecting.
.386
.model flat,stdcall
option casemap:none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
.data
output db "Hello World!", 0ah, 0h;
.code
start:
invoke GetStdHandle, STD_OUTPUT_HANDLE
invoke WriteConsole, eax, addr output, sizeof output, ebx, NULL
invoke ExitProcess, 0
end start
If you have only ever seen Assembly code in debuggers this sight might be a little surprising, no push
es, no mov
es… this is no Assembly! It seems closer to C if anything, with its includes and normal looking function calls. The reason for this is that between what I knew as Assembly, and more high level languages, there is some overlap, because Assembly programmers obviously want some comfort as well.
The invoke
instruction in this code isn’t actually an instruction. Instead, it might get transformed into the push
/mov
/call
pattern I had expected to see.
push NULL
push ebx
push sizeof output
push addr output
push eax
call WriteConsole
Especially if you come from higher level languages you can see why one might prefer invoke
over this. Not only do you have to type more this way, the default Windows calling convention for functions (stdcall) also requires you to push arguments in reverse order, from right to left, which invoke
handles automatically.
Similarly, sizeof
is a “magical” construct as well, that tells us the length of a string in this example. But it’s just there for convenience. Usually you would have to figure out the length of that string yourself.
MASM has quite a few such creature comforts, and while I’m sure those are great to have when you’re developing seriously, I didn’t want to be tempted by convenient functions like that at the start. I also looked at GoAsm, another assembler with focus on Windows, but it too had quite a few such features, and it’s less popular, meaning you’d find less support for it.
Outside of Windows there’s a few other options, but the most popular choice appears to be NASM. Not only is it less focused on Windows, it also doesn’t come with too many features out of the box, aside from what you might expect from Assembly. Since the syntax was also to my liking, I decided to go with that one.
While looking into GoAsm, however, I tried the linker GoLink from the same author, and I quite liked how simple to use it was, so I went with it. As previously mentioned, switching to a different linker down the line should be fairly simple.
So my tools of choice ended up being NASM and GoLink, both of which can easily be downloaded for free.
Hello, World!
Alright, time for a proper Hello World!
global _start
section .data
hello: db 'Hello, World!',10
helloLen: equ $-hello
section .text
_start:
; write hello world
mov eax, 4
mov ebx, 1
mov ecx, hello
mov edx, helloLen
int 80h
; exit with code 0
mov eax,1
mov ebx,0
int 80h
Now that’s more like it! Look at those mov
es and int
errupts! And how the string length is manually calculated! I love it! There’s just one little problem. While you see examples like this a lot, this code won’t run under Windows. In the Linux world, you can use interrupts to call functions in the kernel, such as “function” 0x80, which essentially prints a string. In DOS you could use such interrupts as well, but not on Windows. Microsoft wants you to use the Windows API for such purposes, and that might look something like this.
global _start
extern GetStdHandle
extern WriteConsoleA
section .data
hello: db 'Hello, World!',10
helloLen: equ $-hello
section .text
_start:
; get standard output handle
push -11
call GetStdHandle
; write hello world
push 0
push 0
push helloLen
push hello
push eax
call WriteConsoleA
; return 0
xor eax, eax
ret
Paste this code into a text file, compile and link it with the following commands, and you’re good to go.
> nasm -f win32 hello_world.asm
> golink /console /entry _start hello_world.obj kernel32.dll
Of note are, for one, the argument to nasm, -f win32
, which tells it that we want 32-bit Windows code generated, then the entry
argument to golink, telling it the label where the program starts (_start
in this case), and finally the argument “kernel32.dll” at the end, which we add to tell the linker to search for external functions (or rather symbols) in that DLL. This is what allows us to call GetStdHandle
and WriteConsoleA
, which aren’t part of the code, but which we told the Assembler to make addressable, because they exist in some external file.
So far, so good! This isn’t difficult at all! Well…
I miss C
Naturally, you can’t do a lot with a program that does nothing but output some text. The next step for me is usually to extend my Hello Worlds, to make them ask my name and greet me directly. Read the input and print the formatted message Hello, [input]!
What's your name?
: exec
Hello, exec!
And this took more than copying and understanding a Hello World example. See, as far as I can tell, there’s no viable, build-in function to print a formatted string in the Windows API, nor is there a function to read a line from the input that works exactly as I was expecting.
WriteConsoleA
writes a string, but doesn’t support formatting. ReadConsoleA reads a line, but it includes the line-break (\r\n
) in the result, which you use to end the data input. Now, there’s a very simple solution to these problems: use printf
and getline
. These are part of the C library, which you can easily link to, and they’re the functions I might use for this Hello World test in other languages. But if I had wanted to use C functions, I could write C. What’s next, link to .NET and use Console.WriteLine
? No, that doesn’t seem right. I won’t learn anything by going the easy route.
First things first, printing a formatted string. Without using the C library, you seem to have exactly two options here. One is writing your own formatting function, but I didn’t feel quite ready for that by this point. The other is called wsprintfA
and is part of user32.dll.
https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-wsprintfa
Unfortunately, wsprintfA
isn’t exactly ideal, because:
- Microsoft strongly recommends not to use it.
- It limits the output string to 1024 characters.
- It has no limit checks outside of those 1024 characters.
They recommend some alternatives, but these are only implemented via header files, so they presumably forgot about all the Assembly programmers out there. These alternatives internally use vsnprintf
, which is a C library function… Because, naturally, Microsoft’s C++-based DLLs link against the C library. In lack of a better alternative, I decided to use wsprintfA
for the moment, but to implement a better solution later on.
So! Prepare a buffer, write a formatted string to it, and print everything!
hello: db 'Hello, %s!',0
helloLen: equ $-hello
input: db 'exec',0
buffer: times 1024 db 0
; ...
push input
push hello
push buffer
call wsprintfA ; format string
add esp, 12 ; clean up stack
mov ebx, eax ; save length of the formatted string
push -11
call GetStdHandle
push 0
push 0
push ebx ; string length
push buffer ; buffer
push eax ; handle
call WriteConsoleA
The buffer is a simple byte array. We call wsprintfA
, save the return value, which is the length of the formatted string, and pass everything to WriteConsoleA
as before. Just include “user32.dll” in the linking process now, and you’ll get a formatted message.
> hello_world.exe
Hello, exec!
One thing of note in this code, is that after calling wsprintfA
, the arguments must be removed from the stack manually, as the function doesn’t clean up after itself. That’s because it’s a variadic function, that takes a variable amount of parameters, and it would be difficult for the callee to determine how many parameters it’s supposed to remove from the stack, so the caller needs to take care of that. And the simplest way to remove 3 arguments from the stack, is to move the stack pointer by 3*4 bytes (3 arguments, times 32-bit per argument on the stack). This is also how the “cdecl” calling convention, which C uses, works, so you always have to clean up after calling functions in the C library. But the 32-bit Windows API uses stdcall, where the function takes care of that.
Now only reading a line is left!
Reading is hard
As I’ve mentioned above, there is a function to read a line from the input, but the result includes the line break. Also, at this point I was still just calling the right functions to get things done, but I did want to get my hands dirty, so instead of relying on ReadConsole
I decided to give ReadConsoleInput
a try. Hoh boy…
I just wanted to read from the input, character by character, and write those into a buffer, but ReadConsoleInput
doesn’t simply read characters, it’s basically an event for everything that happens to the console, from mouse input to focusing the console window. It’s actually much more powerful than a simple getchar
, and with that, I certainly had an oppurtunity to get my hands dirty, because not only do you have to read characters in a loop to fill up a buffer, you also have to work with the structs that function returns, pick out the events you’re interested in, output the characters you take from the input, etc. This was the first actual code I wrote that didn’t just call some functions, and it took me a while to get it right.
From misaligning my struct definitions, to access violations due to passing the wrong registers or address offsets, to logic errors because I failed to write basic if checks, I had everything. It was kinda funny to start up a debugger and see my code 1:1 in there though 😊
After a day of fiddling around with ReadConsoleInput
, and also testing PeekConsoleInput
at some point, I ended up with a simple routine that could do what I wanted, though it wasn’t perfect. It didn’t support backspace yet, nor navigating with the arrow keys, all of which are things you have to implement yourself if you were to go this route, which I hadn’t thought about.
My final, though unfinished and unoptimized code, looked something like this.
struc INPUT_RECORD
.EventType resw 1
alignb 4
; union
.Event:
.KeyEvent:
.MouseEvent:
.WindowBufferSizeEvent:
.MenuEvent:
.FocusEvent:
resb KEY_EVENT_RECORD.sizeof
; Include an empty label at the end of the struct,
; which's offset will be equal to the struct's size.
.sizeof resb 0
endstruc
struc KEY_EVENT_RECORD
.bKeyDown resb 1
alignb 4
.wRepeatCount resw 1
.wVirtualKeyCode resw 1
.wVirtualScanCode resw 1
; union
.uChar:
.UnicodeChar:
.AsciiChar:
resw 1
.dwControlKeyState resd 1
.sizeof resb 0
endstruc
; Buffer for ReadConsoleInput
inputRecord resb INPUT_RECORD.sizeof
; ...
ReadLine:
push ebp
mov ebp, esp
mov edi, [esp+4] ; buffer
mov ecx, [esp+8] ; size
.read:
; TODO: Don't call GetStdHandle over and over.
push STD_INPUT_HANDLE
call GetStdHandle
; Read one character from stdin
push readLineEventsRead
push 1
push inputRecord
push eax
call ReadConsoleInput
; Cancel if read console failed
cmp eax, 0
je .done
; Read next character if event was not a key press
cmp word [inputRecord+INPUT_RECORD.EventType], KEY_EVENT
jne .read
cmp byte [inputRecord+INPUT_RECORD.KeyEvent+KEY_EVENT_RECORD.bKeyDown], 1
jne .read
; Cancel if the character was a new line
mov dl, byte [inputRecord+INPUT_RECORD.KeyEvent+KEY_EVENT_RECORD.AsciiChar]
cmp dl, `\r`
je .done
cmp dl, `\n`
je .done
; Don't accept any more characters if buffer is full
cmp dword [ebp+4], 0
je .read
; Write character to buffer and advance it by 1, where the next
; character or a terminator can be placed.
mov byte [edi], dl
inc edi
; Decrement size to keep track of how much space we have left in
; the buffer.
dec dword [ebp+4]
; Write character to stdout
push 1
push inputRecord+INPUT_RECORD.KeyEvent+KEY_EVENT_RECORD.AsciiChar
call WriteN
jmp .read
.done:
mov byte [edi], 0
pop ebp
ret 8
And after I finally had something that kind of did what I wanted… I quickly switched to using ReadConsole
instead and just trimmed the new line at the end 😅
ReadLine:
mov ebx, [esp+4] ; buffer
mov ecx, [esp+8] ; size
push STD_INPUT_HANDLE
call GetStdHandle
push 0
push readLineEventsRead
push ecx
push ebx
push eax
call ReadConsole
; Get length of string
mov eax, [readLineEventsRead]
sub eax, 2
; Trim new line by terminating string after the read characters,
; where the line break would start.
add ebx, eax
mov byte [ebx], 0
ret 8
It’s fun to try to reinvent the wheel every once in a while, and it’s always a good lerning experience, but of course you should use whatever gets you to your goal the fastest, and implementing your own input reader is definitely not part of that if you can get around it.
But after finishing my readers and writers, I was done with my extended Hello World program.
extern Write
extern WriteLine
extern WriteLineF
extern ReadLine
section .data
nameQuery db `What's your name?\r\n: `,0
hello db 'Hello, %s!',0
inputGiven db `(Input given: %s)`,0
noName db 'Fine, keep your secrets.',0
section .bss
inputLen equ 64
input resb inputLen
section .text
_start:
push nameQuery
call Write
push inputLen
push input
call ReadLine
push eax
push input
push inputGiven
call WriteLineF
add esp,8
pop eax
cmp eax, 0
jne .sayHello
push noName
call WriteLine
jmp .end
.sayHello:
push input
push hello
call WriteLineF
add esp, 8
.end:
xor eax, eax
ret
> hello_world.exe
What's your name?
:
(Input given: )
Fine, keep your secrets.
> hello_world.exe
What's your name?
: exec
(Input given: exec)
Hello, exec!
Conclusion
From start to finish, it took me an entire weekend to read up on Assembler, decide on a toolchain, write print and read functions, get everything to a point where I can compile my code with a simple command, run it, and not get any crashes. Though I once more realized how small a layer C is on top of Assembly.
If you allow it yourself, Assembly isn’t so different from slightly less low level languages, and given a few convenience functions and clever macros, you can actually end up with something that isn’t so far off from base C. You can easily simulate constructs like ifs and loops, make accessing arguments and variables easier, even replace calling constructs, like with invoke
, to arrive at something that looks closer to languages like C in some ways.
Assembly takes more time and effort, and making mistakes is much easier (if not guaranteed) for sure, but no matter how you approach it, using the C library, writing everything from scratch, or taking a mixed approach, at some point you will have a base library that you can use to write programs with relative ease, because you end up just using the right functions and macros for the job at hand.
Just like you might not want to use C for a project because you can be more productive in a higher level language, you probably shouldn’t use Assembly either if your goal is to just get things done and you have other options, not to mention that in most cases compilers can produce better Assembly code than you anyway… But I would recommend the experience to people who want to see the real low level, or who’re interested in reverse engineering, because learning a few things about Assembly, common patterns, and why some things are the way they are is eye opening. And it is kinda fun 😁
If I were to continue writing in Assembly, I would definitely start adding macroinstructions like invoke
to my code, but I would write them myself, so I still know exactly what they do, and how. Wouldn’t want to lose that learning experience.
Next stop: a server emulator in Assembly 😏