Хабрахабр

[Перевод] RISC-V с нуля

В этой статье мы исследуем различные низкоуровневые концепции (компиляция и компоновка, примитивные среды выполнения, ассемблер и многое другое) через призму архитектуры RISC-V и её экосистемы. Я сам веб-разработчик, на работе ничем таким не занимаюсь, но мне это очень интересно, отсюда и родилась статья! Присоединяйтесь ко мне в этом беспорядочном путешествии в глубины низкоуровневого хаоса.

Сначала немного обсудим RISC-V и важность этой архитектуры, настроим цепочку инструментов RISC-V и запустим простую программу C на эмулированном оборудовании RISC-V.

  1. Что такое RISC-V?
  2. Настройка инструментов QEMU и RISC-V
  3. Привет, RISC-V!
  4. Наивный подход
  5. Приподнимая завесу -v
  6. Поиск нашего стека
  7. Компоновка
  8. Стоп! Hammertime! Runtime!
  9. Отладка, но теперь по-настоящему
  10. Что дальше?
  11. Дополнительно

RISC-V — это свободная архитектура набора команд. Проект зародился в Калифорнийском университете в Беркли в 2010 году. Важную роль в его успехе сыграла открытость кода и свобода использования, что резко отличалось от многих других архитектур. Возьмите ARM: чтобы создать совместимый процессор, вы должны заплатить авансовый сбор от $1 млн до $10 млн, а также выплачивать роялти 0,5−2% с продаж. Свободная и открытая модель делает RISC-V привлекательным вариантом для многих, в том числе для стартапов, которые не могут оплатить лицензию на ARM или другой процессор, для академических исследователей и (очевидно) для сообщества open source.

ARM запустила сайт, который пытался (довольно безуспешно) подчеркнуть предполагаемые преимущества ARM над RISC-V (сайт уже закрыт). Стремительный рост популярности RISC-V не остался незамеченным. Проект RISC-V поддерживают многие крупные компании, включая Google, Nvidia и Western Digital.

Мы не сможем запустить код на процессоре RISC-V, пока не настроим окружение. К счастью, для этого не нужен физический процессор RISC-V, вместо него возьмём qemu. Для установки следуйте инструкциям для вашей операционной системы. У меня MacOS, поэтому достаточно ввести одну команду:

# also available via MacPorts - `sudo port install qemu`
brew install qemu

Удобно, что qemu поставляется с несколькими готовыми к работе машинами (см. опцию qemu-system-riscv32 -machine).

Далее установим OpenOCD для RISC-V и инструменты RISC-V.

Запомните его для будущего использования. Загружаем готовые сборки RISC-V OpenOCD и инструментов RISC-V здесь.
Извлекаем файлы в любой каталог, у меня это ~/usys/riscv.

mkdir -p ~/usys/riscv
cd ~/Downloads
cp openocd-<date>-<platform>.tar.gz ~/usys/riscv
cp riscv64-unknown-elf-gcc-<date>-<platform>.tar.gz ~/usys/riscv
cd ~/usys/riscv
tar -xvf openocd-<date>-<platform>.tar.gz
tar -xvf riscv64-unknown-elf-gcc-<date>-<platform>.tar.gz

Задайте переменные среды RISCV_OPENOCD_PATH и RISCV_PATH, чтобы другие программы могли найти нашу цепочку инструментов. Это может выглядеть по-разному в зависимости от ОС и оболочки: я добавил пути в файл ~/.zshenv.

# I put these two exports directly in my ~/.zshenv file - you may have to do something else.
export RISCV_OPENOCD_PATH="$HOME/usys/riscv/openocd-<date>-<version>"
export RISCV_PATH="$HOME/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>"
# Reload .zshenv with our new environment variables. Restarting your shell will have a similar effect.
source ~/.zshenv

Создадим в /usr/local/bin символическую ссылку для этого исполняемого файла, чтобы в любой момент запускать его без указания полного пути на ~/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>/bin/riscv64-unknown-elf-gcc.

# Symbolically link our gcc executable into /usr/local/bin. Repeat this process for any other executables you want to quickly access.
ln -s ~/usys/riscv/riscv64-unknown-elf-gcc-8.2.0-<date>-<version>/bin/riscv64-unknown-elf-gcc /usr/local/bin

