Modbus をPCの中で動かす ―― 産業用通信をハードなしで学ぶ(1/5)

  • modbus
  • python
  • 産業用通信
  • 組み込み
  • ネットワーク

工場で動いている装置同士は、どうやって会話しているのか。

その世界には Modbus、CANopen、EtherCAT、PROFINET といった通信規格が並んでいます。専用のハードウェアと数十万円のソフトウェアが要る、というイメージを持たれがちです。

実際に装置を動かすならそのとおりですが、学ぶだけなら事情が違います。主要な規格にはオープンソースの実装があり、ハードウェアがなくても、自分のPCの中だけで動かせます。

このシリーズは全5回です。Modbus から CANopen、OPC UA へと順に見ていき、最終回でこの3つを1つのゲートウェイにつなぎます。pip install 4つと、無料で読める仕様書だけで全部動きます。

産業用通信シリーズ全体の階層図
このシリーズで扱う5回分。現場のセンサやサーボから、上位の IT・クラウドまでを順に見ていきます。今回はいちばん下の Modbus からです。

初回は Modbus。1979年に生まれて、2026年の今も現役です。

この記事で作るもの

PCの中に Modbus のスレーブ機器(サーバ)を立て、そこに読み書きしながら、線の上を流れる生のバイト列を16進で読みます。

$ ./.venv/bin/python 01_modbus/demo_tcp.py
--- 保持レジスタ 0〜3 を読む ---
  RX 00 01 00 00 00 06 01 03 00 00 00 04
  TX 00 01 00 00 00 0b 01 03 08 00 00 00 00 00 00 00 00
  -> [0, 0, 0, 0]
(中略)
--- 存在しない 9000 番地を読む ---
  RX 00 05 00 00 00 06 01 03 23 28 00 01
  TX 00 05 00 00 00 03 01 83 02
  isError: True | ExceptionResponse(dev_id=1, function_code=131, exception_code=2)

この16進の羅列を、1バイトずつ最後まで読んでいきます。

使うもの(すべてオープンソース・費用ゼロ・ハード不要)

pymodbus 3.14.0(BSD-3-Clause)だけです。仕様書も Modbus Organization が登録不要で無料公開しています(後述)。

git clone https://github.com/logicia32/industrial-networks-lab.git
cd industrial-networks-lab
python3 -m venv .venv                                     # Windows: py -m venv .venv
./.venv/bin/pip install "pymodbus[serial]==3.14.0"        # Windows: .venv\Scripts\pip

以降のコマンドは、この industrial-networks-lab の中で実行します。5回ぶんのコードが同じリポジトリに入っているので、clone は最初の1回だけです。

本編は全OSで動きます(付録の RTU だけ Windows 不可)。以降のコマンドは Linux / macOS 形式で書くので、Windows では .venv\Scripts\python に読み替えてください。動作確認は Python 3.12 / WSL2 Ubuntu 24.04。Python 3.10 以上が必要です。


① Modbus とは何か

1979年、Modicon 社(今の Schneider Electric)が自社の PLC 同士を繋ぐために作りました。設計目標は「安いこと」。当時のマイコンで実装でき、シリアル線1本で足りることが最優先でした。

今でも、太陽光パワコン、電力メーター、温調計、インバータといったセンサやメーターの類は、たいてい Modbus に対応しています。

Modbus 対応フィールド機器の模式図
Modbus に対応した機器は、こういう箱です。表示部と端子、RS-485 か Ethernet のポートを持ち、線の向こうで PC とつながっています。

これだけ長く残っているのは、やっていることが少ないからだと思います。Modbus にあるのは次の2つだけ。

  1. マスタが「何番地を何個ください」と聞き、スレーブが答える
  2. データは 16bit のレジスタか、1bit のコイル

認証もセッションも暗号化も、機器の自動発見も、データ型の宣言もありません。そのぶん8bitマイコンでも実装でき、仕様も大きく変わらないまま使われ続けてきました。この足りなさが、後の回で CANopen や OPC UA が何を足したのかを見るときの基準になります。


② 仕組み

4つのデータ領域

Modbus のデータは4種類あります。

領域 単位 読み書き イメージ
Coils 1 bit 読み書き 出力リレー
Discrete Inputs 1 bit 読み取り専用 入力接点
Holding Registers 16 bit 読み書き 設定値・指令値
Input Registers 16 bit 読み取り専用 測定値

よく使われるのは Holding Registers と Input Registers です。

プロトコルの上では、この4つはファンクションコードで区別される別々のテーブルです。ただし、機器の中で物理メモリまで4つに分かれているとは限りません。仕様は、同じメモリを複数のテーブルとして見せる作りも認めています。

40001番地の正体

Modbus を触っていると、どこかで「40001」に出会います。ベンダのマニュアルには「運転周波数は 40002 番地」などと書いてあります。

ところがプロトコルの仕様書には、40001 という表記が一度も出てきません。仕様(MODBUS Application Protocol Specification V1.1b3)が決めているのは、データモデルでは要素を1から数え、線の上(PDU)では0から数える、という対応です。データモデルの n 番目が、線の上の n−1 番地になります。そのうえで、こう明記されています。

The pre-mapping between the MODBUS data model and the device application is totally vendor device specific.
(データモデルと機器のアプリケーションの対応付けは、完全にベンダ依存である)

40001 は線の上の 0 番地というのは Modicon 984 という古い PLC 由来の慣習表記であって、規格の一部ではありません。

Modicon 40001・データモデル1起点・ワイヤ上0番地の対応図
同じ場所を指す3通りの数え方。慣習表記の 40001、データモデルの1番目、線の上の 0 番地が、−1 のずれでつながっています。

多くの機器はこの慣習どおりですが、ずれるものもあります。最初に繋ぐときは、1つ書いて1つ読んで、期待した場所に入ったかを確かめておくと安心です。

ファンクションコード

「何をしてほしいか」を表す1バイトです。よく出てくるのはこのあたりです。

FC 名前 対象
03 Read Holding Registers 保持レジスタを読む
04 Read Input Registers 入力レジスタを読む
06 Write Single Register 保持レジスタに1つ書く
16 (0x10) Write Multiple Registers 保持レジスタにまとめて書く
01 / 05 / 15 (0x0F) Read Coils / Write Single Coil / Write Multiple Coils コイル(1bit)を読む・1つ書く・まとめて書く

フレームの形

Modbus には伝送方式が2系統あります。

RTU(シリアル線)

[アドレス 1B][ファンクションコード 1B][データ][CRC 2B]

TCP

[MBAPヘッダ 7B][ファンクションコード 1B][データ]
 └ Transaction ID 2B / Protocol ID 2B / Length 2B / Unit ID 1B

真ん中の [ファンクションコード][データ] は両者で完全に同じで、これを PDU と呼びます。RTU と TCP の違いは、その PDU をどう包むかだけです。

RTU フレームと TCP フレームの重ね合わせ図
真ん中の PDU は RTU も TCP も同じ。RTU は前後にアドレスと CRC を付け、TCP は前に MBAP ヘッダを付けます。TCP に CRC はありません。

Unit ID が TCP でも残っている理由

IP アドレスがあるのに、なぜ機器の番号が要るのか。ゲートウェイのためです。1つの IP アドレスの裏にシリアル線の機器が何台もぶら下がる構成があり、その先の何番かを指定するのが Unit ID。シリアル線のスレーブアドレスがそのまま昇格したものです。最終回のゲートウェイで実際に使います。

例外応答

要求が処理できないとき、スレーブはファンクションコードの最上位ビットを立てて返します。FC 03 なら 0x83 です。

例外コード 意味
01 ILLEGAL FUNCTION(対応していないFC)
02 ILLEGAL DATA ADDRESS(範囲外の番地)
03 ILLEGAL DATA VALUE(要求に入っている個数や長さが不正。書いた値が業務的に妥当かどうかではない)
04 SERVER DEVICE FAILURE
sequenceDiagram
    participant M as マスタ
    participant S as スレーブ
    Note over M,S: 正常な読み取り
    M->>S: FC 03「0番地から4個ください」
    S-->>M: FC 03 データ8バイトを返す
    Note over M,S: 存在しない番地への読み取り
    M->>S: FC 03「9000番地をください」
    S-->>M: FC 0x83 / 例外コード 02(範囲外の番地)

③ 手を動かす

スレーブ機器を立てて、読み書きする

01_modbus/demo_tcp.py の要点だけ抜き出します(全文は後述のリポジトリにあります)。

def trace(sending: bool, data: bytes) -> bytes:
    """線の上を流れる生バイトをそのまま表示する"""
    print(f"  {'TX' if sending else 'RX'} {data.hex(' ')}")
    return data


async def main():
    # 機器の中身を作る(コイル / 入力接点 / 保持レジスタ / 入力レジスタ)
    co = [SimData(0, count=8, values=False, datatype=DataType.BITS)]
    di = [SimData(0, count=8, values=True,  datatype=DataType.BITS)]
    hr = [SimData(0, count=8, values=0, datatype=DataType.REGISTERS)]
    ir = [SimData(0, count=4, values=7, datatype=DataType.REGISTERS)]
    dev = SimDevice(1, simdata=(co, di, hr, ir))

    server = ModbusTcpServer(dev, address=("127.0.0.1", 5020), trace_packet=trace)
    asyncio.create_task(server.serve_forever())
    await asyncio.sleep(0.5)

    client = AsyncModbusTcpClient("127.0.0.1", port=5020)
    await client.connect()

    rr = await client.read_holding_registers(0, count=4, device_id=1)
    await client.write_register(0, 1234, device_id=1)
    rr = await client.read_holding_registers(9000, count=1, device_id=1)   # 範囲外
    print("  isError:", rr.isError(), "|", rr)

trace_packet に渡した関数が、送受信されるバイト列をそのまま見せてくれます。なお SimDevice の4ブロックは、空のリストを渡すと IndexError で落ちます。それで、使わない領域にも SimData を1つずつ置いてあります。

出力を1バイトずつ読む

RX 00 01 00 00 00 06 01 03 00 00 00 04
バイト 意味
0-1 00 01 Transaction ID(1個目の要求)
2-3 00 00 Protocol ID(Modbus TCP では常に0)
4-5 00 06 これ以降の長さ 6 バイト(Unit ID 1 + PDU 5)
6 01 Unit ID = 1
7 03 FC 03 = Read Holding Registers
8-11 00 00 00 04 開始番地 0(慣習表記の 40001)から 4 個

応答は TX 00 01 00 00 00 0b 01 03 08 ...00 0b は以降が 11 バイトという意味で、その内訳は Unit ID 1 + FC 1 + バイト数 1 + データ 8 です。08 はそのデータ部の 8 バイト、つまり 16bit × 4個ぶんを指します。書き込みの RX ... 01 06 00 00 04 d2 にある 04 d2 は 1234 の16進です。

そして例外応答:

RX 00 05 00 00 00 06 01 03 23 28 00 01
                           └ 0x2328 = 9000 番地を要求
TX 00 05 00 00 00 03 01 83 02
                        │  └ 例外コード 02 = ILLEGAL DATA ADDRESS
                        └ 0x83 = 0x03 の最上位ビットを立てたもの

②で読んだ仕様が、そのままバイト列に出ています。ここが読めれば、Modbus 機器とのやりとりは Wireshark なしでも追えます。

付録

  • 01_modbus/demo_rtu.py — RTU をシリアルポートなしで動かす(Linux / macOS)。pty.openpty() で仮想シリアルのペアを作るので socat も root も不要です。実行すると RX 01 03 00 00 00 04 44 09 のような RTU フレームが見え、MBAP の7バイトが消えて先頭にアドレス、末尾に CRC が付いただけで、真ん中の 03 00 00 00 04 は TCP とまったく同じことが確認できます
  • libmodbus(LGPL-2.1-or-later)— 産業機器の実物は C です。リリース tarball には configure が同梱されているので ./configure && make だけでビルドできます(cmake も root も不要)

④ 分かったこと、分からないままのこと

ここまでで読み書きはできました。ただ、動かしてみて私が気になったのは、Modbus が決めていない部分の多さのほうです。

1. 型がない

読めたのは [1234, 0, 0, 0] という 16bit の数字の列だけです。これが温度なのか電圧なのか、単位は何か、小数点がどこか。線の上には一切書かれていません。

32bit の値はもっと厄介です。仕様が定めているのは「16bit レジスタ1個の中は上位バイトが先」だけで、2つのレジスタをまたぐときの順番には触れていません。上位バイトから順に A,B,C,D と呼ぶと、実機で見かけるのは ABCD / CDAB / BADC / DCBA の4通り。後ろの2つはレジスタの中のバイトまで入れ替わっていて仕様どおりではありませんが、機器やツールの設定項目としては実在します。

32bit 値のワード順4パターン ABCD/CDAB/BADC/DCBA
同じ32bit の値が、レジスタの並べ方しだいで ABCD / CDAB / BADC / DCBA の4通りに化けます。どれで読むべきかは機器のマニュアル頼みです。

「Modbus はビッグエンディアン」で済むのは、レジスタ1個の中までです。

2. 意味がネットワークの外にある

40001番地に何が入っているかは、ベンダのPDFの中にしかありません。機器に問い合わせても返ってこないので、人がPDFを読んで、コードに定数として書き写すことになります。

3. マスタが全部を順番に聞く

スレーブは自分から喋れません。異常が起きても、マスタが聞きに来るまで黙っています。スレーブが30台あれば、1周するのに30往復かかります。

4. セキュリティは「ない」のではなく「使われていない」

MODBUS/TCP Security という仕様が実在します。TLS で暗号化と相互認証を行うもので、modbus.org から無料・登録不要でダウンロードできます。仕様が無いわけではなく、対応した機器の話をあまり聞かない、という状態です。

コラム: Modbus の上に意味を足した人たち(SunSpec)

太陽光発電の業界には SunSpec という取り決めがあります。先頭2レジスタに 0x5375, 0x6E53(ASCII で SunS)というマーカーを置き、そこから「モデルID + 長さ」のヘッダを数珠つなぎに辿れるようにしてあります。アドレスしかない Modbus の上に、後から自己記述の層を貼り付けたわけです。

コード

この連載で動かすコードは、5回ぶんまとめて GitHub に置いてあります。今回の 01_modbus/ のほかに、あとの回で使う CANopen と OPC UA も同じリポジトリにあります。

https://github.com/logicia32/industrial-networks-labhttps://github.com/logicia32/industrial-networks-lab

次回

次は CANopen です。Modbus に足りなかったもののうち、「デバイス自身が、自分に何があるかの辞書を持つ」という仕組みが、規格の中心に据えられています。

今度はサーボモータが回ります。これも、PCの中だけで動かせます。

今回使ったもののライセンス

pymodbus 3.14.0(BSD-3-Clause)/ libmodbus 3.1.11(LGPL-2.1-or-later)。いずれもオープンソースです。

仕様書(執筆時点でいずれも無料・登録不要)

  • MODBUS Application Protocol Specification V1.1b3 — modbus.org/file/secure/modbusprotocolspecification.pdf
  • MODBUS/TCP Security Protocol Specification — modbus.org/file/secure/modbussecurityprotocol.pdf

Serial Line と TCP の実装ガイドも同じ場所にあります。

この記事は Zenn に初出したものを加筆・補足したものです ── Zenn の元記事を見る