はじめに
OSSのTCP/IPプロトコルスタックである Light-waight IP (lwIP) をマイコンに実装してイーサネット通信する際に、意外な盲点となるハマり所についてご紹介します。
本稿はSTM32CubeMXで生成したコード(バージョン2.2.1)を元に構成しています。
lwIPのメモリプールについて
リソース制限の厳しい組み込みシステムで使われる lwIP では、独自のメモリプール(memp / pbuf)機構による「ゼロコピー方式」を通信処理全体に適用することで、少ないメモリで最高のパフォーマンスが得られるように工夫されています。
この「メモリプール」による恩恵は、TCPのストリーム通信でもっとも顕著に表れます。
TCPのフロー制御についておさらい
TCP/IPの基本のキに該当する内容ではありますが、TCPのストリーム通信におけるフロー制御と、ウィンドウバッファ枯渇について解説します。
TCPでは、データを受信する側が「自分が一度に受け取れるデータ量(ウィンドウサイズ)」を送信側に伝えることで、処理能力を超えるデータが送り付けられないように調整しています。これをフロー制御(スライディングウィンドウ方式)と呼びます。
通常、受信したデータは一旦TCPの「受信バッファ」に蓄えられ、そこから上位のアプリケーション層がデータを読み出していきます。
- バッファの蓄積:
送信側から次々とデータ(TCPセグメント)が届くため、受信バッファの空き容量がどんどん減っていきます。 - ウィンドウサイズの減少:
受信側は、データを正常に受け取ったという確認応答(ACK)を返す際、ヘッダ内の「ウィンドウサイズ(Window = RCV.WND)」という項目を、現在のバッファの「空き容量」に合わせて小さく申告するようになります。 - バッファ枯渇と送信停止(Zero Window):
ついに受信バッファが完全に満杯になると、受信側は「ウィンドウサイズ=0(Zero Window)」のACKを返します。これを受け取った送信側は、それ以上データを送れなくなり、送信を一時停止(ストップ)します。 - 空きの復活:
アプリケーションがデータを読み出すとTCPはその分のバッファを解放し、ウィンドウ更新を送信側に通知します。これを受け取った送信側は、送信を再開します。
送信側についても、アプリ側から送られてきたデータを送信バッファに貯め、相手側(受信側)からの「受け取ったよ」というACKが来たら、そのシーケンスのところまで開放します。それまでは、再送可能なようにバッファに残しておきます。
ここで注意しなければならないのは、「IPパケットとして送られてくるTCPセグメント単位」と、「上位アプリが処理するセグメント単位」が必ずしも一致せず、しかも非同期に行われるということです。
マイコンベースの組込みシステムでは、メモリ(RAM)使用量削減のためにTCP受信バッファを(例えば4Kバイト~8Kバイトといったオーダーで)小さく設計することがよくあります。
この時、実際は受信処理を進められる状態なのに受信バッファが解放されず、RCV.WND値が極小の値のままデッドロックしてしまう現象が起きやすくなり、アプリ側の実装において注意が必要です。
TCPの仕様・欠陥として有名な Silly Window Syndrome (SWS: 愚かなウィンドウ症候群) のちょっとしたバリエーション、RFC1122が言及しているSWSそのものではないですが、組み込みシステム特有の制約によって「擬似的なデッドロック状態」に陥ったケースです。この現象について詳しく見てみましょう。
「愚かなウィンドウ症候群」っぽい現象
極端な設定ですが、
- 上位アプリの処理ブロックサイズの単位は600バイト
- イーサネット通信で送られてくるTCPパケットの区切りが800バイト
(TCP_MSS(最大セグメントサイズ)を800に設定した想定です) - TCP受信バッファサイズが2000バイト
- 上位アプリは通信より速い周期で処理できる
という例で考えてみましょう。
単純に考えると、「処理能力>通信量」かつ「バッファサイズが2.5倍」なので、能力的には全く問題ないはずです。
この時、現象が発生するメカニズム(前提条件)を図示します。
【TCP受信バッファのデッドロック】
TCP受信バッファ(初期状態)
┌──────────────────────────────────────────────────────────────────────────┐
│ (2000Byte) │
└──────────────────────────────────────────────────────────────────────────┘
1. TCPパケット(800バイト)受信
※上位アプリの処理単位600バイト+次のシーケンス分200バイトが
バッファの先頭から積み上げられる
┌──────────────────────────────────────────────────────────────────────────┐
│////////// (800B) //////////│ (1200B) │
└──────────────────────────────────────────────────────────────────────────┘
◀──────────────────────────▶◀──────────────────────────────────────────▶
バッファ中 空き
2. データ600バイト分をアプリが引き抜き
※中途半端な残り200バイトはそのままTCPバッファに残る
◀─────────────────▶ 引き抜き(空き状態に)
┌──────────────────────────────────────────────────────────────────────────┐
│ (600B) |/(200B)/│ (1200B) │
└──────────────────────────────────────────────────────────────────────────┘
◀─────────────────▶◀─────▶◀───────────────────────────────────────────▶
空き バッファ中 空き
3. TCPパケット(800バイト)受信
※前のシーケンス残り分400バイト+次のシーケンス分400バイトが
バッファの次のポイントから積み上げられる
┌->新たなTCPセグメントはここから積み上げる
┌──────────────────────────────────────────────────────────────────────────┐
│ (600B) |/(200B)/│///////////(800B)/////////////│ (400B) │
└──────────────────────────────────────────────────────────────────────────┘
◀─────────────────▶◀────────────────────────────────────▶◀────────────▶
空き バッファ中 空き
【極小ウィンドウ(WIN)の広告】
ここで受信側TCPは WIN=400 とした確認応答(ACK)を送信し、
バッファの空きが残り400バイトになったことを送信側に伝える。
4. 溜まったデータをアプリが引き抜き
※1000(=200+800)バイトのうち600バイトを引き抜く
◀─────────────────▶ 引き抜き(空き状態に)
┌──────────────────────────────────────────────────────────────────────────┐
│ (600B) │ (600B) |/////(400B)/////│ (400B) │
└──────────────────────────────────────────────────────────────────────────┘
◀─────────────────────────────────────▶◀─────────────▶◀───────────────▶
空き バッファ中 空き
5. TCPパケット(400バイト)受信
※送信側は受信側ウィンドウバッファの残量に合わせ400バイトのみ送信する
┌──────────────────────────────────────────────────────────────────────────┐
│ (1200B) |/////(400B)/////│//////(400B)/////│
└──────────────────────────────────────────────────────────────────────────┘
◀─────────────────────────────────────▶◀────────────────────────────────▶
空き バッファ中
【ゼロウィンドウ発生】
受信側のバッファは完全に満杯となり、これ以上受信不可
WIN=0を送信側に伝達・伝送を中断 (ブロック)
6. 溜まったデータをアプリが引き抜き
※1000(=200+800)バイトのうち600バイトを引き抜く
引き抜き(空き状態に) ◀───────────────────────▶
┌──────────────────────────────────────────────────────────────────────────┐
│ (1200B) | (600B) │/(200B)/│
└──────────────────────────────────────────────────────────────────────────┘
◀───────────────────────────────────────────────────────────────▶◀─────▶
空き バッファ中
※しかしバッファ末尾の200バイトは埋まったまま
↓
相互待ち (デッドロック)
実際にはバッファの空きがあるのにもかかわらず、上位層アプリケーションプロトコルの受信ブロック処理単位と、TCPパケットの同期が崩れてしまい、バランスが崩れるとバッファの枯渇(満杯)が起きてしまうという例です。
- アプリ側:
データが足りなくて(まとまった600バイトが来ないと処理できないのに、200バイトしか渡されず)ブロックを解放できない - TCP側:
TCPはバッファが空かなくてこれ以上データを受信できない
という、お互いが相手のアクションを待つ形になり、実際には処理を進められる能力があるにもかかわらず、相互待ちのデッドロック状態(通信ハングアップ現象)となっている訳です。
この現象に関し、TCPはUDPのようなデータグラム通信ではなく、ストリーム通信というのがポイントです。TCP通信は上位プロトコルのセグメント(処理単位)とは無関係なストリームとすることで、シーケンス通り、効率良く確実な伝送が可能という大きなメリットを受益できる(上位アプリの都合に関係なく、複数のセグメントを相乗りさせたり、分割したり、それらを組み合わせたりすることができる)一方で、原理的にこのような現象が起こり得ます。
ゼロコピーの罠
上記の現象は、バッファサイズが小さい制限下において、組込みシステムならではの効率化が裏目に出てしまったというものです。もう一度まとめましょう。
- なぜ起きるか:
届いたパケットが中途半端だと、アプリは処理を保留(ブロック)する。 - ゼロコピーにより:
アプリが処理を終えるまでバッファ領域が解放されない。中途半端なデータがTCP受信バッファ内に「居座り」続けてしまう。 - 結果:
後続のパケットが届くとバッファは満杯(RCV.WND=0)になるが、アプリから見れば「まだ必要なデータブロックが揃いきっていないため、引き抜き処理が走らない。これにより、TCP層は空きを待ち、アプリは残りのデータを待つという完全なデッドロックに陥る。
これに対し、アプリ側でTCP受信バッファから取り残さないようにする、つまり、データを受け取ったらすぐにTCP受信バッファが空となるよう、常に全てのデータを引っ張るようにするのが一つの対処法となります。
これにより、送信側に対して常に安定したウィンドウサイズ(RCV.WND = 送信側に通知するWindow値)を返し続けることができ、同期ズレによるデッドロックを完全に回避できるという訳です。
ただしそのためには、アプリ内部での(中間バッファ→ワークバッファへという重層的な)バッファコピーという操作が必要になってきます。デュアルバッファ、もしくはキューのような仕組みをアプリ側にもたせなければなりません。レイヤ間の分離(抽象化)という原則(理想)からは外れてしまいます。
そもそも、CPUリソースもメモリリソースもギリギリな組込みシステムにおいて、パケット処理のオーバーヘッド(バッファコピーのためのCPUコスト)を避けるために、できるだけ「ゼロコピー方式」を採用したいという事情があります。
この「ゼロコピー」と「バッファコピー」をどこで、どう配分するかが、組込みエンジニアの腕の見せ所となります。
例えば、教科書通りレイヤ間の分離(抽象化)をきちんとしようとすると、チープな組込みシステムに取り入れるには少しばかり重い実装となったり、μITRON仕様のTCP/IP実装ではゼロコピーAPIが定義されているものの、恐ろしく使いづらいものとなってしまったりします。
これに対し、LwIPは「メモリプール」を導入することで、ゼロコピーを最大限活用しつつ、この問題を根本的に解決しています。
メモリプール
具体的には、固定長の連続バッファという構造をやめ、小さなメモリブロックを動的に管理する方法です。
基本的な原理としては、処理済みの領域(バッファの右側)を都度解放(メモリプールに返却)し、代わりに新たなメモリブロックをアロケートし、バッファの右側につなげるというものです。
バッファ全体が1つの固定メモリではないため、部分的な解放と再利用のサイクルが回り、デッドロックを防げます。
なお、この小さなブロックためのメモリ領域はあらかじめRAMからプールしておきます。
イメージとしてはこんな感じでしょうか。
TCPバッファ (3KB)
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│500B │ │500B │ │500B │ │500B │ │500KB│ │500B │
└─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘
◀────────────▶◀─────▶◀───────────────────▶
処理完了 アプリ 未使用
(使用済み) 処理中
↓解放(返却) ↓追加
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│500B │ │500B │ │500KB│ │500B │ │500KB│ │500B │
└─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘
◀────▶◀────────────────────────────────────▶
TCPが新規受付可能
使用済みブロックのみ即座に解放
(プールへ戻り、再利用可能へ)→ 空いたブロックは次の受信に使い回す
この例の500バイトというのは嘘で、実際にはこのような値に設定することは無いと思います。念のため。
言い換えると、ちゃんとしたOS、あるいは高級言語のランタイムが内部でやっているような高度なメモリ管理を、簡単な機構にまとめて、プロトコルスタックの内部でやっている、ということになります。
ちなみに、メモリブロックは連結リスト(単方向リンクリスト)で動的にチェイン(連結)する管理方法です。
正確には、メモリプール(memp)という大枠の中に、可変長パケットバッファ(pbuf)という仕掛け(コンポーネント)が組み込まれている構造となります。メモリプールはTCPやUDPのコネクション管理をするPCB(Protocol Control Block)など、様々なコンポーネントで利用されます。
ここで重要なポイントですが、pbufは可変長のバッファですが、ブロック自体は固定サイズです。管理構造の中に実効データサイズ(とリンクされたトータルのデータサイズ)の情報が含まれていて、管理機構により可変長バッファとして取り扱えるようになっています。
このため、コンポーネントの外から見たら可変長バッファであっても、メモリの断片化(フラグメンテーション)を起こさないようになっています。
各方式のトレードオフ比較を表にまとめました。
| 方式 | デッドロック | コピーコスト | メモリ管理の複雑さ | 適したユースケース |
|---|---|---|---|---|
| 単純なゼロコピー | 発生する | ゼロ | 単純 | メモリが極小かつ、アプリが完全にストリーム処理可能な場合(μIP+Modbus/TCPのような用途限定の「なんちゃって」TCP/IP実装) |
| バッファコピー | 解消される | 発生する | 単純 | CPU処理能力に余裕があり、実装の堅牢性を最優先する場合 |
| メモリプール | 解消される | ほぼゼロ | 複雑化する | コピーによる遅延を許容できない高速通信、かつ高度な通信スタック(lwIP)が使える場合 |
注意点としては、アプリケーション側でも lwIP のメモリプールのハンドリングをする必要があり、lwIP以外のプロトコルスタックへのポータビリティについては、ある程度の制約があるということでしょうか。なお、メモリプールからのデータの取り出しや格納のためのAPIとして pbuf_get_contiguous() や pbuf_copy_partial() 等が用意されています。
これについては、上位アプリの(TCP/IPスタックとの)結合レイヤに、pbuf とアプリ内部の連続バッファとのデータコピー処理を持たせれば、かなり緩和されます。
結局のところは、TCP/IP層とアプリ層の間でのデータコピーは必要となるのですが、メモリプールを使わない実装に比べ、ずっとスマートに処理できます(少なくともアプリ側にTCPレイヤの配慮が食い込むということは無くなる)。
このレイヤ間の分離については、受信側のみならず、送信側についても、対向局のダウンやネットワーク経路での障害などによりシーケンスが進まなくなると、アプリ側に影響を及ぼす危険性があり、ある程度のコスト支払いは許容してでも、きちんとやっておいた方が良いと考えます。
注意点(ハマり所)
ここからが本題です。
パフォーマンス向上のために採用された「ゼロコピー方式」と「メモリプールによるパケットバッファ」ですが、パフォーマンス追求のため、かなり思い切った構成となっています。
それは、「lwIPではメモリプール(memp / pbuf)により、イーサネット送受信バッファメモリが直接、イーサネット(L2)からTCP/IP(L3/L4)、ひいてはアプリケーション(L7)まで地続きの単一バッファとして共有される」 というものです。
LANケーブルから入ってきたデータは、イーサネットMACからのDMA転送によって直接この共有メモリに配置されます。そこからCPUによるコピーは一切挟まず、ポインタ(ディスクリプタ)の受け渡しだけでTCP層、そしてアプリケーション層まで到達します。CPUリソースが極めて貧弱な組込みMCUにおいて、これは最大のパフォーマンスを生み出します。
しかし一方で、本来独立しているべき「データリンク層(L2)」と「トランスポート/アプリケーション層(L4/L7)」の物理メモリ境界(レイヤー境界)が曖昧になってしまい、効率化と引き換えに、上位層の些細な振る舞いが物理層レベルの通信不能を引き起こすという、アーキテクチャ上のトレードオフを抱えています。
デバイス層への食い込み
lwIPの PBUF_POOL がイーサネットMACのDMAディスクリプタ(ハードウェアが直接参照するポインタのリングバッファ)と地続きで直結しているシステムにおいて、特定のTCPセッションがアプリ層の遅延等でパケット(pbuf)を保持し続けると、共通のメモリプール全体が枯渇します。
この結果、「イーサネットMACが、新規パケットを格納するための空きDMAバッファをハードウェアに割り当てられなくなる」という事態に陥り、トラブルを起こしている特定のセッションだけでなく、「正常な別セッションの通信」や、低レイヤーの「ARPリクエスト」など、デバイス全体の通信が完全沈黙(Pingすら応答不可)する全損級の障害へと発展します。これは、後述する通りデバッグ困難な致命的障害に相当します。
このゼロコピーを追求したメモリプール(pbuf)がデバイス層(イーサネットMAC/DMA)まで結合する構造(ハードウェア(DMA)結合型メモリプール)は、パフォーマンスの極限を追求できる一方で、レイヤーを横断する「巻き込み型」バッファ枯渇を引き起こすリスクがあるのです。
以下は、TCPセッション数の増大やアプリの遅延が、イーサネットMACのハードウェアバッファを完全に巻き込んで機能不全に陥る(デッドロックする)構造を示したものです。
【lwIPにおけるゼロコピー・メモリプールの結合構造と障害遷移】
[アプリケーション層(L7)]
│ ●意図しないTCPセッション数の増大
│ ●特定のセッションでアプリの処理が滞留
▼
[TCP層(L4)]
│ ●アプリがデータを未処理のため、受信データ(pbuf)が解放されない
│ ●セッション全体の合計で、lwIPのメモリプールを限界まで消費
▼
【レイヤー境界の崩壊】lwIPの pbuf プール(メモリプール)が枯渇
▼
[データリンク層 / 物理ハードウェア(L2)]
│ ●イーサネットMACの「DMAディスクリプタ(リングバッファ)」が、
│ 上位でロックされた pbuf を指したまま動かなくなる。
│ ●次に届く新規パケットを格納するための、
│ 「空きDMAバッファ」をハードウェアに割り当て(アサイン)できなくなる。
▼
[イーサネットIF全体の完全沈黙]
●既存の滞留セッションが動かないだけでなく、
【正常な別セッションの通信】や【ARPリクエスト】といった、
全く関係のない下位パケットすらMACハードウェアが受信拒否(ドロップ)する。
この構造がもたらす2つの問題
1. デバッグの困難さ
このバッファは、ハードウェア(DMA)、割り込みコンテキスト(イーサネットドライバ)、カーネル/OS(lwIPスレッド)、そしてタスク(アプリケーション)の4つの異なるコンテキストから非同期にアクセス・保持されます。
「あるパケットがなぜ解放されないのか」を追おうとしても、それがアプリケーションのロジックによるものか、TCPの再送制御によるものか、それともイーサネットMACドライバのディスクリプタ解放漏れ(バグ)によるものかの切り分けが極めて難しく、再現性の低いタイミング問題となります。
イーサネットMACが物理的にハングアップする(Pingすら返せなくなる)ため、そこから問題を切り分けて原因を絞り込むのが極めて難しい現象となります。
また、lwIP の memp / pbuf コンポーネントはとても優秀なアーキテクチャとなっている反面、少々凝り過ぎのきらいがあります。このため、デバッガとの相性が非常に悪く、インスペクションは困難を極めるものと思われます。
更に、STマイクロのコード(STM32CubeMXで生成されるSTM32Cubeライブラリ)のイーサネットI/F(HALドライバとの連携)の実装がなかなか難解で、この状況に輪をかけています。
2. TCPセッション増大による「巻き込み型」バッファ枯渇
通常のOSであれば、特定のTCPセッションがバッファを食いつぶしても、影響はそのソケット(またはプロセス)内だけに閉じます。
しかし、この共有プール構造では、クライアント接続(セッション数)が想定外に膨らんだり、一部の接続で上位アプリがブロックしただけで、共通のメモリプールがすべてロックされます。結果として、イーサネットMACコントローラが「物理的に新しいパケットを1バイトも受け取れない」状態になり、デバイス全体がネットワークから孤立する(Pingすら返せなくなる)という、レイヤーを無視したシステム全損級の障害を引き起こします。
解決・緩和に向けた設計思想
この「諸刃の剣」を制御するためには、システム設計において以下の境界線を引く必要があります。
- DMA専用バッファとlwIPプールの完全分離(部分コピーの許容)
イーサネットMACのDMAディスクリプタには、ハードウェア専用の小さな固定リングバッファ(例:ドライバ内でのみ閉じた領域)を割り当てます。受信割り込み(ISR)のタイミングで、そこからlwIPの pbuf へデータを「1回だけコピー」して即座にDMA側を解放する設計に変えます。これにより、極限のゼロコピーではなくなりますが、上位層のバッファ詰まりが物理MACの受信拒否に直結する最悪のシナリオは防げます。 - セッション数制限(Connection Limit)とリソース上限の同期
lwipopts.h において、最大TCPセッション数(MEMP_NUM_TCP_PCB)やメモリプール数(MEMP_NUM_PBUF)の設定を、「最悪のケース(全セッションが同時に最大バッファを占有したとき)」でもイーサネットドライバ側の要求を満たせる配分に計算し、ハードコードで厳格に制限をかけます。
1. ゼロコピーを意図的に諦め、ドライバ層に「ローカルバッファ」を挟む
イーサネットインタフェース(ethernetif.c)の内部だけで閉じた、バッファコピーの仕掛けを追加します。
lwIP の GitHub リポジトリから引っ張ってきたテンプレート(サンプル)用 ethernetif.c の low_level_input() 関数を見てみます。STマイクロが作り込んでいる ethernetif.c はかなりカスタマイズされているのですが、やっていることが非常に見難いため、敢えてこちらを見てみます。
/**
* Should allocate a pbuf and transfer the bytes of the incoming
* packet from the interface into the pbuf.
*
* @param netif the lwip network interface structure for this ethernetif
* @return a pbuf filled with the received packet (including MAC header)
* NULL on memory error
*/
static struct pbuf *
low_level_input(struct netif *netif)
{
struct ethernetif *ethernetif = netif->state;
struct pbuf *p, *q;
u16_t len;
/* Obtain the size of the packet and put it into the "len"
variable. */
len = ;
#if ETH_PAD_SIZE
len += ETH_PAD_SIZE; /* allow room for Ethernet padding */
#endif
/* We allocate a pbuf chain of pbufs from the pool. */
p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL);
if (p != NULL) {
#if ETH_PAD_SIZE
pbuf_remove_header(p, ETH_PAD_SIZE); /* drop the padding word */
#endif
/* We iterate over the pbuf chain until we have read the entire
* packet into the pbuf. */
for (q = p; q != NULL; q = q->next) {
/* Read enough bytes to fill this pbuf in the chain. The
* available data in the pbuf is given by the q->len
* variable.
* This does not necessarily have to be a memcpy, you can also preallocate
* pbufs for a DMA-enabled MAC and after receiving truncate it to the
* actually received size. In this case, ensure the tot_len member of the
* pbuf is the sum of the chained pbuf len members.
*/
read data into(q->payload, q->len);
}
acknowledge that packet has been read();
// 省略
}
return p;
}
該当するのがfor文の中です。ここに、イーサネットDMAのリングバッファからlwIPのパケットバッファ(のチェイン)へのコピーと、DMAのディスクリプタからのロック解除を記述するようにします。
この部分のコメントの中で、「必ずしもmemcpyする必要はなくて、あらかじめDMAリングバッファに割り当て済みの pbuf (のlenメンバとtot_lenメンバ)に、実際の受信サイズを与えてやればいいよ」旨の解説が書かれており、STの実装はまさにそのような感じになっています。
パケットバッファのサイズをイーサネットフレームサイズ(1536バイト)より大きく設定すれば、連結せず単発のパケットバッファで完結させられますが、スケーラビリティ(移植性)考えると、ここはオリジナルを尊重し弄らない方が適切かと考えます。
なお、STマイクロの ethernetif.c では、DMAバッファへの pbuf マッピングはHALドライバからのコールバック関数 HAL_ETH_RxAllocateCallback() / HAL_ETH_RxLinkCallback() で行われています。
これにより、上位の詰まりがハードウェアのハングアップへ連鎖するのを防ぐことができます。
2. 破綻を招く「相関関係」の見落としを防ぐ
ローカルコピーをせずDMAバッファを直接ハンドリングする実装の場合、バッファの枯渇やデッドロックを防ぐためには、「ハードウェアが要求する受信バッファの総量」が、「lwIPが用意しているプール(PBUF_POOL)の総量」を下回るようにアサインする必要があります。
注意が必要なのは、CubeMXのGUIは、各レイヤーの設定を別々の画面で行うため、「イーサネット層のハードウェア制約(DMA)」と「lwIP層のソフト仕様(lwipopts.h)」の整合性をチェックしてくれないということです。その結果、「CubeMXが吐き出したデフォルトのまま動かしたら、数時間後に突然Pingすら返さなくなる」という致命的な障害が発生することがあります。
STM32CubeMXのlwIP設定(より根源的には lwipopts.h)は項目数も多く、かなり分かりにくい内容なのですが、特にHALドライバとlwIPの相関関係設定はちょっとしたブラックボックスになっていて、これから始めようとする場合に、かなりの確率で突き当たる難所です。
イーサネットMACのDMAコントローラは、常に次のパケットを受け入れるために、空き状態のディスクリプタ(バッファのポインタ)を一定数、常駐させておく必要があります。もしも PBUF_POOL_SIZE(lwIP全体のプール数)を、DMAの受信ディスクリプタ数(ETH_RX_DESC_CNT)と同じかそれ以下にしてしまうと、「上位アプリの処理待ちでいくつかのpbufがロックされた瞬間、DMAコントローラへ引き渡す空きバッファがゼロになり、物理MACの受信が完全停止する」という状況(前述のデッドロック)を招きます。
各パラメータの具体的な連動関係
- Rx Descriptor Length (ETH_RX_DESC_CNT)
DMAの受信ディスクリプタ数。 - PBUF_POOL_BUFSIZE
1つのpbufが持つバッファサイズ。イーサネットの最大フレーム(MTU 1500 + ヘッダ等=1536〜1720バイト)が綺麗に収まるサイズにします。ここを小さく(例: 512バイト)すると、1つのパケットを受信するのに複数のpbufがチェーン状に連結されるため、管理オーバーヘッドが急増してハングアップの確率が上がります。 - TCP_MSS(最大セグメントサイズ)
イーサネットであれば通常 1460バイト(1500 - IPヘッダ20B - TCPヘッダ20B)固定です。 - TCP_WND(TCP受信ウィンドウサイズ)
送信側に一度に送っていいよと許可するサイズ。ここは最低でも TCP_MSS の2倍〜4倍に設定します。これより小さくすると通信速度が極端に落ちます。
基本となる設計方程式は以下の通りです。
$$\text{PBUF_POOL_SIZE} \ge \text{ETH_RX_DESC_CNT} + \text{TCP_WND} + \alpha$$
この式 α は待ち受けを常に空けておくための余剰分です。
詳しくは、STマイクロエレクトロニクスが公式に検証・発行している AN3966(lwIP TCP/IP stack demonstration) を参考に、開発するデバイスの要件に合わせ設定していきます。
実行時に「バッファの残数」をデバッグタスクで監視(可視化)する
lwIPには標準で統計情報を取得する機能(LWIP_STATS)が備わっていますので、実環境での想定外のバーストトラフィックで破綻する前兆を監視するため、メモリ監視タスクを忍ばせるのもありです。
- 別タスク、またはModbusの診断用レジスタ経由で、lwip_stats.memp[MEMP_PBUF_POOL].avail(プールの最大数)と .used(現在使用中)を常時モニタリング
- もし used が最大数に張り付いている時間があれば、それは「デッドロックの一歩手前」を意味するため、結合テストの段階で「PBUF_POOL_SIZE を増やす」か「セッションの最大数を絞る」といった軌道修正を行うことができる
あとがき
長いことイーサネット開発を離れている間に、TCPがモデルチェンジして RFC793 から RFC9293 に変わっていることを知りびっくりしました。まさに浦島太郎状態です。
あと、lwIPはコールバックが多用されていて、コードから受信フローを追いかけるのはなかなか大変だと思います。 公式の受信フロー図 が役に立つと思います。