Как сделать ОС для микроконтроллера

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

Короче говоря, надеюсь это будет полезно, или хотя бы интересно, для людей, ищущих ответы на вопросы на формах и статьях на Pikabu Хабре, а не в патентах, документации и прочих унылых источниках, где нет вставок с мемами.

image-loader.svg

Итак… Мне очень давно стало интересно, насколько это сложно, круто ли, с какими проблемами придется столкнутся в процессе и какие задачи придется решить.

Вот и решил, пару месяцев назад, сделать свою ОС.

У меня нет цели сделать ОС, дать ей пафосное имя и выложить ее потом, как делают пафосные пацаны. Я ее выложу, когда доделаю. А может и нет. А может и не доделаю… Меня скорее интересует — разобраться, как сделать, нежели сделать.

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

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

Да, я решил заняться этим, не попользовавшись ничем подобным. Из ОС использовал только кооперативную OSA на PIC24F. Да это очень профессиональный подход. Как говорится — профессионала видно из далека. Спасибо ​

Хотя…, я же пользуюсь Windows? Да! Она же вытесняющая? Тоже да! К тому же, я очень опытный пользователь! Наверное зря наговариваю на себя…

Определение ОС

image-loader.svg

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

На правах опытного пользователя Google (или Senior Google User, если хотите) — именую результат этой статьи «Embedded Operating System».

Если я не прав — можем позже похоливарить!)

Итак, скажем, что у нас это «Embedded OS», а дальше будем называть просто «ОС» или «операционная система».

Мое определение

Embedded OS — это программная прослойка, позволяющая реализовать псевдо многопоточность, на однопроцессорной системе. То есть — обычном микроконтроллере (да, даже на ардуино, уже предвкушаю этот вопрос : D).

Принцип работы ОС

Исходя из определения — основная задача, которую должна решать наша ОС, это реализация искусственной многопоточности на однопоточном процессоре.

Опустим инициализацию, тогда, в ходе работы, алгоритм искусственной многопоточности будет выглядеть так:

  1. Выполняется задача »1»;

  2. Квант времени задачи заканчивается, и возникает прерывание таймера ОС;

  3. Вызывается ядро ОС;

  4. Сохраняется контекст текущей задачи в стек этой задачи;

  5. Планировщик выбирает следующую задачу;

  6. Контекст следующей задачи восстанавливается из стека новой задачи;

  7. Происходит прыжок в то место задачи »2», на котором она была прервана в последний раз;

  8. Выполняется задача »2»;

  9. Квант времени задачи … ну вы поняли … и … «here we go again».

Переключение потоков изображено на рисунке, который я беспощадно позаимствовал из другой статьи на подобную тему. Прошу меня sorry, не ругайте, но мне лень это просто перерисовывать ​

image-loader.svg

И вот в этом алгоритме уже фигурируют некоторые фичи, которые нужно понять и простить реализовать. Итого нам нужно:

  1. Понять, что такое «Задача»;

  2. Понять, что такое «Квант времени задачи»;

  3. Понять, что такое «Контекст задачи»;

  4. Понять, что такое «Переключение контекста»;

  5. Понять, что такое «Стек задачи»;

  6. Понять, что такое «Планировщик»;

  7. Понять, что такое «Ядро»;

  8. Убедиться, что все точно понятно;

  9. Реализовать все эти приблуды!

  10. Понять, что все было понятно не точно;

  11. Переделать все заново.

Термины и определения

Задача

Это просто Си-функция, содержащая бесконечный цикл.

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

Пример задачи:

void Task1(void)
{
  for(;;)
  {
      /* some important actions */
  }
}

Квант времени задачи

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

Например, если квант времени возьмем как 1 мс, тогда временная диаграмма работы ОС будет выглядеть как на рисунке ниже. Условимся, что время выполнения кода ядра, для переключения контекста занимает 50 мкс.

image-loader.svg

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

