Ключевое слово volatile
Эта статья перевод этой статьи
Volatile
Вам когда-нибудь приходилось встречаться с подобными проблемами?
- Код, который отлично работал, пока вы не включили оптимизацию
- Код, который работал отлично, пока вы не начали использовать прерывания
- Чудные левые драйвера
- RTOS таски, которые работают в изоляции, но валятся, как только появляется новый процесс
Если ваш ответ «да», то скорее всего вы не использовали служебное слово volatile в коде.
Вы не одиноки. Большинство программистов с трудом понимают приминение volatile. К сожалению, почти во всех книгах по си о volatile переменных рассказывается в паре предложений.
Служебное слово volatile в языке си используется при объявлении переменных. Оно указывает компилятору, что данная переменная может быть изменена в любое время, без каких либо действия со стороны кода, окружающего эту переменную. Последствия этого достаточно серьёзные. Но для начала, давайте рассмотрим синтаксис.
Синтаксис
Для объявления volatile переменной поместите служебное слово volatile до или после имени типа. К примеру, эти два объявления идентичны.
volatile int foo; int volatile foo;
Довольно часто в программах используются указатели на volatile переменные, особенно для работы с портами ввода-вывода. К примеру, обе строчки кода объявляют указатель на беззнаковую volatile 8-битную переменную:
volatile uint8_t * pReg; uint8_t volatile * pReg;
Volatile указатели на не-volatile переменные используются редко, но вот синтаксис:
int * volatile p;
Ну и для полноты, если вам нужен volatile указатель на volatile переменную
int volatile * volatile p;
Между прочим, более полное объяснение того, почему volatile ставится после типа переменной (например, int volatile * foo) можно прочитать в колонке Dan Sak "Top-Level cv-Qualifiers in Function Parameters" (Embedded Systems Programming, February 2000, p. 63).
И наконец, если вы используете volatile для объявления экземпляра структуры или объединения, то всё содержимое структуры/объединения становится волатильным. Если такое поведение вам не нужно, то применяйте volatile к отдельным полям структуры/объединения.
Правильное использование служебного слова volatile
Переменная должны быть объявлена volatile, если она может быть внезапно изменена. На практике. Таким образом могут измениться только три типа переменных
- Регистры, отображаемые на память
- Глобальные переменные, изменяемые сопрограммой прерывания
- Глобальные переменные, изменяемые в многопоточной среде
Каждый из этих случаев рассмотрим в отдельной главе нашего повествования
Периферийные регистры
Встраиваемые системы включают в себя железо, обычно с довольно сложной периферией. Эти периферийные устройства могут менять состояние своих регистров асинхронно с работой программы. Для простого примера, представьте себе 8-битный регистр состояния, которые отображается на адрес 0x1234. Требуется проводить поллинг состояния до тех пор, пока в регистре не станет нулевое значение. Наивное, неправильное решение будет выглядеть так
uint8_t * pReg = (uint8_t *) 0x1234; // Wait for register to become non-zero while (*pReg == 0) { } // Do something else
Этот код почти со стопроцентной вероятностью отвалится после включения оптимизации, так как компилятор сгенерирует примерно такой код:
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
Мы достигли нужного нам результата.
Более тонкие проблемы возникают с регистрами со специфическими свойствами. К примеру, у многих периферийных устройств есть регистры, которые обнуляются после того, как их прочитают. Слишком много (или чересчур мало) чтений может привести к довольно неожиданным результатам.
Прерывания
Обработчики прерываний часто изменяют состояние переменных, которое проверяется в основной ветви кода. К примеру, обработчик прерываний на последовательном порту проверяет каждый новый символ на равенство EXT (скорее всего, он сигнализирует об окончании сообщения). Если символ равен EXT, то ISR выставляет глобальный флаг. Неверная реализация
int etx_rcvd = FALSE; void main() { ... while (!ext_rcvd) { // Wait } ... } interrupt void rx_isr(void) { ... if (ETX == rx_char) { etx_rcvd = TRUE; } ... }
Если оптимизации компилятора выключены, то такой код может работать. Тем не менее, даже плохонький компилятор сломает код. Дело в том, что компилятор понятия не имеет, что переменная ext_rcvd может быть изменена в ISR (так как функция rx_isr нигде в коде не вызывается явно. прим. пер.). Компилятор решит, что значение !ext_rcvd всегда истина, и выйти из цикла невозможно. Следовательно, весь код после цикла может быть удалён за ненадобностью. Если повезёт, то компилятор пожалуется на этот участок кода. Если не повезёт (или вы легкомысленно относитесь к предостережениям компилятора) – ваш код с треском провалится. Естественно, во всём будет виноват «отвратительный оптимизатор».
Решение – объявить переменную ext_rcvd волатильной. Тогда все ваши проблемы (ну точно часть из них) исчезнут.
Многопоточные приложения
Несмотря на наличие очередей, пайпов и других поддерживаемых планировщиком способов взаимодействия в операционных системах реального времени, всё ещё довольно часто таски передают друг-другу информацию через общую область памяти (таким образом, глобальную). Даже если вы используете вытесняющий планировщик, компилятор ничего не знает о том, что такое переключение контекста, или когда оно может произойти. Таким образом, проблема любого таска, изменяющего общую глобальную переменную, принципиально не отличается от рассмотренной ранее проблемы с прерываниями. Таким образом, все глобальные переменные должны быть объявлены volatile. К примеру, этот код нарывается на неприятности:
int cntr; void task1(void) { cntr = 0; while (cntr == 0) { sleep(1); } ... } void task2(void) { ... cntr++; sleep(10); ... }
Этот код скорее всего перестанет работать как надо, после включения оптимизаций. Объявление cntr волатильной правильный способ решить проблему.
Финальные размышления
Некоторые компиляторы позволяют вам неявно все переменные объявлять волатильными. Не впадите в искушение: по существу, это просто способ отключить мозг. Кроме того, код станет менее эффективным.
Также боритесь с искушением ругать компилятор и отключить оптимизатор. Современные оптимизирующие компиляторы настолько совершенны, что сложно вспомнить хоть один баг, вызванный их неправильной работой. А вот плохую работу программистов, не умеющих пользоваться volatile переменными, я встречаю угнетающе часто.
Если вам попался кусок странно работающего кода, погрепайте volatile в тексте программы. Если grep ничего не вернул, то примеры из этой статьи хорошая точка для старта.
