Тайны пингвина: как работают исключения и прерывания в Linux?
Привет, хабр! Моя прошлая статья о работе памяти в Linux вам понравилась. Сегодня мы разберем работу исключений и прерываний.
Что это, как они работают в ОС и Linux? Давайте разберемся вместе!
Что такое прерывания?
Прерывание — это событие, которое изменяет нормальный поток выполнения программы и может быть сгенерировано аппаратными устройствами или даже самим процессором. При возникновении прерывания текущий поток выполнения приостанавливается и запускается обработчик прерывания. После запуска обработчика прерываний предыдущий поток выполнения возобновляется. Они бывают синхронные — вызваны текущим контекстом исполнения и асинхронные — вызваны извне. Согласно терминологии Intel синхронные — исключения, асинхронные — прерывание.
Классификация прерываний
Маскируемые прерывания — запросы на прерывания (IRQ запросы) могут быть «замаскированы» и не вызвать прерывания исполнения.
Немаскируемые прерывания NMI (Non Mascable Interrupt) — всегда должны быть обработаны, достаточно небольшое количество прерываний являются таковыми.
Каждое прерывание и исключение в x86 идентифицируется 8 битным беззнаковым числом — вектором прерывания.
Классификация исключений
Ошибки — синхронные с повторением прерывания. Если ошибку можно обработать, то в стэке ядра сохраняется регистр eip, указывающий на инструкцию, которая вызвала ошибку и будет переисполнена.
Ловушки — синхронные без повторения прерывания. Вызываются определёнными инструкциями, после выполнения которых управление вернётся процессу, и eip будет указывать на следующую инструкцию. Ловушки применяются в основном для отладки.
Программные исключения — возникают по запросу из программы: int. Используются, например, для системных вызовов.
Кратко опишем принцип работы системного вызова.
Прерывания генерируются довольно многими источниками: таймеры, устройства IO. Исключения вызываются обычно либо ошибками в коде, либо аномальными ситуациями, как отсутствие страницы в памяти.
Когда происходит прерывание процессор должен сохранить текущий счётчик команд eip в стеке режима ядра и занести в eip адрес, соответсвующий обработчику прерывания.
Обработка прервания должна удовлетворять некоторым условиям:
- Ядро должно, как можно быстрее обработать прерывание. Потому они делятся на неотложные и те, обработка которых может подождать, достаточно просто отметить на будущее, что нужно сделать.
- Ядро должно суметь обработать вложенные прерывания.
- Количество критических секций, где прерывания должны быть отключены, должно быть минимально.
Во время прерывания, CPU останавливает текущую задачу и передаёт управление специальной процедуре — обработчику прерываний. Обработчик прерываний обрабатывает прерывания и передаёт управление обратно к ранее остановленной задаче. Мы можем разделить прерывания на три типа:
Программные прерывания — когда программное обеспечение сигнализирует CPU, что ему нужно обратиться к ядру. Эти прерывания обычно используются для системных вызовов;
Аппаратные прерывания — когда происходит аппаратное событие, например нажатие кнопки на клавиатуре;
Исключения — прерывания, генерируемые процессором, когда CPU обнаруживает ошибку, например деление на ноль или доступ к странице памяти, которая не находится в ОЗУ.
Каждому прерыванию и исключению присваивается уникальный номер — номер вектора. Номер вектора может быть любым числом от 0 до 255. Существует обычная практика использовать первые 32 векторных номеров для исключений, а номера от 32 до 255 для пользовательских прерываний. Вот так — NUM_EXCEPTION_VECTORS, определённый как: #define NUM_EXCEPTION_VECTORS 32
CPU использует номер вектора как индекс в таблице векторов прерываний. Для перехвата прерываний CPU использует APIC.
В x86 системный вызов может быть организован несколькими путями. Первый — через прерывание int 0×80, которое переключало контекст пользователя на ядреный. Второй — выполнить ассемблерную инструкцию — sysenter. Выйти из системного вызова можно с помощью iret или sysexit соответсвенно. При этом номер системного вызова, который нужно выполнить, сохраняется в регистре eax.
Теперь вернёмся к общей теме прерываний и исключений.
У каждого аппаратного устройства на плате может 1 и более линия IRQ. Все они подключены к контролеру прерываний, например к APIC, который принимает запросы на прерывание для процессора. Если IRQ выставлена, то контролер преобразует принятый запрос в вектор, сохраняет в порте ввода-вывода и посылает сигнал INTR процессору, после чего ждёт подтверждение от процессора о приёме прерывания и т.д.
IRQ линии могут быть выборочно отключены, но прерывание при этом не потеряется, оно будет доставлено процессору после включения соответствующего прерывания. Но это не маскировка сигналов. Сигналы маскируются с помощью флага IF в регистре флагов EFLAGS. Если он сброшен, то маскируемые прерывания игнорируются.
Как было сказано ранее обработчик прерываний должен быть быстрым, но иногда ему нужно выполнять очень большое количество работы. Потому прерывания делиться на TOP HALF — которые нужно немедленно обработать, например аппаратные ошибки, и BOTTOM HALF — обработку которых можно отложить. Прерывание может быть сгенерировано несколькими способами:
- Level: Прерывание регистрируется при изменении сигнала с 0 на 1. После чего необходимо подтверждение об обработке, чтобы сигнал снова опустился в 0 и мы могли принять следующее прерывание. Как видно, это не очень эффективно, потому что мы игнорируем все другие прерывания, пока обрабатываем текущее.
- Edge: Прерывание регистрируется изменением напряжения на входе с 0 на 1 или с 1 на 0. Что позволяет обрабатывать прерывания во время прерывания. NAPI (new API): подход к генерации прерываний для сетевых драйверов Linux. Идея в том, чтобы обрабатывать большой (критический) пул сетевых пакетов сразу. Как только пакетов становиться больше определённого значения генерируется прерывание и происходит обработка всего пула сразу.
Исключения делятся на три типа:
- Ошибки (Faults) — исключения, по окончании обработки которых прерванная команда повторяется;
- Ловушки (Traps) — исключения, при обработке которых CPU сохраняет состояние, следующее за командой, вызвавшей исключение;
- Аварии (Aborts) — исключения, при обработке которых CPU не сохраняет состояния и не имеет возможности вернуться к месту исключения
Для реагирования на прерывание CPU использует специальную структуру — таблицу векторов прерываний (Interrupt Descriptor Table, IDT). IDT является массивом 8-байтных дескрипторов, наподобие глобальной таблицы дескрипторов, но записи в IDT называются шлюзами (gates). CPU умножает номер вектора на 8 для того чтобы найти индекс записи IDT. Но в 64-битном режиме IDT представляет собой массив 16-байтных дескрипторов и CPU умножает номер вектора на 16. Из предыдущей части мы помним, что CPU использует специальный регистр GDTR для поиска глобальной таблицы дескрипторов, поэтому CPU использует специальный регистр IDTR для таблицы векторов прерываний и инструкцию lidt для загрузки базового адреса таблицы в этот регистр.
Дескрипторы прерываний и ловушек содержат дальний указатель на точку входа обработчика прерываний. Различие между этими типами заключается в том, как CPU обрабатывает флаг IF. Если обработчик прерываний был вызван через шлюз прерывания, CPU очищает флаг IF чтобы предотвратить другие прерывания, пока выполняется текущий обработчик прерываний. После выполнения текущего обработчика прерываний CPU снова устанавливает флаг IF с помощью инструкции iret.
Остальные биты в шлюзе прерывания зарезервированы и должны быть равны 0. Теперь давайте посмотрим, как CPU обрабатывает прерывания:
- CPU сохраняет регистр флагов, CS, и указатель на инструкцию в стеке.
- Если прерывание вызывает код ошибки (например, #PF), CPU сохраняет ошибку в стеке после указателя на инструкцию;
- После выполнения обработчика прерываний для возврата из него используется инструкция iret.
Обработка прерываний
Существуют несколько контекстов работы процесса:
task — обычный контекст режима ядра. В этом контексте макрос current () вернёт task_struct текущего процесса. В этом контексте процесс может быть свободно усыплён, прерван, так же разрешены переходы в другие контексты, например в ядерный.
IRQ — контекст прерывания. В этом контексте current () вернёт task_struct прерванного процесса, прерывание не ассоциировано с каким бы то ни было процессом. Другие прерывание в этом контексте обычно выключены. В этом контексте нельзя уснуть. Переключиться на другой процесс тоже нельзя. В этом контексте мы обрабатываем прерывания типа TOP HALF. Обработчики должны быть быстрыми и простыми.
SOFT IRQ — отличается от предыдущего тем, что другие прерывания в этом контексте разрешены. В этом контексте обрабатываются BOTTOM HALF прерывания, обработка которых может подождать и в общем не столько критична, как из верхней половины. В этом режиме соответсвенно возможны вложенные прерывания.
_atomic — в нём в основном работаю ядерные функции. Макрос current () имеет смысл, он возвращает task_struct текущего процесса. Прерывания разрешены, но переключить на исполнение другого процесса нас не могут. В таком режиме исполняются атомарные операции.
User — обычные пользовательские процессы.
Диаграмма переключения контекста:
- user → task (exception/syscall)
- user → irq (interrupt)
- task → irq (interrupt)
- task → atomic (запрет переключения)
- task → user (iret)
- task → task (многозадачность, вызов schedule)
- irq → soft irq — раазрешить прерывания
- irq → task5
- soft → irq
- soft → task
- atomic → task
- atomic → irq
Проверить в каком мы сейчас контексте можно с помощью макросов:
/* (/include/linux/preempt.h)
* Are we doing bottom half or hardware interrupt processing?
* Are we in a softirq context? Interrupt context?
* in_softirq - Are we currently processing softirq or have bh disabled?
* in_serving_softirq - Are we currently processing softirq?
*/
#define in_irq() (hardirq_count())
#define in_softirq() (softirq_count())
#define in_interrupt() (irq_count())
#define in_serving_softirq() (softirq_count() & SOFTIRQ_OFFSET)
/* Are we in NMI context? */
#define in_nmi() (preempt_count() & NMI_MASK)
#define in_atomic() (preempt_count() != 0)
Процесс может быть переключён на другой несколькими способами:
- Добровольно — процесс может позвать schedule (). Однако тут есть забавный факт — планировщик может снова вызвать этот же процесс, что можно обойти предварительно изменив состояние процесса макросом — set_current_state (). Как мы разбирали раньше такое может быть проделано только в Task контексте.
- Принудительно — прерывание по таймеру.
Концепции оборудования
Программируемый контроллер прерываний
Устройство, поддерживающее прерывания, имеет выходной контакт, используемый для сигнализации запроса на прерывание. Выводы IRQ подключены к устройству под названием «программируемый контроллер прерываний» (PIC), которое подключено к выводу INTR процессора.
PIC обычно имеет набор портов, используемых для обмена информацией с процессором. Когда устройству, подключенному к одной из линий IRQ PIC, требуется внимание процессора, происходит следующий процесс:
- устройство вызывает прерывание на соответствующем выводе IRQn
- PIC преобразует IRQ в векторное число и записывает его в порт, который ЦП может прочитать.
- PIC вызывает прерывание на выводе CPU INTR
- PIC ждет, пока процессор подтвердит прерывание, прежде чем вызвать другое прерывание.
- ЦП подтверждает прерывание и начинает его обработку.
Позже увидим, как процессор обрабатывает прерывание. Обратите внимание, что по замыслу PIC не будет вызывать другое прерывание, пока ЦП не подтвердит текущее прерывание.
Как только прерывание подтверждено ЦП, контроллер прерываний может запросить другое прерывание, независимо от того, завершил ЦП обработку предыдущего прерывания или нет. Таким образом, в зависимости от того, как ОС управляет процессором, возможны вложенные прерывания.
Контроллер прерываний позволяет индивидуально отключать каждую линию IRQ. Это позволяет упростить проектирование, гарантируя, что обработчики прерываний всегда выполняются последовательно.
Контроллеры прерываний в SMP-системах
В системах SMP мы можем иметь несколько контроллеров прерываний.
Например, в архитектуре x86 каждое ядро имеет локальный APIC, используемый для обработки прерываний от локально подключенных устройств, таких как таймеры или термодатчики. Затем существует APIC ввода-вывода, используемый для распределения IRQ от внешних устройств к ядрам ЦП.
Управление прерываниями
Чтобы синхронизировать доступ к общим данным между обработчиком прерываний и другими потенциальными параллельными действиями, такими как инициализация драйвера или обработка данных драйвера, часто требуется включать и отключать прерывания контролируемым образом.
Это можно сделать на нескольких уровнях:
- на уровне устройства
- путем программирования регистров управления устройством
- на уровне ПОС
- PIC можно запрограммировать на отключение определенной линии IRQ.
- на уровне процессора; например, на x86 можно использовать следующие инструкции:
- cli (очистить флаг прерывания)
- sti (флаг прерывания Set)
Приоритет прерываний
Большинство архитектур также поддерживают приоритет прерываний. Когда эта опция включена, она разрешает вложение прерываний только для тех прерываний, которые имеют более высокий приоритет, чем текущий уровень прерывания.
Не все архитектуры поддерживают приоритеты прерываний. Также сложно поддерживать определение общей схемы приоритетов прерываний для операционных систем общего использования, а некоторые ядра (включая Linux) не используют приоритеты прерываний. С другой стороны, большинство RTOS используют приоритеты прерываний, поскольку они обычно используются в случаях с большим количеством ограничений, где легче определить приоритеты прерываний.
Обработка прерываний в архитектуре x86
В этом разделе нашей статьи будет рассмотрено, как процессор обрабатывает прерывания в архитектуре x86.
Таблица дескрипторов прерываний
Таблица дескрипторов прерываний (IDT) связывает каждый идентификатор прерывания или исключения с дескриптором инструкций, которые обслуживают соответствующее событие. Мы назовем идентификатор номером вектора, а соответствующие инструкции — обработчиком прерываний/исключений.
IDT имеет следующие характеристики:
- он используется ЦП в качестве таблицы переходов при запуске данного вектора
- это массив записей размером 256×8 байт.
- может находиться где угодно в физической памяти
- процессор находит IDT с помощью IDTR
Ниже мы можем найти векторную схему Linux IRQ. Первые 32 записи зарезервированы для исключений, вектор 128 используется для интерфейса системных вызовов, а остальные в основном используются для обработчиков аппаратных прерываний.
В x86 запись IDT имеет 8 байт и называется воротами. Ворота могут быть 3-х типов:
- Ворота прерывания содержат адрес обработчика прерывания или исключения. Переход к обработчику отключает маскируемые прерывания (флаг IF сбрасывается).
- Шлюзы-ловушки, похожие на шлюзы прерываний, но они не отключают маскируемые прерывания при переходе к обработчику прерываний/исключений.
- Шлюзы задач (не используются в Linux).
Давайте посмотрим на несколько полей записи IDT:
- селектор сегмента, индексируйте GDT/LDT, чтобы найти начало сегмента кода, в котором находятся обработчики прерываний
- смещение, смещение внутри сегмента кода
- T представляет тип ворот
DPL, минимальные права, необходимые для использования содержимого сегментов.
Адрес обработчика прерываний
Чтобы найти адрес обработчика прерывания, нам сначала нужно найти начальный адрес сегмента кода, в котором находится обработчик прерывания. Для этого мы используем селектор сегмента для индексации в GDT/LDT, где мы можем найти соответствующий дескриптор сегмента. Это обеспечит начальный адрес, хранящийся в поле «база». Используя базовый адрес и смещение, мы теперь можем перейти к началу обработчика прерывания.
Стек обработчика прерываний
Подобно передаче управления обычной функции, передача управления обработчику прерывания или исключения использует стек для хранения информации, необходимой для возврата к прерванному коду.
Как видно на рисунке ниже, прерывание помещает в регистр EFLAGS перед сохранением адреса прерванной инструкции. Определенные типы исключений также вызывают помещение кода ошибки в стек, чтобы помочь отладить исключение.
Обработка запроса на прерывание
После генерации запроса на прерывание процессор запускает последовательность событий, которые в конечном итоге заканчиваются запуском обработчика прерываний ядра:
- ЦП проверяет текущий уровень привилегий
- если нужно изменить уровень привилегий
- изменить стек на тот, который связан с новой привилегией
- сохранить информацию старого стека в новом стеке
- сохранить EFLAGS, CS, EIP в стеке
- сохранить код ошибки в стеке на случай прерывания
- выполнить обработчик прерываний ядра
Возврат из обработчика прерывания
Большинство архитектур предлагают специальные инструкции для очистки стека и возобновления выполнения после выполнения обработчика прерывания. В x86 IRET используется для возврата из обработчика прерывания. IRET аналогичен RET, за исключением того, что IRET увеличивает ESP на дополнительные четыре байта (из-за флагов в стеке) и перемещает сохраненные флаги в регистр EFLAGS.
Для возобновления выполнения после прерывания используется следующая последовательность (x86):
- выведать код ошибки (в случае прерывания)
- вызов в IRET
- извлекает значения из стека и восстанавливает следующий регистр: CS, EIP, EFLAGS
- если уровень привилегий изменен, возвращается к старому стеку и старому уровню привилегий
Обработка прерываний в Linux
В Linux обработка прерываний осуществляется в три этапа: критический, немедленный и отложенный.
На первом этапе ядро запустит общий обработчик прерываний, который определяет номер прерывания, обработчик прерывания для этого конкретного прерывания и контроллер прерываний. На этом этапе также будут выполнены любые критически важные действия по времени (например, подтверждение прерывания на уровне контроллера прерываний). Прерывания локального процессора отключаются на время этой фазы и продолжают блокироваться на следующей фазе.
На втором этапе будут выполнены все обработчики драйвера устройства, связанные с этим прерыванием. В конце этой фазы вызывается метод «конца прерывания» контроллера прерываний, позволяющий контроллеру прерываний повторно подтвердить это прерывание. В этот момент прерывания локального процессора разрешены.
Наконец, на последнем этапе обработки прерываний будут выполняться отложенные действия контекста прерывания. Их также иногда называют «нижней половиной» прерывания (верхняя половина является частью обработки прерывания, которая выполняется с отключенными прерываниями). На этом этапе прерывания разрешены на локальном процессоре.
Вложенные прерывания и исключения
Linux раньше поддерживал вложенные прерывания, но несколько лет назад это было удалено, чтобы избежать все более сложных решений проблем переполнения стека — разрешить только один уровень вложенности, разрешить несколько уровней вложенности до определенной глубины стека ядра и т. д.
Тем не менее, вложенность между исключениями и прерываниями все еще возможна, но правила довольно строгие:
- исключение (например, ошибка страницы, системный вызов) не может препятствовать прерыванию; если это произойдет, это считается ошибкой
- прерывание может предотвратить исключение
- прерывание не может вытеснить другое прерывание (раньше это было возможно)
На диаграмме ниже показаны возможные сценарии вложения:
Контекст прерывания
Пока обрабатывается прерывание (с момента, когда ЦП переходит к обработчику прерывания и до момента возврата обработчика прерывания — например, выдается IRET), говорят, что код выполняется в «контексте прерывания».
Код, который выполняется в контексте прерывания, имеет следующие свойства:
- он запускается в результате IRQ (а не исключения)
- нет четко определенного контекста процесса, связанного
- не разрешено запускать переключение контекста (без сна, расписания или доступа к пользовательской памяти)
Отложенные действия
Отложенные действия используются для запуска функций обратного вызова в более позднее время. Если отложенные действия запланированы из обработчика прерывания, соответствующая функция обратного вызова будет запущена после завершения работы обработчика прерывания.
Существует две большие категории отложенных действий: те, которые выполняются в контексте прерывания, и те, которые выполняются в контексте процесса.
Целью отложенных действий в контексте прерывания является предотвращение выполнения слишком большого объема работы в функции обработчика прерывания. Слишком долгая работа с отключенными прерываниями может привести к нежелательным последствиям, таким как увеличение задержки или снижение производительности системы из-за отсутствия других прерываний (например, отбрасывание сетевых пакетов из-за того, что ЦП не отреагировал вовремя на исключение пакетов из сетевого интерфейса, а буфер сетевой карты был переполнен). полный).
У отложенных действий есть API-интерфейсы, позволяющие: инициализировать экземпляр, активировать или запланировать действие, а также замаскировать/отключить и размаскировать/включить выполнение функции обратного вызова. Последний используется в целях синхронизации между функцией обратного вызова и другими контекстами.
Обычно драйвер устройства инициализирует структуру отложенного действия во время инициализации экземпляра устройства и активирует/планирует отложенное действие из обработчика прерываний.
Soft (мягкие) прерывания
Soft IRQ — это термин, используемый для низкоуровневого механизма, который реализует отсрочку работы от обработчиков прерываний, но по-прежнему работает в контексте прерывания.
Программные API-интерфейсы IRQ:
инициализировать: open_softirq ()
активация: raise_softirq ()
маскировка: local_bh_disable (), local_bh_enable ()
После активации функция обратного вызова do_softirq () запускается либо:
- после обработчика прерывания или
- из ветки ядра ksoftirqd
Поскольку программные прерывания могут перепланировать сами себя или могут возникнуть другие прерывания, которые их перепланируют, они потенциально могут привести к (временному) остановке процесса, если не будут установлены проверки. В настоящее время ядро Linux не позволяет запускать программные прерывания более чем MAX_SOFTIRQ_TIME или перепланировать их более чем MAX_SOFTIRQ_RESTART раз подряд.
Как только эти ограничения достигаются, специальный поток ядра пробуждает ksoftirqd, и все остальные ожидающие мягкие прерывания будут запускаться из контекста этого потока ядра.
Использование программных прерываний ограничено, они используются несколькими подсистемами, которые имеют низкие требования к задержке и высокую частоту.
Заключение
Работа прерываний и исключений в Linux и операционных системах — очень сложная вещь. Она очень близка к железу и низкоуровневому программированию. Обработчики прерываний и исключений обычно пишут на ассемблере или на С, чаще всего с ассемблерными ставками.
Если вы вдруг вздумаете создать свою операционную систему с ядром на С, вам обязательно стоит это знать.
Вещь это очень нужная, которую стоит знать всем системным разработчикам или администраторам и низкоуровневым программистам, да и для общего развития тоже было бы неплохо. Но это мое субъективное мнение.
Все материалы взяты из открытых источников.
Ссылки на интересные материалы: