Сегодня мы продолжаем делать нащу операционку для AVR и, в частности, Arduino. В предыдущих частях мы уже реализовали полноценное переключение контекстов процессов и добились многозадачного выполнения нескольких процессов. Теперь пора добавить поддержку таких системных вызовов, как sleep
и yield
. Первый будет предоставлять процессу возможность приостановить выполнение на конкретный промежуток времени, не занимая при этом процессорные ресурсы, а второй - просто передавать выполнение следующему по очереди процессу. Кроме того, мы внесем некоторые усовершенствования в само ядро нашей операционной системы atmos
.
Начнем мы с усовершенствований и исправлений. Рекомендую вам открыть старый код ядра на GitHub прежде, чем читать дальше. Во-первых, у нас в коде класса kernel
была объявлена функция run
, запускающая ОС. Эта функция была помечена атрибутом naked
. В этом нет никаких проблем, но вот в теле функции - есть. Из нее мы вызываем C++-функцию initialize_scheduler_timer
, а этого, как вы помните из пояснений в предыдущих статьях, делать не стоит. Документация на атрибут naked
крайне не рекомендует выполнять любой C/C++-код из таких функций, обещая нам неопределенное поведение. Нам повезло: ничего не упало и не сломалось, но ошибку все-таки придется исправить. Уберем атрибут naked
. Но теперь, если мы попробуем скомпилировать код, то получим ошибку о том, что функция run
не может быть помечена атрибутом noreturn
, потому что она возвращает управление. Атрибут noreturn
, напоминаю, сообщает компилятору о том, что функция никогда не возвращает управления. Именно для этого данный атрибут и был добавлен к функции run
. Ошибку эту компилятор выдает, потому что тело функции завершается ассемблерным макросом, и компилятор C++ не может знать, что этот макрос в конце осуществляет переход на совершенно другой код (а именно, передает управление одному из процессов ОС). Чтобы сообщить компилятору о том, что макрос не вернет управление, в самый конец функции допишем строку __builtin_unreachable();
. Эта встроенная в GCC функция сообщает ему, что до точки ее вызова управление не дойдет никогда. Теперь наше ядро снова собирается.
Есть еще одна проблема: все та же функция run
в конце осуществляет переход на тело функции switch_to_next_process_context_func
. А та сразу начинает выполнять макрос, который делает две (для некоторых контроллеров три) операции pop
. Мы их туда в предыдущей части добавили, чтобы освободить место на стеке, которое требуется для вызова функции choose_next_process
. Эти три pop
'а, скорее всего, ничего плохого не сделают (потому что у нас на стеке есть кое-какие данные, как минимум, адрес возврата из функции run
, и stack underflow не возникнет), но в данном случае эти инструкции совершенно лишние. Поэтому вместо вызова макроса switch_to_next_process_context_func
мы будем вызывать напрямую choose_next_process
, а потом осуществлять переход на метку внутри тела функции switch_to_next_process_context_func
, которую мы поставим непосредственно перед записью в стековые регистры SPH
и SPL
.
Еще одна правка коснулась функции save_context_and_switch_to_next_process_func
: ее я выпилил полностью вместе с прилагающейся функцией offset_of
. Там была очень нетривиальная ассемблерная вставка ради того, чтобы только сохранить текущий стековый указатель процесса. Этот код я перенес внутрь функции switch_to_next_process_context_func
, написанной на C++, немного изменив порядок действий.
Код после правок я буду приводить уже с нововведениями, касающимися операций sleep
и yield
. Поэтому ими мы теперь и займемся.
Начнем мы с нашего файла конфигурации config.h
. Раз мы желаем иметь возможность приостановить любой процесс на заданный промежуток времени, нам это время придется как-то отслеживать. Для этого мы введем счетчик срабатываний прерывания планировщика (tick count), который будет инкрементироваться каждый раз, когда планировщик получает управление. Размер этого счетчика будет устанавливать пользователь, для этого мы добавим макрос ATMOS_TICK_COUNTER_TYPE
, раскрываться который будет по умолчанию в uint8_t
(однобайтовый счетчик, максимальное значение 255), но его можно будет изменить, выставив uint16_t
(2 байта), __uint24
(3 байта), uint32_t
(4 байта) или uint64_t
(8 байтов). В зависимости от размера этого счетчика будет меняться максимальное непрерывное время ожидания процессов. Например, если у нас размер счетчика - 1 байт (т.е. его максимальное значение равно 255), а таймер настроен на срабатывания раз в 10 миллисекунд, то максимальное время ожидания будет равно 255 * 10мс = 2550мс ~ 2.5 секунды
. Если же мы выберем двухбайтовый счетчик, у которого максимально допустимое значение равно 65535, то максимальное время ожидания процесса будет уже 65535 * 10мс ~ около 11 минут
! Но при выборе более емкого счетчика и кода будет сгенерировано больше, и оперативной памяти потребуется тоже чуть дольше. Так что этот размер нужно будет выбирать по потребностям.
Стоит отметить, что при выборе периода срабатывания прерывания планировщика, например, в 10 миллисекунд погрешность вызова sleep
тоже будет в районе 10 миллисекунд. Кроме того, если процесс ОС уже разбудила, это не гарантирует того, что он получит управление сразу же. Перед ним могут быть еще несколько процессов в очереди, которые тоже хотят получить свой квант времени по 10 миллисекунд каждый.
Следующая опция конфигурации, которая будет доступна пользователю ОС, - это ATMOS_ENABLE_SYSTEM_PROCESS
, которая будет включать или отключать процесс, который мы назовем системным. Этот процесс будет в нашей ОС получать управление всегда, когда все остальные процессы спят. Ведь когда нам нечего выполнять, мы не можем просто так остановить контроллер, ему нужно что-то пережевывать. Кстати, такой процесс есть и в Windows:
Вот, видите, он занял 99% ресурсов процессора просто потому, что все остальные процессы практически бездействуют, а система остановить процессор на время бездействия не имеет возможности. Вот и гоняет процесс, который ничего не делает.
Мы же дадим свободу этот процесс вообще не создавать, выставив значение макроса ATMOS_ENABLE_SYSTEM_PROCESS
в 0
. Это даст возможность более гибкой настройки. Например, системный процесс нам не нужен, если хотя бы один из пользовательских процессов никогда не будет вызывать sleep
. Выключив системный процесс, мы сэкономим оперативную и программную память.
Конечно, у нас будут опции конфигурации ATMOS_SUPPORT_YIELD
и ATMOS_SUPPORT_SLEEP
, которые будут включать поддержку системных вызовов yield
и sleep
, соответственно. Если включить только sleep
, то и yield
тоже будет включен автоматически, потому что sleep
будет неявно использовать yield
, и без последнего мы ничего сделать не сможем.
В файл checks.h
мы добавим код, который проверит, что пользователь задал допустимое значение макроса ATMOS_TICK_COUNTER_TYPE
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
namespace detail { template<typename T> struct tick_counter_checker { static constexpr bool is_valid = false; }; struct valid_type { static constexpr bool is_valid = true; }; template<> struct tick_counter_checker<uint8_t> : valid_type {}; template<> struct tick_counter_checker<uint16_t> : valid_type {}; template<> struct tick_counter_checker<__uint24> : valid_type {}; template<> struct tick_counter_checker<uint32_t> : valid_type {}; template<> struct tick_counter_checker<uint64_t> : valid_type {}; } //namespace detail static_assert(detail::tick_counter_checker<ATMOS_TICK_COUNTER_TYPE>::is_valid, "ATMOS_TICK_COUNTER_TYPE does not name a valid tick counter type"); |
Мы задали шаблонную структуру tick_counter_checker
, в которую будем передавать значение макроса. Если оно является одним из допустимых, то компилятор выберет при инстанциации шаблона одну из его явных специализаций, и ошибка выведена не будет, потому что во всех этих специализациях выставлено значение is_valid = true
. А вот если значение недопустимо, то будет выбрана структура по умолчанию, и компилятор выдаст ошибку о том, что тип счетчика некорректен.
В файл defines.h
мы добавим новые макросы:
1 2 |
#define ATMOS_NOINLINE __attribute__((noinline)) #define ATMOS_ALWAYS_INLINE __attribute__((always_inline)) inline |
Первый макрос мы применим, чтобы пометить одну из функций атрибутом noinline
. Это нужно для того, чтобы заставить компилятор выделить для функции отдельное тело и адрес и никогда не встраивать ее в другие функции целиком. Позже я поясню, с какой целью нам это понадобится. Второй макрос - противоположность первому, он заставит функцию всегда встраиваться в тело тех функций, которые будут ее вызывать.
Затем, в файле forward_list.h
в класс forward_list_tagged
мы добавим пару методов:
1 2 |
static void set_next(list_element_type* elem, list_element_type* next) ATMOS_NONNULL(1); void set_first(list_element_type* elem); |
Первый метод будет устанавливать для элемента списка elem
следующий элемент next
, а второй метод - устанавливать для списка первым элементом (head) elem
. Это все нам потребуется, когда мы будем манипулировать списком выполняющихся и ожидающих процессов.
Теперь перейдем к файлу process.h
, там тоже кое-что новенькое добавится. Подключим к этому файлу наш конфиг config.h
, а затем в теле класса process
определим следующий тип:
1 2 3 |
#if ATMOS_SUPPORT_SLEEP using tick_t = ATMOS_TICK_COUNTER_TYPE; #endif //ATMOS_SUPPORT_SLEEP |
tick_t
- это, по сути, тот тип счетчика срабатываний прерывания таймера, который определяет пользователь ОС в ее конфигурации. В структуру control_block
, определенную в предыдущей части, также добавится новое поле:
1 2 3 |
#if ATMOS_SUPPORT_SLEEP tick_t sleep_until = 0; #endif //ATMOS_SUPPORT_SLEEP |
Значение этого поля будет указывать, до какого срабатывания таймера процесс будет приостановлен. Это займет некоторый объем памяти для каждого из процессов (зависит от размера типа tick_t
, определенного пользователем). Наконец, добавим несколько публичных функций в интерфейс класса process
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#if ATMOS_SUPPORT_SLEEP || ATMOS_SUPPORT_YIELD static void ATMOS_NOINLINE ATMOS_NAKED yield(); #endif //ATMOS_SUPPORT_SLEEP || ATMOS_SUPPORT_YIELD #if ATMOS_SUPPORT_SLEEP static void sleep_ticks(tick_t ticks); template<typename T> static void sleep_us(T microseconds) { sleep_ticks(static_cast<tick_t>(microseconds / ATMOS_TICK_PERIOD_US)); } template<typename T> static void sleep_ms(T milliseconds) { sleep_ticks(static_cast<tick_t>(1000ul * milliseconds / ATMOS_TICK_PERIOD_US)); } #endif //ATMOS_SUPPORT_SLEEP |
Если включена опция ATMOS_SUPPORT_SLEEP
или ATMOS_SUPPORT_YIELD
, то добавится объявление функции yield
. Эта функция будет передавать управление от текущего процесса (который и вызвал функцию) следующему по очереди. Мы добавили к этой функции атрибуты naked
и noinline
. Первый необходим, потому что мы напишем код функции сами, используя ассемблерные вставки и макросы, и нам не нужен сгенерированный компилятором пролог и эпилог. Второй атрибут нужен, чтобы функция не встраивалась в тело других функций и имела отдельный адрес. Когда процесс будет ее вызывать, этот адрес будет автоматически контроллером добавляться на вершину стека процесса. Поведение будет полностью аналогично тому, которое мы имеем при срабатывании прерывания планировщика: на стеке мы всегда будем иметь адрес возврата в тело процесса, в то место, с которого нужно будет продолжать выполнение, когда процесс получит управление в следующий раз. Только в случае yield
(или sleep
) процесс сам добровольно будет отказываться от положенного ему процессорного времени, в отличие от прерывания планировщика, который принудительно передает управление другому процессу.
Функции sleep
, sleep_us
и sleep_ms
позволяют приостановить выполнение текущего процесса на заданное количество срабатываний прерывания планировщика, либо на заданное количество микросекунд/миллисекунд. Это системный вызов sleep
, который по-настоящему приостановит выполнение процесса на заданное время, а другие процессы смогут в это время спокойно выполняться.
Теперь начинается самая мякотка - файл kernel.cpp
. Сперва обсудим, как мы будем реализовывать наш функционал. У нас уже есть список выполняющихся процессов running_processes
. Теперь же нам понадобится еще список ожидающих процессов. На время, пока процесс спит, он будет удален из списка выполняющихся процессов и будет добавлен в список приостановленных. В этом списке мы будем упорядочивать процессы в порядке возрастания номера срабатывания прерывания планировщика (будем называть его дальше tick). Например, если текущий tick равен 2, и какой-либо процесс говорит ядру, что желает ждать 5 tick'ов, то tick, когда ядро должно будет его разбудить - это 2 + 5 = 7. Но тут есть еще одна загвоздка: значение tick'а может переполняться. Например, если у нас tick_t
- это uint8_t
(однобайтовый тип), а текущий номер tick'а планировщика равен 250, и тут какой-то процесс просит усыпить его на 10 тиков... То значение 260 у нас в один байт не влезет. Произойдет переполнение, и мы при сложении 250 + 10 получим 4. Это не совсем то, чего мы ожидали, но с этим можно работать. Более того, переполнение беззнакового типа абсолютно легально в C++. Добавим вместо одного списка ожидающих процессов два: waiting_processes
и waiting_processes_overflown
. Если вдруг значение tick'а, до которого хочет спать процесс, вызывает переполнение, то мы будем помещать этот процесс во второй список. Если же не вызывает, то в первый. Каждый раз, когда срабатывает планировщик, мы значение текущего tick'а будем увеличивать на единицу, и рано или поздно это значение тоже переполнится и станет равным нулю. В этот момент мы поменяем местами содержимое наших списков waiting_processes
и waiting_processes_overflown
. Такой трюк позволит справиться с переполнением. Давайте теперь перейдем к коду:
1 2 3 4 5 6 7 8 9 10 11 12 |
#if ATMOS_ENABLE_SYSTEM_PROCESS process_list_element_tagged* system_process = nullptr; #endif //ATMOS_ENABLE_SYSTEM_PROCESS ... #if ATMOS_SUPPORT_SLEEP process_list_element_tagged* next_process = nullptr; process_list waiting_processes{}; process_list waiting_processes_overflown{}; atmos::process::tick_t tick_counter = 0; #endif //ATMOS_SUPPORT_SLEEP |
Если включена поддержка системного процесса, то определим указатель на него. Если включена поддержка ожидания, то у нас появляется сразу несколько переменных: указатель на процесс, который должен выполняться следующим (next_process
), два списка, которые я описал выше, и счетчик tick'ов планировщика. next_process
необходим, потому что в условиях перемещений процессов между списками нам будет недостаточно информации о том, какой процесс выполнить следующим при использовании старой логики, которую мы придумали в предыдущей статье. Например, если текущий процесс заснул, и мы его переместили в список waiting_processes
, то при попытке получить следующий элемент списка (process_list::next(current_process)
) мы получим лажу. Этот вызов вернет нам либо нулевой указатель (если за ним больше нет элементов), либо указатель на другой заснувший процесс, который следует за текущим в списке waiting_processes
. Но мы не можем на него переключаться, ведь он еще спит! Поэтому нам и необходим явный указатель, который будет явно сообщать, на какой процесс переключаться.
Далее рассмотрим функцию, которая будет увеличивать текущий счетчик tick'ов планировщика и будить процессы, которые дождались своего tick'а:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
void ATMOS_ALWAYS_INLINE tick_and_wake_up_processes() { //Инкрементируем счетчик tick'ов планировщика. if(!++tick_counter) { //Если он переполнился - меняем местами //содержимое списков waiting_processes и //waiting_processes_overflown. auto temp = waiting_processes; waiting_processes = waiting_processes_overflown; waiting_processes_overflown = temp; } //Далее проверяем, не можем ли мы разбудить //какие-то из процессов. process_list_element_tagged* prev = nullptr; process_list_element_tagged* current = waiting_processes.first(); while(current) { //Находим последний процесс в списке //waiting_processes, у которого значение //sleep_until меньше или равно текущему //значению счетчика tick'ов планировщика. if(tick_counter < (*current)->process.sleep_until) break; prev = current; current = process_list::next(current); } if(prev) { //Если мы нашли хотя бы один процесс, который пора будить, //то перемещаем все процессы, идущие в списке //перед ним, а также его самого в список выполняющихся //процессов. process_list::set_next(prev, running_processes.first()); running_processes.set_first(waiting_processes.first()); waiting_processes.set_first(current); } } |
Эта функция у нас будет всегда встраиваться в тело той функции, которая ее вызовет. Это нам необходимо, чтобы не расходовать стековую память на адреса возврата. Для этого мы добавили к функции атрибут always_inline
. В остальном все поясняется в комментариях к функции, но я дополнительно проиллюстрирую картинками, что у нас происходит во время каждого срабатывания прерывания планировщика:
На картинке изображено, как сначала у нас есть четыре выполняющихся и три спящих процесса. На следующем шаге значение счетчика tick'ов становится равным 3. Мы пролистываем список спящих процессов и находим два, которые надо разбудить. Мы отрезаем их от списка спящих процессов и прикрепляем в начало списка выполняющихся процессов.
Переходим к функции choose_next_process
. Так как ее вотчина расширилась, я переименовал ее в save_sp_and_choose_next_process
. Я приведу ее код полностью, хотя часть кода осталась с прошлой версии ОС:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
//Если поддержка ожидания включена, то у функции //будет аргумент increment_tick_count. Он будет говорить //о том, нужно ли инкрементировать текущее значение //счетчика tick'ов планировщика. #if ATMOS_SUPPORT_SLEEP atmos::process::stack_pointer_type ATMOS_HOT ATMOS_USED save_sp_and_choose_next_process(uint8_t increment_tick_count) asm("save_sp_and_choose_next_process"); atmos::process::stack_pointer_type save_sp_and_choose_next_process(uint8_t increment_tick_count) #else //ATMOS_SUPPORT_SLEEP atmos::process::stack_pointer_type ATMOS_HOT ATMOS_USED save_sp_and_choose_next_process() asm("save_sp_and_choose_next_process"); atmos::process::stack_pointer_type save_sp_and_choose_next_process() #endif //ATMOS_SUPPORT_SLEEP { #if ATMOS_SUPPORT_SLEEP //Если необходимо увеличить значение счетчика tick'ов, //то вызываем функцию tick_and_wake_up_processes, //рассмотренную выше. if(increment_tick_count) tick_and_wake_up_processes(); #endif //ATMOS_SUPPORT_SLEEP process_list_element_tagged* current; #if ATMOS_SUPPORT_SLEEP //Если включена поддержка ожидания, то сперва //сохраним стековый указатель текущего процесса... current = current_process; if(current) (*current)->process.stack_pointer = SP; //...затем возьмем следующий процесс... current = next_process; if(!current) //...если такого нет - берем первый... current = running_processes.first(); if(current) { //...и сохраним указатель на процесс, который //выполнится при следующем вызове save_sp_and_choose_next_process. next_process = process_list::next(current); } else { # if ATMOS_ENABLE_SYSTEM_PROCESS //...если же процессов на выполнение вообще нет, //то будем выполнять системный процесс. current = system_process; # endif //ATMOS_ENABLE_SYSTEM_PROCESS } #else //ATMOS_SUPPORT_SLEEP //Если поддержка ожидания выключена - все будет по-старому. current = current_process; auto* first = running_processes.first(); if(!current) { current = first; } else { //Единственное отличие - мы и тут теперь //сохраняем стековый указатель процесса. (*current)->process.stack_pointer = SP; current = process_list::next(current); if(!current) current = first; } #endif //ATMOS_SUPPORT_SLEEP //Дальше все как обычно. current_process = current; return (*current)->process.stack_pointer; } |
Так как я снова все пояснил в комментариях, подробнее раскрою только один момент. Объявление функции у нас будет меняться в зависимости от того, включена ли поддержка sleep'а. Если она включена, то функция будет принимать аргумент uint8_t increment_tick_count
. Это флаг, который будет говорить о том, нужно ли инкрементировать значение счетчика tick'ов и будить процессы, или нет. Так как функция save_sp_and_choose_next_process
будет вызываться из разных мест (и из процессов, желающих заснуть, и из прерывания планировщика), то инкрементировать этот счетчик нам нужно будет не всегда, а только при вызове из прерывания.
Функцию switch_to_next_process_context_func
я тоже переименовал, теперь она называется save_context_and_switch_to_next_process_context_func
. Рассмотрим отличия от предыдущей версии:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
void save_context_and_switch_to_next_process_context_func() { //Этого ассемблерного макроса здесь не было, //раньше он вызывался из прерывания планировщика, но //теперь переехал сюда, потому что функция //будет вызываться из нескольких мест. save_context_except_r31_and_sreg(); __asm__ __volatile__ ( "pop r28 \n\t" "pop r29 \n\t" #ifdef __AVR_3_BYTE_PC__ "pop r2 \n\t" #endif //__AVR_3_BYTE_PC__ #if ATMOS_SUPPORT_SLEEP //Еще одно изменение: эти две инструкции. //О них я расскажу ниже. "clr r24 \n\t" "bld r24, 0 \n\t" #endif //ATMOS_SUPPORT_SLEEP ATMOS_CALL "save_sp_and_choose_next_process \n\t" #ifdef __AVR_3_BYTE_PC__ "push r2 \n\t" #endif //__AVR_3_BYTE_PC__ "push r29 \n\t" "push r28 \n\t" //Данная метка тоже раньше отсутствовала. "switch_to_stack: \n\t" #if !defined(__AVR_HAVE_8BIT_SP__) && !defined(__AVR_SP8__) "out __SP_H__, r25 \n\t" #endif //16-bit stack "out __SP_L__, r24 \n\t" :: ); ... |
Что это за две инструкции - clr r24
и bld r24, 0
, которых раньше не было? Они добавляются в код только в том случае, если у нас включена поддержка sleep'а. Так как функция save_sp_and_choose_next_process
в этом случае у нас имеет аргумент uint8_t increment_tick_count
, то нам нужно передать этот аргумент в функцию. По соглашению вызовов AVR GCC первый однобайтовый аргумент в функцию передается всегда в регистре R24
. Поэтому мы сначала инструкцией clr r24
очищаем его значение. Мы можем манипулировать значением этого регистра, потому что к моменту выполнения этого кода его изначальное значение уже сохранено в контексте процесса. Вторая инструкция bld r24, 0
загружает значение флага T
из регистра SREG
в нулевой бит регистра R24
. Таким образом, если этот флаг будет выставлен в единицу, то и значение R24
станет равно единице, и функция save_sp_and_choose_next_process
на входе получит значение аргумента increment_tick_count
, равное единице. Бит T
регистра SREG
может быть использован для передачи пользовательской информации. Больше ни для каких целей он не годится. Мы будем выставлять значение этого бита в единицу (после сохренения значения SREG
в контексте процесса) перед вызовом функции из прерывания планировщика и в ноль при вызове этой функции из других мест.
Метка switch_to_stack
будет упоминаться дальше по тексту.
Теперь необходимо определить функцию системного процесса. Она не будет представлять из себя ничего сложного - это просто бесконечный цикл:
1 2 3 4 5 6 7 8 9 |
#if ATMOS_ENABLE_SYSTEM_PROCESS void ATMOS_NAKED system_process_entry_point() { __asm__ __volatile__ ( "1: jmp 1b \n\t" :: ); } #endif //ATMOS_ENABLE_SYSTEM_PROCESS |
Мы объявили эту функцию naked
и написали ее тело на ассемблере - это всего лишь одна инструкция jmp
, которая осуществляет переход на саму себя, выполняя таким образом бесконечный цикл. Вот вам и процесс "Бездействие системы" (он же - "System idle process").
Код прерывания планировщика изменится совсем чуть-чуть:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
ISR(ATMOS_TIMER_INTERRUPT_NAME, ISR_NAKED ATMOS_HOT) { save_r31_and_sreg_from_scheduler(); #if ATMOS_SUPPORT_SLEEP __asm__ __volatile__ ( "set \n\t" :: ); #endif //ATMOS_SUPPORT_SLEEP save_context_and_switch_to_next_process_context(); } |
Мы сохраняем значение регистров R31
и SREG
в контексте процесса, а затем выставляем значение флага T
регистра SREG
в единицу. Для этого используется инструкция set
. Далее следует вызов функции save_context_and_switch_to_next_process_context_func
(через соответствующий макрос).
Код функции run
, запускающей ОС, изменился чуть сильнее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
void kernel::run() { #if ATMOS_ENABLE_SYSTEM_PROCESS //Если включен системный процесс, то нужно выделить //для него память, а затем создать его. static atmos::process_memory_block<0> system_process_memory; system_process = create_process(system_process_entry_point, system_process_memory.get_memory(), decltype(system_process_memory)::memory_block_size); #endif //ATMOS_ENABLE_SYSTEM_PROCESS initialize_scheduler_timer(); __asm__ __volatile__ ( #if ATMOS_SUPPORT_SLEEP //Сбрасываем значение флага T регистра SREG, //если включена поддержка sleep'а. "clt \n\t" #endif //ATMOS_SUPPORT_SLEEP //Далее берем первый процесс, готовый к //выполнению, и переключаем на него контекст. ATMOS_CALL "save_sp_and_choose_next_process \n\t" ATMOS_JUMP "switch_to_stack \n\t" :: ); //Назначение этой функции уже пояснялось выше. __builtin_unreachable(); } |
Осталось разобраться с функциями yield
и sleep
. Начнем с первой.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#if ATMOS_SUPPORT_SLEEP || ATMOS_SUPPORT_YIELD void process::yield() { //Сначала сохраняем значения регистров //R31 и SREG в контексте процесса... save_r31_and_sreg_from_task(); # if ATMOS_SUPPORT_SLEEP //...затем, если включена поддержка sleep'а, //сбрасываем значение флага T регистра SREG... __asm__ __volatile__ ( "clt \n\t" :: ); # endif //ATMOS_SUPPORT_SLEEP //...и переключаемся на следующий процесс, //готовый к выполнению. save_context_and_switch_to_next_process_context(); } #endif //ATMOS_SUPPORT_SLEEP || ATMOS_SUPPORT_YIELD |
Здесь ничего сложного - два макроса и одна ассемблерная инструкция. Функция sleep
несколько толще:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
#if ATMOS_SUPPORT_SLEEP void process::sleep_ticks(tick_t ticks) { //Если процесс хочет спать 0 tick'ов, //просто делаем yield. if(!ticks) { yield(); return; } //В противном случае отключаем прерывания до конца функции... atmos::kernel_lock lock; //...вычисляем, до какого значения tick'а процесс //будет спать. Результат сложения может переполниться! tick_t sleep_until = tick_counter + ticks; //Прикапываем это значение в контрольный блок процесса... (*current_process)->process.sleep_until = sleep_until; //...и удаляем процесс из списка выполняющихся. running_processes.remove(current_process); //Если сложение выше переполнилось, то будем добавлять //процесс в список waiting_processes_overflown, в противном //случае - в список waiting_processes. auto& target_list = sleep_until < ticks ? waiting_processes_overflown : waiting_processes; //Добавляем в выбранный список процесс перед тем процессом, //у которого значение sleep_until больше, чем у текущего. //Таким образом, списки waiting_processes_overflown и //waiting_processes у нас отсортированы по возрастанию //по значению sleep_until в контрольных блоках процессов. target_list.insert_before(current_process, [sleep_until](const auto* before) { return before->process.sleep_until > sleep_until; }); //Все, нам пора передавать управление следующему //процессу! yield(); } #endif //ATMOS_SUPPORT_SLEEP |
Готово! Теперь наше ядро умеет усыплять процессы по их запросу, а потом будить их, когда необходимо. Давайте же воспользуемся этими фишками! Откроем файл main.cpp
и заменим все вызовы _delay_ms
на process::sleep_ms
. Скомпилируем код и проверим его, прошив в Arduino, как поясняется в предыдущих статьях. На Arduino Mega 2560 код у меня теперь занимает 996 байтов программной памяти и 225 байтов оперативной (я немного увеличил объем стека всем процессам), а на Arduino Uno R3 - 864 байта программной и 221 байт оперативной. При этом, мы оставили возможность отключить все ненужные опции и получить тот же размер ОС, как и в предыдущей версии!
Вах, теперь наши светодиоды моргают с теми периодами, которые мы задали, а не в три раза медленнее, как в предыдущей версии ОС! Первый светодиод загорается и гаснет 10 раз в секунду, второй - раз в 300 миллисекунд, а третий - раз в секунду. Можно поздравить себя: поставленная цель достигнута!
Что же можно сделать с нашей ОС дальше? Можно, например, добавить поддержку динамической памяти, чтобы создавать произвольное количество процессов, не выделяя под них память заранее. Можно добавить поддержку завершения процессов. Тогда процессы смогут завершаться, или их можно будет завершать принудительно. Наконец, можно добавить приоритеты процессам в стиле RTOS: если есть хотя бы один процесс с более высоким приоритетом, все остальные процессы с более низким не выполняются. После того, как все это будет реализовано, можно добавлять простейшие примитивы синхронизации, например, очереди данных с ожиданиями.
А пока что скачать текущую версию ОС можно по ссылке: atmos source code, также код проекта доступен на GitHub.
>Мы объявили эту функцию naked и написали ее тело на ассемблере - это всего лишь одна инструкция jmp, которая осуществляет переход на саму себя, выполняя таким образом бесконечный цикл.
А почему не sleep?
Это одно из возможных усовершенствований, можно сделать, чтобы вместо тупого бесконечного цикла в цикле вызывались
sleep_enable
иsleep_cpu
. Будет энергию экономить немного. Перед этим нужно будет выставить режим слипа вidle
, чтобы прерывания таймера не отключились. Подробнее - здесь. Это, кстати, один из плюсов операционной системы - можно приостанавливать контроллер на то время, пока ни одна задача не выполняется. Если бы всё выполнялось в единственном потоке, этого достичь было бы гораздо сложнее.не пробовали std::priority_queue поднять с кастомным алокатором для спящих процессов?
Не пробовал и не вижу особого смысла, полноценная реализация займет слишком много памяти (и оперативной, и программной). И не факт, что будет быстрее в рамках примитивного контроллера с частотой 20МГц и относительно небольшим количеством задач, даже несмотря на то, что сложность вставки меньше на порядок, чем у нынешней реализации с односвязным списком.
@dx
привет, профессионал, мне нужно решение этой проблемы. с радостью покажу мою благодарность и вознаграждение. Спасибо
https://privnote.com/FYbbDSLp#vVkTXcrbv
Доброго дня!
Очень интересный цикл статей, спасибо! Правда пока пробежался по диагонали и решаю - надо ли мне это? Поясню задачу.
Планируется пульт управления макетом: приём данных по UART, опрос нажатых кнопок (128), зажигание светодиодов (448) и вывод изображений разного размера на экран. Ардуино Мега 2560, Модуль ЖК-экрана TFT 3,5 дюйма Ultra HD 320X480 для платы Arduino MEGA 2560 R3 с Али.
Кнопки вводятся быстро (матрица 16х8), зажигание светиков тоже (40х595 и 2х7219, всё за 260 мкс), сам код обработки данных ещё не начинал, но думаю будет не очень затратно по времени. Затык в рисовании на экране. Если мелкие пиктограммы и текст вполне терпимо, то изображение на пол-экрана с SD выводится около секунды.
Хотелось бы иметь частоту опроса кнопок и обновления светодиодов 5-10 раз в секунду, экран можно дольше, т.к. это относительно редкая задача (период от пары секунд до минут). Решения вижу три.
1. Разбить изображение на мелкие кусочки и растянуть вывод по тактам.
2. Попробовать реализовать через вашу ОС (придётся вникать глубже :) ).
3. Посмотреть в сторону STM (самый трудозатратный вариант, ибо вникать ещё дольше).
Собственно вопрос - как считаете, есть ли смысл привлекать ОС ради единственного и довольно редко вызываемого длительного процесса?
Разбивать изображение на кусочки - это, мне кажется, слишком сложно и неподдерживаемо. Если без ОС реализовывать, я бы сделал опрос кнопок и управление светодиодами через прерывания (для кнопок - внешние или таймера, для светодиодов - таймера). Если же решите взять ОС, то советую взять готовую проверенную, например, FreeRTOS, нет смысла писать свою. Там есть куча всякой функциональности для реализации многозадачности и синхронизации задач, много мануалов и примеров использования, хорошая кастомизация. STM32 тоже неплохой вариант, там частота повыше, а в некоторых моделях есть аппаратная реализация протоколов для работы с дисплеями. Для STM тоже много мануалов и готового ПО (начиная от библиотек производителя, заканчивая mbed и тем же arduino). В общем, если бы я был привязан к AVR, я бы смотрел в сторону прерываний или FreeRTOS
Ясно, спасибо большое! Попробую RTOS, как минимум выиграю на чтении UART (будет прерывание, а не регулярный опрос флага, как делал в предыдущем проекте), ну и с изображениями меньше возни. Глянул, действительно много примеров, можно разобраться.
Отправил комент и дошло, что не сразу понял ваш ответ - можно вообще без ОС реализовать, только на прерываниях!! Это будет проще. Как-то раньше не шибко их пользовал сознательно )) Один раз энкодер подключал, там повозился, а прочие счётчики-таймеры вставлял методом копи-пасты из сети. Начну с прерываний, пожалуй, ОС тут избыточна. Спасибо!
Да, именно это я и имел в виду: обработчики кнопок можно повесить на внешние прерывания, для UART тоже есть прерывания, для светодиодов можно завести прерывание по таймеру, а основной код пусть когда нужно выводит картинку (самое долгое действие, которое можно прерывать).