Загрузил Andrej S.

Structured Text (ST) IEC 61131-3 Учебник

ST
I
E
C6
1
1
3
13
Из
у
ч
а
е
м
St
r
u
c
t
u
r
e
dT
e
x
t
с
т
а
н
д
а
р
т
а
МЭК6
1
1
3
13
А
в
т
о
р
:
Се
р
г
е
йР
о
ма
н
о
в
I
SBN:
9
78164
1
991
063
Ст
и
л
и
с
т
и
ч
е
с
к
и
йр
е
д
а
к
т
о
р
:
Св
е
т
л
а
н
аКо
с
т
ыч
е
в
а
Т
е
х
н
и
ч
е
с
к
и
ер
а
д
а
к
т
о
р
ы:
К
у
л
а
г
и
нН.
,
Ши
шо
вО.
В.
Вс
еп
р
а
в
аз
а
щи
ще
н
ы(
с
)
2020
L
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Оглавление
Терминология ................................................................................................................................ 8
Лог изменений в книге ............................................................................................................... 10
v.1.202................................................................................................................................... 10
v.1.124................................................................................................................................... 10
О книге ......................................................................................................................................... 12
Structured Text (ST) стандарта МЭК 61131-3........................................................................ 12
Онлайн поддержка по книге .................................................................................................. 12
Обращение к читателю........................................................................................................... 12
Для кого эта книга? ................................................................................................................. 13
Почему я написал эту книгу?................................................................................................. 13
Структура книги...................................................................................................................... 14
1. Справочник ...................................................................................................................... 14
2. Рецепты ............................................................................................................................ 15
Ресурсы .................................................................................................................................... 15
Использованные материалы................................................................................................... 16
О ST .............................................................................................................................................. 17
О языке Structured Text ........................................................................................................... 17
Среда разработки IDE............................................................................................................. 17
Если ST такой крутой, то почему большинство разработчиков пользуется графическими
языками? .................................................................................................................................. 18
Рождение ПЛК..................................................................................................................... 19
Рождение графических языков .......................................................................................... 20
Стандартизация и рождение ST......................................................................................... 21
Заключение .......................................................................................................................... 22
Зачем был нужен ST?.............................................................................................................. 22
Почему нужно знать ST? ........................................................................................................ 23
1. Вы на вершине пирамиды .............................................................................................. 23
2. Продуктивность .............................................................................................................. 24
3. Производительность ....................................................................................................... 24
Что будет дальше?................................................................................................................... 24
1. Спецификация областей ................................................................................................. 24
2. Стандартизация областей ............................................................................................... 25
3. Специализация областей ................................................................................................ 26
4. Упрощение требований .................................................................................................. 26
Справочник .................................................................................................................................. 27
Стр. 1 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Введение ...................................................................................................................................... 28
Язык высокого уровня ............................................................................................................ 28
Структура................................................................................................................................. 28
Порядок исполнения программы .......................................................................................... 29
Элементы высокого уровня.................................................................................................... 31
Глобальные переменные..................................................................................................... 33
Синтаксис .................................................................................................................................... 35
Компилятор.............................................................................................................................. 35
Комментарии ........................................................................................................................... 35
Для чего нужны комментарии?.......................................................................................... 36
Как часто использовать комментарии? ............................................................................. 36
Пустые символы...................................................................................................................... 37
Инструкции.............................................................................................................................. 37
Выражения............................................................................................................................... 38
Операторы.................................................................................................................................... 39
Стандартные операторы ......................................................................................................... 39
1. Арифметические операторы (Arithmetic) ..................................................................... 39
2. Операторы Сравнения (Relational) ................................................................................ 39
3. Логические операторы (Logical).................................................................................... 40
4. Битовые операторы (Bitwise) ......................................................................................... 40
Функциональные операторы.................................................................................................. 41
5. Числовые Операторы (Numeric) .................................................................................... 41
6. Операторы выбора (Selection)........................................................................................ 42
7. Операторы смещения бита (Bitshift) ............................................................................. 43
8. Другие операторы ........................................................................................................... 44
Общие принципы работы операторов................................................................................... 45
Старшинство........................................................................................................................ 45
Два операнда........................................................................................................................ 46
Сокращенные вычисления ................................................................................................. 46
Условия......................................................................................................................................... 49
IF ............................................................................................................................................... 49
Паттерны IF ......................................................................................................................... 49
Усреднение....................................................................................................................... 49
Предварительное отрицание .......................................................................................... 50
Примеры паттернов ............................................................................................................ 50
Логический код................................................................................................................ 50
Паттерн предварительного отрицания .......................................................................... 51
Паттерн усреднения ........................................................................................................ 52
Паттерн минимизации вложенности................................................................................. 53
Стр. 2 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Пример минимизации вложенности.............................................................................. 54
Еще пример...................................................................................................................... 56
CASE ........................................................................................................................................ 58
Последовательности ........................................................................................................... 60
Циклы........................................................................................................................................... 61
FOR........................................................................................................................................... 61
Массивы ............................................................................................................................... 61
Пошаговые вычисления ..................................................................................................... 62
WHILE...................................................................................................................................... 62
Массивы ............................................................................................................................... 63
REPEAT.................................................................................................................................... 64
CONTINUE .............................................................................................................................. 64
EXIT ......................................................................................................................................... 65
Осторожность .......................................................................................................................... 65
Открытый цикл ................................................................................................................... 65
Сложные вычисления ......................................................................................................... 66
Работа в циклах со входами и выходами ПЛК ................................................................. 66
Переменные ................................................................................................................................. 69
Объявления переменных ........................................................................................................ 69
Присвоение имени .................................................................................................................. 70
Области видимости................................................................................................................. 71
Локальные переменные ...................................................................................................... 71
Глобальные переменные..................................................................................................... 71
Входные и выходные .......................................................................................................... 71
Терминология .................................................................................................................. 71
Описание.......................................................................................................................... 72
Внешние переменные ......................................................................................................... 73
Открытые переменные ....................................................................................................... 73
Список всех областей видимости переменных................................................................ 74
Дополнительные ключи.......................................................................................................... 74
CONSTANT ......................................................................................................................... 74
RETAIN и NON_RETAIN ................................................................................................... 75
AT.......................................................................................................................................... 75
Типы переменных ................................................................................................................... 76
Категория 1: Одноэлементные переменные ..................................................................... 76
Категория 2: Группы элементарных типов данных ......................................................... 79
Общие типы (general types) ............................................................................................ 79
Integer: (целочисленный)................................................................................................ 80
Floating point: (числа с плавающей точкой).................................................................. 81
Стр. 3 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Strings: (строковые переменные) ................................................................................... 88
Bit strings: (битовые строки) .......................................................................................... 98
Time: (время) ................................................................................................................. 102
Категория 3: Наследственные типы данных................................................................... 130
Structured (структуры) .................................................................................................. 130
Enumerated (перечисления) .......................................................................................... 131
Alias (Псевдоним) ......................................................................................................... 133
Pointer (Указатель)......................................................................................................... 133
Sub-ranges (диапазоны)................................................................................................. 151
Union (Объединение) .................................................................................................... 152
Array (массивы) ............................................................................................................. 154
Категория 4: Константы литералы .................................................................................. 167
Символьные константы ................................................................................................ 167
Типизированные константы......................................................................................... 168
Константы числового представления.......................................................................... 169
Функции и ФБ ........................................................................................................................... 171
Разница между функциями и ФБ......................................................................................... 171
Как выбрать между двумя вариантами? ............................................................................. 171
Особенность ФБ ................................................................................................................ 172
Особенность Функции...................................................................................................... 172
Вывод ................................................................................................................................. 172
Функциональный блок (ФБ) ................................................................................................ 173
Синтаксис .......................................................................................................................... 173
Расширение ФБ ................................................................................................................. 173
Наследственность ......................................................................................................... 174
Метод (METHOD)......................................................................................................... 176
Действия (ACTION)...................................................................................................... 178
Свойство (PROPERTY)................................................................................................. 179
Переход (TRANSITION)............................................................................................... 179
Работа с функциональными блоками.............................................................................. 179
1. Во время вызова функционального блока .............................................................. 181
2. Назначение до и после вызова ................................................................................. 182
Функция ................................................................................................................................. 184
Синтаксис функции .......................................................................................................... 185
Работа с функциями.......................................................................................................... 185
Функции, возвращающие несколько элементов ............................................................ 186
1. Выходная область...................................................................................................... 186
2. Выходные структуры ................................................................................................ 187
3. Разъединение ............................................................................................................. 188
Стр. 4 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Функции с памятью переменных .................................................................................... 188
Способ 1......................................................................................................................... 190
Способ 2......................................................................................................................... 190
Способ 3......................................................................................................................... 191
Рекурсии............................................................................................................................. 192
Книга рецептов.......................................................................................................................... 193
Обработка сигналов .................................................................................................................. 194
Аналоговые сигналы............................................................................................................. 194
Округление значения аналоговых сигналов с плавающей запятой.............................. 195
Фильтрация аналоговых сигналов................................................................................... 196
Вид фильтра 1: Усредняющий по количеству ............................................................ 196
Вид фильтра 2: Усредняющий по времени ................................................................. 198
Масштабирование Аналоговых сигналов....................................................................... 198
Пример использования ..................................................................................................... 199
Другие полезные функции ............................................................................................... 200
Дискретные сигналы............................................................................................................. 201
Триггеры и импульсы ....................................................................................................... 201
R_TRIG (Raise Trigger) - Передний фронт.................................................................. 202
F_TRIG (Fall Trigger) - Задний фронт ......................................................................... 203
DE (Detect Edge) - Определить фронт......................................................................... 204
SR,RS - Назначение переменных по импульсу .......................................................... 205
Обработка сигналов .......................................................................................................... 207
Шумоподавление........................................................................................................... 207
Выделение...................................................................................................................... 210
Клики.................................................................................................................................. 211
Длинный клик................................................................................................................ 211
Двойной клик ................................................................................................................ 212
Выходные сигналы............................................................................................................ 212
PWM ............................................................................................................................... 212
Обработка ошибок .................................................................................................................... 216
Универсальный блок обработки ошибок ............................................................................ 217
1. Объект ошибки.............................................................................................................. 217
2. Функции управления ошибками.................................................................................. 218
3. Общий блок обработки ошибок................................................................................... 219
Входные переменные........................................................................................................ 220
Выходные переменные ..................................................................................................... 220
Локальные переменные .................................................................................................... 221
Логика ................................................................................................................................ 221
Пример использования ..................................................................................................... 221
Стр. 5 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Запуск двигателей ..................................................................................................................... 222
Универсальный блок............................................................................................................. 222
Входные.............................................................................................................................. 223
Выходные........................................................................................................................... 224
Локальные.......................................................................................................................... 224
Примеры работы ................................................................................................................... 226
Запуск одного двигателя................................................................................................... 226
2 Насоса ............................................................................................................................. 228
3 насоса по очереди .......................................................................................................... 230
Последовательности ................................................................................................................. 234
SFC в ST................................................................................................................................. 234
Элементы SFC ........................................................................................................................... 235
Метод 1: Элементы SFC ....................................................................................................... 235
Предварительно................................................................................................................. 235
Описание метода ............................................................................................................... 236
Порядок анализа шагов .................................................................................................... 237
Примеры решения переходов в случае дивергенций................................................. 239
Определение перехода для очистки в случае дивергенции....................................... 241
Спецификаторы AQ (Action Qualifier) ............................................................................ 242
Спецификатор N (Non-stored) ...................................................................................... 243
Спецификатор S (Stored) .............................................................................................. 243
Спецификатор L (Time Limited)................................................................................... 244
Спецификатор D (Time Delayed).................................................................................. 245
Спецификатор P (Pulse) ................................................................................................ 246
Спецификатор SD (Stored and time Delayed) .............................................................. 247
Спецификатор DS (Delayed and Stored) ...................................................................... 248
Спецификатор SL (Stored and time Limited)................................................................ 248
Спецификатор P0 (Pulse, falling edge) ......................................................................... 249
Спецификатор P1 (Pulse, rising edge) .......................................................................... 250
Общая иллюстрация работы AQ.................................................................................. 250
Конструкция CASE ................................................................................................................... 252
Метод 2: CASE ...................................................................................................................... 252
Именные шаги ....................................................................................................................... 253
Таймеры в последовательностях ......................................................................................... 254
Таймер за CASE ................................................................................................................ 254
Таймер внутри CASE........................................................................................................ 255
Пошаговый вызов ................................................................................................................. 257
Параллельные процессы ...................................................................................................... 260
Другие случаи........................................................................................................................ 261
Стр. 6 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Sockets ........................................................................................................................................ 262
Общие сведения .................................................................................................................... 262
Проблемы работы с сокетами .............................................................................................. 263
Введение ................................................................................................................................ 263
Паттерн программирования ................................................................................................. 265
Пример Modbus TCP Client и Server.................................................................................... 265
Сокет TCP Client (Modbus TCP Master)........................................................................... 265
Client целиком ............................................................................................................... 266
Разбор примера.............................................................................................................. 269
Сокет TCP Server (Modbus TCP Slave) ............................................................................ 277
Server целиком................................................................................................................... 278
Разбор примера.............................................................................................................. 282
Описание функций библиотеки SysLibSocket.................................................................... 288
SysSockCreate .................................................................................................................... 288
Входные переменные.................................................................................................... 289
Пример создания сокета ............................................................................................... 290
SysSockConnect ................................................................................................................. 291
Входные переменные.................................................................................................... 291
SysSockAccept ................................................................................................................... 293
Входные переменные.................................................................................................... 293
SysSockBind ....................................................................................................................... 293
Входные переменные.................................................................................................... 293
SysSockClose...................................................................................................................... 294
Входные переменные.................................................................................................... 294
SysSockListen..................................................................................................................... 294
Входные переменные.................................................................................................... 294
SysSockGetOption.............................................................................................................. 294
Входные переменные.................................................................................................... 295
Об авторе.................................................................................................................................... 297
Стр. 7 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Терминология
IDE
Integrated Development Environment - среда разработки программы для контроллера. Это
может быть CoDeSys, e!COCKPIT для WAGO или TIA Portal для Siemens. Любая среда
разработки, которая поддерживает МЭК 61131-3.
ST
Structured Text.
IL
Instruction List.
CFC
Continuous Function Chart.
SFC
Sequential Function Chart.
КДС
Русское сокращение CoDeSys.
ФБ
Функциональный блок.
POU
Program Organization Unit. Это может быть программа, функция или функциональный
блок.
Стр. 8 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
ООП
Объектно ориентированное программирование.
IIoT
Industrial Internet of Things - Индустриальный интернет вещей.
MQTT
MQ Telemetry Transport. Протокол для передачи сообщений между устройствами в IIoT.
ПЧ
Преобразователь Частот.
ПО
Программное Обеспечение.
Стр. 9 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Лог изменений в книге
Здесь указывается какие изменения вносились в книгу от одной сборки к другой. Здесь
указываются только те сборки, которые были опубликованы и распространены. Изменения
промежуточных сборок, перечисляются в последней опубликованной сборке.
v.1.202
• Исправлены ошибки подсветки синтаксиса.
• Новое оглавление, с ссылками и номерами страниц. Так же в оглавление добавлен
еще один уровень в глубину.
• ссылка - Добавлено описание нового типа данных UNION .
• Спасибо Евгений Кислов (АН СПУ | инженер | Овен). Из важных изменений:
◦ Много мелких правок и уточнений в разделе о языке ST.
◦ Термин Объявления был изменен на термин Инструкция. Термином
объявление теперь подразумевается только объявление переменных.
◦ Примеры инициализации многомерных массивов
◦ Пример сортировки массива
◦ Глава Другие случаи для применения CASE переписана.
• ссылка - Список ресурсов, добавлен новый ресурс это подсветка синтаксиса
библиотекой PrismJS.
• Несколько опечаток. Спасибо Евгений Ларкин, Андрей Дёмшин.
v.1.124
• Поправил все ссылки внутренние по документу.
• Поправил дату сборки в верхнем колонтитуле.
• Добавлен лог изменений.
• ссылка - Описание цикла WHILE . В примере был не рабочий код который мог создать
проблему watch dog. Это когда время работы цикла, превышает отведенное. Был
использован другой более подходящий пример, и немного изменен текст описания.
Спасибо Ю. Поспеев.
• ссылка - Поправлен пример и использования цикла WHILE для прохода по массиву. В
примере не учитывался индекс массива. Такой цикл ни когда не закончился бы или
производил ошибку, при обращении к несуществующему индексу.
• ссылка - Глава "Оценка булевых", была переименована в "Сокращенные
вычисления" и полностью переписана. В ней были указаны не точные данные по
Стр. 10 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
стандартной работе приоритета вычисления булевых операторов. Спасибо Антон
Бравов.
Стр. 11 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
О книге
Structured Text (ST) стандарта МЭК
61131-3
Важно
Эта книга написана именно в том порядке, в котором мне хотелось бы,
чтобы вы ее прочли.
Онлайн поддержка по книге
Что то не понятно? Нужен ответ на вопрос или совет?
• Группа ВК
• Группа Телеграм
Обращение к читателю
Дорогой друг! Я знаю, что ты хороший и добрый человек. Уверен, что тебе ничего не жаль
для друзей. Ты готов делиться с ними всем, что у тебя есть, и, если будет нужно, отдашь
для них, как говорится, последнюю рубаху. Но я прошу тебя не поступать так с этой
книгой.
Круг ее читателей весьма узок, и даже если каждый, кому книга нужна, купит ее по той
мизерной цене, по которой я ее продаю, мне все равно не окупить тех лет кропотливой
работы, которые я на нее потратил. А уж тем более, если эту книгу можно будет свободно
скачать с какого-нибудь бесплатного ресурса. Я тоже твой друг. Не поступай так с моим
произведением, пожалуйста.
Если же ты сейчас читаешь бесплатный экземпляр этой книги, который тебе удалось
каким-то образом получить, то после прочтения ты сможешь оценить мой труд и свою
пользу, оплатив одну копию в магазине plati.market в электронном виде или бумажный
экземпляр в свою библиотеку.
Стр. 12 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Для кого эта книга?
Эта книга не для начинающих разработчиков ПЛК. Она будет полезна следующим
группам специалистов.
Первая группа - это те, кто уже имеет хотя бы минимальный опыт программирования
ПЛК на графических языках, таких как LD, FBD и СFС, SFC и других языках стандарта
МЭК 61131-3. Большого опыта не нужно.
Несмотря на то, что здесь дается достаточно подробное объяснение элементов, их
назначения, применения, все же подразумевается, что у читателя уже есть понимание того,
что такое ПЛК, как он работает, что такое входы и выходы, каких типов они бывают, и т.д.
Эта книга для тех, кто уже работает с ПЛК, но не имеет опыта
программирования в текстовых языках и хочет большей гибкости и
продуктивности в работе. Она для тех, кто намеревался освоить ST, но
не решался этого сделать, а, возможно, и пытался, но не смог из-за
отсутствия хорошего материала.
Вторая группа - опытные программисты ПЛК, которые уже работают на ST, но не имеют
как опыта программирования на Pascal-подобных языках высокого уровня, так и навыка
решения на этих языках иных задач, кроме автоматизации. Например, программирование
прикладных решений или сервисов, веб-сервисов, комплексов клиент-сервер.
Если вы программируете на ST, но ваш профессиональный кругозор
ограничен только этим языком, скорее всего, мой почти 20-летний
опыт программирования на разных текстовых языках будет для вас
весьма ценен. Вы сможете выделить для себя интересные паттерны
программирования, оптимизации и структуризации кода.
Почему я написал эту книгу?
Еще в детстве я познакомился с принципами электротехники - мой отец работал
электриком в шахте, я посещал радиокружок, имел понятие об основных принципах
построения электрических схем. Программированием микроконтроллеров, системами
"умный дом" и промышленной автоматизацией я начал увлекаться в 2010 году. Не прошло
и двух лет, как я уже собрал свой первый Щит Управления ЛОС, работающий под
управлением контроллера xLogics. В этом щите я разрабатывал как программу, так и
электрическую схему. С тех пор я интегрировал множество систем, используя разные
контроллеры нескольких производителей.
Стр. 13 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В результате я сделал заключение, что контроллеры, поддерживающие стандарт МЭК
61131-3, на практике являются самыми удобными. Обучившись работе с ними,
впоследствии можно успешно оперировать огромным набором ПЛК разных
производителей без специальной переподготовки. К тому же, одна и та же программа
легко, с минимальными изменениями, адаптируется с ПЛК одного производителя на ПЛК
другого.
Из всех языков стандарта МЭК 61131-3 я больше склонялся к текстовому языку ST.
Занимаясь прикладным программированием в текстовых языках, высоко оценил его
потенциал в плане гибкости, а также прочувствовал ограниченность графических языков.
В ходе изучения языка ST я столкнулся с полным отсутствием хорошей обучающей базы,
как в русскоязычном Интернете, так и в англоязычном. Обнаружил низкое качество кода в
найденных примерах, особенно в русскоязычной сети. По крайней мере, ситуация была
таковой на момент написания этой книги.
Задачи, которые я ставил перед собой, когда писал эту книгу:
1. Это моя собственная памятка. Когда пишешь то, чему учишь, то и сам лучше
запоминаешь.
2. Желание поделиться накопленным опытом и помочь: одним - освоить язык
программирования, а другим - улучшить качество кода.
3. Как у преподавателя учебного центра Кыргызского Государственного Технического
Университета (КГТУ), у меня есть желание сформировать методическую базу для
последующего создания соответствующих программ обучения, в том числе и для
высших учебных заведений.
Структура книги
Книга состоит из двух основных разделов.
1. Справочник
Раздел содержит основной расширенный набор знаний о языке ST, описание типов
переменных, синтаксиса, операторов и т.д. Как правило, описание включает примеры кода
в контексте предоставляемого материала, а в некоторых случаях - готовые типовые
решения в виде функций и функциональных блоков.
Стр. 14 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
2. Рецепты
В этом разделе собраны готовые рецепты, практические наработки, примеры решения
типовых задач. Эта часть выполнена в виде тематических статей, организованных по
темам.
Ресурсы
В ходе подготовки книги понадобилось немало совмещений ST с современными
технологиями. В процессе работы было создано несколько успешных проектов.
1. Расширение поддержки языка Structured Text для Visual Studio Code
Буквально за несколько месяцев оно стало самым популярным и самым
скачиваемым из всех подобных. Расширение помогает просматривать или верстать
код в текстовом редакторе с подсветкой, просмотреть структуру программы, имеет
множество готовых снипетов.
2. Подсветка синтаксиса для highlight.js
Это самая популярная библиотека JavaScript для подсветки синтаксиса в интернете.
Вы наверняка заметили, что синтаксис у всех примеров Structured Text в Интернете
не подсвечен. Надеюсь, это скоро изменится. Поддержка уже есть. Все примеры в
этой книге были подсвечены именно этой библиотекой.
3. Подсветка синтаксиса для Monaco Editor
Редактор Monaco - самый распространенный веб-компонент редактора кода c
открытым исходным кодом от Microsoft, который используется в веб-приложениях
или веб-решениях. Теперь везде, где используется этот редактор, будет подсветка
синтаксиса языка Structured Text.
4. Подсветка синтаксиса для PrismJS
Prism это еще одна библиотека подсветки синтаксиса, не менее популярная.
Например Prism используется в просмотре документа в Visual Studio Code, Drupal,
Stripe, Sitepoint, React и другие именитые вендоры. Так же на многих блог
платформах. И тут теперь есть поддержка Structured Text.
Стр. 15 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Использованные материалы
При подготовке книги я изучил и использовал множество сторонних материалов. Мне
хотелось ничего не упустить и сделать книгу единой базой знаний. Я использовал
материалы как на русском, так и на английском языках.
1. INTERNATIONAL STANDARD МЭК 61131-3 2014.
2. Structured Text Tutorial to Expand Your PLC Programming Skills, статья.
3. Документация по ST контроллеров Beckhoff.
4. Документация по ST платформы Logicals.
5. Шишов О.В., профессор кафедры электроники и наноэлектроники Морд.ГУ.
Учебная презентация по ST.
6. Шишов О.В., профессор кафедры электроники и наноэлектроники Морд.ГУ.
Учебный материал по языкам МЭК.
7. Петров И. В., Пастушенков Д. В., сотрудники компании "Пролог". Программируем
временные сложности.
8. Петров И.В. Язык ST для С программистов.
9. Видео-серия (на 2:30) Learning PLCs with Structured Text, снятый @evanmj
10. Исходные коды библиотеки OSCAT.
11. Статья Wojciech Gomolka The concept of Sockets and basic Function Blocks for
communication over Ethernet
Стр. 16 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
О ST
О языке Structured Text
Structured Text - один из пяти языков, поддерживаемых стандартом МЭК 61131-3,
разработанный МЭК (международной электротехнической комиссией) для
Программируемых Логических Контроллеров (PLC). Это язык высокого уровня, блочно
структурированный и синтаксически напоминающий Pascal, на котором он и основан.
Стандарт МЭК 61131-3 включает в себя 5 языков:
• LD - (Ladder diagram) Лестничные диаграммы
• FBD - (Function block diagram) Диаграммы функциональных блоков
• SFC - (Sequential function chart) Пошаговые функциональные графики
• ST - (Structured text) Структурный текст
• IL - (Instruction list) Список инструкций
Все языки стандарта МЭК 61131-2 основаны на единой платформе. Многие их элементы
(переменные, система адресации, функции, функциональные блоки и многое другое)
определены в них одинаково. Это позволяет одновременно использовать различные языки
при создании отдельных программных компонентов (POU), даже в рамках одного
программного проекта.
Все языки стандарта МЭК 61131-3 основаны на единой элементной базе. Переменные и
функции определяются на общей платформе, поэтому могут быть использованы во всех
пяти языках внутри стандарта МЭК 61131-3 и одновременно в одной программе.
Среда разработки IDE
ST, как он задуман стандартом МЭК 61131-3, является довольно мощным языком, ничем
не уступающим Си. Все ограничения языка ST в большинстве случаев обусловлены его
реализацией конкретным вендором, нежели самим стандартом.
Существует множество сред разработки, поддерживающих стандарт МЭК 61131-3. Это
Codesys от 3S, TwinCAT от Beckhoff, TIA Portal от Siemens и другие. Как правило, вопрос о
том, какую среду разработки выбрать, перед программистом не стоит, так как, хотя сам
язык стандартизирован, способа его интерпретации в ПЛК нет и внедряется он всеми поразному. Поэтому каждый производитель ПЛК допускает работу с контроллером в какой-
Стр. 17 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
то одной IDE. ПЛК будет привязан или к IDE, произведенной самим производителем ПЛК
(TwinCAD, TIA Portal...), или к общей IDE (Codesys, logi.CAD 3, ...).
Следует отметить, что эта тенденция постепенно меняется и производители начинают
создавать открытые платформы. Например, в рамках экосистемы PLCnext (Phoenix
Contact) пользователь может использовать или МЭК-ориентированное ПО, или плагины
для Eclipse и других IDE. Другой пример - Wago PFC200 можно программировать как в
CODESYS (есть варианты для 2.3 и 3.5), так и e!COCKPIT. И хотя e!COCKPIT построен на
базе CODESYS - но, тем не менее, он содержит и уникальный функционал, которого в
CODESYS нет.
Как простой текстовый редактор я рекомендую VS Code. Данная книга
написана как раз в этом редакторе. Как часть работы над книгой, я
написал расширение для VS Code - Structured Text language support,
которое через неделю стало самым популярным из всех подобных.
Оно дает полную поддержку подсветки синтаксиса и множество
сниппетов.
Если ST такой крутой, то почему
большинство разработчиков пользуется
графическими языками?
Чтобы не быть голословным в утверждении что графические языки являются более
популярными, я провел небольшой опрос в котором приняли участие более 450 инженеров
и программистов ПЛК, который выявил что только 31% программистов ПЛК используют
текстовые языки, коим является ST.
Стр. 18 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Таким образом, если ST не такой популярный, может он и не так уж хорош?
Я выбрал этот вопрос первым, потому что ответ на него таится в понимании антропологии
ST. А это, в свою очередь, проливает свет на другие важные вопросы.
Рождение ПЛК
Обратите внимание, я хочу проследить не хронологию с точными датами, а просто
последовательность событий и показать, как формировалась реальность, в которой мы
находимся сейчас, чтобы понять, какие причины лежали в основе тех или иных изменений.
Перенесемся в начало 70-х годов 20-го века. Программирование микропроцессоров и
первые ПЛК. Конечно, микропроцессоры уже использовались и в 60-х, но это были
именно микропроцессоры. Проектировались системы автоматизации, которые
производились под конкретного заказчика вместе с платой и представляли собой
специализированные устройства.
То есть, если клиенту требовалась автоматизация, ему предлагалось разработать систему
автоматизации, в которую входило как программное обеспечение для микропроцессора,
так и изготовление самой платы управления или, другими словами, изготовление
устройства, которое сейчас мы зовем ПЛК. Устройство проектировалось и
изготавливалось под конкретного заказчика и интегрировалось в технологический
процесс. Именно оттуда возник термин "Системный Интегратор". Кстати,
Стр. 19 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
микропроцессоры в то время программировалось как раз таки текстовым языком Си или
на Ассемблере. Как, в принципе, и сейчас микропроцессоры программируются
преимущественно на Си.
Работая от проекта к проекту, Системные Интеграторы, вывели формулу I/O. Вход/выход.
Все просто. Любой процесс имеет одну и ту же топологию. Есть датчики - входы и
исполнительные устройства - выходы. Те и другие бывают двух типов: дискретные и
аналоговые.
К тому времени уже почти сформировались стандарты аналоговых сигналов. Например,
стандарт сигнала 4-20мА (ISA SP50) первоначально был опубликован в 1966м году, а вот
стандарт 0-10V Analog Control Protocol, Draft 9 был опубликован уже в Июне 1997го.
Коробочный продукт в виде свободно программируемого ПЛК стал естественным
логическим шагом. Зачем постоянно проектировать и интегрировать системы под каждого
заказчика? Проще сделать универсальное устройство, запустить его в серийное
производство, оно станет доступным и простым для внедрений.
Подобный принцип развития событий лежит в основе почти всех технологий.
Для примера, подобным образом развивался и веб. Сначала сайты делались под каждого
заказчика отдельно. Разрабатывалась структура баз данных, страницы сайта, дизайн,
писался код. Позже веб-разработчики вывели формулу, что в 90% случаев задачи схожи и
что у сайтов одинаковая топология. Всем нужны страницы. Страницы обладают набором
свойств: заголовок, текст, дата создания, автор, изображение. Так почему бы не создать
универсальные скрипты? Так родились CMS, как Joomla и Wordpress. Или, например, при
решении задачи стилизации страниц тоже вывелась единая топология и появились готовые
фреймворки, как Twitter Bootstrap.
Важно понять, что единообразие задач порождает инструменты для их решения.
Рождение графических языков
Но вот проблема: для того, чтобы это было экономически выгодно, типовые свободно
программируемые микропроцессорные аппаратные решения должны выпускаться и
реализовываться массово, но при этом пользователю нужно их программировать, причем,
программировать под конкретные задачи. И если их программировать на Ассемблере или
Си, то нужно знать их внутреннюю архитектуру. Кто будет это делать? Где найти столько
высококвалифицированных специалистов? К тому же, стоят их услуги недешево. Да и при
смене процессора, будет меняться и архитектура, и придется снова учиться.
Стр. 20 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Естественным решением стала гениальная идея. В мире уже есть миллионы инженеровпроектировщиков релейных схем. ПЛК призвано заменить релейные схемы, а значит, его
логика работы должна отражать релейную схему. Так родился LD, который и по сей день,
наверное, самый распространенный язык, и во многих IDE является языком по
умолчанию, например, в TIA Portal. Потребовалось совсем немного усилий - и армия
проектировщиков схем и техников релейной автоматики превратилась в армию
программистов ПЛК.
Потом увидели возможность создать язык даже проще, чем LD. Это - FBD. Блочно
структурированный. Если LD был понятен проектировщикам схем релейной логики, то
новый язык функциональных блоков был уже понятен и архитекторам (разработчикам)
диаграмм логически связанных процессов, инженерам-схемотехникам, привыкших
работать с характерной для FBD элементной базой - триггерами, счетчиками и т.д.
Стандартизация и рождение ST
Со временем появилась другая проблема. Графические символы контактов и катушек в
логических диаграммах, наименования блоков, делающих одни и те же задачи, у всех сред
программирования ПЛК были разными, как и другие аспекты реализации языков. Это
тормозило популяризацию ПЛК, так как входной порог, даже для опытного программиста,
все еще был высок. Приходилось практически заново изучать среду программирования
для каждого нового производителя. Представьте себе, что было бы, если бы реализация
РНР на каждом веб-сервере была бы немного разной. На этом сервере, чтобы получить
время, функция time() , на другом gettime() , на третьем current_time(). Пришлось бы
читать документацию по РНР для каждого сервера. Это был бы ад, не так ли? Но это и был
ад для программистов ПЛК, даже на графических языках.
Естественным образом потребовалась стандартизация, чтобы программист тратил как
можно меньше времени для освоения новой среды разработки программ от нового
производителя.
Так в 1993-м году родился наш великий и могучий МЭК 61131-3. Цифра 3 в конце
означает раздел стандарта, который описывает языки программирования. Именно в этот
стандарт и вошел ST. Это был первый специализированный текстовый язык высокого
уровня (кроме С, который в этот стандарт, конечно, не входит), на котором предложили
программировать ПЛК.
Тоже самое происходило и в развитии веб. Появились готовые универсальные решения, но
они все были реализованы по-разному и на разных языках. Существовала проблема при
Стр. 21 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
реализации интеграций между разными системами. Сначала появились RSS, затем SOAP, а
потом и REST API и JSON API, которые стандартизировали формат обмена данными.
Заключение
Думается, нет необходимости делать заключение - мы ответили на поставленный вопрос:
почему ST не так популярен, как графические языки.
Зачем был нужен ST?
ST и все другие языки этого стандарта построены на одной и той же элементной базе. Так
зачем же нужно было создавать текстовый язык? Ведь если база одна, то, казалось бы,
фактически все можно сделать и другими языками.
Первая причина в том, что программисты-прикладники стали искать для себя реализацию
в области АСУ ТП. По разным причинам: чтобы найти новую сферу на рынке труда,
потому что эта область значительно выросла и конкуренция в ней не так велика, или для
самосовершенствования. В любом случае, графическое "чудо" в виде LD и FBD не смогло
их удовлетворить, так как в нем отсутствовали основные и фундаментальные элементы
любого языка - это циклы, условия (типа кейс), массивы. Без этих фундаментальных баз
программирования при создании программы специалист как без рук.
Вторая причина - специализация. В любой растущей отрасли такое происходит. Возьмем
для примера веб. В конце девяностых годов прошлого века существовала одна
специальность - веб-мастер. Позже она разделилась на веб-разработчика и
администратора; потом разработчики разделились на фронтэнд и бэкэнд разработчиков;
затем фронтэнд разработчики разделились на кодеров и дизайнеров, а бэкэндразработчики - на кодеров и архитекторов баз данных. То есть, внутри отрасли, из-за ее
роста, пошел процесс разделения на более узкие специализации.
Так и тут: специализации инженер-проектировщик релейных схем и программист ПЛК
стали разделяться. Это, может быть, не так очевидно для сегодняшних реалий - у нас до
сих пор один "веб-мастер", который сделает все: и спроектирует, и запрограммирует, и
соберет, и запустит, и обслужит. Но на западе разграничение очень четкое.
Проектировщик, электромонтажник, программист, архитектор - это разные специальности,
разные лицензии, и один специалист не может вмешиваться в работу других.
И, разумеется, если появился новый вид специалиста - программист ПЛК, уже не нужно
придумывать для него упрощенный язык. Мы не пытаемся переквалифицировать
Стр. 22 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
проектировщика релейных схем. Нам нужно дать специалисту мощный инструмент. А
таковым может стать только текстовый язык.
Почему нужно знать ST?
Вначале, когда вы начинаете программировать ПЛК, может показаться, что использование
таких графических языков как FBD, CFC, SFC, LD, проще. Это действительно так, но
только для маленьких программ или маленьких функциональных блоков и функций. Хотя,
нужно признать, что для тех, кто до этого не программировал вообще, порог вхождения в
графический язык ниже, чем в текстовый. Но, в результате, потратив чуть больше времени
на изучение ST, позже можно сэкономить недели или даже месяцы рабочего времени.
Умение пользоваться текстовым языком при программировании ПЛК дает инженеру
множество преимуществ.
1. Вы на вершине пирамиды
Тот факт, что все языки стандарта МЭК 61131-3 построены на одной элементной базе, не
делает истиной утверждение, что любой язык может делать тоже самое, что и другой.
1. Ни один язык, кроме ST, не может делать того же, что SFC. Как мы знаем SFC это
язык макетирования пошаговых задач. Исполнить такое блоками (FBD, LD),
решится только самый отчаянный разработчик. А вот ST в свою очередь может
полностью исключить необходимость SFC, и с легкостью исполнить эти задачи,
включая самые сложные сценарии дивергенций и конвергенций.
2. Ни один другой язык не может делать всего того, что может ST.
Эти два тезиса ставят ST на вершину пирамиды, где утверждение, что ST может все, что
могут другие языки, верно. Как и утверждение, что ни один другой язык не может всего
того, что может ST.
И раз ST на вершине пирамиды, то и любой, знающий его, тоже поднимается на эту
вершину.
С 2015 года за рубежом наблюдается очевидно прослеживаемый тренд: знание ST стало
преимуществом при приеме на работу, а ведущие производители делают ST
рекомендованным языком программирования. Таким образом, знание ST выводит
специалиста в первый вагон эшелона автоматизации и делает его приоритетным.
Стр. 23 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
2. Продуктивность
Знание и использование ST повышает продуктивность, что ведет к повышению доходов на
значительный процент.
ST требует меньше времени на создание программы, а, как известно, "время - деньги".
Легко читаемый код позволит как быстро вклиниться в уже готовый проект, так и
поддерживать большой проект, где много кода. Ведь известно, что графические языки
имеют некоторую привлекательность - блоков немного, но в большой программе это
может превратиться в кошмар.
ST стандартизирован, и не потребуется много времени, чтобы переключиться от одного
контроллера к другому, например, из СoDeSys в TIA Portal от Siemens или e!COCKPIT от
WAGO. В дополнение, код ST легко копировать и переносить из одного ПЛК в другой.
3. Производительность
Данное преимущество не совсем однозначно. Правильно созданная программа на
графическом языке будет не менее производительна.
На ST намного меньше шансов создать переусложненный код. Код естественным образом
получается проще и лаконичнее. В придачу, использование определенных техник работы с
массивами и указателями может реально сделать откомпилированную версию программы
меньше в размере. Это, разумеется, отразится на производительности.
Что будет дальше?
Это немного не по теме, но если вы прочли до этой строки, будет не правильно не
поразмышлять о том, как прошлое будет формировать будущее. Прошлое помогает
увидеть будущее. Что же нас ждет в дальнейшем? Куда будут двигаться технологии? Какой
следующий логический шаг?
Понимая, откуда мы пришли и как все развивается, на каких принципах основаны
изменения, можно с большей вероятностью прогнозировать будущее.
1. Спецификация областей
Как мы знаем, если долго работать в какой-то области, начинаешь видеть закономерности
и схожесть топологии задач. Поэтому можно однозначно сказать, что рынок ждет новых,
более узких, специфичных решений.
Стр. 24 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Например, если раньше мы брали универсальный контроллер и делали программу для
отопительного котла или делали решение для автоматизации котла из типовых устройств,
то в будущем появятся специфичные контроллеры, которые будет предназначены для
автоматизации именно котельного оборудования. Ведь все котлы по сути схожи или имеют
схожую топологию. Можно создать устройство, которое будут настраивать через панель и
выбирать: что за топливо, какой тип горелки, какой тип котла, как выставить все задержки
и т.д. Для внедрения нужно просто подключить к нему все датчики и исполнительные
механизмы. Экономия на проектировании щита, на исполнении и внедрении, а так же
понижение квалификации пользователя.
И такие решения начнут появляться для всех областей: контроллер управления насосной
станцией, контроллер управления локальным очистным сооружением, контроллер
управления теплицей, грибницей, контроллер для фонтана, и т.д.
Это касается не только контроллеров, но и релейной автоматики. Такие устройства, как
реле контроля фаз, например, закрывают малые области решения небольших задач.
Допустим, имеется три насоса, и при каждом новом включении нужно, чтобы включался
другой насос - для выравнивания моточасов. Возможно, в будущем создадут реле "один
вход и три выхода", чтобы при каждой новой подаче сигнала на вход включались по
очереди разные выходы. Это просто предположение.
2. Стандартизация областей
Для того, чтобы упростить жизнь пользователям, увеличить продажи и расширить рынок
сбыта, занизив требования к пользователю, потребуются новые стандарты. Или
стандартизация там, где ее еще не было.
Одной и самых важных на сегодня проблем является интеграция оборудования и
коммуникация данных. Ethernet, ModbusTCP, Modbus, EtherCAT, ProfiNET, ProfiBus,
FieldBus и множество других. Однозначно, что-то начнет брать вверх. И, скорее всего, это
будут сетевые технологии на основе Ethernet, так как при использовании оптоволокна
можно добиться беспрецедентной скорости, и расстояния передачи данных. Мало
стандартизировать интерфейсы, нужно стандартизировать и протоколы.
Ведь теперь не контроллеры и системы I/O общаются между собой. Датчики должны
передавать данные напрямую на исполнительные механизмы или другие датчики.
Имеются Облачные технологии и облачные вычисления. Индустриальный интернет Вещей
(IIoT). Скоро каждый утюг должен будет передавать в облако данные о том, сколько он
потребил энергии, что он гладил, кто гладил, потому что перед использованием
пользователь должен будет активировать на утюге свой профайл сканером отпечатка
пальца чтобы ребенок случайно не смог включить прибор и обжечься. Все ради нашей
Стр. 25 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
безопасности. Нужно также иметь возможность удаленного отключения, чтобы не бежать
домой сломя голову, вспомнив, что забыл выключить утюг, и т.д.
Устройства должны передавать данные из одной части мира в другую, и не просто из
одного щита в другой или от одного ПЛК к другому, а от одного устройства к другому
напрямую. Например, утюг отправит чайнику сообщение, что он закончил и теперь
чайнику можно включиться. Новые задачи приведут к новым стандартам - например,
MQTT. Я предполагаю, что именно этот стандарт станет основным протоколом передачи
данных между ПЛК, датчиками, SCADA, облачными службами, исполнительными
механизмами, мобильными устройствами, системами "умный дом" и т.д.
3. Специализация областей
Будут требоваться специалисты все более и более узких областей. Если сегодня ищут того,
кто может и проектировать, и программировать, еще и собирать, то завтра потребуется не
только отдельно программист, но, например, программист именно панелей и интерфейсов
взаимодействия с оператором, и отдельно - программист ПЛК. Или проектировщик
именно систем определенного рода и т.д.
Могут появиться новые специальности. Например, если специализация областей широко
шагнет, появится специальность "настройщик ПЧВ" - человек, который знает, как
настраивать специализированные приборы.
4. Упрощение требований
Всем будет двигать коммерческий интерес. Продажи - это основа. Чтобы много продавать,
нужно понизить порог вхождения и тем самым расширить потребительский рынок.
Поэтому я ожидаю от устройств упрощения в использовании. Например, пружинный
зажим - он будет устанавливаться везде, потому что гибкий провод под него не нужно
обжимать. Повышается надежность, скорость сборки, минимизируется набор
необходимого инструмента.
Уже происходит поставка множества готовых библиотек. Библиотеки для MQTT, готовые
шаблоны и библиотеки для управления другими устройствами, например, для
архивирования данных, для управления ПЧВ. Все это уже начинает появляться, правда,
пока не в полной мере. Зарубежные производители уже поставляют множество библиотек,
например, для работы с DMX, DALI. В любом случае, программировать ПЛК будет все
легче и легче.
Стр. 26 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Справочник
В этом разделе содержится справочная информация о языке ST в объеме, не выходящем за
рамки темы знакомства с языком.
Автор настоятельно не советует читателю сразу переходить в раздел рецептов, хотя
понимает, что искушение велико. Справочник составлен не в сухой форме документации, а
приближен к стилю урока. Многие примеры, приведенные здесь, отражают решения
реальных задач.
Раздел сформирован таким образом, чтобы впоследствии можно было обращаться к нему
за быстрой справкой по синтаксису языка.
Стр. 27 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Введение
Язык высокого уровня
Тем, кто уже программировал на таких языках как PHP, Python или С, будет проще освоить
ST. Синтаксис ST разработан таким образом, чтобы он походил на языки высокого уровня
- с переменными, циклами, условиями и операторами.
Для тех, кто никогда не сталкивался с языками высокого уровня, ST может стать
прекрасным вводным языком.
Структура
Давайте ознакомимся с основной структурой программы, написанной на ST.
PROGRAM PLC_PRG
VAR
a : BOOL;
END_VAR
a := TRUE;
REPEAT
a := FALSE;
UNTIL a := FALSE;
END_REPEAT;
END_PROGRAM;
Внимательно разберем эту структуру. Поняв ее, мы поймем, как работает вся программа.
Обратим внимание на то, что программа начинается и заканчивается ключевыми словами
PROGRAM и END_PROGRAM , а все, что между этими словами, ваша программа ST. Давайте
будет называть их разделительными ключами или просто ключами.
Важно!
В дальнейших примерах я буду опускать ключи PROGRAM и
END_PROGRAM , но они будут подразумеваться.
Открывающими и закрывающими ключами также могут быть:
1. CONFIGURATION и END_CONFIGURATION для определения конфигурации.
Стр. 28 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
2. FUNCTION_BLOCK и END_FUNCTION_BLOCK для определения функционального блока.
3. FUNCTION и END_FUNCTION для определение функции.
4. И еще пара десятков подобных.
Важно!
В большинстве сред разработки IDE эти ключи скрыты, не требуется
их вводить. Они уже подразумеваются, вы же только вводите текст
программы или POU.
Порядок исполнения программы
Программа исполняется строка за строкой, пока не достигнет ключа завершения
программы END_PROGRAM. Но ключ END_PROGRAM не остановит всю программу после ее
исполнения. Как только программа достигнет этого ключа, она начнет исполняться снова.
И так бесконечно в цикле, раз за разом.
Контроль управления программой осуществляет связанная с ней задачи. Как правило
основная программа исполняется циклически. Исключения составляют программы
которые вызываются однократно, привязанные к определенным событиям.
Если вы знакомы с циклами FOR или REPEAT , то основная программа работает по тому же
принципу и никогда не останавливается. Если отобразить языком С как работает
программа, то получится примерно следующее:
InitVariables();
while(1)
{
ReadInputs();
PLC_PRG();
WriteOutputs();
}
Последовательность работы, как правило, неизменна для всех ПЛК.
1. При старте ПЛК однократно исполняем InitVariables(); , где инициализируются
все переменные, которые мы определили во всех программах, глобальные
переменные, функциональные блоки (ФБ), а также входы и выходы ПЛК. Регистры
входов и выходов особые, они физически привязаны или к ножкам процессора, или
к коммуникационной шине, и изменение значений этих регистров приведет к
изменению физического состояния выхода. И наоборот - подача сигнала на вход
ПЛК приведет к изменению значения регистра входа.
Стр. 29 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
2. Затем начинаем в цикле исполнять саму программу ПЛК с определенным
интервалом. Это цикл, который никогда не прерывается, потому что while(1)
всегда будет возвращать TRUE . Внутри этого цикла выполняем следующее.
◦ Сначала загружаем все состояния входов ПЛК в отведенные для них
переменные. Другие - локальные и глобальные переменные, помнят свои
значения с предыдущего цикла.
◦ Выполняем саму программу ПЛК.
◦ Присваиваем выходам значения обработанных программой переменных
связанных с выходами.
ST исполняется последовательно, строка за строкой. Следующая строка не будет
исполнена, пока предыдущая не закончит исполнение. Такой стиль называется
синхронным или блокирующим.
Внимание!
Исключение составляют функциональные блоки. Программа не будет
ждать окончания их работы, чтобы перейти к следующей строке. (См.
Функции и ФБ)
Какой важный вывод можно сделать из понимания принципа работы ПЛК? Рассмотрим
следующий пример:
VAR
a AT %QX0.0.1 : BOOL;
END_VAR
a := TRUE;
act1()
a := FALSE;
act1()
a := TRUE;
Предположим, у нас есть некая переменная a , привязанная к физическому выходу по
адресу контроллера %QX0.0.1 . В теле программы мы меняем ее состояние с TRUE на
FALSE и обратно. Между этим исполняем какие-то подпрограммы act1() . Допустим,
время исполнения подпрограммы act1() 500 мс.
Вопрос: Будет ли выход ПЛК менять свое состояние в FALSE на 500
мс каждый цикл работы контроллера?
Ответ: НЕТ.
Стр. 30 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Ведь назначения выходов происходит только тогда, когда программа завершит работу, а
значит, выходу будет присвоено только то значение, которое будет в переменной a на
момент завершения программы, а все промежуточные значения будут проигнорированы.
Это дает возможность использовать определенный паттерн программирования. Например,
вместо:
IF b > 10 THEN
a := TRUE;
ELSE
a := FALSE;
END_IF
можно смело сделать:
a := FALSE;
IF b > 10 THEN
a := TRUE;
END_IF
Конечно, в данном случае идеальным преобразованием будет просто одна строка a := (b
> 10); , но вот какой принцип я хочу продемонстрировать этим примером.
Разница в примерах в том, что в первом из них переменной a присваивается значение
FALSE только если переменная b меньше 10. А во втором примере переменной a
присваивается значение FALSE каждый цикл программы, хоть и на короткий промежуток
времени, не зависимо от значения переменной b , даже если b больше 10.
Не важно, какой промежуток времени прошел между присвоениями переменной a разных
значений, выход ПЛК не будет мигать. Подробнее об этом поговорим, изучая условия (См.
подробнее условия IF ).
Элементы высокого уровня
На схеме ниже вы можете увидеть элементы высокого уровня и то, как они относятся друг
ко другу. Эти элементы можно программировать на ST.
Стр. 31 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Элемент CONFIGURATION - это элемент языка, который соответствует системе ПЛК.
Конфигурация содержит один или несколько элементов RESOURCE , каждый из которых
содержит задачи TASK , которые запускают программы PROGRAM .
Например:
CONFIGURATION DefaultCfg
VAR_GLOBAL
Start_Stop AT %IX0.0: BOOL
ON_OFF
AT %QX0.0: BOOL;
END_VAR
RESOURCE Resource1
TASK NewTask (INTERVAL := t#20ms);
PROGRAM Main WITH NewTask : PLC_PRG;
END_RESOURCE
END_CONFIGURATION
Обычно производители ПЛК генерируют файл конфигурации автоматически, а для его
изменения создают визуальный интерфейс. Так что, скорее всего, вам напрямую не
придется столкнуться с работой с подобными файлами.
Например, вызов задачи в Codesys.
Стр. 32 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В конфигурации выглядит вот так:
TASK NewTask (INTERVAL := t#20ms);
PROGRAM Main WITH NewTask : PLC_PRG_ST;
Вы можете сами определить время цикла. Например, 20 ms. Это значит, что если
программа закончится раньше, чем через 20 ms, то контроллер подождет, пока это время
истечет, чтобы запустить следующий цикл программы. Можно параллельно вызвать
несколько программ, по разным циклам или событиям. Хотя параллельный запуск
программ поддерживается не всеми IDE или не все CPU способны с этим справиться.
Поэтому, если вам нужно запустить больше одной программы параллельно, обязательно
узнайте у производителя ПЛК, что их продукция поддерживает подобную возможность.
Совет
Время исполнения нужно настраивать таким образом, чтобы его точно
хватило на выполнение программы. Для этого с помощью
соответствующих средств IDE нужно обязательно определить время
исполнения программы.
Глобальные переменные
В конфигурации объявляются глобальные переменные. Например, в Codesys присваиваем
имя qRGB для выхода контроллера.
Стр. 33 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
;
При компиляции программы конфигурация будет создана автоматически, и эта переменная
будет добавлена в нее как глобальная.
VAR_GLOBAL
qRGB AT %QX2.0: BOOL;
END_VAR
Стр. 34 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Синтаксис
Синтаксис ST или любого другого языка - это набор правил написания инструкций,
определяющий значение и форму языка. Синтаксис определяет то, каким образом и в
какой последовательности тот или иной символ или их набор будет интерпретирован в
программе.
Разные символы, такие как точка с запятой ; , двоеточие : и даже невидимый символ
новой строки - все имеет свое значение и цель. Что-то является оператором, что-то
функцией, что-то определением, и все вместе формирует программу.
Далее рассмотрим все отдельные части синтаксиса в деталях. Есть и общие правила.
• Каждая строка, инструкция заканчивается точкой с запятой ; . ST весь состоит из
инструкций и ; нужны чтобы их разделять.
• ST не чувствителен к регистру, хотя это хорошая практика - использовать регистры
для простоты чтения программы.
• Пробел не имеет никакой функции, но его следует использовать для форматирования
программы, для простоты ее чтения.
Компилятор
Прежде чем закачать программу на ПЛК, IDE переведет ST (или любой другой язык МЭК
61131) в машинный код напрямую, или иногда используя промежуточный язык. В
большинстве случаев таким языком является IL, но иногда и сам ST является
промежуточным языком для компиляции графических языков в машинный код.
Это будет сделано компилятором. Другими словами, вы используете ST для объяснения
компилятору того, что вы хотите, а компилятор использует синтаксис ST для того, чтобы
вас понять.
Например, если он увидит символ ; то поймет, что достиг конца инструкции. Компилятор
прочтет инструкцию до конца и только потом превратит его в команду для исполнения.
Комментарии
В программе при использовании языка ST вы можете написать текст, который не будет
исполнен. Эта возможность используется для вставки комментариев.
Стр. 35 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Совет:
Старайтесь приучить себя хорошо комментировать свой код. Это
поможет вам вспомнить значение кода, когда вы вы вернетесь к нему
позже, или другому программисту понять его.
// Комментарий одной строкой
a := 10; (* Комментарий в конце строки *)
a := 10; /* Комментарий в конце строки */
a := 10; // Комментарий в конце строки
(* Многострочный
комментарий *)
/* Многострочный
комментарий */
Хотя в стандарте МЭК 61131-3 определены все эти виды комментариев, я обнаружил, что
не во всех IDE, созданных различными разработчиками этих пакетов, они поддерживаются
в полной объеме. Например, в Codesys v2.3 работают только (* ... *) . Комментарий
типа /* ... */ не поддерживаются даже в Codesys 3.5, хотя и включена поддержка
однострочных комментариев // .
Я буду использовать комментарии в приводимых примерах кода, так что вы еще не раз
увидите, как они пишутся. Я склонен всегда использовать комментарии типа (* ... *) ,
по причине того что они поддерживаются всеми IDE.
Для чего нужны комментарии?
Комментарии полезны не только для того чтобы комментировать код и писать описания
идей заложенных в ваши алгоритмы логики. Комментарии можно использовать, чтобы
закомментировать участок кода и сделать его не исполняемым без его удаления. Например
у вас есть код для отладки или проверки программы во время симуляции, но который вам
не нужен при запуске программы на ПЛК.
Как часто использовать комментарии?
Если у вас хорошая память, то вы можете комментировать минимально или вообще не
комментировать. Но у памяти есть свойство забывать. Моя рекомендация - использовать
инструмент комментирования в меру. Не обязательно описывать всю идею. Иногда, когда
разбираешься в чужом коде, достаточно лишь одной строки, в которой изложен принцип,
Стр. 36 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
скрывающийся за текущим выражением. Привычка комментировать код обязательно
сбережет вам несколько часов времени и несколько десятков нервных клеток.
Пустые символы
Символы пробела, табуляции, новой строки можно использовать где угодно. ST к ним не
чувствителен. Но нельзя разделить подобными символами имена переменных, ключи,
разделители и идентификаторы.
IF
a >
10
THEN
b :=
TRUE
;
END_IF
Пример выше, хотя и слабо читаемый, но рабочий для ST код.
Но если разбить TRU E или END _IF или : = , то возникнет ошибка.
Инструкции
ST состоит из инструкций (statement). Что такое инструкция?
Определение:
Инструкция - это команда для ПЛК о том, что ему делать.
Рассмотрим первую инструкцию. В качестве примера возьмем объявление переменной,
что само по себе так же является инструкцией.
a : BOOL;
Компилятор прочтет это, увидит в конце ; , и поймет, что это инструкция. Закончив анализ
самой инструкции, поймет, что нужно объявить переменную типа BOOL (булев тип) и
отделить для нее место в памяти на один бит.
Вот еще примеры инструкций:
a := TRUE;
или
Стр. 37 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
ton1(IN := TRUE, PT := T#500ms);
Так как мы уже говорил о том, что пустые символы не расцениваются языком ST, как
следствие не обязательно начинать новую инструкцию с новой строки. В одной строке
может быть несколько инструкций разделенных символом ; .
a := TRUE; ton1(IN := TRUE, PT := T#500ms);
А так же инструкции могут быть разбиты на несколько строк.
a := TRUE;
ton1(
IN := TRUE,
PT := T#500ms
);
Вызов функции или функционального блока тоже считается инструкцией. Даже полная
конструкция условия от IF до END_IF , как мы узнаем далее, считается инструкцией, хотя
и может содержать инструкции внутри себя.
Выражения
Определение:
Выражение (expression) - это конструкция языка ST, которая состоит
из операндов и операторов и производит значение определенного
типа.
Операнд - это объект, над которым оператор производит действие. Например:
a := (b - c) * 100 + ADD(b, c);
• Инструкция - a := (b - c) * 100 + ADD(b, c);
• Выражение - (b - c) * 100 + ADD(b, c)
• Операнды - b , c , 100 и ADD(b, c). Функции или, скорее, производные функций,
тоже считаются операндами.
• Операторы ( , ) , - , * и ADD , так как все функции можно считать оператором,
если они используются в выражении.
Стр. 38 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Операторы
Стандартные операторы
Операторы в ST можно разбить на 4 группы. Каждая группа имеет свою специфику и
производит специфичный тип данных.
1. Арифметические операторы (Arithmetic)
Все арифметические операторы, как правило, применяются для записи и осуществления
математических вычислений. Результатом их использования всегда будет проведение
математических вычислений и получение соответствующего числового значения.
• + сложение (add)
• – вычитание (subtract/negate)
• * умножение (multiply)
• ** возведение в степень (exponent)
• / деление (divide)
• MOD остаток от деления (modulo divide)
a := 15 MOD 4 (* результат 3 *)
2. Операторы Сравнения (Relational)
Для сравнения или определения отношения между значениями двух переменных или
выражений. Результат всегда будет булевым, TRUE или FALSE .
• = равно (EQ)
• < меньше чем (LT)
• <= меньше чем или равно (LE)
• > больше чем (GT)
• >= больше чем или равно (GE)
• <> не равно (NE)
Использовать можно как символы, так и буквенные обозначения. Последние 2 строки в
примере выполняют одно и тоже и их результат будет одинаковый.
a := 15;
b := 15;
c := a = b;
Стр. 39 из 298
(* результат TRUE *)
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
c := a EQ b; (* результат TRUE *)
3. Логические операторы (Logical)
Используются, если нужно, работая с булевыми значениями, реализовывать логические
выражения. Результат также всегда будет булевым, TRUE или FALSE .
• & и AND логическое И
• OR логическое ИЛИ
• XOR ИЛИ с отрицанием
• NOT отрицание или негатив, инвертирование переменной. Из TRUE в FALSE, либо
наоборот.
a := TRUE;
b := FALSE;
c := a OR b;
(* результат TRUE *)
c := a AND b;
(* результат FALSE *)
c := NOT a OR b; (* результат FALSE *)
Логические операторы часто используются в сочетании с операторами сравнения. Так как
оператор сравнения производит булево значение, то далее это значение можно
использовать в получении результатов логических операций.
Проверяем, что значение b находится между значениями а и с .
a := 10;
b := 20;
с := 30;
d := ((b > a ) AND (b < C)); (* результат TRUE *)
4. Битовые операторы (Bitwise)
Битовые операторы вычисляют результат побитно. Это значит, что простая логическая
операция производится для каждого бита отдельно.
Логические и битовые операторы выглядят одинаково и применяются автоматически. Если
хотя бы один операнд в выражении будет булевым, то автоматически будет произведено
вычисление булевым логическим оператором, а если оба операнда в выражении будут
битовыми, как BYTE , WORD , DWORD , и т.д., то будет произведено битовое вычисление.
Результат - это число, сумма битовых операций.
• & и AND логическое И
Стр. 40 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• OR логическое ИЛИ
• XOR ИЛИ с отрицанием
• NOT отрицание или негатив, инвертирование переменной. Из TRUE в FALSE, либо
наоборот.
var1 := (2#1001_0011 AND 2#1000_1010) (* Результат 2#1000_0010 *)
Так как у оператора AND с двух сторон битовая переменная, применяется битовый
оператор. Если вы помните выполнение сложения столбиком, то он работает по тому же
принципу. Сначала оператором AND сравнивается первый бит в одном и в другом
операторе и сохраняется в первый бит результата. И так - бит за битом.
• 2#1001_0011 (147)
• 2#1000_1010 (138)
• 2#1000_0010 (130)
Как понятно из примера, в производном 2-х байт оператором AND , битами равными 1
будут те, где они равны 1 в правом И в левом операнде, или верхнем и нижнем, если мы
смотрим на это как на вычисление столбцом.
Функциональные операторы
Уверен, что такого термина как "функциональные операторы" не существует. Следующая
группа операторов немного неоднозначна в плане того, являются ли они операторами.
Многие расценивают следующие операторы, как функции, потому что синтаксически
вызываются они в ST как функции INDEXOF(Variable) , ABS(RealVar) . С другой стороны,
и математические операторы в некоторых IDE можно вызывать как функции, например,
ADD(2, 15) , но это не делает их функциями.
Следующую группу в ST принято считать операторами, но некоторые программисты
называют их функциями.
5. Числовые Операторы (Numeric)
Числовое операторы производят основные математические вычисления. Результат всегда
будет числом типа ANY_REAL , поэтому присваивать значения нужно переменным типа
ANY_REAL или использовать конвертацию. Исключение составляет оператор ABS . Он
вернет целое число ANY_INT .
• ABS возвращает абсолютное число "модуль" значения a := ABS(4.5); будет 4
Стр. 41 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• ACOS возвращает арккосинус
• ASIN возвращает арксинус
• ATAN возвращает арктангенс
• COS возвращает косинус
• EXP возводит в степень
• EXPT возводит в степень с указанием степени
• LN возвращает натуральный логарифм
• LOG возвращает логарифм
• SIN возвращает синус
• SQRT возвращает квадратный корень
• TAN возвращает тангенс
Эти операторы работают со следующими группами типов переменных: ANY_NUM
( ANY_INT , ANY_REAL ).
cr := 20;
(* радиус круга *)
cs := 3.14 * EXPT(cr, 2); (* площадь круга Пи Эр квадрат *)
6. Операторы выбора (Selection)
• SEL - бинарный выбор. Бинарный, потому что переменная, по которой этот выбор
осуществляется, должна быть булева типа. В примере это b.
a: = SEL(b, in1, in2);
Если b будет равно TRUE , то a будет присвоено in2 , а если b будет FALSE , то a
будет присвоено in1 . Переменные in* могут быть любого типа.
• MAX - возвращает большее число из переданных. Стандарт не ограничивает
количество передаваемых значений, но если вы используете CoDeSys вы наткнетесь
на ограничение в 2 значения. Тоже самое касается следующего оператора.
a := 10;
b := 20;
c := MAX(a, b); (* Результат будет 20 *)
• MIN - возвращает меньшее число из переданных 2-х.
• LIMIT - ограничение значений переменной по минимуму и максимуму.
a := LIMIT(10, b, 20);
Стр. 42 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В данном примере, если b находится между 10 и 20, то a будет присвоено значение
b . Если значение b меньше минимума 10, то a будет присвоено 10, а если значение
b будет больше максимума 20, то a будет присвоено 20.
• MUX - мультиплексор. Это хитрая штука - не сразу понятно, для чего нужна, но со
временем вы сами увидите, куда ее вставить. Но как правило, в текстовом языке,
этот оператор применяется очень редко. Я ни разу не применял.
a := MUX(k, in0, in1, in2.........);
a и in0 - in* должны быть одного типа. k может быть ANY_INT , ANY_BIT .
a будет присвоено значение in* по номеру в переменной k .
k : 1;
a := MUX(k, 10, 20, 30);
a будет равно 20, так как k начинается с 0-го индекса.
7. Операторы смещения бита (Bitshift)
• SHL - SHR - побитное смещение влево или вправо на заданное количество бит.
a := 2#0001_0100; (* 20 в десятичной системе *)
b := SHL(a, 1);
(* 2#0010_1000 или 40 в десятичной *)
c := SHR(a, 2);
(* 2#0000_0101 или 5 в десятичной *)
Если сместить все биты влево на 1, то в b будет 2#0010_1000 или 40 в десятичной
системе. Если сместить на 2 бита вправо, то c будет 2#0000_0101 или 5 в
десятичной системе.
Важно!
Если длина входной переменной меньше, чем предполагаемое
смещение, то можно получить некорректные данные.
Подробнее. Для большей очевидности, допустим, у BYTE 2#1000_0000 , это 128 в
десятичной системе.
VAR
a: BYTE := 2#1000_0000;
b: WORD;
END_VAR
b := SHL(a, 1);
Стр. 43 из 298
(* Результат 0 *)
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
b := SHL(BYTE_TO_WORD(a), 1); (* Результат 256 *)
Если сдвинуть на 1 бит влево SHL , то первая единица уйдет за пределы, а справа
новый бит заполнится нулем и мы получим 2#0000_0000 или 0. А, возможно, мы
хотели получить 2#0000_0001_0000_0000 или 256. Автоматически этого не
произойдет, даже если мы присваиваем результат переменной типа WORD . Нужно
обязательно предварительно преобразовать переменную перед операцией смещения.
• ROL - ROR это ротация влево или вправо. То же побитное смещение, только
скрывающийся вправо или влево бит назначается новому биту слева или справа.
Представьте, что число закольцовано и оно просто крутится.
8. Другие операторы
Все ниже перечисленные операторы, не описаны стандартом МЭК, но некоторые ведущие
IDE, такие как CoDeSys, TwinCAT, их поддерживают.
• INDEXOF - с помощью этого оператора можно узнать индекс программного
объекта(POU).
a := INDEXOF(TON_1);
• SIZEOF - позволяет узнать размер или количество байт, отведенных для этой
переменной.
VAR
ar := ARRAY[0..4] OF INT;
c := INT;
END_VAR
с := SIZEOF(ar);
(* результат будет 10. По 2 байта на каждый элемент массива *)
• ADR - возвращает адрес байта в памяти ПЛК, где хранится входная переменная. В
основном, этот оператор используется для привязки переменной к указателю.
Подробнее этот оператор будет рассмотрен в главе об указателях.
• ADRINST - возвращает экземпляр ФБ или POU.
• ^ - снятие косвенности (Dereferencing) или разыменовывание. Используется для
получения значения переменной, на которую ссылается указатель.
• BITADR - возвращает адрес бита.
Стр. 44 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Общие принципы работы операторов
Старшинство
Операторы применяются к операндам последовательно, по определению старшинства.
Самый старший оператор в выражении будет применен первым, далее - младшие, и так до
конца выражения. Операторы одинакового старшинства применяются слева направо. Как в
школьной математике - сначала выполняем умножение, затем сложение и т.д.
Например, есть операнды a , b и c типа INT со значениями 1, 2 и 4, соответственно.
Тогда выражение a + b - c * (c / b) будет оценено следующей последовательностью.
1. Скобки (c / b) имеют высшее старшинство, поэтому будут посчитаны первыми (4
- 2) и получится 2 .
2. Умножение c*(2) будет следующим, потому что имеет следующее старшинство
4*(2) и получится 8 .
3. Сложение a + b и вычитание b - 8 имеют одинаковое старшинство и поэтому
будут исполняться по очереди слева направо. Сначала сложение 1 + 2 , получится
3 , а потом вычитание 3 - 8 , получится -5 .
Таблица операторов по старшинству.
#
Описание
Пример
Старшинство
1
Скобки
A + (B / C)
11
2
Вызов функции
ABS(A) , ADD(X, Y)
10
3
снятие косвенности ^
a^
9
4
Унарный минус
-A
8
5
Унарный плюс +
+A
8
6
Отрицание NOT
NOT A
8
7
Возведение в степень ** A ** B
7
8
Умножение *
A * B
6
9
Деление /
A / B
6
10
Остаток от деления
A MOD B
6
11
Сложение
A + B
5
12
Вычитание
A - B
5
13
Сравнение (<, >, <=, >=) A < B
4
14
Сравнение (=)
4
Стр. 45 из 298
A = B
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
#
Описание
Пример
Старшинство
15
Неравенство <>
A <> B
4
16a Булево &
A & B
3
16b Булево AND
A AND B
3
17
Булево XOR
A XOR B
2
18
Булево OR
A OR B
1
Два операнда
Если у выражения 2 операнда, то левый операнд оценивается первым. Например,
выражение:
(a EQ b) = (a AND b)
Выражение (a EQ b) будет оценен первым, а (a AND b) вторым.
Обратите внимание, вначале я назвал (a EQ b) операндом, а потом выражением. Это и
есть выражение само по себе со своими операторами и операндами, но внутри другого
выражения рассматривается как единое целое с производным результатом, а это уже
операнд. Другими словами, это выражение, но для оператора сравнения = - операнд.
Сокращенные вычисления
Сокращенные вычисления - это когда выражения с булевыми переменными оцениваются
только до точки, где результат уже понятен. Например:
IF (a > b) AND (b > 10) THEN
a := b;
END_IF
Оцениваем сравнение AND . Это значит, что операнды слева и справа должны быть равны
TRUE . Если хотя бы один из операндов вернет FALSE , то оценивать другие операнды уже
не имеет смысла, так как их конъюнкция все равно будет FALSE .
Например, если операнд слева, который оценивается первым, (a > b) вернет FALSE , то
уже нет смысла оценивать операнд справа, (b > 10) . Ведь даже если (b > 10) будет
TRUE , этого все равно не достаточно, чтобы условие сработало. Поэтому анализ
выражения прекратится на (a > b) . Далее пример с оператором OR :
IF (a > b) OR (b > 10) THEN
a := b;
Стр. 46 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_IF
Если (a > b) будет равен TRUE , то уже не нужно оценивать следующий операнд, ведь
результат сравнения все равно будет TRUE .
К сожалению, подобные сокращенные вычисления не оговорены в стандарте ST. В
ST выражения как справа так и слева AND , OR оцениваются все условия всегда. Но нам все
равно важно, в какой последовательности условия вписываются в выражение, по
следующим причинам:
1. Зная что в некоторых других языках это работает, это поможет выработать хорошую
практику написания кода и стиль.
2. В ExST существует альтернатива в виде операторов AND_THEN и OR_ELSE .
Операторы AND_THEN и OR_ELSE не являются частью стандарта МЭК 61131-3 а являются
расширением языка ST. Обычно на него ссылаются как на ExST (Extended ST). К радости,
эти операторы внедрены многими вендорами. Это CoDeSys 3.5 а значит и все основанные
на CoDeSys IDE как например e!COCKPIT от WAGO, TwinCat от Beckhoff, Machine Expert
от Schneider и другие ведущие вендоры. Одним словом, шансы высоки, что в той IDE в
которой вы будет работать, будет включена поддержка этих операторов.
Если в условиях есть функции, которые выполняют сложные расчеты, их можно
располагать последними, и тогда в половине случаев их вычисления даже не будут
проводиться.
Например, имеется выражение с логическим оператором, где используется функция
MYCOUNT(c, d) , расчет которой занимает 1 миллисекунду.
IF MYCOUNT(c, d) AND_THEN a THEN
b := TRUE;
END_IF
Данная конструкция будет занимать 1 миллисекунду времени каждый цикл ПЛК, не важно
a будет TRUE или FALSE . А если развернуть операнды:
IF a AND_THEN MYCOUNT(c, d) THEN
b := TRUE;
END_IF
В этом случае, если a будет равно FALSE , проверка условия займет 1 микросекунду или
0.001 миллисекунды, так как MYCOUNT(c, d) даже не будет вычисляться.
Стр. 47 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Правило здесь простое, в выражениях сравнения оператором AND_THEN , первыми ставим
те операнды, которые чаще всего возвращают FALSE чтобы исключить дальнейший анализ
выражения, а в выражениях сравнения оператором OR_ELSE , первым ставим тот, что чаще
всего вернет TRUE .
Стр. 48 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Условия
IF
IF - пожалуй, самая распространенная конструкция. IF позволяет программе принимать
решения - при каких условиях и что требуется исполнить. Я считаю, что IF - наиболее
интуитивно понятная конструкция условия.
IF [булево выражение] THEN
<инструкции>;
ELSIF [булево выражение] THEN
<инструкции>;
ELSE
<инструкции>;
END_IF;
Вся конструкция блока от IF до END_IF сама по себе является инструкцией, хотя и
содержит инструкции внутри себя, так что символ конца инструкции ; можно поставить
после END_IF . Но, как правило, это не обязательно. После THEN , ELSE и ELSIF символ ;
не ставится.
Рассмотрим пример. Если температура на входе rT1 поднялась выше 100 градусов,
подаем сигнал на аварийный выход xAlert1 :
IF rT1 > 100.0 THEN
xAlert1 := TRUE;
ELSE
xAlert1 := FALSE;
END_IF
Паттерны IF
Так как конструкция IF самая важная для любой логики, то все паттерны в основном
крутятся вокруг этой конструкции.
Усреднение
Применив паттерн усреднения к нашему примеру, код можно сократить до:
xAlert1 := (rT1 > 100.0);
Стр. 49 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Данный паттерн можно применить везде, где внутри условий идет назначение одних и тех
же булевых переменных. Применять подобный паттерн можно достаточно часто, так как
результат выражения в условии конструкции IF булев и ее можно напрямую назначить
всем булевым переменным.
Паттерн усреднения можно использовать не везде, так как внутри конструкций IF могут
быть не только булевы значения, но и некая логика, что делает невозможным полностью
избавиться от конструкции IF .
Предварительное отрицание
Еще один паттерн, который я называю "паттерн предварительного отрицания".
xAlert1 := FALSE;
IF rT1 > 100.0 THEN
xAlert1 := TRUE;
END_IF
Идея этого паттерна в том, что мы сбрасываем все булевы переменные, с которыми
работаем внутри конструкции IF , в FALSE перед условием, а потом используем только
условия, которые изменят переменную в TRUE . Или наоборот - в зависимости от того,
какой приоритет у данной переменной. Как мы выяснили в главе "Введение - порядок
исполнения", такой подход допустим, так как не произойдет отключение выхода на
миллисекунды, если переменная привязана к выходу.
Преимущества этого паттерна: код легче читать, он содержит меньше строк, его работу
проще понимать.
Примеры паттернов
Сравним 2 примера из реального кода управления установки сублимационной сушки. Оба
они делают одно и тоже: включают компрессор охладителя и нужный клапан режима - на
холод или на тепло.
Логический код
Под логическим подразумевается ход мысли: как перевести в код условие, сказанное
словами. Имеются переменные:
• xCoolerOn - Сигнал с панели на включение охладителя
• xSpIndCooler - Индикатор на панели работы охладителя
• xIRKF - Сигнал с реле контроля фаз
Стр. 50 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• qCooler - Выход, включающий охладитель
• qValveCoolerHeat - Выход, режим работы охладителя на нагрев
• qValveCoolerCool - Выход, режим работы охладителя на охлаждение
• xSpCoolerModeHeat - Условие с панели, режим работы охладителя - нагрев или
охлаждение
Допустим, есть условие, которое словами можно выразить так:
Если подан сигнал с панели и реле контроля фаз в порядке, то
включаем охладитель, а если нет - выключаем охладитель. Если
включаем охладитель и режим установлен на охлаждение, то
переключаем на холод; в другом случае - на тепло.
Вот как это описывается в коде.
(* Если подан сигнал с панели и реле контроля фаз в порядке, *)
IF xCoolerOn AND xIRKF THEN
(* то включаем охладитель *)
qCooler := xSpIndCooler := TRUE;
(* Если включаем охладитель и режим установлен на
охлаждение, то переключаем на холод *)
IF xSpCoolerModeHeat = FALSE THEN
qValveCoolerCool := TRUE; (* Режим холод *)
qValveCoolerHeat := FALSE; (* Режим нагрев *)
(* в другом случае на тепло *)
ELSE
qValveCoolerHeat := TRUE; (* Режим нагрев *)
qValveCoolerCool := FALSE; (* Режим холод *)
END_IF
(* а если нет, то выключаем охладитель *)
ELSE
qCooler := xSpIndCooler := FALSE;
qValveCoolerCool := qValveCoolerHeat := FALSE;
END_IF;
Посмотрите на комментарий в коде: это в точности наше условие. На самом деле, был
скопирован текст из условия, чтобы сделать комментарии. Код написан так, как мы
словами высказываем само условие.
Паттерн предварительного отрицания
Тот же код, но с примененным паттерном предварительного отрицания.
Стр. 51 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
qValveCoolerCool := qValveCoolerHeat :=
qCooler := xSpIndCooler := FALSE;
IF xCoolerOn AND xIRKF THEN
qCooler := xSpIndCooler := TRUE;
IF xSpCoolerModeHeat THEN
qValveCoolerHeat := TRUE; (* Режим нагрев *)
ELSE
qValveCoolerCool := TRUE; (* Режим холод *)
END_IF
END_IF;
Какой пример читается легче?
Сначала первый пример может показаться более информативным и логичным, потому что
напрямую отображает ход мысли. Но обратим внимание, что второй пример написан с
применением паттерна предварительного отрицания. Если принять это в расчет, очевидно,
что второй пример намного прозрачнее и легче. Даже буквально - в нем меньше строк.
В коде первого примера читаем в первом условии qCooler := xSpIndCooler := TRUE; И
сразу вопрос: а когда они будут FALSE ? Где в коде это условие находится? Нужно
пересмотреть весь код программы, чтобы найти ответ и убедиться, что эта переменная
нигде больше не назначается. Это небольшой пример, но при работе с файлом в 2000 строк
будет совсем непросто прослеживать логику, даже если вы являетесь автором программы.
И тем более, если программу писали не вы.
Но когда мы смотрим на то же условие qCooler := xSpIndCooler := TRUE; в примере с
предварительным отрицанием, не возникает никаких вопросов. Происходит понимание
того, что это и есть то условие, при котором эти переменные включатся. Во всех
остальных случаях они будут равны FALSE , так как уже известно или имеется в виду, что
все переменные входят в условие в состоянии FALSE .
Мы не ищем, где эти переменные будут отключаться, а подразумеваем их отключение в
начале любой логики. Во втором примере состояние переменных FALSE - это входное
условие, а в первом - вопрос, на который нужно получить ответ.
Паттерн усреднения
Если сюда применить еще и паттерн усреднения, то код сократится еще больше.
qValveCoolerCool := qValveCoolerHeat := qCooler := xSpIndCooler := FALSE;
IF xCoolerOn AND xIRKF THEN
qCooler := xSpIndCooler := TRUE;
qValveCoolerHeat := (xSpCoolerModeHeat);
Стр. 52 из 298
(* Режим нагрев *)
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
qValveCoolerCool := (NOT xSpCoolerModeHeat); (* Режим холод *)
END_IF;
Как видите, здесь и предварительное отрицание, и усреднение. Подобная техника придет
не сразу, но с опытом вы будете писать все более лаконичный и стабильный код. Прочитав
и поняв концепт подобных паттернов, вы в любом случае быстрее придете к подобному
способу мышления.
Чем меньше строк, тем проще понять код. Меньше его реальный откомпилированный
размер, занимаемый в памяти устройства, следовательно, теоретически, он должен
работать быстрее.
Паттерн минимизации вложенности
Еще один важный паттерн - минимизация вложенности. Часто одно условие исполняется
внутри другого условия, внутри которого могут быть еще условия. Большая вложенность
условий часто делает код неудобочитаемым, а отступы уменьшают видимую часть кода по
ширине.
К счастью, паттерн минимизации вложенности может улучшить ситуацию в большинстве
случаев. Он работает, если условие является общим для всего POU, а такое не редкость.
Основная идея этого паттерна - использование ключа RETURN для прерывания кода POU.
Ведь IF делает то же самое - исключает часть логики.
Любую конструкцию, где дополнительная вложенность условий находится в ELSE :
IF (A) THEN
// Логика 1
ELSE
IF (B) THEN
// Логика 2
END_IF
// Логика 3
END_IF
можно преобразовать в:
IF (A) THEN
// Логика 1
RETURN;
END_IF
IF (B) THEN
// Логика 2
END_IF
Стр. 53 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
// Логика 3
А конструкцию, где дополнительная вложенность условий находится в основном условии
IF :
IF (A) THEN
IF (B) THEN
// Логика 1
END_IF
// Логика 2
ELSE
// Логика 3
END_IF
можно преобразовать, вывернув логику основного условия:
IF (NOT A) THEN
// Логика 3
RETURN;
END_IF
IF (B) THEN
// Логика 1
END_IF
// Логика 2
Данный паттерн применим только в том случае, если вся бизнес-логика POU заключена в
одном условие.
Пример минимизации вложенности
Например, код функционального блока таймера с памятью, который я писал для
сублимационной установки. Не обязательно вникать в бизнес-логику этого примера,
просто посмотрите на структуру конструкций IF .
IF pt > T#0s THEN
IF RS OR (TimeWorked > PT) THEN
TimeWorked := T#0s;
TON1(IN := FALSE);
ELSE
IF NOT TON1.Q AND NOT IN THEN
TimeWorked := TimeWorked + TON1.ET;
END_IF
TON1(IN := IN, PT := SEL(IN, T#0MS, PT - TimeWorked), Q => Q);
TW := TimeWorked + TON1.ET;
TP := REAL_TO_WORD(TIME_TO_REAL(TW) * 100.0 / TIME_TO_REAL(PT));
END_IF
ELSE
Стр. 54 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Q := TRUE;
END_IF
Здесь вложенность на 3 уровня - один IF включает в себя другой. Но мы сможем от нее
полностью избавиться.
Так как весь код таймера заключен в одно условие, мы можем убрать первую вложенность.
Вложенные условия находятся в основном условии IF , а значит, нам нужно вывернуть
логику:
IF pt = T#0S THEN
Q := TRUE;
RETURN;
END_IF
IF RS OR (TimeWorked > PT) THEN
TimeWorked := T#0S;
TON1(IN := FALSE);
ELSE
IF NOT TON1.Q AND NOT IN THEN
TimeWorked := TimeWorked + TON1.ET;
END_IF
TON1(IN := IN, PT := SEL(IN, T#0MS, PT - TimeWorked), Q => Q);
TW := TimeWorked + TON1.ET;
TP := REAL_TO_WORD(TIME_TO_REAL(TW) * 100.0 / TIME_TO_REAL(PT));
END_IF
Этот пример делает то же самое. Но смотрите, что получилось: теперь наша логика - это
опять один блок условия. К подобному блоку можно снова легко применить наш паттерн.
Но так как вложенные условия теперь находятся в ELSE , мы не выворачиваем логику.
IF pt = T#0S THEN
Q := TRUE;
RETURN;
END_IF
IF RS OR (TimeWorked > PT) THEN
TimeWorked := T#0S;
TON1(IN := FALSE);
RETURN;
END_IF
IF NOT TON1.Q AND NOT IN THEN
TimeWorked := TimeWorked + TON1.ET;
END_IF
TON1(IN := IN, PT := SEL(IN, T#0MS, PT - TimeWorked), Q => Q);
TW := TimeWorked + TON1.ET;
Стр. 55 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
TP := REAL_TO_WORD(TIME_TO_REAL(TW) * 100.0 / TIME_TO_REAL(PT));
Посмотрите, как радикально изменился код программы, не изменив логики. Представьте,
если бы вложенность была еще больше или было больше самих условий. Подобный
паттерн часто помогает превратить почти нечитаемый код в прекрасный предмет
искусства.
Еще пример
Из примера запуска двигателя, описанного в этой книге:
outDrive := FALSE;
IF inReset THEN
outError := FALSE;
END_IF;
IF NOT inEnable THEN
outError := FALSE;
ELSE
IF inTask AND NOT outError THEN
outDrive := inKF;
IF fbTON1.Q AND NOT inBack THEN
fbTON2(IN := TRUE, PT := T#500ms);
IF fbTON2.Q THEN
fbTON2(IN := FALSE);
outError := TRUE;
END_IF;
END_IF;
ELSE
outDrive := FALSE;
fbTON2(IN := FALSE);
END_IF;
fbTON1(IN := outDrive, PT := T#500ms);
END_IF;
Как видим, не вся логика заключена в условие и вложенные условия в ELSE . Строки до
основного условия нам не мешают, так как они будут находиться всегда до любого
RETURN , который мы применим. Получаем:
outDrive := FALSE;
IF inReset THEN
outError := FALSE;
END_IF;
IF NOT inEnable THEN
Стр. 56 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
outError := FALSE;
RETURN;
END_IF;
IF inTask AND NOT outError THEN
outDrive := inKF;
IF fbTON1.Q AND NOT inBack THEN
fbTON2(IN := TRUE, PT := T#500ms);
IF fbTON2.Q THEN
fbTON2(IN := FALSE);
outError := TRUE;
END_IF;
END_IF;
ELSE
outDrive := FALSE;
fbTON2(IN := FALSE);
END_IF;
fbTON1(IN := outDrive, PT := T#500ms);
Основная идея этого паттерна - использование ключа RETURN для прерывания кода POU.
Исполняем все, что до этого ключа, и не исполняем все, что после. Ведь IF делает то же
самое: исполняет одну часть и не исполняет другую.
outDrive := FALSE;
IF inReset THEN
outError := FALSE;
END_IF;
IF NOT inEnable THEN
outError := FALSE;
RETURN;
END_IF;
IF NOT inTask OR outError THEN
outDrive := FALSE;
fbTON2(IN := FALSE);
RETURN;
END_IF;
outDrive := inKF;
fbTON1(IN := outDrive, PT := T#500ms);
IF fbTON1.Q AND NOT inBack THEN
fbTON2(IN := TRUE, PT := T#500ms);
IF fbTON2.Q THEN
fbTON2(IN := FALSE);
outError := TRUE;
END_IF;
END_IF;
Стр. 57 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
И снова мы можем применить наш паттерн. Не знаю, насколько это было бы
целесообразно на практике, но для наглядности разницы изначального примера и того, что
может получиться, применим наш паттерн еще раз. Применив его, мы увидим, что можем
оптимизировать условия в одно.
outDrive := FALSE;
IF inReset THEN
outError := FALSE;
END_IF;
IF NOT inEnable THEN
outError := FALSE;
RETURN;
END_IF;
IF NOT inTask OR outError THEN
RETURN;
END_IF;
outDrive := inKF;
fbTON1(IN := outDrive, PT := T#500ms);
fbTON2(IN := fbTON1.Q AND NOT inBack, PT := T#500ms);
IF fbTON1.Q AND NOT inBack AND fbTON2.Q THEN
outError := TRUE;
END_IF;
Посмотрите конечный вариант - как преобразовался код функционального блока, когда мы
применили к нему паттерны предварительного отрицания, минимизации вложенности и
запуска таймеров за условиями IF .
В основном примере он останется неизменным, чтобы быть более привычным для
восприятия, но сравните - что было и что стало, и насколько это разный код, при том, что
логика осталась неизменной. Думаю, вы видите, какой великолепной гибкостью обладают
текстовые языки программирования.
Что нам дало применение подобного паттерна? Во-первых, ширина видимого текста
увеличилась. А во-вторых, подобный код намного легче читать и анализировать и, что
более важно - расширять и отлаживать.
CASE
CASE - это конструкция для группировки нескольких условий, объединенных
зависимостью от значения одной переменной.
Стр. 58 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
CASE <Var1> OF
<value1>:<instruction1>
<value2>:<instruction2>
<value3, value4, value5>:<instruction3>
<value6..value10>:<instruction4>
{ELSE
<ELSE-instruction>}
END_CASE;
• Переменная условия <Var1> должна быть из группы типов ANY_NUM или
перечислением.
• То, что в фигурных скобках, {} не обязательно.
• Условие может быть одно <value1> .
• Условий может быть несколько <value1, value2> , перечисленных через запятую.
• Условие может определять промежуток от ... до <value1..value2> .
Если условие IF объединено по значению одной и той же переменной, как например:
IF Svetofor = 1 THEN
sColor := "Green";
ELSIF Svetofor = 2 THEN
sColor := "Yellow";
ELSIF Svetofor = 3 THEN
sColor := "Red";
ELSE
sColor := "White";
END_IF
подобную конструкцию можно заменить на CASE
CASE Svetofor OF
1: sColor := "Green";
2: sColor := "Yellow";
3: sColor := "Red";
ELSE
sColor := "White";
END_CASE
В этом случае читаемость кода будет выше.
Вместо числовых индексов можно использовать перечисления (См. Перечисления).
Например, если у вас есть свой тип перечисление,
TYPE
enumSV : (green := 1, orange := 2, red := 3);
END_TYPE
то CASE может выглядеть более интуитивно.
Стр. 59 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR
Svetofor : enumSV;
END_VAR
CASE Svetofor OF
green: sColor := "Green";
orange: sColor := "Yellow";
red:
sColor := "Red";
ELSE
sColor := "White";
END_CASE
Такой блок читается еще проще.
Вот другой простой пример использования CASE , преобразующий номер ошибки в
сообщение об ее уровне.
VAR
iErrorCode : INT;
sMsg : STRING;
END_VAR
CASE iErrorCode OF
(* С сотой по двухсотую это - предупреждения *)
100..200:
sMsg := 'Предупреждение';
(* Эти конкретные три номера - ошибки *)
340, 350, 370:
sMsg := 'Ошибка';
(* Все коды от -100 до -1 критические ошибки*)
neg100..neg1:
sMsg := 'Критическая ошибка';
ELSE
sMsg := 'Ошибок Нет';
END_CASE;
Последовательности
Одной из самых сильных сторон CASE является возможность использовать его для
создания пошаговых операций, последовательностей или для фиксирующего вызова ФБ.
Подробно об этом читайте в книге рецептов Последовательности
Стр. 60 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Циклы
Циклы - это уникальное преимущество ST, которое отсутствует в других графических
языках. Именно циклы обеспечивают гибкость работы в текстовом языке.
В этой теме на примерах вы увидите элементы, с которыми мы еще не познакомились массивы, структуры. Было очень не просто решить какую тему поставить первой,
переменные или циклы. В теме о переменных, в примерах есть циклы и наоборот. Я
решил, что важнее сначала понять циклы, а если примеры тут будут не совсем понятны,
пропустите их, они станут более понятными, когда мы закончим изучать переменные.
FOR
Цикл FOR , как правило, используется для прохождения по переменным типа ARRAY . Его
также можно применять в алгоритмах, где присутствует пошаговый счет.
Отличие цикла FOR в том, что максимальное количество итераций, которые он исполнит,
заранее известно.
FOR <выражение> TO <int> BY <int> DO
...
END_FOR;
<выражение> как правило содержит назначение переменной, индекса массива. Например:
FOR iCount := 1 TO 100 DO
//...
END_FOR;
Такой цикл, сделает 100 итераций и iCount внутри этого цикла будет автоматически
инкриминирован на 1 после каждого цикла.
Инструкция BY <int> не является обязательной и применяется только в том случае, если
увеличение индекса должно быть больше чем 1.
Массивы
Допустим, имеется несколько температурных датчиков, каждый из которых регулирует
свой выход нагревателя.
Рассмотрим пример, где имеется 3 массива: один с уставками aPV , другой - с
показателями датчиков aTSensors , последний массив aOuts привязан к выходам ПЛК.
Стр. 61 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Включаем выход, если температура упала ниже уставки.
VAR
aPV:
ARRAY[1..5] OF REAL := [
34.5, 32.0, 28.4, 27.0, 35.8
];
aTSensors: ARRAY [1..5] OF REAL;
aOuts:
ARRAY[1..5] OF BOOL;
iCount:
INT := 0;
END_VAR
FOR iCount := 1 TO 5 DO
aOuts[iCount] := (aTSensors[iCount] < aPV[iCount]);
END_FOR
Пошаговые вычисления
Автору этой книги, к сожалению, не известны случаи применения данной технологии в
реальности. Тем не менее, исходя из того, что подобные примеры представлены во всех
инструкциях и мануалах, здесь он также публикуется. Возможно, у читателей возникнут
идеи того, где и как это можно использовать.
VAR
count, sum, I : INT;
END_VAR
count := 1;
sum := 0;
FOR I := 1 TO 50 BY 2 DO
sum
:= ADD(sum, count);
count := ADD(count, 1);
END_FOR;
В данном примере I счетчик цикла. Цикл будет работать 24 раза до 49, так как
инкрементация будет по 2 или через один. 1, 3, 5, 7, .... 47, 49.
WHILE
Основная конструкция цикла WHILE .
WHILE <выражение булево> DO
<инструкции>
END_WHILE;
Цикл WHILE исполняет инструкции или инструкции внутри цикла, пока условие между
WHILE и DO будет равно TRUE . Как только оно изменится на FALSE , работа цикла
Стр. 62 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
прекратится и программа перейдет к исполнению строк программы или инструкциям,
написанным после цикла.
Отличие цикла WHILE от FOR в том, что итерации могут исполняться непредсказуемое
число раз, до тех пор, пока условие в выражении между WHILE и DO будет равно TRUE .
Например, мы хотим найти и удалить из строки все символы пробела. Мы заранее не знаем
сколько их будет, поэтому мы будем искать символ, удалять его, и потом искать опять,
чтобы убедиться что больше символов пробела не осталось в строке.
Весь пример может быть пока не совсем понятен, но главное тут принцип, что мы
продолжаем искать пока не удалим все символы, не важно сколько их, и не зная заранее их
количества.
VAR
(* Строка для обработки *)
sStr: STRING[20] := 'this is test';
(* Позиция найденного символа *)
iPos: INT;
END_VAR
(* Ищем символ пробела в строке. Если найдем то iPos будет > 0 *)
iPos := FIND(sStr, ' ');
WHILE iPos > 0 DO
(* Удаляем символ пробела из строки, и сохраняем в туже переменную *)
sStr := DELETE(sStr, 1, iPos);
(* Снова ищем символ пробела *)
iPos := FIND(sStr, ' ');
END_WHILE;
Массивы
Можно использовать WHILE и для того, чтобы проходить по массиву. Когда это может быть
полезно? Например, это производится до тех пор, пока не встретится определенное
значение.
Например, прохождение по массиву производится до тех пор, пока не встретится значение
температуры в любом элементе массива, превышающее уставку.
VAR
axTemps: ARRAY[1..10] OF REAL;
rTempMax: REAL := 32.5;
bCount:
BYTE;
END_VAR
bCount := 1;
WHILE axTemps[bCount] < rTempMax AND bCount <= 10 DO
Стр. 63 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
heat(bCount);
bCount := bCount + 1;
END_WHILE;
REPEAT
Основная конструкция цикла REPEAT .
REPEAT
<инструкции>
UNTIL <выражение булево>
END_REPEAT;
Цикл REPEAT работает, пока условие UNTIL равно FALSE .
Цикл REPEAT применяется для тех же задач, что и WHILE , с той лишь разницей, что в
REPEAT инструкции исполнятся хотя бы один раз. Как мы видим, выражение условия
UNTIL здесь стоит в конце, поэтому мы сначала исполняем инструкции в REPEAT , а потом
проверяем условие. Значит, инструкции внутри REPEAT будут исполнены хотя бы один
первый раз, даже если условие UNTIL вернет TRUE . Иногда это бывает необходимо.
CONTINUE
Оператор CONTINUE , если поддерживается, то всеми тремя циклами: FOR , WHILE и
REPEAT .
Этот оператор пропускает одну итерацию и переходит к следующей. Предположим, есть
структура нагревателей.
TYPE Heater:
STRUCT
enable: BOOL; (* Вкл/Откл нагреватель *)
SV: REAL;
(* Set Value - Уставка *)
PV: REAL;
(* Processed Value - Текущее значение *)
Delta: REAL; (* Гистерезис *)
END_STRUCT
END_TYPE
Управляем только теми нагревателями, которые включены.
VAR
sHeaters: ARRAY[1..10] OF Heater;
bCount: BYTE;
END_VAR
FOR bCount := 1 TO 10 DO
IF sHeaters[bCount].enable = FALSE THEN
CONTINUE;
Стр. 64 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_IF;
PID(sHeaters[bCount]);
END_FOR
В данном примере функция регулирования PID(sHeaters[bCount]) исполнится только
если свойство структуры enable равно TRUE . А если нет, то CONTINUE прервет текущую
итерацию цикла и перейдет к следующей.
EXIT
Оператор EXIT , если поддерживается, то всеми тремя циклами: FOR , WHILE и REPEAT .
Обычно оператор EXIT в других языках останавливает исполнение всей программы. В ST
оператор EXIT прерывает работу цикла.
Предположим, имеется такая же структура, что и в примере c CONTINUE , но теперь стоит
задача прервать цикл FOR , как только любой из нагревателей нагрелся до 50 градусов.
FOR bCount := 1 TO 10 DO
IF sHeaters[bCount].PV > 50.0 THEN
EXIT;
END_IF;
PID(sHeaters[bCount]);
END_FOR
В этом примере все последующие итерации FOR прекратятся, когда будет вызвано EXIT .
Осторожность
При использовании циклов нужно быть очень аккуратным.
Открытый цикл
Все циклы работают синхронно, или другой термин - блокирующий цикл. Это значит, что
программа не перейдет к исполнению следующей строки, пока не закончит исполнение
предыдущей.
При этом, как мы уже знаем из раздела справочника Ведение - Порядок исполнения,
основная программа ПЛК тоже работает в цикле. После исполнения всей программы, ПЛК
начинает исполнение программы заново.
Стр. 65 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Получается, что все итерации любого цикла FOR , WHILE и REPEAT должны быть
завершены в одном цикле ПЛК прежде, чем ПЛК запустит новый цикл основной
программы. Другими словами, ПЛК не завершит цикл основной программы, пока не
завершиться цикл FOR , WHILE или REPEAT , который вы написали.
Например, расположим в программе подобный код.
WHILE TRUE DO
;
END_WHILE;
Это приведет к зависанию ПЛК. Так как подобный цикл никогда не прервется, то и ПЛК
не перейдет к исполнению следующий строки за циклом, а значит, цикл основной
программы никогда не будет завершен.
Каждый цикл должен быть закрыт, иными словами, в нем должен быть код,
обеспечивающий выход из работы цикла.
Сложные вычисления
Если вычислительная задача внутри цикла на каждую его итерацию будет слишком
сложной и долгой, как и количество самих итераций будет большим, то можно легко
увеличить минимальное время одного цикла ПЛК до неприемлемых значений.
Если цикл ПЛК слишком длинный, то ПЛК будет работать с задержкам. Например, цикл
ПЛК 2 секунды. Если нажать кнопку для управление любым процессом в начале цикла, то
процесс запустится только через 2 секунды после нажатия. Мало того, чтобы это
сработало, кнопку придется все это время удерживать.
Работа в циклах со входами и выходами ПЛК
Особое внимание нужно обратить на особенность работы циклов с реальным входами и
выходами ПЛК. Условие цикла не может зависеть от реальных входов, если сам цикл
управляет выходом.
Известно, что значения на выходные переменные присваиваются, когда вся программа
ПЛК завершит один цикл, а данные со входов считываются один раз перед началом нового
цикла исполнения программы.
Рассмотрим пример. Предположим, мы управляем ПЧВ через аналоговый выход ПЛК.
Пытаемся удержать давление в системе. Конечно, для этого никто не будет использовать
Стр. 66 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
циклы, а применит готовые блоки ПИД регуляторов, но для демонстрации ошибки это
будет очень наглядно.
VAR
(* Данные с аналогового входа измерения давления в системе *)
rPressure AT %ID0.1 : REAL;
(* Аналоговый выход, для управления ПЧВ *)
rVFD AT %QD0.1 : REAL;
END_VAR
WHILE rPressure < 2.5 DO
rVFD := rVFD + 0.1;
END_WHILE
Итак, мы видим, что пока давление не поднялось до 2.5 бар, мы добавляем на выход по 0.1,
чтобы увеличить частоту вращения ПЧВ. Программа очень простая, все понятно. Но
работать она не будет.
Здесь 2 проблемы.
1. При увеличении rVFD частота двигателя не будет увеличена, так как это выходная
переменная, а она будет присвоена выходу ПЛК только по завершении полного
цикла программы. Но программа никогда не завершится, так как частота никогда не
увеличится и, следовательно, давление. Раз не изменится давление, условия нашего
цикла никогда не поменяются.
2. Даже если бы частота увеличилась, данные с rPressure обновились бы только
после завершения цикла всей программы ПЛК, перед началом следующего цикла.
Программа не завершится до тех пор, пока WHILE не закончит работу. Реальное
давление у нас может быть 4 бара, но переменная rPressure будет все еще иметь
значение < 2.5. Таким образом, WHILE никогда не завершит работу, так как
rPressure никогда не изменится, потому что цикл ПЛК никогда не закончится. И
так по кругу.
Таким образом, мы можем заблокировать работу ПЛК полностью.
С другой стороны, следующий пример будет работать, хотя и с реальным выходом ПЛК.
VAR
(* Дискретный выход №1 *)
xOut1 AT %IX0.1 : BOOL;
END_VAR
WHILE xOut1 = TRUE DO
xOut1 := FALSE;
END_WHILE
Стр. 67 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Хотя переменная xOut1 не присваивается выходу и физически выход %IX0.1 не сменит
состояние после первой итерации цикла WHILE , сама переменная в программе изменится и
на следующую итерацию уже будет равна FALSE . Условие xOut1 = TRUE вернет FALSE и
остановит цикл WHILE .
Стр. 68 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Переменные
Переменные представляют собой средства идентификации объектов данных, содержание
которых может изменяться. Например, это данные, связанные с входами, выходами или
памятью ПЛК.
Объявления переменных
Прежде, чем рассмотрим типы переменных, разберемся, как переменные объявляются,
чтобы стали понятны следующие примеры.
Заметка
В разных программах IDE переменные называются по-разному.
Например, в CoDeSys это переменные (variables), в SIMATIC STEP 7
это теги (tags), а в Studio 5000 Logix Designer для Allen Bradley это
символы (symbols).
Объявление делается в верхней части программы, в области введения переменных, между
ключевыми словами VAR и END_VAR .
PROGRAM demo
VAR
a : BOOL;
END_VAR
// Программа тут
END_PROGRAM;
Переменной можно присвоить значение по умолчанию. Оно будет присвоено при
инициализации переменной один раз - на старте программы.
a : BOOL := TRUE;
Можно при помощи ключа AT явно привязывать (См. AT) переменные к точным адресам
памяти ПЛК.
a AT %IX0.0.1 : BOOL; (* Переменная а с привязкой к адресу *)
Объявление можно разделить на 4 части.
[имя] AT [адрес] : [тип] := [значение]; (* [комментарий] *)
Стр. 69 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
1. [имя] - Имя переменной.
2. AT - Ключ привязки переменной к адресу памяти или I/O (См. AT).
3. [адрес] - Адрес ячейки памяти, где хранить переменную. Подробно об адресации
будет рассказано позже.
4. : - Оператор объявления типа.
5. [тип] - Тип переменной.
6. := Оператор присвоения значения.
7. ; Определение окончания инструкции.
8. (* *) Комментарий.
Присвоение имени
Имя переменной может содержать только символы латинского алфавита любого регистра,
символ _ и цифры 0-9. Любой другой символ приведет к ошибке.
Есть и правила, которые не обязательны к исполнению, но считаются общепринятыми,
хорошей практикой.
Принято присваивать префикс при объявлении переменных, сокращение от типа самой
переменной.
• BOOL - x - например: xStart
• BYTE - b - например: bStep
• WORD - w - например: wTime
• INT - i - например: iVal
• USINT - usi - например: usiChannel
• ARRAY - a - где это префикс переменной элемента массива, например: awTmp массив
из WORD
Вот ещё несколько примеров объявления переменных:
axList: ARRAY[1..5] OF BOOL;
xStart_1: BOOL;
xStart_Motor1: BOOL;
wMotor_Speed: WORD;
Для переменных с физических входов и выходов, я присваиваю еще один префикс DI_ ,
DO_ , AI_ и AO_ . Например, если у нас есть аналоговый вход типа WORD , измеряющий
давление, то переменная будет такой: AI_wPressure . Здесь в объявлении виден и префикс
AI_ , и w . Во-первых, таким образом можно быстро получить список всех переменных в
подсказке, просто введя первый префикс, во-вторых, видно не только, что это вход или
Стр. 70 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
выход, но и тип этого входа или выхода - дискретный или аналоговый. Ведь аналоговый
вход или выход может быть REAL , а дискретный вход или выход может быть WORD .
Области видимости
Локальные переменные
Чаще других в программах объявляются локальные внутренние переменные. Это все, что
размещено между VAR и END_VAR . Эти переменные доступны для чтения и записи только
внутри той POU, в которой они были определены.
Глобальные переменные
Это тип переменных, которые видны во всех POU. Как правило, такие переменные
размещаются между ключами VAR_GLOBAL и END_VAR .
Глобальные переменные объявляются в конфигурации между CONFIGURATION и
END_CONFIGURATION . Для видимости глобальных переменных в POU нужно объявить те же
переменные внутри POU в VAR_EXTERNAL , но в некоторых IDE как например CoDeSys
этого не требуется.
Входные и выходные
Терминология
Внешний объект - это POU, в котором вызывается тот POU, о котором идет речь.
Например, если говорится о функции SQRT , для нее внешним объектом будет POU, в
котором она вызывается.
По значению. Передача переменной по значению значит, что в POU передается только
значение переменной и не существует связи между внешним объектом и вызываемым
POU. После присвоения переменной ее можно менять внутри POU, но это не изменит
переменную внешнего объекта, так как было передано просто значение.
По ссылке. Передача переменной по ссылке значит, что передается не значение
переменной из внешнего объекта, а ссылка на эту переменную. То есть, если внутри POU
поменяли значение подобной переменной, ее значение во внешнем объекте тоже
изменится, так как было передано не само значение, а ссылка на значение переменной
внешнего объекта.
Стр. 71 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Описание
Входные и выходные переменные позволяют определить переменные, которым можно
присвоить значения, и таким образом передать их в POU из внешнего объекта в момент
вызова POU, и переменные, которые вернут значения после вызова POU и передадут их
значения во внешний объект.
VAR
a : BOOL;
END_VAR
VAR_INPUT
IN : BOOL;
PT : TIME;
END_VAR
VAR_OUTPUT
Q : BOOL;
END_VAR
Входные переменные VAR_INPUT передаются из внешнего объекта по значению, они
являются локальными для POU, в котором определены. Их нельзя изменить внутри POU.
То есть, присвоить им иное значение возможно, но переменная внешнего объекта, из
которой это значение было передано, не изменится.
Выходные переменные VAR_OUTPUT можно менять в POU, их значения передаются далее
во внешний объект.
Проходные переменные VAR_IN_OUT . Им можно присвоить значения из внешнего объекта,
их можно изменять внутри POU, и их значения передадутся далее во внешний объект.
Можно смотреть на них, как на входные переменные, передающиеся по ссылке.
Эти типы определения переменных обычно используется при написании функций и ФБ
при создании программы, если предполагается ее вызов из другой программы.
Например, функция, которая считает площадь круга:
FUNCTION rCircleP : REAL
VAR_INPUT
rRadius : REAL;
END_VAR
VAR CONSTANT
pi : REAL := 3.14159265359;
END_VAR
rCircleP := pi * (rRadius * 2);
END_FUNCTION
Стр. 72 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Внешние переменные
Внешние переменные определяются при помощь ключа VAR_EXTERNAL .
При определении внешних переменных им нельзя назначать значения по умолчанию. Это
связано с тем, что этот ключ, скорее, не определяет новые переменные, а ссылается на них
и делает их доступными, как локальные, для текущего POU.
Большинство IDE автоматически делают глобальными все переменные, определенные в
VAR_GLOBAL . Но если писать программу на ST без помощи IDE, то для того, чтобы сделать
глобальную переменную видимой в POU, нужно ее определить, как внешнюю. Например,
имеется глобальная переменная:
CONFIGURATION
VAR_GLOBAL
iNum : INT := 10;
END_VAR
END_CONFIGURATION
Тогда в программе нужно было бы обозначить ее как внешнюю, прежде чем мы получим к
ней доступ.
PROGRAM
VAR_EXTERNAL
iNum : INT;
END_VAR
VAR
iNum2 : INT := 20;
iSum : INT;
END_VAR
iSum := iNum + INum2;
END_PROGRAM
Открытые переменные
Вряд ли вы скоро сможете воспользоваться этой функцией, но сейчас есть возможность
открывать доступ к коммуникационным серверам, описанным в 5-ом разделе стандарта
(МЭК 61131-5), к переменным, привязанным к входам, выходам или внутренним
переменным. Для этого используется ключ VAR_ACCESS . Но в тех IDE, где имеется
поддержка МЭК 61131-5, подобное можно делать в графическом интерфейсе, и нет
необходимости писать подобный код. Но понимать, как это работает изнутри, все равно
полезно.
Доступ к переменной должен определяться полным путем к переменной, начиная с имени
POU, и разделяя путь . точкой:
Стр. 73 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR_ACCESS
SHARE1 : RESOURCE1.%IX1.1 : BOOL READ_ONLY;
SHARE2 : RESOURCE1.PROGRAM1.iCount : UINT READ_WRITE;
END_VAR
Не будем уделять этому много внимания, так как в настоящее время данная функция не
имеет практического применения, хотя это интересно и полезно знать для общего
кругозора.
Список всех областей видимости переменных
Ключ
Описание
VAR
Локальная для POU
Временное хранилище для переменных которые не сохраняют свои
VAR_TEMP
значениям между вызовами POU, в котором объявлены
VAR_INPUT
Назначается извне, не может быть изменена внутри POU
VAR_OUTPUT
Назначается из POU во внешний объект
VAR_IN_OUT
Назначается извне, может быть изменена в POU
VAR_EXTERNAL
Назначается из VAR_GLOBAL и может меняться в POU
VAR_GLOBAL
Глобальная переменная, видимая в любом POU
VAR_ACCESS
Определение уровня доступа к переменной
VAR_CONFIG
Инициализация, специфичная экземпляру
Дополнительные ключи
CONSTANT
Этот ключ определяет группу переменных, как переменные защищенные от изменений,
константы. Такую переменную нельзя переназначить. Пример с Pi подходит как нельзя
лучше. Это неизменяемое число.
VAR CONSTANT
PI : REAL := 3.14;
c_MAX_LENGTH : INT := 100;
END_VAR
Желательно всегда использовать константы там, где это имеет смысл, чтобы защититься от
случайных изменений значения этой переменной.
Стр. 74 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Принято именовать константы большими буквами. Часто ставят префикс с_ к константе.
Это улучшает читабельность кода. Сразу видно, что это константа, а не перечисление,
например. Легко найти нужную константу при автозаполнении, просто введя с_ - выпадет
список со всеми константами.
RETAIN
и NON_RETAIN
Ключ RETAIN позволяет сделать переменную энергонезависимой. Обычно NON_RETAIN не
используется, так как по умолчанию все переменные создаются энергозависимыми. Если
необходимо, чтобы какая-то переменная сохранила свое значение после сбоя программы
или выключения ПЛК, можно использовать ключ RETAIN :
VAR RETAIN
xError:
BOOL := TRUE;
uiCounter: UINT := 0;
END_VAR
Здесь нужно быть осторожным. Как правило, ресурс подобной памяти в ПЛК ограничен
количеством циклов перезаписи. Не желательно использовать переменные, которые часто
меняют свое значение.
AT
AT позволяет привязать переменную к точному адресу памяти контроллера. Обычно это
делается для привязки переменных к адресам входов и выходов ПЛК. AT можно
использовать, чтобы явно задавать адрес хранения переменной в памяти ПЛК.
VAR
rTemperature AT %ID0.0.0: REAL;
END_VAR
Адресация начинается со знака % и далее идут один или два буквенных символа.
Первый символ ссылается на адресное положение переменной. Этот символ всегда должен
присутствовать.
Сим.
Адрес
I
Входы ПЛК
Q
Выходы ПЛК
M
Внутренняя память
Второй символ обозначает размер переменной
Стр. 75 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Сим.
Автор: Сергей Романов
Адрес
X или без
Один бит BOOL
B
Один байт (8 bits) размер BYTE
W
Слово (16 bits) размер WORD
D
Двойное слово (32 bits) DWORD
L
Большое (quad) слово (64bits) размер LWORD
Адресное пространство отображается числами. Это может быть и 0.0 - 9.9 и 0.0.0.0 9.9.9.9 или просто 0 - 9999 без точек. Количество разрядов (чисел разделенных точкой)
зависит от разных производителей и объема отведенной производителем области памяти, к
которой мы обращаемся. Разряды могут быть не только в диапазоне 0-9, но и в диапазоне
0-255.
Ответ на вопрос, в каких рамках можно обращаться к адресам памяти, нужно искать в
руководствах производителей контроллеров, которые вы программируете. Определяются
эти разряды в конфигурации CONFIGURATION , в ключе VAR_CONFIG ... END_VAR .
Типы переменных
Типы данных делятся на 3 основные категории.
Внимание
Правильный подбор типов переменных поможет использовать память
ПЛК наиболее эффективно и писать более производительные, менее
ресурсоемкие программы.
Категория 1: Одноэлементные переменные
Когда создается переменная с именем, то в памяти контроллера под нее отводится место.
Это делается автоматически. Как сообщалось ранее, при объявлении переменной можно
указать к какой области памяти ее привязать.
VAR
rTemperature AT %ID0.0.0: REAL;
END_VAR
Можно не только привязывать адреса к именам переменных, но и напрямую использовать
адреса в коде программы.
Стр. 76 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
IF %IX0.1.2 THEN
%QX0.0.1 := TRUE;
END_IF
Одноэлементные переменные напрямую обращаются к зонам памяти.
Этот пример говорит о том, что если на дискретном входе ПЛК 0.1.2 будет сигнал, нужно
включить выход ПЛК 0.0.1.
Обращаться можно не только к входам и выходам ПЛК, и не только к булевым значениям.
Можно обращаться и к внутренней памяти. Например, обратиться к ячейке памяти
размером WORD можно через символ %MW0 .
%MW0 := 4585;
Важно
Существует опасность перезаписи значений при использовании
одноэлементных типов.
Суть потенциальной проблемы в том, что размер адресации X , B , W , D , L не отводится в
разных ячейках памяти. То есть, %MW0 , %MB0 , %MX0 - все начинаются с одного и того же
адреса памяти. По сути, %MB0 и %MB1 - это первый во второй байт %MW0 , а сам %MW0 первый из двух регистров %MD0 .
Стр. 77 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Если, например, сделать так:
%MW0 := 15; (* побитно будет 0000 0000 0000 1111 *)
%MB1 := 15; (* побитно будет 0000 1111 *)
то в результате %MW0 будет равно 3, 855 в байтах 0000 1111 0000 1111 , так как ячейке
памяти %MB1 было присвоено 15, а это второй байт %MW0 .
Важно
Существуют ли программисты "грешники"? Да. Это те, кто использует
подобную технику работы с одноэлементными переменными. Такая
практика считается порочной, это очень плохой стиль
программирования. Я советую использовать его только в случаях
крайней необходимости или во время написания коротких, тестовых
программ для проверки идеи. Однако, рекомендую пользоваться
именными переменными.
Стр. 78 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Категория 2: Группы элементарных типов
данных
• General (общие)
• Integers (целочисленные)
• Floating point (числа с плавающей точкой)
• Strings (строки)
• Bit strings (битовые строки)
• Time (время)
В каждой группе элементарных типов есть несколько типов данных, определенных в МЭК
61131-3:
Общие типы (general types)
Каждый из простых типов, описанных далее в этом разделе, относится так же и к общему
типу ANY . Вот иерархия общих типов и элементарных типов, к которым они относятся.
ANY
ANY_DERIVED
ANY_ELEMENTARY
ANY_MAGNITUDE
ANY_NUM
ANY_REAL
LREAL
REAL
ANY_INT
LINT, DINT, INT, SINT
ULINT, UDINT, UINT, USINT
ANY_DURATION
TIME
LTIME
ANY_BIT
LWORD, DWORD, WORD, BYTE, BOOL
ANY_CHARS
ANY_STRING
STRING
WSTRING
ANY_CHAR
CHAR
WCHAR
ANY_DATE
DATE_AND_TIME
DATE, TIME_OF_DAY
Стандарт это не оговаривает, но некоторые IDE позволяют в некоторых случаях,
применять к переменным некоторые общие типы. Тогда, в теории, переменная сможет
Стр. 79 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
принимать значения из любой переменной дочернего типа. Например, объявив
переменную типа ANY_REAL , можно назначать ей переменные дочерних типов, таких как
REAL так и LREAL .
VAR
iCount : ANY_NUM;
a: INT;
b: USINT;
END_VAR
iCount := a;
iCount := b;
Такой подход хорош при написании функций или функциональных блоков, где точно не
известно, какой тип данных поступит в функцию, поэтому общий тип подходит как нельзя
лучше. К сожалению, в связи с непростой логикой и определенными сложностями в
реализации, не все вендоры внедрили и поддерживают данную возможность.
Integer: (целочисленный)
Тип данных МЭК
Формат
Значение
-128 ... 127
Кол. байт
SINT
Short Integer
1
USINT
Unsigned Short Integer 0 ... 255
INT
Integer
-32768 ... 32767 2
UINT
Unsigned Integer
0 ... 2^16-1
DINT
Double Integer
-2^31 ... 2^31-1 4
UDINT
Long Double Integer
0 ... 2^32-1
LINT
Long Integer
-2^63 ... 2^63-1 8
ULINT
Unsigned Long Integer 0 ... 2^64-1
1
2
4
8
Обратим внимание на то, что целочисленные типы данных с приставкой U не принимают
отрицательных значений. Всегда стоит использовать тип с приставкой U , если известно,
что значение не будет отрицательным.
Пример определения переменных целочисленного типа с назначением в коде:
VAR
usiStage : USINT := 0;
diMotorStepsCounter : DINT := 1_254;
END_VAR
usiStage := 14;
Стр. 80 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Назначение можно делать как в десятичном формате, так и в других форматах (описано
позже). Обратите внимание на символ _ . Его можно использовать в числах, а так же в
типах время. Обычно его используют как разделитель для удобства чтения длинных чисел.
Полезные POU
Важно!
Большая часть примеров функций, приведенных в разделе
переменных, были взяты из библиотеки OSCAT.
К переменным числового типа можно применить все арифметические операторы, такие
как + , - , * , / , MOD и т.д., а если преобразовать в Floating point, то и все числовые
операторы, такие как ABS , SQRT , COS , SIN и т.д. Но, к сожалению, в стандарт МЭК
61131-3 не входят, как обязательные, некоторые полезные математические функции. Мы их
рассмотрим, изучая числа с плавающей точкой.
EVEN
Вычисляет, является ли число четным или нет. Если четное, то вернет TRUE , а если нет вернет FALSE .
FUNCTION EVEN : BOOL
VAR_INPUT
in : DINT;
END_VAR
EVEN := NOT in.0;
END_FUNCTION
Floating point: (числа с плавающей точкой)
Тип данных МЭК Формат
Значение
REAL
Real Numbers
±10^±38
LREAL
Long Real Numbers ±10^±308
Назначения можно присваивать следующим образом:
VAR
rNum : REAL;
END_VAR
rNum := 4.5;
rNum := 1.64e+009;
Стр. 81 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Полезные POU
Стандарт не предусматривает большого количества математических и арифметических
функций, которые в других языках являются базовыми. Рассмотрим несколько примеров
полезных функций для работы с данными типа ANY_REAL .
FLOOR
Округляет значение и возвращает ближайшее целое значение, которое меньше или равно
X.
floor(3.14) = 3
floor(-3.14) = -4
FUNCTION FLOOR : DINT
VAR_INPUT
X : REAL;
END_VAR
FLOOR := REAL_TO_DINT(X);
IF DINT_TO_REAL(FLOOR) > X THEN
FLOOR := FLOOR - 1;
END_IF;
END_FUNCTION
CEIL
Округляет значение и возвращает ближайшее целое значение, которое больше или равно
X.
ceil(3.14) = 4
ceil(-3.14) = -3
FUNCTION CEIL : DINT
VAR_INPUT
X : REAL;
END_VAR
CEIL := REAL_TO_DINT(X);
IF DINT_TO_REAL(CEIL) < X THEN
CEIL := CEIL + 1;
END_IF;
END_FUNCTION
EXP10
Вычисляет с основанием степени 10.
Стр. 82 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
exp10(2) = 100
exp10(3) = 1000
FUNCTION EXP10 : REAL
VAR_INPUT
X : REAL;
END_VAR
EXP10 := EXP(X * 2.30258509299405);
END_FUNCTION
EXPN
Возведение в степень X^N . Хотя в стандарте есть оператор EXPT , по заявлению OSCAT,
данный алгоритм работает в 30 раз быстрее в CoDeSys.
FUNCTION EXPN : REAL
VAR_INPUT
X : REAL;
N : INT;
END_VAR
VAR
sign: BOOL;
END_VAR
sign := N.15;
N := ABS(N);
IF N.0 THEN EXPN := X; ELSE EXPN := 1.0; END_IF;
N := SHR(N, 1);
WHILE N > 0 DO
X := X * X;
IF N.0 THEN EXPN := EXPN * X; END_IF;
N := SHR(N, 1);
END_WHILE;
IF sign THEN EXPN := 1.0 / EXPN; END_IF;
END_FUNCTION
ROUND
Округляет число с плавающей точкой до указанного количества знаков после запятой.
round(12.23456789, 2) = 12.23
FUNCTION ROUND : REAL
VAR_INPUT
r: REAL; (* Число для обработки *)
p: USINT; (* Точность после запятой *)
END_VAR
ROUND := DWORD_TO_REAL(REAL_TO_DWORD(r * EXP10(p))) / EXP10(p)
Стр. 83 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_FUNCTION
CMP
Сравнивает 2 входных числа, если их первые цифры совпадают. Количество сравниваемых
цифр передается в параметре N .
cmp(3.141516, 3.141517, 6) вернет TRUE .
FUNCTION CMP : BOOL
VAR_INPUT
X, Y : REAL;
N : INT;
END_VAR
VAR
tmp : REAL;
END_VAR
tmp := ABS(x);
IF tmp > 0.0 THEN
tmp := EXP10(INT_TO_REAL(FLOOR(LOG(tmp))-N+1));
ELSE
tmp := EXP10(tmp);
END_IF;
CMP := ABS(X - Y) < tmp;
END_FUNCTION
MODR
Вычисляет остаток от деления для числа с плавающей запятой.
modr(5.5, 2.5) = 0.5
FUNCTION MODR : REAL
VAR_INPUT
IN : REAL;
DIVI : REAL;
END_VAR
IF divi = 0.0 THEN
MODR := 0.0;
ELSE
MODR := in - DINT_TO_REAL(FLOOR(in / divi)) * divi;
END_IF;
END_FUNCTION
Стр. 84 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
DEG
Конвертирует радианы в градусы. Эта функция нужна для работы со временем и
определения времени восхода и заката солнца.
FUNCTION DEG : REAL
VAR_INPUT
rad : REAL;
END_VAR
DEG := MODR(57.29577951308232 * RAD, 360.0);
END_FUNCTION
D_TRUNC
Усекает число с плавающей запятой в целое число DINT . 1.5 станет 1, а -1.5 станет -1.
Данная функция необходима, потому что REAL_TO_DINT на разных системах может вернуть
разный результат.
FUNCTION D_TRUNC : DINT
VAR_INPUT
X : REAL;
END_VAR
D_TRUNC := REAL_TO_DINT(X);
IF X > 0.0 THEN
IF DINT_TO_REAL(D_TRUNC) > X THEN
D_TRUNC := D_TRUNC - 1;
END_IF;
ELSE
IF DINT_TO_REAL(D_TRUNC) < X THEN
D_TRUNC := D_TRUNC + 1;
END_IF;
END_IF;
END_FUNCTION
FRACT
Эта функция возвращает дробную часть числа с плавающей точкой.
fract(3.14) = 0.14
FUNCTION FRACT : REAL
VAR_INPUT
x : REAL;
END_VAR
IF ABS(x) < 2.0E9 THEN
FRACT := x - DINT_TO_REAL(D_TRUNC(x));
ELSE
FRACT := 0.0;
END_IF;
Стр. 85 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_FUNCTION
RDM
Вычисляет псевдо-случайное число. Чтобы сгенерировать число, функция считывает
таймер ПЛК и создает число с плавающей точкой от 0 до 1. Чтобы использовать эту
функцию больше, чем один раз, в одном цикле ПЛК, нужно ее вызывать с разным
значением входного параметра last . В него проще внести прошлое число, чтобы
исключить повторения.
FUNCTION RDM : REAL
VAR_INPUT
last : REAL;
END_VAR
VAR
tn : DWORD;
tc : INT;
END_VAR
tn := TIME_TO_DWORD(TIME());
tc := BIT_COUNT(tn);
tn.31 := tn.2;
tn.30 := tn.5;
tn.29 := tn.4;
tn.28 := tn.1;
tn.27 := tn.0;
tn.26 := tn.7;
tn.25 := tn.6;
tn.24 := tn.3;
tn := ROL(tn, BIT_COUNT(tn)) OR 16#80000001;
tn := tn MOD 71474513 + INT_TO_DWORD(tc + 77);
RDM := FRACT(DWORD_TO_REAL(tn) / 10000000.0 *
(math.E - LIMIT(0.0, last, 1.0))
);
END_FUNCTION
В приведенном выше примере math.E = 2.71828182845904523536028747135266249 константа Эйлера.
Генерирование случайных чисел является достаточно частой задачей. Так как на выходе у
нас 0-1, то чтобы получить число в необходимом нам диапазоне, нужно сделать линейное
масштабирование. Например, чтобы получить случайное число от 1 до 100.
rMyRandom := SCALE_R(RDM(0.5), 0, 1, 1, 100);
Пример реализации функции SCALE_R приведен в книге рецептов, в разделе управления
сигналами ПЛК.
Стр. 86 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Некоторые общие принципы
Есть некие общие принципы, которые объединяют поведение переменных типа ANY_NUM
( ANY_INT и ANY_REAL ). А также и битовых ANY_BIT .
Результирующие типы
Важно!
Данное правило не специфицировано в стандарте МЭК 61131-3 и
поэтому может быть неприменимо в некоторых IDE некоторых
производителей.
В большинстве случаев данное правило исполняется одинаково всеми производителями,
так как это обычная практика в программировании. Иными словами, в других языках это
работает именно так, и именно этого поведения разработчик интуитивно ожидает от
интерпретатора кода.
Выражение с операндами, не имеющими конкретного типа данных, обрабатываются в
соответствии с типом, присвоенным переменной, принимающей результат.
VAR
diA, diB, diC, : DINT;
siA : SINT := 127;
END_VAR
diA := 127 + 127;
(* Сложение исполняется как DINT потому что
не вовлечено дополнительных типов
а diA имеет тип DINT. *)
diB := 127 + SINT#127; (* SINT#127 автоматически преобразуется в DINT.
Далее, как в первом примере. *)
diC := 127 + siA;
(* siA преобразуется в DINT. Далее, как в
первом примере. *)
Как видно из комментариев в примере, когда реализация ST сделана правильно, не
потребуется предварительного преобразования типов, если делается вычисление в
пределах одной группы типа переменных, в данном случае ANY_INT . Хотя в примере одно
из слагаемых может хранить сумму меньшую, чем возможный результат сложения.
Перегрузка операнда
Перегрузка операнда - это возможность завершить операцию при использовании
операндов разного типа внутри одной группы переменных, например ANY_INT .
Стр. 87 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Подобная возможность внедряется не всеми и работает по-разному. Здесь мы просто
рассматриваем понятие, но как это будет действовать, зависит от среды разработки.
Например:
VAR
a : REAL;
b : INT := 9;
c : USINT := 255;
d : USINT := 255;
f : INT;
END_VAR
f := c + d;
a := INT_TO_REAL(b) / 7;
В первом вычислении f := c + d; мы складываем 2 переменные типа USINT с
максимальным значением, равным 255. По идее, их сумма выходит за рамки USINT , но,
тем не менее, без необходимости предварительно преобразовать их в INT это объявление
должно работать корректно. Другими словами, результат сложения будет перегружен
большим значением, чем сами операнды могли бы хранить.
Второй пример: мы выполняем деление и пытаемся его сохранить как число с плавающей
запятой REAL , а это группа ANY_REAL . Здесь нам потребуется преобразование переменной
b , так как она относится к другой группе переменных типа ANY_NUM и перегрузить
операнд не получится.
Strings: (строковые переменные)
Тип данных МЭК
Формат
Значение
STRING
Символьная строка
'Моя строка'
WSTRING
Символьная строка двухбайтная "Моя строка"
CHAR
Однобайтный символ
'A'
WCHAR
Двухбайтный символ
"A"
Примеры объявления строковых переменных.
VAR
sMessage1 : STRING := 'Motor Stopped!';
sMessage2 : WSTRING := "Двигатель запущен!";
END_VAR
Стр. 88 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Кавычки имеют значение
Одиночные кавычки, как правило, определяют строку нуля или более однобайтных
символов символьной таблицей ISO/IEC 10646-1. В однобайтных строках комбинация из 3
символов - доллара $ и последующих за ним 2-х шестнадцатеричных символов, например
'$AE' , определяют код 8-битного символа. Могут использоваться любые HEX символы.
Двойные кавычки, как правило, определяют строку нуля или более двухбайтных символов
символьной таблицей ISO/IEC 10646-1. В двубайтных строках комбинация из 5 символов доллара $ и последующих за ним четырех шестнадцатеричных символов "$00С4" ,
определяют код 16-битного символа.
Символьно-строковые литералы
№ Описание
Пример
Однобайтовые символы и строки символов с ' '
1а Пустая строка (длины ноль)
''
1b Строка длины 1 или символ CHAR, содержащий единственный символ
'A'
1с Строка длины один или символ CHAR, содержащий символ пробела
' '
1d
1е
Строка длины один или символ CHAR, содержащий символ одиночной
'$''
кавычки
Строка длины один или символ CHAR, содержащий символ двойной
'"'
кавычки
1f Поддержка двухсимвольных комбинаций таблицы ниже
'$R$L'
1h Строка длины пять символов $1.00
'$
$1.00'
1g
Поддержка представления символа со знаком доллара '$' и двумя
'$0А'
шестнадцатеричными цифрами
Двухбайтовые символы или символьные строки с " "
2а Пустая строка (длины ноль)
2Ь
""
Строка длины один или символ WCHAR, содержащий единственный
"А"
символ
2с Строка длины один или символ WCHAR, содержащий символ пробела
2d
2е
" "
Строка длины один или символ WCHAR, содержащий символ одиночной
"'"
кавычки
Строка длины один или символ WCHAR, содержащий символ двойной
"$""
кавычки
2f Поддержка двухсимвольных комбинаций таблицы ниже
Стр. 89 из 298
"$R$L"
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
№ Описание
Пример
2h Строка длины пять символов $1.00
"$
$1.00"
2g
Поддержка представления символа с знаком доллара '$’ и четырьмя
"$00С4"
шестнадцатеричными цифрами
Двухсимвольные комбинации в символьных строках
№ Описание
Комбинация
1
Знак доллара
$$
2
Единичная кавычка
$'
3
Двойная кавычка
$"
4
Перевод строки
$L или $l
5
Новая строка
$N или $n
6
Прогон (перевод) страницы $Р или $p
7
Возврат каретки
$R или $r
8
Табуляция
$Т или $t
• Комбинация $' действительна только внутри строковых литералов с одиночными
кавычками.
• Комбинация $" действительна только внутри строковых литералов с двойными
кавычками.
Стандартные POU
Стандарт предусматривает, а значит, в любой IDE вы можете найти следующие функции
для работы со строками.
Во всех примерах указывать имена входных переменных не обязательно. Здесь они
указываются для лучшего понимания назначения входного параметра. Например:
LEFT(IN:='ASTR', L:=3) можно использовать просто: LEFT('ASTR', 3) .
• LEN - функция, которая возвращает длину строки или, другими словами, количество
символов в строке. Например:
A := LEN('TEST'); (* A = 4 *)
• LEFT - функция возвращает набор символов, указанных в параметре L c левой
части строки. Например:
Стр. 90 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
A := LEFT(IN:='ASTR', L:=3);
Автор: Сергей Романов
(* A = 'AST' *)
• RIGHT - функция возвращает набор символов, указанных в параметре L c правой
части строки. Например:
A := RIGHT(IN:='ASTR', L:=3);
(* A = 'STR' *)
• MID - функция возвращает набор символов, указанный в параметре L (Length),
начиная с символа параметра P (Position). Например:
A := MID(IN:='ASTR', L:=2, P:=2);
(* A = 'ST' *)
• CONCAT - функция конкатенации, возвращает строку, объединенную из нескольких
строк. Например:
A := CONCAT('AB', 'CD', 'E');
(* A = 'ABCDE' *)
Не во всех IDE можно ввести неопределенное количество параметров в подобную
функцию. Например, в CoDeSys можно ввести только 2 параметра. Чтобы получить
тоже самое в CoDeSys, нужно будет написать:
A := CONCAT('AB', 'CD');
A := CONCAT(A, 'E');
Пример использования конкатенации вы увидите при описании типа переменных
время.
• INSERT - функция вставляет строку параметра IN2 в строку параметра IN1 после
символа по счету параметра P . Например:
A := INSERT(IN1 := 'ABC', IN2 := 'XY', P := 2);
(* A : 'ABXYC' *)
• DELETE - удалить количество символов параметра L в строке IN , начиная с
позиции P . Например:
A := DELETE(IN := 'ABXYC', L := 2, P := 3);
(* A = 'ABC' *)
• REPLACE - заменяет количество символов параметра L в строке IN1 на строку
IN2 , начиная с позиции P . Например:
A := REPLACE(IN1 := 'ABCDE', IN2 := 'X', L := 2, P := 3);
(* A = 'ABXE' *)
Стр. 91 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• FIND - функция возвращает число, номер позиции найденной строки параметра
IN2 в строке параметра IN1 . Если ничего не найдено, то функция вернет 0.
A := FIND(IN1:='ABCBC', IN2:='BC');
(* A = 2 *)
Согласитесь, это вполне расширенный набор функций для работы со строкой. Рассмотрим
несколько примеров применения.
Примеры работы с строковыми функциями
Перевод регистра TO_UPPER
Изменить регистр символа с маленькой буквы на заглавную. Работает только с латинскими
буквами формата ASCII.
FUNCTION TO_UPPER : BYTE
VAR_INPUT
IN : BYTE;
END_VAR
IF in > 96 AND in < 123 THEN
TO_UPPER := in AND 16#DF;
ELSIF in > 223 AND in <> 247 AND
in <> 255 AND setup.EXTENDED_ASCII THEN
TO_UPPER := in AND 16#DF;
ELSE
TO_UPPER := in;
END_IF;
END_FUNCTION
setup.EXTENDED_ASCII может быть или TRUE или FALSE , в зависимости от символьной
таблицы, которая поддерживается ПЛК. Обычно стоит в TRUE . Оперируем числами, так
как каждое число соответствует символу из таблицы ASCII. Заглавные символы удалены
от простого символа на равное расстояние, и простой битовый оператор AND 16#DF делает
свое дело.
Проход циклом по строке
Посмотрим, как можно пройтись по строке символ за символом в цикле. Это очень хорошо
видно на примере функции COUNT_CHAR , которая считает, как много указанных символов
содержится в данной строке.
FUNCTION COUNT_CHAR : INT
VAR_INPUT
str : STRING[255]; (* Строка *)
chr : BYTE;
(* Символ для подсчета *)
END_VAR
Стр. 92 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR
pt : POINTER TO BYTE;
l: INT;
pos: INT;
END_VAR
(* Указатель на символ *)
(* Длина строки *)
(* Текущая позиция строки для прохода *)
pt := ADR(str);
l := LEN(str);
COUNT_CHAR := 0;
FOR pos := 1 TO l DO
IF pt^ = CHR THEN
COUNT_CHAR := COUNT_CHAR + 1;
END_IF;
pt := pt + 1;
END_FOR;
END_FUNCTION
Для прохождения в цикле используем указатель. Стоит пропустить этот пример, так как
тема указателей будет рассмотрена позже. Изучив ее и вернувшись сюда снова, вы все
поймете без комментариев.
Подобная техника используется в стандартных функциях. Она поможет создать целый
набор функций, проверяющих строковое значение на формат данных. Например, чтобы
проверить, является ли строка числом для функции IS_NUM . Все остальное - как и в
примере выше.
FOR pos := 1 TO L DO
IF NOT (pt^ > 47 AND pt^ < 58) THEN
IS_NUM := FALSE;
RETURN;
END_IF;
PT := PT + 1;
END_FOR;
IS_NUM := TRUE;
Известно, что в таблице ASCII символы чисел хранятся в ячейках с 48 до 57, так что
возможно проверить, все ли символы строки находятся в этом пределе. Это можно сделать
перед преобразованием в числовой формат, например. Если преобразовывать в число с
плавающей запятой, то можно сюда добавить проверку на наличие символа . - точки. Из
таблицы внизу понятно, что нужно только добавить OR pt^ = 46 в условие, и мы
проверим на число с плавающей запятой.
Вот символьная таблица
Число Символ
Число Символ Число Символ Число Символ
0
32
NUL (null)
Стр. 93 из 298
SPACE 64
@
96
`
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Число Символ
Число Символ Число Символ Число Символ
1
SOH (start of heading)
33
!
65
A
97
a
2
STX (start of text)
34
"
66
B
98
b
3
ETX (end of text)
35
#
67
C
99
c
4
EOT (end of transmission)
36
$
68
D
100
d
5
ENQ (enquiry)
37
%
69
E
101
e
6
ACK (acknowledge)
38
&
70
F
102
f
7
BEL (bell)
39
'
71
G
103
g
8
BS (backspace)
40
(
72
H
104
h
9
TAB (horizontal tab)
41
)
73
I
105
i
10
LF (NL line feed, new line)
42
*
74
J
106
j
11
VT (vertical tab)
43
+
75
K
107
k
12
FF (NP form feed, new page) 44
,
76
L
108
l
13
CR (carriage return)
45
-
77
M
109
m
14
SO (shift out)
46
.
78
N
110
n
15
SI (shift in)
47
/
79
O
111
o
16
DLE (data link escape)
48
0
80
P
112
p
17
DC1 (device control 1)
49
1
81
Q
113
q
18
DC2 (device control 2)
50
2
82
R
114
r
19
DC3 (device control 3)
51
3
83
S
115
s
20
DC4 (device control 4)
52
4
84
T
116
t
21
NAK (negative acknowledge) 53
5
85
U
117
u
22
SYN (synchronous idle)
54
6
86
V
118
v
23
ETB (end of trans. block)
55
7
87
W
119
w
24
CAN (cancel)
56
8
88
X
120
x
25
EM (end of medium)
57
9
89
Y
121
y
26
SUB (substitute)
58
:
90
Z
122
z
27
ESC (escape)
59
;
91
[
123
{
28
FS (file separator)
60
<
92
\
124
|
29
GS (group separator)
61
=
93
]
125
}
30
RS (record separator)
62
>
94
^
126
~
31
US (unit separator)
63
?
95
_
127
DEL
Стр. 94 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Получение текущего часа из даты и времени
Предположим, есть параметр DT, который хранит текущую дату и время, и нам требуется
получить текущий час. Это, конечно, можно сделать математическим путем, а можно и
через строку. Использование строки в данном случае будет понятнее и интуитивнее.
Нивелировать ошибку в таком решении гораздо проще, чем в математическом.
(* получим строку 'DT#2000-01-01-23:30:59' *)
sData := DT_TO_STRING(dtCurrent);
(* получим 2 символа с 15го = строка '23' *)
sHour := MID(sData, 2, 15);
(* преобразуем в число *)
iHour := STRING_TO_INT(sHour);
Обратите внимание, что указывать имена входных переменных для функции обработки
строки типа MID(IN := sData, L := 2, P := 15) не обязательно. В описании функций
это сделано, чтобы ссылаться на них в объяснениях.
На практике подобная задача решалась бы отдельной функцией и в одной строке.
FUNCTION DT_TO_HOUR: INT
VAR_INPUT
CDT: DT;
END_VAR
DT_TO_HOUR := STRING_TO_INT(MID(DT_TO_STRING(cdt), 2, 15));
END_FUNCTION
Таким способом можно получить любой элемент времени. Подробнее об этом - в разделе
описания типа данных время.
Удаление символа из строки
Допустим, нужно удалить все табуляции или пробелы в строке. Любой символ или
сочетание символов.
FUNCTION STR_CLEAR : STRING
VAR_INPUT
Str: STRING; (* Входная строка *)
Find: STRING; (* Что удалить *)
END_VAR
VAR
sTemp: STRING; (* Для временной строки *)
iPos: INT; (* Позиция найденного символа *)
END_VAR
sTemp := Str;
REPEAT
Стр. 95 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
iPos := FIND(sTemp, Find);
IF iPos <> 0 THEN
sTemp := DELETE(sTemp, LEN(Find), iPos);
END_IF;
UNTIL (iPos = 0)
END_REPEAT;
STR_CLEAR := sTemp;
END_FUNCTION
Используем цикл REPEAT . Это хороший пример разницы циклов REPEAT и WHILE , так как
нужно, чтобы наш код проверки наличия символа исполнился хотя бы один раз.
Смотрим, есть ли в строке искомый паттерн iPos := FIND(sTemp, Find) . Если да, то
iPos будет больше 0 , тогда удаляем найденный паттерн sTemp := DELETE(sTemp,
LEN(Find), iPos) и, если iPos не был равен 0 , начинаем еще одну итерацию цикла, где
снова ищем в строке, из которой уже удалено одно вхождение искомого паттерна.
Получение значения из строки JSON
В современных реалиях распространения IIoT все чаще используется протокол MQTT, он
очень часто в качестве значения (payload) передает строку JSON. Например, датчик может
передать температуру и влажность в виде строки
{
"temperature": 34.5,
"humidity": 40
}
Напишем функцию, которая будет анализировать эту строку и выделять 2 параметра в
отдельные переменные.
FUNCTION JSON_TO_TEMP : BOOL
VAR_INPUT
Msg: STRING; (* Строка с сообщением *)
END_VAR
VAR_OUTPUT
Temperature: REAL; (* Температура *)
Humidity: BYTE; (* Влажность от 0 до 100 *)
END_VAR
VAR
sTmp: STRING; (* Для временной строки *)
sTemp: STRING; (* Для временной строки температуры *)
sHum: STRING; (* Для временной строки влажности *)
iPos: INT;
(* Позиция найденного символа *)
END_VAR
Стр. 96 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
sTmp := Msg;
sTmp := STR_CLEAR(sTmp, ' '); (* Удалим все пробелы *)
sTmp := STR_CLEAR(sTmp, '$n'); (* Удалим все символы новой строки *)
sTmp := STR_CLEAR(sTmp, '$r'); (* Удалим все символы возвраты каретки *)
sTmp := STR_CLEAR(sTmp, '$t'); (* Удалим все табуляции *)
sTmp := STR_CLEAR(sTmp, '"'); (* Удалим все двойные кавычки *)
sTmp := STR_CLEAR(sTmp, '{'); (* Удалим все фигурные скобки *)
sTmp := STR_CLEAR(sTmp, '}'); (* Удалим все фигурные скобки *)
(* теперь sTmp := 'temperature:34.5,humidity:40' *)
iPos := FIND(sTmp, ', ');
sTemp := LEFT(sTmp, iPos - 1);
(* sTemp = 'temperature:34.5' *)
sHum := RIGHT(sTmp, LEN(sTmp) - iPos); (* sHum = 'humidity:40' *)
iPos := FIND(sTemp, ':');
sTemp := RIGHT(sTemp, LEN(sTemp) - iPos); (* sTemp := '34.5' *)
iPos
:= FIND(sHum, ':');
sHum := RIGHT(sHum, LEN(sHum) - iPos);
(* sHum := '40' *)
Temperature := STRING_TO_REAL(sTemp);
Humidity := STRING_TO_BYTE(sHum);
END_FUNCTION
Здесь применены все основные принципы анализа строки. В начале использована функция
STR_CLEAR , которая была создана ранее для того, чтобы очистить строку от ненужных
символов и убедиться, что формат будет одинаковым. Ведь в JSON могут быть пробелы,
отступы, лишние символы или написание одной строкой. Чтобы не рисковать, лучше
просто удалить все ненужные символы.
Затем находим позицию iPos := FIND(sTmp, ', ') , и это позволит разделить JSON на 2
части. Получаем правую и левую части от запятой. Это демонстрирует, как работают
стандартные функции. В практической работе лучше, я считаю, применить функцию,
которая разделяет строку по запятой на массив данных, а затем пройтись по массиву и
сохранить все значения. Подобную функцию рассмотрим позже, когда будем изучать
массивы.
Далее, используя ту же технику, получаем правую часть от : и в той, и в другой строке.
В конце просто преобразуем полученные значения в нужный формат.
Почему мы просто не взяли данные, как это было в примере Получение текущего часа из
даты и времени? Потому что строка даты и времени постоянная в плане формата или
количества символов, а JSON имеет переменный формат. Например, температура может
Стр. 97 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
быть 35.6 , а может быть и 24.34 или 123.45 . Как видим, количество символов растет,
поэтому получение участка строки с константой позиции будет невозможным.
Bit strings: (битовые строки)
Тип данных МЭК Формат
Значение
BOOL
Boolean
1 bit
BYTE
Byte
8 bits
WORD
Word
16 bits
DWORD
Double Word 32 bits
LWORD
Long Word
64 bits
Пример определения битовых переменных:
VAR
xStartMotor1 : BOOL := FALSE;
xMotor1ErrorState : BOOL := FALSE;
bColor : BYTE := 250;
END_VAR
В битовых переменных всегда хранится число (если мы рассматриваем переменную как
десятичное значение). Разница между числовым и битовым хранением в том, что можно
иметь доступ к данным побитно и применять разные битовые операторы.
Допустим, есть переменная bColor из примера выше, вы хотите назначить ей значение 3.
Эта переменная типа байт ( BYTE ), а значит 8 бит. Или 0000 0000 , где каждая цифра
соответствует одному биту. Если десятичное значение 3 перевести в битовое, получится
0000 0011 . Значит, назначить можно тремя способами.
Color := 3;
или
Color := 2#0000_0011;
или
Color.0 := 1; (* бит 1 *)
Color.1 := 1; (* бит 2 *)
Color.2 := 0; (* бит 3 *)
Color.3 := 0; (* бит 4 *)
Color.4 := 0; (* бит 5 *)
Color.5 := 0; (* бит 6 *)
Color.6 := 0; (* бит 7 *)
Стр. 98 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Color.7 := 0; (* бит 8 *)
Можно как назначать индивидуальные биты, так и читать индивидуальные биты.
Такой способ очень полезен при работе с другими устройствами по сети RS-485 или
RS-232. Обычно в таких устройствах имеются регистры, в которых можно побитно
отправлять и считывать данные.
Такой способ удобен также для того, чтобы в одной переменной хранить состояние
нескольких булевых переменных.
Объединения и разделения
Частой задачей является объединение 2-х BYTE в WORD или 2-х WORD в DWORD , или
наоборот - разделение WORD на 2 BYTE .
Принцип объединения
Для объединения используется оператор OR . Предположим, нужно объединить 2 байта в
один WORD .
VAR
w1, w2, wWord: WORD;
b1: BYTE := 2#0100_1000;
b2: BYTE := 2#1001_0100;
END_VAR
w1 := BYTE_TO_WORD(b1);
w2 := BYTE_TO_WORD(b2);
wWord := SHL(w1, 8) OR w2;
Сначала
2#0000_0000_0100_1000; (* Байт b1 преобразованный в WORD *)
2#0000_0000_1001_0100; (* Байт b2 преобразованный в WORD *)
В начало добавляется один пустой байт 0000_0000 . Теперь мы применяем оператор OR ,
который, когда применяется к байтовым переменным, сравнивает значения побитно.
Предварительно мы сдвинули первый байт влево на 8 и получили такое сравнение:
2#0100_1000_0000_0000; (* Байт b1 со смещением в лево на 8 бит *)
2#0000_0000_1001_0100; (* Байт b2 *)
2#0100_1000_1001_0100; (* Результат сравнения *)
Стр. 99 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
На выходе получим переменную WORD , сложенную из 2-х байт.
Принцип разъединения
Теперь сделаем обратное действие. У нас есть WORD , и мы хотим получить 2 байта.
VAR
wWord: WORD := 2#1000_1000_0101_0101;
b1, b2: BYTE;
END_VAR
b1 := WORD_TO_BYTE(wWord);
b2 := WORD_TO_BYTE(SHR(wWord, 8));
При преобразовании WORD_TO_BYTE берем только старший байт, то есть из
2#1000_1000_0101_0101 мы возьмем только 2#0101_0101 . Второй раз сначала сдвигаем на
8 бит вправо и получаем 2#0000_0000_1000_1000 , а затем из него уже 2#1000_1000 .
Полезные POU
К этой категории переменных можно отнести стандартные операторы SHL , SHR , ROR ,
ROL , описанные в разделе об операторах.
Рассмотрим другие полезные функции, которые нам могут пригодиться.
BIT_COUNT
Считает количество бит, равных TRUE в DWORD .
Пример: bit_count(3) = 2
Потому что 2 бита равны 1, а остальные равны 0.
FUNCTION BIT_COUNT : INT
VAR_INPUT
IN : DWORD;
END_VAR
WHILE in > 0 DO
IF in.0 THEN
Bit_Count := Bit_Count + 1;
END_IF;
in := SHR(in, 1);
END_WHILE;
END_FUNCTION
Стр. 100 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
BIT_LOAD_B
Загружает значение одного бита в байт.
bit_load_b(2#0000_0000, 1, 2) = 2#0000_0010
FUNCTION BIT_LOAD_B : BYTE
VAR_INPUT
IN : BYTE; (* Байт, в котором нужно произвести операцию *)
VAL : BOOL; (* Присваиваемое значение *)
POS : INT; (* Какому по номеру биту *)
END_VAR
VAR CONSTANT
dat : BYTE := 1;
END_VAR
IF VAL THEN
BIT_LOAD_B := in OR SHL(dat, pos);
ELSE
BIT_LOAD_B := in AND (NOT SHL(dat, pos));
END_IF;
END_FUNCTION
Конечно, мы могли бы назначить бит для байта через точку:
bMyByte.1 := 1;
Дело в том, что на практике такой способ будет работать не везде, хотя согласно стандарту
- индексный доступ к битам должен быть обеспечен. Данная функция является хорошим
примером принципа объединения и разделения.
BIT_OF_DWORD
Вычисляем значение единичного бита в переменной типа DWORD .
FUNCTION BIT_OF_DWORD : BOOL
VAR_INPUT
in: DWORD;
N: INT; (* номер бита *)
END_VAR
BIT_OF_DWORD := (SHR(in, N) AND 16#00000001) > 0;
END_FUNCTION
Посмотрите на этот гениальный хак. Мы создаем DWORD , где последний бит равен 1, затем
двигаем под него бит, который нужно определить, и сравниваем оператором AND .
BIT_TOGGLE_DW
Инвертируем указанный бит в DWORD
Стр. 101 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
FUNCTION BIT_TOGGLE_DW : DWORD
VAR_INPUT
IN : DWORD;
POS : INT;
END_VAR
BIT_TOGGLE_DW := SHL(DWORD#1, POS) XOR IN;
END_FUNCTION
Этот пример так же является прекрасной иллюстрацией оператора XOR .
SWAP_BYTE
Поменяем байт местами в WORD и DWORD .
FUNCTION SWAP_BYTE : WORD
VAR_INPUT
IN : WORD;
END_VAR
SWAP_BYTE := ROL(in, 8);
END_FUNCTION
FUNCTION SWAP_BYTE2 : DWORD
VAR_INPUT
IN : DWORD;
END_VAR
Swap_Byte2 := (ROR(in, 8) AND 16#FF00FF00) OR
(ROL(in, 8) AND 16#00FF00FF);
END_FUNCTION
Time: (время)
Время является одним из трех измерений, в которых мы существуем, поэтому работать со
временем приходится очень часто. Ведь время - сама сущность бытия. Задач,
использующих время, не просто много - не будет преувеличением сказать, что все до
единой задачи в ПЛК решаются с использованием времени.
Одни задачи используют событийную структуру - от одного условия к другому, не
применяя переменных типа "время", не делая их измерений или вычислений, или
использования таймеров. Например:
VAR
a, b: BOOL;
END_VAR
IF a THEN
b := TRUE;
ELSE
Стр. 102 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
b := FALSE;
END_IF
Рассуждая философски, можно представить, что в какой-то момент времени b включится,
а в какой-то момент - выключится. Знаю, это похоже на спекуляцию, но я хотел
акцентировать внимание на том, насколько время само по себе фундаментально, и что мы,
люди, не можем решить ни одной задачи вне времени.
Другие задачи напрямую связаны с измерением, вычислением и иными манипуляциями со
временем. Интересно размышлять о том, когда люди научились измерять время, как они
поделили год на 365 дней, при этом раз в 4 года установили високосный год, в сутках
определили 24 часа, часы разделили на минуты, минуты - на секунды и т.д. В
библиографии есть ссылка на статью "Временные сложности", где можно почерпнуть
много интересных мыслей на эту тему. Советую ее прочесть.
Моя же задача - привести примеры и объяснить основные принципы работы со временем таким, каким мы его знаем сегодня.
Типы времени
Для начала рассмотрим типы данных, определяющих время, установленные стандартом
МЭК 61131-3. Их всего 4 типа.
TIME
Интервал времени. Можно использовать префикс T для определения значения константой.
Состоит из полей дней (d), часов (h), минут (m), секунд (s) и миллисекунд (ms). Порядок
полей должен быть таким, в каком они перечислены. Менять его нельзя, но можно
пропускать ненужные элементы.
VAR
t1, t2, t3 : TIME;
ton1: TON;
END_VAR
t1 := T#1h2m20s100ms;
t2 := T#20m100ms;
t2 := T#1h_20s;
ton1(IN := TRUE, PT := T#300s);
Значения могут превышать границы естественного диапазона, так как это не время дня, а
просто интервал времени. Например, можно указать минут больше, чем 60.
T#120m (* Это 2 часа *)
Стр. 103 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Но это не относится к младшим полям или следующим, а только к первому полю. Так,
например, следующее будет ошибкой. Если есть поле часа, то младшее поле минут не
может превышать естественного номера 60.
T#1h120m (* Ошибка *)
Для улучшения восприятия или читабельности кода можно использовать символ _ для
разделения полей. Как и в числовых типах, этот символ не учитывается, а используется
для визуального разделения.
T#5h_30m_40s;
TIME_OF_DAY
Время дня. Можно использовать префикс TOD для определения значения константой. TOD
не надо путать с T - они принципиально разные. Время в TOD ограничено диапазоном от 0
до 23:59:59.999, а в T оно не ограничено. В TOD мы не можем превышать допустимые
пределы каждого поля. Если это секунды, то значение может быть только от 0 до 59. Если
часы, то от 0 до 23.
TOD хранит точку во времени, а T хранит промежуток времени или время работы какого-
то процесса.
TOD состоит из полей: час (HH), минуты (MM), секунды(SS), дробная часть секунд -
миллисекунды (sss) Эти поля разделяются знаком : . Одни должны идти в строгом порядке
как перечислены. Первыми будут всегда часы и т.д.
VAR
tod1, tod2, tod3 : TIME_OF_DAY;
END_VAR
tod1 := TOD#15;
(* 15:00:00 *)
tod2 := TOD#15:30;
(* 15:30:00 *)
tod3 := TOD#16:25:45;
(* 16:25:45 *)
tod3 := TOD#16:25:45.123; (* 16:25:45.123 *)
DATE
Дата или календарное число. Можно определить при помощи символа D . Имеет структуру
ГГГГ-ММ-ДД. Все числа имеют естественный диапазон.
VAR
d1 : DATE;
END_VAR
d1 := D#2017-09-14; (* 14 Сентября 2017 *)
Стр. 104 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
DATE_AND_TIME
Дата или календарное число с временем. Можно определить при помощи символа DT . Это
похоже на совмещение типа DATE и TIME_OF_DAY . Его формат может быть непривычен для
нас, но он определен и принят во многих странах международным стандартом ISO 8601.
VAR
dt1 : DATE_AND_TIME;
END_VAR
(* 11 Декабря 2017 в 19:25 когда я писал эту статью. *)
dt1 := DT#2017-12-11-19:25:00;
Внутреннее представление
К сожалению, стандартом МЭК 61131-3 не предусмотрено, каким образом такие типы
данных хранятся, и каждый производитель осуществляет их хранение по-своему.
Есть 2 основных способа того, как можно было бы хранить данные.
1. Структура - данные хранятся в виде переменной структуры, в которой отдельно
хранится год, отдельно - месяц и т.д. Такой способ удобен для конечного
разработчика, но неудобен в вычислениях.
2. Числовой - данные хранятся в виде числа, что является более распространенным
способом.
Заметка
Так как я предполагаю, что большинство людей, читающих эту книгу,
будут пользоваться средой CoDeSys, оттолкнёмся от того, что все
переменные типа "дата" и "время" хранятся в регистре памяти типа
DWORD .
Как нам представить данные в виде числа? Для этого нужно от чего-то оттолкнуться.
Например, наш современный календарь тоже числовой. Год 2019 имеет свое начало. Чтобы
определить точку во времени, где мы сейчас находимся, нужна исходная или нулевая
точка. В нашем календаре это Рождество Христово. Считается что дата 0000-00-00 это
день рождения Христа, поэтому сейчас не просто 2019 год, а 2019 год от Рождества
Христова.
В языках программирования общепринято брать за дату отсчета 0 часов 1 января 1970
года.
Стр. 105 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Данные типа DATE_AND_TIME и DATE хранятся в виде количества секунд с этой даты.
Длительность TIME и TIME_OF_DAY имеют дискретность в 1 мс. Максимальная
длительность, которую могут хранить переменные типа TIME , составляет примерно 1193
часа или почти 50 суток. Именно столько миллисекунд можно сохранить в 2-х байтах
переменной размера DWORD .
Получить время
Посмотрим, как можно получить текущую дату и время. Рассмотрим 3 разных способа.
RTC
RTC - (Real Time Clock) или часы реального времени. Это стандартный ФБ стандарта МЭК
61131-3. Этот ФБ в стандарте не рекомендован к исполнению, а служит только примером.
Это связано с некоторыми сложностями, которые мы рассмотрим ниже.
VAR
dtCurrent: DT;
fbRTC: RTC;
END_VAR;
fbRTC(EN := TRUE, PDT := DT#2000-12-12-12:00:00, CDT => dtCurrent);
Здесь переменной dtCurrent будет определено значение текущего времени в формате
DATE_AND_TIME . Важно понимать, что как только на EN появится сигнал, переменной CDT
будет присвоено то, что было передано на PDT , и только потом значение начнет отсчет.
Другими словами, нужна какая-то система, которая будет хранить точку отсчета. Неудобно
постоянно инициализировать этот блок при каждом включении.
Недостаток этого блока в том, что если сделать несколько экземпляров блока, не связанных
между собой, то они могут считать по-разному и иметь отклонения относительно друг
друга.
Решение лежит в реализации аппаратных RTC, что и сделано на многих ПЛК. При
наличии аппаратного RTC, ФБ может инициализироваться от его значений, если PDT не
указан. Аппаратные часы RTC хороши тем, что сохраняют ход во время отключения ПЛК в
небольшой статической памяти. Но аппаратные часы нужно обязательно настроить при
первом включении.
SysLibTime
Другой способ: в CoDeSys есть специальная библиотека SysLibTime .
Стр. 106 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR
stTime: SysTime64;
stTimeExt: SystemTimeDate;
fbCurTime: CurTimeEx;
END_VAR
stTimeExt.dwHighMsec
stTimeExt.dwLowMSecs
stTimeExt.Year
stTimeExt.Month
stTimeExt.Day
stTimeExt.Hour
stTimeExt.Minute
stTimeExt.Second
stTimeExt.Milliseconds
stTimeExt.DayOfWeek
:= 0;
:= 0;
:= 0;
:= 0;
:= 0;
:= 0;
:= 0;
:= 0;
:= 0;
:= 0;
IF Save THEN
stTimeExt.Year
stTimeExt.Month
stTimeExt.Day
:= spTimeYear;
:= spTimeMonth;
:= spTimeDay;
stTimeExt.HOUR
stTimeExt.Minute
END_IF;
:= spTimeHour;
:= spTimeMinute;
fbCurTime(SystemTime := stTime, TimeDate := stTimeExt);
На выходе stTimeExt - это структура, которая содержит данные даты и времени в
отдельных переменных. В начале каждого цикла мы инициализируем переменную
stTimeExt и сбрасываем все в 0. Почему? Потому что если в этой переменной есть
значения, то fbCurTime может расценить их так, как будто вы назначаете новое время.
Сбросив все в 0, мы убеждаемся, что будем получать текущее время, а не устанавливать
его.
Мы видим здесь пример изменения значения аппаратных часов времени. Надо заметить
что в стандартном исполнении библиотеки, подобный функционал не предусматривается,
а возможен на ПЛК Овен, с доработанной ими библиотекой. Предположим, что в
переменных spTime*** хранятся раздельно часы, минуты, год, месяц и т.д., которые
нужно назначить, и если мы подадим сигнал на Save , то произойдет назначение времени.
Эта функция реализована на многих ПЛК, но у ней есть недостаток - отсутствие выходной
переменной типа DT , даты и времени. Делать дальнейшие вычисления, которые мы
рассмотрим далее, будет сложнее. Мы увидим, как из этих данных создать переменную
типа DT или любого другого типа времени.
Стр. 107 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
SysLibRtc
В CoDeSys есть специальная библиотека SysLibRtc . Ее использование самое простое и с
самым удобным результатом. Не все ПЛК, например, ПЛК ОВЕН, поддерживают эту
библиотеку.
VAR
dtCurrent: DT;
END_VAR
dtCurrent := SysRtcGetTime(TRUE);
Теперь dtCurrent содержит дату и время в формате DT , и мы можем использовать это в
математических вычислениях, приведенных ниже.
Стандартные преобразования типов времени
Смешивать в выражениях переменные разного типа нельзя. При необходимости
применяются операции преобразования типов.
Преобразования из одного типа времени в другой осуществляются при помощи функций
преобразования, которые, как правило, присутствуют в любой IDE.
VAR
dt1 : DATE_AND_TIME := DT#2017-11-08-19:25:00;
tod1: TIME_OF_DAY;
t1:
TIME;
d1:
DATE;
END_VAR
tod1 := DATE_AND_TIME_TO_TOD(dt1);
d1
:= DATE_AND_TIME_TO_DATE(dt1);
t1
:= TIME_OF_DAY_TO_TIME(tod1);
Общий принцип преобразования - это вызов функции по структуре [ИЗ ФОРМАТА]_TO_[В
ФОРМАТ] .
Преобразование в тип времени
Часто мы получаем время в разбитом формате - как в примере получения времени через
библиотеку SysLibTime . Она хранит месяц, год, час, минуты и т.д. по отдельности, в
разных переменных. Как их преобразовать в одну переменную времени DT ?
Есть 2 способа. Один - через математику, путем получения нужного числа секунд или
миллисекунд, второй - через строки.
Стр. 108 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Упаковка часов, минут, секунд и миллисекунд в TIME
Пример через математику.
FUNCTION HMS_TO_TIME : TIME
VAR_INPUT
h, m, sec, ms : UINT;
END_VAR
HMS_TO_TIME := DWORD_TO_TIME(((h * 60 + m) * 60 + sec) * 1000 + ms);
END_FUNCTION
PROGRAM
VAR
tMyTime: TIME;
END_VAR
(* tMyTime = T#10h20m *)
tMyTime := HMS_TO_TIME(10, 20, 0, 0);
END_PROGRAM
Все очень просто: переводим часы h в минуты и прибавляем m минуты. Общее число
минут переводим в секунды, умножением на 60 и прибавляем секунды sec , то же делаем
и для миллисекунд. Получаем количество миллисекунд и преобразуем в тип TIME .
Упаковка года, месяца, дня, часов, минут, секунд и миллисекунд в DT
FUNCTION YMDHMS_TO_DT : DT
VAR_INPUT
y, mn, d, h, m, sec, ms : UINT;
END_VAR
VAR
sTemp: STRING;
END_VAR
sTemp := CONCAT("DT#", UINT_TO_STRING(y));
sTemp := CONCAT(sTemp, "-");
sTemp := CONCAT(sTemp, UINT_TO_STRING(mn));
sTemp := CONCAT(sTemp, "-");
sTemp := CONCAT(sTemp, UINT_TO_STRING(d));
sTemp := CONCAT(sTemp, "-");
sTemp := CONCAT(sTemp, UINT_TO_STRING(h));
sTemp := CONCAT(sTemp, ":");
sTemp := CONCAT(sTemp, UINT_TO_STRING(m));
sTemp := CONCAT(sTemp, ":");
sTemp := CONCAT(sTemp, UINT_TO_STRING(s));
YMDHMS_TO_DT := STRING_TO_DT(sTemp);
END_FUNCTION
Стр. 109 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В примере сначала методом конкатенации создаем строку. В нашем случае получается
строка типа DT#2000-01-01-12:00:00 . Потом преобразуем эту строку в тип DT
стандартной функцией преобразования типов STRING_TO_DT .
Это пример получения даты и время через строки. Мы могли бы здесь так же использовать
математику, как в предыдущем примере, путем перемножения получить нужное число и
преобразовать его в DT , но тут мы используем другой подход.
Я называю такой способ строковая обработка времени. Мне такой подход кажется более
читабельным, он намного интуитивнее, натуральней и логичней, хотя конкретно в этом
примере занимает больше строк.
Единственный его недостаток в том, что он требует больше времени на обработку
процессором и увеличивает общее время одного цикла ПЛК. С другой стороны, если
операций с датами немного, то это не существенно. Хотя разница в скорости
математического способа и строкового примерно в 4 раза, речь идет о микросекундах.
Например, приведенный нами пример займет 20 микросекунд, а математический 5
микросекунд. Это все еще 50 раз за одну миллисекунду. Если делать тысячи операций с
датами, тогда это может существенно отразиться на производительности, и, если это
критично, следует использовать математический метод.
Может, в этом конкретном примере строк получилось больше, но далее мы увидим, что
такой способ более элегантный и краткий. Я предпочитаю всегда использовать именно его.
Все математические примеры далее приведены для понимания основных принципов
расчета времени, в качестве альтернативы я покажу, как ту же задачу решать строковым
способом.
Упаковка года, месяца, дня в DATA
Еще пример, как упаковать год, месяц день в переменную типа DATA , но уже
математическим способом.
Здесь есть сложность. Для получения формата DATA нужно вычислить количество дней от
нулевой даты до указанной. Для этого необходимо узнать, сколько дней прошло с 1-го
января 1970-го года. Но мы не можем просто умножить количество лет на 365 дней в году,
ведь у нас есть високосные годы.
Давайте сначала сделаем функцию, которая посчитает, сколько прошло дней до указанного
года.
FUNCTION DAYS_TILL_YEAR : UINT
VAR_INPUT
Стр. 110 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
y: UINT;
END_VAR
(* Получаем количество лет прошло с 1970го года *)
y := y - 1970;
DAYS_TILL_YEAR := y * 365 + ((y+1)/4) ((y+69)/100) + ((y+369)/400);
END_FUNCTION
• y * 365 - узнаем, сколько дней прошло от 1970-го года, если бы у нас все года
имели одно и тоже количество дней.
• ((y+1)/4) - Прибавляем по дню на каждый високосный год. Первый за этот период
високосный год был в 1973 г., так что y+1 позволит получить целое число
количества високосных годов с 1970 г. до y .
• ((y+69)/100) и ((y+369)/400) - отнимаем число лет, кратное 100 и не кратное 400.
Эти поправки включаются в 2001 году. Так как 2000 год был високосным, мы его не
исключаем.
Понадобится еще одна функция, чтобы определить количество дней от начала года до
указанного месяца.
FUNCTION DAYS_TILL_MONTH : UINT
VAR_INPUT
y, m: UINT;
END_VAR
VAR
days: ARRAY[1..12] OF UINT :=
0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334;
END_VAR
DAYS_TILL_MONTH := days[m] + BOOL_TO_INT(m > 2 AND IS_LEAP_YEAR(y));
END_FUNCTION
• days[m] возвращает нужное число, так как массив days содержит нужный ответ, но
без учета високосного кода.
• BOOL_TO_INT(m > 2 AND IS_LEAP_YEAR(y)) добавляет единицу, если год високосный
и февраль уже прошел.
Обратите внимание, что эта функция будет работать, если m указывает от 1 до 12.
Теперь мы готовы лаконично перевести год, месяц и день в формат DATE .
FUNCTION YMD_TO_DATE : DATE
VAR_INPUT
y, m, d: UINT;
END_VAR
Стр. 111 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
YMD_TO_DATE := DWORD_TO_DATE(
86400 * (DAYS_TILL_YEAR(y) + DAYS_TILL_MONTH(y, m) + d - 1)
);
END_FUNCTION
Видите, как много нужно понимать, чтобы произвести правильный расчет математическим
способом. Даже если бы это решалось меньшим количеством строк, в расчет придется
взять много данных.
Смотрите, как легко, в одной функции, с этим справился бы строковый метод.
FUNCTION YMD_TO_DATE : DATE
VAR_INPUT
y, m, d: UINT;
END_VAR
VAR
sTemp: STRING;
END_VAR
sTemp := CONCAT("D#", UINT_TO_STRING(y));
sTemp := CONCAT(sTemp, "-");
sTemp := CONCAT(sTemp, UINT_TO_STRING(m));
sTemp := CONCAT(sTemp, "-");
sTemp := CONCAT(sTemp, UINT_TO_STRING(d));
YMD_TO_DATE := STRING_TO_DATE(sTemp);
END_FUNCTION
Создаем строку типа D#2000-01-01 и конвертируем в дату. Самое главное - здесь не
обязательно понимать все тонкости расчета времени математическим способом, вести учет
високосных лет. Это все равно, что просто напечатать дату символами, а ПЛК ее сам
распознает.
Преобразование из переменной типа время
Иногда нужно наоборот - из переменной типа время получить отдельно часы, минуты и
т.д.
Это вычисление также лежит в основе многих других вычислений, которые мы будем
делать, поэтому давайте внимательно его рассмотрим.
Извлечение часов, минут, секунд и миллисекунд из TIME
FUNCTION TIME_TO_ELEMENTS : BOOL
VAR_INPUT
Стр. 112 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
tTime : TIME;
END_VAR
VAR_OUTPUT
h, m, sec, ms : UINT;
END_VAR
VAR
tTemp : UINT;
END_VAR
(* Переводим временной промежуток в количество миллисекунд *)
tTemp := TIME_TO_UINT(tTime);
(* 1000 миллисекунд в секунде. Остаток от деления и будет
остаток в миллисекундах *)
ms := tTime MOD 1000;
(* Переводим временной промежуток в количество секунд *)
tTemp := tTemp / 1000;
(* 60 секунд в минуте. Делим наш промежуток в секундах на 60 и
получаем количество полных минут, а остаток от деления будут
лишние секунды. *)
sec := tTemp MOD 60;
(* Переводим временной промежуток в количество минут *)
tTemp := tTemp / 60;
(* 60 минут в часе, а tTemp количество минут. Остаток от деления
лишние минуты. *)
m := tTemp MOD 60;
(* Делим количество минут на 60 и получаем полное количество часов *)
h := tTemp / 60;
TIME_TO_ELEMENTS := TRUE;
END_FUNCTION
Обратите внимание, что здесь используется функция и имеется несколько выходов, что не
типично для функции. Вероятно, удобней было бы создать несколько функции по одному
выходу на функцию, как TIME_TO_HOUR , TIME_TO_SECOND , и т.д. Либо нужно создать
функцию, которая вернет структуру, содержащую по отдельности все элементы времени.
Снова выделю математический и строковый способы. Интуитивно мы, люди, используем
строковый способ - он нам ближе. Если нужно узнать, сколько часов, мы смотрим на
строку времени 12:35 на часах и выделяем 12 как часы. То же самое можно сделать и тут.
Вот пример решения этой задачи строковым способом:
FUNCTION TIME_TO_ELEMENTS : BOOL
VAR_INPUT
tTime : TIME;
END_VAR
Стр. 113 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR_OUTPUT
h, m, sec : UINT;
END_VAR
VAR
sTemp: STRING;
END_VAR
(* Теперь время имеет строку типа 'TOD#12:12:59' *)
sTemp := TOD_TO_STRING(TIME_TO_TOD(tTime));
h
:= STRING_TO_UINT(MID(sTemp, 5, 2));
m
:= STRING_TO_UINT(MID(sTemp, 8, 2));
sec := STRING_TO_UINT(MID(sTemp, 11, 2));
TIME_TO_ELEMENTS := TRUE;
END_FUNCTION
Посмотрите, насколько проще и интуитивней этот метод. Просто выделяем из строки
нужную нам часть, ровно так, как мы это делаем в повседневной жизни, глядя на часы.
Извлечение года, месяца и дня из DATE
Извлечение года, месяца и дня из DATE - задача сложней, чем упаковка, которую мы
рассмотрели выше в функции YMD_TO_DATE .
Хотя мы и знаем полное число суток, трудно рассчитать сколько в них полных лет или
месяцев, так как все они имеют переменную длительность.
Сначала будем определять год или месяц приблизительно, с возможной ошибкой на один
больше. Затем, обратным преобразованием проверим результат на наличие ошибки. В этом
примере я покажу, как использовать структуру, чтобы можно было вернуть функцией одно
значение и не создавать VAR_OUTPUT для возврата множественных значений. Сначала
объявим структуру, которая будет содержать по отдельности год, месяц и день:
TYPE YMD:
STRUCT
y: UINT;
m: UINT;
d: UINT;
END_STRUCT
END_TYPE
Создадим функцию, которая вернёт эту структуру. Таким образом, возвращаем только одну
переменную, при этом, она содержит 3 значения.
FUNCTION DATE_TO_YMD : YMD
VAR_INPUT
dat: DATE;
Стр. 114 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_VAR
VAR
uiDays, uiDayInYear: UINT;
END_VAR
(* Получаем число дней с 1970г *)
uiDays := DWORD_TO_UINT(DATE_TO_DWORD(dat) / 86400);
(* Грубо вычисляем год *)
DATE_TO_YMD.y := uiDays / 365 + 1970;
(* Сверяем количество полученных дней с обратным преобразованием,
и если обратное преобразование больше, значит у нас на один
год больше, чем нужно. *)
IF uiDays < DAYS_TILL_YEAR(DATE_TO_YMD.y) THEN
DATE_TO_YMD.y := DATE_TO_YMD.y - 1;
END_IF;
(* Определяем, сколько дней остаток от начала найденного года,
чтобы посчитать месяцы *)
uiDayInYear := uiDays - DAYS_TILL_YEAR(DATE_TO_YMD.y) + 1;
(* Грубо считаем количество месяцев в остатке дней *)
DATE_TO_YMD.m := MIN(uiDayInYear/29 + 1, 12);
(* Сверяем количество полученных месяцев с обратным преобразованием,
и если обратное преобразование больше или равно, значит у нас
на один месяц больше чем нужно. *)
IF uiDayInYear <= DAYS_TILL_MONTH(DATE_TO_YMD.y, DATE_TO_YMD.m) THEN
DATE_TO_YMD.m := DATE_TO_YMD.m - 1;
END_IF;
(* Осталось вычислить остаток дней. Дни нам проверять не нужно *)
DATE_TO_YMD.d := uiDayInYear DAYS_TILL_MONTH(DATE_TO_YMD.y, DATE_TO_YMD.m);
END_FUNCTION
Я постарался максимально комментировать пример, чтобы каждое действие было понятно.
Обратим внимание, что здесь мы еще используем DAYS_TILL_YEAR и DAYS_TILL_MONTH ,
написанные раньше.
Далее, на радость нам, представляю строковый способ той же функции. Мало того, что он
весьма компактный, так еще не нужно ничего вычислять и проверять. Жертвуем мы всего
10 микросекунд или 0.01 миллисекунды:
FUNCTION DATE_TO_YMD : YMD
VAR_INPUT
dat: DATE;
END_VAR
VAR
sTemp: STRING;
Стр. 115 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_VAR
(* Получаем дату строкой 'D#2000-01-01' *)
sTemp := DATE_TO_STRING(TIME_TO_TOD(tTime));
DATE_TO_YMD.y := STRING_TO_UINT(MID(sTemp, 3, 4));
DATE_TO_YMD.m := STRING_TO_UINT(MID(sTemp, 8, 2));
DATE_TO_YMD.d := STRING_TO_UINT(MID(sTemp, 11, 2));
END_FUNCTION
Если вы поймете принцип, который лежит в основе работы строкового метода как
вычленения, так и формирования переменных, то сильно облегчите себе жизнь.
Математические операции
Проще всего делать вычисления с типом TIME . Можно выполнять присваивание,
сравнение, сложение, вычитание, умножение и деление на число, использовать их в
стандартных ограничителях и мультиплексорах:
VAR
t1, t2, t3 : TIME;
END_VAR
t2:= T#0d;
t1 := t2 - T#2m; (* 0мин – 2мин = -2мин*)
t1 := t1 + T#5m; (* -2мин + 5мин = 3мин*)
t1 := t1/2; (* 3мин/2 = 1мин30сек *)
t3 := LIMIT(T#2s, t1, T#30s); (* t3 = 30сек *)
При проведении вычислений нужно обратить внимание на 3 момента.
1. Могут возникать отрицательные длительности. Допустимы они или нет, зависит от
проекта и задачи, но нужно об этом помнить.
2. Общая длина временного промежутка ограничена, и может произойти
переполнение. Убедитесь, что в результате вычислений вы не выходите за 1193 часа.
3. Нельзя делить TIME на TIME . Можно делить TIME на число. Если нужно поделить
TIME на TIME , нужно преобразовать одно TIME в число.
t1 := t2 / TIME_TO_DWORD(t3);
Делать вычисления с TIME_OF_DAY сложнее из-за его функциональной принадлежности.
Это всего лишь отметка времени дня в сутках, а сутки - это 24 часа. Имеет смысл или
Стр. 116 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
получить разницу между двумя отметками времени, что даст TIME , или провести
сравнение. Операции умножения или деления для времени суток не имеют смысла.
Для осуществления сравнений можно использовать стандартные операторы GT , < , SEL , и
т.д.
MyTOD := SEL(CurTOD > TOD#22:00, TOD#08:00, TOD#18:00);
Сравнение текущего времени дня
Создадим функцию определения, что текущее время находится в промежутке между двумя
отметками времени TIME_OF_DAY . Данное вычисление делается путем несложного
сравнения, до тех пор, пока у нас не будет ситуации, когда промежуток выйдет из
диапазона одних суток. Например, сравнить, если текущее время в промежутке между
20:00 вечера и 2:00 ночи следующих суток. Функция должна учесть подобные
обстоятельства.
FUNCTION TOD_IS_BETWEEN : BOOL
VAR_INPUT
CurrTime: TIME_OF_DAY; (* текущее время *)
FromTime: TIME_OF_DAY; (* нижняя граница *)
ToTime:
TIME_OF_DAY; (* верхняя граница *)
END_VAR
TOD_IS_BETWEEN := (
FromTime > ToTime
AND (
CurrTime > FromTime OR CurrTime < ToTime
)
) OR (
FromTime < ToTime
AND (
CurrTime < ToTime AND CurrTime > FromTime
)
);
END_FUNCTION
Первая часть (FromTime > ToTime AND (CurrTime > FromTime OR CurrTime < ToTime)) это сравнение, при условии, что FromTime > ToTime , а значит, имеется переход из одних
суток в другие, поэтому сравнение будет через оператор OR - (CurrTime > FromTime OR
CurrTime < ToTime) .
Вторая часть сравнения (FromTime < ToTime AND (CurrTime < ToTime AND CurrTime >
FromTime)) проверяет, что диапазон расположен в пределах одних суток, тогда сравнение
делается через оператор AND .
Стр. 117 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Вычисление разницы отметок времени
При вычислении разницы возникает такая же проблема. Разница вычисляется легко, если
диапазон отметок времени лежит в пределах одних суток. А если он в разных сутках? Вот
функция вычисления разницы времени:
FUNCTION TOD_DIFF : TIME
VAR_INPUT
FromTime: TIME_OF_DAY; (* нижняя граница *)
ToTime:
TIME_OF_DAY; (* верхняя граница *)
END_VAR
IF ToTime < FromTime THEN
TOD_DIFF := T#24H - TOD_TO_TIME(FromTime) + TOD_TO_TIME(ToTime);
ELSE
TOD_DIFF := ToTime - FromTime;
END_IF
END_FUNCTION
В данном случае более надежным было бы вычисление с использованием даты DT . Тогда,
чтобы получить разницу времени, нужно просто вычесть одно время из другого.
TResult := DT_To - DT_From;
Определение дня недели
Вычислить день недели довольно просто, так как количество дней в неделе не менялось с
начала мироздания и не смещалось. Все, что нам нужно, это разделить количество дней с
известной нам даты, которая, как говорилось ранее, 1 января 1970 года, на 7 дней в неделе,
Получаем количество полных недель с этой даты, а остаток от деления как раз и будет наш
день недели.
FUNCTION DAY_OF_WEEK : USINT
VAR_INPUT
dDate : DATE;
END_VAR
VAR
NumOfDays : UINT;
END_VAR
NumOfDay := DATE_TO_DWORD(dDate) / 86400;
// 1
NumOfDay := NumOfDay + 3;
// 2
DAY_OF_WEEK := DWORD_TO_UINT(NumOfDay MOD 7); // 3
END_FUNCTION
Этот расчет можно было сделать в одной строке, но я разделил его на несколько строк для
наглядности примера.
Стр. 118 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В первой строке преобразуем дату в количество секунд с 1 января 1970 года
DATE_TO_DWORD(dDate) и делим на 86400 (количество секунд в сутках). Вычисляем,
сколько суток прошло с 1 января 1970 года.
Пока опустим вторую строку, она станет более понятной позже. Перейдём к разбору
третей строки.
В третьей строке получаем остаток от деления DWORD_TO_UINT(NumOfDay MOD 7) .
Известно, что 1 января 1970-го был четверг. Если наше число ровно делится на 7 без
остатка, значит вычисляемая дата - тоже четверг. Если остаток 1, то пятница, 2 - суббота и
т.д. Четверг становится базовым 0 для отсчета дней недели.
Такой счет с четверга, как 0, не очень удобен. Обычно счет идет с 0 до 6, где 0 это
воскресенье или понедельник. В большинстве стран мира принято считать первым днем
недели воскресенье, но в некоторых странах, как и в нашей, это понедельник. В мире ПЛК
общепринятой практикой считается использование стандарта ISO-8601.
Определение:
ISO 8601 — международный стандарт, выпущенный организацией ISO
(International Organization for Standardization), который описывает
форматы дат и времени, и даёт рекомендации для его использования
в международном контексте.
В этом стандарте первым днем недели считается понедельник, поэтому будет иметь смысл
вернуть число от 0 до 6, где 0 это понедельник.
• 0 - Понедельник
• 1 - Вторник
• 2 - Среда
• 3 - Четверг
• 4 - Пятница
• 5 - Суббота
• 6 - Воскресенье
В этом счете четверг - третий день. Значит, чтобы изменить базовый 0 четверга на 3, нужно
прибавить 3.
Таким образом, мы сдвигаем счет. Прибавив 3, мы делаем так, что остаток от деления
будет не 0, а 3. А это как раз четверг, если взять за 0 понедельник.
Значит, если мы хотим вести счет от воскресенья, нужно прибавить 4, чтобы вернулось 4.
Если 0 это воскресенье, то четверг будет четвертым днем.
Стр. 119 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Определение високосного года
Високосным считается любой год, который делится на 400, и годы, которые делятся на 4,
но не делятся на 100. Вводным параметром в функцию будет год.
FUNCTION IS_LEAP_YEAR : BOOL
VAR_INPUT
uiYear : UINT;
END_VAR
IS_LEAP_YEAR := uiYear MOD 400 = 0 OR
uiYear MOD 4 = 0 AND uiYear MOD 100 <> 0;
END_FUNCTION
FUNCTION DAYS_IN_YEAR : UINT
VAR_INPUT
uiYear: UINT;
END_VAR
IF IS_LEAP_YEAR(uiYear) THEN
DAYS_IN_YEAR := 366;
ELSE
DAYS_IN_YEAR := 365;
END_IF;
END_FUNCTION
Вычисление порядкового дня в году
FUNCTION DAY_OF_YEAR : UINT
VAR_INPUT
month: UINT; (* Текущий месяц *)
day:
UINT; (* Текущий день *)
END_VAR
DAY_OF_YEAR := DAYS_TILL_MONTH(month) + day;
END_FUNCTION
У нас уже есть функция DAYS_TILL_MONTH , которая вычисляет количество дней в году до
указанного месяца. Остается только добавить количество дней.
Вычисления порядкового номера недели в году
FUNCTION WEEK_OF_YEAR : UINT
VAR_INPUT
cur_date: DATE; (* Текущая дата *);
END_VAR
WEEK_OF_YEAR := ((DAY_OF_YEAR(cur_date) + 6) / 7);
IF
DAY_OF_WEEK(cur_date) <
DAY_OF_WEEK(YEAR_STARTS(cur_date))
Стр. 120 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
THEN
WEEK_OF_YEAR := WEEK_OF_YEAR + 1;
END_IF;
END_FUNCTION
Для вычисления порядкового номера недели необходимо знать, с какого дня недели
начинается год. Для этого нужно из текущей даты извлечь текущий год и получить день
недели на 01 января текущего года. Для простоты кода я создал отдельную функцию
YEAR_STARTS , которая строковым способом извлекает дату на 1 января текущего года.
FUNCTION YEAR_STARTS : DATE
VAR_INPUT
cur_date: DATE; (* Текущая дата *);
END_VAR
VAR_TEMP
str: STRING[25]; (* Временная строка *)
END_VAR
str := DATE_TO_STRING(cur_date); (* получаем строку `D#2000-12-30` *)
YEAR_STARTS := STRING_TO_DATE(
CONCAT(CONCAT('D#', MID(str, 3, 4)), '-01-01')
);
END_FUNCTION
Определение времени восхода и заката
Время захода и восхода солнца очень часто используется для организации освещения или,
например, динамической подсветки зданий.
Первая функция, которая нам понадобится, вернет время солнцестояния на заданной
широте в заданное время:
FUNCTION SUN_MIDDAY : TOD
VAR_INPUT
LON : REAL;
UTC : DATE;
END_VAR
VAR
T : REAL;
OFFSET : REAL;
END_VAR
T := INT_TO_REAL(DAY_OF_YEAR(utc));
OFFSET := -0.1752 * SIN(0.033430 * T + 0.5474) - 0.1340 *
SIN(0.018234 * T - 0.1939);
SUN_MIDDAY := HOUR_TO_TOD(12.0 - OFFSET - lon * 0.0666666666666);
END_FUNCTION
Еще нам понадобиться функция RAD которая конвертирует градусы в радианы.
Стр. 121 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
FUNCTION RAD : REAL
VAR_INPUT
DEG : REAL;
END_VAR
RAD := MODR(0.0174532925199433 * DEG, math.PI2);
END_FUNCTION
Теперь сама функция расчета времени заката и восхода:
FUNCTION_BLOCK SUN_TIME
VAR_INPUT
LATITUDE : REAL;
(* Широта *)
LONGITUDE : REAL;
(* Долгота *)
UTC : DATE;
(* Текущее время в UTC *)
(* Градус над горизонтом, при котором уже считается
наступление заката или восхода *)
H : REAL := -0.83333333333;
END_VAR
VAR_OUTPUT
MIDDAY:
TOD; (* Время дня солнцестояния *)
SUN_RISE:
TOD; (* Время восхода *)
SUN_SET:
TOD; (* Время заката *)
(* Угол наклона солнца в солнцестояние в градусах *)
SUN_DECLINATION: REAL;
END_VAR
VAR
dk:
REAL; (* Угол наклона солнца в полдень в радианах *)
delta: TIME; (* Дельта от солнцестояния до восхода или заката *)
b:
REAL;
END_VAR
MIDDAY := SUN_MIDDAY(longitude, utc);
b := latitude * 0.0174532925199433;
dk := 0.40954 * SIN(0.0172 * (INT_TO_REAL(DAY_OF_YEAR(utc)) - 79.35));
SUN_DECLINATION := DEG(DK);
IF SUN_DECLINATION > 180.0 THEN
SUN_DECLINATION := SUN_DECLINATION - 360.0;
END_IF;
SUN_DECLINATION := 90.0 - LATITUDE + SUN_DECLINATION;
delta := HOUR_TO_TIME(
ACOS((SIN(RAD(H)) - SIN(B) * SIN(DK)) / (COS(B) * COS(DK))) *
3.819718632
);
SUN_RISE := MIDDAY - delta;
SUN_SET := MIDDAY + delta;
END_FUNCTION_BLOCK
Другие функции, которые здесь используются, такие как DEG() , MODR() , вы найдете в
математических примерах типов переменных REAL .
Стр. 122 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Таймеры
Когда только начинаешь писать на ST, таймеры могут быть самым непонятным моментом.
Они не работают как надо, иногда вообще не работают, и трудно понять, почему.
С другой стороны, таймеры - это наиболее часто используемые блоки, поэтому будет
важным понять принцип их работы.
Первое, что нужно понять. Таймеры, как и все ФБ, являются не блокирующими, или
другой термин - асинхронными. Это значит, что POU, выполняющий вызов ФБ таймера, не
будет ждать, пока таймер завершит работу, чтобы перейти к исполнению другой строки
POU. Он просто сохранит текущее значение времени в памяти и продолжит работу. Таким
образом, программа может пройтись по таймеру несколько раз, прежде чем таймер
завершит работу. Другими словами, контроллер выполнит несколько циклов прохода по
общей программе, прежде чем таймер истечёт.
Стандартом предусмотрено 3 блока таймера, которые вы, скорее всего, сможете
использовать в любой IDE.
• TON - Timer On Delay - Задержка времени на включение.
• TOF - Timer Off Delay - Задержка времени на выключение.
• TP - Time Pulse - Временной импульс.
Точность таймера
На точность таймера могут повлиять как программа, так и "железо". Если время цикла 10
мс, конечно, точность измерения таймера будет с шагом в 10 мс. Если таймер начинает
работу по входному сигналу, то могут повлиять задержки времени измерения входных
сигналов. Именно поэтому существует такое понятие как "быстрые входы". Чем они
быстрее, тем больше точность работы контроллера. Поэтому важно, чтобы в ПЛК был
производительный процессор, ведь чем быстрее цикл контроллера, тем точнее таймеры.
Здесь нужен умеренный подход. Если вы делаете автоматизацию грибницы, можно взять и
не супер быстрый контроллер. Задержки на пару секунд включения устройств увлажнения
или подогрева не сыграют большой роли для всего процесса. Если нужны очень точные
измерения, например, для конвейера из нескольких синхронизированных между собой
механизмов, то желательно ускорить его работу. Или, мы читаем данные с энкодера, тогда
нужно приобретать более производительные контроллеры или модули с быстрыми
входами.
Стр. 123 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Сброс таймера
Одна из распространенных ошибок начинающих программистов при работе с таймером они забывают сделать сброс после окончания его работы. Однажды запущенный таймер не
запустится во второй раз, пока он не будет сброшен. Нужно понимать, что таймер начинает
работу в момент, когда на входе появляется сигнал. Чтобы запустить таймер во второй раз,
нужно его обязательно выключить хотя бы на один цикл ПЛК, а потом снова включать.
Чтобы сбросить таймер, нужно вызвать его в выключенном состоянии, как минимум, на
один цикл программы.
TON_1(IN := FALSE);
Чаще всего такая ситуация возникает при использовании таймера внутри условия IF .
Допустим, имеется конвейер по упаковке фруктов. Когда коробка с фруктами, которая
движется по конвейеру, проезжает участок мойки, бесконтактный датчик xPositionWash
подает сигнал, что коробка под форсунками и нужно опрыскать ее водой, включив
форсунки на 2 секунды.
Мы будем использовать таймер ТР (Timer Pulse). Этот таймер выдает на выход Q импульс
заданной длительности после поступления импульсного сигнала на вход IN . Импульсного
- значит, что даже если вход IN отключить до того пока таймер окончит отсчет заданного
времени, таймер будет продолжать работать.
Предположим бесконтактный датчик - это xPositionWash , а мойка - это xWasher .
VAR
TP_1: TP;
END_VAR
IF xPositionWash THEN
TP_1(IN := TRUE, PT := T#2s);
END_IF
xWasher := TP_1.Q;
На первый взгляд кажется, что это рабочий код. Как только сработал датчик
xPositionWash , запускаем таймер. Его выходная переменная Q будет равно TRUE на 2
секунды. Ее мы и используем, чтобы включить мойку.
Проблема этого кода в том, что входная переменная таймера IN всегда равна TRUE и
никогда не бывает FALSE . Ведь импульс происходит при смене отрицательного сигнала
FALSE в положительный TRUE . Это значит, что если мы хотим, чтобы таймер заработал,
Стр. 124 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
мы должны написать код так, чтобы в момент включения, или, другими словами, подачи
TRUE на IN , там был FALSE .
В нашем же случае это сработает только один раз. Первый раз - после включения
контроллера. Последующее вызовы TP_1 не запустят таймер, так как он будет в состоянии
завершенной работы. Даже при том, что несколько циклов программы таймер TP_1 не
вызывается, потому что датчик xPositionWash не имеет сигнала. Тот факт, что мы не
вызываем таймер несколько циклов, не является его сбросом.
Данная проблема возникла из-за того, что мы используем таймер внутри условия IF .
Логичным решением кажется выполнение сброса, когда на датчике нет сигнала.
IF xPositionWash THEN
TP_1(IN := TRUE, PT := T#2s);
ELSE
TP_1(IN := FALSE);
END_IF
xWasher := TP_1.Q;
Таким образом мы подготавливаем таймер к следующему сигналу на датчике, и когда он
появится, на входе IN произойдет смена сигнала с FALSE в TRUE , и таймер начнет отсчет.
Самое правильное и удобное решение - это вынос всех таймеров за любые условия так,
чтобы они исполнялись в любой цикл ПЛК. Вот как может выглядеть рабочий пример.
TP_1(IN := xPositionWash, PT := T#2s);
xWasher := TP_1.Q;
Казалось бы, все то же самое, но в этом примере таймер будет вызываться на каждый цикл
ПЛК, а не только когда xPositionWash = TRUE , а сам xPositionWash становится
выключателем таймера. Это работает, потому что условие простое.
Если условие сложное и порядок условий многоуровневый, то наш пример может
выглядеть вот так:
TP_1.IN := FALSE;
IF xPositionWash THEN
TP_1.IN := TRUE;
END_IF
TP_1(Q => xWasher);
Мы можем задавать условие включения таймера в многоуровневых сложных условиях,
вызывая таймер в конце. Главный принцип в том, чтобы таймер вызывался за условиями,
Стр. 125 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
на любой цикл ПЛК, даже когда он нам не нужен, а просто в этот момент вызывался бы
выключенным.
Рассмотрим наиболее часто используемый таймер TON . Это таймер задержки включения.
Когда мы подаем сигнал на вход IN этого таймера, он отсчитает заданное время, прежде
чем включит выход Q . Как правило, именно сигнал на выходе Q становится точкой сброса
таймера.
Если мы вызываем этот таймер внутри условия, единственный способ решения нашей
задачи с этим таймером:
VAR
TON_1: TON;
END_VAR
xWasher := FALSE;
IF xPositionWash THEN
TON(IN := TRUE, PT := T#2s);
xWasher := NOT TON_1.Q;
ELSE
TON(IN := FALSE);
END_IF
Как видите, если мы вызываем таймер внутри условия, нам приходится вызывать его в
нескольких местах программы, чтобы сделать сброс. Это делает код программы менее
читаемым, да и в принципе это не правильно. Таймер должен вызываться только в одном
месте, один раз в цикле программы. Вынос таймера за условие поможет решить проблему.
Наш пример можно решить вот так:
TON(IN := xPositionWash, PT := T#2s);
xWasher := (xPositionWash AND NOT TON_1.Q);
Моя задача показать вам, как бы решилась задача с многоуровневыми, вложенными
условиями, пример может выглядеть вот так:
TON.IN := FALSE;
IF xPositionWash THEN
TON.IN := TRUE;
END_IF
TON(PT := T#2s);
xWasher := (xPositionWash AND NOT TON_1.Q);
Другими словами, мы где-то в программе назначаем таймеру задачу на включение, а потом
вызываем его.
Стр. 126 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Сложность нашего примера заключается в том, что по окончании работы таймера условие
на включение таймера не меняется, а остается тем же. Ведь датчик присутствия коробки
все еще может подавать сигнал после обработки водой и по истечении двух секунд. После
завершения работы таймера условие на его включение не меняется.
Если бы наш таймер менял условие, то можно было бы вызывать его внутри условия без
проблем:
VAR
TON_1: TON;
a: BOOL;
END_VAR
IF a THEN
TON_1(IN := TRUE, PT := T#2s);
IF TON_1.Q THEN
a := FALSE;
TON_1(IN := FALSE);
END_IF
END_IF
Следите за тем, чтобы вызов таймера имел как можно меньшую глубину вложенности
внутри разных условий, а по возможности - вообще не имел вложенности.
Пример с выключением условия можно сделать и с выносом таймера за условие:
VAR
TON_1: TON;
a: BOOL;
END_VAR
TON_1(IN := a, PT := T#2s);
IF TON_1.Q AND a THEN
a := FALSE;
END_IF
Обратите внимание, как гибко ST позволяет нам решить одну и ту же задачу разными
подходами, паттернами, типами таймеров и т.д.
Устройство Таймера
Чтобы понять внутреннее устройство и работу таймера в совершенстве, давайте
попробуем написать свой собственный таймер TON таким, каким он создан в стандартной
библиотеке Codesys. Таймер будет основан на функции TIME() , которая возвращает
количество миллисекунд, прошедших с запуска контроллера.
Стр. 127 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
FUNCTION_BLOCK TON
VAR_INPUT
IN: BOOL; (* Запустить таймер по повышающему сигналу и
отключить по понижающему *)
PT: TIME; (* время задержки перед включением выхода Q *)
END_VAR
VAR_OUTPUT
Q: BOOL; (* станет TRUE, когда время PT истечет после
подачи сигнала на IN *)
ET: TIME; (* Время, которое таймер уже проработал *)
END_VAR
VAR
M: BOOL;
(* память включения *)
StartTime: TIME; (* время начала отсчета *)
END_VAR
(* Если таймер включился *)
IF (IN) THEN
(* Сработает только один раз, так как М в конце кода
присвоится значение TRUE. Назначаем тут точку времени,
когда таймер запустился. *)
IF (NOT M) THEN
(* Сохранить точку отсчета таймера *)
STARTTIME := TIME();
END_IF
(* Если не окончен отсчёт *)
IF (NOT Q) THEN
(* Считаем прошедшее время с начала работы таймера *)
ET := TIME() - STARTTIME;
(* Если время работы таймера равно или даже больше
чем уставка PT, назначаем выход Q *)
IF (ET >= PT) THEN
Q := TRUE;
ET := PT;
END_IF
END_IF
ELSE
(* Таймер выключен, все сбрасываем *)
Q := FALSE;
ET := t#0s;
END_IF
M := IN;
END_FUNCTION_BLOCK
Сделайте паузу и попытайтесь понять этот код. Посмотрите его, строка за строкой,
несколько раз, пока не станет понятно, что тут происходит.
Для дополнительного ознакомления размещу здесь коды таймеров TOF и TP :
FUNCTION_BLOCK TP
(* Пульсовый таймер. Q включается на время PT на каждый
повышающий сигнал входа IN *)
Стр. 128 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR_INPUT
IN: BOOL; (* Включение таймера *)
PT: TIME; (* Длина сигнала *)
END_VAR
VAR_OUTPUT
Q: BOOL; (* Выход, который включается на время PT
после повышающего сигнала на IN *)
ET: TIME; (* Время, которое таймер уже работает *)
END_VAR
VAR
StartTime: TIME; (* Точка времени начала работы таймера *)
END_VAR
IF (Q) THEN
ET := TIME() - STARTTIME;
IF (ET >= PT) THEN
Q := FALSE;
IF (NOT IN) THEN
ET := t#0s;
ELSE
ET := PT;
END_IF
END_IF
ELSIF (NOT IN) THEN
(* Сбросить таймер *)
ET := t#0s;
ELSIF (IN AND ET = t#0s) THEN
(* Начать таймер *)
STARTTIME := TIME();
Q := TRUE;
ET := t#0s;
END_IF
END_FUNCTION_BLOCK
И еще:
FUNCTION_BLOCK TOF
(* Таймер задержки отключения. Q равен FALSE, после
выдержки времени PT как только на IN пропал сигнал. *)
VAR_INPUT
IN: BOOL; (* Начать отсчет после выключения *)
PT:TIME; (* Время, через которое Q выключится *)
END_VAR
VAR_OUTPUT
Q: BOOL; (* Включается сразу же после включения
IN и выключается, как только пройдёт
время PT после выключения IN *)
ET: TIME; (* Время работы *)
END_VAR
VAR
M: BOOL;
(* Память *)
StartTime: TIME; (* Время начала отсчета таймера *)
END_VAR
Стр. 129 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
IF (IN) THEN
(* Сбросить все *)
Q := TRUE;
ET := t#0s;
ELSE
IF (M) THEN
(* Начать таймер *)
STARTTIME := TIME();
END_IF
IF (Q) THEN
(* Таймер в работе *)
ET := TIME() - STARTTIME;
IF (ET >= PT) THEN
Q := FALSE;
ET := PT;
END_IF
END_IF
END_IF
M := IN;
EDN_FUNCTION_BLOCK
Категория 3: Наследственные типы данных
• Structured (Структуры)
• Enumerated (Перечисления)
• Alias (Ссылка)
• Pointer (Указатель)
• Union (Объединение)
• Array (Массивы)
• Sub-ranges (Диапазоны)
Structured (структуры)
Структура уверенно станет очень полезным типом данных. Использование структур в
графических языках не всегда удобно. И не всегда понятно, где конкретно их применять. В
ST, напротив, структуры вносят порядок и чистоту в программу.
Определение:
Структура - это композитный тип данных или набор значений разного
типа в одной переменной.
Для объявления структуры используются ключи STRUCT и END_STRUCT . Объявляются
структуры между ключей TYPE и END_TYPE :
Стр. 130 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
TYPE
stRange : STRUCT
Start: INT := 0;
End:
INT := 0;
END_STRUCT
END_TYPE
Или, например, структура для управления объектом "теплица":
TYPE
stGreenhouse : STRUCT
Temperature: REAL; (* температура *)
Humidity:
USINT; (* влажность в процентах 1-100 *)
Fan:
BOOL; (* Управление вентилятором *)
Windows:
BOOL; (* Управление окнами - открыть или закрыть *)
END_STRUCT
END_TYPE
Чтобы воспользоваться структурой в программе:
VAR
rg : stRange;
gh : stGreenhouse;
END_VAR
rg.Start := 25;
rg.End := 27;
// Если температура ниже нижнего предела,
// закрыть окна и выключить вентилятор
IF gh.Temperature < rg.Start THEN
gh.Fan := FALSE;
gh.Windows := FALSE;
END_IF
// Если температура выше верхнего предела,
// открыть окна и включить вентилятор
IF gh.Temperature > rg.End THEN
gh.Fan := TRUE;
gh.Windows := TRUE;
END_IF
Enumerated (перечисления)
Перечисления позволяют обращаться к значениям переменной по имени. Объявляются
перечисления между ключей TYPE и END_TYPE :
TYPE
enSvetofor : (red, green, orange := 5);
END_TYPE
Стр. 131 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В этом примере red равно 0, green это 1, и orange равно 5. И как это теперь
использовать в программе?
VAR
sv : enSvetofor;
END_VAR
IF sv = green THEN
(* зажигаем зеленый свет *)
END_IF
sv := red; (* sv будет равно 0 или red *)
sv := 5;
(* sv будет равно 5 или orange *)
Можно делать назначения числом. Например, sv := 5 значит, что sv равен orange .
Чтение значений перечисления или обращение к перечислению может осуществляться поразному. Например, в CoDeSys 2.3 можно просто обратиться к перечислению по имени sv
= orange , как это приведено в примере. В CoDeSys 3.5 нужно всегда обращаться только к
полному имени через точку sv = enSvetofor.orange .
Важно
Нельзя использовать одно и тоже имя перечисления для разных
типов.
TYPE
enSvetofor : (red, green, orange);
enColors : (black, yellow, red);
END_TYPE
Если в программе создать обращение к перечислению red , это произведет ошибку на
стадии компиляции, так как перечисление red используется в 2-х типах перечислений в
примере вверху.
Это значит, что, конечно, можно определять одинаковые имена перечислений, но тогда
придется обращаться к ним в программе полным именем enSvetofor.red , enColors.red .
Является общей практикой определять уникальные имена перечислений, насколько это
возможно:
TYPE
enSvetofor: (SV_LIGHT_RED, SV_LIGHT_GREEN, SV_LIGHT_ORANGE);
enColors:
(COLOR_RED, COLOR_YELLOW, COLOR_RED);
END_TYPE
Стр. 132 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Alias (Псевдоним)
Псевдоним используется для того, чтобы сослаться на уже существующий тип.
Объявляются ссылки между ключей TYPE и END_TYPE . Например:
TYPE
Message: STRING[50];
END_TYPE;
И в программе:
VAR
m : Message;
END_VAR
IF inTemperature < 15 THEN
m := 'Слишком холодно';
END_IF
Pointer (Указатель)
Указатели - еще одно из преимуществ языка ST. Объявлять переменные указатели можно и
в графических языках, но пользоваться ими удобнее именно в ST.
Указатели попали в стандарт только в 3й редакции в 2013 году. А появились они в CoDeSys
и в некоторых других IDE гораздо раньше. Эт значит что внедрены они были без
стандарта, так как это видели разработчики IDE. Именно по этому трудно описать как
пользоваться указателями в одной главе так, что бы это можно было применить в любой
среде разработки.
Считаю элегантным и удобным то, как указатели реализованы в CoDeSys, а значит и в
производных, как e!COCKPIT от WAGO или TwinCAT от Beckhoff. От этого я и буду
отталкиваться, описывая этот тип.
Определение:
Указатель позволяет ссылаться на область памяти, где хранится
любая переменная любого типа или даже ФБ.
При объявлении указателя указывается только тип переменной, на которую он,
предположительно, будет ссылаться.
VAR
pt : POINTER TO INT;
i: INT;
Стр. 133 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_VAR
i := 10;
pt := ADR(i);
Вот, что нужно знать по синтаксису указателей:
1. Чтобы объявить указатель в области определения переменных, перед указанием типа
используется ключевое слово REF_TO , REF TO или POINTER TO - в зависимости от
среды разработки. REF_TO предложено стандартом, но в CoDeSys используется ключ
POINTER TO .
2. Чтобы дать указателю адрес переменной в памяти ПЛК, используется функция
REF() или ADR() - в зависимости от среды разработки. И снова REF() - это
рекомендация стандарта, а ADR() - то, как реализовано в CoDeSys.
3. Чтобы получить значение указателя, используется оператор разыменовывания ^ ,
который ставится после имени указателя.
Терминология:
Я использую термин разыменовывать для оператора ^
(dereference). Еще одним термином для этого оператора может быть
снятие косвенности.
Переменную, к которой привязан указатель через ADR() или REF() ,
называют исходной переменной.
После операции присвоения, переменная указателя pt будет ссылаться на ту же область
памяти, что и исходная переменная i . Получается, что у одной переменной два имени.
Теперь неважно, измените вы значение указателя pt или значение переменной i - это
отразится на обоих переменных.
В Codesys указатели исполнены иначе от рекомендации стандарта. Объявление указателя
POINTER TO , а привязка - к указателю переменной ADR() . Это связано с тем, что в
стандарте указатели появились позже, чем они были внедрены некоторыми вендорами.
Разберем следующий пример:
VAR
pt: POINTER TO INT;
a: INT := 10;
b: INT;
END_VAR
(* Указатель ссылается на переменную а и равен 10ти *)
Стр. 134 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
pt := ADR(a);
(* Теперь и указатель pt равен 15 *)
a := 15;
(* Переменная b равна 15 *)
b := pt^;
Или вот так, чтобы было понятнее:
VAR
pt: POINTER TO INT;
A: INT := 10;
B: INT;
END_VAR
(* Указатель ссылается на переменную A и равен 10ти *)
pt := ADR(A);
(* Теперь указатель равен 20, а значит и переменная А *)
pt^ := 20;
(* Переменная B равна 20 *)
B := A;
В первом примере было изменено значение исходной переменной, а во втором значение
указателя. Но и в том, и в другом случае изменяются обе переменные.
Преобразование данных
Важно понимать, что сами указатели не имеют типа данных. Переменная pt всегда будет
хранить значение типа DWORD или LDWORD в зависимости от типа системы 32 или 64 бит,
независимо от типа, на который ссылается указатель. Не важно к какому типу будет
привязан указатель при помощь функции ADR , она всегда вернет значение типа DWORD ,
хранящее адрес ячейки памяти, где находится первый бит исходной переменной.
Это значит, что типы данных, на которые ссылается указатель в области определения
переменных, и тип данных исходной переменной могут не совпадать. Например:
VAR
wWord:
WORD;
iInt:
INT;
abArray: ARRAY[1..2] OF BYTE;
dwRead: DWORD;
dwWrite: DWORD;
pt_w:
pt_i:
pt_a:
Стр. 135 из 298
POINTER TO WORD;
POINTER TO INT;
POINTER TO ARRAY[1..2] OF BYTE;
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
pt_dw: POINTER TO DWORD;
END_VAR
pt_w := ADR(abArray);
pt_i := ADR(dwRead);
pt_a := ADR(iInt);
pt_dw := ADR(wWord);
Обратите внимание: мы объявили несколько переменных разного типа, а затем указатели
на переменные такого же типа. Но при привязке мы не соблюдаем типы. Это вполне
рабочий пример. При этом, даже количество байт в исходной переменной и типе указателя
не должны совпадать.
Привязываем к указателю на число INT переменную двойного слова DWORD , размер
которой, по сути, на 2 байта больше.
pt_i
:= ADR(dwRead);
Что произойдет, если обратиться к значению этого указателя через pt_i^ ? Мы просто
прочтем первые 2 байта переменной dwRead и представим их как число INT .
Еще пример: присваиваем указателю на тип DWORD переменную типа WORD , размер
которой на 2 байта меньше.
pt_dw := ADR(wWord);
Что произойдет, если обратиться к значению этого указателя через pt_dw^ ? Прочтем 2
байта переменной wWord , а затем следующие по очереди 2 байта памяти, при этом не
важно, что в них сохранено.
Важно:
Крайне не желательно, чтобы размер указателя был меньше или
больше чем размер переменной на которую он ссылается. Исключения
составляют те случаи, когда вы точно понимаете что делаете.
Итак, объявив указатель одного типа, а сославшись на исходную переменную другого
типа, можно выполнять всевозможные преобразования типов.
Важный вывод: можно вообще не ссылаться на переменные через оператор ADR .
Допустим, мы хотим прочитать все ячейки памяти с 16#1600 в шестнадцатеричном виде
или 5632 в десятичном, по 16#16FF или 5887, а это 255 ячеек памяти, сохранив их, как
массив из байт.
Стр. 136 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Зачем нам это? Хотя это действительно мало применимо в реальной жизни, и адреса ячеек
памяти могут меняться от одной загрузки ПЛК к другой, я хочу продемонстрировать
принцип перемещения по адресам памяти. Это очень частая используемая техника. Код
типа pt := pt + 1; вы встретите не раз. Пример ниже, это попытка продемонстрировать
принцип его работы.
Допустим, известно, что именно в этой области 16#1600 - 16#16FF зарезервированы
нужные нам данные.
VAR
pt: POINTER TO BYTE;
ar: ARRAY[0..254] OF BYTE;
i: INT;
END_VAR
pt := 16#1600;
FOR i := 0 TO 254 DO
ar[i] := pt^;
pt := pt + 1;
END_FOR;
Как видно, здесь нет никаких привязок через REF или ADR , просто назначаем номер
ячейки памяти напрямую. Далее в цикле извлекаем значение этой ячейки при помощи
разыменовывания pt^ и сохраняем, как элемент массива. Ведь указатель ссылается на тип
переменной BYTE , а значит, мы прочтем только один байт из ячейки памяти при каждом
разыменовывании.
Затем увеличиваем адрес на единицу pt := pt + 1; , установив адресом ячейки памяти
следующий байт, чтобы в следующей итерации цикла, получить значение следующей
ячейки.
Аналогичный пример. Тот же принцип, но читаем уже регистрами или блоками по 2 байта:
VAR
pt: POINTER TO WORD;
ar: ARRAY[0..127] OF WORD;
i: INT;
END_VAR
pt := 16#1600;
FOR i := 0 TO 127 DO
ar[i] := pt^;
pt := pt + 2;
END_FOR;
Стр. 137 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Изменяем тип указателя на WORD , как и элемент массива. Получаем в 2 раза меньше блоков
на том же участке памяти, массив сократился до 127. В цикле передвигаем указатель на 2
байта, а не на один pt := pt + 2; .
Еще пример для закрепления материала:
VAR
ptByte: POINTER TO BYTE;
abChars: ARRAY[1..5] OF BYTE;
sText:
STRING[5] := 'qwert';
iCount: USINT;
END_VAR
ptByte := ADR(sText);
FOR iCount := 1 TO 5 DO
abChars[iCount] := ptByte^;
ptByte := ptByte + 1;
END_FOR
Данный пример читает строку и сохраняет каждый ее символ в массиве из байт. То есть,
преобразует строку в массив из символов.
Первое, это ptByte := ADR(sText) . Как мы уже знаем, указателю на один тип можно
присвоить переменную любого другого типа данных.
Теперь указатель ссылается на первый байт переменной sText в ячейке памяти. В цикле
назначаем элементу массива первый байт строки, получая значение указателя, которое
будет байтом:
abChars[iCount] := ptByte^;
Затем увеличиваем указатель на один, таким образом, переходим к следующему адресу
ячейки памяти:
ptByte := ptByte + 1;
Мы знаем, что переменная sText: STRING[5] := 'qwert' хранит данные в памяти
последовательно, одна ячейка за другой. Поэтому на следующей итерации цикла прочтем
и сохраним в массив следующй байт строковой переменной sText . И так до тех пор, пока
не дойдем до конца.
Это был еще один пример для закрепления концепции указателей, но подобная задача
могла решиться просто созданием указателя на массив из байт.
Стр. 138 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR
abChars: POINTER TO ARRAY[1..5] OF BYTE;
sText:
STRING[5] := 'qwert';
END_VAR
abChars := ADR(sText);
Оба примера рабочие, дают один и тот же результат и демонстрируют один и тот же
принцип, и идею.
Во втором примере мы создали указатель на массив из байт и привязались к первому байту
строки, так что при разыменовывании прочтем эти 5 байтов как массив из байт.
Данная концепция открывает перед нами ряд техник, которые можно использовать для
преобразования одних типов в другие.
Строка в массив регистров
Пример преобразования данных. Например, по Modbus получаем несколько регистров
подряд. Если в конфигурации на каждый регистр сделать отдельную конфигурацию, то и
опрос будет идти по очереди. Предположим, имеется 100 регистров в конфигурации, это
значит, что будет сделано 100 опросов по порядку, прежде чем цикл опроса повторится.
Это приводит к замедлению работы сети, что часто бывает недопустимо.
Если опрашиваемые регистры подчиненного устройства (slave) расположены по порядку,
то можно создать переменную типа строка для чтения всех регистров одним запросом.
Например, нужно опросить с первого регистра до двадцатого.
Посмотрим пример конфигурации:
Стр. 139 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
1. Добавили опрос на получение строки.
2. Дали ей имя sStr.
3. Определили чтение с 1-го регистра и далее читать 40 байт. Почему не 20? Мы ведь
хотим читать 20 регистров. Мы знаем что в одном регистре 2 байта. Значит, чтобы
прочитать 20 регистров, нужно прочитать 40 байт строки.
Теперь в коде нам только остается создать переменную и сослаться на нее:
VAR_GLOBAL
gawrRegisters: POINTER TO ARRAY[1..20] OF WORD;
END_VAR
Есть массив значений типа WORD , состоящий из элементов размером с один регистр, их 20.
Теперь в коде:
gawrRegisters := ADR(sStr);
Теперь мы можем ссылаться на регистры. Например, получить значение 3-го регистра в
подчиненном устройстве - это все равно, что получить значение 3-го элемента массива:
wSomething := gawrRegisters^[3];
Понимая принцип работы указателя, можем двигаться дальше. Допустим, известно, что в
подчиненном устройстве, из которого мы читаем, в порядке регистров первые 2 байта управляюще биты, за ними следуют 2 значения с плавающей запятой, а за ними - значение
простого числа.
Делаем карту регистров подчиненного устройства в виде структуры:
TYPE MB_MAP:
STRUCT
cb1: BYTE;
cb2: BYTE;
sv1: REAL;
sv2: REAL;
w1: WORD;
END_STRUCT
END_TYPE
Объявим указатель не типа массив, а типа нашей структуры:
VAR_GLOBAL
gstRegisters: POINTER TO MB_MAP;
END_VAR
Стр. 140 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Теперь можно обращаться напрямую к переменным с плавающей запятой или
управляющим битам:
gstRegisters := ADR(sStr);
rSomething := gstRegisters^.sv1;
xStart
:= gstRegisters^.cb1.2;
Это великолепно. Если до этого места вы все поняли, уверяю, вам будет намного проще
решать множество задач на ПЛК.
Массив байт - в массив слов
Еще одна распространенная операция - преобразование массива из BYTE в массив WORD .
Это особенно часто требуется при работе с собственными протоколами обмена данными
или с собственной реализацией любого стандартного протокола.
Как правило, в COM порт или Socket данные отправляются и читаются побайтно. Но сами
значения часто хранятся в 2-х байтах, как word.
Допустим, имеется переменная abBuffer - буфер данных, получаемых из сети, и это
массив из BYTE , 0..19 индексов или 20 элементов массива. Его нужно преобразовать в
массив из WORD . Определим массив с 10-ю элементами, так как в одном элементе WORD
содержится 2 элемента BYTE :
VAR_GLOBAL
gawWords: POINTER TO ARRAY[1..10] OF WORD;
END_VAR
Теперь в коде:
gawWords := ADR(abBuffer);
Можно получать доступ к регистрам по 2 байта:
wSomething := gawWords^[3];
По тому же принципу можно совершить обратное преобразование массивов любых типов
данных ANY_INT или ANY_BIT .
Стр. 141 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Картирование
Без указателей
Допустим, ПЛК регулирует нагрев нескольких участков. Имеются несколько датчиков
температуры и выходы для включения нагрева. Вот как это выглядит без указателей:
PROGRAM TermoReg
VAR
rSV1: REAL := 37.7;
rSV2: REAL := 25;
rSV3: REAL := 14.6;
END_VAR
(* Логически *)
IF DI_rTempr1 > rSV1 THEN
DO_xHeater1 := TRUE;
ELSE
DO_xHeater1 := FALSE;
END_IF
(* Предварительное отрицание *)
DO_xHeater2 := FALSE;
IF DI_rTempr2 > rSV2 THEN
DO_xHeater2 := TRUE;
END_IF
(* Усреднение *)
DO_xHeater3 := (DI_rTempr3 > rSV3);
END_PROGRAM
В этом коде регулируем 3 нагревательных элемента DO_xHeater* . Еще раз публикую
пример разницы паттернов на условие IF . Все 3 назначения абсолютно одинаковы по
результату, но различаются в написании. Обратите внимание на то, как по-разному можно
было написать условие. Просто напоминаю про разные паттерны.
Обычно я использую префикс переменной DI_ - означает, что это ссылка на физический
вход ПЛК (Digital Input). Префикс DO_ - означает, что переменная ссылается на
физический выход ПЛК (Digital Output).
• rSV* - уставки для регулирования (от английского Set Value).
• DI_rTempr* - измеренное значение на входе.
• DO_xHeater* - нагревательный элемент.
Стр. 142 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
С указателями
Рассмотрим, как данный пример можно решить с помощью указателей. Возьмем "святую
троицу" ST (Структуры, Массивы, Указатели). Не случайно они использованы вместе в
одном примере - это поможет не только лучше разобраться с указателями, но и увидеть,
как это все работает вместе.
Управление нагревом - довольно однообразная операция, поэтому ее можно сделать в
цикле. Но как в цикле ссылаться на переменные или просто адреса? В некоторых языках
можно строить имя переменной из других переменных и использовать его как
переменную. Например, на РНР:
for (i =1; i > 10, i++) {
${"rHeater" + $i} = ${"rSV" + $i} > ${"rTempr" + $i}
}
Здесь видно, что можно обратиться к переменной, просто восстановив имя этой
переменной. Строку назначения читаем вот так, если, например, $i будет равно 3:
$rHeater3 = $rSV3 > $rTempr3
Предварительно назначив имена переменным, ими можно управлять из цикла. Подобной
возможности в ST нет, поэтому выход - создание массива данных с одноименными
переменными:
TYPE
stHeater: STRUCT
(* Ссылка на результат аналогового входа *)
rSensor: POINTER TO REAL;
(* Ссылка на байт, к которому привязан выход ПЛК *)
bHeater: POINTER TO BYTE;
(* Номер бита в байте выхода *)
bHeaterNum: BYTE;
(* Уставка для нагрева *)
rSV: REAL;
END_STRUCT
END_TYPE
CONFIGURATION
VAR_GLOBAL
gaHeaters: ARRAY[1..3] OF stHeater;
END_VAR
END_CONFIGURATION
Сначала определяем структуру stHeater , которая будет хранить ссылку на вход ПЛК,
датчик температуры rSensor , выход ПЛК на нагреватель bHeater и уставку rSV .
Стр. 143 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Затем объявляем глобальную переменную в CONFIGURATION . Это будет массив gaHeaters
из определенной нами структуры.
Теперь сама программа:
PROGRAM TermoReg
VAR
xInit: BOOL := FALSE;
iCount: INT;
END_VAR
IF NOT xInit THEN
MapIO();
xInit := TRUE;
END_IF
FOR iCount := 1 TO 5 DO
SET_HEATER(iCount,
(gaHeaters[iCount].rSensor^ < gaHeaters[iCount].rSV)
);
END_FOR;
END_PROGRAM
Первый блок:
IF NOT xInt THEN
MapIO();
xInit := TRUE;
END_IF
Исполняем функцию MapIO один раз - при инициализации программы или при старте
ПЛК. Здесь и делаем все картирование. Задача запуска кода в начале запуска ПЛК, но
только один раз, весьма типична. Перед вами классический пример того, как это можно
сделать.
Все привязки вынесены в функцию FUNCTION с именем MapIO . Это не обязательно должна
быть функция. Чаще я использую ACTION . В случае использования ACTION , в нем будет
доступ ко всем локальным переменным текущей программы. Функцию удобно
использовать только в том случае, если мы работаем с глобальными переменным. Можно
также использовать PROGRAM . Этот POU я использую, если инициализация программы
достаточно сложная и требует разной логики и своих локальных переменных.
Иногда привязку реально выполнить средствам IDE. Например, в Codesys 3.5 можно сразу
привязывать входы и выходы к глобальным массивам.
Вот как в функции MapIO происходит привязка:
Стр. 144 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
FUNCTION MapIO : BOOL
gaHeaters[1].rSensor
gaHeaters[1].bHeater
gaHeaters[1].bHeaterNum
gaHeaters[1].rPV
:= ADR(%ID0.0.1);
:= ADR(%QB1);
:= 0;
:= 38.5;
gaHeaters[2].rSensor
gaHeaters[2].bHeater
gaHeaters[2].bHeaterNum
gaHeaters[2].rPV
:= ADR(%ID0.0.2);
:= ADR(%QB1);
:= 1;
:= 27.0;
gaHeaters[3].rSensor
gaHeaters[3].bHeater
gaHeaters[3].bHeaterNum
gaHeaters[3].rPV
:= ADR(%ID0.0.3);
:= ADR(%QB1);
:= 2;
:= 112.0;
MapIO := TRUE;
END_FUNCTION
В функции MapIO мы осуществляем привязку при помощи метода ADR . Как входной
параметр, можно напрямую указать области памяти ПЛК ADR(%ID0.0.1) , а можно и имя
переменной ADR(gqHeater1) , если вы, например, присвоили выходу имя в конфигурации
ПЛК.
Так как мы не можем делать указатели напрямую к булевым переменным, то для
включения нагревателя сделали две переменные. Одна bHeater - указатель на байт, в
котором хранится бит, которым нужно управлять. Другая bHeaterNum - номер бита в этом
байте.
Например, возьмем ПЛК WAGO и модуль выхода 750-0536 (помечен цифрой 1).
Стр. 145 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
У него есть 8 выходов, все их адреса лежат в %QX0.* , где * это 8-15. Из мануала известно,
что биты %QX0.0 до %QX0.6 находятся в байте %QB0 , а биты %QX0.7 до %QX0.15 - в байте
%QB1 . Для полной картины байты %QB0 и %QB1 находятся в %QW0 . Это мы рассматривали,
изучая одноэлементные типы.
Допустим, нам нужно сослаться на второй выход, а это бит номер 1 байта %QB1 , так как
индексация битов начинается с 0. Значит, в привязках укажем, как байт, где находится
выход - %QB1 , а номер бита 1.
В примере выше конфигурируем выходы нагревателей, как выходы 1-3, модуля 750-0536.
Далее - блок регулирования нагрева:
FOR iCount := 1 TO 3 DO
SET_HEATER(iCount,
(gaHeaters[iCount].rSensor^ < gaHeaters[iCount].rSV)
);
END_FOR;
Здесь наглядно показано, что мы вызываем функцию для включения нагревателя и
передаем в нее условие, которое вернет TRUE или FALSE . Если температура на датчике
меньше уставки, значит TRUE , и это значение мы передадим на выход нагревателя, а если
температура больше уставки, то FALSE отключит нагреватель.
Резонный вопрос: почему функция? Почему не сделать назначение прямо в цикле? На это
есть 2 причины. Первая - я хочу продемонстрировать, как сделать ссылки на булевы
переменные, так как POINTER TO BOOL не работает и указатель должен ссылаться, как
минимум, на BYTE. Вторая причина - сделать код чище и читабельнее. В самом теле кода,
где должна содержаться бизнес-логика, оставляем только логику. По коду видно, что мы
управляем нагревателем, его индекс и на каком условии. Как происходит само назначение,
нас не волнует, тем более, что для этого потребуется несколько строк, которые могли бы
просто загромоздить основную программу.
FUNCTION SET_HEATER : BOOL
VAR_INPUT
HeaterNumber: INT; (* Номер нагревателя в массиве *)
SV: BOOL;
(* Значение для назначения *)
END_VAR
CASE gaHeaters[HeaterNumber].bHeaterNum OF
0: gaHeaters[HeaterNumber].bHeater^.0 := SV;
1: gaHeaters[HeaterNumber].bHeater^.1 := SV;
2: gaHeaters[HeaterNumber].bHeater^.2 := SV;
3: gaHeaters[HeaterNumber].bHeater^.3 := SV;
4: gaHeaters[HeaterNumber].bHeater^.4 := SV;
5: gaHeaters[HeaterNumber].bHeater^.5 := SV;
6: gaHeaters[HeaterNumber].bHeater^.6 := SV;
Стр. 146 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
7: gaHeaters[HeaterNumber].bHeater^.7 := SV;
END_CASE
SET_HEATER := TRUE;
END_FUNCTION
Не сразу понятно, что тут происходит, но давайте разберемся. Переменная
gaHeaters[HeaterNumber].bHeaterNum хранит номер бита для этого нагревателя в байте
выхода ПЛК, который привязан к gaHeaters[HeaterNumber].bHeater . Мы делаем CASE и
по номеру этого бита включаем или выключаем бит, в указанном байте.
Обратите внимание, что это одна из причин, по которой переменная массива нагревателей
сделана глобальной - чтобы можно было управлять ей из функций, не передавая их туда
как параметр. Это так же весьма распространенная техника. Подробно о ней поговорим,
изучая функции и ФБ.
Другой пример и иное решение. Фактически, мы могли сделать так:
FOR iCount := 1 TO 3 DO
SET_HEATER(iCount);
END_FOR;
А в функции:
FUNCTION SET_HEATER : BOOL
VAR_INPUT
HeaterNumber: INT; (* Номер нагревателя в массиве *)
END_VAR
VAR
SV: INT;
(* Номер бита нагревателя *)
END_VAR
SV := (
gaHeaters[HeaterNumber].rSensor^ < gaHeaters[HeaterNumber].rSV
);
gaHeaters[HeaterNumber].bHeater^ := BIT_LOAD_B(
gaHeaters[HeaterNumber].bHeater^, SV,
gaHeaters[HeaterNumber].bHeaterNum);
SET_HEATER := TRUE;
END_FUNCTION
Этот пример будет работать точно так же.
Вместо CASE здесь использована функция BIT_LOAD_B , которая изменяет указанный по
номеру бит в байте на установленное значение. Эту функцию мы создали, изучая битовый
Стр. 147 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
тип данных. Именно ее и нужно применить вместо CASE . Я же использовал CASE в первом
примере, чтобы продемонстрировать принцип назначения отдельного бита в байте.
Может показаться, что в этом примере код основной программы сделан еще чище, но это
не так. В этом случае мы вынесли бизнес-логику в функцию. Теперь при чтении
программы нужно будет открыть функцию, чтобы понять, что там происходит. В первом
случае логика оставалась в теле основной программы, и это делало ее более читабельной и
контролируемой.
Обратите внимание на правильное распределение кода - иногда это может быть важным и
сэкономит время.
Распределенный код
Казалось бы, без указателей, структур и массива кода было бы меньше. Возможно, так и
есть, в нашем конкретном примере. Подобные задачи нужно структурировать именно так.
Представьте, что алгоритм обработки каждого нагревателя составляет не одну строку, а
100 строк. Или у нас не 3, а 100 элементов массива. Тогда дополнительные определения
структур, массивов и картирование не увеличат код, а уменьшат. Причем, существенно.
Тем не менее, уменьшение кода - не самая главная причина использовать подобный
подход. В данном примере мы получаем распределенный код, по функциональным
действиям.
1. Структура позволяет собрать все переменные, относящиеся к этой функциональной
задаче, в одной переменной. Это позволяет в глобальных переменных определить
только одну переменную массива, а не множество отдельных переменных.
2. Область привязки. В нашем случае это функция MapIO . Хотя тут может быть много
строк, логики в них нет, поэтому их нужно не понимать, а только подразумевать. Мы
их написали и забыли про них, а в самой программе они не загромождают код. С
другой стороны, они позволяют упростить ту часть кода, где содержится сама
бизнес-логика приложения.
3. Сама логика приложения находится в PROGRAM , где она и должна быть. И в PROGRAM
нет ничего лишнего, так что читаться это будет очень легко.
Такой код легче поддерживать, легче читать, даже при том, что строк иногда может
получиться больше.
Допустим, нужно привязать новые переменные к указателям. Мы знаем, что все привязки
находятся в методе MapIO . Открываем метод и привязываем. Еще лучше, если нужно
узнать, что и к чему привязано. Не нужно будет искать по всем POU, где и что
Стр. 148 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
привязывается - открываем метод MapIO и сразу видим карту всех привязок. Вдобавок, как
я уже говорил, в CoDeSys 3.5 можно сделать подобные привязки прямо в конфигурации.
То же самое касается и логики - не надо искать, где мы управляем этим регулятором, а где
другим. Все делается в одном месте. Если нам нужно внести коррективы в процесс
регулирования, например, добавить гистерезис или переделать на PID-регулирование, то
исправив один блок кода, мы сразу исправляем все регуляторы.
Играет роль не только поиск кода, но и его объем. Представьте, что у нас 100 элементов
массива и для каждого нужно поменять алгоритм работы. В нашем примере это делается в
одном месте, в противном случае, пришлось бы редактировать в 100 местах.
Не определенные массивы
Следующая область использования указателей - необходимость передачи в блок массива
неопределенного размера.
Допустим, имеется ФБ, в котором есть входная переменная типа массив. Принимаемый
массив может быть разного размера [1..5] , а может быть и [1..1000] . Если вы
определите входную переменную [1..1000] , а передадите туда переменную [1..5] ,
скорее всего, при компиляции получите ошибку несоответствия типов. И наоборот.
PROGRAM
VAR
axMyArray: ARRAY[1..5] OF BOOL;
fbTest1: Test;
END_VAR
fbTest1(axIN := axMyArray);
END_PROGRAM
FUNCTION_BLOCK Test
VAR_INPUT
axIN: ARRAY[1..100] OF BOOL;
END_VAR
VAR
iCount: INT;
END_VAR
FOR iCount := 1 TO 100 DO
axIN[iCount] := TRUE;
END_FOR;
END_FUNCTION_BLOCK
Такой код не будет работать, так как axMyArray и axIN не соответствуют. Нужно, чтобы
определение переменной, как в ФБ, так и в программе, было одинаково.
Стр. 149 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Указатели могут помочь. Это будет работать не во всех реализациях и IDE, но в Codesys
это работает. Вот так можно поправить код, чтобы можно было передавать массивы
неизвестного размера:
PROGRAM
VAR
axMyArray: ARRAY[1..5] OF BOOL;
fbTest1: Test;
END_VAR
fbTest1(axIN := ADR(axMyArray), iSize := SIZEOF(axMyArray));
END_PROGRAM
FUNCTION_BLOCK Test
VAR_INPUT
axIN: POINTER TO ARRAY[1..10000] OF BOOL;
iSize: INT; (* Будет равен 5 в нашем примере *)
END_VAR
VAR
iCount: INT;
END_VAR
FOR iCount := 1 TO iSize DO
axIN^[iCount] := TRUE;
END_FOR;
END_FUNCTION_BLOCK
1. Нужно помнить, что количество байт в исходной переменной не должно совпадать с
количеством байт определенного указателя. Мы пользуемся именно этой
возможностью и, определив входную переменную, как указатель POINTER TO
ARRAY[1..10000] OF BOOL , сможем передавать в нее любой массив из булевых
значений любого размера. Если размер будет больше, чем 1..10000 , то мы не
сможем прочесть лишнее. Поэтому определять массив следует по максимуму.
2. Для того, чтобы обработать принимаемый массив, нужно знать, сколько в нем
элементов. Ведь мы определили входной массив как 1..10000 с запасом, а нам
необходимо знать точно, сколько элементов во входном массиве. Для этого нужно
передать в функциональный блок количество элементов. Можно просто передать
число iSize := 5 , если мы заранее знаем размер, а если не знаем количества
элементов, можем их посчитать iSize := SIZEOF(axMyArray) . Как считать
количество элементов массива, мы рассмотрим далее.
Считать элементы массива нужно именно в исходном массиве, а не в ссылке. Если
бы мы сделали подсчет внутри ФБ, то получили бы значение 10000 , так как именно
этот размер был определен в VAR_INPUT . Например:
Стр. 150 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
FUNCTION_BLOCK Test
VAR_INPUT
axIN: POINTER TO ARRAY[1..10000] OF BOOL;
END_VAR
VAR
iSize: INT;
iCount: INT;
END_VAR
(* Будет равен 10000, а не 5 *)
iSize := SIZEOF(axIN^);
FOR iCount := 1 TO iSize DO
axIN^[iCount] := TRUE;
END_FOR;
END_FUNCTION_BLOCK
Здесь iSize := SIZEOF(axIN^) будет равен 10000, так как после снятия
косвенности, мы получаем указанный тип ARRAY[1..10000] OF BOOL . Поэтому
нужно посчитать количество элементов до передачи массива в ФБ.
3. В цикле FOR мы используем вычисленный размер массива в TO iSize для
ограничения цикла.
4. Используем оператор разыменовывания ^ для доступа к значению axIN^[iCount] .
Sub-ranges (диапазоны)
Позволяет определить часть переменной или, другими словами, ограничить значение
переменной. Его можно определять между ключей TYPE и END_TYPE . Например:
TYPE
SubInt : INT (-10..10);
END_TYPE
Или при определении локальных переменных:
VAR
Temperature1 : INT (-50..200);
Humidity: INT (0...100) := 75;
END_VAR
Sub-range можно определить для следующих типов данных: SINT , USINT , INT , UINT ,
DINT , UDINT , BYTE , WORD , DWORD , LINT , ULINT , LWORD .
Верхние и нижние ограничения должны быть допустимым значением для выбранного
типа. Данный пример будет ошибочным, так как BYTE может быть только от 0 до 255.
Стр. 151 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR
Temperature1 : BYTE (0..300);
END_VAR
Union (Объединение)
Теперь, когда мы закончили с указателями, давайте разберем тип UNION . Данный тип не
входит в стандарт МЭК 61131-3 но он есть в CoDeSys 3.5 и заслуживает чтобы его
упомянуть.
Пример объединения из документации КДС.
TYPE name:
UNION
a : LREAL;
b : LINT;
END_UNION
END_TYPE
Осиновым правилом объединение должно являться то, что все свойства объединения,
должны иметь одинаковый размер в байтах. UNION как бы объединяет все переменные в
одну. Изменение одной переменной изменяет другую.
Рассмотрим пример:
TYPE MY_UNION:
UNION
a : BYTE;
b : SINT;
с : USINT;
END_UNION
END_TYPE
PROGRAM Test
VAR
uTest: MY_UNION;
END_VAR
uTest.a := 192; (* Или это 2#1100_0000 *)
END_PROGRAM
Мы присвоили a но и b и c тоже сменили значение. b теперь равно -64 а c равно тоже
192. Можно сказать что мы просто сделали преобразование из типа BYTE в SINT и USINT .
UNION автоматически преобразует один тип в другой внутри одного объединения, тем же
принципом что и преобразование при помощи указателей. Именно по этому размер всех
свойств UNION должен быть одинаковый. Можно сказать что все переменные внутри
Стр. 152 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
юнион ссылаются на оду и туже область памяти, и изменяя ее через одну переменную,
изменяет их все.
Вот еще один пример UNION . Часто, требуется преобразовать регистр WORD в отдельные
переменные типа BOOL . Например вы управляете ПЧВ Овен. Там есть регистр командного
слова, где каждый бит, это определенная команда для ПЧВ.
Вот такой принцип можно использовать в этом случае.
TYPE STR_BITS :
STRUCT
x1 : BIT;
x2 : BIT;
x3 : BIT;
x4 : BIT;
x5 : BIT;
x6 : BIT;
x7 : BIT;
x8 : BIT;
x9 : BIT;
x10 : BIT;
x11 : BIT;
x12 : BIT;
x13 : BIT;
x14 : BIT;
x15 : BIT;
x16 : BIT;
END_STRUCT
END_TYPE
TYPE UNION_BITS_WORD :
UNION
strBits : STR_BITS;
wWord : WORD;
END_UNION
END_TYPE
PROGRAM Test
VAR
uTest: UNION_BITS_WORD;
stBits: STR_BITS;
END_VAR
stBits.x1 := 1;
stBits.x2 := 1;
uTest.strBits := stBits;
END_PROGRAM
В этом примере uTest.wWord будет равно 3. И наоборот, если мы присвоим значение
uTest.wWord , то изменится uTest.strBits .
Стр. 153 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Array (массивы)
Этот тип данных нужно понять очень хорошо. Это то преимущество ST, которое
невозможно использовать в других языках МЭК 61131-3. Считаю, что бессмысленно
изучать ST, не разобравшись с массивами.
В графических языках не получится работать эффективно с массивами, проходить их
циклами и обрабатывать данные. Поэтому начнем рассматривать массивы с простейших
примеров, переходя и заканчивая сложными.
Определение переменной типа "массив"
Определим массив следующим образом.
VAR
a : ARRAY[1..4] OF INT;
b : ARRAY[-10..20] OF BOOL;
c : ARRAY[1..c_MAX_GH] OF Greenhouse;
l : ARRAY[Svetofor#green..Svetofor#red] OF BOOL;
END_VAR
VAR CONSTANT
c_MAX_GH: INT := 5;
END_VAR
Определять любое начало и конец индексации не обязательно с нуля или единицы, здесь
могут быть и отрицательные числа. Допустимо использовать константы, как в случае с
переменной с , или перечисления, как в случае с переменной l .
Например, [10..20] инициирует массив из 10 элементов, начиная с индекса 11.
Обратите внимание на переменную c . Здесь начинается чудо возможностей, которое
случается, когда мы соединяем наши знания. Берем определенную нами ранее структуру
Greenhouse и создаем массив. Представьте, что у вас модульный тепличный комплекс.
Один модуль - это одна теплица и один элемент массива типа Greenhouse . Вы можете
сделать алгоритм управление модулем, а затем, чтобы добавить управления новым
модулем, нужно всего лишь добавить в массив новый элемент.
Пример использования массива в коде
VAR
a : ARRAY [1..4] OF INT;
b : ARRAY [0..20] OF BOOL;
c : ARRAY [1..3] OF Greenhouse;
counter : INT;
END_VAR
Стр. 154 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
a[1] := 40;
b[0] := TRUE;
FOR counter := 1 TO 3 BY 1 DO
IF c[counter].Temperature > 32 THEN
c[counter].Fan := TRUE;
END_IF
END_FOR
В данном примере мы проходим циклом по всем теплицам и в тех из них, где температура
больше 32 градусов, включаем вентиляцию. Уверен, вы поняли идею и масштаб
возможностей.
Массивы так же могут быть многомерными. Для объявления многомерного массива,
назначаем индексы на второй уровень через запятую. Например:
VAR
a : ARRAY [1..2, 1..3] OF BOOL;
END_VAR
a[1, 2] := TRUE;
a[2, 3] := TRUE;
Сначала инициализируется массив с двумя элементами, с 1 по 2. Затем в каждом
инициированном элементе этого массива инициализируется другой массив, с 1 до 3, cо
значением типа BOOL . По умолчанию стоит FALSE для каждого элемента массива.
Представление этого массива после назначения в примере в виде JSON было бы
следующим:
[
[false, true, false],
[false, false, true]
]
Кратко еще раз:
// Чтение элемента одномерного массива
a := b[2];
// Чтение элемента многомерного массива
a := b[3, 4];
// Чтение свойств элемента определенной нами структуры в массиве
a := b[1].Temperature;
// Назначение элемента одномерного массива
b[2] := TRUE;
Стр. 155 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
// Назначение элемента многомерного массива
b[3, 4] := TRUE;
Важно!
Если вы что-то не поняли о массивах на этом этапе, прочтите еще раз
вдумчиво и, пожалуйста, не двигайтесь дальше, пока не разберетесь
как следует.
Присвоение данных массиву
Одно дело - объявить массив, другое - заполнить его данными. Назначить данные в массив
можно, как в коде программы, так и при инициализации. Предыдущие примеры
продемонстрировали, как назначать значения в коде - как для простых, так и для
многомерных массивов, для массивов созданных нами структур. Приведу пример
назначения элементов при инициализации массива.
Синтаксис может разниться от одной среды разработке к другой. В первом примере 2
разных синтаксиса, но в последствии будет приведен синтаксис CoDeSys 3.5
Назначить всем 2-м элементам массива значение TRUE
a : ARRAY [1..2] OF BOOL := [TRUE, TRUE]; (* Codesys 3.5 *)
a : ARRAY [1..2] OF BOOL := TRUE, TRUE;
(* Codesys 2.3 *)
[true, true] // Представление результата в виде JSON объекта
Частичная инициализация. Можно назначить только первые элементы, и не обязательно
назначать все. Указанные элементы примут значение по умолчанию.
e : ARRAY [1..5] OF BOOL := [TRUE, TRUE];
[true, true, false, false, false]
Групповые назначения. Например, нужно назначить первые 2 значения TRUE , а следующие
3 значения FALSE . Конечно, можно и не назначать 3 значения в FALSE , как уже известно из
примера вверху. Ведь по умолчанию все значения и так создаются FALSE , но я это привел
для наглядности.
2(TRUE) значит два значения TRUE , и 3(FALSE) значит 3 значения FALSE .
b : ARRAY [1..5] OF BOOL := [2(TRUE), 3(FALSE)];
Стр. 156 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
[true, true, false, false, false]
Двумерный массив. Назначаем 2 значения на первый элемент массива и 2 на второй.
c : ARRAY [1..2, 1..2] OF INT := [20, 15, 17, 43];
[
[20, 15],
[17, 43]
]
Еще один многомерный массив, но с групповыми назначениями. В этом случае назначаем
массив из значений от 1 до 10 на первые 5 элементов. Следующие 4 элемента будут 4
пятерки и 3 двойки. И последний элемент - все 10 двоек. Все, что не назначено, примет
значение 0.
d : ARRAY [1..10, 1..10] OF INT := [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 5(5), 3(2), 0, 0, 10(2)
];
[
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
[5, 5, 5, 5, 5, 2, 2, 2, 0, 0],
[2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
]
Вот и десерт. Инициализация массива из ранее определенных нами структур. В данном
примере каждый элемент массива назначается отдельно, через запятую, с разными
данными. Не обязательно объявлять все переменные структуры, можно выборочно - только
те, что нужны:
f : ARRAY [1..2] OF Greenhouse := [
(Temperature := 30, Humidity := 40),
(Temperature := 31, Humidity := 45, Fan := TRUE)
];
[
{
Temperature : 30,
Humidity : 40,
Fan : false,
Windows : false
},
{
Temperature : 31,
Humidity : 45,
Fan : true,
Windows : false
}
Стр. 157 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
]
Теперь - инициализация массива из определенных нами структур, с использованием
группового назначения:
g : ARRAY [1..10] OF Greenhouse := [ 10((Fan := TRUE, Windows := TRUE)) ];
[
{
Temperature : 0,
Humidity : 0,
Fan : true,
Windows : true
},
// и так 10 раз ...
]
Примеры функций для работы с массивами
К сожалению, стандарт не предусматривает никаких функций для работы с массивами,
поэтому вы не найдете ничего ни в одной IDE. Это связано со строгой типизацией данных
элементов массива. Мы попытаемся создать универсальные функции, которые смогут
работать с массивами любых типов ANY_INT и ANY_BIT .
Одновременно закрепим тему с указателями. Для создания универсальных функций, как
входной элемент, мы будем использовать указатель на массив из самых больших данных
REAL, то есть, по 4 байт на элемент. И уже внутри, при помощи тех же указателей,
преобразовывать их обратно в нужный формат.
Вычисление максимального значения в массиве
Допустим, имеется массив значений и нужно найти большее значение.
FUNCTION ARRAY_MAX : INT
VAR_INPUT
ar := ARRAY[0..4] OF INT := [12, 34, 5, 8, 67];
END_VAR
FOR i := 0 TO 4 BY 1 DO
ARRAY_MAX := MAX(ARRAY_MAX, ar[i]);
END_FOR
END_FUNCTION
Данный пример очень простой, но при этом ограниченный. Эта функция будет работать с
массивами только одного типа INT и только определенного количества элементов - 5.
Стр. 158 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Это подойдет для конкретной задачи, но не для библиотеки универсальных функций. В
следующих примерах рассмотрим, как создавать универсальные функции, в которые
можно подставлять массивы разных типов и размеров.
Определение количества элементов массива
Напишем универсальную функцию, которая возвращает число равное количеству
элементов массива. Она потребуется во всех остальных наших функциях.
Первое, что нужно сделать - определить перечисление ARR_TYPE , определяющее тип
данных в элементах массива, чтобы знать, сколько занято байт на один элемент массива:
TYPE ARR_TYPE (
ARR_TYPE_BOOL,
ARR_TYPE_BYTE,
ARR_TYPE_WORD,
ARR_TYPE_DWORD,
ARR_TYPE_REAL,
ARR_TYPE_LREAL
)
END_TYPE
Это не окончательная версия, сюда можно добавить пропущенные типы, с которыми вы
работаете.
Теперь сама функция, подсчитывающая количество элементов массива:
FUNCTION ARRAY_COUNT : UINT
VAR_INPUT
ARR_SIZE: UINT;
(* Размер массива *)
ARR_TYPE: ARR_TYPE; (* Тип данных элементов массива *)
END_VAR
CASE ARR_TYPE OF
ARR_TYPE.ARR_TYPE_BOOL,
ARR_TYPE.ARR_TYPE_BYTE: ARRAY_COUNT := ARR_SIZE / 1;
ARR_TYPE.ARR_TYPE_WORD: ARRAY_COUNT := ARR_SIZE / 2;
ARR_TYPE.ARR_TYPE_DWORD,
ARR_TYPE.ARR_TYPE_REAL: ARRAY_COUNT := ARR_SIZE / 4;
ARR_TYPE.ARR_TYPE_LREAL: ARRAY_COUNT := ARR_SIZE / 8;
END_CASE;
END_FUNCTION
Чтобы воспользоваться этой функцией, не нужно передавать в нее сам массив. Для расчета
количества элементов требуется знать общий размер массива и размер одного элемента
массива. Теперь в коде мы можем вызвать эту функцию:
Стр. 159 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
PROGRAM PLC_PRG
VAR
arTest: ARRAY[1..3] OF WORD := [10, 20, 30]; (* Массив *)
uiNum: UINT; (* Количество элементов в массиве *)
END_VAR
(* будет равен 3 *)
uiNum := ARRAY_COUNT(SIZEOF(arTest), ARR_TYPE.ARR_TYPE_WORD);
END_PROGRAM
Для определения количества элементов в массиве, первым параметром передаем общий
размер переменной массива в байтах, используя функцию SIZEOF() . Эта функция
возвращает количество BYTE , которые переменная занимает в памяти ПЛК.
Например:
SIZEOF(BOOL#0); // = 1
SIZEOF(BYTE#0); // = 1
SIZEOF(WORD#0); // = 2
SIZEOF(DWORD#0); // = 4
SIZEOF(LREAL#0); // = 8
Переменные типа BOOL , как и BYTE , считаются за 1 единицу.
Когда мы создаем массив, то фиксированное количество байт отводится для него в памяти
в соответствии с типом данных и количеством элементов массива. Рассмотрим массивы с
10-ю элементами с теми же типами, что и в предыдущем примере. Разумеется, размер
будет таким же, что и в предыдущем примере, но умноженный на 10:
VAR
aBool: ARRAY[1..10] OF BOOL;
aByte: ARRAY[1..10] OF BYTE;
aWord: ARRAY[1..10] OF WORD;
aDword: ARRAY[1..10] OF DWORD;
aLreal: ARRAY[1..10] OF LREAL;
END_VAR
SIZEOF(aBool); // = 10 байт
SIZEOF(aByte); // = 10 байт
SIZEOF(aWord); // = 20 байт
SIZEOF(aDword); // = 40 байт
SIZEOF(aLreal); // = 80 байт
Итак, для получения количества элементов массива нам нужно разделить общий размер
переменной на размер одного элемента массива.
VAR
aBool:
aByte:
aWord:
Стр. 160 из 298
ARRAY[1..10] OF BOOL;
ARRAY[1..10] OF BYTE;
ARRAY[1..10] OF WORD;
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
aDword: ARRAY[1..10] OF DWORD;
aLreal: ARRAY[1..10] OF LREAL;
as: ARRAY[1..5] OF INT;
END_VAR
(* Использовать расчетное значение размера одного элемента *)
as[1] = SIZEOF(aBool) / SIZEOF(BOOL#0); (* = 10 *)
as[2] = SIZEOF(aByte) / SIZEOF(BYTE#0); (* = 10 *)
as[3] = SIZEOF(aWord) / SIZEOF(WORD#0); (* = 10 *)
as[4] = SIZEOF(aDword) / SIZEOF(DWORD#0); (* = 10 *)
as[5] = SIZEOF(aLreal) / SIZEOF(LREAL#0); (* = 10 *)
(* Использовать константу размера одного элемента *)
as[1] = SIZEOF(aBool) / 1; (* = 10 *)
as[2] = SIZEOF(aByte) / 1; (* = 10 *)
as[3] = SIZEOF(aWord) / 2; (* = 10 *)
as[4] = SIZEOF(aDword) / 4; (* = 10 *)
as[5] = SIZEOF(aLreal) / 8; (* = 10 *)
Для того, чтобы получить количество элементов массива в нашей функции ARRAY_COUNT ,
общего размера массива недостаточно. Нужно знать, какого типа элементы в этом массиве.
Именно поэтому вторым параметром передаем перечисление ARR_TYPE.ARR_TYPE_WORD ,
которое удобно и читаемо определяет, какой тип у массива. Используя это перечисление,
мы знаем, какую константу использовать. Обратите внимание, что синтаксис вызова
перечисления может быть разным. В примере, как это работает в Codesys 3.5. В Codesys
2.3, можно было исплоьзовать просто значение перечисления ARR_TYPE_WORD .
Это нужно хорошо понять, так как данная технология лежит в основе других функций,
которые мы рассмотрим.
Получение суммы значений массива
FUNCTION ARRAY_SUM : REAL
VAR_INPUT
ARR: POINTER TO ARRAY[0..38000] OF REAL; (* Входной массив *)
ARR_SIZE: UINT; (* Размер массива *)
ARR_TYPE: ARR_TYPE; (* Тип массива *)
END_VAR
VAR
uiNum: UINT; (* Количество элементов массива *)
uiCount: UINT; (* Число для цикла *)
aByte: POINTER TO ARRAY[0..38000] OF BYTE;
aWord: POINTER TO ARRAY[0..38000] OF WORD;
aDword: POINTER TO ARRAY[0..38000] OF DWORD;
aReal: POINTER TO ARRAY[0..38000] OF REAL;
END_VAR
uiNum := ARRAY_COUNT(ARR_SIZE, ARR_TYPE);
Стр. 161 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
CASE ARR_TYPE OF
ARR_TYPE.ARR_TYPE_BYTE: aByte := ADR(ARR^);
ARR_TYPE.ARR_TYPE_WORD: aWord := ADR(ARR^);
ARR_TYPE.ARR_TYPE_DWORD: aDword := ADR(ARR^);
ARR_TYPE.ARR_TYPE_REAL: aReal := ADR(ARR^);
END_CASE;
FOR uiCount := 0 TO uiNum - 1 DO
CASE ARR_TYPE OF
ARR_TYPE.ARR_TYPE_BYTE:
ARRAY_SUM := (ARRAY_SUM + BYTE_TO_REAL(aByte^[uiCount]));
ARR_TYPE.ARR_TYPE_WORD:
ARRAY_SUM := (ARRAY_SUM + WORD_TO_REAL(aWord^[uiCount]));
ARR_TYPE.ARR_TYPE_DWORD:
ARRAY_SUM := (ARRAY_SUM + DWORD_TO_REAL(aDword^[uiCount]));
ARR_TYPE.ARR_TYPE_REAL:
ARRAY_SUM := (ARRAY_SUM + aReal^[uiCount]);
END_CASE;
END_FOR;
END_FUNCTION
Сложность универсальности данной функции заключается в том, что не известно, какой
тип данных определен как элемент массива. Пришлось писать отдельную функцию на
каждый тип, но стоит задача сделать универсальную функцию, которая может обработать
массив с любыми данными типа ANY_INT и ANY_BIT , и с любым количеством элементов.
Для этого снова используем наше перечисление ARR_TYPE , которое и позволит определить,
в какие данные преобразовать массив, так как входной массив всегда будет содержать
элементы типа REAL . Для этого используем указатели. Это еще один пример того, как
указатели просто ссылаются на адрес первого байта и могут удобно преобразовывать
данные. В теории, входную переменную ARR: POINTER TO ARRAY[0..38000] OF REAL
можно было бы заменить на ARR: POINTER TO BYTE так как все, что нам нужно - это
просто адрес первого байта входящего массива - ведь мы все равно будем его
восстанавливать через указатели.
Это мы и делаем в первом CASE . Преобразуем входной массив, который всегда
размечается по 4 байта на элемент, в массив с нужным размером и типом элемента.
Здесь мы уже применили нашу функцию ARRAY_COUNT , чтобы получить количество
элементов массива в переменную uiNum . Теперь, когда мы знаем количество элементов в
массиве, можем пройти по массиву циклом FOR . В цикле, используя такой же CASE , мы
можем получить корректные данные текущего элемента массива.
В данном примере обрабатываем всего лишь несколько типов элементов массива, чтобы не
делать пример слишком большим. Вы можете сами добавить сюда обработку других типов
ANY_INT или ANY_BIT .
Стр. 162 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
И потом в программе:
PROGRAM PLC_PRG
VAR
arTest: ARRAY[1..3] OF WORD := [10, 20, -30]; (* Массив *)
uiSum: REAL; (* Сумма значений массива *)
END_VAR
(* uiSum Будет равно 0 *)
uiSum := ARRAY_SUM(ADR(arTest), SIZEOF(arTest), ARR_TYPE.ARR_TYPE_WORD);
END_PROGRAM
Получение среднего арифметического значения массива
FUNCTION ARRAY_AVG : REAL
VAR_INPUT
ARR: POINTER TO ARRAY[0..38000] OF REAL; (* Входной массив *)
ARR_SIZE: INT; (* Размер массива *)
ARR_TYPE: ARR_TYPE; (* Тип массива *)
END_VAR
ARRAY_AVG := ARRAY_SUM(ARR, ARR_SIZE, ARR_TYPE) /
ARRAY_COUNT(ARR_SIZE, ARR_TYPE);
END_FUNCTION
Имея 2 функции - получения количества элементов массива и общей суммы - мы можем
легко рассчитать среднее значение элементов массива.
PROGRAM PLC_PRG
VAR
arTest: ARRAY[1..3] OF WORD := [10, 20, 30]; (* Массив *)
uiAvg: REAL; (* Среднее значение элементов массива *)
END_VAR
(* uiSum Будет равно 20 *)
uiAvg := ARRAY_AVG(ADR(arTest), SIZEOF(arTest), ARR_TYPE_WORD);
END_PROGRAM
Получение минимального или максимального значения в массиве
FUNCTION ARRAY_MIN : REAL
VAR_INPUT
ARR: POINTER TO ARRAY[0..38000] OF REAL; (* Входной массив *)
ARR_SIZE: INT; (* Размер массива *)
ARR_TYPE: ARRAY_TYPE; (* Тип массива *)
END_VAR
VAR
uiNum: UINT; (* Количество элементов массива *)
uiCount: UINT; (* Число для цикла *)
rTemp: REAL; (* Временная переменная для хранения *)
Стр. 163 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
aByte: POINTER TO ARRAY[0..38000] OF BYTE;
aWord: POINTER TO ARRAY[0..38000] OF WORD;
aDword: POINTER TO ARRAY[0..38000] OF DWORD;
aReal: POINTER TO ARRAY[0..38000] OF REAL;
END_VAR
uiNum := ARRAY_COUNT(ARR_SIZE, ARR_TYPE);
CASE ARR_TYPE OF
ARR_TYPE_BYTE: aByte := ADR(ARR^);
ARR_TYPE_WORD: aWord := ADR(ARR^);
ARR_TYPE_DWORD: aDword := ADR(ARR^);
ARR_TYPE_REAL: aReal := ADR(ARR^);
END_CASE;
FOR uiCount := 0 TO uiNum - 1 DO
CASE ARR_TYPE OF
ARR_TYPE_WORD: rTemp := WORD_TO_REAL(aWord^[uiCount]);
ARR_TYPE_BYTE: rTemp := BYTE_TO_REAL(aByte^[uiCount]);
ARR_TYPE_DWORD: rTemp := DWORD_TO_REAL(aDword^[uiCount]);
ARR_TYPE_REAL: rTemp := aReal^[uiCount];
END_CASE;
ARRAY_MIN := MIN(ARRAY_MIN, rTemp);
END_FOR;
END_FUNCTION
Вот так можно получить минимальное значение. Если требуется получить максимальное
значение, нужно создать такую же функцию с именем ARRAY_MAX и просто поменять:
ARRAY_MIN := MIN(ARRAY_MIN, rTemp);
На:
ARRAY_MAX := MAX(ARRAY_MAX, rTemp);
Сортировка массива
Посмотрите пример функции сортировки элементов массива по восходящей ASC (от
меньшего к большему).
FUNCTION ARRAY_SORT : BOOL
VAR_INPUT
ARR: POINTER TO ARRAY[0..38000] OF REAL; (* Входной массив *)
ARR_SIZE: INT;
(* Размер массива *)
ARR_TYPE: ARRAY_TYPE; (* Тип массива *)
END_VAR
VAR
uiNum:
UINT; (* Количество элементов массива *)
uiCount: UINT; (* Число для цикла *)
uiCount2: UINT; (* Число для цикла 2 *)
Стр. 164 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
rTemp:
REAL; (* Временная переменная для хранения *)
aByte: POINTER TO ARRAY[0..38000] OF BYTE;
aWord: POINTER TO ARRAY[0..38000] OF WORD;
aDword: POINTER TO ARRAY[0..38000] OF DWORD;
aReal: POINTER TO ARRAY[0..38000] OF REAL;
END_VAR
uiNum := ARRAY_COUNT(ARR_SIZE, ARR_TYPE);
CASE ARR_TYPE OF
ARR_TYPE_BYTE: aByte := ADR(ARR^);
ARR_TYPE_WORD: aWord := ADR(ARR^);
ARR_TYPE_DWORD: aDword := ADR(ARR^);
ARR_TYPE_REAL: aReal := ADR(ARR^);
END_CASE;
(* цикл для каждого элемента массива *)
FOR uiCount := 0 TO uiNum - 1 DO
(* цикл для сравнения каждого элемента массива с каждым
элементом массива *)
FOR uiCount2 := 0 TO uiNum - 1 DO
CASE ARR_TYPE OF
ARR_TYPE_BYTE:
IF(aByte^[uiCount2] > aByte^[uiCount]) THEN
rTemp := BYTE_TO_REAL(aByte[uiCount]);
aByte^[uiCount] := aByte^[uiCount2];
aByte^[uiCount2] := REAL_TO_BYTE(rTemp);
END_IF
ARR_TYPE_WORD:
IF(aWord^[uiCount2] > aWord^[uiCount]) THEN
rTemp := WORD_TO_REAL(aWord[uiCount]);
aWord^[uiCount] := aWord^[uiCount2];
aWord^[uiCount2] := REAL_TO_WORD(rTemp);
END_IF
ARR_TYPE_DWORD:
IF(aDword^[uiCount2] > aDword^[uiCount]) THEN
rTemp := DWORD_TO_REAL(aDword[uiCount]);
aDword^[uiCount] := aDword^[uiCount2];
aDword^[uiCount2] := REAL_TO_DWORD(rTemp);
END_IF
ARR_TYPE_REAL:
IF(aReal^[uiCount2] > aReal^[uiCount]) THEN
rTemp := aReal[uiCount];
aReal^[uiCount] := aReal^[uiCount2];
aReal^[uiCount2] := rTemp;
END_IF
END_CASE;
END_FOR
END_FOR
END_FUNCTION
Разберем основной принцип.
Стр. 165 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Функция возвращает BOOL , потому что для нас не важен результат того, что она нам
вернёт. Так как массив передается по ссылке, то применив к нему изменения внутри
функции, получим применение к самому массиву, и нам не нужно его возвращать.
Разберем основной принцип сортировки. Он происходит в этом участке кода:
IF(aReal^[uiCount2] < aReal^[uiCount]) THEN
rTemp := aReal[uiCount];
aReal^[uiCount] := aReal^[uiCount2];
aReal^[uiCount2] := rTemp;
END_IF
Так как не известен конечный тип данных элемента массива, применяем этот алгоритм к
разным типам. Как и в предыдущей функции, сначала преобразуем входной массив в
массив с исходными данными, а потом используем этот новый преобразованный массив
для сортировки.
Хорошей практикой будет сортировка массива по импульсу, чтобы сделать сортировку
только один раз и не нагружать процессор постоянно.
Разбиение строки в массив по символу
Не часто, но появляется задача: превратить строку в массив. Допустим, мы получаем
строку 12, 34, 56 или 1001:Message:notice . Нужно вычленить отдельные элементы
путем разбиения строки в массив. Возможно, это ближе к функциям обработки строк, но,
мне думается, этому примеру место здесь.
При разбиении строки в массив всегда получится массив из строк, так что создадим
функцию, которая возвращает подобный тип данных:
FUNCTION SPLIT : ARRAY[0..255] OF STRING[250]
VAR_INPUT
STR: STRING[250]; (* Строка для анализа *)
CHAR: STRING[1];
(* Символ для разделения *)
END_VAR
VAR
iPos: INT;
sTest: STRING[250];
iIndex: INT;
END_VAR
sTest := STR;
REPEAT
iPos := FIND(sTest, CHAR);
IF iPos = 0 THEN
SPLIT[iIndex] := sTest;
ELSE
Стр. 166 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
SPLIT[iIndex] := LEFT(sTest, iPos - 1);
sTest := RIGHT(sTest, LEN(sTest) - iPos);
END_IF;
iIndex := iIndex + 1;
UNTIL (iPos = 0)
END_REPEAT;
END_FUNCTION
Теперь в программе:
PROGRAM PLC_PRG
VAR
arsTopic: ARRAY[0..255] OF STRING[250];
END_VAR
arsTopic := SPLIT('10, 25, 17', ', '); (* ['10', '25', '17'] *)
END_PROGRAM
На выходе получим массив ['10', '25', '17'] . Обратите внимание, что все элементы
этого массива будут строки, и если мы захотим использовать их для вычисления, нужно
будет преобразовать в число STRING_TO_INT .
Категория 4: Константы литералы
Это тип переменных, которые можно использовать в программе без предварительного их
определения. В отличие от одноэлементных переменных, которые мы тоже можем
использовать в программе без предварительного определения. Например:
IF %IX0.1.2 THEN
%QX0.0.1 := TRUE;
END_IF
Константы не могут менять своего значения на протяжении всей программы.
Символьные константы
Символьные константы нужны, когда мы напрямую используем значение переменной в
коде программы.
IF wTemp > 30.5 THEN
// Do something
END_IF
Здесь 30.5 является символьной константой. Вот примеры:
1. Цифровая - -12 , 0 , 123_456 , +986
Стр. 167 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
2. С плавающей точкой - -12.0 , 0.0 , 0.4560 , 3.14159_26
3. С плавающей точкой и экспонентой - -1.34E-12 или -1.34e-12
4. Однобайтная строка - 'This is text'
5. Двухбайтная строка - "Это текст"
6. Булева - TRUE и FALSE
Знак _ в числовых константах допустим в любом месте и используется как разделитель
для улучшения читабельности числа. Например, 100_000_000 читается намного проще,
чем 100000000 .
IF SMS.Text = "START HEATER" THEN
xHeater := BOOL#1;
rTemperature := 24.5;
END_IF
Это пример, где использованы 2 символьные константы "START HEATER" и 24.5 , и одна
типизированная.
Типизированные константы
Вы уже видели примеры подобных констант, например, времени T#2s500ms .
VAR
fbTon1: TON;
END_VAR
fbTon1(EN := TRUE, PT := T#20s);
Вот пример, как можно использовать константу времени T#20s в программе, без
необходимости определения переменной.
Подобным образом можно использовать не только время, но практически любой тип
ANY_ELEMENTARY .
Типизированная константа имеет конструкцию [Тип]#[Значение] , например:
BOOL#TRUE
BOOL#0
BYTE#255
INT#-1465
REAL#50
LREAL#-1.34E-12
WORD#2345
STRING#'OK'
WSTRING#"OK"
TOD#12:30:55
D#2008-01-31
Стр. 168 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
И так далее.
Можно обращаться и к элементам перечисления. Например, есть перечисление:
TYPE
enSvetofor : (Red, Green, Yellow := 5)
END_TYPE
Обратиться к значениям без предварительного определения можно так:
(* В CoDeSys 2.3 *)
enSvetofor#Red;
// 0
enSvetofor#Green;
// 1
enSvetofor#Yellow; // 5
(* В CoDeSys 3.5 *)
enSvetofor.Red;
// 0
enSvetofor.Green;
// 1
enSvetofor.Yellow; // 5
Константы числового представления
Числовой ввод можно использовать в разных представлениях или, другими словами,
разных системах исчисления. Вот пример присвоения одной переменной одного и того же
числа 14 в разных числовых представлениях.
iNum := 14;
(* В десятичном формате *)
iNum := 2#0000_1110; (* В двоичном формате *)
iNum := 8#16;
(* В восьмеричном формате *)
iNum := 16#E;
(* В шестнадцатеричном формате *)
Отличие констант числового представления от типизированных констант в том, что их
можно применять ко всем типам ANY_INT :
VAR
iInt:
INT;
bByte: BYTE;
siSint: SINT;
END_VAR
iInt
bByte
siSint
:= 2#0000_1110;
:= 2#0000_1110;
:= 2#0000_1110;
iInt
bByte
siSint
:= 8#16;
:= 8#16;
:= 8#16;
Можно использовать константы числового представления в типизированных константах:
Стр. 169 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
INT#16#EFA3
WORD#2#0000_0100_0110_1110
Стр. 170 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Функции и ФБ
Разница между функциями и ФБ
Необходимо четко понять разницу между функциями и ФБ (Функциональный Блок). В
начале программирования на ST эта разница может быть не очевидна из-за синтаксической
схожести данных понятий. Вот чем они отличаются:
1. ФБ нужно предварительно объявлять в области объявления переменных, а функцию
- нет.
2. При вызове функции не требуется привязывать входные переменные к именам
входных параметров, как в ФБ: TON1(IN := TRUE, PT := T#2s) , достаточно просто
передать их в той же последовательности, в какой они определены самой функцией.
Если бы пример выше был вызовом функции, а не ФБ, то это выглядело бы так
TON1(TRUE, T#2s) .
3. Так как мы не определяем функцию в области определения переменных, как делаем
это с ФБ, в памяти для нее не резервируется место, а значит, экономятся ресурсы
ПЛК.
4. Функция возвращает значение, а ФБ присваивает его выходным переменным и
ничего не возвращает, поэтому функция может вернуть только одно значение (хотя и
присвоить нескольким), а ФБ - несколько.
5. Функции вызываются синхронно, а ФБ - асинхронно. Или, иными словами, функции
вызываются с блокировкой, а ФБ - без. Это значит, что пока функция не завершит
свою работу, программа не перейдет к исполнению следующей строки, в отличие от
ФБ. Подробнее об этом поговорим позже.
6. Во время отладки можно следить за внутренними переменными конкретного
экземпляра ФБ, а следить за функцией нельзя.
Как выбрать между двумя вариантами?
Итак, вы начинаете создавать свои собственные POU и перед вами встает вопрос: что
именно нужно создавать в конкретном случае, функцию или ФБ?
Стр. 171 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Исходя из информации, описанной выше, для выбора - что использовать при написании
своей библиотеки, у меня есть только одно правило: везде, где только возможно,
использовать функцию. Использовать ФБ только там, где невозможно использовать
функцию.
Чтобы следовать этому правилу, нужно понимать лишь разницу, описанную в пункте 1
списка.
Особенность ФБ
Мы определяем ФБ или создаем его экземпляр в разделе объявления локальных
переменных программы. Например, создадим экземпляр таймера TON .
VAR
ton1 : TON;
END_VAR
В памяти контроллера отделяется место, где от цикла к циклу ПЛК хранятся все входные,
выходные и локальные переменные этого ФБ, что называется "экземпляр ФБ". Можно
определить несколько экземпляров одного и того же ФБ. Этот экземпляр привязывается к
локальной переменной программы, которая называется "идентификатор ФБ". В нашем
примере переменная ton1 - идентификатор экземпляра таймера TON .
Это значит, что при переходе от одного цикла исполнения программы к другому, все
локальные для ФБ переменные сохраняют свои значения. Например, для счетчика
сохраняется счет, для таймера - время до окончания.
Особенность Функции
Функция ничего не запоминает. Она запускается один раз, и при следующем цикле
программы все локальные для функции переменные сбрасываются.
Вывод
Итак, есть только 2 причины, по которым вы должны выбрать ФБ, а не функцию:
1. Если внутри ФБ вы собираетесь использовать любые другие ФБ, такие как,
например, таймеры, счетчики или триггеры.
2. Если вы хотите, чтобы какая-то локальная переменная ФБ сохраняла свое значение
от одного цикла программы к другому.
Стр. 172 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Например, вы пишите свой таймер или счетчик. Несмотря на то, что не используете
внутри других ФБ, вам нужно запоминать текущее состояние вашего таймера от
одного цикла к другому.
Вы хотите выделить передний или задний фронт любой локальной или входной
переменной. Вам нужно запомнить состояние переменной из прошлого цикла, ведь
не зря R_TRIG и F_TRIG - функциональные блоки, а не функции.
Во всех других случаях используйте функцию.
Функциональный блок (ФБ)
Синтаксис
ФБ создается при помощи ключевых слов FUNCTION_BLOCK и END_FUNCTION_BLOCK .
Пример:
FUNCTION_BLOCK TimeToWork
VAR_INPUT
IN: BOOL; (* Включение блока в работу *)
PT: TOD; (* Время включения *)
END_VAR
VAR_OUTPUT
Q: BOOL; (* Выход блока *)
END_VAR
VAR
curTod: TOD; (* Текущее время дня *)
END_VAR
curTod := DT_TO_TOD(glbDataTime);
Q := (PT > curTod);
END_FUNCTION_BLOCK
Блок, который включает выход Q , если установленное время дня PT больше, чем текущее
время дня.
Для ФБ можно использовать области видимости VAR_INPUT , VAR_OUTPUT , VAR и не
приведенное в примере VAR_IN_OUT . (См. Переменные).
Расширение ФБ
Расширение позволяет, в дополнение к стандартным элементам (переменным), таким как:
• VAR_INPUT
• VAR_OUTPUT
• VAR_IN_OUT
Стр. 173 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• VAR
Создавать дополнительные элементы блока, такие как:
• Действия - Action
• Методы - Methods
• Свойства - Properties
• Переходы - Transition
Расширение ФБ основано на концепте наследственности объектно-ориентированного
программирования. Один ФБ, расширяя другой ФБ, передает ему свои элементы в
дополнение к собственным элементам блока.
Заметка:
Расширения ФБ, описанные в этой главе, ссылаются больше на то как
это реализовано в Codesys 3.5
Расширение ФБ значит:
1. Наследуемый ФБ содержит доступ ко всем элементам родительского (базового) ФБ.
Можно использовать базовый ФБ в любом контексте, где подразумевается
использование любого ФБ.
2. Наследуемый ФБ может перезаписывать элементы, определенные в родительском
ФБ. Это значит, что можно переопределить метод, действие, свойство с
одноименным названием, входными и выходными переменными, как это определено
в родительском блоке.
3. Наследуемый блок не может иметь те же самые имена входных и выходных
переменных.
4. Переменные и методы родительского блока можно напрямую получать через
указатель SUPER .
Наследственность
Стандарт предусматривает возможность расширения одного ФБ другим. Для этого
используется ключевое слово EXTENDS в определении ФБ.
FUNCTION_BLOCK AnotherFB
FUNCTION_BLOCK TimeToWork EXTENDS AnotherFB
Стр. 174 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Используя понятие наследственности, удобно рассматривать интерфейсы. Смотрите на
интерфейс, как на ФБ, который создан только для того, чтобы быть родительским и
никогда не использоваться в программе напрямую.
Об интерфейсах важно знать следующее:
• ФБ можно расширить не одним, а несколькими интерфейсами.
• Интерфейс можно расширять другим интерфейсом.
• К интерфейсу можно добавить методы, причем, только их определения, входные и
выходные переменные, но не реализацию самого метода.
• Для того, чтобы использовать интерфейс в программе, нужно, чтобы имелся ФБ,
который расширяется от этого интерфейса.
• Это значит, что ФБ, расширяемый от интерфейса, должен иметь все методы и
атрибуты, определенные в интерфейсе. Входы, выходы и то, что возвращает метод
или атрибут, должны быть одинаковыми.
• Можно получить разные реализации методов одного и того же интерфейса в разных
ФБ, расширяемых от этого интерфейса.
• Нельзя определять переменные и действия в интерфейсе. Может быть определена
только коллекция методов, свойств, их входных и выходных параметров.
Пример:
INTERFACE I1
METHOD GetName : STRING
END_METHOD
END_INTERFACE
FUNCTION_BLOCK A IMPLEMENTS I1
END_FUNCTION
FUNCTION_BLOCK B IMPLEMENTS I1
END_FUNCTION
В данном случае нужно, чтобы ФБ А и В обязательно имели в себе реализацию метода
GetName , чего в примере нет.
Следующая значимая возможность: используя интерфейс, как тип переменных в функции,
ей можно назначать разные блоки. Потому что все блоки от этого интерфейса должны
иметь тот же набор элементов, хотя, возможно, и с разной реализацией. Пример кода:
FUNCTION callITF: BOOL
VAR_INPUT
inIN: I1;
END_VAR
callITF := inIN.GetName();
END_FUNCTION
Стр. 175 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
PROGRAM PLC_PRG
VAR
fbA: A;
fbB: B;
END_VAR
callITF(fbA);
callITF(fbB);
END_PROGRAM
Как видно в примере, мы вызываем функцию callITF , передав в нее совершенно разные
ФБ. Это возможно потому что оба ФБ наследуются от одного интерфейса.
Метод ( METHOD )
Определение
Метод создается внутри ФБ, интерфейса или программы PROGRAM .
METHOD PRIVATE mMyMethod : BOOL
VAR_INPUT
END_VAR
END_METHOD
Методы могут также иметь VAR_INPUT , VAR_OUTPUT , VAR и VAR_IN_OUT . Хотя методы
автоматически имеют доступ ко всем внутренним, входным и выходным переменным
родительского POU. Данные переменные используются для передачи в них данных из
программы, которая вызывает метод. Если метод вызывается только внутри самого POU,
то, скорее всего, нет надобности для входных и выходных переменных метода, так как в
этом случае мы будем работать с локальными переменными, которые и так доступны в
методе.
Методы могут возвращать значение, как это делают функции.
Методы могут иметь разную видимость:
• PUBLIC - Такой метод можно вызывать, где угодно: как в программе, где определен и
используется данный ФБ, так и в наследственных ФБ.
• PRIVATE - Приватные методы можно вызывать только внутри самого ФБ
• PROTECTED - Защищенные методы можно вызывать внутри ФБ, где этот метод
определен, и в дочернем ФБ, который наследуется от этого ФБ.
Стр. 176 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Вызов
Если ФБ наследуется от другого ФБ, то все методы, свойства, локальные, входные и
выходные переменные родительского ФБ становятся доступными в новом блоке. Внутри
наследующего ФБ можно получить к ним доступ, использую ключевое слово SUPER .
SUPER - это указатель на родительский ФБ, поэтому требуется применение оператора
снятия косвенности ^ :
SUPER^.ParentMethod()
В ФБ, который наследует от другого ФБ, для доступа к собственным методам нужно
использовать ключевое слово THIS , которое тоже является указателям на текущий ФБ и
тоже требует снятия косвенности:
THIS^.LocalMethod()
В самой программе метод ФБ можно вызвать через . точку от определения экземпляра
ФБ:
fbMyFB.LocalMethod()
Пример кода:
FUNCTION_BLOCK FB_Base
VAR_OUTPUT
iCnt : INT;
END_VAR
METHOD mDoIt : BOOL
iCnt := -1;
END_METHOD
METHOD mDoAlso : BOOL
mDoAlso := TRUE;
END_METHOD
END_FUNCTION_BLOCK
FUNCTION_BLOCK FB_1 EXTENDS FB_Base
VAR_OUTPUT
iBase : INT;
END_VAR
THIS^.mDoIt();
SUPER^.mDoIt();
SUPER^.mDoAlso();
iBase := SUPER^.iCnt;
Стр. 177 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
METHOD mDoIt : BOOL
iCnt := 1111;
mDoIt := TRUE;
END_METHOD
END_FUNCTION_BLOCK
PROGRAM PLC_PRG
VAR
fbMy: FB_1;
xResult: BOOL;
END_VAR
xResult := fbMy.mDoIt();
END_PROGRAM
Действия ( ACTION )
Действия создаются внутри ФБ или программы PROGRAM . Они подобны функциям или
методам, с той разницей, что не имеют собственной области определения входных и
выходных параметров. Действия имеют доступ и могут манипулировать переменными
базового POU (ФБ или программа):
FUNCTION_BLOCK withAction
VAR
iCount : INT;
END_VAR
actReset();
ACTION actReset:
iCount := 0;
END_ACTION
END_FUNCTION_BLOCK
Внутри базового метода они вызываются как функции и не принимают никаких
параметров.
В программе действия ФБ вызываются через точку . :
PROGRAM PLC_PRG
VAR
fbMy: withAction;
END_VAR
fbMy.actReset();
END_PROGRAM
Достаточно подробно о действиях написано в главе, посвященной последовательностям
(См. Последовательности).
Стр. 178 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Свойство ( PROPERTY )
Свойства создается внутри ФБ, интерфейса, или программы PROGRAM , это расширение
стандарта МЭК 61131-3 для ООП. Свойства используются для инкапсуляции данных,
потому что к ним можно получить доступ удаленно и одновременно они служат
фильтрами. Для этого у свойств есть методы доступа Get и Set , которые разрешают
доступ на запись и чтение свойств экземпляра POU.
Свойство можно добавить не только в ФБ, программу или список глобальных переменных,
но и в интерфейс. Конечно, в интерфейсе не может быть реализации Get и Set , а только
определение имени свойства и уровня доступа:
PROGRAM PLC_PRG
VAR
iCount: INT;
END_VAR
PROPERTY PUBLIC Prop : INT
GET
VAR
END_VAR
Prop := iCount;
END_GET
SET
VAR
END_VAR
iCount := Prop;
END_SET
END_PROPERTY
END_PROGRAM
Таким образом можно создать свойство программы или ФБ, к которому, в случае
наследования, можно будет получить доступ в соответствии с уровнем доступа. В данном
примере PUBLIC .
Переход ( TRANSITION )
Переход не является частью ООП, а просто возможным расширением программы.
Подробно о переходах изложено в разделе, посвященном последовательностям (См.
Последовательности).
Работа с функциональными блоками
Тот факт, что назначить входную переменную ФБ можно до его вызова, а получить
выходную после вызова, не всегда очевиден. Понимание этого вопроса дает
беспрецедентную гибкость в написании программ.
Стр. 179 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Из-за того, что ФБ заранее объявляется в области объявления переменных, это дает
возможность обратиться к входным и выходным переменным ФБ через . не вызывая при
этом самого ФБ.
Рассмотрим пример вызова таймера TON .
У функционального блока TON 2 входные и 2 выходные переменные, что хорошо видно в
графическом языке.
Зачастую, именно эта наглядность и делает графические языки более популярными. Сразу
понятно, что нужно передать в ФБ и что можно получить на выходе. В редакторе ST, не во
всех, но в большинстве IDE, как, например, CoDeSys 3.5, можно увидеть подсказку при
вводе параметров:
Пример:
VAR
ton1 : TON;
END_VAR
ton1.IN := TRUE;
Входной переменной IN назначено значение TRUE . Важно понимать, что в следующем
цикле программы после первого исполнения ton1.IN := TRUE; , перед этой строкой
переменная IN уже будет равна TRUE , так как ПЛК запоминает состояние входных,
выходных и локальных переменных ФБ от одного цикла к другому. Например:
VAR
ton1 : TON;
xStart: BOOL;
Стр. 180 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_VAR
IF xStart THEN
ton1.IN := TRUE;
END_IF
Допустим, мы нажали на кнопку xStart и ton1.IN присвоили TRUE . Что будет если мы
отпустим кнопку xStart ? Чему будет равно ton1.EN ? Оно останется равным TRUE .
Все входные, выходные и локальные переменные ФБ до их назначения сохранят значения,
назначенные в прошлом цикле программы.
Понимание этого принципа фундаментально и открывает новые возможности в
организации кода и использовании привычных паттернов программирования.
Я разделяю технику вызова ФБ на 2 метода, которые разнятся способом назначения
переменных.
1. Во время вызова функционального блока
Это техника, когда все входные и выходные переменные назначаются при вызове ФБ
между скобками () .
VAR
ton1
: TON;
ton1_Q : BOOL;
ton1_ET : TIME;
END_VAR
ton1(
IN := TRUE,
PT := T#5s,
Q => ton1_Q,
ET => ton1_ET);
IF ton1_Q THEN
// Do Something
END_IF
Следует обратить внимание на то, что входные переменные назначаются оператором
присвоения := , а выходные - оператором => . В примере мы передаем значение выходной
переменной Q в локальную переменную ton_Q .
Так как мы указываем имена входных и выходных переменных перед их присвоением,
порядок их расположения не имеет значения. Вот абсолютно равные выражения:
ton1(Q => ton1_Q, ET => ton1_ET, IN := TRUE, PT := T#5s);
ton1(PT := T#5s, Q => ton1_Q, IN := TRUE, ET => ton1_ET);
Стр. 181 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
ton1(IN := TRUE, Q => ton1_Q, PT := T#5s);
Как следует из последнего примера, не обязательно назначать все существующие у блока
входные или выходные переменные при его вызове.
Такой метод назначения переменных ФБ хорошо работает для блоков с небольшим
количеством переменных и с короткими именами. Когда блок большой, данный метод
может выглядеть не так эстетично.
Такой метод подойдет и там, где блок вызывается статически. То есть, его входные и
выходные переменные, да и он сам не влияют на логику программы и блок работает
просто как подпрограмма. Например: вызов ФБ, который делает ПЛК Modbus Slave
устройством по адресу 1 и передает карту регистров хранящейся в переменной HMI , как
понятно из контекста, для чтения панелью оператора:
stComSettings.Port := 0;
stComSettings.dwBaudRate := 115200;
fbMBSlave(
xEnable
:= TRUE,
stComSettings
:= stComSettings,
usiSlaveAddress
:= 1,
pSlaveBuffer
:= ADR(HMI),
uiSlaveBufferSize := SIZEOF(HMI)
);
В таких случаях ФБ играет, скорее, роль подпрограммы. Второй способ вызова здесь даже
не имеет смысла, и это единственный способ вызова, когда все переменные передаются
при вызове блока.
2. Назначение до и после вызова
Можно произвести назначение входных переменных перед вызовом ФБ при помощи
символа . . Вот пример, где назначаем входные переменные IN и PT до вызова ФБ, а
выходную переменную Q при вызове.
VAR
fbTON : TON;
xOut : BOOL;
END_VAR
fbTON.IN := TRUE;
fbTON.PT := T#5s;
fbTON(Q => xOut); // вызов
IF xOut THEN
Стр. 182 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
// Do Something
END_IF
Иногда редактор кода ST недостаточно информативен, например, в CoDeSys версии 2.х.
Непросто получить подсказку о том, какие имеются входные и выходные переменные. В
данной технике даже в CoDeSys 2.х вы получите информацию, какие переменные
доступны в ФБ, после того как введете символ . .
Выходные переменные, как Q в примере ниже, можно читать и после вызова функции:
VAR
fbTON : TON;
xOut : BOOL;
END_VAR
fbTON.IN := TRUE;
fbTON.PT := T#5s;
fbTON(); // вызов
xOut := fbTON.Q;
IF xOut THEN
// Do Something
END_IF
Если имеется доступ к выходным переменным и после вызова ФБ, то их можно не
присваивать другим переменным, а использовать непосредственно значения самих
выходных переменных в логике.
VAR
fbTON : TON;
END_VAR
fbTON.IN := TRUE;
fbTON.PT := T#5s;
fbTON();
IF fbTON.Q THEN
// Do Something
Стр. 183 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_IF
Такая техника выглядит более элегантно и открывает новые возможности:
• Код легче читать и писать.
• Можно сделать назначение входным переменным на определенных условиях до
вызова ФБ.
• Можно вызвать ФБ и уже потом читать его выходные переменные.
• Как следствие, можно получить значение выходной переменной блока, как после его
вызова, так и до.
• Не нужно объявлять локальные переменные для присвоения им выходных
переменных ФБ.
Вот пример комбинированного назначения с идеальным балансом использования.
Назначаем входную переменную времени PT перед вызовом ФБ TON . Включение
таймера входной переменной IN при вызове самого ФБ. В условии используем выход
блока Q уже после вызова ФБ без его присвоения локальной переменной:
VAR
fbTON : TON;
END_VAR
IF AI_Temperature > 30 THEN
fbTON.PT := T#5s;
ELSE
fbTON.PT := T#10s;
END_IF
fbTON(IN := TRUE);
IF fbTON.Q THEN
// Do Something
END_IF
Функция
Функция - это POU, который возвращает только один элемент. Сама функция в выражении
может считаться оператором, а ее вызов с производным значением можно считать
операндом. Возвращаемый элемент, кроме обычных типов ANY_ELEMENTARY , может быть и
массивом, и структурой.
Функция не сохраняет значения внутренних переменных от одного вызова к другому, даже
если объявить локальные переменные, используя ключ RETAIN . Это связано с тем, что мы
Стр. 184 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
не объявляем экземпляр функции, как делаем это с ФБ, и для нее не резервируется место в
памяти ПЛК. Поэтому:
1. Функции не занимают место в памяти, что может повысить производительность,
хотя и незначительно.
2. Недостаток: во время отладки трудно проследить значения переменных внутри
функции.
3. Если внутри функции требуется использовать ФБ или иметь переменную, которая
будет сохранять свое значение от вызова к вызову, следует использовать не
функцию, а ФБ. Во всех других случаях следует использовать функцию.
Синтаксис функции
В функции можно использовать области видимости VAR , VAR_INPUT , VAR_OUTPUT и
VAR_IN_OUT . Область VAR_OUTPUT дает возможность функции вернуть более, чем одно
значение.
Пример функции, которая считает площадь прямоугольника в квадратных метрах:
FUNCTION SQA: REAL
VAR_INPUT
Width: REAL; (* Ширина в сантиметрах *)
Height: REAL; (* Высота в сантиметрах *)
END_VAR
SQA := (Width / 100) * (Height / 100);
END_FUNCTION;
Обратите внимание, что в определении имени функции через : определяем тип данных,
которые должна вернуть эта функция FUNCTION SQA: REAL .
Для того, чтобы функция вернула данные, не используется ключ RETURN , как обычно
делается в других языках, а присваивается выходное значение одноименное имени
функции переменной SQA := ... .
Работа с функциями
Как отмечалось ранее, функцию не нужно определять в области определения переменных
программы. При вызове функции не обязательно обозначать имена переменных, как это
делается в ФБ. Можно просто передать их функцию в том же порядке, в каком они в ней
определены. Указывать имена входных переменных не обязательно, но можно:
Стр. 185 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
PROGRAM PLC_PRG
VAR
rSQA: REAL; (* Вычисленная площадь прямоугольника *)
END_VAR
rSQA := SQA(12, 45);
IF SQA(12, 45) > 0.01 THEN
sMsg := 'Too big!';
END_IF;
END_PROGRAM
Как видно, в первый раз мы вызвали функцию и присвоили ее значение переменной
rSQA := SQA(12, 45); , а во втором случае использовали в качестве оператора для
вычисления площади и операнда для сравнения в выражении SQA(12, 45) > 0.01 .
Помните, как выражения были одновременно выражениями и операндами? Так и тут. Для
12 и 45 , SQA - это оператор, который производит вычисление, а для > , все выражение
SQA(12, 45) является операндом.
Функции, возвращающие несколько
элементов
1. Выходная область
Благодаря возможности определять область VAR_OUTPUT , появляется и возможность
создания функций с дополнительными выходными переменными.
Допустим, есть функция, которая вернет 2 значения - площадь в метрах и в сантиметрах
квадратных:
FUNCTION SQA: BOOL
VAR_INPUT
Width: REAL; (* Ширина в сантиметрах *)
Height: REAL; (* Высота в сантиметрах *)
END_VAR
VAR_OUTPUT
AreaCm: REAL; (* Площадь в квадратных сантиметрах *)
AreaM: REAL; (* Площадь в квадратных метрах *)
END_VAR
AreaCm := Width * Height;
AreaM := (Width / 100) * (Height / 100);
SQA := TRUE;
END_FUNCTION;
Стр. 186 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Обратите внимание: мы назначили функции тип, который она вернет, BOOL . В конце
просто присвоили SQA := TRUE . Это значит, что сама функция всегда вернет TRUE , если ее
использовать как оператор.
И далее - вызов в программе:
PROGRAM PLC_PRG
VAR
(* Вычисленная площадь прямоугольника в метрах квадратных *)
rSQA_M: REAL;
(* Вычисленная площадь прямоугольника в сантиметрах квадратных *)
rSQA_Cm: REAL;
END_VAR
SQA(Width := 12, Height := 45, AreaCm => rSQA_Cm, AreaM => rSQA_M);
END_PROGRAM
Теперь при вызове желательно обозначить имена входных и выходных переменных, как
это делается для ФБ. Такую функцию нельзя будет использовать напрямую в выражении,
как оператор.
2. Выходные структуры
Второй способ для функции вернуть больше одного значения я считаю приоритетным. Это
использование структуры, как типа, который возвращает функция:
TYPE SQA_RESULT :
STRUCT
AreaCm: REAL; (* Площадь в квадратных сантиметрах *)
AreaM: REAL; (* Площадь в квадратных метрах *)
END_STRUCT
END_TYPE
FUNCTION SQA: SQA_RESULT
VAR_INPUT
Width: REAL; (* Ширина в сантиметрах *)
Height: REAL; (* Высота в сантиметрах *)
END_VAR
SQA.AreaCm := Width * Height;
SQA.AreaM := (Width / 100) * (Height / 100);
END_FUNCTION;
Такой способ выглядит чище. Далее в программе:
PROGRAM PLC_PRG
VAR
stSQAR:
END_VAR
Стр. 187 из 298
SQA_RESULT; (* Вычисленная площадь прямоугольника *)
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
stSQAR := SQA(12, 45);
IF stSQAR.AreaM > 0.01 THEN
sMsg := 'Too big!';
END_IF;
END_PROGRAM
Использовать функцию, как оператор или операнд, пользуясь этим методом, так же
невозможно, но ее можно вызывать без указания имен входных переменных.
3. Разъединение
Следующий способ для функции вернуть больше, чем одно значение - это разделение
функций. Да, в реальности функция вернет только одно значение, это не метод возврата
нескольких значений в чистом виде, но все же иногда это тоже хорошее решение.
Особенно, если вычисление в функции предполагается использовать в выражениях как
операнд:
FUNCTION SQA_SM: REAL
VAR_INPUT
Width: REAL; (* Ширина в сантиметрах *)
Height: REAL; (* Высота в сантиметрах *)
END_VAR
SQA := Width * Height;
END_FUNCTION;
FUNCTION SQA_M: REAL
VAR_INPUT
Width: REAL; (* Ширина в сантиметрах *)
Height: REAL; (* Высота в сантиметрах *)
END_VAR
SQA := (Width / 100) * (Height / 100);
END_FUNCTION;
Вместо одной функции создайте столько, сколько выходных значений вам нужно. Это
увеличит количество функций, но упростит код самой бизнес-логики программы, где они
будут использоваться.
Функции с памятью переменных
Как говорилось выше, главное правило: функции следует использовать везде, где только
возможно. И только там, где это невозможно - применять ФБ. Но даже в тех случаях, где
Стр. 188 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
вам кажется, что нужно использовать ФБ, все-таки стоит проявить смекалку и заменить на
функцию, расширив сферу применения функций.
Есть несколько способов создавать функции, которые помнят переменные от одного
вызова к другому.
Когда это будет полезно?
1. Допустим, у вас небольшое вычисление именно в ФБ, потому что требуется память
переменных от одного вызова к другому. Вы вызываете его много раз в коде,
приходится создавать много экземпляров ФБ. Это весьма громоздко.
2. Ваш ФБ в результате возвращает только одну переменную, и вы хотите использовать
её как оператор, чтобы не загромождать код, а этого нельзя сделать с ФБ.
Причин может быть и больше. С опытом вы сами поймете, когда лучше, чтобы ваш ФБ
был функцией.
Далее мы рассмотрим, какие есть способы, но сначала разберем задачу с решением ее в
ФБ. Это блок инкриментации значения на заданное число при каждом вызове ФБ:
FUNCTION_BLOCK Incrmnt
VAR_INPUT
Val: INT;
END_VAR
VAR_OUTPUT
Out: INT;
END_VAR
Out := Out + Val;
END_FUNCTION
И в программе нужно будет сделать так:
PROGRAM PLC_PRG
VAR
fbIncr: Incrmnt; (* Экземпляр ФБ *)
iValue: INT;
(* Вычисленное значение *)
END_VAR
fbIncr(Val := 1, Out => iValue);
IF iValue > 100 THEN
// Do something
END_IF
END_PROGRAM
Мы использовали ФБ, потому что нужно, чтобы переменная Out хранила прошлое
значение к следующему вызову.
Стр. 189 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Теперь рассмотрим, как это можно сделать функциями.
Способ 1
Этот способ самый простой. Объявляем глобальные переменные и работаем с ними в
функции. Вот пример функции, которая каждый вызов инкрементирует определенную
переменную на заданное значение и возвращает новое значение:
VAR_GLOBAL
iValue: INT;
END_VAR
FUNCTION Incrmnt: INT
VAR_INPUT
Val: INT; (* На сколько увеличить *)
END_VAR
Incrmnt := iValue := (iValue + Val);
END_FUNCTION
И в программе нужно будет написать так:
PROGRAM PLC_PRG
IF Incrmnt(1) > 100 THEN
// Do something
END_IF
END_PROGRAM
Обратите внимание, насколько меньше кода в программе. В ней нет определения
переменной для присвоения входного значения, нет определения инициализации ФБ и нет
отдельного вызова ФБ. Мы можем просто использовать нашу калькуляцию, как оператор,
непосредственно в выражении, при этом она не только будет возвращать значение, но и
инкрементировать.
Внимание
Нужно заметить, что стандарт, рекомендует избегать работы с
внешними переменными в функциях и ФБ.
Способ 2
Данный способ - это передача в функцию предыдущего значения. Такой метод подходит
только в случае необходимости хранить в функции значение того, что функция возвращает.
FUNCTION Incrmnt: INT
VAR_INPUT
Стр. 190 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Old: INT; (* Старое значение *)
Val: INT; (* На сколько увеличить *)
END_VAR
Incrmnt := Old + Val;
END_FUNCTION
В программе нужно будет написать так:
PROGRAM PLC_PRG
VAR
iValue:
END_VAR
INT; (* Вычисленное значение *)
iValue := Incrmnt(iValue, 1);
IF iValue > 100 THEN
// Do something
END_IF
END_PROGRAM
Здесь кода больше, чем в первом способе, но все-таки меньше, чем с использованием ФБ.
Этот способ иногда намного удобнее, чем первый - в зависимости от задачи.
Способ 3
Последний способ - использование указателей. Фактически это способ 2, с разницей в том,
что здесь мы передаем не старое значение, а ссылку на него. Преимущество в том, что нет
необходимости присваивать значение для его сохранения. Значит, можно использовать
функцию, как операнд в выражении:
FUNCTION Incrmnt: INT
VAR_INPUT
Old: POINTER TO INT; (* Переменная для увеличения *)
Val: INT;
(* На сколько увеличить *)
END_VAR
Incrmnt := Old^ := (Old^ + Val);
END_FUNCTION
В программе нужно будет написать так:
PROGRAM PLC_PRG
VAR
iValue:
END_VAR
INT; (* Вычисленное значение *)
IF Incrmnt(ADR(iValue), 1) > 100 THEN
// Do something
END_IF
Стр. 191 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_PROGRAM
Рекурсии
Так как функции вызываются синхронно, то есть опасность рекурсии.
Определение:
Рекурсия - определение, описание, изображение какого-либо объекта
или процесса внутри самого этого объекта или процесса, то есть
ситуация, когда объект является частью самого себя.
В программировании рекурсии возникают, когда одна функция вызывает саму себя. Иногда
это решение, а иногда - проблема. Например:
FUNCTION A: BOOL
A();
END_FUNCTION
PROGRAM PLC_PRG
A();
END_PROGRAM
Это простейший пример рекурсии которую делать нельзя. В программе вызываем
функцию, которая вызывает саму себя. Такой код вызовет зависание ПЛК, так как цикл
основной программы никогда не прервется.
Рекурсии в ST для решения задач используются крайне редко. Это связано со строгой
типизацией данных.
Стр. 192 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Книга рецептов
Этот раздел посвящен готовым решениям из практической работы программиста, советам
и рекомендациям.
Да, можно подробно рассказать о типах, синтаксисе, операторах. Не представляет большой
сложности прочесть об этом в литературе, в мануале к любой IDE - там все расписано и
проиллюстрировано примерами.
Другое дело - понимать, как совокупность полученных сведений применить на практике.
Допустим, вы прочли об указателях. А где их целесообразно применять? В каких случаях
использовать? Как превратить теорию в готовые элегантные решения?
Пожалуй, подготовить раздел Справочника смог бы даже старательный студент. А вот
научить чему-то практическому в состоянии только опытный пользователь. Тот, кто уже не
раз наступал на грабли и теперь уже точно знает, как решать те или иные задачи
правильно. Я не претендую на звание Гуру, а просто пытаюсь объяснить замысел и
значение этого раздела книги. Отмечу, что он будет полезен лишь тем, кто познакомился с
ST в первой части Справочника, или уже был знаком с ним, получив информацию из
других источников.
Стр. 193 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Обработка сигналов
I/O - Input (вход) / Output (выход).
Одной из самых частых задач в программировании ПЛК является обработка входных и
выходных сигналов. Есть 2 типа сигналов: аналоговые (AI, AO) и дискретные (DI, DO).
Давайте рассмотрим, что можно делать с теми и с другими.
Хорошей практикой будет присвоение имени переменной префиксов, чтобы сразу было
видно, что эта переменная привязана к регистру входа или выхода ПЛК.
• DI_ - Дискретный вход (Digital Input)
• DO_ - Дискретный выход (Digital Output)
• AI_ - Аналоговый вход (Analog Input)
• AO_ - Аналоговый выход (Analog Output)
Аналоговые сигналы
Частой проблемой аналоговых сигналов является их нестабильность. Это может быть
связано с помехами в сети. Например, кабели аналоговых датчиков проходят слишком
близко к силовым кабелям. Или это могут быть высокочастотные помехи, которые создает
ПЧ. Нестабильность приводит к скачкам значения, а это может провоцировать
непредвиденные ситуации. Рассмотрим пример:
VAR
AI_Temperature: AT %ID0.0 REAL; (* Читаем регистр аналогового входа *)
END_VAR
IF (AI_Temperature > 80) THEN
enMachineState := 0;
SET_ERROR(101, 'Critical Temperature');
END_IF;
Если температура поднимется выше 80 градусов, регистрируем ошибку и переводим
машину в состояние паузы. Если помехи произведут скачок до подобной температуры, то
машина отключится. Реальная температура может быть в норме. Еще один пример:
VAR
AI_Temperature: AT %ID0.0 REAL; (* Читаем регистр аналогового входа *)
DI_Start: AT %IX0.0 BOOL;
(* Читаем регистр дискретного входа *)
fbSR1: SR;
(* Блок Set Reset *)
END_VAR
fbSR1(
Стр. 194 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
SET1 := (DI_Start),
RESET := (AI_Temperature > 80),
Q1 => DO_Motor
);
Здесь видим, что выход ПЛК DO_Motor включается при сигнале на входе ПЛК DI_Start .
Предположим, это кнопка. Выключение происходит по температуре. Как только
температура достигла значения (AI_Temperature > 80) , следует отключить мотор.
Как видно из кода, если произойдет помеха и значение температуры скакнет даже на
миллисекунду, то процесс отключится, пока его снова не запустит оператор нажатием
кнопки. Другими словами, отключение может произойти раньше, чем процесс реально
должен закончиться, а оператор может это заметить не сразу.
Для исключения подобных проблем, используется техника фильтрации аналоговых
сигналов.
Округление значения аналоговых сигналов с
плавающей запятой
Аналоговые сигналы всегда удобно хранить и обрабатывать, как целые числа. Далее
примеры фильтрации сигналов тоже будут с числом типа WORD . Иногда на аналоговом
входе мы получаем число типа REAL , или получаем это число, когда преобразуем WORD в
REAL , чтобы отобразить его пользователю. Ведь это единственная причина, почему нам
нужен тип REAL для аналоговых сигналов. Для вычислений и работы программы это не
нужно.
Рассмотрим функции преобразования REAL в WORD и обратно, c сохранением
определенного количества знаков после запятой:
FUNCTION REAL_TO_WORD2 : WORD
VAR_INPUT
r: REAL; (* Заданное число с плавающей запятой *)
p: USINT; (* Точность после запятой *)
END_VAR
REAL_TO_WORD2 := REAL_TO_WORD(r * EXP10(p));
END_FUNCTION
FUNCTION WORD_TO_REAL2 : REAL
VAR_INPUT
w: WORD; (* Заданное целое число *)
p: USINT; (* Точность после запятой *)
END_VAR
WORD_TO_REAL2 := WORD_TO_REAL(w) / EXP10(p);
Стр. 195 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_FUNCTION
Я назвал функции как стандартное преобразование, но добавил цифру 2 в конце. Пример
функции EXP10 я уже приводил в описании типов переменных с плавающей запятой.
Например, температура в REAL 23.345637394. Нам нужна точность только в 2 знака после
запятой, можно сделать преобразования в WORD . Ведь в подавляющем большинстве
случаев точность чисел с плавающей запятой нужна не больше трех разрядов. Пример:
VAR
rX: REAL := 23.34563;
wX: WORD;
END_VAR
wX := REAL_TO_WORD2(rX, 2); (* получим 2334 *)
rX := WORD_TO_REAL2(wX, 2); (* получим 23.34 *)
Обратите внимание, что после преобразования получилось не такое уж и большое число.
И хотя REAL размером в 4 байта, а WORD всего в 2, подобное преобразование позволяет нам
легко сохранить REAL в WORD в большинстве случаев. Если и нет, то всегда можно
преобразовать в DWORD . В дополнение мы сразу округляем значение, убирая из него все
лишнее.
Фильтрация аналоговых сигналов
Рассмотрим примеры фильтрации значения типа WORD , но используя этот принцип, можно
сделать ФБ на фильтрацию других типов данных. Решить эту задачу с типом WORD проще
и надежнее, чем с числами с плавающей запятой. В случае, если исходное значение,
требующее обработки, хранится с плавающей запятой, можно предварительно
преобразовать его, как это описано в предыдущей части.
Использовать мы будем не функции, а ФБ, так как нам нужно запомнить некоторые
переменные от одного цикла программы к другому.
Есть 2 основных вида фильтрации аналоговых сигналов.
Вид фильтра 1: Усредняющий по количеству
Эта функция хранит до 32-х значений в буфере и выдает среднее значение. Вы сами
можете решить, сколько значений сохранить для получения среднего числа.
FUNCTION_BLOCK FILTER_N_R
VAR_INPUT
X : WORD;
(* Входное значение *)
Стр. 196 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
N : UINT;
(* Номер, размер буфера минимум 32 *)
RST : BOOL; (* Сброс буфера *)
END_VAR
VAR_OUTPUT
Y : WORD; (* Выходное отфильтрованное значение *)
END_VAR
VAR
xInit: BOOL;
aBuffer : ARRAY[1..32] OF WORD;
iCount: INT;
dwSum : DWORD;
END_VAR
VAR_TEMP
iTmp : INT;
END_VAR
(* ограничим N в размер буфера *)
N := MIN(N, 32);
(* Инициализация *)
IF NOT init OR rst OR N = 0 THEN
xInit := TRUE;
iTmp := UINT_TO_INT(N) - 1;
FOR iCount := 1 TO iTmp DO
aBuffer[iCount] := X;
END_FOR;
dwSum := Y * N;
Y := X;
RETURN;
END_IF;
iTmp
:= UINT_TO_INT(N);
iCount := INC1(iCount, iTmp);
dwSum := dwSum + X - aBuffer[iCount];
Y
:= DWORD_TO_WORD(dwSum / N);
aBuffer[iCount] := X;
END_FUNCTION_BLOCK
Данные в буфере обновляются каждый раз на каждый новый цикл ПЛК, по одной ячейке
за раз. Это значит, что если вы поставите N := 10 , то получите среднее значение входного
параметра за последние 10 циклов работы ПЛК. Чем больше N , тем меньше отклонение,
но, с другой стороны, это отклонение дольше остается в вычислении.
Первый блок кода инициализации заполняет буфер одинаковыми значениями и
инициирует все переменные. Этот блок срабатывает один раз в момент запуска фильтра
или ПЛК.
Далее мы замещаем по кругу значения в буфере на каждый новый цикл, новое значение - в
новую ячейку массива. Для определения того, какое значение заменить, используется
функция INC1 . Эта функция не входит в стандарт. Функция INC1 инкрементирует iCount
Стр. 197 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
по единице на каждый цикл ПЛК, а по достижении iCount до значения iTmp , сбрасывает
iCount на 1. Как домашнее задание, попробуйте написать эту функцию сами.
Вид фильтра 2: Усредняющий по времени
Этот способ возвращает среднее значение за определенный промежуток времени. Это не
совсем классическое усреднение. Время берется в расчет для проверки значения только
при смене значения и только с учетом разницы измерения от одного цикла ПЛК к другому.
Чем больше разница изменения значения, тем больше применяется фильтр:
FUNCTION_BLOCK FILTER_T_W
VAR_INPUT
X : WORD;
T : TIME;
END_VAR
VAR_OUTPUT
Y : WORD;
END_VAR
VAR
xInit: BOOL;
tCurr: DWORD;
tLast: DWORD;
tTmp: DWORD;
END_VAR
(* Получаем текущее время *)
tCurr := TIME_TO_DWORD(TIME());
(* Инициализация *)
IF NOT xInit OR T = T#0s THEN
xInit := TRUE;
tLast := tCurr;
Y := X;
ELSIF Y = X THEN
tLast := tCurr;
ELSE
tTmp := WORD_TO_DWORD(X - Y) * (tCurr - tLast) / TIME_TO_DWORD(T);
IF tTmp <> 0 THEN
Y := DINT_TO_WORD(WORD_TO_DINT(Y) + DWORD_TO_DINT(tTmp));
tLast := tCurr;
END_IF;
END_IF;
END_FUNCTION_BLOCK
Масштабирование Аналоговых сигналов
Подключенные аналоговые датчики к ПЛК, как правило, выдают значения, равные типу
переменной. Например, если входной регистр ПЛК - это WORD , то значение может
передаваться от 0 до 65535. Может быть и другое значение - нужно смотреть в
Стр. 198 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
документации к модулю аналогового входа конкретного производителя. Некоторые модули
позволяют конфигурировать их, получая на входе ПЛК уже иметь масштабное значение.
Вот функция, осуществляющая линейное масштабирование числа с плавающей запятой
REAL :
FUNCTION SCALE_R : REAL
VAR_INPUT
X : REAL;
(* Входное значение *)
I_LO : REAL; (* Входное минимальное *)
I_HI : REAL; (* Входное максимальное *)
O_LO : REAL; (* Выходное минимальное *)
O_HI : REAL; (* Выходное максимальное *)
END_VAR
IF I_LO = I_HI THEN
SCALE_R := O_LO;
ELSE
SCALE_R := (O_HI - O_LO) / (I_HI - I_LO) *
(LIMIT(I_LO, X, I_HI) - I_LO) + O_LO;
END_IF;
END_FUNCTION
Пример использования
Допустим, у нас датчик давления 4-20мА с пределами измерения 0-6 Бар.. Это значит, что
при 4 мА это 0 бар, и при 20 мА это 6 бар. Предположим, что входной регистр, к которому
подключен датчик, имеет тип WORD , измеряет в диапазоне 0-32763, назначено имя
AI_Pressure . Еще раз, получается, что 20 мА = 6 бар = 32763, а 4 мА = 0 бар = 0.
Нам нужно превратить значение 0-32763 в значение 0-6 с плавающей точкой, с точностью
2 знака после запятой, и применить фильтрацию по времени, тогда:
VAR
rPressure: REAL;
(* Конечное значение измерения давления *)
wPressure: WORD;
(* Промежуточное значение измерения давления *)
fbFilter: FILTER_T_W; (* Блок фильтра *)
END_VAR
(* Масштабирование *)
rPressure := SCALE_R(AI_Pressure, 0, 32763, 0, 6);
(* Фильтрация *)
fbFilter(X := (REAL_TO_WORD2(rPressure, 2)), T := T#500ms, Y => wPressure);
rPressure := WORD_TO_REAL2(wPressure, 2);
Стр. 199 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Другие полезные функции
Преобразование температуры из Цельсия в Фаренгейт и обратно:
FUNCTION C_TO_F : REAL
VAR_INPUT
celsius : REAL;
END_VAR
C_TO_F := celsius * 1.8 + 32.0;
END_FUNCTION
FUNCTION F_TO_C : REAL
VAR_INPUT
fahrenheit : REAL;
END_VAR
F_TO_C := (fahrenheit - 32.0) * 0.5555555555555;
END_FUNCTION
Преобразование температуры из Цельсия в Кельвин и обратно:
FUNCTION C_TO_K : REAL
VAR_INPUT
Celsius : REAL;
END_VAR
C_TO_K := Celsius - phys.T0;
END_FUNCTION
FUNCTION K_TO_C : REAL
VAR_INPUT
Kelvin : REAL;
END_VAR
K_TO_C := Kelvin + phys.T0;
END_FUNCTION
Или универсальная функция, которая конвертирует любую температуру в любой другой
формат измерения. На вход можно подать только один параметр, а другие оставить
пустыми. Если подать 2 параметра, то они будут сложены вместе:
FUNCTION_BLOCK TEMPERATURE
VAR_INPUT
K: REAL;
(* Кельвин *)
C: REAL := -273.15; (* Цельсий *)
F: REAL := -459.67; (* Фаренгейт *)
Re: REAL := -218.52; (* Реомюре *)
Ra: REAL;
(* Ранкиной *)
END_VAR
VAR_OUTPUT
YK: REAL;
YC: REAL;
YF: REAL;
YRe: REAL;
YRa: REAL;
Стр. 200 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_VAR
YK := K + (C + 273.15) + (F + 459.67) * 0.555555555555 +
(Re * 1.25 + 273.15) + (Ra * 0.555555555555);
YC := YK -273.15;
YF := YK * 1.8 - 459.67;
YRe := (YK - 273.15) * 0.8;
YRa := YK * 1.8;
END_FUNCTION_BLOCK
М/с в км/ч и обратно:
FUNCTION MS_TO_KMH : REAL
VAR_INPUT
ms : REAL;
END_VAR
MS_TO_KMH := ms * 3.6;
END_FUNCTION
FUNCTION KMH_TO_MS : REAL
VAR_INPUT
kmh : REAL;
END_VAR
KMH_TO_MS := kmh * 0.2777777777777;
END_FUNCTION
Дискретные сигналы
Триггеры и импульсы
Говоря о дискретных сигналах, нельзя обойти тему триггеров и импульсов.
Определение триггера:
Триггер - событие, определяющее передний или задний фронт
дискретного сигнала на входе ПЛК или переменной.
И
Определение импульса:
Импульс - смена сигнала из 0 ( FALSE ) в 1 ( TRUE ) на 1 период
(цикл) работы ПЛК. Импульс является производным триггера. То
есть, триггер в результате выдаст импульс.
Стр. 201 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Понятие фронта сигнала является базовым в релейной логике и произошло из релейной
схемотехники. У каждого сигнала есть передний и задний фронт. Событие или триггер
переднего фронта происходит, когда сигнал меняет свое состояние с 0 на 1, а триггер
заднего фронта происходит, когда сигнал пропадает или меняется с 1 на 0.
В стандарте предусмотренны 2 блока, определяющие передний и задний фронт сигнала.
R_TRIG (Raise Trigger) - Передний фронт
Переменные блока
Имя Тип
Описание
CLK Входная
Условие для триггера
Q
Выходная Выход на котором появляется импульс
Пример использования блока триггера:
VAR
fbRT1: R_TRIG;
xStart: BOOL;
END_VAR
fbRT1(CLK := xStart);
IF fbRT1.Q THEN
// То, что нужно сделать
END_IF;
Посмотрим, как блок триггера устроен изнутри и поймем, как выделяется импульс на
переднем фронте:
FUNCTION_BLOCK R_TRIG
VAR_INPUT
CLK:BOOL;
END_VAR
VAR_OUT
Q: BOOL;
END_VAR
VAR
M: BOOL;
END_VAR
Q := FALSE;
IF CLK AND NOT M THEN
Q := TRUE;
END_IF
M := CLK;
END_FUNCTION_BLOCK
Стр. 202 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В этом примере видно не только выделение переднего фронта, но и формирование
импульса. Триггер и импульс неразрывно связаны. Любой триггер длится не дольше
импульса, ведь смена сигнала происходит в одном цикле ПЛК. Только в одном цикле
состояние сигнала меняется по отношению к состоянию того же сигнала в предыдущем
цикле.
Значит, для определения триггера или фронта сигнала нужно знать значение сигнала в
предыдущем цикле ПЛК. Для хранения этого значения создаем внутреннюю переменную
M и в конце блока назначаем ей значение сигнала, за которым следим, и который
находится в переменной CLK ( M := CLK; ).
Этой строкой мы как бы разделили: все, что после этой строки - это М , хранит значение
CLK текущего цикла, а то, что до этой строки - это М прошлого цикла, или CLK прошлого
цикла. Таким образом, условие IF CLK AND NOT M THEN определяет смену сигнала в
переменой CLK из FALSE в TRUE . Если текущее состояние сигнала TRUE , а в предыдущем
цикле ПЛК оно было FALSE , то мы поймали передний фронт сигнала или поднятие(Raise)
сигнала.
Несложно сформировать импульс на переменной Q . Так как триггер длится всего один
импульс, мы просто делаем назначение Q в TRUE , если условие сработало, но
предварительно назначаем Q в FALSE . Таким образом, на Q будет положительный сигнал
только один цикл ПЛК.
F_TRIG (Fall Trigger) - Задний фронт
Переменные блока
Имя Тип
Описание
CLK Входная
Условие для триггера
Q
Выходная Выход на котором появляется импульс
Пример использования блока триггера:
VAR
fbFT1: F_TRIG;
xStart: BOOL;
END_VAR
fbFT1(CLK := xStart);
IF fbFT1.Q THEN
// То что нужно сделать
END_IF;
Стр. 203 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Посмотрим, как блок триггера устроен изнутри и поймем, как выделяется импульс на
заднем фронте:
FUNCTION_BLOCK F_TRIG
VAR_INPUT
CLK:BOOL;
END_VAR
VAR_OUT
Q: BOOL;
END_VAR
VAR
M: BOOL;
END_VAR
Q := FALSE;
IF NOT CLK AND M THEN
Q := TRUE;
END_IF
M := CLK;
END_FUNCTION_BLOCK
Блок определения заднего фронта абсолютно идентичен блоку определения переднего
фронта и использует тот же принцип. Единственная разница - это условие IF NOT CLK AND
M THEN . Если сигнала нет, но в прошлом цикле ПЛК он был, значит мы поймали задний
фронт сигнала или падение(Fall) сигнала.
DE (Detect Edge) - Определить фронт
Вот пример одного блока DE (от английского Detect Edge или по-русски определить
фронт), который выделяет передний и задний фронт сигнала, и выдает импульс на QR или
QF соответственно.
FUNCTION_BLOCK DE
VAR_INPUT
CLK:BOOL;
END_VAR
VAR_OUT
QR: BOOL; (* R От слова Raise *)
QF: BOOL; (* F От слова Fall *)
END_VAR
VAR
M: BOOL;
END_VAR
QR := QF := FALSE;
IF CLK AND NOT M THEN
QR := TRUE;
ELSIF NOT CLK AND M THEN
QF := TRUE;
END_IF
Стр. 204 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
M := CLK;
END_FUNCTION_BLOCK
SR,RS - Назначение переменных по импульсу
В любом графическом языке для назначения переменных по импульсу нам пришлось бы
использовать блоки SR или RS . Блоки SR или RS - это сокращение от SetReset и ResetSet.
Оба блока делают одно и то же: если подать импульс на входную переменную SET ,
включают выход OUT , а если подать импульс на входную переменную RESET , выключают
выход OUT . Отличаются SR или RS только приоритетом. Приоритетом является первый
символ имени функции. Для SR приоритет SET , а для RS приоритет RESET .
Приоритет значит, что если одновременно на входную переменную SET и на RESET подать
сигнал, то у блока SR будет выход на OUT , потому что SET в приоритете, а у блока RS не
будет сигнала на выходе OUT , потому что RESET в приоритете.
В ST применения подобных блоков практически не встречается, если только вы не
переучивались с графических языков и просто пытаетесь повторить саму программу из
блоков на ST, а не логику программы.
Рассмотрим пример графической программы:
Есть некий сигнал xSignal . Если он пропадет, нам нужно зарегистрировать ошибку
xError . И даже если сигнал xSignal вернётся обратно, ошибка должна оставаться, пока
ее не сбросят кнопкой xReset .
Стр. 205 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В графическом языке кроме блока триггера нам понадобилось бы еще использовать блок
SR или RS для запоминания сигнала.
Посмотрим, как будет выглядеть код, если мы просто перенесем графическую программу в
ST:
VAR
xSignal: BOOL;
xError: BOOL;
xReset: BOOL;
FT1: F_TRIG;
SR1: SR;
END_VAR
FT1(CLK := xSignal);
SR(SET1 := FT1.Q, RESET := xReset, Q1 => xError);
Это очень компактный код самой программы. Вот как это будет выглядеть без ФБ:
VAR
xSignal:BOOL;
xSignalM:BOOL;
xReset: BOOL;
xError: BOOL;
END_VAR
IF NOT xSignal AND xSignalM THEN
xError := TRUE;
ELSIF xReset
xError := FALSE;
END_IF
xSignalM := xSignal;
Нам не обязательно использовать SR или RS . В ST необходимость использовать эти блоки
отсутствует полностью.
Хотя второй пример содержит больше строк кода, он содержит и ряд преимуществ:
1. Используется меньше памяти, так как мы не выделяем память под функциональные
блоки.
2. Зона определения переменных намного чище.
3. Читать такой код легче, так как логика того, что происходит с переменными, прямо
перед глазами, а не спрятана в ФБ.
Если переменных для запоминания предыдущего состояния нужно много, то можно
воспользоваться массивом, чтобы не засорять область объявления переменных. Пример:
Стр. 206 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
VAR
xButton:BOOL;
axM: ARRAY [0..20] OF BOOL; (* Память - Memory *)
xOut1: BOOL;
END_VAR
IF xButton AND NOT axM[0] THEN
xOut1 := TRUE;
END_IF
axM[0] := xButton;
Обработка сигналов
Шумоподавление
Дискретные сигналы тоже не лишены проблем, связанных с шумами, но они другого рода.
Шум может возникать в момент нажатия кнопки. Контакт может не сразу установиться,
может создаться несколько сигналов. На низких токах срабатывания, как, например, 3V,
наводки в цепях могут приводить к ложным срабатываниям. К чему это может привести,
подскажет воображение.
Данную проблему можно решить хардварно. То есть, предусмотреть фильтр на входе DI в
виде электронной схемы. Это может быть фильтр RС или ферритовое кольцо, или
опторазвязка.
Есть и программные решения проблемы. Обычно такой метод сглаживания называют
debounce. Его принцип в том, что устанавливается минимальное время, в течение которого
положительный сигнал должен быть устойчив. Пример:
FUNCTION_BLOCK DebounceIN
VAR_INPUT
X: BOOL; (* Входной сигнал *)
END_VAR
VAR_OUTPUT
Y: BOOL; (* Выходной сигнал *)
END_VAR
VAR
tStart: TIME; (* Время начала *)
M: BOOL; (* Память сигнала *)
END_VAR
IF X AND NOT M THEN
tStart := TIME();
END_IF;
M := X;
Y := X AND ((TIME() - tStart) > T#100ms);
Стр. 207 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_FUNCTION_BLOCK
Этот ФБ выдаст сигнал, только если на входе есть устойчивый сигнал минимум 100
миллисекунд.
Выделяем триггер переднего фронта и в этот момент сохраняем текущую точку времени.
Далее присваиваем выходной переменной значение, если входная переменная все еще не
изменила свое значение и 100 миллисекунд истекли.
Фильтровать нужно не только появление сигнала, а так же его потерю:
FUNCTION_BLOCK DebounceOut
VAR_INPUT
X: BOOL; (* Входной сигнал *)
END_VAR
VAR_OUTPUT
Y: BOOL; (* Выходной сигнал *)
END_VAR
VAR
tStart: TIME; (* Время начала *)
M: BOOL; (* Память сигнала *)
END_VAR
IF NOT X AND M THEN
tStart := TIME();
END_IF;
M := X;
Y := X;
IF NOT X AND ((TIME() - tStart) < T#100ms) THEN
Y := TRUE;
END_IF
END_FUNCTION_BLOCK
Здесь мы, напротив, выделяем задний фронт и в этот момент сохраняем точку во времени.
Затем, если входной сигнал все еще отсутствует и 100 миллисекунд не истекли,
продолжаем удерживать сигнал на выходе.
И в первом, и во втором случае мы используем время, а значит, могли просто использовать
ФБ таймера TON или TOF. Но без таймера понятнее, что происходит внутри. Подобные
решения подойдут для использования со своим триггером. DebounceIn для R_TRIG, а
DebounceOut для F_TRIG. Для DE их использование будет невозможно. Так что есть смысл
сделать Debounce, который фильтрует в обе стороны:
FUNCTION_BLOCK DebounceInOut
VAR_INPUT
X: BOOL; (* Входной сигнал *)
END_VAR
VAR_OUTPUT
Стр. 208 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Y: BOOL; (* Выходной сигнал *)
END_VAR
VAR
tStart: TIME; (* Время начала *)
M: BOOL; (* Память сигнала *)
END_VAR
IF (NOT X AND M) OR (X AND NOT M) THEN
tStart := TIME();
END_IF;
M := X;
Y := X;
IF ((TIME() - tStart) < T#100ms) THEN
Y := NOT X;
END_IF
END_FUNCTION_BLOCK
В этом примере мы сохранили отметку времени на переднем или заднем фронте сигнала.
Далее мы передали вход X на Y . И только если не прошло 100 миллисекунд с момента
срабатывания любого триггера, мы удерживаем старое состояние переменной X . Если в
течение 100 миллисекунд сигнал вернется на место, то скачка на выходе не будет. Если же
произойдет скачок, то tStart изменится и мы начнем отсчет новых 100 миллисекунд.
Теперь можно написать идеальный блок определения переднего или заднего фронта с
фильтрацией от шумов. Вот пример блока DEF (Detect Edge Filtered), выделяющего
передний и задний фронт с фильтром на 100 миллисекунд:
FUNCTION_BLOCK DEF
VAR_INPUT
CLK:BOOL;
END_VAR
VAR_OUT
QR: BOOL; (* R От слова Raise *)
QF: BOOL; (* F От слова Fall *)
END_VAR
VAR
fbDIO1: DebounceInOut;
FCLK: BOOL; (* Отфильтрованный вход CLK *)
M: BOOL;
END_VAR
fbDIO1(X := CLK, Y => FCLK);
QR := QF := FALSE;
IF FCLK AND NOT M THEN
QR := TRUE;
ELSIF NOT FCLK AND M THEN
QF := TRUE;
END_IF
M := FCLK;
Стр. 209 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_FUNCTION_BLOCK
Сначала фильтруем входной сигнал, который делает задержу на 100 миллисекунд, как на
падении, так и на поднятии сигнала. Если колебания будут короче 100 миллисекунд, то он
их проигнорирует. Уже потом выделяем импульс на переднем и заднем фронте
отфильтрованного сигнала.
Выделение
Любое нажатие кнопки, как правило, нужно обрабатывать триггером. Это связано со
спецификой циклического исполнения программы контроллера. Рассмотрим небольшую
задачу: требуется менять состояние выходного сигнала по каждому нажатию кнопки.
Данный пример отчетливо демонстрирует необходимость триггеров:
IF xButton THEN
xOut1 := NOT xOut1;
END_IF
По логике, по нажатии кнопки xButton мы переворачиваем сигнал выхода xOut1 , таким
образом, xOut1 должна инвертироваться при каждом нажатии xButton . Приведенный код
не будет корректно работать, потому что контроллер исполняет программу непрерывно
(См. Порядок исполнения программы). Это значит, что даже за короткое время удержания
кнопки контроллер может успеть обработать программу несколько раз. По нашим меркам
времени мы сделали одно короткое нажатие, а по меркам времени контроллера он прожил
10-20 жизней. Это значит, что переворот сигнала xOut1 := NOT xOut1; тоже произойдет
несколько раз. В итоге, если при отпускании кнопки контроллер успел сделать нечетное
количество проходов, то сигнал xOut1 изменится. Если было четное количество
проходов, то сигнал останется неизменным. Это совсем не то, что мы хотим.
Чтобы исправить этот код, нужно использовать триггер. Вот пример решения с
использованием блока:
VAR
xButton: BOOL;
fbRT1: R_TRIG;
xOut1 AT %Q0.1: BOOL;
END_VAR
fbRT1(CLK := xButton);
IF fbRT1.Q THEN
xOut1 := NOT xOut1;
END_IF
Стр. 210 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Или тот же самый пример, но без использования блока, а использования принципа,
который мы выучили:
VAR
xButton: BOOL;
xButtonM: BOOL;
xOut1 AT %Q0.1: BOOL;
END_VAR
IF xButton AND NOT xButtonM THEN
xOut1 := NOT xOut1;
END_IF
xButtonM := xButton;
Клики
Часто требуется дополнительный функционал. У нас может быть только одна кнопка, но
мы хотим выполнять разные действия. Например, долгим нажатием кнопки "пуск"
сбросить все ошибки. Это может касаться не только входов ПЛК, но и переменных внутри
программы, связанных с HMI.
Длинный клик
FUNCTION_BLOCK LongPress
VAR_INPUT
X: BOOL; (* Входной сигнал *)
TM: TIME; (* Время нажатия *)
END_VAR
VAR_OUTPUT
Y: BOOL; (* Выходной сигнал *)
END_VAR
VAR
tStart: TIME; (* Время начала *)
M: BOOL; (* Память сигнала *)
END_VAR
IF X AND NOT M THEN
tStart := TIME();
END_IF;
M := X;
Y := X AND ((TIME() - tStart) > TM);
END_FUNCTION_BLOCK
Этот ФБ идентичен debounce, отличается лишь тем, что время увеличено и вынесено во
входные параметры. Вы сами можете обозначить, как долго должна быть нажата кнопка.
Стр. 211 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Двойной клик
FUNCTION_BLOCK DoublePress
VAR_INPUT
X: BOOL; (* Входной сигнал *)
END_VAR
VAR_OUTPUT
Y: BOOL; (* Выходной сигнал *)
END_VAR
VAR
tStart: TIME; (* Время начала *)
M: BOOL; (* Память сигнала *)
END_VAR
Y := FALSE;
IF X AND NOT M THEN
IF (TIME() - tStart) > T#100ms AND (TIME() - tStart) < T#800ms THEN
Y := TRUE;
END_IF;
tStart := TIME();
END_IF;
M := X;
END_FUNCTION_BLOCK
Как видите, разница с предыдущим блоком незначительна. Строка IF X AND NOT M THEN
отлавливает момент, когда появляется сигнал. Подробнее об этом поговорим в следующем
подразделе. В этот момент сохраняем текущую точку во времени, но ранее проверяем если прошлая точка была не ранее, чем 100 миллисекунд, но и не позже 800 миллисекунд,
значит это двойной клик.
Здесь условие на 100 миллисекунд сразу же является сглаживанием debounce.
Выходные сигналы
Выходные сигналы так же требуют обработки в некоторых случаях. Рассмотрим несколько
полезных функций.
PWM
Часто требуется подать на выход пульсирующий сигнал. Такой сигнал называется ШИМ
(PWM).
Широтно-импульсная модуляция (ШИМ, англ. pulse-width modulation (PWM)) — процесс
управления мощностью методом пульсирующего включения и выключения прибора.
Стр. 212 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Различают аналоговую ШИМ и цифровую ШИМ, двоичную (двухуровневую) ШИМ и
троичную (трёхуровневую) ШИМ. Мы будем говорить только о цифровом ШИМ.
Прежде чем мы рассмотрим сами ФБ, которые генерируют ШИМ сигнал, нужен будет ФБпомощник CLK_PWM . Этот ФБ создает импульсы с заданной частотой:
FUNCTION_BLOCK CLK_PWM
VAR_INPUT
EN: BOOL;
PT: TIME := T#10ms;
END_VAR
VAR_OUTPUT
Q: BOOL;
END_VAR
VAR
M: BOOL;
last: TIME;
tm: TIME;
END_VAR
tm := TIME();
IF EN AND NOT M THEN
last := tm - PT;
END_IF;
M := EN;
Q := tm - last >= PT;
IF Q THEN last := tm; END_IF;
END_FUNCTION_BLOCK
Если вызвать этот блок вот так:
VAR
fbCP: CLK_PWM;
END_VAR
fbCP(EN := TRUE, PT := T#5ms);
На выходе fbCP.Q будет импульс в один цикл ПЛК, раз в 5 миллисекунд.
PWM_DC
ШИМ-сигнал заданной частоты со смещением соотношения. Каждый цикл ШИМ можно
разделить на 2 фазы: Ton (время включения) и Toff (время выключения). Соотношение
задается от 0 до 1. Например, соотношение 0.5 разделит время каждого цикла шим на 50%
на Ton и 50% на Toff.
FUNCTION_BLOCK PWM_DC
VAR_INPUT
F: REAL; (* Частота, раз в секунду *)
Стр. 213 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
DC: REAL; (* Соотношение *)
END_VAR
VAR_OUTPUT
Q: BOOL;
END_VAR
VAR
CLK: CLK_PWM;
TP1: TP;
tmp: REAL;
END_VAR
(* Если частота ШИМ 0, то прерываем исполнение ФБ *)
IF F <= 0.0 THEN
Q := FALSE;
RETURN;
END_IF;
(* Вычисляем сколько миллисекунд требуется на один цикл ШИМ *)
tmp := 1000.0 / F;
(* Запускаем генератор сигналов *)
CLK(PT := REAL_TO_TIME(tmp));
(* Создаем выдержанный пульс на нужное время *)
TP1(in := clk.Q, pt := REAL_TO_TIME(tmp * DC), Q => Q);
END_FUNCTION_BLOCK
PWM_PW
Создает ШИМ-сигнал с заданным временем фазы Ton. Например, мы можем установить F
на 100 раз в секунду. Это значит, один цикл ШИМ будет 10 миллисекунд. Теперь поставим
PW в T#5ms и получим равное время для фазы Ton и Toff по 50%.
FUNCTION_BLOCK PWM_PW
VAR_INPUT
F: REAL; (* Частота, раз в секунду (Гц) *)
PW: TIME; (* Длина фазы Ton *)
END_VAR
VAR_OUTPUT
Q: BOOL;
END_VAR
VAR
CLK: CLK_PWM;
TP1: TP;
END_VAR
(* Если частота ШИМ 0 или время одного цикла меньше,
чем время для фазы Ton, то прерываем исполнение ФБ *)
IF F <= 0.0 OR ((1000.0 / F) < PW) THEN
Q := FALSE;
RETURN;
END_IF;
Стр. 214 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
(* Запускаем генератор сигналов *)
CLK(PT := REAL_TO_TIME(1000.0 / F));
(* Создаем выдержанный пульс на нужное время
TP1(in := CLK.Q, PT := PW, Q => Q);
END_FUNCTION_BLOCK
Стр. 215 из 298
*)
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Обработка ошибок
Неотъемлемой частью любой программы является отслеживание ошибок в ее работе.
Имеются в виду не ошибки кода, а ошибки процесса, например: бак переполнен, ошибка
работы мотора, коробка сместилась на ленте и т.п. В этом случае не просто нужно
отследить ошибку, но и передать ее дальше.
Обработка ошибок ставит следующие задачи:
1. Регистрация индивидуальных ошибок. Ошибки бывают разные. Одни из них
должны блокировать работу техпроцесса, другие - нет. Некоторые ошибки
производят на выходе только импульс, некоторые - постоянны. Нужен способ
регистрировать наличие ошибки, чтобы даже когда ошибки уже не будет, состояние
все еще сообщало об ошибке, пока не будет произведен ручной сброс.
2. Общая регистрация наличия ошибки. Она может применяться, например, на
сигнализации. Если хотя бы одна ошибка произошла, то регистрируем общую
ошибку и включаем сигнализацию. Можно будет отключить сигнализацию сбросом,
даже если ошибка все еще присутствует в программе.
3. Способность сброса ошибок. Все зарегистрированные ошибки уходят только после
сброса в ручную или автоматически. Мы знаем, что после сброса регистрация
отменяется, даже если ошибка все еще в системе. Выключаем звуковой сигнал, при
этом все еще видим текущие ошибки. Сбрасываем и все индивидуальные ошибки, и
если они блокировали какие-то процессы, то через сброс возобновляем работу.
4. Сигнал текущего наличия ошибки. Этот сигнал не сбрасывается кнопкой сброс,
сигнал есть, пока имеется хотя бы одна ошибка. Это можно использовать для
передачи на панель оператора или для индикации ошибки на двери щита.
5. Номер последней ошибки. Эта переменная может быть использована для передачи
на панель оператора, чтобы отобразить описание последней ошибки, используя
элемент динамического текста.
6. Мигатель ошибки. Этот выход можно подключить к лампочке, и она будет мигать
столько раз, какой номер ошибки у нас в программе. Это удобно, когда у нас нет
панели, а хочется понять, какая именно ошибка произошла. Если номер ошибки
большой, не всегда удобно считать мигания, но это, по крайней мере, лучше, чем
просто горящая лампочка - мы можем понять, что произошло.
Стр. 216 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Универсальный блок обработки
ошибок
Предлагаю следующую систему обработки ошибок, которая состоит из нескольких блоков.
1. Объект ошибки
Для хранения и операций с ошибками использовать структуру _ERR :
TYPE _ERR : STRUCT
State:
BOOL;
(* Текущее состояние *)
StateM:
BOOL;
(* Состояние прошлого цикла *)
Msg:
STRING[100]; (* Сообщение ошибки *)
Zoomer:
BOOL;
(* Передать на звуковую сигнализацию *)
Latch:
BOOL;
(* Регистрировать ошибку *)
LatchState: BOOL;
(* Состояние зарегистрированное *)
Lock:
BOOL;
(* Ошибка блокирует процесс *)
END_STRUCT;
END_TYPE
Номер ошибки фактически будет равен индексу в массиве, где будут храниться все
ошибки.
• State - сигнал управления ошибкой. Как только тут появится TRUE , будет считаться,
что ошибка произошла.
• StateM - состояние прошлого цикла для определение фронта сигнала ошибки.
• Msg - сообщение ошибки или описание ошибки.
• Zoomer - передать сигнал на звуковую сигнализацию, если произошла эта ошибка.
Если нет регистрации, зуммер будет работать, пока есть ошибка. Как только ошибка
пропадёт, зуммер утихнет. При наличии регистрации, зуммер будет работать до тех
пор, пока не будет произведен ручной сброс.
• Latch - зарегистрировать ошибку. Регистрация происходит в момент, когда ошибка
появляется, иными словами, на переднем фронте сигнала ошибки. Регистрация
остается, даже если ошибка уже пропала, до тех пор, пока не будет произведен
ручной сброс. Если, пока ошибка есть, произвести сброс, то регистрация будет
сброшена, так как она назначается только в момент появления ошибки. Значит, что
после сброса, чтобы новая регистрация произошла, нужно, чтобы ошибка пропала и
снова появилась, или просто снова появилась.
Стр. 217 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Совместно с параметром Zoomer можно осуществить подачу звукового сигнала,
даже после того, как ошибка пропала из системы, чтобы привлечь внимание
оператора.
• LatchState - зарегистрированное состояние.
• Lock - показывает, блокирует эта ошибка техпроцесс или нет.
После создания типа нужно объявить глобальную переменную массива, хранящего все
ошибки:
CONFIGURATION
VAR_GLOBAL
glbErrors: ARRAY[1.._ERR_NUMS] OF _ERR
(Msg := 'Too hot', Latch := TRUE),
(Msg := 'Too cold', Latch := TRUE, Lock := TRUE),
(Msg := 'Motor failed', Zoomer := TRUE, Latch := TRUE);
END_VAR
VAR_GLOBAL CONSTANT
_ERR_NUMS: UINT := 3;
END_VAR
END_CONFIGURATION
При определении массива ошибок, сразу инициализируем имена каждой ошибки,
параметры регистрации и сигнализации. Учтем, что по умолчанию все значения равны
FALSE , так что нам нужно определить только те, что будут равны TRUE .
2. Функции управления ошибками
Эта функция позволяет быстро управлять ошибкой по номеру. Здесь мы назначаем ошибку.
Если нужно - регистрируем ошибку.
FUNCTION ERR_SET: BOOL
VAR_INPUT
Num: INT; (* Номер ошибки *)
Val: BOOL; (* Значение ошибки *)
EBD_VAR;
glbErrors[Num].State = Val;
(* Если ошибка только что произошла и нужна регистрация,
регистрируем ошибку *)
IF Val AND NOT glbErrors[Num].StateM AND glbErrors[Num].Latch THEN
glbErrors[Num].LatchState := TRUE;
END_IF
glbErrors[Num].StateM := Val;
END_FUNCTION
Стр. 218 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Следующая функция нужна для сброса ошибки или сброса всех ошибок:
FUNCTION ERR_RESET: BOOL
VAR_INPUT
Num: INT; (* Номер ошибки *)
EBD_VAR;
glbErrors[Num].LatchState := glbErrors[Num].State := FALSE;
END_FUNCTION
FUNCTION ERR_RESET_ALL: BOOL
VAR_INPUT
EN: BOOL; (* Сигнал на сброс *)
EBD_VAR;
VAR
i: INT : = 1;
END_VAR;
IF EN THEN
FOR i := 1 TO _ERR_NUMS DO
ERR_RESET(i);
END_FOR;
END_IF;
END_FUNCTION
Еще одна функция - наличие блокирующих процесс ошибок. Ее удобно использовать для
быстрого блокирования техпроцесса:
FUNCTION ERR_BLOCK: BOOL
VAR
i: INT : = 1;
END_VAR;
ERR_BLOCK := FALSE;
FOR i := 1 TO _ERR_NUMS DO
IF (glbErrors[i].LatchState OR glbErrors[i].State) AND
glbErrors[i].Lock
THEN
ERR_BLOCK := TRUE;
END_IF;
END_FOR;
END_FUNCTION
3. Общий блок обработки ошибок
Рассмотрим универсальный блок обработки ошибок:
FUNCTION_BLOCK Alarms
VAR_INPUT
inReset: BOOL; (* Сброс зарегистрированных ошибок *)
END_VAR
VAR_OUTPUT
Стр. 219 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
outLastAlarm:
INT; (* Номер последней ошибки *)
outIsAlarm:
BOOL; (* Есть ошибки на индикатор/на панель *)
outIsZoomer:
BOOL; (* Есть ошибки для зуммера *)
outBlinker:
BOOL; (* Мигает номер ошибки *)
END_VAR
VAR
inResetM: BOOL; (* Память сброса для определения фронта *)
bCount: BYTE; (* Счётчик для цикла *)
END_VAR
(* Если мы подали сигнал сброса, сбрасываем общую регистрацию
ошибки, а так же номер последней ошибки *)
IF inReset AND NOT inResetM THEN
ERR_RESET_ALL();
outLastAlarm := 0;
END_IF;
inResetM := inReset;
outIsAlarm := outIsZoomer := FALSE;
(* Проходим в цикле массив входных ошибок *)
FOR bCount := 1 TO _ERR_NUMS DO
(* Смотрим, если присутствует ошибка, то назначаем состояние
общей ошибки *)
IF glbErrors[bCount].State OR glbErrors[bCount].LatchState THEN
outIsAlarm := TRUE;
outLastAlarm := bCount;
END_IF;
(* Включаем зуммер, если зуммер разрешен и ошибка
зарегистрирована *)
IF glbErrors[bCount].LatchState AND glbErrors[bCount].Zoomer THEN
outIsZoomer := TRUE;
END_IF;
END_FOR;
END_FUNCTION_BLOCK
Входные переменные
• inReset - Сброс всех зарегистрированных ошибок - как индивидуальных, так и
общих.
Выходные переменные
• outLastAlarm - Номер последней ошибки. Можно использовать для передачи в
панель оператора или для мигателя.
• outIsAlarm - Текущее наличие ошибки.
• outIsZoomer - Включит звуковую сигнализацию.
• outBlinker - Мигает номер ошибки.
Стр. 220 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Локальные переменные
• inResetM - Память прошлого состояния входа сброс, для выделения фронта.
• bCount : - Вспомогательная переменная для прохода в цикле по массиву ошибок.
Логика
Я постарался закомментировать код. Смотрите комментарии в коде. Вы уже должны быть
знакомы со всеми основными концептами, поэтому комментарии минимальны.
Пример использования
Лучший пример всегда в контексте какой-то задачи, поэтому рассмотрим его следующей
главе, где будем говорить о том, как запускать двигатель.
Стр. 221 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Запуск двигателей
Одной из самых частых задач является запуск двигателя. Двигателем может быть
вентилятор, конвейер, насос, и т.д. Сценариев работы тоже множество. Насосная станция это, допустим, станция заполнения бака водой или, напротив, опустошения. Насосов
может быть один, два и больше, а сценарии их работы - основной и резервный,
попеременная работа, вспомогательная работа, и т.п.
Универсальный блок
В этом примере напишем универсальный ФБ запуска двигателя, а затем рассмотрим, как с
его помощью можно решить разные сценарии:
FUNCTION_BLOCK Drive
VAR_INPUT
inEnable: BOOL; (* Разрешение на работу *)
inTask:
BOOL; (* Задача на работу *)
inKF:
BOOL; (* Контроль фаз *)
inBack:
BOOL; (* Обратная связь включения *)
inReset: BOOL; (* Сброс ошибки *)
END_VAR
VAR_OUTPUT
outDrive: BOOL; (* Включить мотор *)
outError: BOOL; (* Задача на работу есть, но мотор не работает *)
END_VAR
VAR
fbTON1: TON; (* Таймер задержки для проверки обратной связи
от двигателя *)
fbTON2: TON; (* Таймер для ошибки, как фильтр обратного сигнала *)
END_VAR
outDrive := FALSE;
IF inReset THEN
outError := FALSE;
END_IF;
IF NOT inEnable THEN
outError := FALSE;
ELSE
IF inTask AND NOT outError THEN
outDrive := inKF;
IF fbTON1.Q AND NOT inBack THEN
fbTON2(IN := TRUE, PT := T#500ms);
IF fbTON2.Q THEN
fbTON2(IN := FALSE);
Стр. 222 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
outError := TRUE;
END_IF;
END_IF;
ELSE
outDrive := FALSE;
fbTON2(IN := FALSE);
END_IF;
fbTON1(IN := outDrive, PT := T#500ms);
END_IF;
END_FUNCTION_BLOCK
Опишем входные и выходные переменные
Входные
• inEnable - Разрешение на работу. Если сигнала нет, то задача на включение
двигателя подаваться не будет. Это не задача для работы двигателя, а общий
рубильник. Так сказать, включение и выключение работы самого блока.
• inTask - Задача на работу. Сигнал, который включает сам двигатель. Это может
быть реле времени - если включаем вентилятор по времени, или реле уровня - если
включаем насос на наполнение бака, или поплавок для той же задачи, а, может, реле
давления, и т.п.
• inKF - Сигнал с реле контроля фаз. Он должен присутствовать для нормальной
работы, но сюда можно подключить любой не блокирующий сигнал. Иными
словами, любой сигнал, который остановит работу двигателя, но не выдаст ошибки,
потому что это не ошибка работы двигателя, а ошибка в электрической сети. Почему
мы не расцениваем нарушения контроля фаз как ошибку? Потому что ошибка
блокирует работу двигателя до проверки и сброса оператором. После ошибки
двигателя, нам нельзя его запускать, это может привести к его поломке. После
ошибки контроля фаз, двигатель можно снова запустить без внимания оператора.
Допустим, у нас в сети перенапряжение, через час оно ушло, мы можем снова
запустить двигатель.
• inBack - Обратная связь включения двигателя. Обычно НО контакт на контакторе.
Должен быть сигнал, если есть выход на включение двигателя.
Если сигнала нет, то блок считает, что произошла ошибка. Сломался контактор, или
отключился двигатель по защите. Подобная ошибка блокирует работу двигателя,
переводит блок в режим ошибки и не даёт запускать двигатель до тех пор, пока не
Стр. 223 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
будет произведен сброс, даже если контакт вернулся в нормальное для блока
положение.
Сюда можно подключить так же и любые блокирующие работу двигателя контакты.
• inReset - Сброс ошибки. После сброса блок снова выходит в рабочее состояние.
Выходные
• outDrive - Задача на включение мотора. Обычно назначается прямым ходом на
выход ПЛК.
• outError - Наличие ошибки в блоке. Используется для вывода на панель или для
переключения на другой блок.
Локальные
• fbTON1 - Таймер TON для проверки обратной связи от двигателя.
• fbTON2 - Таймер TON для ошибки, как фильтр обратного сигнала.
Теперь рассмотрим сам код. Мы рассмотрим код без применения к нему паттерна
минимизации вложенности, но в описании условий IF мы рассматриваем этот пример и
применим к нему наш паттерн.
Назначаем задачу на двигатель в "выключено". Используем паттерн предварительного
отрицания:
outDrive := FALSE;
Обрабатываем сброс. Если есть сигнал на сбросе, то сбрасываем выходную переменную
ошибки:
IF inReset THEN
outError := FALSE;
END_IF;
Далее, если блок выключен, сбрасываем ошибку. Выключение блока и включение его
заново работает как сброс. И это логично: обычно все так работает - если выключить и
включить, то должно все сброситься. При этом выполняем команду RETURN , которая
прекращает дальнейшую работу блока. Ничего после этой строки не исполняется. Если
блок выключен, не нужно исполнять код работы блока. Просто убедимся, что двигатель
выключен, мы это уже сделали строкой выше:
Стр. 224 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
IF NOT inEnable THEN
outError := FALSE;
ELSE
Далее логика:
IF inTask AND NOT outError THEN
// Логика программы
ELSE
outDrive := FALSE;
fbTON2(IN := FALSE);
END_IF;
fbTON1(IN := outDrive, PT := T#500ms);
Сначала объясню основную конструкцию. Код вложенной логики пока удален. У нас есть
условие IF inTask AND NOT outError THEN . Говоря простыми словами, если есть задача
двигателю работать и блок не в состоянии ошибки, то тут исполним логику, которую
разберем позже, в любом другом случае в ELSE выключаем двигатель.
Еще одна строка - запуск таймера:
fbTON1(IN := outDrive, PT := T#500ms);
Это таймер, который начинает отсчет, как только задача на включение двигателя
поставлена на выход outDrive .
Это нужно для проверки обратной связи. Контакт обратной связи - устройство
механическое, может потребоваться какое-то время, пока он замкнется. Это время может
быть дольше, чем 2 цикла ПЛК. Без таймера задержки проверки может сложиться
ситуация, что в одном цикле ПЛК мы поставили задачу на включение, а в следующем,
проверяя обратную связь, обнаружили, что ее нет, и тогда произойдет блокирующая
ошибка. Хотя причина может быть в том, что механически потребовалось больше 10
миллисекунд времени для срабатывания контакта, а за это время ПЛК уже перешел на
следующий цикл.
outDrive := inKF;
IF fbTON1.Q AND NOT inBack THEN
fbTON2(IN := TRUE, PT := T#500ms);
IF fbTON2.Q THEN
fbTON2(IN := FALSE);
outError := TRUE;
END_IF;
END_IF;
Стр. 225 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Теперь посмотрим логику программы. Включаем двигатель, если с контролем фаз все
нормально. Обратите внимание, что ошибка контроля фаз не блокирует работу двигателя, а
просто отключает его. Как только контроль фаз придет в порядок, двигатель заработает.
Далее в условии смотрим: если таймер fbTON1 закончил отсчет, другими словами,
задержка на проверку обратной связи истекла и обратной связи нет, то включаем второй
таймер. В противоположность ошибке контроля фаз, ошибка работы двигателя блокирует
работу блока до тех пор, пока не будет произведен сброс.
По сути, мы могли просто назначить ошибку без второго таймера fbTON2 . Вот так:
outDrive := inKF;
IF fbTON1.Q AND NOT inBack THEN
outError := TRUE;
END_IF;
Как мы знаем, нет ничего стабильного. Из миллиона циклов ПЛК, которые он сделает за
неделю или месяц, возможно, всего на один цикл, по какой то причине, сигнала обратной
связи не будет. Не по тому, что его действительно нет, а потому что произошло что-то шум или ошибка ПЛК.
Поэтому используем еще один таймер fbTON2 , который работает как фильтр. Он
проверяет, если обратный сигнал устойчиво отсутствует больше 500 миллисекунд, тогда
переводим блок в режим ошибки.
Примеры работы
Рассмотрим разные сценарии работы программ, используя этот блок.
Запуск одного двигателя
Задача состоит в том, чтобы запустить один двигатель - насос, по сигналу с поплавка.
Как дополнение, применим здесь блок управления ошибками - для наглядности его
использования в конкретной задаче.
Сначала определим переменные, привязанные к реальным входам DI и выходам DO ПЛК,
а так же необходимые переменные для управления ошибками:
CONFIGURATION
VAR_GLOBAL
DI_xKF
AT %IX0.0: BOOL; (* Вход ПЛК, реле контроля фаз *)
DI_xDriveBack AT %IX0.1: BOOL; (* Обратная связь от мотора *)
Стр. 226 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
DI_xLevel
DI_xLevelErr
DI_xReset
DO_xDrive
DO_xError
AT %IX0.2: BOOL; (* Индикатор уровня *)
AT %IX0.3: BOOL; (* Индикатор аварийного уровня *)
AT %IX0.4: BOOL; (* Кнопка сброса ошибки мотора *)
AT %QX0.0: BOOL; (* Выход ПЛК, включение мотора *)
AT %QX0.1: BOOL; (* Выход ПЛК, индикатор наличия
ошибки *)
DO_xErrSignal AT %QX0.2: BOOL; (* Выход ПЛК, звуковая
сигнализация ошибки *)
glbErrors: ARRAY[1.._ERR_NUMS] OF _ERR
(Msg := "Ошибка двигателя", Zoomer := TRUE,
Latch := TRUE, Lock := TRUE),
(Msg := "Ошибка контроля фаз"),
(Msg := "Ошибка аварийного уровня", Zoomer := TRUE);
END_VAR
VAR_GLOBAL CONSTANT
_ERR_NUMS: UINT := 3;
END_VAR
END_CONFIGURATION
Рассмотрим ошибки:
1. Ошибка двигателя. Выдает звуковую сигнализацию ( Zoomer := TRUE ) и будет
зарегистрирована ( Latch := TRUE ). Это значит, что до тех пор, пока не будет
произведен ручной сброс, зуммер подает сигнал. Эта ошибка блокирует ( Lock :=
TRUE ) техпроцесс.
2. Ошибка контроля фаз, без зуммера. При ее наличии горит лампочка ошибки на
панели щита.
3. Ошибка уровней. Имеет зуммер, но не регистрируется. Зуммер будет работать, пока
есть авария уровня. Как только уровень нормализуется, пропадёт ошибка и зуммер.
Сама программа:
PROGRAM PLC_PRG
VAR
fbDrive: Drive; (* Мотор *)
fbAlarms: Alarms; (* Управление ошибками *)
END_VAR
fbDrive(
inEnable := NOT ERR_BLOCK(),
inTask
:= DI_xLevel,
inKF
:= DI_xKF,
inBack
:= DI_xDriveBack,
inReset := DI_xReset,
outDrive => DO_xDrive
);
(* Первая ошибка - мотор в ошибке *)
Стр. 227 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
ERR_SET(1, fbDrive.outError);
(* Вторая ошибка - реле контроля фаз *)
ERR_SET(2, NOT DI_xKF);
(* Третья ошибка - достигнут аварийный уровень *)
ERR_SET(3, DI_xLevelErr);
fbAlarms(
inReset
outIsAlarm
outIsZoomer
);
END_PROGRAM
:= DI_xReset,
=> DO_xError,
=> DO_xErrSignal
Как видите, теперь мы умеем очень быстро запускать двигатель. Все это можно было
написать одной строкой, если бы не обработка ошибок.
2 Насоса
Рассмотрим еще одну задачу. 2 насоса откачивают воду из накопительной емкости. Вот,
что нужно решить:
1. Работа насосов происходит по поплавку рабочего уровня.
2. Выравнивание моточасов. Насосы работают поочередно: сутки один, сутки другой.
3. При аварийном уровне дополнительно должен включиться второй насос.
4. Если насос, который в данное время должен работать, вышел из строя, его заменит
другой.
VAR_GLOBAL
DI_xKF
AT %IX0.0: BOOL; (* Вход ПЛК, реле контроля фаз *)
DI_xDrive1Back AT %IX0.1: BOOL; (* Обратная связь от мотора 1 *)
DI_xDrive2Back AT %IX0.2: BOOL; (* Обратная связь от мотора 2 *)
DI_xReset1
AT %IX0.3: BOOL; (* Кнопка сброса ошибки мотора 1 *)
DI_xReset2
AT %IX0.4: BOOL; (* Кнопка сброса ошибки мотора 2 *)
DI_xLevelWork AT %IX0.5: BOOL; (* Поплавок рабочего уровня *)
DI_xLevelAlarm AT %IX0.6: BOOL; (* Поплавок аварийного уровня *)
DO_xDrive1
AT %QX0.0: BOOL; (* Выход ПЛК, включение мотора 1 *)
DO_xDrive2
AT %QX0.1: BOOL; (* Выход ПЛК, включение мотора 2 *)
END_VAR
Снова определим входы и выходы ПЛК. И сама программа:
PROGRAM PLC_PRG2
VAR
fbBlink: BLINK; (* Выравнивание моточасов *)
fbDrive1: Drive; (* Мотор 1 *)
fbDrive2: Drive; (* Мотор 2 *)
Стр. 228 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
xTask1, xTask2 : BOOL; (* Задача на работу двигателю *)
END_VAR
fbBlink(IN := TRUE, TIME_LOW := T#24h, TIME_HIGH := T#24h);
xTask1 := (fbBlink.OUT AND DI_xLevelWork) OR DI_xLevelAlarm;
xTask2 := (NOT fbBlink.OUT AND DI_xLevelWork) OR DI_xLevelAlarm;
fbDrive1(
inEnable := TRUE,
inTask := (xTask1 OR (xTask2 AND fbDrive2.outError)),
inKF := DI_xKF,
inBack := DI_xDrive1Back,
inReset := DI_xReset1,
outDrive => DO_xDrive1
);
fbDrive2(
inEnable := TRUE,
inTask := (xTask2 OR (xTask1 AND fbDrive1.outError)),
inKF := DI_xKF,
inBack := DI_xDrive2Back,
inReset := DI_xReset2,
outDrive => DO_xDrive2
);
END_PROGRAM
Обратите внимание на лаконичность кода. Если его отформатировать для реальной
программы, а не для примера, который должен удобно читаться и поместиться в ширину
листа книги, то получится всего 3 строки кода.
Итак, объявлен ФБ fbBlink , который работает с периодичностью 24 часа. Если на выходе
этого блока OUT есть сигнал, то запускаем один двигатель, если нет - другой. Таким
образом, сутки работает один двигатель, сутки - другой. Единственный недостаток такого
решения - это то, что отсчет суток начнется с момента включения программы. Это не
всегда приемлемо, поэтому можно сделать свой блок, который будет переключать по часам
реального времени в точное заданное время.
Далее определяем переменные xTask2 и xTask2 для включения насосов. Тут все просто:
если рабочий поплавок DI_xLevelWork сработал и сейчас цикл для этого двигателя, то
включаем задачу. Есть еще OR DI_xLevelAlarm . Аварийный уровень означает, что первый
насос не справляется и что нужно включить второй. Получается, что при аварийном
уровне всегда работают 2 насоса, поэтому добавляем это условие на любой насос.
На входных переменных inTask ставим условие (xTask1 OR (xTask2 AND
fbDrive2.outError)) . Поясню словами: задача на этот насос, или же задача на другой
насос, но тот насос в ошибке.
Таким образом мы закрываем все 4 поставленные задачи.
Стр. 229 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
3 насоса по очереди
Этот пример более сложный, но без объяснений - только комментарии в коде.
Постарайтесь сами разобраться, что происходит в программе.
Итак, задача:
1. Три насоса, включаются по реле давления.
2. Включение поочередное, каждый раз при новом включении, включается следующий
насос.
3. 2 реле давления. Одно для максимального давления - отключает все насосы, когда
давление набрано. Второе реле для минимального давления, если оно падает ниже
минимального, включаем дополнительно любой свободный насос.
Сначала объявим наши входы и выходы:
VAR_GLOBAL
DI_xKF
AT %IX0.0: BOOL; (* Вход ПЛК, реле контроля фаз *)
DI_xDrive1Back AT %IX0.1: BOOL; (* Обратная связь от мотора 1 *)
DI_xDrive2Back AT %IX0.2: BOOL; (* Обратная связь от мотора 2 *)
DI_xDrive3Back AT %IX0.3: BOOL; (* Обратная связь от мотора 3 *)
DI_xReset
AT %IX0.4: BOOL; (* Кнопка сброса ошибки *)
DI_xPressMin
AT %IX0.5: BOOL; (* Минимальное давление в магистрали *)
DI_xPressMax
AT %IX0.6: BOOL; (* Максимальное давление в магистрали *)
DO_xDrive1
AT %QX0.0: BOOL; (* Выход ПЛК, включение мотора 1 *)
DO_xDrive2
AT %QX0.1: BOOL; (* Выход ПЛК, включение мотора 2 *)
DO_xDrive3
AT %QX0.2: BOOL; (* Выход ПЛК, включение мотора 3 *)
END_VAR
Нам понадобится дополнительная функция, чтобы код был чище. Она будет увеличивать
номер насоса при каждом включении от 1 до 3 и по достижении опять начнет с 1.
FUNCTION BumpUp : BYTE
VAR_INPUT
CurVal: BYTE; (* Текущее значение *)
END_VAR
IF CurVal >= 3 THEN
CurVal := 1;
ELSE
CurVal := CurVal + 1;
END_IF;
BumpUp := CurVal;
END_FUNCTION
Теперь сама программа:
PROGRAM PLC_PRG3
VAR
Стр. 230 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
arDrives: ARRAY[1..3] OF Drive; (* Массив из моторов *)
xM1, : BOOL; (* Память для выделения фронта *)
xStart : BOOL; (* Память для выделения фронта *)
bLastPump: BYTE := 1; (* Номер последнего работающего насоса *)
i:INT;
count: BYTE; (* Количество текущих работающих насосов *)
END_VAR
(* Если все моторы в ошибке, то прерываем программу *)
IF arDrives[1].outError AND
arDrives[2].outError AND
arDrives[3].outError
THEN
RETURN;
END_IF;
(* Каждый раз, когда давление падает ниже максимального,
меняем номер насоса. Для этого выделяем задний фронт
сигнала с датчика максимального давления *)
IF NOT DI_xPressMax AND xM THEN
bLastPump := BumpUp(bLastPump);
END_IF;
xM := DI_xPressMax;
(* Номер мы сменили, а вдруг насос в ошибке? Поэтому проходим
циклом, чтобы проверить насос на ошибку, и если есть ошибка выбрать следующий насос, у которого нет ошибки. А что если
следующий тоже в ошибке?
По этому выбираем следующий насос в цикле, пока не найдем нужный.
А что если все в ошибке? Мы будем вечно крутиться в цикле? Нет,
в начале программы мы делаем выход, если ошибка на всех насосах,
так что мы можем быть уверены, что хоть один рабочий насос у
нас есть *)
WHILE arDrives[bLastPump].outError DO
bLastPump := BumpUp(bLastPump);
END_WHILE;
count := 1;
(* Проходим циклом массив из блоков управления насосами *)
FOR i := 1 TO 3 DO
(* Устанавливаем все входные переменные, которые
схожи для всех блоков *)
arDrives[i].inEnable := TRUE;
arDrives[i].inReset := DI_xReset;
arDrives[i].inKF := DI_xKF;
(* Назначаем задачу. Если это текущий выбранный насос, то будет
включено, а если нет, то выключено *)
arDrives[i].inTask := (bLastPump = i);
(* Теперь включаем дополнительно насос, если давление
меньше минимального *)
IF bLastPump <> i (* Если это насос, который не выбран текущим *)
AND NOT DI_xPressMin (* Если давление меньше минимального *)
AND NOT arDrives[i].outError (* Если насос не в ошибке *)
Стр. 231 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
AND count = 1 (* Если мы еще не назначили второй насос *)
THEN
(* Помечаем, что второй насос назначен, активируем задачу *)
count := 2;
arDrives[i].inTask := TRUE;
END_IF;
END_FOR;
(* Запускаем блоки с индивидуальными переменными *)
arDrives[1](inBack := DI_xDrive1Back, outDrive => DO_xDrive1);
arDrives[2](inBack := DI_xDrive2Back, outDrive => DO_xDrive2);
arDrives[3](inBack := DI_xDrive3Back, outDrive => DO_xDrive3);
END_PROGRAM
Постарайтесь разобраться, что тут происходит.
У этого примера 2 ошибки, можно назвать их недочетами:
1. На самом старте нет минимального давления, программа включит 2 насоса, второй как дополнительный. Давление резко наберется, и второй насос выключится. Таким
образом, каждый раз на старте дополнительный насос будет включаться на
несколько секунд.
2. Если нет минимального давления, то включится второй насос. Как только это
произойдет, скорее всего, давление поднимется выше минимального, значит,
программа перестанет включать второй насос, и она его выключит. Как только
произойдет выключение, давление снова упадет и насос снова включится. И так по
кругу.
Подобные ошибки обозначаются термином "дребезг". Так же, как дребезг может
возникнуть на контактах реле неправильной схемы, так и у нас будет дребезг на
выходе дополнительного насоса. Дребезг - вовсе не обязательно с высокой частотой
несколько раз в секунду. Если насос будет включаться и выключаться раз в 5 секунд,
то пока он наберет давление, пока оно упадет, это все равно дребезг. Дребезг может
быть и дольше - например, раз в минуту.
Подсказка как можно решить эти проблемы:
1. Решить технически: поставить на станцию большую компенсационную емкость гидроаккумулятор. Таким образом, потеря давления замедлится, что удлиннит время
дребезга до приемлемых пределов.
2. Ввести временные лимиты - например, минимальное время отдыха насоса. Насос не
включится, пока это время не истечет или никакой другой насос не готов к работе.
Стр. 232 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Установить минимальное время работы дополнительного насоса. Или установить
задержку на включение дополнительного насоса.
Если выбрать этот способ, то пусть это будет ваше домашнее задание. Измените код,
добавив в него задержки минимальной работы дополнительного насоса.
Такие решения применяются на практике, я лично их внедрял. Они не дадут
сбалансированной работы и стабильного давления без хорошо рассчитанного
гидроаккумулятора. Это связано с выбранным решением, удерживать давление по реле
давления. В подобных задачах лучшим решением будет перейти на частотное, каскадное
управление насосами по аналоговому датчику давления.
Стр. 233 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Последовательности
SFC в ST
Для создания программ пошагового вызова в МЭК 61131 существует язык SFC (Sequential
Function Charts). Его название говорит само за себя: Sequential - последовательный.
По моему мнению, SFC - не совсем язык. Или даже SFC совсем НЕ ЯЗЫК
программирования, хотя и назван таковым в Википедии.
SFC - это, скорее, средство определения структуры программы для ее пошагового вызова:
какая инструкция на определенном шаге будет исполняться и каковы условия перехода от
одного шага к другому. Сами же инструкции пишутся на других языках
программирования, таких как ST, FBD, LD, CFC.
Другими словами, в SFC вы никогда не присвоите переменной значение, не вызовите
никакой функции или ФБ, а лишь определите структуру вызова действий (Action), которые
пишутся на других языках.
ST - единственный язык, который может полностью заменить SFC и позволит обходиться
без него.
Ни один другой язык МЭК 61131 не сможет работать без SFC, если речь идет о
последовательных вызовах.
В ST есть 2 способа решения последовательно исполняемых задач.
Стр. 234 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Элементы SFC
Метод 1: Элементы SFC
Предварительно
Уверен, что эта тема останется только примером, и в жизни вы не будете пользоваться
этим методом. Цель его размещения в книге - проинформировать читателя о его
существовании в стандарте. Проигнорировать подобное было бы не правильно. Да и вдруг
кто-то решится именно на такую реализацию.
При этом тема очень интересная и даже если вы будете пользоваться SFC в графическом
языке, вам будет намного понятнее, как работает логика SFC, особенно, как можно
использовать спецификаторы. Так же вы узнаете, на сколько ST урезан в известных нам
реализациях.
У метода Элементы SFC в ST есть преимущества.
• За счет AQ он может быть очень гибким. То есть, возможности этой реализации
весьма широки.
• Структура программы при таком подходе хорошо структурирована в блоки.
Вот причины, почему, я уверен, этим методом вы не будете пользоваться.
1. Хотя данный метод является частью стандарта МЭК 61131-3, его внедрение не
распространено. Насколько мне известно на сегодняшний момент, этот метод
поддерживается в Logi.CAD 3.
Вы не сможете использовать этот метод в CoDeSys, TwinCAT, TIA Portal, e!
COCKPIT, и т.д.
Таким образом, детали описанного процесса, анализа шагов и переходов,
представлены так, как это реализовано в Logi.CAD 3. И это единственная среда
разработки где вы сможете попробовать этот метод, если появится интерес.
2. Данный метод обратен паттерну программирования концентрации кода (См. Таймер
внутри CASE ). Код разбросан по документу. Например, в INITIAL_STEP StepInit
вызываются 2 действия, а сами действия снизу. Нужно прокрутить, чтобы
посмотреть, что там происходит. Нужно не только крутить, но и просматривать
каждую строку, чтобы не пропустить искомое. Крутить "скроллик" и пользоваться
Стр. 235 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
поиском по тексту придется очень много. Если среда позволяет создавать действия,
как элемент дерева программы в меню проекта, то тогда код действия будет
открываться в новом окне, и чтобы начать читать дальше, его нужно будет закрыть
или работать с множеством открытых окон.
Описание метода
Для эмуляции SFC в ST предусмотренны специальные элементы SFC, такие как
TRANSITION , ACTION , STEP и INITIAL_STEP . Они интуитивно понятны любому, кто уже
делал хотя бы маленькие программки в SFС.
Рассмотрим пример работы манипулятора, который перемещает деталь из первой зоны
конвейера во вторую:
INITIAL_STEP StepInit:
(* Р1 - Вызвать действие один раз при входе в шаг *)
actInit(P1);
(* Р0 - Вызвать действие один раз при выходе из шага *)
actStart(P0);
END_STEP
TRANSITION FROM StepInit TO Step1
(* Если нажали кнопку старт *)
:= xStart;
END_TRANSITION
STEP Step1:
(* Возвращаем манипулятор в исходное положение *)
actMoveBack(P);
END_STEP
TRANSITION t0 FROM Step1 TO Step2
(* Сенсор сработал, запчасть в зоне захвата *)
:= xSensorZ1;
END_TRANSITION
STEP Step2:
(* Включаем манипулятор, чтобы передвинуть запчасть *)
actMovePart(P);
END_STEP
TRANSITION t1 FROM Step2 TO Step1
(* Сенсор сработал, запчасть передвинута *)
:= xSensorZ2;
END_TRANSITION
(* Если нажали кнопку стоп, останавливаем весь процесс *)
TRANSITION t3 FROM (Step1, Step2) TO StepInit
:= xStop;
END_TRANSITION
Стр. 236 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
ACTION actStart:
(* Включить конвейер *)
xBelt := TRUE;
END_ACTION
ACTION actInit:
(* На шаге ожидания все отключаем *)
xBelt := FALSE;
xMover := FALSE;
END_ACTION
ACTION actMovePart:
xMover := TRUE;
END_ACTION
ACTION actMoveBack:
xMover := FALSE;
END_ACTION
Порядок анализа шагов
Чтобы лучше понимать, как все исполняется, а именно, действия ACTION и переходы
TRANSITION , рассмотрим логику и последовательность интерпретации кода.
В каждом цикле ПЛК идет проверка, что элемент SFC из текущей ветки SFC* может быть
оценен. Эта проверка осуществляется в порядке, в котором элементы STEP , INITIAL_STEP
и TRANSITION введены в текстовом редакторе ST кода. Поэтому порядок введения
элементов важен. Шаг 1, за ним переход из шага 1 в шаг 2, а затем и сам шаг 2. Элементы
SFC ACTION можно размещать в конце кода.
Каждый шаг имеет внутренние переменные, (флаги), которым назначается разное
значение. Например, можно обращаться к переменной времени T , чтобы узнать текущее
время исполнения шага Step2 :
TRANSITION FROM Step2 TO Step3
:= Step2.T > T#10s - T#200ms;
END_TRANSITION
Оценка ветки SFC следует в прямой последовательности, начиная с INITIAL_STEP , через
переход или переходы TRANSITION на следующий шаг, потом, через следующие переходы,
на следующий шаг и т.д. Так регулируется сама последовательность анализа ветки SFC.
Перед описанием анализа - несколько терминов:
• Ветка SFC - это программа от INITIAL_STEP до последнего перехода TRANSITION в
серии шагов.
Стр. 237 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• Предшественник - шаг STEP , предшествующий переходу, или шаг, из которого
осуществляется переход.
• Приемник - шаг STEP , в который осуществляется переход.
• Очистка Перехода - здесь значение слова "очистка" похоже на значение в термине
"таможенная очистка". Это проверка условия активного перехода TRANSITION .
Активным считается тот переход, у которого предшественник активен на данный
момент. Переход, условие которого вернуло TRUE , называется очищенный переход.
Порядок проверки переходов на очистку осуществляется в том порядке, в котором
они введены в текстовом редакторе ST кода.
Вот как происходит сам анализ:
1. В начале каждого цикла происходит очистка переходов. Если какой-то переход
очищен, то происходит немедленная активация одного шага или нескольких шагов
приемников.
Исключение составляет первый цикл контроллера при первом запуске. В этом
случае не происходит никаких очисток переходов.
2. Для каждого действия ACTION , которое связано с текущим активным шагом,
назначается внутренняя входная переменная. Допустим, есть действие:
ACTION action1
a := TRUE;
END_ACTION;
Тогда внутренняя входная переменная действия action1.action-qualifier будет
переведена в TRUE . Имя переменной action-qualifier зависит от самого действия
и от AQ, который был использован (См. Спецификаторы AQ).
Если у вызова действия есть входная переменная времени action1(D, T#5s); , то
это время назначается для внутренней входной переменной измерения времени
action1.T .
3. В зависимости от назначенного и поведения AQ, внутренняя выходная переменная
действия action1.Q назначается в TRUE . Инструкции внутри действия ACTION
будет обрабатываться только тогда, когда внутренняя входная переменная Q будет
равна TRUE . Такое действие называется активным.
Если в шаге STEP в списке есть несколько активных действий, то они
обрабатываются в порядке, в котором введены в текстовом редакторе ST кода.
Стр. 238 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
4. В конце каждого цикла определяются все очищенные переходы TRANSITION . Все
шаги предшественники STEP будут немедленно деактивированы. Всем действиям
ACTION , связанным этими шагами, внутренняя входная переменная спецификатора
action1.action-qualifier присваивается FALSE . В зависимости от поведения
спецификатора, внутренняя переменная action1.Q тоже назначается в FALSE .
Инструкции таких действий больше не исполняются.
Можно сделать следующие заключения по анализу ветки SFC:
• Любой активированный шаг остается активным хотя бы один цикл ПЛК.
• Если в активном шаге нет ни одного действия, то он ожидает, пока переход этого
шага не вернет TRUE .
• Если условие перехода становится TRUE в одном цикле ПЛК, то ассоциированные
переходы переключатся только в следующем цикле ПЛК.
• Следующая ветка SFC не обязательно начинает анализ после того, как закончится
текущая ветка.
• Действия могут продолжать исполняться, даже если шаг, на котором они были
активированы, уже не активен в зависимости от AQ.
Примеры решения переходов в случае дивергенций
Определение:
Дивергенция - от лат. divergere — обнаруживать расхождение. В
контексте нашей темы это момент разделения одной ветки SFC на 2
или более.
ST код, представленный в примерах, не отражает всей ветки SFC, а только тот код,
которого достаточно, чтобы отразить принцип анализа.
Определение активного шага в случае одновременной
дивергенции
Одновременная дивергенция присутствует тогда, когда очистка перехода ведет к переходу
сразу к нескольким шагам. Пример:
TRANSITION t0 FROM S0 TO (S2, S1)
:= condition_0;
END_TRANSITION
TRANSITION t1 FROM (S2, S1) TO S3
:= ...;
Стр. 239 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_TRANSITION
STEP S1 :
act1(N);
END_STEP
STEP S2 :
act2(N);
END_STEP
ACTION act2:
...;
END_ACTION
ACTION act1:
...;
END_ACTION
Этот же пример в блоках - для более наглядного представления.
Объяснение анализа:
• Шаг S0 активен, а значит и переход t0 тоже. На диаграмме - порядок исполнения
для этого перехода (2).
• В конце цикла определяется переход для очистки, если condition_0 равно true , то
переход очищается и предшественник S0 деактивируется.
• В следующем цикле ПЛК t0 уже очищена, следовательно, S2 и S1 активируются.
• Анализ действий, ассоциированных с S1 или S2 , зависит от AQ этих действий. Так
как используется спецификатор N , внутренняя входная переменная будет именем N ,
и ей присвоится значение TRUE для act1_ACB.N и act2_ACB.N .
• Как следствие, внутренняя выходная переменная Q для обоих действий act1_ACB.Q
и act2_ACB.Q тоже будет назначена в TRUE . Это означает, что act1 и act2 могут
исполняться одновременно, но к ним применяется порядок в той очередности, в
Стр. 240 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
какой эти действия записаны в редакторе ST, значит, act2 будет исполняться
первым (5), а act1 вторым (6).
Определение перехода для очистки в случае
дивергенции
Дивергенция образовывается, если несколько переходов ведут из одного и того же шага.
Определение очередности очистки переходов определяется той очередностью, с которой
они введены в редакторе ST кода. Пример:
TRANSITION t0 FROM S0 TO S1
:= condition_0;
END_TRANSITION
TRANSITION t1 FROM S0 TO S2
:= condition_1;
END_TRANSITION
TRANSITION t2 FROM S1 TO S3
:= ...;
END_TRANSITION
TRANSITION t3 FROM S2 TO S3
:= ...;
END_TRANSITION
Для наглядности этот же участок ветки SFC в диаграммах:
Объяснение анализа:
В этом примере выбор идет только по одной выбранной ветке. S1 и S2 не могут работать
одновременно.
Стр. 241 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• Если шаг S0 активный, то переходы t0 и t1 тоже активные. Пример на диаграмме
показывает порядок выполнения: переход (2) для перехода t0 и (3) - для перехода
t1 .
• В конце цикла определяется переход для очистки. t0 имеет приоритет перед t1 ,
так как в коде TRANSITION t0 ... END_TRANSITION располагается над TRANSITION
t1 ... END_TRANSITION . На диаграмме переходы находятся0 на одной высоте, так
как применяется правило "слева направо".
◦ Если condition_0 будет равно TRUE , t0 нужно очистить и, следовательно,
S0 деактивируется. Тогда condition_1 для t1 больше не проверяется, ведь
S0 уже деактивирован и значит t1 тоже не активна.
В следующем цикле t0 очищается и, как следствие, активируется его
приемник S 1.
◦ Только если condition_0 не равен TRUE , а condition_1 равен TRUE , t1
будет переход для очистки и она деактивирует S0 .
В следующем цикле t1 очистится и, следовательно, ее приемник S2 будет
активирован.
Спецификаторы AQ (Action Qualifier)
Действия можно вызывать с разными спецификаторами действий AQ, кроме P0 и P1 ,
которые уже есть в примере. Например:
INITIAL_STEP Step1 :
Action1();
Action2(D, T#5s);
Action3(L, T#5s);
Action4(SD, T#15s);
END_STEP
Рассмотрим все возможные спецификаторы и их работу. Предположим, есть действие,
которое можно вызывать с разными спецификаторами:
ACTION Action_1
a := TRUE;
END_ACTION;
Конечно, помним, что инструкции в действии исполняются только тогда, когда внутренняя
выходная переменная действия Q будет равна TRUE (действие активно). Условия, при
которых Action_1.Q станут равны TRUE , зависят от внутренних входных переменных
Стр. 242 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Action_1.action-qualifier и Action_1.T , где action-qualifier это имя
спецификатора, например, Action_1.N .
У каждого действия при определенных обстоятельствах может появиться несколько
внутренних входных переменных action-qualifier . Например, действие может
одновременно вызываться в двух параллельно активных шагах. В одном со
спецификатором S , а в другом - со спецификатором R или N . О том, как логически такое
решается, описано в Общая иллюстрация работы AQ.
Спецификатор N (Non-stored)
Действие без спецификатора или со спецификатором N работает одинаково.
Параметр времени не является обязательным.
Технически
Action_1.Q будет равно TRUE все время, пока Action_1.N равно TRUE и R будет FALSE .
Простыми словами
Действие будет работать, пока шаг, в котором оно вызывается, является активным и это же
действие не вызвано со спецификатором R ни в каком другом шаге, активном в том же
время.
Спецификатор S (Stored)
Параметр времени не является обязательным.
Стр. 243 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Технически
Action_1.Q будет равен TRUE после сигнала TRUE на S (с защелкой). Это как
использовать блок SR . Достаточно импульса, чтобы активировать выход. При этом R
должно быть FALSE . Если R переходит в TRUE , пока S равна TRUE , хотя выход
Action_1.Q выключается, при этом S не сбрасывается.
Простыми словами
Действие начинает исполняться, когда активируется шаг, в котором оно вызывается.
Действие продолжает исполняться, даже если этот шаг деактивируется, пока не
активируется шаг, где это же действие будет вызвано со спецификатором R . Если действие
вызывается в активном шаге и в этот момент параллельно активируется шаг, где это же
действие вызывается со спецификатором R , то исполнение этого действия
приостанавливается, но само действие не деактивируется.
Сброс действия происходит при вызове действия со спецификатором R при условии, что
это действие стоит на защелке и не вызывается ни в каком другом параллельно активном
шаге.
Спецификатор L (Time Limited)
Параметр времени обязателен.
Технически
Работает до конца указанного параметра времени при вызове Action_1(L, T#20s) .
Action_1.Q будет равен TRUE , так же как и спецификатор N , пока L = TRUE и R = FALSE ,
но при этом ограничено временем, указанным в параметре время Action_1.T
Стр. 244 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Время T начинает отсчет, как только L меняется в TRUE (в момент переднего фронта).
Наличие R = TRUE выключает Q , но не меняет счета времени T .
Простыми словами
Действие начинает исполняться, когда шаг, в котором оно вызвано, активируется, и
продолжает исполняться до истечения указанного времени, либо до окончания активности
шага, где оно вызывается, если шаг перестанет быть активным раньше истечения времени.
Спецификатор D (Time Delayed)
Параметр времени обязателен.
Технически
Работает после указанного промежутка времени, указанного при вызове Action_1(D,
T#20s) .
Action_1.Q = TRUE так же, как и спецификатор N , пока D = TRUE и R = FALSE , но только
после того, как истечет время Action_1.T .
Время T начинает отсчет, как только D меняется в TRUE (в момент переднего фронта).
Наличие R = TRUE выключает Q , но не меняет счета времени T .
Простыми словами
Действие начинает работать после задержки указанного времени, пока шаг, в котором оно
вызывается, активен. Действие прекращает работу, если шаг перестал быть активным.
Стр. 245 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Если шаг закончит свою активность прежде, чем истечет время, действие ни разу не
исполнится.
Если в другом, параллельно активном шаге, это же действие вызывается со
спецификатором R , то его исполнение приостанавливается, но таймер не сбрасывается.
Спецификатор P (Pulse)
Параметр времени не является обязательным.
Технически
Action_1.Q будет равен TRUE один импульс, другими словами, в течение одного цикла
ПЛК, всякий раз, когда будет определен передний фронт на внутренней переменной P .
При этом R должен быть равен FALSE .
Простыми словами
Действие исполняется каждый раз при активации шага, где это действие вызывается,
действие исполнится за один цикл контроллера. При этом в момент активации шага это
действие не должно вызываться ни в каком параллельно исполняемом шаге со
спецификатором R .
Стр. 246 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Спецификатор SD (Stored and time Delayed)
Параметр времени обязателен.
Технически
Работа с задержкой включения. Два условия в порядке очередности должны совпасть,
чтобы Action_1.Q стало равно TRUE .
1. Если после того, как SD = TRUE есть хотя бы один импульс (с защелкой).
2. После времени T , переданного при вызове действия Action_1(SD, T#20s) .
3. И в этот момент R = FALSE
Action_1.Q присваивается TRUE , сбросить это может только R = TRUE .
Наличие R = TRUE выключает Q , но не меняет счета времени T , который начинается
каждый раз, когда определен передний фронт на SD .
Простыми словами
Действие активируется, когда шаг, в котором оно вызывается, становится активным (с
защелкой). Начинает работать через заданный промежуток времени, даже если шаг уже не
активен. Работает, пока не будет произведен сброс вызовом этого действия со
спецификатором R .
Если это же действие вызывается в параллельно активном шаге со спецификатором R , шаг
не исполняется и время сбрасывается. Новый отсчет времени начинается, когда шаг,
который вызывает это действие со спецификатором R , деактивируется.
sd
Стр. 247 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Спецификатор DS (Delayed and Stored)
Параметр времени обязателен.
Технически
1. Если Action_1.DS = TRUE без прерываний в течение времени T , переданного при
вызове действия Action_1(DS, T#20s) .
2. И в этот момент R = FALSE
Наличие R = TRUE выключает Q , но не меняет счета времени T , которое начинается
каждый раз, когда определен передний фронт на DS .
Если после R = TRUE станет R = FALSE , а DS так и не прерывался, то Q не выключится.
Он отключится только на момент, пока R = TRUE .
Простыми словами
Действие начнет работать, если шаг, в котором оно вызывается, будет активным без
прерывания в течение заданного параметра времени, и будет работать, пока не будет
произведен сброс вызовом этого действия со спецификатором R .
Если это же действие вызывается в параллельно активном шаге со спецификатором R ,
время отсчета не сбрасывается, но действие не исполняется.
Сброс активности шага происходит только при условии, что это действие вызывается со
спецификатором R и параллельно не вызывается ни одним другим шагом.
Спецификатор SL (Stored and time Limited)
Параметр времени обязателен.
Стр. 248 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Технически
Работает по защелке, ограниченное количество времени T , переданного при вызове
действия Action_1(DS, T#20s) .
Action_1.Q = TRUE , если SL = TRUE хотя бы один цикл контроллера, (защелка) и R =
FALSE . Действие активно, пока не пройдет время T . Отсчет времени T начинается каждый
раз, когда на SL меняется в TRUE (передний фронт SL см t1), или R меняется в FALSE
(задний фронт R см. t4 и t5)
Простыми словами
Действие будет активным после вызова в активном шаге, по истечении заданного времени,
даже если шаг уже перестал быть активным. Будет работать, пока не будет произведен
сброс вызовом этого действия со спецификатором R .
Если это же действие вызывается в параллельно активном шаге со спецификатором R ,
действие прекращает исполняться и время сбрасывается. Действие снова начинает
исполняться заданный промежуток времени, когда параллельно активный шаг,
вызывающий это действие со спецификатором R , деактивируется.
Спецификатор P0 (Pulse, falling edge)
Параметр времени не является обязательным.
Технически
Action_1.Q = TRUE один импульс или один цикл ПЛК, если P0 изменяется в FALSE (по
заднему фронту сигнала) и при этом R = FALSE .
Стр. 249 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Простыми словами
Срабатывает один раз при выходе из шага.
Спецификатор P1 (Pulse, rising edge)
Параметр времени не является обязательным.
Технически
Action_1.Q = TRUE один импульс или один цикл ПЛК, если P1 изменяется в TRUE (по
переднему фронту сигнала) и при этом R = FALSE .
Простыми словами
Срабатывает один раз при входе в шаг.
Общая иллюстрация работы AQ
Представляю вам FBD, который схематично отображает принцип работы всех
спецификаторов AQ.
Восприятие схемы упростят следующие моменты:
Стр. 250 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
• Внутренняя входная переменная Action_1.action-qualifier принимает значение
TRUE , когда действие ACTION привязано к шагу STEP и этот шаг является активным.
• Эта иллюстрация отображает только спецификаторы (как N , R , S ) вместо полных
имен, как ( Action_1.N , Action_1.S ).
• Для упрощения привязки Action_1.Q к спецификатору, вместо имени действия
используются имена спецификаторов для сокращения (как N.Q , S.Q ).
• Внутренняя входная переменная Action_1.T содержит значение периода времени в
формате TIME , которое назначается при вызове действия с определенными
спецификаторами, например, Action_1(DS, T#20s) .
• Иллюстрация внизу определяет это значение времени как T .
• Спецификатор R всегда имеет приоритет. Комбинация R с другими
спецификаторами производит отключение Action_1.Q .
• Если одно и то же действие ACTION в одном шаге или нескольких одновременно
используется несколько раз, с разными спецификаторами, работающими со
временем (= L , D , SD , DS , SL ), ни одно из действий ACTION не будет исполняться.
• Если вы используете одно и то же действие ACTION , вызывая его с разными
спецификаторами (= N , S , P , P0 , P1 ), они исполняются с логикой ИЛИ ( OR ).
Стр. 251 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Конструкция CASE
Метод 2:
CASE
Второй способ - это использование CASE . Фактически, данный способ почти
единственный - ранее отмечалось, что первый способ поддерживается не каждой средой
разработки. Зато CASE можно с уверенностью применить где угодно.
Если вы усвоите основную идею данной реализации, то в SFC вам больше заглядывать не
захочется.
Рассмотрим такой же пример работы манипулятора, как мы это делали в методе 1 перемещение детали из первой зоны конвейера во вторую.
Для лучшей наглядности здесь я даю много комментариев:
VAR
iSteps: INT;
END_VAR
(* Если выключили конвейер, переводим в первый шаг ожидания *)
IF NOT xStop THEN
iSteps := 0;
END_IF
CASE iSteps OF
0: (* Ждем команды на старт. Начальный шаг *)
(* На шаге ожидания все отключаем *)
xBelt := FALSE;
xMover := FALSE;
(* Если нажали кнопку пуск, запускаем конвейер
и переходим на шаг ожидания детали *)
IF xStart THEN
xBelt := TRUE; (* Запускаем конвейер *)
iSteps := 10; (* Переходим на следующий шаг *)
END_IF
(* Ждем, пока деталь доедет до датчика движения *)
10:
(* Если деталь попала в зону 1, включаем манипулятор,
который передвинет деталь в зону 2, и переходим
на другой шаг *)
IF xSensorZ1 THEN
xMover := TRUE;
iSteps := 20;
END_IF
Стр. 252 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
(* Ждем, пока деталь дойдет до зоны 2 *)
20:
(* Если деталь дошла до зоны 2, выключаем
манипулятор, чтобы он вернулся в исходное положение,
и переходим обратно на шаг ожидания новой детали
в зоне 1 *)
IF xSensorZ2 THEN
xMover := FALSE;
iSteps := 10;
END_IF
END_CASE
Этот очень короткий пример приведен для того, чтобы продемонстрировать принцип
организации пошагового вызова при помощи конструкций CASE . Хорошо видно, насколько
здесь меньше кода.
Обратите внимание на нумерацию шагов. Можно было бы использовать шаги с номерами
0, 1, 2 и так далее. Если для нумерации шага применяется число, то обычно используют
десятки. Если вам понадобиться вставить шаг между какими то двумя шагами, вы это
сделаете, не нарушив порядкового счета последовательности шагов.
Шаги не обязательно размещать в CASE в том же порядке, в котором они следуют друг за
другом, назначая им номера по порядку - это нужно только для упрощения визуального
восприятия.
Именные шаги
Использовать нумерацию в шагах не очень удобно.
• Во-первых, по номеру совершенно не понятно, что этот шаг делает. Нужно
анализировать код самого шага или добавлять комментарий.
• Во-вторых, придется следить за очередностью нумерации. Очередность цифр
помогает восприятию понять, что за чем исполняется. Для этого придется выделять
номера десятками 10 , 20 , 30 и так далее, чтобы можно было вставить какой-то шаг
между уже существующими шагами, не нарушив натурального порядка. Например,
нужно вставить шаг между 10 и 20 . Это будет, к примеру, 15 . И еще останется
место, если потребуется вставить шаг между 10 и 15 .
Отличным решением тут станет использование перечислений для присвоения шагам
тестовых имен. Например:
TYPE
enumSteps: (pIdle, pStepName1, pStepName2);
Стр. 253 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_TYPE
В этом случае всегда можно будет добавить новый шаг, не нужно, чтобы его нумерация
совпадала с порядком исполнения шагов - по имени сразу будет понятно, что делается на
этом шаге:
VAR
eStep: enumStep;
END_VAR
CASE eStep OF
pIdle: // Ожидание начала работы
pStepName1: // Шаг 1
pStepName2: // Шаг 2
END_CASE
Таймеры в последовательностях
Очень часто выход из какого-то шага происходит по времени, поэтому использование
таймеров является частой практикой. Есть 2 паттерна программирования CASE с
таймерами.
Таймер за CASE
Такой метод рекомендован некоторыми популярными книгами по МЭК 61131-3 и статьями
в Интернете, хотя я считаю что у метода есть ряд недостатков, но есть и ряд преимуществ.
Рассмотрим пример ФБ типа BLINK . Когда на вход блока IN поступает сигнал, блок
начинает менять состояние выхода Q . Включает его на время T1 , а потом выключат на
время T2 .
Это, можно сказать, аналог блока BLINK с использованием CASE :
FUNCTION_BLOCK MyBlink
VAR_INPUT
IN: BOOL; // Включить блок
T1: TIME; // Время включенного состояния
T2: TIME; // Время выключенного состояния
END_VAR
VAR_OUTPUT
Q: BOOL;
END_VAR
VAR
TON1: TON;
TON2: TON;
iStep: USINT := 0;
END_VAR
Стр. 254 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
IF NOT IN THEN
iStep := 0;
END_IF
TON1(IN := (iStep = 1), PT := T1);
TON2(IN := (iStep = 2), PT := T2);
CASE iStep OF
0:
Q := FALSE;
IF IN THEN
iStep := 1;
END_IF
1:
Q := TRUE;
IF TON1.Q THEN
iStep := 2;
END_IF
2:
Q := FALSE;
IF TON2.Q THEN
iStep := 1;
END_IF
END_CASE
END_FUNCTION_BLOCK
Я вижу следующие преимущества подхода, когда ФБ таймера вызывается за пределами
самой конструкции CASE :
1. Так как таймер вынесен за условие CASE , он срабатывает каждый цикл контроллера,
значит, нам не надо сбрасывать таймер по окончании работы, он сам сброситься.
2. Меньше кода внутри CASE выглядит чище.
Вопрос: Когда применять этот паттерн?
Ответ: Если в конструкции CASE есть только один таймер и/или
конструкция небольшая, и ее всю вместе с вызовом таймеров за
пределами CASE видно на одном экране без прокрутки.
Таймер внутри CASE
Я больше склоняюсь к использованию такого паттерна:
FUNCTION_BLOCK MyBlink
VAR_INPUT
IN: BOOL; // Включить блок
T1: TIME; // Время включенного состояния
T2: TIME; // Время выключенного состояния
Стр. 255 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_VAR
VAR_OUTPUT
Q: BOOL;
END_VAR
VAR
TON1: TON;
iStep: USINT := 0;
END_VAR
IF NOT IN THEN
iStep := 0;
END_IF
CASE iStep OF
0:
Q := FALSE;
IF IN THEN
iStep := 1;
END_IF
1:
Q := TRUE;
TON1(IN := TRUE, PT := T1);
IF TON1.Q THEN
iStep := 2;
TON1(IN := FALSE);
END_IF
2:
Q := FALSE;
TON1(IN := TRUE, PT := T2);
IF TON1.Q THEN
iStep := 1;
TON1(IN := FALSE);
END_IF
END_CASE
END_FUNCTION_BLOCK
Рассмотрим преимущества похожего примера, но с таймерами, которые запускаются
внутри шагов конструкции CASE :
1. Внутри шага стало больше кода, но он локализован или сконцентрирован в самом
шаге, а не разбросан по всей программе.
Я имею в виду, что когда вы анализируете код шага, вам не надо листать прокруткой
код, чтобы посмотреть: где этот таймер вызывается, на каких условиях и на какое
время. Все, что касается этого шага, находится в одном месте.
Если условие CASE занимает больше пары сотен строк, постоянное листание тудасюда, чтобы разобраться, согласитесь, напрягает.
Стр. 256 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
2. Теперь мы можем использовать только один блок таймера TON , так как в один
момент времени находимся только в одном шаге, а значит, вызывается только один
ФБ таймера. Если в CASE много шагов, это может повлиять на объем используемой
памяти и в результате - на быстродействие.
А быстродействие - магическое слово. Если "что-нибудь" может на него
положительно повлиять, даже незначительно, мы просто обязаны это "что-нибудь"
применить.
3. Не нужно вызывать блоки таймеров при каждом цикле программы. Допустим, у нас
100 шагов, а таймер используется только в десяти. Это значит, что при паттерне
Таймер за CASE все время, пока программа будет в других 90 шагах, таймер будет
вызываться каждый цикл ПЛК, но он нам не нужен.
И снова мы сталкиваемся с быстродействием, а значит, должны к этому отнестись
внимательно. У нас всего один экземпляр ФБ таймера на все шаги, и вызывается он
только тогда, когда это нужно.
Вопрос: Когда применять этот паттерн?
Ответ: Всегда, когда не рекомендуется применять другой паттерн
Таймер за CASE .
Пошаговый вызов
Рассмотрим на примере, как всё, что мы знаем о CASE , может работать вместе.
Допустим, у нас упаковочная машина для конфет. Пишем алгоритм для участка измерения,
где считаем, сколько конфет попадёт в упаковку. Итак, у нас следующий алгоритм.
1. Открываем бункер и сыплем конфеты. Есть датчик, который делает один импульс на
каждую пролетающую конфету.
2. Досчитали нужное количество, закрываем бункер.
3. Запечатываем упаковку термопрессом за 1 секунду.
4. Продергиваем рукав упаковки, чтобы насыпать следующую партию.
Для начала создадим перечисление, чтобы давать шагам имена:
TYPE
enumSteps: (
pIdle,
// Ожидание начала процесса
pMoveBelt,
// Протягиваем ленту
pCountCandy, // Отсчитываем конфеты в пакет
pClosePack // Запаиваем упаковку
Стр. 257 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
);
END_TYPE
Теперь сама программа:
VAR
eStep : enumSteps;
fbCTU : CTU;
fbTON : TON;
END_VAR
(* Если нажали кнопку остановки, то останавливаем процесс *)
IF xBtnStop THEN
eStep := pIdle;
END_IF
CASE eStep OF
pIdle:
(* В режиме ожидания выключаем все *)
xBelt
:= FALSE;
xСandyGate := FALSE;
xClamp
:= FALSE;
(* Если нажали кнопку старт, начинаем процесс,
сбрасываем таймер и счетчик *)
IF xBtnStart THEN
eStep := pMoveBelt;
fbCTU(RESET := TRUE);
fbCTU.RESET := FALSE;
fbTON(IN := FALSE);
END_IF
pMoveBelt:
(* Включаем протяжку *)
xBelt := TRUE;
(* Если датчик увидел метку на рукаве упаковки,
выключаем протяжку и переходим на шаг заполнения пакета *)
IF xPackPosition THEN
xBelt := FALSE;
eStep := pCountCandy;
END_IF
pCountCandy:
(* Открываем бункер с конфетами *)
xСandyGate := TRUE;
(* Назначаем датчик пролета конфет на вход отсчета *)
fbCTU.CU := xCandyFly;
(* Назначаем, что счетчик будет отсчитывать по 20
конфет в 1 упаковку. *)
fbCTU.PV := 20;
Стр. 258 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
fbCTU();
(* Если мы достигли уставки в 20 конфет, сбрасываем
счетчик, закрываем бункер и переходим на шаг склейки упаковки. *)
IF fbCTU.Q THEN
fbCTU(RESET := TRUE);
fbCTU.RESET := FALSE;
xCandyGate := FALSE;
eStep
:= pClosePack;
END_IF
pClosePack:
(* Включаем пневмоцилиндр утюжка на сжатие *)
xClamp := TRUE;
(* Назначаем таймеру время на 1 секунду и запускаем его *)
fbTON(IN := TRUE, PT := T#1s);
(* Когда время истекло, раскрываем утюжок, сбрасываем таймер
и возвращаемся к шагу pMoveBelt для продергивания рукава
упаковки для новой пачки *)
IF fbTON.Q THEN
xClamp := FALSE;
fbTON(IN := FALSE);
eStep := pMoveBelt;
END_IF
END_CASE
Очень элегантно и красиво. Поддерживать работоспособность такой программы не только
просто, но и приятно. Давайте подчеркнем несколько моментов:
1. Преимущество CASE в ST перед SFC в том, что мы сразу видим не только шаг, но и
все, что он исполняет. В SFC нужно заходить в блок, просматривать код на входе в
шаг, на выходе, в самом шаге, смотреть условие перехода.
Поначалу SFC кажется более наглядным и понятным, но это только на первый
взгляд. Понятной мы видим только последовательность, а что происходит внутри
каждого шага этой последовательности, нужно отдельно смотреть в разных окнах.
2. Мы использовали паттерн Таймер внутри CASE не только для таймера, но и для
счетчика fbCTU . Хотя счетчик и таймер применяются только один раз, код у нас
большой и его не видно на одной странице без прокрутки, а значит, паттерн Таймер
за CASE не подойдет.
3. Перечисления, как имена, дают быструю подсказку назначения шага.
Стр. 259 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
4. Используем технику концентрации кода в шаге. Это значит, что мы стараемся весь
код, который относится к определенному шагу, размещать внутри именно этого
шага.
Например, возьмем xСandyGate := TRUE; в шаге pCountCandy . Это действие можно
было вызвать на выходе из шага pMoveBelt :
IF xPackPosition THEN
xBelt := FALSE;
xСandyGate := TRUE;
eStep := pCountCandy;
END_IF
И все работало бы абсолютно так же. Но тогда было бы менее понятно, что
происходит в шаге pCountCandy , если анализировать только его код.
Вы подумаете: почему кто-то вообще захочет разместить xСandyGate := TRUE; в
шаге pMoveBelt ? По принципам оптимизации он туда сам напрашивается. В шаге
pCountCandy присвоение xСandyGate := TRUE; происходит на каждый цикл ПЛК,
пока мы в этом шаге. Если разместить это на выходе из шага pMoveBelt , присвоение
исполнится только один раз, один цикл ПЛК.
Это касается и xBelt := TRUE; в шаге pMoveBelt . Можно было бы разместить это
на выходе из шага pIdle . Так же fbCTU.CU := xCandyFly; и fbCTU.PV := 20;
можно было бы разместить на выходе из шага pMoveBelt , чтобы не исполнять эти
присвоения в каждом цикле ПЛК, пока мы в шаге pCountCandy .
Тут уже вам следует решать, что в приоритете. По моему мнению, экономия работы
процессора не на столько существенна, чтобы разнести код, относящийся к одному
шагу, в другой шаг. Читаемость программы того стоит.
Параллельные процессы
Таких процессов может идти несколько параллельно. Например, далее участок упаковки
пакетов в коробки, по 20 штук в коробку. Естественно, эти процессы не будет идти
следующим шагом, чтобы конфетам не ждать, а будут реализованы в другом кольце CASE
который будет выполняться параллельно с другой скоростью независимо от того, как
протекает процесс расфасовки конфет.
Стр. 260 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Другие случаи
CASE можно и нужно использовать везде, где требуется последовательный или
фиксирующий вызов ФБ.
Как мы уже знаем ФБ вызываются асинхронно или неблокирующим. Причина почему это
сделано так, кроется в принципе исполнения программы в ПЛК. Если сравнить с другими
языками, например, РНР, там исполнение идет от строки к строке и не переходит к
следующей строке, пока предыдущая строка не закончила исполнение. Иными словами,
одна строка блокирует исполнение другой. Поэтому можно в столбец писать открытие
файла, чтение, потом закрытие, и быть уверенным, что чтение не начнется, пока файл не
откроется. При этом запущенный код может исполняться неопределённое количество
времени и по завершении останавливаться до следующего запуска.
Стиль программирования ПЛК на ST другой, потому что программа исполняется по циклу,
и мы не можем выполнять один цикл неопределённое время, а хотим что бы цикл был как
можно меньше. ПЛК не будет ждать, пока ФБ закончит работу, например, подключение к
удаленному сервису, а пойдет дальше. Следовательно, нужно убедиться, что мы вызываем
следующий ФБ (например чтение данных с сервера), когда предыдущий (например
подключение к серверу) завершил свою работу.
Например, мы хотим отправить HTTP запрос на сервер. Подготавливаем данные и
устанавливаем подключение к серверу, это может занять несколько циклов ПЛК, а
делается это все в одном ФБ. Потом отправляем запрос, что так же может занять несколько
циклов и происходит это в другом ФБ. Затем будем читать ответ от сервера, куда мы
отправили данные, это тоже может длиться несколько циклов ПЛК и делается это опять в
другом ФБ.
Так вот, нам нужно фиксировать работу одного ФБ, в течении нескольких циклов, пока он
не завершит работу, при этом не вызывая других ФБ. В результате нужно получить
последовательный вызов ФБ или как я его назвал фиксирующий.
CASE помогает вызывать ФБ с фиксацией его вызова. Дополнительные примеры работы с
CASE , демонстрирующие этот принцип, можно будет рассмотреть в разделе Sockets.
Стр. 261 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Sockets
Определение
Сокеты позволяют соединять разные устройства и осуществлять
коммуникацию между ними на разных уровнях, по разным
интерфейсам и на разных протоколах.
Общие сведения
В мире автоматизации слова: коммуникации, сеть, подключение, передача данных,
распределенные системы управления, удаленное подключение, протокол и так далее,
являются ключевыми, они неотделимы от самой индустрии. Значит, сокеты - это тема,
которую нельзя обойти стороной.
Существует огромное количество протоколов, таких как: Modbus TCP, Modbus RTU,
EtherCAT, ProfiNet, I2C, MQTT, FieldBus, ProfiBus и др. Большинство из них основано на
сетевых каналах Ethernet IEEE 802.3 и используют сетевой транспорт TCP и UDP. Значит,
их можно реализовать при помощи сокетов.
Сокеты абстрактны и не зависимы от протоколов. Можно сказать, что сокеты - протоколагностики. Они ничего не знают ни о передаваемых по ним данным, ни о структуре, ни о
назначении, ни об устройствах, на которых работают. Это просто транспорт, по которому
данные передаются между двумя устройствами или по сети из устройств. О формате
данных и о том, как его интерпретировать при чтении или упаковывать при отправке,
сокеты не знают. Это относится к понятию протокола. Разные протоколы реализованы поразному. Разобрав эту тему, вы сможете с легкостью придумать и внедрить свой
собственный простой протокол.
Для соединения сокеты используют адреса. В сетях TCP\UDP в качестве адресов
используются IP-адреса и порты, а в сетях RS_*_ - номера COM портов.
Сокеты, работающие по TCP, действуют по технологии "клиент-сервер", как и сам TCP.
Если сравнивать сокеты с телефонной линией, то серверный сокет это коммутатор, а
клиентский сокет - телефонный аппарат абонента.
Для организации общения клиент должен знать IP-адрес и номер порта сервера, по
которым он подключается к удаленному устройству. В рамках стека протоколов TCP/IP
Стр. 262 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
различают два типа сокетов: TCP и UDP. Есть 2 вида сокетов: TCP - потоковые, а UDP –
датаграммные.
Созданный сокет на сервере похож на открытое окно. Вы открываете его и ждете, когда в
него что-то прилетит. Анализируете содержимое и трансформируете в удобоваримые
данные, потом обрабатываете или передаете дальше.
Сокет на клиенте - конечная точка сети. Он знает адрес сокета на сервере.
Подключившись к нему, сокет может передавать туда данные и читать ответы с сервера.
Проблемы работы с сокетами
Практически все поставщики предоставляют библиотеку для работы с сокетами. К
сожалению, хотя имена функций могут быть схожими, реализацию самих функций все
библиотеки делают по-разному. Мало того, одна и та же функция в одной и той же
библиотеке может работать по-разному. Например, в CoDeSys есть библиотека
SysLibSocket . ПЛК, работающие под управлением этой среды, поддерживают функции
этой библиотеки, но то, как они работают, может отличаться от одного ПЛК к другому.
Скорее всего, в вашем конкретном случае потребуются дополнительные знания о том, как
работают сокеты конкретно на вашей системе, чтобы примеры в этой книге заработали на
вашем ПЛК.
Поэтому мы будем разбирать, скорее, принцип работы с сокетами, примеры можно
расценивать не как готовый рецепт, а как отправную точку для решения вашей задачи.
Имена функций будем использовать такими, какими они определены в библиотеке
SysLibSocket для CoDeSys 2.3. Если в дальнейшем вам придется работать с другими IDE,
то трудностей не должно возникнуть, так как назначение функций у всех одинаковое, как,
впрочем, и порядок вызова.
Введение
Для начала рассмотрим общий концепт работы сокетов, а потом примеры TCP-клиента и
сервера c протоколом Modbus TCP.
Посмотрите на красиво исполненную диаграмму работы сокетов с сайта российского
производителя ПЛК Fastwel.
Стр. 263 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Внимательно рассмотрите эту схему, пока она не станет вам понятна.
Итак, серверный сокет отвечает на запросы к нему, но сам ничего не опрашивает. Значит, в
сети Modbus это slave. Сокет-клиент, напротив, опрашивает другие устройства, значит,
является master сети Modbus.
Имена функций на картинке соответствуют именам в С/С++. Вот назначения функций и их
аналоги в библиотеке SysLibSocket .
Стр. 264 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
С/С++
SysLibSocket
Описание
socket()
SysSockCreate()
Создать сокет
bind()
SysSockBind()
Привязать к адресу
listen()
SysSockListen()
Слушать данные
accept()
SysSockAccept()
Принять
send()
SysSockSend()
Отправить данные
recv()
SysSockRecv()
Принять данные
close()
SysSockClose()
Закрыть соединение
SysSockShutdown() Удалить сокет
Паттерн программирования
Для программирования сокетов обычно используют конструкцию CASE . Она как нельзя
лучше подходит для этой задачи. Создание подключений - это пошаговые операции. Как
мы уже знаем, для пошаговых операций лучше использовать CASE .
При построении примеров будем опираться на документацию к функциям и документацию
по программному интерфейсу сокетов POSIX.1, но, как говорилось ранее, это не
гарантирует того, что примеры, приведенные здесь, заработают сразу на всех ПЛК, так как
реализация этих функций на разных ПЛК разнится.
Пример Modbus TCP Client и Server
Писать коммуникацию будем по Modbus, поэтому придется поверхностно коснуться
самого протокола Modbus. Это не будет лишним, так как Modbus - один из самых
распространенных протоколов на сегодняшний день.
Сокет TCP Client (Modbus TCP Master)
Начнем не с сервера, а с клиента. В этом случае будет проще разобрать более сложный код
сервера, ведь в нем половину моментов - создание, подключение, сброс и т.п. - уже не
нужно будет комментировать, они идентичны.
К тому же, клиент опрашивает и другие устройства, значит, по сути, в сети Modbus
является мастером.
Чтобы шаги не выглядели как номера, объявим новый тип перечисление
MB_TCP_CLIENT_STEPS . MB_ - это сокращение от Modbus:
Стр. 265 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
TYPE MB_TCP_CLIENT_STEPS : (
MB_STEPS_IDLE,
(* Ожидаем начала роботы *)
MB_STEPS_CREATE,
(* Создаем сокет *)
MB_STEPS_CONNECT, (* Подключаемся к сокету *)
MB_STEPS_SEND,
(* Отправляем запрос *)
MB_STEPS_RESV,
(* Принимаем ответ *)
MB_STEPS_FRMT,
(* Форматировать ответ *)
MB_STEPS_TIMEOUT1, (* Ожидание после ошибки в ответе *)
MB_STEPS_CLOSE,
(* Закрываем подключение *)
MB_STEPS_TIMEOUT2 (* Ожидание перед повторным подключением *)
);
END_TYPE
Client целиком
Посмотрим на код всего примера целиком, а затем сделаем детальный разбор всех его
участков.
Напишем клиент в виде ФБ MB_TCP_CLIENT .
Данный пример - не полный, не такой, каким мог бы быть для надежной работы. Он не
обрабатывает всех возможных ошибок или видов сообщений. Но этот пример и не
минимальный.
Пример не содержит внутренних комментариев - это сделано для того, чтобы этот
достаточно объемный код выглядел минималистичнее. Комментарии к нему я дам позже. К
тому же, комментарий или описание кода в формате книги намного удобнее, чем
комментарии внутри кода.
FUNCTION_BLOCK MB_TCP_CLIENT
VAR
xResult: BOOL; (* Переменная для хранения результата функции *)
diResult: DINT; (* Переменная для хранения результата функции *)
diSock:
DINT; (* Дескриптор (ссылка) сокета *)
diCount: DINT; (* Подсчет отправленных байт *)
uiCount: WORD; (* Номер индекса для прохода по массиву результата *)
uiCon:
BYTE; (* Подсчёт количества попыток подключения *)
iError:
INT; (* Номер последней ошибки сокета *)
fbEnableFall: F_TRIG; (* Триггер на падение входного включающего
сигнала *)
fbTimeoutTON: TON;
(* Таймер задержки перед новым подключением *)
bStep:
MB_TCP_CLIENT_STEPS;
(* Имя текущего шага *)
stSocAddr: SOCKADDRESS;
(* Адрес сокета *)
arbBuffer: ARRAY[0..280] OF BYTE; (* Буфер, куда будет получен
ответ *)
(* Данные, которые будут отправлены с запросом. *)
arbData: ARRAY[0..11] OF BYTE := 00, 01, 00, 00, 00, 06;
Стр. 266 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_VAR
VAR_INPUT
ENABLE:
PORT:
IP:
BOOL; (* Включить опрос *)
UINT := 502; (* Порт *)
DWORD; (* IP адрес в формате HEX, например,
192.168.1.99, будет 16#C0A80163 *)
TIMEOUT1: TIME; (* Время ожидания после ошибки *)
TIMEOUT2: TIME; (* Время ожидания при повторной попытке
подключения *)
REG_START: WORD; (* Регистр, с которого начать *)
REG_NUM:
WORD; (* Количество регистров для чтения *)
FC:
BYTE; (* Функция 03, 04*)
RESET:
BOOL; (* Сброс подключения *)
END_VAR
VAR_OUTPUT
_ERR_CODE: BYTE; (* Номер последней ошибки *)
_DATA: ARRAY[0..255] OF WORD; (* Выходные данные *)
END_VAR
fbEnableFall(CLK := ENABLE);
IF fbEnableFall.Q OR RESET THEN
_ERR_CODE := 0;
bStep
:= MB_STEPS_CLOSE;
END_IF;
CASE bStep OF
MB_STEPS_IDLE:
IF ENABLE THEN
bStep := MB_STEPS_CREATE;
END_IF;
MB_STEPS_CREATE:
diSock := SysSockCreate(
SOCKET_AF_INET, SOCKET_STREAM, SOCKET_IPPROTO_TCP
);
IF diSock <> SOCKET_INVALID THEN
bStep := MB_STEPS_CONNECT;
ELSE
uiCon := uiCon + 1;
IF uiCon > 3 THEN
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 10;
END_IF
END_IF
MB_STEPS_CONNECT:
stSocAddr.sin_family := SOCKET_AF_INET;
stSocAddr.sin_port
:= SysSockHtons(PORT);
stSocAddr.sin_addr
:= SysSockHtonl(IP);
SysSockSetOption(diSock, SOCKET_SOL, SOCK_NBIO, 0, 0);
xResult := SysSockConnect(
diSock, ADR(stSocAddr), SIZEOF(stSocAddr)
);
Стр. 267 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
IF xResult THEN
bStep := MB_STEPS_SEND;
ELSE
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 11;
END_IF;
MB_STEPS_SEND:
arbData[6]
arbData[7]
:= 01; (* 6 - Адрес слейв по модбас *)
:= FC; (* 7 - Функция чтения *)
(* 8, 9 - с какого регистра начать *)
arbData[8] := WORD_TO_BYTE(SHR(REG_START, 8));
arbData[9] := WORD_TO_BYTE(REG_START);
(* 10, 11 - сколько регистров читать *)
arbData[10] := WORD_TO_BYTE(SHR(REG_NUM, 8));
arbData[11] := WORD_TO_BYTE(REG_NUM);
diResult := SysSockSend(diSock, ADR(arbData), SIZEOF(arbData),
SOCKET_MSG_OOB);
IF diResult = -1 THEN
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 12;
END_IF
diCount := diCount + diResult;
IF diCount >= SIZEOF(arbData) THEN
diCount := 0;
bStep := MB_STEPS_RESV;
END_IF;
MB_STEPS_RESV:
diResult := SysSockRecv(diSock, ADR(arbBuffer), SIZEOF(arbBuffer),
SOCKET_MSG_OOB);
IF diResult = -1 THEN
_ERR_CODE := 13;
bStep := MB_STEPS_CLOSE;
END_IF
IF diResult > 0 THEN
IF arbBuffer[7] <> 03 THEN
bStep := MB_STEPS_TIMEOUT1;
_ERR_CODE := arbBuffer[8];
ELSE
bStep := MB_STEPS_FRMT;
END_IF;
END_IF
MB_STEPS_FRMT:
bStep := MB_STEPS_SEND;
Стр. 268 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
REPEAT
_DATA[uiCount] := BYTE_TO_WORD(arbBuffer[10 + (uiCount * 2)])
OR SHL(BYTE_TO_WORD(arbBuffer[9 + (uiCount * 2)]), 8);
uiCount := uiCount + 1;
UNTIL
uiCount >= REG_NUM
END_REPEAT;
uiCount := 0;
MB_STEPS_TIMEOUT1:
fbTimeoutTON(IN := TRUE, PT := TIMEOUT1);
IF fbTimeoutTON.Q THEN
fbTimeoutTON(IN := FALSE);
bStep := MB_STEPS_SEND;
END_IF;
MB_STEPS_CLOSE:
IF diSock > 0 THEN
SysSockGetOption(diSock, SOCKET_ERROR, SO_ERROR, ADR(iError),
SIZEOF(iError));
IF iError <> 0 THEN
_ERR_CODE := INT_TO_BYTE(iError);
END_IF
SysSockClose(diSock);
SysSockShutdown(diSock, 2);
END_IF;
uiCon := 0;
diSock := 0;
bStep := MB_STEPS_TIMEOUT2;
fbTimeoutTON(IN := FALSE);
MB_STEPS_TIMEOUT2:
fbTimeoutTON(IN := TRUE, PT := TIMEOUT2);
IF fbTimeoutTON.Q THEN
fbTimeoutTON(IN := FALSE);
bStep := MB_STEPS_IDLE;
END_IF;
END_CASE;
END_FUNCTION_BLOCK
Разбор примера
Сначала, как обычно, необходимо сделать несколько объявлений переменных.
Большинство переменных закомментировано, далее мы увидим их в действии.
Ниже код, который я буду демонстрировать частями и комментировать под примерами:
fbEnableFall(CLK := ENABLE);
IF fbEnableFall.Q OR RESET THEN
_ERR_CODE := 0;
bStep
:= MB_STEPS_CLOSE;
Стр. 269 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
END_IF;
В начале программы привязываем вход ENABLE к ФБ триггеру fbEnableFall падения
сигнала. Как только на ENABLE произойдет изменение с TRUE на FALSE , сработает это
условие. Он переключит текущий шаг на закрытие подключения и сбросит номер
последней ошибки. Таким образом гарантируется корректное отключение сокета при
выключении блока.
То же самое произойдет при подаче сигнала на RESET . Как только на RESET появится
сигнал, ФБ попытается установить новое подключение:
CASE bStep OF
MB_STEPS_IDLE:
IF ENABLE THEN
bStep := MB_STEPS_CREATE;
END_IF;
Итак, первый шаг. Как мы уже узнали из главы о последовательностях, это хорошая
практика - создавать первоначальный шаг, на котором программа может находиться до тех
пор пока не нужно никаких действий. Как только появляется сигнал на ENABLE , запускаем
весь цикл.
MB_STEPS_CREATE:
diSock := SysSockCreate(
SOCKET_AF_INET, SOCKET_STREAM, SOCKET_IPPROTO_TCP
);
IF diSock <> SOCKET_INVALID THEN
bStep := MB_STEPS_CONNECT;
ELSE
uiCon := uiCon + 1;
IF uiCon > 3 THEN
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 10;
END_IF
END_IF
Шаг создания сокета, который мы осуществляем при помощи функции SysSockCreate .
Чтобы узнать, какие параметры принимает эта функция и какие варианты значений у нас
есть, смотрите описание этой функции, которое я добавил в конце главы о сокетах.
Если сокет был корректно создан, то diSock будет иметь любое значение, кроме
SOCKET_INVALID , что в действительности -1 . Значит, мы можем перейти к следующему
шагу MB_STEPS_CONNECT и осуществить подключение.
Стр. 270 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Если сокет не был создан, то пытаемся создать его еще 3 цикла ПЛК, если после этого не
получается, то переходим к шагу MB_STEPS_CLOSE - закрытие и удаление сокета.
Вы спросите: зачем закрывать сокет, если мы даже не смогли его создать? Это станет
понятно, когда дойдем до шага MB_STEPS_CLOSE .
Совершенно не обязательно делать 3 попытки подключения, достаточно и одной.
Подобный подход я увидел в примере Ивана Мастеренко и привел его здесь, посчитав, что
кому-то он может помочь в определенный момент решить проблему:
MB_STEPS_CONNECT:
stSocAddr.sin_family := SOCKET_AF_INET;
stSocAddr.sin_port
:= SysSockHtons(PORT);
stSocAddr.sin_addr
:= SysSockHtonl(IP);
SysSockSetOption(diSock, SOCKET_SOL, SOCK_NBIO, 0, 0);
xResult := SysSockConnect(
diSock, ADR(stSocAddr), SIZEOF(stSocAddr)
);
IF xResult THEN
bStep := MB_STEPS_SEND;
ELSE
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 11;
END_IF;
Шаг подключения. Сначала заполняем структуру SOCKADDRESS , которая находится в
переменной stSocAddr . Подробное описание структуры и о том, как записать IP адрес в
переменную, будет приведен в конце главы, где описана функция SysSockConnect .
Разумеется, здесь мы задаем адрес, на который мы будем обращаться. На серверной
стороне этого шага нет, а есть шаг привязки этого же адреса к сокету сервера.
Функция SysSockConnect возвращает BOOL - TRUE или FALSE . Если все успешно,
переходим в следующий шаг и отправляем запрос на сервер, если нет - закрываем сокет и
пытаемся все пройти заново.
К сведению
Этот код не будет работать, например, на некоторых ПЛК Овен. У
Овен функция SysSockConnect всегда вернет FALSE . После шага
подключения на ПЛК Овен нужно без проверок переходить к
следующему шагу.
Еще одна функция SysSockSetOption . Она устанавливает разные
параметры подключения.
Стр. 271 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В данном примере мы устанавливаем параметр SOCK_NBIO в 0 . NBIO - Non Blocking IO
или неблокирующий вызов. Ноль в этом вызове значит, что неблокирующий вызов
отключен или что у нас блокирующий вызов. Блокирующий значит, что пока текущая
функция библиотеки SysLibSocket не вернет данные, ПЛК не перейдет к исполнению
следующей строки.
Напоминаю: подробное описание всех используемых тут функций библиотеки
SysLibSocket смотрите в конце главы.
На этом инициализация сокета закончена:
MB_STEPS_SEND:
arbData[6]
arbData[7]
:= 01; (* 6 - Адрес слейв по модбас *)
:= FC; (* 7 - Функция чтения *)
(* 8, 9 - с какого регистра начать *)
arbData[8] := WORD_TO_BYTE(SHR(REG_START, 8));
arbData[9] := WORD_TO_BYTE(REG_START);
(* 10, 11 - сколько регистров читать *)
arbData[10] := WORD_TO_BYTE(SHR(REG_NUM, 8));
arbData[11] := WORD_TO_BYTE(REG_NUM);
diResult := SysSockSend(diSock, ADR(arbData), SIZEOF(arbData),
SOCKET_MSG_OOB);
Переходим к шагу отправки данных. Это рабочая часть сокета. Шаг достаточно большой,
поэтому разберем его по частям.
Итак, первая часть кода шага отправки запроса на сервер. Сначала нужно заполнить
массив отправляемых данных arbData . Что значат эти байты? Это протокол TCP. Первые
6 байт, в рамках одной транзакции, всегда будут одинаковы в запросе или ответе. Это
можно назвать заголовком MBAP Header (Modbus Application Header).
№ Описание
0
Идентификатор транзакции занимает 2 байта, обычно 0. Что сюда поставим, то будет
и в ответе. Для однозначного определения, на какой запрос был получен ответ.
1
Идентификатор транзакции, второй байт.
2
Идентификатор протокола занимает 2 байта, обычно 0. 00 00 - Modbus.
3
Идентификатор протокола, второй байт.
4
Количество байт после этой пары байт. Первый байт всегда 0.
5
Второй байт количества байт.
У нас они определены в области объявления переменных:
Стр. 272 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
arbData: ARRAY[0..11] OF BYTE := 00, 01, 00, 00, 00, 06;
Первые 4 байта обычно 0, далее - пара байт, которая указывает, сколько еще байт нужно
прочитать. Скажем так, принимающая сторона читает первые 6 байтов, из последних 2-х
узнает длину сообщения и читает остаток.
Байт 4 всегда будет 0, потому что длина сообщений не превышает 255, так что одного
нижнего байта всегда достаточно.
Видно, что в коде мы назначаем до 11-го индекса массива, это значит, что у нас 12
элементов в массиве, начиная с 0, или 12 байт. Первые 6 - это заголовок, а после них
остается 6. Значит, пишем туда 06 .
Теперь назначим другие 6 байтов переменной arbData .
№ Описание
6
7
Адрес slave опрашиваемого устройства. У нас клиент, значит, мы в сети мастер
Код функции. Это может быть 03, 04, 16 и т.д. для чтения или записи одного или
некольских регистров или бит.
8
Начальный регистр для чтения. Переменная из 2-х байт
9
Второй байт начального регистра
10
Количество регистров для чтения. Скорее всего, тоже всегда 0, так как ограничение
255 на один запрос.
11 Количество регистров для чтения, второй байт
Закрепим это примером. Вот запрос на чтение функцией 03 из устройства по адресу 9,
одного регистра, начиная с 4-го:
00 00 00 00 00 06 09 03 00 04 00 01
Вот ответ, что 9-е устройство по функции чтения 3 вернуло ответ в 2-х байтах, равный 00
05 или просто 5 :
00 00 00 00 00 05 09 03 02 00 05
В нашем случае мы делаем запрос на чтение по функции 03 или 04, а их формат
идентичен. Подробнее о протоколе Modbus, формате запросов и ответов вы можете
прочитать в этой прекрасной статье. В ней описаны форматы разных запросов и ответов на
разные функции.
https://ipc2u.ru/articles/prostye-resheniya/modbus-tcp/
Стр. 273 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
IF diResult = -1 THEN
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 12;
END_IF
diCount := diCount + diResult;
IF diCount >= SIZEOF(arbData) THEN
diCount := 0;
bStep := MB_STEPS_RESV;
END_IF;
Вторая часть шага MB_STEPS_SEND . Проверяем результат. Переменная diResult содержит
количество отправленных байт. Если diResult равен -1, то у нас присутствует ошибка
отправки. Тогда мы сбрасываем подключение и пытаемся все пройти заново.
Так как мы отправляем данные в массиве arbData , то для подсчета количества байт в этом
массиве можно использовать оператор SIZEOF(arbData) . Это значит, что если diResult
будет равно SIZEOF(arbData) , то все данные были отправлены и можно переходить к
принятию ответа.
Зачем здесь diCount := diCount + diResult; ? По коду видно, что мы пытаемся
суммировать количество отправленных байт от одного цикла ПЛК к другому. Это говорит
о том, что функция SysSockSend может за один цикл не успеть отправить весь массив.
Такое может произойти в случае, если вызов функций библиотеки SysLibSocket настроен
как не блокирующий. Мы помним, что поставили флаг на шаге MB_STEPS_CONNECT , чтобы
получить блокирующий вызов:
SysSockSetOption(diSock, SOCKET_SOL, SOCK_NBIO, 0, 0);
Получается, что подобный подход не обязателен и функция SysSockSend всегда должна
отправить данные за один цикл ПЛК, независимо от размера массива arbData . Я решил
использовать этот пример, так как он будет работать и в блокирующем, и в
неблокирующем режимах, а для читателя это еще один пример, чтобы понять разницу:
MB_STEPS_RESV:
diResult := SysSockRecv(diSock, ADR(arbBuffer), SIZEOF(arbBuffer),
SOCKET_MSG_OOB);
IF diResult = -1 THEN
_ERR_CODE := 13;
bStep := MB_STEPS_CLOSE;
END_IF
IF diResult > 0 THEN
IF arbBuffer[7] <> 03 THEN
bStep := MB_STEPS_TIMEOUT1;
Стр. 274 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
_ERR_CODE := arbBuffer[8];
ELSE
bStep := MB_STEPS_FRMT;
END_IF;
END_IF
На шаге MB_STEPS_RESV принимаем данные от Slave устройства. Если diResult равно -1 ,
то мы знаем, что произошла ошибка чтения данных. В этом случае переходим на шаг
сброса.
Если же результат больше 0, а diResult должен содержать количество прочитанных байт,
то смотрим, нет ли в ответе ошибки. В Modbus можно определить наличие ошибки,
сравнив 7-й байт ответа, который содержит функцию чтения. Мы знаем, что использовали
функцию 03. Если ошибки на слейв-устройстве не было, то вернуться тоже должна
функция 03.
Убедившись, что ошибки нет, переходим на шаг MB_STEPS_FRMT - форматирование
результата. В случае ошибки, переходим на задержку, при этом 8-й байт будет содержать
код ошибки. Вот список кодов ошибок Modbus.
• 01 - Принятый код функции не может быть обработан.
• 02 - Адрес данных, указанный в запросе, недоступен.
• 03 - Значение, содержащееся в поле данных запроса, является недопустимой
величиной.
• 04 - Невосстанавливаемая ошибка имела место, пока ведомое устройство пыталось
выполнить затребованное действие.
• 05 - Ведомое устройство приняло запрос и обрабатывает его, но это требует много
времени. Этот ответ предохраняет ведущее устройство от генерации ошибки таймаута.
• 06 - Ведомое устройство занято обработкой команды. Ведущее устройство должно
повторить сообщение позже, когда ведомое освободится.
• 07 - Ведомое устройство не может выполнить программную функцию, заданную в
запросе. Этот код возвращается для неуспешного программного запроса,
использующего функции с номерами 13 или 14. Ведущее устройство должно
запросить диагностическую информацию или информацию об ошибках от ведомого.
• 08 - Ведомое устройство при чтении расширенной памяти обнаружило ошибку
паритета. Ведущее устройство может повторить запрос, но обычно в таких случаях
требуется ремонт.
Теперь становится понятно, почему мы использовали номера кодов для _ERR_CODE ,
начиная с 10 - чтобы наши внутренние номера ошибок не конфликтовали с ошибками
Modbus.
Стр. 275 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
MB_STEPS_FRMT:
bStep := MB_STEPS_SEND;
REPEAT
_DATA[uiCount] := BYTE_TO_WORD(arbBuffer[10 + (uiCount * 2)])
OR SHL(BYTE_TO_WORD(arbBuffer[9 + (uiCount * 2)]), 8);
uiCount := uiCount + 1;
UNTIL
uiCount >= REG_NUM
END_REPEAT;
uiCount := 0;
На шаге форматирования я объединяю полученные байты массива arbBuffer в регистры
типа WORD . Так мне удобнее. После этого шага переходим на шаг отправки нового запроса,
потом опять чтение, и так по кругу.
Возникает вопрос: как регулировать частоту опроса? Можно вставить еще один шаг с
задержкой между опросами и добавить входной параметр типа TIME . Как часто нужно
опрашивать? Например, раз в секунду, если не нужна скорость и мы хотим поберечь
ресурсы ПЛК и сети.
Я не включил такой код, потому что он несложный, точная копия следующего описанного
шага. Предлагаю другой подход. Обычно я выношу такой ФБ мастера Modbus в отдельную
программу и потом ставлю в задачи запуск этой программы с нужным мне интервалом. На
большинстве систем такие задачи могут исполняться параллельно с основной программой:
MB_STEPS_TIMEOUT1:
fbTimeoutTON(IN := TRUE, PT := TIMEOUT1);
IF fbTimeoutTON.Q THEN
fbTimeoutTON(IN := FALSE);
bStep := MB_STEPS_SEND;
END_IF;
Шаг ожидания перед повторным запросом, если Slave вернул ошибку:
MB_STEPS_CLOSE:
IF diSock > 0 THEN
SysSockGetOption(diSock, SOCKET_ERROR, SO_ERROR, ADR(iError),
SIZEOF(iError));
IF iError <> 0 THEN
_ERR_CODE := INT_TO_BYTE(iError);
END_IF
SysSockClose(diSock);
SysSockShutdown(diSock, 2);
END_IF;
uiCon := 0;
diSock := 0;
bStep := MB_STEPS_TIMEOUT2;
fbTimeoutTON(IN := FALSE);
MB_STEPS_TIMEOUT2:
Стр. 276 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
fbTimeoutTON(IN := TRUE, PT := TIMEOUT2);
IF fbTimeoutTON.Q THEN
fbTimeoutTON(IN := FALSE);
bStep := MB_STEPS_IDLE;
END_IF;
Шаг закрытия не только закрывает и удаляет сам сокет, но и сбрасывает некоторые
переменные и подготавливает все к новой попытке подключения. Так же следующий шаг
MB_STEPS_TIMEOUT2 неразрывно связан с шагом закрытия сокета.
После сброса, который происходит за один цикл ПЛК, переходим на задержку. Затем
переходим на шаг ожидания MB_STEPS_IDLE . Если ENABLE включен, сразу же начнется
новая попытка подключения.
Обратите внимание, что мы закрываем подключение ( SysSockClose ) и удаляем сокет
( SysSockShutdown ), только если diSock > 0 . Потому что мы можем попасть на этот шаг
при любой ошибке, включая ошибку создания самого сокета. Если он не создан, то и
закрывать его не нужно.
Перед закрытием, при помощи функции SysSockGetOption , получаем последнюю ошибку
в сокете, если она есть, то назначаем ее нашему коду ошибки.
Подробнее смотрите описание функции SysSockGetOption , чтобы узнать возможные
параметры и возможные коды ошибок.
END_CASE;
END_FUNCTION_BLOCK
Осталось только закрыть CASE и FUNCTION_BLOCK . Всё. Сначала это может показаться
сложным, но, потратив какое-то время, начинаешь понимать, что это довольно просто.
Сокет TCP Server (Modbus TCP Slave)
При создании сервера используются еще несколько дополнительных шагов. Создадим тип
переменной перечисление MB_TCP_SERVER_STEPS для именования шагов.
В сервере будет немного больше шагов:
TYPE MB_TCP_SERVER_STEPS : (
MB_STEPS_IDLE,
(* Ожидаем начала работы *)
MB_STEPS_CREATE,
(* Создаем сокет *)
MB_STEPS_CONNECT, (* Подключаемся к сокету *)
MB_STEPS_BIND,
(* Привязываем адрес к сокету *)
MB_STEPS_ACCEPT,
(* Принимаем запросы на подключение *)
MB_STEPS_LISTEN,
(* Разрешаем запросам быть принятыми *)
Стр. 277 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
MB_STEPS_SEND,
(* Отправляем запрос *)
MB_STEPS_RESV,
(* Принимаем ответ *)
MB_STEPS_PREP_03, (* Подготовить ответ по функции 03 *)
MB_STEPS_PREP_04, (* Подготовить ответ по функции 04 *)
MB_STEPS_ERR,
(* Подготовить сообщение об ошибке *)
MB_STEPS_TIMEOUT1, (* Ожидание после ошибки в ответе *)
MB_STEPS_CLOSE,
(* Закрываем подключение *)
MB_STEPS_TIMEOUT2 (* Ожидание перед повторным подключением *)
);
END_TYPE
При создании сервера нужно убедиться, что он может обслужить несколько параллельных
подключений. Но это в случае с TCP сервером. Мы же, по сути, создаем Modbus Slave, так
что нам это не обязательно, хотя мы это реализуем, чтобы видеть как это решается.
Server целиком
Посмотрим на код сервера целиком, а затем разберем его по частям. В этом коде много
схожих шагов, поэтому описывать буду только те, что разнятся.
Напишем пример в виде ФБ MB_TCP_SERVER :
FUNCTION_BLOCK MB_TCP_SERVER
VAR
xResult:
BOOL; (* Переменная для хранения результата функции *)
diResult: DINT; (* Переменная для хранения результата функции *)
diSock:
DINT; (* Дескриптор (ссылка) сокета *)
diSockClient: DINT; (* Дескриптор (ссылка) сокета клиента *)
diCount:
DINT; (* Подсчет отправленных байт *)
uiCount:
UINT; (* Номер индекса для прохода по массиву результата *)
uiCon:
UINT; (* Подсчёт количества попыток подключения *)
iError:
INT; (* Номер последней ошибки сокета *)
fbEnableFall: F_TRIG; (* Триггер на падение входного включающего
сигнала *)
fbTimeoutTON: TON;
(* Таймер задержки перед новым подключением *)
diMaxConnections: INT := 3;
bStep:
MB_TCP_SERVER_STEPS;
stSocAddr:
SOCKADDRESS;
(* Имя текущего шага *)
(* Адрес сокета *)
(* Буфер, куда будет получен ответ *)
arbBuffer:
ARRAY[0..280] OF BYTE;
(* Массив для составления ответа *)
arbResponse: ARRAY[0..280] OF BYTE;
END_VAR
VAR_INPUT
ENABLE: BOOL; (* Включить опрос *)
TIMEOUT: TIME; (* Время ожидания при повторной попытке подключения *)
RESET:
BOOL; (* Сброс подключения *)
Стр. 278 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
DATA:
ARRAY[0..255] OF WORD; (* Карта регистров *)
END_VAR
VAR_OUTPUT
_ERR_CODE: BYTE; (* Номер последней ошибки *)
END_VAR
fbEnableFall(CLK := ENABLE);
IF fbEnableFall.Q OR RESET THEN
_ERR_CODE := 0;
bStep
:= MB_STEPS_CLOSE;
END_IF;
CASE bStep OF
MB_STEPS_IDLE:
IF ENABLE THEN
bStep := MB_STEPS_CREATE;
END_IF;
MB_STEPS_CREATE:
diSock := SysSockCreate(
SOCKET_AF_INET, SOCKET_STREAM, SOCKET_IPPROTO_TCP
);
IF diSock <> SOCKET_INVALID THEN
bStep := MB_STEPS_CONNECT;
ELSE
uiCon := uiCon + 1;
IF uiCon > 3 THEN
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 10;
END_IF
END_IF
MB_STEPS_BIND:
sa.sin_family := SOCKET_AF_INET;
sa.sin_addr
:= SysSockHtonl(SOCKET_INADDR_ANY);
sa.sin_port
:= SysSockHtons(502);
SysSockSetOption(diSock, SOCKET_SOL, SOCK_NBIO, 0, 0);
xResult := SysSockBind(iSocket, ADR(stSocAddr), SIZEOF(stSocAddr));
IF xResult THEN
bStep := MB_STEPS_LISTEN;
ELSE
bStep := MB_STEPS_CLOSE;
END_IF;
MB_STEPS_LISTEN:
xResult := SysSockListen(iSocket, diMaxConnections);
IF xResult THEN
bStep := MB_STEPS_ACCEPT;
ELSE
bStep := MB_STEPS_CLOSE;
END_IF;
Стр. 279 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
MB_STEPS_ACCEPT:
diSockClient := SysSockAccept(
iSocket, ADR(stSocAddr), SIZEOF(stSocAddr)
);
SysSockSetOption(diSockClient, SOCKET_SOL, SOCK_NBIO, 0, 0);
IF diSockClient <> SOCKET_INVALID THEN
bStep := MB_STEPS_RESV;
END_IF
fbTimeoutTON(IN := TRUE, PT := T#2m);
IF fbTimeoutTON.Q THEN
fbTimeoutTON(IN := FALSE);
bStep := MB_STEPS_CLOSE;
END_IF
MB_STEPS_RESV:
fbTimeoutTON(IN := FALSE);
diResult := SysSockRecv(
diSockClient, ADR(arbBuffer), SIZEOF(arbBuffer), SOCKET_MSG_OOB
);
IF diResult = -1 THEN
_ERR_CODE := 13;
bStep := MB_STEPS_CLOSE;
END_IF
IF diResult > 0 THEN
CASE arbBuffer[7] OF
03: bStep := MB_STEPS_PREP_03;
04: bStep := MB_STEPS_PREP_04;
ELSE
_ERR_CODE := 01;
bStep := MB_STEPS_PREP_ERR;
END_CASE;
END_IF
MB_STEPS_PREP_03:
DATA[0] := 100;
DATA[1] := 20;
DATA[2] := 365;
DATA[3] := 718;
wRegStart := BYTE_TO_DWORD(arbBuffer[9]) OR
BYTE_TO_DWORD(SHL(arbBuffer[8], 8));
wRegNum
:= BYTE_TO_DWORD(arbBuffer[11]) OR
BYTE_TO_DWORD(SHL(arbBuffer[10], 8));
IF wRegStart + (wRegNum - 1) > 3 THEN
_ERR_CODE := 02;
bStep := MB_STEPS_PREP_ERR;
ELSE
arbResponse[0] := arbBuffer[0]; (* Идентификатор транзакции *)
arbResponse[1] := arbBuffer[1];
Стр. 280 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
arbResponse[2] := arbBuffer[2]; (* Идентификатор протокола *)
arbResponse[3] := arbBuffer[3];
arbResponse[4] := 00;
(* Длина сообщения, 2 байта *)
arbResponse[5] := (wRegNum * 2) + 3;
arbResponse[6] := arbBuffer[6]; (* Код функции чтения *)
arbResponse[7] := arbBuffer[7]; (* Количество байт далее *)
arbResponse[8] := wRegNum * 2;
iCount := 0;
REPEAT
arbResponse[9 + (iCount * 2)] := WORD_TO_BYTE(
SHR(DATA[wRegStart + iCount], 8)
);
arbResponse[10 + (iCount * 2)] := WORD_TO_BYTE(
DATA[wRegStart +iCount]
);
iCount := iCount + 1;
UNTIL
iCount >= wRegNum;
END_REPEAT;
bStep := MB_STEPS_SEND;
END_IF;
MB_STEPS_SEND:
diResult := SysSockSend(
iSockClient,
ADR(arbResponse),
SIZEOF(arbResponse),
SOCKET_MSG_OOB
);
IF diResult = -1 THEN
bStep := MB_STEPS_CLOSE;
_ERR_CODE := 12;
END_IF
diCount := diCount + diResult;
IF diCount >= SIZEOF(arbData) THEN
diCount := 0;
bStep := MB_STEPS_ACCEPT;
END_IF;
MB_STEPR:
arbResponse[0] := arbBuffer[0];
arbResponse[1] := arbBuffer[1];
arbResponse[2] := arbBuffer[2];
arbResponse[3] := arbBuffer[3];
arbResponse[4] := arbBuffer[4];
arbResponse[5] := arbBuffer[5];
arbResponse[6] := arbBuffer[6];
arbResponse[8] := _ERR_CODE;
arbResponse[7] := arbBuffer[7] + 16#80;
Стр. 281 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
bStep := MB_STEPS_SEND;
MB_STEPS_CLOSE:
IF diSock > 0 THEN
SysSockGetOption(diSock, SOCKET_ERROR, SO_ERROR, ADR(iError),
SIZEOF(iError));
IF iError <> 0 THEN
_ERR_CODE := INT_TO_BYTE(iError);
END_IF
SysSockClose(diSock);
SysSockShutdown(diSock, 2);
END_IF;
IF diSockClient > 0 THEN
SysSockGetOption(
diSockClient,
SOCKET_ERROR,
SO_ERROR, ADR(iError),
SIZEOF(iError)
);
IF iError <> 0 THEN
_ERR_CODE := INT_TO_BYTE(iError);
END_IF
SysSockClose(diSockClient);
SysSockShutdown(diSockClient, 2);
END_IF;
uiCon := 0;
diSock := 0;
bStep := MB_STEPS_TIMEOUT2;
fbTimeoutTON(IN := FALSE);
MB_STEPS_TIMEOUT2:
fbTimeoutTON(IN := TRUE, PT := TIMEOUT2);
IF fbTimeoutTON.Q THEN
fbTimeoutTON(IN := FALSE);
bStep := MB_STEPS_IDLE;
END_IF;
END_CASE;
END_FUNCTION_BLOCK
Разбор примера
Область объявлений будет незначительно отличаться. По паре переменных добавилось и
убавилось.
Давайте рассмотрим шаги, которых не было в клиенте:
MB_STEPS_BIND:
sa.sin_family := SOCKET_AF_INET;
sa.sin_addr
:= SysSockHtonl(SOCKET_INADDR_ANY);
sa.sin_port
:= SysSockHtons(502);
SysSockSetOption(diSock, SOCKET_SOL, SOCK_NBIO, 0, 0);
Стр. 282 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
xResult := SysSockBind(iSocket, ADR(stSocAddr), SIZEOF(stSocAddr));
IF xResult THEN
bStep := MB_STEPS_LISTEN;
ELSE
bStep := MB_STEPS_CLOSE;
END_IF;
После создания сокета привязываем к нему адрес, чтобы к нему можно было обратиться.
На клиенте мы используем stSocAddr не для того, чтобы задать адрес, а чтобы указать, к
какому адресу подключаться. Именно этот шаг на сервере и создает эту точку
подключения.
Как IP адрес мы указываем SOCKET_INADDR_ANY . Это привяжет сокет ко всем интерфейсам
устройства. Порт в Modbus TCP всегда 502.
Если привязка была успешной, переходим на следующий шаг MB_STEPS_LISTEN или
сбрасываем сокет, пытаясь создать все заново:
MB_STEPS_LISTEN:
xResult := SysSockListen(iSocket, diMaxConnections);
IF xResult THEN
bStep := MB_STEPS_ACCEPT;
ELSE
bStep := MB_STEPS_CLOSE;
END_IF;
Функция SysSockListen переводит сокет в состояние прослушивания соединений на
подключение и ставит все запросы в очередь.
На этом инициализация сокета закончена.
Если сокет успешно перешел в режим прослушивания подключений, переходим на
следующий шаг MB_STEPS_ACCEPT или сбрасываем сокет, пытаясь создать все заново:
MB_STEPS_ACCEPT:
diSockClient := SysSockAccept(
iSocket, ADR(stSocAddr), SIZEOF(stSocAddr)
);
SysSockSetOption(diSockClient, SOCKET_SOL, SOCK_NBIO, 0, 0);
IF diSockClient <> SOCKET_INVALID THEN
bStep := MB_STEPS_RESV;
END_IF
fbTimeoutTON(IN := TRUE, PT := T#2m);
IF fbTimeoutTON.Q THEN
fbTimeoutTON(IN := FALSE);
Стр. 283 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
bStep := MB_STEPS_CLOSE;
END_IF
C этого шага начинается рабочий процесс. После полного цикла приема и отправки
данных, вернемся снова на этот шаг. Функция SysSockListen ставит запросы в очередь, а
функция SysSockAccept принимает следующий в очереди запрос на подключение и на
получение информации. Пока мы будем получать и отправлять данные, клиент может
прислать еще несколько запросов, так что в следующем круге мы опять вернемся на
SysSockAccept , чтобы принять новый запрос из очереди.
Для того, чтобы осуществить коммуникацию с запросом, который был принят функцией
SysSockAccept , нужно знать, от кого был этот запрос. Сервер должен уметь обрабатывать
запросы от разных клиентов. Кому нужно будет вернуть ответ? Как сервер так и клиент
TCP, должны знать, с каким именно клиентом или сервером они работают в данный
момент. Поэтому SysSockAccept возвращает дескриптор сокета клиента или дескриптор
текущего запроса из очереди. Так как этот дескриптор такой же, какой мы получаем из
функции SysSockCreate , мы проверяем его валидность точно так же, как и на шаге
подключения MB_STEPS_CREATE .
Есть таймер на 2 минуты. Если возвращаемый сокет не валидный, то ждем две минуты, в
течение которых функция SysSockAccept продолжает вызываться. Если в течение этого
времени ни одного запроса на подключение клиента не было, или просто ни одному
клиенту не удалось подключится к нашему серверу, то мы сбрасываем наш серверный
сокет.
Если в клиенте у нас сначала отправка запроса, а потом принятие ответа, то на сервере
наоборот - сначала принимаем запрос, а потом шлем ответ.
MB_STEPS_RESV:
fbTimeoutTON(IN := FALSE);
diResult := SysSockRecv(
diSockClient,
ADR(arbBuffer),
SIZEOF(arbBuffer),
SOCKET_MSG_OOB
);
IF diResult = -1 THEN
_ERR_CODE := 13;
bStep := MB_STEPS_CLOSE;
END_IF
IF diResult > 0 THEN
CASE arbBuffer[7] OF
03: bStep := MB_STEPS_PREP_03;
04: bStep := MB_STEPS_PREP_04;
Стр. 284 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
ELSE
_ERR_CODE := 01;
bStep := MB_STEPS_PREP_ERR;
END_CASE;
END_IF
Запрос принимает данные идентично, как и клиент, сохраняя их в arbBuffer . Теперь
нужно обработать данные. Вы наверняка часто читали в документации к устройствам,
какие функции они поддерживают, какие регистры, что содержат и т.д. Все это и должен
обеспечить наш код. Если мы хотим поддержать функцию 03, то мы должны ее
соответственно обработать, если хотим поддержать 04, то и ее нужно обработать.
Это еще не все. Помните список кодов ошибок, который был приведен ранее, в описании
шага MB_STEPS_RESV примера сокета клиента? Нужно вручную все эти ошибки
обработать. Действуем просто - устанавливаем код ошибки и переходим на
MB_STEPS_PREP_ERR .
Здесь отслеживаем ошибку 01 - запрашиваемый код функции не обрабатывается этим
устройством. Мы можем обрабатывать только 03 и 04, а другие функции нет:
MB_STEPS_PREP_ERR:
arbResponse[0] := arbBuffer[0];
arbResponse[1] := arbBuffer[1];
arbResponse[2] := arbBuffer[2];
arbResponse[3] := arbBuffer[3];
arbResponse[4] := arbBuffer[4];
arbResponse[5] := arbBuffer[5];
arbResponse[6] := arbBuffer[6];
arbResponse[8] := _ERR_CODE;
arbResponse[7] := arbBuffer[7] + 16#80;
bStep := MB_STEPS_SEND;
В случае типа ошибки, которая должна вернуться как ответ клиенту, мы формируем набор
данных в соответствии с требованием протокола. Для этого нужно заменить байт,
хранящий номер функции, а байту за ним нужно установить номер ошибки, список
которых я уже приводил.
Вот таблица изменения 7-го байта кода функции. Но честно говоря, таблица не нужна. Код
ошибки всегда будет равен код функции + 0x80, как мы видим из arbResponse[7] :=
arbBuffer[7] + 16#80; .
Функциональный код в запросе Функциональный код ошибки в ответе
01 (01 hex) 0000 0001
Стр. 285 из 298
129 (81 hex) 1000 0001
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Функциональный код в запросе Функциональный код ошибки в ответе
02 (02 hex) 0000 0010
130 (82 hex) 1000 0010
03 (03 hex) 0000 0011
131 (83 hex) 1000 0011
04 (04 hex) 0000 0100
132 (84 hex) 1000 0100
05 (05 hex) 0000 0101
133 (85 hex) 1000 0101
06 (06 hex) 0000 0110
134 (86 hex) 1000 0110
15 (0F hex) 0000 1111
143 (8F hex) 1000 1111
16 (10 hex) 0001 0000
144 (90 hex) 1001 0000
MB_STEPS_PREP_03:
DATA[0] := 100;
DATA[1] := 20;
DATA[2] := 365;
DATA[3] := 718;
wRegStart := BYTE_TO_DWORD(arbBuffer[9]) OR BYTE_TO_DWORD(
SHL(arbBuffer[8], 8)
);
wRegNum
:= BYTE_TO_DWORD(arbBuffer[11]) OR BYTE_TO_DWORD(
SHL(arbBuffer[10], 8)
);
IF wRegStart + (wRegNum - 1) > 3 THEN
_ERR_CODE := 02;
bStep := MB_STEPS_PREP_ERR;
ELSE
arbResponse[0] := arbBuffer[0]; (* Идентификатор транзакции *)
arbResponse[1] := arbBuffer[1];
arbResponse[2] := arbBuffer[2]; (* Идентификатор протокола *)
arbResponse[3] := arbBuffer[3];
arbResponse[4] := 00;
(* Длина сообщения, 2 байта *)
arbResponse[5] := (wRegNum * 2) + 3;
arbResponse[6] := arbBuffer[6]; (* Код функции чтения *)
arbResponse[7] := arbBuffer[7]; (* Количество байт далее *)
arbResponse[8] := wRegNum * 2;
iCount := 0;
REPEAT
arbResponse[9 + (iCount * 2)] := WORD_TO_BYTE(
SHR(DATA[wRegStart + iCount], 8)
);
arbResponse[10 + (iCount * 2)] := WORD_TO_BYTE(
DATA[wRegStart + iCount]
);
iCount := iCount + 1;
UNTIL
iCount >= wRegNum;
END_REPEAT;
bStep := MB_STEPS_SEND;
END_IF;
Стр. 286 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Далее нужно подготовить данные на отправку. Функции 03 и 04 имеют одинаковую
структуру ответа, так что этот пример подойдет в обоих случаях. Только в функции 04 мы
можем изменять данные, если они не соответствуют.
Предположим, что DATA - это карта наших регистров для чтения. Я его инициализирую,
но, очевидно, что он будет заполняться привязками к другим переменным, скорее всего,
через указатели. Для демонстрации нас устроит простой массив.
Сначала получим номер регистра wRegStart , что будет соответствовать индексу нашего
массива DATA , и количество читаемых регистров wRegNum .
Потом проверим, что у нас есть такой регистр. Если запрашиваемый адрес не входит в
нашу карту, выдаем ошибку 02, согласно спецификации Modbus.
Если у нас имеются нужные данные, то создаем массив для отправки данных. Первые 4
байта (0-3) - протокол и номер транзакции, должны соответствовать тому, что мы приняли.
Байты 4-5 содержат - сколько байт нужно прочесть после этого байта. Это длинна
запрашиваемых регистров плюс 3 дополнительных байта. Последний из этих 3-х
дополнительных байт равен 8, это количество байт после него. Так как один регистр
состоит из 2-х байт, чтобы получить количество байт, которое мы подготовим, нужно
запрашиваемое число регистров wRegNum умножить на 2.
На этом заголовок заканчивается и нужно вставлять данные. Мы их упаковываем в цикле
REPEAT . Нам нужно один регистр WORD разбить на 2 BYTE . Предположим, запросили
регистр 3, у нас это 718. Это будет в WORD :
0000 0010 1100 1110
Первый байт 0000 0010 , а второй 1100 1110 . Преобразование WORD_TO_BYTE оставит
только второй байт. Значит, первый байт нам нужно сдвинуть на 8 бит вправо перед
преобразованием и получить:
0000 0000 0000 0010
Чтобы после преобразования осталось только 0000 0010 .
Данные готовы, переходим на шаг отправки MB_STEPS_SEND :
MB_STEPS_SEND:
diResult := SysSockSend(diSockClient, ADR(arbResponse),
SIZEOF(arbResponse), SOCKET_MSG_OOB);
IF diResult = -1 THEN
bStep := MB_STEPS_CLOSE;
Стр. 287 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
_ERR_CODE := 12;
END_IF
diCount := diCount + diResult;
IF diCount >= SIZEOF(arbData) THEN
diCount := 0;
bStep := MB_STEPS_ACCEPT;
END_IF;
Шаг отправки такой же, как и в клиенте. Как дескриптор, мы используем тот, что получили
на шаге MB_STEPS_ACCEPT . Если отправка успешна, снова переходим на шаг
MB_STEPS_ACCEPT , чтобы получить следующее подключение в очереди.
Описание функций библиотеки
SysLibSocket
Как приложение, я включаю описание функций библиотеки SysLibSocket , которые
используются в примере.
Обратите внимание, что все это функции, а не функциональные блоки. Значит, их вызов
будет блокирующим. Другими словами, мы сможем получать результат функции в одной
итерации ПЛК, если в настройках SysSockSetOption мы не установили неблокирующий
режим.
Так же это просто стандартный интерфейс доступа к функциям сокетов операционной
системы. Это значит, что все эти функции реализованы по-разному на разных ПЛК,
следовательно, результат функции может разниться.
Например, согласно документации, функция SysSockConnect должна вернуть TRUE или
FALSE . Но на контроллерах ОВЕН ПЛК 110 эта функция всегда вернет FALSE , так как
реализация функции connect() операционной системы отличается от документации.
То есть, мы читаем эту документацию не как функция SysSockConnect вернет..., а как
рекомендовано для функции SysSockConnect вернуть.... Значит, перед работой с
сокетами нужно обязательно изучить особенности их реализации на ПЛК конкретного
производителя.
SysSockCreate
Функция возвращает тип DINT и вызывает функцию socket операционной системы.
Новый сокет будет создан и назначен в службу сервисов (Service Provider).
Функция возвращает дескриптор (ссылку) на новый сокет, который используется как
входной параметр для других функций, например, SysSockBind или SysSockConnect .
Стр. 288 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
В зависимости от OS, если сокет был некорректно создан или не был создан, специальная
константа назначается дескриптору: INVALID_SOCKET (или SOCKET_INVALID ). Для
CoDeSys:
SOCKET_INVALID: DINT := -1;
Входные переменные
1. diAddressFamily | DINT
Тип адреса. Вот список доступных семейств типов адресов.
SOCKET_AF_UNSPEC: INT := 0; (* Не определено *)
SOCKET_AF_LOCAL:
INT := 1; (* Локальные (pipes, portals) *)
SOCKET_AF_INET:
INT := 2; (* Интернет протоколы: UDP, TCP, etc. *)
SOCKET_AF_IMPLINK: INT := 3; (* Адреса arpanet imp *)
SOCKET_AF_PUP:
INT := 4; (* pup протоколы: e.g. BSP *)
SOCKET_AF_CHAOS:
INT := 5; (* mit CHAOS протокол *)
SOCKET_AF_NS:
INT := 6; (* XEROX NS протокол *)
SOCKET_AF_ISO:
INT := 7; (* ISO протокол *)
SOCKET_AF_ECMA:
INT := 8; (* european computer manufacturers *)
SOCKET_AF_DATAKIT: INT := 9; (* datakit протоколы *)
SOCKET_AF_CCITT:
INT := 10; (* CCITT протоколы, X.25 и т.д *)
SOCKET_AF_SNA:
INT := 11; (* IBM SNA *)
SOCKET_AF_DECnet: INT := 12; (* DECnet *)
2. diType | DINT
Тип сокета определяет желаемую форму коммуникации. Для пользователей ПЛК
доступно 2 типа.
◦ SOCK_STREAM : потоковый сокет постоянного соединения для двухстороннего
подключения. Надежный и последовательный, без дублирований.
Потоковый (Connection-oriented) - подразумевает, что когда постоянное
соединение установлено, то между двумя программами будет установлен
диалог.
▪ Серверное приложение - то, что предоставляет сервис, создает сокет,
которому дается разрешение принимать входящие запросы.
▪ Клиентская программа - создает сокет-клиент и делает запросы к
приложению сервера. Клиент делает это, подключаясь к адресу (IP и
порт), который был определен сервером.
▪ Сервер принимает запрос на подключение и если подтверждает, что
соединение может быть установлено, принимает запрос и устанавливает
Стр. 289 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
подключение, которое остается активным, пока обе стороны в этом
нуждаются.
Ориентирован на TCP c гарантированной доставкой.
◦ SOCK_DGRAM : для коммуникаций без установления постоянного соединения.
Двухсторонний поток, который не гарантирует надежность,
последовательность или отсутствие дублирования.
Датаграммный (Connectionless) - подразумевает, что нет никакого диалога,
просто на сервере создается точка входа, и клиент может отправлять на нее
запросы. Нет активного обмена данными в реальном времени.
Если постоянное подключение (потоковое) можно сравнить с телефоном - вы
набрали номер и активно обмениваетесь информацией, оставаясь на связи, то
непостоянное соединение (датаграммное) - как почтовый ящик - вы опустили
письмо и не знаете, когда оно дойдет, и дойдет ли вообще. Вам нужно ждать
ответного письма.
Ориентирован на UDP подключения.
В обоих типах сокетов сервер ждет запроса от клиента. Чтобы получать запросы,
нужно, как минимум, создать сокет и привязать его к адресу.
В нормальных обстоятельствах предполагается, что коммуникация будет
осуществляться между сокетами одного типа.
3. diProtocol | DINT
Протокол для сетевых коммуникаций в зависимости от выбранного типа адреса.
Примеры протоколов, поддерживаемыми CoDeSys sockets:
SOCKET_IPPROTO_IP:
DINT := 0;
(* Пустой для IP-протокола *)
SOCKET_IPPROTO_ICMP: DINT := 1;
(* control message протокол *)
SOCKET_IPPROTO_IGMP: DINT := 2;
(* group management протокол *)
SOCKET_IPPROTO_TCP: DINT := 6;
(* TCP протокол *)
SOCKET_IPPROTO_PUP: DINT := 12; (* pup *)
SOCKET_IPPROTO_UDP: DINT := 17; (* UDP : user datagram протокол *)
SOCKET_IPPROTO_RAW: DINT := 255; (* просто IP-пакеты *)
Пример создания сокета
VAR
diSocket:
END_VAR
Стр. 290 из 298
DINT; (* Дескриптор сокета *)
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
(* Подключаемся к UDP сокету *)
diSocket := SysSockCreate(SOCKET_AF_INET, SOCKET_DGRAM, SOCKET_IPPROTO_UDP);
IF diSocket <> SOCKET_INVALID THEN
(* Сокет создан успешно *)
ELSE
(* Ошибка создания сокета *)
END_IF;
SysSockConnect
Функция возвращает тип BOOL и вызывает функцию connect операционной системы.
В случае, если на момент вызова этой функции сокет еще не был привязан функцией
SysSockBind , то локальный адрес будет автоматически назначен. После этого сокет будет
готов отправлять и принимать данные.
В случае успешного подключения функция вернет TRUE , в противном случае FALSE .
Входные переменные
1. diSocket | DINT
Дескриптор сокета, который вернула функция create() .
2. pSockAddr | DWORD
Указатель на переменную типа SOCKADDR
3. piSocketAddrSize | DWORD
Указатель на переменную типа DINT . Эта переменная передает размер переменной в
параметре pSockAddr и может быть извлечена при помощи оператора SIZEOF
Структура SOCKADDR :
TYPE
SOCKADDR: STRUCT
sin_family: INT;
sin_port:
sin_addr:
sin_zero:
END_STRUCT
END_TYPE
Стр. 291 из 298
(* Формат адреса (см. SysSockCreate
парам. diAddressFamily) *)
UINT; (* Порт подключения *)
UDINT; (* IP-адрес *)
ARRAY [0..7] OF SINT; (* Буфер *)
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
TCP и UDP используют IP для задания адресов подключения, для этого, как правило,
используется протокол IPv4. Для него IP записывается в виде 32-битной формы ( UDINT ),
представляемой в символьной форме nnn.nnn.nnn.nnn, разбитый на четыре поля,
разделённых точками, по одному байту в поле, значением от 0-255, например, 192.168.1.1.
Номер порта задается в диапазоне от 0 до 65535 ( UINT ).
Для того, чтобы назначить порт и адрес, нужно провести некоторые предварительные
манипуляции с переменными. Порт назначается через функцию SysSockNtohs , а IP через
SysSockNtohl . Эти функции просто принимают WORD для порта и DWORD для адреса,
меняя порядок бит в соответствии с требованием ТСР, как он реализован в данной
операционной системе.
IP-адрес нужно предварительно сконвертировать из строкового значения, как
192.168.1.200 в переменную типа UDINT . Обычно я конвертирую каждую часть IP адреса
в HEX и из него составляю нужную переменную.
Например, у нас адрес 10.0.100.1 . Вот таблица преобразований, которую я сделал в
калькуляторе в режиме "Программист".
Dec HEX
10
0A
0
00
100 64
1
01
Значит, можно использовать шестнадцатеричный формат для записи IP, вот так:
16#0A006401 .
Для конвертации можно воспользоваться и вот этой функцией:
FUNCTION IP_DECODE : DWORD
VAR_INPUT
str : STRING[15]; (*IP-адрес как строка, например, '192.168.1.2' *)
END_VAR
VAR
pos: INT;
END_VAR
pos := FIND(str, '.');
WHILE pos > 0 DO
IP_DECODE := SHL(IP_DECODE, 8) OR
STRING_TO_DWORD(LEFT(str, pos - 1));
str := DELETE(str, pos, 1);
pos := FIND(str, '.');
END_WHILE;
IP_DECODE := SHL(IP_DECODE, 8) OR STRING_TO_DWORD(str);
Стр. 292 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
SysSockAccept
Функция возвращает тип DINT. Вызывает функцию accept операционной системы, которая
разрешает принимать запросы на подключение к сокету. Возвращает новый дескриптор
(ссылку) сокета. Изначальный сокет сбрасывается в статус прослушивания.
Входные переменные
1. diSocket | DINT
Дескриптор сокета, который был отправлен в функцию listen() и, скорее всего,
тот, что был возвращен функцией create() . Автоматически подключение будет
произведено с сокетом, который был возвращен функцией listen() . Запрос на
соединение будет сделан на сокет, который вернет функция accept() .
2. pSockAddr | DWORD
Указатель на переменную типа SOCKADDR
3. piSocketAddrSize | DWORD
Указатель на переменную типа DINT . Эта переменная передает размер переменной в
параметре pSockAddr и может быть извлечена при помощи оператора SIZEOF
SysSockBind
Функция возвращает BOOL , вызывает функцию операционной системы bind. Эта функция
отделит адрес для сокета.
Обычно привязка делается перед вызовом таких функций, как SysSockListen или
SysSockAccept . В случае успешной операции функция вернет TRUE , в противном случае
FALSE .
Входные переменные
1. diSocket | DINT
Дескриптор сокета, который вернула функция create() .
2. pSockAddr | DWORD
Указатель на переменную типа SOCKADDR
3. piSocketAddrSize | DWORD
Стр. 293 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Указатель на переменную типа DINT . Эта переменная передает размер переменной в
параметре pSockAddr и может быть извлечена при помощи оператора SIZEOF
SysSockClose
Функция возвращает тип BOOL, вызывает функцию операционной системы closesocket,
чтобы закрыть сокет. В случае успешной операции функция вернет TRUE , в противном
случае FALSE .
Входные переменные
1. diSocket | DINT
Дескриптор сокета, который вернула функция create() .
SysSockListen
Функция возвращает тип BOOL, вызывает функцию операционной системы listen. Эта
функция позволит сокету слушать на установленном адресе запросы на подключения и
ставить их в очередь, пока они не будут приняты функцией SysSocketAccept. В случае
успешной операции функция вернет TRUE , в случае, если максимальное количество
запросов в очереди превысит установленное значение, вернет FALSE .
Входные переменные
1. diSocket | DINT
Дескриптор сокета, который вернула функция create() .
2. diMaxConnections | DINT
Максимальное количество запросов на подключение, которое можно передать в
очередь на подключение.
SysSockGetOption
Возвращает BOOL и вызывает функцию getsockopt операционной системы, для того,
чтобы получить определенное значение опции сокета. В случае успешной операции
функция вернет TRUE , в противном случае FALSE .
Эта функция сильно зависит от операционной системы. Имена опций или другие
параметры могут сильно варьироваться от ПЛК к ПЛК. Однако, приняты следующие
стандарты:
Стр. 294 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Входные переменные
1. diSocket | DINT
Дескриптор сокета
2. diLevel | DINT
Уровень, специфичный протоколу. Возможные значения SOL_SOCKET , SOCKET_ERROR ,
IPPROTO_TCP .
3. diOption | DINT
Имя запрашиваемой опции. Возможные значения:
◦ SO_SNDBUF - 0x1001 - send buffer size
◦ SO_RCVBUF - 0x1002 - receive buffer size
◦ SO_SNDTIMEO - 0x1005 - send timeout
◦ SO_RCVTIMEO - 0x1006 - receive timeout
◦ SO_ERROR - 0x1007 - get error status and clear
◦ SO_TYPE - 0x1008 - get socket type
◦ SO_MAXMSG - 0x1010 - get TCP_MSS (max segment size)
◦ SO_RXDATA - 0x1011 - get count of bytes in sb_rcv
◦ SO_TXDATA - 0x1012 - get count of bytes in sb_snd
◦ SO_MYADDR - 0x1013 - return my IP address
◦ SO_NBIO - 0x1014 - set socket into NON-blocking mode
◦ SO_BIO - 0x1015 - set socket into blocking mode
◦ SO_NONBLOCK - 0x1016 - set/get blocking mode via optval param
◦ SO_CALLBACK - 0x1017 - set/get zero_copy callback routine
4. diOptionValue | DWORD
Указатель на переменную, в которую будет записано значения запрашиваемой
опции.
5. diOptionLength | DWORD
Размер переменной, в которую будет записано значение запрашиваемой опции.
Один из наиболее частых вариантов использования этой функции - запрос последней
ошибки, как вы уже видели в примерах. Вот список возможных кодов ошибок,
оформленных как тип переменной перечисление:
Стр. 295 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
TYPE ERROR_CODES : (
SOCK_NO_ERR
:= 0,
(* Нет ошибки *)
SOCK_ERR_MISC
:= -1, (* Разное *)
SOCK_ERR_TIMEDOUT
:= -2, (* Ожидание истекло *)
SOCK_ERR_ISCONN
:= -3, (* Подключение *)
SOCK_ERR_OP_NOT_SUPP
:= -4, (* Опция не поддерживается *)
SOCK_ERR_CONN_ABORTED
:= -5, (* Обрыв подключения*)
SOCK_ERR_WOULD_BLOCK
:= -6, (* Будет блокировка *)
SOCK_ERR_CONN_REFUSED
:= -7, (* Отказ подключения *)
SOCK_ERR_CONN_RESET
:= -8, (* Сброс подключения *)
SOCK_ERR_NOT_CONN
:= -9, (* Нет подключения *)
SOCK_ERR_ALREADY
:= -10, (* *)
SOCK_ERR_IN_VAL
:= -11, (* *)
SOCK_ERR_MSG_SIZE
:= -12, (* Неверный размер сообщения *)
SOCK_ERR_PIPE
:= -13, (* *)
SOCK_ERR_DEST_ADDR_REQ := -14, (* *)
SOCK_ERR_SHUTDOWN
:= -15, (* Закрыто *)
SOCK_ERR_NO_PROTO_OPT
:= -16, (* *)
SOCK_ERR_NO_MEM
:= -18, (* *)
SOCK_ERR_ADDR_NOT_AVAIL := -19, (* *)
SOCK_ERR_ADDR_IN_USE
:= -20, (* *)
SOCK_ERR_IN_PROGRESS
SOCK_ERR_NO_BUF
SOCK_ERR_NOT_SOCK
SOCK_ERR_FAULT
SOCK_ERR_NET_UNREACH
SOCK_ERR_PARAM
SOCK_ERR_LOGIC
SOCK_ERR_NOMEM
SOCK_ERR_NOBUFFER
SOCK_ERR_RESOURCE
SOCK_ERR_BAD_STATE
SOCK_ERR_TIMEOUT
SOCK_ERR_NO_ROUTE
SOCK_ERR_TRIAL_LIMIT
:= -22, (* *)
:= -23, (* *)
:= -24, (* Сокета не найдено *)
:= -25, (* *)
:= -26, (* *)
:= -27, (* *)
:= -28, (* *)
:= -29, (* *)
:= -30, (* *)
:= -31, (* *)
:= -32, (* *)
:= -33, (* *)
:= -36, (* *)
:= -128 (* Время для пробы истекло *)
);
END_TYPE
Стр. 296 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Об авторе
Здравствуйте. Я Сергей Романов, инженер АСУ.
Свою профессиональную деятельность я начинал как Веб-программист. Около 15 лет
занимался программированием на РНР, работал на С#, JavaScript. Я быстро погружался в
любую среду программирования, всякий раз это было очень увлекательное погружение.
Да, мне всегда нравилось программировать!
Несколько лет возглавлял собственную компанию - мы создавали готовые коробочные
решения для различных сфер экономики.
Вёл и продолжаю вести активную преподавательскую деятельность. Из значимых событий
в этой сфере можно выделить:
• 2008 г. Преподавал Веб-программирование (PHP, MySQL) в Кыргызском
Национальном Университете.
• 2014 г. Преподавал Digital Forensics (цифровая криминалистика) в Маниле
(Филиппины) сотрудникам полиции Филиппин.
• 2016 г. Выступил с докладом на конференции JoomlaDay Moscow.
• 2017 г. Преподавал продвинутый курс программирования в CoDeSys (Structured Text)
в Кыргызском Национальном Политехническом Университете.
Более, чем 10 лет, публикую обучающие видео на своем Youtube-канале на такие темы, как
программирование, проектирование, сборка шкафов управления и так далее.
Приятного чтения!
Стр. 297 из 298
ISBN: 978-1-64199-106-3
Версия: 1.207.0 | Дата: 2021-1-5
Автор: Сергей Романов
Искренне Ваш,
Сергей Романов.
Емайл: [email protected]
VK: https://vk.com/serhioromano
Youtube: https://www.youtube.com/user/serhioromano
Стр. 298 из 298
ISBN: 978-1-64199-106-3