~vz227

CHIP-8 Emulator/Interpreter [C++]

December 28, 2025

Tags: emulation

Table of Contents

Overview

An emulator is a program that implements a foreign system architecture in order for software native to the foreign architecture to be executable by the host machine. The terms 'emulator' and 'interpreter' in this case are often used interchangeably as the CHIP-8 never represented a hardware architecure itself, but rather a virtual machine with its own instruction set that was "interpreted" on 8-bit systems in the 1970s.

Similarly to an emulator, an interpreter executes instructions of one language in the host's native architecture. However, an emulator implies the implementation of the foreign architecture's memory layout, registers, display, et cetera. For this reason, it would be more accurate to call this CHIP-8 implementation an emulator.

The repository for the project can be found here.

CHIP-8 Architecture

The CHIP-8 has sixteen 8-bit registers and 4KB of addressable memory. It contains an index register containing the start memory address of data for opcodes to use, as well as the standard program counter and stack pointer. The CHIP-8 keeps a separate stack memory containing only return addresses. A maximum of sixteen stack frames can exist at a given moment. As the maximum address is 0xFFF, we need a word to represent any given address of memory.

The display of the CHIP-8 is 64 by 32 pixels where each pixel may be either on or off. Input is read from a keypad consisting of sixteen buttons. Each button may be either pressed or unpressed, so the keypad state can fit neatly into a single word.

uint8_t registers[16]{};           //16 registers, each one byte in size
uint8_t memory[MEMORY_SIZE]{};     //4KB (4096B) of memory

uint16_t index{};                  //Index register (max address 0xFFF)
uint16_t PC{};                     //Program counter
uint16_t stack[16]{};              //Stack to hold PC values (16 levels)
uint8_t SP{};                      //Stack pointer to index into stack[]

uint8_t delayTimer{};              //Delay timer (decrements at 60Hz)
uint8_t soundTimer{};              //Sound timer (decrements at 60Hz & buzzes while not zero)

uint16_t opcode;                   //Current opcode
uint16_t keypad{};                 //16-button keypad

/* 64x32px display (SDL expects 4B per pixel) */
uint32_t videoBuffer[VIDEO_WIDTH * VIDEO_HEIGHT]{}; 

Font & ROM Addresses

The CHIP-8 system expects specific glyph sprites of five bytes each to be loaded in memory starting from address 0x50. These represent the hexadecimal digits 0 through F when viewed as five rows of byte-long binary strings.

void Chip8::LoadFont()
{
	/* Initialize fontSet */
	Character fontSet[FONTSET_GLYPH_COUNT]
	{
		{0xF0, 0x90, 0x90, 0x90, 0xF0}, // 0
		{0x20, 0x60, 0x20, 0x20, 0x70}, // 1
		{0xF0, 0x10, 0xF0, 0x80, 0xF0}, // 2
		{0xF0, 0x10, 0xF0, 0x10, 0xF0}, // 3
		{0x90, 0x90, 0xF0, 0x10, 0x10}, // 4
		{0xF0, 0x80, 0xF0, 0x10, 0xF0}, // 5
		{0xF0, 0x80, 0xF0, 0x90, 0xF0}, // 6
		{0xF0, 0x10, 0x20, 0x40, 0x40}, // 7
		{0xF0, 0x90, 0xF0, 0x90, 0xF0}, // 8
		{0xF0, 0x90, 0xF0, 0x10, 0xF0}, // 9
		{0xF0, 0x90, 0xF0, 0x90, 0x90}, // A
		{0xE0, 0x90, 0xE0, 0x90, 0xE0}, // B
		{0xF0, 0x80, 0x80, 0x80, 0xF0}, // C
		{0xE0, 0x90, 0x90, 0x90, 0xE0}, // D
		{0xF0, 0x80, 0xF0, 0x80, 0xF0}, // E
		{0xF0, 0x80, 0xF0, 0x80, 0x80}  // F
	};

	/* Store fontSet starting at address 0x50 in memory as per the technical reference */
	for (std::size_t i{0}; i < FONTSET_GLYPH_COUNT; ++i)
	{
		for (std::size_t j{0}; j < FONTSET_BYTES_PER_GLYPH; ++j)
		{
			memory[FONTSET_START_ADDRESS + static_cast<Word>((i * FONTSET_BYTES_PER_GLYPH) + j)] = fontSet[i].sprite[j];
		}
	}
}

ROM files should be loaded into memory from address 0x200 throughout the end of memory.

void Chip8::LoadROM(char* const filePath)
{
	/* Initialize fstream object to read file from filePath */
	std::ifstream file(filePath, std::ios::binary);

	if (file.is_open())
	{
		/* Move pointer to the end of the file to determine size & save that size */
		file.seekg(0, std::ios::end);
		size_t size = static_cast<size_t>(file.tellg());

		/* Make sure file will fit into memory */
		if (static_cast<unsigned int>(size) > (MEMORY_SIZE - ROM_START_ADDRESS))
		{
			fprintf(stderr, "The size of the provided ROM exceeds the memory capacity. Exiting..\n");
			return;
		}

		/* Allocate buffer to hold file contents */
		char* buffer = new char[size];

		/* Return to the beginning of the file */
		file.seekg(0, std::ios::beg);

		/* Read in the file & close it */
		file.read(buffer, static_cast<std::streamsize>(size));
		file.close();

		/* Write buffer into memory starting at 0x200 */
		for (std::size_t i{0}; i < size; ++i)
		{
			memory[ROM_START_ADDRESS + static_cast<Word>(i)] = buffer[i];
		}

		delete[] buffer;
	}

	else
	{
		std::fprintf(stderr, "Could not load specified ROM.\n");
		return;
	}
}

Instruction Decoding

Each opcode is made up of 16 bits that are further split up into 4 blocks of 4 bits. Each block has a specific meaning depending on the opcode family. The initial 4 bits specify the opcode family.

For example, opcode 8xy0 has the function of setting the value in register x to the value in register y, whereas opcode 8xy1 performs the logical OR operation to the values of both registers and stores the result in register x. The registers x and y refer to can be extracted using bit masking.

void Chip8::OP_8xy0()
{
	/* Set Vx = Vy */
	registers[(opcode & 0x0F00) >> 8u] = registers[(opcode & 0x00F0) >> 4u];
}

void Chip8::OP_8xy1()
{
	/* Set Vx = Vx OR Vy */
	registers[(opcode & 0x0F00) >> 8u] |= registers[(opcode & 0x00F0) >> 4u];
}

As opcodes are divided into families based on the first four bits of the opcode (0 to F), instead of decoding instructions with a switch statement, a more efficient approach, as described in Austin Morlan's article, would be to use a two-level function pointer table. The first level indexes into the family of opcodes, and the second level indexes into the function itself based on its last four bits.

/* CHIP-8 function pointer definition */
typedef void (Chip8::* Chip8Func)();

/* Function table & subtables */
Chip8Func table[0xF + 1];		//Table to index into functions using the first digit of the opcode (0x0 to 0xF)
Chip8Func table0[0xE + 1];		//Table to index into functions whose opcode begins with 0 (Max index is E in OP_00EE()) 
Chip8Func table8[0xE + 1];		//Table to index into functions whose opcode begins with 8 (Max index is E in OP_8xyE())
Chip8Func tableE[0xE + 1];		//Table to index into functions whose opcode begins with E (Max index is E in OP_Ex9E())
Chip8Func tableF[0x65 + 1];		//Table to index into functions whose opcode begins with F (Need to take into account last 2 digits due to matching last digits in some opcodes, so max index is 65 in OP_Fx65())

The table now contains function pointers either to opcode handlers themselves (in the case that opcodes are the only ones of their family), or to intermediate functions that further decode the rest of the opcode.

/* First level of function pointer table */
table[0x0] = &Chip8::Table0;
table[0x1] = &Chip8::OP_1nnn;
table[0x2] = &Chip8::OP_2nnn;
table[0x3] = &Chip8::OP_3xkk;
table[0x4] = &Chip8::OP_4xkk;
table[0x5] = &Chip8::OP_5xy0;
table[0x6] = &Chip8::OP_6xkk;
table[0x7] = &Chip8::OP_7xkk;
table[0x8] = &Chip8::Table8;
table[0x9] = &Chip8::OP_9xy0;
table[0xA] = &Chip8::OP_Annn;
table[0xB] = &Chip8::OP_Bnnn;
table[0xC] = &Chip8::OP_Cxkk;
table[0xD] = &Chip8::OP_Dxyn;
table[0xE] = &Chip8::TableE;
table[0xF] = &Chip8::TableF;

/* Second level handlers */
void Chip8::Table0()
{
    (this->*table0[opcode & 0x000F])();
}

void Chip8::Table8()
{
    (this->*table8[opcode & 0x000F])();
}

void Chip8::TableE()
{
    (this->*tableE[opcode & 0x000F])();
}

void Chip8::TableF()
{
    (this->*tableF[opcode & 0x00FF])();
}

So now to execute the current opcode, all that is necessary is to call the function that the current opcode decodes to.

*table[(opcode & 0xF000) >> 12]();

Display

The Dxyn opcode is responsible for writing to the video buffer. x and y represent the starting coordinates of the sprite, and the bits comprising n represent the number of bytes of the sprite. This is equivalent to the height of the sprite. Each pixel is represented by one bit as each pixel's state may be either on or off. Hence, a sprite may be no wider than eight pixels.

The CHIP-8 sprites are drawn by XOR'ing the new sprite pixels with the screen pixels. If, for example, both the sprite pixel and display pixel are set, then the resulting image will have that pixel cleared. Register VF indicates whether a collision occurred after every draw instruction.

void Chip8::OP_Dxyn()
{
	/* Byte count to be read from memory (i.e. the height of the sprite) */
	std::size_t byte_count = opcode & 0x000F;

	/* Current coordinates making sure they wrap around if they're out of bounds */
	Byte xPos = registers[(opcode & 0x0F00) >> 8u] % VIDEO_WIDTH;
	Byte yPos = registers[(opcode & 0x00F0) >> 4u] % VIDEO_HEIGHT;

	/* Make sure register VF is cleared before drawing */
	registers[0xF] = 0;

	/* Iterate through byte count, i.e. each row of the sprite */
	for (std::size_t row{0}; row < byte_count; ++row)
	{
		/* Iterate through bits of current row/byte, i.e. each column of the row */
		for (std::size_t column{0}; column < 8; ++column)
		{
			/* Sprite pixel */
			Byte sprite_pixel = memory[index + static_cast<Word>(row)] & (0x80u >> column);

			/* Screen pixel (as a DWord for convenience with SDL) */
			DWord* screen_pixel = &videoBuffer[((yPos + row) % VIDEO_HEIGHT) * VIDEO_WIDTH + ((xPos + column) % VIDEO_WIDTH)];

			if (sprite_pixel)
			{
				/* Set register VF to 1 or 0 depending on whether a pixel is made to be erased */
                registers[0xF] = (*screen_pixel == 0xFFFFFFFF) ? 1 : registers[0xF];

				/* XOR the screen pixel with the sprite pixel */
				*screen_pixel ^= 0xFFFFFFFF;
			}
		}
	}
}

CPU Cycle

A CPU cycle on the CHIP-8 is similar to that of typical Von Neumann architectures where instructions are sequentially fetched, decoded, and executed. As each opcode is two bytes in size, the program counter must increment by two at a time.

void Chip8::CPUCycle()
{
    /* Fetch current opcode (concatinate bytes at PC and PC+1 & store in opcode) */
    opcode = (memory[PC] << 8) | memory[PC + 1];

    /* Increment PC */
    PC += 2;

    /* Decode & execute */
    (this->*table[(opcode & 0xF000) >> 12])();
}

Timers

The CHIP-8 contains two timers, DT (delay timer) and ST (sound timer). The delay timer provides programs with general purpose delay. The sound timer produces a single-frequency tone when ST is non-zero. Both decremenet at a rate of 60Hz regardless of CPU speed.

void Chip8::TimerUpdate()
{
    if (delayTimer > 0) delayTimer--;
    if (soundTimer > 0) soundTimer--;
}

Rendering & Input with SDL

SDL3 is a library that provides higher-level applications with abstractions to access hardware such as graphics processing, keyboard input, et cetera. Although the inner workings of the SDL3 API are not the point of this article, I believe a basic understanding of some fundamental functions is necessary to understand this CHIP-8 emulator.

The SDL library is divided by functionality into subsystems such as video, audio, joystick, sensor, camera, et cetera. An SDL subsystem represents the library functions that correspond to the specified piece of functionality. We are mainly concerned with the video subsystem.

SDL_CreateWindow() allows us to communicate with the operating system and create a window with desired properties. It returns a pointer to a Window object, which is an abstraction of the operating system's native window. SDL_CreateRenderer() creates a renderer object, which is an SDL abstraction for rendering a texture (another SDL abstraction) to a given window.SDL_CreateTexture() creates an SDL texture abstraction, which is what is fed to the renderer to draw to the screen. SDL_UpdateTexture() may then input the application's pixel data to the texture object.

Documentation of the SDL3 library can be found here.

Display::Display(const char* windowTitle, int windowWidth, int windowHeight, int textureWidth, int textureHeight)
{
	/* Initialize SDL video subsystem */
	if (!SDL_InitSubSystem(SDL_INIT_VIDEO))
	{
		fprintf(stderr, "Could not initialize SDL video subsystem.\n SDL_Error: %s\n", SDL_GetError());
		return;
	}

	/* Initialize window */
	if (window = SDL_CreateWindow(windowTitle, windowWidth, windowHeight, SDL_WINDOW_RESIZABLE); !window)
	{
		fprintf(stderr, "Could not create window.\n SDL_Error: %s\n", SDL_GetError());
	}

	/* Initialize renderer */
	if (renderer = SDL_CreateRenderer(window, NULL); !renderer)
	{
		fprintf(stderr, "Could not create renderer.\n SDL_Error: %s\n", SDL_GetError());
	}

	/* Initialize texture */
	if (texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_STREAMING, textureWidth, textureHeight); !texture)
	{
		fprintf(stderr, "Could not create texture.\n SDL_Error: %s\n", SDL_GetError());
	}

	/* Set resolution for rendering */
	if (!SDL_SetRenderLogicalPresentation(renderer, VIDEO_WIDTH, VIDEO_HEIGHT, SDL_LOGICAL_PRESENTATION_INTEGER_SCALE))
	{
		fprintf(stderr, "Could not set render logical presentation.\n SDL_Error: %s\n", SDL_GetError());
	}

	/* Set scale mode for texture scaling */
	if (!SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_NEAREST))
	{
		fprintf(stderr, "Could not set texture scale mode.\n SDL_Error: %s\n", SDL_GetError());
	}
}

Display::~Display()
{
	SDL_DestroyTexture(texture);
	SDL_DestroyRenderer(renderer);
	SDL_DestroyWindow(window);
	SDL_Quit();
}

void Display::Draw(const void* buffer, int pitch)
{
	SDL_UpdateTexture(texture, NULL, buffer, pitch);
	SDL_RenderClear(renderer);
	SDL_RenderTexture(renderer, texture, nullptr, nullptr);
	SDL_RenderPresent(renderer);
}

Input can be handled with SDL_PollEvent(). Certain keyboard keys are mapped to the sixteen keypad buttons of the CHIP-8.

void Display::ProcessInput(uint16_t &keypad, bool& quit)
{
	SDL_Event event;

	while (SDL_PollEvent(&event))
	{
		switch (event.type)
		{
		case SDL_EVENT_QUIT:
		{
			quit = true;
		} break;

		case SDL_EVENT_KEY_DOWN:
		{
			switch (event.key.key)
			{
			case SDLK_X:
			{
				keypad |= (1 << 0);
            } break;
            ...

		case SDL_EVENT_KEY_UP:
		{
			switch (event.key.key)
			{
			case SDLK_X:
			{
				keypad &= ~(1 << 0);
			} break;
            ...
}

Main Loop

The main loop simply reads user input and runs CPU cycles until the program receives a quit signal.

WIP: Currently, the timer updates with every CPU cycle as opposed to every 60Hz. This is planned to be fixed in the future. Despite this, the emulator seems to run fine.

int main(int argc, char* argv[])
{
	if (argc != 4)
	{
		std::cerr << "Usage: " <<  argv[0] << " <ROM> <Scale> <CPU Cycle Delay>" << std::endl;
		return 1;
	}

	/* Extract arguments */
	char* const romPath{ argv[1] };
	int videoScale{ std::stoi(argv[2]) };
	int cycleDelay{ std::stoi(argv[3]) };

	/* Initialize display */
	Display display("CHIP-8", VIDEO_WIDTH * videoScale, VIDEO_HEIGHT * videoScale, VIDEO_WIDTH, VIDEO_HEIGHT);

	/* Initialize Chip8 & load ROM into memory */
	Chip8 chip8;
	chip8.LoadROM(romPath);

	/* Initialize video pitch (bytes per row of video buffer) & quit flag */
 	int videoPitch{ static_cast<int>(sizeof(chip8.videoBuffer[0])) * VIDEO_WIDTH };
	bool quit{ false };

	while (!quit)
	{
		display.ProcessInput(chip8.keypad, quit);
		chip8.CPUCycle();
		chip8.TimerUpdate();
		display.Draw(chip8.videoBuffer, videoPitch);
		SDL_Delay(cycleDelay);
	}

	return 0;

Credits