Основы программирования на ассемблере NASM

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

1. Введение в архитектуру x86, установка окружения и структура программы

Введение в архитектуру x86, установка окружения и структура программы

Добро пожаловать в курс «Основы программирования на ассемблере NASM». Это первая статья, с которой начнется ваше погружение в мир низкоуровневого программирования. Здесь нет магии, скрытой за абстракциями языков высокого уровня вроде Python или Java. Здесь есть только вы, процессор и память.

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

Что такое язык Ассемблера?

Язык ассемблера (Assembly language) — это человекочитаемое представление машинного кода. Компьютерный процессор не понимает слова «print» или «while». Он понимает только наборы нулей и единиц, которые представляют собой электрические сигналы. Эти наборы называются машинными инструкциями.

Ассемблер — это тонкая прослойка между машинным кодом и программистом. Каждая команда на ассемблере (например, mov или add) обычно соответствует ровно одной машинной инструкции процессора. Это дает вам полный контроль над оборудованием, но и возлагает на вас ответственность за каждый байт и каждый такт процессора.

Мы будем изучать NASM (Netwide Assembler). Это один из самых популярных, гибких и широко используемых ассемблеров для архитектуры x86.

Архитектура x86: Взгляд изнутри

Чтобы писать на ассемблере, нужно понимать, для чего мы пишем. Мы пишем команды для архитектуры x86 (и её 64-битного расширения x86-64). Это архитектура, на которой работают большинство современных персональных компьютеров и серверов (процессоры Intel и AMD).

Ключевые компоненты, с которыми нам предстоит работать:

  • Процессор (CPU) — «мозг», выполняющий инструкции.
  • Оперативная память (RAM) — «рабочий стол», где хранятся данные и код программы во время выполнения.
  • Регистры — сверхбыстрая память, находящаяся прямо внутри процессора.
  • !Схема взаимодействия процессора, регистров и оперативной памяти

    Регистры процессора

    Регистры — это самое важное понятие для начала. Представьте, что оперативная память — это огромный склад, а регистры — это ваши руки. Чтобы что-то сделать с данными (сложить, сравнить), вы должны сначала взять их со склада (памяти) в руки (регистры).

    В архитектуре x86 существует несколько типов регистров. Мы начнем с регистров общего назначения. В 32-битной архитектуре они имеют размер 32 бита (4 байта) и начинаются с буквы E (Extended). В 64-битной — начинаются с R и имеют размер 64 бита.

    Для простоты понимания в этом курсе мы будем часто обращаться к 64-битным регистрам, так как это современный стандарт, но помнить об их 32-битных «предках».

    Основные регистры общего назначения:

    | Регистр (64-bit) | Регистр (32-bit) | Назначение | | :--- | :--- | :--- | | RAX | EAX | Аккумулятор. Используется для арифметики и возврата значений функций. | | RBX | EBX | Базовый регистр. Часто используется как указатель на данные. | | RCX | ECX | Счетчик. Используется в циклах. | | RDX | EDX | Регистр данных. Используется при умножении/делении и вводе/выводе. | | RSI | ESI | Индекс источника. Используется при копировании данных. | | RDI | EDI | Индекс назначения. Используется при копировании данных. | | RSP | ESP | Указатель стека. Указывает на вершину стека (о стеке поговорим позже). | | RBP | EBP | Базовый указатель стека. Используется для навигации по стеку. |

    > Регистры — это самые быстрые ячейки памяти в компьютере. Оптимизация работы с ними — ключ к производительности.

    Память и адресация

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

    Размер памяти измеряется в байтах. Связь между битами и байтами выражается формулой:

    где — это один байт (минимальная адресуемая единица памяти), а — это восемь бит (нулей или единиц), из которых он состоит.

    Установка окружения

    Для работы нам понадобятся два инструмента:

  • NASM — компилятор (ассемблер), который превратит наш текст в объектный файл.
  • Linker (ld) — компоновщик, который превратит объектный файл в исполняемую программу.
  • Мы будем ориентироваться на работу в среде Linux, так как это «родная» среда для изучения системного программирования. Если у вас Windows, рекомендуется использовать WSL (Windows Subsystem for Linux) или виртуальную машину.

    Установка в Linux (Ubuntu/Debian)

    Откройте терминал и введите следующие команды:

    Пакет build-essential установит необходимые утилиты, включая компоновщик ld.

    Проверьте установку:

    Вы должны увидеть версию NASM.

    Структура программы на NASM

    Программа на ассемблере NASM имеет четкую структуру. Она делится на секции (sections). Секции помогают операционной системе понять, как обращаться с разными частями вашей программы.

    Стандартная программа состоит из трех основных секций:

  • section .data
  • Здесь объявляются инициализированные данные. Это переменные, которые имеют значение при запуске программы (константы, строки).

  • section .bss
  • Здесь объявляются неинициализированные данные. Это место под переменные, значения которых мы пока не знаем, но резервируем под них память.

  • section .text
  • Это самая важная секция. Здесь находится сам код программы.

    Пример шаблона программы

    Обратите внимание на global _start. Метка _start — это стандартное имя точки входа в программу для линкера ld в Linux. Директива global делает эту метку видимой извне, чтобы линкер мог её найти.

    Первая программа: «Hello, World!»

    Давайте напишем программу, которая выводит строку «Hello, World!» в терминал и корректно завершается. Мы будем использовать системные вызовы (syscalls) ядра Linux.

    Создайте файл hello.asm и вставьте туда следующий код:

    bash nasm -f elf64 hello.asm -o hello.o bash ld hello.o -o hello bash ./hello ``

    Если вы увидели надпись Hello, World!`, поздравляю! Вы написали и запустили свою первую программу на ассемблере.

    Заключение

    Сегодня мы познакомились с фундаментом: архитектурой x86, регистрами и структурой программы на NASM. Мы узнали, что программа состоит из секций данных и кода, и научились использовать системные вызовы для общения с ОС.

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

    2. Регистры процессора, режимы адресации и базовая арифметика

    Регистры процессора, режимы адресации и базовая арифметика

    В предыдущей статье мы написали нашу первую программу «Hello, World!» и разобрали общую структуру кода на NASM. Теперь пришло время перейти от простого вывода текста к настоящей работе: вычислениям и манипуляции данными.

    Чтобы программировать на ассемблере, нужно научиться думать так, как «думает» процессор. В этой статье мы детально разберем его «рабочий стол» (регистры), научимся доставать данные из памяти разными способами (режимы адресации) и выполнять базовые арифметические операции.

    Анатомия регистров: от 64 до 8 бит

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

    К счастью, регистры x86 устроены по принципу «матрешки». Вы можете обращаться к их частям как к отдельным регистрам.

    Рассмотрим это на примере регистра-аккумулятора RAX.

    !Структура регистра RAX и его составные части: EAX, AX, AH, AL

    Эта иерархия выглядит следующим образом:

    * RAX (64 бита) — весь регистр целиком. * EAX (32 бита) — младшая половина RAX. Это основной регистр в 32-битных системах. * AX (16 бит) — младшая половина EAX. * AL (8 бит) — младшая половина AX (Low). * AH (8 бит) — старшая половина AX (High).

    Это справедливо для регистров A, B, C и D (RAX, RBX, RCX, RDX). Для остальных регистров (RSI, RDI, RBP, RSP) доступны 64, 32, 16 бит и младшие 8 бит (например, SIL, DIL), но у них нет выделенной «старшей» 8-битной части (как AH).

    Почему это важно?

    Если вы запишете число в AL, вы измените младший байт RAX. Если вы запишете число в EAX, старшие 32 бита RAX будут автоматически обнулены (это особенность 64-битного режима).

    Размер данных определяется формулой:

    где — количество байтов, а — количество бит. Например, регистр AX имеет размер 16 бит, что составляет 2 байта.

    Режимы адресации: Как достать данные

    Команда mov (move) — это «рабочая лошадка» ассемблера. Она копирует данные из источника в приемник. Но откуда и куда? В NASM существует несколько способов указать операнды. Это называется режимами адресации.

    Синтаксис команды: mov приемник, источник

    1. Регистровая адресация

    Самый быстрый способ. Данные перемещаются между регистрами.

    2. Непосредственная адресация (Immediate)

    Мы указываем конкретное число (константу) прямо в коде.

    3. Прямая адресация (Direct)

    Мы обращаемся к переменной по её имени (метке). В ассемблере имя переменной — это, по сути, её адрес в памяти.

    Важно: Чтобы получить значение переменной, а не её адрес, в NASM используются квадратные скобки [].

    > Квадратные скобки [] в ассемблере — это операция разыменования. Они говорят процессору: «Сходи по этому адресу и возьми то, что там лежит».

    4. Косвенная регистровая адресация

    Адрес данных хранится в регистре. Это аналог указателей в C/C++.

    5. Базовая адресация со смещением

    Используется для доступа к полям структур или элементам массивов. Мы берем адрес из регистра и добавляем к нему константное смещение.

    Математически адрес вычисляется так:

    где — эффективный адрес памяти, — базовый адрес (в регистре), а — смещение (displacement).

    Размерность операндов и директивы размера

    Ассемблер не всегда может угадать, сколько байт вы хотите скопировать. Рассмотрим пример:

    Процессор знает адрес (в RBX) и знает значение (10). Но он не знает, сколько памяти занять под число 10. Записать его как 1 байт? Как 2 байта? Или как 8 байт?

    В таких случаях мы обязаны явно указать размер с помощью директив:

    * byte — 1 байт (8 бит) * word — 2 байта (16 бит) * dword (double word) — 4 байта (32 бита) * qword (quad word) — 8 байт (64 бита)

    Исправленный вариант:

    Базовая арифметика

    Теперь, когда мы умеем перемещать данные, давайте научимся их обрабатывать. Арифметические команды изменяют значения флагов процессора (о которых мы поговорим позже), что позволяет строить логику ветвления.

    Сложение (ADD) и Вычитание (SUB)

    Синтаксис: add приемник, источник sub приемник, источник

    Действие:

    где — приемник (куда запишется результат), а — источник.

    Примеры:

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

    ~~add [var1], [var2]~~ — Запрещено!

    Нужно делать так:

    Инкремент (INC) и Декремент (DEC)

    Это оптимизированные команды для прибавления и вычитания единицы.

    * inc op * dec op

    Они работают быстрее и занимают меньше места в машинном коде, чем add rax, 1.

    Умножение (IMUL)

    С умножением все немного сложнее, но для начала мы рассмотрим простую форму целочисленного умножения со знаком — imul.

    Двухоперандная форма: imul приемник, источник

    где — приемник (и первый множитель), а — источник (второй множитель).

    Практика: Программа-калькулятор

    Давайте напишем программу, которая вычисляет выражение и возвращает результат как код выхода программы. Пусть . Ожидаемый результат: 25.

    Создайте файл calc.asm:

    Компиляция и проверка

    После запуска программа ничего не выведет на экран, так как мы не использовали write. Однако результат сохранился в коде возврата. Проверить его в Linux можно командой:

    ``bash echo \rightarrow\rightarrow\rightarrow$ AL).

  • Как различать адрес и значение с помощью [].
  • Как указывать размер данных (byte, qword).
  • Как выполнять базовую арифметику.
  • В следующей статье мы научимся управлять потоком выполнения программы: использовать условные переходы (jmp, je, jne`) и создавать циклы, что позволит нам реализовывать сложные алгоритмы.

    3. Управление потоком выполнения: флаги, условные переходы и циклы

    Управление потоком выполнения: флаги, условные переходы и циклы

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

    В языках высокого уровня для этого есть конструкции if, else, while и for. В ассемблере этих слов не существует. Вместо них у нас есть флаги, сравнения и переходы (jumps).

    В этой статье мы научимся принимать решения и зацикливать выполнение кода, превращая линейный скрипт в полноценный алгоритм.

    Регистр флагов RFLAGS

    Как процессор запоминает результат последней операции? Если вы сложили два числа, и получился ноль, где хранится информация об этом событии? Она хранится в специальном регистре RFLAGS.

    RFLAGS — это набор битов (флагов), каждый из которых отвечает за определенное состояние процессора. Мы не записываем в этот регистр данные напрямую (как в RAX), но арифметические команды обновляют его автоматически.

    !Структура регистра флагов с акцентом на ключевые флаги состояния.

    Рассмотрим самые важные флаги для управления потоком:

  • ZF (Zero Flag) — Флаг нуля.
  • Устанавливается в 1, если результат операции равен нулю. Если результат не ноль, флаг сбрасывается в 0. Пример: sub rax, rax (вычитание числа из самого себя) сделает .

  • SF (Sign Flag) — Флаг знака.
  • Устанавливается в 1, если результат операции отрицательный (старший бит равен 1). Иначе 0.

  • CF (Carry Flag) — Флаг переноса.
  • Используется при беззнаковой арифметике. Устанавливается в 1, если произошел перенос из старшего разряда (например, число стало больше, чем вмещает регистр) или заем.

  • OF (Overflow Flag) — Флаг переполнения.
  • Используется при знаковой арифметике. Устанавливается в 1, если результат операции не помещается в отведенный размер со знаком.

    Инструкция сравнения CMP

    Чтобы принять решение, нужно что-то с чем-то сравнить. Для этого используется инструкция cmp (compare).

    Синтаксис: cmp операнд1, операнд2

    Как она работает? Очень просто: она выполняет вычитание, но не сохраняет результат.

    где — временное значение, которое никуда не записывается, — первый операнд, — второй операнд. Единственная цель этой операции — обновить флаги в RFLAGS.

    Представим, что мы выполняем cmp rax, rbx:

    * Если RAX == RBX, то разность равна 0. Следовательно, устанавливается флаг ZF = 1. * Если RAX < RBX, то разность отрицательная. Устанавливается флаг SF = 1 (для знаковых чисел). * Если RAX > RBX, то разность положительная. Флаги ZF = 0 и SF = 0.

    Безусловный переход JMP

    Самый простой способ изменить ход программы — это инструкция jmp (jump). Это аналог печально известного GOTO. Она просто говорит процессору: «Брось всё и начни выполнять код с этой метки».

    Условные переходы (Jcc)

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

    Названия инструкций обычно расшифровываются как «Jump if Condition» (Прыгни, если...).

    Основные переходы после CMP

    | Инструкция | Расшифровка | Условие срабатывания | Описание | | :--- | :--- | :--- | :--- | | JE / JZ | Jump if Equal / Zero | | Прыжок, если равно (или результат 0) | | JNE / JNZ | Jump if Not Equal / Not Zero | | Прыжок, если НЕ равно (или результат не 0) | | JG | Jump if Greater | и | Прыжок, если больше (для знаковых чисел) | | JL | Jump if Less | | Прыжок, если меньше (для знаковых чисел) | | JGE | Jump if Greater or Equal | | Прыжок, если больше или равно (знаковые) | | JLE | Jump if Less or Equal | или | Прыжок, если меньше или равно (знаковые) |

    > Важно: Для беззнаковых чисел (unsigned) используются другие инструкции: JA (Above) вместо JG и JB (Below) вместо JL. Путать их — частая ошибка новичков.

    Реализация ветвления (If-Else)

    Давайте реализуем логику: Если RAX > 10, то RBX = 1, иначе RBX = 0.

    Обратите внимание на структуру. В ассемблере блоки кода идут друг за другом. Если вы не напишете jmp end_if в конце блока else, процессор просто «провалится» дальше и выполнит код блока greater.

    Циклы

    В ассемблере нет цикла for или while. Цикл — это просто метка, тело цикла и условный переход назад на метку.

    Рассмотрим классический пример: сумма чисел от 10 до 1.

    Алгоритм:

  • Поместить счетчик 10 в регистр.
  • Добавить счетчик к сумме.
  • Уменьшить счетчик на 1.
  • Если счетчик не равен 0, перейти к пункту 2.
  • Оптимизация цикла

    Инструкция dec (decrement) автоматически устанавливает флаг ZF, если результат стал нулем. Поэтому явный cmp rcx, 0 часто избыточен.

    Упрощенный вариант:

    Инструкция LOOP

    Архитектура x86 имеет специальную команду loop. Она работает только с регистром RCX (или ECX/CX).

    Команда loop label делает следующее:

  • Уменьшает RCX на 1.
  • Сравнивает RCX с 0.
  • Если RCX != 0, прыгает на метку.
  • Хотя loop выглядит удобнее, современные компиляторы и программисты часто предпочитают связку dec + jnz, так как она более гибкая и на некоторых процессорах выполняется быстрее.

    Практическое задание: Поиск максимума

    Давайте напишем программу, которая находит максимальное число в «массиве» из трех переменных и возвращает его.

    Заключение

    Теперь вы владеете инструментами для создания нелинейных алгоритмов. Вы знаете, что:

  • RFLAGS хранит состояние после операций.
  • CMP сравнивает числа, обновляя флаги, но не меняя данные.
  • JMP и Jcc позволяют прыгать по коду в зависимости от условий.
  • В следующей статье мы разберем одну из самых сложных, но необходимых тем: Стек. Мы узнаем, как работает память при вызове функций и как сохранять данные временно, не используя кучу регистров.

    4. Работа со стеком, процедуры и соглашения о вызовах функций

    Работа со стеком, процедуры и соглашения о вызовах функций

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

    По мере роста кода писать всё в одной куче становится невозможно. В языках высокого уровня мы разбиваем задачу на функции (или процедуры). В ассемблере функции тоже существуют, но, как и всё остальное, они требуют ручного управления. Чтобы функции могли вызывать друг друга и не «ломать» данные, нам понадобится стек.

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

    Что такое стек?

    Стек (Stack) — это область оперативной памяти, выделенная для временного хранения данных. Он работает по принципу LIFO (Last In, First Out — «последним пришел, первым ушел»).

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

    !Схематичное изображение стека в памяти и аналогия со стопкой тарелок.

    В архитектуре x86-64 за работу со стеком отвечает регистр RSP (Stack Pointer). Он всегда хранит адрес вершины стека — то есть адрес последнего добавленного элемента.

    Особенность роста стека

    Важный и контринтуитивный момент: в архитектуре x86 стек растет вниз, в сторону уменьшения адресов памяти.

    * Когда мы добавляем данные, адрес в RSP уменьшается. * Когда мы забираем данные, адрес в RSP увеличивается.

    Инструкции PUSH и POP

    Для работы со стеком используются две основные команды: push (положить) и pop (достать).

    PUSH: Сохранение данных

    Команда push операнд делает две вещи:

  • Уменьшает значение регистра RSP на размер операнда (в 64-битном режиме обычно на 8 байт).
  • Записывает значение операнда в память по новому адресу RSP.
  • Математически это выглядит так:

    где — новое значение указателя стека после операции, — старое значение до операции, а — количество байт (размер 64-битного регистра).

    Пример:

    POP: Извлечение данных

    Команда pop операнд выполняет обратные действия:

  • Читает данные из памяти по текущему адресу RSP и записывает их в операнд.
  • Увеличивает значение RSP на 8 байт.
  • где — новое значение указателя стека, — текущее значение, а — размер извлеченных данных в байтах.

    Пример:

    > Важно: Количество push и pop должно совпадать. Если вы положили в стек 3 числа, а забрали только 2, при возврате из функции программа скорее всего «упадет» (crash), так как адрес возврата будет считан неверно.

    Процедуры: CALL и RET

    Процедура (или функция) в ассемблере — это просто блок кода с меткой, к которому мы переходим, а потом возвращаемся обратно.

    Для вызова процедур используется инструкция call, а для возврата — ret.

    Как работает CALL

    Когда процессор видит call my_func, он не просто прыгает на метку my_func. Ему нужно знать, куда вернуться после завершения функции. Поэтому:

  • Процессор берет адрес следующей инструкции (той, что идет сразу после call) и делает push этого адреса в стек.
  • Выполняет безусловный переход (jmp) на метку функции.
  • Как работает RET

    Инструкция ret (return) делает обратное:

  • Выполняет pop с вершины стека. Процессор предполагает, что там лежит адрес возврата.
  • Переходит (jmp) по этому извлеченному адресу.
  • Именно поэтому баланс стека критически важен. Если внутри функции вы сделали push rax и забыли сделать pop rax, то команда ret возьмет с вершины стека не адрес возврата, а ваше число из RAX. Программа попытается прыгнуть по адресу, равному вашему числу, и завершится с ошибкой Segmentation Fault.

    Соглашения о вызовах (Calling Conventions)

    Представьте, что вы пишете функцию sum(a, b). В каких регистрах вы будете ожидать a и b? В RAX и RBX? Или в RDI и RSI? А где вернуть результат?

    Чтобы функции, написанные разными программистами (или компиляторами C++), понимали друг друга, существует стандарт — ABI (Application Binary Interface). В Linux для 64-битных систем используется стандарт System V AMD64 ABI.

    Передача аргументов

    Первые 6 целочисленных аргументов передаются через регистры в строгом порядке:

    | Аргумент № | Регистр | | :--- | :--- | | 1 | RDI | | 2 | RSI | | 3 | RDX | | 4 | RCX | | 5 | R8 | | 6 | R9 |

    Если аргументов больше 6, остальные передаются через стек (но это встречается редко в простых программах).

    Возврат значения

    Результат работы функции (целое число или указатель) всегда помещается в регистр RAX.

    Сохранность регистров (Volatile vs Non-volatile)

    Это одно из самых важных правил. Регистры делятся на две группы:

  • Caller-saved (можно менять свободно): RAX, RCX, RDX, RDI, RSI, R8-R11.
  • Функция может менять их как угодно. Если вызывающей стороне (Caller) нужны значения в этих регистрах, она сама должна их сохранить перед вызовом.

  • Callee-saved (нужно сохранять): RBX, RBP, RSP, R12-R15.
  • Функция обязана вернуть эти регистры в том же состоянии, в котором они были при входе. Если вы хотите использовать RBX внутри своей функции, вы должны сначала сделать push rbx, а перед выходом — pop rbx.

    Стек-фрейм и локальные переменные

    Часто функции нужны свои локальные переменные, которые живут только во время её выполнения. Регистров на всех не хватит, поэтому используется стек.

    Область стека, выделенная под конкретный вызов функции, называется стек-фреймом (Stack Frame). Для навигации по нему используется регистр RBP (Base Pointer).

    Стандартный пролог (начало) функции:

    Теперь мы можем обращаться к локальным переменным через RBP: * [rbp - 4] — первая локальная переменная. * [rbp - 8] — вторая. * Аргументы, переданные через стек (если их > 6), будут доступны как [rbp + 16] и т.д.

    Стандартный эпилог (конец) функции:

    В NASM для эпилога есть специальная инструкция leave, которая заменяет mov rsp, rbp и pop rbp.

    Практика: Функция возведения в степень

    Напишем программу с функцией power, которая вычисляет . Согласно конвенции: * (основание) будет в RDI. * (показатель) будет в RSI. * Результат вернем в RAX.

    Разбор примера

  • В _start мы подготовили аргументы в RDI и RSI.
  • call power положил адрес возврата в стек и прыгнул в power.
  • Внутри power мы использовали imul для умножения.
  • Мы не использовали регистр RBX, поэтому сохранять его не пришлось.
  • ret вернул управление обратно в _start.
  • Заключение

    Сегодня мы разобрали фундамент модульного программирования на ассемблере:

  • Стек — это память LIFO, растущая вниз.
  • RSP указывает на вершину стека.
  • CALL и RET используют стек для навигации по коду.
  • Соглашения о вызовах (System V ABI) определяют, кто и где хранит данные (аргументы в RDI, RSI..., результат в RAX).
  • В следующей статье мы рассмотрим работу с памятью более детально: массивы, строки и динамическое выделение памяти.

    5. Системные вызовы, макросы и операции со строками

    Системные вызовы, макросы и операции со строками

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

    В этой статье мы откроем дверь в операционную систему. Мы разберем, как именно программа «просит» ядро Linux сделать что-то полезное (например, вывести текст или прочитать файл). Кроме того, мы научимся писать макросы, чтобы не повторять один и тот же код, и освоим мощные инструкции для обработки строк.

    Системные вызовы (Syscalls)

    Ваша программа работает в так называемом пространстве пользователя (User Space). В этом режиме у неё ограниченные права: она не может напрямую управлять жестким диском, сетевой картой или даже выводить пиксели на экран. Всем оборудованием управляет ядро операционной системы (Kernel).

    Чтобы получить доступ к ресурсам, программа должна совершить системный вызов. Это похоже на то, как вы звоните на ресепшн в отеле, чтобы заказать уборку. Вы не убираете сами, вы просите персонал сделать это.

    !Схематичное изображение того, как приложение обращается к ядру операционной системы через интерфейс системных вызовов.

    Механизм системного вызова в x86-64

    В 64-битной архитектуре Linux для этого используется инструкция syscall. Но перед тем как её вызвать, нужно положить правильные значения в регистры.

    Таблица регистров для syscall:

    | Параметр | Регистр | | :--- | :--- | | Номер системного вызова | RAX | | Аргумент 1 | RDI | | Аргумент 2 | RSI | | Аргумент 3 | RDX | | Аргумент 4 | R10 | | Аргумент 5 | R8 | | Аргумент 6 | R9 |

    > Обратите внимание: Это соглашение немного отличается от вызова обычных функций (System V ABI), где 4-м аргументом является RCX. В системных вызовах 4-й аргумент — это R10, так как RCX используется самой инструкцией syscall для сохранения адреса возврата.

    Пример: sys_write и sys_exit

    Давайте вспомним наш «Hello World» и разберем его осознанно. Нам понадобятся номера системных вызовов. Их можно найти в таблицах syscalls для Linux x64 (обычно sys_write — это 1, sys_exit — это 60).

    nasm section .data source db "Hello", 0 dest times 10 db 0

    section .text global _start

    _start: cld ; Движемся вперед (DF=0) mov rsi, source ; Откуда копируем mov rdi, dest ; Куда копируем mov rcx, 5 ; Сколько байт копировать rep movsb ; Выполнить movsb 5 раз ; Теперь в dest лежит "Hello" nasm section .data text db "Assembly is power", 0

    section .text global _start

    _start: mov rdi, text ; Адрес строки mov rcx, -1 ; Устанавливаем максимальное значение счетчика (FFFF...) xor al, al ; Ищем байт 0 (конец строки) cld ; Идем вперед repne scasb ; Сканируем, пока НЕ найдем 0 (или пока RCX не кончится)

    ; Теперь вычислим длину. ; RCX уменьшался каждый раз. ; Начальное значение: -1 (все единицы в бинарном виде) ; Текущее значение: -1 - (длина + 1) not rcx ; Инвертируем биты (эквивалентно превращению в положительное число - 1) dec rcx ; Вычитаем 1, так как scasb прошел и сам нуль-терминатор

    ; В RCX теперь длина строки ; Завершение mov rax, 60 mov rdi, rcx ; Вернем длину как код возврата syscall ``

    Заключение

    Сегодня мы значительно расширили арсенал наших возможностей:

  • Мы научились общаться с ядром Linux через системные вызовы, что позволяет нам читать файлы, писать в консоль и управлять процессами.
  • Мы освоили макросы, которые помогают писать более чистый и понятный код без накладных расходов на вызов функций.
  • Мы разобрали строковые инструкции (movsb, scasb) и префиксы повторения (rep`), которые позволяют обрабатывать массивы данных с максимальной эффективностью.
  • В следующей части курса мы объединим все полученные знания и напишем полноценную программу для работы с файлами.