Си без Си

Использование алгоритмических языков высокого уровня (ЯВУ) и, в частности, Си для программирования микроконтроллеров, несомненно, дает ряд преимуществ по сравнению с языком ассемблера. Основными из них являются:

·        высокая скорость разработки программ;

·        легкость отладки разрабатываемых программ;

·        независимость программного кода от типа контроллера и, соответственно, более или менее простой перенос программ на разные платформы;

·        простота сопровождения программ.

Исходные тексты на языке Си имеют сравнительно небольшие размеры, сами программы, как правило, хорошо структурированы и понятны. Однако компиляторы с языка Си часто менее доступны по сравнению с ассемблером. Кроме того, программный код, генерируемый компиляторами, имеет несколько большие размеры, по сравнению с кодированием на ассемблере, и скорость работы такой программы также бывает несколько меньше. В то же время, при программировании на ассемблере программист полностью контролирует весь код, вплоть до самой последней команды, поскольку в этом случае между ним и контроллером отсутствует посредник в виде компилятора. В то же время, использование ассемблера имеет ряд недостатков, аналогичных достоинствам использования ЯВУ со знаком ‘-‘:

·        низкая скорость разработки программ, с большим риском появления ошибок;

·        большая трудоемкость отладки;

·        сопровождение программ зачастую затруднено;

·        перенос программ на другие платформы часто также затруднен.

Можно найти третий путь написания программ, который позволил бы сочетать достоинства программирования, как на ЯВУ, так  и на языке ассемблера и одновременно сглаживал присущие им недостатки. Пойдя этим путем, мы смогли бы писать компактные и быстрые программы, затрачивая минимальные усилия и не делая при этом  большого количества ошибок. Звучит совсем неплохо! Но что это за путь? Что для этого нужно сделать?

Ответ очень прост: для этого нужно самому стать компилятором. Как это? Что это значит?!!! Это значит, что при кодировании программ нужно просто придерживаться стандартов (соглашений, правил, шаблонов) аналогичных тем, которые используются в компиляторах. Самое главное здесь понять, что любой компилятор генерирует код в соответствии с некоторыми правилами, принятыми его разработчиками. Некоторые из этих правил определяются ЯВУ и мало зависят от конкретной реализации компилятора или архитектуры целевого процессора. Другие соглашения, наоборот, во многом зависят от архитектуры процессора.

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

Соглашения, которые необходимо принять перед разработкой программ,  можно разбить на две группы:

·        соглашения, связанные с обработкой конструкций ЯВУ;

·        соглашения по использованию аппаратных средств (регистров,  памяти) процессора.

Прежде всего, следует отметить, что в любом ЯВУ, и в Си в том числе, определены некоторый набор операторов управления программой (операторы циклов, ветвления и т.п.), множество типов данных, используемых в данном языке, и множество операций над данными различных типов. Далее, стандартом языка оговариваются способы передачи параметров в процедуры и функции: по ссылке, по значению, по имени и т.д. Хотя все эти стандарты определяются в ЯВУ и не зависят от типа процессора, тем не менее, реализация этих стандартом полностью определяется архитектурой процессора.

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

Рассмотрим эти соглашения более подробно.

Трансляция  конструкций языка

Операторы ветвления.

Эти операторы встречаются в программах наиболее часто и, одновременно с этим, наиболее часто являются источником ошибок в программах. В языке Си существуют три возможных варианта организации ветвлений. Это операторы if, ifelse и 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:              ; Здесь начинается следующий оператор

Другой условный оператор Си – оператор ifelse, который записывается следующим образом

                        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     ; Переход к оператору, следующему за ifelse

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 поменять местами операнды и изменив соответствующим образом условие. Правила использования команд условных переходов сведены в следующую таблицу.

 Условие

Команда условного перехода JT

Команда условного перехода JF

Изменение порядка операндов

==

breq

brne

-

!=

brne

breq

-

<

brlt

brge

-

>=

brge

brlt

-

>

brlt

brge

+

<=

brge

brlt

+

< без знака

brlo (brcs)

brsh (brcc)

-

>= без знака

brsh (brcc)

brlo (brcs)

-

> без знака

brlo (brcs)

brsh (brcc)

+

<= без знака

brsh (brcc)

brlo (brcs)

+

Несколько сложнее обстоит дело с реализацией абстрактной инструкции проверки 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 («ложь»). Обработка условного выражения выполняется до тех пор, пока не будет определено значение данного выражения.

Рассмотрим далее схемы трансляции различных условных выражений.

Логическое НЕ. В общем случае, оператор Си ifelse с учетом использования «логического НЕ» имеет следующий вид

                        if (!<C>)                      // Если условие <C> НЕ ВЫПОЛНЯЕТСЯ

                                   {

                                   <S1>                // то выполняется оператор <S1>,

                                   }

                        else      {                      // иначе

                                   <S2>                // выполняется оператор <S2>

                                   }

Схема трансляции такого оператора:

  CHECK (<C>)                        ; Проверка условия

            JT        STATEMENT2              ; Если условие ВЫПОЛНЯЕТСЯ,  то переходим к <S2>

STATEMENT1:                        ; Начало оператора <S1>

            …………                                 ; Тело оператора

            …………                                 ; <S1>

            JUMP  NEXT_STATEMENT     ; Переход к оператору, следующему за ifelse

STATEMENT2:                        ; Начало оператора <S2>

            ………..                                  ; Тело оператора

            ………..                                  ; <S2>

NEXT_STATEMENT:                           ; Здесь начинается следующий оператор


Логическое И. В общем случае, оператор Си ifelse с учетом использования «логического И» имеет следующий вид

                        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     ; Переход к оператору, следующему за ifelse

STATEMENT2:                        ; Начало оператора <S2>

            ………..                                  ; Тело оператора

            ………..                                  ; <S2>

NEXT_STATEMENT:                           ; Здесь начинается следующий оператор

Пример 3: Дан фрагмент кода на языке Си

int          value,               // Переменная

low,                 // Нижняя граница

high;                 // Верхняя граница

char       in_range;          // Результат проверки

……

if (value>=low && value<=high)              // Значение value лежит внутри диапазона lowhigh,

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:                          ; Продолжение программы


 Логическое ИЛИ. В общем случае, оператор Си ifelse с учетом использования «логического ИЛИ» имеет следующий вид

                        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     ; Переход к оператору, следующему за ifelse

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. С помощью данного оператора реализуется много-альтернативное ветвление в программах. Схемы трансляции такого оператора могут быть различными в зависимости от количества вариантов и диапазона их изменений. Если вариантов немного, то данный оператор можно реализовать в виде последовательности операторов ifelse. Если количество вариантов достаточно велико, то такой оператор реализуется при помощи таблиц. Если степень разброса значений вариантов невелика, то достаточно использовать одну таблицу переходов, в которой значения варианта будет являться индексом этой таблицы. Если же разброс значений вариантов велик, то размер такой таблицы резко увеличивается и для уменьшения размера кода целесообразно использовать две таблицы. В одной таблицы помещают значения вариантов, а в другой – соответствующие им адреса переходов.

Более подробно схемы трансляции оператора switch мы обсудим позднее.

Несколько замечаний  относительно команд условного перехода. Можно сделать их менее зависимыми от используемого процессора, если реализовать их в виде макроопределений, используя средства макропроцессора, имеющегося в ассемблере. Для этой цели воспользуемся приведенной выше таблицей. Для имен макрокоманд переходов можно принять, например следующие соглашения. Первые две буквы (JT или JF) используются для указания типа условия, далее следует разделительный символ, и затем мнемоническое обозначение условия, например JT_EQ (переход по выполнению условия ==) или JF_GTU (переход по не выполнению условия >= без знака). Макроопределения для них будут следующими:

JT_EQ             macro  LABEL ; Переход если выполняется условие ==

                        breq     LABEL

                        endm

JF_GTU           macro  LABEL ; Переход если не выполняется условие > (без знака)

                        brcc     LABEL

                        endm

Таким же образом можно написать макроопределения и для остальных команд переходов.


Операторы цикла.

В языке Си для организации циклов в программах используются следующие операторы цикла: while, dowhile и 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 мкс.

Оператор dowhile записывается в Си в виде

            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 и dowhile можно заметить, что второй оператор имеет на одну команду перехода меньше по сравнению с первым и, стало быть, его использование более предпочтительно. Тем не менее, там, где необходимо отложить выполнение цикла до выяснения соответствующего условия, приходится пользоваться оператором while. Оператор dowhile удобно использовать при организации циклов, связанных с подсчетом циклов.

Совет по оптимизации (C, asm): старайтесь там, где возможно, использовать оператор dowhile.

Оператор 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 и dowhile становятся эквивалентными и имеют вид

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 оператором dowhile;                                  

·        операторы цикла с пустым телом могут быть пропущены компилятором в результате оптимизации.                                                                                              


Функции

Одним из основных методов, используемых при программировании на Си, является метод модульного проектирования программ. В соответствии с этим методом, вся программа конструируется как совокупность небольших частей или модулей. Метод модульного программирования позволяет реализовать известный с давних времен принцип «разделяй и властвуй», используемый широко и в настоящее время.   Согласно этому принципу вся  большая задача разбивается на ряд более мелких и, соответственно, более простых подзадач. Назначением модуля и является решение одной или нескольких таких подзадач. Как правило, каждый модуль располагается в своем отдельном файле. Модуль может включать в себя  набор необходимых данных и набор подпрограмм или функций, необходимых для решения требуемой задачи.

Обычно функция представляет собой небольшой фрагмент программного кода, решающий одну или несколько небольших, четко определенных, задач. При этом такая функция оформляется в виде подпрограммы, что позволяет неоднократно использовать данный программный код в остальной программе. В целом программа на Си представляет собой набор таких функций, при этом одна из функций является основной или головной. В соответствии с соглашением, принятым в Си, головная функции функция должна иметь имя 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, выход из функции выполняется после выполнения последнего оператора (оператора, стоящего перед закрывающей фигурной скобкой ‘}’).


Пример: ниже приведен текст стандартной библиотечной функции strcpy.

//

// ** strcpy       -- функция копирует строку, адресуемую указателем src в массив с начальным

//                      адресом dst. Возвращает указатель на строку dst.

//

//static                                     // Если данная функция должна быть локальной – удалить комментарии

char *strcpy (char *dst, const char *src)

            {

            char     *tmp = dst;                   // Вспомогательный указатель

 

            while (*dst++ = *src++);           // Копирование строки

            return dst;                               // Вернуть dst

            }

 

Схема трансляции функции в общем случае имеет следующий вид:

;

;           Декларация функции

;

            ………….

;

;           Пролог функции

;

            ………….

;

;           Основной код функции

;

            …………..

ret_label:                      ; Метка выхода из функции

;

;           Эпилог функции

;

            …………...

ret                      ; Выход из функции

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

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

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

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

По метке ret_label передается управление программой при выполнении операторов return.


Проиллюстрируем сказанное на примере ручной трансляции функции strcpy с учетом принятых ранее соглашений.

;//

;// ** strcpy      -- функция копирует строку, адресуемую указателем src в массив с начальным

;//                     адресом dst. Возвращает указатель на строку dst.

;//

;//static                                    // Если данная функция должна быть локальной – удалить комментарии

;char *strcpy (char *dst, const char *src)

;

; Декларация функции strcpy

;

            PUBLIC           strcpy               ; Определяем функции глобальной, иначе поставить комментарии

                                                           ; перед директивой PUBLIC

            RSEG   CODE                          ; Функция будет размещена в программном сегменте CODE

strcpy:                                                 ; Точка входа в функцию

;           {

;           char     *tmp = dst;                   // Вспомогательный указатель

;

; Пролог функции

;

            st         -Y, r26                         ; Сохранить регистры в стеке

            st         -Y, r27                         ;

            mov     r30, r18                                    ; Установить указатель src

            mov     r31, r19                                    ;

            mov     r26, r16                                    ; Установить указатель tmp

            mov     r27, r17                                    ;

;

; Основной код функции

;

;           while (*tmp++ = *src++);         // Копирование строки

??0:

            ld         r18, Z+                        ; Копирование байта

            st         X+, r18                        ;

            tst        r18                               ; Конец строки

            brne     ??0                              ; Если нет – продолжить цикл

 

ret_strcpy:        ; Точка выхода из функции по оператору return, в данном случае метку можно убрать

;

; Эпилог функции

;

            ld         r27, Y+                        ; Восстановить регистры из стека

            ld         r26, Y+                        ;

;           return dst;                               // Вернуть dst

;

; Результат (dst) уже находится в регистрах r16-r17

;

;           }

            ret

Давайте поразмышляем над тем, что у нас получилось.


Прежде всего, интересно сравнить результат ручной трансляции с кодом, сгенерированным компилятором 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): если для данной функции не задан жестко порядок аргументов в списке, располагайте аргументы в таком порядке, чтобы код этой функции был минимальным.

Оптимизация при выходе из функций. Здесь мы рассмотрим возможности для оптимизации функций в тех случаях, когда последним оператором в текущей функции является вызов некоторой другой функции, после чего выполняется выход из данной функции. На языке Си это может выглядеть, например, так:

f1(….)

            {

            …..

            f2();     // Вызов функции f2

            }          // и выход из f1

На языке ассемблера это будет выглядеть соответственно так

f1:

            …..

            …..

            rcall     f2         ; Вызов f2

            ret                   ; и выход из f1

Легко заметить, что последовательность из двух команд rcall и ret можно заменить одной командой rjmp, т.е.

            rjmp    f2         ; Переход к f2 с возвратом.

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

f1:                    ; Точка входа в функцию f1

            …….

            …….

;           rjmp    f2         ; Вызов f2 с возвратом

;

f2:                    ; Точка входа в функцию f2

            …….

            …….

            ret

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

Некоторые могут возразить, что такая оптимизация не дает существенного выигрыша и затрудняет понимание программы. В ответ на эти возражения я могу сказать, что, как правило, существенный выигрыш может дать лишь правильный выбор структур данных и алгоритмов их обработки. Это делается до того как начато кодирование программы. Оптимизация программного кода дает лишь возможность «выжать» из программы несколько десятков байт кода или микросекунд, в тех случаях, когда имеются проблемы с памятью или скоростью. При этом более или менее существенный выигрыш можно получить лишь при использовании совокупности различных методов оптимизации, один из которых мы только что рассмотрели. Что же касается затруднения в понимании программы, то лично я их не испытываю. Увидев такой прием впервые  в чужих программах, и разобравшись с механизмом его работы, я стал использовать его как один из стандартных своих приемов. Могу добавить, что он не зависит от типа процессора или языка ассемблера.

Здесь у Вас может возникнуть справедливый вопрос: а причем тут Си? На это имеется очень простой ответ: некоторые компиляторы (например, IAR C) используют данный метод оптимизации точно так же, как это можно было бы сделать при кодировании программы вручную, используя ассемблер.

Оптимизация с использованием «расширения» Си. При рассмотрении предыдущих методов оптимизации по умолчанию предполагалось, что программа так или иначе может быть закодирована на языке Си. И не имеет большого значения, как такая программа будет транслироваться: вручную или с использованием компилятора.

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

Обычно в программах имеются специальные функции (анализа, проверки и т.п.), назначением которых является выдача результата описываемого в виде логического значения: ДА-НЕТ, ИСТИНА-ЛОЖЬ и т.д. В стандартном Си отсутствуют специальные средства для описания подобных значений. Обычно такие функции описываются как char или int, которые возвращают значения 1 или 0 в соответствующем регистре (регистрах) в зависимости от типа функции. В C++ для описания подобных функций служит тип bool.  Независимо от того, как описана такая функция, вызывающая программа, получив результат от этой функции, должна проверить его значение путем анализа содержимого соответствующего регистра.


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

char test(void);

Такая функция используется следующим образом:

;           if (test() == TRUE)

            rcall     test

            tst        r16

            breq     false_lab           ; Переход, если результат FALSE

То есть каждый раз, после вызова функции требуется команда для анализа возвращаемого значения, после чего выполняется соответствующий условный переход. Можно было бы отказаться от выполнения команды tst, если принять такое соглашение, при котором возвращаемое логическое значение будет передаваться не через регистр, а через флажок признаков: например флажок переноса C, или флажок T. Для AVR использование последнего более предпочтительно, поскольку значение этого флажка можно запомнить в одном из разрядов какого-либо регистра. А поскольку процессор имеет команды условного перехода по состоянию этих флажков, то дополнительной команды для анализа результата не требуется, и программа будет выполняться быстрее. Осталось только принять соглашение, какое значение флажка будет соответствовать значению TRUE, и каким образом это все можно было бы описать средствами языка Си.

Первое сделать достаточно просто. Примем за TRUE значение флажка T равное 1, тогда значение 0 будет соответствовать логическому значению FALSE. Со вторым дело обстоит несколько сложнее, поскольку в Си не предусмотрено средств для описания каких-либо флажков процессора. Конечно, мы можем ввести новый тип данных для этого случая, например тип bool. С точки зрения синтаксиса языка это не представляет проблемы, поскольку язык имеет возможность создания новых типов данных (typedef). Гораздо сложнее обстоит дело с семантикой (смыслом) этого типа, поскольку любой новый тип данных в Си описывается посредством стандартных базовых типов, для которых определены действия над данными соответствующих типов. Чтобы преодолеть это ограничение языка, мы должны определить семантику типа bool, т.е. сделать некоторое «расширение» языка Си по сравнению со стандартом. Я намеренно беру слово расширение в кавычки, поскольку этот тип bool в том смысле, в каком мы его понимаем, никогда и ни одним компилятором поддерживаться не будет. И нами он будет использоваться только в виде комментариев, только для того чтобы подчеркнуть его исключительность.

Тогда, введя такое расширение мы может описать функцию test как :

bool test(void);

И использование такой функции будет выглядеть так

;           if (test() == TRUE)

            rcall     test

            brtc      false_lab           ; Переход, если результат FALSE

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

Мы могли бы также несколько упростить работу с таким типом, если введем дополнительно некоторые макроопределения. Так для команд переходов, можно ввести такие макроопределения:

br$F     MACRO          label     ; Переход, если FALSE

            brtc      label

            ENDM

br$T     MACRO          label     ; Переход, если TRUE

            brts      label

            ENDM

Для упрощения кодирования возврата из функций типа bool, можно также ввести соответствующие макроопределения:


ret$F    MACRO          ; Выход с возвратом FALSE

            clt                    ; Сбросить флажок T

            ret                    ; и выход

            ENDM

ret$T    MACRO          ; Выход с возвратом TRUE

            set                   ; Установить флажок T

            ret                    ; и выход

            ENDM

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

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

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

В качестве примера реализации функций типа bool рассмотрим функцию InRange и пример ее использования.

;//

;// ** 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

;           }


Следующая функция InRangeCount использует предыдущую функцию в качестве тестовой.

;//

;// ** InRangeCount      -- функция выполняет подсчет числа элементов массива array, попадающих

;//                                в диапазон low … high. Возвращает число элементов массива, значения которых

;//                                лежат в заданном диапазоне. Размер массива array не превышает 255.

://

;char InRangeCount(int *array, int low, int high, char size)

;

            RSEG  CODE

            PUBLIC          InRangeCount

 

InRangeCount:

;

; Аргументы:

;           array    - r16, r17;

;           low       - r18, r19;

;           high      - r20, r21;

;           size      - r22.

;           {

            st         -Y, r24             ; Сохранить регистры

            st         -Y, r25             ;

            st         -Y, r26             ;

            st         -Y, r27             ;

;           char     count = 0;         // Сбросить счетчик

            ldi        r25, 0               ;

;

            mov     r26, r16                        ; Установить указатель

            mov     r27, r17                        ;

            st         -Y, r18             ; Сохранить аргументы в стеке

            st         -Y, r19             ;

            st         -Y, r20             ;

            st         -Y, r21             ;

            mov     r24, r22                        ; Скопировать счетчик циклов

;           do        {

??_1:

;                       if (InRange(*array++, low, high) == TRUE)

            ld         r16, X+            ; Выбрать элемент массива

            ld         r17, X+            ;

            ldd       r18, Y+3          ; Загрузить параметр low

            ldd       r19, Y+2          ;

            ldd       r20, Y+1          ; и high

            ld         r21, Y              ;

            rcall     InRange            ; Проверить элемент

            br$F     ??_2                 ; Вне диапазона – не считается

;                                  ++ count;

            inc       r25                   ; Увеличить счетчик элементов

;                       }

??_2:

;           while (--size);

            dec       r24                   ; Уменьшить счетчик циклов

            brne     ??_1                 ; и переход, если еще не сделано

 

            adiw     r29 : r28, 4       ; Освободить стек данных (CSTACK)

            mov     r16, r25                        ; Вернуть результат

            ld         r27, Y+            ; Восстановить регистры

            ld         r26, Y+            ;

            ld         r25, Y+            ;

            ld         r24, Y+            ;

;           return count;

;           }

            ret

Некоторые комментарии к программе. Во-первых, в данной функции доступ к массиву производится через указатель, а не через индекс, что позволяет упростить код. Во-вторых, можно заметить большое количество пересылок данных между регистрами и стеком. Это связано с принятыми нами соглашениями по использованию регистров. Регистры r16-r23, r30-r31 могут быть изменены вызываемой функцией, регистры r24-r27 вызывающая функция не должна менять, поскольку эти регистры могут быть использованы функцией, вызывающей данную (т.е. InRangeCount). И так как нет гарантии, что рабочие регистры не будут изменены функцией InRange, то пришлось использовать регистровые переменные R24-R27, которые наша функция не должна изменять. Поэтому потребовались команды для сохранения этих регистров в стеке при входе, и восстановления их при выходе. Хуже всего то, что параметры тестовой функции InRange приходится перезагружать в цикле. А это приводит к снижению скорости работы программы.

Я смотрел код для этих функций, который генерирует компилятор. Для InRange разницы никакой нет (если не учитывать наше «расширение»). Для InRangeCount разница очень небольшая, можно ожидать, что код компилятора будет работать чуть быстрее, чем наш. Данный пример показывает, в чем причина потери эффективности кода при использовании Си (и компиляторов тоже) по сравнению с программированием на ассемблере. Эта потеря эффективности является платой за надежность программ.

Самое интересное то, что функция InRange не изменяет никаких регистров, за исключением разве регистра признаков. Мне кажется, что если бы компилятор (IAR C V2.28A) делал глобальную оптимизацию, он бы мог это «заметить», тем более что обе функции я разместил в одном файле, и вызываемая функция в тесте программы была первой. Но нет, компилятор этого не «увидел». Возможно другой компилятор (например, GCC) делает более полную оптимизацию. Поэтому все предосторожности по сохранению регистров могут показаться излишними? В данном случае я не могу сказать однозначно да или нет. Если тексты обеих функций находятся в одном файле, и программист твердо уверен, что функция InRange НИКОГДА не будет изменять регистров, то, наверное, можно упростить функцию InRangeCount. Насколько она упроститься мы чуть позже увидим.

Хочу обратить внимание на работу со стеком внутри функции. Можно заметить ряд неудобств при обращении к данным в стеке. В следующем разделе мы рассмотрим некоторые приемы, позволяющие упростить работу со стеком. Ну а теперь оптимизированная версия функции InRangeCount.

;           {

;           char     count = 0;         // Сбросить счетчик

            ldi        r23, 0               ;

;

            mov     r30, r16                        ; Установить указатель

            mov     r31, r17                        ;

;           do        {

??_1:

;                       if (InRange(*array++, low, high) == TRUE)

            ld         r16, Z+             ; Выбрать элемент массива

            ld         r17, Z+             ; Параметры low и high

                                               ; уже находятся в нужных регистрах

            rcall     InRange            ; Проверить элемент

            br$F     ??_2                 ; Вне диапазона – не считается

;                                  ++ count;

            inc       r23                   ; Увеличить счетчик элементов

;                       }

??_2:

;           while (--size);

            dec       r22                   ; Уменьшить счетчик циклов

            brne     ??_1                 ; и переход, если еще не сделано

 

;           return count;

            mov     r16, r23                        ; Вернуть результат

;           }

            ret

Как мы видим, код значительно упростился, исчезло множество команд связанных с сохранением регистров и их восстановлением и время выполнения функции значительно уменьшилось. И это все в результате всего лишь одного допущения о сохранности регистров в функции (еще раз повторюсь, но в данном случае это так и есть). Может возникнуть традиционный вопрос: что делать, менять соглашения? Или … Здесь можно дать не менее традиционный, одновременно и простой и сложный ответ: ДУМАТЬ!

Что же касается приема «расширения» Си, то можно упомянуть еще об одном варианте оптимизации. Можно использовать переменные типа bool не только для возврата из функций, но и как аргумент самой функции. Иногда в качестве одного из аргументов функции используется некий признак или флажок, в зависимости от состояния которого функция выполняет те или иные действия. Если в качестве такого признака использовать флажок T, то тем самым мы уменьшим количество регистров, необходимых для передачи параметров и упростим код соответствующей функции.

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


Данные

Время от времени в Internet вспыхивают различные «религиозные войны» под лозунгом “X vs. Y”. Одной из них является «война» Asm vs. C (когда во время полемики, эмоции перехлестывают через край, заменяя порой здравый смысл и факты, то назвать происходящее дискуссией очень и очень трудно). Во время подобной «войны», как правило, все споры сосредотачиваются на программах (а точнее на кодах программ). При этом обсуждаются такие характеристиками, как размер кода, быстродействие кода, переносимость, время разработки и т.д. и т.п. В то же время, как сторонники, так и противники Си (Asm), как-то напрочь забывают о данных.

Я считаю это в корне неверным. В свое время Н. Вирт написал простую и, в то же время, замечательную формулу, с которой я согласен целиком и полностью:

ПРОГРАММЫ = АЛГОРИТМ + ДАННЫЕ.

Ведь, если хорошенько вдуматься, что делает программа? ПРОГРАММА перерабатывает входные ДАННЫЕ в соответствии с заданным АЛГОРИТМОМ и формирует выходные ДАННЫЕ. При этом выбор того или иного алгоритма если не полностью, то во многом зависит от данных, от их структуры. И если не известны данные, то и, соответственно, алгоритм. И тогда о какой программе можно вообще говорить.

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

Но вернемся к лозунгу Asm vsC. Попытаемся еще раз проанализировать «pro и contra» возможности использования ассемблера и Си, рассмотрев также и возможности определения структур и типов данных в языках низкого (ассемблер) и высокого (Си) уровней.

Что касается кодирования программ, то об этом уже много раз говорилось, и я могу лишь только повторить. Сторонники ассемблера справедливо считают, что ассемблер дает им возможность полного контроля над программным кодом. Они имеют полную свободу в выборе использования абсолютно всех аппаратных ресурсов процессора, методов кодирования и соответствующих соглашений. И они не имеют посредника в виде компилятора между собой и процессором, а значит им не нужно разбираться в тонкостях его работы.  Отлично! Я это хорошо понимаю и полностью с этим согласен. Но дополнительно ко всему сказанному, они получают сомнительную «свободу» кодирования достаточно сложных алгоритмов при помощи достаточно примитивных инструкций процессора, «свободу» многократного ввода одних и тех же строк программного кода. И это тоже понятно, и от этого не куда не деться. Закон природы: если есть достоинства, то должны быть и недостатки. Сторонники Си в первую очередь наверняка скажут, что запись даже самых сложных алгоритмов получается компактной, и нет необходимости вдаваться в детали их реализации на уровне инструкций. Отладка программы на уровне операторов Си выполняется быстрее. Наличие стандартной библиотеки функций исполняющей системы избавляет программиста от необходимости писать подпрограммы обработки низкого уровня. И это тоже отлично! Но программист теперь уже вынужден разбираться с работой компилятора, находить взаимопонимание с ним. И не всегда вся нужная для этого документация предоставляется разработчиком компилятора. А вдобавок к сказанному, компилятор может иметь ошибки и нужно учиться их обходить, и ждать когда же разработчик выпустит новую версию компилятора, в которой уже не будет этих ошибок. Правда, отнюдь не гарантируется, что при этом не появятся новые. Короче говоря, и при использовании Си тоже есть свои проблемы, даже если они и не связаны непосредственно с программированием.

А как же быть со средствами описания данных. Здесь, я думаю, у ассемблера этих средств уже гораздо меньше, чем у Си. Ассемблер имеет директивы для резервирования памяти под переменные, директивы записи массивов констант в памяти, директивы для управления программными сегментами. Что же касается типов данных, то ассемблер может работать с байтами, 16-разрядными словами, строками символов. Некоторые ассемблеры имеют возможность обрабатывать 32-разрядные данные, но таких сравнительно немного. Еще меньше существует ассемблеров обрабатывающих данные с плавающей точкой. В любом случае все наборы данных в ассемблере рассматриваются просто как последовательность байтов или слов, и не более. Если же нам понадобится описать данные как более сложные, логически связанные структуры, то нам придется обратиться к Си. Си имеет достаточно средств для описания данных практически любой структуры и любой сложности. Этих средств ассемблер на уровне директив просто не имеет. Но это отнюдь не значит, что ассемблер вообще не имеет никаких средств для описания данных. Ведь работали же с данными задолго до появления Си, и в Си все средства для описания данных сделаны именно с учетом накопленного к тому времени опыта. И, в конце концов, независимо от структур данных и их сложности, компилятор генерирует последовательность все тех же байтов и слов, какими бы сложными эти структуры не были. Какие средства имеются у ассемблера для описания данных и как ими пользоваться, мы и рассмотрим в этом разделе.

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

Нас же будет интересовать только один вопрос: как компилятор работает с данными? Допустим, мы имеем в программе описание некоторого набора данных, сделанное в соответствии с правилами языка Си. Очевидно, что эти данные в конечном итоге должны быть размещены где-то в памяти. Соответственно возникает целый ряд вопросов: где именно будут размещаться наши данные, каким образом они будут размещаться, и если существуют правила трансляции описания данных в код (а они, несомненно, существуют), то каковы эти правила. Далее, программа тем или иным способом обрабатывает эти данные. Но прежде, чем выполнить действия связанные с обработкой наших данных, необходимо получить доступ к ним. А какие механизмы доступа существуют, и каким образом они работают? На эти вопросы мы и постараемся ответить. Но, очевидно, что язык Си не является в этом смысле уникальным. Те же проблемы возникают при трансляции с любого другого языка, в том числе и ассемблера. Последнее является для нас наиболее важным, поскольку мы ведем речь о трансляции с языка Си на ассемблер. Все эти проблемы решаемы и решаются. И как именно они решаются или могут решаться мы и постараемся выяснить.

Предварительное замечание. Рассмотрение всех перечисленных выше вопросов будет невозможным без учета используемых (или предполагаемых для использования) инструментальных средств. Поэтому, прежде всего мы должны выбрать инструменты, которыми мы будем далее пользоваться. В качестве таких инструментов я предпочитаю использовать ассемблер фирмы IAR Systems и систему программирования на его основе (IDE, линкер, библиотекарь). На это имеется ряд причин. Во-первых, это наиболее знакомые мне инструменты для работы с AVR. Во-вторых, данный ассемблер имеет достаточно мощные средства поддержки макрообработки по сравнению другими, известными мне. И, наконец,  среду проектирования на ассемблере V1.50B можно совершенно свободно взять как на сайте IAR, так и на сайте Atmel.

Данные в Си

Любой элемент данных в Си характеризуется рядом свойств, которые указываются при определении этого элемента, а именно:

·        класс данных;

·        тип данных;

·        класс памяти;

·        область видимости данных.

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

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


Имеются следующие базовые целые типы (в скобках указано количество байт необходимое для хранения одного атома):

·        char (1). Диапазон представимых чисел: -128 – +127 (signed), 0  255 (unsigned);

·        int (2). Диапазон представимых чисел: -327688 – +32768 (signed), 0  65535 (unsigned);

·        long int (4). Диапазон представимых чисел: -231 – +231-1 (signed), 0  232 (unsigned).

Базовые типы с плавающей точкой определенные в Си могут быть:

·        float (4). Диапазон представимых чисел: ±1.18E-38  ±3.39E+38;

·        double (8). Диапазон представимых чисел: ±2.23E-308  ±1.79E+308.

Классы памяти. В отличие от других ЯВУ Си дает возможность программисту определить тип данных, но и определить место (память), где эти данные будут располагаться. Обычно предполагается, что в системе имеются три области памяти: сверхоперативная (регистры процессора), оперативная статическая и стековая. Исторически сложилось так, что программы, написанные на Си (как впрочем, и на других ЯВУ) должны были работать на компьютерах, снабженных внешней памятью большой емкости (жесткие диски). При этом перед запуском программы она вместе с соответствующими данными предварительно загружалась с диска в оперативную память компьютера, после чего происходил запуск программы.

В настоящее время программы, написанные на Си должны работать не только на больших компьютерах, но также и во встроенных системах (embedded systems), в которых и программа и данные (или какая-то их часть) должны храниться в постоянной памяти. При этом одни контроллеры могут иметь единое адресное пространство для данных и программ. Другие контроллеры могут быть так называемой Гарвардской архитектуры, в которой память программ (ПЗУ) отделена от памяти данных (ОЗУ). Так или иначе, во встроенных системах, независимо от архитектуры процессора, возникает дополнительная проблема размещения данных в постоянной памяти, т.е. кроме указанных областей памяти имеется еще и постоянная память.

Проблема размещения данных в постоянной памяти для процессоров с единым адресным пространством может быть решена достаточно просто с использованием стандартных средств Си. Сложнее дело обстоит дело с процессорами Гарвардской архитектуры. В этом случае стандартных средств языка уже не хватает, и, чтобы решить эту проблему, разработчики компиляторов вынуждены использовать различного рода расширения языка. А поскольку единого стандарта на это нет, то разработчики «расширяют» язык в соответствии со своими представлениями о расширениях. Все это, безусловно, затрудняет перенос программы из одной системы программирования в другую даже для одного и того же процессора. А что говорить о процессорах другой архитектуры. В этом случае возможность переноса становится еще более проблематичной.

Можно также указать на аналогичную проблему, связанную с размещением данных в постоянной памяти данных (ППЗУ или EEPROM), имеющейся у некоторых контроллеров. Или другая проблема, связанная с обращением к портам ВВ. В некоторых контроллерах (в частности в AVR) для обращения к портам ВВ используются специальные команды и, соответственно, такие порты также должны рассматриваться как отдельный класс памяти, и для обеспечения доступа к ней также необходимо расширить язык. И опять же эти расширения в разных компиляторах могут быть сделаны различным образом, что усложняет перенос программ. В утешение можно заметить, что перенос программ на ассемблере с одной архитектуры на другую является куда более сложной задачей. Хотя при необходимости решается и она.

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

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

·        auto указывает компилятору на то, что данные должны размещаться в стековой памяти и для них при входе в функцию автоматически (отсюда и имя квалификатора) выделяется место в стеке. Данный квалификатор практически не используется, поскольку внутри функции переменные, не имеющие квалификатора, по умолчанию размещаются в стеке;

·        static предписывает компилятору разместить данные внутри функции (локальные переменные) не в стеке, а в статической памяти.

Теперь по поводу расширений. Как я уже сказал, подобные расширения никак не оговариваются стандартом языка (иначе они не были бы расширениями) и разработчики компиляторов расширяют синтаксис языка, руководствуясь, прежде всего своими собственными представлениями об этом, что приводит к проблемам при переносе программ из одной среды проектирования в другую. Я, конечно, мог бы порыться в документации на различные компиляторы, и расписать где и какие расширения имеются. Но думаю, что этого не стоит делать, во-первых, потому, что это бы заняло слишком много времени. Во-вторых, нельзя объять необъятное, и, в-третьих, в нашу задачу не входит разбор тех или иных компиляторов. Поэтому мы ограничимся только одним компилятором, взятым за основу – компилятором фирмы IAR Systems. Для размещения данных в различных областях памяти котроллера и для указания соответствующего метода доступа к ним в компиляторе используется набор дополнительных ключевых слов:

·        __flash указывает компилятору на то, что данные должны быть размещены в программной (flash) памяти;

·        __eeprom используется в случае необходимости размещения данных в ППЗУ (EEPROM);

·        __io дает возможность обращения к портам ВВ с использованием соответствующих специальных команд;

·        __regvar предписывает компилятору располагать глобально определенные данные в регистрах процессора. Кроме этого, в опциях компилятора должно быть дано разрешение на размещение глобальных данных в регистрах процессора.

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

Квалификатор класса памяти не является обязательным атрибутом данных. При его отсутствии по умолчанию действуют следующие соглашения. Переменные, объявленные внутри функции без указания класса памяти,  размещаются в стеке, т.е. они имеют по умолчанию класс памяти auto. Переменные, объявленные вне функции, всегда размещаются в статической памяти. Ключевое слово static в этом случае определяет область видимости данных.

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

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

[<класс памяти>] <тип данных> <список переменных>;

Где:

·        <класс памяти> - указание места расположения данных. Скобки […] используются только для того, чтобы показать, что атрибут класса памяти является необязательным и может отсутствовать. В этих случаях класс памяти используется по умолчанию;

·        <тип данных> - может быть либо одним из стандартных типов, либо определен программистом. В последнем случае определение типа должно предшествовать его использованию;

·        <список переменных> - либо перечень идентификаторов скалярных переменных, либо перечень определений векторных переменных разделенных символами запятой.

Примеры определения скалярных переменных:

unsigned char    ch, temp;          // ch и temp переменные без знака размером в 1 байт, область

// видимости не определена, предположительно локальные

// переменные, определенные внутри функции

static int             Index, Count;    // Переменные со знаком размером в 2 байта, предполагается

                                                  // что область видимости ограничивается пределами файла

Замечания по примерам. Из примеров явно не видно, какова область видимости определенных в них переменных, она только лишь предполагается. А на основании чего сделаны такие предположения? Все дело в том, что я сначала написал идентификаторы переменных, совершенно не думая, автоматически. А потом задумался: для каких переменных я бы использовал такие идентификаторы в своей реальной программе? Следуя тем соглашениям, которые я для себя выработал, я и написал об области видимости в комментариях. Здесь видимо сказался некий автоматизм, который появляется с годами. Но это уже другая история.

Теперь несколько подробнее о векторных данных. Как уже было сказано, наборы векторных данных логически организованы в виде массивов, которые, в свою очередь, могут быть как одномерными, так и многомерными. При определении массивов необходимо, в отличие от скалярных переменных, указывать количество элементов, которое заключается в квадратные скобки []. Например

unsigned int       Array[10];        // Массив из 10 16-разрядных беззнаковых чисел

char                   Dim2[5][16];    // Пример определения двумерного массива

При работе с массивами, как правило, возникает два вопроса: каково начальное значение индекса, и каков порядок изменения индекса в многомерных массивах. Первый вопрос возникает потому, что в различных языках начальное значение индекса определено по-разному. Так, например, в Фортране начальное значение индекса фиксировано и равно 1. В языке Паскаль начальное значение индекса не фиксировано, оно определяется программистом при определении массива.

В языке Си начальное значение индекса массивов также фиксировано и равно 0. Так, например, диапазон изменения индекса для массива Array из примера выше будет лежать в пределах 0…9. Хотя такое соглашение и отличается от принятого в обычной математике, но для принятия такого соглашения имеются вполне определенные логические обоснования, которые мы в дальнейшем рассмотрим.

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

© Bill