"Вы читаете о роботах и программировании и думаете: «Было бы здорово сделать что-то подобное самому!» Теми, кем эта идея овладевает чуть больше просто мыслей смотрят кто и как делал своего робота. Читают статьи, смотрят видео. На картинках все понятно. В видеороликах тоже обычно показываются уже готовые продукты, а также сжато показываются технологии их изготовления. И вроде бы то же всё понятно: отпилил, прикрутил, припаял, соединил, запрограммировал вон на той программе вот этим кодом."

четверг, 23 января 2020 г.

Регистры, байты, биты (материал для кружка1)


В этом уроке мы научимся работать напрямую с регистрами микроконтроллера. Зачем? Я думаю в первую очередь это нужно для того, чтобы понимать чужой код и переделывать его под себя, потому что прямая работа с регистрами в скетчах из Интернета встречается довольно часто.
Начнём с того, что вспомним, где мы пишем код: 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, и последующая запись старшего будет проигнорирована.