RustのSTM32向けイーサネットドライバを解説する(送信編)

STM32ボードのイーサネットドライバのRust実装であるstm32-ethクレートの送信部分のロジックの解説をしていきます。

前回の記事では受信部分を解説しました。
そのときは、自分のOS用のドライバでの送信が成功していなかったため、受信のみの解説になってしまったのですが、
今回晴れて送信部分のバグがとれたので、安心して記事を書けるようになりました。

イーサネットモジュールの初期化部分のロジックは受信とかぶっているので省略していきます。
仕様書も前回記事で用いたものを使います。

送信用ディスクリプタとバッファの用意

受信はDMAを用いてメモリにデータが書き込まれ、操作のためにはリングバッファになったディスクリプタと呼ばれる領域とそれに対応するバッファを確保する必要がありました。
送信もDMAを用いてメモリ上のデータを転送し、リングバッファになっているディスクリプタを用いて操作していきます。
リファレンスマニュアルでは33.6.7で解説されています。今回はNormal Tx DMA descriptorsを使います。

stm32-ethではsrc/tx.rsTxRingという構造体がこのリングバッファを抽象化したものです。TxRingEntryが各ディスクリプタとそれに対応するバッファを持っています。
このTxRingEntryと受信のとき使ったRxRingEntryは共にRingEntry<T>というジェネリック型を用いて実装されていることからわかるように、共通点は多いです。ただし、フィールドの位置が微妙に違ったりするので注意しましょう。

この送信用ディスクリプタの初期化には以下のような処理が必要です。

  • TDES0のOWNビットをクリアしておく。このビットがセットされているとDMA側が所持していることになるが、まだ送信するべきものがないのでCPU側で所持する
  • TDES0のTCHビットをセットすることで、セカンドアドレス連鎖を有効にする
  • リングバッファの末尾のエントリ以外の場合、TDES3に次のディスクリプタのアドレスを書き込む。最後のエントリの場合は、アドレスは設定せずにTDES0のTERビットをセットする。

また、ディスクリプタは8バイトにアラインされている必要があります(ワードアライン)。

あと、これは前回書き忘れたのですが、DMAで書き換えられるメモリにアクセスしてポーリングなどの処理を書く場合はcore::ptr::read_volatileを使うなどしないと最適化されてちゃんと動作しない場合があることにも注意しましょう(1敗)。

DMATDLARレジスタに先頭のディスクリプタのアドレスをいれ、DMAOMRのSTビットを立てれば、送信用のDMAの設定は完了です。

DMAからデータを送信する

Eth::sendから呼び出されているTxRing::sendが送信でディスクリプタやバッファの処理をするコードです。実際に見てみましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub fn send<F: FnOnce(&mut [u8]) -> R, R>(&mut self, length: usize, f: F) -> Result<R, TxError> {
let entries_len = self.entries.len();

match self.entries[self.next_entry].prepare_packet(length) {
Some(mut pkt) => {
let r = f(pkt.deref_mut());
pkt.send();

self.next_entry += 1;
if self.next_entry >= entries_len {
self.next_entry = 0;
}
Ok(r)
}
None =>
Err(TxError::WouldBlock)
}
}

関数としては送りたいデータの長さと、ドライバ内のバッファにデータを書き込むための関数を受け取り、Resultを返すという型になっています。
やっていることはまず、利用可能なディスクリプタがあるか探していて、ある場合は、エントリ内のディスクリプタの下準備とバッファ領域を準備します。
ディスクリプタの下準備とは

  • TDES1のTBS1にバッファサイズを書き込む
  • TDES2にバッファのアドレスを書き込む

の2つです。バッファはTxEntry内のバッファのうち、必要な長さのみのスライスへのミュータブルな参照として渡されます。
引数として渡される関数はこのスライスにデータを書き込むこととなります。

その後、準備したバッファ領域に対して受け取った関数fを実行します。
pkt.send()はTDES0のOWNビットを立てる処理でこれでこのエントリがDMA側で処理される準備ができたことになります。
リングバッファのカウンターを更新したら、Okを返しておしまいです。

最後に呼び出し元のEth::sendTxRing::demand_pollを呼び出していますが、これはDMATPDRレジスタに1を書き込むことによってDMAが送信ディスクリプタをポーリングするように要求するものです。

受信とは違い、最後にDrop等は特に実装する必要はないです。

送信用バッファについて

このstm32-ethでは送信用バッファとディスクリプタを1つの構造体にまとめ上げていますが、実はこうする必要はあんまりなかったりします。
そもそもディスクリプタできちんと指定してあげれば、バッファのアドレスに特に縛りはありません。

この実装だと非行率的な例をいくつかあげます。
まず、送信用バッファとは別のメモリ上にすでに出来上がったパケットが存在している場合(例えば定数になっている場合)、引数として与えられる関数fはメモリ間のデータコピーをするだけとなってしまいます。
また、現状の実装ではTxEntryを確保した後、送信用バッファにデータを用意するという流れになっていますが、実際は送信用のデータの用意はTxEntryの確保前でもよいはずです。
このデータの用意がそこそこ時間のかかるものであれば、無駄にTxEntryを確保する時間が長くなってしまいます。

send関数を任意のアドレスとその長さを渡すような関数にしてしまう、という実装にすればこのような問題は解決されそうです。
が、ひとつ注意しなければならないのはライフタイムの問題です。
DMAでアクセスされるバッファ領域が送信中に解放されてしまい別のデータが入るなどとなれば送信が失敗してしまいます。
なので、バッファ領域はライフタイム制約を入れた参照として受け取るとよいでしょう。この場合の制約はドライバと同じ区間生存する、というものならば大丈夫でしょう。

感想

最後のバグの原因はGPIOの設定がひとつだけ間違っていた、というものだったのですが、気がつくまで相当しんどかったです。
とりあえずちゃんと実装できて一安心です。
送信は受信とは違った実装ポイントがあるので、もっと作り込めばおもしろそうだなと思いました。

2020年の振り返り

今年の成果

まずはブログ記事だが、今年はこの記事を含めずに9本で去年より少なくなっている。
一応、zenn.devにも記事を書いたが、それを含めても少ない。
また、自作OSプロジェクトだが、今年は新しいモジュールはイーサネットドライバの受信部分のみであまり進捗は出ていない。

と去年やっていた分野では停滞気味になってしまったが、他の形での成果はいくつか上がった。
1つはRustでの自作OS入門のドキュメントで、これは予想以上に反響をもらった。
こういうまとまったドキュメントを書くのはあまり経験がなかったのだが、はてなブックマークでのトレンド入りするなどしていたらしい(普段そういうサービスを使っていないので気がつかなかった)。

各種プログラミングコンテストにもぼちぼち参加した。AtCoderではABCに何回か出るほか、AtCoder Problmesを利用して毎月一程度の問題数をこなすようにした。
短時間で集中してコーディングするという能力がまだまだだと感じているので、ABCにちゃんとオンタイムで参加して緊張感のある状態で問題を解くという回数は増やした(時間帯が変わってくれるともうちょっと楽なのだが)。
ISUCONでは初めて予選通過できた。が、本選では散々な結果だったしまだまだ知識量も足りていないという部分も感じられた。
予選はトラブルも多く実力を発揮しきれなかったチームも一定数あることを考えると、運に助けられた部分もあったと思う。
しかしながら、ノウハウは自分の中で蓄積できつつあるのは感じているし、予選通過自体は素直に前進であったとポジティブに捉えてはいる。
ICFPCにも出ていた。チームはベストメンバーがそろっていたが、14位で去年と同じくらい。チームの中ではGalaxy Padの仕様をエスパーするのが一番得意という何とも言えない強味でサポートできた。できればもうちょっと他の面でもサポートできれば良かったのだが、なかなか難しい。

オープンソースへのコントリビューションはいくつかのプロジェクトに小さな貢献が何件かあったが、あまり大きな貢献はできなかった。
一応、winitというRust製のウィンドウシステムのライブラリにIME関連のイベントをサポートするというのに貢献を続けている。
が、MacとWindowsの環境への実装ができてないためまだマージはされていない。
これがマージされると他のデスクトップアプリケーションでIMEイベントに対応した機能がいろいろ実装できるのでぜひマージされてほしいが、手持ちにまともなMacとWindowsの開発環境がないので難しい。

他にはArm入門勉強会というイベントで過去に作った自作ハイパーバイザの話をするとかした。
オンラインのこういう勉強会にはちょくちょく今後も顔を出していきたい。

トータルで見れば去年の成果からは思ったよりは伸びなかったが、決して進歩がないわけでもないといったところか。
今年は新型コロナの影響やらなんやらでモチベーションを保つのが難しかったり、生活スタイルを変えなければならなかったりといろいろ厳しいなか、それなりにうまくやれた気もする。

政治のこととか

普段、Twitterとかで政治とか社会問題に関する言及はあんまりしないようにしている。
それらに関する専門知識をさほど持ち合わせているわけでもなく、この手の議論をSNS上で繰り広げてもろくなことにならないケースが多すぎるからである。
とはいえ、自分に直接関係するようなことに対して全くもって言及しない、というのはそれはそれで違うだろうとも思うので、いくつかの問題については言及をあえてした。
年末なので、自分の考えを整理する目的でここに書くが、別にこれに関して反論とかをぶつけてきても必ずしも反応するわけではない、ということは予め断っておく。
またお約束ではあるが、あくまで個人の見解であり、所属する団体を代表するものでもない。

COVID-19

新型コロナウイルスの流行は、無関係な人間は全くいないといっても過言ではないくらい社会にインパクトを与えてしまった。
3月の頭あたりから会社でも全員原則在宅勤務となり、正直その当時はオーバーリアクションなのでは、とも感じていたのだが、全くもって甘い考えであったことはその後の状況を見てのとおり。

一部では流行は自然収束するとか、日本人は免疫を持っているとか楽観説を唱える言論も少なからずあったわけだけど、現状の国内外のデータと照らし合わせれば説得力はほぼ皆無になりつつあると思う。
自分には医学的な知識はほとんどないので、論文を読んで本当に正しそうみたいな議論は残念ながらできないが、日本の分科会に属する医学の専門家たちの知見はおおむね信頼していいのだろうと思っている。
もっとも、その知見に基づいてきちんとしたアクションがとられているかどうか、メッセージの発信の仕方などには議論はあるとは思うけど。

大手メディアの報道もかなりいい加減で、かなり信憑性が低い言説であったり、専門家のメッセージもかなり歪に切り取られることも少なくなかった。
このあたりはそこまで驚きではなかったが、 自分の観測範囲のそこそこ知識もきちんとあると思っている人たちですら、怪しい言説をシェアしたり、流行初期にあった日本は意図的に感染者数を隠しているのような陰謀説に加担するひとすらいたのは正直驚いた。
なので、自分は分科会が上げる資料を時々目を通したり、感染者数の動向の生データをみたり、こういう専門家と矛盾していない解説をしてくれるTwitterアカウントを非公開リストにいれて(信憑性はだいぶ落ちるであろうことに留意しつつ)ウォッチしている。

自分は幸いにも大きく収入が落ち込むということはなかったが、人との接触が歓迎されないという状況が長く続くというのは気分がよくない。在宅勤務も正直そこまで好きじゃない。
おそらく来年中にコロナ問題が完全解決とはならず、一定の制限が続くのではないかなと理解しているが、一日も早く事態が好転するのを願わずにはいられない。

Coinhive裁判

Coinhiveを設置したウェブアプリケーション製作者が逮捕され裁判になるという事件があり、地裁では無罪になったが今年2月の高裁判決では逆転有罪となってしまった。
自分はこのケースを無罪になるべきだと思っている。
詳しい説明は日本ハッカー協会の寄稿などがわかりやすいと思うので、ここでは詳しく解説しない。

Coinhiveに関しては知り合いでも有罪になるべきではないか、という意見の人が少なからずいたりする。
そういう主張の背景にはCoinhiveを設置するのは金儲けのためで悪意に満ちたものという思い込みであったり、あるいは倫理観にかけた行為であるという点でそういう意見になっていると思っている。
しかしながら、ウェブアプリケーションを運営するにあたってなんらかの収益化は必要不可欠だし、倫理観にかける=違法であるわけではないしそこは議論をきちんと分けるべきと思う。
もうちょっと踏み込むと、そもそも倫理観にかけた行為であったのか、という点も議論の残る点ではある。
例えば有名なセキュリティソフトの開発元のNortonは、自社製品でこのようなマイニングスクリプトをブロックするようにしてはいるものの、100%悪として断罪はできないだろうとしている。

そもそもウェブ関連の倫理観というのはまだまだ成熟が甘く、どこからがアウトでどこまでがセーフかという線引きが難しい事案は多くあると思う。
他の産業の倫理観を引き合いに非難するというのも、ブラウザというサンドボックス内で実行されるアプリケーションという性質を考えると適当ではないだろう。
もっとも今ではブラウザ標準でこのようなスクリプトは弾かれるようになっているし、こういう手法は今なら倫理的ではないと言えるかもしれない。

たしかに、ウェブ関連のアプリケーションはかなりの大手ですら倫理的にどうなの、と思うようなことを平気でやっていたりするので、健全な状態とは言い難いとは思う。
しかしながら、このようにぽっと出の技術を使った個人開発者を叩いても、このような現状が好転するようなながれにはならないとは思う。

来年について

もうそろそろ若手とは言い難い年齢になってきたので、できるだけ多くのことに今のうちに学んでいけたらと思っている。
具体的には

  • 実際に広く使われている組込みRTOSの仕様や実装を学ぶ(Rustに限らない)
  • ARMv8-Aでのベアメタルプログラミング
  • ネットワークスタックについての理解
  • ウェブアプリケーションのセキュリティ施策
  • OSSに関するより粒度の大きい貢献

あたりが面白いテーマかなと思っている。おそらく全部をカバーするのは難しいと思うけど。

あとはコロナの影響で難しくなってきてはいるが、人との交流は増やしたいとぼんやり思っている。
もともと、人と積極的に親しくするというのは苦手で、所属しているグループで孤立するということはないのだが、例えばプライベートで会って遊ぶみたいな機会はほとんどなかったりする。
人との距離を見誤り怒られたり、立ち入ってはいけない領域に踏み入れてしまうということも、ちょっと前だと少なくなかったように思う。
最近だとないがそれは単に人との距離を詰めるのをさぼっているだけ、という気もする。
人との交流が少なくなると自分の精神的にもよくないというのは感じているので、なんらかの方法で増やせないかなあと思うのだが、はてさてどうすればいいのやら。

今年もあと数日残っているし、この年末に大きなニュースが入ってくるというのも珍しくないのだが、暇なので振り返ってみた。
来年も引き続き技術的なことを発信できていけたらなあと思う。

RustのSTM32向けイーサネットドライバを解説する(受信編)

この記事は自作OS Advent Calendar 2020の20日目の記事です。

RustでSTM32ボード用の自作OSをしていて、アプリケーションの幅を増やしたくて、イーサネットドライバを組むことにしました。
使用しているのはNucleo-429ZIボードで、イーサネットモジュールが付属しています。
去年から取り組みはじめてはいたものの、今までのペリフェラルとは違い仕様が複雑で何度も挫折して中断しまくったのですが、ようやく受信部分だけ完成したので解説していきたいと思います。

参照するコードは自分が組んだコードでもよかったのですが、送信部分がバグっているのと、参考にしてきたstm32-ethクレートのほうがずっと出来が良いのでそちらを使います。
なお、バージョンはv0.1.2とちょっと古いバージョンです。なお、stm32-ethのこのバージョンではいくつか立てる必要のないと思われるビットを立てていたりするので、実際に参考にする場合はちゃんと仕様書で確認しながら見ることをおすすめします。

仕様書を入手する

仕様書なくしてドライバはつくれません。今回はCPUのリファレンスマニュアルとデータシート、ボードのユーザーマニュアルがまず必要です。
更に、イーサネット通信は外部物理層(PHY)に対してIEEE 802.3で定義されているインターフェスを介してCPUのイーサーネットモジュールと通信することで実現されています。
このPHYに関するマニュアルも必要です。ユーザーマニュアルによるとこのボードではLAN8742A-CZ-TRを使っているとのことなので、これの仕様書も入手しましょう。

セットアップ

イーサネットモジュールを使うにはまずGPIOやクロックの供給などの初期設定をする必要があります。
GPIOのピンのうちいくつかを適切なAlternateモード設定するのですが、リファレンスマニュアルの方の33.3章のTable 185にピンの対応関係が書いてあります。
しかし、これをよく見ると複数のピンに同じ機能が割り当てられているのがわかると思います。実はボードのユーザマニュアル6.11章にこのボードで利用可能なマッピングがちゃんと書かれています。
なので、ピンの設定はボードのマニュアルを参照しましょう。
Alternateモードの11がイーサネット用のモードです。設定方法は8章のGPIOの章を参照しましょう。

PHYとの通信方法はReduced Media-independent Interface(RMII)と呼ばれる方法を使います。これはMIIより少ないピン数で通信できる方法です。
リファレンスマニュアル33.4.4で書かれているように、RMIIを使うにはSYSCFG_PMCレジスタで23ビット目を立てる必要があります。

クロックの供給はSYSCFGとイーサネットモジュール、使うGPIOに対して行う必要があります。GPIOはA,B,C,Gを使います。

stm32-ethでこれらのことをやっているのが、src/setup.rssetup関数とsetup_pins関数となっています。

イーサネットモジュールの初期化

続いてイーサネットモジュールの初期化を行います。stm32-ethではsrc/lib.rsEth::initに相当する部分です。
この初期化には送信に関係する設定もおこなっている場合があります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
fn init(&mut self) -> &Self {
self.reset_dma_and_wait();

// set clock range in MAC MII address register
let clock_range = ETH_MACMIIAR_CR_HCLK_DIV_16;
self.eth_mac.macmiiar.modify(|_, w| unsafe { w.cr().bits(clock_range) });

self.get_phy()
.reset()
.set_autoneg();

// Configuration Register
self.eth_mac.maccr.modify(|_, w| {
// CRC stripping for Type frames
w.cstf().set_bit()
// Fast Ethernet speed
.fes().set_bit()
// Duplex mode
.dm().set_bit()
// Automatic pad/CRC stripping
.apcs().set_bit()
// Retry disable in half-duplex mode
.rd().set_bit()
// Receiver enable
.re().set_bit()
// Transmitter enable
.te().set_bit()
});
// frame filter register
self.eth_mac.macffr.modify(|_, w| {
// Receive All
w.ra().set_bit()
// Promiscuous mode
.pm().set_bit()
});
// Flow Control Register
self.eth_mac.macfcr.modify(|_, w| {
// Pause time
w.pt().bits(0x100)
});
// operation mode register
self.eth_dma.dmaomr.modify(|_, w| {
// Dropping of TCP/IP checksum error frames disable
w.dtcefd().set_bit()
// Receive store and forward
.rsf().set_bit()
// Disable flushing of received frames
.dfrf().set_bit()
// Transmit store and forward
.tsf().set_bit()
// Forward error frames
.fef().set_bit()
// Operate on second frame
.osf().set_bit()
});
// bus mode register
self.eth_dma.dmabmr.modify(|_, w| unsafe {
// Address-aligned beats
w.aab().set_bit()
// Fixed burst
.fb().set_bit()
// Rx DMA PBL
.rdp().bits(32)
// Programmable burst length
.pbl().bits(32)
// Rx Tx priority ratio 2:1
.pm().bits(0b01)
// Use separate PBL
.usp().set_bit()
});

self
}

まずは、DMAのソフトウェアリセットをかけています。DMABMRレジスタのSRビット(ビット0)をセットするとDMAコントローラのソフトウェアリセットになり、
リセットが終了すると自動的にクリアされるのでそれを待ちます。

次にPHYモジュールとアクセスするための設定をしていきます。まずは、MIIでPHYのレジスタにアクセスするための下準備として、MACMIIARレジスタのビット4:2でクロックの範囲を指定します。
データシートの3.31章によると、25MHzで動作するようなので、0b010にセットすればよさそうです。
PHYモジュールのレジスタアクセスは、MACMIIARに読み書きしたいレジスタ番号と読み書きのモードを指定してMACMIIDRに書き込みなら自分でデータを書き込み、読み込みならばこのレジスタに値がPHYから書き込まれます。
動作の完了はMACMIIARのMBビットがクリアされることによりわかります。

具体的なPHYモジュールのレジスタの説明はLANの仕様書の4.2章からたどることができます。
Basic Control Registerの15ビットをセットすることでソフトリセットをしたのち、12ビットと9ビットを立てることでAuto-Negotiationを有効にすることで、
ハードウェア側で勝手にパラメータ調整を任せることができます。
そのあと、PHY special control/status register(31)の12ビットが立っていれば、auto-negotiationが完了したことがわかります(が、stm32-ethでは確認していないようです。いいのかな?)。

あとは、ペリフェラルのMAC側とDMA側の設定をおこなっていくことになります。
MACCRのCSTF・FES・DM・APCS・RD・RE・TEビットを立て(RDビットはDMビットを立てて全二重モードにしているのでおそらく無視されている)、MACFFRのRA・PMビットを立て、MACFCRのPTフィールドでポーズ時間を設定し、DMAOMRでDTCEFD・RSF・DFRF・TSF・FEF・OSFを立て、DMABMRのAAB・FB・RDP・PBL・PM・USPフィールドの値を設定しています。
これらのフィールドすべてを解説するのはしんどいので、マニュアルを参照してください。

受信用ディスクリプタとバッファの用意

イーサネットのデータはDMAを介してメモリに読み書きされます。そのためのディスクリプタと呼ばれるメモリ領域を確保しないといけません。また、送信されてきたデータが書き込まれるバッファも必要です。これらをリングバッファとしてDMAは利用します。
リファレンスマニュアルでは33.6.8で解説されています。今回はNormal Rx DMA descriptorsを使います。

stm32-ethではsrc/rx.rsRxRingという構造体がこのリングバッファを抽象化したものです。RxRingEntryが各ディスクリプタとそれに対応するバッファを持っています。
src/lib.rsEth::newでこれらの初期化をしたのち、DMAレジスタに値を書き込んでこのリングバッファを使うようにしています。

初期化処理で必要なのは

  • RDES0のOWNビットを立てることで、DMA側にディスクリプタの所有権を譲る
  • RDES1のRBS1でバッファのサイズを指定し、RCHビットを立ててセカンドアドレス連鎖を有効化しておく
  • RDES2にバッファのアドレスを登録
  • RDES3に次のディスクリプタのアドレスを登録する。リングバッファの最後のエントリの場合、RDES3は設定せず、RDES1のRERビットを立てる

です。またディスクリプタは8バイトにアラインされている必要があります(ワードアライン)。

アラインメントを実現するために、stm32-ethではalignedというクレートを使ってアラインメントを保証しています。
また、RxRingEntryにはバッファとディスクリプタが対となって格納されていますが、必ずしも対になっている必要はなく、RDES2にきちんとアドレスを格納しておけば基本的にRAMのどこでも構いません。

DMARDLARレジスタに先頭のディスクリプタのアドレスをいれ、DMAOMRのSRビットを立てれば、受信用のDMAの設定は完了です。

DMAからデータを受信する

DMAからデータを受信してみましょう。本来は正しく設定して、データが来る毎に割り込みを発生させて処理させるのがいいのでしょうが、今回はポーリングで行きます。
RxRing::recv_nextを見てみましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub fn recv_next(&mut self, eth_dma: &ETHERNET_DMA) -> Result<RxPacket, RxError>
{
if ! self.running_state(eth_dma).is_running() {
self.demand_poll(eth_dma);
}

let entries_len = self.entries.len();
let result = self.entries[self.next_entry].take_received();
match result {
Err(RxError::WouldBlock) => {}
_ => {
self.next_entry += 1;
if self.next_entry >= entries_len {
self.next_entry = 0;
}
}
}

result
}

まずはDMASRのRPSフィールドをみて受信処理状態を見ています。
もし、実行中になっていない場合は、DMARPDRに1をセットして受信ポールを要求します。

take_receivedは受信が完了しているエントリを取り出すメソッドになっています。
受信完了すると、RDES0のOWNビットがクリアされCPU側に渡されたことが示されています。
また、FSビットとLSビットをみてこのディスクリプタに対応するバッファにすべてデータが入っているかを確認しています。
バッファの長さより長いデータが来た場合は通常は次のエントリに続きのデータが格納されています。
しかし今回、バッファのサイズはデータシートにかかれているVLANフレームの最大長の1522バイトなので、2つのディスクリプタにデータがまたがることを想定しないつくりになっているようです。

take_receivedRxPacketという構造体が返されていて、Derefによって、データが格納されたバッファへのスライスへの参照に型強制させることにより、読み込みが可能になります。
RxPacketからはディスクリプタやデータが格納されていないバッファへの操作はライブラリ外からはできないようになっている、というわけです。

エントリを使い終わったら本来であればOWNビットを立て直すことでリングバッファに復帰させる必要がありますが、recv_next内ではこれからバッファのデータを読み込むわけなのでそれができていません。
ではどうするかというと、DropとしてRxPacketがライフタイムを終えるとOWNビットが立てられるようになっています。

1
2
3
4
5
impl<'a> Drop for RxPacket<'a> {
fn drop(&mut self) {
self.entry.desc_mut().set_owned();
}
}

これぞRustの力、という感じでこのあたりの設計は非常に参考になりました。

テスト

受け取ったパケットをシリアルで垂れ流す、という方法でテストしました。
適当にLANにつなげば何かしらのパケットは流れてくるし、そうでない場合は、pkttoolsなどを使いわかりやすいパケットを流せばいいと思います。

以下はstm32-ethを使ったサンプルコードです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#![no_std]
#![no_main]

use stm32f4xx_hal::{
gpio::GpioExt,
stm32::Peripherals,
serial::config::Config,
serial::Serial,
stm32::RCC,
rcc::RccExt,
time::{Bps, U32Ext},
};
use stm32_eth::{Eth, RingEntry};
use cortex_m_rt::entry;
use core::fmt::{self, Write as FmtWrite};
extern crate panic_halt;


#[entry]
unsafe fn main() -> ! {
let p = Peripherals::take().unwrap();

// Setup pins and initialize clocks.
let gpiod = p.GPIOD.split();

stm32_eth::setup(&p.RCC, &p.SYSCFG);
let gpioa = p.GPIOA.split();
let gpiob = p.GPIOB.split();
let gpioc = p.GPIOC.split();
let gpiog = p.GPIOG.split();
stm32_eth::setup_pins(
gpioa.pa1, gpioa.pa2, gpioa.pa7, gpiob.pb13, gpioc.pc1,
gpioc.pc4, gpioc.pc5, gpiog.pg11, gpiog.pg13
);
// Allocate the ring buffers
let mut rx_ring: [RingEntry<_>; 8] = Default::default();
let mut tx_ring: [RingEntry<_>; 2] = Default::default();
// Instantiate driver
let mut eth = Eth::new(
p.ETHERNET_MAC, p.ETHERNET_DMA,
&mut rx_ring[..], &mut tx_ring[..]
);

let rcc = p.RCC.constrain();
let clocks = rcc.cfgr.freeze();
let pd8 = gpiod.pd8.into_alternate_af7();
let pd9 = gpiod.pd9.into_alternate_af7();
let config = Config::default().baudrate(115_200u32.bps());
let mut serial = Serial::usart3(p.USART3, (pd8, pd9), config, clocks).unwrap();
let (mut tx, _) = serial.split();

loop {
if let Ok(pkt) = eth.recv_next() {
for p in pkt.iter() {
tx.write_char(*p as char).unwrap();
}
}
}
}

感想

仕様書だけでは何をすればいいのかを読み解くのが大変で、他の実装を参考にしながら手探りで実装していったのでそれなりに大変だった。
送信部分もこみで解説するつもりだったが、受信部分だけでもそれなりのボリュームになったので、まあ、これはこれでいいかな、と思っている。
近日中に送信部分も解説記事を書きたい。

ISUCON10決勝に参加しました

ISUCON10予選でなんと勝ち抜くことができたので、決勝に参加しました。
チーム名は「勉強不足の分は有り余る才能でカバーしようかなと思っております」で、チームメイトは@kenkooooさんと@GolDDranksさんでした。
今回もフルリモートでの参加になりました。
決勝に残ったチームとしては唯一のRustを使うチームになりました。

まだ、公式には点数が公表されていませんが、おそらく再起動試験で落ちて失格扱いだと思います。
再起動試験に通っていても競技中の最高スコアは11411点程度だったので入賞には程遠かったと思います。

今回は学生の一人チームが優勝したということでかなり驚いています。去年の予選は一人で参加して本当に何もできなく、
今回の決勝も対処すべき問題がいろいろと散りばめられているので、一人で全部なんとかしてしまうのは本当にすごいと思います。

当日やったこと

自分の視点からやったことを振り返ります。

8時半頃:起床。今回は前日にちゃんと睡眠がとれてコンディションはよいように感じた。

10時の競技開始前まで:朝食、朝のリングフィット、出前や飲み物などの準備

10時:今回は予定通りに競技がスタート。運営の皆様、お疲れ様です。

いつもどおりansibleスクリプトを走らせたり、マニュアルをチェックしたり、サーバーの構成を確認したり。
Ubuntu20.04になっていたことでうまく動かないところもあったが、なんとか修正してセットアップ。

今回はMySQLのバージョンも8にデフォルトでなっている他、サーバーはいつものnginxではなくenvoyになっているのでアクセスログのセットアップにかなり手間取ってしまった。
また、サーバーは3台与えられたが、スペックがそれぞれ異なり1台目は1GBのメモリに2コアのCPUだったが、2台目は2GBのメモリが、3台目は4コアにと増強されていた。
メモリが少なくてansibleスクリプトを走らせながらだとベンチマークが通らなかったりした。
とりあえず、メモリが多い2台目をメインのサーバーとして使うことにした。
Rust実装に切り替えてスコアは7000点程度という感じだった。

12時頃:アクセスログが取れるようになり、ボトルネック分析ができるようになる。
その間、@GolDDranksさんがダッシュボードAPIのキャッシュを導入したり、@kenkooooさんがチーム上限をいじったりしていた。
チーム上限をいじって異常に負荷をかけるとenvoyが途中で落ちてしまい、ベンチマークが動かないことがわかったので、下手にいじらないことにした。

12時半頃:SQLのログを見て適当にindexを張ってみる。今回は予選での反省を活かし、pt-query-digestを活用して改善が見込まれそうなクエリを探していった

13時15分頃:サーバー複数台の準備。コア数の多い3台目をアプリケーションに、2台目をSQLサーバーにという構成にしてみる。

実は初期実装に微妙なバグがあって、SQLサーバーのホスト名の参照すべき環境変数が間違っておりちょっと手間取る。
ベンチを回してみたところ、2台目をアプリケーションに、3台目をSQLサーバーにしたほうがよさそうだったので、とりあえずそういう構成に。
最終的にスコアが9928まで上がる。

14時50分頃:ダッシュボードAPIのキャッシュが導入されるもベンチは通らず。キャッシュの生存判定が緩すぎたっぽい

15時10分頃:Rust側でgzipを有効化。スレッド数の変更も試したが、かえってスコアが落ちるのでやめておく。
SQLのログを見ていると、コンテスト情報をとってくるためのSQLが頻繁に呼ばれているので、それをアプリケーション側でなんとかすることを思いついたので実装開始。

16時半頃:ダッシュボードAPIのキャッシュの生存判定をいじって1万を突破

16時50分頃:自分のコンテスト情報をアプリケーション側でキャッシュする実装が導入される、が、あんまりスコアが伸びない。
ダッシュボードAPIのキャッシュが効いて、そもそもこのSQLがそんなに呼ばれなくなったことが原因か

17時頃:3台目が余裕がありそうなのに対し、2台目がキツキツだったので、構成を再度逆にしようとする。
が、ベンチを回してみたところ、サーバーがフリーズ。メモリが枯渇したらしい。運営にサーバーを強制的に再起動してもらって、その後スワップメモリを導入しておく。

17時20分頃:@GolDDranksさんが一部のSQLのロックの除去を試みるも、ベンチ通らず。
サーバー構成変更も原因不明のエラーが出てしまってうまくいかなかったので、諦めて再起動試験やログを切るなどの最終調整に移行する。

17時50分頃:ベンチマークガチャを、と思い何回か回すが、直後に今回は追試でのスコアが最終スコアということを思い出し、無意味ということに気がつく。
とはいうものの、もうやれることもないので、撤退。最高スコアは11411点でした。

20時頃:結果発表。特に賞はもらえず。

後にコンテストサーバーに再度ログイン可能ということで調べたのだったが、なぜかブラウザからの動作確認ができないことに気がつく。
原因を追跡すると、自分の導入したコンテスト情報のキャッシュロジックが間違っていることに気がつく。
このキャッシュロジックは/initializeをするときにキャッシュに値を挿入するのだが、アプリケーションを再起動するとこのキャッシュの値は当然消える。
ベンチマークを実行している間はアプリケーションは再起動しないし、絶対/initializeが最初に呼ばれるのでエラーは起きないのだが、
今回の再起動試験は、サーバーを再起動させた直後ブラウザからの動作を確認する、というものが含まれていて、ブラウザから動作確認する場合は/initializeは当然呼ばれない。
そのため、サーバーがコンテスト情報をDBから取得できずにエラーを返してしまうため、おそらく失格になったと思われる。

感想・反省

再起動試験に落ちなかったとしても、1位が4万点台のスコアを叩き出していることを考えると、優勝までは程遠かった、ということがわかる。
サーバーは2台しか活用できなかったし、それもスペックをフルに活かせていない構成だったので、このへんの構成を変えただけでももっとスコアは伸びたと思う。
DBの詳細なチューニングも十分にする時間はなく、かなりやることが多いな、という印象でした。

envoyに振り回されたところもあるので、nginxなどある程度経験のあるものへの切り替えも視野に入れるべきだったかもしれません。が、切り替えられるほど習熟しているかどうかも怪しく、踏み切れませんでした。

戦略もあんまり正しくなかったかもしれません。もうちょっと俯瞰的にどこがボトルネックになっているかを手を動かす前に詳細に分析してあげることも必要だったと思います。
例えば、今回のアプリはAPIアプリとベンチマーカーの2つの構成となっていたのですが、このうち片方を別サーバーにしてあげるとかでも負荷分散ができたはずです。
どうやったらサーバー3台を活かすことができたのかがおそらく勝負の鍵だったと思うので、DBを分ける、程度しかできなかったのは敗因として大きかったと思います。

あとは、マニュアルをもっと読み込んで、再起動試験を真面目にやるべきでした。再起動後のベンチマーク試験のパスは確認していたのですが、ブラウザからの追試は完全に抜けていました。

優勝したチームは一人で全部やったらしいですが、そういう超人でない以上、チーム内での役割分担とかはもうちょっとなんとかできたかもしれません。
ただ、即席チームで決勝までこれたのはかなりよかったのかなあと思います。
来年も機会があれば、なんらかの形で参加したいです。

最後に、毎年このコンテストを開催してくれる運営の皆様、ありがとうございました。今回はRustという自分が好きな言語での実装が提供されてとても楽しかったです。

ISUCON10 予選参加記(予選通過しました)

ISUCON10にチーム「勉強不足の分は有り余る才能でカバーでカバーしようかなと思っております」で@kenkooooさんと@GolDDranksさんと参加して、予選通過しました。
一昨年は、違うメンバーとですが出場したものの予選敗退、昨年は一人でNode.jsで参加するもののGoの参考実装のスコアすら超えられない悲惨な結果でした。
今回はRustでの参考実装が提供されそうだったというのと、Go言語を今更勉強するのもなあという気持ちもあったところに、
@kenkooooさんがRustを使うチームメイトを募集していたので誘いにのり、Rustにしました。

事前準備

事前練習としてISUCON9の予選問題をGoからRustに切り替えつつ最適化する、という練習をしていました。
ここでactixやsqlxの使い方を勉強しつつ、ISUCONで必要になる計測やチューニング方法の確認をしました

計測ツールとしてはnginxのログをalpで解析する方法と、NewRelicの使い方を確認しました。
また、CPU使用率やメモリ使用率などをリアルタイムで監視できるnetdataも用意しておきました。

デプロイの手順も確認しておいて、チームメンバー間でのコードを共有はgithubのレポジトリを介してやりましたが、サーバーへのデプロイは手元のビルド生成物をrsyncでデプロイする形式をとりました。
理由としてはコンパイルがそれなりにサーバーに負荷をかけること、サーバーに入ってgit pullしたりするのはいろいろと面倒な場面があることが上げられます。

/etc以下のファイルはetckeeperで管理しておきます。サーバーごとに変えたいとか権限の問題もあるので、特にリモートレポジトリでの管理はせず、ダイレクトに編集します。

事前にサーバーの環境はUbuntuであるとわかっていたので、例年通り自前のansibleスクリプトで各種ツールの導入や基本的なbashrcやvimrcの導入もできるようにしておきました。

当日の流れ

自分の視点から、当日の流れはこんな感じになった。

8時位:起床。入眠失敗してかなりつらい

9時位:PCを起動して支度をしていると、開始が2時間延期になったことを知る

12時位まで:リングフィットしたり、AtCoderの問題といたり、トイレの便座カバーを洗濯したり、お昼の出前をとったり

12時20分:競技開始。しかし、ポータルの不具合によりすぐにはベンチマーカーは走らせられない状態とのこと。

事前に用意しておいたansibleスクリプトを走らせて基本的な設定は済ませつつ、ゆっくりとコードを読んだりマニュアル読んだり、開発環境を整えたり。
各サーバーにsshできること、データベースにも接続できることなどを確認。
@kenkooooさんがgithubにウェブアプリのコードを共有し、各エンドポイント用の関数を別ファイルに切り出すことで編集がしやすいように手配

13時15分頃:ベンチマークが走らせられるようになる。

初回実行時のスコアは537
ベンチマークを走らせた結果をnginxのログからalpでどのエンドポイントがボトルネックになるかを観察。
/api/estate/searchなどは回数が多いが、一回の実行あたりの時間が長そうなのは/api/estate/nazotteだな、などとあたりをつける。

自分は/api/estate/searchのコードを見るものの、例年みたいにN+1問題のような感じでもなく、純粋にSQLクエリが重いだけっぽい。
ということで、インデックスを適当に張ってみたり、MySQLの設定を見直すということに注目する。

14時35分頃:@kenkooooさんの手によって、server2をMySQL専用にして、server1をウェブアプリ専用にする設定が導入される。server3はこの段階では使ってない。

ポータルが不安定だったり、nginxのログ設定がうまくいってなかったり、アプリケーションの挙動の確認などで、ここらへんはわりとあたふたしている。
@GolDDranksさんがボットフィルター入れたりなどの変更はあるものの、大きなスコアの変化はなし。

15時45分頃:nginxの設定を見直してみる。

静的ファイル配信周りに最適化の余地がありそうだったが、よくよくレギュレーションを見ると、/api以下のパフォーマンスしか見られないらしいのでスルーする。
gzipとかが有効になってなさそうなので、Rustアプリ側で圧縮を有効化してみたり、ワーカーの数を4にしてみたり。
ワーカー数はデフォルトだとCPUの数に等しくなり、今回は1コアだったのでワーカーが1つのみになるので、これだとIO待ちとかが有効活用されないのでは、と思って4にしてみた。
が、この辺の変更でもスコアは大きくは変わらず。500点台が続く

