用纯数字逻辑电路实现一个 8-bit 音乐播放器:自研五段 CPU + 基于 Logisim Buzzer 的 APU + NSF 文件离线转换工具链。最终能从 Logisim 内置 Buzzer 播放任意开源 NES 游戏音乐, 带完整的播放/暂停/切歌/计时控制。
在 Logisim Evolution 里搭建一个能播放真实 NES 游戏音乐的"硬件"播放器。
输入是开源社区的 .nsf 文件,输出是从 Buzzer 真实发出的音乐声音。
MM:SS整个系统分为三大块:转换脚本(在电脑上跑)、CPU(在 Logisim 里搭)、APU(在 Logisim 里搭)。 它们之间用简单的数据格式解耦,前期可以并行推进。
这个区别非常关键,决定了整个项目的复杂度:
在 Logisim 里完整实现一个 6502 CPU,加载真实的 NSF 文件, 让它像真 NES 一样运行 6502 程序。
→ 需要 56 条指令、bank switching、NMI 中断处理…
→ 工作量爆炸,作业要求只是"五段 CPU"
Python 脚本把 NSF 转成"等待 N 拍 → 写寄存器 R = V"这种超简单的指令流。 Logisim CPU 只需要 5-7 条自定义指令就能跑。
→ CPU 极简,几十个组件搞定
→ 五段流程清晰,验收时好讲
真实 2A03 有五个发声通道:Pulse×2、Triangle、Noise,以及 DMC(Delta Modulation Channel)。
DMC 不是发声器,而是1-bit DPCM 采样回放通道——它能播放预先录制好的任何声音: 鼓点、人声、采样乐器都行。NES 游戏里那些"语音喊叫"或"真实鼓声"都是用它做的。 但 NES 卡带空间极小,DMC 的采样率低、采样长度短,所以听起来很糙。
在 Logisim 里实现 DMC 需要:DPCM 解码器 + 独立的 ROM 访问通道 + 字节缓冲器, 工作量大约占整个 APU 的 30%,而且效果有限。
转换脚本检测到 NSF 使用 DMC 寄存器($4010–$4013)时,直接跳过这些写入。
损失的是"鼓点和人声采样",保留的是"主旋律 + 和声 + 噪声鼓点"——大部分 NES 音乐听感损失有限。
真实 2A03 的三角波是 32 步阶梯(4-bit 量化),听起来有"颗粒感",是 NES 经典音色之一。 很多教程会建议在 Logisim 里自己搓出这个阶梯。
但我们经过讨论决定不搓阶梯,直接用 Buzzer 的 triangle 波形,理由如下:
代价:硬核 NES 玩家在低音区能听出"太干净了"。
收益:APU 工作量大幅压缩,时间投入到 CPU 和扩展功能上。
符合我们"不百分百还原也没关系,但不能割裂"的目标。
这是整个系统的声音出口。我们的 APU 设计就是围绕 Buzzer 的能力展开的。
Logisim Evolution 的 Buzzer 组件(位于 I/O Extra 库)有以下输入引脚:
Volume Width 决定(常用 7-bit 或 8-bit)可配占空比。给 Pulse 1 + Pulse 2 用。
数学三角波,比 2A03 阶梯更纯净。给 Triangle 用。
Java Random 生成,与 2A03 LFSR 不同序列但听感都是噪声。给 Noise 用。
Logisim 的 Buzzer 输出到系统声卡,多个 Buzzer 同时响时,声卡会自动叠加它们的声音——
这就是天然的混音器,不需要我们自己搭加法器。
所以我们的 APU 设计变得非常简洁:每个通道一个 Buzzer,4 个 Buzzer 并联,分别接收各自的频率/音量/使能信号。
用 Python 写,在电脑上运行,不进 Logisim。负责把 .nsf 文件转成 ROM 映像。
| Opcode | 格式(字节) | 语义 |
|---|---|---|
0x00 | 00 NN | 等待 NN 个时间单位(短等待,最长 255) |
0x01 | 01 RR VV | 写 APU 寄存器 RR = VV,暂时不带 KK 字段 |
0x02 | 02 RR VV KK | 同上 + 附 MIDI 音符号 KK(可视化用,预留) |
0x03 | 03 NN NN | 长等待(16-bit 时间,最长 65535) |
0xFF | FF | 曲目结束 / 循环点 |
# ROM 内容示例(一段假想的曲子) # 假设时间单位 = 1 个 CPU 周期 01 00 9F # 写 Pulse1 控制 = 0x9F(音量+占空比) 01 02 AA # 写 Pulse1 频率低 = 0xAA 01 03 00 # 写 Pulse1 频率高 = 0x00 → 触发发声 03 04 00 # 等待 1024 个周期(约 1/4 拍) 01 02 BB # 改变频率 00 80 # 等待 128 个周期 ... FF # 曲目结束,触发循环或下一首
Logisim 实际时钟跑得很慢(最快也就几 kHz),无法跑到 NES 的 1.79 MHz。 脚本里必须按比例缩放:
时间值和频率值要同时缩放,否则会"节奏对了音高错"或反过来。 调试时先做"音高正确但慢放"版本验证电路,再调到合适速度。
自研单周期五段 CPU。不用开源 6502——作业要求是"五段架构"而不是"能跑 NES 的 CPU", 自己搓的极简版更符合验收要求。
为了简化译码电路,统一用 16-bit 等长指令。高 4 位是 opcode,低 12 位是操作数。
问题:WAIT 指令需要让 CPU 停在原地等 N 个周期,怎么实现?
方案:EX 阶段维护一个 12-bit"等待计数器"寄存器。
WAIT 指令到 EX 时把计数器装载为 N,之后每个时钟 -1,直到归零才发出 stall=0。
WB 阶段看到 stall=1 就不更新 PC,IF 阶段于是反复取同一条 WAIT 指令——
但因为计数器在递减,实际上是在"原地走时间"。
约定 APU 寄存器映射到地址 $4000–$400F(共 16 个 8-bit 寄存器,模仿真实 NES 布局)。
MEM 阶段拿到 reg_id 后,根据 opcode 判断是不是 WRITE 类指令:
apu_we[reg_id] = is_write_op && stage==MEM
APU 寄存器组就是 16 组 8-bit D 触发器,每组有自己的写使能。这是 CPU 跟 APU 之间唯一的通信。
真流水化需要处理"数据冒险"和"控制冒险",工作量翻倍。 作业只要求"有五个阶段"——单周期五段满足要求。组件数估计:CPU 整体约 60-80 个元件。
APU 的工作是:解析 CPU 写入的寄存器值 → 算出频率/音量/使能 → 喂给对应的 Buzzer。 没有波形生成器,没有混音器——Buzzer 已经搞定了。
| 地址 | 归属 | bit 7..0 | 用途 |
|---|---|---|---|
$4000 | Pulse 1 | DDLC NNNN | 占空比(2)/长度禁(1)/包络禁(1)/音量(4) |
$4001 | Pulse 1 | EPPP NSSS | 扫频(我们忽略,写入但不用) |
$4002 | Pulse 1 | TTTT TTTT | 频率低 8 位 |
$4003 | Pulse 1 | LLLL LTTT | 长度计数 + 频率高 3 位 |
$4004-7 | Pulse 2 | 同 Pulse 1 | |
$4008 | Triangle | CRRR RRRR | 线性计数控制 |
$400A | Triangle | TTTT TTTT | 频率低 8 位 |
$400B | Triangle | LLLL LTTT | 长度计数 + 频率高 3 位 |
$400C | Noise | --LC NNNN | 长度禁 + 音量 |
$400E | Noise | M--- PPPP | 模式 + 周期索引 |
$400F | Noise | LLLL L--- | 长度计数 |
$4015 | 状态 | ---D NT21 | 通道使能(noise/triangle/pulse2/pulse1) |
每个通道都是这三件事:① 从寄存器读出 timer 值 → ② 用公式换算成 Hz → ③ 接到 Buzzer 的 Frequency 引脚。 加上音量、使能、占空比的接线。不写任何波形生成逻辑。
NES 的 4 种占空比(12.5%/25%/50%/75%)映射到 Buzzer 的 8-bit Duty Cycle 输入:
$4000 bit7-6 → MUX 选择 → {32, 64, 128, 192}
(Buzzer Duty 是 0-255 满量程,所以 NES 50% = Buzzer 128)
Pulse 通道 = 频率换算 + 占空比查表 + 音量。这是工作量最大的通道。
Buzzer 的 triangle 波形不接受 Duty Cycle 输入。
所以 Triangle 通道只需要:11-bit timer → 频率换算 → Buzzer.Frequency。
音量?真实 2A03 的 Triangle 通道没有音量寄存器——它只有"响"或"不响"两态。
我们也照做:Buzzer.Volume 接一个固定值(比如最大值),用 $4015 使能位控制响不响。
整个 Triangle 通道大约 10 个组件就够了。
Noise 通道的频率不是 11-bit timer,而是 $400E bit3-0 这 4 位作为周期表索引。
用一个 16 项的 ROM 查找表把索引映射到频率:
# NES Noise 周期表(NTSC,单位 CPU 周期) 4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068 # 缩放后写入查找 ROM,输出接 Buzzer.Frequency
Buzzer 的 noise 波形是 Java Random,不是真 NES 的 LFSR——
但听感上都是噪声,对作业来说够用了。
$4015 寄存器的低 4 位分别控制 4 个通道的使能。
每个通道把这一位 AND 到自己的 Buzzer.Select 引脚上。
切歌/停止时把 $4015 写成 0 就能瞬间静音所有通道。
4 个 Buzzer 都连到 Logisim 仿真器的音频输出,系统声卡自动叠加它们的声音。
这就是天然的线性混音——不需要我们自己搭加法器。
这是把工作量交给 Buzzer 组件最大的好处。
这部分是纯逻辑,不经过 CPU。用按钮 + 几个触发器搞定。
NSF 格式本身没有曲长字段——它是 6502 程序,理论上能无限循环。
连 NSFPlay 这种大软件也是用"默认播放 N 分钟然后淡出"处理的。
所以我们也这么做:用户可以设置上限(DIP 开关 4/5/8 分钟),到了自动切下一首。
ROM 里只存一个完整循环,硬件自动 PC 清零无缝循环播放。
为后期想加的钢琴键盘可视化和计时条提前预留接口。
现阶段建一个空的 Display 子电路占位,主电路布线一次到位。
| 信号名 | 位宽 | 来源 | 用途 |
|---|---|---|---|
elapsed_min | 8-bit | 计时器 | 分钟显示 |
elapsed_sec | 6-bit | 计时器 | 秒数显示 |
limit_min | 4-bit | DIP 开关 | 用户设定上限 |
track_id | 4-bit | 曲目计数器 | 当前曲目编号 |
freq_ch1..4 | 11-bit ×4 | APU 寄存器 | 音高 → 钢琴键 |
vol_ch1..4 | 4-bit ×4 | APU 寄存器 | 判断是否在响 |
midi_key_ch1..4 | 7-bit ×4 | WKEY 指令 | 直接读 MIDI 音符(脚本预编码) |
可视化更新频率不需要和 CPU 时钟同步(人眼 30 Hz 就够)。 子电路入口加一组采样寄存器 + 30 Hz 慢时钟,CPU 跑得再快也只采样到稳定帧。 这相当于给显示加了"帧缓冲",能避免画面抖动且节省组件资源。
四个角色,组长负责统筹和补位,其他三人各主攻一块。 三大块工作可并行推进,前期不互相阻塞。
统筹三方进度、维护接口约定、负责系统联调。技术上每块都参与一点,是最了解全局的人。
负责 NSF → ROM 映像的离线工具链。纯软件工作,前期可独立推进,不依赖 Logisim 部分。
.txt负责 Logisim 里的五段 CPU。验收时这块最受关注,要做扎实。
负责音频合成 + 输出。这块最有"出活"的成就感——一旦做完就能听到声音。
$4000–$400F 落到 APU 哪个寄存器。
所有人:通读 nesdev wiki 的 APU 章节
A:起草接口文档第一版,开 git 仓库
B:搭 Python 环境,跑通 NSFPlay 导出日志
C:在 Logisim 里搭一个最简单的"PC + ROM + 译码"看效果
D:在 Logisim 里玩 Buzzer 组件,测试不同 waveform 属性下的声音
一周后碰一次,对齐接口、调整方案。
今天是 5 月 16 日(周六),验收 6 月 15 日(周日),共 30 天。 划分成 4 个周期,每个周期有明确的 demo 标准——不是"做完了",而是"能演示什么、能听到什么"。 最后留 3 天 buffer 应对 bug 和验收准备。
不要拖到最后才发现问题。每周日固定开会,每人说三件事:
1) 这周做完了什么(拿 demo 出来)
2) 下周准备做什么
3) 卡住的地方 / 需要别人配合的事
开会时长不超过 30 分钟。超时说明问题太多,应该拆成 1v1 单独解决。
遇到具体组件不会用、属性不知道怎么配、寄存器位定义记不清的时候——看官方文档,不要凭印象。
遇到具体组件不会用时直接查这些页面:
v2.0 raw,然后空格分隔的十六进制。
Logisim Evolution 内置帮助系统:选中任意组件 → 右键 → Show Component Help →
直接打开该组件的文档页(其实就是 mbaillif 那个站的镜像)。不用搜索,最快。