Добрый день, уважаемые камрады-датагорцы! Сегодня, рассмотрев некоторые общие моменты, мы займёмся программированием.
Содержание статьи / Table Of Contents
↑ Общие сведения
Прежде, чем перейти к особенностям кода для того или иного МК, обсудим круг вопросов, относящихся к каждому из них.↑ Что читать?
В рамках одной статьи затруднительно осветить все детали, особенно учитывая, что повествование будет осуществляться в двух направлениях — язык программирования и функционал микроконтроллера. Поэтому, вам потребуется дополнительная литература.В качестве учебника по Си рекомендую книгу Брайана Кернигана и Денниса Ритчи «Язык программирования Си», изложенную в простой и доходчивой форме.
Касательно МК достаточно, как мне кажется, вдумчивого изучения технической документации — даташитов, спецификаций и мануалов. Те из вас, кто хочет глубже вникнуть в детали работы ядра nRF52832 и STM32F401, могут обратиться к Cortex-M4 User Guide и Reference Manual.
Все вышеупомянутые источники выложены в архив статьи.
Кроме того, крайне желательно ознакомиться хотя бы с описательной частью нашей серии статей «Ассемблер...».
↑ Термины и понятия
Чтобы не загружать читателя, основная часть понятий и терминов будет вводится по мере возникновения необходимости в их применении. Однако, некоторые понадобятся нам на первых же шагах, в связи с чем определимся с ними здесь.• Компиляция, в общем смысле — перевод кода с-файлов в ассемблерный с последующими компоновкой в единый отладочный elf- и преобразованием его в загрузочный hex-файл.
• Препроцессинг — предваряющий компиляцию этап, в ходе которого препроцессор осуществляет подготовительную работу, в т.ч. исполняет адресованные ему директивы, производит замену в коде макроопределений на значения определяемых ими констант и пр.
• Директива — указание препроцессору, помеченное символом «#».
• Заголовочный (или хэдер-) файл — файл с расширением h, содержащий вспомогательную информацию. Подключается к компиляции посредством директивы #include, размещаемой в самом верху с-файла, который использует данный хэдер-файл.
#include <имя хэдер-файла>
или
#include "имя хэдер-файла"
Тип обрамления имени заголовочного файла определяет маршрут его поиска: угловые скобки направляют препроцессор в папки тулчейна, а кавычки — в папку проекта и далее, если файл там не найден, в те же папки тулчейна.
• Библиотека, в простейшем случае — совокупность двух файлов (с- и h-) с единым именем, содержащих и описывающих набор функций. Так же, как и в предыдущем случае, подключается к компиляции через директиву #include с указанием имени заголовочного файла.
↑ Типы данных
Любая программа оперирует различными числовыми данными: целочисленными или вещественными, знаковыми или беззнаковыми, т.е. принимающими и положительные и отрицательные или только положительные значения, соответственно. Текущее значение некоторых из них требует временного хранения, для чего выделяется место в оперативной памяти. При этом, длина регистров ОЗУ может быть разной, например, для ATmega8 она составляет 8, а для STM32F401 и nRF52832 — 32 бита. Если использовать в программе формулировки вроде «выделить 2 регистра памяти», в случае с AVR-8 это будет означать 16, а с Cortex-M4 — 64 бита. Очевидно, о переносимости такого кода не может быть и речи, поэтому отталкиваются от длины не регистров, а самих данных, в связи с чем введено понятие «тип данных».Стандартом установлены следующие базовые типы целочисленных и вещественных данных:
• void, означающий отсутствие значения данных,
• char — для целочисленных 8-битных данных,
• int — для целочисленных данных, длина которых составляет 16 или 32 бита, в зависимости от конкретной среды программирования,
• float — для чисел с плавающей точкой одинарной точности,
• double — для чисел с плавающей точкой двойной точности.
Затем, посредством квалификаторов знака (signed и unsigned) и длины (short и long), осуществляется более строгая типизация. Подробная информация по указанным типам данных приведена в Таблице 1.
Обращу ваше внимание на несколько моментов:
а) Тип char, в зависимости от реализации языка и настроек компилятора, может быть знаковым или беззнаковым. В нашем случае он — беззнаковый.
б) Тип int — всегда знаковый.
в) В той или иной реализации языка минимальные значения знаковых целочисленных данных могут отличаться на единицу, например, -127, вместо -128 для signed char или -32767 вместо -32768 для short. Конкретные значения определяются в файле limits.h тулчейна. В нашем случае действительны значения, приведённые в Таблице 1.
г) Граничные значения для вещественных чисел определены в файле float.h тулчейна.
Возможно, кому-то из вас больше придётся по душе производная от базовых линейка типов целочисленных данных вида intN_t/uintN_t, определённых в заголовочном файле stdint.h тулчейна, который необходимо подключить к компиляции при использования линейки. Соответствие указанных типов вышеприведённым представлено в Таблице 2.
Таблица 2. Соответствие типов данных
Следует упомянуть ещё одни тип данных, предназначенный для отражения логических значений — _Bool. С целью совместимости с С++, для типа _Bool в файле stdbol.h тулчейна определена производная bool, а также даны макроопределения её значений: false (для 0) и true (для 1).
Теперь, о представлении данных в программе.
Константы, т.е. данные не меняющие своего значения, можно прописывать непосредственно в коде, при этом для удобочитаемости и гибкости последнего в некоторых случаях желательно давать им макроопределения, о чём — чуть ниже. По умолчанию размерность констант — int, но при необходимости можно задать длину явно с помощью соответствующего суффикса, следующего сразу после значения константы: 65535UI (Unsigned Int), 16000000UL (Unsigned Long) и т.п.
Синтаксис объявления данных, значение которых может меняться (или переменных), следующий:
тип переменной имя переменной;
При этом, существует несколько гласных и негласных правил, которых мы и будем придерживаться в последующем:
1. Выбор типа переменной определяется диапазоном значений, который она может принимать по ходу исполнения программы.
2. Имя переменной является аналогом метки в ассемблере и служит, с одной стороны, для идентификации компилятором адреса переменной в ОЗУ. С другой стороны, по имени программист различает те или иные данные, поэтому желательно, чтобы оно несло полезную смысловую нагрузку. Простое имя оформляется прописными буквами, а в для составного применяется так называемый «Camel style», когда первая составляющая пишется полностью прописными, а все последующие начинаются с заглавной. Для переменных, хранящих одинаковые по назначению данные, можно использовать нумерацию через символ подчёркивания.
3. Во избежание попадания «мусора» при подаче питания/сбросе желательно инициализировать переменную при объявлении, т.е. присвоить ей посредством оператора «=» (или записать в неё) начальное значение. Чаще всего это — 0.
Для примера, объявление двух переменных для хранения текущего 16-битного значения времени двух разных процессов, может выглядеть так:
unsigned short timeValue_1 = 0;
unsigned short timeValue_2 = 0;
Обратите внимание на точку с запятой, которые в Си завершают каждую операцию, в т.ч. присваивания. Далее по коду применяют уже только имя переменной без указания её типа.
Дополнительно к уже сказанному советую поискать информацию о приведении типов данных.
↑ Арифметические и логические операции. Битовые маски
Поскольку тема логических операций и битовых масок подробно рассмотрена в первой части «Ассемблер...», а основы математики известны всем нам со школы, остановлюсь лишь на главных моментах.В Си приняты следующие операторы арифметических и логических операций:
Таблица 3. Операторы арифметических и логических операций
В общем случае синтаксис операции над содержимым регистра выглядит следующим образом:
Рисунок 1. Синтаксис операции
При этом, понятие «Регистр» относится и к регистрам ОЗУ, т.е. вся нижеследующая информация по операциям справедлива и для переменных. Понимать запись на Рисунке 1 следует так: «Считать текущее значение регистра (этап 1), произвести операцию между этим значением и заданным числом (этап 2), записать получившийся результат обратно в регистр (этап 3)».
С целью экономии времени, впредь мы будем применять свёрнутую форму синтаксиса:
Рисунок 2. Свёрнутая форма синтаксиса операции
В этом случае, как видите, оператор и символ присваивания пишутся слитно.
Ниже представлены примеры операций с упомянутыми выше переменными:
/* Арифметические операции */
timeValue_1 -= 2;
timeValue_2 += 5;
timeValue_1 *= 8;
timeValue_2 /= 4;
/* Логические операции */
timeValue_1 >>= 8;
timeValue_2 <<= 4;
Инкремент (увеличение 1) и декремент (уменьшение на 1) значения переменной можно осуществлять в ещё более короткой форме:
/* Инкремент */
timeValue_1++;
/* Декремент */
timeValue_1--;
При инициализации модуля МК (порт ввода/вывода, таймер, АЦП и пр.) в регистры его настроек записывается то или иное число, двоичное представление которого — нули и единицы в требуемых битах регистра. В ходе работы может понадобиться изменить значение определённого бита, не меняя состояния других. Кроме того, иногда переменную используют как хранилище флагов, когда значение каждого из её битов отражает состояние отдельного процесса и должно, при необходимости, изменяться программистом соответствующим образом. Во всех указанных случаях удобнее использовать комбинацию логических операций и битовых масок.
Напомню, что маска числа с единицей в k-м бите и нулями в остальных выглядит так:
(1 << k)
Особо отмечу следующий нюанс. Единица в маске сама по себе является константой, а дефолтная размерность её, как говорилось выше — int, т.е. 16 или 32 бита. В случаях реализации языка или компилятора, когда длина int по умолчанию составляет 16 бит, попытка оформить маску с единицей в 16-м и выше битах приведёт к формированию некорректной маски, для исключения чего к единице добавляют соответствующий суффикс (UL или ULL).
Чтобы получить число с нулём в k-м бите и единицами в остальных, достаточно инвертировать предыдущую маску:
~(1 << k)
Для случая с единицами в нескольких битах необходимо произвести операцию ИЛИ между соответствующими масками:
/* Число с единицами в битах k, m, n и нулями в остальных */
(1 << k) | (1 << m) | (1 << n)
/* Инверсия предыдущего числа */
~((1 << k) | (1 << m) | (1 << n))
Далее между текущим значением регистра и маской применяется логическая операция ИЛИ, И либо ИСКЛЮЧАЮЩЕЕ ИЛИ в случаях когда значение требуемого бита надо установить в 1, сбросить в 0 либо поменять на противоположное, соответственно:
/* Установить в единицу 4-й бит регистра */
Регистр |= (1 << 4);
/* Сбросить в ноль 2-й и 6-й биты регистра */
Регистр &= ~((1 << 2) | (1 << 6));
/* Поменять на противоположное значение 5-го бита регистра */
Регистр ^= (1 << 5);
/* Объявить переменную flags и */
unsigned int flags = 0;
/* установить в единицу 7-й, 3-й и 0-й биты */
flags |= (1 << 7) | (1 << 3) | (1 << 0);
/* сбросить в ноль 3-й бит */
flags &= ~(1 << 3);
/* поменять на противоположное значение 7-го бита */
flags ^= (1 << 7);
Позже, мы будем активно использовать ещё одну возможность, которую даёт комбинация логической операции И с маской. Чтобы определить состояние k-го бита регистра, достаточно произвести указанную операцию между текущим значением регистра и маской с единицей в искомом бите.
РЕГИСТР & (1 << k)
Результат будет ненулевым только, если k-й бит находится в состоянии логической единицы.
Важно иметь представление о приоритете операторов в сложных конструкциях, о чём вы можете прочитать в рекомендованной выше литературе или сети.
↑ Макроопределения, макросы, функции
Из примеров с переменной flags выше ясно только то, что мы меняем состояние битов 7, 3 и 0, однако можно повысить их информативность, а заодно и читабельность, если посредством директивы #define препроцессора дать номерам битов условные названия или макроопределения.Предположим, что значения 1/0 указанных битов отражают состояние ВКЛЮЧЕНО/ВЫКЛЮЧЕНО:
• бит 7 — мотора,
• бит 3 — светодиода,
• бит 0 — реле.
Тогда, дав соответствующие макроопределения,
#define MOTOR_STATE_FLAG 7
#define LED_STATE_FLAG 3
#define RELAY_STATE_FLAG 0
можно переписать код следующим образом:
unsigned int flags = 0;
flags |= (1 << MOTOR_STATE_FLAG) | (1 << LED_STATE_FLAG) | (1 << RELAY_STATE_FLAG);
flags &= ~(1 << LED_STATE_FLAG);
flags ^= (1 << MOTOR_STATE_FLAG);
Помимо повышения читабельности, применение макроопределений даёт ряд других преимуществ, в т.ч.:
а) Когда одна и та же константа многократно используется в программе, для изменения её значения во всех местах присутствия понадобится изменить лишь значение соответствующего макроопределения.
б) В случае, если имеются две константы одинакового значения, но отражающие разные сущности (например, 3 секунды и 3 измерения АЦП), снижаются риски изменения значения не той константы при возникновении необходимости в такой замене.
Макрос — практически то же, что и макроопределение, только по отношению к фрагменту кода. Оформим макросы изменения значений битов (всех вместе взятых и каждого по отдельности):
#define SET_ALL_FLAGS flags |= (1 << MOTOR_STATE_FLAG) | (1 << LED_STATE_FLAG) | (1 << RELAY_STATE_FLAG)
#define CLEAR_ALL_FLAGS flags &= ~((1 << MOTOR_STATE_FLAG) | (1 << LED_STATE_FLAG) | (1 << RELAY_STATE_FLAG))
#define TOGGLE_ALL_FLAGS flags ^= (1 << MOTOR_STATE_FLAG) | (1 << LED_STATE_FLAG) | (1 << RELAY_STATE_FLAG)
#define SET_MOTOR_STATE_FLAG flags |= (1 << MOTOR_STATE_FLAG)
#define CLEAR_MOTOR_STATE_FLAG flags &= ~(1 << MOTOR_STATE_FLAG)
#define TOGGLE_MOTOR_STATE_FLAG flags ^= (1 << MOTOR_STATE_FLAG)
#define SET_LED_STATE_FLAG flags |= (1 << LED_STATE_FLAG)
#define CLEAR_LED_STATE_FLAG flags &= ~(1 << LED_STATE_FLAG)
#define TOGGLE_LED_STATE_FLAG flags ^= (1 << LED_STATE_FLAG)
#define SET_RELAY_STATE_FLAG flags |= (1 << RELAY_STATE_FLAG)
#define CLEAR_RELAY_STATE_FLAG flags &= ~(1 << RELAY_STATE_FLAG)
#define TOGGLE_RELAY_STATE_FLAG flags ^= (1 << LED_STATE_FLAG)
и перепишем наш код:
unsigned int flags = 0;
SET_ALL_FLAGS;
CLEAR_LED_STATE_FLAG;
TOGGLE_RELAY_STATE_FLAG;
Макросы, в отличие от макроопределений, могут принимать аргументы, что делает их использование более гибким. Пропишем макросы, принимающие в качестве аргументов имя регистра и номер бита, а затем меняющие значение последнего соответствующим образом:
#define SET_BIT(arg1, arg2) arg1 |= (1 << arg2)
#define CLEAR_BIT(arg1, arg2) arg1 &= ~(1 << arg2)
#define TOGGLE_BIT(arg1, arg2) arg1 ^= (1 << arg2)
Тогда, код примера примет следующий вид:
unsigned int flags = 0;
SET_ALL_FLAGS;
CLEAR_BIT(flags, LED_STATE_FLAG);
TOGGLE_BIT(flags, RELAY_STATE_FLAG);
Чтобы не перегружать с-файл, макроопределения и макросы обычно помещают в связанный хэдер-файл. В отношении их оформления также существуют неписанные правила:
1. Имена макроопределений макросов пишутся заглавными буквами.
2. Элементы составного имени отделяются знаком подчеркивания.
3. В именах сходных по назначению макроопределений можно использовать нумерацию через символ подчёркивания.
Весь код любой программы можно, при желании, разместить в main.c. Но, отлаживать такую программу — удовольствие не из самых приятных, поэтому функционально обособленные фрагменты кода лучше оформлять в виде (извините за тавтологию) функций и выносить их в отдельную библиотеку. Такой подход предпочтителен ещё и потому, что грамотно составленную библиотеку можно без изменений переносить из проекта в проект, экономя при этом немало времени.
Прописывается функция в с-файле и синтаксис её, в общем случае, следующий:
тип возвращаемого аргумента имя функции(тип и имя принимаемого аргумента)
{
return возвращаемый аргумент;
}
Поясню некоторые моменты:
а) Перед именем функции прописывается тип возвращаемого ею аргумента, если таковой предполагается. В противном случае указывается тип void.
б) К имени функции мы будем применять те же негласные правила, что и в случае с переменной (Camel style и пр.).
в) В круглых скобках после имени функции указывается тип и имя принимаемого аргумента. В GCC, если функция не принимает какого-либо аргумента, указывать тип void не обязательно, поэтому далее круглые скобки после функции в подобных случаях будем оставлять пустыми, в т.ч. и в отношении основной функции main().
г) Содержимое (или «тело») функции размещается внутри фигурных скобок.
д) Если функция предполагает возврат аргумента, её тело обязательно должна завершать инструкция return с аргументом соответствующего типа.
Помимо вышесказанного, в одноимённом хэдер-файле необходимо объявить прототип функции, который фактически дублирует её заголовок:
тип возвращаемого аргумента имя функции(тип и имя принимаемого аргумента);
Вызов функции осуществляется через её имя:
/* Вызов функции не имеющей аргументов */
function_1();
/* Вызов функции с возвращаемым аргументом */
int a = function_2();
/* Вызов функции с принимаемым аргументом */
int b = 5;
function_3(b);
Следует иметь в виду, что в Си реализована передача в функцию по значению. Это означает, что в функцию function_3() выше передаётся значение переменной b (т.е. число 5), а не сама она и работа функции никак не затронет состояние переменной.
↑ Структура проекта и назначение его файлов
В прошлый раз нами был создан проект first, в рамках которого и будет происходить дальнейшее наше обучение. Выясним на его примере структуру любого другого проекта, который вы будете создавать в будущем.Рисунок 3. Структура проекта для МК с ядром а) AVR-8 и б) Cortex-M4
Папка .vscode создаётся автоматически, как только вы вносите какое-либо изменение в текущие настройки редактора через File/Preferences или функциональную клавишу F1. На данный момент она содержит единственный файл — c_cpp_properties.json, куда, среди прочего, внесены пути ко включаемым при компиляции папкам. В дальнейшем .vscode может пополнятся и другими файлами, если вы надумаете, например, поменять цвет фона или размер шрифта.
В папке inc мы будем хранить заголовочные h-файлы, а в src — с-файлы с исходным кодом.
↑ LinkerScript.ld
Как говорилось выше, для гибкости и читабельности программа разносится по нескольким с-файлам. В ходе компиляции их содержимое переводится в ассемблерный код и собирается (компонуется) в единый elf-файл. Условия компоновки, в частности, начальный адрес и и размер флэш- и оперативной памяти МК, определяются в специальном файле, именуемом скриптом. В недрах установленных нами тулчейнов имеются дефолтные версии скриптов.В случае с AVR автор ядра и периферии МК — одно лицо, поэтому параметры памяти для каждого конкретного микроконтроллера прописаны в скриптах тулчейна, что даёт возможность подключать их при компиляции, не вводя в состав проекта явно.
Разработчик ARM лишь ограничивает область памяти, а каждый производитель МК на базе этого ядра сам определяет её конкретные размеры и начальный адрес в пределах заданной области, по причине чего, видимо, в скриптах тулчейна указанные данные отсутствуют. Учитывая изложенное, к шаблонным файлам для Cortex-M4 добавлен LinkerScript.ld, чтобы пользователь мог контролировать параметры памяти для МК, программируемого в данный момент. Фрагмент скрипта, который подлежит корректировке при смене МК, выглядит, в общем случае, так:
MEMORY
{
RAM (): ORIGIN = начальный адрес ОЗУ, LENGTH = размер ОЗУ
FLASH (): ORIGIN = начальный адрес флэш-памяти, LENGTH = размер флэш-памяти
}
Начальный адрес (ORIGIN) обычно неизменен для МК данного производителя, но при сомнениях можно обратиться и к интернету.
Что касается размеров памяти (LENGTH), в случае с nRF информацию следует искать в спецификации. Чтобы получить аналогичные данные по STM32, необходимо перейти по ссылке и выбрать интересующее вас ядро, например, Cortex-M4, как показано на Рисунке 4.
Рисунок 4. Выбор ядра МК
Далее, в таблице на открывшейся странице кликаете по нужной линейке
Рисунок 5. Выбор линейки микроконтроллеров
и по диаграмме на следующей странице определяете размер обоих типов памяти вашего МК.
Рисунок 6. Определение размеров памяти МК
↑ Makefile и variables.mk
Два основных правила из Makefile обуславливают компиляцию/компоновку файлов проекта (правило all) и загрузку hex-файла в МК (правило upload). Правило size предназначено для вывода в консоль информации о том, сколько памяти (флэш и ОЗУ) потребуется для размещения и работы вашей программы.Исполнение любого правила запускается из консоли командой «make имя правила» + Enter. Поскольку правило all расположено первым по ходу, его имя в команде можно опустить, набрав лишь «make» + Enter. Чтобы избежать повторений в тексте, договоримся, что в дальнейшем словосочетание «компилировать программу» будет означать для нас команду из консоли «make» + Enter, а «загрузить программу» — «make upload» + Enter.
В variables.mk даны макроопределения, используемые в Makefile, первое из которых — макроопределение FILE имени проекта и, соответственно, hex-файла. В принципе, можно от проекта к проекту не менять его текущее значение «projectName», но, тогда со временем у вас накопится куча прошивок с одним и тем же именем, что, согласитесь, не очень удобно. Поэтому, изменим значение FILE на «first».
Помимо упомянутых выше, в Makefile для AVR-8 прописаны правила записи (fuses_write) или чтения (fuses_read) фьюзов и мы, пользуясь случаем, опробуем первое из них.
Дефолтная частота тактирования процессора указанного МК составляет 1МГц, что вызывает ряд неудобств:
• Загрузка hex-файла происходит медленно, а в некоторых случаях программатор USBasp может и не «увидеть» МК, требуя изменить частоту либо установить джампер J2, входящий в его состав.
• Процент ошибок при обмене данными по протоколу UART на частоте 1 МГц — высоковат.
Учитывая изложенное, повысим частоту до 8 МГц, для чего перейдём в калькулятор фьюзов и убедимся, что соответствующее значение младшего фьюз-байта составляет 0xC4.
Рисунок 7. Значения фьюз-байтов ATmega8 при частоте тактирования 8 Мгц
Затем, изменим значение макроопределения в строке 61 файла variables.mk на вышеуказанное
LOW_BYTE = 0xC4
и перезапишем фьюзы командой из консоли «make fuses_write» + Enter, получив там же сообщение об успешной записи
avrdude.exe: safemode: Fuses OK (E:FF, H:D9, L:C4)
↑ main.c
Файл main.c из папки src является обязательным элементом и основой любой программы, поэтому детально разберём его начальное содержимое.#include <avr/io.h> // "stm32f4xx.h", "nrf.h"
void SystemInit() // для STM32F401 и nRF52832
{
}
int main(void)
{
/* Код, исполняемый один раз*/
while(1)
{
/* Код, исполняемый в цикле */
}
}
1. Первой строкой включаются заголовочные файлы, которые прямо или опосредованно содержат адреса регистров МК, макроопределения их битов и прочую информацию.
2. Те из вас, кто ознакомился с «Ассемблер...», в курсе, что любая программа начинается с размещения векторов прерываний и указания на вершину стека. В Си программист освобождён от этого, поскольку код указанных процедур уже прописан в startup-файле и автоматически помещается в самом верху вашей программы в ходе компиляции. При подаче питания/сбросе осуществляется переход по вектору RESET к основной функции main() либо напрямую (для ATmega8), либо через функцию SystemInit(), по причине чего последняя и присутствует в main.c для STM32F401 и nRF52832. Присутствует и ладно, тем более, что впоследствии мы используем её с выгодой для себя.
3. Тип возвращаемого и принимаемого аргументов функции main() в нашем случае — всегда int и void, соответственно.
4. Как следует из комментариев, код, размещённый в main() до цикла while(), исполняется процессором один раз. Чаще всего это — первоначальные настройки модулей МК, используемых в данном проекте. Инструкции, которые мы прописываем внутри фигурных скобок цикла while(), процессор исполняет по кругу, временно покидая его лишь для обработки прерываний или перехода к вызываемым функциям.
5. Комментарии к коду реализуются двумя способами: предваряются символом «//», если комментарий умещается в одну строку
// комментарий в одну строку
либо заключаются между символами «/*» и «*/» при комментировании блока строк.
/*
комментарий
в
несколько
строк
*/
При этом, не возбраняется комментировать вторым способом и одну строку.
↑ Обмен данными с МК
Как в ходе обучения, так и в дальнейшем, при отладке написанных программ, вы будете сталкиваться с необходимостью знать текущее значение какого-либо регистра, чтобы убедиться в верности кода. С этой целью мы в первой части статьи установили расширение Serial для VS Code и теперь самое время настроить его и проверить обмен данными между МК и компьютером.1. Подключите USB-UART адаптер к компьютеру. На борту макетной платы nRF52832 имеется собственный адаптер, поэтому её достаточно подключить напрямую.
2. Выясните в диспетчере устройств Windows номер выделенного под адаптер COM-порта.
3. Пройдите во вкладку Serial monitor в нижней части VS Code и выберите порт вашего адаптера из списка «Port», а также скорость 9600 — из списка «Baud rate». Соединение с адаптером устанавливается нажатием кнопки «Start monitoring».
Рисунок 8. Настройки последовательного порта
4. Скопируйте для вашего МК из архива статьи файлы Serial.c и Serial.h в папки проекта src и inc, соответственно. Библиотека Serial содержит две нужные нам функции:
• serialBegin() инициализирует протокол UART и прописывается до цикла while() функции main().
• serialPrintln() передаёт в последовательный порт строку, заключённую в кавычки, и размещается в том месте кода, где требуется вывод информации.
5. Включите библиотеку в компиляцию и добавьте, для примера, в функцию main() следующий код:
#include “Serial.h”
int main()
{
serialBegin();
serialPrintln("Hello World!");
while(1)
{
}
}
6. Соедините вывод TX микроконтроллера (PD1 – для ATmega8, PA2 или A2 – для макетной платы STM32F401) с аналогичным выводом адаптера, скомпилируйте программу, загрузите её в МК и убедитесь, что строка успешно передана в последовательный порт.
Рисунок 9. Отражение данных в мониторе порта
7. Вывод в монитор численных значений потребует применения функций библиотеки stdio тулчейна. Для ATmega8 указанная библиотека обнаруживается автоматически, а вот в случае с Cortex-M4 для успешного её поиска необходимо в файле c_cpp_properties.json после определения стандарта языка добавить через запятую путь к компилятору. В моём случае это выглядит следующим образом:
"cStandard": "c99",
"compilerPath": "D:/GNU/ARM/armGnuToolchain/bin/arm-none-eabi-gcc.exe"
↑ Схема устройства
Проверять работу написанных программ мы будем с помощью устройства, схема которого приведена на Рисунке 10, а распиновка для каждого из рассматриваемых нами МК — в Таблице 4.Рисунок 10. Схема устройства для проверки кода
Выводы подключения кнопок мы будем программно подтягивать к питанию через внутренние резисторы МК.
Таблица 4. Распиновка устройства
В случае с макетными платами задействованы имеющиеся на борту:
• Пары светодиодов и кнопок, которые подключены к пинам P0.17-P0.18 и P0.13-P0.14, соответственно — для nRF52832.
• Светодиод, подключённый к пину PC13 (C13) — для STM32F401.
↑ Порт ввода-вывода
На каждый из пинов МК, в связи с ограниченностью их количества, возлагается несколько функций, дефолтная из которых, обычно — вход/выход для чтение из-вне/записи во-вне дискретного значения: логических 1 или 0. При этом, пины объединяются в порты ввода-вывода (GPIO), количество которых зависит от комплектации МК, в частности:• для ATmega8 — 3 порта: B (пины PB0-PB7), C (пины PC0-PC6) и D (пины PD0-PD7),
• для STM32F401 (QFPN48) — 3 порта: A (пины PA0-PA15), B (пины PB0-PB10, PB12-PB15) и C (пины PC13-PC15),
• для nRF52832 — один порт, который и составляют имеющиеся 32 пина P0.0-P0.31.
Работу порта регулируют несколько регистров, через которые:
а) Устанавливается направление пина (вход или выход).
б) Пин подтягивается к питанию или опускается к земле, если есть такая необходимость.
в) Обеспечивается запись логических 1 или 0 на пин, настроенный как выход.
г) Считывается текущее логическое состояние пина.
Какие именно регистры и что с ними делать для корректной работы модуля GPIO, вы можете узнать из пятой части «Ассемблер...».
Настроим выводы LED_1-LED_4 МК нашего устройства как выходы и оформим код управления светодиодами, для чего создадим файлы leds.h и leds.c в папках inc и src, соответственно.
Содержимое заголовочных файлов будет иметь следующий вид:
ATmega8
#ifndef LEDS_H_
#define LEDS_H_
#include <avr/io.h>
/* Макроопределения и макросы */
// запись на пин PD6 логического 0
#define LED_1_ON PORTD &= ~(1 << PD6)
// запись на пин PD6 логической 1
#define LED_1_OFF PORTD |= (1 << PD6)
// изменение состояния пина PD6 на противоположное
#define LED_1_TOGGLE PORTD ^= (1 << PD6)
// запись на пин PD7 логического 0
#define LED_2_ON PORTD &= ~(1 << PD7)
// запись на пин PD7 логической 1
#define LED_2_OFF PORTD |= (1 << PD7)
// изменение состояния пина PD7 на противоположное
#define LED_2_TOGGLE PORTD ^= (1 << PD7)
// запись на пин PB1 логического 0
#define LED_3_ON PORTB &= ~(1 << PB1)
// запись на пин PB1 логической 1
#define LED_3_OFF PORTB |= (1 << PB1)
// изменение состояния пина PB1 на противоположное
#define LED_3_TOGGLE PORTB ^= (1 << PB1)
// запись на пин PB2 логического 0
#define LED_4_ON PORTB &= ~(1 << PB2)
// запись на пин PB2 логической 1
#define LED_4_OFF PORTB |= (1 << PB2)
// изменение состояния пина PB2 на противоположное
#define LED_4_TOGGLE PORTB ^= (1 << PB2)
/* Прототип функции инициализации пинов */
void ledsInit();
#endif
STM32F401
#ifndef LEDS_H_
#define LEDS_H_
#include "stm32f4xx.h"
/* Макроопределения и макросы */
// запись на пин PC13 логического 0
#define LED_1_ON GPIOC->ODR &= ~GPIO_ODR_ODR_13
// запись на пин PC13 логической 1
#define LED_1_OFF GPIOC->ODR |= GPIO_ODR_ODR_13
// изменение состояния пина PC13 на противоположное
#define LED_1_TOGGLE GPIOC->ODR ^= GPIO_ODR_ODR_13
// запись на пин PC14 логического 0
#define LED_2_ON GPIOC->ODR &= ~GPIO_ODR_ODR_14
// запись на пин PC14 логической 1
#define LED_2_OFF GPIOC->ODR |= GPIO_ODR_ODR_14
// изменение состояния пина PC13 на противоположное
#define LED_2_TOGGLE GPIOC->ODR ^= GPIO_ODR_ODR_14
// запись на пин PB1 логического 0
#define LED_3_ON GPIOB->ODR &= ~GPIO_ODR_ODR_1
// запись на пин PB1 логической 1
#define LED_3_OFF GPIOB->ODR |= GPIO_ODR_ODR_1
// изменение состояния пина PB1 на противоположное
#define LED_3_TOGGLE GPIOB->ODR ^= GPIO_ODR_ODR_1
// запись на пин PB2 логического 0
#define LED_4_ON GPIOB->ODR &= ~GPIO_ODR_ODR_2
// запись на пин PB2 логической 1
#define LED_4_OFF GPIOB->ODR |= GPIO_ODR_ODR_2
// изменение состояния пина PB2 на противоположное
#define LED_4_TOGGLE GPIOB->ODR ^= GPIO_ODR_ODR_2
/* Прототип функции инициализации пинов */
void ledsInit();
#endif
nRF52832
#ifndef LEDS_H_
#define LEDS_H_
#include "nrf.h"
/* Макроопределения и макросы */
// запись на пин P0.17 логического 0
#define LED_1_ON NRF_GPIO->OUT &= ~(GPIO_OUT_PIN17_High << GPIO_OUT_PIN17_Pos)
// запись на пин P0.17 логической 1
#define LED_1_OFF NRF_GPIO->OUT |= (GPIO_OUT_PIN17_High << GPIO_OUT_PIN17_Pos)
// изменение состояния пина P0.17 на противоположное
#define LED_1_TOGGLE NRF_GPIO->OUT ^= (GPIO_OUT_PIN17_High << GPIO_OUT_PIN17_Pos)
// запись на пин P0.18 логического 0
#define LED_2_ON NRF_GPIO->OUT &= ~(GPIO_OUT_PIN18_High << GPIO_OUT_PIN18_Pos)
// запись на пин P0.18 логической 1
#define LED_2_OFF NRF_GPIO->OUT |= (GPIO_OUT_PIN18_High << GPIO_OUT_PIN18_Pos)
// изменение состояния пина P0.18 на противоположное
#define LED_2_TOGGLE NRF_GPIO->OUT ^= (GPIO_OUT_PIN18_High << GPIO_OUT_PIN18_Pos)
// запись на пин P0.15 логического 0
#define LED_3_ON NRF_GPIO->OUT &= ~(GPIO_OUT_PIN15_High << GPIO_OUT_PIN15_Pos)
// запись на пин P0.15 логической 1
#define LED_3_OFF NRF_GPIO->OUT |= (GPIO_OUT_PIN15_High << GPIO_OUT_PIN15_Pos)
// изменение состояния пина P0.15 на противоположное
#define LED_3_TOGGLE NRF_GPIO->OUT ^= (GPIO_OUT_PIN15_High << GPIO_OUT_PIN15_Pos)
// запись на пин P0.16 логического 0
#define LED_4_ON NRF_GPIO->OUT &= ~(GPIO_OUT_PIN16_High << GPIO_OUT_PIN16_Pos)
// запись на пин P0.16 логической 1
#define LED_4_OFF NRF_GPIO->OUT |= (GPIO_OUT_PIN16_High << GPIO_OUT_PIN16_Pos)
// изменение состояния пина P0.16 на противоположное
#define LED_4_TOGGLE NRF_GPIO->OUT ^= (GPIO_OUT_PIN16_High << GPIO_OUT_PIN16_Pos)
/* Прототип функции инициализации пинов */
void ledsInit();
#endif
Как видите:
1. Каждый хэдер-файл начинает и завершает конструкция директив #ifndef-#define-#endif, призванная в больших проектах исключить многократное включение одного и того же заголовочного файла и, как следствие, ошибку переопределения.
2. Из main.c в leds.h перенесено подключение заголовочного файла (avr/io.h, stm32f4xx.h, nrf.h), поскольку теперь адресами регистров и макроопределениями их битов будет оперировать библиотека leds, а основная функция — использовать лишь макросы и функции библиотеки. Строго говоря, у main.c останется доступ к указанному файлу, но опосредованно, через хэдер-файл библиотеки.
3. Включение, выключение переключение светодиодов реализованы через макросы, хотя можно было прописать их в виде функций. Однако, в случаях, когда фрагмент кода состоит из одной строки, макрос предпочтительнее, т.к. его исполнение занимает у процессора меньше времени, в связи с отсутствием инструкций перехода и возврата.
4. Из-за того, что светодиоды в нашем устройстве — активно-низкие, в макросах применена обратная логика, т.е. для включения светодиода соответствующий пин переводится в состояние 0, а для выключения — в 1.
C-файл библиотеки будет содержать единственную функцию, инициализирующую пины как выходы.
ATmega8
#include "leds.h"
/* Функция инициализации пинов */
void ledsInit()
{
// Настроить пины PD7 и PD6 как выходы
DDRD |= (1 << PD7) | (1 << PD6);
// Настроить пины PB2 и PB1 как выходы
DDRB |= (1 << PB2) | (1 << PB1);
// Выключить светодиоды
LED_1_OFF;
LED_2_OFF;
LED_3_OFF;
LED_4_OFF;
}
STM32F401
#include "leds.h"
/* Функция инициализации пинов */
void ledsInit()
{
// Включить тактирование порта B
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;
// Настроить пины PB2 и PB1 как выходы
GPIOB->MODER |= GPIO_MODER_MODER2_0 | GPIO_MODER_MODER1_0;
// Включить тактирование порта C
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN;
// Настроить пины PC14 и PC13 как выходы
GPIOC->MODER |= GPIO_MODER_MODER14_0 | GPIO_MODER_MODER13_0;
// Выключить светодиоды
LED_1_OFF;
LED_2_OFF;
LED_3_OFF;
LED_4_OFF;
}
nRF52832
#include "leds.h"
/* Функция инициализации пинов */
void ledsInit()
{
// Настроить пины P0.15-P0.18 как выходы
NRF_GPIO->PIN_CNF[15] |= (GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos);
NRF_GPIO->PIN_CNF[16] |= (GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos);
NRF_GPIO->PIN_CNF[17] |= (GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos);
NRF_GPIO->PIN_CNF[18] |= (GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos);
// Выключить светодиоды
LED_1_OFF;
LED_2_OFF;
LED_3_OFF;
LED_4_OFF;
}
Отмечу, что:
а) Первой строкой c-файла библиотеки всегда подключается парный хэдер-файл.
б) При наборе имён модулей (например, GPIOB для STM32F401 и NRF_GPIO для nRF52832), регистров (в частности, DDRB для ATmega8, MODER для STM32F401, PIN_CNF для nRF52832), масок и битов всплывает список подсказки, что облегчает и ускоряет процесс. Кроме того, указанные имена очень хорошо коррелируют с таковыми в технической документации, так что вы быстро разберётесь с этими страшным символам и привыкнете к ним.
в) Опять-таки, по причине активно-низких светодиодов функция инициализации содержит строки их выключения, т.к. дефолтный логический уровень сигнала на пине, настроенном как выход — 0, что, как вы помните, включает светодиод.
Теперь, в main.c нужно подключить библиотеку и можно помигать светодиодом LED_3, к примеру:
#include “leds.h”
void SystemInit() // для STM32F401 и nRF52832
{
}
int main()
{
ledsInit();
while(1)
{
LED_3_TOGGLE;
}
}
Однако, после компиляции программы и загрузки её в МК вы вряд ли увидите блинк без осциллографа, поскольку текущая частота тактирования высока (8 МГц — для ATmega8, 16 МГц — для STM32F401, 64 МГц — для nRF52832), а в цикле while() основной функции — лишь одна строка кода, которая и исполняется с соответствующей периодичностью.
Учитывая изложенное, привяжем включение/выключение светодиодов LED_1 — LED_3 к состоянию кнопок по следующему алгоритму:
Если нажата только кнопка BUTTON_1, включить светодиод LED_1, а остальные выключить
Если же нажата только кнопка BUTTON_2, включить светодиод LED_2, а остальные выключить
Если же нажаты обе кнопки, включить светодиод LED_3, а остальные выключить
Во всех остальных случаях выключить все светодиоды
Как видите, алгоритм предусматривает ветвление программы, для организации которого применяют конструкцию if-else if-else, имеющую, в общем случае, следующий синтаксис:
if(выражение 1) /* ветка 1 */
код, исполняемый при истинности выражения 1
else if(выражение 2) /* ветка 2 */
код, исполняемый при истинности выражения 2
...
else if(выражение n-1) /* ветка n-1 */
код, исполняемый при истинности выражения n-1
else // /* ветка n */
код, исполняемый когда выражения все предыдущие выражения — ложны
При этом:
а) Инструкция if может использоваться и самостоятельно.
б) Выражение формируется посредством операторов отношения или сравнения на равенство:
> — больше,
>= — больше или равно,
< — меньше,
<= — меньше или равно,
== — равно,
!= — не равно.
в) Если выражение содержит несколько условий, их комбинируют с помощью логических операторов:
&& — для случая, когда должны выполняться все условия,
|| — для случая, когда должно выполняться хотя бы одно из условий,
г) Если код ветки состоит более, чем из одной строки, его заключают в фигурные скобки.
Кроме вышеприведённой, можно задействовать конструкцию ветвления switch-case, с которой вы, уверен, разберётесь самостоятельно, почитав литературу.
Создадим библиотеку buttons и пропишем в её с-файле функцию инициализации пинов подключения кнопок:
ATmega8
#include "buttons.h"
void buttonsInit()
{
// Настроить пины PD3 и PD2 как входы
DDRD &= ~((1 << PD3) | (1 << PD2));
// и подтянуть к питанию
PORTD |= (1 << PD3) | (1 << PD2);
}
STM32F401
#include "buttons.h"
void buttonsInit()
{
// Включить тактирование порта A
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// Настроить пины PA10 и PA9 как входы
GPIOA->MODER &= ~(GPIO_MODER_MODER10 | GPIO_MODER_MODER9);
// и подтянуть к питанию
GPIOA->PUPDR |= GPIO_PUPDR_PUPDR10_0 | GPIO_PUPDR_PUPDR9_0;
}
nRF52832
#include "buttons.h"
void buttonsInit()
{
// Настроить как вход и подтянуть к питанию пин P0.13
NRF_GPIO->PIN_CNF[13] = (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | (GPIO_PIN_CNF_PULL_Pullup << GPIO_PIN_CNF_PULL_Pos);
// Настроить как вход и подтянуть к питанию пин P0.14
NRF_GPIO->PIN_CNF[14] = (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | (GPIO_PIN_CNF_PULL_Pullup << GPIO_PIN_CNF_PULL_Pos);
}
Стоит отметить, что фрагменты настройки пинов как входов — не обязательны, поскольку при подаче питания/сбросе они и так устанавливаются входами. Однако, эти нюансы не очевидны для начинающего, в связи с чем я решил всё же разместить указанный код.
Текущее состояние пинов портов ввода/вывода отражают регистры:
• PINB, PINC и PIND — для ATmega8,
• IDR модуля GPIO — для STM32F401,
• IN модуля GPIO — для nRF52832.
Чтобы знать, нажата или отпущена та или иная кнопка в данный момент, оформим в хэдер-файлах библиотеки макросы посредством упоминавшихся выше операторов.
ATmega8
#ifndef BUTTONS_H_
#define BUTTONS_H_
#include <avr/io.h>
#define BUTTON_1_PUSHED (PIND & (1 << PD2)) == 0
#define BUTTON_1_RELEASED (PIND & (1 << PD2)) != 0
#define BUTTON_2_PUSHED (PIND & (1 << PD3)) == 0
#define BUTTON_2_RELEASED (PIND & (1 << PD2)) != 0
/* Прототип функции инициализации пинов */
void buttonsInit();
#endif
STM32F401
#ifndef BUTTONS_H_
#define BUTTONS_H_
#include "stm32f4xx.h"
#define BUTTON_1_PUSHED (GPIOA->IDR & GPIO_IDR_IDR_9) == 0
#define BUTTON_1_RELEASED (GPIOA->IDR & GPIO_IDR_IDR_9) != 0
#define BUTTON_2_PUSHED (GPIOA->IDR & GPIO_IDR_IDR_10) == 0
#define BUTTON_2_RELEASED (GPIOA->IDR & GPIO_IDR_IDR_10) != 0
/* Прототип функции инициализации пинов */
void buttonsInit();
#endif
nRF52832
#ifndef BUTTONS_H_
#define BUTTONS_H_
#include "nrf.h"
#define BUTTON_1_PUSHED (NRF_GPIO->IN & (GPIO_IN_PIN14_High << GPIO_IN_PIN14_Pos)) == 0
#define BUTTON_1_RELEASED (NRF_GPIO->IN & (GPIO_IN_PIN14_High << GPIO_IN_PIN14_Pos)) != 0
#define BUTTON_2_PUSHED (NRF_GPIO->IN & (GPIO_IN_PIN13_High << GPIO_IN_PIN13_Pos)) == 0
#define BUTTON_2_RELEASED (NRF_GPIO->IN & (GPIO_IN_PIN13_High << GPIO_IN_PIN13_Pos)) != 0
/* Прототип функции инициализации пинов */
void buttonsInit();
#endif
Осталось, следуя алгоритму, прописать код main.c:
#include "leds.h"
#include "buttons.h"
void SystemInit() // для STM32F401 и nRF52832
{
}
int main()
{
ledsInit();
buttonsInit();
while(1)
{
// Если нажата только кнопка BUTTON_1,
if(BUTTON_1_PUSHED && BUTTON_2_RELEASED)
{
// включить светодиод LED_1, а остальные выключить
LED_1_ON;
LED_2_OFF;
LED_3_OFF;
}
// Если же нажата только кнопка BUTTON_2,
else if(BUTTON_1_RELEASED && BUTTON_2_PUSHED)
{
// включить светодиод LED_2, а остальные выключить
LED_1_OFF;
LED_2_ON;
LED_3_OFF;
}
// Если же нажаты обе кнопки,
else if(BUTTON_1_PUSHED && BUTTON_2_PUSHED)
{
// включить светодиод LED_3, а остальные выключить
LED_1_OFF;
LED_2_OFF;
LED_3_ON;
}
// Во всех остальных случаях выключить все светодиоды
else
{
LED_1_OFF;
LED_2_OFF;
LED_3_OFF;
}
}
}
↑ Файлы
🎁Папки проектов и коды в zip 82.4 Kb ⇣ 44↑ Ссылки
• Atmega8a даташиты, мануалы• Cortex M4 даташиты, мануалы
• nRF52832 даташиты, мануалы
• STM32F401 даташиты, мануалы
• Подборка книг по языкам, ОС, алгоритмам, компиляторам и т.п. Включая «Язык программирования Си» Кернигана и Ритчи.
Пожалуй, для размышлений и самостоятельной работы материала достаточно, так что продолжим в следующий раз.
Спасибо за внимание!
Камрад, рассмотри датагорские рекомендации
🌼 Полезные и проверенные железяки, можно брать
Опробовано в лаборатории редакции или читателями.