Files
obsidian-vault/Modbus/RTU_Framing_Source_Analysis.md
T

6.7 KiB
Raw Blame History

Modbus RTU Frame Delimiting: pymodbus vs libmodbus (source analysis)

Источник кода — реальные файлы с GitHub, скачаны при разборе бага modbus-bridge (см. family/plans/t610-home-automation §5-кватер-З и §5-кватер-Ж). Локальные копии: /Users/admin/modbus_src/.

Границы (URL)

  • pymodbus dev (новый фреймер): https://raw.githubusercontent.com/pymodbus-dev/pymodbus/dev/pymodbus/framer/rtu.py
  • pymodbus v3.4.1 (классический ModbusRtuFramer): .../v3.4.1/pymodbus/framer/rtu_framer.py
  • pymodbus v3.0.2: .../v3.0.2/pymodbus/framer/rtu_framer.py
  • pymodbus serial client (t35): .../v3.4.1/pymodbus/client/serial.py, .../v3.6.9/pymodbus/client/serial.py
  • libmodbus: https://raw.githubusercontent.com/stephane/libmodbus/master/src/modbus-rtu.c, .../src/modbus.c, .../src/modbus-rtu-private.h

Константы

pymodbus v3.0.2/3.4.1 ModbusRtuFramer: _min_frame_size = 4, _hsize = 0x01. Таблица baud (докстринг, 1.5c = 18 бит, 3.5c = 38 бит):

 Baud  1.5c (18 bits)   3.5c (38 bits)
 1200   13333.3 us       31666.7 us
 4800    3333.3 us        7916.7 us
 9600    1666.7 us        3958.3 us
19200     833.3 us        1979.2 us
38400     416.7 us         989.6 us
 1 Byte = start + 8 bits + parity + stop = 11 bits

libmodbus: MODBUS_RTU_MAX_ADU_LENGTH 256, _MODBUS_RTU_HEADER_LENGTH 1, _MODBUS_RTU_CHECKSUM_LENGTH 2.

Формула T3.5 (pymodbus client/serial.py)

self._t0 = float(1 + bytesize + stopbits) / baudrate     # 3.6.9 (в 3.4.1: 1+8+2 захардкожено)
if baudrate > 19200:
    self.silent_interval = 1.75 / 1000    # мс
else:
    self.inter_byte_timeout = 1.5 * self._t0
    self.silent_interval = 3.5 * self._t0

При 9600 бод: t0 = 11/9600 = 1.146 мсT3.5 = 4.01 мс, T1.5 = 1.72 мс.

⚠️ В pymodbus фреймер таймингами не занимается — T3.5 живёт в серийном клиенте. Фреймер делает чистый байтовый парсинг.

Как делится кадр — три подхода

Проект Делимитация Мусор в буфере
libmodbus (эталон, C) по byte-count из функции: читает адрес+функцию → compute_meta_length_after_functioncompute_data_length_after_meta (берёт byte-count из кадра) tcflush(TCIOFLUSH) при bad CRC (MODBUS_ERROR_RECOVERY_PROTOCOL)
pymodbus dev (framer/rtu.py) CRC-hunting: for used_len in range(data_len) — сдвиг на байт, допускает мусор до и после кадра ждёт/сдвигается, не дропает целиком
pymodbus классич. (v3.0) длина из calculateRtuFrameSize() (byte-count) resetFrame() → буфер в b"" целиком при bad CRC/bad UID

libmodbus — ключевой код (src/modbus.c, _modbus_receive_msg)

step = _STEP_FUNCTION;
length_to_read = ctx->backend->header_length + 1;
while (length_to_read != 0) {
    rc = ctx->backend->select(ctx, &rset, p_tv, length_to_read);
    ...
    rc = ctx->backend->recv(ctx, msg + msg_length, length_to_read);
    msg_length += rc;
    length_to_read -= rc;
    if (length_to_read == 0) {
        switch (step) {
        case _STEP_FUNCTION:
            length_to_read = compute_meta_length_after_function(msg[header_length], msg_type);
            ...
        case _STEP_META:
            length_to_read = compute_data_length_after_meta(ctx, msg, msg_type);
            if ((msg_length + length_to_read) > ctx->backend->max_adu_length) {
                errno = EMBBADDATA;   // "too many data" — защита от переполнения
                return -1;
            }
            step = _STEP_DATA;
        }
    }
}
return ctx->backend->check_integrity(ctx, msg, msg_length);

compute_data_length_after_meta (MSG_CONFIRMATION): length = msg[header_length + 1]byte-count прямо из кадра, +2 на CRC.

pymodbus классический — resetFrame() (тот самый сброс)

def resetFrame(self):
    """Reset the entire message frame.
    It is hard to know if we are simply out of sync or if there is
    an error in the stream as we have no way to check the start or
    end of the message (python just doesn't have the resolution to
    check for millisecond delays).
    """
    self._buffer = b""
    self._header = {"uid": 0x00, "len": 0, "crc": b"\x00\x00"}

pymodbus dev — hunting-режим (framer/rtu.py, decode)

for used_len in range(data_len):
    if data_len - used_len < self.MIN_SIZE:
        return 0, 0, 0, self.EMPTY          # мало данных — ждём
    dev_id = int(data[used_len])
    if self.device_ids and dev_id not in self.device_ids:
        return data_len, 0, 0, self.EMPTY
    if not (pdu_class := self.decoder.lookupPduClass(data[used_len:])):
        continue                             # сдвиг на байт (мусор перед кадром)
    if not (size := pdu_class.calculateRtuFrameSize(data[used_len:])):
        return 0, dev_id, 0, self.EMPTY
    if data_len < used_len + size:
        return 0, dev_id, 0, self.EMPTY      # кадр не готов — ждём
    for test_len in range(data_len, used_len + size - 1, -1):
        start_crc = test_len - 2
        ...
        if not FramerRTU.check_CRC(data[used_len:start_crc], crc_val):
            continue                         # мусор ПОСЛЕ кадра — пробуем короче
        return data_len, dev_id, 0, data[used_len + 1 : start_crc]

Выводы для нашего сниффера (modbus-bridge)

  1. Пассивный сниффер T3.5 нужен обязательно — мы не мастер, не знаем длину ожидаемого ответа заранее.
  2. Мусор нельзя копить. Канон (pymodbus resetFrame, libmodbus tcflush) — сбрасывать битый буфер, а не сдвигать бесконечно.
  3. Дроп только ПОСЛЕ паузы. Пока на шине нет тишины ≥T3.5 — кадр ещё может продолжаться, байты копим.
  4. Наш фикс (коммит 3748feb в git_admin/HA-ZONT-Modbus) = T3.5-гейт + resetFrame-семантика + hunting-скан сохранён.

Связанные заметки