Алгоритм работы выглядит примерно так:

  1. Настраивается прерывание таймера на 1 мс;

  2. Запускается ОС;

  3. Задача 1 работает;

  4. Таймер делает «тик-так» много раз;

  5. Срабатывает прерывание таймера;

  6. ОС переключает контекст на задачу 2 и сбрасывает таймер;

  7. Задача 2 работает;

  8. Таймер делает «тик-так» много раз;

  9. Срабатывает прерывание таймера;

  10. ОС переключает контекст на задачу 2 и сбрасывает таймер;

  11. Aaaand… here we go again…

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

Контекст задачи

Здесь все чуть сложнее, если вы не имеете опыта с ассемблером.

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

Глобальные переменные хранятся в ОЗУ. Это когда вы объявляете переменную вне функций, или в функциях с модификатором «static».

А локальные?

Ну, а их как бы и не существует вовсе. Они как бы есть. Но и в то же время — их нет.

Но должны же они где-то храниться?

А вот они, как раз, и хранятся в регистрах общего назначения, но хранятся там только в конкретные моменты.

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

Например, на Си, функция выглядит так:

uint8_t d;

void Function(void)
{
	uint8_t a;
	uint8_t b;
	uint8_t c;
	
	c = a + b;

	PORTB = c;

	d = c – a;
}

Объявление переменных a, b и c, на самом деле, вообще не содержат действий, и не скрывают за собой ассемблерных инструкций.

И вот только в строке «c = a + b» начинают работать регистры общего назначения.

А вот переменная «d» — уже хранится в ОЗУ, и поэтому существует всегда.

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

image-loader.svg

Ну…, есть еще прерывания. Обработчик прерывания — это такая же функция, которая может тоже вызвать функцию и так далее. И как они используют одни и те же регистры? Если, по сути, могут прервать любую функцию в любой момент, и данные в регистрах будут повреждены.

А все просто.

Во-первых, есть стандарт, описывающий, какие регистры для каких целей и как используются компилятором, например вот: здесь.

Выдержка из стандарта по поводу использования регистров AVRВыдержка из стандарта по поводу использования регистров AVR

Во-вторых, из «во-первых» получаем, что каждая функция, кроме прерываний, может свободно использовать регистры R18-R27, R30, R31, не думая о том, что эти регистры нужны еще кому-то, а вот регистры R2–17, R28, R29 должна в начале работы сохранить, а после работы — вернуть на место.

Вот для этого, компилятор втихаря вначале каждой функции и прерывания вставляет код вида:

push R15
push R16
push R17

И в конце функции, перед выходом:

pop R17
pop R16
pop R15

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

push/pop — это ассемблерные инструкции для работы со стеком.

Итак…

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

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

Сохраняется контекст в стек соответствующей задачи.

Уточненьице…

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

Переключение контекста

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

Получается, что код, а точнее псевдокод, для переключения контекста будет выглядеть так:

void ChangeContext(void)
{
	SaveRegsIntoStack();
	SelectNextTask();
	RestoreRegsFromStack();
	return;
}

SaveRegsIntoStack — это макрос, содержащий ассемблерный код для сохранения всех регистров в стек.

RestoreRegsFromStack — это макрос, содержащий ассемблерный код для выгрузки всех регистров из стека.

В итоге, если расшифровать эти макросы, все будет выглядеть так:

void ChangeContext(void)
{
	push R0
	push R1
	… R2 … R30 …
	push R31
	push SREG 
	
	currentTask = GetNextTask();
	
	pop SREG
	pop R31
	… R30 … R2 …
	pop R1
	pop R0
	
	return;
}

Стек задачи

Стек — это тип памяти, или способ хранения данных, который используется, в основном, для сохранения адресов возврата при вызове функций и возникновении прерываний.

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

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

Например, у AVR — всего 32 регистра общего назначения, это значит, что если у вас 4 задачи, то вам необходимо отдать на это (32×4) = 128 байт. Учитывая, что, например, в ATmega88 всего 1Кб ОЗУ — это таки потеря потерь.

Вывод: создавать отдельную задачу на каждый светодиод — явно неразумно : D

Получается, что стек — это просто буфер байт, для каждой задачи свой, и каждая задача должна знать границы этого буфера и указатель на текущий байт внутри.

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

