Tutorial  on  ProgrammingLinux

Bare-metal programming: Write your first Assembly program

Explore what a computer, CPU architecture, and kernel are, learn the history and family of Assembly ISAs, compare CISC vs RISC, and write your first x86_64 assembly "Hello, World!" program.

Have you ever wondered what actually happens inside your computer when you type a command or execute a program?

When you write code in high-level languages like Python, C, or JavaScript, everything feels clean and friendly. But down at the hardware level, physical silicon chips do not understand high-level concepts like functions, classes, or conditional branches. They only process pulses of electricity that represent zeroes and ones.

You might remember that we use compilers to turn high-level languages into binary executables. A compiler is simply another program. Its job is to translate human-readable source code into something closer to the metal: Assembly language, and ultimately, machine code instructions.

In this tutorial, we will take a practical journey down to the metal. We will examine how computers execute code, explore CPU architectures, clock cycles, and kernels, see how Assembly evolved from human-punched cards, and write an x86_64 Assembly program.

Step 1: What is a Computer, CPU Architecture, & The Clock Cycle?

If you opened a computer case and looked closely at the processor, you would see a small square of silicon containing billions of microscopic transistors. But how does that physical chip run software?

Fundamentally, a computer is an electronic machine that carries out three steps continuously:

  1. Fetch: Read the next instruction from main memory (RAM).
  2. Decode: Interpret what that instruction is asking the CPU to do.
  3. Execute: Perform the operation and store the result.

The Heartbeat of the CPU: The Clock Cycle

This continuous cycle is driven by an internal quartz oscillator called the CPU Clock.

When a processor is rated at 3.5 GHz, its CPU clock ticks 3.5 billion times every second. Each tick represents one Clock Cycle. Simple assembly instructions (such as moving a value into a register) may execute in a single clock cycle, while more complex operations can require multiple cycles.

The CPU Instruction Pipeline, Core Registers, ALU, and Memory.

The CPU Instruction Pipeline & Architecture: Fetch, Decode, Execute, and Writeback stages driving CPU Registers, the ALU, and Memory.

To execute instructions, the CPU relies on three core internal components:

  • Registers: Small, ultra-fast storage locations built directly inside the CPU core (such as %rax, %rdi, or %rsp on 64-bit Intel and AMD processors). They serve as the CPU's immediate workspace.
  • Arithmetic Logic Unit (ALU): The mathematical engine that performs arithmetic operations (addition, subtraction) and logical operations (AND, XOR).
  • Instruction Pointer (%rip): A specialized register holding the memory address of the next instruction waiting to be fetched.

Who Names the Registers? (The Vendor Manuals)

Why are x86_64 registers named %rax, %rdi, or %rsi, while ARM64 uses x0, x1, x2?

Whoever designs and manufactures the physical CPU chip (such as Intel, AMD, or ARM) defines its Instruction Set Architecture (ISA). The chip vendor names every register, specifies every binary opcode, and documents how each instruction behaves.

Intel 64 and IA-32 Architectures Software Developer's Manual cover.

The Intel 64 and IA-32 Architectures Software Developer's Manual: the authoritative reference for x86_64 hardware behavior.

These details are published in comprehensive vendor documentation, such as the multi-volume Intel 64 and IA-32 Architectures Software Developer's Manual and the AMD64 Architecture Programmer's Manual. Compiler authors and assembly programmers rely on these manuals as the authoritative reference on hardware behavior.

Step 2: System Calls: The Bridge to the Kernel

If any application running on a computer could directly modify storage hardware or overwrite memory belonging to other applications, a single buggy process could easily corrupt memory or crash the entire system.

To maintain stability and safety, CPUs enforce hardware execution constraints:

  • User Mode (Userspace): A constrained mode where regular applications run. The CPU prevents your code from accessing memory outside its assigned address space or touching physical hardware directly.
  • Kernel Mode: An unconstrained mode reserved for the Operating System Kernel, which manages physical hardware, device drivers, and system memory safely.
Linux Kernel Architecture overview showing Userspace, System Calls, and Kernel subsystems.

Linux Kernel Architecture overview showing how applications interact with kernel subsystems via system calls (Source: ebpf.io).

Inside the Syscall Gate: Step-by-step execution flow of ./hello requesting sys_write from the Kernel.

Inside the Syscall Gate: Step-by-step execution flow of the ./hello process passing registers across the hardware trap to the Kernel.

When an application needs to perform an I/O operation (such as printing text to the screen, reading a file, or exiting), it cannot touch the hardware directly. Instead, it requests assistance from the kernel using a System Call (Syscall).

The system call interface acts as a universal adapter between your application and physical hardware.

For example, imagine if every program had to write custom driver code specifically for an LG monitor, a Samsung display, or a Dell screen. If a user plugged in a different monitor, the app would crash. Instead, your program simply asks the kernel to perform a sys_write to stdout. The kernel translates that request into the exact electrical commands needed for whatever display or storage hardware is attached.

This stability is a fundamental guarantee provided by the kernel: as long as the operating system exposes the same system call interface, your application logic remains portable across different hardware devices and physical machines.

A system call follows these steps:

  1. Setup: The program loads argument values into specific CPU registers (%rax for the system call ID, and %rdi, %rsi, %rdx for arguments).
  2. The Trap Gate: The program executes the syscall instruction.
  3. Privilege Switch: The CPU pauses the program, switches from Constrained User Mode to Unconstrained Kernel Mode, and jumps to the kernel's system call handler table.
  4. Hardware Execution: The kernel verifies permissions, interacts with hardware drivers (such as sending character bytes to the display), and returns execution control back to the userspace program.

