Everything posted by IThub
-
CAN Bus: протокол, который держит автомобили и заводы
Почему CAN Bus? История одного протокола1983 год. Инженеры Bosch смотрят на жгут проводки в Mercedes-Benz W126 и понимают, что так продолжаться не может. 1000+ метров провода, сотни коннекторов — система ненадёжна, дорога и тяжела. Им нужна шина данных, по которой все блоки управления могут общаться. В 1986 году появляется CAN (Controller Area Network). В 1991 году Mercedes внедряет CAN в S-класс. Сегодня нет ни одного автомобиля без CAN. И не только автомобиля: промышленные роботы, строительная техника, медицинское оборудование, поезда, самолёты. Секреты успеха: Надёжность — дифференциальный сигнал, устойчив к помехам Детерминизм — приоритетная схема без коллизий Простота — только 2 провода (CANH и CANL) Скорость — до 1 Мбит/с (Classic CAN), до 8 Мбит/с (CAN FD) Физический уровень: как это работаетCAN использует дифференциальную пару: два провода CANH и CANL. Информация кодируется разностью напряжений, а не абсолютным значением. Рецессивный бит (логическая 1): CANH ≈ 2.5В, CANL ≈ 2.5В → разность ≈ 0В Доминантный бит (логический 0): CANH ≈ 3.5В, CANL ≈ 1.5В → разность ≈ 2В Дифференциальный сигнал нечувствителен к синфазным помехам — если на оба провода наводится шум, разность остаётся неизменной. Именно поэтому CAN работает в двигательном отсеке автомобиля рядом с высоковольтной проводкой зажигания. ТопологияСтрого линейная шина с терминаторами 120 Ом на концах: [Узел A]──────[Узел B]──────[Узел C]──────[Узел D] 120Ом 120Ом Максимальная длина зависит от скорости: Скорость Макс. длина 1 Мбит/с 25 м 500 Кбит/с 100 м 250 Кбит/с 250 м 125 Кбит/с 500 м 10 Кбит/с 5000 м Структура CAN-фрейма (Standard 11-bit)SOF│ Identifier (11 бит) │RTR│IDE│r0│ DLC (4) │ Data (0-8 байт) │ CRC │ACK│ EOF 1 11 1 1 1 4 0-64 15 2 7 SOF (Start of Frame): 1 доминантный бит — все узлы синхронизируются. Identifier (ID, 11 бит): Идентифицирует тип сообщения, а не адрес отправителя/получателя. Одновременно определяет приоритет — чем меньше ID, тем выше приоритет. ID=0 — наивысший приоритет. RTR (Remote Transmission Request): Запрос данных от другого узла (редко используется). DLC (Data Length Code): Количество байт данных, 0–8. Data: Полезные данные, 0–8 байт. CRC (15 бит): Контрольная сумма для обнаружения ошибок. ACK: Все узлы, успешно принявшие фрейм, устанавливают доминантный бит в поле ACK. Отправитель проверяет — если ACK не получен, повторяет передачу. Extended Frame (29-bit ID)Для приложений где 2048 идентификаторов мало (J1939, CANopen): SOF│ ID_A (11) │SRR│IDE=1│ ID_B (18 бит) │RTR│... 29-битный ID даёт 536 870 912 возможных идентификаторов. Битовый арбитраж: без коллизийСамая элегантная часть CAN. Когда два узла начинают передачу одновременно — нет коллизии, как в Ethernet. Побеждает тот, у кого ID меньше (приоритетнее). Механизм: каждый передающий узел одновременно читает шину. Пока он видит то, что передаёт — продолжает. Как только видит расхождение (отправил рецессивный 1, а на шине доминантный 0 — значит другой узел передаёт доминантный бит с более высоким приоритетом) — немедленно прекращает передачу и переходит в режим приёма. Узел A: 0 0 0 1 0 ... (ID = 0b00010...) Узел B: 0 0 0 0 1 ... (ID = 0b00001...) Бит 4: Узел A передаёт рецессивный (1) Узел B передаёт доминантный (0) Шина показывает доминантный (0) → Узел A видит расхождение и ОСТАНАВЛИВАЕТСЯ → Узел B продолжает передачу Узел B выиграл арбитраж! Нет потерянных данных, нет задержек. Обработка ошибок и состояния узлаCAN имеет развитую систему самодиагностики. Каждый узел ведёт два счётчика: TEC (Transmit Error Counter) REC (Receive Error Counter) Состояния узла: Error Active (TEC<128, REC<128) — Нормальная работа ↓ TEC или REC ≥ 128 Error Passive (TEC≥128 или REC≥128) — Узел работает, но: - Не посылает Active Error Flags - Ждёт 8 рецессивных бит между передачами ↓ TEC ≥ 256 Bus Off — Узел ОТКЛЮЧЁН от шины (требует программного сброса или 128×11 рецессивных бит) Это важно: неисправный узел не "разваливает" шину, а сначала становится пассивным, затем отключается — остальные продолжают работать. STM32: встроенный CAN-контроллерSTM32F103 имеет встроенный bxCAN (Basic Extended CAN). Пины: PA11/PA12 или PB8/PB9. #include "stm32f1xx_hal.h" CAN_HandleTypeDef hcan; // ===== ИНИЦИАЛИЗАЦИЯ CAN 500 Кбит/с ===== void CAN_Init_500kbps(void) { hcan.Instance = CAN1; // Тайминг для 500 Кбит/с при тактовой 36 МГц // Bit time = Prescaler × (1 + BS1 + BS2) // 36 МГц / 4 / (1+7+2) = 900 Кбит/с ... нет, подберём: // 36 МГц / 9 / (1+3+2) = 500 Кбит/с ← правильно hcan.Init.Prescaler = 9; hcan.Init.Mode = CAN_MODE_NORMAL; // CAN_MODE_LOOPBACK для теста! hcan.Init.SyncJumpWidth = CAN_SJW_1TQ; hcan.Init.TimeSeg1 = CAN_BS1_3TQ; // BS1 = 3 TQ hcan.Init.TimeSeg2 = CAN_BS2_2TQ; // BS2 = 2 TQ hcan.Init.TimeTriggeredMode = DISABLE; hcan.Init.AutoBusOff = ENABLE; // Автоматический выход из Bus-Off hcan.Init.AutoWakeUp = DISABLE; hcan.Init.AutoRetransmission = ENABLE; // Автоповтор при ошибках hcan.Init.ReceiveFifoLocked = DISABLE; hcan.Init.TransmitFifoPriority= DISABLE; HAL_CAN_Init(&hcan); // ===== ФИЛЬТР ПРИЁМА ===== CAN_FilterTypeDef filter = {0}; // Принимать ВСЕ сообщения (маска 0 — все биты любые) filter.FilterBank = 0; filter.FilterMode = CAN_FILTERMODE_IDMASK; filter.FilterScale = CAN_FILTERSCALE_32BIT; filter.FilterIdHigh = 0x0000; filter.FilterIdLow = 0x0000; filter.FilterMaskIdHigh = 0x0000; // Маска 0 = принимать всё filter.FilterMaskIdLow = 0x0000; filter.FilterFIFOAssignment = CAN_RX_FIFO0; filter.FilterActivation = ENABLE; HAL_CAN_ConfigFilter(&hcan, &filter); // Активировать прерывания приёма HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING); HAL_CAN_Start(&hcan); } // ===== ОТПРАВКА СООБЩЕНИЯ ===== HAL_StatusTypeDef CAN_SendMessage(uint32_t id, uint8_t *data, uint8_t len) { CAN_TxHeaderTypeDef txHeader; uint32_t txMailbox; txHeader.StdId = id; // 11-битный ID txHeader.ExtId = 0; txHeader.IDE = CAN_ID_STD; txHeader.RTR = CAN_RTR_DATA; txHeader.DLC = len; txHeader.TransmitGlobalTime = DISABLE; return HAL_CAN_AddTxMessage(&hcan, &txHeader, data, &txMailbox); } // ===== ПРИЁМ ЧЕРЕЗ ПРЕРЫВАНИЕ ===== void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan_ptr) { CAN_RxHeaderTypeDef rxHeader; uint8_t rxData[8]; if (HAL_CAN_GetRxMessage(hcan_ptr, CAN_RX_FIFO0, &rxHeader, rxData) == HAL_OK) { uint32_t id = (rxHeader.IDE == CAN_ID_STD) ? rxHeader.StdId : rxHeader.ExtId; // Обработка по ID switch (id) { case 0x100: // Состояние узла 1 process_node1_status(rxData, rxHeader.DLC); break; case 0x200: // Измерения температуры process_temperature_data(rxData, rxHeader.DLC); break; default: // Неизвестное сообщение break; } } } // Пример обработки температурных данных // Договорённость: 2 байта = температура × 10 (signed) void process_temperature_data(uint8_t *data, uint8_t len) { if (len < 2) return; int16_t raw = (int16_t)((data[0] << 8) | data[1]); float temperature = raw / 10.0f; if (temperature > 80.0f) { // Высокая температура — принять меры activate_cooling(); } } // ===== ПРИМЕР: ПЕРИОДИЧЕСКАЯ ОТПРАВКА ДАННЫХ УЗЛА ===== void CAN_SendNodeStatus(void) { uint8_t data[8]; // Байт 0: статусные биты data[0] = 0x00; if (motor_running) data[0] |= 0x01; if (fault_active) data[0] |= 0x02; if (io_ready) data[0] |= 0x04; // Байты 1-2: скорость двигателя (об/мин × 10, unsigned) uint16_t speed_raw = (uint16_t)(motor_speed_rpm * 10.0f); data[1] = speed_raw >> 8; data[2] = speed_raw & 0xFF; // Байты 3-4: ток (А × 100, signed) int16_t current_raw = (int16_t)(motor_current_a * 100.0f); data[3] = current_raw >> 8; data[4] = current_raw & 0xFF; // Байты 5-6: температура (°C × 10, signed) int16_t temp_raw = (int16_t)(temperature_c * 10.0f); data[5] = temp_raw >> 8; data[6] = temp_raw & 0xFF; // Байт 7: номер пакета (для обнаружения потерь) static uint8_t packet_num = 0; data[7] = packet_num++; CAN_SendMessage(0x100, data, 8); } Arduino + MCP2515: добавляем CANArduino не имеет встроенного CAN. Используем MCP2515 — внешний CAN-контроллер с SPI. #include <SPI.h> #include <mcp2515.h> // Библиотека arduino-mcp2515 MCP2515 mcp2515(10); // CS pin struct can_frame rxMsg, txMsg; void setup() { Serial.begin(115200); SPI.begin(); mcp2515.reset(); mcp2515.setBitrate(CAN_500KBPS, MCP_8MHZ); // 500 Кбит/с, кварц 8 МГц mcp2515.setNormalMode(); // или setLoopbackMode() для теста без шины Serial.println("CAN Bus Ready"); } // Отправка измерений температуры void sendTemperature(float temp_celsius) { txMsg.can_id = 0x200; // ID нашего сообщения txMsg.can_dlc = 2; // 2 байта данных // Упаковываем: температура × 10, int16 int16_t raw = (int16_t)(temp_celsius * 10.0f); txMsg.data[0] = raw >> 8; txMsg.data[1] = raw & 0xFF; mcp2515.sendMessage(&txMsg); } void loop() { // Приём сообщений if (mcp2515.readMessage(&rxMsg) == MCP2515::ERROR_OK) { Serial.print("ID: 0x"); Serial.print(rxMsg.can_id, HEX); Serial.print(", DLC: "); Serial.print(rxMsg.can_dlc); Serial.print(", Data: "); for (int i = 0; i < rxMsg.can_dlc; i++) { Serial.print("0x"); Serial.print(rxMsg.data[i], HEX); Serial.print(" "); } Serial.println(); // Обработка сообщения статуса узла if (rxMsg.can_id == 0x100 && rxMsg.can_dlc >= 3) { bool running = rxMsg.data[0] & 0x01; bool fault = rxMsg.data[0] & 0x02; uint16_t speed_raw = (rxMsg.data[1] << 8) | rxMsg.data[2]; float speed = speed_raw / 10.0f; Serial.print("Узел 1: "); Serial.print(running ? "Работает" : "Стоит"); if (fault) Serial.print(" [АВАРИЯ]"); Serial.print(", Скорость: "); Serial.print(speed); Serial.println(" об/мин"); } } // Отправка своих данных каждые 100 мс static uint32_t lastSend = 0; if (millis() - lastSend >= 100) { lastSend = millis(); float temp = 25.0f + analogRead(A0) * 0.05f; sendTemperature(temp); } } Linux SocketCAN: мощный инструментарийНа Linux (Raspberry Pi, промышленные PC) CAN работает через SocketCAN — часть ядра с 2009 года. # Настройка CAN интерфейса # Если есть USB-CAN адаптер (Peak PCAN, Kvaser, SeedStudio) sudo ip link set can0 up type can bitrate 500000 # Или через модуль ядра для MCP2515 на Raspberry Pi # /boot/config.txt: # dtoverlay=mcp2515-can0,oscillator=8000000,interrupt=25 # Просмотр трафика candump can0 # Отправка сообщения: ID=0x100, 3 байта cansend can0 100#010203 # Фильтрация - только ID 0x100-0x1FF candump can0 100~1FF # Статистика ошибок ip -details -statistics link show can0 # Python + python-can # pip install python-can import can import struct import time # Создаём интерфейс bus = can.interface.Bus(channel='can0', bustype='socketcan') # Отправка def send_temperature(temp: float): raw = struct.pack('>h', int(temp * 10)) # big-endian signed short msg = can.Message(arbitration_id=0x200, data=raw, is_extended_id=False) bus.send(msg) # Приём с фильтрацией по ID bus.set_filters([ {"can_id": 0x100, "can_mask": 0x7FF, "extended": False}, # Только 0x100 {"can_id": 0x200, "can_mask": 0x7FF, "extended": False}, # И 0x200 ]) print("Ожидаем CAN-сообщения...") for msg in bus: if msg.arbitration_id == 0x100: status = msg.data[0] speed = struct.unpack('>H', bytes(msg.data[1:3]))[0] / 10.0 current = struct.unpack('>h', bytes(msg.data[3:5]))[0] / 100.0 temp = struct.unpack('>h', bytes(msg.data[5:7]))[0] / 10.0 print(f"Узел 100: {'Работает' if status & 1 else 'Стоит'}, " f"n={speed} об/мин, I={current}А, T={temp}°C") Протоколы высшего уровняJ1939 — для грузовой техники и дизелейJ1939 — стандарт SAE для коммуникации в коммерческом транспорте и тяжёлой технике. Работает поверх CAN с 29-битными ID. Структура J1939 ID (29 бит): Приоритет (3б) │ Reserved (1б) │ Data Page (1б) │ PGN (8б) │ Source Address (8б) Популярные PGN (Parameter Group Number): PGN 0xF004 (EEC1) — данные двигателя: обороты, момент, нагрузка PGN 0xFEF1 (CCVS) — скорость, круиз-контроль PGN 0xFEE5 (HOURS) — моточасы PGN 0xFEE6 (TIME) — время и дата CANopen — для промышленной автоматизацииCANopen — стандарт для промышленного оборудования: частотники, серводрайвы, I/O модули. Определяет: Словарь объектов (Object Dictionary) — структурированное хранилище всех параметров устройства PDO (Process Data Object) — быстрая передача данных реального времени (заменяет аналог 4-20мА) SDO (Service Data Object) — медленная конфигурация и чтение параметров NMT (Network Management) — управление состоянием узлов Heartbeat / Node Guarding — контроль жизнеспособности узлов Пример: частотник с CANopen. Через SDO читаем/пишем параметры (коэффициент разгона, макс. частота). Через PDO каждые 10 мс обмениваемся уставкой и текущими значениями. Диагностика сети CANПризнаки проблем:Симптом Вероятная причина Bus Off у одного узла Неисправный узел, помехи на кабеле Много ошибок CRC Неправильный биттайминг, плохая линия Узел не видит свои сообщения в ACK Он один на шине, некому подтверждать Периодические потери сообщений Нет терминаторов или два на одном конце Все узлы в Bus Off Неисправная нагрузка на шине Инструменты:PEAK PCAN-USB (~€80) + PCAN-View (бесплатно) — лучший бюджетный вариант Kvaser Leaf Light — популярен с CANalyzer Vector CANalyzer — профессиональный инструмент для автомобильных применений Linux candump / cansniffer — бесплатно, для socketcan-интерфейсов Wireshark с плагином SocketCAN — анализ на Linux CAN FD: следующее поколениеCAN FD (Flexible Data-rate) — развитие стандарта 2012 года. Нет обратной совместимости на физическом уровне, но концептуально тот же подход. Отличия от Classic CAN: До 64 байт данных в одном фрейме (против 8) До 8 Мбит/с в поле данных (при сохранении 1 Мбит/с для арбитража) Обязательная CRC 21-бит для надёжности при высоких скоростях CAN FD активно внедряется в новых автомобилях (все автомобили с AUTOSAR) и промышленной автоматизации. STM32G4, STM32H7 имеют встроенный FDCAN-контроллер. Практический чеклист для CAN-системы□ Терминаторы 120 Ом на ОБОИХ концах шины (и только там) □ Экранированная витая пара (для помехонагруженных сред) □ Максимальная длина ответвлений (stub) < 0.3 м □ Все узлы имеют ОДИНАКОВУЮ скорость и биттайминг □ Адреса узлов (если используются) уникальны □ Заземление: один общий провод + заземление экрана в одной точке □ Проверить напряжение CANH/CANL (рецесс.: оба ~2.5В, домин.: разность ~2В) □ Подключить анализатор и убедиться в отсутствии ошибок □ Задокументировать все ID и значения данных ЗаключениеCAN Bus — это элегантное инженерное решение, выдержавшее испытание десятилетиями. Детерминизм без коллизий, встроенная обработка ошибок, устойчивость к помехам — всё это делает CAN первым выбором для распределённых систем управления с жёсткими требованиями к надёжности. Для старта: MCP2515 + Arduino даёт минимальный стенд за $5. SocketCAN на Raspberry Pi — бесплатный анализатор. PCAN-USB — профессиональный инструмент за разумные деньги. Знание CAN открывает двери в automotive-разработку, промышленную автоматизацию и встраиваемые системы. Это инвестиция, которая окупается.
-
Промышленный IoT: MQTT, телеметрия и мониторинг оборудования
IIoT: не просто модное словоПромышленный IoT (IIoT — Industrial Internet of Things) — это не умный чайник и не фитнес-браслет. Это системы, которые собирают данные с реального производственного оборудования, анализируют их и помогают принимать решения. Реальный кейс: завод по производству подшипников. Раньше плановое ТО каждые 3 месяца — меняли подшипники в редукторах "по графику". После внедрения IIoT (вибродатчики на каждом редукторе + MQTT + аналитика): 30% редукторов работали нормально и менялись зря, 5% уже имели износ и могли выйти из строя раньше графика. Экономия на расходниках — 28%, аварийных остановок из-за поломки — минус 4 в год. Это и есть предиктивное обслуживание. И начинается оно с правильного сбора данных. Архитектура IIoT-системыУровень 0 — Полевые устройства: Датчики (температура, вибрация, давление, ток) Исполнительные механизмы Уровень 1 — Агрегаторы / Граничные узлы (Edge): ESP32, Raspberry Pi, промышленные шлюзы Протоколы: Modbus, 1-Wire, I2C, SPI, 4-20мА Уровень 2 — Брокер сообщений: MQTT Broker (Mosquitto, EMQX, HiveMQ) Нормализация данных, маршрутизация Уровень 3 — Обработка и хранение: Node-RED / Python — логика, алармы InfluxDB / TimescaleDB — временные ряды PostgreSQL — конфигурация, справочники Уровень 4 — Визуализация и аналитика: Grafana — дашборды, алерты Jupyter Notebook — анализ данных ML модели — предиктивная аналитика MQTT: почему именно онMQTT (Message Queuing Telemetry Transport) — лёгкий протокол публикации/подписки, разработанный IBM в 1999 году для телеметрии нефтепроводов через спутник. Идеален для IoT: Лёгкий: заголовок всего 2 байта, работает при 2G-соединении Асинхронный: устройства не опрашиваются, а сами публикуют данные QoS (Quality of Service): три уровня надёжности доставки Retain: брокер хранит последнее значение, новые подписчики сразу его получают Last Will: автоматическое сообщение при потере связи с устройством Уровни QoS:QoS 0 (At most once): Отправил и забыл. Быстро, но сообщение может потеряться. Для частых нечувствительных данных (телеметрия каждую секунду). QoS 1 (At least once): Гарантированная доставка, но возможны дубликаты. Для алармов и важных событий. QoS 2 (Exactly once): Ровно один раз. Медленнее, для критичных команд управления. Структура топиков (best practices):factory/ ← Завод ├── line1/ ← Производственная линия 1 │ ├── conveyor1/ ← Конвейер 1 │ │ ├── telemetry ← Данные датчиков (JSON, часто) │ │ ├── status ← Состояние (работает/стоит) │ │ ├── alarms ← Аварии │ │ └── commands ← Команды управления │ └── robot1/ │ └── telemetry └── utilities/ ├── compressor1/ │ └── telemetry └── hvac/ └── telemetry Примеры топиков: factory/line1/conveyor1/telemetry factory/line1/robot1/status factory/+/+/alarms ← Подписка на все аварии всей линии 1 factory/# ← Подписка на ВСЁ (осторожно!) Установка и настройка Mosquitto# Ubuntu/Debian sudo apt update sudo apt install mosquitto mosquitto-clients # Конфигурация /etc/mosquitto/mosquitto.conf: listener 1883 allow_anonymous false password_file /etc/mosquitto/passwd # TLS (обязательно для производства!): listener 8883 cafile /etc/ssl/certs/ca-certificates.crt certfile /etc/mosquitto/certs/server.crt keyfile /etc/mosquitto/certs/server.key require_certificate false # WebSocket для Node-RED и браузерных клиентов: listener 9001 protocol websockets # Логирование: log_dest file /var/log/mosquitto/mosquitto.log log_type all # Создание пользователя: sudo mosquitto_passwd -c /etc/mosquitto/passwd username sudo systemctl enable mosquitto sudo systemctl start mosquitto # Тест: mosquitto_sub -h localhost -u user -P pass -t "factory/#" -v & mosquitto_pub -h localhost -u user -P pass -t "factory/test" -m "hello" ESP32: узел сбора данныхESP32 — идеальный Edge-узел: WiFi/BT, 240 МГц, 520 КБ RAM, куча периферии, цена $3–5. #include <WiFi.h> #include <PubSubClient.h> #include <ArduinoJson.h> #include <Wire.h> #include "Adafruit_BME280.h" // ===== КОНФИГУРАЦИЯ ===== const char* WIFI_SSID = "Factory_WiFi"; const char* WIFI_PASSWORD = "secretpass"; const char* MQTT_SERVER = "192.168.1.100"; const int MQTT_PORT = 1883; const char* MQTT_USER = "esp32_node1"; const char* MQTT_PASS = "nodepass"; const char* DEVICE_ID = "conveyor1"; // Топики const char* TOPIC_TELEMETRY = "factory/line1/conveyor1/telemetry"; const char* TOPIC_STATUS = "factory/line1/conveyor1/status"; const char* TOPIC_ALARMS = "factory/line1/conveyor1/alarms"; const char* TOPIC_COMMANDS = "factory/line1/conveyor1/commands"; const char* TOPIC_WILL = "factory/line1/conveyor1/status"; WiFiClient espClient; PubSubClient mqtt(espClient); Adafruit_BME280 bme; // Состояние bool motorRunning = false; float setpoint = 50.0f; uint32_t lastPublish = 0; uint32_t uptime_sec = 0; // ===== ПОДКЛЮЧЕНИЕ ===== void connectWiFi() { WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.print("WiFi..."); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(" OK"); Serial.println(WiFi.localIP()); } void connectMQTT() { while (!mqtt.connected()) { Serial.print("MQTT..."); // Last Will & Testament — сообщение при потере связи const char* willMsg = "{\"online\":false}"; if (mqtt.connect(DEVICE_ID, MQTT_USER, MQTT_PASS, TOPIC_WILL, 1, true, willMsg)) { Serial.println(" OK"); // Сообщение о подключении mqtt.publish(TOPIC_STATUS, "{\"online\":true}", true); // Подписываемся на команды mqtt.subscribe(TOPIC_COMMANDS, 1); // QoS 1 } else { Serial.printf(" Ошибка: %d\n", mqtt.state()); delay(5000); } } } // ===== ОБРАБОТКА КОМАНД ===== void mqttCallback(char* topic, byte* payload, unsigned int length) { // Парсим JSON команду StaticJsonDocument<256> doc; DeserializationError error = deserializeJson(doc, payload, length); if (error) { Serial.println("JSON error"); return; } String topicStr = String(topic); if (topicStr == TOPIC_COMMANDS) { // Команда пуск/стоп if (doc.containsKey("run")) { motorRunning = doc["run"].as<bool>(); Serial.printf("Команда: %s\n", motorRunning ? "ПУСК" : "СТОП"); } // Изменение уставки if (doc.containsKey("setpoint")) { setpoint = doc["setpoint"].as<float>(); Serial.printf("Уставка: %.1f\n", setpoint); } // Сброс аварии if (doc["reset_alarm"].as<bool>()) { Serial.println("Сброс аварии"); } } } // ===== ПУБЛИКАЦИЯ ДАННЫХ ===== void publishTelemetry() { // Читаем датчики float temperature = bme.readTemperature(); float humidity = bme.readHumidity(); float pressure = bme.readPressure() / 100.0f; int current_raw = analogRead(34); // 4-20мА через ACS712 float current_a = current_raw * 25.0f / 4095.0f; // 0-25А // Формируем JSON StaticJsonDocument<512> doc; doc["device_id"] = DEVICE_ID; doc["timestamp"] = millis() / 1000; doc["uptime"] = uptime_sec; doc["running"] = motorRunning; doc["setpoint"] = setpoint; JsonObject sensors = doc.createNestedObject("sensors"); sensors["temperature"] = round(temperature * 10) / 10.0; sensors["humidity"] = round(humidity * 10) / 10.0; sensors["pressure"] = round(pressure * 10) / 10.0; sensors["current"] = round(current_a * 100) / 100.0; // Диагностика устройства JsonObject diag = doc.createNestedObject("diagnostics"); diag["wifi_rssi"] = WiFi.RSSI(); diag["free_heap"] = ESP.getFreeHeap(); diag["cpu_freq"] = ESP.getCpuFreqMHz(); // Сериализация и публикация char payload[512]; serializeJson(doc, payload); mqtt.publish(TOPIC_TELEMETRY, payload, false); // QoS 0, не retain // Проверка алармов if (temperature > 80.0f) { StaticJsonDocument<128> alarm; alarm["type"] = "high_temperature"; alarm["value"] = temperature; alarm["limit"] = 80.0f; alarm["message"] = "Превышена температура двигателя!"; char alarmPayload[128]; serializeJson(alarm, alarmPayload); mqtt.publish(TOPIC_ALARMS, alarmPayload, true); // retain = true } } // ===== SETUP / LOOP ===== void setup() { Serial.begin(115200); Wire.begin(21, 22); if (!bme.begin(0x76)) { Serial.println("BME280 не найден!"); } connectWiFi(); mqtt.setServer(MQTT_SERVER, MQTT_PORT); mqtt.setCallback(mqttCallback); mqtt.setBufferSize(1024); // Увеличиваем буфер для больших сообщений connectMQTT(); } void loop() { // Переподключение при потере связи if (!mqtt.connected()) { if (WiFi.status() != WL_CONNECTED) { connectWiFi(); } connectMQTT(); } mqtt.loop(); // Публикация каждые 5 секунд if (millis() - lastPublish >= 5000) { lastPublish = millis(); uptime_sec += 5; publishTelemetry(); } } Python: обработка и алармыimport paho.mqtt.client as mqtt import json import time from datetime import datetime from influxdb_client import InfluxDBClient, Point from influxdb_client.client.write_api import SYNCHRONOUS # ===== КОНФИГУРАЦИЯ ===== MQTT_BROKER = "192.168.1.100" MQTT_PORT = 1883 MQTT_USER = "backend" MQTT_PASS = "backendpass" INFLUX_URL = "http://localhost:8086" INFLUX_TOKEN = "your-influx-token" INFLUX_ORG = "factory" INFLUX_BUCKET = "telemetry" # ===== ИНИЦИАЛИЗАЦИЯ ===== influx_client = InfluxDBClient(url=INFLUX_URL, token=INFLUX_TOKEN, org=INFLUX_ORG) write_api = influx_client.write_api(write_options=SYNCHRONOUS) # Состояние алармов (дедупликация) active_alarms = {} def on_message(client, userdata, msg): topic = msg.topic try: data = json.loads(msg.payload.decode()) except json.JSONDecodeError: print(f"Ошибка JSON в топике {topic}") return # Маршрутизация по топику if "/telemetry" in topic: handle_telemetry(topic, data) elif "/alarms" in topic: handle_alarm(topic, data) elif "/status" in topic: handle_status(topic, data) def handle_telemetry(topic: str, data: dict): """Запись телеметрии в InfluxDB""" # Извлекаем путь устройства из топика # factory/line1/conveyor1/telemetry → ['factory', 'line1', 'conveyor1', 'telemetry'] parts = topic.split('/') if len(parts) < 4: return location = parts[1] # line1 device = parts[2] # conveyor1 sensors = data.get('sensors', {}) # Формируем точку данных для InfluxDB point = ( Point("telemetry") .tag("location", location) .tag("device", device) .tag("device_id", data.get('device_id', device)) .field("temperature", float(sensors.get('temperature', 0))) .field("humidity", float(sensors.get('humidity', 0))) .field("pressure", float(sensors.get('pressure', 0))) .field("current", float(sensors.get('current', 0))) .field("running", int(data.get('running', False))) .field("wifi_rssi", int(data.get('diagnostics', {}).get('wifi_rssi', 0))) ) try: write_api.write(bucket=INFLUX_BUCKET, record=point) except Exception as e: print(f"InfluxDB ошибка: {e}") # Проверка пороговых значений temp = sensors.get('temperature', 0) if temp > 85.0: send_alert(device, "critical", f"Критическая температура: {temp}°C") elif temp > 75.0: send_alert(device, "warning", f"Высокая температура: {temp}°C") def handle_alarm(topic: str, data: dict): """Обработка аларм-сообщений от устройства""" parts = topic.split('/') device = parts[2] if len(parts) >= 3 else "unknown" alarm_type = data.get('type', 'unknown') alarm_key = f"{device}_{alarm_type}" # Дедупликация: не спамим одинаковые алармы now = time.time() if alarm_key in active_alarms: if now - active_alarms[alarm_key] < 300: # 5 минут return active_alarms[alarm_key] = now print(f"🚨 АВАРИЯ [{device}]: {data.get('message', alarm_type)}") # Здесь можно добавить отправку в Telegram, email, SMS send_notification( f"⚠️ Авария на {device}\n" f"Тип: {alarm_type}\n" f"Значение: {data.get('value', 'N/A')}\n" f"Время: {datetime.now().strftime('%H:%M:%S')}" ) def handle_status(topic: str, data: dict): """Отслеживание онлайн/офлайн устройств""" parts = topic.split('/') device = parts[2] if len(parts) >= 3 else "unknown" online = data.get('online', False) print(f"{'🟢' if online else '🔴'} {device}: {'онлайн' if online else 'офлайн'}") if not online: send_alert(device, "critical", f"Устройство {device} потеряло связь!") def send_alert(device: str, level: str, message: str): """Отправка алерта (пример — в лог)""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"[{timestamp}] {level.upper()} [{device}]: {message}") def send_notification(text: str): """Здесь интеграция с Telegram Bot API""" # import requests # requests.post(f"https://api.telegram.org/bot{TOKEN}/sendMessage", # json={"chat_id": CHAT_ID, "text": text}) print(f"NOTIFICATION: {text}") # ===== ЗАПУСК ===== client = mqtt.Client(client_id="backend_processor") client.username_pw_set(MQTT_USER, MQTT_PASS) client.on_message = on_message client.connect(MQTT_BROKER, MQTT_PORT, 60) client.subscribe("factory/#", qos=1) # Подписка на всё print("Backend запущен, ожидаем данные...") client.loop_forever() InfluxDB + Grafana: красивые дашбордыУстановка через Docker Compose:# docker-compose.yml version: '3.8' services: mosquitto: image: eclipse-mosquitto:2 ports: - "1883:1883" - "9001:9001" volumes: - ./mosquitto/config:/mosquitto/config - mosquitto_data:/mosquitto/data influxdb: image: influxdb:2.7 ports: - "8086:8086" environment: DOCKER_INFLUXDB_INIT_MODE: setup DOCKER_INFLUXDB_INIT_USERNAME: admin DOCKER_INFLUXDB_INIT_PASSWORD: secretpassword DOCKER_INFLUXDB_INIT_ORG: factory DOCKER_INFLUXDB_INIT_BUCKET: telemetry DOCKER_INFLUXDB_INIT_RETENTION: 30d # Хранение 30 дней volumes: - influxdb_data:/var/lib/influxdb2 grafana: image: grafana/grafana:latest ports: - "3000:3000" environment: GF_SECURITY_ADMIN_PASSWORD: grafanapass GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "yesoreyeram-infinity-datasource" volumes: - grafana_data:/var/lib/grafana depends_on: - influxdb node-red: image: nodered/node-red:latest ports: - "1880:1880" volumes: - nodered_data:/data volumes: mosquitto_data: influxdb_data: grafana_data: nodered_data: Flux-запрос в Grafana (температура за последний час):from(bucket: "telemetry") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "telemetry") |> filter(fn: (r) => r._field == "temperature") |> filter(fn: (r) => r.device == "conveyor1") |> aggregateWindow(every: 1m, fn: mean, createEmpty: false) |> yield(name: "mean_temperature") Предиктивная аналитика: пример с вибрациейimport numpy as np from scipy.fft import fft, fftfreq from scipy.stats import zscore def analyze_vibration(samples: list, sample_rate: int = 1000) -> dict: """ Анализ вибрации для обнаружения износа подшипников. samples: список отсчётов акселерометра sample_rate: частота дискретизации, Гц """ data = np.array(samples, dtype=float) n = len(data) # Временные характеристики rms = np.sqrt(np.mean(data**2)) # Эффективное значение (RMS) peak = np.max(np.abs(data)) # Пиковое значение crest = peak / rms if rms > 0 else 0 # Пик-фактор (norma: 1.4–2.5, износ: >4) kurtosis = float(np.mean((data - np.mean(data))**4) / (np.std(data)**4 + 1e-10)) # Спектральный анализ (FFT) spectrum = np.abs(fft(data))[:n//2] freqs = fftfreq(n, 1.0 / sample_rate)[:n//2] # Поиск доминирующих частот top_indices = np.argsort(spectrum)[-5:][::-1] dominant_freqs = [(float(freqs[i]), float(spectrum[i])) for i in top_indices] # Оценка состояния # Crest Factor: <2.5 — норма, 2.5–4 — внимание, >4 — износ # Kurtosis: <3 — норма (гауссов шум), >6 — дефект (удары) if crest > 4.0 or kurtosis > 6.0: status = "FAULT" recommendation = "Замените подшипник в течение 48 часов" elif crest > 2.5 or kurtosis > 4.5: status = "WARNING" recommendation = "Запланируйте замену при следующем ТО" else: status = "OK" recommendation = "Оборудование в норме" return { 'rms': round(rms, 4), 'peak': round(peak, 4), 'crest_factor': round(crest, 2), 'kurtosis': round(kurtosis, 2), 'dominant_freqs': dominant_freqs[:3], 'status': status, 'recommendation': recommendation, } # Пример использования с MQTT: def on_vibration_data(client, userdata, msg): data = json.loads(msg.payload) samples = data['samples'] device = data['device_id'] analysis = analyze_vibration(samples, sample_rate=data.get('sample_rate', 1000)) # Публикуем результат анализа result_topic = f"factory/analytics/{device}/bearing_health" client.publish(result_topic, json.dumps(analysis), retain=True) if analysis['status'] != 'OK': print(f"⚠️ {device}: {analysis['recommendation']}") # Отправить уведомление... Безопасность IIoTЭто не опционально. Промышленные системы с интернет-подключением — лакомая цель для атак. Минимальный стандарт: TLS везде — Mosquitto с сертификатами, никакого plaintext Аутентификация — уникальный логин/пароль для каждого устройства, или X.509 сертификаты Авторизация по ACL — устройство conveyor1 пишет только в factory/line1/conveyor1/#, не может читать чужие команды Сегментация сети — IoT-устройства в отдельном VLAN, без прямого доступа в интернет OTA-обновления — возможность удалённого обновления прошивки при обнаружении уязвимостей Мониторинг аномалий — необычное количество сообщений, соединения с нестандартных IP # Mosquitto ACL файл /etc/mosquitto/acl: # Устройство conveyor1 — пишет только в свои топики user esp32_conveyor1 topic write factory/line1/conveyor1/+ topic read factory/line1/conveyor1/commands # Backend — читает всё, пишет команды user backend topic readwrite factory/# ЗаключениеIIoT с MQTT — это доступная и проверенная технология, которую можно внедрить даже на небольшом предприятии с минимальными затратами. ESP32 + Mosquitto + InfluxDB + Grafana — весь стек работает на одном Raspberry Pi 4 или бюджетном сервере. Главные принципы: данные должны быть точными (правильная калибровка датчиков), надёжными (QoS, переподключение, buffering), безопасными (TLS, ACL) и полезными (не просто собирать, а анализировать и действовать). Начните с малого: один датчик температуры, один MQTT-брокер, один Grafana-дашборд. После первого успешного графика желание расширять систему появится само.
-
ПИД-регулятор: теория, настройка и реальные примеры
Что такое ПИД и почему он вездеПИД-регулятор (Пропорционально-Интегрально-Дифференциальный) — самый распространённый алгоритм автоматического управления в промышленности. По различным оценкам, более 90% всех промышленных регуляторов используют ПИД или его вариации. Термостат в вашей духовке. Круиз-контроль в автомобиле. Система стабилизации квадрокоптера. Регулятор давления в насосной станции. Система автопилота самолёта. Везде — ПИД. Почему? Потому что он: Прост в понимании — три компоненты, три параметра Работает для подавляющего большинства объектов управления Хорошо изучен — 80 лет теории и практики Легко реализуется — 10–20 строк кода Математика: просто о сложномПусть у нас есть: SP (Setpoint) — желаемое значение (уставка) PV (Process Variable) — измеренное значение e(t) = SP – PV — ошибка регулирования u(t) — управляющий сигнал (выход регулятора) Формула ПИД: u(t) = Kp × e(t) + Ki × ∫e(t)dt + Kd × de(t)/dt │ │ │ Пропорциональная Интегральная Дифференциальная составляющая составляющая составляющая Разберём каждую составляющую на примере: нагреватель, цель — держать 80°C. P — Пропорциональная составляющаяУправляющий сигнал пропорционален текущей ошибке. Температура 60°C, уставка 80°C, ошибка 20°C → включить нагрев на 20×Kp%. Проблема чистого P-регулятора: статическая ошибка (offset). При стабильном нагреве нагрев должен компенсировать теплопотери, значит ошибка никогда не станет нулём — иначе нагрев выключится и температура упадёт. Система "зависает" с постоянной небольшой ошибкой. I — Интегральная составляющаяСуммирует ошибку со временем. Даже маленькая постоянная ошибка, накапливаясь, создаёт всё больший управляющий сигнал — и в конечном счёте устраняет статическую ошибку. Проблема: интегральное насыщение (windup). Если выход ограничен (0–100%), а ошибка долго накапливается (например, нагрев не справляется при открытом окне), интеграл "разгоняется" до огромного значения. Потом, когда ошибка уменьшается, нужно много времени чтобы интеграл "разрядился" — система перерегулирует. D — Дифференциальная составляющаяРеагирует на скорость изменения ошибки. Если ошибка быстро уменьшается (температура быстро растёт к уставке) — D-составляющая "притормаживает" для предотвращения перерегулирования. Проблема: шум. Производная усиливает высокочастотный шум измерений. Любой дёрг датчика превращается в огромный кратковременный скачок управляющего сигнала. Поэтому D всегда применяется с фильтром. Дискретная реализация (для цифровых систем)В реальных системах регулятор выполняется с дискретным шагом Ts (время выборки). Непрерывная формула преобразуется в разностное уравнение: P = Kp × e[k] I = I[k-1] + Ki × e[k] × Ts D = Kd × (e[k] - e[k-1]) / Ts u[k] = P + I + D Но правильная реализация сложнее. Полная профессиональная реализация: class PIDController: """ Промышленный ПИД-регулятор с: - Anti-windup (ограничение интеграла) - Производная по измерению (не по ошибке) - Фильтр производной - Ограничение выхода - Bump-less transfer (безударное переключение авто/ручной) """ def __init__(self, kp: float, ki: float, kd: float, ts: float, # Период дискретизации, секунды out_min: float = 0.0, out_max: float = 100.0, filter_coeff: float = 0.1): # Коэффициент фильтра D self.kp = kp self.ki = ki self.kd = kd self.ts = ts self.out_min = out_min self.out_max = out_max self.filter_coeff = filter_coeff # Nf: чем меньше, тем сильнее фильтрация # Состояние self.setpoint = 0.0 self.integral = 0.0 self.prev_measurement = None self.filtered_deriv = 0.0 self.output = 0.0 # Режим self.auto_mode = True def set_setpoint(self, sp: float): self.setpoint = sp def set_manual(self, output: float): """Ручной режим — задать выход напрямую""" self.auto_mode = False self.output = max(self.out_min, min(self.out_max, output)) # При переходе обратно в авто — интеграл синхронизируется def set_auto(self): """Переход в автоматический режим (bump-less)""" if not self.auto_mode: # Инициализируем интеграл текущим выходом # чтобы не было скачка при переключении self.integral = self.output self.auto_mode = True def compute(self, measurement: float) -> float: """ Вычислить управляющий сигнал. measurement — текущее значение регулируемой величины. Вызывать строго с периодом ts! """ if not self.auto_mode: return self.output if self.prev_measurement is None: self.prev_measurement = measurement error = self.setpoint - measurement # === Пропорциональная составляющая === p_term = self.kp * error # === Интегральная составляющая с anti-windup === # Предварительно вычисляем интеграл integral_new = self.integral + self.ki * error * self.ts # === Дифференциальная составляющая === # Производная по ИЗМЕРЕНИЮ (не по ошибке) — избегаем derivative kick # при изменении уставки raw_deriv = -(measurement - self.prev_measurement) / self.ts # Фильтр производной (экспоненциальный) self.filtered_deriv = (self.filter_coeff * raw_deriv + (1 - self.filter_coeff) * self.filtered_deriv) d_term = self.kd * self.filtered_deriv # === Предварительный выход === output_raw = p_term + integral_new + d_term # === Ограничение выхода === self.output = max(self.out_min, min(self.out_max, output_raw)) # === Anti-windup: обновляем интеграл только если не насыщены === # Или используем "back-calculation anti-windup" if self.output == output_raw: # Нет насыщения — обновляем интеграл нормально self.integral = integral_new else: # Насыщение — не накапливаем интеграл дальше # Back-calculation: уменьшаем интеграл на величину насыщения saturation = output_raw - self.output self.integral = integral_new - saturation self.prev_measurement = measurement return self.output def get_components(self) -> dict: """Для отладки и мониторинга""" error = self.setpoint - (self.prev_measurement or 0) return { 'setpoint': self.setpoint, 'error': error, 'p_term': self.kp * error, 'i_term': self.integral, 'd_term': self.kd * self.filtered_deriv, 'output': self.output, } Методы настройки коэффициентовМетод 1: Инженерная настройка (руками)Алгоритм по шагам: Ki = 0, Kd = 0. Постепенно увеличиваем Kp до появления устойчивых колебаний (нарастающих) — это Kc. Уменьшаем Kp до 0.5 × Kc. Медленно увеличиваем Ki. Он должен устранить остаточную ошибку. Если появились колебания — Ki велик. Если нужно улучшить реакцию и уменьшить перерегулирование — добавляем Kd. Часто тепловые объекты обходятся без D. Метод 2: Циглера-Николса (классика)Установить Ki = 0, Kd = 0 Увеличивать Kp до возникновения незатухающих колебаний Записать: Ku — критический коэффициент, Tu — период колебаний Тип Kp Ki Kd Только P 0.5 × Ku — — PI 0.45 × Ku 0.54 × Ku / Tu — ПИД 0.6 × Ku 1.2 × Ku / Tu 0.075 × Ku × Tu Результат: Обычно агрессивный, дающий ~25% перерегулирования. Хорош как стартовая точка. Метод 3: Ступенчатый отклик (Step Response)Более безопасный метод — не нужно доводить до автоколебаний: Перевести систему в устойчивое состояние Скачком изменить управляющий сигнал на 10–20% Записать переходный процесс import numpy as np import matplotlib.pyplot as plt from scipy.optimize import curve_fit def fit_first_order_plus_delay(t, data, step_size): """ Аппроксимация объекта управления моделью первого порядка с запаздыванием: G(s) = K × exp(-L×s) / (T×s + 1) """ # Нормализация данных y_norm = (data - data[0]) / step_size # Начальные параметры: K, T, L K_init = y_norm[-1] # Статический коэффициент усиления T_init = t[np.argmin(np.abs(y_norm - 0.63 * K_init))] L_init = t[np.argmin(np.abs(y_norm - 0.05 * K_init))] def model(t, K, T, L): result = np.zeros_like(t) for i, ti in enumerate(t): if ti > L: result[i] = K * (1 - np.exp(-(ti - L) / T)) return result try: popt, _ = curve_fit(model, t, y_norm, p0=[K_init, T_init, max(L_init, 0.001)], bounds=([0, 0, 0], [np.inf, np.inf, np.inf])) K, T, L = popt return K, T, L except RuntimeError: return K_init, T_init, max(L_init, 0.001) def imc_tuning(K, T, L, lambda_factor=1.0): """ IMC (Internal Model Control) настройка — лучший метод для объектов с запаздыванием. lambda_factor: чем больше — тем медленнее, но стабильнее (рекомендуется L...3L) """ lambda_c = lambda_factor * max(L, 0.1 * T) Kp = (2 * T + L) / (2 * K * (lambda_c + L)) Ti = T + L / 2 # Постоянная интегрирования Td = T * L / (2 * T + L) # Постоянная дифференцирования Ki = Kp / Ti Kd = Kp * Td return { 'Kp': round(Kp, 4), 'Ki': round(Ki, 4), 'Kd': round(Kd, 4), 'Ti': round(Ti, 4), 'Td': round(Td, 4), } # Пример использования: # K=2.0 (усиление объекта), T=30с (постоянная времени), L=5с (запаздывание) params = imc_tuning(K=2.0, T=30.0, L=5.0, lambda_factor=1.0) print(f"Kp={params['Kp']}, Ki={params['Ki']}, Kd={params['Kd']}") Симуляция и проверка настройкиПеред применением в реальной системе — всегда симулируйте: import numpy as np import matplotlib.pyplot as plt def simulate_pid(kp, ki, kd, setpoint, sim_time=200, ts=0.1, K=2.0, T=30.0, L=5.0, noise_std=0.1): """ Симуляция ПИД с объектом первого порядка + запаздывание. Возвращает массивы времени, выходного значения, управляющего сигнала. """ steps = int(sim_time / ts) delay_steps = int(L / ts) t = np.zeros(steps) pv = np.zeros(steps) u = np.zeros(steps) sp = np.zeros(steps) pid = PIDController(kp, ki, kd, ts, out_min=0.0, out_max=100.0) # Ступенчатое изменение уставки sp[:steps//4] = 0 sp[steps//4:] = setpoint # Буфер запаздывания u_delayed = np.zeros(delay_steps + 1) for i in range(1, steps): t[i] = i * ts sp_now = sp[i] pid.set_setpoint(sp_now) # Добавляем шум измерения noise = np.random.normal(0, noise_std) # Управляющий сигнал u[i] = pid.compute(pv[i-1] + noise) # Обновляем буфер запаздывания u_delayed = np.roll(u_delayed, 1) u_delayed[0] = u[i] # Объект управления: первый порядок с запаздыванием # dy/dt = (K × u_delayed - y) / T pv[i] = pv[i-1] + ts * (K * u_delayed[-1] - pv[i-1]) / T return t, pv, u, sp # Сравнение разных настроек fig, axes = plt.subplots(2, 1, figsize=(12, 8)) configs = [ {'kp': 0.5, 'ki': 0.01, 'kd': 2.0, 'label': 'IMC (lambda=1)', 'color': 'blue'}, {'kp': 1.5, 'ki': 0.05, 'kd': 5.0, 'label': 'Агрессивный', 'color': 'red'}, {'kp': 0.2, 'ki': 0.005, 'kd': 0.5, 'label': 'Консервативный', 'color': 'green'}, ] for cfg in configs: t, pv, u, sp = simulate_pid( kp=cfg['kp'], ki=cfg['ki'], kd=cfg['kd'], setpoint=60.0 ) axes[0].plot(t, pv, label=cfg['label'], color=cfg['color']) axes[1].plot(t, u, color=cfg['color'], alpha=0.7) axes[0].plot(t, sp, 'k--', label='Уставка', linewidth=2) axes[0].set_ylabel('Температура, °C') axes[0].set_title('Сравнение настроек ПИД-регулятора') axes[0].legend() axes[0].grid(True) axes[1].set_xlabel('Время, с') axes[1].set_ylabel('Управляющий сигнал, %') axes[1].grid(True) plt.tight_layout() plt.savefig('pid_comparison.png', dpi=150) Специальные случаи и решенияКаскадный ПИД (Cascade Control)Используется когда одного ПИД недостаточно. Классический пример: температурный реактор с нагревателем и теплоносителем. Внешний ПИД (медленный): SP температуры → [ПИД_темп] → SP расхода теплоносителя Внутренний ПИД (быстрый): SP расхода → [ПИД_расход] → Управление клапаном Внутренний контур в 5–10 раз быстрее внешнего. Преимущество: внутренний контур компенсирует возмущения по теплоносителю до того, как они повлияют на температуру. class CascadePID: """Каскадный регулятор с двумя ПИД""" def __init__(self, outer_pid: PIDController, inner_pid: PIDController): self.outer = outer_pid self.inner = inner_pid def compute(self, outer_measurement: float, inner_measurement: float) -> float: """ outer_measurement — медленная переменная (температура) inner_measurement — быстрая переменная (расход) """ # Внешний контур вырабатывает уставку для внутреннего inner_setpoint = self.outer.compute(outer_measurement) # Ограничиваем уставку внутреннего контура inner_setpoint = max(0.0, min(100.0, inner_setpoint)) self.inner.set_setpoint(inner_setpoint) # Внутренний контур управляет исполнительным устройством return self.inner.compute(inner_measurement) ПИД с ограничением скорости изменения выхода (Rate Limiting)Некоторые исполнительные устройства не любят резких скачков (например, регулирующие клапана — быстрое перемещение вызывает гидроудары): def rate_limited_output(new_output: float, prev_output: float, max_rate: float, ts: float) -> float: """ Ограничивает скорость изменения выходного сигнала. max_rate — максимальное изменение в секунду (например, 10%/сек) """ max_change = max_rate * ts change = new_output - prev_output change = max(-max_change, min(max_change, change)) return prev_output + change ПИД для нестационарных объектов (Gain Scheduling)Если параметры объекта меняются в зависимости от рабочей точки (например, нагрев при низкой температуре работает иначе чем при высокой): class GainSchedulingPID: """ПИД с переключением коэффициентов в зависимости от уставки""" def __init__(self): # Коэффициенты для разных диапазонов температур self.schedules = [ # (max_sp, kp, ki, kd) (50.0, 2.0, 0.05, 1.0), # Низкая температура (100.0, 1.5, 0.03, 0.8), # Средняя (200.0, 1.0, 0.02, 0.5), # Высокая (объект другой!) ] self.pid = PIDController(kp=2.0, ki=0.05, kd=1.0, ts=1.0) def update_gains(self, setpoint: float): for max_sp, kp, ki, kd in self.schedules: if setpoint <= max_sp: self.pid.kp = kp self.pid.ki = ki self.pid.kd = kd break def compute(self, setpoint: float, measurement: float) -> float: self.update_gains(setpoint) self.pid.set_setpoint(setpoint) return self.pid.compute(measurement) Реализация на C для встраиваемых систем#include <stdint.h> #include <float.h> typedef struct { float kp; float ki; float kd; float ts; // Период дискретизации, секунды float out_min; float out_max; float filter_coeff; // Коэффициент фильтра производной (0.01..0.2) // Состояние float setpoint; float integral; float prev_measurement; float filtered_deriv; float output; uint8_t first_run; } PIDState; void PID_Init(PIDState *pid, float kp, float ki, float kd, float ts, float out_min, float out_max) { pid->kp = kp; pid->ki = ki; pid->kd = kd; pid->ts = ts; pid->out_min = out_min; pid->out_max = out_max; pid->filter_coeff = 0.1f; pid->setpoint = 0.0f; pid->integral = 0.0f; pid->prev_measurement = 0.0f; pid->filtered_deriv = 0.0f; pid->output = 0.0f; pid->first_run = 1; } void PID_SetSetpoint(PIDState *pid, float sp) { pid->setpoint = sp; } void PID_Reset(PIDState *pid) { pid->integral = 0.0f; pid->filtered_deriv = 0.0f; pid->first_run = 1; } float PID_Compute(PIDState *pid, float measurement) { float error, p_term, d_term, output_raw; float raw_deriv, integral_new; if (pid->first_run) { pid->prev_measurement = measurement; pid->first_run = 0; } error = pid->setpoint - measurement; // Пропорциональная p_term = pid->kp * error; // Интегральная (предварительный расчёт) integral_new = pid->integral + pid->ki * error * pid->ts; // Дифференциальная по измерению raw_deriv = -(measurement - pid->prev_measurement) / pid->ts; // Фильтр производной (IIR первого порядка) pid->filtered_deriv = pid->filter_coeff * raw_deriv + (1.0f - pid->filter_coeff) * pid->filtered_deriv; d_term = pid->kd * pid->filtered_deriv; // Суммируем output_raw = p_term + integral_new + d_term; // Ограничение выхода if (output_raw > pid->out_max) pid->output = pid->out_max; else if (output_raw < pid->out_min) pid->output = pid->out_min; else pid->output = output_raw; // Anti-windup: обновляем интеграл только если нет насыщения if (pid->output == output_raw) { pid->integral = integral_new; } else { // Back-calculation float saturation = output_raw - pid->output; pid->integral = integral_new - saturation; } pid->prev_measurement = measurement; return pid->output; } // Использование на STM32/Arduino (в прерывании таймера): PIDState temp_pid; void PID_ISR(void) // Вызывается каждые 100 мс { float measured_temp = read_temperature_sensor(); float heater_power = PID_Compute(&temp_pid, measured_temp); // Перевести в ШИМ 0-255 uint8_t pwm = (uint8_t)(heater_power * 255.0f / 100.0f); set_heater_pwm(pwm); } Диагностика плохой работы ПИДСимптом: Медленный выход на уставку, большая статическая ошибкаМало Kp и/или Ki Проверьте нет ли механических ограничений (клапан не открывается полностью) Проверьте масштабирование — правильно ли в % интерпретируется выход? Симптом: Сильные устойчивые колебанияKp слишком велик Ki слишком велик (самая частая причина!) Период дискретизации слишком большой относительно динамики объекта Симптом: Большое перерегулирование при изменении уставкиВелико Kp, увеличьте Kd Используйте "setpoint weighting": P-составляющую считайте как Kp × (w × SP – PV), где w < 1 Симптом: Шумный выход, дёргает исполнительный механизмШум датчика, увеличьте filter_coeff для фильтрации D Уменьшите Kd Добавьте мёртвую зону: не реагировать если |error| < deadband ЗаключениеПИД-регулятор — это не просто формула, это искусство. Одни и те же Kp, Ki, Kd дадут отличные результаты на одном объекте и катастрофу на другом. Понимание физики объекта управления важнее знания формул. Практический совет: начинайте с IMC-метода настройки — он даёт предсказуемые результаты и физический смысл у параметра lambda (желаемая скорость реакции). Используйте симуляцию перед внедрением. Добавляйте anti-windup всегда — без него реальная система будет вести себя непредсказуемо после насыщения. И помните: 80% задач управления решается PI-регулятором (без D). D добавляйте только когда PI действительно недостаточно и когда шум измерений не является проблемой.
-
Arduino в реальных проектах: от мигания светодиодом до промышленного прототипа
Честный разговор об ArduinoArduino получил репутацию "игрушки для начинающих". Это несправедливо. Arduino — это доступная платформа с огромной экосистемой, которая используется в реальных устройствах, промышленных прототипах и мелкосерийном производстве. Да, у неё есть ограничения: 8-битный AVR, 16 МГц, 2 КБ RAM. Но для огромного класса задач этого более чем достаточно. Умный контроллер теплицы, анализатор вибраций, узел сбора данных, промышленный таймер — Arduino справится. Проблема не в платформе. Проблема в том, как большинство людей пишут код для Arduino. Сегодня покажу как писать Arduino-код правильно — как настоящие инженеры. Главная ошибка: delay()delay() — это яд для Arduino. Пока выполняется delay(1000), микроконтроллер ничего не делает. Не обрабатывает кнопки. Не читает датчики. Не обновляет выходы. Он просто спит. В реальном устройстве с несколькими задачами это неприемлемо. Правильный подход: millis() и конечный автомат// ПЛОХО — как делают 90% новичков: void loop() { digitalWrite(LED1, HIGH); delay(500); digitalWrite(LED1, LOW); delay(500); // Пока мигает LED1 — ничего другого не происходит! } // ХОРОШО — неблокирующий код: class BlinkTask { private: uint8_t pin; uint32_t interval; uint32_t lastToggle; bool state; public: BlinkTask(uint8_t _pin, uint32_t _interval) : pin(_pin), interval(_interval), lastToggle(0), state(false) {} void begin() { pinMode(pin, OUTPUT); } void update() { uint32_t now = millis(); if (now - lastToggle >= interval) { lastToggle = now; state = !state; digitalWrite(pin, state); } } }; BlinkTask led1(13, 500); // LED на пине 13, период 500 мс BlinkTask led2(12, 200); // LED на пине 12, период 200 мс void setup() { led1.begin(); led2.begin(); } void loop() { led1.update(); // Каждый вызов занимает микросекунды led2.update(); // Оба LED мигают независимо! // Здесь можно добавить ещё десятки задач } Антидребезг кнопок: правильная реализацияФизическая кнопка при нажатии/отпускании "дребезжит" — за несколько миллисекунд создаёт 10–50 ложных переключений. Простой delay(50) тут не поможет — он заблокирует программу. class Button { private: uint8_t pin; uint8_t currentState; uint8_t lastRawState; uint32_t lastChangeTime; uint32_t debounceTime; // Callbacks void (*onPress)(); void (*onRelease)(); void (*onLongPress)(); uint32_t pressTime; uint32_t longPressTime; bool longPressTriggered; public: Button(uint8_t _pin, uint32_t _debounce = 50, uint32_t _longPress = 1000) : pin(_pin), currentState(HIGH), lastRawState(HIGH), lastChangeTime(0), debounceTime(_debounce), onPress(nullptr), onRelease(nullptr), onLongPress(nullptr), pressTime(0), longPressTime(_longPress), longPressTriggered(false) {} void begin() { pinMode(pin, INPUT_PULLUP); // Внутренняя подтяжка, нажатие → LOW } void setOnPress(void (*cb)()) { onPress = cb; } void setOnRelease(void (*cb)()) { onRelease = cb; } void setOnLongPress(void (*cb)()){ onLongPress = cb; } bool isPressed() { return currentState == LOW; } void update() { uint32_t now = millis(); uint8_t rawState = digitalRead(pin); // Антидребезг: состояние должно держаться debounceTime мс if (rawState != lastRawState) { lastChangeTime = now; lastRawState = rawState; } if (now - lastChangeTime >= debounceTime) { if (rawState != currentState) { currentState = rawState; if (currentState == LOW) { // Нажата pressTime = now; longPressTriggered = false; if (onPress) onPress(); } else { // Отпущена if (onRelease) onRelease(); } } } // Проверка длинного нажатия if (currentState == LOW && !longPressTriggered) { if (now - pressTime >= longPressTime) { longPressTriggered = true; if (onLongPress) onLongPress(); } } } }; // Использование: Button btn1(2); Button btn2(3); int counter = 0; void onBtn1Press() { Serial.println("Кнопка 1 нажата"); counter++; } void onBtn1LongPress() { Serial.println("Длинное нажатие! Сброс"); counter = 0; } void onBtn2Press() { Serial.println("Кнопка 2 нажата"); counter--; } void setup() { Serial.begin(115200); btn1.begin(); btn2.begin(); btn1.setOnPress(onBtn1Press); btn1.setOnLongPress(onBtn1LongPress); btn2.setOnPress(onBtn2Press); } void loop() { btn1.update(); btn2.update(); // ... другие задачи } Конечный автомат (State Machine): управление процессомКонечный автомат — правильный способ описывать сложное поведение без вложенных if-else и флагов. Пример: автоматическая стиральная машина (упрощённо): enum WasherState { STATE_IDLE, STATE_FILL_WATER, STATE_WASH, STATE_DRAIN, STATE_SPIN, STATE_COMPLETE, STATE_ERROR }; enum WasherEvent { EVENT_START, EVENT_WATER_FULL, EVENT_WASH_DONE, EVENT_DRAIN_DONE, EVENT_SPIN_DONE, EVENT_ERROR, EVENT_RESET }; class WashingMachine { private: WasherState state; uint32_t stateEnterTime; uint32_t washDuration; uint32_t spinDuration; // Пины static const uint8_t PIN_VALVE = 4; // Клапан залива воды static const uint8_t PIN_PUMP = 5; // Насос откачки static const uint8_t PIN_MOTOR = 6; // Двигатель барабана static const uint8_t PIN_LED_RUN = 7; // Индикатор работы static const uint8_t PIN_SENSOR_FULL = 8; // Датчик полного бака void enterState(WasherState newState) { state = newState; stateEnterTime = millis(); // Действия при входе в состояние switch (state) { case STATE_IDLE: allOff(); Serial.println("Ожидание..."); break; case STATE_FILL_WATER: digitalWrite(PIN_VALVE, HIGH); Serial.println("Заполнение водой..."); break; case STATE_WASH: digitalWrite(PIN_VALVE, LOW); digitalWrite(PIN_MOTOR, HIGH); Serial.println("Стирка..."); break; case STATE_DRAIN: digitalWrite(PIN_MOTOR, LOW); digitalWrite(PIN_PUMP, HIGH); Serial.println("Слив воды..."); break; case STATE_SPIN: digitalWrite(PIN_PUMP, LOW); // Быстрое вращение для отжима analogWrite(PIN_MOTOR, 200); // 78% мощности Serial.println("Отжим..."); break; case STATE_COMPLETE: allOff(); Serial.println("Стирка завершена!"); break; case STATE_ERROR: allOff(); Serial.println("АВАРИЯ!"); break; } } void allOff() { digitalWrite(PIN_VALVE, LOW); digitalWrite(PIN_PUMP, LOW); digitalWrite(PIN_MOTOR, LOW); } public: WashingMachine(uint32_t _washMin = 30, uint32_t _spinMin = 5) : state(STATE_IDLE), washDuration(_washMin * 60000UL), spinDuration(_spinMin * 60000UL) {} void begin() { pinMode(PIN_VALVE, OUTPUT); pinMode(PIN_PUMP, OUTPUT); pinMode(PIN_MOTOR, OUTPUT); pinMode(PIN_LED_RUN, OUTPUT); pinMode(PIN_SENSOR_FULL, INPUT_PULLUP); allOff(); } void sendEvent(WasherEvent event) { switch (state) { case STATE_IDLE: if (event == EVENT_START) enterState(STATE_FILL_WATER); break; case STATE_FILL_WATER: if (event == EVENT_WATER_FULL) enterState(STATE_WASH); if (event == EVENT_ERROR) enterState(STATE_ERROR); break; case STATE_WASH: if (event == EVENT_WASH_DONE) enterState(STATE_DRAIN); if (event == EVENT_ERROR) enterState(STATE_ERROR); break; case STATE_DRAIN: if (event == EVENT_DRAIN_DONE) enterState(STATE_SPIN); if (event == EVENT_ERROR) enterState(STATE_ERROR); break; case STATE_SPIN: if (event == EVENT_SPIN_DONE) enterState(STATE_COMPLETE); break; case STATE_ERROR: if (event == EVENT_RESET) enterState(STATE_IDLE); break; default: break; } } // Вызывать в loop() каждый цикл void update() { uint32_t elapsed = millis() - stateEnterTime; // Индикатор работы bool running = (state != STATE_IDLE && state != STATE_COMPLETE && state != STATE_ERROR); digitalWrite(PIN_LED_RUN, running ? (millis() % 500 < 250) : LOW); switch (state) { case STATE_FILL_WATER: // Проверяем датчик уровня if (digitalRead(PIN_SENSOR_FULL) == LOW) { sendEvent(EVENT_WATER_FULL); } // Таймаут заполнения 10 минут if (elapsed > 600000UL) { sendEvent(EVENT_ERROR); } break; case STATE_WASH: if (elapsed >= washDuration) { sendEvent(EVENT_WASH_DONE); } break; case STATE_DRAIN: // 3 минуты на слив if (elapsed >= 180000UL) { sendEvent(EVENT_DRAIN_DONE); } break; case STATE_SPIN: if (elapsed >= spinDuration) { sendEvent(EVENT_SPIN_DONE); } break; default: break; } } WasherState getState() { return state; } }; WashingMachine washer(30, 5); // 30 мин стирка, 5 мин отжим Button startBtn(2); void setup() { Serial.begin(115200); washer.begin(); startBtn.begin(); startBtn.setOnPress([]() { washer.sendEvent(EVENT_START); }); } void loop() { washer.update(); startBtn.update(); } ПИД-регулятор: управление температуройclass PIDController { private: float kp, ki, kd; float setpoint; float integral; float prevError; float integralLimit; float outputMin, outputMax; uint32_t lastTime; public: PIDController(float _kp, float _ki, float _kd, float _outMin = 0.0f, float _outMax = 100.0f) : kp(_kp), ki(_ki), kd(_kd), setpoint(0), integral(0), prevError(0), integralLimit(50.0f), outputMin(_outMin), outputMax(_outMax), lastTime(0) {} void setSetpoint(float sp) { setpoint = sp; } void reset() { integral = 0; prevError = 0; } float compute(float measured) { uint32_t now = millis(); float dt = (now - lastTime) / 1000.0f; // Секунды lastTime = now; if (dt <= 0 || dt > 1.0f) dt = 0.1f; // Защита от аномальных dt float error = setpoint - measured; // Интегральная составляющая с ограничением (anti-windup) integral += error * dt; integral = constrain(integral, -integralLimit, integralLimit); // Производная (фильтруем шум — берём производную измерения, не ошибки) float derivative = -(measured - prevError) / dt; // -d(PV)/dt prevError = measured; float output = kp * error + ki * integral + kd * derivative; return constrain(output, outputMin, outputMax); } }; // Применение: ПИД-термостат с твёрдотельным реле (SSR) const uint8_t PIN_HEATER = 9; // ШИМ-выход на SSR const uint8_t PIN_TEMP_SCL = A4; // I2C — датчик температуры const uint8_t PIN_TEMP_SDA = A5; PIDController tempPID(2.0f, 0.5f, 1.0f, 0.0f, 255.0f); float readTemperature() { // Здесь чтение датчика DS18B20 или термопары MAX6675 // Возвращаем демо-значение: return 25.0f + random(-10, 10) / 10.0f; } void setup() { Serial.begin(115200); pinMode(PIN_HEATER, OUTPUT); tempPID.setSetpoint(75.0f); // Уставка 75°C } void loop() { static uint32_t lastPID = 0; if (millis() - lastPID >= 500) { // ПИД каждые 500 мс lastPID = millis(); float temp = readTemperature(); float output = tempPID.compute(temp); analogWrite(PIN_HEATER, (uint8_t)output); Serial.print("T="); Serial.print(temp, 1); Serial.print("°C SP=75°C OUT="); Serial.print((int)output * 100 / 255); Serial.println("%"); } } Modbus RTU Slave на Arduino#include <SoftwareSerial.h> // RS-485 через MAX485 SoftwareSerial rs485(10, 11); // RX, TX const uint8_t DE_RE_PIN = 4; // Driver Enable / Receiver Enable // Данные устройства (10 Holding Registers) uint16_t holdingRegs[10] = {0}; uint16_t inputRegs[10] = {0}; const uint8_t DEVICE_ADDRESS = 1; uint16_t crc16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { crc = (crc & 1) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); } } return crc; } void rs485Send(uint8_t *data, uint8_t len) { digitalWrite(DE_RE_PIN, HIGH); // Режим передачи delayMicroseconds(100); rs485.write(data, len); rs485.flush(); delayMicroseconds(100); digitalWrite(DE_RE_PIN, LOW); // Режим приёма } void processModbus(uint8_t *req, uint8_t len) { if (req[0] != DEVICE_ADDRESS) return; uint16_t rxCRC = (req[len-1] << 8) | req[len-2]; if (crc16(req, len-2) != rxCRC) return; uint8_t resp[64]; uint8_t rlen = 0; uint8_t fc = req[1]; uint16_t addr = (req[2] << 8) | req[3]; uint16_t count = (req[4] << 8) | req[5]; resp[rlen++] = DEVICE_ADDRESS; resp[rlen++] = fc; if (fc == 0x03 && addr + count <= 10) { // Read Holding Registers resp[rlen++] = count * 2; for (uint16_t i = 0; i < count; i++) { resp[rlen++] = holdingRegs[addr + i] >> 8; resp[rlen++] = holdingRegs[addr + i] & 0xFF; } } else if (fc == 0x04 && addr + count <= 10) { // Read Input Registers resp[rlen++] = count * 2; for (uint16_t i = 0; i < count; i++) { resp[rlen++] = inputRegs[addr + i] >> 8; resp[rlen++] = inputRegs[addr + i] & 0xFF; } } else if (fc == 0x06 && addr < 10) { // Write Single Register holdingRegs[addr] = (req[4] << 8) | req[5]; memcpy(resp + 2, req + 2, 4); rlen += 4; } else { resp[1] |= 0x80; resp[rlen++] = (fc == 0x03 || fc == 0x04 || fc == 0x06) ? 0x02 : 0x01; } uint16_t c = crc16(resp, rlen); resp[rlen++] = c & 0xFF; resp[rlen++] = c >> 8; rs485Send(resp, rlen); } void setup() { Serial.begin(115200); rs485.begin(9600); pinMode(DE_RE_PIN, OUTPUT); digitalWrite(DE_RE_PIN, LOW); // Режим приёма по умолчанию } void loop() { // Обновляем Input Registers реальными данными inputRegs[0] = analogRead(A0); // Аналоговый вход 0-1023 inputRegs[1] = (uint16_t)(millis() / 1000); // Uptime в секундах // Принимаем Modbus-запросы static uint8_t rxBuf[64]; static uint8_t rxLen = 0; static uint32_t lastByte = 0; while (rs485.available()) { if (rxLen < sizeof(rxBuf)) { rxBuf[rxLen++] = rs485.read(); } lastByte = millis(); } // Конец фрейма — пауза 3.5 символа (при 9600 бод ~4 мс) if (rxLen > 0 && millis() - lastByte > 4) { processModbus(rxBuf, rxLen); rxLen = 0; } } Работа с несколькими датчиками I2C#include <Wire.h> #include <Adafruit_BMP280.h> #include <Adafruit_SHT31.h> #include <RTC_DS3231.h> Adafruit_BMP280 bmp; Adafruit_SHT31 sht; RTC_DS3231 rtc; struct SensorData { float temperature_bmp; float pressure_hpa; float temperature_sht; float humidity; uint32_t timestamp; bool bmp_ok; bool sht_ok; }; SensorData readAllSensors() { SensorData data = {0}; data.timestamp = rtc.now().unixtime(); // BMP280: давление и температура if (bmp.begin(0x76)) { data.temperature_bmp = bmp.readTemperature(); data.pressure_hpa = bmp.readPressure() / 100.0f; data.bmp_ok = true; } // SHT31: температура и влажность if (sht.begin(0x44)) { data.temperature_sht = sht.readTemperature(); data.humidity = sht.readHumidity(); data.sht_ok = (!isnan(data.temperature_sht)); } return data; } void printSensorData(const SensorData& d) { Serial.print("T1="); Serial.print(d.temperature_bmp, 1); Serial.print("°C "); Serial.print("P="); Serial.print(d.pressure_hpa, 1); Serial.print("гПа "); Serial.print("T2="); Serial.print(d.temperature_sht, 1); Serial.print("°C "); Serial.print("H="); Serial.print(d.humidity, 1); Serial.println("%"); } void setup() { Serial.begin(115200); Wire.begin(); Wire.setClock(400000); // Fast mode 400 кГц if (!bmp.begin(0x76)) Serial.println("BMP280 не найден!"); if (!sht.begin(0x44)) Serial.println("SHT31 не найден!"); if (!rtc.begin()) Serial.println("DS3231 не найден!"); } void loop() { static uint32_t lastRead = 0; if (millis() - lastRead >= 5000) { // Каждые 5 секунд lastRead = millis(); SensorData data = readAllSensors(); printSensorData(data); } } Запись данных на SD-карту#include <SPI.h> #include <SD.h> const uint8_t SD_CS_PIN = 10; bool sdAvailable = false; void sdInit() { sdAvailable = SD.begin(SD_CS_PIN); if (!sdAvailable) { Serial.println("SD-карта не найдена!"); } else { Serial.println("SD-карта готова."); } } void logData(const String& filename, float temp, float humidity, uint32_t timestamp) { if (!sdAvailable) return; File file = SD.open(filename, FILE_WRITE); if (!file) { Serial.println("Ошибка открытия файла!"); return; } // CSV-формат file.print(timestamp); file.print(','); file.print(temp, 2); file.print(','); file.println(humidity, 2); file.close(); } // Создание нового файла каждый день String getDailyFilename(uint32_t unixtime) { // Простой расчёт дня uint32_t day = unixtime / 86400; return "LOG_" + String(day) + ".CSV"; } Советы по надёжности Arduino-проектов1. Сторожевой таймер (Watchdog)#include <avr/wdt.h> void setup() { wdt_enable(WDTO_2S); // Сброс если loop() не выполняется 2 секунды } void loop() { wdt_reset(); // Сброс таймера — "я ещё живой" // ... ваш код } 2. EEPROM для сохранения настроек#include <EEPROM.h> struct Settings { float setpoint; uint16_t interval; uint8_t mode; uint16_t checksum; }; void saveSettings(const Settings& s) { Settings toSave = s; // Простая контрольная сумма toSave.checksum = (uint16_t)(s.setpoint * 100) + s.interval + s.mode; EEPROM.put(0, toSave); } bool loadSettings(Settings& s) { EEPROM.get(0, s); uint16_t expected = (uint16_t)(s.setpoint * 100) + s.interval + s.mode; return (s.checksum == expected); // Данные корректны? } 3. Ограничение частоты Serial// НЕ ПИШИТЕ В Serial КАЖДЫЙ ЦИКЛ LOOP! // При 115200 бод запись 100 байт занимает ~8мс void printPeriodic(float value) { static uint32_t lastPrint = 0; if (millis() - lastPrint >= 200) { // Максимум 5 раз в секунду lastPrint = millis(); Serial.println(value); } } ЗаключениеArduino — это не учебная игрушка, это платформа. Разница между "поделкой" и "устройством" — не в железе, а в качестве кода. Ключевые принципы: никогда не используйте delay() в итоговом устройстве, структурируйте код как набор независимых задач через millis(), используйте конечные автоматы для сложной логики, добавляйте watchdog timer и защиты, документируйте код. С этими принципами Arduino становится полноценным инструментом для создания надёжных устройств — от умного дома до промышленных узлов сбора данных.
-
STM32 с нуля: практическое руководство для тех, кто вырос из Arduino
Почему STM32, а не продолжать на ArduinoArduino — отличный старт. Но в какой-то момент вы упираетесь в потолок: скорость 16 МГц не хватает, Flash/RAM заканчивается, нужны возможности которых у AVR нет — несколько UART, USB Device, Ethernet MAC, криптоускоритель, DSP-инструкции. STM32 — это семейство 32-битных микроконтроллеров от STMicroelectronics на базе ядер ARM Cortex-M. Характеристики даже бюджетного STM32F103C8T6 ("Blue Pill"): Параметр Arduino Uno (ATmega328P) STM32F103C8T6 Ядро AVR 8-бит ARM Cortex-M3 32-бит Тактовая частота 16 МГц 72 МГц Flash 32 КБ 64 КБ RAM 2 КБ 20 КБ GPIO 23 37 ADC 6 × 10-бит 10 × 12-бит Таймеры 3 7 SPI / I2C / UART 1 / 1 / 1 2 / 2 / 3 USB Нет Full-Speed USB 2.0 Цена ~$3–5 (оригинал) ~$0.8–2 И это самый простой STM32. Линейки F4, F7, H7 — ещё на порядок мощнее. Семейства STM32: как не запутатьсяSTM32 делится на несколько линеек по ядру и позиционированию: STM32F0/F1/F3 — Базовые (Cortex-M0/M3/M4) F103: самый популярный, "Blue Pill", 72 МГц — идеален для старта F303: F3 с матфлоатом и операционными усилителями внутри F030/F042: ультрадешёвые, от $0.3, для массового производства STM32F4 — Производительные (Cortex-M4F с FPU) F401/F411: 84–100 МГц, USB, хороший баланс F407/F429: 168 МГц, Ethernet MAC, FMC для внешней SDRAM, камеры Популярны для DSP-задач, аудио, обработки изображений STM32F7/H7 — Высокопроизводительные (Cortex-M7) H743: 480 МГц, двойная точность float, L1-кэш, умереть не встать Используются в промышленных системах реального времени STM32L — Низкое энергопотребление (Low Power) L051/L071: ток в sleep < 1 мкА, для батарейных устройств STM32G/U — Новые серии (2019–2022) G431/G474: отличные для силовой электроники (Timer1 с мёртвым временем) U5: Cortex-M33 с TrustZone, IoT-безопасность Рекомендации для старта: STM32F103C8T6 (Blue Pill) или STM32G031 для новых проектов. Настройка среды разработкиSTM32CubeIDE + STM32CubeMXSTM32CubeIDE — официальная бесплатная среда от ST. Включает: Eclipse-based IDE Компилятор GCC ARM OpenOCD для программирования/отладки Встроенный STM32CubeMX для генерации кода инициализации Установка: Скачать STM32CubeIDE с сайта st.com (требует регистрации, бесплатно) Установить, выбрать пакеты для нужных семейств Подключить программатор ST-Link V2 ($3–5 на AliExpress) Первое подключение (Blue Pill → ST-Link V2):ST-Link V2 Blue Pill SWDIO → PA13 SWCLK → PA14 GND → GND 3.3V → 3V3 Важно: На оригинальных Blue Pill загрузчик прошит неправильно. Для работы с ST-Link через SWD это не проблема — программируем напрямую в flash. HAL vs LL: что выбратьSTMicroelectronics предоставляет два уровня библиотек: HAL (Hardware Abstraction Layer): Высокоуровневый, максимально переносимый код Автоматически генерируется CubeMX Проще в использовании, больше overhead Рекомендуется для большинства проектов LL (Low Layer): Тонкие обёртки над регистрами, почти без overhead Максимальная производительность и предсказуемость Нужно хорошее знание периферии Для критичного по времени кода Смешанный подход (лучший для опытных): HAL для инициализации (CubeMX генерирует) LL для критичных по времени операций в прерываниях GPIO: мигаем светодиодом правильноCubeMX генерирует такой код инициализации GPIO: // Автосгенерированный код CubeMX static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOC_CLK_ENABLE(); // Включаем тактирование порта C // Настройка PC13 как выход (встроенный LED на Blue Pill) GPIO_InitStruct.Pin = GPIO_PIN_13; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // Push-Pull выход GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 2 МГц, достаточно для LED HAL_GPIO_Init(GPIOC, &GPIO_InitStruct); // Входной сигнал на PA0 (кнопка) GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; // Внутренний pull-up HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); } Управление GPIO в программе: // Включить / выключить / переключить HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // LED on (active low) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // LED off HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); // Toggle // Читать состояние входа GPIO_PinState btn = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); if (btn == GPIO_PIN_RESET) { // Кнопка нажата (pull-up, нажатие — к GND) HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); } GPIO режимы:GPIO_MODE_OUTPUT_PP — Push-Pull выход (стандартный) GPIO_MODE_OUTPUT_OD — Open-Drain (для I2C, совместимость 5В) GPIO_MODE_INPUT — Вход GPIO_MODE_IT_RISING/FALLING/RISING_FALLING — Вход с прерыванием GPIO_MODE_AF_PP — Альтернативная функция (UART, SPI, Timer...) GPIO_MODE_ANALOG — Аналоговый режим (для ADC/DAC) UART: последовательная связь// Инициализация UART1 на PA9(TX)/PA10(RX), 115200 бод static void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); } // Отправка данных (блокирующий режим) char msg[] = "Hello STM32!\r\n"; HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), 100); // timeout 100ms // Приём (с таймаутом) uint8_t rx_buf[64]; uint16_t bytes_received; HAL_StatusTypeDef status = HAL_UART_Receive(&huart1, rx_buf, 64, 1000); // printf через UART (настройка retarget) // В файле syscalls.c добавить: int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 100); return ch; } // После этого можно использовать printf! printf("Температура: %.2f°C\r\n", temperature); Приём через прерывания (правильный подход):// Буфер приёма и флаги #define RX_BUFFER_SIZE 256 static uint8_t rx_buffer[RX_BUFFER_SIZE]; static uint8_t rx_byte; // Один байт для приёма по прерыванию static uint16_t rx_index = 0; static volatile uint8_t line_ready = 0; // Запускаем прием одного байта в прерывании // (вызвать после инициализации и после каждого приёма) void UART_StartReceive(void) { HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } // Callback — вызывается автоматически при приёме байта void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_byte == '\n' || rx_index >= RX_BUFFER_SIZE - 1) { rx_buffer[rx_index] = '\0'; rx_index = 0; line_ready = 1; // Сигнализируем что строка готова } else if (rx_byte != '\r') { rx_buffer[rx_index++] = rx_byte; } // Запускаем следующий приём HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } } // В главном цикле: if (line_ready) { line_ready = 0; printf("Получено: %s\r\n", rx_buffer); process_command((char*)rx_buffer); } Таймеры: ШИМ и точное времяТаймеры STM32 — мощнейшая периферия. Используются для ШИМ, измерения частоты, генерации прерываний, управления сервоприводами. ШИМ (PWM) для управления яркостью/скоростью:// TIM3, Channel 1, PA6, частота 1 кГц // Настройка через CubeMX, затем в коде: // Запуск ШИМ HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // Изменение скважности (0–999 для ARR=999) // 500 = 50% скважности __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 500); // Плавное изменение яркости LED for (int i = 0; i <= 999; i++) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, i); HAL_Delay(1); } for (int i = 999; i >= 0; i--) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, i); HAL_Delay(1); } Управление сервоприводом (50 Гц, 1–2 мс):// TIM2, 50 Гц (период 20 мс = 20000 тиков при предделителе 72-1) // ARR = 19999, PSC = 71 → 1 тик = 1 мкс #define SERVO_MIN_US 1000 // 1 мс = левый предел #define SERVO_MAX_US 2000 // 2 мс = правый предел #define SERVO_MID_US 1500 // 1.5 мс = центр void servo_set_angle(int angle_degrees) // 0–180 градусов { // Линейное масштабирование угла → ширина импульса uint32_t pulse = SERVO_MIN_US + (uint32_t)(angle_degrees * (SERVO_MAX_US - SERVO_MIN_US) / 180); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse); } // Использование: servo_set_angle(0); // Левый предел HAL_Delay(1000); servo_set_angle(90); // Центр HAL_Delay(1000); servo_set_angle(180); // Правый предел Прерывание по таймеру (точный период):// Прерывание каждые 1 мс от TIM6 (базовый таймер) // Запуск: HAL_TIM_Base_Start_IT(&htim6); // Callback: void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { system_tick_ms++; // Собственный миллисекундный счётчик // Задачи каждые 10 мс if (system_tick_ms % 10 == 0) { adc_trigger_conversion(); } // Задачи каждые 1000 мс if (system_tick_ms % 1000 == 0) { led_heartbeat_toggle(); } } } АЦП: чтение аналоговых сигналов// Одиночное преобразование (блокирующий режим) uint32_t adc_read_single(void) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); // Ждём не более 10 мс uint32_t value = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return value; // 0–4095 для 12-бит АЦП } // Перевод в напряжение (опорное 3.3В): float adc_to_voltage(uint32_t raw) { return raw * 3.3f / 4095.0f; } // Перевод в температуру для NTC-термистора (10кОм, B=3950): float ntc_to_celsius(uint32_t raw_adc) { float voltage = adc_to_voltage(raw_adc); float resistance = 10000.0f * voltage / (3.3f - voltage); // Делитель с 10кОм // Уравнение Стейнхарта-Харта (упрощённое) float steinhart; steinhart = resistance / 10000.0f; // R/Rnom steinhart = logf(steinhart); // ln(R/Rnom) steinhart /= 3950.0f; // / B steinhart += 1.0f / (25.0f + 273.15f); // + 1/T0 steinhart = 1.0f / steinhart; // Инверсия return steinhart - 273.15f; // Кельвин → Цельсий } АЦП с DMA (несколько каналов, без участия CPU):// Настройка: ADC + DMA, Continuous mode, 4 канала (PA0-PA3) #define ADC_CHANNELS 4 static uint16_t adc_dma_buffer[ADC_CHANNELS]; // Запуск: HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buffer, ADC_CHANNELS); // DMA автоматически обновляет буфер! // В основном цикле просто читаем: float temp = ntc_to_celsius(adc_dma_buffer[0]); float pressure = adc_dma_buffer[1] * 3.3f / 4095.0f; // Callback при завершении преобразований всех каналов: void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // Все 4 канала обновлены adc_data_ready = 1; } I2C: подключение датчиков// Пример: датчик давления/температуры BMP280 #define BMP280_ADDR 0x76 << 1 // 7-бит адрес, сдвиг влево для HAL // Читать регистр uint8_t BMP280_ReadReg(uint8_t reg) { uint8_t value; HAL_I2C_Mem_Read(&hi2c1, BMP280_ADDR, // Адрес устройства reg, // Адрес регистра I2C_MEMADD_SIZE_8BIT, &value, // Буфер 1, // Количество байт 100); // Таймаут return value; } // Записать регистр void BMP280_WriteReg(uint8_t reg, uint8_t value) { HAL_I2C_Mem_Write(&hi2c1, BMP280_ADDR, reg, I2C_MEMADD_SIZE_8BIT, &value, 1, 100); } // Читать несколько байт подряд void BMP280_ReadBurst(uint8_t reg, uint8_t *buf, uint8_t len) { HAL_I2C_Mem_Read(&hi2c1, BMP280_ADDR, reg, I2C_MEMADD_SIZE_8BIT, buf, len, 100); } // Инициализация BMP280 void BMP280_Init(void) { // Проверка ID (должен быть 0x60) uint8_t id = BMP280_ReadReg(0xD0); if (id != 0x60) { printf("BMP280 не найден! ID=0x%02X\r\n", id); return; } // Нормальный режим, oversampling ×4 для давления и температуры BMP280_WriteReg(0xF4, 0x97); // ctrl_meas: осsp×4, osst×4, normal mode BMP280_WriteReg(0xF5, 0xA0); // config: t_sb=1000мс, filter=16 } // Чтение данных (упрощённо, без компенсации) typedef struct { float temperature; float pressure; } BMP280_Data; BMP280_Data BMP280_ReadData(void) { uint8_t raw[6]; BMP280_ReadBurst(0xF7, raw, 6); // press_msb, press_lsb, press_xlsb, temp×3 int32_t raw_press = ((int32_t)raw[0] << 12) | ((int32_t)raw[1] << 4) | (raw[2] >> 4); int32_t raw_temp = ((int32_t)raw[3] << 12) | ((int32_t)raw[4] << 4) | (raw[5] >> 4); // Реальный код должен использовать калибровочные коэффициенты из регистров! // Это упрощение для иллюстрации BMP280_Data data; data.temperature = raw_temp / 5120.0f; // Очень грубо! data.pressure = raw_press / 25600.0f; // Очень грубо! return data; } SPI: быстрая связь с периферией// SPI — быстрее I2C, до 45 МГц на STM32F4 // Пример: дисплей ST7735 (128×160 пикселей) // CS-пин вручную (NSS в software-режиме) #define LCD_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define LCD_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define LCD_DC_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_RESET) // Command #define LCD_DC_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_SET) // Data void LCD_SendCommand(uint8_t cmd) { LCD_DC_LOW(); LCD_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); LCD_CS_HIGH(); } void LCD_SendData(uint8_t *data, uint16_t len) { LCD_DC_HIGH(); LCD_CS_LOW(); HAL_SPI_Transmit(&hspi1, data, len, 1000); LCD_CS_HIGH(); } // Быстрая передача через DMA (не блокирует CPU) void LCD_SendDataDMA(uint8_t *data, uint16_t len) { LCD_DC_HIGH(); LCD_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, data, len); // CS поднимется в callback после завершения DMA } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { LCD_CS_HIGH(); dma_complete = 1; } } Структура хорошего проекта на STM32project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ └── stm32f1xx_hal_conf.h │ └── Src/ │ ├── main.c — Только инициализация + main loop │ ├── stm32f1xx_it.c — Обработчики прерываний │ └── syscalls.c — printf retarget ├── Drivers/ │ ├── CMSIS/ — ARM заголовки, системный файл │ └── STM32F1xx_HAL_Driver/ — HAL библиотека (не трогать) ├── App/ — ВАШ КОД здесь! │ ├── sensors/ │ │ ├── bmp280.c / .h │ │ └── ntc.c / .h │ ├── control/ │ │ ├── pid.c / .h │ │ └── state_machine.c / .h │ ├── comm/ │ │ ├── modbus_slave.c / .h │ │ └── protocol.c / .h │ └── app.c — Главная логика приложения └── CMakeLists.txt / .ioc — Конфигурация проекта Принцип: main.c содержит только вызов App_Init() и App_Run(). Вся логика — в директории App/. Типичные ошибки и как их избежать1. Забыли включить тактирование периферии // Без этого HAL_GPIO_Init ничего не сделает! __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); // CubeMX делает это автоматически — используйте его для инициализации 2. Неправильные Alternate Functions STM32F1 имеет фиксированный маппинг пинов. STM32F4+ — гибкий (GPIO_AF1_..., GPIO_AF7_...). Смотрите datasheet, раздел "Alternate function mapping". 3. Блокирующие задержки в прерываниях // НЕЛЬЗЯ! HAL_Delay использует SysTick-прерывание низшего приоритета void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { HAL_Delay(100); // Зависнет если прерывание выше приоритета SysTick! } 4. Запись в HAL-переменную напрямую Не изменяйте поля структур huart1, htim2 вручную в рантайме — используйте API функции. 5. Stack Overflow STM32F103 имеет только 20 КБ RAM. Большие массивы на стеке — прямой путь к Hard Fault. Объявляйте большие буферы как глобальные или статические. Отладка: когда всё идёт не такHard Fault Handler — ваш лучший другvoid HardFault_Handler(void) { // Получаем регистры состояния __asm volatile("TST lr, #4\n" "ITE EQ\n" "MRSEQ r0, MSP\n" "MRSNE r0, PSP\n" "B HardFault_HandlerC"); } void HardFault_HandlerC(uint32_t *stack_frame) { printf("=== HARD FAULT ===\r\n"); printf("PC = 0x%08lX\r\n", stack_frame[6]); // Адрес проблемной инструкции printf("LR = 0x%08lX\r\n", stack_frame[5]); printf("CFSR= 0x%08lX\r\n", SCB->CFSR); // Причина fault while(1); } SWO (Serial Wire Output) — printf без UARTЕсли UART занят, используйте SWO для отладочного вывода. В STM32CubeIDE включается за 5 кликов, вывод идёт в консоль IDE без дополнительного кабеля. ЗаключениеSTM32 — это настоящий профессиональный инструмент. Первые шаги сложнее чем с Arduino: нужно настроить тактирование, разобраться с HAL, понять концепцию прерываний и DMA. Но это окупается многократно: производительность, периферия, потребление, цена в серийном производстве. Рекомендуемый путь: STM32F103 Blue Pill + ST-Link V2 + STM32CubeIDE. Первый проект — мигание LED через прерывание таймера. Второй — чтение кнопки с антидребезгом. Третий — датчик по I2C с выводом в UART. К четвёртому проекту вы уже будете чувствовать себя уверенно. Документация ST: datasheet, Reference Manual, Programming Manual — читайте их. Они написаны хорошо и содержат ответы на все вопросы.
-
Протокол Modbus: полное практическое руководство
Почему Modbus жив и актуален спустя 45 летModbus был разработан компанией Modicon в 1979 году для связи ПЛК по последовательной шине. Прошло почти полвека — а протокол по-прежнему является самым распространённым в промышленной автоматизации. По различным оценкам, более 30 миллионов устройств в мире используют Modbus. Секрет долголетия прост: протокол исключительно прост в понимании и реализации. Он работает по схеме "мастер-слейв", имеет открытую спецификацию, не требует лицензирования, поддерживается абсолютно всеми промышленными устройствами. Сегодня существуют три основные реализации: Modbus RTU — по последовательной шине (RS-232, RS-485) Modbus ASCII — текстовое представление, устарело Modbus TCP — поверх TCP/IP Ethernet Архитектура: мастер и слейвыModbus — строго мастер-слейв (Master-Slave) протокол: Мастер (Master): Инициирует все запросы. Только один мастер в сети. Обычно это ПЛК, SCADA-сервер, промышленный компьютер. Слейв (Slave): Отвечает на запросы мастера. В сети RS-485 может быть до 247 слейвов с адресами 1–247. Адрес 0 — широковещательный (слейвы не отвечают). Важно: Слейв никогда не инициирует передачу! Он только отвечает. Мастер Слейв 1 Слейв 2 Слейв 3 | | | | |── Request → Addr=1 ─────>| | | |<── Response ────────────| | | | | | | |── Request → Addr=2 ──────────────>| | |<── Response ──────────────────────| | Типы данных (регистры)Modbus оперирует четырьмя типами данных: 1. Coils (Coil Status) — Дискретные выходыРазмер: 1 бит Доступ: Чтение и запись мастером Функциональные коды: FC01 (читать), FC05 (записать один), FC15 (записать несколько) Адреса: 00001–09999 (в Modbus-нотации), 0x0000–0xFFFF (в PDU) Применение: Состояния реле, клапанов, двигателей 2. Discrete Inputs (Input Status) — Дискретные входыРазмер: 1 бит Доступ: Только чтение мастером (данные поступают от физических входов) Функциональные коды: FC02 (читать) Адреса: 10001–19999 Применение: Состояния кнопок, датчиков, концевиков 3. Holding Registers — Регистры храненияРазмер: 16 бит (2 байта), беззнаковое целое 0–65535 Доступ: Чтение и запись мастером Функциональные коды: FC03 (читать), FC06 (записать один), FC16 (записать несколько) Адреса: 40001–49999 Применение: Уставки, параметры настройки, команды управления 4. Input Registers — Входные регистрыРазмер: 16 бит Доступ: Только чтение мастером Функциональные коды: FC04 (читать) Адреса: 30001–39999 Применение: Измеренные значения датчиков, счётчики Хранение чисел с плавающей точкой16 бит для float недостаточно. Для передачи float используют два последовательных регистра (32 бит = IEEE 754): Регистр 40001 (HIGH word): первые 16 бит float Регистр 40002 (LOW word): вторые 16 бит float Значение 3.14159: IEEE 754: 0x40490FDB HIGH: 0x4049 LOW: 0x0FDB Важная ловушка: порядок байт и слов (endianness) различается у разных производителей! Четыре варианта: Big-Endian, Little-Endian, Big-Endian Byte Swap, Little-Endian Byte Swap. Смотрите документацию устройства. Modbus RTU: структура фреймаRTU (Remote Terminal Unit) — бинарный формат, максимально компактный. ┌─────────┬──────────────┬──────┬────────────────────┬───────┐ │ Address │ Function Code│ Data │ Data │ CRC │ │ 1 байт │ 1 байт │ ... │ ... │ 2 байт│ └─────────┴──────────────┴──────┴────────────────────┴───────┘ Пример запроса FC03 (читать Holding Registers): Запрос: "Слейв №1, дай мне 2 регистра начиная с адреса 0x0064 (100)" 01 03 00 64 00 02 85 D5 01 — Адрес слейва 03 — Код функции (читать Holding Registers) 00 64 — Начальный адрес (0x0064 = 100) 00 02 — Количество регистров (2) 85 D5 — CRC16 (контрольная сумма) Ответ слейва: 01 03 04 01 F4 00 0A 2B 11 01 — Адрес слейва 03 — Код функции 04 — Количество байт данных (2 регистра × 2 байта = 4) 01 F4 — Значение регистра 100 (0x01F4 = 500) 00 0A — Значение регистра 101 (0x000A = 10) 2B 11 — CRC16 Ответ при ошибке (Exception Response): 01 83 02 C0 F1 01 — Адрес слейва 83 — Код функции + 0x80 (признак ошибки) 02 — Код исключения (02 = Illegal Data Address) C0 F1 — CRC16 Коды исключений:Код Название Описание 01 Illegal Function Устройство не поддерживает данный FC 02 Illegal Data Address Запрошенный адрес не существует 03 Illegal Data Value Недопустимое значение данных 04 Server Device Failure Внутренняя ошибка устройства 06 Server Device Busy Устройство занято, повторите позже Расчёт CRC16CRC16 (Cyclic Redundancy Check) — контрольная сумма для обнаружения ошибок передачи. Алгоритм несложный, но важный: uint16_t ModbusCRC16(uint8_t *buffer, uint16_t length) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < length; i++) { crc ^= (uint16_t)buffer[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; // Полином Modbus } else { crc >>= 1; } } } return crc; // Младший байт первый в фрейме! } // Использование: uint8_t frame[] = {0x01, 0x03, 0x00, 0x64, 0x00, 0x02}; uint16_t crc = ModbusCRC16(frame, 6); // Добавить в конец фрейма: (crc & 0xFF), (crc >> 8) Важно: В Modbus RTU CRC передаётся младшим байтом вперёд (Little-Endian)! RS-485: физический уровеньModbus RTU работает поверх RS-485 — дифференциальной последовательной шины. Параметры сети:Длина: до 1200 м (при скорости 9600 бод), до 100 м (при 115200 бод) Устройств: до 32 без репитеров, до 247 с репитерами Скорости: 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200 бод Линия: витая пара, лучше экранированная (STP) Терминаторы: 120 Ом на каждом конце шины Топология — только шина!Мастер ──────┬──────────┬──────────┬──────── Терминатор 120Ом 120Ом ───┘ Слейв1 Слейв2 Слейв3 НЕЛЬЗЯ делать "звезду" — отражения сигнала разрушат связь. Типичные проблемы RS-485:Проблема 1: Нет терминирующих резисторов Симптомы: связь работает на малой скорости, не работает на высокой. Или работает с одним устройством, не работает с несколькими. Решение: 120 Ом строго на двух концах шины — и только там. Проблема 2: Земля не подключена RS-485 — дифференциальный сигнал (A-B), не требует общего провода теоретически. Практически — без общей земли при большой разнице потенциалов (грозозащита, разные здания) трансиверы сгорают. Третий провод "GND" обязателен. Проблема 3: Смешаны A и B Сигнальные линии перепутаны местами. Ошибка типичная при ручном монтаже. Симптом: нет ответа вообще или постоянные ошибки CRC. Проблема 4: Нет pull-up/pull-down резисторов на линии Когда все устройства молчат (пауза между транзакциями), линия "висит в воздухе". Нужны подтягивающие резисторы: A через ~560 Ом на +5В, B через ~560 Ом на GND. Многие USB-RS485 адаптеры имеют их встроенными. Modbus TCP: Ethernet-версияModbus TCP — это Modbus RTU без адреса устройства и без CRC, завёрнутый в TCP/IP пакет. Структура Modbus TCP фрейма:┌─────────────┬──────────────────────────────────────────────┐ │ MBAP Header (7 байт) │ PDU (Protocol Data Unit) │ ├──────┬───────┬──────────┬───────┬──────────────────────────┤ │TrID │ Proto │ Length │ UnitID│ FC │ Data │ │2 байт│ 2 байт│ 2 байта │ 1 байт│1 байт│ N байт │ └──────┴───────┴──────────┴───────┴──────┴──────────────────┘ TrID — Transaction Identifier (любое число, повторяется в ответе) Proto — 0x0000 (всегда) Length — длина оставшейся части (Unit ID + FC + Data) UnitID — адрес устройства (для RTU-TCP шлюзов) Порт: 502 (стандартный, зарезервирован IANA) TCP обеспечивает надёжную доставку — CRC не нужен. Но помните: TCP не обеспечивает реальное время. Задержки могут варьироваться от единиц миллисекунд до нескольких секунд при перегрузке сети. Практика: Modbus на PythonБиблиотека pymodbus (полноценная реализация)# Установка: pip install pymodbus from pymodbus.client import ModbusTcpClient, ModbusSerialClient import struct import time # ========== MODBUS TCP ========== def read_vfd_status_tcp(host: str, port: int = 502, unit_id: int = 1) -> dict: """ Читаем параметры частотника по Modbus TCP. Пример для ABB ACS550. """ client = ModbusTcpClient(host=host, port=port, timeout=3) if not client.connect(): raise ConnectionError(f"Не удалось подключиться к {host}:{port}") try: # Читаем Input Registers 30001-30006 (адрес 0-5) result = client.read_input_registers( address=0, # Начальный адрес (0 = регистр 30001) count=6, # Количество регистров slave=unit_id ) if result.isError(): raise Exception(f"Ошибка Modbus: {result}") regs = result.registers return { 'status_word': regs[0], # Слово состояния 'speed_rpm': regs[1], # Скорость об/мин 'frequency_hz': regs[2] / 100.0, # Частота (0.01 Гц) 'current_a': regs[3] / 10.0, # Ток (0.1 А) 'voltage_v': regs[4], # Напряжение 'power_kw': regs[5] / 10.0, # Мощность (0.1 кВт) 'running': bool(regs[0] & 0x0001), 'fault': bool(regs[0] & 0x0008), } finally: client.close() def write_vfd_command_tcp(host: str, freq_hz: float, run: bool, unit_id: int = 1): """Управление частотником через Modbus TCP""" client = ModbusTcpClient(host=host, port=502, timeout=3) if not client.connect(): raise ConnectionError(f"Не удалось подключиться") try: # Задаём частоту (Holding Register 40002, адрес 1) freq_raw = int(freq_hz * 100) # 50.00 Гц → 5000 client.write_register(address=1, value=freq_raw, slave=unit_id) # Команда пуск/стоп (Holding Register 40001, адрес 0) control_word = 0x0002 if run else 0x0001 # 2=Run, 1=Stop client.write_register(address=0, value=control_word, slave=unit_id) print(f"VFD: {'Пуск' if run else 'Стоп'}, частота {freq_hz} Гц") finally: client.close() # ========== MODBUS RTU ========== def create_rtu_client(port: str, baudrate: int = 9600) -> ModbusSerialClient: """Создаём RTU клиент для RS-485""" return ModbusSerialClient( port=port, # '/dev/ttyUSB0' или 'COM3' baudrate=baudrate, bytesize=8, parity='N', # N=None, E=Even, O=Odd stopbits=1, timeout=1.0 ) def scan_modbus_rtu_network(port: str, baudrate: int = 9600) -> list: """ Сканирование Modbus RTU сети — ищем все активные устройства. Возвращает список адресов ответивших устройств. """ client = create_rtu_client(port, baudrate) client.connect() found_devices = [] print(f"Сканирование Modbus RTU на {port}, {baudrate} бод...") for address in range(1, 248): try: # Пробуем прочитать 1 регистр — если устройство есть, оно ответит result = client.read_holding_registers(0, 1, slave=address) if not result.isError(): found_devices.append(address) print(f" ✅ Найдено устройство: адрес {address}") except Exception: pass # Таймаут — устройства нет time.sleep(0.05) # Пауза между запросами client.close() print(f"Найдено устройств: {len(found_devices)}") return found_devices # ========== РАБОТА С FLOAT ========== def registers_to_float(high_reg: int, low_reg: int, byte_order: str = 'big') -> float: """ Конвертация двух Modbus-регистров в float IEEE 754. byte_order: 'big' (ABCD), 'little' (DCBA), 'big_swap' (BADC), 'little_swap' (CDAB) """ if byte_order == 'big': raw = struct.pack('>HH', high_reg, low_reg) elif byte_order == 'little': raw = struct.pack('<HH', low_reg, high_reg) elif byte_order == 'big_swap': raw = struct.pack('>HH', ((high_reg & 0xFF) << 8) | (high_reg >> 8), ((low_reg & 0xFF) << 8) | (low_reg >> 8)) else: raw = struct.pack('>HH', high_reg, low_reg) return struct.unpack('>f', raw)[0] def float_to_registers(value: float, byte_order: str = 'big') -> tuple: """Конвертация float в два Modbus-регистра""" raw = struct.pack('>f', value) high_reg, low_reg = struct.unpack('>HH', raw) if byte_order == 'big': return high_reg, low_reg elif byte_order == 'little': return low_reg, high_reg return high_reg, low_reg # ========== ПРИМЕР OPROS SCADA ========== class ModbusDataCollector: """ Циклический опрос Modbus-устройств для SCADA/мониторинга. """ def __init__(self, host: str): self.client = ModbusTcpClient(host=host, port=502, timeout=5) self.data = {} def poll_all_devices(self) -> dict: """Опросить все устройства и вернуть данные""" if not self.client.connect(): return {'error': 'connection_failed'} try: results = {} # Насос 1 (Unit ID = 1) pump1 = self.client.read_input_registers(0, 4, slave=1) if not pump1.isError(): r = pump1.registers results['pump1'] = { 'running': bool(r[0] & 1), 'fault': bool(r[0] & 8), 'freq_hz': r[1] / 100.0, 'current_a': r[2] / 10.0, 'power_kw': r[3] / 10.0, } # Датчик давления (Unit ID = 5, счётчик давления) pressure = self.client.read_input_registers(0, 2, slave=5) if not pressure.isError(): r = pressure.registers results['pressure_bar'] = registers_to_float(r[0], r[1]) # Расходомер (Unit ID = 6) flow = self.client.read_input_registers(0, 4, slave=6) if not flow.isError(): r = flow.registers results['flow'] = { 'instant_m3h': registers_to_float(r[0], r[1]), 'total_m3': registers_to_float(r[2], r[3]), } return results finally: self.client.close() # ========== ЗАПУСК ========== if __name__ == "__main__": # Пример использования collector = ModbusDataCollector('192.168.1.100') while True: data = collector.poll_all_devices() print(f"Давление: {data.get('pressure_bar', 0):.2f} бар") print(f"Насос 1: {'Работает' if data.get('pump1', {}).get('running') else 'Стоит'}, " f"{data.get('pump1', {}).get('freq_hz', 0):.1f} Гц") time.sleep(1) Реализация Modbus Slave на микроконтроллере (C)Иногда нужно сделать собственное устройство с Modbus-интерфейсом. Вот минимальная реализация для STM32/Arduino: #include <stdint.h> #include <string.h> #define MODBUS_ADDRESS 1 // Адрес нашего устройства #define HOLDING_REG_COUNT 20 // Количество Holding Registers #define INPUT_REG_COUNT 10 // Количество Input Registers // Данные регистров static uint16_t holding_regs[HOLDING_REG_COUNT] = {0}; static uint16_t input_regs[INPUT_REG_COUNT] = {0}; // Расчёт CRC16 static uint16_t crc16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 1) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; } // Отправка ответа (реализуйте под вашу платформу) extern void uart_send(uint8_t *data, uint16_t len); // Обработка Modbus запроса void modbus_process_request(uint8_t *request, uint16_t req_len) { uint8_t response[256]; uint16_t resp_len = 0; // Проверяем адрес if (request[0] != MODBUS_ADDRESS) return; // Проверяем CRC uint16_t received_crc = (request[req_len-1] << 8) | request[req_len-2]; uint16_t calculated_crc = crc16(request, req_len - 2); if (received_crc != calculated_crc) return; // CRC ошибка — игнорируем uint8_t fc = request[1]; uint16_t addr = (request[2] << 8) | request[3]; uint16_t count = (request[4] << 8) | request[5]; response[resp_len++] = MODBUS_ADDRESS; response[resp_len++] = fc; switch (fc) { case 0x03: // Читать Holding Registers { if (addr + count > HOLDING_REG_COUNT) { // Исключение: неверный адрес response[1] = fc | 0x80; response[resp_len++] = 0x02; break; } response[resp_len++] = count * 2; // Количество байт for (uint16_t i = 0; i < count; i++) { response[resp_len++] = holding_regs[addr + i] >> 8; response[resp_len++] = holding_regs[addr + i] & 0xFF; } break; } case 0x04: // Читать Input Registers { if (addr + count > INPUT_REG_COUNT) { response[1] = fc | 0x80; response[resp_len++] = 0x02; break; } response[resp_len++] = count * 2; for (uint16_t i = 0; i < count; i++) { response[resp_len++] = input_regs[addr + i] >> 8; response[resp_len++] = input_regs[addr + i] & 0xFF; } break; } case 0x06: // Записать один Holding Register { if (addr >= HOLDING_REG_COUNT) { response[1] = fc | 0x80; response[resp_len++] = 0x02; break; } holding_regs[addr] = (request[4] << 8) | request[5]; // Эхо запроса как ответ memcpy(response + 2, request + 2, 4); resp_len += 4; break; } case 0x10: // Записать несколько Holding Registers { if (addr + count > HOLDING_REG_COUNT) { response[1] = fc | 0x80; response[resp_len++] = 0x02; break; } uint8_t byte_count = request[6]; for (uint16_t i = 0; i < count; i++) { holding_regs[addr + i] = (request[7 + i*2] << 8) | request[8 + i*2]; } // Ответ: адрес и количество записанных регистров response[resp_len++] = request[2]; response[resp_len++] = request[3]; response[resp_len++] = request[4]; response[resp_len++] = request[5]; break; } default: { // Неизвестная функция response[1] = fc | 0x80; response[resp_len++] = 0x01; // Illegal Function break; } } // Добавляем CRC uint16_t resp_crc = crc16(response, resp_len); response[resp_len++] = resp_crc & 0xFF; response[resp_len++] = resp_crc >> 8; // Отправляем ответ uart_send(response, resp_len); } // Обновление Input Registers из реальных данных void modbus_update_inputs(uint16_t temp_x10, uint16_t pressure_x100, uint16_t status_bits) { input_regs[0] = temp_x10; // Температура × 10 (250 = 25.0°C) input_regs[1] = pressure_x100; // Давление × 100 (1013 = 10.13 бар) input_regs[2] = status_bits; // Биты состояния } Диагностика сети Modbus: практические инструментыWireshark для Modbus TCPWireshark понимает Modbus TCP "из коробки". Фильтр для захвата: modbus.func_code — фильтр по функциональному коду tcp.port == 502 — весь Modbus TCP трафик modbus.exception_code — только ошибки ModRSsim2 / Diagslave — эмуляторы слейваНезаменимы при разработке — тестируете мастер без реального оборудования. Свой анализатор на Python:import socket import struct def modbus_tcp_sniffer(host: str, port: int = 502): """Простой анализатор Modbus TCP запросов""" FC_NAMES = { 1: 'Read Coils', 2: 'Read Discrete Inputs', 3: 'Read Holding Registers', 4: 'Read Input Registers', 5: 'Write Single Coil', 6: 'Write Single Register', 15: 'Write Multiple Coils', 16: 'Write Multiple Registers', } sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) while True: # Читаем MBAP-заголовок (7 байт) header = sock.recv(7) if len(header) < 7: break transaction_id = struct.unpack('>H', header[0:2])[0] protocol_id = struct.unpack('>H', header[2:4])[0] length = struct.unpack('>H', header[4:6])[0] unit_id = header[6] # Читаем PDU pdu = sock.recv(length - 1) fc = pdu[0] fc_name = FC_NAMES.get(fc, f'Unknown FC {fc}') if fc < 0x80: # Запрос или нормальный ответ if len(pdu) >= 5: addr = struct.unpack('>H', pdu[1:3])[0] count = struct.unpack('>H', pdu[3:5])[0] print(f"[{transaction_id}] Unit={unit_id} | {fc_name} | " f"Addr={addr} Count={count}") else: # Ошибка exc_code = pdu[1] print(f"[{transaction_id}] EXCEPTION: FC={fc & 0x7F} Code={exc_code}") Типичные проблемы и решения"Устройство не отвечает"Алгоритм диагностики: Проверьте физику: контакты, полярность A/B, терминаторы Проверьте параметры порта: baudrate, parity, stopbits — должны совпадать с устройством Проверьте адрес — он правильно задан в устройстве? Не все устройства имеют адрес "1" по умолчанию Используйте осциллограф или логический анализатор — видны ли данные на линии? Попробуйте другой кабель "Иногда не отвечает, CRC-ошибки"Слишком длинная линия без репитера Отсутствуют или не там стоят терминаторы Несколько устройств с одинаковым адресом Помехи от силового оборудования — проложите кабель отдельно "Данные неверные"Неправильный порядок байт (endianness) для float Смещение адреса: в документации адреса часто указываются с 1 (40001), а в запросе нужно с 0 (0x0000) Неправильный масштабный коэффициент (×10, ×100, ×0.01...) ЗаключениеModbus — это фундамент промышленной коммуникации. Несмотря на почтенный возраст, он остаётся стандартом де-факто и будет таковым ещё долгие годы. Понимание структуры фрейма, типов данных и физического уровня RS-485 — это базовый навык любого инженера автоматики. Для новых проектов, где нет ограничений совместимости, стоит рассматривать OPC UA или MQTT как более современные альтернативы. Но если перед вами стоит задача интегрировать любое промышленное оборудование — с вероятностью 90% оно имеет Modbus, и знание этого протокола решит задачу быстро и надёжно.
-
Программирование ПЛК: от нуля до первой рабочей программы
Что такое ПЛК и почему это не просто "мощный Arduino"ПЛК (Программируемый Логический Контроллер) — это специализированный промышленный компьютер, разработанный для управления технологическими процессами в реальном времени. Главное отличие от обычного компьютера или Arduino — детерминизм: гарантированное время реакции на входные сигналы, независимо от загрузки процессора. Когда на производстве нужно, чтобы насос включился строго через 50 мс после срабатывания датчика — это ПЛК. Когда допустима задержка в секунду — можно обойтись более дешёвыми решениями. Когда вопрос в надёжности и работе в условиях вибраций, пыли, температур от -40 до +70°C — снова ПЛК. Другие ключевые отличия: Цикличность: программа выполняется повторяющимися циклами (обычно 1–100 мс). Каждый цикл: считать все входы → выполнить программу → записать все выходы Гальваническая развязка входов/выходов: промышленные сигналы (24В DC, 220В AC) изолированы от внутренней логики Горячая замена модулей: во многих системах можно менять I/O-модули без остановки контроллера Встроенная диагностика: ПЛК сам следит за собственным здоровьем Архитектура ПЛК: как это устроеноЦентральный процессорный модуль (CPU)Выполняет программу, управляет обменом данных, содержит основную память. В S7-1200 CPU также имеет встроенные входы/выходы, Ethernet порт и возможность расширения. Модули ввода-вывода (I/O Modules)Дискретные входы (DI): Воспринимают сигналы "есть напряжение / нет напряжения". Обычно 24В DC или 220В AC. Примеры источников: кнопки, концевые выключатели, датчики приближения (индуктивные, ёмкостные), фотодатчики, реле. Дискретные выходы (DO): Управляют исполнительными устройствами. Бывают транзисторные (24В DC, быстрые) и релейные (любое напряжение до 250В AC, медленные, но универсальные). Аналоговые входы (AI): Принимают непрерывные сигналы: 4–20мА, 0–10В, термопары (тип K, J, T...), термосопротивления (Pt100, Pt1000). Преобразуют в число (обычно 0–27648 для диапазона 0–100%). Аналоговые выходы (AO): Выдают аналоговые сигналы для управления частотниками, регулирующими клапанами, позиционерами. Память ПЛК (на примере Siemens)Область Обозначение Описание Входы I, %IX Образ входов, обновляется каждый цикл Выходы Q, %QX Образ выходов, пишется в физику после цикла Меркеры M, %MX Внутренние биты/байты/слова — "рабочая память" Таймеры T (S7-classic) / IEC-timer Отсчёт времени Счётчики C (S7-classic) / IEC-counter Счёт импульсов Data Blocks DB Блоки данных: рецепты, уставки, история Стандарт МЭК 61131-3: пять языков программированияМеждународный стандарт определяет пять языков для ПЛК. Хороший инженер знает минимум два-три. 1. Ladder Diagram (LD) — Релейно-контактная схемаИсторически первый язык — имитация схем из физических реле. Читается слева направо, как электрическая цепь. Левая шина — "фаза", правая — "ноль". Ток "течёт" если путь замкнут. I0.0 I0.1 Q0.0 Пуск Стоп (НЗ) Выход насос ──┤ ├────┤/├──────────( )── Q0.0 (самоподхват) ──┤ ├── Эта схема — классика: кнопка ПУСК (I0.0) запускает насос (Q0.0), контакт самоподхвата удерживает его включённым, кнопка СТОП (I0.1, нормально-закрытый) его отключает. Преимущества LD: Понятен электрикам без IT-образования, визуально отображает логику цепей, легко отлаживать онлайн (подсвечиваются активные цепи). Недостатки: Громоздкий для сложных вычислений и работы с данными. 2. Function Block Diagram (FBD) — Диаграмма функциональных блоковПрограмма строится из готовых блоков (AND, OR, NOT, таймеры, счётчики, ПИД-регуляторы), соединённых сигнальными линиями. Хорошо подходит для управления потоками сигналов. I0.0 ──┐ ├──[AND]──── Q0.0 I0.1 ──┘ 3. Structured Text (ST) — Структурированный текстЯзык высокого уровня, похожий на Pascal/Ada. Самый мощный для математических вычислений, работы с массивами, строками. (* Программа управления насосом с ПИД *) IF Start AND NOT Stop THEN Running := TRUE; END_IF; IF Stop THEN Running := FALSE; END_IF; (* Расчёт ПИД *) IF Running THEN Error := Setpoint - Feedback; Integral := Integral + Error * CycleTime; Integral := LIMIT(-100.0, Integral, 100.0); (* Ограничение интеграла *) Output := Kp * Error + Ki * Integral + Kd * (Error - PrevError) / CycleTime; Output := LIMIT(0.0, Output, 100.0); PrevError := Error; ELSE Output := 0.0; Integral := 0.0; END_IF; 4. Instruction List (IL) — Список инструкцийАссемблер для ПЛК. Устаревший язык, в новом стандарте МЭК 61131-3 третьей редакции официально deprecated. Знать необязательно. 5. Sequential Function Chart (SFC) — Диаграмма последовательных функцийПохоже на блок-схему или граф состояний. Идеален для описания технологических последовательностей: шаг 1 → условие перехода → шаг 2 → условие → шаг 3... Реальный проект: управление насосной станциейРазберём полноценный пример — система управления двумя насосами с чередованием и защитами. Техническое задание:2 насоса, работают поочерёдно для равномерного износа Автоматическое включение второго при отказе первого Защита от сухого хода (датчик уровня) Защита от давления (реле давления) Ручной/автоматический режим Счётчик моточасов каждого насоса Распределение входов/выходов:ВХОДЫ: I0.0 — Кнопка ПУСК (автоматический режим) I0.1 — Кнопка СТОП I0.2 — Переключатель Авт/Ручной I0.3 — Датчик уровня (нижний предел — сухой ход) I0.4 — Датчик уровня (верхний предел — бак полон) I0.5 — Реле давления насос 1 (авария — нет давления) I0.6 — Реле давления насос 2 I0.7 — Тепловое реле насос 1 (перегрев) I1.0 — Тепловое реле насос 2 I1.1 — Ручное управление насос 1 (в ручном режиме) I1.2 — Ручное управление насос 2 ВЫХОДЫ: Q0.0 — Контактор насос 1 Q0.1 — Контактор насос 2 Q0.2 — Лампа "Работа авто" Q0.3 — Лампа "Авария" Q0.4 — Сирена (авария критическая) Программа на Structured Text (ST):PROGRAM PumpStation (* ===== ПЕРЕМЕННЫЕ ===== *) VAR // Входы btnStart AT %I0.0 : BOOL; btnStop AT %I0.1 : BOOL; swAutoManual AT %I0.2 : BOOL; // TRUE = авто snsLevelLow AT %I0.3 : BOOL; // TRUE = уровень низкий (авария) snsLevelHigh AT %I0.4 : BOOL; // TRUE = бак полон relPressure1 AT %I0.5 : BOOL; // FALSE = нет давления (авария) relPressure2 AT %I0.6 : BOOL; thmRelay1 AT %I0.7 : BOOL; // TRUE = перегрев (авария) thmRelay2 AT %I1.0 : BOOL; btnPump1Manual AT %I1.1 : BOOL; btnPump2Manual AT %I1.2 : BOOL; // Выходы outPump1 AT %Q0.0 : BOOL; outPump2 AT %Q0.1 : BOOL; lampAutoRun AT %Q0.2 : BOOL; lampFault AT %Q0.3 : BOOL; siren AT %Q0.4 : BOOL; // Внутренние переменные SystemRun : BOOL := FALSE; ActivePump : INT := 1; // Какой насос сейчас основной Fault : BOOL := FALSE; FaultCode : INT := 0; Pump1Fault : BOOL := FALSE; Pump2Fault : BOOL := FALSE; // Таймеры TimerPumpStart : TON; // Задержка пуска насоса TimerRotation : TON; // Таймер чередования (8 часов) TimerAlarmDelay : TON; // Задержка подтверждения аварии // Счётчики моточасов Hours_Pump1 : DINT := 0; Hours_Pump2 : DINT := 0; TimerH_Pump1 : TON; TimerH_Pump2 : TON; END_VAR (* ===== ЛОГИКА АВАРИЙ ===== *) // Авария сухого хода — критическая, немедленная остановка IF snsLevelLow THEN Fault := TRUE; FaultCode := 1; // Сухой ход SystemRun := FALSE; END_IF; // Задержанные аварии давления (3 секунды для исключения ложных срабатываний) TimerAlarmDelay(IN := (outPump1 AND NOT relPressure1) OR (outPump2 AND NOT relPressure2), PT := T#3S); IF TimerAlarmDelay.Q THEN IF outPump1 AND NOT relPressure1 THEN Pump1Fault := TRUE; FaultCode := 2; // Нет давления насос 1 END_IF; IF outPump2 AND NOT relPressure2 THEN Pump2Fault := TRUE; FaultCode := 3; // Нет давления насос 2 END_IF; END_IF; // Тепловая защита IF thmRelay1 THEN Pump1Fault := TRUE; FaultCode := 4; // Перегрев насос 1 END_IF; IF thmRelay2 THEN Pump2Fault := TRUE; FaultCode := 5; // Перегрев насос 2 END_IF; // Оба насоса в аварии IF Pump1Fault AND Pump2Fault THEN Fault := TRUE; SystemRun := FALSE; END_IF; (* ===== КОМАНДЫ ПУСК/СТОП ===== *) IF btnStart AND NOT Fault THEN SystemRun := TRUE; END_IF; IF btnStop OR snsLevelHigh THEN // Стоп или бак полон SystemRun := FALSE; END_IF; // Квитирование аварии (нажать СТОП для сброса) IF btnStop THEN Fault := FALSE; FaultCode := 0; Pump1Fault := FALSE; Pump2Fault := FALSE; END_IF; (* ===== АВТОМАТИЧЕСКИЙ РЕЖИМ ===== *) IF swAutoManual AND SystemRun THEN // Чередование каждые 8 часов TimerRotation(IN := SystemRun, PT := T#28800S); // 8 часов = 28800 секунд IF TimerRotation.Q THEN IF ActivePump = 1 THEN ActivePump := 2; ELSE ActivePump := 1; END_IF; TimerRotation(IN := FALSE); // Сброс таймера TimerRotation(IN := TRUE); END_IF; // При аварии основного насоса — переключаемся на резервный IF ActivePump = 1 AND Pump1Fault AND NOT Pump2Fault THEN ActivePump := 2; ELSIF ActivePump = 2 AND Pump2Fault AND NOT Pump1Fault THEN ActivePump := 1; END_IF; // Задержка пуска 1 секунда (защита от дребезга) TimerPumpStart(IN := SystemRun, PT := T#1S); outPump1 := TimerPumpStart.Q AND (ActivePump = 1) AND NOT Pump1Fault; outPump2 := TimerPumpStart.Q AND (ActivePump = 2) AND NOT Pump2Fault; (* ===== РУЧНОЙ РЕЖИМ ===== *) ELSIF NOT swAutoManual THEN outPump1 := btnPump1Manual AND NOT Pump1Fault; outPump2 := btnPump2Manual AND NOT Pump2Fault; ELSE outPump1 := FALSE; outPump2 := FALSE; END_IF; (* ===== СЧЁТЧИКИ МОТОЧАСОВ ===== *) TimerH_Pump1(IN := outPump1, PT := T#1S); IF TimerH_Pump1.Q THEN Hours_Pump1 := Hours_Pump1 + 1; TimerH_Pump1(IN := FALSE); TimerH_Pump1(IN := TRUE); END_IF; TimerH_Pump2(IN := outPump2, PT := T#1S); IF TimerH_Pump2.Q THEN Hours_Pump2 := Hours_Pump2 + 1; END_IF; (* ===== ИНДИКАЦИЯ ===== *) lampAutoRun := SystemRun AND swAutoManual; lampFault := Fault OR Pump1Fault OR Pump2Fault; siren := Fault; // Сирена только при критической аварии END_PROGRAM Таймеры и счётчики: подробноТаймеры и счётчики — основа любой программы ПЛК. В стандарте МЭК 61131-3 они реализованы как функциональные блоки. Типы таймеровTON (Timer On Delay) — таймер с задержкой включения: TimerFan(IN := MotorRun, PT := T#5S); // Q становится TRUE через 5 секунд после включения MotorRun FanStart := TimerFan.Q; TOF (Timer Off Delay) — таймер с задержкой выключения: TimerFan(IN := MotorRun, PT := T#30S); // Q остаётся TRUE ещё 30 секунд после выключения MotorRun // Используется для дополнительного охлаждения после остановки FanRun := TimerFan.Q; TP (Timer Pulse) — таймер импульса: TimerBuzzer(IN := AlarmNew, PT := T#2S); // При фронте AlarmNew генерирует импульс 2 секунды // Независимо от того, сколько ещё держится AlarmNew Buzzer := TimerBuzzer.Q; СчётчикиCTU (Count Up) — счётчик вперёд: CounterBottles(CU := BottleSensor, R := ResetButton, PV := 100); // Считает бутылки, при достижении 100 — Q=TRUE BatchComplete := CounterBottles.Q; CurrentCount := CounterBottles.CV; // Текущее значение CTD (Count Down) — счётчик назад: CounterProducts(CD := ProductSensor, LD := LoadButton, PV := OrderQty); // PV загружается при LD=TRUE OrderComplete := CounterProducts.Q; // Q=TRUE когда CV=0 Работа с аналоговыми сигналамиМасштабирование аналогового входаАналоговый модуль S7-1200 возвращает значение 0–27648 для диапазона 0–100% входного сигнала (4–20мА или 0–10В). Для получения реального значения нужно масштабирование: FUNCTION_BLOCK ScaleAnalog VAR_INPUT RawValue : INT; // Сырое значение от АЦП (0..27648) RawMin : INT; // Мин значение АЦП (обычно 0 или 5530 для 4мА) RawMax : INT; // Макс значение АЦП (обычно 27648) PhysMin : REAL; // Физический минимум (например, 0.0 бар) PhysMax : REAL; // Физический максимум (например, 16.0 бар) END_VAR VAR_OUTPUT PhysValue : REAL; // Результат в физических единицах Broken : BOOL; // Обрыв линии (значение ниже 4мА) END_VAR // Проверка обрыва линии (для 4-20мА: ниже ~5% = обрыв) Broken := (RawValue < (RawMin - 1000)); IF NOT Broken THEN // Линейное масштабирование PhysValue := PhysMin + (REAL(RawValue - RawMin) / REAL(RawMax - RawMin)) * (PhysMax - PhysMin); // Ограничение выхода PhysValue := MAX(PhysMin, MIN(PhysMax, PhysValue)); ELSE PhysValue := PhysMin; // При обрыве — безопасное значение END_IF; END_FUNCTION_BLOCK Использование: // Датчик давления 4-20мА, диапазон 0-16 бар PressureSensor( RawValue := %IW64, // Адрес аналогового входа RawMin := 5530, // 4мА соответствует 5530 RawMax := 27648, // 20мА соответствует 27648 PhysMin := 0.0, PhysMax := 16.0 ); Pressure := PressureSensor.PhysValue; // Давление в барах PressureAlarm := PressureSensor.Broken; // Авария обрыва линии IF Pressure > 12.0 THEN HighPressureAlarm := TRUE; END_IF; Организационные блоки и структура программыПрофессиональная программа ПЛК разделена на функциональные блоки: Организация в TIA Portal (Siemens):OB1 (Main) — Главный цикл ├── FC10: ReadInputs — Чтение и нормализация входов ├── FC20: SafetyLogic — Приоритетные защиты (всегда первыми!) ├── FB30: PumpControl [DB30] — Управление насосами (с памятью) ├── FB40: PIDControl [DB40] — ПИД-регулятор ├── FC50: WriteOutputs — Запись выходов └── FC60: Diagnostics — Диагностика и коммуникации OB35 (Cyclic Interrupt, 10ms) — Быстрые задачи └── FB35: FastCounter — Высокоскоростной счётчик OB82 (I/O Error) — Обработка ошибок I/O-модулей OB121 (Programming Error) — Обработка ошибок программы Принцип: защиты и аварии — всегда в начале главного цикла. Они должны отработать независимо от состояния остальной программы. Отладка и диагностика программыОнлайн-мониторингВ TIA Portal при подключении к ПЛК все блоки отображают реальные значения переменных. В Ladder Diagram активные цепи подсвечиваются зелёным — мгновенно видно что работает. Форсирование переменных (Force)Можно принудительно установить значение входа или переменной для тестирования. Внимание: принудительные значения перекрывают реальные физические сигналы. Не забудьте снять форсирование перед вводом в эксплуатацию! ТрассировкаЗапись значений переменных в реальном времени с временной меткой. Незаменима для поиска редко возникающих ошибок — включаете запись и ждёте появления проблемы. Типичные ошибки начинающих:1. Использование выходных катушек несколько раз в Ladder НЕПРАВИЛЬНО: Цепь 1: I0.0 → Q0.0 (катушка) Цепь 2: I0.1 → Q0.0 (катушка снова!) Вторая катушка перезаписывает первую. Результат Q0.0 определяется только второй цепью. ПРАВИЛЬНО: Цепь 1: I0.0 ──┐ ├── Q0.0 Цепь 2: I0.1 ──┘ 2. Гонка состояний при SET/RESET // ОПАСНО: порядок операций важен IF Condition1 THEN SET(Coil); END_IF; IF Condition2 THEN RESET(Coil); END_IF; // Если оба условия TRUE — RESET побеждает (последний) // Убедитесь, что это желаемое поведение! 3. Деление на ноль // ВСЕГДА проверяйте делитель IF Denominator <> 0.0 THEN Result := Numerator / Denominator; ELSE Result := 0.0; // Безопасное значение END_IF; Коммуникация ПЛК с внешним миромModbus TCP (через Ethernet)Большинство современных ПЛК поддерживают Modbus TCP "из коробки". Это самый распространённый протокол для связи с SCADA, HMI и частотниками. В S7-1200 для Modbus TCP используются системные функциональные блоки: MB_CLIENT — ПЛК как Modbus-мастер (опрашивает устройства) MB_SERVER — ПЛК как Modbus-сервер (отвечает на запросы SCADA) OPC UAСовременный открытый протокол для промышленной коммуникации. Поддерживает семантику данных, безопасность, публикацию/подписку. Все серьёзные ПЛК последних поколений имеют встроенный OPC UA сервер. PROFINET/EtherCATПромышленные реальном-временные сети. PROFINET — стандарт Siemens и Profibus International. EtherCAT — от Beckhoff, исключительно быстрый (цикл 250 мкс). Используются для связи с распределёнными I/O-модулями, сервоприводами, vision-системами. Советы по надёжности программыВсегда инициализируйте переменные — не полагайтесь на "нулевое" начальное значение Используйте watchdog-таймер — если программа "зависла", таймер переводит выходы в безопасное состояние Документируйте каждую переменную — через полгода вы забудете что значит переменная b47 Разделяйте задачи по функциональным блокам — один блок = одна задача Тестируйте на симуляторе перед загрузкой в реальный ПЛК Делайте резервные копии программы перед каждым изменением — версионирование спасало многих Стандартизируйте именование: btnStart — кнопка, snsLevel — датчик, outPump — выход, tmrDelay — таймер ЗаключениеПрограммирование ПЛК — это отдельная инженерная дисциплина на стыке электротехники, автоматики и программирования. Ключевой принцип: программа управляет реальным оборудованием, и любая ошибка может привести к аварии или травме. Поэтому надёжность, защиты и понятность кода здесь важнее красоты архитектуры. Начните с Ladder Diagram — он прозрачен и хорошо отлаживается онлайн. Освойте Structured Text для сложных вычислений. Используйте SFC для технологических последовательностей. И всегда: сначала безопасность, потом функциональность. Хорошая программа ПЛК должна безопасно остановить оборудование при любой нештатной ситуации — потере связи, пропадании питания, выходе из строя датчика. Проектируйте с расчётом на отказ.
-
Частотные преобразователи: полное руководство по выбору, подключению и настройке
Введение: зачем вообще нужен частотникПредставьте насос, который гоняет воду в системе водоснабжения. Без частотного преобразователя он работает в одном режиме — на полную мощность. Давление в сети выросло — открывается байпасный клапан и лишняя энергия тупо рассеивается. Это как ехать на машине с педалью газа в полу и тормозить ногой одновременно. Частотный преобразователь (ЧП, частотник, VFD — Variable Frequency Drive, инвертор) решает эту проблему радикально: он плавно регулирует скорость электродвигателя, меняя частоту и напряжение питания. Насосу нужно меньше давления — он просто крутится медленнее. Результат: экономия электроэнергии 30–70%, меньше износ оборудования, мягкий пуск без ударных нагрузок. Сегодня частотники стоят везде: насосы, вентиляторы, конвейеры, компрессоры, станки, лифты, краны. Если в вашем производстве есть асинхронный электродвигатель — с вероятностью 80% он или уже управляется частотником, или должен управляться. Принцип работы: что происходит внутриПонимание принципа работы — ключ к правильной настройке и диагностике. Внутри любого частотника три основных блока: 1. Выпрямитель (Rectifier)Входное переменное напряжение (380В/50Гц) выпрямляется в постоянное. Используется трёхфазный диодный мост. На выходе получаем ~540В постоянного тока (380 × √2 ≈ 537В). 2. Звено постоянного тока (DC Bus)Электролитические конденсаторы большой ёмкости сглаживают пульсации выпрямленного напряжения. Именно здесь хранится энергия рекуперации при торможении. Важно: после отключения питания конденсаторы остаются заряженными до 540В в течение нескольких минут. Всегда ждите разряда перед обслуживанием! 3. Инвертор (Inverter)IGBT-транзисторы переключаются по алгоритму ШИМ (широтно-импульсная модуляция) с частотой 2–16 кГц, формируя синусоидальный ток нужной частоты и амплитуды. Именно здесь и происходит "магия" — создание переменного тока с произвольными параметрами. Сеть 380В/50Гц → [Выпрямитель] → 540В DC → [Инвертор ШИМ] → Переменный ток 0-400Гц / 0-380В → Двигатель Типы управления: скалярное vs векторноеЭто первое принципиальное решение при настройке. Выбор не правильный — получите либо плохую динамику, либо нестабильную работу. Скалярное управление (V/f = const)Самый простой алгоритм: соотношение напряжения к частоте держится постоянным. Поднимаем частоту с 50Гц до 25Гц — напряжение тоже снижается вдвое (с 380В до 190В). Магнитный поток ротора остаётся примерно постоянным. Когда использовать: Насосы и вентиляторы (вентиляторная нагрузка) Момент не нужен на малых скоростях Несколько двигателей на одном частотнике Простые конвейеры без точного позиционирования Ограничения: При малых скоростях момент проседает. Нет контроля скорости под нагрузкой — при увеличении нагрузки двигатель чуть замедляется. Векторное управление (Vector Control)Алгоритм управляет не просто частотой и напряжением, а непосредственно вектором магнитного потока и моментом. Два варианта: Без датчика скорости (Sensorless Vector): Математическая модель двигателя внутри частотника оценивает скорость ротора и момент. Требует настройки параметров двигателя (автотюнинг). Точность: ±0.5–2% от номинала. С датчиком скорости (Closed Loop Vector): Энкодер на валу двигателя даёт точную информацию о скорости. Точность: ±0.01–0.1%. Используется в станках, намоточных машинах, лифтах. Когда использовать векторное: Нужен полный момент при нулевой скорости (краны, экструдеры) Точное поддержание скорости под переменной нагрузкой Быстрая динамика разгона/торможения Один двигатель — один частотник Выбор частотника: критерии и расчётМощностьГлавное правило: мощность частотника ≥ мощности двигателя. Для большинства применений выбирают одноступенчатое превышение по каталогу. Например, двигатель 11 кВт — берём частотник 11 кВт или 15 кВт. Поправочные коэффициенты: Условие Коэффициент к мощности Длинный кабель (>50м) ×1.1–1.2 Тяжёлый пуск (компрессор) ×1.25–1.5 Частые пуски/остановы ×1.25 Высокая температура (>40°C) Деrating по паспорту Высота >1000м над уровнем моря Derating 1% на каждые 100м ТокНоминальный ток частотника должен быть не меньше номинального тока двигателя с учётом пиковых нагрузок. Многие приводы имеют режим перегрузки 150% на 60 секунд или 200% на 3 секунды. Серии популярных производителейБюджетный сегмент (учёба, простые задачи): Delta VFD-E, VFD-M — отличное соотношение цена/качество Hyundai N700E — популярны в России Веспер Е5 — отечественный производитель Средний сегмент (промышленность): ABB ACS310, ACS550 — надёжные, хорошая документация Danfoss FC-051, FC-102 — особенно хороши для насосов Schneider Electric Altivar 312, 320 Высокий сегмент (серводрайвы, точное позиционирование): Siemens SINAMICS G120, S120 ABB ACS880 — промышленный стандарт Yaskawa A1000, GA700 Подключение: схема и важные нюансыСиловая частьСеть 3ф 380В | [Автоматический выключатель] ← Защита от КЗ, НЕ защита от перегрузки! | [Сетевой реактор] ← ОБЯЗАТЕЛЬНО при искажённой сети или мощности >15кВт | [Частотник] L1 L2 L3 — вход питания PE — заземление (обязательно!) U V W — выход на двигатель | [Моторный дроссель] ← при кабеле >20м, защита от перенапряжений | [Двигатель] Критические ошибки подключения: Никогда не подключайте выход ЧП к сети! U,V,W — только на двигатель. Никогда не ставьте контактор между ЧП и двигателем без специальной схемы — IGBT умирает мгновенно. Заземление обязательно — без него ЧП работает через паразитные ёмкости и сгорит от статики или помех. Разделяйте силовые и сигнальные кабели — минимум 20–30 см между ними. Управляющая частьТиповое подключение аналогового задания скорости (0–10В): ПЛК/потенциометр → AVI (Analog Voltage Input) — задание частоты ПЛК/кнопка → DI1 (Digital Input 1) — команда ПУСК ПЛК/кнопка → DI2 (Digital Input 2) — команда СТОП ЧП → DO1 (Digital Output 1) → ПЛК — сигнал "работает" ЧП → DO2 (Digital Output 2) → ПЛК/лампа — сигнал "авария" Токовое задание (4–20мА) более помехоустойчиво для длинных кабелей. При 4мА = 0 об/мин, при 20мА = максимальные обороты. Потеря сигнала (обрыв кабеля) → 0мА → частотник видит это как аварию. Основные параметры настройкиКаждый производитель имеет свои номера параметров, но логика везде одинакова. Покажем на примере логики настройки: Группа 1: Параметры двигателяЭто самые важные параметры — частотник должен "знать" двигатель: P_мощность = 7.5 кВт (с шильдика двигателя) P_напряжение = 380 В P_ток = 15.5 А P_частота = 50 Гц P_скорость = 1440 об/мин (или 1450, 1460 — смотреть шильдик) P_cos_phi = 0.86 После ввода этих данных — обязательно запустите автотюнинг (Auto-tuning). Частотник сам измерит активное сопротивление обмоток, индуктивность и другие параметры. Занимает 30–120 секунд. Двигатель при этом либо неподвижен (статический тюнинг), либо вращается (динамический — точнее). Группа 2: ОграниченияМин. частота = 5–10 Гц (ниже нельзя — перегрев двигателя) Макс. частота = 50–60 Гц (можно выше, но нужен расчёт подшипников) Макс. ток = 110–120% от номинала двигателя Группа 3: Разгон и торможениеВремя разгона = 5–30 с (чем тяжелее механизм — тем больше) Время торможения = 5–30 с Тип кривой = S-образная (плавнее для конвейеров, насосов) Слишком короткое время торможения → ошибка OV (перенапряжение в звене DC) → нужен тормозной резистор или увеличить время. Группа 4: Источник задания и управлениеИсточник задания частоты: аналог 0–10В / 4–20мА / цифровые входы / Modbus Источник команды пуск/стоп: цифровые входы / пульт / Modbus Режим управления: скалярный / векторный без датчика / с датчиком ПИД-регулятор в частотникеБольшинство современных ЧП имеют встроенный ПИД-регулятор. Это позволяет строить замкнутую систему управления без внешнего контроллера. Типичный пример: насос с поддержанием давления Датчик давления (4–20мА) → Вход обратной связи ЧП Уставка давления → Задание (аналог или цифровое значение) Выход ПИД → Управляет частотой насоса Настройка ПИД (упрощённый метод Циглера-Николса): Установить I=0, D=0, поднимать P до возникновения устойчивых колебаний Записать критический коэффициент Kc и период колебаний Tc P = 0.6×Kc, I = 2×Tc, D = Tc/8 Для насосов и вентиляторов (инерционная нагрузка) типичные значения: P (пропорциональная составляющая): 20–50% I (интегральная): 2–10 секунд D (дифференциальная): 0–1 секунда (часто не нужна) Важно: Включите функцию Sleep/Wake — при малом потреблении (ночное время) насос останавливается, при снижении давления — запускается. Экономия 15–30% электроэнергии. Защитные функции и их настройкаСовременный частотник — это не просто регулятор, это полноценная система защиты двигателя и механизма. Тепловая защита двигателя (Motor Thermal Protection)Электронный тепловой реле внутри ЧП моделирует нагрев двигателя на основе тока и времени. Задаётся номинальный ток двигателя (I_nom) и тепловая постоянная времени (обычно 30–600 секунд). Гораздо точнее биметаллических реле — учитывает режим работы. Защита от перенапряжения (OV — Over Voltage)Срабатывает когда напряжение в звене DC превышает порог (~800В для 380В-сетей). Причины: резкое торможение (обратная ЭДС двигателя), выброс в сети. Решения: увеличить время торможения, поставить тормозной резистор, включить функцию "регулирование торможения по напряжению DC". Защита от перегрузки (OC — Over Current)Ток превысил допустимый предел. Причины: механическое заклинивание, слишком короткое время разгона, неправильно введены параметры двигателя. Никогда не повышайте порог защиты бездумно — это приведёт к перегреву или сгоранию двигателя. Потеря фазы (Input/Output Phase Loss)Отсутствует одна из фаз входного питания или обрыв в кабеле до двигателя. Критически важная защита — трёхфазный двигатель на двух фазах перегревается за секунды. Коммуникация: Modbus RTU на практикеПочти все промышленные частотники поддерживают Modbus RTU через RS-485. Это позволяет управлять приводом с ПЛК или SCADA без аналоговых сигналов. Типичные Modbus-регистры (адреса условные, смотрите документацию вашего ЧП):Регистр Тип Описание 40001 Holding Управляющее слово (пуск/стоп/реверс) 40002 Holding Задание частоты (×0.01 Гц, т.е. 5000 = 50.00 Гц) 40003 Input Статусное слово (работает/авария) 40004 Input Текущая частота 40005 Input Ток двигателя (×0.1А) 40006 Input Напряжение DC-шины 40007 Input Код последней аварии Пример управления через Python (для тестирования и прототипирования):import minimalmodbus import time # Настройка соединения vfd = minimalmodbus.Instrument('/dev/ttyUSB0', 1) # COM-порт, адрес устройства = 1 vfd.serial.baudrate = 9600 vfd.serial.bytesize = 8 vfd.serial.parity = 'N' vfd.serial.stopbits = 1 vfd.serial.timeout = 0.5 vfd.mode = minimalmodbus.MODE_RTU def vfd_start(): """Команда ПУСК вперёд""" vfd.write_register(0x2000, 0x0002, functioncode=6) # Control word: Run Forward def vfd_stop(): """Команда СТОП (свободный выбег)""" vfd.write_register(0x2000, 0x0001, functioncode=6) # Control word: Stop def vfd_set_freq(freq_hz: float): """Задать частоту в Гц (0.0 - 50.0)""" value = int(freq_hz * 100) # Например 50.0 Гц → 5000 vfd.write_register(0x2001, value, functioncode=6) def vfd_get_status() -> dict: """Считать текущее состояние""" status_word = vfd.read_register(0x2100, functioncode=3) freq = vfd.read_register(0x2101, functioncode=3) / 100.0 current = vfd.read_register(0x2102, functioncode=3) / 10.0 voltage_dc = vfd.read_register(0x2103, functioncode=3) return { 'running': bool(status_word & 0x0001), 'fault': bool(status_word & 0x0008), 'frequency': freq, 'current': current, 'dc_voltage': voltage_dc } # Пример использования try: print("Запуск двигателя...") vfd_set_freq(30.0) # Задаём 30 Гц vfd_start() for i in range(10): time.sleep(2) status = vfd_get_status() print(f"Частота: {status['frequency']} Гц, Ток: {status['current']} А") if status['fault']: print("АВАРИЯ! Проверьте частотник.") break print("Остановка...") vfd_stop() except Exception as e: print(f"Ошибка связи: {e}") Типичные аварии и их устранениеF001 / OC — Сверхток при пускеСимптомы: Ошибка возникает сразу при подаче команды пуск или в первые секунды разгона. Причины и решения: Слишком короткое время разгона → Увеличить в 2–3 раза Механическое заклинивание → Проверить механику, проворачивается ли вал вручную КЗ в кабеле или обмотках → Мегомметром проверить изоляцию кабеля (500В) и двигателя Неверные параметры двигателя → Перепроверить ток, мощность, cos_phi Включён режим векторного управления без автотюнинга → Запустить автотюнинг F002 / OV — Перенапряжение в DC-шинеСимптомы: Ошибка при торможении или при резком снижении нагрузки. Причины и решения: Слишком короткое время торможения → Увеличить время Нет тормозного резистора при большом маховике → Установить резистор Высокое напряжение сети (>415В) → Проверить сеть, возможно нужен трансформатор Включить функцию "Voltage regulation during deceleration" — ЧП сам замедляет торможение F003 / OH — ПерегревСимптомы: После длительной работы или в жаркую погоду. Причины и решения: Загрязнён радиатор → Очистить сжатым воздухом (не водой!) Сломан вентилятор охлаждения → Заменить Недостаточно места для вентиляции → Минимум 10 см сверху и снизу Температура в шкафу >40°C → Добавить принудительную вентиляцию шкафа Слишком высокая частота ШИМ → Снизить с 8кГц до 4кГц (будет чуть громче, но прохладнее) F004 / LV — Пониженное напряжениеСимптомы: При просадке сети или при запуске мощного оборудования рядом. Решения: Проверить напряжение сети мультиметром под нагрузкой Установить сетевой реактор — сглаживает просадки Настроить время повторного пуска после восстановления питания (Auto-restart) Энергосбережение: реальные цифрыЗакон куба: мощность вентилятора/насоса пропорциональна кубу скорости. Снизили скорость на 20% → потребление упало на 49%! P2/P1 = (n2/n1)³ При n1 = 50 Гц, n2 = 40 Гц (снижение на 20%): P2/P1 = (40/50)³ = 0.512 → экономия 48.8%! Реальный пример из практики: вентилятор системы вентиляции цеха, 55 кВт, работал 24/7 на 50 Гц. После установки частотника с датчиком CO2 и ПИД-регулятором: Среднесуточная частота работы: 35–40 Гц Фактическое потребление: снизилось с 55 кВт до 22–28 кВт Годовая экономия: ~250 000 кВт·ч При тарифе 6 руб/кВт·ч: 1 500 000 руб/год Стоимость частотника 55 кВт: ~180 000 руб Срок окупаемости: 6 недель Чек-лист при вводе в эксплуатациюПеред первым пуском обязательно проверить: □ Напряжение питания соответствует номиналу ЧП (380В ±10%) □ Заземление подключено и проверено (<4 Ом) □ Кабели L1-L2-L3 и U-V-W не перепутаны местами □ Нет КЗ между фазами и на землю (мегомметром) □ Введены параметры двигателя (шильдик) □ Проведён автотюнинг □ Настроены ограничения: мин/макс частота, макс ток □ Настроены времена разгона/торможения □ Проверена правильность направления вращения (на малой скорости 5–10 Гц) □ Настроены аварийные выходы и тестирована их реакция □ Записаны все изменённые параметры в документацию ЗаключениеЧастотный преобразователь — один из самых универсальных и окупаемых инструментов в промышленной автоматизации. Правильно подобранный и настроенный, он одновременно экономит электроэнергию, продлевает ресурс двигателя и механического оборудования, даёт возможность тонкого управления технологическим процессом. Ключевые принципы, которые стоит запомнить: выбирайте тип управления под задачу, всегда вводите точные параметры двигателя и делайте автотюнинг, не пренебрегайте сетевыми и моторными дросселями, настраивайте защиты адекватно нагрузке. И помните про безопасность — конденсаторы DC-шины хранят смертельное напряжение ещё несколько минут после отключения питания. Изучите документацию на ваш конкретный привод — производители вкладывают в неё годы опыта тысяч инсталляций. Это лучший источник правильных решений для конкретного устройства.
-
Архитектура очередей: как мы убрали "тормоза" из HTTP-запросов и спасли нервы пользователей
Пользователи не любят ждать. Если кнопка "Отправить" не реагирует три секунды — они уже в Twitter пишут что ваш сайт сломан. Один из самых мощных инструментов для улучшения perceived performance — асинхронная обработка через очереди. Что уходит в очередьВ HTTP-запросе должно происходить только то, что нужно пользователю для немедленного ответа. Что НЕ нужно пользователю немедленно: Отправка email Генерация PDF Пересчёт статистики Синхронизация с внешними системами Изменение размеров изображений Push-уведомления Webhook-уведомления партнёров Всё это — в очередь. HTTP отвечает за 50ms. Фоновый воркер делает остальное. Базовая архитектура очередей на Redis + CI4<?php namespace App\Libraries\Queue; class Queue { private \Redis $redis; private string $prefix = 'queue:'; public function push(string $queueName, BaseJob $job, int $delay = 0): string { $payload = [ 'id' => $id = uniqid('job_', true), 'class' => get_class($job), 'data' => serialize($job), 'attempt' => 0, 'created_at' => time(), ]; if ($delay > 0) { $this->redis->zAdd( $this->prefix . 'delayed:' . $queueName, time() + $delay, json_encode($payload) ); } else { $this->redis->lPush($this->prefix . $queueName, json_encode($payload)); } return $id; } public function pop(string $queueName, int $timeout = 5): ?array { $this->promoteDelayedJobs($queueName); $result = $this->redis->brPop($this->prefix . $queueName, $timeout); return $result ? json_decode($result[1], true) : null; } private function promoteDelayedJobs(string $queueName): void { $delayedKey = $this->prefix . 'delayed:' . $queueName; $jobs = $this->redis->zRangeByScore($delayedKey, '-inf', time()); foreach ($jobs as $job) { $this->redis->multi(); $this->redis->zRem($delayedKey, $job); $this->redis->lPush($this->prefix . $queueName, $job); $this->redis->exec(); } } } Worker — CI4 CLI команда<?php namespace App\Commands; use App\Libraries\Queue\Queue; class QueueWorker extends BaseCommand { protected $name = 'queue:work'; protected $description = 'Process jobs from the queue'; private bool $shouldStop = false; public function run(array $params): void { $queueName = $params[0] ?? 'default'; $maxJobs = (int) ($this->getOption('max-jobs') ?? 0); $processed = 0; if (function_exists('pcntl_signal')) { pcntl_signal(SIGTERM, fn() => $this->shouldStop = true); pcntl_signal(SIGINT, fn() => $this->shouldStop = true); } $queue = new Queue(); while (!$this->shouldStop) { if (function_exists('pcntl_signal_dispatch')) { pcntl_signal_dispatch(); } $payload = $queue->pop($queueName); if (!$payload) continue; $this->processJob($queue, $queueName, $payload); $processed++; if ($maxJobs > 0 && $processed >= $maxJobs) break; } } private function processJob(Queue $queue, string $queueName, array $payload): void { try { $job = unserialize($payload['data']); $job->setAttempt($payload['attempt'] + 1); set_time_limit($job->getTimeout()); $job->handle(); } catch (\Throwable $e) { $payload['attempt']++; if ($payload['attempt'] < $job->getMaxAttempts()) { $delay = pow(2, $payload['attempt']) * 10; $queue->push($queueName, $job, $delay); } else { $job->failed($e); $queue->bury($queueName, $payload); } } } } Реальный пример: отправка email<?php namespace App\Jobs; use App\Libraries\Queue\BaseJob; class SendWelcomeEmailJob extends BaseJob { protected int $maxAttempts = 5; protected int $timeout = 30; public function __construct( private readonly int $userId, private readonly string $email, private readonly string $name ) {} public function handle(): void { service('email')->send( to: $this->email, subject: 'Добро пожаловать!', view: 'emails/welcome', data: ['name' => $this->name] ); model('EmailLogModel')->insert([ 'user_id' => $this->userId, 'type' => 'welcome', 'sent_at' => date('Y-m-d H:i:s'), 'status' => 'sent', ]); } } Использование в Controller: // Вместо: $emailService->send(...); // ждём 500ms // Делаем: $queue = new Queue(); $queue->push('emails', new SendWelcomeEmailJob($user->id, $user->email, $user->name)); // HTTP response за 15ms вместо 515ms return $this->response->setJSON(['status' => 'registered', 'message' => 'Check your email']); РезультатыEndpoint До После POST /register 580ms 45ms POST /upload-photo 2300ms 120ms POST /generate-report 8000ms 80ms (async) POST /checkout 1200ms 340ms Bounce rate на страницах с "тяжёлыми" действиями упал на 34%. Конверсия регистрации выросла на 12%. Просто потому что форма теперь отвечает мгновенно. Пользователи не знают о ваших очередях. Они просто чувствуют что сайт быстрый. Это и есть цель. ⚡ Максим — продуктовый DevOps с горящими глазами и умеренно сгоревшими нервами. Пишу про реальный highload, реальные ошибки и реальные решения. Больше таких историй — на ithub.uno, там собираются те, кто делает, а не только говорит.
-
Горизонтальное масштабирование PHP: всё что вы хотели знать, но боялись спросить
"Просто добавь серверов" — говорят менеджеры. "Это не так просто" — говорим мы. Сегодня расскажу почему не так просто, и как всё-таки сделать так, чтобы было просто.Проблема №1: Состояние сессийЗапускаете второй сервер, и пользователи жалуются: "я только что вошёл, а меня снова просит логин". Потому что сессия хранится в файловой системе первого сервера. Правильное решение: Redis-сессии: // app/Config/App.php public string $sessionDriver = 'CodeIgniter\Session\Handlers\RedisHandler'; public string $sessionSavePath = 'tcp://redis-cluster:6379?auth=password&database=1'; public int $sessionExpiration = 7200; public bool $sessionMatchIP = false; // Важно! Иначе CDN сломает сессии С Sentinel для HA: public string $sessionSavePath = 'tcp://redis-sentinel:26379?auth=password&database=1&sentinel_master=mymaster'; Проблема №2: Загрузка файловПользователь загружает аватар на сервер #1. Следующий запрос идёт на сервер #2 — он ничего не знает об этом файле. Решение: S3-совместимое хранилище: <?php namespace App\Services; use Aws\S3\S3Client; class FileStorageService { private S3Client $s3; public function __construct() { $config = config('FileStorage'); $this->s3 = new S3Client([ 'version' => 'latest', 'region' => $config->region, 'endpoint' => $config->endpoint, 'credentials' => ['key' => $config->key, 'secret' => $config->secret], 'use_path_style_endpoint' => $config->usePathStyle, ]); } public function upload(string $localPath, string $remotePath, string $acl = 'private'): string { $this->s3->putObject([ 'Bucket' => $this->bucket, 'Key' => $remotePath, 'SourceFile' => $localPath, 'ACL' => $acl, 'ContentType' => mime_content_type($localPath), ]); return $this->getUrl($remotePath); } public function getSignedUrl(string $remotePath, int $expiry = 3600): string { $cmd = $this->s3->getCommand('GetObject', ['Bucket' => $this->bucket, 'Key' => $remotePath]); return (string) $this->s3->createPresignedRequest($cmd, "+{$expiry} seconds")->getUri(); } } Проблема №3: Cron jobs10 серверов, у каждого crontab — пользователи получают email 10 раз. Решение: distributed locking: <?php namespace App\Libraries; class DistributedLock { private string $lockValue; public function __construct( private readonly string $key, private readonly int $ttl = 300 ) { $this->lockValue = gethostname() . '_' . getmypid() . '_' . uniqid(); } public function acquire(): bool { // SET key value NX EX ttl — атомарная операция $result = cache()->getRedis()->set( $this->key, $this->lockValue, ['NX', 'EX' => $this->ttl] ); return $result === true; } public function release(): bool { // Lua для атомарного освобождения только СВОЕГО лока $script = <<<LUA if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end LUA; return (bool) cache()->getRedis()->eval($script, [$this->key, $this->lockValue], 1); } } Базовый класс для singleton cron-команд: abstract class SingletonCommand extends BaseCommand { protected int $lockTtl = 300; final public function run(array $params): void { $lock = new DistributedLock('cron_lock_' . static::class, $this->lockTtl); if (!$lock->acquire()) { $this->write("Another instance is already running. Skipping."); return; } try { $this->execute($params); } finally { $lock->release(); } } abstract protected function execute(array $params): void; } Чеклист горизонтального масштабированияПеред тем как добавить второй сервер: ✅ Сессии в Redis/Memcached, не в файловой системе ✅ Файлы в S3/MinIO, не на диск ✅ Кэш в Redis, не в файловой системе ✅ Логи в centralized storage (ELK/Loki) ✅ Cron с distributed locking ✅ Конфиги из environment variables ✅ Нет hardcoded hostname в коде ✅ Нет состояния в оперативной памяти между запросами ✅ Graceful shutdown (SIGTERM handler) ✅ Health checks настроены Если всё это есть — добавление сервера занимает минуты. Если нет — недели головной боли. На ithub.uno таких архитектурных обсуждений всегда много — там есть практические нюансы, которых нет в документации. Масштабируйтесь грамотно. 📊🖥️
-
CI/CD: от "фтп в продакшн" до zero-downtime deploy за 4 минуты
Это история эволюции. Начнём с того момента, когда деплой выглядел так: "Ваня, выгрузи файлики на сервер через FileZilla". И закончим тем, что у нас сейчас — полностью автоматический pipeline с тестами, security checks, zero-downtime deploy и автоматическим rollback. Эра FTP (тёмные времена)Просто знайте: когда я пришёл в эту компанию, деплой выглядел следующим образом: Разработчик локально делал изменения Открывал FileZilla Перетаскивал папку app/ на сервер Молился Это было в 2019 году. Не в 2009, а в 2019. Такое ещё встречается. Шаг 1: Git + простой CI# .gitlab-ci.yml v1.0 — наивная версия stages: - test - deploy test: stage: test script: - composer install - php vendor/bin/phpunit deploy: stage: deploy script: - ssh deploy@production "cd /var/www/app && git pull && composer install" only: - main git pull на продакшне — это тоже не очень хорошая идея, но это следующий шаг. Шаг 2: Деплой через rsync + atomic switch#!/bin/bash set -euo pipefail DEPLOY_PATH="/var/www" RELEASES_PATH="$DEPLOY_PATH/releases" CURRENT_LINK="$DEPLOY_PATH/current" RELEASE_ID=$(date +%Y%m%d_%H%M%S) RELEASE_PATH="$RELEASES_PATH/$RELEASE_ID" mkdir -p "$RELEASE_PATH" rsync -az --exclude='.git' --exclude='vendor' --exclude='writable' \ ./ "$RELEASE_PATH/" ln -sf "$SHARED_PATH/writable" "$RELEASE_PATH/writable" ln -sf "$SHARED_PATH/.env" "$RELEASE_PATH/.env" cd "$RELEASE_PATH" composer install --no-dev --optimize-autoloader --no-interaction php spark migrate --no-interaction # Атомарное переключение симлинка — zero downtime! ln -sfn "$RELEASE_PATH" "$CURRENT_LINK" nginx -s reload ls -dt "$RELEASES_PATH"/* | tail -n +6 | xargs rm -rf echo "✅ Deploy $RELEASE_ID complete" Rollback: ls -dt /var/www/releases/* | head -2 | tail -1 | xargs -I{} ln -sfn {} /var/www/current nginx -s reload Шаг 3: Docker + KubernetesDockerfile для PHP 8.3 + CI4: FROM composer:2.7 AS vendor-builder WORKDIR /app COPY composer.json composer.lock ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist COPY . . RUN composer dump-autoload --optimize --classmap-authoritative FROM php:8.3-fpm-alpine AS production RUN apk add --no-cache libzip-dev icu-dev \ && docker-php-ext-install pdo_mysql zip intl opcache \ && pecl install redis igbinary \ && docker-php-ext-enable redis igbinary COPY docker/php/php.ini /usr/local/etc/php/php.ini COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf WORKDIR /var/www/html COPY --from=vendor-builder /app/vendor ./vendor COPY --from=vendor-builder /app/app ./app COPY --from=vendor-builder /app/system ./system COPY --from=vendor-builder /app/public ./public COPY --from=vendor-builder /app/spark ./spark HEALTHCHECK --interval=10s --timeout=3s --retries=3 \ CMD php-fpm -t || exit 1 EXPOSE 9000 USER www-data CMD ["php-fpm"] GitLab CI/CD — финальная версия: stages: - validate - test - security - build - deploy-staging - integration-test - deploy-production - verify variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA lint: stage: validate script: - find app/ -name "*.php" -exec php -l {} \; - composer validate --strict unit-tests: stage: test services: - mysql:8.0 - redis:7.0-alpine script: - php spark migrate --env=testing - vendor/bin/phpunit --coverage-min=70 sast: stage: security script: - vendor/bin/psalm --no-cache - composer audit --no-dev build-image: stage: build script: - docker build --target production -t $DOCKER_IMAGE . - docker push $DOCKER_IMAGE deploy-production: stage: deploy-production when: manual # Требует ручного подтверждения! script: - kubectl set image deployment/php-app php-fpm=$DOCKER_IMAGE -n production - kubectl rollout status deployment/php-app -n production --timeout=10m post-deploy-verify: stage: verify script: - sleep 60 - php spark synthetic:run --suite=smoke --env=production - | ERROR_RATE=$(curl -s "http://prometheus/api/v1/query?query=rate(http_errors_total[5m])" | jq '.data.result[0].value[1]') if (( $(echo "$ERROR_RATE > 0.05" | bc -l) )); then echo "Error rate too high! Auto-rollback!" kubectl rollout undo deployment/php-app -n production exit 1 fi Итого: что получилиМетрика FTP era Git + rsync Docker + k8s Время деплоя 5-40 мин 3-8 мин 2-4 мин Downtime при деплое 30s-5min 0 0 Rollback время 10-30 мин 2 мин 45 секунд Деплоев в день 1-2 5-10 20-30 Инцидентов из-за деплоя 2-3/мес 0-1/мес ~0 Больше деплоев в день — это не потому что мы хаотичнее. Маленькие частые деплои безопаснее больших редких. Каждый PR идёт в прод в тот же день. Это называется continuous delivery. И это меняет жизнь. 🚀
-
Мониторинг: от "прод упал, узнали от пользователей" до "прод думает что упасть и уже получил алерт"
Есть два типа DevOps. Первые узнают об авариях от пользователей. Вторые — за 5 минут до того как проблема станет аварией. Я прошёл путь от первого ко второму. Это было долго, больно и неочевидно. Расскажу как. Уровень 0: Никакого мониторингаЭто страшное место. Узнаёшь что что-то не так, когда: Пишет пользователь в поддержку Звонит CEO в воскресенье Видишь в Twitter "ваш сайт сломан" Это лечится быстро — после первого воскресного звонка от CEO инстинкт самосохранения быстро мотивирует к действию. Уровень 1: Uptime monitoringСамое простое: проверяем что сайт отвечает: class HealthCheck extends BaseCommand { protected $name = 'health:check'; public function run(array $params): void { $endpoints = config('HealthCheck')->endpoints; foreach ($endpoints as $endpoint) { $start = microtime(true); try { $response = \Config\Services::curlrequest()->get($endpoint['url'], [ 'timeout' => 10, 'http_errors' => false, ]); $duration = (microtime(true) - $start) * 1000; $status = $response->getStatusCode(); if ($status !== 200 || $duration > $endpoint['threshold_ms']) { $this->alertTeam($endpoint, $status, $duration); } } catch (\Exception $e) { $this->alertTeam($endpoint, 0, 0, $e->getMessage()); } } } } Лучше чем ничего. Но это как термометр в одной комнате большого дома. Уровень 2: Application метрикиPrometheus + Grafana. В CI4 интегрируем через After-фильтр: <?php namespace App\Filters; class MetricsFilter implements FilterInterface { public function before(RequestInterface $request, $arguments = null): void { $GLOBALS['_request_start_time'] = microtime(true); } public function after( RequestInterface $request, ResponseInterface $response, $arguments = null ): ResponseInterface { $duration = microtime(true) - ($GLOBALS['_request_start_time'] ?? microtime(true)); $status = $response->getStatusCode(); $route = service('router')->controllerName(); $method = $request->getMethod(); $metrics = service('metricsCollector'); $metrics->histogram( 'http_request_duration_seconds', $duration, ['method' => $method, 'route' => $route, 'status' => $status] ); $metrics->counter( 'http_requests_total', 1, ['method' => $method, 'route' => $route, 'status' => (string)intdiv($status, 100) . 'xx'] ); if ($status >= 500) { $metrics->counter('http_errors_total', 1, ['route' => $route]); } return $response; } } Уровень 3: Предиктивные алертыНастоящий прорыг — когда алерты стали срабатывать ДО того как всё упало. Пример: за 8-12 минут до OOM Redis всегда происходило: Memory usage рос быстрее обычного (+15% за 5 минут) Cache hit rate начинал падать Eviction rate был 0 Prometheus alerting rule: groups: - name: redis_predictive rules: - alert: RedisMemoryGrowthAnomaly expr: | ( redis_memory_used_bytes / redis_memory_max_bytes > 0.75 ) and ( rate(redis_memory_used_bytes[5m]) > 0 ) and ( redis_stat_keyspace_hits / (redis_stat_keyspace_hits + redis_stat_keyspace_misses) < 0.85 ) for: 3m labels: severity: warning annotations: summary: "Redis memory growing fast, potential OOM in ~10min" - alert: MySQLConnectionPoolDanger expr: | mysql_global_status_threads_connected / mysql_global_variables_max_connections > 0.8 for: 2m labels: severity: critical annotations: summary: "MySQL connection pool at {{ $value | humanizePercentage }}" Уровень 4: Synthetic monitoringПроверяем что реальный пользовательский сценарий работает end-to-end. CLI-команда CI4 каждые 2 минуты симулирует key user journey: namespace App\Commands\Synthetic; class CheckoutFlow extends BaseCommand { protected $name = 'synthetic:checkout'; public function run(array $params): void { $client = service('curlrequest'); $baseUrl = env('SYNTHETIC_BASE_URL'); $metrics = service('metricsCollector'); $start = microtime(true); $step = 'init'; try { $step = 'login'; $loginResp = $client->post($baseUrl . '/api/auth/login', [ 'json' => ['email' => env('SYNTHETIC_USER_EMAIL'), 'password' => env('SYNTHETIC_USER_PASSWORD')], 'timeout' => 5, ]); assert($loginResp->getStatusCode() === 200); $token = json_decode($loginResp->getBody(), true)['token']; $step = 'product_list'; $productsResp = $client->get($baseUrl . '/api/products', [ 'headers' => ['Authorization' => "Bearer $token"], 'timeout' => 3, ]); assert($productsResp->getStatusCode() === 200); // ... другие шаги $metrics->counter('synthetic_checkout_flow_success_total', 1); $this->write("✅ Checkout flow OK "); } catch (\Throwable $e) { $metrics->counter('synthetic_checkout_flow_failure_total', 1, ['step' => $step]); service('alertManager')->fire(level: 'critical', title: "Synthetic checkout failed at: {$step}", body: $e->getMessage()); } } } Текущий стек мониторингаPrometheus — сбор метрик Grafana — визуализация и алертинг Loki — агрегация логов Alertmanager — маршрутизация (Telegram, PagerDuty, Slack) Synthetic monitoring — CI4 CLI команды в cron OpenTelemetry + Jaeger — distributed tracing MTTR за год: Период MTTR До нормального мониторинга 47 минут После 8 минут Это не просто красивая цифра. Это живые деньги. Мониторинг — это бизнес-инвестиция с прямым ROI. 📈
-
PHP 8.3 JIT: я провёл месяц бенчмарков и вот что реально происходит
Когда PHP 8.0 вышел с JIT-компилятором, интернет взорвался. "PHP теперь БЫСТРЕЕ Python!" "JIT изменит всё!" "Переписывайте на PHP!". Заголовки были прекрасны. Реальность — немного иначе. Я потратил месяц на бенчмарки JIT в нашем реальном highload-проекте на PHP 8.3 + CodeIgniter 4.6. Расскажу что нашёл. Без маркетинга, только цифры и контекст. Немного теории (обещаю, коротко)JIT (Just-In-Time compilation) — это компиляция горячего кода в нативные машинные инструкции во время выполнения. В отличие от OPcache, который кэширует opcode (байткод PHP), JIT генерирует настоящий машинный код. PHP реализует два режима JIT: Tracing JIT (jit=tracing) — анализирует трассы выполнения, оптимизирует горячие пути. Лучше для численных вычислений. Function JIT (jit=function) — компилирует целые функции. Более предсказуемый, но менее агрессивный. Конфигурация в php.ini: [opcache] opcache.enable=1 opcache.jit=tracing opcache.jit_buffer_size=256M opcache.jit_hot_loop=64 opcache.jit_hot_func=127 opcache.jit_hot_return=8 opcache.jit_hot_side_exit=8 Бенчмарк 1: Мандельброт (классика)function mandelbrot(float $x0, float $y0): int { $x = 0.0; $y = 0.0; $iteration = 0; $maxIteration = 1000; while ($x * $x + $y * $y <= 4.0 && $iteration < $maxIteration) { $xTemp = $x * $x - $y * $y + $x0; $y = 2.0 * $x * $y + $y0; $x = $xTemp; $iteration++; } return $iteration; } Результаты: Конфигурация Время PHP 8.3, OPcache off 8.43s PHP 8.3, OPcache on, JIT off 4.21s PHP 8.3, OPcache on, JIT tracing 0.84s PHP 8.3, OPcache on, JIT function 1.12s JIT Tracing ускорил Мандельброт в 5 раз. Потрясающий результат! Но кто в вашем web-приложении считает Мандельброт? Бенчмарк 2: Реальный CI4 requestРезультаты на типичном API endpoint (среднее по 10,000 запросов): Конфигурация Среднее время P99 OPcache on, JIT off 42.3ms 89ms OPcache on, JIT tracing 41.8ms 87ms OPcache on, JIT function 42.1ms 88ms Разница: 1.2%. Это в пределах погрешности измерений. Почему JIT не помогает веб-приложениямТипичный PHP web-request проводит время примерно так: ~45% — ожидание БД (I/O bound) ~25% — сетевые операции (Redis, внешние API) ~15% — OPcache-оптимизированный PHP-код ~10% — сериализация/десериализация (JSON, etc.) ~5% — собственно бизнес-логика с вычислениями JIT помогает именно с CPU-bound вычислениями. Но в web-запросе CPU-bound части — это ~20% от общего времени. Максимальное теоретическое ускорение: 16%. На практике — 1-3%. Правильная конфигурация для highload CI4[opcache] opcache.enable=1 opcache.enable_cli=0 opcache.memory_consumption=256 opcache.interned_strings_buffer=32 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 ; В prod — ВСЕГДА 0 opcache.save_comments=1 ; CI4 использует атрибуты opcache.preload=/var/www/app/preload.php opcache.preload_user=www-data opcache.jit=tracing opcache.jit_buffer_size=128M opcache.jit_hot_loop=64 opcache.jit_hot_func=127 И обязательно — preload для CI4: <?php // preload.php $preloadClasses = [ \App\Models\UserModel::class, \App\Models\ProductModel::class, \App\Libraries\Cache\ResilientCacheService::class, \App\Services\AuthService::class, // ... топ-50 самых используемых классов ]; foreach ($preloadClasses as $class) { if (class_exists($class)) { opcache_compile_file((new \ReflectionClass($class))->getFileName()); } } С preload время ответа на первые запросы после деплоя сократилось с 200ms до 45ms. Это важнее JIT. Итог: стоит ли включать JIT?Включайте, если: У вас PHP 8.1+ (в 8.0 JIT был сыроват) Есть CPU-bound операции (обработка данных, вычисления) Достаточно памяти для JIT buffer Не ждите чудес, если: Приложение I/O-bound (БД, Redis, внешние API) Это стандартное CRUD web-приложение Надеетесь что JIT заменит оптимизацию запросов Прежде чем думать о JIT, убедитесь что правильно настроен OPcache, нет N+1 запросов, есть кэширование и нормальные индексы в БД. Это даст 10-100× ускорение. JIT — ещё 1-5% сверху. Но 5% при 10 миллионах запросов в сутки — тоже деньги. Поэтому включайте. Просто знайте зачем. 📊
-
Как мы пережили Black Friday: postmortem, который я не хотел писать
Это не та история, которой гордятся. Это история, которую рассказывают тихо, за пивом, другим DevOps'ам — чтобы они не совершили те же ошибки. Но знаете что? Я решил рассказать её громко. Потому что честность важнее репутации, а реальные истории учат лучше, чем придуманные кейсы. Black Friday. Наш e-commerce на CI4. Трафик × 15 от обычного. Что могло пойти не так? Спойлер: всё. Подготовка (которой, как оказалось, было недостаточно)За три недели до BF мы провели "подготовку". По тем временам нам казалось, что мы сделали всё правильно: ✅ Провели нагрузочный тест на 5× нормального трафика ✅ Настроили автомасштабирование k8s ✅ Прогрели CDN-кэш для статики ✅ Оптимизировали топ-20 медленных запросов ✅ Увеличили connection pool MySQL ✅ Настроили алерты Что мы не сделали (и о чём потом пожалели): ❌ Не протестировали 15× трафика (только 5×) ❌ Не протестировали scenario с высоким числом одновременных checkout операций ❌ Не проверили поведение при частичном отказе зависимостей ❌ Не подготовили runbook для дежурной команды Хронология событий00:00 — Midnight madness старт Трафик начал расти в 23:45. К полуночи — ×4 от нормы. Всё отлично, k8s масштабируется, метрики в норме, team в Teams чатится позитивно. 00:23 — Первый звонок 🔴 ALERT: Checkout error rate > 5% 5% checkout'ов не проходят. Это много. Начинаем копать. В логах: Deadlock found when trying to get lock; try restarting transaction MySQL deadlock. Два процесса пытались обновить одну и ту же запись в таблице inventory (остатки товаров) одновременно. При ×4 трафике вероятность коллизии выросла критически. Быстрое решение: добавили SELECT ... FOR UPDATE с retry-логикой: public function decrementStock(int $productId, int $quantity): bool { $maxRetries = 3; $retryDelay = 100; // ms for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { $this->db->transStart(); try { $product = $this->db ->table('inventory') ->where('product_id', $productId) ->lockForUpdate() ->get() ->getRowArray(); if ($product['stock'] < $quantity) { $this->db->transRollback(); return false; } $this->db->table('inventory') ->where('product_id', $productId) ->update(['stock' => $product['stock'] - $quantity]); $this->db->transCommit(); return true; } catch (\Exception $e) { $this->db->transRollback(); if ($attempt < $maxRetries && str_contains($e->getMessage(), 'Deadlock')) { usleep($retryDelay * 1000 * $attempt); continue; } throw $e; } } return false; } Деплой. Прошло. Продолжаем. 01:47 — Главное событие 🔴 CRITICAL: Database primary unreachable 🔴 CRITICAL: All checkout endpoints down 🔴 CRITICAL: Payment service timeout MySQL primary упал. Не лёг — упал. Полностью. Причина выяснилась позже: диск заполнился из-за бинарных логов (binary log retention не был настроен для ситуации с × 15 write операциями). df -h /var/lib/mysql # Filesystem: 100% used (512GB / 512GB) 512 гигабайт. Всё. Диск кончился. MySQL не может писать — MySQL падает. Элегантно, ничего не скажешь. Автоматический failover сработал — реплика стала primary. Это заняло 23 секунды. 23 секунды — 100% ошибок на checkout. При трафике BF это $147,000 потерянной выручки. За двадцать три секунды. Затем выяснилось: наша реплика не была настроена на роль primary. У неё не было некоторых критических stored procedures. Checkout стал работать, но с ошибками 15%. Следующие 40 минут команда в поту накатывала stored procedures на новый primary, чистила binlogs на упавшем сервере (он был ещё нужен), настраивала новую репликацию. 02:47 — Всё относительно стабильно Error rate 2.3%. Для BF — терпимо, но не хорошо. 04:15 — Redis OOM OOM command not allowed when used memory > 'maxmemory' Redis закончил память. Eviction policy была noeviction — вместо того чтобы выкидывать старые ключи, Redis начал отклонять все write-операции. Приложение посыпалось. Быстрый фикс: redis-cli CONFIG SET maxmemory-policy allkeys-lru LRU eviction включён. Redis начал вытеснять старые ключи. Кэш-хиты упали с 94% до 61%, нагрузка на MySQL снова выросла, но хотя бы приложение работало. Постмортем: что пошло не такПричина 1: Мы не тестировали реальный сценарий Нагрузочный тест в ×5 — это не BF при ×15. Мы протестировали "всё нормально", а не "всё горит". Нужно было делать chaos testing: убивать primary во время нагрузки, заполнять диск, устраивать OOM Redis. Причина 2: MySQL binlog retention -- Должно быть настроено! SET GLOBAL binlog_expire_logs_seconds = 86400; -- 24 часа Это одна строчка. ОДНА. И она бы предотвратила заполнение диска. Причина 3: Replica не была готова к роли primary Наша "репликация" была настроена для read scaling, а не для failover. Stored procedures, triggers, специфичные настройки — ничего из этого не дублировалось. Это фундаментальная ошибка в архитектуре HA. Причина 4: Redis maxmemory-policy noeviction Кто-то (я) настроил noeviction "чтобы данные не терялись". Логика благородная. Результат катастрофический. В production eviction лучше, чем полный отказ сервиса. Что изменилось после1. GameDay — обязательная практика Раз в квартал мы проводим "день катастрофы": специально ломаем production-like окружение и смотрим как команда реагирует. Сценарии: упал primary MySQL, заполнился диск, OOM Redis, одна нода k8s недоступна. 2. Runbook для каждого critical alert Каждый алерт в PagerDuty теперь ссылается на confluence-страницу с пошаговым runbook. Дежурный не должен думать — он должен читать и выполнять. 3. Pre-BF чеклист # Automated pre-event checks ./scripts/pre-event-check.sh # Checks: # ✓ MySQL disk usage < 50% # ✓ Binlog retention configured # ✓ Redis maxmemory-policy = allkeys-lru # ✓ Replica can promote to primary (stored procs present) # ✓ HPA max replicas sufficient # ✓ CDN cache warm # ✓ All alerts configured and tested # ✓ On-call rotation confirmed # ✓ Rollback plan documented Финансовые итогиОбщие потери от инцидентов BF: ~$230,000 (прямые потери выручки + компенсации + репутационный ущерб). Следующий год, после всех изменений: BF прошёл без единого critical инцидента. Error rate — 0.08%. Выручка выросла на 340% по сравнению с прошлым BF. Цена нормальной подготовки: 3 недели работы команды + $15,000 на gameday инфраструктуру. Разница: 230,000 vs 15,000. Выбор очевиден. Делитесь похожими историями — на ithub.uno такие postmortem'ы читают и обсуждают живее всего. Потому что в них — настоящий опыт. 💀➡️🧠
-
N+1 запросов: убийца производительности, которого все знают и никто не замечает
Есть проблемы, о которых говорят на каждой конференции, пишут в каждом учебнике и которые всё равно продолжают жить в каждом втором продакшн-проекте. N+1 — именно такая. Это как тараканы: знаешь о них, ведёшь с ними борьбу, думаешь что победил — а потом открываешь новый модуль и привет. Сегодня расскажу про реальный кейс из нашего highload-проекта на PHP 8.2 + CodeIgniter 4. И покажу, как мы с этим боролись системно, а не точечными заплатками. Что такое N+1 на практикеТеория все знают. Загружаешь список из N объектов, потом для каждого делаешь ещё один запрос. Итого 1 + N запросов вместо 1-2. При N=100 это 101 запрос вместо 2. Ничего страшного, да? Нет. Вот реальный пример из нашего кода. Страница со списком заказов: // OrderModel.php — выглядит невинно public function getOrdersList(int $page = 1): array { $orders = $this->paginate(50); foreach ($orders as &$order) { // Запрос #1 ... #50: грузим пользователя $order['user'] = model('UserModel')->find($order['user_id']); // Запрос #51 ... #100: грузим товары заказа $order['items'] = model('OrderItemModel') ->where('order_id', $order['id']) ->findAll(); // Запрос #101 ... #150: грузим статус доставки $order['delivery'] = model('DeliveryModel') ->where('order_id', $order['id']) ->first(); } return $orders; } 50 заказов на странице. Итого: 1 (список) + 50 (пользователи) + 50 (товары) + 50 (доставка) = 151 запрос. На страницу. Которую открывают 500 раз в минуту. Итого 75,500 запросов в минуту только на эту одну страницу. MySQL рыдал. Тихо, но рыдал. Обнаружение: логирование запросов в CI4Первым шагом была инструментация. CI4 позволяет логировать все запросы через Toolbar, но в highload нам нужно что-то более production-ready. Мы написали EventSubscriber, который считает запросы на request: <?php namespace App\Subscribers; use CodeIgniter\Events\Events; class QueryCounterSubscriber { private static int $queryCount = 0; private static array $slowQueries = []; public static function register(): void { Events::on('DBQuery', [self::class, 'onQuery']); } public static function onQuery(\CodeIgniter\Database\Query $query): void { self::$queryCount++; $duration = $query->getDuration(6); if ($duration > 0.1) { // 100ms threshold self::$slowQueries[] = [ 'sql' => $query->getQuery(), 'duration' => $duration, 'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10), ]; } if (self::$queryCount > 50) { log_message('warning', sprintf( 'N+1 suspicion: %d queries for %s %s', self::$queryCount, service('request')->getMethod(), service('request')->getUri()->getPath() )); } } public static function getReport(): array { return [ 'total' => self::$queryCount, 'slow_queries' => self::$slowQueries, ]; } } Через неделю мы нашли 23 endpoint'а с N+1. Некоторые делали до 800 запросов за один HTTP request. Один из них — статистическая страница для администратора — делал 1,247 запросов. Нет, это не опечатка. Решение 1: Eager Loading через Query BuilderВ CI4 нет встроенного ORM с eager loading как в Laravel. Но это не повод делать N+1. Пишем вручную — это даже лучше, потому что контролируешь каждый запрос: <?php namespace App\Models; class OrderModel extends Model { public function getOrdersWithRelations(int $page = 1): array { // Запрос 1: список заказов $orders = $this->paginate(50); if (empty($orders)) { return []; } $orderIds = array_column($orders, 'id'); $userIds = array_unique(array_column($orders, 'user_id')); // Запрос 2: все пользователи одним запросом $users = model('UserModel') ->whereIn('id', $userIds) ->findAll(); $usersMap = array_column($users, null, 'id'); // Запрос 3: все товары заказов одним запросом $items = model('OrderItemModel') ->whereIn('order_id', $orderIds) ->findAll(); $itemsMap = []; foreach ($items as $item) { $itemsMap[$item['order_id']][] = $item; } // Запрос 4: вся доставка одним запросом $deliveries = model('DeliveryModel') ->whereIn('order_id', $orderIds) ->findAll(); $deliveriesMap = array_column($deliveries, null, 'order_id'); // Собираем результат в памяти foreach ($orders as &$order) { $order['user'] = $usersMap[$order['user_id']] ?? null; $order['items'] = $itemsMap[$order['id']] ?? []; $order['delivery'] = $deliveriesMap[$order['id']] ?? null; } return $orders; } } 151 запрос → 4 запроса. Время ответа страницы: с 3.2 секунды до 87 миллисекунд. Разница в 37 раз. Буквально изменением подхода к написанию одного метода. Решение 2: Автоматическое обнаружение в CI4 FilterЧтобы N+1 не возвращались незаметно, добавили Filter для development/staging: <?php namespace App\Filters; use CodeIgniter\Filters\FilterInterface; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; class QueryAnalyzerFilter implements FilterInterface { private const WARNING_THRESHOLD = 30; private const ERROR_THRESHOLD = 100; public function before(RequestInterface $request, $arguments = null): void { QueryCounterSubscriber::reset(); } public function after( RequestInterface $request, ResponseInterface $response, $arguments = null ): ResponseInterface { $report = QueryCounterSubscriber::getReport(); $count = $report['total']; if ($count >= self::WARNING_THRESHOLD) { $level = $count >= self::ERROR_THRESHOLD ? 'error' : 'warning'; log_message($level, sprintf( '[QueryAnalyzer] %s %s: %d queries', $request->getMethod(), $request->getUri()->getPath(), $count )); if (ENVIRONMENT === 'development') { $response->setHeader('X-Query-Count', (string) $count); $response->setHeader('X-Query-Warning', $count >= self::ERROR_THRESHOLD ? 'N+1_DETECTED' : 'HIGH_QUERIES'); } } return $response; } } Теперь любой новый endpoint с N+1 автоматически логируется с уровнем error. Это попадает в наш ELK стек, алертинг срабатывает, приходит уведомление. Разработчик узнаёт о проблеме ещё на code review этапе, а не когда MySQL упал в прод. Цифры до и послеEndpoint Запросов ДО Запросов ПОСЛЕ Время ответа ДО Время ПОСЛЕ Список заказов 151 4 3200ms 87ms Профиль пользователя 89 3 1800ms 45ms Каталог товаров 347 5 8900ms 210ms Статистика 1247 12 31000ms 890ms Суммарная нагрузка на MySQL упала на 78%. Не шучу. Просто убрали N+1 — и почти вдвое освободили ресурсы БД. Главный выводN+1 — это не ошибка джуниоров. Это системная проблема, которая возникает когда нет инструментов для её обнаружения и нет культуры её предотвращения. Добавьте автоматическое логирование числа запросов. Сделайте Code Review check на паттерны N+1. И помните: каждый .find() внутри цикла — это потенциальная бомба. Удачи вашим базам данных. 🗄️
-
Kubernetes в продакшне: 18 месяцев, 47 инцидентов и одно просветление
Прежде чем начать, хочу сказать одну важную вещь: я люблю Kubernetes. Искренне. Как любят сложного человека — за глубину, за то, что никогда не знаешь чего ожидать, за то, что каждый день чему-то учишься. И одновременно хочется иногда взять его и... ну, вы понимаете. Восемнадцать месяцев в продакшне с k8s. Сорок семь инцидентов в PagerDuty. Из них тридцать один — "это мы сами виноваты". Остальные шестнадцать — "это k8s виноват, но мы неправильно его настроили". Итого: сорок семь раз мы были виноваты сами. Добро пожаловать в правду. Начало: эйфорияМы переехали на k8s с bare-metal + ansible + systemd. По тем временам это был большой шаг. Наконец-то: декларативная конфигурация, автомасштабирование, rolling updates без даунтайма, самолечение. Красота! Первая неделя прошла в написании манифестов. Я писал их как поэт — вдохновенно, не жалея строк. YAML цвёл. Deployments, Services, ConfigMaps, Secrets, Ingress — всё было прекрасно. apiVersion: apps/v1 kind: Deployment metadata: name: php-app spec: replicas: 3 template: spec: containers: - name: php-fpm image: myapp:latest # ← вот здесь уже первая мина resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" image: myapp:latest — это первая из ошибок, которая позже стоила нам часа даунтайма. Тег latest — это зло. Это не версия, это "бог знает что". При следующем деплое k8s может подтянуть другой образ на разные ноды, и у вас будет кластер, где половина подов — старая версия, а половина — новая, и они несовместимы. Всегда используйте immutable теги: git sha, semver, timestamp. Всегда. Инцидент #7: OOMKiller приходит ночьюНаше PHP приложение — CI4 с тяжёлой бизнес-логикой. PHP-FPM воркеры потребляют память по-разному в зависимости от endpoint'а. Лёгкий API — 40MB. Тяжёлый отчёт — 380MB. Мы выставили limits.memory: 512Mi и думали, что этого хватит. Ночью запустился cron-джоб, который генерировал ежемесячные отчёты. Каждый воркер под отчёт — ~380MB. PHP-FPM с 8 воркерами = 3GB. Лимит пода — 512MB. OOMKilled Exit Code: 137 Под убит. k8s перезапускает. Новый под опять запускает отчёт. Опять OOMKilled. Restart loop. Кейс называется CrashLoopBackOff, и выглядит он в логах примерно так: pod/php-app-7d9f8c-xk2p9 0/1 CrashLoopBackOff 14 47m Четырнадцать рестартов за 47 минут. k8s упорно пытался поднять под, PHP упорно пытался сожрать память, OOMKiller упорно его убивал. Это было похоже на зомби-апокалипсис в миниатюре. Решение: разделение воркеров Мы разделили PHP-FPM на два пула: ; /etc/php-fpm.d/www.conf — общий пул [www] pm = dynamic pm.max_children = 20 pm.max_requests = 500 ; /etc/php-fpm.d/heavy.conf — пул для тяжёлых операций [heavy] pm = ondemand pm.max_children = 4 pm.max_requests = 50 pm.process_idle_timeout = 10s И создали отдельный Deployment для heavy-операций с увеличенными лимитами: # heavy-deployment.yaml resources: requests: memory: "512Mi" cpu: "500m" limits: memory: "2Gi" cpu: "2000m" Nginx роутит по prefix: location /api/reports/ { fastcgi_pass heavy-php-fpm:9001; } location / { fastcgi_pass www-php-fpm:9000; } Элегантно? Не очень. Работает? Да. Инцидент #19: Liveness probe убивает продЭто был шедевр. Мы настроили liveness probe — k8s регулярно проверяет, жив ли под, и если нет — убивает и перезапускает: livenessProbe: httpGet: path: /health port: 80 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 Endpoint /health возвращал 200 и JSON со статусами всех зависимостей: Redis, MySQL, очередь. Казалось бы — отлично. И вот в один прекрасный день MySQL реплика начала лагать (проблема с дисками). Запросы к реплике стали занимать 15-20 секунд. Наш /health endpoint проверял реплику, таймаут probe — 5 секунд. Итог: Liveness probe failed: Get "http://10.244.2.5/health": context deadline exceeded k8s решил, что под нездоров. Убил его. Поднял новый. Новый тоже упёрся в лагающую реплику. Тоже убит. В какой-то момент k8s убивал поды быстрее, чем они успевали принять трафик. Мы устроили собственноручный DDoS на наш прод с помощью liveness probe. Это надо уметь. Решение: Разделили liveness и readiness: # Liveness: только "жив ли процесс" livenessProbe: httpGet: path: /health/live # Проверяет только PHP-FPM ping port: 80 initialDelaySeconds: 30 periodSeconds: 30 timeoutSeconds: 2 failureThreshold: 5 # Readiness: "готов ли принимать трафик" readinessProbe: httpGet: path: /health/ready # Проверяет все зависимости port: 80 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 // CI4 Controller class HealthController extends BaseController { public function live(): ResponseInterface { // Только базовая проверка процесса return $this->response->setJSON(['status' => 'ok']); } public function ready(): ResponseInterface { $checks = [ 'database' => $this->checkDatabase(), 'redis' => $this->checkRedis(), 'queue' => $this->checkQueue(), ]; $healthy = !in_array(false, $checks, true); return $this->response ->setStatusCode($healthy ? 200 : 503) ->setJSON([ 'status' => $healthy ? 'ready' : 'degraded', 'checks' => $checks, ]); } } Теперь при проблемах с репликой поды переставали получать трафик (readiness failed), но не убивались (liveness — ok). Через 20 минут реплика восстановилась, readiness снова позеленела, трафик вернулся. Без единой 500-й ошибки. Инцидент #31: HPA и смерть от масштабированияHorizontal Pod Autoscaler — прекрасная вещь. Трафик растёт — поды добавляются. Трафик падает — поды убираются. Автоматически. Без участия человека. Кроме случаев, когда всё идёт не так. Мы настроили HPA по CPU: apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: php-app-hpa spec: minReplicas: 3 maxReplicas: 30 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 Ждём нагрузочного теста. Трафик растёт. HPA видит CPU 85% → добавляет поды. Новые поды стартуют, прогревают OPcache, пока прогреваются — CPU у них 90% → HPA добавляет ещё поды. Те тоже прогреваются → CPU опять высокий → ещё поды. Через 3 минуты у нас было 28 подов из максимальных 30. Они все одновременно прогревали OPcache, коннектились к MySQL, коннектились к Redis. MySQL задыхался от 28×20 = 560 новых соединений. Redis cluster не успевал. Прод лёг под весом собственного масштабирования. Решение: Добавить cooldown и stabilization: behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 2 # Не более 2 подов за раз periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300 Использовать custom metrics (RPS), а не CPU: metrics: - type: Pods pods: metric: name: http_requests_per_second target: type: AverageValue averageValue: "300" Добавить PHP-FPM warming в startup probe: startupProbe: httpGet: path: /warmup port: 80 failureThreshold: 30 periodSeconds: 5 Endpoint /warmup прогревал OPcache и пул соединений. Пока startupProbe не вернула 200 — под не получал трафик и не учитывался в метриках HPA. ПросветлениеПосле 18 месяцев и 47 инцидентов я пришёл к простому выводу: Kubernetes не упрощает жизнь. Он переносит сложность из "как запустить" в "как правильно настроить". И это совершенно другой уровень сложности — более тонкий, более коварный, и требующий понимания системы на глубоком уровне. Ключевые правила, которые я вколотил в стену рядом с рабочим столом: Никогда image:latest — только иммутабельные теги Liveness ≠ Readiness — это разные вещи с разной ценой ошибки Resource limits — это не потолок, это граница жизни и смерти пода HPA с CPU — ловушка для PHP. Используйте RPS или custom metrics Один под не значит один процесс — PHP-FPM это N воркеров Chaos engineering — убивайте поды намеренно, пока это не сделает production И последнее: k8s — это не серебряная пуля. Это мощный инструмент с огромным количеством движущихся частей. Уважайте его, изучайте, читайте исходники при необходимости. И обязательно найдите коммьюнити — на ithub.uno, на форумах, на конференциях. Потому что некоторые грабли лучше подбирать чужим лбом. До следующего CrashLoopBackOff. 🤕
-
Redis Cluster: как я потерял данные, нашёл их обратно и поседел дважды
Есть вещи, которые меняют тебя как специалиста. Первый деплой в прод. Первый incident report, который ты пишешь в 3 ночи. И первый раз, когда ты видишь в логах Redis: CLUSTERDOWN Hash slot not served. Вот это последнее — особенное. После такого начинаешь иначе смотреть на жизнь, на архитектуру и на документацию, которую ты "почти дочитал". Сегодня расскажу про Redis Cluster в highload-продакшне. Без прикрас, без маркетинговых буклетов. Только боль, инсайты и несколько команд, которые спасли мне карьеру. Контекст: зачем вообще Redis ClusterК тому моменту мы уже года полтора успешно жили на одном Redis-инстансе с репликой. Всё было хорошо: 50GB данных, ~80,000 ops/sec в пике, latency стабильно под 1ms. Идиллия. Потом случился бизнес. Нас купили, влили денег, пользователей стало в пять раз больше. Нагрузка выросла до 380,000 ops/sec. Один Redis задыхался. CPU на инстансе — 94% (Redis однопоточный в плане основного event loop, напоминаю). Latency поползла вверх — 8ms, 15ms, 40ms... Решение очевидное: Redis Cluster. Шардирование данных по hash slots (всего 16384 слота) на несколько нод. Я читал документацию. Я смотрел туториалы. Я думал, что готов. Я не был готов. Первая попытка: наивнаяПоднял кластер из 3 мастеров + 3 реплик. Конфигурация нод: redis1 (master) — слоты 0-5460 redis2 (master) — слоты 5461-10922 redis3 (master) — слоты 10923-16383 redis4 (replica) — реплицирует redis1 redis5 (replica) — реплицирует redis2 redis6 (replica) — реплицирует redis3 Всё прекрасно работало на стейджинге. В прод переехали ночью. Первые два часа — тишина и красивые графики. Потом началось. Наш код активно использовал MGET, MSET и пайплайны. И вот тут — сюрприз из документации, которую я "почти дочитал": в Redis Cluster мульти-ключевые операции работают только если все ключи находятся в одном hash slot. CROSSSLOT Keys in request don't hash to the same slot Это сообщение я запомнил навсегда. Потому что половина нашего кода сыпала им как из ведра. Погружение в hash tagsRedis Cluster использует CRC16 от имени ключа для определения слота. Но если в ключе есть фигурные скобки {}, то для вычисления слота используется только содержимое скобок — это называется hash tag. # Эти ключи попадут в РАЗНЫЕ слоты: user:1:profile → slot 7638 user:1:settings → slot 2892 user:1:cart → slot 6899 # А эти — в ОДИН, потому что hash tag {user:1}: {user:1}:profile → slot 7638 {user:1}:settings → slot 7638 {user:1}:cart → slot 7638 Казалось бы, просто добавить фигурные скобки и всё. Но у нас было 200+ мест в коде с генерацией ключей. И это был PHP-монолит, переписанный на CI4 модули. Кайф. Мы написали специальный класс-обёртку: <?php namespace App\Libraries\Cache; class ClusterAwareCacheKey { public static function userScoped(int $userId, string $suffix): string { return sprintf('{user:%d}:%s', $userId, $suffix); } public static function sessionScoped(string $sessionId, string $suffix): string { return sprintf('{session:%s}:%s', $sessionId, $suffix); } public static function globalKey(string $key): string { // Глобальные ключи не группируем — пусть шардируются равномерно return $key; } } И CI4 Cache Driver, который умеет работать с Cluster: <?php namespace App\Libraries\Cache; use CodeIgniter\Cache\Handlers\RedisHandler; class RedisClusterHandler extends RedisHandler { protected \RedisCluster $redis; public function initialize(): void { $config = config('Cache'); $this->redis = new \RedisCluster( null, $config->redisClusterNodes, $config->redisTimeout, $config->redisReadTimeout, true, // persistent $config->redisAuth ); $this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY); } public function getMultiple(array $keys, mixed $default = null): array { // Группируем ключи по слотам для batch-операций $slotGroups = $this->groupBySlot($keys); $result = []; foreach ($slotGroups as $slotKeys) { $values = $this->redis->mget($slotKeys); foreach ($slotKeys as $i => $key) { $result[$key] = $values[$i] !== false ? $this->unserialize($values[$i]) : $default; } } return $result; } private function groupBySlot(array $keys): array { $groups = []; foreach ($keys as $key) { $slot = $this->calculateSlot($key); $groups[$slot][] = $key; } return array_values($groups); } private function calculateSlot(string $key): int { // Извлекаем hash tag если есть if (preg_match('/\{([^}]+)\}/', $key, $matches)) { $key = $matches[1]; } return crc32($key) & 0x3FFF; // 16384 слота } } Второй уровень ада: failoverМы починили CROSSSLOT. Живём. Счастливы. Три недели тишины. Потом у нас умер сервер. Не виртуалка, не под — физический сервер. Просто взял и умер в 14:22 в среду. На нём жили redis2 (мастер) и redis5 (реплика redis1, то есть другого мастера). Вот тут начался настоящий thriller. Redis Cluster должен автоматически делать failover: реплика замечает, что мастер недоступен, объявляет выборы, становится новым мастером. Это работает. Но есть нюанс, который я знал теоретически и совершенно недооценил практически. Время failover — от 15 до 30 секунд. Пятнадцать секунд, в течение которых слоты 5461-10922 не обслуживаются. При 380,000 ops/sec. Можете себе представить, как выглядел error rate в эти 15 секунд? Error rate: 0.1% → 34% → 67% → 89% → 67% → 12% → 0.3% Наш PHP-код в CI4 при ошибке Redis просто падал с exception. Никакого graceful degradation. Никакого fallback на БД. Просто 500-е ответы. Вот это был момент просветления. Решение: Circuit Breaker для RedisМы реализовали паттерн Circuit Breaker специально для Redis. В CI4 это элегантно делается через Service Container: <?php namespace App\Libraries\Cache; class ResilientCacheService { private const FAILURE_THRESHOLD = 5; private const RECOVERY_TIMEOUT = 30; private const HALF_OPEN_MAX_CALLS = 3; private string $state = 'closed'; // closed | open | half-open private int $failureCount = 0; private int $lastFailureTime = 0; private int $halfOpenCalls = 0; public function __construct( private readonly RedisClusterHandler $redis, private readonly \CodeIgniter\Cache\CacheInterface $fallback ) {} public function get(string $key, mixed $default = null): mixed { if ($this->isOpen()) { return $this->fallback->get($key, $default); } try { $value = $this->redis->get($key); $this->onSuccess(); return $value ?? $default; } catch (\Throwable $e) { $this->onFailure($e); return $this->fallback->get($key, $default); } } public function set(string $key, mixed $value, int $ttl = 0): bool { if ($this->isOpen()) { return $this->fallback->set($key, $value, $ttl); } try { $result = $this->redis->save($key, $value, $ttl); $this->onSuccess(); return $result; } catch (\Throwable $e) { $this->onFailure($e); return $this->fallback->set($key, $value, $ttl); } } private function isOpen(): bool { if ($this->state === 'open') { if (time() - $this->lastFailureTime > self::RECOVERY_TIMEOUT) { $this->state = 'half-open'; $this->halfOpenCalls = 0; return false; } return true; } return false; } private function onSuccess(): void { if ($this->state === 'half-open') { $this->halfOpenCalls++; if ($this->halfOpenCalls >= self::HALF_OPEN_MAX_CALLS) { $this->state = 'closed'; $this->failureCount = 0; } } elseif ($this->state === 'closed') { $this->failureCount = max(0, $this->failureCount - 1); } } private function onFailure(\Throwable $e): void { $this->lastFailureTime = time(); $this->failureCount++; if ($this->state === 'half-open' || $this->failureCount >= self::FAILURE_THRESHOLD) { $this->state = 'open'; log_message('critical', 'Redis Circuit Breaker OPEN: ' . $e->getMessage()); } } } Регистрируем в Services: // app/Config/Services.php public static function resilientCache(bool $getShared = true): ResilientCacheService { if ($getShared) { return static::getSharedInstance('resilientCache'); } return new ResilientCacheService( new RedisClusterHandler(), new FileHandler() // filesystem как fallback ); } Теперь при падении Redis кластера приложение продолжало работать — медленнее, с filesystem кэшем, но без 500-х ошибок. Error rate во время следующего (учинённого намеренно!) failover теста: 0.8%. Против 89% до. Третий круг: Memory fragmentationЭто тихий убийца. Redis работает, данные записываются и читаются, но потихоньку mem_fragmentation_ratio ползёт вверх. redis-cli --cluster call all-nodes INFO memory | grep mem_fragmentation_ratio Через полгода работы я увидел значение 2.47. Норма — от 1.0 до 1.5. Значение 2.47 означает, что Redis использует в 2.47 раза больше памяти, чем реально нужно для данных. На наших 80GB инстансах это ~50GB впустую. Причина — интенсивные операции записи/удаления ключей с разным TTL, что приводит к фрагментации heap у jemalloc. Решение — Active defragmentation: redis-cli CONFIG SET activedefrag yes redis-cli CONFIG SET active-defrag-ignore-bytes 100mb redis-cli CONFIG SET active-defrag-threshold-lower 10 redis-cli CONFIG SET active-defrag-threshold-upper 100 redis-cli CONFIG SET active-defrag-cycle-min 25 redis-cli CONFIG SET active-defrag-cycle-max 75 Через сутки mem_fragmentation_ratio опустился до 1.18. Мы вернули ~45GB памяти. Без перезапуска. В прод. Итог и выводыRedis Cluster — мощнейший инструмент, но он требует понимания на уровне "читал исходники, а не только README". Мои главные уроки: Hash tags планируйте заранее, не когда уже CROSSSLOT в логах Failover длится 15-30 секунд — ваш код должен это переживать Circuit Breaker — обязательный паттерн, не опциональный Мониторьте mem_fragmentation_ratio — без этого потеряете память Multi-key операции — только в одном слоте — это не баг, это дизайн Latency в cluster выше, чем в standalone — заложите это в SLA Если вы тоже занимаетесь highload и Redis — заходите на ithub.uno, там есть живые обсуждения именно таких кейсов. Без теории ради теории, только production experience. Удачи с кластерами. И пусть ваши слоты всегда будут served. 🔴✅
-
Пятница, 23:47. Прод лежит. Я в ванной. Классика.
Привет, коллеги по несчастью. Меня зовут Максим, я продуктовый DevOps с десятью годами шрамов на психике и подгоревшим нервным окончанием там, где у нормальных людей находится чувство покоя. Сегодня я расскажу вам историю, которую в каждой IT-компании мира знают наизусть, но всё равно каждый раз проживают как первый раз. Историю о том, как прод падает именно тогда, когда тебе это меньше всего нужно. Итак. Декабрь. Пятница. Мы только что задеплоили «маленький hotfix» — ну там, буквально пять строчек, ничего серьёзного. Я уже мысленно дома, уже открываю холодильник, уже слышу шипение открываемой банки. И тут — дзынь. Алерт в Telegram. Потом ещё один. Потом ещё пять. Потом просто поток, как будто кто-то открыл кран с тревогами. 🔴 CRITICAL: Response time > 30s 🔴 CRITICAL: Error rate 78% 🔴 CRITICAL: Database connections exhausted 🔴 CRITICAL: Redis timeout 🔴 CRITICAL: Payment service unreachable Пять алертов за 40 секунд. Это рекорд, кстати. Я горжусь. Анатомия катастрофыТеперь давайте по-серьёзному, потому что случай был действительно интересный с технической точки зрения, и на ithub.uno такие постморtem-разборы ценятся. Итак, что мы имели на тот момент: Стек: PHP 8.2, CodeIgniter 4.5, MySQL 8.0 (кластер primary + 2 replica), Redis 7.0 Cluster, Nginx, всё это добро в k8s на трёх нодах Трафик: ~3500 RPS в пике, средний — около 800 RPS Hotfix: изменили одну строчку в модели, которая отвечала за выборку пользовательских настроек Что могло пойти не так? Всё. Абсолютно всё. Первое, что я сделал — зашёл на Grafana. Там картина маслом: RPS упал с 800 до 120, латентность взлетела с 80ms до 28 секунд (ДВАДЦАТИ ВОСЬМИ, Карл!), количество активных connections к MySQL упёрлось в потолок — 500/500, CPU на всех подах — 95%+. Классический симптом. Я такое уже видел. Это называется «connection pool exhaustion» в сочетании с «slow query лавиной». Но причина была нетривиальной. КопаемПервым делом — slow query log на MySQL: SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 0.1; Через 30 секунд смотрю лог и вижу: SELECT u.*, us.*, p.*, pr.*, c.* FROM users u LEFT JOIN user_settings us ON us.user_id = u.id LEFT JOIN profiles p ON p.user_id = u.id LEFT JOIN preferences pr ON pr.user_id = u.id LEFT JOIN cart c ON c.user_id = u.id AND c.status = 'active' WHERE u.id = 12345 Запрос сам по себе несложный. Выполняется за 0.003 секунды. Но... EXPLAIN показывает Using temporary; Using filesort на таблице cart. И вот тут начинается детективная история. Дело в том, что за час до деплоя наш аналитик (земля ему пухом) запустил миграцию, которая добавила в таблицу cart новое поле meta_json TEXT. При этом индекс idx_cart_user_status не пересоздавался. Он просто... перестал эффективно работать после изменения статистики таблицы. MySQL решил, что full scan выгоднее. При этом таблица cart содержала 47 миллионов строк. А наш «маленький hotfix»? Он убрал кэширование этого запроса. Буквально одну строку: // Было: return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId)); // Стало (hotfix убрал кэш "для дебага"): return $this->buildUserData($userId); И вот оно. Идеальный шторм. Медленный запрос × отсутствие кэша × высокий трафик = прод в нокауте. Решение в боевых условияхВремени на красивые решения нет. Алгоритм действий: Шаг 1: Feature flag У нас есть система feature flags на Redis. Мгновенно включаю maintenance mode для новых пользователей, пропуская залогиненных: // CI4 Filter class MaintenanceFilter implements FilterInterface { public function before(RequestInterface $request, $arguments = null) { if (cache()->get('maintenance_mode') && !auth()->check()) { return redirect()->to('/maintenance'); } } } Трафик упал на 40%. Дышим. Шаг 2: Откат kubectl rollout undo deployment/app --to-revision=15 Это не решение проблемы с индексом, но снимает острую боль — кэш вернулся, запросы снова летают. Шаг 3: Экстренное создание индекса Пока прод восстанавливается, параллельно: -- На реплике сначала тестируем CREATE INDEX idx_cart_user_status_new ON cart(user_id, status) INCLUDE (id, created_at, meta_json) ALGORITHM=INPLACE, LOCK=NONE; ALGORITHM=INPLACE, LOCK=NONE — это не просто красивые слова. На таблице в 47 миллионов строк это разница между «индекс создаётся 4 минуты без локов» и «БД заблокирована на 25 минут, прощайте». Через 6 минут индекс на реплике. Тестируем — запрос летает за 1.2ms. Прогоняем на primary. Ещё 4 минуты. Готово. Шаг 4: Хотфикс хотфикса return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId)); Да, просто вернули строку обратно. Иногда лучшее решение — отмотать назад. Постмортем и урокиЧерез два дня я провёл постмортем. Вот ключевые выводы, которые я теперь вколачиваю в голову каждому новому разработчику: 1. Никогда не убирайте кэш "для дебага" в прод Это как снять шлем "чтобы лучше видеть". Дебажьте на стейджинге. Там специально и создано это место. 2. Любая миграция схемы БД требует аудита индексов Мы внедрили правило: к каждому PR с миграцией прилагается EXPLAIN до и после на production-like данных (у нас есть анонимизированный дамп). 3. Алерты должны быть actionable Пять одновременных алертов — это не пять проблем. Это одна проблема с пятью симптомами. Мы перенастроили alertmanager с группировкой и подавлением дублей. 4. Connection pool — ваш первый друг и первый враг В CI4 мы теперь явно конфигурируем пул: // app/Config/Database.php public array $default = [ 'DBDriver' => 'MySQLi', 'hostname' => env('DB_HOST'), 'pconnect' => false, // persistent connections OFF в highload! 'DBDebug' => false, // ... ]; Persistent connections в highload — это бомба замедленного действия. Отключайте. 5. Chaos engineering — не роскошь, а необходимость После этого инцидента мы раз в квартал намеренно убиваем случайный под в прод-кластере. Да, в проде. Нет, это не безумие — это единственный способ убедиться, что система действительно resilient. ФиналВ 01:23 прод поднялся полностью. Показатели вернулись к норме. Я наконец открыл ту банку. Она была тёплой. Но знаете что? Этот инцидент стоил нам примерно $4,000 потерянной выручки и несколько седых волос. Зато мы получили бесценный опыт и полностью переработали процесс деплоя. Теперь у нас есть автоматическая проверка slow queries перед каждым деплоем, обязательный review индексов при миграциях и — самое главное — правило: никаких деплоев в пятницу после 18:00. Это правило нарушают только те, кто ещё не прожил свою первую пятничную аварию. После первой — никогда.
-
43d231f0fc54563a97cb9954a0b8f0d6455a52cd7fa32d7b92eda3a8168cf5cc_1.jpg
- IMG_3974.jpeg
-
У нас появилась группа IThub в телеграмме
Рады сообщить ,что мы сделали группу в Телеграмм, с топиками ботами и прочими приколюхами))) Заходи и подписывайся в ТГ
-
Systemd: The Complete Guide from Zero to Hero — Architecture, Units, cgroups, Logging, and Real-World Examples
Introduction: Why SysVinit Died and What Systemd FixedImagine a chef cooking dinner for 10 guests but making each dish completely from scratch, one at a time — starting the salad only after the soup is fully served. That's essentially how SysVinit worked: it started services one by one, in a fixed order, regardless of whether they were actually dependent on each other. As Linux systems grew more complex, this became a serious bottleneck: Slow boot times. Service A waits for Service B to finish, even if there's zero dependency between them. No process tracking. Init launched a script and moved on. A child process crashed? SysVinit had no idea. Log chaos. Every service wrote logs wherever it wanted — /var/log/nginx/, syslog, /tmp/ — no unified interface. Brittle shell scripts. The /etc/init.d/ scripts were fragile, hard to maintain, and inconsistent across distros. In 2010, Lennart Poettering introduced systemd to solve all of these problems simultaneously: parallel startup, dependency graphs, control groups, and centralized logging. The community response was controversial (to put it mildly), but today systemd is the de-facto standard on Fedora, Debian, Ubuntu, Arch, RHEL, and most other major distributions. Let's break it down piece by piece. Part 1. Systemd Architecture — What's Under the Hood1.1 PID 1 — The Ruler of All ProcessesWhen the Linux kernel boots, it launches the very first user-space process with PID 1. On systemd systems, that process IS the systemd daemon. It's the direct parent of everything else in the system. This matters for two reasons: If PID 1 crashes, the system panics. Hence systemd is written to be extremely robust. All orphaned processes (whose parent died) are automatically reparented to PID 1. Linux Kernel └── systemd (PID 1) ├── journald (logging) ├── udevd (device management) ├── networkd (networking) ├── nginx.service (your web server) ├── postgresql.service (database) └── ... all other services1.2 Key Componentssystemd (PID 1) The conductor of the whole orchestra. It reads unit files, builds a dependency graph, launches processes in the right order, and tracks them via cgroups. systemctl Your control panel. When you type systemctl start nginx, this tool does NOT start nginx directly. It sends a D-Bus message to the systemd daemon, which does the actual work. This is a fundamental difference from running a script. journald Centralized logging daemon. It captures stdout and stderr from all services, enriches each entry with structured metadata (PID, UID, unit name, hostname), and stores everything in a binary format that supports complex queries — think SQL for logs. udevd Device manager. When you plug in a USB drive, udevd creates /dev/sdb, loads the appropriate kernel modules, and can trigger specific services. networkd, timedated, logind Specialized daemons for network management, system time, and user sessions. They all communicate with PID 1 via D-Bus. 1.3 D-Bus — The Communication BackboneD-Bus is an inter-process communication (IPC) system bus — think of it as an internal messaging platform between processes. Instead of processes calling each other's functions directly (which is unsafe), they send structured messages through the bus. Example flow for systemctl start nginx: systemctl forms a D-Bus message: "Call the StartUnit method with argument nginx.service" The message goes onto the system bus The systemd daemon receives and processes it Returns the result through the same bus This provides security (permissions checked at the D-Bus level), flexibility (any program can manage services), and extensibility. Part 2. Units — The Building Blocks of SystemdA unit is a description of any system resource as a declarative configuration file. Think of it as the "passport" for a service, socket, timer, or mount point. 2.1 Where Units LivePath Purpose Priority /usr/lib/systemd/system/ Units installed by package manager Lowest /etc/systemd/system/ Your custom units and overrides High /run/systemd/system/ Temporary units (gone after reboot) Highest 2.2 Unit Types.service — Service Units (Most Common)Describes a daemon or process. This is what you'll use in 90% of cases. ini # /etc/systemd/system/myapp.service [Unit] Description=My Awesome Application After=network.target postgresql.service Requires=postgresql.service [Service] Type=simple ExecStart=/usr/bin/myapp --config /etc/myapp/config.yml ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5s User=myapp Group=myapp [Install] WantedBy=multi-user.targetThe Type= parameter — get this right: Type Behavior When to Use simple Service is considered started immediately after ExecStart launches Most modern applications forking Program calls fork() and the parent exits. Systemd waits for this. Classic Unix daemons (nginx, apache) notify Program signals systemd via sd_notify() when ready Programs with native systemd API support oneshot Program runs and exits. Systemd waits for completion. Scripts, one-off tasks dbus Service is considered started when it claims a D-Bus name Daemons using D-Bus idle Start delayed until all other jobs complete Low-priority background tasks Restart policy: ini [Service] # Restart= options: # no — never restart # on-success — only on exit code 0 # on-failure — on non-zero exit, signal, or timeout (most common choice) # on-abnormal — on signal or timeout (not normal exit) # always — always restart (even after systemctl stop!) Restart=on-failure RestartSec=5s # Limit restart attempts: # Max 5 attempts within 30 seconds, then give up StartLimitIntervalSec=30s StartLimitBurst=5.socket — Socket-Based Activation (Lazy Launch)This is one of the most powerful and underappreciated features of systemd. The idea: why keep 20 services running when most of them get called once an hour? Socket-based activation works like this: systemd opens and listens on a socket (port, Unix socket, or FIFO) The actual service is not running The first connection arrives systemd launches the service and passes the established connection to it The client never notices — the connection isn't lost! ini # /etc/systemd/system/echo.socket [Unit] Description=Echo Server Socket [Socket] ListenStream=12345 Accept=no [Install] WantedBy=sockets.targetini # /etc/systemd/system/echo.service [Unit] Description=Echo Server [Service] Type=simple ExecStart=/usr/local/bin/echo-server # Service receives the socket via file descriptor 3 StandardInput=socketEnable: sudo systemctl enable --now echo.socket — and the service starts on the first connection. .timer — Cron Replacement with SuperpowersSystemd timers beat cron on several fronts: Support dependencies (run only if some service is running) Logged in journald like any other unit Can "catch up" on missed runs after reboot (Persistent=true) Support random delays to spread load across the hour ini # /etc/systemd/system/backup.timer [Unit] Description=Daily Backup Timer [Timer] # Run every day at 02:30 OnCalendar=*-*-* 02:30:00 # Random delay up to 10 minutes (don't hammer the server at exactly 02:30!) RandomizedDelaySec=10m # Run the task if it was missed (e.g. system was off) Persistent=true [Install] WantedBy=timers.targetini # /etc/systemd/system/backup.service [Unit] Description=Daily Backup Job [Service] Type=oneshot ExecStart=/usr/local/bin/backup.sh User=backupEnable: sudo systemctl enable --now backup.timer Check all active timers: systemctl list-timers --all OnCalendar syntax cheatsheet: Expression Meaning daily Every day at 00:00 weekly Every Monday at 00:00 monthly 1st of every month *-*-* 09:00:00 Every day at 09:00 Mon-Fri *-*-* 08:30:00 Weekdays at 08:30 *-*-1,15 00:00:00 1st and 15th of every month Validate an expression: systemd-analyze calendar "Mon-Fri *-*-* 08:30:00" .target — Unit Groups (Replacing Runlevels)A target is not a service — it's a synchronization point. Think of it as a "system state" to be reached. Target SysV Runlevel Meaning poweroff.target 0 Shutdown rescue.target 1 Single-user mode multi-user.target 3 Multi-user, no GUI graphical.target 5 With graphical interface reboot.target 6 Reboot bash # Check current default target (like current runlevel) systemctl get-default # Switch target (like init 3) sudo systemctl isolate multi-user.target # Set default target sudo systemctl set-default multi-user.target2.3 Unit Dependencies — A Graph, Not a QueueThis is one of the key differentiators from SysVinit. Instead of a fixed sequence, systemd builds a directed dependency graph. Dependency directives: Directive Type Behavior Requires= Hard If the dependency fails to start, this unit also fails and stops with it Wants= Soft Tries to start the dependency, but won't stop if it fails BindsTo= Very hard Like Requires, but this unit stops whenever the dependency stops PartOf= One-way Stops/restarts together with the dependency, but doesn't start with it Conflicts= Conflict Cannot run simultaneously with the specified unit Ordering directives: Directive Behavior After= This unit starts AFTER the specified one Before= This unit starts BEFORE the specified one Part 3. cgroups — Why Systemd Always Knows Your Processes3.1 The Problem cgroups SolveConsider: nginx is running. It forks 4 workers. One worker forks a CGI process. That forks something else. Now there are 10 processes, all "belonging" to nginx, but in SysVinit there was no way to track this. Control Groups (cgroups) are a Linux kernel mechanism that lets you group processes hierarchically and manage them collectively. Systemd automatically creates a cgroup for every service. All child processes live inside that group. Always. /sys/fs/cgroup/ ├── system.slice/ │ ├── nginx.service/ ← all nginx processes here │ │ ├── pid: 1234 (master) │ │ ├── pid: 1235 (worker 1) │ │ ├── pid: 1236 (worker 2) │ │ └── pid: 1237 (cache loader) │ ├── postgresql.service/ │ └── redis.service/ └── user.slice/ └── user-1000.slice/ ← user processes3.2 What cgroups Give You in PracticeClean process termination — no zombie processes: When you run systemctl stop nginx, systemd sends the signal to the entire cgroup — all 10 processes die, including ones you didn't know existed. No more phantom workers. Monitoring: bash # Show process tree for a service's cgroup systemd-cgls /system.slice/nginx.service # Real-time resource monitoring (like top, but for cgroups) systemd-cgtop3.3 Resource Limits via Unit FilesInstead of manually configuring cgroups, just add lines to your [Service] section: ini [Service] # === MEMORY === # Soft limit: systemd will aggressively reclaim memory MemoryHigh=400M # Hard limit: OOM Killer will kill the process if exceeded MemoryMax=512M # Guaranteed memory (won't be given to others) MemoryMin=100M # === CPU === # 50% of a single core CPUQuota=50% # Or: CPU weight (1-10000, default=100) CPUWeight=200 # === DISK I/O === IOReadBandwidthMax=/dev/sda 50M IOWriteBandwidthMax=/dev/sda 20M # === NETWORK === IPAccounting=yes IPAddressAllow=192.168.0.0/24 IPAddressDeny=anyVerify current limits: bash # Check cgroup filesystem directly cat /sys/fs/cgroup/system.slice/nginx.service/memory.max # 536870912 (512 MB in bytes) # Or via systemctl systemctl show nginx.service | grep -E 'Memory|CPU|IO'Part 4. journald — Logs as a Database4.1 Why journald Beats Plain Text LogsA plain syslog is a text file. Want to find all nginx errors from the last hour? You write grep "error" /var/log/nginx/error.log | grep "$(date +%b\ %d)" and hope for the best. journald is a structured store with indexes. Every entry is not a text string but an object with fields: _SYSTEMD_UNIT=nginx.service ← which service _PID=1234 ← which process _UID=www-data ← which user _HOSTNAME=web-01 ← which host PRIORITY=3 ← severity level (err) MESSAGE=connection refused... ← the message itself _SOURCE_REALTIME_TIMESTAMP=... ← precise timestamp4.2 Complete journalctl Referencebash # === BASIC QUERIES === # All logs for a service sudo journalctl -u nginx.service # Last 50 lines sudo journalctl -u nginx.service -n 50 # Follow in real time (like tail -f) sudo journalctl -u nginx.service -f # From a specific time sudo journalctl -u nginx.service --since "2024-01-15 10:00:00" sudo journalctl -u nginx.service --since "1 hour ago" sudo journalctl -u nginx.service --since today sudo journalctl -u nginx.service --since yesterday --until "2024-01-14 23:59:59" # === FILTERING BY SEVERITY === # 0=emerg, 1=alert, 2=crit, 3=err, 4=warning, 5=notice, 6=info, 7=debug sudo journalctl -p err # only errors sudo journalctl -p err..warning # err through warning sudo journalctl -u nginx -p warning # nginx warnings only # === FILTERING BY BOOT === sudo journalctl -b # current boot sudo journalctl -b -1 # previous boot sudo journalctl -b -2 # two boots ago sudo journalctl --list-boots # list all boots # === OUTPUT FORMATS === sudo journalctl -u nginx -o json # JSON (for parsing) sudo journalctl -u nginx -o json-pretty # Formatted JSON sudo journalctl -u nginx -o verbose # All metadata fields sudo journalctl -u nginx -o cat # Message text only # === ADVANCED QUERIES === # Logs for a specific process sudo journalctl _PID=1234 # Logs from a specific user sudo journalctl _UID=1000 # Combine conditions (OR) sudo journalctl _SYSTEMD_UNIT=nginx.service + _SYSTEMD_UNIT=php-fpm.service # Export to file sudo journalctl -u nginx --since today -o json > nginx-today.json # === JOURNAL MANAGEMENT === # Disk usage of the journal sudo journalctl --disk-usage # Clean up logs older than 2 weeks sudo journalctl --vacuum-time=2weeks # Clean up to a specific size sudo journalctl --vacuum-size=500MPart 5. Real-World Scenarios5.1 Creating a Production-Ready Service from Scratchini # /etc/systemd/system/api-server.service [Unit] Description=API Server Documentation=https://github.com/company/api-server After=network-online.target Wants=network-online.target Requires=postgresql.service After=postgresql.service [Service] Type=notify ExecStart=/usr/local/bin/api-server EnvironmentFile=/etc/api-server/env Environment="PORT=8080" Environment="LOG_LEVEL=info" Restart=on-failure RestartSec=5s StartLimitIntervalSec=60s StartLimitBurst=3 User=api Group=api WorkingDirectory=/opt/api-server # === SECURITY HARDENING === NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict ProtectHome=yes ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes SystemCallFilter=@system-service ReadWritePaths=/var/lib/api-server /var/log/api-server # === RESOURCE LIMITS === MemoryMax=512M CPUQuota=200% LimitNOFILE=65536 [Install] WantedBy=multi-user.target5.2 Drop-in Files — Override Without Touching Originalsbash # systemctl edit creates the override file automatically sudo systemctl edit nginx.service # Creates: /etc/systemd/system/nginx.service.d/override.confini [Service] MemoryMax=256M Restart=always Environment="NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx"bash sudo systemctl daemon-reload sudo systemctl restart nginx.service # View the full effective config (original + drop-ins) sudo systemctl cat nginx.service5.3 Boot Time Analysis and Optimizationbash # Total boot time systemd-analyze # Startup finished in 2.134s (kernel) + 8.643s (userspace) = 10.777s # Top boot-time offenders systemd-analyze blame # Critical path to a specific target systemd-analyze critical-chain graphical.target # Generate visual timeline (open in browser!) systemd-analyze plot > boot-plot.svg # Validate a unit file for errors systemd-analyze verify /etc/systemd/system/myapp.service5.4 Diagnosing a Failing Service — Step by Stepbash # Step 1: Service status sudo systemctl status myapp.service # Step 2: Recent logs with full detail sudo journalctl -u myapp.service -n 100 --no-pager # Step 3: Logs since last boot (for startup issues) sudo journalctl -u myapp.service -b # Step 4: All errors in the system at the time of failure sudo journalctl -p err --since "10 min ago" --no-pager # Step 5: Check dependencies systemctl list-dependencies myapp.service # Step 6: Run manually as the service user (to reproduce) sudo -u myapp /usr/local/bin/myapp --config /etc/myapp/config.yml # Step 7: Check environment variables sudo systemctl show myapp.service -p Environment # Step 8: Check file permissions sudo systemctl cat myapp.service | grep -E 'ExecStart|WorkingDirectory|User' sudo ls -la /usr/local/bin/myappQuick Reference CheatsheetService ControlTask Command Start sudo systemctl start <name> Stop sudo systemctl stop <name> Restart sudo systemctl restart <name> Reload config (no stop) sudo systemctl reload <name> Status systemctl status <name> Enable autostart sudo systemctl enable <name> Disable autostart sudo systemctl disable <name> Enable AND start sudo systemctl enable --now <name> Block permanently sudo systemctl mask <name> Viewing StateTask Command All running services systemctl list-units --type=service --state=running All failed systemctl --failed Check autostart systemctl is-enabled <name> Check active systemctl is-active <name> Dependency tree systemctl list-dependencies <name> Who depends on this systemctl list-dependencies --reverse <name> All timers systemctl list-timers Logs (journalctl)Task Command Service logs sudo journalctl -u <name> Last N lines sudo journalctl -u <name> -n 50 Real-time sudo journalctl -u <name> -f Current boot sudo journalctl -u <name> -b Errors only sudo journalctl -u <name> -p err Since time sudo journalctl -u <name> --since "1h ago" Journal size sudo journalctl --disk-usage Cleanup sudo journalctl --vacuum-time=2weeks Performance DiagnosticsTask Command Boot time systemd-analyze Boot bottlenecks systemd-analyze blame Critical chain systemd-analyze critical-chain <target> Visual timeline systemd-analyze plot > boot.svg Validate unit file systemd-analyze verify /path/to/unit cgroup tree systemd-cgls cgroup resource usage systemd-cgtop ConclusionSystemd is not a monster to be feared — it's a powerful tool that makes you dramatically more effective as a sysadmin or developer. Key takeaways from this guide: Units are declarative resource descriptions. Write them properly and use available security directives. cgroups mean systemd always knows where your processes are. Use this for monitoring and resource constraints. journald is a database, not a text file. Learn to query it properly. Drop-in files — never edit original package-installed unit files. systemd-analyze — your first tool when diagnosing boot problems.
-
Systemd: Полное руководство от новичка до эксперта — Архитектура, Юниты, cgroups, Логирование и Реальные Примеры
Введение: Почему SysVinit умер, и при чём тут SystemdПредставьте себе повара, который готовит ужин на 10 человек, но строго по одному блюду за раз — сначала суп, потом только начинает нарезать салат. Именно так работала старая система инициализации SysVinit: запускала службы строго по одной, в заранее заданном порядке. Независимо от того, зависят ли они друг от друга. С усложнением Linux-систем это стало болью: Медленная загрузка. Служба A ждёт завершения службы B, даже если между ними нет никакой реальной зависимости. Нет контроля за процессами. Запустил — и забыл. Упал дочерний процесс? SysVinit об этом не знает. Хаос в логах. Каждый сервис пишет куда хочет: один в /var/log/nginx/, другой в syslog, третий в /tmp/. Никакой единой точки входа. Хрупкие скрипты. Shell-скрипты в /etc/init.d/ — это огромное поле для ошибок и несовместимостей между дистрибутивами. В 2010 году Леннарт Поттеринг (Lennart Poettering) представил systemd — систему, которая решала все эти проблемы разом. Параллельный запуск, граф зависимостей, контрольные группы, централизованные логи. Сообщество поначалу встретило его в штыки (споры были жаркими), но сегодня systemd — стандарт де-факто в Fedora, Debian, Ubuntu, Arch, RHEL, CentOS и большинстве других дистрибутивов. Давайте разберём его по косточкам. Часть 1. Архитектура systemd — что происходит под капотом1.1 PID 1 — главный процесс системыКогда ядро Linux загружается, оно запускает самый первый пользовательский процесс с PID 1. В системах с systemd это и есть демон systemd. Он — прямой родитель всех остальных процессов в системе. Это важно по двум причинам: Если PID 1 упадёт — система паникует. Поэтому systemd написан максимально надёжно. Все процессы, которые становятся «сиротами» (их родитель умер), автоматически переходят под крыло PID 1. Ядро Linux └── systemd (PID 1) ├── journald (логирование) ├── udevd (устройства) ├── networkd (сеть) ├── nginx.service (ваш веб-сервер) ├── postgresql.service (БД) └── ... все остальные сервисы1.2 Ключевые компоненты системыsystemd (PID 1) Главный дирижёр. Читает юнит-файлы, строит граф зависимостей, запускает процессы в нужном порядке, следит за их состоянием через cgroups. systemctl Ваш пульт управления. Когда вы пишете systemctl start nginx, эта утилита НЕ запускает nginx напрямую. Она отправляет сообщение по D-Bus демону systemd, который и выполняет работу. Это ключевое отличие от простого вызова скрипта. journald Централизованная система логирования. Перехватывает stdout и stderr всех сервисов, обогащает каждую запись метаданными (PID, UID, имя юнита, хостнейм) и сохраняет в структурированном бинарном формате. Это позволяет делать сложные запросы к логам — как SQL к базе данных. udevd Менеджер устройств. Когда вы подключаете USB-флешку, именно udevd создаёт /dev/sdb, загружает нужные модули ядра и может запустить определённый сервис. networkd, timedated, logind Специализированные демоны для управления сетью, системным временем и пользовательскими сессиями. Они общаются с PID 1 через D-Bus. 1.3 D-Bus — как компоненты разговаривают друг с другомD-Bus — это системная шина сообщений, аналог внутренней корпоративной почты между процессами. Вместо того чтобы процессы вызывали функции друг друга напрямую (что небезопасно), они отправляют структурированные сообщения через шину. Практический пример: systemctl start nginx systemctl формирует D-Bus-сообщение: «Вызови метод StartUnit с аргументом nginx.service» Сообщение уходит в системную шину Демон systemd получает его и выполняет Возвращает ответ через ту же шину Это даёт безопасность (права проверяются на уровне D-Bus), гибкость (любая программа может управлять сервисами) и расширяемость. Часть 2. Юниты — строительные блоки systemdЮнит (unit) — это описание любого системного ресурса в виде декларативного конфигурационного файла. Думайте о нём как о «паспорте» для сервиса, сокета, таймера или точки монтирования. 2.1 Где хранятся юнитыПуть Назначение Приоритет /usr/lib/systemd/system/ Юниты, установленные пакетным менеджером Низкий /etc/systemd/system/ Ваши кастомные юниты и переопределения Высокий /run/systemd/system/ Временные юниты (исчезают после перезагрузки) Высший 2.2 Типы юнитов.service — сервисы (самый частый тип)Описывает демон или процесс. Именно с ним вы работаете в 90% случаев. ini # /etc/systemd/system/myapp.service [Unit] Description=My Awesome Application After=network.target postgresql.service Requires=postgresql.service [Service] Type=simple ExecStart=/usr/bin/myapp --config /etc/myapp/config.yml ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5s User=myapp Group=myapp [Install] WantedBy=multi-user.targetПараметр Type= — это важно понимать правильно: Тип Поведение Когда использовать simple systemd считает сервис запущенным сразу после старта ExecStart Большинство современных программ forking Программа делает fork() и завершает родительский процесс. systemd ждёт этого. Старые Unix-демоны (nginx, apache) notify Программа сама сигнализирует systemd через sd_notify(), что готова Программы с поддержкой systemd API oneshot Программа выполняется и завершается. systemd ждёт завершения. Скрипты, одноразовые задачи dbus Сервис считается запущенным, когда занял имя на D-Bus Демоны, использующие D-Bus idle Запуск откладывается до завершения всех остальных задач Фоновые задачи с низким приоритетом Жизненный цикл и перезапуск: ini [Service] # Варианты для Restart=: # no — не перезапускать никогда # on-success — только при коде выхода 0 # on-failure — при любом ненулевом коде, сигнале или таймауте (самый частый выбор) # on-abnormal — при сигнале или таймауте (не при коде выхода) # always — перезапускать всегда (даже при systemctl stop!) Restart=on-failure RestartSec=5s # Ограничение на количество перезапусков: # Максимум 5 попыток за 30 секунд, потом сдаться StartLimitIntervalSec=30s StartLimitBurst=5.socket — socket-based activation (ленивый запуск)Это одна из самых мощных и недооценённых фич systemd. Идея простая: зачем держать 20 сервисов запущенными, если большинство из них обращаются раз в час? Socket-based activation работает так: systemd открывает и слушает сокет (порт, Unix socket, FIFO) Сам сервис не запущен Приходит первое подключение systemd запускает сервис и передаёт ему уже установленное соединение Клиент не замечает разницы — соединение не потеряно! ini # /etc/systemd/system/echo.socket [Unit] Description=Echo Server Socket [Socket] ListenStream=12345 Accept=no [Install] WantedBy=sockets.targetini # /etc/systemd/system/echo.service [Unit] Description=Echo Server Service [Service] Type=simple ExecStart=/usr/local/bin/echo-server # Сервис получит сокет через файловый дескриптор 3 StandardInput=socketАктивация: sudo systemctl enable --now echo.socket — и сервис будет запускаться автоматически при первом подключении. .timer — замена cron с суперспособностямиТаймеры systemd мощнее cron по нескольким причинам: Поддерживают зависимости (запустить только если работает такой-то сервис) Логируются в journald как обычные юниты Могут «догнать» пропущенные запуски после перезагрузки (Persistent=true) Точность до секунды и поддержка случайных задержек для распределения нагрузки ini # /etc/systemd/system/backup.timer [Unit] Description=Daily Backup Timer [Timer] # Запускать каждый день в 02:30 OnCalendar=*-*-* 02:30:00 # Случайная задержка до 10 минут (не всё одновременно в 02:30!) RandomizedDelaySec=10m # Запустить задачу, если она была пропущена (например, система была выключена) Persistent=true [Install] WantedBy=timers.targetini # /etc/systemd/system/backup.service [Unit] Description=Daily Backup Job [Service] Type=oneshot ExecStart=/usr/local/bin/backup.sh User=backupАктивация: sudo systemctl enable --now backup.timer Проверить все активные таймеры: systemctl list-timers --all Синтаксис OnCalendar: Выражение Значение daily Каждый день в 00:00 weekly Каждый понедельник в 00:00 monthly 1-го числа каждого месяца *-*-* 09:00:00 Каждый день в 09:00 Mon-Fri *-*-* 08:30:00 По будням в 08:30 *-*-1,15 00:00:00 1-го и 15-го каждого месяца Проверить выражение: systemd-analyze calendar "Mon-Fri *-*-* 08:30:00" .target — группы юнитов (замена runlevel)Target — это не сервис, а точка синхронизации. Думайте об этом как о «состоянии системы», которого нужно достичь. Target Аналог runlevel Значение poweroff.target 0 Выключение rescue.target 1 Однопользовательский режим multi-user.target 3 Многопользовательский без GUI graphical.target 5 С графическим интерфейсом reboot.target 6 Перезагрузка bash # Узнать текущий target (аналог текущего runlevel) systemctl get-default # Сменить target (аналог init 3) sudo systemctl isolate multi-user.target # Установить target по умолчанию sudo systemctl set-default multi-user.target.path — реакция на события файловой системыАналог incron. Запускает сервис при изменениях в файловой системе. ini # /etc/systemd/system/watch-uploads.path [Unit] Description=Watch for new uploads [Path] # Запустить связанный .service когда появится этот файл PathExists=/var/spool/uploads/trigger.flag # Или мониторить директорию на изменения DirectoryNotEmpty=/var/spool/uploads/ [Install] WantedBy=multi-user.target2.3 Зависимости между юнитами — граф, а не очередьЭто одно из ключевых отличий systemd от SysVinit. Вместо фиксированного порядка — направленный граф зависимостей. Директивы зависимостей: Директива Тип Поведение Requires= Жёсткая Если зависимость не запустилась — этот юнит тоже не стартует и останавливается вместе с ней Wants= Мягкая Пробует запустить зависимость, но если та упадёт — не останавливается BindsTo= Очень жёсткая Как Requires, но юнит останавливается если зависимость остановится в любой момент PartOf= Односторонняя Останавливается/перезапускается вместе с зависимостью, но не запускается вместе с ней Conflicts= Конфликт Не может работать одновременно с указанным юнитом Директивы порядка: Директива Поведение After= Этот юнит запускается ПОСЛЕ указанного Before= Этот юнит запускается ДО указанного ini [Unit] # Правильная комбинация: сначала БД, потом мы, и мы не работаем без БД After=postgresql.service Requires=postgresql.serviceЧасть 3. cgroups — почему systemd знает всё о ваших процессах3.1 Проблема, которую решают cgroupsПредставьте: nginx запущен. Он форкает 4 воркера. Один воркер форкает ещё процесс для CGI. Тот форкает что-то ещё. Итого 10 процессов, и все они «принадлежат» nginx, но в SysVinit это было невозможно отследить. Control Groups (cgroups) — механизм ядра Linux, который позволяет объединять процессы в иерархические группы и управлять ими совместно. Systemd автоматически создаёт cgroup для каждого сервиса. Все дочерние процессы — внутри этой группы. Всегда. /sys/fs/cgroup/ ├── system.slice/ │ ├── nginx.service/ ← все процессы nginx здесь │ │ ├── pid: 1234 (master) │ │ ├── pid: 1235 (worker 1) │ │ ├── pid: 1236 (worker 2) │ │ └── pid: 1237 (cache loader) │ ├── postgresql.service/ ← и postgres здесь │ └── redis.service/ └── user.slice/ └── user-1000.slice/ ← процессы пользователя3.2 Что даёт cgroup на практикеТочный kill без зомби-процессов: При systemctl stop nginx systemd отправляет сигнал всей cgroup — умирают все 10 процессов, включая те, о которых вы не знали. Больше никаких «phantom workers». Мониторинг: bash # Показать дерево процессов cgroup сервиса systemd-cgls /system.slice/nginx.service # Вывод: # /system.slice/nginx.service # └─ 1234 /usr/sbin/nginx -g daemon off; # ├─ 1235 nginx: worker process # ├─ 1236 nginx: worker process # └─ 1237 nginx: cache loader process # Мониторинг ресурсов в реальном времени (как top, но для cgroups) systemd-cgtop3.3 Ограничение ресурсов через юнит-файлыЭто магия. Вместо сложных настроек cgroups вручную — просто добавляете строки в секцию [Service]: ini [Service] # === ПАМЯТЬ === # Мягкий лимит: systemd начнёт агрессивно освобождать память MemoryHigh=400M # Жёсткий лимит: OOM Killer убьёт процесс если превысит MemoryMax=512M # Гарантированная память (не будет отдана другим) MemoryMin=100M # === CPU === # 50% от одного ядра CPUQuota=50% # Или: вес CPU (1-10000, default=100) CPUWeight=200 # === ДИСК (I/O) === # Ограничение чтения/записи для конкретного устройства IOReadBandwidthMax=/dev/sda 50M IOWriteBandwidthMax=/dev/sda 20M # === СЕТЬ (через IP accounting) === IPAccounting=yes IPAddressAllow=192.168.0.0/24 IPAddressDeny=anyПроверка текущих лимитов: bash # Посмотреть параметры cgroup напрямую в файловой системе cat /sys/fs/cgroup/system.slice/nginx.service/memory.max # 536870912 (512 МБ в байтах) # Или через systemctl systemctl show nginx.service | grep -E 'Memory|CPU|IO'Часть 4. journald — логи как база данных4.1 Почему journald лучше текстовых логовОбычный syslog — это текстовый файл. Хочешь найти все ошибки nginx за последний час? Пишешь grep "error" /var/log/nginx/error.log | grep "$(date +%b\ %d)" и молишься. journald — структурированное хранилище с индексами. Каждая запись — не строчка текста, а объект с полями: _SYSTEMD_UNIT=nginx.service ← какой сервис _PID=1234 ← какой процесс _UID=www-data ← от какого пользователя _HOSTNAME=web-01 ← на каком хосте PRIORITY=3 ← уровень важности (err) MESSAGE=connection refused... ← само сообщение _SOURCE_REALTIME_TIMESTAMP=... ← точное время4.2 Полное руководство по journalctlbash # === БАЗОВЫЕ ЗАПРОСЫ === # Все логи конкретного сервиса sudo journalctl -u nginx.service # Последние 50 строк sudo journalctl -u nginx.service -n 50 # Следить в реальном времени (как tail -f) sudo journalctl -u nginx.service -f # С определённого момента sudo journalctl -u nginx.service --since "2024-01-15 10:00:00" sudo journalctl -u nginx.service --since "1 hour ago" sudo journalctl -u nginx.service --since today sudo journalctl -u nginx.service --since yesterday --until "2024-01-14 23:59:59" # === ФИЛЬТРАЦИЯ ПО УРОВНЮ ВАЖНОСТИ === # 0=emerg, 1=alert, 2=crit, 3=err, 4=warning, 5=notice, 6=info, 7=debug sudo journalctl -p err # только err sudo journalctl -p err..warning # от err до warning sudo journalctl -u nginx -p warning # предупреждения nginx # === ФИЛЬТРАЦИЯ ПО ЗАГРУЗКЕ === sudo journalctl -b # текущая загрузка sudo journalctl -b -1 # предыдущая загрузка sudo journalctl -b -2 # позапрошлая загрузка sudo journalctl --list-boots # список всех загрузок # === ФОРМАТЫ ВЫВОДА === sudo journalctl -u nginx -o json # JSON (для парсинга) sudo journalctl -u nginx -o json-pretty # JSON с форматированием sudo journalctl -u nginx -o verbose # Все поля записи sudo journalctl -u nginx -o cat # Только текст сообщений # === ПРОДВИНУТЫЕ ЗАПРОСЫ === # Логи конкретного процесса sudo journalctl _PID=1234 # Логи от конкретного пользователя sudo journalctl _UID=1000 # Комбинирование условий (OR) sudo journalctl _SYSTEMD_UNIT=nginx.service + _SYSTEMD_UNIT=php-fpm.service # Экспорт в файл sudo journalctl -u nginx --since today -o json > nginx-today.json # === УПРАВЛЕНИЕ ЖУРНАЛОМ === # Размер журнала на диске sudo journalctl --disk-usage # Очистка старых логов (оставить только за последние 2 недели) sudo journalctl --vacuum-time=2weeks # Очистка до определённого размера sudo journalctl --vacuum-size=500M4.3 Настройка journaldini # /etc/systemd/journald.conf [Journal] # Максимальный размер журнала на диске SystemMaxUse=1G # Максимальный размер одного файла журнала SystemMaxFileSize=100M # Хранить журналы не дольше MaxRetentionSec=1month # Сжатие (по умолчанию включено) Compress=yes # Перенаправить в syslog (для совместимости) ForwardToSyslog=no # Уровень логирования по умолчанию MaxLevelStore=debug MaxLevelSyslog=warningПосле изменения: sudo systemctl restart systemd-journald Часть 5. Практика — реальные сценарии5.1 Создание production-ready сервиса с нуляЗадача: создать сервис для Go-приложения с полной изоляцией и автоматическим перезапуском. ini # /etc/systemd/system/api-server.service [Unit] Description=API Server Documentation=https://github.com/company/api-server After=network.target Wants=network-online.target After=network-online.target # Если зависит от БД: Requires=postgresql.service After=postgresql.service [Service] Type=notify # Путь к бинарнику ExecStart=/usr/local/bin/api-server # Путь к конфигу через переменную окружения EnvironmentFile=/etc/api-server/env # Или напрямую: Environment="PORT=8080" Environment="LOG_LEVEL=info" # Перезапуск Restart=on-failure RestartSec=5s StartLimitIntervalSec=60s StartLimitBurst=3 # Пользователь и группа User=api Group=api # Рабочая директория WorkingDirectory=/opt/api-server # === БЕЗОПАСНОСТЬ === # Запрет повышения привилегий NoNewPrivileges=yes # Изолированный /tmp PrivateTmp=yes # Только чтение для /usr, /boot, /etc ProtectSystem=strict # Запрет доступа к домашним директориям ProtectHome=yes # Изолированная сеть для системных вызовов ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes # Список разрешённых системных вызовов SystemCallFilter=@system-service # Разрешить запись только в эти директории ReadWritePaths=/var/lib/api-server /var/log/api-server # === РЕСУРСЫ === MemoryMax=512M CPUQuota=200% # Ограничение открытых файлов LimitNOFILE=65536 [Install] WantedBy=multi-user.targetПрименение: bash sudo systemctl daemon-reload sudo systemctl enable --now api-server.service sudo systemctl status api-server.service5.2 Drop-in файлы — переопределение без изменения оригиналаЗолотое правило: никогда не редактируйте файлы в /usr/lib/systemd/system/. Используйте drop-in файлы. bash # Удобный способ — systemctl edit сам создаст файл sudo systemctl edit nginx.serviceСоздастся файл /etc/systemd/system/nginx.service.d/override.conf: ini [Service] # Добавим лимит памяти к стандартному nginx MemoryMax=256M # Переопределим тип перезапуска Restart=always # Добавим переменную окружения Environment="NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx"bash # После сохранения: sudo systemctl daemon-reload sudo systemctl restart nginx.service # Посмотреть итоговую конфигурацию (оригинал + drop-ins) sudo systemctl cat nginx.service5.3 Анализ и оптимизация времени загрузкиbash # Общее время загрузки systemd-analyze # Startup finished in 2.134s (kernel) + 8.643s (userspace) = 10.777s # Топ «тормозов» при загрузке systemd-analyze blame # 4.123s NetworkManager-wait-online.service # 2.456s plymouth-quit-wait.service # 1.234s dev-sda1.device # 0.987s apparmor.service # Критический путь загрузки — что тормозит конкретный target systemd-analyze critical-chain graphical.target # Визуальная диаграмма в SVG (откройте в браузере!) systemd-analyze plot > boot-plot.svg # Проверить юнит-файл на ошибки systemd-analyze verify /etc/systemd/system/myapp.serviceЧастые причины медленной загрузки и как их лечить: Проблема Симптом Решение NetworkManager-wait-online.service 20-30 секунд ожидания сети sudo systemctl disable NetworkManager-wait-online.service (если сеть не нужна при загрузке) Сервис висит на старте Долгий timeout Проверить TimeoutStartSec= и зависимости Много последовательных зависимостей Длинный critical chain Заменить Requires= на Wants= где возможно 5.4 Диагностика падающего сервиса — пошаговый алгоритмbash # Шаг 1: Статус сервиса sudo systemctl status myapp.service # Ищем: статус (failed/active), последние строки лога, код выхода # Шаг 2: Последние логи с подробными метаданными sudo journalctl -u myapp.service -n 100 --no-pager # Шаг 3: Логи с момента последней загрузки (для проблем при старте) sudo journalctl -u myapp.service -b # Шаг 4: Все ошибки в системе в момент падения sudo journalctl -p err --since "10 min ago" --no-pager # Шаг 5: Проверить зависимости systemctl list-dependencies myapp.service # Все зависимости должны быть зелёными # Шаг 6: Запустить вручную под тем же пользователем (для воспроизведения) sudo -u myapp /usr/local/bin/myapp --config /etc/myapp/config.yml # Шаг 7: Посмотреть все переменные окружения сервиса sudo systemctl show myapp.service -p Environment # Шаг 8: Проверить права доступа к файлам sudo systemctl cat myapp.service | grep -E 'ExecStart|WorkingDirectory|User' sudo ls -la /usr/local/bin/myappЧасть 6. Продвинутые техники6.1 Шаблонные юниты — один файл для многих экземпляровЕсли нужно запустить один и тот же сервис с разными параметрами (например, несколько воркеров), используйте шаблоны. ini # /etc/systemd/system/worker@.service # Обратите внимание на @ в имени файла! [Unit] Description=Worker Instance %i After=network.target [Service] Type=simple ExecStart=/usr/local/bin/worker --id=%i --config=/etc/worker/config.yml User=worker Restart=on-failure [Install] WantedBy=multi-user.targetЗапуск нескольких экземпляров: bash # %i заменится на значение после @ sudo systemctl start worker@1.service sudo systemctl start worker@2.service sudo systemctl start worker@3.service # Или всех сразу: sudo systemctl enable worker@{1..5}.service sudo systemctl start worker@{1..5}.service # Посмотреть все запущенные экземпляры systemctl list-units 'worker@*'6.2 Временные сервисы через systemd-runbash # Запустить команду как временный сервис (исчезнет после завершения) sudo systemd-run --unit=my-task /usr/bin/python3 /opt/scripts/heavy_task.py # С ограничением ресурсов sudo systemd-run --unit=cpu-heavy --property=CPUQuota=50% --property=MemoryMax=256M \ /usr/bin/python3 /opt/scripts/heavy_task.py # Следить за прогрессом journalctl -u my-task -f6.3 Полезные команды, о которых мало кто знаетbash # Проверить юнит-файл на синтаксические ошибки ДО применения systemd-analyze verify /etc/systemd/system/myapp.service # Показать все переопределения (drop-in файлы) для сервиса systemctl cat nginx.service # Показать все свойства юнита systemctl show nginx.service # Показать конкретное свойство systemctl show nginx.service -p MainPID systemctl show nginx.service -p MemoryCurrent # Перезагрузить конфиги без daemon-reload (для drop-in файлов) sudo systemctl daemon-reload # Перечитать конфиги всех сервисов (более мягкий вариант) sudo systemctl reload-or-restart nginx.service # Узнать, какой пакет установил юнит systemctl cat nginx.service | head -1 # # /lib/systemd/system/nginx.service dpkg -S /lib/systemd/system/nginx.service # Debian/Ubuntu rpm -qf /lib/systemd/system/nginx.service # RHEL/Fedora # Блокировка: сервис не запустится даже вручную sudo systemctl mask dangerous-service.service # Разблокировка sudo systemctl unmask dangerous-service.serviceШпаргалка — все команды в одном местеУправление сервисамиЗадача Команда Запустить sudo systemctl start <name> Остановить sudo systemctl stop <name> Перезапустить sudo systemctl restart <name> Перечитать конфиг (без остановки) sudo systemctl reload <name> Reload или restart sudo systemctl reload-or-restart <name> Статус systemctl status <name> Включить автозапуск sudo systemctl enable <name> Выключить автозапуск sudo systemctl disable <name> Включить И запустить sudo systemctl enable --now <name> Заблокировать навсегда sudo systemctl mask <name> Просмотр состоянияЗадача Команда Все запущенные сервисы systemctl list-units --type=service --state=running Все упавшие systemctl --failed Проверить автозапуск systemctl is-enabled <name> Проверить активность systemctl is-active <name> Дерево зависимостей systemctl list-dependencies <name> Кто зависит от этого systemctl list-dependencies --reverse <name> Все таймеры systemctl list-timers Логи (journalctl)Задача Команда Логи сервиса sudo journalctl -u <name> Последние N строк sudo journalctl -u <name> -n 50 В реальном времени sudo journalctl -u <name> -f За текущую загрузку sudo journalctl -u <name> -b Только ошибки sudo journalctl -u <name> -p err С определённого времени sudo journalctl -u <name> --since "1h ago" Размер журнала sudo journalctl --disk-usage Очистить старые sudo journalctl --vacuum-time=2weeks Диагностика производительностиЗадача Команда Время загрузки systemd-analyze Что грузилось дольше всех systemd-analyze blame Критический путь systemd-analyze critical-chain <target> Визуальная диаграмма systemd-analyze plot > boot.svg Проверить юнит-файл systemd-analyze verify /path/to/unit Дерево cgroups systemd-cgls Ресурсы cgroups в реальном времени systemd-cgtop ЗаключениеSystemd — это не монстр, которого стоит бояться. Это мощный инструмент, понимание которого делает вас значительно эффективнее как системного администратора или разработчика. Несколько ключевых идей, которые стоит унести из этой статьи: Юниты — декларативные описания ресурсов. Пишите их правильно, используя все доступные настройки безопасности. cgroups — системd всегда знает, где ваши процессы. Используйте это для мониторинга и ограничения ресурсов. journald — это база данных, а не текстовый файл. Учитесь делать правильные запросы. Drop-in файлы — никогда не редактируйте оригинальные юнит-файлы из пакетов. systemd-analyze — ваш первый инструмент при проблемах с загрузкой.
-
Скачать HPE 3PAR Management Console и TOOLS — полный набор ISO (SSMC, Excel Client, 3PAR MC 4.7.3) для администрирования StoreServ
Собран полный набор установочных ISO-образов для администрирования систем хранения HPE 3PAR StoreServ. В архиве представлены оригинальные дистрибутивы консолей управления, вспомогательных инструментов и клиентов для работы с массивами 3PAR. Доступны следующие образы: • 3PAR_Management_Console_4.7.3_QR482-11188.iso • 3PAR_Management_Console_4.7_QR482-10116.iso • 3PAR_SSMC_Excel_client_installer_SW_QR482-10120.iso • 3PAR_StoreServ_Management_Console_2.2_QR482-10119.iso • TOOLS_3.1.3.202_GA_Z7550-10556.iso Эти пакеты включают: 3PAR Management Console (MC) — классическую Java-консоль для управления массивами HPE 3PAR. StoreServ Management Console (SSMC) — современный веб-интерфейс администрирования. SSMC Excel Client — плагин для управления и мониторинга массивов через Microsoft Excel. 3PAR Tools — набор служебных утилит и вспомогательных компонентов для обслуживания системы. Подходит для администрирования и обслуживания различных моделей HPE 3PAR StoreServ, включая среды виртуализации, SAN-инфраструктуру и корпоративные системы хранения. Скачать образы можно по ссылке: Вот ссылка (Тык тут) Материалы будут полезны администраторам систем хранения, инженерам по инфраструктуре и специалистам по поддержке SAN, которым требуется доступ к классическим версиям консолей управления HPE 3PAR.
-
EMC VNX5300 не загружается после дегауcса системных дисков — где найти образы FLARE и можно ли восстановить ОС?
Коллеги, всем доброго времени суток! Столкнулся с проблемой при восстановлении EMC VNX5300 (дисковая полка на 25 дисков 2.5"). Информация по устройству: PROD ID / SN: CKM0013120101506 P/N: 900-567-002 Ситуация следующая: первые 4 системных диска, на которых находилась ОС массива, были уничтожены дегауссом. После этого были куплены 4 новых диска, но, разумеется, без системного образа. Что уже сделал: подключился к контроллеру через COM-порт (есть консольный кабель); при загрузке система пишет, что необходимо обратиться в поддержку; пробовал использовать команду recover. После ввода recover появляется ошибка: Data directory boot service Not Loaded Error! после чего происходит перезагрузка контроллера. Фактически массив сейчас не может загрузиться, так как системные диски пустые. В поддержку Dell обратиться не получается — профиль помечен жёлтой галкой, и дальнейшие действия непонятны (нет доступа к загрузкам). Собственно вопросы: Есть ли вообще возможность восстановить ОС на EMC VNX5300, если системные диски были полностью уничтожены? Можно ли самостоятельно развернуть системный образ на новые диски? Где можно найти образы для установки на системные диски? Насколько удалось выяснить, нужен образ примерно такого вида: FLARE-05.33.009.5.186.mif или что-то из этой же ветки FLARE OS для VNX5300. Диски, если что, имеются разные (совместимые), с этим проблем нет. Буду очень благодарен, если кто-то подскажет: возможную процедуру восстановления, либо поделится образами FLARE / recovery для VNX5300. Перепробовал уже, кажется, всё, что можно — но пока безрезультатно.