pinMode и digitalWrite изнутри
digitalWrite(13, HIGH) выглядит как одна операция, но ядро Arduino выполняет за ней три поиска по таблицам во флеш-памяти, проверку таймера и запрет прерываний. Разберём её по исходнику и сравним с одной командой SBI.
Обновлено Редакция GAW
От Arduino к регистрамурок 1 из 830 мин
Цель урока: Понять по исходнику ядра, что делают pinMode и digitalWrite, заменить их записью в DDRx и PORTx и измерить разницу в тактах таймером Timer1.
pinMode(13, OUTPUT) и digitalWrite(13, HIGH) — первые строки почти любого скетча. В уроке 2 трека «AVR с нуля» тот же светодиод включался записью в регистры DDRB и PORTB. Посмотрим, что лежит между этими двумя мирами, — исходник ядра ArduinoCore-avr версии 1.8.8, файлы wiring_digital.c, Arduino.h и variants/standard/pins_arduino.h.
Номер вывода — это индекс в таблице
У ATmega328P нет «вывода 13»: есть бит 5 порта B. Перевод номеров Arduino в порты лежит в pins_arduino.h в виде массивов во флеш-памяти (атрибут PROGMEM, строки 126–232):
| Таблица | Что хранит | Для вывода 13 |
|---|---|---|
digital_pin_to_port_PGM | номер порта: PB = 2, PC = 3, PD = 4 | PB |
digital_pin_to_bit_mask_PGM | маска бита | _BV(5) |
digital_pin_to_timer_PGM | таймер, если вывод умеет ШИМ | NOT_ON_TIMER |
port_to_output_PGM | адрес регистра PORTx по номеру порта | &PORTB |
port_to_mode_PGM | адрес регистра DDRx | &DDRB |
Выводы 0–7 платы — порт D, 8–13 — порт B, 14–19 (они же A0–A5) — порт C. Константы из флеш-памяти AVR читает команда LPM (раздел 8.2 даташита), поэтому макросы digitalPinToPort() и остальные из Arduino.h (строки 178–184) — это вызовы pgm_read_byte() и pgm_read_word(). Все выводы Uno и Nano с портами и готовым кодом показывает карта выводов Arduino.
Что делает pinMode
// ArduinoCore-avr 1.8.8, cores/arduino/wiring_digital.c, lines 29-59 (abridged)
void pinMode(uint8_t pin, uint8_t mode)
{
uint8_t bit = digitalPinToBitMask(pin);
uint8_t port = digitalPinToPort(pin);
volatile uint8_t *reg, *out;
if (port == NOT_A_PIN) return;
reg = portModeRegister(port);
out = portOutputRegister(port);
if (mode == INPUT) {
uint8_t oldSREG = SREG; cli();
*reg &= ~bit; // input
*out &= ~bit; // pull-up off
SREG = oldSREG;
} else if (mode == INPUT_PULLUP) {
uint8_t oldSREG = SREG; cli();
*reg &= ~bit; // input
*out |= bit; // pull-up on
SREG = oldSREG;
} else {
uint8_t oldSREG = SREG; cli();
*reg |= bit; // output: PORT is left as it was
SREG = oldSREG;
}
}
Из этого кода следуют две особенности, о которых редко пишут в описаниях Arduino:
pinMode(pin, INPUT)не только делает вывод входом, но и выключает подтяжку, сбрасывая бит PORT;pinMode(pin, OUTPUT)не трогает PORT. Если до этого вывод былINPUT_PULLUP, бит PORT равен единице, и выход сразу встанет в высокий уровень.
Второе — полезный приём. По таблице 14-1 даташита выход с PORTxn = 1 даёт высокий уровень, а переход из входа в выход проходит через промежуточное состояние (раздел 14.2.3). Если нагрузка не должна увидеть даже короткий низкий уровень, сначала пишут digitalWrite(pin, HIGH) — у входа это включит подтяжку, — и только потом pinMode(pin, OUTPUT).
Что делает digitalWrite
// ArduinoCore-avr 1.8.8, cores/arduino/wiring_digital.c, lines 138-163
void digitalWrite(uint8_t pin, uint8_t val)
{
uint8_t timer = digitalPinToTimer(pin);
uint8_t bit = digitalPinToBitMask(pin);
uint8_t port = digitalPinToPort(pin);
volatile uint8_t *out;
if (port == NOT_A_PIN) return;
if (timer != NOT_ON_TIMER) turnOffPWM(timer);
out = portOutputRegister(port);
uint8_t oldSREG = SREG;
cli();
if (val == LOW) {
*out &= ~bit;
} else {
*out |= bit;
}
SREG = oldSREG;
}
По шагам: три чтения таблиц, проверка «есть ли такой вывод», выключение ШИМ, чтение адреса регистра PORT ещё из одной таблицы, сохранение регистра состояния SREG с запретом прерываний, изменение бита и восстановление SREG. Любое значение, кроме LOW, считается высоким уровнем: digitalWrite(13, 7) включит светодиод.
Зачем запрещать прерывания? Адрес порта известен только во время выполнения, и *out |= bit компилятор превращает в три шага — прочитать регистр, изменить бит, записать. Если между чтением и записью сработает прерывание и изменит соседний бит того же порта, запись вернёт старое значение. Это классическая ошибка чтения-модификации-записи, и cli() закрывает окно для неё.
turnOffPWM: скрытый побочный эффект
Если у вывода есть таймер (выводы 3, 5, 6, 9, 10 и 11), digitalWrite вызывает turnOffPWM() (строки 75–136). Это switch, который сбрасывает один бит: для вывода 9 (TIMER1A) — COM1A1 в регистре TCCR1A, для вывода 6 (TIMER0A) — COM0A1 в TCCR0A. Пока биты COMnx1:0 установлены, вывод принадлежит таймеру, и бит PORT на нём не виден (раздел 15.6 даташита). Без этой проверки digitalWrite(9, LOW) после analogWrite(9, 128) ничего бы не изменил.
То же самое регистрами
// Pin 13 of the Uno is PB5.
DDRB |= _BV(DDB5); // pinMode(13, OUTPUT)
PORTB |= _BV(PORTB5); // digitalWrite(13, HIGH)
PORTB &= ~_BV(PORTB5); // digitalWrite(13, LOW)
PINB = _BV(PINB5); // toggle: writing 1 to PINB5 inverts PORTB5
Адрес PORTB известен при компиляции и равен 0x05 — он в диапазоне 0x00–0x1F, где работают команды SBI и CBI (раздел 8.5). В FAQ avr-libc 2.3.2, в ответе на вопрос «How do I use a #define'd constant in an asm statement?», сказано, что запись PORTB |= (1 << 7) оптимизатор превращает в одну команду SBI, если операнды это позволяют. Одну команду прерывание разорвать не может, поэтому запрет прерываний не нужен. Это верно только для одного бита: если менять сразу несколько, PORTB |= 0x21, компилятор снова читает, меняет и записывает регистр, и защищать такую запись от прерываний нужно самим.
Чего регистровая версия не делает: не проверяет номер вывода, не выключает ШИМ и не работает на другой плате — на Mega 2560 вывод 13 это уже PB7.
Считаем такты
Сначала оценка по разделу 37 даташита. SBI и CBI выполняются за 2 такта. В digitalWrite есть как минимум:
| Что | Команды | Тактов |
|---|---|---|
| три чтения таблиц из флеш-памяти | 3 × LPM | 9 |
| адрес регистра PORT (16 бит) | 2 × LPM | 6 |
| сохранить SREG и запретить прерывания | IN, CLI | 2 |
| изменить бит через указатель | LD, ST (и OR) | 5 |
| восстановить SREG | OUT | 1 |
| вызов и возврат, если функция не встроена | CALL, RET | 8 |
Итого 31 такт — без вычисления адресов таблиц, сравнений, переходов и передачи аргументов (расчёт наш, нижняя граница). Против 2 тактов SBI — разница больше чем на порядок. Точное число зависит от версии avr-gcc и от того, встроит ли оптимизатор функцию при сборке с -flto (этот ключ стоит в platform.txt ядра), поэтому его лучше измерить.
Скетч, который считает такты
Timer1 без делителя прибавляет единицу за каждый такт процессора (табл. 16-5, CS12:0 = 001). Ядро настроило его под ШИМ, поэтому переводим его в обычный режим — analogWrite() на выводах 9 и 10 в этом скетче работать не будет:
void setup() {
Serial.begin(115200);
pinMode(13, OUTPUT);
TCCR1A = 0; // normal mode instead of the core's 8-bit PWM
TCCR1B = _BV(CS10); // clk/1: TCNT1 counts CPU cycles
noInterrupts(); // keep the Timer0 interrupt out of the window
TCNT1 = 0;
uint16_t empty = TCNT1; // cost of the measurement itself
TCNT1 = 0;
digitalWrite(13, HIGH);
uint16_t core = TCNT1;
TCNT1 = 0;
PORTB |= _BV(PORTB5);
uint16_t reg = TCNT1;
interrupts();
Serial.print(F("digitalWrite, cycles: "));
Serial.println(core - empty);
Serial.print(F("PORTB |=, cycles: "));
Serial.println(reg - empty);
}
void loop() {}
Число для PORTB |= должно получиться около 2, для digitalWrite — по оценке выше не меньше 31. При 16 МГц такт длится 62,5 нс, так что 31 такт — это около 1,9 мкс (расчёт наш). Одна оговорка: компилятор обязан сохранять порядок только обращений к volatile-регистрам, а время между ними он не учитывает (эссе avr-libc о перестановке кода). Если результат выглядит странно, загляните в листинг: avr-objdump -d -S по файлу .elf из папки сборки скетча, путь к ней Arduino IDE печатает при подробном выводе компиляции. Команды и их такты описаны в статье «Система команд AVR».
Ток выводов
Регистровый код так же легко сжигает вывод, как и digitalWrite. Абсолютный максимум — 40 мА на вывод и 200 мА через выводы VCC и GND (раздел 29.1), а рабочий ток держат в единицах миллиампер: светодиод — только через резистор, нагрузку мощнее — через транзисторный ключ. Никогда не соединяйте выход напрямую с GND, с питанием или с другим выходом: при противоположных уровнях ток ограничен только сопротивлением выходных транзисторов. Суммарные ограничения по группам выводов — в таблице урока о портах.
Задание
- Какой порт, маску и таймер найдёт
digitalWrite(10, LOW)в таблицах Uno и какой бит какого регистра сброситturnOffPWM()? - Цикл переключения вывода 13 состоит из двух команд:
SBI PINB, 5иRJMPназад. Какая частота меандра на выводе при 16 МГц? - Скетч выполняет
pinMode(7, INPUT_PULLUP);, затемpinMode(7, OUTPUT);. Какой уровень окажется на выводе 7 и почему?
Ответ и решение
-
Вывод 10 — порт B, маска
_BV(2)(PB2), таймерTIMER1B.turnOffPWM()выполнитcbi(TCCR1A, COM1B1)— сбросит бит COM1B1 регистра TCCR1A, и вывод снова будет подчиняться биту PORTB2. -
SBI — 2 такта, RJMP — 2 такта (раздел 37): вывод меняет уровень каждые 4 такта, период — 8 тактов. Частота — 16 МГц / 8 = 2 МГц (расчёт наш).
-
Высокий.
INPUT_PULLUPзаписал единицу в PORTD7, а веткаOUTPUTвpinModeменяет только DDRD7. Вход с подтяжкой становится выходом с PORTxn = 1, а это высокий уровень (табл. 14-1).
Что дальше
В уроке 2 разберём digitalRead() и режим INPUT_PULLUP — и увидим, что чтение вывода тоже выключает ШИМ. Регистры портов ATmega328P с адресами и битами — на странице регистров GPIO, регистр состояния — на странице регистров ядра. Маски для нескольких выводов сразу соберёт калькулятор битовых масок, а почему тактов так много, объясняет термин «Атомарная операция».
Частые вопросы
Почему digitalWrite запрещает прерывания?
Порт меняется через указатель, взятый из таблицы, а это три команды: прочитать регистр, изменить бит, записать обратно. Если между чтением и записью сработает прерывание и изменит другой бит того же порта, запись затрёт его изменение. Поэтому ядро сохраняет SREG, выполняет cli(), меняет бит и восстанавливает SREG. Запись PORTB |= _BV(5) с известным адресом компилятор превращает в одну команду SBI, которую прерывание разорвать не может.
Почему digitalWrite выключает ШИМ на выводе?
Пока в регистре таймера установлены биты COMnx1:0, выходом управляет таймер, а не бит PORT (разд. 15.6 даташита). Чтобы digitalWrite(9, LOW) действительно дал низкий уровень после analogWrite(9, 128), ядро сначала сбрасывает бит COM1A1 функцией turnOffPWM. Вернуть ШИМ можно только новым вызовом analogWrite.
Можно ли в одном скетче смешивать digitalWrite и PORTB?
Можно: digitalWrite пишет в тот же регистр PORTB, только находит его по таблице. Нужно помнить две вещи: запись PORTB |= _BV(1) не выключает ШИМ на выводе 9, если до этого был analogWrite, а запись сразу нескольких битов не атомарна, и если тот же порт меняет обработчик прерывания, её надо обернуть в ATOMIC_BLOCK или cli() и восстановление SREG.
Источники
- Arduino. ArduinoCore-avr 1.8.8: cores/arduino/wiring_digital.c, Arduino.h, variants/standard/pins_arduino.h — тег 1.8.8 — коммит 86df345 от 21 мая 2026 (GitHub API, tags и commits); wiring_digital.c, стр. 29–59: pinMode — INPUT сбрасывает бит PORT, INPUT_PULLUP ставит, OUTPUT меняет только DDR, всё при сохранённом SREG и cli(); стр. 75–136: turnOffPWM сбрасывает бит COMnx1; стр. 138–163: digitalWrite — три чтения таблиц, проверка таймера, запись в PORT при запрещённых прерываниях; Arduino.h, стр. 43–45 и 178–184: INPUT = 0, OUTPUT = 1, INPUT_PULLUP = 2, макросы digitalPinToPort, digitalPinToBitMask, digitalPinToTimer и portOutputRegister через pgm_read_byte и pgm_read_word; pins_arduino.h, стр. 126–232: таблицы PROGMEM для Uno — выводы 0–7 порт D, 8–13 порт B, 14–19 порт C, таймеры выводов 3, 5, 6, 9, 10, 11
- Microchip. ATmega48A/PA/88A/PA/168A/PA/328/P Data Sheet (DS40002061B) — разд. 8.2: константы во флеш-памяти читает команда LPM; разд. 8.5: регистры с адресами 0x00–0x1F доступны командам SBI и CBI; разд. 14.4.2: PORTB по адресу 0x05; разд. 14.1: SBI и CBI меняют один бит порта, не трогая остальные; разд. 14.2.1 и табл. 14-1: биты DDxn и PORTxn, состояния вывода; разд. 14.2.2: запись 1 в PINxn переключает PORTxn, для этого годится SBI; разд. 14.2.3: промежуточные состояния при переключении вход/выход; разд. 15.6: пока установлены биты COM0x1:0, выход сравнения таймера подменяет обычную функцию порта; разд. 16.11.1–16.11.4: TCCR1A, TCCR1B, TCNT1; табл. 16-5: CS12:0 = 001 — без делителя; разд. 29.1: 40 мА на вывод, 200 мА через VCC и GND; разд. 37: такты команд SBI, CBI, LD, ST, LPM, IN, OUT, CLI, CALL, RET
- AVR-LibC 2.3.2: FAQ и эссе Problems with Reordering Code (github.com/avrdudes/avr-libc) — FAQ (doc/api/faq.dox.in в исходниках 2.3.2), раздел «How do I use a #define'd constant in an asm statement?», примечание: стандартные битовые операции C вида PORTB |= (1 << 7) оптимизатор превращает в одну команду SBI, если позволяют операнды; эссе о перестановке кода: компилятор обязан сохранять порядок только volatile-обращений и не учитывает время между ними; _BV(bit) = (1 << (bit))
Нашли ошибку?
Смотрите также
- Порты и регистрыDDRx, PORTx, PINx, маски битов и мигание светодиодом
- Пины ArduinoПин Arduino → порт ATmega, ШИМ, прерывания и регистры
- Система команд AVRВсе инструкции с флагами и тактами, адресация, примеры кода
- Чтение-модификация-записьИзменение части регистра в три шага и связанные с этим гонки
- SREG — регистр состояния AVRВосемь флагов регистра состояния AVR и как их используют