COKPOWEHEU

View on GitHub

Практическое применение RISC-V при программировании микроконтроллеров

(Оглавление)

13. Дробные числа

Представим, что мы собрались использовать микроконтроллер по прямому назначению - микроконтролировать какую-нибудь величину из реального мира. Ну, например, температуру. Тут сразу же возникает ряд вопросов. В каких единицах ее измерять? В каких единицах ее отображать? В каких единицах ее хранить? Ну, с измерением все просто: в каких единицах датчик позволяет, в таких и измеряем. Например, распространенный датчик DS18B20 возвращает цифровой код. А терморезистор меняет свое сопротивление, которое можно измерить при помощи аналого-цифрового преобразователя. В любом случае датчик скорее всего выдаст нам какое-то целое число, которое придется так или иначе преобразовать. С отображением тоже все просто: стандартной единицей температуры является градус Кельвина. Но в быту привычнее все же градусы Цельсия. А вот вопрос хранения более сложный, и к нему вернемся позже. Пока же можно заметить еще одну проблему: разрешения в один градус не всегда хватает. Скажем, для медицинского градусника хорошо бы отображать десятые доли. А уж для лабораторных применений могут потребоваться и сотые, и даже больше. Это приводит нас к необходимости познакомиться с дробными числами и способами их обработки в контроллере.

13.1. IEEE754

Наиболее распространенным на сегодняшний день форматом представления дробных чисел является IEEE754. Согласно нему, число представлено как мантисса (значащие цифры), порядок и знак. То есть классическая экспоненциальная форма записи числа. Например, в десятичном числе 1.23·10⁸ мантисса это 1.23, а порядок - 8. Точно так же это выглядит и с двоичными числами: 1.001011·2¹⁰¹⁰ число 1.001011 это мантисса, а 1010 - порядок. Причем в экспоненциальной форме целая часть мантиссы всегда состоит из одной значащей цифры (то есть не нуля). И при использовании двоичной системы счисления это оказывается крайне удобно, ведь единственная цифра кроме нуля там - единица. Отсюда возникает первая особенность IEEE754: раз первая цифра мантиссы всегда равна 1, ее можно не хранить. Вторая особенность заключается в том, что для представления отрицательной мантиссы используется не дополнительный код, к которому мы привыкли при работе с целыми числами, а просто отдельный бит знака. Вероятно, это связано с тем, что сложение и вычитание всегда требуют выравнивания порядков, то есть сдвигов мантиссы влево-вправо. Да еще подразумеваемая единица до запятой. Все выгоды от дополнительного кода при этом пропадают.

От общих соображений углубимся немного в конкретику. IEEE754 специфицирует размер каждого поля, причем в нескольких вариантах. Первый вариант используется для 32-битного представления: под мантиссу отводится 23 бита (с 0 по 22-й), под порядок 8 битов (23 - 30), под знак - один (31-й). Для 64-битного представления размеры побольше: 52 под мантиссу, 11 под порядок, 1 под знак. Есть и 128-битный формат, но его мы рассматривать не будем. Как, впрочем, и 64-битный.

Ну и третья особенность данного формата - хранение порядка увеличенным на 127. То есть если в поле порядка хранится число 200, то сам порядок равен (200 - 127). Вероятно, это сделано чтобы если записать во все биты числа нули, порядок получился минимально возможным. Причем само число при этом оказывается еще меньше, чем можно было бы подумать, и это четвертая особенность. Если порядок равен -127 (в поле порядка записан ноль), число считается денормализованным. То есть вместо неявной единицы, про которую мы говорили у мантиссы, там предполагается неявный ноль. Таким образом, число, состоящее из всех нулей, строго равно нулю. Кстати, это еще одна причина использовать такой странный формат отрицательных порядков: на аппаратном уровне проверить все ли там нули крайне просто (хотя проверить на 0b10000000 было бы сложнее всего на один инвертор…). Ну и до кучи упомяну про пятую особенность: не все битовые комбинации, которые можно записать в число, являются корректными числами. Некотороые из них обозначают специальные значения - бесконечности, не-числа, ошибки. Эти значения могут возникать, скажем, при делении на ноль, извлечении корней и т.п.

Также рекомендую ознакомиться с соответствующими лекциями на uneex (2022, 2024)

Давайте для примера рассмотрим, как расшифровать вот такую двоичную запись: 11000011100111010001010001100011

S [  E   ] [         M           ]
1 10000111 00111010001010001100011

S - sign, знак
E - exponent, порядок
M - mantissa, мантисса

Знак равен 1, то есть число отрицательное.

Порядок равен 10000111₂ = 135₁₀. Вычитаем 127, получаем 8.

Мантисса равна (1.)00111010001010001100011 или, в десятичном формате, 100111010001010001100011₂ / 2²³ = 1,22718465328. Умножаем на порядок (2⁸ = 256), не забываем добавить знак и получаем -314,15927124. Осталось проверить правильно ли проведен расчет:

int main(){
  union{
    uint32_t u;
    float f;
  }val;
  val.u = 0b11000011100111010001010001100011;
  printf("%f\n", val.f);
}
$ gcc main.c
$ ./a.out 
-314.159271

Все верно!

13.2 Аппаратный модуль FPU

В контроллере ch32v303 работа с дробными числами одинарной точности (32 бита) реализована аппаратно. Об этом говорит буква f в списке расширений imafc. Напоминаю, что другой наш контроллер, gd32vf103, имеет список расширений imac, то есть аппаратно дробных чисел не поддерживает. Вообще, работа с FPU в RISC-V реализована несколько странно, добавлением практически автономного блока (сопроцессора) с собственными регистрами. Зачем это сделано и чем не устроило использование обычных регистров, мне неизвестно. Возможно, ради совместимости с D, Q и подобными расширениями (64-, 128-битные дробные числа). Это 32-битные помещаются в один регистр, а 64-битные уже нет. Впрочем, существуют и экзотические расширения Zfinx (float in X), Zdinx (double in X), Zhinx (half in X), в которых дробные числа хранятся как раз на обычных целочисленных регистрах. Двойная точность там обеспечивается регистровой парой. Но это уже сильный расход регистров, да и вообще не поддерживается нашим контроллером. В нашем же случае вместе с модулем FPU добавляется 32 специальных регистра f0 - f31. Как и обычные, они разделены на временные (ft0 - ft11), сохраняемые (fs0 - fs11), и аргументы функций (fa0 - fa7). Конвенции по сохранению при использовании в функциях такие же, как для обычных регистров.

Соответственно любой расчет на FPU начинается с того, чтобы загрузить в f-регистр значение либо из обычного регистра (команда вроде fcvt.s.wu fa5, a5) или из памяти (например, flw fa0, 12(s3)), провести с ним какие-то операции, после чего выгрузить обратно (fcvt.w.s a0, fa5 / fsw fa0, 12(s3)). Заметьте, что у команды fcvt несколько модификаций, поскольку она может преобразовывать f32, f64, … в u32, i32, u64, … и обратно. Собственно, суффиксы .w, .s, .l, .d отвечают именно за это. В нашем случае, когда поддерживаются только 32-битные целые (.w / .wu) и только 32-битные дробные (.s), набор суффиксов оказывается небольшим. Еще fcvt умеет округлять значение вверх (к +∞), вниз (к -∞), к нулю и от нуля. За это отвечает третий, опциональный, аргумент. Например, fcvt.w.s a0,fa5,rtz говорит “взять float значение из fa5, округлить до ближайшего целого в сторону нуля (round towards zero) и сохранить в int32_t регистр a0”. Впрочем, слабо представляю для чего выбор округления может пригодиться в повседневном программировании.

Подробно рассматривать команды работы с данным модулем смысла не вижу (если кому-то все же интересно, их можно найти в документации на ядро RISC-V или в тех же лекциях на uneex). Дело в том, что если уж программа достаточно сложна, чтобы потребовалась работа с дробными числами, писать ее скорее всего будут не на ассемблере, а как минимум на Си. Но вот особенности и ограничения знать придется в любом случае. Самое банальное - если вы используете дробные числа в прерывании, компилятор будет вынужден сохранить все f-регистры. Аналогично если из прерывания вызывается другая функция (компилятор ведь может не знать не используются ли дробные числа где-то в ней). Понятное дело, сохранение 32 лишних регистров никак не прибавляет скорости обработки. А вот со второй особенностью будет лучше ознакомиться на примере кода

  uint32_t t_prev = systick_read32();
  
  volatile float x = 1.1;
  volatile float res = 0;
  for(int i=0; i<9; i++)res += x;
  
  uint32_t t_cur = systick_read32();
  
  UART_puts(USART, "Float:");
  uart_fpi32(res*100000000, 8);
  UART_puts(USART, "\r\nt=");
  uart_fpi32( t_cur - t_prev, 0 );
  UART_puts(USART, "\r\n");

В отличие от gd32 (и, на минуточку, стандарта RISC-V!), у ch32 нет регистра mtime, вместо него используется периферийный таймер systick. Вероятно, скопированный с аналогичного в ARM. Впрочем, функционально они не слишком отличаются. Здесь стоит обратить внимание на две вещи: первое - время выполнения кода, 81 такт. И второе -результат сложения, 9.90000064. Это обусловлено тем, что числа-то мы задаем в десятичной системе, а хранятся они в двоичной, причем для хранения отведено всего 23 бита (ну хорошо, 24), что соответствует приблизительно 7 десятичным разрядам. Причем стоит помнить, что эти 7 разрядов достижимы разве что для идеальных условий. При выполнении математических операций точность будет каждый раз снижаться, так что в реальности доверять более чем 4-5 разрядам уже нельзя. Также из этого следует, что проверять дробные числа на строгое равенство почти никогда нельзя. То есть следующий код будет работать некорректно:

  for(float x = 0; x != 10; x+=0.1){...}

Ближайшими значениями будут не 9.9 и 10.0, а 9.90000128 и 10.00000192

13.3 Программная реализация

А что же делать с gd32vf103, в котором, как и в большинстве других контроллеров, модуля FPU нет? Использовать программную реализацию. К счастью, тип float входит в стандарт языка Си, то есть будет поддерживаться компилятором в любом случае. Но не все так просто. Если мы просто изменим в makefile тип ядра на imac, компилятор нас обругает. Дело в том, что реализация работы с дробными числами компилятора gcc находится в отдельной библиотеке libgcc.a, причем отдельно для каждого подтипа ядер. И что еще веселее, несмотря на то, что мы этот подтип явно указываем, компилятор не желает его учитывать. Но если ему подсказать “ищи в /usr/lib/gcc/riscv64-unknown-elf/12.2.0/rv32imac/ilp32/ библиотеку gcc”, он ее подставит. Вот только писать 12.2.0 прямо в makefile как-то неприлично. Вдруг выйдет новая версия. Поэтому для себя на Debian пришлось написать вот такой костыль:

GCCVER=`$(CC) --version | sed -n "1s/.* \([0-9]*[.][0-9]*[.][0-9]*\).*/\1/p"`
GCCPATH = -L/usr/lib/gcc/riscv64-unknown-elf/$(GCCVER)/$(ARCH_$(MCU))/ilp32/
...
LDFLAGS += $(GCCPATH) -lgcc

И тут возникает еще одна проблема, на этот раз с Каракатицей. Похоже, в репозитории ALT Linux вообще нет библиотеки с программной реализацией дробных чисел. Но они работают над этим. С другой стороны, это отличная возможность любому желающему написать свою реализацию!

Но вернемся к контроллеру. Тот же самый код, на том же самом контроллере ch32v303, но с настройками imac (как будто FPU у нас нет) выдает в качестве результата суммирования те же 9.90000064 (что хорошо: поведение программной и аппаратной реализаций совпадают), но вот время выполнение возрастает аж до 789 тактов - почти в 10 раз!

13.4 Числа с фиксированной точкой

