- PVSM.RU - https://www.pvsm.ru -
CP/M — это известная операционная система для 8 битных процессоров Intel 8080. Если вам интересно узнать, как она была устроена на самом деле (не по пересказам, а по оригинальному исходному коду) — добро пожаловать под кат.
Ядро CP/M состоит из трех основных компонентов:
CCP (Console Command Processor) — командный процессор.
BDOS (Basic Disk Operating System) — основная дисковая система, отвечающая за файловые операции.
BIOS (Basic Input/Output System) — аппаратно-зависимая часть с драйверами устройств.
BIOS мы рассматривать не будем, так как она сильно привязана к конкретному железу. С её API можно ознакомиться здесь [1].
Оригинальные исходные коды CCP и BDOS с авторскими комментариями доступны здесь [2].
Изначально я хотел изучить версию 2.2, но она написана на ассемблере, и этот момент сильно осложняет изучение. К счастью, более ранние версии написаны на языке PL/M (Programming Language for Microcomputers). Доступны исходные коды BDOS версии 1.4 и CCP версии 0.7. Ими и ограничимся.
С API BDOS можно ознакомиться здесь [3]. Она предоставляет набор операций для работы с файловой системой. Чтобы понять, как работает BDOS, нужно разобраться в устройстве самой файловой системы — фундаментальной концепцией операционных систем.
Диск (8-дюймовый гибкий магнитный диск объемом 250 Кбайт) разбит на 77 дорожек (track). Каждая дорожка поделена на 26 секторов размером 128 байт. Сектор — минимальная порция данных для чтения или записи.
У сектора есть два номера: физический номер в пределах дорожки и уникальный логический номер (logical sector number) в пределах всего диска. Интересно, что физический порядок секторов на дорожке не совпадает с их номерами из-за из-за оптимизации interleaving: последовательные сектора расположены с промежутками для ускорения последовательного чтения. Но на логику работы BDOS это не влияет.
Первые 2 дорожки на диске — загрузочные, там записан образ ОС (CCP, BDOS и BIOS), который при старте компьютера загружается в память. Следующие 16 секторов отведены под единственный каталог (CP/M поддерживает только один каталог на диске). В остальной части диска хранятся сами файлы.
Информация о файлах хранится в виде записей о файлах — directory entry или dirent. Структура для разных версий ОС описана здесь [4].
В языке PL/M нет привычных record или struct, поэтому на псевдокоде это выглядит так:
DirEnt = packed record
Status: byte; (* Статус: 0xE5 - удален, иначе - занят *)
Filename: array [0..7] of char;
Extension: array [0..2] of char;
Extent: byte; (* Номер экстента (см. ниже) *)
Reserved1, Reserved2: byte;
RecordsCount: byte; (* Кол-во секторов, занятых данными в этом экстенте *)
Allocation: array [0..15] of byte; (* номера блоков, отведенных для файла *)
end;
Каждая запись занимает 32 байта, поэтому всего на диске может быть 64 записей о файлах.
В массиве Allocation логично было бы хранить номера секторов файла. Но есть нюанс: в один байт помещается только число от 0 до 255, а секторов на диске около двух тысяч. Поэтому разработчики пошли на хитрость: они объединили 8 секторов в один блок размером 1 Кб. Теперь в Allocation хранятся номера блоков, а не секторов.
Если файл занимает меньше 16 блоков, то оставшиеся элементы Allocation заполняются нулями.
Неиспользуемое пространство в конце последнего блока файла заполняется значением 0xE5.
Если файл занимает больше 16 блоков, то в каталоге хранятся несколько записей (экстентов) для этого файла. У них совпадают имя и расширение, а поле Extent последовательно увеличивается. В Allocation каждого такого экстента хранятся следующие блоки с содержимым файла.
В RecordsСount хранится количество реально используемых секторов в данном экстенте. Максимум 0x80 (или 128) = 16 блоков по 8 секторов.
Если в поле Status записано 0xE5, то данная запись считается удалённой.
Функция BDOS вызывается из ассемблера следующим образом:
CALL 0005H
При этом в регистр C надо записать номер функции, в регистр DE — ее аргумент (часто указатель на FCB).
Например, открытие файла выглядит так:
MVI C, 15
LXI D, fcb
CALL 0005H
Далее рассмотрим некоторые операции над файлами.
Размонтирование диска происходит, например, при теплом старте (после завершения работы программы). Тогда же автоматически монтируется диск A:. Остальные диски монтируются при первом обращении к ним.
Информация о смонтированных дисках хранится в глобальной переменной Dlog. Это битовая маска:
Declare Dlog byte initial (0);
или на псевдокоде
const MaxDsk = 3; (* наибольший номер диска *)
var Dlog: set of [0 .. MaxDsk];
Информация о свободных/занятых блоках хранится в переменных Alloci (и всего их MaxDsk + 1)
Declare Alloc2 (32) byte;
или на псевдокоде
const MaxAll = 242; (* наибольший блок *)
var Alloc: array [0 .. MaxDsk] of set of [0 .. MaxAll];
Контрольные суммы секторов, выделенных для записей о файлах, хранятся в переменных Checki (и всего их — MaxDsk +1)
Declare ChkSiz equ '16',
Check2 (ChkSiz) byte;
При размонтировании Dlog присваивается пустое множество (см. DskMon системный вызов 13).
При монтировании (см. Select, Initialize) заполняются следующие структуры данных:
в Dlog добавляется номер диска.
в Alloc[диск] резервируются блоки 0 и 1 (они выделены под каталог).
на основе записей о файлах в Alloc[диск] добавляются номера занятых блоков.
в процессе чтения записей о файлах в Check[диск] записываются контрольные суммы секторов (см. процедуры ReadDir, CheckSum). Они нужны, чтобы обнаруживать ситуацию, когда пользователь физически сменил дискету в дисководе, но монтирование ее не произвел.
FCB — это дескриптор файла, который используется при выполнении системных вызовов для работы с файлами. На псевдокоде эта структура выглядит так:
FCB = packed record
DriveNumber: byte; (* Номер дисковода (0 - текущий, 1 - A:, 2 - B:, и т.д.) *)
Filename: array [0..7] of char;
Extension: array [0..2] of char;
Extent: byte;
Reserved1, Reserved2: byte;
RecordsCount: byte; (* Заполняется BDOS *)
Allocation: array [0..15] of byte; (* Заполняется BDOS *)
CurrentRecord: byte; (* Текущая запись (сектор) внутри экстента, используется BDOS *)
end;
Он очень похож на DirEnt. В поле DriveNumber передается 0 когда файл находится на текущем дисководе.
Если файл находится на другом дисководе, то нужно передать его номер.
CP/M также хранит глобальное состояние: CURDSK - текущий дисковод (A: — 0, B: — 1, и т.д.), CURTRK - текущая дорожка, CURREC - logical sector number начального сектора на CURTRK.
Программа заполняет DriveNumber, Filename, Extension и, при необходимости, Extent.
BDOS заполняет private-поля RecordsCount, Allocation и возможно CurrentRecord.
Поиск - центральная процедура файловых операций. Она используется в остальных системных вызовах для работы с файлами.
Для поиска первой записи о файле используется процедура SEARCH. Для поиска следующей записи о файле используется процедура SEARCHN.
Поиск работает путем сравнения записей о файлах на диске с образцом в FCB.
При вызове SEARCH передаются указатель на FCB и количество начальных байт в записи (SEARCHL), которые должны совпасть. Эти параметры сохраняются в глобальных переменных SEARCHA и SEARCHL, чтобы SEARCHN могла их использовать.
В поле DriveNumber FCB можно указать диск для поиска. Номер диска сохраняется в переменной CURDSK, и поле DriveNumber очищается, потому что в записях о файлах на диске они нулевые.
В полях Filename и Extension FCB можно использовать символ '?' для поиска по маске.
Меняя длину поиска SEARCHL, можно искать с разной точностью (об этом — в описании конкретных функций).
Результат поиска хранится в переменной DCNT - номер записи о файле.
Вот как выглядит реализация на PL/M:
DECLARE FRE EQU '12',
FNM EQU '13';
DECLARE SEARCHL BYTE, /* SEARCH LENGTH SET BY SEARCH */
SEARCHA ADDRESS; /* SEARCH ADDRESS SET BY SEARCH */
SEARCHN: PROCEDURE;
/* SEARCH FOR THE NEXT DIRECTORY ELEMENT, ASSUMING A PREVIOUS
CALL ON SEARCH WHICH SETS SEARCHA AND SEARCHL */
DECLARE (I,C) BYTE;
INFO = SEARCHA;
DO FOREVER;
CALL READ$DIR(FALSE);
IF (RET := DCNT) = 255 THEN RETURN;
I = 0;
DO WHILE (I < SEARCHL) AND
/* MATCH OR QUESTION MARK */
((C := S(I)) = GETBUFF(DPTR+I) OR C = 63);
I = I + 1;
END;
IF I = SEARCHL THEN RETURN;
END;
END SEARCHN;
SEARCH: PROCEDURE(XL);
DECLARE XL BYTE;
SEARCHL = XL;
SEARCHA = INFO;
DCNT = 255;
CALL HOME;
/* NOW READY TO READ THE DISK */
CALL SEARCHN;
END SEARCH;
Вызывает процедуру SEARCH с длиной FNM (13 байт). Это означает, что ищем полное совпадение по имени, расширению и полю Extent.
Найдя запись, BDOS копирует поля RecordsCount и Allocation из записи каталога в FCB.
OPEN: PROCEDURE;
DECLARE I BYTE;
/* SEARCH FOR DIRECTORY ENTRY, COPY TO FCB */
CALL SEARCH(FNM);
IF DCNT <> 255 THEN
DO I=FNM TO LFB;
S(I) = GETBUFF(DPTR+I);
END;
END OPEN;
Вызывает процедуру SEARCH(12) с длиной FRE (12 байт). То есть ищем по имени и расширению, игнорируя Extent. Это позволяет найти все экстенты файла.
Для каждой найденной записи:
из Alloc[диск] удаляются номера занятых блоков, указанные в поле Allocation записи.
поле Status меняется на 0xE5 (признак удаленного файла).
обновленная запись сохраняется на диск.
DELETE: PROCEDURE;
DECLARE (I,J,K) BYTE;
CALL CHECK$WRITE; /* WRITE PROTECTED? */
/* SEARCH ONLY UP THROUGH THREE CHARACTER EXTENT */
CALL SEARCH(FRE);
DO FOREVER;
IF DCNT = 255 THEN /* NO MORE ENTRIES MATCH */ RETURN;
/* SET EACH NON-ZERO DISK MAP ENTRY TO 0 IN ALLOC VECTOR */
CALL SCANDM(0);
BUFF(DPTR) = EMP;
/* ARECORD HAS BEEN PREVIOUSLY SOUGHT BY READDIR */
CALL WRDIR; /* WRITE DIRECTORY ENTRY */
CALL SEARCHN;
END;
END DELETE;
Процедура DISKREAD читает с диска очередной сектор с данными файла.
В этой процедуре сектор называется RECORD, что вносит путаницу с записью о файле. В общем случае файл разбит несколько (N) секторов. Они условно пронумерованы 0, 1, ..., N-1 и названы RecordNumber. Они не имеет отношения ни к номеру сектора на дорожке, ни к logical sector number.
Процедура состоит из следующих частей (упрощенно на псевдокоде):
(* Проверка конца файла *)
const MaxRecordsCount = 127;
if (fcb.CurrentRecord >= fcb.RecordsCount) and (fcb.CurrentRecord > MaxRecordsCount)
then return 1;
(* При необходимости переход к следующему экстенту *)
if (fcb.CurrentRecord >= fcb.RecordsCount) and (fcb.CurrentRecord = MaxRecordsCount)
then begin
OPEN$REEL(TRUE); (* заполняет в fcb значениями RecordsCount и Allocation из записи о файле с fcb.Extent+1 *)
fcb.CurrentRecord := 0;
end;
(* Вычисление номера сектора на диске *)
var ARECORD: word;
ARECORD := fcb.Allocation[fcb.CurrentRecord div 8]; (* 8 = количество секторов в одном блоке *)
ARECORD := ARECORD*8 + fcb.CurrentRecord mod 8;
(* вычисление номера дорожки и номера сектора на дорожке из ARECORD *)
SEEK;
(* Вызываются процедуры SETTRK, SETSEC, READ из BIOS, *)
(* чтобы прочитать сектор в область памяти, указанную заранее при помощи системного вызова 26 *)
READ;
(* чтобы при следующем вызове DISKREAD прочитался следующий сектор с данными файла *)
fcb.CurrentRecord := fcb.CurrentRecord + 1;
return 0;
Оригинальный код:
DISKREAD: PROCEDURE;
CALL GETFCB;
IF RCOUNT <= VRECORD THEN
DO; RET = 1;
IF VRECORD = 128 THEN CALL OPEN$REEL(TRUE);
VRECORD = 0;
IF RET <> 0 THEN RETURN;
END;
DO; CALL INDEX;
/* ERROR 2 IF READING UNWRITTTEN DATA */
IF LOW(ARECORD) = 0 THEN RET = 1; ELSE
DO; CALL ATRAN;
/* ARECORD IS NOW ACTUAL DISK ADDRESS */
CALL SEEK;
/* NOW READ THE BUFFER */
CALL SETDMA; /* SELECTS DATA DMA ADDRESS */
CALL RDBUFF;
CALL RESETDMA; /* SELECTS DIR DMA ADDRESS */
CALL SETFCB;
END;
END;
END DISKREAD;
Процедура DISKWRITE записывает сектор с данными файла на диск.
Процедура состоит из следующих частей (упрощенно на псевдокоде):
(* Проверка выделения блока *)
var ARECORD: word;
ARECORD := fcb.Allocation[fcb.CurrentRecord div 8]; (* 8 = количество секторов в одном блоке *)
(* Если ARECORD = 0, то это значит что в записи о файле не указан блок для этого сектора, *)
(* т.е. нужно выделить для него место на диске. *)
(* GET$BLOCK ищет в Alloc[диск] номер свободного блока *)
fcb.Allocation[fcb.CurrentRecord div 8] := GET$BLOCK();
(* Вычисление номера сектора на диске *)
ARECORD := fcb.Allocation[fcb.CurrentRecord div 8];
ARECORD := ARECORD*8 + fcb.CurrentRecord mod 8;
(* вычисление номера дорожки и номера сектора на дорожке из ARECORD *)
SEEK;
(* Вызываются процедуры SETTRK, SETSEC, WRITE из BIOS, *)
(* чтобы записать в сектор на диске данные из области памяти, указанную заранее при помощи системного вызова 26. *)
WRITE;
(* чтобы при следующем вызове DISKWRITE записался следующий сектор с данными файла *)
if fcb.RecordsCount <= fcb.CurrentRecord
then fcb.RecordsCount := fcb.CurrentRecord + 1;
if fcb.CurrentRecord == MaxRecordsCount
then begin
OPEN$REEL(FALSE); (* заполняет в fcb значениями RecordsCount и Allocation из записи о файле с fcb.Extent+1 *)
(* если такая запись о файле не найдена, то добавляет ее *)
fcb.CurrentRecord := 0;
end
else fcb.CurrentRecord := fcb.CurrentRecord + 1;
Оригинальный код:
DISKWRITE: PROCEDURE;
DECLARE (I,L) BYTE;
CALL CHECK$WRITE; /* IN CASE WRITE PROTECTED */
CALL GETFCB;
IF VRECORD > MRC THEN /* PAST EOF, NEXT REEL NOT OPENED */
RET = 1; ELSE
DO; CALL INDEX;
IF LOW(ARECORD) = 0 THEN /* NOT ALLOCATED */
DO; /* THE ARGUMENT TO GET$BLOCK IS THE STARTING POSITION
FOR THE DISK SEARCH - THIS SHOULD BE THE LAST ALLOCATED
BLOCK FOR THIS FILE, OR THE VALUE 0 IF NO SPACE HAS BEEN
ALLOCATED TO THIS FILE */
I = 0;
IF (L := FDM + SHR(VRECORD,3)) > FDM THEN
/* THERE IS A PREVIOUS BLOCK ALLOCATED */ I = S(L-1);
IF (I := GET$BLOCK(I)) = 0 THEN /* NO MORE SPACE */
RET = 2; ELSE
DO; CALL SET$ALLOC$BIT(I,1);
/* BLOCK IS ALLOCATED */
ARECORD, S(L) = I;
END;
END;
/* CONTINUE IF NO ERROR IN ALLOCATION */
IF RET = 0 THEN
DO; CALL ATRAN;
CALL SEEK;
CALL SETDMA;
CALL WRBUFF;
CALL RESETDMA;
IF RCOUNT <= VRECORD THEN RCOUNT = VRECORD+1;
/* CHECK FOR END-OF-REEL, IF FOUND ATTEMPT TO OPEN
NEXT REEL IN PREPARATION FOR THE NEXT WRITE */
IF VRECORD = MRC THEN
DO;
/* UPDATE CURRENT FCB BEFORE GOING TO THE NEXT EXTENT */
CALL SETFCB; CALL OPENREEL(FALSE);
/* VRECORD REMAINS AT MRC CAUSING END-OF-FILE
IF NO MORE DIRECTORY SPACE IS AVAILABLE */
IF RET = 0 THEN VRECORD = 255; /* GOES TO ZERO */
RET = 0;
END;
CALL SETFCB;
END;
END;
END DISKWRITE;
В современных терминах CCP — это интерпретатор командной строки.
Выполнен в виде бесконечного цикла, который
выводит приглашение (например, "A>").
читает команду пользователя (процедура READCOM).
парсит и классифицирует ее. Определяет, является ли команда внутренней (встроенной в CCP, такие как DIR, ERA, TYPE) или же это имя исполняемой программы на диске (процедура INTRINSIC).
подготовка аргументов. Заполняет FCB-структуру COMFCB, в которую записываются имя файла и расширение, извлеченные из командной строки (процедура FILLFCB).
исполняет команду. Запускает либо внутренний код, либо загружает и передает управление внешней программе с диска.
BREAK:
CALL CRLF;
/* ARRIVE HERE ON BREAK KEY OR BACKSPACE TO BEGINNING OF LINE */
DO; /* MONITOR COMMAND PROCESSOR */
DECLARE (I,J) BYTE;
DECLARE LA ADDRESS;
INCLA: PROCEDURE;
/* UTILITY PROCEDURE TO INCREMENT THE LOAD ADDRESS */
LA = LA + 80H;
END INCLA;
DO FOREVER;
/* SET THE DMA ADDRESS AT 80H */
CALL MON1(26,80H);
CALL PRINTCHAR('A' + CSELECT);
CALL PRINTCHAR ('>');
CALL READCOM;
CALL CRLF;
IF COMLEN > 0 THEN
DO;
CBP = 0;
CALL INTRINSIC;
CALL FILLFCB;
IF INT = 0 THEN /* DIRECTORY */
DO;
CALL SEARCHF;
DO WHILE DCNT <> 255;
DO I=1 TO 12;
IF I = 9 THEN CALL PRINTCHAR(' ');
CALL PRINTCHAR(GETBUFF((ROR(DCNT,3) AND 0110$0000B)+I));
IF BREAK$KEY THEN GO TO BREAK;
END;
CALL CRLF;
CALL SEARCHN;
END;
END;
ELSE IF INT = 1 THEN /* DELETE COMMAND */
DO;
CALL DELETE(.COMFCB);
END;
ELSE IF INT = 2 THEN /* TYPE COMMAND */
DO;
CALL SEARCHF;
IF DCNT = 255 THEN CALL COMERR(CBP);
ELSE
DO;
CALL OPEN(.COMFCB);
DO WHILE DISKREAD(.COMFCB) = 0;
I = 0;
DO WHILE I < 128 AND (J := GETBUFF(I)) <> E5H;
CALL PRINTCHAR(J); I = I + 1;
IF BREAK$KEY THEN GO TO BREAK;
END;
END; /* DISKREAD */
END; /* OF FILE PRESENT */
END;
ELSE IF INT = 3 THEN /* SAVE */
DO;
/* ... */
END;
ELSE IF INT = 7 THEN /* RENAME FILE */
DO;
/* ... */
END;
ELSE
/* LOOK FOR THE COMMAND ON DISK */
DO; /* MAY BE A DISK PREFIX */
IF INT <= 5 THEN /* A: = 4, B: = 5 */
CALL SELECT(INT - 4);
IF COMLEN > CBP THEN
DO;
CALL FILLFCB;
CALL MOVE(.'COM',.COMFCB+9,3);
CALL OPEN(.COMFCB);
IF DCNT = 255 THEN CALL COMERR(0);
ELSE
DO;
LA = TRAN;
DO WHILE (I := DISKREAD(.COMFCB)) = 0;
CALL MOVE(80H,LA,128);
CALL INCLA;
END;
IF I = 1 THEN
DO; /* LOAD OK */
CBP = TCBP;
CALL FILLFCB;
/* SET-UP FCB DIRECTLY AHEAD OF BUFF */
CALL MOVE(.COMFCB,5CH,33);
CALL TRANSIENT(5CH);
END;
ELSE CALL ERROR;
END;
END;
END;
END;
END; /* OF DO FOREVER */
END; /* OF MONITOR COMMAND PROCESSOR */
В этом коде также можно увидеть примеры использования системных вызовов для поиска, удаления, последовательного чтения файла.
Удивляет простота CP/M: один каталог на диск, максимум 64 файла, фиксированный набор драйверов и никакой многозадачности или асинхронного ввода/вывода. И несмотря на это — большая популярность.
Но простота снижает порог входа. Чтобы познакомиться с устройством ОС, не нужно продираться сквозь миллионы строк кода. Исходники CP/M — удобный учебник для тех, кто хочет понять, как работают операционные системы "под капотом".
Надеюсь, этот небольшой ликбез по внутренностям CP/M оказался полезным и, возможно, вдохновит вас покопаться в историческом коде знаменитых систем.
Автор: IlyasKzn
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/fajlovaya-sistema/446373
Ссылки в тексте:
[1] здесь: http://seasip.info/Cpm/bios.html
[2] здесь: http://www.cpm.z80.de/source.html
[3] здесь: http://seasip.info/Cpm/bdosfunc.html
[4] здесь: http://seasip.info/Cpm/formats.html
[5] Источник: https://habr.com/ru/articles/1009044/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1009044
Нажмите здесь для печати.