Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Acmeをプログラムから操作する

Acmeは、Plan 9のために書かれたテキストエディタです。現在はPlan 9 from User SpaceUnix環境にも移植されています。

このエディタはプログラムから操作するための機能がいくつか用意されていて、それを使うとキーボードやマウスなどの入力イベントを外部のプログラムが受け取り、加工してエディタに反映するなどといったことができます。標準で用意されているのはメーラーですが、最近Russ CoxがTodoを公開していました。

プログラマ向けの情報はAcmeのマニュアルにあります。

また、Goから扱うためのパッケージもあります。いろいろ複雑なeventファイルを適切に扱ってくれるので便利です。

基本

Acmeは水色の領域(tag)と黄色の領域(body)を合わせてwindowとして扱います。Acmeはwindow毎に1つディレクトリを用意していて(以下の実行例では1という名前のディレクトリ)、windowを開くたびに2,3...と増えていきます。プログラムからAcmeを操作する場合は、これらのディレクトリを使って行います。

$ 9p ls acme | mc
1       cons    draw    index   log
acme    consctl editout label   new

$ 9p ls acme/1 | mc
addr    ctl     editout event   tag     xdata
body    data    errors  rdsel   wrsel

開いているファイルの取得

acme/indexファイルにも、現在開いているファイルやディレクトリのリストが記録されています。acmeディレクトリを直接読み込んでも同じことはできますが、ほとんどの場合はこちらを使ったほうが簡単でしょう。

$ 9p read acme/index
          1          35         318           1           0 /Users/lufia/ Del Snarf Get | Look 
          2          39          20           0           1 /Users/lufia/.inputrc Del Snarf | Look 
          4          39          26           1           0 /Users/lufia/lib/ Del Snarf Get | Look 
          5          40         101           0           0 /Users/lufia/lib/npmrc Del Snarf | Look 

1行が1つのwindowを表し、左から順番に固定幅で次のような情報が書かれています。

  1. windowのID
  2. タグ行の文字数(rune)
  3. ファイルの文字数(rune)
  4. 開いているファイルがディレクトリかどうか(ディレクトリなら1)
  5. 開いているファイルが編集中(未保存)かどうか(編集中なら1)
  6. タグ行の内容

テキストの編集

windowディレクトリ(1/など)配下のaddrdataは、編集しているテキストを読み書きするために使います。

addrAcmeが扱うアドレスを書くとテキストが選択された状態になります。不要な改行を加えるとエラーになるので気をつけましょう。Acmeは以下のような書式をアドレスとして扱います。

  • 2 - 2行目を選択
  • 2,4 - 2〜4行目を選択
  • /func.*\n/ - funcから改行までを選択
  • #0,#2 - ファイルの先頭から2文字選択

範囲選択が成功すると、dataxdataの内容が範囲で選択された部分に置きかわります。datareadすると、範囲選択部分が終わってもテキストが続けば最後まで読んでしまいますが、xdataは範囲の終わりでEOFとなります。同様にwriteすると選択した範囲を新しいテキストで置き換えます。

$ 9p read acme/2/body
1行目
2行目
3行目
$ echo -n '2' | 9p write acme/2/addr
$ 9p read acme/2/data
2行目
3行目
$ echo -n '2' | 9p write acme/2/addr
$ 9p read acme/2/xdata
2行目

ただし、addrを更新しても内部的に選択された状態になるだけで、画面とはリンクしていません。画面を更新する場合はctlを使います。

$ echo -n '#30,#45' | 9p write acme/1/addr
$ echo -n 'dot=addr' | 9p write acme/1/ctl

dot=addrは変わったコマンドですが、dotは画面で選択されている範囲、addraddrファイルの内容を表すので、addrファイルの内容で画面の選択範囲を更新する意味になります。画面で選択した範囲をaddrに設定するaddr=dotもあります。

イベントの取得

Acmeのイベントは大きく2種類あります。ひとつはエディタ全体でのイベント、もうひとつはwindow単位で発生するイベントです。

acme/log

logはwindowのID、操作、ファイル名の3つが連続したテキストです。ファイル名は省略される場合もあります。実行例を示します。

$ 9p read acme/log
1 focus /Users/lufia/
2 new     # Newコマンドを2ボタンで実行した場合
2 focus 
3 new /Users/lufia/src/    # 3ボタンでファイルを開いた場合
3 focus /Users/lufia/src/
2 del 
3 focus /Users/lufia/src/
3 del /Users/lufia/src/
1 focus /Users/lufia/

スペースで区切った3つのうち、最初のカラムはAcmeのwindowを表す数字です。2つ目は操作を表すコマンドで、これはいくつかあります。

op 意味
new 新しいwindowを作成した
focus windowにマウスポインタが移動した
del windowを削除した
get ファイルを再読み込みした
put ファイルを保存した
zerox windowを複製した

最後のカラムは開いているファイル名またはディレクトリ名です。

acme/<id>/event

eventはwindow単位のイベントが流れてくるファイルです。このファイルをopenしている間は、イベントが流れてくる代わりにAcmeデフォルトの動作は止まります(書き戻すことでデフォルトの動作を起こせる)。

ファイルの内容は1つのイベントが1行になっていて、エディタを操作するたびに1行追加されていきます。eventファイルを開いてからのイベントは読み込みできますが、過去のイベントを読むことはできません。

[origin][type][addr0] [addr1] [flag] [n] [text?]\n

originは何からイベントが発生したかを表す1文字です。

origin どこからイベントが発生したか
E bodyまたはtagファイル
F eventファイルへの書き込みなど
K キーボード入力によるイベント
M マウス操作によるイベント

typeは大きく4つですが、tagまたはbodyのどちらで発生したものかを区別します。また、typeによって続きのaddr0addr1が意味するものが変わります。

type 意味
D bodyからaddr0addr1の範囲が削除された
d tagからaddr0addr1の範囲が削除された
I bodyaddr0addr1textを挿入した
i tagaddr0addr1textを挿入した
L bodytextを検索(マウスボタン3)
l tagtextを検索(マウスボタン3)
X bodytext実行(マウスボタン2)
x tagtextを実行(マウスボタン2)

Dまたはdイベントの場合、addr0addr1の値は削除前の範囲を指します。そのため、イベント発生後に範囲の内容を読み込んでも、元のテキストは消えてしまっているため取り出せません。また、Iまたはiイベントの場合は、addr0addr1はテキストを挿入し終えた後の範囲(新しく増えたテキスト部分)を表します。では2文字以上範囲選択した状態でテキスト入力するとどうなるか、ですが、この場合はDIのイベントに分割されます。従って、addrdataファイルを使って以下の操作を行なった場合、

$ echo -n 2 | 9p write acme/12/addr
$ echo hello | 9p write acme/12/data

eventファイルには分割された次のイベントが届きます。

FD6 12 0 0
FI6 12 0 6 hello

イベントの残り要素、flagtextは複雑なのでacmeのマニュアルを読んでください。textは色々な要因により省略されたりします。

利用例

AcmeのファイルAPIを使った例をいくつか挙げます。

# 4行目から9行目の範囲をaddrに設定
echo -n '4,6' | 9p write acme/<id>/addr

# 選択した範囲(4行目から9行目)を読む
9p read acme/<id>/xdata

# 先頭からのオフセットで範囲選択
echo -n '#34,#54' | 9p write acme/5/addr

# 選択した範囲を更新
echo test | 9p write acme/<id>/data

# addrの内容をwindowに反映(dot)
echo -n 'dot=addr' | 9p write acme/<id>/ctl

# windowで選択した範囲をaddrに設定
echo -n 'addr=dot' | 9p write acme/<id>/ctl

# 変更ありの状態にする
echo -n dirty | 9p write acme/<id>/ctl

# 変更ありフラグを落とす
echo -n clean | 9p write acme/<id>/ctl

Plan 9 ANSI/POSIX環境での環境変数

Plan 9ネイティブのCライブラリはPOSIXに準拠しておらず、独自の習慣がある。例えばfopenfcloseなどは使わずopencloseシステムコールを使う。バッファリングが必要であればBiobufを使いなさいという態度を取る。Goの標準パッケージに名残が残っているので、知っていれば雰囲気は伝わると思う。*1

Plan 9環境変数/env以下にファイルとして提供されていて、プロセス毎に異なる内容が扱える*2。これらの変数は配列もサポートしていて、配列の場合は\0で区切られたバイト列として表現される。例えばhome='/usr/lufia'の場合、/env/homeには"/usr/lufia"と保存されていて、path=(. /bin)('.'と'/bin'の配列)の場合/env/pathの内容は".\0/bin"となる。

ANSI/POSIX環境

Plan 9にはネイティブ環境の他に、Unixツールを移植するためだけにANSI/POSIX環境(APE)も用意されている。このライブラリはネイティブとは分けて、ヘッダは/sys/include/ape/に、アーカイブ/386/lib/ape//amd64/lib/ape/などアーキテクチャ毎に置かれている。POSIXでは、getenvputenvで設定した環境変数environ配列からも同じ内容が参照できなければならないという制約があり、environ配列は"home=/usr/lufia"のような"name=value"形式の文字列配列と定義されているため、ANSI/POSIX環境では/envを使わずメモリ上の配列を操作する方針をとる。だからgetenvenviron配列を検索するだけの関数で、putenvenviron配列を更新するだけの関数として用意されている。*3

他のプロセスへ環境変数を引き渡す時は、単純にexecveの引数にenviron配列を渡すだけで良い。execveは渡されたenviron配列を使って/envを再構築する。APEライブラリを使ったプログラムは、mainを実行する前に/envenviron配列へ読み込む。こうするとPlan 9ネイティブなプログラムは/envを参照すればよいし、APEライブラリを使って書かれたプログラムでは引き続きenviron配列として参照できるようになる。これらの処理は以下のソースコードに書かれている。

  • /sys/src/ape/lib/ap/plan9/_envsetup.c
  • /sys/src/ape/lib/ap/plan9/execve.c

ただし、Cの文字列は\0を文字列の終わりと扱ってしまうため、ネイティブ環境で配列となっている環境変数をそのまま扱うと(1つ目の\0で終わってしまって)2つ目以降の要素を扱うことができない。ネイティブ環境の場合は/env以下にファイルがあるため、stat等でファイルサイズを取れば長さが分かるけれども、environ配列はCの文字列になってしまうため、本来の長さを知る手段がない。なので_envsetup\00x1に置き換えていて、execve/envへ書き戻す前に0x1\0へ戻す実装となっていた。

そういった理由から、Plan 9ANSI/POSIX環境で、getenvを使って得た文字列の途中に0x1が見つかった場合、その文字列は配列として扱ってあげる必要がある。

バグ

余談だけどANSI/POSIX環境のgetenvenviron配列の要素を指すポインタを返す。putenvenviron配列を更新するが、getenvが返したポインタがまだ参照されている可能性があるため、以前の値を変更してはいけない。なので何度もputenvを呼び出ししていると、以前の値で使っていたメモリは消すことも再利用することもできずそのままリークする。setenvも最終的にはputenvと同じなので、同じようにリークする。

Plan 9ネイティブのgetenvmallocした値を返すので、使い終わったらユーザ側で適切にfreeしなければならない。

*1:他にもdialtokenizeなど割と多く残っている

*2:正確には、rforkのオプションにRFENVGRFCENVGがあって、これらを使って環境変数を子プロセスと共有したり分離したりといった操作をすることになる

*3:明らかに動いてなかったので修正した

Goでファイルの存在確認