Без использования вытесняющей ОС, ОЗУ микроконтроллера будет выглядеть так:

Address

Data

0

0×00

1

0×00

2

0×00

3

0×00

4

0×00

5

0×00

6

0×00

7

0×00

8

0×00

9

0×00

10

0×00

11

0×00

12

0×00

13

0×00

14

0×00

RAMEND →

15

0×00

<-Stack pointer

С использованием ОС, уже будет выглядеть так:

Address

Data

0

0×00

Common
variables

1

0×00

2

0×00

3

0×00

4

0×00

5

0×00

6

0×00

7

0×00

8

0×00

9

0×00

10

0×00

11

0×00

12

0×00

13

0×00

14

0×00

15

0×00

Task 3
stack

16

0×00

17

0×00

18

0×00

19

0×00

20

0×00

21

0×00

22

0×00

<-Stack pointer

23

0×00

Task 2
stack

24

0×00

25

0×00

26

0×00

27

0×00

28

0×00

29

0×00

30

0×00

<-Stack pointer

31

0×00

Task 1
stack

32

0×00

33

0×00

34

0×00

35

0×00

36

0×00

37

0×00

38

0×00

<-Stack pointer

39

0×00

40

0×00

41

0×00

42

0×00

43

0×00

44

0×00

RAMEND →

45

0×00

<-Hardware SP

Получается, стек задачи можно описать структурой вида:

typedef struct {
	uint8_t *buf;	// Указатель на буфер
	uint8_t *ptr;	// Указатель на текущий байт в буфере
} Stack_t

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

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

Тогда код для переключения контекста уже будет иметь вид:

void ChangeContext(void)
{
	/* Сохраняем контекст задачи */
	push R0
	push R1
	… R2 … R30 …
	push R31
	push SREG 
	
	/* Сохраняем текущее состояние аппаратного указателя на стек */
	currentTask->stack.ptr = HARDWARE_STACK_POINTER;

	/* Выбираем следующую задачу */
	currentTask = GetNextTask();
	
	/* Перемещаем аппаратный указатель стека на буфер стека следующей задачи */
	HARDWARE_STACK_POINTER = currentTask->stack.ptr;
	
	/* Восстанавливаем контекст задачи */
	pop SREG
	pop R31
	… R30 … R2 …
	pop R1
	pop R0

	return;
}

Это практически готовый код для переключения контекста.

Стек ядра и стек прерывания

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

Об этом написано, опять же, в статье, на которую я уже давал ссылку: тут.

image-loader.svg

Стек ядра

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

Этот стек будет содержать только адреса возврата, выделять место для регистров общего назначения нам не нужно, а значит, в нем должно быть место только для адресов возврата. Если ядро имеет цепочку вложенных вызовов из трех функций, то это 3 адреса, если мы говорим об AVR, то это 6 байт. Вообще фигня…

Как это выглядит в коде — представлено ниже. Уже знакомая функция изменения контекста:

void ChangeContext(void)
{
	/* Сохраняем контекст задачи */
	push R0
	push R1
	… R2 … R30 …
	push R31
	push SREG 
	
	/* Сохраняем текущее состояние аппаратного указателя на стек */
	currentTask->stack.ptr = HARDWARE_STACK_POINTER;

	/* Перемещаем аппаратный указатель стека на буфер стека ядра */
	HARDWARE_STACK_POINTER = kernelStack.ptr;

	/* Выбираем следующую задачу */
	currentTask = GetNextTask();

	/* Сохраняем аппаратный указатель стека */
	/* На самом деле это не обязательно, т.к. мы вернулись из функции, */
	/* и указатель вернулся в исходное состояние до вызова функции. */
	/* Но это для понимания и предсказуемости кода */
	kernelStack.ptr = HARDWARE_STACK_POINTER;
	
	/* Перемещаем аппаратный указатель стека на буфер стека следующей задачи */
	HARDWARE_STACK_POINTER = currentTask->stack.ptr;
	
	/* Восстанавливаем контекст задачи */
	pop SREG
	pop R31
	… R30 … R2 …
	pop R1
	pop R0

	return;
}

Стек прерываний

Стека прерывания у нас не будет. Почему? Я уже почти дописал статью, и вспомнил, что я его не сделал, да, там всего пара макросов, но я не хочу быстро их добавлять, не протестировав нормально, и прикрепить этот проект к статье. Это как-то неправильно. Пусть, лучше, его не будет. Но я его опишу. Это уже что-то ​

В дальнейшем, может быть, вернусь к этой статье и исправлю этот недостаток, когда и ОС допилю.

Для прерываний можно без зазрения совести использовать тот же буфер стека, что и для ядра, т.к. они оба используются в прерываниях, и кто первый встал — того и стек!

Если мы добавим стек прерываний, то пользователь ОС просто должен в каждое свое прерывание добавить строку для перемещения стека в начало обработчика прерывания и для восстановления в конце.

Тогда любое прерывание будет выглядеть как-то так:

ISR(SOME_IMPORTANT_INTERRUPT)
{
	/* Здесь могут быть скрытые PUSH, которые добавит компилятор */

	/* Сохраняем значение аппаратного указателя стека текущей задачи */
	currentTask->stack.ptr = HARDWARE_STACK_POINTER;
	
	/* Перемещаем аппаратный указатель стека на буфер стека ядра/прерывания */
	HARDWARE_STACK_POINTER = kernelStack.ptr;

	/* Что-то делаем, пользовательский код */
	/* …………………… */
	/* …………………… */
	
	/* Восстанавливаем аппаратный указатель стека на буфер стека текущей задачи */
	HARDWARE_STACK_POINTER = currentTask->stack.ptr;
	
	/* Здесь могут быть скрытые POP, которые добавит компилятор */

	return;
}

Понятно, что это должно быть скрыто в макросы, но пока что для наглядности и для понимания принципа, как это должно работать.

Планировщик

Это отдельная история, которая будет реализована примитивно, т.к. это отдельная тема, но суть планировщика не меняется — это просто Си функция, которая должна вернуть нам указатель на следующую задачу.

В нашей ОС не будет алгоритмов планирования, планировщик будет примитивным.

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

Я работал однокристальщиком в одной небольшой фирме, где меня заставляли поддерживать 1С, так что я знаю, что он чувствует, когда занимается не тем, для чего был рожден ​

Это я не таю злобу на 1С, я просто не был рожден чтобы работать XD

Планировщик должен выполнять две функции:

  1. Определить, какая задача будет выполняться следующей;

  2. Минимизировать простой процессора.

Т.к. мы не забываем, что делаем ОС на AVR, то лучшей оптимизацией для минимизации простоя процессора является — отказ от сложных алгоритмов планирования. Тем более, что объем ОЗУ так и говорит, что несколько задач — это прям максимум!

А тупой перебор массива из 2, 4 или даже 10 (десять задач, карл) элементов — это гораздо быстрее, чем какой-то, даже самый примитивный алгоритм планирования.

Я не буду заливать про односвязный список, чтобы не перегружать, т.к. это просто оптимизация планировщика. А оптимизация — это не про сейчас, это «future simple» ​

Код планировщика я тупо скопировал реальный, который у меня был на данным момент. На самом у меня было много вариантов, и с одно- и с двусвязным списком, но пока что решил вернуться на изначальный, т.к. для меня он проще на этапе отладки и более предсказуемый.

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

static Task_t *GetNextTask (void)
{
	static uint8_t currentTaskIndex = 0;
	bool taskAvailable;
	uint8_t i;
	
	if(Kernel_currentTask->status == TASK_STATUS_RUN)
	{
		Kernel_currentTask->status = TASK_STATUS_READY;
	}
	
	i = CONFIG_USER_TASKS_NUMBER;
	while(i--)
	{
		Kernel_currentTask = &Kernel_tasksList[currentTaskIndex];
		
		taskAvailable = false;
		if(Kernel_currentTask->status == TASK_STATUS_READY)
		{
			taskAvailable = true;
		}
		
		currentTaskIndex++;
		if(currentTaskIndex >= CONFIG_USER_TASKS_NUMBER)
		{
			currentTaskIndex = 0;
		}
		
		if(taskAvailable)
		{
			break;
		}
	}
	
	if(!taskAvailable)
	{
		Kernel_currentTask = &taskIdle;
	}
	
	Kernel_currentTask->status = TASK_STATUS_RUN;

return Kernel_currentTask;
}

Ядро

Ядро ОС — это часть ОС, которая и занимается переключением контекста.

Казалось бы, важнейшая часть ОС, че так мало написал?! А? А? А? А?

Ну, а я отвечу — ядро содержит фичи, которые мы уже рассмотрели чуть выше.

Вот, как бы и все…

Инициализация и запуск

Вот тут сейчас и будут основные прЕколы, которые не давали мне спать спокойно.

В ходе работы получается классный конечный автомат. Аппаратный стек работает сам по себе. Он же на то и «аппаратный». Задачи переключаются.

А вот, как оказалось, запустить этот «конечный автомат» — та еще дилемма.

На самом деле идей было полно. Все они разные. Но каждая из них имеет заметный такой недостаток. Я, до сих пор, и не знаю какой вариант действительно самый крутой. Сейчас выбрал последний, т.к. он наиболее оптимальный по объему кода и быстродействию.

Дилемма

Вот есть у нас, например, три задачи.

У каждой уже есть свой стек, каждая — как на подбор, хоть «косынку» запускай!

Но, ядро ОС вызывается в прерывании. А как работает вызов функций и прерываний? Правильно, при вызове функции в стек сохраняется адрес последней инструкции плюс единичка.

И вот произошло первое прерывание, мы попали в обработчик прерывания:

  • Из него мы вызываем обработчик ядра ОС «Kernel ()»;

  • Сохраняем регистры в стек;

  • Выбираем следующую задачу;

  • Указатель стека перемещаем на буфер стека задачи;

  • И возвращаемся из функции «Kernel ()».

А куда возвращаемся?

Адрес возврата в прерывание находится не в стеке этой задачи, т.к. аппаратный указатель стека (Hardware SP) и карта ОЗУ после первого прерывания будет выглядеть так:

Address

Data

0

0×00

Common
variables

1

0×00

2

0×00

3

0×00

4

0×00

5

0×00

6

0×00

7

0×00

8

0×00

9

0×00

10

0×00

11

0×00

12

0×00

13

0×00

14

0×00

15

0×00

Task 3
stack

16

0×00

17

0×00

18

0×00

19

0×00

20

0×00

21

0×00

22

0×00

<-Stack pointer

23

0×00

Task 2
stack

24

0×00

25

0×00

26

0×00

27

0×00

28

0×00

29

0×00

30

0×00

<-Stack pointer

31

0×00

Task 1
stack

32

0×00

33

0×00

34

0×00

35

0×00

36

0×00

37

0×00

38

0×00

<-Stack pointer

39

0×00

40

0×00

41

0×00

<-Hardware SP

42

0xCC

43

0xDD

44

0xAA

RAMEND →

45

0xBB

Видно, что адреса возврата попали в исходный стек, который не имеет отношения ни к одной из задач. Если мы теперь переключили указатель стека на буфер стека задачи при смене контекста, то после выхода из функции «Kernel ()» мы не вернемся в обработчик прерывания, и из него потом не вернемся в задачу, т.к, если сейчас выбрана «Задача 1», то указатель стека будет указывать на ячейку »38», а там явно нет адреса возврата, там либо мусор, либо нули.

Очевидно, что в стек каждой задачи необходимо вручную подставить:

  1. Адрес возврата в тело функции соответствующей задачи (это просто адрес функции);

  2. Адрес возврата в обработчик прерывания.

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

А вот второе — далее посмотрим… В этом и проблема!

Решение 1

Первое, что я придумал и реализовал было так.

В «ChangeContext ()» есть флаг «static bool firstEntry = true;», который указывает на то, что это первый вход в функцию «ChangeContext ()» после сброса процессора.

Если это первый вход — значит мы берем из ОЗУ, по указателю на стек, адрес возврата в прерывание, и можем его подставить в стек каждой задачи.

Этот флаг потом сбрасываем в «false».

И при повторных входах этот код не выполняется.

Получается, что все задачи получают в свой стек адрес возврата, если это первое прерывание ОС.

Все работает, но здесь мы имеем код, который нужен только один раз за время жизни всей программы, а выполняем мы его в самой требовательной по быстродействию секции, в самом ядре, и выполняем каждый раз. Конечно, проверка одного флага — это несколько тактов, но все же — это не круто!

Мы же крутые? Конечно крутые!

Идем дальше!

Решение 2

Адреса обработчиков всех прерываний хранятся в памяти программы, а именно в самом ее начале, начиная с нулевого адреса.

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

Открыв HEX файл, ну или проект в режиме отладки, находим, что в памяти программы есть такие буквы:

image-loader.svg

Красным выделены вектора прерываний. Далее я привожу кривой, но все же наглядный рисунок, совмещенный с даташитом.

image-loader.svg

И оно же, в увеличенном виде, красным выделил нужные нам данные, для нужного вектора прерывания. Мне нужно было прерывание «TIMER0 COMPA».

image-loader.svg

Видим данные »0×76C0». Т.к. это Big-endian, на самом деле там »0xC076».

Шо такое 0xC076?

А вот вспомнив, что вначале всех программ на ASM (когда писал на ASM, да, я крутой) я писал:

RJMP 0x0000	; Reset
RJMP 0x0001	; INT0
RJMP 0x0002	; INT1
...

Понимаем, шо это такое есть на самом деле! Точнее, я надеюсь, что я понимаю…

В даташите «AVR Assembler Instruction Set Manual» находим инструкцию RJMP, и видим ее формат:

image-loader.svg

Где 0b1100 => 0xC

И подтверждаем, что »0xC0» вначале — это инструкция RJMP, отбросив которую получим относительный адрес обработчика прерывания.

Далее получаем фактический адрес в памяти:

0x76 + 15 = 0x85

Где »15» — это порядковый номер нашего прерывания в таблице векторов прерываний.

В отладке у меня действительно, обработчик прерывания располагался по адресу 0×85.

То есть, в момент инициализации мы можем прочитать адрес оттуда, и во время инициализации каждой задачи сразу подставлять этот адрес в стек этой задачи.

Процессорное время в ядре мы не теряем, т.к. это выполняется только один раз на этапе инициализации.

Уже круто!

Но только это строки кода, которые содержат код для конкретной архитектуры, и конкретно под этот микроконтроллер. И берет адрес конкретного прерывания. А если нам нужен будет Timer2 вместо Timer0? Та вы шо? Это нам шо, городить дефайны, в которых потом можно ногу сломать? И так для каждого микроконтроллера и для каждого прерывания?

Так ну не, это, конечно круто! Но это перебор крутости!

Решение 3

Я держал это решение в голове с самого начала, но принимать его на вооружение ну капец как не хотел.

Оно избавляет нас от недостатков как первого, так и второго способов.

Но приносит свой недостаток. Та бл…

В прерывании мы не вызываем «ChangeContext ()» как функцию, а при помощи ассемблера «прыгаем» в эту функцию. Тогда мы оказываемся внутри функции, но при этом, в стек не сохраняется адрес возврата, а значит, вернувшись из функции «ChangeContext ()», из стека будет взят сразу адрес возврата в задачу.

Одни плюсы!

Сейчас этот вариант мне, конечно, нравится, т.к. он самый быстрый, и экономит 2 байта на лишний адрес возврата, да и не нужно городить дефайны в config-файле и лишний код.

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

Оператор «goto» тоже работает как часы, но от него отказались по этой же причине. И не зря, хотя в некоторых ситуациях он наоборот делает код более понятным.

Получается, со скрипом принимаем это решение и тогда код запуска ОС будет выглядеть как в листинге ниже. Это опять псевдокод. Код «OS_Init» и «OS_CreateTask» опустим, т.к. это банально инициализация внутренних переменных.

