为什么是 DOS,而不是 Linux 或单片机

在做线切割机(Wire EDM)这类运动控制设备的控制系统选型时,通常有三条路:

方案 优势 劣势
单片机(STM32等) 成本低、可靠、专用 算力有限,复杂插补算法(三次样条、NURBS)跑不动
Linux + 实时补丁(PREEMPT_RT) 现代工具链、算力强 调度延迟不确定,中断响应受内核调度影响,工程复杂
DOS(裸机/近裸机) 没有调度器抢占问题,中断响应可预测,x86 算力够用 工具链老旧,需要自己管理硬件

DOS 本身没有真正意义上的多任务调度——它是单任务操作系统,你的程序独占 CPU。这恰恰是运动控制场景需要的:你自己完全掌控中断优先级和响应时序,不会被无关的系统进程抢占。这也是为什么很多工业设备(老式数控机床、老式医疗设备)至今还在跑 DOS 或 DOS 衍生的实时系统。

TSR(Terminate and Stay Resident,终止并驻留)技术,就是在 DOS 环境下实现"后台常驻服务"的核心机制——对于线切割机来说,它就是实时插补计算循环和伺服脉冲发生器的载体。


第一部分:TSR 的基本原理

1.1 什么是 TSR

正常的 DOS 程序执行完毕后,会调用 INT 21h AH=4Ch(正常终止)把内存还给系统。而 TSR 程序执行"终止"操作后,依然占用内存,并且已经把自己的代码挂接到某个中断向量上——之后每次那个中断触发,都会执行 TSR 里的代码,即使原程序早已"退出"。

1
2
3
4
5
普通程序:  加载 → 执行 → 终止 → 释放全部内存

TSR 程序:  加载 → 初始化 → 挂接中断 → "终止但驻留"
    内存里的代码持续通过中断被调用

1.2 两种终止并驻留的方式

中断调用 说明
INT 27h(旧式,DOS 1.0起) 简单但灵活性差,驻留大小限制在 64KB 内
INT 21h AH=31h(推荐,DOS 2.0+) 现代 TSR 标准做法,可以设置任意驻留大小,还能返回错误码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <dos.h>

void keep_resident(unsigned int paragraphs, unsigned char exit_code) {
    union REGS regs;
regs.h.ah = 0x31;      // Keep Process (TSR)
    regs.h.al = exit_code; // 退出码
    regs.x.dx = paragraphs; // 驻留内存大小(以16字节为单位)
    int86(0x21, &regs, &regs);
    // 此调用不会返回
}

内存大小的计算:驻留内存以"段"(paragraph,16字节)为单位。如果你的常驻代码+数据总共 4KB,需要设置:

1
paragraphs = (4096 + 15) / 16;  // 向上取整

第二部分:中断向量表与劫持机制

2.1 中断向量表(IVT)结构

实模式下,中断向量表位于内存最低地址 0000:0000,共 256 个中断(每个 4 字节:2字节偏移 + 2字节段地址):

1
2
3
4
5
6
7
8
9
地址       中断号   用途
0000:0000    INT 00h   除零错误
0000:0004    INT 01h   单步调试
...
0000:0020    INT 08h   系统定时器(18.2 次/秒,关键!)
...
0000:0024    INT 09h   键盘中断
...
0000:00CC    INT 33h   鼠标服务

2.2 中断劫持的标准流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <dos.h>

void interrupt (*old_timer_handler)();

void interrupt new_timer_handler() {
    // === 你的实时任务代码 ===
    do_interpolation_step();  // 插补计算的一步
    output_pulse();   // 输出伺服脉冲
    
    // 调用原处理程序(维持系统时钟正常)
    (*old_timer_handler)();
}

void install_tsr() {
    old_timer_handler = getvect(0x08);   // 保存原向量
    setvect(0x08, new_timer_handler);    // 挂接新向量
}

void uninstall_tsr() {
    setvect(0x08, old_timer_handler);    // 恢复原向量(退出前必须!)
}

关键细节:INT 08h 是系统定时器中断,来自 8253/8254 PIT 芯片,默认频率 18.2 Hz(每 54.925 毫秒一次)。DOS 的时钟计数、delay() 函数、很多"假多任务"效果全都基于这个中断。


第三部分:面向运动控制的定时器重编程

3.1 提升定时器频率

18.2 Hz 对于插补计算和脉冲输出来说太慢了(约每 55ms 一次,而伺服脉冲通常需要几 kHz 到几十 kHz)。需要重新编程 8253/8254 PIT 芯片,提高中断频率。

PIT 芯片的输入时钟是 1.193182 MHz,中断频率由分频值决定:

1
中断频率 = 1193182 / 分频值

例如要达到 10kHz 中断频率:

1
2
3
4
5
6
7
8
9
#include <dos.h>

void set_timer_frequency(unsigned int freq_hz) {
    unsigned int divisor = 1193182L / freq_hz;
    
 outportb(0x43, 0x36);   // 控制字:通道0,先写低字节后高字节,模式3
    outportb(0x40, divisor & 0xFF);    // 分频值低字节
    outportb(0x40, (divisor >> 8) & 0xFF); // 分频值高字节
}

极其重要的一点:改变了 PIT 频率后,DOS 内部依赖 18.2Hz 的逻辑(时钟、delay())都会变快或变慢。所以中断处理函数里通常需要手动模拟原始的 18.2Hz 节拍,累积计数到一定值才调用原处理程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <dos.h>

#define TICK_RATIO  (10000 / 18) // 新频率相对于18.2Hz的倍数(近似)

void interrupt (*old_timer_handler)();
volatile unsigned long tick_counter = 0;
volatile long accumulator = 0;

void interrupt new_timer_handler() {
    // === 高频实时任务(每次都执行)===
    do_interpolation_step();
    output_servo_pulse();
    
    tick_counter++;
    
    // === 低频任务:模拟原始18.2Hz节拍,维持DOS时钟正常 ===
    accumulator += 18;
    if (accumulator >= 10000) {
        accumulator -= 10000;
 (*old_timer_handler)();  // 触发原BIOS时钟处理
 } else {
        outportb(0x20, 0x20);    // 手动向 8259 PIC 发送 EOI(中断结束)信号
    }
}

为什么需要手动发 EOI? 因为原来的 BIOS 时钟处理程序里会发 EOI(End of Interrupt)信号给 8259 中断控制器,告诉它"这次中断处理完了,可以接收下一次"。如果你跳过了原处理程序,就必须自己发这个信号,否则同级或低优先级中断会被永久阻塞。

1
2
8259 PIC 端口 0x20:主控制器命令端口
写入 0x20 = EOI(非特定结束)命令

第四部分:中断服务程序(ISR)的编程约束

4.1 中断处理函数中的禁忌

在中断上下文里执行的代码,跟普通函数完全不同的规则:

禁止/受限操作 原因
调用 DOS 服务(INT 21h) DOS 本身不可重入!如果中断发生时 DOS 正在执行别的调用,再次进入会破坏内部状态
动态内存分配(malloc) DOS 的内存管理函数不可重入,同上
printf/scanf 等标准库函数 内部大量调用 DOS 服务
长时间循环 会阻塞后续中断,影响系统响应
浮点运算(部分情况) 需要保存/恢复 FPU 状态,否则会污染主程序的浮点上下文

4.2 DOS 不可重入问题的标准解法

工业级 TSR 采用"延迟处理“模式——中断服务程序只做最紧急、最快的操作(读硬件、置标志位),复杂逻辑放到主循环里处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
volatile int pulse_request_flag = 0;
volatile long position_x = 0, position_y = 0;

void interrupt new_timer_handler() {
    // 中断内:只做最简单的硬件操作
    if (need_pulse()) {
        outportb(PULSE_PORT, PULSE_HIGH);
        pulse_request_flag = 1;  // 标记,交给主循环处理
    }
    outportb(0x20, 0x20);  // EOI
}

int main() {
    install_tsr();
 
    while (1) {
        if (pulse_request_flag) {
            pulse_request_flag = 0;
       // 复杂的插补计算、DOS文件I/O、屏幕显示等都放这里
     calculate_next_interpolation_point();
            update_display();
        }
    }
}

这个模式本质上就是中断只负责"时间关键"部分,主循环负责"逻辑复杂"部分——跟现代嵌入式开发里 ISR + 任务队列的思想完全一致。

4.3 关中断保护临界区

当主循环和中断处理程序共享数据(如上面的 position_x),如果数据超过一个字(16位),读写不是原子操作,可能被中断打断导致数据损坏。这时需要临时关闭中断:

1
2
3
4
5
6
7
8
9
#include <dos.h>

long read_position_safely() {
    long result;
    disable();       // CLI 指令,关中断
    result = position_x;     // 32位读取,非原子操作,需要保护
    enable();    // STI 指令,开中断
    return result;
}

关中断的代价:关中断期间,所有中断(包括定时器)都会被阻塞。对于运动控制场景,关中断时间必须做到微秒级,否则会导致插补计算错过节拍、脉冲输出抖动。


第五部分:TSR 与栈的问题

5.1 为什么需要独立的中断栈

DOS 中断发生时,会复用当前正在执行的程序的栈。如果主程序的栈本身已经很满(深层函数调用),中断处理程序再往上叠加局部变量,可能导致栈溢出,这是 DOS TSR 最常见也最难调的 bug 之一。

工业级 TSR 的标准做法是切换到独立的中断栈

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define ISR_STACK_SIZE 1024
unsigned char isr_stack[ISR_STACK_SIZE];
unsigned int saved_ss, saved_sp;

void interrupt new_timer_handler() {
    asm {
        mov saved_ss, ss
        mov saved_sp, sp
        mov ax, seg isr_stack
        mov ss, ax
        mov sp, offset isr_stack + ISR_STACK_SIZE
    }
    
    // === 中断处理逻辑,现在用的是独立栈 ===
    do_interpolation_step();
 
    asm {
    mov ss, saved_ss
      mov sp, saved_sp
    }
    
    outportb(0x20, 0x20);
}

第六部分:完整的运动控制 TSR 架构示例

结合上述所有要点,一个线切割机插补控制的 TSR 骨架大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <dos.h>
#include <stdio.h>

/* ---- 全局状态(中断与主循环共享)---- */
volatile long current_x = 0, current_y = 0;
volatile long target_x = 0, target_y = 0;
volatile int running = 1;

void interrupt (*old_timer)();

/* ---- 直线插补的一步(简化的Bresenham思路)---- */
void interrupt timer_isr() {
    static long err = 0;
    long dx = target_x - current_x;
    long dy = target_y - current_y;
    
    if (dx != 0 || dy != 0) {
        // 简化的逐点比较插补算法
        if (labs(dx) > labs(dy)) {
            current_x += (dx > 0) ? 1 : -1;
     outportb(0x300, 0x01);  // X轴脉冲(示例端口)
        } else {
            current_y += (dy > 0) ? 1 : -1;
     outportb(0x300, 0x02);  // Y轴脉冲
 }
    }
    
    outportb(0x20, 0x20);  // EOI
}

void install() {
    old_timer = getvect(0x08);
    
    disable();
    set_timer_frequency(5000);  // 5kHz插补节拍
    setvect(0x08, timer_isr);
    enable();
}

void restore() {
    disable();
 set_timer_frequency(18);    // 恢复标准18.2Hz(约)
    setvect(0x08, old_timer);
    enable();
}

int main() {
    printf("线切割机插补控制器 - TSR 模式启动\n");
    install();
  
    /* 主循环:负责非实时逻辑(G代码解析、显示、通信)*/
 while (running) {
        parse_next_gcode_line();  // 解析下一条加工指令,更新 target_x/y
    update_status_display();
 if (kbhit() && getch() == 27) running = 0;  // ESC 退出
    }
    
    restore();
    printf("已恢复系统定时器,安全退出\n");
    return 0;
}

注意:这个示例作为常驻服务跑(不调用 INT 21h AH=31h 终止),是因为整体程序本身还需要跑主循环处理 G 代码解析。真正的 TSR(终止后仍驻留)更适合"装好就不用管,靠中断在后台默默干活"的场景,比如热键工具、打印后台服务。对于线切割机控制器来说,你可能更需要的是主循环 + 高频中断这种混合架构,而不是严格意义上的传统 TSR。


第七部分:从 TC3/DOS 迁移到 DJGPP 的实时性考量

如果后续要把这类控制程序迁移到 DJGPP(32位保护模式),有几个关键差异需要注意:

方面 16位实模式(TC3) 32位保护模式(DJGPP)
中断向量表 直接在 0000:0000,setvect/getvect 需要用 _go32_dpmi_lock_code 锁定中断代码所在页面,避免虚拟内存换出
中断处理函数 void interrupt 关键字 需要用 _go32_dpmi_allocate_iret_wrapper 包装真正的 ISR
I/O 端口 inportb/outportb inportb/outportb(DJGPP 也提供,几乎兼容)
内存锁定 不需要(DOS本身没有分页) 必须锁定中断处理代码和栈,否则触发缺页异常时会死锁
栈切换 手工 asm 切换段寄存器 更复杂,保护模式段管理不同

DJGPP 下写实时中断处理程序难度显著上升,尤其是内存锁定这一步,是很多人在保护模式下写运动控制程序踩坑最多的地方——如果中断处理代码所在的内存页被虚拟内存系统换出到磁盘,下次中断触发时试图执行这段代码会引发页故障,而页故障处理本身又需要中断环境,容易死锁。

考虑到你的场景对实时性要求高、还要跑复杂插补计算,这里有个权衡:继续用 16 位实模式 TC3/汇编写核心的中断服务程序(时序关键的部分),把非实时的部分(G代码解析、UI、通信)迁移到现代工具链,可能比整体迁移到 DJGPP 更稳妥、风险更低。


总结

TSR 技术在运动控制场景下的核心价值

  1. 没有操作系统调度器抢占 —— 这是选择 DOS 而不是 Linux 的核心原因,中断响应时序完全在你的掌控之中
  2. 中断向量劫持 —— 让高频定时器中断成为插补计算和脉冲输出的可靠时钟源
  3. 8253/8254 PIT 重编程 —— 从 18.2Hz 提升到 kHz 级别,满足伺服控制需求
  4. 中断/主循环分层架构 —— 时间关键的部分放中断里(越简单越好),复杂逻辑放主循环
  5. 可重入性和临界区保护 —— DOS 本身不可重入,必须小心设计共享数据的访问

这套架构本质上跟现代嵌入式 RTOS 的"中断服务程序 + 任务队列"思想是一致的——只是在 DOS 这个"裸机操作系统"上,你需要自己搭建这套基础设施,而不是依赖内核提供。这也正是 DOS 相比 Linux 在实时控制场景下的优势:没有中间层,没有不确定性,一切尽在掌控


参考资料

  • Ralf Brown’s Interrupt List(INT 08h/INT 21h/INT 33h 完整文档)
  • IBM PC 8253/8254 Programmable Interval Timer 数据手册
  • Michael Abrash’s Graphics Programming Black Book(大量涉及中断编程实践)
  • DOS编程:TSR与设备驱动开发(相关中文技术资料)

相关阅读