Программирование AVR 9 способов испортить код с помощью volatile Thu, March 28 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

9 способов испортить код с помощью volatile Печать
Добавил(а) microsin   

Квалификатор volatile на языке C/C++ несколько похож на препроцессор C: уродливый, тупой инструмент, который легко может быть применен неправильно. Этот инструмент настолько плох, что он позволит качественно выполнить задачу только при очень узком стечении обстоятельств. В этой статье будет кратко объяснен смысл volatile и его история, вместе с некоторыми примерами как не следует использовать volatile. Сделана попытка объяснить, как наиболее эффективно создавать корректное системное программное обеспечение с использованием volatile. Хотя эта статья фокусируется на языке C, почти все, о чем здесь идет речь, также применимо и на C++ (здесь приведен перевод статьи [1]).

[Что вообще означает программа на языке C?]

Стандарт C [2] определяет смысл программы на языке C с терминах "абстрактной машины", о которой Вы можете думать как о простом, не оптимизированном интерпретаторе C. Поведение любой имеющейся реализации C (компилятор плюс целевой процессор) должно давать те же самые побочные эффекты, как и абстрактная машина, или иначе стандарт устанавливает очень малое соответствие между абстрактной машиной и вычислениями, которые реально происходят на целевой платформе. Другими словами, программа на C может считаться спецификацией желаемых эффектов, и уже конкретная реализация C решает, какие лучше всего стоит предпринять действия, чтобы заставить эти желаемые эффекты произойти.

Как простой пример, рассмотрим функцию (этот код представляет абстрактную семантику, которая задает поведение алгоритма):

int loop_add3 (int x)
{
   int i;
   for (i=0; i < 3; i++)
      x++;
   return x;
}

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

loop_add3:
   movl  4(%esp), %eax
   addl  $3, %eax
   ret

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

[Что означает volatile?]

Проблема разрыва между абстрактной и конкретной семантикой языка C состоит в том, что этот язык был изначально разработан как низкоуровневый язык, предназначенный для разработки операционных систем. Написание кода для операционной системы (ОС) часто требует, чтобы разрыв между абстрактной и реальной семантикой был минимальным, или вообще отсутствовал. Например, если ОС хочет создать новую таблицу страницы памяти, на абстрактной семантике C не удается получить важный факт: то, что программист требует реальной таблицы страницы, которая находится в RAM, где она может пересекаться с аппаратными средствами. Если реализация C вдруг решит, что таблица страницы бесполезна (не используется), то она может быть как-то оптимизирована, поэтому разработчики получат совсем не то, что хотели. Канонический пример этой проблемы - когда компилятор решает, что заполнение нулями чувствительных данных бесполезно, и он это выкидывает. Поскольку абстрактная машина C не была разработана для учета случаев, что эти данные могли бы быть прочитаны где-нибудь позже, то оптимизация законна (хотя очевидно будет нежелательной).

Стандарт C дает нам только несколько путей соединить абстрактную и реальную машины:

• Аргументы функции main().
• Возвращаемое значение из функции main().
• Сторонние функции в стандартной библиотеке C.
• Переменные volatile.

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

Путь, которым volatile соединяют друг с другом абстрактную и реальную семантику, следующий:

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

Если обобщить:

• Volatile не дает никакого эффекта на абстрактной машине; фактически, стандарт C явно устанавливает, что реализации C очень близко отражают абстрактную машину (т. е. простой интерпретатор языка C), так что volatile не оказывает никакого эффекта.

• Доступы к переменным, определенным с ключевым словом volatile, побуждают реализацию компилятора C выполнять определенные действия на уровне реальных вычислений.

[Откуда произошло volatile?]

Исторически взаимосвязь между абстрактной и реальной машинами устанавливалась главным образом от случая к случаю: поначалу компиляторы делали оптимизацию не настолько хорошо, чтобы создавать значительный разрыв между абстрактной и реальной семантикой (т. е. не наблюдалась проблема, которую должна была впоследствии исправлять volatile). По мере улучшения качества оптимизаторов стало ясно, что требуется некая коррекция семантики.  20 лет назад по этой теме был отличный USENET-пост за авторством Doug Gwyn:

