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

Вот на этом месте понимание и кончалось. Ключи есть, единицы с нулями есть, а откуда берётся всё остальное, непонятно.
С тех пор прошло прилично времени. Кода я написал немало, повозился с ардуино, поработал с мини-компьютерами на 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 |
|
эмулятор, даёт |
xPack RISC-V GCC 15.2.0-1 |
|
ассемблер и линкер |
xPack Windows Build Tools 4.4.1-3 |
|
|
Все три - 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"
Все инструкции, которые встретились, одной таблицей — чтобы не гуглить по ходу:
Инструкция |
Что делает |
|---|---|
|
положить число в регистр. Псевдоинструкция, ассемблер развернёт её сам |
|
положить в регистр адрес метки. Тоже псевдоинструкция |
|
прочитать один байт по адресу и дополнить нулями до слова |
|
записать младший байт регистра по адресу |
|
сложить регистр с числом |
|
перейти на метку, если в регистре ноль |
|
перейти на метку безусловно |
|
остановить процессор до прерывания |
Регистры 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:

ШАГ 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 - обзор того, что это за книга и кому подойдёт, если не хочется брать вслепую.
Ассемблер 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
ARM-ы для самых маленьких: тонкости компиляции и компоновщик - лучший разбор скриптов
ldна русском из тех, что я нашёл. Архитектура другая, но синтаксисMEMORYиSECTIONSтот же самый, а объяснение, что компоновка это «выдрать секции из объектных файлов и разложить по адресам», ставит всё на место.Введение в ELF-файлы в Linux: понимание и анализ - что за формат мы собираем и почему у него два разных взгляда на одни и те же байты: секции для линкера, сегменты для загрузчика.
UTF-8: откуда взялся двадцать один байт
Как же прекрасна структура UTF-8 - наглядно и с разбором конкретных файлов побайтно. После неё дамп
.rodataиз этой статьи читается без напряжения.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 в терминале - совсем разные вещи.
А пока вопрос к тем, кто это уже проходил. С чего начинали вы и где сели в лужу в первый раз? Мне интересно, у всех ли первая яма про кодировку, или это только моя.