Микро-курс по программированию контроллеров SCADAPack на Си

  • Tutorial
image

На Хабре откровенно мало статей про АСУ ТП. Более того, подозреваю, что программирование в отрасли промышленной автоматизации для большинства хабровчан — некий магический темный лес со странными легендами и существами. И вот мне захотелось провести небольшую экскурсию по этому лесу в познавательных целях, но познавательными целями ограничиваться не будем, и постараемся, чтобы данный материал был полезен людям, только начинающим свой путь в АСУТП, либо впервые столкнувшимися с рассматриваемым типом контроллеров.

image

image

Итак, знакомьтесь. Это программируемый логический контроллер (ПЛК) под названием SCADAPack фирмы Schneider Electric (ранее Control Microsystems).
Программи́руемый логи́ческий контро́ллер (сокр. ПЛК; англ. programmable logic controller, сокр. PLC; более точный перевод на русский — контроллер с программируемой логикой), программируемый контроллер — промышленный контроллер, используемый для автоматизации технологических процессов. В качестве основного режима работы ПЛК выступает его длительное автономное использование, зачастую в неблагоприятных условиях окружающей среды, без серьёзного обслуживания и практически без вмешательства человека. [Википедия]

ПЛК эти заслужили славу своей надежностью и богатыми возможностями программирования. Внутри у контроллера в зависимости от серии стоит ARM-процессор на котором работает операционная система VxWorks.

В промышленной автоматизации общепризнанным стандартом являются языки МЭК, такие как LD/LAD, FDB и ST. Первый из них представляет собой ни что иное, как схемы, похожие на схемы релейной логики. Второй представляет собой ни что иное, как схемы, похожие на схемы с логическими элементами и электронными компонентами (таймеры, счетчики, и т.д.). Третий представляет собой текстовый язык, навевающий воспоминания о Паскале. Но сегодня мы поговорим не про них (желающие всегда могут погуглить), а про разработку под эти контроллеры на Си, что во-первых гораздо ближе «простым программистам», а во-вторых спасает при необходимости программирования сложных математических расчетов или реализации нестандартных коммуникационных протоколов.

Для компиляции нам понадобится, собствено, компилятор, заголовочные файлы и стандартная библиотека контроллера. Всё это можно найти на сайте производителя под названием C Tools, а описание API — там же.
Разработка начинается с написания Makefile'а (скрипта для сборки проекта из исходников в бинарный файл). Пример makefile'а можно найти в директории C Tools:
C:\Program Files\Control Microsystems\CTools\Controller\Framework Applications\TelePACE
Там же есть пример main.cpp и файла appstart.cpp (он тоже необходим для сборки).
Ctools.h лежит в C:\Program Files\Control Microsystems\CTools\Controller\TelePACE

Обратить внимание в нём стоит на 3 вещи:
objects = appstart.o main.o
данная строка задает, какие файлы необходимо компилировать для сборки прошивки. Если у вас проект разделен (а так и должно быть) на .c- или .cpp-файлы с заголовками (.h- или .hpp-файлы), то они должны быть перечислены в этой секции. Если вы вдруг забудете что-нибудь, компилятор напомнит об этом ошибкой Undefined reference.

CTOOLS_PATH = C:\Program Files\Control Microsystems\CTools
Это путь к Ctools. Скореектируйте, если он у вас отличается.

TARGET = SCADAPack350
эта строка определяет, под какое семейство контроллеров мы компилируем прошивку. Возможные варианты:
SCADAPack350 (сюда относятся 357, и т.д.), SCADAPack33x, 4203

Компиляция прошивки производится командой make из командной строки.
Если выводится сообщение, что не удалось найти эту команду, проверьте, что у вас в системной переменной PATH прописан путь к библиотекам C-Tools:
C:\Program Files\Control Microsystems\CTools\Arm7\host\x86-win32\bin

Простое приложение


Для компиляции простой (и пустой) прошивки, нам будет необходим Makefile, файлы appstart.cpp и main.cpp.

Их примеры можно найти в той же директории C Tools, что была упомянута выше. Appstart.cpp отвечает за инициализацию оборудования и среды выполнения, а в main.cpp мы уже можем писать нужный нам код.

Общая структура программы на Си для SP выглядит вот так:

