Getting Started with Assembly on x86-64 MacOS: Hello World


Assembly language programming is something most programmers, hackers, and security researchers understand the basic principles of, but not all developers have direct experience implementing. In prior decades when C compilers were not as efficient, and higher level languages like BASIC couldn’t keep up an acceptable level of performance for certain tasks, being able to get down close to the hardware level by writing assembly code (essentially human-readable machine code) was a useful skill.

Nowadays there’s a reasonable argument that in most cases assembly isn’t necessary for software development, and in many cases modern compilers can output more efficient assembly code than the vast majority of programmers. Still, if you are serious about understanding how computers work at a low level, and therefore how potential vulnerabilities and exploits can occur on different hardware and operating systems, a basic working knowledge of assembly is essential.

With assembly, we can get right down to the level of writing data to registers in a processor. If programming for bare metal, writing assembly can help us write our own kernals and system calls. We won’t be doing bare metal today though. Instead, I’m hoping this tutorial will be accessible to a number of coders who might not have had a ton of experience with low level developement. For this tutorial we will be writing a basic hello world program for x86-64 Intel processors on computers running MacOS. There are a number of great tutorials out there on x86-64 assembly but many of them target computers running a version of Linux, which while I love, simply isn’t as popular or widely used as MacOS or Windows.

There isn’t one “Assembly Language”

The term “Assembly Language” generally refers to some form of human-readable machine code. But unlike C/C++, or high level scripting languages like python and javascript, there are several types of distinct assembly languages. That’s because there are several types of hardware architectures for processors. Along with this, there are different forms of syntax that have been invented by the various hardware vendors and there is no unified single standard. Similarly, there can be operating system specific code in assembly. This may sound strange since we've already stated that assembly is largely tied to the hardware, but unless you are writing for bare metal, assembly code will typically include system calls that are OS specific. Of course, system calls don’t come from a vacuum. They can be written themselves in assembly (or C and other languages) by the creators of the operating system, though that is beyond the scope of this tutorial. If you are interested in learning about creating kernals and basic operating systems, here is a relatively accessible tutorial on writing a bare metal operating system for Raspberry Pi 4 hardware. Let’s now get started with the code.

Hello World in x86-64 Assembly for Intel Based MacOS

Follow the steps below to write and assemble a 'Hello World' program. You can also get the code and assembling instructions from the GitHub repo for this tutorial.

Step 1: Create a project directory for our Hello World program. I use a directory called “Projects” for most of my coding related work:

$ cd Projects
$ mkdir hello_assembly
$ cd hello_assembly

Step 2: Create your assembly file, in this case a “*.s” file, and open the file with your favorite code editor. I’ll use nano and stay in the terminal but VS code or anything else will be fine:

$ touch hello_assembly.s
$ nano hello_assembly.s

Step 3: Write the following program into your editor. We’ll go over it line by line after we assemble:

.global start.intel_syntax noprefix
start: # Write "Hello World" mov rax, 0x2000004 # system call 4 (write code) mov rdi, 1 lea rsi, hello_world[rip] mov rdx, 12 # Set register rdx to 12 syscall # envoke syscall
# Exit program mov rax, 0x2000001 # system call 1 (exit code) mov rdi, 99 # set the exit code to 99 syscall
hello_world: # Definition of hello_world .asciz "Hello World\n"

Step 4: Verify you have gcc installed. Both the following commands should give you version information:

$ gcc --version
$ as --version

If not installed, install gcc and as first before continuing.

Step 5: Generate an object (.o) file

$ as hello_assembly.s -o hello_assembly.o

You should now have a file “hello_assembly.o” in your project directory as well as your .s file. The “as hello_assembly.s” part means “assemble the file hello_assembly.s” aka turn it into (almost) machine code. The “-o hello_assembly.o” part means “output the assembled code as an object file called hello_assembly.o”. I included the “(almost)” there because in order to actually run the program on a UNIX like operating system like MacOS it must be in the format of a UNIX executable which it is not yet. Object (.o) files need to first be “linked” together to create a UNIX executable. In our simple example there is just the one object file, but we still need to run it through the linking process to format it correctly so that our computer can run it as a UNIX executable which we'll do in the next step. You’re likely already be familiar with this if you’ve done C or C++ programming before.

Step 6: Convert your assembled object file into a UNIX executable so we can actually run it:

$ gcc -o hello_assembly hello_assembly.o -nostdlib -static

If you check your current directory you should now also have a file simply called “hello_assembly” which if you right click and “Get Info” should say “Kind: Unix executable”.

Step 7: Test that this program actually runs. Run the following command from the same directory:

$ ./hello_assembly

If everything went right you should see “Hello World” printed in your terminal. Congrats, you’ve just made your first program in x86-64 assembly for MacOS. Now let’s go over what the code is doing line by line.

Code Explanation

At the beginning we have some boilerplate code:

