Программирование AVR AVR-GCC: руководство по встраиванию кода на ассемблере Fri, March 29 2024  

Поделиться

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

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

AVR-GCC: руководство по встраиванию кода на ассемблере Печать
Добавил(а) microsin   

Компилятор GNU C (gcc) для микроконтроллеров Atmel AVR RISC предоставляет специальные возможности для встраивания кода на ассемблере в программы на языке C. Это очень полезная возможность может использоваться для ручной оптимизации критических частей программного обеспечения или для использования специфических инструкций микроконтроллера, которые не доступны на языке C. Имейте в виду, что этот документ не касается модуля, полностью написанного на языке ассемблера, поэтому за дополнительной информацией обращайтесь к avr-libc и примерам кода на ассемблере. Здесь и далее дан перевод документации Inline Assembler Cookbook [1]. Этот документ описывает поведение компилятора GCC версии 3.3. Извинения автора и другие отступления от главной темы опущены.

[Оператор asm компилятора GCC]

Начнем с простого примера чтения данных из ножек порта D:

asm("in %0, %1" : "=r" (value) : "I" (_SFR_IO_ADDR(PORTD)) );

Каждый оператор asm разделен двоеточием на части. Всего может быть 4 части:

1. Инструкции ассемблера, заданные как одиночная строковая константа:

"in %0, %1"

2. Список выходных операндов, разделенных запятыми. В нашем примере используется только один операнд:

"=r" (value)

3. Список входных операндов, разделенных запятыми. И снова в нашем примере используется только один операнд:

"I" (_SFR_IO_ADDR(PORTD))

4. Используемые регистры (clobbered registers), в нашем примере эта часть пустая (не используется). Список используемых регистров сообщает компилятору, что мы будем использовать эти регистры в ассемблерном коде, и будем менять их значение самостоятельно. Таким образом, компилятор не будет предполагать, что можно использовать эти регистры для промежуточных вычислений. Подробнее см. раздел "Clobbers".

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

asm(code : output operand list : input operand list [: clobber list]);

В секции кода операнды обозначены символом процента, за которым идет одна цифра. %0 относится к первому операнду, %1 ко второму и так далее. Из нашего примера:

%0 относится к "=r" (value)
и
%1 относится к "I" (_SFR_IO_ADDR(PORTD)).

Пока это может выглядеть немного странным (что такое =r, что такое I?), но синтаксис списка операндов будет скоро объяснен. Сначала посмотрим, какой код сгенерирует компилятор от оператора asm из нашего примера:

        lds r24,value
/* #APP */
        in r24, 12
/* #NOAPP */
        sts value,r24

Комментарии добавлены компилятором, чтобы информировать ассемблер, что сгенерированный код был получен не генерацией операторов C, а операторов встраивания ассемблера (inline assembler). Компилятор выбрал регистр r24 как место хранения значения, прочитанного из PORTD, но компилятор может выбрать и другой регистр. Он даже может неявно загрузить или сохранить значение, или может не включать вообще Ваш код ассемблера. Все эти принятия решений являются частью стратегии оптимизации кода. К примеру, если Вы не используете значение переменной в остальной части программы на C, то компилятор удалит Ваш код, если не отключите оптимизацию. Чтобы избежать такого поведения, добавляйте атрибут volatile к оператору asm:

asm volatile("in %0, %1" : "=r" (value) : "I" (_SFR_IO_ADDR(PORTD)));

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

asm("in %[retval], %[port]" :
    [retval] "=r" (value) :
    [port] "I" (_SFR_IO_ADDR(PORTD)) );

Последняя часть инструкции asm, так называемый clobber list (об этом далее), используется для ручного указания компилятору о модификациях, которые выполняются в коде ассемблера. Эта часть может быть опущена (как в нашем примере), и все другие части должны присутствовать, но могут быть оставлены пустыми. Если Ваш код ассемблера не использует входные и/или выходные операнды, два символа двоеточия должны все еще завершать строку кода ассемблера. Хороший пример - простой оператор, запрещающий прерывания:

asm volatile("cli"::);

[Код ассемблера]

Вы можете использовать те же мнемоники инструкций ассемблера, которые используете с любым другим ассемблером AVR. И Вы можете написать много операторов ассемблера в одну строку кода - сколько хотите, сколько поместится в память FLASH микроконтроллера.

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

Чтобы сделать код более читаемым, Вы можете разместить каждый оператор на отдельной строке:

asm volatile("nop\n\t"
             "nop\n\t"
             "nop\n\t"
             "nop\n\t"
             ::);

