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


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

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

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

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

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

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


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



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

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

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

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

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

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

…все переменные WinCC OA этого регистра хранения будут выставлены в TRUE.
Должен заменить, что это не конкретная ошибка конкретной версии под конкретную операционную систему. Нет, я провел ряд проверок, и выяснилось, что ошибка имеет место в том числе и в WinCC OA 3.19 patch 1. Сохраняется ли она в более актуальных версиях в настоящий момент выяснить не удается в силу отсутствия нужных дистрибутивов, в том числе - и на разнообразных каперских сайтах.
Разумеется, это никуда не годится, и проблему надо решать. В конкретном том случае вопрос решил "в лоб", ведь есть полный доступ к исходникам. Я сопоставил каждому BOOL свой отдельный MW, т.е. в регистре хранения у меня хранится только один бит.
Но такое пространство для маневра на уровне ПЛК есть далеко не всегда, поэтому обходим ошибку WinCC OA средствами WinCC OA.
Шаг №1. Мы читаем не несколько бит регистра хранения отдельно, а один регистр хранения (у нас получается не несколько адресных конфигов на каждый бит, а один адресный конфиг на один регистр). Поиграв немного со значениями слова на контроллере, наблюдаем, что оно считывается с сервера без искажений.







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

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




Копи-пастим на остальные 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 функцию точки данных, которая сработает по изменению исходного регистра хранения. Главное достоинство этого способа - он простой. Главный недостаток - по изменению регистра хранения будут обновляться все метки времени всех битов, упакованных в этот регистр.
Настройки приведены ниже. Позволю себе проявить еще немного лени и привести пример лишь с тремя битами.


Проверяем




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

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


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


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

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

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

Обмен у нас двунаправленный, поэтому и задача состоит из двух подзадач:
Если мы меняем битовый DPE, то новое значение этого бита должно выставиться, как соответствующий бит регистра хранения. Другие биты регистра хранения должны сохранять свое значение.
Если меняется регистр хранения. Проверяем, изменился ли интересующий нас бит. Если изменился, выставляем новое значение битового 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) связаться со мной. Я проведу новые изыскания и, возможно, внесу изменения в эти две части статьи.
