如何开发任天堂ns的模拟器?
(视频时长:40分11秒。)
注:本教程是根据油管大神Javidx9的视频所翻译成的文字版教程,只是用来学习编程技术,非本人原创,侵删。
1、为什么要开发模拟器
(从0分00秒到2分34秒)。
看电视太无聊,想玩玩以前的电视游戏,但是由于存放太久了,游戏机无法运行,只好自己编写一个模拟器,在电脑上玩吧。
2、引用了开放的代码
(从2分34秒到5分24秒)。
并不是直接引用他人的模拟器代码,而是通过引用其他高手在网站中共享的游戏参数代码(http:http://wiki.nesdev.com),加上自己的努力,我已经完成了90%的开发工作。
这是一个从头开始开发编写的模拟器,可能还会有一些小问题需要完善,依据公开可用的逆向工程信息和数据表创建了这个模拟器(simulater)。自己构建模拟器的好处之一就是,所有内部数据都实时显示,可以看到主游戏屏幕、实时内存更新、CPU状态、正在执行的指令。
这不是一个很复杂的项目。我们不会一开始就编写代码,而是先了解一下需要掌握的基础知识,包括十六进制记数法(hexadecimal notation)、二进制技巧(binary tricks)、位字段(bit field)和嵌套的整体架构(architeture)。
3、用专业工具可以把游戏卡带的内存提取出来
(从5分24秒到8分28秒)。
模拟控制台(emulate the console)本身不是违法的,它只是一个电路(it's simply a circuit),在上面无法进行侵犯游戏版权的活动。但是在美国,游戏的分发(distribution of the game)是非法的,在网上下载游戏是非法的(downloading the game online),所以我不得不从我自己的游戏库(stock of games)中提取(get)游戏内存(memory),以便在我的模拟器中运行它们。
因此我在网上买了一个这种电路板,然后把卡带插上去,然后从卡带中提取游戏内存(extract the memory content from the cartridge),后面的一个视频将会讲解这个电路板是如何工作的,因为它的工作方式与我们的模拟器非常相似(similar)。
4、开发之前,讲一讲内存进制之间的原理
(从8分28秒到15分19秒)。
NESdev Wiki网(http:http://wiki.nesdev.com)里面有无数热心爱好者对NES程序进行逆向工程后所贡献的数十万小时的资源。这里没有代码,它不会告诉你程序如何执行,但它会告诉你不同组件的时钟频率是多少,它会告诉你某些内存寄存器应该起什么作用。这些高手们不得不进行逆向工程,原因是任天堂没有发布任何的文档,所以他们不得不编写程序,并拿一些电子设备、示波器等来看游戏机是如何运作的,并免费分享给大家。非常感谢他们。
下面,先从基本的16进制表示法(hexadecimal notation)开始学习。
我希望对这个视频的学习后,你们对二进制数的概念有最基本的了解。
首先,我拿一个数65来举例,因为它的二进制数很容易记住(0100-0001)。
十进制(decimal system)的65和二进制的01000001是等价的,它们只是进制不同。
4-1、十进制(人类使用的计数法)。
十进制65=6*10^1+5*10^0。它是以10为底的表示法。从最右边的数字开始,它们的底数对应的幂分别是:0、1、2、3、4、5等等。
幂 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|
基数 | 10 | 10 | 10 | 10 | 10 | 10 | 10 |
数码 | 0 | 0 | 0 | 0 | 0 | 6 | 5 |
4-2、二进制(计算机使用的计数法)。
二进制表示法(binary system)也是一样的,0100-0001=0*2^7+1*2^6+0*2^5+0*2^4+0*2^3+0*2^2+0*2^1+1*2^0。以2为基数,对应的幂从右到左分别也是:0、1、2、3、4等等。
幂 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
基数 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
数码 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 |
65的十进制的科学表示法:6*10^1+5*10^0=60+5=65。
二进制如何转换成十进制呢:1*2^6+1*2^0=64+1=65。
当我提到第0位的时候,我是指二进制数的最右边那个1(0100-0001)。而通常的8位的二进制数,第7位指的是最左边那个0(0100-0001)。
模拟器(emulating)是模拟硬件(hardware)的运行,而硬件主要由晶体管(transistors)、触发器(flip-flops)、总线(buses)和电线(wires)组成,没有可以理解十进制格式数值的基本硬件组件(fundamental hardware component),所以我们用二进制来控制一系列的开关(switches)。
二进制对计算机很适用,对人类却很糟糕,因为数字增长得太快了。
8个位的二进制数,可以存储0~255范围的数值,但这不够用。为了存储更大的数值,我们可以用16个位(0000-0000-0000-0000)。
当我想向另一个人传达这个16个位的二进制数时(0110-0001-1001-1110),这个位数就比较多了,我宁愿用十进制法。因为,如果我告诉我朋友,我需要数字0110-0001-1001-1110,尽管听起来很有规律,但这不是一种可行的交流方式。
4-3、十六进制(人与计算机共用的计数法)。
但十进制的问题是,它在视觉上没法让我直观感受到这个二进制序列是多少,我的大脑没法在看到十进制的65的时候,自动转换成二进制数。我们需要的是一种介于两者之间的格式,能够看到那些位是使用中的,而且作为一个人类,可以直观的比较数值之间的大小。这就是十六进制法的来由。
通过十六进制表示法,我们会把这个二进制数(0110-0001-1001-1110)分成4组,每组4个位,每组数值的取值范围是0~15,所以可以转换如下:
二进制 | 0110 | 0001 | 1001 | 1110 |
---|---|---|---|---|
十进制 | 6 | 1 | 9 | 14 |
十六进制 | 6 | 1 | 9 | E |
由于新的表示法中,不建议出现占两个位的数字,所以我们把14转换成了其它符号。
我们已经有0、1、2、3、4、5、6、7、8、9共10个数字了,然后增加了A、B、C、D、E、F共6个数字,合计就有16个数字了。其中A表示10,B表示11,C表示12,D表示13,E表示14,F表示15。
所以上面的二进制数(0110-0001-1001-1110)=十六进制HEX的619E。
经过一定的练习,我们可以直接看出十六进制数对应的二进制结构,例如9=1001。而且,我们可以马上看出619E大于619B。
在不同的文献中,你会看到不同进制会用不同的符号标识以便于互相转换,在C++中,我们会用0b作为二进制数的前缀,用0x(或者用$、#、x都有)作为十六进制的前缀,如下所示:
十进制(decimal):65。
二进制(binary):0b01000001。
十六进制(hexadecimal):0x41、$41、#41、x41。
5、计算机如何使用二进制。
(从15分19秒到27分07秒)
你可能在想,为什么不直接使用十进制,原因很简单,因为硬件需要用最简单的方式运算,而它所用的方式跟我们在编程中习惯使用的方式不同。例如在C++中,我们用的char是一个8位的数据类型(01101011),一个字节这么大,你和我可能会把它当成一个数值,但计算机硬件可能会有不同的看法。
例如,它可能会把最右边的3个二进制数表示0~7范围的某个值(some value),也许会把中间的2个二进制数表示4种状态(some state),然后左边数过来的第2、3个二进制数作为控制某个硬件的开关(switch),然后最左边的1个二进制数可能根本就用不上(N/A)。
我们可能只需要7位的数据,但计算机不方便存储7位的值,所以我们使用数据类型char来存储,并忽略其中的一个数据位。
5-1、计算机是如何运算的。
(从16分30秒到20分13秒)
由于我编写的是硬件模拟器,所以我要知道如何提取整个8位二进制数中的有用的部分并解释它们的含义。
然后反过来(Conversely),我可能需要从数据存储的信息中构建出控制硬件运行所需的二进制位,为了便于这种形式数据的运算,我们需要使用位运算操作。
(英文原文:Conversely,I might need to construct the bits required for the hardware out of information on store of the data time.
To facilitate this form of data processing, we need to use bitwise operations.)
例如,我现在感兴趣的是最右边的3个二进制数(01101011),如何能够获取到它们。在这种情况下,我可以构建二进制掩码(binary mask)来提取这3个数字,其它不感兴趣的数字,我给0,而最右边3个数字,我给1,掩码就是00000111。然后,我用【与运算】来计算这2个二进制数(01101011 & 00000111)。
①【与运算】(AND operator,写做&),见0出0,全1出1。
(从17分00秒到20分13秒)
可以获取指定位的值,也可以查看指定位是0还是1。
数 | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 |
---|---|---|---|---|---|---|---|---|
掩码 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 |
结果 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
用相似的方法,我们也可以提取其它部位的数值,例如中间2位。
数 | 0 | 1 | 1 | 0 | 1 | 0 | 1 | 1 |
---|---|---|---|---|---|---|---|---|
掩码 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 |
结果 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |
这2个数值可以有0、1、2、3共四种结果(二进制是00、01、10、11四种)。但是这2个数值跟上面的3个数值不一样,我们需要把这2个数值往右移动3位。我们可以使用【位移运算符】来做到,【位移运算符】是>>(2个向右的尖角符号)。假设我的变量是X,想把它右移3个位,当然,我也可以把它左移3个位。
现在,假如我们想知道从左数起第3位的开关是0还是1,那我们可以有多种方法:
①我们可以简单的用相同的十六进制数来测试它,00100000的十六进制数是0x20,那就用变量X跟0x20相“与”(进行【与运算】),结果是0的话说明开关就是0,结果是1开关就是1。If(x&0x20)。
②我们也可以使用在编译时构造的常量对其进行测试。例如,我拿常量00000001,然后把它左移5个位,然后再跟变量X进行【与运算】。If(x&(1<<5))。
我想说的是,我们有很多方法可以达到目的,你要做的就是,在给定的情况下,选择最好的方法。
5-2、位运算操作(bitwise operation)。
(从20分13秒到24分08秒)
位运算操作有以下几种:
运算名称 | 运算符 | 口诀 | 作用 |
---|---|---|---|
与(AND) | & | 见0出0,全1出1 | 获取指定位的值,查看指定位是0还是1. |
或(OR) | | | 见1出1,全0出0 | 设置指定位的值。 |
取反(NOT) | ~ | 1变0,0变1 | 消除指定位的值。 |
异或(XOR) | ^ | 相同为0,不同为1 | 切换指定位的值。 |
左位移(shift left) | << | 乘以2 | |
右位移(shift right) | >> | 除以2 |
①【或运算】(OR operator,|),可以用来设置二进制数的指定位。
(从20分13秒到21分20秒)
方法:先取一个空白的8位二进制数x(x=0b00000000),然后我想让第2~4位设置成0~7之间的数值y(例如设置成y=5=0b101)。接下来,我要把0b101左移到对应的位置(y<<2),所以我就得到了y=00010100。为了让x的中间3位变成指定的值,我会对x自己使用【或运算】,即x|=00010100,也可以写成x|=(y<<2)。
②【取反运算】(invert operator,~),可以把一个指定位设置为0。
(从21分20秒到22分16秒)
如何取消设置一个位(bit,二进制位),如何把一个特定的位设置为0。假设我想把x(x=10111011)的第5位清除掉,我可以先使用【或运算符】和一些【位移运算】轻松构造一个掩码(00100000),然后用【取反运算(invert operator,~)】把掩码的二进制位全部反转(11011111),最后用【与运算符(&)】把x和“反转的掩码”进行“与运算”,得到结果(10011011),写成公式就是:x&=~(y<<5)。
我们也可以用同样的方式来清除一组二进制位。
③【异或运算】(Exclusive OR,XOR,^),可以把指定位在0和1之间切换。
(从22分16秒开始到24分08秒)
还有一种常用的操作,就是切换指定位的能力,所以例如二进制数的第5位是1(10111011),我想把它变为0(或者可能是0变成1),我事先不知道会发生什么情况。我们已经看过了【与运算】和【取反运算】,其实还有另一种方式,就是【异或运算】(Exclusive OR,XOR,^)。
下面我标出了想要切换的位(x=10111011),然后再次构造掩码(00100000)来识别出指定的位,然后我把x和掩码进行异或运算,得到运算结果(10011011)。如果我们用运算结果和掩码进行【异或运算】的话,发现竟然又变回x了。
以上的位运算操作,除了可以用于二进制数,也可以用于十进制数或者十六进制数,你只需要考虑如何实现你想要达到的目的即可。
5-3、如何在C++中控制二进制位。
(从24分08秒到27分07秒)
如果你很熟练使用位运算操作,那么在模拟器中我们会经常用上。
有时候为了清晰表示一个二进制数的结构(structure),我们会把二进制数拆解为几个方便使用的部分(properties)。
C++提供了专门为此服务的工具(facility),叫做位字段(bitfields)。
可以让C++的编译器(compiler)构造一个数据结构(data structure),它会把指定位分配(allocate)给特定的关键字(word)。
让我们再用前面的例子来演示,8位的二进制数最右边的3位表示的是某个值,范围是0~7。但是,我只想给这个值分配3个位的精度(But I only want to allocate 3 bit of accuracy to that value),在代码中我可以使用冒号(colon)来声明(declare)我想要多少位,例如3位。下一个变量是表示状态的2位的二进制位,然后还有2个表示开关的分别都是1位的二进制位,最后还有1个没有用上的二进制位。
这里很重要的是,这些二进制位组合起来(add up)的整体可以在C++用变量表示出来,所以我们总共有8个位(bits)。
假如我有一个变量A,它的结构跟上面的一样,我可以简单的在A后面加上属性描述来表示想要的二进制位,例如A.value=4,并把它当作C++表达式中的元素,我可以给它分配数值,也可以读取它的值。当然,我也可以表示其它的二进制位,例如我把A.sw2=0,开关位设置为0。
通过这种方式,我就不必一直使用位运算操作,在整个系列中,在某些情况下这是一个适用的解决方案,而在其它情况下通过手工来处理二进制机器操作是正确的解决方案。
但在这种情况下,我如何同时把所有二进制位设置为0,这可能是在重置阶段需要的功能,最简单的选择就是把这个结构和另一个相同类型的变量之间建立关联位。例如,我们知道char类型是8位的,于是我们创建一个类型是char的变量reg(char reg;),如果我们有一个变量A,然后跟它建立关联,我可以简单写到“A.reg=0x00;”,这样就会把所有二进制位设置成0。当然,我仍然可以给每个独立的二进制位设置数值,例如”A.sw2=1;”。你可以自行摸索,并创建出更复杂的嵌套结构和联合,来更好的表示复杂的数据结构。
就像我之前说的,根据实际情况,使用最合适的方法,我会尽量追求易读性(readability)。
6、NES的构造。
(从27分07秒到37分51秒)
与现代机器相比,NES看起来感觉很简单,但不要被迷惑了,它是一个丰富而复杂的机器编程语言。
在本系列视频中,我会把NES分解成基本的组成部分(fundamental components),然后逐个视频来讲解。单独来看这些视频不会有什么意义,所有我会先提供一个整体的框架,简要描述NES的构造方式、以及各个组件如何进行通信。在这个视频中,我不打算讨论代码,这里主要是对NES的整体构造进行俯瞰。
6-1、中央处理器。
跟大多少计算机系统一样,NES有一个CPU(中央处理器,central processing unit),它的设计名称(designation)是2AO3,这是CPU的芯片类型,它实际是6502的变体版本。在那个年代,作为微控制器的选择有很多,它只是一个6502芯片,唯一让它脱颖而出的原因是它内置了音频电路(audio circuitry),而6502没有。所以,对在我的模拟器中,我只是简单实现一个6502处理器的功能,因为这让我未来有机会在其它事情中使用它(不需要考虑音频)。
这个CPU没有内部存储器(internal memory),所以它必须要连接到一些总线(via bus),其它硬件连接到这个总线,即使我把它们绘制成离散对象(discrete object),但它们不一定是组合整体必要组件。
6-2、内存。
CPU通常需要一些内存,而NES有高达2KB的内存。从根本上讲,CPU是一个8位的设备,它的内部存储器只能存储8位的数据,但它可以寻址(address)16位的地址范围,所以理论上(potentially)它可以与64KB的内存进行交互(interface)。在这个总线上,该内存映射到位置$0000~$07FF(即2KB的十六进制数据)。因此,当CPU把地址放在总线上的这个区间内时,这个特定的对象(particular object)将会进行响应(respond)。因此即使CPU可以寻址64KB的内存,大部分的地址都将解析为空(nothing),我们稍后会讨论这个问题。
6-3、音频处理器。
总线上还有其它设备,例如APU(音频处理器,audio processing unit),负责生成声音。实际上(in reality),APU是CPU的一部分,但从概念上(conceptually),把它当作连接到总线的外围设备更方便。APU连接的内存不多,从$4000~$4017,但这个区间是编程中的很高的地址,而且有很多的细节需要注意,我们将会用单独一个视频来讲解。
还有很多设备通过总线连接到CPU,但我们等会再拆解,先看看游戏卡带(cartridge),它里面包含了专门内存,存放的是提供给CPU运行的程序(this is the device that contains the memories but typically have the programs the CPU runs)。
6-4、卡带内存。
卡带内存的映射地址从$4020~$FFFF,所以我们已经看到的就是,我们无法把这个巨大的地址空间当作一个数组(so what we are starting to see already,is we can't just represent the giant address space as an array)。依据CPU想要读取或写入的地址,我们需要做出不同的响应。
6-5、图像处理器。
还有一个连接到总线的附属设备,当然也是最重要最复杂的一个,就是PPU(图像处理器,picture processing unit),有时我会口误称其为像素处理器(pixel)。相比于它跟CPU的重要关系,却只有非常小的内存地址映射,范围是$2000~$2007。你看到我写的是7,可能以为我错用了十进制来写,实际上它就是十六进制。PPU是一个完全独立的设备,它的官方设计名称(official deisgnation)是2CO2。
管理这些设备的是时钟(feeding these devices are clocks),每当时钟滴答转动,CPU会做一些事情或PPU做一些事情。特别是PPU,每当时钟滴答一次,它就会输出一个像素(pixel)到屏幕。CPU和PPU实际上以不同的时钟频率(clock speed)在运行,后续再说。在很多情况中,你可以把PPU看作最早期的图形卡之一(graphic cards)。
CPU根本不负责把任何东西绘制在屏幕上,而是把数据送入PPU,然后PPU会合成为显示在屏幕上的图片,因此我们可以把PPU视为一个并行处理单元,它是一个复杂的小设备,它有自己的总线。虽然CPU有64KB的寻址范围,但PPU也有16KB的寻址范围。而PPU的总线的工作方式跟CPU的总线一样,它也连接了不同的设备。
6-6、图像内存。
早期的PPU总线有一些图像的内存映射(some memory maps to graphics),这些是构成游戏场景的精灵或图块的实际像素数据。图像内存有8KB(存储在$0000~$1FFF之间),但是这些图像内存不一定存储在NES中,实际上它存储在卡带中。
6-7、视频内存。
下一个组件是一个小的视频内存(VRam,video memory),它总共有2KB的内存,实际上,它的作用是存储要在背景中绘制的图块的编号(this memory is used to store a identify represents which tiles to draw in the background)。它存储在$2000~$27FF之间。当我们在本系列的后续视频开始谈论PPU的详细信息时,我们将看到实际上这个单元的寻址方式有一些额外的复杂性(we will see that is actually some additional complication how this unit is address)。
6-8、调色板。
连接到PPU总线的最后的设备负责控制调色板(palette),它存储在内存的$3F00~$3FFF之间。与现在我们熟悉的图像格式(bitmap、jpg、png)不同,NES有它自己专用的图像格式,在后续的系列视频我们会详细讲解。事实上,在NES中的所有图像都是每个像素2个比特大小(two bits per pixel),而这2个比特会在调色板中建立索引(and those two bits were indexed into a palette),以调用我们在屏幕上看到的所有可用颜色(to choose all of the available color we saw on the screen)。
6-9、对象属性内存。
PPU还有最后一个连接它的设备,这个设备无法通过任何总线来获取,它是一个称为对象属性内存(OAM,object attribute memory)的小内存,这个内存用来存储我们在屏幕上看到的精灵的位置(the locations of the sprites)。
实际上,CPU只有8个信箱存放信息和与PPU交换信息(CPU only has eight letter boxes through which to deposit information and exchange information with the PPU)。考虑到PPU所执行任务的复杂性,这信箱数量看起来不多。精灵(sprites,就是在屏幕上移动的东西),由CPU清楚地定位,PPU只是负责绘图,它不实现任何物理或运动(it doesn't implement any physics or move),所以CPU需要每帧填充这个对象属性内存的(so the CPU needs to populate this OAM memory every frame)。
6-10、直接内存连接。
PPU的时钟频率是CPU的3倍,所以CPU手动更新“对象属性内存”的速度会太慢,所以NES提供了一个专用的外围设备来做这个,它就是DMA(direct memory access)。它会暂停CPU并将内存直接传输到对象属性内存中。这样CPU就可以在它自己的内存空间区域中,为精灵应该出现的地方做好准备,这样它在这个有限的空间传输数据的时候就不会给自己造成困境。
6-11、映射器。
那些眼睛敏锐的人可能已经注意到了一些限制,首先8KB的图像听起来不多,但是在NES首次发布的时候,游戏都非常简单,这些内存也就足够了(adequate amount),毕竟图像是低分辨率而且很简陋。但随着时间推移,它发展得越来越高水平(sophistication),它的观众对于复杂图像的要求也越来越高。
任天堂的设计师们显然对此也非常具有前瞻性,他们知道拓展NES的功能对其作为长期设备的成功至关重要,所以他们给卡带增加了额外的电路,当然这使卡带变得更昂贵。这些电路叫做mapper(映射器),有很多不同种类的映射器。总体来看,在这个平台上我估计有700多款获得授权的任天堂游戏。幸运的是,对我的需求来说,大多数游戏大概只用到了3到4种不同配置的映射电路,所以它作为我们的模拟器的一部分,我们也必须模拟这个设备。这个设备要负责所谓的存储体切换(bank switching),这就是CPU可以在卡带上配置映射器的地方,目的是为相同的地址范围分配不同的数据。
在一个实例中,假设内存块A(bank A)包含了一级(level 1)的图像,当你达到二级时(level 2),卡带会被编程,以便所有图像都映射到内存块B中,所以你在那个级别会有不同的图像。而且好处就是,不需要通过总线来加载或处理任何东西。我们只是简单的把地址的映射(mapping of the addresses)修改到了卡带上的不同内存地址。
对于CPU和PPU在总线上寻找这些地址的时候,它们看不出有什么区别,它们不知道有什么被改变了,但映射器传输了不同的数据。通过这种方式,我们可以把更大的内存提供给图像和程序使用。程序的数据显然也是存储在卡带上的,所有不同层级的布局和配置的信息也是如此。很可能是因为某些游戏所需的所有内存远远超过了CPU可寻址范围所允许的64KB限额,因此CPU可以配置这个映射电路,以便于指定连接到总线的内存块是哪个。我们看到了,用PPU可以修改图像,而用CPU可以修改正在运行的程序。
7、系列视频大纲。
(从37分51秒到最后40分11秒)
我说过我会制作一系列的视频,第1个视频专门介绍CPU和它的总线,这是一个完整的独立模块,我们也可以用在其它地方。第2个视频,我们会研究PPU的一半,我们会研究它如何处理显示在屏幕上的背景图像。第3个视频,我们会研究PPU的另一半,研究如何处理精灵。第4个视频,我们会研究映射电路。而第5个视频,我们会研究如何生成声音。
目前我还没有打算制作第6个视频,如果有的话,这个系列将会太长了。但是,这对于我来说是一个重要的项目,这是自我的记录,我很高兴能完成它。如果不是我喜欢的事情,我根本不可能完成。但是,正如你所看到的,在我们面前的就是一个可以工作的NES模拟器,我希望本系列视频提供的信息,也能够让你独立制作一个,但你还需要一些在NESDEV网站上获取的信息。我不打算在本系列视频中逐个字节的介绍如何创建任天堂模拟器,而我想提供的是对整个系统的总体的概念,尤其是对NES内部工作原理的深入理解。因为它是一台迷人的机器,我很热爱它。我个人很高兴开启了这个系列视频,这是我长久以来一直想做的事情,我至少可以在油管的6频道Javidx9(onelonecoder)上传和展示自己剪辑的视频了。我不会马上提供源代码,因为如果我直接拿出来,人们会直接拿来就用,我尽量把源代码跟视频关联起来。我特别欣赏这个视频,尤其是在技术和代码方面的省略,但这是开启这段编程之旅所必要的步骤。所以如果你喜欢这个视频,请给我一个大大的赞,并考虑订阅,我很期待下次跟大家见面。
保重。