The first time I tried to print “Hello, World!” in pure Assembly without a C library backing me up, I realized just how much modern languages hide from you. There’s no printf. There’s no Console.WriteLine. There’s just you, the CPU, and a very deliberate conversation with the operating system (or, in bare-metal environments, directly with hardware ports and memory-mapped registers). That conversation is what this post is about.
I’ll walk through how I/O actually happens at the Assembly level — from old-school BIOS interrupts and port-mapped I/O, through modern Linux syscalls, to memory-mapped I/O on embedded ARM systems — with real, runnable code examples along the way.
The Core Idea: I/O Needs a Privileged Intermediary
In user-space Assembly programming, you almost never talk to hardware directly. Devices like keyboards, disks, and network cards are managed exclusively by the operating system kernel, which runs in a more privileged CPU mode (Ring 0 on x86, EL1 on ARM). Your program requests I/O by asking the kernel to do it on your behalf — this request mechanism is called a system call (syscall).
The general flow looks like this:
sequenceDiagram
participant App as User Program
participant CPU as CPU (Ring 3 -> Ring 0)
participant Kernel as OS Kernel
participant Dev as Hardware Device
App->>CPU: Load syscall number + arguments into registers
App->>CPU: Execute syscall instruction (int 0x80 / syscall / svc)
CPU->>Kernel: Trap into kernel mode
Kernel->>Dev: Perform actual I/O (read disk, write to terminal, etc.)
Dev-->>Kernel: Return data/status
Kernel-->>CPU: Return result in register
CPU-->>App: Resume execution in user mode
Linux x86-64 Syscalls: Writing to Standard Output
On modern 64-bit Linux, the cleanest way to perform I/O in raw Assembly is the syscall instruction. Here’s a full working example that writes “Hello, World!\n” to stdout:
section .data
msg db "Hello, World!", 0xA
msglen equ $ - msg
section .text
global _start
_start:
; write(1, msg, msglen)
mov rax, 1 ; syscall number for sys_write
mov rdi, 1 ; file descriptor 1 = stdout
mov rsi, msg ; pointer to buffer
mov rdx, msglen ; length of buffer
syscall
; exit(0)
mov rax, 60 ; syscall number for sys_exit
xor rdi, rdi ; exit code 0
syscall
Reading input follows the mirror-image pattern using sys_read (syscall number 0):
section .bss
buffer resb 64
section .text
global _start
_start:
; read(0, buffer, 64)
mov rax, 0 ; syscall number for sys_read
mov rdi, 0 ; file descriptor 0 = stdin
mov rsi, buffer ; destination buffer
mov rdx, 64 ; max bytes to read
syscall
; rax now holds number of bytes actually read
; ... process buffer here ...
mov rax, 60
xor rdi, rdi
syscall
The Old Way: DOS/BIOS Interrupts (16-bit Real Mode)
Before Linux syscalls and the syscall instruction existed as we know them, I/O on x86 was commonly performed via software interrupts, especially in DOS and early bootloader code. This is still taught because it’s a fantastic way to understand low-level I/O without an OS getting in the way:
; Print a character using BIOS interrupt 0x10 (video services)
mov ah, 0x0E ; teletype output function
mov al, 'A' ; character to print
int 0x10 ; BIOS video interrupt
; Print a string using DOS interrupt 0x21
mov ah, 0x09 ; DOS function: print string (must end in '$')
mov dx, msg ; pointer to string
int 0x21
This interrupt-driven model works because the BIOS or DOS installs handler routines at fixed interrupt vector addresses, and int triggers a jump to whichever handler corresponds to that interrupt number — conceptually identical to modern syscalls, just implemented with an older mechanism.
ARM: System Calls via svc
On ARM (AArch64 Linux), the equivalent of x86’s syscall instruction is svc #0 (Supervisor Call). The register convention differs slightly — arguments go in x0–x5, the syscall number goes in x8, and the return value comes back in x0:
.data
msg:
.ascii "Hello from ARM!\n"
msglen = . - msg
.text
.global _start
_start:
// write(1, msg, msglen)
mov x0, #1 // file descriptor: stdout
ldr x1, =msg // buffer pointer
mov x2, #msglen // buffer length
mov x8, #64 // syscall number for sys_write on ARM64
svc #0
// exit(0)
mov x0, #0
mov x8, #93 // syscall number for sys_exit on ARM64
svc #0
Port-Mapped I/O vs Memory-Mapped I/O
Beyond syscalls (which are the abstraction most programmers use), there are two fundamentally different hardware-level I/O models, and it’s worth understanding both, especially if you ever write bare-metal or driver-level code:
| Model | How it Works | Typical Instructions | Common On |
|---|---|---|---|
| Port-Mapped I/O (PMI/PIO) | Devices exist in a separate I/O address space, distinct from memory | IN, OUT (x86 only) | x86/x86-64 legacy peripherals (keyboard controller, legacy serial ports) |
| Memory-Mapped I/O (MMIO) | Devices are mapped into the regular memory address space and accessed with normal load/store instructions | MOV, LDR, STR | ARM (exclusively), modern x86 PCIe devices |
An x86 example of raw port I/O (only possible in kernel mode or with special privilege):
; Read a byte from I/O port 0x60 (PS/2 keyboard data port)
in al, 0x60
; Write a byte to I/O port 0x64 (PS/2 keyboard command port)
mov al, 0xAD
out 0x64, al
ARM has no IN/OUT instructions at all — every peripheral register on ARM systems is accessed via ordinary loads and stores to specific physical addresses reserved for that device, which is why memory-mapped I/O is the only model ARM supports:
// Example: writing to a memory-mapped UART transmit register
ldr x0, =0x09000000 // UART base address (example, board-specific)
mov w1, #'H'
strb w1, [x0] // store byte to transmit register
I/O Internal Workflow: From Instruction to Device
Here’s the layered process a single “write a character” request goes through on a typical modern OS:
| Layer | Responsibility |
|---|---|
| Application (Assembly code) | Prepares registers, issues syscall/interrupt |
| CPU exception mechanism | Switches privilege level, jumps to kernel entry point |
| Kernel syscall dispatcher | Identifies syscall number, routes to correct handler |
| Device driver | Translates generic request into device-specific commands |
| Hardware abstraction / bus | Sends commands over I/O port or memory-mapped register |
| Physical device | Actually performs the action (renders pixel, sends UART byte, etc.) |
Buffered vs Unbuffered I/O
Something that confused me early on: why does sys_write sometimes seem instant, while higher-level library functions like printf sometimes delay output until a newline or program exit? The answer is buffering, which typically happens in user-space library code (like glibc’s stdio), not in the syscall itself. Raw syscalls like sys_write are unbuffered — each call is a direct kernel request. Libraries add buffering on top to reduce the overhead of frequent, small syscalls, since each syscall involves an expensive context switch between user mode and kernel mode.
Practical Use Cases
- Bootloaders: Before an OS even exists, code running at boot uses raw BIOS interrupts or UART memory-mapped registers to print debug messages.
- Embedded systems programming: Writing directly to memory-mapped UART, GPIO, or timer registers is the norm on microcontrollers with no OS at all.
- Performance-critical I/O: Understanding syscall overhead helps you batch reads/writes efficiently instead of issuing a syscall per byte.
- Reverse engineering and malware analysis: Recognizing
syscall/int 0x80/svcpatterns in disassembly tells you exactly what a binary is doing with the outside world.
Debugging I/O Code
With strace on Linux, you can watch every syscall your Assembly program makes in real time:
strace ./myprogram
This shows exactly which syscall numbers were invoked, what arguments were passed, and what was returned — invaluable for confirming your register setup matches what the kernel expects.
With GDB, you can set a breakpoint right before a syscall instruction and inspect register state to make sure rax, rdi, rsi, rdx (or their ARM equivalents) hold exactly what you intend:
break *0x401020
run
info registers rax rdi rsi rdx
Common Mistakes
- Wrong syscall number for the target architecture — syscall numbers differ between x86-64 and ARM64 (and even between 32-bit and 64-bit x86), so copying a syscall table from the wrong reference is a frequent source of confusion.
- Off-by-one buffer lengths, leading to truncated or garbage output.
- Forgetting the file descriptor argument entirely, especially when porting old DOS interrupt-based code that had no concept of file descriptors.
- Assuming
IN/OUTwork on ARM — they simply don’t exist on that architecture; every ARM I/O access must go through memory-mapped addresses.
Best Practices
- Prefer syscalls over legacy interrupts on modern OS targets; interrupts like
int 0x80still work on Linux for backward compatibility but are slower than the nativesyscallinstruction. - Batch small I/O operations into larger buffered writes/reads where possible to reduce syscall overhead.
- Always double check the syscall table for your specific architecture and OS version before hardcoding syscall numbers.
- When working with memory-mapped I/O on embedded ARM targets, always consult the specific board’s datasheet for correct register addresses — these are not standardized across vendors the way x86 legacy ports are.
Windows I/O: Calling the Win32 API from Assembly
Everything so far has focused on Linux and bare-metal targets, but I’d be leaving out an important piece if I skipped Windows entirely, since it uses a very different I/O model at the Assembly level. Windows doesn’t expose raw syscall numbers as a stable public interface the way Linux does — instead, user-mode Assembly code calls into the Win32 API (kernel32.dll) using the standard x64 calling convention, exactly like calling any other function:
extern GetStdHandle
extern WriteFile
extern ExitProcess
section .data
msg db "Hello from Windows Assembly!", 0xD, 0xA
msglen equ $ - msg
bytes_written dd 0
section .text
global main
main:
sub rsp, 40 ; shadow space + alignment (Win64 ABI requirement)
mov ecx, -11 ; STD_OUTPUT_HANDLE
call GetStdHandle
mov r12, rax ; save handle
mov rcx, r12
lea rdx, [msg]
mov r8d, msglen
lea r9, [bytes_written]
mov qword [rsp+32], 0 ; lpOverlapped = NULL
call WriteFile
xor ecx, ecx
call ExitProcess
Notice the “shadow space” — the Win64 calling convention requires the caller to reserve 32 bytes on the stack even when a callee has fewer than four arguments, because the callee is allowed to spill its register arguments there if it needs to. This is a Windows-specific quirk that has nothing to do with the underlying I/O operation itself, but it will silently corrupt your stack if you forget it when calling any Win32 API function from hand-written Assembly.
Checking for Errors After a Syscall
Something beginners (myself included) often skip: checking whether an I/O operation actually succeeded. On Linux, a negative return value in rax after a syscall indicates an error, encoded as the negated errno value:
syscall
cmp rax, 0
jl handle_error ; rax < 0 means an error occurred; -rax is the errno value
On Windows, most Win32 API functions return a boolean success/failure value in eax, and the actual error code must be retrieved separately with a call to GetLastError. Skipping this check is one of the most common reasons “my program just silently does nothing” bugs happen — the I/O call failed, but nothing in the code ever looked at the return value to notice.
Interrupt-Driven I/O at the Hardware Level
Everything discussed so far involves your program actively requesting I/O. But there’s a second, complementary model worth understanding: hardware-initiated interrupts, where a device (like a keyboard or a network card) signals the CPU asynchronously the moment it has data ready, rather than your code repeatedly asking “is there data yet?” (polling). At the Assembly level, this involves an Interrupt Descriptor Table (IDT) on x86 or a Vector Table on ARM, mapping specific interrupt numbers to handler routine addresses that the CPU jumps to automatically the instant the hardware interrupt fires. This is squarely OS/kernel and embedded-firmware territory rather than everyday application programming, but recognizing the pattern helps when reading bootloader or driver disassembly.
File I/O Beyond Standard Streams
Everything so far has focused on stdin/stdout, but file I/O follows the exact same syscall pattern with one extra step: you must first open the file to obtain a file descriptor before you can read or write it. Here’s a complete Linux x86-64 example that opens a file, writes to it, and closes it:
section .data
filename db "output.txt", 0
text db "Written from Assembly", 0xA
textlen equ $ - text
section .text
global _start
_start:
; open(filename, O_WRONLY|O_CREAT|O_TRUNC, 0644)
mov rax, 2 ; sys_open
mov rdi, filename
mov rsi, 0x241 ; O_WRONLY(1) | O_CREAT(0x40) | O_TRUNC(0x200)
mov rdx, 0o644 ; permissions
syscall
mov r12, rax ; save file descriptor
; write(fd, text, textlen)
mov rax, 1
mov rdi, r12
mov rsi, text
mov rdx, textlen
syscall
; close(fd)
mov rax, 3
mov rdi, r12
syscall
; exit(0)
mov rax, 60
xor rdi, rdi
syscall
The pattern of “open once, use the returned descriptor repeatedly, close when done” is universal across every kind of I/O on Unix-like systems — the same file descriptor abstraction covers regular files, sockets, pipes, and even devices, which is precisely why sys_read/sys_write work identically regardless of what the descriptor actually points to.
Converting Numbers for Display
A subtlety that trips up nearly every beginner: the syscalls above only ever move raw bytes — there’s no built-in “print an integer” operation anywhere in the kernel interface. If you want to display the number 42, you must first convert it into the ASCII characters '4' and '2' yourself. Here’s a minimal integer-to-ASCII routine:
; Converts the unsigned integer in eax to a null-terminated
; decimal string in buffer (destructive to eax, ecx, edx)
int_to_string:
mov ecx, buffer + 15 ; work from the end of the buffer backward
mov byte [ecx], 0 ; null terminator
.next_digit:
dec ecx
xor edx, edx
mov ebx, 10
div ebx ; eax = eax/10, edx = eax%10
add dl, '0'
mov [ecx], dl
test eax, eax
jnz .next_digit
ret
This kind of manual digit-extraction routine (dividing repeatedly by 10 and converting each remainder to its ASCII digit) is exactly what every printf-style formatting function does internally before it ever reaches a write syscall — high-level languages just hide this step from you entirely.
Frequently Asked Questions
Q: Do I always need to write raw syscalls in Assembly, or can I call C library functions? You can absolutely call into libc from Assembly (e.g., calling printf following the System V calling convention) — many real-world Assembly programs do exactly this to avoid reinventing buffered I/O.
Q: Why does int 0x80 still work on 64-bit Linux if syscall is the modern instruction? It’s kept for backward compatibility with 32-bit programs and older tooling, but it forces a slower transition path and uses the 32-bit syscall table, so it’s discouraged for new 64-bit code.
Q: How does interrupt-driven I/O differ from polling in embedded systems? Polling means your code repeatedly checks a status register to see if a device is ready; interrupt-driven I/O means the device signals the CPU asynchronously via a hardware interrupt, letting your code do other work in the meantime.
Summary and Key Takeaways
Assembly language I/O boils down to a controlled hand-off to something more privileged than your own code — whether that’s a BIOS interrupt handler, an OS kernel via syscall/svc, or, in embedded contexts, direct memory-mapped hardware registers. x86 additionally supports legacy port-mapped I/O via IN/OUT, while ARM relies exclusively on memory-mapped I/O.
Key points to remember:
- Modern Linux x86-64 uses the
syscallinstruction; ARM64 usessvc #0, with different register conventions for arguments. - Legacy DOS/BIOS code used software interrupts (
int 0x10,int 0x21) for the same purpose. - Port-mapped I/O (
IN/OUT) is x86-specific; memory-mapped I/O works everywhere, including exclusively on ARM. - Tools like
straceandGDBare essential for verifying that your I/O syscalls are set up correctly.
References
- Linux System Call Table (x86-64) — kernel.org syscall documentation
- Intel® 64 and IA-32 Architectures Software Developer’s Manuals — Intel Corporation
- ARM Architecture Reference Manual for A-profile architecture — Arm Ltd.
- GNU Binutils and GNU Assembler (
as) Documentation — Free Software Foundation - Ralf Brown’s Interrupt List (classic BIOS/DOS interrupt reference)