Пишем свою операционку для Arduino. Шаг 3 — переключаем контексты

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

context switch

В контроллерах AVR целая куча всяких регистров. Это регистры общего назначения, который позволяют производить вычисления и общаться с оперативной памятью, регистр флагов SREG, регистры указателя стека (SPL, SPH), регистры, управляющие портами ввода-вывода и целая гора регистров для управления встроенными устройствами (таймерами, UART, АЦП, watchdog'ом и т.д.). Процесс ОС может работать с любыми из этих регистров. Но если один процесс выставляет значение какого-то регистра в единицу, а потом управление передается другому процессу, который это значение меняет, например, на 3, то первый может очень удивиться, увидев неожиданно изменившиеся данные. Для того, чтобы избежать подобных ситуаций, следует ввести понятие контекста. Под контекстом процесса мы будем понимать набор регистров, которые перед передачей управления другому процессу будут ядром системы сохраняться. Когда управление будет возвращаться к процессу, мы его контекст будем восстанавливать.

Какие же регистры мы включим в контекст? Их же целая куча! Конечно, нужно сохранять и восстанавливать регистры общего назначения, потому что они используются компилятором GCC/G++ постоянно во всех операциях (в вычислениях, при вызове функций, в циклах и условиях...). Также стоит сохранять регистр SREG. Этот регистр содержит набор битовых флагов, которые также применяются в математических вычислениях и условных операциях. Кроме того, он содержит флаг, указывающий контроллеру, разрешать ли прерывания, и это состояние тоже неплохо бы запоминать отдельно для каждого процесса. Еще нам надо будет хранить указатель на стек, который хранится в регистрах SPH и SPL. Ведь у каждого процесса будет свой личный стек. Так как указатель на вершину стека 16-разрядный, а регистры 8-разрядные, их у нас два штуки: первый хранит старшие 8 байтов указателя, а второй - младшие 8 байтов. Если у контроллера 255 байтов оперативной памяти или меньше, то стековый указатель помещается в один байт, и регистр будет всего один - SPL.

А вот другие регистры, например, ввода-вывода, сохранять мы не будем. Если какой-то процесс выставила на вывод контроллера PC0 +5 вольт, а другой вдруг выставляет уровень 0 вольт на тот же вывод, то это скорее конфликт и ошибка в программе. Если бы мы сохраняли значения портов ввода-вывода, то при переключениях между этими двумя процессами уровень на этой ножке контроллера бы постоянно переключался с 0 на +5 вольт и обратно. Это не то, что задумывалось. Поэтому подобные регистры не войдут в наш контекст, и пока что синхронизация доступа к таким регистрам будет оставаться работой самих процессов. В будущем мы, возможно, запилим некий драйвер менеджера портов, который будет выполнять эту работу.

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

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

atmos process memory layout

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

Напишем код, который будет сохранять на стеке процесса и загружать оттуда же содержимое регистров общего назначения и регистра SREG. Этот код придется писать на ассемблере, потому что в C++/C попросту нет подходящих средств. Сохранять или восстанавливать контекст мы будем в два этапа, напишем для этого два макроса. Первый макрос будет сохранять/восстанавливать только регистры R31 и SREG (и при необходимости выключать прерывания), а второй - все остальные. Такое разделение нужно, чтобы между вызовом этих двух макросов мы могли в коде планировщика задач выполнить еще какие-то действия, например, решить, какой процесс запустится следующим. После сохранения R31 и SREG у нас будет один свободный регистр, а также отключенные прерывания. При этом мы сможем воспользоваться памятью, выделенной для стека текущего процесса, в своих целях до того, как сохраним в ней все оставшиеся регистры. Если бы мы сохраняли все регистры сразу, то свободной памяти могло и не остаться (она была бы занята содержимым этих регистров).

Итак, добавим в наш проект atmos новый файл context_switch.h. Начнем так:

Тут мы определили макрос, содержащий номер бита I, отвечающего за включение/отключение прерываний. Это нам понадобится, чтобы управлять прерываниями, устанавливая или сбрасывая этот бит в регистре SREG. Также мы определили значение, которое принимает встроенный макрос __AVR_ARCH__ на архитектуре avrtiny (или reduced tiny). Это архитектура некоторых контроллеров с малым объемом памяти, у которых отсутствуют регистры общего назначения R0 - R15, а присутствуют только R16 - R31. Запилим первый макрос, сохраняющий R31 и SREG и отключающий прерывания, он у нас будет в двух вариантах:

Первый макрос мы будем вызывать из планировщика, а второй - из самого процесса (если процесс, например, вызовет системную функцию sleep). Второй макрос нам потребуется не сразу, но позже мы его обязательно применим. Что же у нас происходит? Мы сохраняем значение регистра R31 на стеке текущего процесса, затем считываем значение регистра SREG, используя регистр R31, выставляем в нем значение бита I в единицу и тоже сохраняем на стеке. Выставив бит в единицу, мы сохраним на стеке значение SREG, как если бы у нас были включены прерывания. Но так как макрос save_r31_and_sreg_from_scheduler вызывается только из планировщика, то и попадаем мы в планировщик по прерыванию таймера, а это значит, что прерывания на момент входа в планировщик были включены. Потом, правда, контроллер их автоматически отключает, поэтому мы вручную выставляем значение бита I, чтобы прерывания снова были включены, когда мы контекст процесса будем восстанавливать. Строка "I" (ATMOS_AVR_INTERRUPT_BIT) служит для того, чтобы пробросить константу ATMOS_AVR_INTERRUPT_BIT в ассемблерный код из кода C++. Эта константа потом будет там доступна под именем %0 (потому что она первая по счету, а нумерация начинается с нуля).

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

Здесь самое время напомнить, почему мы не будем поддерживать архитектуру AVR XMega. Дело в том, что эта архитектура имеет три уровня прерываний, и более приоритетные прерывания могут прерывать менее приоритетные. Настройка прерываний тоже отличается. Это лишние сложности, которые нам сейчас не нужны. В архитектурах avr или avrtiny уровень прерываний только один, и при входе в прерывание никакое другое прерывание не может сработать, пока мы не закончим обработку и не включим прерывания.

Перейдем к коду, сохраняющему оставшийся контекст. Он будет сохранять на стеке процесса регистры R0 - R30 для архитектур avr и R16 - R30 для архитектуры avrtiny. Отмечу, что я пишу "архитектуры avr" во множественном числе не случайно: их много (avr2, avr51, avr6 и т.д.).

Здесь все совсем просто. Берем и сохраняем все регистры. Неясным может быть только обнуление командой clr регистра R17 для avrtiny и R1 для avr. Это делается для того, чтобы GCC/G++ не снесло башку от наших ассемблерных манипуляций. По конвенции вызовов AVR-GCC эти регистры должны быть всегда нулевыми. В то же время, некоторые инструкции могут менять значение этих регистров, а потом GCC их будет восстанавливать, однако, если планировщик прервет такую операцию посередине выполнения, может оказаться так, что значение этих регистров внутри планировщика будет ненулевым. Поэтому мы должны обнулить их значения, чтобы внутри планировщика мы могли писать код на C++, а не только на ассемблере.

А можем ли мы как-нибудь укоротить код для архитектур avr? Имеющийся код занимает аж 64 байта программной памяти. Оказывается, можно его сделать более компактным, но ценой уменьшенного быстродействия. Укороченный код будет выглядеть так:

Тут уже логика будет посложнее, но делает она ровно то же, что и длинный вариант. Основывается этот код на той хитрости, что регистры R0 - R31 в архитектуре avr замаплены в оперативную память и имеют адреса от 0 до 31 соответственно. То есть, если мы посмотрим, что лежит по адресу 0, то увидим значение регистра R0, если запишем в адрес 1 некоторое число, то это число окажется в регистре R1, ну и так далее. В коде выше мы сначала сохраняем на стеке регистры R30 и R29 (R31 у нас уже сохранен к этому моменту), потом обнуляем значения регистров R30 и R31. Затем мы с помощью команды ld начинаем в цикле загружать содержимое ячеек памяти по адресам от нулевого до 28-го. Это будет как раз содержимое регистров R0 - R28. Строка ld r29, Z+ используется для косвенной адресации памяти и означает буквально "считать значение по адресу Z, записать это значение в регистр R29, а потом увеличить значение регистра Z на единицу". Регистр Z - это название 16-разрядного регистра, который составлен из двух частей: R31 (старший байт) и R30 (младший байт). Так как мы уже сохранили и обнулили эти два регистра, мы можем их использовать как указатель на память, который каждую итерацию нашего цикла будет увеличиваться на единицу. После выполнения команды ld мы кладем значение на стек, затем сравниваем, не достиг ли наш счетчик значения 29. Когда он достигнет этого значения, это будет обозначать, что мы считали все регистры от R0 до R28 включительно и сложили их значения на стек. Регистры R29, R30 и R31 уже были сохранены ранее. Команда brne 1b (branch if not equal - перейти, если не равно) осуществляет переход на метку 1: по коду выше в том случае, если значение регистра R30 (он же - младшая часть регистра Z) оказывается не равным 29. В конце мы также очищаем регистр R1, как и в длинном варианте макроса.

Оптимизированный по размеру код занимает всего 18 байтов программной памяти. Мы сэкономили аж 46 байтов! Но выполняться этот вариант будет в несколько раз медленнее: 63 такта для длинного кода, 209 тактов для длинного.

В файле context_switch.h я оформил эти макросы так, чтобы пользователь сам мог выбрать, каким вариантом пользоваться. Для этого я в файл config.h внес новую конфигурационную опцию ATMOS_OPTIMIZE_CONTEXT_SWITCH_SIZE, которая при активации будет на архитектурах avr включать более короткий вариант кода сохранения и загрузки контекста, но более медленный.

Давайте теперь сделаем макросы, которые будут восстанавливать сохраненный на стеке контекст. Делать это мы будем точно так же в два этапа, но в обратном порядке: первый макрос будет восстанавливать регистры R0 - R30, а второй - R31 и SREG. Начнем с первого макроса. Здесь я сразу приведу оба варианта (короткий и медленный, а также длинный и быстрый) для архитектур avr.

С вариантами, состоящими из одних только pop'ов, все понятно: мы просто восстанавливаем значения регистров со стека процесса, которому собираемся передать управление. А вот с коротким вариантом для архитектур avr опять все хитрее. Сначала мы записываем в регистр R30 значение 29, затем очищаем регистр R31. Таким образом, регистр Z (состоящий из пары R31:R30, как вы помните) будет указывать на 29-й байт оперативной памяти. Далее, как и при сохранении контекста, в цикле мы со стека берем одно за другим сохраненные значения регистров и начинаем их записывать в ячейки памяти, на которые мапятся регистры R28 - R0. Команда st -Z, r29 делает следующее: уменьшает на единицу значение регистра Z, затем берет значение из регистра R29 и записывает его в байт оперативной памяти по адресу Z. R29 у нас сейчас выступает временным регистром для переброски данных. Потом мы проверяем, не дошли ли мы уже до нулевого адреса (командой tst r30), и если дошли, то прекращаем цикл, а затем восстанавливаем оставшиеся регистры R29 и R30.

Ну и второй макрос, восстанавливающий значения R31 и SREG:

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

Здесь у нас код будет немного сложнее. Сначала мы со стека берем сохраненное значение SREG и кладем его в регистр R31. Потом смотрим, установлен ли там бит, отвечающий за включение прерываний. Если не установлен, то мы просто записываем это значение напрямую в SREG, затем восстанавливаем лежащий на стеке регистр R31 и возвращаемся по адресу возврата, который у нас остался на стеке, выполняя инструкцию ret. Если же бит установлен, то мы его сбрасываем командой andi и только потом пишем значение в SREG. Это делается для того, чтобы нас не оборвало какое-нибудь прерывание сразу после того, как мы установили значение SREG, включив прерывания. Далее мы также восстанавливаем R31 и выполняем инструкцию reti, которая не только перекинет нас по адресу возврата, который лежит на стеке, но и атомарно включит прерывания. Такой финт не прокатит на архитектуре AVR XMega, и это еще одна причина, почему мы ее не поддерживаем.

Давайте теперь протестируем весь этот код. Проверять будем, используя симулятор, встроенный в Atmel Studio. Напишем в файле main.cpp следующий код:

Кратко поясню, что здесь происходит.

  • Сначала мы записываем в регистры R0 - R31 значения от 1 до 32, значение 123 в SREG.
  • Сохраняем контекст, в который как раз входят все эти регистры. Сохранять его будем на стеке текущей функции, так как никаких процессов у нас пока нет, и мы тестируем сам функционал сохранения и загрузки регистров.
  • Очищаем все регистры - R0 - R31 и SREG.
  • Восстанавливаем их из сохраненного ранее контекста.

Ставим точку остановка на самое начало функции main, кликнув на полосе слева от кода:

context switch test code

Выбираем конфигурацию Debug и запускаем проект. Когда мы нажмем F5, Atmel Studio ругнется, что нужно выбрать, как мы хотим отлаживать код, и откроет окно выбора. Нужно выбрать Simulator и сохранить настройки проекта:

Atmel Studio AVR simulator selection

Снова жмем F5. Исполнение будет остановлено в самом начале функции main на нашем брейкпоинте. Далее нам нужно открыть окно Processor Status, чтобы видеть содержимое регистров контроллера. Жмем Debug - Windows - Processor Status. Теперь нажимаем F10, выполняя таким образом первую ассемблерную вставку. Видим, что во всех регистрах R0 - R31 теперь записаны числа от 1 до 32, соответственно. Также можно обратить внимание на флаги, выставленные в регистре SREG.

testing atmos context switch

Нажимаем F10 трижды, выполняя сохранение контекста и ассемблерную вставку, очищающую все регистры. Видим, что везде теперь нули, а в SREG все флаги сброшены:

testing atmos context switch

Снова нажимаем дважды F10. Наша программа перейдет на бесконечный цикл и начнет выполняться, мы ее остановим нажатием кнопки "Пауза". Контекст должен быть восстановлен, проверим это:

testing atmos context switch

Видим, что значения регистров R0 - R31 и SREG восстановились до первоначальных значений. Наши макросы работают правильно! Теперь можно выполнить такую же проверку, переключив значение макроса ATMOS_OPTIMIZE_CONTEXT_SWITCH_SIZE в файле config.h с нуля на единицу (или наоборот), чтобы задействовать другой набор макросов. Я также выполнил проверку макросов для архитектуры avrtiny, немного изменив код ассемблерных вставок, записывающих в регистры общего назначения разные числа, а потом обнуляющих их.

На этом на сегодня все: мы написали код, который умеет сохранять и восстанавливать контекст процесса и успешно проверили его с разными настройками на разных архитектурах. Скачать полный код солюшена можно здесь: atmos full solution, актуальные изменения также доступны на GitHub проекта atmos.

Пишем свою операционку для Arduino. Шаг 3 — переключаем контексты: 1 комментарий

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *