CP/M — это известная операционная система для 8 битных процессоров Intel 8080. Если вам интересно узнать, как она была устроена на самом деле (не по пересказам, а по оригинальному исходному коду) — добро пожаловать под кат.

Границы исследования

Ядро CP/M состоит из трех основных компонентов:

  • CCP (Console Command Processor) — командный процессор.

  • BDOS (Basic Disk Operating System) — основная дисковая система, отвечающая за файловые операции.

  • BIOS (Basic Input/Output System) — аппаратно-зависимая часть с драйверами устройств.

BIOS мы рассматривать не будем, так как она сильно привязана к конкретному железу. С её API можно ознакомиться здесь.

Оригинальные исходные коды CCP и BDOS с авторскими комментариями доступны здесь.

Изначально я хотел изучить версию 2.2, но она написана на ассемблере, и этот момент сильно осложняет изучение. К счастью, более ранние версии написаны на языке PL/M (Programming Language for Microcomputers). Доступны исходные коды BDOS версии 1.4 и CCP версии 0.7. Ими и ограничимся.

BDOS (Basic Disk Operating System)

С API BDOS можно ознакомиться здесь. Она предоставляет набор операций для работы с файловой системой. Чтобы понять, как работает BDOS, нужно разобраться в устройстве самой файловой системы — фундаментальной концепцией операционных систем.

Файловая система

Диск (8-дюймовый гибкий магнитный диск объемом 250 Кбайт) разбит на 77 дорожек (track). Каждая дорожка поделена на 26 секторов размером 128 байт. Сектор — минимальная порция данных для чтения или записи.

У сектора есть два номера: физический номер в пределах дорожки и уникальный логический номер (logical sector number) в пределах всего диска. Интересно, что физический порядок секторов на дорожке не совпадает с их номерами из-за из-за оптимизации interleaving: последовательные сектора расположены с промежутками для ускорения последовательного чтения. Но на логику работы BDOS это не влияет.

Первые 2 дорожки на диске — загрузочные, там записан образ ОС (CCP, BDOS и BIOS), который при старте компьютера загружается в память. Следующие 16 секторов отведены под единственный каталог (CP/M поддерживает только один каталог на диске). В остальной части диска хранятся сами файлы.

Запись о файле (Directory Entry)

Информация о файлах хранится в виде записей о файлах — directory entry или dirent. Структура для разных версий ОС описана здесь.

В языке 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) заполняются следующие структуры данных:

  1. в Dlog добавляется номер диска.

  2. в Alloc[диск] резервируются блоки 0 и 1 (они выделены под каталог).

  3. на основе записей о файлах в Alloc[диск] добавляются номера занятых блоков.

  4. в процессе чтения записей о файлах в Check[диск] записываются контрольные суммы секторов (см. процедуры ReadDir, CheckSum). Они нужны, чтобы обнаруживать ситуацию, когда пользователь физически сменил дискету в дисководе, но монтирование ее не произвел.

Структура FCB (File Control Block)

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.

Поиск файла (системные вызовы 17 и 18)

Поиск - центральная процедура файловых операций. Она используется в остальных системных вызовах для работы с файлами.

Для поиска первой записи о файле используется процедура 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;

Открытие файла (системный вызов 15)

Вызывает процедуру 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;

Удаление файла (системный вызов 19)

Вызывает процедуру 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;

Чтение файла (системный вызов 20)

Процедура 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;

Запись файла (системный вызов 21)

Процедура 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 (Console Command Processor)

В современных терминах CCP — это интерпретатор командной строки.

Выполнен в виде бесконечного цикла, который

  1. выводит приглашение (например, "A>").

  2. читает команду пользователя (процедура READCOM).

  3. парсит и классифицирует ее. Определяет, является ли команда внутренней (встроенной в CCP, такие как DIR, ERA, TYPE) или же это имя исполняемой программы на диске (процедура INTRINSIC).

  4. подготовка аргументов. Заполняет FCB-структуру COMFCB, в которую записываются имя файла и расширение, извлеченные из командной строки (процедура FILLFCB).

  5. исполняет команду. Запускает либо внутренний код, либо загружает и передает управление внешней программе с диска.

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 оказался полезным и, возможно, вдохновит вас покопаться в историческом коде знаменитых систем.