Вы можете встретить и англоязычные варианты этого понятия - Reentrancy или Reenterable. И даже кривоязычное - реентерабельность. Нет, речь не идет о том, что какую то процедуру нельзя вызвать более одного раза. Давайте попробуем разобраться.
Повторная входимость это возможность повторного вызова процедуры до ее завершения. Это не совсем точное и полное определение, но нам его достаточно. Другими словами, процедуру можно вызывать не дожидаясь окончания ее текущего выполнения. Несколько раз.
Зачем такое нужно? Наиболее часто это иллюстрируют на примере рекурсивных вызовов, хотя рекурсия совсем иное понятие. Давайте посмотрим на классический пример - факториал. Вычисление факториала очень удобно выполнять рекурсивно. Вот один из простых вариантов программной реализации
Подобный пример кода можно встретить во многих учебниках программирования. Здесь нам интересно только то, что fact вызывает сама себя. То есть, она получает управление снова еще до того момента, когда предыдущий вызов завершился. А для этого функция должна быть повторно входимой.
Другим примером иллюстрации повторной входимости служит многопоточность. Надо отметить, что понятие повторной входимости появилось задолго до многопоточности. Но многопоточность, как и появление разделяемых библиотек (dll, so), существенно повысили актуальность вопроса повторной входимости.
Давайте посмотрим на простую иллюстрацию
Два потока одной программы выполняются параллельно. В случае однопроцессорной ЭВМ это будет квазипараллельное выполнение, но для нас это не имеет значения. Важно то, что потоки выполняются независимо друг от друга (синхронизацию, например, семафоры, не будем рассматривать), а значит вызов Func может произойти или одновременно, или почти одновременно. Вне зависимости от того, закончила ли Func свою работу для другого потока.
Здесь можно заменить "поток" на "приложение" и мы получим ситуацию, когда Func располагается в разделяемой библиотеке. Отлично, все это может происходить на универсальной ЭВМ, но у нас то разговор о микроконтроллерах. Как все это может касаться нас?
А касается напрямую! Давайте вспомним, что микроконтроллеры поддерживают, за редким исключением, прерывания. А прерывания, аппаратные, возникают асинхронно и могут рассматриваться как параллельный поток.
Разумеется, код обработки прерываний должен быть как можно более коротким и быстрым. И не должен оказывать побочного влияния на работу основной программы. Но иногда приходится писать функции, которые будут вызываться и основной программой и из обработчика прерывания. А это требует, что бы такая функция поддерживала повторную входимость.
Повторная входимость, язык С и размещение в памяти
Код каждой функции существует в единственном экземпляре и его место размещения не влияет на повторную входимость. А вот размещение переменных влияет очень сильно.
Давайте подумаем, что нужно для обеспечения возможности процедуры работать параллельно самой себе? Каждый экземпляр выполняющейся функции должен иметь свой собственный, независящий от других экземпляров набор переменных. И это самое основное требование.
Давайте вспомним, как у нас могут размещаться переменные. Во первых, статически, на этапе компиляции, по постоянным адресам. Во вторых, локальные переменные в стеке. В третьих, динамически размещаемые переменные (например, с помощью malloc/free).
Однако, динамические переменные мы не будем рассматривать как отдельный случай. По той простой причине, что адрес такой динамической переменной все равно будет храниться или в локальной переменной, или в статически размещаемой.
Локальные переменные в стеке создаются для каждого экземпляра, для каждого вызова, процедуры. И они удовлетворяют требованию независимости от выполняющегося экземпляра процедуры.
Глобальные и статические переменные используются совместно всеми процедурами программы. Они не являются независимыми, что создает проблемы для повторной входимости. Но означает ли это, что повторно входимая функция в принципе не может использовать статически размещаемые переменные? Давайте попробуем разобраться.
Этот пример имитирует работу счетчика ограниченной разрядности. Пусть func уже выполняется, причем управление находится у оператора if. Например, обнаружено переполнение счетчика, а значит, нужно скорректировать его значение. В этот момент func вызвали еще раз. При этом произойдет увеличение еще не скорректированного значения счетчика. Но предыдущий экземпляр func об этом не подозревает и присвоит счетчику свое значение - остаток от переполнения. Но новый экземпляр этого тоже не знает и будет выполнять операции с счетчиком находящемся в процессе обновления. Что приведет к искажению данных.
На первый взгляд, если убрать обработку переполнения выполняемую оператором if, функция может стать повторно входимой. Однако, это не так. Дело в том, что один оператор С транслируется, как правило, в несколько машинных команд (а иногда и в вызов служебных функций). Переключение потоков или возникновение прерывания никак не учитывает границы операторов С. Поэтому не планируемое взаимодействие экземпляров выполняющихся функций может произойти внутри любого оператора. И произойдет перезапись данных искажающая результат.
Для этого примера нельзя подобрать повторно входимый вариант. Во всяком случае, в чистом и идеальном случае истинного параллелизма выполнения потоков. Не поможет даже использование связанных с потоками раздельных вариантов статических переменных (если компилятор дает такую возможность). С этой точки зрения пример не очень удачный, но зато наглядно демонстрирующий проблему.
В реальности можно заблокировать на время переключение потоков внутри одного приложения. Но нельзя запретить переключение задач операционной системой (если вы обычный пользователь и программа прикладная). А для наших микроконтроллеров можно запретить прерывания на время работы func. Например, так
Но это не является собственно решением проблемы. Кроме того, между вызовом sti, которая разрешает прерывания, и реальным завершением func тоже может возникнуть прерывание, которое не исказит данные счетчика, но приведет не верному возвращаемому значению, которое не будет соответствовать реальному состоянию счетчика.
Давайте посмотрим на сгенерированный для STM8 код
В строке 60 в регистр Х, который компилятор использовал вместо аккумулятора, так как он 16-битный, для работы с целыми числами, загружается содержимое нашего счетчика (компилятор присвоил ему внутреннее имя _func_counter_65536_2). В строке 61 к нему прибавляется аргумент функции n, который не имеет своего имени и постоянного адреса, разместившийся в стеке со смещением 3. В строке 63 результат сложения снова сохраняется в счетчике. Не обращайте внимания на комментарий в строке 62, просто тут уже поработал оптимизатор.
То есть, наша строка
counter+=n;
превратилась в три машинные команды
ldw x, _func_counter_65536_2+0
addw x, (0x03, sp)
ldw _func_counter_65536_2+0, x
И прерывание может возникнуть, например, между addw и последующей ldw. При этом новые прерывания будут запрещены, так что новый вызов func отработает до конца без помех. Регистры будут сохранены перед передачей управления написанному программистом обработчику и восстановлены после возврата из него. Среди этих регистров будет и Х.
Для примера предположим, что counter=120, а наша функция вызывается так
some_var=func(8);
Если прерываний нет, а значит и повторная входимость не имеет значения, some_var получит значение 3. (120+8)%125=3.
А если прерывание в указанном ранее месте произошло? Регистр Х в точке прерывания содержит 128 (результат сложения), но в памяти, в самой переменной все еще 120, так как команда сохранения еще не отработала.
Не важно, с каким параметром функция будет вызвана из обработчика прерывания (параллельный поток). Потому что после завершения обработчика регистры будут восстановлены и управление вернется к
ldw _func_counter_65536_2+0, x
которая и запишет в counter значение 128. А далее отработает if, где и будет скорректирован счетчик до того же самого значения - 3. Вызов func из прерывания никак не отразился на результате для нашего случая. А это ошибка в работе программы.
Можно выбрать другую точку возникновения прерывания и проявление ошибки может стать иным. Но она останется. Кстати, обратите внимание, что остаток от деления здесь вычисляет функция __modsint, которая вызывается в строке 71. И может быть прервана даже эта функция.
Кстати, не поможет и передача в функцию адреса счетчика, вместо использования внутренней статической переменной. Сам счетчик все равно будет изменяться не одной командой, а несколькими. Значит эта цепочка команд может быть прервана. Попробуйте самостоятельно рассмотреть этот случай. В качестве упражнения.
Что же делать? Самым правильным будет использовать только локальные переменные в функции требующей повторной входимости. Если это по каким то причинам невозможно, то необходимо запрещать прерывания на время выполнения функции. Это не совсем честный прием и он увеличивает задержку обработки прерываний, но зато позволяет избежать проблем.
Когда сам микроконтроллер сопротивляется повторной входимости
Увы, бывает и так. И речь идет о PIC, которые мне очень нравятся. В чем же тут проблема? Ведь язык С не зависит от целевого процессора! В теории, и в идеальном мире, не зависит. Но реальность вносит свои коррективы.
Дело в том, что в PIC нет аппаратного стека данных. Компилятор решает эту проблему статическим размещением локальных переменных аналогично организации перекрытий. Я уже рассказывал об этом ранее. Компилируемый стек отлично решает вопросы размещения локальных переменных, но полностью исключает возможность повторной входимости.
Если же компилятор (речь про XC8, совершенно конкретный компилятор) обнаруживает, что некая процедура вызывается и в цепочке вызовов основной программы, и из обработчика прерывания, то он создает отдельную скрытую копию кода процедуры! Со своими локальными переменными.
Если же возможно использование программного стека (более одной пары FSR:INDF у микроконтроллера), то компилятору нет необходимости в создании копии кода. Он просто использует стек, как обычно.
Заключение
Даже использование языков высокого уровня не позволяет полностью игнорировать особенности целевой архитектуры. Вопрос повторной входимости является общим, и не теоретическим, а самым практическим. И вам не удастся его избежать при разработке программ для микроконтроллеров, если используются прерывания.
Затронутые в статье вопросы мы еще будем рассматривать, уже применительно к конкретным ситуациям, когда будем использовать прерывания в наших учебных проектах. Это будет довольно просто, так как прерывания это не полноценное параллельное программирование. Но упускать из виду особенности разработки повторно входимых процедур все равно нельзя.
Еще раз остановлюсь на основных моментах:
- Повторно входимая функция должна использовать только локальные переменные, которые размещаются в стеке. Так же, безопасным является использование переданных по значению параметров.
- При необходимости использовать переменные со статическим типом размещения (глобальные, статические, локальные статические) следует предусматривать блокировку переключения потоков. В случае микроконтроллеров это сводится к блокировке прерываний на время работы с такими переменными.
- Помните, что main это тоже функция! Работа с прерываниями будет рассматриваться в отдельных статьях, но сейчас хочу сказать о одном важном моменте. Если какая то переменная изменяется и в основной программе, и в обработчике прерывания, то не забывайте использовать блокировку прерываний при работе с ней в основной программе. Это кажется не очевидным, но это тоже вопрос повторной входимости! Подумайте, почему это так.
Спасибо Максиму Филатову, который в комментариях напомнил, что передаваемые в функцию по значению параметры так же безопасны для использования в повторно входимой функции. О чем я не написал сразу в статье.
Я не стал, как планировал ранее, рассматривать в статье еще и вопрос позиционной независимости. Статья получилась бы слишком большой. Поэтому позиционную независимость будем рассматривать в следующей статье.
До новых встреч!