OS1: примитивное ядро на Rust для x86

Я решил написать статью, а если получится — то и серию статей, чтобы поделиться своим опытом самостоятельного исследования как устройства Bare Bone x86, так и организации операционных систем. На данный момент мою поделку нельзя назвать даже операционной системой — это небольшое ядро, которое умеет загружаться из Multiboot (GRUB), управлять памятью реальной и виртуальной, а также выполнять несколько бесполезных функций в режиме многозадачности на одном процессоре.


При разработке я не ставил себе целей написать новый Linux (хотя, признаюсь, лет 5 назад мечтал об этом) или впечатлить кого-либо, поэтому особо впечатлительных прошу дальше не смотреть. Что мне на самом деле захотелось сделать — разобраться, как устроена архитектура i386 на самом базовом уровне, и как именно операционные системы делают свою магию, ну и покопать хайповый Rust.


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


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


Литература и источники


Большую часть я черпал, конечно же, с отличного ресурса OSDev — как с вики, так и с форума. Во вторую очередь я назову Филиппа Оппермана с его блогом — большое количество информации о связке Rust и железа.


Некоторые моменты подсмотрены в ядре Linux, Minix — не без помощи специальной литературы, такой как книга Таненбаума “Операционные системы. Разработка и реализация”, книга Роберта Лава “Ядро Linux. Описание процесса разработки”. Сложные вопросы об организации архитектуры x86 решались при помощи мануала “Intel 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide”. В понимании формата бинарников, компоновки — гайды по ld, llvm, nm, nasm, make.
UPD. Спасибо CoreTeamTech за напоминание о чудесной системе Redox OS. Из ее исходников я не вылезал. К сожалению, официальный GitLab системы недоступен с русских IP, так что можно посмотреть на GitHub.


Еще одно предисловие


Я осознаю, что я не хороший программист на Rust, более того — это мой первый проект на этом языке (не лучший способ начать знакомство, не так ли?). Поэтому реализация может показаться вам совершенно некорректной — заранее хочу попросить о снисхождении к моему коду и буду рад комментариям и предложениям. Если уважаемый читатель сможет подсказать мне, куда и как двигаться дальше — также буду очень благодарен. Некоторые фрагменты кода могут быть скопированы из туториалов как есть и незначительно модифицированы, но к таким участкам я постараюсь дать максимально понятные пояснения, чтобы у вас не возникли те же вопросы, что и у меня при их разборе. Я так же не претендую на использование верных подходов в проектировании, поэтому если мой менеджер памяти вызовет у вас желание писать гневные комментарии — я прекрасно понимаю почему.


Инструментарий


Итак, я начну с погружения в средства разработки, которыми я пользовался. В качестве среды я выбрал хороший и удобный редактор кода VS Code с плагинами для Rust и отладчика GDB. VS Code иногда не очень хорошо дружит с RLS, особенно при переопределении его в конкретном каталоге, поэтому после каждого обновления Rust nightly мне приходилось переустанавливать RLS.


Язык Rust был выбран по нескольким причинам. Во-первых, его растущая популярность и приятная философия. Во-вторых, его возможности работать с низким уровнем но с меньшей вероятностью “выстрелить себе в ногу”. В-третьих, как любитель Java и Maven, я очень пристрастился к системам сборки и управлениям зависимостями, а cargo уже встроен в toolchain языка. В-четвертых, мне просто захотелось чего-то нового, не такого как Си.


Для низкоуровневого кода я взял NASM, т.к. я уверенно себя чувствую в Intel-синтаксисе, а также мне комфортно работать с его директивами. Я осознанно отказался от ассемблерных вставок в Rust, чтобы явственно разделить работу с железом и высокоуровневую логику.
В качестве общей сборки и компоновки использованы Make и линкер из поставки LLVM LLD (как более быстрый и качественный линкер) — это дело вкуса. Можно было обойтись и build-скриптами для cargo.


Для запуска использован Qemu — мне нравится его скорость, интерактивный режим и возможность прицепить GDB. Чтобы загружаться и сразу иметь всю информацию о железе — конечно же GRUB (Legacy более просто в организации заголовка, так что берем его).


Линковка и компоновка


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


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


Логический, линейный, виртуальный, физический...

Логический, линейный, виртуальный, физический адреса. Я сломал голову на этом вопросе, поэтому за деталями хочу адресовать к этой отличной статье


Для операционных систем, которые используют страничную организацию памяти, в 32-х разрядной среде каждой задаче доступно 4 ГБ адресного пространства памяти, даже если у вас установлено 128 МБ ОЗУ. Это происходит как раз за счет страничной организации памяти, отсутствие страниц в оперативной памяти обрабатывается соответствующим образом.


