AVR035: эффективное кодирование на C для 8-битных AVR Печать
Добавил(а) microsin   

В даташите AVR035 [1] рассматриваются следующие вопросы:

• Доступ к ячекам памяти, касающихся ввода/вывода (I/O)
• Доступ к I/O, привязанных к памяти (Memory Mapped I/O)
• Доступ к данным в памяти программ (Data в памяти FLASH)
• Доступ к данным в энергонезависимой памяти (Data в EEPROM)
• Создание файлов данных в EEPROM (EEPROM Data Files)
• Эффективное использование переменных и данных
• Контрольный список для отладки программ
• Использование битовых полей и битовых масок (Bit-field и Bit-mask)
• Использование макросов и функций
• 18 способов уменьшения размера кода
• 5 способов экономии RAM

Высокоуровневый язык C (High-level Language, HLL) становится все более популярен для программирования микроконтроллеров. Есть очень много достоинств при использовании C в сравнении с ассемблером: сокращение времени разработки, код легче поддерживать и портировать, и код проще использовать повторно. За это приходится платить увеличением размера кода, и в результате часто уменьшается быстродействие. Чтобы уменьшить влияние этих недостатков, архитектура AVR настроена на эффективное декодирование и выполнение инструкций, которые обычно генерируются компиляторами C.

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

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

[Архитектура, подстроенная под язык C]

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

Когда данные сохранены в эти 32 рабочих регистра, то для каждой арифметической операции не нужны перемещения данных из памяти и обратно, что значительно ускоряет работу кода. Некоторые регистры могут комбинироваться в 16-битные указатели, которые могут осуществлять эффективный доступ к данным в памяти RAM и в памяти программ (FLASH). Для больших объемов памяти указатели могут комбинироваться из 3 восьмибитных регистров, формируя 24-битный адрес, по которому можно получить доступ к 16 мегабайтам данных, и это без переключения страниц!

[Режимы адресации]

Архитектура AVR имеет 4 указателя на память, которые используются для доступа к памяти данных и программ. Указатель стека (stack pointer, SP) специально выделен для хранения адреса возврата из функции. Компилятор C выделяет один указатель как стек параметра. Два остальных указателя являются указателями общего назначения, используемыми компилятором C для загрузки и сохранения данных. Пример ниже показывает, как эффективно использовать указатели для обычных операций с указателями на C.

char *pointer1 = &table[0];
char *pointer2 = &table[49];
*pointer1++ = *--pointer2;

Последний оператор примера сгенерирует следующий код ассемблера:

      LD R16,-Z ; преддекремент указателя Z и загрузка данных
      ST X+,R16 ; сохранение данных с постинкрементом указателя X

Ниже даны примеры для четырех режимом адресации с задействованием указателя. Все операции с указателями являются инструкциями из одного слова (2 байта кода), которые выполняются за 2 такта частоты ядра AVR.

Косвенная адресация (indirect addressing). Применяется для адресации в массивах и переменных указателя:

*pointer = 0x00;

Косвенная адресация со смещением (indirect addressing with displacement). Позволяет получить доступ ко всем элементам в структуре путем указания на первый элемент структуры (через адрес указателя), и добавления смещения к адресу указателя (смещение указывает на нужное поле в структуре), причем значение указателя не изменяется. Также этот режим доступа используется для доступа к переменным в программном стеке и для доступа к ячейкам массива.

Косвенная адресация с постинкрементом (indirect addressing with post-increment). Применяется для эффективного доступа к элементам массива и к переменным указателя, когда нужен инкремент указателя после доступа:

*pointer++ = 0xFF;

Косвенная адресация с преддекрементом (indirect addressing with pre-decrement). Применяется для эффективного доступа к элементам массива и к переменным указателя, когда нужен декремент указателя до доступа:

*--pointer = 0xFF;

Указатели также используются для доступа к памяти программ (FLASH). В дополнение к косвенной адресации через указатели, память данных может быть адресована напрямую (direct addressing). Это дает доступ ко всей памяти данных в инструкции, состоящей из 2 слов (4 байта кода).

[Поддержка 16-битных и 32-битных переменных]

Набор инструкций AVR содержит специальные инструкции для работы с 16-битными числами. Они включают операцию сложения/вычитания над прямым значением и словом (Add/Subtract Immediate Values to Word), инструкции ADIW, SBIW. Арифметические операции и операции сравнения для 16-битных чисел состоят из 2 инструкций и выполняются за 2 такта частоты ядра. Есть 32-битные арифметические операции и операции сравнения, состоящие из 4 инструкций, и выполняющихся за 4 такта частоты ядра. Это эффективнее, чем аналогичные показатели у большинства 16-битных процессоров!

