RT-11 — это операционная система из 1970-х годов для популярного в то время мини-компьютера PDP-11 фирмы DEC. В СССР известна под именами Электроника 60, ДВК, БК 0011М. Для тех, кто любит изучать чужой код в поисках инженерной эстетики — дальнейшее изложение.

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

Ядро RT-11 поставлялось в виде исходного кода, которое предполагается с помощью утилиты sysgen настроить и перекомпилировать под конкретное железо и возможности — знакомый сценарий для тех, кто пересобирал ядро Linux. Совпадение не случайно: Unix тоже зародилась в компании DEC.

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

  1. RT-11 Software Support Manual

  2. RT–11 System Internals Manual

  3. RT-11 Programmer's Reference Manual

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

Состав ядра:

  1. RMON — резидентный монитор, постоянно находится в оперативной памяти.

  2. USR — предоставляет функции для работы с файловой системой. Может быть временно выгружен на диск, чтобы освободить место программе пользователя.

  3. Драйверы устройств.

  4. KMON — интерпретатор командной строки для интерфейса с пользователем. При запуске программ тоже выгружается из памяти.

Резидентный монитор существует в нескольких вариантах: однозадачном, двухзадачном и с расширенной памятью. Мы рассмотрим однозадачный вариант в части асинхронного ввода-вывода и двухзадачный вариант в части реализации многозадачности. Из драйверов возьмем только драйвер дисковода. KMON оставим за скобками.

Примеры (псевдо)кода будут в виде, переведенном на язык Си, потому что он создан для PDP-11. В начале 70-х памяти было мало, ее старательно экономили, и поэтому длину идентификаторов ужимали до шести символов. Не удивляйтесь малопонятным шестибуквенным сокращениям в коде. Интересно отметить, что в стандартной библиотеке Си короткие идентификаторы дожили до наших дней.

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

Тема 1. Асинхронный ввод-вывод

PDP-11 — компьютер однопроцессорный и однопоточный. Однако периферия типа жесткого диска может выполнять операции ввода-вывода без участия центрального процессора и сигнализирует о завершении через механизм прерываний. Поэтому в RT-11 функция QMANGR лишь добавляет запрос операции ввода-вывода в очередь драйвера устройства. Аппаратное прерывание при завершении операции вызывает обработчик в драйвере, который вызывает функцию QCOMP, чтобы проинформировать программу о готовности данных.

Запуск операции может произойти в двух местах:

  • QMANGR — если очередь была пуста (устройство не занято).

  • QCOMP — если устройство освободилось.

Такой подход удобен, когда RT-11 работает в многозадачном режиме: центральный процессор не простаивает, когда одна программа ожидает результат ввода-вывода, а переключается на выполнение задачи с меньшим приоритетом (привет node.js).

Структуры данных

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

// Структура элемента очереди операций ввода-вывода
typedef struct QueueElement {
    // Q.LINK - указатель на следующий элемент
    struct QueueElement *link;
    // Q.CSW - указатель на запись канала
    Channel *csw;
    // Q.BLKN - номер блока на устройстве
    uint16_t block_number;
    // Q.FUNC - операция (0 = чтение/запись и т.д.)
    uint8_t func;
    // Q.UNIT - номер устройства (например, 1 в названии DX1)
    uint8_t unit;
    // Q.BUFF - адрес буфера (для чтения или записи)
    uint16_t *buffer;
    // Q.WCNT - количество слов (которые нужно прочитать или записать)
    int16_t word_count;
    // Q.COMP - функция, которую нужно вызвать после завершения операции
    void (*completion)(void);
} QueueElement;

// очередь из одного элемента
QueueElement QSTART;
QueueElement* AVAIL = &QSTART;
int16_t QCNT = 1;
int16_t QSIZE = 1;

Для работы с файлом необходим дескриптор файла, который называется Channel:

typedef struct {
    // C.CSW - Channel Status Word
    uint16_t csw;
    // C.SBLK - номер первого блока файла (0 если не файл)
    uint16_t start_block;
    // C.LENG - длина файла в блоках (если открыт через .LOOKUP)
    // или размер свободного места (если открыт через .ENTER)
    uint16_t length;
    // C.USED - наибольший записанный блок
    uint16_t used_block;
	uint16_t unused_field;
    // C.DEVQ - количество ожидающих запросов ввода/вывода
    uint8_t device_queue;
    // C.UNIT - номер устройства
    uint8_t unit;
    // C.SIZ - размер блока в байтах
    uint16_t block_size;
} Channel;

Непосредственно ввод-вывод осуществляет драйвер устройства:

typedef struct DeviceQueue {
    // последний элемент в очереди (LQE)
    QueueElement *last;
    // первый элемент в очереди (CQE)
    QueueElement *first;
    // обработчик, который берет в работу очередной элемент очечеди (CQE)
	void (*start)(struct DeviceQueue*);
    // ... остальные поля драйвера
    // interrupt_vector, interrupt_handler, stat
} DeviceQueue;

QMANGR: постановка в очередь

Вызывается внутри монитора при выполнении системного вызова. Забирает свободный элемент, заполняет его параметрами операции и добавляет в очередь драйвера.

/**
 * @param block - номер блока на диске
 * @param device - указатель на драйвер устройства
 * @param channel - указатель на CSW (Channel Status Word) - запись канала
 * @param buffer - адрес буфера
 * @param word_count - длина читаемого/записываемого в словах
 * @param unit - номер устройства
 * @param completion - функция завершения
 * @param is_async - асинхронный ввод-вывод
 */
void QMANGR(uint16_t block, DeviceQueue *device, Channel *channel, 
    uint16_t *buffer, uint16_t word_count, uint8_t unit,
	void (*completion)(void), bool is_async)
{
    QueueElement *current_elem;
    
    // Ожидаем появления свободного элемента в очереди
    do {
        INTON();
        INTOFF();
    } while (QCNT <= 0);
    
    // Забираем один элемент из списка свободных
    QCNT--;
    current_elem = AVAIL;
    AVAIL = current_elem->link;
    
    INTON();
    
    // Заполняем элемент очереди
    current_elem->link = NULL;
    current_elem->csw = channel;
    
    // Если уже слишком много запросов, то подождем их завершения
    while (channel->device_queue == 255) {
        // пустой цикл ожидания
		// устройство на аппаратном уровне вызывает прерывание и срабатывает его обработчик
		// который уменьшит device_queue
		// поэтому цикл не бесконечный
    }
    // Увеличиваем счётчик запросов в канале
    channel->device_queue++;
    
    // Продолжаем заполнение
    QueueElement *fill_ptr = current_elem;
    fill_ptr->block_number = block;
    fill_ptr->func = 0; // рассмотриваем только функции чтения/записи
    fill_ptr->unit = unit;
    fill_ptr->buffer = buffer;
    fill_ptr->word_count = word_count;
    fill_ptr->completion = completion;
    
    INTOFF();
    
    // Помещаем элемент в очередь драйвера
	QueueElement *first_elem = device->first;
	if (first_elem != NULL) {
		// Очередь не пуста - добавляем в конец
		device->last->link = current_elem;
		device->last = current_elem;
	} else {
		// Очередь пуста - элемент становится и первым, и последним
		device->first = current_elem;
		device->last = current_elem;
		// Запускаем обработку очередного элемента
		device->start(device);
	}
    
    INTON();
    
    // Если операция синхронная, то ожидаем завершения
    if (!is_async) {
        // Ожидаем, пока счётчик запросов не станет 0
        while (channel->device_queue != 0) {
            // пустой цикл ожидания
        }
    }
}

QCOMP: завершение операции

Вызывается из драйвера в обработчике прерывания. Она:

  1. Проверяет наличие ошибки оборудования.

  2. Обновляет счётчики канала.

  3. Удаляет элемент из очереди драйвера.

  4. Возвращает элемент в список свободных.

  5. Вызывает функцию завершения, если необходимо.

  6. Запускает следующий запрос в очереди (если есть).

/**
 * @param device - указатель на драйвер устройства
 */
void QCOMP(DeviceQueue *device)
{
    QueueElement *qe = device->first;
    int channel_num;
    void (*comp)(void);
    
    channel = qe->csw;
    
    // Проверяем бит ошибки оборудования в статусе канала
    if (channel->csw & HARD_ERROR_BIT) {
        // HALT - останов процессора
        exit(EXIT_FAILURE);
    }
       
    // Уменьшаем счётчик запросов в канале
    channel->device_queue--;
    
    // Сохраняем состояние процессора
    GETPSW();
    INTOFF();
    
    // Удаляем элемент из очереди обработчика
    // Проверяем, есть ли следующий элемент
    if (qe->link != NULL) {
        // Очередь не пуста - обновляем CQE
        device->first = qe->link;
    } else {
        // Очередь пуста - очищаем LQE и CQE
        device->first = NULL;
		device->last = NULL;
    }
    
    // Возвращаем элемент в список свободных
    qe->link = AVAIL;
    AVAIL = qe;
    QCNT++;
    
    // Получаем код завершения (COMP)
    comp = qe->completion;
    
    // Восстанавливаем состояние процессора
    PUTPSW();  // включает INTON
    
	// Вызываем функцию завершения
	if (comp != NULL) {
		comp();
	}
    
    // Если есть следующий элемент в очереди
    // то запускаем обработку очередного элемента
    if (device->first != NULL) {
        device->start(device);
    }
}

В качестве примеров использования этого фреймворка далее идет рассказ про драйвер дисковода и системный вызов .READ.

Тема 2. Драйвер дисковода DX (реализация)

Давайте посмотрим на примере кода драйвера дисковода DX, вариант реализации операции чтения/записи. Документацию взаимодействия с контроллером можно посмотреть здесь. Исходный код драйвера с авторскими комментариями доступен здесь.

Кратко про алгоритм чтения сектора. Размер сектора равен 128 байтам. На следующие два адреса отображаются порты ввода-вывода устройства DX:

// порт для передачи команд и чтения статуса
#define RX_CS 0177170
// порт для приема и передачи данных
#define RX_DB 0177172
  1. В регистр команд RX_CS нужно записать команду CS_READ.

  2. В регистр данных RX_DB нужно записать номер сектора.

  3. В RX_DB нужно записать номер дорожки.

  4. Подождать, пока на аппаратном уровне контроллер прочитает сектор с диска в свой буфер.

  5. В RX_CS нужно записать команду CS_EMPTY_BUF.

  6. Из RX_DB нужно последовательно 128/2=64 раз прочитать значение (контроллер последовательно слово за словом передает содержимое своего буфера).

Функция DXSTRT запускает операцию. По сути она копирует аргументы из элемента очереди в локальные переменные.

// интерфейс драйвера
DeviceQueue dx_driver = { .start = DXSTRT };

// адрес буфера
uint16_t* BUFFER_ADDR;
// логический номер сектора
uint16_t RX_LSN;
// код функции
int16_t RX_FUNC2;
// количество байт для чтения/записи
uint16_t BYTE_COUNT;

// счетчик прерываний
bool is_first_int;

void DXSTRT(DeviceQueue* device)
{
    QueueElement *cqe = device->first;
    
    int16_t oper = CS_GO;
    // кол-во слов, положительное означает чтение
    int16_t word_count = cqe->word_count;
    if (word_count < 0) {
        // запись
		oper = oper | CS_WRITE;
        word_count = -word_count;
    } else {
		// чтение
		oper = oper | CS_READ;
	}

	// Сохранение параметров операции
	BYTE_COUNT = word_count * 2;
    BUFFER_ADDR = cqe->buffer;
    RX_FUNC2 = oper;
	
	// Сохраняем логический номер сектора
	// 1 блок = 4 сектора
	RX_LSN = cqe->block_number * 4;
	
    is_first_int = true;
    uint16_t* cs_ptr = (uint16_t*)RX_CS;
	//нужно выставить бит CS_INT, чтобы устройство сгенерировало прерывание, 
	//если оно освободилось и готово принимать новые команды
    *cs_ptr |= CS_INT;
}

Далее дисковод генерирует прерывание о готовности и вызывается обработчик DXINT. Он прочитывает или записывает сектор на диск и делает это несколько раз в зависимости от переданного аргумента cqe->word_count.

Обратите внимание, что часть команд, записываемых в порт RX_CS, имеют 0 в бите прерывания CS_INT. Поэтому контроллер не генерирует прерывание после завершения команды. И код узнает о готовности чтением из регистра состояния RX_CS бита готовности.

// Ожидание готовности контроллера
// проверкой бита готовности
#define WAIT_READY    do { } while (!(*cs_ptr & 0x00FF));
#define WAIT_READY2   do { } while (!(*cs_ptr & CS_TRANSFER));

#define CHECK_ERROR   if (!(*cs_ptr & CS_TRANSFER)) { RXERR2(device); return; }

void DXINT()
{
	DeviceQueue* device = &dx_driver;
	
    uint16_t* cs_ptr = (uint16_t*)RX_CS;
    uint16_t* db_ptr = (uint16_t*)RX_DB;

    // Проверяем статус контроллера
    if (*cs_ptr & CS_ERR) {
        RXERR2(device);
        return;
    }

    uint16_t size = 128; // размер сектора в байтах
    if (CS_WRITE & RX_FUNC2) { // передача данных на диск
        if (!is_first_int) {
			// подготовка следующего сектора
            if (!NEXT_SEC(device, size)) {
			    return;
			}
        }
        // Код операции "заполнить буфер"
        SILOFE(CS_GO | CS_FILL_BUF, size, true);
    } else if (!is_first_int) { // получение данных с диска
        // Код операции "опустошить буфер"
        SILOFE(CS_GO | CS_EMPTY_BUF, size, false);
        if (!NEXT_SEC(device, size)) {
		    return;
		}
    }

    // Преобразование логического номера сектора в физические координаты
    // из RXLSN нужно получить: физическую дорожку (0..75), физический сектор (1..26)
    uint16_t track = RX_LSN / 26;
    uint16_t sector = RX_LSN % 26 + 1;

    // Выполнение команды на устройсте
    *cs_ptr = RX_FUNC2;
    WAIT_READY;
    CHECK_ERROR;

    // Записываем сектор в регистр данных
    *db_ptr = sector;
	WAIT_READY;
    CHECK_ERROR;

    // Записываем дорожку в регистр данных
    *db_ptr = track;
	
    is_first_int = false;
    *cs_ptr |= CSINT;
}

bool NEXT_SEC(DeviceQueue* device, uint16_t size)
{
    //Увеличиваем адрес буфера на размер сектора
    BUFFER_ADDR = BUFFER_ADDR + (size / 2);
    ++RX_LSN;
    BYTE_COUNT -= size;
    
    if (BYTE_COUNT > 0) {
        return true;          
    }

	// на этом операция завершена
    BYTE_COUNT = 0;
	//Очищаем CS контроллера
    uint16_t* cs_ptr = (uint16_t*)RX_CS;
    *cs_ptr = 0; 
	// Вызываем функцию завершения
    QCOMP(device);
	
    return false;
}

void SILOFE(uint16_t cmd, int max_buffer, bool is_fill)
{
    uint16_t* cs_ptr = (uint16_t*)RX_CS;
    uint16_t* db_ptr = (uint16_t*)RX_DB;

    //Записываем команду в контроллер
    *cs_ptr = cmd;

    uint16_t byte_count = BYTE_COUNT;
    if (byte_count == 0) {
        goto lzf;
    }
    
    // Сравниваем с размером сектора
    if (byte_count > max_buffer) {
        byte_count = max_buffer;
    }
    uint16_t* R2 = BUFFER_ADDR;

    // передача/получение буфера
    do { 
        WAIT_READY2;

        if (is_fill) {
            *db_ptr = *R2;
            R2++;
        } else {
            *R2 = *db_ptr;
            R2++;
        }

        byte_count -= 2;
    } while (byte_count > 0); 
   
lzf:
    uint16_t t;
    // заполнение нулями остатка
    while (true) { 
        WAIT_READY;

        if (*cs_ptr & CS_TRANSFER) {
            if (is_fill) {
                *db_ptr = 0;
            } else {
                t = *db_ptr;
            }
        } else {
            // значит CS_DONE
            break;
        }
    }
}

Тема 3. Системные вызовы

Программы обращаются к монитору посредством системных вызовов. Их список и описание можно посмотреть здесь. Системный вызов выполняется при помощи инструкции EMT — по сути это вызов подпрограммы, адрес которой записан по адресу 30_8. В момент запуска RT-11 записывает там ссылку на функцию EMTPRO, которая выступает в роли диспетчера, переадресуя вызов в зависимости от его аргументов. Передача аргументов может быть различной, рассмотрим в качестве примера EMT 375. В регистр R0 нужно записать адрес области памяти с аргументами и выполнить инструкцию EMT 375. Первый байт области — номер канала, второй — номер функции. Состав и расположение остальных аргументов специфично для каждой функции. В случае ошибки во время выполнения системного вызова будет установлен флаг переноса и код ошибки можно посмотреть в ERRBYT — ячейке памяти с адресом 52_8.

		; (числовые константы в ассемблере MACRO-11 - восмеричные)
		
		; параметры
ARGS:   .BYTE   CHANNEL	; номер канала ввода-вывода
		.BYTE	#10		; системный вызов .READ
        .WORD   BLOCK	; порядковый номер блока в файле, который нужно прочитать в буфер
        .WORD   USRBUF	; адрес буфера
        .WORD   1000	; длина буфера в словах, 
                        ; т.е. количество слов, которые нужно прочитать
        .WORD   0		; синхронный ввод-вывод
		
		; вызов
        MOV     ARGS,R0
        EMT     375
        BCS     IO_ERROR

На псевдокоде реализация EMTPRO выглядит так:

// количество каналов по умолчанию
#define CHNUM   16
// набор из 16 каналов
Channel _CSW[CHNUM];
// количество каналов
uint16_t I_CNUM = CHNUM;
// указатель на набор каналов
Channel* I_CSW = &_CSW[0];