При этом на самом деле приложениям обычно доступно несколько меньше, чем 4 ГБ. Это объясняется тем, что ОС должна обслуживать прерывания, системные вызовы, а значит — как минимум их обработчики должны находиться в этом адресном пространстве. Перед нами встает вопрос: куда именно в эти 4 ГБ поместить адреса ядра, чтобы программы могли корректно работать?


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


Эта концепция называется Higher-half kernel (здесь отсылаю вас к osdev.org, если хотите сопутствующую информацию). Какой участок памяти выбрать — зависит только от ваших аппетитов. Кому-то достаточно 512 МБ, я же решил отхватить себе 1 ГБ, поэтому мое ядро размещается по адресу 3 ГБ + 1 МБ (+ 1 МБ необходим, чтобы соблюсти границы lower-higher memory, GRUB загружает нас в физическую память после 1 МБ).
Так же для нас важно указать точку входа в наш исполняемый файл. Для моего исполняемого файла это будет функция _loader, написанная на ассемблере, на которой я остановлюсь подробнее в следующем разделе.


О точке входа

Вы знали, что вам всю жизнь врали насчет того, что main() является точкой входа в программу? На самом деле, main() — это конвенция языка Си и языков, им порожденных. Если покопаться, то выяснится примерно следующее.


Во-первых, каждая платформа имеет свою спецификацию и название точки входа: для linux это обычно _start, для Windows — mainCRTStartup. Во-вторых, эти точки можно переопределить, но тогда не получится пользоваться прелестями libc. В-третьих, эти точки входа по умолчанию предоставляет компилятор и они находятся в файлах crt0..crtN (CRT — C RunTime, N — количество аргументов main).


Собственно что делают компиляторы типа gcc или vc — выбирают платформозависимый скрипт линковки, в котором прописана стандартная точка входа, выбирают нужный объектный файл с готовой функцией инициализации рантайма Си и вызовом функции main и производят линковку с выходом в виде файла нужного формата со стандартной точкой входа.


Так вот, для наших целей стандартную точку входа и инициализацию CRT нужно отключать, так как у нас нет совсем ничего, кроме голого железа.


Что еще необходимо знать для линковки? Как будут располагаться секции данных (.rodata, .data), неинициализированных переменных (.bss, common), а также помнить, что GRUB требует расположения заголовков мультизагрузки в первых 8 КБ бинарника.


Итак, теперь мы можем написать скрипт линковщика!


ENTRY(_loader)
OUTPUT_FORMAT(elf32-i386)

SECTIONS {
    . = 0xC0100000;

    .text ALIGN(4K) : AT(ADDR(.text) - 0xC0000000) {
        *(.multiboot1)
        *(.multiboot2)
        *(.text)
    }

    .rodata ALIGN(4K) : AT(ADDR(.rodata) - 0xC0000000) {
        *(.rodata*)
    }

    .data ALIGN (4K) : AT(ADDR(.data) - 0xC0000000) {
        *(.data)
    }

    .bss : AT(ADDR(.bss) - 0xC0000000) {
        _sbss = .;
        *(COMMON)
        *(.bss)
        _ebss = .;
    }
}

Загрузка после GRUB


Как уже было сказано выше, спецификация Multiboot требует, чтобы заголовок находился в первых 8 КБ загрузочного образа. Полную спецификацию можно посмотреть здесь, я же остановлюсь только на интересующих подробностях.


  • Должно соблюдаться выравнивание по 32 бита (4 байта)
  • Должно присутствовать магическое число 0x1BADB002
  • Нужно указать мультизагрузчику, какую информацию мы хотим получить и как размещать модули (в моем случае я хочу, чтобы модуль ядра был выровнен на страницу 4 КБ, а также получить карту памяти, чтобы сэкономить себе время и силы)
  • Предоставить контрольную сумму (контрольная сумма + магическое число + флаги должны давать ноль)

MB1_MODULEALIGN equ  1<<0
MB1_MEMINFO     equ  1<<1
MB1_FLAGS       equ  MB1_MODULEALIGN | MB1_MEMINFO
MB1_MAGIC       equ  0x1BADB002
MB1_CHECKSUM    equ -(MB1_MAGIC + MB1_FLAGS)
section .multiboot1
align 4
    dd MB1_MAGIC
    dd MB1_FLAGS
    dd MB1_CHECKSUM

