К вопросу о пинах

в 10:16, , рубрики: микроконтроллеры avr, программирование микроконтроллеров

Дню знаний посвящается...

Данный пост посвящен тому, с чем сталкиваются все пользователи Ардуино (далее по тексту А, имейте в виду что под этой буквой будет прятаться как сам кристалл, так и среда разработки программ), а именно с работой с портами ввода/вывода.

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

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

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

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

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

Например, если Вы хотите, чтобы расположенный на плате А светодиод засветился, то Вы должны на пин 13 подать уровень «Low», а для продвижения данных через микросхему сдвигового регистра сформировать на пине 6 переход из низкого уровня в высокий и так далее. Причем для первого случая — управления светодиодом дальнейшие рассуждения не имеют особого смысла, поскольку приемником информации является человеческий глаз, а его возможности по части быстродействия не превышают десятков Герц, то для управления сдвиговым регистром время изменения уровня на ножке весьма существенно, так как для вывода информации на внешнее устройство (например, семи-сегментный индикатор) Вам потребуется множество изменений и их совокупное время может оказаться неприемлемым для конкретного случая (будет блокировать работу остальных частей программы, критичных по периоду опроса, типа обработки энкодера, что нарушит их работу). Ну а если Вы управляете пином виртуального SPI, то получившееся быстродействие SD карточки Вас весьма неприятно удивит, так что задача ускорения работы с пинами вполне практическая.

Для управления пинами в А существуют предопределенные функции, главной из которых является DigitalWrite, которой Вы должны сообщить номер пина для модификации и значение на нем после выполнения функции. Однако, если у Вас после написания команды DigitalWrite(13,Low) проблемы закончились (при условии, что Вы не забыли где то раньше команду настройки режима пина), то у исполняющей системы они только начинаются. Дело в том, что существуют архитектуры МК, в которых каждый пин действительно имеет уникальный адрес, чем обеспечивается легкое отображения Вашей команды на систему команд МК, чем и занимается исполняющая система (связка компилятора и системной библиотеки), но фирма Atmel в те времена, когда создавалась А, своих поклонников подобными изысками не баловала (это не совсем верно, но в первом приближении так). В микроконтроллерах семейства Мега, на которые платформа А исторически базировалась, принята несколько иная схема работы с пинами. Здесь работа с внешним миром осуществляется не через уникальные пины, а через порты ввода/вывода, которые представляют собой совокупность пинов (в данном случае не более 8) и, соответственно, каждый пин имеет в физическом представлении 2 параметра — имя порта (представляется буквой от А до Е в разных представителях семейства МК) и номером бита внутри порта (цифра от 0 до 7). Так, например, пин 13 может иметь физический адрес РB.5 в одном МК, и РC.0 в другом.

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

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

void digitalWrite(uint8_t pin, uint8_t val) { 
uint8_t timer = digitalPinToTimer(pin); 
uint8_t bit = digitalPinToBitMask(pin);
uint8_t port = digitalPinToPort(pin); 
volatile uint8_t *out;
 if (port == NOT_A_PIN) return; 
// If the pin that support PWM output, we need to turn it off before doing a digital write. 
if (timer != NOT_ON_TIMER) turnOffPWM(timer);
out = portOutputRegister(port);
uint8_t oldSREG = SREG; 
cli(); 
if (val == LOW) { *out &= ~bit; } else { *out |= bit; } 
SREG = oldSREG; }

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

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

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

#define digitalPinToBitMask(P) ( pgm_read_byte( digital_pin_to_bit_mask_PGM + (P) ) )

И обнаруживаем, что это макрос-расширение, который передает номер пина другой функции и добавляет имя таблицы, где хранятся номера битов. Далее выясняем, что:

#define pgm_read_byte(address_short)  pgm_read_byte_near(address_short)

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

#define pgm_read_byte_near(address_short)  __LPM((uint16_t)(address_short))

Это… да, верно, макрос-обертка, Вы начинаете улавливать принцип, передающий свои аргументы в функцию:

#define __LPM(addr) __LPM_enhanced__(addr)

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

#define __LPM_enhanced__(addr)  (__extension__({  
uint16_t __addr16 = (uint16_t)(addr);  
uint8_t __result;  
__asm__  (  "lpm %0, Z" "nt"  : "=r" (__result)  : "z" (__addr16)  );  __result;  }))

Рассмотрим текст функции извлечения данных из таблицы более внимательно, тут есть ряд интересных моментов. Поскольку нам дана ассемблерная вставка, то следует учитывать особенности реализации архитектуры МК, для нас важно, что он 8-разрядный и аккумуляторный.

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

Сразу же подумаем об альтернативах — во-первых, это может быть вариантная реализация, основывающаяся на определении типа параметра, если это возможно в данном препроцессоре (я его не настолько хорошо знаю, чтобы привести данное решение), в-вторых, это может быть прямой запрет использования статически неопределенных выражений, когда конструкция DigitalWrite(BasePinNumber+6, Low) приведет к ошибке компилятора и Вам придется превратить ее в int PinNumber=BasePinNumber+6; DigitalWrite(PinNumber,Low), что мне представляется приемлемой ценой за увеличение быстродействия в остальных случаях.

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

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

Есть великолепный анекдот на эту тему. После одной из битв Наполеон спрашивает у маршала артиллерии, почему артиллерия не стреляла. Маршал отвечает: «На то было множество причин, Сир. Во-первых, у нас не было снарядов. Во-вторых ...». Наполеон прерывает его: «Достаточно»

… из которых в данный момент определяющей является то, что в ней весьма неудобно смотреть промежуточные файлы, в том числе код на языке ассемблер. Поэтому дальнейшие рассматриваемые результаты относятся к коду, получаемому в онлайн компиляторе gcc.godbolt.org в режиме AVR gcc 4.5.2 при включенной оптимизации -O. Сразу хочу заверить читателя, что данный компилятор порождает весьма эффективный код, если бы я писал его руками, то получилось бы не намного лучше (хотя все таки и чуть лучше), я думаю, что в А компиляторе результаты лучше, чем полученные этим способом, точно не будут.

Далее (вернее, чуть ранее) мы видим извлечение дополнительной информации из вспомогательной таблицы, которая сообщает нам, не является ли данный пин выходным портом таймера. Я не очень понимаю, как именно этот факт может повлиять на нежелание исполняющей системы работать с таким пином без отключения таймера (мне кажется, что в данном случае она на себя много берет), но то, что такая проверка требует дополнительного времени, для меня несомненно. Возможно, это наследие проклятого прошлого, истинный смысл подобного решения недоступен для непосвященных в сокровенные тайны А. Что мы можем сделать для ускорения работы? Ну можно совместить данную проверку с получением индекса порта, введя специальное значение, тем более, что такая подобная проверка индекса порта на допустимость осуществляется несколькими строками позже. Далее, мы можем определить условную компиляцию, предоставив пользователю возможность определить, а готов ли он платить временем исполнения (если бы речь шла о времени компиляции, я был бы только рад) за дополнительную проверку. Ну и как вишенка на торте, само условие проверки с использованием двойного отрицания мне лично представляется несколько вычурным, простое условие if (timer == IS_ON_TIMER)ничем не уступает оригинальному условию, но понятнее при прочтении. Обратим еще внимание на то, что получаем это значение в одном месте, а используем (причем один раз) намного позже, что тоже не есть красиво, что неизбежно для языка С, но у нас то С++, и можно сделать правильнее, хоть и не быстрее.

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

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

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

Смотрим дальше и замечаем, что номер бита извлекается в одну стадию, а адрес порта — в две стадии, сначала по номеру пина получаем индекс порта — число от 0 до количества портов, а затем на основании индекса извлекаем собственно адрес порта. Зачем так сделано, ведь это очевидно дольше, чем сразу получить нужный нам адрес — можно придумать два объяснения. Во-первых, такая методика дает бОльшую гибкость — честно говоря, притянутое за уши объяснение. Вторая возможная причина — экономия размера ПЗУ, которая составит в байтах количество пинов минус размер дополнительного кода, то есть байтов 6-8, что мне представляется явно недостаточной компенсацией за существенное уменьшение быстродействия. Более того, тот же результат может быть достигнут и при помощи указания в первой таблице не индекса, а смещения с последующим превращением его в адрес менее затратным путем сложения с базой или даже комбинирования, как продемонстрировано в моей реализации. Да, этот способ не столь переносим, как оригинальный, но насколько я помню, директивы условной компиляции никто не отменял. Вообще то, у меня складывается впечатление, что многие компоненты библиотек А делались на скорую руку из уже существующих универсальных (кто такой универсал — человек, который умеет делать множество дел одинаково плохо) заготовок, а дальше действовали по принципу «Эта штука работает? — Да. — Не трогай ее.» К сожалению, намного более быстрый способ обращения к портам через команды in и out в данном случае неприемлем, поскольку указать номер порта в качестве аргумента команды в общем случае невозможно.

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

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

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

И еще один маленький камешек в огород А — из текста функции нетрудно видеть, что описание работы функции не вполне верно — если значение второго параметра равно LOW, то бит будет сброшен, в противном случае (а не если параметр будет равен HIGH, как в описании) бит будет установлен. Если этот параметр принимает только указанные значения, то данное уточнение не имеет смысла, но в оригинальной функции его значения ничем не ограничены, кроме доброй воли программиста.

Для того, чтобы Вам были понятны следующие вычисления, под спойлером лежит получившийся при компиляции код с комментариями:

Ассемблерный код


 digitalWrite(unsigned char, unsigned char):

преамбула 9 тактов
        push r15
        push r16
        push r17
        mov r16,r22
        mov r18,r24                           
        ldi r19,lo8(0)

uint8_t timer = digitalPinToTimer(pin);  7 тактов
        mov r30,r18
        mov r31,r19
        subi r30,lo8(-(digital_pin_to_timer_PGM))
        sbci r31,hi8(-(digital_pin_to_timer_PGM))
        lpm r24, Z

uint8_t bit = digitalPinToBitMask(pin); 7 тактов
        mov r30,r18
        mov r31,r19
        subi r30,lo8(-(digital_pin_to_bit_mask_PGM))
        sbci r31,hi8(-(digital_pin_to_bit_mask_PGM))
        lpm r17, Z

uint8_t port = digitalPinToPort(pin);  7 тактов
        subi r18,lo8(-(digital_pin_to_port_PGM))
        sbci r19,hi8(-(digital_pin_to_port_PGM))
        mov r30,r18
        mov r31,r19
        lpm r15, Z

if (port == NOT_A_PIN) return;  2 такта
        tst r15
        breq .L12

if (timer != NOT_ON_TIMER) turnOffPWM(timer); 3 такта в случае невызыва функции, в случае вызова лучше не считать 
        cpse r24,__zero_reg__
        rcall turnOffPWM(unsigned char)

out = portOutputRegister(port); 14 тактов
        mov r30,r15
        ldi r31,lo8(0)
        lsl r30
        rol r31
        subi r30,lo8(-(port_to_output_PGM))
        sbci r31,hi8(-(port_to_output_PGM))
        lpm r24, Z+
        lpm r25, Z
        mov r30,r24
        mov r31,r25

uint8_t oldSREG = SREG;  1 такт
        in r24,__SREG__

cli(); 1 такт
        cli

if (val == LOW) { *out &= ~bit; } else { *out |= bit; } 10/8  для LOW/HIGH
        tst r16
        brne .L15
        ld r25,Z
        com r17
        and r17,r25
        st Z,r17
        rjmp .L16
.L15:
        ld r25,Z
        or r17,r25
        st Z,r17
.L16:

SREG = oldSREG; 1 такт
        out __SREG__,r24
.L12:
постамбула 10 тактов
        pop r17
        pop r16
        pop r15
        ret
main:
....
собственно вызов 4 такта
        ldi r24,lo8(1)
        ldi r22,lo8(0)
        rcall digitalWrite(unsigned char, unsigned char)
.....        ret

Теперь можно подвести итоги. Вызов+преамбула+постамбула — 4+9+10 = 23, защита ресурса — 3, защита таймера — 7+3 = 10, защита пина — 2 получение номера бита — 7, получение адреса порта — 7+14 = 21, модификация значения — 10/8, что дает нам время исполнения функции 76/74 такта, или при тактовой частоте МК 16 МГц составит 4.75/4.625 мксек — результат вполне ожидаемый для того, кто видел исходный код и знаком с архитектурой AVR. В разных источниках я видел разные цифры времени исполнения функции digitalWrite, но они были только больше полученных в данном случае.

Интересное наблюдение — время установки и сброса бита различаются, что не есть слишком хорошо. Данный недостаток легко исправить, заменив условие на обратное, тогда мы получаем 9/9 — выравнивание приводит к увеличению одного из времен, но зато они выравниваются — мелочь, а приятно.

А было бы здорово

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

	register char tmp;
  	tmp=*out;
  	tmp |= bit;
  	bit = ~ bit;
        if (val == HIGH) tmp &= bit;
        *out=tmp;

