Загрузочная запись раздела PBR Windows 7

Метки:  , , , , , , , , ,

В предыдущей статье я рассказывал о логике работы загрузочного сектора MBR Windows 7. Основной целью работы MBR при традиционном способе загрузки (BIOS/POST→MBR), является нахождение, загрузка и передача управления коду загрузочного сектора раздела (PBR). В продолжении цикла статей о процессе загрузки операционной системы Windows, данная публикация освещает очередной этап загрузки ОС и посвящена изучению логики работы загрузочного сектора раздела PBR Windows 7.

Partition Boot Record (PBR) - загрузочная запись раздела (партиции), которая является очередным этапом запуска операционной системы (при традиционном, MBR/legacy способе загрузки) и выполняет действия по нахождению/чтению/загрузке/передаче управления более функциональной программе следующего этапа (в случае с Windows) - менеджеру загрузки NTLDR/BOOTMGR (в зависимости от версии ОС).

Многие задаются вопросом: зачем нам создавать такую, казалось бы, на первый взгляд неудобную и очевидно избыточную цепочку загрузки, не проще ли вовсе отказаться от этапа PBR и грузить загрузчик следующего этапа сразу из кода MBR? Технически это, безусловно, возможно, если бы не принятое в своё время соглашение, что код MBR считается независимым от какой-либо операционной системы, фактически универсальным [общим] этапом загрузки, а вот PBR уже относится к конкретной реализации операционной системы (фактически входит в её состав) и обеспечивает дальнейшую её загрузку. По этой же причине не совсем корректно называть этап PBR вторым (после MBR) условным этапом загрузки операционной системы Windows, логичнее было бы тогда считать его первым. Сектор PBR может размещаться на:

  • Устройствах хранения, имеющих разделы (жесткие/твердотельные диски) -- в качестве первого сектора раздела (партиции) - рассматриваемая нами ситуация;
  • Устройствах хранения, [формально] не имеющих разделов (гибкие диски, флешки) -- в качестве первого сектора диска (подменяет собой MBR);

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

  • напрямую :: кодом BIOS (по аналогии MBR);
  • косвенно :: кодом MBR (случай, описываемый в данной статье);
  • косвенно :: кодом менеджера загрузки (для Windows: NTLDR/Bootmgr);

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

В системах с несколькими разделами на диске, записей PBR может быть несколько - по одной записи на каждый первичный раздел (партицию).

Часто, в тех или иных источниках, можно встретить и альтернативное название загрузочной записи раздела - Volume Boot Record (VBR, загрузочная запись тома), реже встречается именование Partition Boot Sector (PBS, загрузочный сектор раздела).

В подавляющем большинстве сценариев загрузки сектор PBR (VBR) размещается на носителе начиная с первого сектора раздела (партиции). Зачастую первый сектор раздела путают с первым сектором [всего] физического диска (там обычно располагается MBR).

Код PBR загружается по уже хорошо нам знакомому (идентичному с MBR) стартовому адресу 0000:7C00. В операционной системе Windows 7 загрузочная запись раздела занимает аж целых 12 физических секторов (сами понимаете, что в других версиях операционной системы величина эта может варьироваться). На этот счет тоже имеются разночтения:

  • встречается мнение, что по аналогии с MBR, к PBR относятся все секторы (12), а первый сектор в цепочке секторов PBR носит название PBS.
  • есть и другая точка зрения, что PBR/PBS это только один сектор, а все остальные (идущие за ним) 11 секторов относятся уже к коду менеджера BOOTMGR.

Эта особенность для нас не столь уж значима и мы условимся, что для PBR у раздела, на котором он размещается, резервируются последовательно-идущие сектора.

Теперь воспользуемся специализированной утилитой DMDE и сохраним дамп сектора (фактически копия 512 байт, размещенных в нулевом секторе диска) PBR Windows 7 во внешний файл. Посмотрим, что же он из себя представляет:

Первые 2 байта (шестнадцатеричное: EB 52) занимает инструкция перехода jmp на 82 байта вперед по коду. Третий байт (90) представляет из себя инструкцию nop (No Op, нет операции), которая ничего не делает и фактически играет роль заполнителя. Спрашивается, зачем нам прыжок в самом начале кода? Причина подобного явления заключается в том, что здесь по соглашению располагаются служебные данные: сразу за указанными инструкциями перехода и заполнителя (jmp+nop) идет сигнатура (OEM ID) NTFS раздела (загрузочные разделы Windows 7 работают исключительно с файловой системой NTFS), занимающая 8 байт. Уже за сигнатурой в секторе размещается блок данных, носящий название BIOS Parameter Block (BPB).

BIOS Parameter Block (BPB) - структура, описывающая физическое расположение данных раздела (иначе: ключевых параметров файловой системы). Актуальна для разделов, содержащих файловые системы FAT12, FAT16, FAT32, HPFS, NTFS.

BPB бывает стандартным (в старых реализациях ОС) и расширенным, дополненным (современные реализации ОС). Расширенный блок параметров BIOS (BPB NTFS) занимает 73 байта и располагается в области со смещением 000B-0053. Рассмотрим подробнее его структуру:

Смещение Размер Значение Описание
000B 2 байта 00 02 (512) Размер сектора в байтах. 512. Не рекомендуется использоваться другие значения, поскольку в мире уже огромное количество кода использует именно это значение.
000D байт 08 Секторов на кластер. Размер кластера = 512*8 = 4Кб.
000E 2 байта 00 00 Зарезервированные сектора.
0010 1 байт 00 Для NTFS разделов всегда = 0. Количество таблиц FAT для FAT-раздела.
0011 2 байта 00 00 Для NTFS разделов всегда = 0. Максимальное количество записей в корневой директории для FAT12/16.
0013 2 байта 00 00 Для NTFS разделов всегда = 0, поскольку параметр на используется в NTFS. Общее количество секторов раздела.
0015 байт F8 Дескриптор носителя. F8 = Fixed Disk.
0016 2 байта 00 00 Для NTFS разделов всегда = 0. FAT: секторов на таблицу FAT.
0018 2 байта 3F 00 Геометрия диска. Секторов на дорожку.
001A 2 байта FF 00 Геометрия диска. Количество головок.
001C 4 байта 00 08 00 00 (2048) Количество "скрытых секторов". Количество скрытых секторов ОС Windows7/Vista было увеличено до значения 2048.
0020 4 байта 00 00 00 00 в NTFS не используется. Всегда = 0. FAT - старшая часть слова количества секторов.
0024 4 байта 80 00 80 00 в NTFS не используется. Всегда = 80008000. FAT: первый байт - номер диска (80h), второй байт - флаги, третий байт - сигнатура, четвертый байт - зарезервирован.
0028 8 байт FF 1F 03 00 00 00 00 00 Всего секторов в разделе.
0030 8 байт 55 21 00 00 00 00 00 00 Номер стартового кластера для $MFT файла раздела.
0038 8 байт 02 00 00 00 00 00 00 00 Номер стартового кластера для $MFTMirror файла раздела.
0040 знаковое двойное слово F6 00 00 00 Кластеров на каждую файловую запись. Значение знаковое. Поэтому, если старший бит первого байта равен 1, то длина записи равна степени двойки отрицательного значения. Например, если байт = F6, то длина записи равна 210 (потому как 10==-F6). Последние 3 байта всегда 0 для NTFS, не используются.
0044 двойное слово 01 00 00 00 Кластеров на каждый блок индекса. Алгоритм идентичен описанному выше. Последние 3 байта всегда 0 для NTFS, не используются.
0048 8 байт 1A AA 3B C8 C2 3B C8 CC Серийный номер раздела. На самом деле, по команде DIR мы видим не все байты номера.
0050 4 байта 00 00 00 00 Контрольная сумма. Почему всегда 0?
Структуру BPB приведена тут неспроста, она потребуется нам по ходу изложения, на неё мы будем время от времени ссылаться и пояснять кое-какие аспекты.

Теперь, что бы углубиться в логику работы, нам необходимо получить исходный код сектора. Я воспользуюсь привычным нам уже методом и дизассемблирую полученный ранее дамп сектора в виде .bin-файла с помощью дизассемблера IDA. После получения исходного кода приступим к его изучению.
В самом начале сектора PBR Windows 7, как мы уже и говорили, имеется переход по смещению 0000:7C54, это реализовано с целью "перепрыгнуть" описанный выше BPB.

  1. переход по адресу 0000:7C54
  2. пустой операнд (ничего не делаем)

