查看原文
其他

【译】超硬核|在自制的 CPU 上运行 Rust

张汉东 觉学社 2022-08-20

看到一篇有趣且硬核的文章,边翻译边学习。

作者自制了一个 CPU ,然后用 Rust 实现了软件部分,包括一些简单的程序:绘图器、BASIC/Scheme 语言解释器、Web 服务器、终端模拟器和MIDI 音乐播放器等。本文将涉及许多主题内容,喝一杯,慢慢看。

原文:https://zdimension.fr/crabs-all-the-way-down/

尽管自90年代以来,各种各样的 CPU 架构数量已经逐步减少,但现在仍然有许多不同的、不兼容的CPU架构在使用。大多数计算机使用x86_64,几乎所有的移动设备和最近的 Mac 都使用某种基于ARM64ISA(指令集架构)。

不过在特定领域,还有更奇特的:大多数路由器仍然使用MIPS(历史原因),一部分开发者使用RISC-V,PS3 使用PowerPC,20年前的一些服务器使用Itanium,当然,IBM 仍然在销售他们基于S/390的大型机(现在改名为z/Architecture)。嵌入式世界的产品就更多了。AVR(用于Arduino)、SuperH(土星、Dreamcast、卡西欧9860计算器),以及可敬的8051,一个1980年的英特尔芯片,至今仍在生产、销售,甚至被第三方扩展。

所有这些架构在其定义特征上都有所不同,主要的区别是:

  • 字的大小(word size)。8、16、31、32、64位,有时更多。
  • 设计风格(design style)。RISC(指令少,操作简单),CISC(指令多,执行复杂的操作,VLIW(指令长,同时并行做很多事情)。
  • 存储架构(memory architecture)。哈佛(Harvard,独立的代码存储器和数据存储器),冯-诺伊曼(von Neumann,共享)。
  • 许可成本。RISC-V是开放的,可以免费使用,而X86和ARM等则需要许可费。
  • 特性集(features set):有些特性在特定架构平台有特定的支持。比如,浮点数(x87)、加密(AES-NI)、支持本地高级字节码执行(Jazelle、AVR32B)、矢量计算(SSE、AVX、AltiVec)。

这还不算 DSP 架构,轻描淡写地说,它是 ISA 版本的“阴阳魔界(Twilight Zone)”(支持各种奇妙的算术运算,特殊的数据大小等)。

译注: Twilight,本意是晨光或暮光,特指黎明或黄昏时分的太阳尚位于地平线以下,微弱的阳光已开始照耀大地,使世界陷入一种为朦胧的状态。黄昏时分通常被视为通往幻想世界的入口。

很多人构建了他们自制的CPU,要么在实际的面包板上,要么在软件中,用于模拟器或电路合成 。他们都是非常有趣的项目,即使对初学者来说也是如此,因为它确实有助于掌握代码如何转化为电信号,为我们使用的每个设备提供动力,以及如何真正在简单操作的基础上实现复杂的语言功能。

制作一个 CPU

有些场景促使我在数字电路模拟器中设计一个简单的 类 ARM(ARM-ish)的 CPU。我最初使用的是logisim-evolution (后来我成为其开发团队的成员),最近由于性能原因,我将电路迁移到了Digital(Logisim无法在超过50或60Hz的频率下模拟我的电路,而 Digital 则达到了20kHz)。

选择 ARM 是因为它支持ARM Thumb指令集的一个子集,它本身就是ARM CPU所支持的多个指令集之一。它使用32位字,但指令的宽度为16位。

但又类 ARM (-ish)是因为,它只支持其中的一个子集(很大,但远不完整),并且在某些方面被故意限制。不支持那些奇怪的指令,如PUSH / POP / LDM / STM系列(RISC ARM ISA中的一个巨大的来自CISC的污点),并且被汇编器实现为手动 load/store。同时也不支持中断(Interrupt)。

【此段重复,可忽略】从本质上讲,我设计的不仅仅是一个CPU,而是一个可以称为计算机的东西;它有一个ROM,一个RAM,以及作为 "前面板 "的各种设备。

从本质上讲,我设计的不仅仅是一个 CPU,而是一个可以称为计算机的东西:它有一个ROM,一个RAM,以及作为 "前面板(front panel)"的各种设备。

设备

一台真正有用的计算机,不仅需要一个 CPU 和一个内存芯片。它还会有外围设备和其他设备:键盘、屏幕、磁盘驱动器、扬声器、网卡等,几乎所有你能(或不能)想象的东西都已经被做成了计算机设备。

归根结底,你唯一需要的是能够从设备中传输数据。有两种相反的方式来做到这一点:要么设备是特别的,要么就不是。

基本上,一些架构(X86,说的就是你)除了内存之外,还有一个特殊的、独立的地址空间用于I/O,有其特殊的、不同的指令:在8086上,你会用MOV来读写主内存,用IN/OUT来读写设备。一些设备(最重要的设备:PS/2控制器、软盘、串口......)有一个固定的端口号,其他设备在启动时由BIOS分配一个端口号。在过去,通常的做法是要求设置环境变量或编写配置文件来通知软件哪些设备被插入(例如著名的BLASTER配置行)。这被称为PMIO(端口映射的输入/输出)。

另一个选择,也是几乎所有其他人使用的选择,包括现代X86计算机,是拥有一个统一的虚拟(virtual)地址空间。

我在这里使用虚拟这个词是为了将这个统一的地址空间与真正的物理内存地址空间区分开来(物理内存地址空间本身只在具有单一内存单元的机器上有真正的意义)。还有一个叫做虚拟内存(virtual memory)的概念,它指的是一个完全不相关的东西(虽然在方式上很相似):通过使用交换(swap)等策略,为程序提供一个比计算机RAM更大的地址空间,允许将RAM页面移动到磁盘存储中,以释放工作内存的空间。

想象一下,IP地址应该是映射整个互联网的,但在现实中,一个地址不一定要精确地映射到某个地方的一台机器。例如,127.0.0.1(IPv6中的::1)是本地回环地址,映射到你正在使用的机器。这不需要通过网络通信的软件知道,因为映射是由操作系统的网络栈完成的。

这里也是一样:(虚拟)地址空间的区域被映射到物理组件上。为了给你一个现实世界的例子,下图是NES 的地址空间。

img

0800(十六进制)的地址被映射到WRAM(工作RAM),从20002008映射到PPU(图形卡)的控制寄存器,从40004018映射到APU(声卡)。这被称为MMIO(内存映射的输入/输出)。

对我而言,这种方法的最大优点是它的简单性。从CPU的角度来说:它只是内存而已!我认为这是最重要的。取设备区域的地址、读、写,真的很简单。它也使软件层面更容易:你不必写内联汇编来调用特殊指令,只要你能从指针上读写,就可以了。

除此之外,内存映射还可以用来提供对不同内存芯片(如ROM和RAM)的访问。下面是我的电路图示意。

circuit

注意组件和映射器之间的边缘的箭头;它们表示组件是只读、读/写或只写的。

CPU

与真正的 CPU 相比,我们要做的这个 CPU 非常简陋。

它和拇指一样大,有 16 个 32 位的寄存器,编号为r0r15。最后三个有专用别名:r13sp(Stack Pointer,栈指针),r14lr(Link register,链接寄存器),r15pc(Program Counter,程序计数器)。

栈指针(Stack Pointer)

内存是很难的,暂且不表,后面会讲。

16个是很多的。所以在现实中,它们被分为低(r0-r7)和高(r8-r15)寄存器。高位寄存器只能用特定的指令进行操作,所以低位寄存器是平常要用到的。

指令被分为几类,每一类都包含有一个相同头部(common header)的指令。我不在这里一一列举,但最常用的是ALU(算术和逻辑)运算、load/store 指令(相对于pc、sp或一般寄存器)、栈操作指令和分支指令(有条件和无条件)。

指令组由独立的子电路处理,它们都写入共享总线中。

总线(Bus)

Bus 是一个令人惊讶的多义词。它在很多领域都有定义,甚至在电子和硬件领域,它也被用来做各种事情。所有这些定义之间的共同因素是 "连接其他事物的东西"。与前几部分一样,无论如何我都会简化解释,因为电子学是一个庞大的研究领域。

在电路设计术语中,总线是一组连接在一起的导线。具体来说,在这种情况下,它是一组电线,在某一瞬间只有一根电线发出信号。在电气领域,这是通过使用所谓的三态逻辑来实现的:一个信号要么是0,要么是1,要么是Z(发音为 "高阻抗")。Z是 “较弱”的信号,即,如果你将Z信号与01信号相连,将输出后者。这很有用:你可以有很多独立的元件,每个都有一个 "enable"输入,只有在它们被使能时才输出信号,否则就输出Z。它是一个简单的逻辑门,如果被启用,则输出其输入信号而不改变,否则输出Z

然后,所有这些组件都可以插在一起,你可以使能其中一个,并轻松获得其输出。

组件实例:带寄存器偏移的 load/store

这是一个处理形如 {direction}R{sign}{mode} {destination}, [{base}, {offset} 指令的组件,其中:

  • {direction}:不是 LD(load),就是 ST(store)
  • {sign}:要么不做(不扩展),要么就是 S(符号扩展值,以填充32位)
  • {mode}:要么是 nothing(全字,32位)和 H(半字,16位),要么是 B(字节,8位)
  • {destination}:目标寄存器,要读出/写入
  • {base}, {offset}:内存中的地址(将是两者的值之和)

比如,ldrh r1, [r2, r3] 大致相当于C代码中的r1 = *(short*)(r2 + r3)

这组指令可以按如下表所示编码:

1514131211109876543210
0101opcode


Ro

Rb

Rd

opcode 是一个 3位的值,用于同时对{direction}, {sign} {mode}进行编码。

{direction} 有两个可能的值(load, store)。{sign} 有两个(raw, sign-extended),{mode}有三个 (word, halfword, byte)。这就意味着有 2⋅2⋅3=12 种可能多组合,起码超过了opcode2^3=8 种可能值,所以有些组合是不可能的。

这是因为只有对不完整值(halfword, byte)的 load操作可以进行符号扩展,所以无效的组合(strsh, strsb, strs, ldrs)没有得到编码。

这里是电路图:

circuit2

这里使用到一些不同的逻辑单元:

  • 小三角形是隧道(Tunnel):命名的导线可以在电路中的任何地方接入。
  • 大梯形是复用器(Multiplexers):它们输出第n个输入,其中n也是一个输入。
  • 三条线的三角形是缓冲器(Buffers):如果边线是高电平,它们就输出其输入,否则就输出Z(高阻抗)。
  • 黄色方框将一个3位的低位寄存器号码转换成4位的寄存器号码(在其前面加一个0)。
  • 旁边有整数范围的大矩形是分割器(splitters):它们将一个多比特的值分割成多个较小的值,以访问单个比特或比特范围。

从上到下依次为:

  • 三个寄存器(Rd, Rb, Ro)在各自的位置(0-2, 3-5, 6-8)从指令中被读取,并被送到相应的全局通道(RW, RA, RB)。
  • opcode被解码,以检查它是一个store000,001,010)还是一个load(剩余值)。
  • opcode被解码以找到模式的值。0代表字,1代表半字,2代表字节。
  • opcode再次被解码以找到符号的值(仅对操作码011111来说是真的,所以我们可以检查最后两个比特是高位)。

Instr0708隧道是这个组件的激活引脚;如果当前指令属于这个指令组,它就是高电平。

几乎所有的其他元件都和这个元件一样,当你把它们都插在一起时,你就得到了一个可以执行指令的电路。

内存很难

操作数据并将其存储在某处以便你以后可以取回,这个看似简单的问题实际上......并不简单。让你的CPU访问一个大的线性内存单元阵列是不够的,你必须决定你要用它来做什么。看看这个Python程序。

print("Hello, World!")

字符串应该存放在哪里?它一定是在某个地方。那print呢?它不是一条指令,它只是一个全局变量,恰好被设置为一个内置函数或方法类型的对象,你可以用()操作符调用。它也必须被储存在某个地方。记住,你目前真正拥有的东西是一个大的数字数组。除此之外,你真正拥有的唯一一组操作是{"按地址加载(load)数值","按地址存储(store)数值"}。不是吗?

CPU的语言是汇编指令。这些指令有一个固定的、定义好的编码,在ARM Thumb指令集上,它们总是(也就是几乎总是)有相同的大小:16位。忽略指令头(告诉你这是哪条指令),这将占用几个比特,我们很快就会发现,如果我们把地址作为立即值(指令中为常量值),我们不能寻址到超过216字节的内存。

因此:寻址模式和内存对齐。

如果我们看一下平常的程序,可以观察到内存有两个主要的使用情况:存储局部变量(函数中的变量,或参数),和存储全局变量(全局配置,将在程序之间共享的内存)。

用例分配大小最大生命周期分配时间释放时间
本地(Local)通常为小分配在当前函数调用内当进入当前函数时当离开当前函数时
全局(Global)任意静态生命周期任意时刻任意时刻

有一个明显的区别:一方面,"本地内存",用于小的、确定的分配,而 "全局内存",用于任何事情,在任何时间,很少有限制。

这如何映射到我们的 "大块内存单元"?我们将从 "全局 "内存开始。我们对它的使用方式一无所知,所以我们不能做太多的假设。你可以在任何时候要求任何数量的字节,并在任何时候把它还给操作系统,而且你通常希望 "还给 "的空间可以被后续分配使用。这很难。现实世界中的对应物是一大堆东西,躺在地上,等着某个程序把它捡起来,使用它,或者把它扔进垃圾桶。由于这个原因,它被称为 “堆(heap)”。

接下来,我们可以看到 "本地内存 "以一种特定的方式演变:当我们进入一个函数时,它就会增长,当我们退出时,它就会缩小,而且函数调用遵循类似栈(stack)的模式(当你进入一个函数时,你可以做任何你想做的事,但你最终总是在某个点退出它)。事实上,它确实是一个栈(在算法数据结构的意义上),它有两个操作:push (增长)和pop(缩小)。这个 "本地内存 "被称为栈。

由于它是以这种方式增长和收缩的,所以我们其实不需要对分配的内存块做任何登记,比如它们在哪里,用什么策略来选择分配新块的位置,等等。我们唯一需要的数据是 “深度”(即我们在该堆栈中的深度,或者换句话说,栈的长度)。通常的做法是,我们将内存中的某个地方设置为栈的起点,并在某个地方(例如,在一个寄存器中)保留一个全局变量,该变量包含栈最顶层的项(topmost item)在内存中的位置:栈指针(在ARM上为sp,或其全名为r13)。

还有一点我没有说明:栈的增长方向。有些架构使它向上生长(push等于增加栈指针,pop等于减少),但大多数架构则相反,使它向下生长。向下增长意味着你可以很容易地使堆(heap)从地址0开始,并使栈(stack)从任何最大的地址开始,而且你可以保证它们不会发生碰撞,直到堆向上增长得太多或堆向下增长得太多。

在内存术语中,向上意味着增加,向下意味着减少。"栈向下增长 "意味着栈的增长会减少栈指针,反之亦然。网上的许多图都是以地址0为顶点来说明内存,暗示向下意味着增加,但这是一种误导。

现在我们知道了内存的工作原理,那么我们该如何访问它呢?我们已经看到,由于指令太小,我们无法全部寻址,那么我们如何应对这种情况呢?

答案是使用不同的寻址模式。例如,如果你想访问栈上的内存,你通常会访问栈顶部的东西(例如你的局部变量),所以你不必给出完整的内存地址(大),而只需要给出相对于栈指针的数据距离(小)。这就是sp-relative寻址模式,看起来像ldr r1, [sp, #8]

此外,我们可以假设你大多会存储4字节或更大的东西,所以我们会说栈是字对齐(word-aligned)的:所有东西都会被移动,以便地址是4的倍数。

有时,你想存储只对单一功能有用的数据。例如, switch / match指令通常使用跳(jump)表来实现:在程序中存储一个偏移量列表,然后加载正确的偏移量并跳转过去。由于这将被存储在函数本身的代码中,因此相对于代码中的当前位置进行内存操作就变得很有用,这就是如何得到pc-relative寻址的:ldr r2, [pc, #16]。与sp一样,内存是字对齐的,所以偏移量必须是4的倍数。

函数调用

在汇编中,调用函数的最简单方法是通过使用jump。你把一个标签放在某个地方,然后跳到那里。但有一个问题:你怎么回去呢?一个函数可以从多个地方被调用,所以你需要能够 “记住 ”这个函数是从哪里被调用的,而且你需要能够跳到一个地址,而不是跳到一个已知的标签。

简单的方法,就像之前的栈指针一样,是使用一个全局变量(即寄存器)来存储调用者的地址,并有一个特殊的跳转指令,将寄存器设置到当前位置(链接),这样我们就可以在以后回到它(分支)。在ARM上,这就是bl(branch-link)系列指令,该寄存器被称为链接寄存器(缩写为lr,昵称为r14)。

但是还有一个问题:它对嵌套调用不起作用! 如果你从另一个被调用的函数里面调用一个函数,链接寄存器的值会被覆盖。

这其实不是一个新问题:当你调用一个函数时,其他的寄存器也会被覆盖,你不能指望程序员去阅读他们所调用的每个函数的代码,看看哪些寄存器是安全的,哪些不是。这里涉及到调用惯例:在每个架构(X86、ARM......)上都有一套规则(ABI),告诉你一切是如何工作的,一个函数被允许做什么,特别是哪些寄存器应该被被调用者保留下来。一个保留的寄存器不是只读的:被调用者可以对它做任何事情,只要当控制权被交还给调用者时,旧的值就回来了。

解决这个问题的方法是通过寄存器保存。当进入一个函数时,在栈中为局部变量分配空间,但也为必须保留的寄存器分配空间,当退出时,原始值从栈中放回到寄存器中。

在ARM上的这些寄存器中,链接寄存器也被保存。ARM特殊寄存器可以作为通用寄存器使用的一个很酷的方面是,你不必使用分支指令来跳转到某个地方:你可以直接写到PC中去。

ARM汇编中的函数的通常模式是这样的:

my_function:
 push {r4, r5, lr} ; save r4, r5 and lr
 movs r4, #123 ; do stuff
 movs r5, #42
 pop {r4, r5, pc} ; restore the values to r4, r5 and *pc*!

设备

要使一个电路看起来像一台计算机,需要的东西并不多。对于初学者来说,你可能想从以下方面入手。

  • 一个键盘(读取原始字符输入)。
  • 终端显示器(显示字符,如终端模拟器)。
  • 一个视频显示器(显示原始像素数据)。
  • 一个随机数发生器。
  • 一个十进制的7段显示器。
  • 一个网卡(可以通过TCP接收和传输数据)。
  • 所有这些都被CPU和在其上运行的程序视为内存中的地址。例如,向地址0xFFFFFF00写一个字节将在终端显示器上显示一个字符。从地址0xFFFFFF18中读取一个字节,就可以知道键盘缓冲区是否为空。

运行代码

在这个东西上运行代码的最简单方法是简单地编写机器代码并将其加载到ROM中。

这里有一个简单的程序。

movs r0, #255 ; r0 = 255 (0x000000FF)
mvns r0, r0 ; r0 = ~r0 (0xFFFFFF00, address of terminal)
movs r1, #65 ; r1 = 65 (ASCII code of 'A')
str r1, [r0] ; *r0 = r1

它将被汇编为 20ff 43c0 2141 6001(8 字节),当被加载运行时,它会在 4 个周期后输出 A

当然,用汇编编写程序并不完全实用。我们在很久以前就为此发明了宏汇编程序和高级(与汇编相比)编程语言,所以在这里就这样做吧。我最初用的是C语言,但很快就换成了Rust,因为它的易用性和强大的宏支持(对于像这样的受限环境很有用)。

Rust(技术上来说,参考编译器rustc)使用LLVM作为编译的后端,所以任何LLVM支持的目标,Rust都在一定程度上支持。在这里,我使用内置目标thumbv6m-none-eabi(ARM v6-M Thumb,没有供应商或操作系统,嵌入式ABI),但有一个很大的限制:我的CPU不是一个完整的ARM CPU。

由于不是所有的指令都被支持(有些指令是由我自制的汇编器模拟的),我不能只是建立ARM二进制文件并加载它们。我需要使用我自己的汇编器,所以我直接调用编译器,告诉它发出原始汇编代码,然后将其发送到我的汇编器,最后生成可加载的二进制文件。

此外,由于我运行的代码没有操作系统,没有任何外部代码,所以我不能使用Rust的标准库。这是一个完全支持的用例(称为no_std),并不意味着我完全不能使用任何东西:它只是意味着我不使用std crate(通常的标准库),而是使用 core crate,它只包含最基本的必需品,特别是不依赖于运行在下面的操作系统。然而,核心库不包括任何依赖堆分配的东西(如StringVec),这些都是在alloc库中找到的,由于与我的构建系统有关的一些复杂原因,我也不使用这个核心库。

基本上,我写了我自己的标准库。我现在可以写一些程序,比如:

fn main() {
 println!("Hello, world!");

 screen::circle(101020, ColorSimple::Red);

 let x = fp32::from(0.75);
 let mut video = screen::tty::blank().offset(5050);
 println!("sin(", Blue.fg(), x, Black.fg(), ") = ", Green.fg(), x.sin(), => &mut video);
}

输出:


陷阱

使用rustc的原始汇编输出意味着我不能依靠我正在构建的 crate 以外的其他 crate 的代码(这需要使用链接器,我在这里没有使用)。我甚至不能使用编译器的内置函数:像memcpymemclr这样的函数经常被用来执行块拷贝,但它们并不存在于生成的汇编中,所以我不得不自己实现它们(我从Redox这里借用了一些代码)。

另一个问题是,由于我正在模拟一些指令(通过将它们翻译成其他支持的指令序列),分支偏移量可能比编译器预期的要大。问题是:Thumb上的条件性分支需要一个8位有符号的立即值,所以如果你试图跳到超过128条指令的前面或后面,你就不能对该指令进行编码。

在实践中,这意味着我经常要从函数中提取代码块以使其更小,而且整个代码库都使用了#[inline(never)],以迫使编译器将这些代码块放在单独的函数中。

实现一个可用的标准库并不是最简单的任务。确保整个库符合人机工效学原理,使用起来很顺手则更难。我不得不使用许多不稳定的(nightly-only)功能,如GATs、关联类型默认值和特化等等。

选择 Rust 完成整个项目的舒适度,让我有兴趣在未来的底层/嵌入式开发项目中选择使用 Rust。如果用C语言来做这个项目的百分之一,难度会大得多,而且代码的可读性也不会像这个项目一样。

成品展示

绘图器(Plotter)

展示视频:https://zdimension.fr/content/media/2022/08/javaw_8FU7Np02mK.mp4


这个绘图器使用fixed-point(16.16)数字库,三角函数是用泰勒级数实现的。

BASIC 解释器

这是一个简单的BASIC解释器REPL,类似于80年代的家用电脑(如C64)上的东西。你可以逐行输入程序,显示它们,并运行它们。支持的指令有PRINTINPUTCLSGOTOLET。提示支持LISTRUNLOADASMASMRUN

basic

程序也可以通过网络加载(类似于C64上的磁带加载),用一个程序,如:

cat $1 <(echo) | # read file, add newline
dos2unix |   # convert line ends to Unix (LF)
nc -N ::1 4567  # send to port

在这里,LOAD开始监听,收到的行会以#为前缀显示,并像用户输入的那样进行阅读。

basic2

程序也可以被编译成 Thumb 汇编以获得更高的性能。相关视频:https://zdimension.fr/content/media/2022/08/javaw_ABdTnpRbRl.mp4

这部分工作机制:

  • 每个BASIC指令和表达式都被转换(编译)为一连串的汇编指令,例如CLS只是将12(\f)存入终端输出的地址,而表达式只是设置了一个小的栈机,对公式进行计算并将结果存入r1LET指令计算其表达式并将r1存入变量的存储单元中。
  • 一旦程序被编译,它就像一个函数一样被执行,像这样。
let ptr = instructions.as_ptr();
let as_fn: extern "C" fn() -> () = unsafe { core::mem::transmute(ptr) };
as_fn();
  • bx lr指令被附加在指令列表的最后,所以当程序结束时,它将控制权交还给解释器。

Web 服务器

web

Scheme 语言 REPL


终端模拟器

这是一个简单的终端模拟器,支持ANSI(VT)转义代码的一个子集(足以显示漂亮的颜色和移动光标)。

它使用从这里借来的5x7字体,ANSI解码逻辑是手工编写的(外面有一些工具箱就是这样做的,但它们支持所有的ANSI代码,而我只需要这个程序的一个非常小的子集)。

像这样的一个脚本可以用来启动一个shell,并把它输送到电路中。

exec 3<>/dev/tcp/127.0.0.1/4567
cd ~
unbuffer -p sh -c 'stty echo -onlcr cols 80 rows 30 erase ^H;sh' <&3 1>&3 2>&3

shell

MIDI 音乐播放器

Digital提供了一个MIDI输出组件,它支持按下或释放某个乐器的键,所以我写了一个简单的程序,用midly来解析通过网络发送的MIDI文件,然后解码有用的信息来播放歌曲。

相关视频:https://zdimension.fr/content/media/2022/08/javaw_vJd5RnPa6r-1.mp4

小结

总而言之,这很有趣。ARM/Thumb是一个很好的架构,可以作为一个辅助项目来实施,因为它得到了编译器的良好支持,而且它的一个足够小的子集就足以运行有趣的代码。Logisim和Digital都是用于数字电路仿真的优秀工具。Rust是做事情和制造东西的好工具。

最后,本文项目源码:parm_extended:https://github.com/zdimension/parm_extended

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存