Символ перевода строки \n и символ табуляции \t делают генерируемый листинг ассемблера более читаемым. Поначалу это может показаться немного странным, однако это способ, с помощью которого компилятор создает свой собственный листинг кода на ассемблере.

Вы можете также использовать некоторые специальные регистры, обозначенные специальными символами.

Символ Регистр
__SREG__ Status register, регистр статуса по адресу 0x3F
__SP_H__ Старший байт указателя стека по адресу 0x3E
__SP_L__ Младший байт указателя стека по адресу 0x3D
__tmp_reg__ Регистр r0, используется для временного хранения
__zero_reg__ Регистр r1, всегда нуль

Регистр r0 может свободно использоваться в Вашем коде на ассемблере, и его не нужно восстанавливать по окончании Вашего ассемблерного кода. Хорошая идея - использовать __tmp_reg__ и __zero_reg__ вместо r0 или r1, потому что в случае новой версии компилятора могут поменяться правила использования регистров.

[Входные и выходные операнды]

Каждый входной и выходной операнд описывается ограничивающей строкой, за которой идет выражение C в круглых скобках. Для AVR-GCC 3.3 определены следующие ограничивающие символы:

Символ Для чего используется Диапазон
a Просто верхние регистры от r16 до r23
b Базовые пары регистров указателей y, z
d Верхние регистры от r16 до r31
e Пары регистров указателей x, y, z
q Регистр указателя стека SPH:SPL
r Любой регистр от r0 до r31
t Временный регистр r0
w Специальные верхние пары регистров r24, r26, r28, r30
x Пара регистров для указателя X x (r27:r26)
y Пара регистров для указателя Y y (r29:r28)
z Пара регистров для указателя Z z (r31:r30)
G Константа в формате с плавающей запятой 0.0
I 6-битное положительное целое число-константа от 0 до 63
J 6-битное отрицательное целое число-константа -63 до 0
K Целочисленная константа 2
L Целочисленная константа 0
l Нижние регистры от r0 до r15
M 8-битная целая константа от 0 до 255
N Целочисленная константа -1
O Целочисленная константа 8, 16, 24
P Целочисленная константа 1
Q (GCC >= 4.2.x) Адрес памяти со смещением, базирующийся на указателе Y или Z.  
R (GCC >= 4.3.x) Целочисленная константа. от -6 до 5

Выбор правильного ограничивающего (constraints) символа зависит от диапазона констант или регистров, которые должны быть совместимы с используемыми с ними инструкциями AVR. Компилятор C не проверяет ни одну строку Вашего кода ассемблера. Но он может проверить ограничивающий символ на Ваше C выражение. Однако, если Вы укажете неправильный ограничивающий символ, то компилятор может без всяких предупреждений передать ошибочный код ассемблеру. И, конечно, ассемблер выдаст сообщение (часто малопонятное) об ошибке, или сообщение о внутренней ошибке. Например, если Вы укажете ограничение "r" и будете использовать этот регистр с инструкцией "ori" Вашего кода на ассемблере, то компилятор может выбрать любой регистр. Ошибка произойдет, если компилятор выберет регистры от r2 до r15 (регистры r0 или r1 не будут выбраны, потому что они используются для особых целей). Для этого случая правильным ограничивающим символом будет "d". Если Вы выберете символ "M", то компилятор удостоверится, что Вы не передаете ничего, кроме 8-битного значения. Позже мы увидим, как передавать многобайтовые выражения в код ассемблера.

В следующей таблице показаны все инструкции AVR, которым требуются операнды, и соответствующие им ограничивающие символы. Они недостаточно строги из-за неподходящих определений ограничивающих символов в версии 3.3. Нет, например, никакого ограничения, которое ограничивает константы целого числа диапазоном от 0 до 7 для набора бит и операций очистки бит.

Mnemonic Constraints Mnemonic Constraints
adc r,r   add r,r
adiw w,I   and r,r
andi d,M   asr r
bclr I   bld r,I
brbc I,label   brbs I,label
bset I   bst r,I
cbi I,I   cbr d,I
com r   cp r,r
cpc r,r   cpi d,M
cpse r,r   dec r
elpm t,z   eor r,r
in r,I   inc r
ld r,e   ldd r,b
ldi d,M   lds r,label
lpm t,z   lsl r
lsr r   mov r,r
movw r,r   mul r,r
neg r   or r,r
ori d,M   out I,r
pop r   push r
rol r   ror r
sbc r,r   sbci d,M
sbi I,I   sbic I,I
sbiw w,I   sbr d,M
sbrc r,I   sbrs r,I
ser d   st e,r
std b,r   sts label,r
sub r,r   subi d,M
swap r      

Символы ограничения (constraints) могут быть с одиночным префиксом-модификатором. Ограничения без модификатора указывают на операнды только для чтения. Вот эти модификаторы:

Модификатор Что он указывает
= Операнд только для записи, обычно используется для всех выходных операндов.
+ Операнд для чтения и записи.
& Регистр, который должен использоваться только для вывода.

Выходные операнды должны быть только для записи, и выражение C должно быть lvalue [3]. Lvalue означает, что операнды должны существовать и быть допустимы для левой стороны операции присваивания. Имейте в виду, что компилятор не будет проверять, имеют ли операнды разумный тип для вида операции, используемой в инструкциях ассемблера.

Входные операнды, как Вы уже догадались, должны быть только для чтения. Однако как быть в случае, если нужен один и тот же операнд для ввода и вывода? В inline-коде ассемблера не поддерживаются операнды read-write. Но тут есть другое решение. Для входного операнда возможно использовать одиночную цифру на месте символа-ограничителя. Использование цифры n говорит компилятору использовать тот же самый регистр как для n-ного операнда, начиная с 0. Пример:

asm volatile("swap %0" : "=r" (value) : "0" (value));

Оператор поменяет местами нибблы в 8-битной переменной с именем value. Ограничитель "0" говорит компилятору использовать тот же самый входной регистр, что касается первого операнда. Однако имейте в виду, что это автоматически не подразумевает обратный случай. Компилятор может выбрать те же самые регистры для входа и выхода, даже если ему не сказали сделать так. Это не составит проблемы в большинстве случаев, однако может стать фатальным, если выходной операнд модифицирован в коде ассемблера перед тем, как был использован входной операнд. В ситуации, когда Ваш код зависит от разных регистров для входных и выходных операндов, Вы должны добавить модификатор ограничителя & к операнду выхода. Следующий пример демонстрирует эту проблему:

asm volatile("in %0,%1"    "\n\t"
             "out %1, %2"  "\n\t" 
             : "=&r" (input) 
             : "I" (_SFR_IO_ADDR(port)), "r" (output)
            );

В этом примере входное значение читается из порта и затем выходное значение пишется в тот же самый порт. Если вдруг компилятор выберет тот же самый регистр для входа и для выхода, то выходное значение будет уничтожено первой инструкцией ассемблера. К счастью, этот пример использует & constraint modifier для инструктирования компилятора не выбирать любой регистр для выходного значения, которое используется для любых их входных операндов. Вернемся обратно к перестановке. Вот пример кода для перестановки младшего и старшего байта в 16-битном значении:

asm volatile("mov __tmp_reg__, %A0" "\n\t"
             "mov %A0, %B0"         "\n\t"
             "mov %B0, __tmp_reg__" "\n\t"
             : "=r" (value)
             : "0" (value)
            );

Сначала Вы заметите использование регистра __tmp_reg__, который был упомянут в секции "Код ассемблера". Вы можете использовать этот регистр без сохранения его содержимого. Полностью новыми являются буквы A и B в %A0 и %B0. Фактически они обращаются к двум различным 8-битным регистрам, и оба содержат часть имеющегося значения. Другой пример, перестановка байт в 32-битном значении:

asm volatile("mov __tmp_reg__, %A0" "\n\t"
             "mov %A0, %D0"         "\n\t"
             "mov %D0, __tmp_reg__" "\n\t"
             "mov __tmp_reg__, %B0" "\n\t"
             "mov %B0, %C0"         "\n\t"
             "mov %C0, __tmp_reg__" "\n\t"
             : "=r" (value)
             : "0" (value)
            );

Вместо того, чтобы перечислить тот же самый операнд как операнд входа и выхода, этот операнд может быть объявлен как операнд для чтения и записи (read-write). Соответствующий модификатор должен быть добавлен к операнду вывода, и соответствующий входной операнд оставляется пустым:

asm volatile("mov __tmp_reg__, %A0" "\n\t"
             "mov %A0, %D0"         "\n\t"
             "mov %D0, __tmp_reg__" "\n\t"
             "mov __tmp_reg__, %B0" "\n\t"
             "mov %B0, %C0"         "\n\t"
             "mov %C0, __tmp_reg__" "\n\t"
             : "+r" (value));

Если операнды не помещаются в один регистр, то компилятор будет автоматически назначать достаточное количество регистров, чтобы принять весь операнд. В коде ассемблера %A0 соответствует самому младшему байту первого операнда, %A1 самому младшему байту второго операнда, и так далее. Следующий байт первого операнда будет соответствовать %B0, следующий %C0 и так далее.

Это также подразумевает, что часто нужно привести тип входного операнда к желаемому размеру.

Заключительная проблема может возникнуть при использовании регистровых пар - указателей. Если Вы зададите входной операнд как

"e" (ptr)

и компилятор выберет регистр Z (r30:r31), то

%A0 будет соответствовать r30
и
%B0 будет соответствовать r31.

Но обе версии потерпят ошибку на стадии ассемблирования, если Вы явно нуждаетесь в Z, наподобие

      ld r24,Z

Если Вы напишете

      ld r24, %a0

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

[Clobbers]

Как было упомянуто ранее, последняя часть оператора asm является списком clobbers, который может быть опущен, включая разделитель в виде двоеточия. Однако, если Вы используете в коде ассемблера какие-то регистры, которые не были переданы как операнды, то Вы должны сообщить компилятору об этом. Следующий пример делает атомарный инкремент. То есть сразу инкрементируется 8-битное значение, на которое указывает переменная указателя, и гарантируется, что эта операция не будет прервана обработчиком прерывания или другим потоком в многопоточной среде выполнения. Обратите внимание, что мы должны использовать указатель, потому что увеличенное значение должно быть сохранено прежде, чем будут снова разрешены прерывания.

asm volatile(
    "cli"               "\n\t"
    "ld r24, %a0"       "\n\t"
    "inc r24"           "\n\t"
    "st %a0, r24"       "\n\t"
    "sei"               "\n\t"
    :
    : "e" (ptr)
    : "r24"
);

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

    cli
    ld r24, Z
    inc r24
    st Z, r24
    sei

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

asm volatile(
    "cli"                       "\n\t"
    "ld __tmp_reg__, %a0"       "\n\t"
    "inc __tmp_reg__"           "\n\t"
    "st %a0, __tmp_reg__"       "\n\t"
    "sei"                       "\n\t"
    :
    : "e" (ptr)
);

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

{
    uint8_t s;
    asm volatile(
        "in %0, __SREG__"           "\n\t"
        "cli"                       "\n\t"
        "ld __tmp_reg__, %a1"       "\n\t"
        "inc __tmp_reg__"           "\n\t"
        "st %a1, __tmp_reg__"       "\n\t"
        "out __SREG__, %0"          "\n\t"
        : "=&r" (s)
        : "e" (ptr)
    );
}

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

{
    uint8_t s;
    asm volatile(
        "in %0, __SREG__"           "\n\t"
        "cli"                       "\n\t"
        "ld __tmp_reg__, %a1"       "\n\t"
        "inc __tmp_reg__"           "\n\t"
        "st %a1, __tmp_reg__"       "\n\t"
        "out __SREG__, %0"          "\n\t"
        : "=&r" (s)
        : "e" (ptr)
        : "memory"
    );
}

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

В большинстве ситуаций есть решение лучше, декларировать саму переменную указателя как volatile:

volatile uint8_t *ptr;

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

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

[Макросы на ассемблере (Assembler Macros)]

Чтобы повторно использовать части Вашего кода, написанные на языке ассемблера, полезно задать их как макрос и разместить в подключаемых файлах (заголовки *.h, include files). AVR Libc поставляется с набором готовых макросов, которые можно найти в папке avr/include. Использование этих заголовочных файлов может приводить к предупреждающим сообщениям компилятора, если они используются в модулях, которые компилируются в режиме жесткого ANSI (strict ANSI mode). Чтобы избежать этого, Вы можете написать __asm__ вместо asm и __volatile__ вместо volatile. Это эквивалентные алиасы.

Другая проблема при использовании макросов возникнет, если Вы используете метки в коде. В таких случаях для меток используйте специальный паттерн %=, который будет заменен уникальным числом в каждом операторе asm. Вот пример, который взят из avr/include/iomacros.h:

#define loop_until_bit_is_clear(port,bit) 
        __asm__ __volatile__ (             
        "L_%=: " "sbic %0, %1" "\n\t"      
                 "rjmp L_%="               
                 : /* no outputs */        
                 : "I" (_SFR_IO_ADDR(port)),  
                   "I" (bit)    
        )

Когда макрос используется в первый раз, то метка L_%= может быть оттранслирована в L_1404, в следующий раз она может быть оттранслирована в L_1405 или что-то подобное. В любом случае каждый раз метки будут уникальными.

Другим вариант - использование стиля Unix-assembler для нумерации меток. Это объяснено в статье "Как отлаживать файл ассемблера в avr-gdb" ("How do I trace an assembler file in avr-gdb?") [2]. Вышеупомянутый пример будет выглядеть так:

