В последнее время появилось много микроконтроллеров на ядрах ARM Cortex-M*, которые поддерживают аппаратную реализацию математики плавающей запятой (FPU). В основном FPU работают с одиночной точностью (float) и её вполне достаточно для работы с сигналами, полученными с АЦП. FPU позволяет забыть о проблемах дискретизации и проблемах переполнения целочисленных вычислений. FPU быстр - все математические операции с одиночными float
, кроме деления и взятия корня, занимают на Cortex-M4F один такт. Поэтому после перехода на Cortex-M4F мы вздохнули свободно и стали писать математику на float
. Как же мы удивились, найдя в скомпилированном коде математические операции над double
с программной, очень медленной эмуляцией.
В статье рассказывается, как обнаружить и исправить присутствие double в прошивках, где ядро аппаратно поддерживает тип float
, но не поддерживает double
.
Работа ведётся в среде IAR Embedded Workbench на примере реального кода на языке Си.
Проблема double чисел в ARM Cortex-M*
А велика ли проблема, что часть математики выполнилась с программной эмуляцией double
, вместо аппаратно поддерживаемых float
? Вот здесь показывается, что double
медленнее в 27 раз, а здесь числа поменьше (я насчитал разницу в 7-15 раз). Согласитесь, что терять в скорости математических алгоритмов на порядок из-за ошибочного использования double
, где достаточно float
, достаточно грустно. При этом в качестве решения проблем фантомных double
иногда предлагается просто не использовать FPU:
My mantra is not to use any floating point data types in embedded applications,
or at least to avoid them whenever possible: for most applications they are not
necessary and can be replaced by fixed point operations. Not only floating point
operations have numerical problems, but they can also lead to performance problems...
Ну а мы бояться performance problems не будем и научимся фантомных double
избегать.
Нам интересны ядра Cortex-M, в которых FPU для single precision есть, а для double precision - нет. Ниже приведена таблица ядер Cortex-M. Наличие FPU у помеченных ✅ ядер опционально. Ядра, имеющие этот блок, часто помечаются суффиксом F: например, Cortex-M4F.
Версия ядра |
FPU (half precision) |
FPU (single precision) |
FPU (double precision) |
---|---|---|---|
Cortex-M0 |
❌ |
❌ |
❌ |
Cortex-M0+ |
❌ |
❌ |
❌ |
Cortex-M1 |
❌ |
❌ |
❌ |
Cortex-M3 |
❌ |
❌ |
❌ |
Cortex-M4 |
❌ |
✅ (Optional) |
❌ |
Cortex-M7 |
❌ |
✅ (Optional) |
|
Cortex-M23 |
❌ |
❌ |
❌ |
Cortex-M33 |
❌ |
✅ (Optional) |
❌ |
Cortex-M35P |
❌ |
✅ (Optional) |
❌ |
Cortex-M55 |
✅ (Optional) |
||
Cortex-M85 |
✅ (Optional) |
Программная эмуляция
Стандарт Си требует поддержки чисел с плавающей точкой, как float
, так и double
. Как же компилируется код для ядер без аппаратной поддержки дробных типов? Каждая математическая операция заменяется на вызов функции программной эмуляции. У каждого компилятора своя реализация подобных эмуляторов. Всякая работа с дробными типами на ядрах без соответствующего FPU приведёт к вызову эмулирующих функций! Более того, если отдельно не включить опцию поддержки FPU в настройках компилятора, то будет использоваться программная эмуляция для чисел с плавающей запятой, даже если они могли бы выполняться аппаратно.
Рассмотрим случай эмуляции чисел с плавающей запятой.
// Example for no-FPU device
const int N = 1000;
int half_of_N;
// 1) Inefficient way
half_of_N = 0.5 * N; // C standard treats 0.5 as double literal
// 2) Optimized way
half_of_N = N / 2;
Умножение целого числа на 0.5
пройдет в четыре этапа:
-
конвертация N в
double
тип, вызов соответствующей функции..._i2d()
-
умножение
double
константы0.5
на сконвертированный вdouble
N, т.е. вызов эмулирующей функции -..._dmul()
-
конвертация результата обратно в
int
- функция..._d2i()
-
присваивание результата в half_of_N.
Простое умножение на дробное число на ядре без FPU привело к вызову минимум трёх сторонних функций. Более того, в прошивку были прилинкованы реализации этих функций, что увеличивает её размер грубо на 1 кБ. Чтобы прошивка не увеличивалась нужно найти все места использования программной эмуляции и убрать их.
Проверка наличия double эмуляции
Итак, мы решили проверить, нет ли в нашей прошивке случайного использования double. Иногда диагностику может провести сам компилятор (cсылка, смотреть комментарии). Так, например, gcc имеет специальный флаг -Wdouble-promotion
, который может оповестить программиста о неявной конвертации float
в double
. Также дробные константы могут быть интерпретированы как float
, если gcc передать флаг -fsingle-precision-constant
. В IAR Embedded Workbench нет встроенного инструмента для решения этой проблемы. Придётся заниматься реверсом процесса сборки прошивки с конца.
-
Откройте проект и перейдите в его настройки.
-
Выберите подпункт
Linker->List
и поставьте галочку в чекбоксGenerate linker map file
. -
Соберите проект. В папке output в дереве проектов должен появится *.map файл.
-
Откройте его и отлистайте до секции "ENTRY LIST". В ней вы найдёте имена всех функций, которые используются в коде. В том числе и всевозможные
..._f2d()
,..._dmul()
и прочие. -
Достаточно провести поиск по функциям конвертации
f2d
,ui2d
иl2d
. В моём случае я нашёл следующие функции.
Это значит, что фантомный double
в прошивке есть. На скиншоте видно, что в списке присутствует еще много функций конвертации типов и программной эмуляции математических операций с плавающей запятой. В момент избавления от последнего использования типа double
они разом исчезнут.
Поиск double в коде.
Напоминаю, что мы оптимизируем код для ядра с FPU, который поддерживает только float
. Cortex-M4F, например.
Первая ступень - поиск по тексту кода
Пропустим неинтересный поиск по всем файлам проекта (Ctrl+Shift+F) слова double
. Оказалось, что где-то рука программиста дрогнула и он его всё-таки написал.
А вот тут можно поискать частую ошибку:
// ST5918L3008 parameters are used as default values.
const static StepperParameters_t StepperParametersDefault =
{
.L = 0.0076 * 0.1f, // H -> 0.1H
.R = 2.2 * 0.1f, // Ohm -> 0.1Ohm
.Fm = 0.009 * 100.0f, // Wb -> cWb
};
static void StepperFOCSensorless_PIDReconfigureCallback(void)
{
/*
* Update PID if SPID was changed
* 1. Kp, Ki, Kd units in firmware: mA/rad, mA/(rad*T) and mA*T/rad, where T is period.
* 2. Kp, Ki, Kd units in GUI: A/rad, A/(rad*s), A*s/rad but XiLab multiplies them on 0.001 before sending.
* 4. Parameters can be changed during movement.
*/
// assign new values
PositionRegulator_param.Kp = 1e6 * BCDFlashParams.SPID.Kpf; // rad -> mA
PositionRegulator_param.Kd = 1e6 * BCDFlashParams.SPID.Kdf * STEPPER_FOC_PWM_FREQ ; // rad/T -> mA
PositionRegulator_param.Ki = 1e6 * BCDFlashParams.SPID.Kif * STEPPER_FOC_PWM_PERIOD_FLOAT; // rad T -> mA
}
Язык C коварен тем, что по умолчанию все дробные числа трактует как double
. Например, 0.0076
- это double
. Если мы умножаем его на 0.1f
, то результирующий тип выбирается, как более расширенный из двух, то есть double
. Ещё большее коварство проявляется при работе с экспоненциальной формой: число 1e6
не является целочисленным, его тип тоже double
. Используйте суффикс f
/F
на конце числовых констант, чтобы явно указать тип float
. Например 1e6f
.
Как найти в прошивке неправильные константы? Для этого используем поиск по регулярным выражениям, который встроен в IAR Embedded Workbench. Выражение для поиска [^.dw#][0-9]+[.eE][0-9]*[^.w0-9]
.
Регулярное выражение ищет не начинающиеся с букв, точек и # цифры, где есть точка или буква экспоненты в середине или в конце, после чего не идёт точка, буква или цифра. Такое регулярное выражение даёт много срабатываний в комментариях к коду, что довольно быстро фильтруется визуально. Оно также может не обрабатывать константы 3e-4
или 1e3L
, не имеющим тип float
, но мне хватило и такого регулярного выражения.
Теперь ошибки посложнее:
/*
* right half of voltage cicle is inside current circle
* NOTE: if voltage circle is inside current circle (Imax > Iu + I0) then intersection point does not exists,
* but Iu^2 - Imax^2 - I0^ < -2*I0^2 -2*I0*Iu and Id < -I0 - Iu <= I0
*/
if (Id_limit < -I0)
{
// FW_VOLTAGE_ONLY: limit only voltage
if (fabs(Irq) < Iu)
{
pIr->d = -I0 + sqrtf(Iu * Iu - Irq * Irq);
if (pIr->d > 0)
{
pIr->d = 0.0f;
}
pIr->q = Irq;
return; // nolimit
}
else
{
pIr->d = -I0;
if (Irq > 0.0f)
{
pIr->q = Iu;
}
else
{
pIr->q = -Iu;
}
return; // limit
}
}
/*
* Main case: Id_limit is between located between -I0 and 0.
*/
/*
* Check if line Iq = Irq intersects the voltage circle
* then calculate intersection Id coordinate and check if it is is not greater then Id_limit
*/
if (fabs(Irq) >= Iu || (Id_fw = -I0 + sqrtf(Iu * Iu - Irq * Irq)) < Id_limit)
{
/*
* LIMIT_MAIN_CASE: apply maximal current in Irq direction
*/
pIr->d = Id_limit;
pIr->q = sqrtf(Imax * Imax - Id_limit * Id_limit);
if (Irq < 0)
{
pIr->q = -pIr->q;
}
return; // limit
}
В этом коде все дробные переменные имеют тип float
, но double
всё-таки возникает. И дело в вызове функций. Попробуйте догадаться о какой функции идёт речь.
А пока немного теории именования функций математической библиотеки. Для функций взятия корня, синуса, косинуса, и даже для модуля числа есть функции, принимающие и возвращающие тип double
, а есть их более быстрые альтернативы, работающие с типом float
. Они имеют суффикс f
в конце.
// Double functions
sin(x); // double
cos(x); // double
sqrt(x); // double
fabs(x) // double
// Float functions
sinf(x); // float alternative
cosf(x); // float alternative
sqrtf(x); // float alternative
fabsf(x); // float alternative
В нашем случае дело было в fabs()
. На первый взгляд может показаться, и мне казалось, что fabs()
уже возвращает float
. Это не так. fabs
означает floating point abs, возвращающий double
. А fabsf
- floating point abs for float, это уже float
функция.
Как найти функции, принимающие double тип? Могу только посоветовать пройтись поиском по математическим функциям: sqrt, cos, sin, fabs... Замените их на float
аналоги: sqrtf, cosf, sinf, fabsf...
Первая стадия завершена. Скомпилируйте проект и проведите повторную диагностику. В нашем случае диагностика показала наличие double. Поэтому мы копнули глубже.
Вторая ступень - поиск по объектным файлам
Если первая ступень не избавила вас от double
, то нужно хотя бы сузить пространство поиска. Для этого составьте список найденных в диагностике эмулирующих функций, а затем сделайте поиск имён этих функций в объектных файлах, *.o
. Идея здесь такова: линкер присоединил их в проект, так как в одном или более объектном файле на них стоит ссылка. Ссылка записывается именем функции, а мы его знаем. Для поиска мы использовали Double Commander. Это open source, cross platform клон Total Commander.
В одном из объектных файлов найдётся использование функций из *.map
файла (если нет, то вы линкуете еще какой-то бинарный код, например библиотеки; в нашем случае это было не так). Соответствующий *.c
файл явно или неявно требует использования double
. К сожалению дальше остаётся только просматривать код строчка за строчкой и искать проблемы глазами. И тем не менее, это лучше, чем ничего :)
Так например, поиск по объектным файлам подсказал мне, что double
используется в файле bldc.c
. Внимательно изучая код, я наткнулся на незнакомую библиотечную функцию arm_inv_clarke_f32
. Перейдя в её определение, я нашёл проблему:
Оказалось, что это ошибка реализации библиотечной функции в arm_math.h из CMSIS, revision: v1.4.4. Тут мы видим знакомое по примеру выше умножение на 0.5
. Это double
тип! Пришлось патчить системную библиотеку.
Выводы
То, что делается в GCC одним-двумя флагами, делается в IAR поиском с регулярными выражениями, а также поиском в бинарных файлах, а затем пристальным просматриванием кода. И самая сложная ошибка нашлась в библиотеке CMSIS. То есть даже, если вы пишете код без ошибок, то это не гарантирует отсутствия фантомных double. Они могут наследоваться из библиотек и на порядок замедлять работу математических алгоритмов.
Результат был достигнут: с помощью изложенных в данной статье действий удалось избавиться от double
в реальной прошивке для Cortex-M4F. Все примеры кода настоящие. Работа заняла около 6 часов. Количество объектных файлов в прошивке около 80.
Напоследок пример дизассемблера фрагмента кода до и после изменений.
Авторы: Запуниди Сергей, Шамплетов Никита
Автор: Сергей Запуниди