Регистры, байты, биты


В этом уроке мы научимся работать напрямую с регистрами микроконтроллера. Зачем? Я думаю в первую очередь это нужно для того, чтобы понимать чужой код и переделывать его под себя, потому что прямая работа с регистрами в скетчах из Интернета встречается довольно часто.

Начнём с того, что вспомним, где мы пишем код: Arduino IDE. Несмотря на большое количество не всегда оправданного негатива в сторону этой программы, она очень крутая. Помимо кучи встроенных инструментов и поддержки “репозиториев” от сторонних разработчиков, Arduino IDE позволяет нам программировать плату на разных языках программирования. Это некая условность, но по сути получается три языка:

  • C++, который мы изучили в рамках уроков на сайте;
  • Ассемблер (ассемблерные вставки) – прямая работа с микроконтроллером, очень сложный язык;
  • “Язык” регистров микроконтроллера. Условно назовём его отдельным языком, хоть он таковым и не является.

Что такое регистр? Тут всё весьма просто: это сверхбыстрые блоки оперативной памяти объёмом 1 байт, находящиеся рядом с ядром МК и периферией. На стороне программы это обычные глобальные переменные, которые можно читать и изменять. В регистрах микроконтроллера хранятся “настройки” для различной его периферии: таймеры-счётчики, порты с пинами, АЦП, шина UART, I2C, SPI и прочее железо, встроенное в МК. Меняя регистр, мы даём практически прямую команду микроконтроллеру, что и как нужно сделать. Запись в регистр занимает 1 такт, то есть 0.0625 микросекунды (при частоте тактирования 16 МГц) – это ОЧЕНЬ быстро. Имена регистров фиксированные, полный перечень с подробным описанием можно найти в даташите на микроконтроллер (официальный даташит на ATmega328 – Arduino Nano/UNO/Mini). Работа с регистрами очень непростая, если вы не выучили их все наизусть, потому что названия у них обычно нечитаемые, аббревиатуры. Так называемые “Ардуиновские” функции собственно и занимаются тем, что работают с регистрами, оставляя нам удобную, понятную и читаемую функцию. Ничего сверхъестественного в этом нет.

Зачем вообще работать с регистрами напрямую? Есть несколько больших преимуществ:

  • Скорость работы: чтение/запись регистра выполняется максимально быстро, что позволяет ускорить работу с МК (например дёргать пины вручную вместо digitalWrite()). Максимальная скорость работы с периферией МК нужна далеко не всегда, поэтому если вам не нужно сэкономить несколько микросекунд – с регистрами можно даже не связываться.
  • Объём памяти: прямая работа с регистрами позволяет максимально компактно работать с периферией МК, читая и записывая только нужные биты, поэтому написанный под свои задачи конкретный код займёт меньше места, чем чьи-то готовые универсальные функции и библиотеки (например тот же digitalWrite или работа с Serial). При работе с Ардуино можно не заморачиваться по этому поводу практически никогда, а вот если делать проект на ATTiny – там придётся ужимать код вплоть до байта.
  • Гибкость настройки: работа с микроконтроллером напрямую при помощи регистров позволяет очень гибко настраивать периферию под свои задачи. Дело в том, что все существующие Ардуино-библиотеки охватывают чуть больше половины возможностей микроконтроллера! Огромное количество настроек и полезных трюков не описано нигде, кроме даташита, и для их использования нужно уметь читать этот самый даташит и работать с регистрами. Собственно об этом читайте ниже.

Итак, с точки зрения кода, регистр – это переменная, которую мы можем читать и писать. В микроконтроллерах серии ATmega/ATtiny регистры 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) Ардуиновская макро-функция

Два рассмотренных способа возвращают 0 или 1 в зависимости от состояния бита. Пример:

void setup() {
  TCCR1B = 0;             // обнулили регистр
  bitSet(TCCR1B, CS12);   // включили бит №2
  Serial.begin(9600);     // открыли порт
  Serial.println(bitRead(TCCR1B, 2)); // получили 1
}

Ещё больше примеров работы с битами смотри в предыдущем уроке по битовым операциям.

16-бит регистры


У Ардуинок (AVR) встречаются также сдвоенные 16-битные регистры, состоят из двух 8-битных, например “сдвоенный регистр” АЦП ADC состоит из ADCH и ADCL, или регистр таймера ICR1 состоит из ICR1H и ICR1L. АЦП у нас 10 битный, но регистры – 8 битные, поэтому часть (8  бит) хранится в одном регистре (ADCL), а остальное (2 бита) – в другом (ADCH). Смотрите, как это выглядит в виде таблицы:

Вопрос: как нам принять или изменить это самое 10 битное число, если оно разбито на два разных регистра? Очень просто: работа с такими сдвоенными регистрами встроена в компилятор avr-gcc и можно просто работать с ними напрямую как с обычной переменной, например:

int val = ADC; 	// читаем значение
ICR1 = 1234;	// записываем значение

Такую запись почему-то используют редко, возможно для совместимости с другими компиляторами. Чаще всего вы встретите вот такой вариант, в котором значения “склеиваются” через сдвиг:

int val = ADCL + (ADCH << 8);
// возможен вариант 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, и последующая запись старшего будет проигнорирована.

ВАЖНО: запись в 16 бит регистры производится начиная со СТАРШЕГО байта, чтение – с МЛАДШЕГО

Важные страницы