Что нужно для начала программирования на ассемблере RISC-V

  • Готовая среда: Jupiter для практики ASM, riscv32-none-elf для компиляции и GHDL+GtkWave для симуляции.
  • Мастер-циклы, условные операторы и функции с RISC-V ABI и правильной обработкой стека.
  • ECALL в зависимости от среды: Jupiter (простые коды) против Linux (a0..a2 и a7 с системными вызовами).
  • Сделайте шаг вперед: скомпилируйте C/C++ в двоичный код, сгенерируйте ПЗУ и запустите на ЦП RV32I в ПЛИС.

Ассемблер RISC-V

Если вас интересует низкоуровневое программирование и вы хотите изучить программирование на ассемблере на современных архитектурах, то RISC-V — одна из лучших точек входа. Эта открытая ISA, имеющая большую популярность в промышленности и академических кругах, позволяет вам практиковаться от простых симуляторов до запуска на ПЛИС, через полные инструментальные цепочки для компиляции C/C++ и изучения сгенерированного ASM.

В этом практическом руководстве я расскажу вам, шаг за шагом и с очень земным подходом, Что нужно для начала программирования на ассемблере RISC-V: инструменты, рабочий процесс, основные примеры (условные операторы, циклы, функции, системные вызовы), типичные лабораторные упражнения и, если вы готовы, взгляд на реализацию ЦП RV32I и на то, как запустить собственный двоичный файл на синтезированном с помощью ПЛИС ядре.

Что такое ассемблер RISC-V и как он связан с машинным языком?

RISC-V определяет архитектуру открытого набора команд (ISA): Базовый репертуар RV32I включает 39 инструкций. Очень ортогонален и прост в реализации. Ассемблер (ASM) — это низкоуровневый язык программирования, использующий мнемонические обозначения, такие как add, sub, lw, sw, jal и т. д., согласованные с ISA. Базовый машинный код — это биты, понятные процессору; ассемблер — это его представление, понятное человеку. ближе к аппаратному обеспечению, чем любой язык высокого уровня.

Если вы работаете с C, то заметите, что ASM не работает «как есть»: его необходимо собрать и соединить для создания двоичного кода. В свою очередь, это позволяет управлять регистрами, режимами адресации и системными вызовами с хирургической точностью. А если вы работаете с обучающим симулятором, вы увидите «вызов» как механизм ввода/вывода и завершения, с определенными соглашениями в зависимости от среды (например, Jupiter или Linux).

Инструменты и среда: симуляторы, набор инструментов и ПЛИС

Для быстрого старта идеально подойдет графический симулятор Jupiter. Это ассемблер/симулятор, предназначенный для обучения, вдохновлённый SPIM/MARS/VENUS и используемый в университетских курсах. С его помощью вы можете писать, собирать и запускать программы RV32I, не настраивая весь набор инструментов с нуля.

Если вы хотите пойти дальше, вас может заинтересовать bare-metal toolchain: riscv32-none-elf (GCC/LLVM) для компиляции C/C++ в двоичные файлы RISC-V и утилиты, такие как objdump, для дизассемблирования. Для аппаратного моделирования GHDL позволяет компилировать VHDL, выполнять его и сохранять сигналы в файл .ghw для анализа с помощью GtkWave. А если вы готовы к реальному оборудованию, Вы можете синтезировать процессор RV32I в ПЛИС с использованием сред производителей (например, Intel Quartus) или бесплатных наборов инструментов.

Начало работы с Jupiter: основные правила потока и ассемблера

Юпитер упрощает процесс обучения. Вы создаете и редактируете файлы на вкладке «Редактор», и каждая программа начинается с глобального тега __start. Обязательно объявите его с помощью директивы .globl (да, именно .globl, а не .global). Теги заканчиваются двоеточием, а комментарии могут начинаться с символа # или ;.

Пара полезных правил экологии: одна инструкция на строку, а когда будете готовы, сохраните его и нажмите F3, чтобы скомпилировать и запустить. Программы должны завершаться вызовом выхода (ecall); в Jupiter установка 10 в a0 сигнализирует о завершении программы, аналогично «выходу».

Минимально ваш скелет ASM на Юпитере может выглядеть так, с чистой точкой входа и завершением по вызову: Это основа остальных упражнений..

.text
.globl __start
__start:
  li a0, 10     # código 10: terminar
  ecall         # finalizar programa

