Ассемблер — это собирательное название языков низкого уровня. «Низкий» здесь означает не примитивность, а близость к машинному коду, который компьютеры понимают без перевода (в отличие от высокоуровневых языков). Ассемблер — что-то среднее между машинным кодом и командами на естественном языке.
Нужно ли учиться писать на ассемблере в 2024 году? Да, если хотите войти в высшую лигу и делать то, что почти никто не может, например создавать программы для микроконтроллеров. А вот читать ассемблер, чтобы понимать принципы работы высокоуровневого кода, будет полезно многим программистам.
Если научитесь читать ассемблерный код, то сможете:
- разобраться, как в памяти хранятся временные данные, регистр и прочие важные вещи, позволяющие правильно работать с памятью во всех высокоуровневых языках;
- понять, какие оптимизации выполняет компилятор и как они влияют на конечную производительность и поведение программы;
- работать с низкоуровневыми API и добавлять в высокоуровневый код ассемблерные вставки для лучшей производительности.
Сегодня потренируемся разбираться в ассемблерном коде. Для этого преобразуем код высокоуровневой программы в ассемблер, посмотрим получившийся код и то, какие оптимизации применяет компилятор, чтобы сделать его эффективнее.
Краткий обзор
В статье про ассемблер мы уже разбирали его базовые понятия: что такое регистры, из чего состоит программа и какие бывают команды. Кратко напомним основные моменты.
В ассемблере используются мнемоники — короткие символические имена, которые представляют машинные команды (например, MOV
, ADD
, SUB
).
После мнемоник следуют операнды — значения, над которыми выполняется операция. Они могут быть регистрами процессора, то есть специальными ячейками памяти, адресами памяти или непосредственными значениями. Например:
MOV eax, 5 ; Переместить значение 5 в регистр eax
Здесь MOV
— это мнемоника команды перемещения, eax
и 5
— операнды. Комментарий после ; объясняет, что делает эта строчка кода.
Инструмент Compiler Explorer
Мы будем переводить высокоуровневую программу в ассемблер через Compiler Explorer и смотреть, как она работает, обращается к памяти и что происходит на каждом этапе.
Compiler Explorer — онлайн-инструмент, созданный Мэттом Годболтом для исследования компиляторов. В Compiler Explorer можно писать код на высокоуровневом языке и сразу видеть, какой ассемблерный код генерирует для него компилятор. А если это Python или Java, можно увидеть их байт-код.
Принцип работы инструмента простой: слева выбираем нужный язык, справа — версию компилятора. Затем в окно слева вводим код, а справа видим сгенерированный ассемблер.
С помощью Compiler Explorer удобно изучать синтаксис ассемблера: наводим курсор на команду и сразу видим её описание. А через контекстное меню можно раскрыть полное описание и перейти на страницу документации.
Современные компиляторы очень умные. При работе с кодом они используют оптимизации для улучшения производительности и эффективности генерируемого машинного кода. Могут удалять «мёртвый» код, который никогда не используется, уменьшать число итераций цикла, предсказывать ветвления и так далее. С помощью флагов компилятора разработчик может включать разные виды оптимизаций или вообще их отключать. Например, флаг -О1
означает базовую оптимизацию.
Выбирая различные типы оптимизации, можно увидеть, как компилятор оптимизирует тот или иной кусок кода. Инструмент сам подсвечивает, какая строчка во что превратилась и как реализовались исходные решения.
Читаем высокоуровневую программу
Разберем небольшую программу на С++ и посмотрим на разные уровни оптимизации. Есть небольшой код, который определяет функцию square
: она принимает один целочисленный аргумент num и возвращает его квадрат.
int square(int num) {
return num * num;
}
В ассемблере без оптимизации код будет выглядеть так:
square(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, eax
pop rbp
ret
Строчки кода в обоих окнах раскрашены в разные цвета. Если наводить курсор на разные блоки кода, мы увидим соответствия ассемблерного кода. Три строки на С++ превратились в 7 строк на ассемблере. Разберёмся, что тут происходит.
Код сохраняет текущее состояние стека (push rbp
), чтобы потом его можно было восстановить. Это поддерживает правильный порядок вызовов функций и возвращений из них.
Затем код готовит место в памяти для хранения входного числа (mov rbp
, rsp
и mov DWORD PTR [rbp-4]
, edi
). После этого берёт это число, умножает его на само себя, чтобы получить квадрат (mov eax
, DWORD PTR [rbp-4]
и imul eax
, eax
), и сохраняет результат. Наконец, восстанавливает исходное состояние стека (pop rbp
) и возвращает результат умножения обратно в вызывающую программу (ret
).
Теперь включим базовую оптимизацию -O1
и посмотрим на новый сгенерированный код:
square(int):
imul edi, edi
mov eax, edi
ret
Код стал намного меньше: мы видим, что компилятор убрал все подготовительные действия по работе с памятью. В первой строке код умножает значение, хранящееся в регистре edi
, само на себя. Результат умножения сохраняется в том же регистре edi
. Во второй строке копируется значение из регистра edi
в регистр eax
. И наконец команда ret возвращает управление вызывающей функции.
Компилятор определил, что для выполнения функции не требуется сохранение состояния стека и подготовка локальных переменных, как в первом случае, и все операции можно выполнить прямо в регистрах.
Такой ассемблер уже не выглядит сложным и пугающим, и разобраться в нём намного проще. А изучая оптимизированный и неоптимизированный ассемблерный код, можно понять, как программа использует ресурсы системы.
Случай неправильной оптимизации
Редко, но бывает, что оптимизации компилятора приводят к ошибке в работе корректной программы. И вычислить это можно, только покопавшись в ассемблерном коде.
Три года назад пользователь Reddit поделился случаем, когда его код на C выдавал неправильный результат при включённой оптимизации в версии компилятора GCC 9+.
Причиной ошибки стал такой фрагмент кода:
int main() {
for (unsigned a = 0, b = 0; a < 6; a += 1, b += 2)
if (b < a)
return 1;
return 0;
}
В коде выполняется цикл, в котором переменные a
и b
инициализируются значениями 0. На каждом шаге цикла a
увеличивается на 1, а b
— на 2, пока a
не достигнет 6. В каждой итерации проверяется условие if (b < a)
. Если условие истинно, код завершает выполнение и возвращает 1[t/ags]. Если цикл завершается без прерываний, код возвращает
.0
При этом условие if (b < a)
никогда не будет истинным, поскольку b
всегда увеличивается на 2, в то время как a
увеличивается только на 1. Поэтому код всегда должен возвращать 0.
Без включенных оптимизаций код вёл себя ожидаемо, но стоило добавить любую оптимизацию, и код выдавал 1, чего не должно было происходить. При изучении проблемы выяснилось, что компилятор генерировал такой ассемблерный код:
main:
mov eax, 1
ret
Здесь мы видим, что инструкция mov
перемещает значение 1
в регистр eax
, который используется для возврата значения из функции. И всё. То есть, компилятор просто убирал весь цикл с условием, что и давало некорректный результат.
Реддитор сообщил о своей проблеме, и баг компилятора исправили. Оказалось, что разработчики не добавили поддержку беззнаковых (unsigned) типов данных.
Что дальше
Если всё-таки захотелось освоить программирование на ассемблере, рекомендуем почитать книгу Андрея Столярова «Программирование на языке ассемблера NASM для ОС Unix». Там на подробных примерах описана вся суть низкоуровневого программирования.
А в следующей серии поговорим про сайзкодинг — искусство делать программы в минимальное количество байтов.