Learn › Linux System Calls › The Boundary
strace -y -e trace=openat,read,write,close cat /etc/hostname - a hands-on Linux lab on a real virtual machine.
Trace how a program opens, reads, writes, and closes a file, and learn the three file descriptors every program is born with.
In the last lesson you watched ls open a library and get back 3. You saw it and moved on. This lesson is about that 3, because it is one of the most useful ideas in all of Linux, and almost nobody explains it plainly.
When a program opens a file, the kernel does not hand it the file. It hands back a small whole number. From that moment on, the program talks about the file using only that number. Read from it, write to it, close it, always by the number. The file itself stays in the kernel's back room.
That number is called a file descriptor. Think of a coat check. You hand over your coat and get a little numbered tag. You do not carry the coat around; you carry the tag, and when you want the coat back you show the tag. A file descriptor is the tag, and the kernel is holding the coat.
By the end of this lesson you will trace a program opening a file, watch the descriptor come back, and follow that one number as the program reads and closes the file. You will also meet the three descriptors every program is born with, and use one flag that turns a bare number into the path it stands for.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every screen in this lesson was captured by booting this exact lab and running these exact commands. Addresses and pipe numbers on your own machine will be your own. The lab user is tracer and the machine is named syscall-lab. Your progress in the lab is tracked automatically, so type commands naturally.
A full trace of even a tiny program is mostly setup: asking for memory, opening libraries. When you only care about files, you can tell strace to show just the calls you name. That is the -e trace= flag. After it, list the calls you want, separated by commas.
Here is cat /etc/hostname traced with the list narrowed to two calls, openat and write:
strace -e trace=openat,write cat /etc/hostname
openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
write(1, "syscall-lab\n", 12syscall-lab
Two calls, the whole story. cat opened /etc/hostname and got back 3. Then it wrote the contents to descriptor 1. That is the entire job of cat on one file: open it, read it, write it out.
The write line looks tangled, and there is a reason. strace prints its report to the error stream while cat prints the real hostname to the screen, and the two landed on the same line with nothing between them. You met this exact weave in the last lesson. Sending the trace to a file with -o, or looking at cleaner calls, untangles it. Hold that thought.
A bare 3 tells you a file is open, but not which file. Once a program has ten files open, ten different numbers, a plain trace is a wall of numbers with no names. The fix is one letter. The -y flag tells strace to annotate every descriptor with the path it points to.
Trace cat /etc/hostname again, this time with -y, and narrow the calls to the file operations so the setup noise stays out of the way. Focus the result on the target file:
strace -y -e trace=openat,read,close cat /etc/hostname 2>&1 | grep hostname
prompt: tracer@syscall-lab:~$ answer: strace -y -e trace=openat,read,close cat /etc/hostname 2>&1 | grep hostname ||| strace -y -e trace=openat,read,close cat /etc/hostname ||| strace -y cat /etc/hostname 2>&1 | grep hostname output: openat(AT_FDCWD</home/tracer>, "/etc/hostname", O_RDONLY) = 3</etc/hostname> read(3</etc/hostname>, "syscall-lab\n", 131072) = 12 read(3</etc/hostname>, "", 131072) = 0 close(3</etc/hostname>) = 0 hint: Add -y for path annotations and -e trace= to pick the calls: strace -y -e trace=openat,read,close cat /etc/hostname 2>&1 | grep hostname
Read the whole life of one descriptor, top to bottom. The openat returns 3</etc/hostname>. That </etc/hostname> is the -y annotation: the kernel handed back 3, and -y is reminding you that 3 means /etc/hostname.
Now follow the 3 down the screen. read(3</etc/hostname>, ...) reads from it and gets 12 bytes, the eleven letters of syscall-lab plus a newline. A second read returns 0, which is how the kernel says end of file, nothing left. Then close(3</etc/hostname>) hands the tag back.
That is the pattern under every file operation on Linux: open to get a number, read or write using the number, close to give it back. The -y flag is what lets you read it as plain English instead of a column of anonymous digits.
It is fair to ask why the kernel bothers with a numbered tag at all. Why not just hand the program the file?
Because the file lives on the other side of the wall. In the last lesson you saw that a program runs walled off from the hardware, and reaches the kernel only by asking. A file on disk is the kernel's to manage: where its bytes are, who may touch it, how far along you have read. The program is not allowed to hold any of that. So the kernel keeps the real thing and gives out a tag.
The number is also small and cheap. It is just an index into a little table the kernel keeps for your program, a table of every file it has open. Descriptor 3 is row three in that table. This is why descriptors are always small counting numbers rather than long addresses: they are row numbers, nothing more.
You have seen write(1, ...) put text on the screen twice now, once in the last lesson and once above. Every program is born with three descriptors already open before it opens a single file of its own. Take a position on which one is the screen.
>>> It is 1. Every program starts life with three descriptors already open: 0 is standard input, where typed input comes from; 1 is standard output, where normal output goes; 2 is standard error, where problems go. A write to 1 is a program putting something on your screen, which is why you saw write(1, ...) print the hostname. If you picked 0, that is the one a program reads from, not writes to, and it is the easiest of the three to swap in your memory, so pin it down now. If you picked the third answer, these three never change; the shell opens them before the program even starts, which is why every program can print without opening anything first.
You do not have to take the three descriptors on faith. Linux shows you the open files of any running program under /proc, as a little folder of numbered links. Look at the ones a fresh program is handed with ls -l /proc/self/fd. It prints something like this:
total 0
lr-x------ 1 tracer tracer 64 Aug 19 05:13 0 -> /dev/null
l-wx------ 1 tracer tracer 64 Aug 19 05:13 1 -> pipe:[2485]
l-wx------ 1 tracer tracer 64 Aug 19 05:13 2 -> pipe:[2463]
lr-x------ 1 tracer tracer 64 Aug 19 05:13 3 -> /proc/884/fd
Read the numbers on the left: 0, 1, 2 are already there before this program opened anything. Those are the trinity, standard input, output, and error, every program is born holding them. The arrows show where each one currently points, and those targets are whatever the shell wired up: here 0 came from /dev/null and 1 and 2 were joined to a pipe.
The exact arrows and the numbers in pipe:[2485] and /proc/884/fd will be different on your machine every time, because they depend on how the shell launched the command. Do not memorize them. The one fixed fact is the count: three descriptors, numbered 0, 1, 2, open before any file of your own.
You have the three starting descriptors fixed in your mind: 0, 1, 2. Now a program opens its very first file. Predict the number the kernel writes on the tag.
>>> It is 3. The kernel always hands back the lowest number not already in use. Since 0, 1, and 2 are taken from birth, the first file a program opens is 3, the next is 4, and so on. This is why you saw openat(...) = 3 for /etc/hostname and again for the very first library in the last lesson: in both cases it was the first thing that program opened. If you picked 0, that row is already spoken for by standard input and the kernel will not reuse it while it is open. If you picked the third answer, there is a firm rule, lowest free number wins, and it is worth trusting because it makes traces predictable.
Here is the picture almost every beginner carries, and it is worth dragging into the light so you can drop it. It is natural to imagine that when a program opens a file, it now holds the file, the way you hold a book once you pick it up.
The traces you just read are the proof that it does not. The program never held /etc/hostname. It held the number 3. Every time it wanted a byte, it asked the kernel, using 3, and the kernel went and got it. When it was done it handed 3 back with close. The file stayed in the kernel's care the entire time.
This matters more than it looks. Two programs can hold their own tags for the very same file. Each has a different number, and each is at a different point in reading it. Neither steps on the other, because the kernel is the one holding the file and tracking who is where. Once you see a descriptor as a tag and not the thing itself, a lot of Linux stops being mysterious.
You are about to trace a program you did not write, mystery_reader, and work out which file it opens. Without -y, its trace shows openat(AT_FDCWD, ...) = 3 and then read(3, ...), and that bare 3 is all you get. Predict the fastest way to learn what 3 is.
>>> Re-run with -y. That is exactly the flag's job: it stamps the path onto every descriptor on every line, so read(3</home/tracer/vault/records.db>, ...) tells you what 3 is without you tracking it back to the openat yourself. If you picked reading the source, you often cannot; the whole point of tracing is to see what a program does when you do not have its source. If you picked the third answer, the kernel knows the path behind every open descriptor and -y simply asks it, which is what makes this a two-second job instead of guesswork.
In your home directory is a compiled program called mystery_reader. Run it and it prints a line, but it does not tell you where that line came from. You are going to find the file it reads without ever opening its source. Trace it with -y, and focus on the calls that touch its file.
strace -y ./mystery_reader 2>&1 | grep records.db
prompt: tracer@syscall-lab:~$ answer: strace -y ./mystery_reader 2>&1 | grep records.db ||| strace -y ./mystery_reader 2>&1 | grep vault ||| strace -y ./mystery_reader 2>&1 | grep records output: openat(AT_FDCWD</home/tracer>, "/home/tracer/vault/records.db", O_RDONLY) = 3</home/tracer/vault/records.db> read(3</home/tracer/vault/records.db>, "id,name,role\n1,tux,mascot\n2,beas"..., 64) = 42 close(3</home/tracer/vault/records.db>) = 0 hint: Trace with -y and filter to the file it touches: strace -y ./mystery_reader 2>&1 | grep records.db
Solved. The openat line names the file in its argument and again in the -y annotation: /home/tracer/vault/records.db. The program opened it read-only, got back 3, and from there every line refers to it as 3</home/tracer/vault/records.db>.
Read the read line for a bonus. The kernel returned 42, meaning it read forty-two bytes, and the trace shows the start of them: id,name,role\n1,tux,mascot\n2,beas before it cuts off with .... So you learned not just which file, but what is in it, the head of a small comma-separated table. You did all of that to a program whose code you never saw. That is the real power of the trace: it tells you the truth about what a program touches, no source required.
Your lab scores what you can show it found. Three short files capture the three facts from this lesson: which descriptor is standard output, the first number a fresh open returns, and the file the mystery program reads. Write each one, then read all three back.
echo 1 > answers/stdout_fd.txt
echo 3 > answers/first_fd.txt
echo /home/tracer/vault/records.db > answers/mystery_file.txt
prompt: tracer@syscall-lab:~$ answer: echo 1 > answers/stdout_fd.txt; echo 3 > answers/first_fd.txt; echo /home/tracer/vault/records.db > answers/mystery_file.txt; echo stdout_fd:; cat answers/stdout_fd.txt; echo first_fd:; cat answers/first_fd.txt; echo mystery_file:; cat answers/mystery_file.txt ||| cat answers/stdout_fd.txt answers/first_fd.txt answers/mystery_file.txt output: stdout_fd: 1 first_fd: 3 mystery_file: /home/tracer/vault/records.db hint: Echo each fact into its file under answers/, then cat all three back to check them
Three files, three facts. stdout_fd.txt holds 1, the descriptor your output goes to. first_fd.txt holds 3, the number a fresh open returns. mystery_file.txt holds the path you found by tracing. The grader reads these, so writing them is not busywork; it is you stating in plain form what the traces told you.
Stop and notice what changed. At the start of this lesson 3 was a number you had glanced at once. Now you can read the whole life of a file through it: openat hands the number back, read and write use it, close returns it, and -y shows you the path the whole time.
You also cracked a program you did not write, finding the exact file it reads from its trace alone. That skill scales. The same move works on a program that will not start, a service reading the wrong config, a tool touching a file it should not. You watch its descriptors, and the truth is right there.
Scaffolding off. No command is printed from here on.
In the lab, trace a different file read of your choosing, cat answers/first_fd.txt is a fine one, with -y and the file calls. Follow the descriptor the same way: find the openat that hands back a number, the read that uses it, and the close that returns it. Notice that the number is 3 again, because your program starts fresh with only 0, 1, 2 open.
The point is not a new command. It is that the pattern you learned on /etc/hostname and on mystery_reader is the same pattern on every file read there is: open to a number, use the number, close it. Prove it to yourself on a file you pick.
You took the little number a trace hands back and learned to read a whole file operation through it. Here is every form this lesson taught:
| Command | What it does | |
|---|---|---|
strace -e trace=openat,write cat /etc/hostname | Show only the calls you name, not the setup noise | |
strace -y cat /etc/hostname | Annotate every descriptor with the path it points to | |
ls -l /proc/self/fd | See the three descriptors a program is born holding | |
| `strace -y ./mystery_reader 2>&1 \ | grep records.db` | Follow a program's descriptor to the file it opens |
And the five facts underneath all of it:
0 input, 1 output, 2 error.3, the lowest free number after the trinity.-y annotates each descriptor with its path, turning a bare number into a name you can read.When a trace shows you a bare number and you want to know the file, reach for -y first. It is the single flag that turns descriptor tracing from guesswork into reading. Pair it with -e trace=openat,read,write,close to cut a noisy trace down to just the file operations.
You watched read return the number of bytes it got, and write return the number it wrote. Those counts are the whole subject of the next lesson. It covers how reading and writing actually move bytes, why a read can come back with fewer bytes than you asked for, and what a return of 0 really means.
This lesson walked you through the whole idea. You trimmed a trace with -e trace=, annotated descriptors with -y, met the trinity 0, 1, 2, watched a fresh open return 3, and cracked a program you did not write by following its descriptor to the file. Then you wrote down the three facts the traces told you.
The lab is that same work on a real machine with strace already installed, a mystery program planted, and an answers directory waiting. It scores five things: you filtered a trace to the file calls, you resolved descriptors to paths with -y, you recorded standard output as 1, you recorded the first open as 3, and you found the file the mystery program reads.
The objectives name the goal, not the command. You read what needs to be true, recall the form, and type it. Your progress is tracked automatically as you work, so type naturally, and run check-progress whenever you want to see your score.
Launch the lab and follow the number.
Practice Files Through the Syscall Lens in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.