In keeping with tradition, the first program we write should do nothing except print out "Hello, world!" and then exit. In most languages, this is a one- or two-line snippet, but in assembly it takes a little more than that. Don't worry though, it's conceptually the same as in any other language. All we need to do is tell the operating system what to print.
Start by opening up your favorite text editor and typing the following program:
section .data message db "Hello, world!", 10 section .text global _start _start: mov rax, 1 mov rdi, 1 mov rsi, message mov rdx, 14 syscall
This probably looks surprisingly complicated for a program which only prints a line of text to the console. However, while it is undeniably verbose, it's not actually complex: only a single high-level action is performed by this code. It just happens to be broken up into a lot of lines.
At its highest level, this program is divided into two sections: data and text.
- The data section contains data which the program will use. When the program runs, the contents of this section will be loaded into memory and made available for use by the code. In this case, the data section contains a string of text called message, which we'll be printing out to the console.
- The text section is where the code goes. This is a list of instructions that tells the computer what to do when the program runs. In this case, it will be telling the operating system to print the string message to the console.
Note: in assembly, you can't have literal strings (like "Hello, world!") mixed directly into your code. Data and the code that acts on that data must be declared in separate sections.
We'll break the program down and examine each line one at a time.
This begins the data section, where static data of any kind can be provided to your program. When the program runs, the operating system will load this data into memory and it will be made available to your code to read or manipulate.
The data here can be anything: numbers, arrays, strings, or anything else. Data is really just a series of 0s and 1s, and it's up to the code to interpret that data. The meaning or context of the data comes from how your code deals with it.
message db "Hello, world!", 10
This line declares a string called message, with a value of "Hello, world!". Breaking this line down further, you can see it's comprised of several parts:
- message - The name you will use later (in the code section) to refer to the data being declared. Wherever message appears in the code, the computer will know you mean this "Hello, world!" string. This name is up to you as the programmer. It should be descriptive, and what it refers to should be clear.
- db - This is the data type. In this case,
dbmeans that the data being declared is a series of bytes. Bytes are 8-bit integers, each with a value from 0-255. We're using bytes because each character in an ASCII string can be represented by one byte. There are other options for this field, like
dq, which refer to larger integers.
- "Hello, world!" - This is the data that message will refer to, and is what we will be printing out to the console later. Each letter, comma, space, or exclamation point is one byte.
- , 10 - This is actually a continuation of the data "Hello, world!". This is the ASCII code for a newline character, which will be added to the end of the string. It will make sure that after the string is printed, the console advances to the next line, rather than just writing the next prompt out on the same line as our text. If you're used to other languages, this is comparable to the "\n" in "Hello, world!\n".
This marks the end of the data section and the beginning of the text section, which is where the code goes. The data section simply declares static data for later use, but code is a series of instructions which control the computer and modify its state.
For this simple program, we're only going to do one thing: print a line of text to the console. Of course, as a lowly program running on a multi-user system like modern Linux, we can't interact directly with the video hardware to change what appears on screen (and we wouldn't want to either, since that would require in-depth technical knowledge of every video card we want to support, and make it impossible for our programs to run alongside other programs). So instead, we tell the operating system what we want to print to the console, and it does all the hard work for us. The way this is done is by performing a system call. We will set a number of registers which will explain to Linux the work we want it to do for us, and then tell Linux when we're ready for it to go. This will require a handful of lines of code, but conceptually they make up a single operation.
global _start _start:
These two lines do different things, but they are closely connected. Basically,
this defines the entry-point of the program. When the program is run, the line
_start: will be executed first. This is how the
computer knows which instruction to start with when the program is run.
mov rax, 1
This is the first real line of code: the first instruction which the processor will actually execute. Let's break it down into pieces:
- mov is the instruction type, or opcode. It's a command, and it tells
the processor what kind of action to carry out. In this case,
movstands for "move". It's used to move data and values around between registers and memory. We'll get into these in more detail later, but for now you can think of
movas a way to shuffle information around so it can be worked on or stored for later.
- rax refers to a register. This is a temporary storage location, which values can be written to, read from, or operated on. There are lots of registers: rax, rbx, rcx, rdx, rbp, rsp, rdi, rsi, etc. Many of them have special purposes or are reserved for certain things, but rax is a general purpose register. This means it can be used by a programmer for whatever they need. If you're coming from a higher level language, you can think of registers as built-in global variables. In fact, they're significantly faster than typical variables, because they're not located in system memory: they're located inside the processor itself.
- 1 is an immediate value, which means the value "1" is encoded directly into the instruction. We're not moving information from a register or from a memory address, it's just the literal value "1".
All together, this instruction tells the processor to put the value 1 into
rax. It can be read as "move 1 into rax" or "load rax with 1" or
whatever you prefer. If you're used to higher level programming languages, you
can think of it as
rax = 1.
After this instruction executes,
rax will be set to the value 1. This is
because we're preparing to make a call to the operating system. 1 stands for
sys_write, which tells the operating system to write some data to a file or
rax is the register Linux checks to figure out what a program wants
it to do.
mov rdi, 1
This is very similar to the above, but in this case we're loading the value 1
into the register
rdi. In this context, 1 stands for STDOUT, which means to
write to the console. This could also be 2 to write to STDERR, which is
normally where errors are written.
After this instruction executes, both
rdi will be set to the value
mov rsi, message
We've told Linux we want to write some data (
rax = 1) and we've told it where
to write that data (
rdi = 1). Now we need to tell Linux what to write. Here
rsi to the beginning of our "Hello, world!" string. When Linux checks
this register to see what data to write to STDOUT, it will find the location
in memory where the message string begins.
mov rdx, 14
Now we tell Linux how many bytes of message to write. This may seem weird.
The reason for this is when we set
rsi to message, we didn't really set
rsi to the entire string "Hello, world!". Registers like
rsi on a 64-bit
processor only have 64 bits each, which is 8 bytes. The string "Hello, world!"
is ASCII, where each character takes up one byte. This means our string takes
up a total of 14 bytes including the newline at the end. So it's impossible to
store the entire string in a single register all at once. What we do instead is
store the location in memory where the string begins.
When Linux goes to examine
rsi, it won't see "Hello, world!", it will see an
address that points to a location in memory where the letter "H" is. By
continually reading the next address, it will be able to see all of "Hello,
world!" one character at a time. However, it would have no way of knowing when
to stop. After the string, it would keep reading whatever happened to be in
memory after the string. It might be uninitialized garbage data, it might be
other values or strings in the program, or it might even be memory this program
isn't allowed to access. So we have to tell Linux when to stop writing data.
We do that by setting the value of
rdx to the number of bytes/characters
to print. In this case that number is 14, in order to include the entire string
"Hello, world!" and the trailing newline character (the 10).
Okay, we're all set to call Linux! This line lets the operating system know we
have some work for it to do and that the details of that work have been loaded
into the proper registers. When this line is executed, control will be passed
from our program to the operating system. Linux will check the values we loaded
into the registers
rdx, and it will use those values
to determine what to do.
Linux will do the following:
raxand find the value 1, which stands for sys_write. This means we want to write some data.
rdito see where to write the data and find 1, which stands for STDOUT (the console).
rsito see what data to write, and find the address of our string.
rdxto see how much data to write, and find the length of our string.
- Finally, with these 4 questions answered, Linux will write 14 characters starting from the "H" in "Hello, world!" out to the console, and then return control to our program.
Running the program
Type the above program into your preferred text editor, and save the file as "hello.asm". There are two necessary steps before we can actually run it.
First, we will use an assembler to convert the program into machine code. We'll be using a program called nasm to do this. On the terminal, type the following command and hit enter. Make sure you're in the same directory as the file "hello.asm" which you just created.
nasm -f elf64 hello.asm
This runs the nasm program, telling it to assemble the file "hello.asm". The contents of "hello.asm" will be converted from assembly language into machine code and the output will be written to a new file called "hello.o". This is called an object file.
-f elf64 option tells nasm that this is a 64-bit program. Without this,
nasm will assume that "hello.asm" is a 32-bit program and fail, since
it's not 32-bit code.
If nasm reports any errors, check that you typed the program exactly as it appears above and try again. If it's successful, there won't be any output.
The file "hello.o" should now be present alongside "hello.asm". This is the machine code version of your program. The code you wrote has been converted into a format the computer is capable of executing.
However, it's still not quite ready to run. Right now, it's just a blob of machine code and data. The operating system wouldn't know what to do with it. In order for the operating system to understand it, we have to use a linker to convert it into an executable file format. In this case, we'll be using the GNU linker (called ld) to turn our object file into an executable file ready for Linux to run:
This runs the GNU program ld and tells it to take our object file "hello.o" and make it into an executable file. By default it will name this new executable file "a.out".
Now for the moment of truth: run the program!
If everything went well, you should see the following output:
Hello, world! Segmentation fault (core dumped)
Uh, that's not quite what we expected.
So our program partially worked. It wrote the string out to the console as expected, but we also got a very nasty error: the dreaded segfault.
A segmentation fault basically means that the operating system didn't like something that a program did (or tried to do). It often happens when a program tries to access memory that it doesn't own or uses an instruction in an invalid way. Segmentation faults can be very frustrating because they give so little information about what exactly happened or even where in the program the error occurred.
In this case, it's not actually that complicated: we just forgot to exit the program. Linux expects that when a program is finished running, it will tell it so. We didn't do that: we printed a string to the console, and then did nothing else. Linux had no way of knowing if we were really finished, or if it should keep trying to run instructions. To fix this problem, we need to make another system call like the console print call we made above.
Add the following code to the end of your program:
mov rax, 60 mov rdi, 0 syscall
This should look pretty familiar, but we'll step through it like before to point out the differences:
mov rax, 60
We're making another system call to Linux, but this time with a different command. Instead of a 1, which means sys_write, we're passing a 60, which means sys_exit. This tells the operating system that the program is finished running and it's safe to shut it down and remove it from memory.
mov rdi, 0
Next we set
rdi to 0. This is the exit status code. Whenever a program
finishes, it returns a status code, which other programs can use to determine
whether a program finished successfully. In this case nothing went wrong, so we
return a 0, which indicates success.
Finally, we make the system call. Linux checks
rax to determine what to do.
It finds 60, which means we want to end the program, so that's what it does.
Re-assemble, re-link, and re-run the program after saving the new changes:
nasm -f elf64 hello.asm ld hello.o ./a.out
You should now only see the following:
We exited the program the way Linux expects, so the segmentation fault is gone! You can also see the error code by running the following:
This line will print out the status code of the last program run. Since we
rdi to 0 before returning, 0 will be returned in this case. Try
changing the value of
rdi to something else and seeing how it changes the
Note: if you run
echo $? multiple times, it will always show a 0 after the
first run. This is because echo is itself a program which returns 0 on
success. So if you run it twice, then on the second run, it will be reporting
the exit code of the previous time echo was run, rather than the status code of
the program run before that.
Improving the code
There are some problems with this program. Primarily, we're using a lot of magic numbers: numbers like 1, 0, and 60, which have special meanings but only within certain contexts. This hurts the readability of the program. Any time you can, you should try to avoid using these magic numbers because they make the program harder to read without providing any benefits.
An easy way to fix this problem is to use macros. Macros in nasm allow you to define a string of descriptive text which, when the file is assembled, will be replaced with something else. A macro looks like this:
%define sys_write 1
This macro definition tells nasm to replace all occurrences of the word "sys_write" with the value "1" during assembly. We can use this to improve the readability of the program. Change the program to match the following:
%define sys_write 1 %define stdout 1 %define sys_exit 60 %define success 0 %define nl 10 section .data message db "Hello, world!", nl section .text global _start _start: mov rax, sys_write mov rdi, stdout mov rsi, message mov rdx, 14 syscall mov rax, sys_exit mov rdi, success syscall
It's now much easier to tell at a glance which system calls are being made and what they're doing. Rather than a bunch of integers scattered throughout the code, there are descriptive bits of text.
But there's still one magic number, and it's an even worse offender than the
others! The 14, which corresponds to the number of characters in the message
string, is not only a magic number: it's also dependent on another value in the
program. If we were to change the value of message to some other text, we
would also have to remember to update the
mov rdx, 14 line to match the new
length of message. In a simple program like this, that wouldn't be a big
deal. But in a more complicated program with hundreds or thousands of
instructions, where a single string might be re-used several times throughout
the code, it could require hunting down lots of system calls and updating the
character count over and over. It would be easy to miss some of the places
where the change needed to happen, and depending on how well we tested the
program, we may not even realize we'd missed a spot until much later when a
user of the program reported a bug.
To solve this problem, we can use a feature of nasm which allows us to define a symbol whose value is automatically set to the length of a string:
message_len equ $-message
This sets the symbol message_len to be equal to the number of bytes in the symbol message. So whenever the assembler encounters "message_len" in the program's source code, it will replace it with the number of characters in message. Adjust the program one final time to incorporate the new change:
%define sys_write 1 %define stdout 1 %define sys_exit 60 %define success 0 %define nl 10 section .data message db "Hello, world!", nl message_len equ $-message section .text global _start _start: mov rax, sys_write mov rdi, stdout mov rsi, message mov rdx, message_len syscall mov rax, sys_exit mov rdi, success syscall
No more magic numbers! You can change the text "Hello, world!" to be anything
you want and you don't have to worry about updating the system call that prints
it. The literal values like 0, 1, and 60 still appear in the code, but they're
defined - at a glance you can see what they mean. And the code itself under
_start: is much clearer and what it does is more obvious.
- Make the program print the "Hello, world!" message multiple times.
- Change the program to make it print several different messages instead of just "Hello, world!".