Чтобы привести определенный пример, драйверы устройств UNIX почти всегда кодируются полностью на C, и на PDP-11 и подобных архитектурах с привязкой к памяти ввода/вывода, некоторые регистры выполняют различные действия для операций "чтение байта", "чтение слова", "запись байта", "запись слова", "чтение-модификация-запись", или другие вариации операций по доступу к шине памяти. Попытки получить генерацию правильно работающего машинного кода драйвера на C было довольно затруднительным, с появлением трудно обнаруживаемых ошибок. Если используются компиляторы не от Ritchie, включение оптимизации также изменяло бы поведение программы. Как минимум одна версия компилятора UNIC (UNIX Portable C Compiler, PCC) имела специальный хак для распознавания конструкций наподобие следующей:

((struct xxx *)0177450)->zzz

Подобное выражение было бы потенциальной ссылкой на пространство ввода/вывода (регистры периферийного устройства), и в этом месте следует избегать чрезмерной оптимизации (когда константа попадает в область адресного пространства Unibus I/O). X3J11 решил, что проблему надо решать, и ввел "volatile" чтобы устранить необходимость в этих взломах. Однако хотя было рекомендовано для реализаций удовлетворять минимально возможной ширине для данных, квалифицированных как volatile, и было решено непрактичным настаивать на внедрении этого требования в каждой реализации; так что конструкторам была предоставлена некоторая свобода в разработке компиляторов.

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

[9 способов поломать код системы с помощью volatile]

Наиболее очевидный вид ошибки, связанный с ключевым словом volatile - пропустить его в том месте, где оно нужно. Давайте посмотрим на специальный пример. Предположим, что мы разрабатываем программу для 8-битного микроконтроллера AVR, у которых (в некоторых моделях) нет аппаратного перемножителя. Поскольку умножение выполняется программно, нас возможно интересовало бы, насколько медленно будет выполнено вычисление, чтобы можно было избежать замедления программы. Так что напишем маленький бенчмарк наподобие следующего:

#define TCNT1 (*(uint16_t *)(0x4C))
 
signed char a, b, c;
 
uint16_t time_mul (void)
{
   uint16_t first = TCNT1;
   c = a * b;
   uint16_t second = TCNT1;
   return second - first;
}

Здесь TCNT1 указывает на аппаратный регистр, который находится по реальному адресу 0x4C. Этот регистр предоставляет доступ к Timer/Counter 1: это свободно считающий 16-битный таймер, который, как мы предполагаем, считает с некоторой скоростью, подходящей для эксперимента. Мы читаем регистр перед и после операции умножения, и вычитаем полученные значения для получения времени выполнения операции умножения. Примечание: хотя на первый взгляд этот код выглядит ошибочным для случая, когда TCNT1 переполняется и переваливает через 65535, становясь 0, он реально работает для длительностей между 0 и 65535 тиков.

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

; Компиляция модуля:
; avr-gcc -Os -S -o - reg1.c
time_mul:
   lds   r22,a
   lds   r24,b
   rcall __mulqi3
   sts   c,r24
   ldi   r24,lo8(0)
   ldi   r25,hi8(0)
   ret

Теперь видна причина проблемы: оба чтения из регистра TCNT1 были удалены, и функция просто всегда возвращает константу 0 (avr-gcc возвратит 16-битное значение в паре регистров r24:r25).

Как могло произойти, что компилятор никогда не делает чтение из TCNT1? Для начала вспомним, что значение программы на языке C стандартом определено как абстрактная машина. Поскольку правила для абстрактной машины ничего не говорят про аппаратные регистры (или конкурентные вычисления) реализации C разрешено предположить наличие двух чтений из объекта, который никто не изменяет, поэтому оба чтения вернут одинаковое значение. Конечно, любое значение, вычтенное само из себя, вернет 0. Так что трансляция компилятором avr-gcc была выполнена исключительно правильно; это просто наша программа неправильная.

Чтобы исправить проблему, нужно поменять код, который указывает на TCNT1 как на ячейку volatile.

#define TCNT1 (*(volatile uint16_t *)(0x4C))

Теперь реализация C не свободна удалить чтения, и также предположить, что читаемое значение останется тем же самым при каждом чтении. На тот раз компилятор сгенерирует код лучше:

time_mul:
   in    r18,0x2c
   in    r19,0x2d
   lds   r22,a
   lds   r24,b
   rcall __mulqi3
   sts   c,r24
   in    r24,0x2c
   in    r25,0x2d
   sub   r24,r18
   sbc   r25,r19
   ret

Хотя этот код ассемблера корректный, наш код C все еще содержит скрытую ошибку. Обсудим это позже.

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

Рассмотрим другой пример. Во встраиваемых системах некоторые вычисления должны ожидать срабатывания обработчика прерывания (ISR). Ваш код может выглядеть примерно так:

int done;
 
__attribute((signal)) void __vector_4 (void)
{
   done = 1;
}
 
void wait_for_done (void)
{
   while (!done) ;
}

Здесь функция wait_for_done() разработана для вызова из основного контекста (не прерывания), в то время как код __vector_4() будет запушен контроллером прерываний в ответ на некое внешнее событие. Этот код был скомпилирован в следующий ассемблерный код:

__vector_4:
   push  r0
   in    r0,__SREG__
   push  r0
   push  r24
   ldi   r24,lo8(1)
   sts   done,r24
   pop   r24
   pop   r0
   out   __SREG__,r0
   pop   r0
   reti
 
wait_for_done:
   lds   r24,done.
L3:
   tst   r24
   breq  .L3
   ret

Код для обработчика прерывания выглядит хорошо: он сохраняет значение в переменную done как и ожидалось. Остальная часть кода обработчика это просто шаблон прерывания AVR. Однако код wait_for_done() содержит важное упущение: вместо того, чтобы проверять значение ячейки памяти RAM, где находится done, постоянно в цикле проверяется значение регистра r24. Это случается из-за того, что абстрактная машина C нет никаких представлений об обмене данными между конкурентными потоками выполнения кода (все равно что - потоки, прерывания или что-то еще; в нашем примере это основной код и прерывание). И снова трансляция полностью корректна, но не соответствует цели разработчика.

Если мы пометим переменную done ключевым словом volatile, то обработчик прерывания не поменяется, но wait_for_done() теперь выглядит по-другому:

wait_for_done:
.L3:
   lds   r24,done
   tst   r24
   breq  .L3
   ret

Этот код будет работать. Одна из проблем здесь была в видимости переменной. Когда мы сохраняем глобальную переменную на языке C, какие вычисления, работающие на машине, будут гарантировать видимость сохранения? Когда мы загружаем значение из глобальной переменной, какие вычисления предполагают генерировать значение? В обоих случаях, ответ будет "вычисления, которые выполняют загрузку или сохранение, предполагается единственным вычислением, которое имеет значение". Таким образом язык C не делает гарантию видимости для нормальных ссылок на переменную. Квалификатор volatile принуждает сохранять в память или загружать из памяти, давая нам способ гарантировать видимость между несколькими вычислениями (потоками, прерываниями, сопрограммами или чем-то подобным).

И опять наш код C содержит скрытый баг, который мы будет разбирать позже.

Некоторые другие легитимные использования volatile, включая создание переменных в программах UNIX, видимых для сигнальных обработчиков, обсуждаются Hans Boehm [5].

Вывод: абстрактная машина C соединяется с реальной машиной только в нескольких местах. Поведение памяти реальной машины может сильно отличаться от операций, заданных в исходном коде. Если Вам нужны дополнительные соединения между двумя уровнями абстракций, например для доступа к регистрам устройств, то может помочь квалификатор volatile.