#define loop_until_bit_is_clear(port,bit) 
        __asm__ __volatile__ (             
        "1: " "sbic %0, %1" "\n\t"      
                 "rjmp 1b"               
                 : /* no outputs */        
                 : "I" (_SFR_IO_ADDR(port)),  
                   "I" (bit)    
        )

[Функции-заглушки на C (C Stub Functions)]

Макрос будет вставлять кусок кода во все места, которые ссылаются на макрос. Это может быть неприемлемо для больших блоков кода ассемблера, поскольку приведет к быстрому расходу памяти программ. В этом случае можно задать stub функцию C, которая не содержит ничего, кроме как Ваш код ассемблера. Пояснение: обычно Stub-функциями называют функции-заглушки, которые заданы, но не содержат реального кода на C.

void delay(uint8_t ms)
{
    uint16_t cnt;
    asm volatile (
        "n"
        "L_dl1%=:" "\n\t"
        "mov %A0, %A2" "\n\t"
        "mov %B0, %B2" "\n"
        "L_dl2%=:" "\n\t"
        "sbiw %A0, 1" "\n\t"
        "brne L_dl2%=" "\n\t"
        "dec %1" "\n\t"
        "brne L_dl1%=" "\n\t"
        : "=&w" (cnt)
        : "r" (ms), "r" (delay_count)
        );
}

Назначение этой функции состоит в задержке выполнения программы на указанное количество миллисекунд - с помощью подсчета циклов. Глобальная 16-битная переменная delay_count должна содержать тактовую частоту CPU в герцах, деленную на 4000, и она должна быть установлена перед вызовом этой подпрограммы в первый раз. Как было описано в секции "Clobbers", эта подпрограмма использует локальную переменную для того, чтобы хранить временное значение.

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

uint16_t inw(uint8_t port)
{
    uint16_t result;
    asm volatile (
        "in %A0,%1" "\n\t"
        "in %B0,(%1) + 1"
        : "=r" (result)
        : "I" (_SFR_IO_ADDR(port))
        );
    return result;
}

Примечание: функция inw() поставляется в составе avr-libc.

[Использование имен C в коде ассемблера]

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

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

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

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

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

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

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

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

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

Для получения дополнительной информации по использованию inline-ассемблера см. руководство gcc [4].

[Разбор примера кода inline-ассемблера GCC]

Для того, чтобы лучше понять, как по-настоящему используется inline-ассемблер, полезно разобрать хороший рабочий пример [5]. Это код ассемблера AVR, который работает на частоте 16 МГц и управляет светодиодной RGB-лентой, сделанной на основе драйвера WS2811 [6]. Вот этот код целиком, а потом разберем его строчка за строчкой.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/*
 * Copyright 2012 Alan Burlison, alan@bleaklow.com.  All rights reserved.
 * Use is subject to license terms.
 */

/*
 * WS2811 RGB LED driver. Рассчитан на частоту кварца 16 МГц, выдает скорость 
 *  выходных данных 800 кГц. Проверялся на Minumus 32 board с микроконтроллером 
 *  Atmel ATmega32U2. Компилятор AVR GCC, пример кода для использования см.
 *  в файле threepixeldemo.c.
 * http://bleaklow.com/2012/12/02/driving_the_ws2811_at_800khz_with_a_16mhz_avr.html
 *
 * Прим. переводчика: WS2811.h был успешно протестирован на макетной плате
 *  AVR-USB-MEGA16 с микроконтроллером ATmega32A, кварцевым резонатором 20 МГц,
 *  на светодиодной ленте GE60RGB2811C из 300 шт. RGB-светодиодов 5050 RGB LED.
 */

#ifndef WS2811_h
#define WS2811_h

// Структура для значения цветов RGB.
typedef struct __attribute__ ((__packed__)) {
    uint8_t r;
    uint8_t g;
    uint8_t b;
} RGB_t;

#ifndef ARRAYLEN
#define ARRAYLEN(A) (sizeof(A) / sizeof(A[0]))
#endif

/*
 * Inline asm macro для вывода 24-битного цвета RGB в прядке (G,R,B), старший бит MSB
 *  идет первым.
 * 0 биты кодируются интервалами 250ns hi, 1000ns lo, 1 биты 1000ns hi, 250ns lo.
 * r18 = red байт для вывода
 * r19 = green байт для вывода
 * r20 = blue байт для вывода
 * r26 = сохраненный SREG
 * r27 = внутренний счетчик циклов
 */
