Культура программирования для ПЛК: Переменные, Буферы, Массивы, Проверка границ и Данных

Проекту исполнилось 16 лет! Поддержать проект материально, проспонсировать проекты Автора или сделать ему подарок можно на этой странице: "Донаты и Спонсорство, Список Желаний".

Число просмотров: 3 952 

Ужасный код программы на ПЛК: Нет понятных именований объектов (со смыслом)

Ужасный код программы на ПЛК: Нет понятных именований объектов (со смыслом)

Хах! На самом деле всё, что я сейчас понапишу, изначально осваивалось мной в примерно 2002-2004 годах в программировании на VC++, а до этого — в программировании (на бумажке) микроконтроллеров MCS-51. Всё, связанное с тем, что я хочу рассказать в очередной раз, уже было: и про выход за границу памяти (и получение данных непойми откуда), выход за границу массивов и циклов, и даже неосвобождение системных ресурсов, которое давало глюки через недели или месяцы работы программы…

Но теперь точно такая же тема всплыла в программировании для ПЛК! А раз так — то пусть будет пост для продвинутых новичков, которые сталкиваются с этим и потом удивляются, почему программа глючит и работает то хорошо, а то криво. Я ощущаю себя каким-то чёртовым стариканом, который поучает молодёжь, и который думает о том, что все эти самые «Да я хотел быстро написать простую программку, а она чего-то ПЛК вешает» всё равно будут неисправимы, и будут спешить, косячить и делать по-своему. Может, это не стариканство (которого во мне нет), а мудрость? ;)

ОЧЕНЬ ВАЖНО! В этом посте я НЕ говорю про ВЫРАВНИВАНИЕ памяти (ПЛК и Компьютеры иногда распределяют короткие блоки памяти кратно 2 или 4 байтам, а не подряд)!!! Все мои примеры приведены БЕЗ выравнивания: байты данных в них идут подряд!

1. Зачем нужна культура кода и какой от неё прок? Почему все брюзжат как стариканы?

Прежде, чем я начну нудить (потому что это самый скучный раздел), я напомню вам несколько материалов, которые будут полезны и интересны:

В общем, иногда получается так, что маааленькая ошибочка стоит много денег или убивает людей. А мы тут программируем как раз в критичной области: ПЛК. То есть, можем управлять станками, химическими реакциями, отоплением, вентиляцией, двигателями. Объединяет это всё как раз то, что если программа в ПЛК засбоит — она может что-то сжечь, взорвать или убить людей.

А чтобы такого не было (а точнее, чтобы сократить количество глюков в программах) и придумывают разные технологии (отладку, статический анализ кода, тестирование). Но большинство людей в любой сфере всегда взвоют, если им сказать: «Делай то, что ты привык, но потом отвлекись и сделай что-то ещё для проверки действий». И не сделают ничего. Или будут делать нехотя с мыслью «Я ж программу сделал, вон она работает. Дело окончено, а тут, оказывается, ещё и какие-то проверки делать — да ну нафиг».

Поэтому лично мне близок подход в стиле фразы про «Чисто не там, где убирают, а где не мусорят». Я трактую её не как зануды, которые имеют ввиду что надо каждую соринку подбирать и нести к помойке (вот оно — то самое лишнее действие). Я трактую смысл этой фразы более глубоко: нужно организовать рабочий процесс так, чтобы отвлекаться на лишние действия не приходилось: эти действия должны быть естественной частью процесса.

К примеру, если говорить про мусор — то я сделал себе коробочку для сбора мелкого мусора при сборке щитов, и все обрезки изоляции и жилок проводов складываю туда СРАЗУ. А потом, когда слышу о том, как многие при сборке щитов кидают всё это на пол, а после завершения работ убирают, — я удивляюсь. Что им мешает сразу скидывать мусор в удобное место? И мозг не напрягается, и сразу чисто становится.

Такой же подход я применяю и в программировании: вместо того, чтобы сначала писать программу, а потом вычитывать код и приводить его в порядок, я СРАЗУ пишу программы аккуратно и хорошо. И вот эта технология как раз и помогает мне сократить (а кое-где вообще исключить) разные ошибки или глюки.

Вот какие приёмы я применяю:

  • Именую объекты и переменные с префиксами так, чтобы они сортировались по алфавиту с нужным префиксом: «hwBtn», «hwVent», «hwLamp»;
  • Всегда задаю начальные значения всем важным переменным в коде (кроме временных VAR_TEMP или тех, которые сразу же вычисляются, получая свои значения);
  • Скрываю лишние внутренние (локальные) переменные в Функциональных Блоках (FB, пост про них) при помощи атрибута hide_all_locals;
  • Использую SIZEOF() для определения размера буферов памяти;
  • Проверяю указатели на адреса, полученные через ADR(), на нулевые значения;
  • Максимально использую константы для определения:
    • Имён шагов конечных автоматов;
    • Границ циклов и массивов;
    • Длин буферов памяти.
  • Очищаю буферы памяти перед приёмом данных или перед заполнением их на отправку;
  • Слежу за тем, чтобы правильно закрывать все открытые ресурсы (Файлы, Порты).

Брюзжу я из-за того, что я, как начал читать форум ОВЕНа, охерел: там выкладывают иногда настолько ужасные проекты с названиями типа «Городской Фонтан», «Котельная города Курска», что удивляешься тому, как ЭТО вообще работает и как мудохается программист, разбираясь в сотне квадратиков с именами типа DD_01, DD_89…

Ужасный код программы на ПЛК: Имена переменных заданы подряд без смысла

Ужасный код программы на ПЛК: Имена переменных заданы подряд без смысла

Я удивляюсь тому, как в такой сложной и ответственной сфере работают дураки без образования, которым не вдолбишь то, что в какой-то момент банальный выход на границы цикла может порушить всю программу… Я удивляюсь тому, как много народа, которым удобные правила оформления кода и защита от косяков просто не нужна. Они вместо SIZEOF() ставят число, которое, как им кажется, подходит по их рассуждениям. Они привыкли не именовать объекты и переменные. Они могут понаписать в нескольких местах циклы по массиву с разными конечными границами FOR, потому что, когда массив увеличится в размерах, они где-то исправят, а где-то забудут…

Вот этот мой пост направлен на то, чтобы подсказать простые и удобные приёмы работы с кодом тем, кто этого хочет или не знает о них. А заодно рассказать о том, откуда берутся странные глюки. Чаще всего эти глюки связаны с выходом за границы массивов или буферов памяти. Иногда они могут служить не только глюками, а ещё и объектом хакерских атак — те самые «переполнения буфера».

2. Имена объектов и переменных. Их аккуратное оформление.

Начинаю с занудного. Но аргументированного: Пожалуйста приучите себя НОРМАЛЬНО именовать все объекты и переменные! Я считаю, что это важно для всех.

Во-первых, можно понять, к чему относится или что делает эта переменная. Например «ft1» — это ни хера не ясно. А вот «fbPowerOffSig» — примерно понятно: это какой-то функциональный блок (fb), который делает какой-то сигнал выключения питания.

Я опущу все эти пресловутые «Другим будет легче понимать ваш код» — пусть это останется в скучных книгах о программировании. Я приведу более понятный аргумент: вам будет удобнее писать вашу программу. А раз удобнее — то быстрее. А раз быстрее — то за одно и то же время вы сделаете больше таких программ. А значит, заработаете на них больше денег. Профит!

Посмотрите на этот чёртов код:

Ужасный код программы на ПЛК: Имена переменных заданы при помощи ассистента ввода автоматически и без смысла

Ужасный код программы на ПЛК: Имена переменных заданы при помощи ассистента ввода автоматически и без смысла

Программа такого человека должна стоить… Хм! Ну миллионов пять, я бы сказал. Не потому что она круто всё делает (это городской фонтан какого-то города, если что — её выкладывали на форуме ОВЕНа, потому что она не работала). А потому что без этого человека НИКТО в ней не разберётся. Только он помнит, что такое tn1, rt1, rp1, rt4, rt5 и прочие.

А если говорить серьёзно — то за такую программу я бы его отдал бы под суд. Как диверсанта. С формулировкой «Искуственно завышенная стоимость владения программой из-за её нечитаемости». Возможно, автор этой программы делает только её одну, или ему на всё наплевать (эту тему я снова недавно поднимал).

Во-вторых, если переменные или объекты имеют имена с префиксами, то они сами собой группируются при сортировке их по алфавиту в окнах выбора. Это крайне удобно, и я вовсю этим пользуюсь, как уже говорил выше в прошлом разделе.

Вот здесь чувак явно старался:

Ужасный код программы на ПЛК: Имена переменных заданы по номерам каналов IO, но не имеют смысла

Ужасный код программы на ПЛК: Имена переменных заданы по номерам каналов IO, но не имеют смысла

Этот проект — тоже с форума ОВЕНа. Это огромный Умный Дом. Который тоже плохо работал, и поэтому его выложили на форум.

Здесь автор старался, но всё равно это плохо: он поименовал переменные выходов не по логическому назначению в программе, а по техническому: Адрес модуля IO + Номер выхода этого модуля. Так делать тоже не надо: в программе нам удобнее видеть свет с названиями помещений — «Прихожая», «Кухня», «Спальня», чтобы ориентироваться в нём независимо от того, к какому входу/выходу какого модуля (пост про модули IO) подключены нужные нам лампы света.

Вот, например, нужно будет поменять адрес модуля A2 c «2» на «34». Разве автор будет переименовывать все переменные вида «A2_Svet1_16out_A2W» на «A34_Svet1_16out_A34W»? Да никогда! Поэтому лучше именовать их сразу по группам света.

Напоминаю свой пост про технологии отладки и тестирования ПЛК. Там я показывал то, как я разбираю битовые маски и делаю привязку всего ввода-вывода всего проекта в ОДНОМ месте. Я считаю это удобным, так как привязку IO можно посмотреть только в одном месте, и изменить её там же.

Вот так выглядит кусок кода моей привязки:

Привязка переменных ввода-вывода к групповым переменным IO (CodeSys 3.5)

Привязка переменных ввода-вывода к групповым переменным IO (CodeSys 3.5)

Тут её легко можно поменять или посмотреть.

Это же правило касается и объектов в дереве проекта ПЛК (справедливо для CodeSys 3.5). В том же самом посте про отладку программ на ПЛК я показывал то, как можно обратиться к объекту Modbus-устройства в дереве, чтобы получить его статус или ошибки:

Диагностика ошибок ModBus в CodeSys 3.5: переменные флага ошибки и номера канала опроса

Диагностика ошибок ModBus в CodeSys 3.5: переменные флага ошибки и номера канала опроса

Конечно же, когда этот объект назван ясно и чётко — то код становится понятным. А если «Modbus_Slave_Device», «Modbus_Slave_Device_1», «Modbus_Slave_Device_2» — то нет.

Я выработал для себя кучу правил, которым следую во всех языках, которыми пользуюсь. Ещё раз напомню пост про Тестирование и Отладку программ на ПЛК, где эти правила тоже описаны. Здесь я буду повторяться.

У меня есть привычка группировать все объекты по папкам. Так как в них всё сортируется по алфавиту, то я могу снова пользоваться системой префиксов. Или просто группировать нужные мне блоки вместе:

Пример хорошей структуры моих проектов: все POU разложены по подпапкам и имеют префиксы в названии

Пример хорошей структуры моих проектов: все POU разложены по подпапкам и имеют префиксы в названии

