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