Как стать автором
Обновить

Комментарии 84

Гарантированный стандартом способ обнулить все елементы массима, это сделать вот так

int buffer[size] = {0};

И не нужно memset-ов ни каких
Вот что думает об этом Visual C++ 10

   304: int _tmain(int /*argc*/, _TCHAR* /*argv[]*/)
   305: {
   306: 	const size_t size = 100;
   307: 	int buffer[size] = {0};
   308: 	return 0;
00403940  xor         eax,eax  
   309: }
00403942  ret  


Нетрудно видеть, что сгенерирован только код, обеспечивающий «return 0;». И такое поведение полностью соответствует Стандарту.
Не путайте теплое с мягким, компилятор здесь вообще ваш массив выпилил за ненедобностью. Если бы он использовался, то он сгенерил бы код для инициализации нулями
Это вы не путайте теплое с мягким. В примере ниже массив используется. Покажете код memset в примере ниже?

   304: int _tmain(int /*argc*/, _TCHAR* /*argv[]*/)
   305: {
00403940  sub         esp,194h  
00403946  mov         eax,dword ptr [___security_cookie (407018h)]  
0040394B  xor         eax,esp  
0040394D  mov         dword ptr [esp+190h],eax  
00403954  push        esi  
00403955  push        edi  
   306: 	const size_t size = 100;
   307: 	int buffer[size] = {0};
   308: 	for( size_t i = 0; i < size; i++ ) {
00403956  mov         edi,dword ptr [__imp__rand (4050C0h)]  
0040395C  xor         esi,esi  
0040395E  mov         edi,edi  
   309: 		buffer[i] = rand();
00403960  call        edi  
00403962  inc         esi  
00403963  cmp         esi,64h  
00403966  jb          wmain+20h (403960h)  
   310: 	}
   311: 	memset( buffer, 0, sizeof( buffer ) );
   312: 	return 0;
   313: }
00403968  mov         ecx,dword ptr [esp+198h]  
0040396F  pop         edi  
00403970  pop         esi  
00403971  xor         ecx,esp  
00403973  xor         eax,eax  
00403975  call        __security_check_cookie (403BCEh)  
0040397A  add         esp,194h  
00403980  ret
И в этом примере массив тоже не используется. Компилятор, естественно, не заполняет массив нулями, если может доказать что никто никогда их не прочитает.

А вот пример когда массив используется. Показать memset?

void f(void*);

void g() {
int buffer[100] = {0};
f(buffer);
}

void g() {
0: sub $0x198,%rsp
int buffer[100] = {0};
7: xor %eax,%eax
9: mov $0x32,%ecx
e: mov %rsp,%rdi
11: rep stos %rax,%es:(%rdi)
f(buffer);
14: mov %rsp,%rdi
17: callq 1c <_Z1gv+0x1c>
18: R_X86_64_PC32 _Z1fPv-0x4
}
1c: add $0x198,%rsp
23: retq
Замечательно. Между тем пост (более-менее в основном) о перезаписи массива перед выходом из функции. Где в вашем коде перезапись массива перед выходом из функции g()?
Не съезжйте с темы. Вам показали а) цитату из стандарта б)правильный пример где компилятор не оптимизирует инициализацию нулями.

В следующий раз пишите на темы, в которых вы ориентируетесь.
Так-так, давайте не будем переходить на личности.
В тексте статьи ведь указано конкретное применение этого всего: гарантировать выделение и обнуление памяти «здесь и сейчас», вне зависимости от того, кто там дальше будет (или не будет) в неё писать, дабы избежать накладных задержек на выделение и ошибок связанных с отсутствием обнуления. Тут автор прав.
Вызов любой функции с передачей туда массива сразу говрит, что оптимизировать ничего нельзя.

Оптимизировать он будет только если а) массив никуда не передается б) не ЧИТАЕТСЯ локально. Если код только пишет в массив, но не читает, ничего инициализировать компилятор не будет, т.к. не смыла.

