Архитектура Аудит Военная наука Иностранные языки Медицина Металлургия Метрология Образование Политология Производство Психология Стандартизация Технологии |
Няет текущий процесс новым процессом; а с другой - следит за использование процессора процессами и заставляет их переключаться, если они занимают процессор слишком долго.
То, как планировщик Linux определяет, какому процессу передавать управление, подробно описано в гл. 7, «Планировщик и синхронизация ядра»; тем не менее, если говорить кратко, планировщик определяет приоритеты на основе прошлого быстродействия (сколько процессорного времени процесс занимал ранее) и критического характера быстродействия для процесса (прерывания имеют более критический характер, чем ведение лога системы). Помимо этого, планировщик Linux управляет выполнением процессов на многопроцессорной машине (SMP). Существует несколько интересных особенностей для сбалансированной загрузки нескольких процессоров, таких, как привязка процесса к определенному процессору. Как было сказано ранее, базовая функциональность планировщика остается идентичной планировщику системы с одним процессором. Драйверы устройств Linux Драйверы устройств - это интерфейсы для работы ядра с жесткими дисками, памятью, звуковыми картами, сетевыми картами и другими устройствами ввода и вывода. Ядро Linux обычно включает несколько драйверов по умолчанию; Linux не будет слишком полезен, если не сможет принять ввод с клавиатуры. Драйверы устройств выделены в отдельный модуль. Несмотря на то что Linux имеет монолитное ядро, он сохраняет высокую степень модульности, позволяя динамическую загрузку каждого драйвера. Тем не менее стандартное ядро может оставаться относительно небольшим и постепенно расширяться в зависимости от конфигурации системы, на которой запущен Linux. В ядре Linux 2.6 драйверы устройств применяют два основных способа отображения их статуса пользователю системы: файловые системы /pros и /sys. При этом /ргос обычно применяется с целью отладки и слежения за устройствами, a /sys используется для изменения настроек. Например, если у вас есть радиотюнер на встроенном Linux-устройстве, вы можете видеть частоту по умолчанию и возможность ее изменения в разделе устройств в sysf s. В гл. 5, «Ввод-вывод», и 10, «Добавление вашего кода в ядро», мы подробно рассмотрим драйверы устройств для символьных и блочных устройств. Точнее говоря, мы коснемся драйвера устройства /dev/random и посмотрим, как он собирает информацию с других устройств Linux-системы. Переносимость и архитектурные зависимости По мере рассмотрения «внутренностей» ядра Linux время от времени мы будем обсуждать некоторые аспекты основного оборудования или архитектуры. Кроме того, ядро Linux - это большая совокупность кода, запускаемая на определенном типе процессоров и поэтому обладающая точными знаниями об этом процессоре (или процессорах), Глава 1 • Обзор включая набор инструкций и возможности. Тем не менее от каждого программиста ядра или системного программиста не требуется быть экспертом в микропроцессорах благодаря удачной идее многослойного (layered) строения ядра, что позволяет отлаживать многие возникающие проблемы прямо по мере их возникновения. Ядро Linux создано таким образом, чтобы уменьшить количество аппаратно-зависи-мого кода. Когда требуется взаимодействие с аппаратной частью, вызываются соответствующие библиотеки, отвечающие за выполнение отдельных функций на данной архитектуре. Например когда ядро хочет выполнить переключение контекста, оно вызывает функцию switch__to (). Так как ядро компилируется под конкретную архитектуру (например, PowerPC или х86), оно линкуется (во время компиляции) с соответствующими include-файлами include/asm-ppc/system.h*uiH include/asm-i386/system.h соответственно. Во время загрузки архитектурно-зависимый код инициализации выполняет вызов к Firmware BIOS (BIOS - это программное обеспечение для загрузки, описанное в гл. 9, «Построение ядра Linux»). В зависимости от целевой архитектуры с аппаратным обеспечением взаимодействуют различные слои программного обеспечения. Код ядра, ответственный за работу с этим аппаратным обеспечением, находится на более высоком слое. Благодаря этому ядро Linux можно назвать портабелъным (portable) на различные архитектуры. Ограничения проявляются в тех случаях, когда невозможно портировать драйверы, по причине того, что такое аппаратное обеспечение несовместимо с данной архитектурой или она является недостаточно популярной для портирования на нее драйверов. Для создания драйвера устройства программист должен иметь спецификацию данного аппаратного обеспечения на уровне регистров. Не все производители предоставляют подобную документацию из-за проприетарного характера этого аппаратного обеспечения. Это в некоторой степени ограничивает распространение Linux на различные архитектуры. Резюме В этой главе представлен краткий обзор и описание тем, которые мы далее рассмотрим более подробно. Также мы упомянули некоторые особенности Linux, которым он обязан своей популярностью, и некоторые его недостатки. В следующей главе описываются базовые инструменты для эффективного изучения ядра Linux. Упражнения 1. В чем разница между системой UNIX и UNIX-клоном? 2. Что означает термин «Linux on Powen>? 3. Что такое пользовательское пространство? Что такое пространство ядра? Упражнения 4. Что является интерфейсом к функциональности ядра из пространства пользовательских программ? 5. Как связаны пользовательский UID и имя пользователя? 6. Перечислите способы связи файлов с пользователями. 7. Перечислите типы файлов, поддерживаемых Linux. 8. Является ли оболочка частью операционной системы? 9. Для чего нужны защита файла и его режимы? 10. Перечислите виды информации, которую можно найти в структуре, хранящей метаданные. 11. В чем заключается основное различие между символьными и блочными устройствами? 12. Какие подсистемы ядра Linux позволяют ему работать как многопоточная система? 13. Каким образом процесс становится родителем другого процесса? 14. В этой главе мы рассмотрели два иерархических дерева: дерево файлов и дерево процессов. В чем они похожи? Чем они отличаются? 15. Связаны ли Ш процесса и ID пользователя? 16. Для чего процессам назначаются приоритеты? Все ли пользователи могут изменять приоритеты процессов? Если могут или не могут, то почему. 17. Используются ли драйверы устройств только для добавления поддержки нового аппаратного обеспечения? 18. Что позволяет Linux быть портируемой на разные архитектуры системой? Глава Исследовательский инструментарий В этой главе: ■ 2.1 Типы данных ядра ? 2.2 Ассемблер ? 2.3 Пример языка ассемблера ? 2.4 Ассемблерные вставки ? 2.5 Необычное использование языка С ? 2.6 Короткий обзор инструментария для исследования ядра ? 2.7 Говорит ядро: прослушивание сообщений ядра ? 2.8 Другие особенности ? Резюме ? Проект: Hellomod ? Упражнения Глава 2 • Исследовательский инструментарий
этой главе приведен обзор основных конструкций программирования под Linux и описаны некоторые методы взаимодействия с ядром. Мы начнем с обзора основных типов данных Linux, используемых для эффективного хранения и получения информации, методов программирования и основ языка ассемблера. Это даст нам фундамент для более подробного анализа ядра в следующих главах. Затем мы опишем, как Linux компилирует и собирает исходный код в исполнимый код. Это будет полезно для понимания кросс-платформенного кода и заодно познакомит вас с GNU-набором инструментов. После этого будет описано несколько методов получения информации от ядра Linux. Мы проведем как анализ исходного и исполнимого кода, так и вставку отладочных сообщений в ядро Linux. Эта глава представляет собой сборную солянку обзора и комментариев по поводу принятых в Linux соглашений1. Типы данных ядра Ядро Linux содержит множество объектов и структур, за которыми нужно следить. Для примера можно привести страницы памяти, процессы и прерывания. Способность быстро находить каждый из объектов среди всех остальных является залогом эффективности системы. Linux использует связанные списки и деревья бинарного поиска (вместе с набором вспомогательных структур), для того чтобы, во-первых, сгруппировать объекты внутри отдельных контейнеров и, во-вторых, для эффективного поиска отдельного элемента. Связанные списки Связанные списки (linked list) - это распространенные в компьютерной науке типы данных, повсеместно используемые в ядре Linux. Обычно в ядре Linux связанные списки реализуются в виде циклических двусвязных списков (рис. 2.1). Поэтому из каждого элемента такого списка мы можем попасть в следующий или предыдущий элемент. Весь код связанных списков можно посмотреть в include/linux/list.h. Этот подраздел описывает основные особенности связанных списков. Связанный список инициализируется с помощью макросов LIST_HEAD и INIT_ LIST_HEAD: include/linux/list.h 28 struct list_head { 1 Мы еще не углубляемся в глубины ядра. Здесь представлен обзор инструментов и концепций, необходимых для навигации в коде ядра. Если вы являетесь более опытным хакером, вы можете пропустить эту главу и сразу перейти к «внутренностям» ядра, описание которых начинается в гл. 3, «Процессы: принципиальная модель выполнения». Типы данных ядра 2 9 struct list_head *next, *prev; 32 #define LIST_HEAD_INIT(name) { & (name), & (name) } 3 4 #define LIST_HEAD(name) \ 35 struct list_head name = LIST_HEAD_INIT(name) 36 37 #define INIT_LIST_HEAD(ptr) do { \ 38 (ptr)-> next = (ptr); (ptr)-> prev = (ptr); \ 39 } while (0) Строка 34 Макрос LIST_HEAD создает голову связанного списка, обозначенную как name. Строка 37 Макрос INIT_LIST_HEAD инициализирует предыдущий и следующий указатели структуры ссылками на саму себя. После обеих этих вызовов паше содержит пустой двусвязный список1.
Рис. 2.1. Связанный список после вызова макроса INITJLISTJiEAD Простые стек и очередь могут быть реализованы с помощью функций list_add () и list_add_tail () соответственно. Хорошим примером может послужить следующий отрывок из рабочего кода очереди: kernel/workqueue.с 330 list_add(& wq-> list, & workqueues); Ядро добавляет wq-> lis t к общесистемному списку рабочей очереди, workqueues. Таким образом, workqueues - это стек очередей. Аналогично следующий код добавляет work-> entry в конец списка cwq-> worklist. При этом cwq-> worklist рассматривается в качестве очереди: Пустой связанный список определяется как список, для которого head-> next указывает на голову списка. Глава 2 • Исследовательский инструментарий kernel/workqueue.с 84 list_add_tail(& work-> entry, & cwq-> worklist); Для удаления элемента из очереди используется list_del (), которая получает удаляемый элемент в качестве параметра и удаляет элемент с помощью простой модификации следующего и предыдущего узлов таким образом, чтобы они указывали друг на друга. Например, при уничтожении рабочей очереди следующий код удаляет рабочую очередь из общесистемного списка рабочих очередей: kernel/workqueue.с 382 list_del(& wq-> list); В include/linux/list. h находится очень полезный макрос list_f or__each_ entry: include/linux/list.h 349 /* 350 * list_for__each_entry - проход по списку указанного типа 351 * @pos: type * to используется как счетчик цикла. 3 52 * @head: голова списка.
353 * ©member: имя list_struct внутри структуры. 354 */ 355 tdefine list_for__each__entry(pos, head, member) 356 for (pos = list_entry((head)-> next, typeof(*pos), member), 357 prefetch(pos-> member.next); 358 & pos-> member! = (head); 359 pos = list__entry(pos-> member.next, typeof(*pos), member), 3 60 prefetch(pos-> member.next)) Эта функция перебирает весь список и выполняется со всеми его элементами. Например, при включении процессора он будит все процессы для каждой рабочей очереди: kernel/workqueue.с 59 struct workqueue_jstruct { 60 struct cpu_workqueue__struct cpu__wq[NR__CPUS]; 61 const char *name; 62 struct list_head list; /* Пустая в однопоточном режиме */ 63 }; Типы данных ядра 466 case CPU_ONLINE: 467 /* Удаление рабочих потоков. */ 468 list_for_each__entry(wq, Sworkqueues, list) 469 wake_up_process(wq-> cpu_wq[hotcpu].thread); 470 break; Макрос раскрывает и использует список list_head с помощью структуры workqueue_structwq для обхождения всех списков, головы которых находятся в рабочих очередях. Если это кажется вам немного странным, помните, что нам не нужно знать, в каком списке мы находимся, для того чтобы его посетить. Мы узнаем, что достигли конца списка тогда, когда значение указателя на следующий элемент текущего вхождения будет указывать на голову списка. Рис. 2.2 иллюстрирует работу списка рабочих очередей1. workqueue_struct Workqueue.struct Workqueue.struct
*н cpu_wq *\ cpu_wq *J cpujwq
Name Name Name
Liist Prev Liist Prev Liist Prev
Next Next Next Рис. 2.2. Список рабочих очередей Дальнейшее усовершенствование связанного списка заключается в такой реализации, где голова списка содержит только один указатель на первый элемент. В этом состоит главное отличие от двусвязного списка, описанного в предыдущем разделе. Используемый в хеш-таблицах (описанных в гл. 4, «Менеджмент памяти») единственный указатель головы не имеет указателя назад, на хвостовой элемент списка. Таким образом достигается меньший расход памяти, так как указатель хвоста в хеш-таблицах не используется. include/linux/list.h 484 struct hlist_head { 1 Кроме этого, можно использовать list_for_each_entry_reverse О для посещения элементов списка в обратном порядке. Глава 2 • Исследовательский инструментарий 485 struct hlist_node *first; 486 }; 488 struct hlist_node { 489 struct hlist_node *next, **pprev; 490 }; 492 #define HLIST_HEAD_INIT {.first = NULL } 493 #define HLIST_HEAD(name) struct hlist_head name = {.first = NULL } Строка 492 Макрос HLIST_HEAD_INIT устанавливает указатель first в указатель на NULL. Строка 493 Макрос HLIST_HEAD создает связанный список по имени и устанавливает указатель first в указатель на NULL. Этот список создается и используется ядром Linux в рабочей очереди, как мы увидим далее в планировщике, таймере и для межмодульных операций. Поиск Подразд. 2.1.1 описывает объединение элементов в список. Упорядоченный список элементов сортируется по значению ключа каждого элемента (например, когда каждый элемент имеет ключ, значение которого больше предыдущего элемента). Если мы хотим обнаружить определенный элемент (по его ключу), мы начнем с головы и будем перемещаться по списку, сравнивая значение его ключа с искомым значением. Если значения не равны, мы переходим к следующему элементу, пока не найдем подходящий. В этом примере время, необходимое для нахождения нужного элемента, прямо пропорционально значению ключа. Другими словами, такой линейный поиск выполняется тем дольше, чем больше элементов в списке. Большое 0 Для теоретической оценки времени работы алгоритма, необходимого для поиска заданного ключа поиска, используется нотация большое О (Big-O). Она показывает наихудшее время поиска для заданного количества элементов (п). Для линейного поиска Big-О нотация показывает 0(п/2), что означает среднее время поиска, т. е. перебор половины ключей списка. Источник: Национальный институт стандартов и технологий (www.nist.org) При большом количестве элементов в списке для сортировки и поиска требуемых данных операционной системе требуются более быстрые методы поиска, чтобы подобные операции ее не тормозили. Среди множества существующих методов (и их реализаций) для хранения данных Linux использует деревья. 2.1 Типы данных ядра 2.1.3 Деревья Используемые в Linux для управления памятью деревья позволяют эффективно получать доступ и манипулировать данными. В этом случае эффективность измеряется тем, насколько быстро мы сможем сохранять и получать отдельные группы данных среди других. В этом подразделе представлены простые деревья, и в том числе красно-черные деревья, а более подробная реализация и вспомогательные элементы показаны в гл. 6, «Файловые системы». Деревья состоят из узлов (nodes) и ребер (edges) (см. рис. 2.3). Узлы представляют собой элементы данных, а ребра - связь между узлами. Первый, или верхний, узел является корнем дерева, или корневым (root) узлом. Связь между узлами описывается как родителифагеЫ)\ дети (child), или сестры (sibling), где каждый ребенок имеет только одного родителя (за исключением корня), каждый родитель имеет одного ребенка или больше детей, а сестры имеют общего родителя. Узел, не имеющий детей, называется листом (leaf). Высота (height) дерева - это количество ребер от корня до наиболее удаленного листа. Каждая строка наследования в дереве называется уровнем (level). На рис. 2.3 b и с находятся на один уровень ниже a, a d, e и f на два уровня ниже а. При просмотре элементов данного набора сестринских узлов упорядоченные деревья содержат элементы сестры с наименьшим значением ключа слева и наибольшим справа. Деревья обычно реализуются как связанные списки или массивы, а процесс перемещения по дереву называется обходом (traversing) дерева. Корень (Родитель)
Рис. 2.3. Дерево с корнем Бинарные деревья До этого мы рассмотрели поиск ключа с помощью линейного поиска, сравнивая наш ключ на каждой итерации. А что если с каждым сравнением мы сможем отбрасывать половину оставшихся ключей? Бинарное дерево (binary tree) в отличие от связанного списка является иерархической, а не линейной структурой данных. В бинарном дереве каждый элемент или узел ука- Глава 2 • Исследовательский инструментарий зывает на левый и правый дочерние узлы и, в свою очередь, каждый дочерний узел указывает на левого и правого ребенка и т. д. Главное правило сортировки узлов заключается в том, чтобы у каждого левого дочернего узла значение ключа было меньше, чем у родителя, а у правого больше или равно родительскому. В результате применения этого правила мы знаем, что для значения ключа в данном узле левый дочерний узел и его потоки содержат меньшие значения ключей, чем у данного, а правый и его потомки - большее или равное значение ключа. Сохраняя данные в бинарном дереве, мы уменьшаем данные для поиска на половину в каждой итерации. В нотации Big-О его производительность (с учетом количества искомых элементов) оценивается как О log(n). Сравните этот показатель с линейным поиском со значением Big-0 0(n/2). Алгоритм, используемый для прохода по бинарному дереву, прост и отлично подходит для рекурсивной реализации, так как в каждом узле мы сравниваем значение нашего ключа и переходим в левое или правое поддерево. Далее мы обсудим реализации, вспомогательные функции и типы бинарных деревьев. Как только что говорилось, узел бинарного дерева может иметь только одного левого, только одного правого потомка, обоих (левого и правого) потомков или не иметь потомков. Для упорядоченного бинарного дерева действует правило, что для значения узла (х) левый дочерний узел (и все его потомки) имеют значения меньше х, а правый дочерний узел (и все его потомки) имеют значение больше х. Следуя этому правилу, если в бинарное дерево вставляется упорядоченный набор значений, оно превращается в линейный список, что приводит к относительно медленному поиску значений. Например, если мы создаем бинарное дерево со значениями [0, 1, 2, 3, 4, 5, 6], 0 будет находиться в корне, 1 больше 0 и будет его правым потомком; 2 больше 1 и будет его правым потомком; 3 будет правым потомком 2 и т. д. Сбалансированным по высоте (height-balanced) бинарным деревом является такое дерево, которое не имеет листьев, более удаленных от корня, чем остальные. По мере добавления узлов в дерево его нужно перебалансировать для более эффективного поиска; что выполняется с помощью поворотов (rotation). Если после вставки данный узел (е) имеет левого ребенка с потомками на два уровня больше, чем другие листья, мы должны выполнить правый поворот узла е. Как показано на рис. 2.4, е становится родителем h и правый ребенок е становится левым ребенком h. Если выполнять перебалансировку после каждой вставки, мы можем гарантировать, что нам нужен только один поворот. Это правило баланса (когда ни один из листьев детей не должен находиться на расстоянии больше одного) известно как AVL-дерево [в честь Дж. М. Адельсона-Велски (G. M. Adel-son-Velskii) и Е. М. Лендис (Е. М. Landis)]. Типы данных ядра Рис. 2.4. Правый поворот Красно-черные деревья Красно-черное дерево, похожее на AVL-дерево, используется в Linux для управления памятью. Красно-черное дерево - это сбалансированное бинарное дерево, в котором каждый узел окрашен в красный или черный цвет. Вот правила для красно-черного дерева: • Все узлы являются либо красными, либо черными. • Если узел красный, оба его потомка - черные. • Все узлы-листья - черные. • При перемещении от корня до листа каждый путь содержит одинаковое количество черных узлов. Как AVL-, так и красно-черные деревья имеют производительность О log(n) (в нотации Big-O), зависящую от количества вставленных данных (сортированных/несортированных) и поиска; каждый тип обладает своими преимуществами. [В Интернете можно найти несколько интересных книг, посвященных производительности деревьев бинарного поиска (BST).] Как говорилось ранее, в компьютерной науке используются многие структуры данных и связанные с ними алгоритмы поиска. Целью этого раздела является помочь вам в ваших исследованиях концепций и структур данных, используемых для организации данных в Linux. Понимание основ списков и деревьев поможет вам понять более сложные операции, такие, как управление памятью и очереди, которые обсуждаются в следующей главе. Глава 2 • Исследовательский инструментарий Ассемблер Linux - это операционная система. Поэтому его часть тесно связана с процессором, на котором он работает. Авторы Linux проделали огромную работу по минимизации процессорно- (и архитектурно-) зависимого кода, стараясь писать как можно менее архитектурно-зависимый код. В этом разделе мы рассмотрим следующее: • Каким образом некоторые функции реализуются на х86- и PowerPC-архитектурах. • Как использовать макросы и встроенный ассемблерный код. Целью этого раздела является раскрытие основ, необходимых вам для того, чтобы разобраться в архитектурно-зависимом коде ядра и не заблудиться в нем. Мы оставим серьезное программирование на языке ассемблера для других книг. Также мы рассмотрим некоторые тонкости применения языка ассемблера: встроенный ассемблер. Чтобы конкретнее говорить о языке ассемблера для х86 и РРС, давайте поговорим об архитектуре каждого из этих процессоров. PowerPC PowerPC - это архитектура с ограниченным набором вычислительных инструкций (Reduced Instruction Set Computing, RISC). Архитектура RICS предназначена для увеличения производительности за счет упрощения выполнения набора инструкций за несколько циклов процессора. Для того чтобы воспользоваться преимуществами параллельных (суперскалярных) инструкций аппаратного обеспечения, некоторые из этих инструкций, как мы вскоре увидим, далеко не так просты. Архитектура PowerPC совместно разработана ЮМ, Motorola и Apple. В табл. 2.1 перечислен пользовательский набор регистров PowerPC. Таблица 2.1. Набор регистров PowerPC
CR 32 32 Регистр состояния 1 LR 32 64 Регистр связи 1 CTR 32 64 Регистр счетчика 1 GPR[0...31] 32 64 Регистр общего 32 назначения XER 32 64 Регистр исключений с 1 фиксированной точкой FPR[0...31] 64 64 Регистр с плавающей 32 точкой Ассемблер Таблица 2.I. Набор регистров PowerPC (Окончание) FPSCR 32 64 Регистр контроля 1 управления с плавающей точкой Табл. 2.2 иллюстрирует применение бинарного интерфейса приложений для общих регистров и регистров с плавающей точкой. Переменные регистры могут использоваться в любое время, а постоянные-только для выполнения вызовов предусмотренных функций. Бинарный интерфейс приложений [Application Binary Interface (ABI)] ABI - это набор соглашений, позволяющий компоновщику объединять отдельные скомпилированные модули в один юнит без перекомпиляции, соглашений на вызовы, машинный интерфейс и интерфейс операционной системы. Помимо всего прочего, ABI определяет бинарный интерфейс между юнитами. Существует несколько разновидностей PowerPC ABI. Обычно они связаны с целевой операционной системой и/или оборудованием. Эти вариации и расширения основаны на разработанной в AT& T документации UNIX System V Application Binary Interface и ее более поздних вариациях из Santa Cruz*Operation (SCO). Соответствие ABI позволяет компоновать объектные файлы, откомпилированные различными компиляторами. Таблица 2.2. Использование регистров ABI
Глава 2 • Исследовательский инструментарий Таблица 2.2. Использование регистров ABI (Окончание) f2-f4 Переменный Параметры со 2-го по 4-й, возвращают скалярное значение с плавающей точкой F5-A3 Переменный Параметры с 5-го по 13-й fl4-f31 Постоянный Зарегистрирован для вызовов Архитектура PowerPC с 32 битами использует инструкции длиной 4 бита, выровненные по слову. Она оперирует байтами, полусловом, словом и двойным словом. Инструкции делятся на переходы, инструкции с фиксированной точкой и с плавающей точкой. Условные инструкции Регистр состояния (condition register, CR) применяется для всех условных операций. Он разбит на 8 4-битовых полей, которые можно явно изменить инструкцией move, неявно в результате выполнения инструкции или чаще всего в результате инструкции сравнения. Регистр связывания (link register, LR) используется в некоторых видах условных операций для получения адреса перехода и адреса возврата из условной инструкции. Регистр счетчика (count register, CTR) хранит счетчик циклов, увеличиваемый с помощью некоторых условных инструкций. Кроме этого, CTR хранит адрес перехода для некоторых из условных инструкций. В дополнение к CTR и LR условные инструкции PowerPC могут выполнять переходы по относительному или абсолютному адресу. При использовании расширенных мнемоник становятся доступными еще множество различных условных и безусловных инструкций перехода. Популярное:
|
Последнее изменение этой страницы: 2016-03-25; Просмотров: 725; Нарушение авторского права страницы