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

Вот на этом месте понимание и кончалось. Ключи есть, единицы с нулями есть, а откуда берётся всё остальное, непонятно.
С тех пор прошло прилично времени. Кода я написал немало, повозился с ардуино, поработал с мини-компьютерами на 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 в терминале - совсем разные вещи.
А пока вопрос к тем, кто это уже проходил. С чего начинали вы и где сели в лужу в первый раз? Мне интересно, у всех ли первая яма про кодировку, или это только моя.
arteast
По делу надо еще этот UART сначала настроить, а потом для каждого символа дожидаться, пока регистр (Transmitter Holding Register в NS16550A, который в virt эмулируется) освободится. Впрочем, туториалов такого рода в интернете немало, и никто этого не делает... потому что никто не запускает их на реальном железе и не пытается напечатать что-то более длиннее hellord.
Pro100lamer Автор
Спасибо, вы попали в больное место.
Полез проверять и упёрся в неожиданное: мой "стенд" эту ошибку показать не может в принципе.
QEMU отдаёт байты так быстро, как получится, скорость линии не моделирует, и THRE у него не снимается никогда.
То есть проверить себя мне было нечем, даже если бы я задумался.
Тема оказалась заметно больше комментария.
Разбираю её во второй статье: собираю стенд, на котором ошибку видно.
Спасибо за наводку, сам бы прошёл мимо.
arteast
QEMU действительно самостоятельно не эмулирует скорость линии, но THRE он снимать умеет. Он просто проксирует все на фронтенд устройства uart. Если фронтенд - это консоль, и пишем мы десяток символов, то оно пролетит моментально. А вот если, к примеру, устройство UART гостя отображается на реальное uart-устройство хоста, то настройки в госте (типа divisor) будут приводить к соответствующей перенастройке реального устройства, и наоборот, переполнение буфера реального устройства будет приводить к заполнению xmit FIFO на госте, и снятию THRE. Конечно, получается не вполне честное поведение (как минимум фактическая глубина FIFO будет больше, чем должно быть для 16550) - но если послать "войну и мир" в COM-порт, который замаплен на настоящий COM-порт, то средняя скорость передачи будет реально такой, какая была настроена в 16550 (и THRE большую часть времени будет снят).
Pro100lamer Автор
Проверил, вы правы.
Написал программу на 300 тысяч записей, которая считает, сколько раз передатчик оказался занят.
С выводом в файл ноль раз. С медленным TCP-приёмником счётчик сразу упирается в потолок. THRE снимается нормально, просто у меня на другом конце была консоль, которая не тормозит никогда.
FIFO для этого даже не нужен, дело в
tsr_retry. Проброс настроек в реальный порт тоже нашёл, всё так.Статья ещё не опубликована, поправку внесу сразу. Спасибо.