C# для системного программирования: когда высокий уровень встречается с низким железом
Когда слышишь словосочетание «системное программирование», в голове обычно всплывают образы бородатых гуру, пишущих на C или ассемблере, с терминалами, залитыми зелёным текстом. А C# — это же «для бизнес-приложений», «под Windows Forms» и «для тех, кто не хочет париться с указателями». Спойлер: это миф.
На самом деле C# — это швейцарский нож для системного программиста, который хочет писать быстро, безопасно, но при необходимости — лезть в самое пекло неуправляемой памяти. В этой статье мы разберем, как C# помогает решать реальные системные задачи: от работы с процессами до прямого доступа к памяти через WinAPI. И да, примеры кода будут — без них никуда.
Почему C# — не просто «бизнес-игрушка»?
Системное программирование на C# — это не оксюморон. Платформа .NET (особенно .NET 5+) даёт разработчику уникальную комбинацию: производительность, близкую к нативному коду, и удобство высокоуровневого языка. Вы можете писать драйверы, работать с процессами, выделять память вручную и при этом не сойти с ума от управления ресурсами.
Ключевые возможности, которые делают C# пригодным для системного программирования:
- Unsafe-код и указатели — да, в C# можно работать с адресами напрямую, как в C++.
- P/Invoke — вызов любых функций из нативных библиотек (WinAPI, libc, драйверы).
- Span
и Memory — безопасная работа с непрерывными блоками памяти без лишних аллокаций. - Native AOT — компиляция в нативный код без JIT и CLR.
- Interop с C/C++ — встраивание C# в существующие системные проекты.
Если вы думали, что C# — это только про веб и базы данных, приготовьтесь удивляться.
Unsafe-код: игра с огнём, но под контролем
Ключевое слово unsafe в C# — это как красная кнопка на ядерной ракете. Нажал — и ты можешь делать всё: разыменовывать указатели, работать с фиксированными объектами, выделять стековую память. Но и ответственность — абсолютная.
Допустим, нам нужно быстро скопировать массив байт, минуя проверки границ. В безопасном коде мы бы использовали Array.Copy, но если мы пишем системную утилиту для работы с драйвером, каждая микросекунда на счету.
using System;
unsafe class MemoryCopy
{
public static void FastCopy(byte[] source, byte[] dest, int length)
{
fixed (byte* pSrc = source, pDst = dest)
{
byte* ps = pSrc;
byte* pd = pDst;
for (int i = 0; i < length; i++)
*pd++ = *ps++;
}
}
}
Здесь мы используем fixed для «закрепления» управляемых массивов в памяти, чтобы сборщик мусора не сдвинул их во время копирования. Результат — копирование на скорости, близкой к memcpy на C. Конечно, для повседневных задач это избыточно, но для системного программирования на C# — must-have.
Когда unsafe оправдан?
- Работа с сетевыми пакетами на уровне сырых байт.
- Взаимодействие с аппаратным обеспечением через DMA.
- Реализация собственных аллокаторов памяти.
- Оптимизация критических участков кода (например, обработка видео с камеры).
P/Invoke и WinAPI: C# стучится в ядро
Настоящее системное программирование невозможно без вызова нативных функций. C# предоставляет для этого механизм P/Invoke (Platform Invocation Services). Хотите получить список процессов, управлять окнами или читать память другого процесса? Пожалуйста.
Рассмотрим классический пример — получение списка запущенных процессов через WinAPI-функцию CreateToolhelp32Snapshot.
using System;
using System.Runtime.InteropServices;
using System.Collections.Generic;
public class ProcessInfo
{
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
[DllImport("kernel32.dll")]
private static extern bool Process32First(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
[DllImport("kernel32.dll")]
private static extern bool Process32Next(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
[StructLayout(LayoutKind.Sequential)]
private struct PROCESSENTRY32
{
public uint dwSize;
public uint cntUsage;
public uint th32ProcessID;
public IntPtr th32DefaultHeapID;
public uint th32ModuleID;
public uint cntThreads;
public uint th32ParentProcessID;
public int pcPriClassBase;
public uint dwFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string szExeFile;
}
public static List GetProcessList()
{
var processes = new List();
uint TH32CS_SNAPPROCESS = 0x00000002;
IntPtr snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == IntPtr.Zero) return processes;
PROCESSENTRY32 procEntry = new PROCESSENTRY32();
procEntry.dwSize = (uint)Marshal.SizeOf(typeof(PROCESSENTRY32));
if (Process32First(snapshot, ref procEntry))
{
do
{
processes.Add(procEntry.szExeFile);
} while (Process32Next(snapshot, ref procEntry));
}
return processes;
}
}
Этот код — типичный пример C# системного программирования с WinAPI. Мы объявляем структуру PROCESSENTRY32 с точным размещением полей (StructLayout), импортируем функции и работаем с неуправляемым дескриптором. Красота в том, что C# берёт на себя маршалинг строк и структур, а мы получаем чистый системный функционал.
Советы по P/Invoke:
- Всегда проверяйте
SetLastError = trueи вызывайтеMarshal.GetLastWin32Error(). - Используйте
SafeHandleвместо сырыхIntPtr— это предотвратит утечки. - Для сложных структур используйте
Marshal.SizeOfиMarshal.PtrToStructure.
Работа с памятью: Span, Memory и аллокаторы
В системном программировании на C# нельзя игнорировать современные фичи .NET — Span и Memory. Они позволяют работать с любыми буферами (массивы, строки, неуправляемая память) без копирования и с минимальными накладными расходами.
Например, вы пишете