Литература: W. Stallings. Operating systems. Bach M. J. The design of the unix operating system



Скачать 229.54 Kb.
Pdf просмотр
Дата18.11.2016
Размер229.54 Kb.
Просмотров406
Скачиваний0
ТипЛитература

ОС Linux. Программирование ядра
сост. Э.О. Шишкин
Литература:
W. Stallings. Operating systems.
Bach M.J.The design of the UNIX operating system.
Uresh Vahalia. Unix Internals: the New Frontiers.
Б.Керниган, Д. Ричи. Язык программирования С.
Cтандарт ISO C99.
Компилятор GCC, info (1) gcc
Исходники ядра ОС Линукс 2.6.21-mm1
www.kernel.org
Документация: linux/Documentation
Навигация по коду ядра:
make TAGS
Поиск определений по образцу в текстовом редакторе xemacs:
M -. M-, M-Shift-*
Поиск строк в файлах директории dirmane
, включающих образец function_name
:
# find dirname -name "*.[ch]" | xargs grep -n "function_name"
Электронный справочник man.
Утилита apropos
(1).
Глава 1.
Обзор ОС Линукс
1. Процесс. Виртуальное адресное пространство процесса. Структуризация ВАП. Физические адреса.
Трансляция витруальных адресов. Разделяемые сегменты памяти. MMU. Ситуация отсутствия страницы.
2. Многозадачность.
3. Операционная система, система, дистрибутивы.
4. Компоненты ядра ОС.
5. Ядро и пользовательские процессы. Прикладные библиотеки и интерфейс системных вызовов.
6. Прерывания. Асинхронный режим. Обработчики прерываний. Контекст прерывания.
7. Три типа действий выполняемых процессором в любой момент времени в ОС Линукс.
8. Схема взаимодействия между прикладными процессами, ядром, и аппаратным обеспечением.
9. Более пристальный взгляд на ядро Линукс (макроядра и микроядра, модули, SMP, преемптивное планирование, поддержка потоков, открытый исходный код на C).
10. Использование языковых расширений С (ISO C99, GCC). Обзор некоторых встроенных функций GCC.
Начнем с основной абстракции теории операционных систем – процесса. Данное ниже определение мы впоследствии уточним с учетом многозадачности.
Процесс - это программа, выполняющаяся на какой-либо целевой платформе.
Каждый процесс имеет собственную совокупность виртуальных адресов (ВА), виртуальное адресное пространство (ВАП). Такие адреса вырабатываются транслятором, который переводит программу на машинный язык. В зависимости от ОС бывают разные способы структуризации ВАП:
1. плоские: {i | 0 <= i <= 2^N - 1}, N зависит от целевой архитектуры (N = 32 для x86).

2. сегментированные {(i, j )} i - номер сегмента, j - смещение в сегменте.
В ОС Линукс используется плоское ВАП.
Физические адреса (ФА) - это номера ячеек оперативной памяти, т.е. реальные аппаратные адреса,
доступные в системе. Например, если в системе имеется 64Mb памяти, то в ней допустимые физические адреса могут находиться в пределах от 0 до 0x3fffffff. Т.о. адресуются байты информации, которые представлены набором транзисторов в микросхемах оперативной памяти (SIMM, DIMM и т.д.).
0 0xffffffff 0 0x3fffffff
Виртуальные адреса (x86) физические адреса
При выполнении какой-либо программы ее виртуальные адреса транслируются в физические. В
многозадачной ОС на одной и той же машине может быть запущено много процессов, при этом, вообще говоря, одни и те же ВА разных процессов транслируются в разные ФА, но иногда бывает необходимо
(хотя бы из экономических соображений), чтобы несколько процессов разделяли общие данные или инструкции (коды). При этом соответствующие участки ВАП (т.н. разделяемые сегменты памяти)
отображаются на один и тот же участок ФП.
Трансляция ВА <-> ФА находится под совместным контролем аппаратного модуля управления памятью
MMU и специальной программного обеспечения, называемого ядром ОС. Виртуальные (логические) и физические адреса подразделяются на страницы (логические и физические). Ядро сообщает MMU, какие
ЛС должны быть отображены на конкретные ФС. Когда не удается выполнить преобразование адреса
(например, поступивший ЛА оказался недопустим, или логическая страница больше не имеет физического аналога, и должна быть загружена в память), MMU сообщает об этом ядру (exception, ситуация отсутствия страницы), которое обрабатывает эту ситуацию должным образом (вызывает обработчик этой исключительной ситуации – в ОС Линукс это функция do_page_fault()
).
UNIX - многозадачная ОС (или система с виртуальными процессорами), т.е. позволяет запустить на выполнение одновременно несколько программ, скомпилированных для конкретной целевой платформы.
Отсутствие многозадачности (режим кассы):
Поддержка многозадачности (режим с виртуальными процессорами):
Т.е. в системе с поддержкой многозадачности каждая программа должна получить свой виртуальный процессор. Распределением процессорных ресурсов также занимается ядро ОС.
CPU
task1
task2
task3
task4
task1
task2
task3
task4
CPU1
CPU2
scheduler

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

ядро;

bootloader (grub, lilo);

командный интерпретатор, shell (bash)

системные утилиты;

др. интерфейсы пользователя.
Под системой будем понимать ОС и все пользовательские программы, которые работают под ее управлением.
ОС Линукс поставляется вместе с конкретным дистрибутивом, который также содержит программное обеспечение (библиотека libc и др прикладные программы), разработаное в рамках проекта GNU,
возглавляемого R.Stallman. Разработку ядра ОС Линукс серии 2.6 возглавляет Linus Torvalds.
В основном, нашей темой будет ядро ОС Линукс. Типичные его компоненты:

обработчик прерываний

планировщик (распределяет процессорные время между процессами)

система управления памятью (управляет адресным пространством процессов)

системные службы (сетевая подсистема, подсистема межпроцессного взаимодействия IPC)

