Вольтметр на analogRead выдавал ровно два вольта, потом ровно три, а между ними — ничего. Пятнадцать минут я перетыкал провода, пока не всмотрелся в строку v = raw * 5 / 1023: все три операнда целые, значит и деление выходило целым — дробный хвост не округлялся, а молча выбрасывался. При raw меньше 205 результат вообще прижимался к нулю. Вот откуда «ровно два вольта» на всем нижнем ходу ручки.
Целочисленная арифметика — не дефект Arduino, а его родной язык: ATmega328 в железе не умеет делить с дробью, float прикручен сбоку и стоит тактов. Умение предсказать, где именно срежется дробная часть, отличает работающую шкалу от случайной. В этом уроке — как C++ усекает числа, почему порядок «сначала умножить, потом делить» спасает точность и что map() на самом деле делает со своими аргументами.
Опрос ручки и базовый map() мы проходили раньше: потенциометр с dimmer и аналоговый вход. Здесь спуск под капот — в целочисленное деление и приведение типов.
Целочисленное деление: дробная часть исчезает молча
Правило одно: когда оба операнда целые, / возвращает целое, отбрасывая хвост к нулю. 7 / 2 = 3, 799 / 1023 = 0, 5000 / 1023 = 4. Для отрицательных — то же усечение к нулю: −7 / 2 = −3, а не −4. Предупреждений компилятор не выдаёт — для него это обычная операция. Отсюда первая привычка: в любом выражении с делением спросите себя, целые ли тут операнды.
Деление на степени двойки стоит выделить отдельно: raw / 4 отбрасывает два младших бита и сжимает 10-битное показание АЦП в байт — быстро, без умножений, на 8-битном процессоре это одна команда сдвига. Так показания экономно складывают в массив-журнал типа byte. А почему analogRead возвращает int, хотя byte бы хватило? АЦП десятибитный: 0–1023 в восьми битах теряет точность, поэтому тип результата — int с запасом.
Порядок операций — половина точности. raw * 100 / 1023 при raw = 511 даёт 49: сначала умножение до 51100, потом единственное усечение. А raw / 1023 * 100 почти всегда даёт 0 — деление случается первым и срезает всё до нуля или единицы. Правило стоит одной секунды при письме и сотни часов отладки: умножай до деления, держи промежуточное число большим.
Масштаб потерь честными цифрами: проценты по формуле raw * 100 / 1023 промахиваются максимум на единицу — при 1023 ровно 100, при 1022 — 99. Для яркости светодиода этого достаточно. Для вольт уже нет: (raw * 5) / 1023 выдаёт ступени по 0,2 В, а если поделить раньше — сырой raw / 1023 обнулит всё до 204 включительно. Ступени видны на приборе — пора приводить к float.
Следующая ступень — милливольты, и тут int ломается окончательно. Формула raw * 5000 / 1023 на Uno неверна: произведение 1023 × 5000 = 5 115 000 не влезает в 16-битную int-арифметику с потолком 32767. Лечится суффиксом L, который делает операнд long: raw * 5000L / 1023 честно считает до 5000 мВ шагом 5 мВ. Моё правило: множитель, превосходящий диапазон значений, — сразу с L, не полагаясь на порядок действий.
Заодно прикиньте честное разрешение: 10 бит АЦП дают шаг 5000 / 1023 ≈ 4,9 мВ. Третий знак после запятой в вольтах уже за пределами разрешения — это не измерение, а арифметика. Я округляю до милливольт целыми через ту же формулу с L или печатаю два знака: Serial.print(volty) по умолчанию даёт два, а Serial.print(volty, 4) — четыре, но длинный хвост нужен только при отладке калибровки.
void setup() { Serial.begin(9600); Serial.println(7 / 2); // 3: хвост выброшен Serial.println((float)7 / 2); // 3.50: float-операнд - деление вещественное Serial.println((int)2.9); // 2: усечение к нулю Serial.println((int)-2.9); // -2: тоже к нулю, не к -3 Serial.println((byte)300); // 44: 300 - 256, без предупреждений int raw = analogRead(A0); Serial.println(raw * 5 / 1023); // ступени по 0,2 В Serial.println((float)raw * 5.0 / 1023.0); // честные вольты с дробями} void loop() {}Приведение в C-стиле — скобка с типом перед значением. (float)raw говорит: возьми это целое и работай с ним как с вещественным — дальше деление видит float-операнд и считает честно, (float)raw * 5.0 / 1023.0 при raw = 511 даёт 2.50. Литерал с точкой — 5.0 — делает выражение вещественным даже без скобок. Обратное приведение — усечение: (int)2.9 даст 2, (int)−2.9 даст −2, опять к нулю, а не к ближайшему целому; округление — отдельная работа, round() или прибавка половины.
Делитель тоже может быть ловушкой: деление на ноль для целых язык не ловит, на AVR результат формально не определён, а делитель часто ноль не буквально, а после усечения — 0.4, приведённый к int, уже ноль. Если делитель вычисляется, зажимайте его снизу: divisor = max(divisor, 1). Это же относится к масштабу из переменной: пока делитель — константа 1023, беда не придёт; как только он стал вычисляемым — добавьте проверку. Я прилипил max(divisor, 1) навсегда после того, как делил яркость на измеренный интервал, а интервал в первый проход вышел нулевым.
Цена вопроса на 8-битном ATmega: операция float — десятки микросекунд против единиц у целых, и каждая float-переменная весит 4 байта ОЗУ. Мой рубеж такой: для индикации и ШИМ — только целые, яркость светодиода считается в int и не жалуется; для приборов — float без скупости, расчёт температуры термистора без вещественных чисел нечитаем вовсе. Проверка по времени простая: тысяча float-операций в цикле на Uno стоит порядка десятков миллисекунд против единиц у целых — в плотном опросе разница уже заметна.
| Запись | Что вычислит плата |
|---|---|
| raw / 1023 * 100 | почти всегда 0: деление первым срезает всё до нуля |
| raw * 100 / 1023 | 0–100 целыми, промах до единицы: 511 даёт 49 |
| (float)raw * 5.0 / 1023.0 | честные вольты с дробями: 511 даёт 2.50 |
| map(raw, 0, 1023, 0, 10) | ступени 0–10: тот же порядок «умножить, потом делить» |
Порядок операций в map() и потолок long
map() в Arduino — не магия, а четыре действия: (x − in_min) * (out_max − out_min) / (in_max − in_min) + out_min. Умножение стоит до деления, и это осмысленно: промежуточное произведение хранит точность, единственное усечение случается на делении. Поэтому map(511, 0, 1023, 0, 100) даёт 49, а не 0. Внутри всё целое: дробных диапазонов map не умеет, «0–5 В» она отдаст ступенями 0, 1, 2, 3, 4 — и это не каприз библиотеки, а её определение.
У переполнения здесь тоже есть зубы. Промежуточное произведение живёт в long — до 2 147 483 647 на Uno. Вызов map(0, 0, 65535, 0, 65535) умножает 65535 × 65535 — это 4,29 миллиарда, за потолком: результат испорчен ещё до деления. Граница проверяется на калькуляторе за минуту: перемножьте разности диапазонов до вызова; для АЦП 0–1023 запас огромный, широкие 16-битные шкалы — уже на кромке.
Тот же принцип работает и за пределами map: случайные числа random масштабируют целыми, parseInt из Serial режет дробные хвосты при разборе строк. Самодельная шкала — всегда повод перепроверить порядок действий и спросить, кто в выражении делит первым.
И ещё одна обязанность map, о которой молчат короткие примеры: она не зажимает результат в выходной диапазон. Вход за пределами in_max — выход пропорционально за пределами out_max: при raw = 1030 вызов map(1030, 0, 1023, 0, 255) вернёт 256, а analogWrite такое значение заворачивает по-своему. Мой хвост формулы всегда одинаковый: map, затем constrain до нужных границ — два вызова вместо одного, зато шкала не выпрыгивает за края ни при каком мусоре на входе.
Диапазоны map могут быть и обратными, и пересекать ноль: map(raw, 0, 1023, 255, 0) переворачивает шкалу — ручка вправо, свет тусклее; map(raw, 0, 1023, -20, 40) рисует температуру со знаком. Отрицательные внутренние разности для long — штатная работа, а вот если промежуточные значения живут в unsigned переменных, минус превращается в огромный положительный виток. Правило у меня простое: где в формуле возможен минус — там знаковый long, беззнаковость оставьте времени и одометрам.
В Serial симулятора три шкалы на одном и том же raw, и движок здесь повторяет плату дословно: целые операнды — усечение, float-операнд — вещественное деление, map — ступени. Проведите ручку медленно: proc прыгает через десятые, V ползёт плавно, shag щёлкает раз в десятую шкалы. На краях видно больше всего: при raw до десяти proc прижат к нулю, а V уже показывает сотые — так своими глазами видна цена усечения. Это и есть карта потерь, снятая с одной переменной.
Граница метода, как я её вижу: float покупает читаемость, но не точность АЦП. Десятые доли вольта — предел честности 10-битного измерения, и длинный хвост после запятой в печати — самообман. Печатайте два знака и помните: за вторым — не измерение, а результат формулы.
- 1. Сколько напечатает Serial.println(7 / 2)?
- 2. raw = 150. Что вернёт raw * 5 / 1023?
- 3. Чему равно map(511, 0, 1023, 0, 100)?
- 4. Что напечатает Serial.println((int)-2.9)?
- 5. Почему (float)raw * 5.0 / 1023.0 считает честно?
- Оба операнда целые — деление отбрасывает хвост к нулю: 7/2 = 3, (int)−2.9 = −2.
- Умножайте до деления: raw * 100 / 1023 при 511 даёт 49, а raw / 1023 * 100 — почти всегда 0.
- map() — целочисленная формула (x−in_min)*(out_max−out_min)/(in_max−in_min)+out_min; произведение должно влезать в long.
- (float)raw делает деление вещественным; float на ATmega стоит десятки микросекунд — место ему в приборах, не в ШИМ.
- Округление целыми: (raw * 100L + 511) / 1023; сужение типов проверяйте — (byte)300 молча даст 44.
Частые вопросы по теме урока
- Сколько напечатает Serial.println(7 / 2)?
- Оба операнда целые — деление целочисленное: 3, хвост выброшен. Для 3.50 нужен float-операнд: (float)7 / 2. 3
- raw = 150. Что вернёт raw * 5 / 1023?
- Сначала умножение: 750. Потом целочисленное деление 750 / 1023 = 0 — ступени по 0,2 В прижимаются к нулю на нижнем ходу. 0
- Чему равно map(511, 0, 1023, 0, 100)?
- map считает (511 − 0) * 100 / 1023 + 0: умножение до деления даёт 51100 / 1023 = 49,9 — усечение к 49. 49
- Что напечатает Serial.println((int)-2.9)?
- Целочисленное усечение идёт к нулю, а не к ближайшему целому: (int)−2.9 = −2. Для округления нужен round(). -2
- Почему (float)raw * 5.0 / 1023.0 считает честно?
- Приведение и литерал с точкой делают выражение float-овым: деление выходит вещественным. Без них оба операнда целые — хвост выбрасывается. в выражении появился вещественный операнд — деление стало вещественным