3-2.NICの実装
今回から2回に分けて、NICとNICドライバーをRustで実装していきます。
まずは、本日は、NICの実装に入ります。
今回ソースは、src/layer1/component/nic.rs
に作成していきます。
[packet-pilot]
├── Cargo.lock
├── Cargo.toml
├── css
├── index.html
├── js
├── pkg
├── src
│ ├── layer1
│ │ ├── component
│ │ │ ├── ethernet_cable.rs (2-4で実装したイーサネットケーブルのコンポーネント)
│ │ │ ├── nic.rs (*NICのコンポーネントをここに)
│ │ │ ├── nic_driver.rs (*NICドライバーのコンポーネントをここに)
│ │ │ └── mod.rs
│ ├── common
│ └── receive_callback.rs (callbackの定義をcommonに移動し、Datalink層のコールバック関数の定義を追加)
前回定義した内容をもとに、NICとNICドライバーを表現する構造体を考えていきます。
まずは、NICから行きましょう。


このように定義していました。
あと、NICでは、そのNICが繋がるNICドライバーが信号を受け取る口
を用意してくれるはずですので、その情報を保持しておく必要がありますね。
これらを加味して、このようにRustで構造体を定義してみました。
/// NICの本体
#[derive(Clone)]
pub struct NICState {
id : String,
mac_addr : MacAddress,
connected_cable : Option<EthernetCable>,
driver_callback : Option<DatalinkLayerCallback>, // NICドライバーがデータを受け取るコールバック
}
/// I/F用NIC構造体
#[derive(Clone)]
pub struct NIC {
state: Arc<Mutex<NICState>>,
}
なぜこのようにStateという本体と、I/F用の構造体を分けているのか?は、 こちらの「2-3.Arc、Mutex、dyn、Send、Sync、vtable、全部まとめて調査しました(by調査班)」を参考にしてください。 2024年の冬休み前に調査班が丁寧に調べてくれたRustの深〜いお話しです。
データの共有(=Arc)
と、データの共用(=Mutex)
というセットで使っています。
そして、NICですので、イーサーネットケーブルがつながります。 MACアドレスは実はこのNICが持っているものでしたね。ここに定義しておきます。
DatalinkLayerCallback
driver_callback : Option<DatalinkLayerCallback>, // NICドライバーがデータを受け取るコールバック
このフィールドは、
・NICに繋がった、NICドライバーが、
・データを受信するときはここで受信するからここにデータを送ってね。という関数を、
・NIC側で覚えておくためのフィールドになります。
NICで受け取った、物理層(Layer1)のイーサーネットフレームは、
// 物理層のイーサネットフレームの定義(Layer 1)
pub struct PhysicalLayerFrame {
pub preamble: [u8; 7], // プリアンブル (7バイト)
pub sfd: u8, // スタートフレームデリミタ (1バイト)
pub ethernet_frame: EthernetFrame, // データリンク層のイーサネットフレーム
}
ハード上で「プリアンブル (7バイト)」「スタートフレームデリミタ (1バイト)」を取り除いて データリンク層のイーサネットフレームの定義(Layer 2)となり、
// データリンク層のイーサネットフレームの定義(Layer 2)
pub struct EthernetFrame {
pub dst_mac: [u8; 6], // 宛先MACアドレス (6バイト)
pub src_mac: [u8; 6], // 送信元MACアドレス (6バイト)
pub ethertype: u16, // イーサータイプ (2バイト)
pub data: Vec<u8>, // データリンク層のペイロード
}
NICドライバーが受け取ります。
それで、今回データリンク層のデータ受信用のコールバック関数として、このように定義しました。 データリンク層での繋がりの時はこちらを今後も使用していきます。
pub type DatalinkLayerCallback = Arc<dyn Fn(EthernetFrame) + Send + Sync>;
簡単におさらいしておくと、
Arc
で囲んでいるということは、複数で共有する可能性があるからですね。
NICドライバーが持っている受信用関数を、NICも持ちますからね。
dyn
は、"dynamic" の略で、コンパイル時ではなく、実行時に具体的な実装が決定される=動的ディスパッチ(実行時ポリモーフィズム)を可能にするものです。(トレイト・オブジェクトを示しています)
Fn
は、Rustの関数トレイトの1つで、「この型は関数のように呼び出せる」という意味です。
Fn(EthernetFrame)
ということは、EthernetFrame を引数として受け取る関数orクロージャということを表しています。
Send
は、その型がスレッド間で所有権を移動できることを意味します。
例: スレッドAからスレッドBにデータを渡す場合、データがSendを実装している必要があります。
今回のケースではNICからNICドライバーに EthernetFrame というデータを渡しますね。
Sync
は、その型が複数のスレッドで同時に参照されても安全であることを意味します。
例: 共有データを複数スレッドで読み取る場合、そのデータがSyncを実装している必要があります。
ということで、今回のNICは、
/// NICの本体
#[derive(Clone)]
pub struct NICState {
id : String,
mac_addr : MacAddress,
connected_cable : Option<EthernetCable>,
driver_callback : Option<DatalinkLayerCallback>, // NICドライバーがデータを受け取るコールバック
}
/// I/F用NIC構造体
#[derive(Clone)]
pub struct NIC {
state: Arc<Mutex<NICState>>,
}
というカタチにしました。
NICのハタラキ
まず、Displayを実装しておきましょう。NICの現在の内容を表示します。 State=本体側は、
/// Display
impl Display for NICState {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let connected_cable = self.connected_cable
.as_ref()
.map(|cable| cable.to_string())
.unwrap_or_else(|| "None".to_string());
let driver_callback_ptr = self.driver_callback
.as_ref()
.map(|cb| cb.as_ref() as *const dyn Fn(EthernetFrame));
write!(
f,
"#NIC State \n\
#id : {}\n\
#mac_addr : {}\n\
#connected_cable : {}\n\
#driver_callback : [{}]\n",
self.id,
self.mac_addr,
connected_cable,
driver_callback_ptr
.map(|ptr| format!("{:p}", ptr))
.unwrap_or_else(|| "None".to_string()),
)
}
}
そして、NIC構造体の方のDisplayは、中のStateの実装をそのまま再利用するかたちにしました。
impl fmt::Display for NIC {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// ミューテックスをロックしてStateにアクセス
let state = self.state.lock().unwrap();
// NICStateのDisplayの実装を再利用
write!(f, "{}", *state)
}
}
NICのはたらきとしては、 ・新規にNICを配置した時newされる ・このNICカードに、EthernetCable を接続するときに呼ぶ ・NIC ドライバーの受信用コールバックをセット ・このNICのMACアドレスを取得 ・このNICのidを取得 ・データを送信。(NICドライバーからNICにEthernetFrame送信する際にNICドライバーから呼ばれる) ・NICの受信用コールバック関数(繋がっているイーサーネットケーブルから呼ばれる) ・受信すべきframeかどうかチェック という風になります。
ここでおさえておきたいのは、イーサーネットケーブルは繋がっているデバイスからの電気信号をなんでも送る。というのがそのハタラキでした。 が、NICは物理層(Layer1)のアドレスである、「MACアドレス」を持っており、「自身のMACアドレス宛に来たものと、ブロードキャストアドレスは受信する」という ルールがありましたね。ですので、ここで、受信すべきEthernetFrameかどうか?をチェックしてからNICドライバーに送るかどうか判断しています。
それでは、順番に実装していきます。
新規にNICを配置した時newされる
[NIC] // I/F用NIC構造体側での実装
/// 新規にNICを配置
pub fn new(id:Option<String>) -> Self {
debug("NIC::new() called.");
NIC{
state : Arc::new(Mutex::new(NICState::new(id))),
}
}
[NICState]// NIC本体の構造体側での実装
fn new(id:Option<String>) -> Self {
let nic_id = id.unwrap_or_else(|| format!("nic-{}",rand::thread_rng().gen_range(9..9999)));
let mac_addr = MacAddress::new();
debug(&format!("my mac address is {:?}",mac_addr.to_string()));
NICState {
id : nic_id,
mac_addr,
connected_cable : None,
driver_callback : None,
}
}
I/F側では、Stateのnew()を呼んで、stateを作り出しています。 本体側では、idを指定していた場合は指定されたidで、なければランダムに作り出したnic-9999のような文字列をidとしています。 そして、MACアドレスを新規に作り、登録しています。まだ、何も繋がっていないので、ケーブル情報やドライバー情報はNoneです。
このNICカードに、EthernetCable を接続するときに呼ぶ
このシミュレーションの世界では、イーサーネットケーブルを接続した時のルールがありましたね。「データの流れ」
のルールです。
「それぞれのネットワークコンポーネントがデータを受けるための受口を用意して、それを接続するケーブルに伝えておく」
ということが必要になります。
NICとしては、接続されているイーサーネットケーブルにどこに電気信号を流せばいいのか?
を伝えておくということですね。
/// このNICカードに、EthernetCable を接続するときに呼ぶ
pub fn connect_cable(&self, cable: EthernetCable) {
debug("NIC::connect_cable() called.");
// ミューテックスをロックしてStateにアクセス
let mut state = self.state.lock().unwrap();
let nic_id = state.id.clone();
let state_arc = Arc::clone(&self.state);
let callback =
Arc::new(
// 1. 物理層からフレームを受信
move |frame: PhysicalLayerFrame| {
// 2. NICStateへのアクセスを取得
let state = state_arc.lock().unwrap();
// 3. フレームの受信処理
state.receive_frame(frame);
// -->スコープを抜けると自動的にロックが解放される
}
);
// EthernetCableの接続状態をチェックして、空いてる方のエンドポイントを接続
if cable.get_endpoint1_component_id().is_none() && cable.get_endpoint2_component_id().is_none(){
debug("NIC::connect_cable() both endpoint is none, so connect to endpoint1.");
cable.connect_endpoint1(Some(nic_id.clone()));
}
else if cable.get_endpoint1_component_id().is_none() && cable.get_endpoint2_component_id().is_some(){
debug("NIC::connect_cable() endpoint1 is none, endpoint2 is alrealdy connected, so connect to endpoint1.");
cable.connect_endpoint1(Some(nic_id.clone()));
}
else if cable.get_endpoint1_component_id().is_some() && cable.get_endpoint2_component_id().is_none(){
debug("NIC::connect_cable() endpoint1 is already connected, endpoint2 is none, so connect to endpoint2.");
cable.connect_endpoint2(Some(nic_id.clone()));
}else{
return;
}
// 接続が完了したら、NICのState につながっている EthernetCable を保持しておく
state.connected_cable = Some(cable.clone());
// 接続してきた EhternetCable に対して受信用コールバック関数を設定
cable.set_callback(nic_id, callback);
}
最終的には、2-2でRustで実装したイーサーネットケーブルの、set_callback関数
で、NICのidに対しては、
このコールバック関数(=state.receive_frame(frame))を呼んでね。という風にセットしています。
コールバック関数は、Arc化したクロージャー関数が呼び出されます。 NICとケーブルが繋がっている限り、参照が消えることはないですので、Arc化することで、ずっとこのコールバック関数は保持されます。
/// EthernetCableの本体
#[derive(Clone)]
pub struct EthernetCableState {
pub id : String,
pub endpoint1_component_id : Option
ケーブルを機器に接続。→両端がつながったらフラグ立てる
/// ケーブルの接続どちらかの端がまずどちらかに繋がるのでOptionにしてコンポーネントのIdを渡す
pub fn connect(&self, ep1_connect_id: Option<String>, ep2_connect_id: Option<String>) {
let mut state = self.state.lock().unwrap();
state.endpoint1_component_id = ep1_connect_id;
state.endpoint2_component_id = ep2_connect_id;
if state.endpoint1_component_id.is_some() && state.endpoint2_component_id.is_some() {
state.connected = true;
}
}
あまり同時にケーブルを繋げるシチュエーションってないですよね。PCとL2 SwitchがあったらPCに繋げてから、L2 Switchとか、 順番になると思いましたので、ここでは、繋げるコンポーネントのidを、Option型にしておきました。
明示的にそれぞれ個別繋げる場合もあるかと思いますので、個別の接続関数を用意しておきます。
pub fn connect_endpoint1(&self, ep1_connect_id: Option<String>) {
debug("EthernetCable::connect_endpoint1() called.");
let mut state = self.state.lock().unwrap();
state.endpoint1_component_id = ep1_connect_id;
if state.endpoint1_component_id.is_some() && state.endpoint2_component_id.is_some() {
debug(&format!("EthernetCable({})::bothe connected.",state.id));
state.connected = true;
}
}
pub fn connect_endpoint2(&self, ep2_connect_id: Option<String>) {
debug("EthernetCable::connect_endpoint2() called.");
let mut state = self.state.lock().unwrap();
state.endpoint2_component_id = ep2_connect_id;
if state.endpoint1_component_id.is_some() && state.endpoint2_component_id.is_some() {
debug(&format!("EthernetCable({})::bothe connected.",state.id));
state.connected = true;
}
}
そして、それぞれの端につながっているコンポーネントが何なのか?を調べたい時もありますね。 その時のために、今つながっているコンポーネントのidを返す関数も用意しておきます。
pub fn get_endpoint1_component_id(&self) -> Option<String> {
let state = self.state.lock().unwrap();
state.endpoint1_component_id.clone()
}
pub fn get_endpoint2_component_id(&self) -> Option<String> {
let state = self.state.lock().unwrap();
state.endpoint2_component_id.clone()
}
次に、
/// ケーブル接続時に、データきたらここに渡してねというcallbackをsetする
/// これをケーブルに伝えておくことで、データきた時イーサーネットケーブルは指定されている
/// PhysicalLayerCallbackを呼び出す
pub fn set_callback(&self, id:String,callback:PhysicalLayerCallback){
debug("EthernetCable::set_callback() called.");
let mut state = self.state.lock().unwrap();
if state.endpoint1_component_id.is_none() && state.endpoint2_component_id.is_none() {
//両方ともまだつながっていないのでセットできません
return;
}
if let Some(ep1_id) = &state.endpoint1_component_id{
if *ep1_id == id{
debug("EthernetCable::set_callback() set endpoint1 callback.");
state.endpoint1_callback = Some(callback.clone());
}
}
if let Some(ep2_id) = &state.endpoint2_component_id{
if *ep2_id == id{
debug("EthernetCable::set_callback() set endpoint2 callback.");
state.endpoint2_callback = Some(callback.clone());
}
}
}
この関数の引数は、接続するネットワークコンポーネントのidとそのコンポーネントが用意しているデータ受信用のコールバックです。 どちらのケーブルにつながっているコンポーネントか?を見て、そのidのコンポーネントのコールバックとして、endpoint1_callbackか、endpoint2_callbackに保持しておきます。
電気信号を流す
はい。そしてメインの機能である電気信号を流すですね。
/// データを送信する。上位層から呼ばれる関数。
/// このケーブルにPacketを流したい上位層のコンポーネントがこの関数を呼び出すことで、 ケーブルの先に電気信号を流す
pub fn transmit_signal(&self, from_id:String, frame: PhysicalLayerFrame) {
debug("EthernetCable::transmit_signal() called.");
debug(&format!("EthernetCable::transmit_signal() frame={:?}",frame));
let state = self.state.lock().unwrap();
// 両端がつながっていなかったら終了
if !state.connected && !state.endpoint1_callback.is_none() && !state.endpoint2_callback.is_none(){
debug(("both endpoint not connected."));
return;
}
// 送られるデータはどちらのendpointから来たか探す
let ep1 = state.endpoint1_component_id.clone().unwrap();
let ep2 = state.endpoint2_component_id.clone().unwrap();
debug(&format!("EthernetCable::transmit_signal() from_id={:?}",from_id));
debug(&format!("EthernetCable::transmit_signal() ep1={:?}",ep1));
debug(&format!("EthernetCable::transmit_signal() ep2={:?}",ep2));
let other_endpoint = if from_id == ep1 {
debug("from ep1 --> callback to ep2");
state.endpoint2_callback.clone().unwrap()
} else if from_id == ep2 {
debug("from ep2 --> callback to ep1");
state.endpoint1_callback.clone().unwrap()
} else {
// エラーハンドリング: どちらのエンドポイントにも一致しない場合
debug("Unexpected endpoint ID");
return;
};
// 送り先のデバイスのCallBackを呼び出し信号を送る
other_endpoint(frame);
}
この関数のパラメータは、from_id=送信元のコンポーネントのidと、frame=実際に送信するデータです。
この中では、
電気信号を流そうとしても、その先がつながっていなかったら伝える先がいないので、そこで終了しています。
そして、ケーブルのどちらの端からきたものなのかをみて、その反対側のコンポーネントの受信用コールバックを呼び出します。
デバッグ用関数
最後におまけで、今回各所で、debug
という関数が呼ばれています。
debug("EthernetCable::transmit_signal() called.");
これは、showTerminal
というJS側で定義した関数を呼び出し、「Packet Pilot Terminal」に状況を
表示するための関数です。こちらは、また、WASM実装回の時、どのようにしているかをご紹介します。
// -- for WASM debug
pub fn debug(s: &str) {
let message= format!("\r\n------------\r\n[Debug] {}\r\n------------\r\n",s);
showTerminal(&message);
}
今回、イーサーネットケーブルでの、実装は以上になります。 Arc、Mutex、Fn、Sync、SendなどRustでよく使う部分について、ここでしっかり自分のものにしておいてください。 調査班が調べてくれた「2-3.Arc、Mutex、dyn、Send、Sync、vtable、全部まとめて調査しました(by調査班)」をじっくり読んでみてください。
では、次回は、このイーサーネットケーブルをWASM化して、JS側の実装を行います。
ネットワークのこと、Packet、アドレスのことについて頭に入れておきたい場合は、本誌Vol.1で復習しておいてくだださい。
ネットワークを流れるPacketをプログラムから操作できるようになりたいという人には必見のマガジンです。