Данная справка рассказывает про подводные камни при работе с неуправляемыми .dll, которые можно встретить при работе с модулями OpenGL и OpenCL, входящеми в состав стандартных модулей языка PascalABC.NET.

Справки по соответствующим библиотекам:

  • OpenGL.dll
  • OpenCL.dll

(Далее "исходные библиотеки" относится сразу к обеим этим библиотекам)

Отдельных справок по модулям OpenGL и OpenCL (далее просто "н.у. модули", что значит "низко-уровневые") нет, потому что они сделаны по общим принципам .


Отношение к высоко-уровневым модулям

Н.у. модули созданы как промежуточная точка между исходными библиотеками и модулями OpenGLABC и OpenCLABC, на случай если вы хотите использовать OpenGL и/или OpenCL, но хотите написать свою высоко-уровневую оболочку. Возможно, ради опыта. Возможно, ради особых оптимизаций. И т.п.

Если вы будете использовать только модули OpenGLABC и OpenCLABC - данная справка вам не нужна, потому что они специально созданы так, чтоб отстранить программиста от всех сложностей работы с неуправляемыми .dll и предоставить привычный ООП интерфейс со всевозможными удобствами.


Багтрагер

Если:

  • В н.у. модулях или данной справке найдена ошибка или чего-либо не хватает;

  • Вы наткнулись на какую то особенностью работы с неуправляемыми .dll, которая тут не (или недо-) описана:

Пишите в issue.

Лучше прочитайте оффициальный документ от microsoft, если хотите точной и подробной информации.

Если в кратце:

Есть платформа .Net, объединяющая много языков программирования. В том числе C# и PascalABC.NET.

.exe и .dll созданные такими языками содержат код в особом виде, позволяющем легко подключать .dll (и теоретически .exe, но это плохо) созданные на 1 .Net языке к программе на любом другом .Net языке.
(в паскале это делается с помощью $reference)

Такие .exe и .dll называются управляемыми. .exe и .dll созданные на любом другом (то есть не .Net) языке называются неуправляемыми.

OpenCL.dll и OpenGL.dll созданы на- и для языков C/C++, поэтому являются неуправляемыми.

Большинство функций из н.у. модулей требует какой-либо чистки. В основном потому что при переводе с C++ тип int* может оказаться и указателем, и var-параметром, и массивом.

При автоматическом переводе кода с C++ на паскаль - создаются все возможные перегрузки. А те перегрузки, которые не подходят конкретным подпрограммам - надо убирать вручную. В этом и состоит чистка подпрограмм.

Все разом функции не почистишь, в одном только OpenGL их >3000. Но сделать это, всё же, надо. Если хотите помочь - можно писать и в issue, но лучше (и проще для вас, когда разберётесь) создать fork репозитория, сделать там нужные изменения и послать pull-request. Могу объяснить подробнее в vk или по sunserega2@gmail.com.


Чтоб чистить подпрограммы было проще - я написал несколько инструментов. Вся упаковка н.у. модулей находится тут. Откройте папку OpenCL или OpenGL. Они устроены одинаково, поэтому объяснять можно на любой из них.

Все файлы (кроме .exe, .pdb и .pcu, которые создаёт паскаль) в этих папках открываются любым текстовым редактором.


Fixers

Контроль содержимого модуля находится в папке Fixers. В этой папке есть следующие подпапки:

  • Funcs - контроль подпрограмм;
  • Enums - контроль записей-перечислений;
  • Structs - контроль просто записей (записей-контейнеров, если хотите).

Во всех папках находится файл ReadMe.md, объясняющий синтаксис.


MiscInput

MiscInput содержит другие данные, используемые при создании модулей, которые имеют разные значения для OpenCL и OpenGL.

Синтаксис у всех предельно прост. Не вижу смысла объяснять подробнее.


Log

Log содержит логи последней сборки соответствующего модуля.

