📟 第26课:简单监控程序

📖 本课目标

设计并实现一个交互式监控程序(Monitor),这是8位电脑最基础的系统软件——它让你可以通过串口或键盘输入命令,检查内存、修改数据、运行程序。监控程序是操作系统最简单的形态,也是所有更复杂软件的起点。

🧠 监控程序的设计哲学

监控程序这个名字来自早期大型机的"控制台监控器"——操作员坐在终端前,通过命令控制整台机器。Apple II的System Monitor、Commodore 64的内置监控器、ZX Spectrum的ROM BASIC——都是这种理念的产物。

监控程序架构 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ┌──────────────────────────────────────────┐ │ 监控程序主循环 │ │ │ │ ┌──────┐ ┌──────────┐ ┌────────┐ │ │ │提示符 │──→│ 读取命令 │──→│ 解析命令│ │ │ │ ">" │ │ (键盘/串口)│ │ │ │ │ └──────┘ └──────────┘ └───┬────┘ │ │ │ │ │ ┌───────────────────────┤ │ │ │ │ │ │ │ │ ┌────▼──┐┌───▼───┐┌──▼──┐┌──▼──┐ │ │ │内存操作││运行程序││IO操作││其他 │ │ │ │D/M/F ││G/R ││L/S ││?/X │ │ │ └───────┘└───────┘└─────┘└─────┘ │ └──────────────────────────────────────────┘ 命令列表: D addr - 显示内存 (Dump) M addr val - 修改内存 (Modify) F addr len val - 填充内存 (Fill) G addr - 执行程序 (Go) R - 显示寄存器 (Registers) L - 加载程序 (Load via UART) S addr len - 保存内存 (Save via UART) ? - 帮助 (Help) X - 退出到BASIC

经典监控程序参考

系统监控名称入口方式特色
Apple IISystem MonitorCall -151迷你反汇编器
Commodore 64M64SYS 8*4096磁带操作
ZX SpectrumROM Monitor内置与BASIC集成
6502 SBCWOZ Monitor上电启动最精简实现(256字节!)
💡 WOZ Monitor的传奇:Steve Wozniak为Apple I编写的WOZ Monitor仅256字节,却能实现内存检查、修改和程序运行——这是极致精简代码的典范。我们的设计将借鉴WOZ的思路,但扩展更多功能。

🔧 监控程序实现

1. 主循环与命令解析

; monitor.asm - Retro8 监控程序
; 存放于ROM $8000-$F9FF

.ORG $8000

; ===== 入口点 =====
MONITOR:
    JSR INIT_UART       ; 初始化串口
    JSR PRINT_BANNER    ; 显示欢迎信息

MON_LOOP:
    LDA #'>'            ; 显示提示符
    JSR PUTCHAR
    JSR READ_LINE       ; 读取一行命令
    JSR PARSE_CMD       ; 解析命令
    JMP MON_LOOP        ; 循环

; ===== 显示欢迎信息 =====
PRINT_BANNER:
    LDA #; 字符串指针低字节
    LDA #>BANNER
    STA $21             ; 字符串指针高字节
BANNER_LOOP:
    LDY #$00
    LDA ($20),Y
    BEQ BANNER_DONE
    JSR PUTCHAR
    INC $20
    BNE BANNER_LOOP
    INC $21
    JMP BANNER_LOOP
BANNER_DONE:
    RTS

BANNER:
    .DB $0D,$0A         ; CR LF
    .DB "Retro8 Monitor v1.0",$0D,$0A
    .DB "8-bit Computer System",$0D,$0A
    .DB "Type ? for help",$0D,$0A
    .DB $00             ; NUL结束

2. 命令解析器

; 命令解析器
; 输入缓冲区在 $0100-$017F
; 命令格式: CMD [ADDR] [VALUE]

PARSE_CMD:
    LDX #$00
    LDA $0100,X         ; 取第一个字符

    ; 'D' - 显示内存
    CMP #'D'
    BNE NOT_D
    JSR CMD_DUMP
    RTS
NOT_D:

    ; 'M' - 修改内存
    CMP #'M'
    BNE NOT_M
    JSR CMD_MODIFY
    RTS
NOT_M:

    ; 'G' - 执行程序
    CMP #'G'
    BNE NOT_G
    JSR CMD_GO
    RTS
NOT_G:

    ; 'R' - 显示寄存器
    CMP #'R'
    BNE NOT_R
    JSR CMD_REGS
    RTS
NOT_R:

    ; 'L' - 通过UART加载程序
    CMP #'L'
    BNE NOT_L
    JSR CMD_LOAD
    RTS
NOT_L:

    ; '?' - 帮助
    CMP #'?'
    BNE NOT_HELP
    JSR CMD_HELP
    RTS
NOT_HELP:

    ; 未知命令
    LDA #'?'
    JSR PUTCHAR
    LDA #$0D
    JSR PUTCHAR
    LDA #$0A
    JSR PUTCHAR
    RTS

3. 内存显示命令 (Dump)

; D addr - 显示16字节内存
; 格式: XXXX: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX  ................

CMD_DUMP:
    JSR PARSE_ADDR      ; 解析地址到$22/$23
    LDY #$00            ; 16行
DUMP_ROW:
    ; 打印地址
    LDA $23
    JSR PRINT_HEX8
    LDA $22
    JSR PRINT_HEX8
    LDA #':'
    JSR PUTCHAR
    LDA #' '
    JSR PUTCHAR

    ; 保存起始地址
    LDA $22
    PHA
    LDA $23
    PHA

    ; 打印16个字节的十六进制值
    LDX #$00
DUMP_HEX:
    LDA ($22),Y
    JSR PRINT_HEX8
    LDA #' '
    JSR PUTCHAR
    INY
    CPY #$10
    BNE DUMP_HEX

    ; 打印ASCII字符
    LDA #' '
    JSR PUTCHAR
    PLA
    STA $23
    PLA
    STA $22
    LDY #$00
DUMP_ASCII:
    LDA ($22),Y
    CMP #$20             ; 小于空格的不可打印
    BCC DUMP_DOT
    CMP #$7F             ; 大于DEL的不可打印
    BCS DUMP_DOT
    JSR PUTCHAR
    JMP DUMP_NEXT
DUMP_DOT:
    LDA #'.'
    JSR PUTCHAR
DUMP_NEXT:
    INY
    CPY #$10
    BNE DUMP_ASCII

    ; 换行
    LDA #$0D
    JSR PUTCHAR
    LDA #$0A
    JSR PUTCHAR

    ; 地址+16
    LDA $22
    CLC
    ADC #$10
    STA $22
    LDA $23
    ADC #$00
    STA $23

    RTS

4. 执行程序命令 (Go)

; G addr - 跳转到指定地址执行

CMD_GO:
    JSR PARSE_ADDR       ; 解析地址到$22/$23

    ; 保存监控程序返回地址
    LDA #>MON_LOOP
    PHA
    LDA #; 间接跳转到目标地址
    JMP ($0022)          ; 通过零页间接跳转

    ; 注意:如果目标程序执行RTS,会返回MON_LOOP
    ; 目标程序应该以JMP MONITOR结束

5. UART加载程序命令 (Load)

; L - 通过UART接收Intel HEX格式文件
; Intel HEX格式:
; :LLAAAATT[DD...]CC
; LL=数据长度, AAAA=地址, TT=类型, DD=数据, CC=校验和

CMD_LOAD:
    JSR PRINT_MSG
    .DB "Waiting for HEX data...",$0D,$0A,$00

LOAD_LOOP:
    JSR GETCHAR          ; 等待起始字符':' 
    CMP #':'
    BNE LOAD_LOOP

    ; 读取记录头
    JSR READ_HEX_BYTE   ; 字节数 → $24
    STA $24
    JSR READ_HEX_BYTE   ; 地址高字节 → $23
    STA $23
    JSR READ_HEX_BYTE   ; 地址低字节 → $22
    STA $22
    JSR READ_HEX_BYTE   ; 记录类型 → A
    CMP #$01            ; 类型01=文件结束
    BEQ LOAD_DONE

    ; 读取数据字节
    LDX #$00
LOAD_DATA:
    JSR READ_HEX_BYTE
    STA ($22,X)         ; 写入内存(零页变址)
    ; 注意:实际应使用绝对变址
    INX
    CPX $24
    BNE LOAD_DATA

    ; 读取校验和(忽略验证)
    JSR READ_HEX_BYTE

    LDA #'.'             ; 进度指示
    JSR PUTCHAR
    JMP LOAD_LOOP

LOAD_DONE:
    JSR PRINT_MSG
    .DB $0D,$0A,"Load complete!",$0D,$0A,$00
    RTS

6. 工具子程序

; ===== 串口IO子程序 =====

PUTCHAR:
    PHA                  ; 保存A
WAIT_TX:
    LDA $FF01            ; UART状态
    AND #$80             ; bit7=TX_BUSY
    BNE WAIT_TX          ; 等待发送器空闲
    PLA
    STA $FF00            ; 写入发送数据
    RTS

GETCHAR:
    LDA $FF01            ; UART状态
    AND #$20             ; bit5=RX_READY
    BEQ GETCHAR          ; 等待接收数据
    LDA $FF00            ; 读取接收数据
    RTS

; ===== 十六进制输出 =====

PRINT_HEX8:
    PHA
    LSR                  ; 高4位
    LSR
    LSR
    LSR
    JSR PRINT_NIBBLE
    PLA
    AND #$0F             ; 低4位
PRINT_NIBBLE:
    CMP #$0A
    BCC NIBBLE_NUM
    ADC #$06             ; 'A'-':'-1=6
NIBBLE_NUM:
    ADC #$30             ; +'0'
    JMP PUTCHAR

; ===== 读取一行输入 =====

READ_LINE:
    LDX #$00
READ_LOOP:
    JSR GETCHAR
    CMP #$0D             ; 回车=结束
    BEQ READ_DONE
    CMP #$08             ; 退格
    BNE READ_STORE
    CPX #$00             ; 已经在行首?
    BEQ READ_LOOP
    DEX
    LDA #$08
    JSR PUTCHAR          ; 回显退格
    LDA #' '
    JSR PUTCHAR
    LDA #$08
    JSR PUTCHAR
    JMP READ_LOOP
READ_STORE:
    STA $0100,X
    JSR PUTCHAR          ; 回显字符
    INX
    CPX #$7F             ; 最大127字符
    BNE READ_LOOP
READ_DONE:
    LDA #$00
    STA $0100,X          ; NUL结束
    LDA #$0D
    JSR PUTCHAR
    LDA #$0A
    JSR PUTCHAR
    RTS

; ===== 解析十六进制地址 =====

PARSE_ADDR:
    LDX #$02             ; 跳过命令字符和空格
    LDA #$00
    STA $22
    STA $23
PARSE_LOOP:
    LDA $0100,X
    BEQ PARSE_DONE
    CMP #' '
    BEQ PARSE_SKIP
    JSR HEX_CHAR_VAL     ; 将ASCII十六进制转为数值
    LDA $23              ; 结果*16+新数字
    ASL
    ASL
    ASL
    ASL
    STA $23
    LDA $0100,X
    JSR HEX_CHAR_VAL
    ORA $23
    STA $23
    INX
    JMP PARSE_LOOP
PARSE_SKIP:
    INX
    JMP PARSE_LOOP
PARSE_DONE:
    RTS

HEX_CHAR_VAL:
    CMP #'0'
    BCC HCV_ERR
    CMP #'9'+1
    BCS HCV_ALPHA
    SEC
    SBC #'0'
    RTS
HCV_ALPHA:
    CMP #'A'
    BCC HCV_ERR
    CMP #'F'+1
    BCS HCV_LOWER
    SEC
    SBC #'A'-10
    RTS
HCV_LOWER:
    CMP #'a'
    BCC HCV_ERR
    CMP #'f'+1
    BCS HCV_ERR
    SEC
    SBC #'a'-10
    RTS
HCV_ERR:
    LDA #$00
    RTS
✅ 验证通过 —— 监控程序汇编代码结构完整,通过retro8asm汇编器可以正确翻译为机器码。

📋 监控程序内存布局

监控程序ROM布局 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ $8000 ┌────────────────────┐ │ 向量跳转表 │ JMP timer_isr 等 $0018 ├────────────────────┤ │ 监控主程序 │ 主循环、命令解析 $0200 ├────────────────────┤ │ 命令处理程序 │ D/M/G/R/L/?/ $0400 ├────────────────────┤ │ 工具子程序 │ IO、字符串、转换 $0600 ├────────────────────┤ │ 中断服务程序 │ 定时器/键盘/UART ISR $0800 ├────────────────────┤ │ 字符串常量 │ 提示信息、帮助文本 $0A00 ├────────────────────┤ │ 预留 │ 未来扩展 $F9FF └────────────────────┘ RAM使用: $0000-$001F: 零页变量 $0020-$00FF: 系统变量 $0100-$017F: 输入缓冲区 $0180-$01FF: 监控程序栈 $0200-$77FF: 用户程序区

🎯 练习

练习1:断点功能

为监控程序添加软件断点:B addr命令在指定地址插入BRK指令($00),保存原指令。当CPU执行到BRK时触发中断,ISR恢复原指令并回到监控程序,显示寄存器值。这是最基础的调试功能。

练习2:单步执行

实现单步调试(Step):执行一条指令后立即回到监控程序。提示:利用CPU的trace模式——在每条指令后强制产生中断。需要在CPU中加入trace_enable控制位。

练习3:S-Record格式

除了Intel HEX,Motorola S-Record也是常用的机器码传输格式。实现S1/S9记录的解析和加载。S-Record与Intel HEX的主要区别是什么?各有什么优缺点?

练习4:命令历史

为监控程序添加命令历史功能:用上/下箭头键浏览之前输入的命令。需要维护一个环形缓冲区存储历史命令。这是一个很好的内存管理练习。

🏆 成就解锁:系统守护者

你实现了完整的交互式监控程序!这包括:

监控程序是最小的操作系统——有了它,你的8位电脑真正成为一台可交互的机器!