файловая система
Ядро - привилегированная программа: имеет доступ ко всем областям защищенной памяти и _полный доступ к аппаратному обеспечению. В то время, как пользовательским программам доступно лишь некоторое подмножество машинных ресурсов, они не могут выполнять некоторые системные функции,
напрямую обращаться к аппаратуре, и др. недозволенные вещи (к примеру, если пользовательская программа пытается манипулировать регистром состояния процессора, то будет возвращена ошибка).
Подобное обращение происходит только при посредничестве ядра. Иначе, ядро может рассматриваться как "дисциплинирующий" набор методов взаимодействия с аппаратурой, ибо любой пользователь рассматривается как потенциальная опасность для системы.
Прикладные библиотеки и интерфейс системных вызовов
Прикладные программы, работающие в системе могут взаимодействовать с ядром (и с аппаратным обеспечением при посредничестве ядра) при помощи интерфейса системных вызовов (ИСВ) по следующей схеме:
Программа -> API -> Библиотека -> ИСВ -> Ядро
Т.о. прикладные программы обычно вызывают функции различных библиотек (чтобы не изобретать велосипед) таких, как например libc, которые в свою очередь обращаются к интерфейсу системных вызовов, для того, чтобы отдать распоряжение ядру выполнить какие-либо действия от их имени.
Правая часть цепочки (Библиотека -> ИСВ -> Ядро) не обязательно имеет место: пользовательская программа может вообще не обращаться к системным функциям ядра. Иногда библиотечные функция используют весьма тонкую прослойку над ИСВ (как, например, open(), а иногда вообще их не используют
(как, например, strcpy()
). Важно отметить, что ядро Линукс не использует libc (не линкуется с ней), а использует свои собственные аналоги strcpy()
, и др. функций.
Отсутствует аналог функции printf(),
вместо этого есть printk()
- форматирование и буферизация данных с последующим выводом содержимого буфера на системную консоль демоном syslogd. Важное отличиие от printf
- это то, что можно задавать уровень важности сообщений, которые будут, или не будут печататься, в соответствии со значением приоритета, который выставлен в данный момент в системе. Системный вызов имеет две компонетны: первая компонента – это библиотечная функция,
использующая программное прерывание со специальным номером (
0x80
). Вторая компонента - это соответствующая функция ядра ОС, запускаемая в ответ на это прерывание.
Пример (печать строки “Hello World” в stdout (файловый дескриптор 1), asm, x86, AT&T syntax):
movl
$4,%eax
// номер системного вызова в таблице ядра movl
$STDOUT,%ebx
//
(1)
значение файлового дескриптора movl
$hello,%ecx // указатель на строку movl
$12,%edx
// число символов int
$0x80 // программное прерывание
Здесь номер системного вызова (4) и другие аргументы помещается в регистры (этим занимается библиотечная функция write
()). Обработка такого программного прерывания ядром включает выполнение функции под номером 4 в иерархии системных вызовов ядра, а именно ssize_t sys_write(unsigned int fd, const char * buf, size_t count);
Утилита strace. Отслеживает системные вызовы, сделанные каким-либо процессом, а также сигналы,
полученные каким-либо процессом.
Стандарты. POSIX
Распространенность различных реализаций UNIX привела к появлению проблем совместимости.
Существование отличий было заложено изначально за счет наличия двух веток развития (System V, AT&T
и BSD, создаваемого в Беркли). Различия были как обусловленные дизайном ядра, так и на уровне интерфейса программирования (в основном, последние).
Это привело к появлению стандартов, описывающих взаимодействие между программами и операционной системой. Большинство производителей признало три стандарта (SVID of AT&T, POSIX of IEEE, X/Open
Portability guide of X/Open group).
POSIX-1 (POSIX 1003.1):
http://www.unix.org/single_unix_specification/
Эти стандарты не описывают, каким именно образом этот интерфейс должен быть реализован, оставив на компетенции разработчиков ОС, на каком уровне они будут поддерживать рекомендации стандарта (в ядре, или через прикладные библиотеки, или комбинированным образом)
POSIX расшифровывается как portable operating system based on UNIX. Однако, POSIX-совместимая операционная система, вообще говоря, не обязательно есть ОС семейства UNIX. Более подробно о т.н.
UNIX-way, который не задокументирован в POSIX можно прочитать в соответствующей литературе.
Взаимодействие с аппаратурой
В компетенцию ядра входит управление аппаратным обеспечением. Практически все платформы, в т.ч. и те, на которых работает ОС Линукс, используют прерывания, которые прерывают работу ядра в асинхронном режиме (т.е. заранее не известно, в какой момент времени произойдет прерывание и в каком состоянии будет находится система в тот момент времени). Каждому типу прерывания соответствует номер N, который используется ядром, для выполнения соответствующего обработчика (interrupt handler),
который обрабатывает прерывание и отправляет на него ответ. Пример: ввод символов с клавиатуры
(контроллер клавиатуры генерирует прерывание, дававя знать, что в буфере клавиатуры есть данные, ядро определяет номер прерывания и запускает обработчик, который обрабатывает эти данные и сигнализирует контроллеру клавиатуры, что он готов к приему новых данных. Для обеспечения синхронизации ядро может запрещать прерывания (или все, или только прерывания определенного типа). В ОС Линукс для быстрого реагирования на прерывания обработчики прерываний выполняются не в контексте процесса, а в специальном контексте прерывания.
В ОС Линукс процессор выполняет один из трех типов действий:

Работа в режиме ядра в контексте некоторого процесса (работа от имени этого процесса)


Работа в режиме ядра в контексте прерывания по обработке некоторого прерывания, не связанного с процессом

Работа в режиме задачи (по выполнению пользовательской задачи)
Здесь режим == пространство. ВАП процесса делится на пространство пользователя и пространство ядра. пр. пользователя пр. ядра
|<---------------------------------------------------------->|<---------------->
******************************************************
0 0xbfffffff 0xffffffff
Такое деление зависит от архитектуры. Для архитектуры x86 часть ВАП включающая адреса 0 – 0xbfffffff называется пространством пользователя. Часть ВАП процесса, начинающаяся с адреса 0xC0000000
называется пространством ядра (не путать с виртуальным адресным пространством ядра!) и пользовательской задаче не доступна. Здесь хранится стек ядра при выполнении какого-либо системного вызова от имени процесса. В свою очередь, ядро имеет доступ к пространству пользователя посредством макросов get_user(), put_user()
Более пристальный взгляд на ядро Линукс
1. Ядро Линукс - это макроядро (монолитный статический бинарный файл, существующий в виде большого исполняемого образа, который выполняется_один_ раз и использует _одну_ копию ВАП). В
системах с микроядром в отличие от макроядра большая часть функционакльности ядра реализована в виде отдельных процессов, которые выполняются в привилегированном режиме и взаимодействуют друг с другом посредством сообщений.поддержка динамической загрузки/выгрузки модулей.
2. поддержка SMP (симметрической мультипроцессорной обработки)
3. вытесняющее планирование заданий (вытеснение заданий, даже тех, которые работают в режиме ядра). Это свойство имеет значение для приложений реального времени. Вытесняющим ядром обладают только ОС Solaris и IRIX)
4. Поддержка многопоточности на уровне процессов (каждому потоку предоставляется свой виртуальный процессор - пример с кассой)
программа1
программа2
программа3
Интерфейс системных вызовов
Подсистемы ядра
Драйверы устройств
Аппаратное обеспечение

5. открытый исходный код, написанный на смеси языков С и asm.
Яыковые расширения С для программирования ядра Линукс
Ядро написано не на чистом языке С в соответствии со стандартом ANSI, а на том, что называется языковым расширением gcc. Кроме того, используется стандарт ISO C99
Встроенный ассемблер:
В частях ядра, специфичных для каой-либо платформы, а также тех, где быстродействие критично,
используется встраивание ассемблерных инструкций в обычные функции языка C при помощи директивы asm().
Функции с подстановкой тела (inline functions)
Определяются при помощи ключевых слов static inline.
Исполняемый код такой функции вставляется во все места программы, где указан ее вызов. Делается это с целью избежания обычных затрат на вызов и возврат из функции, сохранение и восстановление регистров,
повышение уровня оптимизации. Обратная сторона медали: увеличение объема кода, т.е. используемой памяти, в связи с этим уменьшение эффективности использования процессорного кэша инструкций.
Обычно подстановека используется для небольших функций в коде критичном ко времени выполнения.
Пример: static inline int function_name (long x)
Декларация такой функции должна быть описана перед любым ее вызовом, иначе подстановка не будет осуществлена.
Базовые типы используемые в ядре
В ядре используются только два базовых целочисленых типа данных: char, int.
Типы float и double не используются.
Квалификатор "двойное целое"
Определяется как long long long long int
- знаковое (
LL для констант)
unsigned long long int
- беззнаковое (
ULL
для констант)
Типы данных ядра с фиксированым размером занимаемой памяти
В ядре есть специальные целочисленые типы данных
__uXX, __sXX
(XX = 8, 16, 32, 64)
Это типы данных (соответственно, беззнаковые и знаковые), обьекты которых занимают фиксированный размер (XX bit) в памяти независимо от архитектуры.
Пример:
__u64
Определены в заголовочном файле linux/include/asm/types.h, который генерируется в процессе компиляции в зависимости от целевой архитектуры (Например, для x86 тип __u64 определяется как unsigned long long).
Составной оператор
Такой оператор, заключенный в скобки, может появляться в качестве выражения в GNU C. Это позволяет использовать циклы, операторы выбора и локальные переменные внутри выражения.
Составной оператор - это последовательность операторов, заключенная в фигурные скобки; в этой конструкции скобки окружают фигурные скобки.
Пример: вычисление абсолютной величины foo():

({ int y = foo (); int z;
if (y > 0) z = y;
else z = - y;
z; })
Последней вещью в составном операторе должно быть выражение, после которого следует точка с запятой; значение этого подвыражения служит значением всей конструкции. (Если вы используете какой- нибудь другой вид оператора последним внутри фигурных скобок, конструкция имеет тип void, и таким образом не имеет значения.)
Это свойство особенно полезно, чтобы делать макроопределения "надежными" (такими, что они вычисляют каждый операнд ровно один раз.) Например, функция "максимум" обычно определяется как макро в стандартном C так:
#define max(a,b) ((a) > (b) ? (a) : (b))
Но это определение вычисляет либо a, либо b дважды, с неправильными результатами, если операнд имеет побочные эффекты. В GNU C, если вы знаете тип операндов (здесь положим его int), вы можете безопасно определить макро таким образом:
#define maxint(a,b) \
({int _a = (a), _b = (b); _a > _b ? _a : _b; })
Встроенные операторы недопустимы в константых выражениях, таких как значения перечислимых констант, ширина битового поля или начальное значение статической переменной.
Последний макрос можно модифицировать для использования с любыми целочислеными типами (а не только с int). Для этого нам потребуется следующая особенность GCC:
Ключевое слово typeof
Еще один способ сослаться на тип некоторого выражения. Синтаксическое использование аналогично sizeof (агрументом может быть обект, или имя типа), но конструкция с typeof ведет себя аналогично той,
что определенна при помощи typedef.
Пример:
typeof (x[0](1))
(Здесь x - массив указателей на функции)
t ypeof (int *)
#define max(a,b) \
({ typeof (a) _a = (a); \
typeof (b) _b = (b); \
_a > _b ? _a : _b; })
Рассмотрим макрос memberof: по указателю ptr на структуру получить указатель на ее поле с именем member.
#define memberof(ptr, member) (&(ptr)->(member))
Упражнение
: Написать макрос containerof(ptr,type,member)
, решающий обратную задачу: по указателю ptr на поле member некоторой структуры, имеющей тип type нужно получить указатель на обертывающую структуру.
Ответ:
#define containerof(ptr, type, member) ({
\
const typeof( ((type *)0)->member ) *__mptr = (ptr);
\
(type *)( (char *)__mptr - offsetof(type,member) );})
здесь макрос offsetof определен сл. образом:
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
Встроенные функции GCC
Используются для генерации более качественного машинного кода.
l
ong __builtin_expect (long exp, long c)
Возвращает значение выражения exp.
с - CTC, для указания ожидаемого значения выражения exp.
Пример:
if (__builtin_expect(ptr == NULL, 0))
error();
В ядре эта директива обернута макросами likely, unlikely
, которые учитывают ошибки в старых компиляторах, а также включают функции do_check_likely()
для проверки правильности предсказаний:
i f (unlikely(ptr != NULL))
error();
void __builtin_prefetch (const void * addr, ...)
пополнение процессорного кэша данными по адресу addr.
Можно также передать опциональные параметры (CTC):
rw
- чтение или запись (0,1 ).
locality
- степень задержки данных в кэше (0 - не выгружать вообще, 3 - не задерживать).
Пример:
for (i = 0; i < n; i++){
a[i] = a[i] + b[i];
__builtin_prefetch (&a[i+j], 1, 1);
__builtin_prefetch (&b[i+j], 0, 1);
/* ... */
}
int __builtin_constant_p (exp)
Используется для выяснения во время компиляции, не является ли выражение exp константой, известной во время компиляции (CTC) . Возвращенное значение 1 означает, что гарантировано является, 0 означает отсутствие предположений по разным причинам (включая соответствие заданному уровню оптимизации).
Эта функция позволяет сэкономить на вычислениях, уменьшить расход памяти на переменные (что существенно для embedded проектов), и т.д.
В случае возврата значения 1, компилятору необходимо дать возможность произвести folding constant,
например, как в следующем примере:
#define Scale_Value(X) \
(__builtin_constant_p (X) ? ((X) * SCALE + OFFSET) : Scale (X))
Пример имспользования - функция void * kmalloc(size_t size)
для динамического выделения памяти ядра. Если size - CTC, то аллокация происходит особенно быстро.
int __builtin_types_compatible_p (TYPE1, TYPE2)

TYPE1, TYPE2
- типы (не выражения!)
Возвращает 1, если типы (без учета квалификаторов) эквивалентны. 0 - в противном случае.
Такая конструкция может применятся в константных выражениях.
Пример: типы int[]
и int[5]
совместимы. Типы int and char *
не совместимы, даже если соответствующие объекты занимают одинаковое количество памяти.
Упражнение. Написать и проверить на практике "параноидальную" версию макроса для
числа элементов массива
#define ARRAY_SIZE(x) (sizeof(x) / sizeof((x)[0]))
которая выдавала бы ошибку во время компиляции, если x - не массив, а указатель.
Ответ:
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]) \
+ sizeof(typeof(int[1 – 2*!!__builtin_types_compatible_p(typeof(arr), \
typeof(&arr[0]))]))*0)
void * __builtin_return_address (unsigned int LEVEL)
Возвращает адрес возврата текущей функции, или одной из вызывающих ее функций.
LEVEL - количество фреймов в стеке вызовов, на которые нужно подняться.
Фрейм - это участок стека, содержащий локальные переменные и сохраненные регистры. Обычно адрес фрейма - это адрес первого слова, положенного в стек функцией.
0 - для получения адреса возврата текущей функции,
1 - для получения адреса возврата функции, вызывающей текущую.
Важно отметить, что функция может быть встроена компилятором даже без указания static inline,
и при этом не создаст фрейма в стеке вызовов, поэтому во избежание конфузов можно пользоваться указанием аттрибута noinline:
static __attribute__((noinline)) int function (void)
Глава 2.
Управление процессами в ОС Линукс
Процесс в ОС. Контекст процесса. Пользовательский, регистровый и системный контекст.
Дескриптор процесса. Структура thread_info и ее вычисление для различных архитектур.
Состояния процесса. Диаграмма состояний.
Поток, как программная абстракция. Потоки пространства пользователя и пространства ядра.
Процессы с быстрым переключением контекста и легковесные процессы.
Создание процессов. Дерево процессов.
Тенхника Copy-on-Write. Функция do_fork()
Завершение процесса. Системный вызов exit()
Удаление дескриптора процесса. Системный вызов wait4().
Поддержка потоков в ОС Линукс. Библиотеки LinuxThreads, NPTL.
Системный вызов clone() и передаваемые ему флаги
Процесс в многозадачной ОС. Контекст процесса
С точки зрения многозадачной ОC процесс - это нечто большее, чем исполняемый программный код.
Это еще и единица диспетчеризации в системе. Часто на процесс еще накладывается и требование быть единицей владения ресурсами, но мы его опустим (как будет видно дальше, это приведет к тому, что в операционной системе сотрется различие между процессами и потоками). В такой ОС каждый процесс дополнительно нуждается в некой специальной инфраструктуре, необходимой для того, чтобы он мог освободить процессор с тем условием, чтобы некоторое время спустя снова получить его в свое
распоряжение для дальнейших вычислений. Такая инфраструктура входит в общий набор данных,
полностью характеризующий процесс в системе, имеющий название контекста процесса. Это составляет предмет т.н. “переключение контекста”, основной функция планировщика операционной системы.
Контекст процесса делится на
. пользовательский контекст
. системный контекст
. регистровый контекст
Пользовательский контекст
Это информация о том, какими данными занято содержимое виртуального адресного пространства процесса согласно единой спецификации, в которую входят:

