Перейти к содержанию

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 = 4PB
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 × LPM9
адрес регистра PORT (16 бит)2 × LPM6
сохранить SREG и запретить прерыванияIN, CLI2
изменить бит через указательLD, ST (и OR)5
восстановить SREGOUT1
вызов и возврат, если функция не встроенаCALL, RET8

Итого 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, с питанием или с другим выходом: при противоположных уровнях ток ограничен только сопротивлением выходных транзисторов. Суммарные ограничения по группам выводов — в таблице урока о портах.

Задание

  1. Какой порт, маску и таймер найдёт digitalWrite(10, LOW) в таблицах Uno и какой бит какого регистра сбросит turnOffPWM()?
  2. Цикл переключения вывода 13 состоит из двух команд: SBI PINB, 5 и RJMP назад. Какая частота меандра на выводе при 16 МГц?
  3. Скетч выполняет pinMode(7, INPUT_PULLUP);, затем pinMode(7, OUTPUT);. Какой уровень окажется на выводе 7 и почему?
Ответ и решение
  1. Вывод 10 — порт B, маска _BV(2) (PB2), таймер TIMER1B. turnOffPWM() выполнит cbi(TCCR1A, COM1B1) — сбросит бит COM1B1 регистра TCCR1A, и вывод снова будет подчиняться биту PORTB2.

  2. SBI — 2 такта, RJMP — 2 такта (раздел 37): вывод меняет уровень каждые 4 такта, период — 8 тактов. Частота — 16 МГц / 8 = 2 МГц (расчёт наш).

  3. Высокий. 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.

Источники

  1. 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
  2. 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
  3. 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))

Нашли ошибку?