Learn › Linux System Calls › Processes and Plumbing
strace -f -e trace=desc,process bash -c "ls | wc -l" - a hands-on Linux lab on a real virtual machine.
Trace a shell building a pipeline and a redirect, and watch pipe and dup2 wire one program's output into another's input.
You type ls | wc -l and get a number back: how many files are in the directory. Two separate programs ran. ls listed the names, and wc -l counted the lines. Neither one knew the other existed.
So how did the output of ls become the input of wc? The programs did not arrange it. The shell did, in the moment between reading your command and running it, with two system calls. One builds a channel. The other moves the end of that channel onto a descriptor a program already uses.
In this lesson you watch the shell do it. You will trace a real pipeline and a real redirect, name the two calls by sight, and see that wc reads its input the same way it always does, never knowing the input is a pipe.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every graded screen in this lesson was captured by booting this exact lab and running these exact commands. The illustrative traces show real output, but the [pid NNN] numbers in them are assigned fresh each boot, so yours will be different numbers. The lab user is tracer and the machine is named syscall-lab. Your progress is tracked automatically, so type commands naturally.
A plain strace follows one program. A pipeline is two programs, and the shell spawns each as a child. To see inside the children you add -f, which means follow every child the traced command spawns. Without it you would watch the shell and miss the interesting part.
Run the pipeline under strace and narrow the trace to the calls that touch descriptors and processes, so the flood of memory setup does not bury the two calls that matter.
strace -f -e trace=desc,process bash -c "ls | wc -l"
Buried in the output are three lines that are the whole story. They look like this:
pipe2([3, 4], 0) = 0
[pid 848] dup2(3, 0) = 0
[pid 847] dup2(4, 1) = 1
Read the names, not the numbers. pipe2 built the channel. Then dup2 appears twice, once inside each child. The [pid NNN] prefix is strace labelling which child made the call, and those pid numbers will differ every time you run this. The call names will not.
Start with the first line: pipe2([3, 4], 0) = 0. This is the shell asking the kernel for a pipe. A pipe is not one thing; it is a pair of descriptors. One end you write to, the other end you read from. Bytes you write into the write end come out of the read end, in order.
The kernel handed back two new numbers inside the brackets: 3 and 4. Descriptor 3 is the read end. Descriptor 4 is the write end. These are file descriptors, the same small integers you met when openat returned 3 two lessons back. A pipe is just two of them, handed out together.
That = 0 at the end means the call succeeded. So after this one line, the shell holds both ends of a fresh channel, numbered 3 and 4, and nothing is connected to anything yet. The wiring is the next step.
The pipe exists on descriptors 3 and 4, but ls writes to descriptor 1 and wc reads from descriptor 0. Those are the standard output and standard input every program is born with. The shell has to bridge that gap, and dup2 is how.
dup2(a, b) copies descriptor a onto descriptor b. After it runs, b refers to whatever a referred to, and the old b is closed. Think of it as pointing an existing number at a new target. The program keeps using the same number and lands somewhere new.
Look at the trace again. dup2(4, 1) runs in the child that becomes ls: it points descriptor 1, standard output, at the write end of the pipe. dup2(3, 0) runs in the child that becomes wc: it points descriptor 0, standard input, at the read end. Now ls printing to 1 feeds wc reading from 0.
You see dup2(3, 0) = 0 in the child that runs wc. Take a position on what that one line did before you read on.
>>> It pointed standard input at the pipe. dup2(3, 0) copies descriptor 3, the read end, onto descriptor 0. From that moment, anything reading standard input is reading the pipe. If you picked the first answer, dup2 opens nothing; the pipe already existed from the earlier pipe2, and dup2 only redirects an existing number. If you picked the third, dup2 does close the old 0, but only to replace it with the pipe end, not to disable anything; the whole point is to keep 0 usable and make it mean the pipe.
Here is the trick the whole lesson is built on. By the time wc starts running, descriptor 0 already points at the pipe. The shell did that with dup2 before it ever launched wc. So wc does what it always does: it reads standard input.
wc contains no pipe code. It does not check whether its input is a pipe, a file, or your keyboard. It reads descriptor 0 and counts. The shell arranged for descriptor 0 to be the pipe, and wc is none the wiser. Every filter program in Linux works this way, which is why you can chain any of them together.
That is the payoff of the two calls. pipe2 makes a channel on two spare descriptors. dup2 moves those ends onto 0 and 1 before each program starts. The programs stay simple; the shell does the plumbing.
wc detects a pipe and calls a special pipe-reading function.wc reads standard input as always; the shell wired the pipe onto descriptor 0 before wc started.wc the input is a pipe.>>> No, and this is the idea to keep. The shell used dup2 to point descriptor 0 at the pipe before launching wc, so wc just reads 0 the way it reads any input. If you picked the first answer, that would mean every filter needs pipe-aware code, and none of them do; the uniformity is the point. If you picked the third, there is no such flag, and needing one would break the simple rule that any program reading standard input can sit in a pipeline unchanged.
Now trace a redirect instead of a pipe. ls > out.txt sends the output of ls into a file. Watch it the same way:
strace -f bash -c "ls > /tmp/out.txt"
The shape is familiar. The shell opens the file, then moves that open descriptor onto standard output. It looks something like this, with your own descriptor number in place of 3:
openat(AT_FDCWD, "/tmp/out.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 3
dup2(3, 1) = 1
openat opened the file and got descriptor 3. Then dup2(3, 1) pointed standard output at that file. It is the exact same dup2 move you saw in the pipeline, just aimed at a file instead of a pipe end. A redirect and a pipe are the same mechanism wearing different clothes.
Look at where that dup2 landed: descriptor 1. A > redirect always moves the file onto descriptor 1, standard output, because that is where a program's normal output goes. The program writes to 1 as always, and 1 now points at the file.
This is why 1 is the number to remember. ls > out.txt catches standard output. If you wanted to catch error messages instead you would redirect 2, standard error, which is a later lesson. For the plain > you use every day, the descriptor is 1.
It is natural to picture ls and wc talking to each other directly, ls handing its lines across to wc like passing a note. The traces you just read are the proof that no such thing happens.
ls never mentions wc. It writes to descriptor 1, exactly as it would if you ran it alone. wc never mentions ls. It reads descriptor 0. Neither program knows a pipeline exists. The connection lives entirely in the shell, which built the pipe and moved the ends onto 0 and 1 before either program drew breath.
This is the mental shift. A pipe is not two programs cooperating. It is the shell wiring two descriptors together and then stepping back. Once you see it that way, every pipeline you ever write is just pipe2 plus a pair of dup2 calls you now know how to find.
>>> It is 1. A > redirect points standard output at the file, so dup2 moves the file onto descriptor 1. If you picked 0, that is standard input, the reading end; a > redirect is about output, so it never touches 0 (the < redirect does that). If you picked 2, standard error stays on your screen during a plain >; you redirect 2 on purpose with 2>, and telling 1 from 2 is exactly what the redirection lesson drills.
Your lab scores what you can show it found. Three short files capture the three facts from this lesson: the call that builds the pipe, the call that moves a descriptor, and the descriptor a redirect writes to. Write each name, then read all three back.
echo pipe2 > answers/pipe_call.txt
echo dup2 > answers/moved_call.txt
echo 1 > answers/redirect_fd.txt
prompt: tracer@syscall-lab:~$ answer: echo pipe2 > answers/pipe_call.txt; echo dup2 > answers/moved_call.txt; echo 1 > answers/redirect_fd.txt; echo pipe:; cat answers/pipe_call.txt; echo moved:; cat answers/moved_call.txt; echo redirect_fd:; cat answers/redirect_fd.txt ||| echo pipe: && cat answers/pipe_call.txt && echo moved: && cat answers/moved_call.txt && echo redirect_fd: && cat answers/redirect_fd.txt output: pipe: pipe2 moved: dup2 redirect_fd: 1 hint: Echo each answer into its file, then print a label and cat each file: echo pipe2 > answers/pipe_call.txt, and so on.
Three files, three facts. pipe_call.txt holds pipe2, the call that builds the channel. moved_call.txt holds dup2, the call that points a descriptor at a new target. redirect_fd.txt holds 1, the descriptor a > redirect writes to.
The grader reads these files, so writing them is not busywork. It is you stating, in three words, the mechanism you just watched: build a pipe, move its ends, and know which end is standard output.
Stop and notice what changed. An hour ago ls | wc -l was a single gesture that produced a number. Now it is a sequence you can narrate: the shell calls pipe2 and gets descriptors 3 and 4, calls dup2(4, 1) in one child and dup2(3, 0) in the other, then runs ls and wc, which read and write standard descriptors with no idea a pipe is involved.
That reading does not stop at ls | wc -l. Every pipeline on the machine, however long, is the same two calls repeated: one pipe2 per stage boundary, one dup2 on each side. You can now open any of them up and see the wiring.
Scaffolding off. No command is printed from here on.
In the lab, trace ls | sort | wc -l the same way you traced the two-stage pipeline. It has two pipe boundaries instead of one, so count the pipe2 calls and the dup2 calls you find. A pipeline of three programs has two channels between them.
Then trace a redirect of your own, something like date > /tmp/when.txt, and find the single dup2 that moves the file onto descriptor 1. Same move, different program, exactly as you saw with ls.
You took a pipeline you have typed a hundred times and read the machinery underneath it. Here is every form this lesson taught:
| Command | What it does | |
|---|---|---|
| `strace -f -e trace=desc,process bash -c "ls \ | wc -l"` | Trace both sides of a pipeline, showing pipe2 and dup2 |
strace -f bash -c "ls > /tmp/out.txt" | Trace a redirect and watch dup2 move the file onto 1 | |
echo pipe2 > answers/pipe_call.txt | Record the call that builds the channel | |
echo dup2 > answers/moved_call.txt | Record the call that moves a descriptor |
And the facts underneath all of it:
pipe2 hands back both.dup2(a, b) points descriptor b at whatever a refers to, then runs the program.0 and 1 before each program starts.0 and writes 1 as always.> redirect is the same dup2 move, aimed at a file, and it lands on descriptor 1.When a pipeline misbehaves, trace it with -f and look for the pipe2 and dup2 lines first. They tell you exactly which descriptor each program's input and output was wired to, which is usually where the surprise is.
You have watched programs start, open files, read and write descriptors, and now get wired together. The next step is a program that is already running and misbehaving: attaching strace to a live process and reading what it is stuck on.
This lesson walked you through the whole channel. You traced a pipeline with -f, found the pipe2 that builds the pipe, found the dup2 that moves each end onto a standard descriptor, traced a redirect into a file, and saw it land on descriptor 1. 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 and an answers directory waiting. It scores five things: you traced a pipeline with -f, you named the pipe call, you named the call that moves a descriptor, you traced a redirect into a file, and you recorded the descriptor a redirect writes to.
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 watch the shell wire two programs together.
Practice Pipes and Redirection Under the Hood in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.