Соглашения о вызовах (ABI) и управление стеком

Программирование функций на ассемблере требует соблюдения следующих соглашений: Аргументы обычно поступают в a0..a7Результат обычно возвращается в a0, и вызовы должны сохранять адреса возврата (ra) и сохранённые регистры (s0..s11). Для этого вам поможет стек (sp): он резервирует место при входе и восстанавливает его при выходе.

Вот несколько инструкций, которые вы будете использовать постоянно: li и la для загрузки немедленных сообщений и адресов, add/addi для сложения, lw/sw для доступа к памяти, безусловные переходы j/jal и возвраты jr ra, а также условные операторы, такие как beq/bne/bge. Вот краткое напоминание с типичными примерами:

# cargar inmediato y una dirección
li t1, 5
la t1, foo

# aritmética y actualización de puntero de pila
add t3, t1, t2
addi sp, sp, -8   # reservar 8 bytes en stack
sw ra, 4(sp)      # salvar ra
sw s0, 0(sp)      # salvar s0

# acceso a memoria con base+desplazamiento
lw t1, 8(sp)
sw a0, 8(sp)

# saltos y comparaciones
beq t1, t2, etiqueta
j etiqueta
jal funcion
jr ra

Классический цикл в RISC-V можно структурировать наглядно, разделяющее условие, тело и шагВ Jupiter вы также можете вывести значения с помощью ecall на основе кода, загруженного в a0:

.text
.globl __start
__start:
  li t0, 0      # i
  li t1, 10     # max
cond:
  bge t0, t1, endLoop
body:
  mv a1, t0     # pasar i en a1
  li a0, 1      # código ecall para imprimir entero
  ecall
step:
  addi t0, t0, 1
  j cond
endLoop:
  li a0, 10     # código ecall para salir
  ecall

Для рекурсивных функций позаботьтесь о сохранении/восстановлении регистров и ra. Факториал — канонический пример что заставляет вас задуматься о стековом кадре и возврате управления по правильному адресу:

.text
.globl __start
__start:
  li a0, 5          # factorial(5)
  jal factorial
  # ... aquí podrías imprimir a0 ...
  li a0, 10
  ecall

factorial:
  # a0 trae n; ra tiene la dirección de retorno; sp apunta a tope de pila
  bne a0, x0, notZero
  li a0, 1          # factorial(0) = 1
  jr ra
notZero:
  addi sp, sp, -8
  sw s0, 0(sp)
  sw ra, 4(sp)
  mv s0, a0
  addi a0, a0, -1
  jal factorial
  mul a0, a0, s0
  lw s0, 0(sp)
  lw ra, 4(sp)
  addi sp, sp, 8
  jr ra

Ввод/вывод с помощью eCall: различия между Jupiter и Linux

Инструкция ecall используется для вызова служб из среды. В Jupiter простые коды в a0 (например, 1 вывести целое число, 4 вывести строку, 10 выйти) Управление доступными операциями. В Linux, однако, a0..a2 обычно содержат параметры, a7 — номер системного вызова, а семантика соответствует вызовам ядра (запись, выход и т. д.).

Этот «Hello World» для Linux иллюстрирует эту закономерность: вы подготавливаете регистры a0..a2 и a7 и запускаете eCall. Обратите внимание на директиву .global и точку входа _start:

# a0-a2: argumentos; a7: número de syscall
.global _start
_start:
  addi a0, x0, 1     # 1 = stdout
  la a1, holamundo   # puntero al mensaje
  addi a2, x0, 11    # longitud
  addi a7, x0, 64    # write
  ecall
  addi a0, x0, 0     # return code 0
  addi a7, x0, 93    # exit
  ecall
.data
holamundo: .ascii "Hola mundo\n"

Если ваша цель — практиковать логику управления, память и функции, Юпитер дает вам мгновенную обратную связь Многие лабораторные работы включают в себя автооценщик для проверки вашего решения. Если вы хотите попрактиковаться во взаимодействии с реальной системой, вам придётся скомпилировать для Linux и использовать системные вызовы ядра.

Начальные упражнения: условные операторы, циклы и функции