Step 3: What is Assembly? (ISAs, CISC vs RISC, & History)

Assembly is not a single programming language. Because every CPU architecture has its own ISA defined by its vendor, assembly is a family of languages. An assembly program written for an x86_64 Intel processor looks quite different from one written for an ARM64 processor on Apple Silicon or mobile devices.

Assembly Language Family Tree comparing CISC and RISC architectures.

Assembly Language Family Tree: Key ISA branches comparing CISC (x86_64) and RISC (ARM64, RISC-V) architectures.

CISC vs. RISC: Two Philosophies

CPU architectures generally fall into two main design paradigms:

  1. CISC (Complex Instruction Set Computer) (e.g., x86_64): CISC architectures provide feature-rich instructions. A single CISC instruction can read from memory, perform an arithmetic operation, and store the result back to memory in a single instruction.
    # x86_64 (CISC): Add a value from memory directly into a register
    addq (%rbx), %rax
    
  2. RISC (Reduced Instruction Set Computer) (e.g., ARM64, RISC-V): RISC architectures use simple, uniform instructions. To perform arithmetic on data stored in memory, a RISC processor requires loading the data into a register first (a Load/Store model):
    // ARM64 (RISC): Load from memory first, then perform addition
    ldr x1, [x0]       // Load value from memory address in x0 into x1
    add x2, x2, x1     // Add x1 to x2
    

The Early Days

If we use compilers to translate high-level code into assembly, and assemblers to turn assembly into binary machine code, how was the very first assembler created? What compiled the first compiler?

This classic chicken-and-egg question takes us back to the dawn of computing. Before modern keyboards, displays, or software tools existed, there were no automated programs to do the translation.

In the 1940s and 1950s, the first "assemblers" were human programmers. Developers wrote their code on paper, looked up instruction tables manually, and translated assembly mnemonics into raw binary opcodes. They then encoded these opcodes by punching holes into paper punch cards.

Historical 80-column IBM punch cards used for early computer programming.

Historical 80-column IBM punch cards used for encoding early computer programs (Source: Wikipedia).

Developers would submit stacks of punch cards at computing centers, hand them to operators, and wait in batch queues to verify whether their program succeeded or failed. Once humans wrote an initial assembler directly in raw binary opcodes, that basic assembler could finally be used to assemble more advanced tools. This process is known as bootstrapping.

The Cost of a Typo & The Origin of "Patching"

In the punch card era, a single typo or misplaced hole carried a heavy cost. A developer had to wait hours or even overnight in batch queues just to receive a printout indicating a syntax error.

A physical punch card repaired with tape patching over accidentally punched holes.

A physical punch card with adhesive tape patches covering incorrect holes, demonstrating the origin of the software term "patching".

To avoid re-punching an entire stack of cards, programmers physically glued small pieces of paper or adhesive tape over misplaced holes to restore the paper surface, or re-punched corrected holes over the covered areas. This physical repair of damaged code is where the modern software engineering term "patching" (and "software patch") originated.

Step 4: Write your first x86_64 Assembly program

Now let's write, assemble, and run an x86_64 assembly program from scratch in your Linux environment.

1. Create your exercise workspace

Create a dedicated directory for your assembly experiments:

mkdir -p ~/exercise && cd ~/exercise

2. Write the source code (hello.s)

Create a file named hello.s with the following content:

.text
.globl _start

_start:
    movq $1, %rax           # Syscall #1: sys_write
    movq $1, %rdi           # Argument 1: stdout (file descriptor 1)
    leaq msg(%rip), %rsi    # Argument 2: pointer to string buffer
    movq $14, %rdx          # Argument 3: length of string (14 bytes)
    syscall                 # Request the kernel to write text to stdout

    movq $60, %rax          # Syscall #60: sys_exit
    xorq %rdi, %rdi         # Argument 1: exit status 0 (success)
    syscall                 # Request the kernel to terminate the program

.section .rodata
msg:
    .ascii "Hello, World!\n"

Understanding the code step-by-step

Here is a breakdown of what happens when this file is processed:

  • .text and _start: .text tells the assembler that executable instructions follow. _start specifies the entry point symbol for the linker.
  • Preparing sys_write:
    • We store 1 in %rax to select system call #1 (sys_write).
    • We store 1 in %rdi to specify file descriptor 1 (stdout).
    • We load the string address into %rsi and its byte length (14) into %rdx.
    • We execute syscall to trigger the hardware gate.
  • Preparing sys_exit:
    • We store 60 in %rax (sys_exit) and 0 in %rdi (success status).
    • We execute syscall again to exit cleanly.

If you are curious about system call numbers and register arguments for other operations on Linux (such as opening files or managing memory), you can inspect the Chromium OS Linux System Call Table.

Assemble hello.s into an object file using the GNU Assembler (as):

as hello.s -o hello.o

Link the object file into an executable binary using the GNU Linker (ld):

ld hello.o -o hello

Execute your assembly binary and verify its exit code:

./hello
echo $?

You should see Hello, World! printed to stdout, with 0 returned as the exit status.

Congratulations! You have now written, assembled, and executed an x86_64 assembly program directly on Linux. You have peeled back the layers of high-level abstractions, stepped across the system call boundary, and communicated directly with the kernel and hardware.

About the Author

Başar Subaşı

Başar Subaşı

Find this author online

Writes about

linuxprogramming

Frequently covers

assemblycgccbare-metalelf