Как составить программу для микроконтроллера

Приветствую всех сограждан и читателей журнала Датагор! Пользуясь кучей времени, предоставленной коронавирусом (даже в нашествии такой гадости можно, при желании, найти положительные моменты), решил поднять и пересмотреть записи по микроконтроллерам (МК), которые я делал в разное время для своих детей. В итоге родилась идея объединить разрозненные материалы. Настоящая работа не является учебным курсом по программированию МК, хотя некоторые его элементы будут присутствовать. Скорее, это попытка осветить путь от написания программы до её загрузки в микроконтроллер. Я лишь расскажу о доступных практически для каждого средствах, необходимых для прохождения этого пути, расставлю «вешки» по всему маршруту и намечу направления. Соединять их вам придётся самостоятельно.

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

Содержание статьи / Table Of Contents

Изначально, записывая «шпаргалки» для детей, я определил пару условий в изложении, чтобы оно, с одной стороны, не отпугнуло читающего в самом начале пути, а с другой — дало бы общее понимание работы микроконтроллеров и снизило барьеры при переходе от одного типа МК к другому:
• Рассматривать несколько МК разных архитектур и, желательно, разных производителей. При этом, давать такое объяснение принципа работы, которое подходило для всех рассматриваемых МК, но не было совсем уж популистским.
• При разборе практических примеров, обойтись без использования специализированной среды программирования (IDE). Тема МК сама по себе не самая примитивная, а необходимость вникать параллельно в работу нескольких IDE для разных МК оптимизма начинающему никак не прибавляет.
Эти же принципы, несколько расширив их, я решил перенести на статью.

Объектами изучения нам послужат микроконтроллеры:


ATTINY85-20PU DIP8


ATMEGA8A-PU


Макетная плата STM32F401


Ebyte SoC nRF52832 тестовая плата
Если точно такой платы не найдёте, можно купить модуль nRF52832 и распаять отладочную плату.

nRF52832
Минимум пинов, которые необходимо вывести:
• GND,
• VCC,
• SWDIO и SWDCLK для программирования и отладки,
• Reset,
• несколько GPIO.
В файловый архив выложен мануал этого модуля.


USBasp

st-link v2

USB-UART адаптер CH340


Макетная плата

Соединительные провода разного типа

Потенциометры

Светодиоды

В качестве среды программирования мы будем использовать обычный блокнот Notepad++, компилировать написанную программу посредством компилятора GCC от GNU. С отладкой кода нам помогут программы Tetminal и PuTTY, а с его загрузкой в МК — avrdude и openocd.

1. Скачать и установить

Notepad++

.
2. При необходимости выбрать русский язык в Settings/Preferences/General/Localization.
3. В Плагины/Управление плагинами, во вкладке Доступные выставить галочки напротив Explorer и NppExec и нажать кнопку Установить.
4. Выставить галочки напротив Плагины/Explorer/Explorer, Плагины/NppExec/Show Console и Follow $(CURRENT_DIRECTORY). Слева и снизу от окна редактора появятся окна проводника и консоли, соответственно.
5. В Опции/Определение стилей выбрать подходящий стиль и для языков С, Makefile и Assembler настроить подходящие цвета и размеры шрифтов.
6. Чтобы настройки стиля вступили в силу в Синтаксисы выбрать A/Assembly, C/C или M/Makefile при работе с соответствующим файлом.

1. Создать на удобном для вас диске папки:
• GNU,
• GNU/AVR,
• GNU/AVR/avrdude,
• GNU/ARM,
• GNU/MinGW.
2. Скачать и распаковать в папку GNU/ARM файлы

Arm GNU Toolchain 6.3.1 — Windows

и 

openocd-20200701.7z

.
Переименовать распакованные папки в armGnuToolchain и OpenOCD.
3. Скачать и распаковать в папку GNU/AVR файл

AVR 8-bit Toolchain v3.62 — Windows

.
Переименовать распакованную папку в avrGnuToolchain.
4. Скачать и распаковать в папку GNU/AVR/avrdude файл

avrdude-6.1-mingw32.zip

.
5. Скачать файл

mingw-get-setup

и запустить его, указав GNU/MinGW как папку для установки. В ходе установки будет запущен MinGW Installation Manager, в котором достаточно выбрать базовый пакет (Basic Setup) и нажать Installation/Apply Changes.

Если с установкой возникли проблемы, можно скачать готовый вариант папки MinGW из архива.
6. В Панель управленияСистемаДополнительные параметры системыПеременные средыПеременные среды для пользователяPath добавить пути к папкам:
• GNUARMarmGnuToolchainbin
• GNUARMOpenOCDbin
• GNUAVRavrGnuToolchainbin
• GNUAVRavrdude
• GNUMinGWmsys1.0bin

7. Перезагрузить компьютер.

Скачать и распаковать на удобном для вас диске программы

Terminal 1.9b

и 

PuTTY

В архив выложены rar-файлы всех упомянутых выше программ.

Драйвер USBasp также выложен в архив, а ST-link v2 устанавливается автоматически при первом подключении к компьютеру. Оба программатора после установки должны отобразиться в «Диспетчере устройств» Windows.

Как бы мне ни хотелось сразу перейти к практике, придётся сделать отступление в теорию. Попытаюсь ограничиться её минимумом, который облегчит понимание практического материала в последующем. Более того, уже в этой главе мы начнём знакомиться с некоторыми реальными командами МК AVR и ARM. Добавлю, что местами буду приводить англоязычный вариант терминов и аббревиатур: все-же чтения даташитов и мануалов вам не избежать, а их издатели упрямо не желают переходить на великий и могучий.

В моём восприятии, как программиста, микроконтроллер — две большие кучи регистров. В первую кучу (память программ) мы загружаем программу в виде последовательностей нулей и единиц. При этом, значительная доля содержания программы сводится к своевременной записи правильного набора нулей и единиц в нужный регистр второй кучи (памяти данных), либо чтению из него этих наборов. В случае языка ассемблер мы имеем дело, по большей части, именно с регистрами памяти данных, поэтому выясним для начала, что они из себя представляют.
Уверен, вам известен такой элемент, как D-триггер (далее — триггер) и его основные свойства:
1. Выход триггера T может находиться только в одном из двух логических состояний — 1 (на выходе — напряжение питания) или 0 (на выходе — земля).
2. Значение (0 или 1) со входа D переносится (записывается) на выход T триггера по фронту синхронизирующего (тактового) сигнала С и сохраняется до следующей записи либо отключения питания.

Ассемблер для микроконтроллера с нуля. Часть 1. Начало пути

Если взглянуть на триггер в плоскости информации, можно сказать, что он хранит 1 бит данных со значением 0 или 1.
Соединив параллельно несколько триггеров, мы и получим регистр, разрядность или битность которого определяется количеством составляющих его триггеров. Совокупность линий данных триггеров регистра принято называть шиной данных. Тактовый сигнал для всех триггеров регистра — единый, т. е. перенос значений с линий шины данных на выходы (запись в регистр) происходит одновременно для всех триггеров. Нумерация битов регистра ведётся справа-налево, начиная с нулевого. Обычно, битность регистров МК кратна восьми (8, 16, 32).

Пример 8-битного регистра приведён на Рисунке 5.

Если необходимо увеличить объём хранимой информации, регистры объединяют в массивы. В этом случае, чтобы обеспечить запись в определённый регистр массива (или чтения из него), требуются линии выбора, которые все вместе именуются шиной адреса. Номера или адреса регистров массива также начинаются с нулевого значения. Пример массива из 4 регистров, в котором с целью экономии входы и выходы триггеров сведены в одну двунаправленную шину, представлен на Рисунке 6.

Чтобы не загромождать рисунок, впредь будем изображать регистр без линий шин данных/адреса и тактирования, обозначая его как rn (где, n — адрес регистра), а хранимые в нём данные — помещать внутрь квадратов, символизирующих триггеры.

Не берусь утверждать, что регистры МК организованы на базе именно D-триггеров. Более того, физическая суть записи в регистры памяти программ (flash-память) — совершенно иная. Тем не менее, принципы хранения и движения оперативной информации в микроконтроллерах я постарался передать верно.

В завершение — о единицах измерения хранимой в регистрах информации и некоторых общепринятых терминах.

Восемь бит информации составляют 1 байт. Биты и байты с самым маленьким номером в заданном диапазоне часто называют младшими, а с самым большим номером — старшими. То же самое, кстати, относится и к адресам. Для обозначения многобайтных данных иногда применяют термин «слово» («word»).

В случае, когда речь идёт о тысячах байт, может возникнуть лёгкая путаница. Дело в том, что исторически использовалась единица 1 килобайт, равная 1024 байт.
Думаю, поборники «чистоты во всём», возмущённые тем, что «кило» — 1024, а не 1000, добились, в конце концов, принятия двух единиц измерения:
1 килобайт (KB) — 1000 байт
1 кибибайт (KiB) — 1024 байт.
Не уверен, что жизнь программистов после этого стала стремительно улучшаться, и поэтому не стал бы тратить ваше время на подобную информацию, однако с таким многообразием единиц измерения согласились, кажется, не все.
При прочтении даташитов, вы убедитесь, что производители МК по-прежнему используют приставку K, подразумевая 1024 байт. Кроме того, в следующих главах нам предстоит делать расчёт адресов регистров, исходя из их общего количества, поэтому давайте договоримся: в рамках данной статьи 1K байт — это 1024 байт.

Поскольку регистры ничего, кроме 0 и 1, содержать не могут, можно сказать, что микроконтроллер оперирует в поле двоичной системы счисления.
На Рисунке 7 нули и единицы, записанные в регистр, образуют 8-битное двоичное число 10101010. В программировании двоичное число предваряют символами 0b: 0b10101010.

На первых порах представление числа в двоичном формате при написании программы может показаться наиболее естественным и понятным. Но, попробуйте набрать в текстовом редакторе несколько раз одну и ту же комбинацию из 32 нулей и единиц, не вызвав при этом ряби в глазах и сомнений, что в том или ином разряде не перепутаны 0 и 1, и вы поймёте, что с этим надо что-то делать. Поэтому, обычно применяют привычное десятичное или шестнадцатеричное представление числа.

Обычно, десятичные числа используют в программе, когда речь идёт о численном выражении какой-либо величины (например, 5 секунд или 12 тонн).

В случае шестнадцатеричной системы счисления перед числом прописываются символы 0x. Одно из бесспорных преимуществ шестнадцатеричного представления заключается в том, что каждые два разряда числа, начиная с младшего, составляют 1 байт. К примеру, байты числа 0×12A57F — 0×12, 0хA5 и 0×7F. О пользе этого свойства вы узнаете в главе, посвящённой практике. Добавлю, что эта система, помимо программы, широко используется в документации МК: адреса регистров в даташитах и мануалах представлены в шестнадцатеричной форме.

Для уверенности в том, что набранное вами десятичное или шестнадцатеричное число отражает требуемую комбинацию 0 и 1 в двоичном представлении, надо бы знать, как переводится число из одной системы счисления в другую. Однако, не буду забивать вам головы информацией о методах такого перевода: полагаю, что для начала вполне достаточно использовать калькулятор Windows.

Как видите, число, записанное в регистр на Рисунке 7 — 170 в десятичной и 0xAA в шестнадцатеричной системах счисления.

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

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

Запись операции читается как «сдвинуть число m влево n раз». На Рисунке 9 приведён пример 2-кратного сдвига влево числа m = 3 (0b00000011), записанного в регистр r0.

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

Запись операции читается как «сдвинуть число m вправо n раз», а сама операция работает так же, как и предыдущая, но в обратном направлении.
Если на Рисунке 9 поменять местами верхний и нижний регистры, получится иллюстрация двукратного сдвига вправо числа 12 (0b00001100).

С применением этой операции значение каждого бита числа меняется на противоположное, т.е инвертируется, поэтому её часто называют инверсией. На рисунке 10 результат инверсии числа A = 15 (0b00001111) из регистра r0 записан в r1 как число С = 240 (0b11110000).

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

Результат равен 1 только если оба бита пары равны 1.

Результат равен 1 если хотя бы один из двух бит пары равен 1.

Результат равен 1 только, если один из двух бит пары равен 1, а другой — 0.

Отмечу, что форма записи логической операций в Таблице 1 и соответствующая ассемблерная команда (инструкция) МК — не одно и то же. Кроме того, на иллюстрациях операций между двумя регистрами (И, ИЛИ, ИСКЛЮЧАЮЩЕЕ ИЛИ) результат, для наглядности, записывается в третий регистр, в реальности же он, обычно, сохраняется в первом из двух, участвующих в операции регистров.
В Таблице 2 приведены некоторые инструкции логических операций МК AVR и ARM с кратким описанием их работы.

Чтобы вам было легче понять, а мне — объяснить суть вопроса, представим 8-битный регистр (Data Register, DR), входящий в состав микроконтроллера и ответственный за связь последнего с внешним миром. Подключим к выводам DR лампочки и договоримся о двух вещах:

1. Мы не можем делать с DR ничего, кроме записи/чтения числа в/из него, да и то не напрямую, а только через вспомогательный регистр r0. К чему такие сложности, вы поймёте из следующего раздела главы.

2. Логика нашего устройства — прямая т. е. число 1 в n-м бите DR включает соответствующую лампу, а 0 — выключает.

Включим жёлтую лампу на выводе 7, записав через r0 в регистр DR число 0b10000000 (128 в десятичной системе счисления), и пусть себе горит. Поскольку речь пойдёт о логике, забудем на время об электрической грамотности и изобразим наше устройство следующим образом:

Как включить красную лампу, подключённую к выводу 4, а затем выключить её, не меняя состояние включённой ранее жёлтой лампы или, если обобщить, как изменить состояние одного или нескольких битов регистра DR, не меняя состояния остальных? Легко сообразить, что для включения красной лампы надо добавить 1 в четвёртый бит и полученное число 0b10010000 (144) записать в DR.

Чтобы выключить красную лампу, не меняя состояния жёлтой, необходимо вернуть в DR число 128. Казалось бы, всё просто и можно вполне обойтись без логических операций. Но, обратим внимание на следующую не очевидную деталь: формируя числа для включения/выключения лампы, мы не должны забывать, что в седьмом бите в обоих случаях должна быть единица. Запомнить номер одного бита — 7 — не сложно так же, как и его состояние, тем более, что оно неизменно. К тому же, регистр у нас — всего один, да и картинка перед глазами облегчает дело.

В реальности картинок, как вы понимаете, нет, а регистров может быть сотни, к тому же 32-битных. Более того, по ходу программы их значение может меняться не один раз. Выходит, программист, чтобы поменять состояние одного бита, должен помнить текущее состояние оставшихся 31 битов для формирования правильного числа. И так — для сотен регистров. И это ещё не самое худшее. Не редко бывает так, что состояние одного или нескольких битов регистра определяется внешними устройствами (кнопками, сенсорами и т. д.) и тогда мы можем даже не знать, каково их текущее состояние.

Короче говоря, нужен механизм, пригодный для чисел любой длины и позволяющий нам менять состояние заданного бита, не затрагивая остальные, т. е. не утруждая себя знанием их текущего состояния. Такой механизм реализуется с помощью логических операций при условии наличия ещё одного вспомогательного регистра — r1.

Операция ИЛИ позволяет записать 1 в любой бит числа, не меняя состояния остальных битов, для чего потребуется:
1. Считать текущее значение DR в r0.
2. Записать в r1 число, в котором значение требуемого бита равно 1, а остальных — 0. В случае с красной лампой это — 4-й бит, а число — 0b00010000.
3. Применить операцию ИЛИ посредством инструкции OR r0, r1 (или ORR r0, r1 — для ARM), результат работы которой, как видно из Таблицы 2, запишется в r0.
4. Скопировать полученное число из r0 в DR.

Очевидно, операция дополнительно к жёлтой не включит ни одной лампы, кроме красной. Более того, если бы мы вдруг забыли, что красная лампа уже включена (1 в четвёртом бите DR до операции) и, тем не менее, провели операцию, это было бы лишь повторное включение и без того включенной лампы, что — не смертельно.
Если включить воображение, можно сказать, что в ходе операции число в r1 накладывается, подобно маске, на число в r0 для получения требуемого результата, поэтому далее будем использовать этот термин.

Операция ИСКЛЮЧАЮЩЕЕ ИЛИ меняет значение требуемого бита на противоположное, не затрагивая остальные биты, если:
1. Считать текущее значение DR в r0.
2. Записать в r1 такую же, как и в предыдущем случае, маску — 0b00010000.
3. Применить операцию ИСКЛЮЧАЮЩЕЕ ИЛИ посредством инструкции EOR r0, r1.
4. Скопировать полученное число из r0 в DR.
На Рисунке 17 приведён пример включения и последующего выключения красной лампы без изменения состояния жёлтой посредством двойного применения инструкции EOR r0, r1.

С помощью операции И можно обнулить любой бит числа, не меняя состояния остальных, для чего нужно:
1. Считать текущее значение DR в r0.
2. Записать в r1 маску, в которой значение требуемого бита равно 0, а остальных — 1, т. е. 0b11101111 в нашем случае.
3. Применить операцию И посредством инструкции AND r0, r1.
4. Скопировать полученное число из r0 в DR.

Как легко убедиться, будь любая другая лампа, помимо жёлтой, включена до операции, её состояние не изменилось бы и после. Опять же, если красная лампа будет изначально выключена (0 в четвёртом бите DR до операции), мы, применив по забывчивости операцию И, всего лишь получим попытку её повторного выключения.

Всё, изложенное выше, работает, при внесении соответствующего изменения в маску, и в случае, когда речь идёт об одновременном изменении состояния нескольких битов. Например, если параллельно с красной, требуется включать/выключать лампу на нулевом выводе DR вид маски будет следующим:

• 0b00010001 — для операций ИЛИ/ИСКЛЮЧАЮЩЕЕ ИЛИ,
• 0b11101110 для операции И.

Есть ещё одна польза от применения логической операции И — возможность проверить текущее состояние любого бита регистра. Предположим, что включение/выключение красной лампы обусловлено положением внешнего переключателя, подключённого ко 2-му выводу DR. Тогда, следует периодически:
1. Считывать текущее значение DR в r0.
2. Записывать в регистр r1 маску, в которой 2-й бит равен 1, а остальные — 0, т. е. 0b00000100.
3. Применять операцию И посредством инструкции AND r0, r1.

Вы можете, меняя на Рисунке 19 содержимое r0 до операции, убедиться, что после операции в него всегда будет возвращаться число 0 (0b00000000), кроме единственного варианта — когда состояние 2-го бита DR, а значит и r0, до операции, равно 1. В этом случае в r0 после операции запишется число 0b00000100, что и будет сигналом для включения красной лампы. Во всех остальных случаях её следует выключать.
Приведённый пример проверки состояния справедлив и для комбинации битов. То есть, если бы включение красной лампы определяла комбинация из единиц в 0-м и 5-м битах регистра DR, то маской и числом в r0 после операции, обуславливающим включение красной лампы, будет 0b00100001.

Приведу самый простой пример их использования. Если записать в регистр r0 число 1 (0b00000001), а затем последовательно выполнять 7 инструкций сдвига влево (LSL r0) и 7 вправо (LSR r0), получится эффект «бегущего огня».

Ещё об одном распространённом варианте применения операции сдвига. Вы наверняка заметили из Рисунка 9, что единичный сдвиг влево равноценен умножению на 2, а вправо — делению на 2. Учитывая, что сдвиг исполняется МК быстрее, чем умножение/деление, программисты зачастую используют первую операцию взамен второй, когда скорость работы программы критична.
Операция сдвига влево, наряду с операцией НЕ, используется, помимо прочего, для формирования битовых масок, о чём — ниже.

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

Раз уж мы стали использовать инструкции МК, познакомимся ещё с одной — записи числа в регистр. На Рисунке 20 приведён пример инструкции записи в регистр r1 маски включения красной лампы для обоих ядер МК.

Мнемоники (LDI и LDR) инструкции схожи, поскольку в их основе лежит одно и то же слово «load». Поэтому впредь наряду с «запись в регистр» будем использовать выражение «загрузка в регистр».
Глядя на число в правой части Рисунка 20. попытайтесь оценить, сколько непередаваемых эмоций может принести вам работа в лоб с 32-битными масками. Вообще то, общепринятые правила позволяют не отражать незначащие разряды, т. е. число 16 можно записать в обоих случаях как 0b10000, а компилятор сам дополнит недостающие слева нули в соответствии с разрядностью МК.

Ну, а если, всё же, все разряды значащие? Тогда никуда не денешься: придётся набирать этих 8- или 32-главых монстров в тексте программы или на калькуляторе, чтобы получить их десятичный (шестнадцатеричный) аналог. В любом случае риск поставить не туда 0 или 1 остаётся.

Попробуем обойтись малой кровью.
Начать следует с того, что компилятор переводит в двоичное представление не только десятичные и шестнадцатеричные числа, попадающиеся на его пути, но и числа, выраженные в форме записи логических операций. Если хотите понять, что это значит на нормальном человеческом языке, вернёмся к Таблице 1 и вспомним, что 4-кратный сдвиг влево числа 1 выражается формой и даёт результат, представленные на Рисунке 21.

Приглядевшись повнимательнее к Рисунку 21, вы заметите две вещи:
1. В результате операции получилось число 16 (0b00010000), т. е. маска для включения красной лампы.
2. Число n в форме записи (т.е. — 4) фактически указывает на номер бита, в котором должен оказаться после операции младший бит сдвигаемого числа (в нашем случае — единица). В этом контексте можно сказать, что результатом операции n-кратного сдвига влево числа 1 всегда будет число, в котором n-й бит равен единице, а остальные — нулю. Имейте в виду, что все эти фокусы не проходят со сдвигом вправо.

Так вот, мы можем в инструкциях МК из Рисунка 20 вместо двоичного представления числа 16 записать форму 4-кратного сдвига влево числа 1:

,
а компилятор, встретив такую конструкцию, заменит её на число, в котором 4-й бит равен единице, а остальные — нулю, т. е. приведёт всё к виду на Рисунке 20.

Как быть с маской для погашения красной лампы — числом 0b11101111? Вы, скорее всего, уже поняли, что оно — инверсия предыдущей маски 0b00010000. Это позволяет использовать конструкцию

при компиляции, которой, с учётом скобок, определяющих последовательность действий, произойдёт следующее:
1. Формирование числа 0b00010000.
2. Инверсия числа из п. 1 в искомую маску 0b11101111.

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

Принимая во внимание скобки, компилятор в первом случае:
1. Сформирует число, в котором k-й бит равен 1, а остальные — 0.
2. Сформирует число, в котором m-й бит равен 1, а остальные — 0.
3. Сформирует число, в котором n-й бит равен 1, а остальные — 0.
4. Применит операцию ИЛИ между числами из п. п.1 и 2., в результате чего получится новое число с единицами в битах k и m и нулями — в остальных.
5. Применит операцию ИЛИ между числами из п. 3 и 4. с получение окончательного числа с единицами в битах k, m и n и нулями — в остальных.

Во втором случае шестым пунктом добавится инверсия числа из п. 5.

Остался один не обязательный, но очень полезный штрих.
Само по себе число в строке кода не несёт полезной информации, если только оно не количественное выражение чего-либо (2 слонёнка, 5 мартышек и т. п.). Увидев в чужой программе конструкцию подобную той, что на Рисунке 22, вы сможете догадаться лишь, что автор собирается что-то делать с 4-м битом. Замена формы логической операции на число 16 только прибавит загадочности. Наверное, потому и называют такие числа магическими, что выяснить их назначение можно только с помощью магии. Даже собственный код недельной давности, утыканный магическими числами, может ввести вас в ступор, а если вы рискнёте передать его в таком виде коллеге по цеху или заказчику, будьте готовы узнать о себе много нового и интересного.

Ситуацию можно улучшить, добавив к строке комментарий, что, мол, готовится включение красной лампы. Но, комментарий в каждой строке — перебор в другую сторону. Поэтому в случае на Рисунке 22. предпочтительнее использовать условное название или макроопределение для числа 4.
Все используемые в тексте программы макроопределения прописываются до их применения и в ассемблере от GCC имеют следующий общий вид:

НАЗВАНИЕ = значение

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

Для нас такая запись, согласитесь, более информативна, чем предыдущая, а компилятор все встреченные в коде макроопределения RED_LAMP заменит числом 4.

Есть ещё одна веская причина, оправдывающая применение макроопределений. Представьте, что вы практически закончили программу, в десятках разных мест которой включаете/выключаете красную лампу без использования макроопределения. И тут заказчик сообщает, что по соображениям топологии платы красную лампу решено подключить к выводу 6, а вам надо всего лишь внести крохотные, по его мнению, изменения в коде. Не факт, что вы найдёте все четвёрки, подлежащие замене на 6, а это чревато странностями в работе всего устройства. При использовании же макроопределения замену придётся делать лишь раз — в самом макроопределении.

В заключение, чтобы вы могли в полной мере ощутить пользу от применения логических конструкций и макроопределений, приведу пример двух вариантов (с использованием чисел в форме двоичной и записи логических операций) части кода необходимого для включения/выключения внешних устройств, которые подключены к следующим выводам регистра DR микроконтроллера ARM:

• зелёный светодиод — к 0-му,
• синий светодиод — к 17-му,
• мотор — к 31-му.

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

На Рисунке 27 приведена блок-схема модели микроконтроллера.

Несмотря на то, что это — всего лишь модель, к тому же очень упрощённая, постараюсь на её примере дать функциональную картину МК, в объёме, достаточном для первоначального этапа, и в общих чертах справедливую как для AVR, так и для ARM. Случаи же, когда детали устройства и работы реального микроконтроллера и модели принципиально не совпадают, будут освещены по ходу текущей главы, либо — в практической части статьи.

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

Через этот блок МК общается с внешними устройствами. В контексте этого общения задача программиста заключается в том, чтобы в заданное время на определённом выводе МК:
• обеспечить наличие логических 1 или 0 (напряжения питания или 0В, соответственно),
• считать текущее состояние — логические 1 или 0.
• считать значение аналогового сигнала. Как правило, выводы МК для реализации этой функции жёстко определены. В нашей модели такой вывод — с порядковым номером 0.

Назначение этого блока заключается в непосредственном исполнении задачи, определённой выше:
• Порт ввода/вывода (ПВВ, GPIO) обеспечивает запись на выводы МК, либо считывание с них цифрового сигнала — логических 1 или 0,
АЦП (ADC) измеряет уровень аналогового напряжения на выводе 0 МК.
Таймер считает поступающие на него тактовые импульсы. Зная период такта, мы можем вычислить общую продолжительность счёта, что даёт возможность с точность до одного тактового периода задавать время чтения информации с выводов МК, либо записи на них.

Обратиться к элементам блока периферии (настроить их или записать/считать данные) мы можем только через соответствующие регистры, расположенные в памяти данных ядра.
В реальных МК модулей периферии значительно больше, а их функциональные возможности — шире. Более того, они могут дублироваться (3 порта, 2 таймера, 5 АЦП и т. д.), но всё это не меняет сути дела.

В нашей модели в состав ядра входят:
• Центральный процессор (ЦПУ). Именно для него мы пишем программу. В моменты, когда требуется произвести арифметическую или логическую операцию, ЦПУ привлекает арифметико-логическое устройство (АЛУ).
• Память программ. Сюда загружается написанная нами программа. В нашем случае память состоит из двадцати двух 16-битных регистров с адресами от 0 до 21. Программа это — набор инструкций, понятных ЦПУ. Длина инструкции нашего МК составляет 16 бит, поэтому в каждый регистр может быть записана лишь одна.
Содержимое памяти программ сохраняется даже, если питание МК отключено.
• Память данных. Как следует из названия, здесь хранятся данные. О том, какие именно — чуть позже. Организована память данных в виде 22 регистров, длиной 8 бит каждый.
Информация в памяти данных сохраняется только, если МК запитан, иначе все её регистры обнуляются. В реальном МК при сбросе/отключении питания отдельные регистры памяти данных могут принимать ненулевое значение, определённое производителем.
• Программный счётчик (ПС, PC) содержит адрес инструкции, которую ЦПУ должен исполнить следующей.

Помимо упомянутых блоков на схеме присутствуют:
• Генератор тактовых импульсов (ГТИ). Этот узел запускает работу МК и задаёт её скорость. Единственное, что действительно следует знать о ГТИ реальных МК на начальном этапе, так это — возможность выбора элемента, определяющего его частоту — внутренняя RC-цепочка или внешний кварцевый резонатор. О том, как осуществить этот выбор, мы поговорим в одной из последующих глав.
• 8-битная шина данных, через которую блоки МК обмениваются информацией. Ширина шины данных определяет разрядность МК.
• 16-битная шина команд, по которой ЦПУ считывает инструкции из памяти программ.

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

Как вы видите, первыми в этой памяти расположены два регистра общего назначения (РОН, GPR), знакомые вам r0 и r1. В реальных МК регистров общего назначения — более десяти. Я не случайно выделил эти регистры, ЦПУ и АЛУ одним синим цветом. Дело в том, что ни ЦПУ, ни АЛУ не имеют прямого доступа ко всем остальным, кроме РОН, регистрам памяти данных. Не существует команды для ЦПУ «записать число 5 в регистр данных ПВВ с адресом 3». Для реализации этой операции потребуется минимум две инструкции:
1. Загрузить число 5 в r0.
2. Скопировать число из r0 в регистр с адресом 3.

Точно также АЛУ не может сложить прямо числа, которые записаны, к примеру, в регистрах с адресами 10 и 11 или применить к ним логическую операцию. Для этого необходимо считать числа из указанных регистров в r0 и r1 и уже между ними проводить требуемую операцию.

Следом за РОН идут регистры периферии. В рассматриваемой модели их — по два на каждый модуль (регистр настройки и данных).

Назначение регистров данных следующее:

По сути это — регистр DR из предыдущего раздела главы. Когда мы выводим данные вовне, значение (1 или 0) в n-ном бите регистра данных обуславливает логическое состояние (1 или 0, соответственно) на выводе МК с таким же номером. В случае же чтения данных извне ситуация обратная: логические 1 или 0 на n-ном выводе МК отражаются как 1 или 0 в бите регистра данных c порядковым номером n.

По мере счёта значение регистра данных таймера увеличивается от нуля до 0b11111111 (255), а затем опять сбрасывается в 0. И так — до тех пор, пока тактирование таймера не будет отключено.

Сюда АЦП записывает двоичное представление измеренного на выводе 0 МК значения аналогового напряжения.

Теперь — о регистрах настроек. Биты этих регистров отвечают за следующее:

Если значение n-го бита — 1, вывод МК с таким же номером работает как выход, 0 — как вход.

• 0-й бит. Если значение бита — 1, тактирование таймера включено, 0 — выключено.
• 1-й и 2-й биты. Если комбинация их значений — 00, то частота тактирования таймера равна частоте ГТИ, 01 — частота ГТИ/2, 10 — частота ГТИ/64, 11 — частота ГТИ/128.
• 3-й бит. Если значение бита — 1, разрешено прерывание таймера — сигнала о том, что он досчитал до своего максимума и сбросился в ноль, 0 — прерывание запрещено.
• 4-й — 7-й биты не используются, т. е. зарезервированы.

• 0-й бит. Если значение бита — 1, тактирование АЦП включено, 0 — выключено.
• 1-й и 2-й биты. Этими битами также, как и у таймера, регулируется частота тактирования АЦП.
• 3-й бит. 1 — разрешен сигнал (прерывание) АЦП о том, что измерение завершено и результат преобразования сохранён в регистре данных АЦП. 0 — прерывание запрещено.
• 4-й бит. С записью 1 в этот бит стартует измерение. Значение бита автоматически сбрасывается в 0 по завершению измерения.
• 5-й — 7-й биты зарезервированы.

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

Предположим, что мы решили собрать устройство на базе нашего МК, которое каждые 255 секунд с максимальной скоростью измеряет аналоговый сигнал от фоторезистора, подключённого к выводу 0, и, в зависимости от уровня освещённости, включает/выключает лампу на выводе 4.

Примем частоту ГТИ за 128Гц. Тогда в регистры настроек периферии нужно записать через РОН следующие числа:
ПВВ
0-й вывод МК должен работать как вход (значение соответствующего бита регистра настроек — 0), а 4-й — как выход (значение бита — 1). Поскольку направление работы остальных выводов нам не важно, настроим их как входы. Получаем число 0b00010000.

Таймер
• 0-й бит. Разрешаем тактирование — 1.
• 1-й и 2-й биты. Делим частоту ГТИ на 128, т. е. частота тактирования таймера будет 1Гц. Тогда, чтобы переполниться (досчитать до 255) и выдать прерывание ему понадобится как раз 255 секунд. Комбинация значений битов — 11.
• 3-й бит. Разрешаем прерывание таймера — 1.
Искомое число — 0b00001111.

АЦП
• 0-й бит. Разрешаем тактирование — 1.
• 1-й и 2-й бит. Нам нужна максимальная скорость измерения, т. е. частота тактирования АЦП. Отказываемся от деления частоты ГТИ. Комбинация — 00.
• 3-й бит. Разрешаем прерывание АЦП — 1.
В итоге — число 0b00001001.

Алгоритм программы будет выглядеть так:
1. Настраиваем периферию.
2. В цикле, при каждом прерывании от таймера записываем 1 в 4-й бит регистра настроек АЦП, запуская тем самым измерение освещённости. Чтобы не затереть при этом уже записанное в этот регистр число 0b00001001, применяем логическую операцию ИЛИ и маску 0b000010000.
3. По прерыванию от АЦП считываем значение из регистра данных АЦП. Если оно меньше порогового (которое, например, равно 40), включаем лампу, записав 1 в 4-й бит регистра данных ПВВ, в противном случае — гасим.

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

Бит I. Чуть выше мы говорили о битах в регистрах настройки периферии, разрешавших прерывания таймера и АЦП. Эти биты называют битами локального разрешения прерывания. Бит I — в принципе разрешает использовать механизм прерываний, т. е. это — бит глобального разрешения прерываний, без установки программистом в 1 которого локальные разрешения прерываний силу иметь не будут.
Следует отметить, что бит I — особенность МК AVR. В ARM для глобального контроля за прерываниями выделен целый модуль, называемый Nested Vectored Interrupt Controller (NVIC).

Биты Z и N также доступны программисту как для чтения, так и для записи. Однако, для нас, в первую очередь, интересно их свойство автоматически устанавливаться в 1 в определённых случаях:

Бит Z устанавливается в 1 автоматически, если в результате какой-либо операции АЛУ образуется ноль. К примеру, нам нужно узнать, равно ли значение регистра данных таймера 48. Для этого:
1. Считываем значение регистра данных таймера в r0.
2. Загружаем в r1 число 48.
3. Вычитаем значение одного РОН из другого.
4. Если в результате вычитания Z примет значение 1, числа равны.

К автоматической записи в бит N единицы приводит образование отрицательного числа после какой-либо операции АЛУ, что даёт возможность использовать его для проверки условий «больше-меньше». Если в результате вычитания значений двух РОН бит N устанавливается в 1, вычитаемое больше уменьшаемого, и наоборот. Именно этот бит помог бы нам сравнить текущий уровень освещённости с пороговым в вышеприведённом примере.

Второй специальный регистр в памяти данных — указатель стека SP (Stack Pointer). Функция этого регистра настолько сильно взаимосвязана с работой программы, что будет правильнее, если я расскажу вам о нём ниже, при обсуждении памяти программ. Скажу лишь, что сразу после сброса/подачи питания в SP должен быть записан адрес старшего регистра памяти данных (в нашей модели это — 21), поэтому оба регистра окрашены в единый серый цвет.

Осталось выяснить, для чего служит область памяти SRAM.
Представьте, что в рассмотренном выше примере устройство должно реагировать не на мгновенное значение освещённости, а на среднее от результатов 5 измерений. Куда размещать массив данных до их усреднения?
Для этого и пригодится сектор SRAM, названный кучей (heap). В общем случае данные размещаются по направлению от младшего адреса (10) кучи к старшему (19).

Функция сектора стек (stack) опять же тесно связана с работой программы и о ней — ниже.

Включите всё ваше воображение и представьте, что я, будучи заместителем директора по кадрам, написал и согласовал с руководством круг обязанностей для специалиста вновь открываемого управления. Название должности, кстати, звучит как «Центральная персона управления» (сокращённо — «ЦПУ»).

Согласно документа, названного для солидности «Основная функция (main)», ЦПУ, придя утром на работу должен включить и настроить печатающую машинку, стукнув по ней три раза, а затем в цикле набирать букву «А» или «Б», в зависимости от того, включена сигнальная лампа на стене или отключена. После набора каждой буквы необходимо совершить два прихлопа и три притопа.
Природа одарила меня ленью и, чтобы не повторять два раза инструкции о притопах и прихлопах, я вынес их в отдельный список под названием «Подпрограмма» и в итоге получил следующее:

«Основная функция (main
1. Включить печатающую машинку.
2. Стукнуть по машинке 3 раза.
3. Если сигнальная лампа включена, набрать букву «А». Иначе — перейти к строке 6.
4. Выполнить подпрограмму.
5. Перейти к строке 3
6. Набрать букву «Б».
7. Выполнить подпрограмму.
8. Перейти к строке 3.

«Подпрограмма»
1. Сделать два прихлопа.
2. Сделать три притопа.
3. Вернуться к основной функции и продолжить её.

Во время исполнения инструкций любого из списков могут произойти следующие события (назовём их «прерываниями»):
1. Звон колокола. При этом автоматически настройки печатающей машинки сбрасываются, а сама она — отключается.
2. Лай собаки.
3. Звонок в дверь.
4. Стук в окно.

ЦПУ, при наступлении любого из указанных событий, должен завершить исполняемую инструкцию, затем всё бросить и отреагировать на каждое событие соответствующим образом. Тут я решил несколько усложнить задачу и сделать реакцию на прерывания двухступенчатой, приложив к каждой ступени отдельный список. Первый список — «Вектор прерывания» — состоит всего лишь из одной инструкции, предписывающей перейти ко второму списку, называемому «Обработчик прерывания», причём для удара колокола обработчиком прерывания служит основная функция. Выглядеть всё это будет так:

«Вектор удара колокола»
1. Перейти к началу основной функции.

«Вектор лая собаки»
1. Перейти к обработчику лая собаки.
«Обработчик лая собаки»
1. Мяукнуть.
2. Вернуться к брошенному делу и продолжить его.

«Вектор звонка в дверь»
1. Перейти к обработчику звонка в дверь.
«Обработчик звонка в дверь»
1. Подпрыгнуть.
2. Вернуться к брошенному делу и продолжить его.

«Вектор стука в окно»
1. Перейти к обработчику стука в окно.
«Обработчик стука в окно»
1. Сделать 5 приседаний.
2. Вернуться к брошенному делу и продолжить его.

Функции по меньшей мере странные, нудные и однообразные, а мне ещё надо срочно подобрать под это дело исполнителя. Естественно, выпускники Гарварда, Кембриджа и прочих оксфордов дружно отказываются выполнять работу, которая может бросить тень на их репутацию и дипломы.

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

Опасаясь, что с такими способностями и памятью он рано или поздно натворит бед (перепутает списки, вернётся не к тому делу или просто заснёт на рабочем месте), я принимаю превентивные меры:
1. В помощь ЦПУ придаю бухгалтера-АЛУ для ведения счётных операций.
2. Говорю своему охраннику с тёмным прошлым и кличкой «ГТИ», чтобы он задавал исполнителю и бухгалтеру ритм работы, пиная их со строгой периодичностью.
3. Объединяю все списки в один большой («Программа»), в котором:
• присваиваю блокам названия прежних списков,
• применяю сквозную нумерацию строк (впредь, вместо термина «номер строки» будем употреблять «адрес» или «адрес инструкции»).
• меняю все инструкции перехода на однообразное «Перейти к адресу n».

Критический взгляд на Программу, даёт понимание того, что всё ещё осталась пара моментов, которые могут сбить с толку ЦПУ:
• Инструкция «Выполнить подпрограмму» не указывает, где последняя находится.
• Инструкция «Вернуться» стала короче, чем «Вернуться к брошенному делу и продолжить его», но от этого не стала менее загадочной.

Поэтому, исполнителю передаётся маленький прибор («Программный счётчик» или «ПС»), на дисплее которого отображается:
• на инструкции «Перейти к адресу n» — адрес перехода,
• на инструкции «Выполнить подпрограмму» — адрес первой инструкции подпрограммы (13),
• на инструкции «Вернуться» — адрес возврата,

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

Памятуя, что писать и читать вновь нанятый работник всё же умеет, я вручаю ему пару листков:
• Первый листок (пусть он называется «SRAM») разлинован на 12 строк с номерами или адресами от 10 по 21. При необходимости, ЦПУ может записывать данные (количество сделанных приседаний или промежуточные результаты расчётов бухгалтера, к примеру) по адресам 10-19 («куча»). Но, ему под страхом смерти запрещено использовать две последние строки, окрашенные в серый цвет («стек»).

• Во второй листок («Указатель стека» или «SP») исполнитель должен всего лишь один раз, перед включением печатной машинки, записать старший адрес SRAM (т.е. — 21), о чём в программу добавлена соответствующая инструкция (по адресу 4). Кстати, такая запись называется указанием на вершину стека.

Работа ПС, SP и SRAM определённым образом взаимосвязана:
• При каждом ударе колокола все они обнуляются, вместе с отключением печатной машинки.

• При наступлении каждого из трёх оставшихся прерываний автоматически:
а) адрес следующей после исполняемой в данный момент инструкции записывается в SRAM по адресу, указанному в SP,
б) значение SP уменьшается на единицу.
в) адрес вектора прерывания записывается в ПС

• Каждый раз, когда в программе встречается инструкция «Выполнить подпрограмму», автоматически:
а) адрес следующей инструкции записывается в SRAM по адресу, указанному в SP,
б) значение SP уменьшается на единицу.
в) стартовый адрес подпрограммы (13) записывается в ПС.

• Каждый раз, когда в программе встречается инструкция «Вернуться», автоматически:
а) значение SP увеличивается на единицу.
б) значение в SRAM по адресу, указанному в SP, записывается в ПС.

Чтобы быть спокойным, я решаю промоделировать ту или иную рабочую ситуацию: сажаю ЦПУ, АЛУ и ГТИ в одну комнату, включаю сигнальную лампу и бью в колокол. Как вы помните, ПС, SP и SRAM при этом обнуляются, а печатающая машинка отключается со сбросом настроек.

Итак,
1. ЦПУ видит в ПС число 0 и переходит на этот адрес программы.
2. По адресу 0 — переход на адрес 4. ЦПУ убеждается, что в ПС указан тот же адрес и переходит.
3. Осуществляется запись в SP последнего адреса SRAM (21). ПС при этом увеличивается на 1 — до 5.
4. ЦПУ включает печатающую машинку и настраивает её (инструкции по адресам 5,6).
5. Поскольку сигнальная лампа включена, набирается буква «А».
6. На инструкции по адресу 8 («Выполнить подпрограмму»):
• Адрес следующей инструкции (9) записывается в SRAM адресу 21, поскольку именно это число записал ЦПУ в SP чуть ранее.
• Значение SP уменьшается (декрементируется) на 1, т. е. вершина стека теперь — 20.
• В ПС записывается стартовый адрес подпрограммы (13), куда и отправляется ЦПУ.

7. Сделав в требуемых количествах прихлопы и притопы (не забывайте, что ПС на этих инструкциях просто инкрементируется), ЦПУ подходит к адресу 15 (инструкция «Вернуться») и здесь:
• значение SP увеличивается на единицу — до 21.
• Значение из SRAM по адресу 21 (а там у нас — 9) записывается в ПС.
• ЦПУ выполняет инструкцию по указанному в ПС адресу 9, т. е. переходит к адресу 7. В этот момент я выключаю сигнальную лампу.

8. Поскольку лампа выключена, ЦПУ переходит по адресу 10.
9. Во время набора буквы «Б» раздаётся стук в окно. При этом:
• Адрес следующей инструкции (11) записывается в SRAM по адресу 21 (именно до этого значения увеличился SP в п.7).
• Значение SP декрементируется до 20.
• Адрес вектора стука в окно (3) записывается в ПС.
• ЦПУ завершает печатать букву «Б» и переходит по адресу в ПС — 3.

10. Перейдя по адресу 20 (об этом было указано в инструкции по адресу 3), ЦПУ прилежно приседает, а затем осуществляется возврат к адресу 11 в порядке, описанном п. 7.

Вдумчивый читатель может заинтересоваться, почему ЦПУ запрещено делать записи в строке 20 SRAM, если она в приведённых выше десяти пунктах ни разу не использовалась? Да и без указателя стека можно вполне обойтись: при инструкции «Выполнить подпрограмму» и прерываниях просто записать адрес инструкции, следующей за текущей, в 21-ю строку SRAM, а потом благополучно вернуть в ПС.
Давайте примем такой вариант и представим следующее.
1. ЦПУ набрал букву «А» и перешёл к подпрограмме. Адрес следующей инструкции (9) записывается в SRAM по адресу 21.
2. Во время второго прихлопа (адрес — 13) звонят в дверь и:
• При наличии стека и указателя на него адрес следующей инструкции (14) был бы записан в SRAM по адресу 20. Но, мы от них отказались, поэтому число 14 записывается по адресу 21 затирая предыдущую запись (9).
• Адрес вектора звонка в дверь (2) записывается в ПС.
3. ЦПУ переходит к адресу 2, оттуда — к адресу 18, подпрыгивает и возвращается к адресу, записанному в 21-й строке SRAM, т. е. 14.
4. В это время к шефу компании прибывает иностранная делегация и он решает похвастать перед ними тем, как замечательно работает новое управление.
5. Шумной толпой они вваливаются в комнату и видят, что бухгалтер и охранник, разинув рты, уставились на ЦПУ, который безостановочно притоптывает потому, что после каждых трёх притопов вновь возвращается к адресу 14, а адрес 9, куда он должен был в конце концов вернуться, затёрт.

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

1. Все мы имели дело с детской пирамидкой и помним, что кольцо, надетое последним, снимается первым. Стек, как вы наверняка заметили, работает по тому же методу LIFO (Last In First Out): значение, записанное последним, считывается первым.

2. В нашей модели, с целью упростить объяснение и облегчить его восприятие, размеры стека и кучи чётко определены, а ЦПУ в приказном порядке запрещены записи в стек. В реальных микроконтроллерах нет инструментов (специальной инструкции или аппаратного механизма) для разграничения стека и кучи. Кроме того, запись в стек возможна не только автоматически (при переходах в подпрограмму или обработчик прерывания): и в AVR, и в ARM имеется инструкция PUSH rn, которая сохраняет текущее значение n-го РОН в вершину стека. Таким образом, с ростом объёма сохраняемой информации куча и стек движутся навстречу друг другу и может, в конце концов, произойти их наложение: либо стек «продавит» кучу, либо куча «сорвёт» вершину стека.

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

Избежать их позволят несколько простых правил:
• старайтесь не увлекаться вложенными подпрограммами,
• в программах на ассемблере контролируйте использование инструкций PUSH и парной ей POP.
• в программах на С/С++ не злоупотребляйте глобальными переменными.
• при необходимости сохранения в куче постоянного потока данных применяйте циклический буфер.

Ну что же, вроде как всё работает. Я, как напоминание о моих трудах, вешаю на стену увеличенные копии схемы программы, SP и SRAM, рассаживаю троицу по рабочим местам и делаю фотографию для истории.

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

Перед тем, как закончить с теорией, приведу некоторую информацию по реальным МК, рассматриваемых в данной работе.
Прежде всего — о документации, знакомиться с которой, рано или поздно, вам придётся в любом случае.
В случае с ATtiny85 и ATmega8 вполне достаточно внимательного изучения даташитов.
Для МК на базе ARM информация по ядру и периферии разнесена:

1. STM32F401 Datasheet.
2. STM32F401 Reference Manual.
3. STM32 Cortex-M4 Programming Manual.

1. nRF52832 Product Specification.

1. Cortex-M4 Generic User Guide.
2. Cortex-M4 Technical Reference Manual.
3. ARMv7-M Architecture Reference Manual
Указанные выше документы выложены в архив, и все последующие ссылки будут делаться именно на них.

На рисунке 31. представлены карты памяти ATtiny85 и ATmega8.

Как видите, всё — очень близко к модели из Рисунка 27, за исключением количества регистров.

Память программ
Для обоих МК объём этой памяти составляет 8К или 8 * 1024 = 8192 байт. Организована она в виде массива из 16-битных или 2-байтных регистров в количестве 8192 / 2 = 4096 штук с адресами от 0 (0×0000) по 4095 (0×0FFF). Длина инструкций ATtiny85 и ATmega8, как и в модели, составляет 16 бит, т. е. каждый регистр может содержать лишь одну инструкцию. Набор инструкций, доступный программисту, представлен в Таблице «Instruction Set Summary» на страницах даташита за номером 202 (ATtiny85) и 311 (ATmega8).

Память данных
Регистры памяти данных обоих МК — 8 битные.
Количество регистров общего назначения — 32 с адресами от 0 по 31 (0×001F). Несмотря на то, что РОН имеют адреса, доступ к ним возможен и непосредственно по именам (r0, r1, r16 и т. д.), что обычно и делается.
Следующие 64 регистра памяти данных с адресами от 32 (0×0020) по 95 (0×005F) — так называемые регистры ввода-вывода (Input/Output Registers), включая регистры периферии и спецрегистры SREG и SP.
С адресацией регистров ввода-вывода AVR существует один нюанс. Адреса, указанные выше (0×0020 — 0×005F) — абсолютные. Сдвинув их на 32 позиции, можно получить относительные адреса — от 0 (0×0000) по 63 (0×003F). Соответственно, предусмотрены два набора инструкций для чтения/записи по абсолютным и относительным адресам регистров ввода-вывода.
Полный перечень регистров ввода-вывода и их адреса (абсолютные или относительные) можно узнать из Таблицы «Register Summary» на страницах 200 (ATtiny85) и 309 (ATmega8) даташита. Отмечу, что для ATtiny85 в указанной таблице приведены лишь относительные адреса регистров, а для ATmega8 — и абсолютные (в скобках), и относительные.
Объём SRAM составляет 512 и 1024 байт для ATtiny85 и ATmega8, соответственно.

Обратите внимание на несколько важных цифр, которые будут использоваться нами впоследствии:
1. Старшие адреса SRAM ATtiny85 и ATmega8 — 0×025F и 0×045F, соответственно. Именно эти значения нам предстоит в первых строках программы записывать в SP для указания вершины стека.
2. Младший адрес памяти программ обоих МК — 0×0000. Начиная с этого адреса будет загружаться в микроконтроллер написанная нами программа.

Выдержки из карт памяти STM32F401 и nRF52832, наряду с Cortex M-4, приведены на Рисунке 32.

Компания ARM, как следует из рисунка, определяет границы блоков памяти (периферии, SRAM и программ), выделив на каждый по 0.5G байтов. Производители же МК на базе ядра ARM (в нашем случае — STMicroelectronics и Nordic Semiconductor), не выходя, обычно, за рамки этих ограничений, определяют стартовый адрес и объём каждого типа памяти, требуемый как для удовлетворения потребностей разработанной ими периферии, так и для эффективной работы всего микроконтроллера в целом.
Адресация регистров всех типов памяти — сквозная.
По аналогии с AVR определим наиболее важные для нас адреса карт памяти.
1. SRAM обоих МК имеет объём 64K байт и начинается с адреса 0×20000000.
2. Младший адрес памяти программ STM32F401 — 0×08000000, а nRF52832 — 0×00000000.

13 регистров общего назначения (r0 — r12), и 4 спецрегистра (указатель стека SP, регистр статуса программы PSR, программный счётчик PC и регистр связи LR, о назначении которого вы узнаете позже) в адресном пространстве не отражены и доступ к ним в программе осуществляется, как и в случае с РОН AVR, непосредственно по именам.

Адреса и наименования регистров периферии приводятся в конце раздела по каждому модулю периферии в «STM32F401 Reference Manual» и «nRF52832 Product Specification».
Набор инструкций для обоих МК можно найти в «Cortex-M4 Generic User Guide» (Раздел 3.1 «Instruction set summary»). Кроме того, для STM32F401 эта информация представлена в Разделе 3 «STM32 Cortex-M4 Programming Manual».
В последующих главах нам пригодится следующая информация касательно инструкций, рассматриваемых в данной работе МК.

Для 32-битных МК на базе ядра ARM предусмотрено два набора инструкций:
1. ARM, длина инструкций которого составляет 32 бита.
2. Thumb с инструкциями длиной 16 бит, призванный минимизировать размер программы после компиляции, а следовательно — объём flash-памяти, требуемой для её размещения.
Микроконтроллеры на базе ядра Cortex M-4, в том числе STM32F401 и nRF52832, используют второй набор — Thumb.

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

Говоря «полный», я имею в виду случай, когда программа содержит участки, написанные как на языке Си, так и на ассемблере.

Давайте, не вникая пока в подробности, рассмотрим этапы процесса.

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

2. с— и h-файлы компилируются в ассемблер-файлы. На этом этапе может быть включен и Startup-файл, если он написан на Си и не скомпилирован предварительно. Обычно, Startup-файл содержит код, обеспечивающий подготовительную работу: указание на вершину стека, таблицу векторов прерываний и т. п.

3. Этап ассемблирования. Все имеющиеся файлы с расширением .S преобразуются в объектные файлы с расширением .o.
Опять же, здесь включается Startup-файл, если он написан на ассемблере и предварительно не преобразован в объектный файл.

4. На этом этапе осуществляется компоновка (линковка) всех объектных файлов проекта в единый elf-файл. Помимо созданных в текущем проекте в компоновку могут включаться объектные файлы из других проектов, а также библиотеки (файлы с расширением .a).
Условия компоновки определяются в скрипте с расширением .ld.

5. Полученный в ходе предыдущего этапа elf-файл уже является исполняемым: его используют для отладки — пошаговой проверки работы программы на реальном МК или в симуляторе с целью поиска и устранения ошибок.

6. Происходит окончательное преобразование программы в файл с расширением .hex или .bin, который и загружается в МК (этап 7).

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

🎁dokumentacija-mk.zip
 24.26 Mb ⇣ 77

• Программное обеспечение.zip (443.5 Мб) на облаке yandex.ru
В архивах вы найдёте:

Продолжение следует!
Благодарю за внимание.

Конспект первой лекции по программированию современных микроконтроллеров на примере STM32 и операционной системы RIOT. Лекции читаются в Институте информационных технологий МИРЭА по субботам, с 12:50 в актовом зале на 4 этаже корпуса Д. В занятиях отводится 1,5 часа на саму лекцию и 3 часа на практические занятия в лаборатории IoT Академии Samsung по теме лекции.

Привет, Гиктаймс! Как мы и обещали, начинаем публикацию конспектов лекций, которые сейчас читаются в Институте ИТ МИРЭА. По результатам первой, вводной лекции мы решили немного изменить структуру курса — вместо планировавшихся двух потоков по 5 занятий будет один поток на 7 занятий. Это позволит в более спокойном темпе разобрать ряд вспомогательных вопросов, а также статьи с конспектом будут появляться на GT каждую неделю в течение всего марта и апреля, а не через неделю, как планировалось раньше.

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

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

Цель курса — освоение навыков, позволяющих свободно работать с микроконтроллерами на ядре ARM Cortex-M на современном уровне и, при наличии такого желания, двигаться в сторону дальнейшего углубления своих знаний.

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

Микроконтроллер

Если говорить коротко, то микроконтроллер — это классический пример «системы на чипе», включающей в себя как процессорное ядро, так и набор вспомогательных и периферийных устройств, позволяющий микроконтроллеру во многих случаях быть полностью самодостаточным.

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

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

Микроконтроллеры уже достаточно давно развиваются параллельно с микропроцессорами — так, до сих пор встречающаяся в промышленных изделиях архитектура Intel 8051 была разработана в 1980 году. В каких-то моментах линии их развития начинают пересекаться с микропроцессорами — так, старшие модели микроконтроллеров имеют интерфейсы для внешнего ОЗУ, а производители микропроцессоров интегрируют на кристалл всё больше периферийных устройств (достаточно вспомнить, что на заре «персоналок» даже кэш-память набиралась внешними микросхемами) — но в любом случае они остаются двумя существенно отличающимися ветвями развития.

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

Разумеется, у микроконтроллера есть свои ограничения — технически невозможно упаковать в один кристалл то, что в большом ПК занимает половину немаленькой платы.

  • Рабочие частоты редко превышают 200 МГц, а чаще находятся в районе десятков мегагерц.
  • Объём оперативной памяти — в пределах мегабайта, а чаще — в районе десятков килобайт.
  • Объём памяти программ — в пределах мегабайта, а чаще — в районе десятков-сотен килобайт.

Мы в рамках курса будем работать с микроконтроллерами STM32L151CC, имеющими 32 КБ ОЗУ, 256 КБ ПЗУ и максимальную рабочую частоту 32 МГц (на платах Nucleo-L152RE стоят чуть более серьёзные чипы — 80 КБ ОЗУ и 512 КБ ПЗУ).

Память

В общем случае внутри микроконтроллера может быть четыре вида памяти:

  1. Постоянная память (флэш-память) используется для хранения пользовательских программ и, иногда, некоторых настроек самого микроконтроллера. Если при указании на характеристики микроконтроллера пишут объём памяти, не указывая, какой именно — как правило, это про флэш. Содержимое флэша не сбрасывается при пропадании питания, срок хранения информации в нём в нормальных условиях обычно не менее 10 лет.
  2. Оперативная память используется для выполнения пользовательской программы и хранения «сиюминутных» данных. ОЗУ всегда сбрасывается при перезагрузке или выключении питания, а также может не сохраняться при входе в некоторые режимы сна. В микроконтроллерах часто нет чёткого разделения на память программ и память данных — в результате можно встретить термин «выполнение из ОЗУ», означающий, что в ОЗУ находятся не только данные, но и сама программа; впрочем, это достаточно экзотические случаи.
  3. EEPROM. Тоже относится к постоянной памяти, но существенно отличается от флэш-памяти своими характеристиками. У флэша есть два больших недостатка, делающие его очень неудобным для сохранения из программы каких-то текущих данных — во-первых, у флэша ограниченное число перезаписей одной и той же ячейки, во-вторых, с флэшом часто можно работать только целыми страницами, которые имеют размер в сотни байт, даже если вам надо перезаписать всего один байт. EEPROM этих недостатков лишён — срок его службы обычно вдесятеро больше (от 100 тыс. до 1 млн. перезаписей), и работать в нём можно с каждым байтом по отдельности. По этой причине EEPROM используют для постоянного хранения данных, генерируемых самой программой (архивы измерений, настройки программы и т.п.), его типовой объём составляет единицы килобайт, но есть он не во всех контроллерах.
  4. Системная память. Области постоянной памяти, недоступные пользователю для записи, а записывающиеся при производстве микроконтроллера. Обычно в них находится исполняемый код загрузчика (о нём ниже), но могут также храниться какие-либо калибровочные константы, серийные номера или даже вспомогательные библиотеки для работы с периферийными устройствами

Посмотреть на организацию памяти конкретного контроллера можно в его даташите. Вот, например, даташит на STM32L151CC, на странице 51 которого представлена карта памяти этого семейства.

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

Регистры

Дело в том, что всё — вообще всё — общение со всеми периферийными устройствами микроконтроллера и всеми его настройками осуществляется с помощью всего двух операций:

  • прочитать значение по заданному адресу
  • записать значение по заданному адресу

Всё, что есть внутри микроконтроллера, обязательно отображено на какой-то адрес. Эти адреса называются регистрами (не путайте с регистрами процессора — в регистрах процессорах находятся данные, над которыми процессор производит операции; в регистрах, о которых мы говорим, находятся некие специальные данные, которые специфическим образом отображаются на состояние различных аппаратных блоков микроконтроллера).

Так, например, если мы хотим, чтобы на третьей ножке порта А микроконтроллера (PA2, нумерация идёт с нуля) появилась «1», нам надо записать «1» в третий бит регистра, расположенного по адресу 0x4002014. А если эта ножка настроена как вход и мы, наоборот, хотим узнать, какое на ней значение — нам надо прочитать третий бит регистра по адресу 0x40020010.

Да, чтобы указать контроллеру, входом или выходом является эта ножка — надо записать соответствующие значения в соответствующие биты по адресу 0x40020000.

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

Разумеется, работать с числовыми адресами довольно неудобно, поэтому для каждого микроконтроллера на ядре Cortex-M существует библиотека CMSIS (Cortex Microcontroller Software Interface Standard), самый важный компонент которой для нас — заголовочный файл, описывающий имеющиеся в конкретном контроллере регистры и дающий им относительно человекочитаемые имена.

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

int pin_num = 2; /* PA2*/
GPIOA->MODER &= ~(0b11 << (pin_num*2)); /* сбросили биты настройки ножки PA2 на всякий случай */
GPIOA->MODER |= 0b01 << (pin_num*2); /* установили биты настройки ножки PA2 в 01 — выход */
GPIOA->ODR |= 1 << pin_num; /* установили ножку PA2 в 1 */

GPIOA->MODER &= ~(0b11 << (pin_num*2)); /* сбросили биты настройки ножки PA2, теперь это вход */
uint32_t pa2_value = GPIOA->IDR & (1 << pin_num); /* прочитали состояние ножки PA2 */

Все названия регистров и значения полей в них описаны в документе, который можно считать Библией программиста микроконтроллеров — Reference Manual (он, разумеется, свой для каждого семейства контроллеров, ссылка дана на RM0038, соответствующий семейству STM32L1). Отмечу, что более чем 900 страниц RM0038 — это не очень большой объём информации, легко можно встретить контроллеры с руководствами по 1500-2000 страниц. Вряд ли есть кто-то, помнящий хотя бы треть такого руководства наизусть, но умение быстро в нём ориентироваться — обязательное качество для хорошего программиста.

Разумеется, этот код — лишь условно человекопонятный. Использование буквенных названий вместо адресов радикально снижает процент ошибок в коде и увеличивает его читаемость, но всё ещё крайне далеко от того, что большинство людей назовёт «нормальным» кодом.

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

В случае с STM32 основная библиотека называется Standard Peripherals Library, она же StdPeriphLib, она же SPL. Помимо неё, существует выпускаемая ST библиотека LL, и ряд сторонних библиотек — например, LibOpenCM3. Сторонние библиотеки часто поддерживают и контроллеры других производителей, но в силу распространённости STM32 они обычно оказываются на первом месте.

Так, при использовании SPL обращения к регистрам, которые мы совершали, чтобы зажечь светодиод, превратятся в обращения к функциям GPIO_Init и GPIO_Write.

Впрочем, нельзя не заметить, что среди профессиональных разработчиков отношение к SPL — двойственное.

С одной стороны, SPL позволяет значительно быстрее набросать «скелет» проекта, особенно при использовании графических средств конфигурирования контроллера, таких как STM32 CubeMX. При этом код будет довольно хорошо (настолько, насколько у них совпадает набор периферийных устройств и возможностей, которыми вы пользуетесь) переноситься между разными контроллерами семейства STM32.

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

Кроме того, библиотеки, выпущенные конкретным производителем чипов, хоть и позволяют в каких-то пределах мигрировать между чипами этого производителя, но перескочить, например, с STM32L1 на ATSAMD21 с кодом, написанным для SPL, у вас не получится при всём желании.