Goでファイルの存在確認について、インターネットではos.Statの戻り値がエラーかどうかを判定する方法が紹介されていますけれど、os.Statはファイルが存在する場合でもエラーを返すことがあるため、この方法では正しく判定できないケースが存在します。また、複数プロセスが同じファイルにアクセスする場合は、os.Statの直後で、別のプロセスによってファイルが作成されたり削除されたりするかもしれません。正確に存在確認する場合は、存在確認した後に行う処理によっていくつかパターンがありますが、基本は、事前に存在を確認するのではなく、意図しない場合にエラーとなるようなフラグを立てておいて、OSのシステムコールが返したエラーを判定することになります。

Cなどでは、Unix系OSと異なり、Windowsは別の関数とフラグを、Plan 9は別のフラグを使いますが、Go標準パッケージのsyscallはその辺りの違いを吸収してくれているので、以下の内容はそのまま使えます。*1

目的別に紹介

ファイルの存在確認をする目的ごとに、対応方法は異なります。以下ではos.OpenFileを使いますが、Goにおいてはos.Openまたはos.Createは以下の呼び出しと同じなので、同じフラグになるのであればどの関数を使っても構いません。

// os.Open(name)は以下と同じ
os.OpenFile(name, os.O_RDONLY, 0)

// os.Create(name)は以下と同じ
os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)

ファイルが存在すれば読む

例えば設定ファイルがあれば読む場合などです。事前の確認を行わず、ファイルを読み込みフラグで開いて、エラーなら、それを使って原因を調べるといいでしょう。

f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
    if os.IsNotExist(err) {
        return nil // ファイルが存在しない
    }
    return err // それ以外のエラー(例えばパーミッションがない)
}
// ファイルが正しく読み込める
return nil

ioutil.ReadFileなどを使う場合も、とりあえず読み込んでみて、エラーなら上と同じように判定すればいいです。

ファイルを作成するが存在した場合は何もしない

ファイルがなければデフォルトの値でファイルを作成する場合などで使います。os.O_CREATEと同時にos.O_EXCLをセットすることで、作成できなかった場合にエラーとなるため、存在していたことを判定したい場合はos.IsExistで確認する必要があります。

f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
if err != nil {
    if os.IsExist(err) {
        return nil // ファイルが既に存在していた
    }
    return err // それ以外のエラー(例えばパーミッションがない)
}
// ファイルに書き込み可能
return nil

ファイルが存在すれば読み込むが、なければ新規作成する

ログなどをファイルへ書き込む場合に使うと良さそうです。ファイルの有無によりエラーとなることがないため、エラーの判定は特にありません。

f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
    return err // エラー(例えばパーミッションがない)
}
// ファイルを読み書き可能
return nil

ファイルが存在すれば削除して新規作成する

設定ファイルの更新などで使います。実際はファイルを削除するわけではなく、os.O_TRUNCフラグによってファイルの内容を消去しています。

f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
    return err // エラー(例えばパーミッションがない)
}
// ファイルに書き込み可能
return nil

ioutil.WriteFileはこのフラグと同等です。

ファイルが存在すれば削除する

削除の場合はos.OpenFileの代わりにos.Removeを使いますが、基本はos.OpenFileと同様に、実行してからのエラーを判定します。

err := os.Remove(file)
if err != nil {
    if os.IsNotExist(err) {
        return nil // 存在していないので何もしなくていい
    }
    return err // エラー(例えばパーミッションがない)
}
// ファイルに書き込み可能
return nil

参考情報

アトミックなファイル更新

ファイルの書き込みについて補足です。

上記では、ファイルの内容を更新するパターンを紹介しましたが、ファイルの内容を更新途中で電源が落ちたなどの原因によって、中途半端な状態が発生することがあります。これを避けるために、POSIXでは同一ファイルシステムにおいてrenameがアトミックであることを利用して、新しいファイルの内容を別のファイルとして保存して、全て終わったあとで本来のファイル名にリネームする手法が使われます。これを自分で書くのは意外と大変なので、Goならnatefinch/atomicを使えば良いでしょう。