.global start.intel_syntax noprefix

This means that when we run our executable it will begin under “start:” and that we will be using Intel Syntax with no prefix. If you take a look at some other examples of Intel assembly you might see a lot of “%” signs and other characters that make it uglier and harder to read. Using “noprefix” makes for a bit cleaner code.

Next under “start:”, our first line is

    mov rax, 0x2000004                # system call 4 (write code)

This means “move the value 0x2000004 into register rax”. There are many registers on an Intel processor. “rax” is used for system calls. On MacOS, system calls are offset by 0x200000, and system call 4 is for writing (0x200000 + 4 = 0x200004).

In the next line we have:

    mov rdi, 1

Or “move 1 into register rdi”. The rdi register in this case is used for what’s called a file descriptor. 1 is for “standard output”, whereas 0 is for standard input and 2 is for error.

Next we have:

    lea rsi, hello_world[rip]

The “lea” stands for 'load effective address'. Essentially we are saying create a buffer (set of bytes) that we’ll give the variable name “hello_world” to, and store the address (a pointer) to that buffer in the register rsi. The actual definition of those bytes is under 'hello_world:' later on in the program. rip is a special register for instruction pointers in x86-64. For other operating systems using x86-64 architecture, you may see “lea rsi, [hello_word]” instead. In either case, read this line as “create a pointer to buffer hello_world and store it in rsi”.

Next we have:

    mov rdx, 12                       # Set register rdx to 12

This means “set register rdx to 12”. Why 12? Because here rdx stores the length of hello_world: 'Hello World\n' (12 chars, each 1 byte).

And finally:

    syscall                           # envoke syscall

This envokes the system call, in our case system call 4 for writing.

What we’ve just gone over is the meat of our program. Let’s summarize why each of the last 5 lines are necessary. We want to write a sequence of characters (buffer) to the standard out of our OS so that we can see them in our opened terminal. To do that, the Intel processor+OS need the following:

  1. Which system call we want to use. For us we want to write something so we use system call 4 (0x2000004)
  2. A file descriptor telling us to perform the write to the standard output of the OS
  3. A place in memory for the processor to grab characters we want to write to the standard output, and the values of those characters (so a pointer and the characters in “Hello World\n”)
  4. The number of characters we want to write (12 characters in “Hello World\n”).
  5. With registers rax, rdi, rsi, and rdx all loaded up with the appropriate values indicating what we want to do with the system call, we then actually tell the processor to perform the system call.

Different system calls may require different registers to be set, but in our case for the write call (0x200004) we needed to set rax, rdi, rsi, and rdx. For different calls it is helpful to know what is required. Here are 2 helpful links:

NOTE: This is for Linux x86-64 and the exact system call numbers are different for MacOS. But it should give you a sense of what is needed for various system calls. Register names “rax” etc. are the same. What is different is the exact integers needed for “rax” and other registers for a given function. While quite old, you can view system call information for Mac with the following document. You can see that even after all this time the system call number for “write” is still 4:

The rest of the program is less important. At the bottom we have the values of hello_world buffer with “.anciz” meaning it is a asci string and zero delimited (null terminated/ends with a 0 or “\0” indicating the end of the string). For example: 'hi' = {'h','i','\0'}. Another example, this time in hex values: 'Hello World!' is 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 00.

But before that we have a three line sequence:

    mov rax, 0x2000001                # system call 1 (exit code)    mov rdi, 99                       # set the exit code to 99    syscall

As you might have guessed we are doing a second system call here. This time we are using system call 1 (0x200000 + 1) which we place into register rax. On MacOS this is the system call for exiting the program. Register rdi is the file descriptor which we’ve arbitrarily set to 99. This is simply an exit code, and we can make it whatever we want. By calling syscall after moving these values into registers rax and rdi, the program exits with exit code 99. To confirm this you can run the program again and then echo the last code:

$ ./hello_assembly
$ echo $?

Which should print our exit code “99” in the Terminal. For a great video on writing a Hello World program in assembly for x86-64 for Linux, checkout YouTuber Low Level Learning's video here. Notice that although he’s working with x86-64 and using Intel syntax like we are, the Linux environment requires slightly different code. Remember that your own setup or operating system may mean writing your assembly code in a slightly different way than someone else, and what works for them may not work for you. Even when using the same processor, you will need to look up the specific syscalls and syntax for your OS and/or assembler.

I hope you have learned a little bit about assembly programming from this tutorial, and can now apply it on your Intel-based Mac or use the same concepts for other operating systems. I should note that you may have an easier time working with Linux while learning assembly since it is a much more open platform with easier to find information. That said, there aren't a lot of beginner tutorials in assembly for MacOS so I hope this helped you out. Be sure to checkout the rest of our tutorials. If you are interested in how leverage your knowledge of assembly for program hacking and reverse engineering, check out our beginner reverse engineering tutorials under 'Related' below.