text section (инструкции) эти данные берутся из исполняемого файла;

data section (инициализированные переменные) берутся из исполняемого файла;

bss section (неинициализированные глобальные переменные) изначально заполнена нулями из исполняемого файла при помощи специальной техники (hole in a file), которая находится в компетенции файловой системы (обьектные файлы создаются изощренным образом, который включает в себя комбинацию записей в файл, в т.ч. по смещениям превышающим размер файла, а также операций expanding truncate).

text, data, bss sections для каждой совместно используемой библиотеки (такой, как libc) и динамический компоновщик.

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

динамически выделяемая память (при помощи библиотечных функций malloc и т.д.)

совместно используемая область памяти (выделенная при помощи mmap())
Последние три динамические структуры поддерживаются ядром. Например, при переполнении стека возникает ошибка сегментации, автоматически обрабатываемая ядром, которое увеличивает размер стека,
не посылая при этом процессу никаких сигналов.
Упражнение. Написать программу, при помощи которой для какой-либо имеющейся в распоряжении
архитектуры определить (ориентировочно) в виртуальном адресном пространстве соответствующего
процесса начала text, data, bss секций, кучи, совместно используемой области памяти, стека. Определить
направление роста последнего.
Ответ:
#include
#include
стек код (фикс. размер) куча (malloc)
свободная область
(возм., отображенная при помощи mmap())
0xbfffffff
0