16時10分頃:@kenkooooさんのnazotteの変更が入る。

この辺からスコアが伸び始める。インデックスを張ったりすることで700点台に入る。
この段階でベンチマークを回すときにnetdataのダッシュボードを見るとMySQLサーバーのCPU使用率が非常に高いことに気がつく。一方でアプリ側は余裕がある。
メモリの使用率も全体としてはまだ余裕がある。この辺に注目すればスコアが伸びるのでは、と予想をつける。

16時50分頃:DB2台体制の準備に取り掛かる

MySQLサーバーのCPU使用率がボトルネックになってそう、ということは、サーバー数を増やせばスコアが伸びるのでは、と考えた。
今回のクエリはテーブル間でJOINすることでスコアが伸びることはなさそうだったのと、テーブル数はたったの2つのみ。
ということは、各テーブルごとに専用のサーバーを立てれば、比較的容易に負荷分散ができそうだ、ということに気がついて実装に取り掛かる。

その間に他のチームメイトはアプリケーション側でデータをキャッシュすることなどで高速化を測り、スコアが1000点台に乗り始める。

18時半頃:使ってなかったserver3を追加してDB2台体制が整う。

この時点でのスコアは1775。一気に伸び始める。

18時50分頃:MySQLの設定を見直していたら、実はクエリキャッシュがきいていないことに気がつく

query_cache_sizeがデフォルトで正の値が設定されていたので、てっきり有効になっていると思ってましたが、
query_cache_typeがデフォルトだと0に設定されて無効になっていました。1に変えて有効化すると、なんとスコアが2465に一気に伸びる!
キャッシュサイズが大きすぎるとCPUの負荷も上がるかもなあと思って、大きくは変更しませんでした。

19時頃:時々、ベンチマークが落ちるようになる

アプリケーション側のキャッシュロジックがかなり怪しかったので、ここを他のメンバーが見直しつつ、自分はSQLの設定をいじったり、インデックスの貼り方を見直したり。
MySQLTunerというのを使ったんですが、どうもサーバーが貧弱すぎるせいか、まずRAMを増設せよ、みたいなアドバイスが出てきたりして、あまり活用できませんでした。
インデックスの仕組みも自分があまり勉強したことがなかったため、適当な複合インデックスを貼ったりはしていたのですが、果たして効果があったかはよく検証できませんでした。

20時頃:@kenkooooさんが離脱

まだ、アプリが不安定な状態が続いていたので、アプリケーション側のキャッシュロジックを全部取り外すことに。
一時的に3000点台も記録しましたが、キャッシュロジックを取り除くと2500点付近まで落ちてしまうことに…
とはいうものの、残り時間もわずかで、凍結前のスコアボードを見るに、この点数でも十分に決勝に残れそうだったのでこのままいくことに。
本当は再起動試験を真面目にやりたかったのですが、再起動の手順を確認しておらず、万が一再起不能になると運営からの救済も厳しい時間帯だと思ったので、行わないことに。
ただ、設定はすべてファイル経由でおこなっていたし、systemdのRust側のサービスのみが有効になっていることだけを確認はしておいたので、まあ大丈夫だろうと。

netdataなどを落としたり、ログのレベルを落としたりして、スコアガチャの時間に。2684点が終了10分前あたりに出て、ここで作業ストップ。
不必要なsshコネクションを落としたりして、天命を待つことに。

24時頃:結果発表。予選通過!

正直、今までの結果もそこまで良くなかったので、まさか通過できるとは思ってませんでした。
チームも即席で、チーム内のコミュニケーションもすべてテキストチャットで不安な面もありましたが、意外となんとかなりました。
この手のプログラミングコンテストでここまでいい成績を残せたのははじめてな気がします。
本戦も引き続きがんばっていきたいです。

反省

良かった点

  • テキストチャットのみでも比較的コミュニケーションはとれる
    • ボイスチャットだと不必要なインターセプトも入る可能性があるので、テキストのみというのは意外と悪くないかも
    • 個人の好みや場面に依存はすると思う
  • エンドポイント毎にファイルを切り出すのはよかった。見通しがよくなる
  • netdata等の監視ツールは大事。ansibleなどで自動で入れられるとすごく楽
    • NewRelicも用意はしていたが、今回は活用できませんでした
  • お昼ご飯はとても大事。今回はスポンサーである出前館を利用として、出前を注文しておきました
    • 参加費と思って、ちゃんといいものを食べましょう

悪かった点

  • 再起動試験の手順はもっと早くに確認しよう
  • MySQLのインデックスについてなど、ちゃんと勉強しよう
    • 5.7では降順インデックスは存在しないらしい。8では存在する
    • Generated Columnをソート用に使うというテクニックもあるらしい
      − 他にも不要なSELECT FOR UPDATEを取り除ける、というのも見逃していた
      − 前日の睡眠はきちんととりましょう…

9/14 追記

チームメイトの@kenkooooさん視点の参加記です。

他のチームの参戦記もいろいろ読ませてもらって、自分たちのできてなかった改善点はだいたいこんなところだと思います

  • 実行結果をアプリ側でキャッシュする
    • low_pricedが主か。自分たちもやろうとしたが、バグらせてしまい断念
  • MySQL 8.xの使用
  • nginxのclient_body_buffer_sizeの調整
    − nginxのログをちゃんと見ると警告が出ていたらしい
  • MySQLの真面目な解析
    long_query_time=0として全部スロークエリとしてログに吐かせたあとpt-query-digestで解析するといいっぽい
    • EXPLAINを使うとインデックスがちゃんと効いているか確認できる
  • recommended_estateWHERE句の最適化
    • 椅子の高さ・幅・奥行きのうち下位2つのみを取り出せば、ORによる連結が減らせる。GENERATED COLUMNを活用すれば、さらに減らせる

その他、他のチームの参戦記は以下の公式のブログにまとめられるそうです。

今回、はじめて初期実装としてRust言語が追加されたことに関しては運営の皆様とそれに協力してくださったボランティアの方に深く感謝します。
初期実装がない状態だと敗戦濃厚でした。また、実装自体もちゃんとしていて不利になることがありませんでした。

一方で、今回の大会ではポータルの不調が例年に比べると目立ち、多くのチームが混乱しているように見えました。
自分たちもキャッシュロジックの追加時、ポータルの不調に出会い、アプリが悪いかベンチマークが悪いかでかなり混乱してしまいました。
アクセスができない時間帯もありましたが、僕個人としては、そういう時間は冷静に次に何ができるかを見直せる時間になったりしたので、大きくは影響しなかったのかなと思ってます。
特に今年は予選が一日に集中している関係でかかる負荷が想定よりも重かったなどの事情もあったのでしょうか。
この点は少々、残念ではありましたが、毎年多大な労力をかけて開催していただけることは非常にありがたいと思っています。
運営の皆様には改めて感謝の気持ちを伝えたいと思います。ほんとうにありがとうございます。

Rustで組込みプログラミングや自作OS作成をするには

「Rustで始める自作組込みOS入門」という本というかドキュメントの公開を始めました。

これは以前からつくっていたErkOSという自作OSでの経験を元にして、どうやったらRustで自作組込みOSの最初の一歩を踏み出せるか、というものをドキュメントにしたものです。
このドキュメントはこの前の技術書展の告知が来たあたりから構想を練っていて、すきま時間にちまちまと書き進めていたものですが、とりあえず、プロセスの切り替えっぽいところまでの説明を終えることができたので公開しました。

組込みでRustをやる話や自作OSを書く話というのは先駆者がたくさんいて、僕自身もそれらの資料を参考にしつつ書き進めて来ました。
一応、それらの既存のものとは差別化はしているつもりではあるものの、既存のものを完全に上回るというものではないです。内容もまだまだ足りない。

以前、RustでOSを書くプロジェクトもろもろでいくつかOSを書く際に参考になりそうなプロジェクトをまとめましたが、情報も古くなってきたので、改めて参考になるプロジェクトを紹介していきたいと思います。

Redox

RustでデスクトップOSを書くというプロジェクト。完成度はかなり高く、GUIツールなどもすべてRustで書き直す、とかなり本格的。
最近のブログポストによると実際のハードウェアで動くための課題はかなりクリアできているようで、
今はコンパイラそのものを改良してRedox上でRedoxをビルドすることを目指しているらしい。

このRedoxプロジェクトから派生したクレートも役に立つものが多い

Tock

Rustで書かれたCortex-M向けの組込みOS。SOSPで論文を出していいたりとOSそのもの構造も興味深い。
かつてはRustコンパイラに手を加えたりするものが主流でしたが、このTockはnightlyコンパイラだけでコンパイルできるのも特徴。

最近、Googleが発表したOpenSKというUSBのセキュリティキーのオープンな実装を発表したが、そこで使われているのもこのTockです。

Rust Embeddedグループ

Rustにはいくつかの公式ワーキンググループがあって、そのうちの1つがこのEmbeddedワーキンググループです。
各種ドキュメントや組込み開発で使えるクレートを公開しています。
主にCortex-MやCortex-A、RISC-Vをターゲットにしています。

The Embedonomiconは一からベアメタルプログラミングするための手引になっていてとても参考になります。

RaspberryPiを使ったOS開発のチュートリアルはGICv2にも対応しているようなので、Cortex-Aで自作OSしたい人は参考になるかもしれません。

OS開発ということにフォーカスしているわけではないですが、個々のコンポーネントを書く際はかなり参考になります。

Rust OSDev

非公式グループですが、OSを書く、ということに焦点を置いたグループです。
こちらはx86系のアーキテクチャをたーゲットにしています。

UEFIのアプリケーションを書くのに便利なuefiクレートなど、x86系の自作OSに役立つクレートを多く出しています。

