fbpx

Каталог статей

Каталог статей для размещения статей информационного характера

Как выучить

Обучение чтению языка ассемблера x86

Обучение чтению языка ассемблера x86

16-разрядный микропроцессор Intel 8086, 1978 год (источник: RodolfoNeres через Wikimedia Commons)

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

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

Но самое главное, изучение языка ассемблера может быть очень веселым.

Обычно чтение языка ассемблера совсем не весело

К сожалению, большинство из нас знакомятся с языком ассемблера только после того, как что-то пошло не так, ужасно не так, когда мы сталкиваемся с чем-то вроде этого:

Вот как выглядит ошибка сегментации в отладчике. Отладчик показывает мне язык ассемблера, потому что не знает, что еще мне показать. Ошибка сегментации” означает, что одна из инструкций языка ассемблера, например, приведенная выше строка movb $0x6c, 0x1(%rax), попыталась записать в часть памяти, которая ей не разрешена.

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

Преобразование моего собственного кода на ассемблер

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

Я разработчик Ruby, и поэтому мне было интересно узнать, как будет выглядеть мой Ruby-код, переведенный на язык ассемблера. К сожалению, интерпретатор Ruby (по крайней мере, стандартная “MRI” версия Ruby) никогда не делает этого. Вместо этого интерпретатор Ruby сам компилируется в машинный язык и запускает мой код с помощью виртуальной машины. Но я хотел посмотреть, что будет делать настоящая машина, а не виртуальная.

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

Я начал с написания очень простой программы, которая прибавляет 42 к заданному целому числу:

Это был и Ruby код:

и код Crystal:

Оба, конечно, выдавали один и тот же результат. Но только Crystal мог создать копию на языке ассемблера:

Это создавало файл add_forty_two.s, который содержал 10,000s строк кода на языке ассемблера. (Я открыл файл add_forty_two.s в текстовом редакторе и поискал “add_forty_two”, имя моей функции. Сначала я нашел сайт вызова, код, который вызывает мою функцию add_forty_two:

Я вернусь к этому немного позже. Снова поискав, я нашел версию моей функции на языке ассемблера x86:

Затем я удалил все директивы ассемблера, такие как .globl и .cfi_offset. Когда-нибудь будет интересно узнать об этом, но я хотел сосредоточиться на реальных машинных инструкциях. И наконец, я вставил оставшийся код внутрь моей функции Ruby.

Затем я увидел, что на самом деле делает мой компьютер, когда выполняет add_forty_two:

Язык ассемблера x86: Почти легко читать

Этот код почти легко прочесть. Я могу догадаться, что означает каждая инструкция: push, add, move и т.д., но я не могу понять, что здесь происходит. mov, вероятно, означает перемещение, но что перемещает компьютер? И откуда куда?

Язык ассемблера x86 был разработан в Венгрии?

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

Я жил в Будапеште около года в 1992 году и сумел стать разговорным на венгерском языке, хотя с тех пор я его полностью забыл. Красивый язык, венгерский, как известно, труден для изучения иностранцами. Его грамматика не похожа ни на итальянский, ни на французский, ни на другие романские языки, ни на русский, ни на другие славянские языки Восточной Европы.

Единственная часть венгерской грамматики, которую я до сих пор помню, заключается в том, что вместо того, чтобы использовать отдельные слова для предлогов, таких как внутри, снаружи и т.д., вы добавляете различные суффиксы к целевому слову. Например, “внутри дома” будет házban. Дом – это ház, а внутри – ban. Аналогично “в Будапеште” будет Budapesten – суффикс en означает “в”. Код языка ассемблера x86 напоминает мне венгерский. Вы не используете mov для перемещения чего-либо; вы используете movq . Вы не добавляете что-то, вы используете инструкцию addl.

Оказывается, ассемблер x86 намного проще венгерского; есть только несколько простых суффиксов, которые относятся к размеру данных, с которыми вы работаете. Вот два примера:

Инструкция addl на самом деле означает “add long”, где “long” означает 4-байтовое или 32-битовое значение. В Crystal это соответствует типу Int32, который является типом целых чисел по умолчанию и типом, который использует мой метод add_forty_two.

Вот еще один пример:

Буква q обозначает “четверное” слово, или 8-байтовое или 64-битовое значение. Большинство x86-кода в наши дни работает с 64-битными или 32-битными значениями, поэтому чаще всего вы увидите инструкции, заканчивающиеся на q или l . Другие суффиксы – w для слова (16 бит или 2 байта) или b для 1 байта или 8 бит.

Регистры x86

Но как насчет всех операндов инструкций? Почему все они имеют префикс “%”, например %rsp или %edi? Чтение языка ассемблера x86 также напоминает мне чтение кода Perl. Множество знаков препинания без видимой причины. Подобно Perl, язык ассемблера x86 использует сигилы или магические знаки препинания для указания типа значения каждого операнда.

Вот два моих примера инструкций:

Здесь символ “$” означает, что 42 – это литеральное или “немедленное” значение. Как вы уже догадались, это строка кода, которая добавляет 42 к чему-то. Но к чему она прибавляет? По символу “%” мы видим, что код x86 добавляет 42 в регистр edi.

А что такое регистр? В двух словах, микропроцессор внутри вашего компьютера использует регистры для хранения значений во время выполнения кода. Поэтому приведенная выше инструкция добавляет 42 к любому значению, содержащемуся в регистре edi, и сохраняет его обратно в edi .

Вот второй пример:

Эта инструкция, movq , обращается к двум регистрам: rsp и rbp . Как вы можете догадаться, она перемещает значение, находящееся в регистре rsp, в регистр rbp.

Сколько всего регистров? Как они называются? Давайте посмотрим на них с помощью LLDB:

Вы видите, что в процессоре Intel моего Mac более 20 регистров, каждый из которых содержит 64-битное или 8-байтовое значение. LLDB показывает значения в шестнадцатеричном формате. Сегодня у меня нет времени объяснять, для чего используются все эти регистры, но вот несколько основных моментов:

rax, rbx, rcx и rdx – это регистры общего назначения, используемые для хранения промежуточных значений, загруженных из памяти или используемых во время какого-либо вычисления.

rsp – указатель стека, который хранит в памяти местоположение вершины стека.

rbp – указатель базы, в памяти которого находится основание текущего кадра стека.

rip – это указатель инструкции, который содержит в памяти местоположение следующей инструкции для выполнения

и rflags – серия флагов, используемых, например, инструкциями сравнения.

На самом деле, в современном микропроцессоре x86 существует гораздо больше регистров; LLDB показывает мне только наиболее часто используемые регистры. Для полного объяснения, окончательным руководством по всему этому является Руководство разработчика программного обеспечения Intel. К счастью, код моей функции на языке ассемблера использует только несколько регистров. Мне не нужно понимать их все.

Но подождите минутку. Почему моя инструкция addl ссылается на регистр edi? Его нет в списке регистров, показанном LLDB. Где происходит эта операция сложения? Какой регистр она использует?

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

Что? Это безумие! Зачем любому языку программирования использовать префиксы для обозначения размера данных в одном месте, а затем использовать суффиксы для обозначения того же самого в другом месте? Чтобы понять это, вы должны помнить, что синтаксис языка ассемблера не был разработан в одночасье. Вместо этого он постепенно развивался в течение многих лет. Первоначально для регистров использовались простые двухбуквенные имена: ax , bx , cx . dx , sp и ip . Такими были регистры оригинального 16-разрядного микропроцессора 8086 в 1970-х годах. Позже, в 1980-х годах, когда Intel создала 32-битные микропроцессоры, начиная с 80386, они переименовали (или расширили) регистры ax , bx , cx и т.д., превратив их в eax , ebx , ecx и т.д.. Позже они были переименованы в rax , rbx и т.д. для 64-битных процессоров.

Как вы можете видеть здесь, даже сегодня в ассемблере x86 один и тот же регистр может называться по-разному, например al или ah для 8-битных, ax для 16-битных, eax для 32-битных и rax для 64-битных.

Язык ассемблера x86: Читает слева направо, за исключением случаев, когда читает справа налево

Возвращаясь к инструкции перемещения, как мы узнаем, в какую сторону происходит перемещение?

То есть, перемещает ли эта инструкция данные из rsp в rbp? Или из rbp в rsp? Читает ли она слева направо или справа налево?

Это может быть и так, и так! Оказывается, существует две версии синтаксиса x86: “синтаксис AT&T или GNU Assembler (GAS)”, который я использовал до сих пор, а также синтаксис “Intel”. GAS читает слева направо:

синтаксис AT&T/GAS

Но не менее правильным и распространенным является синтаксис Intel, который читается справа налево:

Intel syntax

Если вы видите знаки, похожие на знаки Perl (например, %rsp и %rbp), значит, вы читаете синтаксис GAS, и значения будут перемещаться слева направо. Если вы не видите никаких знаков “%” или “$”, то у вас синтаксис Intel, и значения перемещаются справа налево. Также обратите внимание, что синтаксис Intel не добавляет “q” или “l” к именам команд. Эта статья отлично объясняет различия между двумя стилями.

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

Выполнение моей простой программы

Теперь, когда я понял основы синтаксиса языка ассемблера x86, я готов вернуться к моему коду add_forty_two и попытаться понять, как он работает. Вот он снова:

Читая 6 инструкций внутри add_forty_two, мы видим три различные операции. Во-первых, мы устанавливаем новую рамку стека для нашей функции:

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

Я не буду рассматривать это сегодня. В следующей статье я рассмотрю более сложный пример, содержащий локальные переменные и e

Мы можем убедиться в этом, вернувшись к месту вызова в файле add_forty_two.s, к коду, который вызывает мою функцию:

Обратите внимание, как первая инструкция movl копирует значение 10 в регистр edi (младшие 32 бита регистра rdi):

Далее инструкция callq вызывает мою функцию с 10 в edi :

Поэтому, когда выполняется инструкция addl, она добавит 42 к аргументу 10.

Далее выполняется инструкция movl, которая копирует результат 52 из edi в eax:

Это, в свою очередь, становится возвращаемым значением моей функции:

Опять же, мы можем убедиться в этом, прочитав код места вызова еще раз:

Что происходит после возврата add_forty_two? Он перемещает %eax, возвращаемое значение, в %edi, где оно становится аргументом второго вызова функции, вызова puts.

Я не уверен, что такая схема использования регистров %edi и %eax для хранения аргументов и возвращаемых значений функций является стандартным соглашением x86. Я предполагаю, что это шаблон, который использует генератор кода LLVM. Возможно, LLVM использует эту технику только для функций с одним аргументом и одним возвращаемым значением, таких как add_forty_two.

Следующее время

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

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *