К вопросу о параметрах драйвера в Linux, или как я провел выходные

в 11:13, , рубрики: linux kernel, Программирование, Разработка под Linux

«Мы ленивы и нелюбопытны»

К вопросу о параметрах драйвера в Linux, или как я провел выходные - 1

На сей раз поводом к посту послужила статья в неплохом журнале, посвященном ОС Linux (далее по тексту Л), в которой привлеченный «эксперт» хвалил драйвер подключения ЖКИ к плате Raspbery. Поскольку подобные вещи (подключение, не ОС) входят в сферу моих профессиональных интересов, статью просмотрел с вниманием, потом нашел собственно текст «драйвера» и был слегка удивлен, что ЭТО можно хвалить. Ну, в общем то, уровень эксперта можно определить хотя бы потому, что он упорно именовал программу драйвером, несмотря на то, что она им никоим образом не является. Казалось бы, и фиг с ним, мало ли кто что пишет для себя, но выкладывать подобное в открытый доступ — «я и не знал, что так можно».

Особенно порадовало то обстоятельство, что адрес устройства на шине I2C напрямую задавался в тексте программы и для его изменения требовалась ее перекомпиляция (хорошо, что не всего ядра). Я, кстати, заметил, что на форумах, посвященных Л, наиболее популярный ответ на любой вопрос о проблемах в ПО, «пересобери последнюю версию ядра». Мне данный подход представляется несколько странным, наверное, я чего-то не знаю. Но, тем не менее, возник вопрос, а как в Л действительно реализуется (внутри, не снаружи — там все просто и понятно) параметризация драйвера, ответу на который и посвящен данный пост.

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

Но, прежде чем посмотреть исходники, сначала немного подумаем, а как бы мы это сделали, если бы получили подобную задачу (а вдруг пригласят меня после этого поста в майнтейнеры Л — и ведь не откажешься). Итак, имеется возможность создания модуля — некоей специальным образом оформленной программной единицы, которая может быть загружена в память для исполнения при помощи некоторой системной утилиты (insmode — далее И), при этом в качестве параметров запуска передается строка символов. Данная строка может иметь в своем составе строго определенные лексические единицы, описание формата которых задается при создании исходного тексте модуля, и эти единицы содержат информацию, позволяющую изменить значение внутренних переменных данного модуля.

Рассмотрим более внимательно способ описания вышеуказанных лексических единиц, это нам необходимо для рассмотрения различных вариантов решения. Определяется единица разбора вызовом макроса, которому сообщается необходимая информация — имя переменной, которую надлежит модифицировать в процессе настройки, ее внешнее имя (обычно совпадает с предыдущим), тип переменной из ограниченного набора и права доступа к переменной в стиле rw-rw-rw. Дополнительно может быть задана (необязательная) текстовая строка описания переменной. Очевидно, что эта информация необходима и достаточна (в совокупности с правилами оформления синтаксических единиц — разделителей и лексем) для построения парсера списка параметров, заданного в виде текстовой строки, но оставляет простор для реализации распределения функций между участника процесса.

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

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

Порассуждаем немного в стиле «если бы директором был я» и придумаем возможные реализации. Как мы могли бы реализовать подобное поведение системной утилиты и модуля — начнем разбор вариантов в порядке возрастания сложности.

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

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

У этого решения есть два недостатка:

  1. непонятно, зачем нам вообще И, можно сразу вызывать модуль с параметрами из командной строки,
  2. код модуля (инициализационная часть) должен содержать все три раздела необходимой информации, причем эта информация необходима только при запуске модуля и в дальнейшем не используется, а место занимает всегда. Сразу оговоримся, что эта информация в обязательном порядке занимает место в файле, а вот в память при загрузке модуля может и не уходить, если все сделать аккуратно. Для того, чтобы сделать именно так, вспоминаем директивы _init и _initdata (кстати, а как они работают, надо бы разобраться — вот и тема очередного поста — будете ждать его с нетерпением?). Но и в последнем случае разделы 2 и 3 информации в файле явно избыточны, поскольку один и тот же код будет присутствовать во множестве модулей, злостно нарушая принцип DRY.

