Работа с RISC-V контроллерами на примере GD32VF103 и CH32V303. Часть 6. Дробные числа

от автора

Макетная плата GD32VF103

Одно из основных предназначений микроконтроллера — это получение информации извне, ее обработка и выдача реакции. Причем зачастую эта информация представлена не в цифрах, а в терминах реального мира: 3 сантиметра, 101 килопаскаль, 3.6 вольта. Мало того, что информацию надо получить, ее зачастую надо потом отобразить человеку. Вот только подобные аналоговые величины плохо ложатся на целочисленные переменные, с которыми так хорошо работает контроллер. О том, как дробные числа можно закодировать и какие при этом встречаются подводные камни, сегодня и поговорим.

Часть 1. Введение

Часть 2. Память и UART

Часть 3. Прерывания

Часть 4. Си и таймеры

Часть 5. DMA

10.1 IEEE754

Начнем с классического для «компьютерного» программирования решения — тип данных float (а также double и подобные). Наиболее распространенный сегодня формат представления дробных чисел — это 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. Причем само число при этом оказывается даже не 1.0·2⁻¹²⁷, как можно было подумать, а еще меньше. И это четвертая особенность. Если порядок равен –127 (в поле порядка записан ноль), число считается денормализованным. То есть вместо неявной единицы, про которую мы говорили у мантиссы, там предполагается неявный ноль.

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

Наконец, пятая особенность: не все битовые комбинации, которые можно записать в число, являются корректными числами. Некоторые из них обозначают специальные значения — бесконечности, не-числа, ошибки. Эти значения могут возникать, скажем, при делении на ноль, извлечении корней и т.п. Для дополнительной информации рекомендую ознакомиться с соответствующими лекциями на uneex (2022, 2024).

Некоторую сложность в ручных операциях с дробными числами может представлять то, что переводить из двоичной системы целые числа умеют почти все, а вот к дробным мы не привыкли. И даже большинство калькуляторов не привыкло. Принцип там, разумеется, тот же, что и в любой другой позиционной системе. Рассмотрим число 1010.1101₂:

2⁰ 2⁻¹ 2⁻² 2⁻³ 2⁻⁴
1 0 1 0 . 1 1 0 1

Можно, конечно, умножать побитово, но оперировать отрицательными степенями двойки опять же неудобно. Поэтому сначала умножим все число на 2⁴, чтобы оно стало целым, а потом поделим на 2⁴ обратно:

2⁷ 2⁶ 2⁵ 2⁴ 2⁰
1 0 1 0 1 1 0 1

Перевести двоичное число 10101101₂ в десятичное сумеет любой калькулятор: 173. Множитель 2⁴ также вычисляется легко: 16. Вот и получается, что наше исходное дробное число равно 173 / 16 = 10.8125.

По этому принципу мы и будем переводить в десятичный формат мантиссу. В ней один бит (равный 1) до точки и 23 бита после. Поэтому записываем биты, как будто это целое число, переводим в десятичный формат и делим на 2²³.

В качестве примера рассмотрим вот такое число: 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

Все верно!

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

В контроллере CH32V303 работа с дробными числами одинарной точности (32 бита) реализована аппаратно. Об этом говорит буква f в списке расширений imafc. Напоминаю, что другой наш контроллер, GD32VF103, имеет список расширений imac, то есть аппаратно дробных чисел не поддерживает. Вообще, работа с FPU в RISC-V реализована несколько странно, добавлением практически автономного блока (сопроцессора) с собственными регистрами.

Зачем это сделано и чем не устроило использование обычных регистров, я достоверно сказать не могу. Возможно, ради совместимости с D, Q и подобными расширениями (64-, 128-битные дробные числа). Это ведь только 32-битные float-ы помещаются в один регистр, а 64-битные уже нет. Впрочем, существуют и экзотические расширения Zfinx (float in X), Zdinx (double in X), Zhinx (half in X), в которых дробные числа хранятся как раз в обычных целочисленных регистрах. Двойная точность там обеспечивается регистровой парой. Но это уже сильный расход регистров, да и вообще не поддерживается нашим контроллером.