Не всегда помогает SPL и читаемости кода — в программах, написанных с её использованием, нередко можно увидеть простыни размером в полстраницы, состоящие из одних только вызовов SPL.

Наконец, SPL решает лишь одну проблему — абстрагирования от «железа» и работы с регистрами. Однако по мере развития проекта вы столкнётесь ещё с несколькими, например:

  • Виртуализация периферийных устройств. Например, в вашем контроллере есть всего один таймер часов реального времени (RTC), на который можно установить два независимых события — и в то же время в серьёзной программе запросто может оказаться пять-шесть функций, который используют таймер регулярно (для периодического выполнения заданий) или разово (например, для отсчёта задержки), причём другие таймеры им не подходят. В этой ситуации вам потребуется функция-менеджер, которая будет организовывать одновременную работу всех этих процедур с единственным имеющимся таймером.
  • Многозадачность. Любая достаточно сложная система быстро обрастает большим количеством всевозможных процедур, которым надо срабатывать с различной периодичностью или по различным событиями. Знакомый многим по Arduino цикл loop() уже на полудесятке утрамбованных в него разношёрстных процедур начинает выглядеть уродливым монстром, а попытка организовать в его рамках ещё и приоритизацию задач вселяет ужас в сердца людей. В этот момент вы захотите вынести все задачи из loop() в независимые функции, оставив в цикле только планировщик, который будет к указанным задачам обращаться. Это будет первыми зачатками многозадачности (о полной её реализации, типах планировщиков и общении между разными задачами мы поговорим на следующей лекции).
  • Разделение труда. Как только разработка системы выйдет на уровень, на котором её ведут несколько человек, перед вами встанет задача разделения обязанностей — помимо оптимизации разработки, имеющая ещё и чисто практический смысл: в мире довольно мало программистов-универсалов, которые могут с одинаковой эффективностью отлаживать и работу с процессором, и сетевой стек, и пользовательский интерфейс. Со значительно большей вероятностью каждый из членов вашей команды будет лучше других разбираться в какой-то одной области — поэтому вам быстро захочется разделить эти области на уровне кода, чтобы, например, специалист по пользовательскому интерфейсу не был вынужден через строчку сталкиваться с обращением к регистрам контроллера, и наоборот. Это приведёт к разбиению вашего кода на отдельные модули, общающиеся друг с другом через стандартизированные API.

Все эти задачи — и попутно ещё много других — решает операционная система.

Несмотря на то, что ОС требует для своего существования дефицитных ресурсов контроллера (обычно 5-20 КБ постоянной памяти и ещё столько же оперативной), преимущества использования ОС настолько велики, что на данный момент в профессиональной разработке для встраиваемых систем около 70 % проектов используют ту или иную ОС.

Строго говоря, на нижнем уровне ОС может использовать вендорские библиотеки, подобные SPL. Однако в рамках нашего курса мы будем работать с RIOT OS, нижнеуровневый код которой для семейства STM32 написан на регистрах — работу с SPL же мы затрагивать не будем вообще.

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

Операционная система

В виде максимально упрощённой схемы ОС можно представить как набор компонентов, выстроенных в определённую иерархию:

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

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

Точнее, если вспоминать фразу Артура Кларка про технологии, неотличимые от магии, то это мышлении можно скорее назвать псевдомагическим, переформулировав афоризм как «любая технология, достаточно хорошо спрятанная от пользователя, становится неотличима от магии».

На самом деле, разумеется, никакой особенной функции меню в Arduino IDE, как и в любой другой IDE, не несёт — это лишь графическая оболочка для доступа к некоторым совершенно стандартным функциям и особенностям современных программных систем.

Если мы посмотрим на то, как выглядит RIOT OS в виде набора файлов на диске, то без труда опознаем разложенные по папочкам компоненты системы: HAL лежит в папке cpu (и если мы её откроем, то увидим описания для десятков различных микроконтроллеров, от AVR до PIC32), описания построенных на этих контроллерах плат — boards, драйверы внешних устройств — drivers, ядро ОС — core, системные и вспомогательные сервисы ОС — sys, пользовательские приложения — examples.

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

Исходные коды ОС

Мы будем работать с исходными кодами в версии https://github.com/unwireddevices/RIOT/tree/mirea — это ответвление от основной разработки RIOT OS, в котором силами Unwired Devices улучшена поддержка микроконтроллеров STM32L1, а также добавлены некоторые полезные сервисы, например, таймеры на базе часов реального времени, включая миллисекундный таймер.

Исходные коды можно загрузить с Github, выбрав кнопку «Clone/Download» и «Download ZIP», но лучшим вариантом будет создание собственного репозитория. Для этого зарегистрируйтесь на GitHub, после чего вернитесь в указанный выше репозиторий и нажмите кнопку «Fork» — исходные коды будут скопированы в ваш аккаунт, откуда вы сможете уже без проблем работать с ними.

Я не буду описывать здесь детали работы с GitHub и Git — в интернете есть масса отличных пошаговых руководств, повторять которые нет смысла.

Компиляция простейшего приложения

Благодаря тому, что ОС берёт на себя всё взаимодействие с микроконтроллером, простейшее возможное приложение в общем-то ничуть не сложнее, чем традиционный «Hello world» на большом ПК:

#include <stdio.h>
int main(void)
{
    puts("Hello World!");
    printf("You are running RIOT on a(n) %s board.n", RIOT_BOARD);
    printf("This board features a(n) %s MCU.n", RIOT_MCU);
    return 0;
}

В структуре нашей ОС это приложение располагается в папке example/hello-world в файле main.c (оно там уже есть).

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

1. Windows 8 и старее. К сожалению, придётся использовать среду MinGW, медленную и неудобную. Процедура установки нужных компонентов достаточно подробно описана здесь. Отмечу, что для работы с Git/GitHub придётся также отдельно поставить Git for Windows, который притащит свою урезанную версию MinGW. При желании всё это можно свести в один терминал MinGW, но проблем с очень низкой скоростью работы MinGW и общим его неудобством это не решит.

В целом, Windows 7 и Windows 8, как можно понять, являются не самым удачным выбором для разработки.

2. Windows 10. Откройте магазин Microsoft Store, найдите в нём Ubuntu и установите. Если при первом запуске Ubuntu будет ругаться на выключенный компонент Windows, откройте системное приложение «Включение или отключение компонентов Windows», найдите там «Поддержка Windows для Linux» и включите.

Вся дальнейшая работа происходит в среде Ubuntu, значительно более комфортной и быстрой, нежели MinGW.

Скачайте компилятор и сопровождающие его утилиты отсюда (внимание: вам нужна 64-битная версия для Linux!), откройте Ubuntu, распакуйте архив и укажите системе пути к нему:

cd /opt
sudo tar xf /mnt/c/Users/vasya/Downloads/gcc-arm-none-eabi-7-2017-q4-major-linux.tar.bz2
export PATH=/opt/gcc-arm-none-eabi-7-2017-q4-major/bin/:$PATH
export PATH=/opt/gcc-arm-none-eabi-7-2017-q4-major/arm-none-eabi/bin/:$PATH
echo "export PATH=/opt/gcc-arm-none-eabi-7-2017-q4-major/bin/:$PATH" >> ~/.bashrc
echo "export PATH=/opt/gcc-arm-none-eabi-7-2017-q4-major/arm-none-eabi/bin/:$PATH" >> ~/.bashrc

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

После этого выполнение в консоли команды arm-none-eabi-gcc —version должно сообщать, что такой компилятор есть, а его версия — 7.2.1 (на текущий момент).

Для работы с Git вам потребуется сгенерировать пару из приватного и публичного ключей командой ssh-keygen, после чего скопировать публичный ключ (в Ubuntu под Windows это можно сделать командой cat ~/.ssh/id_rsa.pub, потом выделить выведенное мышкой и нажать Enter — оно скопируется в буфер обмена) и добавить его в ваш аккаунт GitHub. После этого можно будет работать с GitHub из командной строки командой git.

NB: если вы ранее не работали в командной строке Linux, то обратите внимание на два полезных момента: курсорные стрелки вверх и вниз позволяют листать историю введённых команд, а клавиша Tab дополняет набранный вами путь к файлу или папке до конца (то есть cd /opt/gcc- превратится в строку cd /opt/gcc-arm-none-eabi-7-2017-q4-major). Последнее служит также хорошей проверкой, правильно ли вы набираете путь — если неправильно, дополнен он по очевидной причине не будет. Если возможных вариантов дополнения несколько, то двойное нажатие Tab выведет их все.

NB: в Windows вам будет удобнее работать, если сами исходные коды ОС размещаются в папке, напрямую доступной из Windows, например Documents/git/RIOT. Из-под MinGW она будет доступна по пути /c/Users/vasya/Documents/git/RIOT, из-под Ubuntu — /mnt/c/Users/vasya/Documents/git/RIOT. В этом случае вы сможете свободно пользоваться для работы с кодом, например, текстовыми редакторами, написанными для Windows, такими как Notepad++.

3. Linux. Установка среды сборки ничем не отличается от инструкции для Windows 10, кроме того, что Microsoft Store вам не потребуется. Также не ищите gcc-arm-none-eabi в репозитории вашего дистрибутива — скачайте наиболее свежую версию с его официального сайта.

После установки среды сборки откройте консоль, перейдите в папку с RIOT и в подпапку examples/hello-world, после чего запустите команду make.

Скорее всего, она быстро прервётся ошибкой и сообщением, что у вас не хватает unzip (под Windows 10 по умолчанию он не устанавливается), make или других утилит. В Windows 10 их можно установить командой (перечень недостающего даётся простым списком через пробел):

sudo apt-get install unzip make

После установки попробуйте ещё раз запустить make — точнее, оптимальным вариантом является вызов команды «make clean && make»: первая очищает мусор, оставшийся от предыдущей попытки. Без ней компилятор может ошибочно решить, что какой-то из уже собранных модулей не менялся, и не пересобирать его — в результате вы получите прошивку, собранную из кусков старого и нового кода.

NB: в оригинальном RIOT приложение hello-world собирается для архитектуры native, то есть, в нашем случае ноутбука или десктопа, x86. Однако в нашем коде в параметрах сборки проекта уже указана плата unwd-range-l1-r3, использующая контроллер stm32, поэтому в начале процедуры вы должны увидеть строку

Building application "mirea" for "unwd-range-l1-r3" with MCU "stm32l1".

В случае успеха за ней вы увидите десяток-два строчек, начинающихся с команды make — это сборка отдельных компонентов операционной системы. Закончится всё сообщением об успешном создании файла прошивки mirea.elf с указанием размеров различных типов данных (объёмов используемой флэш-памяти и ОЗУ).

Как-то вот так:

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

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

P.S. И в качестве дополнения — живая 360-градусная запись лекции (запись семинаров не проводилась по понятной причине: «у доски» там говорится мало, значительная часть работы идёт уже с отдельными студентами, у которых что-то получается или не получается):

Прежде чем читать эту статью, рекомендуем вам прочитать статью: Микроконтроллеры

Что подтянуть в си

В наших проектах мы будем использовать
язык программирования Си. Поэтому, вам
необходимо очень хорошо его знать.
Прежде чем читать данную статью,
рекомендуем предварительно подтянуть
знания по этому языку программирования.
В данном разделе мы рассмотрим наиболее
важные для программирования
микроконтроллеров элементы языка Си.
Их надо знать на пятёрку!

Основные типы данных,
приведение типов, числовые константы

Константы

Ни одна программа на Си не обходится
без констант. Константа может быть
определена как переменная с модификатором
const, а может просто быть указана в тексте
программа.

  1. const int i = 10; //констант i, равная 10

  2. #define I 10 //константа I определенная через#define

  3. int b;

  4. b *= 2; //константа 2, сразу указанная втексте

Лучше использовать вместо const, методы
#define. Это позволяет задавать вам константы
при сборке программы, используя make в
сложных проектах.

Когда вы используете числовые константы
не забудьте добавить модификатор типа
этой константы.

  1. 10 //константа типа int !!!! (2 или 1 байт)

  2. 10L //константа типа long (4 байта)

  3. 10U //беззнаковая константа

  4. 10F //число float

  5. 10D //число double

Если этого не сделать, то компилятор
может ошибиться, и не привести её к
нужному типу. Например, 1<< 31 = 0, а вот
1L << 31 = 2 в 32 степени. Программируя
микроконтроллеры, вы всегда должны
помнить о типе каждой константы, каждой
переменной. Это очень важно!

Переменные

Когда вы пишете программу для МК, то
у вас обязательно будут какие-то
переменные. Очень большое значение
имеет тип этих переменных. Вы должны
точно знать сколько бит занимает тот
или иной тип. К сожалению в Си типы
определены не очень строго, например,
тип int может занимать 2 байта или 4 байта.
Как правило, это зависит от компилятора
или выбранной среды программирования.
Поэтому когда вы начинаете работать в
новой среде, обязательно уточните в
документации, сколько байт занимает
каждый тип данных.

Во многих библиотеках на Си для
микроконтроллеров используются
специальные типы, обозначающие точное
количество занимаемых бит. Например:
uint8_t — этот типа, без знаковое (u —
unsigned) целое число (int — integer) длиной 8
бит. Если такие типы определены, лучше
использовать их, чем стандартные типы
int, char и т. д.

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

  1. u8 i = 255;

  2. u16 b;

  3. b = i * 2; //не явное приведение типа, bбудет равно 254!!!! а не 500

  4. b = (u16)i * 2 //явное приведение типа b = 500

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

В МК всегда не хватает памяти, всегда
идёт гонка за производительностью.
Поэтому, большинство ваших переменных,
должно иметь тот тип, с которым комфортно
работает микроконтроллер. Например,
для 32-х битных МК, это u32, для 8-ми битных
u8. Это очень сильно влияет на
производительность вашей программы.

Битовые операции

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

  • i << 10 — сдвинуть значение
    переменной i на 10 бит влево. Если
    представить значение переменной i в
    двоичном коде, то данная операция
    сдвигает все биты влево, на пустое место
    становится 0, старшие биты теряются.
    Пример: 0b10000110 << 2 = 0b00011000.

  • i >> 10 — сдвинуть значение
    переменной на 10 бит вправо.

  • i & 0b111 — логическое умножение,
    работает оно как обычное умножение,
    только применяется к каждому биту.
    Данная операция очень часто используется,
    для выделения из переменной значения
    одного бита в условиях. Например, i &
    0b100, даст нам 0b100, если третий бит равен
    1 и 0 в других случаях, а значит условие
    if (i & 0b100) сработает, когда третий бит
    выставлен в 1. Можно сказать, что данная
    операция является наложением маски на
    значение переменной.

  • i | 0b001 — логическое сложение.
    Работает как обычное сложение, только
    для каждого бита, при этом 1 | 1 = 1, а не
    2. Данная операция используется для
    установки нужных бит в значении в
    единицу, не затрагивая других бит.
    Например, i | 0b100, выставит третий бит в
    единицу.

  • ~ i — логическое отрицание, данная
    операция меняет все 1 на 0. Очень часть
    используется вместе с логическим
    умножением, для снятия нужного бита.
    Например, i & (~ 0b100) обнулит третий бит
    в переменной i.

В некоторых микроконтроллерах
(например, STM32), есть специальные служебные
регистры, установка в которых, определённого
бита в 1, снимает бит в нужном регистре.
Если есть такие особенности, то
предпочтительно использовать именно
эти служебные регистры, а не обычные.

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

  1. typedef struct GPIOx{

  2. u32:0 PIN0;

  3. u32:1 PIN1;

  4. ...

  5. u32:31 PIN31;

  6. } GPIOA;

Такая структура позволит вам удобно
обращаться к битам, через их символьное
описание. GPIOA-> PIN0 = 1;

Для экономии места, очень удобно, для
флажков использовать биты, тогда в одном
байте можно разместить 8 флажков, однако,
для ускорения работы, лучше использовать
родные типы для данного микроконтролера.

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

  1. (u16) i = (a << 8) | b;

Вызов функций по указателю

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

  1. typedef void(*PFN_Callback_t)(void); //создаем типуказатель на функцию

  2. void * PcallBack; //создаем переменную указательна функцию

  3. PcallBack = (void*)(&func1); //записываем впеременную нужную нам функцию

  4. ((PFN_Callback_t)PcallBack)(); //вызываем функцию

Разбираем данные на байты

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

Разберём подробнее как можно «разобрать»
данные на байты.

  1. u8 i;

  2. u16 b;

  3. u32 f;

Введём три переменные. В любой момент
вы можете обращаться к ним как к массиву.

  1. (u8 *) (&b) [0] = 1;

  2. (u8 *) (&b) [1] = 2;

  3. sendUart( (u8 *) (&f)[0]);

  4. sendUart( (u8 *) (&f)[1]);

  5. sendUart( (u8 *) (&f)[2]);

  6. sendUart( (u8 *) (&f)[3]);

  7. (u8 *) (&f) [0] = readUart();

  8. (u8 *) (&f) [1] = readUart();

  9. (u8 *) (&f) [2] = readUart();

  10. (u8 *) (&f) [3] = readUart();

Приводим адрес переменной b к указателю
на массив байт. И обращаемся к первому
и второму байту. Посылаем по UART 32-ух
битное число побайтно и потом собираем
его обратно.

Функции, inline

Для того, чтобы вызвать какую либо
функцию, процессор должен сохранить в
стеке текущее состояние регистров,
записать параметры функции в регистры,
потом осуществить вызов. Это очень
долго. Поэтому, в программировании
микроконтроллеров, очень часто
используется модификатор inline. Он
означает, что весь кусок функции
копируется в код программы без вызова
функции. При этом конечно увеличивается
размер программы, зато уменьшается
скорость выполнения. Если компилятор
поддерживает такую возможность, то
обязательно пользуйтесь ей. Если же её
нет, то применяйте #define. Это конечно
менее удобно, но работает аналогично.

  1. inline void store_char(unsigned char c, ring_buffer *rx_buffer)

  2. {

  3. int i = (unsigned int)(rx_buffer->head + 1) % RX_BUFFER_SIZE;

  4. if (i != rx_buffer->tail) {

  5. rx_buffer->buffer[rx_buffer->head] = c;

  6. rx_buffer->head = i;

  7. }

  8. }

Обычно, это какие-то небольшие функции,
которые должны выполняться быстро,
например, использоваться в прерываниях.

Целочисленная математика и
тип float

В микроконтроллерах используется в
основном целочисленная математика.
Есть отдельные МК, которые имеют FPU
(float point) модуль для проведения расчетов
с дробными числами типов float и double. Если
ваш МК не имеет такого модуля, то вам
стоит избегать данных типов в вашей
программе. Выполнение операций с данными
типами на МК без модуля FPU будет выполняться
очень долго. Например, среднее время
операции умножения дробных чисел на
STM8 занимает 2000 тактов!!!

При работе с целочисленной математикой,
вы должны всегда следить за размерностью
результата, а также быть осторожнее с
операциями деления. Прежде чем делить
результат на что-то, для повышения
точности, вы должны сначала провести
все операции умножения. Например,
результат будет разный, если (2 / 100 * 100)
и (2 * 100 / 100). В первом случае вы получите
0, потому что, 2 меньше 100 и первая операция
деления даст 0. А во втором случае получите
единицу, потому что 200 / 100 = 1. Все операции
деления в целочисленной математике не
имеют остатка!

Если вам нужно приводить к общему виду
какие либо единицы измерения, то делать
это необходимо в самом конце, когда
выводим информацию пользователю,
особенно это касается операций с
суммированием, иначе вы потеряете
точность. Для повышения точности
используйте умножение на большое число,
а потом поделите на него. Этот приём
очень часто используется в программах
на МК. При этом, для умножения лучше
использовать числа степени 2, а не десятки
и тысячи, так как операции умножения на
степень 2, это простой сдвиг битов,
выполняется гораздо быстрее чем умножение
на 100.

Допустим, нам надо посчитать ток,
которое потребляет устройство. У нас
есть датчик тока. Каждую секунду мы
знаем мгновенный ток. Чтобы посчитать
ток, потраченный за минуту, надо каждую
секунду делить его на 60 и сложить
полученный результат. Но, в целочисленной
математике, вам надо сложить все
результаты, а потом поделить на 60. То
есть, правильнее будет сделать так:

  1. //вводим две переменные! Для секунд идля тока!

  2. u32 tok;

  3. u32 sec;

  4. //каждую секунду добавляем ТОК, иничего не делим.

  5. sec++;

  6. tok+= gettok();

  7. //когда надо выдать результат, тутбудем делить

  8. sendLCD( tok / sec);

  9. {ccc]

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

Основная модель программирования МК

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

  1. #define …

  2. //секция 1

  3. int main() {

  4. //секция 2 - инициализация

  5. while (1) {

  6. //секция 3 - основной цикл

  7. };

  8. }

Это не случайное сходство для различных
сред программирования МК. В данной мини
программе заложена основная модель
программирования всех микроконтроллеров.
Любой микроконтроллер работает после
включения в бесконечном цикле. И вы
должны это запомнить. Любая программа
для МК никогда не заканчивается. После
подачи напряжения, инициализации и
старта, МК работает непрерывно, выполняя
бесконечно Основной цикл программы.

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

