На гаражных воротах у меня висели шесть герконовых концевиков, и статус створок я собирал пачкой digitalRead: шесть вызовов подряд, каждый — около пяти микросекунд, между первым и последним чтением разбег набирал почти сорок микросекунд. Для глаз — пустяк. Но журнал событий однажды напечатал строку, где две створки отметились в одной записи, хотя физически щёлкнули с разницей в полминуты: два чтения попали на разные стороны смены уровня, и байт статуса склеился из двух кадров времени. Никто не сломался, никто не упал — просто карта ворот начала врать, а врать она может тихо и годами.
Вторая половина той же истории — цена. Полдюжины вызовов digitalRead съедали заметный кусок кадра опроса, и в нём переставал успевать соседний код. На форуме я встретил чужой скетч, где вся гребёнка входов читалась одной строкой: vse = PIND — и честно не поверил, что это заменяет восемь строк.
В этом уроке — сторона входа прямой работы с портами: PINx читает весь порт одной командой, маска отсекает занятые биты, инверсия через XOR переворачивает подтянутые входы, а цифры выигрыша меряем руками, а не по ощущениям. Три регистра и карту «какой пин каким битом сидит» я уже раскладывал в уроке про PORTB и DDRB — там про запись; здесь про чтение, где правила свои.
PINx: весь порт одним чтением
У каждого порта, кроме регистров направления и уровня, есть входное окно — PINx. Чтение PIND возвращает уровни всех восьми бит в один такт: это снимок, а не очередь из восьми опросов. digitalRead ходит в ту же память по одному пину, но перед этим лезет в таблицу соответствий и проверяет режимы — отсюда десятки тактов на вызов: в старых ядрах мерили около сотни, свежее ядро ужало примерно до пятидесяти, мой замер по меткам micros() стабильно показывал около пяти микросекунд. Чтение PINx — два такта, 125 нс на 16 МГц: выигрыш в десятки раз, и это без всяких библиотек.
Скорость — половина выгоды. Вторая — атомарность. Восемь digitalRead подряд разбегаются на 30-40 мкс, и если фронт прилетел посреди пачки, байт собирается из двух моментов времени: половина бит — до смены, половина — после. PINx такого не умеет физически: снимок сделан в одном такте, промежуточных состояний не существует. Для статуса, который уезжает в Serial, EEPROM или радио одной записью, это разница между журналом и враньём: пакет с наполовину устаревшими битами неотличим от честного.
Теперь маска. На порт D у Uno всегда один квартирант: биты PD0 и PD1 держит USB-приёмопередатчик, через который живёт Serial. Читать PIND без маски — значит тащить в свою карту чужие биты; на записи это глушит монитор порта, на чтении — тише и потому хуже: ничего не ломается, показания просто подмешиваются. Вторая поправка — подтяжка: входы с INPUT_PULLUP держат HIGH, а замкнутый контакт роняет линию в ноль, то есть нажатие читается как 0. Переворачивать нужно только свои биты — и для этого берём XOR с маской, а не тильду.
// Шесть концевиков на D2-D7 — биты PD2-PD7 порта D.// PD0 и PD1 заняты USB-приёмопередатчиком: маска 0b11111100 в каждой операции.const byte MASKA = 0b11111100; void setup() { DDRD &= ~MASKA; // шесть бит на вход, чужие не тронуты PORTD |= MASKA; // подтяжка на все шесть разом Serial.begin(9600); Serial.println("PIND: shest kontaktov odnim chteniem"); byte summa = 0; // сумма нужна: без неё компилятор выкинет циклы unsigned long t0 = micros(); for (unsigned int i = 0; i < 10000; i++) { summa += digitalRead(2); // классика: таблица пинов на каждый вызов } unsigned long tPacha = micros() - t0; t0 = micros(); for (unsigned int i = 0; i < 10000; i++) { summa += (PIND >> 2) & 1; // порт: чтение плюс сдвиг с маской } unsigned long tPort = micros() - t0; Serial.print("10000 x digitalRead: "); Serial.print(tPacha); Serial.println(" mks"); Serial.print("10000 x PIND + maska: "); Serial.print(tPort); Serial.println(" mks"); Serial.println(summa); // печатаем, чтобы циклы остались живыми} void pechatBaita(byte b) { // BIN молчит про ведущие нули: печатаем сами for (int i = 7; i >= 0; i--) { Serial.print((b >> i) & 1); }} void loop() { byte vhod = PIND ^ MASKA; // XOR переворачивает свои биты: замкнуто - 1 Serial.print("vorota=B"); pechatBaita(vhod & MASKA); // восемь бит с ведущими нулями Serial.println(); delay(1000);}Разбор по строкам. DDRD &= ~MASKA и PORTD |= MASKA задают направление и подтяжку на все шесть бит одной строкой каждая — pinMode пришлось бы вызывать шесть раз. Чтение vhod = PIND ^ MASKA: XOR с маской переворачивает только свои шесть бит, чужие два остаются как были. Вот почему не тильда: vhod = ~PIND перевернул бы все восемь бит, и в карту попали бы уровни RX/TX — в моменты обмена по Serial там скачет мусор, и в журнале ворот «открывается» несуществующая калитка. XOR режет по маске, и потому операция инверсии безопасна на разделённом порту.
В замере — два приёма, которые стоит унести с собой. Первый: (PIND >> 2) & 1 вырезает один бит из байта — сдвиг опускает нужный бит на нулевую позицию, маска срезает соседей; вся механика масок собрана в уроке про побитовые операции, здесь она работает на регистре. Второй: сумма summa печатается в конец setup не для красоты. Цикл, чей результат никто не использует, компилятор имеет право выкинуть целиком — и оба замера обнулятся. Побочный результат — страховка честности.
Цифры на моём Uno: пачка из десяти тысяч digitalRead — около пятидесяти миллисекунд, те же десять тысяч чтений через PIND — единицы микросекунд. Ваши числа будут другими: они зависят от версии ядра и оптимизации, и в этом смысл методики — замерять у себя, а не цитировать чужое. Отдельно про вывод байта: Serial.print(vhod, BIN) печатает без ведущих нулей, и строка для 0b00000101 выглядит как 101 — поэтому в скетче байт печатается побитно, циклом от старшего бита к младшему; механика второго аргумента и ведущих нулей разобрана в уроке про BIN, HEX и DEC.
| Способ | Цена на вход | Атомарность кадра | Код на восемь входов |
|---|---|---|---|
| digitalRead по одному | 50-100 тактов каждый | нет: разбег 30-40 мкс между первым и последним | восемь строк и восемь переменных |
| PIND целиком плюс маска | 2-4 такта на весь байт | да: снимок одного такта | одна строка, байт сразу в Serial |
| PIND ^ маска (подтяжка) | 2-4 такта | да, и уровни уже в логике «1 = замкнуто» | одна строка без единой ветки |
Когда пачка окупается, а когда это усложнение
Окупается порт там, где расписание меряется тактами. Программные шины — one-wire, бит-бэнг дисплеев, свой протокол на двух проводах: там каждая микросекунда расписана, и digitalRead ломает тайминг самим фактом вызова. Мультиплексная индикация, где кадр делится между строками, — та же история: чем короче опрос входов, тем больше кадра остаётся индикации. И гребёнки однотипных концевиков, чей статус едет наружу одним пакетом: байт из одного чтения сразу живёт в журнале целиком. Тот же принцип «целый кадр одной операцией» у соседнего чипа — в уроке про 74HC595 через аппаратный SPI, а регистры АЦП, ужимающие analogRead со ста микросекунд до пары десятков, разобраны в уроке про быстрый АЦП.
Не окупается — на одиночной кнопке в спокойном скетче. Одна digitalRead на оборот loop() на фоне delay(1000) не стоит ничего, и строка if (digitalRead(PIN_BTN) == LOW) читается всеми, включая вас через год. Мой водораздел простой: порт целиком читай, когда биты ты назначил сам и их восемь или почти восемь; всё, что делит порт с чужими функциями, — только через маску в каждой операции. Порт D на Uno почти всегда делёнка — Serial живёт там постоянно, и это правило для него не отменяется никогда. Одиночные кнопки остаются в digitalRead, как в уроке про кнопку и digitalRead, — читаемость дороже наносекунд, которых никто не мерил.
И последняя граница: переносимость. Запись PIND привязана к карте портов ATmega328P; на Mega те же кнопки окажутся в другом регистре, и скетч поедет только после правки адресов. Прячьте чтение в функцию с именем по смыслу — vhodyVorot() возвращает байт статуса — и голый регистр будет жить в одном месте файла, а не в каждом if.
Что докрутить
Три задания на закрепление. Первое: перенесите карту ворот на PORTB — шесть концевиков на D8-D13, биты PB0-PB5, чтение PINB с маской 0b00111111, и убедитесь, что скетч правится одной заменой констант. Второе: печатайте строку статуса только при смене байта — храните прошлый кадр в переменной prev и сравнивайте; Serial перестанет молотить в пустоту. Третье, тетрадное: посчитайте такты кадра из восьми digitalRead против одного чтения PIND при 16 МГц и переведите разницу в микросекунды — после этого числа из урока перестанут быть цитатами.
И четвёртое, на переносимость: заверните чтение в функцию byte vhodyVorot() с маской внутри и уберите регистр из loop() совсем. Через месяц добавьте на Mega вторую группу концевиков — и посмотрите, сколько строк пришлось править: всё отличие между «регистр в одном месте» и «регистр в каждом условии».
- 1. Сколько тактов на Uno занимает одно чтение PIND?
- 2. Зачем в скетче с PIND маска 0b11111100?
- 3. Что делает строка vhod = PIND ^ 0b11111100 при INPUT_PULLUP?
- 4. Почему пачка из восьми digitalRead может склеить байт из двух кадров?
- 5. Зачем в замере скорости печатается сумма summa?
- Чтение PINx возвращает весь порт в один такт: снимок, а не пачка из восьми чтений по 50-100 тактов
- На Uno маскируйте порт D в каждой операции: биты PD0/PD1 держит USB-приёмопередатчик — на записи это глушит Serial, на чтении подмешивает чужие биты
- Подтяжку переворачиваем XOR-маской: vhod = PIND ^ 0b11111100 — свои биты инвертированы, чужие не тронуты; тильда на разделённом порту запрещена
- Пачка digitalRead разбегается на 30-40 мкс: фронт посреди пачки собирает байт из двух кадров; PINx атомарен по физике
- Замер — пачкой из десятков тысяч повторов с печатью побочного результата: шаг micros() 4 мкс, а без использования суммы оптимизатор выкинет цикл
Частые вопросы по теме урока
- Сколько тактов на Uno занимает одно чтение PIND?
- Чтение входного окна порта — одна-две машинные команды: 62,5-125 нс на 16 МГц. digitalRead тратит десятки тактов на таблицу пинов и проверку режимов. один-два такта
- Зачем в скетче с PIND маска 0b11111100?
- На PD0/PD1 живёт аппаратный UART к USB-чипу. Без маски чужие биты попадают в карту статуса и подмешивают мусор; подтяжку маска тоже ставит, но причина обязательности — именно занятые биты. биты PD0/PD1 заняты USB-приёмопередатчиком Serial
- Что делает строка vhod = PIND ^ 0b11111100 при INPUT_PULLUP?
- XOR с маской инвертирует только биты под единицами маски. Тильда перевернула бы все восемь, и уровни RX/TX попали бы в карту — потому для разделённого порта берут XOR. переворачивает только свои шесть бит: замкнутый контакт даёт 1
- Почему пачка из восьми digitalRead может склеить байт из двух кадров?
- Каждый вызов стоит десятки тактов, между первым и последним чтением набегает 30-40 мкс. PINx снимает весь порт в один такт, промежуточных состояний у него не существует. за 30-40 мкс — фронт посреди пачки оставит половину бит старыми
- Зачем в замере скорости печатается сумма summa?
- Цикл, чей результат никто не использует, оптимизатор выбрасывает целиком — вместе с замером. Побочный результат держит циклы живыми. иначе компилятор имеет право выкинуть оба цикла как пустые