Plan 9とGo言語のブログ

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

Goアセンブリの書き方

Goアセンブリの書き方からビルド方法までを一通り調べました。Goアセンブリを書いたことのない人がコードを書いてリンクできるところまでは一通り書いているつもりですが、Goアセンブリの言語仕様を網羅してはいないので、興味があれば最後に書いた参考情報も読んでみてください。

この記事ではGo 1.16.xでAMD64命令セットを扱いますが、具体的な命令や値のサイズ以外は、他のアーキテクチャを使う場合でもだいたい同じだと思います。

アセンブリコードの書き方

GoのアセンブリPlan 9アセンブリを概ね踏襲していて、AT&T記法です。整数を受け取って、それに2を加算した値を返す関数func add2(i int32) int32を書いてみましょう。アセンブリのコードは.sファイルに書きます。また、アセンブリアーキテクチャに強く依存するので、Goの習慣にしたがってファイル名にはアーキテクチャ名も入れておきましょう。

func_amd64.s

TEXT ·add2(SB),$0-12
    MOVL i+0(FP),AX    // 引数iをAXレジスタに
    ADDL $2, AX        // 2を加算
    MOVL AX, ret+8(FP) // 計算結果を戻り値として返す
    RET

馴染みのない場合はおそらく何もわからないと思うので、少し丁寧に書きます。

Goのアセンブリでは、関数はTEXTディレクティブで開始します。上の関数は·add2という名前ですが、名前の頭に奇妙な·(middle dot/center dot; 0u00B7)が付いています。これはパッケージ名を区切る文字で、本来はmain.add2となりますが、アセンブリでは.(dot)が名前に使えないので、代わりに·を使っています*1macOSの場合、この文字はOpt+Shift+9で入力できます。Plan 9やplan9portならAlt..と順番にキーを叩けばいいです*2。そしてパッケージ名を省略するとリンク時に補完されるので、最終的にこの命令はmain.add2関数を定義することになります。関数名の後ろについている(SB)ですが、SBはStatic Base擬似レジスタで、プログラムアドレス空間の先頭オフセットを表します。「SBからの特定オフセットにmain.add2という名前をつけて以降の命令を関数ボディとする」が正確な意味らしいですが、関数を定義する場合は必ずこのように書く、と覚えてしまっていいと思います。

これでTEXT ·add2(SB)まで説明しました。その後に$0-12と続いている値は、演算ではなく$<スタックフレームのサイズ>-<引数と戻り値のサイズ>を表します。add2関数は、ローカル変数を持たないのでスタックフレームのサイズは0です。次に引数と戻り値のサイズですが、int32が2つなので8になると考えてしまいますが、GoのABIは戻り値の開始位置はワード長でアラインされるようです。AMD64のワード長は8なので、引数部分が(4+align)で8バイト、戻り値はそのまま4で合計12です。

次に MOVL i+0(FP),AX という行は、MOVLは32bitの数値を左から右へコピーする命令です。MOVxは他にもいくつかあり、それぞれ

  • MOVB(Byte): 8bit
  • MOVW(Word): 16bit
  • MOVL(Long): 32bit
  • MOVQ(Quad): 64bit
  • MOVO(Octo): 128bit

の値を扱います。Goのアセンブラでは、MOVxに限らず他の命令も、上記のような末尾文字で扱う値のサイズを決定します。命令の時点でサイズが決まるため、RAXEAXなどレジスタを使い分ける必要はありませんが、8bitを扱う場合はAHALも使えます。命令の後に続くi+0(FP)という表記は、FP擬似レジスタのオフセット0という意味です。i+の部分はシンボル名で、エラーを検出するため、FPレジスタを参照する場合はシンボル名が必須です。

最後に、コード中に現れる$nは即値です。この$TEXTディレクティブの最後に現れるものとは異なります。$(4+1)のように演算も行えます。

ビルドしてみる

では次に、この関数を使ってみましょう。参照する側は1箇所を除いてよくあるGoのコードです。

main.go

package main

import "fmt"

func add2(i int32) int32

func main() {
    i := add2(20)
    fmt.Println(i)
}

宣言だけのadd2関数がありますが、この宣言により、アセンブリで記述したmain.add2をGoの関数として参照できるようになります。ビルドする方法は普段通りgo buildです。

% go build -o a.out
% ./a.out
22

このとき、関数宣言に書く引数の名前はi+0(FP)の名前と合わせておきましょう。異なった名前にすると、go vetにより

% go vet
# asm
./func_amd64.s:2:1: [amd64] add2: unknown variable i; offset 0 is ix+0(FP)
./func_amd64.s:3:1: [amd64] add2: 8(SP) should be ix+0(FP)

のように怒られます。戻り値が1つの場合はデフォルトのretが使えますが、多値を返す場合は名前をつけましょう。

GoのABI(Application Binary Interface)

ABIとは、関数やシステムコールの呼び出し規約などの総称です。ABIについては以下の記事が分かりやすいなと思いました。

satoru-takeuchi.hatenablog.com

上のコードで見たように、Goとアセンブリの関数はスタックを経由して引数と戻り値を渡します。例えばmain

var i int32

i = 20
i = add2(i)

のようなコードがあった場合、add2を呼び出す前のスタック例(アドレスは適当)は

 ↑メモリの先頭アドレス
 0x120..0x127 [main関数から戻る際のリターンアドレス]
 0x128..0x12c [main関数の変数iが利用しているスタック領域]
 ↓メモリの末尾アドレス

のように積まれていますが、add2を呼び出すと

 ↑メモリの先頭アドレス
+0x100..0x107 [add2関数から戻る際のリターンアドレス]
+0x108..0x10b [add2関数の引数iで利用しているスタック領域]
+0x10c..0x10f [未使用(アラインメント)]
+0x110..0x113 [戻り値を書き込むスタック領域]
+0x114..0x117 [未使用(アラインメント)]
+0x118..0x11f [関数呼び出し前のBPレジスタ値]
 0x120..0x127 [main関数から戻る際のリターンアドレス]
 0x128..0x12c [main関数の変数iが利用しているスタック領域]
 ↓メモリの末尾アドレス

のように利用状況が変わります。このとき、SPレジスタは0x100を指し、FPレジスタは0x108を指します。なのでi+0(FP)とすると最初の引数を参照できますし、上では使っていませんが2つ目の引数があればj+4(FP)などと書けます。

SPレジスタとFPレジスタ

引数や戻り値を扱うとき、上ではFP擬似レジスタを使いましたが、go tool compile -Sgo build -gcflags=-Sで生成したアセンブリコードはSP擬似レジスタを使って引数などを参照します。i+0(FP)i+8(SP)は結局どちらも同じ場所を指すので、どちらを使っても支障はありませんが、FPレジスタの場合はアラインメント間違いなどを検出してエラーにしてくれるので、手書きする場合は基本的にFPレジスタを使う方が良いと思います。

ただし、複雑なのですがi+8(SP)8(SP)は必ずしも同じ場所を指すとは限りません。ハードウェアがSPレジスタを持つ場合、SP擬似レジスタを扱う場合はi+8(SP)のようにシンボル名が必須で、8(SP)のようにシンボル名を省略するとハードウェアレジスタを扱うことになります。

文字列とスライス

Goから引数として文字列またはスライスをアセンブリのコードに渡す場合、文字列の場合はポインタと長さ、スライスの場合はポインタと長さとキャパシティの3つが渡されます。

func str(s string)
func slice(p []byte)

の場合、アセンブリから参照する場合は以下のようになります。

TEXT ·str(SB),$0-16
    MOVQ s+0(FP),AX
    MOVQ s_len+8(FP),CX
    RET

TEXT ·slice(SB),$0-24
    MOVQ p+0(FP),AX
    MOVQ p_len+8(FP),CX
    MOVQ p_cap+16(FP),DX
    RET

独自型を使う場合

独自に定義した構造体などを受け渡しする場合、アセンブリから#include "go_asm.h"すると、それぞれのサイズやオフセットを定数で扱えるようになります。

type Point struct {
    X int
    Y int
}

func addpt1(p Point) Point

func main() {
    fmt.Println(addpt1(Point{X: 1, Y: 2}))
}

この場合、addpt1は以下のように書けます。定数を使うと、途中にフィールドが追加されたり、順番が変わったりしても安心ですね。

#include "go_asm.h"

TEXT ·addpt1(SB),$0-32
    MOVQ p+Point_X(FP),AX
    MOVQ p+Point_Y(FP),CX
    ...

go buildする場合、事前にgo_asm.hを用意する必要はなく、コンパイラが裏で作ってくれます。go_asm.hにどのような値が定義されるのか気になる場合は、以下のコマンドでgo_asm.hを出力できます。

% go tool compile -asmhdr go_asm.h *.go
% cat go_asm.h                                     
// generated by compile -asmhdr from package main

#define Point__size 16
#define Point_X 0
#define Point_Y 8

ここには現れてませんが、constで定義した定数も扱ってくれるようです。

他の関数を呼ぶ

ここまでで、Goの関数からアセンブリのコードを呼ぶ方法を書きました。今度はアセンブリ側からGoの関数を呼ぶ場合の手順です。結局は、呼び出す側で

 ↑メモリの先頭アドレス
