Нет, речь пойдет, как и всегда в рамках данного цикла, не о собственно языке С. Речь пойдет о том, как размещение переменных и констант в памяти микроконтроллера влияет на работу с ними. В частности, на присваивание им начальных значений.
Сегодня в качестве примера мы будем пользоваться вот таким фрагментом программы на С
Здесь вы можете легко увидеть переменные нескольких типичных групп. В предыдущей статье
мы уже сталкивались с этим и знаем, как они размещаются в памяти микроконтроллера. Но тогда мы оставили в стороне вопросы инициализации переменных. А ведь здесь не мало интересного и важного.
Не смотря на множество возможных вариантов определения переменных, нас будет интересовать лишь несколько наиболее типичных, так как остальные сводятся к ним. В частности, с точки зрения микроконтроллера, нет разницы между глобальными переменными и статическими переменными, мы уже рассматривали этот вопрос ранее. Ограничения области видимости значимы только для программиста и компилятора, а размещаются эти переменные одинаково.
В статье я не буду рассматривать неинициализируемые переменные. То есть те, которые в принципе не подлежат инициализации и это явно указано программистом или в заголовочных файлах описания микроконтроллера. Например, с помощью атрибута noinit или любым иным предусмотренным компилятором способом. Такие переменные компилятором только размещаются в памяти, причем зачастую по фиксированным аппаратным адресам, так как соответствуют регистрам аппаратуры. Никаких дополнительных действий, скрытых от программиста на С, с ними не выполняется.
Влияние компилятора
Выбранный компилятор оказывает существенное влияние на генерируемый машинный код. Но, в рамках данной статьи, наиболее значимы два следующих фактора.
Не инициализированные переменные все таки иногда бывают инициализированными
Странное утверждение? Но на самом деле такое действительно бывает. Да, переменная может иметь абсолютно любое значение до момента первого присваивания ей какого либо заданного значения. И это может приводить к трудноуловимым ошибкам, если программист забыл выполнить присваивание, но использует переменную как источник данных для вычислений.
Что бы немного уменьшить риск возникновения ошибки при работе программы, из-за забывчивости программиста, некоторые компиляторы все таки выполняют инициализацию не инициализированных переменных. Такая инициализация сводится к очистке, обнулению, памяти занимаемой переменными, инициализация которых явно не описана программистом.
Условно можно представить, что наш фрагмент программы рассматривается компилятором вот так
Однако, такая забота компилятора приводит к дополнительному увеличению и объема кода программы, и времени подготовки ее к выполнению (до передачи управления main), и времени ее работы. И мы скоро это увидим.
Поэтому автоматическое обнуление не инициализированных переменных обычно можно отключить специальными директивами компилятора или ключами в командной строке его запуска.
Когда имеет смысл отключать автоматическую очистку? Если для вас важен размер кода, а переменные точно получают значения до использования, то автоматическое обнуление отключить может иметь смысл. Во всех остальных случаях не стоит отказывать от помощи компилятора. Если конечно это доступно. Разумеется, у вас может быть свое мнение на этот счет.
Инициализация переменных может быть невозможна
Да, бывает и такое. Например, компилятор CC5X для PIC (который мне очень нравится) не поддерживает инициализацию переменных. Соответственно, программисту необходимо обеспечить ручную инициализацию в виде обычного присваивания в начале main. Считаете это ужасным?
На самом деле тут нет ничего страшного. Просто этот компилятор специально рассчитан на минимальное вмешательство в написанное программистом, что важно для микроконтроллеров с ограниченными ресурсами. У него есть свои плюсы и минусы. В частности, весьма удобны расширения языка для низкоуровневой работы, что безусловно плюс. Зато нет инициализации и невозможно использовать более одного индексного (массив) элемента в выражениях.
Но вас же никто не заставляет пользоваться именно этим компилятором.
Инициализированные переменные со статическим типом размещения вне зависимости от области видимости
Как вы уже знаете, переменные со статическим типом размещения имеют время жизни равное времени работы (в микроконтроллерах время жизни и время работы программы не одно и тоже) программы и постоянные адреса в памяти данных. Инициализация таких переменных выполняется только один раз, при старте программы. Причем до передачи управления main. А то, что они собраны в одну программную секцию, позволяет нам рассматривать их и просто как один непрерывный блок памяти. А это как раз и приводит на к основному способу присвоения переменным начальных значений.
Микроконтроллер имеет команду чтения данных из памяти программ
Такая возможность в явном виде есть в AVR (например, команда LPM), STM8 (адресное пространство единое) и PIC18 (команда TBLRD). Менее очевидно, что память программ доступна как данные для чтения в линейной области памяти (с адреса 0x8000) Enhanced Mid-range PIC с помощью косвенной адресации. Еще менее очевидно, что можно хранить данные, как константы, в памяти программ с помощью команд RETLW для любого PIC. Массив констант просто размещается в памяти программ в виде последовательных команд RETLW, а сами константы получаются через обращение к последовательным адресам командой CALL в WREG.
Можно создать программную секцию в памяти программ, например, с именем .сinit. и поместить туда инициализирующие значения. Тогда мы сможет при старте программы, до передачи управления main, скопировать это значения в память данных.
В иллюстрации я использовал стандартную функцию memcpy для простоты и наглядности. Она знакома всем. Хотя можно было сделать это и явно написанным циклом.
Здесь addr_of позволяют получить начальный адрес размещения соответствующей секции. Это псевдофункция, так как у нее нет выполняемого кода. Компилятор просто подставляет вместо них соответствующее значение. size_of это тоже псевдофункция (не путать с sizeof!), вместо которой компилятор подставляет длину в байтах соответствующей секции. Разумеется, размер секций .cinit. и data должен быть одинаковым.
Кстати, точно так же может выполняться копирование констант, о чем я кратко говорил в статье "Микроконтроллеры для начинающих. Часть 28. Мост к Си. Размещение в памяти. Секции, сегменты, особенности"
Микроконтроллер не может читать память программ как данные
Хоть мы и выяснили, что рассматриваемые нами микроконтроллеры могут читать память команд как данные (пусть и не всегда явно, и с ограничениями), но рассмотреть этот случай все таки стоит. Для полноты картины.
В этом случае компилятору ничего не остается, кроме как использовать просто команды загрузки константы в аккумулятор и сохранения аккумулятора в переменной. То есть, обычные присваивания, с точки зрения С.
Но, по большому счету, это ведь тоже является сохранением инициализирующего значения в памяти программ, в коде команды. Только эти значения недоступны в виде собственно данных, что и не позволяет написать цикл.
Вмешательство оптимизатора
На самом деле есть еще один фактор, который оказывает влияние на способ инициализации статически размещаемых переменных. Я говорю об оптимизаторе, который есть в каждом компиляторе. Если инициализация требуется всего 1-2 простым переменным, то цикл с копированием из ПЗУ в ОЗУ будет выполняться дольше, и займет больше места, по сравнению с обычными присваиваниями.
Оптимизатор может оценить суммарные затраты на два способа инициализации и выбрать оптимальный вариант.
Не инициализированные обнуляемые переменные со статическим типом размещения вне зависимости от области видимости
Если не инициализированные переменные не обнуляются, то и никаких дополнительных действий не требуется. Поэтому будем рассматривать только переменные, которые автоматически инициализируются нулевым значением.
Такие переменные не представляют особой сложности. Создавать блок нулевых значений в памяти программ не требуется. Секцию не инициализированных переменных можно просто обнулить функцией memset
memset(addr_of(bss), 0, size_of(bss))
или просто в цикле. Если рассматривать этот процесс с точки зрения языка С. Кстати, обратите внимание, что не инициализированные секции разместились в другой секции.
Инициализированные локальные (размещаемые в стеке) переменные
В отличии от ранее рассмотренных, такие переменные не имеют постоянного адреса. И их инициализация выполняется каждый раз при входе в блок, в котором они определяются.
Проще всего, и компилятор так и поступает обычно, создать и инициализировать локальную переменную выполнив команду PUSH, которая поместит в стек нужное значение.
Не инициализированные локальные переменные
Если компилятор выполняет автоматическое обнуление не инициализированных переменных, то в стек просто можно поместить 0 командой PUSH. Здесь нет отличий от предыдущего случая.
Если автоматическое обнуление не выполняется, то можно ускорить создание переменных просто скорректировав SP на суммарный размер таких переменных. Разумеется, для этого надо разместить такие переменные последовательно, в виде непрерывного блока. Точно так же, как мы уже видели в предыдущей статье "Микроконтроллеры для начинающих. Часть 29. Мост к Си. Стек - не только для локальных переменных".
Интересные особенности и тонкости
Мы почти закончили разбираться переменными, осталось рассмотреть самое интересное.
Начнем с переменных st_lcl_i_var1 и st_lcl_u_var1 из нашего примера. Первая инициализируется при определении, а вторая создается без инициализации, а потом ей присваивается начальное значение. Есть ли разница между этими переменными, с точки зрения темы сегодняшней статьи? И если есть, то в чем?
Самое основное различие будет в секции, в которой переменные разместятся.
А все остальное, кроме секции, зависит от того, какой вариант инициализации выберет компилятор и выполняется ли автоматическое обнуления не инициализированных переменных. А значит, возможны четыре случая:
- Инициализация выполняется оператором присваивания, автоматического обнуления нет. В этом случае не будет никакой разницы между st_lcl_i_var1 и st_lcl_u_var1.
- Инициализация выполняется копированием из ПЗУ в ОЗУ, автоматического обнуления нет. В этом случае разница уже будет. st_lcl_i_var1 получит значение до передачи управления main, а внутри main код инициализации для нее будет отсутствовать. А для st_lcl_u_var1 автоматического обнуления нет. И внутри main будет создан код для оператора присваивания st_lcl_u_var1 нужного значения.
- Инициализация выполняется копированием из ПЗУ в ОЗУ, автоматическое обнуление есть. В этом случае разница уже будет более значительной. st_lcl_i_var1 получит значение до передачи управления main, а внутри main код инициализации для нее будет отсутствовать. st_lcl_u_var1 так же получит значение, нулевое, до передачи управления main. И внутри main будет создан код для оператора присваивания st_lcl_u_var1 нужного значения. Как видно, автоматическое обнуление в этом случае просто непроизводительно потратит время процессора. А это значит, что при автоматическом обнулении компилятором не инициализированных переменных инициализацию лучше предусматривать при создании переменной, а не в виде отдельного оператора присваивания. Хотя, с точки зрения программиста на языке С, оба варианта дают один и тот же результат.
- Инициализация выполняется оператором присваивания, автоматическое обнуление есть. Данный случай практически эквивалентен предыдущему. С теми же самыми выводами.
Нужно отметить, что здесь, как всегда, может вмешаться оптимизатор. Но наиболее вероятно, что он выбросит лишнюю команду CLR в четвертом случае, а вот в случае 3 окажется бесполезен. Почему так? А вот над этим предлагаю подумать вам. В качестве упражнения.
В примере это отсутствует, но давайте рассмотрим и локальные переменные. Вот два варианта:
uint8_t var=15;
и
uint8_t var;
var=15;
Что можно тут сказать? В целом, ситуация такая же, как и в ранее рассмотренном случае со статически размещаемыми переменными. Но здесь не будет разных секции, локальные переменные всегда размещаются в стеке. И вариантов будет 2, а не 4.
- Автоматическое обнуление есть. При этом второй вариант, с отдельным оператором присваивания, будет менее эффективен, так как обнуление излишне. Вполне вероятно, что оптимизатор выбросит команду загрузки в стек нулевого значения. Но гарантировать этого нельзя. Поэтому стоит инициализировать переменную при создании, а не отдельным оператором присваивания. И потери времени для локальных переменных будут больше, так как лишние команды будут выполняться не один раз, а при каждом входе в блок.
- Автоматического обнуления нет. В данном случае, дополнительных потерь времени при использовании отдельного оператора присваивания, скорее всего, не будет. Хотя многое будет зависеть от системы команд процессора микроконтроллера и времени их выполнения с различными способами адресации.
Заключение
Рассмотренная сегодня тема касается, в большей степени, не размещения переменных в памяти, и их подготовки к использованию в программе. Обычно это делает стартовый код (startup code), который выполняется до main, или пролог функций.
Но вот особенности инициализации могут повлиять на выбор места и способа размещения переменных в памяти программистом. С помощью ключей компилятора, создания собственных секций, применением специальных атрибутов. А иногда используется и собственный вариант стартового кода или ассемблерные фрагменты. Да, это уже темы не для начинающих, но иметь общее представление, которое и описывает статья, нужно.
В следующей статье мы рассмотрим вопросы позиционной независимости и повторной входимости кода. Это интересная тема, которая, на первый взгляд, имеет мало отношения к микроконтроллерам. Но на самом деле имеет прямое отношение.
До новых встреч!