И вуаля, у нас рабочий набор инструментов RISC-V! Все наши исполняемые файлы, такие как riscv64-unknown-elf-gcc, riscv64-unknown-elf-gdb, riscv64-unknown-elf-ld и другие, лежат в ~/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>/bin/.
Обновление 26 мая 2019 года:

Для решения этой проблемы выпущен патч, но пока можете пропустить этот раздел. К сожалению, из-за бага в RISC-V QEMU, программа freedom-e-sdk 'hello world' в QEMU больше не работает. Я отслеживаю ситуацию и обновлю статью после исправления бага. Эта программа не понадобится в дальнейших разделах статьи.

этот комментарий. Для дополнительной информации см.

Начнём с клонирования репозитория freedom-e-sdk от SiFive: Настроив инструменты, давайте запустим простую программу RISC-V.

cd ~/wherever/you/want/to/clone/this
git clone --recursive https://github.com/sifive/freedom-e-sdk.git
cd freedom-e-sdk

По традиции, начнём с программы 'Hello, world' из репозитория freedom-e-sdk. Используем готовый Makefile, который они предоставляют для компиляции этой программы в режиме отладки:

make PROGRAM=hello TARGET=sifive-hifive1 CONFIGURATION=debug software

И запускаем в QEMU:

qemu-system-riscv32 -nographic -machine sifive_e -kernel software/hello/debug/hello.elf
Hello, World!

Это отличное начало. Можно запустить и другие примеры из freedom-e-sdk. После этого напишем и попробуем отладить собственную программу на C.
Начнём с простой программы, которая бесконечно складывает два числа.

cat add.c
int main() return 0;
}

Мы хотим запустить эту программу, и первым делом нужно скомпилировать её для процессора RISC-V.

# -O0 to disable all optimizations. Without this, GCC might optimize # away our infinite addition since the result 'c' is never used.
# -g to tell GCC to preserve debug info in our executable.
riscv64-unknown-elf-gcc add.c -O0 -g

Здесь создаётся файл a.out, такое имя gcc по умолчанию даёт исполняемым файлам. Теперь запускаем этот файл в qemu:

# -machine tells QEMU which among our list of available machines we want to
# run our executable against. Run qemu-system-riscv64 -machine help to list
# all available machines.
# -m is the amount of memory to allocate to our virtual machine.
# -gdb tcp::1234 tells QEMU to also start a GDB server on localhost:1234 where
# TCP is the means of communication.
# -kernel tells QEMU what we're looking to run, even if our executable isn't # exactly a "kernel".
qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -kernel a.out

Мы выбрали машину virt, с которой изначально поставляется riscv-qemu.

Теперь, когда наша программа работает внутри QEMU с сервером GDB на localhost:1234, подключимся к нему клиентом RISC-V GDB с отдельного терминала:

# --tui gives us a (t)extual (ui) for our GDB session.
# While we can start GDB without any arguments, specifying 'a.out' tells GDB # to load debug symbols from that file for the newly created session.
riscv64-unknown-elf-gdb --tui a.out

И мы внутри GDB!

This GDB was configured as "--host=x86_64-apple-darwin17.7.0 --target=riscv64-unknown-elf". │
Type "show configuration" for configuration details. │
For bug reporting instructions, please see: │
<http://www.gnu.org/software/gdb/bugs/>. │
Find the GDB manual and other documentation resources online at: │ <http://www.gnu.org/software/gdb/documentation/>. │ │
For help, type "help". │
Type "apropos word" to search for commands related to "word"... │
Reading symbols from a.out... │
(gdb)

Можем попытаться запустить в GDB команды run или start для исполняемого файла a.out, но в данный момент это не сработает по понятной причине. Мы компилировали программу как riscv64-unknown-elf-gcc, так что хост должен работать на архитектуре riscv64.

Такая ситуация — одна из основных причин существования клиент-серверной модели GDB. Но есть выход! Как вы помните, мы только что запустили riscv-qemu и сказали запустить сервер GDB на localhost:1234. Мы можем взять исполняемый файл riscv64-unknown-elf-gdb и вместо запуска на хосте указать ему некую удалённую цель (сервер GDB). Просто подключаемся к этому серверу:

(gdb) target remote :1234 │
Remote debugging using :1234

Теперь можно установить некоторые точки останова:

(gdb) b main
Breakpoint 1 at 0x1018e: file add.c, line 2.
(gdb) b 5 # this is the line within the forever-while loop. int c = a + b;
Breakpoint 2 at 0x1019a: file add.c, line 5.

И, наконец, указываем GDB continue (сокращённая команда c), пока не достигнем точки останова:

(gdb) c Continuing.

Вы быстро заметите, что процесс никак не завершается. Это странно… разве мы не должны немедленно достичь точки останова b 5? Что случилось?

Тут видно несколько проблем:

  1. Текстовый UI не может найти источник. Интерфейс должен отображать наш код и любые близлежащие точки останова.
  2. GDB не видит текущей строки выполнения (L??) и выводит счётчик 0x0 (PC: 0x0).
  3. Какой-то текст в строке ввода, который в полном виде выглядит так: 0x0000000000000000 in ?? ()

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

Чтобы ответить на эти вопросы, давайте повторим нашу команду GCC с флагом -v, чтобы получить более подробную выдачу, что на самом деле происходит.

riscv64-unknown-elf-gcc add.c -O0 -g -v

Выдача большая, так что не будем просматривать весь листинг. Важно отметить, что хотя GCC формально является компилятором, но по умолчанию выполняет ещё и компоновку (чтобы ограничиться только компиляцией и сборкой, следует указать флаг -c). Почему это важно? Ну, взгляните на фрагмент из подробной выдачи gcc:

# The actual `gcc -v` command outputs full paths, but those are quite
# long, so pretend these variables exist.
# $RV_GCC_BIN_PATH = /Users/twilcock/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>/bin/
# $RV_GCC_LIB_PATH = $RV_GCC_BIN_PATH/../lib/gcc/riscv64-unknown-elf/8.2.0 $RV_GCC_BIN_PATH/../libexec/gcc/riscv64-unknown-elf/8.2.0/collect2 \ ...truncated... $RV_GCC_LIB_PATH/../../../../riscv64-unknown-elf/lib/rv64imafdc/lp64d/crt0.o \ $RV_GCC_LIB_PATH/riscv64-unknown-elf/8.2.0/rv64imafdc/lp64d/crtbegin.o \ -lgcc --start-group -lc -lgloss --end-group -lgcc \ $RV_GCC_LIB_PATH/rv64imafdc/lp64d/crtend.o ...truncated...
COLLECT_GCC_OPTIONS='-O0' '-g' '-v' '-march=rv64imafdc' '-mabi=lp64d'

Я понимаю, что даже в сокращённом виде это много, поэтому позвольте объяснить. В первой строке gcc выполняет программу collect2, передаёт аргументы crt0.o, crtbegin.o и crtend.o, флаги -lgcc и --start-group. Описание collect2 можно почитать здесь: если вкратце, collect2 организует различные функции инициализации во время запуска, делая компоновку в один или несколько проходов.

Как вы можете догадаться, crt означает 'C runtime'. Таким образом, GCC компонует несколько файлов crt с нашим кодом. Здесь подробно расписано, для чего предназначен каждый crt, но нас интересует crt0, который выполняет одно важное дело:

«Ожидается, что этот объект [crt0] содержит символ _start, который указывает на начальную загрузку программы».

Суть «начальной загрузки» зависит от платформы, но обычно она включает в себя важные задачи, такие как установка стекового фрейма, передача аргументов командной строки и вызов main. Да, наконец-то мы нашли ответ на вопрос: именно _start вызывает нашу основную функцию!
Мы решили одну загадку, но как это приближает нас к первоначальной цели — запуску простой программы на C в gdb? Осталось решить несколько проблем: первая из них связана с тем, как crt0 настраивает наш стек.

Параметры по умолчанию выбираются на основе нескольких факторов: Как мы видели выше, gcc по умолчанию выполняет компоновку crt0.

  • Целевой триплет, соответствующий структуре machine-vendor-operatingsystem. У нас это riscv64-unknown-elf
  • Целевая архитектура, rv64imafdc
  • Целевая ABI, lp64d

Обычно всё работает нормально, но не для каждого процессора RISC-V. Как упоминалось ранее, одна из задач crt0 — настроить стек. Но он не знает, где конкретно должен быть стек для нашего CPU (-machine)? Он не справится без нашей помощи.

