Linuxのパイプを説明してみます。 Linux v5.1のデータ構造をベースにした説明になります。
データ構造の説明
| データ | 説明 |
|---|---|
| files_struct{} | 自プロセスで開いているファイル群の情報を管理する構造体。 |
| fd_array[] | ファイルディスクリプタテーブル。file{}へのポインタを格納する配列型の変数。 |
| file{} | ファイル操作の情報を管理する構造体。ファイルを開くと割り当てられる。 |
| dentry{} | ファイル名やディレクトリの階層構造を管理する構造体。 |
| inode{} | VFS上で管理するinode情報を収めた構造体。 |
図説
標準入出力を扱う2つのプロセス間の通信を例に、パイプ(単方向)の仕組みを説明します。
1. 親プロセスを起動した状態
ここがスタート地点です。

files_struct{}が持つ変数fd_array[]には、標準入力/標準出力/標準エラー出力を表すfile{}へのポインタが格納されています(図ではこれらに紐づくfile{}は省略しています)。
2. pipe(2)
親プロセスでパイプを作成します。

pipe(2)は以下の形式になっています。
int pipe(int pipefd[2]);
呼び出すことで、引数として渡した配列にパイプのファイルディスクリプタが設定されます。それぞれpipefd[0]がパイプのread用、pipefd[1]がwrite用に対応します。図のfd_array[]のインデックス3、4がファイルディスクリプタ(int型)そのものです。
そして、2つのファイルディスクリプタは異なるfile{}を指しています。read用、write用はそれぞれO_RDONLY、O_WRONLYのフラグが立っているので、見分けが付くだけでなく、フラグに合った操作しかできないようになっています。
それから、2つのfile{}がdentry{}やinode{}を共有しているのがわかると思います。これは、パイプの実体を共有していることを示しています。パイプの実体はメモリ上に確保したバッファであり、inode{}の変数i_pipe中で保持しています。このバッファ領域を読み書きすることでプロセス間通信を実現しています。
3. fork(2)
子プロセスを作成します。

新たにプロセスを作るにはfork(2)を使います。fork(2)を呼び出すと、呼び出し元を親とする子プロセスが作成され、親の持つfiles_struct{}が子プロセスにコピーされます。
ここでのポイント1つ目は、ファイルディスクリプタはプロセスごとに管理しているということです。これにより、pipe(2)で割り当てられたファイルディスクリプタを含むfd_array[]を子プロセスでも利用可能になっています。
2つ目のポイントは、fork(2)によってdentry{}やinode{}はコピーされないということです。パイプの実体を共有するという目的に対して、この挙動がうまく働いています。
4. 使用しないファイルディスクリプタをcloseする
使用しないファイルディスクリプタは早々に閉じておきます。

親が持つwrite用のfd_array[4]と、子が持つread用のfd_array[3]は使用しないのでclose(2)で閉じます。
パイプへの書き込みが完了して、子のfd_array[4]が閉じられた状態でも、親のfd_array[4]が閉じられていないとEOFが送られず、readする際にブロックされてしまいます。
5. dup2(2)
最後にファイルディスクリプタテーブルを書き換え、子プロセスの出力先と親プロセスの入力元をパイプに向けます。

dup2(2)を使うとファイルディスクリプタをコピーすることができます。形式は以下の通りです。
int dup2(int oldfd, int newfd);
呼び出すと、既存のnewfdを閉じた上でoldfdをnewfdにコピーします。これを使用して、
子の持つwirte用のfd_array[4]をfd_array[1](元は標準出力)へ、
親の持つread用のfd_array[3]をfd_array[0](元は標準出力)へ、
コピーします。
dup2(2)が完了したら、親のfd_array[4]と子のfd_array[3]をclose(2)で閉じて、作業完了です。
6. 作業完了
これで「子がパイプに書き込み、親がパイプから読み込む」という単方向パイプの挙動が実現できます。
