Привет, датагорцы — любители Ассемблера!
В пункте 2.5.2 «Инструкции условного перехода» предыдущей части статьи мы переложили на язык инструкций AVR-8 и Cortex M-4 шутливые обязанности ЦПУ из первой части.
Не смотря на то, что полученный в итоге код успешно выполняет поставленную перед ним задачу — эмулирует в терминал печать букв, прихлопы и притопы — подобная структура программы, когда всё её содержимое размещается в одном файле, несёт в себе ряд недостатков.
Выясним, что это за недостатки и как их устранить.
Содержание статьи / Table Of Contents
↑ Недостатки подхода «всё в одном»
Первое, что бросается в глаза при взгляде на упомянутую выше программу — её неудобочитаемость. Код уже не помещается в пределы экранной страницы и приходится пользоваться прокруткой, чтобы перейти к нужной строке. Кроме того, за нагромождением инструкций теряются общая структура программы и алгоритм её работы. В реальности указанные трудности только усиливаются, поскольку даже средних размеров программа может состоять из сотен строк.Есть и неявные минусы. Неизбежным этапом при создании устройства на базе МК является отладка написанной программы, то есть поиск и устранение ошибок. И речь идёт не о явных грамматических или синтаксических ляпах, которые компилятор успешно отлавливает и сообщает нам, в какой именно строке они допущены, а о ошибках, которые компилятор не считает таковыми.
К примеру, алгоритмические, когда нарушена последовательность применения инструкций или ошибки наложения, которые возникают в случае, если мы по невнимательности в одном фрагменте программы используем РОН, содержащий данные, актуальные для другого её участка.
В случае собранного в одном файле кода отладка превращается в задачу со многими неизвестными, поскольку указанные ошибки могут быть допущены в любом месте программы.
Куда проще, когда есть возможность отлаживать код по частям и лишь затем, убедившись в работоспособности каждой части, проверять работу программы в целом.
Помимо изложенного, из-за ограничений, наложенных на имена меток, процедуры прихлопов и притопов в программе продублированы. Следовательно, одни и те же наборы инструкций по два раза каждый будут записаны в память программ (ПП) микроконтроллера, что является нерациональным её использованием.
Перечисленные проблемы легко разрешить, если использовать макросы и подпрограммы. Последние зачастую именуют функциями, поэтому впредь мы будем использовать именно этот термин.
↑ Макросы
По сути, макрос — то же макроопределение, только за именем его скрывается не константа, а фрагмент кода. Соответственно, очень похожи порядок их применения и механизм работы.1. В самом верху ассемблер-файла или в отдельном хидер-файле нижеследующим образом оформляется макрос:
.macro имя макроса
/* Тело макроса, в котором
размещается фрагмент кода */
.endm
2. В том месте программы, где должен располагаться фрагмент, составляющий тело макроса, прописывается имя макроса.
3. Компилятор, встретив в коде имя макроса, меняет его на содержимое тела макроса.
Более гибкой в использовании является форма макроса со входными аргументами, которая записывается несколько иначе:
.macro имя макроса аргумент 1, аргумент 2, … , аргумент n
/* Тело макроса, в котором размещается фрагмент кода,
использующий аргументы макроса */
.endm
При этом, в теле макроса необходимо, в соответствии с требованиями GCC, добавлять символ обратной косой черты «\» перед каждым упоминанием имени любого из аргументов.
Практические примеры оформления макросов обоих типов будут приведены чуть ниже.
Размещение макроса в том же файле, в котором он применяется, мало что даёт в плане сокращения объёма текста, поскольку фактически это будет перенос его фрагмента из одного места файла в другое. Большего эффекта можно добиться, если записывать макросы в специально созданный для этого хидер-файл macro.h, а затем подключать его, при необходимости, в ассемблер-файле посредством директивы .include.
Важно понимать, что применение макросов не уменьшает количество инструкций МК в коде (т.е. не приводит к экономии ПП), а лишь позволяет оптимизировать текст программы, визуально сворачивая несколько строк в одну. В связи с этим не стоит ими увлекаться, оформляя как макрос первый попавшийся участок кода. Гораздо разумнее использовать макросы лишь в случаях с фрагментами, которые повторяются от проекта к проекту, не зависимо от назначения этих проектов. При таком подходе можно с полным правом добавить файл macro.h в папку template, доведя общее количество шаблонных файлов до пяти.
Рассмотрим для обеих платформ примеры эффективного использования макросов.
↑ Макросы для AVR-8
Наиболее очевидным и достойным кандидатом на свёртку для этих МК является процедура указания на вершину стека.Оформим в файле macro.h макрос с именем stackPointerInit:
.macro stackPointerInit
LDI r16, lo8(RAMEND)
OUT SPL, r16
LDI r16, hi8(RAMEND)
OUT SPH, r16
.endm
Тогда, соответствующие участки кода в main.S будут выглядеть так:
.include "attiny85.h" /* или "atmega8.h" */
.include "macro.h"
main:
/* Указать на вершину стека */
stackPointerInit
Для демонстрации порядка оформления и результата применения макроса со входными аргументами представим, что нам нужно в цикле основной программы устанавливать в 1, а затем сбрасывать в 0:
а) Третий бит PB3 регистра ввода-вывода PORTB, макроопределения которых оформим в шаблонном хидер-файле (attiny85.h или atmega8.h):
PORTB = 0x38
PB3 = 3
б) Первый бит одно-байтной переменной с именем flags, прописанной предварительно в секции данных.
Задача, кстати, вполне реальная и будет реализована в проекте из следующей части статьи.
Вспомним, что пара инструкций LDS/STS обеспечивает доступ к регистрам как периферии, так и SRAM, а ANDI/ORI — осуществление операций И/ИЛИ, соответственно, между содержимым РОН и константой.
Учитывая вышеизложенное, добавим в macro.h к уже имеющемуся там stackPointerInit макросы установки setBit и сброса clearBit бита регистра:
.macro setBit arg_1, arg_2
/* Считать в r16 значение регистра, определённого аргументом arg_1 */
LDS r16, \arg_1
/* Установить в 1 бит r16, определённый аргументом arg_2 */
ORI r16, (1 << \arg_2)
/* Записать число из r16 в регистр, определённый аргументом arg_1 */
STS \arg_1, r16
.endm
.macro clearBit arg_1, arg_2
/* Считать в r16 значение регистра, определённого аргументом arg_1 */
LDS r16, \arg_1
/* Сбросить в 0 бит r16, определённый аргументом arg_2 */
ANDI r16, ~(1 << \arg_2)
/* Записать число из r16 в регистр, определённый аргументом arg_1 */
STS \arg_1, r16
.endm
Обратите внимание на следующие детали:
1. Поскольку в макросах setBit и сlearBit задействован r16, следует убедиться до их применения, что указанный РОН не содержит данные, актуальные для другого фрагмента программы.
2. В связи с использованием пары инструкций LDS/STS, в макроопределениях регистров периферии, по отношению к которым применяются макросы setBit и сlearBit, необходимо указывать абсолютные значения их адресов.
Осталось оформить описанную выше задачу в виде программы:
.include "attiny85.h" /* или "atmega8.h" */
.include "macro.h"
.data
flags: .byte 1
.text
.org Reset_vector
RJMP main
.global main
main:
/* Указать на вершину стека */
stackPointerInit
main_loop:
/* Установить и сбросить 3-й бит регистра PORTB */
setBit PORTB, PB3
clearBit PORTB, PB3
/* Установить и сбросить 1-й бит переменной flags */
setBit flags, 1
clearBit flags, 1
/* Вернуться к началу цикла основной программы */
RJMP main_loop
.end
Как видите, для обращения к регистрам разного типа мы использовали одни и те же макросы, меняя лишь значения их аргументов. Более того, применение макросов позволило свернуть текст и сделать его более доступным для понимания.
↑ Макросы для Cortex M-4
Чтобы не плодить однотипные примеры, рассмотрим условия оформления и применения макросов лишь в отношении STM32F401, поскольку для nRF52832 они — абсолютно такие же.В качестве примера макроса без аргументов можно оформить в macro.h строку инициализации указателя стека как макрос с таким же, как и для AVR-8, именем stackPointerInit, что ещё хотя бы чуть-чуть сблизит в вашем восприятии два ядра:
.macro stackPointerInit
.word RAMEND
.endm
Продублируем для STM32F401 пример из предыдущего пункта с установкой и сбросом для:
а) Бита 12 регистра ODR порта ввода-вывода GPIOB, прописав в stm32f401.h их макроопределения:
GPIOB = 0x40020400
ODR = 0x14
ODR_12 = 12
б) Второго бита переменной flags, объявив её предварительно в секции данных.
Добавим в macro.h отвечающие поставленной задаче макросы setBit и сlearBit, используя модификации инструкций AND/ORR для проведения операций И/ИЛИ, соответственно, между РОН и константой:
.macro setBit arg_1, arg_2
/* Считать в r3 значение регистра, определённого аргументом arg_1 */
LDR r2, =\arg_1
LDR r3, [r2]
/* Установить в 1 бит r3, определённый аргументом arg_2 */
ORR r3, (1 << \arg_2)
/* Записать число из r3 в регистр, определённый аргументом arg_1 */
STR r3, [r2]
.endm
.macro clearBit arg_1, arg_2
/* Считать в r3 значение регистра, определённого аргументом arg_1 */
LDR r2, =\arg_1
LDR r3, [r2]
/* Сбросить в 0 бит r3, определённый аргументом arg_2 */
AND r3, ~(1 << \arg_2)
/* Записать число из r3 в регистр, определённый аргументом arg_1 */
STR r3, [r2]
.endm
Тогда, программа в main.S будет иметь следующий вид:
.include "stm32f401.h"
.include "macro.h"
.data
flags: .byte 1
.text
.org 0
stackPointerInit
.org Reset_vector
.word main + 1
.global main
main:
main_loop:
/* Установить и сбросить 12-й бит регистра ODR порта GPIOB */
setBit (GPIOB + ODR), ODR_12
clearBit (GPIOB + ODR), ODR_12
/* Установить и сбросить 2-й бит переменной flags */
setBit flags, 2
clearBit flags, 2
/* Вернуться к началу цикла основной программы */
B main_loop
.end
Так же, как и в случае с AVR-8, перед применением setBit и сlearBit следует убедиться, не содержат ли РОН r2 и r3 данные, которые могут использоваться другими участками программы.
↑ Функции
В функцию обычно выносятся два типа фрагментов кода:а) Многократно повторяющиеся внутри одного проекта. В частности, функция delay, присутствующая в программе прихлопов/притопов представляет из себя набор инструкций, замкнутых в цикл, из которого процессор выходит по истечении заданного времени.
б) Участки программы, ответственные за настройку и последующую работу модулей периферии, а также протоколов общения МК с внешними устройствами (I2c, SPI, UART и т.д.). В качестве примера такого случая можно привести функции uartInit и uartSendByte из предыдущей части статьи.
Использование функций позволяет, в отличие от макросов, не только визуально, но и реально сократить размер кода, а следовательно уменьшить объём занимаемой программой flash-памяти.
К оформлению функций предъявляются практически те же требования, что и в случае с основной функцией:
1. Начало функции обозначается посредством метки.
2. Функция, которая должна быть доступна из других файлов, объявляется глобальной с помощью директивы .global.
Ничто не мешает разместить функцию в main.S, прописав её ниже основной функции и до метки .end. Однако, в этом случае мало что меняется в плане читабельности и гибкости кода, в связи с чем лучше изначально взять за привычку структурировать и разносить по нескольким файлам даже небольшую программу. Среди прочего, такой подход позволяет, при необходимости, переносить без изменений файл с нужными функциями из одного проекта в другой. К примеру, файлы uart.o и delayAVR.o (delayCortex.o) из архива предыдущей части статьи довольно часто подключаются мною к новому проекту на этапе отладки написанной программы.
Структура ассемблер-файла, в который выносятся функции, определяется теми же условиями, что и в случае с main.S:
а) Файл разбивается на две секции — .data и .text. Если использование переменных внутри данного файла не предусмотрено, секцию данных можно исключить.
б) Нижнюю границу кода следует отмечать директивой .end.
в) Желательно завершать файл пустой строкой, во избежание замечаний со стороны компилятора.
Оформляя ту или иную функцию, мы не всегда можем помнить, свободны ли используемые ею РОН или хранят данные, которые актуальны для других фрагментов программы, особенно находящихся в других файлах. В таких случаях, во избежание ошибок наложения, желательно при входе в функцию сохранять в стек текущие значения всех задействованных в ней РОН, а перед выходом — считывать их обратно посредством инструкций PUSH и POP, соответственно. При этом, необходимо помнить, что порядок считывания из стека — обратный порядку записи в него, т.е. значение РОН, сохранённое первым, восстанавливается последним.
Нередки случаи использования вложенных функций, когда одна из них вызывает вторую.
Оптимизируем программу с прихлопами и притопами, оформив подходящие её участки как функции, а заодно выясним, как связана работа функций со спецрегистрами и стеком в случае с AVR-8 и Cortex M-4.
Но, прежде создадим в Notepad++ папку нового проекта с произвольным именем (например, cpuJobDuties, т.е. должностные обязанности ЦПУ) и скопируем в неё следующие файлы для выбранного вами МК:
а) Теперь уже пять, включая macro.h, шаблонных файлов из папки template.
б) Объектные файлы uart.o и delayAVR.o (или delayCortex.o) из архива предыдущей части статьи.
↑ Функции для AVR-8
В предыдущей части статьи, лишь с целью продемонстрировать работу как можно большего числа инструкций, процедуры прихлопов и притопов были оформлены по-разному. Перепишем процедуру притопов с применением тех же инструкций, что и в случае с прихлопами: /* Процедура прихлопов */
CLR r17
LDI r18, CLAPS_MAX_NUM
doClap:
INC r17
LDI r16, 'c'
RCALL uartSendByte
CP r17, r18
BRNE doClap
/* Процедура притопов */
CLR r17
LDI r18, SQUATS_MAX_NUM
doSquat:
INC r17
LDI r16, 's'
RCALL uartSendByte
CP r17, r18
BRNE doSquat
Учитывая, что и прихлопы, и притопы являются проявлениями одного и того же действия — физических упражнений — вынесем циклическую часть обоих процедур в функцию doExercise. Последняя, размещённая в main.S, будет выглядеть вместе с фрагментами кода, вызывающими её, следующим образом:
CLAPS_MAX_NUM = 2
SQUATS_MAX_NUM = 3
main:
main_loop:
/* Сделать прихлопы */
CLR r17
LDI r18, CLAPS_MAX_NUM
LDI r16, 'c'
RCALL doExercise
/* Сделать притопы */
CLR r17
LDI r18, SQUATS_MAX_NUM
LDI r16, 's'
RCALL doExercise
/* Перейти к метке main_loop */
RJMP main_loop
doExercise:
/* Сделать упражнение */
INC r17
RCALL uartSendByte
CP r17, r18
BRNE doExercise
/* Вернуться из функции */
RET
.end
Как видите:
а) Вызов функции doExercise и возврат из неё обеспечиваются инструкциями RCALL и RET, соответственно.
б) При оформлении функции не использована директива .global, поскольку она является локальной, т.е. находится в том же файле, из которого вызывается.
Прикинем пользу от применения функции в плане экономии памяти программ:
1. Четыре инструкции doExercise (INC, RCALL, CP и BRNE) присутствуют в коде лишь однажды, в то время как в предыдущей части статьи прописывались 4 раза (в doClap_A, doClap_B, doSquat_A и doSquat_B).
2. С другой стороны, добавились инструкция вызова функции RCALL, которая в полной программе будет применяться четырежды (по два раза — на прихлопах и притопах), и инструкция возврата RET, используемая 1 раз.
3. 4 х 4 — 4 — 1 = 11 инструкций.
4. Итого, 11 х 2 байта = 22 байта экономии. И это — результат, полученный только на четырёх инструкциях, а ведь в реальной функции их могут быть десятки.
Продолжим оптимизацию, для чего создадим в папке проекта файл exercises.S, в который перенесём функцию doExercise:
.text
doExercise:
/* Сохранить в стек текущие значения r17 — r18 */
PUSH r17
PUSH r18
/* Сделать упражнение */
doExercise_loop:
INC r17
RCALL uartSendByte
CP r17, r18
BRNE doExercise_loop
/* Восстановить значения r17 — r18 в обратном порядке */
POP r18
POP r17
/* Вернуться из функции */
RET
.end
В данном конкретном случае сохранение в стек и последующее восстановление текущих значений r17 – r18 не требуется, поскольку нам точно известно, что нигде более, кроме doExercise, они не используются. Поэтому, инструкции PUSH/POP прописаны только в качестве примера порядка их применения.
Заметьте, что в функцию дополнительно введена метка doExercise_loop, имя которой и использует теперь инструкция BRNE в качестве аргумента. Сделано это, чтобы инструкции PUSH не исполнялись несколько раз, попав в цикл.
Полный объём кода в main.S по-прежнему оставляет желать лучшего, в связи с чем добавим в exercises.S ещё одну функцию doBothExercises, в которую перенесём:
а) инструкции очистки r17 и загрузки чисел в r16 и r18,
б) инструкцию вызова функции doExercise,
в) инструкции, обеспечивающие секундную задержку и вывод в Terminal символа пробела.
Туда же, в exercises.S, отправим макроопределения максимального количества прихлопов и притопов:
CLAPS_MAX_NUM = 2
SQUATS_MAX_NUM = 3
.text
.global doBothExercises
doBothExercises:
/* Сохранить в стек текущие значения r16 — r18 */
PUSH r16
PUSH r17
PUSH r18
/* Сделать прихлопы */
CLR r17
LDI r18, CLAPS_MAX_NUM
LDI r16, 'c'
RCALL doExercise
/* Сделать притопы */
CLR r17
LDI r18, SQUATS_MAX_NUM
LDI r16, 's'
RCALL doExercise
/* Вывести в Terminal символ пробела */
LDI r16, 32
RCALL uartSendByte
/* Обеспечить задержку в 1 секунду */
RCALL delay
/* Восстановить значения r16 — r18 в обратном порядке */
POP r18
POP r17
POP r16
/* Вернуться из функции */
RET
doExercise:
/* Сделать упражнение */
INC r17
RCALL uartSendByte
CP r17, r18
BRNE doExercise
/* Вернуться из функции */
RET
.end
Обратите внимание, что:
1. Функция doBothExercises объявлена глобальной, поскольку будет вызываться из другого файла (main.S), в то время как doExercise — по-прежнему локальная в связи с тем, что обращение к ней происходит лишь в exercises.S.
2. Функция doExercise вызывается из doBothExercises, т.е. является вложенной по отношению к последней.
3. Инструкции сохранения и восстановления значений r17 – r18 перенесены в doBothExercises и объединены с таковыми для r16. Дублировать их в doExercise не имеет никакого смысла.
4. Метка doExercise_loop в функции doExercise исключена, в связи с отсутствием инструкций PUSH.
В итоге, программа в main.S примет следующий окончательный вид:
.include "attiny85.h" /* или "atmega8.h" */
.include "macro.h"
LAMP_BRIGHTNESS_CURRENT_VALUE = 32
LAMP_BRIGHTNESS_THRESHOLD = 15
.text
.org Reset_vector
RJMP main
.global main
main:
stackPointerInit
RCALL uartInit
main_loop:
LDI r20, LAMP_BRIGHTNESS_CURRENT_VALUE
CPI r20, LAMP_BRIGHTNESS_THRESHOLD
BRLO type_B
type_A:
LDI r16, 'A'
RCALL uartSendByte
RCALL doBothExercises
RJMP main_loop
type_B:
LDI r16, 'B'
RCALL uartSendByte
RCALL doBothExercises
RJMP main_loop
.end
Можете сравнить полученный результат с финальной программой из пункта 2.5.2.1 предыдущей части статьи и сделать выводы о пользе применения макросов и функций.
Чтобы полноценно продемонстрировать влияние работы функций на состояние спецрегистров и стека, вполне достаточно кода, содержащего всего две функции, одна из которых является вложенной. Поэтому, сократим текст нашей программы до следующего минимума:
main.S
.include "attiny85.h" /* или "atmega8.h" */
.include "macro.h"
.text
.org Reset_vector
RJMP main
.global main
main:
stackPointerInit
main_loop:
RCALL doBothExercises
RJMP main_loop
.end
exercises.S
CLAPS_MAX_NUM = 2
.text
.global doBothExercises
doBothExercises:
/* Сохранить в стек текущие значения r16 — r18 */
PUSH r16
PUSH r17
PUSH r18
/* Сделать прихлопы */
CLR r17
LDI r18, CLAPS_MAX_NUM
LDI r16, 'c'
RCALL doExercise
/* Восстановить значения r16 — r18 в обратном порядке */
POP r18
POP r17
POP r16
/* Вернуться из функции */
RET
doExercise:
/* Сделать упражнение */
INC r17
CP r17, r18
BRNE doExercise
/* Вернуться из функции */
RET
.end
Анимация одного полного цикла работы такой программы для ATtiny85 представлена на Видео 1.
Видео 1. Демонстрация работы программы для МК Attiny85.
Как видите, инструкции из файлов main.S и exercises.S объединены в ходе компиляции и последовательно загружены в память программ МК, начиная с её младшего адреса 0х0000. В левой части каждого кадра отображается состояние РОН, регистров SRAM из области стека и спецрегистров (SP, SREG и PC) после выполнения помеченной маркером инструкции.
Пройдёмся по кадрам видео, начиная с первого:
1. При исполнении инструкции RJMP в программный счётчик РС записывается значение адреса, являющееся аргументом инструкции.
2. По завершению процедуры указания на вершину стека (адрес 0х0008 ПП) составляющие (SPL и SPH) спецрегистра SP содержат значение старшего адреса SRAM – 0х025F.
3. Выполнение инструкции RCALL по адресу 0х000А приводит к следующим последствиям:
а) В РС загружается значение 0х000Е адреса первой инструкции функции doBothExercises.
б) Два байта адреса (0x00 и 0х0C) инструкции, которая должна была быть исполнена следующей, записываются в стек. При этом SPL дважды декрементируется и в итоге SP указывает на следующий свободный регистр стека (0х025D).
4. Каждая инструкция PUSH по адресам 0x000E – 0x0012 обуславливает сохранение в стек текущего значения соответствующего РОН, декремент SPL и увеличение на 2 значения PC.
5. Исполняя следующие три инструкции (адреса 0x0014 – 0x0018 ПП) ЦПУ очищает r17 и загружает в r18 и r16 значения максимального количества прихлопов (0х02) и ASCII кода символа «с» (0х63), соответственно. Поведение РС при этом — такое же, как и в предыдущем пункте.
6. Реакция МК на инструкцию RCALL по адресу 0х001А аналогична случаю из п.3:
а) Значение адреса следующей инструкции (байты 0х00 и 0х1С) записывается в стек, уменьшая при этом значение SP до 0х0258.
б) В РС помещается значение 0х0024 адреса первой инструкции функции doExercise.
7. Согласно инструкций по адресам 0х0024 — 0х0028 ПП процессор будет увеличивать на 1 число в r17, сравнивать числа в r17 и r18 и возвращаться к адресу 0х0024 до тех пор, пока значения чисел в r17 и r18 не сравняются. В этом случае:
а) При исполнении инструкции CP устанавливается в 1 первый бит Z регистра статуса SREG, т.е. значение последнего становится равным 0х02.
б) На инструкции BRNE обнуляется бит Z регистра SREG, а в РС загружается адрес 0x002A следующей ниже инструкции RET.
8. Выполнение инструкции RET по адресу 0х002А ПП приводит к считыванию из стека в РС значения 0х001С адреса возврата и увеличению значения SPL на 2 до 0x5A.
9. При исполнении каждой инструкции POP по адресам 0x0001C – 0x0020:
а) Из стека в соответствующий РОН считывается сохранённое там ранее значение.
б) Число в SPL увеличивается на 1.
в) Значения PC увеличивается на 2.
10. Так же, как и в пункте 8 инструкция RET по адресу 0х0020 ПП обуславливает считывание из стека в РС значения 0х000С адреса возврата и увеличение значения SPL на 2 до 0x5F.
11. По адресу 0x000C ПП осуществляется загрузка в РС значения адреса первой инструкции main_loop, куда и переходит ЦПУ, завершая один круг цикла основной функции.
Как видите картина мало чем отличается от описанной в Главе 7 первой части статьи.
Для Atmega8 всё будет происходить точно так же, за исключением начального значения указателя стека SP: 0x45F вместо 0x25F.
↑ Функции для Cortex M-4
В отличие от AVR-8, процедуры прихлопов и притопов для Cortex M-4 в предыдущей части статьи были изначально оформлены с применением одинаковых инструкций: /* Процедура прихлопов */
LDR r3, =0
MOV r2, CLAPS_MAX_NUM
doClap:
ADD r3, 1
MOV r1, 'c'
BL uartSendByte
CMP r3, r2
BNE doClap
/* Процедура притопов */
LDR r3, =0
MOV r2, SQUATS_MAX_NUM
doSquat:
ADD r3, 1
MOV r1, 's'
BL uartSendByte
CMP r3, r2
BNE doSquat
Поэтому, можно сразу переписать их как функцию, назвав её doExercise с учётом того, что и прихлопы и притопы являются упражнениями. Но, прежде необходимо прояснить одно важное отличие в механизме вызова функций и возврата из них для МК ARM по сравнению с AVR.
В простейшем случае указанные действия обеспечиваются применением инструкций BL и BX, соответственно:
main:
main_loop:
/* Вызвать функцию doExercise */
BL doExercise
/* Перейти к началу цикла основной функции */
B main_loop
doExercise:
/* Вернуться из функции */
BX LR
.end
При исполнении инструкции вызова BL значение адреса инструкции, подлежащей к исполнению следующей (в данном случае — B main_loop), сохраняется не в стек, как это происходит в AVR-8, а в регистр связей LR. В программный счётчик PC при этом загружается значение адреса первой инструкции вызываемой функции. При возврате из функции посредством инструкции BX значение адреса, хранимое в LR, считывается в PC и процессор переходит к исполнению инструкции, расположенной по указанному адресу (т.е. — той же B main_loop).
Всё это прекрасно работает ровно до тех пор, пока мы не используем вложенные функции.
Добавим в doExercise вызов вложенной функции uartSendByte и посмотрим, что из этого выйдет:
main:
main_loop:
/* Вызвать функцию doExercise */
BL doExercise
/* Перейти к началу цикла основной функции */
B main_loop
doExercise:
/* Вызвать вложенную функцию */
BL uartSendByte
/* Вернуться из функции */
BX LR
.end
При вызове doExercise из цикла основной функции значение адреса следующей инструкции (B main_loop) запишется, как говорилось выше, в LR, а в РС будет помещён адрес первой инструкции doExercise (BL uartSendByte), выполнение которой, в свою очередь, приведёт к повторной записи в LR значения адреса следующей инструкции теперь уже doExercise (BX LR).
Предыдущее значение LR (адреса инструкции B main_loop) будет, как вы понимаете, при этом затёрто. Вернувшись из uartSendByte в doExercise процессор исполнит инструкцию возврата BX, но вернётся при этом не в основную функцию, как это должно быть, а к самой BX, поскольку именно её адрес был записан в LR последним.
Говоря иначе, инструкция BX функции doExercise замкнёт процесс на себя и мы получим ситуацию из Главы 7 первой части статьи, когда в самый ответственный момент ЦПУ заклинило на притопах.
Чтобы избежать подобных проблем, следует на входе в функцию сохранять текущее значение LR в стек посредством инструкции PUSH, а перед возвратом из неё — восстанавливать с помощью инструкции POP:
main:
main_loop:
/* Вызвать функцию doExercise */
BL doExercise
/* Перейти к началу цикла основной функции */
B main_loop
doExercise:
/* Сохранить текущее значение LR в стек */
PUSH {LR}
/* Вызвать вложенную функцию */
BL uartSendByte
/* Восстановить значение LR */
POP {LR}
/* Вернуться из функции */
BX LR
.end
Воспользуемся ещё одной особенностью МК ARM, заключающейся в доступности программного счётчика PC для записи/чтения со стороны программиста. Учитывая, что при возврате из функции значение из LR так и так записывается в PC, заменим две последние инструкции функции:
/* Восстановить значение LR */
POP {LR}
/* Вернуться из функции */
BX LR
на одну, но равнозначную по результату применения:
/* Загрузить в PC значение из LR */
POP {PC}
Тогда, процедуры прихлопов/притопов, оформленные как функция doExercise, и вызывающие её фрагменты цикла основной программы будут выглядеть так:
CLAPS_MAX_NUM = 2
SQUATS_MAX_NUM = 3
main:
main_loop:
/* Сделать прихлопы */
LDR r3, =0
MOV r2, CLAPS_MAX_NUM
MOV r1, 'c'
BL doExercise
/* Сделать притопы */
LDR r3, =0
MOV r2, SQUATS_MAX_NUM
MOV r1, 's'
BL doExercise
/* Перейти к началу цикла основной функции */
B main_loop
doExercise:
/* Сохранить текущее значение LR в стек */
PUSH {LR}
/* Сделать упражнение */
doExercise_loop:
ADD r3, 1
BL uartSendByte
CMP r3, r2
BNE doExercise_loop
/* Вернуться из функции*/
POP {PC}
.end
Обратите внимание на метку doExercise_loop, введённую в состав функции, чтобы инструкция PUSH не попала в цикл, исполняясь несколько раз. Именно имя указанной метки теперь является аргументом инструкции BNE.
По соображениям, высказанным в начале главы, перенесём функцию doExercise в созданный для этого в папке проекта файл exercises.S:
.syntax unified
.text
.global doExercise
doExercise:
PUSH {LR}
/* Сохранить текущие значения r2 и r3 */
PUSH {r2}
PUSH {r3}
/* Сделать упражнение */
doExercise_loop:
ADD r3, 1
BL uartSendByte
CMP r3, r2
BNE doExercise_loop
/* Восстановить в обратном порядке значения r2 и r3 */
POP {r3}
POP {r2}
/* Вернуться из функции*/
POP {PC}
.end
Как видите:
а) В самом верху файла прописана директива .syntax unified, чтобы избавиться от необходимости предварять в коде каждое числовое значение символом «#».
б) Функция doExercise объявлена глобальной, поскольку вызываться будет теперь уже из другого файла.
в) Текущие значения используемых функцией РОН r2 и r3 сохраняются при входе в неё в стек и восстанавливаются в обратном порядке на выходе. И хотя в данном случае такая процедура совсем не обязательна в связи с тем, что для других участков программы значения r2 и r3 не актуальны, оставим её в качестве примера работы инструкций PUSH/POP.
Кстати, для ARM в применении упомянутых инструкций также есть свои нюансы.
Если вы сохраняете/считываете значения РОН по одному, как это сделано в коде выше, то необходимо придерживаться оговорённого ранее метода LIFO: последовательность чтения из стека — обратная последовательности записи в него. Однако, при использовании варианта инструкций со списком РОН процессор самостоятельно определяет, какое число в стеке относится к тому или иному РОН, т.е. снимает с вас ответственность за соблюдение последовательности записи/чтения. Учитывая изложенное, перепишем соответствующим образом содержимое exercises.S, добавив заодно к списку РОН ещё и LR с PC:
.syntax unified
.text
.global doExercise
doExercise:
/* Сохранить текущие значения r2, r3 и LR */
PUSH {r2—r3, LR}
/* Сделать упражнение */
doExercise_loop:
ADD r3, 1
BL uartSendByte
CMP r3, r2
BNE doExercise_loop
/* Восстановить значения r2, r3 и вернуться из функции */
POP {r2—r3, PC}
.end
С целью повысить компактность кода в main.S, отправим в exercises.S ещё и макроопределения максимального числа прихлопов/притопов, а также добавим функцию doBothExercises, в которую перенесём:
а) инструкции загрузки нуля в r3, максимального количества прихлопов/притопов в r2 и символов «с»/«s» в r1.
б) инструкцию вызова функции doExercise,
в) процедуры задержки и вывода в Terminal символа пробела.
г) инструкции сохранения и восстановления текущих значений r1—r3.
.syntax unified
CLAPS_MAX_NUM = 2
SQUATS_MAX_NUM = 3
.text
.global doBothExercises
doBothExercises:
/* Сохранить в стек текущие значения r1-r3 и LR */
PUSH {r1—r3, LR}
/* Сделать прихлопы */
LDR r3, =0
MOV r2, CLAPS_MAX_NUM
MOV r1, 'c'
BL doExercise
/* Сделать притопы */
LDR r3, =0
MOV r2, SQUATS_MAX_NUM
MOV r1, 's'
BL doExercise
/* Вывести в Terminal символ пробела */
MOV r1, 32
BL uartSendByte
/* Обеспечить задержку в 1 секунду */
BL delay
/* Восстановить значения r1-r3 и вернуться из функции*/
POP {r1—r3, PC}
doExercise:
PUSH {LR}
/* Сделать упражнение */
doExercise_loop:
ADD r3, 1
BL uartSendByte
CMP r3, r2
BNE doExercise_loop
/* Вернуться из функции*/
POP {PC}
.end
Заметьте, что функция doExercise вновь оформлена как локальная, т.к. вызывается из того же файла, в котором находится сама.
С учётом предпринятых мер, текст main.S сократится до:
.include "stm32f401.h" /* или "nrf52832.h" */
.include "macro.h"
LAMP_BRIGHTNESS_CURRENT_VALUE = 12
LAMP_BRIGHTNESS_THRESHOLD = 15
.text
.org 0
stackPointerInit
.org Reset_vector
.word main + 1
.global main
main:
BL uartInit
main_loop:
MOV r4, LAMP_BRIGHTNESS_CURRENT_VALUE
CMP r4, LAMP_BRIGHTNESS_THRESHOLD
BLT type_B
type_A:
MOV r1, 'A'
BL uartSendByte
BL doBothExercises
B main_loop
type_B:
MOV r1, 'B'
BL uartSendByte
BL doBothExercises
B main_loop
.end
Уверен, вы сможете самостоятельно посчитать по аналогии с AVR-8 количество сэкономленных в результате оптимизации инструкций, а следовательно и байтов памяти программ.
Чтобы прояснить картину взаимной связи работы функций, спецрегистров и стека, сократим программу, как и в случае с AVR-8, до необходимого и достаточного минимума:
main.S
.include "stm32f401.h" /* или "nrf52832.h" */
.include "macro.h"
.text
.org 0
stackPointerInit
.org Reset_vector
.word main + 1
.global main
main:
main_loop:
BL doBothExercises
B main_loop
.end
exercises.S
.syntax unified
CLAPS_MAX_NUM = 2
.text
.global doBothExercises
doBothExercises:
/* Сохранить в стек текущие значения r1-r3 и LR */
PUSH {r1—r3, LR}
/* Сделать прихлопы */
LDR r3, =0
MOV r2, CLAPS_MAX_NUM
MOV r1, 'c'
BL doExercise
/* Восстановить значения r1-r3 и вернуться из функции*/
POP {r1—r3, PC}
doExercise:
PUSH {LR}
/* Сделать упражнение */
doExercise_loop:
ADD r3, 1
CMP r3, r2
BNE doExercise_loop
/* Вернуться из функции*/
POP {PC}
.end
а затем подробно рассмотрим результат компиляции и загрузки полученного кода в nRF52832, а также работы одно цикла основной функции, приведённые в Видео 2.
Видео 2. Демонстрация работы программы для SoC nRF52832.
Правая часть кадра Видео 2 является представлением нашей программы в ПП nRF52832. Как видите, код из файлов main.S и exercises.S собран при компиляции в единый список инструкций, исполнение текущей из которых помечается маркером.
В левой части картинки расположены два блока:
а) Оперативная память SRAM.
б) Так называемый файл регистров, который объединяет РОН и спецрегистры (указатель стека SP, регистр связей LR, программный счётчик PC и регистр статуса PSR). Заметьте, что к трём первым из указанных спецрегистров можно обращаться ещё и как к обычным РОН (r13, r14 и r15, соответственно).
Выясним, как меняется состояние того или иного регистра слева при исполнении очередной инструкции программы:
1. При сбросе/подаче питания МК программный счётчик РС обнуляется и ЦПУ, соответственно, начинает работу с адреса 0х00000000 ПП, загружая в SP значение 0х2000FFFF старшего адреса SRAM. Программный счётчик РС при этом увеличивается на 4.
2. Далее (адрес 0х0000004 ПП) осуществляется переход к первой и единственной в нашем случае инструкции основной функции BL с увеличением на 4 значения РС.
3. При исполнении инструкции BL (адрес 0х0000008 ПП) происходит следующее:
а) Значение адреса 0х0000000С следующей инструкции B записывается в LR.
б) Аргумент 0x00000010 инструкции BL загружается в PC и именно по этому адресу переходит процессор.
4. Согласно указаниям инструкции PUSH текущие значения r1–r3 и LR сохраняются в стек. При этом, значение SP декрементируется до 0x2000FFEF, указывая на текущий свободный регистр стека, а число в PC вновь увеличивается на 4.
5. Исполнение следующих трёх инструкций (LDR и два MOV) приводит к загрузке нуля в r3, максимального числа прихлопов (0х02) в r2, ASCII кода (0х63) символа «с» в r1 и увеличению на 12 единиц значения PC.
6. По аналогии с п.3, при исполнении инструкции BL по адресу 0х00000020 ПП:
а) Значение 0х00000024 адреса следующей инструкции POP записывается в LR.
б) Аргумент 0x00000028 инструкции BL, т.е. адрес первой инструкции функции doExercise загружается в PC.
7. Исполняя инструкции doExercise, процессор сохраняет в стек текущее значение LR, уменьшая число в SP до 0x2000FFEB, а затем крутится в цикле doExercise_loop, увеличивая значение r3 и сравнивая полученное число со значением r2. При их равенстве произойдёт следующее:
а) На инструкции CMP установится в 1 тридцатый бит Z спецрегистра PSR, в связи с чем значение последнего изменится с нуля на 0x40000000.
б) При исполнении инструкции BNE в РС будет загружен, в отличие от первого круга doExercise_loop, не аргумент 0х0000002С инструкции BNE, а число 0х00000038, т.е. адрес следующей инструкции POP. Бит Z регистра PSR при этом обнулится.
8. Инструкция POP по адресу 0х00000038 ПП обуславливает копирование из стека в PC числа 0х00000024, обозначая переход по этому адресу. При этом значение SP уже инкрементируется до 0x2000FFEF.
9. По выполнению очередной инструкции POP (адрес 0х00000024) SP вновь укажет на старший адрес SRAM. При этом, будут считаны из стека:
а) в r1–r3 – первоначальные их значения,
б) в РС – значение 0х0000000С адреса инструкции, подлежащей к исполнению следующей.
10. Исполняя инструкцию В по адресу 0х0000000С, процессор перейдёт к находящейся четырьмя байтами выше инструкции BL, замыкая тем самым один круг main_loop.
Обратите внимание, что РОН, спецрегитры и регистры SRAM сохраняют, как и говорилось в предыдущих частях статьи, свои текущие значения до перезаписи или отключения питания МК.
Для STM32F401 отличие заключается лишь в значении младшего адреса ПП: программа будет размещена, начиная с адреса 0х08000000.
↑ Компиляция сложносоставного проекта
В случае компиляции проекта, состоящего из нескольких файлов, каждый отдельный ассемблер-файл преобразуется посредством утилиты as в объектный файл с расширением .o, после чего последние собираются утилитой ld в единый elf-файл с произвольным именем (обычно, это — имя проекта). На этом же этапе подключаются, при необходимости, сторонние объектные файлы, такие как uart.o или delayAVR.o (delayCortex.o), к примеру. Далее, как и в случае с проектом, состоящего из одного файла, elf-файл при помощи утилиты objcopy трансформируется в файл прошивки с расширением .hex, который и загружается в МК.С учётом изложенного, содержимое Makefile для нашего проекта cpuJobDuties будет выглядеть так:
AVR-8
all:
avr-as main.S -mmcu=mcu_name -o main.o
avr-as exercises.S -mmcu=mcu_name -o exercises.o
avr-ld main.o exercises.o uart.o delayAVR.o -T LinkerScript.ld -o cpuJobDuties.elf
avr-objcopy cpuJobDuties.elf -j .text -j .data -O ihex cpuJobDuties.hex
avrdude -p mcu_name -c usbasp -P usb -e -U flash:w:cpuJobDuties.hex:i
Cortex M-4
all:
arm-none-eabi-as main.S -mcpu=cortex-m4 -o main.o
arm-none-eabi-as exercises.S -mcpu=cortex-m4 -o exercises.o
arm-none-eabi-ld main.o exercises.o uart.o delayCortex.o -T LinkerScript.ld -o cpuJobDuties.elf
arm-none-eabi-objcopy cpuJobDuties.elf -S -O ihex cpuJobDuties.hex
openocd -f interface/stlink.cfg -f target/mcu_name.cfg -c "init" -c "reset init" -c "flash write_image erase cpuJobDuties.hex" -c "reset" -c "exit"
Напомню, что все надписи «mcu_name» в примерах выше должны быть заменены на «attiny85», «atmega8», «stm32f4x» или «nrf52», в зависимости от выбранного вами МК.
После компиляции папка проекта дополнится двумя объектными файлами (main.o и exercises.o), а также elf- и hex-файлы с именем cpuJobDuties.
Представим, что мы решили использовать функцию doExercise в другом проекте. Для этого достаточно:
1. Скопировать файл exercises.o папку нового проекта.
2. В требуемом месте кода нового проекта вызвать функцию (RCALL doExercise – для AVR, BL doExercise – для ARM).
3. Подключить exercises.o к сборке в соответствующей строке Makefile так, как мы сделали в примере выше это для uart.o и delayAVR.o (delayCortex.o).
В случае с часто используемыми функциями такой подход позволяет сэкономить кучу времени, не переписывая каждый раз заново их содержимое. Именно это и подразумевалось в самом начале Главы 2 под словами о гибкости кода, разнесённого по отдельным файлам.
↑ Оформление и отладка проекта по частям
Осталось осветить вопрос о пользе структурирования программы в плане отладки.Теперь уже зная все основные нюансы оформления и работы макросов и функций перепишем проект с самого начала так, как будто мы только что создали папку cpuJobDuties и скопировали в неё пять шаблонных файлов, а также файлы uart.o и delayAVR.o (или delayCortex.o).
Зная наперёд, что будем разносить программу по отдельным файлам, создадим в папке проекта файл exercises.S. Кроме того, сразу внесём в Makefile дополнения, оговорённые в предыдущей главе.
Поскольку каждый фрагмент кода в примерах выше подробно комментировался, в этот раз я опущу все комментарии, чтобы не занимать попусту экранное пространство.
Для начала пропишем и проверим работу ветвления, в зависимости от результата сравнения текущего значения яркости лампы с пороговым:
AVR-8
.include "attiny85.h" /* или "atmega8.h" */
.include "macro.h"
LAMP_BRIGHTNESS_CURRENT_VALUE = 12
LAMP_BRIGHTNESS_THRESHOLD = 15
.text
.org Reset_vector
RJMP main
.global main
main:
stackPointerInit
RCALL uartInit
main_loop:
LDI r20, LAMP_BRIGHTNESS_CURRENT_VALUE
CPI r20, LAMP_BRIGHTNESS_THRESHOLD
BRLO type_B
type_A:
LDI r16, 'A'
RCALL uartSendByte
RCALL delay
RJMP main_loop
type_B:
LDI r16, 'B'
RCALL uartSendByte
RCALL delay
RJMP main_loop
.end
Cortex M-4
.include "stm32f401.h" /* или "nrf52832.h" */
.include "macro.h"
LAMP_BRIGHTNESS_CURRENT_VALUE = 12
LAMP_BRIGHTNESS_THRESHOLD = 15
.text
.org 0
stackPointerInit
.org Reset_vector
.word main + 1
.global main
main:
BL uartInit
main_loop:
MOV r4, LAMP_BRIGHTNESS_CURRENT_VALUE
CMP r4, LAMP_BRIGHTNESS_THRESHOLD
BLT type_B
type_A:
MOV r1, 'A'
BL uartSendByte
BL delay
B main_loop
type_B:
MOV r1, 'B'
BL uartSendByte
BL delay
B main_loop
.end
Если после компиляции и загрузки программы в МК вы получите периодический вывод в Terminal символа «А» или «В» в зависимости от того, больше или меньше пятнадцати значение LAMP_BRIGHTNESS_CURRENT_VALUE, значит с логикой программы всё в порядке.
Далее оформляем в exercises.S функцию doExercise, объявив её пока глобальной:
AVR-8
.text
.global doExercise
doExercise:
INC r17
RCALL uartSendByte
CP r17, r18
BRNE doExercise
RET
.end
Cortex M-4
.text
.global doExercise
doExercise:
INC r17
RCALL uartSendByte
CP r17, r18
BRNE doExercise
RET
.end
а в main.S проверяем работу только вновь созданной функции:
AVR-8
.include "attiny85.h" /* или "atmega8.h" */
.include "macro.h"
.text
.org Reset_vector
RJMP main
.global main
main:
stackPointerInit
RCALL uartInit
main_loop:
CLR r17
LDI r18, 2
LDI r16, 'c'
RCALL doExercise
LDI r16, 32
RCALL uartSendByte
RCALL delay
RJMP main_loop
.end
Cortex M-4
.include "stm32f401.h" /* или "nrf52832.h" */
.include "macro.h"
.text
.org 0
stackPointerInit
.org Reset_vector
.word main + 1
.global main
main:
BL uartInit
main_loop:
LDR r3, =0
MOV r2, 2
MOV r1, 'с'
BL doExercise
MOV r1, 32
BL uartSendByte
BL delay
B main_loop
.end
Меняя количество (значение r18 для AVR и r2 для Cortex M-4) и тип упражнения (значение r16 для AVR и r1 для Cortex M-4) убеждаемся, что в Terminal печатаются через пробел соответствующие символы в требуемом количестве, а следовательно функция doExercise работает так, как ей и положено. После этого, можно в exercises.S придать функции статус локальной и добавить макроопределения максимального количества прихлопов/притопов.
Следующим шагом добавляем в exercises.S глобальную функцию doBothExercises:
AVR-8
CLAPS_MAX_NUM = 2
SQUATS_MAX_NUM = 3
.text
.global doBothExercices
doBothExercices:
CLR r17
LDI r18, CLAPS_MAX_NUM
LDI r16, 'c'
RCALL doExercise
CLR r17
LDI r18, SQUATS_MAX_NUM
LDI r16, 's'
RCALL doExercise
LDI r16, 32
RCALL uartSendByte
RCALL delay
RET
doExercise:
INC r17
RCALL uartSendByte
CP r17, r18
BRNE doExercise
RET
.end
Cortex M-4
.syntax unified
CLAPS_MAX_NUM = 2
SQUATS_MAX_NUM = 3
.text
.global doBothExercises
doBothExercises:
PUSH {r1-r3, LR}
LDR r3, =0
MOV r2, CLAPS_MAX_NUM
MOV r1, 'c'
BL doExercise
LDR r3, =0
MOV r2, SQUATS_MAX_NUM
MOV r1, 's'
BL doExercise
MOV r1, 32
BL uartSendByte
BL delay
POP {r1-r3, PC}
doExercise:
PUSH {LR}
doExercise_loop:
ADD r3, 1
BL uartSendByte
CMP r3, r2
BNE doExercise_loop
POP {PC}
.end
и проверяем корректность её работы, вызывая в main.S:
AVR-8
.include "attiny85.h" /* или "atmega8.h" */
.include "macro.h"
.text
.org Reset_vector
RJMP main
.global main
main:
stackPointerInit
RCALL uartInit
main_loop:
RCALL doBothExercices
RJMP main_loop
.end
Cortex M-4
.include "stm32f401.h" /* или "nrf52832.h" */
.include "macro.h"
.text
.org 0
stackPointerInit
.org Reset_vector
.word main + 1
.global main
main:
BL uartInit
main_loop:
BL doBothExercises
B main_loop
.end
Если в Terminal с периодичностью в секунду появляются через пробел два символа «с» и три «s», значит работа doBothExercises (включая вызов вложенной doExercise) соответствует нашим ожиданиям, а следовательно проверка отдельных фрагментов программы завершена и мы можем собрать её воедино.
Далёк от мысли утверждать, что такой подход к оформлению и отладке программы единственно правильный. В конце концов, каждый из вас выберет через пробы и ошибки свой вариант. Тем не менее, поделиться своим опытом я счёл нужным.
Спасибо за внимание! ?
Камрад, рассмотри датагорские рекомендации
🌼 Полезные и проверенные железяки, можно брать
Опробовано в лаборатории редакции или читателями.