К счастью, qemu позволяет легко сбросить информацию о машине в дамп dtb (device tree blob). В команде qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -kernel a.out мы использовали машину virt.

# Go to the ~/usys/riscv folder we created before and create a new dir # for our machine information.
cd ~/usys/riscv && mkdir machines
cd machines # Use qemu to dump info about the 'virt' machine in dtb (device tree blob) # format.
# The data in this file represents hardware components of a given # machine / device / board.
qemu-system-riscv64 -machine virt -machine dumpdtb=riscv64-virt.dtb

Данные dtb трудно читать, поскольку это в основном двоичный формат, но есть утилита командной строки dtc (device tree compiler), которая может преобразовать файл в нечто более читаемое.

# I'm running MacOS, so I use Homebrew to install this. If you're
# running another OS you may need to do something else.
brew install dtc
# Convert our .dtb into a human-readable .dts (device tree source) file.
dtc -I dtb -O dts -o riscv64-virt.dts riscv64-virt.dtb

На выходе файл riscv64-virt.dts, где мы видим много интересной информации о virt: количество доступных ядер процессора, расположение памяти различных периферийных устройств, таких как UART, расположение встроенной памяти (ОЗУ). Стек должен быть в этой памяти, поэтому поищем его с помощью grep:

grep memory riscv64-virt.dts -A 3 memory@80000000 { device_type = "memory"; reg = <0x00 0x80000000 0x00 0x8000000>; };

Как видим, у этого узла в качестве device_type указано 'memory'. Судя по всему, мы нашли то, что искали. По значениям внутри reg = <...> ; можно определить, где начинается банк памяти и какова его длина.

Однако внутри reg четыре значения. В спецификации devicetree видим, что синтаксис reg — это произвольное количество пар (base_address, length). Странно, разве для одного банка памяти не хватит двух значений?

Эти значения не указаны в нашем узле памяти, а родительский узел памяти — просто корневая часть файла. Опять же из спецификации devicetree (поиск свойства reg) мы узнаём, что количество ячеек <u32> для указания адреса и длины определяется свойствами #address-cells и #size-cells в родительском узле (или в самом узле). Поищем в ней эти значения:

head -n8 riscv64-virt.dts
/dts-v1/; / { #address-cells = <0x02>; #size-cells = <0x02>; compatible = "riscv-virtio"; model = "riscv-virtio,qemu";

Оказывается, и для адреса, и для длины требуется по два 32-битных значения. Это означает, что со значениями reg = <0x00 0x80000000 0x00 0x8000000>; наша память начинается с 0x00 + 0x80000000 (0x80000000) и занимает 0x00 + 0x8000000 (0x8000000) байт, то есть заканчивается по адресу 0x88000000, что соответствует 128 мегабайтам.
С помощью qemu и dtc мы нашли адреса ОЗУ в виртуальной машине virt. Мы также знаем, что gcc по умолчанию компонует crt0, не настраивая стек как нам нужно. Но как использовать эту информацию, чтобы в итоге запустить и отладить программу?

Наш crt0 должен знать, где начинается верхняя часть стека, чтобы правильно инициализировать его. Поскольку crt0 нас не устраивает, есть один очевидный вариант: написать собственный код, а затем скомпоновать его с объектным файлом, который получился после компиляции нашей простой программы. Что если мы захотим использовать в эмуляторе другой CPU, такой как sifive_e, с другими характеристиками? Мы могли бы жёстко закодировать значение 0x80000000 непосредственно в crt0, но это не очень подходящее решение с учётом изменений, которые могут понадобиться в будущем.

Компоновщик GNU ld позволяет определить символ, доступный из нашего crt0. К счастью, мы далеко не первые задаём этот вопрос, и уже существует хорошее решение. Мы можем определить символ __stack_top, подходящий для разных процессоров.

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

Основная цель скрипта компоновщика — описать, как сопоставляются разделы файлов на входе и выходе, и управлять компоновкой памяти выходного файла.

Зная это, давайте скопируем скрипт компоновщика по умолчанию riscv64-unknown-elf-ld в новый файл:

cd ~/usys/riscv
# Make a new dir for custom linker scripts out RISC-V CPUs may require.
mkdir ld && cd ld
# Copy the default linker script into riscv64-virt.ld
riscv64-unknown-elf-ld --verbose > riscv64-virt.ld

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

vim riscv64-virt.ld # Remove everything above and including the ============ line
GNU ld (GNU Binutils) 2.32 Supported emulations: elf64lriscv elf32lriscv
using internal linker script:
==================================================
/* Script for -z combreloc: combine and sort reloc sections */
/* Copyright (C) 2014-2019 Free Software Foundation, Inc. Copying and distribution of this script, with or without modification, are permitted in any medium without royalty provided the copyright notice and this notice are preserved. */
OUTPUT_FORMAT("elf64-littleriscv", "elf64-littleriscv", "elf64-littleriscv")
...rest of the linker script...

После этого запустим команду MEMORY, чтобы вручную определить, где будет __stack_top. Найдите строку, которая начинается с OUTPUT_ARCH(riscv), она должна быть в верхней части файла, и под ней добавьте команду MEMORY:

OUTPUT_ARCH(riscv)
/* >>> Our addition. <<< */
MEMORY
{ /* qemu-system-risc64 virt machine */ RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M }
/* >>> End of our addition. <<< */
ENTRY(_start)

Мы создали блок памяти под названием RAM, для которого допустимы чтение (r), запись (w) и хранение исполняемого кода (x).

Теперь можно его использовать. Отлично, мы определили макет памяти, соответствующий спецификациям нашей машины virt RISC-V. Мы хотим поместить в память наш стек.

Открываем свой скрипт компоновщика (riscv64-virt.ld) в текстовом редакторе и добавляем несколько строк: Нужно определить символ __stack_top.

SECTIONS
{ /* Read-only sections, merged into text segment: */ PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x10000)); . = SEGMENT_START("text-segment", 0x10000) + SIZEOF_HEADERS; /* >>> Our addition. <<< */ PROVIDE(__stack_top = ORIGIN(RAM) + LENGTH(RAM)); /* >>> End of our addition. <<< */ .interp : { *(.interp) } .note.gnu.build-id : { *(.note.gnu.build-id) }