Сразу за этим командами следует блок данных BPB NTFS. Его мы уже приводили и увидеть его можно на общем дампе сектора PBR Windows 7, размещенном выше в статье. Следующий фрагмент кода сектора PBR демонстрирует хорошо уже нам знакомую по предыдущей статье технику инициализации сегментов реального режима процессора:

  1. [метка]
  2. Запрещаем асинхронные аппаратные прерывания (все прерывания кроме немаскируемых);
  3. Обнуляем регистр AX;
  4. Обнуляем сегмент стека SS при помощи AX;
  5. Указатель стека SP инициализируем в значение 7С00h;
  6. Разрешаем асинхронные аппаратные прерывания.

Затем мы наблюдаем блок кода, который при помощи команды возврата из процедуры retf, выполняет переход по адресу 07C0:0066 (он же 0000:7C66). Поэтому, дальнейшая адресация, после данного блока, пойдет у нас уже относительно указанного адреса.

  1. Занесем в стек значение 07C0h;
  2. Восстановим его в DS. то есть DS=07C0h;
  3. Сохраним DS (07C0h);
  4. Занесем в стек значение 0066h;
  5. Команда возврата, извлекающая из стека значения для перехода. Прыгаем по адресу 07C0:0066.

Итак, ранее мы уже условились, что в начиная с текущего блока, код PBR Windows 7 будет адресоваться относительно сегмента и смещения 07C0:0066. Нижеприведенная часть кода проверяет наличие расширений прерывания int 13h. Подобная проверка уже имелась в коде MBR, из чего можно заключить, что использования общих блоков кода на начальном этапе загрузки операционной системы у PBR и MBR не наблюдается, возможно в каких-то сценариях код PBR выполняется без предварительного исполнения кода MBR?

  1. Запишем значение регистра DL в память по смещению 000Eh в сегменте данных. В DL у нас содержится номер диска. Остался от MBR, либо содержится в DL после передачи управления от BIOS;
  2. Проверяем двойное слово по смещению 0003 на значение "NTFS". Действительно ли это загрузочная запись NTFS?
  3. Если нет - то уходим на вывод ошибки A disk read error occured;
  4. Запишем в регистр AH значение 41h. Это номер функции;
  5. Запишем в регистр BX значение 55AAh. Необходимый параметр для функции 41h;
  6. Вызываем функцию прерывания. Определяем наличие расширений.

Код, приведенный далее, проверяет возвращенные функцией 41h прерывания 13h значения и отвечает на вопрос: доступны ли на компьютере расширения прерывания работы с диском (int 13h)? Для этого проверяется три условия.

  1. Если carry flag (CF, флаг переноса) не сброшен, то переходим на вывод ошибки A disk read error occured;
  2. Сравним слово в регистре BX со значением AA55h. Сменилось ли значение с 55AA на AA55? Это один из признаков успешного выполнения функции;
  3. Если не сменилось, то опять уходим на вывод ошибки A disk read error occured;
  4. Проверим нулевой бит регистра CX. Это второе условие наличия расширений;
  5. Если нулевой бит регистра CX не сброшен, то продолжаем выполняться и переходим по адресу 07C0:008D. Фактически проверяется условие CX > 0?
  6. [метка]
  7. Если же сброшен, то произошла ошибка и снова переходим на процедуру вывода ошибок по адресу 07C0:016A.

Что мы видим в идущем далее фрагменте? Управление на этот участок кода переходит только в случае обнаружения расширений прерывания 13h. Здесь код получает у нас расширенные параметры текущего накопителя для использования этих данных в дальнейшем. Делается это с помощью функции 48h прерывания 13h. Итогом является блок параметров или результирующий буфер, занимающий 30 байт и содержащий искомые параметры накопителя. Обратите внимание, что в этом фрагменте кода проводятся хитроумные манипуляции со стеком. Для лучшего понимания логики работы этого блока стоит обратить внимание на так называемые "внутренние переменные", которые активно используются кодом на данном этапе. Эти переменные адресуются непосредственно через регистр сегмента данных DS и в нашем коде имеют вид DS:XXXX:

  • ds:000E - номер диска.
  • ds:000B - размер сектора в байтах.
  • ds:000F - размер сектора в байтах, деленный на 16. Используется для приращения сегментного регистра ES в цикле.
  • ds:0011 - содержит абсолютный номер сектора для чтения через функцию 42h.
  • ds:0016 - счетчик количества циклов внутри процедуры sub_011D.
  • ds:001C - количество скрытых секторов.

Для начала, я приведу сам блок параметров:

Блок параметров
Смещение Размер Описание
00h 2 байта Размер буфера = 30 = 1Eh
02h 2 байта Информационные флаги
04h 4 байта Количество цилиндров диска (нумерация начинается с 0, поэтому реальных на 1 больше)
08h 4 байта Количество головок диска (нумерация начинается с 0, поэтому реальных на 1 больше)
0Ch 4 байта Секторов на дорожке (нумерация начинается с 1)
10h 8 байт Общее количество секторов на диске (нумерация начинается с 0, поэтому реальных на 1 больше)
18h 2 байта Байт на сектор
1Ah 4 байта (Опционально) Указатель на параметры расширенной конфигурацию диска (Enchanced Disk Drive, EDD)
  1. [метка]
  2. Сохраним DS в стек;
  3. Вычтем из текущего значения регистра SP значение 18h. Тем самым мы подготовим регистр SP как базу для задания смещения блока в регистре SI;
  4. Запишем в стек значение 1Ah. Тем самым смещаем указатель стека SP еще на 2 байта в минус;
  5. Зададим номер функции (48h) получения расширенного блока параметров диска;
  6. DL = индекс диска. Ранее мы его сохраняли во внутренней переменной ds:000Eh. Теперь восстанавливаем для использования;
  7. SI = SP. То есть SI = 7BE4. SI задает смещение буфера, куда будет помещен блок параметров диска. Таким образом блок размером в 30 байт у нас будет лежать по адресу ds:7BE4;
  8. Сохраним SS в стеке;
  9. Восстановим DS из стека. Таким способом мы приравняли DS = SS;
  10. Вызываем функцию прерывания. Считываем блок параметров;
  11. Загружаем младший байт регистра флагов в AH;
  12. Добавляем к SP значение 18h. Тем самым мы выставили значение SP = 7BFC, и не спроста. По данному адресу в стеке у нас лежит параметр "байт на сектор";
  13. Загружаем содержимое AH в младший байт регистра флагов;
  14. Восстановим AX. Теперь в AX у нас получается слово "байт на сектор";
  15. Восстановим DS;
  16. Если carry flag (CF) после вызова прерывания int 13h не сброшен, то произошла ошибка и мы переходим на вывод ошибки A disk read error occured;
  17. Сравним значение AX (байт на сектор, которое вернула функция 48h) со значением слова по смещению ds:000Bh (07C0:000B, или 0000:7C0B), то есть байт на сектор в BPB. Параметры диска совпадают?
  18. Если не равно, то переход на код вывода ошибки A disk read error occured;
  19. Сохраним содержимое регистра AX по адресу 07С0:000Fh (0000:7C0F). То есть мы записываем младший байт параметра "Reserved Sectors" и параметр "Количество таблиц FAT для раздела FAT". Видимо, просто трюк, поскольку последний параметр не нужен и всегда равен нулю для NTFS, его можно и подпортить?
  20. Логический сдвиг вправо слова по адресу ds:000Fh на 4 бита. Тем самым мы делим значение на 16. Зачем? Для того, чтобы далее по коду использовать в приращении значения сегментного регистра ES, который у нас отвечает за сегментную часть адреса считывания сектора в память;
  21. Сохраним значение сегментного регистра DS в стеке;
  22. Восстановим это значение в регистр DX. DX=DS;
  23. Обнулим регистр BX;
  24. Присвоим CX значение 2000h (десятичное 8192). Вероятно, CX у нас будет использоваться как счетчик считываемых байтов. Будем читать сразу 16 секторов. Что называется "с запасом", поскольку нам то нужно всего 8?
  25. Вычтем из CX значение AX. Это сделано для корректирования размера считываемого блока;
  26. Увеличим слово по адресу ds:0011h на единицу. Это незначащее поле BPB для NTFS, у нас используется как абсолютный номер сектора для чтения через функцию 42h. Поскольку начальное значение было нулевым, теперь там 1, тем самым указывая на второй от начала сектор раздела. Ведь первым является сам PBR, поэтому начинаем читать со следующего после него;
  27. [метка]
  28. Прибавляем к значению регистра DX значение слова по адресу ds:000Fh;
  29. ES = DX. Зачем нам в цикле каждый раз менять значение сегмента ES? Дело в том, что внутри процедуры чтения сектора sub_011D мы используем сегмент ES для задания сегментной части у целевого адреса, куда помещается считанный сектор;
  30. Увеличим на единицу слово по адресу ds:0016h. Это незначащее поле BPB для NTFS, вероятно используется как счетчик циклов внутри процедуры sub_011D. После увеличения приобретает значение 1, поскольку изначально поле было нулевым;
  31. Вызываем подпрограмму sub_011D, которая у нас отвечает за чтение секторов;
  32. Вычтем из CX (начальное значение =8192), значение AX (=512). Таким образом каждую итерацию цикла уменьшаем счетчик;
  33. Переход на cs:00C5, если CX больше AX. Тем самым образуется цикл чтения секторов по условию.

Следующий далее участок кода, по аналогии с кодом из MBR, предназначен для определения наличия TPM версии 1.2. Это действительно странно, поскольку практически аналогичную проверку мы уже проводили в коде MBR, однако разработчики решили определить наличие TPM и тут? Возможно предположения относительно "автономности" кода PBR не лишены основания? Как и ранее, возникает предположение, что это сделано для того, что бы в каком-то гипотетическом сценарии загрузки PBR мог грузиться независимо от MBR.

  1. Загружаем в регистр AX номер функции BB00h (TCG_StatusCheck);
  2. Вызываем прерывание 1Ah;
  3. Сравним значение регистра EAX с нулем. Одно из условий;
  4. Если не ноль, то BIOS не поддерживаем спецификацию TCG, выходим из кода TCG и переходим по адресу 010D;
  5. Иначе, если поддержка есть - проверим регистр EBX на значение 41504354h (TCPA), значения расположены в обратном порядке;
  6. Если значение не совпадает, то выход из кода TCG, переход по адресу 010D;
  7. Сравним значение регистра CX с 102h. Версия 1.02?
  8. Если нет, то выход из кода TCG - переход по смещению 010D.

Идущий далее блок кода PBR Windows 7 у нас выполняется в случае поддержки TPM оборудованием и в случае наличия необходимой разработчикам версии TPM - 1.02. Вызывается функция TCG_CompactHashLogExtendEvent. Более подробно аналогичный участок кода описан в предыдущей статье, посвященной MBR.

Нижележащий блок кода предназначен для заполнения нулями блока данных по адресу ES:1028h.

  1. [метка]
  2. Обнулим регистр AX. Ноль будет использоваться как заполнитель;
  3. Инициализируем регистр DI значением 1028h. Начиная с адреса ES:DI (ES:1028h) у нас пойдет циклическая запись нулей;
  4. В регистр CX запишем счетчик байтов - 0FD8h (4056);
  5. Сбросим флаг направления (DF), то есть направление - вперед, регистр DI будет увеличиваться каждый раз на единицу;
  6. Выполнить команду инициализации строки байт. Область памяти будет заполнена нулями;
  7. Переход по адресу cs:027Ah. Сюда мы ранее считали весь оставшийся код PBR, размещенный на диске начиная со второго сектора раздела и (скорее всего) представляет собой простейший парсер NTFS, предназначенный для нахождения и загрузки файла Bootmgr. Всё, мы ушли!
  8. Нет операции. Простой заполнитель. Резерв.
  9. Нет операции. Простой заполнитель. Резерв.

В конце кода первого сектора PBR Windows 7 у нас размещена подпрограмма считывания секторов диска, выполняющая "расширенное" чтение. Для этого используется функция 42h прерывания 13h. Процедура за один проход читает по одному сектору. Заметьте, в отличие от кода MBR, код PBR выполняет чтение только с помощью "расширенной" функции чтения. В процедуре используется несколько внутренних переменных:

  • ds:0011h - содержит абсолютный номер сектора для чтения через функцию 42h. начальное значение =1, то есть начинаем отсчет со второго сектора раздела.
  • ds:001Ch - количество скрытых секторов. прибавляем это значение для определения [начального] номера сектора для чтения.
  • ds:000Eh - номер диска.
  • ds:000Fh - размер сектора в байтах, деленный на 16. Используется для увеличения сегментного регистра ES.
  • ds:0016h - счетчик количества внутренних циклов. всегда равно 1 при входе в процедуру.

Финальная часть кода PBR Windows 7 представляет собой блок вывода сообщений об ошибках, которые могут возникать в процессе исполнения кода. Сам вывод происходит довольно распространенным методом, при помощи функции 0Eh прерывания 10h. После вывода сообщений происходит приостановка процессора при помощи исполнения инструкции hlt (до момента возникновения внешнего прерывания). Следом располагается "короткий" переход на адрес 0176, который "зацикливает" hlt.

  1. [метка]
  2. Загружаем указатель на строку (8Ch) из ячейки памяти по адресу ds:01F8h в регистр AL;
  3. Вызываем процедуру вывода;
  4. Загружаем указатель на строку (D6h) из ячейки памяти по адресу ds:01FBh в регистр AL;
  5. Вызываем процедуру вывода;
  6. После вывода всех ошибок следует останов инструкцией hlt;
  7. На всякий случай, если hlt отработала некорректно (?)- выполняем прыжок по адресу 0176 (вечный цикл);
  8. [подпрограмма/процедура]
  9. Сама процедура вывода строки. Тут у нас вычисляется реальный адрес выводимой строки. Делается это следующим образом: заносим в ah значение 1, а в AL у нас уже есть значение указателя. Тем самым пара AH+AL (или, другими словами регистр AX) примут вид 1XXh, то есть либо 18Ch для первого и 1D6h для второго вызовов процедуры. А эти значения и есть реальные смещения сообщений об ошибках. Стоит заметить так же, что строки начинаются с преамбулы в два байта ODh, 0Ah, "возврат каретки" и "перевод строки" соответственно. Это распространенная техника перевода строки при использовании функций прерывания 10h;
  10. SI = AX;
  11. [метка]
  12. Загрузим байт в AL из DS:SI;
  13. Сравним AL с нулем, не конец ли строки? после каждой строки у нас располагается классический нуль-терминатор, знаменуя её окончание;
  14. Если достигнут конец строки - выход из процедуры;
  15. AH = 0Eh - функция вывода на экран (в режиме телетайпа);
  16. BX = 7 - атрибут вывода;
  17. Вызов функции 0Eh прерывания 10h. Собственно, вывод [одного] символа на экран;
  18. Цикл по адресу 017D, пока не выведем на экран всю строку (по одному байту);
  19. Возврат из процедуры печати (вывода) символов.

То есть, в коде, который представлен первым сектором PBR, мы видим вывод всего двух сообщений A disk read error occured и Press Ctrl+Alt+Del to restart. Все остальные сообщения, которые мы могли наблюдать на дампе сектора, вероятнее всего выводятся уже кодом из последующих секторов PBR, изучение которых мы будем проводить в будущем.

Заключение

На этом анализ кода первого сектора PBR Windows 7 завершен. Стоит отметить, что логика работы не всех частей кода PBR была мною раскрыта должным образом, однако, надеюсь устранить данный недостаток в будущем, когда пройдет затяжной приступ лени. По сути, что сделал у нас код первого сектора PBR Windows 7? Фактически, он загрузил код следующих за ним 8 секторов, которые все вместе составляют код PBR, с носителя в память и передал этому коду управление. На этапе PBR все операции выполняются в реальном, 16-битном режиме работы процессора.

Комментарии: 4

  1. FeelUs

    приглядитесь к команде
    0000:0121 mov eax, ds:11h
    по-моему он читает сектора не со следующего после VBR а со следующего после зарезервированных секторов после VBR

    1. FeelUs

      вернее приглядитесь к следующей команде
      0000:0125 add eax, ds:1Ch

      1. einaare

        да, если учесть что в ds:1Ch - количество скрытых секторов, то так оно и есть. действительно была неточность, исправил.

  2. Виталий

    Очень крутая статья! Спасибо за материал!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *