Сразу предупреждаю, что не собираюсь разводить холивары насчет преимуществ AVR-ассемблера перед С/Arduino, или даже перед BASCOM-AVR и MikroPascal for AVR — каждый инструмент уместен в своей области. У ассемблерного подхода в ряде случаев имеются свои преимущества — в основном это небольшие проекты, а также системы, в которых требуется точный расчет времени. Немаловажное достоинство этого подхода — простота необходимого инструментария. Но один из крупнейших недостатков в сравнении с языками высокого уровня — отсутствие готовых библиотек хотя бы для базовых задач. Для того же Arduino они имеются на все случаи жизни, но, к сожалению, совмещать их с ассемблером оказывается сложно и потому нецелесообразно — проще уж все и сделать с помощью самого Arduino. Поэтому некоторое время назад я задался целью создать более-менее законченную экосистему для проектов на основе AVR-контроллеров с программированием на чистом ассемблере.
Основные результаты по созданию такой экосистемы изложены в книжке под названием «Программирование микроконтроллеров AVR: от Arduino к ассемблеру». Там же вы найдете подробное изложение целесообразности и границ применимости изложенного подхода. Руководствуясь приведенными в книге примерами, можно строить вполне законченные проекты с минимальной затратой сил и средств, и получить в результате девайс, ласкающий взор своей компактностью, экономичностью и скоростью работы. В этой статье я привожу один из примеров обращения с современными периферийными устройствами с помощью ассемблера, который работает лучше, быстрее и стабильнее, чем его аналог на Arduino.
Примеры тестовых программ, приведенные в этой статье далее, показывают, как на ассемблере работать со строчными дисплеями (алфавитно-цифровыми, текстовыми, знакосинтезирующими — все это разные называния одного и того же типа) на базе HD44780-совместимых контроллеров, взамен LiqudCrystal — одной наиболее широко применяемых Arduino-библиотек. В этих программах вы, кроме того, встретите примеры использования на ассемблере портов UART и I2C (TWI).
Когда очередная версия монстра, в который превратился когда-то компактный и быстрый инструмент, ныне известный под названием Atmel Studio, отказалась устанавливаться на то железо, которым я располагаю и потребовала более современный комп, я окончательно решил попробовать вернуться к истокам, когда никаких AVR GCC еще не существовало. Обдумав ситуацию, я понял, что в ряде случаев отказ от высокоуровневых инструментов позволит сэкономить деньги и время, позволяя при этом получать при этом более надежные и эргономичные девайсы. Остальные подробности по этому поводу см. в упомянутой книге. И еще раз хочу повторить: это не развязывание холивара, это напоминание о том, что инструмент обязан быть адекватен задаче.
Замечание по поводу практического применения AVR-ассемблера. Для того, чтобы создавать на нем программы, достаточен минимальный набор инструментов: собственно ассемблер (программы далее рассчитаны на avrasm2), набор файлов макроопределений для имеющихся у вас контроллеров (inc-файлов) соответствующей ассемблеру версии, текстовый редактор а-ля Блокнот и AVR-программатор. Ассемблер avrasm2.exe и inc-файлы извлекаются из любой версии AVR Studio с номером 5 и выше. Все это прелестно заработает хоть на самом древнем 386-м десктопе с экраном 640х480 и Windows 98. Но если вы в упор не можете обойтись без продвинутых средств отладки, то никто, конечно, не мешает вам ваять свои программы в самой AVR Studio или всяческих Протеусах, если вам это по душе — примеры ниже не содержат ничего такого специфического. Для старых контроллеров (в том числе ATmega8, ATmega16 и ATtiny2313, на которые я в основном ориентируюсь, можно употреблять и версию AVR Studio 4.х (с ассемблером avrasm32), куда более компактную и дружелюбную к пользователю. Подробнее обо всех этих особенностях также см. упомянутую книгу.
И еще замечание о терминологии: в ассемблере никаких, разумеется «функций» не существует и все подпрограммы оформляются одинаково (несколько отличаются от обычных подпрограмм только обработчики прерываний). Я их по привычке все скопом называю процедурами, хотя, строго говоря, это тоже не совсем верно — чаще всего они что-то где-то все-таки возвращают, как функции.
Описанные далее программы рассчитаны на работу в принципе с любыми строчными дисплеями LCD или OLED, имеющими HD44780-совместимый контроллер. Проверялись они на продукции Winstar (OLED, LCD), BLAZE (LCD BCB1602), российской фирмы МЭЛТ (с отечественными же контроллерами внутри) и др. Демо-примеры ориентированы на наиболее часто применяющийся тип 16 символов в 2 строки (1602), но базовые ассемблерные процедуры без изменений или с минимальной коррекцией могут применяться для любой конфигурации без специального указания типа: однострочные дисплеи (с числом символов больше 8) или 4-строчные все равно логически разбиты на две строки.
Демо-программа на Arduino
Начнем с того, что создадим простенькую демо-программу вывода на экран часов с термометром — для иллюстрации того, как это выглядит на Arduino. Для этого примера возьмем OLED-дисплей, как наиболее сложный случай для библиотеки LiqudCrystal — как я неоднократно писал (например, здесь), для OLED ее приходится несколько допиливать. Доработанную версию русифицированной библиотеки LiquidCrystalRus_OLED с примером применения можно скачать отсюда. Несколько видоизмененный текст этого примера (назовем его OLED_Liquid_Crystal_1602_test) мы и возьмем здесь за основу. На рис. ниже приведена схема подключения дисплея к отечественному клону Arduino mini (Iskra mini):
Тогда программа будет такой:
#include <LiquidCrystalRus_OLED.h>
// RS, E, DB4, DB5, DB6, DB7
LiquidCrystalRus OLED1(3, 4, 5, 6, 7, 8);
volatile byte month=0; //месяцы 0-11
//год постояный 2020, число 01
volatile word time=0; //минуты 0-1440 (23:59)
//названия месяцев по-русски, дополненные до 8 символов пробелами:
const char *m_name[] = {" января ","февраля "," марта "," апреля "," мая "," июня "," июля ","августа ","сентября","октября "," ноября ","декабря "};
byte degree[8] = { //значок градуса
0b00001100,
0b00010010,
0b00010010,
0b00001100,
0b00000000,
0b00000000,
0b00000000,
0b00000000
};
void setup() {
delay (1000);
OLED1.begin(16,2); //16 символов 2 строки
OLED1.clear();
OLED1.createChar(0, degree);//создаем значок градуса
OLED1.setCursor(9,0); //верхняя строка, 9 позиция
OLED1.print("-22,3"); //-22.3
OLED1.write(byte(0));//градусов
OLED1.print('C'); //Цельсия
OLED1.setCursor(0,0); //верхняя строка, 0 позиция
OLED1.print("00:00"); //время
OLED1.setCursor(0,1); //нижняя строка нулевая позиция
OLED1.print("01 января 2020"); //дата
OLED1.setCursor(2,0); //верхняя строка, 2 позиция ":"
OLED1.blink(); //двоеточие мигает
delay(1000);
}
void loop() {
time++; if (time==1440) time=0; //1440 число минут в сутках
byte hours=time/60; //число условных часов
OLED1.setCursor(0,0); //верхняя строка, 0 позиция
if (hours>=10) OLED1.print(hours);
else
{OLED1.print('0'); OLED1.print(hours);} //с ведущим нулем
OLED1.print(':');
byte minits=time%60; //минуты - остаток от часов
if (minits>=10) OLED1.print(minits);
else
{OLED1.print('0'); OLED1.print(minits);} //с ведущим нулем
OLED1.setCursor(3,1); //нижняя строка 3 позиция
//выводим месяцы:
OLED1.print(m_name[month]); //выводим месяц на место января
OLED1.setCursor(2,0); //верхняя строка, 2 позиция ":"
OLED1.blink(); //двоеточие мигает
delay(1000);
month++;
if (month==12) month=0;
}//конец loop
Этот скетч, вместе с приводимыми дальше ассемблерными примерами, вы сможете найти в архиве, ссылка на который размещена в конце статьи. Программа каждую секунду (что задается с помощью функции delay()
) меняет название месяца и добавляет единицу к значению минут. При достижении числа 23:59 отсчет времени сбрасывается в нули и начинается заново. Эти операции демонстрируют, как обращаться со строками и делить длинное число времени на минуты и часы. При прямом получении значений часов, минут и секунд из часов реального времени (RTC) делить ничего не потребуется, но организация отсчета времени в программе может быть самой разнообразной, так что это знание будет не лишним.
Результат работы программы показан на фото:
Условное число (01), год (2020) и значение температуры остаются в этой демо-программе неизменными, а примеры подключения к Arduino различных градусников вы найдете и без меня. В верхнюю строку вы можете также запихнуть и значение влажности со значком процентов, особенно если удалить последнюю букву «C» и сдвинуть температуру еще на один знак вправо. Я намеренно не конкретизирую этот вопрос, так как в подключении фирменных датчиков при ассемблерном подходе имеются свои особенности (подробности см. в упомянутой книге).
Здесь мы создаем значок градуса с помощью функции createChar
. Увы, нормальный значок градуса я встречал только в знакогенераторе отечественных дисплеев фирмы МЭЛТ, для остальных требуется его отрисовка. Ранее (в том числе и в библиотеке LiquidCrystalRus_OLED) я заменял значок градуса на верхний квадратик (символ 0xdf, восьмеричный код 337), но это некрасиво. Отметим, что функция createChar
библиотеки LiquidCrystal в отношении OLED-дисплеев Winstar работает не очень надежно (иногда значок просто пропадает при первом включении), и причины мне установить не удалось. Соответствующая ассемблерная процедура (см. далее) не сбоила ни разу.
Напомню также, что в доработанной библиотеке LiquidCrystalRus_OLED введена замена кода нуля (0x30) на код буквы «O» (0x4f). Желающие могут вернуть перечеркнутый ноль обратно, просто удалив или закомментировав строку замены (строка 308 измененного файла LiquidCrystalRus_OLED.cpp).
Отметим еще, что теоретически к одному контроллеру можно подключать сколько угодно подобных дисплеев, если вывод Enable (E) каждого подключать к отдельному порту, остальные линии подключаются просто параллельно. И необязательно это должны быть дисплеи одного типа (может быть один текстовый, другой графический, один LCD, другой OLED и т.д.), лишь бы интерфейсы управления у них совпадали. На практике, так как контроллер при этом еще что-то делает, одновременно лучше подключать не более 2-3 дисплеев, чтобы сильно не тормозить все остальное — процедуры управления дисплеем в Arduino-версии достаточно долгие.
По поводу мигания двоеточия. Здесь мы в конце каждого цикла устанавливаем курсор на соответствующую позицию и включаем аппаратное мигание функцией blink()
. Это отлично работает как раз на OLED-вариантах, в вот во всех прошедших через мои руки LCD аппаратный «блинк» реализован совершенно безобразно. Поэтому в них приходится мигание реализовывать программно и мы предусмотрим в ассемблерной «библиотеке» возможность отключения-включения функции аппаратного мигания.
Нужно добавить про вывод русских символов на дисплей, с чем в Arduino традиционно творится жуткая путаница. У меня несколько лет назад была надежда, что ситуация как-то выправится, но судя по обсуждениям, вплоть до последних версий IDE этого не произошло. В программе я просто указал русские символы, так как LiquidCrystalRus_OLED работает с кодировкой UTF-8, и в большинстве случаев это проходит безболезненно. Если же у вас будут выводиться «кракозябры», можно попробовать рецепты, приведенные в статье на iarduino, где попытались вывести некоторую закономерность. Загляните также в официальную статью (довольно мутную) на arduino.cc. В ассемблерных программах мы будем работать с таблицей знакогенератора напрямую, и такой проблемы не возникнет.
Ассемблерная демо-программа
Подключение к ATmega8
Все примеры далее рассчитаны на ATmega8, но ничто не может вам помешать адаптировать их для любого подходящего AVR. Для этого придется сменить ссылку на inc-файл (у нас тут это будет m8def.inc), а также, возможно, заменить номера и названия портов, к которым подключается дисплей. Учтите, что при выводе через параллельный интерфейс на ассемблере удобно работать с выводами, идущими подряд и относящимися к одному порту, о чем подробнее далее. Не стоит применять какой-нибудь Tiny, у которого четырех подряд идущих выводов портов не имеется: конечно, это вполне возможно, но приведет к необходимости слишком существенных переделок программ и схем.
Как указано в программе и на схеме выше, дисплей WEH1602 подключается к выводам 3-8 Arduino. Для Arduino это удобно — выводы идут подряд, причем важные для применения в качестве часов-термометра выводы I2C и АЦП остаются свободными. А вот для ассемблера такое подключение не очень хорошо: если посмотрите соответствие выводов Arduino-AVR, то увидите, что выводы портов идут вразбивку; последний вывод данных оказывается подключенным к порту PB0, тогда как первые три — к старшим битам порта D. Можно просто сдвинуть эту тетраду на младшие биты порта B, но тогда мы «наедем» на выводы программирования (PB3 = MISO), а без нужды этого делать не следует. Да и просто неудобно в отладке: придется все время отключать-подключать программатор.
В ATmega8 удобно подключиться к четырем младшим битам порта C, которые идут подряд в том числе и в разводке выводов (и не только для DIP-корпуса). При этом мы теряем младшие входы АЦП (ADC0-ADC3), но у нас остаются еще два (ADC4-ADC5), а в планарном 32-выводном корпусе TQFR еще имеются и дополнительные ADC6-ADC7. Выводы PC4-PC5 (ADC4-ADC5), кроме того, заняты аппаратным I2C (SDA, SCL), но на ассемблере удобней пользоваться программным, которому можно назначить любые два вывода любых портов, и программа при этом получается как минимум не сложнее официального способа. Ее применение мы увидим ближе к концу этой статьи на примере чтения часов реального времени (RTC).
Выводы RS и E мы оставим подключенными к портам PD3 и PD4. В результате получим такую схему:
На схеме к контроллеру подключен кварцевый резонатор 4 МГц, все программы подогнаны под эту частоту тактирования. Однако, для управления дисплеями источник частоты неважен — можно работать и от встроенного генератора (для ATmega8 частоту 4 МГц дает установка фьюзов CKSEL3:0=0011
) и от внешнего кварца (для ATmega8 фьюзы CKSEL3:0=1111
). Источник тактирования выбирается в зависимости от требований остальной части схемы: так, например, для нормальной работы UART встроенный генератор применять не рекомендуется (у нас далее последовательный порт применяется для начальной установки часов). Для другой частоты тактирования нужно будет пересчитать постоянные для подсчета задержек, о чем подробнее далее.
Я традиционно пользуюсь 10-контактным разъемом программирования (под него заточены крайне удобные программаторы AS-2/3/4), и именно он приведен на схеме. Для подключения к более распространенному и компактному 6-контактному потребуется элементарный переходник или просто установка другого разъема (см. конфигурации). Если у вас контроллер в DIP-корпусе, то его удобно программировать отдельно (можно просто на макетке), а потом установить в схему на панельку.
Программа
Обычный ATmega8 существует в нескольких разновидностях, соответственно в последних версиях Atmel Studio имеются две модификации файла макроопределений: m8def.inc просто и m8adef.inc. Разницы между ними я не нашел никакой, потому можно применять любой из них для любой модификации контроллера.
Общие части программы управления (инициализацию дисплея, вывод команд и данных) я вынес в отдельный файл, назвав его LCD1602.prg. Такое расширение файла — неофициальная придумка, с целью отличия его от законченной программы, можно оставить и официальное .asm, если хочется. Таким образом мы получаем некий аналог библиотеки, его добавляют в конечную программу через обычную директиву .include
. Учтите, что никакой оптимизации тут нет, и .include
просто тупо копирует исходный текст указанного в ней файла в конечную программу, учитываются только директивы условной компиляции, если они есть.
Для начала нам потребуются процедуры задержек, они необходимы для формирования правильного обмена контроллера с дисплеем — контроллер ведь работает гораздо быстрее дисплея. Кроме самой первой задержки на установление питания перед инициализацией, в принципе без них можно обойтись, если сформировать полностью корректный протокол с проверкой флага занятости (busy flag — см. процедуры инициализации и загрузки в даташитах на дисплеи), но этого никто не делает, так как проверка сильно загромождает программу. Проще просто немного задержать подачу следующей команды.
Задержки будем формировать программным путем (подобно функции delayMicroseconds()
в Arduino), последовательным уменьшением на единицу некоего числа:
Delay:
subi Razr0,1
sbci Razr1,0
;sbci Razr2,0 – если потребуется 3-й регистр
brcc Delay
Длительность такой процедуры — по одному такту на каждое вычитание (команды subi
или sbci
), плюс два такта на переход обратно к началу цикла (brcc
). В общем случае число N, соответствующее нужному интервалу времени T (с) при тактовой частоте fтакт (Гц) можно получить по формуле N = T∙fтакт/(r+2), где r — число регистров. Соответственно, один задействованный регистр при частоте 4 МГц даст максимальную задержку (N = $FF = 255) примерно 200 мкс, два (N = $FFFF = 65535) — 65,5 мс, три (N = $FFFFFF = 16777215) — около 21 сек. Нам понадобятся задержки 150 мкс, 5 мс и 500 мс, они определены опытным путем, и подходят для любых типов дисплеев.
Можно сделать одну универсальную процедуру Delay
(сэкономив количество команд в коде), но для удобства программирования сделаем три отдельных процедуры задержек:
Del_150mks: ;процедура задержки на 150 мкс
cli ;запрещаем прерывания
push Razr0
ldi Razr0,200 ;
Del_50: dec Razr0
brne Del_50
pop Razr0
sei ;разрешаем прерывания
ret
;N = TF/4 5 ms N= 5000 при 4 МГц
Del_5ms:
cli ;запрещаем прерывания
push Razr0
push Razr1
ldi Razr1,high(5000) ;старший байт N
ldi Razr0,low(5000) ;младший байт N
R5_sub:
subi Razr0,1
sbci Razr1,0
brcc R5_sub
pop Razr1
pop Razr0
sei ;разрешаем прерывания
ret
;N = TF/5 500 ms N= 400000 при 4 МГц
Del_500ms:
cli ;запрещаем прерывания
push Razr0
push Razr1
push Razr2
ldi Razr2,byte3(400000) ;старший байт N
ldi Razr1,high(400000) ;средний байт N
ldi Razr0,low(400000) ;младший байт N
R200_sub:
subi Razr0,1
sbci Razr1,0
sbci Razr2,0
brcc R200_sub
pop Razr2
pop Razr1
pop Razr0
sei ;разрешаем прерывания
ret
Итого нам потребуется три регистра. Чтобы их можно было задействовать в основной программе для каких-то других целей, прерывания на время задержек запрещаются (команды cli/sei
), а регистры помещаются в стек в начале и извлекаются в конце (команды push/pop
). Конечно, если регистров хватает, то лучше для других целей использовать свободные, как мы и будем поступать далее. Ассемблер avrasm2 (в отличие от старого avrasm32) не любит переименований, и будет на них выдавать предупреждения (warnings, см. программу реальных часов далее).
Кроме этих трех регистров, нам в этом «библиотечном» файле еще потребуется всего одна рабочая переменная, которую назовем традиционно temp
. Итого инициализация регистров в начале программы будет выглядеть так:
. . . . .
.def temp = r16 ; рабочий регистр
;регистры r17-r19 помещаются в стек:
.def Razr0 = r17 ;счетчик задержки
.def Razr1= r18 ;счетчик задержки
.def Razr2 = r19 ;счетчик задержки
. . . . .
Кроме этого, мы для удобства присвоим имена битам, управляющим выводами RS и E дисплея, а также битам установки адреса знакогенератора и установки номера строки для соответствующих команд::
.equ E = 4 ;PD4 (вывод 6 контроллера)
.equ RS = 3 ;PD3 (вывод 5 контроллера)
.equ Addr = 7 ;бит7=1 команда установки адреса в RAM
.equ Str = 6 ;бит6=0 - строка 1, бит6=1 - строка 2
Теперь можно приступать к процедурам. Сначала придется оформить две процедуры для вывода команд по 4-битовому интерфейсу:
LCD_command: ;выводим тетраду команды из младших бит temp
cbi PortD,RS ;RS=0
out PortC,temp ;выводим младшую PC3..0
sbi PortD,E ;E=1 - строб 1 mks
nop ;1 mks при 4 МГц
nop
nop
nop
cbi PortD,E ;E=0
ret
LCD_command_4bit: ;выводим байт команды из temp в два приема
cbi PortD,RS ;RS=0
swap temp ;
out PortC,temp ;выводим старший PC0..3
sbi PortD,E ;E=1 - строб 1 mks
nop ;1 mks
nop
nop
nop
cbi PortD,E ;E=0
nop ;1 mks
nop
nop
nop
swap temp ;
out PortC,temp ;выводим младший PC0..3
sbi PortD,E ;E=1 - строб 1 mks
nop ;1 mks
nop
nop
nop
cbi PortD,E ;E=0
ret
Почему две? Первая выводит в порт данных дисплея только четыре бита (младших), вторая осуществляет вывод полного байта по 4-битовому интерфейсу в один прием. Иными словами, две процедуры LCD_command
заменяют одну LCD_command_4bit
. То есть в принципе достаточно одной (второй), но я слизнул эту идею из кода LiquidCrystal — хотя в даташитах на дисплеи процедура указана обычно иначе, но это соответствует оригинальному описанию HD44780 от фирмы Hitachi, и все работает отлично («работает — не трогай!»). Первая процедура понадобится только в начале инициализации, которая здесь выглядит следующим образом:
LCD_ini: ;все почти как в LiqidCrystal
ldi temp,0b00011000 ;PB3,PB4 на выход
out DDRD,temp
cbi PortD,E ;E=0
ldi temp,0b00001111 ;PC0-PC3 на выход
out DDRC,temp
rcall Del_500ms ;ждем 0,5 с - установление питания
ldi temp,0b00000011
rcall LCD_command
rcall Del_5ms
ldi temp,0b00000011
rcall LCD_command
rcall Del_5ms
ldi temp,0b00000011
rcall LCD_command
rcall Del_5ms
#ifdef Rus_table
;для Wistar OLED
ldi temp,0b00101010 ;DL=1-4 bit N=1–2 строки, FT=10-рус/англ таблица
#else
;для остальных рус/англ дисплеев
ldi temp,0b00101000 ;DL=1 - 4 bit N=1 – 2 строки,
#endif
rcall LCD_command_4bit
rcall Del_5ms
ldi temp,0b00001000
rcall LCD_command_4bit ;дисплей Off
rcall Del_5ms
ldi temp,0b00000001
rcall LCD_command_4bit ;дисплей clear
rcall Del_5ms
ldi temp,0b00000110
rcall LCD_command_4bit ;I/D=1 - инкремент S=0 - сдвиг курсора
rcall Del_5ms
#ifdef Blink
;включение с миганием
ldi temp,0b00001101 ;D=1-дисплей On B=1-мигает символ в позиции курсора
#else
;просто включение
ldi temp,0b00001100 ;D=1- дисплей On
#endif
rcall LCD_command_4bit
rcall Del_5ms
ldi temp,0b10000000 ;курсор в позицию 0,0
rcall LCD_command_4bit ;
rcall Del_5ms
ret
Задержки подобраны, как уже говорилось, опытным путем и процедура работает безупречно на всех проверенных мной типах дисплеев, причем запуск проходит быстрее и совершено без сбоев, в отличие от LiquidCrystal в Arduino. В отдельных случаях может потребоваться включение-выключение питания после первой загрузки программы.
Здесь применена условная компиляция в двух местах. Во-первых, это опция мигания в позиции курсора, о которой мы говорили ранее. Для включения этой опции нужно в основной программе где-то перед строкой .include "LCD1602.prg"
вставить строку #define Blink
. Во-вторых, опция включения русской таблицы в OLED-дисплеях Winstar (она имеет номер 0x02), она включается строкой #define Rus_table
.
Правкой значений двух младших бит в этой опции можно также включать и другие кодировочные таблицы. У русифицированных LCD-дисплеев по умолчанию (то есть номер 0x00) стоит таблица, аналогичная таблице 0x02 Winstar, но часто встречаются и другие случаи. Например, у дисплеев МЭЛТ вторая таблица (номер 0x01) содержит кириллицу в кодировке 1251 (ANSI), что позволяет вводить русский текст в ассемблерной программе непосредственно с клавиатуры (при условии, что именно такая кодировка установлена у вас в редакторе кода).
Далее нам понадобится процедура вывода данных (она отличается от вывода команды сочетанием уровней на RS и E):
LCD_data: ;выводим код сисмвола из temp в 2 приема
#ifdef Zerosymb
cpi temp,$30 ;код цифры ноль
brne Z_ok
ldi temp,'O' ;подменяем ноль на букву О
Z_ok:
#endif
sbi PortD,RS ;RS=1
swap temp ;
out PortC,temp ;выводим старший PC0..3
sbi PortD,E ;E=1 - строб 1 mks
nop ;1 mks
nop
nop
nop
cbi PortD,E ;E=0
nop ;1 mks
nop
nop
nop
swap temp ;
out PortC,temp ;выводим младший PC0..3
sbi PortD,E ;E=1 - строб 1 mks
nop ;1 mks
nop
nop
nop
cbi PortD,E ;E=0
rcall Del_150mks
ret
Все процедуры вывода данных в дисплей, как видите, обременены некоторым количеством команд nop
— таким образом реализована пауза в 1 мкс для надежной установки уровней на выводах контроллера дисплея (частота 4 МГц все-таки для него высоковата). Здесь также применена условная компиляция — для замены ненавидимого мной перечеркнутого нуля на букву «О» (все иллюстрации в этой статье сделаны с такой заменой). Чтобы включить эту опцию, в основной программе должно стоять #define Zerosymb
.
Осталось две необходимых процедуры. Одна из них — установка курсора на нужное место строка: позиция (в обратном порядке, чем в LiquidCrystal, что кажется мне более естественным). Эту процедуру оформляем в виде макроса для удобства указания параметров:
.macro Set_cursor
push temp ;чтобы не думать о сохранности temp
ldi temp,(1<<Addr)|(@0<<Str)+@1;курсор строка @0(0-1) позиция @1(0-15)
rcall LCD_command_4bit ;
rcall Del_5ms
pop temp
.endm
Сохранение рабочей переменной temp в стеке здесь применено для того, чтобы можно было помещать вызов этого макроса в любое место основной программы, в которой рабочая переменная также будет широко использоваться.
И последняя процедура рисует значок градуса и помещает его на место символа номер 1. Здесь, в отличие от LiquidCrystal, она оформлена в единую подпрограмму вместе с данными:
Symbol_degree: ;рисуем символ градуса
ldi temp,0b01001000 ;CGRAM адрес 0х01
rcall LCD_command_4bit ;
rcall Del_5ms
ldi temp,0b00001100
rcall LCD_data
ldi temp,0b00010010
rcall LCD_data
ldi temp,0b00010010
rcall LCD_data
ldi temp,0b00001100
rcall LCD_data
ldi temp,0b00000000
rcall LCD_data
ldi temp,0b00000000
rcall LCD_data
ldi temp,0b00000000
rcall LCD_data
ldi temp,0b00000000
rcall LCD_data
ldi temp,0b10000000 ;курсор в позицию 0,0
rcall LCD_command_4bit ;
rcall Del_5ms
ret
Демо-программа на ассемблере
Демо-программа будет работать аналогично показанной ранее на примере Arduino. Нам надо решить несколько проблем, которые в Arduino решаются как бы автоматически. Во-первых, это размещение строк-констант с названиями месяцев: их можно размещать в программной памяти, в SRAM (как в Arduino) или в EEPROM. Последний способ плох тем, что он не очень надежен (EEPROM может испортиться при невнимательном отношении к включению-выключению питания), зато строки можно загрузить заранее и отдельно от программы. Мы здесь для демонстрации применим два других способа: в демо-программе разместим массив строк в программной памяти, а в реальной программе часов (см. далее) загрузим их в SRAM в начале программы.
Вторая проблема заключается в том, что мы можем выводить информацию на дисплей только посимвольно, команд для выгрузки целой строки или числа (подобно тому, как это сделано в функции print()
), разумеется, не существует. Поэтому нам придется любое число, содержащее больше одного десятичного разряда, преобразовывать в десятичное (BCD) и выводить разряды по отдельности. Повторяю, что в RTC все величины (часы-минуты-секунды-дни-месяцы-годы) хранятся по отдельности и уже в BCD-форме, потому там у нас встанет обратная задача по формированию номера месяца из BCD-кода. Но проблема эта в реальных программах все равно встает при выводе данных с различных датчиков. Поэтому мы в демо-программе сразу покажем, как решается и эта задача, облегчив себе ее тем, что будем считать отдельно часы и минуты — чтобы не возиться с преобразованиями больших чисел.
Создадим новый файл OLED1602_proba.asm, в той же папке, где размещена «библиотека» LCD1602.prg. Объявим регистры-переменные и включим опции (для OLED-дисплея я их включаю все). Начало программы будет таким:
.include "m8def.inc"
#define Blink ;включено мигание в позиции курсора
#define Rus_table ;включена поддержка русской таблицы (для OLED)
#define Zerosymb ;вкл подмена 0 на букву О
;.def temp = r16 ; рабочий регистр - определен в LCD1602.prg
;.def Razr0 = r17 ;счетчик задержки - определен в LCD1602.prg
;.def Razr1= r18 ;счетчик задержки - определен в LCD1602.prg
;.def Razr2 = r19 ;счетчик задержки - определен в LCD1602.prg
.def temp1 = r20 ;вспомогательный регистр
.def month = r21 ;номер месяца
.def hour = r22 ;число часов
.def min = r23 ;число минут
rjmp RESET ;Reset Handler
Закомментированные определения — для памяти, чтобы все время не вспоминать, какие регистры уже заняты в «библиотечном» файле .prg и подо что.
Здесь из всех векторов прерывания задействован только самый первый Reset
, по нулевому адресу в памяти. Сразу после него можно уже размещать массив названий месяцев:
m_name:
.db $20,$C7,$BD,$B3,'a','p',$C7,$20,
$E4,'e',$B3,'p','a',$BB,$C7,$20,
$20,$BC,'a','p',$BF,'a',$20,$20,
$20,'a',$BE,'p','e',$BB,$C7,$20,
$20,$20,$BC,'a',$C7,$20,$20,$20,
$20,$20,$B8,$C6,$BD,$C7,$20,$20,
$20,$20,$B8,$C6,$BB,$C7,$20,$20,
'a',$B3,$B4,'y','c',$BF,'a',$20,
'c','e',$BD,$BF,$C7,$B2,'p',$C7,
'o',$BA,$BF,$C7,$B2,'p',$C7,$20,
$20,$BD,'o',$C7,$B2,'p',$C7,$20,
$E3,'e',$BA,'a',$B2,'p',$C7,$20
Обратный слеш, если кто не знает, позволяет в AVR-ассемблере разбивать длинные строки. Как и в случае Arduino, название каждого месяца дополнено пробелами до 8 символов. Русские буквы обозначаются HEX-кодами, в соответствии с таблицей знакогенератора. Для удобства я ее привожу здесь в максимально обезличенном виде (на пустых местах в разных дисплеях размещаются разные символы; например, для МЭЛТ в позиции $99 имеется нормальный значок градуса):
Размещение массива в самом начале программы имеет смысл, заключающийся в том, чтобы он занял место в пределах одного байтового сегмента. Извлекать данные мы будем через двухбайтовый указатель ZH:ZL
, и чтобы не возиться с добавлением смещения конкретного месяца к двухбайтовому числу, мы будем добавлять только его только к младшему регистру ZL
.
Далее нам надо не забыть добавить нашу библиотеку:
.include "LCD1602.prg"
Мы могли бы добавить массив и после нее — код библиотеки занимает 300 байт (с учетом первой команды rjmp — 302 байта), а объем массива 96 байт, так что он оказался бы все равно в пределах одного байтового сегмента (второго, а не самого первого). Но это нужно все считать, и несложно промахнуться при изменениях программы, так что надежнее либо размещать в самом начале, либо уж делать по правилам: добавлять смещение к двухбайтовому указателю.
Далее нам понадобится одна-единственная вспомогательная процедура bin2bcd8 — преобразование 8-битового HEX-числа в BCD-код:
;преобразование 8-разрядного hex в неупакованный BCD
;вход hex = temp, выход BCD temp1 — старший, temp — младший
bin2bcd8: ;вход hex= temp, выход BCD temp1-старш; temp - младш
clr temp1 ;clear result MSD
bBCD8_1:
subi temp,10 ;input = input - 10
brcs bBCD8_2 ;abort if carry set
inc temp1 ;inc MSD
rjmp bBCD8_1 ;loop again
bBCD8_2:
subi temp,-10 ;compensate extra subtraction
ret
Англоязычные комменты перекочевали сюда из старой атмеловской аппноты 204, откуда заимствована эта процедура.
Теперь можно писать собственно программу. Она, согласно традиции, начинается с необходимых установок по метке Reset
(аналог функции setup()
):
RESET:
ldi temp,low(RAMEND)
out SPL,temp
ldi temp,high(RAMEND) ;указатель стека
out SPH,temp
clr hour ;
clr min ;обнулили минуты и часы
rcall LCD_ini ;инициализация дисплея
rcall Symbol_degree ;рисуем символ градуса
. . . . .
Далее идет начальное заполнение дисплея символами, которые больше не будут меняться:
;=== начальный вывод
Set_cursor 0,0 ;курсор строка 0 позиция 0
ldi temp,'0' ;0
rcall LCD_data
ldi temp,'0' ;0
rcall LCD_data
ldi temp,':' ;:
rcall LCD_data
ldi temp,'0' ;0
rcall LCD_data
ldi temp,'0' ;0
rcall LCD_data
Set_cursor 0,9 ;курсор строка 0 позиция 9
ldi temp,'-' ;минус
rcall LCD_data
ldi temp,'1' ;1
rcall LCD_data
ldi temp,'2' ;2
rcall LCD_data
ldi temp,',' ;запятая
rcall LCD_data
ldi temp,'3' ;3
rcall LCD_data
ldi temp,$01 ;рисованный значок градуса
rcall LCD_data
ldi temp,'C' ;C
rcall LCD_data
. . . . .
< и так далее – вторая строка>
. . . . .
;в конце обязательно ставим курсор в позицию двоеточия:
Set_cursor 0,2 ;курсор строка 0 позиция 2 - мигает аппаратно
. . . . .
Далее программа в цикле посекундно меняет названия месяцев, а также после каждого такого 12-секундного цикла увеличивает значение минут и часов (в отличие от Arduino-программы, которая обновляла часы вместе с месяцами каждую секунду). Обратите внимание, что здесь месяцы нумеруются с нуля, то есть в реальности при получении их с часов нужно вычитать единицу:
Gcykle:
;=== перебираем месяцы по названию
ldi month,0 ;нулевой месяц - январь
mon_num: ;перебираем месяцы от 0 до 11
Set_cursor 1,3 ;курсор строка 1 позиция 3 (0-15)
mov temp,month
lsl temp
lsl temp
lsl temp ;умножили на 8
ldi ZH,high ((m_name)*2) ;адрес начала массива названий
ldi ZL,low ((m_name)*2)
add ZL,temp ;прибавляем адрес названия
ldi temp1,8 ;повторяем для 8 символов месяца
mon_view: ;загружаем строку 8 символов
lpm ;очередной байт массива в r0
mov temp,r0
rcall LCD_data
adiw ZL,1
dec temp1
brne mon_view
Set_cursor 0,2 ;курсор строка 0 позиция 2 - мигает аппаратно
rcall Del_500ms
rcall Del_500ms ;пауза 1 с
inc month
cpi month,12
brlo mon_num
;=== добавляем минуты и часы
inc min ;увеличиваем минуты
cpi min,60
brlo out_time ;если меньше 60, то на вывод времени
clr min ;иначе обнуляем минуты
inc hour ;увеличиваем часы
cpi hour,24
brlo out_time ;если меньше 24, то на вывод времени
clr hour ;иначе обнуляем минуты
;==== выводим минуты и часы
out_time:
mov temp,hour
rcall bin2bcd8 ;преобразуем в BCD, temp1 — старший, temp — младший
subi temp,-$30 ;код младшей цифры часов = значение +$30
Set_cursor 0,1 ;курсор строка 0 позиция 1
rcall LCD_data ;выводим младший
mov temp,temp1
subi temp,-$30 ;код старшей цифры часов = значение +$30
Set_cursor 0,0 ;курсор строка 0 позиция 0
rcall LCD_data ;выводим старший
mov temp,min
rcall bin2bcd8 ;преобразуем в BCD, temp1 — старший, temp — младший
subi temp,-$30 ;код младшей цифры минут = значение +$30
Set_cursor 0,4 ;курсор строка 0 позиция 4
rcall LCD_data ;выводим младший
mov temp,temp1
subi temp,-$30 ;код старшей цифры часов = значение +$30
Set_cursor 0,3 ;курсор строка 0 позиция 3
rcall LCD_data ;выводим старший
rjmp Gcykle ;к самому началу
Развернуто комментировать тут особо нечего, все основное сказано ранее. Программа OLED1602_proba займет в памяти 702 байта (Arduino-аналог занимает почти 4 кбайта, притом, что данные у него размещаются в ОЗУ, а не вместе с программой). Полностью архив со всем приведенным здесь программами можно скачать по ссылке в конце статьи.
Рабочая программа часов
Тут необходима даже не одна, а две программы. Для начала нам понадобится начальная установка часов, и я ее выделил в отдельную программу, так как коррекция требуется нечасто — популярный модуль на основе DS3231 вполне прилично держит время как минимум в течение полугода-года. Так что перегружать основную программу редко используемой функциональностью я не стал — здесь она приводится все-таки в иллюстративных целях. А при желании объединить установку с основной программой можно и самостоятельно.
Полная схема подключения часов:
В схеме можно применять как готовые модули RTC (выводы обозначены красным цветом), так и самостоятельно изготовленные. Программа далее рассчитана на два типа RTC — стандартный DS1307, и более точный DS3231. Схема типового модуля на основе стандартной DS1307 обведена красным прямоугольником, DS3231 мало от нее отличается, только выводов у самой микросхемы больше. Вне зависимости от типа RTC, готовый модуль обязательно должен иметь вывод частоты SQW.
Основная программа (подробнее о ней далее) представляет собой просто часы, датчик температуры мы подключать не будем, чтобы не загромождать пример — для него потребуется еще много дополнительной функциональности, а статья не резиновая. И так придется растекаться в стороны, так как и для установочной и для основной программы потребуются манипуляции с I2C-интерфейсом. Соответствующие ассемблерные процедуры софтового I2C-порта у меня также выделены в отдельную «библиотеку» i2c.prg. Разбирать я ее подробно не буду — все подробности см. в книге. Работает «библиотека» с любыми двумя цифровыми выводами контроллера, по умолчанию настроена на выводы PD6 (SCL) и PD7 (SDA), (выводы 12 и 13 ATmega8 в DIP-корпусе). Для изменения выводов, в том числе и под другие контроллеры, следует править файл i2c.prg, в нем самом все подробно расписано в комментариях.
Программа начальной установки часов
Установочная программа rtc_set.asm рассчитана на работу с монитором последовательного порта Arduino, как самого доступного и простого в использовании, но при этом самого замороченного с точки зрения форматов данных — он ведь рассчитан на Arduino-библиотеку print()
, которая как бэ автоматически с этими форматами разбирается. При работе через другой монитор порта учтите, что посылать команды следует в текстовом (ASCII) виде, как указано далее. Как мы говорили, программа рассчитана на два типа RTC — DS1307 и DS3231. У них несколько различаются регистры управления и состояния по умолчанию, потому в программе применена условная компиляция: для стандартной DS1307 перед компиляцией необходимо закомментировать строку #define ds3231
.
После загрузки программы к порту UART контроллера (PD0 и PD1, выводы 2 и 3 DIP-корпуса) подключается любой USB-UART адаптер (напомню, что выводы RxD и TxD разных устройств соединяются перекрестно). При подключении адаптера не забудьте, что у контроллера может быть только одно питание: либо от адаптера, либо от внешнего источника (в последнем случае вывод VCC адаптера не должен подключаться к схеме!). Скорость работы порта установлена 9600, такую же скорость надо установить и в мониторе порта. UART-адаптер (кроме питания!) можно подключать без отключения дисплея и датчика — в установочной программе задействованы только выводы UART и установленные нами выводы I2C, остальные не затрагиваются.
Обратите внимание на соединение вывода часов SQW с портом PD2. На этом выводе после инициализации выдается секундный меандр, который будет вызывать прерывание INT0, и через него управлять работой контроллера. В данном случае это просто чтение значений из часов и посылка их через UART, но в основной программе это управление немного усложнится.
Пошлите через монитор порта заглавную букву «R» (read). По этой команде часы инициализируются и значения времени будут выдаваться в порт каждую секунду в том порядке, в каком они размещены в регистрах часов: Секунды, Минуты, Часы, День недели, Дата, Месяц, Год. Остановить вывод можно, подав в качестве команды английское «T» (terminate). При первом включении, скорее всего, будет выдаваться полная несуразица. Однако, для корректной инициализации необходимо команду «R» послать перед остальными действиями.
Установка часов выполняется посылкой команды «S» (set), в одной строке с которой посылаются значения времени и даты в том же порядке (в текстовом виде, обязательно в двухразрядном исполнении, то есть с ведущим нулем, если это необходимо). Секунды не посылаются, они автоматически установятся в ноль. Например, такая строка установки: S 23 17 03 27 01 21
установит часы на 25.01.21, среда (03 день недели), время 17:23. Для установки удобно использовать часы Windows, предварительно уточнив их показания через интернет. Строку следует сформировать заранее на минуту-другую раньше указанного в ней времени, и отправить ее нажатием клавиши Enter в момент, когда секундная стрелка на часах Windows дойдет до начала указанной минуты. Вместе с установкой всего времени секунды сбросятся в нулевое значение и часы пойдут с начала установленной минуты.
Программа часов
Здесь нам понадобится больше переменных, и мы применим частичное пересечение регистров с уже задействованными (тем более, что еще некоторые применяются в процедурах I2C):
;.def temp = r16 ; рабочий регистр - определен в LCD1602.prg и в i2c.prg
;.def Razr0 = r17 ;счетчик задержки - определен в LCD1602.prg
;.def Razr1= r18 ;счетчик задержки - определен в LCD1602.prg
;.def Razr1= r19 ;счетчик задержки - определен в LCD1602.prg
; регистр r17 занят также в i2c.prg
.def sek = r18 ;счетчик сeкунд
.def bcdD = r19 ;дес. часов
.def bcdE = r20 ;ед. часов
.def month = r21 ;месяц
.def temp1 = r22 ;вспомогательный
На это дело ассемблер выдаст ряд warnings, но не обращайте на них внимания — в программе рассчитано все так, чтобы одни и те же регистры не мешали друг другу в различных применениях.
Программа часов отличается от демо-программы выше размещением массива названий месяцев не в программной памяти, а в SRAM. Поскольку здесь это единственный объект, размещаемый в этой памяти, то пользоваться директивой .byte
, резервирующей место в памяти, нет никакой нужды — мы просто разместим массив подряд, начиная с начала SRAM (ориентируясь на константу SRAM_start
). К сожалению, никаких средств, столь же удобных, как директивы типа .db
для размещения констант в программной памяти и EEPROM, для SRAM не предлагается. Поэтому мы составим длиннющую процедуру, которая в начале программы будет посимвольно загружать русские названия месяцев. Они такие же, как в массиве m_name
демо-программы, только выровнены пробелами до 10 знаков, а не до 8:
Store_month: ;пишем в SRAM названия месяцев, не более 8 символов
;выровненные пробелами справа и слева до 10 символов
ldi ZH,High(SRAM_START) ;старший байт начала RAM = 0
ldi ZL,Low(SRAM_START) ;младший байт начала RAM
;января
ldi temp,' ';пробел
st Z+,temp
ldi temp,' ';пробел
st Z+,temp
ldi temp,$C7 ;буква я
st Z+,temp
ldi temp,$BD ;буква н
st Z+,temp
ldi temp,$B3 ;буква в
st Z+,temp
ldi temp,'a'
st Z+,temp
ldi temp,'p'
st Z+,temp
ldi temp,$C7 ;буква я
st Z+,temp
ldi temp,' ';пробел
st Z+,temp
ldi temp,' ';пробел
st Z+,temp
. . . . . ;остальные месяцы
;декабря
ldi temp,' ';пробел
st Z+,temp
ldi temp,$E3 ;буква д
st Z+,temp
ldi temp,'e'
st Z+,temp
ldi temp,$BA ;буква к
st Z+,temp
ldi temp,'a'
st Z+,temp
ldi temp,$B2 ;буква б
st Z+,temp
ldi temp,'p'
st Z+,temp
ldi temp,$C7 ;буква я
st Z+,temp
ldi temp,' ';пробел
st Z+,temp
ldi temp,' ';пробел
st Z+,temp
ret
Массив займет в памяти 120 байт, память у ATmega8 начинается с адреса 96 (SRAM_start
), то есть весь массив уложится в один байтовый сегмент, и мы, как и ранее, спокойно можем манипулировать одним младшим регистром указателя ZL. Только в прошлый раз для определения смещения мы умножали на 8 троекратным сдвигом, а здесь применим операцию аппаратного умножения mul
(иным словами, этот алгоритм годится только для Mega). Вот так мы будем извлекать название месяца из памяти по его номеру, и выводить символы на дисплей:
. . . . .
;извлекаем месяц из памяти
ldi ZH,High(SRAM_START) ;старший байт начала RAM
ldi ZL,Low(SRAM_START) ;младший байт начала RAM
dec month ;адрес на 1 меньше, чем номер месяца
ldi temp,10
mul month,temp ;умножили на 10
add ZL,r0 ;прибавили к адресу результат - он уместится в один байт
ldi temp1,10 ;10 символов выводим
out_month: ;выводим символы месяца
ld temp,Z+
rcall LCD_data
dec temp1
brne out_month
. . . . .
Курсор при этом уже установлен на нужную позицию ранее выведенными символами даты — потому здесь названия и выравниваются до 10 знаков, чтобы не было нужды переставлять курсор каждый раз.
Вернемся к извлечению данных из часов — например, как мы получаем номер месяца для приведенного фрагмента программы? В часах реального времени все хранится в BCD-форме, то есть минуты-часы, а также дату и год нам надо просто поделить поразрядно и преобразовать в символы. Начало процедуры извлечения и преобразования тогда выглядит так:
ReadClk_m: ;чтение часов и вывод
ldi YL, 0b11010000 ;адрес device DS1307
ldi YH,1 ;адрес регистра минут
sbis PinD,pSDA
ret ;выход, если линия занята
rcall start
mov DATA,YL ;адрес device DS1307, r/w=0
rcall write
mov DATA,YH ;адрес регистра минут
rcall write
rcall start
sbr YL,1 ;r/w=1
mov DATA,YL ;адрес device DS1307, r/w=1
rcall write
set ;put ACK
rcall read ;min
;BCD минуты - в temp, выход - bcdD:bcdE
mov bcdE,temp
andi bcdE,0b00001111 ;выделяем младший
mov bcdD,temp
andi bcdD,0b11110000 ;выделяем старший
swap bcdD ;меняем тетрады местами
;минуты прочитали - выводим, верхняя строка - время
Set_cursor 0,9 ;курсор строка 0 позиция 9 минуты
subi bcdD,-$30 ;код старшей цифры минут = цифра +$30
mov temp,bcdD
rcall LCD_data
subi bcdE,-$30 ;код младшей цифры минут = цифра +$30
mov temp,bcdE
rcall LCD_data
. . . . .
; <далее аналогично минуты, часы, день недели, дату, месяц, год>
ret
С месяцем сложнее, его номер придется вычислять преобразованием BCD-числа в HEX, то есть совершать обратное преобразование к тому, что мы делали в предыдущем случае:
bcd2bin8: ;bcd -> в обычное число
;только для Mega!!!
;на входе в temp упакованное BCD-значение
;на выходе в month hex-значение
ldi temp1,10
mov month,temp
andi temp,0b11110000 ;выделяем старший
swap temp ;старший в младшей тетраде
mul temp,temp1 ;умножаем на 10, в r0 результат умножения
mov temp,month ;возвращаемся к исходному
andi temp,0b00001111 ;выделяем младший
add temp,r0 ;получили hex
mov month,temp ;возвращаем в month
ret
В результате вывод строки месяца получается такой:
. . . . .
;BCD месяц
rcall read ;month
rcall bcd2bin8 ;месяц - число в регистре month
;извлекаем месяц из памяти
ldi ZH,High(SRAM_START) ;старший байт начала RAM
ldi ZL,Low(SRAM_START) ;младший байт начала RAM
dec month ;адрес на 1 меньше, чем номер месяца
ldi temp,10
mul month,temp ;умножили на 10
add ZL,r0 ;прибавили к адресу результат - он уместится в один байт
ldi temp1,10 ;10 символов выводим
out_month: ;выводим символы месяца
ld temp,Z+
rcall LCD_data
dec temp1
brne out_month
. . . . .
Сама программа работает следующим образом: по прерыванию INT0, возникающему от вывода SQW часов каждую секунду, выполняется чтение только секунд. Прочитанное число сравнивается с нулем, и при совпадении вызывается описанная процедура полного чтения и вывода. Несмотря на ее кажущуюся навороченность, выполняется она всего за время порядка миллисекунды. Результат работы программы – на фото:
Полный текст программы OLED16x02_clock.asm и всех остальных, представленных статье — в архиве.
В следующей статье я покажу то, что в книге освещено недостаточно подробно: беспроводную передачу данных и заодно работу с одним из самых приличных и одновременно недорогих датчиков температуры.
Автор: Ревич Юрий