Использование алгоритмических языков высокого уровня (ЯВУ) и, в частности, Си для программирования микроконтроллеров, несомненно, дает ряд преимуществ по сравнению с языком ассемблера. Основными из них являются:
· высокая скорость разработки программ;
· легкость отладки разрабатываемых программ;
· независимость программного кода от типа контроллера и, соответственно, более или менее простой перенос программ на разные платформы;
· простота сопровождения программ.
Исходные тексты на языке Си имеют сравнительно небольшие размеры, сами программы, как правило, хорошо структурированы и понятны. Однако компиляторы с языка Си часто менее доступны по сравнению с ассемблером. Кроме того, программный код, генерируемый компиляторами, имеет несколько большие размеры, по сравнению с кодированием на ассемблере, и скорость работы такой программы также бывает несколько меньше. В то же время, при программировании на ассемблере программист полностью контролирует весь код, вплоть до самой последней команды, поскольку в этом случае между ним и контроллером отсутствует посредник в виде компилятора. В то же время, использование ассемблера имеет ряд недостатков, аналогичных достоинствам использования ЯВУ со знаком ‘-‘:
· низкая скорость разработки программ, с большим риском появления ошибок;
· большая трудоемкость отладки;
· сопровождение программ зачастую затруднено;
· перенос программ на другие платформы часто также затруднен.
Можно найти третий путь написания программ, который позволил бы сочетать достоинства программирования, как на ЯВУ, так и на языке ассемблера и одновременно сглаживал присущие им недостатки. Пойдя этим путем, мы смогли бы писать компактные и быстрые программы, затрачивая минимальные усилия и не делая при этом большого количества ошибок. Звучит совсем неплохо! Но что это за путь? Что для этого нужно сделать?
Ответ очень прост: для этого нужно самому стать компилятором. Как это? Что это значит?!!! Это значит, что при кодировании программ нужно просто придерживаться стандартов (соглашений, правил, шаблонов) аналогичных тем, которые используются в компиляторах. Самое главное здесь понять, что любой компилятор генерирует код в соответствии с некоторыми правилами, принятыми его разработчиками. Некоторые из этих правил определяются ЯВУ и мало зависят от конкретной реализации компилятора или архитектуры целевого процессора. Другие соглашения, наоборот, во многом зависят от архитектуры процессора.
Вы тоже можете принять для себя соответствующие правила. В этом подходе нет ничего нового. Любой программист, со временем, так или иначе, вырабатывает свой стиль, свои правила, свои приемы программирования. Но это часто делается интуитивно, методом проб и ошибок. При этом выработка своего стиля может потребовать достаточно долгого времени. Я думаю, что это время можно значительно сократить, если делать это более осмысленно, более целенаправленно.
Соглашения, которые необходимо принять перед разработкой программ, можно разбить на две группы:
· соглашения, связанные с обработкой конструкций ЯВУ;
· соглашения по использованию аппаратных средств (регистров, памяти) процессора.
Прежде всего, следует отметить, что в любом ЯВУ, и в Си в том числе, определены некоторый набор операторов управления программой (операторы циклов, ветвления и т.п.), множество типов данных, используемых в данном языке, и множество операций над данными различных типов. Далее, стандартом языка оговариваются способы передачи параметров в процедуры и функции: по ссылке, по значению, по имени и т.д. Хотя все эти стандарты определяются в ЯВУ и не зависят от типа процессора, тем не менее, реализация этих стандартом полностью определяется архитектурой процессора.
Помимо этого, при разработке компилятора дополнительно определяются соглашения, связанные с генерацией кода, которые целиком зависят только от архитектуры используемого процессора. Обычно эти соглашения относятся использованию регистров процессора в теле процедуры (функции), способам передачи фактических аргументов процедурам или функциям на машинном уровне (регистры, стек, выделенные ячейки памяти и т.д.), а также способ возвращения значений функциями.
Рассмотрим эти соглашения более подробно.
Операторы ветвления.
Эти операторы встречаются в программах наиболее часто и, одновременно с этим, наиболее часто являются источником ошибок в программах. В языке Си существуют три возможных варианта организации ветвлений. Это операторы if, if…else и switch.
Прежде чем говорить об их реализации и упростить дальнейшие рассуждения введем несколько абстрактных инструкций. Нам понадобятся две инструкции условных переходов JF (Jump if FALSE – переход, если ЛОЖЬ) и JT (Jump if TRUE – переход, если ИСТИНА), инструкция безусловного перехода JUMP, а также инструкция проверки некоторого условия (или, говоря по-другому, вычисления условного выражения), назовем ее CHECK. Операндами инструкций переходов будут являться имена меток, так как обычно записывается на языке ассемблера. Операндом инструкции CHECK будет являться условие, требующее проверки в соответствии с данным оператором ветвления и записанное в соответствии с синтаксисом Си.
Рассмотрим реализацию оператора Си – оператор if. Данный оператор записывается следующим образом
if (<C>) // Если выполняется условие <C>, то
{
<S> // выполняется оператор <S>
}
То есть в операторе if происходит проверка некоторого условия <C>, и если данное условие выполняется (результат проверки – TRUE), то выполняется оператор <S>. В противном случае данный оператор пропускается. Схема трансляции (шаблон) оператора if с помощью введенных выше инструкций выглядит так:
CHECK (<C>) ; Проверка условия и
JF NEXT_STATEMENT ; переход, если условие не выполняется
………….. ; Тело оператора
………….. ; <S>
NEXT_STATEMENT: ; Здесь начинается следующий оператор
Другой условный оператор Си – оператор if …else, который записывается следующим образом
if (<C>) // Если выполняется условие <C>, то
{
<S1> // выполняется оператор <S1>
}
else { // иначе
<S2> // выполняется оператор <S2>
}
Этот оператор является обобщением оператора if. Здесь так же, как и в предыдущем случае происходит проверка условия <C>, и в случае выполнения данного условия выполняется оператор <S1>. Однако в отличие от оператора if, если условие не выполняется (результат проверки – FALSE), то выполняется оператор <S2>. Схема трансляции этого оператора будет такой:
CHECK (<C>) ; Проверка условия
JF STATEMENT2 ; Если условие НЕ ВЫПОЛНЯЕТСЯ,
; то переходим к <S2>
STATEMENT1: ; Начало оператора <S1>
………… ; Тело оператора
………… ; <S1>
JUMP NEXT_STATEMENT ; Переход к оператору, следующему за if … else
STATEMENT2: ; Начало оператора <S2>
……….. ; Тело оператора
……….. ; <S2>
NEXT_STATEMENT: ; Здесь начинается следующий оператор
Теперь посмотрим, как реализовать введенные выше инструкции применительно к реальному процессору и соответствующему языку ассемблера. Используем принцип: от простого - к сложному. Проще всего реализовать инструкцию безусловного перехода JUMP. У AVR имеется две инструкции безусловного перехода: jmp и rjmp. Желательно использовать инструкцию rjmp, как более короткую (1 слово) и лишь при необходимости использовать jmp (2 слова).
Сложнее реализовать инструкции JF и JT. Реализация данных инструкций зависит от того, какое из условий проверяется в данном операторе if… . Всего в Си возможны 6 различных условий:
· == (равно):
· != (не равно);
· <= (меньше или равно, со знаком или без знака);
· > (больше, со знаком или без знака):
· < (меньше, со знаком или без знака):
· >= (больше или равно, со знаком или без знака).
То есть всех возможных условий может быть десять, соответственно и инструкций условного перехода также должно быть десять. Имеющиеся условия и соответствующие им инструкции можно разбить на пары таким образом, что каждому условию можно поставить в пару ему противоположное, т.е. если первое условие есть ИСТИНА (TRUE), то условие ему противоположное будет ЛОЖЬ (FALSE). Например: == (!=), < (>=), > (<=). В AVR имеются соответствующие инструкции условных переходов: breq (brne), brlt (brge) и т.д.
Таким образом, если в операторе проверяется условие равенства (==), то абстрактной инструкции JT будет соответствовать реальная инструкция breq, а инструкции JF – инструкция brne. И наоборот, если выполняется проверка условия неравенства, то инструкции JT будет соответствовать инструкция brne, а инструкции JF – инструкция breq. Рассуждая таким образом можно определить инструкции условных переходов и для других условий.
Однако задача усложняется тем, что у AVR отсутствуют команды переходов, соответствующих условиям > и <= (как со знаком, так и без знака). Преодолеть это ограничение можно следующим образом. Допустим, имеются два числа a и b. При этом если выполняется такое условие, что a > b, то отсюда следует, что b < a. Поскольку условию < соответствуют команды переходов brlt или brlo (со знаком или без знака соответственно), то для реализации отсутствующих команд условных переходов достаточно в операции CHECK поменять местами операнды и изменив соответствующим образом условие. Правила использования команд условных переходов сведены в следующую таблицу.
|
Несколько сложнее обстоит дело с реализацией абстрактной инструкции проверки CHECK. Её конкретная реализация полностью зависит от типов данных, для которых производится проверка, с учетом ограничений архитектуры выбранного процессора. Для одних типов данных в качестве инструкции CHECK может быть использована реальная инструкция процессора, ей эквивалентная. Для других типов данных может быть использована последовательность из нескольких команд процессора, тогда как для третьих потребуется использовать команду вызова специальной подпрограммы, выполняющей проверку данного условия. Рассмотрим реализацию CHECK для данных различных типов.
Сравнение данных типа char (unsigned char). Данные этого типа занимают один байт. Операция проверки условия для данных этого типа в общем случае реализуется с помощью одной из двух инструкций сравнения: cp или cpi. Первая инструкция используется для сравнения данных, размещенных в регистрах, вторая – для сравнения содержимого регистра с некоторой константой.
Пример 1: Дан фрагмент кода на языке Си
signed char a, b, c; // Данные длиной 1 байт, со знаком
……
if (a < b) //
c = b;
else
c = a;
Перепишем данный фрагмент на язык ассемблера с использованием введенных ранее абстрактных инструкций и получим код
CHECK a < b ; Проверка условия
JF label2 ; Условие не выполняется,
; выполняем альтернативный оператор
label1: ; Начало первого оператора
mov c, b ; Копируем в c значение b
JUMP next_label ; Переход к продолжению программы
label2: ; Начало альтернативного оператора
mov c, a ; Копируем в c значение a
next_label: ; Продолжение программы
Это уже ближе к реальной программе, однако, требуется заменить абстрактные инструкции реальными. Поскольку выполняется сравнение двух переменных, то инструкцию CHECK заменяем инструкцией cp. Далее, так как проверяется условие «меньше» (<), то альтернативное условие будет «больше или равно» (>=), которому будет соответствовать инструкция условного перехода brge. В качестве инструкции безусловного перехода будем использовать команду rjmp. У нас получится такой код
cp a, b ; Сравнить a и b
brge label2 ; Если a >= b, то переход
label1:
mov c, b ; Копирование в c значение b
rjmp next_label ; Продолжение следует
label2:
mov c, a ; Копируем в с значение a
next_label: ; Продолжение программы
Практически мы уже получили программу на ассемблере, но… При трансляции такой программы ассемблер выдаст сообщения об ошибках. Дело в том, что в качестве операндов в инструкциях cp и mov должны быть указаны регистры процессора, а не переменные Си. Устранить это несоответствие можно либо заменой операндов в этих инструкциях регистрами процессора (при этом необходимо указать в комментариях соответствие между переменными и регистрами), либо воспользоваться директивой препроцессора #define.
Кроме того, в полученном коде неудачно сделаны комментарии. По сути дела они поясняют, ЧТО делают те или иные инструкции, вместо того чтобы объяснить ДЛЯ ЧЕГО они используются. Исходный текст на Си несет больше информации, чем сделанные нами комментарии. Поэтому в качестве комментариев в данном случае было бы лучше использовать операторы транслируемого фрагмента программы.
Перепишем наш код еще раз, учтя при этом сделанные замечания.
//
// Определим переменные
//
#define a r16
#define b r17
#define c r18
cp a, b ; if (a < b)
brge label2 ;
label1:
mov c, b ; c = b;
rjmp next_label ;
label2: ; else
mov c, a ; c = a;
next_label: ; Продолжение программы
Особо следует выделить случай сравнения переменной с нулем. Для 8-разрядных переменных можно использовать либо инструкцию cpi, например:
cpi r16, 0 ; Сравнить r16 с нулем
либо инструкцию tst, например:
tst r16 ; Проверить r16
Инструкция cpi является более универсальной, но она не может быть использована для регистров r0 .. r15. Инструкция tst можно использовать для любого регистра, но ее применение ограничено проверкой условий ==, !=, <, >=.
Сравнение данных типов int, unsigned int и short. Для данных этого типа требуется одно 16-разрядное слово. Для реализации инструкции CHECK здесь одной инструкций микроконтроллера явно недостаточно. В общем случае может потребоваться последовательность из нескольких инструкций, которая может быть оформлена в виде подпрограммы. Однако в наборе команд семейства AVR для этого случая предусмотрена специальная инструкция сравнения с учетом флага переноса – cpc. Данная инструкция позволяет значительно упростить программы обработки 16-разрядных данных. Рассмотрим предыдущий пример, заменив при этом тип данных на тип int.
Пример 2: Дан фрагмент кода на языке Си
int a, b, c; // Данные длиной 2 байтa, со знаком
……
if (a < b) //
c = b;
else
c = a;
В результате трансляции такого фрагмента мы получим следующий код:
//
// Размещение переменных в регистрах:
// a - r17 (старший байт), r16 (младший байт);
// b - r19 (старший байт), r18 (младший байт);
// c - r21 (старший байт), r20 (младший байт).
//
cp r16, r18 ; if (a < b)
cpc r17, r19 ;
brge label2 ;
label1:
mov r20, r18 ; c = b;
mov r21, r19 ;
rjmp next_label ;
label2: ; else
mov r20, r16 ; c = a;
mov r21, r17 ;
next_label: ; Продолжение программы
Как видим, сравнение 16-разрядных слов в AVR не намного сложнее сравнения байтов. В случае использования контроллеров типа ATmega можно дополнительно упростить программу при использовании инструкции movw.
Особо следует выделить случаи, когда происходит сравнение переменной с константой. При сравнении байтовых переменных, как уже было сказано, можно использовать команду cpi. Однако команда сравнения с константой с учетом флага переноса (типа cpic) в наборе отсутствует. Преодолеть это ограничение при работе с 16-разрядными словами можно различными способами.
В простейшем случае 16-разрядная константа предварительно загружается в дополнительную регистровую пару и далее выполняется аналогично рассмотренному случаю. То есть:
ldi r22, (LOW)CONST ; r23,r22 <- CONST
ldi r23, (HIGH)CONST ;
cp r16, r22 ; if (a < CONST)
cpc r17, r23 ;
………….
Это наиболее очевидный путь и именно так, скорее всего, поступит компилятор при генерации кода. Но такое решение имеет два недостатка: использование дополнительно двух регистров и 4-х инструкций, необходимых для сравнения. В некоторых случаях это может привести к дополнительным накладным расходам, связанным с сохранением используемых регистров в стеке и их последующем восстановлении из стека.
Второй путь менее очевидный. Можно несколько упростить данную последовательность команд и уменьшить число дополнительных регистров, если воспользоваться командой cpi там, где это возможно. Фрагмент кода при этом получится следующий:
ldi r23, (HIGH)CONST ; if (a < CONST)
cpi r16, (LOW)CONST ;
cpc r17, r23 ;
………….
Как мы видим, количество необходимых команд сократилось до трех, и дополнительно понадобился только один регистр вместо двух. Хотя такой код является более эффективным по сравнению с предыдущим, тем не менее, он стал более сложным для понимания.
Вывод: простота программы и её эффективность далеко не всегда синонимы.
Здесь также следует выделить случай сравнения с нулем. В тех случаях, когда необходима проверка условий ==, !=, < или >=, можно использовать более короткие последовательности команд. Пусть, например переменная типа int находится в регистрах r19 (старший байт) и r18 (младший байт). Регистр r16 используется в качестве рабочего. Тогда можно использовать следующие последовательности команд:
mov r16, r18 ; Проверка условий == или !=
or r16, r19 ;
или
tst r19 ; Проверка условий < или >=
Сравнение данных типов long и unsigned long. Поскольку данные этих типов занимают четыре байта, то реализации операции сравнения требуется последовательность из четырех команд: команды cp и трех команд cpc. В остальном реализация операторов условного перехода для 32-разрядных данных аналогична операторам для данных с меньшей разрядностью.
Сравнение данных типа float. Поскольку микроконтроллеры семейства AVR не поддерживают обработку данных с плавающей точкой, то для выполнения операции сравнения необходимо использовать соответствующую процедуру. Впрочем, это относится и к остальным операциям арифметики с плавающей точкой. Поэтому, операцию сравнения данных типа float рассмотрим позднее.
Логические выражения. В языке Си определены следующие логические операции (в порядке убывания старшинства операции):
· ‘!’ – «логическое НЕ»;
· ‘&&’ – «логическое И»;
· ‘||’ – «логическое ИЛИ».
В языке Си также определен порядок обработки логических выражений, а именно – слева направо. Данные выражения могут принимать одно из двух значений: TRUE («истина») или FALSE («ложь»). Обработка условного выражения выполняется до тех пор, пока не будет определено значение данного выражения.
Рассмотрим далее схемы трансляции различных условных выражений.
Логическое НЕ. В общем случае, оператор Си if … else с учетом использования «логического НЕ» имеет следующий вид
if (!<C>) // Если условие <C> НЕ ВЫПОЛНЯЕТСЯ
{
<S1> // то выполняется оператор <S1>,
}
else { // иначе
<S2> // выполняется оператор <S2>
}
Схема трансляции такого оператора:
CHECK (<C>) ; Проверка условия
JT STATEMENT2 ; Если условие ВЫПОЛНЯЕТСЯ, то переходим к <S2>
STATEMENT1: ; Начало оператора <S1>
………… ; Тело оператора
………… ; <S1>
JUMP NEXT_STATEMENT ; Переход к оператору, следующему за if … else
STATEMENT2: ; Начало оператора <S2>
……….. ; Тело оператора
……….. ; <S2>
NEXT_STATEMENT: ; Здесь начинается следующий оператор
Логическое И. В общем случае, оператор Си if … else с учетом использования «логического И» имеет следующий вид
if (<C1> && <C2>) // Если выполняются условия <C1> И <C2>
{
<S1> // то выполняется оператор <S1>,
}
else { // иначе
<S2> // выполняется оператор <S2>
}
Схема трансляции этого оператора имеет вид
CHECK (<C1>) ; Проверка условия <C1>
JF STATEMENT2 ; Если это условие НЕ ВЫПОЛНЯЕТСЯ,
; то переходим к <S2> , в проверке условия <C2>
; уже нет необходимости
CHECK (<C2>) ; иначе проверяем условие <C2>
JF STATEMENT2 ; Если это условие НЕ ВЫПОЛНЯЕТСЯ,
; то переходим к <S2>
STATEMENT1: ; Начало оператора <S1>
………… ; Тело оператора
………… ; <S1>
JUMP NEXT_STATEMENT ; Переход к оператору, следующему за if … else
STATEMENT2: ; Начало оператора <S2>
……….. ; Тело оператора
……….. ; <S2>
NEXT_STATEMENT: ; Здесь начинается следующий оператор
Пример 3: Дан фрагмент кода на языке Си
int value, // Переменная
low, // Нижняя граница
high; // Верхняя граница
char in_range; // Результат проверки
……
if (value>=low && value<=high) // Значение value лежит внутри диапазона low…high,
in_range = 1; // то все нормально
else // иначе
in_range = 0; // ошибка
После промежуточной трансляции имеем:
CHECK value >= low ; Проверка нижней границы
JF false_label ; значение value меньше low
CHECK high >= value ; Проверка верхней границы
; !!! Условие и порядок операндов изменены
JF false_label ; значение value больше high
true_label: ; Все нормально
in_range = 1
JUMP continue_label ; Продолжаем
false_label: ; Ошибка
in_range = 0
continue_label: ; Продолжение программы
Окончательная трансляция даст нам код
;
; Определения переменных:
;
; int value, // Переменная - r19:r18
; low, // Нижняя граница - r21:r20
; high; // Верхняя граница - r23:r22
; char in_range; // Результат проверки - r16
;
…….
; if (value>=low && value<=high) // Значение value лежит внутри диапазона low…high,
cp r18, r20 ; Проверка нижней границы
cpc r19, r21 ;
brlt false_label ; значение value меньше low
cp r22, r18 ; Проверка верхней границы
cpc r23, r19 ;
brlt false_label ; значение value больше high
; in_range = 1; // то все нормально,
true_label:
ldi r16, 1
rjmp continue_label ; Продолжаем
; else // иначе
; in_range = 0; // ошибка
false_label:
ldi r16, 0
continue_label: ; Продолжение программы
Логическое ИЛИ. В общем случае, оператор Си if … else с учетом использования «логического ИЛИ» имеет следующий вид
if (<C1> || <C2>) // Если выполняется условие <C1> ИЛИ условие <C2>
{
<S1> // то выполняется оператор <S1>,
}
else { // иначе
<S2> // выполняется оператор <S2>
}
Схема трансляции этого оператора имеет вид
CHECK (<C1>) ; Проверка условия <C1>
JT STATEMENT1 ; Если это условие ВЫПОЛНЯЕТСЯ,
; то переходим к <S1> , в проверке условия <C2>
; уже нет необходимости
CHECK (<C2>) ; иначе проверяем условие <C2>
JF STATEMENT2 ; Если это условие НЕ ВЫПОЛНЯЕТСЯ,
; то переходим к <S2>
STATEMENT1: ; Начало оператора <S1>
………… ; Тело оператора
………… ; <S1>
JUMP NEXT_STATEMENT ; Переход к оператору, следующему за if … else
STATEMENT2: ; Начало оператора <S2>
……….. ; Тело оператора
……….. ; <S2>
NEXT_STATEMENT: ; Здесь начинается следующий оператор
В самом общем случае условные выражения могут состоять из комбинаций И, ИЛИ, НЕ. Такие выражения можно упростить, если воспользоваться теоремой Де-Моргана. Применительно к условным выражения Си это можно записать следующим образом:
· !(<C1> && <C2>) эквивалентно !<C1> || !<C2>;
· !(<C1> || <C2>) эквивалентно !<C1> && !<C2>.
Вероятностная оптимизация условных выражений. В рассмотренных выше условных выражениях условие <C1> проверяется первым и проверяется всегда, тогда, как условие <C2> проверяется или не проверяется в зависимости от значения условия <C1>. Поэтому мы можем назвать условие <C1> основным, а условие <C2> - дополнительным. Назовем также значение условия <C1>, при котором прекращается дальнейшая обработка оставшейся части всего условного выражения, определяющим. Тогда для выражения «логическое И» определяющим значением будет «ЛОЖЬ», тогда как для выражения «логическое ИЛИ» определяющим значением будет «ИСТИНА». Очевидно, чем выше вероятность того, что условие <C1> будет принимать определяющее значение, тем будет меньше вероятность необходимости проверки условия <C2> и, стало быть, в среднем время вычисления всего выражения в целом уменьшится.
Допустим, например, что из входного потока в буфер считывается посимвольно некоторое слово. При этом считывание в буфер прекращается, если из входного потока пришел символ, не являющийся символом буквы, ИЛИ произошло заполнение буфера. Естественно, что размер буфера должен быть выбран исходя из наибольшей допустимой длины слова. При этом получается, что вероятность того, что слово закончилось, будет выше вероятности того, что буфер заполнился. Если при этом основным условием будет условие принадлежности входного символа к множеству букв, то проверка заполнения буфера будет выполняться достаточно редко. Другими словам время, необходимое для вычисления выражения
(СИМВОЛ НЕ БУКВА) ИЛИ (БУФЕР ЗАПОЛНЕН)
будет в среднем меньше времени, необходимого для вычисления выражения
(БУФЕР ЗАПОЛНЕН) ИЛИ (СИМВОЛ НЕ БУКВА)
Конечно, здесь следует иметь в виду, что это справедливо лишь при соответствующем соотношении размера буфера и средней длины считываемых слов. Если размер буфера будет меньше средней длины слова, то вся ситуация меняются с точностью до наоборот.
Поэтому в тех случаях, когда логически выражения <C1> и <C2> равноправны и порядок их проверки не имеет значения в смысле логики, то использование того условия в качестве основного, у которого априорно вероятность принятия им определяющего значения выше, позволит в среднем повысить скорость выполнения программы. Поскольку принятие условием <C1> одного из двух возможных значений является более или менее случайным, то я и называю такую оптимизацию вероятностной. Возможно, что такая оптимизация и не даст большого выигрыша, но когда из программы необходимо «выжать» максимум быстродействия, приходится учитывать и вероятности тех или иных условий.
Оператор switch. С помощью данного оператора реализуется много-альтернативное ветвление в программах. Схемы трансляции такого оператора могут быть различными в зависимости от количества вариантов и диапазона их изменений. Если вариантов немного, то данный оператор можно реализовать в виде последовательности операторов if … else. Если количество вариантов достаточно велико, то такой оператор реализуется при помощи таблиц. Если степень разброса значений вариантов невелика, то достаточно использовать одну таблицу переходов, в которой значения варианта будет являться индексом этой таблицы. Если же разброс значений вариантов велик, то размер такой таблицы резко увеличивается и для уменьшения размера кода целесообразно использовать две таблицы. В одной таблицы помещают значения вариантов, а в другой – соответствующие им адреса переходов.
Более подробно схемы трансляции оператора switch мы обсудим позднее.
Несколько замечаний относительно команд условного перехода. Можно сделать их менее зависимыми от используемого процессора, если реализовать их в виде макроопределений, используя средства макропроцессора, имеющегося в ассемблере. Для этой цели воспользуемся приведенной выше таблицей. Для имен макрокоманд переходов можно принять, например следующие соглашения. Первые две буквы (JT или JF) используются для указания типа условия, далее следует разделительный символ, и затем мнемоническое обозначение условия, например JT_EQ (переход по выполнению условия ==) или JF_GTU (переход по не выполнению условия >= без знака). Макроопределения для них будут следующими:
JT_EQ macro LABEL ; Переход если выполняется условие ==
breq LABEL
endm
JF_GTU macro LABEL ; Переход если не выполняется условие > (без знака)
brcc LABEL
endm
Таким же образом можно написать макроопределения и для остальных команд переходов.
Операторы цикла.
В языке Си для организации циклов в программах используются следующие операторы цикла: while, do…while и for. Кроме того, иногда бывают случаи, когда необходимо по некоторым причинам либо принудительно завершить цикл, либо завершить текущую итерацию цикла, пропуская все оставшиеся внутри цикла, операторы. Для этой цели в теле цикла могут быть использованы специальные операторы break и continue соответственно.
Операторы break и continue. Эти операторы могут быть как условными, так и безусловными. Условная форма оператора на языке Си имеет вид (на примере break):
if (<C>) // Проверка условия <C>
break; // и выход из цикла при выполнении данного условия
Схема трансляции в этом случае будет
CHECK <C> ; Проверка условия
JT BREAK_LABEL ; и выход
В случае безусловного оператора break используется команда безусловного перехода:
JUMP BREAK_LABEL ; Выход из цикла
Все выше сказанное также относится и к оператору continue, с той лишь разницей, что в качестве метки перехода используется метка CONTINUE_LABEL.
Оператор while записывается следующим образом
while (<C>) // ПОКА условие <C> есть ИСТИНА
{
<S> // циклически выполняется оператор <S>
}
То есть в операторе while проверка условия <C> происходит ПЕРЕД выполнением оператора цикла <S>, и если условие не выполняется, то цикл прерывается. Если изначально при передаче управления данному оператору while условие <C> не выполняется, то оператор цикла не будет выполнен ни разу. Операторы break и continue в теле оператора цикла дают возможность либо досрочно прервать цикл, либо принудительно перейти к следующему циклу соответственно. В самом первом приближении, схема его трансляции выглядит так:
LOOP_LABEL: ; Сюда идем при очередном цикле
CONTINUE_LABEL: ; Сюда осуществляется переход по оператору continue
CHECK <C> ; Проверка условия <C>
JF BREAK_LABEL ; Если условие <C> не выполняется, то выходим из цикла
…….
……. ; Тело оператора цикла
…… ;
JUMP LOOP_LABEL ; и переход к следующему циклу
BREAK_LABEL: ; Сюда мы попадаем при выходе из оператора while
; или при переходе по оператору break
Такая схема не является оптимальной с точки зрения быстродействия. Ей присущи сравнительно большие накладные расходы времени, связанные с организацией цикла. Действительно, предположим, что оператор цикла выполняется N раз. Обозначим время выполнения команды JUMP как TJUMP, времена выполнения команды JF как T0JF и T1JF для случаев, когда команда пропускается и команда выполняется соответственно. Тогда общее время необходимое для организации цикла TLOOP будет равно
TLOOP = N*TJUMP + N*T0JF + T1JF
С целью уменьшения временных затрат, связанных с организацией цикла чаще используется другая, несколько измененная, схема.
JUMP CONTINUE_LABEL ; Вход в оператор while
LOOP_LABEL: ; Сюда идем при очередном цикле
…….
……. ; Тело оператора цикла
…….
CONTINUE_LABEL: ; Сюда осуществляется переход по оператору continue
CHECK <C> ; Проверка условия <C>
JT LOOP_LABEL ; Если условие <C> выполняется, то переходим к циклу
BREAK_LABEL: ; Сюда мы попадаем при выходе из оператора while
; или при переходе по оператору break
Общее время необходимое для организации цикла TLOOP можно определить как
TLOOP = TJUMP + T0JF + N*T1JF
Для команд переходов AVR времена выполнения равны TJUMP = 2, T1JF = 2 и T0JF = 1 машинных циклов контроллера. Тогда время, связанное с организацией цикла будет составлять
TLOOP = N*2 + N*1 + 2 = N*3 + 2 в первом случае;
и
TLOOP = 2 + 1 + N*2 = N*2 + 3 во втором случае.
Из этого следует, что экономия времени при трансляции по второй схемой по сравнению с первой составит N-1 машинных циклов контроллера. Если далее предположить, что N = 10, время цикла контроллера 125 нс (частота 8МГц), то разница составит 9 циклов контроллера, или 1.125 мкс. Если же количество циклов принять N = 100, разница составит уже 99 циклов контроллера или 12.375 мкс.
Оператор do … while записывается в Си в виде
do { // Выполнять оператор
….. //
….. // цикла <S>
….. // до тех пор
}
while (<C>); // пока условие <C> есть ИСТИНА
В операторе do …. while проверка условия происходит ПОСЛЕ выполнения оператора цикла <S>. Поэтому оператор цикла всегда выполняется хотя бы один раз. Схема трансляции оператора имеет вид:
LOOP_LABEL: ; Точка входа в оператор. Сюда идем при очередном цикле
…….
……. ; Тело оператора цикла <S>
…… ;
CONTINUE_LABEL: ; Сюда осуществляется переход по оператору continue
CHECK <C> ; Проверка условия <C>
JT LOOP_LABEL ; Если условие <C> выполняется, то переходим к циклу
BREAK_LABEL: ; Сюда мы попадаем при выходе из оператора while
; или при переходе по оператору break
Сравнивая операторы while и do… while можно заметить, что второй оператор имеет на одну команду перехода меньше по сравнению с первым и, стало быть, его использование более предпочтительно. Тем не менее, там, где необходимо отложить выполнение цикла до выяснения соответствующего условия, приходится пользоваться оператором while. Оператор do…while удобно использовать при организации циклов, связанных с подсчетом циклов.
Совет по оптимизации (C, asm): старайтесь там, где возможно, использовать оператор do…while.
Оператор for. Общая форма записи оператора for имеет вид:
for (<INIT>; <C>; <INCREMENT>)
{
<S>
}
Где:
· <INIT> - установка начального значения параметра цикла;
· <C> - условие продолжения цикла;
· <INCREMENT> - изменение параметра цикла при каждой итерации:
· <S> - оператор цикла.
Оператор for в большинстве случаев эквивалентен следующей последовательности операторов:
<INIT>; // Инициализация цикла
while (<C>) // ПОКА <C> есть ИСТИНА
{
<S> // Выполнить оператор <S>
<INCREMENT> // и затем изменить параметр цикла
}
Пример: Обнуление 100 элементов массива array
for (i = 0; i < 100; ++i)
{
array[i] = 0;
}
Схема трансляции оператора for в общем случае имеет вид:
INIT ; Инициализация цикла
JUMP CHECK_LABEL ; и переход к началу цикла
LOOP_LABEL: ; Начало оператора цикла
………
……… ; Тело оператора цикла <S>
………
CONTINUE_LABEL: ; Переход по оператору continue
INCREMENT ; Изменение параметра цикла
CHECK_LABEL: ; Начало цикла
CHECK <C> ; Проверка условия <C>
JT LOOP_LABEL ; и переход к следующей итерации если условие выполняется
BREAK_LABEL: ; Точка выхода из цикла по оператору break
Исходя из этого, приведенный выше пример можно записать так
i = 0; ; Обнулить индекс
JUMP CHECK_LABEL ; Перейти к циклу
LOOP_LABEL:
array[i] = 0; ; Обнулить элемент массива
CONTINUE_LABEL:
++i; ; Увеличить индекс
CHECK_LABEL:
CHECK i < 100 ; Не все элементы обнулены?
JT LOOP_LABEL ; Переход если нет
BREAK_LABEL:
Особые случаи использования операторов цикла.
Бесконечный цикл можно реализовать как с помощью оператора while, так и с помощью оператора for. В случае использования оператора while запись бесконечного цикла имеет вид
while (1) // Повторять бесконечно
{
<S>
}
То же самое может быть сделано с помощью оператора for
for (;;) // Повторять бесконечно
{
<S>
}
Здесь запись оператора while(1) эквивалентна записи while (1 != 0). Естественно, что это условие выполняется всегда, и любой оптимизирующий компилятор распознает этот случай на этапе трансляции и просто генерирует команду безусловного перехода. Хотя в записи операторе for (;;) все выражения в скобках и отсутствуют, тем не менее, символы ‘;’, используемые в качестве разделителей должны присутствовать обязательно. Независимо от того, какой из операторов цикла Вы используете, схема трансляции бесконечного цикла будет одной и той же:
LOOP_LABEL: ; Повторять бесконечно
……… ; оператор <S>
……… ;
JUMP LOOP_LABEL
Отсутствие тела оператора цикла. Иногда тело оператора цикла может быть пустым. В этих случаях операторы while и do…while становятся эквивалентными и имеют вид
while (<C>);
При этом схема трансляции такого оператора будет
LOOP_LABEL: ; Точка входа в оператор. Сюда идем при очередном цикле
CHECK <C> ; Проверка условия <C>
JT LOOP_LABEL ; Если условие <C> выполняется, то цикл продолжается
Оператор for при отсутствии оператора цикла выглядит как
for (<INIT>; <C>; <INCREMENT>);
Соответственно схема трансляции буде иметь вид
INIT ; Инициализация цикла
JUMP CHECK_LABEL ; и переход к началу цикла
LOOP_LABEL: ; Начало оператора цикла
INCREMENT ; Изменение параметра цикла
CHECK_LABEL: ; Начало цикла
CHECK <C> ; Проверка условия <C>
JT LOOP_LABEL ; и переход к следующей итерации если условие выполняется
Операторы цикла с пустым телом обычно используются в тех случаях, когда есть возможность совместить все необходимые в цикле действия с проверкой условия. Например,
while (*dst++ = *src++); // Копирование строки
или
for (i = 10; --i;); // Программная задержка
Замечания по реализации (C):
· в результате оптимизации некоторые компиляторы могут изменить один оператор цикла другим, например оператор while оператором do…while;
· операторы цикла с пустым телом могут быть пропущены компилятором в результате оптимизации.
Одним из основных методов, используемых при программировании на Си, является метод модульного проектирования программ. В соответствии с этим методом, вся программа конструируется как совокупность небольших частей или модулей. Метод модульного программирования позволяет реализовать известный с давних времен принцип «разделяй и властвуй», используемый широко и в настоящее время. Согласно этому принципу вся большая задача разбивается на ряд более мелких и, соответственно, более простых подзадач. Назначением модуля и является решение одной или нескольких таких подзадач. Как правило, каждый модуль располагается в своем отдельном файле. Модуль может включать в себя набор необходимых данных и набор подпрограмм или функций, необходимых для решения требуемой задачи.
Обычно функция представляет собой небольшой фрагмент программного кода, решающий одну или несколько небольших, четко определенных, задач. При этом такая функция оформляется в виде подпрограммы, что позволяет неоднократно использовать данный программный код в остальной программе. В целом программа на Си представляет собой набор таких функций, при этом одна из функций является основной или головной. В соответствии с соглашением, принятым в Си, головная функции функция должна иметь имя main. При запуске программы управление передается головной функции, и программа завершается при выходе из нее. Особенностью встраиваемых систем является то, что функция main должна включать в себя бесконечный цикл.
Обращение к любой функции осуществляется посредством вызова функции. При вызове функции указывается имя вызываемой функции и передается в виде списка аргументов вся информация, необходимая для выполнения возлагаемой на данную функцию. По завершению данной функции она возвращает вызывающей функции некоторую информацию (значение), которая может быть использована далее в программе. В отличие от других алгоритмических ЯВУ, в Си отсутствует понятие процедур, то есть подпрограмм, не возвращающих какого-либо результата. Вместо этого в тех случаях, когда от функции не требуется возвращение результата, данная функция должна оформляться соответствующим образом.
Все функции, используемые в программе, можно разделить на три группы:
· функции, полностью определяемые программистом;
· библиотечные функции, то есть те функции, которые определены стандартом языка и поставляются вместе с компилятором в виде библиотеки исполняющей системы;
· служебные функции. Хотя эти функции и находятся в библиотеке вместе другими библиотечными функциями, но, в отличие от последних, их назначение и использование полностью определено разработчиками компилятора. Обычно к таким функциям относятся функции, связанные с реализацией операций над данными различных типов, которые не имеют аппаратной поддержки в соответствующем процессоре. Это могут быть функции умножения, деления, сдвигов, операции с плавающей точкой и т.п. Вызов служебных функций генерируется компилятором во время генерации кода.
Рассмотрение служебных функций мы отложим до более подходящего момента и рассмотрим лишь вопросы, возникающие при реализации двух первых групп функций. Обычно при написании какой-либо функции программисту приходится принимать решения по определению:
· области видимости функции;
· способа передачи аргументов;
· области передачи аргументов;
· порядка вычисления аргументов;
· способа возврата результата;
· места размещения локальных переменных;
· использования регистров внутри функции.
Область видимости функции. Функции могут быть определены либо как глобальные (видимые во всех файлах проекта), либо могут быть сделаны локальными (видимыми и доступными только в том файле, где они определены).
В языке Си все глобально определенные функции должны быть описаны в виде прототипов функций. Прототип функции позволяет компилятору корректно обрабатывать вызов данной функции и контролировать его. В прототипе функции указывается тип возвращаемых данных, число и типы аргументов, а также порядок их следования. Прототипы функций обычно помещаются в соответствующие заголовочные файлы. Например:
char *strcpy(char *dst, char *src);
Способ передачи аргументов определяет, КАКАЯ именно информация передается функции в качестве аргумента. Используется два способа передачи аргумента: по ссылке и по значению.
В первом случае в качестве аргумента используется не сама переменная, а ее адрес. Недостатком такого способа является возможность появления побочных эффектов, вызванная тем, что данная функция может изменить текущее значение переменной. Побочные эффекты снижают надежность разрабатываемых программ, а отладка их становится более сложной. Кроме того, в некоторых случаях передача аргумента по ссылке становится невозможной, если переменная не имеет адреса (переменная расположена в регистре) или если в качестве аргумента передается значение некоторого выражения.
Во втором случае в функцию передается копия значения переменной. При этом все манипуляции внутри функции с копией никак не влияют на значение самой переменной, предотвращая тем самым возможность появления побочных эффектов.
В языке Си приняты соглашения по передаче аргументов функциям, согласно которым скалярные переменные передаются функциям по значению, а массивы – по ссылке. В тех случаях, когда необходима передача функции скалярного аргумента по ссылке, используется реализованный в Си механизм указателей.
Область передачи аргументов определяет, ГДЕ именно будут размещаться аргументы функции. В зависимости от места размещения аргументов различают следующие способы передачи аргументов:
· через стек, то есть аргументы записываются в стековую область памяти;
· в регистрах;
· через статическую область памяти, то есть для данной функции создается специальная область в статической памяти, куда копируется аргументы, прежде чем будет сделан вызов данной функции. В этом случае реализация реентерабельных функций становится затруднительной.
При размещении аргументов в стеке под них выделяется соответствующая область в стековой памяти, обычно это происходит каждый раз при записи в стек очередного аргумента. Естественно, что при выходе из функции, занятая область стека должна быть освобождена. Вопрос состоит в том, как именно это должно быть сделано. В одном случае стек может освобождаться внутри вызываемой функции, перед возвратом из нее. Во втором случае, стек освобождается вызывающей функцией, после того как к ней вернется управление. Первый вариант предпочтительней, с точки зрения размера кода. Вообще же это соглашение полностью определяется разработчиками компилятора.
Способ размещения регистров во многом определяется архитектурой целевого процессора. Скажем, в MSP430 имеется возможность адресации через указатель стека SP, и передача аргументов через стек выглядела бы вполне логичной. В других процессорах, имеющих большое количество регистров (например, AVR), для передачи аргументов можно выделить соответствующие регистры. Наконец, для процессоров семейства PIC, не имеющих стека или регистров, для размещения аргументов остается использовать статическую память.
Поскольку язык Си никак не оговаривает способ размещения аргументов, то это соглашение целиком определяется разработчиками компилятора. Так, например, в IAR C используется комбинированный способ передачи параметров, при котором часть параметров передается через регистры, а часть – через стек.
Порядок вычисления аргументов определяет очередность обработки аргументов в списке. Возможны два варианта: слева направо и справа налево. При обработке слева направо аргументы, стоящие в списке слева, обрабатываются в первую очередь, а аргументы, стоящие в списке справа, - в последнюю. Во втором варианте порядок обработки аргументов прямо противоположный.
Порядок обработки аргументов справа налево удобно использовать в тех случаях, когда число аргументов у функции не определено и может меняться от вызова к вызову, как, например, у стандартной библиотечной функции printf.
Опять же, стандарт Си не оговаривает порядок обработки аргументов, он целиком определяется разработчиками компилятора. Это может вызвать проблемы при переносе программы на другую платформу, или даже просто при смене компилятора.
Способ возврата результата определяет место, куда помещается значение, возвращаемое функцией. Так же как и аргументы функции, результат функции может быть размещен либо в стеке, либо в регистрах, либо статической памяти. Способ возврата значения, используемый в каком-либо компиляторе, обычно указывается в руководстве по данному компилятору.
Место размещения локальных переменных оговаривается стандартом Си с точки зрения сохранности значений локальных переменных от одного вызова функции до другого. Если нет необходимости сохранять значения локальных переменных между двумя последующими вызовами функции, то такие переменные по умолчанию размещаются в стеке, либо в регистрах. В последнем случае для указания компилятору о размещении переменной в регистре используется квалификатор класса памяти register. Обычно современные оптимизирующие компиляторы игнорируют это указание, поскольку они сами определяют, какие именно переменные следует разместить в регистрах, а какие – в стеке. Естественно, что при размещении локальных переменных или в стеке или в регистрах, значения таких переменных при входе в функцию будут не определенными.
В тех случаях, когда значения переменных необходимо сохранять от одного вызова функции до другого, соответствующие локальные переменные должны размещаться в статической области памяти. Для этого используется квалификатор класса памяти static.
Выбор места для размещения переменных, является одним из наиболее важных вопросов возникающих при кодировании функции. От того, насколько правильно сделан этот выбор, зависит как размер программы, так и ее быстродействие. Рассмотрим различные варианты несколько подробнее.
Размещение переменных в стеке позволяет экономно использовать оперативную память (обычно ее часто не хватает) процессора, поскольку память под переменные в стеке выделяется при входе в функцию, и освобождается при выходе из нее. Эффективность программ при размещении переменных в стеке получается нисколько не меньше, чем в случае использования статической памяти, а иногда может быть даже и выше, при соответствующей организации стека. Однако следует помнить, что размер стека тоже ограничен.
Замечания по реализации:
· (C, asm) при размещении в стеке большого количества переменных (например, массивов) возможно переполнение стека, в результате чего поведение программы становятся непредсказуемым. Однако компиляторы часто выдают необходимую информацию, позволяющую определить необходимый размер стека;
· (C) для инициализации большого количества данных в стеке (например, массивов) компиляторы размещают набор начальных значений данных в программной памяти, откуда они считываются при входе в функцию. Размещение таблиц в программной памяти позволяет повысить эффективность кода, как с точки зрения размера, так и сточки зрения времени его выполнения;
· (C) необходимо помнить, что данные в стеке существуют только на момент выполнения данной функции и при выходе из нее данные не сохраняются. Поэтому возвращение указателей на данные в стеке приводят к непредсказуемому поведению программы. В тех случаях, когда требуется возвращать указатели на некоторые локальные данные (массивы, структуры и т.д.), необходимо размещать последние в статической области памяти.
Размещение переменных в статической памяти чаще всего используется в тех случаях, когда значения локальных переменных требуется сохранять в промежутках между обращениями к данной функции. Типичными примерами могут служить процедуры обработки прерываний, а также генераторы последовательностей чисел в соответствии с некоторыми рекуррентными формулами. Недостатком такого способа размещения является то, что статическая память, выделенная для таких переменных, становится доступной для использования только внутри данной функции и не может быть использована другими функциями.
Использование регистров внутри функции. В зависимости от назначения регистров и выполняемых ими функций, регистры можно разделить на следующие группы:
· рабочие регистры, они используются для хранения промежуточных результатов при выполнении различных операций, для передачи параметров функции, для хранения возвращаемого результата. Их содержимое не сохраняется при входе в функцию и может изменяться произвольным образом;
· регистры для размещения локальных переменных. Их содержимое не может меняться произвольно и, как правило, в случае использования таких регистров внутри функции их содержимое необходимо предварительно сохранять при входе в функцию и восстанавливать при выходе из нее. Видимость таких переменных ограничена той функцией, в которой они объявлены;
· регистры для размещения глобальных переменных, используются для хранения переменных, доступных всей программе. Они также не могут изменяться произвольно внутри какой-либо функции;
· служебные регистры. Регистры данной группы используется для выполнения чисто служебных функций, например, в качестве указателя области стека.
Хотя способ использования регистров в основном определяется программистом (или разработчиком компилятора), тем не менее, он должен определяться исходя из особенностей архитектуры используемого процессора. Например, если процессор имеет небольшое число регистров или не имеет их вообще, то этот вопрос отпадает сам по себе. С другой стороны, если архитектура процессора такова, что ряд регистров специализирован для выполнения конкретных функций, то этот вопрос тоже во многом решен архитекторами данного процессора. И, наконец, в тех случаях, когда процессор имеет достаточное количество регистров общего назначения, программисту предоставляется возможность полностью решить этот вопрос самостоятельно.
Даже беглый обзор вопросов, возникающих при написании функций, показывает, насколько сложной может быть процедура реализации какой-либо функции, тем более что все рассмотренные вопросы необходимо решать при написании КАЖДОЙ функции программы. Если рассматривать функции как своего рода кирпичики, из которых строится здание Вашей программы, то для того, чтобы это здание было прочным и не разваливалось от различных «толчков» необходимо, чтобы кирпичики-функции были хорошо «подогнаны» друг к другу, были бы унифицированы. Из этого вытекает то, что унификация эта должна быть сделана до того как будет создан хотя бы один «кирпичик». Другими словами, до того как Вы начнете процесс кодирования своей программы, необходимо прежде принять соответствующие соглашения, связанные, в том числе, и с реализацией функций.
Хочу еще раз подчеркнуть, что в случае использования Си все соглашения при программировании определяются либо языком, либо определяются разработчиками компилятора. При этом в разных компиляторах могут быть приняты совершенно различные соглашения, даже если целевой процессор будет одним и тем же. При программировании на ассемблере все соглашения принимаются разработчиком (или группой разработчиков) программы.
В качестве примера рассмотрим соглашения, принятые в компиляторе Си фирмы IAR Systems для процессоров семейства AVR. Но, прежде всего, рассмотрим основные особенности архитектуры AVR, это позволит нам понять, из чего исходили разработчики компилятора, принимая соответствующие соглашения. Отметим следующие особенности архитектуры семейства AVR:
1. регистровая структура процессора. Процессор имеет 32 регистра (R0-R31) общего назначения. Хотя часть регистров может выполнять дополнительные функции, связанные с обращением к памяти, те не менее, узкоспециализированные регистры отсутствуют;
2. все регистры процессора можно разделить на две группы: R0-R15 и R16-R31. При этом регистры первой группы не могут быть использованы в качестве операндов в командах с непосредственной адресацией (команды загрузки, команды логических и арифметических операций);
3. процессор не имеет инструкций, связанных с обработкой данных находящихся в памяти, необходимо обрабатываемые данные предварительно загружать в регистры;
4. для обращения к данным в памяти могут быть использованы регистры R26-R31. При этом они объединяются в пары и образуют три 16-разрядных регистра указателей памяти: R27-R26 (X-регистр), R29-R28 (Y-регистр) и R31-R30 (Z-регистр). Для Y- и Z–регистров имеются команды загрузки / запоминания со смещением. Z-регистр может быть также использован при обращении к памяти программ;
5. указатель стека в процессоре адресуется как порт ВВ. Это дает возможность программисту определять как размер, так и место стековой памяти в ОЗУ. Тем не менее, адресация к памяти посредством указателя стека в процессоре отсутствует.
Организация стековой памяти. Поскольку адресация памяти через указатель стека у AVR отсутствует, то обращение к данным в стеке непосредственно через указатель стека становится весьма затруднительным, имеющийся аппаратный указатель стека может быть использован только для запоминания адресов возврата при обращении к подпрограммам или при прерываниях. Это предопределило разделение стековой памяти на две области: стека адресов возврата (RSTACK) и стека данных (CSTACK).
Для организации стека данных в качестве указателя стека необходимо было использовать одну из регистровых пар указателей памяти: регистры X, Y или Z. Поскольку для регистра X отсутствуют команды запоминания / загрузки со смещением, то в качестве указателя стека данных возможно использование либо регистра Y, либо регистра Z. А так как регистр Z является единственным средством для доступа к программной памяти, то кандидатом на роль указателя стека данных остается только регистр Y, который и был использован разработчиками компилятора соответствующим образом.
Использование регистров процессора. Регистры R0-R3, R16-R23 и R30-R31 используются в качестве рабочих. Регистры R4-R15, R24-R27 могут быть использованы для хранения локальных переменных, поэтому их значения не могут быть изменены в вызываемой функции. При этом регистры R4-R15 могут быть использованы для хранения глобальных переменных при установке в проекте соответствующих опций. Дополнительно необходимо указать компилятору, в каких регистрах будут размещаться те или иные глобальные переменные. Эта возможность является нестандартным расширением языка Си, которое может вызвать проблему при замене компилятора.
Передача параметров функции. Для передачи параметров функции используются регистры R16-R23 и стек. Вычисление (обработка) параметров производится слева направо. Необходимо отметить, использование регистров для передачи параметров в старых и новых версиях компилятора несколько различаются.
Так в версиях 1.xx через регистры передавались первые два (левых) параметра, остальные (3-й и все последующие) передавались через стек. При этом, 1-й параметр размещался в регистрах R16-R19, 2-й – в регистрах R20-R23. Число регистров, занимаемых параметром, зависело от типа данных. Так 1-й параметр, имеющий тип char, размещался в регистре R16, данные типа int размещались в регистрах R16-R17 (младший и старший байт соответственно), а данные типов long или float размещались в регистрах R16-R19.
В версиях 2.xx параметры по-прежнему размещаются в регистрах R16-R23, однако использование этих регистров не связано с номером параметра. При этом для очередного параметра выделяются имеющиеся свободные регистры, начиная с R16. Если оставшихся свободных регистров недостаточно для размещения параметра, то данный параметр и все последующие параметры размещаются в стеке. Следовательно, число параметров, размещаемых в регистрах, определяется типом данных. Так в случае типа char в регистрах можно разместить до восьми параметров, тогда как в случае типа long (float) - только четыре.
Как мы видим соглашения, принятые в новых версиях, усложнились. Но в результате появилась возможность разместить в регистрах большее число параметров, по сравнению со старыми версиями, и, как следствие, повысить эффективность программ.
Возвращение результата. Для возвращения результата функции используются регистры R16-R23. При этом результат, имеющий тип char, возвращается в регистре R16, тип int – в регистрах R16-R17, тип long (float) – в регистрах R16-19.
Из анализа принятых соглашений можно сделать вывод о том, что они сделаны с учетом архитектуры семейства AVR, вполне разумно, и мы можем воспользоваться ими при разработке собственных программ на ассемблере. Это даст нам также возможность сравнить результаты трансляции сделанной нами вручную с результатом, полученным с помощью компилятора фирмы IAR systems.
На этом мы заканчиваем общие рассуждения и переходим непосредственно к реализации самих функций. В языке Си функции описываются следующим образом:
<класс памяти><тип>ИМЯ_ФУНКЦИИ(<список аргументов>)
{
<тело функции>
}
где: <класс памяти> - квалификатор, определяющий область видимости данной функции. Если он отсутствует, то функция определяется как глобальная, т.е. она доступна всей программе. Для ограничения видимости функции пределами файла, где она определена, используется квалификатор класса static;
<тип> - тип функции, указывает типа результата, возвращаемого функцией. Типом результата может быть либо базовым типом (char, int, long и т.д.), либо указателем на него. Функция может возвращать некоторую структуру, либо указатель на нее. Функция НЕ МОЖЕТ возвращать МАССИВ данных какого-либо типа. Если возвращения результата не требуется, то тип функции определяется как void (пустой). Такая функция является эквивалентом процедуры (procedure), имеющейся в других алгоритмических языках (например, в Паскале);
ИМЯ_ФУНКЦИИ – любой допустимый в языке идентификатор, используемый для именования данной функции. Каждая глобально определенная функция должна иметь свое уникальное имя. Имена статических функций, определенных в различных файлах, могут совпадать;
<список аргументов> - перечень аргументов, разделенных символом ‘,’. Для каждого аргумента в списке должны быть указаны тип аргумента и его идентификатор. Список аргументов может быть пустым (void) в тех случаях, когда передача каких-либо параметров функции не требуется;
<тело функции> представляет собой последовательность операторов, выполняющихся при передаче управления данной функции (при ее вызове). Для принудительного завершения функции служит оператор возврата return. Данный оператор имеет две формы:
return <выражение>; и просто return; Первая форма используется в тех случаях, когда функция возвращает некоторый результат. При этом значение выражения преобразуется к типу функции. Вторая форма используется в функциях, имеющих тип void. В случае отсутствия в теле функции операторов return, выход из функции выполняется после выполнения последнего оператора (оператора, стоящего перед закрывающей фигурной скобкой ‘}’).
Прежде всего, интересно сравнить результат ручной трансляции с кодом, сгенерированным компилятором IAR C V2.28A.
\ In segment CODE, align 2, keep-with-next
159 char *strcpy(char *dst, const char *src)
160 {
\ __nearfunc char *strcpy(char *, char const *);
\ strcpy:
\ 00000000 2F5B MOV R21,R27
\ 00000002 2F4A MOV R20,R26
\ 00000004 2FA2 MOV R26,R18
\ 00000006 2FB3 MOV R27,R19
161 char *tmp = dst;
\ 00000008 2FE0 MOV R30,R16
\ 0000000A 2FF1 MOV R31,R17
162
163 while (*tmp++ = *src++);
\ ??strcpy_0:
\ 0000000C 912D LD R18,X+
\ 0000000E 9321 ST Z+,R18
\ 00000010 2322 TST R18
\ 00000012 F7E1 BRNE ??strcpy_0
164 return dst;
\ 00000014 2FA4 MOV R26,R20
\ 00000016 2FB5 MOV R27,R21
\ 00000018 9508 RET
165 }
Различий немного, но они есть. Первое весьма несущественное: изменено использование регистров указателей. Мы использовали регистр X в качестве указателя tmp и регистр Z в качестве указателя src. У компилятора это сделано наоборот, что в данном случае не имеет никакого значения. Другое отличие более существенно: мы сохранили содержимое регистров R26, R27 в стеке, компилятор использовал для этой цели свободные рабочие регистры R20, R21. Хотя размер кода получился один и тот же, тем не менее, время выполнения кода полученного в результате трансляции компилятором несколько меньше, чем это сделано у нас. В данном случае компилятор оказался «умнее» нас, но, как говорится, на ошибках учатся.
Далее, анализируя полученный код, можно увидеть, что основную часть кода функции занимает пролог и эпилог. Невольно может возникнуть вопрос: а нельзя ли как-то уменьшить размер кода и, соответственно, время его выполнения за счет уменьшения (или устранения) команд связанных с сохранением, копированием и восстановлением регистров? Например, можно было бы передавать аргументы в регистрах указателях и тогда не требовалось тратить время на копирование регистров. На этот вопрос можно было бы ответить утвердительно, если бы не одно НО: но как быть с надежностью программы? Что же все-таки важнее: надежность программы или экономия пары – другой байт кода и нескольких десятых долей микросекунд? Я думаю, безусловно, важнее первое. Конечно, если Вы видите, что принятые Вами соглашения сделаны неудачно и их следует изменить, то это нужно сделать. И сделать как можно быстрее, пока Вы еще только начинаете кодирование программы. Но если соглашения приняты, то соблюдать их нужно ВСЕГДА неукоснительно, без всяких исключений. Это является одним из условий надежности Вашей программы.
Если Вы все же решите сократить код пролога и эпилога функции, то можно написать соответствующие служебные подпрограммы, которые вызываются при входе в функцию и перед выходом из нее. Этот стандартный прием используется во многих компиляторах. Используется он и в компиляторе IAR в тех случаях, когда включается оптимизация по размеру программы. Но за уменьшение размера кода приходится при этом расплачиваться увеличением времени выполнения функции.
Тем не менее, существует ряд приемов кодирования функций, позволяющих создавать более эффективный код, как в смысле размера кода, так и смысле времени его выполнения.
Оптимизация в функциях.
Как уже было сказано, неукоснительное соблюдение принятых соглашений является одним из условий создания надежных программ. Хотя платой за это является возможное снижение эффективности кода, что мы и можем увидеть, анализируя коды, сгенерированные компиляторами.
Тем не менее, существуют вполне определенные приемы кодирования позволяющие создавать более компактные коды, не нарушая принятых нами соглашений. Некоторые из этих приемов могут быть использованы при кодировании функций, как на ассемблере, так и на Си. Другие возможны только в случае использования ассемблера. И те, и другие рассматриваемые здесь приемы связаны только с входом в функцию и выходом из нее. Другие приемы оптимизации будут рассмотрены позднее. Но все по порядку.
Порядок аргументов в списке. Для примера возьмем функцию strcpy, рассмотренную выше. Допустим, что у нее изменен порядок аргументов в списке, т.е. обращение к функции выглядело бы так:
char *strcpy(const char *src, char *dst);
Если бы эта функция не была бы стандартной (в данном случае мы стандарт нарушаем), то какая разница, в каком порядке располагать аргументы. Ан нет, с учетом принятых нами соглашений разница хоть и небольшая, но имеется. В соответствии с описанием функции и принятых нами соглашений необходимо, чтобы указатель dst возвращался в регистрах R16,R17, тогда как в функцию этот параметр передается в данном случае через регистры R18,R19. И, стало быть, потребуются дополнительные команды для пересылки данного параметра перед выходом из функции. Я думаю, что размер кода увеличится в этом случае на две команды. Тот, кто хочет, может это проверить.
Можно рассмотреть другой пример. Пусть имеется некоторая функция, скажем f1 аргументами a, b и с (тип аргументов не имеет значения). Данная функция вызывает другую функцию, скажем f2. Это выглядит примерно так:
f1(a, b, c)
{
f2(a);
…..
}
Легко заметить, что аргумент a как при вызове функции f1, так и затем при вызове функции f2 должен размещаться в одном и том же регистре (или регистрах), т.е. никаких дополнительных пересылок данных между регистрами не требуется. Если же функция f1 имела бы другой порядок расположения аргументов в списке, скажем: f1(c, b, a), то перед вызовом f2 аргумент a должен быть скопирован в соответствующий регистр. А так как там в это время находится аргумент c, то, в свою очередь, тот тоже должен быть предварительно где-то сохранен. Т.е. потребуются дополнительные инструкции, необходимые для выполнения соответствующих пересылок. Заметим, что при этом никаких полезных действий не совершается. Так или иначе, можно сделать вывод: порядок размещения аргументов в списке влияет на размер кода.
Совет по оптимизации (C, asm): если для данной функции не задан жестко порядок аргументов в списке, располагайте аргументы в таком порядке, чтобы код этой функции был минимальным.
;//
;// ** InRange -- функция выполняет проверку значения аргумента value на принадлежность его
;// диапазону low … high. Возвращает значение TRUE, если значение value находится
;// в заданном диапазоне. В противном случае возвращает значение FALSE.
;//
;bool InRange (int value, int low, int high)
;
RSEG CODE
PUBLIC InRange
InRange:
;
; Аргументы:
; value - r16, r17;
; low - r18, r19;
; high - r20, r21.
; {
; if (value>=low && value<=high) // Значение value лежит внутри диапазона low…high,
cp r16, r18 ; Проверка нижней границы
cpc r17, r19 ;
brlt f_label ; значение value меньше low
cp r20, r16 ; Проверка верхней границы
cpc r21, r17 ;
brlt f_label ; значение value больше high
; return TRUE; //
ret$T
; return FALSE; // Значение value вне заданного диапазона
f_label:
ret$F
; }