Понятно, что использование чисел с плавающей точкой в контроллерах без FPU достаточно накладно. Но ведь и диапазон значений, с которыми они работают, вполне ограничен. Та же температура, о которой мы говорили в начале, для бытовых условий меняется где-то от -50 до +150 градусов. Ну хорошо, у нас, знакомых с паяльником, аж до 350 - 400. Тут нет нужды использовать разделение на мантиссу и порядок, достаточно просто считать не в единицах градусов, а в десятых долях. Или в сотых, или в тысячных. А при выводе на дисплей просто поставить в нужном месте точку. То есть температура 36.6 градусов может храниться как 366 дециградусов или 36600 миллиградусов. А это уже целые числа, работа с которыми нам хорошо знакома и не представляет никаких сложностей. Такое представление называется числами с фиксированной точкой. Давайте перепишем наш предыдущий код под работу с числами с фиксированной точкой:

  t_prev = systick_read32();
  
  volatile uint32_t y = 110000000; //1.1 * 10⁸
  volatile uint32_t ires = 0;
  for(int i=0; i<9; i++)ires += y;
  
  t_cur = systick_read32();
  
  UART_puts(USART, "Fixed-point:");
  uart_fpi32(ires, 8);
  UART_puts(USART, "\r\nt=");
  uart_fpi32( t_cur - t_prev, 0 );
  UART_puts(USART, "\r\n");

Результат расчета 9.90000000, время 73 такта. Мы выиграли и по точности, и по быстродействию. Причем не только у программной реализации FPU, но и у аппаратной! Но, разумеется, не все так радужно. Диапазон целых чисел все-таки ограничен, для 32-битных он составляет всего ±2·10⁹. Сравните с float, где диапазон 10³⁸. То есть сверхмалые и сверхбольшие числа таким способом не обработать. Но, повторяю, в микроконтроллерах диапазон чисал почти всегда известен заранее.

К слову о числах с фиксированной точкой. Давайте заглянем в один интересный файл:

$ cat /sys/class/thermal/thermal_zone0/temp 
44000

Если что, это температура процессора компьютера, равная в моем случае 44 градусам. Как видите, представлена она в миллиградусах, то есть числа с фиксированной точкой вполне себе используются даже в современных операционных системах для мощного железа, не имеющего никаких проблем с дробными числами.

13.5 Хранение

Вернемся к вопросу хранения физических величин. Отдельно отмечу, что речь здесь идет не о внутреннем представлении в контроллере, а о том, в котором оно передается во внешний мир и показывается пользователю. И это различие существенно! Потому что очень велик соблазн выдавать сразу сырые данные, скажем, с АЦП или датчика, или что-то в числах с фиксированной точкой. Так делать не надо! Через какое-то время вы попросту забудете за что эти величины отвечают и как их перевести во что-то осмысленное. Поэтому для любого общения с внешним миром лучше всего использовать числа в стандартной системе Си. Если уж масса, то в килограммах (даже если получится 9.1093837e-31), если напряжение, то в вольтах, если температура, то в градусах, если расстояние то в метрах. Чтобы потом, через пять лет не вспоминать, что вот тут величина выводилась в десятках миллиметров, а вот тут в килоомах. Если помимо чисел возможно вывести подсказку - совсем замечательно: можно там указать формат вывода (в каком столбце что) и единицы измерения.

Но как обычно, реальный мир все-таки отличается от идеального, и возможности вывода (или даже расчета) дробных чисел может физически не быть, или их использование неоправдано усложнит устройство. Тогда действительно приходится выводить как получится, а подробности описывать в документации. С этим вы будете регулярно сталкиваться при подключении покупных датчиков температуры, ускорения и всего остального.

Впрочем, вопрос хранения больше относится именно к хранению данных, скажем, на компьютере или в специальном файле на SD-карточке в устройстве.

13.6 Табличные функции

Довольно часто возникают задачи, связанные с вычислением математически тяжелых функций. Да хотя бы синус. Лобовое решение float x = sin(y); слишком часто оказывается неэффективным. Вместо этого можно воспользоваться тем, что у нас довольно много флеш-памяти, и разместить в ней рассчитанную заранее таблицу значений. Причем значения могут быть в любом диапазоне. Тот же синус удобнее считать не в радианах, а в долях от 8-битного числа. То есть 0 это 0 радиан, 127 это π, а 256 - 2π. И значение синуса пусть меняются не от -1 до +1, а от 0 до 255 или от -127 до +127. Примерно так я рассчитывал, например, матрицы трехмерного преобразования в RARS. Такая таблица занимает всего 256 байтов, а вычисляется за считанные такты. Разумеется, 256 значений для каких-то задач может оказаться недостаточно, но когда лимитирующим фактором является скорость (например, частотный преобразователь или контроллер мотор-колеса), такой способ вполне оправдан.

13.7 Цифровой синтез сигналов, DDS

Кстати о генерации синусоид. Допустим, мы хотим синтезировать звуковой сигнал при помощи ШИМ. Максимальная частота таймера равна тактовой частоте контроллера. По умолчанию 8 МГц. При 8-битном ШИМ его частота составит 31250 Гц. Но ведь нам нужен не меандр, а синусоида. То есть надо последовательно вывести все 256 значений из нашей таблицы. Максимальная частота составит уже 122 Гц. Как-то маловато… Но ведь никто нас не заставляет непременно использовать все отсчеты. Скажем, если нам нужна частота 244 Гц можно выводить каждое второе значение из таблицы, если 488 - каждое четвертое и так далее. Чуть более сложная математика появится если желаемая частота не делится на наши 122 Гц нацело, но все равно ничего сверхъестественного, зависимость скорости прохождения по массиву остается линейной.

Исходный код примера доступен на github Внимание: для сборки с аппаратной поддержкой дробных чисел используется makefile_hw.mk , а с программной - makefile_sw.mk

Д/З

  1. Посмотрите дизассемблерный вывод примеров, когда FPU используется и когда нет.

  2. Прочитайте в документации какие CSR-регистры связаны с FPU и какие интересные команды там поддерживаются.

  3. Воспроизведите проблему сохранения f-регистров в прерывании. Пример кода, при котором компилятор будет вынужден добавить сохранение f-регистров при входе и восстановление при выходе.

  4. Реализуйте ввод и вывод дробных чисел через UART. Простой вариант: только в алгебраической записи (123.456). Сложный - еще и в экспоненциальной (1.23456e+2).

  5. Сколько тактов занимает вычисление синуса с использованием FPU, без него и через таблицу?

  6. Реализуйте генерацию синусоиды на ШИМ-выходе (PA8, который на Каракатице подключен к микрофону). Простой вариант: фиксированной частоты и амплитуды. Посложнее - частота и амплитуда задаются с UART. Совсем сложный - сумма нескольких синусоид.

Offtop, размышления к вопросу хранения данных

Когда-то я осваивал аппаратный модуль USB в контроллерах stm32f1. В частности, учил микросхему изображать флеш-накопитель. Но просто предоставить часть собственной флешки или оперативной памяти компьютеру показалось не интересно. Поэтому я реализовал виртуальную файловую систему fat16, которая бы нигде не хранилась, а формировалась “на лету”. По тому же принципу, как устроены обычные fusefs: система говорит какие байты хочет прочитать, мы определяем, в какой часть fat16 они попадают, и возвращаем то, что дам должно быть. Самое простое, чего таким способом можно добиться - отображать какие-нибудь статические “файлы”, не занимая лишнего места под целый образ файловой системы. Это оказалось очень удобно, когда я делал программатор - переходник на UART, подобный тому, что используется в Каракатице. Только без микрофона, зато с “флешкой”, на которой я разместил файл со справкой какие ножки за что отвечают, примеры makefile-ов, inf-файл чтобы windows тоже могла установить драйвера. А еще - файл прошивки самого контроллера. То есть просто отображение его собственной памяти на виртуальный файл. Так что эта штука может клонировать сама себя - максимум опенсорса! Более того, после небольшого извращения туда поместился даже архив с исходниками. Но это все предыстория.

Интересно другое применение такой технологии: можно осуществлять сбор и хранение данных в любом формате (хоть в сырых значениях АЦП), а при подключении к компьютеру отображать их как текстовый файл в человеко-читаемом формате - с шапкой, с комментариями, сразу пересчитанный в физические единицы. К сожалению, из-за особенностей работы с флешками сделать этот файл динамическим невозможно. Сколько данных там оказалось на момент подключения, столько и будет, обновляться в реальном времени не получится. Но вот для снятия уже измеренных логов - отлично.

CC BY 4.0