Калькулятор — самая старая программа человечества в том смысле, что она старше компьютеров как класс, и всё же именно на нём я поначалу отмахнулся: есть же телефон. Забрал слова обратно через один запуск: дело не в арифметике, а в разборе строки на три поля — «12 * 5» — и в разветвлении по знаку. Это уменьшенная копия любого командного протокола: парсер команд с OK и ERR и CSV-разбор из урока про strtok выросли ровно из этих же движений. Первый запуск у меня выдал «12 * 5 = 0»: знак операции прочитался… пробелом, switch промахнулся мимо всех веток, а default-ветки тогда ещё не было. Мистика на одну минуту, польза — на весь урок.
Разберём цепочку разбора: parseInt() забирает первое число, односимвольный read() — знак операции, второй parseInt() — второе число; между ними peek-цикл доедает пробелы, в конце — уже знакомый доещик перевода строки. Затем switch по символу операции — та же механика, что в уроке про switch-case, только дискриминатор не число режимов, а char. Вокруг арифметики — две ловушки с последствиями: деление на ноль, которое на реальной плате не останавливает скетч, и переполнение int, которое можно увидеть живьём одной кнопкой: 32767 + 1 = -32768.
Железо минимальное: светодиод на D12 мигает паттерном «посчитал» и «делить нельзя», кнопка на D2 запускает демо переполнения. Парсинг чисел берём готовым из урока про parseInt и parseFloat — таймаут, пропуск мусора и хвостовой перевод строки описаны там по винтикам.
Строка на три поля: число, знак, число
После отправки «12 * 5» в буфере лежат семь байт: две цифры, пробел, звёздочка, пробел, цифра и перевод строки. parseInt() собирает 12 и обрывается на пробеле — сам пробел он не ест, хвост остаётся лежать. Пока его не убрать, read() вернёт именно пробел, и знак операции потеряется: это и была моя нулевая сумма из первого абзаца. Лечение — крошечный цикл: while (Serial.available() > 0 && Serial.peek() == ' ') Serial.read(); — peek() подглядывает следующий байт без извлечения, и цикл выедает все пробелы подряд, сколько бы их ни поставили.
Знак операции забираем read()'ом — ровно один байт. Почему не readStringUntil(): та ждёт терминатор до секундного таймаута и возвращает строку, а нам нужен один символ, который уже лежит в буфере. Второй parseInt() пропустит пробел перед числом сам — пропуск нецифровых у него в ДНК. Формат «12*5» без пробелов тоже сработает: parseInt оборвётся на звёздочке, peek-цикл не найдёт пробелов, read() возьмёт знак. Протокол терпим к пунктуальности отправителя — для ручного ввода это стоит много.
В конце строки — тот самый невидимый перевод строки, который монитор порта докладывает к каждому Enter. Универсальный финал разбора: while (Serial.available() > 0) Serial.read(); — буфер пуст, следующий ход начинается с чистого листа. Когда полей становится три и больше, эта цепочка превращается в конвейер из strtok и atoi — урок про CSV-строки показывает именно такой масштаб.
const int LED = 12;const int BTN = 2; // кнопка: демо переполненияint prevBtn = HIGH; // фронт нажатия: демо не повторяется void flashOk() { digitalWrite(LED, HIGH); delay(200); digitalWrite(LED, LOW);} void flashNo() { for (int i = 0; i < 3; i = i + 1) { digitalWrite(LED, HIGH); delay(80); digitalWrite(LED, LOW); delay(80); }} void demoOverflow() { int big = 32767; // потолок int на Uno Serial.print("32767 + 1 (int) = "); Serial.println((int16_t)(big + 1)); // -32768 Serial.print("300 * 200 (int) = "); Serial.println((int16_t)(300 * 200)); // -5536 long wide = (long)big + 1; Serial.print("32767 + 1 (long) = "); Serial.println(wide); // 32768} void setup() { pinMode(LED, OUTPUT); pinMode(BTN, INPUT_PULLUP); Serial.begin(9600); Serial.println("Format: 12 * 5 | knopka D2 - demo perepolneniya");} void loop() { int btn = digitalRead(BTN); if (btn == LOW && prevBtn == HIGH) { // фронт: демо на каждое нажатие demoOverflow(); flashOk(); delay(300); } prevBtn = btn; if (Serial.available() > 0) { int a = Serial.parseInt(); while (Serial.available() > 0 && Serial.peek() == ' ') { Serial.read(); // доедаем пробелы перед знаком } char op = Serial.read(); int b = Serial.parseInt(); while (Serial.available() > 0) { Serial.read(); // доещик конца строки } if (op == '+') { long r = (long)a + b; Serial.print(a); Serial.print(" + "); Serial.print(b); Serial.print(" = "); Serial.println(r); flashOk(); } else if (op == '*') { long r = (long)a * b; Serial.print(a); Serial.print(" * "); Serial.print(b); Serial.print(" = "); Serial.println(r); flashOk(); } else if (op == '-') { long r = (long)a - b; Serial.print(a); Serial.print(" - "); Serial.print(b); Serial.print(" = "); Serial.println(r); flashOk(); } else if (op == '/') { if (b == 0) { // охрана ДО знака деления Serial.println("Delit na nol nelzya."); flashNo(); } else { float r = (float)a / b; Serial.print(a); Serial.print(" / "); Serial.print(b); Serial.print(" = "); Serial.println(r, 2); flashOk(); } } else { Serial.print("Neznakomaya operaciya: "); Serial.println(op); flashNo(); } } delay(10);}Каждая арифметическая ветка перед сложением или умножением приводит операнды к long: (long)a + b. Для суммы это перестраховка, для произведения — необходимость: 300 * 200 — это 60000, и в 16-битный int оно не влезает. long на Uno — 32 бита, потолок около двух миллиардов, и 60000 там помещается с запасом. Деление идёт через float — калькулятор, отвечающий «7 / 2 = 3», теряет половину правды, — а печатается вторым аргументом: Serial.println(r, 2) даёт ровно два знака после точки.
Символ-дискриминатор — char со сравнением в одиночных кавычках: op == '*'. Двойные кавычки означают строку, и сравнение символа со строкой в C++ не сойдётся даже при одинаковом содержимом. Ветка else в конце — та самая «плата, которая умеет сказать не поняла»: знак «?» уйдёт туда, светодиод мигнёт трижды, и терминал покажет, какой именно символ до нас доехал. Без этой ветки незнакомый знак дал бы нулевой результат — и вы бы снова ловили мистику вроде моей из первого абзаца.
Светодиод на D12 дублирует вердикт, пока глаза на клавиатуре: одна вспышка 200 мс — расчёт готов, три коротких — заявка отклонена. Кнопка на D2 с INPUT_PULLUP прижимается к земле, и на каждом нажатии скетч проигрывает демо переполнения: две суммы в int с обёрткой через дно и одна в long — для сравнения. Паттерны «светодиод как лампочка-индикатор» не требуют ни дисплея, ни дополнительного провода — только дисциплины.
Деление на ноль и честные дроби
Проверка if (b == 0) стоит до знака деления — это не перестраховка, а единственное место, где поведение ещё под вашим контролем. Что будет без неё: на реальной Uno целочисленное деление на ноль не роняет скетч — прерывания на это у AVR нет, процессор вернёт мусорное значение, и калькулятор продолжит жить с неверным ответом в кармане. В симуляторе движок честнее: он останавливает скетч с сообщением про деление на ноль. В обоих мирах ответ один — спросить делитель до того, как делить, и ответить своей строкой вместо молчаливой ерунды.
Дробный вывод — отдельный экзамен на знание типов. Правило C++: если оба операнда целые, деление целочисленное — 7 / 2 даёт 3, хвост отбрасывается ещё на этапе вычисления. Один операнд float — и весь расчёт дробный: (float)a / b при a=7, b=2 даёт 3.5. Порядок записи критичен: (float)(a / b) сначала разделит нацело, потом приведёт готовую тройку к 3.0 — дробь потеряна до всякого print. Второй аргумент println(r, 2) только форматирует: он не вернёт то, что потеряно в вычислении.
По умолчанию Serial.print дробного числа печатает два знака — 3.14 вместо 3.14159. Вторым аргументом задаётся точность от 0 до 7: println(3.14159, 4) даст 3.1416 с округлением. Больше семи смысла нет: float на Uno — 32-битный по стандарту IEEE 754, держит около семи значащих цифр, и далее идут уже не ваши данные, а шум округления. Для напряжений и температур двух-трёх знаков хватает всегда — подробный разбор форматированного вывода я делал в уроке про отладочный вывод.
| Выражение | Типы | Что напечатает | Почему |
|---|---|---|---|
| 7 / 2 | оба int | 3 | целочисленное деление, хвост отброшен при вычислении |
| 7.0 / 2 | float и int | 3.50 | один операнд дробный — весь расчёт дробный |
| println((float)a / b, 2) | a=7, b=2 | 3.50 | приведение ДО деления сохраняет дробь |
| println((float)(a / b), 2) | a=7, b=2 | 3.00 | сначала 3, потом приведение — дробь потеряна |
Переполнение живьём: 32767 + 1 = -32768
int на Uno — 16 бит со знаком, диапазон от -32768 до 32767, и границы эти не мягкие: следующий шаг за потолком замыкается на дно. Сумма 32767 + 1 в int равна -32768 — не сбой, не сообщение, просто число, которое вы не заказывали. Произведение 300 * 200 по школьной математике — 60000, а int после обёртки скажет -5536. Демо по кнопке D2 проигрывает обе строки подряд и тут же печатает long-вариант: 32768 и 60000 — лечение видно на тех же числах.
В скетче демо записано с приведением (int16_t): на реальной Uno int и есть 16 бит, скобка там ничего не меняет — переполнение случится само. Движок симулятора считает числа шире шестнадцати бит, поэтому скобка возвращает арифметику в честные 16 — результат на обоих мирах одинаковый: -32768 и -5536. Формально стандарт C++ называет переполнение знакового int неопределённым поведением; AVR-GCC на практике заворачивает через дно, но полагаться на UB в проде — плохая привычка: для счётчиков и сумм, которые могут уйти за 32767, ставьте long сразу.
Где это стреляет вне калькулятора: миллитоны датчика за минуту, сумма показаний АЦП для усреднения, millis-арифметика в неправильных типах. Правило на память: любое произведение двух int-значений уже подозрительно, если оба могут превысить 180 — 181 * 181 = 32761 впритык к потолку, а 182 * 182 — уже дно. Ссылка на даты не нужна — достаточно этого умножения, чтобы раз и навсегда привить привычку проверять порядок величин до компиляции.
- Цепочка разбора «12 * 5»: parseInt, peek-цикл доедает пробелы, read() берёт знак, второй parseInt, финальный доещик перевода строки
- read() — для одного символа; readStringUntil() — для слова до терминатора: перепутать легко, а таймаут 1000 мс вылезет не там, где ждали
- Охрана делителя стоит до знака «/»: на AVR целочисленное деление на ноль вернёт мусор без остановки, в симуляторе скетч прервётся
- (float)a / b — приведение до деления; println(r, 2) лишь форматирует и не возвращает потерянную дробь
- int на Uno — 16 бит: 32767 + 1 = -32768, 300 * 200 = -5536; для произведений и сумм с ростом — long (32 бита)
- 1. Что напечатает Serial.println(5 / 2, 2)?
- 2. Какой байт вернёт Serial.read() сразу после parseInt() для строки «12 * 5», если пробелы не доедать?
- 3. Что произойдёт на реальной Uno при целочисленном a / 0, если проверки нет?
- 4. Сколько напечатает int-переменная после r = 300 * 200 на Uno?
- 5. Зачем в ветках сложения и умножения стоит (long)a + b вместо a + b?
Частые вопросы по теме урока
- Что напечатает Serial.println(5 / 2, 2)?
- 5 / 2 для двух int — уже готовая двойка, дробь отброшена при вычислении. Второй аргумент println лишь форматирует: 2 печатается как 2.00. 2.00 — деление целочисленное, знаки только форматируются
- Какой байт вернёт Serial.read() сразу после parseInt() для строки «12 * 5», если пробелы не доедать?
- parseInt собирает 12 и обрывается на пробеле, оставляя его в буфере. Без peek-цикла read() вернёт именно пробел — и знак операции потеряется. пробел — parseInt останавливается на нём и не съедает
- Что произойдёт на реальной Uno при целочисленном a / 0, если проверки нет?
- У AVR нет аппаратного прерывания на деление на ноль: процессор вернёт неопределённое значение, и калькулятор продолжит жить с мусором. В симуляторе движок честно прерывает скетч. вернётся мусорное значение — скетч продолжит работать с неверным ответом
- Сколько напечатает int-переменная после r = 300 * 200 на Uno?
- int на Uno — 16 бит: диапазон -32768..32767. Число 60000 оборачивается через дно: 60000 - 65536 = -5536. В long ответ честный — 60000. -5536 — сумма ушла за потолок 32767 и завернулась через дно
- Зачем в ветках сложения и умножения стоит (long)a + b вместо a + b?
- long на Uno — 32 бита, потолок около двух миллиардов: 60000 помещается с запасом. Приведение до операции расширяет и сам расчёт, а не только печать. чтобы результат поместился даже при 60000 — long 32-битный