Архитектура и система команд RISC-V
Базовая статья RISC-V и RISC-V
- Фиксированная длина команды — 32 бита
- Адресуется побайтно
- Трёхадресная строго регистровая система команд
- операции над оперативной памятью — только обмен с регистрами
- нет общедоступного регистра флагов
условные переходы — атомарные операции
32 регистра, из которых r0 — специальный, он содержит нестираемый 0, что само по себе удобно + используется для аппаратного ускорения (например, r0 в качестве приёмника означает «ничего никуда не записывать»)
- Регистры 32 битные (64-битные для rv64)
Используется конвейер:
- выполнение одной инструкции разделается на 5 стадий, каждая из которых выполняется ровно за один такт работы процессора;
- несколько инструкция на разных стадиях выполняются параллельно, если между ними нет зависимостей.
Рассмотрим наиболее примитивную модель процессора из эмулятора Ripes
Цикл работы процессора:
(F, Istruction Fetch)
Выборка очередной инструкции из памяти: в регистр PC заносится либо результат прибавления 4 к предыдущему значению PC, либо адрес перехода из Branch, а затем содержимое по этому адресу считывается в Istruction Memory
(D, Instruction decode)
- Декодирование опкода / функций
Заполнение блока операндов Registers значениями из соответствующих регистров и непосредственного операнда в Immediate (если присутствует в инструкции)
(E, Execute operation)
Работа ALU: операции над содержимым блока операндов (регистрами и непосредственным значением) — сложение, сдвиг и прочие преобразования данных, а также вычисление адресов (перехода и адресации со смещением)
(M, Memory access)
- Работа с памятью (чтение / запись)
(W, Write back)
- Обновление блока операндов и самих регистров (самый последний элемент на схеме)
Система команд RISC-V
- 4 базовых типа команд
R — типа «регистр-регистр-регистр» (Register)
I — типа «непосредственное значение-регистр-регистр» (Immediate)
S — типа «регистр-регистр-непосредственное значение» (Store)
U — типа «непосредственное значение-регистр» (Upper)
- Пояснения к схеме:
opcode — код операции (6 битов)
rs1 — № регистра-источника (5 битов)
rs2 — № регистра-операнда (5 битов)
rd — № регистра-приёмника (5 битов)
imm[11:…] — непосредственный операнд размером в 12 битов
В случае, когда непосредственное значение определяет «приёмник» (смещение адреса для «близкого» перехода или записи результата в память), 12 битов целиком в поле rd не помещаются, и его приходится «распиливать» (инструкция типа S).
- Непосредственный операнд всегда знаковый, и его знак всегда приходится на 31-й бит. Это значит, что процессору легко отличить отрицательное число от положительного, даже если оно хранится в двух частях машинного слова: у отрицательного числа единичный 31-й бит
imm[31:…] — непосредственный операнд размером в 20 битов. Используется в инструкциях типа U для заполнения старших двадцати битов регистра (в операциях «далёкого» перехода и как дополнительная инструкция при записи в регистр полного 32-разрядного непосредственного операнда)
- 31-й бит снова знаковый!
funct — поле функции (6 битов), используется для разных инструкций, у которых код операции одинаковый. Например, все арифметические инструкции типа I имеют одинаковый opcode OP-IMM (чему он равен?), а различаются полем funct. По-видимому, для эффективной реализации R-команд в конвейере удобнее не декодировать опкод, а по-быстрому сравнить его с нулём, и получать значения регистров, параллельно декодируя функцию, чтобы потом её применить.
Интерпретация значения imm может отличаться (например, в командах перехода в imm хранится смещение без последнего бита, так как адрес инструкции всегда чётен)
Система расширяема
«M» — целочисленное умножение и деление
«F», «D» и «Q» — вещественная арифметика 932, 64 и 128-битная)
«C» — «упакованные» инструкции (переменный размер команды)
«V — векторные операции
- …
… и сужаема (соответствующие расширения можно не реализовывать, если они не нужны)
Стоит ещё раз заметить, что в ISA RISC-V невозможно хранить полный адрес непосредственно в инструкции (как это было в УМ). Полный адрес хранится в регистре (в зависимости от типа инструкции либо в pc, либо в x*), а в непосредственном операнде указывается смещение.
Язык ассемблера
Ассемблер: транслятор, преобразующий исходный текст некоторой программы из представления, удобного для человека, в машинные коды. Язык программирования, с которого происходит трансляция, называется языком ассемблера.
Задачи, решаемые ассемблером:
- Автоматическое вычисление адресов
- Метки и адресная арифметика
- Заполнение памяти и выравнивание
- Читаемость
- Ключевые слова и идентификаторы вместо чисел
- Умолчания и сокращения (например, опускается регистр по умолчанию или нулевое смещение в косвенной адресации)
Удобные формы команд (в т. ч. псевдоинструкции, в которых очевидный синтаксис транслируется в неочеваидную реализацию, например, копирование регистров — это сложение с нулём)
- Однозначность получаемого машинного кода
- Инструкция всегда транслируется в один и тот же машинный код
Частичная взаимная однозначность: машинный код можно дизассемблерировать, восстановится всё, кроме имён и (если постараться, псевдоинструкции тоже можно восстановить)
- Повторное использование
- Макроопределения и макроподстановка
- Поддержка нескольких исходных файлов
- Поддержка ОС (библиотеки, исполняемый формат и т. п.) — часто выносится в отдельную программу-компоновщик (linker)
Название «сборщик», вероятно, происходит от способности сначала собирать метки в программе, а затем преобразовывать их в адреса. Кроме того, исходный код большой программы обычно разбит на несколько файлов, возможно, имелдась в виду сборка единого машинного кода из них.
Цикл программирования на языке ассемблера — классический для компилируемого языка:
- Написание текста программы
- Трансляция в машинные коды (запускаемый файл)
- Загрузка запускаемого файла в память и запуск
Специфика RARS: программа транслируется сразу в соответствующие места машинной памяти, нет необходимости хранить оттранслированный запускаемый файл.
Обзор ISA
Шпаргалка (NB! в RARS есть встроенная подсказака)
Общий вид инструкции ассемблера RISC-V:
[метка:] операция [операнд1[, операнд2[, …]]]
- Квадратные скобки означают необязательность (необязательны метки и количество операндов в зависимости от типа инструкции варьируется от 0 до 3)
- В некоторых диалектах можно опускать запятые между операндами
- Инструкция транслируется в одну машинную команду
- Псевдоинструкция (см. ниже) может транслироваться в несколько команд
- В качестве операндов, в зависимости от типа инструкции, могут выступать регистры и константы (в специальных случаях может добавляться четвёртый операнд, например, тип округления вещественного числа в виде мнемонического сокращения)
В тексте программы на языке ассемблера могут встречаться т. н. директивы — команды самому ассемблеру.
- В языке ассемблера RISC-V директивы начинаются с точки
- Большая часть директив не транслируется в какое-либо содержимое машинной памяти.
Например, в программе наверняка встретятся директива .text, обозначающая область программного кода, и .data, обозначающая область данных. Встретив такие директивы, ассемблер RARS поменяет адрес, начиная с которого он заполняет оперативную память оттранслированным кодом. Сами по себе эти директивы ни во что не превращаются
Директивы размещения данных в памяти (например, .word или .asciz) интерпретируются как данные (числа или строки соответственно), и эти данные размещаются в очередные адреса памяти
TODO Примеры инструкций (базовый набор?)
Псевдоинструкции
Псевдоинструкции — это заложенные в спецификацию конструкции языка ассемблера, семантика которых не совпадает с машинными командами, в которые они раскрываются
- Синонимы для не вполне очевидных реализаций:
Assembler Code Disassembler not t3 t2 0xfff3ce13 x28 x7 0xffffffff
Непосредственная константа 0xfff занимает 12 битов
31-й бит единичный, так что это -1
При преобразовании в 32-разрядное число происходит распространение знака (поэтому дизассемблер показывает уже 0xffffffff, то есть 32-разрядное -1)
Побитовое исключающее или с ячейкой, все биты которой единичны, — это и есть искомый побитовый not
- Наиболее эффективные из нескольких вариантов:
Assembler Code Disassembler mv t1 t0 0x00500333 add x6 x0 x5
Процессор может оптимизировать это «сложение», вообще не выполняя его, т. к. первый операнд — всегда нулевой регистр zero (x0)
- В командах, относительно которых есть договорённость использовать некоторые конкретные регистры, эти регистры не указываются; можно не указывать и нулевые смещения
Assembler Code Disassembler ret 0x00008067 jalr x0 x1 0
Псевдоинструкция «возврат из подпрограммы» в действительности реализована как «текущее значение pc записать в регистр x0 (zero), перейти по адресу, хранящемуся в регистре x1 (ra) со смещением 0.
Тут тоже довольно много места для оптимизации со стороны процессора — например, ничего записывать в zero не надо
Могут раскладываться в несколько последовательных операций
Assembler Code Disassembler call subr 0x00000317 auipc x6 0 0x010300e7 jalr x1 x6 16
- Псевдоинструкция «вызов подпрограммы» реализована той же инструкцией!
Во вспомогательный регистр x6 (t1 — это значение взято по умолчанию) должен попасть точный адрес перехода. Но самая длинная константа занимает 20 битов в команде типа U. Поэтому в этом регистре с помощью инструкции auipc формируется приблизительный адрес перехода — текущее значение pc, к которому прибавляется смещение, округлённое до 32-20=12 битов (в нашем случае 0)
Далее выполняется команда «записать в x1 (ra) адрес возврата (текущий +4), и перейти по адресу, хранящемуся в x6 со смещением 16»
- В зависимости от конкретных операндов могут раскрываться в разные команды
Assembler Code Disassembler li t1 0x123 0x12300393 → addi x7 x0 0x00000123 li t0 0x1234567 0x01234337 → lui x6 0x00001234 0x56730313 addi x6 x6 0x00000567
Пример псевдоинстркуции li приёмник константа (загрузка в регистр-приёмник непосредственного значения-константы) в двух вариантах: с небольшим и с большим числами
Нет никакой инструкции «положить число в регистр», зато есть инструкция «сложить I-число с регистром zero (нестираемым нулём) и положить результат в регистр»
Если константа в li больше 12 битов (не подходит для инструкции типа I), псевдоинструкция раскладывается в две:
записать старшие 20 битов 32-разрядной константы в старшие 20 битов регистра (младшие 12 битов регистра при этом обнуляются) — инструкция типа U,
добавить оставшиеся 12 битов константы в регистр — инструкция типа I
Соответственно, программисту не надо в уме прикидывать, влезает ли константа в 12 битов, и самому распиливать/объединять части, если не влезает.
- Надо помнить, что 12 битов включает в себя знаковый, так что 11 битов
это — наиболее эффективные способы реализации команды li регистр число
Обратите также внимание на то, что к регистрам регистры можно обращаться как xРЕГИСТР, так и по их мнемонике (tРЕГИСТР, aРЕГИСТР, sРЕГИСТР, zero и т. п.), отражающей ковенции их использования.
Найдите все поля инструкций типа I и U в колонке Code из примера выше.
Полный список инструкций, поддерживаемых в RARS.
- В самом RARS есть интерактивная подсказка, её довольно удобно пользоваться, не зазубривая все инструкции заранее
Представление о «внешних вызовах» (environment call)
Низкоуровневое программирование (в машинных кодах или на языке ассемблера) требует некоторого количества высокоуровневых операций. Например, простейший ввод десятичных чисел с клавиатуры, если его реализовывать от начала до конца, становится крайне сложным мероприятием: надо управлять внешним устройством (клавиатурой), отслеживать появление на нём введённых символов, выявлять конец ввода этих символов, хранить их где-то, а после окончания ввода — преобразовывать из строкового представления (каждый символ — это один байт, обозначающий цифру) в десятичное число (одна ячейка памяти). И это мы ещё не договорились об обработке ошибок ввода!
Зачастую исполнитель низкоуровневых команд вообще не выполняет такие сложные операции, а передаёт их «на уровень выше»: кто-то или что-то, что запустило программу, пускай само позаботится о вводе и выводе. Например, в учебных машинах вводом и выводом занимается сама программа modelmachine по запросу программиста, а в системе команд вообще ничего про ввод и вывод нет.
В RISC-V это «обращение к запустившему нас окружению» включено в систему команд — это инструкция ecall. С её помощью образом программа может самостоятельно вводить и выводить данные (и выполнять множество других функций), если известно, что эти операции умеет выполнять окружение. Окружением (aka «то, что нас запустило») может при этом являться что угодно — ядро операционной системы, гипервизор, аппаратура, а в случае RARS — программный код на Java.
Список таких функций сильно зависит от природы окружения. Например, в RARS они определены так. От программиста требуется только положить в регистр a7 (x17) номер внешнего вызова согласно таблице, в регистры a0 - a6 (x10 - x16) — ожидаемые этим конкретным внешним вызовом параметры и выполнить инструкцию ecall.
По большей части в домашних заданиях мы будем пользоваться тремя внешними вызовами — ввод, вывод и останов.
1 li a7 5 # Внешний вызов №5 — ввести десятичное число
2 ecall # Результат — в регистре a0
3 mv t0 a0 # Сохраняем результат в t0
4 ecall # Регистр a7 не менялся, тот же внешний вызов
5 add a0 t0 a0 # Прибавляем ко второму число первое
6 li a7 1 # Внешний вызов №1 — вывести десятичное число
7 ecall
8 li a7 10 # Внешний вызов №10 — останов программы
9 ecall