Методика все та же — минимум объяснений, максимум рецептов. Для глубинного понимания происходящих процессов рекомендую обратиться к документации в MSDN — этот раздел уже даже перевели на русский язык.
Учиться будем на конкретных примерах. Естественно, как это принято, начнем мы с самого простого приложения — hello, world! Для вывода этого текста, мы заинтеропим функцию MessageBox из WinAPI, на примере которой подробно разберемся со строками и кодировкой.
Так, кто сказал MessageBox.Show? Мне не нравится ваша розовая кофточка, ваши сиськи и ваш микрофон, встала и ушла отсюда. Вернешься с умными советами, когда мы будем массивы структур маршаллить. Чтобы у остальных не было соблазнов — работать будем в рамках консольного проекта, не подключая Windows.Forms.
Итак, пример первый, тривиальный. Создаем статический класс, пишем определение функции с extern, указываем, что за функцию мы хотим получить, а дальше все точно так же, как и в моей предыдущей статье — определяем параметры маршаллинга соответствующими атрибутами.
Следует обратить внимание на задание EntryPoint и CharSet. EntryPoint — это экспортируемое имя функции, которую мы хотим вызвать, а CharSet — используемая кодировка. Если вы немного разбираетесь в WinAPI, то знаете, что большинство функций имеют две версии — ANSI и Unicode. Первые жуют и выдают ANSI-строки, вторые, соответственно, Unicode. Различаются они суффиксом — так, функция MessageBox, работающая с Unicode-строками (которую, мы, кстати, и вызываем) в библиотеке имеет имя MessageBoxW. Однако умный маршаллер .NET знает об этой особенности WinAPI (еще бы ли, он не знал), а потому при обработке атрибута DllImport автоматически подставляет суффикс в зависимости от кодировки.
Однако, мы можем задать используемую кодировку и напрямую. Например, так:
Здесь мы работаем с ANSI-кодировкой. Однако, подняв окошко с messagebox-ом, который определен таким образом, вы увидите, что от передаваемых строк остались лишь первые символы. В чем же дело?
Дело в маршаллинге строк, а точнее в типа LPTStr. Остановимся на нем поподробнее.
Дело в том, что в C++, в отличии от C#, где царит его величество Unicode, существуют еще и ANSI-строки. В терминах C++, тип, используемый для ANSI-строки называется LPSTR (long pointer to string), для Unicode-строки — LPWSTR (long pointer to wide string), а LPTSTR — специальный тип, который определен следующим образом:
То есть в зависимости от того, как мы компилируемся, у нас подставляется тот или иной тип строки — либо Unicode, либо ANSI. Это было сделано для того, чтобы обеспечить совместимость на уровне кода с версиями ОС, которые не поддерживают Unicode. Теперь это конечно выглядит атавизмом, но Microsoft вынуждена тащить это решение для совместимости со старыми версиями.
А что есть такое LPTStr в .NET? MSDN услужливо подсказывает, что этот тип эквивалентен либо LPStr для Windows 98, либо LPWStr для Windows NT и старше. Таким образом, теоретически, определенная таким образом функция будет работать и в Windows 98. Однако, если вспомнить, что для Win98 доступен только фреймворк версии 1.1 — то можно выкинуть все то, что я написал, из головы, и всегда маршаллить функции как Unicode следующим образом:
Ну а если вдруг вам попадется хитрая библиотека, которая понимает только Ansi — спокойненько прописываете CharSet.Ansi и используете LPStr для строк. Вот и вся магия.
Кстати, EntryPoint можно и не задавать — по умолчанию он эквивалентен тому имени функции, которое вы задаете в коде, так что если они совпадают — то игнорируйте этот параметр.
Если функция принимает в качестве входного параметра перечисление (так, например, у MessageBox это параметр type), то можно определить соответствующий enum в C#, унаследовав его от нужного типа, и передать его в качестве параметра, например, так
Теперь мы можем использовать не числовые константы, а нормальный enum.
Для того, чтобы определить возвращаемую из функции строку — просто прописываете out или ref у параметра функции. Однако, здесь существует одна хитрость, связанная с тем, что для возврата строк многие функции требуют под себя буфер фиксированного размера. Так, например, функция GetWindowText, которая определена следующим образом:
При вызове функции из C++ делается что-то вроде этого:
Функция GetWindowText заполняет полученный буфер требуемыми данными, а с помощью nMaxCount следит за тем, чтобы не произошло переполнения буфера.
Если мы попробуем воспользоваться стандартной методикой:
То попытка вызова такой функции обернется неудачей. Дело в том, что строки в C# принципиально не поддаются изменению — при любой операции, вроде конкатенации, поиска подстроки и тому подобной создается новый объект в памяти. Тогда как же быть?
Выход — использовать StringBuilder.
И вызывать функцию следующим образом:
Кстати, если вы обратите внимание, то у объекта StringBuilder нет модификатора ref. Он и не нужен — дело в том, что маршаллер .NET понимает, что в таких ситуациях StringBuilder используется для возвращаемого значения.
Маршаллинг возвращаемого функцией значения делается аналогично тому, как я показывал в предыдущей статье — установкой атрибута [return: MarshalAs(...)].
При маршаллинге структур они обязательно должны быть выровненные (LayoutKind.Sequential).
Если в структуре имеется строковый буфер фиксированной длины, то маршаллеру следует особо на это указать, иначе структура «расползется» в памяти и вы получите на выходе черте что.
Рассмотрим оба этих правила на примере функции GetVersionEx. Полное определение ее на С++ таково:
Итак, мы видим, что в структуре содержится строковый буфер фиксированной длины. Соответственно, наши действия будут такими. Определяем структуру
И функцию.
Однако, кроме структур в C++ существует такая вещь, как объединение. Объединение — это когда один и тот же объем двоичных данных в памяти интерпретируется различным образом, в зависимости от каких-то внешних или внутренних факторов. Самым известным типом объединения является тип VARIANT.
Объединения хороши тем, что позволяют экономить память при сохранении огромной гибкости внутреннего представления.
В .NET тоже можно создать объединение. Выглядеть это будет следующим образом — мы задаем в атрибуте StructLayout параметр LayoutKind.Explicit, который говорит о том, что мы сами определяем, как в памяти размещается структура, и задаем смещение каждого поля в байтах от начала структуры.
Выглядит эта штука дико забавно.
Результат работы программы:
Казалось бы, проблема с объединениям решена, но не тут-то было. Попытавшись добавить в объединение ссылочный тип (например, строку)
Вы получите оглушительный «бум» по голове.
Could not load type 'TestStruct' from assembly 'TestPInvoke, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' because it contains an object field at offset 0 that is incorrectly aligned or overlapped by a non-object field.
Проблема в том, что ссылочные поля в памяти располагаются в виде указателей. Перекрывая (overlap) указатель типом-значением, мы получаем возможность манипуляции памятью. Например, так.
В этом случае на экран вывелось бы содержимое памяти по адресу 0xA000 до первого двойного нуля. Используя в качестве ссылочного типа класс, содержащий типы-значения (например, int), мы можем манипулировать памятью напрямую. Естественно, такого безобразия CLR допустить не может, а потому подобные действия явно запрещены.
Что делать? Начинать биться головой об стену, потому что единственный выход — это определять столько видов структур, сколько требуется для того, чтобы все поля-значения не перекрывались полями-ссылками, а для вызова функций использовать перегрузку с гаданием на кофейной гуще, какого типа результат вернет нам функция. Жуть. Страшная жуть. Поэтому по возможности — избегайте таких объединений, где поля-ссылки соседствуют с полями-значениями. А если не удается — штудируйте MSDN, данная тема слишком велика для нашего обзора.
Множество функций WinAPI требуют или возвращают HANDLE. Если говорить упрощенно, то HANDLE — это указатель на объект ядра ОС, а по факту — это четырехбайтное целое, которое остается неизменным с момента, как мы запрашиваем объект до момента, когда мы его освобождаем.
Для работы с HANDLE в C# предназначен класса SafeHandle. Однако, передача этого класса напрямую в функции через p/invoke невозможна. Придется изворачиваться.
Возьмем в качестве примера функцию SetSecutiryInfo (описание функции на MSDN). Пока не обращаем внимания на всякие левые вещи, сосредоточимся на главном.
Сценарий работы с SafeHandle демонстрируется на примере FileStream.
Сценарий следует выполнять с точностью до строчки кода. Работа с unmanaged-ресурсами требует точности и аккуратности, дабы не допустить memory leak-ов.
Здесь мы сталкиваемся с двумя неизвестными доселе параметрами атрибута DllImport. Первый — SetLastError при установки в true позволяет, в случае ошибки, сохранить ее код, после чего его можно получить при помощи Marshal.GetLastWin32Error. Если в описании функции сказано, что значение ошибки можно получить через GetLastError, то следует устанавливать этот флаг. Второй — CallingConvention устанавливает соглашение вызова функции — PASCAL (он же stdcall, он же WinApi) или cdecl. Вся разница — в том, кто очищает стек — вызывающий код или вызванная функция. Все WinAPI функции используют соглашение stdcall.
Однако, статья уже переросла все мыслимые пределы и я вынужден остановиться. За пределами нашего рассмотрения остались такие интересные вещи, как ручной парсинг структур из IntPtr и обратно, оборачивание возвращаемых из функции HANDLE, GlobalAlloc и буферы, сложные типы данных и ручной маршаллинг с помощью класса Marshall. Честно говоря, мне эта тема кажется уж слишком специфичной для аудитории хабра, поэтому я скорее всего рассматривать ее не буду. Данных методов вполне достаточно для большинства типовых задач, в которых требуется p/invoke, ну а если вам попалась задачка посложнее — тогда, что поделать, придется штудировать MSDN.
UPD: Из зала подсказывают, что на Win98 возможна установка .NET вплоть до версии 2.0, что сильно ничего не меняет, но тем не менее, стоит отметить этот факт.
Строки и enum-ы
Учиться будем на конкретных примерах. Естественно, как это принято, начнем мы с самого простого приложения — hello, world! Для вывода этого текста, мы заинтеропим функцию MessageBox из WinAPI, на примере которой подробно разберемся со строками и кодировкой.
Так, кто сказал MessageBox.Show? Мне не нравится ваша розовая кофточка, ваши сиськи и ваш микрофон, встала и ушла отсюда. Вернешься с умными советами, когда мы будем массивы структур маршаллить. Чтобы у остальных не было соблазнов — работать будем в рамках консольного проекта, не подключая Windows.Forms.
Итак, пример первый, тривиальный. Создаем статический класс, пишем определение функции с extern, указываем, что за функцию мы хотим получить, а дальше все точно так же, как и в моей предыдущей статье — определяем параметры маршаллинга соответствующими атрибутами.
public static class PInvoke
{
[DllImport("User32.dll", EntryPoint="MessageBox", CharSet=CharSet.Auto)]
public extern static int MsgBox(
[MarshalAs(UnmanagedType.I4)] int hwnd,
[MarshalAs(UnmanagedType.LPTStr)] string text,
[MarshalAs(UnmanagedType.LPTStr)] string caption,
[MarshalAs(UnmanagedType.U4)] uint type);
}
Следует обратить внимание на задание EntryPoint и CharSet. EntryPoint — это экспортируемое имя функции, которую мы хотим вызвать, а CharSet — используемая кодировка. Если вы немного разбираетесь в WinAPI, то знаете, что большинство функций имеют две версии — ANSI и Unicode. Первые жуют и выдают ANSI-строки, вторые, соответственно, Unicode. Различаются они суффиксом — так, функция MessageBox, работающая с Unicode-строками (которую, мы, кстати, и вызываем) в библиотеке имеет имя MessageBoxW. Однако умный маршаллер .NET знает об этой особенности WinAPI (еще бы ли, он не знал), а потому при обработке атрибута DllImport автоматически подставляет суффикс в зависимости от кодировки.
Однако, мы можем задать используемую кодировку и напрямую. Например, так:
[DllImport("User32.dll", EntryPoint="MessageBoxA", CharSet=CharSet.ANSI)]
Здесь мы работаем с ANSI-кодировкой. Однако, подняв окошко с messagebox-ом, который определен таким образом, вы увидите, что от передаваемых строк остались лишь первые символы. В чем же дело?
Дело в маршаллинге строк, а точнее в типа LPTStr. Остановимся на нем поподробнее.
Дело в том, что в C++, в отличии от C#, где царит его величество Unicode, существуют еще и ANSI-строки. В терминах C++, тип, используемый для ANSI-строки называется LPSTR (long pointer to string), для Unicode-строки — LPWSTR (long pointer to wide string), а LPTSTR — специальный тип, который определен следующим образом:
#ifdef UNICODE
typedef LPCWSTR LPCTSTR;
#else
typedef LPCSTR LPCTSTR;
#endif
То есть в зависимости от того, как мы компилируемся, у нас подставляется тот или иной тип строки — либо Unicode, либо ANSI. Это было сделано для того, чтобы обеспечить совместимость на уровне кода с версиями ОС, которые не поддерживают Unicode. Теперь это конечно выглядит атавизмом, но Microsoft вынуждена тащить это решение для совместимости со старыми версиями.
А что есть такое LPTStr в .NET? MSDN услужливо подсказывает, что этот тип эквивалентен либо LPStr для Windows 98, либо LPWStr для Windows NT и старше. Таким образом, теоретически, определенная таким образом функция будет работать и в Windows 98. Однако, если вспомнить, что для Win98 доступен только фреймворк версии 1.1 — то можно выкинуть все то, что я написал, из головы, и всегда маршаллить функции как Unicode следующим образом:
public static class PInvoke
{
[DllImport("User32.dll", EntryPoint="MessageBox", CharSet=CharSet.Unicode)]
public extern static int MsgBox(
[MarshalAs(UnmanagedType.I4)] int hwnd,
[MarshalAs(UnmanagedType.LPWStr)] string text,
[MarshalAs(UnmanagedType.LPWStr)] string caption,
[MarshalAs(UnmanagedType.U4)] uint type);
}
Ну а если вдруг вам попадется хитрая библиотека, которая понимает только Ansi — спокойненько прописываете CharSet.Ansi и используете LPStr для строк. Вот и вся магия.
Кстати, EntryPoint можно и не задавать — по умолчанию он эквивалентен тому имени функции, которое вы задаете в коде, так что если они совпадают — то игнорируйте этот параметр.
Если функция принимает в качестве входного параметра перечисление (так, например, у MessageBox это параметр type), то можно определить соответствующий enum в C#, унаследовав его от нужного типа, и передать его в качестве параметра, например, так
[Flags]
public enum MBoxStyle : uint
{
MB_OK = 0,
MB_OKCANCEL = 1,
MB_RETRYCANCEL = 2,
MB_YESNO = 4,
MB_YESNOCANCEL = 8,
MB_ICONEXCLAMATION = 16,
MB_ICONWARNING = 32,
MB_ICONINFORMATION = 64 ...
}
[DllImport("User32.dll", EntryPoint="MessageBox", CharSet=CharSet.Unicode)]
public extern static int MsgBox(
[MarshalAs(UnmanagedType.I4)] int hwnd,
[MarshalAs(UnmanagedType.LPWStr)] string text,
[MarshalAs(UnmanagedType.LPWStr)] string caption,
[MarshalAs(UnmanagedType.U4)] MBoxStyle type);
Теперь мы можем использовать не числовые константы, а нормальный enum.
Для того, чтобы определить возвращаемую из функции строку — просто прописываете out или ref у параметра функции. Однако, здесь существует одна хитрость, связанная с тем, что для возврата строк многие функции требуют под себя буфер фиксированного размера. Так, например, функция GetWindowText, которая определена следующим образом:
int GetWindowText(HWND hWnd, LPTSTR lpString, INT nMaxCount);
При вызове функции из C++ делается что-то вроде этого:
const int BUFF_SIZE = 200;
LPTSTR buff = new TCHAR[BUFF_SIZE];
GetWindowText(hwnd, buff, BUFF_SIZE);
Функция GetWindowText заполняет полученный буфер требуемыми данными, а с помощью nMaxCount следит за тем, чтобы не произошло переполнения буфера.
Если мы попробуем воспользоваться стандартной методикой:
[DllImport("User32.dll", EntryPoint = "GetWindowText", CharSet = CharSet.Unicode)]
public extern static void GetWindowText(int hWnd, [MarshalAs(UnmanagedType.LPWStr)] ref string lpString, int nMaxCount);
То попытка вызова такой функции обернется неудачей. Дело в том, что строки в C# принципиально не поддаются изменению — при любой операции, вроде конкатенации, поиска подстроки и тому подобной создается новый объект в памяти. Тогда как же быть?
Выход — использовать StringBuilder.
[DllImport("User32.dll", EntryPoint = "GetWindowText", CharSet = CharSet.Unicode)]
public extern static void GetWindowText(int hWnd, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpString, int nMaxCount);
И вызывать функцию следующим образом:
StringBuilder sb = new StringBuilder(256);
PInvoke.GetWindowText(handle, sb, sb.Capacity);
Кстати, если вы обратите внимание, то у объекта StringBuilder нет модификатора ref. Он и не нужен — дело в том, что маршаллер .NET понимает, что в таких ситуациях StringBuilder используется для возвращаемого значения.
Маршаллинг возвращаемого функцией значения делается аналогично тому, как я показывал в предыдущей статье — установкой атрибута [return: MarshalAs(...)].
Структуры и объединения.
При маршаллинге структур они обязательно должны быть выровненные (LayoutKind.Sequential).
Если в структуре имеется строковый буфер фиксированной длины, то маршаллеру следует особо на это указать, иначе структура «расползется» в памяти и вы получите на выходе черте что.
Рассмотрим оба этих правила на примере функции GetVersionEx. Полное определение ее на С++ таково:
typedef struct _OSVERSIONINFO
{
DWORD dwOSVersionInfoSize;
DWORD dwMajorVersion;
DWORD dwMinorVersion;
DWORD dwBuildNumber;
DWORD dwPlatformId;
TCHAR szCSDVersion[128];
} OSVERSIONINFO;
BOOL GetVersionEx(LPOSVERSIONINFO lpVersionInfo);
Итак, мы видим, что в структуре содержится строковый буфер фиксированной длины. Соответственно, наши действия будут такими. Определяем структуру
[StructLayout(LayoutKind.Sequential)]
public struct OSVERSIONINFO
{
[MarshalAs(UnmanagedType.I4)] public int dwOSVersionInfoSize;
[MarshalAs(UnmanagedType.I4)] public int dwMajorVersion;
[MarshalAs(UnmanagedType.I4)] public int dwMinorVersion;
[MarshalAs(UnmanagedType.I4)] public int dwBuildNumber;
[MarshalAs(UnmanagedType.I4)] public int dwPlatformId;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szCSDVersion; // Задаем буфер константного размера
}
И функцию.
[DllImport("kernel32", EntryPoint = "GetVersionEx", CharSet=CharSet.Unicode)]
public static extern bool GetVersionEx2(ref OSVERSIONINFO osvi);
Однако, кроме структур в C++ существует такая вещь, как объединение. Объединение — это когда один и тот же объем двоичных данных в памяти интерпретируется различным образом, в зависимости от каких-то внешних или внутренних факторов. Самым известным типом объединения является тип VARIANT.
Объединения хороши тем, что позволяют экономить память при сохранении огромной гибкости внутреннего представления.
В .NET тоже можно создать объединение. Выглядеть это будет следующим образом — мы задаем в атрибуте StructLayout параметр LayoutKind.Explicit, который говорит о том, что мы сами определяем, как в памяти размещается структура, и задаем смещение каждого поля в байтах от начала структуры.
[StructLayout(LayoutKind.Explicit)]
public struct TestStruct
{
[FieldOffset(0)]
public int a;
[FieldOffset(0)]
public float b;
}
Выглядит эта штука дико забавно.
TestStruct s = new TestStruct();
s.b = 5.2f;
Console.WriteLine(s.b);
Console.WriteLine(s.a);
s.a = 99;
Console.WriteLine(s.b);
Console.WriteLine(s.a);
Console.ReadLine();
Результат работы программы:
5,2
1084647014
1,387285E-43
99
Казалось бы, проблема с объединениям решена, но не тут-то было. Попытавшись добавить в объединение ссылочный тип (например, строку)
[StructLayout(LayoutKind.Explicit)]
public struct TestStruct
{
[FieldOffset(0)]
public int a;
[FieldOffset(0)]
public float b;
[FieldOffset(0)]
public string c;
}
Вы получите оглушительный «бум» по голове.
Could not load type 'TestStruct' from assembly 'TestPInvoke, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' because it contains an object field at offset 0 that is incorrectly aligned or overlapped by a non-object field.
Проблема в том, что ссылочные поля в памяти располагаются в виде указателей. Перекрывая (overlap) указатель типом-значением, мы получаем возможность манипуляции памятью. Например, так.
s.c = "";
s.a = 0xA000;
Console.WriteLine(s.c);
В этом случае на экран вывелось бы содержимое памяти по адресу 0xA000 до первого двойного нуля. Используя в качестве ссылочного типа класс, содержащий типы-значения (например, int), мы можем манипулировать памятью напрямую. Естественно, такого безобразия CLR допустить не может, а потому подобные действия явно запрещены.
Что делать? Начинать биться головой об стену, потому что единственный выход — это определять столько видов структур, сколько требуется для того, чтобы все поля-значения не перекрывались полями-ссылками, а для вызова функций использовать перегрузку с гаданием на кофейной гуще, какого типа результат вернет нам функция. Жуть. Страшная жуть. Поэтому по возможности — избегайте таких объединений, где поля-ссылки соседствуют с полями-значениями. А если не удается — штудируйте MSDN, данная тема слишком велика для нашего обзора.
Работа с HANDLE.
Множество функций WinAPI требуют или возвращают HANDLE. Если говорить упрощенно, то HANDLE — это указатель на объект ядра ОС, а по факту — это четырехбайтное целое, которое остается неизменным с момента, как мы запрашиваем объект до момента, когда мы его освобождаем.
Для работы с HANDLE в C# предназначен класса SafeHandle. Однако, передача этого класса напрямую в функции через p/invoke невозможна. Придется изворачиваться.
Возьмем в качестве примера функцию SetSecutiryInfo (описание функции на MSDN). Пока не обращаем внимания на всякие левые вещи, сосредоточимся на главном.
[DllImport("advapi32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)]
public static extern Int32 SetSecurityInfo
(
IntPtr handle,
SE_OBJECT_TYPE ObjectType,
SECURITY_INFORMATION SecurityInfo,
IntPtr psidOwner,
IntPtr psidGroup,
IntPtr pDacl,
IntPtr pSacl
);
Сценарий работы с SafeHandle демонстрируется на примере FileStream.
FileStream fs = new FileStream("c:\test.txt");
bool success = false;
// Добавляем ссылку на объект - это не позволит GC прибить его во время работы.
fs.SafeFileHandle.DangerousAddRef(ref success);
if (success)
{
// Получаем handle в IntPtr
IntPtr h = fs.SafeFileHandle.DangerousGetHandle();
// Вызываем native-метод
PInvoke.SetSecutiryInfo(h, <остальные параметры>);
// Теперь handle нам не нужен и его можно убивать - убираем лишнюю ссылку.
fs.SafeFileHandle.DangerousRelease();
}
else
{
// невалидный HANDLE, обработка ошибок.
}
Сценарий следует выполнять с точностью до строчки кода. Работа с unmanaged-ресурсами требует точности и аккуратности, дабы не допустить memory leak-ов.
Здесь мы сталкиваемся с двумя неизвестными доселе параметрами атрибута DllImport. Первый — SetLastError при установки в true позволяет, в случае ошибки, сохранить ее код, после чего его можно получить при помощи Marshal.GetLastWin32Error. Если в описании функции сказано, что значение ошибки можно получить через GetLastError, то следует устанавливать этот флаг. Второй — CallingConvention устанавливает соглашение вызова функции — PASCAL (он же stdcall, он же WinApi) или cdecl. Вся разница — в том, кто очищает стек — вызывающий код или вызванная функция. Все WinAPI функции используют соглашение stdcall.
Однако, статья уже переросла все мыслимые пределы и я вынужден остановиться. За пределами нашего рассмотрения остались такие интересные вещи, как ручной парсинг структур из IntPtr и обратно, оборачивание возвращаемых из функции HANDLE, GlobalAlloc и буферы, сложные типы данных и ручной маршаллинг с помощью класса Marshall. Честно говоря, мне эта тема кажется уж слишком специфичной для аудитории хабра, поэтому я скорее всего рассматривать ее не буду. Данных методов вполне достаточно для большинства типовых задач, в которых требуется p/invoke, ну а если вам попалась задачка посложнее — тогда, что поделать, придется штудировать MSDN.
UPD: Из зала подсказывают, что на Win98 возможна установка .NET вплоть до версии 2.0, что сильно ничего не меняет, но тем не менее, стоит отметить этот факт.