В мире системного программирования, где приходится писать драйверы оборудования или напрямую взаимодействовать с отображаемыми в память устройствами, это взаимодействие почти всегда осуществляется через отображаемые в память регистры, предоставляемые оборудованием. Обычно мы взаимодействуем с этими вещами через побитовые операции над некоторым числовым типом фиксированной ширины. Например, представьте себе 8-битный регистр с тремя полями:

+----------+------+-----------+---------+
| (unused) | Kind | Interrupt | Enabled |
+----------+------+-----------+---------+
   5-8       2-4        1          0

Число под именем поля определяет биты, используемые этим полем в регистре. Чтобы включить этот регистр, нужно записать значение 1, представленное в двоичном виде как 0000_0001, чтобы установить бит разрешения поля. Однако часто у нас также есть существующая конфигурация в реестре, которую мы не хотим нарушать. Скажем, мы хотим включить прерывания на нашем устройстве выше, но также хотим быть уверены, что само устройство остается включенным. Для этого мы должны объединить значение поля Interrupt со значением поля Enabled. Мы бы сделали это с помощью побитовых операций:

1 | (1 << 1)

Это дает нам двоичное значение 0000_0011 путем or объединения 1 с 2, которое мы получаем, сдвигая 1 влево на 1. Мы можем записать это в наш регистр, оставив его включенным, но также разрешая прерывания.

Это много, что нужно держать в голове, особенно при работе с потенциально сотнями регистров для полной системы. На практике мы делаем это с помощью мнемоники, которая отслеживает позицию поля в регистре и ширину этого поля, т. е. какова его верхняя граница?

Вот пример одной из этих мнемоник, это макросы C, которые заменяют свои вхождения кодом с правой стороны. Это сокращение для регистра, который мы изложили выше. Левая часть & ставит нас в положение для этого поля, а правая сторона ограничивает нас только битами этого поля.

Затем мы использовали бы их, чтобы абстрагироваться от вывода значения регистра с чем-то вроде:

И это состояние искусства, на самом деле. Фактически, именно так в ядре Linux появляется основная масса драйверов.

Есть ли лучший способ? Подумайте о преимуществах безопасности и выразительности, если бы наша система типов была основана на исследованиях современных языков программирования. То есть, что мы могли бы сделать с более богатой и выразительной системой типов, чтобы сделать этот процесс более безопасным и надежным?

Уровень I: использование Rust для взаимодействия с оборудованием

В Rust мы можем использовать структуры данных для представления полей, мы можем прикреплять их к определенным регистрам, и мы можем обеспечить краткую и разумную эргономику при взаимодействии с оборудованием. В этом первом примере используются самые основные средства, предоставляемые Rust, но, несмотря на это, просто добавленная структура несколько снижает плотность по сравнению с примером C выше. Теперь поле — это именованная вещь, а не число, полученное из теневых побитовых операторов, а регистры — это типы со состоянием — еще один дополнительный уровень абстракции по сравнению с оборудованием.

Продолжая наш регистр выше в качестве примера:

+----------+------+-----------+---------+
| (unused) | Kind | Interrupt | Enabled |
+----------+------+-----------+---------+
   5-8       2-4        1          0

Как мы можем захотеть выразить это в типах Rust?

Мы начнем аналогичным образом, определив константы для смещения каждого поля, то есть, насколько оно далеко от младшего значащего бита, и его маску. маска – это значение, двоичное представление которого можно использовать для обновления или чтения поля из регистра.

Далее мы объявим тип поля и выполним наши операции, чтобы преобразовать заданное значение в его релевантное для позиции значение для использования внутри регистра.

Наконец, тип Register, который охватывает числовой тип, соответствующий ширине нашего регистра. Register имеет функцию update, которая обновляет регистр с заданным полем.

Это хорошо, но не идеально. Мы должны не забыть взять с собой маску и смещение, и мы вычисляем их специально, вручную, что может быть проблематично. Мы, люди, не сильны в точных и повторяющихся задачах — мы склонны уставать или терять концентрацию, и это приводит к ошибкам. Переписывание вручную масок и смещений по одному регистру за раз почти наверняка закончится плохо. Вот такую ​​задачу мы должны оставить машине.

Во-вторых, что, если бы существовал способ, чтобы сам тип поля содержал информацию о маске и смещении? Что, если бы мы могли выявлять ошибки в нашей реализации в том, как мы обращаемся к аппаратным регистрам и взаимодействуем с ними во время компиляции, а не обнаруживаем их во время выполнения? Возможно, мы можем положиться на одну из стратегий, обычно используемых для выявления проблем во время компиляции, таких как типы.

Повышение уровня