получить следующую минимально возможную программу

        ld r18,Z
        or r18,r17
        com r17
        cpse r16,one_reg
        and r18,r17
        st Z,r25

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

Ну а теперь посмотрим, к чему мы пришли в результате реализации вышеперечисленных изменений при оптимизации на скорость. Вот исходный код:

Оптимизированный по скорости код

#define I_NEED_TIMER_CHECKING 0
#define I_NEED_PORT_CHEKING 0
#define I_NEED_OLD_PORT 0
#define I_NEED_OLD_DATA 0
#define I_NEED_INTERRUPTS 0

void digitalWrite(uint8_t pin, uint8_t val)
{
#if ( I_NEED_TIMER_CHECKING == 1)
   uint8_t timer = digitalPinToTimer(pin);
#endif
   uint8_t bit = digitalPinToBitMask(pin);

   uint8_t port;
#if I_NEED_OLD_PORT == 1
   port = digitalPinToPort(pin);
#else
   port = digitalPinToPortNew(pin);
#endif

#if I_NEED_PORT_CHEKING == 1 
   if (port == NOT_A_PIN) return;
#endif
 
#if ( I_NEED_TIMER_CHECKING == 1)
   if (timer != NOT_ON_TIMER) turnOffPWM(timer);
#endif

   uint8_t *out;
#if I_NEED_OLD_PORT == 1
   out = (uint8_t *) portOutputRegister(port);
#else
   out = (uint8_t *) ( port + BASEPORT );
#endif

#if I_NEED_INTERRUPTS ==1  
   uint8_t oldSREG = SREG;
   cli();
#endif
 
#if I_NEED_OLD_DATA == 0 
   if (val == LOW) {
      *out &= ~bit;
    } else {
      *out |= bit;
    }
#else
    if (val != LOW) {
      *out |= bit;
    } else {
      *out &= ~bit;
    }
#endif
 
#if I_NEED_INTERRUPTS ==1  
    SREG = oldSREG;
#endif
};

А вот что из него получается

digitalWrite(unsigned char, unsigned char):
преамбула 1 такт
        ldi r25,lo8(0)

uint8_t bit = digitalPinToBitMask(pin); 7 тактов
        mov r30,r24
        mov r31,r25
        subi r30,lo8(-(digital_pin_to_bit_mask_PGM))
        sbci r31,hi8(-(digital_pin_to_bit_mask_PGM))
        lpm r18, Z

uint8_t port = digitalPinToPortNew(pin); 7 тактов
        subi r24,lo8(-(digital_pin_to_port_new_PGM))
        sbci r25,hi8(-(digital_pin_to_port_new_PGM))
        mov r30,r24
        mov r31,r25
        lpm r24, Z

uint8_t *out = (uint8_t *) ( port + BASEPORT ); 2 такта
        mov r26,r24
        ldi r27,lo8(0)

	if (val == LOW) { *out &= ~bit;} else { *out |= bit; } 8/8 тактов
        tst r22
        brne .L13
        com r18
        ld r30,X
        and r18,r30
        st X,r18
        ret
.L13:
        ld r30,X
        or r18,r30
        st X,r18

постамбула 4 такта
        ret

main:
....
собственно вызов 4 такта
        ldi r24,lo8(1)
        ldi r22,lo8(0)
        rcall digitalWrite(unsigned char, unsigned char)
....

Вызов+преамбула+постамбула — 4+1+4 = 9, защита ресурса — 0, защита таймера — 0, защита пина — 0, получение номера бита — 7, получение адреса порта — 7+2 = 9, модификация значения — 8/8, что дает нам время исполнения функции 9+7+9+8/8 = 33/33 такта, или при тактовой частоте МК 16 МГц составит 2.062/2.062 мксек, совсем неплохо, ускорение в 2 раза, но можно и лучше, для чего есть два пути.

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

PinAdr Pin13=TransferPin(PIN13);
DigitalPut(&Pin13,LOW);

И мы видим, что наши усилия увенчались успехом — время модификации пина составляет всего лишь 13+3+9+8/8 = 33 такта при включенной защите от прерываний и 30 тактов при выключенной (остальные режимы не влияют на данную функцию, поскольку остались в фазе преобразования, а Вы догадались не ставить ее перед каждым вызовом фазы исполнения), при этом время вычисления адреса увеличилось незначительно, чего и следовало ожидать. Но почему прирост не слишком большой, всего лишь 10%, ведь мы убрали из фазы выполнения работу с таблицами? Все дело в извлечении параметров функции из памяти при таком подходе, обратите внимание на то, как возросла длительность преамбулы и длительность выборки (9) и поглотила значительную часть выигрыша от операции.