В нашем же случае вместе с модулем FPU добавляется 32 специальных регистра f0–f31. Как и обычные, они разделены на временные (ft0–ft11), сохраняемые (fs0–fs11) и аргументы функций (fa0–fa7). Конвенции по сохранению при использовании в функциях такие же, как для обычных регистров. Но надо помнить, что с ними умеет работать только сопроцессор, а не основное ядро. Поэтому все операции с f-регистрами пройдут только через специальные FPU-инструкции.

Любой расчет на 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». Впрочем, слабо представляю для чего выбор округления может пригодиться в повседневном программировании. Но если вдруг понадобится — вот он. Кстати, округление можно настроить не только для каждой команды индивидуально, но и для всех сразу, за это отвечает CSR-регистр fcsr.

Подробно рассматривать команды работы с данным модулем смысла не вижу. Если кому-нибудь все же интересно, их можно найти в документации на ядро 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");

Здесь uart_fpi32 — всего лишь функция вывода на UART числа с фиксированной точкой. Что это такое — чуть ниже.

Что иллюстрирует пример? Первое — время выполнения кода, 81 такт. И второе — результат сложения, не 9.9 ровно, а 9.90000064. Это обусловлено тем, что числа-то мы задаем в десятичной системе, а хранятся они в двоичной, причем для хранения отведено всего 23 бита (ну хорошо, 24), что соответствует приблизительно 7 десятичным разрядам. Причем стоит помнить, что эти 7 разрядов достижимы разве что для идеальных условий. При выполнении математических операций точность будет каждый раз снижаться, так что в реальности доверять более чем 3–5 разрядам уже нельзя. Причем уточню: речь идет не о знаках после точки, а именно о 3–5 значимых цифрах. Также из этого следует, что проверять дробные числа на строгое равенство нельзя почти никогда. То есть следующий код будет работать некорректно:

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

Ближайшими значениями являются не 9.9 и 10.0, а 9.90000128 и 10.00000192.

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

А что же делать с GD32VF103, в котором модуля FPU нет? Использовать программную реализацию. К счастью, тип float входит в стандарт языка Си, то есть будет поддерживаться компилятором в любом случае. Но не все так просто.

Если мы только изменим в makefile тип ядра на imac, компилятор нас обругает. Дело в том, что реализация работы с дробными числами компилятора gcc находится в отдельной библиотеке libgcc.a, причем отдельно для каждого подтипа ядер (по крайней мере, в risc-v gcc в Debian так). И что еще веселее, хотя этот подтип мы явно указываем, компилятор не желает его учитывать. Но если ему подсказать «ищи в /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

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

В некоторых дистрибутивах поддержку 32-разрядных float-ов не завезли. Правильным решением было бы пинать мейнтейнеров, чтобы поправили, но можно взять библиотеку из дистрибутива, в котором поддержка есть. Вот, например, версии из моего Debian: для ядер rv32imac и rv32imafc. И разумеется, никто не запрещает переписать соответствующие функции самостоятельно — это замечательная практика по внутреннему устройству float-ов. А еще после такой практики надолго отпадет желание использовать их где попало.

10.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³⁸. То есть сверхмалые и сверхбольшие числа таким способом не обработать. Но, повторяю, в микроконтроллерах диапазон чисел почти всегда известен заранее.

И вот теперь, когда мы рассмотрели, что такое числа с фиксированной точкой, можно чуть подробнее описать принцип работы uart_fpi32(val, dot). По сути, это всего лишь преобразование целого числа в строку, размещение после dot символов (считая справа) десятичной точки и вывод полученной строки в UART. Ее код настолько прост, что несколько раз мне было лень искать предыдущую реализацию и я писал ее с нуля. Самое сложное в ней (в том смысле, что все остальное еще проще) — добавить нули между концом числа и точкой, если выводится число вроде 0.00123.

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

Из любопытства я проверил и другие операции: выполнил каждую 10000 раз в цикле и вычислил среднее количество тактов на одну операцию.

Операция fixed-point Hardware FPU Software float SW/HW
Сложение 1.70 2.65 63.58 24.0
Умножение 1.70 2.65 80.86 30.5
Деление 10.70 10.65 84.89 8.0
sqrtf 11.65 264.08 22.7
sinf 1518.75 17876.18 11.7

10.5 Не только внутри контроллеров

Как ни странно, числа с фиксированной точкой применяются не только в микроконтроллерах с их ограниченными ресурсами. Так, в линуксе есть вот такой файл с интересным содержимым:

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

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

Размер дробной части не обязательно задавать в десятичном формате. Рассмотрим такую распространенную микросхему, как DS18B20. Это цифровой термометр с диапазоном до 125 градусов и разрешением до 12 бит. А примечателен он в данном случае тем, что значения выдает именно в формате с фиксированной точкой: 8 старших бит — целая часть, 4 младших — дробная, слева дополняется знаковым битом до двухбайтной величины. Отрицательные значения представлены в дополнительном коде. То есть в десятичном формате достаточно разделить двоичный результат на 2⁴. Рассмотрим пару примеров декодирования из его документации:

0x00A2 -> 162₁₀ / 2⁴ = 10.125 градуса 0xFE6F -> -401₁₀ / 2⁴ = -25.0625 градуса

Для отображения температуры человеку выдачу с фиксированной точкой в двоичной системе стоит перевести в формат фиксированной точки в десятичной: умножить на 10 в нужной степени и поделить на 2 в степени, соответствующей исходному формату (в нашем случае 4). Допустим, нам достаточно одного знака после точки: 0x00A2 * 10¹ / 2⁴ = 162 * 10 / 16 = 101.25. Дробная часть отбрасывается, после первого разряда выставляется точка, и получается искомое 10.1. Или 0xFE6F * 10² / 2⁴ = –2506.25 -> –25.06.

10.6 Хранение

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

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

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

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

Примерно так я рассчитывал, например, матрицы трехмерного преобразования в RARS, и такая же таблица используется в примере ниже. Она занимает всего 256 байт, а на ее использование потом тратятся считаные такты. Если вспомнить законы тригонометрии, размер таблицы можно сократить в два, в четыре раза и даже больше. Но это усложнит последующее использование, так что придется искать баланс между точностью, занимаемой памятью и скоростью.

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

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

Но ведь никто нас не заставляет непременно использовать все отсчеты. Скажем, если нам нужна частота 244 Гц, можно выводить каждое второе значение из таблицы, если 488 — каждое четвертое и так далее. Если желаемая частота не делится на наши 122 Гц нацело, код становится несколько более сложным. Интереса ради я набросал, как он может выглядеть:

volatile uint32_t dpos = (1LLU<<32) * 1000 / (144000000 / 256); //                           │          │        │         └─── разрядность ШИМ //                           │          │        └───────────── Частота тактирования таймера //                           │          └────────────────────── Выходная частота //                           └───────────────────────────────── Размер переменной счетчика (32 бита)  const int8_t sin256[256] = {0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,59,62,65,67,70,73,75,78,80,82,85,87,89,91,94,96,98,100,102,103,105,107,108,110,112,113,114,116,117,118,119,120,121,122,123,123,124,125,125,126,126,126,126,126,127,126,126,126,126,126,125,125,124,123,123,122,121,120,119,118,117,116,114,113,112,110,108,107,105,103,102,100,98,96,94,91,89,87,85,82,80,78,75,73,70,67,65,62,59,57,54,51,48,45,42,39,36,33,30,27,24,21,18,15,12,9,6,3,0,-3,-6,-9,-12,-15,-18,-21,-24,-27,-30,-33,-36,-39,-42,-45,-48,-51,-54,-57,-59,-62,-65,-67,-70,-73,-75,-78,-80,-82,-85,-87,-89,-91,-94,-96,-98,-100,-102,-103,-105,-107,-108,-110,-112,-113,-114,-116,-117,-118,-119,-120,-121,-122,-123,-123,-124,-125,-125,-126,-126,-126,-126,-126,-127,-126,-126,-126,-126,-126,-125,-125,-124,-123,-123,-122,-121,-120,-119,-118,-117,-116,-114,-113,-112,-110,-108,-107,-105,-103,-102,-100,-98,-96,-94,-91,-89,-87,-85,-82,-80,-78,-75,-73,-70,-67,-65,-62,-59,-57,-54,-51,-48,-45,-42,-39,-36,-33,-30,-27,-24,-21,-18,-15,-12,-9,-6,-3};  //Прерывание по переполнению Timer4 (TIM_UIE) __attribute__((interrupt)) void TIM4_IRQHandler(void){   static uint32_t pos = 0;   pos += dpos;    TIM4->CH1CVR = 127 + sin256[(pos>>24)]; //Для адресации используются только 8 старших бит, остальные — аккумулятор, в них хранится ошибка, накопившаяся к текущему времени    static uint32_t ppos = 0;   if(pos < ppos)GPO_T(GLED); //при переполнении переменной-счетчика мигаем светодиодом, так проще измерить частоту   ppos = pos;    TIM4->INTFR = 0; }

Код работает и даже рисует на экране осциллографа красивую синусоиду. Правда, при частотах выше 5 кГц она становится несколько треугольной — но чего вы хотели, 6 точек на период.

В реальном применении, разумеется, вместо прерывания от таймера логично использовать DMA. Как минимум оно будет значительно реже отвлекать ядро от его задач. А если чуть-чуть пожертвовать точностью, туда можно записать вообще всю таблицу значений и больше про него не думать. Но здесь ради наглядности я решил обойтись таймером.

Не менее очевидно, что вместо синуса можно использовать и любую другую функцию. Скажем, при работе с бесколлекторными двигателями или частотными преобразователями иногда используют вот такой «покореженный синус», а то и еще более извращенные вариации.

покореженные синусы

Такая странная форма сигналов позволяет достичь максимальной амплитуды межфазного напряжения, сохраняя его форму синусоидальной. Межфазное напряжение (между фиолетовой и зеленой фазами) нарисовано на графиках желтой линией. Достаточно подробно об этом рассказал в своем ролике TDM Lab.

10.9 Внезапный фейл с дробным числом

Это не относится напрямую к теме, просто история из жизни. На работе у меня установлен источник питания с управлением по COM-порту путем посылки обычных текстовых строк. В частности, для установки выходного напряжения, скажем, в 1.23 В нужно послать строку "VSET0 1.23\r\n". Эту строку я формировал обычным sprintf(buff, "VSET0 %.3lg\r\n", volt);. И однажды оказалось, что при установке напряжений около нуля прибор зависает и перестает менять напряжение. Проблема оказалась в формате %lg, который в обычных условиях заставляет sprintf автоматически выбирать способ записи — алгебраический (123.456) или экспоненциальный (1.23456e+2). Для обычного вывода на экран или в файл это очень удобно, но вот прибору экспоненциальная форма категорически не понравилась.

Не знаю, какая у этой истории мораль. Разве что при разработке своих приборов надо не лениться и реализовывать разные форматы ввода. В частности, не забывать, что десятичным разделителем может быть не только точка, но и запятая.

Дополнительная информация

Оригинал на github pages

Видеоверсия на Ютубе (только по gd32)

CC BY 4.0


ссылка на оригинал статьи https://habr.com/ru/articles/889598/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *