[Перевод] Эволюция переключения контекста x86 в Linux

df8ca2556ca28afde78fe531a3eb892b.png

В прошлые выходные, изучая интересные факты об аппаратном переключателе контекста 80386, я вдруг вспомнил, что первые версии ядра Linux полагались именно на него. И я погрузился в код, который не видел уже много лет. Сейчас я решил описать это чудесное путешествие по истории Linux. Я покажу все самородки и забавные артефакты, которые нашёл по пути.

Задача: проследить, как изменялось переключение контекста в ядре Linux от первой (0.01) до последней версии LTS (4.14.67), с особым акцентом на первую и последнюю версии.


На самом деле история не о переключении контекста, а об эволюции Linux от небольшого проекта к современной операционной системе. Переключатель контекста просто отражает эту историю.

О каком переключении контекста идёт речь?


Хотя много что можно рассматривать как переключение контекста (например, переход в режим ядра, переход к обработчику прерываний), я имею в виду общепринятое значение: переключение между процессами. В Linux это макрос switch_to()и всё, что в нём находится.

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

Краткий список задач переключателя контекста:

  1. Переуказание рабочего пространства: восстановление стека (SS: SP).
  2. Поиск следующей инструкции: восстановление IP (CS: IP).
  3. Восстановление состояния задачи: восстановление регистров общего назначения.
  4. Своппинг адресных пространств памяти: обновление каталога страниц (CR3)
  5. … и многое другое: FPU, структуры данных ОС, регистры отладки, аппаратные обходные пути и т. д.


Не всегда очевидно, когда и где выполняются эти задачи, если другой процесс захватывает CPU. Например, аппаратное переключение контекста до Linux 2.2 скрывает задачи 2, 3 и 4. Задача 3 ограничена, так как переключение происходит между режимами ядра. Восстановление пользовательского потока является задачей iret после возвращения планировщика. Многие из этих задач в разных версиях ядра плавают между switch_to() и планировщиком. Можно гарантировать только то, что в каждой версии мы всегда увидим своп стека и переключение FPU.

Для кого это предназначено?


Ни для кого конкретно. Для понимания вам нужно только знать ассемблер x86 и, наверное, иметь минимальное образование в части проектирования ОС.

Сразу скажу, я не мейнтейнер и не контрибутор в ядро Linux. Любую информацию от этих товарищей или из списка рассылки разработчиков ядра, которая противоречит моей информации, следует воспринимать серьёзно. У меня случайный личный проект, а не научная статья в рецензируемом журнале.


Раннее ядро Linux простое и функциональное, с небольшим списком ключевых функций:

  • Единственная архитектура (80386/i386): только один тип переключателя контекста. Многие особенности 80386 жёстко закодированы по всему ядру. Для справки по этим частям я взял «Руководство программиста Intel 80386» (1986).
  • Аппаратное переключение контекста: для смены задач ядро использует встроенные механизмы 80386.
  • Один процесс с упреждающей многозадачностью: одновременно активен только один CPU с одним процессом. Однако в любой момент может начаться другой процесс. Таким образом, применяются обычные правила синхронизации: блокировка общих ресурсов (без спин-блокировок). В крайнем случае возможно отключение прерываний, но сначала рассмотреть блокировку мьютекса.


d580f81b36718f5531ea3790fd00c8ab.png

Без дальнейших церемоний, взглянем на два ранних переключателя контекста. Код отформатирован для лучшей читаемости: по одному элементу в строке без символов продолжения (\).

Linux 0.01

/** include/linux/sched.h */
#define switch_to(n) {
struct {long a,b;} __tmp;
__asm__("cmpl %%ecx,_current\n\t"
  "je 1f\n\t"
  "xchgl %%ecx,_current\n\t"
  "movw %%dx,%1\n\t"
  "ljmp %0\n\t"
  "cmpl %%ecx,%2\n\t"
  "jne 1f\n\t"
  "clts\n"
  "1:"
  ::"m" (*&__tmp.a),
  "m" (*&__tmp.b),
  "m" (last_task_used_math),
  "d" _TSS(n),
  "c" ((long) task[n]));
}


Linux 0.11

/** include/linux/sched.h */
#define switch_to(n) {
struct {long a,b;} __tmp;
__asm__("cmpl %%ecx,_current\n\t"
  "je 1f\n\t"
  "movw %%dx,%1\n\t"
  "xchgl %%ecx,_current\n\t"
  "ljmp %0\n\t"
  "cmpl %%ecx,_last_task_used_math\n\t"
  "jne 1f\n\t"
  "clts\n"
  "1:"
  ::"m" (*&__tmp.a),
  "m" (*&__tmp.b),
  "d" (_TSS(n)),
  "c" ((long) task[n]));
}


Сразу бросается в глаза, какой он маленький! Достаточно маленький, чтобы разобрать каждую строчку в отдельности:

#define switch_to(n) {


Итак, switch_to() — это макрос. Он появляется ровно в одном месте: в самой последней строке schedule(). Следовательно, после предварительной обработки макрос совместно использует область планировщика. Идёт проверка в глобальной области неизвестных ссылок, таких как current и last_task_used_math. Входной аргумент n — порядковый номер следующей задачи (от 0 до 63).

__asm__("cmpl %%ecx,_current\n\t"


Резервирует 8 байт (64 бита) в стеке, доступном через два 4-байтовых элемента a и b. Мы установим некоторые из этих байтов позже для операции дальнего перехода.

"je 1f\n\t"


Переключатель контекста — один длинный встроенный блок на ассемблере. Первая инструкция определяет, является ли целевая задача уже текущей. Это вычитающее сравнение значения в регистре ECX со значением текущего current из планировщика. Оба содержат указатели на task_struct какого-то процесса. Ниже в ECX стоит указатель целевой задачи в качестве заданного входа: "c" ((long) task[n]). Результат сравнения устанавливает значение регистра состояния EFLAGS: например, ZF = 1, если оба указателя совпадают (x — x = 0).

"xchgl %%ecx,_current\n\t"


Если следующей задачей является текущая, переключать контекст не нужно, поэтому следует пропустить (перепрыгнуть) всю эту процедуру. Инструкция je проверяет, что ZF = 1. Если это так, то переходит к первой метке '1' после этой точки в коде, которая находится на 8 строк впереди.

"movw %%dx,%1\n\t"


Обновляет глобальный current для отражения новой задачи. Указатель из ECX (task[n]) переключается на current. Флаги не обновляются.

"ljmp %0\n\t"


Перемещает индекс селектора сегментов дескриптора целевой задачи (TSS) в ранее зарезервированное пространство. Технически это перемещает значение из регистра DX в __tmp.b, то есть байты с 5 по 8 нашей зарезервированной 8-байтовой структуры. Значение DX является заданным входом: "d" (_TSS(n)). Многоуровневый макрос _TSS взрывается в селекторе сегментов валидного TSS, который я рассмотрю чуть ниже. Суть в том, что наиболее значимые два байта __tmp.b теперь содержат указатель сегмента на следующую задачу.

"ljmp %0\n\t"


Вызывает аппаратный переключатель контекста 80386, перейдя к дескриптору TSS. Этот простой переход может сбить с толку, потому что тут три разные идеи: во-первых, ljmp является косвенным дальним переходом, которому нужен 6-байтовый (48-битный) операнд. Во-вторых, операнд %0 ссылается на неинициализированную переменную __tmp.А. Наконец, переход к селектору сегментов в GDT имеет особое значение в x86. Давайте разберём эти моменты.

Косвенный дальний переход


Важный момент, что у этого перехода 6-байтовый операнд. Руководство программиста 80386 описывает переход так:

ae5fb367632800feb6f59b53ee8d96c0.png

Переход к __tmp.a


Напомним, что структура __tmp содержала два 4-байтных значения, а в основе структуры элемент a. Но если мы используем этот элемент в качестве базового адреса 6-байтового операнда, то достигнем двух байтов внутри целого числа __tmp.b. Эти два байта являются частью «селектора сегмента» дальнего адреса. Когда процессор видит, что сегмент представляет собой TSS в GDT, то часть смещения полностью игнорируется. Тот факт, что __tmp.a неинициализирован, не имеет значения, потому что у __tmp.b всё равно валидное значение благодаря предыдущей инструкции movw. Добавим адрес перехода на диаграмму:

c02e5a974ab3b2c6f907564b1316a0cf.png

Откуда мы знаем, что этот адрес ссылается на GDT? Я раскрою детали в других строчках кода, но краткая версия заключается в том, что четыре нулевых бита в селекторе запускают поиск GDT. Макрос _TSS(n) гарантирует присутствие этих четырёх нулей. Нижние два бита — это уровень привилегий сегмента (00 соответствует supervisor/kernel), следующий нулевой бит означает использование таблицы GDT (хранящейся в GDTR во время загрузки системы). Четвёртый ноль технически является частью индекса сегмента, который форсирует все поиски TSS на чётных записях таблицы GDT.

Аппаратный переключатель контекста


Адрес перехода в __tmp определяет дескриптор TSS в GDT. Вот как это описано в руководстве для 80386:

b1c014050e704604c7064c14e33af920.png

Процессор автоматически делает для нас следующее:

  • Проверяет, разрешён ли текущий уровень привилегий (мы находимся в режиме ядра, поэтому всё нормально).
  • Проверяет, что TSS действителен (должен быть).
  • Сохраняет всё текущее состояние задачи в старом TSS, всё ещё хранящемся в регистре задач (TR), так что не нужно задействовать EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI, ES, CS, SS, DS, FS, GS и EFLAGS. EIP увеличивается до следующей инструкции и тоже сохраняется.
  • Обновляет TR для новой задачи.
  • Восстанавливает все регистры общего назначения, EIP и PDBR (своп адресного пространства). Переключатель задач завершил работу, поэтому устанавливается флаг TS в регистре CR0.


Вот так единственная инструкция "ljmp %0\n\t" взяла и выполнила все этапы переключения контекста. Осталось только немного прибраться.

"cmpl %%ecx,%2\n\t"


Проверяем, что предыдущее задание восстановило математический сопроцессор. Аргументом является указатель last_task_used_math. Флаг TS помогает проверить, остался ли у сопроцессора остался другой контекст. Аппаратные переключатели контекста не управляют сопроцессором.

"jne 1f\n\t"


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

"clts\n"


Снимаем флаг TS, если последний процесс восстановил состояние сопроцессора.

"1:"


Метка конца переключателя контекста. Все переходы к этой метке пропускают некоторые или все процедуры.

::"m" (*&__tmp.a),


В этом блоке ассемблера нет выходных данных, а первые входные данные (%0) — расположение в памяти первых четырёх байтов дальнего указателя на дескриптор TSS в GDT. Используется только в качестве ссылки на адрес, значение неинициализировано.

"m" (*&__tmp.b),


Второй вход (%1) — это расположение в памяти байтов 5 и 6 дальнего указателя на дескриптор TSS. Технически это место занимает четыре байта в памяти, но проверяются и используются только первые два.

"m" (last_task_used_math),


Третий вход (%2) — расположение в памяти указателя на последний task_struct, который восстановил состояние сопроцессора.

"d" (_TSS(n)),


Четвёртый вход (%3 / %%edx) — адрес селектора сегментов дескриптора TSS в GDT. Давайте разберём макрос:

#define _TSS(n) ((((unsigned long) n)<<4)+(FIRST_TSS_ENTRY<<3))
#define FIRST_TSS_ENTRY 4


Это означает, что первый дескриптор TSS является 4-й записью (отсчёт индекса начинается с 4-го бита селектора сегмента). Каждая последующая TSS занимает каждую вторую запись GDT: 4, 6, 8 и т. д. Первые восемь задач выглядят так:

Task # 16-bit Segment Selector
0 0000000000100  0  00
1 0000000000110  0  00
2 0000000001000  0  00
3 0000000001010  0  00
4 0000000001100  0  00
5 0000000001110  0  00
6 0000000010000  0  00
7 0000000010010  0  00


Биты адреса разделены форматом поля, как это положено в 80386:

600837d6d6710747e269775a515658b6.png

Наименее значимые четыре бита всегда нулевые, что соответствует режиму супервизора, таблице GDT и форсирует чётные записи индекса GDT.

"c" ((long) task[n]));


Последний вход (%4 / %ecx) — указатель на новый task_struct, на который мы переключаемся. Обратите внимание, что значение %%ecx изменяется на предыдущую задачу непосредственно перед переключением контекста.

Различия между 0.01 и 0.11


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

  • _last_task_used_math удалён в качестве входной переменной, поскольку символ уже доступен в глобальной области. Соответствующая операция сравнения изменена на прямую ссылку.
  • Инструкция xchgl поменялась местами с movw, чтобы приблизить её к аппаратному переключателю контекста (ljmp). Проблема в том, что эти операции не являются атомарными: маловероятно, что между xchgl и ljmp может произойти прерывание, которое приведёт к ещё одному переключению контекста с неправильной задачей current и несохранённым состоянием реальной задачи. Замена местами этих инструкций делает такую ситуацию очень маловероятной. Однако в долго работающей системе «очень маловероятно» — синоним для «неизбежно».


Примерно за год между 0.11 и 1.0 вышло около 20 патчей. Основная часть усилий была сосредоточена на драйверах, функциях для пользователей и разработчиков. Максимальное количество задач увеличилось до 128, но в переключении контекста произошло не так много фундаментальных изменений.

Linux 1.0


Linux 1.0 по-прежнему работает на одном CPU с одним процессом, используя аппаратное переключение контекста.

963c40196485c16d16d5d53c8d15c910.png

Linux 1.0