Давайте изменим наш предыдущий пример, используя typenum, библиотеку, которая предоставляет числа и арифметику на уровне типа. Здесь мы параметрируем наш тип Field с его маской и смещением, делая его доступным для любого экземпляра Field без необходимости включать его на сайте вызова.

И теперь, возвращаясь к конструктору Field, мы можем опустить параметры маски и смещения, поскольку сам тип содержит эту информацию:

Выглядит неплохо, но… что происходит, когда мы допускаем ошибку относительно того, подходит ли заданное значение в поле? Рассмотрим простую опечатку, где мы поставили 10 вместо 1:

В нашем коде выше? Каков ожидаемый результат? Что ж, код, который у нас есть сейчас, установит этот разрешенный бит в 0, потому что 10 & 1 = 0. Это прискорбно; было бы неплохо знать, что значение, которое я пытаюсь записать в поле, действительно помещается в это поле до фактической попытки записи. На самом деле, я бы обрезал старшие биты ошибочного значения поля неопределенное поведение (вздохи).

Безопасность прежде всего

Как мы можем проверить, что значение поля соответствует предписанной позиции в общем случае? Больше номеров типового уровня!

Мы можем добавить параметр Width к Field и использовать его, чтобы убедиться, что мы можем поместить данное значение в поле.

Теперь мы можем построить Field только в том случае, если заданное значение подходит! В противном случае у нас есть None, который сигнализировал бы нам о том, что произошла ошибка, а не обрезал старшие биты значения и молча записал бы неожиданное значение.

Однако обратите внимание, что это вызовет ошибку во время выполнения. Однако мы заранее знали значение, которое хотели записать, помните? Давайте воспользуемся этим фактом, чтобы поднять планку уверенности. Мы можем научить компилятор полностью отклонять программу с недопустимым значением поля — нам не нужно ждать, пока мы ее запустим!

На этот раз мы добавим привязку признака (предложение where) к новой реализации new, называемой new_checked, которая запрашивает, чтобы входящее значение было меньше или равно максимально возможному значению, которое поле с заданным Width может держать.

Только числа, для которых выполняется это свойство, имеют реализацию этого признака, поэтому, если мы дадим число, которое не подходит, оно не скомпилируется. Посмотри!

12 |     reg.update(RegEnabled::new_checked::<U10>());
   |                           ^^^^^^^^^^^^^^^^ expected struct `typenum::B0`, found struct `typenum::B1`
   |
   = note: expected type `typenum::B0`
           found type `typenum::B1`

Теперь мы действительно сделали это. new_checked приведет к невозможности создания программы с ошибочно завышенным значением поля. Таким образом, наша предыдущая опечатка не сработает во время выполнения, поскольку мы бы никогда не получили артефакт для запуска. Теперь мы приближаемся к пику ржавчины в отношении того, насколько безопасными мы можем сделать аппаратные взаимодействия с отображением памяти.

Однако то, что мы записали еще в первом примере на C, было гораздо более кратким, чем набор параметров типа, к которому мы пришли. Можно ли сделать такую ​​вещь, когда вы говорите о потенциальных сотнях или даже тысячах регистров?

Безопасно и доступно

Раньше я называл вычисление наших масок вручную проблематичным, но тогда все, что я действительно делал, было тем же самым проблематичным — хотя и на уровне типов. Хотя использование такого подхода приятно, для того, чтобы мы действительно могли написать любой код, требуется довольно много шаблонов и ручная транскрипция (здесь я говорю о синонимах типов).

Мы хотели что-то вроде макросов регистров mmio для TockOS, но такое, которое генерировало бы наши реализации с типобезопасностью с наименьшим возможным количеством ручной транскрипции. В результате получился макрос, который генерирует необходимый шаблон для получения Tock-подобного API, плюс нашу проверку границ на основе типов. Чтобы использовать его, вы записываете некоторую информацию о регистре, его полях, их ширине и смещениях, а также дополнительные значения, подобные перечислению, если вы хотите придать значение возможным значениям, которые может иметь поле.

Исходя из этого, мы генерируем типы регистров и полей, как в последнем примере выше, где индексы — Width, Mask и Offset — получаются из значений, введенных в разделы WIDTH и OFFSET определений поля. Также обратите внимание, что все числа здесь typenums; они войдут прямо в наши определения Поля!

Сгенерированный код пространств имен регистров и их полей через имя, заданное для регистра и полей, примерно так:

Сгенерированный API содержит номинально ожидаемые примитивы чтения и записи для получения необработанного значения регистра, но он также имеет способы получить значение отдельного поля, выполнить коллективные действия и выяснить, установлены ли какие-либо или все наборы битов. Вы можете прочитать документацию по полному сгенерированному API здесь.