У меня есть привычка создавать таблицу сигналов IO в XLS-файле, при помощи которой я собираю щиты и привязываю IO в ПЛК:

Пример таблицы сигналов ввода-вывода для модулей IO с их назначением в щите

Пример таблицы сигналов ввода-вывода для модулей IO с их назначением в щите

Когда я начал работать с панелями оператора, то мне понадобилось высчитывать номера битов и регистров.

И снова мне помог XLS-файл. Я пронумеровал там все эти биты и регистры и вписал в них нужные мне флаги и значения.

Вот тут у меня используются биты. Слева указан номер регистра. Благодаря этому я знаю, какие биты в каких регистрах находятся:

Пример хорошей структуры моих проектов: В XLS-файле подсчитаны и расписаны номера битов для панели оператора

Пример хорошей структуры моих проектов: В XLS-файле подсчитаны и расписаны номера битов для панели оператора

А вот и регистры:

Пример хорошей структуры моих проектов: В XLS-файле подсчитаны и расписаны номера регистров для панели оператора

Пример хорошей структуры моих проектов: В XLS-файле подсчитаны и расписаны номера регистров для панели оператора

Блин, это же не так сложно! Возьмите Эксель, сделайте себе такие таблички — и вы упростите себе жизнь, чтобы потом номера регистров не подбирать и не вспоминать о том, какой занят, а какой нет.

Я вовсю, как и говорил, использую префиксы. Вот пример их использования:

Пример хорошей структуры моих проектов: Переменные входов имеют префиксы, по которым можно понять их подгруппу

Пример хорошей структуры моих проектов: Переменные входов имеют префиксы, по которым можно понять их подгруппу

hw — это «Hardware» — аппаратная часть, входы или выходы. «Status» (статус), «Btr» (батареи), «Shtor» (шторы), «DSK» (двери сухой контакт), «Ctrl» (управление) — указание на подсистемы или логические части программы.

Пример хорошей структуры моих проектов: Переменные выходов имеют префиксы, по которым можно понять их подгруппу

Пример хорошей структуры моих проектов: Переменные выходов имеют префиксы, по которым можно понять их подгруппу

В итоге мой код может выглядеть так:

Пример хорошей структуры моих проектов: Программа на ПЛК использует логически понятные имена блоков и переменных

Пример хорошей структуры моих проектов: Программа на ПЛК использует логически понятные имена блоков и переменных

Напоминаю про удобную фишку с атрибутом «hide_all_locals» в CodeSys 3.5 (был описан в посте про Функции и Функциональные Блоки). Если его написать в начале Программы (PRG) или Функционального Блока (FB), то все их локальные переменные будут скрыты от доступа по точке.

Специальный атрибут hide_all_locals (скрытие локальных переменных) - включен

Специальный атрибут hide_all_locals (скрытие локальных переменных) - включен

Без этого атрибута будет так:

Процесс выбора переменных при наборе программы на ST (локальные переменные открыты)

Процесс выбора переменных при наборе программы на ST (локальные переменные открыты)

А с указанным так:

Процесс выбора переменных при наборе программы на ST (локальные переменные скрыты)

Процесс выбора переменных при наборе программы на ST (локальные переменные скрыты)

3. Начальные значения переменных. Где это важно и почему.

Ещё одна хорошая практика — это задавать начальные значения переменным, которые вы используете в коде. Особенно удобно это в функциональных блоках (снова даю ссылку на пост про них).

Почему для меня это важно:

  • Переменные получат точно определённые значения, которые я хочу. Например, при запуске Конечного Автомата он сразу будет иметь состояние State_Stop, а не какое-то «нулевое»;
  • Если это входы Функционального Блока (FB), то они получат нужные значения по умолчанию, если их не задали в программе (когда они не использованы).

Вот пример описания Функционального Блока (FB), в котором входы имеют начальные значения:

Пример описания переменных Функционального Блока (FB) различных видов

Пример описания переменных Функционального Блока (FB) различных видов

А теперь расскажу вам страшные байки времён программирования на СИ. Дело в том, что в большинстве языков программирования принят стандарт о том, что если для переменной не указано начальное значение, то она равна нулю или пустому значению (пустой строке).

Но вот в некоторых случаях в СИ-подобных языках работает интересное правило: «Программист — умнее всех! Если он что-то не сделал — то это зачем-то надо». Поэтому там, если переменной не задано начальное значение вручную, оно будет какое попало. Потому что переменная — это просто именованная область памяти. А в этой памяти может быть всё, что угодно, если мы туда ничего не записали вручную.

Смотрите, как это работает. Это мой мутный код 2003 года. Здесь я объявил переменную «i» (для цикла) и не задал её начальное значение.

Поэтому, когда код только начинает выполняться, эта переменная имеет значение 0xCCCCCCCC (при отладочной сборке) или мусор из памяти (при финальной сборке). Так как переменная у нас может быть отрицательной, то это значение трактуется как «-858993460»:

Инициализация переменных в памяти программы: если явно не задана, может быть чем угодно

Инициализация переменных в памяти программы: если явно не задана, может быть чем угодно

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

При этом переменной «n» (число объектов) я присвоил значение (считал из реестра — «45»), и она это значение сразу же и получила:

Инициализация переменных в памяти программы: значение явно задано, всё хорошо

Инициализация переменных в памяти программы: значение явно задано, всё хорошо

Наша переменная «i» получает правильное значение «0» только при начале цикла в коде «for(i = 0; i < n; i++)»:

Инициализация переменных в памяти программы: значение появилось только в момент запуска цикла FOR

Инициализация переменных в памяти программы: значение появилось только в момент запуска цикла FOR

Мой пример был простой и лёгкий, потому что про цикл: там всегда указывается начальное значение переменной, и это очевидно. А вот если бы это был какой-то флаг «Запуск конвейера»? Или «Включить радиационное излучение», как в аппарате THERAC-25, ссылку на трагедию которого я давал в начале поста?

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

4. Работа с памятью (Переменные, Адреса, Указатели). Как это устроено?

Теперь давайте погрузимся в более сложные моменты — в то, как организована память в Компьютерах и ПЛК. Когда мы пишем простые программы для управления светом или какими-то моторами по кнопкам «Пуск» и «Стоп» — это знать не обязательно. А вот когда у нас начинается работа с данными в буферах или программа странно глючит — то тут-то самое время понять, как все наши данные хранятся в памяти.

В большинстве наших компьютеров и ПЛК память у нас организована смешано. Это значит, что программа (команды для процессора) и данные (наши переменные) находятся в одной памяти (раздельная память чаще всего у микроконтроллеров и микропроцессоров). В некоторых случаях они вообще могут идти подряд. Например команда «Загрузить строку ПРИВЕТ ОВЕН» может быть задана в памяти как 0x20, 0x32, 0x80, «ПРИВЕТ ОВЕН» (числа в начале — это выдуманные команды процессора). Такой способ хранения данных позволяет экономить память, но может приводить и к глюкам.

Вот какие мысли можно сказать про память:

  • На самом деле память — единая и общая для всей программы ПЛК и его системных ресурсов и подпрограмм (ядра);
  • Переменные — это ссылки на кусочки памяти ПЛК;
  • Эти кусочки памяти ПЛК имеют разный размер в зависимости от того, сколько данных хранится в переменной;
  • Все данные одной переменной (например, если это строка или массив) идут в памяти ПЛК подряд без фрагментов.

Если бы не было переменных, то мы могли бы обращаться к памяти только напрямую. Тогда операция «iIndex := iIndex + 1» могла бы быть записана только с прямыми адресами: «Ячейка 0x004EA8 = Ячейка 0x004EA8 + 1». Это плохо — замучаешься с ними работать. А когда мы изменим программу — эти адреса ещё и изменятся, потому что в программу добавятся новые переменные или команды процессора. Собственно, поэтому во всех языках и появились переменные: удобное средство работы с данными без заморочек о том, под каким адресом в памяти ПЛК они находятся.

Я захотел рассказать про эту особенность из-за того, что внутри программы ПЛК всегда можно сделать обратную операцию: узнать адрес памяти ПЛК, в котором находится наша переменная. Это делает оператор ADR(). Он возвращает адрес в памяти на момент исполнения программы в ПЛК, и поэтому такой адрес всегда будет правильный. Такой адрес ещё называют указателем: он указывает на место в памяти, по которому лежат какие-то данные.

Как это можно использовать? А для подстановки или копирования данных разных типов и назначения. Для этого нужна библиотека работы с памятью. В CodeSys 2.3 это будет библиотека SysLibMem, а в CodeSys 3.5 — MEM или другая похожая.

Обычно применяется две самые распространённые функции:

  • MemSet()/MemFill() — Заполняет указанный кусочек памяти заданным значением. Это очень удобно, когда надо очистить (обнулить) буфер или массив.
  • MemCopy()/MemMove() — Копирует память из одного места в другое. Это удобно, когда надо скопировать данные из какого-то буфера (например, принятого по интерфейсу связи) в какую-то переменную.

Эти функции принимают Адрес буфера (или Адрес Источника и Адрес, куда записать) и длину данных.

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

Примеры применения, которые я выдумал (почти без кода):

  • Очистка массива arrData любой длины: MemSet(ADR(arrData), SIZEOF(arrData), 0) — заполнит кусок памяти нулями. Без цикла, без ручного присвоения;
  • Копирование структуры (STRUCT) в байтовый массив для передачи по интерфейсу связи или, например, записи в файл: MemMove(ADR(arrData), ADR(myVarStruct), SIZEOF(myVarStruct)). Тут структура представляется просто как массив байт, с которыми можно делать что угодно.
  • Тот же способ можно использовать, если мы получили по RS-485 4 байта, а нам из них надо склеить FLOAT, кстати.

Тут вы увидели ещё один оператор — SIZEOF(). Как раз про него я и поговорю. Он возвращает размер переменной в байтах — как раз то, что нужно для функций работы с памятью.

Ещё один классный приём — отобразить свою структуру (STRUCT) на Slave-память ПЛК. Это позволяет ни фига не объявлять много имён Slave-переменных в ПЛК, а объявить самую первую и на её адрес, полученный при помощи ADR() назначить указатель на нашу структуру. После этого обращаться ко всем Slave-переменным через поля структуры, а не поимённо.

Кайф этого удобства в том, когда в Slave-переменных есть набор одинаковых блоков. Например, переменные для панели оператора для 8 одинаковых насосов (статус, давление, флаги работы). Тогда можно создать структуру, которая опишет один блок переменных для одного насоса, а потом отобразить на Slave-память ПЛК массив этих структур.

И вот сейчас мы и подошли к тому, как наши переменные распределяются в памяти и сколько байт они занимают.

Разные типы переменных занимают разное число байт в памяти, потому что хранят разное количество данных. Например, так:

  • BOOL — 1 байт;
  • BYTE — 1 байт;
  • WORD, INT — 2 байта;
  • DWORD, LONG, REAL — 4 байта.

Оператор SIZEOF() вернёт точный размер переменной в байтах. Использовать его правильно, так как иногда размер переменных зависит от платформы ПЛК или Компьютерной программы: иногда для 32-битных систем LONG может занимать 4 байта, а для 64-битных — 8 байт. И вот тут вот SIZEOF() поможет.

Я придумал и объявил несколько штук переменных разных типов:

Организация памяти: Объявление переменных

Организация памяти: Объявление переменных

По моей схемке эти переменные расположатся в памяти в том порядке, как я их объявил:

Организация памяти: Распределение переменных в памяти (без выравнивания)

Организация памяти: Распределение переменных в памяти (без выравнивания)

Но… Не всё так просто! Дело в том, что в ПЛК и компьютерах есть ВЫРАВНИВАНИЕ адресов. Оно используется для правильной внутренней адресации памяти в программах и чаще всего кратно 2 или 4 байтам. Поэтому на самом деле BOOL может занимать 2 байта, а строка в 4 символа, которая заняла 5 байт, займёт 6 байт.

И… Снова не всё так просто! Строка в 4 символа длиной в 5 байт — это не ошибка, а ещё одна особенность программ для ПЛК и компьютеров! Здесь используется удобный механизм хранения строк, при котором данные в строке заканчиваются символом с кодом «0».

  • Такой механизм позволяет создавать строки почти неограниченной длины: их длина ограничится размером байтового буфера для них и нулевым символом на конце.
    В Visual Basic использовался другой формат хранения строки: первые 4 байта в её начале хранили её длину. А значит, строка не могла быть больше, чем 4 294 967 295 символов, даже если памяти было больше.
    На строки с нулевым символом на конце требуется чуть меньше памяти: +1 байт на символ (+2 на UNICODE), а не 4 байта на длину;
  • Под такие строки сразу выделяется вся заданная в длине строки память. Целиком и подряд. Это удобно, если в строку надо будет что-то добавлять-дописывать;
  • Строки с нулевым символом на конце упрощают работу строковых функций: цикл обхода строки по символам крутится до тех пор, пока не кончится размер буфера или пока не встретит нулевой символ. Поэтому такой формат строк стал так популярен.
  • Этот формат имеет и минус: если в строке до её конца встретится нулевой символ, то все стандартные функции работы со строками там и остановятся и не обработают следующую часть строки. Поэтому, если вы работаете с нестандартными протоколами связи по RS-485, НЕ надо использовать строковые переменные как буферы данных и строковые функции для поиска последовательности байт в них. Используйте байтовые массивы.
    Это же формат имеет и плюс: любую строку можно «обрезать», подставив в нужное место символ с кодом ноль.

Вот поэтому-то наша переменная sValue : STRING(4) занимает в памяти ПЛК 5 байт. Вот, смотрите на скриншот:

Организация памяти: Настоящий размер переменной строки в байтах памяти (не равен длине строки)

Организация памяти: Настоящий размер переменной строки в байтах памяти (не равен длине строки)

А вот так выглядит память подобной переменной в СИ. Я тут выделил символ с кодом 0, который завершает строку:

Организация памяти: Размещение строки с конечным нулём в памяти

Организация памяти: Размещение строки с конечным нулём в памяти

5. Очистка буферов в памяти. Почему это важно?

Недавно на форуме ОВЕНа один пользователь столкнулся с непонятным глюком (спасибо тебе, чувак — как раз из-за тебя и родился этот пост!). Он получает по HTTP JSON-строку, собирая её по байтам в буфере, а потом копирует в строковую переменную для обработки.

И вот он пишет на форуме примерно так: «Чё за фигня? У меня почему-то данные в строке получается всегда одной длины — самой большой, какая была! Я даже задавал меньшее количество байт в MemMove() — но всё равно данные остаются от строки большой длины».

Он даже делал сброс ПЛК по команде из CodeSys, но это не помогло (потому что при обычном сбросе ПЛК память там не очищается).

В чём тут секрет? Разбираемся!

Вот как будет работать строка с нулевым символом в конце:

Работа с буферами: Как строка распределяется в памяти и завершается нулём

Работа с буферами: Как строка распределяется в памяти и завершается нулём

  • При загрузке ПЛК она вся будет заполнена нулями (так как ПЛК автоматически инициализирует все переменные);
  • Если присвоить строке длинное значение из кода программы штатно через «:=», то в память запишется это значение, а потом добавится нулевой символ;
  • Если потом присвоить этой же строке короткое значение снова штатно через «:=», то в память запишется это значение и СНОВА после него добавится нулевой символ.

Только вот предыдущее значение в памяти НЕ очистится, а останется: зачем его стирать, если все штатные функции работы со строками работают с нулевым символом в конце? Если бы мы очищали каждый раз всю строку, то такая операция (проход по всем байтам строки) занимала бы бОльшее время, чем установка нулевого символа в нужном месте строки.

Если мы работаем со строками штатно через строковые переменные, то всё будет нормально. Единственным минусом будет только то, что в конце строки останутся старые длинные данные. Но ни одна строковая функция их не «увидит».

У пользователя была совсем другая ситуация. Он получал по HTTP данные в виде массива байтов и числа байт в нём. Звучит это как «Вам передано 13 байт» — и всё. И вот этот пользователь (он, конечно, даже не прочитает мою статью, потому что торопыга) просто копировал принятые данные в строковую переменную через MemMove().

Смотрите, что тогда у нас будет:

Работа с буферами: Буфер-строка после копирования в неё данных без завершения нулевым символом

Работа с буферами: Буфер-строка после копирования в неё данных без завершения нулевым символом

  • Когда ПЛК запущен, строка заполнена нулями, так же, как и обычно — она пустая;
  • Пользователь копирует длинное значение «Data Value:136» длиной в 14 байт в эту строку. Так как он копирует только 14 байт, то они заполняют строку с самого начала.
    В буфере строки до сих пор остаются нулевые символы, и поэтому ПЛК правильно заканчивает строку: до первого нулевого символа — 15го байта;
  • Дальше пользователь получает и копирует короткое значение «State:ON». Он ожидает его и получить, но он НЕ добавляет нулевой символ в его конце. Поэтому строка будет считана до первого нулевого символа, который стоит на прежнем месте, и он получает «State:ONue:136».

Если бы он очищал свои буферы и строки через MemSet() или правильно ставил завершающий нулевой символ — то всё было бы хорошо. Вот. Из-за таких мелочей могут глючить программы и ломать оборудование.

6. Выход за границы памяти. Переполнение переменных.

Это ещё одна неочевидная фишка программирования в любых языках, которая приводит к глюкам.

Например, в том же аппарате лучевой терапии THERAC-25, который убивал людей повышенной дозой облучения, для сигнала «Настройки в порядке, можно включаться» использовалась одна байтовая переменная BOOL, которая принимала 0 или «не ноль».

Вроде всё ж логично, да? Но не совсем. Потому что там тоже были некоторые любители писать BOOL b := 1 или b := b + 1, чтобы из FALSE сделать TRUE. У нас на форуме ОВЕНа такие есть, и они доказывали мне, что это норм, и TRUE — это всё, что больше нуля. Так вот каждый 255ый раз байтовая переменная в THERAC-25 переполнялась, и аппарат думал, что он ещё не включен, хотя излучение уже шло на жертву.

А есть ещё ситуации с обратным переполнением — на уменьшение. Я сам чуть не сделал одну такую. У меня был счётчик циклов типа WORD — беззнаковый. Ну, казалось бы, всё верно: число циклов будет от нуля и больше. Но беда в том, что этот счётчик уменьшался, а циклом был пресловутый WHILE, с которым легко накосячить.

Ситуация, которая у меня чуть не возникла, была такая. Условием работы цикла было не «Больше нуля», а «Больше или равно нулю». Так как WHILE стоял в начале цикла, то он делал так:

  • Счётчик равен 1 — работаем, внутри цикла отнимаем 1, должны получить 0;
  • Счётчик равен 0 — работаем, внутри цикла отнимаем 1, должны получить -1;
  • …а дальше, так как переменная у меня была беззнакового типа, было бы её ПЕРЕПОЛНЕНИЕ до значения 65535! И цикл продолжался бы от 65535 до 0, потом переменная переполнялась, и цикл бы крутился снова и снова!

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

7. Выход за границы памяти. Неуловимые качественные глюки. Константы.

А теперь рассмотрим ещё один САМЫЙ-САМЫЙ-САМЫЙ распространённый глюк программирования, с которым сталкивается чуть ли уже не каждый на форуме ОВЕН (настолько снизился порог вхождения в ПЛК, что туда приходит незнающий народ). Это — выход за границы данных в памяти!

Проявляется он так:

  • ПЛК просто останаливает программу с Исключением типа «Access Violation»;
  • Программа то работает нормально, а то какие-то данные вдруг изменяются сами собой на какие-то дикие значения;
  • Глючат стандартные FBшки из библиотек или системная часть ПЛК: то работают нормально, а то нет.

Опасность этих ошибок в том, что они крайне сложно отслеживаются, так как для ПЛК (кроме явного «Access Violation») всё происходит хорошо: он что-то записывает в какую-то память и, раз исключения «Access Violation» нет, то явно успешно. Программа работает именно так, как задумал программист ;)

Чтобы понять, как эти ошибки работают и появляются, давайте вспомним то, о чём я рассказывал в других частях этого поста (про устройство памяти и обращение к ней):

  • Данные одной переменной в памяти идут подряд;
  • Соседние переменные могут тоже идти подряд в памяти;
  • В пределах памяти программы (если она не затрагивает системного ядра ПЛК) можно писать почти что попало и куда попало;
  • Конкретно в CodeSys всякие массивы адресуются без проверок выхода за их границы. И это ПЛОХО.

Итак, берём наш переменные, которые мы объявили ранее для примера, и начинаем обходить массив в цикле, делая какие-то вычисления:

Выход за границы циклов: Ошибочное определение границ цикла

Выход за границы циклов: Ошибочное определение границ цикла

Вот только массив у нас начинается с 1, а цикл мы начали с 0. Ну, ошиблись, бывает.

Приведёт это вот к чему. Мы вычислим значение «wData + INT_TO_WORD(iIndex) * 2», которое будет равно «0 + 0 * 2 = 0» и… запишем его не в первый элемент массива, а в переменную iIndex, которая у нас отвечает за цикл!

Я показал это место красным:

Выход за границы циклов: Записывая в массив, мы записали на самом деле в переменную цикла iIndex

Выход за границы циклов: Записывая в массив, мы записали на самом деле в переменную цикла iIndex

Пока wData = 0, то ничего не будет: на первом шаге цикла переменная iIndex и так равна нулю. А вот если бы wData была бы равна НЕ нулю, то… тогда бы вычисленное значение записалось бы в iIndex и ИСПОРТИЛО БЫ ЕГО! Дальше были бы варианты: если бы перезаписанный iIndex укладывался в условие работы цикла (до 4х) — то цикл перескочил бы на эту итерацию, пропустив другие. А если бы перезаписанный iIndex был бы больше условия работы цикла — то цикл дальше вообще не выполнялся бы.

При этом самое ужасное было бы и то, что iIndex перезаписался бы СРАЗУ, в момент записи в несуществующий нулевой элемент массива arrData[0] и, если бы в теле цикла нам нужно было бы использовать iIndex в других местах, он УЖЕ имел бы другое значение. Перезаписанное!

Вот другие варианты такого глюка:

  • Портим данные переменных, которые относятся к другой части программы ПЛК и находятся совсем в другом месте.
    Если эти переменные являются условиями работы циклов — циклы тоже пойдут считать не то и не так.
  • Портим данные переменных Функциональных Блоков (FB), заставляя их не так работать.
    Я один раз поймал такой глюк: у меня блок R_TRIG просто ну вот не хотел работать. Один. Остальные работали, а этот всегда был в TRUE на выходе, хоть тресни. Это был триггер импульса на команду открытия штор. И… совсем в другом месте — в задаче обработки массива очереди SMS-сообщений я вышел за границы массива, записывая TRUE (сигнал что СМСка принята в очередь) в… то место, где был вход блока R_TRIG!!!