[Инициализация указателя стека (Stack Pointer)]

После подачи питания на микроконтроллер или после сброса (RESET) нужно загрузить указатель стека до того, как будет вызвана любая функция. Командный файл для линкера определяет размещение в памяти и размер стека, что и послужит информацией для настройки указателя стека. Конфигурирование стека (объема задействованной памяти под стек) и его настройка обсуждается в даташите AVR032 [2].

[Доступ к ячейкам памяти пространства ввода/вывода (I/O)]

Память ввода вывода AVR легко доступна на языке C. Все регистры в области памяти I/O декларируются в заголовочном файле, который именуется на основе модели микроконтроллера, наподобие ioxxxx.h, где xxxx указывает на тип AVR. Ниже показан код, где приведены примеры доступа к пространству памяти ввода/вывода. Ниже оператора на языке C приведен соответствующий код ассемблера, который будет сгенерирован для этого оператора.

#include < io8515.h >
void C_task main(void)
{
   // Декларирование переменной для промежуточных данных 
   // для чтения и записи через регистр I/O
   char temp;
   temp = PIND;            // чтение регистра PIND в переменную
   // IN R16, LOW(16) ; чтение памяти I/O
   TCCR0 = 0x4F;           // запись значения в ячейку I/O
   // LDI R17,79 ; загрузка значения
   // OUT LOW(51),R17 ; запись в память I/O
   // Установка или сброс одного бита:
   PORTB |= (1<<PD2);      // PD2 задает номер разряда (0..7) порта
   // SBI LOW(24),LOW(2) ; установка бита в пространстве I/O
   ADCSR &= ~(1<<ADEN);    // очистка бита ADEN в регистре ADCSR
   // CBI LOW(6),LOW(7) ; очистка бита в пространстве I/O
   // Установка и очистка через битовую маску:
   DDRD |= 0x0C;           // установка битов 2 и 3 регистра DDRD
   // IN R17,LOW(17) ; чтение памяти I/O
   // ORI R17,LOW(12) ; модификация
   // OUT LOW(17),R17 ; запись памяти I/O
   ACSR &= ~(0x0C);        // очистка битов 2 и 3 регистра ACSR
   // IN R17,LOW(8) ; чтение памяти I/O
   // ANDI R17,LOW(243) ; модификация
   // OUT LOW(8),R17 ; запись памяти I/O
   //Проверка: установлен или очищен один бит:
   if(USR & (1<<TXC))      // Проверка флага UART Tx
   {
      PORTB |= (1<<PB0);
      // SBIC LOW(11),LOW(6) ; прямая проверка I/O
      // SBI LOW(24),LOW(0)
      while(!(SPSR & (1<<WCOL)));   // ожидание установки флага WCOL
      // ?0003:SBIS LOW(14),LOW(6) ; прямая проверка I/O
      // RJMP ?0003
      /* Test if an I/O register equals a bitmask */
      if(UDR & 0xF3)       // проверка на 0 результата операции AND над регистром
                           // UDR и константой 0xF3
      {
      }
      // IN R16,LOW(12) ; чтение памяти I/O
      // ANDI R16,LOW(243) ; операция AND
      // BREQ ?0008 ; переход, если равно
      //?0008:
   }
   
   // Установку и очистку битов в регистрах I/O можно определить макросами:
   #define SETBIT(ADDRESS,BIT)   (ADDRESS |=  (1<<BIT))
   #define CLEARBIT(ADDRESS,BIT) (ADDRESS &= ~(1<<BIT))
   // Макрос для проверки одного бита ячейки в пространстве I/O:
   #define CHECKBIT(ADDRESS,BIT) (ADDRESS & (1<<BIT))
   //Пример использования:
   if(CHECKBIT(PORTD,PD1))    // проверка, установлен ли разряд 1 порта D
   {
      CLEARBIT(PORTD,PD1);    // сброс разряда 1 порта D
   }
   if(!(CHECKBIT(PORTD,PD1))) // проверка, сброшен ли разряд 1 порта D
   {
      SETBIT(PORTD,PD1);      // установка разряда 1 порта D
   }
   ...
}

[Адресация Memory Mapped I/O]

Некоторые микроконтроллеры AVR имеют на борту специальный интерфейс для доступа к внешней памяти данных. Этот интерфейс может использоваться для доступа к внешней памяти RAM, EEPROM, или его можно использовать как ввод/вывод, привязанный к ячейкам памяти (memory mapped I/O). Следующие примеры показывают, как декларировать, записывать и читать ячейки memory mapped I/O:

