Работа с портами ввода-вывода микроконтроллеров на Си++
Материал из eeWiki - открытая энциклопедия по электронике
Содержание |
Введение
При разработке программ для микроконтроллеров (МК) работа с внутренний и внешней периферией является очень важной частью (а иногда и единственной) программы. Это своего рода фундамент, на котором основывается более высокоуровневая логика программы. От эффективности взаимодействия с периферией напрямую зависит эффективность программы в целом. Под эффективностью здесь следует понимать не только скорость выполнения и минимальный размер кода, но и эффективность написания и сопровождения кода.
Многие внешние устройства подключаются к МК через порты ввода-вывода общего назначения (GPIO). Эффективность взаимодействия с этими устройствами во многом зависит от способа работы с портами ввода-вывода.
Тут возникают два, на первый взгляд, противоречивых требования:
- Драйвера внешней периферии хочется писать максимально абстрагировавшись от конкретного способа подключения к микроконтроллеру, а ещё лучше независимо от типа микроконтроллера. Переписывать «библиотечный» код для каждого проекта не очень хорошо.
- Скорость и размер кода в большинстве случаев имеют большое значение.
Ситуация осложняется огромным количеством способов подключения одного внешнего устройства к одному контроллеру и в большинстве случаев определяется удобством изготовления печатной платы наличием свободных линий в порту, необходимостью использовать некоторые линии портов для их специальных возможностей и т.д. Устройство может быть подключено как к одному порту, так и к нескольким, линии порта могут быть как упорядочены по их логическому весу в подключенном устройстве, так и нет. Оно вообще может быть подключено через какой-нибудь расширитель ввода-вывода, например, сдвиговый регистр. При таком разнообразии очень сложно, чтобы драйвер устройства не был заточен под конкретный способ подключения этого устройства, или-же не был слишком громоздким и медленным в случае более универсального решения.
Кажется, что найти универсальное и эффективное решение здесь невозможно. Очень часто в пользу скорости и компактности кода жертвуют и универсальностью и логической структурой программы и как следствие читаемостью кода, просто не отделяя способ подключения устройства от логики его работы. При этом код драйвера модифицируется и подгоняется вручную под каждый проект, под каждый способ подключения.
Давайте разберемся, какие методы работы с портами ввода-вывода традиционно применяются при программировании на чистом Си.
Можно выделить следующие подходы (вариант с жестко заданными в коде именами портов и номерами ножек не рассматриваем):
- Определение портов и линий ввода-вывода с помощью препроцессора.
- Передача порта в код, который его использует, посредством указателя.
- Виртуальные порты.
Примеры приведены для МК семейства AVR. Компилятор avr-gcc, но описываемые подходы могут быть применены к любым другим МК, для которых имеется стандартный Си/Си++ компилятор.
Препроцессор.
Способов использования препроцессора для работы с портами в МК существует великое множество. В самом простом и самом распространенном случае просто объявляем порт и номера ножек, к которым подключено наше устройство с помощью директивы #define, не забыв, конечно, про DDR и PIN регистры, если они нужны.
Нет ничего проще, чем помигать светодиодом, подключенным к одному из выводов МК:
#include <avr/io.h> #include <util/delay.h> #define LED_PORT PORTA #define LED_PIN 5 int main() { while(1) { LED_PORT |= 1 << LED_PIN; //зажечь _delay_ms(100); LED_PORT &= ~(1 << LED_PIN); _delay_ms(100); } }
Строчка
LED_PORT |= 1 << LED_PIN;
после компиляции превращается в одну команду процессора:
sbi PORTA, 5
также как и
LED_PORT &= ~(1 << LED_PIN);
компилируется в:
cbi PORTA, 5
Выглядит всё очень просто и эффективно. А что, если нам надо управлять несколькими линиями сразу? Вот пример из хорошо известной библиотеки для работы с дисплеем HD44780 Scienceprog.com Lcd Lib:
#define LCD_RS 0 //define MCU pin connected to LCD RS #define LCD_RW 1 //define MCU pin connected to LCD R/W #define LCD_E 2 //define MCU pin connected to LCD E #define LCD_D4 4 //define MCU pin connected to LCD D3 #define LCD_D5 5 //define MCU pin connected to LCD D4 #define LCD_D6 6 //define MCU pin connected to LCD D5 #define LCD_D7 7 //define MCU pin connected to LCD D6 #define LDP PORTD //define MCU port connected to LCD data pins #define LCP PORTD //define MCU port connected to LCD control pins #define LDDR DDRD //define MCU direction register for port connected to LCD data pins #define LCDR DDRD //define MCU direction register for port connected to LCD control pins
А вот так там выглядит функция инициализации дисплея:
void LCDinit(void)//Initializes LCD { _delay_ms(15); LDP=0x00; LCP=0x00; LDDR|=1<<LCD_D7|1<<LCD_D6|1<<LCD_D5|1<<LCD_D4; LCDR|=1<<LCD_E|1<<LCD_RW|1<<LCD_RS; //---------one------ LDP=0<<LCD_D7|0<<LCD_D6|1<<LCD_D5|1<<LCD_D4; //4 bit mode LCP|=1<<LCD_E|0<<LCD_RW|0<<LCD_RS; _delay_ms(1); LCP&=~(1<<LCD_E); _delay_ms(1); //-----------two----------- LDP=0<<LCD_D7|0<<LCD_D6|1<<LCD_D5|1<<LCD_D4; //4 bit mode LCP|=1<<LCD_E|0<<LCD_RW|0<<LCD_RS; _delay_ms(1); LCP&=~(1<<LCD_E); _delay_ms(1); //-------three------------- LDP=0<<LCD_D7|0<<LCD_D6|1<<LCD_D5|0<<LCD_D4; //4 bit mode LCP|=1<<LCD_E|0<<LCD_RW|0<<LCD_RS; _delay_ms(1); LCP&=~(1<<LCD_E); _delay_ms(1); //--------4 bit--dual line--------------- LCDsendCommand(0b00101000); //-----increment address, cursor shift------ LCDsendCommand(0b00001110); }
Здесь автор ещё пытается записывать биты в порт согласно тому, как они заданы define-ами. А в функции посылки команды в дисплей автор уже забыл про свои дефайны и молчаливо полагает, что шина данных дисплея подключена строго к старшим четырём разрядам порта:
void LCDsendCommand(uint8_t cmd) //Sends Command to LCD { LDP=(cmd&0b11110000); LCP|=1<<LCD_E; _delay_ms(1); LCP&=~(1<<LCD_E); _delay_ms(1); LDP=((cmd&0b00001111)<<4); LCP|=1<<LCD_E; _delay_ms(1); LCP&=~(1<<LCD_E); _delay_ms(1); }
В этом случае правильнее было-бы применить побитовый вывод в порт:
void LCDwrite4(uint8_t cmd) { LDP &= ~(1<<LCD_D7|1<<LCD_D6|1<<LCD_D5|1<<LCD_D4); //clear data bus if(cmd & (1 << 0)) LDP |= LCD_D4; if(cmd & (1 << 1)) LDP |= LCD_D5; if(cmd & (1 << 2)) LDP |= LCD_D6; if(cmd & (1 << 3)) LDP |= LCD_D7; } void LCDsendCommand(uint8_t cmd) //Sends Command to LCD { LCDwrite4(cmd); LCP|=1<<LCD_E; _delay_ms(1); LCP&=~(1<<LCD_E); _delay_ms(1); LCDwrite4(cmd); LCP|=1<<LCD_E; _delay_ms(1); LCP&=~(1<<LCD_E); _delay_ms(1); }
Так уже будет работать при любом распределении линий шины данных в порту МК, однако размер кода несколько увеличится. С этим уже можно как-то жить. А если для каждой линии завести свой дефайн для имени порта, то таким образом уже можно будет распределить их по разным портам. Размер кода при этом ещё больше раздуется, ведь совмещать записи, даже констант, уже не получится.
Развивая тему с препроцессором можно задавать номер ножки и ее порт в одном определении, ведь Си-шный препроцессор работает не с идентификаторами и не какими-то ни-было языковыми конструкциями, а просто со строковыми литералами.
#define LCD_RS PORTA, 0 //define MCU pin connected to LCD RS #define LCD_RW PORTB, 1 //define MCU pin connected to LCD R/W #define LCD_E PORTB, 2 //define MCU pin connected to LCD E
Добавим к этому средства для манипуляции линией и получим так называемые макросы Аскольда Волкова:
#define _setL(port,bit) do { port &= ~(1 << bit); } while(0) #define _setH(port,bit) do { port |= (1 << bit); } while(0) #define _clrL(port,bit) do { port |= (1 << bit); } while(0) #define _clrH(port,bit) do { port &= ~(1 << bit); } while(0) #define _bitL(port,bit) (!(port & (1 << bit))) #define _bitH(port,bit) (port & (1 << bit)) #define _cpl(port,bit,val) do {port ^= (1 << bit); } while(0)
Этот подход, в отличии от предыдущих, уже можно сделать переносимым на разные аппаратные платформы. Достаточно только определить эти макросы для целевой платформы соответствующим образом. Однако, у нас по-прежнему остались нерешенными некоторые проблемы. Во-первых, большой размер и низкое быстродействие кода, ведь мы используем побитовый вывод в порты. Даже если часть линий находятся в одном порту и идут подряд, определить эту ситуацию с помощью макросов невозможно. И если, например, размер кода окажется слишком большим, то уже написанную и отлаженную библиотеку придется подгонять под конкретный проект, плодя многочисленные ее версии и, возможно, внося ошибки.
Во-вторых, подключение нескольких однотипных устройств возможно только путём дублирования кода. Опять-же результирующий размер программы неоправданно увеличивается. А потом в две версии одного и того-же кода вносятся изменения независимо друг от друга и каждая из них начинает жить своей жизнью. Спасает здесь только относительно малый размер программ для встроенных систем, ведь если что не так, то можно всё быстренько переписать заново :)
Многие компиляторы для AVR поддерживают побитовый доступ к портам на уровне специальных расширений компилятора (CVAVR, MicroC AVR) или встроенных библиотек (IAR C/C++ Compiler for AVR). Такой побитовый доступ несложно реализовать и в avr-gcc с помощью битовых полей (собственно в IAR примерно так и это и реализовано):
typedef struct Bits_t { uint8_t Bit0 :1; uint8_t Bit1 :1; uint8_t Bit2 :1; uint8_t Bit3 :1; uint8_t Bit4 :1; uint8_t Bit5 :1; uint8_t Bit6 :1; uint8_t Bit7 :1; }Bits; #define PortaBits (*((volatile Bits*)&PORTA)) #define LedPin PortaBits.Bit5 int main() { DDRA = 1 << 5; while(1) { LedPin = 1; //зажечь _delay_ms(100); LedPin = 0; //выключить _delay_ms(100); } }
Получается немного чище и удобнее, чем в случае использования макросов, но принципиальных отличий нет. Те-же достоинства и недостатки.
В итоге при использовании препроцессора для манипуляций с портами ввода-вывода мы получаем:
- Простоту и ясность для простых вещей – очень просто написать пару макросов, чтоб поморгать светодиодом.
- Высокую скорость и компактность кода при отказе от универсальности (все ножки в одном порту и желательно по порядку).
- Не расходуется дополнительная память.
- Содержащий большое количество битовых операций код достаточно сложно читать.
- Можно сделать универсально и относительно переносимо пожертвовав размером и скоростью кода (побитовый вывод).
- Для управления несколькими однотипными устройствами придется дублировать код.
Передача порта через указатель.
Как уже выяснили один из основных недостатков пи работе с портами ввода-вывода с помощью препроцессора это сложность использования однотипных устройств. Такой сложности не возникает если передавать порт в код, который его использует через указатель.
Порты ввода-вывода в большинстве МК есть не что иное как просто ячейка памяти или регистр в пространстве ввода-вывода и естественно к нему можно обратиться по адресу через указатель.
Удобно запаковать указатель на порт и битовые маски нужных ножек в одну структуру, чтоб потом ее передавать в функцию, которая что-то с ними будет делать. Здесь лучше использовать именно битовые маски, а не битовые позиции, иначе сдвиги вида (1 << some_bit_position) не могут быть вычислены на этапе компиляции (потому, что some_bit_position не константа, а переменная) и будут честно выполнится в каждом месте где встретятся. Возьмём сдвиговый регистр-защёлку, например 74HC595, который часто используется для экономии выводов МК при подключении многовыводной периферии.
typedef struct ShiftReg_t { volatile uint8_t *port; uint8_t data_pin_bm; uint8_t clk_pin_bm; uint8_t latch_pin_bm; }ShiftReg; ... //ShiftReg.c void WriteShiftReg(ShiftReg *reg, uint8_t value) { for(uint8_t i=0; i<8; i++) { if(value & 1) //выводим данные *reg->port |= reg->data_pin_bm; else *reg->port &= ~reg->data_pin_bm; //тактовый импульс *reg->port |= reg->clk_pin_bm; value >>= 1; *reg->port &= ~reg->clk_pin_bm; } //защёлкиваем данные в регистр *reg->port |= reg->latch_pin_bm; *reg->port &= ~reg->latch_pin_bm; } //main.c ... #include <avr/io.h> //вывода data и clk могут быть общие. ShiftReg reg1 = {&PORTA, 1<<1, 1<<2, 1<<3}; ShiftReg reg2 = {&PORTA, 1<<1, 1<<2, 1<<4}; int main() { DDRA = 0xff; DDRB = 0xff; WriteShiftReg(®1, 0xff); WriteShiftReg(®2, 0x55); while(1) { } }
От дублирования кода мы избавились, одна функция WriteShiftReg используется для записи во много сдвиговых регистров. Читаемость кода не пострадала. К тому-же появилась возможность менять порт и ножки к которым подключен регистр во время выполнения программы. Полезность такой возможности, правда, сомнительна особенно для маленьких МК. Таким способом удобно работать с перифирией требующей немного линий ввода-вывода и подключенноу к МК во множественном числе, в том числе подключенные с использованием каких-либо последовательных протоколов USART, SPI (если не хватает аппаратных) 1-Wire и т.д.
Подключение таким образом устройств требующих много линий ввода-вывода непрактично. Код получится слишком громоздким, медленным и требующим много памяти. В данном примере каждый такой сдвиговый регистр занимает 5 байт памяти. Да и побитовый доступ к порту через указатель не самая дешёвая операция:
*reg->port |= reg->latch_pin_bm;
ld r30, X+ ld r31, X sbiw r26, 0x01 ; 1 ld r24, Z adiw r26, 0x04 ; 4 ld r25, X sbiw r26, 0x04 ; 4 or r24, r25 st Z, r24
(Фрагмент ассемблерного листинга WriteShiftReg)
Как видно, компилятор не может теперь сопримизировать обращение к порту и установка бита теперь занимает 9 инструкций вместо одной.
Чтобы несколько оптимизировать код можно ввести дополнительные ограничения, например, задать номера ножек константами, и выкинуть соответствующие им битовые маски. В примере со сдвиговым регистром можно заменить константами data_pin_bm и clk_pin_bm и исключить их из структуры, а latch_pin_bm оставить как есть для универсальности:
typedef struct ShiftReg_t { volatile uint8_t *port; uint8_t latch_pin_bm; }ShiftReg; enum {clk_pin_bm = 1 << 0, data_pin_bm = 1 << 1}; ... //ShiftReg.c void WriteShiftReg(ShiftReg *reg, uint8_t value) { for(uint8_t i=0; i<8; i++) { if(value & 1) //auaiaei aaiiua *reg->port |= data_pin_bm; else *reg->port &= ~>data_pin_bm; //oaeoiaue eiioeun *reg->port |= clk_pin_bm; value >>= 1; *reg->port &= clk_pin_bm; } //cau?eeeaaai aaiiua a ?aaeno? *reg->port |= reg->latch_pin_bm; *reg->port &= ~reg->latch_pin_bm; }
Такая оптимизация сократит код WriteShiftReg примерно на 25 % с незначительной потерей в удобстве.
Итого:
- Удобно использовать для подключения многих однотипных устройств, требующих не много линий ввода-вывода.
- Нет необходимости в дублировании кода.
- Можно менять порт и линии подключения устройства во время выполнения программы.
- Низкая скорость доступа к портам.
- Большой размер кода.
- Требуется дополнительная память для хранения указателя на порт и битовых масок.
- Неудобно и неэффективно работать с большим количеством линий ввода-вывода.
Виртуальные порты.
Нужно подключить к МК несколько устройств требующих достаточно много линий ввода-вывода, драйвер которых обладает достаточно сложной и объёмной логикой, например, тот-же дисплей HD44780 (при использовании 4х битного интерфейса требует 7 линий). К тому-же устройства могут быть подключены различными способами – к разным линиям портов, или через сдвиговый регистр. Дублировать код драйвера и подгонять его под каждый способ подключения устройства – нет уж, спасибо. Да и размер скомпилированного кода рискует не поместится в целевой МК. Передавать порты драйверу в через указатели? Слишком большие накладные расходы при работе с портами через указатели, много памяти, медленно и громоздко.
Здесь лучше применить, так называемые виртуальные порты. На языке Си они могут быть реализованы как группа функций, принимающих входное значение и выполняющих соответствующие операции ввода-вывода:
void VPort1Write(uint8_t value) { PORTA = (PORTA & 0xf0) | (value & 0x0f); PORTB = (PORTB & 0x0f) | (value & 0xf0) >> 4; } void VPort1DirWrite(uint8_t value) { DDRA = (DDRA & 0xf0) | (value & 0x0f); DDRB = (DDRB & 0x0f) | (value & 0xf0) >> 4; } uint8_t VPort1Read() { return (PORTA & 0xf0) | (PORTB & 0x0f) << 4; } uint8_t VPort1PinRead() { return (PINA & 0xf0) | (PINB & 0x0f) << 4; }
В этом примере входное значение из 8-ми бит распределено между 4-мя младшими битами портов PORTA и PORTB. Реализация функции для вывода команды в дисплей HD44780 с использованием такого виртуального порта будет выглядеть так:
void LCDwrite4(uint8_t value, WriteFunc write) { enum{LCD_E=4, LCD_RS=5, LCD_RW=6}; uint8_t tmp; tmp = (value & 0x0f) | (1 << LCD_E); //совмещаем вывод тетрады //и установку LCD_E write(tmp); _delay_ms(1); tmp &= ~(1 << LCD_E); write(tmp); _delay_ms(1); } void LCDsendCommand(uint8_t cmd, WriteFunc write) //Sends Command to LCD { LCDwrite4(cmd >> 4, write); //старшая тетрада LCDwrite4(cmd, write); //младшая тетрада } ... void DoSomthing() { ... VPort1DirWrite(0xff); LCDsendCommand(0x30, VPort1Write); ... }
Такой подход достаточно эффективен для вывода многобитного значения. Из накладных расходов только вызов функции по указателю, но за один вызов выводится сразу много бит. Это эффективнее побитового вывода в порт, но менее эффективно вывода в обычный порт (когда все линии устройства подключены по логическому порядку к одному порту). Тут всё уже зависит от того как реализована функция виртуального порта. Однако если нужно изменить только один бит, то придется запомнить предыдущее значение (или прочитать его из порта), наложить на него соответствующую битовую маску и записать в виртуальный порт. То есть изменения одного бита в этом порту будет дороже записи всех бит порта. Поэтому, например, в функции LCDwrite4 запись тетрады совмещена с установкой бита LCD_E.
Если необходимо не только писать в виртуальный порт, но и читать из него и управлять направлением, подтяжкой или ещё что-то, то функций реализующих виртуальный порт будет много и все их надо писать.
Чего-же мы добились с помощью виртуальных портов:
- Логика работы с устройством полностью отделена от способа подключения устройства.
- Нет необходимости в дублировании кода.
- Сравнительно небольшие накладные расходы на вывод в порт.
- Чистота и понятность кода.
- Необходимо вручную писать реализацию виртуального порта – много однотипных функций (совсем зажрались, подумают некоторые товарищи).
- Манипуляции с отдельными битами неэффективны.
Подход Си++ к работе с портами.
Что может нам предложить язык Си++ при работе с портами ввода-вывода по сравнению чистым Си? Давайте сначала сформулируем, что мы хотим получить в результате наших изысканий:
- Логика работы с устройством должна быть отделена от способа его подключения.
- Не должно быть дублирования кода при подключении многих однотипных устройств.
- Эффективно работать с отдельными битами.
- Эффективно работать с многобитовыми значениями.
- Решение должно быть переносимо на разные аппаратные платформы.
- Не должно использоваться дополнительная память.
- Легкость написания и сопровождения кода.
- Реализация полностью на стандартном Си++.
От динамической конфигурации линий ввода-вывода сразу отказываемся из-за необходимости доступа к портам через указатель со всеми вытекающими последствиями.
Удобно было бы описать линию ввода-вывода в виде отдельной сущности, т.е. класса. В Си++ даже если в классе не объявлено ни одного поля, переменная этого класса всё равно будет иметь размер как минимум один байт, потому, что переменная должна иметь адрес. Значит, нам не надо создавать объекты этого класса, а все функции в нем сделать статическими. А как тогда различать разные линии? Можно сделать этот класс шаблоном, а порт и номер бита передавать в виде параметров шаблона. С номером бита всё ясно – это целое число, его легко передать как нетиповой параметр шаблона. А как быть с портом? Посмотрим, как определены порты ввода-вывода в заголовочных файлах avr-gcc:
#define _MMIO_BYTE(mem_addr) (*(volatile uint8_t *)(mem_addr)) #define _SFR_MEM8(mem_addr) _MMIO_BYTE(mem_addr + __SFR_OFFSET) #define PORTB _SFR_IO8(0x18)
Параметры шаблона могут быть только типовыми или целочисленными константными выражениями, вычисляемыми во время компиляции. Ни указатель, ни ссылку нельзя предать как параметр шаблона:
template<uint8_t *PORT, uint8_t PIN> //ошибка class Pin {...};
Может быть попробовать передавать адрес порта в виде целого числа, а потом его преобразовывать в указатель:
template<unsigned PORT, uint8_t PIN> class Pin { public: static void Set() { *(volatile uint8_t*)(PORT + __SFR_OFFSET) |= (1 << PIN); } ... };
Это уже работает, но адрес порта придется задавать вручную в виде целого числа, что неудобно:
typedef Pin<0x18, 1> Pin1; ... Pin1::Set(); //sbi 0x18, 1
Взять адрес PORTB по имени не получится потому, что операция взятия адреса не может появляться в константных выражениях, коими должны быть параметры шаблона:
typedef Pin<(unsigned)&PORTB, 1> Pin1; // ошибка
Однако, нам нужно передавать не только адрес порта, ещё нужны PINx и DDRx регистры. К тому-же, в таком виде о переносимости не может быть и речи. Можно, конечно, написать макрос, которому передаём соответствующие имена регистров, и он генерирует соответствующий класс. Но тогда Pin будет слишком жестко завязан на конкретную реализацию портов.
Можно написать перечисление в котором объявлены все порты с удобочитаемыми именами и функцию, которая возвращает нужный порт в зависимости от переданного параметра шаблона.
enum Ports {Porta, Portb, Portc}; template<Ports PORT, uint8_t PIN> class Pin { public: static volatile uint8_t & GetPort() { switch(PORT) { case Porta: return PORTA; case Portb: return PORTB; case Portc: return PORTC; } } static volatile uint8_t & GetPin(){...} static volatile uint8_t & GetDDR(){...} static void Set() { GetPort() |= (1 << PIN); } ... };
Функцию GetPort можно объявить как внутри класса, так и снаружи. Реализовать её можно, например, с помощью оператора switch или специализаций шаблонной функции:
template<Ports PORT> volatile uint8_t & GetPort(); template<> volatile uint8_t & GetPort<Porta>() { return PORTA; } template<> volatile uint8_t & GetPort<Portb>() { return PORTB; } ... template<Ports PORT, uint8_t PIN> class Pin { public: static void Set() { GetPort<PORT>() |= (1 << PIN); } };
Однако, такой подход всё равно ограничен. В первую очередь потому, что мы жестко завязываемся на конкретную реализацию портов. Во многих семействах МК для управления портами ввода-вывода имеются дополнительные регистры для быстрого сброса/установки/переключения отдельных бит и много чего ещё. Вот, например, структура, описывающая порт в МК семейства Atmel XMega:
typedef struct PORT_struct { register8_t DIR; /* I/O Port Data Direction */ register8_t DIRSET; /* I/O Port Data Direction Set */ register8_t DIRCLR; /* I/O Port Data Direction Clear */ register8_t DIRTGL; /* I/O Port Data Direction Toggle */ register8_t OUT; /* I/O Port Output */ register8_t OUTSET; /* I/O Port Output Set */ register8_t OUTCLR; /* I/O Port Output Clear */ register8_t OUTTGL; /* I/O Port Output Toggle */ register8_t IN; /* I/O port Input */ register8_t INTCTRL; /* Interrupt Control Register */ register8_t INT0MASK; /* Port Interrupt 0 Mask */ register8_t INT1MASK; /* Port Interrupt 1 Mask */ register8_t INTFLAGS; /* Interrupt Flag Register */ register8_t reserved_0x0D; register8_t reserved_0x0E; register8_t reserved_0x0F; register8_t PIN0CTRL; /* Pin 0 Control Register */ register8_t PIN1CTRL; /* Pin 1 Control Register */ register8_t PIN2CTRL; /* Pin 2 Control Register */ register8_t PIN3CTRL; /* Pin 3 Control Register */ register8_t PIN4CTRL; /* Pin 4 Control Register */ register8_t PIN5CTRL; /* Pin 5 Control Register */ register8_t PIN6CTRL; /* Pin 6 Control Register */ register8_t PIN7CTRL; /* Pin 7 Control Register */ } PORT_t;
Чтобы изолировать класс Pin от конкретной реализации портов ввода-вывода введём дополнительный уровень абстракции. Добавление нового уровня абстракции вовсе не обязательно влечёт за собой какие-то накладные расходы.
С классом описывающим порт ввода-вывода у нас возникает та-же проблема, что и с классом Pin: как связать класс с конкретными регистрами? Можно конечно попытаться сделать это с помощью перечислений и частичной специализации, но в данном случае это всё-таки лучше сделать с помощью препроцессора:
#define MAKE_PORT(portName, ddrName, pinName, className, ID) \ class className{\ ... };
Теперь объявим портов на все случаи жизни:
#ifdef PORTA MAKE_PORT(PORTA, DDRA, PINA, Porta, 'A') #endif ... #ifdef PORT MAKE_PORT(PORTR, DDRR, PINR, Portr, 'R') #endif
Проанализировав реализацию портов ввода-вывода различных семейств МК составим минимальный интерфейс для эффективного управления портами (управление режимами подтяжки пока опустим):
// Псевдоним для типа данных порта. // Для ARM, например, это будет uint32_t. typedef uint8_t DataT;\ // Записать значение в порт PORT = value static void Write(DataT value); // Прочитать значение записанное в порт static DataT Read(); //Записать значение направления линий В/В static void DirWrite(DataT value); // прочитать направление линий В/В static DataT DirRead(); //Установить биты в порту PORT |= value; static void Set(DataT value); // Очистить биты в проту PORT &= ~value; static void Clear(DataT value); // Очистить по маске и установить PORT = (PORT & ~clearMask) | value; static void ClearAndSet(DataT clearMask, DataT value); // Переключить биты PORT ^= value; static void Togle(DataT value); // Установить биты направления static void DirSet(DataT value); // Очистиь биты направления static void DirClear(DataT value); // Переключить биты направления static void DirTogle(DataT value); // прочитать состояние линий В/В static DataT PinRead(); // Уникальный идентификотор порта enum{Id = ID}; // Разрядность порта (бит) enum{Width=sizeof(DataT)*8};
Реализация этого интерфейса для семейств Tiny и Mega AVR будет выглядеть так:
#define MAKE_PORT(portName, ddrName, pinName, className, ID) \ class className{ \ public: \ typedef uint8_t DataT; \ private: \ static volatile DataT &data() \ { \ return portName; \ } \ static volatile DataT &dir() \ { \ return ddrName; \ } \ static volatile DataT &pin() \ { \ return pinName; \ } \ public: \ static void Write(DataT value) \ { \ data() = value; \ } \ static void ClearAndSet(DataT clearMask, DataT value) \ { \ data() = (data() & ~clearMask) | value; \ } \ static DataT Read() \ { \ return data(); \ } \ static void DirWrite(DataT value) \ { \ dir() = value; \ } \ static DataT DirRead() \ { \ return dir(); \ } \ static void Set(DataT value) \ { \ data() |= value; \ } \ static void Clear(DataT value) \ { \ data() &= ~value; \ } \ static void Togle(DataT value) \ { \ data() ^= value; \ } \ static void DirSet(DataT value) \ { \ dir() |= value; \ } \ static void DirClear(DataT value) \ { \ dir() &= ~value; \ } \ static void DirTogle(DataT value) \ { \ dir() ^= value; \ } \ static DataT PinRead() \ { \ return pin(); \ } \ enum{Id = ID}; \ enum{Width = sizeof(DataT)*8}; \ };
Поскольку в семействе XMega все регистры порта сгруппированы в одну структуру и есть специальные регистры чтобы быстро устанавливать/очищать/переключать отдельные биты порта, реализация нашего интерфейса будет несколько проще:
#define MAKE_PORT(portName, className, ID) \ class className{ \ public: \ typedef uint8_t DataT; \ public: \ static void Write(DataT value) \ { \ portName.OUT = value; \ } \ static void ClearAndSet(DataT clearMask, DataT value) \ { \ Clear(clearMask); \ Set(value); \ } \ static DataT Read() \ { \ return portName.OUT; \ } \ static void DirWrite(DataT value) \ { \ portName.DIR = value; \ } \ static DataT DirRead() \ { \ return portName.DIR; \ } \ static void Set(DataT value) \ { \ portName.OUTSET = value; \ } \ static void Clear(DataT value) \ { \ portName.OUTCLR = value; \ } \ static void Togle(DataT value) \ { \ portName.OUTTGL = value; \ } \ static void DirSet(DataT value) \ { \ portName.DIRSET = value; \ } \ static void DirClear(DataT value) \ { \ portName.DIRCLR = value; \ } \ static DataT PinRead() \ { \ return portName.IN; \ } \ static void DirTogle(DataT value) \ { \ portName.DIRTGL = value; \ } \ enum{Id = ID}; \ enum{Width=8}; \ }; #ifdef PORTA MAKE_PORT(PORTA, Porta, 'A') #endif ... #ifdef PORTR MAKE_PORT(PORTR, Portr, 'R') #endif
Анологично можно определить порты В\В для других семейств МК. Порты В/В теперь инкапсулированы в классы, и мы можем их использовать как типовые параметры шаблонов. Приступим к реализации класса для линии ввода-вывода:
template<class PORT, uint8_t PIN> class TPin { public: typedef PORT Port; enum{Number = PIN}; static void Set() { PORT::Set(1 << PIN); } static void Set(uint8_t val) { if(val) Set(); else Clear(); } static void SetDir(uint8_t val) { if(val) SetDirWrite(); else SetDirRead(); } static void Clear() { PORT::Clear(1 << PIN); } static void Togle() { PORT::Togle(1 << PIN); } static void SetDirRead() { PORT::DirClear(1 << PIN); } static void SetDirWrite() { PORT::DirSet(1 << PIN); } static uint8_t IsSet() { return PORT::PinRead() & (uint8_t)(1 << PIN); } };
Протестируем полученный класс:
typedef TPin<Porta, 1> Pa1; ... Pa1::Set(); //sbi 0x1b, 1 ; 27 Pa1::Clear(); //cbi 0x1b, 1 ; 27 Pa1::Togle(); //in r24, 0x1b ; 27 //ldi r25, 0x02 ; 2 //eor r24, r25 //out 0x1b, r24 ; 27
Для удобства определим короткие имена для всех возможных линий В/В:
#ifdef PORTA typedef TPin<Porta, 0> Pa0; ... typedef TPin<Porta, 7> Pa7; #endif ... #ifdef PORTR typedef TPin<Portr, 0> Pr0; ... typedef TPin<Portr, 7> Pr7; #endif
Как видно, никаких накладных расходов нет, эффективность получилась на уровне того, что можно получит с помощью препроцессора. Те кто давно пишут на Си могут возразить – стоило ли писать какие-то непонятные классы на две страницы вместо того, чтобы написать пару однострочных #define-ов и получить тоже самое?
Конечно-же стоило. Ведь получили мы далеко не тоже самое. Во-первых, класс TPin объединяет в себе все операции применимые к линии В/В. Во- вторых, он жестко типизирован и его можно использовать как параметр шаблона. Например, класс для записи значения в сдвиговый регистр:
template<class ClockPin, class DataPin, class LatchPin, class T = uint8_t> class ThreePinLatch { public: typedef T DataT; enum{Width=sizeof(DataT)*8}; static void Write(T value) { for(uint8_t i=0; i < Width; ++i) { DataPin::Set(value & 1); ClockPin::Set(); value >>= 1; ClockPin::Clear(); } LatchPin::Set(); LatchPin::Clear(); } };
Постой пример его использования:
typedef ThreePinLatch<Pa0, Pb3, Pc2> Reg1; int main() { Pa0::SetDirWrite(); Pb3::SetDirWrite(); Pc2::SetDirWrite(); while(1) { Reg1::Write(PORTD); } }
Вызов Reg1::Write компилируется в следующий ассемблерный листинг:
Reg1::Write: ldi r25, 0x00 sbrs r24, 0 rjmp .+4 sbi 0x18, 3 rjmp .+2 cbi 0x18, 3 sbi 0x1b, 0 cbi 0x1b, 0 subi r25, 0xFF cpi r25, 0x08 breq .+4 lsr r24 rjmp .-24 sbi 0x15, 2 cbi 0x15, 2 ret
Сгенерированный листинг не уступает написанному вручную на ассемблере. И кто говорит, что Си++ избыточен при программировании для МК? Попробуйте переписать этот пример на чистом Си, сохранив чистоту и понятность кода, разделение логики работы устройства от конкретной реализации портов В/В и такую-же эффективность.
Списки линий ввода-вывода.
Это только начало. Теперь нам предстоит самое интересное - реализовать эффективный вывод многобитных значений. Для этого нам нужна сущность объединяющая группу линий В/В – своеобразный список линий В/В. Поскольку и порты и отдельные линии у нас представлены различными классами, то логично реализовывать список линий с помощью шаблонов. Но здесь есть одна проблема: список линий может содержать различное число линий, а шаблоны в Си++ имеют фиксированное число параметров (а стандарте Cxx03, в следующей версии появятся Variadic templates). Нам поможет библиотека Loki, написанная Андреем Александреску. В ней реализовано множество шаблонных алгоритмов для манипуляций со списками типов произвольной длинны. Это нам подойдёт – списки типов превращаются в списки линий ввода-вывода. Что, собственно, такое списки типов лучше всего почитать у их автора Андрея Александреску в книге Современное проектирование на С++. Очень рекомендую прочитать, хотя-бы мельком, главу «Списки типов» в этой книге. Без этого будет мало понятно, что происходит дальше.
Не во всех МК предусмотрены команду для манипуляций с отдельными битами в портах В/В. В семействе MegaAVR тоже есть порты для которых недоступны битовые операции. Поэтому чтобы сделать операции с портами максимально эффективными нам нужно отказаться от побитового вывода – одно чтение, модификация значения и запись.
То есть нужно записать N битов из входного значения в N битов произвольно расположенных в нескольких портах В/В. Или по другому говоря, сгруппировать записываемые биты по портам и вывести их за раз.
В упрощенном виде алгоритм записи значения в произвольный список линий В/В будет выглядеть так:
- Определить список портов к которых подключены линии из списка.
- Для каждого порта:
- Определить список линий к нему подключенный.
- По этому списку сгенерировать битовую маску для битов, которые не нужно менять.
- Спроецировать биты из входного значения на соответствующие им места в регистре порта во временный буфер.
- Наложить битовую маску на регистр порта (т.е. очистить в нем те биту, куда будем записывать новое значение)
- Записать значение из временного буфера в регистр порта.
Выглядит всё это очень сложно. Когда мы пишем реализацию виртуальных портов на Си, то все эти операции проделываем вручную, а сейчас наша задача заставить компилятор выполнять эту работу. Для этого в нашем распоряжении есть списки типов и техника шаблонного метапрограммирования. Пусть компилятор сам тасует биты и считает битовые маски!
Приступим.
У каждой линии в списке есть два номера:
- Номер бита во входном значении
- Номер бита в порту, куда он отображается
Оба они понадобятся для того, чтобы спроецировать биты из входного значения в порт. Второй номер класс TPin помнит сам, оно хранится в enum-е:
enum{Number = PIN};
Чтобы запомнить первый номер понадобится дополнительный шаблон:
template<class TPIN, uint8_t POSITION> struct PW //Pin wrapper { typedef TPIN Pin; enum{Position = POSITION}; }; <p>Хотя можно было этого и не делать, а вычислять битовыю позицию потом с помощью алгоритма IndexOf.</p> <p>Этот шаблон хранит тип линии В/В и ее битовую позицию в списке. Теперь приступим к генерации собственно списка линий. Для определённости ограничим длину списка 16-ю линиями, ели надо можно добавить и больше, потом. Для этого возьмём шаблон MakeTypelist из библиотеки Loki и модифицируем его под свои нужды:</p> <code lang='c'> template < int Position, // стартовая битовая позиция, сначала это 0 typename T1 = NullType, typename T2 = NullType, typename T3 = NullType, typename T4 = NullType, typename T5 = NullType, typename T6 = NullType, typename T7 = NullType, typename T8 = NullType, typename T9 = NullType, typename T10 = NullType, typename T11 = NullType, typename T12 = NullType, typename T13 = NullType, typename T14 = NullType, typename T15 = NullType, typename T16 = NullType, typename T17 = NullType > struct MakePinList { private: // рекурсивно проходим все параметры // на следующей итерации Position увеличится на 1, // а T2 превратится в T1 и так далее typedef typename MakePinList < Position + 1, T2 , T3 , T4 , T5 , T6 , T7 , T8 , T9 , T10, T11, T12, T13, T14, T15, T16, T17 >::Result TailResult; enum{PositionInList = Position}; public: // Result это и есть требуемый список линий typedef Typelist< PW<T1, PositionInList>, TailResult> Result; }; //конец списка //конец рекурсии, когда список пуст template<int Position> struct MakePinList<Position> { typedef NullType Result; };
В результате на выходе имеем «голый» список типов наших линий ввода вывода. Мы уже можем объявить список из нескольких линий, сделать с ним пока ничего нельзя – для него не определены никакие операции:
typedef MakePinList<Pa1, Pa2, Pa3, Pb2, Pb3>::Result MyList;
Зато его можно передать в другой шаблон как один параметр. Воспользуемся этим и напишем класс реализующий операции с этим списком:
template<class PINS> struct PinSet { ... };
Далее напишем алгоритм для преобразования списка линий в список соответствующих им портам:
//шаблон принимает список линий в качестве параметра template <class TList> struct GetPorts; // для пустого списка результат – пустой тип template <> struct GetPorts<NullType> { typedef NullType Result; }; // для непустого списка // конкретизируем, что это должен быть список типов // содержащий голову Head и хвост Tail template <class Head, class Tail> struct GetPorts< Typelist<Head, Tail> > { private: // класс TPin помнит свой порт // запоминаем этот тип порта typedef typename Head::Pin::Port Port; //рекурсивно генерируем хвост typedef typename GetPorts<Tail>::Result L1; public: // определяем список портов из текущего порта (Port) и хвоста (L1) typedef Typelist<Port, L1> Result; };
Теперь мы можем конвертировать список линий в соответствующий список портов, однако один и тот-же порт может содержаться в нем несколько раз. Нам нужны не повторяющиеся порты, по этому воспользуемся алгоритмом NoDuplicates из библиотеки Loki:
// конвертируем список линий в соответствующий список портов typedef typename GetPorts<PINS>::Result PinsToPorts; // генерируем список портов без дудликатов typedef typename NoDuplicates<PinsToPorts>::Result Ports;
Чтобы организовать рекурсивный проход по списку портов понадобится еще один шаблон класса. Назовём его PortWriteIterator. В качестве шаблонных параметров он принимает список портов и исходный список линий:
template <class PortList, class PinList> struct PortWriteIterator;
В этом классе и будет находиться реализация операций с отдельными портами. Определим специализацию этого класса для пустого списка линий.
template <class PinList> struct PortWriteIterator<NullType, PinList> { // DataType может быть uint8_t или uint16_t (а может и uint32_t в дальнейшем) template<class DataType> static void Write(DataType value) { /*ничего не делаем тут*/ } };
Далее необходимо выбрать из списка линий, те которые принадлежат определённому порту.
// шаблон принимает два параметра: // TList - список линий // T – тип порта дл якоторого template <class TList, class T> struct GetPinsWithPort; // для пустого списка результат – пустой тип (т.е. тоже пустой список) template <class T> struct GetPinsWithPort<NullType, T> { typedef NullType Result; }; // если TList это список типов, голова в котором это PW<TPin<T, N>, M>, т.е. линия в заданном порту T с битовыми позициями N и M в порту и во входном значении соответственно, то вставляем её в голову нового списка. Рекурсивно обрабатываем хвост. template <class T, class Tail, uint8_t N, uint8_t M> struct GetPinsWithPort<Typelist<PW<TPin<T, N>, M>, Tail>, T> { typedef Typelist<PW<TPin<T, N>, M>, typename GetPinsWithPort<Tail, T>::Result> Result; }; // если голова списка - любой другой тип, то вставляем на её место рекурсивно обработанный хвост. template <class Head, class Tail, class T> struct GetPinsWithPort<Typelist<Head, Tail>, T> { typedef typename GetPinsWithPort<Tail, T>::Result Result; };
Теперь вычислим битовую маску для порта.
//Параметр TList должен быть список линий template <class TList> struct GetPortMask; // Для пустого списка возвращаем 0. template <> struct GetPortMask<NullType> { enum{value = 0}; }; template <class Head, class Tail> struct GetPortMask< Typelist<Head, Tail> > { //value = битовая маска для головы | битовая маска оставшейся части списка enum{value = (1 << Head::Pin::Number) | GetPortMask<Tail>::value}; }; Теперь напишем реализацию для функции записи в порт: // Head – голова списка портов – текущий порт // Tail – оставшийся список // PinList – исходный список линий. template <class Head, class Tail, class PinList> struct PortWriteIterator< Typelist<Head, Tail>, PinList> { //Определим линии принадлежащие текущему порту. typedef typename GetPinsWithPort<PinList, Head>::Result Pins; // Посчитаем битовую маску для порта enum{Mask = GetPortMask<Pins>::value}; typedef Head Port; template<class DataType> static void Write(DataType value) { // проецируем биты из входного значения в соответствующие биты порта // как это реализованно увидим дальше uint8_t result = PinWriteIterator<Pins>::UppendValue(value); // если кол-во бит в записываемом значении совпадает с шириной порта, // то записываем порт целиком. // это условие вычислится во время компиляции if((int)Length<Pins>::value == (int)Port::Width) Port::Write(result); else { // PORT = PORT & Mask | result; Port::ClearAndSet(Mask, result); } // рекурсивно обрабатываем остальные порты в списке PortWriteIterator<Tail, PinList>::Write(value); } }
Функция PinWriteIterator::UppendValue отображает биты во входном значении в соответствующие им биты в порту. Эффективность списков линий в целом зависит в первую очередь именно от реализации этого отображения, поскольку оно, в общем случае, должно выполняться динамически и не всегда может быть вычислено во время компиляции. В зависимости от распределения записываемых бит в результирующем порту применим несколько стратегий отображения бит:
- если записываемые биты в порту расположены последовательно, спроецируем их все сразу с помощью сдвига на нужное число бит и соответствующей битовой маски
- если одиночный бит в исходном значении и в порту имеют одну позицию, спроецируем его с помощью побитного ИЛИ.
- в остальных случаях, если бит во входном значении равен 1, то устанавливаем в 1 соответствующий ему бит в регистре.
// Tlist – список линий принадлежащих одному порту template <class TList> struct PinWriteIterator; // специализация для пустого списка – возвращаем 0 template <> struct PinWriteIterator<NullType> { template<class DataType> static uint8_t UppendValue(const DataType &value) { return 0; } }; // специализация для непустого списка template <class Head, class Tail> struct PinWriteIterator< Typelist<Head, Tail> > { template<class DataType> static inline uint8_t UppendValue(const DataType &value) { // проверяем, если линии в порту расположены последовательно // если часть линий в середине списка будет расположена последовательно, то // это условие не выполнется, так, что есть ещё простор для оптимизации. if(IsSerial<Typelist<Head, Tail> >::value) { // сдвигаем значение на нужное число бит и накладываем не него маску if((int)Head::Position > (int)Head::Pin::Number) return (value >> ((int)Head::Position - (int)Head::Pin::Number)) & GetPortMask<Typelist<Head, Tail> >::value; else return (value << ((int)Head::Pin::Number - (int)Head::Position)) & GetPortMask<Typelist<Head, Tail> >::value; } uint8_t result=0; if((int)Head::Position == (int)Head::Pin::Number) result |= value & (1 << Head::Position); else // это условие будет вычисляться во время выполнения программы if(value & (1 << Head::Position)) result |= (1 << Head::Pin::Number); // рекурсивно обрабатываем оставшиеси линии в списке return result | PinWriteIterator<Tail>::UppendValue(value); } };
Для определения того, что линии в порту расположены последовательно напишем следующий шаблон.
template <class TList> struct IsSerial; // специализация для пустого списка template <> struct IsSerial<NullType> { // пустой список последователен enum{value = 1}; // номер текущей линии enum{PinNumber = -1}; // признак конца списка enum{EndOfList = 1}; }; // для непустого списка template <class Head, class Tail> struct IsSerial< Typelist<Head, Tail> > { // последовательна ли оставшаяся часть списка typedef IsSerial<Tail> I; // запоминаем номер текущей линии в её порту enum{PinNumber = Head::Pin::Number}; // не конец списка enum{EndOfList = 0}; // список последователен если // номер текущей линии равен номеру следующей - 1 И // оставшаяся часть списка последовательна ИЛИ // текущая линия последняя в списке enum{value = ((PinNumber == I::PinNumber - 1) && I::value) || I::EndOfList}; };
С учётом всего выше написанного класс PinSet будет выглядеть так:
template<class PINS> struct PinSet { private: // конвертируем список линий в соответствующий список портов typedef typename GetPorts<PINS>::Result PinsToPorts; public: typedef PINS PinTypeList; // генерируем список портов без дубликатов typedef typename NoDuplicates<PinsToPorts>::Result Ports; // длинна списка линий enum{Length = Length<PINS>::value}; // выбираем тип данных записываемый в список линий // если длинна списка меньше или равна 8 берём тип uint8_t, // если больше – uint16_t typedef typename IoPrivate::SelectSize<Length <= 8>::Result DataType; //записать значение в список линий static void Write(DataType value) { PortWriteIterator<Ports, PINS>::Write(value); } };
Собственно списки линий уже должны работать:
typedef MakePinList<0, Pa1, Pa2, Pa3, Pb3, Pb4>::Result MyList; typedef PinSet<MyList> MyPins; ... MyPins::Write(0x55);
Однако, пользоваться ими пока не очень удобно. Поэтому сделаем вокруг нашей реализации списков линий прозрачную и удобную обёртку:
template < typename T1 = NullType, typename T2 = NullType, typename T3 = NullType, typename T4 = NullType, typename T5 = NullType, typename T6 = NullType, typename T7 = NullType, typename T8 = NullType, typename T9 = NullType, typename T10 = NullType, typename T11 = NullType, typename T12 = NullType, typename T13 = NullType, typename T14 = NullType, typename T15 = NullType, typename T16 = NullType, typename T17 = NullType > struct PinList: public PinSet < typename MakePinList < 0, T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17 >::Result > { // тело этого класса пусое, весь функционал наследован от PinSet };
Теперь можно объявлять списки линий следующим образом:
typedef PinList<Pa1, Pa2, Pa3, Pb3, Pb4> MyPins; MyPins::Write(0x55);
Стало достаточно удобно – можно один раз объявить такой список линий, а потом использовать его где угодно, передавать его как шаблонный параметр в классы и функции реализующие управление периферией. А для того, чтобы изменить способ подключения достаточно подправить только одну строку.
Настало время протестировать списки линий с различными конфигурациями и на разных МК. Выше приведённый пример компилируется в следующий ассемблерный листинг:
//вывод в PORTA in r24, 0x1b andi r24, 0xF1 ori r24, 0x0A out 0x1b, r24 //вывод в PORTB in r24, 0x18 andi r24, 0xE7 ori r24, 0x10 out 0x18, r24
Как видно, компилятору все значения были известны и он благополучно посчитал все битовые маски и логические операции, не оставив ничего лишнего на время выполнения. А как он поведёт себя если записываемое значение не известно во время компиляции? Рассмотрим следующий пример (список линий тот-же самый):
// MCU AtMega16 MyPins::Write(PORTC); // читаем PORTC in r18, 0x15 ; 21 //вывод в PORTA in r25, 0x1b ; 27 mov r24, r18 add r24, r24 andi r24, 0x0E ; 14 andi r25, 0xF1 ; 241 or r24, r25 out 0x1b, r24 ; 27 //вывод в PORTB in r24, 0x18 ; 24 andi r18, 0x18 ; 24 andi r24, 0xE7 ; 231 or r18, r24 out 0x18, r18 ; 24
Оригинал, печатается с разрешения автора
Продолжу позже.
![[Заглавная страница]](/w/images/logo.png)