Для улучшения ситуации используем другой способ передачи параметров, а именно DigitalPut2(Pin13.Port,Pin13.Mask,LOW); и получаем куда больший выигрыш 15+8/8 = 24 такта с отключенной защитой (27 с защитой), что почти на 30% лучше быстрого варианта в 30 тактов и в 3 раза быстрее оригинального варианта:

Исходный код реализации тут

typedef struct { uint8_t *Port; uint8_t Mask;} PinAdr;

PinAdr TransferPin(uint8_t Pin) {
  PinAdr PinAdrTmp;       
  uint8_t port;

#if I_NEED_OLD_PORT == 1
  port = digitalPinToPort(Pin);
#else
  port = digitalPinToPortNew(Pin);
#endif
 
#if I_NEED_PORT_CHEKING == 1 
  if (port == NOT_A_PIN) PinAdrTmp.Mask=0; else
#endif
  PinAdrTmp.Mask = digitalPinToBitMask(Pin);

#if ( I_NEED_TIMER_CHECKING == 1)
  uint8_t timer = digitalPinToTimer(Pin);
  if (timer != NOT_ON_TIMER) turnOffPWM(timer);
#endif

#if I_NEED_OLD_PORT == 1
  PinAdrTmp.Port = (uint8_t *) portOutputRegister(port);
#else
  PinAdrTmp.Port = (uint8_t *) ( port + BASEPORT );
#endif

  return PinAdrTmp;
}

void DigitalPut(PinAdr &Pin, uint8_t val) {
#if I_NEED_INTERRUPTS ==1  
     uint8_t oldSREG = SREG;
     cli();
#endif
     if (val != LOW) {
       *(Pin.Port) |= Pin.Mask;
     } else {
       *(Pin.Port) &= ~ Pin.Mask;
     }

#if I_NEED_INTERRUPTS ==1  
     SREG = oldSREG;
#endif
};

void DigitalPut2(uint8_t *Port, uint8_t Mask, uint8_t val) {
#if I_NEED_INTERRUPTS ==1  
     uint8_t oldSREG = SREG;
     cli();
#endif
     if (val != LOW) {
        *Port |= Mask;
     } else {
        *Port &= ~ Mask;
     }

#if I_NEED_INTERRUPTS ==1  
     SREG = oldSREG;
#endif
};

А полученный ассемблерный код здесь

DigitalPut(PinAdr&, unsigned char):
        mov r30,r24
        mov r31,r25
        tst r22
        breq .L14
        ld r26,Z
        ldd r27,Z+1
        ld r25,X
        ldd r24,Z+2
        or r24,r25
        st X,r24
        ret
.L14:
        ld r26,Z
        ldd r27,Z+1
        ldd r24,Z+2
        com r24
        ld r25,X
        and r24,r25
        st X,r24
        ret
DigitalPut2(unsigned char*, unsigned char, unsigned char):
        mov r30,r24
        mov r31,r25
        tst r20
        breq .L17
        ld r24,Z
        or r22,r24
        st Z,r22
        ret
.L17:
        com r22
        ld r24,Z
        and r22,r24
        st Z,r22
        ret
main:
.....
        mov r24,r28
        mov r25,r29
        adiw r24,1
        ldi r22,lo8(0)
        rcall DigitalPut(PinAdr&, unsigned char)
        ldd r24,Y+1
        ldd r25,Y+2
        ldd r22,Y+3
        ldi r20,lo8(0)
        rcall DigitalPut2(unsigned char*, unsigned char, unsigned char)

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

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

Дело в том, что когда я заявлял о невозможности другого способа изменения состояния бита в регистре, я намекнул, что он таки есть. И этот путь связан с использованием специальных команд работы с битами, а именно команд с мнемониками CBI и SBI. Почему мы оставили эту возможность напоследок — потому что это зависимое решение и оно плохо реализуемое (вообще никак не реализуемое) в рамках языка С, но то же самое можно сказать и про чтение данных из таблиц, привлечение ассемблера неизбежно.

