Секундомер для настольных гонок был моим первым дисплеем, на котором цифры стали врагами. Формула в скетче была честная, дисплей исправный, но на экране творилось: сначала «t=9.9», потом «t=10.00» — и вся строка съезжала вправо, последний разряд упирался в край, а заголовок «t=» уползал непонятно куда. В минутном формате я печатал минуты и секунды как попало: «1:7.4» вместо «01:07.4», а когда секунд стало двузначное, колонка вообще уехала. Половину вечера я подозревал дисплей, прежде чем понял: он рисует ровно то, что я ему отправил, — просто мои числа имеют разную длину, а я не придумал, кто отвечает за ширину.
В этом уроке договоримся, кто отвечает за ширину, — и это всегда скетч, а не библиотека. Разберём второй аргумент print(), который для дробей задаёт знаки после запятой, а для целых — систему счисления (та же команда, другой смысл — на этом спотыкаются даже опытные), посмотрим на округление при печати и соберём секундомер MM:SS.d с ведущими нулями и правым выравниванием в симуляторе. База здесь простая: setCursor() и print() мы уже проходили в уроке про begin, setCursor и print.
Второй аргумент print(): запятая или система счисления
У print() есть необязательный второй аргумент, и его смысл зависит от типа первого. Для дробного числа это количество знаков после точки: lcd.print(3.14159, 1) выведет «3.1», lcd.print(3.14159, 3) — «3.142» с округлением. Без второго аргумента float печатается с двумя знаками по умолчанию. Для целого числа тот же аргумент задаёт основание системы счисления: lcd.print(255, HEX) даст «FF», lcd.print(255, BIN) — «11111111», константы HEX, BIN, OCT, DEC встроены. Ловушка в том, что синтаксис один, а семантика разная: поменяете в коде тип переменной с целой на дробную — и загадочный второй аргумент из системы счисления молча превратится в число знаков после запятой. Компилятор не пикнет, а экран покажет ерунду.
Пара слов о точности, чтобы потом не удивляться. float на Arduino занимает 4 байта и несёт примерно 6–7 значащих цифр: у 3.14159 достоверны пять знаков, у 123456.7 — уже шесть, а просить print(x, 4) у больших значений бессмысленно, хвост будут занимать выдуманные цифры. Если проекту нужны точные сотые — работайте в целых числах (миллисекундах, десятых градуса) и печатайте через деление, так мы поступим в секундомере ниже. Системы счисления при печати мы разбирали и для Serial в уроке про BIN, HEX и DEC — правила у print() на дисплее те же самые.
Два нюанса, которые доучивают картину. Первый — отрицательные числа: минус тоже занимает знакоместо, и «9.9» против «-9.9» различаются на символ, поэтому в расчёт ширины ячейки минус входит по умолчанию, а не «когда случится». Второй — HEX на экране — это не только теория: в уроке про свои символы через createChar мы задавали коды глифов, и при отладке знакогенератора lcd.print(code, HEX) на второй строке экономит целый цикл «печатать — смотреть в Serial — править»: код символа виден прямо там же, где он рисуется.
float v = 3.14159;lcd.print(v); // 3.14 — по умолчанию два знакаlcd.print(v, 1); // 3.1 — один знак после точкиlcd.print(v, 3); // 3.142 — с округлениемlcd.print(v, 0); // 3 — дробную часть отрезали int n = 255;lcd.print(n); // 255 — десятичнаяlcd.print(n, HEX); // FF — шестнадцатеричнаяlcd.print(n, BIN); // 11111111 — двоичная| Значение переменной | lcd.print(x) | lcd.print(x, 1) | lcd.print(x, 3) |
|---|---|---|---|
| 3.14159 | 3.14 | 3.1 | 3.142 |
| 2.0 | 2.00 | 2.0 | 2.000 |
| -7.05 | -7.05 | -7.1 | -7.050 |
| 99.999 | 100.00 | 100.0 | 100.000 |
Посмотрите на последнюю строку таблицы: 99.999 при печати с округлением превращается в 100.00 — значение не менялось, а ширина на экране выросла с пяти символов до шести. Это и есть главный источник съезжающих колонок: ширина напечатанного числа зависит от значения, а ячейка на экране фиксирована. Решение называется правым выравниванием: правый край числа прибивают к постоянной позиции, а лишнюю ширину съедают пробелы слева. Когда формат известен заранее, всё просто: температуры от -20 до +50 с одним знаком — это максимум четыре символа («-19.5»), значит, печатать стоит в колонку 16 минус ширина, а перед числом — нужное количество пробелов.
Проговорим расчёт сетки до конца, потому что на бумаге это занимает минуту и заменяет полчаса экспериментов. Пусть верхняя строка термометра выглядит как «T: -19.5 C». Колонки 0–1 — «T:», колонка 2 — пробел, колонки 3–6 — число, колонка 7 — пробел, колонка 8 — «C». Значит, setCursor(3, 0) перед печатью значения, а пробелы затирания — после. Как только каждая ячейка получила адрес и максимальную ширину, съезды исчезают по определению: некуда съезжать. В нижней строке при этом спокойно живут влажность или время — по той же схеме, отдельным набором ячеек.
Второй источник мусора — хвосты. Если предыдущее значение было шире нового («25.5» сменилось «9.1»), старые цифры остаются справа, потому что новый print() их не затёр. Классическое лекарство мы разбирали ещё в уроке про первый текст по I2C: печатать после числа хвост из пробелов. А на экранах, где места впритык, помогает 20x4 с его четырьмя строками — но выравнивание и там делается этими же приёмами, просто строк больше.
Секундомер в симуляторе
Соберём тот самый таймер для гонок, только правильно. Кнопка на D2 — старт и стоп: первое нажатие запускает отсчёт, второе фиксирует время, светодиод на D12 горит, пока секундомер тикает. Тумблер на D3 переключает манеру печати: в выключенном положении скетч печатает секунды «как получится» — со скачущей шириной и съездами, во включённом — формат MM:SS.d с ведущими нулями и правым выравниванием. Время считаем из millis(): старт запоминает начальное значение, стоп вычисляет разницу. Сравнивать два режима удобнее на одном и том же значении — остановите секундомер и щёлкайте тумблером: число на экране одно, а плотность вёрстки — разная. Запустите, ткните старт, подождите с десяток секунд, нажмите стоп и изучите обе ветки.
Обратите внимание на внутренности: десятые доли мы не считаем через float — весь расчёт целочисленный, в сотнях миллисекунд. Десятые — это остаток от деления на 10, секунды — остаток от деления на 60, минуты — целая часть. Ни одной дроби, ни одного округления, ни одного сюрприза с шириной. Заметьте и второй строке: подписи режимов одинаковой длины, чтобы при переключении тумблера на строке не оставался хвост от прежней надписи — та же гигиена фиксированных ячеек, только для текста.
Разберём математику, потому что она переносится на любой дисплейный проект без изменений. total — это миллисекунды: либо застывшая разница со стопа, либо живая millis() - startMs во время отсчёта. Деление на 100 переводит их в десятые доли секунды — переменная tenth, дальше чистая арифметика остатков: d = tenth % 10 даёт десятые, s берётся из (tenth / 10) % 60, чтобы после 59 секунд отсчёт шёл по кругу, m — целые минуты. Ведущие нули — это if перед каждой печатью: две проверки, ноль таинственности. Формат MM:SS.d всегда занимает ровно шесть знакомест, поэтому после него достаточно шести-семи пробелов затирания, и колонки стоят как приклеенные.
Неформатированная ветка напротив печатает total * 0.001 — секунды дробным числом с двумя знаками по умолчанию. Присмотритесь к переходу через десять секунд: «9.99» — четыре символа, «10.00» — пять, и всё, что правее, уезжает. Именно это я и получал в первой версии гонок. Проверьте крайние значения, а не средние: границы диапазона — единственное место, где ширина ломается, о чем честно предупреждала таблица с округлением 99.999 выше. Кнопку перед боевым применением стоит ещё и антидребезгом защитить, как в уроке про digitalRead и дребезг — в тренажёре механический дребезг не моделируется, а на настоящей кнопке он жив.
Тот же приём переносится на любые величины. Расстояние с дальномера печатайте с одним знаком и ячейкой в три символа, влажность с DHT — целым числом в две ячейки, напряжение с делителя — с двумя знаками и запасом под минус. Разница между «работает» и «работает красиво» почти всегда в этих трёх строках: фиксированная ячейка, ведущий ноль, пробелы затирания. А если экран в проекте маленький и ячеек не хватает, сначала смотрите на формат величины: «01:07.4» информативнее «1.1174 сек» при той же ширине. И проверяйте вёрстку на реальных данных часами: съезды редко видны на первых секундах работы — они проявляются, когда величина доходит до своего крайнего значения.
- Добавьте в форматированный режим сотые доли: ещё одна цифра через (tenth / 1) % 10 не выйдет, потому что tenth — уже десятые; заведите переменную сотых (total / 10) и пересчитайте разряды.
- Заставьте секундомер показывать отрицательное время: вычтите из нуля пару секунд и посмотрите, как минус влияет на ширину, — потом компенсируйте его в расчёте ширины.
- Печатайте в format-ветке ещё и счётчик кругов на второй строке вместо подписи fmt — с ведущим нулём, разумеется.
- Перенесите printTime() в скетч термометра из своего прошлого проекта и убедитесь, что фиксированные ячейки устраняют съезды на любых значениях.
- Второй аргумент print() для float — знаки после запятой, для int — основание счисления; смена типа переменной молча меняет смысл аргумента
- print(float) без аргумента даёт два знака; просить больше 6–7 значащих цифр у float бессмысленно
- Округление при печати расширяет число: 99.999 с одним знаком — это 100.0, проверяйте края диапазона
- Правое выравнивание — это setCursor на позицию «16 минус ширина» плюс пробелы; ведущие нули — if перед печатью разряда
- Время считайте в целых единицах (миллисекундах, десятых) и собирайте разряды делением и остатком — без float и сюрпризов
- 1. Что выведет lcd.print(3.14159, 2)?
- 2. Что напечатает lcd.print(255, BIN)?
- 3. Почему строка ломается при переходе значения с 9.99 на 10.00?
- 4. Зачем перед печатью секунд стоит if (s < 10) lcd.print("0");
- 5. Почему для выравнивания я предпочитаю if-цепочки, а не String(s).length()?
Частые вопросы по теме урока
- Что выведет lcd.print(3.14159, 2)?
- Для дробного числа второй аргумент — число знаков после точки, с округлением. По умолчанию, без аргумента, float печатается с двумя знаками. 3.14
- Что напечатает lcd.print(255, BIN)?
- Для целого числа второй аргумент — основание системы счисления: BIN даёт двоичную запись 11111111, HEX дал бы FF. 11111111
- Почему строка ломается при переходе значения с 9.99 на 10.00?
- Ширина напечатанного числа зависит от значения. Лечат пробелами после числа и правым выравниванием в фиксированную позицию. Ширина выросла на символ, а хвост никто не затёр
- Зачем перед печатью секунд стоит if (s < 10) lcd.print("0");
- Ведущий ноль фиксирует ширину разряда: «01:07.4» вместо «1:7.4» — позиции колонок перестают прыгать при переходе через десять. Чтобы получить ведущий ноль: 05 вместо 5
- Почему для выравнивания я предпочитаю if-цепочки, а не String(s).length()?
- Каждый String в цикле оставляет дыры в куче, и через часы работы Uno кончается память. Целочисленная арифметика и if-ы этого не создают. Строковые объекты фрагментируют память на Uno