RussianLDP Рейтинг@Mail.ru
WebMoney: 
WMZ Z294115950220 
WMR R409981405661 
WME E134003968233 
Visa 
4274 3200 2453 6495 

Назад Вперед Оглавление


2. Управление процессами и прерываниями

2.1 Структура Task и таблица процессов

Каждый процесс под Linux динамически распределяет структуру struct task_struct. Максимальное число процессов, которые могут быть созданы под Linux, ограничено только количеством физической представленной памяти и записано в kernel/fork.c:fork_init():

/*
 * The default maximum number of threads is set to a safe
 * value: the thread structures can take up at most
 * half of memory.
 */
max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 2;

Это на архитектуре IA32 обычно означает num_physpages/4. Как пример, на машине с 512M памяти Вы можете создавать 32k потоков. Это значительное усовершенствование по сравнению с лимитом 4k-epsilon для старых (2.2 или раньше) ядер. Кроме того, это может быть изменено во время выполнения, используя KERN_MAX_THREADS sysctl(2) или просто procfs-интерфейс к ядру:

# cat /proc/sys/kernel/threads-max
32764
# echo 100000 > /proc/sys/kernel/threads-max
# cat /proc/sys/kernel/threads-max
100000
# gdb -q vmlinux /proc/kcore
Core was generated by `BOOT_IMAGE=240ac18 ro root=306
video=matrox:vesa:0x118'.
#0  0x0 in ?? ()
(gdb) p max_threads
$1 = 100000

Набор процессов в Linux-системе представляется как совокупность структур struct task_struct, которые связаны двумя способами:

  1. Как хэш-таблица, хэшированная по pid, или
  2. Как круговой, дважды связанный, список, использующий указатели p->next_task и p->prev_task.

Хэш-таблица называется pidhash[] и определена в файле include/linux/sched.h:

/* PID hashing. (shouldnt this be dynamic?) */
#define PIDHASH_SZ (4096 >> 2)
extern struct task_struct *pidhash[PIDHASH_SZ];

#define pid_hashfn(x)   ((((x) >> 8) ^ (x)) & (PIDHASH_SZ - 1))

Задачи хэшируются их значением pid, и вышеупомянутая хеш-функция, как предполагается, распределяет элементы однородно в их области (от 0 до PID_MAX-1). Хэш-таблица используется, чтобы быстро найти задачу по данному pid, используя функцию find_task_pid() из файла include/linux/sched.h:

static inline struct task_struct *find_task_by_pid(int pid)
{
  struct task_struct *p, **htable = &pidhash[pid_hashfn(pid)];

  for(p = *htable; p && p->pid != pid; p = p->pidhash_next)
    ;
  return p;
}

Задачи в каждом хэш-списке (то есть, хэшированные к тому же самому значению) связаны с помощью p->pidhash_next/pidhash_pprev, которые используются функциями hash_pid() и unhash_pid(), чтобы вставить и удалить данный процесс в хэш-таблицу. Они выполнены при защите чтения-записи вызовом tasklist_lock для WRITE.

Круговой, дважды связанный, список, который использует p->next_task/prev_task поддерживается так, чтобы легко можно было пройти все задачи в системе. Это достигнуто макрокоманды for_each_task() из include/linux/sched.h:

#define for_each_task(p) \
  for (p = &init_task ; (p = p->next_task) != &init_task ; )

Пользователи for_each_task() должны брать tasklist_lock для READ. Обратите внимание, что for_each_task() использует init_task, чтобы отметить начало и конец списка, а это не безопасно потому, что неактивная задача (pid) 0 никогда не завершится.

Модификаторы процесса хэш-таблицы и/или таблицы связей процессов, особенно fork(), exit() и ptrace(), должны брать tasklist_lock для WRITE. Что является более интересным, так это то, что авторы должны также отключить прерывания на локальном CPU. Причина для этого нетривиальна: функция send_sigio() работает, задача вносится в список, и таким образом берет tasklist_lock для READ, но это вызвано из kill_fasync() именно в контексте прерывания.

Теперь, когда мы понимаем, как структуры task_struct связаны вместе, позвольте нам исследовать члены task_struct. Они объединяют членов UNIX struct proc и struct user.

Другие версии UNIX отделяют информацию о задаче, которая должна сохраниться резидентной всегда (называется это 'proc structure', которая включает состояние процесса, информацию планировщика и т.д.) от прочих данных, которые необходимы только когда процесс запущен (называется 'u area', которая включает таблицу описателей файлов, информацию о дисковых квотах и т.д.). Единственной причиной для такого уродливого проекта было то, что память была очень недостаточным ресурсом. Современные операционные системы не нуждаются в таком разделении и, следовательно, поддерживают состояние процесса всегда в ядерной резидентной структуре данных. Структура task_struct объявлена в include/linux/sched.h и в настоящее время имеет размер ровно 1680 байт.

Поле state объявлено как:

volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */

#define TASK_RUNNING            0
#define TASK_INTERRUPTIBLE      1
#define TASK_UNINTERRUPTIBLE    2
#define TASK_ZOMBIE             4
#define TASK_STOPPED            8
#define TASK_EXCLUSIVE          32

Почему TASK_EXCLUSIVE определено как 32, а не 16? Потому, что 16 было исчерпано TASK_SWAPPING, и я забыл сдвинуть TASK_EXCLUSIVE, когда я удалил все ссылки на TASK_SWAPPING.

Поле volatile в p->state означает, что это может изменяться асинхронно (из программы обработки прерывания):

  1. TASK_RUNNING: означает, что задача, как предполагается, стоит в очереди на выполнение. Причина, по которой задача может быть помечена как TASK_RUNNING, но не помещена в runqueue в том, что пометить задачу и вставить в очередь, строго говоря, вещи разные. Вы должны поставить блокировку runqueue_lock на чтение и запись, чтобы рассмотреть runqueue. Если Вы так сделаете, Вы затем увидите, что каждая задача в runqueue пребывает в состоянии TASK_RUNNING. Все правильно, так и должно быть. НО! Обратное не истина по причине, объясненной выше. Точно так же драйверы могут отмечать себя (или контекст процесса, в котором они выполняются) как TASK_INTERRUPTIBLE (или TASK_UNINTERRUPTIBLE), а уже затем вызывать schedule(), которая потом удалит их из runqueue (если не имеется ждущего обработки сигнала, тогда это будет оставлено в runqueue).
  2. TASK_INTERRUPTIBLE: означает, что сейчас задача бездействует, но может быть пробуждена сигналом или истечением таймера.
  3. TASK_UNINTERRUPTIBLE: аналогично TASK_INTERRUPTIBLE, за исключением этого задача не может быть пробуждена.
  4. TASK_ZOMBIE: задача завершила работу, но не имела собранного состояния (запущена функция wait()).
  5. TASK_STOPPED: задача была остановлена из-за сигналов управления работы или из-за ptrace(2).
  6. TASK_EXCLUSIVE: это не отдельное состояние, но может быть добавлено через OR к TASK_INTERRUPTIBLE или TASK_UNINTERRUPTIBLE. Это означает, что когда эта задача бездействует в очереди ожидания со многими другими задачами, она будет пробуждена одна вместо того, чтобы вызвать проблему "thundering herd", пробуждая всех ожидающих.

Флажки Task содержат информацию относительно статусов процесса, которые не взаимно исключительны:

unsigned long flags;    /* per process flags, defined below */
/*
 * Per process flags
 */
#define PF_ALIGNWARN    0x00000001      /* Print alignment warning msgs */
                                        /* Not implemented yet, only for 486*/
#define PF_STARTING     0x00000002      /* being created */
#define PF_EXITING      0x00000004      /* getting shut down */
#define PF_FORKNOEXEC   0x00000040      /* forked but didn't exec */
#define PF_SUPERPRIV    0x00000100      /* used super-user privileges */
#define PF_DUMPCORE     0x00000200      /* dumped core */
#define PF_SIGNALED     0x00000400      /* killed by a signal */
#define PF_MEMALLOC     0x00000800      /* Allocating memory */
#define PF_VFORK        0x00001000      /* Wake up parent in mm_release */
#define PF_USEDFPU      0x00100000      /* task used FPU this quantum (SMP) */

Поля p->has_cpu, p->processor, p->counter, p->priority, p->policy и p->rt_priority связаны с планировщиком и будут рассматриваться позже.

Поля p->mm и p->active_mm указывают соответственно на адресное пространство процесса, описанное структурой mm_struct, и активное адресное пространсто, если процесс не имеет реального пространства (например, ядерные потоки). Это помогает минимизировать потоки TLB при переключении адресных пространств, когда задача планируется. Так, если мы планируем поток в ядре (который не имеет никакой p->mm), то следующий next->active_mm не будет установлен в prev->active_mm задачи, которая была спланирована снаружи ядра. А вот она получит тот же prev->mm, если prev->mm != NULL. Адресное пространство может быть разделено между потоками, если флажок CLONE_VM передан системному вызову clone(2) или vfork(2).

Поля p->exec_domain и p->personality касаются индивидуальности задачи, то есть, пути, которым некоторые системные вызовы ведут себя, чтобы эмулировать другие версии UNIX.

Поле p->fs содержит информацию файловой системы, которая под Linux означает три части данных:

  1. root-каталог и точка монтирования,
  2. альтернативный root-каталог и точка монтирования,
  3. текущий рабочий каталог и точка монтирования.

Эта структура также включает счет ссылок потому, что это может быть разделено между имитируемыми задачами, когда флажок CLONE_FS передан системному вызову clone(2).

Поле p->files хранит таблицу описателей файлов. Это также может быть разделено между задачами, если CLONE_FILES определен в системном вызове clone(2).

Поле p->sig содержит драйверы сигнала и может быть разделено между имитируемыми задачами посредством вызова CLONE_SIGHAND.

2.2 Создание и завершение задач и ядерных потоков

Различные книги по операционным системам определяют "процесс" различными способами, начинающимися с того, что это выполняемая копия программы, и заканчивающимися тем, что эта копия получена системным вызовом clone(2) или fork(2). Под Linux имеется три вида процессов:

  • Неактивный,
  • ядерный и
  • задачи пользователя.

Неактивный процесс создан во время компиляции для первого CPU. Он затем "вручную" создан для каждого CPU посредством архитектурно-специфичного вызова fork_by_hand() в arch/i386/kernel/smpboot.c, который разворачивает вызов fork(2). Неактивные задачи совместно используют одну структуру init_task, но имеют частную структуру TSS в CPU-массиве init_tss. Неактивные задачи все имеют pid=0, и никакие другие задачи разделять pid не могут, то есть применен флажок CLONE_PID в clone(2).

Ядерные процессы созданы, используя функцию kernel_thread(), которая вызывает системный вызов clone(2) в режиме ядра. Ядерные процессы обычно не имеют никакого адресного пространства пользователя, то есть p->mm = NULL, поскольку они явно делают exit_mm(), например, через вызов функции daemonize(). Ядерные процессы могут всегда обращаться к ядерному адресному пространству непосредственно. Они распределяют номера pid в низком диапазоне. Управление в кольце процессора 0 (на x86) подразумевает, что ядерные процессы активно пользуются всеми привилегиями ввода-вывода и не могут быть перехвачены планировщиком.

Задачи пользователя созданы посредством системных вызовов clone(2) или fork(2), оба из которых внутренне вызывают kernel/fork.c:do_fork().

Что же случается, когда процесс пользователя делает вызов fork(2)? Хотя fork(2) зависим от архитектуры из-за различных путей передачи стека пользователя и регистров, фактическая основная функция do_fork() переносима и размещена в файле kernel/fork.c.

Следующие шаги будут выполнены:

  1. Локальная переменная retval установлена к -ENOMEM, поскольку это значение, в которое errno должен быть установлен, если fork(2) свалится, чтобы распределить новую структуру task.
  2. Если CLONE_PID установлен в clone_flags, возвращается ошибка (-EPERM), если вызов произвел неактивный процесс (только в течение начальной загрузки). Нормальные процессы пользователя не могут передавать CLONE_PID в clone(2) и ожидать, что этот фокус у них пройдет. Для fork(2) это не так, поскольку clone_flags установлен в SIFCHLD: это верно только тогда, когда do_fork() вызывается через sys_clone(), который передает clone_flags из значения, запрошенного из пользовательского пространства.
  3. current->vfork_sem инициализируется (это позже очищено в потомке). Это используется sys_vfork() (системный вызов vfork(2) соответствует записи clone_flags=CLONE_VFORK|CLONE_VM|SIGCHLD), чтобы заставить родителя бездействовать до тех пор, пока потомок не вызовет mm_release(), например, в результате выполнения другой программы через exec() или выхода посредством exit(2).
  4. Новая структура task распределена, используя архитектурно-зависимый макрос alloc_task_struct(). На x86 это только gfp с приоритетом GFP_KERNEL. Это первая причина того, почему fork(2) может бездействовать. Если что-то пошло не так при распределении, вернется системная ошибка -ENOMEM.
  5. Все значения из структуры task актуального процесса будут скопированы в новый, используя назначение структуры *p = *current. Возможно, это должно быть заменено memset? Позже поля, которые не должны быть унаследованы потомком, будут отдельно установлены к правильным значениям.
  6. Большая ядерная блокировка принимается, поскольку остальная часть кода иначе не была бы повторно используема.
  7. Если родитель имеет ресурсы пользователя (понятие UID в Linux достаточно гибкое, чтобы делать это вопросом, а не фактом), то проверяется, превысил ли пользователь мягкое ограничение RLIMIT_NPROC. Если да, то происходит сбой с возвратом ошибки -EAGAIN. А вот если нет, то счетчик процессов увеличивается с помощью uid p->user->count.
  8. Если общесистемное число задач превышает настраиваемое значение max_threads, происходит сбой с возвратом ошибки -EAGAIN.
  9. Если двоичный выполняемый объект принадлежит модульной области выполнения, происходит приращение счетчика ссылок соответствующего модуля.
  10. Если двоичный выполняемый объект принадлежит к двоичному формату, происходит приращение счетчика ссылок соответствующего модуля.
  11. Потомок отмечен как 'has not execed' (p->did_exec = 0).
  12. Потомок отмечен как 'not-swappable' (p->swappable = 0).
  13. Потомок помещен в состояние 'uninterruptible sleep', то есть p->state = TASK_UNINTERRUPTIBLE.
  14. Записи p->flags потомка установлены согласно значению clone_flags. Для простого fork(2) это будет p->flags=PF_FORKNOEXEC.
  15. Pid детеныша p->pid будет установлен, используя быстрый алгоритм в файле kernel/fork.c:get_pid().
  16. Остальная часть кода в do_fork() инициализирует остальную часть структуры task. В самом конце структура task потомка хэшируется в таблице pidhash, и потомок будет пробужден (TODO: wake_up_process(p) устанавливает p->state=TASK_RUNNING и добавляет процесс к runq, следовательно, p->state можно и не устанавливать в TASK_RUNNING раньше в do_fork()). Интересная часть устанавливает p->exit_signal в clone_flags & CSIGNAL, который для fork(2) означает только SIGCHLD и устанавливает p->pdeath_signal в 0. pdeath_signal используется, когда процесс забывает родителя оригинала. это значение можно получать или менять командами PR_GET/SET_PDEATHSIG вызова prctl(2).

Таким образом, задача создана. Имеются несколько путей для завершения:

  1. Системный вызов exit(2).
  2. При получении сигнала о завершении.
  3. Принудительное завершение в некоторых исключительных ситуациях.
  4. Вызов bdflush(2) с func==1 (это специфично для Linux, для совместимости со старыми дистрибутивами, которые все еще имели строку update в файле /etc/inittab в настоящее время работа модификации выполнена ядерным потоком kupdate).

Функции, выполняющие системные вызовы под Linux, имеют префикс sys_, но они обычно связаны только с проверкой параметра или архитектурно-специфическими способами передать некоторую информацию, а фактическая работа выполнена функциями с префиксом do_. Так что sys_exit() реально вызывает do_exit(), чтобы делать дело. Хотя другие части ядра иногда вызывают sys_exit() в то время, как они должны вызывать do_exit().

Функция do_exit() находится в kernel/exit.c. Обратите внимание относительно do_exit():

  • Использует глобальную ядерную блокировку (ее не снимает!).
  • Вызывает schedule(), который никогда не возвращается.
  • Устанавливает состояние задачи в TASK_ZOMBIE.
  • Передает любому потомку current->pdeath_signal, если не 0.
  • Передает любому предку current->exit_signal, который является обычно равным SIGCHLD.
  • Освобождает ресурсы, распределенные fork, закрывает открытые файлы и т.д.
  • На архитектурах, которые используют ленивое переключение FPU (ia64, mips, mips64) аппаратные средства требуют передать FPU монопольное использование (если принадлежит текущему) в none.

2.3 Планировщик Linux

Работа планировщика должна вынести решение о распределении доступе к текущему CPU между многими процессами. Планировщик выполнен в основном ядерном файле kernel/sched.c. Соответствующий файл заголовка: include/linux/sched.h включен (косвенно или явно) фактически любым исходным файлом ядра.

Поля структуры task, релевантные планировщику:

  • p->need_resched: это поле установлено, если schedule() вызовется при следующей возможности.
  • p->counter: число импульсов сигнала времени часов, оставшихся до окончания выделенного кванта времени, уменьшается по таймеру. Когда это поле становится меньше или равным нолю, оно будет сброшено в 0, а p->need_resched будет установлено. Это также иногда названо динамическим приоритетом процесса потому, что это может изменяться отдельно.
  • p->priority: статический приоритет процесса, изменяется только через известные системные вызовы, например, nice(2), POSIX.1b sched_setparam(2) или 4.4BSD/SVR4 setpriority(2).
  • p->rt_priority: приоритет в реальном масштабе времени.
  • p->policy: стратегия планирования определяет, к которому планирующему классу принадлежит задача. Задачи могут изменять их класс планирования, используя системный вызов sched_setscheduler(2). Имеющие силу значения: SCHED_OTHER (обычный UNIX-процесс), SCHED_FIFO (POSIX.1b FIFO-процесс в реальном масштабе времени) и SCHED_RR (POSIX-процесс round-robin в реальном масштабе времени). Можно также дописать OR SCHED_YIELD к любому из этих значений, чтобы отметить, что процесс решил выдавать CPU, например, вызывая sched_yield(2). FIFO-процесс в реальном масштабе времени выполняется до тех пор, пока: a) блокирует ввод-вывод, b) явно не отдает CPU или c) будет вытеснен другим процессом реального времени с более высоким приоритетом (значение в p->rt_priority. SCHED_RR работает так же, как и SCHED_FIFO, за исключением того, что, когда кончается квант времени, он помещается в конец runqueue.

Алгоритм планировщика прост, несмотря на большую очевидную сложность функции schedule(). Функция сложна потому, что она осуществляет три алгоритма планирования в одном, а также из-за тонких SMP-проблем.

Вродле бы не нужные goto в schedule() на самом деле нужны там для того, чтобы генерировать самый лучший оптимизированный (для i386) код. Также обратите внимание, что планировщик (подобно почти всему коду ядра) был полностью переделан для 2.4, следовательно, обсуждение ниже не относится к 2.2 или более ранним ядрам.

Итак, рассмотрим функцию подробно:

  1. Если current->active_mm==NULL, то что-то неправильно. Текущий процесс, даже ядерный поток (current->mm==NULL), всегда должен иметь имеющий силу параметр p->active_mm.
  2. Если имеется что-то, что нужно сделать в очереди задач tq_scheduler, это обрабатывается немедленно. Очередь задач обеспечивает ядерный механизм, чтобы планировать выполнение функций в более позднее время. Мы рассмотрим это в деталях в другом месте.
  3. Инициализируются локальные переменные prev и this_cpu для текущей задачи и текущему CPU.
  4. Проверяется, вызывался ли schedule() из программы обработки прерывания (из-за ошибки).
  5. Освобождается глобальная блокировка ядра.
  6. Если имеется некоторая работа, которую надлежит сделать через механизм softirq, она выполняется сейчас.
  7. Инициализируется локальный указатель struct schedule_data *sched_data, чтобы указать на CPU-специфичную область планирования данных, которая содержит значение TSC из last_schedule и указатель на последнюю планируемую структуру task.
  8. runqueue_lock принимается. Обратите внимание, что используется spin_lock_irq() поскольку в schedule() гарантируется, что прерывания допускаются. Следовательно, когда разблокируется runqueue_lock, можно только заново включить ее вместо того, чтобы сохранить и восстановить eflags (вариант spin_lock_irqsave/restore).
  9. task state machine: Если task в состоянии TASK_RUNNING, так все и останется. Если же задача находится в состоянии TASK_INTERRUPTIBLE, и есть сигнал, ждущий обработки, это перемещается в состояние TASK_RUNNING. Во всех других случаях, это удалено из runqueue.
  10. next (самый лучший кандидат, который нужно планировать) установлен к неактивной задаче этого центрального процессора. Однако, совершенство этого кандидата очень низко (-1000), в надежде, что имеется кто-то лучший, чем это.
  11. Если prev (текущая) задача пребывает в состоянии TASK_RUNNING, то текущее совершенство установлено в максимум, и это отмечено как лучший кандидат, который нужно планировать, чем неактивная задача.
  12. Теперь runqueue исследован и совершенство каждого процесса, который может планироваться на этом центральном процессоре, по сравнению с текущим значением, установлено. Процесс с самым высоким значением побеждает. Теперь понятие "может планироваться на этом процессоре" должен быть разъяснен: каждый процесс на runqueue готов планироваться. В SMP процесс может планироваться только в рамках того процессора, на котором выполняется. Совершенство вычислено функциональным вызовом goodness(), который обрабатывает процессы в реальном масштабе времени, делая их совершенство очень высоким (1000+p->rt_priority), превышение значения 1000 гарантирует, что процесс процесс SCHED_OTHER не сможет победить. Так что реально речь идет о сравнении p->rt_priority. Функция совершенства возвращает 0, если квант времени процесса (p->counter) исчерпан. Для процессов, выполняемых не в реальном масштабе времени, начальное значение совершенства установлено в p->counter: такой процесс получит управление с меньшей вероятностью. Архитектурно-специфичная константа PROC_CHANGE_PENALTY пытается реализовать метод близости процессора (то есть, давать преимущество для процесса на том же самом процессор. Это также дает небольшое преимущество для процессов с mm, указывающим на текущий active_mm или процессам без адресного пространства, то есть ядерным потокам.
  13. Если текущее значение совершенства равно 0, то весь список процессов (не только те, которые находятся в runqueue!) будет исследован, и их динамические приоритеты повторно вычислены, используя очень простой алгоритм:
    recalculate:
    {
      struct task_struct *p;
    
      spin_unlock_irq(&runqueue_lock);
      read_lock(&tasklist_lock);
      for_each_task(p) p->counter = (p->counter >> 1) + p->priority;
      read_unlock(&tasklist_lock);
      spin_lock_irq(&runqueue_lock);
    }
    
    Обратите внимание, что мы снимаем runqueue_lock прежде, чем повторно вычисляем. Причина: то, что мы проходим весь набор процессов, а это может занять длительное время, в течение которого schedule() мог бы обратиться к другому CPU и выбрать процесс с совершенством, достаточно хорошим для того CPU, пока мы на этом CPU были вынуждены повторно вычислить приоритеты. По общему признанию это несколько непоследовательно потому, что в то время как мы (на этом CPU) выбираем процесс с самым лучшим совершенством, schedule() на другом CPU может повторно вычислить динамические приоритеты.
  14. Здесь и далее next указывает на задачу, которую нужно планировать, так что мы инициализируем next->has_cpu в 1 и next->processor в this_cpu. Теперь можно разблокировать runqueue_lock.
  15. Если мы переключаемся обратно к той же самой задаче (next==prev), то можем просто повторно приобретать глобальную ядерную блокировку и вернуться, то есть пропускать весь аппаратный уровень (регистраторы, стек и т.д.) и VM-наполнение (чтобы включить каталог страниц, повторно вычислить active_mm и так далее).
  16. Макрос switch_to() специфичен для архитектуры. На i386 он связан с обработкой FPU, TSS и LDT, перезагрузкой сегментных и отладочных регистров.

2.4 Реализация связанного списка в Linux

Прежде, чем мы продолжим исследовать реализацию очередей ожидания, мы должны ознакомиться с Linux-стандартом реализации дважды связанного списка. Очереди (так же, как и все остальное в Linux) активно их используют. Реализация обычно называется "реализацией list.h", поскольку наиболее релевантный файл include/linux/list.h.

Фундаментальная структура данных здесь struct list_head:

struct list_head {
  struct list_head *next, *prev;
};

#define LIST_HEAD_INIT(name) { &(name), &(name) }
#define LIST_HEAD(name) \
  struct list_head name = LIST_HEAD_INIT(name)

#define INIT_LIST_HEAD(ptr) do { \
  (ptr)->next = (ptr); (ptr)->prev = (ptr); \
} while (0)

#define list_entry(ptr, type, member) \
  ((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member)))

#define list_for_each(pos, head) \
  for (pos = (head)->next; pos != (head); pos = pos->next)

Первые три макрокоманды для инициализации пустого списка указанием сразу указателей next и prevна себя. Это очевидно из синтаксических ограничений C, которые должны использоваться, например, LIST_HEAD_INIT() может использоваться для инициализации элемента структуры в объявлении, второй может использоваться для статической переменной, инициализирующей объявления, а третий внутри функции.

Макрос list_entry() дает доступ к индивидуальному элементу списка, например (взято из fs/file_table.c:fs_may_remount_ro()):

struct super_block {
   ...
   struct list_head s_files;
   ...
} *sb = &some_super_block;

struct file {
   ...
   struct list_head f_list;
   ...
} *file;

struct list_head *p;

for (p = sb->s_files.next; p != &sb->s_files; p = p->next)
{
  struct file *file = list_entry(p, struct file, f_list);
  do something to 'file'
}

Хороший пример использования макроса list_for_each() находится в планировщике, где в очереди runqueue разыскивается процесс с самым высоким совершенством:

static LIST_HEAD(runqueue_head);
struct list_head *tmp;
struct task_struct *p;

list_for_each(tmp, &runqueue_head)
{
  p = list_entry(tmp, struct task_struct, run_list);
  if (can_schedule(p))
  {
     int weight = goodness(p, this_cpu, prev->active_mm);
     if (weight > c) c = weight, next = p;
  }
}

Здесь p->run_list определен как struct list_head run_list внутри структуры task_struct и обрабатывает присоединение к списку. Удаление элемента из списка и добавление (к голове или хвосту списка) выполнено макросами list_del(), list_add() и list_add_tail(). Примеры ниже добавляют и удаляют задачу из runqueue:

static inline void del_from_runqueue(struct task_struct * p)
{
  nr_running--;
  list_del(&p->run_list);
  p->run_list.next = NULL;
}

static inline void add_to_runqueue(struct task_struct * p)
{
  list_add(&p->run_list, &runqueue_head);
  nr_running++;
}

static inline void move_last_runqueue(struct task_struct * p)
{
  list_del(&p->run_list);
  list_add_tail(&p->run_list, &runqueue_head);
}

static inline void move_first_runqueue(struct task_struct * p)
{
  list_del(&p->run_list);
  list_add(&p->run_list, &runqueue_head);
}

2.5 Очереди ожидания

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

Linux-реализация позволяет семантику пробуждения, используя флаг TASK_EXCLUSIVE. Вы можете использовать известную очередь, а затем просто вызвать одну из функций sleep_on, sleep_on_timeout, interruptible_sleep_on или interruptible_sleep_on_timeout или можете определить Вашу собственную очередь ожидания и использовать для добавления и удаления себя из очереди функции add и remove_wait_queue. Функции wake_up и wake_up_interruptible применяются, чтобы пробудиться, когда необходимо.

Пример первого использования очередей ожидания: взаимодействие между распределителем страниц (в mm/page_alloc.c:__alloc_pages()) и демоном ядра kswapdmm/vmscan.c:kswap()) посредством очереди kswapd_wait,, определенной в mm/vmscan.c. Демон kswapd бездействует в этой очереди и будет пробужден всякий раз, когда распределитель страниц должен освободить некоторые страницы.

Пример автономного использования очереди: взаимодействие между процессом пользователя, запрашивающим данные через системный вызов read(2), и ядром, работающим в контексте прерывания, чтобы обеспечить данные. Программа обработки прерывания могла бы напоминать (показана упрощенная версия drivers/char/rtc_interrupt()):

static DECLARE_WAIT_QUEUE_HEAD(rtc_wait);

void rtc_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
  spin_lock(&rtc_lock);
  rtc_irq_data = CMOS_READ(RTC_INTR_FLAGS);
  spin_unlock(&rtc_lock);
  wake_up_interruptible(&rtc_wait);
}

Программа обработки прерывания получает данные, читая их из некоторого специфического для устройства I/O-порта (макрос CMOS_READ() превращается в пару команд outb/inb), а затем пробуждается, кто бы ни бездействовал в очереди ожидания rtc_wait.

Теперь системный вызов read(2) может быть выполнен как:

ssize_t rtc_read(struct file file, char *buf, size_t count, loff_t *ppos)
{
  DECLARE_WAITQUEUE(wait, current);
  unsigned long data;
  ssize_t retval;

  add_wait_queue(&rtc_wait, &wait);
  current->state = TASK_INTERRUPTIBLE;
  do {
    spin_lock_irq(&rtc_lock);
    data = rtc_irq_data;
    rtc_irq_data = 0;
    spin_unlock_irq(&rtc_lock);
    if (data != 0) break;
    if (file->f_flags & O_NONBLOCK)
    {
       retval = -EAGAIN;
       goto out;
    }
    if (signal_pending(current))
    {
       retval = -ERESTARTSYS;
       goto out;
    }
    schedule();
  } while(1);
  retval = put_user(data, (unsigned long *)buf);
  if (!retval) retval = sizeof(unsigned long);
out:
  current->state = TASK_RUNNING;
  remove_wait_queue(&rtc_wait, &wait);
  return retval;
}

В функции rtc_read() происходит следующее:

  1. Мы объявляем элемент очереди, указывающий на текущий контекст процесса.
  2. Мы добавляем этот элемент в очередь rtc_wait.
  3. Мы отмечаем текущий контекст как TASK_INTERRUPTIBLE, что означает, что это не будет перепланировано после следующей спячки.
  4. Мы проверяем, нет ли каких доступных данных, если имеем их, то копируем данные в буфер пользователя, отмечаемся как TASK_RUNNING, удаляемся из очереди и возвращаем управление.
  5. Если не имеется никаких данных, проверяем, определил ли пользователь неблокированный I/O. Если определил, получаем ошибку EAGAIN (аналог EWOULDBLOCK).
  6. Мы также проверяем, является ли сигнал ждущим обработки, и если так, то сообщаем более высокие уровни, чтобы перезапустить системный вызов в случае необходимости. Под необходимостью здесь понимаются детали расположения сигнала, как это определено в системном вызове sigaction(2).
  7. Затем мы заснем, пока процесс не будет пробужден программой обработки прерывания. Если мы не отмечались как TASK_INTERRUPTIBLE, то планировщик мог бы разбудить нас до того, как данные станут доступными, таким образом вызывая ненужную обработку.

Использование очередей ожидания делает довольно простым выполнение системного вызова poll(2):

static unsigned int rtc_poll(struct file *file, poll_table *wait)
{
  unsigned long l;

  poll_wait(file, &rtc_wait, wait);
  spin_lock_irq(&rtc_lock);
  l = rtc_irq_data;
  spin_unlock_irq(&rtc_lock);
  if (l != 0) return POLLIN | POLLRDNORM;
  return 0;
}

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

2.6 Ядерные таймеры

Ядерные таймеры используются, чтобы вызвать выполнение специфической функции (называемой "обработчиком таймера") в определенное время в будущем. Основная структура данных struct timer_list определена в include/linux/timer.h:

struct timer_list
{
  struct list_head list;
  unsigned long expires;
  unsigned long data;
  void (*function)(unsigned long);
  volatile int running;
};

Поле list предназначено для компонования во внутренний список, защищенный timerlist_lock. Поле expires представляет собой значение jiffies, когда функциональный драйвер function должен вызываться с данными data, переданными ему как параметр. Поле running используется на SMP, чтобы проверить, что драйвер таймера сейчас работает на другом CPU.

Функции add_timer() и del_timer() соответственно добавляют и удаляют данный таймер к списку. Когда таймер истекает, он будет удален автоматически. Прежде, чем таймер используется, он обязан быть инициализирован посредством функции init_timer()! А перед тем, как добавить таймер в список, обязательно надо правильно установить поля function и expires.

2.7 Нижние половины

Иногда приемлемо расчленить работу, которую нужно выполнить внутри программы обработки прерывания, на непосредственную работу (например, подтверждение прерывания, модификацию состояний и т.д.) и работу, которая может быть отложена до лучших времен, когда прерывания допускаются (например, сделать некоторую постобработку данных, пробудить процессы, ждущие эти данные, и т.д). Нижние половины как раз и представляют собой самый старый механизм для отсроченного выполнения ядерных задач и были доступны с Linux 1.x. В Linux 2.0 был добавлен новый механизм, называемый "очереди задач", который будет подробно рассмотрен ниже.

Нижние половины преобразованы в последовательную форму global_bh_lock, то есть может быть только одна нижняя половина управления на любом CPU в один момент времени. Однако, при попытке выполнять драйвер, если global_bh_lock недоступен, нижняя половина будет отмечена (то есть, планироваться) для выполнения, так что обработка может продолжаться, в противоположность занятому циклу на global_bh_lock.

Всего может быть максимум 32 зарегистрированные нижние половины. Функции, требуемые, чтобы ими управлять, определены так (все экспортированы в модули):

  • void init_bh(int nr, void (*routine)(void)): устанавливает нижнюю половину драйвера, указанного параметром routine в слот nr. Слот должен быть перечислен в include/linux/interrupt.h в форме XXXX_BH, например, TIMER_BH или TQUEUE_BH. Обычно подпрограмма инициализации подсистемы (init_module() для модулей) устанавливает требуемую нижнюю половину, используя эту функцию.
  • void remove_bh(int nr): противоположность init_bh(), то есть демонтирует нижнюю половину, установленную в слоте nr. Не имеется никакой проверки ошибок, выполняемый там, так что, например, remove_bh(32) будет вызывать системный сбой panic/oops. Обычно подпрограмма очистки подсистемы (cleanup_module() для модулей) использует эту функцию, чтобы освободить слот, который может позже многократно использоваться некоторой другой подсистемой. Возможно, будет реализован список всех нижних половин, зарегистрированных в системе, в /proc/bottom_halves.
  • void mark_bh(int nr): помечает нижнюю половину в слоте nr для выполнения. Обычно программа обработки прерывания отметит нижнюю половину для выполнения в более безопасное время.

Нижние половины глобально блокированы в списке задач, так что вопрос, когда нижняя половина драйверов выполнена, является в действительности вопросом о том, когда выполнена задача. Ответ: в двух местах: a) на каждом вызове schedule() и b) на каждом пути возврата из interrupt/syscall в entry.S.

2.8 Очереди задач

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

  1. Только фиксированное максимальное количество (32).
  2. Каждая нижняя половина может быть связана только с одной функцией драйвера.
  3. Нижние половины использованы со spinlock, так что они не могут блокироваться.

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

  1. tq_timer: очередь задач таймеров, выполняется на каждом прерывании таймера и при освобождении tty-устройства (закрытие или освобождение открытого устройства терминала). Так как драйвер таймера выполняется в контексте прерывания, задачи в tq_timer также выполняются в контексте прерывания и, таким образом, не могут блокироваться.
  2. tq_scheduler: очередь задач планировщиков, использованная планировщиком (а также при закрытии tty-устройства, подобно tq_timer). Так как планировщик выполняется в контексте перенесенного процесса, задачи в tq_scheduler могут делать что-нибудь, что они находят нужным, то есть блокировать, использовать данные контекста процесса и так далее.
  3. tq_immediate: это действительно нижняя половина IMMEDIATE_BH, так что драйверы могут использовать queue_task(task, &tq_immediate), а затем mark_bh(IMMEDIATE_BH) в контексте прерывания.
  4. tq_disk: использована низким уровнем доступа к блочным устройствам и RAID, чтобы запустить фактические запросы. Эта очередь задач экспортируется в модули, но не должна использоваться, если не преследует специальные цели, для которых это и было разработано.

Если драйвер не использует собственные очереди задач, он не должен вызывать run_tasks_queues(), чтобы обработать очередь, за исключением обстоятельств, объясненных ниже.

Причина того, что очереди задач tq_timer/tq_scheduler использована не только в обычных местах, но и в другом месте (закрывающееся tty-устройство: всего лишь один пример) становится ясным, если не забывать, что драйвер может планировать задачи в очереди, и эти задачи имеют смысл только в то время, как специфический образец устройства все еще имеет силу (это обычно означает, что все нормально, пока прикладная программа не закрывает это устройство). Так что драйвер может быть должен вызвать run_task_queue(), чтобы сбросить задачи, находящиеся в очереди потому, что разрешение им выполниться в более позднее время может не иметь смысла, то есть релевантные структуры данных, возможно, освобождаются и многократно используются иным образцом. Это причина, по которой Вы видите run_task_queue() в tq_timer и tq_scheduler не в прерывании таймера и schedule().

2.9 Tasklets

Не реализовано. Планируется на будущее.

2.10 Программные прерывания

Не реализовано. Планируется на будущее.

2.11 Как системные вызовы выполнены на архитектуре i386?

Имеются два механизма под Linux для выполнения системных вызовов:

  • Вызовы lcall7/lcall27 и
  • Программное прерывание int 0x80.

Местные программы Linux используют int 0x80, а исполняемые модули из других UNIX-систем (Solaris, UnixWare 7...) задействуют механизм lcall7. Имя lcall7 исторически вводит в заблуждение потому, что это также покрывает lcall27 (например, в Solaris/x86), но функция драйвера названа lcall7_func.

Когда происходит начальная загрузка системы, функция arch/i386/kernel/traps.c:trap_init() устанавливает IDT так, чтобы вектор 0x80 (of type 15, dpl 3) указал на адрес записи system_call из arch/i386/kernel/entry.S.

Когда пользовательская прикладная программа делает системный вызов, параметры переданы через регистры, и прикладная программа выполняет инструкцию int 0x80. Это вызывает ловушку в ядерный режим и переход процессора к точке входа system_call в файле entry.S. Далее:

  1. Сохраняются регистры.
  2. Устанавливаются %ds и %es в KERNEL_DS, чтобы все данные (и сегмент дополнительного пространства) ссылки были сделаны в ядерном адресном пространстве.
  3. Если значение %eax больше, чем NR_syscalls (сейчас это 256), произойдет сбой с ошибкой ENOSYS.
  4. Если задача ptraced (tsk->ptrace & PF_TRACESYS), делается специальная обработка. Это должно поддерживать программы, подобные strace (аналог SVR4 truss(1)) или разные отладчики.
  5. Вызывается sys_call_table+4*(syscall_number from %eax). Эта таблица инициализирована в том же самом файле (arch/i386/kernel/entry.S), чтобы указать на индивидуальные драйверы системного вызова, которые под Linux обычно имеют префикс sys_, например, sys_open, sys_exit и им подобные. Эти C-драйверы системного вызова найдут свои параметры в стеке, где их сохранил SAVE_ALL.
  6. Вводится 'system call return path'. Это отдельная метка потому, что используется не только int 0x80, но также lcall7 и lcall27. Это связано с обработкой tasklets (включая нижние половины), проверяя, если нужен schedule() (tsk->need_resched != 0), имеется ли задержка сигналов, и если она есть, обрабатывает их.

Linux поддерживает до 6 параметров для системных вызовов. Они переданы в %ebx, %ecx, %edx, %esi, %edi (%ebp используется временно, см. функцию _syscall6() в файле asm-i386/unistd.h). Номер системного вызова передан через %eax.

2.12 Атомные операции

Имеются два типа атомных операций: точечные рисунки и atomic_t. Точечные рисунки очень удобны для поддержания понятия "распределенных" или "свободных" модулей из некоторой большой совокупности, где каждый модуль идентифицирован некоторым номером, например, свободные inodes или блоки. Они также широко используются для простой блокировки, например, чтобы обеспечивать исключительный доступ к открытому устройству. Пример этого может быть найден в файле arch/i386/kernel/microcode.c:

/*
 *  Bits in microcode_status (31 bits of room for future expansion).
 */
#define MICROCODE_IS_OPEN   0  /* set if device is in use */

static unsigned long microcode_status;

Не имеется никакой потребности инициализировать microcode_status 0, поскольку BSS под Linux явно выставляется в 0.

/*
 * We enforce only one user at a time here with open/close.
 */
static int microcode_open(struct inode *inode, struct file *file)
{
  if (!capable(CAP_SYS_RAWIO)) return -EPERM;
  /* one at a time, please */
  if (test_and_set_bit(MICROCODE_IS_OPEN, &microcode_status))
     return -EBUSY;
  MOD_INC_USE_COUNT;
  return 0;
}

Операции на точечных рисунках:

  • void set_bit(int nr, volatile void *addr): установить бит nr в точечном рисунке, указанном в addr.
  • void clear_bit(int nr, volatile void *addr): очистить бит nr в точечном рисунке, указанном в addr.
  • void change_bit(int nr, volatile void *addr): переключить бит nr (если установлен, очистить, если очищен, установить) в точечном рисунке, указанном в addr.
  • int test_and_set_bit(int nr, volatile void *addr): атомно установить бит nr и вернуть старое значение.
  • int test_and_clear_bit(int nr, volatile void *addr): атомно очистить бит nr и вернуть старое значение.
  • int test_and_change_bit(int nr, volatile void *addr): атомно переключить бит nr и вернуть старое значение.

Эти операции используют макрокоманду LOCK_PREFIX, которая на SMP-ядрах вычисляет базовый префикс инструкции блокировки, и ничего не делает на UP-ядрах. Это гарантирует атомность доступа в SMP-среде.

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

  • atomic_read(&v): читает значение atomic_t переменной v.
  • atomic_set(&v, i): устанавливает значение atomic_t переменной v в integer i.
  • void atomic_add(int i, volatile atomic_t *v): добавляет integer i к значению атомной переменной, указанной на v.
  • void atomic_sub(int i, volatile atomic_t *v): вычитает integer i из значения атомной переменной, указанной на v.
  • int atomic_sub_and_test(int i, volatile atomic_t *v): вычитает integer i из значения атомной переменной, указанной на v. Если новое значение равно 0, вернет 1, в противном случае 0.
  • void atomic_inc(volatile atomic_t *v): увеличивает значение на 1.
  • void atomic_dec(volatile atomic_t *v): уменьшает значение на 1.
  • int atomic_dec_and_test(volatile atomic_t *v): уменьшает значение. Если новое значение равно 0, вернет 1, в противном случае 0.
  • int atomic_inc_and_test(volatile atomic_t *v): увеличивает значение. Если новое значение равно 0, вернет 1, в противном случае 0.
  • int atomic_add_negative(int i, volatile atomic_t *v): добавляет значение i к v и возвращает 1, если результат отрицателен. Возврат 0, если результат больше или равен 0. Эта операция используется для выполнения семафоров.

2.13 Spinlocks, Read-write Spinlocks и Big-Reader Spinlocks

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

SMP-поддержка была добавлена в Linux 1.3.42 15 Nov 1995 (первоначальный патч был сделан для версии 1.3.37 в October 1995).

Если критическая область кода может быть выполнена любым контекстом процесса и контекстом прерывания, то способ защищать это, используя команды cli/sti на UP-системах такой:

unsigned long flags;

save_flags(flags);
cli();
/* critical code */
restore_flags(flags);

В то время как это нормально работает на однопроцессорных машинах, на SMP это очевидно бесполезно потому, что та же самая последовательность кодов может быть выполнена одновременно на другом процессоре, и в то время как cli() обеспечивает защиту против чехарды с контекстом прерывания на каждом CPU индивидуально, это не обеспечивает никакую защиту вообще против той же самой проблемы с контекстами, работающими на различных CPU. Здесь и используется механизм spinlock.

Имеются три типа spinlocks: vanilla (базисный), read-write и big-reader spinlock. Read-write spinlock должен использоваться, когда имеется естественная тенденция "много читателей и немного авторов". Пример этого: доступ к списку зарегистрированных файловых систем (подробности можно найти в файле fs/super.c). Список охраняется file_systems_lock с применением read-write spinlock потому, что исключительный доступ нужен только при регистрации и отмене регистрации файловой системы, но любой процесс может читать файл /proc/filesystems или использовать системный вызов sysfs(2), чтобы принудительно выполнить просмотр в режиме "только для чтения" списка file_systems. Применяя read-write spinlocks, можно иметь много читателей одновременно, но только с одним автором, и не может иметься никаких читателей в то время, как имеется автор. Было бы хорошо, если новые читатели не будут получать блокировку в то время, как имеется автор, пробующий получить блокировку, то есть, если Linux могла бы правильно иметь дело с проблемой потенциального перехвата автора многими читателями. Это означало бы, что читатели должны быть блокированы в то время, как имеется автор, пытающийся получить блокировку. С другой стороны, читатели-то обычно берут блокировку в течение очень короткого времени, так что не факт, что их надо блокировать в то время, как автор берет блокировку для потенциально более длительных действий. Пока что читатели имеют приоритет.

Big-reader spinlock является формой read-write spinlock, оптимизированной для очень большого доступа для чтения со штрафом за запись. Имеется ограниченное число big-reader spinlock: в настоящее время существует только два, один используется только на sparc64 (глобальная переменная irq), а другой применяется для работы с сетями. Во всех других случаях, где образец доступа не вписывается в любой из этих двух сценариев, нужно использовать базисный spinlock. Вы не можете что-либо блокировать в зоне ожидания любого вида spinlock.

Spinlock входят в три группы plain, _irq() и _bh().

  1. Plain spin_lock()/spin_unlock(): если Вы знаете, что прерывания всегда отключаются или если Вы не переключаете контекст прерывания (например, изнутри программы обработки прерывания), то Вы можете использовать это. Это не касается состояния прерывания текущего CPU.
  2. spin_lock_irq()/spin_unlock_irq(): если Вы знаете, что прерывания всегда допускаются, то Вы можете использовать эту версию, которая просто отключает (на блокировке) и заново разрешает (на разблокировке) прерывания на текущем CPU. Например, функция rtc_read() использует spin_lock_irq(&rtc_lock) (прерывания всегда допускаются внутри read()) пока rtc_interrupt() использует spin_lock(&rtc_lock) (прерывания всегда отключаются внутри программы обработки прерывания). Обратите внимание, что rtc_read() применяет spin_lock_irq(), а не более универсальный spin_lock_irqsave() потому, что на входе к любым системным вызовам прерывания всегда допускаются.
  3. spin_lock_irqsave()/spin_unlock_irqrestore(): самая сильная форма. Предназначена, чтобы использовать, когда состояние прерывания неизвестно, но только для прерываний вообще. Не имеет никакого смысла, если текущий обработчик прерываний не выполняет критический код.

Причина, по которой Вы не можете использовать spin_lock(), если участвуете в конкуренции с программами обработки прерывания: если Вы примените эту функцию, а потом произойдет прерывание на том же самом CPU, он будет ждать блокировку вечно. Держатель блокировки, будучи прерванным, не будет продолжать работу до завершения программы обработки прерывания.

Наиболее общее использование spinlock: обращения к разделенным между контекстами пользовательских процессов структурам данных и программам обработки прерываний:

spinlock_t my_lock = SPIN_LOCK_UNLOCKED;

my_ioctl()
{
  spin_lock_irq(&my_lock);
  /* critical section */
  spin_unlock_irq(&my_lock);
}

my_irq_handler()
{
  spin_lock(&lock);
  /* critical section */
  spin_unlock(&lock);
}

Имеется пара моментов, на которые стоит обратить внимание относительно этого примера:

  1. Контекст процесса, представляемый здесь как типичный метод драйвера ioctl() (параметры и значения возврата опущены для ясности), должен использовать spin_lock_irq() потому, что это знает, что прерывания всегда допускаются при выполнении устройством метода ioctl().
  2. Контекст прерывания, представляемый здесь my_irq_handler(), (параметры и значения возврата опущены для ясности), может использовать простую форму spin_lock() потому, что прерывания заблокированы внутри программы обработки прерываний.

2.14 Семафоры и семафоры для чтения-записи

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

Семафоры для чтения-записи отличаются от базисных семафоров так же, как read-write spinlock отличается от обычного spinlock: можно иметь много читателей одновременно, но только с одним автором, и не может иметься никаких читателей в то время, как имеются авторы. То есть, автор блокирует всех читателей, и новые читатели будут блокированы в то время, как автор ждет.

Также, базисные семафоры могут быть прерываемыми: они используют только операции down/up_interruptible() вместо простых down()/up() и проверяют значение, возвращенное из down_interruptible(). Это не ноль, если операция была прервана.

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

Простой пример использования семафора находится в файле kernel/sys.c, это реализация системных вызовов gethostname(2) и sethostname(2):

asmlinkage long sys_sethostname(char *name, int len)
{
  int errno;

  if (!capable(CAP_SYS_ADMIN)) return -EPERM;
  if (len < 0 || len > __NEW_UTS_LEN) return -EINVAL;
  down_write(&uts_sem);
  errno = -EFAULT;
  if (!copy_from_user(system_utsname.nodename, name, len))
  {
     system_utsname.nodename[len] = 0;
     errno = 0;
  }
  up_write(&uts_sem);
  return errno;
}

asmlinkage long sys_gethostname(char *name, int len)
{
  int i, errno;

  if (len < 0) return -EINVAL;
  down_read(&uts_sem);
  i = 1 + strlen(system_utsname.nodename);
  if (i > len) i = len;
  errno = 0;
  if (copy_to_user(name, system_utsname.nodename, i)) errno = -EFAULT;
  up_read(&uts_sem);
  return errno;
}

Точки, на которые стоит обратить внимание относительно этого примера:

  1. Функции могут блокировать при копировании данных из (или в) пространства пользователя в copy_from_user()/copy_to_user(). Следовательно, они не могли бы использовать здесь никакую форму spinlock.
  2. Выбранный тип семафора: read-write, в противоположность базисному потому, что может иметься большое количество параллельных запросов gethostname(2), которые не должны быть взаимоисключающими.

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

2.15 Поддержка ядра для загрузки модулей

Linux монолитная операционная система, и несмотря на все современные разговоры относительно некоторых преимуществ, предлагаемых операционными системами, основанными на микро-ядерном проекте (цитируется Linus Torvalds):

... message passing as the fundamental operation of the OS is just an exercise in computer science masturbation. It may feel good, but you don't actually get anything DONE.

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

Однако, при выделении ядерных функциональных возможностей в отдельные "процессы", как это выполнено в микро-ядрах, ничего хорошего не происходит. А вот выделение частей ядра в динамически загружаемые по требованию ядерные модули желательно в некоторых обстоятельствах (например, на машинах с малой памятью или для ядер установки, которые иначе могли бы содержать драйверы устройств, которые являются взаимоисключающими). Решение, включать ли поддержку для загружаемых модулей, должно быть сделано во время компиляции и определено опцией CONFIG_MODULES. Поддержка автозагрузки модуля через механизм request_module() представляет собой отдельную опцию компиляции (CONFIG_KMOD).

Следующие функциональные возможности могут быть выполнены как загружаемые модули под Linux:

  1. Символьные и блочные драйверы устройств.
  2. Дисциплины линии терминала.
  3. Виртуальные (регулярные) файлы в /proc и в devfs (например, /dev/cpu/microcode и /dev/misc/microcode).
  4. Двоичные форматы файлов (ELF, aout, Windows PE, DOS EXE и другие).
  5. Области выполнения (Linux, UnixWare7, Solaris и другие).
  6. Различные файловые системы.
  7. System V IPC.

Есть несколько вещей, которые не могут быть выполнены как модули под Linux (вероятно, потому, что это не имеет смысла для них):

  1. Планирование алгоритмов.
  2. VM-политика.
  3. Буферный кэш, кэширование страниц и прочие типы кэшей.

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

  1. caddr_t create_module(const char *name, size_t size): распределяет size байт, используя vmalloc() и отображает структуру модуля в начале этого места. Этот новый модуль затем будет включен в список, возглавляемый module_list. Только процесс с CAP_SYS_MODULE может вызывать этот системный вызов, другие получат возврат EPERM.
  2. long init_module(const char *name, struct module *image): загружает перемещенное изображение модуля и вызывает подпрограмму для инициализации модуля. Только процесс с CAP_SYS_MODULE может вызывать этот системный вызов, другие получат возврат EPERM.
  3. long delete_module(const char *name): пытается выгрузить модуль. Если name==NULL, пытается выгрузить все неиспользуемые модули.
  4. long query_module(const char *name, int which, void *buf, size_t bufsize, size_t *ret): возвращает информацию относительно модуля или относительно всех модулей.

Командный интерфейс, доступный пользователям, состоит из следующего:

  • insmod: вставляет одиночный модуль.
  • modprobe: вставляет модуль и все модули, от которых он зависит.
  • rmmod: удаляет модуль.
  • modinfo: выводит информацию о модуле, включая автора, описание, принимаемые модулем параметры и так далее.

Кроме способности загружать модуль вручную, используя insmod или modprobe, также можно иметь модуль, вставленный автоматически ядром, когда специфические функциональные возможности требуются. Ядерный интерфейс для этого: функция, названная request_module(name), которая экспортируется в модули так, чтобы модули могли также загружать и другие модули. Функция request_module(name) внутренне создает ядерный поток, который выполняет команду modprobe -s -k module_name, применяя стандартный ядерный интерфейс exec_usermodehelper() (который также экспортируется в модули). Функция возвращает 0 при успехе, однако, обычно не стоит проверять код возврата из request_module(). Вместо этого применяется идиома программирования:

if (check_some_feature()==NULL) request_module(module);
if (check_some_feature()==NULL) return -ENODEV;

Например, это выполнено fs/block_dev.c:get_blkfops(), чтобы загрузить модуль block-major-N, когда сделана попытка открыть блочное устройство с major N. Очевидно, что не имеется никакого модуля, названного block-major-N (разработчики Linux выбрали только заметные имена для модулей), но это будет отображено к соответствующему имени модуля, используя файл /etc/modules.conf. Однако, для наиболее известных главных чисел (и других видов модулей) команды modprobe/insmod знают, который реальный модуль надо загрузить и без обращения к файлу /etc/modules.conf.

Хороший пример загрузки модуля: системный вызов mount(2). Он принимает тип файловой системы как строку на которой fs/super.c:do_mount() затем проходит к fs/super.c:get_fs_type():

static struct file_system_type *get_fs_type(const char *name)
{
  struct file_system_type *fs;

  read_lock(&file_systems_lock);
  fs = *(find_filesystem(name));
  if (fs && !try_inc_mod_count(fs->owner)) fs = NULL;
  read_unlock(&file_systems_lock);
  if (!fs && (request_module(name) == 0))
  {
     read_lock(&file_systems_lock);
     fs = *(find_filesystem(name));
     if (fs && !try_inc_mod_count(fs->owner)) fs = NULL;
     read_unlock(&file_systems_lock);
  }
  return fs;
}

Несколько вещей, на которые надо обратить внимание в этой функции:

  1. Сначала мы пытаемся находить файловую систему с заданным именем среди тех, которые уже зарегистрировались. Это выполнено при защите file_systems_lock, принимаемого для чтения (поскольку мы пока не изменяем список зарегистрированных файловых систем).
  2. Если такая файловая система найдена, мы пытаемся получить для нее новую ссылку, пробуя увеличить счетчик хранения модуля. Это всегда возвращает 1 для статически скомпонованных файловых систем или для модулей, которые пока не удвляются. Если try_inc_mod_count() вернет 0, значит мы рассматриваем это как сбой, то есть, если модуль загружен, но удаляется, это примерно то же самое, как если бы модуль не был загружен вообще.
  3. Мы снимаем file_systems_lock потому, что мы собираемся делать затем request_module() операцию блокирования, и, следовательно, мы не можем задерживать spinlock. Фактически, в этом специфическом случае, мы должны были бы в любом случае отменить file_systems_lock, даже если request_module() не вызывает блокировку, и загрузка модуля была выполнена в том же самом контексте. Причина для этого: функция инициализации модуля пробует вызывать register_filesystem(), который будет брать тот же самый file_systems_lock read-write spinlock для записи.
  4. Если попытка загрузиться была успешной, то мы берем file_systems_lock spinlock и пробуем размещать недавно зарегистрированную файловую систему в списке. Обратите внимание, что это немного неправильно потому, что в принципе возможна ошибка в команде modprobe. Она может выпасть в coredump после того, как успешно загрузила запрошенный модуль, когда request_module() будет терпеть неудачу даже при том, что новая файловая система будет зарегистрирована, get_fs_type() не будет ее находить.
  5. Если файловая система найдена, и мы способны получить ссылку для нее, мы именно ее и возвращаем. Иначе мы возвращаем NULL.

Когда модуль загружен в ядро, он может обратиться к любым символам, которые экспортируются как общие ядром, используя макрос EXPORT_SYMBOL() или другими в настоящее время загруженными модулями. Если модуль использует символы из другого модуля, это отмечено как зависимость от того модуля в течение перевычисления зависимостей, выполняемой командой depmod -a при начальной загрузке (например, после установки нового ядра).

Обычно, нужно соответствовать набору модулей с версией ядерных интерфейсов, которые они используют, который под Linux просто означает версию ядра, так как не имеется никакого специального ядерного интерфейса для механизма версий вообще. Однако, имеются ограниченные функциональные возможности, названные "module versioning" или CONFIG_MODVERSIONS, которые позволяют избегать перекомпиляции модулей при переключении к новому ядру. Есть ядерная таблица символов, которая обрабатывается по-разному для внутреннего доступа и для доступа из модулей. Элементы общих (то есть экспортируемых) таблицей символов сформированы 32bit контрольными суммами C-определений. Чтобы найти символ, используемый модулем в течение загрузки, загрузчик должен соответствовать полному представлению символа, которое включает контрольную сумму. Он откажется загружать модуль, если эти символы отличаются. Это случается только тогда, когда ядро и модуль компилируются с включенной поддержкой module versioning. Если любой из компонентов использует первоначальные имена символа, загрузчик просто пробует соогласовать ядерные версии, объявленные модулем и ядром, и отказывается загружаться, если они отличаются.


Назад Вперед Оглавление

Поиск

 

Найди своих коллег!