在8088单板机上用汇编语言创建进程,本质上是实现一个极简的多任务切换机制 。由于8088工作在实模式、无MMU,所有进程共享同一物理地址空间,进程切换的核心是保护/恢复CPU寄存器现场(即"上下文切换")。
一、整体思路:协作式多任务模型
在资源有限的8088单板机上(典型配置:RAM约32KB、ROM 64KB、无MMU),常见的实现方式是协作式、单地址空间、中断驱动的微型OS。其核心特点:
-
单地址空间:所有任务共享同一内存地址空间,任务间独立依赖编程约定,没有硬件保护
-
协作式调度 :任务必须主动调用调度函数(
yield)让出CPU,非抢占式 -
切换核心 :保存当前任务的寄存器到其私有栈,恢复下一个任务保存的寄存器,通过
IRET返回执行 -
可选定时器:可用8253定时器产生周期中断(如每20ms),在中断中调用调度器
本文采用协作式实现(无定时器抢占),这样代码最简洁,适合教学理解。你也可以在此基础上加入中断调用。
二、核心数据结构:任务控制块(TCB)
每个任务需要一个任务控制块(TCB)来记录其运行状态。在8088实模式下,一个极简TCB只需保存:
-
任务的下一个TCB指针(用于循环调度)
-
任务的栈指针备份(SS:SP)
-
任务的程序入口(CS:IP)
为实现简单,我们直接让每个任务拥有独立栈区域,TCB只保存栈指针备份。
text
; TCB结构定义(4字节)
TCB_Next dw 0 ; 下一任务TCB指针
TCB_SP dw 0 ; 任务栈指针备份(SS:SP)
实际实现中,还可以添加一个字节作为任务状态标志(就绪/运行/等待),但协作式调度下最简单的是循环就绪队列。
三、实现步骤详解
步骤1:定义TCB数组和任务栈
; 定义最大任务数和TCB区域
MAX_TASKS EQU 3
TCB_TABLE DW MAX_TASKS DUP(0, 0) ; 每任务占2个字:Next指针 + SP备份
CurrentTask DW 0 ; 当前运行的任务索引 (0~MAX_TASKS-1)
; 定义各任务的私有栈区域(每任务256字节)
Task0Stack DB 256 DUP(0)
Task1Stack DB 256 DUP(0)
Task2Stack DB 256 DUP(0)
步骤2:初始化TCB与任务栈
在系统启动时,为每个任务设置栈指针和任务入口。
InitTasks:
mov cx, MAX_TASKS ; 任务个数
mov si, offset Task0Stack ; 指向任务0的栈底部
mov di, offset TCB_TABLE ; 指向TCB表
xor bx, bx ; 任务索引,从0开始
InitTask:
; 计算任务栈顶(栈底+256-2,因为栈向下增长)
mov ax, ss ; 内核栈段
add si, 256 - 2
mov [di+2], si ; 保存栈指针备份到TCB_SP
; 链接TCB: 下一任务指针
mov ax, bx
inc ax
cmp ax, MAX_TASKS
jb SetNext
xor ax, ax ; 最后一个任务指向任务0
SetNext:
shl ax, 1 ; 每个TCB占4字节(Next + SP),索引*2得偏移
add ax, offset TCB_TABLE
mov [di], ax ; 保存Next指针
; 更新到下一个任务
add di, 4
inc bx
add si, 256 - 2 - 256 ; si恢复到下一个任务的栈底
loop InitTask
ret
更常用的做法:在每个任务的私有栈顶预先压入任务入口CS:IP和FLAGS ,这样第一次切换时通过IRET即可直接进入任务执行。下面展示如何在任务栈中构造初始上下文:
; 初始化任务0栈(构造返回上下文)
mov ax, SEG Task0_Entry
mov bx, OFFSET Task0_Entry
push ax ; 压入CS
push bx ; 压入IP
pushf ; 压入FLAGS(模拟IRET弹出的状态)
将Task0Stack作为栈底,栈指针指向上述压栈后的位置,存到TCB_SP。切换时用IRET直接弹出FLAGS、IP、CS进入任务。
步骤3:编写任务切换函数(核心)
任务切换的核心是:
-
保存当前任务的寄存器(AX,BX,CX,DX,SI,DI,BP,DS,ES等)到其栈
-
将当前SP保存到当前TCB_SP
-
从当前TCB.Next获取下一个TCB的SP并加载到SS:SP
-
从新任务的栈恢复寄存器
-
执行IRET返回新任务继续执行
实际代码实现如下:
TaskSwitch:
; 保存当前任务现场到栈中
push ax
push bx
push cx
push dx
push si
push di
push bp
push ds
push es
; 保存当前任务的栈指针到TCB_SP
mov bx, [CurrentTask] ; 当前任务索引
shl bx, 1 ; 索引*4得TCB偏移(每个TCB4字节)
shl bx, 1
add bx, offset TCB_TABLE + 2 ; TCB_SP的偏移
mov [bx], sp ; 保存当前SP到TCB_SP
; 切换到下一个任务
sub bx, 2 ; bx指向TCB.Next
mov bx, [bx] ; 取下一任务的TCB指针
mov ax, [bx] ; 取下一任务的TCB_SP
mov sp, ax ; 切换栈
; 恢复新任务现场
pop es
pop ds
pop bp
pop di
pop si
pop dx
pop cx
pop bx
pop ax
iret
步骤4:创建新任务的入口
每个任务是一个独立的程序段,任务结束后必须调用调度器或无限循环,避免CPU失控:
Task0_Entry:
; 任务0代码
call Task1_Start
jmp $ ; 或调用调度器循环
为方便演示,可让每个任务在控制台输出字符串后主动让出CPU:
Task0_Entry:
mov dx, offset Msg0
call PrintString
call Yield ; 主动让出CPU
jmp Task0_Entry ; 无限循环
Task1_Entry:
mov dx, offset Msg1
call PrintString
call Yield
jmp Task1_Entry
步骤5:让出CPU函数(Yield)
Yield函数调用TaskSwitch实现切换:
Yield:
call TaskSwitch
ret
或使用软件中断实现系统调用:
; 设置INT 21h系统调用
SysCall:
cmp ah, 1
je Yield_Service
iret
Yield_Service:
call TaskSwitch
iret
四、完整代码示例
下面给出一个完整的协作式多任务切换汇编程序,在8088/DOS下可直接汇编运行。
; **************************************************
; 8088 单板机多任务切换演示(协作式调度)
; 编译: MASM mytask.asm
; 链接: LINK mytask.obj
; 运行: mytask.exe
; **************************************************
CODE SEGMENT
ASSUME CS:CODE, DS:DATA, SS:STACK
START:
; 初始化DS
mov ax, DATA
mov ds, ax
; 初始化TCB表
call InitTasks
; 设置当前任务为任务0
mov [CurrentTask], 0
; 设置初始栈(从任务0开始)
mov bx, offset TCB_TABLE + 2 ; TCB_SP of task0
mov sp, [bx]
; 模拟IRET 进入任务0
pop ax
pop bx
pop cx
pop dx
pop si
pop di
pop bp
pop ds
pop es
iret
; 初始化任务栈和TCB
InitTasks:
mov cx, MAX_TASKS
mov si, offset Task0Stack
mov di, offset TCB_TABLE
xor bx, bx
InitLoop:
; 为任务栈顶构造初始返回上下文
mov ax, SEG Task0_Entry
push ax
mov ax, OFFSET Task0_Entry
push ax
pushf
; 保存栈指针到TCB
mov sp, si
add sp, 254
mov [di+2], sp
; 创建TCB链表
mov ax, bx
inc ax
cmp ax, MAX_TASKS
jb SetNext
xor ax, ax
SetNext:
shl ax, 1
add ax, offset TCB_TABLE
mov [di], ax
add di, 4
inc bx
add si, 256
loop InitLoop
ret
; 任务切换函数
TaskSwitch:
push ax
push bx
push cx
push dx
push si
push di
push bp
push ds
push es
mov bx, [CurrentTask]
shl bx, 1
shl bx, 1
add bx, offset TCB_TABLE + 2
mov [bx], sp
sub bx, 2
mov bx, [bx]
mov sp, [bx + 2]
pop es
pop ds
pop bp
pop di
pop si
pop dx
pop cx
pop bx
pop ax
iret
; Yield系统调用
Yield:
call TaskSwitch
ret
; 任务0入口
Task0_Entry:
mov dx, offset Msg0
call PrintString
call Yield
jmp Task0_Entry
; 任务1入口
Task1_Entry:
mov dx, offset Msg1
call PrintString
call Yield
jmp Task1_Entry
; 简单字符打印(调用DOS中断)
PrintString:
mov ah, 9
int 21h
ret
CODE ENDS
DATA SEGMENT
Msg0 db 'Task 0 running...', 0Dh, 0Ah, '$'
Msg1 db 'Task 1 running...', 0Dh, 0Ah, '$'
MAX_TASKS equ 2
TCB_TABLE dw MAX_TASKS DUP(0, 0)
CurrentTask dw 0
DATA ENDS
STACK SEGMENT
Task0Stack db 256 DUP(?)
Task1Stack db 256 DUP(?)
STACK ENDS
END START
五、在此基础上扩展
如果你想在此基础上加入定时器抢占(时间片轮转) ,可以在代码中加入8253/8259中断处理:
-
初始化8253定时器:设置每20ms产生一次中断
-
编写中断服务例程 :在中断中保存当前寄存器、调用
TaskSwitch切换任务、恢复新任务 -
在中断向量表中注册:将中断向量指向定时器ISR,清除中断屏蔽寄存器允许中断
这样就能实现简单的基于时间片的抢占式多进程了。
六、注意事项
-
堆栈溢出风险:每个任务栈只分配了256字节,实际使用要注意避免递归或大局部变量导致栈溢出
-
共享资源冲突:多任务共享打印等资源时,需要自己加锁或确保互斥(协作式下可依靠主动让出规避)
-
无内存保护:一个任务的错误可能破坏其他任务数据,需要开发者严格遵守编程约定
-
单板机运行:如果是真正的8088单板机(无DOS环境),需要自己实现字符输出的硬件级代码,通过8255并口或串口驱动显示
希望这份代码能帮助你在8088上跑起自己的微型"多进程"系统!
