Powered By Blogger

Tuesday, April 27, 2010

Ещё немного о потоках ядра: kthreadd

ещё буквально пара слов о потоках ядра. Вероятно, Вы замечали в выводе ps -ef поток ядра kthreadd. Наверняка, у Вас даже возникал вопрос, для чего он нужен? На самом деле, всё достаточно просто. Опосредованно взаимодействуя с помощью определённых API с данным потоком, различные части ядра могут ставить в очередь на создание новые потоки, которые и создаёт kthreadd. Данные API ядра используются наряду с функцией kernel_thread(), с тем отличием, что создание нового процесса происходит не сразу же. Сам поток kthreadd стартует после инициализации основного потока ядра в функции rest_init() init/main.c:

static noinline void __init_refok rest_init(void)
        __releases(kernel_lock)
{
        int pid;

        rcu_scheduler_starting();
        kernel_thread(kernel_init, NULL, CLONE_FS | CLONE_SIGHAND);
        numa_default_policy();
        pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
        kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
        unlock_kernel();

        /*
        * The boot idle thread must execute schedule()
        * at least once to get things moving:
        */
        init_idle_bootup_task(current);
        preempt_enable_no_resched();
        schedule();
        preempt_disable();

        /* Call into cpu_idle with preempt disabled */
        cpu_idle();
}
Описатель задачи (struct task_struct) kthreadd хранится в переменной ядра kthreadd_task. Заметьте, что функция kernel_thread() возвращает идентификатор нового процесса (потока). Для того, чтобы получить описатель задачи, в ядре, в частности в приведённом коде, используется функция find_task_by_pid_ns(), первый аргумент которой - идентификатор потока, чей описатель задачи нам нужен, а второй - пространство идентификаторов процесса-предка (в данном случае - init).

Функция потока kthreadd реализована в kernel/kthread.c:

int kthreadd(void *unused)
{
        struct task_struct *tsk = current;

        /* Setup a clean context for our children to inherit. */
        set_task_comm(tsk, "kthreadd");
        ignore_signals(tsk);
        set_cpus_allowed_ptr(tsk, cpu_all_mask);
        set_mems_allowed(node_possible_map);

        current->flags |= PF_NOFREEZE | PF_FREEZER_NOSIG;

        for (;;) {
                set_current_state(TASK_INTERRUPTIBLE);
                if (list_empty(&kthread_create_list))
                        schedule();
                __set_current_state(TASK_RUNNING);

                spin_lock(&kthread_create_lock);
                while (!list_empty(&kthread_create_list)) {
                        struct kthread_create_info *create;

                        create = list_entry(kthread_create_list.next,
                                struct kthread_create_info, list);
                        list_del_init(&create->list);
                        spin_unlock(&kthread_create_lock);

                        create_kthread(create);

                        spin_lock(&kthread_create_lock);
                }
                spin_unlock(&kthread_create_lock);
        }

        return 0;
}
Если не вдаваться сейчас во все детали, то из кода видно, что kthreadd() "крутится" в вечном цикле, в начале каждого прохода проверяя состояние списка kthread_create_list. Если список пуст, то поток устанавливает своё состояние как "спящий" и отдаёт управление, вызывая функцию планировщика schedule(). Если в списке есть элементы, то поток взводит спин-блокировку на списке, чтобы обезопасить его от изменений и до тех пор, пока список не пуст последовательно выполняет следующие действия:
  1. в переменную create типа struct create_thread_nfo* получаем элемент списка;
  2. удаляем элемент из списка;
  3. снимаем спин-лок со списка, так что теперь в него снова можно добавлять новые элементы извне;
  4. используя данные, находящиеся по адресу, сохранённому в create, создаём новый поток с помощью вспомогательной функции create_kthread() (не путать с kthread_create() и kernel_thread()! в отличие от них, create_kthread() не экспортируется за пределы kthread.o);
  5. ну и наконец снова взводим спин-лок на списке, чтобы не произошло ничего неожиданного, пока мы будем проверять пуст ли список потоков к созданию :)

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

struct kthread_create_info
{
        /* Information passed to kthread() from kthreadd. */
        int (*threadfn)(void *data);
        void *data;

        /* Result passed back to kthread_create() from kthreadd. */
        struct task_struct *result;
        struct completion done;

        struct list_head list;
};
Самые интересные на данный момент поля здесь - это threadfn - указатель на функцию, которая должна выполняться в отдельном потоке, data - указатель на данные, которые будут использоваться потоком, result - указатель на описатель задачи для нового потока.

В список новые элементы добавляются с помощью функции kthread_create():

/**
* kthread_create - create a kthread.
* @threadfn: the function to run until signal_pending(current).
* @data: data ptr for @threadfn.
* @namefmt: printf-style name for the thread.
*
* Description: This helper function creates and names a kernel
* thread.  The thread will be stopped: use wake_up_process() to start
* it.  See also kthread_run(), kthread_create_on_cpu().
*
* When woken, the thread will run @threadfn() with @data as its
* argument. @threadfn() can either call do_exit() directly if it is a
* standalone thread for which noone will call kthread_stop(), or
* return when 'kthread_should_stop()' is true (which means
* kthread_stop() has been called).  The return value should be zero
* or a negative error number; it will be passed to kthread_stop().
*
* Returns a task_struct or ERR_PTR(-ENOMEM).
*/
struct task_struct *kthread_create(int (*threadfn)(void *data),
        void *data,
        const char namefmt[],
        ...)
{
        struct kthread_create_info create;

        create.threadfn = threadfn;
        create.data = data;
        init_completion(&create.done);

        spin_lock(&kthread_create_lock);
        list_add_tail(&create.list, &kthread_create_list);
        spin_unlock(&kthread_create_lock);

        wake_up_process(kthreadd_task);
        wait_for_completion(&create.done);

        if (!IS_ERR(create.result)) {
                struct sched_param param = { .sched_priority = 0 };
                va_list args;

                va_start(args, namefmt);
                vsnprintf(create.result->comm, sizeof(create.result->comm),
                        namefmt, args);
                va_end(args);
                /*
                 * root may have changed our (kthreadd's) priority or CPU mask.
                 * The kernel thread should not inherit these properties.
                 */
                sched_setscheduler_nocheck(create.result, SCHED_NORMAL, ¶m);
                set_cpus_allowed_ptr(create.result, cpu_all_mask);
        }
        return create.result;
}

EXPORT_SYMBOL(kthread_create);
kthread_create() принимает указатель на функцию, которая должна выполняться в отдельном потоке (threadfn), указатель на данные для потока (data) и имя нового потока (namefmt). Сперва kthread_create() инициализирует поля переменной create типа struct kthread_create_info. Затем на время добавления нового элемента в список потоков, "ждущих" создания, kthread_create() взводит спин-лок, чтобы никто больше не мог добавить новые элементы и внести сумятицу в наши дела :) Новый элемент списка добавляется в хвост с помощью макроса list_add_tail(). Затем спин-лок снимается - список снова свободен. Далее, kthread_create() будит поток kthreadd с помощью wake_up_process(), который должен будет проверить очередь и запустить новый поток, как описывалось выше. Ну и наконец, если при создании нового потока не возникло ошибок, то подготавливаем такие реквизиты нового потока, как имя и параметры планирования. Оставив новый поток в состоянии сна, возвращаем управления. Вот и всё, что делает kthread_create(). Если кратко, то она ставит в очередь новый запрос на создание потока, дожидается, пока не отработает рабочий поток kthreadd и не будет создан новый спящий поток.

На этом матрёшка не заканчивается. В упомянутой функции kthreadd() мы сознательно пропустили одно место:

        spin_unlock(&kthread_create_lock);
        create_kthread(create);
        spin_lock(&kthread_create_lock);
create_kthread() - ещё одна вспомогательная внутренняя функция, которая с помощью уже знакомого нам вызова kernel_thread() создаёт реальный поток. Но, не всё так просто. На самом деле, здесь создаётся не тот поток, который указывался в качестве аргумента threadfn для kthread_create()! Создаётся всего лишь новый поток kthread - опять же, внутренняя неэкспортируемая за пределы единицы трансляции функция :)
static void create_kthread(struct kthread_create_info *create)
{
        int pid;

        /* We want our own signal handler (we take no signals by default). */
        pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD);
        if (pid < 0) {
                create->result = ERR_PTR(pid);
                complete(&create->done);
        }
}
По результату, который будет положительным числом - идентификатором процесса (pid) в сучае успеха или отрицательным - код ошибки, узнаём, как всё прошло.

Что же делает kthread()? Не так и много. Сначала, новый поток копирует все необходимые данные из переданного ему аргумента _create - т.е., адрес функции потока (threadfn) и данные для потока (data). Во внутреннюю пемеременную self типа struct kthread записываем "состояние" потока - should_stop - не 0, если поток должен быть остановлен и exited - код возврата, если поток завершился.

static int kthread(void *_create)
{
        /* Copy data: it's on kthread's stack */
        struct kthread_create_info *create = _create;
        int (*threadfn)(void *data) = create->threadfn;
        void *data = create->data;
        struct kthread self;
        int ret;

        self.should_stop = 0;
        init_completion(&self.exited);
        current->vfork_done = &self.exited;

        /* OK, tell user we're spawned, wait for stop or wakeup */
        __set_current_state(TASK_UNINTERRUPTIBLE);
        create->result = current;
        complete(&create->done);
        schedule();

        ret = -EINTR;
        if (!self.should_stop)
                ret = threadfn(data);

        /* we can't just return, we must preserve "self" on stack */
        do_exit(ret);
}
Здесь:
        /* OK, tell user we're spawned, wait for stop or wakeup */
        __set_current_state(TASK_UNINTERRUPTIBLE);
        create->result = current;
        complete(&create->done);
        schedule();
мы устанавливаем состояние потока в TASK_UNINTERRUPTIBLE (спящий процесс). В описатель задачи - result - записываем указатель на текущий контекст (ведь когда kthread была запущена через kernel_thread(), у нас уже свой контекст выполнения для данного экземпляра kthread()). Далее, сигнализируем о завершении инициализации описателя нового процесса и состояния задачи с помощью complete() (здесь я намеренно пока не углубляюсь в то, что такое атомарное ожидание). Просим ядро выполнить перепланирование процессов. В этом месте, по сути, выполнение нашего нового потока приостанавливается, т.к. планировщик не будет выделять ему процессорное время ввиду того, что поток спит... Следущие строки будут выполнены только после того, как поток будет разбужен:
         ret = -EINTR;
        if (!self.should_stop)
                ret = threadfn(data);

        /* we can't just return, we must preserve "self" on stack */
        do_exit(ret);
Тут, как будто, ничего мистического нет. Сразу, как только поток вновь получит процессор в своё владение (будет кем-то разбужен), в переменную-код возврата мы записываем код ошибки EINTR - "процесс прерван". Затем необходимо проверить, не успел ли кто-то отменить выполнение потока. Если нет, то наконец-то выполняем именно нашу функцию - threadfn(), передавая ей в качестве аргумента данные для работы. Ну а после этого - делаем do_exit() после того, как функция threadfn() возратит управление.

Такова подсистема поочередного запуска потоков в общих чертах. Всё остальное достаточно просто:

  • void kthread_bind(struct task_struct *k, unsigned int cpu) - создать поток, привязанный к конкретному процессору;
  • int kthread_stop(struct task_struct *k) - запросить останов потока;
  • int kthread_should_stop(void) - проверка, был ли запрошен останов потока (удобно использовать внутри самого потока);
  • kthread_run(threadfn, data, namefmt, ...) - макрос, который делает то же, что kthread_create() с той лишь разницей, что поток сразу будет пробуждён.

Monday, April 12, 2010

Linux - Потоки ядра

Когда-то всё повторится с вероятностью 99%,
но в других обстоятельствах, уже с другими людьми,
по другому поводу, но с той же целью –
стукнуть человека граблями по лбу,
чтобы не забылся человек. ©

Пожалуй, пришло время обсудить ещё одну интересную тему, касающуюся ядра Linux. И тема эта – потоки ядра. Не станем тянуть кота за хвост, а сразу перейдём к делу. Во-первых, выясним для себя, что такое поток? Для того, чтобы ответить на этот вопрос, зададимся другим вопросом, связанным с первым. Что такое процесс? В современных мультизадачных операционных системах есть возможность запустить несколько приложений, которые будут работать какбы параллельно, не мешая друг другу. Можно, допустим, запустить плейер, слушать музыку и одновременно набивать текст в редакторе. Что-то вроде того, что делаю я сейчас :) Но само приложение, это, грубо говоря, всего лишь образ на диске, файл, который необходимо открыть и запустить программу действий, хранящихся в нём. Такие действия есть ничто иное, как инструкции процессора. Но это ещё не процесс. Процесс – некая сущность, абстрактная по своей природе, которой оперирует операционная система. Если посмотреть на классические определения, то процесс – это совокупность кода (инструкция процессора) и ресурсов, выделенных во владение данному коду. Ведь инструкции должны в свою очередь также чем-то оперировать, верно? Где-то необходимо хранить промежуточные и конечные результаты работы. По обстоятельствам, хранить их можно либо на диске (файлы), либо в оперативной памяти. В данной случае блоки памяти, выделенные в распоряжение коду и дескрипторы файлов (то, посредством чего процесс ссылается на объекты ядра - файлы) – это и есть ресурсы. Разумеется, всё многообразие ресурсов, которыми может владеть процесс, не исчерпывается памятью и файловыми дескрипторами. Оставим в покое такие тривиальные вещи, как память и файлы и добавим, что для процесса, т.е. выполняющегося кода, есть ещё один крайне важный ресурс – процессорное время. Каждый процесс имеет в своём распоряжении определённый квант времени, на протяжении которого он волен делать с процессором всё (ну, или точнее, почти всё), что угодно, в том числе, он может отказаться от своего кванта и отдать его другому процессу. Тот, кто занимается выделением квантов времени процессам, управляет очередью, в которой за временем стоят процессы и непосредственно ведает величиной кванта времени, который будет выделен тому или иному процессу – это ядро операционной системы. Если быть точнее, подсистема ядра, занимающаяся планированием процессов или попросту – планировщик (scheduler). Процессы, таким образом, работают отнюдь не одновременно и параллельно (для простоты отвлечёмся от того факта, что в последнее время широкое распространение получили SMP-системы, основанные на многоядерных процессорах, где истинный параллелизм выполнения кода действительно возможен). Вместо этого планировщик последовательно переключает процессы, давая возможность каждому из них на какое-то время воспользоваться центральным процессором в своих целях. Так как происходит это достаточно быстро, то создаётся иллюзия, что несколько процессов работают одновременно. Таким образом, процесс – это логическая сущность, нечто, что в данный момент выполняется. Однако, это не предельная сущность. В современных операционных системах почти повсеместно имеет место ещё одна сущность – поток. Поток, это приближённо говоря часть процесса, выполняющаяся параллельно другим его частям. Если мы возьмём такой классический пример, как GUI-приложение, то один поток в таком процессе может, допустим, отрисовывать пользовательский интерфейс, в то время, как второй поток будет заниматься действительно полезной работой :) Взаимодействуя друг с другом в таком процессе можно обеспечить актуальное состояние графического интерфейса, отражающего прогресс выполнения задачи – ну хоть отрисовывать прогресс-бар, к примеру :) В терминах многопоточного программирования у приложения всегда есть как минимум один поток – главный. Главный поток в процессе своей деятельности может порождать вторичные потоки. В зависимости от платформы поддержка потоков может быть реализована на уровне ядра операционной системы, либо сугубо средствами библиотек. Если поддержка процессов ядром неизбежна, то, как было сказано, потоки вовсе не обязаны поддерживаться данной ОС. Потоки очень похожи на процессы, но есть одно весьма важное отличие. Каждый процесс владеет своим набором ресурсов и ресурсы эти недоступны другим процессам, выполняющимся в системе. Так должно быть в идеале и так есть, за исключением тех случаев, когда приложения сами заинтересованы в разделении своих ресурсов (межпроцессное взаимодействие - IPC). С потоками дело обстоит иначе. Все потоки данного процесса разделяют общие ресурсы, которые имеются в распоряжении данного процесса – память, дескрипторы файлов, сокетов, и проч. После небольшого вводного курса обратимся к объекту наших изысканий – ядру Linux. Изначально в ядре Linux не было поддержки потоков. Вместо этого потоки реализовывались средствами стандартной библиотеки POSIX – pthread. С одной стороны, такой подход хорош тем, что устраняется зависимость приложения от конкретного ядра, а с другой, отсутствие поддержки со стороны ядра сильно усложняет реализацию самой библиотеки, ведь необходимо как-то обеспечить хитроумные механизмы выполнения потоков, межпоточной синхронизации и проч. Позже ситуация изменилась и в ядре появилась концепция так называемых облегчённых процессов (lightweight process). В отличие от ядра NT (то, которое Windows) в ядре Linux нет выделенной концепции потока. В качестве потока как единица планирования на уровне ядра выступает упомянутый облегчённый процесс. Если не вдаваться в детали, то облегчённый процесс подобен обычному, за исключением того, что ресурсы облегчённых процессов разделяются между собой. Т.о., облегчённый процесс и есть интерпретация концепции потока в рамках ядра Linux. Благодаря введению облегчённых процессов многие вещи упростились, т.к. теперь ядро способствует процессам в распараллеливании задач. Теперь упростилась реализация пользовательских библиотек для поддержки многопоточности, можно спокойно использовать объекты блокировки ядра, создавать пулы потоков и др. Теперь мы подошли к тому, чтобы ответить, что из себя представляют потоки ядра, чем они отличаются от пользовательских потоков, кому и зачем всё это вообще нужно? В принципе, потоки ядра очень похожи на пользовательские процессы за исключением того обстоятельства, что свои данные потоки ядра хранят в памяти самого ядра и, соответственно, имеют доступ к виртуальному адресному пространству ядра, они т.о. имеют доступ к функциям ядра и его структурам данных. Поток для ядра – это возможность запустить на фоне определённую задачу, которая будет выполняться не непрерывно, а, допустим, ожидая наступления какого-то события, будет «спать», т.е., асинхронно. Как и в случае с процессами, потоки какое-то время способны владеть процессом, пока они не будут вытеснены другим потоком, что имеет место в случае т.н. вытесняющей многозадачности. Ниже мы рассмотрим, как используются потоки ядра и что они делают на конкретных примерах и с привлечением сведений о том, что такое очереди выполнения, ожидания и состояния процесса.
Для начала посмотрим, что есть в нашей системе на базе ядра Linux ветки 2.6. Сделать это просто, если запустить ps с ключами “e” и “f”:
$ ps –ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 22:36 ?        00:00:00 init [3]
root         2     1  0 22:36 ?        00:00:00 [ksoftirqd/0]
root         3     1  0 22:36 ?        00:00:00 [events/0]
root        38     3  0 22:36 ?        00:00:00 [pdflush]
root        39     3  0 22:36 ?        00:00:00 [pdflush]

…
root      4066  4015  0 22:59 tty3     00:00:00 ps -ef
В приведённом листинге потоки ядра это те, чьи имена взяты в квадратные скобки. Допустим, поток ядра ksoftirqd занимается обеспечением системы обработки мягких прерываний по IRQ. Суть идеи состоит в том, что как известно, в ядре Linux реализована следующая модель прерываний. Обработчик прерывания имеет две «половины» - верхнюю и нижнюю. Верхняя половина прерывания требует сиюминутной обработки, в то время, как обработка нижней половины может быть отложена и её ядро не обязано обработать мгновенно. Откладывая т.о. выполнение части кода, отвечающей за обработку прерываний, система экономит время, которое может быть отдано под выполнение более приоритетных на данный момент задач. В функции потока ksoftirqd входит наблюдение за тем, чтобы все запросы на прерывания были обслужены с одной стороны, и с другой, чтобы система не загружалась до предела такими запросами. Номер после слэша – это число, идентифицирующее поток, т.к. в SMP-системах на каждом процессоре выполняется свой экземпляр потока ksoftirqd. Если в Вашей системе 4 процессора, либо 4-соответственно в системе будут 4 потока ksoftirqd – ksoftirqd/0-ksoftirqd/3, которые будут привязаны каждый к своему процессору или процессорному ядру. Ещё один поток ядра – events, обеспечивает централизованную поддержку рабочих очередей. Если какая-то часть ядра желает отложить выполнение работы, то эта часть может либо создать собственную рабочую очередь, либо поставить задание в очередь, созданную потоком events. Ещё один пример того, как ядро создаёт потоки для фоновых задач – pdflush. Так как известно, что по быстродействию память неизмеримо лучше, чем диск, то в целях соблюдения баланса производительности данные, предназначенные для записи на накопитель не попадают туда мгновенно. Вместо этого данные по запросам записи накапливаются в кэше. pdflush как раз и занимается синхронизацией буферного кэша с содержимым диска. Поток pdflush сбрасывает на диск т.н. «грязные» страницы кэша, т.е. те, в которые производилась запись. При этом pdflush следит, чтобы объём свободной памяти не слишком понижался и чтобы страницы с данными не находились в кэше больше определённого времени. Если объём доступной памяти уменьшается, а в кэше находятся данные, ожидающие записи или если некоторые страницы задержались, то pdflush обеспечивает запись кэшированных данных на диск. Из ранее приведённого листинга видно, что в системе есть 2 экземпляра pdflush, но без номеров, которые бы указывали на привязку к процессорам. На самом деле, новые экземпляры потока pdflush создаются по мере роста нагрузки на систему ввода/вывода, либо когда один поток сильно загружен. khubd – часть подсистемы USB ядра, которая отслеживает подключение к USB-хабу устройств и занимается конфигурированием устройств, поддерживающий горячее подключение (hot-plugged). Наконец, kjournald – это поток журналирования, используемый ядром в реализация файловых систем с поддержкой журналирования, как например ext3.
Для того, чтобы лучше понять суть сказанного, попробуем реализовать свой собственный поток ядра и начнём с постановки задачи: допустим, нам нужен такой поток, который бы вёл наблюдение за состоянием ядра и асинхронно с помощью вспомогательной пользовательской программы уведомлял Вас, что определённые структуры ядра находятся в состоянии, не вполне хорошем или неудовлетворительном. Как например ситуация, когда свободное место в принимающем буфере сетевого драйвера почти иссякло. Чтобы решить поставленную задачу необходимо следующее:
  1. код должен быть оформлен, как фоновое задание, которое ожидает наступления некого асинхронного события;
  2. наш код должен иметь доступ к структурам данных ядра, ведь определение критического уровня буфера выполняется другими частями ядра;
  3. наш код должен вызывать вспомогательное пользовательское приложение, что затратно в плане времени.

Наш поток освобождает процессор и пробуждается только тогда, когда наступает ожидаемое событие, например, изменение в структуре, за которой мы ведём наблюдение. Пробудившись, поток должен вызвать вспомогательное приложение, которое и обязано уведомить пользователя.
Приведённый далее вызов создаёт поток ядра:
ret = kernel_thread (mykthread, NULL, CLONE_FS | CLONE_FILES | CLONE_SIGHAND | SIGCHLD);
Наш поток можно создать там, где это наиболее подходит. Ну, хотя бы в init/main.c. Набор флагов, передаваемый функции kernel_thread(), определяет, какие ресурсы должны разделяться между родителем и его потоками-потомками.
CLONE_SIGHAND – предписывает сделать общими обработчики сигналов Unix;
CLONE_FILES – разделять дескрипторы открытых файлов;
CLONE_FS – родитель и потомок используют общую информацию о файловой системе. Сюда входит корневой каталог, текущий каталог и параметр umask процесса. Любой вызов chroot(2), chdir(2), umask(2), выполненный либо потомком, либо родителем влияет также и на другой процесс.
В коде, приводимом далее, функция ядра daemonize() создаёт поток без ассоциированных с ним пользовательских ресурсов. Следующий вызов reparent_to_init() изменяет родителя взывающего потока на поток init. Каждый поток (равно, как и процесс) в Linux (да и не только в нём) имеет единственного родителя. В случае, когда родительский процесс завершается, не дождавшись окончания работы своего дочернего потока (процесса), такой осиротевший процесс становится зомби. Проще говоря, процесс-зомби уже не планируется на выполнение, но в то же время запись о процессе, поставленная ему в соответствие и описывающая его не удаляется. Вся информация сохраняется, хотя, по существу, процесс уже завершён и больше никому не нужен. Переназначение процесса-родителя позволяет избежать таких неприятностей. В ядрах ветки 2.6 функция daemonize() сама вызывает reparent_to_init(). Так как вызов daemonize() по умолчанию блокирует все сигналы, нам необходимо вызвать функцию allow_signal(), чтобы указать, какие сигналы можно доставлять нашему потоку. Т.к. в ядре нет обработчиков сигналов, как например, в glibc, необходимо использовать специальную функцию signal_pending(), с помощью которой можно определить, был ли потоку послан сигнал и если да, то обработать его соответственно. В нашем примере мы обрабатываем только SIGKILL, по которому поток завершается.
static DECLARE_WAIT_QUEUE_HEAD (myevent_waitqueue);
rwlock_t myevent_lock;

static int mykthread (void *unused)
{
        unsigned int event_id = 0; 

        DECLARE_WAITQUEUE (wait, current);

        /* Код, необходимый для того, чтобы наш код стал потоком ядра 
        * без каких-либо ресурсов, присущих пользовательским процессам */
        daemonize (”mykthread”);
        reparent_to_init (); /* Для ядер ветки 2.4 */

        /* Запросить доставку сигнала SIGKILL */
        allow_signal (SIGKILL);

        /* Поток спит в очереди ожидания, пока он не будет разбужен 
        * той частью ядра, которая непосредственно имеет дело с интересующей
        * нас структурой данных */
        add_wait_queue (&myevent_waitqueue, &wait);

        for (;;) {
                /* Освободить процессор, пока не наступит ожидаемое событие */
                set_current_stat (TASK_INTERRUPTIBLE);
                schedule ();

                /* Выход, если получен сигнал SIGKILL */
                if (signal_pending (current)) break;

                /* Сюда переходит управление, когда поток разбужен  */
                read_lock (&myevent_lock); /* Начало критической секции */

                if (myevent_id) { /* Защита от "хвостовых" (повторных) пробуждений */
                        event_id = myevent_id;
                        read_unlock (&myevent_lock); /* Здесь заканчивается критическая секция */

                        /* Вызвать зарегистрированное пользовательское приложение-хелпер
                        * передав ему через переменные окружения необходимую информацию */
                        run_umode_handler (event_id); /* См. далее */
                } else {
                        read_unlock (&myevent_lock); 
                }
        }

        set_current_state (TASK_RUNNING);
        remove_wait_queue (&myevent_waitqueue, &wait);
        return 0;
}

Если Вы откомпилируете приведённый код в составе ядра, то при загрузке с новым ядром по команде ps сможете увидеть свой новый поток, который выполняется, как потомок init:
$ ps –ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 21:56 ?        00:00:00 init [3]
root         2     1  0 22:36 ?        00:00:00 [ksoftirqd/0]
…
root         111   1  0 21:56 ?        00:00:00 [mykthread]
…

Прежде чем снова углубляться в детали реализации потоков ядра, взглянем на код ниже:
/* Выполняется теми частями ядра, которые владеют необходимыми данными, которые мы мониторим */

        /* … */

        if (my_key_datastructure looks troubled) {
                write_lock (&myevent_lock); 
                /* Записываем наблюдаемую структуру */
                myevent_id = datastructure_id;
                write_unlock (&myevent_lock);

                /* Разбудить поток mykthread */
                wake_up_interruptible (&myevent_waitqueue); 
        }

        /* … */
Полезную работу ядро выполняет используя контекст процесса или контекст прерывания. Причём контекст процесса и контекст прерывания не связаны друг с другом. Чтобы не вносить путаницу, необходимо вероятно пояснить, что когда речь идёт о контексте прерывания, то подразумевается не обязательно сам факт прерывания а всего лишь контекст, в котором ядро выполняет отложенные вызовы. Самый первый кусок кода нашей реализации потока выполняется в контексте потока. В то время как код, приведённый абзацем ранее, использует контекст прерывания для обработки отложенной функции и в равной же степени он может быть использован в контексте процесса. Оба вида контекстов связываются посредством структур данных ядра. myevent_id и myevent_waitqueue в приведённом нами коде используются для связывания контекстов. Доступ к переменной myevent_id управляется спин-блокировкой (spin lock). Потоки ядра выполняются в режиме вытесняющей многозадачности только если при конфигурировании ядра включен параметр CONFIG_PREEMPT. Без включения этого параметра и патча вытесняющей многозадачности в ядрах ветки 2.4 наш поток заморозит всю систему, если он сам не заснёт. Если в первом куске кода, приводимом в начале этой статьи, закомментировать вызов schedule(), то с выключенным параметром CONFIG_PREEMPT ядро также окажется заблокированным.
Давайте ещё раз посмотрим на код, который усыпляет наш поток mykthread до наступления события.
        add_wait_queue (&myevent_waitqueue, &wait);

        for (;;) {
                /* .. */
                set_current_state (TASK_INTERRUPTIBLE);
                schedule ();
                /* Точка A */

                /* .. */
        }

        set_current_state (TASK_RUNNING);
        remove_wait_queue (&myevent_waitqueue, &wait);
В очередях ожидания хранятся записи потоков, которые ожидают наступления события или системного ресурса. Поток в очереди ожидания спит до тех пор, пока он не будет разбужен обработчиком прерывания или другим потоком, который ведёт наблюдение за каким-либо объектом. Постановка в очередь и удаление из неё осуществляются с помощью функций add_wait_queue() и remove_wait_queue() соответственно. Пробуждение поставленного в очередь потока/процесса выполняется функцией wake_up_interruptible(). В приведённом выше кусочке кода set_current_state() необходима для того, чтобы установить состояние потока «выполняется».
Поток ядра (равно, как и обычный процесс) может находиться в одном из следующих состояний:
  • TASK_RUNNING: Процесс выполняется (использует процессор) или находится в очереди выполнения, ожидая выделения процессорного времени.
  • TASK_INTERRUPTIBLE: Процесс приостановлен до наступления определенного события. Это состояние может быть прервано сигналами. После получения сигнала или возобновления путем явного выполнения "пробуждающего" вызова процесс переходит в состояние TASK_RUNNING.
  • TASK_UNINTERRUPTIBLE: Данное состояние аналогично TASK_INTERRUPTIBLE, с той лишь разницей, что в нем не происходит обработка сигналов. Прерывание процесса, находящегося в этом состоянии, может быть нежелательным, поскольку ожидание может быть частью некоторой важной задачи. При наступлении ожидаемого события процесс возобновляется путем явного выполнения "пробуждающего" вызова.
  • TASK_STOPPED: Выполнение процесса остановлено, он не выполняется и не может начать выполняться. Процесс переходит в это состояние по получении таких сигналов, как SIGSTOP, SIGTSTP и т.д. Процесс сможет снова стать исполняемым после получения сигнала SIGCONT.
  • TASK_TRACED: Процесс находится в этом состоянии при выполнении его мониторинга такими процессами, как, например, отладчики.
  • EXIT_ZOMBIE: Процесс завершен. Он будет находиться в системе до момента получения родительским процессом статистической информации по его выполнению.
  • EXIT_DEAD: Конечное состояние (соответствующее своему названию). Процесс переходит в это состояние при его удалении из системы после того, как родительский процесс получит всю статистическую информацию, выполнив системный вызов wait4() или waitpid().
Кстати, начиная с версии ядра 2.6.25 введено новое состояние процесса – TASK_KILLABLE если процесс приостановлен с приоритетом, допускающим прерывания, он ведет себя так, как если бы находился в состоянии TASK_UNINTERRUPTIBLE, но вдобавок имеет возможность обрабатывать фатальные сигналы.
Вернёмся к нашему потоку. mykthread спит, находясь в очереди ожидания (myevent_waitqueue) и изменяет своё состояние на TASK_INTERRUPTIBLE, давая понять, что он отказывается от ожидания в очереди выполнения и, стало быть, квантов времени, которые ему может выделить планировщик выполнения процессов. Вызов функции schedule() необходим для того, чтобы планировщик выбрал другой процесс из очереди выполнения. Когда другая часть ядра будит mykthread с помощью wake_up_interruptible(), поток помещается обратно в очередь выполнения планировщика, а состояние задачи меняется на TASK_RUNNING, т.о. ситуация гонок отсутствует даже в том случае, когда процесс пробуждается, находясь в состоянии TASK_INTERRUPTIBLE (к слову: механизм пробуждения процесса из очереди ожидания, а точнее, механизм самой очереди ожидания предотвращает ситуацию, известную, как "стадо перед грозой", когда несколько процессов ждут одного события - например, освобождения ресурса. Когда ядро будит их, при эсклюзивном доступе к ресурсу получается, что большинство процессов пробуждаются лишь для того, чтобы поучаствовать в гонке за ресурсом и не солоно хлебавши продолжить спать - таких процессов из чсла разбуженных будет большинство, ведь победитель возможен только один) и происходит вызов функции планирования - schedule(). Поток перемещается также в очередь выполнения, если ему доставлен сигнал SIGKILL. Когда планировщик в порядке очерёдности извлекает поток mykthread из очереди выполнения, выполнение потока продолжается в точке А.
Теперь, для полной реализации задуманного, всё, что нам остаётся сделать, это реализовать вспомогательное приложение, которое будет запускаться потоком ядра и, собственно, уведомлять пользователя о событии. Итак, хорошая новость. Для нашего удобства ядро поддерживает механизм вызова пользовательских приложений, если требуется выполнить какие-то определённые действия. Например, если включена функция автоматической подгрузки модулей, то ядро по необходимости динамически подгружает необходимые модули, используя для этой цели пользовательский загрузчик модулей. По умолчанию, загрузчик модулей - /sbin/modprobe, но ничто не мешает нам заменить его на другой, зарегистрировав альтернативный загрузчик с помощью файла /proc/sys/kernel/modprobe. Аналогичным образом ядро уведомляет пользовательское окружение о событиях, связанных с устройствами, поддерживающими горячее подключение (hot-plug). В данном случае вспомогательное приложение по умолчанию - /sbin/hotplug, которое можно заменить, переписав содержимое файла /proc/sys/kernel/hotplug. В приведённом далее коде реализована функция, с помощью которой поток ядра mykthread уведомляет пользовательское окружение о наступлении события. Приложение, которое необходимо вызвать, регистрируется в виртуальной файловой системе /proc системным вызовом sysctl, если конечно при конфигурировании ядра был разрешён интерфейс sysctl (CONFIG_SYSCTL). Для того, чтобы добавить свой sysctl-параметр ядра, необходимо добавить новый элемент в массив kern_table в файле kernel/sysctl.c:
{
 KERN_MYEVENT_HANDLER, "myevent_handler", &myevent_handler, 256,
 0644, NULL, &proc_dostring, &sysctl_string
}

Благодаря этому при загрузке в модифицированное ядро на файловой системе /proc появится такой файл: /proc/sys/kernel/myevent_handler.
Чтобы зарегистрировать свой обработчик, достаточно в командной строке вполнить следующую команду:
$ echo /path/to/kernel-helper > /proc/sys/kernel/myevent_handler

Т.о., при наступлении ожидаемого события будет выполнен наш /path/to/kernel-helper.
static void run_umode_handler (int event_id)
{
        int i = 0;
        char *argv[2], *envp[4], *buffer = NULL;
        int value;

        argv[i++] = myevent_handler; /* Определено в kernel/sysctl.c */

        /* Запись идентификатора структуры */
        if (!(buffer = kmalloc (32, GFP_KERNEL))) return;
        sprintf (buffer, "TROUBLED_DS=%d", event_id);

        /* Если пользовательские обработчики не зарегистрованы, то возврат управления */
        if (!argv[0]) return;
        argv[i] = 0;

        /* Подготовка окружения для /path/to/kernel-helper */
        i = 0;
        envp[i++] = "HOME=/";
        envp[i++] = "PATH=/sbin:/bin:/usr/sbin:/usr/bin";
        envp[i++] = buffer;
        envp[i] = 0;

        /* Запустить пользовательское приложение, /path/to/kernel-helper */
        value = call_usermodehelper (argv[0], argv, envp, 0);

        /* Check return values */
         …

         kfree (buffer);
}
Информацию о структуре данных ядра, которая по каким-то причинам может вызвать у нас интерес, мы передаём пользовательскому процессу через переменную окружения (TROUBLED_DS). В принципе, вспомогательным приложением может быть даже простой шелл-скрипт, который уведомит пользователя по эл.почте о наступившем событии, при чём информацию о наблюдаемой структуре данных ядра он также сможет спокойно получить из переменной окружения:
#!/bin/bash
echo Kernel datastructure $TROUBLED_DS  
  is in trouble | mail –s Alert root
Функция call_usermodehelper() выполняется в контексте запускаемого процесса и работает с полномочиями пользователя root (root capabilities). В ядрах ветки 2.6 этот механизм реализуется как очередь заданий (work queue).
Вот мы и рассмотрели в довольно общих чертах потоки ядра, зачем они нужны, как создаются, работают и используются. Но здесь мы привели лишь отрывочные фрагменты кода и немного теории. Самый лучший способ изучить всё в деталях – обратиться к исходному коду ядра. Для тех, кого всё описанное заинтересовало, упоминаемые в тексте потоки ядра ksoftirqd, pdflush, и khubd реализованы соответственно в kernel/softirq.c, mm/pdflush.c и drivers/usb/core/hub.c.
Код функции daemonize() можно найти в файле kernel/exit.c для ядер ветки 2.6 и в файле kernel/sched.c, если речь о версии 2.4. Если Вас интересует механизм вызова пользовательских приложений, хелперов ядра, милости просим обратить внимание на код в файле kernel/kmod.c. Вот, пожалуй, и всё. Надеюсь, было интересно и, главное, полезно :)

Ссылки.

[1] Материалы из статьи Sreekrishnan Venkateswaran на http://www.linux-mag.com/ (увы, не помню точно адрес статьи);
[2] "Ядро LINUX", Д.Бовет, М.Чезати, 3-е изд.;
[3] Исходные коды ядра Linux (2.6.25-33), как всегда.

Saturday, February 13, 2010

Когда память работает на Вас: использование ramfs и tmpfs

Если производительность операций дискового ввода/вывода не соответствует Вашим нуждам, то наиболее дешёвое и наименее затратное по времени решение - это разместить очень часто используемые файлы в оперативной памяти (RAM). Чтение и запись в память значительно быстрее, чем соответствующие операции на дисковой файловой системе. Операции дискового ввода/вывода, как те, что используются при работе с базами данных, позволяют улучшить производительность, если перенести данные в файловую систему, находящуюся в оперативной памяти.

Почему именно оперативная память? Оперативная память быстра. Она работает на скоростях доступа порядка наносекунд. Оперативная память (RAM) не вращается. Дисковые накопители содержат механизмы, которые находятся в движении. Это означает, что операции чтения и записи, требующие поиска, значительно медленнее нежели работа с RAM. Для примера, память DDR3 позволяет перемещать данные со скоростью выше 10ГБ/с. Даже самый быстрый жёсткий диск фирмы Hitachi - UltraStar, работающий на скорости 15000 об/м работает с данными в среднем на скоростях от 119МБ/с до 198МБ/с, а пиковая скорость - максимум 600МБ/с. RAM имеет большее среднее время безотказной работы. Так как RAM не содержит механических частей, то её показатель наработки на отказ гораздо лучше, чем у дисковых накопителей.

В качестве файловых систем, работающих в RAM, в Вашем распоряжении есть tmpfs и ramfs. Давайте посмотрим, как можно настроить файловую систему в RAM и попутно избежать некоторых общих проблем при использовании RAM-ФС.

ramfs

tmpfs и ramfs работают по-разному. ramfs может использовать только системную память, данные о ней не отображаются по команде df -h, она также не имеет ограничений по размеру и не выдаёт сообщений об ошибках при установке опциональных ограничений (т.е., ограничение размера файловой системы, задаваемое при монтировании, как показано ниже - перев.). То обстоятельство, что ramfs не имеет ограничений по размеру и Вы никогда не увидите предупреждающих сообщений при установке опциональных ограничений может показаться противоречивым, но на самом деле это не так. Вы можете ограничить размер файловой системы ramfs, но Вы не получите предупреждения при превышении этого ограничения, равно как сама система не будет делать ничего, чтобы предотвратить превышение ограничения.

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

# mount -t fs_type device mount_dir

Синтаксис команды mount для настройки RAM-ФС размером в 200МБ для БД в каталоге /opt/data будет таким:

# mount -t ramfs -o size=200m ramfs /opt/data

Как говорилось ранее, эта ФС не будет отображена по команде df -h. Единственный способ обнаружить её - команда mount.

# mount

/dev/mapper/VolGroup00-LogVol00 on / type ext3 (rw)
proc on /proc type proc (rw)
sysfs on /sys type sysfs (rw)
devpts on /dev/pts type devpts (rw,gid=5,mode=620)
/dev/sda1 on /boot type ext3 (rw)
none on /proc/sys/fs/binfmt_misc type binfmt_misc (rw)
sunrpc on /var/lib/nfs/rpc_pipefs type rpc_pipefs (rw)
ramfs on /opt/data type ramfs (rw,size=200m)

Если Вы попытаетесь записать на подмонтированную таким образом ФС более 200МБ данных, запись продолжится без каких-либо предупреждений со стороны системы. Предел в 200МБ является избыточным и никак не влияет на реальный размер ФС или на то, сколько данных Вы можете на неё записать. Это является главным недостатком ramfs.

Системные администраторы слишком занятые работой, могут захламлять ФС различными файлами - после установки патчей, ПО, или после продолжительного тестирования системы. Для преодоления этой проблемы ramfs является эффективным решением. Все файлы, в длительном хранении которых на дисковом накопителе нет необходимости, могут размещаться на временной ФС в памяти (ramfs). После размонтирония всё содержимое RAM-ФС утрачивается, а все ненужные файлы т.о. разом исчезают из системы, не засоряя её.

Небольшое замечание по поводу ramfs: хоть формально при монтировании ramfs Вы можете указать размер файловой системы, Вам необходимо следить за её заполненностью. И вот почему: размер ramfs увеличивается динамически и система не предотвратит переполнение ФС, как было сказано. Допустим, у Вас 2Гб оперативной памяти, у Вас на /tmp/ram подмонтирована ramfs на 1Гб. Когда данные на Вашей ramfs перевалят за 1Гб, Вы всё ещё сможете дописывать что-то новое в /tmp/ram. Система будет молча выполнять всё, что Вы ей скажете. Однако, когда объём данных превысит объём физической памяти (2Гб в нашем случае), система может зависнуть, т.к. в RAM больше физически нет места для хранения данных. Имейте это ввиду и считайте, что Вас предупредили.

tmpfs

Для операций с реальными данными наиболее предпочтительным выбором является tmpfs, нежели ramfs. В отличие от последней, tmpfs имеет фиксированный размер, данные, хранимые на ней, могут размещаться как в системной памяти, так и в разделе подкачки. При превышении размера ФС система выдаёт сообщения об ошибке превышения размера ФС. Синтаксис монтирования tmpfs похож на уже виденный нами:

# mount -t tmpfs -o size=200mb tmpfs /opt/data

df -h показывает сведения о подмонтированной tmpfs так же, как о любой другой реальной ФС:

# df -h

Filesystem            Size  Used Avail Use% Mounted on
/dev/mapper/VolGroup00-LogVol00
                      360G  225G  117G  66% /
/dev/sda1              99M   25M   70M  27% /boot
tmpfs                 200M     0  200M   0% /opt/data

mount показывает следующую информацию:

# mount

/dev/mapper/VolGroup00-LogVol00 on / type ext3 (rw)
proc on /proc type proc (rw)
sysfs on /sys type sysfs (rw)
devpts on /dev/pts type devpts (rw,gid=5,mode=620)
/dev/sda1 on /boot type ext3 (rw)
none on /proc/sys/fs/binfmt_misc type binfmt_misc (rw)
sunrpc on /var/lib/nfs/rpc_pipefs type rpc_pipefs (rw)
tmpfs on /opt/data type tmpfs (rw,size=200m)

Когда объём записываемых на ФС данных превышает размер самой tmpfs, система выдаёт сообщение "No space left on device", предупреждая Вас, что ФС заполнена. tmpfs во всём схожа с обычными дисковыми ФС, за исключением того, что она непостоянна. Вы можете добавить в /etc/fstab соответствующую строку, чтобы tmpfs монтировалась после каждой перезагрузки.

Следует иметь ввиду, что ramfs и tmpfs - непостоянные ФС, которые находятся в памяти. Это значит, что при крахе системы, перезагрузке или останове - не важно по какой причине, все данные, хранящиеся на RAM-ФС, будут безвозвратно утеряны. Поэтому используя tmpfs Вам следует позаботиться о том, чтобы периодически делать дампы данных с tmpfs на постоянный носитель. Помните пословицу: "Безопасный, быстрый, дешёвый; выбирай любые два". Использование RAM-ФС - это быстрое и недорогое решение, но небезопасное.

Оригинал статьи на английском здесь.

Saturday, May 16, 2009

Связные списки в ядре Linux

Введение:

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

Ясная, лёгкая в использовании реализация циклического двусвязного списка на С находится в файле include/linux/list.h. Код этой реализации эффективен и переносим - в противном случае он не был бы в ядре. Если при программировании ядра необходим список для работы с множеством данных любой структуры, то используют двусвязные списки ядра linux. Путём минимальной правки (удаление аппаратно-зависимого ускорения предвыборки записей списка) списки могут быть адаптированы к использованию в приложениях пользовательского режима. Здесь находится версия файла, пригодная для такого использования.

Преимущества использования таких списков следующие:

  1. Независимость от типа:
    Вы можете использовать любую структуру данных, какую заблагорассудится.
  2. Переносимость:
    Хотя я не пробовал использовать списки на всех платформах, но можно предположить, что данная реализация весьма переносима. В противногм случае она бы не попала в дерево исходного кода ядра.
  3. Простота использования:
    В силу независимости списков от типа данных записей для инициализации, доступа к элементам списка, прохождения по списку используются одни и те же функции.
  4. Простота восприятия:
    Макросы и подставляемые (inline) функции делают код очень элегантным и простым для понимания.
  5. Экономия времени:
    Вам не нужно изобретать колесо. Использование списков позволяет существенно сэкономить время на отладку и повторное создание списков для каждой структуры данных, которая используется в программе.
Реализация списков в ядре linux отличается от той, которую Вы могли видеть ранее. Обычно, данные содержатся в элементах связанного списка:
struct my_list {
        void *myitem;
        struct my_list *next;
        struct my_list *prev;
};
Реализации списков в ядре создаёт иллюзию, что это данные содержат в себе список! Например, если у Вас есть список данных со структурой struct my_cool_list, то список будет выглядеть следующим образом:
struct my_cool_list {
        struct list_head list; /* kernel's list structure */
        int my_cool_data;
        void* my_cool_void;
};
Следует отметить следующее:
  1. Список содержится в структурах, которые необходимо связать.
  2. Структуру struct list_head можно поместить везде в объявлении Вашей структуры.
  3. struct list_head может иметь любое имя.
  4. У вас в Вашей структуре может быть несколько полей типа struct list_head!
Например, объявление, приведённое ниже также правильно:
struct todo_tasks{
        char *task_name;
        unsigned int name_len;
        short int status;

        int sub_tasks;

        int subtasks_completed;
        struct list_head completed_subtasks; /* структура списка */

        int subtasks_waiting;
        struct list_head waiting_subtasks;  /* ещё один список таких же или других элементов! */

        struct list_head todo_list;  /* список дел - todo_tasks */
};
Вот несколько примеров использования списокв в самом ядре: Раз уж мы вплотную подошли к спискам, то посмотрим на объявление типа struct list_head в include/linux/list.h:
struct list_head{
        struct list_head *next;
        struct list_head *prev;
};
Пожалуй, самое время перейти к деталям. Для начала, давайте посмотрим, как можно использовать этот тип в нашей программе, а затем рассмотрим, как эта структура работает.

Использование списка:

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

Ниже приведён пример создания, добавления, удаления и прохождения списка.

#include <stdio.h>
#include <stdlib.h>

#include "list.h"

struct kool_list{
        int to;
        struct list_head list;
        int from;
};

int main(int argc, char **argv)
{
        struct kool_list *tmp;
        struct list_head *pos, *q;
        unsigned int i;
        struct kool_list mylist;
        INIT_LIST_HEAD(&mylist.list);
        /* вместо строки выше можно было бы использовать макрос
         * LIST_HEAD(mylist) при объявлении списка; данный макрос объявляет
         * переменную типа struct list_head с указанным именем и инициализирует её.
         */

        /* добавление элементов в mylist */
        for (i = 5; i != 0; --i) {
                tmp = (struct kool_list *)malloc (sizeof (struct kool_list));
                 /* INIT_LIST_HEAD(&tmp->list); 
                 *
                 * здесь производится инициализация поля list динамически созданной структуры типа struct
                 * kool_list. Эту инициализацию можно пропустить, если следуим будет вызов add_list() или
                 * любая другая функция подобного рода, которая выполняет инициализацию полей next, prev
                 */
                 printf ("enter to and from:");
                 scanf ("%d %d", &tmp->to, &tmp->from);

                 /* добавить новый элемент 'tmp' в список элементов mylist */
                 list_add(&(tmp->list), &(mylist.list));
                 /* можно также использовать list_add_tail(), которая добавляет новые элементы
                  * в хвост списка
                  */
        }
        printf ("\n");

        /* теперь у нас есть циклически связанный список элементов типа struct kool_list.
        * теперь распечатаем весь список
        */

        /* list_for_each() - это макрос, реализующий цикл прохождения по элементам списка.
        * первый аргумент макроса используется как счётчик, или иными словами в цикле он
        * используется для указания на поле типа list_head текущего элемента списка.
        * второй аргумент - указатель на список. Макрос не манипулирует этим указателем.
        */
        printf ("traversing the list using list_for_each()\n");
        list_for_each(pos, &mylist.list) {
                 /* в этой строке: pos->next указывает на поле 'list' следующего элемента списка, а
                 * pos->prev  - на поле 'list' предыдущего элемента. Здесь элемент - это запись типа
                 * struct kool_list. Но нам нужен сам элемент, а не его поле 'list'! list_entry() позволяет
                 * получить указатель на сам элемент. Как это делается, см. ниже в части "Как
                 * это работает?".
                 */
                 tmp = list_entry (pos, struct kool_list, list);

                 /* в качестве аргументов макрос принимает указатель на struct list_head, в котором
                 * хранится текущая позиция списка, второй аргумент - тип пользовательской структуры,
                 * членом которой является поле, на которое указывает, первый аргумент и третий
                 * аргумент - имя этого поля (член типа struct list_head в пользовательской структуре).
                 * Макрос возвращает указатель на структуру, членом которой является первый аргумент.
                 * Или точнее говоря, на который указывает первый аргумент - pos.
                 * Например, выше list_entry() возвратит указатель на элемент типа struct kool_list,
                 * частью которого является pos
                 */

                 printf("to = %d from= %d\n", tmp->to, tmp->from);
        }
        printf ("\n");
         /* т.к. наш список цикличен, то его можно обойти и в обратном порядке.
          * всё, что для этого надо - заменить макрос 'list_for_each' на 'list_for_each_prev',
          * а всё остальное останется без изменений.
          *
          * Также можно использовать list_for_each_entry(), чтобы пройти по элементам списка
          * данного типа. Например:
          */
        printf ("traversing the list using list_for_each_entry()\n");
        list_for_each_entry(tmp, &mylist.list, list)
                printf ("to = %d from = %d\n", tmp->to, tmp->from);
                /* Как видно, здесь мы не используем макрос list_entry(), чтобы получить указатель
                 * на элемент списка типа struct kool_list. Этот указатель помещается в         переменную tmp
                 * на "полуавтомате" самим макросом list_for_each_entry() :)
                 */
        printf ("\n");

        /* теперь будем вежливыми и освободим память, захваченную под записи kool_list items.
         * т.к. мы будем удалять записи из списка с помощью list_del(), нам нужна защищённая
         * версия макроса list_for_each().
         * такая версия названа list_for_each_safe(). Этот макрос НЕОБХОДИМО использовать
         * для организации цикла, который предусматривает удаление или перемещение записей
         * из одного списка в другой.
         */
        printf ("deleting the list using list_for_each_safe()\n");
        list_for_each_safe(pos, q, &mylist.list){
                 tmp = list_entry(pos, struct kool_list, list);
                 printf ("freeing item to = %d from = %d\n", tmp->to, tmp->from);
                 list_del(pos);
                 free (tmp);
        }

        return 0;
}

Как это работает?

В общем, большая часть реализации довольна тривиальна, но тонко она выполнена. Тонкость заключается в том, чтобы получить адрес целой структуры по адресу члена, являющегося частью этой структуры (т.е., получить указатель на структуру, содержащую в себе элемент типа struct list_head). Этот трюк проделывает макрос list_entry(), как мы увидели ранее. Теперь попытаемся понять, как он это делает.

#define list_entry(ptr, type, member) \
        ((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member)))
Если опираться на ранее приведённый пример, то макрос будет развёрнут следующим образом:
        ((struct kool_list *)((char *)(pos) - (unsigned long)(&((struct kool_list *)0)->list)))
Это смущает многих, однако приём достаточно прост и является широко известным (см. вопрос 2.14 - англ.). Используя указатель на поле типа struct list_head в структуре данных, макрос list_entry() просто высчитывает адрес самой структуры. Чтобы выполнить такой расчёт, необходимо выяснить, где в структуре данных находится член типа list_head (смещение list_head). Затем просто вычисляется смещение члена типа list_head по отношению к указателю, который был передан макросу в качестве первого аргумента.

Следующий вопрос, как нам вычислить смещение элемента внутри структуры данных? Допустим, у нас есть структура struct foo_bar и нам надо найти смещение элемента boo, который является членом структуры. Сделаем это следующим образом:
        (unsigned long)(&((struct foo_bar *)0)->boo)
Возьмём нулевой адрес памяти и приведём его к указателю на нашу структуру - в данной случае, к указателю на struct foo_bar. Затем возьмём адрес поля, которое нас интересует. Это и будет смещение данного поля внутри структуры. Так как мы узнали абсолютное положение данного элемента в памяти, мы теперь можем с помощью этого элемента получить указатель на конкретный экземпляр целой структуры, опираясь на некоторые данные (например, аргумент pos в случае с макросом list_entry). Вот и всё. Чтобы лучше понять, что происходит, советую вам погонять этот код.
#include <stdio.h>
#include <stdlib.h>

struct foobar {
        unsigned int foo;
        char bar;
        char boo;
};

int main (int argc, char** argv)
{
        struct foobar tmp;

        printf ("address of &tmp is= %p\n\n", &tmp);
        printf ("address of tmp->foo= %p \t offset of tmp->foo= %lu\n", &tmp.foo,
                 (unsigned long) &((struct foobar *)0)->foo);
        printf ("address of tmp->bar= %p \t offset of tmp->bar= %lu\n", &tmp.bar,
                 (unsigned long) &((struct foobar *)0)->bar);
        printf ("address of tmp->boo= %p \t offset of tmp->boo= %lu\n\n", &tmp.boo,
                 (unsigned long) &((struct foobar *)0)->boo);

        printf ("computed address of &tmp using:\n");
        printf ("\taddress and offset of tmp->foo= %p\n",
                (struct foobar *) (((char *) &tmp.foo) - ((unsigned long) &((struct foobar *)0)->foo)));
        printf ("\taddress and offset of tmp->bar= %p\n",
                (struct foobar *) (((char *) &tmp.bar) - ((unsigned long) &((struct foobar *)0)->bar)));
        printf ("\taddress and offset of tmp->boo= %p\n",
                (struct foobar *) (((char *) &tmp.boo) - ((unsigned long) &((struct foobar *)0)->boo)));

        return 0;
}
Вывод приведённого куска кода:
address of &tmp is = 0xbfffed00

address of tmp->foo = 0xbfffed00   offset of tmp->foo= 0
address of tmp->bar = 0xbfffed04   offset of tmp->bar= 4
address of tmp->boo = 0xbfffed05   offset of tmp->boo= 5

computed address of &tmp using:
address and offset of tmp->foo = 0xbfffed00
address and offset of tmp->bar = 0xbfffed00
address and offset of tmp->boo = 0xbfffed00

См. также:

  • Код хэш-таблиц, организованных в виде списков.
  • Ещё код /sutff/src/

Оригинал статьи на английском лежит здесь.

Tuesday, March 18, 2008

Linux - syscalls. Системные вызовы в Linux.

О многом - молвил Морж,- пришла пора поговорить.
Л. Кэролл (Цитата по книге Б. Страустрапа)


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

Вместо введения.

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

Теория. Что такое системные вызовы?

Когда непосвящённым объясняют, что такое ПО (или ОС), то говорят обычно следующее: компьютер сам по себе - это железяка, а вот софт - это то, благодаря чему от этой железяки можно получить какую-то пользу. Грубовато, конечно, но в целом, в чём-то верно. Так же я сказал бы наверно об ОС и системных вызовах. На самом деле, в разных ОС системные вызовы могут быть реализованы по-разному, может розниться число этих самых вызовов, но так или иначе, в том или ином виде механизм системных вызовов есть в любой ОС. Каждый день пользователь явно или не явно работает с файлами. Он конечно может явно открыть файл для редактирования в своём любимом MS Word'е или Notepad'е, а может и просто запустить игрушку, исполняемый образ которой, к слову, тоже хранится в файле, который, в свою очередь, должен открыть и прочитать загрузчик исполняемых файлов. В свою очередь игрушка также может открывать и читать десятки файлов в процессе своей работы. Естественно, файлы можно не только читать, но и писать (не всегда, правда, но здесь речь не о разделении прав и дискретном доступе :) ). Всем этим заведует ядро (в микроядерных ОС ситуация может отличаться, но мы сейчас будем ненавязчиво клониться к объекту нашего обсуждения - Linux, поэтому проигнорируем этот момент). Само по себе порождение нового процесса - это также услуга, предоставляемая ядром ОС. Всё это замечательно, как и то, что современные процессоры работают на частотах гигагерцевых диапазонов и состоят из многих миллионов транзисторов, но что дальше? Да то, что если бы небыло некого механизма, с помощью которого пользовательские приложения могли выполнять некоторые достаточно обыденные и, в то же время, нужные вещи (на самом деле, эти тривиальные действия при любом раскладе выполняются не пользовательским приложением, а ядром ОС - авт.), то ОС была просто вещью в себе - абсолютно бесполезной или же напротив, каждое пользовательское приложение само по себе должно было бы стать операционной системой, чтобы самостоятельно обслуживать все свои нужды. Мило, не правда ли?

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

Как выглядит системный вызов и что из себя представляет? Ну, из того, что было сказано выше, становится ясно, что системный вызов - это подпрограмма ядра, имеющая соответственный вид. Те, кто имел опыт программирования под Win9х/DOS, наверняка помнят прерывание int 0x21 со всеми (или хотя бы некоторыми) его многочисленными функциями. Однако, есть одна небольшая особенность, касающаяся всех системных вызовов Unix. По соглашению функция, реализующая системный вызов, может принимать N аргументов или не принимать их вовсе, но так или иначе, функция должна возвращать значение типа int. Любое неотрицательное значение трактуется, как успешное выполнение функции системного вызова, а стало быть и самого системного вызова. Значение меньше нуля является признаком ошибки и одновременно содержит код ошибки (коды ошибок определяются в заголовках include/asm-generic/errno-base.h и include/asm-generic/errno.h). В Linux шлюзом для системных вызовов до недавнего времени было прерывание int 0x80, в то время, как в Windows (вплоть до версии XP Service Pack 2, если не ошибаюсь) таким шлюзом является прерывание 0x2e. Опять же, в ядре Linux, до недавнего времени все системные вызовы обрабатывались функцией system_call(). Однако, как выяснилось позднее, классический механизм обрабатки системных вызовов через шлюз 0x80 приводит к существенному падению производительности на процессорах Intel Pentium 4. Поэтому на смену классическому механизму пришёл метод виртуальных динамических разделяемых объектов (DSO - динамический разделяемый объектный файл. Не ручаюсь за правильный перевод, но DSO, это то, что пользователям Windows известно под названием DLL - динамически загружаемая и компонуемая библиотека) - VDSO. В чём отличие нового метода от классического? Для начала разберёмся с классическим методом, работающим через гейт 0x80.

Классический механизм обслуживания системных вызовов в Linux.

Прерывания в архитектуре х86.

Как было сказано выше, ранее для обслуживания запросов пользовательских приложений использовался шлюз 0x80 (int 0x80). Работа системы на базе архитектуры IA-32 управляется прерываниями (строго говоря, это касается вообще всех систем на базе x86). Когда происходит некое событие (новый тик таймера, какая-либо активность на некотором устройстве, ошибки - деление на ноль и пр.), генерируется прерывание. Прерывание (interrupt) названо так, потому что оно, как правило, прерывает нормальный ход выполнения кода. Прерывания принято подразделять на аппаратные и программные (hardware and software interrupts). Аппаратные прерывания - это прерывания, которые генерируются системными и периферическими устройствами. При возникновении необходимости какого-то устройства привлечь к себе внимание ядра ОС оно (устройство) генерирует сигнал на своей линии запроса прерывания (IRQ - Interrupt ReQuest line). Это приводит к тому, что на определённых входах процессора формируется соответствующий сигнал, на основе которого процессор и принимает решение прервать выполнение потока инструкций и передать управление на обработчик прерывания, который уже выясняет, что произошло и что необходимо сделать. Аппаратные прерывания асинхронны по своей природе. Это значит, что прерывание может возникнуть в любое время. Кроме периферийных устройств, сам процессор может вырабатывать прерывания (или, точнее, аппаратные исключения - Hardware Exceptions - например, уже упомянутое деление на ноль). Делается это с тем, чтобы уведомить ОС о возникновении нештатной ситуации, чтобы ОС могла предпринять некое действие в ответ на возникновение такой ситуации. После обработки прерывания процессор возвращается к выполнению прерванной программы. Прерывание может быть инициировано пользовательским приложением. Такое прерывание называют программным. Программные прерывания, в отличие от аппаратных, синхронны. Т.е., при вызове прерывания, вызвавший его код приостанавливается, пока прерывание не будет обслужено. При выходе из обработчика прерывания происходит возврат по дальнему адресу, сохранённому ранее (при вызове прерывания) в стеке, на следующую инструкцию после инструкции вызова прерывания (int). Обработчик прерывания - это резидентный (постоянно находящийся в памяти) участок кода. Как правило, это небольшая программа. Хотя, если мы будем говорить о ядре Linux, то там обработчик прерывания не всегда такой уж маленький. Обработчик прерывания определяется вектором. Вектор - это ни что иное, как адрес (сегмент и смещение) начала кода, который должен обрабатывать прерывания с данным индексом. Работа с прерываниями существенно отличается в реальном (Real Mode) и защищённом (Protected Mode) режиме работы процессора (напомню, что здесь и далее мы подразумеваем процессоры Intel и совместимые с ними). В реальном (незащищённом) режиме работы процессора обработчики прерываний определяются их векторами, которые хранятся всегда в начале памяти выборка нужного адреса из таблицы векторов происходит по индексу, который также является номером прерывания. Перезаписав вектор с определённым индексом можно назначить прерыванию свой собственный обработчик.

В защищённом режиме обработчики прерываний (шлюзы, гейты или вентили) больше не определяются с помощью таблицы векторов. Вместо этой таблицы используется таблица вентилей или, правильнее, таблица прерываний - IDT (Interrupt Descriptors Table). Эта таблица формируется ядром, а её адрес хранится в регистре процессора idtr. Данный регистр недоступен напрямую. Работа с ним возможна только при помощи инструкций lidt/sidt. Первая из них (lidt) загружает в регистр idtr значение, указанное в операнде и являющееся базовым адресом таблицы дексрипторов прерываний, вторая (sidt) - сохраняет адрес таблицы, находящийся в idtr, в указанный операнд. Так же, как происходит выборка информации о сегменте из таблицы дескрипторов по селектору, происходит и выборка дескриптора сегмента, обслуживающего прерывание в защищённом режиме. Защита памяти поддерживается процессорами Intel начиная с CPU i80286 (не совсем в том виде, в каком она представлена сейчас, хотя бы потому, что 286 был 16-разрядным процессором - поэтому Linux не может работать на этих процессорах) и i80386, а посему процессор самостоятельно производит все необходимые выборки и, стало быть, сильно углубляться во все тонкости защищённого режима (а именно в защищённом режиме работает Linux) мы не будем. К сожалению, ни время, ни возможности не позволяют нам остановиться надолго на механизме обработки прерываний в защищённом режиме. Да это и не было целью при написании данной статьи. Все сведения, приводимые здесь касательно работы процессоров семейства х86 довольно поверхностны и приводятся лишь для того, чтобы помочь немного лучше понять механизм работы системных вызовов ядра. Кое-что можно узнать непосредственно из кода ядра, хотя, для полного понимания происходящего, желательно всё же ознакомиться с принципами работы защищённого режима. Участок кода, который заполняет начальными значениями (но не устанавливает!) IDT, находится в arch/i386/kernel/head.S:

/*
 *  setup_idt
 *
 *  sets up a idt with 256 entries pointing to
 *  ignore_int, interrupt gates. It doesn't actually load
 *  idt - that can be done only after paging has been enabled
 *  and the kernel moved to PAGE_OFFSET. Interrupts
 *  are enabled elsewhere, when we can be relatively
 *  sure everything is ok.
 *
 *  Warning: %esi is live across this function.
 */
1.setup_idt:
2. lea ignore_int,%edx
3. movl $(__KERNEL_CS << 16),%eax
4. movw %dx,%ax  /* selector = 0x0010 = cs */
5. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */

6. lea idt_table,%edi
7. mov $256,%ecx
8.rp_sidt:
9. movl %eax,(%edi)
10. movl %edx,4(%edi)
11. addl $8,%edi
12. dec %ecx
13. jne rp_sidt

14..macro set_early_handler handler,trapno
15. lea \handler,%edx
16. movl $(__KERNEL_CS << 16),%eax
17. movw %dx,%ax
18. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */
19. lea idt_table,%edi
20. movl %eax,8*\trapno(%edi)
21. movl %edx,8*\trapno+4(%edi)
22..endm

23. set_early_handler handler=early_divide_err,trapno=0
24. set_early_handler handler=early_illegal_opcode,trapno=6
25. set_early_handler handler=early_protection_fault,trapno=13
26. set_early_handler handler=early_page_fault,trapno=14

28. ret
Несколько замечаний по коду: приведённый код написан на разновидности ассемблера AT&T, поэтому Ваше знание ассемблера в его привычной интеловской нотации может только сбить с толку. Самое основное отличие в порядке операндов. Если для интеловской нотации определён порядок - "аккумулятор" < "источник", то для ассемблера AT&T порядок прямой. Регистры процессора, как правило, должны иметь префикс "%", непосредственные значения (константы) префиксируются символом доллара "$". Синтаксис AT&T традиционно используется в Un*x-системах.

В приведённом примере в строках 2-4 устанавливается адрес обработчика всех прерываний по умолчанию. Обработчиком по умолчанию является функция ignore_int, которая ничего не делает. Наличие такой заглушки необходимо для корректной обработки всех прерываний на данном этапе, так как других ещё просто нет (правда, ловушки (traps) устанавливаются немного ниже по коду - о ловушках см. Intel Architecture Manual Reference или что-то подобное, здесь мы не будем касаться ловушек). В строке 5 устанавливается тип вентиля. В строке 6 мы загружаем в индексный регистр адрес нашей таблицы IDT. Таблица должна содержать 255 записей, по 8 байт каждая. В строках 8-13 мы заполняем всю таблицу одними и теми же значениями, установленными ранее в регистрах eax и edx - т.е., это вентиль прерывания, ссылающийся на обработчик ignore_int. Чуть ниже мы определяем макрос для установки ловушек (traps) - строки 14-22. В строках 23-26 используя вышеопределённый макрос мы устанавливаем ловушки для следующих исключений: early_divide_err - деление на ноль (0), early_illegal_opcode - неизвестная инструкция процессора (6), early_protection_fault - сбой защиты памяти (13), early_page_fault - отказ страничной трансляции (14). В скобках приведены номера "прерываний", генерируемые при возникновении соответствующей нештатной ситуации. Перед проверкой типа процессора в arch/i386/kernel/head.S таблица IDT устанавливается вызовом setup_idt:

