Программирование AVR: решение проблем, FAQ Методы оптимизации кода C для 8-битных AVR Fri, March 29 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Методы оптимизации кода C для 8-битных AVR Печать
Добавил(а) microsin   

Здесь приведен перевод апноута Atmel "AVR4027: Tips and Tricks to Optimize Your C Code for 8-bit AVR Microcontrollers" [1]. Затронуты следующие темы:

• Ядро Atmel® AVR® и ознакомление с Atmel AVR GCC
• Советы по уменьшению размера кода
• Советы по уменьшению времени выполнения
• Примеры программ

[Введение]

Ядро AVR построено на продвинутой технологии RISC, и оно оптимизировано под кодирование на языке C. Это обеспечивает хорошее качество разработки с получением большего количества возможностей за меньшую цену. Прим. переводчика: на самом деле, если сравнивать с архитектурой MCS51, в плане объема кода у AVR не все так хорошо. Места под код уходит гораздо больше, потому что большинство команд ассемблера AVR занимают как минимум 2 байта.

Когда идет речь об оптимизации, то обычно имеют в виду два аспекта: объем кода (size optimization) и скорость работы кода (speed optimization). В настоящее время компиляторы C имеют различные опции оптимизации, чтобы помочь разработчикам получить эффективный код либо по размеру, либо по скорости (прим. переводчика: на самом деле чаще всего используется оптимизация по размеру -Os, так как эта оптимизация также позволяет получить и более быстрый код по сравнению с отключенной оптимизацией).

Однако хорошее кодирование на C дает компилятору больше возможностей оптимизировать код, что почти всегда необходимо разработчику. В некоторых случаях оптимизация по одному из аспектов приводит к деградации параметров по другому аспекту, так что разработчик выбирает компромисс для сбалансированного согласования размера кода и скорости выполнения - в зависимости от конкретных условий и техзадания. Понимание некоторых принципов и следование определенным советам в кодировании на языке C для 8-bit AVR помогает разработчику правильно фокусироваться на улучшении эффективности кода в нужном направлении.

В этом апноуте советы базируются на использовании avr-gcc (компилятор C). Однако рассмотренные принципы могут быть использованы и для других компиляторов с соответствующими опциями.

[2 Знания ядра Atmel AVR и Atmel AVR GCC]

Перед оптимизированием программного обеспечения для встраиваемых систем (firmware) необходимо иметь хорошее понимание следующего: какую структуру имеет ядро AVR, и какие стратегии использует AVR GCC для генерирования эффективного кода для этого процессора (прим. переводчика: обычно проприетарные платные компиляторы, например от IAR, позволяют получать гораздо более эффективный код). Здесь приведена короткая сводка возможностей ядра AVR и AVR GCC.

2. 8-битная архитектура Atmel AVR

AVR использует Гарвардскую архитектуру ядра – с раздельным адресным пространством памяти и раздельными шинами для памяти программ (FLASH) и памяти данных (SRAM). AVR имеет регистровый файл 32x8 с быстрым доступом, используемые как рабочие регистры с временем доступа за 1 тактовый цикл. Использование 32 рабочих регистров является ключом к эффективному кодированию на C. Эти регистры имеют тот же функционал, что и традиционный аккумулятор, за исключением того, что их количество 32 штуки. Арифметика AVR и инструкции логики работают для этих регистров, следовательно код может занимать меньше места. За один такт AVR может предоставить два любых регистра для ALU, выполнить нужную операцию, и записать результат обратно в регистровый файл.

Инструкции в памяти программ выполняются в конвейеризацией на одном уровне. Когда выполняется одна инструкция, следующая инструкция предварительно выбирается из памяти программ. Эта концепция дает возможность выполнять по одной инструкции на каждый такт. Большинство инструкций AVR имеют формат одного 16-битного слова. Каждый адрес памяти программ содержит 16- или 32-битную инструкцию.

Пожалуйста обратитесь к секции “AVR CPU Core” даташита для получения дополнительной информации.

2.2 AVR GCC

GCC означает GNU Compiler Collection (коллекция компилятора GNU). Когда GCC используется для цели AVR, он обычно известен как AVR GCC. Действительный исполняемый файл компилятора имеет префикс "avr-", в результате получается "avr-gcc".

AVR GCC предоставляет несколько уровней оптимизации. Это -O0, -O1, -O2, -O3 и -Os. На каждом уровне разрешаются разные опции оптимизации, за исключением опции -O0, которая означает отсутствие оптимизации. Помимо вариантов, рассортированных по уровням оптимизации, Вы можете использовать разные опции оптимизации для получение специфичной оптимизации. Пожалуйста обратитесь к руководству GNU Compiler Collection [3] для получения полного списка опций и уровней оптимизации.

Прим. переводчика: отключение оптимизации -O0 обычно используют только для пошаговой отладки по коду, чтобы можно было нормально оценить алгоритм работы программы и просмотреть значение переменных на каждом шаге.

Помимо "avr-gcc" требуется много дополнительных инструментов для получения конечного исполняемого кода firmware для микроконтроллера AVR. Сгруппированный набор этих инструментов называется тулчейном (toolchain). В этом AVR тулчейне avr-libc служит важной библиотекой кода C, предоставляющей многие те же самые функции, которые можно найти в обычной стандартной библиотеке C и многие дополнительные библиотечные функции, специфичные для AVR.

Пакет AVR Libc предоставляет подмножество функций стандартной библиотеки C для микроконтроллеров Atmel AVR 8-bit RISC. Дополнительно библиотека предоставляет базовый код запуска (startup code), который нужен для большинства приложений. См. руководство avr-libc [4].

Прим. переводчика: когда Вы ищите ошибки в программе и отлаживаете её, то обязательно опробуйте все варианты оптимизации по скорости и размеру кода. Во всех выбранных вариантах оптимизации - в том числе и с отключенной оптимизацией - код должен работать всегда одинаково и надежно, и его работоспособность не должна теряться. Если программа в каком-то варианте не работает, то значит либо код имеет необнаруженную ошибку, либо написан неоптимально, либо есть узкое место. Управление оптимизацией [8] - хорошее средство для тестирования качества кода, не забывайте о нем!

2.3 Платформа разработки

Примеры кода и результаты тестирования этого документа базируются на следующих условиях:

1. Интегрированная система разработки (Integrated Development Environment, IDE): Atmel AVR Studio® 5 (Version: 5.0.1119).
2. AVR GCC 8-bit Toolchain Version: AVR_8_bit_GNU_Toolchain_3.2.1_292 (gcc version 4.5.1).
3. Целевой микроконтроллер AVR (Target Device): Atmel ATmega88PA.

[3 Способы уменьшения размера кода]

В этой секции будут перечислены некоторые советы для уменьшения размера кода. Для каждого будет дано описание и пример кода.

3.1 Подсказка #1 – типы и размеры данных

Используйте всегда самые маленькие по размеру данные, насколько это возможно. Оцените свой код на используемые в нем типы данных. Чтение 8-битного значения из регистра требует только однобайтовой переменной, и ни в коем случае не двухбайтовой, это позволит экономить место под код. Размеры типов данных для 8-бит AVR можно найти в хедере < stdint.h >, и они перечислены в таблице 3-1.

Таблица 3-1. Типы данных 8-бит AVR.

Тип данных Размер
signed char / unsigned char int8_t / uint8_t 8 бит
signed int / unsigned int int16_t / uint16_t 16 бит
signed long / unsigned long int32_t / uint32_t 32 бита
signed long long / unsigned long long int64_t / uint64_t 64 бита

Избегайте некоторых опций компилятора, которые могут изменить эту таблицу (например, avr-gcc -mint8 приводит тип int к 8-битному целому).

В таблице 3-2 показаны примеры использования типов данных разного размера. Использовалась оптимизация по размеру -Os. Вывод от утилиты avr-size дает возможность оценить размер кода.

Таблица 3-2. Пример использования типов данных разного размера.

  unsigned int (16 бит) unsigned char (8 бит)
Код на C
#include < avr/io.h > 
 
unsigned int readADC()
{ 
   return ADCH; 
}; 
 
int main(void) 
{ 
   unsigned int mAdc = readADC();
}
#include < avr/io.h > 
 
unsigned char readADC()
{ 
   return ADCH; 
}; 
 
int main(void) 
{ 
   unsigned char mAdc = readADC();
}
Использование памяти AVR Program: 92 bytes (1.1% full)

Program: 90 bytes (1.1% full)

Уровень оптимизации компилятора -Os (оптимизация по размеру кода)

-Os (оптимизация по размеру кода)

В примере слева использовался тип данных int (2 байта) для возвращаемой величины из функции readADC(), и для временной переменной, используемой для хранения возвращенного результата readADC().

В примере слева использовался тип данных char (1 байт). Чтение из регистра ADCH дает только 8 бит, так что размера char достаточно. Видно, что получена экономия 2 байт, когда для возвращаемой величины readADC() и для временной переменной использовалась однобайтовая переменная.

Примечание: в этой программе C используется код запуска (startup code), который выполняется до входа в функцию main(). Именно поэтому такая простая программа на C занимает целых 90 байт.

3.2 Подсказка #2 – глобальные и локальные переменные

В большинстве случаев не рекомендуется использовать глобальные переменные. Всегда, где только возможно, используйте локальные переменные. Если переменная используется только в функции, то она должна быть декларирована внутри тела функции как локальная переменная.

В теории программирования выбор между декларированием глобальной или локальной переменной зависит от того, как будет использоваться эта переменная.

Если декларируется глобальная переменная, то ей назначается уникальный адрес SRAM во время линковки программы. Также доступ к глобальной переменной обычно будет требовать дополнительных байт кода (обычно 2 байта для большого 16-битного адреса) для получения адреса переменной.

Локальные переменные при декларировании предпочтительно назначаются на регистр или выделенное место в стеке. Как только функция активизируется, локальные переменные также автоматически становятся активными. На выходе из функции локальные переменные могут быть уничтожены.

В таблице 3-3 показано два примера с глобальными и локальными переменными.

Таблица 3-3. Пример использования глобальных и локальных переменных.

  глобальные переменные локальные переменные
Код на C
#include < avr/io.h > 
 
uint8_t global_1; 
 
int main(void) 
{ 
   global_1 = 0xAA; 
   PORTB = global_1; 
}
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t local_1; 
 
   local_1 = 0xAA; 
   PORTB = local_1; 
}
Использование памяти AVR

Program: 104 bytes (1.3% full)
(.text + .data + .bootloader)
Data: 1 byte (0.1% full)
(.data + .bss + .noinit)

Program: 84 bytes (1.0% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Уровень оптимизации компилятора -Os (оптимизация по размеру кода)

-Os (оптимизация по размеру кода)

В примере слева была декларирована однобайтовая глобальная переменная. Вывод утилиты avr-size показал 104 байта занятого места под код на включенной оптимизации -Os (оптимизация по размеру кода).

В примере справа переменная была декларирована в теле функции main() как локальная, размер кода уменьшился до 84 байт, и при этом память SRAM вообще не использовалась.

3.3 Подсказка #3 – индекс цикла (Loop index)

Циклы широко используются в коде 8-bit AVR. Это циклы "while ( ) { }", "for ( )" и "do { } while ( )". Если разрешена опция оптимизации -Os, компилятор будет автоматически оптимизировать циклы для получения того же размера кода.

Однако и тут еще можно незначительно уменьшить объем кода. Если используется цикл "do { } while ( )", то инкремент или декремент индекса цикла будет давать в результате разный размер кода. Обычно мы пишем циклы так, что индекс считает от 0 до максимального значения (используется инкремент), но более эффективно использовать счетчик цикла от максимума до нуля (декремент).

Причина в том, что цикл с инкрементом нуждается в инструкции сравнения - чтобы сравнить текущее значение индекса цикла с максимальной величиной на каждой итерации цикла. Так проверяется, достигла ли величина индекса максимального значения. Когда используется цикл с декрементом, то это сравнение уже не нужно, потому что результат декремента индекса цикла установит флаг нуля Z (zero flag) в регистре SREG, если индекс стал равен 0.

В таблице 3-4 даны два примера, показывающих сгенерированный код для цикла "do { } while ( )" в вариантах с инкрементом и декрементом. Здесь также включена оптимизация по размеру кода -Os.

Таблица 3-4. Примеры цикла do { } while ( ) с инкрементом и декрементом переменной индекса цикла.

  do {} while с инкрементом индекса do {} while с декрементом индекса
Код на C
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t local_1 = 0; 
 
   do
   { 
      PORTB ^= 0x01; 
      local_1++; 
   } while (local_1 < 100); 
}
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t local_1 = 100;
 
   do
   { 
      PORTB ^= 0x01; 
      local_1--; 
   } while (local_1); 
}
Использование памяти AVR

Program: 96 bytes (1.2% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Program: 94 bytes (1.1% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Уровень оптимизации компилятора -Os (оптимизация по размеру кода)

-Os (оптимизация по размеру кода)

Чтобы ясно сравнить строки кода C, этот пример написан в стиле "do {count-- ;} while (count);", а не как обычно использующийся в книгах код "do {} while (--count);". Эти два стиля генерируют алгоритмически одинаковый код.

3.4 Подсказка #4 – совмещение циклов (Loop jamming)

Под Loop jamming подразумевается интегрирование операторов и операций из различных циклов с целью уменьшения количества циклов или для создания одного цикла - чтобы уменьшить количество циклов в коде.

В некоторых случаях циклы реализуются один за другим. В результате может получиться большое количество итераций. В этом случае loop jamming может помочь в увеличении эффективности кода, если несколько циклов скомбинировать в один.

Loop jamming уменьшает размер кода и работает быстрее, потому что устраняется лишняя нагрузка по повторению циклов. В таблице 3-5 можно увидеть, как работает loop jamming.

Таблица 3-5. Пример использования loop jamming.

  раздельные циклы совмещенные циклы
Код на C
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t i, total = 0; 
   uint8_t tmp[10] = {0}; 
 
   for (i=0; i<10; i++)
   { 
      tmp [i] = ADCH; 
   } 
   for (i=0; i<10; i++)
   { 
      total += tmp[i]; 
   }
   UDR0 = total; 
}
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t i, total = 0; 
   uint8_t tmp[10] = {0}; 
 
   for (i=0; i<10; i++)
   { 
      tmp [i] = ADCH; 
      total += tmp[i]; 
   }
   UDR0 = total; 
}
Использование памяти AVR

Program: 164 bytes (2.0% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Program: 98 bytes (1.2% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Уровень оптимизации компилятора -Os (оптимизация по размеру кода)

-Os (оптимизация по размеру кода)

3.5 Подсказка #5 – константы в памяти программ

Часто в программе память SRAM заканчивается быстрее, чем память FLASH. Глобальные переменные констант, таблицы или массивы, содержимое которых остается всегда неизменным, должны быть обычно размещены в секции только для чтения (read-only section, Flash или EEPROM на 8-bit AVR). Это поможет сохранить свободное место в дефицитной памяти SRAM.

В этом примере не будет использоваться ключевое слово "const" языка C. Декларирование объекта со словом "const" всего лишь показывает, что его величина не будет изменена. Атрибут "const" используется, чтобы сообщить компилятору, что данные в переменной только для чтения (read-only), и это увеличивает возможности для оптимизации. Атрибут "const" не говорит о том, где должны быть размещены данные.

Для размещения данных в области памяти программ (read-only) и получения их оттуда библиотека AVR-Libc предоставляет простой макрос "PROGMEM" и макрос "pgm_read_byte". Эти макросы определены в системном хедере < avr/pgmspace.h >.

В таблице 3-6 показано, как можно сохранить память SRAM путем переноса глобальной переменной строки в область памяти программ.

Таблица 3-6. Пример констант в памяти программ (FLASH).

  константы в области данных (SRAM) константы в области памяти программ (FLASH)
Код на C
#include < avr/io.h > 
 
uint8_t string[12] = {"hello world!"}; 
 
int main(void) 
{ 
   UDR0 = string[10]; 
}
#include < avr/io.h >
#include < avr/pgmspace.h >
 
uint8_t string[12] PROGMEM = {"hello world!"};
 
int main(void) 
{ 
   UDR0 = pgm_read_byte(&string[10]); 
}
Использование памяти AVR

Program: 122 bytes (1.5% full)
(.text + .data + .bootloader)
Data: 12 bytes (1.2% full)
(.data + .bss + .noinit)

Program: 102 bytes (1.2% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Уровень оптимизации компилятора -Os (оптимизация по размеру кода)

-Os (оптимизация по размеру кода)

После того, как мы разместили константы в памяти программ, занятые области программы и данных обе уменьшились. Однако при чтении данных из памяти программ будет незначительная лишняя нагрузка, потому что выполнение функции будет медленнее, чем прямое чтение из SRAM.

Если данные, сохраненные в FLASH, используются в коде несколько раз, то мы получим размер кода меньше, если будет использоваться временная переменная вместо прямого использования макроса "pgm_read_byte" несколько раз.

В файле системного хедера < avr/pgmspace.h > имеется еще функции и макросы для сохранения/получения различных типов данных в/из области памяти программ. Для дополнительной информации обратитесь к руководству avr-libc. Также см. [5].

3.6 Подсказка #6 – типы доступа: static

Для глобальных данных используйте везде, где это возможно, ключевое слово static. Если глобальные переменные декларированы с ключевым словом static, то они могут быть доступны только в том файле (модуле C), где были определены. Это предотвращает от незапланированного использования переменной (как внешней переменной, external) в коде других модулей. Это уменьшает количество потенциальных ошибок, что безусловно полезно (хотя и никак не влияет на размер и быстродействие кода).

С другой стороны, необходимо избегать использования static для локальных переменных, декларированных в теле функции. Значение локальных "static" переменных должно быть сохранено между отдельными вызовами функции, и значение переменной сохраняется в течение всего времени работы программы. Это требует постоянной выделенной области данных (SRAM), а также дополнительного кода для доступа к таким переменным. Переменная static в теле функции ведет себя для аспектов оптимизации так же, как и глобальная переменная, и отличается от глобальной переменной только областью видимости (переменная static видна только в теле функции, где была определена).

Функция static проще в оптимизации, поскольку её имя невидимо извне файла (модуля C), где функция была определена, и она не может быть вызвана из других модулей. Если функция static вызывается только 1 раз в файле, компиляция которого произведена с оптимизацией (-O1, -O2, -O3 и -Os), то функция будет автоматически оптимизирована компилятором и заменена встраиванием кода (inline function) при этом не будут использоваться ассемблерные инструкции вызова подпрограммы (call) и возврата (ret). Посмотрите примеры в таблице 3-7.

Таблица 3-7. Пример типов доступа: статическая функция.

  глобальная функция (вызывается один раз) функция static (вызывается один раз)
Код на C
#include < avr/io.h > 
 
uint8_t string[12] = {"hello world!"}; 
 
void USART_TX(uint8_t data); 
 
int main(void) 
{ 
   uint8_t i = 0; 
   while (i<12)
   { 
      USART_TX(string[i++]); 
   } 
} 
 
void USART_TX(uint8_t data) 
{ 
   while(!(UCSR0A&(1 << UDRE0))); 
   UDR0 = data; 
}
#include < avr/io.h > 
 
uint8_t string[12] = {"hello world!"}; 
 
static void USART_TX(uint8_t data); 
 
int main(void) 
{ 
   uint8_t i = 0; 
   while (i<12)
   { 
      USART_TX(string[i++]); 
   } 
} 
 
void USART_TX(uint8_t data) 
{ 
   while(!(UCSR0A&(1 << UDRE0))); 
   UDR0 = data; 
}
Использование памяти AVR

Program: 152 bytes (1.9% full)
(.text + .data + .bootloader)
Data: 12 bytes (1.2% full)
(.data + .bss + .noinit)

Program: 140 bytes (1.7% full)
(.text + .data + .bootloader)
Data: 12 bytes (1.2% full)
(.data + .bss + .noinit)

Уровень оптимизации компилятора -Os (оптимизация по размеру кода)

-Os (оптимизация по размеру кода)

Примечание: если функция вызывается несколько раз, то она не будет преобразована во встраиваемую (inline function), потому что это приведет к увеличению размера кода по сравнению с прямым вызовом функции (ассемблерные инструкции call/ret).

3.7 Подсказка #7 – низкоуровневые инструкции ассемблера

Хорошо написанный код на ассемблере всегда лучше оптимизированного кода C. Недостаток кодирования на ассемблере - непортируемый (и довольно запутанный для AVR GCC) синтаксис, так что его использование в большинстве случаев не рекомендуется.

Однако использование макросов на ассемблере уменьшает головную боль, часто связанную с кодом ассемблера, а также улучшает читаемость и портируемость кода. Используйте макросы вместо функций для задач, которые генерируют меньше 2..3 строк кода ассемблера. Пример в таблице 3-8 показывает использование кода в ассемблерном макросе в сравнении с использованием функции.

Таблица 3-8. Пример использования низкоуровневых инструкций ассемблера.

  функция макрос на ассемблере
Код на C
#include < avr/io.h > 
 
void enable_usart_rx(void) 
{ 
   UCSR0B |= 0x80; 
}; 
 
int main(void) 
{ 
   enable_usart_rx(); 
   while (1) {} 
}
#include < avr/io.h > 
 
#define enable_usart_rx() \ 
__asm__ __volatile__ ( \ 
    "lds r24,0x00C1" "\n\t" \ 
    "ori r24, 0x80" "\n\t" \ 
    "sts 0x00C1, r24" \ 
    ::) 
 
int main(void) 
{ 
   enable_usart_rx(); 
   while (1){} 
}
Использование памяти AVR

Program: 90 bytes (1.1% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Program: 86 bytes (1.0% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Уровень оптимизации компилятора -Os (оптимизация по размеру кода)

-Os (оптимизация по размеру кода)

Для подробностей по использованию кода ассемблера совместно с кодом на C платформы 8-bit AVR обратитесь к секции "Inline Assembler Cookbook" руководства пользователя avr-libc. См. также [6].

[4 Способы уменьшения времени выполнения]

В этой секции будут рассмотрены некоторые советы по уменьшению времени выполнения кода (это повышает скорость работы программы). Для каждого совета будут даны описание и пример кода.

4.1 Подсказка #8 – типы и размеры данных

В дополнение к уменьшению размера кода, правильный выбор типа и размера данных также уменьшает и время выполнения. Для 8-bit AVR доступ к 8-bit (Byte) переменной всегда будет самым эффективным. В таблице 4-1 показано различие использования 8-bit и 16-bit переменных.

Таблица 4-1. Примеры использования типов данных разного размера.

  16-битная переменная 8-битная переменная
Код на C
#include < avr/io.h > 
 
int main(void) 
{ 
   uint16_t local_1 = 10; 
 
   do
   {
      PORTB ^= 0x80; 
   } while (--local_1);
}
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t local_1 = 10; 
 
   do
   {
      PORTB ^= 0x80; 
   } while (--local_1);
}
Использование памяти AVR

Program: 94 bytes (1.1% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Program: 92 bytes (1.1% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Счетчик тактов

90

79

Уровень оптимизации компилятора -O2

-O2

Примечание: при использовании опции –O3 циклы будут автоматически развернуты. Тогда итерации цикла будут преобразованы в повторяющиеся операции с количеством индекса цикла, так что для этого примера не будет различия в генерируемом коде при использовании опции –O3.

4.2 Подсказка #9 - оператор условия

Обычно преддекремент и постдекремент (или прединкремент и постинкремент) в нормальном коде не имеют различий. Например, "i--;" и "--i;" просто сгенерируют один и тот же ассемблерный код. Однако использование этих операторов в условии цикла приводят в различиям в генерируемом коде.

Как показано в "подсказке #3 – индекс цикла", использование индекса цикла с декрементом эффективнее в аспекте размера кода. Но это также позволяет получить более быстрый код для операторов условия.

Кроме того, преддекремент и постдекремент также дают разные результаты. В таблице 4-2 можно увидеть, что для оператора условия с преддекрементом код получается быстрее. Значение счетчика тактов (cycle counter) показывает время выполнения самой длинной итерации цикла.

Таблица 4-2. Пример различных операторов условия цикла.

  постдекремент в операторе условия преддекремент в операторе условия
Код на C
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t loop_cnt = 9; 
   do
   {
      if (loop_cnt--)
      { 
         PORTC ^= 0x01; 
      }
      else
      { 
         PORTB ^= 0x01;
         loop_cnt = 9;
      } 
   } while (1);
}
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t loop_cnt = 10; 
   do
   {
      if (--loop_cnt)
      {
         PORTC ^= 0x01; 
      }
      else
      {
         PORTB ^= 0x01;
         loop_cnt = 10;
      }
   } while (1);
}
Использование памяти AVR

Program: 104 bytes (1.3% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Program: 102 bytes (1.2% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Счетчик тактов

75

61

Уровень оптимизации компилятора -O3

-O3

Переменной "loop_cnt" в двух примерах таблицы 4-2 присвоены разные значения, чтобы код работал одинаково: PORTC0 будет переключен 9 раз, POTRB0 переключится один раз на каждое повторение цикла.

4.3 Подсказка #10 – развернутые циклы

В некоторых случаях нужно развернуть циклы в последовательность одинаковых операций, чтобы увеличить скорость выполнения кода. Это особенно эффективно для коротких циклов. После того, как цикл развернут, нет никакой операции проверки цикла, и выполняется гораздо меньше операций на каждую итерацию цикла. Пример в таблице 4-3 переключает ножку порта 10 раз.

Таблица 4-3. Пример развертки цикла.

  обычный цикл развернутый цикл
Код на C
#include < avr/io.h > 
 
int main(void) 
{ 
   uint8_t loop_cnt = 10; 
   do
   {
      PORTB ^= 0x01; 
   } while (--loop_cnt);
}
#include < avr/io.h > 
 
int main(void) 
{ 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
   PORTB ^= 0x01; 
}
Использование памяти AVR

Program: 94 bytes (1.5% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.1% full)
(.data + .bss + .noinit)

Program: 142 bytes (1.7% full)
(.text + .data + .bootloader)
Data: 0 bytes (0.0% full)
(.data + .bss + .noinit)

Счетчик тактов

80

50

Уровень оптимизации компилятора -O2

-O2

Путем развертки цикла do { } while ( ) мы значительно увеличили скорость выполнения кода с 80 циклов до 50.

Имейте в виду, что после развертки цикла размер кода увеличился с 94 до 142 байт. Это хороший пример компромисса в оптимизации между скоростью кода и его размером.

Примечание: если для этого примера разрешена опция -O3, то компилятор сам автоматически развернет цикл, и будет сгенерирован тот же самый код, что и написанный вручную как в примере справа.

4.4 Подсказка #11 – управление потоком выполнения (Control flow): If-else и switch-case

Конструкции "if-else" и "switch-case" широко используются в программировании на C; правильная организация ветвей может уменьшить время выполнения кода.

Для "if-else" всегда помещайте наиболее вероятные условия на первые места. Тогда другие условия будут проверены реже, и код будет выполняться быстрее. В большинстве случаев это экономит время выполнения.

Использование "switch-case" устраняет недостаток "if-else", потому что для "switch-case" компилятор обычно генерирует таблицы просмотра (lookup tables) с индексом и прямым переходом в нужное место.

Когда сложно использовать "switch-case", мы можем разделить ветви "if-else" на уменьшенные подветви. Этот метод уменьшает время выполнения для самого худшего случая. В примере ниже данные считываются из ADC и затем посылаются через USART. Наихудшем случаем является результат ad_result < = 240.

Таблица 4-4. Пример подветви if-else.

  ветвь if-else подветвь if-else
Код на C
#include < avr/io.h > 
 
uint8_t ad_result; 
 
uint8_t readADC()
{ 
   return ADCH; 
}; 
 
void send(uint8_t data)
{ 
   UDR0 = data; 
}; 
 
int main(void) 
{ 
   uint8_t output; 
   ad_result = readADC(); 
 
   if(ad_result <= 30)
   { 
      output = 0x6C; 
   }
   else if(ad_result <= 60)
   { 
      output = 0x6E; 
   }
   else if(ad_result <= 90)
   { 
      output = 0x68; 
   }
   else if(ad_result <= 120)
   { 
      output = 0x4C; 
   }
   else if(ad_result <= 150)
   {
      output = 0x4E; 
   }
   else if(ad_result <= 180)
   { 
      output = 0x48; 
   }
   else if(ad_result <= 210)
   { 
      output = 0x57; 
   }
   else if(ad_result <= 240)
   { 
      output = 0x45; 
   } 
   send(output); 
}
int main(void) 
{ 
   uint8_t output; 
   ad_result = readADC(); 
 
   if (ad_result <= 120)
   { 
      if (ad_result <= 60)
      { 
         if (ad_result <= 30)
         { 
            output = 0x6C; 
         } 
         else
         { 
            output = 0x6E; 
         }
      } 
      else
      { 
         if (ad_result <= 90)
         { 
            output = 0x68; 
         } 
         else
         { 
            output = 0x4C; 
         } 
      } 
   } 
   else
   { 
      if (ad_result <= 180)
      {
         if (ad_result <= 150)
         { 
            output = 0x4E; 
         } 
         else
         { 
            output = 0x48; 
         } 
      } 
      else
      { 
         if (ad_result <= 210)
         { 
            output = 0x57; 
         } 
         else
         { 
            output = 0x45; 
         } 
      } 
   } 
   send(output); 
}
Использование памяти AVR

Program: 198 bytes (2.4% full)
(.text + .data + .bootloader)
Data: 1 byte (0.1% full)
(.data + .bss + .noinit)

Program: 226 bytes (2.8% full)
(.text + .data + .bootloader)
Data: 1 byte (0.1% full)
(.data + .bss + .noinit)

Счетчик тактов

58 (самый плохой случай)

48 (самый плохой случай)

Уровень оптимизации компилятора -O3

-O3

Мы можем увидеть, что требуется меньше времени для достижения ветви в самом худшем случае. При этом можно заметить, что размер кода увеличился. Таким образом, здесь также виден баланс между скоростью и размером кода, который можно перевесить в нужную сторону в зависимости от специфичных требований к коду.

4.5 Постоянная привязка переменной к регистру

Это можно сделать с помощью определения переменной с директивой register:

register unsigned char counter asm("r3");

Обычно так можно безопасно использовать регистры r2 .. r7. Регистры r8 .. r15 могут использоваться для передачи аргументов компилятором, когда в функции передается много аргументов. Если не этот случай, то эти регистры также можно использовать для привязки к переменным.

Следует уделять особое внимание, чтобы приложение было скомпилировано с непротиворечивым набором привязанных к регистрам переменных, включая возможное использование библиотечных функций.

Для дополнительной информации см. врезку "Имена языка C, используемые в коде ассемблера".

По умолчанию AVR-GCC использует те же самые символические имена функций или переменных в коде C и коде ассемблера. Вы можете указать другое имя для кода ассемблера с использованием специальной формы оператора asm:

unsigned long value asm("clock") = 3686400;

Этот оператор указывает компилятору использовать символическое имя clock вместо имени value. Это имеет смысл только для внешних или статических переменных, потому что у локальных переменных нет символических имен в коде ассемблера. Однако локальные переменные могут содержаться в регистрах.

Для AVR-GCC Вы можете указать исользование конкретного регистра для локальной переменной (это ускоряет работу кода):

void Count(void)
{
    register unsigned char counter asm("r3");
 
    ... какой-то код ...
    asm volatile("clr r3");
    ... еще код ...
}

В инструкции ассемблера "clr r3" будет очищена переменная counter. AVR-GCC не будет полностью резервировать указанный регистр. Если оптимизатор распознает, что эта переменная больше не используется, то её регистр может быть использован повторно для других целей. Однако компилятор не может проверить, приведет ли это использование регистра к конфликту с другими предопределеннными регистрами. Если Вы зарезервируете таким способом слишком много регистров, то компилятор даже может столкнуться с недостатком регистров при генерации кода.

Чтобы поменять имя функции, Вам нужно декларировать её прототип, потому что компилятор не принимает ключевое слово asm в определении функции:

extern long Calc(void) asm ("CALCULATE");

Вызов функции Calc() создаст ассемблерные инструкции для вызова функции CALCULATE.

[5 Пример программы и результат тестирования]

Приложение примера используется для того, чтобы показать эффект по советам оптимизации, рассмотренным в статье. Для этого примера использовалась опция оптимизации по размеру (-s). В этом примере программы использовались некоторые (не все) подсказки для оптимизации кода. Один канал ADC используется для выборки входного сигнала, и результат передается через USART каждые 5 секунд. Если результат преобразования ADC выходит за установленные пределы, отправляется alarm за 30 секунд до того, как приложение переходит в состояние ошибки. В конце главного цикла main, микроконтроллер переходит в режим энергосбережения (power save mode).

Результаты оптимизации по скорости и размеру этого приложения (до и после оптимизации) показаны в таблице 5-1.

Таблица 5-1. Результаты оптимизации примера программы.

Объект теста До оптимизации После оптимизации Результат тестирования
Объем кода (code size) 1444 байт 630 байт -56.5%
Объем данных (data size) 25 байт 0 байт -100%
Скорость выполнения (execution speed) (1) 3.88 мс 2.6 мс -33.0%

Примечание 1: один цикл включает пять чтений выборок из ADC и одну передачу через USART.

[6 Заключение]

В этом документе были перечислены некоторые хитрости и советы для повышения эффективности кода C в аспектах размера и скорости. Благодаря возможностям современных компиляторов C в большинстве случаев они достаточно умны для автоматического использования различных опций оптимизации для разных случаев. Однако никакой компилятор не может знать код лучше разработчика, поэтому хорошее кодирование всегда остается важным. Как показано в примерах, оптимизация по одному аспекту может повлиять на другой аспект. Важно соблюсти в этом вопросе нужный баланс между размером кода и скоростью в зависимости от каждого специфичного случая (требований к коду, основанных на техзадании).

Хотя мы всегда можем использовать эти подсказки для оптимизации кода C, но для их лучшего использования требуется хорошее понимание принципов устройства микроконтроллера и работы компилятора. И определенно есть также и другие навыки и методы оптимизации эффективности кода в разных случаях реализации приложений.

[Ссылки]

1. AVR4027: Tips and Tricks to Optimize Your C Code for 8-bit AVR Microcontrollers site:atmel.com.
2. "Нечестные" методы оптимизации программы по размеру кода в среде AVR Studio + GCC.
3. Options That Control Optimization site:gcc.gnu.org.
4. General information about AVR Libc site:nongnu.org.
5. AVR Studio +gcc: как разместить строки и константы на flash.
6. AVR-GCC: руководство по встраиванию кода на ассемблере.
7. AVR035: эффективное кодирование на C для 8-битных AVR.
8. Управление оптимизацией в IAR.
9. How to permanently bind a variable to a register? site:atmel.com.

 

Комментарии  

 
0 #7 я 06.06.2017 18:07
Цитирую я:
В FAQ Atmel есть совет с помощью ключевого слова register привязывать переменные к физическим регистрам общего назначения (прогуглите How to permanently bind a variable to a register?). Это подходит для 8-битных переменных.


Оказывается работает даже с long long, просто нужно указать адрес старшего байта переменной.
Цитировать
 
 
+2 #6 я 28.05.2017 12:03
Для скорости можно использовать флаг -Ofast.

microsin: про -Ofast в руководстве AVR GCC написано следующее: "Эта опция игнорирует строгое соответствие стандарту. -Ofast разрешает все оптимизации, которые включает опция -O3. Также -Ofast разрешает все оптимизации, которые не являются допустимыми для всех стандартно-совместимых программ. Она включает опции -ffast-math и относящиеся к Фортрану опции -fno-protect-parens и -fstack-arrays.". Так что используйте эту опцию с осторожностью.
Цитировать
 
 
+1 #5 я 16.05.2017 19:45
Легче вставлять ассемблер как тут https://embedderslife.wordpress.com/2012/02/19/avr-gcc-asm-and-c/ т. е. через библиотеку, а не вставку.
Цитировать
 
 
0 #4 я 16.05.2017 19:25
И еще с словом register не работает bit_is_set, и другие макросы из той библиотеки, не знаю почему. У меня Atmel Studio 7.0.
Цитировать
 
 
0 #3 я 16.05.2017 19:19
В старом мануале avr-libc есть флаг -mcall-prologues. сам не пробовал, но есть цитата из мануала: "So generally, it seems -Os -mcall-prologues is the most universal 'best' optimization
level. Only applications that need to get the last few percent of speed benefit from using -O3." (avr libc user manual 1.8.0, пункт 11.17).
Цитировать
 
 
0 #2 я 05.05.2017 15:23
В FAQ Atmel есть совет с помощью ключевого слова register привязывать переменные к физическим регистрам общего назначения (прогуглите How to permanently bind a variable to a register?). Это подходит для 8-битных переменных.
Цитировать
 
 
+2 #1 Дмитрий 28.07.2016 17:16
Подсказка #10 – развернутые циклы. В данном примере выгоднее будет вставить ассемблерные команды наподобие asm("sbi PORTB, 0 "); asm("cbi PORTB, 0"); .. в итоге 10 слов FLASH и 10 тактов выполнения (в большинстве случаев, так как порт B будет в основном лежать до адреса 0x3F).
Цитировать
 

Добавить комментарий


Защитный код
Обновить

Top of Page