Соответсвенно проблема «обнуление памяти «здесь и сейчас», вне зависимости от того, кто там дальше будет (или не будет) в неё писать» имеет чисто академический интерес.
Всё ещё недостаточно условий. Если массив не убегает (escapes), то компилятор всё равно может соптимизировать, например, при помощи SROA:
$ cat /tmp/a.c
int g();
int f()
{
  int x[4] = {0};
  x[1] = g();
  x[2] = g();
  return x[1] + x[2] + x[3];
}
$ clang -emit-llvm -S -O2 /tmp/a.c
$ cat a.s
[...]
define i32 @f() nounwind uwtable {
entry:
  %call = tail call i32 (...)* @g() nounwind
  %call1 = tail call i32 (...)* @g() nounwind
  %add = add nsw i32 %call1, %call
  ret i32 %add
}

declare i32 @g(...)

Как видно, массива как таквого в промежуточном коде нет.
Ну это отдельный случай, сланг здесь вообще решил использовать три отдельные переменные, вместо массива. Третья переменная будет нулем, т.к. она «default initalized»

Мы же обсуждаем инициализацию массивов.
С точки зрения программиста — это массив. А с точки зрения машинного кода это скорее всего будет только два регистра. Понимаете, инициализация не-volatile данных не наблюдаема и поэтому может быть что угодно пока с точки зрения программиста всё выглядит неотличимо.
Не понятно за что минус. Я с вашей позицией и не спорю. Мой изначальный посыл был «форсированная инициализация нулями не имеет смысла». Да, есть случаи, когда память нужно затереть из соображений безопасности, но это отдельная тема и делается это далеко не нулями.
Какая разница — нулями или не нулями перезаписывать память?
Большая разница, если хитриый руткит захочет выловить пароль из вашей программы, он в первую очередь будет перехватывать попытки обнулить участки памяти. Т.к. перехватывать любые перезаписи сильно сложнее и дороже.
Тут можно поспорить.

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

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

>> Соответсвенно проблема «обнуление памяти «здесь и сейчас», вне зависимости от того, кто там дальше будет (или не будет) в неё писать» имеет чисто академический интерес.

Мне кажется вы немного себе противоречите.
Вы уверены что LTCG не выпилит и вызов функции?
Я вообще то именно об этом говорю: код на который я ответил «гарантирует» генерацию в надежде что если тело функции недоступно в текущей единице трансляции, то оно не может быть заинлайнено и удалено если оно пустое или нерелевантное.
Может и в этом случае не выпилить, если в f() этот массив нетрививальным образом используется, например — убегает куда-нибудь в системную библиотеку. Я показывал именно ассемблерный листинг одного объектного файла — там без вариантов должна быть инициализация.

А вообще автор статьи прав, Gunnar какую-то ересь понес к делу не относящуюся и увел тему в сторону :)
Минусуюшим, цитирую стандарт

8.5:
To zero-initialize storage for an object of type T means:

if T is a scalar type, the storage is set to the value of 0 (zero) converted to T;
if T is a non-union class type, the storage for each nonstatic data member and each base-class subobject is zero-initialized;
if T is a union type, the storage for its first data member is zero-initialized;
if T is an array type, the storage for each element is zero-initialized;
if T is a reference type, no initialization is performed.

To default-initialize an object of type T means:

if T is a non-POD class type, the default constructor for T is called
if T is an array type, each element is default-initialized;
otherwise, the storage for the object is zero-initialized.

8.5.1:

If there are fewer initializers in the list than there are members in the aggregate, then each member not explicitly initialized shall be default-initialized (8.5).

8.5.1 говорит, что если инциализаторов меньше чем елементов, то остальные елементы «hall be default-initialized (8.5).».

8.5. говрит, что для массовов, каждый елемент " is default-initialized"
Далее для всех POD типов «otherwise, the storage for the object is zero-initialized

Учтите матчасть, господа
Это не наблюдаемое поведение. Компилятор имеет право сделать ну, например, SROA (scalar replacement of aggregates), и разбить ваш массив на несколько различных переменных, лежащих даже не в соседних ячейках памяти, а несипользуемые ячейки вообще не распределять в памяти (конечно же если адрес этого массива никуда не убегает (escapes)).

for( char* ptr = start; ptr < start + size; ptr += MemoryPageSize ) {
     *ptr;
}


Ага, да. Внезапно DR 1054:
www.open-std.org/jtc1/sc22/wg21/docs/cwg_defects.html#1054

C and C++ differ in the treatment of an expression statement, in particular with regard to whether a volatile lvalue is fetched. For example,


    volatile int x;
    void f() {
        x;    // Fetches x in C, not in C++
    }


В C++11 исправлено.
В первом куске кода char* или volatile char*?
В посте три похожих куска. Первые два без volatile, третий — с volatile. У вас во втором фрагменте кода volatile int x; и в цитате после нее говорится про volatile lvalue, так что я предположил, что вы имели в виду volatile char*, иначе не понятно, в чем именно интрига.
Пересмотрел пост — теперь вижу. Да, там есть кусок кода с volatile char* и я скопировал не тот кусок. Да, в моём комментарии имелось ввиду volatile char*.
> во имя безопасности и паранойи

Если вспомнить как второе слово последнее время используют, думаю их пора признать синонимами.
Не совсем. Безопасность — вещь комплексная. Да, полезно перезаписывать переменные, использованные под хранение секретных данных, но перезапись не делает весь продукт абсолютно безопасным, просто немного поднимает планку.
"Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile.

Если сами данные не имеют квалификатора volatile, а квалификатор volatile добавляется указателю на эти данные, чтение-запись этих данных уже не относится к наблюдаемому поведению.
"
Откуда такая трактовка? Пусть через указатель, но мы меняем именно данные с квалификатором volatile, соответственно это должно быть наблюдаемое поведение. Более того тогда как трактуется «volatile char[100] a»?
Откуда такая трактовка? Пусть через указатель, но мы меняем именно данные с квалификатором volatile, соответственно это должно быть наблюдаемое поведение.

Такая трактовка от того, что после очень тщательных поисков мне так и не удалось найти, где именно Стандарт гарантирует доступ к не-volatile данным через указатель на volatile.

Обратите внимание, я буду рад оказаться неправым в этой части поста, потому что это будет означать, что использование указателей на volatile — просто идеальное решение рассмотренной изначальной проблемы.

Более того тогда как трактуется «volatile char[100] a»?

Visual C++ трактует это как синтаксически неверный код. Видимо, вы имели в виду что-то другое.
Да, я естественно имел в виду «volatile char a[100];»
Возникает вопрос «a[0]=0» — наблюдаемо? А "*a=0"?
А так:
volatile char *b;

b[1]=0;

А чем это от предыдущего отличается?
Я это имел в виду.
>>где именно Стандарт гарантирует доступ к не-volatile данным через указатель на volatile

Ну вот представьте, что у вас есть код

test.cpp
void f (volatile char* buf)
{
 // do something
}


а в main.cpp вы ее вызываете


char buf* = something();
f ((volatile char*)buf);


Компилируя test.cpp, компилятор не знает и не может знать, был оригинальный указатель volatile или нет, он занет только что ему передали volatile и рабоать он с ним будет исходя из этого.
Компилируя test.cpp, компилятор не знает и не может знать

Может при использовании LTCG или ее аналогов.
Все равно не может, вдруг вы собираетесь вашу функцию завернуть в бибилиотеку и вызывать из сторонней программы.
Может, если и на вызывающей стороне, и на вызываемой используется LTCG, — в этом случае он будет «видеть устройство» и того кода, и другого.
каким образом? Я написал библиотеку (dll) и дал вам в скомпилированном виде. Дальше что?
В этом случае, естественно, компилятор не видит устройство вызываемого кода при компиляции вызывающей стороны. Я имел в виду, например, случай, когда вызываемый код находится в статической библиотеке, которая скомпилирована с LTCG.
Именно, а значит никаких чудес — каждое чтение и присваивание в buf будет выполнятся «честно». И даже не важно в разных это файлах или в одном — имеем преобразование из non-volatile в volatile и с этого момента компилятор будет работать с данным указателем, как с volatile. Не пойму, откуда у автора статьи такие странные выводы и паника.
И, кстати, мы про какой стандарт говорим? Я пока что нашёл только упоминание о том, что доступ к volatile переменным, полям, метода и классам не должен порождать side-effects и тому подобное. Доступ подразумевает именно доступ — не важно через указатель или непосредственно к переменной.
А где стандарт «не гарантирует»? Откуда такие выводы-то?
Насколько мне известно, по умолчанию, если в Стандарте что-то явно не указано, то это не гарантируется.
Дык, ещё раз — про который из стандартов мы говорим и почему не гарантируется-то? В 98 — гарантируется. В драфте 2005 — гарантируется. В том смысле что и там и там говорится «access» и соответственно, совершенно по фиг как именно осуществляется доступ к переменной/объекту — непосредственно или через указатель. К сожалению 2003 под рукой нет, но что-то мне подсказывает что там тоже будет написано «access».
По Вашей логике так и const, тоже должен изменяться через указатель. И вообще никакие модификаторы не могут работать — они в стандарте совершенно одинаково описаны.
Я когда про массивы спрашивал именно на это и намекал — нет разницы, как осуществляется доступ — через указатель или непосредственно, а значит поведение должно быть одинаковым.
Не поленился, нашёл 2003 (текущий) стандарт. Там немного по-другому, действительно.
Там написано про наблюдаемое поведение при записи чтении volatile данных. С какого бодуна запись через указатель перестала быть записью?
Не перестала, вопрос в том, где говорится, что использование указателя на volatile для доступа к данным, которые не объявлены как volatile, требует обращения такого же обращения с этими данными, как будто они объявлены volatile.
В ISO/IEC 9899 это говорится в параграфе 6.5.3.2, п. 4:

The unary * operator denotes indirection. If the operand has type ‘‘pointer to type’’, the result has type ‘‘type’’.


То есть тип результата разыменования выводится исключительно из типа разыменуемого указателя, и никакие иные соображения во внимание не принимаются.
Более того, там описаны правила приведения к volatile — никаких сюрпризов, описание чего угодно можно в любой момент привести к volatile и доступ будет осуществляться с учётом этого модификатора. А вот про обратное преобразование ясно сказано, что приведение voltaile к non-volatile допустимо, но результат такого преобразования неизвестен.
volatile char *volatilePtr = static_cast<volatile char*>(ptr);


Не спора ради, а просто интересуюсь. А разве такое объявление не означает, что volatile будут именно данные по указателю, а не сам указатель?
Этот вызывающий вопросы кусок поста написан в предположении, что в случае, когда компилятор точно знает, что этот указатель указывает на данные, которые не объявлены volatile, он имеет право считать их не volatile.
Как-то это очень умно для компилятора получается. Так он и на запись в конст, который на самом деле не конст мог бы не ругаться. :) Изначально, если мы сказали компилятору что это указатель на тип такой-то и более того вызывали каст — усомниться в наших действиях он не должен. А уж если во время запуска что-то поломается — это не его вина, ему так сказали сделать.
Вот мне интересно, зачем вообще компилятор выпиливает мёртвый код? Я вижу две причины наличия мёртвого кода в исходниках: ошибка разработчика или явное намерение разработчика. В обоих случаях достаточно выдать варнинг и не удалять мёртвый код. Ошибку разработчик исправит, а при явном намерении явно отключит варнинг директивой. Или я неправ?
Рассмотрим, например, std::vector. Когда он разрушается или из него удаляются элементы, для этих элементов вызываются деструкторы, скорее всего, для этого используется цикл. Очевидно, если элементы такого типа, что у них деструкторы тривиальны, то деструкторы вызывать не нужно и цикл как таковой тоже не нужен. Способность компилятора удалять мертвый код позволит вам просто написать этот цикл и расчитывать на то, что компилятор в каждом конкретном случае сможет правильно решить, нужен этот цикл или нет.
Нет, вы не правы. Мертвый код может получаться в результате подстановки inline функций и анализа условий, в которых они вызываются. В частности это касается STL, где используются огромные синтаксические конструкции из нескольких вариантов алгоритма и описания, когда какой вариант использовать. Лишние варианты компилятор выкидывает и остается оптимальный и легковесный код.
Спасибо за объяснение. Да, в плюсах действительно много генерированного кода, я как-то не подумал.
зачем вообще компилятор выпиливает мёртвый код

Ошибку разработчик исправит

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

+ такой код может появлять в результате работы самого компилятора, причем довольно часто.
Ну для того я и предлагаю выдавать варнинг, чтобы нашёл :-) Подсветка в редакторе — это тот же варнинг.
Мертвый код может вполне нормально получаться, если используем библиотеки. Хотя это и дурной тон. Например недавно я написал себе шикарную библиотечку на все случаи жизни. Некоторый функции были static inline в хедере. Так оно меня ворнингами о мертвом коде задолбало. Ясен перец, что он в данном конкретном случае мертвый — библиотека-то универсальная.
Любая шаблонная магия порождает сотни тонн мёртвого кода.
volatile char *volatilePtr = static_cast<volatile char*>(ptr);
* volatilePtr = 0;
И все – компилятор более не имеет права удалять запись…
КРАЙНЕ НЕОЖИДАННО… вопреки всем суевериям зачеркнутое утверждение выше неверно.


Ожидал, что модификатор volatile работает как const, т.е. различает конструкции
volatile char * ptr;
и
char * volatile ptr;


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

Проверил выполнение этих предположений, так и есть.
С опциями макс. оптимизациями MSVC делает слeдующее.

Для
void x1(char * z) { *z; }

Имеем код
retn


Для
void x1(volatile char * z) { *z; }

Код
mov  al,[ecx]
retn


Для
void x1(char * volatile z) { *z; }

Код
push ecx
mov  [esp],ecx
mov  eax,[esp]
pop  ecx
retn


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


Использовать volatile для переменных, к которым возможен доступ из нескольких потоков — это хороший способ прострелить себе ногу. Для этой цели есть atomic, а использование volatile почти всегда неверно.
Ну-ну. Если я понимаю на каких архитектурах оно будет работать и знаю, как оно работает, то всё нормально. Позволяет круто заоптимизировать код. А если говорить о всяком низкоуровневом программировании и контроллерах, то там без этого вообще никак — высокоуровневые средства синхронизации либо отсутствуют либо потребляют непозволительное количество ресурсов.
Если я знаю, что кэш когерентен (или отсутствует) и, что выравнивание не будет отключаться — то всегда пожалуйста.
… а также досконально знаешь свой компилятор, или просто веришь что он простит тебе undefined behavior. Аккуратно анализируешь возможные переупорядочения инструкций вокруг каждого такого volatile (как компилятором так и процессором), и можешь обосновать что все они тебе подходят.

А на практике, наверное, просто показываешь пальцем в экран и говоришь — «ну ведь работает же!» :)
Вот сижу я и думаю… Pragma… volatile… большинство индусов (не в обиду нормальным, к сожалению, таких крайне мало) плюют на это, для них нижеследующее будет вЕрхом оптимизации:

void *something(void *shi_t)
{
    // do something
    return "false";
}

int main(void)
{
    if (something(0x0) == "false")
        printf("false\n");
    else
        printf("true\n");

    return 0;
}

Если вы используете memset() или эквивалентный ей написанный самостоятельно цикл, например, такой:

для локальной переменной, то есть риск, что компилятор удалит этот цикл, потому что цикл не влияет на наблюдаемое
поведение (доводы в том посте к наблюдаемому поведению тоже не относятся).


Если локальная переменная сначала чистится, а затем используется, и такое поведение устойчиво воспроизводится то, как мне кажется, это повод написать bug-report. Ведь получается, что компилятор явно влияет на поведение программы.
Здесь речь о том, что она сначала используется, а потом ее неплохо бы почистить.
А что если посмотреть на типичную реализацию SecureZeroMemory()? Она по сути такая:
volatile char *volatilePtr = static_cast<volatile char*>(ptr);
for( size_t index; index < size; index++ )
        * volatilePtr = 0;
}

… Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile. Вот для таких:
volatile buffer[size];

