134 lines
6.7 KiB
Markdown
134 lines
6.7 KiB
Markdown
# 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`)
|
||
|
||
```python
|
||
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_function` → `compute_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`)
|
||
|
||
```c
|
||
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()` (тот самый сброс)
|
||
|
||
```python
|
||
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`)
|
||
|
||
```python
|
||
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-скан сохранён.
|
||
|
||
## Связанные заметки
|
||
|
||
- [[family/plans/t610-home-automation]] — основной док миграции, §5-кватер-Ж/З (диагноз и фикс)
|
||
- [[family/how-to/home-automation]] — карта slave/регистров
|