+0x100..0x107 [add2関数から戻る際のリターンアドレス]
+0x108..0x10b [add2関数の引数iで利用しているスタック領域]
+0x10c..0x10f [未使用(アラインメント)]
+0x110..0x113 [戻り値を書き込むスタック領域]
+0x114..0x117 [未使用(アラインメント)]
+0x118..0x11f [関数呼び出し前のBPレジスタ値]
 0x120..0x127 [main関数から戻る際のリターンアドレス]
 0x128..0x12c [main関数の変数iが利用しているスタック領域]
 ↓メモリの末尾アドレス

のようなメモリレイアウトを作る必要があります。例として、

func neg(i int32) int32 {
    return -i
}

をadd2から呼んで、2つ目の戻り値としてその結果を返すコードを書いてみましょう。Go側のプロトタイプは

func add2(i int32) (ret1 int32, ret2 int32)

に変更します。FPから戻り値のオフセットを参照するため、名前をつけているところにも気をつけてください。以下アセンブリのコードです。Goアセンブリでは、PUSH命令やPOP命令を使わずSPを直接操作します。

TEXT ·add2(SB),$24-16
    // 1つ目の値は引数に+2するだけ、今までと同じ
    MOVL i+0(FP), AX
    ADDL $2, AX
    MOVL AX, ret1+8(FP)

    SUBQ $24, SP    // neg関数の引数と戻り値サイズ+BPレジスタの退避先を確保
    MOVQ BP, 16(SP) // 現在のBPレジスタをpush
    LEAQ 16(SP), BP // BPレジスタを新しいスタックに更新
    MOVQ AX, (SP)   // 最初の引数iを渡す
    CALL ·neg(SB)   // main.negを呼ぶ
    MOVL 8(SP), AX  // main.negの戻り値をAXレジスタに取り出す
    MOVQ 16(SP), BP // 退避していたBPレジスタをpop
    ADDQ $24, SP    // スタックサイズを戻す
    MOVL AX, ret2+12(FP) // 2番目の戻り値として返す
    RET

ところで、気づいた人もいるかもしれませんが、CALL命令を実行する前はSPを24バイト減算していて、0(SP)が最初の引数になっています。しかしmain.neg8(SP)が最初の引数であることを期待します。この差は何なのかというと、CALL命令が暗黙的に、SPレジスタへリターンアドレスをpushしていることによるものです。RETで戻ると、リターンアドレス分がpopされて、SPレジスタCALLする前の値へ戻ります。

また、他に注意した方が良い話題は、AMD64アーキテクチャの場合BPレジスタの値に連動してFP疑似レジスタも変わります。そのため、ADDQ $24, SPでスタック位置を戻した後で戻り値をメモリに書き出す必要があります。逆にしてしまうと、意図しないメモリを更新することになります。

ABI0とABIInternal

これまで、Goとアセンブリのコードがどのように値を交換するのかをみてきました。基本的にスタックを経由してそれを行いますが、これはABI0というルールに則ります。

Go 1.16時点では、利用できるABIはABI0しかありません*3が、以下のプロポーザルによるとレジスタを使った値渡しのABIも検討されているようです。これが安定すれば、ABI1として利用可能になるかもしれません。

ABIの調べ方

上記以外に、errorinterface{}など色々な型を渡したくなると思います。またはポインタをGCで管理したくなるかもしれません*4。その場合、go tool compile -Sまたはgo build -gcflags=-Sを使うと、コンパイルした結果を出力してくれるので、そうやって出力されたアセンブリのコードを眺めると良いかもしれません。ただし、最適化によって関数がインライン化される場合もあるので、//go:noinlineコメントで展開をしないようにコメントしておくといいでしょう。

% cat main.go
package main

import (
    "fmt"
    "io"
)

//go:noinline
func isEOF(err error) bool {
    return err == io.EOF
}

func main() {
    fmt.Println(isEOF(fmt.Errorf("error")))
}

実行結果の例です。長いので最初数行だけ。

% go tool compile -S main.go
"".isEOF STEXT size=105 args=0x18 locals=0x28 funcid=0x0
    0x0000 00000 (fn.go:9)  TEXT "".isEOF(SB), ABIInternal, $40-24
    0x0000 00000 (fn.go:9)  MOVQ (TLS), CX
    0x0009 00009 (fn.go:9)  CMPQ SP, 16(CX)
...

または、go tool objdump.oや実行ファイルからアセンブリコードを出力できますが、特に実行ファイルをgo tool objdumpした場合はとても長くなるので、目的の行を探すのは少し面倒かもしれません。

NOSPLITディレクティブ

ところで、標準パッケージのコードを見ると、アセンブリで書かれた関数のほとんどでNOSPLITディレクティブ(フラグ)をセットしています。

#include "textflag.h"

TEXT ·func(SB),NOSPLIT,$0
    ...

このNOSPLITruntime/textflag.h#defineされている値です。A Quick Guide to Go's Assemblerで、利用できるディレクティブが列挙されています。

#define NOSPLIT 4

通常、ゴルーチンは固有のスタックを持っていて、その初期サイズは決まっています*5。関数を呼び出したとき、コンパイラは「SPレジスタのアドレスと現在のスタック上限を比べて必要なら拡大する」処理を埋め込みますが、NOSPLITはこの動作を抑制するものです。実際に埋め込まれるコードは、この記事の最後に参考情報として挙げたGoアセンブリ入門によると、

again:
    MOVQ    (TLS), CX
    CMPQ    SP, 16(CX)
    JLS morestack // JBEと同じ意味
    ...
morestack:
    CALL    runtime.morestack_noctxt(SB)
    JMP again

といったコードが挿入されるそうです。また、$GOROOT/src/runtime/stack.goのコメントでは、スタックの大きさによって3通りに分岐することが書かれていました。該当部分を引用します。

guard = g->stackguard
frame = function's stack frame size
argsize = size of function arguments (call + return)

stack frame size <= StackSmall:
    CMPQ guard, SP
    JHI 3(PC)
    MOVQ m->morearg, $(argsize << 32)
    CALL morestack(SB)

stack frame size > StackSmall but < StackBig
    LEAQ (frame-StackSmall)(SP), R0
    CMPQ guard, R0
    JHI 3(PC)
    MOVQ m->morearg, $(argsize << 32)
    CALL morestack(SB)

stack frame size >= StackBig:
    MOVQ m->morearg, $((argsize << 32) | frame)
    CALL morestack(SB)

では次に、どういった場合にアセンブリで書いた関数へNOSPLITディレクティブ(フラグ)をセットすると良いのでしょうか。個人的には、正しい基準はあまり分かっていません。一切スタックを使わずCALLもしない関数を除いて、基本的にはNOSPLITを付与しないほうが安全に思えます。とはいえ公式のコードはほとんど全てNOSPLITを与えているし、$GOROOT/src/cmd/internal/obj/x86stacksplitprocessesを読むと、

// この定数は、実際は$GOROOT/src/cmd/internal/objabiで定義されている
const (
    StackSmall = 128
    StackBig = 4096
)

// これは擬似コードです
func processes(ctx, cursym, newprog) {
    p := cursym.Func().Text
    autooffset := p.To.Offset
    ...
    if autooffset < objabi.StackSmall && !p.From.Sym.NoSplit() {
        leaf := true
        if [CALL命令で引数が1つ以上ある関数を呼んでいる] {
            leaf = false
        }
        if [DUFFCOPY, DUFFZEROが使われている && autooffset >= objabi.StackSmall-8] {
            leaf = false
        }
        if leaf {
            p.From.Sym.Set(obj.AttrNoSplit, true)
        }
    }
    ...
}

のようにNOSPLITを付与しているので、利用するスタックサイズが小さくCALL命令も使わない関数はNOSPLITを与えておくと良いかもしれません。

(雑談)go buildを使わずビルドする

基本的にはgo buildを使うだけで十分なんですが、裏で何が行われているのかを知っておくと便利なこともあるかもしれないので紹介します。

まず、go_asm.hがなければ定数が扱えないので、Goのソースコードからgo_asm.hを生成する必要があります。これはgo tool compileで行います。

% go tool compile -asmhdr go_asm.h *.go

生成したら、アセンブリで書いたコードをビルドしましょう。.sに対応する.oファイルが作られたら正常です。go tool nmコマンドで、.oに定義されたシンボルや未解決のシンボルなどを調べられます。

% go tool asm -p main func_amd64.s
% go tool nm func_amd64.o

go tool nmが出力する2番目のフィールドはシンボルのタイプです。

  • T テキストシンボル
  • U 未解決のシンボル

などいくつかあり、それらはgo doc cmd/nmにまとめられています。

次に、アセンブリのコードがどのABIを利用しているか、をファイルとして用意しておく必要があります。今は手書きするコードなら全てABI0ですが、上で触れたように新しいABIが追加されるかもしれません。これはgo tool asmで行います。

% go tool asm -gensymabis -o symabis *.s
% cat symabis
def "".add2 ABI0

上で作ったsymabisgo tool compileに渡します。これを渡しておかないと、

main.main: relocation target main.add2 not defined for ABIInternal (but is defined for ABI0)

のようなエラーでgo tool linkが失敗します。

% go tool compile -symabis symabis -p main main.go

上で作ったオブジェクトファイル(.oファイル)を.aファイルにまとめます。cは新しくアーカイブファイルを作るというオプションです。

% go tool pack c main.a *.o

最後にリンクして終わり。

% go tool link main.a

一通りの手順をMakefileに書くとこのようになります。-Pオプションでパッケージ名を与えてますが、どちらでも良いと思います。