Так как я знал, как ведут себя программы с такими глюками, то пошёл проверять границы массивов и нашёл ошибку (неверный индекс в цикле, условное «i + 1» вместо «i»). Вот так-то!

Как сократить число таких ошибок? Нет. Не «внимательно следить за границами». А ИСПОЛЬЗОВАТЬ КОНСТАНТЫ там, где это возможно!

Перепишем наш код, добавив константы DataSizeLow и DataSizeHigh на границы цикла:

Выход за границы циклов: Использование констант для объявления границ цикла по массиву

Выход за границы циклов: Использование констант для объявления границ цикла по массиву

Теперь эти константы у нас находятся не где-то в коде, а в том же списке переменных, где мы объявили массив. Это хорошо: если мы изменим размер массива, то поменяем только эти константы, и цикл будет работать правильно.

А можно ещё лучше. Дело в том, что CodeSys позволяет совершенно нагло и хамски использовать константы даже в объявлениях массивов!

Вот туда мы их и подставим:

Выход за границы циклов: Использование констант для объявления массива и границ цикла по нему

Выход за границы циклов: Использование констант для объявления массива и границ цикла по нему

Теперь весь наш код зависит только от этих констант. Если мы их изменим — то поменяется и размер массива и число итераций цикла для его обхода!

Такое решение я применяю прям вот ВЕЗДЕ у себя в коде: при использовании буферов данных или очередей. Размер буферов или длина очередей задаются константами.

Вот, например, функция создания запроса Modbus для библиотеки SysCom. Она принимает на вход буферы с размером, заданным константой:

Пример использования констант для размера буферов данных в программе ПЛК

Пример использования констант для размера буферов данных в программе ПЛК

А ещё в CodeSys (даже в CodeSys 2.3) есть встроенный механизм неявных проверок. Он позволяет создать функцию, которая будет вызываться для проверки валидности данных: массивов, деления на ноль, переполнения переменных и других. В этих функциях можно написать свой код: исправление границ, запись в Журнал ПЛК, вызов Исключения в программе.

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

Добавляются такие функции через меню «Добавление объекта» => «POU для неявных проверок…» (в CodeSys 2.3 надо просто создать их с заданными именами):

Добавление POU для неявных проверок в CodeSys

Добавление POU для неявных проверок в CodeSys

Далее следует выбрать то, какие возможности добавить (первая галочка у меня недоступна, так как уже добавлена):

Диалог добавления POU для неявных проверок в CodeSys

Диалог добавления POU для неявных проверок в CodeSys

А после этого написать код функции. Вот мой:

Мой вариант реализации функции CheckBounds для проверки границ массивов

Мой вариант реализации функции CheckBounds для проверки границ массивов

Он у меня составляет сообщение в Журнал ПЛК о том, какие границы нарушены и вызывает исключение программы, чтобы она дальше не работала ни в коем случае.

8. Выход за границы памяти. Взломы и инъекции кода.

Раз уж я тут рассказываю про выход за границы буферов и памяти переменных, то хочу упомянуть вам и про то, что часто попадается в новостях в виде кричащих заголовков про «Найдена уязвимость в роутере/браузере/редакторе/программе! Из-за переполнения буфера злоумышленник может получить контроль над компьютером!».

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

Например, вот так, как я нарисовал. Строка — это путь к какому-то системному файлу, который вводит пользователь из диалогового окна или передаёт по HTTP:

Ошибки, связанные с выходом за границы буферов строк: Исходное состояние памяти

Ошибки, связанные с выходом за границы буферов строк: Исходное состояние памяти

Если длины буфера для строки (48 байт у меня в примере) хватит — то она разместится в нём с завершающим нулевым символом, как и должна. Раз у нас выделено 48 байт, то максимальная строка, которая сюда поместится — это 47 символов.

А что будет, если передать в этот буфер строку, длина которой будет больше, чем 48 (47 + 1) символов? Тогда всё зависит от того, как написал код программист. Если он в работе со строками выставил условие «До нулевого символа или пока не кончится буфер» — то всё будет хорошо: больше чем 48 символов мы никуда не запишем.

А если он ошибся или, как некоторые люди с форума, указал размер буфера вручную вместо использования SIZEOF(), то может быть плохо: данные, которые мы приняли в виде строки, будут записаны ПОВЕРХ исполняемого кода программы — команд процессора.

Знаете, как бывает, если писать код без использования оператора SIZEOF()? Ожидал одну длину строки или буфера, прописал его размер везде (циклы, вспомогательные буферы, итоговый массив данных), а потом вдруг понял, что его мало. Увеличил буферы в одном месте, а в другом забыл. И какая-нибудь MemMove() начала перезаписывать что-то не то и не там…

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

Ошибки, связанные с выходом за границы буферов строк: В память после строки внесён новый программный код

Ошибки, связанные с выходом за границы буферов строк: В память после строки внесён новый программный код

А так как программа «знает», что после буфера строки длиной в 48 байт идёт исполняемый код программы — то она радостно начнёт исполнять новый загруженный в неё код. А этот код может дать команду запустить какой-то фалй на компе, загрузить что-то из инета, и так далее… ведь этот код исполняется прям на самом компьютере, как обычная программа!

В наших промышленных ПЛК такое тоже возможно всюду и рядом. Ошибка buffer overflow — самая-самая частая на свете! Поэтому проверяйте свои программы и следите за размерами массивов с данными. И, как я тут уже упоминал, вовремя ставьте завершающие нулевые символы в строки ;)

9. Неосвобождение ресурсов. Неуловимые глюки после длительной работы программы.

