Варианты расширения протокола Modbus: ускорение опроса и чуть-чуть о безопасности +8


image

Modbus — по сути дела, общепринятый стандарт в системах автоматизации для взаимодействия с датчиками, исполнительными механизмами, модулями ввода-вывода и программируемыми логическими контроллерами.

В сферах, где требуется событийная модель, его постепенно вытесняют более новые стандарты, такие как IEC 60870-5-101/103/104, CANopen, DNP3 и подобные, однако благодаря простоте, модели «запрос-ответ» и возможности работы в полудуплексном режиме, Modbus остается отличным решением для систем телеметрии при работе через радиомодемы.

При увеличении количества объектов, опрашиваемых с базовой станции, и увеличении объемов данных (значения с сенсоров, состояние ввода-вывода, архивы, и т.д.), встает вопрос скорости опроса. Главным недостатком при работе через радиомодем является время «разогрева» радиомодуля при каждой инициации передачи запроса (оно может достигать 200-500 мс), которое умножается на количество узлов ретрансляции. При большой длине цепочки ретрансляции также увеличивается вероятность, что пакет запроса и ответа на него вообще не дойдет до адресата из-за помех. Это всё законы физики, и их не обойти никак.

Про очевидные приемы, вроде группирования в адресном пространстве данных так, чтобы их можно было считать за один запрос, хранения всех значений в Holding-зоне (а не отдельно в Holding и Inputs) по той же причине, мы разбирать не будем, а задумаемся, как же можно улучшить Modbus, чтобы оптимизировать радиообмен. Предложенные решения можно легко реализовать, если вы одновременно являетесь и разработчиком верхнего (OPC-сервер), и разработчиком среднего (контроллерного) уровня, или же если между разработчиками налажен тесный контакт.

Важным вопросом будет сохранение совместимости, чтобы, к примеру, ваш контроллер также мог быть опрошен любым другим стандартным OPC-сервером, а не только вашей разработкой. В случае с Modbus это легко, т.к. стандартных функций там около 24 (из которых обычно на практике используется в лучшем случае половина), а остальные являются либо «пользовательскими», либо зарезервиванными. Как минимум, функции от 65 до 72 и от 100 до 110 можно свободно переопределять для своих нужд, чем мы и займемся.

Большие пакеты


Если качество связи с отдельными объектами очень хорошее (практически без потерь), есть смысл попробовать опрашивать их большими пакетами. По стандарту, максимальный объем данных в пакете — 253 байта. Однако никто не запрещает нам в рамках нашей системы клиент-сервер передавать и больше, и для этого даже не нужно сильно ничего переделывать.
Посмотрим на обычный Modbus-запрос функции 0x3 (Read holding registers):

01 03 FF 01 00 07 4B 44

Адрес устройства, 3-я функция, адрес стартового регистра, количество запрошенных регистров, CRC-16. Для количества запрошенных регистров в ADU зарезервированно 2 байта, то есть, ничто не мешает запрашивать и значения, большие чем 255 — главное, чтобы и опрашиваемое устройство, и сервер опроса, поддерживали эту возможность.

С ответом чуть сложнее,

01 03 FF 01 00 07 14 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E F2 56

как видим, там добавляется один байт (хранящий в себе длину данных в байтах), в него мы уже не влезем, и поэтому придется реализовать кастомную modbus-функцию, в котором под это поле будет отведено 2 байта.

Сжатие данных


Здесь всё просто. Чем меньше мы передаем данных в одном пакете, тем больше вероятности, что он корректно дойдет до адреса назначения и совпадет CRC. Или если мы чуть-чуть не влезаем в один пакет, разумно будет попробовать сжать данные, чтобы не отправлять еще один запрос.
От алгоритма сжатия требуется, чтобы он был простым (т.к. его надо будет реализовывать не только на сервере, но и на контроллере), и хорошо работал с маленькими порциями данных (от 16 до 1024 байт).

С подобными условиями в нашем случае очень хорошо справляется алгоритм RLE, который до смешного прост, но на наборах данных, типичных в регистрах ПЛК (поряд идущие данные с каналов АЦП, уставки конфигурации, и т.д.), он оказался весьма эффективен. Правда, классическая реализация, которую можно встретить почти на всех сайтах с алгоритмами, все-таки не совершенна, поскольку она кодирует только количество повторов, и если повторов мало, то выходной буфер может получиться даже больше, чем входной. Поэтому я использую свою реализацию, которая кодирует не только количество повторов, но и количество не-повторов :)
Упрощенный код примерно такой:

/**
     * @brief Процедура сжатия
     * @param in_buf указатель на массив сжимаемых данных
     * @param out_buf указатель на выходной массив сжатых данных (размером не менее maxclen)
     * @param len длина данных для сжатия
     * @param maxclen максимальная длина выходного массива.
     * @return объем данных в сжатом виде, либо -1 если сжатие вышло неээфективным
     */
int compress(char *in_buf, char *out_buf, int len, int maxclen)
{
    char *c_start = in_buf;
    char *c_curr = in_buf;
    int i;   
    int result_size = 0;
    char curr_type;
    char run_len = 1;

    if (*c_curr == *(c_curr+1))
        curr_type = 0;
    else
        curr_type = 1;

    while (((c_curr - in_buf) <= len) && (result_size < maxclen)) {
        if (
             ( (*c_curr != *(c_curr+1)) && (curr_type == 1) ) ||
             ( (*c_curr == *(c_curr+1)) && (curr_type == 0) ) &&
             (run_len < 127) && ((c_curr - in_buf) <= len)
           )
        {
            c_curr++;
            run_len++;
        }
        else
        {
            if (curr_type == 0)
            {
                *out_buf = run_len;
                out_buf++;
                *out_buf = *c_curr;
                out_buf++;
                c_curr++;
                result_size = result_size + 2;
            }
            else
            {
                run_len--;
                if (run_len > 0)
                {
                    *out_buf = 0x80 | run_len;
                    out_buf++;
                    for (i = 0; i <= run_len; i++)
                        *(out_buf + i) = *(c_start + i);
                    out_buf = out_buf + run_len;
                    result_size = result_size + run_len + 1;
                 }
            }
            c_start = c_curr;
            curr_type = curr_type ^ 1;
            run_len = 1;
        }
    }

    if (result_size >= maxclen)
        return -1;

    return result_size;
}

/**
     * @brief Процедура распаковки
     * @param in_buf указатель на массив разжимаемых данных
     * @param out_buf указатель на выходной массив данных (память должна быть выделена заранее)
     * @param len длина входных данных
     * @return объем распакованных данных
     */
int decompress(char *in_buf, char *out_buf, int len)
{
    char* c_curr = in_buf;
    char count;
    char size = 0;
    int i;
    while ((c_curr - in_buf) < len)
    {
        count = *c_curr & 0x7F;
        size = size + count;
        if (*c_curr & 0x80)
        {
            c_curr++;
            for (i = 0; i < count; i++)
            {
                *out_buf = *c_curr;
                c_curr++;
                out_buf++;
            }
        }
        else
        {
            c_curr++;
            for (i = 0; i < count; i++)
            {
                *out_buf = *c_curr;
                out_buf++;
            }
            c_curr++;
        }
    }
    return size;
}

В сжатом виде данные будут выглядеть примерно так:

81 45 32 41 81 42 02 41

Сначала идет байт, определяющий следует ли за ним блок повторяющихся или неповторяющихся байт (старший бит 0x80 активен для неповторяющихся блоков, и наоборот), а затем непосредственно или сам повторяющийся байт, или массив неповторяющихся, и так далее.

Приведенный код упрощенный, «быстрый и грязный», простор сделать лучше всегда есть. Нужно отметить что этот пример сжимает данные оперируя байтами, а в случае с Modbus, иногда будет разумнее сжимать оперируя словами (по 2 байта). Несложно догадаться, что изменения в коде будут минимальными, а какой из вариатов подойдет вам больше — зависит от характеристик данных, которые вы будете сжимать.

Если вы пользуетесь большими пакетами, а на ПЛК крутится полноценная операционная система (как, например, Linux на LinPac-контроллерах), можно попробовать даже использовать zlib.

Инкрементальное чтение


Если поллинг происходит весьма часто, или наоборот, данные в контроллере обновляются весьма редко, то иногда имеет смысл читать только изменившиеся блоки адресного пространства.
Алгоритм может быть примерно такой:

1. Сервер отправляет запрос, аналогичный 0x3 или 0x4 modbus-функции, но также и указывая номер последнего чтения (просто инкрементальный счетчик)

2. Контроллер проверяет, если номер последнего чтения совпадает с таким же номером в контроллере (то есть предыдущий ответ был корректно доставлен и обработан), то XOR'ом проходит по подготовленному буферу регистров для отправки, сравнивая его с таким же буфером, сохраненным с предыдущего запроса, после чего кодирует только изменившиеся данные примерно следующим образом

<смещение от начала буфера><длина блока><блок данных>

Как можно заметить, алгоритм будет во многом похож на вышеописанный RLE (мы как бы считаем, что все неизменившиеся данные у нас одинаковы) и никакой магии здесь нет.
При использовании этого метода, более-менее часто изменяющиеся данные (таймеры, сигналы с АЦП и тд) должны располагаться в карте регистров рядом друг с другом, а уже потом отдельно тоже сгруппированным образом могут идти нечасто изменяющиеся данные (битовые маски состояния задачи, сигналы дискретных входов, и т.д.).

Немного о безопасности


Modbus не предлагает никаких технологий для шифрования и аутентификации. И из этого следует, что если безопасность для вас — не пустой звук, то лучше использовать для этого более подходящий протокол.

Понятное дело, что критически важными системами через открытый радиоканал никто управлять не будет, но я в своей жизни видел, наверно, десяток систем, которые работали через FSK-радиомодемы, а уж разработчики и пользователи, даже не задумывались, что любой владелец аналогичного радиокомплекта и ноутбука, зная карту регистров может отправлять на объекты любые команды от имени сервера. Та же ситуация относится и к кабельным линиям RS-485, они также ничем не защищены.

Ситуация немного усложняется тем, что часто ПЛК и встраиваемые системы ограничены в ресурсах, и поэтому какой-либо более-менее сложный и надежный симметричный алгоритм туда засунуть будет не так-то просто, не говоря уж об асимметричных.

Поэтому при записи в регистры (отправки исполнительной команды или изменении конфигурации), кроме непосредственно значения регистров, можно дополнительно пересылать, к примеру MD5-хэш (алгоритм прост, быстр, и не смотря на что в нем найдены недостатки по части коллизий, в нашем случае они не играют серьезной роли) от самих данных, некой «соли» (ключа, известного только серверу и контроллеру, и неплохо было бы также предусмотреть ротацию ключей с небольшой периодичностью) и, к примеру, текущей временной метки с точностью до 10 секунд (в завивимости от качества синхронизации времени между сервером и ПЛК и цепочки ретрансляции). Защита далеко не идеальна, но по совокупности факторов может ощутимо осложнить реализацию злобных замыслов.

Если же контроллер несет на борту полноценную ОС, типа Linux или WinCE и мощный процессор, можно размахнуться уже немного шире, и взять, например, libCryptoPP, и шифровать блоки, например, алгоритмом 3DES, причем не только для функций записи, но и даже при обычном чтении.

Расширение адресов


Это решение уже не такое простое, причем не в техническом (т.к. с этим как раз-таки всё просто), а, скорее, в административном плане, потому что тут уже, увы, ломается совместимость.

Если есть необходимость опрашивать более 247 устройств по одной линии связи или в одном частотном диапазоне, стоит рассмотреть вариант перехода на двухбайтные адреса. Некоторые известные контроллеры (например, ScadaPack) такое решение поддерживают «из коробки».

Для возможности опросить устройство локально стандартным modbus--сканером можно предусмотреть сброс устройства в однобайтный режим, например, при замыкании определенной перемычки, либо же зарезервировать какой-нибудь адрес (например, 247), который никогда не будет встречаться в старшем байте расширенных адресов, и при получении пакета с которым контроллер всегда будет рассматривать его как стандартный пакет.

Если алгоритм ретрансляции отрабатывается модемами, то проблем с этим, в принципе, быть не должно, если устройства с адресами, содержащими одинаковый старший байт адреса будут находится в одной группе ретрансляции — таким образом модемы будут воспринимать посылку как обычный Modbus-пакет, ретранслировать его до конечного сектора, там уже его получат все контроллеры, у которых совпадает старший байт адреса, но обработает и ответит на него только один, у которого совпадет не только старший, но и младший байт.

Звучит немного запутанно, но эта схема действительно проверена на практике и работает.




К сожалению, не доступен сервер mySQL