Существует две методики программирования
микроконтроллеров:

  • используется только основной цикл
    — polling (опрос)

  • используются прерывания — interrupt

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

Инициализация

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

Данный код выполняется один раз, при
включении микроконтроллера. Далее
микроконтроллер переходит в основной
цикл.

Стадия инициализации длится очень
быстро — несколько микросекунд, но по
меркам схемотехники, это очень долго.
Такого времени вполне достаточно, чтобы
сгорели внешние транзисторы или другие
детали. Когда проектируете своё
устройство, имейте это ввиду. Все важные
узлы схемы должны работать при полном
отсутствии микроконтроллера! Например,
если у вас H
— мост управления двигателем, то
обязательно должны быть резисторы
подтяжки на транзисторах, которые
обеспечат нужный уровень без работающего
микроконтроллера. Если вы управляете
пищалкой, то транзистор должен иметь
подтяжку к GND, чтобы без МК транзистор
не остался в неопределённом состоянии.

Если вам важно энергопотребление
(например, при питании от аккумулятора),
то необходимо позаботится об этом на
стадии инициализации. Если вы не
используете какую либо периферию, то
необходимо отключить ее, через специальные
регистры. Если у вас остались свободные
выводы у МК, необходимо перевести их в
Push Pull режим и перевести в выдачу Low
сигнала, они не должны оставаться в
неопределённом состоянии. Если вам не
нужна быстрая обработка данных, надо
снизить частоту работы ядра. Все это
делается один раз на этапе инициализации.

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

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

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

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

Основной цикл (polling)

После того как все модули МК
инициализированы, настройки прочитаны,
начинается собственно ваша программа.

Микроконтроллеры сделаны для того,
чтобы автоматизировать какие-то процессы,
занимая как можно меньше места, и делая
это как можно быстрее, экономичнее. Для
лучшего понимания методик программирования
МК, рассмотрим некий просто прибор —
электронный термометр. Примерно так
будет работать его программа в основном
цикле.

  1. Прочитаем данные с датчика
    температуры

  2. Произведём необходимые вычисления,
    чтобы привести эти данные к читаемому
    виду

  3. Выведем их на экран

  4. Перейдём к п.1

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

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

В данном разделе мы не будем рассматривать
то, как МК получает данные от датчика,
по какому-то протоколу с использованием
периферии, или читая данные с вывода
МК. Это будет описано позже. Здесь важно,
чтобы вы поняли, как необходимо разделять
потоки данных в вашей программе.

Основной цикл должен быть написан
максимально без задержек, без использования
функций типа Delay(), с чётким разделением
всех потоков по временным интервалам.
Применительно к нашему термометру,
подумаем, как часто нам надо считывать
данные с датчика температуры? Если
процедура чтения данных будет непрерывно
вызываться в основном цикле, то это, при
работает МК на частоте 16Мгц, даст нам
более 1 млн опросов датчика в секунду.
Надо ли нам столько раз его опрашивать,
сможет ли датчик выдавать информацию
так часто. Об это вы должны думать каждый
раз когда пишите какую либо процедуру
опроса в основном цикле. То есть первое,
что необходимо, это определить частоту,
с которой должен выполняться тот или
иной кусок программы. Основной цикл
должен состоять из большого количества
простых условий, вы все время должны
проверять различные флажки — состояние
выводов, состояние битов в регистрах
периферии, значения переменных, эти
операции выполняются очень быстро и
большую часть времени работы МК, он
должен проверять эти флажки. И только
в момент, когда это необходимо предпринимать
какие-то действия, которые могут
выполняться долгое время.

Теперь уточним нашу задачу. Пусть
датчик температуры выдаёт данные 10 раз
в секунду. Наша программа изменится
следующим образом.

  1. if (timedatchik==0) {

  2. timedatchik = 100;//ms

  3. temp = getdatadatchik();

  4. temp *= 100;

  5. }

  6. if (temp != temoold) refreshlcd();

Мы будем засекать время 100мс, чтобы
получить частоту 10 раз в секунду, и будет
опрашивать датчик только 10 раз в секунду.
Выводить на экран температуру нам тоже
незачем каждый цикл, поэтому и вывод
информации необходим только тогда,
когда поступили новые данные.

А теперь немного отвлечёмся от нашего
примера и рассмотрим конструкции,
которые наиболее часто используются в
основном цикле.

а) Выполнение программы только при
изменении состояния датчика.

  1. while(1) {

  2. kn = getdata();

  3. if (knold != kn) {

  4. ….

  5. }

  6. knold = kn;

  7. }

Создаем две переменные knold и kn, записываем
новые данные в kn, сравниваем значение
старое с текущим, в конце цикла запоминаем
текущее значение kn в переменную knold.
Таким образом, мы можем отследить
ситуацию изменения значения датчика.

б) выполнение программы с заданной
периодичностью:

  1. if (timedel == 0) {

  2. ...

  3. timdel = 10;

  4. }

Переменная timdel уменьшается каждую
миллисекунду или секунду, когда она
равна нулю, то делаем что-то и опять
начинаем новый отсчет времени. Так можно
программировать работу по временным
интервалам.

в) выполнение программы по установленному
флажку:

  1. if (flagset) {

  2. flagset=0;

  3. }

Проверяем установлен ли флажок, если
да, то делаем что нам нужно, и сбрасываем
флаг. Сам флаг устанавливается в другом
месте программы.

г) простой автомат

  1. switch (status){

  2. case 10:

  3. ...

  4. break;

  5. case 20:

  6. ..

  7. break;

  8. case 30:

  9. ..

  10. break;

  11. default:

  12. }

Теперь мы можем описать схему работы
в основном цикле МК.

  1. С нужной периодичностью получаем
    данные с датчиков

  2. В случае изменения данных датчиков,
    проводим нужные вычисления

  3. При необходимости производим
    управляющее действие, что-то выводим
    на экран, воспроизводим музыку и т. д.

  4. Периодически производим какое-то
    действие, например, мигаем светодиодом

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

  1. void getdatadatchik() {

  2. sendbyteI2C(10);

  3. while (bytesending);

  4. sendbyteI2C(10);

  5. while (bytesending);

  6. while (bytereceive==0);

  7. temp = I2Creg->data;

  8. }

Мы останавливаем основной цикл, пока
не получим все данные. Бесконечные
задержки while служат для того, чтобы
дождаться выставленного флажка в
какой-то регистре. И дождаться этого
необходимо именно сейчас, а не наследующем
цикле. Потому что, мы можем узнать о том,
что байт получен слишком поздно, когда
выйдет таймаут, и датчик уже перестанет
передавать данные. В итоге, так или
иначе, в основном цикле начинают возникать
задержки, которые тормозят основную
программу. Ваша задача так расписать
периоды и частоту вызова тех или иных
операций, чтобы одно не мешало другому.
Далее мы рассмотрим, как в этом помогают
прерывания.

Прерывания

Прерывание
(англ. interrupt) — сигнал от программного
или аппаратного обеспечения, сообщающий
процессору о наступлении какого-либо
события, требующего немедленного
внимания. Мы не будем вдаваться в тонкости
работы механизма прерываний. С точки
зрения программирования МК на Си, нам
будет достаточно понимания того факта,
что в каждом микроконтроллере есть
возможность, при наступлении определённого
события приостановить выполнение
основной программы и передать его в
специальную функцию, обработчик данного
прерывания. Набор событий зависит от
конкретного микроконтроллера, они
подробно описаны в datasheets. Рассмотрим,
как можно использовать прерывания в
вашей программе.

Для лучшего понимания, решим простую
задачу — есть кнопка, при её нажатии
надо зажечь светодиод. Если мы будем
эту задачу решать методом опроса
(polling), то мы получим примерно такую
программу

  1. knold = 1;

  2. while (1) {

  3. kn = PORTB->PIN1;//считали состояние кнопки

  4. if ((kn == 0) && knold) {

  5. PORTB->PIN2 = 1;//зажгли светодиод

  6. }

  7. knold = kn;

  8. }

В основном цикле мы все время опрашиваем
состояние вывода PIN1, и как только там
станет 0 (кнопка нажата), то зажигаем
светодиод. Основной минус в таком
подходе, что в основном цикле мы все
время опрашиваем состояние кнопки, а
так как в основном цикле, могут попадаться
и длительные операции, то мы можем
пропустить нажатие кнопки! Если МК занят
в этом время, например, выводом на экран
какой-то информации. Прерывания как раз
и предназначены для того, чтобы убрать
этот минус.

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

Обработка прерываний построена по
принципу очереди. Все прерывания
распределены по приоритетам производителем
МК, а также есть возможность программно
поменять его для каждого прерывания.
Вам обязательно надо понять этот механизм
и научится им пользоваться. Для этого
представим следующую ситуацию, в один
и тот же момент времени, сработало
прерывание по изменению состояния входа
PIN1 — пр1 (приоритет 1), прерывание о
готовности данных ADC — пр2 (приоритет
2), прерывание таймера TIM1 — пр3 (приоритет
2). Как же МК поступит в этом случае?

Во первых, МК отсортирует все эти
прерывания по приоритету, далее по
времени срабатывания и далее просто по
внутреннему номеру прерывания —
пр1,пр2,пр3. После этого он поставит их
всех в очередь, и передаст управление
функции, обработчику прерывания пр1.
Когда его обработка закончится, то
следующему в очереди, и так, пока не
обработает все прерывания. У каждого
прерывания в очереди есть бит отложенного
прерывания (pending bit ), прерывание удаляется
из очереди, только когда этот бит будет
снят. Как правило, снять этот бит вы
должны в обработчике прерывания
самостоятельно. Это сделано специально,
для того, чтобы МК был уверен в том, что
прерывание обработано. Также, такая
схема позволяет обрабатывать в одном
обработчике несколько различных видов
прерываний с разными отложенными битами.
Во многих МК есть возможность обрабатывать
вложенные прерывания, как правило это
включается программно. В этом случае,
другое прерывание более высокого уровня
может прерывать обрабатываемое. Механизмы
реализации тут могут быть разные, более
точно необходимо читать в datasheet. Если
вам необходимо заняться тонкой настройкой
приоритета прерывания, то представьте,
что обработчики каждого прерывания
выполняется очень долго, и вам надо
выяснить остановка какого прерывания
может стать для него критической.
Приоритет такого прерывания надо сделать
самым высоким. И так далее.

Теперь вернёмся к нашей задаче про
кнопку. С использованием прерываний
программа будет выглядеть так.

  1. void INT1(void) { // обработчик прерывания

  2. if (PORTB->PIN1==0) {

  3. kn = 1;

  4. }

  5. }

  6. void main(void) {

  7. while (1) {

  8. if (kn) {

  9. PORTB->PIN2 = 1;//зажгли светодиод

  10. kn=0;

  11. }

  12. }

  13. }

Функция INT1 назначена на обработку
прерывания по изменению значения порта
«B». В обработчике прерывания мы проверяем,
что кнопка нажата и выставляем флажок.
Уже в основном цикле, мы проверяем, что
флажок установлен и делаем нужное
действие. Можно ли зажечь светодиод не
в основном цикле, а сразу в обработчике
прерывания? Конечно да, но вы должны
понимать, что обработчик прерывания
должен выполняться максимально быстро,
чтобы не задерживать другие прерывания.
Поэтому, там должны быть только простые
операции, все сложные вычисления лучше
оставить на основной цикл. Внутри
прерывания можно смело управлять
выводами, читать или записывать
переменные, производить не сложные
операции. Внутри прерываний желательно
не использовать вызовы других функций,
нельзя использовать функции зависящие
от прерываний (например, Delay).

Прерывания — очень удобный механизм,
как правило его используют вместе с
основным циклом. Если вы пишите код, на
границе возможностей МК по скорости,
то вы должны обязательно прочитать в
datasheet сколько тактов МК тратит на вход
в прерывание — это может быть от 8 до 16
тактов! Как видите — не мгновенно. Также
надо понимать, что вести отладку
прерываний в реальном времени, не просто,
пока отладчик остановится, могут
измениться состояния регистров. Поэтому,
если вам необходимо понимать сколько
времени прошло от события до его
обработки, то необходимо использовать
debug pin — выводы выделенные для отладки.
Самое быстрое, что вы можете сделать —
это поменять состояние вывода. И вот
это событие, уже необходимо анализировать
с помощью осциллографа или логического
анализатора — внешнего устройства.

Мы рекомендуем, там где это возможно,
использовать прерывания. Это делает
вашу программу более простой и понятной,
разгружает процессор и позволяет делать
код многопоточным.

Спящий режим

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

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

  • Полная остановка — halt режим,
    наименьшее энергопотребление, как
    правило работают только внешние
    прерывания, именно они и будят МК,
    например, нажатие кнопки.

  • Остановка с активным таймером
    пробуждения — active halt, дополнительно
    работает таймер, который может будить
    МК через определённые интервалы времени.
    Такой режим очень подходит, например,
    для МК, обслуживающего датчик. Прочитали
    датчик, передали сообщение и опять
    уснули. При этом, надо понимать, что МК
    живёт в мире микросекунд. Если он
    поработает 10 миллисекунд, а спать будет
    900 миллисекунд, то потратит одну сотую
    часть от энергии, постоянной работы.
    При этом каждую секунду он будет на
    связи.

  • Остановка с работающей периферией
    — wait режим, позволяет спать ядру, пока
    периферия работает. Например, дали
    задание измерить напряжение, и спать.
    Результат готов, проснулись, обработали.

Как правило, спящий режим активируется
программно. Если вы делаете устройство
на аккумуляторе или батарейкам, то вам
обязательно необходимо изучить какие
режимы энергопотребления имеет ваш
выбранный МК. И обязательно надо
использовать их в программе.

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

Управляем выводами GPIO

GPIO — это самый простой, и одновременно
самый важный модуль МК. В основном вся
работа с ним сводится к двум операциям:

  • при включении МК надо инициализировать
    параметры каждого вывода (конечно это
    можно делать не только при включении
    МК, но и в процессе работы программы)

  • при работе надо, или прочитать
    один бит из регистра отвечающего за
    порт МК, или записать бит

Обычно все выводы МК сгруппированы в
порты, и практически все они доступны
программно (за исключением специальных
выводов — VDD, GND, RESET — иногда и он
доступен). Если вы составляете схему
самостоятельно, обязательно прочитайте
Datasheet на каждый используемый вывод. Во
многих МК, не все выводы одинаковые, и
некоторые из них имеют большое количество
ограничений. Например, STM8S003, выводы I2C,
SDA SCL нельзя перевести в HIGH, они работают
только как Open Drain вывод. Таким образом,
на них нельзя повесить кнопку без
внешнего резистора. Обычно вывод, на
который подключается внешний кварц,
тоже имеет ограничения. Тоже самое
касается RESET. Ограничение может иметь
вывод программирования МК.

Настройки выводов у каждого МК свои.
Но есть общие правила. Каждый вывод
может:

  • быть обычным выводом OUTPUT — то есть
    выдавать 0 или 1 по вашему требованию,
    иметь PULL UP резисторы, быть выходом Open
    Drain, быть в неопределённом состоянии

  • быть входом INPUT — то есть иметь
    возможность программно прочитать
    состояние выхода, обычно в этом случае
    используется триггер Шмитта.

  • иметь альтернативную функцию —
    служить выводом какой либо периферии,
    например, UART RX, SPI MOSI и т. д.

Настройка выводов производится через
специальные регистры. За каждый вывод
обычно отвечает один бит. Для упрощения
работы с МК, чтобы не помнить все выводы,
производители разрабатывают специальные
библиотеки, в которых, или делаются
специальные дефайны (define) для удобства,
или функции для настройки вывода. С ними
программирование вывода становится
простым, а программа читаемой. Вот как,
например, настраивается вывод при
использовании библиотеки от компании
Nuvoton.

  1. GPIO_SetMode(P4, BIT2, GPIO_PMD_OPEN_DRAIN);

а обращение к выводам может быть такое

  1. P43=1; //порт 4, 3 нога в HIGH

  2. P42=0; //порт 4, 2 нога в LOW

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

  1. #define BTON PORTB->10 = 1;

  2. #define BTOFF PORTB->10 = 0;

  3. main {

  4. BTON

  5. while (1) {

  6. if( flag ) BTOFF

  7. }

  8. }

Прочитать состояние вывода на Си тоже
просто. Надо прочитать состояние нужного
бита в регистре, отвечающем за порт.
Самая быстрая битовая операция (с точки
зрения количества тактов) на МК — это
установка в единицу одного бита —
логическое сложение, поэтому во многих
МК, есть специальные регистры, в которых
надо установить бит в 1, чтобы задать на
выводе состояние 0. Это сильно ускоряет
операции с выводами.

Каждый вывод МК имеет ограничения на
максимальную частоту меандра.
Например, в STM8 максимальная частота
меандра на выводе может быть 2 или 10 Мгц.
Чем выше частота, тем больше
энергопотребление! Поэтому, не следует
выбирать максимальную частоту там, где
это не надо. В STM32, чтобы получить частоту
выше 50Мгц, сделаны специальные ячейки
для подзарядки выводов, которые тоже
потребляют энергию и их надо специально
включать. Также надо иметь ввиду, если
частота ножек будет больше 50Мгц, то
потребуются специальные решения при
трассировке платы, с меньшими частотами
проблем не должно возникать. Как правило,
программно получить такой меандр на
выводе не получится, в основном это
возможно только при использовании
периферии, например таймеров.

МК имеет на борту большое количество
различной периферии. Но производитель
не знает, какая периферия будет вам
нужна. Поэтому, он оставляет это на ваш
выбор. Каждый вывод МК ценный и может
служить обычным выводом. Но если вам
нужна периферия, то можно задействовать
определённые выводы под периферию. В
последних моделях МК, периферию можно
подключить практически любой вывод МК,
на младших, она закреплена на фиксированных
выводах. Конечно, это очень удобно, когда
можно использовать любые выводы, это
сильно упрощает трассировку платы.
Поэтому, выбирая МК под проект, имейте
это ввиду. Иногда производитель предлагает
выбрать из нескольких вариантов выводов
для одной периферии. Будьте очень
внимательны при таком выборе выводов,
может оказаться так, что назначая на
определённый вывод UART RX, вы теряете
какую-то другую периферию. На текущий
момент существуют специальные визуальные
конструкторы (см. в конце статьи), которые
позволяют упростить этот процесс,
практически на все серии МК. Обязательно
используйте их, но не доверяйте на 100
процентов, проверяйте по datasheet. Тут лучше
10 раз проверить, один отрезать.

Используя вывод для какой то периферии,
производитель, как правило, оставляет
возможность настроить сам вывод как
вам надо (по любому варианту вывода).
Например, ножка UART TX, может иметь режим
Open Drain (или PULL UP), тогда 1 в TX будет означать
неопределённое состояние ножки, а вовсе
не HIGH! Это очень удобно использовать
при согласовании уровней. Open drain режим
можно использовать для управления
выводом с подтяжкой к другому напряжению,
отличному от питания МК. Например, МК
питается от 3в, а Open drain будет управлять
5В или 1.8В.

Отдельно стоит сказать про выводы,
толерантные к напряжению 5В. Например,
если МК имеет напряжение питания 3.3В и
есть необходимость взаимодействовать
с датчиком или другой микросхемой,
которая имеет напряжение питания 5В.
Различное напряжение питания элементов
схемы порождает проблемы согласования
уровней, что требует схемного решения.
Производители МК специально, для
упрощения таких решений, делают часть
выводов толерантными к 5В. Это говорит
о том, что МК не «сгорит», если подать
на эти ножки 5В, вместо 3.3В. В случае HIGH
состояния вывода у МК на выводе будет
около 3.3В. Такое напряжение воспринимается
пятивольтовыми датчиками как логическая
единица. Это все позволяет не делать
схемных решений по согласованию уровней.
Будьте ВНИМАТЕЛЬНЫ — не все выводы
толерантны к 5В, а только некоторые.
Внимательно читайте Datasheet.

Кнопки

Кнопки — самый часто используемый
элемент в приборах. Ничего сложного в
их обработке нет. Как правило кнопка
подключается одним выводом на GND, а
другим к выводу МК. Таким образом, при
нажатии, вывод соединяется с GND. Чтобы
обработать такой вариант кнопки,
необходимо вывод установить в режим
работы INPUT PULLUP. То есть, когда кнопка не
нажата на выводе должна быть логическая
единица, из-за резистора подтяжки к VDD.
Если кнопка подключена длинными
проводами, то внутренней подтяжки может
не хватить, надо использовать внешний
резистр.

Обрабатывать кнопки можно как в
основном цикле, так и с помощью прерываний.
В начале разберём как это делать с
помощью прерываний. В разных МК, прерывания
на выводах работают по разному. Если
есть такая возможность, лучше настроить
срабатывание прерывания по падающему
сигналу FALL (то есть в момент когда HIGH
меняется на LOW), если такое не возможно,
то по изменению сигнала PIN CHANGE (в этом
случае прерывание будет срабатывать и
при нажатии и при отпускании кнопки).
Как правило, обработчик прерывания один
на весь порт, и если у вас подключено
несколько кнопок к выводам одного порта,
то в обработчике прерывания надо
проверить, какой вывод равен нулю, чтобы
узнать какая кнопка сработала. Далее
все просто, устанавливаем флажок, что
кнопка нажата, и в основном цикле или
сразу делаем нужное действие. Код на Си
будет выглядеть примерно так.

  1. void INT1(void) {

  2. if (PORTB->PIN1 == 0) {

  3. kn = 1;

  4. }

  5. }

  6. main() {

  7. while (1) {

  8. if (kn) {

  9. kn = 0;

  10. }

  11. }

  12. }

