Программирование AVR Как использовать ключевое слово volatile на языке C Tue, November 21 2017  

Поделиться

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

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


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

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

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

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

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

Ключевое слово 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 = /* тут адрес регистра */;), даст компилятору инструктаж от том, что эти строки при оптимизации выбрасывать никак нельзя.

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

Некоторые компиляторы позволяют Вам неявно декларировать все переменные как 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;

[Ссылки]

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

 

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


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

Top of Page