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

image

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

image

image

Итак, знакомьтесь. Это программируемый логический контроллер (ПЛК) под названием SCADAPack фирмы Schneider Electric (ранее Control Microsystems).

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


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

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

Разработка начинается с написания 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 
#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 
#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);


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


В документации 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 файловая система просматривается совершенно спокойно, что может помочь для отладки или сбора данных.

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


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

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


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

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

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

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

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

#include 
#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.
Очень хорошо обладать целостными знаниями Си (например, при реализации алгоритмов это касается приведения типов), чтобы писать код красиво и без ошибок.

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

Огромная благодарность Денису Хизбатову за помощь в подготовке материала и ценные замечания :)

© Habrahabr.ru