Как работает digitalWrite

Все любят простую функцию digitalWrite(pin, LEVEL), с помощью которой так легко зажигать и гасить светодиодики на Arduino. Что она скрывает за собой? Скандалы, интриги, расследования!
Для начала залезем в файл wiring_digital.c, потому что именно там она определена. В объявлении видно, что она кушает два аргумента — номер пина и значение (высокое или низкое состояние) — всё, как мы привыкли:

1
void digitalWrite(uint8_t pin, uint8_t val){

Первое, что делает эта функция — берет номер пина и определяет, к какому таймеру он привязан, какому биту он соответствует на каком порту. Для это используются три функции:

1
2
3
uint8_t timer = digitalPinToTimer(pin);
uint8_t bit = digitalPinToBitMask(pin);
uint8_t port = digitalPinToPort(pin);
 
Эти функции определены в файле Arduino.h:

1
2
3
#define digitalPinToPort(P) ( pgm_read_byte( digital_pin_to_port_PGM + (P) ) )
#define digitalPinToBitMask(P) ( pgm_read_byte( digital_pin_to_bit_mask_PGM + (P) ) )
#define digitalPinToTimer(P) ( pgm_read_byte( digital_pin_to_timer_PGM + (P) ) )
 
Видно, что функция pgm_read_byte — это явно чтение из памяти программ. Разберемся, как оно работает.
 
В файле avr/pgmspace.h есть такое определение:

1
#define pgm_read_byte(address_short) pgm_read_byte_near(address_short)
 
Которое указывает на такое определение:

1
#define pgm_read_byte_near(address_short) __LPM((uint16_t)(address_short))
 
Которое указывает на такое определение:

1
#define __LPM(addr) __LPM_classic__(addr)
 
Которое указывает на такое определение:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define __LPM_classic__(addr) \
(__extension__({ \
uint16_t __addr16 = (uint16_t)(addr); \
uint8_t __result; \
__asm__ \
( \
"lpm" "\n\t" \
"mov %0, r0" "\n\t" \
: "=r" (__result) \
: "z" (__addr16) \
: "r0" \
); \
__result; \
}))
 
Наконец-то добрались до ассемблерной вставки, которая выполняет микрокоманду lpm, читая из памяти программ из указанного адреса в регистр r0, а потом из него записывает в регистр результата и отдает нам.
По какому адресу она читает? Для функции digitalPinToPort(P) этот адрес равен digital_pin_to_port_PGM + (P), где P — номер пина. Номер пина прибавляется к базовому адресу, которые определяются так:

1
2
3
extern const uint8_t PROGMEM digital_pin_to_port_PGM[];
extern const uint8_t PROGMEM digital_pin_to_bit_mask_PGM[];
extern const uint8_t PROGMEM digital_pin_to_timer_PGM[];
 
Например, для UNO это \arduino\variants\standard\pins_arduino.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const uint8_t PROGMEM digital_pin_to_port_PGM[] = {
    PD, /* 0 */
    PD,
    PD,
    PD,
    PD,
    PD,
    PD,
    PD,
    PB, /* 8 */
    PB,
    PB,
    PB,
    PB,
    PB,
    PC, /* 14 */
    PC,
    PC,
    PC,
    PC,
    PC,
};
 
Отсюда видно, что пины с 0 по 7 на порту D, с 8 по 13 на порту B, а с 14 по 19 на порту C.
Далее, для digitalPinToBitMask(P) адрес digital_pin_to_bit_mask_PGM + (P), что соответствует в том же файле следующей структуре в памяти программ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const uint8_t PROGMEM digital_pin_to_bit_mask_PGM[] = {
    _BV(0), /* 0, port D */
    _BV(1),
    _BV(2),
    _BV(3),
    _BV(4),
    _BV(5),
    _BV(6),
    _BV(7),
    _BV(0), /* 8, port B */
    _BV(1),
    _BV(2),
    _BV(3),
    _BV(4),
    _BV(5),
    _BV(0), /* 14, port C */
    _BV(1),
    _BV(2),
    _BV(3),
    _BV(4),
    _BV(5),
};
 
Отсюда понятно, что, например, цифровой пин 8 Arduino соответствует биту 0 в своем порту.
И, наконец, для digitalPinToTimer(P) читается адресов из пачки digital_pin_to_timer_PGM + (P) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const uint8_t PROGMEM digital_pin_to_timer_PGM[] = {
    NOT_ON_TIMER, /* 0 - port D */
    NOT_ON_TIMER,
    NOT_ON_TIMER,
    // on the ATmega168, digital pin 3 has hardware pwm
    #if defined(__AVR_ATmega8__)
    NOT_ON_TIMER,
    #else
    TIMER2B,
    #endif
    NOT_ON_TIMER,
    // on the ATmega168, digital pins 5 and 6 have hardware pwm
    #if defined(__AVR_ATmega8__)
    NOT_ON_TIMER,
    NOT_ON_TIMER,
    #else
    TIMER0B,
    TIMER0A,
    #endif
    NOT_ON_TIMER,
    NOT_ON_TIMER, /* 8 - port B */
    TIMER1A,
    TIMER1B,
    #if defined(__AVR_ATmega8__)
    TIMER2,
    #else
    TIMER2A,
    #endif
    NOT_ON_TIMER,
    NOT_ON_TIMER,
    NOT_ON_TIMER,
    NOT_ON_TIMER, /* 14 - port C */
    NOT_ON_TIMER,
    NOT_ON_TIMER,
    NOT_ON_TIMER,
    NOT_ON_TIMER,
};
 
Здесь видно 2 интересных факта: до сих пор сохранена поддержка Atmega8 и разработчики забыли переписать комментарии, когда переходили с камня 168 на 328. Для 328 камня можно увидеть, что:

  3 пин на канале B 2-го таймера: TIMER2B;
  5 и 6 пины на двух каналах 0-го: TIMER0B, TIMER0A;
  9 и 10 пины на двух каналах 1-го: TIMER1A, TIMER1B;
  11 пин на канале A 2-го: TIMER2A.

Остальные пины не привязаны к таймерам, и для них значение timer будет равно NOT_ON_TIMER.
Вернемся к нашим баранам и продолжим смотреть файл wiring_digital.h. Объявляется указатель *out, который будет содержать адрес порта, с которым мы собираемся манипулировать своим digitalWrite():

1
volatile uint8_t *out;
 
Если выяснилось, что пин, в который мы хотим записать, не существует, то мы не сделаем ничего и вывалимся из функции:

1
if (port == NOT_A_PIN) return;
 
Но где берется NOT_A_PIN? В хедере Arduino.h есть такой дефайн:

1
#define NOT_A_PIN 0
 
Из которого становится понятно, что если мы будем читать из памяти порт, бит и таймер для несуществующего пина, то нам вернется 0. Далее, если пин поддерживает ШИМ, то нужно выключить ШИМ перед тем, как выставлять на нем высокое или низкое состояние:

1
if (timer != NOT_ON_TIMER) turnOffPWM(timer);
 
Откуда может появиться NOT_ON_TIMER, мы уже выяснили. Посмотрим на функцию turnOffPWM(timer), которая определена в wiring_digital.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
static void turnOffPWM(uint8_t timer)
{
	switch (timer){
        #if defined(TCCR1A) && defined(COM1A1)
        case TIMER1A: cbi(TCCR1A, COM1A1); break;
        #endif
        #if defined(TCCR1A) && defined(COM1B1)
        case TIMER1B: cbi(TCCR1A, COM1B1); break;
        #endif
        
        #if defined(TCCR2) && defined(COM21)
        case TIMER2: cbi(TCCR2, COM21); break;
        #endif
        
        #if defined(TCCR0A) && defined(COM0A1)
        case TIMER0A: cbi(TCCR0A, COM0A1); break;
        #endif
        
        #if defined(TIMER0B) && defined(COM0B1)
        case TIMER0B: cbi(TCCR0A, COM0B1); break;
        #endif
        #if defined(TCCR2A) && defined(COM2A1)
        case TIMER2A: cbi(TCCR2A, COM2A1); break;
        #endif
        #if defined(TCCR2A) && defined(COM2B1)
        case TIMER2B: cbi(TCCR2A, COM2B1); break;
        #endif
        
        #if defined(TCCR3A) && defined(COM3A1)
        case TIMER3A: cbi(TCCR3A, COM3A1); break;
        #endif
        #if defined(TCCR3A) && defined(COM3B1)
        case TIMER3B: cbi(TCCR3A, COM3B1); break;
        #endif
        #if defined(TCCR3A) && defined(COM3C1)
        case TIMER3C: cbi(TCCR3A, COM3C1); break;
        #endif
        
        #if defined(TCCR4A) && defined(COM4A1)
        case TIMER4A: cbi(TCCR4A, COM4A1); break;
        #endif
        #if defined(TCCR4A) && defined(COM4B1)
        case TIMER4B: cbi(TCCR4A, COM4B1); break;
        #endif
        #if defined(TCCR4A) && defined(COM4C1)
        case TIMER4C: cbi(TCCR4A, COM4C1); break;
        #endif
        #if defined(TCCR4C) && defined(COM4D1)
        case TIMER4D: cbi(TCCR4C, COM4D1); break;
        #endif
        
        #if defined(TCCR5A)
        case TIMER5A: cbi(TCCR5A, COM5A1); break;
        case TIMER5B: cbi(TCCR5A, COM5B1); break;
        case TIMER5C: cbi(TCCR5A, COM5C1); break;
        #endif
    }
}
 
В переменной timer сейчас содержится название таймера, к которому привязан наш пин, и функция turnOffPWM(uint8_t timer) деактивирует ШИМ на этом таймере через свич-кейс.
Например, вот такая строчка:

1
cbi(TCCR1A, COM1A1);
 
означает, что в регистре управления таймером 1 сбрасывается бит COM1A1 (команда cbi делает это) и порт возвращается в режим GPIO.
 
Указатель *out пока никуда не указывал, но теперь будет указывать на нужный порт:

1
out = portOutputRegister(port);
 
SREG — это регистр, в котором хранится статус ядра: флаги операций и глобальное разрешение прерываний (флаг I). Именно из-за флага I мы должны сохранить текущее состояние регистра SREG и потом восстановить его, чтобы не нарушить работу программы, поскольку мы собираемся запрещать прерывания:

1
uint8_t oldSREG = SREG;
 
Запрещаем прерывания, чтобы нас ничего не потревожило во время установки логического уровня на пине:

1
cli();
 
Если мы хотели установить уровень LOW, пишем 0 в соответствующий бит соответствующего порта. Иначе пишем 1:

1
2
3
4
5
if (val == LOW) {
*out &= ~bit;
} else {
*out |= bit;
}
 
Восстанавливаем SREG:

1
2
SREG = oldSREG;
}
 
