LearnLinux System CallsProcesses and Plumbing

Pipes and Redirection Under the Hood

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.

Two programs, one channel

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.

Trace a whole pipeline

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.

Read the pipe the shell built

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 move: dup2

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.

Commit: what did dup2 actually do?

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.

Why wc never knows it is a 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.

Commit: does wc know it is reading a pipe?

A redirect is the same move

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.

Which descriptor a redirect writes to

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.

The tempting wrong picture

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.

Commit: where does a redirect write?

Write down what you found

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.

Milestone: you can read the plumbing

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.

Challenge: trace a longer pipeline

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.

The kit you just earned

You took a pipeline you have typed a hundred times and read the machinery underneath it. Here is every form this lesson taught:

CommandWhat 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.txtRecord the call that builds the channel
echo dup2 > answers/moved_call.txtRecord the call that moves a descriptor

And the facts underneath all of it:

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.

Ready to practice

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.

More lessons in Processes and Plumbing