bool EMTPRO(EmtParams *params)
{
    if (params->channel_num >= I_CNUM) {
        MONERR(7/*CHAN_E*/, 0, false);
        return false;
    }

    switch (params->function)
    {
        case FUNC_LOOKUP:
            return LOOKUP(&I_CSW[params->channel_num], params->file_name, params->channel_num);
            break;

        case FUNC_READ:
            return READ(&I_CSW[params->channel_num], params->block, params->buffer, params->size, 
                params->completion, params->is_async);
            break;

        case FUNC_WRITE:
            return WRITE(&I_CSW[params->channel_num], params->block, params->buffer, params->size);
            break;

        case FUNC_CSTAT:
            return CSTAT(&I_CSW[params->channel_num], params->buffer);
            break;
			
		// ...
		// и многие-многое другие системные вызовы
        
        default:
            exit(EXIT_FAILURE);
    }

    return false;
}

Системный вызов .READ

Параметры: номер канала, порядковый номер блока в файле, адрес буфера, размер буфера в словах и функция завершения в случае асинхронного ввода-вывода.

Назначение: чтение блока из канала и запись его в буфер. Если размер буфера больше одного блока, то прочитываются несколько блоков.

Алгоритм работы такой:

  1. Проверяется корректность номера блока.

  2. Из канала извлекается ссылка на драйвер.

  3. При помощи функции QMANGR добавляется операция чтения в очередь драйвера.

bool READ(Channel *channel, uint16_t block, uint16_t* buffer, uint16_t size,
	void (*completion)(void), bool is_async) 
{
    // Вычисление actual_size: сколько слов (<= size) можно прочитать, чтобы не выйти за конец файла
	uint16_t actual_size;
    uint16_t result = TSWCNT(block, size, channel, &actual_size);
    
	if (result == 7) {
		// Запрашиваемый блок за пределами файла
        return false;
    }
	
    // Получение индекса устройства (биты 1-5) из статуса канала
    uint16_t csw = channel->csw; 
    uint16_t dev_index = (csw & INDEX_MASK) >> 1;  
    
    // Получение драйвера из таблицы $ENTRY
    DeviceQueue* lqe = ENTRY[dev_index];
    
    // Вычисление абсолютного номера блока на диске =
	// относительный блок в файле + начальный блок файла на диске
    uint16_t physical_block = block + channel->start_block;
    
    // Добавление операции чтения в очередь драйвера
    QMANGR(physical_block, lqe, channel, 
		buffer, actual_size, channel->unit, 
		completion, is_async);
    
    return true;
}

uint16_t TSWCNT(uint16_t block, uint16_t size, Channel *channel, uint16_t *buffer_size) 
{
    *buffer_size = size;
    
    if (channel->start_block == 0) {
        // Файл не открыт
        return 0;
    }
    
    // сравниваем запрашиваемый блок с длиной файла в блоках
    uint16_t file_last_block = channel->length;
    if (block >= file_last_block) {
        // Запрашиваемый блок за пределами файла
        // Ошибка "Attempted to write past end-of-file."
        EMTERR(0);
        return 7;
    }
    
    // Преобразование длины запроса в блоки
    uint16_t req_blocks = *buffer_size;
	// добавляем 256 для округления вверх
    // req_blocks := (req_blocks + 256) / 256
    req_blocks += 0x00FF;
    req_blocks &= 0xFF00;
    req_blocks >>= 8;
    
    // Вычисление количества блоков до конца файла
    // блок конца буфера: начальный блок + длина запроса в блоках
    uint16_t last_block_to_read = block + req_blocks;
    
    uint16_t remaining_blocks = file_last_block - last_block_to_read;
    if (remaining_blocks >= 0) {
        // Достаточно блоков до конца файла
        return 2;
    }
    
    // Недостаточно блоков - осталось меньше, чем запрошено
    // Фактическое количество блоков до конца файла
    uint16_t remaining = last_block_to_read + remaining_blocks - block;
    // преобразуем длину в блоках в длину в словах
    remaining = (remaining << 8) | (remaining >> 8); // SWAB
	*buffer_size = remaining;
    
    return 1;
}

Системный вызов .WRITE очень похож на .READ, только actual_size передается в QMANGR отрицательным — это признак операции записи.

***

На этом первая часть повествования про устройство RT-11 завершена. В следующей части расскажем про файловую систему и работу с файлами.

Комментарии (2)


  1. vokchaks
    22.04.2026 05:41

    Было время. Работали с ней на ДВК-3(4) и на Электроника-85 запускали. Программы писали для предприятия. И народ работал. Междугородний переговорный пункт автоматизировали .. да и много чего. Спасибо, что напомнили :)


  1. vadimr
    22.04.2026 05:41

    Что это за сишный код? Разве RT-11 была написана не на ассемблере?