fbpx

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

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

Как выучить

Скотт Волчок

Как читать язык ассемблера

Зачем в 2021 году кому-то нужно изучать язык ассемблера? Во-первых, чтение языка ассемблера – это способ узнать, что именно делает ваша программа. Почему именно эта программа на C++ занимает 1 МиБ (скажем), а не 100 КиБ? Можно ли выжать еще немного производительности из той функции, которая постоянно вызывается?

В частности, для C++ легко забыть или просто не заметить какую-то операцию (например, неявное преобразование или вызов копирующего конструктора или деструктора), которая подразумевается исходным кодом и семантикой языка, но не прописана в явном виде. Если посмотреть на ассемблер, созданный компилятором, то все будет на виду.

Вторая, более практическая причина: до сих пор посты в этом блоге не требовали понимания языка ассемблера, несмотря на постоянные ссылки на Compiler Explorer. Однако, по многочисленным просьбам, нашей следующей темой будет передача параметров, и для этого нам понадобится базовое понимание языка ассемблера. Мы сосредоточимся только на чтении языка ассемблера, но не на его написании.

Инструкции

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

Пример 1: Векторная норма

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

и вот результирующий ассемблер x86-64 из clang 11, через Compiler Explorer: 1

Давайте поговорим о первой инструкции: imulq %rdi, %rdi . Эта инструкция выполняет знаковое целочисленное умножение. Суффикс q говорит нам, что она оперирует с 64-битными величинами. (Для сравнения, l, w и b обозначают 32-битные, 16-битные и 8-битные величины соответственно). Он умножает значение в первом заданном регистре ( rdi ; имена регистров имеют префикс со знаком %) на значение во втором регистре и сохраняет результат в этом втором регистре. Это возведение в квадрат v.x в нашем примере кода на C++.

Вторая инструкция делает то же самое со значением в %rsi , что возводит v.y в квадрат.

Далее у нас есть нечетная инструкция: leaq (%rsi,%rdi), %rax . lea означает “загрузить эффективный адрес”, и она сохраняет адрес первого операнда во второй операнд. (%rsi, %rdi) означает “участок памяти, на который указывает %rsi + %rdi “, так что это просто сложение %rsi и %rdi и сохранение результата в %rax . lea – причудливая инструкция, специфичная для x86; на более RISC-архитектуре, такой как ARM64, мы ожидали бы увидеть обычную инструкцию add. 2

Наконец, retq возвращается из функции normSquared.

Регистры

Давайте сделаем небольшой экскурс, чтобы объяснить, что такое регистры, которые мы видели в нашем примере. Регистры – это “переменные” языка ассемблера. В отличие от вашего любимого языка программирования (вероятно), их существует конечное число, они имеют стандартные имена, а те, о которых мы будем говорить, имеют размер не более 64 бит. Некоторые из них имеют специфическое применение, которое мы увидим позже. Я не смогу вспомнить, но, согласно Википедии, полный список 16 регистров в x86_64 выглядит так: rax , rcx , rdx , rbx , rsp , rbp , rsi , rdi , r8 , r9 , r10 , r11 , r12 , r13 , r14 и r15 .

Пример 2: Стек

Теперь расширим наш пример, чтобы отладить печать Vec2 в normSquared :

Помимо очевидного вызова Vec2::debugPrint() const , у нас есть еще несколько новых инструкций и регистров! %rsp – особенный: это “указатель стека”, используемый для поддержания стека вызовов функций. Он указывает на нижнюю часть стека, который на x86 растет “вниз” (в сторону более низких адресов). Таким образом, наша инструкция subq $24, %rsp освобождает место на стеке для трех 64-битных целых чисел. (Вообще, установка стека и регистров в начале вашей функции называется прологом функции). Затем, следующие две инструкции mov сохраняют первый и второй аргументы normSquared , которые являются v.x и v.y (подробнее о передаче параметров слов в следующей статье блога!) в стек, эффективно создавая копию v в памяти по адресу %rsp + 8 . Далее мы загружаем адрес нашей копии v в %rdi с помощью leaq 8(%rsp), %rdi и затем вызываем Vec2::debugPrint() const .

После возврата debugPrint мы загружаем v.x и v.y обратно в %rcx и %rax . У нас есть те же инструкции imulq и addq, что и раньше. Наконец, мы добавляем addq $24, %rsp, чтобы очистить 24 байта 4 стекового пространства, которое мы выделили в начале нашей функции (это называется эпилогом функции), а затем возвращаемся к вызывающей стороне с помощью retq .

Пример 3: Указатель кадра и поток управления

Теперь давайте рассмотрим другой пример. Предположим, что мы хотим напечатать строку на языке C в верхнем регистре и хотим избежать выделения кучи для небольших строк. 5 Мы могли бы написать что-то вроде следующего:

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

Последовательность pushq %rbp; movq %rsp, %rbp очень распространена: она выталкивает указатель кадра, хранящийся в %rbp, в стек и сохраняет старый указатель стека (который является новым указателем кадра) в %rbp . Следующие четыре инструкции pushq сохраняют регистры, которые мы должны сохранить перед использованием. 7

Переходим к телу функции. Мы сохраняем наш первый аргумент ( %rdi ) в %r14 , потому что собираемся вызвать strlen, а она, как и любая другая функция, может перезаписать %rdi (мы говорим, что %rdi “сохраняется вызывающим”), но не %r14 (мы говорим, что %r14 “сохраняется вызывающим”). Мы вызываем strlen(s) с помощью callq strlen и сохраняем sSize + 1 в %rdi с помощью lea 1(%rax), %rdi .

Next, we finally see our first if statement! cmpq $1024, %rax sets the flags register according to the result of %rax – $1024 , and then ja .LBB0_2 (“jump if above”) transfers control to the location labeled .LBB0_2 if the flags indicate that %rax > 1024 . В целом, примитивы более высокого уровня потока управления, такие как операторы if / else и циклы, реализуются на ассемблере с помощью условий

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

1024 и переходим к .LBB0_2 . Этот путь более прост. Мы вызываем оператор new[], сохраняем результат (возвращенный в %rax) в %rbx, вызываем copyUppercase и puts, как и раньше. Для этого случая у нас есть отдельная функция-эпилог, и выглядит она несколько иначе:

Первый mov устанавливает %rdi с указателем на наш массив, выделенный из кучи, который мы сохранили ранее. Как и в эпилоге другой функции, мы восстанавливаем указатель стека и открываем наши сохраненные регистры. Наконец, у нас есть новая инструкция: jmp operator delete[](void *) . jmp – это такая же инструкция, как goto: она передает управление на заданную метку или функцию. В отличие от callq, она не заносит адрес возврата в стек. Поэтому, когда оператор delete[] вернется, он вместо этого передаст управление вызывающей функции printUpperCase. По сути, мы объединили callq для operator delete[] с нашим собственным retq. Это называется оптимизацией хвостового вызова, отсюда и комментарий # TAILCALL, услужливо выдаваемый компилятором.

Практическое применение: отлов неожиданных преобразований

Next, let’s look at the path when %rax >Во введении я говорил, что при чтении ассемблера неявные операции копирования и уничтожения становятся совершенно очевидными. Мы видели некоторые из них в нашем предыдущем примере, но в заключение я хочу рассмотреть распространенный спор о семантике перемещения в C++. Можно ли принимать параметры по значению, чтобы не иметь одну перегрузку для ссылок на l-значения и другую перегрузку для ссылок на r-значения? Есть школа мысли, которая говорит: “Да, потому что в случае lvalue вы все равно сделаете копию, а в случае rvalue все в порядке, пока ваш тип дешев для перемещения”. Если мы посмотрим на пример для случая rvalue, то увидим, что “дешево перемещать” не означает “свободно перемещать”, как бы нам ни хотелось обратного. Если мы хотим получить максимальную производительность, мы можем продемонстрировать, что решение с перегрузкой обеспечит нам это, а решение с побочным значением – нет. (Конечно, если мы не хотим писать дополнительный код для повышения производительности, то “дешево для перемещения”, вероятно, достаточно дешево).

Если мы посмотрим на сгенерированную сборку 9 (которая слишком длинная, чтобы ее включать, хотя я намеренно описал 10 конструкторов, о которых идет речь), то увидим, что createRvalue1 выполняет 1 операцию перемещения (внутри тела MyString::MyString(std::string&&) ) и 1 вызов std::string::

string() (оператор delete перед возвратом). Напротив, createRvalue2 намного длиннее: он выполняет в общей сложности 2 операции перемещения (1 inline, в параметр s для MyOtherString::MyOtherString(std::string s) , и 1 в теле того же конструктора) и 2 вызова std::string::

string (1 для вышеупомянутого параметра s и 1 для члена MyOtherString::str). Справедливости ради, перемещение std::string дешево, как и уничтожение перемещенной из std::string, но это не бесплатно ни с точки зрения процессорного времени, ни с точки зрения размера кода.

Дополнительное чтение~Язык ассемблера появился еще в конце 1940-х годов, поэтому существует множество ресурсов для его изучения. Лично я впервые познакомился с языком ассемблера на курсе EECS 370: Введение в организацию компьютера” в моей альма-матер, Мичиганском университете. К сожалению, большинство материалов курса, ссылки на которые приведены на этом сайте, не являются общедоступными. Вот соответствующие курсы “Как на самом деле работают компьютеры” в Беркли (CS 61C), Карнеги-Меллон (15-213), Стэнфорде (CS107) и Массачусетском технологическом институте (6.004). (Пожалуйста, сообщите мне, если я предложил неправильный курс для какого-либо из этих учебных заведений!) Nand to Tetris также охватывает аналогичный материал, а проекты и главы книги находятся в свободном доступе.~Мое первое практическое знакомство с ассемблером x86, в частности, произошло в контексте эксплойтов безопасности, или обучения, чтобы стать “l33t h4x0r”, как говорили дети. Если это покажется вам более занимательной причиной для изучения ассемблера, отлично! Классическая работа в этой области – Smashing the Stack for Fun and Profit. К сожалению, современные средства защиты усложняют самостоятельное выполнение примеров из этой статьи, поэтому я рекомендую найти более современную практическую среду. Микрокоррупция – это пример, созданный промышленностью, или вы можете попробовать найти проект по безопасности приложений из курса по безопасности в колледже (например, проект 1 из CS 161 в Беркли, который, кажется, сейчас находится в открытом доступе).

Наконец, всегда есть Google и Hacker News. В статье Пэта Шонесси “Learning to Read x86 Assembly Language” от 2016 года эта тема рассматривается с точки зрения Ruby и Crystal, а также недавно (в 2020 году) обсуждался вопрос о том, как выучить ассемблер x86_64.

Удачи, и счастливого хакинга!

Если вы действительно посмотрите на ассемблер ARM64 для этого примера, то увидите, что вместо него используется инструкция madd: madd x0, x0, x0, x8 . Это умножение+прибавление в одной инструкции: она выполняет x0 = x0 * x0 + x8. ︎

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

Вы, наверное, заметили, что мы использовали только 16 байт стекового пространства, хотя выделили 24. Насколько я могу судить, дополнительные 8 байт остались от кода для установки и восстановления указателя кадра, который был оптимизирован. Clang, gcc и icc оставляют лишние 8 байт, а msvc, похоже, тратит 16 байт вместо 8. Если мы выполним сборку с-fno-omit-frame-pointer, то увидим, что остальные 8 байт используются для pushq %rbp в начале функции и popq %rbp в конце. Компиляторы не идеальны; если вы часто читаете ассемблер, вы будете время от времени видеть подобные небольшие промахи в оптимизации. Иногда это действительно упущенные возможности оптимизации, но существует также множество неудачных ограничений ABI, которые заставляют генерировать неоптимальный код для обеспечения совместимости между частями кода, собранными с помощью разных компиляторов (или даже разных версий компилятора).

Я собрал с-fno-exceptions, чтобы упростить пример, удалив путь очистки исключений. Он появляется сразу после вызова хвоста, что, как мне кажется, может сбить с толку. ︎

Еще одна возможная упущенная оптимизация: Я не вижу необходимости в pushq %rax здесь; он не сохраняется при вызове, и нам не важно значение на входе в printUpperCase. Свяжитесь с нами, если вы знаете, является ли это упущенной оптимизацией или на самом деле есть причина сделать это! UPDATE: скорее всего, это происходит потому, что выталкивание регистра меньше и/или быстрее, чем выполнение инструкции sub 8, %rsp. ︎

И снова я думаю, что movq %rsp, %r15 не нужен. %r15 не используется снова, пока мы не выполним movq %r15, %rsp , но за этой инструкцией сразу же следует lea q-24(%rbp), %rsp , которая сразу же перезаписывает %rsp. Я думаю, что мы могли бы улучшить код, удалив две инструкции movq %rsp, %r15 и movq %r15, %rsp. С другой стороны, компилятор icc от Intel также делает, казалось бы, глупые вещи для восстановления %rsp, учитывая этот код, так что либо есть веская причина делать это, либо очистка манипуляций с указателями стека в присутствии массивов переменной длины – это просто трудная или игнорируемая проблема в компиляторах. Опять же, не стесняйтесь, если вы знаете, что это такое! ︎

Опять же, я собирал с-fno-exceptions, чтобы не усложнять ситуацию путями очистки исключений. ︎

Если мы инлайним конструкторы для MyString и MyOtherString, мы действительно получаем некоторую экономию в createRvalue2: мы вызываем оператор delete не более одного раза. Однако мы все равно выполняем 2 операции перемещения, и нам требуется 32 дополнительных байта стека. ︎

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

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