Четыре статьи я писал текст, звал gcc и получал работающую программу. Что происходит между текстом и байтами, меня не занимало: работает же.

В конце прошлой статьи я пообещал разобраться, как из строчки addi sp, sp, -16 получаются те самые 4 байта ff010113. Сел разбираться и управился за вечер: там пять полей и никакой глубины.

А потом решил закодировать так всю первую программу серии. И выяснил, что кодировать в ней почти нечего.

▍ Навигация по серии

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

Ассемблер на Python, который собирает первую программу серии и выдаёт байты, совпадающие с выводом настоящего тулчейна. Не «похожие», а совпадающие: проверяется командой cmp.

И ответ на вопрос, которого я не задавал: сколько строк моей программы вообще есть в таблице кодов процессора.

Обещанные четыре байта

addi sp, sp, -16 это «взять sp, прибавить к нему минус 16, положить обратно в sp». Инструкций с константой в RISC-V много, и все они устроены одинаково: 32 бита нарезаны на пять полей.

биты

поле

значение

что значит

31…20

imm[11:0]

111111110000

константа, тут это минус 16

19…15

rs1

00010

откуда берём: sp это регистр номер 2

14…12

funct3

000

какое действие: 000 это сложение

11…7

rd

00010

куда кладём: тоже sp

6…0

opcode

0010011

вид команды: действие с константой

Склеиваем слева направо и режем по 4 бита:

Разбор addi sp, sp, -16 по битам: пять полей, шестнадцатеричные цифры, слово ff010113 и байты в файле
Разбор addi sp, sp, -16 по битам: пять полей, шестнадцатеричные цифры, слово ff010113 и байты в файле

ff010113. Вот и всё превращение.

Минус 16 в двенадцати битах это 111111110000, дополнительный код: старший бит единица, значит число отрицательное. Никакого отдельного знака в инструкции нет, знак это просто старший бит числа.

А теперь то, на чём я споткнулся. ff010113 это СЛОВО, как его печатает дизассемблер. В файле те же четыре байта лежат наоборот:

$ riscv-none-elf-objcopy -O binary probe.o probe.bin
$ od -A n -t x1 probe.bin
 13 01 01 ff

Младший байт первым. Я честно искал в файле последовательность ff 01 01 13 и не находил, пока не сообразил.

Пробую закодировать всю программу

Инструкция разобрана, дальше дело техники: взять первую программу серии и перевести её построчно. Девять строк, чего там.

_start:
    li      t0, 0x10000000
    la      t1, message
next_char:
    lbu     t2, 0(t1)
    beqz    t2, done
    sb      t2, 0(t0)
    addi    t1, t1, 1
    j       next_char
done:
    wfi
    j       done

Открываю справочник по RISC-V и начинаю искать. lbu есть. sb есть. addi есть, только что разбирали. wfi есть.

А li нет. И la нет. И beqz нет. И j нет.

Не «я плохо искал». Их нет в наборе команд, потому что процессор их не выполняет. Из девяти строк моей первой программы четыре настоящие, а пять придумал ассемблер.

Кто эти пятеро

Проверить легко: дизассемблер умеет показывать честно, если попросить его не приукрашивать. Ключ -M no-aliases запрещает ему печатать псевдоинструкции обратно, а numeric заставляет называть регистры номерами.

riscv-none-elf-objdump -d -M no-aliases,numeric hello.elf
80000000:  100002b7   lui    x5,0x10000
80000004:  00000317   auipc  x6,0x0
80000008:  02430313   addi   x6,x6,36
8000000c:  00034383   lbu    x7,0(x6)
80000010:  00038863   beq    x7,x0,80000020
80000014:  00728023   sb     x7,0(x5)
80000018:  00130313   addi   x6,x6,1
8000001c:  ff1ff06f   jal    x0,8000000c
80000020:  10500073   wfi
80000024:  ffdff06f   jal    x0,80000020
Девять строк исходника против десяти машинных инструкций: пять строк процессор не выполняет
Девять строк исходника против десяти машинных инструкций: пять строк процессор не выполняет

Девять строк исходника дали десять инструкций. Разбираем самозванцев.

j next_char это jal x0, next_char. Инструкция jal прыгает и заодно кладёт адрес возврата в регистр. Если положить его в x0, он пропадёт: x0 в RISC-V всегда ноль, запись в него никуда не сохраняется. Прыжок без возврата это тот же вызов, у которого адрес возврата выброшен.

beqz t2, done это beq x7, x0, done. Отдельной команды «сравнить с нулём» нет, есть «сравнить два регистра». Сравниваем с x0, который всегда ноль.

li t0, 0x10000000 это lui x5, 0x10000. Тут интереснее. Константа в инструкцию целиком не влезает: под неё 12 бит, а нам нужно 32. Поэтому загрузка большого числа это обычно две команды: lui кладёт старшие 20 бит, addi добавляет младшие 12.

Но нашей константе повезло: младшие 12 бит у неё нулевые, и addi не нужен. Одна инструкция вместо двух. Ассемблер посмотрел на число и решил за меня.

la t1, message это две инструкции, и с ней всё сложнее остальных. Ей я посвятил отдельный раздел ниже.

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

Где взялось число 36

la t1, message превратилась в пару:

auipc x6, 0x0
addi  x6, x6, 36

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

Вопрос: откуда взялось 36? Это расстояние от auipc до строки. Строка лежит по 0x80000028, auipc по 0x80000004, разница 36.

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

4:  00000317   auipc  x6,0x0
8:  00030313   addi   x6,x6,0

Ноль. В объектном файле на месте числа 36 стоит ноль, и его подставляет кто-то другой, позже.

Проверяем, кто именно

Тут легко показать неубедительно: положить рядом объектный файл и слинкованный, ткнуть в разные числа. Но между ними поменялись сразу две вещи, и стадия сборки, и адреса. Из такого сравнения строгого вывода не сделать.

Поэтому опыт устроен иначе. Объектный файл собирается ОДИН раз, и больше не трогается: его контрольная сумма печатается до и после. Дальше он ссылается дважды, двумя скриптами компоновщика, которые отличаются ровно одним: куда положить строку.

$ make where

объектный файл собран один раз, его сумма:
e8d509db1ad821b4ff2898f0863431f6 *obj.o

-- .rodata сразу за кодом (link.ld):
80000004:  00000317   auipc  t1,0x0
80000008:  02430313   addi   t1,t1,36    # 80000028 <message>

-- .rodata по 0x80001000 (link-shift.ld):
80000004:  00001317   auipc  t1,0x1
80000008:  ffc30313   addi   t1,t1,-4    # 80001000 <message>

объектный файл не пересобирался, сумма прежняя:
e8d509db1ad821b4ff2898f0863431f6 *obj.o
Один объектный файл, два скрипта компоновщика: сумма файла не меняется, а байты la разные
Один объектный файл, два скрипта компоновщика: сумма файла не меняется, а байты la разные

Один и тот же файл на входе, разные байты на выходе. Значит эти байты написал не ассемблер.

И заметьте: компоновщик переписал ОБЕ инструкции пары. addi со смещением, и auipc со старшей частью тоже, 0x0 против 0x1. Он дописывает не число, а адрес целиком, по кусочкам в двух командах.

Отсюда точная формулировка, и она важнее самого опыта. Не «ассемблер не умеет la», а так: la дорешивает тот, кто знает окончательный адрес. У настоящего тулчейна это компоновщик, тот самый из прошлой статьи.

Свой ассемблер

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

Сердце это пять функций, по одной на формат инструкции. Вот та, что кодирует addi:

def enc_i(op, f3, rd, rs1, imm):
    if not -2048 <= imm <= 2047:
        raise Asm("значение %d не влезает в 12 бит" % imm)
    return ((imm & 0xFFF) << 20) | (rs1 << 15) | (f3 << 12) | (rd << 7) | op

Это буквально таблица из начала статьи, записанная сдвигами. Остальные четыре формата отличаются только тем, как разрезана константа: у ветвлений и прыжков её биты раскиданы по слову так, чтобы номера регистров всегда стояли на одних и тех же местах. Железу удобно, человеку нет.

А вот развёртка la, ради которой всё затевалось:

if mn == "la":
    rd = reg(ops[0])
    delta = target(ops[1])
    hi, lo = split_hi_lo(delta)
    return [enc_u(AUIPC, rd, hi), enc_i(OP_IMM, 0, rd, rd, lo)]

Наш ассемблер её дописывает. Не потому, что он лучше: он выдаёт не объектный файл, а сразу плоский образ по известному адресу, и потому знает, где окажется строка. Разница не в качестве, а в задаче. Мы решаем задачу попроще.

В split_hi_lo спрятана единственная тонкость, на которой я посидел:

hi = (value + 0x800) >> 12
lo = value - (hi << 12)

Младшая часть в addi знаковая. Если её старший бит единица, addi вычтет 4096 вместо того, чтобы прибавить, и к старшей части надо заранее прибавить единицу. Отсюда + 0x800. Без этой поправки всё собирается и почти всё работает, а ломается на половине адресов.

Совпало

$ make compare
наш ассемблер: 63 байт
настоящий:     63 байт
БАЙТ В БАЙТ СОВПАДАЕТ

63 байта: 40 кода и 23 строка. Те самые числа из первой статьи.

Но совпадение байтов это ещё не работающая программа. Грузим наш образ в эмулятор и смотрим:

$ make run
запускаем то, что собрал наш ассемблер:
Привет, мир!

А теперь честно про «больше половины»

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

Справедливо. Первая программа серии крошечная и состоит почти целиком из подготовки: загрузить адрес, загрузить второй, прыгнуть. Посчитал по всем программам, которые накопились за пять статей.

Полная таблица, 26 файлов
файл                          всего псевдо  доля
01-hello/boot.s                   9      5   56%
02-number/boot.s                 70     37   53%
03-stack/boot-broken.s           34     15   44%
03-stack/boot-count.s           135     56   41%
03-stack/boot.s                  97     39   40%
04-elf/boot-decoy.s             104     43   41%
04-elf/boot-entry.s             104     43   41%
04-elf/boot.s                    97     39   40%
callcost/common.s               110     51   46%
callcost/common2.s              109     50   46%
callcost/probe.s                 19     15   79%
callcost/v1-frame16.s            20      6   30%
callcost/v2-frame8.s             19      5   26%
callcost/v3-iter.s               11      9   82%
callcost/v4-static.s             36     12   33%
callcost/w2-parse.s             134     43   32%

Плюс десять мелких проб из первой статьи.

Итог по всем: 1335 инструкций, из них 591 псевдо, это 44%.

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

Зато разброс оказался интереснее среднего: от 26% до 82%. И он не случайный.

Вверху списка v3-iter.s, итеративный Фибоначчи: 11 строк, и почти всё это li, mv, bnez. Внизу v2-frame8.s, плотная арифметика в цикле, 26%. Разница в том, чем программа занята. Там, где она в основном раскладывает значения по регистрам и решает, куда прыгнуть, выдумкой оказывается едва ли не каждая строка. Там, где считает, выдумывать нечего: add, sub, sll в наборе есть, и написаны они своими именами.

Так что правильная формулировка такая: почти половина того, что я пишу, в процессоре не существует, и доля тем выше, чем меньше кусок.

Чего наш ассемблер не умеет

Список короче, чем хотелось бы, и это честно.

Он не выдаёт объектный файл, только плоский образ по заранее известному адресу. Не умеет сжатое расширение C, макросы, выражения в операндах, внешние символы, секции кроме .text и .rodata.

Всё это в наших программах не встречается. Добавлять «на всякий случай» значит писать код, который никто не проверял, а таких у меня в репозитории и так хватает.

А это вообще про ассемблеры или только про RISC-V

44% выдумки это много. Но чьё это свойство, ассемблеров вообще или конкретно RISC-V, из наших замеров не следует. Взял тот же скелет и написал его на x86-64: загрузить адрес строки, взять байт, сравнить с нулём, сдвинуться, прыгнуть назад.

lea     message(%rip), %rsi        lea    0x0(%rip),%rsi
mov     $1, %rdi                   mov    $0x1,%rdi
movzbl  (%rsi), %eax               movzbl (%rsi),%eax
test    %al, %al                   test   %al,%al
je      done                       je     1c <done>
mov     %al, %dl                   mov    %al,%dl
inc     %rsi                       inc    %rsi
jmp     next_char                  jmp    e <next_char>
ret                                ret
nop                                nop

Слева то, что я написал, справа то, что показал дизассемблер. Все 10 написанных мнемоник остались собой: ни одна не развернулась в две и ни одна не превратилась в другую.

Строго говоря, в дизассемблере их вышло 12. Сверка честно закричала «расхождение», и я полез смотреть: 2 лишних nop в хвосте это ассемблер добил секцию до границы, objdump -h показывает .text размером 0x20 при выравнивании 16. Выравнивание, а не развёртка. Но пару минут я думал, что нашёл на x86 псевдоинструкцию.

Причём это не совпадение имён. ret на x86 настоящая инструкция с кодом c3, а не название для jalr x0, ra, 0. nop это байт 90, а не переодетый addi x0, x0, 0. И адрес строки lea берёт одной командой там, где RISC-V нужны две.

Значит 44% это про RISC-V, а не про ассемблеры. Набор команд там урезали нарочно, и всё, чего в нём не осталось, приходится придумывать поверх.

ARM посередине

x86 дал ноль, RISC-V пять из девяти. Между ними просилась третья точка, и ARM для неё подходит: набор там богаче риск-вишного, но беднее x86. Тот же скелет, 32-битный ARM:

   0:	e59f101c 	ldr	r1, [pc, #28]	@ 24 <done+0x8>
   4:	e3a00001 	mov	r0, #1
   8:	e5d12000 	ldrb	r2, [r1]
   c:	e3520000 	cmp	r2, #0
  10:	0a000001 	beq	1c <done>
  14:	e2811001 	add	r1, r1, #1
  18:	eafffffa 	b	8 <next_char>
  1c:	e1a00000 	nop			@ (mov r0, r0)
  20:	e12fff1e 	bx	lr
  24:	00000000 	.word	0x00000000

Написал 9 мнемоник, в выводе 9 строк кода. Но две из них не то, что я писал.

nop собрался как mov r0, r0. Дизассемблер подписывает это сам, в скобках справа: команды nop в наборе нет, есть безобидное присваивание регистра самому себе.

А первая строка это наша la под другим именем. Я написал ldr r1, =message, то есть «загрузи в r1 адрес строки». Вышло ldr r1, [pc, #28], то есть «загрузи в r1 то, что лежит в 28 байтах отсюда». Адреса строки в этой команде нет вообще. Он лежит рядом, в последней строке листинга: та самая .word по смещению 0x24. Её дописал ассемблер, сам, следом за кодом.

И там ноль. Потому что ассемблер этого адреса не знает:

00000024 R_ARM_ABS32       .rodata

Запись знакомая. Это та же просьба к компоновщику, что и у la: «сюда впиши адрес .rodata, я его не знаю». Другие буквы, другое железо, другая история, а тупик один и выход из него один.

Второй строкой objdump -r показывает ещё R_ARM_V4BX на bx lr, но это про другое: разрешение компоновщику подменить команду ради старых процессоров.

Итого 2 выдумки из 9.

Четвёртая архитектура, и она лежит на столе

Пока я это писал, у меня появился ESP32. Он на Xtensa, так что ни одна строчка серии на нём не пойдёт: другой набор команд, другие кодировки, другие 4 байта. Зато он настоящий, и на нём можно не рассуждать, а посмотреть.

Тот же скелет:

   0:	000031        	l32r	a3, fffc0000
   3:	140c      	movi.n	a4, 1
   5:	000352        	l8ui	a5, a3, 0
   8:	458c      	beqz.n	a5, 10 <done>
   a:	331b      	addi.n	a3, a3, 1
   c:	fffd46        	j	5 <next_char>
  10:	f03d      	nop.n
  12:	f00d      	ret.n

Первой строкой я написал movi a3, message, «положи в a3 адрес строки». Команды movi в выводе нет. Есть l32r, «прочитай слово, которое лежит вон там». Слово ассемблер завёл сам, в отдельной секции .literal, и оставил в нём ноль:

00000000 R_XTENSA_32       .rodata

Четвёртая архитектура за статью, и четвёртый раз одно и то же. la на RISC-V, ldr r1, =message на ARM, movi a3, message на Xtensa. Три разных имени для одной просьбы, которую ассемблер выполнить не может, потому что адрес узнаёт не он.

Тут придётся остановиться и сказать, что именно я считаю. У шести команд из восьми в выводе появился хвостик .n: movi.n, beqz.n, addi.n, nop.n, ret.n. Это не подмена, а та же команда в коротком виде, 2 байта вместо 3. Ровно то же самое умеет сжатое расширение C у RISC-V, которого на нашем стенде нет и которое я в риск-вишный счёт не включал. Включи я хвостики сюда, Xtensa обошла бы RISC-V, и сравнение сломалось бы не потому, что так на самом деле, а потому что я в одной колонке считал одно, а в другой другое.

Так что выдумка тут одна из восьми. Четыре замера:

набор

выдумок

из них загрузка адреса

остальных

из скольких

x86-64

0

0

0

10

Xtensa

1

1

0

8

ARM

2

1

1

9

RISC-V

5

1

4

9

Из этих выдумок ровно одна на каждой из трёх последних архитектур это загрузка адреса данных, и она одинаковая: ассемблер не знает адрес, компоновщик дописывает. x86 обошёлся без неё: lea берёт адрес по счётчику команд одной инструкцией. А вот остальные 4 выдумки у RISC-V это уже его собственная бедность набора: nop, ret, li, mv, beqz, j в процессоре не существуют.

Как это выглядит на кристалле

Дизассемблер это всё ещё бумага. Раз плата на столе, я собрал под неё первую программу серии: взять байт, положить в UART, сдвинуться, повторить. Ту самую, с которой всё начиналось, только на Xtensa.

Плата отвечает:

load:0x3ffb0000,len:32
load:0x40080400,len:44
entry 0x4008040c
Привет с железа!

Первые три строки печатает не моя программа, а ПЗУ кристалла. Оно читает из флеша заголовок образа, раскладывает два куска по памяти и прыгает на точку входа. Тут стоит вспомнить четвёртую статью, где я выяснял, кто читает точку входа в ELF, и на нашем стенде не нашёл никого. Здесь читатель есть, и он в кремнии.

А Привет с железа! печатает уже мой цикл, и в нём movi a3, message не исполняется. Исполняется l32r, читающая слово по адресу 0x40080400. Компоновщик положил туда 0x3ffb0000, адрес строки:

40080400 <_start-0xc>:
40080400:	3ffb0000
40080404:	60000000
40080408:	3ff4001c

Три слова, потому что movi в программе три, и ни одна не влезла в команду.

Чего эмулятор мне не рассказал

Дальше две вещи, за которые я заплатил прогонами, и обе про разницу между моделью и железом.

Первая программа серии на кристалле не работает. Она написана честно: положил байт в UART, сдвинулся, следующий. В QEMU это безупречно. На плате выходит вот что:

�а �� �е��Ё�Ђе�!�зџ��
�а �� ����������е�!��� ���
��� ������!���џ��е�� �� л���Ёѵ����!�зџ��

Кристалл на 240 МГц кладёт байты в буфер быстрее, чем провод на 115200 успевает их отдавать. Лишние теряются, и это ровно тот случай, который в первой статье было не увидеть. Модель UART в QEMU принимает байт мгновенно, сколько ни давай, поэтому программа без проверки работает, а откуда взялась проверка в чужом коде, остаётся непонятным.

Чинится тремя строчками: перед записью посмотреть, сколько байт ещё стоит в очереди, и подождать, пока станет ноль.

И ещё одно, случайное. Первая статья серии называлась «Двенадцать символов, двадцать один байт»: там печаталось «Привет, мир!», где 12 знаков занимают 21 байт, потому что русская буква стоит двух. Тогда это была арифметика на бумаге. Здесь она видна на просвет: теряется один байт из пары, и буква перестаёт быть буквой, а становится вот этой кракозяброй. Напиши я строку латиницей, вышло бы «Prvtszeea», и половина потерь прошла бы незамеченной.

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

Fatal exception (3): LoadStoreError
epc1=0x40080412, excvaddr=0x40080420

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

Обе поломки повторяются командами make nowait и make iram-byte.

А теперь RISC-V на живом кристалле

Пока я всё это собирал, у меня появился ESP32-C6. Он на RISC-V, том самом. Не Xtensa, не ARM, а наш набор команд. Тот же la, тот же beqz, те же 4 байта ff010113.

Тот же скелет, собранный нашим же ассемблером:

40800000:	00000517          	auipc	a0,0x0
40800004:	03450513          	addi	a0,a0,52
40800008:	600005b7          	lui	a1,0x60000
4080000c:	00054683          	lbu	a3,0(a0)
40800010:	02068063          	beqz	a3,40800030
40800014:	01c5a703          	lw	a4,28(a1)
40800018:	01075713          	srli	a4,a4,0x10
4080001c:	0ff77713          	zext.b	a4,a4
40800020:	fe071ae3          	bnez	a4,40800014
40800024:	00d5a023          	sw	a3,0(a1)
40800028:	00150513          	addi	a0,a0,1
4080002c:	fe1ff06f          	j	4080000c

Первые две строки это la a0, message, развёрнутая в auipc+addi. Та самая, которую ассемблер не может дописать, и которую дописывает компоновщик. На бумаге мы это разобрали в начале статьи. Здесь она исполняется.

entry 0x40800000
Привет с железа!

Привет из кристалла. Пять архитектур за одну статью, и последняя это та, ради которой начиналась серия.

Чем за это платят

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

RISC-V:   lbu  t2, 0(t1)       2 команды,  8 байт
          beqz t2, done
x86:      cmpb $0, (%rsi)      2 команды,  5 байт
          je   done

Команд столько же, байт меньше, и x86 ещё умеет заглянуть в память прямо из cmp, без отдельной загрузки. Выдумывать имена заставляет бедность набора, но из бедности набора не следует, что процессор сделает больше работы. Это две разные вещи.

Итог

Я думал, что ассемблер это таблица кодов. Таблица там есть: вместе с пятью кодировщиками форматов она заняла 43 строки из 478. Всё остальное это перевод с языка, на котором удобно писать, на язык, который есть у процессора.

Из 9 строк первой программы серии 4 оказались настоящими инструкциями. Одна из 5 оставшихся, la, не может быть дописана до конца даже ассемблером: последнее слово там за компоновщиком.

Код: github.com/Pro100lamer/uart-to-lang, тег article-05. Опыты повторяются командами make promise, make compare, make run, make where, make pseudo, make x86 и make arm. Последней нужен кросс-ассемблер, apt install binutils-arm-linux-gnueabihf.

Всё, что про ESP32 (Xtensa), лежит в 05-asm/esp32, вместе с описанием трёх ловушек. Там свои цели: make listing, make run, make nowait, make iram-byte. Если будете повторять, начните с make backup: остальные цели пишут во флеш поверх загрузчика платы.

ESP32-C6 (RISC-V) лежит в 05-asm/esp32c6. Собирается нашим же riscv-none-elf-as, прошивается через esptool.

А ещё у меня теперь есть RISC-V на столе. Четыре статьи подряд всё происходило в эмуляторе, и единственное доказательство, что программа работает, было окно терминала. Теперь можно проверять на живом кристалле, и в следующих статьях я собираюсь этим пользоваться.

В следующей статье попробую разобрать текст программы на части: не «взять строку и найти в таблице», а понять, где кончается одно слово и начинается другое. Пока мой ассемблер режет строки пробелами и запятыми, и на первом же выражении в операнде это развалится.

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

  • Спецификация RISC-V, том 1. Форматы инструкций в главе про RV32I, таблица псевдоинструкций в приложении. По-английски, но таблицы читаются без языка.

  • RISC-V ASM manual. Список псевдоинструкций с тем, во что каждая разворачивается.

  • Документация GNU as. Директивы, которые я в своём ассемблере повторял.