#include < io8515.h >
 
#define reg (* (char *) 0x8004) // Декларирование адреса memory mapped I/O
 
void C_task main(void)
{
   char xram;  // локальная переменная для промежуточных данных
   reg = 0x05; // запись значения по адресу memory mapped I/O
   xram = reg; // чтение адреса memory mapped I/O
}

Доступ к следующим друг за другом адресам наиболее эффективен, если декларировать постоянный указатель, и добавить к нему значение смещения. Пример ниже показывает, как таким методом получить доступ к memory mapped I/O. Для каждой инструкции показан сгенерированный код ассемблера.

// Определение адресов, привязанных к памяти (memory mapped addresses)
#define data 0x0003
#define address_high 0x0002
#define address_low 0x0001
 
void C_task main(void)
{
   // Начальный адрес в карте памяти
   unsigned char *pointer = (unsigned char *) 0x0800;
   // LDI R30,LOW(0) ; инициализация Z-указателя
   // LDI R31,8
   *(pointer+address_low) |= 0x40; // чтение и модификация ячейки по адресу
   // LDD R18,Z+1 ; загрузка переменной
   // ORI R18,LOW(64) ; модификация
   // STD Z+1,R18 ; сохранение обратно
   *(pointer+address_high) = 0x00; // запись по адресу
   // STD Z+2,R30 ; сохранение нуля
   PORTC = *(pointer+data);        // чтение по адресу и вывод в порт
   // LDD R16,Z+3 ; загрузка переменной
   // OUT LOW(21),R16 ; вывод в порт
}

Обратите внимание, что Z-указатель инициализируется до доступа к памяти, и после этого инструкции LDD и STD (Load and Store with Displacement) используются для доступа к данным. Инструкции LDD и STD состоят из одного слова и выполняются за 2 такта частоты ядра. Указатель Z загружается только 1 раз. Ячейки памяти, привязанные к вводу/выводу (Memory mapped I/O locations) могут быть декларированы как volatile, что говорит о том, что они могут быть неожиданно изменены аппаратурой, и доступ к ним не будет удален при оптимизации.

[Доступ к данным EEPROM]

Во время нормального выполнения кода внутренняя память EEPROM микроконтроллера AVR может быть прочитана и записана. Макросы компилятора IAR для чтения и записи EEPROM находятся в файле ina90.h. Обычно для чтения и записи EEPROM определены следующие макросы:

#define _EEGET(VAR,ADR)/* Чтение данных EEPROM по адресу ADR в переменную VAR */ \
{ \
 while(EECR & 0x02); /* Проверка: готова ли EEPROM к записи */ \
 EEAR = (ADR); /* Запись регистра адреса EEPROM */ \
 EECR |= 0x01; /* Установка строба чтения */ \
 (VAR) = EEDR; /* Чтение данных в переменную за следующий цикл */ \
}
 
#define _EEPUT(ADR,VAL) /* Запись VAL в EEPROM по адресу ADR */\
{\
 while(EECR&0x02); /* Проверка: готова ли EEPROM к записи */ \
 EEAR = (ADR); /* Запись регистра адреса EEPROM */ \
 EEDR = (VAL); /* Запись регистра данных EEPROM */ \
 EECR |= 0x04; /* Установка сигнала мастера записи */ \
 EECR |= 0x02; /* Установить строб записи */ \
}

Пример кода для чтения и записи EEPROM с использованием предопределенных макросов:

#include < io8515.h >
#include < ina90.h >
#define EE_ADDRESS 0x010 /* Определение константы адреса для данных EEPROM */
 
void C_task main(void)
{
   char temp;
   
   _EEGET(temp,EE_ADDRESS);   // чтение данных из EEPROM
   temp += UDR;               // добавить данные UART к переменной temp
   _EEPUT(EE_ADDRESS,temp);   // записать данные в EEPROM
}

Имейте в виду, что если разрешены прерывания, то их нужно запретить во время процесса записи EEPROM, чтобы избежать таймаута бита Master Write Enable (EEMWE). Если программа включает в себя доступ к EEPROM внутри обработчиков прерывания, прерывания должны быть также запрещены перед чтением EEPROM, чтобы избежать порчи регистра адреса EEPROM.

[Создание файлов данных в EEPROM (EEPROM Data Files)]

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

Данные EEPROM и код программы составляют 2 отдельных проекта, которые должны быть скомпилированы и линкованы по отдельности. Заголовочный файл, описывающий структуру памяти EEPROM, используется в обоих проектах - чтобы гарантировать, что данные в памяти EEPROM могут быть доступны по своим символическим именам.

Эта технология будет продемонстрирована на примере, где подразумевается, что в EEPROM находятся следующие настроечные данные:

1. Массив символов (100 байт)
2. Целое число (2 байта)
3. Два беззнаковых символа (каждый по 1 байту)

Файл заголовка EEPROM. Этот файл eeprom.h подключается и к проекту, который определяет содержимое EEPROM, и к проекту программы, которая получает доступ к EEPROM на языке C. Вот содержимое этого файла заголовка:

#define EEPROMADR(x) (unsigned int) (&((TypeEEPROMStruct *)0x0000)->x)
 
typedef struct
{
   char cArray[100];             /* массив символов */
   int iNumber;                  /* целое число */
   unsigned char uMinorVersion;  /* младший код версии */
   unsigned char uMajorVersion;  /* старший код версии */
} TypeEEPROMStruct; /* имя типа */

Директива #define содержит макрос для использования в программе C - чтобы получить адрес переменной структуры. Макрос содержит указатель-константу (0x0000). Чтобы разместить содержимое данных в EEPROM в нужном месте, этот указатель должен быть изменен (это также потребует изменение в файле линкера EEPROM, см. ниже).

Файл программы (содержимого) EEPROM. Файл eeprom.c содержит инициализацию структуры, определенной в заголовочном файле EEPROM (eeprom.h).

#include "eeprom.h" /* Подключение определения типа структуры */
 
#pragma memory=constseg(EEPROM) /* Создать структуру в именованном сегменте */
 const TypeEEPROMStruct __EEPROMInitializationData =
 {
   "Testing ",    /* инициализация cArray */
   0x100,         /* инициализация iNumber */
   0x10,          /* инициализация uMinorVersion */
   0xFF           /* инициализация uMajorVersion */
};

Конфигурационный файл линкера для EEPROM. Для линковки программы EEPROM нужен очень простой файл линкера (eeprom.xcl):

-ca90                      -! Определение модели CPU (AVR) -!
-Z(CODE)EEPROM=0-1FF       -! Адресное пространство EEPROM (внутренняя EEPROM AVR) -!

Адресное пространство установлено здесь в диапазоне 0..1FF (для примера AT90S8515), и оно должно соответствовать диапазону, который используется в микроконтроллере. Имя сегмента "EEPROM", и оно должно соответствовать директиве #pragma memory=constseg(EEPROM) в файле исходного кода eeprom.c.

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

Сборка файла данных EEPROM в формате Intel HEX. Чтобы сгенерировать файл данных содержимого EEPROM в формате Intel-Hex [3], нужно выполнить следующие команды (обратите внимание, что опции -v1 -ms и т. д. не важны для вызова icca90):

icca90 eeprom.c
xlink -f eeprom.xcl -B -Fintel-standard eeprom.r90 -o eeprom.hex

Во время линковки будет выдано следующее сообщение об ошибке:

Error[46]: Undefined external ?CL0T_1_41_L08 referred in eeprom ( eeprom.r90 )

Причина в том, что программа на C ссылается на внешний пустой символ (dummy symbol, ?CL0T_1_41_L08), чтобы гарантировать, что компилируемая программа линкована с корректной версией библиотеки. Поскольку нам не нужна линковка никакой библиотеки, то мы можем игнорировать эту ошибку, и опция -B гарантирует, что выходной файл eeprom.hex будет сгенерирован в любом случае, даже если будет выдано сообщение об ошибке. Можно поступить и по другому, если запустить сборку со следующими опциями:

xlink -f eeprom.xcl -D?CL0T_1_41_L08=0 -Fintel-standard eeprom.r90 -o eeprom.hex

Заданный символ зависит от версии процессора (-v0, -v1 и т. д.), модели памяти (-mt, -ms, и т. д.), а также от версии компилятора, так что имя этого символа может меняться от одной инсталляции к другой (сначала попытайтесь выполнить линковку, проверьте о каком неопределенном символе сообщается, и затем используйте его имя в опции -D). Содержимое сгенерированного файла будет примерно таким (eeprom.hex):

:1000000054657374696E67202000000000000000D2
:1000100000000000000000000000000000000000E0
:1000200000000000000000000000000000000000D0
:1000300000000000000000000000000000000000C0
:1000400000000000000000000000000000000000B0
:1000500000000000000000000000000000000000A0
:0800600000000000000110FF88
:00000001FF

Получение доступа к структуре в EEPROM из программы на языке C. Здесь приведен простой пример программы, которая использует определенную структуру EEPROM для получения доступа к EEPROM (главный модуль программы main.c):

