В операционной системе RT-11 существуют варианты резидентного монитора с поддержкой многозадачности — например, RMONFB. Многозадачность здесь реализована полностью на программном уровне, без аппаратной поддержки. Если вам интересно посмотреть исходный код, отвечающий за многозадачность в RT-11, вместе с пояснениями из Руководства системного программиста — добро пожаловать под кат.

Ликбез по ассемблеру PDP-11

Без знания ассемблера PDP-11 не обойтись, поэтому приведем здесь описание части инструкций. Полное описание можно найти в здесь.

Скрытый текст

В процессоре PDP-11 имеется восемь 16-битных регистров: R0, …, R7. Указатель стека SP — это R6, счетчик команд PC — это R7.

Присваивание (обратите внимание на обратный порядок аргументов):

MOV src, dst	; dst = src

Регистровая адресация:

MOV R1, R2		; R2 = R1

Непосредственная адресация:

MOV #12, R2		; R2 = 012

Косвенная адресация:

MOV (R3), R4	; R4 = R3 
MOV @R3, R4		; R4 = R3

Косвенная адресация с инкрементом:

MOV (R3)+, R4	; R4 = R3++ 
MOV -(R3), R4	; R4 = (–-R3)

Косвенная адресация со смещением:

MOV JNUM(R3), R4	; R4 = R3->JNUM или R4 = *(R3 + JNUM)

Добавление и извлечение из стека:

MOV R4, -(SP)		; PUSH(R4) 
MOV (SP)+, R4		; R4 = POP()

Безусловный переход:

JMP label		; goto label 
BR label		; goto label

Сравнение и условный переход:

CMP R4, R5 
BNE label		; if (R4 != R5) goto label

CMP R4, R5 
BLO label		; if (R4 < R5) goto label

CMP R4, R5 
BHI	label		; if (R4 > R5) goto label 
BGT	label		; if (R4 > R5) goto label 
BEQ	label		; if (R4 = R5) goto label

TST R3 
BMI	label		; if (R3 < 0) goto label 
BPL label		; if (R3 > 0) goto label

Побитовые операции:

BIC R1, R2		; R2 = R2 & ~R1 
BIS R1, R2		; R2 = R2 | R1

Вызов подпрограммы и возврат:

		JSR Ri, lsubr	; PUSH(Ri)
						; Ri = lafter
						; goto lsubr
lafter:
		; ...
		
lsubr:
		; ...
		RTS Ri			; temp = Ri
						; Ri = POP()
						; goto temp

Общая идея алгоритма

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

Аналогичное переключение происходит и в конце обработчиков прерываний.

RT-11 относится к операционным системам реального времени, поэтому цель планировщика — выполнять наиболее приоритетное задание, а не распределять время процессора равномерно между всеми.

Задания и приоритеты

Номер задания в RT-11 — всегда четное число. Фоновому заданию (background job) присвоен номер 0, а оперативному заданию (foreground job) — 2 (в двухзадачной конфигурации системы). В многозадачной конфигурации фоновому заданию также присвоен номер 0, а номер оперативного задания всегда 16_8. Номера остальных заданий (системных) находятся между ними.

Приоритет определяется номером задания: минимальный у фонового, максимальный у оперативного. Динамически изменить приоритет нельзя. В резидентном мониторе номер активного задания хранится в переменной JOBNUM.

Смешанная область и контекст

Вся информация, необходимая резидентному монитору для управления выполнением задания, хранится в так называемой смешанной области (impure area). У фонового задания эта область расположена внутри самого монитора, у остальных — в адресном пространстве задания.

Приведем некоторые данные, которые в ней хранятся (на псевдокоде):

typedef struct {
    // I.JSTA - слово состояния задания
    uint16_t JSTA;
	// I.QHDR - указатель на первый свободный элемент очереди операций ввода-вывода
    QueueElement* QHDR;
	// I.CMPE - указатель на последний элемент очереди подпрограмм завершения операций ввода-выводам
    CompQueueElement* CMPE;
	// I.CMPL - указатель начала очереди подпрограмм завершения операций ввода-вывода
    CompQueueElement* CMPL;
	// I.CHWT - указатель на канал:
	// если задание ожидает, когда счетчик количества элементов очереди канала достигнет нуля,
	// то указатель на канал хранится здесь.
    Channel* CHWT;
	// I.JNUM - номер задания, которому принадлежит эта смешанная область
    uint16_t JNUM;
	// I.CNUM - количество каналов (размер массива I_CSW)
    uint16_t I_CNUM;
	// I.CSW - указатель на массив каналов данного задания
    Channel* I_CSW;
	// I.IOCT - общее число запросов ввода-вывода у задания
    uint16_t IOCT;
	// I.SP - указатель на стек задания
    uint16_t SP; 
	// I.QUE - место для одного элемента очереди ввода-вывода (стандартная очередь из одного элемента)
    QueueElement QUE; 
	// ...
} ImpureData;

// массив ссылок на ImpureData
ImpureData* $IMPUR[MXJNUM/2+1];

Например, очень важным является поле JSTA, в битах которого хранится информация о состоянии процесса:

; Задание ожидает освобождения USR.
USRWT$  =     20
; Задание приостановлено в результате выполнения команды SUSPEND.
KSPND$  =    100
; Есть элементы в очереди подпрограмм завершения операций ввода-вывода.
CPEND$  =    200
; Задание ожидает завершения всех операций ввода-вывода (перед завершением программы).
EXIT$   =    400
; Программа не запущена.
NORUN$  =   1000
; Задание приостановлено.
SPND$   =   2000
; Задание ожидает, когда счетчик количества элементов очереди канала достигнет нуля.
CHNWT$  =   4000
; Задание ожидает освобождения места в буфере вывода терминала.
TTOWT$  =  20000
; Задание ожидает ввода с терминала.
TTIWT$  =  40000
; Выполняется подпрограмма завершения операции ввода-вывода в этом задании.
CMPLT$  = 100000
; Условие блокировки выполнения задания (побитовое или)
BLOCK$  = TTIWT$ ! TTOWT$ ! CHNWT$ ! SPND$ ! NORUN$ ! EXIT$ ! KSPND$ ! USRWT$

Каждое задание имеет свой собственный стек. Контекст задания сохраняется в стеке задания и восстанавливается из него при переключении между заданиями. В контекст входят:

  • ссылка на ImpureData,

  • системная область связи (system communication area) — область памяти с адресами 34_8 - 53_8,

  • содержимое регистров процессора.

Указатель на стек (на момент системного вызова) активного задания хранится в переменной TASKSP, а ссылка на ImpureData хранится в CNTXT.

Процедура переключения контекста CNTXSW

Резидентный монитор вызывает подпрограмму CNTXSW, чтобы сменить контекст и переключиться с одного задания на другое. У нее один аргумент — R5, в нем передается ссылка на смешанную область задания, на которое нужно переключиться, т.е. $IMPUR[job_number/2]. Первым делом в стек активного задания сохраняется содержимое регистров (часть уже сохранили в момент системного вызова):

;; @param R5 - контекст другого задания
CNTXSW:
        MOV     TASKSP,R4		; R4 = TASKSP
        CMP     CNTXT,R5
        BNE     10$				; if (CNTXT == R5) {
        JMP     C.20$			;     goto C.20$
10$:							; }
        MOV     R3,-(R4)		; R4.PUSH(R3)
        MOV     R2,-(R4)		; R4.PUSH(R2)
        MOV     R1,-(R4)		; R4.PUSH(R1)
        MOV     R0,-(R4)		; R4.PUSH(R0)

Далее в тот же стек сохраняется системная область связи:

        MOV     #34,R0			; R0 = 034
								; do {
20$:    MOV     (R0)+,-(R4)		;     TASKSP.PUSH(*R0++)
        CMP     R0,#54
        BLO     20$				; } while (R0 < 054)

И в конце в CNTXT->SP сохраняется ссылка на вершину стека:

        MOV     CNTXT,R2
        ADD     #I.SP,R2
        MOV     R4,(R2)+		; CNTXT->SP = TASKSP

После этого для другого задания в обратном порядке восстанавливаются:

  • CNTXT — ссылка на ImpureData,

  • TASKSP — ссылка на вершину стека другого задания,

  • системная область связи,

  • содержимое регистров.

        MOV     R5,CNTXT		; CNTXT = R5
        ADD     #I.SP,R5
        MOV     @R5,R4			; R4 = CNTXT->SP
								; do {
85$:    MOV     (R4)+,-(R0)		;     *(--R0) = R4.POP()
        CMP     R0,#34
        BHI     85$				; } while (R0 > 034)
        MOV     (R4)+,R0		; R0 = R4.POP()
        MOV     (R4)+,R1		; R1 = R4.POP()
        MOV     (R4)+,R2		; R2 = R4.POP()
        MOV     (R4)+,R3		; R3 = R4.POP()
        MOV     R4,TASKSP		; TASKSP = R4

Далее проверяется наличие элементов в очереди подпрограмм завершения операций ввода-вывода:

C.20$:  MOV     CNTXT,R5		; R5 = CNTXT
        MOV     I.JNUM(R5),JOBNUM ; JOBNUM = CNTXT->JNUM
        TST     @R5
								; // CMPLT$ = Выполняется подпрограмма завершения в этом задании
        BMI     150$			; if (!(CNTXT->JSTA & CMPLT$)) {
        TSTB    @R5
								;     // CPEND$ = Есть элементы в очереди подпрограмм завершения операций ввода-вывода
        BPL     150$			;     if (CNTXT->JSTA & CPEND$) {
		BIC     #<BLOCK$!CPEND$>&^C<NORUN$>,@R5	; CNTXT->JSTA &= ~((BLOCK$ | CPEND$) & ~NORUN$)
        BIS     #CMPLT$,@R5		;         CNTXT->JSTA |= CMPLT$
        ; далее происходит добавление в стек адреса менеджера очереди подпрограмм завершения
		; чтобы его выполнить после переключения на задание
		; ... (пропустим этот код)
								;     }
								; }
150$:   RTS     PC				; return

Запрос планировщика

Планировщик запускается только при наличии соответствующего запроса. Номер задания, инициировавшего запуск планировщика, хранится в переменной INTACT = JOBNUM / 2 + 200_8. Изменение INTACT происходит путем вызова подпрограммы $RQTSW. В INTACT хранится номер задания с наивысшим приоритетом из запрашивавших планировщик, поэтому $RQTSW игнорирует запросы от заданий с более низким приоритетом.

;; @param R5 - номер задания
$RQTSW: CMP     R5,JOBNUM
        BLO     1$				; if (R5 >= JOBNUM) {
$RQSIG: SEC
        RORB    R5				;     R5 = R5 / 2 + 0200
        JSR     PC,GETPSW		;     GETPSW() //сохранение приоритета в стеке
        SPL     7				;     SPL(7) //запрет прерываний
        CMPB    R5,INTACT
        BLOS    2$				;     if (R5 > INTACT) {
								;         //обновление приоритета для планировщика
        MOVB    R5,INTACT		;         INTACT = R5
								;     }
2$:     JSR     PC,$MTPS		;     $MTPS() //восстановление прежнего приотритета
        ASLB    R5				;     R5 = (R5 - 0200) * 2
								; }
1$:     RTS     PC				; return

Переключение в системный режим

Для того чтобы изолировать задания от монитора, система предоставляет два режима выполнения:

  • пользовательский режим (user state),

  • системный режим (system state).

У каждого задания свой стек и своя смешанная область, у монитора — свой системный стек.

При выполнении системного вызова происходит переключение в системный режим. Для этого используется подпрограмма $ENSYS. Обработчики прерываний используют аналогичную подпрограмму $INTEN.

Для вызова $ENSYS используется макрос ENSYS. У него один аргумент — адрес, куда передается управление после выхода из системного режима. Код, который следует за вызовом ENSYS и до инструкции RTS PC выполняется в системном режиме:

        ENSYS   3$
		; начало кода, который выполняется в системном режиме
        MOVB    #377,USROWN
        MOV     IMPLOC,R4
        ; ...
		; конец кода, который выполняется в системном режиме
        RTS     PC
3$:		; место после выхода из системного режима

ENSYS поддерживает вложенные вызовы. Для этого заведен счетчик INTLVL с начальным значением -1. В начале выполнения ENSYS он увеличивается на единицу, перед выходом из нее — уменьшается. При переходе -1 → 0 происходит переключение в системный режим. При обратном переходе 0 → -1 происходит переключение в пользовательский режим.