Классический набор упражнений для начала работы с RISC-V ASM охватывает три столпа: условные операторы, циклы и вызовы функций, с акцентом на правильное управление регистрами и стеком:

  • Отрицательно: функция, которая возвращает 0, если число положительное, и 1, если оно отрицательное. Получает аргумент в a0 и возвращает в a0, без уничтожения энергонезависимых записей.
  • Множитель: выполняет цикл по делителям числа, выводя их во время выполнения и возвращая общую сумму. Вы будете практиковать циклы, деление/модуль и вызовы ecall печатать.
  • Upper: имея указатель на строку, обойти ее и преобразовать строчные буквы в заглавные на месте. Верните тот же адрес; если вы переместите указатель во время цикла, сбросьте его перед возвратом.

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

Копаем глубже: от ISA RV32I к синтезируемому ЦП

RISC-V отличается своей открытостью: любой может реализовать ядро ​​RV32I. Существуют образовательные проекты, которые пошагово демонстрируют, как собрать базовый процессор, работающий с реальными программами. скомпилировано с помощью GCC/LLVM для riscv32-none-elfОпыт многому вас научит относительно того, что происходит «под капотом», когда вы запускаете ассемблер.

Типичная реализация включает контроллер памяти, который абстрагирует ПЗУ и ОЗУ, взаимосвязаны с ядромИнтерфейс этого контроллера обычно имеет:

  • AddressIn (32 бита): адрес для доступа. Определяет источник доступа инструкций или данных.
  • DataIn (32 бита): Данные для записи. Для полуслов используются только 16 младших битов; для байтов — 8 младших битов. Игнорируется при чтении.
  • WidthIn: 0=байт, 1=половина слова (16 бит), 2 или 3=слово (32 бита). Контроль размера.
  • ExtendSignIn: Расширять ли вход в DataOut при чтении 8/16 бит. Это игнорируется в писаниях.
  • WEIn: 0=чтение, 1=запись. Направление доступа.
  • StartIn: начальный фронт; установка его в 1 запускает транзакцию, синхронизированы с часами.

Когда ReadyOut=1, операция завершена: При чтении DataOut содержит данные (со знаковым расширением, если применимо); при записи данные уже находятся в памяти. Этот уровень позволяет заменять внутреннюю память FPGA, SDRAM или внешнюю PSRAM, не затрагивая ядро.

Простая организация обучения определяет три источника VHDL: ROM.vhd (4 КБ), RAM.vhd (4 КБ) и Memory.vhd (8 КБ) который интегрируется как с непрерывным пространством (ПЗУ по адресам 0x0000..0x0FFF, ОЗУ по адресам 0x1001..0x1FFF), так и с GPIO, сопоставленным с адресом 0x1000 (бит 0 соответствует контакту). Контроллер MemoryController.vhd создаёт экземпляр «Memory» и предоставляет интерфейс ядру.

О ядре: ЦП содержит 32 32-битных регистра (x0..x31), где x0 привязан к нулю и недоступен для записи. В VHDL их принято моделировать с помощью массивов и генерировать блоки. чтобы избежать копирования логики вручную, и декодер 5-в-32 для выбора регистра, который получает выходной сигнал от АЛУ.

АЛУ реализовано совместно с селектором (ALUSel) для таких операций, как сложение, вычитание, XOR, OR, AND, смещения (SLL, SRL, SRA) и сравнения (LT, LTU, EQ, GE, GEU, NE)Для экономии LUT в ПЛИС распространён метод реализации однобитных сдвигов и их повторения N циклов с использованием конечного автомата. Это увеличивает задержку, но потребление ресурсов снижается.

Управление осуществляется с помощью мультиплексоров для входов АЛУ (ALUIn1/2 и ALUSel), выбора регистра назначения (RegSelForALUOut), сигналы контроллеру памяти (MCWidthIn, MCAddressIn, MCStartIn, MCWEIn, MCExtendSignIn, MCDataIn), а также специальные регистры PC, IR и счётчик для подсчёта сдвигов. Всё это управляется конечным автоматом с ~23 состояниями.

Ключевой концепцией в этом FSM является «отложенная загрузка»: Эффект выбора входа MUX проявляется на следующем фронте тактового сигнала.Например, при загрузке IR инструкцией, поступающей из памяти, последовательность проходит через состояния выборки (запуск чтения по адресу PC), ожидания ReadyOut, перемещения DataOut в IR и, в следующем цикле, декодирования и выполнения.

