До питання про пинах

Дню знань присвячується...
Цей пост присвячений того, з чим стикаються всі користувачі Ардуїнов (далі по тексту А, майте на увазі, що під цією буквою буде ховатися як сам кристал, так і середовище розробки програм), а саме з роботою з портами вводу/виводу.

Ви запитаєте, а що, власне, тут розглядати? Функції роботи з портами прописані ясно, є велика кількість прикладів, тому використання портів не представляє ніякої складності. Початкова програма миготіння світлодіодом використовує ці функції і прекрасно працює, про що мова?

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

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

Оскільки я бачу перед собою читача з різним рівнем підготовки, то постараюся орієнтуватися на різний діапазон знань в області МК (мікроконтролерів), тому, якщо Вам здасться добре відомим (а буде чимало таких місць сміливо пропускайте цей фрагмент, а от якщо щось буде не цілком зрозуміло, коментарі і існують для того, щоб там задавати питання, я їх читаю і, в міру своїх сил намагаюся відповісти.

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

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

Для управління пінами в А існують визначені функції, головною з яких є DigitalWrite, якій Ви повинні повідомити номер піна для модифікації та значення на ньому після виконання функції. Однак, якщо у Вас після написання команди DigitalWrite(13,Low) проблеми закінчилися (за умови, що Ви не забули де раніше команду налаштування режиму піна), то у виконуючою системи вони тільки починаються. Справа в тому, що існують архітектури МК, в яких кожен пін дійсно має унікальну адресу, чим забезпечується легке відображення Вашої команди на систему команд МК, чим і займається виконуюча система (зв'язка компілятора і системної бібліотеки), але фірма Atmel в ті часи, коли створювалася А, своїх шанувальників подібними вишукуваннями не балувала (це не зовсім вірно, але в першому наближенні так). У мікроконтролерах сімейства Мега, на які платформа А історично базувалася, прийнята дещо інша схема роботи з пінами. Тут робота з зовнішнім світом здійснюється не через унікальні піни, а через порти вводу/виводу, які являють собою сукупність пінів (в даному випадку не більше 8) і, відповідно, кожен пін має у фізичному поданні 2 параметри — ім'я порту (букви від А до Е в різних представників сімейства МК) і номером біти всередині порту (цифра від 0 до 7). Так, наприклад, пін 13 може мати фізичний адресу РВ.5 в одному МК, і РС.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" "\n\t" : "=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""\n\t");}));};
void fset1(void) {(__extension__({__asm__("sbi PORTD,0""\n\t");}));};
void fres2(void) {(__extension__({__asm__("cbi PORTD,1""\n\t");}));};
void fset2(void) {(__extension__({__asm__("sbi PORTD,1""\n\t");}));};
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 \n\t») (ну не зовсім так, але сенс передає), що дозволяє нам заощадити 4 такту на постамбуле за рахунок суміщення операцій, разом 30 тактів з захистом.

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

Спробуємо застосувати принцип поділу фаз разом з модифікацією таблиць і отримуємо:

Новий варіант таблиць
void funcAll(void) {
asm volatile ("nop \n \t ret \n\t");
asm volatile ("nop \n \t ret \n\t");
asm volatile ("cbi PORTD,0 \n \t ret \n\t");
asm volatile ("sbi PORTD,0 \n \t ret \n\t");
asm volatile ("cbi PORTD,1 \n \t ret \n\t");
asm volatile ("sbi PORTD,1 \n \t ret \n\t");
};
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 \n\t"); else asm volatile ("sbi PORTD, 0 \n\t");\
}; \
if (pin == 1) { \
if (val == LOW) asm volatile ("cbi PORTD, 1 \n\t"); else asm volatile ("sbi PORTD, 1 \n\t");\
}; \
if (pin == 2) { \
if (val == LOW) asm volatile ("cbi PORTD, 2 \n\t"); else asm volatile ("sbi PORTD, 2 \n\t");\
}; 
main:
digitalWriteC(13,HIGH);

І замість жахливої конструкції, яку ми написали, згенерований машинний код увійде одна єдина команда правильного зміни правильного біта, оскільки сучасні компілятори настільки гарні в плані оптимізації, але слід врахувати, що при спробі підставити не-константный аргумент виклику ми отримаємо в остаточному коді безліч порівнянь (причому без будь-якого попередження), що навряд чи призведе до очікуваного прискорення роботи функції. Щоб уникнути подібного, на початку макросу вставлена захист від не-типізованого аргументи макросу, яка природно не завадить Вам вистрілити собі в ногу, застосувавши типизированную змінну, але хто я такий, щоб обмежувати Вашу свободу стріляти куди захочеться. Звернемо особливу увагу на той факт, що при подібному підході ми заощаджуємо не тільки на час виконання, але і не програємо (взагалі то точно виграємо, але я віддаю перевагу обтічні формулювання) у розмірі коду (звичайно, маються на увазі машинні коди, які тільки і є сенс враховувати), оскільки команда виклику підпрограми займає не менше слова. Загалом ідеальний варіант, якщо виконуються два вищезгадані умови.

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

Інший ідеальний варіант в тому випадку виглядав би як макроподстановка, генерящая код команди зі своїх константных аргументів, що небудь на зразок:

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

Але мені чомусь побудувати подібну конструкцію не вдалося, якщо хтось знає, як це зробити, прошу в коментарі.

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

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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.