Если сами данные не имеют квалификатора volatile, а квалификатор volatile добавляется указателю на эти данные, чтение-запись этих данных уже не относится к наблюдаемому поведению

Конечно. Потому, что
volatile char a[M]
и
volatile char *b
это разные типы!

А вот этот, тот же:
volatile T (&arr)[M]
Так зачем в функции принимать с типом указателя, когда можно принять с типом volatile-массива?

Зачем нужна SecureZeroMemory(), когда можно использовать такую функцию абсолютно кроссплатформенную?
// Think that another thread reads this array
template<typename T, size_t M>
inline void zero_func(volatile T (&arr)[M]) {
    for(auto &i : arr) i = 0;
}
 
template<typename T, size_t M>
inline void zero_func(volatile T *ptr) {
    zero_func(reinterpret_cast<volatile T (&)[M]>(*ptr));
}


Подробнее на ideone.com
Обратите внимание, что массив передается по ссылке, так что все проблемы с действием квалификаторов остаются, поскольку это «ссылка на volatile».
Только у меня не ссылка на volatile, а volatile-ссылка на статичный массив.
А можно цитату из стандарта, где говорится о том, что volatile-ссылка на объект не гарантирует volatile-поведение?
И чем тогда вообще отличается поведение volatile-ссылка от не volatile ссылки?
А можно цитату из стандарта, где говорится о том, что volatile-ссылка на объект не гарантирует volatile-поведение?
В Стандарте именно такого требования нет, но из этого ровно ничего не следует. Например, в Стандарте не требуется, чтобы запись через неинициализированный указатель приводила к AV (или segmentation fault — кому как нравится).
int main() {
 char arr1[10];
 typedef decltype(arr1) T;
 T & arr2 = arr1;
 T volatile & arr3 = arr1;
 
 char *ptr1;
 char * volatile ptr2 = ptr1;
 char volatile *ptr3 = ptr1;
 char volatile * volatile ptr4 = ptr1;
 
 return 0;
}

В этом коде нет одинаковых типов.
А чем отличается поведение T & и T volatile &?
И чем отличается поведение char * volatile, от char volatile *, и от char volatile * volatile?
Применительно к обязательности перезаписи данных — Стандарт не дает никаких гарантий ни в одном из перечисленных случаев, потому что сам массив не имеет квалификатора volatile. Плюс в примерах с указателями у вас неопределенное поведение, потому что нельзя инициализировать указатель значением неинициализированного указателя.
Хорошо, пусть указатели буду инициализированы, хотя сути касаемо volatile это не меняет:
int main() {
 char arr1[10];
 typedef decltype(arr1) T;
 T & arr2 = arr1;
 T volatile & arr3 = arr1;
 
 char *ptr1 = new char[20];
 char * volatile ptr2 = ptr1;
 char volatile *ptr3 = ptr1;
 char volatile * volatile ptr4 = ptr1;
 delete[] ptr1;
 
 return 0;
}

Т.е. на вопрос: чем отличается поведение T & и T volatile &, и чем отличается поведение char * volatile, от char volatile *, и от char volatile * volatile, ваш ответ — ничем не отличаются?

К слову, как раз наоборот, если изначально указатель/массив был с гарантией перезаписи volatile, то эта гарантия обязана распространятся и на указатели/ссылки которым он присваивается. Если мы попытаемся это обойти, то получим ошибку компилятора. И нам не поможет ни один из кастов, даже reinterpret_cast.
int main() {
 char volatile * volatile ptr4 = new char[20];
 char * ptr1 = reinterpret_cast<char *>(ptr4); // error
 delete[] ptr4;
 
 return 0;
}

Обратное же вполне возможно, компилятор позволяет дать гарантию volatile:
int main() {
 char * ptr1 = new char[20];
 char volatile * volatile ptr4 = ptr1;
 delete[] ptr1;
 
 return 0;
}

Я пока что просто намекаю, что если бы гарантия volatile противоречила изначальному отсутствию volatile или не имела эффекта в одном из случаев char volatile * или char * volatile, то компилятор бы выдавал ошибку, как и в предыдущем случае с reinterpret_cast.

Я к тому, что вы слишком вольно трактуете стандарт, и в вашей цитате:
volatile char *volatilePtr = static_cast<volatile char*>(ptr);
Если сами данные не имеют квалификатора volatile, а квалификатор volatile добавляется указателю на эти данные, чтение-запись этих данных уже не относится к наблюдаемому поведению

Вы добавили квалификатор volatile не указателю на данные, а данным, на который указывает указатель.
А вот чтобы добавить квалификатор volatile указателю на данные, надо писать:
char * volatile  volatilePtr = static_cast<char * volatile>(ptr);
это все разные типы с разным поведением.
Вы добавили квалификатор volatile не указателю на данные, а данным, на который указывает указатель.

Не самим данным (переменной), а l-value. Поменять объявление переменной невозможно, а именно наличие квалификатора volatile у самой переменной определяет, является ли она «volatile data», изменение которых относится к наблюдаемому поведению.

Даже если я неверно трактую Стандарт, то я его трактую слишком строго, а не слишком вольно, потому что я утверждаю отсутствие гарантии.
Для volatile char arr[10]; и volatile int a; выражения arr[1] и a — это тоже l-value. Нельзя дать l-value квалификатор volatile, но не дать его данным этой переменной — это не имеет никакого смысла.
Из вашего утверждения следует, что не существует случаев, когда был бы смысл использовать volatile T *ptr вместо T *ptr, и вы не сможете привести даже примера имеющего какой-то смысл?
Потому, что уже в выражении volatile T *ptr = new T; мы даем volatile не самим данным (переменной), а l-value-переменной. Ещё интересней вывод, что в куче вообще нельзя иметь volatile данные :)

Тогда квалификатор volatile, пройдя через несколько стандартов, имел бы запрет на компиляцию volatile T *.

С излишней строгостью вы на создаете себе проблем, которых не существует.
Мало того, lvalue/rvalue является свойством не объектов(переменных), а выражений :)
Ещё интересней вывод, что в куче вообще нельзя иметь volatile данные :)
Отчего же, отчего же…
volatile T *ptr = new volatile T;
Интересно, а в C? :)
Если вы принципиально не верите, что в этом варианте volatile работает:
// Think that another thread reads this array
template<typename T, size_t M>
inline void zero_func(volatile T (&arr)[M]) {
    for(auto &i : arr) i = 0;
}

Но верите, что работает здесь:
volatile T *ptr = new volatile T;

То должны верить и в такой вариант :)
// Think that another thread reads this array
template<typename T, size_t M>
inline void zero_func(T (&arr)[M]) {
    for(auto &i : arr) new (&i) volatile T(0);
}
 
template<typename T, size_t M>
inline void zero_func(T *ptr) {
    zero_func(reinterpret_cast<T (&)[M]>(*ptr));
}

Но все же интересно, как же в C по вашему в куче иметь volatile данные? :)
Этот путь ведет в никуда — вам не удастся «убедить» меня, что в Стандарте есть что-то, что там явно не написано. Да, «вроде бы» «по логике вещей» «должно быть вот так», но тем не менее — «ссылка на явное утверждение в Стандарте, или этого не было».
Это верно. Но вопрос про C в силе :)
Учитывая:
In general, the semantics of volatile are intended to be the same in C++ as they are in C.

У меня нет готового ответа на этот вопрос, но я подозреваю, что, задав вопрос на StackOverflow, вы получите хороший ответ.
К слову, ещё есть:
int volatile * volatile ptr_v_v;
и это не одно и тоже, что и
int volatile * ptr_v;

int volatile * volatile ptr_v_v; — применяется квалификатор volatile и к адресу в котором хранится указатель, и к адресу на который указывает этот указатель.

#include<iostream>
#include<type_traits>
 
int main() {
    int volatile * volatile ptr_v_v;
    int volatile * ptr_v;    
    std::cout << std::boolalpha;
    std::cout << std::is_same<decltype(ptr_v_v), decltype(ptr_v)>::value << std::endl; 
    
    return 0;
}

flase
Зарегистрируйтесь на Хабре, чтобы оставить комментарий