В силу отмеченных недостатков реализация данного варианта весьма маловероятна. Более того, непонятно, зачем тогда в макросе задавать информацию о типе параметра, ведь сам модуль прекрасно знает, что он модифицирует (хотя она, может быть, нужна для парсера при проверке параметров). Общая оценка вероятности подобного решения — процента 2-3.

Необходимое отступление по поводу отмеченного недостатка номер 2 — я формировался, как специалист, в те времена, когда 256 кбайт оперативной памяти хватало для организации 4 рабочих мест, в 56 кбайтах работала двухзадачная ОС, а однозадачная ОС начинала работать при 16 кбайтах. Ну а 650 кб, которых должно хватить любой программе, были вообще чем то из области ненаучной фантастики. Поэтому я привык считать оперативную память дефицитным ресурсом и крайне неодобрительно отношусь к ее расточительному использованию, если это не вызвано крайней необходимостью (как правило, требованиями к быстродействию), а в данном случае я такой ситуации не наблюдаю. Поскольку большая часть моих читателей сформировалась в иных реалиях, у Вас могут быть свои оценки предпочтительности того либо иного варианта.

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

Под-вариант второго решения — передавать извлеченные параметры не в стартовую часть модуля, а непосредственно его загруженной рабочей части, например, через ioctl — требования по памяти те же. У нас появляется уникальная возможность менять параметры «на лету», не реализуемая в других вариантах. Не очень понятно, зачем нам может потребоваться такая фича, но выглядит красиво. Недостаток — 1) потребуется заранее зарезервировать часть области функций под, возможно, не используемый запрос и 2) код модификатора должен присутствовать в памяти постоянно. Оценка вероятности реализации — процентов 5.

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

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

Оставшиеся 7 процентов оставим на прочие варианты, которые я придумать не смог. Ну а теперь, когда наша фантазия себя исчерпала (моя точно, если есть еще идеи, прошу в комментарии), приступим к изучению исходников Л.

Для начала отмечу что, судя по всему, искусство распределения исходных текстов по файлам утеряно вместе с ОС, умещающимися в 16 кб, поскольку структура директорий, их наименования и имена файлов связаны с содержанием чуть более, чем никак. Учитывая наличие вложенных инклудов, классическое изучение скачанных исходников при помощи редактора превращается в странноватый квест и будет малопродуктивным. К счастью, есть очаровательная утилита Elixir, доступная в онлайн режиме, которая позволяет осуществлять контекстный поиск, и вот с ней процесс становится куда более интересным и плодотворным. Я свои дальнейшие изыскания проводил на сайте elixir.bootlin.com. Да, этот сайт не является официальным сборником сырков ядра, в отличии от kernel.org, но будем надеяться, что исходники на них идентичны.

Для начала посмотрим макрос определения параметров — во первых, мы знаем его название, во вторых, это должно быть проще (ага, сейчас). Расположен он в файле moduleparam.h — вполне разумно, но это приятная неожиданность, учитывая то, что мы увидим далее. Макрос

{0}module_param(name,type,perm)

представляет собой обертку над

 {0a}module_param_named(n,n,t,p)

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

Макрос {0а} содержит вызов трех макросов

{1}param_check_##t(n,&v)

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

{2}module_param_cb(n,&op##t,&v,p)

и

{3}__MODULE_PARM_TYPE(n,t)

(обратите внимание на названия, правда, прелесть), причем первый из них в других местах на используется, то есть рекомендациями Оккама и принципом KISS создатели Л также смело пренебрегают — видимо, какой то задел на будущее. Конечно, это всего лишь макросы, а они не стоят ничего, но все таки….

Первый из трех макросов {1}, как нетрудно понять из названия, проверяет соответствие типов параметров и обертывает

__param_check(n,p,t)

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

А вот два следующих макроса собственно и генерируют элемент таблицы параметров. Почему два, а не один — у меня не спрашивайте, я давно перестал понимать логику создателей Л. Скорее всего, исходя из разницы в стиле этих двух макросов, начиная с имен, второй из них был добавлен позже для расширения функциональности, а модифицировать имеющуюся структуру было нельзя, поскольку изначально пожалели выделить место для указания варианта параметров. Макрос {2}, как всегда, маскирует от нас макрос

{2a}_module_param_call(MODULE_PARAM_PREFIX,n,ops,arg,p,-1,0) 

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

Маленькое замечание — в процессе поисков убеждаемся, что навигация по текстам работает хорошо, но есть два неприятных обстоятельства: не работает поиск по фрагменту наименования (не найден check_param_, хотя check_param_byte обнаружен) и поиск работает только по объявлениям объектов (не найдена переменная — , то есть найдена в данном файле по ctrF, но встроенным поиском по исходникам не обнаруживается). Не слишком обнадеживает, ведь нам может потребоваться поиск объекта вне текущего файла, но «в конце концов, другого у нас нет».

В результате работы {1} в тексте компилируемого модуля при наличии следующих двух строк

module_param_named(name, c, byte, 0x444);
module_param_named(name1, i, int, 0x444);

появляется фрагмент типа нижеприведенного

static const char __param_str_name[] = "MODULE" "." "name";
static struct kernel_param const __param_name 
  __attribute__((__used__)) 
  __attribute__ ((unused,__section__ ("__param"),aligned(sizeof(void *)))) 
  = { __param_str_name, ((struct module *)0), &param_ops_byte, (0x444), -1, 0, { &c } };
static const char __UNIQUE_ID_nametype72[] 
  __attribute__((__used__)) __attribute__((section(".modinfo"), unused, aligned(1))) 
  = "parmtype" "=" "name" ":" "byte";

static const char __param_str_name1[] = "MODULE" "." "name1"; 
static struct kernel_param const __param_name1 
  __attribute__((__used__)) 
  __attribute__ ((unused,__section__ ("__param"),aligned(sizeof(void *)))) 
  = { __param_str_name1, ((struct module *)0), &param_ops_int, (0x444), -1, 0, { &i } };
static const char __UNIQUE_ID_name1type73[] __attribute__((__used__)) 
  __attribute__((section(".modinfo"), unused, aligned(1))) 
  = "parmtype" "=" "name1" ":" "int";

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

Забавно выглядит в последней порожденной строке сочетание атрибутов __used__ и unused одновременно, особенно, если посмотреть на следующий фрагмент кода макроса

#if GCC_VERSION < 30300
# define __used			__attribute__((__unused__))
#else
# define __used			__attribute__((__used__))
#endif 

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

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

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

Дальнейшее изучения макросов нас в понимании задачи не продвинет, поэтому обращаемся к исходному тексту утилиты И и попытаемся понять, что она делает.

Первым делом мы с изумлением убеждаемся, что требуемые сырки в исходники ядра не входят. Да, я готов согласится, что И — утилита и взаимодействует с ядром через точку входа для загрузки модуля, но любая книга по драйверам Л нам рассказывает об этой утилите, поэтому отсутствие «официальной» версии ее исходников где-нибудь рядом с исходниками ядра вызывает у меня непонимание. Ну да ладно, Гугл не подкачал и на сырки мы все равно вышли.

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

Ну и третья удивительная вещь, что собственно название утилиты нигде в исходниках не фигурирует, в названиях файлов не используется и встречается только в make файле, я знаю, что в С мы обязаны именовать главную функцию main, и это не обсуждается (лично я не в восторге от подобного, поскольку избалован Pascal, но моего мнения при проектировании языка не спрашивали), но хоть в комментариях написать внешнее имя утилиты можно было бы. Необходимое примечание — очень много вещей в языке С сделано по принципу «у нас так принято», наверное, когда-то сделать по-другому было трудно, или даже невозможно, ну да что теперь поделаешь, тащим с собой чемодан без ручки дальше.

Находим два пакета, содержащих исходный текст И, также находим сырки на github, видим, что они идентичны и принимаем на веру, что именно так выглядит исходный код утилиты. Далее изучаем только файл на git, тем более, что здесь он как раз называется insmod.c, обнаруживаем, что И для начала преобразует список параметров в одну длинную нуль-терминированную строку, в которой отдельные элементы разделены пробелами. Вслед за этим он вызывает две функции, первая из которых называется grub_file и очевидно открывает бинарник, а вот вторая имеет имя init_module и принимает указатель на открытый файл с бинарником модуля и строку параметров и называется load_module, что позволяет предположить назначение именно этой функции, как загрузку с модификацией параметров.

Обращаемся к тексту второй функции, которая лежит в файле… а вот тут облом — ни в одном из файлов исследуемого репозитория на Гит (ну это то как раз логично, это часть ядра и ее место не здесь) ее нет. Гугл опять спешит на помощь и возвращает нас к сыркам ядра под Elixir и файлу module.c. Следует отметить, что, на удивление, имя файла, содержащего функции работы с модулями, выглядит логично, даже не пойму, чем это объяснить, наверное, случайно получилось .

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

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

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

Например, я совершенно не понимаю, зачем делать внешнюю утилиту, чтобы переводить параметры из стандартной для операционной системы формы (agrc, argv) в форму нуль-терминированной строки с пробелами в качестве разделителей, которая обрабатывается далее системным же модулем — такой подход несколько превосходит мои когнитивные способности. Особенно, если учесть то обстоятельство, что пользователь вводит строку параметров в виде нуль-терминированной строки с пробелами в качестве разделителей и утилита в ядре преобразует ее в форму (argc,argv). Сильно напоминает старый анекдот «Снимаем чайник с плиты, выливаем из него воду и получаем задачу, решение которой уже известно». А поскольку я стараюсь придерживаться принципа «Считай собеседника не глупее себя, до тех пор, пока он не докажет обратное. И даже после этого, ты можешь ошибаться», и в отношении разработчиков Л первая фраза однозначно справедлива, то это означает, что я чего-то недопонимаю, а я так не привык. Если кто может предложить разумное объяснение изложенному факту двойного преобразования, то прошу в комментарии. Но продолжим расследование.

Перспективы осуществления вариантов 1 и 2 становятся «просматривающимися весьма слабо» (очаровательная формулировка из недавней статьи, посвященной перспективам разработки отечественных высокоскоростных АЦП), поскольку было бы весьма странно загрузить модуль в память при помощи функции ядра, а потом передать ему управление для осуществления реализуемой ядром функции, встроенной в его тело. И точно, в тексте функции load_module, мы довольно-таки быстро обнаруживаем вызов parse_args, — похоже, мы на верном пути. Далее быстро проходим по цепочке вызовов (как всегда, мы увидим и функции-обертки, и макросы-обертки, но мы уже привыкли закрывать глаза на подобные милые шалости разработчиков) и обнаруживаем функцию parse_one, которая и размещает требуемый параметр в нужном месте.

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

Однако, мы совсем упустили из вида вопрос о том, как функции разбора получают доступ к массиву образцов параметров, ведь без этого разбор несколько затруднителен. Быстрый просмотр кода показывает, что применен грязный хак очевидный прием – в бинарном файле функция find_module_sections ищет именованную секцию __param, делит ее размер на размер записи (делает еще много чего) и возвращает необходимые данные через структуру. Я бы все-таки поставил буковки p перед именами параметров этой функции, но это дело вкуса.

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

Подводя итоги – выходные прошли с пользой, было интересно разбираться в исходниках Л, кое-что вспомнил и кое-что узнал, а знание лишним не бывает.
Ну а в своих предположениях я не угадал, в Л реализован вариант, который оказался в 7 оставшихся процентах, но уж больно он не очевиден.

Ну и в заключение плач Ярославны (как же без него) почему необходимую информацию (я не имею в виду внутреннюю кухню, а внешнее представление) приходится искать по различных источникам, которые не имеют статуса официальных, где документ, аналогичный книге
«Программное обеспечение СМ ЭВМ. Операционная система с разделением функций.
РАФОС. Руководство системного программиста.», или таких больше не делают?

Автор: GarryC

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js