Чтение порта сбрасывает Pending бит
данного прерывания. В случае обработки
кнопки в основном цикле, нужна будет
переменная knold, и программа будет
выглядеть примерно так.

  1. main() {

  2. while(1) {

  3. kn = PORTB->PIN1;

  4. if (kn==0 && knold) {

  5. //...

  6. }

  7. knold = kn;

  8. }

  9. }

Однако, если вы сделаете первый или
второй вариант кода, то работать это
будет не совсем верно. Проблема в дребезге
контактов. При замыкании контактов, в
кнопке или выключателе, контакты
замыкаются не мгновенно, в момент
замыкания гибки контакты начинают
вибрировать, возникают помехи, и
микроконтроллер видит не одно нажатие
кнопки, а сразу несколько. Обычно это
происходит не дольше 50 миллисекунд.
Таким образом, чтобы корректно обработать
нажатие кнопки, необходимо в случае
использования прерываний отключить
прерывание на данном выводе на 50
миллисекунд, а потом включить обратно.
При использовании основного цикла,
опрос кнопок надо делать каждые 50 мс. А
ещё лучше опрашивать кнопки в прерывании
таймера, каждые 50 миллисекунд, так будет
решена проблема дребезга и вы не
пропустите нажатие кнопки. Дополнительно
можно убедится, что это не наводки от
помех, и после задержки ещё раз проверить
состояние кнопки, и если она все ещё
нажата, то это не помехи, кнопку можно
считать нажатой. Ещё хороший метод,
несколько раз в цикле прочитать состояние
кнопки, и если все разы состояние кнопки
одинаковое, то считаем что кнопка нажата.

Если вы только начинаете работать с
МК, то обязательно поработайте с кнопками.
Типичные задачи, которые вы должны уметь
решать.

  • По нажатию кнопки зажечь светодиод,
    по следующему нажатию выключить

  • По нажатию зажечь, по длительному
    нажатию выключить (3 секунды)

  • По двойному нажатию зажечь и
    тройному нажатию выключить (в течение
    2 секунды 2 нажатия или 3)

Для отслеживания двойного и долгого
нажатия нужна переменная таймер, для
учёта времени, прошедшего с начала
нажатия. Для двойного нажатия также
нужен счётчик. Логика простая. Например,
выделяем время 2 секунды. Кнопка нажата,
счётчик плюс один. Если прошло две
секунды, то обнулили счётчик. Если
счётчик равен 2, то зафиксировано двойное
нажатие. Для долгого нажатия, запускаем
таймер после нажатия, и если до его
окончания кнопку отпустили, то сбрасываем
все и опять ждём нажатия.

Стоит отметить, что кроме программной
защиты от дребезга контактов есть
схемные решения, самое простое — наличие
параллельно выводам кнопки конденсатора
на 100nf. Кнопки очень удобно использовать
для вывода МК из спящего режима, для
этого достаточно настроить обработку
прерываний по нажатию кнопки.

Таймеры

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

Для начала, разберём самый простой
вариант использования таймера — счётчик
миллисекунд. Нам необходимо, чтобы
переменная milis содержала количество
миллисекунд, прошедшее с запуска таймера.
Так как, мы считаем миллисекунды, то тип
переменной сделаем unsigned long, чтобы быстро
не наступило переполнение. Вся работа
с таймерами ведётся с помощью прерываний,
потому что, очень сложно поймать путём
опроса состояния таймера момент перехода
таймера через 0, хотя это тоже возможно.
Разберем как решается данная задача с
помощью прерываний.

Первым делом, необходимо произвести
настройки таймера. Потом, запустить
его, и далее в обработчике прерывания
останется только добавлять единицу к
нашей переменной. Так как нам надо
учитывать миллисекунды, то необходимо
настроить таймер так, чтобы прерывание
вызывалось каждую миллисекунду. Как мы
ранее описывали в статье про
микроконтроллеры,
таймер — это простой счётчик, который
с каждым тактом МК уменьшается (или
увеличивается) на единицу. Основные его
параметры — предделитель PSCR (pre scaler
register) и максимальное значение счетчика
ARR (auto reload register), которое ограничено
разрядностью таймера. Необходимо,
используя эти два параметра и настроить
период срабатывания прерывания. Допустим
частота МК 16 МГц и таймер работает на
этой же частоте (таймер может работать
на частоте отличной от частоты МК). Чтобы
прерывание срабатывало каждую
миллисекунду, необходимо сделать так,
чтобы таймер досчитал до 16 000 и вызывал
прерывание. Для этого достаточно
установить ARR таймера в 16 000. Для такого
большого числа нужно 16 бит. Если таймер
имеет такую разрядность, то достаточно
в ARR записать (16000 — 1), обычно таймер
начинает считать с нуля. Однако, такое
не всегда возможно, разрядность ограничена
производителем МК. На помощь приходит
предделитель. По сути, в этом случае
получается два связанных таймера.
Сначала, каждый такт, предделитель
уменьшается на единицу, и только когда
он дойдет до 0, увеличивается счетчик
таймера CNT (counter), когда его значение
дойдет до ARR, то сработает прерывание.
Например, если предделитель будет равен
15 (16-1), то в ARR надо записать уже не 16 000,
а 16 000 / 16 = 1000. А если предделитель равен
160, то уже 100 надо записать в ARR. Таким
образом, можно пользоваться таймерами
и с низкой разрядностью. Естественно,
такой таймер не позволит учитывать
другой интервал времени, например,
каждые 999 миллисекунд. На Cи таймер
программируется очень просто, для
предделителя и ARR, есть специальные
регистры, надо в них записать нужные
значения и все.

  1. TIM1_PSCR = 160; //предделитель

  2. TIM1_ARR = 100; // максимальное значениетаймера

Для запуска таймера необходимо в
специальном регистре установить в 1
бит. Дополнительно, может понадобиться
включить питание на периферию таймера
TIM1. Если нам необходимо, то надо включить
обработку прерываний таймера — установить
в единицу нужные биты в регистрах. Если
вы работаете с библиотекой, то там могут
быть готовые функции, которые сразу
настраивают таймер. Например, на stm8 это
выглядит так:

  1. TIM1_TimeBaseInit(TIM1_PRESCALER_16, 1000);

  2. TIM1_ClearFlag(TIM1_FLAG_UPDATE);

  3. TIM1_ITConfig(TIM4_IT_UPDATE, ENABLE);

  4. TIM1_Cmd(ENABLE);

Первая команда настраивает таймер,
задает предделитель и значение таймера.
Следующая команда сбрасывает таймер
на 0, а следующая запускает обработку
прерываний. Последняя — стартует сам
таймер. Все, осталось обработать
прерывание.

  1. INTERRUPT_HANDLER(TIM4_UPD_OVF_IRQHandler, 23)

  2. {

  3. milis++;

  4. /* Cleat Interrupt Pending bit */

  5. TIM4_ClearITPendingBit(TIM4_IT_UPDATE);

  6. }

После обработки события, в прерывании
необходимо очистить бит отложенного
прерывания, иначе обработчик будет
вызываться постоянно. Здесь стоит
отметить, что если у вас, например, 8-ми
битный МК, а разрядность таймера 16-ти
битная, то регистр ARR состоит из двух
регистров — обычно _High и _Low. Запись в
эти регистры должна быть в определённой
последовательности, сначала _High, а потом
_Low (читайте это в datasheet). На си это выглядит
примерно так:

  1. TIM1->ARRH = (uint8_t)(TIM1_Period >> 8);

  2. TIM1->ARRL = (uint8_t)(TIM1_Period);

Мы познакомились с основными регистрами
таймера — PSCR и ARR. Что еще можно делать
с помощью таймеров? Таймеры могут
генерировать ШИМ (PWM) сигнал. Для этого
вводится ещё один регистр — сравнения
и захвата CCR (capture and compare). Он имеет такую
же разрядность как и счетчик таймера
CNT. В данный регистр мы можем записать
значение, и когда таймер дойдёт до него,
то будет вызвано дополнительное
прерывание. Таким образом, у нас будет
уже два прерывания — когда таймер
достигнет до данного значения и когда
дойдет до ARR. Если в одном прерывании
подать на некий вывод HIGH, а в другом LOW,
то получится ШИМ сигнал. Значение CCR
будет регулировать скважность ШИМ
сингала.

Таймеры могут самостоятельно управлять
выводами, без обработчиков прерывания.
Это позволяет организовать «железный»
ШИМ, без участия ядра МК. К сожалению,
не все выводы можно задействовать для
управления, обычно они жёстко определены.
Это создаёт определённые неудобства
при трассировке платы. С использованием
же прерываний, можно использовать любой
вывод.

Дополнительно, производители МК
добавляют в каждом таймере несколько
каналов. По сути, это отдельные, таймеры,
но связанные общим предделителем и
максимальным значением. Значение
сравнения CRR у каждого канала может быть
своё. Такая схема позволяет на железном
уровне синхронно работать всем каналам
таймера. В этом случае лучше использовать
прямое управление выводами. Самое частое
использование эта модель находит в
управлении моторами. Специальный бит
в регистре настроек таймера позволяет
синхронно менять все настройки каналов
таймера. Работает это обычно очень
просто, вы записываете новые значения
CRR и других параметров таймера, но реально
таймер не применяет их, пока вы не
установите в единицу этот специальный
бит. И в этом момент, сразу все значения
всех каналов будут обновлены. Также,
можно одновременно запускать все каналы.
Для управления моторами существует
много настроек, которые предназначены
для защиты, быстрое выключение всех
таймеров по специальному выводу,
блокирование настроек таймеров, для
запрета случайного изменения, все их
необходимо читать в datasheet для конкретного
МК.

Таймеры могут помогать декодировать
сигналы. Например, вам необходимо
посчитать сколько микросекунд прошло
между импульсами на определённом выводе
МК. Даже если вы будете делать это с
помощью прерываний, вы не сможете точно
посчитать время из-за работы других
прерываний и задержек по входу в
прерывание. Если нужно точное значение,
то здесь поможет только таймер. При
таком использовании, разрядность таймера
определяет точность посчитанного
времени. По сути одна единица счётчика
(с учетом предделителя) — это минимальная
единица времени, которую вы сможете
учесть. Настраивается тут опять же все
просто. С помощью специального регистра,
вы переводите таймер в режим учета входа
(обычно вывод опять же предопределён),
настраиваете ARR таймера, настраиваете
уровень сигнала, который вы ждёте и
запускаете таймер. В итоге, когда
произойдёт нужное событие, будет вызвано
прерывание, и в регистре сравнения CRR
будет лежать значение, равное времени
интервала. Вам остаётся только его
перевести в нужную единицу времени, в
зависимости от настроек таймера. Где
это ещё может понадобиться: декодирования
сигнала пульта ИК управления телевизором,
декодирование ШИМ сигнала на входе
(например, считывание команды с передатчика
радиоуправления), декодирование 1-wire
сигнала и другие варианты управления
по одному проводу.

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

  • управлять другими таймерами (по
    срабатыванию одного таймера, запускается
    другой таймер, особенно это удобно, при
    декодировании сигнала)

  • управлять другой периферией
    (считывание ADC синхронно с таймером, с
    определённый момент, синхронное
    копирование данных через DMA)

  • генерирование одного импульса
    нужной длины

  • управление моторами с учетом dead
    time (время задержки между включением
    выключением синхронных выводов)

  • управление моторами с получением
    сигналов от энкодера положения вала

Описать все это в данной статье не
возможно. В каждом конкретном случае,
производитель приводит примеры
использования, необходимо ориентироваться
на них и datasheet. В основном все это
реализуется записью определённых
параметров в регистры настроек таймера.
Чтобы изучить все это, лучше всего
поработать с таймерами на практике, в
конкретных приборах.

Учёт временных интервалов

Практически ни одна программа для МК
не обходится без учёта временных событий.
Например, каждые 500 миллисекунд считываем
показания датчика, через 3 секунды после
нажатия кнопки выключить светодиод,
пищать каждую секунду, мигать светодиодом
каждые 5 минут и т. д. Вы должны легко
уметь программировать обработку таких
событий. Есть несколько вариантов как
это можно реализовать. Все они основаны
на таймере, например, миллисекундном.
Итак, у нас есть настроенный таймер и
обработчик прерывания, который срабатывает
каждую миллисекунду, как это настроить
мы писали выше. Рассмотрим как можно
запрограммировать учет таких событий.

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

  1. TIMINT() {

  2. if (timeled) timeled--;

  3. }

  4. main() {

  5. while(1) {

  6. if (timeled==0) {

  7. //делаем что нужно..

  8. timeled = 500;

  9. }

  10. }

  11. }

Конечно, если это задача, помигать
светодиодом, то можно это сделать прямо
в прерывании. Если же это что-то более
массивное, например, обмен с датчиком,
то лучше делать все в основном цикле.
Напомним ещё раз — обработчики прерывания
должны быть очень небольшие и быстрые.
Заметьте, что мы используем не
увеличивающуюся переменную, а
уменьшающуюся. Так у вас не будет проблем
с переполнением.

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

  1. typedef struct

  2. {

  3. timer_res_t msec;

  4. timer_res_t start;

  5. void* pCallback;

  6. } Vtimer_t,*Pvtimer;

  7. TIMINT() {

  8. u8 i;

  9. for (i = 0; i < VTIMER_NUM; i++)

  10. {

  11. if (sVtimer[i].msec) sVtimer[i].msec--;

  12. }

  13. }

  14. main() {

  15. while(1) {

  16. for (i = 0; i < VTIMER_NUM; i++)

  17. {

  18. if (sVtimer[i].msec == 0 && sVtimer[i].pcallback != 0) {

  19. ((PFN_Callback_t)sVtimer[i].pCallback)();

  20. sVtimer[i].msec = sVtimer[i].start;

  21. }

  22. }

  23. }

  24. }

Если нужен какой-то ряд событий по
временным интервалам, то в основном
цикле вы можете использовать убывающие
сравнения со значением переменной.

  1. if (timled > 300) {

  2. //выполняется от 500 до 300

  3. } else if (timeled > 200) {

  4. //выполняется от 300 до 200

  5. } else if(timeled)

  6. //от 200 до 0

  7. }else {

  8. timeled = 500;//перезапуска таймер

  9. }

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

Работа с основной периферией

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

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

Для корректной работы любой периферии,
вы должны вовремя обрабатывать ответы
модуля. Если вы пропустите нужное
событие, или отреагируете на него
некорректно — это может привести к
возникновению различных непредвиденных
ситуаций, в том числе до полного зависания
периферии до следующего выключения
прибора. Важно внимательно читать
datasheet на модули, и действовать согласно
описанию. Производители МК очень часто
пишут дополнения к datasheet — ERRATA, и там
указывают об ошибках в периферии. Если
что-то пошло не так, ищите дополнения к
описанию, смотрите примеры от производителя,
читайте форумы.

ADC

Данная периферия позволяет оцифровать
значение напряжения на определённом
выводе МК, чтобы потом с ним можно было
работать в программе. МК может иметь на
борту несколько модулей ADC, каждый модуль
может иметь несколько каналов. Основное
отличие каналов от независимых модулей
ADC, заключается в невозможности
одновременной оцифровки значений на
выводах разных каналов одного ADC. Каждый
канал подключён к одному модулю ADC через
мультиплексор, и чтобы опросить все
каналы, МК должен последовательно
подключать каждый вывод через мультиплексор
к самому ADC.

Механизм работы с ADC очень простой. Вы
даёте команду, записывая единицу в
специальный бит регистра настроек ADC,
на старт измерения, и через какое-то
время ADC выдаёт результат в регистр
ответа, и выставляет в единицу бит
готовности, и если настроено, вызывает
прерывание. Далее вы обрабатываете
результат. Все это можно делать в основном
цикле. Тогда необходимо ждать ответ в
цикле, проверяя бит готовности и потом
считать полученные данные.

  1. ADC1.CR1 |= ADCSTART;

  2. while ((ADC1.SR & ADCREADY)==0);

  3. rez = ADC1.DATA;

Названия регистров, конечно, могут
отличаться у каждого МК, они свои, общего
стандарта тут нет. Как долго производит
вычисления модуль ADC? Обычно МК содержат
не очень быстрые и точные ADC, в среднем
одно измерение занимает от 1 до 10
микросекунд и имеет разрядность 10-12
бит. Если вам нужен более быстрый или
более точный ADC, то надо подбирать
специальный МК или смотреть в сторону
внешних специализированных ADC. Там
скорость может доходить до 1 наносекунды
и точность до 24 бит.

Мы рассмотрели самый простой случай
работы с ADC — считывание результата с
одного канала. Практически все МК умеют
работать с каналами в последовательном
режиме и управлять последовательностью
опроса выводов. В этом случае, вы задаёте
какие выводы надо опрашивать и в каком
порядке (обычно прямой или обратный
порядок по нумерации выводов ADC1 … ADC10
или ADC10…ADC1) путём записи в регистры
настроек. Запускаете ADC, и по готовности
каждого результата забираете его в
регистре данных. Тут опять же есть разные
варианты настроек, МК может начать
следующее измерение после того, как вы
заберёте предыдущее или работать на
скорость, не успели забрать, потеряли
данные. Удобнее всего в этом режиме
работать через прерывания. Если вам
неважна скорость обработки данных, то
самый правильный вариант, собирать с
помощью прерывания сразу все нужные
каналы в один массив данных и потом уже
в основном цикле работать с ними. ADC
могут работать в непрерывном режиме и
единичном. В непрерывном, после одного
измерения сразу начинается другое.

Вы должны понимать, что любая работа
с ADC требует применения фильтров, это
может быть решено схематически —
добавляется RC цепочка, или программно,
тогда используется цифровой фильтр по
специальному алгоритму. Самый простой
цифровой фильтр, например, скользящее
среднее последних 10 значений. Без
использования фильтров возможны выбросы
пиков значений, что приведёт к неверной
работе вашей программы. Если некуда
спешить, то можно сразу получить 5-10
значений и выбрать из них среднее прямо
в основном цикле.

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

Если нужна синхронная работа ADC с
какими-то событиями, то тут необходимо
использовать таймеры. Они могут давать
сигнал о начале измерения. Правда это
могут не все таймеры, поэтому внимательно
читаем datasheet. Если у МК есть модуль DMA,
мы его рассмотрим далее, то очень удобно
с его помощью, вообще без прерываний,
перемещать результаты ADC сразу в массив
в памяти. В этом случае вы прозрачно
работаете с массивом готовых данных и
программа сильно упрощается. Конечно
цифровой фильтр в таком случае применить
сложнее.

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

К данной периферии также можно отнести
компаратор и функцию Analog Watchdog. Эти
функции позволяют генерировать прерывание
при попадании напряжения на выводе в
определённый заданный вами интервал.

UART

UART — универсальный асинхронный
приемопередающий модуль. Обычно он
может работать и в синхронном режиме
тоже, и по сути является универсальным
синхронно асинхронным интерфейсом.
Возможности этой периферии очень широки.
Кроме классического последовательного
порта она также может декодировать
1-wire протокол, IRDA (инфракрасный приёмник)
декодирование, работа со SmartCard протоколом,
мульти процессорный обмен данный с
использование адресов.

Рассмотрим самый простой, и наиболее
часто встречающийся вариант — классический
асинхронный последовательный порт. С
точки зрения программирования данного
модуля все довольно просто. Как правило
есть два регистра данных — полученные
данные и передаваемые данные, в некоторых
МК это один регистр, через который данные
передаются в независимые регистры,
недоступные программисту. Перед началом
работы необходимо провести инициализацию
периферии. Настроить параметры порта,
скорость обмена (сейчас есть варианты
автоопределения скорости при получении
данных), формат пакета (8-9 бит, наличие
аппаратного контроля чётности и т. д.).
Когда все настроено, остаётся только
получать и передавать данные. Например,
если мы хотим передать данные, то
записываем их в регистр передачи данных
и выставляем флажок начала передачи.
Ждём выставления флажка, что данные
переданы, тогда можно передавать
следующие данные, и так пока не передадим
все данные. Если мы работаем в основном
цикле, то просто в цикле ожидаем нужного
статуса и передаём очередной пакет
данных. При работе с прерывании, функция
отправки выглядит примерно так.

  1. UART_TX_INT() {

  2. data = TxBuffer1[IncrementVar_TxCounter1()];

  3. UART1->DR = data;

  4. if (GetVar_TxCounter1() == GetVar_NbrOfDataToTransfer1())

  5. {

  6. /* Disable the UART1 Transmit interrupt */

  7. UART1_ITConfig(UART1_IT_TXE, DISABLE);

  8. }

  9. }

  10. UART_RX_INT() {

  11. data = UART1->DR;

  12. RxBuffer1[IncrementVar_RxCounter1()] = data

  13. if (GetVar_RxCounter1() == GetVar_NbrOfDataToRead1())

  14. {

  15. /* Disable the UART1 Receive interrupt */

  16. UART1_ITConfig(UART1_IT_RXNE_OR, DISABLE);

  17. }

  18. }

Прерывание на передачу данных вызывается
каждый раз, когда освобождён буфер
передачи данных, в обработчике мы должны
просто положить очередную порцию данных
в регистр данных, если же это все данные
— то необходимо отключить данный вид
прерывания. При получении данных, тоже
самое, прерывание будет вызвано когда
данные будут доступны для чтения,
получаем их, если это конец, то отключаем
прерывания. У вас может возникнуть
вопрос — пока мы будем записывать данные
в регистр, линия будет ждать пакета
данных и нарушится непрерывная передача?
Этого не произойдёт, потому что, на
самом деле МК имеет еще один регистр
данных (буфер), которые реально передаются,
поэтому на самом деле, как только мы
положим данные в регистр данных DR, они
сначала скопируются в другой внутренний
регистр и начнётся передача данных, и
когда передача ещё идёт, уже сработает
прерывание и мы положим новую порцию
данных раньше чем та была отправлена.
Так организуется непрерывный поток
данных. В более мощных МК, данная периферия
может иметь большой буфер в несколько
десятков байт для передачи и получения
данных, это позволяет работать с очень
большими скоростями непрерывных данных.

В данном примере вы заметили, что
используется такое понятие как буфер.
Обычно UART используется для обмена с
компьютером или другими системами, и
как правило обмен происходит на уровне
строк, а не байтов. Поэтому обычно
используется внутренний буфер для
удобной передачи целой строки за раз.
Таким образом, достаточно положить
строку данных в буфер и включить
прерывания, дать сигнал о начале передачи,
все данные будут переданы и прерывания
выключатся. Очень удобно. Тоже самое
при получении. Даём команду и при
наполнении буфера или получении байта
окончания строки — получаем флажок и
разбираемся с полученными данными в
буфере.

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

  1. PUTCHAR_PROTOTYPE

  2. {

  3. /* Write a character to the UART1 */

  4. UART1_SendData8(c);

  5. /* Loop until the end of transmission */

  6. while (UART1_GetFlagStatus(UART1_FLAG_TXE) == RESET);

  7. return (c);

  8. }

  9. GETCHAR_PROTOTYPE

  10. {

  11. char c = 0;

  12. /* Loop until the Read data register flag is SET */

  13. while (UART1_GetFlagStatus(UART1_FLAG_RXNE) == RESET);

  14. c = UART1_ReceiveData8();

  15. return (c);

  16. }

UART очень удобно использовать для
настройки и программирования вашего
устройства по bluetooth или wifi. В этом случае
после установки соединения вы просто
передаете данные в определённом вами
формате по UART. В обработчике прерывания
ждёте начала сообщения, наполняете
буфер и передаёте сигнал готовности,
далее в основном цикле обрабатываете
данные и производите нужные настройки.

Многие МК позволяют загружать прошивку
по данному протоколу, загрузчик
(bootloader) встроенный на заводе, использует
данную периферию. Если такая возможность
есть, используйте эту возможность в
схемах для прошивки по bluetooth или wifi.

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

SPI

Это самый простой протокол обмена
данными, который к тому же, один из самых
быстрых из межмикросхемных протоколов.
Он предполагает наличие одной линии
данных для передачи, одной для чтения,
и одной линии тактового генератора. В
настоящее время существует модификация
QUAD SPI, имеющая 4 вывода данных, работающие
в асинхронном режиме, и один тактовый
вывод, имеющая очень большую пропускную
способность. Если вы работаете с флеш
картами, то лучше выбирать МК с данной
модификацией, иначе скорость чтения/записи
будет низкой.

Следует сразу отметить, что данная
периферия может работать как в режиме
ведущего (master) так и в режиме ведомого
(slave). В случае ведущего, МК генерирует
тактовый сигнал, а в режиме ведомого,
генерирует сигнал другое внешнее
устройство, например, другой МК. Также
существует синхронный режим получения
данных вместе с передачей по трем линиям,
или обмен по двум линиям в двух стороннем
режиме.

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

  1. //ждем окончания передачи когдаможно класть следующий байт

  2. while (SPI_GetFlagStatus(SPI_FLAG_TXE)== RESET)

  3. {

  4. }

  5. /* Write one byte in the SPI Transmit Data Register */

  6. SPI_SendData(TxBuffer2[TxCounter]);

  7. /* Wait the byte is entirely received by SPI */

  8. while (SPI_GetFlagStatus(SPI_FLAG_RXNE) == RESET)

  9. {

  10. }

  11. /* Store the received byte in the RxBuffer2 */

  12. RxBuffer2[RxCounter++] = SPI_ReceiveData();

В случае с прерываниями, по сути, для
МК это будет как бы асинхронная передача,
свой буфер на прием и свой буфер на
передачу. Буфер передачи будет на шаг
впереди. Кладем второй байт, получаем
первый, кладем третий, получаем второй
и так пока не получимпередадим обе
очереди.

Остальные режимы отличаются только
параметрами регистров настройки.
Настроек как всегда много. Можно задавать
какой уровень является тактовым, что
делать на линии во время простоя и т. д.

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

Отметим, что в данном протоколе
используется также вывод CS — выбор
датчика, с которым мы будем общаться.
Он необходим даже в случае одного
единственного датчика, для получения
начального состояния. При включении
питания все выводы МК находятся в
неопределённом состоянии и датчик может
зафиксировать случайные сигналы. Обмен
с датчиком производится только при
низком состоянии вывода CS.

I2C

Данный протокол широко используется
в датчиках, микросхемах внешней памяти.
Поэтому им обязательно надо научиться
пользоваться. Если у вас есть готовый
драйвер обмена по этому протоколу, то
проблем нет, однако обычно производитель
МК в библиотеках даёт только удобный
доступ к регистрам периферии I2C, а полный
драйвер надо писать самостоятельно или
искать в сети. Полный драйвер написать
сложно, он должен уметь обрабатывать
все исключительные ситуации, и как
правило в сети есть частичные драйвера
(они конечно тоже работают, но могут
иметь не корректную обработку редких
случаев). Поэтому данную периферию очень
часто ругают, что реализована она дескать
криво и косо производителем МК. Отчасти
и это верно, потому что, этот модуль
обычно содержит и железные ошибки.
Наиболее простой вариант использования
данной периферии, когда МК является
ведущим, а датчики ведомым. Он обычно
работает отлично и отлажен. Ситуация
наоборот, работает хуже, а самая сложная
— смена ведущего в процессе работы, ещё
хуже. Если у вас последний случай, то
готовьтесь самостоятельно отлаживать
данный протокол и дописывать драйвер.
Вам обязательно понадобится внешний
логический анализатор. Мы рассмотрим
только частый случай, когда МК ведущий.
Также, сразу отметим, что реализовывать
программный вариант данного протокола
это худший случай, выбирайте МК, имеющий
данную периферию, если она вам нужна в
проекте.

I2C — этот
тот случай, когда работать с периферией
лучше через прерывания. Однако надо
иметь ввиду, что в этом случае прерывания
должны иметь очень высокий приоритет!
Почему? Сейчас вы это поймёте, но для
начала разберёмся как производится
обмен по данному протоколу.

Обмен производится по двум линиям —
тактовый сигнал CLOCK (SCL) и данные DATA (SDA).
Как обычно SCL задает ведущий, а вот данные
в этом протоколе могут ходить в обе
стороны, протокол двухсторонний. Протокол
позволяется общаться сразу с несколькими
устройствами находящимися на одной
шине. Для этого у каждого ведомого
устройства должен быть свой уникальный
адрес, ведущему он не нужен. Классическая
реализация использует 7 бит под адрес
и 1 бит для определения направления
данных — чтение (от ведомого к ведущему)
или запись (от ведущего к ведомому)
(существует расширенная версия 10 бит
под адрес). В режиме простоя на обоих
линиях должна быть логическая единица.
Обмен начинается с того, что мастер
формирует сигнал Старт (S), формируя ноль
на линии SCL. Передача заканчивается
сигналом STOP (P), переход от нуля к единице
на линии SCL. Переда началом обмена, мастер
обязательно должен проверить, что линия
не занята — на SCL логическая единица.
Биты считываются при высоком состоянии
линии SCL, изменяются при низком состоянии
SCL. Самая большая хитрость протокола
состоит в 9-м бите — ACKNACK.


Этот
бит подтверждает, что данные получены
или переданы. Некорректная обработка
этого бита может привести к зависанию
линии. Давайте посмотрим на примере,
как происходит обмен по данному протоколу.
Возьмём datasheet на датчик температуры
NCT75, в нем приведены диаграммы сигналов,
которые помогут понять, что происходит
при обмене по данному протоколу.

Как видно на картинке, начинается
обмен с сигнала старт на линии. Далее
МК передаёт адрес датчика и последний
бит RW (запись или чтение) — в данном
случае запись, низкий уровень. После
передачи адреса, если датчик с таким
адресом есть на линии, то он отвечает
сигналом ACK (низкий уровень), если нет
ответа, то сигнал будет NACK (высокий
уровень) и МК должен закончить передачу,
подав сигнал стоп. Далее МК передаёт
адрес регистра указателя с которым
дальше он будет работать. Датчик опять
отвечает ACK, что означается, что данные
получены. МК завершает передачу сигналом
стоп.

Если надо записать один байт в какой
то регистр, то сначала надо выбрать
адрес регистра, а потом не передавая
сигнал стоп, сразу же передать записываемый
байт, и завершить сигналом стоп. Таким
образом, вроде все легко и понятно.
Алгоритм простой: проверили линию, если
линия свободна, подали сигнал старт,
далее передали адрес и указали, что
будем делать — записывать или читать.
Далее получили ответ от датчика, что
все хорошо. Передали записываемые данные
и завершили передачу данных. Если делать
это все руками, в основном цикле, то
особенных проблем нет (в этом просто
случае). Но если работает периферия I2C,
то у нас получается асинхронный обмен
с этой периферией. И если не успеть
отреагировать на ответные сигналы
ACKNACK или не успеть передать их на линию,
то вся система разрушится. По какой
причине можно не успеть? Допустим мы
работаем по прерываниями. МК передал
адрес и получил ACK, пора передавать сам
байт данных. Вы должны его положить в
специальный регистр. МК вас оповестит
и вызовет прерывание, в котором вы и
должны это сделать. Но если у прерывания
низкий приоритет, то его может прервать
другое прерывание и вы продолжите чуть
позже.. весь пакет может нарушится. В
итоге, если вы пользуетесь прерываниями,
то приоритет у I2C должен быть высоким.
Особенно, если вы работаете на высоких
скоростях (есть возможность работать
на скоростях до 3МГц).

Теперь, рассмотрим этот процесс
поподробнее со стороны периферии МК.
Так как периферия работает асинхронно,
то почти во всех МК, можно считать, что
она работает через буфер. Разберём, на
примере STM8, как работает передача данных.


На
данной схеме события, которые отслеживает
МК отмечены как EVx (на все эти события
может быть назначено прерывание).
Генерируем старт, записываем бит в
специальный регистр. EV5 — в статус
регистре выставляется флажок, что старт
сгенерирован. Это событие нужно для
того, чтобы вы положили байт с данными
(адрес) в регистр данных. Далее периферия
сама его отправит и проверит, что получен
ACK. EV6 — адрес корректно отправлен, если
он не было получен, то этого события не
будет. EV8_1 — Регистр данных пустой,
можно записывать данные, записываем
туда данные. EV8 — данные начали
передаваться, они лежат в буфере, а вот
регистр данных пустой — можно записать
следующий байт, хотя передача первого
ещё идёт. Как вы видите мы работает
наперёд, когда идёт передача первого
байта, уже записываем второй байт, и
т. д. Это позволяет МК работать на
быстрой скорости непрерывно. EV8_2 — в
конце мы проверяем, что байт передан и
получен, и генерируем сигнал стоп. Уже
видно возможные ошибки, если не обработать
корректно, что нет ответа при передаче
адреса от датчика, то можно ждать ответа
бесконечно. Если байт прошёл с ошибкой,
то надо начать сначала или повторить
байт. Все эти ситуации надо обрабатывать,
а это усложняет разработку драйвера.
Конечно на практике, это все можно не
обрабатывать, если у вас один два датчика,
то в ошибок скорее всего не будет. Но
вот если, например, в квадрокоптере по
I2C идёт общение с главным датчиком —
акселерометром, то из-за не обработанной
ошибки, он просто упадёт. В проекте
Paparazzi UAV, разработчики написали полноценный
драйвер, можете оценить его сложность.

С получением данных ещё сложнее.
Проблема в том, что МК должен подтверждать
полученные данные сигналом ACK, а последний
байт NACK и потом стоп. Посмотрим диаграмму.


Начало
тут такое же. Передаём старт, потом
адрес. EV6 — говорит, что адрес передан,
а далее, EV7 — Rx буфер заполнен, можно
читать данные. Опять же, МК работает на
перед. Пока мы читаем данные DATA1, он уже
получает данные DATA2, поэтому, для того
чтобы корректно завершить получение
данных, необходимо действовать строго
по datasheet. А именно, в момент EV7_2, не считывая
данные из регистра данных, необходимо
дождаться статуса получения следующего
байта в буфер, BTF — передача байта
завершена. В этом случае, DATAN-2 будет в
DR регистре, DATAN-1 уже в буфере, и вот тут
надо уже установить признак NACK, так как
он будет относится к следующему байту
— то есть последнему! Потом надо прочитать
DR, там DATAN-2, и уже выставить статус STOP,
он будет относится опять же к следующему
байту, последнему, и после этого прочитать
DR опять, там DATAN-1. Остаётся получить
последний байт после статуса EV7. Выполнять
последние команды необходимо с
отключёнными прерываниями, чтобы никто
не нарушил данный процесс. Если же
посылка у нас всего из двух байт, то
ситуация меняется, и действовать надо
раньше, сразу после получения подтверждения
передачи адреса. А если мы читаем один
байт, то NACK надо выставлять до очистки
статуса получения адреса, а стоп
выставлять сразу после очистки статуса
получения адреса.

Вот так запутано работает данная
периферия в режиме без прерываний. С
прерываниями же все проще, достаточно
перед получением последнего байта
установить NACK и STOP в обработчике
прерывания. Но прерывания должны иметь
максимальный приоритет.

Дополнительно стоит отметить, что,
есть ещё событие RESTART, вместо STOP и следом
START, и некоторые датчики ждут полного
завершения и старта заново, а некоторые
сразу ждут RESTART между посылками.

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

  1. buf[0] = addr;

  2. buf[1] = 0x45;

  3. readi2c(buff,1,2, &func);//передать 1 байт,прочитать 2 байта и вызвать функциюfunc…

Мы даём задание прочитать 2 байта по
указателю 0x45 и вызвать функцию func, если
все успешно. Далее в функции просто
обрабатываем нужные данные.

EEPROM

Данный вид памяти используется для
хранения энергонезависимых настроек
и параметров вашей программы. Размер
этой области не очень большой, но и
хранить нам много не надо. Работа с
EEPROM ведётся как с обычными переменными,
по сути надо просто записать или прочитать
данные из определённого адреса памяти.
Производители МК специально защищают
данную область памяти, делая её доступной
только после определённых команд, чтобы
случайно не стереть её по ошибке.

Таким образом, вы сначала должны
разблокировать доступ к области памяти,
далее записываете данные и опять
блокируете. Обычно стирание памяти
производится медленно, а запись быстрее.
Поэтому производители МК специально
делают две команды — стереть данные в
памяти и записать данные в память. Также
может быть одна объединённая команда,
запись сразу со стиранием. При этом
стёртая ячейка, как правило, содержит
все 1, а не 0!

Разные МК имеют различные режимы
записи в этот вид памяти. Практически
у всех есть побайтный режим, может быть
режим записи сразу двух байт, четырех,
стирание может производится только
постранично. Это все необходимо читать
в datasheets.

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

Watchdog timer

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

Часто, еще бывает второй вид охранного
таймера — оконный таймер. Его необходимо
обновлять в строго определённый интервал
времени, не слишком рано и не слишком
поздно — то есть, в заданное окно времени.
Такая схема позволяет отследить ситуацию,
что какая-то часть программы выполняется
слишком быстро или слишком долго, что
говорит о сбое.

DMA

DMA — direct memory access — прямой доступ к
памяти. Это очень полезная периферия.
Она имеет очень много различных настроек.
Здесь мы разберём только основные
принципы её использования.

Данная периферия позволяет копировать
данные из одной области памяти в другую,
минуя центральный процессор. Вы даёте
задание, скопировать один байт из
такой-то ячейки памяти в другую. Совершенно
бесполезная функция, можно же просто
написать на Си пару команд. Однако
ключевым здесь является, то, что процессор
при это свободен, то есть копирование
производится другим модулем, а процессор
в это время выполняет свою работу.
Давайте посмотрим на простом примере,
как это можно использовать.

Допустим, нам необходимо воспользоваться
ADC, и постоянно измерять напряжение на
каком то выводе. Как это сделать мы
рассматривали выше. Мы можем перевести
ADC в непрерывный режим, включить прерывания
и в обработчике прерывания написать:

  1. volt = ADCDATA;

В переменную volt кладем данные из
регистра ADC. Но, как вы понимаете, такая
конструкция отвлекает от важных дел
наш центральный процессор. Он должен
прервать основную программу и выполнить
эту одну команду. Вот, чтобы этого не
делать, и нужна периферия DMA. Достаточно
ей дать задание — копировать из области
памяти регистра ADCDATA в область памяти
переменной volt, по наступлению события
— ADCREADY (ADC посчитал результат). Например,
на STM8L это будет выглядеть так:

  1. #define ADC1_DR_ADDRESS ((uint16_t)0x5344)

  2. #define BUFFER_SIZE ((uint8_t) 0x01)

  3. #define BUFFER_ADDRESS ((uint16_t)(&volt))

  4. SYSCFG_REMAPDMAChannelConfig(REMAP_DMA1Channel_ADC1ToChannel0);//присоединяем событие окончания ADC кDMA каналу 0

  5. DMA_Init(DMA1_Channel0, BUFFER_ADDRESS, //какой каналADC и куда

  6. ADC1_DR_ADDRESS, //откуд

  7. BUFFER_SIZE,//размер буфера

  8. DMA_DIR_PeripheralToMemory,//из другойпериферии в память

  9. DMA_Mode_Normal, //нормальный режим илимассив

  10. DMA_MemoryIncMode_Inc,

  11. DMA_Priority_High,

  12. DMA_MemoryDataSize_HalfWord);

  13. //включаем канал0

  14. DMA_Cmd(DMA1_Channel0, ENABLE);

  15. //если нужно включаем прерывание

  16. //DMA_ITConfig(DMA1_Channel0, DMA_ITx_TC, ENABLE);

  17. //включаем DMA периферию

  18. DMA_GlobalCmd(ENABLE);

Один раз производим настройку, задаём
откуда, куда, сколько байт, какими
партиями копировать данные (по байту
или 2 байту и т. д.) и запускаем в работу
периферию. Теперь, когда ADC закончит
вычисления, то он даст команду DMA и она
скопирует данные в нашу переменную. Нам
же останется просто работать с переменной
volt, там будет всегда свежее значение
данных.

Конечно, для такого простого случая
DMA не нужен. Но возможности этого модуля
гораздо шире. Он может работать с
массивами данных, кольцевыми буферами,
а это уже гораздо интереснее. Например,
можно считывать данные последовательно
с 4 каналов ADC и помещать их в массив.
Можно запускать работу DMA по таймеру.
Можно просто копировать большие объёмы
данных без нагрузки на процессор. Можно
перекладывать данные из одной периферии
в другую, например, с UART в SPI. Можно
получать данные с UART сразу в буфер. Можно
передать массив данных сразу на UART или
SPI.

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

Генераторы кода

Современные микроконтроллеры становятся
все сложнее. Количество периферии на
борту растёт. Программировать их все
сложнее и сложнее. Для облегчения
процесса входа в новые версии МК,
производители пишут специальные
программы, которые могут сами писать
код. Конечно не всю программу за вас, а
только код инициализации периферии.
Дополнительно эти программы имеют
визуальный редактор выводов выбранного
МК. Пользоваться ими очень удобно. Если
вам не нужен код, то обязательно проверьте
по этим программам правильно ли вы
выбрали выводы. Как правило они корректно
учитывают все возможные альтернативные
комбинации выводов периферии.

Такие программы сейчас есть практически
у всех линеек МК. Рассмотрим кратко
основные.

STMCube

STM8CubeMX
— это программа от компании ST
предназначенная для конфигурирования
STM8 серии МК. К сожалению, код она писать
не умеет, но может посчитать энергопотребление
вашей схемы. Также поможет рассчитать
схему тактирования CPU и периферии.
Однозначна полезная программа.

STM32CubeMX
— серия для STM32. Этот продукт уже гораздо
более серьёзный. По сути это не только
конфигуратор, генератор кода, но ещё и
набор библиотек для разных серий МК.
Правда, к этим библиотекам до сих пор
есть много нареканий, с точки зрения
ошибок и производительности, но это
очень удобный механизм. По сути вы
получаете сразу готовый проект, со всеми
библиотеками, с настроенной периферией,
с обработчиками прерываний и т. д.
Остаётся только писать свой код.

MPLab IDE

