Intermediate

I/O Operations in Assembly

Perform input and output in x86 assembly using raw Linux system calls — reading stdin, writing stdout and stderr, and reading and writing files with NASM and Docker

In a high-level language, input and output hide behind friendly functions: print, input, open, read. In assembly there is no standard library and no printf — every byte that leaves or enters your program travels through a system call, a direct request to the operating system kernel. Understanding I/O in assembly means understanding the exact contract each syscall expects: which register holds the call number, which hold the arguments, and what comes back.

On 32-bit Linux, that contract is simple and consistent. You place the syscall number in eax, the arguments in ebx, ecx, and edx (in that order), and execute int 0x80 to hand control to the kernel. When the call returns, eax holds the result — bytes transferred, a file descriptor, or a negative error code. This tutorial uses the same 32-bit int 0x80 convention as the rest of this series.

Every I/O operation here is built from just five syscalls: sys_write (4), sys_read (3), sys_open (5), sys_close (6), and sys_exit (1). The Hello World page already used sys_write to print a fixed string. Here we go further: printing numbers, reading keyboard input, and creating and reading real files on disk — all without a single library call.

Formatted Output to stdout and stderr

Output is more than printing a constant string. Programs interleave labels, computed numbers, and status messages, and they distinguish normal output (file descriptor 1, stdout) from diagnostics (file descriptor 2, stderr). Because assembly has no built-in way to turn a number into text, we bring back the print_int helper from the functions tutorial: it divides by ten repeatedly, collecting ASCII digits into a buffer.

Create a file named output.asm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
section .data
    header   db "=== System Report ===", 10
    headerlen equ $ - header
    label    db "Answer: "
    labellen equ $ - label
    note     db "note: this line went to stderr", 10
    notelen  equ $ - note

section .bss
    numbuf  resb 12         ; scratch space for the digits of a number

section .text
    global _start

_start:
    ; write the header line to stdout (fd 1)
    mov eax, 4              ; sys_write
    mov ebx, 1             ; stdout
    mov ecx, header
    mov edx, headerlen
    int 0x80

    ; write "Answer: " with no trailing newline
    mov eax, 4
    mov ebx, 1
    mov ecx, label
    mov edx, labellen
    int 0x80

    ; convert the number 42 to text and print it (adds a newline)
    mov eax, 42
    call print_int

    ; write a diagnostic to stderr (fd 2) instead of stdout
    mov eax, 4
    mov ebx, 2            ; stderr
    mov ecx, note
    mov edx, notelen
    int 0x80

    mov eax, 1            ; sys_exit
    xor ebx, ebx          ; exit code 0
    int 0x80

; print_int -- prints the unsigned integer in eax, followed by a newline
print_int:
    mov edi, numbuf + 11   ; work backwards from the end of the buffer
    mov byte [edi], 10     ; place a trailing newline
    mov ecx, 10            ; divisor
.loop:
    xor edx, edx           ; clear high half before dividing
    div ecx                ; edx:eax / 10 -> quotient in eax, remainder in edx
    add dl, '0'            ; turn the remainder (0-9) into an ASCII digit
    dec edi
    mov [edi], dl          ; store the digit
    test eax, eax          ; more digits left?
    jnz .loop
    mov ecx, edi           ; ecx = start of the digit string
    mov edx, numbuf + 12   ; edx = length = end - start
    sub edx, edi
    mov eax, 4             ; sys_write
    mov ebx, 1            ; stdout
    int 0x80
    ret

The only difference between writing to the screen and writing to the error stream is the value in ebx: 1 for stdout, 2 for stderr. Both are just file descriptors the kernel opened for you before _start ran. Redirecting them separately (./program 2>errors.log) works precisely because they are distinct descriptors.

Reading Input from stdin

Reading is sys_read (call number 3), the mirror image of sys_write. You give it a file descriptor to read from (0 is stdin), a buffer to fill, and a maximum byte count. Crucially, sys_read returns the number of bytes it actually read in eax — you must use that count, not the buffer size, when you echo the data back, because the user rarely types exactly as many characters as your buffer can hold.

Create a file named input.asm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
section .data
    prompt   db "Enter your name: "
    promptlen equ $ - prompt
    greeting db "Hello, "
    greetlen equ $ - greeting

section .bss
    namebuf resb 64         ; space for the typed input

section .text
    global _start

_start:
    ; print the prompt (no newline, so input appears on the same line)
    mov eax, 4              ; sys_write
    mov ebx, 1
    mov ecx, prompt
    mov edx, promptlen
    int 0x80

    ; read up to 64 bytes from stdin
    mov eax, 3             ; sys_read
    mov ebx, 0            ; stdin
    mov ecx, namebuf
    mov edx, 64
    int 0x80
    mov esi, eax          ; save the byte count sys_read returned

    ; write the "Hello, " prefix
    mov eax, 4
    mov ebx, 1
    mov ecx, greeting
    mov edx, greetlen
    int 0x80

    ; echo exactly the bytes we read (esi includes the trailing newline)
    mov eax, 4
    mov ebx, 1
    mov ecx, namebuf
    mov edx, esi
    int 0x80

    mov eax, 1
    xor ebx, ebx
    int 0x80

Notice mov esi, eax immediately after the read: we preserve the returned length before any other syscall overwrites eax. When the user types Ada and presses Enter, sys_read returns 4 (three letters plus the newline), so echoing esi bytes reproduces the name and its newline exactly.

Writing to a File

Files require one more step than console I/O: you must open the file to obtain a file descriptor before you can write to it, and close it afterward. sys_open (call number 5) takes a null-terminated path in ebx, a set of flags in ecx, and permission bits in edx. It returns a fresh file descriptor in eax, which you then feed to the same sys_write you already know.