Пинать шины

Так как же на самом деле выглядит использование этих определений для реального устройства? Будет ли код замусорен параметрами типов, скрывающими от глаз реальную логику?

Нет! Используя синонимы типов и вывод типов, нам фактически вообще не приходится думать о той части программы, которая относится к уровню типов. Мы можем напрямую взаимодействовать с аппаратным обеспечением и автоматически получать эти гарантии, связанные с ограничениями.

Вот пример блока регистров UART. Мы пропустим объявление самих регистров, так как их здесь слишком много. Вместо этого мы начнем с «блока» регистров, а затем поможем компилятору узнать, как искать эти регистры от указателя до заголовка блока. Мы делаем это, реализуя Deref и DerefMut.

Как только это будет сделано, вы обнаружите, что использовать эти регистры так же просто, как read() и modify().

Для полей, значения которых мы не знаем заранее, мы можем создать их. Я использую unwrap здесь, но в реальной программе с неизвестными входными данными вы, вероятно, захотите проверить, что вы получили Some обратно от этого нового вызова¹⋅².

Ошибка декодирования

В зависимости от вашего личного болевого порога вы могли заметить, что ошибки почти неразборчивы. Давайте посмотрим на не очень тонкое напоминание о том, о чем я говорю:

error[E0271]: type mismatch resolving `<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UTerm, typenum::B1>, typenum::B0>, typenum::B1>, typenum::B0>, typenum::B0> as typenum::IsLessOrEqual<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UTerm, typenum::B1>, typenum::B0>, typenum::B1>, typenum::B0>>>::Output == typenum::B1`
  --> src/main.rs:12:5
   |
12 |     less_than_ten::<U20>();
   |     ^^^^^^^^^^^^^^^^^^^^ expected struct `typenum::B0`, found struct `typenum::B1`
   |
   = note: expected type `typenum::B0`
       found type `typenum::B1`

`expected typenum::B0` найденная часть `typenum::B1` имеет смысл, но что это за typenum::UInt<typenum::UInt, typenum::UInt… чепуха? Ну, typenum представляет числа как двоичные cons-ячейки! Ошибки, подобные этой, затрудняют, особенно когда у вас есть несколько таких номеров уровня типа, ограниченных узкими кварталами, узнать, о каком числе идет речь. Если, конечно, вам не свойственно переводить барочные двоичные представления в десятичные.

После U100-го раза, пытаясь расшифровать какой-либо смысл этого беспорядка, мой товарищ по команде разозлился, как ад, и больше не собирался это принимать, и сделал небольшую утилиту, tnfilt, для анализа смысла из страданий, которые представляют собой двоичные файлы с пространством имен. минус ячейки. Tnfilt использует нотацию в стиле ячеек cons и заменяет ее разумными десятичными числами. Итак, вместе с bounded-registers мы также хотели бы поделиться tnfilt. Вы используете это так:

$ cargo build 2>&1 | tnfilt

Он преобразует вывод выше во что-то вроде этого:

error[E0271]: type mismatch resolving `<U20 as typenum::IsLessOrEqual<U10>>::Output == typenum::B1`

Теперь это имеет смысл!

В заключении

Отображенные в память регистры повсеместно используются при взаимодействии с оборудованием из нашего программного обеспечения, и существует множество способов отображения этих взаимодействий, каждый из которых занимает свое место в спектрах простоты использования и безопасности. С bounded-registers мы начали с того, что заперлись на месте прямо на краю более безопасной стороны этого спектра безопасности, а затем попытались выяснить, как переместить ползунок простоты использования ближе к легкому концу. Из этих амбиций родился bounded-registers, и это вещь, которую мы используем каждый раз, когда сталкиваемся с устройствами с отображением памяти в наших приключениях в Аксоне.

¹ Технически чтение из поля регистра по определению даст вам значение только в установленных пределах, но мы живем не в чистом мире, и вы никогда не знаете, что произойдет, когда в дело вступают внешние системы. Мы действуем по велению аппаратных богов, поэтому вместо того, чтобы загонять вас в ситуацию «возможна паника», мы возвращаем вам Option для обработки случая, когда этого не должно произойти.

² get_field выглядит немного странно. Я смотрю на часть Field::Read, в частности. Field — это тип, и нам нужен экземпляр этого типа для передачи в get_field. Более чистый API может быть чем-то вроде:

regs.rx.get_field::<UartRx::Data::Field>();

Но Field на самом деле является синонимом типа, который имеет фиксированные индексы для ширины, смещения и т. д. Чтобы иметь возможность параметризовать get_field таким образом, нам потребуются типы более высокого порядка!