Глава 3. Основы программирования ядра
Содержание
В этой главе мы окунёмся глубже в API ядра, структуры и определения. Мы также изучим некоторые механизмы, которые активируют код в драйвере. Наконец, мы соберём все эти знания воедино для создания своего первого драйвера с функциональностью, а также приложение клиента.
В этой главе:
-
Общие руководства программирования ядра
-
Сопоставление сборок отладки и выпуска
-
API ядра
-
Функции и коды ошибок
-
Строки
-
Динамическое выделение памяти
-
Связанные списки
-
Атрибуты объекта
-
Собственно объект драйвера
-
Объекты устройств
Разработка драйверов ядра требует Windows Driver Kit (WDK), где размещаются все необходимые заголовки и библиотеки. API самого ядра состоит из функций C, по существу очень похожи на API режима пользователя. Однако имеется ряд отличий. В Таблице 3-1 суммируются все важные различия между программированием в режиме пользователя и программированием в режиме ядра.
Режим пользователя | Режим ядра | |
---|---|---|
Не обрабатываемые исключительные ситуации |
Не обрабатываемые исключительные ситуации приводят к крушению процесса |
Не обрабатываемые исключительные ситуации приводят к крушению всей системы |
Прекращение |
При прекращении процесса все частная память и ресурсы освобождаются автоматически |
Если драйвер выгружается без освобождения всего применяемого им, имеется некая утечка, разрешимая только со следующим запуском |
Возвращаемые значения |
Ошибки API иногда игнорируются |
Не следует (почти) никогда игнорировать ошибки |
IRQL |
Всегда |
Может быть |
Плохое кодирование |
Обычно локализовано в самом процессе |
Может оказывать воздействие на всю систему |
Тестирование и отладка |
Обычно тестирование и отладка выполняются в определённой машине разработчика |
Отладка должна проводиться при помощи другой машины |
Библиотеки |
Могут применяться почти все библиотеки C/C++ (например, STL, boost) |
Большинство стандартных библиотек не могут применяться |
Обработка исключительной ситуации |
Может применять исключительные ситуации C++ или Структурную обработку исключительных ситуаций (SEH, Structured Exception Handling) |
Могут применяться только SEH |
Применение C++ |
Доступен весь C++ времени исполнения |
Никакого C++ времени исполнения |
Происходящие в режиме пользователя исключительные ситуации, которые не перехватываются самой программой вызывают преждевременное прекращение такого процесса. Код режима ядра, с другой стороны, который обладает неявным доверием, не способен восстановиться после исключительной ситуации без обработки. Такая исключительная ситуация приводит к крушению всей системы с печально известным Синим окном смерти (BSOD - Blue screen of death, более новые версии Windows обладают более разнообразными цветами для экрана крушения). На первый взгляд, BSOD может показаться неким видом кары, но по существу, это защитный механизм. Обоснованием этого является то, что продолжение исполнения кода может приводить к необратимому повреждению Windows (например, к удалению важных файлов или повреждению реестра), что влечёт за собой отказ запуска этой системы. А потому лучше сразу всё остановить, чтобы предотвратить потенциальный ущерб. Более подробно BSOD мы обсудим в Главе 6.
Всё это, по крайней мере, ведёт к единственному заключению: код ядра обязан тщательно программироваться, быть педантичным и не упускающим деталей или проверок ошибок.
После прекращения процесса, причём по любой причине - будь она обычной, из-за некой исключительной ситуации без обработки, либо в результате прерывания внешним кодом - это никогда не влечёт никаких утечек: вся частная память освобождается, а все дескрипторы закрываются. Естественно, преждевременное закрытие дескриптора способно вызывать некую утрату данных, например, по причине закрытия дескриптора файла до сброса на диск некоторых данных - однако нет никакой утечки ресурсов вне самого времени жизни данного процесса; это обеспечивается самим ядром.
Драйверы ядра, с другой стороны, не предоставляют такой гарантии. Если некий драйвер выгружается при всё ещё удерживаемой выделенной памятью или открытых дескрипторов - такие ресурсы не будут высвобождены автоматически и отпускаются лишь при следующем запуске системы.
Почему это происходит Не может ли само ядро отслеживать выделение и применение ресурсов драйвера для возможности их автоматического высвобождения при выгрузке драйвера?
Теоретически, этого можно было бы добиться (хотя в настоящее время само ядро не отслеживает такое применение ресурсов). Настоящая проблема состоит в том, что для самого ядра была бы слишком опасной попытка такой очистки. У ядра нет никакой возможности узнать по какой именно причине из данного драйвера утекли эти ресурсы; например, драйвер мог выделить некий буфер, а затем передать его другому драйверу, с которым он осуществляет взаимодействие. Когда само ядро пытается освободить такой буфер при выгрузке первого драйвера, второй драйвер вызывает нарушение прав доступа при доступе к такому освобождённому на текущий момент буферу, что приводит к крушению системы.
Это подчёркивает ответственность драйвера ядра за надлежащую очистку после себя; никто иной этого не сделает.
В обычном коде режима пользователя, возвращаемые из API функций значения порой игнорируются, поскольку его разработчик несколько оптимистичен, полагая что вызванная функция вряд ли приводит к сбою. Это может быть приемлемым или нет для той или иной функции, но в худшем случае исключительная ситуация без обработки позднее приведёт к сбою своего процесса; сама система, тем не менее, останется нетронутой.
Игнорирование возвращаемых значений из API ядра намного опаснее (вспомните предыдущий раздел Прекращение)и, как правило, этого следует избегать. Даже кажущиеся "невинными" функции способны отказывать по неожиданным причинам, поэтому здесь золотое правило - всегда проверять возвращаемые из API ядра значения состояния.
Уровень запроса прерывания (IRQL, Interrupt Request Level) это важное понятие ядра, которое в дальнейшем будет обсуждаться в Главе 6. На данный момент достаточно будет сказать, что обычно IRQL процессора равен нулю и, в частности, он всегда равен нулю при исполнении кода режима пользователя. В режиме ядра он в большинстве случаев также равен нулю, но не всегда. Некоторые ограничения на выполнение кода имеются при IRQL 2 или выше, а это означает, что сам автор драйвера обязан быть осторожным и применять лишь допустимые для столь высокого IRQL API. Воздействие IRQL со значением выше нуля обсуждается в Главе 6.
В программировании режима пользователя C++ применялся на протяжении многих лет и он хорошо работал при сочетании с API Windows режима пользователя. Для режима ядра Microsoft начал официальное сопровождение C++ начиная с Visual Studio 2012 и WDK 8. C++, естественно, не обязателен, однако он обладает некоторыми важными преимуществами относительно очистки ресурсов с идиомой C++ под названием Приобретения ресурсов инициализацией (RAII, Resource Acquisition Is Initialization). Мы слегка будем пользоваться этой идиомой для гарантии того, что у нас нет утечки ресурсов.
C++ в качестве языка программирования почти полностью поддерживается кодом ядра. Однако в самом ядре отсутствует время исполнения C++, а потому некоторые функциональные возможности C++ просто не могут применяться:
-
Операторы
new
иdelete
не поддерживаются и будут приводить к отказу при компиляции. Это обусловлено тем, что их обычное действие состоит в выделении из кучи режима пользователя, что неуместно внутри ядра. API самого ядра обладает функциями "замещения", которые наиболее точно моделируются вследствие функцийmalloc
иfree
. Позднее в этой главе мы обсудим эти функции. Тем не менее, имеется возможность перекрывать операторыnew
иdelete
аналогично тому как это иногда делается в режиме пользователя и активировать функции выделения и освобождения памяти самого ядра. Позднее в этой главе мы рассмотрим как это осуществлять. -
Обладающие не определёнными по умолчанию конструкторами глобальные переменные не будут вызываться - для вызова таких конструкторов нет никакого C/C++ времени исполнения. Таких ситуаций следует избегать, однако имеются некоторые обходные пути:
-
Избегайте в своём конструкторе любой код и вместо создавайте некую подлежащую в явном виде вызову функцию
Init
(то есть изDriverEntry
). -
Выделяйте указатель исключительно как глобальный (или статический) и создавайте реальный экземпляр динамически. Сам компилятор сгенерирует необходимый верный код для активации такого конструктора. Это работает при условии перекрытия операторов
new
иdelete
, как это описывается позднее в данной главе.
-
-
Ключевые слова обработки исключительной ситуации C++ (
try
,catch
,throw
) не компилируются. Это происходит по той причине, что механизм обработки исключительных ситуаций C++ требует своего собственной среды времени исполнения, что не предоставляется в ядре. Обработка исключительной ситуации может выполняться только при помощи Структурной обработки исключительных ситуаций (SEH, Structured Exception Handling) - механизма ядра для обработки исключительных ситуаций. Более подробно мы рассмотрим SEH в Главе 6. -
В самом ядре недоступны стандартные библиотеки C++. Хотя большинство из них основаны на шаблонах, они не компилируются, поскольку могут зависеть от библиотек и семантики режима пользователя. Тем не менее, шаблоны C++ в качестве функциональной возможности языка программирования работают просто отлично. Одним из пригодных способов применения шаблонов является создание альтернатив для типов библиотек режима ядра на основе аналогичных типов из стандартной библиотеки C++ режима пользователя, таких как
std::vector<>
,std::wstring
и тому подобных.
В примерах кода этой книги применяется С++. В таких примерах кода в основном применяются следующие свойства:
-
Ключевое слово
nullptr
, представляющее истинный указательNULL
. -
Ключевое слово
auto
, позволяющее определять подразумеваемый тип при объявлении и инициализации переменных. Это полезно для снижения беспорядка, экономии времени при наборе текста и сосредоточении на важных моментах. -
Везде, где они имеют смысл, будут применяться шаблоны.
-
Перекрытие операторов
new
иdelete
. -
Конструкторы и деструкторы, в особенности для построения типов RAII.
Для разработки ядра можно пользоваться любым стандартом C++. Имеющиеся настройки Visual Studio для новых проектов применяют C++ 14. Однако вы можете изменить стандарт компилятора C++ на любую иную установку, включая C++ 20 (последний стандарт на момент написания этих строк). Некоторые применяемые нами позднее функциональные возможности будут зависеть, как минимум, от C++ 17.
Строго говоря, драйверы ядра можно писать без каких бы то ни было проблем на чистом C. Если вы предпочитаете следовать этим путём, вместо файлов с расширением CPP применяйте файлы с C. Это автоматически активирует для таких файлов компилятор C.
При использовании кода режима пользователя тестирование в целом выполняется в машине разработки (когда могут удовлетворяться все необходимые зависимости). Обычно отладка выполняется через подключения к самому исполняемому коду собственно отладчика (в большинстве случаев Visual Studio) или запуска некого исполняемого файла и присоединения его к соответствующему процессу.
Для кода ядра тестирование обычно выполняется в иной машине, как правило в виртуальной машине, размещаемой в вашей машине разработки. Это обеспечит в случае возникновения BSOD (синего окна смерти) отсутствие воздействия на машину разработки. Отладка кода ядра обязана осуществляться в другой машине, в которой присутствует реальный драйвер. Это обусловлено тем, что достижение точки прерывания в режиме ядра замораживает всю машину целиком, а не просто определённый процесс. Такая машина разработки размещает сам отладчик, в то время как соответствующая вторая машина (и снова, как правило, виртуальная машина) исполняет необходимый код драйвера. Эти две машины должны соединяться через некий механизм с тем, чтобы данные могли протекать между самим хостом (где выполняется сам отладчик) и его целью. Более подробно отладку мы рассмотрим в Главе 5.
В точности как и с проектами режима пользователя, сборка драйверов ядра может выполняться в режимах Отладки или Выпуска (Debug или Release). Имеющиеся отличия аналогичны их двойникам режима пользователя - сборки Отладки не применяют по умолчанию оптимизаций компилятора, но более просты для отладки. Сборки Выпуска пользуются полной оптимизацией компилятора по умолчанию, производя самый быстрый и наименьший из возможных код. Тем не менее, имеются и некие отличия.
Применяемыми в ядре терминами выступают Checked (Debug) и
Free (Release). Хотя проекты ядра Visual Studio продолжают применять термины
Debug/Release, более старая документация пользуется терминами Checked/Free. С точки зрения компиляции, сборка Debug ядра
определяет символ DBG
и устанавливает для него значение 1 (в противовес определяемому
в режиме пользователя символу _DEBUG
). Это означает что вы можете применять такой
символ DBG
в качестве отличия между сборками Debug и Release с условием компиляции.
Например, именно это выполняет макрос KdPrint
: в сборках Debug он выполняет
компиляцию вызывая DbgPrint
, в то время как сборки Release не компилируют ничего,
что в результате вызовы KdPrint
не имеют воздействия в сборках Release. Обычно
это именно то что нужно вам, потому как такие вызовы достаточно затратны. Мы обсудим прочие способы ведения журналов в
Главе 5.
Драйверы ядра пользуются экспортируемыми из компонентов ядра функциями. Эти функции будут носить название
API ядра. Большинство функций реализуются внутри самого модуля ядра
(NtOskrnl.exe
), однако некоторые могут реализовываться прочими модулями ядра,
например, HAL (hal.dll
).
Такой API Ядра это большой набор функций C. Большинство из них начинаются с некого префикса, предполагающего тот компонент, который реализует эту функцию. Таблица 3-2 отображает некоторые из наиболее распространённых префиксов и их значение:
Префикс | Значение | Образец |
---|---|---|
|
Общие функции супервизора |
|
|
Общие функции ядра |
|
|
Диспетчер памяти |
|
|
Общая библиотека времени выполнения |
|
|
Библиотека времени выполнения файловой системы |
|
|
Библиотека мини- фильтра файловой системы |
|
|
Диспетчер объектов |
|
|
Диспетчер ввода/ вывода |
|
|
Безопасность |
|
|
Диспетчер процессов |
|
|
Диспетчер управления питанием |
|
|
Windows management instrumentation |
|
|
Собственные обёртки API |
|
|
Hardware abstraction layer |
|
|
Диспетчер конфигурации (реестр) |
|
Если вы взглянете на список экспортируемых из NtOsKrnl.exe
функций,
вы обнаружите множество функций, которые не документированы в самом Windows Driver Kit; это просто обусловлено фактом
жизни разработчика ядра - не всё документируется.
На данном этапе следует обсудить один из наборов функций - функции с префиксом Zw
.
Эти функции отражают собственные интерфейсы API, доступные в качестве шлюзов из NtDll.Dll
,
причём с реальной представляемой самим Супервизором реализацией. Когда из режима пользователя вызывается некая функция
Nt
, скажем, NtCreateFile
, она достигает
своего Супервизора в его реальной реализации NtCreateFile
. В данном месте
NtCreateFile
может выполнять разнообразные проверки на основании того факта, что его
первоначальная сторона вызова выполняется в режиме пользователя. Такие сведения вызывающей стороны сохраняются на основе
потока- через- поток, причём в соответствующем недокументированном участнике PreviousMode
в соответствующей структуре KTHREAD
для каждого потока.
Замечание | |
---|---|
Вы можете запрашивать предыдущий режим процессора через вызов документированного API
|
С другой стороны, когда драйверу ядра требуется вызвать некую системную службу, он не должен подвергаться тем же проверкам
и ограничениям, которые налагаются на вызывающие стороны режима пользователя. Именно здесь и вступают в действие функции
Zw
. Вызов функции Zw
устанавливает предыдущий режим вызывающей стороны в KernelMode (0)
и затем вызывает
собственную функцию. Например, вызов ZwCreateFile
устанавливает предыдущую
вызывающую сторону в KernelMode
и далее вызывает NtCreateFile
,
что в результате приводит обход некоторых проверок безопасности и буфера NtCreateFile
,
которые бы выполнялись в противном случае. Суть в том, что драйверы ядра обязаны вызывать функции
Zw
, если нет веских причин поступать иначе.
Большинство функций API возвращает некое состояние, указывающее на успешность или неудачу операции. Оно обладает типом
NTSTATUS
, 32- битным целым со знаком. Его значение
STATUS_SUCCESS (0)
означает успех. Отрицательное значение указывает на некий вид ошибки.
Вы можете найти все заданные для NTSTATUS
значения в файле
<ntstatus.h>
.
Большинство путей кода не беспокоятся о точной природе получаемой ошибки, а потому проверки старшего бита вполне достаточно
для выяснения того произошла ли ошибка. Это можно осуществить при помощи макроса
NT_SUCCESS
. Вот пример, который проверяет наличие отказа и регистрирует ошибку когда он
имеет место:
NTSTATUS DoWork() {
NTSTATUS status = CallSomeKernelFunction();
if(!NT_SUCCESS(Statue)) {
KdPirnt((L"Error occurred: 0x%08X\n", status));
return status;
}
// продолжить дополнительные действия
return STATUS_SUCCESS;
}
В некоторых ситуациях возвращаемые из функций значения NTSTATUS
в конечном итоге
всплывают вплоть до режима пользователя. В таком случае значение STATUS_xxx
транслируется в некоторое значение ERROR_yyy
, которое доступно для режима
пользователя через функцию GetLastError
. Обратите внимание, что это не те же самые
числа; например,коды ошибки в режиме пользователя обладают положительными значениями (ноль всё ещё успех). Далее,
устанавливаемое соответствие не будет один- к- одному. В любом случае, обычно это не относится в драйверу ядра.
Внутренние функции драйвера ядра также обычно возвращают NTSTATUS
чтобы указывать
своё состояние успеха/ неудачи. Обычно этого достаточно, поскольку эти функции вызывают API интерфейсы ядра, а потому способны
распространять любую ошибку, просто возвращая то же значение состояния, которое они получили от конкретного API. Это к тому же
подразумевает, что "настоящие" возвращаемые значения функций драйвера обычно возвращаются указателями или ссылками,
предоставляемыми этой функцией в качестве аргументов.
Совет | |
---|---|
Возвращайте |
API по мере необходимости применяет строки во многих ситуациях. В некоторых случаях эти строки являются простыми указателями
Unicode (wchar_t*
или одним из их typedef
, например
WCHAR*
), однако большинство имеющих дело со строками функций ожидают структуры с типом
UNICODE_STRING
.
Замечание | |
---|---|
Применяемый в данной книге термин Unicode в точности эквивалентен UTF-16, что означает 2 байта на символ. Именно так строки хранятся внутренним образом внутри компонентов ядра. В целом, Unicode это некое множество стандартов относительно кодирования символов. Дополнительные сведения вы можете найти на https://unicode.org. |
Структура UNICODE_STRING
представляет строку с её длиной и максимумом её известной
длины. Вот упрощённое определение этой структуры:
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWCH Buffer;
} UNICODE_STRING;
typedef UNICODE_STRING *PUNICODE_STRING;
typedef const UNICODE_STRING *PCUNICODE_STRING;
Значение элемента Length
представлено в байтах (не в символах) и не включает в себя
ограничитель Unicode-NULL
, если он присутствует (ограничитель
NULL
не является обязательным). Значение элемента
MaximumLength
это число байтов, до которого может вырастать данная строка без необходимости
перераспределения памяти.
Манипуляции структурами UNICODE_STRING
обычно выполняются при помощи набора функций
Rtl
, которые конкретно имеют дело со строками. Таблица 3-3 перечисляет
некоторые распространённые функции для манипуляции строками, предоставляемые функциями
Rtl
.
Функция | Описание |
---|---|
|
Инициализирует |
|
Копирует |
|
Сравнивает две строки |
|
Сопоставляет две строки |
|
Добавляет одну строку |
|
Добавляет |
Дополнительно к указанным выше функциям, имеются функции, которые работают с указателями строк C. Более того, внутри
самого ядра реализованы хорошо известные функции строк из C Runtime Library, что к тому же удобно:
wcscpy_s
, wcscat_s
,
wcslen
, wcscpy_s
,
wcschr
, strcpy
,
strcpy_s
и прочие.
Замечание | |
---|---|
Значение префикса |
Совет | |
---|---|
Никогда не пользуйтесь не безопасными функциями. Вы можете вставить
|
Драйверам часто требуется динамическое выделение памяти. Как это уже обсуждалось в Главе 1, размер стека потока ядра достаточно мал, а потому любые большие фрагменты памяти должны выделяться динамически.
Само ядро предоставляет два общих пула памяти для применения драйверами (само по себе ядро также пользуется ими).
-
Пул с подкачкой страниц - пул памяти, который в случае необходимости может выделяться постранично.
-
Невыгружаемый пул - пул памяти, который никогда не выделяется постранично т гарантированно остаётся в оперативной памяти.
Очевидно, что невыгружаемый пул является "лучшим" пулом, поскольку он никогда не способен вызывать ошибку
страницы. Позднее в этой книге мы обнаружим, что в некоторых случаях требуется выделение памяти из невыгружаемого пула.
Драйверам надлежит пользоваться этим пулом экономно, причём исключительно в случае необходимости. Во всех прочих случаях
драйверы обязаны применять выгружаемый пул (с подкачкой страниц). Имеющееся перечисление POOL_TYPE
представляет значения типов пулов. Данное перечисление содержит множество "типов" пулов, однако драйверами должны
применяться лишь три: PagedPool
, NonPagedPool
,
NonPagedPoolNx
(невыгружаемый пул без полномочий исполнения).
Таблица 3-4 суммирует наиболее распространённые функции, применяемые для работы с допустимыми пулами памяти ядра:
Функция | Описание |
---|---|
|
Выделяет память из одного из допустимых пулов с тегом по умолчанию. Данная функция считается устаревшей. Вместо неё надлежит применять следующую функцию из данной таблицы. |
|
Выделяет память из одного из допустимых пулов с предписанным тегом. |
|
То же, что и |
|
Высвобождает выделение. Данная функция знает из какого пула было выполнено данное выделение. |
Замечание | |
---|---|
|
Замечание | |
---|---|
Прочие функции управления памятью рассматриваются в Главе 8, Современные технологии программирования. |
Параметр значения тега позволяет "маркировать" некое выделение значением в 4- байта. Обычно это значение
составляется из символов ASCII общим числом до 4, логически идентифицирующих свой драйвер или некую часть своего драйвера.
Этот тег может применяться в помощь выявлению утечек памяти - когда какие- бы то ни было помеченные тегом выделения с
обозначенным тегом драйвера остаются после выгрузки своего драйвера. Такие выделения пула (со своими тегами) могут
просматриваться при помощи инструмента WDM Poolmon
или моего собственного
инструмента PoolMonXv2
(выгружаемого с http://www.github.com/zodiacon/AllTools). Рисунок 3-1
отображает снимок экрана для PoolMonXv2
.
Замечание | |
---|---|
Вам надлежит применять теги из выводимых на печать символов ACSII. В противном случае запуск вашего драйвера под
управлением |
Наш следующий пример кода отображает выделение памяти и копирование строк для сохранения значения пути реестра,
передаваемого в DriverEntry
и высвобождение этой строки в вашей процедуре Unload.
// определение тега (по причине младшего следования байт, представляемого как 'abcd')
#define DRIVER_TAG 'dcba'
UNICODE_STRING g_RegistryPath;
extern "C" NTSTATUS
DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
UNREFERENCED_PARAMETER(DriverObject);
DriverObject->DriverUnload = SampleUnload;
g_RegistryPath.Buffer = (WCHAR*)ExAllocatePoolWithTag(PagedPool,
RegistryPath->Length, DRIVER_TAG);
if (g_RegistryPath.Buffer == nullptr) {
KdPrint(("Failed to allocate memory\n"));
return STATUS_INSUFFICIENT_RESOURCES;
}
g_RegistryPath.MaximumLength = RegistryPath->Length;
RtlCopyUnicodeString(&g_RegistryPath,
(PCUNICODE_STRING)RegistryPath);
// %wZ is for UNICODE_STRING objects
KdPrint(("Original registry path: %wZ\n", RegistryPath));
KdPrint(("Copied registry path: %wZ\n", &g_RegistryPath));
//...
return STATUS_SUCCESS;
}
void SampleUnload(_In_ PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
ExFreePool(g_RegistryPath.Buffer);
KdPrint(("Sample driver Unload called\n"));
}
Во многих своих внутренних структурах данных ядро применяет циклические двусвязные списки. Например, все процессы в системе
управляются структурами EPROCESS
, связанными в циклический двусвязный список, где в
его заголовке хранится переменная ядра PsActiveProcessHead
.
Все такие списки строятся одинаково вокруг структуры LIST_ENTRY
, определяемой
следующим образом:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY;
Рисунок 3-2 отображает некий образец такого списка, содержащего заголовок и три экземпляра.
Одна такая структура встроена в представляющую интерес реальную структуру. Например, в нашей структуре
EPROCESS
, её элемент ActiveProcessLinks
обладает
типом LIST_ENTRY
, указывающим на свои следующий и предыдущий объекты
LIST_ENTRY
в других структурах EPROCESS
. Сам
заголовок списка хранится обособленно; в случае данного процесса это PsActiveProcessHead
.
Для получения указателя на фактическую представляющую интерес структуру, адрес LIST_ENTRY
можно добыть при помощи макроса CONTAINING_RECORD
.
К примеру, допустим, вы желаете управлять списком структур с типом MyDataItem
,
определённым подобным образом:
struct MyDataItem {
// некоторые элементы данных
LIST_ENTRY Link;
// ещё элементы данных
};
При работе с такими связанными списками, у нас имеется некий заголовок для самого списка, хранимый в переменной. Это
означает, что естественный обход выполняется при помощи значения элемента Flink
данного
списка для указания на следующий LIST_ENTRY
в данном списке. Обладая указателем на
LIST_ENTRY
, нам в действительности требуется MyDataItem
,
содержащий данный элемент записи списка. Именно здесь появляется CONTAINING_RECORD
:
MyDataItem* GetItem(LIST_ENTRY* pEntry) {
return CONTAINING_RECORD(pEntry, MyDataItem, Link);
}
Данный макрос выполняет правильный расчёт смещения и осуществляет приведение к фактическому типу данных (в моём примере
MyDataItem
).
Таблица 3-5 показывает распространённые функции для работы с такими связанными списками. Все операции применяют постоянное время.
Функция | Описание |
---|---|
|
Инициализирует заголовок списка для создания пустого списка. Прямой и обратный указатели указывают на значение прямого указателя. |
|
Вставляет элемент в голову данного списка. |
|
Вставляет элемент в хвост данного списка. |
|
Проверяет является ли список пустым. |
|
Удаляет элемент из головы данного списка. |
|
Удаляет элемент из хвоста данного списка. |
|
Атомарно вставляет элемент в голову списка применяя соответствующую заданную взаимную блокировку. |
|
Атомарно вставляет элемент в хвост списка применяя соответствующую заданную взаимную блокировку. |
|
Атомарно удаляет элемент из головы списка применяя соответствующую заданную взаимную блокировку. |
Последние три функции из таблицы 3-4 осуществляют операцию атомарно при помощи примитива синхронизации с названием spin lock (взаимной блокировки). Взаимные блокировки обсуждаются в Главе 6.
Мы уже видели, что наша функция DriverEntry
получает два параметра, первым
выступает объект драйвера некоторого вида. Это частично документированная структура с названием
DRIVER_OBJECT
, определяемая в заголовках WDK. "Частичная документированность"
означает что некоторые её элементы документированы для применения в драйвере, а некоторые нет. Эта структура выделяется самим
ядром и частично инициализируется. Затем она предоставляется DriverEntry
(и вплоть до
того, пока сам драйвер не выгружает её в своей процедуре Unload). Основная роль самого драйвера в данный момент состоит в
дальнейшей инициализации этой структуры для указания того, какие операции поддерживаются этим драйвером.
Одну из таких "операций" мы видели в своей Главе 2 - процедуру
Unload. Другим важным набором операций для инициализации выступают операции с названием Dispatch
Routines (Процедур организации). Это некий массив указателей на функции, хранимых в элементе
DRIVER_OBJECT
MajorFunction
. Данный набор
определяет какие операции поддерживает его драйвер, например, Create, Read, Write и так далее. Эти индексы определяются
префиксом IRP_MJ_
. Таблица 3-6 показывает некоторые употребимые коды основных
функций и их значения.
Основная функция | Описание |
---|---|
|
Операция создания. Обычно активируется для вызовов
|
|
Операция закрытия. Обычно активируется для вызовов
|
|
Операция считывания. Обычно активируется для |
|
Операция записи. Обычно активируется для |
|
Общий вызов драйвера, активируется по причине вызовов
|
|
Аналогичен предыдущему, но доступен только для вызывающей стороны режима ядра. |
|
Вызывается при останове самой системы когда этот драйвер зарегистрирован для уведомлений об
останове при помощи |
|
Активируется при закрытии самого последнего дескриптора объекта файла, но когда значение счётчика ссылок не ноль. |
|
Активируемый Диспетчером устройств автоматического подключения обратный вызов Автоматического подключения. Обычно представляет интерес для драйверов обслуживания аппаратных средств или для фильтрации таких драйверов. |
|
Активируемый Диспетчером управления питания обратный вызов Управления питанием. Обычно представляет интерес для драйверов обслуживания аппаратных средств или для фильтрации таких драйверов. |
Первоначально сам массив MajorFunction
инициализируется собственно ядром для указания
на внутренние процедуры ядра, IopInvalidDeviceRequest
, которые возвращают состояние
отказа вызывающей стороне, указывающего на то, что эта операция не поддерживается. Это означает, что данному драйверу, в его
процедуре DriverEntry
лишь требуется инициализировать те реальные операции, которые
он сопровождает, оставляя все прочие записи в их значениях по умолчанию.
Например, наш драйвер Sample на данный момент не поддерживает никаких Процедур организации, что означает, что нет никакого
способа взаимодействия с эти драйвером. По крайней мере, драйвер обязан поддерживать операции
IRP_MJ_CREATE
и IRP_MJ_CLOSE
, чтобы допускать
открытие дескриптора к одному из объектов устройств для этого драйвера. В своей следующей главе мы воплотим эти мысли на
практике.
Одной из распространённых структур, встречающихся во многих API ядра, является
OBJECT_ATTRIBUTES
, определяемая следующим образом:
typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
PUNICODE_STRING ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor; // SECURITY_DESCRIPTOR
PVOID SecurityQualityOfService; // SECURITY_QUALITY_OF_SERVICE
} OBJECT_ATTRIBUTES;
typedef OBJECT_ATTRIBUTES *POBJECT_ATTRIBUTES;
typedef CONST OBJECT_ATTRIBUTES *PCOBJECT_ATTRIBUTES;
Данная структура обычно инициализируется при помощи макроса InitializeObjectAttributes
,
который позволяет определять все элементы этой структуры за исключением Length
(настраивается автоматически самим макросом) и SecurityQualityOfService
, который обычно
не нужен. Вот описание его элементов:
-
ObjectName
это значение названия подлежащего созданию/ размещению объекта, предоставленное в виде указателя наUNICODE_STRING
. В некоторых случаях может быть достаточным установка значения вNULL
. Например,ZwOpenProcess
позволяет открывать дескриптор для процесса задавая его PID. Поскольку процессы не обладают названиями, значениеObjectName
в таком случае должно инициализироваться вNULL
. -
RootDirectory
это не обязательный каталог в пространстве имён Диспетчера объектов, когда такой объект является относительным. ЕслиObjectName
определяет полностью определённое имя,RootDirectory
должен быть установлен вNULL
. -
Attributes
позволяет определять набор флагов, которые оказывают воздействие на саму операцию в запросе. Таблица 3- отображает определения флагов и их значения. -
SecurityDescriptor
это не обязательный описатель безопасности (SECURITY_DESCRIPTOR
) для установки в своём вновь создаваемом объекте.NULL
указывает что такой новый объект получает описатель безопасности по умолчанию на основании значения маркера вызывающей стороны. -
SecurityQualityOfService
это не обязательный набор атрибутов, относящихся к уровню воплощения своего нового объекта и режимом отслеживания контекста. Для большинства значения он не имеет значения. Для получения дополнительных сведений обратитесь к соответствующей документации.
Flag (OBJ_) | Описание |
---|---|
|
Возвращаемый дескриптор должен быть помечен как наследуемый. |
|
Создаваемый объект должен быть помечен как неизменный. Неизменные объекты обладают неким дополнительным счётчиком ссылок, который препятствует их уничтожению даже когда все дескрипторы на него закрыты. |
|
При создании некоторого объекта, такой объект создаётся с исключительным доступом. Никакие иные дескриптор не могут быть открыты для этого объекта, запрашивается исключающий доступ, который предоставляется только если такой объект были изначально создан с данным флагом. |
|
Открывать данный объект когда он имеется. В противном случае отказывать в такой операции (не создавать новый объект). |
|
Когда подлежащий открытию объект является объектом символической ссылки, открывать сам объект символической ссылки вместо перехода по этой ссылки к её цели. |
|
Возвращаемый дескриптор должен быть дескриптором ядра. Дескрипторы ядра допустимы в любом контексте процесса и не могут применяться кодом режимом пользователя. |
|
Проверка доступа обязана осуществляться даже когда этот объект открыт в режиме доступа
|
|
Применять соответствие устройства вместо соответствия пользователю, при попытке такого перевоплощения (для получения дополнительных сведений о соответствии устройств обратитесь к соответствующей документации.) |
|
Не следуйте точке повторного синтаксического анализа при её возникновении. Вместо этого
возвращается ошибка ( |
Второй способ инициализации структуры OBJECT_ATTRIBUTES
доступен через макрос
RTL_CONSTANT_OBJECT_ATTRIBUTES
, который пользуется для настройки наиболее употребимыми
элементами - значением названия объекта и значений атрибутов.
Давайте рассмотрим пару образцов применения OBJECT_ATTRIBUTES
. Первым выступает
функция, которая открывает дескриптор процесса, определяемого идентификатором этого процесса. Для данной цели мы пользуемся
API ZwOpenProcess
, задаваемого следующим образом:
NTSTATUS ZwOpenProcess (
_Out_ PHANDLE ProcessHandle,
_In_ ACCESS_MASK DesiredAccess,
_In_ POBJECT_ATTRIBUTES ObjectAttributes,
_In_opt_ PCLIENT_ID ClientId);
Он пользуется ещё одной общей структурой, CLIENT_ID
, которая содержит идентификаторы
процесса и/ или потока:
typedef struct _CLIENT_ID {
HANDLE UniqueProcess; // PID, not handle
HANDLE UniqueThread; // TID, not handle
} CLIENT_ID;
typedef CLIENT_ID *PCLIENT_ID;
Для открытия процесса нам требуется задать значение идентификатора процесса в соответствующем элементе
UniqueProcess
. Обратите внимание, что хотя значением типа
UniqueProcess
является HANDLE
, это
значение уникального идентификатора данного процесса. Основной причиной для задания типа
HANDLE
является то, что идентификаторы процесса и потока вырабатываются из частной
таблицы дескрипторов. Это также поясняет почему идентификаторы процесса и потока всегда умножаются на четыре (в точности как
и обычные дескрипторы), а также почему они не перекрываются.
Вооружившись этими подробностями, вот функция открытия процесса:
NTSTATUS
OpenProcess(ACCESS_MASK accessMask, ULONG pid, PHANDLE phProcess) {
CLIENT_ID cid;
cid.UniqueProcess = ULongToHandle(pid);
cid.UniqueThread = nullptr;
OBJECT_ATTRIBUTES procAttributes =
RTL_CONSTANT_OBJECT_ATTRIBUTES(nullptr, OBJ_KERNEL_HANDLE);
return ZwOpenProcess(phProcess, accessMask, &procAttributes, &cid);
}
Применяемая функция ULongToHandle
выполняет необходимые приведения с тем, чтобы
наш компилятор оставался довольным (HANDLE
содержит 64- бита в 64- битных системах,
однако ULONG
всегда 32- битный). Единственным применяемым в приводимом выше коде
элементом из OBJECT_ATTRIBUTES
являются флаги
Attributes
.
Вторым примером выступает функция, которая открывает дескриптор на файл для доступа на чтение при помощи API
ZwOpenFile
, определяемая так:
NTSTATUS ZwOpenFile(
_Out_ PHANDLE FileHandle,
_In_ ACCESS_MASK DesiredAccess,
_In_ POBJECT_ATTRIBUTES ObjectAttributes,
_Out_ PIO_STATUS_BLOCK IoStatusBlock,
_In_ ULONG ShareAccess,
_In_ ULONG OpenOptions);
Общее обсуждение параметров для ZwOpenFile
зарезервировано для
Главы 11, однако один момент очевиден: значение названия файла
определяется при помощи имеющейся структуры OBJECT_ATTRIBUTES
- для этого нет
отдельного параметра. Вот вся функция открытия дескриптора для доступа к файлу на чтение целиком:
NTSTATUS OpenFileForRead(PCWSTR path, PHANDLE phFile) {
UNICODE_STRING name;
RtlInitUnicodeString(&name, path);
OBJECT_ATTRIBUTES fileAttributes;
InitializeObjectAttributes(&fileAttributes, &name,
OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, nullptr, nullptr);
IO_STATUS_BLOCK ioStatus;
return ZwOpenFile(phFile, FILE_GENERIC_READ,
&fileAttributes, &ioStatus, FILE_SHARE_READ, 0);
}
InitializeObjectAttributes
применяется для инициализации имеющейся структуры
OBJECT_ATTRIBUTES
, хотя также можно воспользоваться и
RTL_CONSTANT_OBJECT_ATTRIBUTES
, так как мы всего лишь определяем значение имени и
атрибутов. Заметьте, что необходимо преобразовывать переданный указатель строки C с окончанием
NULL
в UNICODE_STRING
при помощи
RtlInitUnicodeString
.
Хотя объект драйвера и может выглядеть хорошим претендентом на общение с клиентами, это не так. Фактически, конечными пунктами
взаимодействия с клиентами выступают объекты устройств. Объекты устройств являются экземплярами частично документированной
структуры DEVICE_OBJECT
. Без объектов устройств не с кем общаться. Это означает, что
драйвером должен быть создан по крайней мере один объект устройств, а также ему должно быть присвоено название для возможности
связи с ним клиентов.
Функция CreateFile
(и её вариации) получает первый параметр с названием "имя
файла" в документации, однако в действительности это должно быть место название объекта устройства, в котором некий
действительный файл файловой системы это всего лишь конкретный представитель. Само название CreateFile
нечто вводящее в заблуждение - значение слова "файл" здесь означает "объект файла". Открытие дескриптора для
файла или устройства создаёт некий экземпляр структуры своего ядра FILE_OBJECT
, другой
частично документированной структуры.
Более точно, CreateFile
получает Italic
символическую ссылку, некий объект ядра, который знает как указывать на иной объект ядра.
(Вы можете представлять себе символическую ссылку как аналог в понятии ярлыка файловой системы.) Все имеющиеся символические
ссылки, которые могут применяться из вызовов режима пользователя CreateFile
или
CreateFile2
расположены в соответствующем каталоге Диспетчера
объектов с названием ??. Вы можете наблюдать содержимое этого каталога при
помощи инструментов Sysinternals WinObj. Рисунок 3-3показывает такой каталог
(с названием Global?? в WinObj.
Некоторые из имён выглядят аналогично представлению в C: Aux,
Con и так далее. И в самом деле, это допустимые "имена файлов" для вызовов
CreateFile
. Иные записи выглядят как длинные загадочные строки, а в действительности
они вырабатываются имеющейся системой ввода/ вывода для драйверов на основе аппаратных средств, которые вызывают API
IoRegisterDeviceInterface
. Такие типы символических ссылок не слишком помогают целям
данной книги.
Большинство имеющихся в каталоге \?? символических ссылок указывают на некое
название устройства в каталоге \Device. Имеющиеся в этом каталоге названия не доступны
напрямую вызывающей стороне режима пользователя. Однако к ним можно получать доступ от вызывающей стороны ядра при помощи
API IoGetDeviceObjectPointer
.
Канонический пример это драйвер для Process Explorer. Когда процесс Process Explorer запускается с правами администратора, он устанавливает некий драйвер. Такой драйвер придаёт Process Explorer дополнительные возможности, которые могут быть получены вызывающими объектами режима пользователя, даже когда они работают с повышенными правами. К примеру, Process Explorer в своём диалоговом окне Threads для процесса способен отображать полный стек вызовов для потока, включая функции в режиме ядра. Такой вид сведений невозможно получать в режиме пользователя; именно драйвер предоставляет такие недостающие сведения.
Такой устанавливаемый Process Explorer драйвер создаёт отдельный объект устройства с тем, чтобы его Process Explorer обладал возможностью открывать дескриптор к такому устройству и выполнять запросы. Это означает, что его объект устройства должен обладать именем, а также обязан иметь символическую ссылку в каталоге ??; и она там присутствует, с названием PROCEXP152, вероятно, указывающим на версию 15.2 (на момент написания этих строк). Рисунок 3-4 показывает такую символическую ссылку в WinObj.
Обратите внимание, что эта символическая ссылка для устройства Process Explorer
указывает на \Device\PROCEXP152
, что является внутренним названием, доступным
исключительно для вызывающей стороны ядра (а также, как это будет показано в нашем следующем разделе, для естественных API
NtOpenFile
и NtCreateFile
). Реальный вызов
CreateFile
, осуществляемый основанным на символической ссылке
Process Explorer (либо любого иного клиента) обязан быть присоединённым спереди
\\.\.
. Это требуется для того, чтобы синтаксический анализатор Диспетчера ввода/ вывода
не полагал, что сама строка "PROCEXP152
" ссылается на некий файл без расширения
в своём текущем каталоге. Именно так Process Explorer открыл бы дескриптор к своему
объекту устройства (обращаем внимание на двойной обратный слэш, поскольку обратный слэш выступает экранирующим символом в
C/C++):
HANDLE hDevice = CreateFile(L"\\\\.\\PROCEXP152",
GENERIC_WRITE | GENERIC_READ, 0, nullptr, OPEN_EXISTING,
0, nullptr);
Совет | |
---|---|
Начиная с C++ и далее, вы можете писать строки без экранирования таким символом обратного слэша. В приводимом выше
коде значение пути к устройству может быть записано так: |
Замечание | |
---|---|
Вы можете испробовать самостоятельно приведённый выше код. Если Process Explorer
хотя бы раз запускался с повышенными правами в вашей системе после её запуска, его драйвер должен быть запущен (вы можете
убедиться в этом самим данным инструментом), а вызов к |
Драйвер создаёт объект драйвера при помощи функции IoCreateDevice
. Данная функция
выполняет выделение и инициализацию структуры объекта устройства и возвращает указатель на него своей вызывающей стороне.
Данный экземпляр объекта устройства сохраняется в элементе соответствующего DeviceObject
из структуры DRIVER_OBJECT
. Когда создано более одного объекта устройства, они
формируют обособленный связанный список, в котором элемент NextDevice
из
DRIVER_OBJECT
указывает на свой следующий объект устройства. Обратите внимание, что
все объекты устройств вставляются в голову этого списка, а потому самый первый созданный объект хранится последним; его
NextDevice
указывает на NULL
. Такие зависимости
отображены на Рисунке 3-5.
Наличие символической ссылки упрощает открытие дескриптора к устройству при помощи документированного API режима
пользователя CreateFile
(или из API ZwOpenFile
в самом ядре). Тем не менее, порой полезно обладать возможностью открывать объекты устройств без прохождения через
символическую ссылку. Например, объект устройства может не обладать символической ссылкой, поскольку его драйвер (по какой- то
из причин) вовсе её не предоставил.
Собственная функция NtOpenFile
(и NtCreateFile
)
может применяться для прямого открытия объекта устройства. Microsoft никогда не рекомендует применять собственные API, однако
эти функции являются чем- то документированным для применения в режиме пользователя. Их определение доступно в файле
заголовка <Winternl.h>
:
NTAPI NtOpenFile (
OUT PHANDLE FileHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN ULONG ShareAccess,
IN ULONG OpenOptions);
Обратите внимание на аналогичность с той ZwOpenFile
, которую мы применяли в более
ранней секции - это именно тот же самый прототип функции, только активируемый здесь из режима пользователя, причём в конечном
итоге для запуска в NtOpenFile
внутри Диспетчера ввода, вывода. Данная функция требует
применения структуры OBJECT_ATTRIBUTES
, описанной ранее в этой главе.
Замечание | |
---|---|
Приводимый выше прототип использует такие макросы как IN, OUT и прочие. Они были заменены аннотацией SAL (Язык аннотации исходного кода - Source (Code) Annotation Language). К сожалению, некоторые заголовки файлов ещё не были преобразованы в SAL. |
Для демонстрации применения NtOpenFile
из режима пользователя мы создадим некое
приложение воспроизведения отдельного звука. Обычно, такую службу предоставляет API Windows
Beep
режима пользователя:
BOOL Beep(
_In_ DWORD dwFreq,
_In_ DWORD dwDuration);
Данная функция принимает значение частоты для воспроизведения (в Герцах), а также значение длительности воспроизведения, в миллисекундах. Данная функция синхронная, что означает что она не выполняет возврат пока не истечёт установленная длительность.
Такой API Beep
работает через вызов устройства с названием
\Device\Beep
(вы можете обнаружить его в
WinObj), однако этот драйвер устройства beep не создаёт для него символической ссылки.
Тем не менее, мы можем открыть дескриптор к своему устройству beep при помощи NtOpenFile
.
Затем, для воспроизведения звука мы можем воспользоваться функцией DeviceIoContol
с
правильными параметрами. Хотя и не слишком сложно повторно спроектировать работу драйвера звукового сигнала, к счастью, нам
это не требуется. SDK предоставляет соответствующий файл <ntddbeep.h>
с
необходимыми определениями, включая значение названия устройства само по себе.
Мы начнём с создания приложения Консоли C++ в Visual Studio. Прежде чем мы получим свою функцию
main
, нам необходимы некоторые #includes
:
#include <Windows.h>
#include <winternl.h>
#include <stdio.h>
#include <ntddbeep.h>
<winternl.h>
предоставляет значение определения для
NtOpenFile
(и соответствующие структуры данных), в то время как
<ntddbeep.h>
предоставляет необходимые определения для всего
относительно beep.
Поскольку мы будем пользоваться NtOpenFile
, нам надлежит также выполнить
соединение для NtDll.Dll
, что можно сделать путём добавления
#pragma
в свой код или же добавить эту библиотеку в настройки компоновщика в
свойствах своего проекта. Давайте следовать первым способом, поскольку он проще, и не связываться со свойствами своего
проекта:
#pragma comment(lib, "ntdll")
Замечание | |
---|---|
Без приведённой выше связи, наш компоновщик испытал бы проблему некой ошибки "unresolved external" (неразрешимой внешней ссылки). |
Теперь мы можем приступить к написанию main
, где мы принимаем необязательные
параметры командной строки, указывающие значения частоты и продолжительности для воспроизведения:
int main(int argc, const char* argv[]) {
printf("beep [<frequency> <duration_in_msec>]\n");
int freq = 800, duration = 1000;
if (argc > 2) {
freq = atoi(argv[1]);
duration = atoi(argv[2]);
}
Наш следующий шаг состоит в открытии дескриптора этого устройства при помощи
NtOpenFile
:
HANDLE hFile;
OBJECT_ATTRIBUTES attr;
UNICODE_STRING name;
RtlInitUnicodeString(&name, L"\\Device\\Beep");
InitializeObjectAttributes(&attr, &name, OBJ_CASE_INSENSITIVE,
nullptr, nullptr);
IO_STATUS_BLOCK ioStatus;
NTSTATUS status = ::NtOpenFile(&hFile, GENERIC_WRITE, &attr, &ioStatus, 0, 0);
Строку инициализации названия устройства можно заменить следующей:
RtlInitUnicodeString(&name, DD_BEEP_DEVICE_NAME_U);
Такой макрос DD_BEEP_DEVICE_NAME_U
удобно предоставляется как часть
<ntddbeep.h>
.
Когда такой код завершается успешно, мы можем воспроизвести необходимый звук.Для этого мы вызываем
DeviceIoControl
с управляющим кодом, определяемым в
<ntddbeep.h>
и применяем заданную там структуру, а также заполняем
значения частоты и продолжительности:
if (NT_SUCCESS(status)) {
BEEP_SET_PARAMETERS params;
params.Frequency = freq;
params.Duration = duration;
DWORD bytes;
//
// воспроизведение звука
//
printf("Playing freq: %u, duration: %u\n", freq, duration);
::DeviceIoControl(hFile, IOCTL_BEEP_SET, ¶ms, sizeof(params),
nullptr, 0, &bytes, nullptr);
//
// звук начинает воспроизводиться и вызов возвращается немедленно
// Дожидаемся пока само прикладное приложение не закроется
//
::Sleep(duration);
::CloseHandle(hFile);
}
Наш буфер входных данных, передаваемый в DeviceIoControl
, обязан быть структурой
BEEP_SET_PARAMETERS
, которую мы передаём совместно с её размером. Самым последним
фрагментом в нашей мозаике выступает применение APISleep
для ожидания на основе
заданной продолжительности, в противном случае имеющийся дескриптор для нашего устройства был бы закрыт, а само звучание
отсечено.
Совет | |
---|---|
Напишите приложение,которое воспроизводит некий массив звуков, воспользовавшись приведённым выше кодом. |