Если вы начинали изучение программирования с 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
1: 2 байта иногда называют словом (word; отсюда и суффикс w)
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 ?

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


  1. Akina
    18.09.2024 13:21

    Итак, теперь у нас есть все инструменты для создания ПО на ассемблере.

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

    Вот прямо вижу: "Я переписал весь код в Блокноте и сохранил в файл C:\asm\HelloWorld.asm - но не работает! даже не запускается...".

    Если не считать этой досадной мелочи, а также того что напрямую к SDM не обратиться, ибо пошлёт, то всё остальное - ясно и понятно.


    1. chnav
      18.09.2024 13:21

      Не стреляйте в пианиста, это же перевод ))


  1. vk6677
    18.09.2024 13:21
    +1

    В ассемблере нет по умолчанию "невидимого" завершающего нуля, как Вы пишете. Так что на байт больше передали в системный вызов. У некоторых диалектов ассемблера есть .asciz директива, она добавляет 0 автоматически.

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


    1. SquareRootOfZero
      18.09.2024 13:21

      Там передали 14 байт, и в строке, вроде, 14 символов, с учётом '\n'.


      1. vk6677
        18.09.2024 13:21

        Вы правы. Я не считал байты. Прочитал комментарий к коду. Тем более в начале статьи немного по-другому эта строка записана.


        1. SquareRootOfZero
          18.09.2024 13:21

          Количество байт передано правильное, а вот комментарий неправильный. null-terminated строки ведь не универсальны и не возникают в любом языке сами по себе.


  1. SquareRootOfZero
    18.09.2024 13:21
    +3

    Здесь нет ничего привычного нам: мы не видим ни условных операторов, ни циклов, нет никакого способа создавать функции…

    В смысле, а на высокоуровневых языках вроде JavaScript, Rust, C мы бы в hello world увидели условные операторы, циклы и создание функций?

    Да даже у переменных нет имён!

    А msg?


    1. SIISII
      18.09.2024 13:21

      ну, какую-нибудь функцию вроде main() на языке высокого уровня мы, скорей всего, увидели б. а msg -- это не переменная, это метка (имя для адреса памяти, а как сей адрес используется и что в той памяти -- про это msg ничего не знает)