Как использовать ключевое слово volatile на языке C Печать
Добавил(а) microsin   

Правильное использование ключевого слова volatile языка C многие программисты понимают плохо. Проблема уже никого не удивляет, так как большинство текстов на C пропускают применение volatile. Эта статья научит Вас правильно использовать volatile (перевод статьи [1]).

Знакомы ли Вы с следующими ситуациями в Вашем коде C или C++ встраиваемых систем?

• Код работает хорошо - пока не будут разрешены оптимизации для компилятора.
• Код работает хорошо - пока не будут разрешены прерывания.
• Диковато работающие аппаратные драйвера.
• Задачи RTOS хорошо работают в изоляции - пока не будет порождена некоторая другая задача.

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

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

• Для обращения к регистру процессора, или регистру периферийного устройства, отображенному на адресное пространство как ячейка памяти (memory-mapped register, MMR). Подразумевается, что этот регистр может быть модифицирован асинхронно, при возникновении каких-либо событий, генерируемых аппаратно.
• Для данных, доступ к которым осуществляется с помощью DMA (Direct Memory Access, прямой доступ к памяти).
• Для объектов данных (например, глобальных переменных), которые изменяются в одном из обработчиков прерываний аппаратуры.

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

[Синтаксис ключевого слова volatile]

Чтобы декларировать переменную как volatile, добавьте ключевое слово volatile перед или после типа данных в определении переменной. Например, ниже приведены равноценные определения переменной foo как целого числа со свойством volatile:

volatile int foo;
int volatile foo;

То же самое относится и к указателям на volatile-переменные, особенно связанным с отображенными на память регистрами ввода/вывода (memory-mapped I/O registers). Обе эти декларации одинаково определяют pReg как указатель на беззнаковое 8-битное целое число (байт) со свойством volatile:

volatile uint8_t * pReg;
uint8_t volatile * pReg;

Volatile-указатели на не-volatile данные встречаются очень редко, но стоит все же упомянуть и такой синтаксис, как возможный:

int * volatile p;

И просто для полноты, если Вы вдруг на самом деле должны получить volatile-указатель на volatile-переменную, то напишете это так:

int volatile * volatile p;

Примечание: если хотите получить полное объяснение, где и как лучше размешать volatile (например, почему лучше после типа, вот так: int volatile * foo), прочитайте колонку "Top-Level cv-Qualifiers in Function Parameters" автора Dan Sak (Embedded Systems Programming, февраль 2000, страница 63). Также советую прочитать замечательную статью [2].

И наконец, если Вы применили volatile к структуре (struct) или объединению (union), то все содержимое (все поля) структуры/объединения получит свойства volatile. Если Вам не нужно такое поведение, то следует прицепить квалификатор volatile к отдельным полям (членам) структуры/объединения.

[Правильное использование ключевого слова volatile]

Переменная должна быть декларирована как volatile всякий раз, когда возможно её неожиданное изменение. На практике это могут быть только 3 типа таких переменных:

1. Отображенные на память регистры процессора или его периферийного устройства (memory-mapped peripheral registers).
2. Глобальные переменные, модифицируемые в коде обработчика прерывания (interrupt service routine, ISR).
3. Глобальные переменные, к которым обращаются разные задачи в многопоточном приложении (обычно имеются в виду разные потоки RTOS).

Рассмотрим подробнее каждый из этих трех случаев.

Регистры периферийных устройств процессора. Встраиваемые системы на микроконтроллерах содержат реальное аппаратное оборудование, обычно это различные интерфейсы для обмена данными с внешним миром (UART, SPI, I2C и т. д.). Это так называемые периферийные устройства, и они содержат регистры, значение которых может асинхронно поменяться (асинхронно по отношению к выполняемому коду) в любой момент времени. Как очень простой пример, рассмотрим 8-битный регистр статуса, отображенный на адрес памяти 0x1234. Программа должна опрашивать этот регистр в ожидании, когда он станет не равным 0. Наивная и некорректная реализация будет такой:

uint8_t * pReg = (uint8_t *) 0x1234;
 
// Ожидание, когда регистр станет не равным 0:
while (*pReg == 0) { } // (в цикле ожидания могут быть и другие действия)

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

   mov   ptr, #0x1234
   mov   a, @ptr
loop:
   bz    loop

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

uint8_t volatile * pReg = (uint8_t volatile *) 0x1234;

Теперь код ассемблера станет таким:

   mov   ptr, #0x1234
loop:
   mov   a, @ptr
   bz    loop

Требуемое поведение программы достигнуто.

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

Обработчики прерываний. Обработчики прерываний (interrupt service routines, ISR) часто устанавливают переменные, которые проверяются в основном коде (работающем вне прерывания) или в коде другого прерывания. Например, ISR последовательного порта может проверять каждый принятый символ на предмет появления символа ETX (предположительно обозначающего конец сообщения). Если встретился символ ETX, то ISR может установить некий глобальный флаг. Некорректная реализация может быть следующей:

int etx_rcvd = FALSE;
 
void main() 
{
    ... 
    while (!ext_rcvd) 
    {
        // Wait
    } 
    ...
}
 
