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();
}
}
}
}

感想

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