#include <ctools.h>
#include "nvMemory.h"
int main(void)
{
  // здесь можно произвести какую-нибудь предварительную инициализацию, например, настройку портов, чтение конфигурации, и т.д.

  while (TRUE)
  {
      // здесь будет происходить наш основной цикл программы
      release_processor();
  }
}

Вызов release_processor() необходим в каждом цикле, потому что кроме нашей программы ОС контроллера выполняет также другие служебные процессы (обработчики портов и протоколов, среда исполнения, и т.д.). Без вызова этой функции, к примеру, после запуска программы будет невозможно остановить ее или перепрошить контроллер.

Стиль кодирования в C Tools, увы, оставляет желать лучшего: встречаются разные стили именования функций (process_io() и release_processor(), но ioReadDin16() и addRegAssignment(), а еще getclock()/setclock()), в некоторых схожих функциях с одинаковыми аргументами поменяны местами эти самые аргументы, короче говоря, будьте внимательны.

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

Как основу для правил можно взять отдельные пункты стандартов MISRA (стандарт разработки встраиваемого ПО для автомобилей) или JSF (для авиации).

Загрузка программы в контроллер


Для этого нам и понадобится TelePACE. К контроллеру можно подлючаться по RS232/485, по Ethernet и даже по USB (если он есть у используемой модели скадапака).

image

Принцип примерно один и тот же:

  1. Выбираем сверху в поле Protocol нужный нам протокол (Modbus RTU, Modbus TCP или Modbus USB)
  2. Нажимаем Configure Settings и задаем все нужные данные (RTU-адрес, скорость порта для RS232/485 или IP-адрес для TCP)
  3. Нажимаем Connect и убеждаемся, что соединились с контроллером.
  4. На вкладке Initialize можно сбросить контроллер к первозданному виду — удалить все программы, LAD-проекты, настройки портов и register assignments.
  5. На вкладке C/C++ можно посмотреть, кака программа загружена в контроллер, остановить/запустить ее, загрузить новую (исследуете кнопочки вверху вкладки!).

В Сети весьма кстати нашлось видео, демонстрирующее процесс:


Работа с таймерами


Начнем с самой простенькой программы – Hello World, а именно, помигаем светодиодом на контроллере :)

#include <ctools.h>
#include "nvMemory.h"
// объявим ID нашего события. Можно использовать любые числа от 10, кроме описанных в primitiv.h
#define TIMER1EVENT 10

int main(void)
{
  int led_state = 0;

  // создадим событие, которое будет вызываться 1 раз в 1 секунду (аргумент в 0.1 с)
  startTimedEvent(TIMER1EVENT,10);
  
  while (TRUE)
  { 
    // проверяем, наступило ли событие
    if (poll_event(TIMER1EVENT))
    {
      if (led_state == 0)
        ledPower(LED_ON);
      else
        ledPower(LED_OFF);
      
      led_state = led_state ^ 1;
    }

    release_processor();
  }
}

Работа с modbus-регистрами


Modbus — открытый коммуникационный протокол, основанный на архитектуре ведущий-ведомый (master-slave). Широко применяется в промышленности для организации связи между электронными устройствами. Может использоваться для передачи данных через последовательные линии связи RS-485, RS-422, RS-232, и сети TCP/IP (Modbus TCP). Также существуют нестандартные реализации, использующие UDP. Основные достоинства стандарта — открытость и массовость. Промышленностью сейчас выпускается очень много типов и моделей датчиков, исполнительных устройств, модулей обработки и нормализации сигналов и др. Практически все промышленные системы контроля и управления имеют программные драйверы для работы с MODBUS-сетями. [Википедия]

В SCADAPack работа с Modbus реализована красиво и удобно.

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

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

Обработчик modbus в скадапаках реализован на уровне операционной системы, и поэтому после запуска программы, по всем COM-портам (и по Ethernet) мы сразу можем опрашивать контроллер по модбасу (о настройке портов будет чуть позже). Более того, на modbus-запросы контроллер будет отвечать, даже если программа остановлена.

Запись и чтения modbus-регистров осуществляется функциями dbase() и setdbase(), например, вот так:

request_resource(IO_SYSTEM);
a = dbase(MODBUS, 30001);
b = dbase(MODBUS, 30002);
setdbase(MODBUS, 40020, a * b);
release_resource(IO_SYSTEM);

Данный пример читает два числа из регистров 0001 и 0002 зоны Inputs и сохраняет результат их умножения в регистр 0020 зоны Holding. Всё просто.

Работа с сигналами ввода-вывода


Работа модулями ввода и вывода может проходить тремя способами:

Первый способ


Первый вариант — создать «register assignment». Состояние (дискретные и аналоговые значения) входных каналов модулей DIN и AIN будет автоматически “отображаться” в modbus-регистры, и наоборот, для модулей DOUT и AOUT состояние выходов будет определяться значениями, записанными в регистрах. Для этого используются функции clearRegAssignmnet (очистка все старых назначений) и addRegAssignment (создание новых).

Первый аругмент функции addRegAssignment – тип модуля (список констант можно посмотреть в документации на TelePACE и в заголовочных файлах, могут быть DIN_5401, DIN_5404, AIN_5301 и другие), второй – адрес модуля (он обычно задается перемычкой на самом модуле, а если используются встроенные в SP входы, то равен 0), и далее идут адреса регистров, начиная с которых должна производиться запись (если модуль обеспечивается получение данных разных типов, то и групп регистров будет несколько – coils, status, inputs и т.д.)

// захватываем ресурс IO_SYSTEM
// это нужно делать всегда при работе с модулями ввода-вывода и modbus-регистрами  
request_resource(IO_SYSTEM);

// очистим старые assignments на всякий случай
clearRegAssignment();

// будем читать IO нижней платы типа 5601 в регистры начиная 10001 для дискретных и 30001 для аналоговых
// а состояние DOUT выставлять из регистров начиная с 1 (00001)
addRegAssignment(SCADAPack_lowerIO, 0, 1, 10001, 30001, 0);

// как можно догадаться, SCADAPack_upperIO - это будет верхняя плата (основной контроллерный модуль)

// есть вариант для платы 5604:
// всё то же самое, только еще управляем каналами AOUT, используя значения из регистров 40001 и дальше
// addRegAssignment(SCADAPack_5604IO, 0, 1, 10001, 30001, 40001 ); 

// пример с отдельным модулем AOUT (адрес 1):
// будем выставлять значение аналоговых выходных каналов, соответствующие регистрам modbus начиная с 40001
addRegAssignment(SCADAPack_AOUT, 1, 40001, 0, 0, 0); 

// не забываем освободить ресурс IO_SYSTEM 
release_resource(IO_SYSTEM);

Стоит иметь в виду, что если вы принудительно остановите среду выполнения (runTarget(FALSE)), то они работать не будут, но если вы не вносили изменений в стандартный файл appstart.c в целях оптимизации, то беспокоиться не о чем.

Не забывайте выполнять clearRegAssignment(); при запуске программы, даже если вы не пользуетесь ими – кто знает, кто и что делал на этом контроллере до вас.

Если вы не знаете точно, какая плата ввода-вывода будет стоять на контроллере, на котором будет запущена ваша прошивка, можно воспользовать решением, предложенным специалистами из ОЗНА.

Второй способ


Вызывать явно функции чтения, которые сохранят данные в заданные modbus-регистры.
Это могут быть функции ioRead8Din, ioRead8Ain, ioRead16Din, ioRead16Ain, ioRead5604Inputs, ioWrite16Dout, ioWrite5604Outputs, ioRead4Counter (для счетных входов), ioReadSP2 (для чтения встроенных входов, в зависимости от модели контроллера). Подробности и синтаксис этих команд можно прочитать в документации на C Tools, обычно первым аргументом следует адрес модуля (0 для встроенных входов), а вторыми и дальше – адреса modbus-регистров, начиная с которых нужно сохранить значения сигналов (или откуда их брать для записи в выходные каналы).

Пример:

// адрес модуля 5607 (0, т.к. это встроенная дочерняя плата)
int Module5607Addr = 0;
// захватим ресурс IO_SYSTEM
request_resource(IO_SYSTEM);
// запросим обновление данных с модулей
ioRequest(MT_5607Inputs, Module5607Addr);
ioRequest(MT_SP2Inputs, 0);
// ожидаем события успешного чтения
// при желании можно выполнять другие процедуры, периодически проверяя результат (вместо wait_event использовать poll_event)
ioNotification(IO_COMPLETE);
wait_event(IO_COMPLETE);
// сохраним данные с модуля 5607, дискретные сигналы – начиная с регистра 10001, аналоговые – начиная с регистра 30001
ioRead5607Inputs(Module5607Addr, 10001, 30001);
// сохраним данные со встроенных входов скадапака, дискретные сигналы – начиная с регистра 10101, аналоговые – начиная с регистра 30101
ioReadSP2Inputs(10101,30101);
// освобождаем ресурс IO_SYSTEM
release_resource(IO_SYSTEM);

Вызов функций ioWrite* аналогично произведет установку нужных значений из modbus-регистров в выходные каналы контроллера и модулей.

Третий способ


То же самое, но с сохранением результатов не в modbus-регистры, а в переменные или массив. Функции называются так же, но имеют перегруженную реализацию с другими аргументами.

int Module5607Addr = 0;
// захватываем ресурс IO_SYSTEM, это нужно делать обязательно перед всеми операциями ввода-вывода
request_resource(IO_SYSTEM);
// массив, куда будут положены значения после  чтения дискретных сигналов с  платы 5607
UCHAR DIData [3];
// массив, куда будут положены значения после чтения аналоговых сигналов с платы 5607
INT16 AIData [8];
// массив для чтения дискретных сигналов с встроенных каналов контроллера
UCHAR DIData2 [2];
// массив для чтения аналоговых сигналов с встроенных каналов контроллера
INT16 AIData2 [8];
// запрашиваем обновление данных
ioRequest(MT_5607Inputs, Module5607Addr);
ioRequest(MT_SP2Inputs, 0);
// ожидаем события успешного чтения
// при желании можно выполнять другие процедуры, периодически проверяя результат (вместо wait_event использовать poll_event)
ioNotification(IO_COMPLETE);
wait_event(IO_COMPLETE);
// сохраняем считанные данные куда нам надо
ioRead5607Inputs(Module5607Addr,DIData,AIData);
ioReadSP2Inputs(DIData2,AIData2);
release_resource(IO_SYSTEM);

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

UINT16 InputType[8];
for(int i=0;i < 8; i++)
    InputType[i] = 3;
UINT16 InputFilter = 3;
UINT16 ScanFrequency = 0;
UINT16 OutputType = 1;
UINT16 Mask2 = 0;
ioWrite5607Outputs(Module5607Addr,DOData,AOData,InputType,InputFilter,ScanFrequency,OutputType);
ioRequest(MT_5607Outputs, Module5607Addr);

Конкретные функции и константы для чтения и записи данных в используемые модули нужно смотреть в документации и в заголовочных файлах C Tools. К примеру, у функций для модуля 5607, как можно заметить выше, есть дополнительные опции для настройки типа входа, параметров фильтра, и т.д. Они также описаны в документации.

Есть еще функции получения текущей температуры контроллера и напряжения на батарее – readThermistor(T_CELSIUS), readBattery(). Мелочь, а полезно.

Масштабирование AI


Каналы аналогового ввода модулей контроллера могут работать в разных режимах (например, с входными диапазонами 0-20 или 4-20 мА, это определяется перемычками на модуле). Выдают данные они в единицах АЦП, и преобразовать их в нужную нам шкалу (например 4-20 мА будет соответствовать 0-100%) довольно просто:

// регистр, в котором у нас окажется значение входа AI, которое мы будем преобразовывать
#define AIWaterLevelReg 30001

float WaterLevelScaled = ((float)dbase(MODBUS, AIWaterLevelReg) - 6554) / 26214 * 100;  // для диапазона 4-20 мА
// или 
float WaterLevelScaled = (float)dbase(MODBUS, AIWaterLevelReg) / 32767 * 100;   // для диапазона 0-20 мА

Естественно, вместо 100 можно умножить число на нужный вам верхний предел шкалы.

Настройка портов RS-232/RS-485


Здесь, опять же, никакой магии:

PROTOCOL_SETTINGS comsettings;
pconfig portSettings;
// читаем текущие настройки порта COM2, чтобы их изменить
getProtocolSettings(com2, &comsettings);

// modbus-адрес, по которому контроллер будет отвечать на этом порту
comsettings.station = 1;

comsettings.type = MODBUS_RTU;
comsettings.mode = AM_standard;
get_port(com2,&portSettings);
// скорость обмена, 38400
portSettings.baud =  BAUD38400; 
portSettings.duplex = HALF;
portSettings.parity = PARITY_NONE;
portSettings.data_bits = DATA8;
portSettings.stop_bits = STOP1;
portSettings.flow_tx = TFC_NONE; 
portSettings.flow_rx = RFC_MODBUS_RTU; 
portSettings.type = RS232;
setProtocolSettings(com2, &comsettings);
set_port(com2,&portSettings); 

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

Опрос устройств по modbus


Если вам хочется не просто работать как slave-устройство, но и самим опрашивать другие контроллеры или датчики, есть функции вида master_message(), позволяющая произвести опрос внешних устройств по модбасу, и сохранить результаты к себе в регистры, откуда потом их можно считать и использовать в алгоритме (или же просто предоставить верхнему уровню). Примеры есть в документации, только учтите два нюанса: необходимо обязательно проверять результат выполнения функции командой get_protocol_status(), перед тем как отправлять повторный запрос или работать с полученными данными, и второй нюанс: необходимо либо вообще отключать обработчик modbus на используемом порту, либо следить, чтобы его адрес не совпадал с адресом опрашиваемого устройства (иначе можно получить неопределенное поведение или странные ошибки).

extern unsigned master_message(FILE * stream, unsigned function, unsigned slave_station, unsigned slave_address, unsigned master_address, unsigned length);

stream – порт, через который будет идти обмен данными (например, com1), function – номер функции modbus для запроса (например 3), slave_station – адрес RTU опрашиваемого устройства, slave_address – стартовый адрес регистров в удаленном устройстве, которые мы хотим прочитать, master_address – стартовый адрес регистров на нашем контроллере, куда будут записаны данные, length – количество регистров для чтения).

Пример:

request_resource(IO_SYSTEM);
//чтение по порту com2 3-ей modbus-функций (чтение Holding зоны) с устройства с адресом 1 начиная с регистра 0001 (40001) 17 регистров, записать в регистр 513 нашего контроллера
master_message(com2, 3, 1, 40001, 40513, 17); 

// в последующих циклах проверяем результат
struct prot_status polling_status;
polling_status = get_protocol_status(
com2);
if (polling_status.command == MM_SENT) 
{
     // запрос был отправлен, но ответ еще не получен. 
     // рекомендуется также запоминать время отправки запроса, и контроллировать таймаут, если ответ не был получен за определенное время
}
else
if (polling_status.command == MM_RECEIVED)
{
     // запрос отправлен, ответ получен и успешно расшифрован!
     // можно работать с полученными данными и отправлять следущий запрос
}
else
{
    // что-то пошло не так. смотрите код ошибки и выясняйте в чем дело
}
release_resource(IO_SYSTEM);


Также можно установить свой обработчик Modbus-пакетов (installModbusHandler()) чтобы реализовывать какие-либо расширения протокола.
Система ведет статистику обмена (принято/отправлено, количество ошибок, и т.д.), которую тоже можно считать из соответствующих структур.

Коммуникации


Можно также установить свой обработчик данных, пришедших по COM-портам (install_handle()) для реализации каких-то нестадартных протоколов.
С TCP/IP все тоже хорошо. Доступны стандартные функции работы с BSD Sockets: bind(), getsockopt(), и т.д. Настройки сети на контроллере можно программно считать и записать (ethernetGetIP(), ethernetSetIP()).

Ведение архивов


В документации SCADAPack для этих целей предлагается использовать DataLog из библиотеки C Tools, который, к сожалению, обладает большим количеством недостатков, главный из которых состоит в том, что он не обеспечивает прямой непоследовательный доступ к записям в архиве. Некоторые разработчики обходятся хранением архивов прямо в регистровой памяти, но, учитывая что количество Holding и даже Inputs (при желании их можно использовать тоже, да) в контроллере ограничено, это решение тоже не всегда подходит.

На самом же деле, учитывая что операционная система в контроллере вполне себе POSIX-совместимая, а сам контроллер несет у себя на борту вполне нормальную файловую систему (плюс есть возможность вставлять USB Flash-накопители), есть возможность хранить архивы в файлах на флешке на борту контроллера, о чем совершенно вскользь упомянули в документации.

Работать с файлами можно как и в любой Си-программе:

FILE *mdata;
char* file_mdata = "/d0/logs.dat";
mdata = fopen(file_mdata, "w");
fputs("test log string", mdata);
fclose(mdata);

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

image

В точку /d0/ монтируется файловая система, в /bd0/ — внешний USB-флеш накопитель. Опять же, нет никакой проблемы реализовать копирование архива из встроенной памяти на флешку при нажатии на кноку на контроллере, и много других вариантов.