Типичный путь выборки: при сбросе принудительно устанавливается значение PC=RESET_VECTOR (0x00000000), затем драйвер настраивается на чтение 4 байтов по адресу PC, Ожидается ReadyOut и загружается IRОттуда различные состояния управляют одноцикловыми АЛУ, многоцикловыми сдвигами, загрузками/сохранениями, ветвлениями, переходами и «специальными» операциями (реализация обучения может заставить ebreak намеренно остановить процессор).

Скомпилируйте настоящий код и запустите его на вашем RISC-V

Очень познавательный способ «доказательства концепции» — скомпилировать программу C/C++ с помощью кросс-компилятора riscv32-none-elf, сгенерировать двоичный файл и сохранить его в VHDL ROM. Затем вы проводите симуляцию в GHDL и анализируете сигналы в GtkWave; если все идет хорошо, вы синтезируете в ПЛИС и видите систему, работающую в кремнии.

Во-первых, скрипт линкера, адаптированный к вашей карте: ROM от 0x00000000 до 0x00000FFF, GPIO по адресу 0x00001000 и ОЗУ с адресами от 0x00001001 до 0x00001FFF. Для простоты можно поместить .text (включая раздел .startup) в ПЗУ, а .data — в ОЗУ, пропустив инициализацию данных, если хотите, чтобы первая версия была короче.

С помощью этой карты минималистская процедура загрузки помещает стек в конец SRAM и вызывает main; отмечен как «голый» и находится в разделе .startup поместить его в RESET_VECTOR. После компиляции objdump позволяет увидеть реальный ассемблерный код, который будет выполнять ваш процессор (lui/addi для сборки sp, jal для main и т. д.).

Классический пример мигания — переключение бита 0 сопоставленного GPIO: короткое ожидание для отладки в симуляторе (GHDL+GtkWave) и, на реальном оборудовании, увеличьте количество, чтобы мерцание было заметно. Makefile может создать .bin и скрипт, преобразующий этот двоичный файл в ROM initialization.vhd; после интеграции Вы компилируете весь VHDL, моделируете, а затем синтезируете..

Такой подход к обучению работает даже на старых ПЛИС (например, Intel Cyclone II), где внутренняя оперативная память определяется с использованием рекомендуемого шаблона, а конструкция может быть ресурсоэффективной примерно на 66%. Педагогическая польза огромна.: посмотрите, как продвигается работа ПК, как запускаются операции чтения (mcstartin), ReadyOut проверяет данные, IR захватывает инструкции и как каждый переход или скачок распространяется через FSM.

Чтения, практики и автогрейдер: дорожная карта

В академической среде принято иметь четкие цели: Практикуйте условные операторы и циклы, пишите функции, соблюдая соглашения и управлять памятью. Руководства обычно содержат шаблоны, симулятор (Jupiter), инструкции по установке и автогрейдер для исправления ошибок.

Чтобы подготовить свою среду, примите задание в Github Classroom, если будет предложено, клонируйте репозиторий и откройте Jupiter. Помните, что __start должен быть глобальным., что комментарии могут быть # или ;, что в каждой строке одна инструкция, и что в конце необходимо использовать ecall (код 10 в a0). Скомпилируйте с помощью F3 и запустите тесты. Если система не загружается, классический способ — перезагрузить компьютер.

Что касается ожидаемого формата каждого упражнения, многие руководства включают снимки экрана и указывают: Например, Factor печатает делители, разделенные пробелами. и возвращает количество; Upper должен пройти по строке и преобразовать только строчные буквы в прописные, не трогая пробелы, цифры и знаки препинания, и вернуть исходный указатель.

Оценка обычно распределяет баллы по сериям (10/40/50) и Вы можете запустить проверку, чтобы увидеть оценку автогрейдера.Когда всё будет удовлетворено, добавьте/коммитьте/отправьте и загрузите URL-адрес репозитория в указанное место. Такая дисциплина жизненного цикла поможет вам привыкнуть к строгой проверке и доставке.

Дополнительные упражнения для развития: Фибоначчи, Ханой и чтение с клавиатуры

Освоив основы, приступайте к изучению трех дополнительных классических произведений: fibonacci.s, hanoi.sy syscall.s (или другой вариант, который считывает с клавиатуры и повторяет строку).

  • Фибоначчи: Вы можете сделать его рекурсивным или итеративным; если вы сделаете его рекурсивным, Будьте осторожны с затратами и сохранением ra/s0; итеративные упражнения, циклы и сложения.
  • Ханой: Перевод рекурсивной функции в ASM. Сохраняет контекст и аргументы между вызовами: дисциплинированный стековый фрейм. Распечатывает перемещения «отправитель → пункт назначения» с функцией вызова.
  • Прочитать и повторить: прочитать целое число и строку и вывести строку N раз. На Юпитере используйте соответствующие коды вызова. доступны в вашей практике; в Linux подготовьте a7 и a0..a2 для чтения/записи.

Эти упражнения объединяют передачу параметров, циклы и ввод-вывод. Они заставляют вас думать о взаимодействии с окружающей средой. (Jupiter против Linux) и структурировать ASM так, чтобы он был читаемым и поддерживаемым.

Детали реализации: регистры, АЛУ и состояния

Возвращаясь к образовательному ядру RV32I, стоит рассмотреть несколько мелких деталей, которые сопоставляют то, что вы видите при программировании, с тем, как работает оборудование: операционный стол АЛУ выбранный ALUSel (ADD, SUB, XOR, OR, AND, SLL, SRL, SRA, знаковые и беззнаковые сравнения), «идентичность» как случай по умолчанию и «трюк» использования счетчика для накопления многоцикловых сдвигов.

Регистровая логика с генерацией создает декодер 5→32, и случай RegSelForALUOut=00000 ничего не делает (x0 недоступен для записи, он всегда равен нулю). PC, IR и Counter имеют собственные мультиплексоры, управляемые FSM: из сброса, выборки, декодирования/выполнения (однотактные АЛУ или циклы сдвига), загрузка/сохранение, условные переходы, jal/jalr и специальные функции, такие как ebreak.

При доступе к памяти данных важна координация MUX→Controller: MCWidthIn (8/16/32 бита), MCWEIn (чтение/запись), MCAddressIn (из регистров или ПК), MCExtendSignIn (для подписанных LB/LH) и MCStartIn. Только когда ReadyOut=1, следует захватывать DataOut. и расширенное состояние. Это согласует ваше мышление программиста ASM с текущей аппаратной реальностью.

Все это напрямую связано с тем, что вы наблюдаете в симуляции: каждый раз, когда ПК продвигается вперед, запускается инструкция чтенияMCReadyOut сообщает, что можно загрузить IR, и с этого момента инструкция вступает в силу (например, «lui x2,0x2», а затем «addi x2,x2,-4» для подготовки sp, «jal x1, …» для вызова main). Видеть это в GtkWave очень затягивает.

Ресурсы, зависимости и заключительные советы

Для воспроизведения этого опыта вам понадобится несколько зависимостей: GHDL для компиляции VHDL и GtkWave для анализа сигналовДля кросс-компилятора подойдёт любой GCC riscv32-none-elf (можно скомпилировать свой собственный или установить готовый). Для портирования ядра на ПЛИС используйте среду производителя (например, Quartus на Intel/Altera) или бесплатные наборы инструментов, совместимые с вашим устройством.

Кроме того, стоит прочитать руководства и заметки RISC-V (например, инструкции и зеленые карты), проконсультироваться книги по программированию, и практикуйтесь с Лаборатории, включая Jupiter и Autograder. Поддерживайте распорядок дня: планируйте, реализуйте, тестируйте с учетом пограничных случаев, а затем интегрируйте в более крупные проекты (например, Blinker на ПЛИС).

Благодаря всей этой информации у вас уже есть все необходимое для начала: почему используется ассемблер, а не машинный код, как настроить среду с Jupiter или Linux, шаблоны циклов, условные операторы и функции с правильной обработкой стека и окном в аппаратную реализацию, чтобы лучше понять, что происходит при выполнении каждой инструкции.

Если вам по душе обучение на практике, начните с «Отрицание», «Фактор» и «Верхний», затем переходите к Фибоначчи/Ханою и программе с клавиатурным вводом. Когда освоитесь, скомпилируйте простой код на C++, выгрузите ПЗУ в VHDL, выполните симуляцию в GHDL, а затем переходите к ПЛИС. Это путь от меньшего к большему, в котором каждая часть соответствует предыдущей., и удовлетворение от того, что вы видите, как ваш собственный код перемещает GPIO или мигает светодиодом, бесценно.

лучшие книги по программированию
Теме статьи:
Лучшие книги по программированию для каждого языка программирования