/*
 * start system 32-bit setup. We need to re-do some of the things done
 * in 16-bit mode for the "real" operations.
 */
1. call setup_idt
...
2. call check_x87
3. lgdt early_gdt_descr
4. lidt idt_descr
После выяснения типа (со)процессора и проведения всех подготовительных действий в строках 3 и 4 мы загружаем таблицы GDT и IDT, которые будут использоваться на самых первых порах работы ядра.

Системные вызовы и int 0x80.

От прерываний вернёмся обратно к системным вызовам. Итак, что необходимо, чтобы обслужить процесс, который запрашивает какую-то услугу? Для начала, необходимо перейти из кольца 3 (уровень привелегий CPL=3) на наиболее привелигированный уровень 0 (Ring 0, CPL=0), т.к. код ядра расположен в сегменте с наивысшими привелегиями. Кроме того, необходим код обработчика, который обслужит процесс. Именно для этого и используется шлюз 0x80. Хотя системных вызовов довольно много, для всех них используется единая точка входа - int 0x80. Сам обработчик устанавливается при вызове функции arch/i386/kernel/traps.c::trap_init():

void __init trap_init(void)
{
        ...
        set_system_gate(SYSCALL_VECTOR,&system_call);
        ...
}
Нас в trap_init() больше всего интересует эта строка. В этом же файле выше можно посмотреть на код функции set_system_gate():
static void __init set_system_gate(unsigned int n, void *addr)
{
        _set_gate(n, DESCTYPE_TRAP | DESCTYPE_DPL3, addr, __KERNEL_CS);
}
Здесь видно, что вентиль для прерывания 0х80 (а именно это значение определено макросом SYSCALL_VECTOR - можете поверить наслово :) ) устанавливается как ловушка (trap) с уровнем привелегий DPL=3 (Ring 3), т.е. это прерывание будет отловлено при вызове из пространства пользователя. Проблема с переходом из Ring 3 в Ring 0 т.о. решена. Функция _set_gate() определена в заголовочном файле include/asm-i386/desc.h . Для особо любопытных ниже приведён код, без пространных объяснений, впрочем:
static inline void _set_gate(int gate, unsigned int type, void *addr, unsigned short seg)
{
        __u32 a, b;
        pack_gate(&a, &b, (unsigned long)addr, seg, type, 0);
        write_idt_entry(idt_table, gate, a, b);
}
Вернёмся к функции trap_init(). Она вызывается из функции start_kernel() в init/main.c . Если посмотреть на код trap_init(), то видно, что эта функция переписывает некоторые значения таблицы IDT заново - обработчики, которые использовались на ранних стадиях инициализации ядра (early_page_fault, early_divide_err, early_illegal_opcode, early_protection_fault), заменяются на те, что будут использоваться уже в процессе работы ядра. Итак, мы практически добрались до сути и уже знаем, что все системные вызовы обрабатываются единообразно - через шлюз int 0x80. В качестве обработчика для int 0x80, как опять же видно из приведённого выше куска кода arch/i386/kernel/traps.c::trap_init(), устанавливается функция system_call().

system_call().

Код функции system_call() находится в файле arch/i386/kernel/entry.S и выглядит следующим образом:

        # system call handler stub
ENTRY(system_call)
        RING0_INT_FRAME   # can't unwind into user space anyway
        pushl %eax   # save orig_eax
        CFI_ADJUST_CFA_OFFSET 4
        SAVE_ALL
        GET_THREAD_INFO(%ebp)
        # system call tracing in operation / emulation
        /* Note, _TIF_SECCOMP is bit number 8, and so it needs testw and not testb */
        testw $(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%ebp)
        jnz syscall_trace_entry
        cmpl $(nr_syscalls), %eax
        jae syscall_badsys
syscall_call:
        call *sys_call_table(,%eax,4)
        movl %eax,PT_EAX(%esp)  # store the return value
        ...
Код приведён не полностью. Как видно, сперва system_call() настраивает стек для работы в Ring 0, сохраняет значение, переданное ей через eax в стек, сохраняет все регистры также в стек, получает данные о вызывающем потоке и проверяет, не выходит ли переданное значение-номер системного вызова за пределы таблицы системных вызовов и затем, наконец, пользуясь значением, переданным в eax в качестве аргумента, system_call() осуществляет переход на настоящий обработчик системного вывода, исходя из того, на какой элемент таблицы ссылается индекс в eax. Теперь вспомните старую добрую таблицу векторов прерываний из реального режима. Ничего не напоминает? В реальности, конечно, всё несколько сложнее. В частности, системный вызов должен скопировать результаты из стека ядра в стек пользователя, передать код возврата и ещё некоторые вещи. В том случае, когда аргумент, указанный в eax не ссылается на существующий системный вызов (значение выходит за диапазон), происходит переход на метку syscall_badsys. Здесь в стек по смещению, по которому должно находиться значение eax, заносится значение -ENOSYS - системный вызов не реализован. На этом выполнение system_call() завершается.

Таблица системных вызовов находится в файле arch/i386/kernel/syscall_table.S и имеет достаточно простой вид:

ENTRY(sys_call_table)
        .long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */
        .long sys_exit
        .long sys_fork
        .long sys_read
        .long sys_write
        .long sys_open  /* 5 */
        .long sys_close
        .long sys_waitpid
        .long sys_creat
        ...
Иными словами, вся таблица являет собой ничто иное, как массив адресов функций, расположенных в порядке следования номеров системных вызовов, которые эти функции обслуживают. Таблица - обычный массив двойных машинных слов (или 32-разрядных слов - кому как больше нравится). Код части функций, обслуживающих системные вызовы, находится в платформно-зависимой части - arch/i386/kernel/sys_i386.c, а часть, не зависящая от платформы - в kernel/sys.c .

Вот так обстоит дело с системными вызовами и вентилем 0x80.

Новый механизм обработки системных вызовов в Linux. sysenter/sysexit.

Как упоминалось, достаточно быстро выяснилось, что использование традиционного способа обработки системных вызовов на основе гейта 0х80 пиводит к потере производительности на процессорах Intel Pentium 4. Поэтому Линус Торвальдс реализовал в ядре новый механизм, основанный на инструкциях sysenter/sysexit и призванный повысить производительность ядра на машинах, оснащённых процессором Pentium II и выше (именно с Pentium II+ процессоры Intel поддерживают упомянутые инструкции sysenter/sysexit). В чём суть нового механизма? Как ни странно, но суть осталась та же. Изменилось исполнение. Согласно документации Intel инструкция sysenter является частью механизма "быстрых системных вызовов". В частности, эта инструкция оптимизирована для быстрого перехода с одного уровня привелегий на другой. Если точнее, то она ускоряет переход в кольцо 0 (Ring 0, CPL=0). При этом, операционная система должна подготовить процессор к использовании инструкции sysenter. Такая настройка осуществляется единожды при загрузке и инициализации ядра ОС. При вызове sysenter устанавливает регистры процессора согласно машинно-зависимых регистров, ранее установленных ОС. В частности, устанавливаются сегментный регистр и регистр указателя инструкций - cs:eip, а также сегмент стека и указатель вершины стека - ss, esp. Переход на новый сегмент кода и смещение осуществляется из кольца 3 в 0.

Инструкция sysexit выполняют обратные действия. Она производит быстрый переход с уровня привелегий 0 на 3-й (CPL=3). При этом регистр сегмента кода устанавливается в 16 + значение сегмента cs, сохранённое в машинно-зависимом регистре процессора. В регистр eip заносится содержимое регистра edx. В ss заносится сумма 24 и значения cs, занесённое ОС ранее в машинно-зависимый регистр процессора при подготовке контекста для работы инструкции sysenter. В esp заносится содержимое регистра ecx. Значения, необходимые для работы инструкций sysenter/sysexit хранятся по следующим адресам:

  1. SYSENTER_CS_MSR    0х174 - сегмент кода, куда заносится значение сегмента, в котором находится код обработчика системного вызова.
  2. SYSENTER_ESP_MSR   0х175 - указатель на вершину стека для обработчика системного вызова.
  3. SYSENTER_EIP_MSR    0х176 - указатель на смещение внутри сегмента кода. Указывает на начало кода обработчика системных вызовов.
Данные адреса ссылаются на модельно-зависимые регистры, которые не имеют имён. Значения записываются в модельно зависимые регистры с помощью инструкции wrmsr, при этом edx:eax должны содержать страшую и младшую части 64-битного машинного слова соответственно, а в ecx должен быть занесён адрес ригистра, в который будет произведена запись. В Linux адреса модельно-зависимых регистров определяются в заголовочном файле include/asm-i368/msr-index.h следующим образом (до версии 2.6.22 как минимум они определялись в заголовочном файле include/asm-i386/msr.h, напомню, что мы рассматриваем механизм системных вызовов на примере ядра Linux 2.6.22):
#define MSR_IA32_SYSENTER_CS  0x00000174
#define MSR_IA32_SYSENTER_ESP  0x00000175
#define MSR_IA32_SYSENTER_EIP  0x00000176
Код ядра, ответственный за установку модельно-зависимых регистров, находится в файле arch/i386/sysenter.c и выглядит следующим образом:
1.  void enable_sep_cpu(void)
{
2.        int cpu = get_cpu();
3.        struct tss_struct *tss = &per_cpu(init_tss, cpu);

4.        if (!boot_cpu_has(X86_FEATURE_SEP)) {
5.                put_cpu();
6.                return;
        }

7.        tss->x86_tss.ss1 = __KERNEL_CS;
8.        tss->x86_tss.esp1 = sizeof(struct tss_struct) + (unsigned long) tss;
9.        wrmsr(MSR_IA32_SYSENTER_CS, __KERNEL_CS, 0);
10.        wrmsr(MSR_IA32_SYSENTER_ESP, tss->x86_tss.esp1, 0);
11.        wrmsr(MSR_IA32_SYSENTER_EIP, (unsigned long) sysenter_entry, 0);
12.        put_cpu(); 
}
Здесь в переменную tss мы получаем адрес структуры, описывающей сегмент состояния задачи. TSS (Task State Segment) используется для описания контекста задачи и является частью механизма аппаратной поддержки многозадачности для архитектуры x86. Однако, Linux практически не использует аппаратное переключение контекста задач. Согласно документации Intel переключение на другую задачу производится либо путём выполнения инструкции межсегментного перехода (jmp или call), ссылающейся на сегмент TSS, либо на дескриптор вентиля задачи в GDT (LDT). Специальный регистр процессора, невидимый для программиста - TR (Task Register - регистр задачи) содержит селектор дескриптора задачи. При загрузке этого регистра также загружаются программно-невидимые регистры базы и лимита, связанные с TR.

Несмотря на то, что Linux не использует аппаратное переключение контекстов задач, ядро вынуждено отводить запись TSS для каждого процессора, установленного в системе. Это связано с тем, что когда процессор переключается из пользовательского режима в режим ядра, он извлекает из TSS адрес стека ядра. Кроме того, TSS необходим для управления доступом к портам ввода/вывода. TSS содержит карту прав доступа к портам. На основе этой карты становится возможным осуществлять контроль доступа к портам для каждого процесса, использующего инструкции in/out. Здесь tss->x86_tss.esp1 указывает на стек ядра. __KERNEL_CS естественно указывает на сегмент кода ядра. В качестве смещения-eip указывается адрес функции sysenter_entry().

Функция sysenter_entry() определена в файле arch/i386/kernel/entry.S и имеет такой вид:

/* SYSENTER_RETURN points to after the "sysenter" instruction in
   the vsyscall page.  See vsyscall-sysentry.S, which defines the symbol.  */

 # sysenter call handler stub
ENTRY(sysenter_entry)
        CFI_STARTPROC simple
        CFI_SIGNAL_FRAME
        CFI_DEF_CFA esp, 0
        CFI_REGISTER esp, ebp
        movl TSS_sysenter_esp0(%esp),%esp
sysenter_past_esp:
        /*
         * No need to follow this irqs on/off section: the syscall
         * disabled irqs and here we enable it straight after entry:
         */
        ENABLE_INTERRUPTS(CLBR_NONE)
        pushl $(__USER_DS)
        CFI_ADJUST_CFA_OFFSET 4
        /*CFI_REL_OFFSET ss, 0*/
        pushl %ebp
        CFI_ADJUST_CFA_OFFSET 4
        CFI_REL_OFFSET esp, 0
        pushfl
        CFI_ADJUST_CFA_OFFSET 4
        pushl $(__USER_CS)
        CFI_ADJUST_CFA_OFFSET 4
        /*CFI_REL_OFFSET cs, 0*/
        /*
         * Push current_thread_info()->sysenter_return to the stack.
         * A tiny bit of offset fixup is necessary - 4*4 means the 4 words
         * pushed above; +8 corresponds to copy_thread's esp0 setting.
         */
        pushl (TI_sysenter_return-THREAD_SIZE+8+4*4)(%esp)
        CFI_ADJUST_CFA_OFFSET 4
        CFI_REL_OFFSET eip, 0

        /*
         * Load the potential sixth argument from user stack.
         * Careful about security.
         */
         cmpl $__PAGE_OFFSET-3,%ebp
         jae syscall_fault
1:        movl (%ebp),%ebp
        .section __ex_table,"a"
        .align 4
        .long 1b,syscall_fault
        .previous

        pushl %eax
        CFI_ADJUST_CFA_OFFSET 4
        SAVE_ALL
        GET_THREAD_INFO(%ebp)

        /* Note, _TIF_SECCOMP is bit number 8, and so it needs testw and not testb */
        testw $(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%ebp)
        jnz syscall_trace_entry
        cmpl $(nr_syscalls), %eax
        jae syscall_badsys
        call *sys_call_table(,%eax,4)
        movl %eax,PT_EAX(%esp)
        DISABLE_INTERRUPTS(CLBR_ANY)
        TRACE_IRQS_OFF
        movl TI_flags(%ebp), %ecx
        testw $_TIF_ALLWORK_MASK, %cx
        jne syscall_exit_work
        /* if something modifies registers it must also disable sysexit */
        movl PT_EIP(%esp), %edx
        movl PT_OLDESP(%esp), %ecx
        xorl %ebp,%ebp
        TRACE_IRQS_ON
1:      mov  PT_FS(%esp), %fs
        ENABLE_INTERRUPTS_SYSEXIT
        CFI_ENDPROC
        .pushsection .fixup,"ax"
2:        movl $0,PT_FS(%esp)
        jmp 1b
        .section __ex_table,"a"
        .align 4
        .long 1b,2b
        .popsection
ENDPROC(sysenter_entry)
Как и в случае с system_call() основная работа выполняется в строке call *sys_call_table(,%eax,4). Здесь вызывается конкретный обработчик системного вызова. Итак, видно, что принципиально изменилось мало. То обстоятельство, что вектор прерывания теперь забит в железо и процессор помогает нам быстрее перейти с одного уровня привелегий на другой меняет лишь некоторые детали исполнения при прежнем содержании. Правда, на этом изменения не заканчиваются. Вспомните, с чего начиналось повествование. В самом начале я упоминал уже о виртуальных разделяемых объектах. Так вот, если раньше реализация системного вызова, скажем, из системной библиотеки libc выглядела, как вызов прерывания (при том, что библиотека брала некоторые функции на себя, чтобы сократить число переключений контекстов), то теперь благодаря VDSO системный вызов может быть сделан практически напрямую, без участия libc. Он и ранее мог быть осуществлён напрямую, опять же, как прерывание. Но теперь вызов можно затребовать, как обычную функцию, экспортируемую из динамически компонуемой библиотеки (DSO). При загрузке ядро определяет, какой механизм должен и может быть использован для данной платформы. В зависимости от обстоятельств ядро устанавливает точку входа в функцию, выполняющую системный вызов. Далее, функция экспортируется в пользовательское пространство ввиде библиотеки linux-gate.so.1 . Библиотека linux-gate.so.1 физически не существует на диске. Она, если можно так выразиться, эмулируется ядром и существует ровно столько, сколько работает система. Если выполнить останов системы, подмонтировать корневую ФС из другой системы, то Вы не найдёте на корневой ФС остановленной системы этот файл. Собственно, Вы не сможете его найти даже на работающей системе. Физически его просто нет. Именно поэтому linux-gate.so.1 - это нечто иное, как VDSO - т.е. Virtual Dynamically Shared Object. Ядро отображает эмулируемую таким образом динамическую библиотеку в адресное пространство каждого процесса. Убедиться в этом несложно, если выполнить следующую команду:
f0x@devel0:~$ cat /proc/self/maps
08048000-0804c000 r-xp 00000000 08:01 46         /bin/cat
0804c000-0804d000 rw-p 00003000 08:01 46         /bin/cat
0804d000-0806e000 rw-p 0804d000 00:00 0          [heap]
...
b7fdf000-b7fe1000 rw-p 00019000 08:01 2066       /lib/ld-2.5.so
bffd2000-bffe8000 rw-p bffd2000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]
Здесь самая последняя строка и есть интересующий нас объект:
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]
Из приведённого примера видно, что объект занимает в памяти ровно одну страницу - 4096 байт, практически на задворках адресного пространства. Проведём ещё один эксперимент:
f0x@devel0:~$ ldd `which cat`
        linux-gate.so.1 =>  (0xffffe000)
        libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e87000)
        /lib/ld-linux.so.2 (0xb7fdf000)
f0x@devel0:~$ ldd `which gcc`
        linux-gate.so.1 =>  (0xffffe000)
        libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e3c000)
        /lib/ld-linux.so.2 (0xb7f94000)
f0x@devel0:~$
Здесь мы просто навскидку взяли два приложения. Видно, что библиотека отображается в адресное пространство процесса по одному и тому же постоянному адресу - 0xffffe000. Теперь попробуем посмотреть, что же такое хранится на этой странице памяти на самом деле...

Сделать дамп страницы памяти, где хранится разделяемый код VDSO, можно с помощью следующей программы:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main () {
        char* vdso = 0xffffe000;
        char* buffer;
        FILE* f;

        buffer = malloc (4096);
        if (!buffer)
                exit (1);
        memcpy (buffer, vdso, 4096);

        if (!(f = fopen ("test.dump", "w+b"))) {
                free (buffer);
                exit (1);
        }

        fwrite (buffer, 4096, 1, f);
        fclose (f);
        free (buffer);

        return 0;
}
Строго говоря, раньше это можно было сделать проще, с помощью команды dd if=/proc/self/mem of=test.dump bs=4096 skip=1048574 count=1, но ядра начиная с версии 2.6.22 или, быть может, даже более ранней, больше не отображают память процесса в файл /proc/`pid`/mem. Этот файл, сохранён, очевидно, для совместимости, но не содержит более информации.

Скомпилируем и прогоним приведённую программу. Попробуем дизассемблировать полученный код:

f0x@devel0:~/tmp$ objdump --disassemble ./test.dump

./test.dump:     file format elf32-i386

Disassembly of section .text:

ffffe400 <__kernel_vsyscall>:
ffffe400:       51                      push   %ecx
ffffe401:       52                      push   %edx
ffffe402:       55                      push   %ebp
ffffe403:       89 e5                   mov    %esp,%ebp
ffffe405:       0f 34                   sysenter
...
ffffe40e:       eb f3                   jmp    ffffe403 <__kernel_vsyscall+0x3>
ffffe410:       5d                      pop    %ebp
ffffe411:       5a                      pop    %edx
ffffe412:       59                      pop    %ecx
ffffe413:       c3                      ret
...

f0x@devel0:~/tmp$
Вот он наш шлюз для системных вызовов, весь, как на ладони. Процесс (либо, системная библиотека libc), вызывая функцию __kernel_vsyscall попадает на адрес 0хffffe400 (в нашем случае). Далее, __kernel_vsyscall сохраняет в стеке пользовательского процесса содержимое регистров ecx, edx, ebp, О назначении регистров ecx и edx мы уже говорили ранее, в ebp используется позже для восстановления стека пользователя. Выполняется инструкция sysenter, "перехват прерывания" и, как следствие, очередной переход на sysenter_entry (см. выше). Инструкция jmp по адресу 0xffffe40e вставлена для перезапуска системного вызова с числом 6 аргументами (см. http://lkml.org/lkml/2002/12/18/). Код, размещаемый на странице, находится в файле arch/i386/kernel/vsyscall-enter.S (или arch/i386/kernel/vsyscall-int80.S для ловушки 0x80). Хотя я и нашёл, что адрес функции __kernel_vsyscall постоянный, но есть мнение, что это не так. Обычно, положение точки входа в __kernel_vsyscall() можно найти по вектору ELF-auxv используя параметр AT_SYSINFO. Вектор ELF-auxv содержит информацию, передаваемую процессу через стек при запуске и содержит различную информацию, нужную в процессе работы программы. Этот вектор в частности содержит переменные окружения процесса, аргументы, и проч..

Вот небольшой пример на С, как можно обратиться к функции __kernel_vsyscall напрямую:

#include <stdio.h>

int pid;

int main ()
{
        __asm (
                "movl $20, %eax \n"
                "call *%gs:0x10 \n"
                "movl %eax, pid \n"
        );

        printf ("pid: %d\n", pid);
        return 0;
}
Данный пример взят со страницы Manu Garg, http://www.manugarg.com. Итак, в приведённом примере мы делаем системный вызов getpid() (номер 20 или иначе __NR_getpid). Чтобы не лазить по стеку процесса в поисках переменной AT_SYSINFO воспользуемся тем обстоятельством, что системная библиотека libc.so при загрузке копирует значение переменной AT_SYSINFO в блок управления потоком (TCB - Thread Control Block). На этот блок информации, как правило, ссылается селектор в gs. Предполагаем, что по смещению 0х10 находится искомый параметр и делаем вызов по адресу, хранящемуся в %gs:$0x10.

Итоги.

На самом деле, практически, особого прироста производительности даже при поддержке на данной платформе FSCF (Fast System Call Facility) добиться не всегда возможно. Проблема в том, что так или иначе, процесс редко обращается напрямую к ядру. И для этого есть свои веские причины. Использование библиотеки libc позволяет гарантировать переносимость программы вне зависимости от версии ядра. И именно через стандартную системную библиотеку идёт большинство системных вызовов. Если даже Вы соберёте и установите самое последнее ядро, собранное для платформы, поддерживающей FSCF, это ещё не гарантия прироста производительности. Дело в том, что Ваша системная библиотека libc.so будет попрежнему использовать int 0x80 и справиться с этим можно лишь пересобрав glibc. Поддерживается ли в glibc вообще интерфейс VDSO и __kernel_vsyscall, я, честно признаться, на данный момент ответить затрудняюсь.

Ссылки.

[1] Manu Garg's page, http://www.manugarg.com
[2] Scatter/Gather thoughts by Johan Petersson, http://www.trilithium.com/johan/2005/08/linux-gate/
[3] Старый добрый Understanding the Linux kernel Куда же без него :)
[4] Ну и конечно же, исходные коды Linux (2.6.22)

2008-03-18. Further revisions are possible...

ПОСЕТИТЕЛИ

free counters