#include "eeprom.h" /* Мы используем структуру и макрос */
#include < io8515.h > /* Определения имен регистров EEPROM */
 
/* Подпрограмма обработки ошибок */
void error(void)
{
   for(;;)
   ;        /* Ничего не делать, ошибка */
}
 
void C_task main(void)
{
   int i; /* Используется для чтения EEPROM */
 
   EEAR = EEPROMADR(cArray);        /* Установка адреса для 1 элемента */
   EECR |=1;                        /* Запуск чтения EEPROM */
   if(EEDR != 'T')                  /* Проверка инициализации */
      error();                      /* Нет соответствия -> ошибка */
   EEAR = EEPROMADR(iNumber);       /* Установка адреса для 2 элемента */
   EECR |=1;                        /* Запуск чтения EEPROM */
   i = EEDR ;                       /* Установить младший байт i */
   EEAR = EEPROMADR(iNumber)+1;     /* Установка адреса для второго байта */
   EECR |=1;                        /* Запуск чтения EEPROM */
   i |= EEDR<<8;                    /* Установить старший байт i */
   if(i!=0x100)                     /* Проверка инициализации */
      error();                      /* Нет соответствия -> ошибка */
   EEAR = EEPROMADR(uMinorVersion); /* Установка адреса для 3 элемента */
   EECR |=1;                        /* Запуск чтения EEPROM */
   if(EEDR != 0x10)                 /* Проверка инициализации */
      error();                      /* Нет соответствия -> ошибка */
   EEAR = EEPROMADR(uMajorVersion); /* Установка адреса для 4 элемента */
   EECR |=1;                        /* Запуск чтения EEPROM */
   if(EEDR != 0xFF)                 /* Проверка инициализации */
      error();                      /* Нет соответствия -> ошибка */
   for (;;)
      ; /* Ничего не делать (успешное завершение) */
}

Программа может быть скомпилирована в AVR Studio. Файл eeprom.hex должен быть предварительно загружен в память EEPROM с помощью программатора до запуска программы, иначе выполнение будет останавливаться в процедуре error(). EEPROM может быть загружено файлом eeprom.hex через меню File -> Up/Download после того, как программа будет загружена (понадобится ISP программатор AVR наподобие AVRISP-mkII или любой другой [4]).

[Переменные и типы данных]

Поскольку AVR является 8-разрядным микроконтроллером, постарайтесь ограничить использование 16-битных и 32-битных переменных за исключением тех случаев, когда это абсолютно необходимо. Следующий пример покажет размер кода для 8-битной и 16-битной переменной:

//8-битный счетчик
unsigned char count8 = 5; /* Декларирование переменной, присваивание ей значения */
// LDI R16,5 ; инициализация переменной
do /* Запуск цикла */
{
}while(--count8); /* Декремент счетчика цикла и проверка его на 0 */
// ?0004:DEC R16 ; декремент
// BRNE ?0004 ; переход, если не равно
 
//16-битный счетчик
unsigned int count16 = 6; /* Декларирование переменной, присваивание ей значения */
// LDI R24,LOW(6) ; инициализация младшего байта переменной
// LDI R25,0 ; инициализация старшего байта переменной
do /* Запуск цикла */
{
}while(--count16); /* Декремент счетчика цикла и проверка его на 0 */
// ?0004:SBIW R24,LWRD(1) ; вычесть из 16-битного значения
// BRNE ?0004 ; переход, если не равно

Таким образом, для 8-битной переменной в этом примере задействовано 6 байт кода, а для 16-битной переменной 8 байт кода.

[Эффективное использование переменных на C]

Программа на языке C обычно делится на множество функций, которые выполняют маленькие или большие задачи. Функции принимают данные через параметры, и также могут возвратить данные. Переменные, которые используются внутри функции, называются локальными. Переменные, декларированные вне функции, называются глобальными. Локальные переменные, у которых должно сохраняться значение между отдельными вызовами функции, должны быть декларированы с атрибутом static (статическая локальная переменная).

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

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

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

char global; /* Это глобальная переменная */
 
void C_task main(void)
{
   char local;    /* Это локальная переменная */
   global -= 45;  /* Вычитание из глобальной переменной */
   // LDS R16,LWRD(global) ; загрузка переменной из SRAM в регистр R16
   // SUBI R16,LOW(45) ; выполнение вычитания
   // STS LWRD(global),R16 ; сохранение данных обратно в SRAM
   local -= 34;   /* Вычитание из локальной переменной */
   // SUBI R16,LOW(34) ; выполнение вычитания напрямую из локальной
   // ; переменной в регистре R16
}

