С. Емец
В последнее время множество
разработчиков программного обеспечения переходит с языков низкого уровня
на С. Преимущества C перед ассемблером: более понятный код, повышающий
скорость разработки; универсальность, не требующая изучать досконально
архитектуру используемого процессора; лучшая документируемость и читаемость
алгоритма; наличие библиотек функций; поддержка вычислений с плавающей
точкой и так далее. Но, в то же время, С проигрывает по занимаемым ресурсам
и скорости исполнения приложения. Для решения этой проблемы отдельные
критические участки могут быть написаны на ассемблере и тем или иным способом
включены в проект, но для улучшения всего проекта следует писать более
оптимальный С-код.
Введение
При оптимизации С-кода для разных
микроконтроллеров пользуются различными приемами, но так как одним из
свойств языка С является переносимость (портируемость), то алгоритм, описанный
один раз на С, можно использовать для разных архитектур. При этом, используя
общие приемы оптимизации, можно сделать так, что скомпилированный код
будет работать быстрее и/или требовать меньше памяти. Интересующимся внутренней
структурой С-компилятора и принципами его работы, не зависящими от конкретной
архитектуры, рекомендую ознакомиться с GCC www.gcc.gnu.org.
Но, в то же время, следует помнить, что для отдельных архитектур (особенно
8-бит микроконтроллеров) могут существовать специфические приемы оптимизации.
Для пользователей IAR-C для AVR-микроконтроллеров следует ознакомиться
с Application Note AVR035 http://www.atmel.com/atmel/products/prod201.htm.
Так как AVR-микроконтроллеры имеют архитектуру, ⌠удобную■ для С-компилятора,
то часть общих советов будет совпадать с Note AVR035. Для PIC-микроконтроллеров
и HT С многие ⌠общие■ правила не действительны (все-таки архитектура PIC
не предназначена для языков высокого уровня), да и НТ С нельзя назвать
настоящим С-компилятором, это скорее язык с синтаксисом, подобным С. В
таких случаях наиболее правильной методикой по-видимому является следующая:
нужно скомпилировать исходник не в объектный файл, а в ассемблерный и
сравнить полученные коды для разных вариантов.
Правила, советы и механизм работы С-компилятора
Итак, первое правило, которое
следует запомнить так же, как ⌠жи, ши пиши через и■ ≈ не пользоваться
глобальными переменными там, где этого можно избежать. При том, что это
правило повторяется в каждом учебнике, очень часто разработчики, переходящие
с ассемблера на С, допускают эту ошибку.
Для понимания правила следует
обратить внимание на то, как работают компилятор и линкер: как и куда
распределяется память. Линкер оперирует с секциями (section). Секции служат
для группирования объектов в памяти определенного типа. В разных программных
продуктах существуют различное число и различные названия секций. Пользователь
может как создавать новые секции, так и изменять способ компоновки объектов
по секциям и привязки секций к физической памяти проектируемого (target)
устройства. Для этого используются командные файлы или скрипты линкера
и специальные директивы компилятора. Для процессоров неймановской архитектуры
(Intel x86, большинство 32-разрядных RISC-процессоров), имеющих общую
память программ и данных, доступную по указателю любого типа, с секциями
имеет дело линкер, а код, генерируемый компилятором, обычно не зависит
от того, в какую секцию попадут адресуемые данные. Но для гарвардской
архитектуры большинства 8-бит микроконтроллеров существуют различные механизмы
доступа к разным типам памяти, следствием чего является различный код,
генерируемый компилятором для доступа к объектам, лежащим в разных типах
памяти. Для решения вопроса размещения объектов в памяти разработчики
компилятора вынуждены отходить от стандарта ANSI и добавлять особые ключевые
слова. Примером может служить модификатор flash, использующийся в IAR-C
для AVR. Из-за разнообразия архитектур и С-компиляторов, работающих с
ними, описание названий и типов секций следует искать в документации на
компилятор.
В общем случае, компилятор работает
с четырьмя типами секций: data, bss, code, rdata (в разных продуктах могут
быть другие имена, но назначение сохраняется). Data содержит инициализированные
данные, bss ≈ неинициализированные данные, code ≈ исполняемый код, rdata
≈ различные константы. При этом data и bss располагаются в ОЗУ, а code,
rdata и копируемый образ data ≈ в ПЗУ. Также в ОЗУ должны быть расположены
стек и ⌠куча■ (heap), то есть полностью занимать ОЗУ секциями data и bss
нельзя.
Вернемся к тому, как компилятор
располагает переменные в памяти: инициализированные глобальные переменные
попадают в data, неинициализированные ≈ в bss.
В следующем примере
long j; char i=5; int main() {.... |
для переменной j будет выделено
4 байта в секции bss, а для переменной i ≈ 1 байт в data. При этом начальное
значение i=5 займет место в загружаемом коде (в ПЗУ).
Такое же распределение памяти
будет выполнено и для статических переменных, единственное отличие будет
в том, что они не видны компилятору и линкеру где-либо еще, кроме той
функции, в которой объявлены.
int main() { static long j; static char i=5; .... |
Кроме того, перед передачей управления
функции main() в исполняемом коде, сгенерированном С-компилятором для
встраиваемой системы, исполняется так называемый startup-код. Пользователь
может переписать этот код, который либо содержится в предопределенном
объектном файле (например, crt0.o), либо дается линкеру в явном виде (например,
nostartfiles my_start_up.s). Поставляемый вместе с компилятором и линкуемый
по умолчанию startup-код кроме начальной инициализации периферии должен
заполнить нулями bss-секцию и скопировать из ПЗУ данные в data-секцию.
Некоторые компиляторы имеют опцию, позволяющую пропустить инициализацию,
но пользоваться этим надо осторожно, так как по стандарту ANSI глобальные
переменные должны иметь определенное значение (неинициализированные явно
равны 0) на момент начала исполнения main().
Таким образом, излишнее использование
глобальных или статических переменных не только занимает память в ОЗУ,
но и замедляет запуск программы по RESET. При этом разницы в распределении
памяти между статическими и глобальными переменными нет, но из-за того,
что глобальные переменные имеют видимость во всем проекте, возможны конфликты
в разрешении имени, когда глобальные переменные в разных файлах имеют
одинаковое имя.
Рассмотрим, какие еще методы
распределения ОЗУ имеет С: стек ≈ область памяти, в которой выделяется
место для локальных переменных и параметров/результатов функции (в некоторых
случаях для передачи параметров используется регистровый файл); ⌠куча■
≈ область памяти, с которой имеют дело функции calloc, malloc, free, realloc.
Распределение ОЗУ в стандартной
С-программе выглядит следующим образом:
(последняя ячейка ОЗУ) |
Стек, растет сверху вниз |
граница стека |
Свободная память |
Куча (heap), растет снизу вверх |
начало кучи |
Память, распределенная во время компиляции секции bss, data |
(начало ОЗУ) |
Так как размеры стека и ⌠кучи■
изменяются в процессе выполнения программы (⌠куча■ и стек растут навстречу
друг другу), то при их пересечении данные будут утеряны. В ⌠больших■ системах
за распределением памяти следит ОС и соответствующим образом обрабатывает
ошибочную ситуацию, когда исчерпывается свободная память. Во встраиваемых
системах подобная ошибка приводит к непредсказуемому результату, поэтому
надо принимать определенные меры во время разработки программы. То есть
малый объем ОЗУ в микроконтроллере может наложить ограничения на глубину
вызовов функций и на количество локальных переменных, используемых в функциях.
Но, несмотря на это, переменную
лучше объявлять как локальную. Компилятор попытается разместить переменные
в рабочих регистрах и только займет память на стеке, если это не удастся.
Преимуществом локальных переменных является также и то, что одноуровневые
функции не потребуют дополнительного места для их хранения.
int foo(int i) { double a_foo, b_foo; long i_foo; .... } int boo(int i) { double a_boo, b_boo; long i_boo; .... } int main() { ..... foo(1); boo(2); foo(3); foo(4); boo(-1); ..... } |
В этом примере под локальные
переменные будет выделено столько же места, сколько потребовалось при
единственном вызове foo.
Для написания эффективных С-программ
не менее важным является понимание работы компилятора, собственно той
его части, которая обеспечивает оптимизацию кода. Многие компиляторы (особенно
поддерживающие различные архитектуры) построены по следующему принципу:
после синтаксического анализа С-код переводится в промежуточное представление,
с которым работает оптимизатор, а после оптимизации этот промежуточный
код транслируется в ассемблер.
Перейдем к тому, как облегчить
жизнь оптимизатору С, увеличив тем самым скорость выполнения и уменьшив
размер исполняемого кода.
Единичным элементом, с которым
работает С-компилятор, является С-функция, поэтому много вызовов простых
С-функций не позволяет компилятору эффективно оптимизировать код. Дополнительные
затраты требуются также на вызов, передачу параметров и возврат. Несмотря
на то, что алгоритм, описанный в виде набора простых функций, является
более понятным и проще отлаживаемым, для увеличения эффективности следует
пользоваться другими методами. Это могут быть либо inline-функции, либо
макросы. Не все С-компиляторы поддерживают inline-функции, хотя эта методика
считается более правильной, и при переходе от С к С++ следует использовать
inline для небольших функций. Макросы являются стандартным механизмом
С и поддерживаются всеми компиляторами. Но так как макросы обрабатывает
препроцессор, выполняя просто текстовую подстановку и не проводя никакого
синтаксического анализа, то возможны ошибки, обнаружить которые бывает
непросто. Классический пример неправильного макроса для вычисления квадрата:
#define sqr(x) x*x
При его использовании в случае
y=sqr(x+1)
получим
y=x+1*x+1=2*x+1, а не (x+1)^2
Для правильного использования
этот макрос следует определить следующим образом:
#define sqr(x) (x)*(x)
Макросы также не проверяют параметры,
поэтому пользоваться ими для замены функций следует с осторожностью. Во
всех рекомендациях по языку С++, который поддерживает inline-функции,
рекомендуется использовать функции и сквозную оптимизацию, а не макросы.
Но если язык не имеет поддержки inline-функции, то можно достигнуть уменьшения
размера кода и за счет использования макроса.
Однако, увеличение размера функции
ведет не только к усложнению документируемости и читаемости, но и может
привести к специфической для встраиваемых систем ошибке. Обычно алгоритм
встраиваемой системы считывает данные с набора датчиков и подает воздействия
на управляемые элементы. При этом, с точки зрения компилятора, подобные
запись и чтение могут выглядеть ⌠бессмысленными■ и в процессе оптимизации
могут быть удалены из кода. Для предотвращения этого служит слово volatile
(об использовании этого модификатора речь пойдет ниже). Но использование
volatile не гарантирует сохранения последовательности обращений. То есть
в случае большой функции хороший компилятор может изменить последовательность
воздействий, а это, в свою очередь, может привести к неработоспособности
системы. Для предотвращения этого следует пользоваться ассемблерными вставками
(причем вставка должна содержать не одно обращение, а всю последовательность).
GCC и построенные на его основе коммерческие компиляторы могут оптимизировать
ассемблерный код, написанный пользователем. В этом случае следует пользоваться
конструкцией asm volatile, например, asm volatile (⌠nop■). Но это специфика
конкретного компилятора, и в любом другом случае нужно ознакомиться с
тем, как управляется оптимизатор компилятора (это могут быть ключи командной
строки, директивы #pragma, какие-либо атрибуты, нестандартные модификаторы).
Вернемся к механизмам распределения
ОЗУ в С-программах. Кроме глобальных и статических переменных, распределяемых
линкером в секциях data и bss, и локальных переменных, размещаемых автоматически
в процессе исполнения в рабочих регистрах или на стеке, существует так
называемая ⌠куча■. В микроконтроллерах с малым объемом памяти ⌠куча■ и
работа с ней не поддерживаются (по умолчанию, IAR-C для AVR требует для
⌠кучи■ 2К ОЗУ). Но в то же время, этот механизм выделения памяти контролируется
пользователем, и во многих проектах вызовы malloc, calloc, free встречаются
очень часто. Как и везде, здесь есть свои подводные камни. Эти функции
работают со структурой, похожей на файловую систему (или цепочку кластеров).
Поэтому возможны такие эффекты, как дефрагментация памяти. Но это приводит
к более плохим последствиям, чем дефрагментация файловой системы: по запросу
память должна выделяться целым куском, и освобожденные фрагменты не могут
использоваться, если существует сколь угодно малый кусочек памяти, занятый
позже и не освобожденный. То есть несложно написать такую программу, которая,
реально используя один байт, займет все пространство ⌠кучи■. Также при
выделении памяти требуется место под заголовок, содержащий служебную информацию
(размер памяти, указатель на следующий элемент и т.п.). Поэтому при выделении
множества маленьких кусочков с помощью malloc память будет расходоваться
неэффективно. Из этого можно сделать несколько выводов, применимых к программному
обеспечению встраиваемых систем: так как из main возврата не бывает, то
можно аллоцировать требуемую память один раз и использовать ее (в терминах
языка С++ вызываются только конструкторы объектов, деструкторы отсутствуют);
объект, под который выделяется память, должен быть наибольшего возможного
размера (отдельные запросы для его частей потребуют больше памяти); освобождать
память следует по принципу стека, так как пока не удален последний объект
(созданный последним malloc), память не возвращается. Эти сложности являются
следствием того, что язык С предоставляет пользователю мощные инструменты
для работы с указателями, но при этом отсутствует автоматическая сборка
отработанной памяти, так называемая ⌠автоматическая сборка мусора■. Но
в любом случае при динамическом выделении памяти надо следить, чтобы вся
память, которая была взята, была возвращена в ⌠кучу■: то есть каждому
malloc должен соответствовать free. Иначе возникает эффект, называемый
утечкой памяти (memory leakege), и возникает вероятность краха системы.
Следует обратить особое внимание
на работу с указателями. Они позволяют обеспечить передачу больших объемов
данных между функциями, реализовать эффективный доступ к элементам структуры
и массивам. Но пользоваться указателями следует аккуратно и с пониманием
архитектуры процессора, для которого генерируются коды. Особенно плохо
(как и всегда J) обстоит дело с гарвардскими машинами, так как памяти
каждого типа соответствуют свои типы указателей и команды для работы с
ними. Для указателей, которые изменяются в процессе работы (инкрементируются,
декрементируются, получают какие-либо новые значения), следует предусмотреть,
чтобы они указывали на правильные данные. В С, выполняя операцию ⌠++■,
⌠+=■ или подобную (то есть операцию с указателем и целым числом), компилятор
учитывает тип данных, на которые указывает указатель.
Например:
int *i_p; char *c_p=(char *)i_p; i_p+=5; c_p+=5; |
≈ указатели будут указывать на
разные ячейки памяти.
Поэтому существуют операции преобразования
указателя, и в случае гарвардской архитектуры автоматическое преобразование
указателя не всегда возможно. При этом в гарвардской архитектуре возможно
существование указателей одного типа, работающих для разных областей памяти,
преобразование которых может не поддерживаться компилятором. Еще следует
упомянуть модификаторы far и near, которые могут описывать указатели,
имеющие различный размер и адресующие различные по размеру области памяти.
Если архитектура поддерживает различные типы указателей, то компилятор
обычно пытается воспользоваться указателем минимального размера.
Указатели следует применять для
передачи параметров / возвращения результатов вызываемой функции. Этот
метод позволяет избежать использования глобальных переменных для передачи
параметров. То есть передаваемые значения следует объединить в структуру
и передавать указатель. Пользуясь операциями ⌠&■ и ⌠->■, можно
обеспечить эффективный механизм передачи параметров, не использующий глобальных
переменных и не занимающий время и память копированием данных.
#include <stdio.h> #include <string.h> typedef struct {int handl; char * mess;} message; void get_message(message * ask) { const char hi_mes[]=>>_HELLO_>> const char bi_mess[]=>>_BYE_>> if (ask->handl)strcpy(a-sk->mess,hi_mess);else strcpy(ask->mess,bi_mess); } int main() { char a[10]; message greetinh; greeting.mess=a; greeting.handl=1; get_message(&greeting); printf(<<%s\n>>,greeting.mess); greeting.handl=0; get_message(&greeting); printf(<<%s\n>>,greeting.mess); } |
В стандартной библиотеке таким
механизмом передачи данных пользуется sprintf.
Для работы с какими-либо портами
ввода/вывода, отображенными в пространство памяти, весьма удобными также
оказываются структуры, описанные подобным образом. Операции с указателем
на такую структуру обеспечивают хорошую читаемость исходников плюс эффективный
код, выдаваемый компилятором.
Для экономии памяти следует правильно
описывать переменные. Если переменная принимает целочисленные значения
из диапазона -100...+100, то для ее хранения достаточного одного байта
и описывать ее следует как signed char, а не как int. Так же можно использовать
ключевые слова short и long, так как int в некоторых случаях эквивалентен
short и представляется 2-мя байтами, а в некоторых ≈ эквивалентен long
и представляется 4-мя байтами. При этом нужно представлять архитектуру
используемого процессора, возможно такое задание опций компилятора/ликера,
при котором элемент char будет занимать одну 32-разрядную ячейку памяти
или на распаковку char потребуются дополнительные инструкции. Но на 8-разрядные
архитектуры это не распространяется. Для более эффективного использования
памяти можно пользоваться объединениями union ≈ это не только позволяет
сэкономить память в случае, когда две переменные разных типов не используются
одновременно, но и проводить преобразования типов. Например, для получения
представления 64-разрядного double можно воспользоваться объединением
union { double d; unsigned long l[2];}. При этом следует обращать внимание
на способ хранения данных в памяти (big endian или litle endian, intel
или motorola). В приведенном примере, скомпилированном для ПК с x86 процессором,
показатель будет хранится в ⌠l[1]■, а если скомпилировать для ARM или
PowerPC ≈ то в ⌠l[0]■. При этом процессорные ядра (тот же ARM) могут конфигурироваться
как для big endian, так и для litle endian.
Вопрос с битовыми полями неоднозначен:
они могут потребовать больших затрат, чем непосредственные операции с
битами, но тем не менее битовые поля обеспечивают лучшую читаемость исходников.
Из старых трюков, применяемых
или применявшихся при программировании на С, некоторые могут найти применение
во встраиваемых системах, например, объявление констант с помощью ключевого
слова enum ≈ enum { MAX_BAD_BLOCKS = 10 }; использование условных инструкций
с предекрементом, например, наиболее эффективный цикл do {...} while (--i).
Но это имеет смысл делать для старых компиляторов и носит скорее исторический
смысл.
Специфическими ключевыми словами
для программирования встраиваемых систем являются volatile и interrupt.
Первое есть в ANSI, а второе ≈ расширение языка. Для описания функции,
вызываемой по внешнему событию (прерыванию), используется модификатор
interrupt. Для описания переменной, которая может быть изменена в прерывании,
или порта ввода/вывода в пространстве памяти используется модификатор
volatile. Это ключевое слово запрещает компилятору сохранять значение
переменной в рабочем регистре, заставляя каждый раз его считывать.
int volatile rotation_count; interrupt [NT0_vect] void rotation_step(void) {... |
Если Вы дочитали эту статью и
нашли в ней что-либо интересное, то это хорошо, но лучше будет написать
интересующую конструкцию на конкретном компиляторе для конкретного процессора
и посмотреть, в какой ассемблерный код она превращается при том или ином
уровне оптимизации. Описать все методы и предусмотреть любую ошибку невозможно,
также могут найтись примеры (процессоров и компиляторов), для которых
предлагаемые способы будут бессмысленны или вредны. Но, в любом случае,
использование С может существенно облегчить разработку встраиваемых систем.
Тем более, что в настоящее время появляется все больше С и С++-компиляторов
и процессоров с архитектурой, позволяющей этим компиляторам генерировать
эффективный код.
E-mail: yemets@javad.ru
Ревтов А.Н. пишет... Очень интересная статья
06/01/2016 21:54:28 |
Ваш комментарий к статье | ||||