PKG=main
TARG=a.out

.PHONY: all
all: $(TARG)

$(TARG): main.a
  go tool link main.a

main.a: main.o func_amd64.o
  go tool pack c $@ $^

main.o: main.go symabis
  go tool compile -symabis symabis -p $(PKG) $<

func_amd64.o: func_amd64.s go_asm.h
  go tool asm -p $(PKG) $<

func_amd64.s: go_asm.h

symabis: *.s
  go tool asm -gensymabis -o $@ $^

go_asm.h: *.go
  go tool compile -asmhdr $@ $^

.PHONY: clean
clean:
  rm -f *.o *.a

.PHONY: nuke
nuke:
  rm -f *.o *.a $(TARG) symabis go_asm.h

最後に、ここで書いたgo toolコマンドで生成するオブジェクトは完全にgo buildと同じではありません。go buildの場合は同一パッケージに複数のfunc init()があってもビルドが通るように調整などがされるようなので、基本はgo buildを使うと良いでしょう。

おわり。

参考情報

物理本ですが、The Plan9 Assembler Handbookも参考になります。GoのアセンブラPlan 9のものを下地にしているので、大部分はそのまま役に立ちます。意外とインターネットにはまとまった情報がないので、個人的にはとてもおすすめ。

*1:同様に/も使えないので(division slash; 0u2215)で代用します

*2:lib/keyboardにリストがある

*3:runtimeパッケージなどではABIInternalもある

*4:FUNCDATAPCDATAを使うようです

*5:AMD64の場合は4KBらしいですね

Google Cloud Client Library for Goでのリトライとエラー

Google Cloud Client Library for Goはデフォルトでリトライするので、あまり意識する必要はないと思いますが、場合によってはリトライを細かく制御したくなることはあるかもしれません。この記事では、リトライのために必要そうなオプションをまとめました。

リトライ

クライアントライブラリで実装された一部のAPI、例えばmonitoring.MetricClient.ListTimeSeriesなど、可変長引数でgax.CallOptionを取るものがあります。

import (
    "context"

    gax "github.com/googleapis/gax-go/v2"
    monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3"
)

func (c *MetricClient) ListTimeSeries(ctx context.Context, req *monitoringpb.ListTimeSeriesRequest, opts ...gax.CallOption) *TimeSeriesIterator

これらの関数はgax.CallOptionとしてリトライオプションを持っていて、gax.WithRetryRetryerを渡すことでオプションを生成します。Functional Optionパターンな実装になっていますね。

package gax

import "time"

type CallSettings struct {
    // Retry returns a Retryer to be used to control retry logic of a method call.
    // If Retry is nil or the returned Retryer is nil, the call will not be retried.
    Retry func() Retryer

    // CallOptions to be forwarded to GRPC.
    GRPC []grpc.CallOption
}

type CallOption interface {
    Resolve(cs *CallSettings)
}

type Retryer interface {
    Retry(err error) (pause time.Duration, shouldRetry bool)
}

func WithRetry(fn func() Retryer) CallOption

Retryerを自分で全部実装するのは意外と面倒なんですが、指数バックオフを行うRetryerはクライアントライブラリが用意してくれているので、これを使うといいでしょう。

import (
    "time"

    gax "github.com/googleapis/gax-go/v2"
    "google.golang.org/grpc/codes"
)

var RetryableCodes = []codes.Code{
    codes.Canceled,
    codes.Unknown,
    codes.DeadlineExceeded,
    codes.ResourceExhausted,
    codes.Aborted,
    codes.Internal,
    codes.Unavailable,
    codes.DataLoss,
}

func DefaultRetryOption() gax.Retryer {
    // This configuration performs to retry 3 times; 200ms, 400ms, 800ms
    return gax.OnCodes(RetryableCodes, gax.Backoff{
        Initial:    200 * time.Millisecond,
        Max:        1 * time.Second,
        Multiplier: 2.0,
    })
}

c.ListTimeSeries(ctx, &monitoringpb.ListTimeSeriesRequest{...}, gax.WithRetry(DefaultRetryOption))

ところで、上に引用したCallSettingsのコメントでは、Retrynilの場合はリトライしないと書いていますが、monitoring.MetricClientなどクライアントライブラリで実装されたクライアントは、デフォルトでリトライオプションを持っているので、デフォルトで支障がなければオプションを渡す必要はありません。具体的には、上で例に挙げたListTimeSeriesのデフォルトは以下のような内容です。

import (
    "time"

    gax "github.com/googleapis/gax-go/v2"
    "google.golang.org/grpc/codes"
)

gax.WithRetry(func() gax.Retryer {
    return gax.OnCodes([]codes.Code{
        codes.DeadlineExceeded,
        codes.Unavailable,
    }, gax.Backoff{
        Initial:    100 * time.Millisecond,
        Max:        30000 * time.Millisecond,
        Multiplier: 1.30,
    })
})

このデフォルト値は、それぞれのクライアント構造体メンバー変数に持っています。以下はmonitoring.MetricClientの例ですが、他のクライアントもだいたい同じ作りになっているようにみえます。

type MetricClient struct {
    CallOptions *MetricCallOptions
}

type MetricCallOptions struct {
    ListMonitoredResourceDescriptors []gax.CallOption
    GetMonitoredResourceDescriptor   []gax.CallOption
    ListMetricDescriptors            []gax.CallOption
    GetMetricDescriptor              []gax.CallOption
    CreateMetricDescriptor           []gax.CallOption
    DeleteMetricDescriptor           []gax.CallOption
    ListTimeSeries                   []gax.CallOption
    CreateTimeSeries                 []gax.CallOption
}

エラーの詳細を取得する

Retryerでリトライしても解決しない場合、エラーの内容によって、メッセージキューに戻すか破棄するか、など判断したいことがあるかもしれません。その場合、クライアントライブラリはほとんどの場合にgRPCのエラーを返すので、status.FromErrorなどを使うとgRPCのステータスコードなど詳細を調べられます。

import "google.golang.org/grpc/status"

s, ok := status.FromError(err) // status.Convertでも良い
if !ok {
    return
}
code := s.Code()

ただし、アカウントの認証に失敗したとか、APIが無効になっているなどgRPCへ到達する前のエラーが発生することもありますが、こういったエラーではstatus.FromErrorを使っても詳細を取得できません。その場合はgoogleapi.Errorを使うと、HTTP/1.1ステータスコードなどの詳細な情報を確認できます。

import (
    "errors"

    "google.golang.org/api/googleapi"
)

var e *googleapi.Error
if errors.As(err, &e) {
    return e.Code
}

codes.Codeのコメントによると、gRPCのエラーコードはHTTP/1.1のステータスコードに置き換え可能なので、まとめて扱えるようにしておくと便利かもしれませんね。