Микроконтроллеры компании Microchip можно
настраивать сразу в среде разработки
MPLab IDE. Среда отхватает серию PIC и dsPIC.
Настройка производится на уровне
конфигурирования библиотек. Ранее было
отдельное решение MPLab VDI для визуальной
настройки, но оно больше не поддерживается.

Существует онлайн верия IDE MPLab Express по
данному адресу. Там же есть MPLab Code
Configurator.

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

ATMEL Start

Компания ATMEL имеет он лайн конфигуратор
доступный по этому адресу
http://start.atmel.com/. Тут
даже есть готовые примеры проектов.
Генерация кода, готовый проект. Визуальный
выбор периферии. Все, вплоть до покупки
самого МК.

Если вы только начинаете работать с
МК, то попробуйте этот конфигуратор.

Кроссплатформенная разработка

Каждый вид МК имеет свою периферию и
свои регистры. Поэтому даже среди одного
класса МК — STM32 — очень сложно обеспечить
переносимость кода. В интернет есть
специальные библиотеки, которые стараются
учесть все различия и позволить писать
общий код. Это очень важно для проектов
под разнообразное железо.

Для серии ARM-Cortex M3-M0-M4 есть очень хорошая
библиотека LibOpenCM3
для работы с периферией под GCC,
распространяемая бесплатно. Она очень
часто используется в Open-Source проектах.
Поддержка большого количества ARM
микроконтроллеров — STM32, NXP LPC1000, EXM32,
Atmel SAM3U и другие. Библиотека хорошо
документирована и позволяет писать
кросс платформенные проекты. На ней
написано очень много проектов, откуда
смело можно брать код.

Для серии ATMEGA — существует проект
Arduino, под который написано очень много
готовых библиотек, работа со всевозможными
датчиками, управление внешней памятью,
чего там только нет. Arduino может работать
на разных процессорах и код будет общий.

Учитывая, то что эти библиотеки и
примеры написаны на Си, то их всегда
можно скопировать с одного микроконтроллера
на другой.

Если вы собрались делать проект под
разные устройства, то стоит обратить
внимание на эти библиотеки.

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

Вообще же стоит рассчитывать на то,
что вы пишите уникальный софт под
конкретный микроконтроллер. Поэтому
надо хорошо подумать, когда вы выбираете
МК — хватит ли его на все ваши задачи.
И не бойтесь использовать различные
МК, выбирайте тот, который вам нужен, а
не тот, который вы знаете.

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

Здесь мы разберем пример программы для AVR микроконтроллера на языке Си (C), которая будет использовать ту же принципиальную схему что и в примере с программой на Ассемблере, так что для работы нам пригодится тот-же макет что и в прошлой статье. Мигать светодиоды мы заставим не просто попеременно, а немножко по другому и с дополнительными задержками по времени.

Содержание:

  1. Кратко о языке программирования Си
  2. Исходный код программы на языке Си
  3. Компиляция и прошивка программы в МК
  4. Документация по языку Си и AVR Си
  5. Заключение

Кратко о языке программирования Си

Язык Си является компилируемым статически типизированным языком программирования, который был разработан сотрудниками Bell Labs — Кеном Томпсоном (Ken Thompson) и Деннисом Ритчи (Dennis MacAlistair Ritchie) в начале 1970-х годов.

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

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

При разработке программ на языке Си для AVR микроконтроллеров используется набор библиотек avr-libc и компилятор avr-gcc, с установкой которых в Linux мы уже разобрались в одной из прошлых статей.

Исходный код программы на языке Си

Вполне может быть что вы никогда не писали программ на языке Си — в этом нет ничего страшного. Для того чтобы написать первую программу на AVR C и провести с ней эксперименты вполне достаточно базовых знаний по работе с консолью в Linux. Позже вы сами сможете найти недостающую информации и изучить все необходимое самостоятельно.

Приведенный ниже код программы на Си будет выполнять следующие действия (алгоритм):

  1. Зажечь светодиод 1 и погасить его с небольшой задержкой (два раза подряд);
  2. Выполнить более длительную задержку;
  3. Зажечь светодиод 2 и погасить его с небольшой задержкой (два раза подряд);
  4. Начать все сначала.

Вот исходный код программы который работает по приведенному выше алгоритму:

/* Светодиодная мигалка на микроконтроллере ATmega8
 * https://ph0en1x.net
 */
#define F_CPU 1000000UL  // укажем компилятору частоту ЦПУ
#include <avr/io.h>      // Подключим файл io.h
#include <util/delay.h>  // Подключим файл delay.h

void main(void) {                // начало программы
    // -- установим параметры --
    int delay_ms_1 = 100;        // задержка для светодиода
    int delay_ms_2 = 300;        // задержка между светодиодами
    
    // -- настроим пины порта --
    DDRD |= (1 << PD0);          // пин PD0 порта DDRD на вывод
    DDRD |= (1 << PD1);          // пин PD1 порта DDRD на вывод
    
    // -- основной цикл программы --
    while (1) {                  // реализация бесконечного цикла
        PORTD |= (1 << PD0);     // на пине PD0 высокий уровень
        _delay_ms(delay_ms_1);   // задержка по времени 1
        PORTD &= ~(1 << PD0);    // на пине PD0 низкий уровень
        _delay_ms(delay_ms_1);
        PORTD |= (1 << PD0);
        _delay_ms(delay_ms_1);
        PORTD &= ~(1 << PD0);
        
        _delay_ms(delay_ms_2);   // задержка по времени 2
        
        PORTD |= (1 << PD1);     // на пине PD1 высокий уровень
        _delay_ms(delay_ms_1);   // задержка по времени 1
        PORTD &= ~(1 << PD1);    // на пине PD1 низкий уровень
        _delay_ms(delay_ms_1);
        PORTD |= (1 << PD1);
        _delay_ms(delay_ms_1);
        PORTD &= ~(1 << PD1);
    }
}

Рассмотрим все в исходном коде более подробно.

Строки или части строк что начинаются с двух слешей «//«, а также блоки текста что начинается с символов «/*» и заканчивается символами «*/» — это комментарии. В комментариях может размещаться полезная информация и примечания.

Строкой «#define F_CPU 1000000UL» мы объявляем константу, которая говорит компилятору что частота ЦПУ нашего микроконтроллера равна 1000000Гц (1МГц). Данное объявление необходимо для правильной работы некоторых функций, в нашей программе это функция «_delay_ms». В моем примере микроконтроллер ATmega8 без установки битов-фьюзов по умолчанию работает на внутреннем тактовом RC-генераторе с частотой 1МГц.

Строка «#include <avr/io.h>» производит подключение файла «io.h» к текущему файлу исходного кода, а строка «#include <util/delay.h>» — подключает файл «delay.h».

Узнать где размещаются данные файлы в Linux можно с помощью программы «locate». Установим ее и обновим индекс файлов для поиска:

sudo apt-get install locate
sudo updatedb

В качестве примера, выполним поиск путей где размещаются файлы «io.h» и оставим только те результаты, в которых содержится сочетание символов «avr»:

locate io.h | grep avr

В результате получим список путей ко всем файлам где в имени встречается «io.h», а также путь содержит подстроку «avr»:

/usr/lib/avr/include/stdio.h
/usr/lib/avr/include/avr/io.h
/usr/share/doc/avr-libc/avr-libc-user-manual/group__avr__io.html
/usr/share/doc/avr-libc/avr-libc-user-manual/group__avr__stdio.html
/usr/share/man/man3/io.h.3avr.gz
/usr/share/man/man3/stdio.h.3avr.gz

Здесь мы можем видеть что нужный нам файл находится по пути «/usr/lib/avr/include/avr/io.h». Посмотрев его содержимое можно увидить что он содержит в себе включение других файлов с определениями (AVR device-specific IO definitions), которые в свою очередь зависят от выбранного нами типа микроконтроллера.

Тип микроконтроллера (MCU Type) в даном случае указывается как параметр «-mmcu=atmega8» (для ATmega8) при вызове команды-компилятора «avr-gcc».

В моем случае для микроконтроллера ATmega8 через файл «io.h» подключается следующий файл — «iom8.h» (Input Output Mega8), в нем хранятся все определения таких переменных как PD0, PD1, PB8, DDRD, DDRB, RAMSTART, RAMEND и много всего другого.

Файлы с определениями IO (io*.h) для каждого типа МК хранятся в директории по адресу «/usr/lib/avr/include/avr/», рекомендую зайти туда и посмотреть что в ней творится для более глубокого понимания.

Полистать содержимое файла iom8.h можно в редакторе nano, для этого выполним команду:

nano /usr/lib/avr/include/avr/iom8.h

Для поиска в редакторе nano используйте комбинацию клавиш CTRL+W (для запоминания: where, где).

Также используя команду «cat» можно вывести только те строчки, которые содержат в файле указанное сочетание символов или слово:

cat /usr/lib/avr/include/avr/iom8.h | grep RAM

Данная команда выведет вот такой текст:

#define RAMSTART         (0x60)
#define RAMEND           0x45F
#define XRAMEND          RAMEND

Таким образом можно посмотреть какие есть константы и определения в библиотеке avr-gcc для работы с операциями ввода-вывода(Input-Output), их значения и многое другое для вашего типа микроконтроллера!

Файл «delay.h» содержит в себе определения функций задержки, в частности там содержится код функции «_delay_ms», которую мы будем использовать в примере. Для просчета временной задержки такие функции используют константу «F_CPU», которую мы объявили раньше в начале кода.

Строкой «void main(void) {» с левосторонней фигурной скобки начинается тело нашей программы и заканчивается оно правосторонней фигурной скобкой «}» в самом низу листинга. Таким образом мы объявили основную функцию «main» с которой начнется выполнение программы, тело функции взято в фигурные скобки, а ключевые слова «void» означают что функция не принимает и не возвращает никаких данных, в данном случае.

Важно знать что в языке Си символ точка с запятой «;» является специальным символом — пустым оператором (который ничего не выполняет) и используется для указанию компилятору что это конец команды.

В строчке «int delay_ms_1 = 100;» мы объявили новую переменную «delay_ms_1» с типом «int» (Integer, Целый тип, значения от -32768 до 32767) и присвоили ей значение 100. Служит она в нашей программе для установки задержки в миллисекундах при мелькании каждого из светодиодов.

В следующей строке «int delay_ms_2 = 300;» мы также выполнили инициализацию переменной, которая будет служить для установки времени задержки между мельканиями отдельных светодиодов — 300 миллисекунд.

Дальше идет команда «DDRD |= (1 << PD0);» которой мы настраиваем канал PD0 порта DDRD на вывод. Для этого, при помощи левостороннего сдвига битов (разряов) числа 1, создается битовая маска которую мы накладываем на содержимое регистра управления каналами порта DDRD при помощи битовой операции «|» (логическое ИЛИ).

Следующая команда в коде идентична предыдущей за исключением того что она устанавливает на вывод канал PD1 порта DDRD.

К каналам PD0 и PD1 (ножки 2 и 3 чипа) у нас подключены светодиоды, свечением которых мы и будем управлять.

Строкой «PORTD |= (1 << PD0);» мы выполняем установку бита с номером 0 в байте регистра для порта PORTD, чем мы подаем на канал PD0 высокий уровень и самым зажигаем подключенный к нему светодиод.

Также данную запись можно представить вот так:

PORTD = PORTD | (1 << PD0);

Здесь мы присваиваем переменной PORTD содержимое этой же переменной, применив к ней битовую операцию «|» (логическое ИЛИ), в качестве аргумента которой выступает результат выражения «1 << PD0», который в свою очередь представляет число один, биты которого сдвинуты на 0 (PD0 = 0) разрядов влево.

Дальше мы выполняем небольшую задержку по времени «_delay_ms(delay_ms_1);» вызывая функцию «_delay_ms» и передав ей в качестве аргумента переменную «delay_ms_1», которая уже содержит число 100.

Строкой «PORTD &= ~(1 << PD0);» мы выполняем записываем 0 в бит под номером 0 в байте регистра для порта PORTD и гасим светодиод, который подключен к каналу PD0.

Более развернуто данную строку можно записать так:

PORTD = PORTD & ~(1 << PD0);

Здесь мы выполняем запись в порт PORTD его начального значения, перед тем применив к последнему операцию «&» (логическое И), в качестве второго аргумента которой передаем результат комплексного выражения «~(1 << PD0)», которое в свою очередь представляет число один, сдвинутое на ноль (PD0 = 0) разрядов влево с применением к результату оператора инверсии «~» (смена значений всех бит на противоположные).

В следующих строках в коде мы снова выполняем установку (запись 1) и сброс (запись 0) бита PD0 в байте регистра для порта PORTD с установленной задержкой «delay_ms_1», чем мы заставляем светодиод подключенный к пину канала PD0 зажечься и погаснуть (мелькнуть, blink).

Строкой «_delay_ms(delay_ms_2);» выполняется более длительная задержка по времени с использованием значения переменной «delay_ms_2» которая выше получила значение 300 (задержка в 300 миллисекунд).

Дальше мы дважды производим установку и сброс бита PD1 (бит под номером 1 в байте регистра) в регистре порта PORTD, чем заставляем мелькать светодиод который подключен к пину канала PD1 порта PORTD микроконтроллера.

По завершению приведенных команд все начинается снова в бесконечном цикле «while (1)«.

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

Компиляция и прошивка программы в МК

Для компиляции программы сохраним исходный код в файле под названием «leds_blinking.c». Если у вас уже настроена среда Geany то для компиляции достаточно нажать на панели инструментов кнопку «Compile».

Для компиляции файла с программой на Си в консоли нужно выполнить команду:

avr-gcc -mmcu=atmega8 -Os leds_blinking.c -o leds_blinking.o

В результате работы, если нет ошибок, получим объектный файл leds_blinking.o с которого нам нужно извлечь необходимые данные для прошивки нашего микроконтроллера (в моем случае ATmega8, параметр «-mmcu=atmega8»).

Для извлечения данных и построения файла прошивки в формате Intel Hex нужно нажать в Geany кнопку «Build». Из консоли получить нужный файл можно при помощи команды:

avr-objcopy -j .text -j .data -O ihex leds_blinking.o leds_blinking.hex

Теперь, когда у нас есть файл с прошивкой в формате Intel HEX останется записать его содержимое (прошить) во флешь-памяти микроконтроллера, выполнить эту операцию можно нажав в подготовленной нами среде Geany кнопку «Run or view current file» (Execute).

В консоли выполнить прошивку можно при помощи avrdude командой (для ATmega8 параметр «-p m8», программатор USBAsp «-c usbasp»):

avrdude -c usbasp -p m8 -P usb -U flash:w:leds_blinking.hex

Сразу после прошивки на МК будет послана команда сброса(RESET) и программа начнет выполняться в кристалле, о чем будут свидетельствовать помигивающие светодиоды. Также RESET можно выполнить и вручную, переподключив для этого питание к микроконтроллеру.

Желательно выполнять все шаги (компиляция+построение hex-файла + прошивка) поочередно и вести наблюдение за информацией что появляется в консоли или на информационной панели Geany. Это поможет обнаружить ошибки и замечания если что-то не будет работать так как нужно.

Документация по языку Си и AVR Си

Брайан Керниган и Dennis Ritchie — Язык программирования C: brian-kernighan-and-dennis-ritchie-c-language.pdf.zip (2,1Мб, PDF).

Герберт Шилдт — Полный справочник по C: gerbert-shildt-c-complete-guide.zip (912Кб, HTML).

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

По возможности купите себе хороший и свежий справочник по Си в бумажном виде для удобного обучения и работы.

Библиотека Си для AVR микроконтроллеров (AVR C Runtime Library) — https://savannah.nongnu.org/projects/avr-libc/

По приведенной выше ссылке можно почитать документацию (на английском языке) прямо на сайте или же скачать ее одним файлом в форматах HTML и PDF, там есть вся необходимая информация по использованию библиотеки avr-libc для программирования AVR микроконтроллеров.

Заключение

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

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

В этом видео программатор USBAsp уже отключен, а питание схемы на микроконтроллере осуществляется от батареи КРОНА с напряжением 9В через схему стабилизатора напряжения которая обеспечивает на выходе стабильные 5В.

Начало цикла статей: Программирование AVR микроконтроллеров в Linux на языках Asembler и C.

Содержание

  • 1 Создание проекта в Atmel Studio
  • 2 Код программы в Atmel Studio
  • 3 Компиляция программы
  • 4 Загрузка прошивки в микроконтроллер

Решил начать осваивать микроконтроллеры (далее МК) AVR. Думал что все просто раз и прошил микроконтроллера, но не так все просто как казалось на первый взгляд. В процессе прошивки контроллера возникли ряд трудностей  о которых я хочу описать в этом посте. Я сам начинающий в этом нелегком деле, поэтому если увидели ошибки или другие косяки то прошу сообщить.

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

Подопытным МК будет Atmega8. Для того чтобы ее прошить нужна будет ее распиновка выводов представлена ниже. Распиновку также можно посмотреть тут.  Нам нужно понимать куда подключать программатор куда подавать напряжение.

Распиновка микроконтроллера Atmega8

Далее нам необходимо определиться с программатором которым будем шить МК. Я взял USBasp программатор, самый простой программатор. Они бывают с разными разъемами, какой взять не принципиально. Главное правильно подключить выводы MOSI, MISO, RST, SCK а также питалово VCC и GND к микроконтроллеру. Также нужно установить под этот программатор драйвера, без них ПК просто не поймет что это за устройство такое мы подключили.  Ссылка на драйвера для программатора USBasp  приложена в конце статьи.

USBasp программатор и распиновка выводов

После того как мы выбрали программатор и МК нам нужно их соединить) Вот схема подключения программатора и микроконтроллера

Схема подключения программатора USBasp и микроконтроллера Atmega8

Создание проекта в Atmel Studio

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

Запускам среду Atmel Studio и создаем новый проект.

Создание проекта в Atmel Studio

И выбираем в списке устройств наш камень Atmega8

Создание проекта в Atmel Studio, выбор микрокнтроллера

И пишем программу, в качестве примера возьмем код мигания светодиодом. Так же  вот есть проект мигалки на микроконтроллере с 4-мя светодиодами.

Код программы в Atmel Studio

/*
 * LED blink.c
 *
 * Created: 06.04.2020 21:31:26
 * Author : Mudji
 */
#ifndef F_CPU
#define F_CPU 1000000UL // 1 MHz clock speed
#endif

#include <avr/io.h>
#include <util/delay.h>

int main(void)
{
	DDRC = 0xFF; //Nakes PORTC as Output
	while(1) //infinite loop
	{
		PORTC = 0xFF; //Turns ON All LEDs
		_delay_ms(1000); //1 second delay
		PORTC= 0x00; //Turns OFF All LEDs
		_delay_ms(1000); //1 second delay
	}
}

Следует обратить внимание вот на первые 2 две сточки:

#ifndef F_CPU
#define F_CPU 1000000UL // 1 MHz clock speed
#endif

тут мы определяем частоту тактирования микроконтроллера. В нашем случаем это 1 МГц. И запоминаем это значение — оно нам еще пригодится.

Компиляция программы

Далее необходимо откомпилировать программу, для этого нажимаем Build -> Build Solution или просто нажимаем клавишу F7 . Если никаких ошибок не было то программа откомпилируется и в консоле появится сообщение что все у нас гуд нет никаких варнингов и ошибок.

Сообщение об успешной компиляции программы

И так поздравляю Вас с успешной компиляции программы. Далее нам нужно найти файл hex который появляется после компиляции программы. Его можно найти в папке с проектом, лежит он в папке , в моем случае папка проект Led blink и там в папке Debug находится наш долгожданный файл hex.

Расположение файла hex в папке проекта

Загрузка прошивки в микроконтроллер

Остается только залить этот файл в память прошиваемого микроконтроллера. Для этого я использовал программу Khazama AVR Programmer на мой взгляд очень удобная программа.

Для загрузки прошивки в МК делаем следующее:

  1. Запускаем программу и устанавливаем состояние fuse битов. Что такое фьюз биты мы говорили ранее. Для того чтобы установить их сначала нужно считать, для это жмем Command->Fuses and Lock Bits   и далее во всплывающем окне жмем Read All

Установка фьюз битов микроконтроллера

Тут выставляем нужные нам Fuse биты  Lock биты лучше пока не трогать если не знаете какой бит за что отвечает.

Нас интересуют биты CKSEL 0…3 они отвечают за выбор типа тактирования внешний кварц или внутренний RC генератор. В нашем случае частота 1 МГц и тактирование от внутреннего RC генератора. Поэтому пишем значение 0001.

Fuse биты CKSEL 0...3 отвечающие за настройку частоты тактирования

После того как выставили Fuse биты нажимаем кнопку Write All после чего биты установятся в МК.

Ну и последний этап это загружаем hex файл сначала в буфер программы и далее заливаем в микроконтроллер.

Запись прошивки в микроконтроллер

После загрузки прошивки, микроконтроллер автоматом запуститься и начнет мигать светодиодом, который подключен к порту  выводу порта C.

Понравилась статья? Поделить с друзьями:
  • Как на компьютере найти символы на клавиатуре
  • Как мне найти покупателя на сахар
  • Как найти среднее число попаданий в мишень
  • Как найти свой дом по компасу
  • Как найти хороший майнкрафт бесплатно