Как стать автором
Обновить

RISC-V с нуля

Время на прочтение18 мин
Количество просмотров58K
Автор оригинала: Tyler Wilcock
В этой статье мы исследуем различные низкоуровневые концепции (компиляция и компоновка, примитивные среды выполнения, ассемблер и многое другое) через призму архитектуры 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?


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

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

Настройка инструментов QEMU и RISC-V


Мы не сможем запустить код на процессоре 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/.

Привет, RISC-V!


Обновление 26 мая 2019 года:

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

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


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

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() {
    int a = 4;
    int b = 12;
    while (1) {
        int c = a + b;
    }
    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. Мы можем взять исполняемый файл riscv64-unknown-elf-gdb и вместо запуска на хосте указать ему некую удалённую цель (сервер GDB). Как вы помните, мы только что запустили riscv-qemu и сказали запустить сервер GDB на localhost:1234. Просто подключаемся к этому серверу:

(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 ?? ()

В сочетании с тем, что мы не можем достигнуть точки останова, эти индикаторы указывают: мы что-то сделали не так. Но что?

Приподнимая завесу -v


Чтобы понять происходящие, нужно сделать шаг назад и поговорить, как на самом деле работает наша простая программа на С под капотом. Функция 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 организует различные функции инициализации во время запуска, делая компоновку в один или несколько проходов.

Таким образом, GCC компонует несколько файлов crt с нашим кодом. Как вы можете догадаться, crt означает 'C runtime'. Здесь подробно расписано, для чего предназначен каждый 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-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -kernel a.out мы использовали машину virt. К счастью, qemu позволяет легко сбросить информацию о машине в дамп dtb (device tree blob).

# 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 = <...> ; можно определить, где начинается банк памяти и какова его длина.

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

Опять же из спецификации 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 должен знать, где начинается верхняя часть стека, чтобы правильно инициализировать его. Мы могли бы жёстко закодировать значение 0x80000000 непосредственно в crt0, но это не очень подходящее решение с учётом изменений, которые могут понадобиться в будущем. Что если мы захотим использовать в эмуляторе другой CPU, такой как sifive_e, с другими характеристиками?

К счастью, мы далеко не первые задаём этот вопрос, и уже существует хорошее решение. Компоновщик 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. Теперь можно его использовать. Мы хотим поместить в память наш стек.

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

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.

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

Стоп! Hammertime! Runtime!


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

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

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

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

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

-T указывает путь к нашему скрипту компоновщика, который в нашем случае просто riscv64-virt.ld. Наконец, мы указываем файлы, которые хотим скомпилировать, собрать и скомпоновать: crt0.s и add.c. Как и раньше, в результате получается полноценный и готовый к запуску файл под названием 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. Она подходит к теме немного иначе, чем мы здесь. Реально хорошая лекция, смотрите сами!
Теги:
Хабы:
Всего голосов 41: ↑41 и ↓0+41
Комментарии21

Публикации

Истории

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань