はじめに
前回の説明では、Modbus/TCPにおけるApplication Data Unit(ADU)について説明しました。
ADUは以下のように構成されています:
Modbus/TCP の ADU = MBAP Header(7バイト) + PDU
MBAP Header は、TCP上で Modbus の要求/応答を識別・整合させるためのヘッダであり、PDUの前に必ず付与されます。
1 バイト = 8 ビット
ADU最大サイズ:260 バイト
MBAP Header :7 バイト
├ Transaction Identifier (2バイト)
├ Protocol Identifier (2バイト) [0固定]
├ Length (2バイト)
└ Unit Identifier (1バイト) [0固定]
PDU最大:253バイト
├ Function Code:1バイト
└ Data:最大252バイト
今回は、前回説明しなかったMBAP Headerの各フィールドの役割と、実際のリクエスト/レスポンスの具体例を詳しく見ていきます。
1. MBAP Header の詳細
1.1 MBAP Header のフィールド構成(7バイト)
| フィールド | サイズ | 説明 |
|---|---|---|
| Transaction Identifier | 2B | 要求と応答の対応付けに使うID。クライアントが要求ごとに採番し、サーバーは応答で同じ値を返す。 |
| Protocol Identifier | 2B | Modbus/TCP では通常 0x0000 固定。 |
| Length | 2B | この後ろに続くバイト数(Unit Identifier + PDU の合計)。※MBAP Header自身の7バイトは含まない。 |
| Unit Identifier | 1B | 宛先ユニット識別子。Modbus RTU/ASCII へのゲートウェイで使うことがある。(通常は 0x00固定) |
1.2 Transaction Identifier(TID)の役割
Transaction Identifier(TID)は「問い合わせ番号」です。
クライアントが問い合わせを出すとき、各リクエストに固有のTIDを付与します。
サーバーは返事(レスポンス)に、受け取ったリクエストと同じTIDをそのまま付けて返します。
これにより、返ってきた返事が、どの問い合わせの返事かをクライアントが判別できます。
具体例:複数リクエストの処理
クライアント → サーバー(リクエスト)
├ リクエストA:TID=0x0001(例:0x03 読み取り)
└ リクエストB:TID=0x0002(例:0x10 書き込み)
サーバー → クライアント(レスポンス)
※処理時間の都合で、返事の順序が入れ替わることがある
├ レスポンスB:TID=0x0002(Bの返事が先に返る)
└ レスポンスA:TID=0x0001(Aの返事が後で返る)
このときクライアントは TIDを見るだけで判別できます:
- 「TIDが 0x0002 だから、これは "リクエストB" の返事」
- 「TIDが 0x0001 だから、これは "リクエストA" の返事」
1.3 Protocol Identifier
Modbus/TCP では 0x0000 固定です。
将来的に他のプロトコルと区別する可能性を考慮して設けられたフィールドですが、現在は常に0です。
1.4 Length フィールド
Length は 「この後ろに続くバイト数」を示します。
具体的には、Unit Identifier(1バイト) + PDU のバイト数 です。
なぜ Length が必要なのか?
TCP は「メッセージ単位」ではなく, [バイトの流れ]としてデータを届けます。
そのため、受信側は 「どこまでが 1 通(1ADU)なのか」を自分で判断する必要があります。
Length は、その境界を決めるための重要な情報です。
TCPの性質:データは必ずしもきれいに届かない
TCPでは、送信側が「1通のADU」として送ったデータも、受信側では次のような形で届くことがあります。
パターン1:途中までしか届かない(分割)
送信側:[ADU1: 12バイト全体] を送信
↓
受信側:最初に 7バイトだけ届く
しばらくして残り 5バイトが届く
パターン2:2通分がまとめて届く(結合)
送信側:[ADU1: 12バイト] → [ADU2: 13バイト] を連続送信
↓
受信側:25バイトがまとめて届く
00 01 00 00 00 06 00 03 00 64 00 02 | 00 02 00 00 00 07 00 03 04 12 34 56 78
└────────── ADU1 ──────────┘ └────────────── ADU2 ──────────────┘
パターン3:1.5通分が届く
受信側:最初に 20バイト届く
[ADU1: 12バイト完全] + [ADU2: 8バイト分まで]
残りの 5バイトは後で届く
だから受信側は「この1通(1ADU)の終わりはどこ?」を自分で判断する必要があります。
受信処理の流れ
Lengthを使った受信処理
Length フィールドを使うことで、TCPストリーム上から正しく 1 ADU を切り出すことができます。
ここでは、実際の受信側の処理手順を見ていきます。
受信側は以下の手順で処理します:
- 先頭 6 バイトを読む(Transaction ID + Protocol ID + Length)
- Length を取り出す
- 続けて Length バイト読む(Unit ID + PDU)
- ここまでで ちょうど 1ADU が完成
1.5 Unit Identifier
Modbus RTU/ASCII へのゲートウェイとして動作する場合に、宛先のスレーブIDを指定するために使用します。
通常のModbus/TCP通信では 0x00 固定 として扱われます。
2. PDU(Protocol Data Unit)の構成
PDUは Function Code(1バイト)+ Data で構成されます。
Dataの内容はファンクションコードによって異なります。
2.1 Read系ファンクションのリクエストPDU構造
Read Holding Registers (0x03) や Read Input Registers (0x04) のリクエストは以下の構成です:
PDU構成:
├ Function Code: 1バイト (0x03 または 0x04)
└ Data: 4バイト
├ Start Address: 2バイト(読み取り開始アドレス)
└ Quantity: 2バイト(読み取るレジスタ数)
Quantity(数量)とは:
- 一度のリクエストで何個のレジスタを読み取るかを指定します
- 0x03の場合は、1~125レジスタ(仕様上の上限)
ここまでの説明は以上です。実際のリクエストの中身を見てみましょう!
3. 具体例:Read Holding Registers (0x03)
多分、見たことがあると思いますが、Readリクエストの内訳は以下の通りです。
3.1 リクエストの例
[MBAP Header: 7バイト]
00 01 ← Transaction Identifier (0x0001)
00 00 ← Protocol Identifier (0x0000)
00 06 ← Length (6バイト = Unit ID 1バイト + PDU 5バイト)
00 ← Unit Identifier (0x00)
[PDU: 5バイト]
03 ← Function Code (0x03)
00 64 ← Start Address (0x0064 = 100)
00 02 ← Quantity (0x0002 = 2レジスタ)
これがどういう意味かというと:
クライアントがサーバーに対して以下の内容でリクエストを送信しています:
-
Transaction Identifier(00 01)
- この通信に「1番」という問い合わせ番号を付けています
- サーバーからの返事も同じ番号(00 01)で返ってきます
-
Protocol Identifier(00 00)
- Modbus/TCPであることを示す識別子(常に0x0000固定)
-
Length(00 06)
- この後ろに6バイト続くことを示しています
- 内訳:Unit ID(1バイト)+ PDU(5バイト)= 6バイト
-
Unit Identifier(00)
- 宛先ユニット(通常は0x00固定)
-
Function Code(03)
- ファンクションコード0x03 = Read Holding Registers(ホールディングレジスタ読み取り)
-
Start Address(00 64)
- 読み取り開始アドレス = 0x0064 = 100番地
-
Quantity(00 02)
- 読み取るレジスタ数 = 0x0002 = 2個
まとめると:
「アドレス100番から2個のHolding Registerを読み取ってください」という命令です。
4. Modbus/TCPの重要な制約:連続アドレスのみ読み取り可能
4.1 飛び飛びのアドレスは同時に読めない
Modbus/TCPでは、1回のリクエストで読み取れるのは連続したアドレス範囲のみです。
つまり、Start Address と Quantity で指定できるのは「開始位置から連続したブロック」に限られ、飛び飛び(無順序)のアドレスを1回でまとめて読むことはできません。
例:Start=100 / Quantity=10 なら 100〜109 を連続して読み取れます。
一方で、「100 と 120 と 130 だけを1回で読む」のような飛び飛び指定はできません(連続範囲ではないため)。
この制約のため、必要な点が飛び飛びに存在する場合は、次のいずれかの方針になります。
- 必要な点ごとにリクエストを分ける(通信回数が増える)
- 必要な点を含む“少し広めの連続範囲”を読み、必要な点だけを取り出す(余計なデータも読むが通信回数は減る)
4.2 まとめ読みの上限(125レジスタ)と “端数” の考え方(float32など)
Quantity(数量)はレジスタ数で指定し、0x03/0x04 は 1回あたり最大125レジスタまでです。
したがって、どれだけ連続範囲を広く取りたくても Quantity を126以上にして読むことはできません。
125を超える場合は、必ず複数回のリクエストに分割します。
ここで注意したいのが、float32 のように複数レジスタで1点を表現する型です(float32=2レジスタ、float64=4レジスタ 等)。
例えば float32 をまとめて読む場合、125レジスタ上限は次のようになります。
- 125 ÷ 2 = 62点 + 1レジスタ余り
この「余り1レジスタ」は、必ずしも完全に無駄というわけではありません。読み取りを複数回に分割する前提なら、別リクエスト側の先頭レジスタと組み合わせて1点分を復元できる可能性があります。
4.3 端数を“使う”例:境界をまたいで float32 を復元する
例として、float32 をレジスタ順に大量取得したいケースを考えます。
- 1回目:Start=0 / Quantity=125 → 0〜124 を読む
0〜123 は float32(2レジスタ)として取り出せますが、最後にレジスタ124が1つ余ります。 - 2回目:Start=125 / Quantity=125 → 125〜249 を読む
前回の余り(124)と今回の先頭(125)を組み合わせることで、float32を1点復元できます(2回のレスポンスにまたがって1点を作る)
