Четыре статьи я писал текст, звал 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, messagenext_char: lbu t2, 0(t1) beqz t2, done sb t2, 0(t0) addi t1, t1, 1 j next_chardone: 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,0x1000080000004: 00000317 auipc x6,0x080000008: 02430313 addi x6,x6,368000000c: 00034383 lbu x7,0(x6)80000010: 00038863 beq x7,x0,8000002080000014: 00728023 sb x7,0(x5)80000018: 00130313 addi x6,x6,18000001c: ff1ff06f jal x0,8000000c80000020: 10500073 wfi80000024: 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, 0x0addi x6, x6, 36
auipc кладёт в регистр адрес самой себя плюс старшие биты смещения, addi добавляет младшие. Пара считает адрес относительно текущего места, а не абсолютный, поэтому программу можно двигать по памяти целиком.
Вопрос: откуда взялось 36? Это расстояние от auipc до строки. Строка лежит по 0x80000028, auipc по 0x80000004, разница 36.
Но чтобы это посчитать, надо знать, где окажется строка. А ассемблер этого не знает: он собирает объектный файл, который потом ещё будут раскладывать по памяти. Заглянем в него:
4: 00000317 auipc x6,0x08: 00030313 addi x6,x6,0
Ноль. В объектном файле на месте числа 36 стоит ноль, и его подставляет кто-то другой, позже.
Проверяем, кто именно
Тут легко показать неубедительно: положить рядом объектный файл и слинкованный, ткнуть в разные числа. Но между ними поменялись сразу две вещи, и стадия сборки, и адреса. Из такого сравнения строгого вывода не сделать.
Поэтому опыт устроен иначе. Объектный файл собирается ОДИН раз, и больше не трогается: его контрольная сумма печатается до и после. Дальше он ссылается дважды, двумя скриптами компоновщика, которые отличаются ровно одним: куда положить строку.
$ make whereобъектный файл собран один раз, его сумма:e8d509db1ad821b4ff2898f0863431f6 *obj.o-- .rodata сразу за кодом (link.ld):80000004: 00000317 auipc t1,0x080000008: 02430313 addi t1,t1,36 # 80000028 <message>-- .rodata по 0x80001000 (link-shift.ld):80000004: 00001317 auipc t1,0x180000008: 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) >> 12lo = 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),%rsimov $1, %rdi mov $0x1,%rdimovzbl (%rsi), %eax movzbl (%rsi),%eaxtest %al, %al test %al,%alje done je 1c <done>mov %al, %dl mov %al,%dlinc %rsi inc %rsijmp next_char jmp e <next_char>ret retnop 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 ldrr1, [pc, #28]@ 24 <done+0x8> 4:e3a00001 movr0, #1 8:e5d12000 ldrbr2, [r1] c:e3520000 cmpr2, #0 10:0a000001 beq1c <done> 14:e2811001 addr1, r1, #1 18:eafffffa b8 <next_char> 1c:e1a00000 nop@ (mov r0, r0) 20:e12fff1e bxlr 24:00000000 .word0x00000000
Написал 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 l32ra3, fffc0000 3:140c movi.na4, 1 5:000352 l8uia5, a3, 0 8:458c beqz.na5, 10 <done> a:331b addi.na3, a3, 1 c:fffd46 j5 <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:32load:0x40080400,len:44entry 0x4008040cПривет с железа!
Первые три строки печатает не моя программа, а ПЗУ кристалла. Оно читает из флеша заголовок образа, раскладывает два куска по памяти и прыгает на точку входа. Тут стоит вспомнить четвёртую статью, где я выяснял, кто читает точку входа в ELF, и на нашем стенде не нашёл никого. Здесь читатель есть, и он в кремнии.
А Привет с железа! печатает уже мой цикл, и в нём movi a3, message не исполняется. Исполняется l32r, читающая слово по адресу 0x40080400. Компоновщик положил туда 0x3ffb0000, адрес строки:
40080400 <_start-0xc>:40080400:3ffb000040080404:6000000040080408:3ff4001c
Три слова, потому что movi в программе три, и ни одна не влезла в команду.
Чего эмулятор мне не рассказал
Дальше две вещи, за которые я заплатил прогонами, и обе про разницу между моделью и железом.
Первая программа серии на кристалле не работает. Она написана честно: положил байт в UART, сдвинулся, следующий. В QEMU это безупречно. На плате выходит вот что:
�а �� �е��Ё�Ђе�!�зџ���а �� ����������е�!��� ������ ������!���џ��е�� �� л���Ёѵ����!�зџ��
Кристалл на 240 МГц кладёт байты в буфер быстрее, чем провод на 115200 успевает их отдавать. Лишние теряются, и это ровно тот случай, который в первой статье было не увидеть. Модель UART в QEMU принимает байт мгновенно, сколько ни давай, поэтому программа без проверки работает, а откуда взялась проверка в чужом коде, остаётся непонятным.
Чинится тремя строчками: перед записью посмотреть, сколько байт ещё стоит в очереди, и подождать, пока станет ноль.
И ещё одно, случайное. Первая статья серии называлась «Двенадцать символов, двадцать один байт»: там печаталось «Привет, мир!», где 12 знаков занимают 21 байт, потому что русская буква стоит двух. Тогда это была арифметика на бумаге. Здесь она видна на просвет: теряется один байт из пары, и буква перестаёт быть буквой, а становится вот этой кракозяброй. Напиши я строку латиницей, вышло бы «Prvtszeea», и половина потерь прошла бы незамеченной.
Прочитать байт из памяти команд нельзя. Я сначала положил строку рядом с кодом, в ту же область. Кристалл ответил сам:
Fatal exception (3): LoadStoreErrorepc1=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 auipca0,0x040800004:03450513 addia0,a0,5240800008:600005b7 luia1,0x600004080000c:00054683 lbua3,0(a0)40800010:02068063 beqza3,4080003040800014:01c5a703 lwa4,28(a1)40800018:01075713 srlia4,a4,0x104080001c:0ff77713 zext.ba4,a440800020:fe071ae3 bneza4,4080001440800024:00d5a023 swa3,0(a1)40800028:00150513 addia0,a0,14080002c:fe1ff06f j4080000c
Первые две строки это la a0, message, развёрнутая в auipc+addi. Та самая, которую ассемблер не может дописать, и которую дописывает компоновщик. На бумаге мы это разобрали в начале статьи. Здесь она исполняется.
entry 0x40800000Привет с железа!
Привет из кристалла. Пять архитектур за одну статью, и последняя это та, ради которой начиналась серия.
Чем за это платят
Я чуть не написал, что богатый набор просто лучше. Пошёл проверять, и в нашем цикле x86 действительно уложился в меньшее число байт:
RISC-V: lbu t2, 0(t1) 2 команды, 8 байт beqz t2, donex86: 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), лежит в work/05-asm/esp32, вместе с описанием трёх ловушек. Там свои цели: make listing, make run, make nowait, make iram-byte. Если будете повторять, начните с make backup: остальные цели пишут во флеш поверх загрузчика платы.
ESP32-C6 (RISC-V) лежит в work/05-asm/esp32c6. Собирается нашим же riscv-none-elf-as, прошивается через esptool.
А ещё у меня теперь есть RISC-V на столе. Четыре статьи подряд всё происходило в эмуляторе, и единственное доказательство, что программа работает, было окно терминала. Теперь можно проверять на живом кристалле, и в следующих статьях я собираюсь этим пользоваться.
В следующей статье попробую разобрать текст программы на части: не «взять строку и найти в таблице», а понять, где кончается одно слово и начинается другое. Пока мой ассемблер режет строки пробелами и запятыми, и на первом же выражении в операнде это развалится.
Для тех, кто хочет разобраться сам
-
Спецификация RISC-V, том 1. Форматы инструкций в главе про RV32I, таблица псевдоинструкций в приложении. По-английски, но таблицы читаются без языка.
-
RISC-V ASM manual. Список псевдоинструкций с тем, во что каждая разворачивается.
-
Документация GNU as. Директивы, которые я в своём ассемблере повторял.
ссылка на оригинал статьи https://habr.com/ru/articles/1076022/