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

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

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

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

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

С тех пор прошло прилично времени. Кода я написал немало, повозился с ардуино, поработал с мини-компьютерами на 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 tools

curl -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.zip
unzip -q qemu.zip && mv xpack-qemu-riscv-9.2.4-1 qemu && rm qemu.zip

curl -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.zip
unzip -q gcc.zip && mv xpack-riscv-none-elf-gcc-15.2.0-1 riscv-gcc && rm gcc.zip

curl -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.zip
unzip -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 --version
qemu-system-riscv32 --version
make --version

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

riscv-none-elf-gcc.exe (xPack GNU RISC-V Embedded GCC x86_64) 15.2.0
xPack QEMU emulator version 9.2.4
GNU 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 .rodata
message:
    .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_char

done:
    wfi
    j       done

    .section .rodata
message:
    .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-gcc
QEMU   = qemu-system-riscv32
TARGET = hello.elf

CFLAGS = -march=rv32i -mabi=ilp32 -nostdlib -nostartfiles
QEMUFLAGS = -machine virt -nographic -bios none

all: $(TARGET)

$(TARGET): boot.s link.ld
	$(CC) $(CFLAGS) -T link.ld -o $@ boot.s

run: $(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          	lui	t0,0x10000
80000004:	00000317          	auipc	t1,0x0
80000008:	02430313          	addi	t1,t1,36 # 80000028 <message>

8000000c <next_char>:
8000000c:	00034383          	lbu	t2,0(t1)
80000010:	00038863          	beqz	t2,80000020 <done>
80000014:	00728023          	sb	t2,0(t0) # 10000000 <_start-0x70000000>
80000018:	00130313          	addi	t1,t1,1
8000001c:	ff1ff06f          	j	8000000c <next_char>

80000020 <done>:
80000020:	10500073          	wfi
80000024:	ffdff06f          	j	80000020 <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 в терминале - совсем разные вещи.

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

Комментарии (0)