int main(void)
{
cli();
	Timer1A_Init();
	OS_Init();
	OS_CreateTask(Task1, stackBufTask1, sizeof(stackBufTask1));
	OS_CreateTask(Task2, stackBufTask2, sizeof(stackBufTask2));
sei();
	OS_Start();
}

void OS_Start (void)
{
	currentTask = &tasksList[0];
	StackPointer_SetAddress(currentTask->stackPointer);
	/* Ща буит ассемблер */
	RJMP ChangeContext
  /* Ассемблера не буит */
   
}

void ChangeContext(void)
{
	/* Код написан сильно выше */
}

Голые функции

image-loader.svg

Гугл не хочет сохранять мой запрос «naked functions», наверное, думает, что я ищу порно, поэтому вот еще один мем, который напрашивается сам по себе:

image-loader.svg

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

А т.к. компилятор, как упоминалось сильно выше, сам добавляет инструкции push/pop в каждую функцию и прерывание, чтобы сохранить регистры, которые считает нужными, то нам необходимо это как-то отключить, иначе ничего не поедет. Стек просто сломается, или в регистрах будут не те данные.

По умолчанию, любое прерывание и любую функцию компилятор преобразует вот в такое:

/* Прерывание таймера TIMER0 COMPA */
ISR(TIMER0_COMPA_vect, ISR_NAKED)
{
	push R16
	push R17
	
	/* User code */
	
	pop R17
	pop R16
}

/* Обработчик ядра нашей ОС */
void Kernel(void)
{
	push R16
	push R17
	
	/* User code */
	
	pop R17
	pop R16
}

Даже сохранение пары регистров все ломают. Даже одного. Даже на пол битика!

Для решения этой проблемы у функций могут быть атрибуты.

Одним из таких атрибутов является «naked», который говорит компилятору, что он не должен в эту функцию добавлять prolog (инструкции «push») и epilog (инструкции «pop» и возврата «ret» или «reti», если речь о прерывании).

Поэтому прерывание таймера, в котором мы вызываем ядро ОС, и само ядро ОС должны быть «naked» функциями.

Если кто не знает, все эти атрибуты описаны в документации на соответствующий компилятор.

В этом примере я использую стандартный компилятор WinAVR, если не ошибаюсь, который идет вместе с Atmel Studio (ну, или как там сейчас модно говорить — Microchip Studio).

В коде прототипы обработчика прерывания и функции «ChangeContext ()» будут иметь вид:

/* Прерывание таймера TIMER0 COMPA */
ISR(TIMER0_COMPA_vect, ISR_NAKED)
{
	
}

/* Обработчик ядра нашей ОС */
void ChangeContext(void) __attribute__ ((naked));

Сервисы ОС

Операционная система должна обеспечивать синхронизацию потоков. Для примера будут реализованы только сервисы «Sleep» и семафоры.

Важным замечанием будет, что мы до сих пор не упоминали задачу «Idle».

Сейчас самое время, т.к. она нужна как раз для этих сервисов.

Задача «Idle»

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

Вот здесь нам и нужен Idle-task. Это такая же задача, как и пользовательские, просто она выполняется всегда, когда другие задачи чего-то ждут.

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

Тело этой задачи должно содержать проверку на наличие актуальных задач, и если они есть — инициировать смену контекста.

static void IdleTask(void)
{
	uint8_t i;
	for(;;)
	{
		for(i = 0; i < CONFIG_USER_TASKS_NUMBER; i++)
		{
			Kernel_DisableInterrupts();
			if(Kernel_tasksList[i].status == TASK_STATUS_READY)
			{
				Kernel_Isr();
			}
			Kernel_EnableInterrupts();
		}
	}
}

Sleep

Этот сервис должен позволить задаче передать управление операционной системе, и вернуть управление не ранее чем через указанное кол-во времени.

Тут почти как в ардуинах этих ваших, только во время «delay ()» мы занимаемся чем-то полезным.

Логика сервиса проста как цикл «while»:

  1. Изменяем стату

    © Habrahabr.ru