Двенадцать символов, двадцать один байт: как я научил голый RISC-V говорить «Привет, мир!»

от автора

Я никогда не задумывался, как работает компьютер.

Ну то есть как «не задумывался». Нажал кнопку, загрузилась операционная система, запустил программу, всё поехало. Так живут все, и я жил.

Где-то в колледже до меня дошла первая честная мысль на эту тему. Процессор — это набор ключей. Каждый выдаёт либо единицу, либо ноль. Больше он не умеет ничего.

А дальше происходит магия.

Вот на этом месте понимание и кончалось. Ключи есть, единицы с нулями есть, а откуда берётся всё остальное, непонятно.

С тех пор прошло прилично времени. Кода я написал немало, повозился с ардуино, поработал с мини-компьютерами на ARM, писал программы для ПК. Дёрнуть ногой могу, тут всё честно. Есть регистр, пишешь в него число, нога дёрнулась, результат видно глазами.

Но как из тех же самых ключей получается график на экране? Проценты в углу? Операционная система, в конце концов?

Мысль не отпускала, а разбираться было лень. Вот прямо честно. Лень. Чтобы дойти до сути, надо было убить не один вечер, и каждый раз находилось дело поважнее.

Сейчас всё изменилось. Появились нейросети, и то, на что раньше уходили часы, занимает минуты. Информация ищется быстрее. Код я, чего скрывать, давно пишу не руками.

То есть отговорка кончилась.

Решил так: возьму и выведу что-нибудь в консоль. Без библиотек, без обвязок, без операционной системы под ногами. Реальный код на реальном железе — ну, почти реальном, для начала сойдёт эмулятор.

Сначала думал вывести «hello», как все.

Потом подумал: погодите. Мы же тут все на русском разговариваем.

Пусть будет «Привет, мир!».

Дальше — вверх. Регистры и арифметика. Стек и что происходит при вызове функции. ELF и линкер. Куда что легло и почему 0x80000000. Свой ассемблер вместо gcc. А в конце что-то, что не стыдно назвать языком.

Язык тут побочный продукт, а не цель. Пользователей ему я не ищу и ничего не продаю. Мне надо закрыть дыру в голове, а компилятор проверяет это честнее всего. Машину, которую не понимаешь, не запрограммируешь.

Сразу скажу: код в этой серии я пишу не один — сажаю за него нейросеть, а сам веду, проверяю и разбираюсь, что она там наворотила. Где она села в лужу — рассказываю отдельно.

И где сам сел в лужу, тоже. В каждой статье будет раздел про то, где я споткнулся: что сделал, что получил, почему был неправ(но это не точно).

Что получится в конце

Пустая эмулируемая машина RISC-V, десяток строк ассемблера, и в терминале:

Привет, мир!

Двенадцать символов, а в UART уедет двадцать один байт. Плюс перевод строки, будет двадцать два. Это не опечатка, и это первое, обо что я споткнулся.

Всё делается на Windows 11 и без прав администратора. Под Linux и macOS меняются только пути и имена архивов, команды те же.

Почему RISC-V, а не ардуино

Ардуино у меня лежит в ящике, и первая мысль была взять его. Не взял, и вот почему.

У AVR восемь бит и гарвардская архитектура. Код и данные живут в разной памяти, и это отдельная история, которую придётся объяснять до того, как объяснишь основную. Эмуляторы под него так себе.

RISC-V — открытая архитектура, набор инструкций небольшой и без легаси, а главное, QEMU эмулирует её из коробки, вместе с готовой машиной, у которой UART уже висит по известному адресу. Никакой платы, никакого провода. Собрал, запустил, увидел буквы.

Работаем на голом железе: без операционной системы, без загрузчика, без стандартной библиотеки. Всё, что происходит, происходит потому, что мы это написали.

Что нам понадобится

Я на Windows, всё ставится распаковкой, ничего не прописывается в систему, а версии зафиксированы, так что через год статья соберётся так же(но это не точно).

Инструмент

Откуда

Зачем

xPack QEMU RISC-V 9.2.4-1

github.com/xpack-dev-tools/qemu-riscv-xpack

эмулятор, даёт qemu-system-riscv32

xPack RISC-V GCC 15.2.0-1

github.com/xpack-dev-tools/riscv-none-elf-gcc-xpack

ассемблер и линкер

xPack Windows Build Tools 4.4.1-3

github.com/xpack-dev-tools/windows-build-tools-xpack

make, которого в Git Bash нет

Все три — zip-архивы с релизных страниц GitHub. Под Linux и macOS у тех же проектов лежат свои архивы, команды дальше не меняются.

ШАГ 1: Ставим тулчейн

Распаковываем три архива, каталоги переименовываем в короткие, чтобы пути не зависели от версии.

mkdir tools && cd toolscurl -L -o qemu.zip https://github.com/xpack-dev-tools/qemu-riscv-xpack/releases/download/v9.2.4-1/xpack-qemu-riscv-9.2.4-1-win32-x64.zipunzip -q qemu.zip && mv xpack-qemu-riscv-9.2.4-1 qemu && rm qemu.zipcurl -L -o gcc.zip https://github.com/xpack-dev-tools/riscv-none-elf-gcc-xpack/releases/download/v15.2.0-1/xpack-riscv-none-elf-gcc-15.2.0-1-win32-x64.zipunzip -q gcc.zip && mv xpack-riscv-none-elf-gcc-15.2.0-1 riscv-gcc && rm gcc.zipcurl -L -o bt.zip https://github.com/xpack-dev-tools/windows-build-tools-xpack/releases/download/v4.4.1-3/xpack-windows-build-tools-4.4.1-3-win32-x64.zipunzip -q bt.zip && mv xpack-windows-build-tools-4.4.1-3 build-tools && rm bt.zip

Добавляем три каталога bin в PATH текущей сессии. Только текущей. В систему не лезем, закрыл терминал, ничего не осталось.

export PATH="$PWD/riscv-gcc/bin:$PWD/qemu/bin:$PWD/build-tools/bin:$PATH"

Если вы в PowerShell, а не в Git Bash, команда другая. Это то место, где я сам сначала получил «Имя “make” не распознано».

$env:PATH = "$PWD\riscv-gcc\bin;$PWD\qemu\bin;$PWD\build-tools\bin;" + $env:PATH[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

Вторая строка нужна не для красоты. Консоль PowerShell по умолчанию работает не в UTF-8, и «Привет, мир!» приедет кракозябрами. Программа при этом полностью исправна. Она отдаёт правильные байты, а собирает их обратно в буквы консоль.

Проверяем, что всё живо:

riscv-none-elf-gcc --versionqemu-system-riscv32 --versionmake --version

У меня отвечает так:

riscv-none-elf-gcc.exe (xPack GNU RISC-V Embedded GCC x86_64) 15.2.0xPack QEMU emulator version 9.2.4GNU Make 4.4.1

Если хоть одна команда не нашлась, дальше идти бессмысленно. Разбирайтесь с PATH.

ШАГ 2: Пишем программу

Вся программа — один файл boot.s. Разберём по кускам.

Начало. Секция кода и точка входа с именем _start. Никакого main. Это соглашение стандартной библиотеки, а её у нас нет.

    .section .text    .globl _start_start:    li      t0, 0x10000000      /* регистр данных UART 16550 */    la      t1, message         /* адрес первого байта строки */

Адрес 0x10000000 не выдуман. У машины virt, которую эмулирует QEMU, по этому адресу висит регистр данных UART. Записал туда байт, байт ушёл в терминал. Вот и весь «вывод на экран» на этом уровне. Обычная запись в память, только по особому адресу, за которым стоит не память, а устройство.

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

Теперь цикл. Берём байт. Если он нулевой, строка кончилась. Иначе кладём его в UART и сдвигаемся на байт вперёд.

next_char:    lbu     t2, 0(t1)           /* берём очередной байт */    beqz    t2, done            /* ноль — строка кончилась */    sb      t2, 0(t0)           /* кладём байт в UART */    addi    t1, t1, 1           /* сдвигаемся на байт вперёд */    j       next_char

Пять инструкций. Обратите внимание: цикл не знает ни про символы, ни про кодировки. Он двигает байты по одному, пока не упрётся в ноль. Запомните это, к концу статьи пригодится.

Конец. А конца-то и нет.

done:    wfi    j       done

Возвращаться некуда. Под нами нет ни операционной системы, которая нас запустила, ни вызывающего кода. wfi — «wait for interrupt», останавливает процессор до следующего прерывания. Прерываний мы не настраивали, так что он стоит. А j done на случай, если процессор всё-таки проснётся. Пусть встанет обратно.

И сама строка:

    .section .rodatamessage:    .string "Привет, мир!\n"

.string сам добавит нулевой байт в конец. Тот, на который смотрит beqz.

boot.s целиком
    .section .text    .globl _start_start:    li      t0, 0x10000000      /* регистр данных UART 16550 */    la      t1, message         /* адрес первого байта строки */next_char:    lbu     t2, 0(t1)           /* берём очередной байт */    beqz    t2, done            /* ноль — строка кончилась */    sb      t2, 0(t0)           /* кладём байт в UART */    addi    t1, t1, 1           /* сдвигаемся на байт вперёд */    j       next_chardone:    wfi    j       done    .section .rodatamessage:    .string "Привет, мир!\n"

Все инструкции, которые встретились, одной таблицей — чтобы не гуглить по ходу:

Инструкция

Что делает

li rd, число

положить число в регистр. Псевдоинструкция, ассемблер развернёт её сам

la rd, метка

положить в регистр адрес метки. Тоже псевдоинструкция

lbu rd, смещение(rs)

прочитать один байт по адресу и дополнить нулями до слова

sb rs, смещение(rd)

записать младший байт регистра по адресу

addi rd, rs, число

сложить регистр с числом

beqz rs, метка

перейти на метку, если в регистре ноль

j метка

перейти на метку безусловно

wfi

остановить процессор до прерывания

Регистры t0, t1, t2 — временные, по соглашению о вызовах их можно портить свободно. Соглашение нам пока не нужно, вызовов нет, но привычка полезная.

ШАГ 3: Объясняем линкеру, куда класть код

Компилятор превратит ассемблер в машинный код, но кто-то должен решить, по каким адресам этот код окажется. Обычно это решает операционная система вместе с линкером по умолчанию. У нас нет ни того, ни другого, значит, скажем сами.

Файл link.ld:

OUTPUT_ARCH(riscv)ENTRY(_start)MEMORY{    RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M}SECTIONS{    .text : {        *(.text)        *(.text.*)    } > RAM    .rodata : {        *(.rodata)        *(.rodata.*)    } > RAM}

Откуда 0x80000000. У машины virt оперативная память начинается с этого адреса, и при запуске без прошивки QEMU передаёт управление туда. Мы кладём .text первой секцией, поэтому _start оказывается по адресу 0x80000000, куда процессор и придёт.

Это, кстати, единственное место, где сейчас есть скрытая хрупкость. _start попадает в начало только потому, что объектный файл у нас один. Когда файлов станет больше, порядок перестанет быть гарантированным. Разберём это в статье про ELF и линкер, там ему и место.

Вот как всё это выглядит целиком. Программа лежит по одному адресу, а пишет по другому, и между ними ничего общего, кроме одной инструкции sb:

Карта памяти машины virt: программа лежит по 0x80000000 и пишет байты по 0x10000000

Карта памяти машины virt: программа лежит по 0x80000000 и пишет байты по 0x10000000

ШАГ 4: Собираем и запускаем

Сборка:

riscv-none-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib -nostartfiles -T link.ld -o hello.elf boot.s

Что значат флаги:

  • -march=rv32i — только базовый набор целочисленных инструкций, никаких расширений. Тридцать два бита, минимум сущностей.

  • -mabi=ilp32 — соглашение о вызовах для тридцати двух бит.

  • -nostdlib — стандартной библиотеки нет и не будет.

  • -nostartfiles — и стартового кода тоже нет. Обычно перед main выполняется солидный кусок чужого кода, который готовит окружение. У нас точка входа своя, и до неё ничего не происходит.

  • -T link.ld — использовать наш линкер-скрипт.

Запуск:

qemu-system-riscv32 -machine virt -nographic -bios none -kernel hello.elf

Флаги здесь такие:

  • -machine virt — какую машину эмулируем.

  • -nographic — весь ввод-вывод в терминал, никаких окон.

  • -bios none — вот это важное. Без него QEMU сначала запустит прошивку OpenSBI, и управление получит она, а не мы. Нам нужно, чтобы первым исполнился наш код.

  • -kernel hello.elf — что загружать.

И в терминале:

Привет, мир!

Программа не завершается, она в бесконечном цикле, помните? QEMU висит и ждёт. Выход: Ctrl-A, отпустить, затем X.

ШАГ 5: Прячем команды в Makefile

Набирать это руками каждый раз незачем.

CC     = riscv-none-elf-gccQEMU   = qemu-system-riscv32TARGET = hello.elfCFLAGS = -march=rv32i -mabi=ilp32 -nostdlib -nostartfilesQEMUFLAGS = -machine virt -nographic -bios noneall: $(TARGET)$(TARGET): boot.s link.ld$(CC) $(CFLAGS) -T link.ld -o $@ boot.srun: $(TARGET)$(QEMU) $(QEMUFLAGS) -kernel $(TARGET)clean:rm -f $(TARGET).PHONY: all run clean

Отступы в рецептах — табуляция, не пробелы. С пробелами make скажет missing separator и будет прав.

Дальше всё коротко:

make        # собратьmake run    # собрать и запуститьmake clean  # убрать

Смотрим, что получилось

Раз уж серия про то, как оно устроено внутри, посмотрим на результат, а не только на буквы в терминале.

riscv-none-elf-objdump -d hello.elf
80000000 <_start>:80000000:100002b7          luit0,0x1000080000004:00000317          auipct1,0x080000008:02430313          addit1,t1,36 # 80000028 <message>8000000c <next_char>:8000000c:00034383          lbut2,0(t1)80000010:00038863          beqzt2,80000020 <done>80000014:00728023          sbt2,0(t0) # 10000000 <_start-0x70000000>80000018:00130313          addit1,t1,18000001c:ff1ff06f          j8000000c <next_char>80000020 <done>:80000020:10500073          wfi80000024:ffdff06f          j80000020 <done>

Слева адреса. _start действительно лёг по 0x80000000, как мы и просили линкер. Дальше машинный код. 100002b7 и есть lui t0, 0x10000, одно 32-х битное число. Вот они, ключи из колледжа. Больше в процессоре ничего и нет.

Заодно видно, что li и la, которые я писал в исходнике, не настоящие инструкции, а псевдоинструкции. Ассемблер развернул их в lui и в пару auipc с addi. Всего инструкций получилось 10, из них цикл — 5.

Вся программа весит 63 байта. 40 — код, 23 — строка.

Где я споткнулся

Обещал раздел про грабли, вот он.

12 символов оказались 21 байтом.

Я по привычке считал, что символ — это байт. Посмотрим, что реально лежит в памяти:

riscv-none-elf-objdump -s -j .rodata hello.elf
Contents of section .rodata: 80000028 d09fd180 d0b8d0b2 d0b5d182 2c20d0bc  ............, .. 80000038 d0b8d180 210a00                      ....!..
Строка «Привет, мир!» побайтно: девять кириллических букв по два байта, запятая, пробел и восклицательный знак по одному

Строка «Привет, мир!» побайтно: девять кириллических букв по два байта, запятая, пробел и восклицательный знак по одному

Читается так. d09f — это «П», d180 — «р», d0b8 — «и». Каждая кириллическая буква занимает два байта, потому что это UTF-8. А вот 2c — запятая, 20 — пробел, 21 — восклицательный знак. По одному байту, латиница и знаки препинания в UTF-8 остались однобайтовыми. В конце 0a, перевод строки, и 00, тот ноль, на который смотрит beqz.

Считаем. 9 букв по 2 байта дают 18, плюс запятая, пробел и восклицательный знак. 21 с переводом строки 22, с нулевым байтом 23. Столько и показал size.

И вот тут доходит, что цикл из пяти инструкций всё это время был прав, а я нет. Он не выводит символы. Он двигает байты. Символы собираются обратно уже в терминале, и то только если терминал в UTF-8. Если у вас вместо букв кракозябры, программа ни при чём. Дело в терминале.

Нейросеть уверенно назвала не тот тулчейн.

Спрашивал, чем собирать под RISC-V, получил riscv64-unknown-elf-gcc. Название выглядит настолько канонично, что оно перекочевало ко мне в план работ не глядя. Такого бинарника в xPack-сборке нет вообще, там префикс riscv-none-elf-, и команда из плана не нашлась бы ни при каких обстоятельствах.

Поймал только потому, что перед установкой полез смотреть, что вообще лежит в каталоге bin. Мораль скучная, но повторю: имена файлов проверяются ls, а не спрашиванием.

Проверка, которая проходила на неработающей программе.

Я попросил написать скрипт, который проверяет, что стенд выводит нужную строку. Скрипт запускал QEMU, писал вывод в файл и искал в файле строку. Выглядело разумно.

До первого вопроса: а что будет, если QEMU не запустится вообще? Ответ: файл с прошлого прогона останется лежать на диске, строка в нём найдётся, скрипт скажет «всё хорошо». Проверка подтверждала работоспособность стенда, который не стартовал.

Чинится тремя строчками. Удалить файл перед запуском, убедиться, что он действительно удалился, а потом проверить код возврата QEMU. Но найти это можно только одним способом. Спросить себя: а как эта проверка может соврать?

Я сам ошибся в заголовке.

Первая версия заголовка была «Двенадцать букв, двадцать два байта». Букв в «Привет, мир!» 9, символов 12, байт 21, а 22 — это уже с переводом строки. Три ошибки в четырёх словах, в статье про то, как всё устроено внутри.

Пересчитал перед публикацией. Заодно понял, почему в разделе про UTF-8 стоит показывать дамп памяти, а не рассказывать словами.

Для тех, кто хочет разобраться сам

Я собрал этот стенд не из воздуха. Ниже то, по чему разбирался, почти всё на русском. Порядок не случайный: если пройти сверху вниз, получится то же, что описано в статье, только своими руками и с пониманием, откуда что взялось.

Если совсем с начала: что такое процессор и как он исполняет команды

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

  • Ассемблер RISC-V для начинающих — регистры, соглашения о вызовах, почему набор инструкций такой маленький. Без эмулятора и железа, чистый язык.

  • Изучаем RISC-V с нуля, часть 1: Ассемблер и соглашения — то же самое, но сразу с прицелом на голое железо и с разбором линкер-скрипта. Автор работает на реальной микросхеме GD32VF103, а не в эмуляторе. Полезно посмотреть, чем отличается.

  • RISC-V Reference Card — шпаргалка на две страницы: все инструкции базового набора, регистры, соглашение о вызовах. По-английски, но читать там особо нечего, это таблицы. Держать под рукой удобнее, чем листать спецификацию.

То же, что делали мы: тулчейн, QEMU, свой линкер-скрипт

  • RISC-V с нуля — ближайшая к этой статье вещь на русском. Автор так же поднимает тулчейн, так же запускает qemu-system-riscv с машиной virt, но идёт дальше: вытаскивает из QEMU дерево устройств, находит по нему карту памяти и настраивает стек, чтобы можно было писать на C, а не на ассемблере. Если после моей статьи захочется следующего шага, он там.

  • Операционная система в 1000 строк кода — перевод известного руководства, RISC-V под QEMU, от загрузки до страничной трансляции. Начинается примерно с того места, где эта статья заканчивается, и уезжает далеко вперёд. Частей несколько, ссылки на продолжения внутри.

Линкер и ELF: почему код лёг по 0x80000000

UTF-8: откуда взялся двадцать один байт

Первоисточники, куда идти за правдой

Здесь по-русски ничего нет, но эти два документа отвечают на вопросы, на которые не ответит никакая статья.

  • Спецификации RISC-V — что делает каждая инструкция. Нужен том Unprivileged для lbu, sb и остальных, и Privileged для wfi.

  • Документация QEMU по машине virt — откуда взялся адрес 0x10000000 и почему ОЗУ начинается с 0x80000000. Там же список остальных устройств машины, которые мы пока не трогали.

Итог

Есть эмулируемая машина RISC-V, десять инструкций и одна строка. Никакой операционной системы, никакой стандартной библиотеки. Записываем байты по адресу устройства и видим их в терминале.

Весь код лежит в репозитории, история разбита по шагам этой статьи: github.com/Pro100lamer/uart-to-lang. Тег article-01 — состояние на конец этой статьи.

В следующей статье возьмёмся за регистры и арифметику: научимся складывать числа и выводить результат числом, а не строкой. Окажется, что вывести 4 сложнее, чем «Привет, мир!». Четвёрка в регистре и символ 4 в терминале — совсем разные вещи.

А пока вопрос к тем, кто это уже проходил. С чего начинали вы и где сели в лужу в первый раз? Мне интересно, у всех ли первая яма про кодировку, или это только моя.

ссылка на оригинал статьи https://habr.com/ru/articles/1065554/