В этой статье рассмотрим систему стабилизации квадрокоптера. Статья вдохновлена Программируем квадрокоптер на 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.
Источники.
Комментарии (6)
Sunny-s
07.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
iamkisly
07.07.2025 09:35Я бы глянул еще глубже и посмотрел как это выглядит у MultiWii как прадеда всех современных полетных контроллеров.
Sunny-s
07.07.2025 09:35И кстати, multishot разве кто-то еще использует? Я бы брал какой нибудь Dshot600 как самый распространенный и поддерживаемвый AM32
Indemsys
07.07.2025 09:35Ещё бы это заработало.
Настроить фильтр Калмана так же сложно, как и PID-регулятор: никаких «волшебных» коэффициентов и универсальных архитектур - ни у Калмана, ни у других алгоритмов - не существует.
Качество работы достигается лишь при наличии мощной и непрерывной телеметрии.Поэтому, когда в таких проектах сразу переходят к функциям управления двигателями, это выглядит как «забег вперёд паровоза».
Любые сторонние решения без беспроводной широкополосной телеметрии, построенные на ардуино, можно смело обходить стороной: аналогичный шлаκ ChatGPT сгенерирует и сам. А вот инфраструктуру для реальных исследований он за вас не создаст.
QwertyOFF
07.07.2025 09:35Наверно стоит подсмотреть исходники Betaflight, INAV или Ardupilot. По-моему у них первый уровень стабилизации по угловым скоростям, а не по ориентации в пространстве.
nikolz
нашел вот такое решение:
https://github.com/mrigang04/-STM32F411CEU6-Based-Flight-Controller