Если посмотреть на определение макроса ENSYS, то видим, что у подпрограммы $ENSYS два аргумента — относительный адрес выхода из системного режима и приоритет процессора, который нужно установить перед выполнением кода:

.MACRO  ENSYS   ADR
        JSR    R5,$ENSYS
          .WORD  ADR-.	; относительный адрес ADR
          .WORD  340	; 0-й приоритет процессора
.ENDM   ENSYS

Итак, в начале увеличивается счетчик INTLVL и происходит переключение на системный стек, ссылка на вершину которого хранится в RMSTAK:

;; Вызывается другими подпрограммами монитора, которым необходимо перейти в системный режим.
;; Инструкции следующие за вызовом $ENSYS, будут выполняться в системном режиме.
;; При появлении инструкции RTS PC происходит переход в пользовательский режим
;; и передача управления по адресу, указанному при вызове $ENSYS.
$ENSYS::
        ; пропустим вычисление абсолютного адреса возврата
        ; и добавление PSW в стек перед адресом возврата (т.е. имитация прерывания)
        ; ...
        
        SPL     7			; SPL(7) // 7-й приоритет процессора (запрет прерываний)
;; Вызывается из обработчиков прерываний.
;; Осуществляет переключение в системный режим.
;; Ожидается что предыдущие PSW и PC сохранены в стеке задания.
;; Вход в подпрограмму $INTEN происходит на 7-м приоритете процессора.
$INTEN: MOV     R4,-(SP)
        INC     (PC)+		; INTLVL++
INTLVL: .WORD   -1
        BGT     1$			; if (INTLVL == 0) { //еще не в системном режиме
							;     //переключение на системный стек
        MOV     SP,(PC)+	;     TASKSP = SP
TASKSP: 0
        MOV     (PC)+,SP	;     SP = RMSTAK
RMONSP: RMSTAK
							; }
1$:     MOV     R4,-(SP)

Далее вызывается код, расположенный после ENSYS, как подпрограмма с помощью инструкции JSR:

RMONPS: MOV     #PS,R4
        BIC     (R5)+,@R4	; SPL(arg) //установка приоритета процессора из указанного в агрументе вызова $INTEN
        MOV     (SP)+,R4
        JSR     PC,@R5		; action() //выполнение инструкций, следующих за вызовом $INTEN или $ENSYS, до инструкции RTS PC

После завершения подпрограммы возможны различные варианты. Самый простой случай — выход из вложенного ENSYS. Происходит только уменьшение счетчика INTLVL:

		SPL     7			; SPL(7) // 7-й приоритет процессора (запрет прерываний)
        TST     INTLVL
        BEQ     EXUSER		; if (INTLVL != 0) {
        DEC     INTLVL		;     INTLVL--
        BR      RTICMN		;     return //INTLVL > 0 => все еще остаемся в системном режиме
							; }

Если же ситуация перехода 0 → -1, т.е. готовы переключиться в пользовательский режим, то возможен вариант, когда не было запроса на переключение на другое задание (INTACT пустой). В этом случае происходит уменьшение счетчика INTLVL и переключение на стек активного задания:

EXUSER: SPL     0			; SPL(0) // 0-й приоритет процессора (разрешение всех прерываний)
		
		; далее происходит вызов менеджера очереди fork
		; ... (пропустим этот код)
		
        SPL     7			; SPL(7) // запрет прерываний
        MOV     (PC)+,R4
INTACT: 0
        BNE     EXSWAP		; if (INTACT == 0) {
							;     //переключение на другое задание не требуется
        DEC     INTLVL		;     INTLVL--
        MOV     TASKSP,SP	;     SP = TASKSP //переключение на стек активного задания
RTICMN: MOV     (SP)+,R4	;     // восстановление регистров
        MOV     (SP)+,R5
        RTI					;     return
							; }

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

EXSWAP: BMI     ABORT
        CLR     INTACT			; INTACT = 0
        SPL     0				; SPL(0) //разрешение прерываний
        INC     R4
        ASLB    R4
        MOV     R4,JOBNUM		; JOBNUM = INTACT.JNUM + 2
        ADDR    $IMPUR,R4,ADD	; R4 = &$IMPUR[JOBNUM]
								; //просматриваем состояния заданий в порядке убывания их приоритета начиная с INTACT.JNUM
								; do {
1$:     SUB     #2,JOBNUM		;     JOBNUM -= 2
        BMI     3$				;     if (JOBNUM < 0) goto 3$
        MOV     -(R4),R5		;     R5 = &$IMPUR[JOBNUM]

Задание может не существовать (программа не запущена). Тогда оно исключается из рассмотрения:

        BEQ     1$				;     if (R5 == NULL) continue

Если задание блокировано, то появляется возможность передать управление менее приоритетному заданию:

        BIT     #BLOCK$,@R5
        BEQ     2$				;     if (!(R5->JSTA & BLOCK$)) break
        TST     @R5
        BMI     1$				;     if (R5->JSTA & CMPLT$) continue // Выполняется подпрограмма завершения в этом задании
        TSTB    @R5				;
        BPL     1$				;     if (!(R5->JSTA & CPEND$)) continue //Есть элементы в очереди подпрограмм завершения операций ввода-вывода
        BIT     #KSPND$!NORUN$,@R5	; // KSPND$ = Задание приостановлено в результате выполнения команды SUSPEND.
									; // NORUN$ = Программа не запущена.
        BNE     1$				; } while (R5->JSTA & (KSPND$ | NORUN$))

Если задание существует и готово к выполнению, монитор переключает контекст задания и передает ему управление:

2$:     JSR     PC,CNTXSW		; CNTXSW()
EXUSLK: BR      EXUSER			; goto EXUSER

Очередь ввода-вывода: QMANGR и QCOMP

Операция постановки в очередь QMANGR имеет отличия по сравнению с однозадачным монитором.

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

QFULL:  SPL     0				; SPL(0) //разрешение прерываний
        ENSYS   QGTELT			; //вызов планировщика с помощью ENSYS

        MOV     JOBNUM,R5		; R5 = JOBNUM
        BEQ     9$				; if (R5 != 0) {
        TST     -(R5)			;     R5--
								; }
9$:		JMP     $RQSIG			; $RQTSW(R5) //обновление INTACT
								; return; //вызов планировщика и после возврат на QGTELT
;; Забирает первый свободный элемент из очереди операций ввода-вывода,
;; заполняет его,
;; добавляет в очередь устройства
;; и вызывает обработчик.
;;
;; @param R0 - номер блока на диске
;; @param R2 - &(device->last)
;; @param R3 - указатель на канал
;; @param R5 - указатель на запись с параметрами: block, buffer_addr, length, is_async/completion
;;             (указывает на второе поле - buffer_addr)
;; @param SP[0] - length
QMANGR: MOV     R4,-(SP)		; //сохранение регистров
        MOV     R1,-(SP)
        MOV     CNTXT,R1
        TST     (R1)+
QGTELT: SPL     7				; SPL(7) //запрет прерываний
        MOV     @R1,R4			; R4 = CNTXT->QHDR //Указатель на первый свободный элемент очереди операций ввода-вывода
        BEQ     QFULL			; if (R4 == NULL) QFULL() //переключимся на выполнение других заданий
        CMPB    #255.,10(R3)
        BEQ     QFULL			; if (R3->DEVQ == 255) QFULL()
        MOV     @R4,@R1			; CNTXT->QHDR = R4->LINK //удаляем элемент из очереди
        SPL     0				; SPL(0) //разрешение прерываний
								; //заполняем элемент
        CLR     (R4)+			; R4->LINK = NULL
        MOV     R3,(R4)+		; R4->CSW = R3
        INCB    10(R3)			; R3->DEVQ++
        ; ... (пропустим код заполнения элемента)

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