Create a file named file_write.asm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
section .data
    path     db "greeting.txt", 0   ; path MUST be null-terminated
    content  db "Written by assembly!", 10
    contentlen equ $ - content
    done     db "Wrote greeting.txt", 10
    donelen  equ $ - done

section .text
    global _start

_start:
    ; open (creating if needed) the file for writing
    mov eax, 5             ; sys_open
    mov ebx, path
    mov ecx, 0o1101        ; O_WRONLY | O_CREAT | O_TRUNC
    mov edx, 0o644         ; permission bits rw-r--r--
    int 0x80
    mov esi, eax          ; save the returned file descriptor

    ; write the content to the file (fd is in esi, not 1)
    mov eax, 4            ; sys_write
    mov ebx, esi
    mov ecx, content
    mov edx, contentlen
    int 0x80

    ; close the file to flush and release the descriptor
    mov eax, 6           ; sys_close
    mov ebx, esi
    int 0x80

    ; report success on stdout
    mov eax, 4
    mov ebx, 1
    mov ecx, done
    mov edx, donelen
    int 0x80

    mov eax, 1
    xor ebx, ebx
    int 0x80

The flag value 0o1101 is octal for three combined constants: O_WRONLY (1, write-only), O_CREAT (0o100, create the file if it does not exist), and O_TRUNC (0o1000, empty an existing file first). The permission argument 0o644 is the familiar Unix mode rw-r--r--, used only when the file is newly created. After this program runs, a real file named greeting.txt appears in your working directory.

Reading from a File

Reading a file combines everything so far: open with O_RDONLY, read into a buffer, then write those bytes to stdout so you can see them. As with stdin, sys_read tells you how many bytes it actually delivered, which you pass straight to sys_write.

Create a file named file_read.asm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
section .data
    path     db "greeting.txt", 0
    header   db "File contents:", 10
    headerlen equ $ - header

section .bss
    filebuf resb 256        ; buffer for the file's bytes

section .text
    global _start

_start:
    ; announce what we are about to show
    mov eax, 4
    mov ebx, 1
    mov ecx, header
    mov edx, headerlen
    int 0x80

    ; open the file for reading
    mov eax, 5            ; sys_open
    mov ebx, path
    mov ecx, 0           ; O_RDONLY
    mov edx, 0           ; mode ignored when not creating
    int 0x80
    mov esi, eax         ; save the file descriptor

    ; read up to 256 bytes from the file
    mov eax, 3           ; sys_read
    mov ebx, esi
    mov ecx, filebuf
    mov edx, 256
    int 0x80
    mov edx, eax         ; bytes actually read = length to echo

    ; write those bytes to stdout
    mov eax, 4           ; sys_write
    mov ebx, 1
    mov ecx, filebuf
    int 0x80

    ; close the file
    mov eax, 6           ; sys_close
    mov ebx, esi
    int 0x80

    mov eax, 1
    xor ebx, ebx
    int 0x80

This program reads the file created by file_write.asm, so run that example first. The pattern — open, read the returned count, act on exactly that many bytes, close — is the foundation of all file processing in assembly. Real programs loop on sys_read until it returns 0 (end of file), but a single read suffices for a small file like this one.

Running with Docker

Each example is a complete, standalone program. Assemble and run them with the same NASM image used throughout this series:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the NASM assembly image
docker pull esolang/x86asm-nasm:latest

# Run the formatted-output example
docker run --rm -v $(pwd):/code -w /code esolang/x86asm-nasm x86asm-nasm output.asm

# Run the input example — pipe text in and keep stdin attached with -i
echo "Ada" | docker run --rm -i -v $(pwd):/code -w /code esolang/x86asm-nasm x86asm-nasm input.asm

# Write a file (creates greeting.txt in your working directory)
docker run --rm -v $(pwd):/code -w /code esolang/x86asm-nasm x86asm-nasm file_write.asm

# Read that file back (run file_write.asm first)
docker run --rm -v $(pwd):/code -w /code esolang/x86asm-nasm x86asm-nasm file_read.asm

Expected Output

output.asm (the last line is written to stderr, which the terminal shows alongside stdout):

=== System Report ===
Answer: 42
note: this line went to stderr

input.asm (with Ada provided on stdin):

Enter your name: Hello, Ada

file_write.asm:

Wrote greeting.txt

file_read.asm:

File contents:
Written by assembly!

Key Concepts

  • All I/O is system calls: with no standard library, every read and write is a direct kernel request — eax holds the call number, ebx/ecx/edx hold the arguments, and int 0x80 makes the call.
  • File descriptors are just numbers: 0 is stdin, 1 is stdout, 2 is stderr, and sys_open hands you new descriptors for files. sys_write and sys_read treat them all identically.
  • Always use the returned count: sys_read and sys_write return the number of bytes actually transferred in eax. Echo or process that count, never assume the whole buffer was filled.
  • Preserve results before the next syscall: a returned length or file descriptor sits in eax, which the next int 0x80 will overwrite — copy it into a register like esi first.
  • Files need open and close: sys_open returns a descriptor; sys_close releases it. The open flags (O_WRONLY, O_CREAT, O_TRUNC) and permission bits (0o644) are combined numeric values you pass in ecx and edx.
  • Paths must be null-terminated: sys_open expects a C-style string, so end filename data with a , 0 byte.
  • stdout vs. stderr is one register apart: writing diagnostics to descriptor 2 instead of 1 lets users redirect errors separately — a convention built entirely from a different value in ebx.

Running Today

All examples can be run using Docker:

docker pull esolang/x86asm-nasm:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining