Как правильно получить доступ к отображенной памяти без неопределенного поведения в С++
Я пытался выяснить, как получить доступ к отображенному буферу из C++ 17, не вызывая неопределенное поведение. В этом примере я буду использовать буфер, возвращаемый Vulkan vkMapMemory
.
Итак, согласно N4659 (окончательный рабочий проект C++ 17), раздел [intro.object] (выделение добавлено):
Конструкции в программе C++ создают, уничтожают, обращаются к объектам, обращаются к ним и управляют ими. Объект создается по определению (6.1), новым выражением (8.3.4), при неявном изменении активного члена объединения (12.3) или при создании временного объекта (7.4, 15.2).
Это, по-видимому, единственные допустимые способы создания объекта C++. Итак, допустим, мы получаем указатель void*
на сопоставленную область видимой хостом (и когерентной) памяти устройства (при условии, конечно, что все необходимые аргументы имеют допустимые значения, и вызов завершается успешно, а возвращаемый блок памяти имеет достаточный размер и правильно выровненный):
void* ptr{};
vkMapMemory(device, memory, offset, size, flags, &ptr);
assert(ptr != nullptr);
Теперь я хочу получить доступ к этой памяти как массив с float
. Очевидная вещь, которую нужно сделать, - это static_cast
указатель и продолжить мой веселый путь следующим образом:
volatile float* float_array = static_cast<volatile float*>(ptr);
(volatile
включена, поскольку она отображается как когерентная память и, таким образом, может быть записана графическим процессором в любой момент). Тем не менее, массив с float
технически не существует в этой области памяти, по крайней мере, не в смысле цитируемой выдержки, и, таким образом, доступ к памяти через такой указатель был бы неопределенным поведением. Поэтому, насколько я понимаю, у меня есть два варианта:
1. memcpy
данные
Всегда должна быть возможность использовать локальный буфер, привести его к std::byte*
и memcpy
представить в отображенную область. Графический процессор будет интерпретировать его, как указано в шейдерах (в данном случае, как массив 32-разрядных float
), и, таким образом, проблема решена. Однако это требует дополнительной памяти и дополнительных копий, поэтому я бы предпочел этого избежать.
2. placement- new
массив
Похоже, что раздел [new.delete.placement] не накладывает никаких ограничений на способ получения адреса размещения (он не должен быть указателем, полученным безопасно, независимо от безопасности указателя реализации). Следовательно, должно быть возможно создать действительный массив с плавающей точкой через placement- new
следующим образом:
volatile float* float_array = new (ptr) volatile float[sizeInFloats];
Указатель float_array
теперь должен быть безопасным для доступа (в пределах массива или в прошлом).
Итак, мои вопросы следующие:
- Является ли простой
static_cast
действительно неопределенным поведением? - Это placement-
new
использование хорошо определено? - Применима ли эта методика к подобным ситуациям, таким как доступ к оборудованию с отображенной памятью?
Как примечание, у меня никогда не было проблемы, просто приводя возвращенный указатель, я просто пытаюсь выяснить, каким будет правильный способ сделать это, согласно букве стандарта.
Ответы
Ответ 1
Короткий ответ
Согласно Стандарту, все, что связано с отображением аппаратного обеспечения памяти, является неопределенным поведением, так как эта концепция не существует для абстрактной машины. Вы должны обратиться к руководству по реализации.
Длинный ответ
Несмотря на то, что стандартное отображение памяти с аппаратным отображением является неопределенным, мы можем представить себе любую разумную реализацию, обеспечивающую соблюдение общих правил. Некоторые конструкции имеют более неопределенное поведение, чем другие (что бы это ни значило).
Является ли простой static_cast
действительно неопределенным поведением?
volatile float* float_array = static_cast<volatile float*>(ptr);
Да, это неопределенное поведение, которое много раз обсуждалось в StackOverflow.
Хорошо ли определено это новое место размещения?
volatile float* float_array = new (ptr) volatile float[N];
Нет, хотя это выглядит четко определенным, это зависит от реализации. Как это происходит, operator ::new[]
может зарезервировать некоторые накладные расходы 1, 2, и вы не сможете узнать, сколько, если вы не проверите документацию своей цепочки инструментов. Как следствие, ::new (dst) T[N]
требует неизвестного объема памяти, большего или равного N*sizeof T
и любой dst
вы выделяете, может быть слишком маленьким, включая переполнение буфера.
Как действовать дальше?
Решением было бы вручную построить последовательность чисел с плавающей точкой:
auto p = static_cast<volatile float*>(ptr);
for (std::size_t n = 0 ; n < N; ++n) {
::new (p+n) volatile float;
}
Или эквивалентно, полагаясь на Стандартную библиотеку:
#include <memory>
auto p = static_cast<volatile float*>(ptr);
std::uninitialized_default_construct(p, p+N);
Это создает непрерывно N
неинициализированных volatile float
объектов с volatile float
в памяти, на которую указывает ptr
. Это означает, что вы должны инициализировать те, прежде чем читать их; чтение неинициализированного объекта - неопределенное поведение.
Применима ли эта методика к подобным ситуациям, таким как доступ к оборудованию с отображенной памятью?
Нет, опять же, это действительно зависит от реализации. Мы можем только предполагать, что ваша реализация приняла разумный выбор, но вы должны проверить, что написано в ее документации.
Ответ 2
Спецификация C++ не имеет понятия отображенной памяти, поэтому все, что с ней связано, является неопределенным поведением в отношении спецификации C++. Поэтому вам нужно посмотреть на конкретную реализацию (компилятор и операционную систему), которую вы используете, чтобы увидеть, что определено и что вы можете сделать безопасно.
В большинстве систем отображение вернет память, которая пришла откуда-то еще, и может (или не может) быть инициализирована способом, совместимым с некоторым конкретным типом. В общем, если память изначально была записана в виде значений с float
правильной, поддерживаемой формы, то вы можете безопасно привести указатель к float *
и получить к нему доступ таким образом. Но вам нужно знать, как изначально отображалась отображаемая память.
Ответ 3
C++ совместим с C, и манипулирование необработанной памятью является своего рода тем, для чего C идеально подходит. Так что не волнуйтесь, C++ прекрасно способен делать то, что вы хотите.
- Изменение: - перейдите по этой ссылке для простого ответа на C/C++ совместимость. -
В вашем примере вам не нужно звонить новым вообще! Объяснить...
Не все объекты в C++ требуют строительства. Они известны как типы PoD (обычные старые данные). Они есть
1) Основные типы (float/ints/enums и т.д.).
2) Все указатели, но не умные указатели. 3) Массивы типов PoD.
4) Структуры, которые содержат только базовые типы или другие типы PoD.
...
5) Класс тоже может быть PoD-типа, но соглашение заключается в том, что на все объявленное "класс" никогда не следует полагаться как на PoD.
Вы можете проверить, является ли тип PoD, используя стандартный объект библиотеки функций.
Теперь единственное, что не определено в приведении указателя к PoD-типу, это то, что содержимое структуры не задается ничем, поэтому вы должны рассматривать их как значения "только для записи". В вашем случае вы могли записать их с "устройства", поэтому их инициализация уничтожит эти значения. (Кстати, правильное приведение - это reinterpret_cast)
Вы вправе беспокоиться о проблемах выравнивания, но вы ошибаетесь, полагая, что это то, что может исправить код C++. Выравнивание является свойством памяти, а не языковой особенностью. Чтобы выровнять память, вы должны убедиться, что "смещение" всегда кратно "выравниванию" вашей структуры. На x64/x86 это неправильно не создаст никаких проблем, а только замедлит доступ к вашей памяти. В других системах это может привести к фатальному исключению.
С другой стороны, ваша память не является "энергозависимой", к ней обращается другой поток. Этот поток может быть на другом устройстве, но это другой поток. Вам нужно использовать поточно-ориентированную память. В C++ это обеспечивается атомными переменными. Тем не менее, "атомный" не объект PoD! Вы должны использовать забор памяти вместо этого. Эти примитивы заставляют память считываться из памяти и в нее. Ключевое слово volatile также делает это, но компилятору разрешено изменять порядок изменяемых записей, что может привести к неожиданным результатам.
Наконец, если вы хотите, чтобы ваш код был в стиле "модерн C++", вы должны сделать следующее.
1) Объявите вашу собственную структуру PoD, чтобы представить ваш макет данных. Вы можете использовать static_assert (std :: is_pod <MyType> :: value). Это предупредит вас, если структура не совместима.
2) Объявите указатель на ваш тип. (Только в этом случае не используйте интеллектуальный указатель, если нет способа "освободить" память, которая имеет смысл)
3) Распределять память только через вызов, который возвращает этот тип указателя. Эта функция должна
а) Инициализируйте ваш тип указателя с результатом вашего обращения к Vulkan API.
б) Используйте новый указатель на месте - это не требуется, если вы только записываете данные - но это хорошая практика. Если вы хотите установить значения по умолчанию, инициализируйте их в объявлении структуры. Если вы хотите сохранить значения, просто не задавайте им значения по умолчанию, и новые на месте ничего не сделают.
Используйте забор "приобрести" перед чтением памяти, забор "освободить" после записи. Вулкан может предоставить конкретный механизм для этого, я не знаю. Хотя для всех примитивов синхронизации (таких как блокировка/разблокировка мьютекса) обычно подразумевается ограничение памяти, поэтому вы можете обойтись без этого шага.