Обратите внимание, что инструкции LDS и STS (загрузка из SRAM и сохранение в SRAM) используются для для доступа к переменной в SRAM. Эти инструкции занимают 2 слова инструкций, и выполняются за 2 цикла тактовой частоты. Для этого примера получается следующее: для глобальной переменной 10 байт кода и 5 циклов, для локальной переменной 2 байта кода и 1 цикл.

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

Чтобы ограничить использование глобальных переменных, функции могут быть вызваны с параметрами и возвращать значение, которое может использоваться на C как обычно. Если для передачи функции используется до 2 параметров простого типа (char, int, float, double), то они передаются через регистры R16..R23. Если у функции 2 параметров, или в параметре передаются сложные типы данных (массивы, структуры), то они при вызове функции помещаются либо в программный стек, либо передаются между функциями как указатели на ячейки в SRAM.

Когда нужны глобальные переменные, то они должны быть собраны в структуры, если это уместно. Это делает возможным для компилятора адресовать их косвенно (indirect addressing). В следующем примере показана генерация кода для случая использования глобальных переменных в сравнении с использованием глобальной структуры.

typedef struct
{
   char sec;
}t;
 
t global;   /* Декларирование глобальной структуры */
 
char min;   /* Декларирование глобальной переменной */
 
void C_task main(void)
{
   t *time = &global;
   // LDI R30,LOW(global) ; инициализация указателя Z
   // LDI R31,(global >> 8) ; инициализация старшего байта Z
   if (++time->sec == 60)
   {
      // LDD R16,Z+2 ; загрузка со смещением (Load with displacement)
      // INC R16 ; инкремент
      // STD Z+2,R16 ; сохранение со смещением (Store with displacement)
      // CPI R16,LOW(60) ; сравнение
      // BRNE ?0005 ; переход по метке ?0005, если не равно
   }
   if ( ++min == 60)
   {
      // LDS R16,LWRD(min) ; прямая загрузка из SRAM
      // INC R16 ; инкремент
      // STS LWRD(min),R16 ; прямое сохранение в SRAM
      // CPI R16,LOW(60) ; сравнение
      // BRNE ?0005 ; переход по метке ?0005, если не равно
   }
}

Когда происходит доступ к глобальным переменным через структуру, то компилятор использует указатель Z и инструкции LDD и STD (Load/store with displacement) для доступа к данным переменной. Когда глобальная переменная определена без структуры, компилятор использует инструкции LDS и STS (Load/store direct to SRAM). Различие в размере кода для этого примера получается следующее: для кода со структурой тратится 10 байт под код доступа к переменной, а для переменной без структуры 14 байт. Разница очевидна, однако тут не учитывается инициализация указателя Z на адрес начала структуры (4 байта). Таким образом, если имеется доступ к одному байту, то экономии в размере кода не будет, но если структура содержит несколько переменных - 2 байта или более, то использование глобальной структуры будет эффективнее, чем использование глобальных переменных.

Неиспользуемые места в памяти I/O могут быть задействованы под сохранение глобальных переменных, когда соответствующая периферия не используется. Например, если UART не используется в программе, то как ячейка памяти доступен регистр выбора скорости UART Baud Rate
Register (UBRR). Аналогично, если не используется EEPROM, то можно в программе под глобальные переменные использовать регистр данных EEPROM data register (EEDR) и регистр адреса EEPROM Address Register (EEAR).

Доступ к I/O memory очень эффективен, и в ячейках I/O memory ниже 0x1F могут адресоваться отдельные биты.

[Что эффективнее - битовые поля или битовые маски?]

Чтобы экономить на байтах памяти, когда нужно сохранять двоичные флаги, можно эти флаги хранить в отдельных битах одного байта. Получается байт состояния, в котором упаковано несколько битовых флагов. Этот функционал можно реализовать двумя способами: получать доступ к флагам либо через битовую маску (bit-mask), либо через битовые поля (bit-field, см. [5]). Ниже приведен пример использования битовой маски и битового поля для декларирования байта состояния (status byte):

/* Использование bit-mask для битов состояния */
/* Определение битовых макросов, которые работают аналогично макросам I/O */
#define SETBIT(x,y) (x |= (y)) /* Установка бита y в байте x */
#define CLEARBIT(x,y) (x &= (~y)) /* Очистка бита y в байте x */
#define CHECKBIT(x,y) (x & (y)) /* Проверка бита y в байте x */
 
