CHIP8 Emulator(2)——动手做!

时间:2022-06-15 13:37:26

这两天撸代码,看别人的源码,总算是有了点收获。除了GLUT部分还不太懂外,其他核心部分都已经搞定。

动手!从哪里下手?

观看了前篇的CHIP8介绍,对CHIP8这种语言有了初步的了解,现在就是用代码实现一个CHIP8的虚拟机。参考源码是用C++写的,不太熟。我这里用C语言进行了实现。
下面讲讲实现流程,这里主要要实现三个文件:

  • mychip8.c CHIP8实现的核心代码
  • mychip8.h CHIP8的相关定义
  • main.c 利用GLUT实现图形/输入等逻辑

首先我们来通过写mychip8.h来了解下怎么用代码来定义CHIP8:

CHIP8基本变量定义

//变量声明
unsigned char Gfx[64*32];   //chip8显存
unsigned char V[16]; //16个寄存器,V0~VF 
unsigned char Memory[4096]; //4K内存
unsigned short int I; //地址寄存器
unsigned short int Pc; //程序指针
unsigned short int Stack[16];//栈
unsigned short int Sp; //栈指针
unsigned char Keyboard[16];//16个键值
unsigned short int Opcode; //操作码
unsigned char Delaytimer;//延时定时器
unsigned char Soundtimer;//声音定时器
unsigned char DrawFlag; //绘图标识

上面的这些变量定义都是跟前篇CHIP8介绍有关的,我们在实现一个CHIP8虚拟机时,都要用到上面的变量。

CHIP8用到的几个函数:

void InitializeChip8(); //CHIP8初始化
void HandleOpcode();    //操作码处理,核心部分
int  LoadApp(const char *filename); //加载应用(游戏)

void InitializeChip8()

主要是对上述定义CHIP8变量的初始化,比如显存/栈/程序指针等初始化。

代码如下:

void InitializeChip8()
{
    unsigned int i;

    for(i=0;i<15;i++)
    {
        V[i] = 0;       //寄存器清零
        Stack[i] = 0;   //栈清零
        Keyboard[i] = 0;//按键清零 
    }

    for(i=0;i<4095;i++)
        Memory[i] = 0;  //内存清零

    for(i=0; i<2048; ++i)
        Gfx[i] = 0;     //显存清零

    I = 0;              //地址寄存器清零
    Sp = 0;             //栈指针清零
    Pc = 0x200;         //PC指针指向程序开始的地方
    Opcode = 0;         //操作码清零
    Delaytimer = 0;     //定时器清零
    Soundtimer = 0;

    DrawFlag = 1;   //绘画标识为真

    srand(time(NULL)); //产生随机数种子,后面一个操作码要用到
}

上面代码需要注意的就是程序指针初始是指向0x200处的,程序运行代码开始的地方。

void HandleOpcode()

这个函数中的代码就是CHIP8实现的关键代码,就是对CHIP8操作码取码解码的实现。

代码如下:

void HandleOpcode()
{
    int i;

    Opcode = Memory[Pc] << 8 | Memory[Pc+1]; //取操作码,高位在低地址

    switch(Opcode & 0xF000)
    {
    case 0x0000:
        switch(Opcode & 0x000F)
        {
        case 0x0000:    //Clears the screen.
            for(i=0; i<2048; i++)
                Gfx[i] = 0x0;
            DrawFlag = 1;
            Pc += 2;
            break;
        case 0x000E:    //Returns from a subroutine.
            Sp -= 1;
            Pc = Stack[Sp];
            Pc += 2; //Important!
            break;
        default:
            printf ("Unknown Opcode [0x0000]: 0x%X\n", Opcode); 
        }
        break;

    case 0x1000: // Jumps to address NNN.
        Pc = Opcode & 0x0FFF;
        break;

    case 0x2000: //Calls subroutine at NNN.
        Stack[Sp] = Pc;
        Sp++;
        Pc = Opcode & 0x0FFF;
        break;

    case 0x3000: //Skips the next instruction if VX equals NN.
        if( V[(Opcode&0x0F00) >> 8] == (Opcode&0x00FF))
            Pc += 4;
        else
            Pc += 2;
        break;

    case 0x4000: // Skips the next instruction if VX doesn't equal NN.
        if( V[(Opcode&0x0F00) >> 8] != (Opcode&0x00FF))
            Pc += 4;
        else
            Pc += 2;
        break;

    case 0x5000: //Skips the next instruction if VX equals VY.
        if( V[(Opcode&0x0F00)>>8] == V[(Opcode&0x00F0)>>4])
            Pc += 4;
        else
            Pc += 2;
        break;

    case 0x6000: //Sets VX to NN.
        V[(Opcode&0x0F00) >> 8] = Opcode & 0xFF;
        Pc += 2;
        break;

    case 0x7000:    // Adds NN to VX.
        V[(Opcode&0x0F00)>>8] += Opcode & 0xFF;
        Pc += 2;
        break;

    case 0x8000:    
        switch(Opcode & 0x000F)
        {
        case 0x0000:    // Sets VX to the value of VY.
            V[(Opcode&0x0F00)>>8] = V[(Opcode&0x00F0)>>4];
            Pc += 2;
            break;

        case 0x0001:    //Sets VX to VX or VY.
            V[(Opcode&0x0F00)>>8] |= V[(Opcode&0x00F0)>>4];
            Pc += 2;
            break;

        case 0x0002:    //Sets VX to VX and VY.
            V[(Opcode&0x0F00)>>8] &= V[(Opcode&0x00F0)>>4];
            Pc += 2;
            break;

        case 0x0003:    // Sets VX to VX xor VY.
            V[(Opcode&0x0F00)>>8] ^= V[(Opcode&0x00F0)>>4];
            Pc += 2;
            break;

        case 0x0004:    //Adds VY to VX. VF is set to 1 when there's a carry, and to 0 when there isn't.
            if (V[(Opcode&0x0F00)>>8] + V[(Opcode&0x00F0)>>4] > 255)
                V[15] = 1;
            else
                V[15] = 0;
            V[(Opcode&0x0F00)>>8] += V[(Opcode&0x00F0)>>4];
            Pc += 2;
            break;

        case 0x0005:    // VY is subtracted from VX. VF is set to 0 when there's a borrow, and 1 when there isn't.
            if(V[(Opcode & 0x00F0) >> 4] > V[(Opcode & 0x0F00) >> 8]) 
                V[15] = 0;
            else
                V[15] = 1;
            V[(Opcode&0x0F00)>>8] -= V[(Opcode&0x00F0)>>4];
            Pc += 2;
            break;

        case 0x0006:    // Shifts VX right by one. VF is set to the value of the least significant bit of VX before the shift
            V[15] = V[(Opcode&0x0F00)>>8] & 0x0001;
            V[(Opcode&0x0F00)>>8] >>= 1;    
            Pc += 2;
            break;

        case 0x0007:    // Sets VX to VY minus VX. VF is set to 0 when there's a borrow, and 1 when there isn't.
            if(V[(Opcode & 0x0F00) >> 8] > V[(Opcode & 0x00F0) >> 4])
                V[15] = 0;
            else
                V[15] = 1;
            V[(Opcode&0x0F00)>>8] = V[(Opcode&0x00F0)>>4] - V[(Opcode&0x0F00)>>8];
            Pc += 2;
            break;

        case 0x000E:    //Shifts VX left by one. VF is set to the value of the most significant bit of VX before the shift.
            V[15] = V[(Opcode&0x0F00)>>8] >> 7;
            V[(Opcode&0x0F00)>>8] <<= 1;    
            Pc += 2;
            break;
        default:
            printf ("Unknown Opcode [0x8000]: 0x%X\n", Opcode);
        }
        break;
    case 0x9000:    // Skips the next instruction if VX doesn't equal VY.
        if (V[(Opcode&0x0F00)>>8] != V[(Opcode&0x00F0)>>4] )
            Pc += 4;
        else
            Pc += 2;
        break;

    case 0xA000:    //Sets I to the address NNN.
        I = Opcode & 0x0FFF;
        Pc += 2;
        break;

    case 0xB000:    // Jumps to the address NNN plus V0.
        Pc = V[0] + (Opcode & 0x0FFF);
        break;

    case 0xC000:    //Sets VX to a random number, masked by NN.
        V[(Opcode&0x0F00)>>8] = (rand() % 0xFF) & (Opcode & 0xFF); //random
        Pc += 2;
        break;

    case 0xD000:    // Sprites stored in memory at location in index register (I), maximum 8bits wide. Wraps around the screen. If when drawn, clears a pixel, register VF is set to 1 otherwise it is zero. All drawing is XOR drawing (i.e. it toggles the screen pixels)
    {
        unsigned short x = V[(Opcode & 0x0F00) >> 8];
        unsigned short y = V[(Opcode & 0x00F0) >> 4];
        unsigned short height = Opcode & 0x000F;
        unsigned short pixel;
        unsigned int yline, xline;

        V[15] = 0;
        for (yline = 0; yline < height; yline++)
        {
            pixel = Memory[I + yline];
            for(xline = 0; xline < 8; xline++)
            {
                if((pixel & (0x80 >> xline)) != 0)
                {
                    if(Gfx[(x + xline + ((y + yline) * 64))] == 1)
                    {
                        V[0xF] = 1;                                    
                    }
                    Gfx[x + xline + ((y + yline) * 64)] ^= 1;
                }
            }
        }

        DrawFlag = 1;
        Pc += 2;
    }
    break;

    case 0xE000:
        switch(Opcode & 0x00FF)
        {
        case 0x009E:    // Skips the next instruction if the key stored in VX is pressed.
            if(Keyboard[V[(Opcode & 0x0F00) >> 8]]!= 0)
                Pc += 4;
            else
                Pc += 2;
            break;
        case 0x00A1:    //Skips the next instruction if the key stored in VX isn't pressed.
            if(Keyboard[V[(Opcode & 0x0F00) >> 8]] == 0)
                Pc += 4;
            else
                Pc += 2;
            break;
        default:
            printf ("Unknown Opcode [0xE000]: 0x%X\n", Opcode);
        }
        break;

    case 0xF000:    
        switch(Opcode & 0xFF)
        {
        case 0x07:  //Sets VX to the value of the delay timer.
            V[(Opcode&0x0F00)>>8] = Delaytimer;
            Pc += 2;
            break;
        case 0x0A:  //A key press is awaited, and then stored in VX.
        {
            unsigned char keyPress = 0;

            for(i = 0; i < 16; ++i)
            {
                if(Keyboard[i] != 0)
                {
                    V[(Opcode & 0x0F00) >> 8] = i;
                    keyPress = 1;
                }
            }
            if(!keyPress)                       
                return;

            Pc += 2;
        }
        break;

        case 0x15:  //Sets the delay timer to VX.
            Delaytimer = V[(Opcode&0x0F00)>>8];
            Pc += 2;
            break;

        case 0x18:  // Sets the sound timer to VX.
            Soundtimer = V[(Opcode&0x0F00)>>8];
            Pc += 2;
            break;

        case 0x1E:  //Adds VX to I
            if(I + V[(Opcode & 0x0F00) >> 8] > 0xFFF)   
                V[15] = 1;
            else
                V[15] = 0;
            I += V[(Opcode&0x0F00) >> 8];
            Pc += 2;
            break;

        case 0x29:  //Sets I to the location of the sprite for the character in VX. Characters 0-F (in hexadecimal) are represented by a 4x5 font
            I = V[(Opcode&0x0F00)>>8] * 0x5;
            Pc += 2;
            break;

        case 0x33:  // Stores the Binary-coded decimal representation of VX, with the most significant of three digits at the address in I, the middle digit at I plus 1, and the least significant digit at I plus 2. (In other words, take the decimal representation of VX, place the hundreds digit in memory at location in I, the tens digit at location I+1, and the ones digit at location I+2.)
            Memory[I] = V[(Opcode&0x0F00)>>8] / 100;
            Memory[I+1] = V[(Opcode&0x0F00)>>8] / 10 % 10;
            Memory[I+2] = (V[(Opcode&0x0F00)>>8] % 100) % 10;
            Pc += 2;
            break;

        case 0x55:  // Stores V0 to VX in memory starting at address I

            for(i=0; i<((Opcode&0x0F00)>>8); i++)
            {
                Memory[I+i] = V[i];

            }
            I += ((Opcode & 0x0F00) >> 8) + 1;
            Pc += 2;
            break;

        case 0x65:  // Fills V0 to VX with values from memory starting at address I
            for(i=0; i<((Opcode&0x0F00)>>8); i++)
            {
                V[i] = Memory[I+i];
            }
            I += ((Opcode & 0x0F00) >> 8) + 1;
            Pc += 2;
            break;
        default:
                    printf ("Unknown Opcode [0xF000]: 0x%X\n", Opcode);
        }
        break;
        default:
            printf ("Unknown Opcode: 0x%X\n", Opcode);
    }


    if(Delaytimer > 0)
        --Delaytimer;

    if(Soundtimer > 0)
    {
        if(Soundtimer == 1)
            printf("BEEP!\n");
        --Soundtimer;
    }   

}

对上面的代码有几个地方需要说明下:

  • 不要忘了PC指针的增加
  • 因为用到了switch-case语句,所以不要忘了break和default.
  • 比较难理解的操作码可能就是0xDXYN,事关绘图。操作码说明如下:

Sprites stored in memory at location in index register (I), maximum
8bits wide. Wraps around the screen. If when drawn, clears a pixel,
register VF is set to 1 otherwise it is zero. All drawing is XOR
drawing (i.e. it toggles the screen pixels)

CHIP8绘图是以Sprites来进行的,Sprites是8个像素点来表示。其存放地址已I地址开始进行索引。绘画主要通过异或运算进行,需要注意的是如果发现某个像素点由1变为0的画,则需要设置VF为1,这是用来进行碰撞检测的。

int LoadApp(const char *filename)

加载应用主要就是将我们要加载的ROM,这里是8位游戏源程序。注意其存放的地址应该放到内存的0x200开始处,也就是512字节后。

代码如下:

int LoadApp(const char *filename)
{
    long lSize;
    char *buffer=NULL;
    FILE *pFile =NULL;
    size_t result;
    int i;

    InitializeChip8();  

    pFile = fopen(filename, "rb");
    if (pFile == NULL)
    {
        fputs ("File error", stderr);
    }

    fseek(pFile, 0, SEEK_END);//移动指针到文件末尾
    lSize = ftell(pFile);//获得文件大小
    rewind(pFile);//移动指针到文件开头
    printf("Filesize: %d\n", (int)lSize);

    buffer = (char*)malloc(sizeof(char)*lSize);
    if (buffer == NULL)
    {
        fputs ("Memory error", stderr); 
        return 0;
    }

    // Copy the file into the buffer
    result = fread (buffer, 1, lSize, pFile);
    if (result != lSize) 
    {
        fputs("Reading error",stderr); 
        return 0;
    }

    // Copy buffer to Chip8 memory
    if((4096-512) > lSize)
    {
        for(i = 0; i < lSize; ++i)
            Memory[i + 512] = buffer[i];
    }
    else
        printf("Error: ROM too big for memory");

    fclose(pFile);
    free(buffer);

    return 1;
}

上面的代码就是两个关键文件mychip8.c和mychip8.h的实现。剩下的就是main.c实现。