Стек в ассемблере — одна из фундаментальных структур на уровне процессора. Без него не работают ни вызовы функций, ни сохранение промежуточных значений, ни обработка аппаратных прерываний. Большинство конструкций высокоуровневых языков — локальные переменные, рекурсия, передача аргументов — компилятор превращает именно в операции с этой структурой.
Материал пригодится тем, кто пишет процедуры на ассемблере, изучает архитектуру x86/x64, разбирает дизассемблированный код или хочет понять, что происходит «под капотом» при вызове функций.
По данным Intel Software Developer’s Manual (2024), стек реализован аппаратно: процессор автоматически управляет указателем стека при каждой операции PUSH, POP, CALL и RET без участия программиста.
Как устроен стек: принцип LIFO и указатель SP

Стек работает по принципу LIFO — Last In, First Out. Последний помещённый элемент извлекается первым. Представьте стопку книг: чтобы добраться до нижней, придётся снять все, что лежат сверху. Именно так организована эта структура в памяти — вы всегда обращаетесь только к верхнему элементу.
Текущую вершину отслеживает специальный регистр-указатель. Его имя, размер и диапазон адресов зависят от разрядности режима работы:
| Разрядность | Регистр-указатель | Размер элемента | Диапазон адресов |
|---|---|---|---|
| 16 бит | SP | 2 байта (слово) | 0x0000–0xFFFF |
| 32 бит | ESP | 4 байта (двойное слово) | 0x00000000–0xFFFFFFFF |
| 64 бит | RSP | 8 байт (четверное слово) | до 0xFFFFFFFFFFFFFFFF |
Стек растёт в сторону меньших адресов. При добавлении элемента указатель уменьшается, при извлечении — увеличивается. Это противоречит интуиции, но именно так спроектирован x86: архитектурное решение восходит к первым 16-битным процессорам Intel. Понимать направление роста важно при отладке — иначе ссылки вида [ESP-4] или [RSP+16] в дизассемблере останутся загадкой.
Занесение в стек уменьшает указатель, извлечение — увеличивает. Стек растёт вниз — от старших адресов к младшим.
Сегмент стека и адресация SS:SP
В 16-битном режиме физический адрес вершины вычисляется через пару регистров SS:SP. SS — сегментный регистр стека, SP — смещение внутри сегмента. Вместе они задают конкретный адрес в памяти: куда следующий элемент запишется и откуда будет прочитан.
В 32- и 64-битных режимах сегментная адресация отходит на второй план. ESP и RSP хранят линейный адрес напрямую, стек располагается в плоском адресном пространстве процесса. При отладке 32/64-битного кода ориентироваться нужно на ESP/RSP, а не на пару SS:SP. Смещения от базового указателя — [EBP+8], [RBP-16] — стандартная техника обращения к локальным переменным и параметрам функций.
Изменять указатель напрямую допустимо только намеренно — например, при резервировании памяти для локальных переменных командой SUB ESP, N. В остальных случаях прямая запись в него нарушает логику вызовов и ведёт к непредсказуемому поведению программы.
Команда PUSH: синтаксис и работа с памятью

PUSH записывает значение на вершину. Синтаксис одинаков для всех разрядностей:
PUSH источник
Каждый раз при выполнении происходит два последовательных действия:
- Указатель уменьшается на размер операнда — 2, 4 или 8 байт.
- Значение источника записывается по адресу, на который теперь указывает регистр.
SP = SP − размер_операнда
[SS:SP] = значение_операнда
Важен именно порядок: сначала смещение указателя, потом запись. Если бы запись шла первой, данные перезаписали бы последнее значение, не сохранив его. Это ключевое архитектурное решение, которое обеспечивает корректное поведение структуры при вложенных вызовах.
Пример для 16-битного режима. Пусть SP = 0xFFFE, AX = 0x1234:
PUSH AX ⠀⠀⠀ ; SP → 0xFFFC, по адресу SS:0xFFFC записывается 0x1234
PUSH 5 ⠀⠀⠀⠀ ; SP → 0xFFFA, по адресу SS:0xFFFA записывается 0x0005
PUSH [BX] ⠀ ; SP → 0xFFF8, в стек попадает содержимое ячейки памяти по адресу BX
Командой PUSH ассемблера можно работать с тремя категориями источников: регистры общего назначения, непосредственные значения и ячейки памяти. Это делает её универсальным инструментом — сохранить можно как значение из любого из них, так и константу за одну инструкцию.
При PUSH 5 ассемблер автоматически расширяет константу до размера слова — 2, 4 или 8 байт в зависимости от режима. Писать явное приведение типа не нужно. Но если константа не влезает в знаковый байт (больше 127 или меньше −128), ассемблер всё равно разместит её как полное 32-битное значение.
Допустимые операнды: регистры, константы и флаги
PUSH принимает несколько категорий источников:
- 16/32/64-разрядный регистр:
AX,EBX,RAX - Непосредственное значение:
10h,0FFFFh,-1 - Ячейка памяти:
[BX],DWORD PTR [EAX],QWORD PTR [RSI] - Регистр флагов:
PUSHF(16 бит),PUSHFD(32 бит),PUSHFQ(64 бит) — сохраняет состояние флагов целиком - Все регистры общего назначения разом:
PUSHA(16 бит) илиPUSHAD(32 бит) — удобно в начале процедуры
8-разрядные варианты — AL, BL, CL и другие — не поддерживаются напрямую. PUSH AL вызовет ошибку ассемблирования: она работает только с 2-, 4- и 8-байтными операндами. Если нужно сохранить байт, его помещают в 16-разрядный регистр и затем кладут туда уже его: MOV AH, 0 / PUSH AX.
Команда POP: чтение данных из стека
POP извлекает значение с вершины и помещает его в указанный приёмник. Синтаксис зеркален:
POP приёмник
значение = [SS:SP]
SP = SP + размер_операнда
Сначала данные читаются по текущему адресу указателя, потом он сдвигается вверх. Именно такой порядок гарантирует, что следующий элемент останется нетронутым до следующего обращения.
Поэтому команду POP в ассемблере всегда применяют в порядке, строго обратном занесению. Положили AX, BX, CX — извлекать нужно CX → BX → AX. Если перепутать последовательность, они получат чужие значения — без каких-либо предупреждений компилятора или ассемблера. Это молчаливый баг, который обнаруживается только при выполнении.
Правильный порядок:
PUSH AX
PUSH BX
PUSH CX
; ... промежуточные вычисления ...
POP CX ⠀⠀⠀; восстанавливаем в обратном порядке
POP BX
POP AX
Неправильный порядок:
PUSH AX
PUSH BX
POP AX ⠀⠀⠀; AX получит значение BX — данные перепутаются
POP BX ⠀⠀⠀; BX получит значение AX
Оба фрагмента компилируются без ошибок. Разница обнаруживается только при выполнении — и не сразу, если последующий код случайно «работает» с неверными значениями.
Допустимый приёмник — 16/32/64-разрядный регистр или ячейка памяти. Нельзя указать CS: он хранит текущий сегмент кода, и любая попытка изменить его через POP приводит к переходу процессора в произвольную область. IP/EIP/RIP тоже защищён — только CALL, RET и команды перехода меняют его законным образом.
Данные при извлечении физически не стираются — указатель просто смещается. Очередная запись перезапишет ту же ячейку. Читать «снятые» значения через прямую адресацию ненадёжно: прерывание или вложенный вызов сотрёт их прежде, чем они понадобятся.
Стек в ассемблере: примеры в процедурах

Сохранение регистров и передача параметров — два сценария, где примеры работы с ним переходят из теории в ежедневную практику.
CALL автоматически сохраняет в нём адрес следующей инструкции — точку возврата. RET забирает её и прыгает туда. Программист явно не пишет эти команды в данном месте, но понимать происходящее необходимо: любая лишняя или пропущенная операция между CALL и RET изменит точку возврата, и управление уйдёт куда попало. Так и работают классические атаки через переполнение стекового буфера — злоумышленник целенаправленно перезаписывает нужное значение.
Число операций добавления перед вызовом функции должно совпадать с числом извлечений внутри неё или с явной корректировкой указателя стека после RET. Несбалансированный стек — источник труднодиагностируемых сбоев.
Сохранение регистров в прологе
Если процедура использует регистры, которые нужны вызывающему коду, их сохраняют при входе и восстанавливают перед выходом. Классический пролог x86 фиксирует BP как базу для обращения к параметрам:
MyProc PROC
PUSH BP⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀; сохраняем базовый указатель вызывающего кода
MOV BP, SP⠀⠀⠀⠀⠀⠀⠀⠀; фиксируем фрейм стека текущей процедуры
PUSH AX⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀; сохраняем регистры, которые будем изменять
PUSH BX
; тело процедуры
MOV AX, [BP+4]⠀⠀⠀⠀⠀; первый параметр: смещение +4 от BP
MOV BX, [BP+6]⠀⠀⠀⠀ ; второй параметр: смещение +6 от BP
ADD AX, BX⠀⠀⠀⠀⠀⠀⠀⠀ ; результат в AX
POP BX⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀; восстанавливаем в строго обратном порядке
POP AX
POP BP
RET
MyProc ENDP
Пара PUSH BP / MOV BP, SP называется прологом функции. Она фиксирует текущую вершину в BP так, чтобы смещения к параметрам оставались постоянными — даже когда указатель продолжает меняться при вложенных вызовах или локальных операциях внутри процедуры.
Передача параметров через стек
По соглашению cdecl аргументы передаются в обратном порядке: последний — первым. Вызов sum(3, 7) выглядит так:
PUSH 7 ⠀⠀⠀⠀⠀⠀⠀; второй параметр — в стек первым
PUSH 3 ⠀⠀⠀⠀⠀⠀⠀; первый параметр — в стек вторым
CALL MyProc ⠀⠀; процессор автоматически сохраняет адрес возврата
ADD SP, 4 ⠀⠀⠀⠀; очищаем стек от параметров (2 × 2 байта = 4 байта)
После CALL и пролога структура предсказуема: [BP] — сохранённый BP, [BP+2] — адрес возврата, [BP+4] — первый параметр (3), [BP+6] — второй (7). Схема не меняется с ростом числа аргументов — каждый следующий доступен по смещению +2n от BP в 16-битном режиме или +4n в 32-битном. Именно эти смещения показывает дизассемблер при разборе скомпилированного кода C/C++.
Типичные ошибки при работе со стеком

Стек в ассемблере по принципу LIFO
Большинство проблем возникают из-за нарушения баланса или неверного использования указателя. Особенность таких ошибок — ассемблер компилирует код без предупреждений, а сбой происходит во время выполнения, нередко совсем в другом месте программы.
Чек-лист: 5 правил безопасной работы со стеком
- Число операций записи и извлечения в одном блоке всегда равно — проверяй при написании каждой процедуры вручную.
- Порядок извлечения строго обратный добавлению — нарушение меняет значения в регистрах без ошибки компилятора.
- Не изменяй указатель напрямую вне пролога и эпилога — это скрытая операция, которая ломает цепочку возвратов.
- Не делай
POP CS— изменение сегментного регистра кода ведёт к переходу в случайную точку памяти. - Не читай данные «ниже» вершины через прямую адресацию — прерывание перезапишет их раньше, чем они понадобятся.
Переполнение возникает, когда указатель выходит за нижнюю границу сегмента. Чаще всего причина — рекурсия без условия выхода или цикл с добавлением данных без парного извлечения. Признак: программа зависает или падает, причём место сбоя не совпадает с очевидной ошибкой в коде. При отладке стоит первым делом проверить баланс на каждом уровне вызовов.
Ещё один сценарий — извлечение данных без предшествующего добавления. В ответ придёт то, что лежало там от предыдущего вызова или от системного кода. Значение выглядит правдоподобно — иногда даже случайно совпадает с ожидаемым, — но логика программы нарушена. Такие ошибки требуют пошаговой трассировки с контролем указателя и его содержимого на каждом шаге.
Заключение
PUSH и POP — не просто пара команд ассемблера для записи и чтения. Это фундамент, на котором держится логика вызовов, передача параметров, сохранение контекста и возврат из функций. Любая процедура в x86/x64 так или иначе опирается на эти две инструкции — явно или через автоматические действия CALL и RET.
Главное правило: каждая операция записи требует парного извлечения в правильном месте и в правильном порядке. Нарушение баланса редко сразу даёт явную ошибку — программа продолжает работать, но возвращает неверные результаты или падает в месте, никак не связанном с источником проблемы.
Уверенное понимание того, как команды ассемблера управляют этой памятью, открывает путь к более сложным темам: соглашениям вызова cdecl и stdcall, работе с прерываниями, разбору пролога и эпилога в дизассемблированном коде, анализу уязвимостей класса stack overflow.
Вам нужна биржа фриланса для новичков или требуются разработчики сайтов?


Комментарии