К сожалению, обе эти команды не принимают никаких параметров, что не позволяет указать номер пина для модификации и вид модификации, а эта информация есть часть самой команды и мы имеем чисто теоретически 32 различных команды для 32 различных портов. Учтем возможность наличия у каждого порта 8 бит и получим 256 различных команд. Поскольку вид модификации пина, а именно установка либо сброс бита, тоже есть часть команды, всего получается 512 различных команд.

Немаленькое такое количество, в архитектуре 51 всех команд было не более 256, а тут такая роскошь, но 16-разрядная система команд может себе это позволить. Конечно, далеко не все из этих 512 команд будут производить осмысленные действия на конкретном МК, но некоторые будут и мы должны иметь возможность максимально быстро одну конкретную команду из этого набора выполнить.

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

Первый вариант с таблицей

typedef void func (void);
void fnull(void) {};
void fres1(void) {(__extension__({__asm__("cbi PORTD,0""nt");}));};
void fset1(void) {(__extension__({__asm__("sbi PORTD,0""nt");}));};
void fres2(void) {(__extension__({__asm__("cbi PORTD,1""nt");}));};
void fset2(void) {(__extension__({__asm__("sbi PORTD,1""nt");}));};
func *funcAdr_PGM[] PROGMEM = {
  fnull,fnull,
  fres1,fset1,
  fres2,fset2,
};
#define funcOfPin(P) ( (func *)(pgm_read_word( funcAdr_PGM + (P))) )
void digitalWriteF(uint8_t Pin, uint8_t val) {
#if I_NEED_PORT_CHEKING == 1 
  uint8_t port;
#if I_NEED_OLD_PORT == 1
  port = digitalPinToPort(Pin);
#else
  port = digitalPinToPortNew(Pin);
#endif
  if (port == NOT_A_PIN) return;
#endif
#if ( I_NEED_TIMER_CHECKING == 1)
  uint8_t timer = digitalPinToTimer(Pin);
  if (timer != NOT_ON_TIMER) turnOffPWM(timer);
#endif
  Pin=Pin*2;
  if (val!=LOW) Pin++;
  funcOfPin(Pin)();
};
main:
digitalWriteF(13,LOW);};

А вот и результаты трансляции

digitalWriteF(unsigned char, unsigned char):
Pin=Pin*2;
if (val!=LOW) Pin++;
        lsl r24
        cpse r22,__zero_reg__
        subi r24,lo8(-(1))
func *p=funcOfPin(Pin);
        mov r30,r24
        ldi r31,lo8(0)
        lsl r30
        rol r31
        subi r30,lo8(-(funcAdr_PGM))
        sbci r31,hi8(-(funcAdr_PGM))
        lpm r24, Z+
        lpm r25, Z
p();
        mov r30,r24
        mov r31,r25
        icall
        ret
main:
       ldi r24,lo8(13)
       ldi r22,lo8(0)
       rcall digitalWriteF(unsigned char, unsigned char)

Вызов+преамбула+постамбула — 4+0+4 = 8, защита ресурса — 0 (но она есть), учет значения — 3, получение точки входа — 6, получение адреса функции — 3+3+2 = 8, выполнение операции 3+2+4 = 9, что дает нам время исполнения функции 8+3+6+8+9 = 34/34 такта. Пока не очень ясно, зачем мы вообще все это затеяли, но вот как раз наступил момент, когда мы можем (и должны) слегка улучшить код, порожденный компилятором, и заменить вызов функции p() на asm («ijmp nt») (ну не совсем так, но смысл передает), что позволяет нам сэкономить 4 такта на постамбуле за счет совмещения операций, итого 30 тактов с защитой.

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

Пробуем применить принцип разделения фаз вместе с модификацией таблиц и получаем:

Новый вариант таблиц

void funcAll(void) {
	asm volatile ("nop n t ret nt");
	asm volatile ("nop n t ret nt");
	asm volatile ("cbi PORTD,0 n t ret nt");
	asm volatile ("sbi PORTD,0 n t ret nt");
	asm volatile ("cbi PORTD,1 n t ret nt");
	asm volatile ("sbi PORTD,1 n t ret nt");
};
typedef func *PinAdr;
PinAdr TransferPin(uint8_t Pin) {
#if I_NEED_PORT_CHEKING == 1 
  uint8_t port;
#if I_NEED_OLD_PORT == 1
  port = digitalPinToPort(Pin);
#else
  port = digitalPinToPortNew(Pin);
#endif
  if (port == NOT_A_PIN) return;
#endif
#if ( I_NEED_TIMER_CHECKING == 1)
  uint8_t timer = digitalPinToTimer(Pin);
  if (timer != NOT_ON_TIMER) turnOffPWM(timer);
#endif
  Pin=Pin*4;
  PinAdr PinTmp = (PinAdr) ((int)funcAll + Pin);
  return PinTmp;
};
void DigitalPut(func *Pin, uint8_t val) {
  if (val != LOW) {
    Pin = (PinAdr) ((int)(Pin)+2);
  };
  Pin();
main: 
 digitalPut(Pin13,LOW);
};

