Как стать автором
Обновить

ОС реального времени AQUA RTOS для МК AVR в среде BASCOM AVR

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

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

Избавиться от «макаронного кода» и вернуть сложному проекту на МК гибкость и управляемость помогает использование операционных систем реального времени.
Для микроконтроллеров AVR разработаны и довольно популярны несколько кооперативных ОС реального времени. Однако все они написаны на языке Си или Ассемблер и не подходят тем, кто программирует МК в среде BASCOM AVR, лишая их столь полезного инструмента для написания серьезных приложений.

Чтобы исправить этот недостаток, я разработала простую ОСРВ для среды программирования BASCOM AVR, которую и выношу на суд читателей.

image

Для многих привычный стиль программирования МК – т.н. суперцикл. Код при этом состоит из набора функций, процедур и описателей (константы, переменные), возможно, библиотечных, в целом называемых «фоновым кодом», а также большого бесконечного цикла, заключенного в конструкцию типа do – loop. При пуске сначала выполняется инициализация оборудования самого МК и внешних устройств, задаются константы и начальные значения переменных, а затем управление передается в этот бесконечный суперцикл.
Простота суперцикла очевидна. Большинство задач, выполняемых МК, ведь так или иначе цикличны. Недостатки тоже налицо: если какое-то устройство или сигнал требует немедленной реакции, МК обеспечит ее не раньше, чем обернется цикл. Если длительность сигнала окажется короче, чем период цикла, такой сигнал будет пропущен.

В приведенном ниже примере мы хотим проверить, нажата ли кнопка button:

do
    ' какой-то код
    if button = 1 then ' реакция на нажатие кнопки
    ' еще какой-то код
loop

Очевидно, что если «какой-то код» работает достаточно долго, МК может не заметить короткого нажатия кнопки.

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

on button button_isr ' назначаем обработчик кнопки
enable interrupts

' *** суперцикл ***
do
    ' какой-то код
loop
end

' обработчик кнопки
button_isr:
    ' делаем что-то при нажатии кнопки
return

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

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

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

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

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

Ниже показан подобный код:

on button button_isr ' назначаем обработчик кнопки
enable interrupts

' *** суперцикл ***
do
    ' какой-то код
    if button_flag = 1 then 
        ' реакция на нажатие кнопки
        button_flag = 0 ' не забудем сбросить флаг
    end if
    ' еще какой-то код
loop
end

' *** обработчик прерывания кнопки ***
button_isr:
    button_flag = 1
return

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

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

Виды операционных систем для МК


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

1:
goto 1

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

Другое дело – кооперативные ОС. Здесь сама задача управляет тем, в какой момент передать управление диспетчеру, позволив ему запустить на исполнение другие задачи. Более того, задачи тут обязаны это делать – иначе исполнение кода застопорится. С одной стороны кажется, что такой подход снижает общую надежность: ведь если какая-то задача «зависнет», она никогда не вызовет диспетчер, и вся система перестанет отвечать. С другой стороны, линейный код или суперцикл в этом плане ничем не лучше – ведь они могут зависнуть точно с таким же риском.

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

Переключение задач в BASCOM AVR


Чтобы реализовать переключение задач в среде BASCOM AVR, код задачи, каждая из которых реализована как обычная процедура, должен в каком-то месте вызывать диспетчер – тоже реализованный как обычная процедура.
Представим, что у нас есть две задачи, каждая из которых в каком-то месте своего кода вызывает диспетчер.

sub task1()
    do
        'код Задачи 1
        'вызов диспетчера
    loop
end sub

' ----------------------------------
sub task2()
    do
        'код Задачи 2
        'вызов диспетчера
    loop
end sub

Допустим, выполнялась Задача 1. Давайте поглядим, что окажется в стеке, когда она выполнит «вызов диспетчера»:

адрес возврата к основному коду (2 байта)
вершина стека –> адрес возврата к Задаче 1, вызвавшей диспетчер (2 байта)

Вершина стека будет указывать на адрес инструкции в Задаче 1, которая следует за вызовом диспетчера (инструкция loop в нашем примере).

Цель диспетчера в простейшем случае – передать управление Задаче 2. Вопрос – как это сделать? (предположим, диспетчеру уже известен адрес Задачи 2).

Для этого диспетчер должен вытащить из стека (и где-то запомнить) адрес возврата к Задаче 1, и поместить на это место в стек адрес Задачи 2, после чего дать команду return. Процессор извлечет из стека помещенный туда адрес и, вместо возврата к Задаче 1, перейдет на выполнение Задачи 2.

В свою очередь, когда Задача 2 вызовет диспетчер, мы так же вытащим из стека и сохраним адрес, по которому можно будет вернуться к Задаче 2, и загрузим в стек ранее сохраненный адрес Задачи 1. Дадим команду return – и окажемся в точке продолжения Задачи 1.

В итоге у нас получится такая чехарда:

Задача 1 –> Диспетчер –> Задача 2 –> Диспетчер –> Задача 1 ….

Неплохо! И это работает. Но, конечно, для сколь-нибудь пригодной к практическому использованию ОС этого мало. Ведь не всегда и не все задачи должны работать – например, они могут чего-то ожидать (истечения времени задержки, появления какого-нибудь сигнала и т.п.). Значит, у задач должен быть статус (РАБОТАЕТ, ГОТОВА, ОЖИДАЕТ и т.п). Кроме того, было бы неплохо, чтобы задачам назначался приоритет. Тогда, если более одной задачи готовы к выполнению, диспетчер продолжит ту задачу, которая имеет наибольший приоритет.

AQUA RTOS


Для реализации описанной идеи была разработана кооперативная ОС AQUA RTOS, предоставляющая задачам необходимые сервисы и позволяющая реализовать кооперативную многозадачность в среде BASCOM AVR.

Важное замечание касательно режима процедур в BASCOM AVR
Перед тем, как начать описание AUQA RTOS, следует заметить, что среда BASCOM AVR поддерживает два типа адресации процедур. Это регулируется опцией config submode = new | old.
В случае задания опции old компилятор, во-первых, будет компилировать весь код линейно, вне зависимости от того, используется он где-то или нет, во-вторых, процедуры без аргументов, оформленные в стиле sub name / end sub будет воспринимать как процедуры, оформленные в стиле name: / return. Это позволяет нам передавать адрес процедуры как метку в качестве аргумента другой процедуре путем использования модификатора bylabel. Это касается и процедур, оформленных в стиле в стиле sub name / end sub (в качестве метки нужно передать имя процедуры).
В то же время, режим submode = old налагает некоторые ограничения: процедуры задач не должны содержать аргументов; код файлов, подключенных через $include, включается в общий проект линейно, поэтому в подключенных файлах следует предусмотреть байпас – переход от начала к концу с помощью goto и метки.
Таким образом, в AQUA RTOS пользователь должен либо использовать для задач только старую нотацию процедур в стиле task_name: / return, либо использовать более общепринятое sub name / end sub, добавив в начало своего кода модификатор config submode = old, а во включаемые файлы – байпас goto метка / код включаемого файла / метка:.

Статусы задач AQUA RTOS


Для задач в AQUA RTOS определены следующие статусы:

OSTS_UNDEFINE 
OSTS_READY 
OSTS_RUN 
OSTS_DELAY
OSTS_STOP
OSTS_WAIT 
OSTS_PAUSE 
OSTS_RESTART 

Если задача еще не инициализирована, ей присвоен статус OSTS_UNDEFINE.
После инициализации задача имеет статус OSTS_STOP.
Если задача готова к исполнению, ей присваивается статус OSTS_READY.
Выполняющаяся в данный момент задача имеет статус OSTS_RUN.
Из него она может перейти в статусы OSTS_STOP, OSTS_READY, OSTS_DELAY, OSTS_WAIT, OSTS_PAUSE.
Статус OSTS_DELAY имеет задача, отрабатывающая задержку.
Статус OSTS_WAIT присваивается задачам, которые ожидают семафора, события или сообщения (подробнее о них ниже).

В чем различие статусов OSTS_STOP и OSTS_PAUSED?
Если по какой-то причине задача получает статус OSTS_STOP, то последующее возобновление работы задачи (при получении статуса OSTS_READY) будет осуществляться с точки ее входа, т.е. с самого начала. Из статуса OSTS_PAUSE задача продолжит работу в том месте, где была приостановлена.

Управление статусом задач


Управлять задачами может как сама ОС – автоматически, так и пользователь, путем вызова сервисов ОС. Сервисов управления задачами несколько (имена всех сервисов ОС начинаются с префикса OS_):

OS_InitTask(task_label, task_prio) 
OS_Stop() 
OS_StopTask(task_label) 
OS_Pause()
OS_PauseTask(task_label)
OS_Resume() 
OS_ResumeTask(task_label)
OS_Restart() 

Каждый из них имеет два варианта: OS_сервис и OS_сервисTask (кроме сервиса OS_InitTask, который имеет только один вариант; сервис OS_Init инициализирует саму ОС).

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

Про OS_Resume
Все сервисы управления задачами, кроме OS_Resume и OS_ResumeTask, после отработки автоматически вызывают диспетчер задач. В отличие от них, сервисы OS_Resume* только устанавливают задаче статус OSTS_READY. Этот статус будет обработан только при явном вызове диспетчера.

Приоритет и очередь задач


Как уже сказано выше, в реальной системе некоторые задачи могут оказаться более важными, а другие – второстепенными. Поэтому полезным свойством ОС является возможность назначить задачам приоритет. В этом случае, при наличии нескольких одновременно готовых задач, ОС сначала выберет задачу с наибольшим приоритетом. Если же все готовые задачи имеют равный приоритет, ОС будет ставить их на исполнение по кругу, в порядке, называемом «карусель» или round-robin.