また、最近のOSにはファイルの書き込みバッファが存在するため、バッファを使わない一部の例外を除いて、bufio.Writer.Flushなどでフラッシュしてもファイルには書き込まれていない状態が起こり得ます。これがどういった原理なのかは以下の記事が分かりやすいと思います。

*1:Windowssyscall_windows.goPlan 9const_plan9.go辺り

GitをPlan 9に移植した話

2018年の夏頃から、時間をみつけてはPlan 9にGitを移植していて、概ね動いたので公開する。ソースコードはこの辺りにある。

GitへのPull requestが個人的な平成最後のPull requestだった。

なぜ移植するのか

Plan 9コミュニティの努力によって、PythonMercurialは移植されていたけれど、ここ数年はGitHubでホストされるプロジェクトが多く、Gitが使えないと困るようになってきた。一応、今でもgit-wrapperソースコードのダウンロードは可能だけれど、これはGitHubからzipをダウンロードすることで擬似的に git コマンドを再現しているだけなので、Pull requestを送りたい場合には使えない。

Plan 9でGitを使うためには、公式の git クライアントを移植する方法と、最初から実装する方法の2つあると思う。今回の移植を始める前は、OpenSSLやLibcurlなど、最低限必要なライブラリも全てPlan 9には移植されていなかったし、公式 git クライアントの大部分はCで実装されているので、どうせなら、もっと安全で実装効率の良い言語を使って、必要な部分だけ実装した方がメンテも楽になると思っていた。このアプローチではdgitというGoで再実装しているプロジェクトが存在していて、cloneやmergeなどの基本的なオペレーションは行えるし、今も比較的活発に開発されている。 また、git/fsはCでPlan 9のファイルサーバとして実装しているもので、Plan 9アプリケーションとしてのアプローチは一番正しい。

だけども、独自に実装をしてしまうと、git のアップデートに追従できなくなるんじゃないかという懸念があった。これがGitではなく、RFCみたいな標準仕様があってアップデートも年単位だったなら、Goなどの新しい言語で実装した方がモチベーションも上がるし面倒がないと思う。だけど、Gitは公式のテストケースはあるものの頻繁にアップデートされていくし、それに対してPlan 9開発者は非常に少ないので、公式のコードから離れることは、長期的にみると追従する労力が大きくなって、どこかで限界がくるんじゃないかと思う。だから最善はPlan 9対応のパッチを公式 git クライアントに取り込んでもらうことで、次点ではForkして本家のアップデートに追従しやすくすることじゃないだろうか。今はGitHubがあってパッチも送りやすくなったので、リジェクトされるかもしれないけれど、そんなことで気負いせずに出してみて損はないと思っている。

現在の状況

現在、Plan 9に移植したGitクライアントは、

  • git-add
  • git-commit
  • git-log
  • git-diff
  • git-clone
  • git-push
  • git-fetch

など、よく使うコマンドは動くようになった。本当は git-add -pgit-rebase -i も欲しいけど、こういった一部のサブコマンドはシェルスクリプトPerlで書かれていて、Plan 9のcontrib indexにあるPerlはとても古いのでおそらく動かない。Perlを移植するのはとても大変なので、コードが小さいのであれば、PerlからGoやPythonに書き換えてもいいかなと思う。

Gitを移植する副産物として、opensslcurl コマンドも移植できたし、それなりに動くpthreadも実装できたのは、大変だったけれども結果的には良かった。

Plan 9のローダは先頭から順にシンボルを解決する

半年に1回は8lのエラーでハマっているので書いておこうと思いました。*1

こないだ curlコンパイルしている時に、こういう設定を mkfile に入れました。

# リンクするライブラリのリスト
LIB=\
    /$objtype/lib/ape/libcurl.a\
    /$objtype/lib/ape/libz.a\
    /$objtype/lib/ape/libcrypto.a\
    /$objtype/lib/ape/libssl.a\

それぞれシンボルはアーカイブに含まれているはずなのに、この書き方では、

inflate_stream: undefined: inflate

のようなエラーでリンクできません。依存を順番に解決するような並びにする必要があります。

LIB=\
    /$objtype/lib/ape/libcurl.a\
    /$objtype/lib/ape/libssl.a\
    /$objtype/lib/ape/libcrypto.a\
    /$objtype/lib/ape/libz.a\

なんで順番が違うだけでエラーなんだろうと思ったら、GCCのスタティックリンクの順番は大事で理由が書かれていました。

gccはリンクする際に引数の順番にライブラリを読んでいきそこで見つからない関数(どこか他のライブラリにあるはず)を見つけるとそれを「見つからないテーブル」に登録する。ライブラリを読み込む中でこの見つからないテーブルにある関数が見つかるとそれを解決する。問題は再帰的にやってくれないことで発生する。どうやら速度上の問題でそうなっているらしい。

*1:Plan 9のリンカはローダと呼ぶらしいです

エンジニアとして働く環境に望むこと

なんか最近、給料を上げればエンジニアは逃げないって論調の記事が目について、少し価値観違うなと思ったので気持ちを書いてみた。

過去に、東京一人暮らし、交通費を除いて手取り14万の時があって、働いても貯金がすり減るし昇給が1,000円だったこともあって1年半で辞めた。当時を思い返せば給料は貰えるなら貰えるだけ欲しいけれど、じゃあ当時、4桁の年収があったら続けたかと言われると、たぶん続けていないと思う。

それはなぜかというと。社会人になってからずっとエンジニアとして働いていて、エンジニア以外の職には能力的にも意欲的にも就けないだろうと思っている。なので、エンジニアとしてどこからも雇ってくれなくなるのが長期的には最もリスクで、どんどん入ってくる若い人たちに埋もれないよう学び続けないといけない。もちろん、優秀であるとか特別な何かがあればある程度は安心だろうけれど、少なくとも自分自身が優秀な上位のエンジニアに入るとは思っていないし、プログラミングは基本的に独学なので特殊な技術を持っているわけでもない。だとすると、数年前の常識が非常識になる業界では、知識や経験のアップデートをしていかないとすぐに置いていかれるし、それが続くと、競争力がなくなってエンジニアとしては終わってしまうと感じている。当時は、製品評価とちょっとしたマクロ書く程度の業務で、サーバも数台だったので、おそらくあのままだったら数年後に詰んでいた。例えば40歳50歳になって、マクロや外注管理しか出来なかったら、そんな自分に価値を感じてくれる人は居るだろうか。そして最初に書いたように、エンジニアでなくなったら、本当に何をすればいいか分からない問題がある。

独学でも、インターネットには優秀な人が腐るほどいて、その人たちが生み出す成果物(ソースコード・スライド・ブログ記事など)を見て学ぶことはできるし、自分の能力は全然足りていないなと焦りはするし、置いていかれないように勉強するけれど。だけど自分の中の常識ってなかなか一人では変えられないし、そもそも個人ではできないことも多いので、所属する企業には、自分が成長できる環境であることを期待しているし、そのためのハードルも低くあって欲しいと思っている(例えば新しいことができなかったり、重厚な資料を求められるとしんどい)。他にもいくつか望むことはあるけれど、生活する上で困らない程度の金額が貰えているなら、給与についてはそれほど強い思いはない。

余談だけど、プログラミング必修化には割と好意的で。大学に入ってからプログラミングを知って独学したので、正しく学んだ人に多少のコンプレックスがある。あの大学に入らなければ興味も持たなかったので、後悔はないし納得しているけれど、進路を決める高校一年の頃に必修だったら進路はどうだっただろうかと思うので、進学するモチベーションになるなら良いんじゃないだろうか。

Plan 9のCプリプロセッサを読んだ

Plan 9には cpp(1) というANSIに準拠したCプリプロセッサが用意されていますが、Plan 9標準のCコンパイラコンパイラ自身が簡略化したプリプロセッサを持っているので基本的には使いません。ただし、Cコンパイラが持っているプリプロセッサは設計思想の影響もあり大幅に簡略化されているので、ANSI準拠のプリプロセッサが必要な場合は cpp を使います。8c(1)の場合は -p オプションが渡されれば cpp が使われるようになりますし、APE(ANSI/POSIX Environment)用のCコンパイラpcc(1)は特に何もしなくても cpp が使われます。*1

この cpp#include_next というGCC拡張ディレクティブを追加したくて関連するコードを読みました。

必要な理由

なんで #include_next を追加する必要があったのかというと、いくつかUnix由来のソースコードPlan 9へ移植していた時に、#include_nextが使われているものがありました。この拡張はシステム標準のヘッダファイルから一部を書き換えたい場合に使うことを想定しています。例えば uint32_t 型がシステムによって提供されていない場合、

#include_next <stdint.h>

typedef ulong uint32_t;

という内容を stdint.h というファイル名で #include のサーチパスに入れておくと、ソースコードからは単純に#include <stdint.h>とすればuint32_tが使える状態で読み込まれるという便利なものです。

Plan 9cpp は、実行されない場所に書かれたディレクティブであっても解析はするので、

#if 0
#include_next <stdint.h>
#endif

上記のコードでも #include_next がパースされて、結果として不明なディレクティブなのでエラーになってしまいます。このエラーを簡単に避ける方法がありませんでした。

データ構造

#include のサーチパスは includelist[] によって表現されます。

typedef struct  includelist {
    char    deleted; // 1なら参照しない
    char    always;  // 0の場合は "file.h" のみ対象
    char    *file;   // ディレクトリ名(例: /sys/include)
} Includelist;

#define    NINCLUDE 64
Includelist includelist[NINCLUDE];

デフォルトでは /$objtype/include/sys/include がサーチパスに入っています。また、$include 環境変数がセットされていれば、その内容も含まれます。特にオプションを渡さない場合、以下のような配列になります。

[0] file=/$objtype/include always=1
[1] file=/sys/include always=1
[2] file=$include(1) always=1
[3] file=$include(2) always=1
...
[63] file=. always=0

cpp-I オプションを渡した場合、includelist の末尾に追加されていきます。例えば cpp -I/a/include -I/b/include の場合は以下のようになります。

[0] file=/$objtype/include always=1
[1] file=/sys/include always=1
[2] file=$include(1) always=1
[3] file=$include(2) always=1
...
[61] file=/b/include always=1
[62] file=/a/include always=1
[63] file=. always=0

もう一つ、Source *cursource も重要なデータで、現在処理中のファイルのスタックを表します。

typedef struct source {
    char    *filename;  /* name of file of the source */
    int line;       /* current line number */
    int lineinc;    /* adjustment for \\n lines */
    uchar   *inb;       /* input buffer */
    uchar   *inp;       /* input pointer */
    uchar   *inl;       /* end of input */
    int     ins;        /* input buffer size */
    int fd;     /* input source */
    int ifdepth;    /* conditional nesting in include */
    struct  source *next;   /* stack for #include */
} Source;

Source  *cursource;

これはリンクリストになっていて、現在処理中のファイルが先頭です。

検索

検索する場合は、例えば #include <stdio.h> なら、includelist を後ろから検索していきます*2。この時、deletedが1の場合は常に無視し、alwaysが0の場合は #include <xx> の対象となりません(#include "xx" なら対象)。そうして、stdio.h が見つかったら探索を止めて cursource を更新します*3

#includeがネストした場合は、もう一度 includelist を後ろから検索してファイルを探します。見つかったら cursource のリストが増えて、処理し終われば取り除かれて cursource が以前処理していたファイルに戻ります。

*1:pcc はデフォルトのオプションやサーチパスが異なるだけで、コンパイル自体は 8c で行います

*2:この辺りのコードは /sys/src/cmd/cpp/include.c に書かれています

*3:これは /sys/src/cmd/cpp/lex.csetsourceunsetsource が行います