В хорошо проработанных частях программного обеспечения volatile используется только в тех местах, где это действительно нужно. В этих местах, применение volatile дает эффект, что "эта переменная не играет по правилам язык C: она требует жесткого соединения с подсистемой памяти". В системе, где используется слишком много volatile, переменные могут быть без разбора маркированы как volatile, без какой-либо привязки к оборудованию. Есть 3 причины, почему это плохо. Во-первых, это плохое документирование кода, и может ввести в заблуждение тех людей, которые будут впоследствии поддерживать код. Во-вторых, volatile иногда дадут эффект скрытия багов в программе, таких как гонки (race conditions). Если Ваш код написан так, что он требует для корректной работы определений volatile, и при этом Вы не понимаете причины такого поведения программы, то скорее всего Вы не понимаете в принципе, как работает код, и код нуждается в кардинальной переработке. Намного лучше действительно решить проблему, чем полагаться на взлом или костыль (иногда это называют пляской с бубном) - когда Вы тупо отключаете оптимизацию или вставляя volatile где попало, не пытаясь понять при этом, что на самом деле происходит. И наконец, volatile не дает эффективно работать оптимизатору кода. Издержки/потери, к которым приводит бездумное использование volatile, трудно отследить/выявить, когда volatile разбросаны по всему коду системы – профайлер мало в этом поможет.

Что такое race condition (гонка). Этот термин пришел из электроники в программирование. Он связан с тем, как влияют на поведение электронных схем задержки распространения сигнала на активных элементах схемы. В программировании гонка означает зависимость поведения программы от времени выполнения отдельных частей кода.

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

Вывод: используйте volatile только когда можете предоставить точное соответствие техническим требованиям к программе (или требованиям железа, которое обслуживает программа). Volatile не заменит мозги программиста (Nigel Jones, см. [3]).

На уровне синтаксиса языка C ключевое слово volatile относится к квалификатору типа. Оно может быть добавлено к любому типу по аналогичным правилам, что и применяются для квалификатора const, но все-таки не точно таким же. Ситуация может стать затруднительной, когда квалифицируемые типы используются для построения более сложных типов. Например, есть 4 возможных способа квалифицировать одноуровневый указатель:

int *p;                              // указатель на int
volatile int *p_to_vol;              // указатель на volatile int
int *volatile vol_p;                 // volatile-указатель на int
volatile int *volatile vol_p_to_vol; // volatile-указатель на volatile int

В каждом случае указатель либо volatile, либо нет, и цель указателя также либо volatile, либо нет. Различие является критическим: если Вы используете "volatile-указатель на обычный int" для доступа к регистру устройства, то компилятор свободен оптимизировать все доступы к этому регистру. Также Вы получите код, работающий медленнее, потому что компилятор не может оптимизировать доступ к указателю. Эта проблема частот появляется в списках рассылки, посвященных программированию встраиваемых систем; здесь легко сделать ошибку. Также просто пропустить ошибку в проверяемом коде при беглом просмотре, потому что ключевое слово volatile в критичном месте будет присутствовать, однако может быть размещено неправильно.

Например, этот код ошибочен:

int *volatile REGISTER = 0xfeed;
*REGISTER = new_val;

Чтобы писать чистый, легко поддерживаемый код с использованием volatile, хорошей идеей будет создать более сложные типы с использованием операторов typedef. Например, мы хотим сначала создать новый тип "vint", который будет означать volatile int:

typedef volatile int vint;

После этого мы можем создать указатель на vint:

vint *REGISTER = 0xfeed;

Поля структуры (struct) или объединения (union) могут обладать свойством volatile, и сами структуры и объединения также могут быть volatile. Если агрегирующий тип создан как volatile, то это дает тот же эффект, как если бы каждый его член был определен как volatile.

Вы можете спросить, есть ли смысл декларировать объект одновременно и как const, и как volatile?

const volatile int *p;

Хотя изначально это выглядит как противоречие, но все же это не так. Семантика const на языке C означает "Я соглашусь ничего не сохранять сюда" вместо "это никогда не может быть изменено". Так что это фактически будет вполне осмысленной квалификацией типа, и даже может быть полезно - например, для декларирования регистра таймера, который может спонтанно менять свое значение, но в который нельзя сохранять данные (на этот пример в частности указывают и в стандарте языка C).

Вывод: поскольку синтаксис декларации типа на языке C не (всегда) является полностью легкочитаемым и интуитивным, квалификаторы volatile должны размещаться с вниманием. Операторы typedef могут быть полезны для структурирования сложных определений.

Последняя версия Linux ветки 2.2 была 2.2.26. В этой версии файл arch/i386/kernel/smp.c на строке 125, можно было найти такое определение:

volatile unsigned long ipi_count;

Пока что проблем нет: здесь декларировано переменная числа long без знака для хранения количества внутренних прерываний процессора, и эта переменная сделана volatile. Однако в заголовочном файле include/asm-i386/smp.h на строке 178 было такое определение:

extern unsigned long ipi_count;

C-файлы, которые подключили этот заголовок директивой #include, не обработают переменную ipi_count как переменную volatile, и этот факт легко может привести к (трудно обнаруживаемым) проблемам. Ядра ветки 2.3 также содержат эту ошибку.

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

Другой способ получить противоречивую квалификацию - приведение типов (typecast). Приведение может быть неявным, например передача указателя на volatile в функцию, которая ожидает на входе обычного (не-volatile) указателя. Компилятор предупредит об этом; такие предупреждения никогда не стоит игнорировать или подавлять опциями. Следует избегать явных приведений типов, которые удаляют квалификаторы, потому что такие приведения обычно не ведут к генерации предупреждений компилятора. Стандарт языка C точно устанавливает, что поведение программы не определено, если Вы осуществляете доступ к volatile-объекту через обычный (на не volatile объект) указатель.

Вывод: никогда не используйте противоречивых квалификаций типов, не пренебрегайте предупреждениями компилятора, касающихся типов, и избегайте приведения типов - как явных, так и неявных. Также не подавляйте соответствующие предупреждения компилятора и не игнорируйте их. Если переменная декларирована как volatile, то все доступы к ней, прямые или косвенные, должны быть через переменные volatile и указатели на volatile.

Теперь мы перейдем к проблеме, которую даже некоторые эксперты в программировании встраиваемых систем и семантике C понимают превратно.

Вопрос следующий: что все-таки было не так с предыдущими исправленными примерами кода C, где мы добавили volatile к хендлу регистра TCNT1 и флагу done? Ответ эксперта, в зависимости от того, кому Вы верите, будет либо "все в порядке, проблем нет" либо "компилятор может перетасовать операции так, что в результате получите на выходе испорченный код".

Одна из философий работы C состоит в том, что могут не переместить доступы к глобальным переменные вокруг доступов к volatile-переменным. Кажется, что есть непротиворечивое чтение стандарта, который поддерживает это. Проблема с этим чтением в том, что важные компиляторы основываются на различной интерпретации, которая говорит им, что доступы к не-volatile объектами может быть произвольно перемещаться вокруг доступов к volatile-объектам.

Приведем простой пример (за авторством Arch Robison):

volatile int ready;
int message[100];
 
void foo (int i)
{
   message[i/10] = 42;
   ready = 1;
}

Назначение функции foo() в сохранении значения в массив message и установке флага ready, так чтобы другое прерывание или поток могли увидеть это значение. Из этого кода компиляторы GCC, Intel CC, Sun CC и Open64 сгенерируют код, очень похожий на следующий:

