В комментариях к статье ""Должен, но не обязан". Или немного о сложности сосуществования машииного и реального миров. На примере компиляторов." читатель Дмитрий Константинович Завалишин поставил мне двойку "за незнание ключевого слова volatile". Однако, двойку нужно поставить именно ему. За нежелание разобраться и шаблонный совет, который основывается на недостаточном знании и понимании сути вопроса. Как минимум, он не прочитал статьи, ссылки на которые там даны. И которые собственно и послужили причиной ее написания. И про пару тонкостей, связанных с модификатором volatile, в тех статьях как раз говорится.
Поскольку, к сожалению, шаблонный подход встречается часто, а уверенность в том, что volatile спасает в любых ситуациях с независимо изменяющимися переменными весьма распространена, но зачастую основана на недостаточном понимании, я и решил рассказать обо все немного подробнее. Вопреки расхожему мнению volatile вовсе не является "серебрянной пулей", это лишь инструмент, который нужно понимать и применять правильно.
Шаблонный подход может быть опасен! Или небольшая преамбула
Шаблоны существовали всегда, и будут существовать всегда. Они действительно упрощают жизнь и сокращают затраты сил и средств. Но использовать шаблоны нужно четко понимая границы применимости и суть заложенных в конкретном шаблоне решений. Причем это касается всех областей жизни, а не только высоких технологий и науки.
Пример того, к чему может привести бездумное применение шаблонного решения при разработке электронных схем я приводил в статье "Ошибки бывают и в промышленных системах". В том случае исключительная похожесть выходного каскада на таковой в двухтактных усилителях мощности низкой частоты начисто отключила у разработчиков даже попытку тщательного анализа и критического осмысления примененного решения.
Есть масса аналогичных примеров и в разработке программного обеспечения. Причем широкое распространение, например, STL в С++ имеет не только положительные, но и отрицательные стороны. Сложность ее внутренней структуры приводит к тому, что многие разработчики просто используют тот или иной шаблон, даже не пытаясь проанализировать. Но сегодня мы будем говорить не о STL, а о простом модификаторе volatile.
Что такое volatile?
Это модификатор типа объекта, точно такой же, как const. И в стандарте языка они даже описываются совместно. Но обозначают они прямо противоположные свойства объекта. const определяет, что объект не может измениться, а volatile определяет, что объект может измениться в любой момент времени, причем независимым от программы способом.
Давайте обратимся к официальным документам:
- ISO/IEC 14882:1998(E) Programming languages — C++
- ISO/IEC 9899:TC2 Committee Draft — May 6, 2005
- ANSI/IS0 9899-l 990 (revision and redesignation of ANSI X3.1 59-I 989) American National Standard for Programming Languages - C
Здесь мы можем найти такое описание семантики volatile
An object that has volatile-qualified type may be modified in ways unknown to the implementation or have other unknown side effects. Therefore any expression referring to such an object shall be evaluated strictly according to the rules of the abstract machine. as described in 5.1.2.3. Furthermore. at every sequence point the value last stored in the object shall agree with that prescribed by the abstract machine, except as modified by the unknown factors mentioned previously. What constitutes an access to an object that has volatile-qualified type is implementation-defined.
Другими словами, объект может измениться любым способом, в любой момент времени, по независимым от программы причинам. Кроме того, здесь есть очень важное уточнение - доступ к "изменчивым объектам" определяется реализацией конкретной системы (implementation-defined). Вот этот момент мы запомним, так как он нам еще потребуется.
Там же можно найти и такое уточнение:
volatile is a hint to the implementation to avoid aggressive optimization involving the object because the value of the object might be changed by means undetectable by an implementation.
То есть, модификатор volatile это лишь подсказка для реализации, что бы исключить агрессивную оптимизацию работы с объектом.
Но что же за абстрактная машина и последовательные точки, о которых говорится в описании семантики модификатора? А вот что это такое
The semantic descriptions in this International Standard define a parameterized nondeterministic abstract machine. This International Standard places no requirement on the structure of conforming implementations. In particular, they need not copy or emulate the structure of the abstract machine. Rather, conforming implementations are required to emulate (only) the observable behavior of the abstract machine as explained below.
То есть, все описания в стандарте действительно даются для некоторой абстрактной машины (ЭВМ), внутренняя структура и поведение которой определены не полностью (я не буду приводить весь текст описания этой машины). Конкретные реализации (компилятор и среда времени выполнения) должны эмулировать поведение этой абстрактной машины в той части, когда оно четко определено. Если поведение объявлено как "неопределенное" или "определяемое реализацией", то и поведение конкретной реализации может быть любым.
Accessing an object designated by a volatile lvalue, modifying an object, calling a library I/O function, or calling a function that does any of those operations are all side effects, which are changes in the state of the execution environment. Evaluation of an expression might produce side effects. At certain specified points in the execution sequence called sequence points, all side effects of previous evaluations shall be complete and no side effects of subsequent evaluations shall have taken place.
Поскольку вычисление выражения с объектом являющимся volatile может приводить к побочным эффектам, в определенные моменты времени (certain specified points in the execution sequence), называемые последовательными точками (sequence points) все побочные эффекты предыдущего вычисления должны быть завершены, и не должны влиять текущее вычисление. Кажется немного сложным? На самом деле все довольно просто.
At sequence points, volatile objects are stable in the sense that previous evaluations are complete and subsequent evaluations have not yet occurred.
В точках вычисления (оценки) "изменчивые" объекты являются стабильными. То есть, предыдущее вычисление уже завершено, а новое еще не началось. Не правда ли, довольно любопытно? Изменчивость и стабильность это разные характеристики поведения.
На самом деле тут все верно. Просто дело в том, что объект может быть комплексным (сложным), а это означает, что обеспечить атомарный (неразрывный) доступ к нему не так просто. Например, размер переменной может превышать разрядность процессора. При этом одна операция с переменной потребует несколько физических доступов к ней. Каждая операция считается последовательной точкой. А данное требование означает, что операции не могут перекрываться по времени. То есть, вся последовательность физических доступов одной операции должна полностью завершиться прежде, чем начнется последовательность физических доступов другой операции.
Не правда ли, модификатор volatile оказался вовсе не так прост, как это часто считается? Давайте посмотрим, как же он в реальности работает, когда полезен, а когда не оказывает никакого влияния.
Как модификатор volatile работает
Как следует из определений стандарта, каждая реализация может устанавливать свои особенности для работы с объектами определенными как volatile. Причем вплоть до полного игнорирования данного модификатора. Давайте сначала посмотрим на основное назначение и применение модификатора.
Исключение оптимизации и предположений о состоянии volatile объекта
Возьмем простейший случай, ожидание установки некоего флага
int flag=0;
while(!flag) {}
Здесь мы сбрасываем флаг, а потом ждем, когда он будет установлен каким то, на зависящим от данного фрагмента, образом. В том виде, как это написано выше, компилятор имеет право решить, переменная flag всегда будет равна 0, а значит проверять ее значение не имеет смысла. И все сведется к просто замене на такое вот
while(1) {}
То есть, на простой бесконечный цикл. Но это ведь совсем не то, чего мы ожидали. Что бы компилятор не занимался самодеятельностью нам потребуется дать ему подсказку
volatile int flag=0;
wile(!flag) {}
Вот теперь мы получим действительно ожидание установки флага, а не бесконечный цикл. И это классический пример использования модификатора volatile. Собственного говоря, именно это и предполагалось при его введении в язык С. Флаг может устанавливаться в обработчике прерывания, параллельно выполняющемся потоке, другой программе, аппаратно.
Вы считаете этот пример слишком примитивным и далеким от практического применения? Тогда посмотрите, как работает АПЦ в микроконтроллерах, например, PIC. В управляющем регистре есть бит, установка которого запускает цикл преобразования. А когда результат готов, бит аппаратно сбрасывается. При этом может быть и сгенерировано прерывание, но этот случай я не буду сейчас рассматривать.
// запускаем цикл преобразования АЦП
ADCON0.GODONE=1;
// ожидаем окончания преобразования
while(!ADCON0.GODONE) {}
// преобразование завершено, результат можно использовать
Другим классическим примером использования volatile является исключение размещения переменной в регистре процессора. Например, у нас есть переменная в памяти, связанная с устройством ввода-вывода (перфолента, АЦП, устройство дискретного ввода, и т.д). Каждое считывание этой переменной одновременно запускает, аппаратно, физическое считывание очередной порции информации (шаг перфоленты, преобразование АЦП, чтение состояния дискретных входов), которая будет доступна при следующем чтении.
Предположим, что нам нужно считать три значения с этого устройства ввода-вывода и вычислить их сумму.
int sum=0;
extern volatile int ext_dev;
sum=ext_dev+ext_dev+ext_dev;
Обратите внимание, здесь недопустимо писать 3*ext_dev! Здесь у нас значение ext_dev каждый раз разное! Именно такую вот замену и запрещает делать компилятору использование модификатора volatile. Если его не использовать, то компилятор имеет полное право считать значение ext_dev только один раз, разместить его в регистре процессора, и просто выполнить два дополнительных сложения с содержимым регистра.
Так же обратите внимание, что три использования переменной ext_dev в этом примере задают и три последовательные точки, о который я упоминал ранее. Три доступа к этой переменной предполагают, что при каждлом из них состояние переменной стабильное, а все сторонние эффекты от предыдущих доступов уже завершены.
Обеспечение немедленной записи изменения состояния переменной
Выше я рассмотрел использование volatile объектов в правой части выражений. То есть, к ним осуществлялся доступ для чтения. А теперь давайте посмотрим на такой фрагмент кода
volatile int sum;
extern volatile int ext_dev;
sum=0;
for(int i=3; i--;) { sum+=ext_dev; }
В данном случае проследим за переменной sum. Предположим, что она связана с внешним регистратором, который печатает записываемое в данную переменную значение (аппаратно). Если мы не будем использовать модификатор volatile, то компилятор имеет полное право предположить, что записать результат в sum можно только один раз, после выполнения все фрагмента кода. В результате, мы увидим на регистраторе только одно число - итоговую сумму. Но мы то хотели уцвидеть и 0, и нарастающий итог при каждой итерации цикла.
А дело в том, что без volatile компилятор имеет право разместить промежуточные значения sum в регистре. Указание volatile как раз и предотвращает такое поведение.
Чтение-модификация-запись
Последний пример демонстрирует еще один момент, а именно использование volatile объекта и с правой, и с левой стороны знака равенства. Вот более простой и наглядный пример
volatile int var;
var+=10;
Это очень простой пример. Что бы было еще нагляднее, можно записать это так var=var+10. Что же тут хитрого? Дело в том, что тут мы опять сталкиваемся с последовательными точками выполнения. У нас два раза требуется доступ к var. Первый раз, справа от знака равенства, когда мы считываем значение var. После этого мы выполняем с ним операцию, в данном случае, сложение. Второй доступ к var состоит в записи результата в var. Это именно две точки, в которых состояние var считается стабильным. Причем именно в таком порядке.
Компилятор имеет абсолютно законное право выполнить изменение var, что называется "на месте". Например так (для некоей абстрактной машины):
add 10,var
А может использовать промежуточное хранилище, например, аккумулятор.
lda var
add 10
sta var
Оба варианта правильные и допустимые! Даже не смотря на то, что между командами lda и sta содержимое var может измениться. Это то самое определяемое реализацией поведение.
Модификатор volatile ни коим образом не гарантирует, и даже не предполагает атомарности доступа к объекту! Другими словами, volatile объект не может быть использован в качестве примитива синхронизации.
Об этом честно сообщается в документации на компиляторы. Для тех, кто ее внимательно читает, конечно. Вот пример из документации XC8
The volatile qualifier does not guarantee that any access will be atomic, which is often not the case since the 8-bit PIC MCU architecture can only access a maximum of 1 byte of data per instruction.
Чуть далее я еще раз затрону этот вопрос. Как раз в части разрядности процессора и размера volatile объекта.
Более того, если объектом является некий аппаратный регистр, то никакие программные ухищрения не смогут обеспечить атомарность доступа. Даже только для чтения. Об это не редко забывают.
Особенности реализации. Или немного о подводных камнях
Об этом обычно пишут в документации на компилятор. Но подробности нередко ускользают при ее невнимательном чтении. Я приведу несколько примеров.
Модификатор volatile игнорируется
Да, компилятор имеет на это право. Но даже тут есть пара нюансов. Во первых, такое игнорирование может означать, что никакая переменная не может быть "изменчивой" в принципе. К счастью, такое поведение все таки очень большая редкость в современном мире.Даже не смотря на то, что модификатор volatile используется относительно редко.
Во вторых, абсолютно все переменные могут считаться volatile. Это не означает отсутствие оптимизации. Просто это такая особенность реализации, рассчитанной на специфическую архитектуру.
Модификатор volatile оказывает влияние в некоторых случаях
Это как раз то случай, о котором я говорил в той статье. В компиляторе Zortech С модификатор оказывал влияние на результат компиляции только тогда, когда программа использовала обработчики прерываний. Причем только для переменных, доступ к которым осуществлялся и из собственно программы, и из обработчика.
Экзотика? Да, но в общем и целом обоснованная. Это были времена безраздельного господства MS-DOS и никто не помышлял о многопоточных программах. Единственным случаем, когда модификатор volatile требовался, с точки зрения разработчика компилятора, была обработка прерываний.
Действие модификатора volatile распространяется не на все типы памяти
В программах используются разные типы памяти, с точки зрения размещения переменных.
Глобальные переменные имеют время жизни равное времени работы программы и доступны из любого ее места. Такие переменные имею. постоянные адреса.
Статические переменные отличаются от глобальных тем, что их область видимости ограничена отдельным блоком или единицей компиляции. Такие переменные тоже имеют постоянные адреса и могут быть доступны из других частей программы через указатели и ссылки.
Локальные переменные ограничены и по области видимости, и по времени жизни. Они не имеют постоянных адресов и обычно размещаются в стеке.
Я не стал здесь упоминать ручное распределение памяти через malloc, realloc, free, new и прочие методы. При этом программе возвращается адрес выделенного блока памяти. Время жизни определяется программистом. Собственно говоря, эту память можно отнести к одному из трех описанных типов.
В общем случае, возможно получить адрес переменной с любым типом размещения (включая локальную) и передать его в другую процедуру обеспечив таким образом доступ. А значит, теоретически, любая переменная может оказаться volatile. Однако, если произойдет завершение блока, в котором объявлена локальная переменная, то и она перестанет существовать.
Таким образом, целесообразность присвоения локальной переменной атрибута volatile меньше, чем глобальной или статической. А значит, компилятор может игнорировать модификатор volatile для локальных переменных, но учитывать его для других вариантов размещения.
Работа с аппаратными ресурсами это всегда особый случай
Я уже немного касался этого вопроса, но еще уделю ему немного внимания. Кажется очевидным тот факт, что содержимое аппаратного регистра может измениться в любой момент времени. Менее очевидно, что содержимое регистра может измениться во время доступа к нему.
Это кажется противоречащим декларациям стандарта, но это не так. Давайте представим такую ситуацию
extern volatile long time_counter;
long local_counter;
local_counter=time_counter;
Кажется, что тут нет никаких подводных камней. Но давайте представим, что наш процессор аппаратно поддерживает только int, а разрядность long в два раза больше. Таким образом, невозможно за один раз выполнить подобную операцию присваивания. Фактически, компилятор будет вынужден преобразовать этот фрагмент в нечто подобное
// local_counter=time_counter
local_counter.high=time_counter.high;
local_counter.low=time_counter.low;
Здесь подразумевается, что high и low это старшая и младшая половины соответствующих переменных. Причем последовательность рисваиванийможет быть и иной, то есть, сначала присваивается младшая половина, а потом старшая.
Если у нас time_counter является аппаратным счетчиком-таймером, который работает сам по себе, асинхронно с выполнением программы, то мы запросто можем получить ситуацию, когда его содержимое изменилось между этими двумя строками. Причем вплоть до распространения переноса через весь счетчик. А значит, результат будет совсем не тем, который мы ожидали.
Вспомните, volatile никоим образом не предполагает атомарности доступа. Поэтому, при работе с аппаратными ресурсами разрядностью выше разрядности процессора (или шины) использование volatile абсолютно не является достаточным. И об этом нужно помнить!
Но самое сложное заключается в том, что такое вот изменение переменной в процессе, казалось бы (и ошибочно!), атомарного доступа, может происходить не всегда, а достаточно редко. Например, при высокой загрузке ЭВМ, о чем я скажу чуть дальше. То есть, может возникнуть очень трудно уловимая ошибка.
Программа выполняется не сама по себе, а в среде операционной системы
Это уже больше из области универсальных ЭВМ, хотя касается и микроконтроллеров иногда. Помните, что операционная система может прервать выполнение вашей программы в любой момент времени и переключиться на совсем другую программу. При этом ваша программа скорее всего не будет уведомлена о данном факте.
А теперь давайте вспомним, что volatile не подразумевает атомарности доступа. А значит, время между последовательными доступами к переменной, с разрядностью превышающей разрядность процессора, будет полностью не предсказуемым.
Другими словами, ваша программа может начать вести себя странно при более выской загрузке процессора (ЭВМ). При этом под подозрение зачастую сначала попадет не собственно код программы, а "память сбоит" или "процессор перегревается". Такие ошибки очень трудно отлавливать.
Кроме того, странности в поведении могут быть замечены и при работе программы под отладчиком. Но тут все бывает проще, так сразу понятно, где искать ошибку.
Кстати, это один из способов защиты программы от работы под отладчиком.
volatile и оптимизация
А вот теперь собственно вернусь к ситуации, описанной в начале статьи. Но теперь уже вам все станет гораздо понятнее. Но напомню, что речь идет о статье ""Должен, но не обязан". Или немного о сложности сосуществования машииного и реального миров. На примере компиляторов." и "двойке" за нее. И покажу, почему именно читатель Дмитрий Константинович Завалишин не прав.
Если внимательно вчитаться в описанные в той статье ситуации, то станет заметно, что обе они на самом деле касаются одного и того же. И это даже не собственно оптимизатор, а ситуация, которая попала под оптимизацию.
Напомню, что в первом случае оптимизатор удалял
(*ptr++)&=0xFF
А во втором досрочно прекращал вычисление
res=A*B*C*D...
Все еще ничего не замечаете? В обоих случаях речь идет об оптимизации вычисления выражения, о его неполном вычислении, если результат в какой то момент времени стал уже известен.
Только в первом случае это явно видно, еще на этапе компиляции, а во втором может проявиться только во ремя выполнения. И именно со второго случая я и начну, как бы странно это не показалось.
Поможет ли тут volatile? Очевидно, что абсолютно не поможет! Совершенно не важно, как будет осуществляться доступ к сомножителям. Важен лишь тот факт, что если один из сомножителей оказался равным нулю, то вычислять выражение дальше смысла не имеет.
Но все таки, почему? Ведь volatile как раз на оптимизацию и влияет. Да, но на оптимизацию работы с объектом, и не более того. А вовсе не на вычисление, и порядок вычисления, выражения.
В первом случае может показаться, что Дмитрий Константинович Завалишин прав. Однако, и здесь он ошибается! Изменится ли что то, если мы объявим, что указатель ptr содержит адрес переменной с модификатором volatile? Нет, не изменится ровным счетом ничего.
Так может, стоит объявить временную переменную, ту, которой присваивается 0xFF, volatile? Да, мысль здравая, и безусловно это было испробовано, хотя в статье я и не упоминал про это. Но именно тогда и выяснился тот факт, что Zortech C этот модификатор игнорировал, раз прерывания не использовались (не использовался специальный ключ, который кроме всего прочего подключал к программе специальный библиотечный модуль). Тогда я поленился прочитать внимательно толстый том (более 500 страниц) документации на новую версию посчитав, что больших изменений быть не должно.
Однако, через некоторое время фирма Zortech была поглощена фирмой Semantec. И очередная выпущенная ей версия компилятора уже была ориантирована на разработку программ для Windows. И та особенность работы с volatile, о которой я говорил, была устранена. Исчезла после этого ошибка? Нет!
Удивлены? А все очень просто на самом деле. Да, компилятор перестал удалять код. Но и выполнять его не стал! Просто при вычислении выражения, в данном случае, поразрядное И, проверялось равенство операндов 0 и FF, так как при этом результат заранее известен. Как вы уже догадались, такая проверка выполняется во время выполнения программы, и уже после доступа к volatile переменной. В результате, выполнение кода обходилось.
О панацеях, серебрянных пулях, универсальных советах, шаблонах и рецептах
Нужно четко понимать, что стандартные решения возможны только в стандартных ситуациях. Легко, совершенно не задумываясь, сказать "делай так, как все!". Системное программирование и встраиваемые системы это далеко не всегда стандартные ситуации.
Не существует ни панацеи, ни серебрянной пули, которые гарантированно спасут в абсолютно любой ситуации! Всегда может оказаться так, что "освященные вековыми традициями" решения не срабатывают. Именно поэтому я не устаю говорить, что прежде всего требуется глубокое понимание сути вопросов, задач, процессов, с которыми работаете.
Внимание к деталям, казалось бы, самым незначительным, часто является ключом к успеху. А бездумное применение шаблонных решений далеко не всегда приносит облегчение разработки, сокращение сроков и затраченных средств.
Я рассказал далеко не обо всех тонкостях связанных с модификатором volatile. Но думаю, что уже понятно, что он не так прост, как может показаться. И совершенно точно не является универсальным средством. Но является очень полезным инструментом.
До новых встреч!