在android上实现一个简单的虚拟机Chip8

1,332 阅读7分钟

CHIP-8

CHIP-8是Joseph Weisbecker在上世纪70年代制作的一款用于游戏开发的解释编程语言,最开始运行在COSMAC VIP和Telmac 1800两款电脑上,

解释编程语言自然少不了虚拟机,由于上世纪技术的限制,CHIP-8虚拟机其实很简单,用现在的编程语言实现一个CHIP-8 VM也许就3,400行代码,但是像寄存器,栈,程序计数器等一应俱全是个学习虚拟机,模拟器的起点。查阅一些资料后我萌生了在 Android 上实现一个CHIP-8 VM的想法。先看下CHIP-8的重要组成部分

内存

CHIP-8一般拥有 4096 字节也就是4k内存,VM本身会占据 512 字节,显示信息占用256字节(0xF00-0xFFF),调用栈等占用96字节(0xEA0-0xEFF),不过现在由于硬件没有4k内存的限制,也不必严格的把这些信息限制在这512字节里完全可以申请其他的空间存放(下面显示等部分可以看到),这部分空间现在基本都用于存放字体数据,但是所有程序数据最好装载在512字节(0x200)后。

最开始我想图省事用kotlin来实现CHIP-8,虽然也可以但后来发现对字节,位等操作比较麻烦最后还是用C/C++实现。4k的内存可以使用一个unsigned char数组来表示

unsigned char memory[4096];

寄存器

CHIP-8有16个8位寄存器,V0 - VF。VF是状态寄存器,用于标识借位,像素碰撞等信息。

unsigned char V[16];

还需要有一个16位的索引寄存器用于内存访问,一个程序计数器标识程序运行位置

unsigned short I;
unsigned short pc;

用于存放函数的返回地址,还需要一个栈顶指示器标识栈顶位置

unsigned short stack[16];
unsigned short sp;

定时器

CHIP-8 有2个60HZ(16.6ms触发一次)定时器,从某个值倒数到0。

  • 游戏计时 unsigned char delayTimer;
  • 声音Timer unsigned char soundTimer; 值不为0触发蜂鸣器

Input

CHIP-8接收一个16键的输入 0-F,可以用一个布尔数组来标识哪个键被摁下

bool key[16];

图像和声音

原版的CHIP-8支持 64x32 像素的单色显示,也就只有2084个像素,图像是靠精灵图(8像素宽,1-16像素长)来绘制的。使用XOR操作来翻转像素数据,如果有像素从1翻转到了0,就会触发碰撞检测,寄存器F就被设置为1。有像素数组了在android上绘制起来比较容易,使用SurfaceView按照像素数组0,1分别绘制一个颜色即可。

unsigned char gfx[SCREEN_WIDTH * SCREEN_HEIGHT];

简单的看下一个字体数据就知道是如何显示图形的如字符 '0' 的字体数据

0xF0, 0x90, 0x90, 0x90, 0xF0

0xF0   xxxx----
0x90   x--x----
0x90   x--x----
0x90   x--x----
0xF0   xxxx----

操作码

操作码是整个虚拟机的核心部分,总的来说CHIP-8的工作流程就是把程序装载进来然后按照程序计数器位置不停的从内存中取操作码,然后根据操作码去操作寄存器,做计算,访问内存,绘制精灵图,获取键盘输入信息等。CHIP-8一共就只有35个操作码,每个码按2个字节大端序存储。

参考WIKI的 CHIP-8 Opcode

VM实现

C++层

首先抛开android部分实现一个独立的CHIP-8 VM,他的生命周期我设计了4个

rest -> init -> loadGame -> loop
class Chip8 {
public:
		void rest(); //清除所有信息
    void init(); //初始化
    void loop(); //解析操作码
    void timeTick();//触发timer
    bool loadGame(const char *filePath);//加载游戏文件
    void keyEvent(int, bool);//按键控制	
};

运行大致的流程:

  • rest所有寄存器,内存等里的数据
  • init往memory中填充字体数据
  • loadGame加载游戏文件到memory中
  • 循环调用loop开始从内存中读取操作码进行对应操作
  • 每过16.6ms检查定时器,如果有数据就减一

看一个操作显示的例子就能大概知道loop的运作方式

//opcode = memory[pc] << 8 | memory[pc + 1]; 获取操作码的方式
void Chip8::opD(int X, int Y, int NN, int NNN) {
		// 从寄存器中获取要绘制的精灵图的位置
    unsigned short x = V[X]; 
    unsigned short y = V[Y];
		//精灵图的高度,上文有讲到精灵图是8像素宽,1-16像素高的
    unsigned short height = opcode & 0x000F;
    unsigned short pixel;
    V[0xF] = 0;
    for (int yline = 0; yline < height; yline++) {
				// 用Index Register中的内存地址获取精灵图的某行数据
        pixel = memory[I + yline];
				// 按照一行8个像素对像素数组的对应位置进行翻转
				// 如果有像素从1翻转到0.设置VF标识碰撞发生
        for (int xline = 0; xline < 8; xline++) {
            if ((pixel & (0x80 >> xline)) != 0) {
                int pos = ((x + xline) % SCREEN_WIDTH) +
                          ((y + yline) % SCREEN_HEIGHT) * SCREEN_WIDTH;
                if (gfx[pos] == 1) {
                    V[0xF] = 1;
                }
                gfx[pos] ^= 1;
            }
        }
    }
		//用于触发绘制
    drawFlag = true;
    pc += 2;
}

这个流程都实现以后一个最简单的虚拟机就大致完成了,现在需要做的就是要如和使用它从加载到显示,控制游戏流程构成一个完整的生命周期。

JNI

Android上渲染选择使用SurfaceView就避免不了JNI,JNI负责让kotlin代码获取到CHIP-8对象的指针,并在新的线程中循环调用loop方法运行CHIP-8虚拟机

	//新建CHIP-8	
	public native static long jcreateChip8();
	//传递游戏文件路径用于打开新游戏
    public native static boolean jload(long ptr, String path);
	//游戏主循环
    public native static int jloop(long ptr, byte[] pixs);
	//释放CHIP-8指针
    public native static void jdestory(long ptr);

    /**
     * 按键事件
     *
     * @param index   0x1 - 0xF
     * @param pressed 1 down 0 up
     */
    public native static void jkeyEvent(long ptr, int index, int pressed);

    /**
     * 被native调用,用于播放蜂鸣音效
     */
    public static void beep() {
        BitBGM.get().beep();
    }

这里主要看2个地方

  • jcreateChip8 动态分配Chip8对象返回给java层,用于以后在java线程中使用该对象
static jlong createChip8(JNIEnv *env, jclass clazz) {
    DLOG(TAG_VM, "chip8 created");
    Chip8 *chip8 = new Chip8;
    chip8->beep = playSound;
    return jlong(chip8);
}
  • jloop 通过先创建的Chip8指针调用Chip8的loop和timeTick方法,并根据是否需要绘制填充java层传递过来的byte数组
static jint loop(JNIEnv *env, jclass clazz, jlong ptr, jbyteArray pixs) {
    Chip8 *chip8 = CAST(ptr);
    chip8->loop();
    chip8->timeTick();
    if (chip8->drawFlag) {
        chip8->drawFlag = false;
        unsigned char *gfx = chip8->gfx;
        int size = sizeof(chip8->gfx);
        jbyte buf[size];
        for (int i = 0; i < size; i++) {
            buf[i] = jbyte(gfx[i]);
        }
        env->SetByteArrayRegion(pixs, 0, size, buf);//填充像素数据
        return 1;
    }
    return 0;
}

Android

  • 虚拟机状态
enum class VMState {
    IDLE,   //闲置状态
    RUNNING,//运行中
    STOP    //暂停中
}
  • 虚拟机方法

虚拟机需要对接Activity生命周期,对游戏进行暂停恢复销毁

interface Chip8 {
		//加载游戏文件
    fun loadGame(path: String)
		//循环调用
    fun loop()
		//对应 Activity onStop
    fun onStop()
		//对应 Activity onResume
    fun onResume()
		//对应 Activity onDestory
    fun onDestory()
		//按键事件
    fun keyEvent(index: Int, pressed: Boolean)
}
  • 渲染

在android上渲染必然得使用到SurfaceView,所以新建一个自定义控件继承于SurfaceView和Chip8

class Chip8View(context: Context, attrs: AttributeSet? = null) :
    SurfaceView(context, attrs), Runnable, Chip8

这里主要看几个地方

  • 设置view大小,我把view强行设置成了10倍chip8所能显示的宽高也就是640*320像素
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val w = MeasureSpec.makeMeasureSpec(
            PIX_SCALE.toInt() * CHIP8_P_WIDTH,
            MeasureSpec.EXACTLY
        )
        val h = MeasureSpec.makeMeasureSpec(
            PIX_SCALE.toInt() * CHIP8_P_HEIGHT,
            MeasureSpec.EXACTLY
        )
        super.onMeasure(w, h)
    }
  • 通过JNI获取c++层的Chip8对象指针,并开启新线程负责运行CHIP-8
init {
        nativePtr = ChipJNI.jcreateChip8()
        thread = Thread(this)
        thread?.start()
 }
  • 游戏循环,在线程中不停的通过JNI调用c++层Chip8的loop方法执行操作码并且更具jloop方法返回值决定是否需要重绘,如果需要重绘获取像素数据放大10倍绘制在Canvas上
	override fun run() {
        while (isRunning) {
            loop()
        }
    }

    override fun loop() {
        if (state == VMState.RUNNING) {
            if (ChipJNI.jloop(nativePtr, pixs) == 1)
                drawFrame()
        }
    }

    private fun drawFrame() {
        val canvas = holder.lockCanvas()
        if (canvas == null)
            return
        canvas.apply {
            save()
            drawColor(Color.WHITE)
            val rf = RectF()//10
            for (i in 0..pixs.size - 1) {
                if (pixs[i] == 1.toByte())
                    paint.color = Color.WHITE
                else
                    paint.color = Color.BLACK
                val row = i % 64 * PIX_SCALE
                val col = i / 64 * PIX_SCALE
                rf.set(row, col, row + PIX_SCALE, col + PIX_SCALE)
                drawRect(rf, paint)
            }
        }
        holder.unlockCanvasAndPost(canvas)
    }
  • 自定义一个16个按键的键盘把被按压的按键编号通过Jni传递到C++层的Chip8
  • 使用SoundPool 播放蜂鸣音效
  • 在对应的Activity生命周期中调用Chip8View 对应的方法

完成了!

所有代码我已上传 GITHUB

github.com/Yedev/chip8…

注:

闪烁不是bug,因为以前硬件的原因就是这样设计的

参考:

  1. CHIP-8 WIKI
  2. Cowgod's Chip-8 Technical Reference
  3. How to write an emulator (CHIP-8 interpreter)