/** include/linux/sched.h */
#define switch_to(tsk)
__asm__("cmpl %%ecx,_current\n\t"
        "je 1f\n\t"
        "cli\n\t"
        "xchgl %%ecx,_current\n\t"
        "ljmp %0\n\t"
        "sti\n\t"
        "cmpl %%ecx,_last_task_used_math\n\t"
        "jne 1f\n\t"
        "clts\n"
        "1:"
        : /* no output */
        :"m" (*(((char *)&tsk->tss.tr)-4)),
         "c" (tsk)
        :"cx")


Наиболее существенным изменением стало то, что входной аргумент больше не является индексом номера задачи для массива структур task_struct. Теперь switch_to() принимает указатель на новую задачу. Так что можно удалить структуру __tmp, а вместо неё использовать прямую ссылку на TSS. Разберём каждую строчку.

#define switch_to(tsk)


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

"__asm__("cmpl %%ecx,_current\n\t"


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

"je 1f\n\t"


Не изменилось. Пропустить переключение контекста, если переключатель отсутствует.

"cli\n\t"


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

"xchgl %%ecx,_current\n\t"
"ljmp %0\n\t"


Без изменений: своп текущего процесса для отражения новой задачи и вызова аппаратного переключателя контекста.

"sti\n\t"


Включает прерывания обратно.

"cmpl %%ecx,_last_task_used_math\n\t"
"jne 1f\n\t"
"clts\n"
"1:"


Всё без изменений по сравнению с Linux 0.11. Управляет регистром TS и отслеживает очистку математического сопроцессора от предыдущей задачи.

: /* no output */


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

:"m" (*(((char *)&tsk->tss.tr)-4)),


Загружает селектор сегментов для дескриптора TSS новой задачи, которая теперь непосредственно доступна из указателя task_struct. Элемент tss.tr содержит _TSS (task_number) для ссылки на память GDT/TSS, которая использовалась в ядре до 1.0. Мы все ещё отступаем на 4 байта и загружаем 6-байтовый селектор сегментов, чтобы взять два верхних байта. Весело!

"c" (tsk)


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

:"cx")


Переключение контекста блокирует регистр ECX.

Linux 1.3


Ядро теперь поддерживает несколько новых архитектур: Alpha, MIPS и SPARC. Следовательно, существует четыре разных версии switch_to(), одна из которых включается при компиляции ядра. Код, зависящий от архитектуры, был отделён от ядра, поэтому нужно искать версию x86 в другом месте.

Linux 1.3

/** include/asm-i386/system.h */
#define switch_to(tsk) do {
__asm__("cli\n\t"
        "xchgl %%ecx,_current\n\t"
        "ljmp %0\n\t"
        "sti\n\t"
        "cmpl %%ecx,_last_task_used_math\n\t"
        "jne 1f\n\t"
        "clts\n"
        "1:"
        : /* no output */
        :"m" (*(((char *)&tsk->tss.tr)-4)),
         "c" (tsk)
        :"cx");
        /* Now maybe reload the debug registers */
        if(current->debugreg[7]){
                loaddebug(0);
                loaddebug(1);
                loaddebug(2);
                loaddebug(3);
                loaddebug(6);
        }
} while (0)


Несколько маленьких изменений: весь контекстный переключатель обёрнут фейковым циклом do-while. Фейковым, потому что он никогда не повторяется. Проверка переключения на новую задачу переместилась из switch_to() в код шедулера на С. Некоторые задачи отладки перемещены из кода C в switch_to (), вероятно, чтобы избежать их разделения. Давайте посмотрим на изменения.

#define switch_to(tsk) do {


Теперь switch_to() обёрнут в цикл do-while (0). Эта конструкция предотвращает ошибки, если макрос расширяется до нескольких операторов как следствие выполнения условия (если оно есть). В настоящее время его нет, но учитывая изменения в планировщике, я подозреваю, что это результат правки кода, оставленный на всякий случай. Моё предположение:

Реальный планировщик в 1.3

...within schedule()...

    if (current == next)
       return;
    kstat.context_swtch++;
    switch_to(next);


Возможный вариант, который «ломает» switch_to ()

...within schedule()...

    if (current != next)
        switch_to(next);
                
 /* do-while(0) 'captures' entire 
  * block to ensure proper parse */
__asm__("cli\n\t"
"xchgl %%ecx,_current\n\t"
"ljmp %0\n\t"
"sti\n\t"
"cmpl %%ecx,_last_task_used_math\n\t"
"jne 1f\n\t"
"clts\n"
"1:"
: /* no output */
:"m" (*(((char *)&tsk->tss.tr)-4)),
"c" (tsk)
:"cx");


Без изменений по сравнению с Linux 1.0. По-прежнему прерывания отключаются перед свопом *task_struct из current, затем работает аппаратное переключение контекста и проверяется использование сопроцессора.

/* Now maybe reload the debug registers */
if(current->debugreg[7]){


Проверяет контроль отладки для нового процесса на предмет активного ptrace (ненулевой адрес тут подразумевает активный ptrace). Трекинг отладки переместился в switch_to(). Точно та же последовательность С используется в 1.0. Предполагаю, разработчики хотели убедиться, что: 1) отладка находится как можно ближе к переключателю контекста 2) switch_to самый последний в schedule().

loaddebug(0);
loaddebug(1);
loaddebug(2);
loaddebug(3);


Восстанавливает регистры точки останова отладки из сохранённого состояния ptrace.

loaddebug(6);


Восстанавливает регистр управления отладкой из сохранённого состояния ptrace.

} while (0)


Закрывает блок switch_to(). Хотя условие всегда неизменно, это гарантирует, что парсер примет функцию за базовый блок, который не взаимодействует с соседними условиями в schedule(). Обратите внимание на отсутствие запятой в конце — она стоит после вызова макроса: switch_to(next);.
В июне 1996 года ядро обновилось до версии 2.0, начав 15-летнюю одиссею под этой основной версией, которая заканчилась широкой коммерческой поддержкой. В 2.x почти все фундаментальные системы в ядре претерпели радикальные изменения. Рассмотрим все минорные релизы до выхода 2.6. Версия 2.6 разрабатывалась так долго, что заслуживает отдельного раздела.

Linux 2.0


Linux 2.0 начал с кардинального нововведения: многопроцессорная обработка! Два или больше процессоров могут одновременно обрабатывать код пользователя/ядра. Естественно, это потребовало некоторой доработки. Например, у каждого процессора теперь есть выделенный контроллер прерываний, APIC, так что прерываниями нужно управлять на каждом процессоре отдельно. Нужно переработать такие механизмы, как прерывание таймера (отключение прерываний влияет только на один процессор). Синхронизация сложна, особенно при попытке применить её к уже большой и несвязанной кодовой базе. Linux 2.0 закладывает основу для того, что станет большой блокировкой ядра (BKL)… с чего-то надо начинать.

Теперь у нас две версии switch_to(): однопроцессорная версия (UP) из Linux 1.x и новая улучшенная версия для симметричной многопроцессорной обработки (SMP). Сначала рассмотрим правки в старом коде, потому что некоторые изменения оттуда также включены в версию SMP.

Linux 2.0.1: однопроцессорная версия (UP)


e0cac1b5c0c3b269808a36f947528f9f.png

Linux 2.0.1 (UP)

/** include/asm-i386/system.h */
#else  /* Single process only (not SMP) */
#define switch_to(prev,next) do {
__asm__("movl %2,"SYMBOL_NAME_STR(current_set)"\n\t"
        "ljmp %0\n\t"
        "cmpl %1,"SYMBOL_NAME_STR(last_task_used_math)"\n\t"
        "jne 1f\n\t"
        "clts\n"
        "1:"
        : /* no outputs */
        :"m" (*(((char *)&next->tss.tr)-4)),
         "r" (prev), "r" (next));
        /* Now maybe reload the debug registers */
        if(prev->debugreg[7]){
                loaddebug(prev,0);
                loaddebug(prev,1);
                loaddebug(prev,2);
                loaddebug(prev,3);
                loaddebug(prev,6);
        }
} while (0)
#endif


Cразу очевидны два изменения:

  • У switch_to() появился новый аргумент: процесс *task_struct, с которого мы переключаемся.
  • Макрос для правильной обработки символов во встроенном ассемблере.


Как обычно, пойдём по строкам и обсудим изменения.

#define switch_to(prev,next) do {


Аргумент prev определяет задачу, с которой мы переключаемся (*task_struct). Мы всё ещё оборачиваем макрос в цикл do-while (0), чтобы помочь парсить однострочные if вокруг макроса.

__asm__("movl %2,"SYMBOL_NAME_STR(current_set)"\n\t"


Обновляет текущую активную задачу на новую выбранную. Это функционально эквивалентно xchgl %%ecx,_current за исключением того, что теперь у нас массив из нескольких task_struct и макрос (SYMBOL_NAME_STR) для обработки символов встроенного ассемблера. Зачем для этого использовать препроцессор? Дело в том, что некоторые ассемблеры (GAS) требуют добавления символа подчёркивания (_) к имени переменной C. У других ассемблеров нет такого требования. Чтобы жёстко не вбивать конвенцию, можно настроить её во время компиляции в соответствии с вашим набором инструментов.

"ljmp %0\n\t"
"cmpl %1,"SYMBOL_NAME_STR(last_task_used_math)"\n\t"
"jne 1f\n\t"
"clts\n"
"1:"
: /* no outputs */
:"m" (*(((char *)&next->tss.tr)-4)),


Никаких изменений, о которых мы ещё не говорили.

"r" (prev), "r" (next));


Сейчас мы несём обе задачи в качестве входных данных для встроенного ассемблера. Одно из незначительных изменений — теперь разрешено любое использование регистра. Раньше next был закодирован в ECX.

/* Now maybe reload the debug registers */
if(prev->debugreg[7]){
    loaddebug(prev,0);
    loaddebug(prev,1);
    loaddebug(prev,2);
    loaddebug(prev,3);
    loaddebug(prev,6);
    }
} while (0)


Всё в точности как в ядре 1.3.

Linux 2.0.1: многопроцессорная версия (SMP)


d4032b4ce288a06a189746c69757fd03.png

Linux 2.0.1 (SMP)

/** include/asm-i386/system.h */
#ifdef __SMP__   /* Multiprocessing enabled */
#define switch_to(prev,next) do {
    cli();
    if(prev->flags&PF_USEDFPU)
    {
        __asm__ __volatile__("fnsave %0":"=m" (prev->tss.i387.hard));
        __asm__ __volatile__("fwait");
        prev->flags&=~PF_USEDFPU;
    }
    prev->lock_depth=syscall_count;
    kernel_counter+=next->lock_depth-prev->lock_depth;
    syscall_count=next->lock_depth;
__asm__("pushl %%edx\n\t"
    "movl "SYMBOL_NAME_STR(apic_reg)",%%edx\n\t"
    "movl 0x20(%%edx), %%edx\n\t"
    "shrl $22,%%edx\n\t"
    "and  $0x3C,%%edx\n\t"
    "movl %%ecx,"SYMBOL_NAME_STR(current_set)"(,%%edx)\n\t"
    "popl %%edx\n\t"
    "ljmp %0\n\t"
    "sti\n\t"
    : /* no output */
    :"m" (*(((char *)&next->tss.tr)-4)),
     "c" (next));
    /* Now maybe reload the debug registers */
    if(prev->debugreg[7]){
        loaddebug(prev,0);
        loaddebug(prev,1);
        loaddebug(prev,2);
        loaddebug(prev,3);
        loaddebug(prev,6);
    }
} while (0)


Что, уже становится непонятно? Хотел бы я сказать, что потом станет лучше, но в мире SMP этого не произойдёт. Чтобы сэкономить место, я больше не буду перечислять неизменённые строки.

Три дополнения для переключателя контекста SMP: 1) Изменение способа работы одного сопроцессора с несколькими процессорами; 2) Управление глубиной блокировки, так как блокировка ядра рекурсивна; 3) Ссылка на APIC, чтобы получить CPU ID для текущего *task_struct.

if(prev->flags&PF_USEDFPU)


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

__asm__ __volatile__("fnsave %0":"=m" (prev->tss.i387.hard));


Сохраняет состояние FPU в TSS. FNSAVE используется для пропуска обработки исключений. __volatile__ должен защитить эту инструкцию от изменения оптимизатором.

__asm__ __volatile__("fwait");


Ожидание CPU, пока FPU занят предыдущим сохранением.

prev->flags&=~PF_USEDFPU;


Отключает флаг использования сопроцессора для этой задачи, там всегда ноль.

prev->lock_depth=syscall_count;


Хранит количество вложенных использований блокировки ядра для старой задачи.

kernel_counter+=next->lock_depth-prev->lock_depth;


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

syscall_count=next->lock_depth;


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

__asm__("pushl %%edx\n\t"


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

"movl "SYMBOL_NAME_STR(apic_reg)",%%edx\n\t"


Перемещает адрес ввода-вывода APIC в EDX. Нам нужно использовать APIC, чтобы получить CPU ID, так как мы не знаем, какой процессор работает. apic_reg транслируется во время инициализации ОС.

"movl 0x20(%%edx), %%edx\n\t"


Разыменовывает значение регистра идентификатора APIC в EDX. Фактический ID находится в битах 24–27.

b78a09fe454c84ff075d4a9d5759bbd7.png

"shrl $22,%%edx\n\t"


Сдвигает APIC ID в биты 2–5.

"and $0x3C,%%edx\n\t"


Маскирует только APIC ID в битах 2–5, оставляя номер CPU * 4.

"movl %%ecx,"SYMBOL_NAME_STR(current_set)"(,%%edx)\n\t"


Обновляет указатель задачи текущего CPU на следующую задачу. В версии UP уже удалили специфическое использование ECX для хранения текущей задачи, но в версии SMP оно ещё используется. EDX содержит номер CPU в битах 2–5, умноженный на 4, в масштабе для смещения размера указателя от _current_set.

"popl %%edx\n\t"


Мы закончили с EDX, поэтому восстановим значение, которое было до этой процедуры.

Остальные строки такие же.

Linux 2.2 (1999)


Linux 2.2 действительно стоило ждать: здесь пояивлось программное переключение контекста! Мы всё ещё используем регистр задач (TR) для ссылки на TSS. Процедуры SMP и UP объединены с унифицированной обработкой состояния FPU. Большая часть переключения контекста теперь выполняется в коде C.

cfdc33992fee3168758bf99d6abe0698.png

Linux 2.2.0 (встроенный ассемблер)

/** include/asm-i386/system.h */
#define switch_to(prev,next) do {
    unsigned long eax, edx, ecx;
    asm volatile("pushl %%ebx\n\t"
                 "pushl %%esi\n\t"
                 "pushl %%edi\n\t"
                 "pushl %%ebp\n\t"
                 "movl %%esp,%0\n\t" /* save ESP */
                 "movl %5,%%esp\n\t" /* restore ESP */
                 "movl $1f,%1\n\t"   /* save EIP */
                 "pushl %6\n\t"      /* restore EIP */
                 "jmp __switch_to\n"
                 "1:\t"
                 "popl %%ebp\n\t"
                 "popl %%edi\n\t"
                 "popl %%esi\n\t"
                 "popl %%ebx"
                 :"=m" (prev->tss.esp),"=m" (prev->tss.eip),
                  "=a" (eax), "=d" (edx), "=c" (ecx)
                 :"m" (next->tss.esp),"m" (next->tss.eip),
                  "a" (prev), "d" (next));
} while (0)


Этот новый switch_to() радикально отличается от всех предыдущих версий: он простой! Во встроенном ассемблере меняем местами указатели стека и инструкций (задачи переключения контекста 1 и 2). Всё остальное выполняется после перехода к коду C (__switch_to()).

asm volatile("pushl %%ebx\n\t"
"pushl %%esi\n\t"
"pushl %%edi\n\t"
"pushl %%ebp\n\t"


Хранит EBX, ESI, EDI и EBP в стеке процесса, который мы собираемся поменять местами. (… почему EBX?)

"movl %%esp,%0\n\t" /* save ESP */
"movl %5,%%esp\n\t" /* restore ESP */


Как понятно из комментариев, мы меняем местами указатели стека между старым и новым процессом. У старого процесса операнд %0 (prev->tss.esp), а у нового %5 (next->tss.esp).

"movl $1f,%1\n\t" /* save EIP */


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

"pushl %6\n\t" /* restore EIP */


Подготовка следующей инструкции для новой задачи. Поскольку мы только что перешли на новый стек, этот IP берётся из TSS новой задачи и ставится наверх стека. Выполнение начнётся со следующей инструкции после 'ret' из кода C, который мы собираемся выполнить.

"jmp __switch_to\n"


Переходим к нашему новому и улучшенному программному переключателю контекста (см. ниже).

"popl %%ebp\n\t"
"popl %%edi\n\t"
"popl %%esi\n\t"
"popl %%ebx"


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

Linux 2.2.0 ©

/** arch/i386/kernel/process.c */
void __switch_to(struct task_struct *prev, struct task_struct *next)
{
    /* Do the FPU save and set TS if it wasn't set before.. */
    unlazy_fpu(prev);

    gdt_table[next->tss.tr >> 3].b &= 0xfffffdff;
    asm volatile("ltr %0": :"g" (*(unsigned short *)&next->tss.tr));

    asm volatile("movl %%fs,%0":"=m" (*(int *)&prev->tss.fs));
    asm volatile("movl %%gs,%0":"=m" (*(int *)&prev->tss.gs));

    /* Re-load LDT if necessary */
    if (next->mm->segments != prev->mm->segments)
        asm volatile("lldt %0": :"g" (*(unsigned short *)&next->tss.ldt));

    /* Re-load page tables */
    {
        unsigned long new_cr3 = next->tss.cr3;
        if (new_cr3 != prev->tss.cr3)
            asm volatile("movl %0,%%cr3": :"r" (new_cr3));
    }

    /* Restore %fs and %gs. */
    loadsegment(fs,next->tss.fs);
    loadsegment(gs,next->tss.gs);

    if (next->tss.debugreg[7]){
        loaddebug(next,0);
        loaddebug(next,1);
        loaddebug(next,2);
        loaddebug(next,3);
        loaddebug(next,6);
        loaddebug(next,7);
    }
}


В программном переключателе контекста старый переход к дескриптору TSS заменили на переход к новой функции C: __switch_to(). Эта функция написана на C и включает несколько знакомых компонентов, таких как регистры отладки. Переход к коду C позволяет переместить их ещё ближе к переключателю контекста.

unlazy_fpu(prev);


Проверяем использование FPU и сохраняем его состояние, если он использовался. Теперь это происходит для каждого процесса, который использовал FPU, так что очистка перестала быть ленивой. Процедура такая же, как рутина SMP из 2.0.1, за исключением того, что теперь у нас чистый макрос, который включает в себя ручную настройку TS.

gdt_table[next->tss.tr >> 3].b &= 0xfffffdff;


Очищает бит BUSY для будущего дескриптора задачи. Использует номер задачи для индексации GDT. tss.tr содержит значение селектора сегментов задачи, где для разрешений используются нижние три бита. Нам нужен только индекс, поэтому сдвигаем эти биты. Второй байт TSS изменён для удаления бита 10.

e7693fc83518201122914c5854f59de1.png

asm volatile("ltr %0": :"g" (*(unsigned short *)&next->tss.tr));


Загружается регистр задач с указателем на следующий селектор сегментов задач.

asm volatile("movl %%fs,%0":"=m" (*(int *)&prev->tss.fs));
asm volatile("movl %%gs,%0":"=m" (*(int *)&prev->tss.gs));


Регистры сегментов FS и GS для предыдущей задачи сохраняются в TSS. В аппаратном переключателе контекста этот шаг выполнялся автоматически, но теперь нам нужно сделать это вручную. Но почему? Как Linux использует FS и GS?

В Linux 2.2 (1999) нет понятного ответа. Сказано только, что они используются, поэтому следует их сохранить, чтобы они оставались доступными. Код режима ядра будет «заимствовать» эти сегменты, чтобы указать на сегменты ядра или пользовательских данных. Звуковые и сетевые драйверы делают то же самое. В последнее время (~2.6 и далее) FS и GS часто поддерживают локальное хранилище потока и области данных на каждый процессор, соответственно.

if (next->mm->segments != prev->mm->segments)
    asm volatile("lldt %0": :"g" (*(unsigned short *)&next->tss.ldt));


Восстанавливает сегменты локальной таблицы дескрипторов, если они ещё не соответствуют старому процессу. Это делается путём загрузки регистра LDT.

if (new_cr3 != prev->tss.cr3)
    asm volatile("movl %0,%%cr3": :"r" (new_cr3));


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

loadsegment(fs,next->tss.fs);
loadsegment(gs,next->tss.gs);


FS и GS восстанавливаются для новой задачи. Здесь обеспечивается правильное выравнивание, а в случае проблемы загружается нулевой сегмент.

loaddebug(prev,7);


Наконец, регистр управления отладкой теперь хранится и переключается с помощью TSS. Раньше этот регистр только проверялся, а не использовался для хранения.

Linux 2.4 (2001)


В версии 2.4 появилось много новых функций, таких как потоки ядра и очереди задач. Несмотря на это и несколько изменений в планировщике, переключатель контекста практически не изменился по сравнению с версией 2.2, хотя в нём прекратилось обновление TR в пользу замены всех данных регистра. Я неофициально называю его «последним легаси-ядром», так как во всех следующих релизах используется 64-разрядная архитектура x86.

9fe47c1cbc25d24606a3622694edcafb.png

Linux 2.4.0 (встроенный ассемблер)

/** include/asm-i386/system.h */

#define switch_to(prev,next,last) do {
    asm volatile("pushl %%esi\n\t"
        "pushl %%edi\n\t"
        "pushl %%ebp\n\t"
        "movl %%esp,%0\n\t"     /* save ESP */
        "movl %3,%%esp\n\t"     /* restore ESP */
        "movl $1f,%1\n\t"               /* save EIP */
        "pushl %4\n\t"          /* restore EIP */
        "jmp __switch_to\n"
        "1:\t"
        "popl %%ebp\n\t"
        "popl %%edi\n\t"
        "popl %%esi\n\t"
        :"=m" (prev->thread.esp),"=m" (prev->thread.eip),
         "=b" (last)
        :"m" (next->thread.esp),"m" (next->thread.eip),
         "a" (prev), "d" (next),
         "b" (prev));
} while (0)


Переключатель контекста в ядре 2.4 вносит лишь несколько незначительных изменений: EBX больше не пушится, а включён в выдачу встроенного ассемблера. Появился новый входной аргумент last, который содержит то же значение, что и prev. Он передаётся через EBX, но не используется.

© Habrahabr.ru