В TelePACE файловая система просматривается совершенно спокойно, что может помочь для отладки или сбора данных.

Энергонезависимая память


Как я уже отмечал, в энергонезависимой памяти, прежде всего, хранятся данные Modbus-регистров, то есть значения, сохраненные в них, будуь доступны и после перезагрузки контроллера.
Кроме того, существует отдельная структура s_nvMemory, объявленная в файле nvMemory.h, с максимальным размером 8 килобайт, которую вы можете отредактировать как вам удобно (создав нужные поля) и хранить там данные, которые должны остаться целостными в том числе после пропадания питания и перезагрузки контроллера. Если этого недостаточно, то дополнительно методом allocateMemory() можно выделять блоки энергонезависимой памяти общим размером до 1 мегабайта и хранить в них архивы, логи, и т.д. Самое главное в этом случае — после выделения блока памяти не забыть сохранить указатель на нее в вышеупомянутую структуру s_nvMemory, иначе выделенная память будет потеряна, и вы не сможете ни использовать ее, ни освободить (freeMemory()). А еще при доступе к энергонезависимой памяти нужно не забывать захватывать ресурс DYNAMIC_MEMORY.

Многозадачность и многопроцессность


Можно как одновременно запускать несколько разных программ, так и внутри одной программы запускать одновременно несколько разных методов (функции createTask(), getTaskInfo() и т.д.), задавать им приоритеты и даже слать сигналы между задачами и сообщения между процессами (так называемые 'envelopes').
Многозадачночность кооперативная, то есть переключение между задачами и программами выполняется не принудительно операционной системой, а поочередно, когда они сами готовы передать исполнение дальше или при возникновении определенных событий. С одной стороны, это решает проблему с атомарностью операций при совместном доступе к данным, а с другой стороны, нужно не забывать вызывать release_processor() и не захватывать ресурсы на слишком долгое время. Пример многозадачной программы и граф изменения состояния при возникновении событий описаны в документации в главах «RTOS Example Application Program» и «Explanation of Task Execution».

Динамическая память


malloc() и free() вполне работают, как и new и delete. Единственный довольно важный нюанс — при остановке программы (например, нажатием кнопки Stop), выделенная память системе не возврращается: необходимо осознанно установить обработчик завершения работы программы (installExitHandler) и освободить там все запрошенные блоки из кучи.

Работа с часами реального времени


У контроллера есть также на борту часы реального времени.
Получать текущее время и устанавливать его можно функциями getclock и setclock, в документации есть подробные примеры.

Пример простого алгоритма


Допустим, у нас есть контроллер с платой ввода-вывода 5604, к каналу AI 2 которой подключен аналоговый датчик уровня в емкости, к каналу DO1 подключено реле пускателя насоса.

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

В регистр 0020 зоны Holding (40020) будем записывать приведенное значение уровня для отображения на SCADA или HMI-панели.

Также нужно предусмотреть небольшой гистерезис для защиты от «щелканья» реле насоса в случае, когда уровень колеблется около нужной отметки.

Опрос контроллера будет идти через порт COM2.

#include <ctools.h>
#include "nvMemory.h"

/**
* @brief Инициализируем сканирование входных каналов платы 5604 в адресное пространство modbus-регистров
*/
void initialize_io()
{
    request_resource(IO_SYSTEM);
    clearRegAssignment();
    addRegAssignment(SCADAPack_5604IO, 0, 1, 10001, 30001, 40001);
    release_resource(IO_SYSTEM);
}

/**
* @brief Инициализируем порт COM2 для работы как Modbus Slave, адрес = 1, скорость = 9600
*/
void initialize_ports()
{
    request_resource(IO_SYSTEM);
    PROTOCOL_SETTINGS comsettings;
    pconfig portSettings;

    getProtocolSettings(com2, &comsettings);
    comsettings.station = 1;
    comsettings.type = MODBUS_RTU;
    comsettings.mode = AM_standard;

    get_port(com2,&portSettings);
    portSettings.baud =  BAUD9600; 
    portSettings.duplex = HALF;
    portSettings.parity = PARITY_NONE;
    portSettings.data_bits = DATA8;
    portSettings.stop_bits = STOP1;
    portSettings.flow_tx = TFC_NONE; 
    portSettings.flow_rx = RFC_MODBUS_RTU; 
    portSettings.type = RS232;

    setProtocolSettings(com2, &comsettings);
    set_port(com2,&portSettings); 
    request_resource(IO_SYSTEM);
}

/**
* @brief Функция масштабирования AI-сигнала
* @param ai_value "Сырое" значение AI в единицах АЦП
* @param max Максимальное приведенное значение AI (верхний предел шкалы датчика)
* @return Приведенное значение в измеряемых единицах
*/
float scale_ain(int ai_value, float max)
{
    return ((float)ai_value - 6554) / 26214 * max;
}

/* Константы. Их желательно вынести в отдельный файл и подключать через #include */
#define AILevelReg              30002  // регистр, в который будет считан сигнал AI с датчика уровня (2-ой канал AI)
#define LevelScaledReg          40020  // регистр, в который мы запишем приведенное значение уровня жидкости в сантиметрах
#define PumpLevelTriggerReg     40050  // регистр, в который со SCADA или панели ЧМИ должен быть записан требуемый уровень жидкости (в сантиметрах)
#define PumpOutReg              1      // регистр выхода DO, к которому будет подключено реле пускателя насоса (1-ый канал DO) 
#define LevelHyst               50     // зона нечувствительности уровня в сантиметрах. допустимое отклонение уровня, чтобы избежать дребезга пускателя
#define MaxLevelMeters          500    // максимальный уровень жидкости в емкости (верхний предел датчика уровня), в сантиметрах

int main(void)
{
    // инициализируем порт COM
    initialize_ports();
    // настраиваем обновление дискретных и аналоговых входов-выходов
    initialize_io();

    while (TRUE)
    {   
        request_resource(IO_SYSTEM);

        // читаем значение AI уровня жидкости, преобразуем его к единицам измерения и записываем в регистр
        int level_raw = dbase(MODBUS, AILevelReg);
        float level_scaled = scale_ain(level_raw, MaxLevelMeters);
        setdbase(MODBUS, LevelScaledReg, (int)level_scaled);

        // если уровень выше нормы, включаем насос
        if (level_scaled > (dbase(MODBUS, PumpLevelTriggerReg) + LevelHyst))
            setdbase(MODBUS, PumpOutReg, 1);
        else
        // а если ниже нормы - выключаем
        if (level_scaled < (dbase(MODBUS, PumpLevelTriggerReg) - LevelHyst))
            setdbase(MODBUS, PumpOutReg, 0);

        release_resource(IO_SYSTEM);

        release_processor();
    }
}

Вот, собственно, и всё


Описание всех функций и примеры их использования есть в документации на C Tools.
Очень хорошо обладать целостными знаниями Си (например, при реализации алгоритмов это касается приведения типов), чтобы писать код красиво и без ошибок.

Изучайте, экспериментируйте, и всё получится!

Огромная благодарность Денису за помощь в подготовке материала и ценные замечания :)
  • +11
  • 7,1k
  • 6
Поделиться публикацией

Комментарии 6

    0
    У ПЛК жесткая подложка? На DIN-рейку встанет?
      +1
      Да, он изначально сделан для монтажа на DIN-рейку:
      image

      image
      0
      Очень полезная статья, спасибо!
      Правда не сталкивался с такими задачами АСУ, где нужна реализация на Си.
      Вы могли бы привести примеры, когда это необходимо, или когда это нельзя реализовать на ST например?
        0
        Тут не сколько идет вопрос «нужна реализация» или «нельзя реализовать на других языках», тут скорее идет вопрос «на чем будет удобно».
        Для тех же SCADAPack с прошивкой TelePACE выбор есть только из двух вариантов: LAD и С, а на LAD'е писать парсер пакетов от какой-нибудь диковинной железки или алгоритм расчета обводненности нефтегазовой смеси радости мало. При том что в нефтянке эти контроллеры используются очень широко и много где.
        Из реальных примеров — исходный код "ОЗНА-Массомер" написан практически целиком на Си :)
        Плюс у меня как-то давно была вполне конкретная задача переноса определенного функционала с мелкосерийного локально производимого устройства (на базе AVR-микроконтроллера) на ПЛК, т.к. заказчик не хотел использовать железки от мелких производителей — и вполне логично оказалось, что проще перенести уже существующий код на Си.
          0
          Все понял теперь на счет LAD'а :)
        0
        Ценник у этих скадапаков конский для столь простенького железа. Но сделан добротно. На северах на кустах в качестве контроллера ТМ пашут давно и без особых проблем

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое