В этой статье рассмотрим систему стабилизации квадрокоптера. Статья вдохновлена Программируем квадрокоптер на Arduino (часть 1) . Приступим. Начнем с настройки интерфейсов и таймеров мк. Далее короткое описание функций кода и вывод.Полный код проекта снабжен комментариями которые оформлены под стиль комментариев в HAL.
Настроим четыре канала у TIM1 и включим DMA он будет управлять ESC по Multishot. Multishot — это протокол передачи сигналов от полётного контроллера к регулятору скорости ESC. Ширина импульсов 5 - 25 мкс. ESC измеряет длительность импульса и преобразует её в скорость вращения двигателя. Чем длиннее импульс, тем выше обороты.
Сигнал Multishot :  
Мин: |‾‾‾|_______| (5 мкс)  
50%:  |‾‾‾‾‾‾‾|____| (15 мкс)  
Макс:  |‾‾‾‾‾‾‾‾‾‾‾‾| (25 мкс)  

Микроконтроллер настроен на 100MHz один тик таймера.T = 1/100 МГц = 0.01 мкс = 10 нс  Время переполнения T_overflow = 3000 × 10 нс = 30 мкс В коде укажем минимальное количество тиков 500 это будет 5мкс и максимальное 2500 это будет 25мкс. Далее настроим TIM11 предделитель (Prescaler) таймера 99 -1 период (Period) 999 - 1.  Тактовая частота 100 МГц.  После предделителя 100 МГц / 100 = 1 МГц длительность одного тика 1 / 1 МГц = 1 мкс  период переполнения  1000 × 1 мкс = 1000 мкс = 1 мс частота прерываний 1 / 1 мс = 1000 Гц (1 кГц).  

Таймер TIM11 будет генерировать прерывания с частотой 1 кГц, и в обработчике прерывания (функции обратного вызова) будет вызываться основной цикл управления: run_control_loop():
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim == &htim11) {
        run_control_loop(); // Вызов всей логики управления
    }
}В качестве IMU используется BMX055 подключенный по I2C интерфейс настроен на быстрый режим.


В качестве радиомодуля E220-400T22D подключенный по UART6 для которого включено DMA.


Настройки E220-400T22D производились из программы от EBYTE.

Все настройки по умолчанию. Модуль заработал сразу как только был подключен.
Инициализация фильтров и алгоритмов управления.
После инициализации IMU (BMX055) в коде выполняется настройка нескольких типов фильтров и ПИД-регуляторов:
1. Фильтры низких частот (LPF)
Используется каскадная структура биквадратных фильтров (Biquad) из библиотеки CMSIS-DSP:
arm_biquad_cascade_df2T_init_f32(&lpf_Gx, NUM_STAGES, Coeffs_lpf, filterState_lpf_Gx);
arm_biquad_cascade_df2T_init_f32(&lpf_Gy, NUM_STAGES, Coeffs_lpf, filterState_lpf_Gy);
arm_biquad_cascade_df2T_init_f32(&lpf_Gz, NUM_STAGES, Coeffs_lpf, filterState_lpf_Gz);Всего создается 9 экземпляров фильтров:
- 3 для осей гироскопа (Gx, Gy, Gz) 
- 3 для осей акселерометра (Ax, Ay, Az) 
- 3 для ошибок положения (Errorx, Errory, Errorz) 
2. Фильтры Калмана
Инициализируются отдельно для каждого канала:
kalman_filter_init(&gyro_filter_x, GYRO_PROCESS_NOISE, GYRO_MEASUREMENT_NOISE, GYRO_ESTIMATION_ERROR, GYRO_INITIAL_VALUE);
kalman_filter_init(&gyro_filter_y, GYRO_PROCESS_NOISE, GYRO_MEASUREMENT_NOISE, GYRO_ESTIMATION_ERROR, GYRO_INITIAL_VALUE);
kalman_filter_init(&gyro_filter_z, GYRO_PROCESS_NOISE, GYRO_MEASUREMENT_NOISE, GYRO_ESTIMATION_ERROR, GYRO_INITIAL_VALUE);3. Режекторные фильтры (Notch)
Настроены для подавления конкретных частотных составляющих:
init_notch_filter(¬ch_ax, NOTCH_CENTER_FREQ, NOTCH_WIDTH, NOTCH_SAMPLE_RATE);
init_notch_filter(¬ch_ay, NOTCH_CENTER_FREQ, NOTCH_WIDTH, NOTCH_SAMPLE_RATE);
init_notch_filter(¬ch_az, NOTCH_CENTER_FREQ, NOTCH_WIDTH, NOTCH_SAMPLE_RATE);4. Фильтры высоких частот (HPF)
Используются для устранения постоянной составляющей:
HPF_Init(&hpf_x, HPF_ALPHA, Gx[0]);
HPF_Init(&hpf_y, HPF_ALPHA, Gy[0]);
HPF_Init(&hpf_z, HPF_ALPHA, Gz[0]);5. ПИД-регуляторы
Инициализируются с заданными коэффициентами для каждого канала управления:
PID_Init(&pitch_pid, PITCH_PID_KP, PITCH_PID_KI, PITCH_PID_KD, ALPHA, ALPHA_DERIVATIVE, INTEGRAL_LIMIT, SCALE_FACTOR);
PID_Init(&roll_pid, ROLL_PID_KP, ROLL_PID_KI, ROLL_PID_KD, ALPHA, ALPHA_DERIVATIVE, INTEGRAL_LIMIT, SCALE_FACTOR);
PID_Init(&yaw_pid, YAW_PID_KP, YAW_PID_KI, YAW_PID_KD, ALPHA, ALPHA_DERIVATIVE, INTEGRAL_LIMIT, SCALE_FACTOR);Основной цикл управления
После завершения инициализации переходим в основной цикл, где управление осуществляется через прерывание таймера TIM11 с частотой 1 кГц. В обработчике прерывания вызывается функция run_control_loop(): 
1. Проверка связи с пультом управления
check_connection_loss();
void check_connection_loss(void) {
    uint32_t current_time = HAL_GetTick();
    if ((current_time - last_received_time) > CONNECTION_TIMEOUT_MS) {
        connection_lost = true;
        // Сброс управляющих сигналов
        joystick_x = 0;
        joystick_y = 0;
        right_left = 0;
        potentiometer_value = 0;
        button = 0;
        // Установка минимальной тяги на моторах
        Multishot_SetThrottle(MIN_PULSE_WIDTH, TIM_CHANNEL_1);
        Multishot_SetThrottle(MIN_PULSE_WIDTH, TIM_CHANNEL_2);
        Multishot_SetThrottle(MIN_PULSE_WIDTH, TIM_CHANNEL_3);
        Multishot_SetThrottle(MIN_PULSE_WIDTH, TIM_CHANNEL_4);
    }
}Получает текущее время системного таймера. Проверяет, превышает ли время с последнего успешного приема данных заданный таймаут.  При потере связи устанавливает флаг connection_lost, сбрасывает все управляющие сигналы в ноль,  устанавливает минимальные импульсы на всех моторах.
2. Прием сигнала управления
void receive_and_parse_data(int data_size) {
    calculated_checksum = 0;
    
    // Проверка минимального размера пакета
    if (data_size < 4) {
        return; // Недостаточно данных для разбора
    }
    
    // Вычисление контрольной суммы
    for (uint8_t i = 0; i < data_size - 2; i++) {
        calculated_checksum += buffer_message[i];
    }
    
    // Получение контрольной суммы из пакета
    received_checksum = (buffer_message[data_size - 2] << 8) | buffer_message[data_size - 1];
    
    // Проверка контрольной суммы
    if (received_checksum == calculated_checksum) {
        // Обновление времени последнего успешного получения данных
        last_received_time = HAL_GetTick();
        connection_lost = false;
        
        // Разбор данных управления:
        joystick_x = (int16_t)((buffer_message[0] << 8) | buffer_message[1];
        joystick_y = (int16_t)((buffer_message[2] << 8) | buffer_message[3];
        potentiometer_value = (buffer_message[4] << 8) | buffer_message[5];
        right_left = (int16_t)((buffer_message[6] << 8) | buffer_message[7];
        button = (buffer_message[8] << 8) | buffer_message[9];
    }
}Пакет данных имеет следующую структуру (12 байт):
| Позиция | Данные | Размер | Описание | 
|---|---|---|---|
| 0-1 | joystick_x | 2 байта | Положение джойстика по X | 
| 2-3 | joystick_y | 2 байта | Положение джойстика по Y | 
| 4-5 | potentiometer | 2 байта | Значение потенциометра | 
| 6-7 | right_left | 2 байта | Управление рысканием | 
| 8-9 | button | 2 байта | Состояние кнопки | 
| 10-11 | checksum | 2 байта | Контрольная сумма | 
3. Получение и обработка данных с IMU
Функция get_filter_data_accel();
void get_filter_data_accel() {
    BMX055_Read_Accel(&hi2c1, &BMX055);
    
    Ax[0] = round_to(BMX055.Ax);
    Ax[0] = Ax[0] - offset_Ax;
    Ax[0] = apply_notch_filter(¬ch_ax, Ax[0]);
    arm_biquad_cascade_df2T_f32(&lpf_Ax, Ax, filter_Ax, BLOCK_SIZE);
    filter_Ax[0] = kalman_filter_update(&accel_filter_x, filter_Ax[0]);
    
    // Аналогично для Ay и Az
    ...
}Этапы обработки:
- Чтение сырых данных с акселерометра через I2C 
- Округление значений до 2 знаков после запятой 
- Вычитание калибровочного смещения 
- Подавление узкополосных помех режекторным фильтром 
- Фильтрация низких частот биквадратным фильтром 
- Окончательная фильтрация фильтром Калмана 
Функция get_filter_data_gyro();
void get_filter_data_gyro() {
    BMX055_Read_Gyro(&hi2c1, &BMX055);
    
    Gx[0] = round_to(BMX055.Gx);
    Gx[0] = Gx[0] - bias_Gx;
    Gx[0] = apply_notch_filter(¬ch_gx, Gx[0]);
    Gx[0] = HPF_Update(&hpf_x, Gx[0]);
    arm_biquad_cascade_df2T_f32(&lpf_Gx, Gx, filter_Gx, BLOCK_SIZE);
    filter_Gx[0] = kalman_filter_update(&gyro_filter_x, filter_Gx[0]);
    
    // Аналогично для Gy и Gz
    ...
}- Дополнительный этап - фильтр высоких частот для устранения дрейфа 
- Используются отдельные калибровочные коэффициенты ( - bias_Gxвместо- offset_Ax)
4. Расчет ориентации
Функция get_angle_mahony();
void get_angle_mahony(void) {
    // Преобразование единиц измерения
    float filterAx = filter_Ax[0]*9.81; // м/с²
    float filterGx = filter_Gx[0]*DEG_TO_RAD; // рад/с
    
    // Обновление алгоритма Mahony
    MahonyAHRSupdateIMU(filterAx, filterAy, filterAz, 
                       filterGx, filterGy, filterGz, 
                       CONTROL_LOOP_DT);
    
    // Получение кватерниона ориентации
    Quat_actual[0] = (*(getQ()));
    ...
    
    // Комплементарная фильтрация кватерниона
    float alpha = 0.5;
    float dot = Quat_previous[0]*Quat_actual[0] + ...;
    if (dot < 0) {
        // Инвертирование при отрицательном скалярном произведении
        Quat_actual[0] = alpha * (-Quat_actual[0]) + ...;
        ...
    }
    
    // Нормализация кватерниона
    normalizeQuaternion(Quat_actual);
    
    // Преобразование кватерниона в углы Эйлера
    float q0 = Quat_actual[0];
    ...
    pitch = asinf(2.0f * (q1q3 - q0q2)) * RAD_TO_DEG;
    roll = atan2f(2.0f * (q0q1 + q2q3), q0q0 - q1q1 - q2q2 + q3q3) * RAD_TO_DEG;
    yaw = atan2f(2.0f * (q1q2 + q0q3), q0q0 + q1q1 - q2q2 - q3q3) * RAD_TO_DEG;
    
    // Фильтрация углов
    roll = apply_notch_filter(¬ch_roll, roll);
    ...
}Описание расчета ошибки ориентации.
Расчет ошибки ориентации между текущим положением квадрокоптера и целевым положением заданным с пульта управления выполняется с использованием кватернионов и включает несколько этапов.
Преобразование команд с пульта в целевой кватернион
 Функция eulerToQuaternion() преобразует сигналы джойстика (в диапазоне -100..100) в кватернион целевой ориентации.
void eulerToQuaternion(int joystick_x, int joystick_y, int right_left, float q[4]) {
    // Нормализация входных значений к диапазону [-1, 1] и преобразование в радианы
    float roll = (joystick_y / 100.0f) * M_PI;   // Ось X (крен)
    float pitch = (joystick_x / 100.0f) * M_PI;  // Ось Y (тангаж)
    float yaw = (right_left / 100.0f) * M_PI;    // Ось Z (рыскание)
    // Вычисление половинных углов для оптимизации
    float cy = cos(yaw * 0.5f);
    float sy = sin(yaw * 0.5f);
    float cp = cos(pitch * 0.5f);
    float sp = sin(pitch * 0.5f);
    float cr = cos(roll * 0.5f);
    float sr = sin(roll * 0.5f);
    // Формирование кватерниона
    q[0] = cr * cp * cy + sr * sp * sy; // Вещественная часть (w)
    q[1] = sr * cp * cy - cr * sp * sy; // Мнимая часть (x)
    q[2] = cr * sp * cy + sr * cp * sy; // Мнимая часть (y)
    q[3] = cr * cp * sy - sr * sp * cy; // Мнимая часть (z)
    normalizeQuaternion(q); // Нормализация кватерниона
}Расчет кватерниона ошибки
Функция объединяющая операции расчета кватерниона ошибки.
void quat_error(float q_actual[4], float q_actual_inv[4], 
               float q_target[4], float q_error[4]) {
    // Шаг 1: Вычисление обратного кватерниона текущей ориентации
    quat_inverse(q_actual, q_actual_inv);
    
    // Шаг 2: Умножение целевого кватерниона на обратный текущий
    // q_error = q_target ⊗ q_actual^-1
    quat_multiply(q_target, q_actual_inv, q_error);
    
    // Шаг 3: Нормализация кватерниона ошибки
    normalizeQuaternion(q_error);
}Для расчета ошибки необходимо найти обратный кватернион текущей ориентации.
void quat_inverse(float quat[4], float quat_inv[4]) {
    quat_inv[0] = quat[0];   // w' = w
    quat_inv[1] = -quat[1];  // x' = -x
    quat_inv[2] = -quat[2];  // y' = -y
    quat_inv[3] = -quat[3];  // z' = -z
    // Нормализация не требуется, если входной кватернион нормализован
}Основная операция для расчета ошибки - умножение кватернионов.
void quat_multiply(float q1[4], float q2[4], float result[4]) {
    result[0] = q1[0]*q2[0] - q1[1]*q2[1] - q1[2]*q2[2] - q1[3]*q2[3]; // w
    result[1] = q1[0]*q2[1] + q1[1]*q2[0] + q1[2]*q2[3] - q1[3]*q2[2]; // x
    result[2] = q1[0]*q2[2] - q1[1]*q2[3] + q1[2]*q2[0] + q1[3]*q2[1]; // y
    result[3] = q1[0]*q2[3] + q1[1]*q2[2] - q1[2]*q2[1] + q1[3]*q2[0]; // z
}В формировании управляющих сигналов используется векторная часть кватерниона.
double error_pitch = Quat_error[2] * (-1); // Ось Y
double error_roll = Quat_error[1];         // Ось X
double error_yaw = Quat_error[3];         // Ось Z
forse_pitch = PID_Compute(&pitch_pid, error_pitch, TRIM_PITCH_ERROR);
forse_roll = PID_Compute(&roll_pid, error_roll, TRIM_ROLL_ERROR);
forse_yaw = PID_Compute(&yaw_pid, error_yaw, TRIM_YAW_ERROR);Расчет тяги для моторов.
if(button == 1 && potentiometer_value > 0) {
    // Передний левый мотор
    float pid_correction_1 = forse_roll + forse_pitch + forse_yaw;
    pid_correction_1 = constrain_float(pid_correction_1, -max_pid_correction_mshot, max_pid_correction_mshot);
    int total_power_1 = throttle_mshot + pid_correction_1 + TRIM_FRONT_LEFT;
    total_power_1 = constrain(total_power_1, MIN_PULSE_WIDTH, MAX_PULSE_WIDTH);
    
    // Остальные моторы (аналогично с разными знаками коррекции)
    ...
    
    // Установка тяги
    Multishot_SetThrottle(total_power_1, TIM_CHANNEL_1);
    ...
} else {
    // Режим безопасности
    Multishot_SetThrottle(MIN_PULSE_WIDTH, TIM_CHANNEL_1);
    ...
}Вывод
Целью написания был интерес и желание создать минимально необходимый для работы квадрокоптера код. И я не доволен результатом. Итоговый вариант летает на твердую 2 из 5. Добиться хотя бы вменяемого зависания на месте не получилось. Причина скорее всего в неправильной работе с данными IMU. А именно в неверной настройке фильтров, например как только я создавал фильтр arm_biquad_cascade_df2T_init_f32 выше 2 порядка он тут же переставал работать. Неверный подбор частот данных и частот среза. Недостроил ПИД регулятор ну и возможно дело в ESC и моторах ибо брал их одним комплектом самые дешевые.
Код на GitHub.
Источники.
Комментарии (13)
 - Sunny-s07.07.2025 09:35- ну я бы посмотрел для начала реализацию у того же ardupilot. Там несколько реализаций ядра управления, DCM простейшая, EKF (extended Kalman filter) версий 2 и 3 в текущей репе, вот например EKF3 https://github.com/ArduPilot/ardupilot/blob/master/libraries/AP_NavEKF3/AP_NavEKF3_core.cpp  - iamkisly07.07.2025 09:35- Я бы глянул еще глубже и посмотрел как это выглядит у MultiWii как прадеда всех современных полетных контроллеров. 
 
 - Sunny-s07.07.2025 09:35- И кстати, multishot разве кто-то еще использует? Я бы брал какой нибудь Dshot600 как самый распространенный и поддерживаемвый AM32 
 - Indemsys07.07.2025 09:35- Ещё бы это заработало. 
 Настроить фильтр Калмана так же сложно, как и PID-регулятор: никаких «волшебных» коэффициентов и универсальных архитектур - ни у Калмана, ни у других алгоритмов - не существует.
 Качество работы достигается лишь при наличии мощной и непрерывной телеметрии.- Поэтому, когда в таких проектах сразу переходят к функциям управления двигателями, это выглядит как «забег вперёд паровоза». 
 Любые сторонние решения без беспроводной широкополосной телеметрии, построенные на ардуино, можно смело обходить стороной: аналогичный шлаκ ChatGPT сгенерирует и сам. А вот инфраструктуру для реальных исследований он за вас не создаст. - developer707.07.2025 09:35- Расскажу о одной древней своей разработки. Как то надо было сделать ПИД контроллер для управления нагревателем воды. Приступая к работе изучил теорию ПИД регулирования. Мне она показалась какой то запутанной, а от формул я вообще впал в уныние. - Я решил писать свой велосипед. И в процессе писания своего велосипеда передо мной встали проблемы которые нужно решить, я с удивление обнаружил что эти проблемы похожи на тот высоко математический абстракционизм описывающий теорию ПИД. - После этого я реализовал ПИД в программе с уже пониманием где и как и в какое время влияет на график каждый из коэффициентов ПИД. Да и сами коэффициенты - это самое главное. Обычно люди их подбирают от балды. Потому, а как их ещё выбирать не зная как устроена железяка в которой реализован ПИД алгоритм? - Также был собран параллельно тестовый стенд с нагревателем и с датчиком температуры. Написана программа считывания и визуализирующая графики. И проведено множество тестов с логированием, пониманием - каждого коэффициента ПИД регулятора. - Насколько помню проблема была в том что реальные данные с датчиков имеют невысокую разрешающую способность, а математические функции ПИД регулятора требовали математической точности. Также в реальной программе контроллера пришлось реализовывать гибрид ПИД математики который мог бы нормально работать. Под этим я понимаю точность математики контроллера и сенсоров считывания. Потому как в формулах ПИД для нормальной работы (отсутствие самовозбуждения и прочего) единица после запятой может быть весьма далеко. - В итоге была разработан контроллер который работал почти идеально, настолько насколько позволяла точность сенсоров.  - DvoiNic07.07.2025 09:35- Это, в общем, стандартный путь... Сначала "нафиг мне эти ваши умные слова и математика", "тут всё просто", а в процессе написания приходят к тому же ПИДу, ТАУ, устойчивости регуляторов... Не вы первый, и не вы последний... И в других отраслях часто то же самое - "да нафига какие-то там алгоритмы, тут же всё просто", "зачем мне реляционная алгебра, просто возьму две таблички". Или даже "зачем мне какие-то расчеты прочности, возьму достаточно толстую доску, должна выдержать" 
  - stm_avr07.07.2025 09:35- Пид для нагрева самое простое. Ыункция нагрева такая же как заряд конденсатора)) ПИ регулятоиа хаатит 
 
 
 - QwertyOFF07.07.2025 09:35- Наверно стоит подсмотреть исходники Betaflight, INAV или Ardupilot. По-моему у них первый уровень стабилизации по угловым скоростям, а не по ориентации в пространстве.  - DGray3232 Автор07.07.2025 09:35- Вот это я наверно и упустил. Т.е получается что основа стабильности коптера это данные угловой скорости с гироскопа, а не ориентация в пространстве. В пид регулятор идет ошибка между желаемой и текущей скоростью, а ошибка в ориентации это уже вторичные данные.  - QwertyOFF07.07.2025 09:35- Коптер вообще может летать без акселерометра, только по данным гироскопа. Само собой с какой стороны земля он знать не будет и за все управление отвечает пилот. Вообще получение искусственного горизонта это большая проблема, потому что при полете на коптер действует куча сил кроме гравитации. Я в основном летаю на самолётах под INAV, даже там как следует поупражнявшись в пилотировании искусственный горизонт можно опрокинуть набок. 
  - Gemerus07.07.2025 09:35- Большая часть более-менее быстрых FPV коптеров летают в "Acro" режиме, в котором коптер не использует данные гироскопа и где у него горизонт он не знает, ему это даже не очень интересно. Если хотите пример максимально "палка-верёвка" реализации, смотрите исходники multiwii, ей хватало Atmega32u4 для более менее приличного полёта(но конечно на порядок хуже, чем летают современные контроллеры) 
 
 
 - Genoik07.07.2025 09:35- Зачем это здесь ? - Новой информации никакой не дает, вы ничего своего не придумали и не написали. Половина статьи это вообще картинки из разряда "мигаем светодиодом на STM32". Техническая ценность данной статьи околонулевая. Нет никакой информации по настройке регуляторов, фильтров и т.д. - Вся статья из разряда "Как нарисовать сову". Поэтому нисколько неудивительно, что это не летает, так как все технические моменты, которые влияют на то, чтобы летало, просто пропущены. Лет 7-8 назад на Хабре писали более полезные статьи и про настройку регуляторов для полетных контроллеров и про фильтр Калмана и про Махони. 
 
           
 
nikolz
нашел вот такое решение:
https://github.com/mrigang04/-STM32F411CEU6-Based-Flight-Controller