Конец.
Фух.

На примере посмотрим, что будет происходить, если мы вызовем digitalWrite(3,HIGH);
1. Для определения таймера, к которому привязан 3-й пин, выполнится чтение из ячейки памяти программ по адресу digital_pin_to_timer_PGM + 3, в которой записано TIMER2B. Станет timer=TIMER2B.
2. Для определения бита, которому соответствует 3-й пин, выполнится чтение из ячейки памяти программ по адресу digital_pin_to_bit_mask_PGM + 3, в которой записано _BV(3). Станет bit=_BV(3).
3. Для определения порта, на котором находится 3-й пин, выполнится чтение из ячейки памяти программ по адресу digital_pin_to_port_PGM + 3, в которой записано PD. Станет port=PD.
4. Создастся указатель *out, который указывает на неопределенное место.
5. Проверится, существует ли пин. Выяснится, что он существует.
6. Проверится, привязан ли пин к таймеру. Выяснится, что привязан.
7. Вызовется функция выключения ШИМ с аргументом TIMER2B, эта функция через свич-кейс найдет, какой бит нужно очистить, и очистит его.
8. Указатель *out начнет указывать на PD.
9. Регистр SREG сохранится в другой ячейке.
10. Запретятся прерывания.
11. В PD на пине 3 выставится нужный логический уровень.
12. Регистр SREG вернется на место.
13. ??????
14. PROFIT

P.S. А можно дрыгать пином так:
1
2
PORTD |= (1 << 3)
PORTD &= ~(1 << 3)