Многоядерные процессоры и проблемы ими порождаемые

         

Уровень драйверов


Драйвера обычно не создают своих собственных потоков, довольствуясь уже существующими, но проблем с синхронизацией у них даже больше, чем у приложений. Хуже всего то, что на многопроцессорной системе _одни_ и _те же_ части драйвера могут _одновременно_ выполняться на _различных_ процессорах! Чтобы понять причины такого беспредела, нам необходимо разобраться с базовыми понятиями ядра: IRQL и ISR.

Планировка драйверов осуществляется совсем не так, как потоков прикладного режима. Если прикладной поток может быть прерван в любое время безо всякого вреда, прервать работу драйвера можно только с его явного разрешения, иначе нормальное функционирование системы станет невозможным. Драйвера, обрабатывающие асинхронные события, должны быть либо полностью реентерабельными (т. е. корректно "подхватывать" новое событие во время обработки предыдущего), либо каким-то образом задерживать поступление новых событий, пока они не разберутся с текущим. Первый механизм гораздо более сложен в реализации. Программисты, писавшие резидентов под MS-DOS, должно быть помнят как часто им приходилось пользоваться командой CLI, запрещающий прерывания на время перестройки критических структур данных. Допустим, наш русификатор устанавливает новый обработчик клавиатурного прерывания. Он записал в таблицу векторов свое смещение и только собирался записать сегмент, как пользователь вдруг нажал на клавишу и процессор передал управление по адресу со старым сегментом и новым смещением.

Программируемый контроллер прерываний (Programmable Interrupt Controller, или, сокращенно PIC) оригинального IBM PC был построен на микросхеме i8259A, сейчас же контроллер прерываний встроен непосредственно в южный мост чипсета и эмулирует i8259A лишь в целях обратной совместимости. PIC имеет 15 линий прерываний, а каждая линия — свой приоритет. Во время обработки прерываний, прерывания с равным или более низким приоритетом маскируются, так сказать, откладываясь на потом. Иногда это помогает, иногда нет.
Например, если замаскировать прерывания от таймера более чем на один "тик", системные часы начнут отставать. А если проигнорировать прерывания от звуковой карты и вовремя не "скормить" ей очередную порцию данных, она начнет "булькать", заставляя пользователя рыдать от счастья и биться головой о монитор. Прерывания с более высоким приоритетом прерывают менее приоритетные прерывания, возвращая им управление после того, как они будут обработаны. Усовершенствованные клоны PIC'a (Advanced Programmable Interrupt Controller или, сокращенно, APIC) обеспечивают 256 линий прерываний и, в отличии от обычного PIC'а, способны работать в многопроцессорных системах.



Рисунок 2 архитектура контроллера прерываний на двухпроцессорной машине

Операционная система Windows поддерживает PIC и APIC контроллеры, но использует свою собственную систему приоритетов прерываний, известную под аббревиатурой IRQL, которая расшифровывается как Interrupt Request Levels (Уровни Запроса Прерываний). Всего существует 32 уровня, пронумерованных целыми числами от 0 до 31. Уровень 0 имеет минимальный приоритет, 31 — максимальный. Нормальное выполнение потока происходит на нулевом уровне, называемого пассивным (PASSIVE) и его может прерывать любое асинхронное событие, возникающее в системе. При этом операционная система повышает текущий IRQL до уровня возникшего прерывания и передает управление его ISR (Interrupt Service Routine – процедура обработки прерывания), предварительно сохранив состояние текущего обработчика.

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



Рисунок 3 уровни запросов прерываний и их назначение

Чтобы замаскировать прерывания на время выполнения ISR многие программисты просто повышают уровень IRQL ядерной API-функций KeRaiseIrql(), а при выходе из ISR восстанавливают его вызовом KeLowerIrql().


Даже если они не делают этого явно, за них это делает система. Рассмотрим происходящие события более подробно.

Допустим, поток A работает на уровне IRQL равном PASSIVE_LEVEL (см. рис.4). Устройство Device 1 возбуждает аппаратное прерывание с уровнем DIRQL (т. е. с номером 3 до 31 включительно). Операционная система прерывает выполнение Потока A, повышает IRQL до DIRQL и передает управление на ISR устройства Device 1. Обработчик прерывания обращается к устройству Device 1, делает с ним все, что оно требует, ставит в очередь отложенную процедуру DpcForISR() для дальнейшей обработки и понижает IRQL до прежнего уровня. Отложенные процедуры (Deferred Procedure Calls или, сокращено, DPCs) выполняются на IRQL равном 2 (DISPATCH_LEVEL) и потому не могут начать свою работу вплоть до выхода из ISR.



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



Рисунок 4 обработка аппаратных прерываний на машине с одним процессором

На однопроцессорных системах такая схема работает вполне нормально, но вот на многопроцессорных… каждый процессор имеет свой IRQL, независимый от остальных. Повышение IRQL на одном процессоре никак не затрагивает все остальные и генерация прерываний продолжается (см. рис. 5).



Рисунок 5 маскировка прерываний драйвером на двухпроцессорной машине

Допустим, поток A выполняется на процессоре 1 с IRQL=PASSIVE_LEVEL, в то время как поток B выполняется на процессоре 1 с тем же самым IRQL (см. рис. 6). Устройство Device 1 посылал процессору 0 сигнал прерывания. Операционная система "ловит" его, повышает IRQL процессора 0 до значения DIRQL и передает управление ISR устройства Device 1, которое делает с устройством что положено и ставит в очередь отложенную процедуру DpcForIsr() для дальнейшей обработки.


По умолчанию, функция добавляется в очередь того процессора, на котором запущена ISR (в данном случае процессора 0).

Устройство Device 1 вновь генерирует сигнал прерывания, который на этот раз посылается процессору 1, поскольку процессор 0 еще не успел завершить обработку ISR и не понизил IRQL. Система повышает IRQL процессора 1 до DIRQL и передает управление IRQ устройства Device 1, который делает с устройством все что нужно и ставит отложенную процедуру DpcForIsr() в очередь на процессоре 1.



Рисунок 6 обработка аппаратных прерываний драйвером на двухпроцессорной машине

Затем ISR на обоих процессорах завершаются, система понижает IRQL и начинается выполнение отложенной процедуры DpcForIsr(), стоящей как в очереди процессора 0, так и в очереди процессора 1 Да! Вы не ошиблись! Процедура DpcForIsr() будет исполняться сразу на обоих процессорах одновременно, отвечая за обработку двух прерываний от одного устройства! Как вам это нравится?! В такой ситуации очень легко превратить совместно используемые данные в мешанину, возвратив неожиданный результат или завесив систему (см. рис 7).



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

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



Рисунок 8 защита разделяемых данных спин-блокировками

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



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

Во-первых, посторонний отладчик мог внедрить в начало (или середину функции) программную точку останова, представляющую собой однобайтовую команду с опкодом CCh, сохранив оригинальный байт где-то в памяти. В этом случае, вставлять jump поверх CCh ни в коем случае нельзя, поскольку отладчик может заметить, что точка останова исчезла и поставить ССh еще раз, забыв обновить оригинальное содержимое, оставшееся от старой команды. Корректный перехват в этом случае практически невозможен. Теоретически, можно внедрить jump во вторую инструкцию, но для этого нам необходимо определить где заканчивается первая, а поскольку ее начало искажено программной точкой останова, для ее декодирования придется прибегнуть к эвристическим методам, а они ненадежны. К счастью, большинство функций начинаются со стандартного пролога PUSH EBP/MOV EBP,ESP (55h/8Bh ECh), поэтому, встретив последовательность CCh/8Bh ECh мы вполне уверенно можем внедрять свой jump, начиная с MOV EBP,ESP.

Вот только тут есть один нюанс. Команда ближнего перехода в 32-битном режиме занимает целых 5 байт, поэтому, для ее записи необходимо воспользоваться командой MOVQ, иначе модификация будет представлять неатомарную операцию. Задумайтесь, что произойдет, если мы записали 4 первых байта команды JMP NEAR TARGET командой MOV и только собрались дописать последний байт, как внезапно пробудившийся поток захотел вызвать эту функцию? Правильно — произойдет крах!

Но даже атомарность не спасает от всех проблем.


Допустим, мы записываем 5ти байтовую команду JMP NEAR TARGET поверх 2х байтовой команды MOV EBP,ESP, естественно, затрагивая следующую за ней команду. Даже на однопроцессорных машинах существует вероятность, что какой-то из потоков был ранее прерван сразу же после выполнения MOV EBP,ESP и когда он возобновит свое выполнение, то… окажется _посередине_ команды JMP NEAR TARGET, что повлечет за собой непредсказуемое поведение системы.

Алгоритм безопасной модификации выглядит так: перехватываем INT 03h, запоминая адрес прежнего обработчика, внедряем в начало перехватываемой функции CCh (если только программная точка уже не установлена). При возникновении прерывания INT 03h мы сравниваем полученный адрес со списком адресов перехваченных функций и, если это, действительно, "наш" адрес, выполняем ранее сохраненную машинную инструкцию в своем буфере и передаем управление на вторую инструкцию перехваченной функции. Снимать CCh ни в коем случае нельзя! Поскольку в этот момент функцию может вызывать кто-то еще, но наш перехватчик "прозевает" этот факт!

Если же полученный адрес не "наш", мы передаем управление предыдущему обработчику INT 03h. Тоже самое мы делаем, если программная точка останова была установлена еще до перехвата. Тогда мы позволяем предыдущему обработчику INT 03h восстановить ее содержимое, а сами ставим CCh на следующую инструкцию. Конечно, такой способ перехвата _намного_ сложнее "общепринятого", зато он на 100% надежен и работает в любых конфигурациях — как одно- так и многопроцессорных.


Содержание раздела