#define WS2811(PORT, PIN, RGB, LEN) \
asm volatile( \
/* инициализация */ \
"    cp %A[len], r1      ; проверка len > 0, и немедленный возврат если это так\n" \
"    cpc %B[len], r1\n" \
"    brne 1f\n" \
"    rjmp 16f\n" \
"1:  ld r18, Z+           ; сначала загрузка байта red для вывода\n" \
"    ld r19, Z+           ; загрузка первого байта green для вывода\n" \
"    ld r20, Z+           ; загрузка первого байта blue для вывода\n" \
"    ldi r27, 8           ; загрузка внутреннего счетчика циклов\n" \
"    in r26, __SREG__     ; код критичен по времени выполнения, так что запретим прерывания\n" \
"    cli\n" \
/* green - цикл вывода 8 бит */ \
"2:  sbi  %[port], %[pin] ; перевод вывода в лог. 1, lo -> hi\n" \
"    sbrc r19, 7          ; проверка - в нуле ли старший бит\n" \
"    rjmp 3f              ; true, пропуск перевода hi -> lo\n" \
"    cbi  %[port], %[pin] ; false, перевод hi -> lo\n" \
"3:  sbrc r19, 7          ; выравнивание задержки для двух ветвей кода\n" \
"    rjmp 4f\n" \
"4:  nop                  ; задержка для формирования импульса\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    lsl r19              ; сдвиг к следующему биту\n" \
"    dec r27              ; декремент счетчика бит\n" \
"    cbi %[port], %[pin]  ; pin hi -> lo\n" \
"    brne 2b\n            ; если не передали все биты, цикл\n" \
"    ldi r27, 7           ; перезагрузка внутреннего счетчика бит\n" \
/* red - цикл вывода первых 7 бит */ \
"5:  sbi %[port], %[pin]  ; pin lo -> hi\n" \
"    sbrc r18, 7          ; проверка, в нуле ли старший бит\n" \
"    rjmp 6f              ; true, пропуск pin hi -> lo\n" \
"    cbi %[port], %[pin]  ; false, pin hi -> lo\n" \
"6:  sbrc r18, 7          ; выравнивание задержки для двух ветвей кода\n" \
"    rjmp 7f\n" \
"7:  nop                  ; задержка для формирования импульса\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    lsl r18              ; сдвиг к следующему биту\n" \
"    dec r27              ; декремент счетчика бит\n" \
"    cbi %[port], %[pin]  ; pin hi -> lo\n" \
"    brne 5b              ; если не передали все биты, цикл\n" \
"    nop                  ; выравнивание задержки для двух ветвей кода\n" \
/* red, 8-й бит - вывод его и выборка следующих значений */ \
"    sbi %[port], %[pin]  ; pin lo -> hi\n" \
"    sbrc r18, 7          ; проверка, нулевой ли старший бит\n" \
"    rjmp 8f              ; true, пропуск pin hi -> lo\n" \
"    cbi %[port], %[pin]  ; false, pin hi -> lo\n" \
"8:  sbrc r18, 7          ; выравнивание задержки для двух ветвей кода\n" \
"    rjmp 9f\n" \
"9:  nop                  ; задержка для формирования импульса\n" \
"    nop\n" \
"    nop\n" \
"    ld r18, Z+           ; загрузка следующего red байта\n" \
"    ld r19, Z+           ; загрузка следующего green байта\n" \
"    ldi r27, 7           ; перезагрузка внутреннего счетчика бит\n" \
"    cbi %[port], %[pin]  ; pin hi -> lo\n" \
"    nop                  ; продолжение формирования задержки\n" \
"    nop\n" \
/* blue - цикл вывода первых 7 бит */ \
"10:  sbi %[port], %[pin] ; pin lo -> hi\n" \
"    sbrc r20, 7          ; проверка на нуль старшего бита\n" \
"    rjmp 11f             ; true, пропуск pin hi -> lo\n" \
"    cbi %[port], %[pin]  ; false, pin hi -> lo\n" \
"11: sbrc r20, 7          ; выравнивание задержки для двух ветвей кода\n" \
"    rjmp 12f\n" \
"12: nop                  ; задержка для формирования импульса\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    nop\n" \
"    lsl r20              ; сдвиг к следующему биту\n" \
"    dec r27              ; декремент счетчика бит\n" \
"    cbi %[port], %[pin]  ; pin hi -> lo\n" \
"    brne 10b             ; если не передали все биты, цикл\n" \
"    nop                  ; выравнивание задержки для двух ветвей кода\n" \
/* blue, 8-й бит - вывод & обработка внешнего цикла */ \
"    sbi %[port], %[pin]  ; pin lo -> hi\n" \
"    sbrc r20, 7          ; проверка, нулевой ли старший бит\n" \
"    rjmp 13f             ; true, пропуск pin hi -> lo\n" \
"    cbi %[port], %[pin]  ; false, pin hi -> lo\n" \
"13: sbrc r20, 7          ; выравнивание задержки для двух ветвей кода\n" \
"    rjmp 14f\n" \
"14: nop                  ; задержка для формирования импульса\n" \
"    nop\n" \
"    ldi r27, 8           ; перезагрузка внутреннего счетчика бит\n" \
"    sbiw %A[len], 1      ; декремент внешнего счетчика цикла\n" \
"    breq 15f             ; выход, если внешний счетчик обнулился\n" \
"    ld r20, Z+           ; загрузка следующего байта blue\n" \
"    cbi %[port], %[pin]  ; pin hi -> lo\n" \
"    rjmp 2b              ; внешний цикл, если это нужно\n" \
"15: nop                  ; задержка для формирования импульса\n" \
"    cbi %[port], %[pin]  ; pin hi -> lo\n" \
"    nop                  ; задержка для формирования импульса\n" \
"    nop\n" \
"    out __SREG__, r26    ; снова разрешим прерывания\n" \
"16:\n" \
: \
: [rgb] "z" (RGB), \
  [len] "w" (LEN), \
  [port] "I" (_SFR_IO_ADDR(PORT)), \
  [pin] "I" (PIN) \
: "r18", "r19", "r20", "r26", "r27", "cc", "memory" \
)

/*
 * Определение функции C, чтобы обернуть inline WS2811 для указанного порта и вывода pin.
 */
#define DEFINE_WS2811_FN(NAME, PORT, PIN) \
extern void NAME(const RGB_t *rgb, uint16_t len) __attribute__((noinline)); \
void NAME(const RGB_t *rgb, uint16_t len) { WS2811(PORT, PIN, rgb, len); }

#endif /* WS2811_h */

Строки 18, 19, 161. Задается стандартное определение, защищающее хедер от многократного включения.

Строки 22..26. Эта структура предназначена для хранения цветов одной RGB-ячейки (RGB-светодиод, управляемый одной микросхемой WS2811). Для хранения данных светодиодной ленты, состоящей из нескольких светодиодов, задается массив таких структур. Например, если у Вас заводская RGB-лента GE60RGB2811C (влагозащищенная лента 5 метров длиной), состоящая из 300 RGB-светодиодов, то для её управления нужно задать массив из 300 ячеек RGB_t:

RGB_t rgb[300];

Строки 28..30. Макрос ARRAYLEN, который используется для получения размера массива ячеек RGB_t. Макрос используется так:

WS2811RGB(rgb, ARRAYLEN(rgb));

Для нашего примера (RGB-лента GE60RGB2811C из 300 RGB-ячеек) вызов макроса ARRAYLEN(rgb) вернет 300. Очевидно, что этот макрос задан чисто для наглядности и удобства, так как в нашем примере вместо него можно было просто указать константу 300 с тем же самым результатом:

#define GE60RGB2811C_CELLS 200
RGB_t rgb[GE60RGB2811C_CELLS];
...
WS2811RGB(rgb, GE60RGB2811C_CELLS);

Строки 42, 43. Здесь начинается самое интересное - определение inline-макроса WS2811 с 4 параметрами:

PORT задает 8-битный регистр AVR, которому принадлежит ножка выхода данных для ленты PIN. Например, это может быть регистр PORTB. См. строку 149.
PIN номер бита PORT, который работает как выход данных для ленты. См. строку 150.
RGB адрес массива, где лежат данные всех светодиодов. Для нашего примера это массив RGB_t rgb[300]. Адрес массива rgb будет загружен в индексный регистр z, см. строку 147.
LEN последний параметр макроса, сюда передается длина светодиодной ленты (сколько в ней RGB-светодиодов). Для нашего примера это 300. Значение LEN будет загружено в одну из верхних пар регистров компилятора, см. строку 148.

Строки 45..48. Здесь проверяется на 0 значение регистровой пары, в которую попал параметр LEN. На первый взгляд сложно, но если разобраться, то все просто.

    cp %A[len], r1
    cpc %B[len], r1
    brne 1f
    rjmp 16f

В регистр r1 загружен 0 (это правило inline-ассемблера GCC, о чем упоминалось выше). Хитрыми обозначениями %A[len] и %B[len] просто кодируются соответственно младший и старший байты регистровой пары, в которой хранится параметр LEN. В результате, если в регистровой паре не 0, то произойдет переход по метке 1 (brne 1f). Если же регистровая пара обнулилась, то значит весь массив данных передан, и происходит переход по метке 16 (rjmp 16f). Буква f ничего не значит, просто добавляется к имени метки.