import (
    "errors"
    "net/http"

    "google.golang.org/api/googleapi"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
var codeMappings = map[codes.Code]int{
    codes.OK:                 http.StatusOK,
    codes.Canceled:           499, // Go 1.16のnet/httpには定数がない
    codes.Unknown:            http.StatusInternalServerError,
    codes.InvalidArgument:    http.StatusBadRequest,
    codes.DeadlineExceeded:   http.StatusGatewayTimeout,
    codes.NotFound:           http.StatusNotFound,
    codes.AlreadyExists:      http.StatusConflict,
    codes.PermissionDenied:   http.StatusForbidden,
    codes.ResourceExhausted:  http.StatusTooManyRequests,
    codes.FailedPrecondition: http.StatusBadRequest,
    codes.Aborted:            http.StatusConflict,
    codes.OutOfRange:         http.StatusBadRequest,
    codes.Unimplemented:      http.StatusNotImplemented,
    codes.Internal:           http.StatusInternalServerError,
    codes.Unavailable:        http.StatusServiceUnavailable,
    codes.DataLoss:           http.StatusInternalServerError,
    codes.Unauthenticated:    http.StatusUnauthorized,
}

// Code returns HTTP/1.1 Status Code.
func Code(err error) int {
    if err == nil {
        return http.StatusOK
    }
    if v := errors.Unwrap(err); v != nil {
        err = v
    }
    var e *googleapi.Error
    if errors.As(err, &e) {
        return e.Code
    }
    s, ok := status.FromError(err)
    if !ok {
        return http.StatusInternalServerError
    }
    c, ok := codeMappings[s.Code()]
    if !ok {
        return http.StatusInternalServerError
    }
    return c
}

// IsPermissionError returns true if err is an error categorised of permission denied.
func IsPermissionError(err error) bool {
    switch Code(err) {
    case http.StatusUnauthorized, http.StatusForbidden:
        return true
    default:
        return false
    }
}

他にも何か気づいたら追記します。おわり。

Plan 9におけるTLSの実装

Plan 9(9legacy)は、安定版のパッチを当ててもTLS_RSA_WITH_AES_128_CBC_SHA256までしか対応してなく、そろそろ古くなってきています。9frontはもっと強い暗号スイートに対応しているので、必要なものを移植しようと思いました。とはいえTLSについて詳しくないので、何がどう関連しているのかを中心に既存実装を読んだメモです。

TLSの概要

TLSは、以下の要素(暗号スイート)で構成されている。

  • 鍵交換(暗号化で利用する共通鍵の交換方法)
  • 認証(なりすまし防止)
    • これで証明書に含まれる公開鍵の種類が決まる
  • 暗号化
  • メッセージ認証(改ざん防止、ハッシュ)

RFC 5246 - TLS Protocol Version 1.2では、これらを一つの文字列に連結して、例えばTLS_RSA_WITH_AES_128_CBC_SHA256のようなIDとして表現している。このIDはTLS 1.2の場合、

// TLS 1.2
TLS_[鍵交換]_[認証]_WITH_[共通鍵暗号]_[メッセージ認証]

のように、それぞれの位置に対応するアルゴリズムを当てはめる。このとき鍵交換認証がどちらもRSAの場合はTLS_RSA_WITH_とまとめられるようだった。この記事ではそこまで触れないが、TLS 1.3の場合は大幅に簡素化されて

// TLS 1.3
TLS_[AEAD]_[HASH]

となった。AEADは雑にいうと共通鍵暗号メッセージ認証を同時に行うアルゴリズムで、AES_128_GCMなどがある。そのためTLS 1.3では、具体的にはTLS_AES_128_GCM_SHA256のようなIDとなる。

TLSはまた、複数のプロトコル層を持っている。

TLS一般的な話はこの記事が分かりやすかった。

Plan 9での実際

Plan 9では、複数のコンポーネントが関わってTLSを実現している。具体的には以下の3つ。

まずは簡単にTLS接続の流れを追う。

事前準備

サーバは、サーバ証明書ファイルシステム(例えば/sys/lib/tls/cert.pem)に保存しておき、対応する秘密鍵をホストオーナー(普通はbootes)のfactotumにロードしておく。factotumのデータはホストの再起動によって揮発するので、一般的には、再起動時にsecstoreから読み込むように構成する。

% auth/secstore -G factotum
% cat /mnt/factotum/ctl
key proto=rsa service=tls !p=... !q=...

鍵交換とシークレット生成

TLS接続を開始するとき、クライアントでもサーバでも、libsecの関数を使う。自身がサーバとなる場合はtlsServerを使い、クライアントとなる場合はtlsClientを使う。

#include <mp.h>
#include <libsec.h>

/* fdはTLS通信相手とのコネクション */
int            tlsClient(int fd, TLSconn *conn);
int            tlsServer(int fd, TLSconn *conn);

上記どちらの関数も、渡したfdの内容をTLSで包んだ新しいファイルディスクリプタを返す。関数から返されたファイルディスクリプタは、戻った時点で暗号通信を開始した状態なので、呼び出した側はそのままTLSに乗せるプロトコル*1を喋れば良い。

tlsClienttlsServerがやっていることをもう少し追っていくと、サーバでもクライアントでも、渡されたfdを介したTLSハンドシェイクで暗号スイートの決定と、それに従った鍵交換を行う。また、サーバなら「事前準備」で用意しておいた秘密鍵をfactotumから取り出してシークレットの生成に利用する。これで、暗号スイートのうち鍵交換認証は完了していて、暗号とメッセージ認証のアルゴリズムも決まっていて、暗号化で使うシークレットも用意できたことになる。

次に、tlsClientまたはtlsServer関数はカーネルに処理を引き渡す。

カーネルの役割

Plan 9では、TLSレコードプロトコルカーネルで実装している。上記で暗号に必要な値は全て決まったので、tlsClientまたはtlsServerカーネルが提供するファイルに必要な値を書き込む。実際はCで書かれているが、擬似的には以下のような処理を行う。

n=`{cat '#a'/tls/clone}
echo fd $fd >'#a'/tls/$n/ctl
echo version $protoVersion >'#a'/tls/$n/ctl
echo secret aes_128_cbc sha256 $isclient $secret >'#a'/tls/$n/ctl
# '#a'/tls/encalgsと'#a'/tls/hashalgsを読むと利用可能な暗号関数、ハッシュ関数がわかる

cat '#a'/tls/$n/hand  # TLSハンドシェイクプロトコルする場合はこのファイルを読み書きする
cat '#a'/tls/$n/data  # TLSレコードプロトコルする場合はこのファイルを読み書きする

カーネルに実装されたdevtlsドライバは、最初は暗号化しないが、ctlファイルにシークレットが書き込まれた後は全ての通過するデータを暗号化または複合する。

強い暗号に対応するには

最初に書いたように、9legacyでサポートされている暗号スイートは、まだ禁止されてはいないものの推奨されなくなっている。なのでこのままではまずいのだが、どこを改善すると良いのか。暗号に関わるもののデバッグは困難なので、まずは簡単なところから対応すると良いのだろう。最初はTLS_RSA_WITH_AES_128_GCM_SHA256AES_128_GCMを確認して、次にTLS_DHE_RSA_WITH_AES_128_GCM_SHA256DHEに対応する方針が妥当に思える。意外とTLS_DHE_RSA_WITH_AES_256_GCM_SHA256の組み合わせは存在しなかった。

鍵交換

DHEまたはECDHETLS 1.3でも認められているので、この辺りなら良いだろうと思う。鍵交換はlibsecの鍵交換処理に追加すれば良い。やればいいだけなんだけれど、DHEAES_128_CBCの組み合わせはなさそうだったので、先にAES_128_GCM対応が必要だと思う。

認証(証明書)

これは、factotumに新しくprotoを追加する必要があって、そうすると仮に9legacyへパッチを送ってもマージされるかどうかわからない。なので、やるとしても最後にやる。

AEAD

AEADは認証付き暗号と呼ばれるもので、AES_128_GCMもその一つ。

TLS 1.2プロトコル Appendixによると、以前までのTLSでは、ストリーム暗号とブロック暗号が考慮されていたところに、新しくAEAD暗号が追加されたらしい。

TLS 1.2のレコード層では、

struct ProtocolVersion {
    uint8 major;
    uint8 minor;
};

enum {
    ChangeCipherSpec = 20,
    Alert = 21,
    Handshake = 22,
    ApplicationData = 23,
} ContentType;

/* 最初の平文 */
struct TLSPlaintext {
    ContentType type;
    ProtocolVersion version;
    uint16 length;
    uchar opaque[];
};

/* 暗号テキストは3つに分岐 */
struct GenericStreamCipher {
    /* あまり重要ではないので省略 */
};
struct GenericBlockCipher {
    uchar iv[];      /* データの長さはアルゴリズムによって決まる */
    uchar content[]; /* データの長さはTLSCiphertext.length */
    uchar mac[];     /* データの長さはアルゴリズムによって決まる */
    uint8 padding[];
    uint8 padding_length;
};
struct GenericAEADCipher {
    uchar nonce[];    /* データの長さはアルゴリズムによって決まる */
    uchar content[]; /* データの長さはTLSCiphertext.length */
};
struct TLSCiphertext {
    ContentType type;
    ProtocolVersion version;
    uint16 length;
    union {
        GenericStreamCipher stream;
        GenericBlockCipher block;
        GenericAEADCipher aead;
    };
};

のように分岐していて、これまでのブロック暗号では、暗号化とメッセージ認証を分けて計算していたが、分けて計算することによる問題があるらしく*2、AEADでは暗号化と同時に認証タグと呼ばれる値も生成する。この認証タグを、複合時にも使うものらしい。

ブロック暗号の場合

AEADと比べるために、最初にブロック暗号をみる。ブロック暗号で暗号化する場合、カーネルは例えば以下のようにデータを暗号化する。ここでは、暗号化はaes128_cbcで、メッセージ認証はsha256を使うと仮定する。

TLSCiphertext b;

/* TLS 1.2 */
b.type = ApplicationData;
b.version.major = 3;
b.version.minor = 3;
b.length = len(body);
b.block.iv = (最初は空);
b.block.content = body;
b.block.mac = (最初は空);
/* 面倒なのでパディングは省略 */

/* 未暗号化(ivとmacは空)の状態でハッシュ値を計算する */
b.block.mac = hmac_sha256(64bitシーケンス番号 + b);
b.length += len(b.block.mac);

/* 暗号化 */
b.block.iv = (乱数生成); /* 長さは暗号関数に依存する; 例えばAESは16バイト */
b.length += len(b.block.iv);
b.length = aes128_cbc(&b.block, b.length);

これで、最終的にb.blockは暗号化されて、b.lengthは暗号化されたb.blockの長さを持つ。ここで重要なのは平文をSHA256したハッシュ値を平文の末尾に加えて、それを暗号化しているところで、AEADの場合はここが異なる。

AEAD(認証付き暗号)の場合

ここではAES_128_GCMを使うと仮定して具体的な動きをみる。

まず前提として、AES128-GCMでは

  • 平文
  • 初期化ベクトル(IV)
  • 追加データ(aad)
    • 認証には利用されるが暗号化はしないデータ

を与えると、

  • 暗号文
  • 認証タグ

を返す。Plan 9(9front)の場合は以下の関数プロトタイプを持つ。

#include <mp.h>
#include <libsec.h>

void   setupAESGCMstate(AESGCMstate *s, uchar *key, int keylen, uchar *iv, int ivlen);
void   aesgcm_setiv(AESGCMstate *s, uchar *iv, int ivlen);
void   aesgcm_encrypt(uchar *p, ulong n, uchar *aad, ulong naad, uchar tag[16], AESGCMstate *s);
int    aesgcm_decrypt(uchar *p, ulong n, uchar *aad, ulong naad, uchar tag[16], AESGCMstate *s);

もう少し具体的なコードでみると、

TLSCiphertext b;

/* TLS 1.2 */
b.type = ApplicationData;
b.version.major = 3;
b.version.minor = 3;
b.length = len(body);

aad = 64bitシーケンス番号 + b;   /* [seq:8][type:1][major:1][minor:1][len:2]で13byte */
iv[4:12] ^= aad[0:8];       /* IVの上位4バイトはそのまま残して、後ろ8バイトをNonceで埋める */
aesgcm_setiv(state, iv, len(iv));
aesgcm_encrypt(body, len(body), aad, len(aad), &tag, state);
b.nonce = iv[4:12];           /* 暗号化に使ったivの末尾8バイトをメッセージに含める */
b.content = body + tag;     /* 暗号化したbodyの後ろに認証タグを加える */
b.length = len(b.nonce) + len(body) + len(tag);

これをブロックごとに計算する。AEADに関する記事はこの辺りが面白い。

メッセージ認証の意味

ところで、上記でみたようにAEAD暗号ではメッセージ認証を計算しなくなっているのがわかる。なのでPlan 9(9front)のdevtlsでは、aes_128_gcm_aeadで暗号化する場合のハッシュ関数clearを使うようになっている。だけどもTLSの暗号スイートには依然としてTLS_RSA_WITH_AES_128_GCM_SHA256のようにSHA256という名前が残っているが、これはどこで使っているのか。

RFC 5288 - AES Galois Counter Mode (GCM) Cipher Suites for TLSによると、TLS 1.2の場合はPRF(Pseudo Random Function)でだけ使うと書かれていた。なので鍵交換が終わってしまえば、それ以降使われることはないらしい。

TLS 1.3の場合は、HMACベース鍵導出関数(hkdf)に使うらしいが詳しくは調べていない。

*1:例えばHTTPSの場合はHTTP

*2:詳しくないので詳細はわからないけれども

go getだけでコマンドのバージョンを埋め込む

久しぶりのGoネタです。Go 5 Advent Calendar 2020の18日目が空いていたので書きました。

Goで実装されたコマンドでは、ビルドした時点のバージョンを埋め込むため以下のようなMakefileを用意することがあると思います。

.PHONY: build
build:
  go build -ldflags '-X main.Version=$(VERSION)'

しかしこの方法では、go getなどMakefileを経由せずビルドしたバイナリには適切なバージョンが埋め込まれない問題があります。個人的な意見では、可能な限りgo getでインストールできる状態を維持した方が良いと思っていますが、バージョンを埋め込むためには他に方法がないので仕方がないと理解していました。しかしGo 1.15現在、runtime/debug.ReadBuildInfoを使うと、Goモジュールで管理される場合においてはビルド時のバージョンを取れるようになっていて、これを使えば自前でバージョンを埋め込む必要がなくなるので、go getだけで適切なバージョンを表示させることができます。

ところで、Go1.16からの go get と go install についてで書かれているように、Go 1.16からgo getの挙動が変わってしまいます。この記事を書いている時点ではまだGo 1.15なのでgo get表記を使っていますが、おそらく記事中のgo getgo installに読み替えると、1.16以降もそのまま使えるのではないかと思います。

バージョンの取得

バージョンを取得したい箇所で、以下のように書きましょう。

import (
    "runtime/debug"
)

func Version() string {
    info, ok := debug.ReadBuildInfo()
    if !ok {
        // Goモジュールが無効など
        return "(devel)"
    }
    return info.Main.Version
}

これで、v1.0.0などのような、Goモジュールが認識しているバージョン文字列を取得できます。簡単ですね。

runtime/debug.BuildInfo構造体

Go 1.15時点の定義は以下のようになっていて、mainパッケージのバージョン以外にも、コマンドが依存しているモジュールのimportパスやバージョンも取得できます。ただし、runtime/debug.BuildInfoに含まれるモジュールはコマンドをビルドするために必要なものだけです。例えばテストでのみ参照するモジュールは含まれません。

type BuildInfo struct {
    Path string    // The main package path
    Main Module    // The module containing the main package
    Deps []*Module // Module dependencies
}

type Module struct {
    Path    string  // module path
    Version string  // module version
    Sum     string  // checksum
    Replace *Module // replaced by this module
}

どのように動くのか

この方法を導入した場合に、バージョン表記はどうなるのかをいくつかのパターンで見ていきましょう。

普通にgo getした場合

コマンドのimportパスをそのままgo getした場合ですね。この場合は、Goモジュールが認識する最新のバージョンが使われます。

% go get github.com/lufia/go-version-example
go: downloading github.com/lufia/go-version-example v0.0.2
go: github.com/lufia/go-version-example upgrade => v0.0.2
go: downloading github.com/pkg/errors v0.9.1

% go-version-example 
Main = v0.0.2
Deps[github.com/pkg/errors] = v0.9.1

古いバージョンを指定した場合

この場合も、正しくバージョンを取得できます。

% go get github.com/lufia/go-version-example@v0.0.1
go get github.com/lufia/go-version-example@v0.0.1
go: downloading github.com/lufia/go-version-example v0.0.1

% go-version-example 
Main = v0.0.1

ブランチ名などを明記した場合

正規のバージョンよりmainブランチが進んでいる場合に、@mainを明記してgo getした場合はバージョンの後ろに時刻とハッシュ値が付与されます。

% go get github.com/lufia/go-version-example@main
go: downloading github.com/lufia/go-version-example v0.0.2-0.20201217055003-ddf71bc3f52c
go: github.com/lufia/go-version-example main => v0.0.2-0.20201217055003-ddf71bc3f52c

% go-version-example 
Main = v0.0.2-0.20201217055003-ddf71bc3f52c
Deps[github.com/pkg/errors] = v0.9.1

カレントディレクトリがmainモジュールと同じ場合

少し分かりづらいのですが、カレントディレクトリがGoモジュール管理下にあり、そのimportパスとgo getするimportパスが同一の場合。例えば、

// go.mod
module github.com/lufia/go-version-example

を持つリポジトリの中で

% go get github.com/lufia/go-version-example

としてコマンドをビルドした場合、このときは手元でどのようなタグ付けがされていたとしても、mainのバージョンは常に(devel)という文字列が取得されます。Makefileと併用しようとすると手元にはソースコードが一式あるはずなので、この挙動は少し厄介ですね*1

% cd $GOPATH/src/github.com/lufia/go-version-example

% go get github.com/lufia/go-version-example

% go-version-example 
Main = (devel)
Deps[github.com/pkg/errors] = v0.9.1

どうやっているのか

2020-12-18 16:30: 対応フォーマットの話はgo version -mの話だったので書き換えました。

runtime/debug.ReadBuildInfoは何を読んでいるのか、ですが、ビルド時にruntime.modinfoへモジュール情報が埋め込まれていて、これをruntime/debug.BuildInfoへパースしているようですね。雰囲気で読んだ感じだと、埋め込んでいるのはgo/src/cmd/go/internal/work/exec.gobuild()辺りかな。

runtime.modinfoとだいたい同じ値がgo version -mで調べることができます。こっちはGo 1.13以降で対応されました。

% go version -m ~/bin/ivy
/Users/lufia/bin/ivy: go1.14.6
    path    robpike.io/ivy
    mod robpike.io/ivy  v0.0.0-20191204195242-5feaa23cbcf3 h1:tff9UcwX5PKD7Z+Q7O9EIILnQVm5uRY0/xP90+oXtog=

go versionが読む情報は、例えばELFフォーマットのバイナリでは.go.buildinfoという名前のセクションに64KBのデータとして存在します。Mach-Oの場合はセクション名が__go_buildinfoであったりなど若干の差はありますが、どれも同じデータが埋め込まれています。これらの詳細は、go/src/cmd/go/internal/version以下のソースコードを眺めると良いでしょう。

まとめ

runtime/debug.ReadBuildInfoでコマンドに埋め込まれたバージョンを取得できることを紹介しました。多くの場合は、これを使うとgo getだけでも適切なバージョンが取得できると思います。個人的にはgo getでコマンドを導入することが多くあるので、Makefileに依存しないこの方法が広まってくれると嬉しいのですが、Makefileでクロスコンパイルしている場合は容易に(devel)表記となってしまうところは、少し注意が必要かもしれませんね。

*1:一時的にcdでカレントディレクトリを変更すれば回避は可能ですが

dfコマンドはどこからファイルシステムの統計を取得するのか

CPUやメモリの統計は/proc以下のファイルを見れば調べられますが、ファイルシステムの容量などはどうやって取得しているんだろうと気になったでdf(1)のコードを眺めてみました。

ライブラリの動作検証で用意したコードはこちら。

Linuxの場合

Linuxでは、ファイルシステムの情報はstatvfs(3)で取得できるようです。これはstatfs(2)システムコールのラッパーという扱いですが、基本的にはstatvfs(3)を使うように推奨されます。statvfs(3)は以下のように使います。

#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>
#include <errno.h>
#include <sys/statvfs.h>

char *argv0;

void fstat(char *);
uintmax_t bused(struct statvfs *);
uintmax_t bsize(struct statvfs *, uintmax_t);

int
main(int argc, char **argv)
{
    int i;

    argv0 = argv[0];
    for(i = 1; i < argc; i++)
        fstat(argv[i]);
    return 0;
}

void
fstat(char *path)
{
    struct statvfs fs;

    if(statvfs(path, &fs) < 0){
        fprintf(stderr, "%s: statvfs: %s\n", argv0, strerror(errno));
        exit(1);
    }
    printf("path %s\n", path);
    printf("block size %lu\n", fs.f_bsize);
    printf("fragment size %lu\n", fs.f_frsize);
    printf("fragments in a block %lu\n", fs.f_bsize/fs.f_frsize);

    printf("used blocks %" PRIuMAX "\n", bsize(&fs, bused(&fs)));
    printf("avail blocks %" PRIuMAX "\n", bsize(&fs, fs.f_bavail));
    printf("total blocks %" PRIuMAX "\n", bsize(&fs, fs.f_blocks));
    printf("used inodes %u\n", fs.f_files - fs.f_ffree);
    printf("free inodes %u\n", fs.f_ffree);
    printf("flags");
    if(fs.f_flag&ST_RDONLY)
        printf(" readonly");
    printf("\n");
    printf("namelen %lu\n", fs.f_namemax);
    printf("\n");
}

uintmax_t
bused(struct statvfs *f)
{
    return f->f_blocks - f->f_bfree;
}

uintmax_t
bsize(struct statvfs *f, uintmax_t n)
{
    return n * f->f_frsize / 1024;
}

実行した結果、df(1)の出力と同じ値になっているので正しそうですね。

$ cc fs_linux.c
$ ./a.out /
path /
block size 4096
fragment size 4096
fragments in a block 1
used blocks 15635224
avail blocks 42476028
total blocks 61252420
used inodes 561671
free inodes 3345913
flags
namelen 255

$ df -P /
Filesystem     1024-blocks      Used Available Capacity Mounted on
overlay           61252420  15635224  42476028      27% /

$ df -i /
Filesystem         Inodes   IUsed      IFree IUse% Mounted on
overlay           3907584  561671    3345913   15% /

マウントされたファイルシステムのリストは、getmntent(3)を使うと取得できます。使い方はこんな雰囲気。

FILE *r;
struct mntent *p;

r = setmntent("/etc/mtab", "r");
while(p = getmntent(r))
    printf("%s\n", p->mnt_dir);
endmntent(r);

setmntent(3)の第1引数にファイルパスを渡すようになっていて、上の例でもそうですがこのファイルパスには度々/etc/mtabが使われます。手元の環境では、/etc/mtab/proc/self/mountinfoへのシンボリックリンクとなっているようでした*1getfsent(3)という似た名前の関数もあるけど、こちらは非推奨のようです。

macOSの場合

macOSでは、getmntent(3)が存在しないので、上記のコードがコンパイルできません。また、statvfs(3)は存在しますが、f_ffreeの値がdf(1)の結果と異なっています。

% ./a.out /    
path /
block size 1048576
fragment size 4096
fragments in a block 256
used blocks 22031496
avail blocks 670026888
total blocks 976490576
used inodes 488433
free inodes 586997151     <- この値だけおかしい
flags readonly
namelen 255

% df /         
Filesystem   512-blocks     Used Available Capacity iused      ifree %iused  Mounted on
/dev/disk1s5  976490576 22031496 670024808     4%  488433 4881964447    0%   /

macOSdf(1)ソースコードを読むと、statvfs(3)ではなくstatfs(2)が使われていて、こちらを使えばdf(1)の出力と同じ値を取得できました。ただし、statfs.f_bsizestatvfs.f_frsize相当の値で、statfs.f_iosizestatvfs.f_bsize相当となっていて、f_bsizeの意味が異なっているところは難しいですね。

struct statfs fs;

if(statfs(path, &fs) < 0){
    fprintf(stderr, "%s: statfs: %s\n", argv0, strerror(errno));
    exit(1);
}
printf("mounted on %s\n", fs.f_mntonname);
printf("fstype %s\n", fs.f_fstypename);
printf("block size %u\n", fs.f_iosize);
printf("fragment size %u\n", fs.f_bsize);
printf("fragments in a block %u\n", fs.f_iosize/fs.f_bsize);
...

これでdfと同じ値を読むことができました。

% ./a.out /
mounted on /
fstype apfs
block size 1048576
fragment size 4096
fragments in a block 256
used blocks 22031496
avail blocks 670031712
total blocks 976490576
used inodes 488433
free inodes 4881964447
flags readonly

% df /
Filesystem   512-blocks     Used Available Capacity iused      ifree %iused  Mounted on
/dev/disk1s5  976490576 22031496 670031712     4%  488433 4881964447    0%   /

最後にmacOSでマウントしているファイルシステムの列挙は、getmntinfo(3)で取れます。

struct statfs *f, *p, *e;
int n;

n = getmntinfo(&f, MNT_NOWAIT);
if(n < 0){
    fprintf(stderr, "%s: getmntinfo: %s\n", argv0, strerror(errno));
    return;
}
e = f+n;
for(p = f; p < e; p++){
    printf("mounted on %s\n", p->f_mntonname);
    ...
}
free(f); /* mallocされるのでfreeが必要 */

getmntinfo(3)の代わりに、もっと低レベルなgetfsstat(2)を使っても実現できます。

/* 最初の引数にNULLを渡すとマウントされたファイルシステムの数を調べられる */
n = getfsstat(NULL, 0, MNT_NOWAIT);
if(n < 0){
    fprintf(stderr, "%s: getfsstat: %s\n", argv0, strerror(errno));
    return;
}
f = malloc(sizeof(*f)*n);
if(f == NULL){
    fprintf(stderr, "%s: malloc: %s\n", argv0, strerror(errno));
    return;
}
if(getfsstat(f, sizeof(*f)*n, MNT_NOWAIT) < 0){
    fprintf(stderr, "%s: getfsstat: %s\n", argv0, strerror(errno));
    return;
}

他OSの場合

手元にないので実験していませんが、coreutilsのコードを読む限りは大変そうな気配がありました。

*1:手元とは言ったけど他もだいたい同じかな

ベル研Plan 9を9legacyの安定版にアップデートする

従来のPlan 9公式サイトplan9.bell-labs.com/plan9は2020年現在、更新が停止しておりアクセスができません。代わりに、9p.io/plan9で最終更新日である2015年1月時点のデータがミラーされていて、Wikiやcontribソースコードなどベル研公式サイトが提供していたほとんどのリソースにアクセスできる状態になっています。

いまPlan 9をインストールするなら、

  • 9frontをインストールする
  • 9legacyをインストールする

のどちらかが無難ですが、9p.ioでは当然インストーラもミラーされているので、ベル研によって最後にリリースされたCDまたはUSBディスクイメージを9p.ioからダウンロードしてインストール可能です。とはいえ2015年のイメージなので、TLS 1.2に対応していないなど、色々錆び付いていてそのままでは使い物になりません。この記事と次の記事で、

  • ベル研Plan 9から9legacyの安定版に更新する
  • Gitを使って9legacyの更新を追う
  • (ついでに)Google Compute Engine用のディスクイメージを作る

の3点について、個人的にどうやっているのか紹介します。長くなったのでこの記事では安定版へアップデートするところまで。

ベル研Plan 9のインストール

まずはベル研Plan 9のインストールが必要なのでQEMUにインストールしましょう。この記事では最終的にCompute Engineイメージを作るためQEMUを使いますが、作らないのであればQEMU以外でも構いません。*1

ところで、QEMUには多くのオプションがあり、virtioを使う場合などは非常に長いコマンドラインになってしまうので簡単なwrapperを用意しました。以下の説明ではwrapperと生のオプションを併記しますが、だいぶシンプルになっていると思うので良ければどうぞ。

github.com

イメージ作成とインストール

まずはディスクを作ります。Compute Engineイメージのためraw形式でディスクイメージを作っていますが、Compute Engineイメージを作らないなら、どの形式でも構いません。

$ qemu-img create disk0.raw 10G

次に、ベル研Plan 9のCDイメージからブートします。

$ curl https://9p.io/plan9/download/plan9.iso.bz2 | bunzip2 | tar x
$ ./start.bash -d plan9.iso
qemu-system-x86_64 -m 1G -smp 2 \
  -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,index=0 \
  -device e1000,netdev=ether0 \
  -netdev user,id=ether0,net=10.0.2.0/24 \
  -machine type=pc,accel=hvf \ # ← Linuxならaccel=kvm
  -drive file=plan9.iso,index=2,media=cdrom \
  -boot order=d

この後は通常のPlan 9をインストールする手順なので省略しますが、fossil+ventiではなくfossilのみでフォーマットすると、GNU tarでtar --sparseした際にイメージサイズを小さくできるので、Compute Engineイメージを作る場合はこちらが良いと思います。少々面倒ですが、後からfossil+ventiディスクに移行することは可能です。

インストールが終わったら、あとで使うためにインストール直後のdisk0.rawをバックアップしておきましょう。ここではdisk0-orig.rawとします。

$ cp disk0.raw disk0-orig.raw

9legacyの安定版へアップデート

インストール直後のdisk0.rawから起動します。

$ ./start.bash
qemu-system-x86_64 -m 1G -smp 2 \
  -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,index=0 \
  -device e1000,netdev=ether0 \
  -netdev user,id=ether0,net=10.0.2.0/24 \
  -machine type=pc,accel=hvf

以下のプロンプトが表示されたら、glendaでログインするとrioと共にウィンドウが表示されると思います。

user[none]: 

グレーの背景で右クリックすると、新しいウィンドウを作成できるので作りましょう。以下の作業は、このウィンドウで行います。

ネットワーク設定

まずはネットワークの設定から。上のqemuコマンドを使っていればDHCPによってIPアドレスを設定できると思います。

# DHCPからIPアドレスを設定(再起動すると消える)
% ip/ipconfig

# DNSサーバのIPアドレスを設定
% cat >>/lib/ndb/local

ipnet=qemu-net ip=10.0.2.0 ipmask=255.255.255.0
    dns=10.0.2.3
^D

# DNSクライアントを起動(再起動すると消える)
% ndb/dns -r

このコマンドが何をしているのかは、以下の記事が少し参考になるかもしれません。

ここまで終われば、少なくともインターネットには出られるはず。

% ip/ping -n3 8.8.8.8

9legacy-toolの準備

9legacyはベル研Plan 9に対するパッチ集なので、PatchesからStableと9k kernelにリストされているパッチをローカルのソースコードに当てていく作業となります。一般的には、

% cd /
% hget http://9legacy.org/9legacy/patch/xxx.diff | ape/patch -p1

という手順なのですが、数が多くて非常に大変だし、頻繁にパッチが適用できず中途半端な状態に陥りがちなので、パッチを管理するためのコマンド群を作成しました。

しかしこの時点では、まだPlan 9ではTLS 1.2が使えないのでGitHubからダウンロードできません。なので少し面倒ですが、u9fsを使ってローカルから渡してしまいましょう。u9fsはローカルで実行します。著者の説明ではlaunchdから起動していますが、面倒なので、ここではplan9portのlisten1で一時的に起動させる方法を取りました。

まずはコンパイル

$ make

u9fsPlan 9から接続する際の認証情報を/etc/u9fs.keyから読み込みます*2。このファイルは上から、パスワード、ユーザ名、認証ドメインを1行ずつ書いたファイルなので作ってしまいましょう。u9fs -A fileオプションを使うと、/etc/u9fs.key以外のファイルでも扱えます。

# u9fsが認証するユーザー情報をu9fs.keyに書く; それぞれ値は何でもいい
$ cat u9fs.key
password
username
authdom

できたらu9fsを起動して、Plan 9側からマウントします。

# 9fsポート(564)でlisten
$ listen1 'tcp!*!9fs' ./u9fs -a p9any -A u9fs.key -u $USER -l log

# Plan 9側からu9fsへ接続(ローカルのIPアドレスは192.168.1.3とする)
% 9fs 192.168.1.3
(認証情報を聞かれるのでu9fs.keyに書いた内容を入力する)

% cd /n/192.168.1.3

ファイルのコピーが終わったらu9fsは終了して構いません。

% unmount /n/192.168.1.3

$ pkill u9fs
$ rm u9fs.key log

9legacyパッチを適用

9legacy-toolを使って、手元のソースコードを更新する方法は以下のとおりです。

# パッチを管理するディレクトリを$home/lib/9legacyに作成
% 9legacy/init

# 最新のパッチリストを取得
% 9legacy/update

# システム全体を更新対象のソースツリーとして設定
% echo 'srv -AWP replica' >>/srv/fscons
% mount -c /srv/replica $home/lib/9legacy/plan9

# Stableと9k kernelパッチを適用
% 9legacy/stable >stable.list
% 9legacy/9k >9k.list
% 9legacy/installall -n stable.list 9k.list

# 適用したパッチを確認
% 9legacy/list

# 後始末
% unmount $home/lib/9legacy/plan9
% rm /srv/replica

ソースコードのリビルド

これで手元のソースコードが最新の状態となったので、コマンドやカーネルを更新しましょう。

% cd /sys/src
% mk install

カーネルも更新しておきます。

% cd /sys/src/9
% mk 'CONF=pcf'
% 9fat:
% cp 9pcf /n/9fat/9pcf

% cd /sys/src/9/pcboot
% mk 9load
% cp 9load /n/9fat/9load

ところで9fatはただのFATファイルシステムなんですが、8文字以上のファイル名でも普通に作成できます。だけども9loadから扱えなくなってしまうのでファイル名の長さには注意しましょう。

Virtioを使う

この手順は間違えると起動しなくなるので、必要ならディスクイメージをコピーしておきましょう

Compute Engineで実行する場合、virtio必須なので手元の環境もvirtioを扱うようにしておきましょう。Compute Engineイメージを作らないなら、グラフィックスが使えなくなるなど不都合もあるので、この手順は省略しても構いません。

基本的な手順はvirtioを使うの通りですが、9legacyパッチでカーネルコンフィグにvirtioが追加されているので、デバイス名の変更と再起動だけ行います。ストレージはvirtio-blkとvirtio-scsiの2通り存在していて、QEMUのオプションでどちらを使うか切り替えられますが、Compute Engineに合わせて、この手順ではvirtio-scsiを使います。Plan 9から見ると、virtio-scsiの場合はデバイス名が/dev/sd00のように数字で構成されていて、virtio-blkは/dev/sdF0のようにアルファベットと数字で構成されます。

% 9fat:
% ed /n/9fat/plan9.ini
(bootfile, bootargs, bootdiskエントリのsdC0をsd00に変更)

% cd /tmp
% fossil/conf /dev/sdC0/fossil >fossil.conf
% ed fossil.conf
(fossil.confにあるsdC0をsd00に変更)
% fossil/conf -w /dev/sdC0/fossil fossil.conf
% rm fossil.conf

# Ventiを使っている場合のみ
% venti/conf /dev/sdC0/arenas >venti.conf
% ed venti.conf
(venti.confにあるsdC0をsd00に変更)
% venti/conf -w /dev/sdC0/arenas <venti.conf

virtioに切り替えるとドライバが対応していなくてグラフィックスが使えなくなるのでmonitorの値を変更しましょう。コンソールに流れたログを遡って読んだり、コピペしたりなどができなくなるのはさすがに不便なので、シリアルコンソールを使えるように設定しておきましょう。

# 以下のコマンドでは追記しているけど、既にあれば値を書き換える方が良い
% echo 'monitor=vesa' >>/n/9fat/plan9.ini
% echo 'console=0 b115200 l8 pn s1' >>/n/9fat/plan9.ini

これで再起動します。virtioを使うため、QEMUの起動オプションが変わります。

$ ./start.bash -v
qemu-system-x86_64 -m 1G -smp 2 \
  -device virtio-scsi-pci,id=scsi \
  -device scsi-hd,drive=hd0 \
  -drive file=disk0.raw,format=raw,cache=writethrough,id=hd0,if=none,index=0 \
  -device virtio-net-pci,netdev=ether0 \
  -netdev user,id=ether0,net=10.0.2.0/24 \
  -machine type=pc,accel=hvf

-nographicオプションを付けて起動したQEMUは閉じるボタンなどはありません。ctl+aに続けてcキーを入力するとQEMUモニタに入れるので、そこでqと入力すれば終了できます。また、おそらく終了した時点でターミナルが壊れているので、tputでリセットしておきましょう。

% fshalt

ctl+a q
(qemu) q
$ tput smam  # 反対のオプションはrmam

ところでLinuxでは、認識した順に/dev/sda, /dev/sdbとデバイス名が決まっていきますが、Plan 9は単純に接続された場所によって決まります。SATAプライマリのスレーブに接続されたディスクは、Plan 9では常に/dev/sdC1ですし、セカンダリのマスタは/dev/sdD0です。後の手順でも少し触れますが、Compute Engineの起動ディスクはSCSIの2番目にあるディスクとして接続されているようで、Plan 9からは/dev/sd01と見えます。

おわりに

ひとまずこれで、現在の9legacy安定版にまで更新できました。virtioを使えるようにできた後で、

$ cp disk0.raw disk.raw
$ gtar -Sczf plan9.tar.gz disk.raw

とするとCompute Engineで使えるディスクイメージを作れますが、とはいえ9legacyはパッチ集であり、同じパッチ名でも更新される時があるので、このままでは9legacyの更新に追従することがとても困難です*3。そこで次の記事では、Gitを使ってアップデートする方法を紹介します。

*1:動くかどうかは知りませんが...少なくともESXiでは動作確認しました

*2:認証を不要とすることも試したけどうまく接続できなかった

*3:パッチは2015年時点のソースに対する差分だけど手元はもう変わっている

Plan 9はファイルをどのように使っているか

Plan 9は、ほとんどの操作をファイルを通して行います。リソースの参照だけではなく、シグナルの送信*1などカーネルに対して命令を送る場合も、ある程度はファイルを通して行えます。初見では、これらの習慣は馴染みがないと思うので、普段使いそうなものをいくつか紹介します。

Plan 9Unixよりeverything is a fileを徹底していると言われていますが、Rob Pike氏によるとeverything has the same interfaceとのことです。ファイルというインターフェイスを通して全てのことを行う様子は、以下の例からも理解してもらえるんじゃないでしょうか。

それでは、まずは簡単なところから。

コピー・ペースト

カーネルが提供する/dev/snarfを読み書きするだけです。read(2)すると、エディタなどでコピーしたデータを読み出せます。write(2)したデータは、エディタなど他のプログラムから読み出せます。

% echo hello world >/dev/snarf
% cat /dev/snarf

CopyではなくSnarfというあまり見ない名前を使っている理由は、データを複製しているわけではないから、だそうです。

プロセス操作

プロセス関連の操作は/proc以下のファイルを扱います。/proc/<pid>/statusでプロセスの状態を確認したり、/proc/<pid>/noteで外部からシグナル(Note)を送ったりできます。

% sleep 100 &
% ps | grep sleep
glenda        43551    0:00   0:00        8K Sleep    sleep
% cat /proc/43551/status

シグナル(Note)を送る場合は/proc/<pid>/noteファイルへ文字列を書き込みます。例えばPlan 9のkill(1)はコマンドを出力するだけで、実際にkillするためにはrc(1)へパイプさせる必要があります。

% kill sleep
echo kill>/proc/43551/note # sleep
% kill sleep  | rc

シグナルと異なり、Noteは文字列なので送る側と受け取る側で合意できていれば、文字列の内容はなんでも構いません。プログラム側では書き込まれた文字列が届くので、テキストをみてどう振る舞うかを決めます。

void
catchnote(void *, char *msg)
{
    if(strstr(msg, "alarm"))
        noted(NCONT);
    else
        noted(NDFLT);
}

void
main(void)
{
    notify(catch);
}

システムが送ってくるNoteはnotify(2)でドキュメント化されています。

プロセスの強制終了

ハンドラの有無に関わらず強制終了させたい場合は/proc/<pid>/ctlkillと書き込みます。これはUnixkill -9に相当します。noteと違って、こちらは決まったコマンドしか受け付けません。

% slay sleep
echo kill>/proc/43532/ctl # sleep
% slay sleep | rc

GUI関連

スクリーンショット

rioが動作しているなら、/dev/screenファイルがrioによって提供されているので、これをread(2)するとその時点のスクリーン全体に相当するビットマップが読めます。

% cp /dev/screen screen.bmp

/dev/wsys以下にはウインドウ毎のファイルが用意されているので、/dev/wsys/<n>/windowを読むと画像を取得できます。

% cat /dev/winid
          2
% lc /dev/wsys
1  2
% cp /dev/wsys/2/window win.bmp

スクロールさせる

rioが提供するターミナル(と呼んでもいいのか定かではないけど)は、コマンドの出力が表示範囲を超えてもデフォルトではスクロールしません。代わりに一定量溢れてしまうとプロセスが一時中断します。マウスの中ボタンメニューからscrollを選んでもいいのですが、/dev/wsys/<n>/wctlscrollと書き込むと、該当するウィンドウではプログラムの出力に従ってスクロールするようになります。

# winidから自身のwindow idが読める
% cat /dev/winid
          2
% echo scroll >/dev/wsys/2/wctl

ネットワーク設定

ネットワーク関連の操作は/net以下を扱います。

IPアドレス設定

自身のIPアドレスを設定する場合は、/net/ipifc/cloneadd ipaddr ipmaskと書き込むことで行います。

% echo add 192.168.1.3 255.255.255.0 >/net/ipifc/clone

静的ルーティングは/net/iprouteadd ipnet ipmask ipgwと書きます。

% echo add 192.168.10.0 255.255.255.0 192.168.1.1 >/net/iproute

設定の確認

/net/ndbを読むと、設定されている情報の一部が読めます。ネットワーク関連のプログラムは、このファイルを読んで必要な情報を得ています。

% cat /net/ndb
ip=192.168.1.3 ipmask=255.255.255.0 ipgw=192.168.1.1
    sys=cpu
    dom=cpu.local.internal
    dns=192.168.1.1
    ntp=192.168.1.1

/net/iprouteからはルーティングテーブルを読めます。

% cat /net/iproute
0.0.0.0         /96  192.168.1.1     4    none   -
192.168.1.0     /120 192.168.1.0     4i   ifc    -
192.168.1.0     /128 192.168.1.0     4b   ifc    -
192.168.1.3     /128 192.168.1.3     4u   ifc    0
192.168.1.255   /128 192.168.1.255   4b   ifc    -
127.0.0.0       /104 127.0.0.0       4i   ifc    -
127.0.0.0       /128 127.0.0.0       4b   ifc    -
127.0.0.1       /128 127.0.0.1       4u   ifc    -
127.255.255.255 /128 127.255.255.255 4b   ifc    -
255.255.255.255 /128 255.255.255.255 4b   ifc    -

/net/ipselftabはホスト自身だと判断するIPアドレスのテーブルです。受信したパケットの宛先がこのテーブルに該当すると、カーネル/net/tcp/net/udp以下のファイルを通してパケットをプロセスへ伝えます。

cpu% cat /net/ipselftab
127.0.0.0                                    01 6b  
192.168.1.0                                  01 6b  
127.0.0.1                                    01 6u  
192.168.1.3                                  01 6u  
127.255.255.255                              01 6b  
255.255.255.255                              02 6b  
192.168.1.255                                01 6b  

ところで普通は、これらの設定はip/ipconfigで行いますが、ip/ipconfigDHCPなどから構成を取得して、以下のファイルを更新しているだけです。上記以外にも、多くの設定やコマンドが用意されているので、興味があればip(3)を読むと参考になるでしょう。また、パケットルーティングに関連するカーネルの動作は以下の記事にも書きました。

ネットワークプログラミング

Plan 9にはdial(2)やannounce(2)など高レベルの関数が用意されていますが、これらはシステムコールではなく、ただのライブラリ関数です。*2

TCPクライアント

どのようにOSがファイルを扱うか見るため、高レベル関数は使わずに直接/net/tcp/net/udpなどのディレクトリを扱って通信を行ってみましょう。まずTCPクライアントは、/net/tcp/cloneconnectコマンドを書き込んで、cloneを開いたまま/net/tcp/<n>/dataを読み書きすることになります。以下のコードはTCPのポート9000へ接続して、サーバからデータを受け取るものです。

#include <u.h>
#include <libc.h>

void
main(void)
{
    int ctl, fd, n, c;
    char buf[1<<10];

    /* ctlを閉じるとコネクションも閉じるので開いたままにする */
    ctl = open("/net/tcp/clone", ORDWR);
    if(ctl < 0)
        sysfatal("open: %r");
    /* 新しく割り当てられたコネクション番号を読む */
    n = read(ctl, buf, sizeof(buf)-1);
    if(n < 0)
        sysfatal("read: %r");
    buf[n] = '\0';
    c = atoi(buf);

    fprint(ctl, "connect 127.1!9000");
    snprint(buf, sizeof buf, "/net/tcp/%d/data", c);
    fd = open(buf, ORDWR);
    if(fd < 0)
        sysfatal("open: %r");
    n = read(fd, buf, sizeof(buf)-1);
    if(n < 0)
        sysfatal("read: %r");
    buf[n] = '\0';
    print("%s", buf);

    close(fd);
    close(ctl);
}

/net/tcp/cloneをopen(2)すると、OSによって新しいコネクションのctl(/net/tcp/<n>/ctl)へリダイレクトされたファイルディスクリプタを得ますが、プログラムはまだコネクション番号を知らないので/net/tcp/<n>/dataをopen(2)できません。コネクション番号はctlを読むことにより取得しています。

TCPサーバ

サーバのコードは、Listenするポート番号をannounceコマンドで登録しておきます。/net/tcp/<n>/listenファイルをopen(2)すると、登録したポート番号へ新しい接続があるまでブロックされて、アクセスがあった時にopen(2)から戻ります。このときクライアント毎に新しくコネクションが作成されるので、listenファイルをopen(2)したファイルディスクリプタからコネクション番号を読んでdataファイルを読み書きします。

#include <u.h>
#include <libc.h>

int netopen(int n, char *name, int mode);
int readint(int fd);

void
main(void)
{
    int ctl, cfd, fd;
    int c, c1;
    char buf[1<<10];

    ctl = open("/net/tcp/clone", ORDWR);
    if(ctl < 0)
        sysfatal("open: %r");
    c = readint(ctl);

    fprint(ctl, "announce 9000");
    for(;;){
        cfd = netopen(c, "listen", OREAD);
        /*
        * クライアントへのコネクション番号を取得する。
        * このコードでは1クライアントしか同時に処理できないので、
        * 本当はfork(2)した方が良いけどサンプルなので手抜き....
        */
        c1 = readint(cfd);
        fd = netopen(c1, "data", OWRITE);
        fprint(fd, "hello world\n");
        close(fd);
        close(cfd);
    }
    close(ctl);
}

int
netopen(int n, char *name, int mode)
{
    int fd;
    char buf[100];

    snprint(buf, sizeof buf, "/net/tcp/%d/%s", n, name);
    fd = open(buf, mode);
    if(fd < 0)
        sysfatal("open: %r");
    return fd;
}

int
readint(int fd)
{
    int n;
    char buf[100];

    n = read(fd, buf, sizeof(buf)-1);
    if(n < 0)
        sysfatal("read: %r");
    buf[n] = '\0';
    return atoi(buf);
}

サーバの方は少し複雑な動きをしますが、やっていることは一般的なファイルの読み書きだけなので、シェルスクリプトなどでネットワークプログラムも書けますし、他ホストの/netをimportして、それを読み書きすると簡易なプロキシのように振る舞えます。また、プログラム開発などでは、/netと同じインターフェイスを9Pファイルサーバとして実装して、bind(1)で差し替えてしまうと、プログラムに何の変更も行わずに通信をモックしたりできますね。これらの特徴はファイルというインターフェイスで統一している強みだと思います。

関連マニュアル

Plan 9では上で紹介したもの以外にも、多くのものをファイル経由で扱います。これらは、多くのものがマニュアルの3章または4章に書かれているので困ったら探してみると良いでしょう。

*1:Plan 9ではNoteと呼ぶ

*2:Plan 9のマニュアル2章はライブラリ関数で、システムコールと区別しません