/* Определение констант маски для битов состояния */
#define RETRANS 0x01 /* bit 0 : флаг повторной передачи */
#define WRITEFLAG 0x02 /* bit 1 : флаг устанавливается, когда должна быть запись */
#define EMPTY 0x04 /* bit 2 : флаг опустошения буфера */
#define FULL 0x08 /* bit 3 : флаг заполнения буфера */
  
void C_task main(void)
{
   char status; /* Декларирования байта состояния (status byte) */
   
   // Очистка флагов RETRANS и WRITEFLAG:
   CLEARBIT(status,RETRANS); 
   CLEARBIT(status,WRITEFLAG);
   // Проверка: очищен ли флаг RETRANS:
   if (!(CHECKBIT(status, RETRANS)))
   {
      SETBIT(status,WRITEFLAG);
   }
}

Битовые маски обрабатываются компилятором C очень эффективно, если переменная состояния декларирована как локальная, и используется в пределах функции. Альтернативно с битовыми масками используйте незанятые ячейки I/O, это тоже эффективно.

А вот тот же самый код, но с использованием битовых полей:

/* Использование битовых полей для битов состояния */
void C_task main(void)
{
   struct
   {
      char RETRANS: 1;     /* bit 0 : флаг повторной передачи */
      char WRITEFLAG : 1;  /* bit 1 : флаг устанавливается, когда должна быть запись */
      char EMPTY : 1;      /* bit 2 : флаг опустошения буфера */
      char FULL : 1;       /* bit 3 : флаг заполнения буфера */
   } status; /* Декларирование байта состояния (status byte) */
   
   // Очистка флагов RETRANS и WRITEFLAG:
   status.RETRANS = 0;
   status.WRITEFLAG = 0;
   // Проверка: очищен ли флаг RETRANS:
   if (!(status.RETRANS))
   {
      status.WRITEFLAG = 1;
   }
}

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

Также следует иметь в виду, что стандарт ANSI никак не определяет способ упаковки битовых полей в байт, так что, к примеру, один компилятор может поместить бит начиная со старшего MSB (Most Significant Bit), а другой компилятор начиная с младшего LSB (Least Significant Bit). С битовыми полями пользователь имеет полный контроль над расположением битов внутри переменной.

[Доступ к памяти FLASH (память программ)]

Обычно константа определяется следующим образом:

const char max = 127;

Эта константа копируется из памяти FLASH в память SRAM при запуске программы (когда подается питание на микроконтроллер), и остается в SRAM до завершения программы. Понятно, что это приводит к затратам памяти SRAM. Чтобы сохранить пространство в SRAM, константа может быть сохранена во FLASH, и загружена, когда это необходимо:

flash char max = 127;
flash char string[] = "Эта строка сохранена в памяти FLASH";
 
void main(void)
{
   char flash *flashpointer;  // Декларирование указателя на FLASH
   flashpointer = &string[0]; // Присваивание указателю адреса на место в памяти FLASH
   UDR = *flashpointer;       // Чтение данных из FLASH и запись их в UART;
}

Когда строки сохранены во FLASH, как в этом последнем примере, они могут быть напрямую считаны из FLASH через указатели на память FLASH. Для компилятора C компании IAR C есть специальные библиотечные подпрограммы для обработки строк, подробнее см. руководство "IAR compiler
users manual".

[Управление выполнением кода]

Функция main. Она обычно содержит главный цикл программы. В большинстве случаев нет функций, которые вызывают функцию main, так что нет необходимости резервировать какие-то регистры при входе в функцию main. Так что функция main может быть декларирована как C_task. Это сокращает место под стек, и уменьшает размер кода:

void C_task main(void)
{
}

Циклы. Бесконечные циклы, как ни странно, эффективнее делать с помощью for(;;) { }:

for(;;)
{
   /* Тут тело цикла */
}
// ?0001:RJMP ?0001 ; Переход по метке

Циклы do{ }while(выражение) обычно генерируют более эффективный код, чем while(выражение){ } и for{выражение1; выражение2; выражение3). В следующем примере показан код, генерируемый для цикла do{ }while:

char counter = 100; /* Декларирование переменной цикла */
// LDI R16,100 ; Инициализация переменной
do
{
} while(--counter); /* Декремент счетчика и проверка на 0 */
//?0004: DEC R16 ; Декремент
// BRNE ?0004 ; Переход по метке, если не равно

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

[Что эффективнее - макросы или функции?]

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

Пример ниже показывает, как задача может быть выполнена с помощью функции и с помощью макроса.

/* Главная функция, которая вызывает другую функцию read_and_convert */
void C_task main(void)
{
   UDR = read_and_convert(); /* Чтение значения и отправка в UART */
}
 
/* Функция, которая читает значение с вывода порта, 
 и преобразует его в код ASCII */
char read_and_convert(void)
{
   return (PINB + 0x48); /* Возврат значения как символа ASCII */
}
 
/* Макрос, который делает то же самое, что и read_and_convert */
#define read_and_convert (PINB + 0x48)

Код с вызовом функции будет ассемблирован в следующий код инструкций ассемблера:

main:
      RCALL read_and_convert     ; вызов функции
      OUT LOW(12),R16            ; запись в I/O memory
read_and_convert:
      IN R16,LOW(22)             ; чтение I/O memory
      SUBI R16,LOW(184)          ; добавить к значению 0x48
      RET                        ; возврат из функции

Код с макросом будет ассемблирован в следующий код инструкций ассемблера:

main:
      IN R16,LOW(22)             ; чтение I/O memory
      SUBI R16,LOW(184)          ; добавить к значению 0x48
      OUT LOW(12),R16            ; запись в I/O memory

Получается, что для функции размер кода 10 байт и время выполнения 10 циклов, а для макроса 6 байт и 3 цикла.

[18 советов по уменьшению размера кода]

1. Компилируйте с полной оптимизацией по размеру кода.

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

3. Используйте самый маленький тип данных. Если можно, то используйте беззнаковый тип (unsigned).

4. Если нелокальная переменная используется только в пределах одной функции, то она должна быть определена в пределах этой функции с атрибутом static.

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

6. Используйте указатели со смещением, или декларируйте структуры для доступа к memory mapped I/O;

7. Для бесконечных циклов используйте for(;;) { }.

8. Используйте do { } while(expression), если это применимо.

9. Если это возможно, используйте в цикле счетчики на уменьшение и преддекремент.

10. Обращайтесь к I/O memory напрямую (например, не используйте указатели).

11. Если используются макросы _EEPUT/_EEGET, замените на EECR = ; с EECR |= ;.

12. Используйте битовые маски с типом unsigned char или unsigned int вместо битовых полей.

13. Декларируйте функцию main с атрибутом C_task, если она ниоткуда не вызывается в программе (обычно так и бывает).

14. Используйте вместо функций макросы, если функции ассемблируются в 2-3 строки кода ассемблера.

15. Уменьшите размер сегмента векторов прерываний (interrupt vector segment, INTVEC) до величины, которая действительно нужна для приложения. Альтернативно соедините все сегменты CODE в одну декларацию, и это будет сделано автоматически.

16. Используйте внутримодульное повторное применение функций. Собирайте несколько функций в одном модуле (т. е. в одном файле) для увеличения фактора повторного использования кода.

17. В некоторых случаях полная оптимизация по скорости приводит к большей экономии кода, чем полная оптимизация по размеру. Компилируйте проект помодульно, чтобы исследовать - что дает самый лучший результат.

18. Оптимизируйте C_startup так, чтобы не инициализирвоать неиспользуемые сегменты (например IDATA0 или IDATA1, если все переменные tiny или small).

[5 советов по уменьшению требований к RAM]

1. Все константы и литералы должны быть размещены в памяти FLASH с использованием ключевого слово flash.

2. Избегайте использования глобальных переменных, если можно использовать локальные. Это также уменьшает размер кода. Локальные переменные выделяются динамически из стека, и удаляются оттуда, когда функция теряет управление (произведен выход из функции).

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

4. Хорошую оценку размера кода и стека можно получить из выходного файла статистики линкера (так называемый MAP-файл).

5. Не тратьте пространство памяти на сегменты IDATA0 и UDATA0 за исключением случаев, когда используете переменные tiny (см. файл линкера).

[Контрольный список для отладки программ]

1. Удостоверьтесь, что сегмент CSTACK достаточного размера.

2. Удостоверьтесь, что сегмент RSTACK достаточного размера.

3. Удостоверьтесь, что интерфейс к внешней памяти разрешен, когда он должен быть включен, и запрещен, когда он должен быть выключен.

4. Если обычная функция и обработчик прерывания обмениваются данными через глобальную переменную, то эта переменная должна быть объявлена с атрибутом volatile - чтобы гарантировать, что она считывается из RAM каждый раз, когда должна быть проверена.

[Ссылки]

1. AVR035: Efficient C Coding for 8-bit AVR microcontrollers site:atmel.com.
2. AVR032: командные файлы линкера для компилятора IAR ICCA90.
3. Intel HEX: описание формата файла.
4. Программаторы AVR.
5. IAR, битовые поля (bitfields).
6. "Нечестные" методы оптимизации программы по размеру кода в среде AVR Studio + GCC.
7. Методы оптимизации кода C для 8-битных AVR.