特にこのグループのメンバーの一人であるPhilippさんの自作OSブログは内容が充実していて、自作OSでのテストの書き方やヒープアロケータのデザインと実装といった他のアーキテクチャでも使える項目の解説も充実しています。

日本語の資料

今まで紹介したものはほとんどが英語のものでしたが、日本語のリソースとして@LDScellさんがEmbeddedグループのドキュメントの和訳を公開している他、
「組込み/ベアメタルRustクックブック」という資料を公開しています。

論文紹介:Firecracker: Lightweight Virtualization for Serverless Applications

元論文:https://www.usenix.org/conference/nsdi20/presentation/agache

2018年末、AWS Lambdaを提供するためのシステムとしてFirecrackerという仮想マシンモニター(VMM)がオープンソースとして公開されて注目を集めました。

この論文は、その中身を解説・評価する論文です。

論文概要

USENIX協会主催のNSDIで発表されました。
USENIXはもともとはUnix User Groupという団体でしたが、現在はシステムやネットワーク、セキュリティなどの会議やワークショップを開催しています。
そのなかでもNSDIはネットワーク系の会議としてトップクラスの会議の1つです。
書いている人たちはAWSの中の人のようです。

AWS Lambdaとは、いわゆるサーバーレスコンピューティングといわれるサービスで、イベントの発生ごとに実行されるアプリケーションを設定しておくと、サーバーの設定無しでアプリケーションを実行して応答を返してくれる、というものです。
このアプリケーションを動かすのに使われているフレームワークで使われているのがFirecrackerです。

この論文ではFirecrackerのことをVMMと呼んでいますが、KVMやXenのような「VMM」ではありません。これはちょっと私も混乱したポイントなのですが、ここでのVMMとは各ゲストOSに対してのサンドボックスを提供するためのコンポーネントというような位置づけです。
KVMとQEMUの組み合わせでは、QEMUがこのVMMに相当します。
このブログではVMMをこの論文の使い方にそうように使いますが、私は普段はVMMという言葉をXenやKVMのようなフレームワークに対して使っている(ハイパーバイザと同義の意味で使っている)ので、他の記事とは使い方が異なることに注意してください。
ちなみにFirecrackerはRust製です。

背景・動機

AWS Lambdaをつくるにあたり、以下のような性質を持つ仮想化フレームワークが必要とされました

  • 独立性:複数の関数が同一のハードウェアで走ってもセキュアであること(互いに干渉したり、権限昇格のようなことが起きない)
  • オーバーヘッドと密度:たくさんの関数が同一ハードウェアで動かせるよう、少ないオーバーヘッドで動かせること
  • パフォーマンス:ネイティブ実行に近い速度が出せること
  • コンパチビリティ:任意のLinuxバイナリやライブラリがコード変更や再コンパイルなしで動かせること
  • 高速な切り替え:古い関数実行をクリーンアップし、新しい関数を実行が素早く行えること
  • ソフトアロケーション:CPUやメモリのオーバーコミットが可能なように、関数は必要なリソースしか使わないようにする

現状の類似した独立した環境を提供する既存のものとして、コンテナ・仮想化・言語のVMを使った独立化を挙げています。
Dockerでお馴染みのコンテナは、seccomp-bpfでシステムコールを制限するという形でセキュリティを担保してますが、200を超えるLinuxのシステムコールを制御しなければならず、Linuxのシステムコールのバグの危険性もあります。
Xenなどを使ったハードウェア仮想化は、ゲストOSに完全独立な仮想のCPUを与えるという方法ですが、カーネルまるまるを使うとスタートアップの時間がかかり、メモリのオーバーヘッドも大きいため、密度を上げることが難しいことで知られています。
また、仮想化のフレームワークそのもののコード量が多いことも問題です(trusted computing base、すなわちTCBが大きい)。
Java Virtual Machine(JVM)などの言語のVMは、言語や機能が限定されるため、これも条件を満たしません。

そこで、Firecrackerの取る方法は仮想化のアプローチをベースとしたものになっています。
有名なKVMとQEMUを使った仮想化ではQEMUが非常にコードの大きな部分を占めていて、これを減らすことができればTCBも減らすことができます。
FirecrackerではこのQEMUを置き換えるというものになっています。

Firecrackの構造

Firecrackerの設計方針として、既存のモジュールを再実装するのは基本的に避け、できる限りLinux内のコンポーネントを利用するというものがあります。
これは実装コストを下げるというのと、サービスを運用する際、Linuxの知識がそのまま使えるというものがあります。

FirecrackerはKVMのインフラに乗っかりつつ、QEMUに変わるVMMとして実装し、その上で小さな仮想マシン(MicroVM)を動かすという設計になっています。
VMMはChrome OSのVMMであるcrossvmをベースにコードを削ったりリファクタリングして、現在は完全に別物として実装されています。

Firecrackerはデバイスとして、ネットワークやディスクなどのブロックデバイス、シリアルポートとi8042(PS/2キーボードコントローラ)のみをサポートしています。
このうち、ネットワークドライバとブロックデバイスドライバはvirtioをそのまま使うことでコスト削減に貢献しています

Firecrackerのプロセスを操作するインターフェースとしてREST APIを提供しています。これなら様々な言語から操作することが用意で、それこそcurlなどのコマンドラインツールでも操作可能になるからです。

各VMの独立性を保つためには、プロセスごとのレートリミットをつける必要があります。Firecrackerでは1秒ごとのオペレーション数をAPIから設定することができます。

セキュリティのためのJailerというものも実装されています。これはFirecrackerそのものが使えるインターフェースを制限し、ゲストがVMMの脆弱性をつくことを難しくするためのものです。

評価

大量のVMを走らせるという実験を、QEMUと最近Intelが発表したRust製VMMであるCloud Hypervisorとの比較をおこなっています。

まず、起動時間です。QEMUよりかはパフォーマンスがいいのですが、Cloud Hypervisorには若干劣ります。一方で、メモリのオーバーヘッドはFirecrackerが3MB程度なのに対して、Cloud Hypervisorが13MBなので、こちらはFirecrackerに軍配が上がります。
次にIO性能ですが、実装がいろいろ足りていないため、QEMUにも負けている部分がいろいろあるようです。

さて、はじめに掲げた目標は達成できているでしょうか?

  • 独立性:仮想化を用い、サイドチャネルの対策もした
  • オーバーヘッドと密度:大量のVMを低いオーバーヘッドで動かせることを確認した
  • パフォーマンス:改善の余地はあるが、十分なパフォーマンスを達成
  • コンパチビリティ:変更していないLinuxカーネルを動かせた
  • 高速な切り替え:起動時間が十分に短い
  • ソフトアロケーション:20倍のオーバーサブスクリプションをテストしたが問題なし

ということで、全部満たしていそうです。

感想

仮想化フレームワークとしてUnikernelを以前紹介したが、こちらはよりコンパチビリティを意識したアプローチになっていている一方、きちんとVMの密度を上げられているというのは面白いと思いました。
KVMのフレームワークに詳しくなくVMMという単語の使い方に困惑しましたが、調べてみると結構こういう意味で使っている場合が多いようでややこしいですね…っg

Rustでコマンドラインツールを開発する

コマンドラインツールをつくる際、今まではPythonやRubyなどのスクリプト言語をつかうことが多かった。
スクリプト言語はコンパイルする必要がなく、普通のシェルスクリプトよりかは書きやすいし、外部のライブラリも組込みやすい。
Java・ScalaなどのJVM系言語はC言語系よりかは書きやすいが、JVMの起動の時間が気になってしまいあまり向いていないように思われる。

しかしながら、スクリプト言語での開発にも問題はある。
動的に型がついて危ない、というのは小さくなりがちなちょっとしたツールでは比較的無視しやすいのでいいとする。
一番厄介なのは、実行環境のバージョン違いである。特にプロセス呼び出しで、同じ言語の他のツールを呼び出したりすると突然エラーを吐いたりすることがあった。

この前、社内でコマンドラインツールを書く機会があり、試しにRustで書いてみることにした。
一度コンパイルしてしまいバイナリにしてしまえば、バージョンの違いに苦しむことはないであろう。
変更のたびコンパイルする必要があるとはいえ、一度コンパイルさえしてしまえば高速に動作する。
実はRustの公式ワーキンググループの中にコマンドラインインターフェース(CLI)のためのチームがあり、ドキュメント・ライブラリが整備されている。

ドキュメントは優秀で、コマンドライン引数の処理やエラーハンドリングなどでどういうクレートを使えばいいかを紹介してくれている。

特にstructoptをつかったコマンドライン引数の処理は非常に簡単。シリアライズでお馴染みのserdeみたいに構造体にアトリビュートをつけていくと、コマンドライン引数を構造体に簡単に落とし込める。ヘルプメッセージなども簡単につくれるし、サブコマンドの定義なども柔軟に対応可能。

変更のたびコンパイルする手間であるが、cargo runコマンドを使うことである程度緩和できる。cargo runは変更がない限りは特にはコンパイル処理を行わないのでオーバーヘッドは小さい。
ただし、生で叩くのは面倒なので、以下のようなシェルスクリプトのラッパーを経由して呼び出すことにした。

1
2
3
4
script_path=$(readlink -f "$0")
manifest_path="$(dirname "$script_path")/Cargo.toml"

RUSTFLAGS=-Awarnings cargo run -q --release --manifest-path=$manifest_path -- "$@"

RUSTFLAGSはコンパイル時の警告を消すためにつけている。これをプロジェクトのルートディレクトリにおいておき、このスクリプトへのシンボリックリンクをパスの通っているところに配置すればコマンドラインから簡単に実行できる。
初回時のみ無言でコンパイルを行うが、その後はインクリメンタルにビルドしてくれるので、無言の期間は比較的短くてすむはずである。
もちろん、このようなスクリプトを使わずともバイナリのシンボリックリンクを置くとかでもよいが、そこはお好みで。

Rustで循環参照を含むグラフをArc/Rcを使ってつくる

Rustで循環参照を含む場合の処理はかなり面倒くさい。
基本的にミュータブルな参照は同時に1つしか持てないのだが、例えば双方向連結リストの場合、あるノードに対してその前のノードとその後ろのノードから参照される必要があるので、この場合どうにかしてミュータブルな参照以外の方法で前後のノードへのアクセス方法を確保する必要がある。

ということで、前回のRust LT会でそのテーマで発表した方がいらっしゃった

ここで用いられている方法はunsafeを使い、生ポインタとTyped Arenaという動的に同じ生存区間のメモリを確保できるライブラリを使う、または自作のメモリプールを使うというものである。
しかし、できればunsafeは使いたくない。さらにいうとstdだけで実現できると嬉しい。

Typed Arenaを使う方法は結構有名で、こちらのブログでも紹介されている

ただし、この方法の弱点はメモリを開放する手段がないので、一度ノードをつくってしまうとグラフ全体が生存している間は削除してもメモリが開放されない。
ライフタイムの管理も難しく、設計をうまくしてあげる必要もある。

他の方法としてはRc/Arcを使う方法がある。

Rcは参照カウンタ付きのスマートポインタでArcはそのスレッド安全版である。この方法ではRc/Arcのアクセスのための実行時オーバーヘッドおよび参照カウンタ分のメモリオーバーヘッドが発生するが、
代わりにライフタイムは比較的自由に管理でき、ノードのメモリも参照がなくなると自動的に開放してくれる。

注意点としてはRustはメモリリークに関しての保証はないため、Rcで循環参照をつくってしまうとメモリリークを起こすことになる。
そのため、ノード間の連結は弱参照Weakで実現し、ノード全体の強参照を保持する親構造体を持つことで、この問題を解決する。
この場合、ノードのコンストラクタは直接呼ぶのではなく、親構造体のメソッド呼び出しとして実態をもらうことになる。
また、ノード内部構造は晒したくないので、ユーザーが触るノード構造体の実態はノード内部構造体への弱参照のラッパになる。

弱参照から内部の値にアクセスするのは失敗する可能性があるので、ノードのメソッドは常にResult型で返してあげる必要がある。
また、ノード内部になんらかの値を保持させる場合、そこへのアクセスも少々厄介になる。
今回は、内部値への直接の参照を返すことは諦め、代わりにクロージャーを渡してもらうことにより、内部値の読み込み及び変更を実現した。
実際の実装は以下の通りである。先述のqnighy氏のブログにあった実装をベースにして、ノードの削除、子要素の取得、内部値の読み書きを追加したものである。
Arcを使っているのでスレッド安全でもある。

削除や値の読み書きに対応するために変更した点について説明していく。
ノードの削除はGraphの持っている強参照を削除しないとメモリが開放されないため、Graphのメソッドとして実装した。
強参照がVecのどこに格納されているかを指定するため、NodeInnerに位置を持っておき、それをもとに削除することにした。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub fn remove_node(&self, node: &Node<T>) -> Result<(), GraphError> {
let mut lock = self.0.lock().unwrap();
let rc = node.0.upgrade().ok_or(GraphError::NodeDead)?;
let id = rc.lock().unwrap().id;
if id < lock.nodes.len() {
let tar_node = lock.nodes[id].0.take();
if tar_node.is_none() {
Err(GraphError::InvalidNode)
} else {
lock.nodes[id].1 = lock.next;
lock.next = id;
Ok(())
}
} else {
Err(GraphError::InvalidNode)
}
}

Vecから特定の要素を削除するのはそこそこにめんどくさく、またそれにより他のノードのインデックスがずれると困るので、強参照をもつVecはOption型で値を持っておき、削除された場合はNoneに置き換えることにした。

1
2
3
4
5
6
7
8
#[derive(Debug, Clone)]
struct GraphInner<T> {
nodes: Vec<(Option<Arc<Mutex<NodeInner<T>>>>, usize)>,
next: usize,
}

#[derive(Debug, Clone)]
pub struct Graph<T>(Arc<Mutex<GraphInner<T>>>);

削除が頻繁に起こるとVecがNoneで埋め尽くされてしまうので、Noneになった箇所は再利用できるようにした。そのために、グラフには次のNoneの位置を持たせておき、各ノードにはその次のNoneの位置をもたせることで、
Noneの連結リストを構成することで要素の再利用を行うようにしている。VecにNoneが存在しない場合、末尾に要素を追加する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub fn new_node(&self, value: T) -> Node<T> {
let mut lock = self.0.lock().unwrap();
let next = lock.next;
let inner = NodeInner {
value,
neighbors: Vec::new(),
id: next,
};

let node = Arc::new(Mutex::new(inner));
if next < lock.nodes.len() {
let node_weak = Node(Arc::downgrade(&node));
lock.nodes[next].0.replace(node.clone());
lock.next = lock.nodes[next].1;
node_weak
} else {
let node_weak = Node(Arc::downgrade(&node));
lock.nodes.push((Some(node.clone()), next + 1));
lock.next += 1;
node_weak
}
}

ただし、あくまで強参照用の要素の再利用であって、ノードそのもののメモリ領域の再利用はしていないので、メモリ効率は改善の余地があると思われる。

ノードの値の読み書きであるがArcやMutexで囲われているので、そのまま参照を返すことはできない。
そこで、内部値の参照を受け取って処理をする関数をもらう、というメソッドをつくっておくことにした。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub fn read<V, F>(&self, f: F) -> Result<V, GraphError> where F: Fn(&T) -> V {
let rc = self.0.upgrade().ok_or(GraphError::NodeDead)?;
let lock = rc.lock().or(Err(GraphError::LockFailure))?;
let result = f(&lock.value);
Ok(result)
}

pub fn write(&self, val: T) -> Result<(), GraphError> {
let rc = self.0.upgrade().ok_or(GraphError::NodeDead)?;
let mut lock = rc.lock().or(Err(GraphError::LockFailure))?;
lock.value = val;
Ok(())
}

pub fn modify<F>(&self, f: F) -> Result<(), GraphError> where F: Fn(&mut T) {
let rc = self.0.upgrade().ok_or(GraphError::NodeDead)?;
let mut lock = rc.lock().or(Err(GraphError::LockFailure))?;
f(&mut lock.value);
Ok(())
}

注意点としては、渡す関数中で同一ノードへのアクセスを試みると、Mutexが衝突してデッドロックないしLockFailureが返ってしまう。
Nodeはノードの実態への弱参照であるため、複製が可能であるためにコンパイルそのものは通ってしまう。
また、弱参照であるために、削除されたノードである可能性が常に存在するため、すべてのメソッドはResult型で返ってくる。

弱点・TODO

強参照は1つしかないため、ノードの実態のメモリリークはないが、Arcの参照カウンタのための領域は弱参照が残り続けている限り開放されない。
ノード削除の際、他のノードとの連結情報までは削除をおこなっていないため、連結情報としてその弱参照は生き続ける可能性があるし、そもそもNode型自体は複製可能なので、別の箇所で弱参照が生き続けることもありえる。
一応、ノードの子要素の無効な弱参照は適宜消してあげる関数はつくっておいたが、どこで呼び出すかが問題となる。

また、異なるGraphのノードを識別できていないため、remove_nodeに別のグラフのノードを渡してあげることで挙動がおかしくなる危険性がある。
対策としてはノードになんらかのGraphの識別子をもたせ、それをチェックすることになると思われる(GraphInnerへの弱参照とか?)。

あとは、隣接ノード情報周りは作り込みが甘いので、もうちょっとなんとかしたい。

また、スレッド安全性を捨てればArcがRcになり、Mutexも取れるので実行時オーバーヘッドがマシになると思われるので、そのバージョンもつくってみたい。
あと、オーバーヘッドがどうのとか言っているが、ちゃんとパフォーマンス計測はしていないので、他のバージョンもつくってやってみたい。

進捗ダメです。

X11環境でIMEの仮入力の情報を取得する

LinuxのX11向けのGUIアプリケーションでテキスト入力を扱いたい場合、IMEの存在を意識しなければならない(特に日本人ならば)。
IMEなしならば、キーボードイベントを見て、そのキーのアルファベットをプリントするだけで済むが、IMEが存在する場合、IMEから送られてくる文字列をきちんと処理する必要がある。
そのためには、X11のIMEフレームワークであるXIMの仕様を知る必要がある。

これらの仕様について解説し、実際のコードも紹介している記事はすでにある。

上記の記事を参考にしたと思われる日本語の記事もある。

しかし、実はこれらの記事ではIME内で確定した文字列の情報しか取得しておらず、IME内でまだ確定していない仮入力状態の文字列(例えば変換前の元のひらがな)は取得できていない。
仮入力の文字列を取得できていないと、それらの文字列は当然描画できないし、例えば変換前の文字列を使った入力補完なんかも実装できない。
ある程度ちゃんとしたX11アプリケーションではこれらがちゃんとできていることからも想像がつく通り、XIMではこれらの状態を渡すインターフェースがある。

先述のブログのコードのうち、IMとの通信のためのコンテキスト構造体であるXICをつくっているところを見てみよう。

1
2
3
4
5
XIC ic = XCreateIC(xim,
/* the following are in attr, val format, terminated by NULL */
XNInputStyle, XIMPreeditNothing | XIMStatusNothing,
XNClientWindow, win,
NULL);

XNInputStyleの値としてXIMPreeditNothing | XIMStatusNothingを指定している。このXIMPreeditNothingというところが仮入力状態をどう扱いたいかをしてするものである。
XIMPreeditNothingは仮入力状態を渡すことなく動いてしまう。そのため、代わりにXIMPreeditCallbackを指定しなければならない。
このオプションを使う場合、クライアント側に仮入力の状態が変化した際のコールバックをXNPreeditAttributesとして渡してあげる必要がある。
コールバックにはXNPreeditStartCallbackXNPreeditDoneCallbackXNPreeditDrawCallbackXNPreeditCaretCallbackの4つがある。

  • XNPreeditStartCallback: 仮入力がスタートしたときに呼ばれる
  • XNPreeditDoneCallback: 仮入力が終了したときに呼ばれる
  • XNPreeditDrawCallback: 仮入力状態の文字が更新されたときに呼ばれる。呼び出されたときに変化した文字列の情報が渡されてくる。
  • XNPreeditCaretCallback: 入力カーソルの位置が変わったときに呼ばれる。呼び出されたときに入力カーソルの位置の変化情報が渡されてくる。

これらをXVaNestedList型としてXNPreeditAttributesとしてXCreateICに渡す必要がある。各コールバックはXIMCallback構造体へのポインタとして定義される必要がある。
XIMCallback構造体の定義は以下の通りである。

1
2
3
4
typedef struct {
XPointer client_data;
XIMProc callback;
} XIMCallback;

XPointerはchar *、つまり汎用のポインタで、XIMProcの定義は

1
2
3
4
5
typedef void (*XIMProc)(
XIM,
XPointer,
XPointer
);

となっている。第一引数はXIM型となっているが、こちらのドキュメントではXIC型となっている。どちらが正しいかわからないが、今回はこれは使わないのでスルー。
第二引数はXIMCallbackclient_dataが渡されてきて、第三引数にはコールバックの種類によって必要な情報がサーバー側から送られてくる(以後、call_dataと呼ぶ)。XPointer型を適宜キャストして使うことになる。

XNPreeditStartCallbackではcall_dataにはNULLが渡されてくる。XIMProc型の関数の返り値はvoidとなっているが、実際はこのコールバックは仮入力文字列の長さの上限を返さなければならない。-1とした場合、上限はない。
今回はこのように定義する。

1
2
3
4
5
6
7
8
9
static int preedit_start_callback(
XIM xim,
XPointer client_data,
XPointer call_data)
{
printf("preedit start\n");
// no length limit
return -1;
}

XNPreeditDoneCallbackcall_dataNULLが渡されてくる。今回は仮入力中の文字列を出力したいだけなので、特に何もしないでいいだろう。

1
2
3
4
5
6
7
static void preedit_done_callback(
XIM xim,
XPointer client_data,
XPointer call_data)
{
printf("preedit done\n");
}

XNPreeditDrawCallbackが仮入力文字列に変化が起きた場合の処理である。call_dataXIMPreeditDrawCallbackStruct構造体として渡されてくる。

1
2
3
4
5
6
typedef struct _XIMPreeditDrawCallbackStruct {
int caret; /* Cursor offset within pre-edit string */
int chg_first; /* Starting change position */
int chg_length; /* Length of the change in character count */
XIMText *text;
} XIMPreeditDrawCallbackStruct;

仮入力中のすべての文字が渡されてくるのではなく、変化した文字列の情報が渡されてくる。
chg_firstからchg_length文字数分がtextに変化したという感じである。
ただし、筆者の環境のIMEはchg_firstに常に0が渡されて、textに仮入力中の文字全部が渡されるような動作をした。
しかし、仕様の上では一部しか渡されてこない可能性もあるので、一応注意して実装する。
なお、文字数のカウントはバイト数ではなく、システムの文字コード(通常はUTF-8のはず)で解釈した場合の文字の数であることに注意。

XIMText型として文字列の情報が渡されてくる

1
2
3
4
5
6
7
8
9
typedef struct _XIMText {
unsigned short length;
XIMFeedback *feedback;
Bool encoding_is_wchar;
union {
char *multi_byte;
wchar_t *wide_char;
} string;
} XIMText;

encode_is_wcharのとき、wide_charとして文字列が来るが、今回はmulti_byteで来る場合のみ扱う(gtkでも対応していない)。
lengthはここでもバイト数ではなく、実際の文字の数である。
XIMFeedbackは各文字の装飾に関する情報だが、今回は割愛する。
なお、textNULLになる場合があり、それはchg_firstからchg_lengthまでの文字が削除されたことを意味する。
C言語にはマルチバイト文字をいい感じに扱うライブラリがないため、真面目に仮入力中の文字すべてを正しく把握しようとすると結構面倒そうなので、今回は来た情報だけを標準出力に吐くだけにしておく

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void preedit_draw_callback(
XIM xim,
XPointer client_data,
XIMPreeditDrawCallbackStruct *call_data)
{

printf("callback\n");
XIMText *xim_text = call_data->text;
if (xim_text != NULL)
{
printf("Draw callback string: %s, length: %d, first: %d, caret: %d\n", xim_text->string.multi_byte, call_data->chg_length, call_data->chg_first, call_data->caret);
}
else
{
printf("Draw callback string: (DELETED), length: %d, first: %d, caret: %d\n", call_data->chg_length, call_data->chg_first, call_data->caret);
}
}

最後のXNPreeditCaretCallbackcall_dataとして以下のような構造体として情報が来る。

1
2
3
4
5
typedef struct _XIMPreeditCaretCallbackStruct {
int position; /* Caret offset within pre-edit string */
XIMCaretDirection direction; /* Caret moves direction */
XIMCaretStyle style; /* Feedback of the caret */
} XIMPreeditCaretCallbackStruct;

カーソルのポジションと移動した方向、カーソルの表示の際の装飾情報が渡されてくる。文字列は変化していないので、文字列に関する情報はない。
以下のようなコールバック関数を定義しておく。

1
2
3
4
5
6
7
8
9
10
11
static void preedit_caret_callback(
XIM xim,
XPointer client_data,
XIMPreeditCaretCallbackStruct *call_data)
{
printf("preedit caret\n");
if (call_data != NULL)
{
printf("direction: %d position: %d\n", call_data->direction, call_data->position);
}
}

さて、これらをXICをつくるときのパラメータとして渡すために、XVaNestedListとして渡すには以下のようにする。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
XIMCallback draw_callback;
draw_callback.client_data = NULL;
draw_callback.callback = (XIMProc)preedit_draw_callback;
XIMCallback start_callback;
start_callback.client_data = NULL;
start_callback.callback = (XIMProc)preedit_start_callback;
XIMCallback done_callback;
done_callback.client_data = NULL;
done_callback.callback = (XIMProc)preedit_done_callback;
XIMCallback caret_callback;
caret_callback.client_data = NULL;
caret_callback.callback = (XIMProc)preedit_caret_callback;
XVaNestedList preedit_attributes = XVaCreateNestedList(
0,
XNPreeditStartCallback, &start_callback,
XNPreeditDoneCallback, &done_callback,
XNPreeditDrawCallback, &draw_callback,
XNPreeditCaretCallback, &caret_callback,
NULL);

今回はclient_dataを使わないので、NULLを渡しておいた。ポインタで渡しているだけなので、XIMCallbackXICの生存中にちゃんと生きていなければならないことには注意
そして、XICをつくる部分のコードを以下のように書き換える。

1
2
3
4
5
XIC ic = XCreateIC(xim,
XNInputStyle, XIMPreeditCallbacks | XIMStatusNothing,
XNClientWindow, win,
XNPreeditAttributes, preedit_attributes,
NULL);

完成品はこちら

実行例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
preedit start
callback
Draw callback string: あ, length: 0, first: 0, caret: 1
callback
Draw callback string: あい, length: 1, first: 0, caret: 2
callback
Draw callback string: あいう, length: 2, first: 0, caret: 3
callback
Draw callback string: あいうえ, length: 3, first: 0, caret: 4
callback
Draw callback string: あいうえお, length: 4, first: 0, caret: 5
delievered string: あいうえお
callback
Draw callback string: (DELETED), length: 5, first: 0, caret: 0
preedit done
preedit start
callback
Draw callback string: お, length: 0, first: 0, caret: 1
callback
Draw callback string: おn, length: 1, first: 0, caret: 2
callback
Draw callback string: おの, length: 2, first: 0, caret: 2
callback
Draw callback string: おのr, length: 2, first: 0, caret: 3
callback
Draw callback string: おのれ, length: 3, first: 0, caret: 3
callback
Draw callback string: おのれn, length: 3, first: 0, caret: 4
callback
Draw callback string: おのれの, length: 4, first: 0, caret: 4
callback
Draw callback string: おのれのf, length: 4, first: 0, caret: 5
callback
Draw callback string: おのれのふ, length: 5, first: 0, caret: 5
callback
Draw callback string: おのれのふg, length: 5, first: 0, caret: 6
callback
Draw callback string: おのれのふが, length: 6, first: 0, caret: 6
callback
Draw callback string: おのれのふがk, length: 6, first: 0, caret: 7
callback
Draw callback string: おのれのふがく, length: 7, first: 0, caret: 7
callback
Draw callback string: おのれのふがくw, length: 7, first: 0, caret: 8
callback
Draw callback string: おのれのふがくを, length: 8, first: 0, caret: 8
callback
Draw callback string: 己の富嶽を, length: 8, first: 0, caret: 0
callback
Draw callback string: 己の富嶽を, length: 5, first: 0, caret: 2
callback
Draw callback string: 己の富嶽を, length: 5, first: 0, caret: 0
callback
Draw callback string: おのれの富嶽を, length: 5, first: 0, caret: 0
callback
Draw callback string: 己の富嶽を, length: 7, first: 0, caret: 0
callback
Draw callback string: 己の富嶽を, length: 5, first: 0, caret: 2
callback
Draw callback string: 己の富岳を, length: 5, first: 0, caret: 2
callback
Draw callback string: 己のフガクを, length: 5, first: 0, caret: 2
callback
Draw callback string: 己の不学を, length: 6, first: 0, caret: 2
delievered string: 己の不学を
callback
Draw callback string: (DELETED), length: 5, first: 0, caret: 0
preedit done

リファレンス

一応、以上の仕様は以下のドキュメントにあるのだが、かなり古く読みにくい

概念的な話はこちら。正直、読み方がわからなかった

こっちはXlibの実装の解説ではあるが、レイアウトがメチャクチャである

Xlib − C Language X Interface

結局GTKの実装を参照するほうが楽であったが、GTK4(まだリリースはされていない)の最新版ではなんとXIMのサポートが削除されている。

GTK3までならちゃんと生きているので、そちらを参考にすると良い