Куб четыре на четыре на четыре я собирал по чужому скетчу с форума — и осёкся на третьей строке. Ни одного привычного digitalWrite: вместо них DDRD = 0b11111111, PORTD = 0b00010000, чтение PINB и какая-то запись через тильду. Я тогда честно скопировал пару строк в свою мигалку — и Serial замолчал: одна из скопированных записей съела пины D0 и D1, через которые с компьютером разговаривает USB-приёмопередатчик. Схема мигала, плата была жива, а диалог с ней оборвался.
Второй сюрприз поджидал, когда я вернулся к собственному коду и решил ускорить шкалу из восьми огней на D5-D12. Восемь вызовов digitalWrite — между первым и последним огнём проходило около 40 микросекунд, по пять на каждый вызов. Для глаз пустяк. Но внутри кадра мультиплексирования строке выделяется пара миллисекунд, и каждая лишняя микросекунда на переключение обделяла яркость: чем дольше возишься с записью, тем короче горит строка.
В этом уроке — три регистра, из которых сделан каждый пин Uno: DDRx, PORTx и PINx; таблица, какой пин каким битом сидит в каком регистре; битовые маски, которые ставят, снимают и инвертируют биты, не трогая соседей; и граница разумного — где прямые записи оправданы, а где через месяц не разберёшь собственный скетч.
Три регистра: DDRx, PORTx, PINx
Разберём то, что digitalWrite прятал от нас. Восемь пинов ATmega328P собраны в порт — один байт памяти, где каждый бит сидит на своём пине. У порта три регистра. DDRx задаёт направление: единица — выход, ноль — вход; это то, что делает pinMode. PORTx держит уровень: единица на бите — HIGH на выходе и подтяжка на входе. PINx — читать: фактические уровни на пинах, что бы скетч ни задумал. Буква на месте x — имя порта: B, C или D. В памяти чипа регистры каждого порта сидят соседями: у порта B это DDRB на адресе 0x24, PORTB на 0x25 и PINB на 0x23 — заглянув в дамп памяти, их легко перепутать, если забыть, что входное окно идёт первым.
Механика та же, что у регистров таймеров из урока про таймеры ATmega328: записал байт — железо изменилось немедленно. pinMode(13, OUTPUT) на самом деле выполняет DDRB |= (1 << DDB5), а digitalWrite(13, HIGH) — PORTB |= (1 << PB5). Пин D13 — пятый бит порта B, и никакой магии в слове «пин» нет: только биты.
Теперь цена. digitalWrite на Uno перед записью лезет в таблицу пинов, проверяет режим и ШИМ — я мерил метками micros(): около 5 мкс на вызов при 16 МГц. Запись PORTB = 0b00111111 компилятор превращает в одну команду out — один такт, 62,5 нс. Даже точечная запись одного бита командой sbi стоит два такта — 125 нс. Разница с digitalWrite — от сорока до восьмидесяти раз, и это разница между «успеть внутри кадра» и «не успеть».
| Пины Uno | Порт | Биты | Кто ещё сидит в регистре |
|---|---|---|---|
| D0, D1 | PORTD | PD0, PD1 | RX/TX — USB-приёмопередатчик, Serial |
| D2-D7 | PORTD | PD2-PD7 | свободны; D2 и D3 — внешние прерывания |
| D8-D13 | PORTB | PB0-PB5 | биты 6-7 — кварц, в порт не играем |
| A0-A5 | PORTC | PC0-PC5 | биты 6-7 — служебные функции чипа |
Полный байт пишут редко; обычно надо тронуть один бит, не задев соседей. Ставим бит через |= с маской: DDRB |= (1 << DDB5). Выражение (1 << DDB5) — единица, сдвинутая в позицию бита; операция |= ставит единицу только там, где в маске единица, а соседние биты остаются как были. Снимаем через &=~: PORTB &= ~(1 << PB0) — маска переворачивается, ноль ложится ровно в одну позицию, остальные биты возвращаются на место без изменений.
Моя любимая операция — инверсия через XOR: PORTB ^= (1 << PB0) переключает бит на противоположный при каждом выполнении. Мигалка без единой переменной состояния: одна строка внутри millis-гейта из урока про millis вместо delay. Читают порт через PINB: условие if (PINB & (1 << PINB0)) истинно, когда на пине высокий уровень. И пасхальный трюк даташита: запись единицы в бит регистра PINx инвертирует соответствующий бит PORTx — ATmega328P так умеет, хотя выглядит как опечатка.
Всю побитовую механику — сдвиги, маски, флаги — я по кусочкам разбирал в уроке про битовые операции и маски, на примере регистров I2C-расширителей. Здесь она работает напрямую с железом: байт, записанный в PORTx, виден на пинах в тот же такт.
// Шесть светодиодов на D8-D13 — это биты PB0-PB5 регистра PORTB.// Биты 6-7 того же регистра подключены к кварцу, поэтому маска 0b00111111.const byte MASKA = 0b00111111; void setup() { DDRB |= MASKA; // шесть бит на выход, кварц не трогаем Serial.begin(9600); Serial.println("PORTB: shest ognej na D8-D13");} void loop() { for (byte shag = 0; shag < 6; shag++) { // бегущий огонь: |= ставит один бит PORTB |= (1 << shag); delay(120); } for (byte shag = 0; shag < 6; shag++) { // гасим по одному: &=~ снимает бит PORTB &= ~(1 << shag); delay(120); } PORTB ^= MASKA; // инверсия байта: зажжённое стало погашенным delay(600); PORTB ^= MASKA; delay(600);}Восемь бит на светодиодах в симуляторе
Движок симулятора регистры PORTx не эмулирует: пины там рисует digitalWrite. Честная замена — в скетче заведён байт portValue, наш местный PORTB, и вся логика масок крутится вокруг него; функция vypisatPort() раскладывает байт на восемь digitalWrite. На настоящей плате весь цикл этой функции сворачивается в одну запись PORTB = portValue — 62,5 нс вместо восьми вызовов по пять микросекунд. Макетка: восемь светодиодов — восемь бит, бит 0 сидит на D5, бит 7 — на D12; кнопка D2 прибавляет к байту единицу, и счёт в двоичном виде виден глазами. Раз в секунду скетч инвертирует байт через ^= — ровно та команда, что в коде выше.
Тут же проявляется вторая выгода записи одним байтом, кроме скорости. Цикл vypisatPort() раскладывает биты по очереди: между первым и восьмым огнём — восемь вызовов, около 40 мкс разницы в возрасте свечения. На настоящей плате PORTB = 0b11111111 меняет все восемь бит в один и тот же такт: ни один огонь не опережает соседа. В мультиплексированной матрице именно эта одновременность убирает лесенку яркости, о которой я жаловался в начале урока.
Теперь о цене для головы. Через полгода свой скетч со строкой PORTD ^= 0b00100100 я читал как чужой: какие биты, что это было, почему маска именно такая. Правило, к которому пришёл: регистры прячутся в функции с именами по смыслу — shkaluVklyuchit(), ogonNaShage(n), portInvertirovat(); голые записи остаются только там, где тайминг дышит в затылок: внутренний цикл мультиплекса, программная шина, где каждая микросекунда расписана. Второй аргумент за обёртки — переносимость: та же шкала на digitalWrite из урока про светодиодную шкалу переезжает с Uno на Mega заменой двух констант, а версия на PORTB привязана к карте портов конкретного чипа. Начинал я всё равно с мигалки на digitalWrite — и советую того же: пусть сначала работает медленно и читаемо, ускоряй только измеренное.
Что докрутить
Три задания на закрепление. Первое: перенеси счётчик огней на PORTD с маской 0b11111100, сохранив живой Serial, — два нижних бита придётся обходить в каждой операции. Второе: замени инверсию ^= на трюк с записью единицы в PINB и проверь, что поведение не изменилось. Третье: посчитай в тетради, сколько тактов экономится на одном кадре из восьми огней против восьми digitalWrite — и во сколько раз короче станет сам кадр.
И крайняя, для любопытных: открой отчёт компилятора и посмотри, во что легла запись. Команда out пишет весь байт за один такт, а точечная sbi — два такта на один бит, и компилятор выбирает между ними сам. Когда увидишь sbi там, где ждал out, — вспомни таблицу выше и поймёшь, откуда взялась лишняя сотня наносекунд. Добавь четвёртое задание: оберни шкалу в функции ogonNaShage(n) и shkaluGasisit() — и сравни, сколько строк занял вызов против голой маски в теле loop().
- 1. Сколько времени занимает запись PORTB = 0b00111111 на Uno при 16 МГц?
- 2. После чужой строки DDRD = 0b11111111 монитор порта молчит. Что случилось?
- 3. Что делает PORTB ^= (1 << PB0)?
- 4. Как поставить один бит в регистре, не тронув соседние?
- 5. Что вернёт чтение регистра PINB?
- digitalWrite — около 5 мкс на Uno; запись PORTx — один такт (62,5 нс при 16 МГц), точечный sbi — два такта: выигрыш в 40-80 раз
- DDRx — направление, PORTx — уровень и подтяжка, PINx — фактические уровни; пины Uno сидят битами: D0-D7 = PD0-PD7, D8-D13 = PB0-PB5, A0-A5 = PC0-PC5
- Маски: |= ставит бит, &=~ снимает, ^= инвертирует — соседи не трогаются; запись единицы в PINx тоже инвертирует PORTx
- Полная запись PORTx = байт затирает занятые биты: PD0/PD1 — RX/TX, у PORTB биты 6-7 — кварц; на Uno пиши через маски
- Голые регистры — только там, где тайминг требует; в остальном коде прячь их в функции с именами по смыслу
Частые вопросы по теме урока
- Сколько времени занимает запись PORTB = 0b00111111 на Uno при 16 МГц?
- Полная запись в регистр — одна команда out, один такт. При 16 МГц такт длится 62,5 нс: в десятки раз быстрее digitalWrite с её таблицей пинов и проверками. один такт — 62,5 нс
- После чужой строки DDRD = 0b11111111 монитор порта молчит. Что случилось?
- Полная запись переписывает все восемь бит, а нижние два бита PORTD сидят на пинах D0 и D1 — это аппаратный UART к USB-чипу. Пиши через |= и &=~, если порт разделён с чужими функциями. затёрты биты PD0 и PD1 — пины D0/D1, где живёт RX/TX USB-приёмопередатчика
- Что делает PORTB ^= (1 << PB0)?
- XOR с маской из одного бита переключает его на противоположный при каждом выполнении — мигалка без переменной состояния. инвертирует бит 0: единица меняется на ноль и обратно
- Как поставить один бит в регистре, не тронув соседние?
- Операция |= ставит единицы только под единицами маски, соседние биты остаются как были. Полное присваивание из первого варианта затрёт весь порт. PORTB |= (1 << n)
- Что вернёт чтение регистра PINB?
- PINx — входное окно порта: реальные уровни на ножках. К нему же относится трюк даташита: запись единицы в бит PINx инвертирует соответствующий бит PORTx. фактические уровни на пинах порта B