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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

В архитектуре 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 Базовый указатель стека. Используется для навигации по стеку.

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

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

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

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

1 byte=8 bits1 \text{ byte} = 8 \text{ bits}

где 1 byte1 \text{ byte} — это один байт (минимальная адресуемая единица памяти), а 8 bits8 \text{ bits} — это восемь бит (нулей или единиц), из которых он состоит.

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

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

  1. NASM — компилятор (ассемблер), который превратит наш текст в объектный файл.
  2. Linker (ld) — компоновщик, который превратит объектный файл в исполняемую программу.

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

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

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

sudo apt update
sudo apt install nasm build-essential

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

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

nasm -v

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

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

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

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

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

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

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

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

section .data
    ; Сюда пишем переменные

section .bss
    ; Сюда пишем резервирование памяти

section .text
    global _start   ; Объявляем точку входа для линкера

_start:
    ; Здесь начинается выполнение программы
    ; ... код ...

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

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

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

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

section .data
    msg db "Hello, World!", 0xA  ; Строка байтов. 0xA — это символ новой строки
    len equ $ - msg              ; Вычисляем длину строки автоматически

section .text
    global _start

_start:
    ; 1. Вывод строки на экран
    mov rax, 1          ; Номер системного вызова 'write' (1)
    mov rdi, 1          ; Дескриптор файла: 1 = stdout (стандартный вывод)
    mov rsi, msg        ; Адрес строки, которую хотим вывести
    mov rdx, len        ; Количество байтов для вывода
    syscall             ; Просим ядро выполнить команду

    ; 2. Завершение программы
    mov rax, 60         ; Номер системного вызова 'exit' (60)
    mov rdi, 0          ; Код возврата (0 означает успех)
    syscall             ; Просим ядро выполнить команду

Разбор кода

  1. db (define byte) — определяет последовательность байтов. 0xA — это код символа переноса строки (\n).
  2. equ — определяет константу.
  3. Формула длины строки:

    L=Pcurrent−PstartL = P_{current} - P_{start}

    где LL — длина строки (значение len), PcurrentP_{current} — текущий адрес памяти (обозначается символом $), а PstartP_{start} — адрес начала строки (метка msg). Таким образом, мы вычитаем из текущего адреса адрес начала строки и получаем её длину.
  4. mov (move) — команда пересылки данных. mov rax, 1 означает «поместить число 1 в регистр RAX».
  5. syscall — команда, передающая управление операционной системе для выполнения действия, заданного в регистре RAX.

Компиляция и запуск

Теперь превратим этот код в программу. Выполните в терминале:

  1. Ассемблирование (создание объектного файла):

    nasm -f elf64 hello.asm -o hello.o
    

    Флаг -f elf64 указывает формат файла (Executable and Linkable Format, 64-bit).

  2. Компоновка (создание исполняемого файла):

    ld hello.o -o hello
    
  3. Запуск:

    ./hello
    

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

Заключение

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

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

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

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

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

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

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

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

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

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

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

  • 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-битного режима).

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

Nbytes=Nbits8N_{bytes} = \frac{N_{bits}}{8}

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

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

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

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

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

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

mov rax, rbx    ; Скопировать значение из RBX в RAX

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

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

mov rax, 100    ; Записать число 100 в регистр RAX
mov al, 0xA     ; Записать шестнадцатеричное число A (10) в регистр AL

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

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

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

section .data
    my_var db 50    ; Переменная (байт) со значением 50

section .text
    mov rax, my_var      ; ОШИБКА ЛОГИКИ: В RAX попадет АДРЕС переменной my_var
    mov al, [my_var]     ; ПРАВИЛЬНО: В AL попадет ЗНАЧЕНИЕ (50) из памяти по адресу my_var

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

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

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

mov rbx, my_var     ; Загружаем адрес переменной my_var в RBX
mov al, [rbx]       ; Берем значение из памяти, адрес которой лежит в RBX

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

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

mov rax, [rbx + 8]  ; Взять значение по адресу (RBX + 8 байт)

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

Aeff=Abase+DA_{eff} = A_{base} + D

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

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

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

mov [rbx], 10   ; Ошибка!

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

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

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

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

mov byte [rbx], 10    ; Записать 10 как 1 байт
mov qword [rbx], 10   ; Записать 10 как 8 байт (число 10 и куча нулей)

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

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

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

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

Действие:

Dest=Dest+SrcDest = Dest + Src

Dest=Dest−SrcDest = Dest - Src

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

Примеры:

mov rax, 10     ; RAX = 10
add rax, 5      ; RAX = 10 + 5 = 15
sub rax, 2      ; RAX = 15 - 2 = 13

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

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

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

mov rax, [var2] ; Загружаем второе число в регистр
add [var1], rax ; Складываем

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

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

  • inc op →\rightarrow op=op+1op = op + 1
  • dec op →\rightarrow op=op−1op = op - 1

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

inc rbx         ; Увеличить RBX на 1
dec byte [var]  ; Уменьшить байт в памяти на 1

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

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

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

Dest=Dest×SrcDest = Dest \times Src

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

mov rax, 3
mov rbx, 4
imul rax, rbx   ; RAX = 3 * 4 = 12

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

Давайте напишем программу, которая вычисляет выражение x=a+b−cx = a + b - c и возвращает результат как код выхода программы. Пусть a=10,b=20,c=5a=10, b=20, c=5. Ожидаемый результат: 25.

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

section .data
    val_a dq 10     ; dq = define quadword (8 байт), так как работаем с 64-битными регистрами
    val_b dq 20
    val_c dq 5

section .text
    global _start

_start:
    ; 1. Загружаем val_a в регистр RAX
    mov rax, [val_a]

    ; 2. Прибавляем val_b
    add rax, [val_b]    ; Теперь в RAX лежит 10 + 20 = 30

    ; 3. Вычитаем val_c
    sub rax, [val_c]    ; Теперь в RAX лежит 30 - 5 = 25

    ; 4. Завершаем программу, используя результат как код возврата
    ; Системный вызов exit требует код возврата в регистре RDI
    mov rdi, rax        ; Копируем результат (25) из RAX в RDI
    mov rax, 60         ; Номер системного вызова 'exit'
    syscall

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

nasm -f elf64 calc.asm -o calc.o
ld calc.o -o calc
./calc

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

echo $?

Вы должны увидеть число 25.

Заключение

Сегодня мы сделали огромный шаг вперед. Мы разобрали:

  1. Как «нарезать» регистры на части (RAX →\rightarrow EAX →\rightarrow AX →\rightarrow AL).
  2. Как различать адрес и значение с помощью [].
  3. Как указывать размер данных (byte, qword).
  4. Как выполнять базовую арифметику.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Temp=Operand1−Operand2\text{Temp} = \text{Operand1} - \text{Operand2}

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

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

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

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

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

    jmp my_label    ; Прыгнуть на метку my_label

    ; Этот код никогда не выполнится
    mov rax, 100

my_label:
    mov rax, 200    ; Выполнение продолжится здесь

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

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

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

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

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

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

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

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

section .text
    global _start

_start:
    mov rax, 15     ; Пример значения

    cmp rax, 10     ; Сравниваем RAX с 10
    jg greater      ; Если RAX > 10, прыгаем на метку greater

    ; Блок ELSE (выполняется, если прыжка не было)
    mov rbx, 0
    jmp end_if      ; ВАЖНО: нужно перепрыгнуть блок IF, чтобы не выполнить и его тоже

greater:
    ; Блок IF
    mov rbx, 1

end_if:
    ; Код продолжается здесь
    ; ... выход из программы ...
    mov rax, 60
    xor rdi, rdi
    syscall

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

Циклы

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

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

Алгоритм:

  1. Поместить счетчик 10 в регистр.
  2. Добавить счетчик к сумме.
  3. Уменьшить счетчик на 1.
  4. Если счетчик не равен 0, перейти к пункту 2.
section .text
    global _start

_start:
    mov rcx, 10     ; RCX - наш счетчик (Counter)
    mov rax, 0      ; RAX - здесь будем хранить сумму

my_loop:
    add rax, rcx    ; Добавляем текущее значение счетчика к сумме
    dec rcx         ; Уменьшаем счетчик на 1 (dec также обновляет флаги!)

    cmp rcx, 0      ; Сравниваем счетчик с нулем
    jne my_loop     ; Если не равно 0, прыгаем обратно на my_loop

    ; Здесь цикл завершен. В RAX лежит сумма.

    ; Завершение программы
    mov rdi, rax    ; Вернем сумму как код возврата (для проверки)
    mov rax, 60
    syscall

Оптимизация цикла

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

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

my_loop:
    add rax, rcx
    dec rcx         ; Если RCX станет 0, флаг ZF станет 1
    jnz my_loop     ; Jump if Not Zero (Прыгай, если не ноль)

Инструкция LOOP

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

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

  1. Уменьшает RCX на 1.
  2. Сравнивает RCX с 0.
  3. Если RCX != 0, прыгает на метку.
    mov rcx, 10
    mov rax, 0
start_loop:
    add rax, rcx
    loop start_loop ; Автоматически: dec rcx, jnz start_loop

Хотя loop выглядит удобнее, современные компиляторы и программисты часто предпочитают связку dec + jnz, так как она более гибкая и на некоторых процессорах выполняется быстрее.

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

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

section .data
    num1 dq 50
    num2 dq 80
    num3 dq 20

section .text
    global _start

_start:
    mov rax, [num1]     ; Предположим, что первое число - максимальное

    cmp [num2], rax     ; Сравниваем второе с текущим максимумом
    jle check_third     ; Если num2 <= rax, пропускаем обновление (Jump if Less or Equal)
    mov rax, [num2]     ; Иначе: обновляем максимум

check_third:
    cmp [num3], rax     ; Сравниваем третье с текущим максимумом
    jle finish          ; Если num3 <= rax, идем к концу
    mov rax, [num3]     ; Иначе: обновляем максимум

finish:
    ; В RAX теперь лежит самое большое число (80)
    mov rdi, rax
    mov rax, 60
    syscall

Заключение

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

  1. RFLAGS хранит состояние после операций.
  2. CMP сравнивает числа, обновляя флаги, но не меняя данные.
  3. JMP и Jcc позволяют прыгать по коду в зависимости от условий.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Математически это выглядит так:

RSPnew=RSPold−8RSP_{new} = RSP_{old} - 8

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

Пример:

mov rax, 0x12345678
push rax            ; Сохранить значение RAX в стек

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

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

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

RSPnew=RSPold+8RSP_{new} = RSP_{old} + 8

где RSPnewRSP_{new} — новое значение указателя стека, RSPoldRSP_{old} — текущее значение, а 88 — размер извлеченных данных в байтах.

Пример:

pop rbx             ; Загрузить значение с вершины стека в RBX

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

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

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

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

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

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

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

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

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

  1. Выполняет pop с вершины стека. Процессор предполагает, что там лежит адрес возврата.
  2. Переходит (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)

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

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

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

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

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

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

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

my_func:
    push rbp        ; 1. Сохраняем старый RBP (он callee-saved)
    mov rbp, rsp    ; 2. Делаем RBP базой для текущего фрейма
    sub rsp, 16     ; 3. Выделяем место под локальные переменные (например, 16 байт)

Теперь мы можем обращаться к локальным переменным через RBP:

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

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

    mov rsp, rbp    ; 1. Освобождаем локальные переменные (восстанавливаем RSP)
    pop rbp         ; 2. Восстанавливаем старый RBP
    ret             ; 3. Возврат

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

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

Напишем программу с функцией power, которая вычисляет xnx^n. Согласно конвенции:

  • xx (основание) будет в RDI.
  • nn (показатель) будет в RSI.
  • Результат вернем в RAX.
section .data
    result_msg db "Result: ", 0

section .text
    global _start

; --- Функция power ---
; Вход: RDI = base, RSI = exponent
; Выход: RAX = result
power:
    ; Пролог (здесь не обязателен, так как мы не используем локальные переменные в стеке,
    ; но напишем для порядка)
    push rbp
    mov rbp, rsp

    mov rax, 1          ; Инициализируем результат единицей
    cmp rsi, 0          ; Если степень 0, возвращаем 1
    je .done

.loop:
    imul rax, rdi       ; RAX = RAX * RDI (умножаем на основание)
    dec rsi             ; Уменьшаем счетчик степени
    cmp rsi, 0          ; Проверяем, не дошли ли до 0
    jg .loop            ; Если RSI > 0, повторяем

.done:
    ; Эпилог
    pop rbp             ; Восстанавливаем RBP
    ret                 ; Возврат

; --- Точка входа ---
_start:
    ; Вычислим 5 в степени 3 (5^3 = 125)

    mov rdi, 5          ; Аргумент 1: основание
    mov rsi, 3          ; Аргумент 2: степень

    call power          ; Вызов функции

    ; Теперь в RAX лежит 125.
    ; В реальной программе здесь был бы код вывода числа на экран.
    ; Для простоты вернем результат как код выхода программы.

    mov rdi, rax        ; Код возврата = результат вычисления
    mov rax, 60         ; Системный вызов exit
    syscall

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

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

Заключение

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

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

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

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

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

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

В этой статье мы откроем дверь в операционную систему. Мы разберем, как именно программа «просит» ядро 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).

section .data
    msg db "Hello, Syscall!", 0xA
    len equ $ - msg

section .text
    global _start

_start:
    ; --- sys_write (вывод в stdout) ---
    mov rax, 1          ; Номер системного вызова (sys_write)
    mov rdi, 1          ; Дескриптор файла (1 = stdout)
    mov rsi, msg        ; Адрес буфера с текстом
    mov rdx, len        ; Количество байт для записи
    syscall             ; Передача управления ядру

    ; --- sys_exit (выход) ---
    mov rax, 60         ; Номер системного вызова (sys_exit)
    mov rdi, 0          ; Код возврата (0 = успех)
    syscall

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

Макросы в NASM

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

Чем макрос отличается от процедуры?

  • Процедура (Function/Subroutine): Код находится в памяти в одном экземпляре. При вызове call процессор прыгает туда, выполняет код и возвращается (ret). Экономит память, но тратит время на прыжки и работу со стеком.
  • Макрос (Macro): Это инструкция препроцессору (компилятору). Когда вы используете макрос, компилятор просто копирует и вставляет тело макроса в место вызова. Это работает быстрее (нет прыжков), но увеличивает размер исполняемого файла, если макрос огромный.

Объявление и использование

В NASM макросы объявляются директивами %macro и %endmacro.

Синтаксис:

%macro имя_макроса количество_аргументов
    ; Тело макроса
    ; Доступ к аргументам через %1, %2, %3...
%endmacro

Давайте напишем макрос для завершения программы exit_program:

%macro exit_program 1
    mov rax, 60     ; sys_exit
    mov rdi, %1     ; Первый аргумент макроса (код возврата)
    syscall
%endmacro

section .text
    global _start

_start:
    ; Какой-то код...

    exit_program 0  ; Компилятор заменит эту строку на 3 инструкции выше

Макрос для печати строки

Теперь создадим удобный инструмент для вывода текста.

%macro print_str 2
    ; %1 = адрес строки
    ; %2 = длина строки
    mov rax, 1      ; sys_write
    mov rdi, 1      ; stdout
    mov rsi, %1
    mov rdx, %2
    syscall
%endmacro

Теперь ваша программа станет намного чище:

_start:
    print_str msg, len
    exit_program 0

Совет: Используйте макросы для коротких, часто повторяющихся кусков кода (системные вызовы, сохранение регистров). Для сложной логики лучше использовать процедуры.

Операции со строками

В ассемблере «строка» — это просто последовательность байтов в памяти. Часто нам нужно скопировать строку, сравнить две строки или найти в ней символ. Можно делать это в цикле через mov и cmp, но архитектура x86 предоставляет специальные строковые инструкции, которые делают это аппаратно и эффективно.

Эти инструкции работают с регистрами RSI (Source Index — источник) и RDI (Destination Index — назначение).

Флаг направления (Direction Flag)

Перед работой со строками важно знать, в какую сторону мы движемся: от начала к концу или наоборот. За это отвечает флаг DF в регистре RFLAGS.

  • Если DF = 0: Индексы (RSI/RDI) увеличиваются (идем вперед). Устанавливается командой cld (Clear Direction Flag).
  • Если DF = 1: Индексы уменьшаются (идем назад). Устанавливается командой std (Set Direction Flag).

Математически изменение адреса после выполнения строковой операции описывается формулой:

Anew=Aold±SA_{new} = A_{old} \pm S

где AnewA_{new} — новый адрес в регистре (RSI или RDI), AoldA_{old} — старый адрес, а SS — размер операнда в байтах (1, 2, 4 или 8). Знак плюс или минус зависит от флага DF.

Основные инструкции

Все эти инструкции могут иметь суффиксы размера: b (byte), w (word), d (dword), q (qword).

  1. MOVSB / MOVSW / MOVSQ (Move String) Копирует данные из адреса [RSI] в адрес [RDI]. Автоматически изменяет RSI и RDI.

  2. SCASB (Scan String) Сравнивает значение в аккумуляторе (AL/AX/EAX/RAX) с байтом по адресу [RDI]. Обновляет флаги процессора (как cmp). Используется для поиска символа.

  3. STOSB (Store String) Копирует значение из AL в [RDI]. Удобно для заполнения памяти одним значением (например, обнуление массива).

  4. LODSB (Load String) Копирует значение из [RSI] в AL.

Префиксы повторения (REP)

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

  • REP — Повторять, пока RCX > 0. (Обычно используется с movs или stos).
  • REPE / REPZ — Повторять, пока RCX > 0 И ZF = 1 (пока элементы равны). Используется для сравнения строк (cmps).
  • REPNE / REPNZ — Повторять, пока RCX > 0 И ZF = 0 (пока элементы НЕ равны). Используется для поиска (scas).

Пример 1: Копирование строки (аналог memcpy)

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"

Пример 2: Вычисление длины строки (аналог strlen)

Это классическая задача. Мы будем искать нулевой байт (конец строки) с помощью scasb.

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

Заключение

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

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

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