数字逻辑课程设计 · 最终答辩 2026 · 06

Logisim NSF 音乐播放器

自制 16-bit 五段 CPU × 简化 2A03 APU × 蜂鸣器(Buzzer)—— 在纯数字电路里,播放真实 NES 游戏音乐
.nsf 真实音乐文件 寄存器写入日志 指令 ROM CPU 取指执行 APU 四通道 ♪ 出声
答辩人:___ 组员:___ · ___ · ___
02项目总览 · OVERVIEW

从 NSF 到出声:一条确定的数据链

① 离线工具链 · PC 上运行一次(script/) ② LOGISIM 电路 · 答辩现场实时运行 .nsf 文件 真实 NES 音乐代码 GME 模拟器 运行 6502 + 2A03 hook 全部 APU 寄存器写入 寄存器日志 frame addr value 谁、何时、写了什么 encode.py 日志 → 16-bit 指令字 WAIT / WRITE / END track00–03.txt Logisim v2.0 raw 4 首曲目 ROM 映像 ROM 加载映像(Load Image…) 指令 ROM × 4 addr 16 / data 16 4:1 多路复用器选曲 CPU 16-bit 单周期五段 取指 · 译码 · WAIT 停顿 · ALU APU RegFile + 四通道 Pulse×2 · Triangle · Noise 蜂鸣器(Buzzer)× 4 真实 Hz · 方波/三角波/白噪声 按墙上时间生成波形 ♪♫ 音乐 instr 写寄存器
把问题拆成两半:离线阶段只负责把真实 NSF 展开成确定的寄存器写入序列;硬件阶段只负责按时序重放。 于是 Logisim 里不需要实现完整 6502 和完整 2A03——只需要一颗会"读谱"的 CPU 和一个会"发声"的简化 APU。
Logisim NSF Music Player02 / 15
03背景 · 2A03 原理

2A03 的声音本质:CPU 写 APU 寄存器

  • NES 主芯片 RP2A03 = 6502 CPU 核 + APU(音频处理单元)。
  • APU 以内存映射寄存器暴露给 CPU:$4000–$4013$4015$4017
  • 游戏音乐 = CPU 每帧(约 60Hz)运行 PLAY 例程,反复改写这些寄存器
  • 寄存器语义:timer → 音高,duty → 音色,volume → 响度,$4015 → 声道开关。
NES 音乐不是音频流,而是一串按帧排列的寄存器写入。 只要记录这串写入,再按相同节奏重放,就能复现旋律与主要配器。
例子:同一个数字,APU 按位解释
写 $4000 = 0x9F 0x9F = 1001 1111 bit 7 6 5 4 3 2 1 0 1 0 0 1 1 1 1 1 duty=10 控制位 volume=15 方波占空比 → 音色 0-15 振幅 → 响度 $4001 Sweep:硬件自动滑音/音效、省 CPU;本项目略过,主体仍靠 timer $4003 = LLLLL TTT:高 5 位 length,低 3 位参与 timer 写 $4002/$4003 拼 timer $4003=00000 000 + $4002=11111101 timer = 253 Pulse 频率 = 1789773 / (16 × (timer + 1)) ≈ 440 Hz
RP2A03(NES 主芯片) 6502 CPU 核 每帧运行 PLAY 例程 APU 音频单元 5 个声音通道 写 $4000–$4017 Pulse 1 $4000–$4003 Pulse 2 $4004–$4007 Triangle $4008–$400B Noise $400C–$400F DMC ✕ $4010–$4013 $4015 声道使能 — 哪些通道发声 本项目保留四通道;DMC 采样通道按范围裁剪 每帧 ≈ 60Hz:寄存器变化 → 音乐前进一拍
NESdev Wiki · APU03 / 15
04背景 · 波形科普

四种通道,四种声音的分工

脉冲波 × 2PULSE · $4000–$4007
主旋律与和声。两路独立,撑起乐曲的"歌声"部分。
可变占空比(duty)改变音色;timer 决定音高。
三角波TRIANGLE · $4008–$400B
低音线 / 稳定旋律。比方波更"圆润"。
没有音量控制——只有"响 / 停"两种状态(NESdev)。
噪声NOISE · $400C–$400F
鼓点、镲、爆炸等打击乐层。
LFSR 伪随机 1-bit 噪声,16 档频率可选。
DMC 采样DMC · $4010–$4013
PCM 采样回放(语音、真实鼓声)。
本项目裁剪:dumper 与编码器都跳过 $4010–$4013。
保留前三类共四个通道就能覆盖旋律、和声、低音、鼓点——这是有意识的工程取舍,不是遗漏。
NESdev Wiki · APU Pulse / Triangle / Noise04 / 15
05可行性 · BUZZER 源码

为什么能搬进 Logisim:蜂鸣器(Buzzer)刚好对得上

BUZZER.JAVA · 端口与行为(源码级) 蜂鸣器(Buzzer) 输入/输出(扩展)库 波形(Waveform)属性: 方波 · 三角波 · 白噪声 · 正弦 · 锯齿 FREQ 14-bit · 真实 Hz ENABLE 1-bit VOL 音量位宽可配 PW 8-bit · 占空比 内部行为(音频线程) 输入变化 → updateRequired → 重建 1 秒缓冲 byte[4 × sampleRate] → 重新打开 Clip 播放 有效频率范围 20 – 20000 Hz;波形按真实墙上时间生成,与仿真时钟无关
NES 侧
BUZZER 侧(我们的映射)
timer 11-bit
freq_* ROM 得真实 Hz → FREQ
duty 2-bit
映射成 8-bit 占空比 → PW
volume 4-bit
直接接入 → VOL
$4015 ∧ ch_mask
曲目使能 与 手动开关 → ENABLE
通道类型
波形(Waveform)属性选 方波(Square)/ 三角波(Triangle)/ 白噪声(White noise)
2A03 寄存器的语义(音高 / 音色 / 响度 / 开关)与 Buzzer 的四个输入一一对应。 我们不自己造波形发生器——只做"寄存器 → Buzzer 输入"的翻译电路。
依据:ref/logisim-evolution · std/io/extra/Buzzer.java(端口常量、updateports()、BuzzerWaveform 枚举)
Logisim-evolution v4.1.0 源码05 / 15
06可行性 · 风险对策

Buzzer 可用,但不能让高速时钟每拍改它

源码边界(Buzzer.java)
  • 任何输入变化都触发 updateRequired → 音频线程整段重建缓冲、重开 Clip
  • 参数变化太频繁 = 不停重建 = 爆音、卡顿。
  • 源码注释明确警告:不要把 4kHz 时钟接到 enable
工程对策(两层限速)
  • 测试电路:Splitter(分线器)取计数器(Counter)高位做 ROM 地址 → 换频降到约 60Hz。
  • 真实播放器:CPU 执行 WAIT 主动停 PC → 高速时钟被分成约 60Hz 音乐帧率。
  • 帧提交:APU 内置采样寄存器(Register),frame_commit 在一帧写完后才把参数提交给 Buzzer,避免采到"半更新"组合。
  • 软件节流encode.py 提供颤音死区、最大更新率等过滤选项。
uploads/shot_m1_buzzerstress.png
截图 apu.circ 的 BuzzerStress 验证电路(运行状态下截)。要能看清: 时钟(Clock)→ 计数器(Counter)→ Splitter(分线器)取高位 → freq_pulse ROM → 蜂鸣器(Buzzer)。 用途:证明 Buzzer 链路已实测可发声,高位分频对策有效。
里程碑 1 · BuzzerStress 实测电路
Buzzer.java · milestone 1/206 / 15
07工具链 · NSF → ROM

脚本不生成声音,生成 CPU 能执行的"谱面"

阶段一 · GME dumper(C++)
  • 不自己实现 6502——借成熟模拟器 game-music-emu 正确运行 NSF。
  • hook 单一写入口 Nes_Apu::write_register:每次 APU 写入记录为 frame addr value
  • 帧号来自 PLAY 帧 hook;DMC $4010–$4013 在此跳过。
阶段二 · encode.py(Python)
  • 每帧:先发本帧全部 WRITE,再补 WAIT = 32 − 写入数 → 每帧恒占 32 周期。
  • 空帧合并为整段等待;曲末追加 END
  • 输出 Logisim v2.0 raw 文本,直接 加载映像(Load Image…)进 ROM。
# 寄存器日志            16-bit 指令字
frame 0  $4015 0x0F  →  0x190F  WRITE reg9,0x0F
frame 0  $4000 0x86  →  0x1086  WRITE reg0,0x86
frame 0  $4002 0xFD  →  0x12FD  WRITE reg2,0xFD
(本帧共 3 条写入)0x001D  WAIT 29 = 32−3
frame 1  …
(曲目结束)0xF000  END → PC 归零循环
可行性一句话:NSF 播放最终表现为每帧对 APU 寄存器的确定写入序列; 记录并按相同时序重放,就能复现主要音乐信息。进入 Logisim 后, 所有时序仍由 CPU 的 PC、WAIT、写脉冲决定——脚本没有替硬件干活。
script/gme_dump · script/encode.py07 / 15
08工具链 · 指令集设计

ROM 里是什么:16-bit 定长指令流

指令格式(高 4 位 = OPCODE) 0001 reg_id (4) value (8) WRITE 0000 imm (12) — 等待周期数 WAIT 1111 —(忽略) END
PC 16-bit · 曲目 ROM addr 16 / data 16 · freq ROM 11→14(Pulse/Tri)/ 4→14(Noise)
opcode指令作用用途
0x0WAIT imm12停 PC 若干周期节奏主体
0x1WRITE r,v写 APU 寄存器回放主体
0x2LWAIT长等待空帧合并
0x4LOADI立即数 → RALU / 可控加减
(详见第 13 页)
0x5ADDRd ← Ra + Rb
0x6SUBRd ← Ra − Rb
0x7WRITEREGR 值写 APU
0xFEND曲终 → PC 装 0 循环循环播放
APU 寄存器映射 · reg_id 仅 4 位 → $4015 借用空槽 9(原 $4009 未使用)
0P1
1P1
2P1
3P1
4P2
5P2
6P2
7P2
8TRI
9$4015
10TRI
11TRI
12NOI
13丢弃
14NOI
15NOI
script/encode.py · plan/detailed_plan.html §3–408 / 15
09APU · 总体结构

APU:CPU 写口、RegFile 和四个独立声道

reg_id [4] value [8] WR 写脉冲 frame_clk ch_mask [4] RegFile 译码器(Decoder) 4 → 16 选写目标 16 × 8-bit 寄存器(Register) reg0 … reg15 输出脚命名 regN_out (标签判重不区分大小写) Channel_Pulse ① reg0 / reg2 / reg3 Channel_Pulse ② reg4 / reg6 / reg7(同一子电路复用) Channel_Triangle reg8 / reg10 / reg11 Channel_Noise reg12 / reg14 reg9($4015 低 4 位)∧ ch_mask → 各通道蜂鸣器(Buzzer)的 ENABLE
reg_id / value
4 / 8
CPU 要写哪个寄存器、写什么
WR
1
写脉冲(= is_write · clk)
frame_clk
1
帧提交:一帧写完才把稳定值采样给 Buzzer
ch_mask
4
面板手动声道开关(P1/P2/Tri/Noise)
uploads/shot_apu_overview.png
截图 apu.circ 的 APU 顶层子电路。要能看清: reg_id / value / WR / frame_clk / ch_mask 输入引脚(Pin)、 RegFile(译码器 Decoder + 16 个寄存器 Register)、四个 Channel 子电路实例。
apu.circ · APU 顶层
apu.circ · milestone 209 / 15
10APU · 声道实现

声道实现:寄存器拼 timer,
查频率 ROM,喂 Buzzer

CHANNEL_PULSE 数据通路 reg2 timer low · 8 bit reg3 [2:0] timer hi · 3 bit reg0 [3:0] 音量 reg0 [7:6] 占空比 Splitter (分线器)拼位 timer [10:0] freq_pulse ROM 2048 × 14 bit timer → 真实 Hz 蜂鸣器(Buzzer) 波形 = 方波(Square) FREQ VOL(4-bit 音量) duty → PW 映射 reg9 ∧ ch_mask → ENABLE
Pulse
f = 1789773 / (16·(t+1))
校验:t=253 → 440Hz (A4)
Triangle
f = 1789773 / (32·(t+1))
无音量 · 仅 enable 控制
Noise
4-bit 码 → 40–200Hz
Buzzer 白噪声低频近似
uploads/shot_apu_channel.png
截图 Channel_Pulse 子电路内部。要能看清: Splitter(分线器)把 reg2 / reg3 拼成 11-bit timer、 freq_pulse ROM、蜂鸣器(Buzzer)的 FREQ / VOL / PW / ENABLE 四路接线。
apu.circ · Channel_Pulse 内部
三类声道结构同构;ROM 值不是手填——由 make_tables.py 按 NES 公式 + Buzzer 边界程序化生成,可复现、可校验。
script/make_tables.py · apu.circ10 / 15
11CPU · 总体数据通路(重点)

CPU:单周期五段,重点是控制而不是流水线

top.circ 提供:指令 ROM × 4 + 4:1 多路复用器(Multiplexer) CPU 本体不含曲目 ROM → 同一颗 CPU 可播多曲 IF 取指 PC 16-bit 计数器(Counter) 计数使能 = ¬stall END → 装载 0 循环 pcbus [16] instr [16] ID 译码 Splitter(分线器)拆字段 opcode / reg_id / value / imm / Rd Ra Rb 译码器(Decoder)出控制线 is_wait / is_write / is_end / … EX 执行 ALU:加法器 / 减法器 / 钳位 WAIT 状态机 busy + WAITCNT 倒数 MEM 访存(写 APU) reg_id / value 输出 wr = is_write · clk frame_commit = start · clk WB 写回 PC 步进(stall 门控) R0–R3 写回 一条指令在一个时钟周期内走完五段;唯一的多周期指令是 WAIT
  • "五段"是逻辑阶段,不是流水线——单周期完成 IF→ID→EX→MEM→WB。
  • PC 直接用 16-bit 计数器(Counter)实现,省去"寄存器 + 加法器"组合。
  • 指令 ROM 外置:CPU 暴露 pcbus 出 / instr 入——CPU 与存储器边界清晰,天然支持多曲切换。
uploads/shot_cpu_datapath.png
截图 cpu.circ 的 CPU 子电路全景。要能看清:PC 计数器(Counter)、 pcbus / instr 引脚(Pin)、Splitter(分线器)+ 译码器(Decoder)、WAITCNT、ALU 区、R0–R3、APU 输出信号。
cpu.circ · CPU 全景
cpu.circ · milestone 311 / 15
12CPU · WAIT 停顿状态机(重点)

WAIT 是整台播放器节奏正确的关键

状态机:busy 1-bit 寄存器(Register)+ WAITCNT 向下计数器(Counter) 正常执行 PC 每拍 +1 busy 倒数 stall=1 · PC 冻结 WAITCNT −1 / 拍 遇 WAIT:装载 imm → WAITCNT,置 busy at_zero(Carry):清 busy,PC 放行 +1 未到 0:继续倒数
start     = is_wait · ¬busy   ← 防止 WAIT 被反复重装(死循环陷阱)
en_count  = busy · ¬at_zero
busy_next = start + en_count
stall     = busy_next         → PC 计数使能 = ¬stall
wr        = is_write · clk    ← 连续 WRITE 不丢边沿
frame_commit = start · clk   ← 一帧写完才提交 Buzzer 参数
uploads/shot_cpu_wait.png
截图 cpu.circ 中 WAIT 区局部放大。要能看清: WAITCNT 向下计数器(Counter)、busy 寄存器(Register)、 start / en_count / busy_next / stall 逻辑门、 Carry → at_zero 回线、PC 计数使能由 ¬stall 控制。
cpu.circ · WAIT busy 状态机
  • 节奏不是来自"慢时钟",而是高速时钟下 CPU 主动停 PC——编码器的 frame_cycles=32 预算因此才生效。
  • 诚实声明:WAIT n 实占 n+2 拍,但每帧是均匀常数,节奏不受影响,可由节拍频率 / ALU 修正。
milestone 3 · Counter.java(向下计数 Carry)12 / 15
13CPU · ALU 与存储单元(重点)

ALU 不是孤立演示:它参与真实播放变速

调速通路:每帧 WAIT 的装载值都流经 ALU WAIT imm12 来自指令译码 直通(normal) 加法器(Adder) imm + tempo_delta(slow) 减法器(Subtractor) + 比较器(Comparator)钳位 max(1, imm − delta)(fast) 3:1 多路复用器 tempo_mode(面板开关) WAITCNT.D 装载路径
normal:wait = imm · slow:wait = imm + Δ · fast:wait = max(1, imm − Δ)
通用寄存器堆 R0–R3 与存储单元
  • 4 × 8-bit 寄存器(Register);读口用多路复用器(Multiplexer)选择,写回用译码器(Decoder)选中。
  • LOADI / ADD / SUB / WRITEREG:现场演示 CPU 执行通用加减,结果可直接写 APU。
  • 全部状态元件:PC(16-bit 计数器)· WAITCNT(16-bit 向下计数器)· busy(1-bit)· R0–R3 · APU RegFile。
uploads/shot_cpu_alu.png
截图 cpu.circ 的 ALU + R0–R3 区域。要能看清: 加法器(Adder)、减法器(Subtractor)、比较器(Comparator)、 tempo_mode / tempo_delta 输入、ALU 输出接 WAITCNT 装载路径、 R0–R3 寄存器(Register)与读写多路复用器(Multiplexer)。 探针(Probe)显示 tempo_delta 数值更佳。
cpu.circ · ALU 与寄存器堆
回应"可控加减运算":切 slow / fast 档时,真实曲目每一帧的等待长度都被加法器 / 减法器改变——观众能直接听到节奏变化。
cpu.circ · milestone 413 / 15
14顶层 · TOP.CIRC

top.circ:把 CPU、APU、曲目 ROM 组装成播放器

控制面板 ▶ 播放 / 暂停 — 门控 CPU 时钟 ■ 停止 — 复位 CPU + 静音 ⏮ ⏭ 上一首 / 下一首 — 曲号 ±1,同时复位 PC ◷ tempo_mode / delta — 送 CPU ALU 变速 ⇅ ch_mask × 4 — 四路声道独立开关 ⬓ 探针显示曲号 曲号计数器(Counter) 2-bit · 00–11 ROM track00 ROM track01 ROM track02 ROM track03 4:1 多路复用器 select CPU cpu.circ 加载库引入 instr pcbus [16] · 并联回灌 4 个 ROM 地址 APU → 蜂鸣器(Buzzer)× 4 ♪ reg_id · value wr · frame_commit
uploads/shot_top_panel.png
截图 top.circ 顶层全貌(播放运行中)。要能看清: 控制按钮(Button)/ 开关、曲号计数器(Counter)+ 数码管、4 个 track ROM、 4:1 多路复用器(Multiplexer)、CPU 与 APU 实例、 tempo_mode / tempo_deltach_mask 四路开关、 CPU→APU 的 reg_id / value / wr / frame_commit 连线。
top.circ · 顶层面板(运行中)
top 不引入新算法——价值在边界:指令 ROM 放在 CPU 外, CPU 出地址、top 选曲回灌指令,同一颗 CPU 播放任意曲目。 子电路经 项目 → 加载库 → Logisim-evolution 库…(Project → Load Library → Logisim-evolution Library…)装配。
top.circ · milestone 414 / 15
15END · Q&A

谢谢聆听
欢迎提问

真实 NSF GME hook 指令 ROM Logisim CPU + APU Buzzer