Modbus TCP 深度解析 (二):功能碼詳解與實戰範例

0x00 前情提要

上一集中,我們深入了解了 Modbus TCP 的基本概念和封包結構。今天我們將聚焦於功能碼 (Function Code),這是 Modbus 協議的核心,決定了每個請求要執行什麼操作。

上集練習題答案:

Hex: 00 05 00 00 00 06 01 06 00 0A 03 E8

解析:
- Transaction ID: 0x0005 (5)
- 這是一個請求封包
- 功能碼: 0x06 (寫入單個暫存器)
- 目標地址: 0x000A (10)
- 寫入數值: 0x03E8 (1000)

0x01 功能碼總覽

Modbus TCP 支援多種功能碼,可分為以下幾類:

標準功能碼分類

類別功能碼範圍說明
讀取功能0x01-0x04讀取各種資料型別
寫入功能0x05-0x06寫入單一數值
批次寫入0x0F-0x10批次寫入操作
診斷功能0x08診斷和測試
其他功能0x2B設備識別等

常用功能碼對照表

功能碼名稱資料型別操作類型
0x01Read Coils線圈 (1bit)讀取
0x02Read Discrete Inputs離散輸入 (1bit)讀取
0x03Read Holding Registers保持暫存器 (16bit)讀取
0x04Read Input Registers輸入暫存器 (16bit)讀取
0x05Write Single Coil線圈 (1bit)寫入
0x06Write Single Register保持暫存器 (16bit)寫入
0x0FWrite Multiple Coils線圈 (1bit)批次寫入
0x10Write Multiple Registers保持暫存器 (16bit)批次寫入

0x02 讀取功能碼詳解

0x01 - Read Coils (讀取線圈)

線圈是 1 位元的離散輸出,通常用於控制開關、閥門、指示燈等。

請求格式:

功能碼 (1) + 起始地址 (2) + 數量 (2) = 5 bytes

範例:讀取從地址 0x0000 開始的 8 個線圈

請求: 00 01 00 00 00 06 01 01 00 00 00 08

解析:
┌─────────────── MBAP Header ──────────────┬─────── PDU ────────┐
│ TID  │ PID  │ Len  │ UID │ FC │ Addr │ Qty │
│ 0001 │ 0000 │ 0006 │ 01  │ 01 │ 0000 │ 08  │
└──────┴──────┴──────┴─────┴────┴──────┴─────┘

回應:假設線圈狀態為 10110100 (從右到左)

回應: 00 01 00 00 00 04 01 01 01 2D

解析:
- Byte Count: 0x01 (1 byte,因為 8 個線圈需要 1 個位元組)
- Data: 0x2D (二進位: 00101101)

線圈狀態對應:
位置: 7 6 5 4 3 2 1 0
狀態: 0 0 1 0 1 1 0 1
地址: - - - - 3 2 1 0  (只有前 8 個線圈有效)

0x03 - Read Holding Registers (讀取保持暫存器)

保持暫存器是 16 位元的讀寫暫存器,用於儲存數值資料。

範例:讀取溫度和壓力資料

請求: 00 02 00 00 00 06 01 03 00 64 00 02

解析:
- Transaction ID: 0x0002
- 功能碼: 0x03 (讀取保持暫存器)
- 起始地址: 0x0064 (100) - 溫度暫存器
- 數量: 0x0002 (2 個暫存器) - 溫度和壓力

回應: 00 02 00 00 00 07 01 03 04 00 FA 01 90

解析:
- Byte Count: 0x04 (4 bytes = 2 registers × 2 bytes)
- Register 100: 0x00FA = 250 (25.0°C)
- Register 101: 0x0190 = 400 (4.00 bar)

0x04 - Read Input Registers (讀取輸入暫存器)

輸入暫存器是唯讀的 16 位元暫存器,通常用於感測器資料。

範例:讀取多個感測器數值

請求: 00 03 00 00 00 06 01 04 00 00 00 04

回應: 00 03 00 00 00 0B 01 04 08 01 2C 02 58 03 84 04 B0

解析:
- 8 bytes 資料 = 4 個輸入暫存器
- Register 0: 0x012C = 300 (光度感測器)
- Register 1: 0x0258 = 600 (濕度感測器)
- Register 2: 0x0384 = 900 (壓力感測器)
- Register 3: 0x04B0 = 1200 (流量感測器)

0x03 寫入功能碼詳解

0x05 - Write Single Coil (寫入單個線圈)

用於控制單一的數位輸出。

特殊值說明:

  • 0xFF00: 設定線圈為 ON
  • 0x0000: 設定線圈為 OFF

範例:開啟警報燈

請求: 00 04 00 00 00 06 01 05 00 0A FF 00

解析:
- Transaction ID: 0x0004
- 功能碼: 0x05 (寫入單個線圈)
- 線圈地址: 0x000A (10) - 警報燈控制
- 數值: 0xFF00 (開啟)

正常回應: 00 04 00 00 00 06 01 05 00 0A FF 00
(回應與請求相同,表示操作成功)

0x06 - Write Single Register (寫入單個暫存器)

用於設定單一的數值參數。

範例:設定溫度設定點

請求: 00 05 00 00 00 06 01 06 00 C8 00 DC

解析:
- Transaction ID: 0x0005
- 功能碼: 0x06 (寫入單個暫存器)
- 暫存器地址: 0x00C8 (200) - 溫度設定點
- 數值: 0x00DC = 220 (22.0°C)

正常回應: 00 05 00 00 00 06 01 06 00 C8 00 DC

0x04 批次操作功能碼

0x0F - Write Multiple Coils (批次寫入線圈)

一次設定多個線圈狀態,提高效率。

範例:設定 8 個輸出線圈

請求: 00 06 00 00 00 08 01 0F 00 00 00 08 01 A5

解析:
- 功能碼: 0x0F (批次寫入線圈)
- 起始地址: 0x0000
- 數量: 0x0008 (8 個線圈)
- Byte Count: 0x01 (1 個位元組的資料)
- Data: 0xA5 (二進位: 10100101)

線圈設定:
位置: 7 6 5 4 3 2 1 0
狀態: 1 0 1 0 0 1 0 1
地址: - - - - 3 2 1 0

回應: 00 06 00 00 00 06 01 0F 00 00 00 08
(確認寫入了 8 個線圈)

0x10 - Write Multiple Registers (批次寫入暫存器)

一次設定多個暫存器數值。

範例:設定生產參數

請求: 00 07 00 00 00 0D 01 10 00 64 00 03 06 01 2C 02 58 03 84

解析:
- 功能碼: 0x10 (批次寫入暫存器)
- 起始地址: 0x0064 (100)
- 數量: 0x0003 (3 個暫存器)
- Byte Count: 0x06 (6 bytes = 3 registers × 2 bytes)
- Register 100: 0x012C = 300 (速度設定)
- Register 101: 0x0258 = 600 (溫度設定)
- Register 102: 0x0384 = 900 (壓力設定)

回應: 00 07 00 00 00 06 01 10 00 64 00 03

0x05 實用程式碼範例

Python 實作範例

import struct
import socket

class ModbusTCPClient:
    def __init__(self, host, port=502):
        self.host = host
        self.port = port
        self.sock = None
        self.transaction_id = 1

    def connect(self):
        """建立 TCP 連線"""
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.settimeout(5)  # 5 秒超時
        self.sock.connect((self.host, self.port))

    def _build_mbap_header(self, length, unit_id=1):
        """建立 MBAP Header"""
        tid = self.transaction_id
        self.transaction_id = (self.transaction_id + 1) % 65536

        return struct.pack('>HHHB',
                          tid,      # Transaction ID
                          0,        # Protocol ID
                          length,   # Length
                          unit_id)  # Unit ID

    def read_holding_registers(self, address, count, unit_id=1):
        """讀取保持暫存器"""
        # PDU: Function Code (1) + Address (2) + Count (2)
        pdu = struct.pack('>BHH', 0x03, address, count)

        # MBAP Header: Unit ID (1) + PDU length
        mbap = self._build_mbap_header(len(pdu) + 1, unit_id)

        # 發送請求
        request = mbap + pdu
        self.sock.send(request)

        # 接收回應
        response = self.sock.recv(1024)

        # 解析回應
        if len(response) < 9:  # 最小回應長度
            raise Exception("回應太短")

        # 解析 MBAP Header
        tid, pid, length, uid = struct.unpack('>HHHB', response[:7])

        # 解析 PDU
        func_code = response[7]
        if func_code == 0x03:
            byte_count = response[8]
            data = response[9:9+byte_count]

            # 解析暫存器資料
            registers = []
            for i in range(0, byte_count, 2):
                reg_value = struct.unpack('>H', data[i:i+2])[0]
                registers.append(reg_value)

            return registers
        else:
            raise Exception(f"非預期的功能碼: {func_code}")

    def write_single_register(self, address, value, unit_id=1):
        """寫入單個暫存器"""
        # PDU: Function Code (1) + Address (2) + Value (2)
        pdu = struct.pack('>BHH', 0x06, address, value)

        # MBAP Header
        mbap = self._build_mbap_header(len(pdu) + 1, unit_id)

        # 發送請求
        request = mbap + pdu
        self.sock.send(request)

        # 接收回應
        response = self.sock.recv(1024)

        # 驗證回應
        if response[7] == 0x06:
            return True
        else:
            raise Exception("寫入失敗")

    def close(self):
        """關閉連線"""
        if self.sock:
            self.sock.close()

# 使用範例
def main():
    client = ModbusTCPClient('192.168.1.100')

    try:
        client.connect()
        print("連線成功")

        # 讀取暫存器
        registers = client.read_holding_registers(0, 5)
        print(f"讀取到的暫存器值: {registers}")

        # 寫入暫存器
        client.write_single_register(10, 1234)
        print("寫入成功")

    except Exception as e:
        print(f"錯誤: {e}")
    finally:
        client.close()

if __name__ == "__main__":
    main()

0x06 效能最佳化技巧

1. 批次操作優化

# 不好的做法:逐一讀取
for i in range(10):
    value = client.read_holding_registers(i, 1)[0]
    process(value)

# 好的做法:批次讀取
values = client.read_holding_registers(0, 10)
for i, value in enumerate(values):
    process(value)

2. 連線重用

# 保持連線開啟,避免頻繁建立/關閉
class OptimizedModbusClient:
    def __init__(self, host, port=502):
        self.client = ModbusTCPClient(host, port)
        self.client.connect()

    def __enter__(self):
        return self.client

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.client.close()

# 使用 context manager
with OptimizedModbusClient('192.168.1.100') as client:
    # 多次操作共用同一個連線
    data1 = client.read_holding_registers(0, 10)
    data2 = client.read_holding_registers(20, 5)
    client.write_single_register(30, 500)

0x07 常見錯誤與除錯

1. Transaction ID 不匹配

def verify_transaction_id(request_tid, response):
    response_tid = struct.unpack('>H', response[:2])[0]
    if request_tid != response_tid:
        raise Exception(f"Transaction ID 不匹配: {request_tid} != {response_tid}")

2. 功能碼驗證

def verify_function_code(expected_fc, response):
    actual_fc = response[7]
    if actual_fc == expected_fc + 0x80:  # 錯誤回應
        exception_code = response[8]
        raise Exception(f"Modbus 異常: {exception_code}")
    elif actual_fc != expected_fc:
        raise Exception(f"功能碼不符: 期望 {expected_fc}, 收到 {actual_fc}")

0x08 下集預告

在下一集《資料模型與地址空間》中,我們將探討:

  • Modbus 四種資料型別的詳細說明
  • 地址對應和計算方法
  • 不同設備的地址配置策略
  • 資料型別轉換技巧

0x09 實作練習

練習 1: 分析以下封包是什麼操作?

00 08 00 00 00 09 01 10 00 32 00 02 04 00 64 00 C8

練習 2: 如何用批次寫入設定 16 個線圈的狀態為 “1010110011001100”?

答案將在下一集公布!


本文為 Modbus TCP 深度解析系列第二篇,歡迎持續關注!

聯絡作者

歡迎透過以上方式與我交流資安技術與 CTF 心得!