В
этом уроке мы научимся работать напрямую с регистрами микроконтроллера.
Зачем? Я думаю в первую очередь это нужно для того, чтобы понимать
чужой код и переделывать его под себя, потому что прямая работа с
регистрами в скетчах из Интернета встречается довольно часто.
Начнём
с того, что вспомним, где мы пишем код: Arduino IDE. Несмотря на
большое количество не всегда оправданного негатива в сторону этой
программы, она очень крутая. Помимо кучи встроенных инструментов и
поддержки “репозиториев” от сторонних разработчиков, Arduino IDE
позволяет нам программировать плату на разных языках программирования.
Это некая условность, но по сути получается три языка:
- C++, который мы изучили в рамках уроков на сайте;
- Ассемблер (ассемблерные вставки) – прямая работа с микроконтроллером, очень сложный язык;
- Язык регистров микроконтроллера. Условно назовём его отдельным языком, хоть он таковым и не является.
Что
такое регистр? Тут всё весьма просто: регистры это грубо говоря
глобальные переменные, информацию из которых мы можем как считать, так и
изменить, это сверхбыстрые блоки оперативной памяти объёмом 1 байт,
находящиеся рядом с ядром МК и периферией. В регистрах микроконтроллера
хранятся “настройки” для различной его периферии: таймеры-счётчики,
порты с пинами, АЦП, шина UART, I2C, SPI и прочее железо, встроенное в
МК. Меняя регистр, мы даём практически прямую команду микроконтроллеру,
что и как нужно сделать. Запись в регистр занимает 1 такт, то есть
0.0625 микросекунды. Это ОЧЕНЬ быстро. Названия (имена) регистров
фиксированные, все их описания можно найти в даташите на
микроконтроллер. Работа с регистрами очень непростая, если вы не выучили
их все наизусть. А названия у них обычно нечитаемые, аббревиатуры. Так
называемые “Ардуиновские” функции собственно и занимаются тем, что
работают с регистрами, оставляя нам удобную, понятную и читаемую
функцию.
Итак,
с точки зрения кода, регистр – это переменная, которую мы можем читать и
писать. В микроконтроллерах серии ATmega регистры 8 битные (вроде бы
поэтому сам микроконтроллер и считается 8-ми битным), то есть регистр
это (грубо) переменная типа byte,
меняя которую, можно на низком уровне конфигурировать работу
микроконтроллера. Насколько мы знаем, байт это число от 0 до 255,
получается каждый регистр имеет 255 настроек? Нет, логика здесь совсем
другая: байт это 8 бит, принимающих значение 0 и 1, то есть один регистр
хранит 8 настроек, которые можно включить/выключить. Именно так и
происходит конфигурация микроконтроллера на низком уровне. Давайте для
примера рассмотрим один из регистров таймера 1, под названием TCCR1B.
Картинка из даташита на ATmega328p:
Регистр TCCR1B,
как и положено здоровому байту, состоит из 8 бит. Почти каждый его бит
имеет имя (кроме 5-го, в этом МК он не занят). Что значит каждый бит и
регистр – самым подробным образом расписано в даташите на
микроконтроллер. Названия битов являются чем-то вроде констант, все их
имена “заняты”, и создать переменную, совпадающую с названием бита,
нельзя, ровно как попытаться изменить значение бита, обратившись к его
имени.
- int WGM12; // приведёт к ошибке
- CS11 = 5; // приведёт к ошибке
Так
что если вы случайно назовёте переменную так, как называется регистр
или бит – вы получите ошибку. Но это вряд ли случится, названия у
регистров и битов не очень вменяемые. Но считать значение бита по его
названию – можно! Причём его значение будет равно его номеру в регистре,
считая справа. CS11 равен 1, WGM13 равен 4, ICNC1 равен
7, также эти значения указаны в таблице даташита (над таблицей). Думаю
здесь всё понятно: есть регистр (байт), имеющий уникальное имя и
состоящий из 8 бит, каждый бит тоже имеет уникальное имя, по которому
можно получить номер этого бита в байте его регистра. Осталось понять,
как этим всем пользоваться.
Запись/чтение регистра
Существует
довольно таки много общепринятых способов установки битов в регистрах,
мы рассмотрим их все, чтобы столкнувшись с одним из них вы знали, что
это вообще такое и как оно работает. Значит всё что нам нужно – это
установка нужного бита в регистре в состояние 0 или 1, в этом по сути и
заключается работа с регистром. Рекомендую прочитать урок по битовым операциям, в котором максимально подробно разобрано всё, что касается манипуляций с битами.
Давайте
вернёмся к регистру таймера, который я показывал выше, и попробуем его
сконфигурировать. Первый способ, это явное задание всего байта сразу, со
всеми единицами и нулями. Сделать это можно так:
- TCCR1B = 0b01010101
Таким образом мы включили и выключили нужные биты сразу, одним махом. Как вы помните из урока о типах данных и чисел, микроконтроллеру всё равно, в какой системе исчисления вы с ним работаете, то есть число 0b01010101 у нас в двоичной системе, в десятичной это будет 85, а в шестнадцатеричной – 0x55. И вот эти три варианта абсолютно одинаковы с точки зрения результата:
- TCCR1B = 0b01010101;
- TCCR1B = 85;
- TCCR1B = 0x55;
Только
на первый можно посмотреть и сразу понять, что где стоит. Чего не
скажешь про остальные два. Очень часто в чужих скетчах встречается такая
запись, и это не очень комфортно.
Гораздо
чаще бывает нужно “прицельно” изменить один бит в байте, и тут на
помощь приходят логические (битовые) функции и макросы. Рассмотрим все
варианты, во всех из них BYTE это байт-регистр, и BIT это номер бита, считая с правого края. То есть BIT это цифра от 0 до 7, либо название бита из даташита.
Установка бита в 1 | Установка бита в 0 | Описание |
BYTE |= (1 << BIT); | BYTE &= ~(1 << BIT); | Использование битового сдвига << |
BYTE |= (2^BIT); | BYTE &= ~(2^BIT); | Используем 2 в степени <номер бита> (пример не рабочий!) |
BYTE |= bit(BIT); | BYTE &= ~bit(BIT); | Используем ардуиновский макрос bit(), заменяющий сдвиг |
BYTE |= _BV(BIT); | BYTE &= ~_BV(BIT); | Используем встроенную функцию _BV(), опять же аналог сдвига |
sbi(BYTE, BIT); | cbi(BYTE, BIT); | Используем общепринятые макросы sbi и cbi |
bitSet(BYTE, BIT); | bitClear(BYTE, BIT); | Используем ардуиновские функции bitSet() и bitClear() |
Что
хочу сказать по перечисленным вариантам: они все по сути являются одним
и тем же, а именно – первым, просто обёрнуты в другие функции и
макросы. Время выполнения всех вариантов одинаково, т.к. макро-функции
не делают лишних действий, а приводят все способы к первому, со сдвигом
и |= и &=. Все эти способы вы можете встретить в скетчах из интернета, это факт. Лично мне больше всего нравится ардуиновский bitSet и bitClear, потому что они имеют читаемое название и заранее сидят в библиотеке. Что касается sbi и cbi – то для их использования нужно в самом начале документа (среди остальных дефайнов) создать макро для этих функций:
- #define cbi(sfr, bit) (_SFR_BYTE(sfr) &= ~_BV(bit))
- #define sbi(sfr, bit) (_SFR_BYTE(sfr) |= _BV(bit))
И после этого можно пользоваться sbi и cbi.
Давайте рассмотрим пример, где просто подёргаем TCCR1B разными способами:
- // для использования sbi и cbi
- #define cbi(sfr, bit) (_SFR_BYTE(sfr) &= ~_BV(bit))
- #define sbi(sfr, bit) (_SFR_BYTE(sfr) |= _BV(bit))
- void setup() {
- TCCR1B = 0; // обнулили регистр
- bitSet(TCCR1B, CS11); // включили бит №1
- cbi(TCCR1B, CS11); // вЫключили бит №1
- TCCR1B |= _BV(4); // включили бит №4
- TCCR1B |= (1 << WGM12); // включили бит №3
- TCCR1B &= ~_BV(WGM13); // вЫключили бит №4
- bitClear(TCCR1B, 3); // вЫключили бит №3
- }
Можно ещё добавить вариант, где в одной строчке можно “прицельно” установить несколько битов:
- void setup() {
- TCCR1B = 0; // обнулили регистр
- // ставим бит 1, 3 и 4(WGM13)
- TCCR1B |= _BV(1) | _BV(3) | _BV(WGM13);
- }
Я думаю тут всё понятно, давайте теперь попробуем “прицельно” прочитать бит из регистра:
Чтение бита | Описание |
(BYTE >> BIT) & 1 | Вручную через сдвиг |
bitRead(BYTE, BIT) | Ардуиновская макро-функция bitRead |
Два рассмотренных способа возвращают 0 или 1 в зависимости от состояния бита. Пример:
- void setup() {
- TCCR1B = 0; // обнулили регистр
- bitSet(TCCR1B, CS12); // включили бит №2
- Serial.begin(9600); // открыли порт
- Serial.println(bitRead(TCCR1B, 2)); // получили 1
- }
Теперь вы готовы к любой встрече с регистрами!
16-бит регистры
У Ардуинок (AVR) встречаются также 16-битные регистры, которые на деле разделены на два 8-битных, например регистры АЦП ADCH и ADCL.
АЦП у нас 10 битный, но регистры – 8 битные, поэтому часть (8 бит)
хранится в одном регистре (ADCL), а остальное (2 бита) – в другом
(ADCH). Смотрите, как это выглядит в виде таблицы:
Вопрос: как нам принять это самое 10 битное число, если оно разбито на два разных регистра? Очень просто, используя сдвиг:
- int val = ADCL + (ADCH << 8);
Читать нужно с младшего регистра. Как только мы читаем младший регистр (первый), у МК полностью блокируется доступ к всему регистру, пока не будет прочитан старший. Если прочитать сначала старший – значение младшего может быть утеряно.
Обратная задача: есть опять же мнимый 16-битный регистр (состоящий из двух 8-битных), в который нам нужно записать значение. Например сдвоенный регистр ICR1H и ICR1L, вот таблица:
Микроконтроллер
может работать только с одним байтом, а как нам записать двухбайтное
число? А вот так: разбить число на два байта (при помощи ардуиновских
функций highByte() и lowByte() ), и эти байты записать в соответствующие регистры. Пример:
- uint16_t val = 1500; // просто число типа int
- ICR1H = highByte(val); // пишем старший байт
- ICR1L = lowByte(val); // пишем младший байт
- // читаем байты обратно и "склеиваем" в int
- byte val_1 = ICR1L;
- byte val_2 = ICR1H;
- uint16_t value = val_1 + (val_2 << 8);
Записывать нужно со старшего байта. Как только мы записываем младший байт
(последний) – МК “защелкивает” оба регистра в память, соответственно
если сначала записать младший – в старшем будет 0, и последующая запись
старшего будет проигнорирована.