; $ gcc -O2 barrier1.c -S -o -
foo:
   movl  4(%esp), %ecx
   movl  $1717986919, %edx
   movl  $1, ready
   movl  %ecx, %eax
   imull %edx
   sarl  $31, %ecx
   sarl  $2, %edx
   subl  %ecx, %edx
   movl  $42, message(,%edx,4)
   ret

Очевидно это не соответствует намерению программиста, поскольку флаг сохраняется перед тем, как значение будет записано в массив. В текущей версии компилятора LLVM такого переупорядочивания не происходит, но скорее всего работа кода лишь вопрос шанса (повезет/не повезет), а не конкретного дизайна этого кода. Некоторое количество компиляторов для встраиваемых систем могут отклонить определенный выбор упорядочивания кода, чтобы предпочесть эффективность безопасности. Ходят слухи (но автор не проверял), что свежие компиляторы C/C++ от компании Microsoft также весьма консервативны с организацией доступа к volatile-переменным. Это возможно правильный выбор, но не помогает людям писать портируемый код.

Один из способов исправить проблему - декларировать message как volatile-массив. Стандарт C однозначен в том плане, что побочный эффект работы volatile не должен менять последовательность точек доступа, так что это будет работать. С другой стороны, добавление большего количества квалификаторов volatile может подавить интересные оптимизации где-нибудь в другом месте программы. Не было ли хорошо принудительно упорядочить обращение к данным в памяти только в выбранных местах программы, вместо того чтобы во всех местах обращаться к переменным как volatile?

Конструкция, которая нам нужна, это "барьер компилятора" (compiler barrier, иногда это называют барьером памяти). Стандарт C не предоставляет этого, но многие компиляторы предоставляют. Например, GCC и достаточно совместимые компиляторы (включая LLVM и Intel CC) поддерживают барьер памяти, который выглядит примерно так:

asm volatile ("" : : : "memory");

Это грубо имеет значение "это встраиваемый ассемблерный код (inline assembly code), несмотря на то, что здесь нет ни одной инструкции, можно читать или записывать всю RAM". Эффект от этого такой, что компилятор сбрасывает все регистры в RAM перед барьером и перезагружает их из RAM после. Кроме того, перемещение кода вокруг барьера не разрешается ни в одном из направлений. В основном барьер компилятора относится к оптимизирующему компилятору, как барьер памяти к процессору, выполняющему код неупорядоченно.

Мы может использовать барьер так, как показано в следующем примере:

volatile int ready;
int message[100];
 
void foo (int i)
{
  message[i/10] = 42;
  asm volatile ("" : : : "memory");
  ready = 1;
}

Теперь вывод компилятора будет такой, как мы хотели:

foo:
   movl  4(%esp), %ecx
   movl  $1717986919, %edx
   movl  %ecx, %eax
   imull %edx
   sarl  $31, %ecx
   sarl  $2, %edx
   subl  %ecx, %edx
   movl  $42, message(,%edx,4)
   movl  $1, ready
   ret

Как быть с компиляторами, которые не могут поддерживать барьеры памяти? Одно из плохих решений - надеяться на то, что компилятор недостаточно агрессивен, чтобы в целях оптимизации перетасовать код каким-то вредоносным способом. Другое плохое решение - вставить вызова к внешней функции, где Вы поместили бы барьер. Поскольку компилятор не знает о том, как будет изменена память в этой функции, то это может дать эффект наподобие барьера. Лучшее решение - обратиться к поставщику компилятора, чтобы он исправил проблему, и пока предложил какое-нибудь обходное решение.

Упорядочение кода для микроконтроллеров AVR также обсуждается в статье [4].

Вывод: большинство компиляторов могут и будут (в целях оптимизации) переносить доступы к не-volatile объектам вокруг доступов к volatile объектам, так что не надейтесь на то, что компилятор будет всегда уважать упорядоченное выполнение операторов кода в программе.

Ранее мы обсуждали случай, когда volatile было использовано для того, чтобы сделать переменную видимой для конкурентно запускаемых вычислений (к примеру, код основной программы в функции main и код ISR). Это было - в ограниченных обстоятельствах - допустимой реализацией. С другой стороны, это никогда не допустимо использовать volatile в целях достижения атомарности.

Иногда удивительно для языка системного программирования, что C не предоставляет гарантии атомарности для своих операций над памятью, независимо от того, к каким объектам осуществляется доступ - к volatile или нет. Однако обычно некоторые отдельные компиляторы дают такую гарантию как "выровненные доступы к переменным размером в слово будут атомарными".

Во многих случаях Вы используете блокировки, чтобы получить атомарность. Если Вам повезло, то у Вас есть доступ к хорошо разработанным блокировкам, которые содержат барьеры компилятора. Но если Вы программируете реальное железо на встраиваемом процессоре (микроконтроллере), то можете быть не столь удачливы. Если бы Вы должны были создать свои блокировки, было бы мудро добавить барьеры компилятора. Например, старые версии TinyOS для микроконтроллеров AVR используют эти функции для захвата и освобождения блокировки глобальных прерываний:

char __nesc_atomic_start (void)
{
   char result = SREG;
   __nesc_disable_interrupt();
   return result;
}
 
void __nesc_atomic_end (char save)
{
   SREG = save;
}

Так как эти функции могут быть (и обычно это так) встраиваемыми (inline), то всегда есть возможность для компилятора переместить код из критической секции. Авторы статьи [1] поменяли эти блокировки так:

char__nesc_atomic_start(void)
{
   char result = SREG;
   __nesc_disable_interrupt();
   asm volatile("" : : : "memory");
   return result;
}
 
void __nesc_atomic_end(char save)
{
   asm volatile("" : : : "memory");
   SREG = save;
}

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

Вывод: volatile ничего не делает для достижения атомарности. Используйте блокировки.

Volatile дает весьма ограниченную пользу на машинах, которые активно используют кэширование выполнения инструкций с переупорядочиванием, конвейер кода (т. е. не гарантируется, какая часть кода выполнится раньше по времени, какая позже), в мультипроцессорных системах и т. п. Проблема состоит в том, что доступ volatile принуждает компилятор генерировать операции с памятью на реальной машине, хотя эти загрузки и сохранения сами по себе не принуждают аппаратуру процессора придерживаться определенного поведения. Весьма желательно найти и использовать хорошие блокирующие примитивы. Рассмотрим функцию поворота-разблокировки на ARM-порте Linux:

static inline void arch_spin_unlock (arch_spinlock_t *lock)
{
   smp_mb();
   __asm__ __volatile__("str %1, [%0]\n" : : "r" (&lock->lock), "r" (0) : "cc");
}

Перед разблокировкой выполняется smp_mb(), которое сводится к чему-нибудь наподобие следующего:

__asm__ __volatile__ ("dmb" : : : "memory");

Это одновременно и барьер компилятора, и барьер памяти.

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

Эта проблема пересекается с предыдущими двумя (6, 7), но на ней достаточно важно акцентировать внимание. Arch Robison говорит, что volatile почти бесполезно для многопоточного программирования [6]. И он прав. Если у Вас есть потоки, то тогда у них должны быть блокировки, и Вы должны обязательно их использовать. В правильно синхронизированном коде можно добиться замечательных результатов - где к общим переменным всегда происходит доступ из критических секций кода - выполнение произойдет последовательно, не противоречивым способом (при условии, что блокировки правильно реализованы, и об этом Вам не стоит беспокоиться). Это означает, что если Вы используете блокировки потоков, то не нужно беспокоиться о барьерах компилятора, барьерах системной памяти и даже вообще об volatile. Ни один из этих вопросов больше не важен.

Выводы: чтобы написать корректно работающий многопоточный код, Вам нужны примитивы для предоставления (как минимум) атомарности доступа к переменным и видимости переменных. На современной аппаратуре не удается использовать volatile. Всегда, когда есть такая возможность, пишите правильно синхронизированный многопоточный код. Идиом с двойной проверкой блокировок лучше избегать.

Компиляторы не всегда надежны в своих трансляциях доступа к объектам, которые квалифицированы как volatile. Автор подробно описал пример в другом месте [7], здесь только быстрый пример:

volatile int x;
 
void foo (void)
{
   x = x;
}

Правильное поведение реальной машины в этом коде определено однозначно: здесь сначала должна произойти загрузка из переменной x, затем сохранение в неё. Однако порт GCC для процессора MSP430 ведет себя по-другому:

; $ msp430-gcc -O vol.c -S -o -
foo:
   ret

Сгенерированная функция ведет себя как nop (нет операции), и это неправильно. Обычно компиляторы, основанные на gcc 4.x в большинстве случаев корректно обрабатывают volatile, как и текущие версии LLVM и Intel CC. Версии gcc до релиза 4.0 имеют проблемы, как и некоторое количество других компиляторов.

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

[Как решается проблема в сообществе Linux?]

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

1. Linux часто запускается в многоядерной среде с неупорядоченным выполнением инструкций, где volatile само по себе близко к бесполезности.

2. Ядра Linux предоставляют богатую коллекцию функций для синхронизации и доступа к аппаратуре, которые при правильном использовании устраняют необходимость использования volatile в обычном коде ядра.

Если Вы пишете код для встраиваемых процессоров (микроконтроллеров), у которых гарантируется упорядоченное выполнение ассемблерных инструкций, и у которых бедная или отсутствующая инфраструктура поддержки компилятора C, то может понадобиться больше использовать volatile.

[Общие выводы]

Оптимизирующие компиляторы настолько хитры, что можно судить о них как о процессорах, беспорядочно выполняющих входной код. Кроме того, стандарт C содержит довольно много очень темных (слабо определенных) мест. Квалификатор volatile делает попытку приблизить абстрактный код к реально выполняемому и устранить все эти сложности в одном месте, обеспечивая более актуальное взаимодействие отдельных частей кода. Кроме того, это дает намного меньшие гарантии, чем люди иногда предполагают. Требуется тщательное продумывание и анализ кода при использовании volatile, когда требуется создать надежное программное обеспечение.

Счастливы те программисты кода уровня пользователя (т. е. пишущие не системный код, и не код драйверов), которые могут просто писать код C и C++, без всякого использования volatile. Кроме того, подавляющее большинство программистов кода режима ядра редко когда будут нуждаться в применении volatile. Ключевое слово volatile главным образом нужно тем людям, кто работает с реальным железом; например, для написания кода встраиваемых микроконтроллерных систем или портирования кода операционной системы на новую платформу.

Об авторе статьи [1]: John Regehr, профессор Computer Science, университет Юты в США, имеет опыт программирования компьютеров 26 лет и встраиваемых систем 17 лет. Последние 8 лет преподавал операционные системы, встраиваемые системы, компиляторы и читал связанные с этими темами курсы для студентов старших курсов и аспирантов. Автор попытался рассказать о volatile, и у него есть достаточно хорошее представление о том, что люди обычно делают неправильно при использовании этой возможности языка C. В феврале 2010 года автор прочитал несколько лекций в RWTH Aachen (Рейнско-Вестфальский технический университет Ахена), включая несколько часов, посвященных неправильному использованию квалификатора volatile. Эта статья восполняет материалы тех лекций.

[Ссылки]

1. Nine ways to break your systems code using volatile site:blog.regehr.org.
2. Programming languages - C site:open-std.org.
3. Как использовать ключевое слово volatile на языке C.
4. AVR GCC: оптимизация и проблема перетасовки кода.
5Should volatile Acquire Atomicity and Thread Visibility Semantics? site:open-std.org.
6Volatile: Almost Useless for Multi-Threaded Programming.
7Volatiles Are Miscompiled, and What to Do about It site:cs.utah.edu.

 

Добавить комментарий


Защитный код
Обновить

Top of Page