Четыре статьи я писал текст, звал gcc и получал работающую программу. Что происходит между текстом и байтами, меня не занимало: работает же.
В конце прошлой статьи я пообещал разобраться, как из строчки addi sp, sp, -16 получаются те самые 4 байта ff010113. Сел разбираться и управился за вечер: там пять полей и никакой глубины.
А потом решил закодировать так всю первую программу серии. И выяснил, что кодировать в ней почти нечего.
▍ Навигация по серии
Часть 5. Свой ассемблер ← вы здесь
Что получится в конце
Ассемблер на Python, который собирает первую программу серии и выдаёт байты, совпадающие с выводом настоящего тулчейна. Не «похожие», а совпадающие: проверяется командой cmp.
И ответ на вопрос, которого я не задавал: сколько строк моей программы вообще есть в таблице кодов процессора.
Обещанные четыре байта
addi sp, sp, -16 это «взять sp, прибавить к нему минус 16, положить обратно в sp». Инструкций с константой в RISC-V много, и все они устроены одинаково: 32 бита нарезаны на пять полей.
биты | поле | значение | что значит |
|---|---|---|---|
31…20 |
|
| константа, тут это минус 16 |
19…15 |
|
| откуда берём: |
14…12 |
|
| какое действие: 000 это сложение |
11…7 |
|
| куда кладём: тоже |
6…0 |
|
| вид команды: действие с константой |
Склеиваем слева направо и режем по 4 бита:

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

Один и тот же файл на входе, разные байты на выходе. Значит эти байты написал не ассемблер.
И заметьте: компоновщик переписал ОБЕ инструкции пары. 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. Директивы, которые я в своём ассемблере повторял.