Как видите, мы определяем __stack_top с помощью команды PROVIDE. Символ будет доступен из любой программы, связанной с этим скриптом (предполагая, что сама программа не определит сама что-то с именем __stack_top). Устанавливаем значение __stack_top как ORIGIN(RAM). Мы знаем, что это значение равно 0x80000000 плюс LENGTH(RAM), которая составляет 128 мегабайт (0x8000000 байт). Это означает, что наш __stack_top установлен в 0x88000000.

Для краткости не буду здесь приводит весь файл компоновщика, его можно просмотреть здесь.

Теперь у нас есть всё необходимое для создания своей среды выполнения C. На самом деле это довольно простая задача, вот весь файл crt0.s:

.section .init, "ax"
.global _start
_start: .cfi_startproc .cfi_undefined ra .option push .option norelax la gp, __global_pointer$ .option pop la sp, __stack_top add s0, sp, zero jal zero, main .cfi_endproc .end

Сразу обращает на себя большое количество строк, которые начинаются с точки. Это файл для ассемблера as. Строки с точки называются директивами ассемблера: они предоставляют информацию для ассемблера. Это не исполняемый код, как ассемблерные инструкции RISC-V, такие как jal и add.

Мы будем работать с различными стандартными регистрами RISC-V, поэтому ознакомьтесь с этой таблицей, где рассматриваются все регистры и их назначение. Пробежимся по файлу строка за строкой.

.section .init, "ax"

Как указано в руководстве GNU по ассемблеру 'as', эта строка сообщает ассемблеру внести следующий код в раздел .init, который является выделяемым (a) и исполняемым (x). Этот раздел — ещё одно широко распространенное соглашение для запуска кода в пределах операционной системы. Мы работаем на чистом железе без ОС, поэтому в нашем случае такая инструкция может быть не совсем необходима, но в любом случае это хорошая практика.

.global _start
_start:

.global делает следующий символ доступным для ld. Без этого не пройдёт компоновка, потому что команда ENTRY(_start) в скрипте компоновщика указывает на символ _start как точку входа в исполняемый файл. Следующая строка сообщает ассемблеру, что мы начинаем определение символа _start.

_start: .cfi_startproc .cfi_undefined ra ...other stuff... .cfi_endproc

Эти директивы .cfi информируют о структуре фрейма и о том, как его обработать. Директивы .cfi_startproc и .cfi_endproc сигнализируют о начале и конце функции, а .cfi_undefined ra сообщает ассемблеру, что регистр ra не должен быть восстановлен до любого значения, содержащегося в нем до запуска _start.

.option push
.option norelax
la gp, __global_pointer$
.option pop

Эти директивы .option изменяют поведение ассемблера в соответствии с кодом, когда нужно применить определённый набор опций. Здесь подробно описано, почему важно использование .option в данном сегменте:

… поскольку мы при возможности ослабляем (relax) адресацию последовательностей до более коротких последовательностей относительно GP, начальная загрузка GP не должна быть ослаблена и должна выдаваться примерно так:

.option push
.option norelax
la gp, __global_pointer$
.option pop


чтобы после релаксации получился такой код:

auipc gp, %pcrel_hi(__global_pointer$)
addi gp, gp, %pcrel_lo(__global_pointer$)


вместо простого:

addi gp, gp, 0

А теперь последняя часть нашего crt0.s:

_start: ...other stuff... la sp, __stack_top add s0, sp, zero jal zero, main .cfi_endproc .end

Здесь мы наконец-то можем использовать символ __stack_top, над созданием которого мы столько трудились. Псевдоинструкция la (load address), загружает значение __stack_top в регистр sp (указатель стека), устанавливая его для использования в оставшейся части программы.

Это специальный регистр, который необычен в нескольких отношениях. Затем add s0, sp, zero складывает значения регистров sp и zero (который на самом деле является регистром x0 с жёсткой привязкой к 0) и помещает результат в регистр s0. Во-вторых, s0 иногда действует как указатель фрейма, который даёт каждому вызову функции небольшое пространство в стеке для хранения параметров, передаваемых этой функции. Во-первых, это «сохраняемый регистр», то есть он сохраняется при вызовах функций. Как вызовы функций работают со стеком и указателями фреймов — очень интересная тема, которой легко можно посвятить отдельную статью, но пока просто знайте, что в нашей среде выполнения важно инициализировать указатель фрейма s0.

Здесь jal означает переход и компоновку (Jump And Link). Далее мы видим инструкцию jal zero, main. Функционально jal записывает значение следующей инструкции (регистр pc плюс четыре) в rd, а затем устанавливает регистр pc на текущее значение pc плюс адрес смещения c расширением знака, эффективно «вызывая» этот адрес. Инструкция ожидает операндов в виде jal rd (destination register), offset_address.

Поэтому может показаться странным, что мы в качестве регистра назначения используем регистр zero, который ассемблеры RISC-V интерпретируют как регистр x0. Как упоминалось выше, x0 жёстко привязан к литеральному значению 0, и запись в него бесполезна. Зачем так делать, ведь в других архитектурах есть явная инструкция безусловного перехода? Ведь это означает безусловный переход к offset_address.

Поддержка каждой новой инструкции означает увеличение и, следовательно, удорожание процессора. Этот странный шаблон jal zero, offset_address на самом деле является умной оптимизацией. Вместо того, чтобы загрязнять пространство инструкций двумя инструкциями jal и unconditional jump, архитектура RISC-V поддерживает только jal, а безусловные переходы поддерживаются через jal zero, main. Поэтому чем проще ISA, тем лучше.

Ассемблеры знают, как перевести их в реальные аппаратные инструкции. В RISC-V очень много подобных оптимизаций, большинство из которых принимают форму так называемых псевдоинструкций. Полный список официально поддерживаемых псевдоинструкций см. Например, псевдоинструкцию безусловного перехода j offset_address ассемблеры RISC-V переводят в jal zero, offset_address. 2). в спецификации RISC-V (версия 2.

_start: ...other stuff... jal zero, main .cfi_endproc .end

Наша последняя строчка — это директива ассемблера .end, которая просто обозначает конец файла.
Пытаясь отладить простую программу C на процессоре RISC-V, мы решили множество проблем. Сначала с помощью qemu и dtc нашли нашу память в виртуальной машине virt RISC-V. Затем использовали эту информацию для ручного управления размещением памяти в нашей версии дефолтного скрипта компоновщика riscv64-unknown-elf-ld, что позволило точно определить символ __stack_top. Затем использовали этот символ в собственной версии crt0.s, которая настраивает наш стек и глобальные указатели и, наконец, вызвали функцию main. Теперь можно достичь поставленной цели и запустить отладку нашей простой программы в GDB.

Напомним, вот сама программа на C:

cat add.c
int main() { int a = 4; int b = 12; while (1) { int c = a + b; } return 0;
}

Компилирование и компоновка:

riscv64-unknown-elf-gcc -g -ffreestanding -O0 -Wl,--gc-sections -nostartfiles -nostdlib -nodefaultlibs -Wl,-T,riscv64-virt.ld crt0.s add.c

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

Этот параметр не требуется при запуске приложения на своём хосте (в операционной системе), но в данном случае это не так, поэтому важно сообщить компилятору эту информацию. -ffreestanding сообщает компилятору, что стандартная библиотека может не существовать, поэтому не нужно делать предположений о её обязательном наличии.

Здесь --gc-sections означает «секции сбора мусора», а ld получает указание удалить неиспользуемые секции после компоновки. -Wl — разделённый запятыми список флагов для передачи компоновщику (ld). У нас свой скрипт crt0 и компоновщик, поэтому важно передать эти флаги, чтобы значения по умолчанию не конфликтовали с нашей пользовательской настройкой. Флаги -nostartfiles, -nostdlib и -nodefaultlibs сообщают компоновщику не обрабатывать стандартные системные файлы запуска (например, дефолтный crt0), стандартные реализации системной stdlib и стандартные системные дефолтные связываемые библиотеки.

Наконец, мы указываем файлы, которые хотим скомпилировать, собрать и скомпоновать: crt0.s и add.c. -T указывает путь к нашему скрипту компоновщика, который в нашем случае просто riscv64-virt.ld. Как и раньше, в результате получается полноценный и готовый к запуску файл под названием a.out.

Теперь запустим наш красивенький новенький исполняемый файл в qemu:

# -S freezes execution of our executable (-kernel) until we explicitly tell # it to start with a 'continue' or 'c' from our gdb client
qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -S -kernel a.out

Теперь запустите gdb, не забудьте загрузить символы отладки для a.out, указав его последним аргументом:

riscv64-unknown-elf-gdb --tui a.out GNU gdb (GDB) 8.2.90.20190228-git
Copyright (C) 2019 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=x86_64-apple-darwin17.7.0 --target=riscv64-unknown-elf".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from a.out...
(gdb)

Затем подключим наш клиент gdb к серверу gdb, который мы запустили как часть команды qemu:

(gdb) target remote :1234 │
Remote debugging using :1234

Установим точку останова в main:

(gdb) b main
Breakpoint 1 at 0x8000001e: file add.c, line 2.

И начнём выполнение программы:

(gdb) c
Continuing. Breakpoint 1, main () at add.c:2

Из приведённого выдачи понятно, что мы успешно попали в точку останова на строке 2! Это видно и в текстовом интерфейсе, наконец-то у нас правильная строка L, значение PC: равно L2, а PC: — 0x8000001e. Если вы делали всё как в статье, то выдача будет примерно такой:

Экспериментируйте в своё удовольствие… мы, конечно, немало поработали ради этого! С этого момента можно использовать gdb как обычно: -s для перехода к следующей инструкции, info all-registers для проверки значений внутри регистров по мере выполнения программы и т. д.

Сегодня мы многого добились и, надеюсь, многому научились! У меня никогда не было формального плана для этой и последующих статей, я просто следовал тому, что мне наиболее интересно в каждый момент. Поэтому не уверен, что будет дальше. Мне особенно понравилось глубокое погружение в инструкцию jal, так что может в следующей статье возьмём за основу знания, полученные здесь, но заменим add.c какой-нибудь программой на чистом ассемблере RISC-V. Если у вас есть что-то конкретное, что вы хотели бы увидеть или какие-то вопросы, открывайте тикеты.

Надеюсь, встретимся в следующей статье! Спасибо за чтение!

Если вам понравилась статья и вы хотите узнать больше, посмотрите презентацию Мэтта Годболта под названием «Биты между битами: как мы попадаем в main()» с конференции CppCon2018. Она подходит к теме немного иначе, чем мы здесь. Реально хорошая лекция, смотрите сами!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть