Среди бесчисленных режимов адресации архитектуры х86 существует один такой…
Впрочем, почему «бесчисленных» режимов? Если разобраться, то их немного. Со времен первого процессора 8086 адресация укладывалась в байт, который имел аббревиатуру MODRM, где «MOD» - это собственно режим адресации (т.е. mode), «R» - регистр и «M» - очевидно, память (memory).
Если не рассматривать дальнейшее совершенствование системы адресации с помощью SIB-байта, то, поскольку под MODE в MODRM-байте выделено всего два бита, получается, что возможны всего-навсего четыре режима адресации.

Один из них задает просто пересылку регистра в регистр, без использования адресации как таковой (если задан mode из двух единичных битов), например,
код 8BD3 это mov edx,ebx.
А остальные три подразумевают вычисление адреса, который или просто записан в регистре (включая частный случай не регистра, а прямо константы-адреса в коде команды) или получается прибавлением к содержимому регистра константы, записанной в коде команды. Причем такая константа занимает в коде команды или четыре байта или байт, расширяемый со знаком.

Не будем касаться всяких тонкостей адресации вроде восьмибайтовых адресов-констант для x86-64 или уже упомянутого SIB-байта. Далее речь пойдет только об одном из четырех режимов адресации, у которого mode равен 01 и в кодах команды записан байт расширяемой со знаком адресной константы, прибавляемой к содержимому регистра.
Из-за того, что адрес записан в регистре, а добавка-константа занимает лишь байт, команды с таким режимом адресации короче «обычных», но я не обращал на них особого внимания, поскольку считал, что данный режим применим только при передаче параметров подпрограмме через стек и для части локальных данных в стеке, а также для доступа к отдельным полям агрегатов разнотипных данных.

В самом деле, если перед вызовом подпрограммы необходимые параметры записать в стек, а после вызова переслать регистр стека RSP в RBP и выделить память в стеке для локальных данных, то параметры и локальные данные окажутся «по разные стороны» от текущего значения RBP. И, например, теперь к параметрам можно обращаться как к [RBP]+8, [RBP]+16 и т.д., а к части локальных данных как, например, к [RBP]-8, [RBP]-10, [RBP]-70h и т.п. Т.е. как раз удобно применять режим прибавления байтовой константы со знаком и, тем самым, уменьшать общий размер кода.

Но недавно пришла мысль, что такой же режим адресации можно использовать и в другом случае. Впрочем, обо все по порядку.
Исторически сложилось так, что я использую компилятор, который сам же сопровождаю и по мере сил совершенствую. Оптимизация генерируемого компилятором кода для меня это очень интересная задача и, заодно, нечто вроде хобби. Я не верю заявлениям, что теперь достаточно выдать результат в виде кода LLVM, а далее некий волшебный оптимизатор сгенерирует идеальный код для x86 и заботиться об оптимизации команд на самом низком уровне компиляторам более не надо. Не бывает идеального кода. А кроме того, в глубине души, я считаю настоящим компилятором только тот, который доводит исходный текст программы прямо до исполняемых команд, а не перекладывает с одного языка (со своими «тараканами» и ограничениями) на другой, умножая и число «тараканов» и число ограничений.

Так вот, я с удовольствием занимаюсь оптимизацией генерируемого кода и процесс этот бесконечен. Технология оптимизации самая простая. Получаю листинг программы, включающий исходный текст, сгенерированные коды и эти же коды в виде псевдоассемблера. А затем просто иду по листингу, пытаясь представить, как бы я сам писал этот код на ассемблере, где бы я улучшил этот код и анализируя, почему компилятор так не улучшил. Если догадался, как можно улучшить – вношу очередное дополнение в генератор кода. Замечу, что в основном, это оптимизация на очень низком уровне, когда почти все команды уже сгенерированы, но адреса переходов еще не вычислялись и поэтому команды еще можно менять или выбрасывать.
Очень много оптимизаций кода на этом самом нижнем уровне связаны с отслеживанием состояния и содержимого регистров. Например, самый очевидный случай – в регистр засылается некоторое значение, а выше уже есть точно такая же команда и регистр и значение за это время не изменились, так сказать, не «испортились». В этом случае текущую анализируемую команду можно просто выбросить из программы – она ничего не меняет.

И я обратил внимание, что довольно часто требуется адрес какой-либо переменной, а в текущем состоянии регистров нет такого адреса и поэтому использовать вместо адреса-константы адрес в регистре не получается. Но может быть в регистрах есть адрес СОСЕДНЕЙ переменной? Ведь если она имеет похожий адрес (плюс 127- минус 128 байт) – то бинго! – можно использовать тот самый короткий режим адресации с байтом-константой.
И действительно, оказалось, что в реальных программах такие случаи не редкость. Причем похожий адрес может оказаться даже не у переменной, введенной программистом, а у служебной переменной, выделенной самим компилятором. Хотя на этапе компиляции отдельных модулей окончательные адреса еще неизвестны и будут определены лишь редактором связей, относительные адреса локальных данных уже известны и, следовательно, разница адресов между ними уже не изменится.

Могут возразить, что при таком уменьшении кода возрастает зависимость команд через содержимое регистров, а, следовательно, ухудшается параллельность их выполнения. Да, такое может быть. Но тестирование реальных программ не показало ощутимого или вообще какого-либо замедления. Во-первых, оптимизируемые команды не обязательно оказываются смежными, а, значит их влияние на параллельность выполнения снижается. А во-вторых, на мой взгляд, сейчас недооценивается важность компактности кода для скорости работы. Если в далекие времена процессора 8086 чтение команд из памяти занимало тактов 25, а выполнение команды – тактов 10, то сейчас выполнение команды может занимать 1-2 такта, а чтение кода из памяти – тактов 200 (если не все 400). Поэтому сокращение кода сейчас больше влияет на скорость работы, чем раньше.

Напоследок, приведу пример использования адресации с константой-байтом, взятый из кода реальной программы. Я понимаю, что большинство программистов нового поколения довольно смутно представляет себе какими именно машинными инструкциями обеспечивается результат работы их программ и уж точно никто не будет разбираться в двоичном коде выполняемых команд. И, тем не менее, на пальцах и небольшом фрагменте кода постараюсь пояснить все выше сказанное. При этом в примере двоичный код и код на псевдоассемблере не совпадают. Я сделал такой режим листинга намеренно, чтобы видеть, что нужно было сгенерировать по логике программы и что реально сгенерировал компилятор. Иначе отладка компилятора сильно затрудняется.

Ниже приведена небольшая часть стандартной подпрограммы поиска максимума функции одной переменной на заданном отрезке. Все переменные имеют тип double. На листинге слева направо выведен: номер строки, глубина вложенности (блоков и подпрограмм), двоичный код, исходный текст прописными буквами и текст псевдоассемблера.

Итак, приведенный фрагмент начинается с безусловного перехода и следующей за ним метки @8, введенной самим компилятором для ветви условного перехода. Для простоты анализа считается, что после любой метки состояние регистров не определено, поскольку управление на метку может попасть из любого места программы.
Первая команда загружает в регистр rbx адрес-константу переменной A2. Реально значение загружается лишь в младшую половину rbx (в регистр ebx), а старшая половина rbx при этом автоматически обнуляется.
Следующая команда должна сделать загрузку адреса переменной A1, но оказывается, что адрес A1 лишь на 8 байт меньше A2, поэтому вместо команды mov rdx,offset A1 генерируется более короткая команда lea edx,[rbx]-8.
Затем идут загрузки адресов переменных R3 и R1 и их, оказывается, опять можно выразить через адрес A1, еще сохранившийся в регистре edx.
А вот в следующей загрузке адреса константы 2 не повезло: память для констант компилятор выделил раньше, чем память для переменных и поэтому адрес этой константы имеет значение 1E8h, что слишком далеко от адресов других переменных и использовать короткий режим адресации в этой команде не удается.
Зато уже в следующей команде загрузки адреса переменной X опять ее адрес можно выразить через адрес переменной R1, так и продолжающийся оставаться в регистре rdx.
Следующая загрузка адреса переменной A2 снова может быть выражена через адрес одной из соседних переменных. А в следующей загрузке адреса переменной X еще больше повезло – нужный адрес так и остался в регистре rdi и команду загрузки можно заменить более короткой двухбайтовой пересылкой из rdi в требуемый rdx.
Наконец любопытна последняя оптимизация в данном фрагменте. Здесь в команде вообще нет загрузки адреса, а есть обычная загрузка значения из переменной EPSILON. Но адрес этой переменной случайно оказался лишь на 128 байт меньше, чем адрес переменной X, еще сохранившийся в регистре rdx. Поэтому вместо команды mov rbx,EPSILON длиной 7 байт, сгенерирована команда mov rbx,[rdx]+0ffffff80h, длиной лишь 4 байта.

Таким образом, в этом небольшом фрагменте, где часто использованы локальные статические переменные, удалось сократить практически «на ровном месте» 7 команд в целом на 2х5+3х2=16 байт по сравнению с обычными загрузками адресов. Причем просто из анализа исходного текста никак не следует, что одни адреса можно выразить через другие и это сократит длину кода.
Как говорил персонаж рассказа Дж. Лондона «Малыш видит сны»: «И все благодаря покривившимуся колесику и зоркому глазу». Правда, в данном случае помогло не кривое колесо рулетки, как в рассказе, а подходящий режим адресации.

Комментарии (15)


  1. NickViz
    02.05.2022 12:24
    +2

    насколько реально сейчас проводить разработку на ассемблере? Почему после декомпиляции межделмашевского компилятора (и в процессе доработки получения опыта) его не перевели на что-то более удобное (С там, или что). Как много людей могут этот компилятор развивать/поддерживать и с какими трудозатратами? что будет когда вы ум^w выйдете на пенсию?


    1. Dukarav Автор
      02.05.2022 13:44

      А почему Вы привязались именно к «межделмашевскому» (кстати, что это?) компилятору?
      А сколько вообще людей в мире могут компиляторы поддерживать/развивать и с какими трудозатратами? На мой взгляд, ассемблер здесь не самое сложное препятствие. И потеря энтузиастов для любого проекта грозит гибелью. Краткая история этого компилятора изложена здесь


      1. NickViz
        02.05.2022 15:17
        +1

        вы зря на меня ээ обиделись что-ли. просто я писал когда-то на ассемблере и предстовляю себе сложность разработки и поддержки чего либо нетривиального на асме. я могу понять почему пишут драйвер на асме, почему добавляют в библиотеку вставки на асме, которые могут SSE наиболее эффективно использовать (или сложение с переносом, где перенос во флаге переполнения что-ли). а вот почему люди пишут компилятор (да, это достаточно сложная программа, которую не все осилят) на ассемблере - немного не понятно. где-же счас взять энтузиастов писать всё на ассемблере :-) В общем, не сердитесь, сложный проект, который развивается и приносит пользу - большой плюс автору(ам). межделмаш - была такая калька с IBM.


        1. napa3um
          02.05.2022 21:43

          В своё время я был энтузиастом ассемблера, и даже уже зная и используя высокоуровневые языки и среды разработки пытался поддерживать и маленькое и уютное рабочее окружение хакера и кракера :). Писал довольно комплексные многооконные утилитки с помощьюи RadAsm в качестве IDE и MASM32 (выковырянном из MS VC++) и FASM в качестве компиляторов. Компиляторы, к слову, обладали довольно жирными возможностями написания макросов, и облегчали рутину не только вызова функций (со всеми этими проталкиваниями аргументов в стек), но даже позволяли писать конструкции типа IF или FOR :). В целом по степени выразительности они уже мало отличались от языка C (без плюсов, который, собственно, и был/есть эдаким «кроссплатформенным ассемблером»). Но сейчас, конечно, комплексность прикладных программ такова, что средства управления сложностью в ассемблере остаются уместными только для очень системного и «локального» программирования :).


      1. emusic
        03.05.2022 14:32

        Хм. Если б я, в эпоху активного программирования на ассемблере, сподобился сделать свой компилятор с мало-мальски удобного языка, то и развивал бы его, и дальше все писал только на этом языке. :)


        1. Dukarav Автор
          03.05.2022 17:10

          Да, именно так все и вышло в моем случае. Такой подход имеет много хлопот и неудобств. Но зато можно найти любую ошибку и разобраться в любой особенности.

          Ну и ощутить себя творцом, а заодно и полным хозяином :)


  1. napa3um
    02.05.2022 12:32
    +5

    Поэтому сокращение кода сейчас больше влияет на скорость работы, чем раньше.

    Всё-таки меньше, ибо это всё будет читаться из многоуровневого кэша (на поведение которого в многопроцессных окружениях вообще невозможно контролируемо вилять из одного пользовательского потока исполнения), куда данные черпаются такими объёмами, что подобные оптимизации выглядят попыткой стереть пылинку с самосвала, чтобы он стал легче и поехал быстрее :). ИМХО.


  1. edo1h
    02.05.2022 19:15
    +2

    Если в далекие времена процессора 8086 чтение команд из памяти занимало тактов 25, а выполнение команды – тактов 10, то сейчас выполнение команды может занимать 1-2 такта, а чтение кода из памяти – тактов 200 (если не все 400). Поэтому сокращение кода сейчас больше влияет на скорость работы, чем раньше.

    не совсем корректное сравнение. скорость чтения/записи в память очень высокая, например:
    Twelve 64-bit DDR5 memory channels would theoretically increase the memory bandwidth available to Genoa processors to a whopping 460.8 GB/s per socket
    а вот задержка уже лет двадцать остаётся в диапазоне 10-20 нс, то есть десятки тактов процессора.
    сомневаюсь, что подобные оптимизации окажут насколько-либо измеримое влияние на производительность, они не изменят число промахов кэша, а само по себе уменьшение размера кода даст гомеопатический прирост производительности.
    но почитать было интересно )


  1. perfect_genius
    02.05.2022 23:27

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


    1. Dukarav Автор
      03.05.2022 09:30

      Мне не попадался такой подход, поэтому и решил поделится мыслью.

      Производительность все-таки растет, хотя бывает и трудно понять отчего ))

      В основной программе, которой мы занимаемся, такой подход позволил сократить более 10Кбайт кода


  1. ARad
    03.05.2022 07:08

    Замечу как раз такие "оптимизации" может делать только компилятор, при программировании непосредственно на ассемблере я бы предпочел не делать такие "оптимизации" руками. Код легко может стать невалидным при малейшем изменении взаимных смешений.

    И еще насколько я помню комманда lea edx,[rbx]-8 должна быть записана как lea edx,[rbx - 8] (смещение внутри скобок), тоже самое насчет mov rbx,[rdx]+0ffffff80h.


    1. Dukarav Автор
      03.05.2022 09:35

      Совершенно верно. Это не для программирования вручную. Ведь пришлось даже оставить вывод псевдоассемблера без оптимизации, иначе бывает непонятно, что вообще за команды лепятся ))

      Запись адресации на ассемблере - это дело вкуса. На мой взгляд, в скобках должен быть только регистр или пара регистров с масштабным коэффициентом. Это и ближе к замыслу системы кодировки команд x86.

      А кроме того, так изначально было принято в трансляторе с ассемблера, который я тоже имею честь сопровождать ))


      1. qw1
        04.05.2022 00:54

        Квадратные скобки имеют семантику чтения из памяти по адресу внутри этих скобок.
        Наример,
        mov eax, ebx
        против
        mov eax, [ebx]

        Хотя наверное ваша запись имеет происхождение от си-шного синтаксиса array1[i]:
        mov eax, array1[ebx]
        которое потом как-то превратилось в
        mov eax, [ebx]+array1


        1. Dukarav Автор
          04.05.2022 05:40

          В используемом мною ассемблере можно писать mov eax,[ebx]+array1 и mov eax,array1[ebx] и даже mov array1+array2[ebx]-array1.

          Т.е. внутри скобок только регистры и масштаб, а снаружи - выражение для констант


          1. emusic
            04.05.2022 11:10
            +1

            Кривоват такой синтаксис. На мой взгляд, лучший синтаксис был в режиме Ideal ассемблера TASM - очень логичный, с минимумом разночтений.