Перейти к основному содержимому

Процедуры и прерывания

Разработчику Архитектору

Процедуры в Ассемблере

Общая база: функции в коде — вызов, параметры, возврат и стек. Ниже — call/ret и обработчики прерываний.

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

Вызов процедуры происходит с помощью специальной инструкции CALL. Процессор сохраняет адрес возврата в стек, переключает указатель команд (Instruction Pointer) на начало тела процедуры и начинает выполнение инструкций внутри неё. После завершения работы процедура использует инструкцию RET для извлечения адреса возврата из вершины стека и передачи управления обратно в исходную точку.


Типы процедур

Процедуры в ассемблере классифицируют по способу передачи параметров и объёму используемой памяти. Существуют процедуры, принимающие параметры через регистры, через стек или через глобальные переменные. Параметры, передаваемые через стек, требуют строгого соблюдения порядка их размещения, так как стек работает по принципу LIFO (Last In First Out). Параметры, передаваемые через регистры, позволяют ускорить доступ к данным, но ограничивают количество одновременно обрабатываемых аргументов количеством свободных регистров процессора.

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


Организация памяти процедуры

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

Локальные переменные процедуры хранятся в стеке. При входе в процедуру процессор выделяет пространство под локальные данные, увеличивая указатель стека (Stack Pointer). Это пространство доступно только внутри данной процедуры и защищено от доступа извне. При выходе из процедуры указатель стека уменьшается, освобождая память для следующих операций.

; NASM, 32-bit, cdecl: аргументы в стеке, результат в EAX
; Вызов: push dword 20 / push dword 10 / call add_via_stack / add esp, 8

add_via_stack:
push ebp
mov ebp, esp
mov eax, [ebp + 8] ; первый аргумент (ближе к ret-адресу — выше в стеке)
add eax, [ebp + 12] ; второй аргумент
pop ebp
ret

Разбор:

  • В комментарии задано соглашение cdecl — аргументы кладёт вызывающая сторона, а очистку стека делает тоже вызывающая сторона (add esp, 8 после call).
  • Метка add_via_stack задаёт точку входа в процедуру, возвращающую результат в EAX.
  • push ebp / mov ebp, esp создают стековый кадр, чтобы параметры имели фиксированные смещения относительно EBP.
  • [ebp + 8] и [ebp + 12] читают первый и второй аргументы, лежащие выше адреса возврата в кадре.
  • add eax, [ebp + 12] выполняет суммирование и оставляет итог в регистре возврата.
  • pop ebp / ret восстанавливают контекст и передают управление обратно вызывающему коду.

push ebp / mov ebp, esp формируют кадр стека — через ebp удобно адресовать параметры ([ebp+8], …) и локальные переменные ([ebp-4], …). После ret вызывающий код очищает стек (в cdecl) или это делает сама процедура (в stdcall — см. Первая программа).


Локальные переменные

Локальные переменные существуют только в пределах выполнения конкретной процедуры. Они создаются при входе в процедуру и уничтожаются при выходе. Для выделения памяти под локальные переменные используют уменьшение указателя стека. Размер выделяемого пространства зависит от количества и типа переменных.

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

; Выделение места под локальные переменные
push bp
mov bp, sp
sub sp, 8 ; Выделяем 8 байт под две целочисленные переменные

; Использование локальных переменных
mov word ptr [bp-2], 10 ; Первая переменная = 10
mov word ptr [bp-4], 20 ; Вторая переменная = 20

; Восстановление стека
add sp, 8
pop bp
ret

Разбор:

  • push bp / mov bp, sp фиксируют базу текущего кадра, чтобы смещения к данным не зависели от промежуточных push/pop.
  • sub sp, 8 резервирует 8 байт в стеке под локальные переменные процедуры.
  • Записи mov word ptr [bp-2], 10 и mov word ptr [bp-4], 20 инициализируют локальные поля по отрицательным смещениям от BP.
  • add sp, 8 освобождает выделенную локальную область перед выходом из процедуры.
  • pop bp возвращает базовый указатель предыдущего кадра вызывающей функции.
  • ret извлекает адрес возврата и завершает процедуру.

В этом фрагменте кода показано явное выделение памяти под локальные переменные. Операция sub sp, 8 уменьшает указатель стека на 8 байт, создавая пространство для двух слов. Затем в это пространство записываются значения. Перед выходом из процедуры указатель стека возвращается в исходное состояние операцией add sp, 8.


Стек вызовов

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

Каждый кадр стека содержит следующие элементы — адрес возврата, сохраненный базовый указатель, локальные переменные и параметры. Адрес возврата указывает место в коде, куда нужно вернуться после завершения процедуры. Базовый указатель помогает организовать доступ к параметрам и локальным данным в рамках текущего кадра.

При возникновении ошибки или переполнении стека система прерывает выполнение программы. Переполнение стека происходит, когда глубина вызовов превышает доступный объем памяти. Это часто случается при бесконечной рекурсии или некорректном управлении стеком.

Вложенные call (схема):

main:
call proc_a ; в стек: ret_main
proc_a:
call proc_b ; в стек: ret_main, ret_a
proc_b:
ret ; снимает ret_a, возврат в proc_a
ret ; в proc_a: снимает ret_main

Разбор:

  • Каждый call кладёт свой адрес возврата поверх предыдущих.
  • ret всегда снимает только верхний адрес — LIFO, как у стека тарелок.
  • Порядок ret должен зеркалить порядок call; лишний или пропущенный ret ломает программу.
  • Между call и ret в кадре могут лежать параметры и локальные переменные.
  • Отладчик показывает эту цепочку как stack trace.

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

Процедура может возвращать результат вычислений несколькими способами. Наиболее распространенный метод — использование регистра аккумулятора (например, AX в 16-битной архитектуре или EAX в 32-битной). Значение помещается в регистр перед выполнением инструкции RET. Вызывающая сторона считывает результат из этого регистра.

Для возврата сложных структур данных используют указатели. Процедура записывает данные в память, адресуемую указателем, переданным в качестве параметра. Это позволяет возвращать массивы, структуры или большие объекты без необходимости копирования данных в стек.

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


Прерывания в Ассемблере

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

Прерывания делят на аппаратные и программные. Аппаратные прерывания инициируются внешними устройствами, такими как клавиатура, таймер или сетевой адаптер. Программные прерывания возникают при выполнении специальных инструкций внутри кода программы. Оба типа прерываний используют единую систему векторов для определения адреса обработчика.


Важный контекст: user mode и kernel mode

Для современной разработки нужно различать два уровня:

  • Код ядра/драйвера (ring 0): может ставить обработчики прерываний, работать с IDT, использовать IRETQ, управлять маскированием через контроллеры прерываний.
  • Пользовательская программа (ring 3): не имеет прямого доступа к IDT и аппаратным IRQ; взаимодействует с ОС через системные вызовы и API.

Поэтому большая часть прикладного ассемблерного кода работает не "с прерываниями напрямую", а через контракты ОС. Это нормальная и безопасная модель.


Система векторов прерываний

Система векторов прерываний хранит адреса обработчиков для всех возможных типов прерываний. В архитектуре x86 таблица векторов прерываний (IDT) содержит 256 записей, каждая из которых соответствует определенному типу прерывания. Номер прерывания используется как индекс для поиска адреса обработчика в таблице.

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

В современных ОС IDT принадлежит ядру и недоступна для прямого изменения пользовательскими программами. Это сделано для изоляции и безопасности процессов.


Аппаратные прерывания

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

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

Обработка аппаратных прерываний должна быть быстрой, чтобы не блокировать работу других устройств. Длительные операции выполняют вне контекста прерывания, используя механизмы планирования задач. Это позволяет поддерживать высокую отзывчивость системы даже при интенсивной нагрузке.


Программные прерывания

Программные прерывания вызываются явной инструкцией внутри программы. Инструкция INT (Interrupt) указывает номер прерывания, которое необходимо активировать. Процессор выполняет переход к соответствующему обработчику, сохраняя состояние текущей программы.

Программные прерывания часто используют для обращения к функциям операционной системы в исторических или специализированных средах. Например, прерывание INT 21h в DOS предоставляет широкий набор служебных функций — чтение файла, вывод текста на экран, поиск файлов. Программа передает параметры в регистрах или стеке, а обработчик выполняет требуемую операцию и возвращает результат.

В Linux x86-64 для пользовательских программ стандартный путь - syscall, а не int 0x80 (последний в основном для старого 32-битного контекста). В Windows пользовательский код обычно вызывает WinAPI, а не "голые" программные прерывания.

Пользовательский вызов ядра Linux (ring 3):

mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
lea rsi, [rel msg]
mov rdx, msg_len
syscall

Разбор:

  • это контролируемый шлюз в ядро с проверкой прав.
  • Номер 1 и регистры RDI/RSI/RDX — контракт Linux x86-64, не DOS int 21h.
  • Ядро выполняет операцию от имени процесса и возвращает управление (кроме exit).
  • В отличие от IRQ, программист сам выбирает момент вызова.
  • Тот же механизм используют libc-функции write/read внутри себя.

Использование программных прерываний как основного API сегодня ограничено: современные ОС предпочитают системные вызовы и библиотечные интерфейсы с чёткой моделью прав доступа.


Обработка прерываний

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

Обработчик выполняет необходимую работу, например, считывает данные из буфера ввода или обновляет счетчик времени. После завершения он восстанавливает сохраненные регистры и возвращает управление прерванной программе. Ключевая инструкция IRET (Interrupt Return) выполняет этот возврат, восстанавливая все флаги и указатели.

Если во время обработки возникает новое прерывание более высокого приоритета, процессор может временно приостановить текущий обработчик и перейти к новому, а затем вернуться обратно. Это называется вложенной обработкой прерываний и зависит от политики ядра и настроек контроллера прерываний.


Маскирование прерываний

Маскирование прерываний позволяет временно отключать определенные типы прерываний. Процессор использует специальный флаг в регистре флагов (IF — Interrupt Flag) для разрешения или запрета внешних прерываний. Установка флага разрешает прерывания, сброс — запрещает.

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

Некоторые прерывания невозможно заблокировать (например, NMI - non-maskable interrupt), так как они связаны с критическими событиями аппаратного уровня. Они обрабатываются независимо от обычного флага IF.


Исключения

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

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

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


Векторизация прерываний

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

Каждый тип прерывания имеет свой уникальный номер. Стандартные прерывания зарезервированы системой, пользовательские прерывания можно назначать произвольно. Важно избегать конфликтов номеров, чтобы не нарушить работу системы.

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


Что важно для прикладного разработчика

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

  1. Помогает понимать, почему операции ввода-вывода "просыпаются" асинхронно.
  2. Объясняет природу исключений вроде "деление на ноль" и page fault.
  3. Даёт контекст для отладки падений в дизассемблере и стектрейсе.
  4. Упрощает чтение системного кода в Linux/Windows, где обработчики и шлюзы вызовов явно видны в ассемблерных файлах.

Связанные материалы: Архитектура ассемблерных программ, Команды и подпрограммы, Справочник по ассемблеру.