В AQUA RTOS приоритет назначается задаче при ее инициализации через вызов сервиса OS_InitTask, которому в качестве первого аргумента передается адрес задачи, а в качестве второго – число от 1 до 15. Меньшее число означает больший приоритет. В ходе работы ОС изменение назначенного задаче приоритета не предусмотрено.

Задержки


В каждой задаче задержка отрабатывается независимо от других задач.
Таким образом, пока ОС отрабатывает задержку одной задачи, другие могут выполняться.
Для организации задержек предусмотрены сервисы OS_Delay | OS_DelayTask. В качестве аргумента передается число миллисекунд, на которое откладывается выполнение задачи. Поскольку размерность аргумента – dword, максимальная величина задержки составляет 4294967295 мс – или около 120 часов, что представляется вполне достаточным для большинства приложений. После вызова сервиса задержки автоматически вызывается диспетчер, который на время, пока будет отрабатываться задержка, передает управление другим задачам.

Семафоры


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

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

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

hBSem OS_CreateBSemaphore() 
OS_WaitBSemaphore(hBSem)                              
OS_WaitBSemaphoreTask(task_label, hBSem)
OS_BusyBSemaphore(hBSem)
OS_FreeBSemaphore(hBSem)

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

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

Сервис OS_WaitBSemaphore | OS_WaitBSemaphoreTask переводит (текущую | указанную) задачу в состояние ждать освобождения семафора hBSem, если этот семафор занят, а затем передает управление диспетчеру, чтобы он мог запускать другие задача. Если семафор свободен, передача управления не происходит, и задача просто продолжит выполнение.

Сервисы OS_BusyBSemaphore и OS_FreeBSemaphore устанавливают семафор hBSem в состояние занят или свободен соответственно.

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

События


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

На какие события может реагировать задача? Ну, например:
  • прерывание;
  • возникновение ошибки;
  • освобождение ресурса (иногда для этого удобнее использовать семафор);
  • изменение состояния линии ввода-вывода или нажатие клавиши на клавиатуре;
  • прием или посылка символа по RS-232;
  • передача информации от одной части приложения к другой (см. тж. сообщения).

Система событий реализована через следующие сервисы:

hEvent OS_CreateEvent()
OS_WaitEvent(hEvent)
OS_WaitEventTask(task_label, hEvent)
OS_WaitEventTO(hEvent, dwTimeout)
OS_SignalEvent(hEvent) 

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

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

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

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

Сообщения


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

hTopic OS_CreateMessage()
OS_WaitMessage(hTopic)
OS_WaitMessageTask(task_label, hTopic)
OS_WaitMessageTO(hTopic, dwTimeout) 
OS_SendMessage(hTopic, wMessage)
word_ptr OS_GetMessage(hTopic) 
word_ptr OS_PeekMessage(hTopic) 
string OS_GetMessageString(hTopic) 
string OS_PeekMessageString(hTopic)

Чтобы воспользоваться сервисом сообщений, нужно сначала создать тему сообщения. Это делается через сервис OS_CreateMessage, который возвращает байтовый хэндл темы hTopic, либо через пользовательский обработчик выдает ошибку OSERR_TOPIC_MAX_REACHED, говорящую о том, что достигнуто максимально возможное число тем сообщений, и больше создать не получится.

Чтобы велеть задаче ожидать сообщение на тему hTopic, в ее коде следует вызвать OS_WaitMessage, передав хэндл темы в качестве аргумента. После вызова этого сервиса управление будет автоматически передано диспетчеру задач. Таким образом, этот сервис переводит текущую задачу в состояние ждать сообщения по теме hTopic.

Сервис ожидания с таймаутом OS_WaitMessageTO работает аналогично сервису OS_WaitEventTO системы событий.

Для посылки сообщений предусмотрен сервис OS_SendMessage. Первым аргументом служит хэндл темы, на которую будет передано сообщение, а вторым – аргумент размерности word. Это может быть как самостоятельное число, так и указатель на строку, которая, в свою очередь, уже и является сообщением.

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

strMessage = "Hello, world!"
OS_SendMessage hTopic, varptr (strMessage)

Возобновив работу после вызова OS_WaitMessage, то есть, когда получено ожидаемое сообщение, задача может либо получить сообщение с его последующим автоматическим уничтожением, либо только просмотреть сообщение — в этом случае оно не уничтожится. Для этого служат последние четыре сервиса в списке. Первые два возвращают число размерности word, которое может быть либо самостоятельным сообщением, либо служить указателем строки, которая содержит сообщение. При этом OS_GetMessage автоматически удаляет сообщение, а OS_PeekMessage оставляет его.

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

Внутренняя таймерная служба


