Изучение кода на ассемблере RISC-V с помощью простых примеров и упражнений
Многие примеры кода RISC-V сразу переходят к довольно сложным примерам кода. Я собираюсь начать медленно, приведя несколько очень простых примеров.
Для правильного ознакомления с такими вещами, как регистры, условное ветвление и формат кода ассемблера, я советую прочитать: Сборка RISC-V для начинающих.
Это обзорная история, но она не содержит так много примеров. Как и в первой статье, мы будем использовать онлайн интерпретатор RISC-V Корнельского университета.
При работе с примерами кода вы можете распечатать этот справочный лист RISC-V, который поможет вам: Справочник Джеймса Чжу по RISC-V.
Вероятно, самые важные инструкции:
ADDI rd, zero, immediate
, чтобы загрузить значение (immediate
) в регистрrd
.LW rd, offset(rs1)
для загрузки содержимого в ячейку памятиoffset + rs1
в регистрrd
.SW rd, offset(rs1)
хранить содержимое регистраrd
в ячейке памятиoffset + rs1
.ADD rd, rs1, rs2
в содержимое двух регистров и сохранить значение в регистре назначенияrd
.- Инструкции ветки
BEQ
,BNE
,BLT
. Прочтите предыдущий рассказ, чтобы узнать больше.
Удвоитель входа
Начнем с действительно простого примера. Вы пишете функцию, которая удваивает свой ввод. Если вы читали мое предыдущее вступление: Сборка RISC-V для начинающих.
Вы должны знать, что функции RISC-V получают свои входные данные в регистрах a0
, a1
, вплоть до a7
. Это просто псевдонимы для регистров, начинающихся с x10
.
Запишите решение, прежде чем смотреть на мое решение ниже:
doubler:
ADD a0, a0, a0
Чтобы проверить это, поместите начальное значение в регистр a0
перед запуском кода в симуляторе. Если получится, то a0
надо удвоить. Функции должны возвращать свой результат в a0
, так что это правильный способ сделать это.
Если бы это была настоящая функция, которая вызывалась откуда-то еще, нам понадобилась бы эта строка в конце для возврата из функции:
JALR zero, ra, 0
Обычно ассемблер RISC-V имеет для этого псевдоинструкцию RET
.
Как работает эта инструкция? В реальной программе наша doubler
функция, вызываемая с 42
в качестве аргумента, должна быть написана следующим образом:
ADDI a0, zero, 42
JAL ra, doubler
SUB t3, t4, t2
Это означает, что адрес возврата сохраняется в регистре ra
(x1
), поэтому, когда doubler
возвращает, он начинает выполнение инструкции SUB t3, t4, t2
. Что это обозначает? Просто произвольную инструкцию я туда поставил.
Раз восемь
Хорошо, вот простое продолжение на случай, если предыдущее не было очевидным. На этот раз я хочу, чтобы вы умножили ввод на 8. Вот проблема. В базовом RISC-V ISA нет инструкции умножения, а в Interpreter, который мы используем, нет какого-либо расширения RISC-V M, которое поддерживает деление и умножение.
Посмотрите, сможете ли вы понять, как это сделать, прежде чем рассматривать два моих решения.
Имейте в виду, что если вы хотите изменить ввод на a0
, вам нужно остановить программу. После изменения начального значения перед запуском кода необходимо нажать синюю кнопку Сброс.
Использование сложения
Концептуально умножение - это просто повторяющееся сложение, поэтому это может быть наиболее очевидным решением:
eight_times:
ADD a0, a0, a0
ADD a0, a0, a0
ADD a0, a0, a0
HLT # Stop execution
Использование логических сдвигов
В двоичной системе счисления сдвиг всех цифр на одну позицию влево аналогичен умножению на два. Сдвиг на две позиции похож на умножение на четыре.
010b = 2
010b << 1 = 100b = 4
001b << 2 = 100b
В сборке RISC-V мы выполняем сдвиги влево с помощью SLLI
и SLL
. Суффикс I
указывает, что мы используем непосредственное значение вместо регистра, чтобы указать, на сколько позиций сдвинуть.
eight_times:
SLLI a0, a0, 3
JALR zero, ra, 0
HTL # Stop excution. Normally you put RET
Найти максимальное значение
Это реализация общей функции c = max(a, b)
, которая присваивает c
значение a
или b
, которое больше.
Чтобы решить эту проблему, вам нужно использовать инструкции перехода и ветвления. Подсказка о том, как перемещать значения из одного регистра в другой. Для этого можно использовать инструкции ADD
или ADDI
.
Нормальный ассемблерный код RISC-V имеет псевдоинструкцию MV
, к которой у нас нет доступа. Эти две строки будут эквивалентны:
MV a4, a3
ADDI a4, a3, 0
Хорошо, попробуйте решить эту проблему, прежде чем искать решение ниже:
max:
BLT a0, a1, second # if a0 < a1 then a1 is larger
JAL zero, done
second:
ADD a0, zero, a1 # make a1 the return value
done:
HLT # normally a RET would be here
Если вы не поняли это правильно. Вы можете попробовать реализовать функцию min
.
Простой множитель
Вместо умножения на фиксированное число давайте умножим два произвольных числа. В этом случае вы можете использовать только дополнения и ветки. Это не обязательно должно быть эффективным. Функция принимает два аргумента в a0
и a1
для умножения и, как обычно, возвращает результат в a0
.
multiply:
ADD t0, zero, zero
ADDI a1, a1, -1
accumulate:
ADD t0, t0, a0
ADDI a1, a1, -1
BGE a1, zero, accumulate
ADD a0, zero, t0
HLT
Это немного длиннее и менее читабельно, чем должно быть, потому что нашему интерпретатору не хватает многих инструкций RISC-V. Если бы я не запускал это в симуляторе и мог бы использовать все инструкции, включая псевдо-инструкции, я бы написал это как:
multiply:
LI t0, 0 # set t0 to 0
accumulate:
ADD t0, t0, a0
ADDI a1, a1, -1 # decrement a1
BGT a1, zero, accumulate
MV a0, t0 # copy t0 value to a0
RET # return to calling function
Быстрое умножение
Когда умножение было реализовано программно, метод, который мы использовали выше, не использовался. Это было бы слишком медленно. Вместо этого использовалась комбинация дополнений и сдвигов.
Это ваша следующая сложная попытка создать более эффективный алгоритм умножения. Должно быть возможно выполнить 42 × 20 только с двумя сложениями, а не с двадцати. Вы можете использовать сдвиг влево SLL
или SLLI
, а также сдвиг вправо SRA
и SRAI
.
fast_multiply:
ADD t0, zero, zero # to keep track of result
next_digit:
ANDI t1, a1, 1 # is rightmost bit 1?
SRAI a1, a1, 1
BEQ t1, zero, skip # if right most bit 0, don't add
ADD t0, t0, a0
skip:
SLLI a0, a0, 1 # double first argument
BNE a1, zero, next_digit
ADD a0, zero, t0 # move accum result to a0
HLT
Это решение, хотя оно работает, может быть трудным для выполнения, поэтому давайте сначала напишем версию, если нам доступны все инструкции:
fast_multiply:
LI t0, 0
next_digit:
ANDI t1, a1, 1 # is rightmost bit 1?
SRAI a1, a1, 1
BEQ t1, zero, skip # if right most bit 0, don't add
ADD t0, t0, a0
skip:
SLLI a0, a0, 1 # double first argument
BNE a1, zero, next_digit
MV a0, t0
RET
Хотя этот код Julia, который почти читается как псевдокод, может до некоторой степени прояснить, что происходит.
function multiply(input, counter)
accumulator = 0
while counter > 0
if counter & 1 == 1
accumulator += input
end
counter >>= 1
input <<= 1
end
return accumulator
end
Может быть трудно сразу понять, потому что двоичное мышление нам чуждо. Но если вы подумаете об этом в десятичном выражении, это может быть проще. Допустим, вы умножаете 32 на 432. Вы имеете дело с каждой цифрой в 432 отдельно. Сначала вы умножаете 32 на 2. Затем вы умножаете 32 на 30. Наконец, вы умножаете 32 на 400.
Но мы можем немного изменить логику этого. Строка counter >>= 1
отрезает по одной цифре на самом правом конце. Это означает получение сначала 2, затем 3 и, наконец, 4. Поскольку мы получаем однозначные числа, мы исправляем умножение, всегда увеличивая входное значение 32 в десять раз. Итак, сначала мы умножаем 32 на 2. Затем мы умножаем 320 на 3 и, наконец, умножаем 3200 на 4.
input <<= 1
делает ввод больше на каждой итерации. За исключением того, что мы делаем его не в 10 раз больше, а в 2 раза больше, потому что мы имеем дело с двоичными числами.
Создать список квадратных чисел
Пришло время использовать наши функции как настоящие. Мы собираемся использовать то, что мы узнали ранее, чтобы многократно вызывать умножение, чтобы создать в памяти список квадратных чисел, таких как 4, 9, 16, 25. Нам понадобится цикл, чтобы повторить это.
Пока у меня не будет более полного описания, вы можете попытаться исправить приведенную ниже программу, чтобы она работала должным образом и записывала квадратные числа в последовательные адреса памяти.
ADD s1, zero, a0 ADDI s0, zero, 1 loop: ADDI s0, zero, 1 ADD a0, zero, s0 ADD a1, zero, a0 JAL ra, fast_multiply SLLI t0, s0, 1 SW a0, 0(t0) BLT s0, s1, loop HLT fast_multiply: ADD t0, zero, zero next_digit: ANDI t1, a1, 1 SRAI a1, a1, 1 BEQ t1, zero, skip ADD t0, t0, a0 skip: SLLI a0, a0, 1 BNE a1, zero, next_digit ADD a0, zero, t0 JALR zero, ra, 0
Заключительные замечания
Это будет незавершенная работа. Я буду добавлять сюда небольшие программы и задачи по мере того, как придумываю новые идеи. Многие из этих задач программирования адаптированы из моих примеров Calcutron-33: Как работает современный микропроцессор?