Изучение кода на ассемблере 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: Как работает современный микропроцессор?