Последний вид глюков хорошо проявляется тогда, когда программа работает ДОЛГО, и из-за этого ещё более трудно отлавливается. Связан он с тем, что мы расходуем в программе системные ресурсы — дескрипторы (Handle). Системные дескрипторы — это встроенные ID разных объектов, по которым их можно открывать, делать с ними операции и закрывать.

Сложно? Скажу проще. Когда вы открываете какой-то порт, файл, системный объект ПЛК (Журнал, Тревоги, Тренд и прочее), Ядро ПЛК заносит это себе в некий список: «Ага! Файл открыт под номером 1 на чтение», «О! Порт 3 открыт под номером 88 на запись». А когда вы этот объект закрываете — Ядро ПЛК удаляет его из этого списка.

И… у этого списка тоже есть размер. Например, если он описан как массив WORD — то в нём может быть всего 65535 элементов. И, когда он закончится, ПЛК зависнет или будет глючить.

Как это происходит? Чаще всего так: каждый раз мы открываем какой-то объект, получая его дескриптор, а когда объект не нужен, закрываем его. Это нормально, и так и надо делать. Но ведь иногда при работе с этим объектом может возникать ошибка, и банальный IF вида «Если ошибка — то возврат» не в том месте кода может приводить к тому, что дескриптор не будет закрыт.

Показываю на выдуманном примере простого алгоритма: Открываем порт. Если порт открыт — пишем туда данные. Если была ошибка — то выходим, а иначе закрываем порт:

Трата ресурсов: Пример кода, в котором после ошибки порт не закрывается

Трата ресурсов: Пример кода, в котором после ошибки порт не закрывается

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

Постепенно мы будем открывать и открывать, открывать и открывать. Программа будет тормозить сильнее, сильнее, а потом рухнет. Или зависнет.

Как я уже говорил, такие глюки сложно отловить, потому что мы дескрипторы то закрываем, а то нет. Или же они кончаются через месяц беспрерывной работы ПЛК, потому что при каждом его запуске обнуляются. Такие глюки могут не всплывать годами! Например, работал обычно какой-то станок с Понедельника по Пятницу, а тут вдруг начался аврал, и он проработал ещё и Субботу, Воскресенье, а потом ещё и всю следующую неделю. И к следующему Четвергу заглючил!

Как раз чаще всего грешат этим именно выходы из процедур и подпрограмм по IF в неожиданных местах.

Исправим наш код очевидным методом — добавим перед RETURN закрытие порта:

Трата ресурсов: Код с исправленной ошибкой закрытия порта

Трата ресурсов: Код с исправленной ошибкой закрытия порта

В таком подходе есть скрытое неудобство: в каждом таком выходе по IF надо закрывать все дескрипторы. То есть, снова возникает копипаста. Чтобы этого избежать, в некоторых языках придумали те самые try-catch, при помощи которых можно код закрытия всего, что открыто, прописать один раз в catch и переходить к нему, вызывая исключения (контролируемые нами) в нужных местах программы.

Вот! Я вам и рассказал все приёмы работы с памятью и то, какие глюки на этом можно отловить. Теперь вам осталось это переварить и, кто хочет, переучиться писать более аккуратные программы с проверкой границ массивов, переполнения переменных и буферов в памяти.

Проекту исполнилось 16 лет! Поддержать проект материально, проспонсировать проекты Автора или сделать ему подарок можно на этой странице: "Донаты и Спонсорство, Список Желаний".

4 Отзывов на “Культура программирования для ПЛК: Переменные, Буферы, Массивы, Проверка границ и Данных”


  • 1 vvzvlad

    Блин, а почему люди в 2024 такой штукой вручную занимаются? Аккуратная работа с границами массивов, потому что проверки замедляют программу…
    Это даже не тейк «а почему не писать на питоне»(хотя луа в logic machine моя любовь). Нет, ПЛК, промка, все должно быть надежно и кондово, я понимаю. Но мне казалось, что у человеков в 2024 году достаточно ресурсов процессора и памяти, чтобы не думать о том, насколько замедлится программа и обмазывать все-все проверками, чтобы не допускать багов.
    Это же ПЛК, это не 100500RPS и не кручение терабайтных БД и не нейросети, где каждое ускорение — это возможность сэкономить пару серверов и того стоит. Это ПЛК, тут обьем логики и кода в целом смешной, МК справится, а любой арм, на котором работает современный андроид, должен это делать играюче включая проверки в райнтайме на выход за границы массива те же и сам заботится о областях видимости и освобождении ресурсов, кучу алгоритмов наработали же для питона-жс-джавы же.
    Ну это же как на си писать в 2024. Допустимо только в очень редких случаях, в емебедед например, или ядро линукса (но там это исторически), потому что в эмбедеде экономия в $2 в продакшене на десятках тысячах контроллеров ощутима. Но там контроллер ощутимо влияет на цену конечной железки. Но если сама железка стоит от $100, то лишние $5 в цену кажется небольшой тратой в обмен на повышение качества ПО.

  • 2 Dron9K

    wzvlad: из-за таких как Вы появляются приложения типа калькулятор размером 500 ГБбайт.

  • 3 ducemollari  [Москва]

    «Data Value:136» — таки 14 символов, а не 13.

    Ну и да, питон в ПЛК засовывать необязательно, но сделать в среде проверку на ошибки было бы здорово.

  • 4 CS  [Москва]

    ducemollari Спасибо! Поправил! Вот хоть кто-то читает внимательно! Я, по ходу, когда пост писал, забыл что с нуля считаю.
    А какую? Проверка на выход за границы массивов — да, есть.
    А проверка границ памяти при memcpy — нет, так как это ж функция тупого копирования памяти. Она не знает, что эта память означает, так как воспринимает задание как «Копируй 14 байт отсюда сюда».

Оставить отзыв

Вы должны войти на блог, чтобы оставить комментарий.