Для работы с задержками и отсчета времени AQUA RTOS использует встроенный в МК аппаратный таймер TIMER0. Таким образом, внешний код (фоновый и задач) не должен использовать этот таймер. Но обычно это и не требуется, т.к. ОС снабжает задачи всеми необходимыми средствами работы с временными интервалами. Разрешение таймера составляет 1 мс.

Примеры работы с AQUA RTOS


Начальные настройки


В самом начале пользовательского кода нужно определить, будет ли код исполняться во встроенном симуляторе или на реальном железе. Определите константу OS_SIM = TRUE | FALSE, которая задает режим симуляции.

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

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


Перед началом работы AQUA RTOS должна быть инициализирована. Для этого нужно вызвать сервис OS_Init. Этот сервис настраивает начальные параметры ОС. Что еще более важно, у него есть аргумент – адрес пользовательской процедуры обработки ошибок. У нее, в свою очередь, тоже есть аргумент – код ошибки.

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

Итак, первым этапом работы с AQUA RTOS нужно добавить в пользовательскую программу код инициализации ОС и процедуру обработчика ошибок:

OS_Init my_err_trap 

    '...
    '...
    '...

sub my_err_trap(err_code as byte)
    print err_code 
end sub

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


Вторым этапом нужно инициализировать задачи, указав их имена и приоритет:

OS_InitTask task_1, 1
OS_InitTask task_2 , 1
'...
OS_InitTask task_N , 1

Тестовые задачи


Помигаем светодиодами


Итак, давайте создадим тестовое приложение, которое можно загрузить в стандартную плату Arduino Nano V3. Создайте в папке с файлом ОС какую-нибудь папку (например, test), и там создайте следующий bas-файл:

' начальные установки компилятора
config submode = old 
$include "..\aquaRTOS_1.05.bas"

$regfile = "m328pdef.dat"
$crystal = 16000000
$hwstack = 48
$swstack = 48
$framesize = 64

' объявление процедур
declare sub my_err_trap (byval err_code as byte)
declare sub task_1()
declare sub task_2()

' назначим светодиодам порты и режим работы
led_1 alias portd.4
led_2 alias portd.5
config portd.4 = output
config portd.5 = output

' *** начало кода приложения ***
' инициализируем ОС
OS_Init my_err_trap 

' инициализируем задачи
OS_InitTask task_1, 1
OS_InitTask task_2 , 1

' изначально все задачи имеют статус «остановлена» (OSTS_STOP)
' чтобы задачи начали работать, им нужно задать статус 
' «готова к выполнению» (OSTS_READY) вызовом сервиса OS_ResumeTask
OS_ResumeTask task_1
OS_ResumeTask task_2

' осталось запустить ОС вызовом диспетчера
OS_Sheduler
end

' *** задачи ***
sub task_1 ()
    do
        toogle led_1 ' переключим светодиод 1
        OS_delay 1000 ' приостановить на 1000 мс
    loop
end sub

sub task_2 ()
    do
        toogle led_2 ' переключим светодиод 2
        OS_delay 333 ' приостановить на 333 мс
    loop
end sub

' ****************************************************
' обработчик ошибок
sub my_err_trap(err_code as byte)
    print "OS Error: "; err_code 
end sub

Подключите аноды светодиодов к выводам D4 и D5 платы Arduino (или к другим выводам, изменив соответствующие строки-определения в коде). Катоды через ограничительные резисторы 100...500 Ом подсоедините к шине GND. Скомпилируйте и залейте прошивку в плату. Светодиоды начнут асинхронно переключаться с периодом 2 и 0,66 с.

Давайте рассмотрим код. Итак, сначала мы инициализируем оборудование (задаем опции компилятора, режим работы портов и назначаем aliases), затем – саму ОС, и наконец – задачи.

Поскольку только что созданные задачи находятся в состоянии «остановлена», нужно придать им статус «готова к выполнению» (возможно, не всем задачам в реальном приложении – ведь какие-то из них могут по замыслу разработчика изначально пребывать в остановленном состоянии, и запускаться на выполнение только из других задач, а не сразу при старте системы; однако в данном примере обе задачи сразу должны начать работу). Поэтому для каждой задачи мы вызываем сервис OS_ResumeTask.

Теперь задачи готовы к исполнению, но еще не выполняются. Кто же их запустит? Конечно, диспетчер! Для этого мы должны вызвать его при первом запуске системы. Теперь, если все написано правильно, диспетчер будет поочередно выполнять наши задачи, а мы можем закончить основную часть программы оператором end.

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

Если в начале кода задать константу OS_SIM = TRUE и запустить код не на реальном чипе, а в симуляторе, то можно проследить, как работает ОС.

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

Выбрав задачу, которую следует исполнить (допустим, task_1), диспетчер подменяет в стеке адрес возврата (первоначально он показывает на инструкцию end в основном коде) адресом точки входа задачи task_1, которую система узнает в процессе инициализации задачи, и исполняет команду return, которая заставляет МК вытащить из стека адрес возврата и перейти к нему – то есть, начать исполнение задачи task_1 (оператор do в коде task_1).

Задача task_1, переключив свой светодиод, вызывает сервис OS_delay, который, выполнив необходимые действия, переходит к диспетчеру.

Диспетчер сохраняет адрес, который был в стеке, в блок управления задачи task_1 (указывает на инструкцию, следующую за вызовом OS_delay, т.е. инструкцию loop), а затем, «повернув карусель», обнаруживает, что теперь надо выполнить задачу task_2. Он помещает в стек адрес задачи task_2 (указывает в данный момент на инструкцию do в коде задачи task_2) и исполняет команду return, которая заставляет МК вытащить из стека адрес возврата и перейти к нему – то есть, начать исполнение задачи task_2.

Задача task_2, переключив свой светодиод, вызывает сервис OS_delay, который, выполнив необходимые действия, переходит к диспетчеру.

Диспетчер сохраняет адрес, который был в стеке, в блок управления задачи task_1 (указывает на инструкцию, следующую за вызовом OS_delay, т.е. инструкцию loop), а затем, «повернув карусель», обнаруживает, что теперь надо выполнить задачу task_2. Отличие от первоначального состояния будет в том, что теперь в блоке управления задачей task_1 хранится не стартовый адрес задачи, а адрес точки, с которой произошел переход к диспетчеру. Туда (на инструкцию loop в коде задачи task_1), и будет передано управление.

Задача task_1 выполнит инструкцию loop, и далее весь цикл «Задача 1 – Диспетчер – Задача 2 – Диспетчер» будет повторяться бесконечно.

Посылаем сообщения


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

' начальные установки компилятора
config submode = old 
$include "..\aquaRTOS_1.05.bas"

$regfile = "m328pdef.dat"
$crystal = 16000000
$hwstack = 48
$swstack = 48
$framesize = 64

' объявление процедур
declare sub my_err_trap (byval err_code as byte)
declare sub task_1()
declare sub task_2()

const OS_SIM = TURE ' будем запускать этот код в симуляторе

' объявление переменных
dim hTopic as byte ' переменная для темы сообщений
dim task_1_cnt as byte ' счетчик для задачи 1
dim strMessage as string * 16 ' сообщение

' *** начало кода приложения ***
OS_CreateMessage hTopic

OS_Init my_err_trap

OS_InitTask task_1 , 1
OS_InitTask task_2 , 1

OS_ResumeTask task_1
OS_ResumeTask task_2

OS_Sheduler
end

' *** задачи ***

sub task_1()
    do
        print "task 1"
        OS_Sheduler
        incr task_1_cnt ' увеличим счетчик на 1
        if task_1_cnt > 3 then
            print "task 1 is sending message to task 2"
            strMessage = "Hello, task 2!"
            ' посылаем сообщение задаче 2
            OS_SendMessage hTopic , varptr(strMessage)         
            task_1_cnt = 0
        end if
    loop
end sub

sub task_2()
    do
        print "task 2 is waiting messages..."
        ' будем ждать сообщений от задачи 1
        OS_WaitMessage hTopic
        print "message recieved: " ; OS_GetMessageString (hTopic)
    loop
end sub

' ****************************************************
' обработчик ошибок
sub my_err_trap(err_code as byte)
    print "OS Error: "; err_code 
end sub

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

task 1
task 2 is waiting messages…
task 1
task 1
task 1
task 1 is sending message to task 2
task 1
message recieved: Hello, task 2!
task 2 is waiting messages…
task 1
task 1


Обратите внимание, в каком порядке происходит работа и переключение задач. Как только Задача 1 напечатает task 1, управление передается диспетчеру для того, чтобы он мог запустить вторую задачу. Задача 2 печатает task 2 is waiting messages..., затем вызывает сервис ожидания сообщений на тему hTopic, и управление автоматически передается диспетчеру, который снова вызывает Задачу 1. Та снова печатает task 1 и отдает управление в диспетчер. Однако, поскольку диспетчер обнаруживает, что Задача 2 теперь ожидает сообщений, он возвращает управление Задаче 1 на инструкцию incr, следующую за вызовом диспетчера.
Когда счетчик task_1_cnt в Задаче 1 превысит указанное значение, задача посылает сообщение, но продолжает исполняться – выполняет инструкцию loop и снова печатает task 1. После этого она вызывает диспетчер, который теперь обнаруживает, что для Задачи 2 имеется сообщение, и передает управление ей. Далее процесс выполняется циклически.

Обработка событий


Следующий код опрашивает две кнопки и переключает светодиоды при нажатии на соответствующую кнопку:

' начальные установки компилятора
config submode = old
$include "..\aquaRTOS_1.05.bas"

$regfile = "m328pdef.dat"
$crystal = 16000000
$hwstack = 48
$swstack = 48
$framesize = 64

' объявление процедур
declare sub my_err_trap (byval err_code as byte)
declare sub task_scankeys()
declare sub task_led_1()
declare sub task_led_2()

' зададим светодиодам порты и режим их работы
led_1 alias portd.4
led_2 alias portd.5
config portd.4 = output
config portd.5 = output
button_1 alias pind.6
button_2 alias pind.7
config portd.6 = input
config portd.7 = input

' объявление переменных
dim eventButton_1 as byte
dim eventButton_2 as byte

' *** начало кода приложения ***
eventButton_1 = OS_CreateEvent  ' создадим по событию на каждую кнопку
eventButton_2 = OS_CreateEvent

OS_Init my_err_trap

OS_InitTask task_scankeys , 1
OS_InitTask task_led_1 , 1
OS_InitTask task_led_2 , 1

OS_ResumeTask task_scankeys
OS_ResumeTask task_led_1
OS_ResumeTask task_led_2

OS_Sheduler
end

' *** задачи ***

sub task_scankeys()
   do
      debounce button_1 , 0 , btn_1_click , sub
      debounce button_2 , 0 , btn_2_click , sub
      OS_Sheduler
   loop

btn_1_click:
    OS_SignalEvent eventButton_1
return

btn_2_click:
    OS_SignalEvent eventButton_2
    return
end sub

sub task_led_1()
    do
        OS_WaitEvent eventButton_1
        toggle led_1
    loop
end sub

sub task_led_2()
    do
        OS_WaitEvent eventButton_2
        toggle led_2
    loop
end sub

' ****************************************************
' обработчик ошибок
sub my_err_trap(err_code as byte)
    print "OS Error: "; err_code
end sub

Пример реального приложения под AQUA RTOS



Давайте попробуем представить, как бы могла выглядеть программа автомата по продаже кофе. Автомат должен показывать светодиодами в кнопках наличие вариантов кофе и выбор; принимать сигналы от приемника монет, готовить заказанный напиток, выдавать сдачу. Кроме того, автомат должен управлять внутренним оборудованием: например, поддерживать температуру водонагревателя на уровне 95…97°С; передавать данные о неисправностях оборудования и запасе ингредиентов и принимать команды через удаленный доступ (например, посредством GSM-модема), а также сигнализировать об актах вандализма.

Управляемый событиями подход


Поначалу разработчику бывает нелегко перейти от привычной схемы «суперцикл + флаги + прерывания» к подходу, основанному на задачах и событиях. Это требует выделения основных задач, которые должно выполнять устройство.
Давайте попробуем наметить такие задачи для нашего автомата:

  • контроль и управление нагревателем – ControlHeater()
  • индикация наличия и выбора напитков – ShowGoods()
  • прием монет/купюр и их суммирование – AcceptMoney()
  • опрос кнопок – ScanKeys()
  • выдача сдачи – MakeChange()
  • отпуск напитка – ReleaseCoffee()
  • защита от вандализма – Alarm()

Прикинем степень важности задач и частоту их вызова.
ControlHeater() очевидно важна, поскольку для приготовления кофе нам постоянно нужен кипяток. Но она не должна выполняться слишком часто, потому что нагреватель обладает большой инерционностью, а вода остывает медленно. Достаточно проверять температуру раз в минуту. Дадим этой задаче приоритет 5.
ShowGoods() не слишком важна. Предложение может измениться только после отпуска товара, если запас каких-то ингредиентов окажется исчерпан. Поэтому дадим этой задаче приоритет 8, и пусть она выполняется при пуске автомата и каждый раз при отпуске товара.
ScanKeys() должна иметь достаточно высокий приоритет, чтобы автомат быстро реагировал на нажатие кнопок. Дадим этой задаче приоритет 3, и будем выполнять ее каждые 40 миллисекунд.
AcceptMoney() также является частью интерфейса пользователя. Мы дадим ей тот же приоритет, что и ScanKeys(), и будем выполнять каждые 20 миллисекунд.
MakeChange () выполняется только после отпуска товара. Мы свяжем ее с ReleaseCoffee() и дадим приоритет 10.
ReleaseCoffee() нужна только тогда, когда было принято соответствующее количество денег и нажата кнопка выбора напитка. Для быстроты ответа дадим ей приоритет 2.
Поскольку вандалоустойчивость – довольно важная функция автомата, задаче Alarm() можно поставить самый высокий приоритет – 1, и активировать раз в секунду, чтобы проверить датчики наклона или вскрытия.

Таким образом, нам понадобится семь задач с различными приоритетами. После старта, когда программа считала настройки из EEPROM и инициализировала оборудование, наступает время инициализировать ОС и запустить задачи.

' объявление процедур задач
declare sub ControlHeater()
declare sub ShowGoods() 
declare sub AcceptMoney()
declare sub ScanKeys()
declare sub MakeChange ()
declare sub ReleaseCoffee()
declare sub Alarm()

Для работы в составе RTOS каждая задача должна иметь определенную структуру: в ней должен быть хотя бы один вызов диспетчера (или сервиса ОС, автоматически передающего управление диспетчеру) – только так можно обеспечить кооперативную многозадачность.

Например, ReleaseCoffee() может выглядеть примерно так:

sub ReleaseCoffee()
    do
        OS_WaitMessage bCoffeeSelection
        wItem = OS_GetMessage(bCoffeeSelection)
        Release wItem 
    loop 
end sub 

Задача ReleaseCoffee в бесконечном цикле ожидает сообщение на тему bCoffeeSelection и не делает ничего, пока оно не поступит (управление автоматически возвращается диспетчеру, чтобы он мог запускать другие задачи). Как только сообщение послано, ReleaseCoffee() становится готовой к выполнению, и когда это случается, задача получает содержимое сообщения (код выбранного напитка) wItem с помощью сервиса OS_GetMessage и отпускает товар заказчику. Поскольку ReleaseCoffee() использует подсистему сообщений, сообщение должно быть создано перед запуском многозадачности:

dim bCoffeeSelection as byte
bCoffeeSelection = OS_CreateMessage()

Как было сказано выше, ShowGoods() должна выполняться один раз при пуске и каждый раз при отпуске товара. Чтобы связать ее с процедурой отпуска ReleaseCoffee(), используем сервис событий. Для этого создадим событие перед запуском многозадачности:

dim bGoodsReliased as byte
bGoodsReliased = OS_CreateEvent()

А в процедуру ReleaseCoffee() после строчки Release wItem добавим сигнализацию о событии bGoodsReliased:

OS_SignalEvent bGoodsReliased

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


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

OS_Init Mailfuncion

В пользовательском коде нужно добавить обработчик – процедуру, байтовым аргументом которой будет код ошибки:

sub Mailfuncion (bCoffeeErr)
    print "Mailfunction! Error #: "; bCoffeeErr
    if isErrCritical (bCoffeeErr) = 1 then 
        CallService(bCoffeeErr)
    end if
end sub

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

Запуск задач


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

OS_InitTask ControlHeater , 5
OS_InitTask ShowGoods , 8 
OS_InitTask AcceptMoney , 3
OS_InitTask ScanKeys , 3
OS_InitTask MakeChange, 10
OS_InitTask ReleaseCoffee , 2
OS_InitTask Alarm , 1

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

OS_ResumeTask ControlHeater 
OS_ResumeTask ShowGoods
OS_ResumeTask AcceptMoney 
OS_ResumeTask ScanKeys
OS_ResumeTask MakeChange
OS_ResumeTask ReleaseCoffee
OS_ResumeTask Alarm

Как уже говорились, не все задачи обязательно должны стартовать при запуске многозадачности; некоторые из них могут произвольное время пребывать в состоянии «остановлена» и получать готовность лишь при определенных условиях. Сервис OS_ResumeTask может быть вызван в любое время из любого места кода (фонового или задачи), когда многозадачность уже работает. Главное, чтобы задача, на которую он ссылается, была предварительно инициализирована.

Пуск многозадачности


Теперь все готово для того, чтобы запустить многозадачность. Сделаем это путем вызова диспетчера:

OS_Sheduler

После этого мы смело можем поставить в коде программы end – управление дальнейшим исполнением кода теперь берет на себя ОС.

Давайте посмотрим на код целиком:

' начальные установки компилятора
config submode = old
$include "..\aquaRTOS_1.05.bas"
$include "coffee_hardware.bas" ' файл с процедурами управления оборудованием
' процедуры в этом файле имеют префикс Coffee_
$regfile = "m328pdef.dat" ' Arduino Nano v3
$crystal = 16000000
$hwstack = 48
$swstack = 48
$framesize = 64

Coffee_InitHardware ' инициализация оборудования автомата

' объявление процедур
declare sub Mailfuncion (byval bCoffeeErr as byte) ' обработчик ошибок
declare sub ControlHeater () ' управление водонагревателем
declare sub ShowGoods () ' показать наличие товара
declare sub AcceptMoney () ' прием наличных
declare sub ScanKeys () ' опрос кнопок
declare sub MakeChange () ' выдача сдачи после отпуска товара
declare sub ReleaseCoffee () ' отпуск товара
declare sub Alarm () ' обеспечение безопасности автомата

' проведем начальные настройки оборудования
Coffee_InitHardware ()

' объявление переменных
dim wMoney as long ' счетчик введенных денег
dim wGoods as long ' номер товара

' *** начало кода приложения ***

' инициализация ОС
OS_Init Mailfuncion 

' создадим тему сообщения о выборе напитка
dim bCoffeeSelection as byte
bCoffeeSelection = OS_CreateMessage() 

' создадим событие отпуска товара
dim bGoodsReliased as byte
bGoodsReliased = OS_CreateEvent() 

' инициализация задач
OS_InitTask ControlHeater , 5
OS_InitTask ShowGoods , 8 
OS_InitTask AcceptMoney , 3
OS_InitTask ScanKeys , 3
OS_InitTask MakeChange, 10
OS_InitTask ReleaseCoffee , 2
OS_InitTask Alarm , 1

' подготовка задач к выполнению
OS_ResumeTask ControlHeater 
OS_ResumeTask ShowGoods
OS_ResumeTask AcceptMoney 
OS_ResumeTask ScanKeys
OS_ResumeTask MakeChange
OS_ResumeTask ReleaseCoffee
OS_ResumeTask Alarm

' запуск ОС
OS_Sheduler

end

' *** код задач ***

' -----------------------------------
sub ControlHeater()
    do
        select case GetWaterTemp()
            case is > 97 
                Coffee_HeaterOff ' выключить нагреватель
            case is < 95
                Coffee_HeaterOn ' включить нагреватель
            case is < 5
                CallServce (WARNING_WATER_FROZEN) ' угроза замерзания 
        end select
        OS_Delay 60000 ' ждать 1 минуту
    loop
end sub

' -----------------------------------
sub ShowGoods()
    do
        LEDS = Coffee_GetDrinkSupplies() ' установить состояние порта D,
        ' к которому подключены светодиоды индикации наличия товаров и
        ' ассоциирована переменная LEDS 
         OS_WaitEvent bGoodsReliased ' ожидать события "отпуск товара"
    loop
end sub

' -----------------------------------
sub AcceptMoney()
    do
        wMoney = wMoney + ReadMoneyAcceptor()
        OS_Delay 20
    loop
end sub

' -----------------------------------
sub ScanKeys()
    do
        wGoods = ButtonPressed()
        if wMoney >= GostOf(wGoods) then
            OS_SendMessage bCoffeeSelection, wGoods
            ' отправляет сообщение на тему bCoffeeSelection, которое
            ' содержит код выбранного товара
        end if
        OS_Delay 40
    loop
end sub

' -----------------------------------
sub MakeChange()
    do
        OS_WaitEvent bGoodsReliased ' ожидать события "отпуск товара"
        Refund wMoney
    loop
end sub

' -----------------------------------
sub ReleaseCoffee()
    do
        OS_WaitMessage bCoffeeSelection 'ждать сообщения bCoffeeSelection
        wItem = OS_GetMessage(bCoffeeSelection) ' прочесть сообщение
        Release wItem ' отпустить выбранный товар
        wMoney = wMoney – CostOf (wItem) ' уменьшить на цену товара
        OS_SignalEvent bGoodsReliased ' просигналить об этом задачам
        ' обратите внимание, что это событие могут ждать две задачи:
        ' MakeChange и ShowGoods
        ' обе они, получив сообщение, становятся готовыми к исполнению 
    loop
end sub

' -----------------------------------
sub Alarm() 
    do
        OS_Delay 1000
        if Hijack() = 1 then 
            CallPolice()
        end if
    loop
end sub 
' -----------------------------------

' *** обработчик ошибок ОС ***
sub Mailfuncion (bCoffeeErr)
    print "Mailfunction! Error #: "; bCoffeeErr
    if isErrCritical (bCoffeeErr) = 1 then 
        CallService()
    end if
end sub

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

Исходный код AQUA RTOS


Исходный код версии 1.05 доступен для скачивания по ссылке

Постскриптум


Q: Почему AQUA?
A: Ну, я делала контроллер аквариума, это как «умный дом», только не людям, а для рыбок. Полно всяких датчиков, часы реального времени, релейные и аналоговые силовые выходы, экранное меню, гибкая «программа событий» и даже WiFi-модуль. Интервалы должны отсчитываться, кнопки опрашиваться, датчики обрабатываться, программа событий читаться из EEPROM и выполняться, экран обновляться, вай-фай отвечать. Да еще контроллер должен переходить в многоуровневое меню для настроек и программирования. Делать на флагах и прерываниях – это как раз получить тот самый «макаронный код», в котором ни разобраться, ни модифицировать. Поэтому я и решила, что мне нужна ОС. Вот она и AQUA.

Q: Наверняка в коде полно логических ошибок и глюков?
A: Наверняка. Я, как могла, придумывала разнообразные тесты и гоняла ОС на самых разных задачках, и даже прихлопнула заметное число багов, но это не означает, что всех и полностью. Более чем уверена, что их еще немало затаилось в закоулках кода. Поэтому буду очень благодарна, если вместо того, чтобы тыкать меня в баги мордой, вы вежливо и тактично укажете на них, а лучше и подскажете, как их, по-вашему, лучше исправить. Будет также замечательно, если проект получит дальнейшее развитие как продукт коллективного творчества. Например, кто-нибудь допишет сервис счетных семафоров (не забыли? – я ленивая задница) и предложит другие улучшения. В любом случае буду очень благодарна за конструктивный вклад.
Теги:
Хабы:
Всего голосов 43: ↑42 и ↓1+41
Комментарии20

Публикации

Истории

Ближайшие события