#include
#include
#include
#define ALLOC_SIZE 4096
int bss[100000];
int data = 10;
void print(void * addr, char * desc)
{
printf("%10p: %s\n", addr, desc);
}
main()
{
int fd;
char * ptr1;
char * ptr2;
ptr1 = (char *)malloc(ALLOC_SIZE);
if (!ptr1) {
perror("malloc failed");
exit(-1);
}
free(ptr1);
fd = open("/dev/zero", O_RDONLY);
ptr2 = (char *)mmap(0, ALLOC_SIZE, PROT_READ, MAP_SHARED, fd, 0);
if (ptr2 == MAP_FAILED) {
perror("mmap failed");
close(fd);
exit(-1);
}
munmap(ptr2, ALLOC_SIZE);
close(fd);
print(print, "text1");
print(main, "text2");
print(&data, "data");
print(bss, "bss");
print(ptr1, "heap");
print(ptr2, "mapped");
print(&ptr1, "stack1");
print(&ptr2, "stack2");
}
Результаты на x86:
0x804855c: text1 0x804857a: text2 0x8049120: data
0x8049160: bss
0x80ab008: heap
0x40018000: mapped
0xbfd375a0: stack1 0xbfd3759c: stack2
(стек растет в направлении уменьшения адресов).

Регистровый контекст
Переключение контекста включает сохранение и восстановление регистров.
Регистровый контекст состоит из следующих элементов (регистров):

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

Регистр состояния процессора (PS). Указывает аппаратный статус машины по отношению к процессу. Такое статус отслеживается отдельно для каждого процесса. Регистр PS обычно содержит подполя, содержащие
1. результат последних вычислений (><= 0)
2. переполнен ли регистр с установкой бита переноса
3. текущий уровень прерывания процессора
4. предыдущий режим выполнения процесса (ядра/задачи)
5. текущий режим выполнения процесса (ядра/задачи) по этому подполю определяется, может ли процесс выполнять привилегированные команды и обращаться к адресному пространству ядра.

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

Регистры общего назначения. Содержащие информацию, сгенерированную процессором во время выполнения
Системный контекст
1. Текущее состояние процесса.
2. Управляющая информация о процессе (хранится в адресном пространстве задачи).
3. Таблицы страниц для трансляции адресов ВА->ФА.
4. Стек ядра (записи процедур совершенные ядром от имени процесса, когда процесс выполняется в режиме задачи этот стек пуст).
5. Ожидающие сигналы (см. ниже) Сигнал – это уведомление процесса о наступлении какого-либо события.
6. Стек из контекстных уровней (может содержать до 7 уровней, если в системе поддерживается 5
уровней прерывания: 5 уровней прерывания +1 - пользовательский контекст + 1 системный контекст).
7. Указатели на потоки (для ОС с поддержкой потоков на уровне ядра).
Дескриптор процесса
В ядре ОС Линукс о процессах говорят как о задачах. Ядро хранит информацию о всех процессах в двухсвязном списке task list. Каждый элемент этого списка - дескриптор процесса (ДП), имеющий тип struct task_struct
(linux/include/linux/sched.h). ДП содержит всю необходимую информацию о процессе:
struct task_struct {
unsigned long state; /*
*/
состояние int prio; /*
*/
приоритет unsigned long policy; /*
*/
политика планирования struct task_struct *parent; /*
*/
указатель на родительскую задачу pid_t pid; /*
*/
идентификатор задачи pid_t tgid; /*
*/
идентификатор группы потоков int exit_state /*
*/
код завершения struct mm_struct *mm; /* указатель на адресное пространство

*/
процесса struct fs_struct *fs; /*
*/
информация о файловой системе struct files_struct *files; /*
*/
информация об открытых файлах struct thread_struct thread; /* CPU-specific
*/
данные задачи struct list_head tasks; /*
*/
список всех задач
}
Структура
thread_info
и вычисление ее местоположения для различных
архитектур. Макрос
current.
Получение указателя на дескриптор текущего процесса – операция очень критическая ввиду частого ее вывзова для разных нужд. Причем, настолько критическая, что для этого стараются по возможности задействовать регистры, которые представляют класс памяти на порядок более быстрой, чем RAM. Для каждой архитектуры применяется свой подход, но имеется и универсальный механизм для получения такого указателя, который мы опишем. В настоящее время указатель на задачу хранится в структуре thread_info struct thread_info {
strust task_struct * task; /*
*/ указатель на задачу unsigned long flags; /*
, . . need_resched */
флаги в т ч
__s32 preempt_count; /* счетчик вызовов preempt_disable */
}
Структура thread_info размещается в стеке ядра адресного пространства процесса (при работе в пространстве пользователя этот стек не может быть задействован, т.к. регистр указателя стека указывает на пользовательский стек, но это и не нужно, ибо манипуляции с дескриптором процесса в пространстве пользователя недопустимы). Память для стека ядра и этой структуры аллоцируется при помощи макроса alloc_thread_info
, который сводится к вызову функции
__get_free_pages().
Размещение структуры thread_info в стеке и манипуляции с ней архитектурно-зависимы. Для платформы x86 (см. linux/include/asm-i386/thread_info.h) она помещается в конце стека (который растет в сторону убывания адресов). Для получения указателя на структуру thread_info используется регистр %esp,
хранящий указатель стека:
static inline struct thread_info * current_thread_info(void)
{
return (struct thread_info *)(current_stack_pointer &
(THREAD_SIZE - 1));
}
THREAD_SIZE
- размер стека ядра, который задается параметрами конфигурации (обычно удвоеный размер страницы памяти);
current_stack_pointer – макрос для содержимого регистра указателя стека (esp)
Широко использующийся макрос current вычисляет указатель на текущую задачу просто как current_thread_info()->task;
Такой способ хранения и получения дескриптора процесса (через thread_info
) применяется для многих архитектур.Для некоторых архитектур (напр. RISC), не ощущающих дефицита регистров,
уакзатель на дескриптор процесса хранится в регистре процессора (r2), так что макрос current для такой архитектуры просто возвращает значение такого регистра).
С недавнего времени для платформы x86 (и x86-64) указатель на задачу стали хранить в PDA (per- processor data area), используя механизм поддержки сегментов в x86, включающий регистр %gs и глобальную таблицу дескрипторов GDT). При таком подходе для получения указателя на текущую задачу обращения к оперативной памяти вообще не происходит: используется содержимое регистра,
восстановленное при переключении контекста.
Состояния процесса. Диаграмма состояний
Состояния процесса хранятся в поле state дескриптора процесса (структуры task_struct). Различают пять следующих состояний:
TASK_RUNNING (выполняется, или готов к выполнению).
TASK_INTERRUPTIBLE (приостановлен, нах. в состоянии ожидания некоторого условия).
TASK_UNINTERRUPTIBLE (аналогично предыд., но не возообновляет выполнения при получении сигнала).
TASK_ZOMBIE (процесс завершен, но породивший его процесс еще не вызвал системный вызов wait4() - c помощью которого процееы-родители собирают информацию о потомках).
TASK_STOPPED (выполнение процесса остановлено. Задача не выполняется и не имеет право выполнятся. Такое может произойти при получении сигналов SIGSTOP, ... и в др. случаях)
Текущее состояние процесса устанавливается кодом ядра при помощи функций set_task_state(), set_current_state().
Поток как программная абстракция
Нити - это отдельные потоки выполнения внутри одного процесса (важная программная абстракция).
В дальнейшем будем их так и называть – потоки. Использование потоков – это способ структурирования программ.
Пример: программе-браузеру необходимо как минимум два потока, один из которых загружает страницу,
второй отслеживает, была ли активирована кнопка "стоп", чтобы в таком случае прервать второй поток.
Традиционно потоки поддерживается на уровне приложений так, что процесс является самостоятельной многозадачной миниатюрной операционной системой.
Важным приложением потоков является асинхронный ввод-вывод (процесс, нуждающийся в асинхронных действиях (скажем, в чтении) порождает специальный поток для синхронного чтения, после чего управление передается другому потоку для выполнения прочих действий.
Поток включает в себя следующую уникальную управляющую информацию (соотвествующие компоненты контекста процесса тиражируются в его адресном пространстве в N раз, N – количество нитей в процессе):
Существующий процесс создает новый процесс
TASK_RUNNING
готов к выполнению
TASK_RUNNING
выполняется
TASK_INTERRUPTIBLE
или
TASK_UNINTERRUPTIBLE
TASK_ZOMBIE
fork()
context_switch вытеснение exit()
ожидание события событие произошло


счетчик команд (выполняются в различных местах внутри кода процесса);

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

глобальные переменные;

куча (heap) - память, выделенная оператором malloc() одним потоком доступна для чтения и записи другому потоку этой группы.
Соответствующим образом тиражируется и управляющие компоненты регистрового контекста.
Прикладные потоки и потоки в области ядра. Легковесные процессы
Прикладные потоки также называют процессами с быстрым переключением контекста. Такие потоки не требуют дорогостоящих ресурсов ядра, применения системных вызовов, дополнительных обработчиков прерываний, а также перемещения параметров и данных через границу защиты.
Существуют аналогичные потоки и в области ядра, не требующие связи с прикладными процессами. Такие потоки используют совместно доступные области кода и глобальные данные ядра, но обладают собственным стеком в ядре. Такие потоки используются, например для асинхронного ввода-вывода (aio), а также для обработки прерываний. Потоки ядра являются малозатратными при создании и дальнейшем использовании. Т.о. потоки в области ядра концептуально не отличаются от пользовательских процессов.
Легковесный процесс (или LWP, более высокая абстракция) - это прикладная нить, поддерживаемая ядром. Это значит, что каждый LWP представляется задачей (единицей диспетчеризации ОС, которая планируются ядром на выполнение независимо от процесса. Но при этом LWP совместно разделяют адресное пространство и другие ресурсы процесса (отсюда и само название LWP – lightweight process).
Реализации нитевых библиотек прикладного уровня
Реализация операций с потоками, описаных в POSIX, находится полностью в компетенции операционной системы. Общий подход состоит в мультиплексировании прикладных нитей в LWP и предоставлении возможности для межнитевого планирования, переключения контекста и синхронизации без участия ядра.
Однако, для эффективной работы планировщик пользовательского уровня и планировщик ядра должны работать совместно (схема M:N). Частный случай (схема 1:1 ) - реализация, в которой отсутствует планировщик пользовательского уровня. В этом случае каждой прикладной нити соответствует отдельный
LWP.
н л
л л
н н
л scheduler
CPU
CPU
л л
н н
1:1
N:M
Я

Создание процессов. Дерево процессов
Процесс начинает свое существование с момента создания, в ОС Линукс такое создание выполняется при помощи библиотечной ф-ции fork(2)
, которая создает новый процесс путем полного копирования уже существующего процесса (создается копия процесса, вызвавшего fork
). Процесс, который вызвал fork называется родительским (parent), новый процесс - дочерним (child). Дочерний процесс отличается от родительского значениями идентификаторов
PID,PPID
(которые возвращаются системными вызовами соответственно getpid(), getppid()
).
Часто после такого разветвления нужно выполнить какую-нибудь другую программу. Семейство функций exec()
позволяет создать новое адресное пространство и загрузить в него новый исполяемый код.
Пример:
main()
{
int pid;
if ((pid = fork()) < 0 ) {
perror("fork failed");
exit(0);
} else if (pid == 0) {
printf("speaking of child process with pid =%d\n", getpid());
execl("command", "arg1", "arg2", ...);
printf("Should never get here...\n");
exit (-1);
} else
{
printf("speaking of parent process with pid =%d\n", getpid());
}
}
Самый первый процесс в системе – это boot. За ним зарезервирован pid равный 0. Он запускается пользователем и загружает операционную систему. На последнем этапе загрузки создается процесс init
(
pid=1
), который читает системные файлы сценариев начальной загрузки и выполняет другие программы. В частности, init порождает 6 псевдотерминалов с приглашением запустить login shell.
Каждый процесс в системе имеет только один порождающий процесс. Процессы, порожденные одним и тем же процессом, называются родственниками. Информация о дерево текущих процессов в системе доступна при помощи
# ps axjf
Техника copy-on-write. Функция
do_fork()
В Линуксе библиотечная функция fork() вызывает системный вызов clone(),
в ответ на который ядро запускает sys_clone(),
вызывающую do_fork()
, которая , в свою очередь, вызывает copy_process()
. Раньше при выполнении fork()
делался дубликат всех ресурсов родительского процесса и передавался порожденному. В ОС Линукс fork()
реализован при помощи техники COW
(copy-on-write). При этом копируется только таблица страниц (трансляции адресов ВА->ФА), сами же страницы с данными помечаются особым образом, и если какой-либо из процессов начинает изменять их,
то создаются дубликаты при этом вносятся коррективы в соответствующие таблицы страниц. Такая техника позволяет отложить, или вообще предотвратить копирование данных. Помимо копирования таблицы страниц затратным актом является создание нового дескриптора процесса. Иногда полезно пользоваться библиотечной функцией vfork()
, которая вообще не копирует таблицу страниц. При этом родительский процесс приостанавливается до завершения потомка, причем последнему запрещена запись в адресное пространство.
copy_process()
выполняет следующие действия:

dup_task_struct()
создает стек ядра (поскольку родительский процесс выполняется в режиме ядра), а также структуры thread_info, task_struct.
Память для нового дескриптора процесса выделяется при помощи слаб-аллокатора, который предоставляет возможность повторного использования обьектов и раскрашивания кэша.

Проверка на переполнение лимита на количество процессов в системе (по умолчанию 32768).

Назначается новое значение идентификатора процесса, найденное при помощи alloc_pid().

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

sched_fork() разделяет оставшуюся часть кванта времени между родительским и порожденным процессом.

в do_fork возвращается указатель на новый порожденный процесс.
Завершение процесса. Системный вызов
exit()
Уничтожение процесса происходит в следующих случаях:

процесс вызвал системный вызов exit()

произошел возврат из функции main()
(на самом деле это – предыдущий случай, т.к. компилятор помещает вызов exit()
после такого возврата)

процесс получил сигнал, или возникла исключительная ситуация, которую процесс не может обработать или проигнорировать.
Независимо от того, каким образом процесс завершается, основную массу работы выполняет функция do_exit()
(linux/kernel/exit.c), которая
1. Освобождает все объекты, занятые задачей (если они были заняты только этой задачей) кроме аллоцированных дескриптора процесса и структуры thread_info
2. Устанавливается код завершения задания. хранимый в поле exit_code структуры task_struct
3. Вызывает функцию exit_notify()
, которая отправляет сигналы родительскому процессу и назначает новый родительский процесс для всех дочерних процессов (им становится процесс из группы потоков завершившегося родителя, или процесс init
)
4. Устанавливает состояние
TASK_ZOMBIE
5. Вызывает планировщик schedule()
для переключения на новый процесс. В состоянии
TASK_ZOMBIE процесс никогда не планируется на выполнение, так что это последний код, который выполняется завершающимся процессом
Удаление дескриптора процесса. Системный вызов wait4()
После возврата из функции do_exit() дескриптор процесса все еще существует в системе (в состоянии
TASK_ZOMBIE
) для получения информации о завершившемся процессе. Удаление дескриптора процесса находится в компетенции родителя, котороый должен вызвать для этого библиотечную функцию из семейства wait
()
Все эти функции имеют сходную семантику и сводятся к системному вызову wait4().
Его стандартное поведение - приостановить выполнение вызывающей задачи до тех пор, пока один из порожденных ею процессов не завершится. При этом возвращается
PID
завершившегося процесса. И в область памяти, переданную через указатель, устанавливается код его завершения.
Освобождение дескриптора завершившегося процесса и его структуры thread_info выполняет функция release_task()
. Процесс init периодически вызывает wait()
для освобождения дескрипторов завершившихся осиротевших потомков.
Поддержка потоков в ОС Линукс. Библиотеки LinuxThreads, NPTL.
В ОС Линукс используется модель (1:1) для поддержки прикладных потоков (см. LWP, выше). Это значит,
что каждый прикладной поток получает виртуальный процессор. Модель (N:M) c контекстным переключением в планировщике пользовательского уровня была признана как не вписывающаяся в концепцию ядра Линукс (по той причине, что это требует частого копирования регистров из пространства ядра). Кроме того, была принята во внимание хорошая масштабируемость нового планировщика O(1), для которого большое количество задач в системе не является проблемой.
Стандартной библиотекой для создания и управления потоками в Линукс до ядер 2.6 была т.н.
LinuxThreads. Эта библиотека входила в состав glibc (начиная с glibc 2.0). Потом эта библиотека была заменена более современной, NPTL (Native POSIX Thread Library).
Замечание: LinuxThreads и NPTL - это разные версии библиотеки Pthread (Библиотека, реализующая стандарты POSIX для создания и управления потоками название Pthread). Из соображений совместимости,
бинарные файлы, получающиеся после компиляции исходников LinuxThreads and NPTL имеют одно и то же название (libpthread.so и libpthread.a). По этой причине понятия LinuxThreads и Pthread часто отождествлялись, что часто приводит к путанице, в особенности, когда речь заходит о новой библиотеке
NPTL.
У LinuxThreads был ряд недостатков:
1. Неудовлетворительной производительность и масштабирование.
2. Ограниченное число потоков, которые могли быть созданы одновременно.
3. Наличие отдельного “управляющего потока” для создания и координации потоков внутри процесса.
4. Отсутствие per-thread примитивов синхронизации для межпоточного взаимодействия и разделения ресурсов. Синхронизация велась посредством посылки сигналов.
5. Все потоки одного и того же процесса имели уникальный PID.
Еще одна модель для потоков в ОС Линукс (NGPT - next generation POSIX threads) была предложена IBM.
Она базируется на существующем пакете LinuxThreads, и использует дополнительную внешнюю библиотеку, которая обеспечивает POSIX-совместимость и лучшую производительность, чем стандартный
LinuxThreads. NGPT доступна для использования с ядрами серии 2.4, и более не развивается по причине появления более удачной библиотеки NPTL, которая в настоящее время интегрирована с glibc. Определить текущую версию нитевой библиотеки можно так:
# getconf GNU_LIBPTHREAD_VERSION
NPTL обеспечивает высокую производительность и годится для таких мультипоточных приложений, как бызы данных, web и mail- сервера и т.д. Некоторые вендоры (Red Hat) даже портировали NPTL на старые
ОС с ядрами 2.4. Таким образом, некоторые старые приложения продолжают работать так же и с новой библиотекой NPTL, однако, для полного использовния всех ее возможностей, старые пользовательские приложения, использующие операции с потоками, должны быть переписаны.
В LinuxThreads все потоки имели уникальный pid в том смысле, что getpid()
возвращал разные значения для потоков, порожденных одним и тем же пользовательским процессом. В последних версиях
Линукс getpid()
возвращает так называемый TGID (thyread group id) (
current->tgid), который совпадает для всех потоков одного и того же процесса. Каждый поток наследует tgid родительского процесса. Т.о. getpid()
возвращает один и тот же thread group ID для всех потоков, порожденных каким-либо одним процессом. В NPTL для идентификации потока используется его TID (thread ID),
получаемый при помощи системного вызова gettid()
, который возвращает уникальный идентификатор задачи в системе current->pid
Далее, мы будем иметь дело только с ядрами 2.6, обеспечивающими поддержку NPTL.
В ОС Линукс пользовательский поток создается при помощи библиотечной функции pthread_create,
которая вызывает системный вызов clone()
с определенным набором флагов:
flags
=
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD |
CLONE_SYSVSEM | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID
CLONE_VM
: У порожденного и родительского процессов будет общее адресное пространство. Если этот
флаг не установлен, то дочерний процесс получает копию адресного пространства родительского процесса
(как, например, в случае с fork(2)
) , при этом копируются только таблицы страниц: сами страницы не копируются, а помечаются как readonly и COW (copy-on-write), копии данных создаются только в том случае, если родитель или потомок попытаются изменить соответствующие данные.
CLONE_FS
: Порожденный и родительский процессы совместно используют информацию о файловой системе. Сюда входит root, текущая рабочая директория, umask (маска создания файлов). При этом,
если родительский [дочерний] процесс делает chroot(), chdir(), umask(), то изменения затрагивают дочерний[родительский] процесс.
CLONE_FILES
: Порожденный и родительский процессы совместно используют открытые файлы
(file descriptor table). Если этот флаг не установлен, то дочерний процесс получает дупликаты файловых дескрипторов.
CLONE_SIGHAND
: У порожденного и родительского процессов будут общие обработчики сигналов.
CLONE_THREAD
: Порожденный и родительский процессы будут принадлежать одной группе потоков:
В фазе (4) (см. выше) функция copy_process назначает tgid новой задачи следующим образом:
copy_process()
{
p->tgid = p->pid;
if (clone_flags & CLONE_THREAD)
/*
TGID
*/
установить для ребенка родительского процесса p->tgid = current->tgid;
}
CLONE_SYSVSEM
: У порожденного ит родительского процессов будет общая семантика обработки флага
SEM_UNDO
для семафоров System V.
CLONE_SETTLS
: Для порожденного процесса создать новую область локальных данных потока (thread local storage, TLS). Поддержка загрузки регистра потока, защищенной от сигналов: поскольку сигналы могут прибыть в любое время, любой из них должен быть заблокирован во время вызова clone, либо новый поток должен стартовать уже с загруженым регистром потока.
CLONE_PARENT_SETTID
: Установить идентификатор TID в адресном пространстве родительского процесса в указаном месте (указатель parent_tidptr передается в качестве аргумента системного вызова).
CLONE_CHILD_CLEARTID
: Очистить идентификатор TID в адресном пространстве порожденного процесса после его завершения (указатель child_tidptr передается в качестве аргумента системного вызова). Установка и очистка идентификаторов производятся при помощи макроса put_user()
Обычный вызов fork() реализован при помощи вызова clone()
с флагом
SIGCHLD
vfork()
реализован при помощи clone()
с флагами
CLONE_VFORK | CLONE_VM | SIGCHLD.
Пример.
Замечание: Для компиляции этой программы потребуется указать флаг
-lpthread
/* Demonstrates thread creation and termination */
#include
#include
#include
#include
#include
#include
#define NUM_THREADS
100 /*
*/
число новых потоков
_syscall0(pid_t,gettid);
/*
*/
Все новые потоки из этого примера выполняют следующую общую функцию void *thread_fn (void * nr)

{
pid_t pid = getpid(); /* PID
*/
текущего потока pid_t tid = gettid(); /* TID
*/
текущего потока printf("%d: This is speaking of thread with TID = %d, PID = %d\n",
nr, tid, pid);
pthread_exit(NULL);
}
int main()
{
pthread_t threads[NUM_THREADS];
int ret, t;
for (t = 0; t < NUM_THREADS; t++) {
printf("\nCreating thread %d\n", t);
ret = pthread_create(&threads[t], NULL, thread_fn, (void *)t);
if (ret) {
perror("pthread_create failed");
exit(-1);
}
}
for (t = 0; t < NUM_THREADS; t++)
pthread_join(threads[t], NULL);
pthread_exit(NULL);
}



Поделитесь с Вашими друзьями:


База данных защищена авторским правом ©nethash.ru 2019
обратиться к администрации

войти | регистрация
    Главная страница


загрузить материал