3$:     ADD     #Q.BLKN-Q.COMP,R4	; R4 = &(R4->BLKN) //ссылки в очереди драйвера указывают не на начало элемента (поле LINK), а на поле BLKN
        MOV     R3,-(SP)			; PUSH(channel)
        ENSYS   7$					; 
        INC     I.IOCT-I.QHDR(R1)	; CNTXT->IOCT++; //Общее число запросов ввода-вывода
        MOV     R2,R1				; device = R2
        BIS     #100000,-(R1)		; device->vector |= 0100000 //флаг блокирования драйвера
        TST     (R2)+
        BNE     4$					; if (device->last == NULL) {
									;     //добавляем элемент в пустую очередь устройства
        CLR     (R1)+				;     device->vector = 0
        MOV     R4,(R1)+			;     device->last = R4
        MOV     R4,(R1)+			;     device->first = R4
        JMP     @R1					;     device->start(); 
									;     goto 7$
									; } else {
									;     //поиск конца группы элементов в очереди, принадлежащих заданию JOBNUM
									;     do {
4$:     MOV     @R2,R5				;         R5 = device->first или R5 = R2->LINK
									;         // в обоих случаях R5 содержит указатель на поле BLKN
5$:     MOV     R5,R2
        CMP     -(R2),-(R2)			;         // R2 содержит указатель на поле LINK
        MOV     @R2,R5				;         R5 = R2->LINK
									;         // R5 содержит указатель на поле BLKN
        BEQ     6$					;         if (R5 == NULL) break
        CMP     2(R5),R0			
        BHIS    5$					;     } while (R5->JNUM > R0 /*JOBNUM*/)
									;     //вставляем элемент
6$:     MOV     R5,-4(R4)			;     R4->LINK = R2->LINK
        MOV     R4,@R2				;     R2->LINK = R4
									; }
        ASL     @R1					; device->vector &= ~0100000 // снимаем флаг блокирования драйвера

В-третьих, ожидание завершения добавленной операции ввода-вывода тоже сделано передачей управления планировщику. Здесь используется подпрограмма $SYSWT. У нее два аргумента: флаг причины ожидания для слова состояния задания и инструкции для определения, что ожидаемое событие наступило (т.е. операции ввода-вывода завершены):

7$:     MOV     (SP)+,R3			; channel = POP()
        TST     -(R5)
        BNE     8$					; if (!is_async) {
CHWAIT: MOV     CNTXT,R1
        MOV     R3,I.CHWT(R1)		;     CNTXT->CHWT = R3
        JSR     R4,$SYSWT			;     $SYSWT(CHNWT$) //ожидание, когда счетчик элементов очереди канала достигнет нуля
          .WORD  CHNWT$
									;     //эти инстркции выполняет $SYSWT, чтобы определить что ожидание завершено
        SPL     7					;     SPL(7) // запрет прерываний
        MOVB    10(R3),R2			;     R2 = R3->DEVQ
        NEGB    R2					;
        JSR     PC,@(SP)+			;     return (R2 != 0) //возврат в $SYSWT
									; }
8$:     RTS     PC					; return

Перед выходом из QMANGR еще раз передается управление планировщику, т.к. код выполнялся внутри ENSYS.

Операция завершения QCOMP тоже имеет отличия по сравнению с однозадачным монитором.

Добавили изменение слова состояния задания при изменении счетчиков количества запросов ввода-вывода:

;; Возвращает элемент в список свободных.
;; Или добавлят его в конец очереди подпрограмм завершения.
;; 
;; @param R4 - &(device->first)
QCOMP:  ASR     -4(R4)
        BMI     8$				; if (device->vector & 0100000) return //драйвер заблокирован
        JSR     R3,SAVE30		; //сохранение регистров
        MOV     R4,R1
CMPLT2: MOV     @R1,R4			; R4 = device->first
        MOV     -(R4),R3		; R3 = R4->CSW //канал
        MOVB    Q.JNUM-Q.CSW(R4),R5
        ASR     R5
        ASR     R5
        ASR     R5
        BIC     #177761,R5		; R5 = R4->JNUM
        ADD     PC,R5
        MOV     $IMPUR-.(R5),R5	; R5 = $IMPUR[R5] //CNTXT
								; //Перед продолжением программы, которая запросила синхронный ввод-вывод,
								; //монитор ожидает, когда счетчик элементов очереди канала достигнет нуля
        DECB    10(R3)			; R3->DEVQ--
        BNE     2$				; if (R3->DEVQ == 0) {
        CMP     R3,I.CHWT(R5)
        BNE     2$				;     if (R3 == R5->CHWT) {
        JSR     R4,UNBLOK		;         UNBLOK(CHNWT$) //снимаем флаг CHNWT$ из CNTXT->JSTA
          .WORD  CHNWT$
								;     }
								; }
								; //Перед завершением программы система ожидает, 
								; //когда общее число запросов ввода-вывода у задания дотигнет нуля
2$:     DEC     I.IOCT(R5)		; R5->IOCT--
        BNE     3$				; if (R5->IOCT == 0) {
        JSR     R4,UNBLOK		;     UNBLOK(EXIT$) //снимаем флаг EXIT$ из CNTXT->JSTA
          .WORD   EXIT$
								; }
								; //убираем элемент из очереди драйвера
3$:     MOV     -(R4),(R1)+		; device->first = R4->LINK

Убрали прямой вызов функции завершения. Убрали запуск следующего запроса в очереди. Теперь монитор преобразует элемент очереди ввода-вывода в элемент очереди подпрограмм завершения и добавляет в конец соответствующей очереди:

5$:     CMP     Q.COMP(R4),#1
        BLOS    AQLINK			; if (R4->COMP > 1) {
        BIT     #ABORT$,@R5
        BNE     AQLINK			;     if (!(R5->JSTA & ABORT$)) {
								;         //заполнение элемента очереди подпрограмм завершения
								;         R4->QC_CMP = R4->COMP //адрес подпрограммы завершения
        MOV     @R3,Q.BUFF(R4)	;         R4->QC_CSW = R3->CSW //канал
        SUB     I.CSW(R5),R3	;         R3 = R3 - R5->I_CSW
        MOV     R3,Q.WCNT(R4)	;         R4->QC_OFT = R3 //наподобие индекса в массиве каналов
        TST     (R5)+
        MOV     R5,R2
        MOV     I.JNUM-2(R5),R5
        JSR     PC,$RQTSW		;         $RQTSW(R5->JNUM) //обновление INTACT
        MOV     R2,R5
        BIS     #CPEND$,-(R2)	;         R5->JSTA |= CPEND$
CQLINK: TST     (R5)+
        CLR     @R4				;         R4->QC_LNK = NULL //следующий элемент в очереди подпрограмм завершения
        JSR     PC,GETPSW		;         //сохранение приоритета процессора в стеке
        SPL     7				;         SPL(7) //запрет прерываний
								;         //добавляем элемент в конец очереди подпрограмм завершения
        MOV     (R5)+,R0		;         R0 = R5->CMPE
        BNE     6$				;         if (R0 == NULL) {
        MOV     R5,R0			;             R0 = &(R5->CMPL)
								;         }
6$:     MOV     R4,@R0			;         R5->CMPE->LINK = R4 или R5->CMPL = R4
7$:     MOV     R4,-(R5)		;         R5->CMPE = R4
        JSR     PC,$MTPS		;         PS = POP() //восстановление прежнего приоритета процессора
8$:     RTS     PC				;         return
								;     }
								; }
AQLINK: TST     (R5)+
        JSR     PC,GETPSW		; GETPSW() //сохранение приоритета процессора
        SPL     7				; SPL(7) //запрет прерываний
								; //возвращаем элемент в список свободных
        MOV     (R5)+,@R4		; R4->LINK = R5->QHDR
        BR      7$				; R5->QHDR = R4

Небольшое пояснение. Подпрограмма QCOMP вызывается из драйвера в системном режиме. А функция завершения находится в коде программы и должна выполняться в пользовательском режиме. Поэтому ссылка на нее добавляется в очередь подпрограмм завершения. В конце подпрограммы переключения контекста CNTXSW в стек задания вставляется адрес менеджера очереди подпрограмм завершения $CRTNE. Он вызывается в первую очередь после переключения на стек задания до продолжения выполнения программы. $CRTNE последовательно запускает все подпрограммы завершения из очереди.

Заключение

В многозадачном режиме работы монитора раскрывается польза асинхронного ввода-вывода. Переключение между задачами сделано на основе простой и элегантной концепции — переключении стека задач. Этот механизм легко понять и легко изучать. Остаётся только выразить респект старшему поколению системных программистов и их изобретательности.

PS. Части второй цикла про ОС RT-11 скорее всего не будет. Кому интересно устройство ее файловой системы, можно посмотреть здесь реализацию на языке C# со ссылками на документацию.