С этой неприятной ситуацией я столкнулся в сентябре 2025 года, когда в процессе технического перевооружения некоего объекта гражданской промышленности, название и местоположение которого никому неинтересны, пытался подружить Каскад-Цифра 3.18 (он же WinCC OA) с древним контроллером Schneider (совершенно, не помню, каким именно... изначально написал про серию 580, за что заслуженно получил в комментариях). Этот ПЛК оставался единственным, не подлежащим замене, и порядка полутора тысяч сигналов с него необходимо было перетащить в новую SCADA. SCADA, к слову, крутится на ПК под управлением Astra Linux, то есть - под дебианом.

Старый операторский интерфейс функционировал под ОС Windows XP, а за опрос контроллеров отвечал шнайдеровский родной OFS (OPC Factory Server), поэтому никаких затруднений не возникало. Новая операторская система, в свою очередь, работает под Linux, а это значит никакого OPC и никакого OFS. В новой системе заложен драйвер протокола Modbus TCP на уровне клиентской части. Серверная часть уже была поднята на ПЛК, тут никаких настроек не потребовалось. Но далеко не всем переменным в контроллере был сопоставлен «меркерный» адрес, а значит, далеко не все переменные были доступны в качестве регистров хранения протокола Modbus TCP.

Переменные с меркерными адресами, уже доступные по Modbus
Переменные с меркерными адресами, уже доступные по Modbus
Переменные без абсолютных меркерных адресов, недоступные в таком виде по протоколу Modbus
Переменные без абсолютных меркерных адресов, недоступные в таком виде по протоколу Modbus

Зато есть полный доступ к исходникам ППО ПЛК, а значит - полный доступ к самому ПЛК. Путем простого экспорта в текстовый файл с разделителем (то есть, CSV), правки этого файла в табличном редакторе (тот же LibreOffice Calc, да и Excel никто не отменял) и обратного импорта, все нужные переменные получили столь необходимые абсолютные адреса.

Экспорт данных в текст, т.е. - в csv
Экспорт данных в текст, т.е. - в csv

Хочу лишь обратить внимание на сопоставление меркерной памяти структурам, если таковые используются в ППО ПЛК. Если структура состоит из одних только BOOL, то имейте в виду, что один регистр хранения будет содержать только два булевых значения, по одному значению на каждый байт регистра. Если структура состоит изо всего вперемешку, то следует крайне внимательно изучить способ выравнивания величин. Тем не менее, в сухом остатке мы получаем последовательность регистров хранения. И если с трактовкой вещественных величин трудностей не возникло, все традиционно, один REAL - это два регистра, то с упаковыванием нескольких бит в один регистр хранения начинаются реальные проблемы, которые в жизни могут привести к крайне печальным последствиям.

В чем же проблема? Рассматривем на конкретном примере, плоские данные, без каких-либо структур.

Десять бит упакованы в один регистр
Десять бит упакованы в один регистр

Но снимке выше мы видим, как 10 переменных булевого (двоичного) типа упаковываются в один регистр с абсолютным адресом MW703 (слово №703).

Прописать 10 точек данных в модуле Para - дело нехитрое. Вот так они выглядят

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №5

Адресный конфиг одного элемента точки данных. Остальные абсолютно идентичны, за исключением номера бита. Нумерация тут, почему-то, идет с единицы.

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №6

Конфиг original этого же ЭТД. Метка времени меняется, значит, опрос у меня идет успешно

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №7

Начнем с конца. Переменная SYS_Q - это десятый бит… ну, или девятый, если считать с нуля. В ETM совершенно напрасно некоторые вещи считают с единицы, это вносит уйму путаницы. На момент написания этих строк самого контроллера под рукой нет, но эта ситуация прекрасно повторяется и в общеизвестном симуляторе протокола.

Выставлен бит №9 (считаем с нуля)
Выставлен бит №9 (считаем с нуля)
В модуле para видим значение истина, остальные биты - ложь
В модуле para видим значение истина, остальные биты - ложь

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

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №10
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №11
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №12

А теперь ускорим события и посмотрим, что происходит. Как видим, биты №№1,8,9 (считаем с нуля) приняли значение логической единицы.

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №13

Смотрим в модуле para.

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №14

Бит №0 - ложь, все правильно

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №15

Бит №1 - истина, все верно

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №16

Бит №2 - истина… но почему? У меня точно задано нулевое значение

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №17

Если пройтись по остальным битам, начиная со второго, то их значение тоже будет «истина». Странная логика этого глюка такова: как только какой-либо бит в слове обретает значение «истина», все последующие биты так же определяются драйвером WinCC OA, как «истина». Вот при таком раскладе, когда нулевой бит выставлен в TRUE

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №18

…все переменные WinCC OA этого регистра хранения будут выставлены в TRUE.

Должен заменить, что это не конкретная ошибка конкретной версии под конкретную операционную систему. Нет, я провел ряд проверок, и выяснилось, что ошибка имеет место в том числе и в WinCC OA 3.19 patch 1. Сохраняется ли она в более актуальных версиях в настоящий момент выяснить не удается в силу отсутствия нужных дистрибутивов, в том числе - и на разнообразных каперских сайтах.

Разумеется, это никуда не годится, и проблему надо решать. В конкретном том случае вопрос решил "в лоб", ведь есть полный доступ к исходникам. Я сопоставил каждому BOOL свой отдельный MW, т.е. в регистре хранения у меня хранится только один бит.

Но такое пространство для маневра на уровне ПЛК есть далеко не всегда, поэтому обходим ошибку WinCC OA средствами WinCC OA.

Шаг №1. Мы читаем не несколько бит регистра хранения отдельно, а один регистр хранения (у нас получается не несколько адресных конфигов на каждый бит, а один адресный конфиг на один регистр). Поиграв немного со значениями слова на контроллере, наблюдаем, что оно считывается с сервера без искажений.

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №19
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №20
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №21
Глюки WinCC OA. Драйвер Modbus.Ч��сть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №22
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №23
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №24
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №25

Впрочем, чтобы не создавать себе сейчас лишние трудности, давайте поставим галочку «низкоуровневое сравнение» в адресном конфиге. Этим самым мы настроим систему таким образом, что в обработку будет попадать только измененное значение регистра. Иными словами, если ни один из 10 наших бит в течении условного часа времени не меняется, то не будут меняться и метки времени наших точек данных.

Создаем файлик со скриптом и пишем скрипт с названием modbusGluck (gluck на немецком языке - «счастье», «радость»). Не забываем добавить файл в консоль системы. Скрипт хард-кодовый, очень простой, это решение в лоб. Наведение красоты, создание отдельной структуры (DPT), применение функции dpQueryConnect - разумеется, полезно, можно и даже нужно, но решение этого вопроса я оставляю на читателях (мне лень).

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №26

Смотрим сработку на примере одного бита

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №27
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №28
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №29
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №30

Копи-пастим на остальные 9 бит, получаем вот такой глобальный скрипт.

main()
{
  dpConnect("traceDataCB", "HR703.");
}

traceDataCB(string dpe, bit32 val)
{
  bool oldBit, newBit;
  string nameBit;

  //нулевой бит регистра
  nameBit = "M0901_01_IN_ST." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 0);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "M0906_01_IN_SP." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 1);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "M0906_01_OUT_ST_TM." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 2);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "M0908_01_IN_ST." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 3);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "M0908_02_IN_ST." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 4);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "M0908_03_IN_ST." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 5);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "M0908_04_IN_ST." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 6);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "PLC4_GRINDER_ON." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 7);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "SIMUL_TEST." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 8);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

  nameBit = "SYS_Q." ;
  dpGet(nameBit, oldBit);
  newBit = getBit(val, 9);
  if (oldBit != newBit)
    dpSet(nameBit , newBit);

}

Поиграв достаточно с регистром хранения, мы наблюдаем совершенно иную картину. Меняется именно тот бит, который мы изменили на уровне сервера Modbus. А дополнительная проверка в коде скрипта позволяет не обновлять значение и метку времени того бита, который реально не менялся. Ведь бит упакован в слово регистра, и от сервера приходится новое значение именно всего слова. Так какой смысл нам вводить систему в заблуждение, обновляя бит, который условно равен нулю с момента запуска?

У этой задачи есть решение гораздо проще, хоть и не лишенное недостатков. Мы можем применить на уровне битовых DPE функцию точки данных, которая сработает по изменению исходного регистра хранения. Главное достоинство этого способа - он простой. Главный недостаток - по изменению регистра хранения будут обновляться все метки времени всех битов, упакованных в этот регистр.

Настройки приведены ниже. Позволю себе проявить еще немного лени и привести пример лишь с тремя битами.

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №32
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №33

Проверяем

Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №34
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №35
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №36
Глюки WinCC OA. Драйвер Modbus.Часть 1. Регистры хранения, битовый доступ, чтение данных и тайная комната., изображение №37

Досадных ошибок чтения сигнала нет. Но метка времени, увы, обновляется сразу для всех трех DPE.

Следующая проблема - обращение к битам регистров хранения в режиме «чтение/запись». Тут уже трудно определить ситуацию, как глюк. Скорее, это недокументированная (или плохо документированная) фича, а не бага. Смысл сводится к тому, что если мы заводим два битовых DPE, которые обращаются к одному и тому же регистру хранения в режиме чтение/запись, пусть и к разным его битам, то система нам этого сделать не позволяет… утверждает, что запрещается задавать более одного адреса в режиме записи, которые ссылаются на одну и ту же область память контроллера. С одной стороны можно поспорить, ведь мы ссылаемся на разные биты одного регистра хранения. С другой стороны, мы ссылаемся на один и тот же регистр хранения.

Рассмотрим на примере. Обращаемся в режиме чтение/запись к стартовому биты регистра хранения 700.

Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №1

Изменение в режиме записи (от скады)

Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №2
Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №3

Изменение в режиме чтения (меняем бит на стороне контроллера, смотрим его изменение в скаде)

Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №4
Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №5

Пробуем прописать вторую точку данных, что ссылается на этот же регистр за номером 700.

Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №6

Сделать активным этот адрес нам не позволяют

Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №7

Как обойти это ограничение? Точно так же, как и в предыдущем случае. Будем читать и записывать только регистр хранения целиком, а не его биты, а остальную обработку реализуем в глобальном скрипте. Для этого я удаляю адресные регистры точек данных IO_TAG_1 и IO_TAG_2 и создаю точку данных HR700.

Глюки WinCC OA. Драйвер Modbus.Часть 2. Битовый доступ к регистрам хранения в режиме R/W или в поисках утраченной кукушки, изображение №8

Обмен у нас двунаправленный, поэтому и задача состоит из двух подзадач:

  1. Если мы меняем битовый DPE, то новое значение этого бита должно выставиться, как соответствующий бит регистра хранения. Другие биты регистра хранения должны сохранять свое значение.

  2. Если меняется регистр хранения. Проверяем, изменился ли интересующий нас бит. Если изменился, выставляем новое значение битового DPE.

Эту задачу мне вообще пришлось решать достаточно оперативно, при этом регистров более чем с одной командой было очень мало, поэтому изящности в моем решении нету вообще. То есть, если в прошлой части изящности просто не было, то теперь ее НЕТ ВООБЩЕ. Коллбэк-функция первой подзадачи выглядит следующим образом:

//обработка изменения битовой команды
//взвод или сброс бита приводит к формированию регистра хранения, где бит команды
//принимает значение пользователя, а остальные биты слова (регистра) не меняют значения
//(как и должно быть)
callbackDRbit(dyn_string userData, string dpe, bool valBit)
{
  bit32 valHR;
  bool valHRbit;
  int pos = userData[1]; //номер бита в регистре
  string dpHR = userData[2]; //имя точки данных со значением регистра
  //читаем значение регистра хранения со всеми командами
  dpGet(dpHR, valHR);
  //достаем из него значение этой команды по известному номеру бита
  valHRbit = getBit(valHR, pos);

  if (valBit != valHRbit)
  {
    setBit(valHR, pos, valBit);
    dpSet(dpHR, valHR);
  }
}

Тут я применяю dpConnectUserData, поэтому и обработчик использует эту самую ЮзерДату… наконец-то пригодилась. Пользовательские данные коллбэка представляют собой массив строк, первый элемент которого - это номер бита в регистре хранения, а второй - имя точки данных этого самого регистра хранения. Функция вызывается по изменению бита-команды IO_TAG_1 (или IO_TAG_2). Вначале читается полное значение регистра хранения. Далее читается значение нужного нам бита. Если значение этого бита отличается от нового значения команды IO_TAG_1 (или IO_TAG_2), то мы меняем в регистре хранения только этот бит (а общее значение регистра мы уже прочитали), после чего записываем значение точки данных и отправляем ее тем самым на запись в контроллер.

Вторая подзадача смотрит на изменение всего регистра хранения. По изменению значения регистра мы проверяем - изменился ли нужный нам бит? Номер этого нужного бита точно так же передается в виде пользовательских данных, как часть массива строк. Имя точки данных бита-команды - там же. Если значение бита регистра хранения изменилось, то мы корректируем нужный нам бит-команду.

//изменения регистра хранения должны транслироваться в командный бит
callbackDRword(dyn_string userData, string dpe, bit32 valHR)
{
  bool valHRbit;
  bool valBit;
  int pos = userData[1];
  string dpBit = userData[2];

  valHRbit = getBit(valHR, pos); //это бит из регистра хранения. Регистр изменился, надо проверить - изменился ли "нужынй" бит
  dpGet(dpBit, valBit); //а это бит команды, который соответсвует биту регистра

  if (valBit != valHRbit)  //если они не равны
  { //откорректировать бит команды
    dpSet(dpBit, valHRbit);
  }
}

Функция main скрипта выглядит следующим образом.

/*
  Колхозим обход ограничений драйвера модбас ВинЦЦ 3.18
  Работа с данными ПЛК7
*/
main()
{
  dpConnectUserData("callbackDRbit", makeDynString("0", "HR700."), "IO_TAG_1.");
  dpConnectUserData("callbackDRword", makeDynString("0", "IO_TAG_1."), "HR700.");

  dpConnectUserData("callbackDRbit", makeDynString("1", "HR700."), "IO_TAG_2.");
  dpConnectUserData("callbackDRword", makeDynString("1", "IO_TAG_2."), "HR700.");

}

А вот таким образом выглядит весь скрипт целиком.

/*
  Колхозим обход ограничений драйвера модбас ВинЦЦ 3.18
  Работа с данными ПЛК7
*/
main()
{
  dpConnectUserData("callbackDRbit", makeDynString("0", "HR700."), "IO_TAG_1.");
  dpConnectUserData("callbackDRword", makeDynString("0", "IO_TAG_1."), "HR700.");

  dpConnectUserData("callbackDRbit", makeDynString("1", "HR700."), "IO_TAG_2.");
  dpConnectUserData("callbackDRword", makeDynString("1", "IO_TAG_2."), "HR700.");

}

//обработка изменения битовой команды
//взвод или сброс бита приводит к формированию регистра хранения, где бит команды
//принимает значение пользователя, а остальные биты слова (регистра) не меняют значения
//(как и должно быть)
callbackDRbit(dyn_string userData, string dpe, bool valBit)
{
  bit32 valHR;
  bool valHRbit;
  int pos = userData[1]; //номер бита в регистре
  string dpHR = userData[2]; //имя точки данных со значением регистра
  //читаем значение регистра хранения со всеми командами
  dpGet(dpHR, valHR);
  //достаем из него значение этой команды по известному номеру бита
  valHRbit = getBit(valHR, pos);

  if (valBit != valHRbit)
  {
    setBit(valHR, pos, valBit);
    dpSet(dpHR, valHR);
  }
}

//изменения регистра хранения должны транслироваться в командный бит
callbackDRword(dyn_string userData, string dpe, bit32 valHR)
{
  bool valHRbit;
  bool valBit;
  int pos = userData[1];
  string dpBit = userData[2];

  valHRbit = getBit(valHR, pos); //это бит из регистра хранения. Регистр изменился, надо проверить - изменился ли "нужынй" бит
  dpGet(dpBit, valBit); //а это бит команды, который соответсвует биту регистра

  if (valBit != valHRbit)  //если они не равны
  { //откорректировать бит команды
    dpSet(dpBit, valHRbit);
  }
}

На этом я пока заканчиваю разбор известных мне глюков или «особенностей» драйвера Modbus TCP.

В заключении я хочу попросить читателей, у которых есть инсталлятор самых свежий версии WinCC OA (3.20 с патчем 9 на момент написания в октябре 2025 года, а в декабре 2025 года вышла версия 3.21) связаться со мной. Я проведу новые изыскания и, возможно, внесу изменения в эти две части статьи.