Если вы начинали изучение программирования с JavaScript, Rust, C или любого другого высокоуровневого языка, то ассемблерный код может показаться вам непонятным или даже пугающим.
Рассмотрим следующий код:
section .data
msg db "Hello, World!"
section .text
global _start
_start:
mov rax, 1
mov rdi, 1
mov rsi, msg
mov rdx, 13
syscall
mov rax, 60
mov rdi, 0
syscall
К счастью, по второй строке мы можем понять, что он делает.
Здесь нет ничего привычного нам: мы не видим ни условных операторов, ни циклов, нет никакого способа создавать функции… Да даже у переменных нет имён!
С чего же вообще начать?
Это небольшое введение предназначено для того, чтобы познакомить имеющих опыт в программировании с миром ассемблера. Мы обсудим основы языка и сопоставим их с конструкциями высокоуровневого программирования.
Завершив прочтение этого руководства, вы сможете ориентироваться в ассемблерном коде, будете знать, где искать информацию, и даже сможете самостоятельно писать простые программы.
Приступим!
Hello world
Разумеется, первой программой будет «Hello World».
Но прежде чем углубляться в код, нам нужно вкратце познакомиться с языком. К концу этого раздела мы уже сможем написать и запустить нашу первую ассемблерную программу.
▍ Ассемблер x86-64
Начнём сначала: ассемблер — это не язык.
Ассемблер — это семейство языков программирования, содержащее команды, очень схожие с машинным кодом, который исполняет CPU. Одна из причин существования ассемблерных языков — это создание человекочитаемой версии машинного кода в таких ситуациях, как реверс-инжиниринг, программирование оборудования или разработка игр для консолей.
В этом руководстве я буду использовать ассемблер x86-64, который можно собрать и исполнить на большинстве персональных компьютеров. Такой выбор упростит запуск кода и эксперименты с ним.
По историческим причинам существует две разновидности синтаксиса ассемблера x64-64: один называется Intel, другой — AT&T.
В этом руководстве мы будем использовать диалект Intel, потому что он используется в Intel Software Developer Manuals (SDM) — источнике достоверных данных о том, что на самом деле происходит в CPU, когда ему передаётся команда.
Работа с ассемблером означает близость к оборудованию. Оптимизация примеров кода с целью его портируемости между операционными системами и архитектурами привела бы к запутыванию содержания нашего введения.
Мы будем писать код для Linux, и он также должен нормально выполняться в Windows WSL. Общие концепции и практики остаются применимыми вне зависимости от используемой операционной системы.
▍ Анатомия команды
Команды позволяют приказывать CPU выполнять действия. Они выглядят примерно так:
mov rax, rbx
Они представляют собой наименьшую единицу ассемблерного языка и чаще всего состоят из двух частей:
- мнемоники: сокращённого слова или предложения, описывающего выполняемую операцию,
- операндов: списка из 0-3 элементов, описывающих то, на что влияет операция.
В нашем примере мнемоника — это
mov
, что расшифровывается как move, а операнды — это rax
и rbx
. Эта команда в текстовом виде означает, что нужно записать содержимое rbx
в rax
.Примечание
rax
иrbx
— это регистры, мы познакомимся с ними в следующем параграфе. Пока можете представить, что это переменные, в которых хранятся значения.
У некоторых команд есть не только мнемоники и операнды. Позже нам понадобятся префиксы и директивы размера, поэтому мы поговорим о них в подходящий момент.
Не бойтесь, пока не нужно запоминать все команды. Когда мы будем сталкиваться с новыми операциями, мы будем обсуждать их, а благодаря повторению вы быстро их запомните.
Intel Software Developer Manuals (SDM) будет нашим справочным руководством для следующих глав. Держите его под рукой!
▍ Хранение данных: регистры
Регистры можно рассматривать как пространство для хранения, находящееся прямо в самом CPU. Они маленькие, а доступ к ним невероятно быстр.
Самые часто используемые регистры — это так называемые регистры общего назначения (general purpose). В x86-64 их всего шестнадцать, и каждый из них имеет ширину 64 битов.
Можно получить доступ ко всему регистру или его подмножеству при помощи разных имён. Например, указав
rax
(как в примере выше), мы будем адресовать все 64 бита в регистре rax
. При помощи al
, можно получить доступ к младшему байту того же регистра.Регистр | Старший байт | Младший байт | Младшие 2 байта1 | Младшие 4 байта2 |
---|---|---|---|---|
rax | ah | al | ax | eax |
rcx | ch | cl | cx | ecx |
rbx | bh | bl | bx | ebx |
rdx | dh | dl | dx | edx |
rsp | spl | sp | esp | |
rsi | sil | si | esi | |
rdi | dil | di | edi | |
rbp | bpl | bp | ebp | |
r8 | r8b | r8w | r8d | |
r9 | r9b | r9w | r9d | |
r10 | r10b | r10w | r10d | |
r11 | r11b | r11w | r11d | |
r12 | r12b | r12w | r12d | |
r13 | r13b | r13w | r13d | |
r14 | r14b | r14w | r14d | |
r15 | r15b | r15w | r15d |
2: 4 байта иногда называют двойным словом (double-word, или dword; отсюда и суффикс d)
«Общего назначения» означает, что регистры могут хранить всё, что угодно. Мы увидим, что на практике некоторые регистры имеют особое значение, некоторые команды используют только конкретные регистры, а некоторые стандарты определяют, кто может выполнять в них запись.
Единственный регистр не общего назначения, который нам будет интересен — это
rip
, или регистр указателя команд. В нём хранится адрес следующей для исполнения команды, а потому изменение значений в rip
позволяет программам переходить к произвольным командам в коде.▍ Наш первый ассемблерный файл
Ассемблерные файлы обычно имеют расширение
.s
или .asm
и разделены на три части:- data: здесь мы определяем константы и инициализированные переменные,
- bss: здесь мы определяем неинициализированные переменные,
- text: здесь мы вводим наш код, это единственная обязательная часть файла.
section .data
; здесь константы
section .bss
; здесь переменные
section .text
; здесь код
Примечание
Точка с запятой (;
) — это символ комментария: то, что идёт после него, не будет исполняться.
Ассемблерные программы работают вполне ожидаемым образом. Они начинаются с первой команды, а затем последовательно выполняют одну команду за другой, сверху вниз. Для создания потока управления, например, условных операторов и циклов мы заставляем программы переходить к конкретным командам. Подробнее переходы мы рассмотрим в следующих разделах.
Точно так же, как мы бы использовали функцию
main
во многих языках высокого уровня, ассемблер требует указать точку входа для нашей программы. Это можно сделать при помощи объявления global
, указывающего на метку.Метки в ассемблере позволяют присвоить конкретным командам человекочитаемые имена. Они решают две задачи: повышают понятность кода и позволяют нам ссылаться на эти команды из других частей программы. Объявить метку можно, написав её имя и добавив двоеточие:
label:
. Когда мы хотим сослаться на метку (например, в команде перехода), то её нужно использовать без двоеточия: label
.Обычно
global
ссылается на метку _start
, объявляемую непосредственно после него. Именно отсюда наша программа начинает исполнение.section .data
; здесь константы
section .bss
; здесь переменные
section .text
global _start
_start:
; здесь команды
▍ Наконец-то «Hello World»
Итак, теперь у нас есть все инструменты для создания ПО на ассемблере.
Наша программа будет использовать два системных вызова:
sys_write
для вывода символов в терминал и exit
для завершения процесса с указанным кодом состояния.Системные вызовы (syscall) используются следующим образом:
- выбираем вызываемый системный вызов, записав его идентификатор в
rax
, - передаём аргументы системного вызова, выполняя запись в соответствующие регистры,
- используем команду
syscall
для запуска вызова.
Единственная дополнительная команда, которую мы будем использовать — это
mov
, с которой мы познакомились выше. Она устроена практически как присваивание (оператор =
) в языках высокого уровня: копирует содержимое второго операнда в первый.Давайте взглянем на код. (Весь код введения можно найти в репозитории shikaan/x86-64-asm-intro.)
Код пошагово прокомментирован. Внимательно изучите комментарии!
section .data
; Определяем константу `msg`, то есть строку для печати.
; Используем директиву`db` (define byte) для определения
; констант одного или нескольких байтов (1 символ = 1 байт).
msg db `Hello, World!\n`
section .text
global _start
_start:
; Выполняем системный вызов sys_write для вывода
; на экран. Системные вызовы выполняются при помощи `syscall`,
; они идентифицируются числом.
;
; Идентификатор sys_write - это число 1. Команда `syscall`
; обратится к идентификатору команды
; в регистре `rax`. Поэтому мы запишем туда 1.
mov rax, 1
; Выполняемый системный вызов имеет следующую сигнатуру:
;
; size_t sys_write(uint fd, const char* buf, size_t count)
; Команде `syscall` нужен первый аргумент
; из `rdi`. В данном случае первый аргумент -
; это дескриптор файла, в который нужно выполнять вывод.
; Мы используем 1, что обозначает стандартный вывод.
mov rdi, 1
; Второй аргумент должен находиться в `rsi`,
; а сигнатура даёт нам понять, что это строка, которую
; нужно вывести. Мы определили буфер в разделе .data
; (это `msg`), поэтому нужно просто скопировать его в `rsi`
mov rsi, msg
; `rdx` - это регистр для третьего аргумента.
; Из сигнатуры мы видим, что это количество символов,
; которые мы хотим вывести.
;
; Примечание: строка завершается нулевым символом, то есть
; нам нужно учитывать "невидимый" символ в конце.
mov rdx, 14
; Теперь мы, наконец, запускаем системный вызов
syscall
; Сообщение выведено! Можно выходить из программы.
; Как и раньше, мы вызываем `syscall` с 60, что обозначает
; `exit` и имеет следующую сигнатуру:
;
; void exit(int status)
; `syscall` снова ищет идентификатор
; в `rax`. Мы запишем туда 60, то есть идентификатор `exit`
mov rax, 60
; Первый аргумент снова должен
; находиться в `rdi`.
; Первый аргумент - это код состояния (см. сигнатуру),
; поэтому для выхода без ошибок мы записываем 0.
mov rdi, 0
; Запускаем системный вызов `exit`
; и выходим из программы без ошибок
syscall
Заключение
Мы написали «hello world»!
В этой первой статье мы изучили основные концепции ассемблера, попробовали на практике его синтаксис и даже написали работающее ПО. Более того, мы изучили, как взаимодействовать с операционной системой и готовы писать более интересные программы с условными операторами.
Telegram-канал со скидками, розыгрышами призов и новостями IT ?
Комментарии (37)
vk6677
18.09.2024 13:21+5В ассемблере нет по умолчанию "невидимого" завершающего нуля, как Вы пишете. Так что на байт больше передали в системный вызов. У некоторых диалектов ассемблера есть .asciz директива, она добавляет 0 автоматически.
Системный вызов wtite не требует завершающего нуля, только количество байт для записа.
SquareRootOfZero
18.09.2024 13:21Там передали 14 байт, и в строке, вроде, 14 символов, с учётом '\n'.
vk6677
18.09.2024 13:21Вы правы. Я не считал байты. Прочитал комментарий к коду. Тем более в начале статьи немного по-другому эта строка записана.
SquareRootOfZero
18.09.2024 13:21Количество байт передано правильное, а вот комментарий неправильный. null-terminated строки ведь не универсальны и не возникают в любом языке сами по себе.
SquareRootOfZero
18.09.2024 13:21+7Здесь нет ничего привычного нам: мы не видим ни условных операторов, ни циклов, нет никакого способа создавать функции…
В смысле, а на высокоуровневых языках вроде JavaScript, Rust, C мы бы в hello world увидели условные операторы, циклы и создание функций?
Да даже у переменных нет имён!
А msg?
SIISII
18.09.2024 13:21ну, какую-нибудь функцию вроде main() на языке высокого уровня мы, скорей всего, увидели б. а msg -- это не переменная, это метка (имя для адреса памяти, а как сей адрес используется и что в той памяти -- про это msg ничего не знает)
SquareRootOfZero
18.09.2024 13:21+2На Python или Javascript мы бы запросто могли не увидеть никакой функции main(). А что "не переменная, а имя для адреса памяти" - это всё казуистика, и в C какая-нибудь переменная типа void* тоже, по-сути, имя для адреса памяти. Да, собственно, и не-void указатели - почти то же самое, только ещё сбоку присобачен небольшой хинт для компилятора: "пока думай, что по этому адресу лежит unsigned long double".
SIISII
18.09.2024 13:21Насчёт функций в скриптовых языках Вы, надо полагать, правы. А вот насчёт казуистики -- нет. Переменная A, объявленная как void *A, предназначена для хранения указателя (адреса) -- а соответственно, указывает не просто адрес, а область памяти строго определённого размера (соответствует размеру адреса), предназначенную для определённого применения -- хранения указателя на void. В языках более высокого уровня, чем Си/Си++, с именами переменных ещё больше вещей может быть связано -- связанных, например, с необходимостью динамически менять тип значения переменной в зависимости от того, что ей присвоено. Ассемблерная же метка -- это имя для адреса памяти и, как правило, ничего больше -- хотя размер, связанный с меткой, в некоторых ассемблерах встречается. Использовать переменную A для хранения чего-то, кроме указателя на void, можно, но лишь с помощью специальных "извращений" (операций приведения типа), ну а метка в ассемблере в принципе не накладывает никаких ограничений на её использование: хочешь -- читай или записывай данные произвольного размера, используя её в качестве адреса, хочешь -- передавай на неё управление, как будто там лежит код, хочешь -- используй её как часть выражения для вычисления какого-то другого адреса... Низкий уровень потому и низкий, что не строит абстракций над битиками и байтиками, оставляя это языкам высокого уровня, и в этом смысле переменных как таковых в ассемблере действительно нет -- есть лишь области памяти, иногда носящие имена, а иногда и не имеющие таковых.
SquareRootOfZero
18.09.2024 13:21Насчёт функций в скриптовых языках Вы, надо полагать, правы.
И не только в "скриптовых" (что бы это ни значило) - Лисп, насколько я знаю, к таковым обычно не относят, а вотъ:
(format t "Hello, World!")
А вот насчёт казуистики -- нет.
Выглядит как переменная, ведёт себя как переменная, используется как переменная - но не переменная. Можно перечислить много сходств с переменными в других языках, можно найти много различий, объявить какие-то из них принципиальными, провести через них тонкую красную линию: вот-де, тут у нас уже переменная, а тут ещё имя области памяти - что это всё, если не казуистика? В языках с динамической строгой типизацией - том же Python - насколько я помню, считается, что переменная сама по себе не имеет типа, а указывает на объект произвольного типа где-то в памяти - тоже, получается, не переменная, а метка?
Wesha
18.09.2024 13:21+3Более того, собственно переменных нет! И констант нет! Ничего нет!
Я это описываю так:
Память комьютера — это огромная «шахматная доска», в каждой клетке которой лежит число. В каких‑то из них могут лежать числа, которые нас по той или иной причине интересуют (например, потому что мы сами их туда положили), в остальных — лежат случайные числа («мусор»), но мы в них и не смотрим. Компьютер может найти каждую конкретную клетку по её номеру. Например, клетку № 153 422 731 мы отведём под число, которое будет счётчиком цикла. Но поскольку нам западло помнить такое большое и бессмысленное число, мы себе в табличку запишем, что
i = 153422731
и будем везде использоватьi
. А потом, когда нужно будет готовить двоичный код для компьютера, везде, где мы видим «переменную i», мы будем производить подстановку. Соответственно, в какой‑то момент времени умный человек догадался, что имеет смысл научить сам компьютер вести такую табличку — увидев новое мнемоническое «имя переменной», автоматически находить неиспользуемую ячейку памяти («клетку на доске») и записывать в табличку рядом с именем переменной адрес этой ячейки, а потом при компиляции программы выполнять такую замену (имени — на адрес).perfect_genius
18.09.2024 13:21+1Только не показывайте это новичкам, иначе им взорвёт мозг. Тут каждое предложение вызывает вопросы.
Wesha
18.09.2024 13:21И какие же?
(А ещё я могу про сегментные регистры в ранних x86 рассказать. Вот там вообще ядрёная бомба будет.)
perfect_genius
18.09.2024 13:21+1Память комьютера — это огромная «шахматная доска»
Т.е. чётные и нечётные элементы чем-то отличаются?
в каждой клетке которой лежит число. В каких‑то из них могут лежать числа, которые нас по той или иной причине интересуют (например, потому что мы сами их туда положили), в остальных — лежат случайные числа («мусор»), но мы в них и не смотрим.
Зачем нам класть числа в память? Откуда берётся мусор?
Например, клетку № 153 422 731 мы отведём под число, которое будет счётчиком цикла.
Почему в эту клетку, а не в первую? Что такое "счётчик цикла"?
мы себе в табличку запишем, что
i = 153422731
и будем везде использоватьi
.Почему "i"? Т.е. мы ограничены 26 буквами английского алфавита? Почему нельзя использовать мой родной язык?
двоичный код
мнемоническое «имя переменной»
компиляции
Всё, голова перегрелась от неизвестных непонятных слов.
Wesha
18.09.2024 13:21+8Т.е. чётные и нечётные элементы чем-то отличаются?
Прикиньте себе, в некоторых компьютерах — да. (Например, в PDP-11 MOV может работать только с чётными адресами, забирая оттуда 16 бит разом. А вот MOVB работает с байтами, но с любого адреса.)
Зачем нам класть числа в память
Вы ж программист. Хотите — кладите, не хотите — не кладите, но памяти совсем без чисел не бывает, там всегда что-то лежит, пусть даже и мусор.
Откуда берётся мусор
Остаточные заряды в конденсаторах. Или числа, оставшиеся от предыдущей программы. Тем и отличается
malloc
отcalloc
, что первой глубого пофиг, какой мусор лежит в выделенной ею области памяти, а вторая перед тем, как вернуть указатель, принудительно кладёт во все ячейки области по нулю. Потому-то она и называетсяcalloc
— clean & allocateПочему в эту клетку
Во-первых, потому что это пример. Во-вторых, потому что так компилятор решил. А вот почему он так решил — это требует ответа ещё на пару страничек.
а не в первую?
Во-первых, в нулевую. Во-вторых, некоторые адреса выделены для особых нужд, и компилятор об этом знает.
Что такое "счётчик цикла"?
Эммммм... Вы точно программист?
Т.е. мы ограничены 26 буквами английского алфавита?
Эмммм... Да. И цифрами с подчёркиванием. Вы точно-точно программист?
Почему нельзя использовать мой родной язык?
Так исторически сложилось. Вы точно-точно-точно программист?
голова перегрелась от неизвестных непонятных слов.
Точно не программист.
perfect_genius
18.09.2024 13:21+1Вы почему-то забыли, что речь идёт о новичках, которым вы пытаетесь всё это объяснить.
Только не показывайте это новичкам, иначе им взорвёт мозг.
Такое бывает, когда отвечаешь разных собеседникам и теряешь детали диалога.
Wesha
18.09.2024 13:21Здесь новички не ходят. Если бы я писал книгу для новичков, все детали заняли бы страниц этак двадцать, а расписывать это бесплатно чисто для анонима на Хабре я не намерен.
a1excoder
18.09.2024 13:21+6очередная "hello world" на асме.. и ничего дальше
redfox0
18.09.2024 13:21+3Тут хотя бы современный 64-битный ассемблер, пример которого (упс!) соберётся и будет выполняться только в линуксе, так как используются системные вызовы, минуя, например, стандартную библиотеку Си.
Чуть больше примеров:
; fasm hello.asm ; chmod +x hello ; ./hello format elf64 executable 3 ; linux 2.6.35 compatible x86-64 syscalls table sys_read = 0 sys_write = 1 sys_exit = 60 stdin = 0 stdout = 1 stderr = 2 segment readable c_hello db "Hello world", 10 .len = $ - c_hello segment executable entry $ _start: ; write(stdout, c_hello, len) mov rdi, stdout mov rsi, c_hello mov rdx, c_hello.len mov rax, sys_write syscall ; exit(0) mov rdi, 0 mov rax, sys_exit syscall
Впрочем, интереснее вызывать уже готовые функции Си:
; fasm helloc.asm ; ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 -lc helloc.o -o helloc format elf64 extrn printf ; printf(c_hello, g_name) mov rdi, c_hello mov rsi, g_name call printf
santanico
18.09.2024 13:21+5Я бы не советовал использовать статьи для изучения чего-либо нового. Книга - таков путь!
skymal4ik
18.09.2024 13:21+2Для каких-нибудь ангуляров и реактов пока книга напечатается, она уже устареет. А по видеомонтажу в каком-нибудь блендере видеоуроки мне больше заходят, ибо нагляднее.
Так что в ваших словах есть истина. Но не для всего.
MountainGoat
18.09.2024 13:21+4Лучше бы показали на практике, в чём смысл это учить - если не планируешь кодить на контроллерах с частотой 20Гц.
Wesha
18.09.2024 13:21В чём смысл это учить
Ну да, ну да. Не нужно знать, как оно там унутрях устроено — надо чтобы нажал кнопку, и оно там само всё сделало! /s
(Только учтите, что оно за Вас и конфеты есть будет.)
Kirill-112
18.09.2024 13:21(Только учтите, что оно за Вас и конфеты есть будет.)
Представил нейросеть, которая ест конфеты быстрее и эффективнее, чем человек.
Wesha
18.09.2024 13:21+1Просыпайтесь, нейросеть уже жрёт электричество быстрее и эффективнее, чем человек.
ilshat1976
18.09.2024 13:21+3Доброго времени суток!
Что-то не понял насчёт вывода на экран, а где прерывание 21 или 9 (int 21h; int 09h)?
Сейчас по другому можно выводить на экран? или я не те ассемблеры в ВУЗе на кафедре вычислительной техники изучал (в конце 90-ых), хотя IBMовский вроде тоже в их числе.
redfox0
18.09.2024 13:21Раньше во времена 16 битного x86 действительно вызывали прерывания BIOS и (или) DOS.
В статье показан 64 битный x86-64 и вызов системных вызовов ядра линукса 2.6.35+
Fedorkov
18.09.2024 13:21+2В качестве вводного гайда я бы привёл пример сишной программы с ассемблерной вставкой.
Во‑первых, на чистом асме под линукс, кажется, пишут только демосценщики; а вставки используются гораздо чаще — в качестве микрооптимизации наиболее нагруженного кода.
А во‑вторых, это будет более простой код, не привязанный к линуксу.
Вот небольшой пример ассемблерной вставки у меня в гите: в файле
knapsack.S
реализована функция, переобъявленная с extern‑ом вmain.c
.Ritan
18.09.2024 13:21не привязанный к линуксу
Всё ещё привязан, просто не так явно. И может заработать на других платформах, где соглашение о вызовах совпадает с таковым в линуксе.
mezastel
18.09.2024 13:21У меня на ассемблере написан только парсер интов. Потому что в разы быстрее чем то что дает любая библиотека. Использует, кстати, AVX.
Akina
Вот прямо вижу: "Я переписал весь код в Блокноте и сохранил в файл C:\asm\HelloWorld.asm - но не работает! даже не запускается...".
Если не считать этой досадной мелочи, а также того что напрямую к SDM не обратиться, ибо пошлёт, то всё остальное - ясно и понятно.
chnav
Не стреляйте в пианиста, это же перевод ))