1: はじめに
工場の中に設置されている産業用コンピュータとして PLC というものがあり、PLC のデータを読み書きすることで生産情報を管理することがあります。
今回使用している三菱 PLC では、MC プロトコル (以下 MC) を使ってデータを読み書きできます。
今回は Java で PLC と MC プロトコル (3E) で通信するテストプログラムを作りました。
1-1: MC プロトコルの概要
MC は、シリアルコミュニケーションを利用した 1C, 2C, 3C, 4C や Ethernet を利用した 1E, 3E, 4E に分類されます。
主に利用される MC (Ethernet) は TCP/IP のアプリケーション層のプロトコルであり、PLC 側がサーバとして動作します。
シンプルなプロトコルなので、仕様書を片手にソケット通信プログラムを書くことで、PLC のデータを読み書きできます。
1-2: MC プロトコルと SLMP の違い
- MC プロトコル (Melsec Communication Protocol)
- 三菱電機製MELSECシーケンサ用の通信プロトコル
- 3C, 4C, 3E, 4E など複数のタイプが定義されている
- SLMP (Seamless Message Protocol)
- 複数の PLC メーカーの機器で相互通信できるフィールドネットワーク規格 CC-Link IE で利用されるプロトコル
- MC プロトコル 3E および 4E フレームが、SLMP の ST (単一通信) および MT (複数通信) に組み込まれた
- 主に使用される MC プロトコルと SLMP ほぼ同じ規格だし、本記事も SLMP と言って良いはず
- SLMP 拡張として、機器情報のアクセスおよび他オープンネットワークへのアクセスがあるらしい
参考情報
- エムジートレンド 豆知識 - SLMPについて
- CC-Link 協会 IoT時代のFAネットワークお困りごと解決ガイド
1-3: 環境
動作確認に使用している機材などの情報です。
- PLC
- 三菱シーケンサ Q03UDV
- PLC エンジニアリングツール
- GX Works2
- Java
- Oracle OpenJDK 21
マニュアルの確認が必要ですが、SLMP に対応していれば Q03UDV 以外にも FX5U などでも動くかもしれません。
1-4: 参考情報
1-4-1: メイン
マニュアルのダウンロードには、アカウントの登録が必要です。
- 三菱電機 - MELSECコミュニケーションプロトコル リファレンスマニュアル
- Q シリーズ向けの MC プロトコルマニュアルです。以降 MC マニュアル と呼びます
- 資料番号: sh080003ah
- Qiita - PLCとTCP/IPネットワークを通して通信して、デバイス値の取得や書込みを行う方法
- Java で書かれているため、一番参考にしました
- 最新の Java を使っていると、一部のコードが動きません
- Qiita - GX Works2でSLMP通信を試すための内蔵Ethernet設定
- Q03UDV と GX Works2 の組み合わせでの設定手順が記載されています
1-4-2: その他
全体像がわかりやすいサイト群です。
- Qiita - PLCとRaspberry Piはとっても相性がいい。(通信の行い方)
- PLC や MC プロトコルが何に使えるのか?から解説されていて分かりやすいです
- Qiita - PythonでPLCのレジスタアクセスを試す
- Qiita - [続] PythonでPLCのレジスタアクセスを試す
- ビット・ワードデバイスへのアクセス方法が分かりやすく解説されています
通信ライブラリを作るとき、参考になりそうなサイト群です。
- Qiita - 三菱MELSEC MCプロトコルをpythonで送信
- Python で使える通信ライブラリが構築され、GitHub で公開されています
- GitHub - OkitaSystemDesign/MC-Protocol
- Qiita - PythonでMCプロトコルを使用してPLCのデータを取り出す
- こちらも Python で使える通信ライブラリです
- GitHub - yohei250r/pymcprotocol
- Qiita - 【設備制御プログラム】三菱PLC SLMPデバイスコード C#
- Q シリーズと iQ-R シリーズのコマンドの違いがコードで解説されています
2: PLC の前準備
あらかじめ、PLC側で次のような設定を行う必要があります。
- IPアドレス
- 今回は 172 .16 .12 .171
- MCプロトコルポート番号
- 今回は 1025 を使用します
- 「HTTP は TCP/80」といった決まったポート番号はないようです
- RUN中書込みの許可
- デバイス値を書き込むとき必要
- データの種類
- デフォルト指定のバイナリをそのまま選択する
- ASCII コードに変更することもできますが、プロトコルが変更になります
2-1: 手順(省略)
具体的な手順は、次の記事を参考に設定しました。
GX Works2でSLMP通信を試すための内蔵Ethernet設定
2-2: 動作確認
ping コマンドなどで確認できます。
C:\Users\user>ping 172.16.12.171
172.16.12.171 に ping を送信しています 32 バイトのデータ:
172.16.12.171 からの応答: バイト数 =32 時間 <1ms TTL=63
nmap を使ってポートスキャンしてみましたが、オプション指定のやり方が悪いのか、応答はありませんでした。
C:\Users\user>nmap 172.16.12.171 -p1025
Starting Nmap 7.80 ( https://nmap.org ) at 2025-01-01 17:11 ???? (?W???)
Note: Host seems down. If it is really up, but blocking our ping probes, try -Pn
Nmap done: 1 IP address (0 hosts up) scanned in 0.88 seconds
C:\Users\user>nmap 172.16.12.171 -Pn -p1025
Starting Nmap 7.80 ( https://nmap.org ) at 2025-01-01 17:11 ???? (?W???)
Nmap done: 1 IP address (0 hosts up) scanned in 0.88 seconds
3: Java ソケットプログラミング
3-1: ソケットプログラミング時の注意
プロトコルの仕様書は公開されているので、仕様書を読みながら通信プログラムを作ることになります。ただ、次のような注意点があります。
他にもあれば教えて下さい!
3-1-1: 複数クライアント同時接続できない
MC プロトコルでは PLC 側がサーバとなりますが、残念ながら複数クライアントが同時に接続することはできないようです。
(Q シリーズの MC マニュアル p.22 にて確認しました。他機種だと違うかも)
複数接続するには、ポート番号を複数設定しておいて、同時に接続しないように気をつける必要がありそうです。
3-1-2: ASCII とバイナリでエンディアン(バイトオーダ)が異なる
通信メッセージを送るとき、メッセージの送り方として ASCII とバイナリから選択することができます。
- PLC 側で ASCII を選択すると、設定値が複数バイトにまたがる場合は ビッグエンディアン で送ることになる
- ただし、バイナリを選択すると、 リトルエンディアン で送ることになる
つまり、次の図 (MC マニュアル p.39) を見るとわかりますが、シリアル番号 0x1234 を送るときは次のようになります。
- ASCIIコードだと '1', '2', '3', '4' の順で送ればよい
- バイナリコードだと 0x34, 0x12 の順で送れば良い
3-2: 伝文の作り方
MC プロトコルでは、通信メッセージのことを「伝文」と呼んでいます。
伝文は次のような4種類あります。(MC マニュアル p.41)
- 要求伝文
- とにかくクライアントから送るときの伝文
- 応答伝文
- 正常終了: 応答データあり
- デバイス値の取得を要求したときの応答、応答データに値が格納
- 正常終了: 応答データなし
- デバイス値の変更を要求したときの応答
- 応答伝文: 異常終了
- 要求伝文がおかしかったりしたときの応答、エラー情報にアクセス経路や要求コマンドが格納
- 正常終了: 応答データあり
先ほどの図の「ヘッダ」は TCP のヘッダのため、ソケット通信で意識するのはサブヘッダ以降となります。
また、要求伝文では「監視タイマー」のデータが、応答伝文では「終了コード」に変わります。どちらも 2 byte ですが。
3-2-1: 要求伝文の作り方 (一括読取り、ワードデバイス)
ここでは例として、データレジスタ D200 の値を読み取ろうとしたときの要求伝文を紹介します。実際に伝文を組み立てるときは、複数バイトの数値をリトルエンディアンとして指定する必要があります。
- 1 サブヘッダ (2 byte)
- 要求伝文は 0x0050 固定。応答伝文は 0x00D0 固定。
- 0x0050 (要求)
- 2 アクセス経路 (5 byte)
- 1 要求先ネットワーク番号
- 0x00
- 2 要求先PC番号
- 0xFF
- 3 要求先ユニットI/O番号
- 0x03FF
- 4 要求先PC番号
- 0x00
- 1 要求先ネットワーク番号
- 3 要求データ長 (4 byte)
- 次の監視タイマーと要求データを含めたバイト数 (4, 5)
- 0x000C
- 4 監視タイマー (2 byte)
- 0x0001 (250 ms)
- 5 要求データ (今回は 10 byte)
- 1 コマンド (2 byte)
- 0x0401
- 2 サブコマンド (2 byte)
- 0x0000
- 3 データ (6 byte)
- 1 デバイス番号 (3 byte)
- データレジスタは数値が10進数なので、そのまま 200 -> 0xC8 と指定する
- (もしも、入力 X200 を指定するならば、0x000200 と指定する)
- 0xC80000
- 2 デバイスコード (1 byte)
- データレジスタのデバイスコードを調べて指定する
- 0xA8
- 3 デバイス点数 (2 byte)
- 今回は一括読取りコマンドなので、読み取る点数を指定する
- 0x0001
- 1 デバイス番号 (3 byte)
- 1 コマンド (2 byte)
3-2-2: 応答伝文の作られ方 (一括読取り、ワードデバイス)
ここでは例として、データレジスタ D200 の値を読み取るため要求伝文を送った後、応答伝文を受け取ったときの読み方を紹介します。
- 1 サブヘッダ (2 byte)
- 要求伝文は 0x0050 固定。応答伝文は 0x00D0 固定。
- 0x00D0 (応答)
- 2 アクセス経路 (5 byte)
- 1 要求先ネットワーク番号
- 0x00
- 2 要求先PC番号
- 0xFF
- 3 要求先ユニットI/O番号
- 0x03FF
- 4 要求先PC番号
- 0x00
- 1 要求先ネットワーク番号
- 3 応答データ長 (4 byte)
- 次の終了コードと要求データを含めたバイト数 (4, 5)
- 0x000E
- 4 終了コード (2 byte)
- 正常時は 0x0000
- 異常時は 0 以外(RUN中書込みに失敗したときは 0x0055 だった)
異常時は、終了コードの後に続きもある。
4: 実際のコード
4-1: ワードデバイス (D200) の読取り
一括読取りコマンド (0x0401)を使用して、D200 の値を読み取っています。
実際のコードを読みたい方はこちらをクリック
import java.net.*;
import java.io.*;
import java.util.HexFormat;
// 参考: https://qiita.com/hidehito108/items/e8eca75a46ee7d59feed
public class WordReadSample {
public static void main(String[] args) throws Exception {
Socket so = null;
OutputStream out = null;
BufferedInputStream in = null;
try {
// PLC の IP アドレスとポート番号に合わせてください
String host = "172.16.12.171";
int port = 1025;
// ソケット作成
so = new Socket(host, port);
in = new BufferedInputStream(so.getInputStream());
// 要求伝文の構築
// 注意:
// 各行のコメントには、設定値が16進数で記載されています
// 文字列は16進数でエンコードしたバイト列を記載しています
// 例えば、サブヘッダの設定値は 0x0050 ですが、
// リトルエンディアンのため 0x50 0x00 というバイト列に変換され、"5000" と表記されます
byte[] requestBytes = HexFormat.of().parseHex(
"5000" // 1 サブヘッダ 要求は 0x0050 固定
// 2 アクセス経路
+ "00" // 1 要求先ネットワーク番号 0x00
+ "FF" // 2 要求先局番 0xFF
+ "FF03" // 3 要求先ユニットI/O番号 0x03FF
+ "00" // 4 要求先ユニット局番 0x00
+ "0C00" // 3 要求データ長 (監視タイマー以降のバイト数、監視タイマーも含む)
+ "0100" // 4 監視タイマー 0x0001
// 5 要求データ
+ "0104" // 1 一括読出しコマンド 0x0401
+ "0000" // 2 サブコマンド 0000
// 3 データ
+ "C80000" // 1 先頭デバイス番号 200 = 0x0000C8
+ "A8" // 2 デバイスコード データレジスタ=0xA8
+ "0100" // 3 デバイス点数 0x0001
);
// 500000ffff03000c00010001040000c80000a80100
System.out.println("Request: " + HexFormat.of().formatHex(requestBytes));
// 要求伝文の送信
out = so.getOutputStream();
out.write(requestBytes);
out.flush();
// 応答伝文の受信
byte[] responseBytes = new byte[20]; // 長さは20バイトあれば十分
in.read(responseBytes);
// responseBytes = HexFormat.of().parseHex("d00000ffff030004000000e80300000000000000");
// d00000ffff030004000000e80300000000000000
System.out.println("Response: " + HexFormat.of().formatHex(responseBytes));
// 終了コードチェック Low-Highの順なので8ビットシフト
int finishCode = (((int)responseBytes[10])<<8) | ((int)responseBytes[9]);
if (finishCode != 0) {
System.out.println("Error: " + finishCode);
return;
} else {
System.out.println("Success");
}
// デバイス点数1でワードデバイスとわかっているのでズバリ特定の場所を読む
short value = (short)(((responseBytes[12] & 0xFF) << 8) | (responseBytes[11] & 0xFF));
System.out.println("Value: " + value);
}
finally {
if(out != null) try { out.close(); } catch(Exception e){}
if(in != null) try { in.close(); } catch(Exception e){}
if(so != null) try { so.close(); } catch(Exception e){}
}
}
}
4-2: ワードデバイス (D200) の書込み
一括書込みコマンド (0x1401)を使用して、D200 に値を書き込んでいます。
実際のコードを読みたい方はこちらをクリック
import java.net.*;
import java.io.*;
import java.util.HexFormat;
// 参考: https://qiita.com/hidehito108/items/e8eca75a46ee7d59feed
public class WordWriteSample {
public static void main(String[] args) throws Exception {
Socket so = null;
OutputStream out = null;
BufferedInputStream in = null;
try {
// PLC の IP アドレスとポート番号に合わせてください
String host = "172.16.12.171";
int port = 1025;
// ソケット作成
so = new Socket(host, port);
in = new BufferedInputStream(so.getInputStream());
// 要求伝文の構築 D200 に 16 (0x0010) を書き込む例
// 注意:
// 各行のコメントには、設定値が16進数で記載されています
// 文字列は16進数でエンコードしたバイト列を記載しています
// 例えば、サブヘッダの設定値は 0x0050 ですが、
// リトルエンディアンのため 0x50 0x00 というバイト列に変換され、"5000" と表記されます
byte[] requestBytes = HexFormat.of().parseHex(
"5000" // 1 サブヘッダ 要求は 0x0050 固定
// 2 アクセス経路
+ "00" // 1 要求先ネットワーク番号 0x00
+ "FF" // 2 要求先局番 0xFF
+ "FF03" // 3 要求先ユニットI/O番号 0x03FF
+ "00" // 4 要求先ユニット局番 0x00
+ "0E00" // 3 要求データ長 (監視タイマー以降のバイト数、監視タイマーも含む)
+ "0100" // 4 監視タイマー 0x0001
// 5 要求データ
+ "0114" // 1 一括読出しコマンド 0x1401
+ "0000" // 2 サブコマンド 0000
// 3 データ
+ "C80000" // 1 先頭デバイス番号 200 = 0x0000C8
+ "A8" // 2 デバイスコード データレジスタ=0xA8
+ "0100" // 3 デバイス点数 0x0001
+ "1000" // 4 データ 0x0010 (16)
);
// 500000ffff03000e00010001140000c80000a801001000
System.out.println("Request: " + HexFormat.of().formatHex(requestBytes));
// 要求伝文の送信
out = so.getOutputStream();
out.write(requestBytes);
out.flush();
// 応答伝文の受信
byte[] responseBytes = new byte[20]; // 長さは20バイトあれば十分
in.read(responseBytes);
// responseBytes = HexFormat.of().parseHex("d00000ffff030002000000000000000000000000");
// d00000ffff030002000000000000000000000000
System.out.println("Response: " + HexFormat.of().formatHex(responseBytes));
// 終了コードチェック Low-Highの順なので8ビットシフト
int finishCode = (((int)responseBytes[10])<<8) | ((int)responseBytes[9]);
if (finishCode != 0) {
System.out.println("Error: " + finishCode);
return;
} else {
System.out.println("Success");
}
}
finally {
if(out != null) try { out.close(); } catch(Exception e){}
if(in != null) try { in.close(); } catch(Exception e){}
if(so != null) try { so.close(); } catch(Exception e){}
}
}
}
4-3: ビットデバイス (M10) の読取り
一括読取りコマンド (0x0401)を使用して、M10 の値を読み取っています。
実際のコードを読みたい方はこちらをクリック
import java.net.*;
import java.io.*;
import java.util.HexFormat;
// 参考: https://qiita.com/hidehito108/items/e8eca75a46ee7d59feed
public class BitReadSample {
public static void main(String[] args) throws Exception {
Socket so = null;
OutputStream out = null;
BufferedInputStream in = null;
try {
// PLC の IP アドレスとポート番号に合わせてください
String host = "172.16.12.171";
int port = 1025;
// ソケット作成
so = new Socket(host, port);
in = new BufferedInputStream(so.getInputStream());
// 要求伝文の構築 ビットデバイス M10 を読み込む例
// 注意:
// 各行のコメントには、設定値が16進数で記載されています
// 文字列は16進数でエンコードしたバイト列を記載しています
// 例えば、サブヘッダの設定値は 0x0050 ですが、
// リトルエンディアンのため 0x50 0x00 というバイト列に変換され、"5000" と表記されます
byte[] requestBytes = HexFormat.of().parseHex(
"5000" // 1 サブヘッダ 要求は 0x0050 固定
// 2 アクセス経路
+ "00" // 1 要求先ネットワーク番号 0x00
+ "FF" // 2 要求先局番 0xFF
+ "FF03" // 3 要求先ユニットI/O番号 0x03FF
+ "00" // 4 要求先ユニット局番 0x00
+ "0C00" // 3 要求データ長 (監視タイマー以降のバイト数、監視タイマーも含む)
+ "0100" // 4 監視タイマー 0x0001
// 5 要求データ
+ "0104" // 1 一括読出しコマンド 0x0401
+ "0000" // 2 サブコマンド 0000 (ワードデバイスのまま)
// 3 データ
+ "0A0000" // 1 先頭デバイス番号 10 = 0x00000A
+ "90" // 2 デバイスコード 内部リレーM = 0x90
+ "0100" // 3 デバイス点数 0x0001
);
// 500000ffff03000c000100010400000a0000900100
System.out.println("Request: " + HexFormat.of().formatHex(requestBytes));
// 要求伝文の送信
out = so.getOutputStream();
out.write(requestBytes);
out.flush();
// 応答伝文の受信
byte[] responseBytes = new byte[20]; // 長さは20バイトあれば十分
in.read(responseBytes);
// responseBytes = HexFormat.of().parseHex("d00000ffff030004000000010000000000000000");
// d00000ffff030004000000010000000000000000
System.out.println("Response: " + HexFormat.of().formatHex(responseBytes));
// 終了コードチェック Low-Highの順なので8ビットシフト
int finishCode = (((int)responseBytes[10])<<8) | ((int)responseBytes[9]);
if (finishCode != 0) {
System.out.println("Error: " + finishCode);
return;
} else {
System.out.println("Success");
}
// デバイス点数1でビットデバイスとわかっているのでズバリ特定の場所を読む
short value = (short)(responseBytes[11] & 0x01);
System.out.println("Value: " + value);
}
finally {
if(out != null) try { out.close(); } catch(Exception e){}
if(in != null) try { in.close(); } catch(Exception e){}
if(so != null) try { so.close(); } catch(Exception e){}
}
}
}
4-4: ビットデバイス (M10) の書込み
一括書込みコマンド (0x1401)を使用して、M10 の値を読み取っています。
実際のコードを読みたい方はこちらをクリック
import java.net.*;
import java.io.*;
import java.util.HexFormat;
// 参考: https://qiita.com/hidehito108/items/e8eca75a46ee7d59feed
public class BitWriteSample {
public static void main(String[] args) throws Exception {
Socket so = null;
OutputStream out = null;
BufferedInputStream in = null;
try {
// PLC の IP アドレスとポート番号に合わせてください
String host = "172.16.12.171";
int port = 1025;
// ソケット作成
so = new Socket(host, port);
in = new BufferedInputStream(so.getInputStream());
// 要求伝文の構築 ビットデバイス M10 に 1 を書き込む例
// 注意:
// 各行のコメントには、設定値が16進数で記載されています
// 文字列は16進数でエンコードしたバイト列を記載しています
// 例えば、サブヘッダの設定値は 0x0050 ですが、
// リトルエンディアンのため 0x50 0x00 というバイト列に変換され、"5000" と表記されます
byte[] requestBytes = HexFormat.of().parseHex(
"5000" // 1 サブヘッダ 要求は 0x0050 固定
// 2 アクセス経路
+ "00" // 1 要求先ネットワーク番号 0x00
+ "FF" // 2 要求先局番 0xFF
+ "FF03" // 3 要求先ユニットI/O番号 0x03FF
+ "00" // 4 要求先ユニット局番 0x00
+ "0E00" // 3 要求データ長 (監視タイマー以降のバイト数、監視タイマーも含む)
+ "0100" // 4 監視タイマー 0x0001
// 5 要求データ
+ "0114" // 1 一括書込みコマンド 0x1401
+ "0000" // 2 サブコマンド 0000 (ワードデバイスのまま)
// 3 データ
+ "0A0000" // 1 先頭デバイス番号 10 = 0x00000A
+ "90" // 2 デバイスコード 内部リレーM = 0x90
+ "0100" // 3 デバイス点数 0x0001
+ "0100" // 4 デバイス値 0x0001
);
// 500000ffff03000e000100011400000a00009001000100
System.out.println("Request: " + HexFormat.of().formatHex(requestBytes));
// 要求伝文の送信
out = so.getOutputStream();
out.write(requestBytes);
out.flush();
// 応答伝文の受信
byte[] responseBytes = new byte[20]; // 長さは20バイトあれば十分
in.read(responseBytes);
// responseBytes = HexFormat.of().parseHex("d00000ffff030002000000000000000000000000");
// d00000ffff030002000000000000000000000000
System.out.println("Response: " + HexFormat.of().formatHex(responseBytes));
// 終了コードチェック Low-Highの順なので8ビットシフト
int finishCode = (((int)responseBytes[10])<<8) | ((int)responseBytes[9]);
if (finishCode != 0) {
System.out.println("Error: " + finishCode);
return;
} else {
System.out.println("Success");
}
}
finally {
if(out != null) try { out.close(); } catch(Exception e){}
if(in != null) try { in.close(); } catch(Exception e){}
if(so != null) try { so.close(); } catch(Exception e){}
}
}
}