Одно из основных предназначений микроконтроллера — это получение информации извне, ее обработка и выдача реакции. Причем зачастую эта информация представлена не в цифрах, а в терминах реального мира: 3 сантиметра, 101 килопаскаль, 3.6 вольта. Мало того, что информацию надо получить, ее зачастую надо потом отобразить человеку. Вот только подобные аналоговые величины плохо ложатся на целочисленные переменные, с которыми так хорошо работает контроллер. О том, как дробные числа можно закодировать и какие при этом встречаются подводные камни, сегодня и поговорим.
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⁻¹ | 2⁻² | 2⁻³ | 2⁻⁴ | |
---|---|---|---|---|---|---|---|---|
1 | 0 | 1 | 0 | . | 1 | 1 | 0 | 1 |
Можно, конечно, умножать побитово, но оперировать отрицательными степенями двойки опять же неудобно. Поэтому сначала умножим все число на 2⁴, чтобы оно стало целым, а потом поделим на 2⁴ обратно:
2⁷ | 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). Для обычного вывода на экран или в файл это очень удобно, но вот прибору экспоненциальная форма категорически не понравилась.
Не знаю, какая у этой истории мораль. Разве что при разработке своих приборов надо не лениться и реализовывать разные форматы ввода. В частности, не забывать, что десятичным разделителем может быть не только точка, но и запятая.
Дополнительная информация
Видеоверсия на Ютубе (только по gd32)
ссылка на оригинал статьи https://habr.com/ru/articles/889598/
Добавить комментарий