このセクションは、DMA転送周りのメモリ安全なAPI構築における主要な要件について、説明します。
DMAペリフェラルは、プロセッサの動作(メインプログラムの実行)と並行してメモリ転送を行うために使用されます。
DMA転送は、memcpy
を実行するためにスレッドを生成すること(thread::spawn
を参照)とほぼ同等です。
メモリ安全なAPIの要件を説明するために、fork-joinのモデルを使用します。
次のDMAプリミティブを考えます。
{{#include ../ci/dma/src/lib.rs:6:57}}
{{#include ../ci/dma/src/lib.rs:59:60}}
Dma1Channel1
は、Serial1
というシリアルポート(別名UARTまたはUSART)#1と1ショットモード(つまりサーキュラーモードでない)でやり取りするように、
静的に設定されていると想定して下さい。
Serial1
は次のようなブロッキングするAPIを提供します。
{{#include ../ci/dma/src/lib.rs:62:72}}
{{#include ../ci/dma/src/lib.rs:74:80}}
{{#include ../ci/dma/src/lib.rs:82:83}}
例えば、(a)非同期にバッファを送信し、(b)非同期にバッファを埋めるように、Serial1
APIを拡張したいとしましょう。
メモリアンセーフなAPIから出発し、完全にメモリ安全になるまで繰り返し改善していきます。 各ステップで、非同期メモリ操作を扱う際に対処すべき問題を理解するために、 APIがどのように壊れる可能性があるか、を説明します。
初心者向けに、Write::write_all
を参考に使ってみましょう。
単純化のため、全てのエラー処理を無視します。
{{#include ../ci/dma/examples/one.rs:7:47}}
注記
Transfer
は、上述のAPIの代わりに、フューチャーやジェネレータベースのAPIとして公開できるでしょう。 それは、API設計の問題で、API全体のメモリ安全性にはほとんど関係がありません。 そのため、このテキストでは、詳しく説明しません。
Read::read_exact
の非同期バージョンも実装できます。
{{#include ../ci/dma/examples/one.rs:49:63}}
write_all
APIの使い方は次のとおりです。
{{#include ../ci/dma/examples/one.rs:66:71}}
そして、read_exact
APIの使用例です。
{{#include ../ci/dma/examples/one.rs:74:86}}
mem::forget
は安全なAPIです。もし私達のAPIが本当に安全なら、
未定義動作を起こさずに両方のAPIを同時に使えるはずです。
しかしながら、そうではありません。次の例を考えます。
{{#include ../ci/dma/examples/one.rs:91:103}}
{{#include ../ci/dma/examples/one.rs:105:112}}
ここで、スタック上に確保された配列を埋めるために、foo
からDMA転送を開始します。
そして、戻り値のTransfer
をmem::forget
します。
その後、foo
から戻り、bar
関数を実行します。
この一連の操作は、未定義動作を引き起こします。DMA転送はスタックメモリに書き込みますが、
そのメモリはfoo
から戻った時に解放され、bar
でx
とy
のような変数を確保するために再利用されます。
実行時、x
とy
の値は、ランダムなタイミングで書き換わる可能性があります。
DMA転送はbar
関数のプロローグによりスタックにプッシュされた状態(例えば、リンクレジスタ)を上書きする可能性もあります。
mem::forget
を使わずに、mem::drop
を使うと、Transfer
のデストラクタはDMA転送を停止し、
プログラムを安全にすることができることに留意して下さい。
しかし、メモリ安全性を強制するためにデストラクタの実行に頼ることはできません。
なぜなら、mem::forget
とメモリリーク(RCサイクルを参照)はRustでは安全だからです。
両APIのバッファのライフタイムを'a
から'static
に変更することで、この問題を解決できます。
{{#include ../ci/dma/examples/two.rs:7:12}}
{{#include ../ci/dma/examples/two.rs:21:27}}
{{#include ../ci/dma/examples/two.rs:35:36}}
もし前と同じ問題を再現しようとすると、mem::forget
はもはや問題になりません。
{{#include ../ci/dma/examples/two.rs:40:52}}
{{#include ../ci/dma/examples/two.rs:54:61}}
前回同様、Transfer
の値をmem::forget
した後も、DMA転送は続いています。
今回は、これは問題になりません。なぜならbuf
は静的に確保されており(例えば、static mut
変数)、
スタック上にないからです。
私達のAPIは、DMA転送を行っている間、ユーザーがSerial
インタフェースを使えてしまいます。
これは、DMA転送が失敗するか、データロスを発生させる可能性があります。
オーバーラップしての利用を防ぐ方法は、いくつかあります。
1つの方法は、Transfer
がSerial1
の所有権を取得し、wait
が呼ばれた時に所有権を返すことです。
{{#include ../ci/dma/examples/three.rs:7:32}}
{{#include ../ci/dma/examples/three.rs:40:53}}
{{#include ../ci/dma/examples/three.rs:60:68}}
ムーブセマンティクスは、DMA転送を行っている間、Serial1
へのアクセスを静的に防ぎます。
{{#include ../ci/dma/examples/three.rs:71:81}}
オーバーラップして利用できないようにする方法が他にもいくつかあります。
例えば、Serial1
にDMA転送中かどうかを示す(Cell
)フラグを追加できます。
もしフラグがセットされている時は、read
, write
, read_exact
およびwrite_all
は、
実行時にエラー(例えば、Error::InUse
)を返します。
このフラグはwrite_all
/ read_exact
が使われた時にセットし、Transfer.wait
でクリアします。
コンパイラは、よりプログラムを最適化するため、non-volatileなメモリ操作の順番を入れ替えたり、結合する自由があります。 現在のAPIでは、この自由が未定義動作を引き起こします。 次の例を考えます。
{{#include ../ci/dma/examples/three.rs:84:97}}
ここで、コンパイラは、自由にt.wait()
の前にbuf.reverse()
を移動することができます。
この移動は、プロセッサとDMAが同時にbuf
を修正するデータ競合を起こします。
同様に、コンパイラはゼロクリア操作をread_exact
の後に移動するかもしれません。
それもデータ競合を起こします。
これらの問題ある順番の入れ替えを起こさないために、compiler_fence
を使えます。
{{#include ../ci/dma/examples/four.rs:9:65}}
volatileな書き込みをするself.dma.start()
の後ろに先行するメモリ操作が移動されないように、
read_exact
とwrite_all
ではOrdering::Release
を使います。
同様に、volatileな読み込みをするself.is_done()
の前に後続のメモリ操作が移動されないように、
Transfer.wait
ではOrdering::Acquire
を使います。
フェンスの効果をより理解しやすくするために、前回セクションの例を少し修正したバージョンを示します。 フェンスを追加しており、メモリ操作の順序はコメントで記述しています。
{{#include ../ci/dma/examples/four.rs:68:87}}
Release
フェンスのおかげで、ゼロクリアする操作は、read_exact
より後ろに動かすことができません。
同様に、Acquire
フェンスのおかげで、reverse
操作はwait
より前に動かすことができません。
両フェンスの間にあるメモリ操作は、フェンスを超えて自由に順序を入れ替えることができますが、
buf
に関わるような操作はありません。そのため、順序の入れ替えは、未定義動作を起こしません。
compiler_fence
は求められているものより少し強いことに注意して下さい。例えば、
このフェンスは、buf
とx
とがオーバーラップしない(Rustのエイリアス規則のため)ことが分かっているにも関わらず、
x
に対する操作が結合されないようにします。しかしながら、
compiler_fence
より細かい粒度のintrinsicは存在していません。
ターゲットアーキテクチャによります。Cortex M0とM4Fコアについて、AN321は次のように言っています。
3.2 一般的な使い方
(..)
DMBの使用はCortex-Mプロセッサではほとんど必要ありません。なぜならCortex-Mプロセッサは メモリトランザクションの順序を変更しないからです。しかし、ソフトウェアが他のARMプロセッサ、 特に複数のマスターがあるシステム、で再利用される場合は必要です。例えば、
- DMAコントローラ設定。バリアは、CPUのメモリアクセスとDMA操作との間で必要です。
(..)
4.18 複数のマスターがあるシステム
(..)
47ページの図41や図42でDMBやDSB命令を除去すると、何らかのエラーが発生します。なぜなら、Cortex-Mプロセッサは
- メモリ転送の順序を入れ替えない
- オーバーラップした2つの書き込み転送を許可しない
ここで、図41は、DMAトランザクションを開始する前に使用されるDMB(メモリバリア)命令を示しています。
Cortex-M7コアの場合、データキャッシュ(DCache)を使っていれば、 DMAで使用されるバッファを手動で無効化しない限り、メモリバリア(DMB/DSB)が必要になります。
もしターゲットがマルチコアシステムの場合、メモリバリアが必要になる可能性が非常に高いです。
もしメモリバリアが必要な場合、compiler_fence
の代わりにatomic::fence
を使わなければなりません。
これは、Cortex-MデバイスではDMB命令を生成するはずです。
私達のAPIは要件よりも制約が強いです。例えば、 次のプログラムは正しいですが、対応できません
{{#include ../ci/dma/examples/five.rs:67:85}}
このようなプログラムに対応するため、バッファの引数をジェネリックにできます。
{{#include ../ci/dma/examples/five.rs:9:65}}
注記:
AsSlice<Element = u8>
(AsMutSlice<Element = u8
)の代わりに、AsRef<[u8]>
(AsMut<[u8]>
)を使うことができます。
これで、reuse
プログラムに対応できます。
この修正でAPIは値として配列(例えば、[u8; 16]
)を受け取れるようになります。
しかし、配列を使用すると、ポインタが不正になる可能性があります。
次のプログラムを考えます。
{{#include ../ci/dma/examples/five.rs:88:103}}
{{#include ../ci/dma/examples/five.rs:105:112}}
read_exact
操作は、start
関数にあるbuffer
のアドレスを使います。
このローカルbuffer
は、start
から戻った時に解放され、read_exact
で使われているポインタは不正になります。
unsound
の例と似たような状況になるでしょう。
この問題を避けるため、APIで使用するバッファに、ムーブされてもメモリの位置を保ち続けることを要求します。
Pin
ニュータイプは、このような保証を提供します。
全てのバッファがあらかじめ「pin」されていることを要求するように、APIを更新します。
注記: 以降のプログラムをコンパイルするためには、Rust
1.33.0以上
が必要です。 執筆時点(2019-01-04)では、nightlyチャネルの使用を意味します。
{{#include ../ci/dma/examples/six.rs:16:33}}
{{#include ../ci/dma/examples/six.rs:48:59}}
{{#include ../ci/dma/examples/six.rs:74:75}}
注記:
Pin
ニュータイプの代わりにStableDeref
トレイトを使うことができますが、Pin
は標準ライブラリで提供されるため、Pinを選びました。
この新しいAPIでは、&'static mut
参照、Box
化したスライス、Rc
化されたスライスなどを使えます。
{{#include ../ci/dma/examples/six.rs:78:89}}
{{#include ../ci/dma/examples/six.rs:91:101}}
Pinを使うことで、スタックに割り当てられた配列を安全に使えるのでしょうか? 答えは、ノーです。次の例を考えます。
{{#include ../ci/dma/examples/six.rs:104:123}}
{{#include ../ci/dma/examples/six.rs:125:132}}
これまで何回も見た通り、スタックフレームの破壊により、上記のプログラムは未定義動作に陥ります。
このAPIは、Pin<&'a mut [u8]>
(ここで'a
はstatic
ではありません)の型を持つバッファに対して、
安全ではありません。
この問題を解決するため、どこかに'static
境界を追加しなければなりません。
{{#include ../ci/dma/examples/seven.rs:15:25}}
{{#include ../ci/dma/examples/seven.rs:40:51}}
{{#include ../ci/dma/examples/seven.rs:66:67}}
これで問題のプログラムは拒絶されます。
これでAPIはBox
やデストラクタを持つ型を受け入れることができます。
Transfer
が早めにドロップされたときに何をすべきか決める必要があります。
通常、Transfer
の値は、wait
メソッドを使って消費されます。しかし、転送が完了する前に、
暗黙的もしくは明示的に、値をdrop
することも可能です。
例えば、Transfer<Box<[u8]>>
の値をドロップすると、バッファは解放されます。
これは、まだ転送中であれば、DMAが解放済みのメモリに書き込むため、未定義動作を引き起こします。
このような状況では、Transfer.drop
でDMA転送を止めることが1つの選択肢です。
他の選択肢は、Transfer.drop
が転送完了を待つことです。
より簡単なので、前者を選びます。
{{#include ../ci/dma/examples/eight.rs:18:72}}
{{#include ../ci/dma/examples/eight.rs:82:99}}
{{#include ../ci/dma/examples/eight.rs:109:117}}
これで、バッファが解放される前にDMA転送が中断されます。
{{#include ../ci/dma/examples/eight.rs:120:134}}
まとめると、メモリ安全なDMA転送を行うために、これら全てを考えなければなりません。
Pin<B>
という固定バッファと間接参照を使います。あるいは、StableDeref
トレイトを使用できます。
B: 'static
というバッファの所有権をDMAに渡す必要があります。
- メモリ安全性をデストラクタの実行に頼ってはいけません。
APIと
mem::forget
が一緒に使われるとどうなるか、考えて下さい。
- DMA転送を中断するカスタムデストラクタを追加、もしくは、転送完了まで待機、するようにして下さい。
APIと
mem::drop
が一緒に使われるとどうなるか、考えて下さい。
このテキストでは製品レベルのDMA抽象を構築するために要求される詳細を省略しています。
例えば、DMAチャネルの設定(ストリーム、サーキュラー vs ワンショットモードなど)、バッファのアライメント、
エラー処置、デバイスに依存しない抽象の作り方などについてです。
これらの点は、読者 / コミュニティの演習とします (:P
)。