После загрузки Multiboot гарантирует некоторые условия, которые мы должны учесть.


  • В регистре EAX находится магическое число 0x2BADB002, которое говорит, что загрузка прошла успешно
  • В регистре EBX находится физический адрес структуры с информацией о результатах загрузки (о ней поговорим значительно позже)
  • Процессор переведен в защищенный режим, страничная память выключена, сегментные регистры и стек находятся в неопределенном (для нас) состоянии, GRUB использовал их для своих нужд и нужно их переопределить как можно скорее.

Первое, что нам необходимо сделать — включить страничную организацию памяти, настроить стек и уже наконец передать управление в высокоуровневый код Rust.
Я не буду детально останавливаться на страничной организации памяти, Page Directory и Page Table, ибо про это написаны отличные статьи (одна из них). Главное, чем хочу поделиться — страницы это не сегменты! Пожалуйста, не повторяйте мою ошибку и не грузите адрес таблицы страниц в GDTR! Для таблицы страниц предназначен регистр CR3! Страница может иметь различный размер в разных архитектурах, для простоты работы (чтобы иметь только одну таблицу страниц), я выбрал размер в 4 МБ за счет включения PSE.


Итак, мы хотим включить виртуальную страничную память. Для этого нам нужна таблица страниц, и ее физический адрес, загруженный в CR3. При этом наш бинарный файл был слинкован так, чтобы работать в виртуальном адресном пространстве со смещением в 3 ГБ. Это значит, что все адреса переменных и метки имеют смещение в 3 ГБ. Таблица страниц — всего лишь массив, в котором по индексу страницы находится ее реальный адрес, выровненный на размер страницы, а также флаги доступа и состояния. Так как я использую 4 МБ страницы, мне нужно всего одну таблицу страниц PD с 1024 записями:


section .data
align 0x1000
BootPageDirectory:
    dd 0x00000083
    times (KERNEL_PAGE_NUMBER - 1) dd 0
    dd 0x00000083
    times (1024 - KERNEL_PAGE_NUMBER - 1) dd 0

Что в таблице?


  1. Самая первая страница должна вести на текущий участок кода (0-4 МБ физической памяти), так как все адреса в процессоре физические и трансляция в виртуальные еще не выполняется. Отсутствие этого дескриптора страницы приведет к немедленному краху, так как процессор не сможет взять следующую инструкцию после включения страниц. Флаги: бит 0 — таблица присутствует, бит 1 — страница записываемая, бит 7 — размер страницы 4 МБ. После включения страниц запись обнулим.
  2. Пропуск до 3 ГБ — нули гарантируют, что страницы нет в памяти
  3. Отметка 3 ГБ — наше ядро в виртуальной памяти, ссылающееся на 0 в физической. После включения страниц будем работать именно здесь. Флаги аналогичны первой записи.
  4. Пропуск до 4 ГБ.

Итак, мы объявили таблицу и теперь хотим загрузить ее физический адрес в CR3. Не забываем про смещение адреса в 3 ГБ на этапе линковки. Попытка загрузить адрес как есть отправит нас по реальному адресу 3 ГБ + смещение переменной и приведет к немедленному краху. Поэтому берем адрес BootPageDirectory и вычитаем из него 3 ГБ, кладем в CR3. Включаем PSE в регистре CR4, включаем работу со страницами в регистре CR0:


    mov ecx, (BootPageDirectory - KERNEL_VIRTUAL_BASE)
    mov cr3, ecx

    mov ecx, cr4
    or ecx, 0x00000010
    mov cr4, ecx

    mov ecx, cr0
    or ecx, 0x80000000
    mov cr0, ecx

Пока все идет хорошо, но как только мы обнулим первую страницу, чтобы окончательно перейти в верхнюю половину 3 ГБ, все рухнет, так как в регистре EIP все еще лежит физический адрес в районе первого мегабайта. Чтобы поправить это, проводим простую манипуляцию: в ближайшем месте ставим метку, загружаем ее адрес (он уже со смещением в 3 ГБ, помним об этом) и делаем безусловный переход по нему. После этого ненужную страницу можно обнулить для будущих приложений.


    lea ecx, [StartInHigherHalf]
    jmp ecx

StartInHigherHalf:
    mov dword [BootPageDirectory], 0
    invlpg [0]

Теперь дело за совсем малым: инициализировать стек, передать структуры GRUB и хватит уже ассемблера!


    mov esp, stack+STACKSIZE
    push eax
    push ebx
    lea ecx, [BootPageDirectory]
    push ecx
    call  kmain
    hlt

section .bss
align 32
stack:
    resb STACKSIZE

Что нужно знать об этом участке кода:


  1. Согласно Си-конвенции вызовов (она применима и для Rust), переменные в функцию передаются через стек в обратном порядке. Все переменные выровнены на 4 байта в x86.
  2. Стек растет с конца, поэтому указатель на стек должен вести в конец стека (добавляем STACKSIZE к адресу). Размер стека я взял 16 КБ, должно хватать.
  3. В ядро передается: магическое число Multiboot, физический адрес структуры загрузчика (там лежит ценная для нас карта памяти), виртуальный адрес таблицы страниц (где-то в пространстве 3 ГБ)

Также не забываем объявить, что kmain — extern, а _loader — global.


Дальнейшие шаги


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


Полный код проекта доступен на GitLab.


Спасибо за внимание!


UPD2: Часть 2
UPD2: Часть 3

Поддержать автора
Поделиться публикацией

Комментарии 25

    +3
    Круто! В конце 90-х еще мечтал о таком, но инфы было мало, интернета не было. Загрузчик был готов, еще несколько вещей типа графики были тоже (правда их надо было под PM переделать еще), но застопорился на переключении в защищенный режим, а потом уже не до того было. Я на чистом ассемблере писал.
    P.S. Про OsDev знаю, сам там иногда лазил. Мне бы в те времена такой ресурс…
      +1
      Вы молодец. Вас ждет много интересного впереди. И вам может быть полезным посмотреть на Redox OS.
        +2
        Конечно же! Как я мог забыть… Я постоянно поглядывал в репозиторий. Сейчас добавлю в статью, спасибо вам.
        +1
        Отличная статья! Найден оптимальный баланс между изложением только важных/новых вещей и пропуском (с отсылками к другим статьям) очевидных/общеизвестных/разжеваных ранее. За «правильный» ассемблер — отдельный плюс (сам так и не смог привыкнуть ко всяким этим %eax и прочим).
          0
          За «правильный» ассемблер — отдельный плюс

          Были б мы на ЛОРе, я б знал как это прокомментировать. Здесь придётся ограничиться многозначительным молчанием.

            +1
            Вот поэтому я и не пишу каменты на ЛОРе, здесь заботливое НЛО есть…

            А если серьёзно — не могли бы поделиться, почему тот синткасис лучше, и чем именно, или порекомендовать почитать на тему? Это ведь не просто вкусовщина, есть и объективные причины?

            Сам я со времён DOS'а, как старый пользователь MASM и TASM, привычен к интеловскому, но это всего лишь привычка.
              +1
              почему тот синткасис лучше

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


              Но я сомневаюсь, что найдётся хотя бы один человек, предпочитающий одно другому по объективным (рациональным) причинам. Привычка/идеология и не более.

                0
                Найдётся. Долго пользовался Интелловским, плевался на AT&T. Потом решил посмотреть, что там за другой синтаксис такой. Удобство победило привычку. Сейчас AT&T читается проще и кажется более адекватным, логичным и удобным.
            0

            Вообще "правильный" ассемблер в данном случае не очень хорош, так как под другие платформы в основном используется синтаксис at&t, и портировать будет сложнее — придется изменять не только код под платформу, но и систему сборки. Но мне приятнее писать в Интел синтаксисе, так что пусть будет.

              0
              «The reason why the GNU Assembler (GAS) uses AT&T syntax for x86 assembly is for compatibility with AT&T's x86 assemblers. Instead of using a syntax based on Intel's official x86 assembly syntax, AT&T chose to create a new syntax based on their earlier 68000 and PDP-11 assemblers. „

              Зависит от платформы. Под ARM, к счастью, он не используется.
              +1
              Спасибо, очень познавательно. Тоже взялся изучать Rust. Ждем продолжения!
                0
                Если посмотреть, как именно компиляторы собирают программы на этапе линковки, то окажется, что они начинаются с линейного адреса ноль или около ноля. Это значит, что если образ ядра займет пространство памяти около ноля, программы собранные таким образом не смогут исполняться, любая jmp инструкция программы приведет ко входу в защищенную память ядра и ошибке защиты. Поэтому если мы хотим в будущем пользоваться не только самописными программами, разумно отдать приложению как можно больший кусок памяти около ноля, а образ ядра разместить повыше.

                Вот этот кусок понять не смог.
                Линкер собирает программу с опорой на BaseAddress, который в общем случае, может быть любым. Нам ведь никто не запрещает разместить ядро по адресу, скажем, 1000h (нулевая страница — guard), а все прикладные программы компоновать с адресом загрузки 80000000h.

                Проблема будет лишь в том случае, если вы хотите запустить программу, написанную и собранную под другую ОС без перекомпоновки (что возможно только если ваш API полностью бинарно совместим, а это вряд ли).

                Главное, чем хочу поделиться — страницы это не сегменты! Пожалуйста, не повторяйте мою ошибку и не грузите адрес таблицы страниц в GDTR! Для таблицы страниц предназначен регистр CR3!

                Даже затрудняюсь представить, как можно было так подумать…

                для простоты работы (чтобы иметь только одну таблицу страниц), я выбрал размер в 4 МиБ за счет включения PSE.

                А как же потери при небольших размещениях? Не велика гранулярность для 32 бит?
                  0
                  Можно ведь вроде вообще не использовать страницы, они нужны только для реализации виртуальной памяти (свопа, грубо говоря).
                    0
                    Да, можно инициализировать сегменты сначала, а страничную адресацию — потом, или вовсе ею не пользоваться.
                    Нет, не только для свопа. ВАП нужно, в первую очередь (имхо), для 1) расположения некоторых модулей по пересекающимся адресам, 2)реализации поведения copy-on-write, 3) для преодоления фрагментации физической памяти. При страничной адресации у вас по-прежнему может фрагментироваться ВАП одного процесса, но это будут лишь его личне половые проблемы. Без страничной адресации вы быстро попадаете на ситуацию вида:
                    1. гигабайт — занят
                    2. гигабайт — свободен
                    3. гигабайт — занят
                    4. гигабайт — свободен

                    У вас 2 гига свободной памяти, а дать программе буффер в 1.5 гига вы не можете.

                    4) guard pages для стеков и для поимки null-pointer dereference


                    0
                    Вот этот кусок понять не смог.
                    Линкер собирает программу с опорой на BaseAddress, который в общем случае, может быть любым. Нам ведь никто не запрещает разместить ядро по адресу, скажем, 1000h (нулевая страница — guard), а все прикладные программы компоновать с адресом загрузки 80000000h.

                    Проблема будет лишь в том случае, если вы хотите запустить программу, написанную и собранную под другую ОС без перекомпоновки (что возможно только если ваш API полностью бинарно совместим, а это вряд ли).

                    Совершенно верно. Однако, это специфические случаи, требующие скармливания дополнительных инструкций линкеру. В самом общем случае под большинство ОС программы предпочитают начинаться с начала адресного пространства. В том же Windows дела обстоят так: 0x00000000 — 0x0000FFFF: нулевые указатели, 0x00010000 — 0x7FFEFFFF: память программы, 0x80000000 — 0xFFFFFFFF: память ядра.

                    А как же потери при небольших размещениях? Не велика гранулярность для 32 бит?

                    Велика, конечно. Надо брать 4 КиБ. Когда-нибудь у меня дойдут руки и до 4 КиБ. На этом этапе я захотел сделать жизнь чуть проще, оставив один уровень таблиц, благо PSE это позволяет.
                      0
                      требующие скармливания дополнительных инструкций линкеру. В самом общем случае под большинство ОС программы предпочитают начинаться с начала адресного пространства. В том же Windows дела обстоят так: 0x00000000 — 0x0000FFFF: нулевые указатели, 0x00010000 — 0x7FFEFFFF: память программы, 0x80000000 — 0xFFFFFFFF: память ядра.

                      Ну, положим, то, где начинается память, зависит от того, где ОС ее выдаст, тут линкер ни при чем. Линкер всего-навсего поменяет base address, это один дополнительный ключ.
                      Про нулевые указатели — guard page — я писал выше и так
                        0
                        Зачем тратить место под guard page?
                        Rust же супер надежный и безопасный :)
                      +2

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


                      А по теме, что-то подумалось, а не перепишет ли в ближайшем будущем кто-нибудь, чисто по приколу, DOS на rust?

                        0
                        Grub та еще шляпа. К сожалению, по личному опыту, были большие проблемы с работоспособностью под USB 1.0
                          –3
                          Если КиБ и ГиБ ещё можно как то понять, то что значит МиБ? Мигабайт? Ну в целом статья полезная, информации по этой теме очень мало.
                            +5
                            Вам сюда. Я использую двоичные приставки, идеологически.
                              0
                              Тогда из текста стоит убрать все «мегабайты».
                                +1
                                Наплыв комментариев вынудил меня пойти копаться в ГОСТах и поправках, и вот что обнаружилось. КБ — 1024 байт, кБ — 1000 байт, и так далее. С другой стороны, в западных стандартах склоняются к KiB, MiB, etc… Сложный, в общем, вопрос. Убрал со всех статей КиБ, Миб, ГиБ, дабы никому глаз не резало и не мешало сути.
                                  0
                                  И заменить на «метры».

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

                            Самое читаемое