interrupt void rx_isr(void) 
{
    ... 
    if (ETX == rx_char) 
    {
        etx_rcvd = TRUE;
    } 
    ...
}

Когда оптимизация компилятора выключена (так обычно поступают при отладке), этот код может работать. Однако практически любой более-менее достойный оптимизатор "поломает" этот код. Проблема в том, что компилятора нет никакой информации о том, что переменная etx_rcvd может быть изменена в ISR. Пока компилятор считает так, то для него выражение !ext_rcvd всегда истина, так что цикл никогда не завершится. Следовательно, весь код после тела цикла оптимизатором может быть просто удален оптимизатором. Если Вам повезет, то компилятор сообщит об этом. Если не повезет (или Вы еще не научились обращать внимание на сообщения компилятора), то работа кода будет нарушена. Естественно, вина сразу будет возложена на "паршивый оптимизатор".

Решением в этой ситуации будет объявить переменную etx_rcvd как volatile. Тогда все Ваши проблемы (или по крайней мере проблемы, связанные с этим случаем) исчезнут.

Многопоточные приложения. Несмотря на наличие очередей (queues), каналов (pipes), и других механизмов обмена в многопоточном окружении (RTOS), все равно обычной практикой остается обмен информацией между двумя потоками с помощью общей памяти (глобальной переменной). Даже когда Вы добавите вытесняющий планировщик в свой код, у компилятора все равно нет никаких предположений, что сработает переключение контекста, и что это может произойти. В этом случае, когда другая задача модифицировала общую глобальную переменную, может произойти то же самое, как и с обработчиком прерывания, что уже обсуждалось выше. Как и в предыдущем примере, общая переменная должна быть объявлена как volatile. Например, здесь могут быть проблемы в коде при доступе к общей переменной cntr из разных задач:

int cntr;
 
void task1(void) 
{
    cntr = 0; 
    
    while (cntr == 0) 
    {
        sleep(1);
    } 
    ...
}
 
void task2(void) 
{
    ...
    cntr++; 
    sleep(10); 
    ...
}

Этот код скорее всего не будет работать, когда оптимизация будет разрешена. Декларирование cntr как volatile будет правильным путем решения проблемы.

Доступ к отображаемым на память регистрам (memory-mapped register, или MMR) осуществляется с помощью указателей. Здесь приведен еще один пример, где были проблемы с отсутствием ключевого слова кода C для микроконтроллера PIC. Код должен делать перестановку содержимого между двумя периферийными регистрами:

temp = *pRegisterA;
*pRegisterA = *pRegisterB;
*pRegisterB = temp;

Здесь pRegisterA и pRegisterB указывали на некоторые адреса в памяти, где расположены регистры MMR.

Поскольку данные, на которые указывали pRegisterA и pRegisterB, нигде больше не использовались в коде, оптимизатор компилятора полностью выкидывал этот код. Изменение декларации этих указателей как указателей на volatile int (т. е. как uint8_t volatile * pRegisterA = /* тут адрес регистра */;), даст компилятору инструктаж от том, что эти строки при оптимизации выбрасывать никак нельзя.

Пример из жизни - процессор Blackfin ADSP-BF538F, компилятор VisualDSP. Столкнулся с необходимостью присвоить статус volatile для переменной twistate, иначе неправильно работал цикл ожидания while (twistate != TWI_IDLE){}.

Алгоритм работы программы следующий: есть обработчик таймера, который в случае установки переменной twistate = TWI_STOP делает определенные действия по остановке TWI и меняет переменную twistate = TWI_IDLE. Основная программа синхронизируется с обработчиком прерывания, ожидая момента установки twistate == TWI_IDLE в бесконечном цикле while (twistate != TWI_IDLE){}.

// Состояния обработчика прерывания таймера для
// переменной twistate:
#define TWI_IDLE        0
#define TWI_RUNNING     1
#define TWI_STOP        2
 
/*volatile*/ u8 twistate = TWI_IDLE;
 
// Обработчик прерывания таймера 0, который срабатывает
// с частотой 20 кГц.
EX_INTERRUPT_HANDLER(Timer0ISR)
{
   *pTIMER_STATUS = TIMIL0;
   ...
   switch (twistate)
   {
   case TWI_IDLE:
      break;
   case TWI_RUNNING:
      LED(1);
      // Запись одной выборки в ЦАП:
      ...
      LED(0);
      break;
   case TWI_STOP:
      // Остановка таймера:
      ...
      twistate = TWI_IDLE;
      break;
   }
}
 
void DAC_stop (void)
{
   twistate = TWI_STOP;
   while (twistate != TWI_IDLE){}
}

Листинг дизассемблера процедуры DAC_stop, когда переменная twistate без атрибута volatile:

               DAC_stop
[FFA00964]     LINK 0x0;
// twistate = TWI_STOP;
[FFA00968]     P1.L = 0x027c;
[FFA0096C]     P1.H = 0xff80;
[FFA00970]     R0 = 2;
[FFA00972]     B [ P1 ] = R0;
// while (twistate != TWI_IDLE){}
[FFA00974]     CC = R0 == 0;
[FFA00976]     IF CC JUMP 4   /* 0xFFA0097A */
[FFA00978]     JUMP.S -4      /* 0xFFA00974 */
[FFA0097A]     UNLINK;
[FFA0097E]     RTS;

Листинг дизасемблера процедуры DAC_stop, когда переменная twistate с атрибутом volatile:

               DAC_stop
[FFA00964]     LINK 0x0;
// twistate = TWI_STOP;
[FFA00968]     P1.L = 0x027d;
[FFA0096C]     P1.H = 0xff80;
[FFA00970]     R0 = 2;
[FFA00972]     B [ P1 ] = R0;
// while (twistate != TWI_IDLE){}
[FFA00974]     R0 = B [ P1 ] ( Z );
[FFA00976]     CC = R0 == 0;
[FFA00978]     IF CC JUMP 4   /* 0xFFA0097C */
[FFA0097A]     JUMP.S -6      /* 0xFFA00974 */
[FFA0097C]     UNLINK;
[FFA00980]     RTS;

В показанных листингах дизассемблера Blackfin хорошо видно, почему не срабатывает выход из цикла while (twistate != TWI_IDLE){}, когда переменная twistate без атрибута volatile. Значение twistate проверяется через регистр R0, и когда переменной twistate присвоен статус volatile, то регистр R0 в цикле while постоянно обновляется свежим значением переменной (жирным шрифтом выделена дополнительная строка, которая обновляет регистр R0). Если же переменная twistate без атрибута volatile, то обновление регистра в цикле R0 не происходит: переменная twistate уже давно поменяла значение, а в регистр R0 загружено старое значение twistate, и выхода из цикла while не происходит.

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

[Дополнительные замечания]

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

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

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

Примечание: эта статья была опубликована в июле 2001 года в журнале "Embedded Systems Programming". Если Вы хотите процитировать где-нибудь эту статью, то возможно окажется полезной еще и эта статья: Jones, Nigel. "Introduction to the Volatile Keyword", журнал "Embedded Systems Programming", июль 2001. Подробнее освещение темы volatile см. в статье [2].

[А как быть со структурами?]

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

typedef struct _example_struct
{
  unsigned int STATUS_REG;
  unsigned int INT_MASK_REG;
  unsigned int VERSION_REG;   // имеет жестко фиксированное значение 0xd00d
} example_struct, *Pexample_struct;
 
Pexample_struct Pexs = EXAMPLE_BASE_ADDR;
unsigned int result;
 
result = Pexs->STATUS_REG;

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

В этом примере наилучшей практикой будет следующее определение структуры с регистрами I/O MMR:

typedef struct { ... } volatile newtype_t;
 
newtype_t * const p_newtype = (newtype_t *) BASEADDR;

[Простой пример, где нужен volatile]

В программе ниже показан простой цикл, который должен мигать светодиодом с частотой 1 Гц. Переключение светодиода должно происходить по установку флажка timeout_elapsed, который устанавливает обработчик прерывания таймера по истечении задержки в переменной timeout.

u8 timeout;
bool timeout_elapsed = true;
 
// Этот обработчик переполнения таймера настроен на запуск
// каждую миллисекунду:
ISR(TIMER1_OVF_vect)
{
   if ((!timeout_elapsed) && (0!=timeout))
   {
      if (0==--timeout)
         timeout_elapsed = true;
   }
}
 
int main(void)
{
   Timer1Start();
   sei();
   
   while (1) 
   {
      LED(0);
      timeout = 500;
      timeout_elapsed = false;
while(!timeout_elapsed);
// Если timeout_elapsed не определена как volatile, // то в это место управление никогда не дойдет. LED(1); timeout = 500; timeout_elapsed = false;
while(!timeout_elapsed);
} }

По всем правилам логики светодиод должен исправно погасать и зажигаться через задержку в 500 мс, потому что задержка записывается в переменную timeout, и эта переменная используется в обработчике прерывания как счетчик времени, по истечении которого устанавливается флажок timeout_elapsed. Соответственно цикл задержки while(!timeout_elapsed) должен прерваться.

Однако, когда timeout_elapsed не определена как volatile, выход из цикла задержки задержки while(!timeout_elapsed) никогда не произойдет. Причина в том что оптимизатор gcc точно "знает", что поскольку между действиями сброса флага и его проверки никаких операторов нет (между операторами timeout_elapsed = false и while(!timeout_elapsed) переменная timeout_elapsed не модифицируется), то условие цикла while всегда будет положительным. В результате оптимизатор преобразует оператор while(!timeout_elapsed) в оператор while(true), и цикл получится бесконечным.

Исправить ситуацию можно объявлением переменной timeout_elapsed как volatile:

volatile bool timeout_elapsed = true;

Определение volatile даст понять компилятору, что переменная timeout_elapsed может в любой момент поменяться, потому что она используется где-то еще, и в результате цикл в while(!timeout_elapsed) останется нормальная проверка переменной.

[Ссылки]

1. How to Use C's volatile Keyword site:barrgroup.com.
2. 9 способов испортить код с помощью volatile.