Является ли доступ к регистрам через предопределенные статические адреса неопределенным поведением в C++?
Я собираю программу на C++ для работы в автономной среде, и процессор, на котором я работаю, определяет 32-битный периферийный регистр, который будет доступен (edit: memory-mapped) в PERIPH_ADDRESS
(выровнено правильно и не перекрывается с любым другим объектом C++, стеком и т. д.).
Я компилирую следующий код с PERIPH_ADDRESS
предопределено, позже свяжите его с полной программой и запустите.
#include <cstdint>
struct Peripheral {
const volatile uint32_t REG;
};
static Peripheral* const p = reinterpret_cast<Peripheral*>(PERIPH_ADDRESS);
uint32_t get_value_1() {
return p->REG;
}
static Peripheral& q = *reinterpret_cast<Peripheral*>(PERIPH_ADDRESS);
uint32_t get_value_2() {
return q.REG;
}
extern Peripheral r;
// the address of r is set in the linking step to PERIPH_ADDRESS
uint32_t get_value_3() {
return r.REG;
}
Любой из get_value
функции (напрямую или через p
/ q
) есть неопределенное поведение? Если да, я могу это исправить?
Я думаю, что эквивалентный вопрос был бы: может ли любой соответствующий компилятор отказаться от компиляции ожидаемой программы для меня? Например, один с UB sanitezer включен.
Я рассмотрел [ http://eel.is/c++draft/basic.stc.dynamic.safety ] и [ basic.compound # def: object_pointer_type ], но, похоже, это ограничивает допустимость указателей только для динамических объектов. Я не думаю, что это относится к этому коду, потому что "объект" в PERIPH_ADDRESS
никогда не считается динамическим. Я думаю, что я могу с уверенностью сказать, что хранение обозначено p
никогда не достигает конца срока хранения, его можно считать статическим.
Я также рассмотрел Почему C++ запрещает создание допустимых указателей с допустимого адреса и типа? и ответы на этот вопрос. Они также относятся только к адресам динамических объектов и их действительности, поэтому они не отвечают на мой вопрос.
Другие вопросы, которые я рассмотрел, но не мог ответить сам, которые могли бы помочь с основным вопросом:
- Я сталкиваюсь с какими-либо проблемами UB, потому что объект никогда не создавался в абстрактной машине C++?
- Или я действительно могу считать объект со статической продолжительностью хранения, "построенным" должным образом?
Очевидно, я бы предпочел ответы, которые ссылаются на любой недавний стандарт C++.
7 ответов
Это краткое изложение очень полезных ответов, первоначально опубликованных @curiousguy @Passer By, @Pete Backer и другими. В основном это основано на стандартном тексте (отсюда тег language-lawyer) со ссылками, предоставленными другими ответами. Я сделал это вики-сообществом, потому что ни один из ответов не был полностью удовлетворительным, но у многих были хорошие моменты. Не стесняйтесь редактировать.
Код определяется реализацией в лучшем случае, но может иметь неопределенное поведение.
Части, определенные реализацией:
reinterpret_cast
от целочисленного типа до указательного типа определяется реализацией. [ expr.reinterpret.cast / 5]Значение целочисленного типа или типа перечисления может быть явно преобразовано в указатель. Указатель, преобразованный в целое число достаточного размера (если таковое существует в реализации) и обратно в тот же тип указателя, будет иметь свое первоначальное значение; Отображения между указателями и целыми числами определяются реализацией. [Примечание: за исключением случаев, описанных в [basic.stc.dynamic.safety], результатом такого преобразования не будет значение указателя, полученное безопасно. - конец примечания]
Доступ к энергозависимым объектам определяется реализацией. [ dcl.type.cv/5]
Семантика доступа через переменную glvalue определяется реализацией. Если предпринята попытка получить доступ к объекту, определенному с типом volatile-qualified с использованием энергонезависимого glvalue, поведение не определено.
Части, где следует избегать UB:
Указатели должны указывать на действительный объект в абстрактной машине C++, в противном случае программа имеет UB.
Насколько я могу судить, если реализация абстрактной машины является программой, созданной разумным, совместимым компилятором и компоновщиком, работающим в среде, в которой регистр отображен в памяти, как описано, то можно сказать, что реализация имеет C++
uint32_t
объект в этом месте, и нет UB с какой-либо из функций. Похоже, это разрешено [ intro.compliance / 8]:Соответствующая реализация может иметь расширения (включая дополнительные библиотечные функции), при условии, что они не изменяют поведение любой правильно сформированной программы. [...]
Это все еще требует либеральной интерпретации [ intro.object / 1], потому что объект не создается ни одним из перечисленных способов:
Объект создается по определению ([basic.def]), выражению new, при неявном изменении активного члена объединения ([class.union]) или при создании временного объекта ([conv.rval], [класс.время]).
Если реализация абстрактной машины имеет компилятор с дезинфицирующим средством (
-fsanitize=undefined
,-fsanitize=address
), то, возможно, придется добавить дополнительную информацию в компилятор, чтобы убедить его в том, что в этом месте есть действительный объект.Конечно, ABI должен быть правильным, но это подразумевалось в вопросе (правильное выравнивание и отображение памяти).
Это зависит от реализации, имеет ли реализация строгую или ослабленную безопасность указателя [ http://eel.is/c++draft/basic.stc.dynamic.safety. При строгой безопасности указателя объекты с динамической длительностью хранения могут быть доступны только через безопасный производный указатель [ http://eel.is/c++draft/basic.stc.dynamic.safety.
p
а также&q
значения не такие, но объекты, на которые они ссылаются, не имеют динамической длительности хранения, поэтому этот пункт не применяется.Реализация может иметь ослабленную безопасность указателя, и в этом случае достоверность значения указателя не зависит от того, является ли оно безопасно полученным значением указателя. Альтернативно, реализация может иметь строгую безопасность указателя, и в этом случае значение указателя, относящееся к объекту с динамической продолжительностью хранения, которое не является безопасным значением указателя, является недопустимым значением указателя [...]. [Примечание: эффект от использования недопустимого значения указателя (включая передачу его функции освобождения) не определен, см. [Basic.stc].
Практический вывод состоит в том, что поддержка, определяемая реализацией, необходима, чтобы избежать UB. Для здравомыслящих компиляторов результирующая программа не содержит UB или может иметь UB, на которую можно очень положиться (в зависимости от того, как вы на нее смотрите). Дезинфицирующие средства, однако, могут обоснованно жаловаться на код, если им явно не сказано, что в ожидаемом месте существует правильный объект. Вывод указателя не должен быть практической проблемой.
Это определяется реализацией, что означает приведение из указателя [expr.reinterpret.cast]
Значение целочисленного типа или типа перечисления может быть явно преобразовано в указатель. Указатель, преобразованный в целое число достаточного размера (если таковое существует в реализации) и обратно в тот же тип указателя, будет иметь свое первоначальное значение; Отображения между указателями и целыми числами определяются реализацией.
Поэтому это четко определено. Если ваша реализация обещает вам, что результат приведения действителен, у вас все в порядке. †
Связанный вопрос относится к арифметике указателей, которая не связана с рассматриваемой проблемой.
† По определению, действительный указатель указывает на объект, подразумевая, что последующие косвенные указания также четко определены. Следует проявлять осторожность, чтобы убедиться, что объект находится в пределах его жизненного цикла.
Имеет ли какая-либо из функций get_value (напрямую или через p/q) неопределенное поведение?
Да. Все они. Все они получают доступ к значению объекта (типа Peripheral
) что в отношении объектной модели C++ не существует. Это определено в [basic.lval / 11], AKA: правило строгого алиасинга:
Если программа пытается получить доступ к сохраненному значению объекта через glvalue, отличный от одного из следующих типов, поведение не определено:
Проблема не в "броске"; это использование результатов этого броска. Если там есть объект указанного типа, то поведение четко определено. Если нет, то он не определен.
А так как нет Peripheral
там это UB.
Теперь, если ваша среда выполнения обещает, что есть объект типа Peripheral
по этому адресу, то это хорошо определенное поведение. В противном случае нет.
Если да, я могу это исправить?
Нет. Просто положись на UB.
Вы работаете в ограниченной среде, используя автономную реализацию, возможно, предназначенную для конкретной архитектуры. Я бы не стал потеть.
На практике из предложенных вами конструкций этот
struct Peripheral {
volatile uint32_t REG; // NB: "const volatile" should be avoided
};
extern Peripheral r;
// the address of r is set in the linking step to PERIPH_ADDRESS
uint32_t get_value_3() {
return r.REG;
}
Скорее всего, он не нарушит "удивительное" поведение оптимизатора, и я бы сказал, что его поведение в худшем случае определяется реализацией.
Так как r
в контексте get_value_3
объект с внешней связью, который не определен в этом модуле перевода, компилятор должен предположить, что этот объект существует и уже был правильно сконструирован при создании кода для get_value_3
, Peripheral
является объектом POD, поэтому нет необходимости беспокоиться о статическом упорядочении конструктора. Функция определения объекта для жизни по определенному адресу во время соединения является воплощением поведения, определяемого реализацией: это официально документированная особенность реализации C++ для оборудования, с которым вы работаете, но она не покрыта стандартом C++.
Предостережение 1: абсолютно не пытайтесь делать это с не POD-объектом; в частности, если Peripheral
имел нетривиальный конструктор или деструктор, который, вероятно, вызывал бы неправильные записи по этому адресу при запуске.
Предупреждение 2: Объекты, которые должным образом объявлены как const
а также volatile
очень редки, и поэтому компиляторы, как правило, имеют ошибки в обработке таких объектов. Я рекомендую использовать только volatile
для этого аппаратного регистра.
Предостережение 3: Как указывает суперкат в комментариях, в любой момент времени в определенной области памяти может быть только один объект C++. Например, если есть несколько наборов регистров, мультиплексированных в блок адресов, вам нужно выразить это как-то одним объектом C++ (возможно, объединением), а не несколькими объектам, которым назначен один и тот же базовый адрес.
Ни стандарт C, ни стандарт C++ формально не охватывают даже действия по связыванию объектных файлов, скомпилированных различными компиляторами. Стандарт C++ не дает никаких гарантий того, что вы можете взаимодействовать с модулями, скомпилированными с любым компилятором C, или даже что это означает для взаимодействия с такими модулями; язык программирования C++ даже не подчиняется стандарту C для какой-либо базовой функции языка; не существует класса C++, формально гарантированного совместимости со структурой C. (Язык программирования C++ даже формально не признает, что существует язык программирования C с некоторыми фундаментальными типами с тем же написанием, что и в C++.)
Все взаимодействия между компиляторами по определению выполняются ABI: Application Binary Interface.
Использование объектов, созданных вне реализации, должно выполняться в соответствии с ABI; это включает системные вызовы, которые создают представление объектов в памяти (например, mmap
) а также volatile
объекты.
Я не знаю, ищите ли вы здесь ответ юриста или практический ответ. Я дам вам практический ответ.
Определение языка не говорит вам, что делает этот код. Вы получили ответ, который говорит, что поведение определяется реализацией. Я не уверен, так или иначе, но это не имеет значения. Предположим, что поведение не определено. Это не значит, что будут плохие вещи. Это означает только то, что определение языка C++ не говорит вам, что делает этот код. Если компилятор использует документы, что он делает, это нормально. И если компилятор не документирует это, но все знают, что он делает, это тоже хорошо. Код, который вы показали, является разумным способом доступа к отображенным в памяти регистрам во встроенных системах; если бы это не сработало, многие бы расстроились.
Код, подобный приведенному выше, эффективно стремится использовать C как форму "ассемблера высокого уровня". Хотя некоторые люди настаивают на том, что C не является ассемблером высокого уровня, авторы Стандарта C сказали это в своем опубликованном документе Rationale:
Несмотря на то, что он стремился дать программистам возможность писать действительно переносимые программы, Комитет C89 не хотел заставлять программистов писать портативно, чтобы исключить использование C в качестве "высокоуровневого ассемблера": способность писать машинный код является одной из сильных сторон языка C. Именно этот принцип в значительной степени мотивирует проведение различия между строго соответствующей программой и соответствующей программой (§4).
Стандарты C и C++ сознательно избегают требования, чтобы все реализации были пригодны для использования в качестве ассемблеров высокого уровня, и не пытаются определить все поведения, необходимые для того, чтобы сделать их пригодными для таких целей. Следовательно, поведение таких конструкций, как ваша, которые эффективно рассматривают компилятор как высокоуровневый ассемблер, не определяется Стандартом. Однако авторы Стандарта явно признают ценность способности некоторых программ использовать язык в качестве высокоуровневого ассемблера и, таким образом, явно предполагают, что такой код, как ваш, может быть использован в реализациях, предназначенных для поддержки таких конструкций - сбой определение поведения никоим образом не подразумевает, что такой код "сломан".
Еще до того, как был написан стандарт, реализации, предназначенные для низкоуровневого программирования на платформах, где было бы целесообразно обрабатывать преобразования между указателями и целыми числами одинакового размера, просто переосмысливая их биты, по существу, единодушно обрабатывали бы такие преобразования. Такая обработка значительно облегчает низкоуровневое программирование на таких платформах, но авторы Стандарта не видят причин для этого. На платформах, где такое поведение не имело бы смысла, такой мандат был бы вредным, а на тех, где это имело бы смысл, разработчики компиляторов вели себя соответствующим образом с ним или без него, делая его ненужным.
К сожалению, авторы Стандарта были слишком самонадеянны. Опубликованное Обоснование заявляет о желании поддержать Дух C, принципы которого включают "Не мешайте программисту делать то, что нужно сделать". Это может означать, что на платформе с естественным упорядочением памяти может возникнуть необходимость иметь область памяти, которая "принадлежит" разным контекстам выполнения в разное время, качественная реализация, предназначенная для низкоуровневого программирования на такой платформе, учитывая что-то вроде:
extern volatile uint8_t buffer_owner;
extern volatile uint8_t * volatile buffer_address;
buffer_address = buffer;
buffer_owner = BUFF_OWNER_INTERRUPT;
... buffer might be asynchronously written at any time here
while(buffer_owner != BUFF_OWNER_MAINLINE)
{ // Wait until interrupt handler is done with the buffer and...
} // won't be accessing it anymore.
result = buffer[0];
следует прочитать значение из buffer[0]
после того, как код прочитал object_owner
и получил значение BUFF_OWNER_MAINLINE
, К сожалению, некоторые реализации считают, что было бы лучше попытаться использовать какое-то ранее наблюдаемое значение buffer[0]
чем трактовать изменчивые обращения как возможное освобождение и повторное приобретение права собственности на рассматриваемое хранилище.
В общем, компиляторы будут обрабатывать такие конструкции надежно с отключенной оптимизацией (и фактически будут делать это с или без volatile
), но не может эффективно обрабатывать такой код без использования специфичных для компилятора директив (что также volatile
нет необходимости). Я думаю, что дух C должен прояснить, что качественные компиляторы, предназначенные для низкоуровневого программирования, должны избегать оптимизаций, которые ослабят volatile
семантика способами, которые не позволят программистам низкого уровня делать то, что может понадобиться на целевой платформе, но, очевидно, это недостаточно ясно.