Строки 49..51. Во временные регистры r18, r19, r20 загружаются последовательно байты по адресу в индексе z (как мы помним, туда загружен адрес массива rgb).

Строка 52. В регистр r27 (счетчик бит) загружается значение 8.

Строки 53, 54. Значение регистра SREG запоминается в r26 и запрещается работа прерываний.

Строки 56..67. Здесь идет работа по формированию передачи одного бита по данным в регистре r19. Сигнал данных переводится в уровень лог. 1 и лог. 0 соответственно командами sbi и cbi. Операнды этих команд %[port], и %[pin] берутся из параметров PORT и PIN макроса WS2811.

Строки 68..71. Обработка всех битов в регистре r19 по порядку - с декрементом счетчика бит и сдвигом к следующему биту.

Строки 74..139. Аналогичным образом обрабатываются r19, r20, и передаются оттуда данные в светодиодную ленту. Отличие от предыдущего кода только в том, что иногда подряд передаются только 7 бит, и 8-й бит передается отдельно. Это сделано для того, чтобы выделить время для загрузки в регистры r18, r19, r20 новой порции байт R, G, B. В строке 135 делается декремент регистровой пары len, и в строке 136 делается проверка на 0 - если счетчик обнулился, то произойдет переход по метке 15 и завершение всей передачи. При этом восстанавливается оригинальное значение SREG, и будут разрешены прерывания, если до работы кода inline-ассемблера они были разрешены. Если же len не равен нулю, то в строке 139 произойдет зацикливание по метке 2 (будут передаваться остальные данные RGB).

Строки 147..150. Здесь с помощью хитрого синтаксиса задается соответствие входных параметров макроса и внутренним именам кода inline кода ассемблера. Имена "z", "w", "I" кодируют используемые регистры для указанных имен, z соответствует регистровой паре R31:R30, w регистровой паре из верхних регистров (по выбору компилятора), I задает 6-битное число-константу (см. таблицу в разделе "Входные и выходные операнды").

Строка 151. Здесь задаются используемые регистры (clobbered registers). Понятно, что означает "r18", "r19", "r20", "r26", "r27" - с этими регистрами мы встречались в коде. "cc" означает, что инструкции кода inline-ассемблера могут менять регистр флагов (condition code register, для AVR это регистр SREG). "memory" означает, что код inline-ассемблера может получать доступ к памяти непредсказуемым образом. Это приводит к тому, что GCC не делает кэширование значений памяти в регистрах и не оптимизирует сохранение в память или загрузку оттуда. Зачем тут это указано в контексте AVR и кода, который на самом деле не меняет память - непонятно, наверное "на всякий случай".

Строки 157..159. Здесь специальным образом задается C-имя функции, соответствующее вызову inline-ассемблера.

[Ссылки]

1. Inline Assembler Cookbook site:nongnu.org.
2. How do I trace an assembler file in avr-gdb? site:nongnu.org.
3. Что такое lvalue и rvalue?
4. GCC online documentation site:gcc.gnu.org.
5. Driving the WS2811 at 800KHz with a 16MHz AVR site:bleaklow.com.
6. WS2811: микросхема для управления трехцветным RGB-светодиодом.
7. Как комбинировать программу на Си (C) с кодом ассемблера (ASM).

 

Комментарии  

 
+1 #3 rpocc 10.11.2023 01:35
Как и в переводе, так и в оригинале я не обнаружил правильного символа констрэйнта для передачи в инлайн ассемблер 16-битных констант. А это бывает нужно, например, чтобы сделать какой-нибудь LDS/STS или другую операцию на заданный адрес выше стандартных регистров без использования косвенной адресации. (например, на расширенные порты ATMEGA2560) Как оказалось после серии экспериментов, есть символ "X" (именно заглавная!), позволяющий передать в ассемблер вообще любое числовое значение и использовать его как тот самый label.
Цитировать
 
 
0 #2 Sergei Grigorev 16.06.2017 22:55
Классная статья, буду еще вникать. но вот один вопрос, о чем здесь не сказано. Обратный слеш в конце каждой строки в последнем примере. Зачем его добавляют? Что то вроде nix-ового экранирования перевода строки?

microsin: это стандартный синтаксис C для продолжения строки.
Цитировать
 
 
+1 #1 samsim 07.02.2015 13:13
Программа работает превосходно. Спасибо автору за такой полезный материал, мне очень пригодился. Буду и дальше посещать Ваш сайт.
Цитировать
 

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


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

Top of Page