Они в основном используются чтоб было проще увидеть на что именно повлияли ваши изменения и как (используя проверку изменений git'а).

Но файл FinalFuncOverloads.log так же особо полезен перед началом чистки, чтоб увидеть какие перегрузки уже есть.


Чтоб применить фиксеры и посмотреть на что они влияют - вызывайте Pack Most.pas.

Ну а чтоб полностью собрать модули - вызывайте PackAll.exe в корне репозитория.
(или .bat файлы там же, для сборки отдельных компонентов)

В кратце:

  • Н.у. модули представляют весь возможный функционал из своих соответствующих .dll;
  • Но при этом балансируют между максимальными удобством и низко-уровневостью.

Если нужна высоко-уровневость - используйте соответственно модули OpenGLABC и OpenCLABC .


Подробнее о структуре модулей:

  • Записи-имена
  • Записи-перечисления
  • Классы-контейнеры подпрограмм

Исключения из общих принципов:

  • Особые типы модуля OpenGL

В исходных библиотеках обращение ко всем объектам идёт по "именам" (их так же можно назвать дескрипторами или id этих объектов).

Имена объектов - это числа (в OpenGL обычно на 32 бита, в OpenCL - зависит от битности системы).

Чтоб в подпрограмму, принимающую имена объектов определённого типа нельзя было передать имя объекта неправильного типа - в н.у. модулях для каждого типа объектов описана подобная запись:

  gl_buffer = record
    public val: UInt32;
    public constructor(val: UInt32) := self.val := val;
    public static property Zero: gl_buffer read default(gl_buffer);
    public static property Size: integer read Marshal.SizeOf&<UInt32>;
    public function ToString: string; override := $'gl_buffer[{val}]';
  end;

Такой подход не замедляет готовую программу, но позволяет отловить некоторые ошибки на этапе компиляции.

Поле .val и конструктор публичны только на случай ошибки в перегрузках, то есть если подпрограмма принемает неправильный тип имени.

В обычной ситуации - вы будете взаимодействовать с именами только 3 способами:

  1. Объявление:

    var name: gl_buffer;
    var names: array of gl_buffer := new gl_buffer[5];
    
  2. Передача исходным библиотекам:

    gl.CreateBuffers(1, name);
    gl.CreateBuffers(names.Length, names);
    
  3. Использование статичного свойства .Zero:

    procedure MyProc(buff: gl_buffer);
    begin
      ...
    end;
    ...
    MyProc(gl_buffer.Zero);
    // То же самое, но с лишними скобками:
    MyProc(default(gl_buffer));
    

    У настоящих объектов имя никогда не будет нулевым.

    Но бывает не мало случаев когда исходные библиотеки могут принимать нулевое имя.
    К примеру, привязка шейдера с нулевым именем в OpenGL отменяет предыдущую привязку шейдера.

Многие параметры подпрограмм в исходных библиотеках принимают перечисления (enum'ы).

Перечисления, как и имена, это числа. Но в отличии от имён - перечисления принимают заданные заранее константные значения.

В качестве примера:

procedure gl.BeginQuery(target: QueryTarget; id: gl_query)

Параметр target принимает одно из значений, сгрупированных в записи QueryTarget:

  QueryTarget = record
    public val: UInt32;
    public constructor(val: UInt32) := self.val := val;
    
    private static _TRANSFORM_FEEDBACK_OVERFLOW           := new QueryTarget($82EC);
    private static _VERTICES_SUBMITTED                    := new QueryTarget($82EE);
    private static _PRIMITIVES_SUBMITTED                  := new QueryTarget($82EF);
    private static _VERTEX_SHADER_INVOCATIONS             := new QueryTarget($82F0);
    private static _TIME_ELAPSED                          := new QueryTarget($88BF);
    private static _SAMPLES_PASSED                        := new QueryTarget($8914);
    private static _ANY_SAMPLES_PASSED                    := new QueryTarget($8C2F);
    private static _PRIMITIVES_GENERATED                  := new QueryTarget($8C87);
    private static _TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN := new QueryTarget($8C88);
    private static _ANY_SAMPLES_PASSED_CONSERVATIVE       := new QueryTarget($8D6A);
    
    public static property TRANSFORM_FEEDBACK_OVERFLOW:           QueryTarget read _TRANSFORM_FEEDBACK_OVERFLOW;
    public static property VERTICES_SUBMITTED:                    QueryTarget read _VERTICES_SUBMITTED;
    public static property PRIMITIVES_SUBMITTED:                  QueryTarget read _PRIMITIVES_SUBMITTED;
    public static property VERTEX_SHADER_INVOCATIONS:             QueryTarget read _VERTEX_SHADER_INVOCATIONS;
    public static property TIME_ELAPSED:                          QueryTarget read _TIME_ELAPSED;
    public static property SAMPLES_PASSED:                        QueryTarget read _SAMPLES_PASSED;
    public static property ANY_SAMPLES_PASSED:                    QueryTarget read _ANY_SAMPLES_PASSED;
    public static property PRIMITIVES_GENERATED:                  QueryTarget read _PRIMITIVES_GENERATED;
    public static property TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN: QueryTarget read _TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN;
    public static property ANY_SAMPLES_PASSED_CONSERVATIVE:       QueryTarget read _ANY_SAMPLES_PASSED_CONSERVATIVE;
    
    ...
    
  end;

То есть вызов может выглядеть так:

var name: gl_query;
...
gl.BeginQuery(QueryTarget.VERTICES_SUBMITTED, name);

Чтоб увидеть доступные значения - достаточно написать QueryTarget и поставить точку, после этого анализатор кода покажет список доступных имён.


Так же бывают особые перечисления - битовые маски. Они могут принимать сразу несколько значений:

procedure gl.Clear(mask: ClearBufferMask);
gl.Clear(
  ClearBufferMask.COLOR_BUFFER_BIT + // Очистка поверхности рисования одним цветом
  ClearBufferMask.DEPTH_BUFFER_BIT   // Очистка буфера глубины - нужна при рисовании 3D
);

Все подпрограммы исходных библиотек можно разделить на подпрограммы ядра + множество мелких расширений.


Все подпрограммы ядра находятся в классе gl/cl для OpenGL/OpenCL соответственно.

То есть если вы хотите вызвать функцию clCreateBuffer надо писать:

cl.CreateBuffer(...);

По нажатию точки после cl вам так же покажет список всех функций в ядре OpenCL.


С OpenGL немного сложнее:

  1. В каждом контексте у каждой подпрограммы может быть свой адрес.
  2. Получение адреса подпрограммы выглядит по-разному на разных платформах.

В модуле OpenGL это реализовано так:

//ToDo Создание контекста и привязка его к текущему потоку

// Все адреса и экземпляры делегатов создаются в конструкторе
// В "<>" указывается имя платформы
// Можете написать "Pl" и нажать Ctrl+пробел, чтоб получить список платформ
var gl := new OpenGL.gl<PlWin>;

while true do
begin
  // Локальные переменные имеют бОльший приоритет чем классы
  // Поэтому тут "gl" это не класс, а экземпляр, который создали выше
  gl.Clear( ClearBufferMask.COLOR_BUFFER_BIT );
  
  //ToDo само рисование
  
end;

У каждого расширения есть свой класс. К примеру, так используется расширение GL_AMD_debug_output:

//ToDo Опять же, сначала контекст

var glDebugOutputAMD := new OpenGL.glDebugOutputAMD<PlWin>;
...
glDebugOutputAMD.DebugMessageEnableAMD(...);

В модуле OpenGL так же есть особые классы, wgl, gdi и glx:

  • gdi содержит несколько методов библиотеки gdi32.dll. На этой библиотеке основано всё в System.Windows.Forms.
    Подпрограммы включённые в класс gdi - это то, что может понадобиться вам, чтоб настроить и подготовить форму для рисования на ней с помощью OpenGL.

  • wgl содержит методы для подключения OpenGL к окну Windows.

  • glx содержит методы для подключения OpenGL к окну XWindow.

Оба этих класса работают как класс cl, то есть им не надо создавать экземпляр.


И последнее что вам надо знать об этих классах:
Если вы получаете NullReferenceException, при попытке вызова функции из инициализируемых классов, как gl:

  1. Скорее всего вы попытались вызвать подпрограмму, которой нет в реализации библиотеки на вашем компьютере.
    Проверьте версию библиотеки, или, если это подпрограмма из расширения - проверьте существование у вас этого расширения.

  2. Так же, возможно, проблема в н.у. модуле. Если вы уверены что п.1. вас не касается - напишите в issue.

Библиотека OpenGL.dll имеет несколько функций, принимающих вектора и матрицы (в основном для передачи значений в шейдеры).

В модуле OpenGL для каждого типа вектора и матрицы описана отдельная запись. Они особенны тем, что поддерживают некоторые математические операции, которые можно считать высокоуровневыми, а значит противоречущими основным принципам н.у. модулей.

Но реализовывать их все в качестве extensionmethod'ов было бы сложно, не красиво, а в случае статичных методов и свойств - ещё и невозможно.


ToDo сейчас все индексные свойства кроме .ColPtr (.val, .Row и .Col) убраны из релиза, потому что я не знаю как безопастно и эффективно их реализовывать. Постараюсь в ближайшее время придумать, что можно сделать.


Векторы

Все типы векторов можно описать разом как Vec[1,2,3,4][ b,ub, s,us, i,ui, i64,ui64, f,d ].

Каждый тип вектора берёт по 1 из значений, перечисленных в [], через запятую.

К примеру, есть типы Vec2d и Vec4ui64.

Число в первых скобках - значит кол-во измерений вектора.

Буква (буквы) в следующих скобках - значат тип координат вектора:

  • b=shortint, s=smallint, i=integer, i64=int64: Все 4 типа - целые числа, имеющие бит знака (±) и занимающие 1, 2, 4 и 8 байт соответственно;

  • Они же но с приставкой u - целые числа без знака. К примеру ui значит целое на 4 байта без знака, то есть longword (он же cardinal);

  • f=single и d=real - числа с плавающей запятой, на 4 и 8 байт соответственно.

Таким образом Vec2d хранит 2 числа типа real, а Vec4ui64 хранит 4 числа типа uint64.

Свойства

У векторов есть только индексное свойство val. Оно принимает индекс, считаемый от 0, и возвращает или задаёт значение вектора для соответствующего измерения.

К примеру:

var v: Vec4d;
v[0] := 123.456; // Записываем 123.456 по индексу 0
v[1].Println; // Читаем и выводим значение по индексу 1
v.val[2] := 1; // Можно так же писать и имя свойства

Но использование этого свойства не рекомендуется. Прямое обращение к полю всегда будет быстрее. То есть аналогично предыдущему коду:

var v: Vec4d;
v.val0 := 123.456;
v.val1.Println;
v.val2 := 1;

Используйте свойство val только тогда, когда индекс это НЕ константа.

Унарные операторы

var v0: Vec3d;
...
// v1 будет иметь ту же длину, но
// противоположное v0 направление
var v1 := -v0;
// А унарный + не делает ничего, он только
// для красоты. То есть v2 будет =v0
var v2 := +v0;

Умножение/деление на скаляр

var v1: Vec3d;
var v2: Vec3i;
...
// Выведет вектор, имеющий то же
// направление что v1, но в 2 раза длиннее
(v1*2).Println;

// Выведет вектор, имеющий то же
// направление что v1, но в 2 раза короче
(v1/2).Println;

// К целочисленным векторам вместо
// обычного деления надо применять div
(v2 div 2).Println;

Операции с 2 векторами

var v1, v2: Vec3d;
...
// Скалярное произведение векторов
(v1*v2).Println;

// Сумма векторов, складывает
// отдельно каждый элемент вектора
(v1+v2).Println;

// Разность векторов, тоже работает
// отдельно на каждый элемент вектора
(v1-v2).Println;

Чтоб применить 1 из этих операций к 2 векторам - их типы должны быть одинаковые.
Если это не так - 1 из них (или оба) надо явно преобразовать, так чтоб типы были одинаковые:

var v1: Vec3d;
var v2: Vec2i;
...
( v1 + Vec3d(v2) ).Println;

SqrLength

Метод .SqrLength возвращает квадрат длины (то есть модуля) вектора.
Возвращаемый тип .SqrLength совпадает с типом элементов вектора.
Каким образом находить корень полученного значения - дело программиста.

var v1: Vec3d;
...
v1.SqrLength.Println; // Квадрат длины
v1.SqrLength.Sqrt.Println; // Сама длина

Normalized

Метод .Normalized возвращает нормализированную (с длиной =1) версию вектора.
Так как эта операция требует деления (на длину вектора), она применима только к векторам с элементами типов single или real (f или d).

var v1 := new Vec3d(1,1,1);
v1.Println;
v1.SqrLength.Sqrt.Println;
var v2 := v1.Normalized;
v2.Println;
v2.SqrLength.Sqrt.Println; // Обязательно будет 1

Cross

Статичные методы .Cross[CW,CCW] возвращают векторное произведение двух 3-х мерных векторов ("Cross product", не путать со скалярным произведением).
Векторное произведение - это вектор, перпендикулярный обоим входным векторам и имеющий длину, равную площади параллелограмма, образованного входными векторами.

Не работает для векторов с элементами-беззнаковыми_целыми, потому что даёт переполнение на практически любых входных значениях. Если найдёте нормальное применение - напишите в issue.

В математике произведение векторов может вернуть один из двух противоположных друг-другу векторов, в зависимости от ориентации системы координат. В модуле OpenGL это решено следующим образом:

  • Vec3d.CrossCW(new Vec3d(1,0,0), new Vec3d(0,1,0)) = new Vec3d(0,0,1).

  • Vec3d.CrossCCW(a,b) = -Vec3d.CrossCW(a,b);

Ввод с клавиатуры

Статичные методы Read и Readln создают новый вектор из элементов, прочитанных из стандартного ввода:

// Прочитать 2 числа из ввода
Vec2d.Read('Введите 2 координаты:').Println;

// Прочитать 2 числа из ввода
// и затем пропустить всё до конца строки
Vec2d.Readln.Println;

Превращение в строку

var v1: Vec4d;
...
v1.Println; // Вывод вектора
// s присвоит ту же строку, что выводит .Println
var s := v1.ToString;

Методы .ToString и .Println должны быть использованы только для чего то вроде дебага или красивого вывода, потому что операции со строками это в целом медленно.


Матрицы

Все типы матриц можно описать разом как Mtr[2,3,4]x[2,3,4][f,d].

У каждой квадратной матрицы есть короткий синоним.
К примеру вместо Mtr3x3d можно писать Mtr3d.

Так же стоит заметить - конструктор матрицы принимает элементы по строкам, но в самой матрице элементы хранятся в транспонированном виде.

Это потому, что в OpenGL.dll в шейдерах матрицы хранятся по столбцам.
Но если создавать матрицу конструктором - элементы удобнее передавать по строкам, вот так:

var m := new Mtr3d(
  1,2,3, // (1;2;3) станет нулевой строкой матрицы
  4,5,6,
  7,8,9
);

Свойства

Как и у векторов, у матриц есть свойство val:

var m: Mtr4d;
m[0,0] := 123.456;
m[1,2].Println;
m.val[3,1] := 1;

И как и у векторов - val всегда медленнее прямого обращения к полям:

var m: Mtr4d;
m.val00 := 123.456;
m.val12.Println;
m.val31 := 1;

Но у матриц так же есть свойства для столбцов и строк:

var m: Mtr3d;
...
m.Row0.Println; // Вывод нулевой строчки в виде вектора
m.Row1 := new Vec3d(1,2,3);
m.Col2.Println;

И в качестве аналога val - строку и стобец тоже можно получать по динамическому индексу (но, опять же, это медленнее):

var m: Mtr3d;
...
m.Row[0].Println;
m.Row[1] := new Vec3d(1,2,3);
m.Col[2].Println;

Для столбцов так же есть особые свойства, возвращающие не столбец, а его адрес в памяти:

var m: Mtr3d;
...
var ptr1 := m.ColPtr0;
var ptr2 := m.ColPtr[3];

Использовать это свойство не всегда безопастно.

Оно должно быть использовано только для записей, хранящихся на стеке или в неуправляемой памяти.

Для более безопастной альтернативы - можно использовать методы .UseColPtr*.

Identity

Это тоже свойство, но статичное и применение совершенно другое:

Identity создаёт новую единичную матрицу. То есть матрицу, у которой главная диагональ заполнена 1, а всё останое заполнено 0.

Mtr3d.Identity.Println;
// Работает и для не_квадратных матриц
Mtr2x3d.Identity.Println;

UseColPtr*

Методы .UseColPtr* принимают подпрограмму, принимающую адрес определённого столбца в виде var-параметра.

В отличии от свойств .ColPtr*, методы .UseColPtr* безопастны для матриц, хранящихся в экземплярах классов и статичных полях:

uses OpenGL;

procedure p1(var v: Vec3d);
begin
  Writeln(v);
end;

function f1(var v: Vec3d): string :=
v.ToString;

begin
  var o := new class(
    m := Mtr3d.Identity
  );
  o.m.UseColPtr0(p1);
  o.m.UseColPtr1(f1).Println;
end.

Scale

Статичный метод .Scale возвращает матрицу, при умножении на которую вектор маштабируется в k раз.

var m := Mtr3d.Scale(2);
var v := new Vec3d(1,2,3);
(m*v).Println; // (2;4;6)

Translate

Статичный метод .Translate возвращает матрицу, при умножении на которую к вектору добавляется заданное значение.

var m := Mtr4d.Translate(1,2,3);

// Последний элемент должен быть 1,
// чтобы матрица из .Translate правильно работала
var v := new Vec4d(0,0,0,1);

(m*v).Println; // (1;2;3)

Так же есть статичный метод .TraslateTransposed. Он возвращает ту же матрицу что .Translate, но в транспонированном виде.

2D вращение

Группа статичных методов .Rotate[XY,YZ,ZX][cw,ccw] возвращает матрицу вращения в определённой плоскости.

Первые скобки определяют плоскость.
(Но у 2x2 матриц есть только XY вариант)

Вторые скобки определяют направление вращения:

  • cw (clock wise): по часовой стрелке
  • ccw (counter clock wise): против часовой стрелки

3D вращение

Группа статичных методов .Rotate3D[cw,ccw] возвращает матрицу вращения вокруг нормализованного 3-х мерного вектора.
(разумеется, не существует для матриц 2x2,2x3 и 3x2)

Det

Метод .Det возвращает определитель матрицы. Существует только для квадратных матриц.

Transpose

Метод .Transpose возвращает транспонированную версию матрицы:

var m := new Mtr2x3d(
  1,2,3,
  4,5,6
);
m.Transpose.Println; // Выводит:
// 1 4
// 2 5
// 3 6

Умножение матрицы и вектора

m*v - это обычное математическое умножение матрицы m и вектора v, возвращающее результат после применения преобразования из m к v.

Но так же как в шейдерах - поддерживается и обратная запись:
v*m это то же самое что m.Transpose*v.

Умножение 2 матриц

m1*m2 - это математическое умножение матриц m1 и m2.

Ввод с клавиатуры

Статичные методы Read[,ln][Rows,Cols] создают новую матрицу из элементов, прочитанных из стандартного ввода:

// Прочитать 3*4=12 элементов из ввода
// и сохранить в новую матрицу по строкам
Mtr3x4d.ReadRows('Введите 12 элементов матрицы:').Println;

// Прочитать 4 элемета из ввода, переходя на
// следущую строку ввода после чтения каждого столбца
Mtr2d.ReadlnCols(
  'Введите столбцы матрицы...',
  col -> $'Столбец #{col}:'
).Println;

Превращение в строку

Как и у векторов - матрицы можно выводить и превращать в строку

var m: Mtr4d;
...
m.Println; // Вывод матрицы
// s присвоит ту же строку, что выводит .Println
var s := m.ToString;

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

Обычно это не проблема для .Println, потому что и консоль, и окно вывода в IDE имеют моноширный шрифт и поддерживают юникод.

Но если выводить на форму, то придётся специально поставить моноширный шрифт.
А если выводить в файл, надо выбрать кодировку файла - юникод (UTF).

Большинство проблем при использовании неуправляемых .dll вытекают из следующих 2 различий:

  1. В .Net строки и массивы это стандартные типы, доступные для всех .Net языков.

    А в C++ строки и массивы не только описаны не так же как в .Net . У них так же есть множество разных стандартов. Благо, исходные библиотеки придерживаются 1 общего стандарта.

  2. В управляемом коде оперативная память (дальше просто память) обычно управляется сборщиком мусора. Поэтому если создать массив - когда он стал не нужен о нём можно просто забыть.

    А в C++ память управляется только программистом, поэтому неуправляемую память нужно всегда освобождать после использования.
    Забытая и не_освобождённая неуправляемая память называется утечкой памяти. И это 1 из самых сложно-ловимых багов, потому что у него нет явно видимых симптомов, вроде вызова ошибки, пока память окончательно не закончится.

В .Net так же можно выделять и освобождать неуправляемую память, статичными методами класса Marshal: .AllocHGlobal и .FreeHGlobal соответственно. Обычно это надо для п.1., для преобразований между управляемыми и неуправляемыми типами.

Ещё одно отличие неуправляемой памяти - она не очищается нулями при выделении, а значит содержит мусорные данные. И память GPU (к примеру, содержимое буферов) тоже.

Полное имя класса Marshal это System.Runtime.InteropServices.Marshal. Чтоб не писать его целиком - можно написать в начале файла uses System.Runtime.InteropServices, и дальше писать только Marshal.


Стоит так же заметить, что в н.у. модулях все подпрограммы, напрямую вызывающие подпрограммы из неуправляемых .dll принимают параметрами как неуправляемые, так и управляемые типы массивов и строк, по мере необходимости.

Если ваша необходимость не удовлетворена (то есть не хватает перегрузки с определённым типом) - это особо хороший повод написать в issue.


Страницы:

  • Тесты эффектов сборщика мусора
  • var-параметры
  • Массивы
  • Строки
  • Делегаты

Кроме удаления неиспользуемых экземпляров классов, сборщик мусора так же может произвольно перемещать используемые объекты, более плотно упаковая их в памяти.

И он прекрасно справляется с тем, чтоб сделать эти перемещения незаметными, в обычных ситуациях. Но как только речь находит об указателях и неуправляемом коде - начинаются проблемы. Чтоб избежать их, надо очень хорошо понимать как работает сборщик мусора.

uses System;
uses System.Runtime.InteropServices;

type
  punch_gc_callback = procedure(ptr: pointer);
  
function get_addr(a: array of integer) := '$'+Marshal.UnsafeAddrOfPinnedArrayElement(a,0).ToString('X');

function ptr_adr<T>(var a: T) := new IntPtr(@a);

function copy_arr(a: IntPtr; punch_gc: punch_gc_callback): IntPtr;
external 'Dll1.dll';
function copy_arr(a: ^integer; punch_gc: punch_gc_callback): IntPtr;
external 'Dll1.dll';

function copy_arr(var a: integer; punch_gc: punch_gc_callback): IntPtr;
external 'Dll1.dll';
function copy_arr(var a: byte; punch_gc: punch_gc_callback): IntPtr;
external 'Dll1.dll';

function copy_arr([MarshalAs(UnmanagedType.LPArray)] a: array of integer; punch_gc: punch_gc_callback): IntPtr;
external 'Dll1.dll';

function copy_arr_recall1(var a: integer; punch_gc: punch_gc_callback): IntPtr :=
copy_arr(@a, punch_gc);

function copy_arr_recall2(a: array of integer; punch_gc: punch_gc_callback): IntPtr :=
copy_arr(a, punch_gc);

function copy_arr_recall3(var a: integer; punch_gc: punch_gc_callback): IntPtr :=
copy_arr(a, punch_gc);

function copy_arr_recall4_helper(a: ^integer; punch_gc: punch_gc_callback): IntPtr;
begin
  punch_gc(a);
  Result := copy_arr(a^, ptr->begin end); // второй раз вызывать punch_gc и вывод - ни к чему, всё ломается уже на предыдущей строчке
end;
function copy_arr_recall4(var a: integer; punch_gc: punch_gc_callback): IntPtr :=
copy_arr_recall4_helper(@a, punch_gc);

function copy_arr_recall5(var a: integer; punch_gc: punch_gc_callback): IntPtr :=
copy_arr(PByte(pointer(@a))^, punch_gc);

function get_int(punch_gc: punch_gc_callback; var a: integer): integer;
begin
  punch_gc(@a);
  Result := 4;
end;
function copy_arr_recall6(var a: integer; punch_gc: punch_gc_callback): IntPtr :=
copy_arr(PByte(pointer(IntPtr(pointer(@a))+get_int(punch_gc, a)))^, ptr->begin end);

// У меня это вызывает смещение массива в памяти, но только при первом вызове
// Writeln тоже вызывает этот метод, поэтому придётся обходиться Console.WriteLine
// Вообще это ужастный костыль, но я не знаю ничего лучше
procedure punch_gc := System.Diagnostics.Debug.WriteLine('');

begin
  var a := Arr(1,2,3,4,5,6);
  var b := Arr(1,2,3,4,5);
  Console.WriteLine('begin');
  Console.WriteLine(get_addr(a));
  Console.WriteLine(get_addr(b));
  
  // punch_gc работает только 1 раз, эти строчки только чтоб протестировать, работает ли он у вас вообще
//  punch_gc;
//  Console.WriteLine('after first gc');
//  Console.WriteLine(get_addr(a));
//  Console.WriteLine(get_addr(b));
  
  {$region заголовки вызова copy_arr}
  
  // безопастно
//  var ptr := copy_arr(a,              // передавать как массив безопастно
//  var ptr := copy_arr(a[0],           // передавать элемент массива var-параметром безопастно
//  var ptr := copy_arr(a[1],           // и это касается не только элемента [0]
//  var ptr := copy_arr_recall2(a,      // безопастно, потому что с точки зрения copy_arr_recall2 ситуация та же что "copy_arr(a,"
//  var ptr := copy_arr_recall3(a[0],   // и var-параметры тоже безопастны через промежуточные подпрограммы
//  var ptr := copy_arr_recall5(a[0],   // тут указатели не попадают в готовый .exe, они только чтоб успокоить компилятор, поэтому безопастно
  
  // НЕ безопастно
//  var ptr := copy_arr(Marshal.UnsafeAddrOfPinnedArrayElement(a,0), ptr-> // GC не следит за содержимым IntPtr
//  var ptr := copy_arr(ptr_adr(a[0]),  // и за другими формами указателей тоже
//  var ptr := copy_arr_recall1(a[0],   // проблема не в передаче адреса возвращаемым значением
//  var ptr := copy_arr_recall4(a[0],   // кроме того, проблема вообще не в неуправляемом коде, в управляемом тоже воспроизводится
//  var ptr := copy_arr_recall6(a[0],   // в отличии от recall5 - тут указатели попадают в готовый .exe, поэтому небезопастно
  
  {$endregion заголовки вызова copy_arr}
  
  var ptr := copy_arr(a,
  ptr->
  begin
    Console.WriteLine('before gc');
    Console.WriteLine(get_addr(a));
    Console.WriteLine('$'+IntPtr(ptr).ToString('X'));
    Console.WriteLine(get_addr(b));
    
    // "b" в любом случае перемещается при punch_gc, его ничего не держит. Таким образом оно показывает что punch_gc успешно сработал
    // Но главное тут - если "ptr" и "a" окажутся разным, значит неуправляемый код потерял настоящий адрес "a" при перемещении
    // Тесты приведённые тут показывают так же что GC вообще не перемещает "a" в любом безопасном сценарии. И никогда не меняет "ptr"
    punch_gc;
    Console.WriteLine('after gc');
    Console.WriteLine(get_addr(a));
    Console.WriteLine('$'+IntPtr(ptr).ToString('X'));
    Console.WriteLine(get_addr(b));
  end);
  
  Console.WriteLine('end');
  Console.WriteLine(get_addr(a));
  Console.WriteLine(get_addr(b));
  
//  punch_gc;
//  Console.WriteLine('after last gc');
//  Console.WriteLine(get_addr(a));
//  Console.WriteLine(get_addr(b));
  
  // Показывает эффекты НЕ безопастного вызова
  // Точнее если неуправляемый код потеряет адрес массива,
  // то тут будет мусор (или ошибка доступа, но её я ни разу не получил)
  var res := new byte[20];
  Marshal.Copy(ptr,res,0,20);
  res.Println;
end.

Dll1.dll должна быть неуправляемой библиотекой, содержащей следующую функцию (это C++):

extern "C" __declspec(dllexport) BYTE* copy_arr(int* a, void (*punch_gc)(void*))
{
    BYTE* res = new BYTE[20]; // выделяем 20 байт неуправляемой памяти
    punch_gc(a); // вызываем ту подпрограмму, чей адрес сюда передали
    memcpy(res, a, 20); // копируем 20 байт из "a" в "res"
    return res; // плохо что неуправляемая память не освобождается, но в этом тесте не важно
}

Подробнее о параметрах:

  1. a принимает указатель на integer, что в C++ так же может являеться массивом с элементами типа integer;

  2. punch_gc принемает адрес подпрограммы, принемающей void* (безтиповый указатель) и возвращающей void (ничего не возвращающей, то есть это процедура);

  3. Ну и возвращаемое значение - BYTE*. Так же как a, вообще указатель, но в данном случае массив.

Пожалуйста, попробуйте поэксперементировать с этим кодом сами. И если найдёте что то интересное - обязательно напишите в issue. В этом деле много тестов не бывает.

В кратце:

Вся безопастность зависит только от объявления подпрограммы. Если подпрограмма принимает:

  • Массив или var-параметр:
    Пока вызов неуправляемой подпрограммы не завершится - сборщик мусора НЕ будет передвигать объект в памяти.

  • Указатель в любом виде (типизированный, безтиповый или даже обёрнутый в запись вроде IntPtr):
    Передавать адрес содержимого класса НЕ безопастно.

  • Любой размерный тип (то есть запись):
    Сборщик мусора никак не управляет записями, при передаче в подпрограмму их значение копируется. Поэтому это всегда безопастно.

В .Net var-параметры реализованы через указатели. То есть эти 2 кода:

procedure p1(var i: integer);
begin
  i.Println;
  i := 5;
end;

begin
  var i := 3;
  p1(i);
  i.Println;
end.
procedure p1(i: ^integer);
begin
  i^.Println;
  i^ := 5;
end;

begin
  var i := 3;
  p1(@i);
  i.Println;
end.

Генерируют практически одинаковые .exe .

Отличие состоит в том, что передавать содержимое класса (к примеру массива) var-параметром безопастно.

В то же время если передавать указатель на содержимое класса - сборщик мусора может в любой момент переместить память, ломая указатель.

Статичные поля так же могут быть перемещены, как и содержимое экземпляров, даже если они имеют размерный тип.
И глобальные переменные это, на самом деле, тоже статичные поля.

А захваченные лямбдой локальные переменные превращаются в поля специального анонимного класса, поэтому, опять же, могут быть перемещены даже если у них размерный тип.

В .Net массивы хранят не только содержимое, но и данные о своём размере.

А в C++ вместо обычных массивов используется безформенная область памяти.
При её выделении - в переменную записывается указатель [0] элемента. А о том чтоб сохранить данные о размере этой области - должен позаботится программист.
(вообще обычно в C++ используют обёртки, хранящие длину так же как .Net массивы. Но OpenGL.dll и OpenCL.dll это не касается)


Если вы видели старые коды с использованием OpenGL из какого то из паскалей - наверняка видели что то такое:

glИмяПодпрограммы(@a[0]);

Но в PascalABC.Net так делать нельзя! Получение адреса элемента массива моментально создаёт утечку памяти, потому что компилятор, на всякий случай, вставляет полную блокировку массива в памяти, используя GCHandle с GCHandleType.Pinned.

Такая блокировка нужна, потому что иначе полученный указатель может в любой момент стать устаревшим.

Обычно GCHandle освобождают методом .Free. Но если позволить компилятору использовать GCHandle - освобождение никогда не произойдёт, потому что компилятор не знает когда указатель станет не нужен.


Из очевидных вариантов - использовать GCHandle самостоятельно. Он создаётся статичным методом GCHandle.Alloc. Далее, адрес можно получить методом GCHandle.AddrOfPinnedObject.

Но GCHandle довольно ограничен. К примеру, он может заблокировать array of char, но не array of r, для r - запись, содержащая поле типа char. И то же самое с DateTime и ещё несколькими стандартными типами, которые GCHandle считает "опасными".


Как видно в тесте на странице выше - массив можно заблокировать в памяти без GCHandle, если передавать его параметром-массивом или var-параметром.

И, в отличии от GCHandle, это будет работать с массивами с любым размерным типом элементов.

К примеру, если имеем процедуру p1 из неуправляемой .dll, принимающую массив из двух чисел типа integer:

var a := new integer[5](...);
p1(a); // передача массива целиком
p1(a[3]); // передача [3] элемента var-параметром

Из первого вызова p1 возьмёт только элементы a[0] и a[1], потому что p1 по условию требует только два элемента.

Из второго вызова p1 возьмёт a[3] и a[4], потому что в C++ нет разницы между указателем на один элемент и указателем на начало массива.


Обычнно эти два способа передать массив в неуправляемый код - всё что вам понадобится.

Но, допустим, вы хотите написать подпрограмму для создания OpenGL буфера из массива векторов. Можно сделать перегрузку для каждого типа вектора, но тогда получится очень много дублей кода. Этого довольно просто избежать, используя шаблоны:

// это не настоящая подпрограмма, а только пример
procedure FillBuffer(var data: byte);
external 'some.dll';

// external подпрограммы не могут быть шаблонными, поэтому нужна ещё одна промежуточная перегрузка
// "where T: record;" делает так, что FillBuffer будет можно вызвать только для размерных типов T
procedure FillBuffer<T>(var data: T); where T: record;
begin
  // Компилятор развернёт это в "FillBuffer(data)"
  // То есть никакие преобразования в .exe не попадут
  // Но указатели всё равно нужны, чтоб компилятор не ругался на несовместимость типов
  FillBuffer(PByte(pointer(@data))^);
end;

procedure FillBuffer<T>(data: array of T); where T: record;
begin
  // В C++ нет разницы между массивом и адресом начала его содержимого
  // Поэтому можно передавать массив в виде [0] элемента-var-параметра.
  FillBuffer(data[0]);
end;

Но это для одномерных массивов. А что насчёт многомерных?

Сделать перегрузку для заданного кол-ва измерений не сложно:

procedure FillBuffer<T>(data: array[,] of T); where T: record;
begin
  // Многомерные массивы расположены в памяти как одномерные,
  // Но обращение к элементам идёт по нескольким индексам
  // Элемент [0,0,...] в любом случае будет в самом начале,
  // Поэтому кол-во измерений не влияет на сложность кода
  // Элемент [x,y] будет расположен на позиции "x*h+y", где "h" - кол-во допустимых значений "y"
  FillBuffer(data[0,0]);
end;

Но, опять же, получается так, что для каждой размерности - приходится добавлять перегрузку.

И, к сожалению, в данном случае я не знаю красивого способа обхода.
Лучшее что я могу придумать - создать Dictionary<integer, Action<System.Array>>, где ключи - размерности массивов, а значения - делегаты, работающие с соответствующей размерностью.
Когда происходит вызов с массивом определённой размерности - создавать новый делегат в виде динамичного метода, с помощью System.Reflection.Emit, если его ещё нет в словаре.

Как и массивы - неуправляемые строки это указатель на первый символ строки.
Но со строками ещё сложнее - исходные библиотеки хранят строки в кодировке ANSI (1 байт на символ).
А управляемые строки - хранят символы в кодировке Unicode (2 байта на символ).

Кроме того, у неуправляемых строк принятно добавлять в конце строки символ #0. Это позволяет вообще не хранить длину строки. Вместо этого конец строки считается там, где первый раз встретится символ #0.


Благо, для перевода между этими типами уже есть Marshal.StringToHGlobalAnsi и Marshal.PtrToStringAnsi.

Но будьте осторожны - Marshal.StringToHGlobalAnsi выделяет неуправляемую память для хранения неуправляемого варианта строки.
Когда неуправляемая память стала не нужна - её надо обязательно удалить методом Marshal.FreeHGlobal, иначе получите утечку памяти.


В отличии от массивов - пытаться передавать строки в виде символа-var-параметра безсмысленно, из за разницы форматов содержимого.

Более того, передача символа строки var-параметром, в отличии от массивов, всё равно вызывает копирование строки, на всякий случай, потому что в .Net строки неизменяемы, а компилятор не может знать, будет неуправляемый код только читать, или ещё и перезаписывать строку.

Единственный способ не выполнять лишних копирований - написать свою обёртку неуправляемых строк. Обычно оно того не стоит.

Но если вы, к примеру, создаёте много OpenGL шейдеров из исходников - можно перед компиляцией программы:

  1. Прочитать все текстовые файлы исходников шейдеров;
  2. Использовать Marshal.StringToHGlobalAnsi чтоб получить неуправляемые строки;
  3. Пересохранить их в бинарном виде (то есть как массив байт содержимого неуправляемой строки);
  4. Полученные бинарные файлы подключать в виде $resource, читать как массив байт и его уже передавать неуправляемому коду вместо строки.

Делегат - это адрес подпрограммы:

procedure p1(i: integer);
begin
  Writeln(i);
end;

begin
  
  var d: System.Delegate := p1; // это не вызов, а получение адреса p1
  d.DynamicInvoke(5); // вообще .DynamicInvoke это очень медленно
  
  var p: integer->();
  // Такое же объявление как на предыдущей строчке, но в другом стиле
//  var p: Action<integer>;
  // И ещё один стиль. Этот особенный, потому что
  // он неявно создаёт новый тип делегата
//  var p: procedure(i: integer);
  p := p1;
  
  // Типизированные делегаты можно вызывать быстрее и проще,
  // так же как обычные подпрограммы
  p(5);
  
end.

Так же как обычные подпрограммы - подпрограммы из неуправляемых .dll могут принимать делегаты параметром.
Далее всё будет рассматриваться на примере cl.SetEventCallback из модуля OpenCL, потому что с ним есть особые проблемы.

Объявление cl.SetEventCallback:

    static function SetEventCallback(&event: cl_event; command_exec_callback_type: CommandExecutionStatus; pfn_notify: EventCallback; user_data: IntPtr): ErrorCode;

Объявление EventCallback:

  EventCallback = procedure(&event: cl_event; event_command_status: CommandExecutionStatus; user_data: IntPtr);

Рассмотрим следующий пример:

uses System;
uses OpenCL;

begin
  
  var cb: EventCallback := (ev,st,data)->
  begin
    Writeln($'{ev} перешёл в состояние {st}');
  end;
  
  var ev: cl_event; //ToDo := ...
  
  cl.SetEventCallback(ev, CommandExecutionStatus.COMPLETE, cb,IntPtr.Zero).RaiseIfError;
end.

Этот код может время от времени вылетать, потому что:

  1. cl.SetEventCallback вызывает свой коллбек тогда, когда посчитает нужным (но обычно после того как вызов cl.SetEventCallback завершился);

  2. Делегаты - это классы.

  3. Сборщик мусора распоряжается памятью классов и удаляет их, тоже когда посчитает нужным.

Раз после вызова cl.SetEventCallback делегат cb больше нигде не используется - сборщик мусора может в любой момент решить удалить его. Но, опять же, это редко случается сразу после вызова cl.SetEventCallback, поэтому ошибки связанные с этим удалением могут быть плавающие.

Если сборщик мусора удалит делегат, а затем .dll попытается его вызвать - это приведёт или к ошибке доступа, или к моментальному беззвучному вылету.

Чтоб запретить сборщику мусора удалять делегать - нужно создать GCHandle, привязанный к нему.
Но в отличии от массивов - GCHandleType.Pinned не нужно, потому что сборщик мусора не может перемещать адрес исполняемого кода (а он единственное что передаётся в .dll). Это потому, что этот адрес хранится в виде указателя на неуправляемый код.

uses System.Runtime.InteropServices;
uses System;
uses OpenCL;

begin
  
  var gc_hnd: GCHandle;
  var cb: EventCallback := (ev,st,data)->
  begin
    
    Writeln($'{ev} перешёл в состояние {st}');
    
    // В данном случае освобождать GCHandle станет можно тогда, когда делегат 1 раз выполнится,
    // А значит очень удобно поставить освобождение в конец самого делегата
    gc_hnd.Free;
  end;
  gc_hnd := GCHandle.Alloc(cb);
  
  var ev: cl_event; //ToDo := ...
  
  cl.SetEventCallback(ev, CommandExecutionStatus.COMPLETE, cb,IntPtr.Zero).RaiseIfError;
end.