И, как всегда, ассемблерный код

DigitalPut(void (*)(), unsigned char):
        cpse r22,__zero_reg__
        adiw r24,2
        mov r30,r24
        mov r31,r25
        icall ( ijmp )
        ret
main:
        lds r24,main::Pin13
        lds r25,main::Pin13+1
        ldi r22,lo8(1)
        rcall DigitalPut(void (*)(), unsigned char)

Вызов+преамбула+постамбула — 7+0+4 = 11, защита ресурса — 0 (но она есть), учет значения — 3, получение точки входа — 0, получение адреса функции — 0, выполнение операции 2+3+2+4 = 11(7), что дает нам время исполнения функции 11+3+11(7) = 25(21) тактов, что лучше, чем для варианта с портами на целых 6 тактов. Заметим, что для ослабления требований к памяти, мы расположили таблицу команд несколько иным образом, тогда нам потребуется ровно 2*П слов в памяти программ. Вам решать, какая реализация предпочтительнее, быстродействие их близко.

Если мы аккуратно перепишем на ассемблере вызов функции и саму функцию работы с пином (оставляю это упражнение на долю пытливого читателя), то получим 11+3+(3+2)5=19 тактов, и это, похоже, предел совершенства.

Что мы получили в результате такой резкой смены парадигмы — существенное увеличение быстродействия в варианте с защитой от совместного использования ресурса, но не слишком значительный прирост в сравнении с вариантом, когда для порта защита отключена. На первый взгляд, несколько странный результат, ведь если раньше основная работа производилась четырьмя командами, то теперь только одной и мы должны были бы получить кратный прирост скорости. Дело в том, что накладные расходы на выполнение собственно модификации состояния пина не изменились и мы по прежнему должны потратить 2 такта на вызов функции и 4 на выход из нее (а еще определенное количество тактов на передачу фактических параметров, но будем считать, что нам повезло и эта часть оптимизирована компилятором до полного отсутствия), раньше было 4+4=8 тактов на обработку, теперь получается 1+4=5 тактов и прирост производительности даже не в 2 раза, а если учесть еще и время на подготовку — пересылку параметров в регистры, то и того меньше. А заплатить за такое не слишком значительное увеличение быстродействия нам пришлось вполне конкретной памятью программ и данных для хранения адреса функции.

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

Что это за случаи и почему они не всегда присутствуют, хотя и довольно таки часто? Речь идет о ситуации, когда выполнены два условия: первое — нам доступны исходные коды программных модулей, для которых необходима быстрая работа с пинами, и второе — номера пинов должны быть константными, то есть известны на этапе компиляции (именно константными, а не статически определенными, это важно). Тогда мы можем создать макро-подстановку для работы с пинами следующего вида:

Идеальное решение с точки зрения быстродействия

#define digitalWriteC(pin,val)  
	if (pin == 0) { 
	 if (val == LOW) asm volatile ("cbi PORTD, 0 nt"); else asm volatile ("sbi PORTD, 0 nt");
	}; 
	if (pin == 1) { 
	 if (val == LOW) asm volatile ("cbi PORTD, 1 nt"); else asm volatile ("sbi PORTD, 1 nt");
	}; 
	if (pin == 2) { 
	 if (val == LOW) asm volatile ("cbi PORTD, 2 nt"); else asm volatile ("sbi PORTD, 2 nt");
	}; 
main:
	digitalWriteC(13,HIGH);

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

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

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

#define DigitalPut(Pin,Data) ( asm ( "DW 0x0123+((Pin / 8) << 4)+(Pin % 8) + (Data << 8))

Но мне почему то построить подобную конструкцию не удалось, если кто знает, как это сделать, прошу в комментарии.

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

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

Автор: GarryC

Источник

Поделиться новостью

* - обязательные к заполнению поля