C with cc65
cc65 is a complete, mature C compiler targeting 6502 systems. It's what lets you write readable, structured C code for the Commodore 64, Atari 8-bit, Apple II, NES, and other 6502 machines — without having to write every byte in assembly.
The toolchain has three main parts:
| Tool | Role |
|---|---|
| cc65 | Compiles C source (.c) to ca65 assembly (.s) |
| ca65 | Assembles .s files to object files |
| ld65 | Links object files into a binary for your target |
The IDE wraps all three — you just write C and click Build.
Why C on retro hardware?
8-bit C isn't as fast as hand-written assembly, but it's dramatically more productive for everything except the tightest inner loops. You get:
- Variables with real names instead of register aliases
- Functions, structs, and arrays
- Compile-time error checking
- Reusable code across platforms (change the target, recompile)
A common approach: write game logic in C, drop into ca65 assembly only for performance-critical rendering or interrupt handlers.
Quick start in the IDE
- Choose a 6502 platform (C64, Atari 800, Apple II, etc.) and select the C / cc65 toolchain.
- The template gives you a
main.cwith the correct#includes andmain()signature for that platform. - Edit and Build — errors from the C compiler appear first, followed by linker errors if any.
- Run in the emulator.
Read the template
The starter template handles the platform-specific main() signature, any required init calls, and the linker configuration. Read through it before writing your own code — it saves a lot of head-scratching.
Hello, C64
#include <stdio.h>
#include <c64.h>
int main(void) {
/* Change border and background colour */
VIC.bordercolor = COLOR_RED;
VIC.bgcolor0 = COLOR_BLACK;
/* Print to screen */
printf("Hello from cc65!\n");
return 0;
}
c64.h gives you the VIC struct with named fields for every VIC-II register. No magic numbers needed.
Useful headers per platform
cc65 ships with platform-specific headers. Use them — they make your code readable and correct:
Common patterns
Screen output
#include <conio.h>
clrscr(); // Clear screen
gotoxy(10, 5); // Move cursor to column 10, row 5
cputs("Hello!"); // Print string (faster than printf)
cputc('A'); // Print single character
conio.h functions (cputs, gotoxy, cputc) are much faster than printf on 8-bit targets. Use printf for debugging; use conio for game output.
Reading the keyboard
#include <conio.h>
char key = cgetc(); // Wait for keypress, return character
if (kbhit()) { // Returns non-zero if key is waiting
key = cgetc();
}
Working with hardware registers (C64)
#include <c64.h>
/* Set border colour */
VIC.bordercolor = COLOR_CYAN;
/* Read joystick port 2 (active low — 0 means pressed) */
unsigned char joy = CIA1.pra;
if (!(joy & 0x01)) { /* up */ }
if (!(joy & 0x02)) { /* down */ }
if (!(joy & 0x04)) { /* left */ }
if (!(joy & 0x08)) { /* right */ }
if (!(joy & 0x10)) { /* fire */ }
Memory operations
#include <string.h>
/* Fast memory fill */
memset(destination, value, count);
/* Fast memory copy */
memcpy(destination, source, count);
/* C64: directly clear screen RAM */
memset((void*)0x0400, 0x20, 1000); // Fill screen RAM with spaces
memset((void*)0xD800, 0x01, 1000); // Fill colour RAM with white
Inline assembly
Drop into ca65 assembly for time-critical code:
/* Single instruction */
asm("nop");
/* Multiple lines */
asm(
"lda #0\n"
"sta $D020\n" /* Black border */
);
For larger assembly routines, write a separate .s file and link it alongside your C source.
Calling assembly from C
Write a ca65 .s file:
; set_border.s
.export _set_border
.proc _set_border
; Argument arrives in A register (__fastcall__ convention)
sta $D020 ; Set border colour
rts
.endproc
Declare and call it in C:
/* Declare the external assembly function */
void __fastcall__ set_border(unsigned char colour);
/* Use it */
set_border(2); /* Red border */
The __fastcall__ calling convention passes the first argument in A (and X for 16-bit values) — the fastest way to get data from C into assembly.
Memory, types, and performance
Type sizes on cc65
| Type | Size | Range |
|---|---|---|
char / unsigned char |
1 byte | -128–127 / 0–255 |
int / unsigned int |
2 bytes | -32768–32767 / 0–65535 |
long / unsigned long |
4 bytes | ±2 billion |
| pointer | 2 bytes | 16-bit address |
Use unsigned char wherever possible. Signed arithmetic on an 8-bit CPU is slower. unsigned int is fine for 16-bit values, but avoid long in hot paths — 32-bit operations require several instructions.
Speed tips
Use register variables — cc65 tries to keep them in CPU registers or zero page:
Prefer unsigned char loop counters — an 8-bit counter takes one instruction to decrement and branch.
Use static for hot local variables — static locals live in BSS (global memory) rather than the C stack, so they use faster direct addressing:
void update(void) {
static unsigned char i; /* Faster than a stack variable */
for (i = 0; i < 8; i++) { /* ... */ }
}
printf is slow — use it for debugging only. In production code, use cputc / cputs / your own number formatter.
The linker config
cc65 uses a linker configuration file (.cfg) to define where code and data live. The IDE templates include the correct one for each platform — you rarely need to touch it, but knowing it exists helps when you hit a linker error.
Common linker errors and what they mean:
| Error | Cause |
|---|---|
undefined symbol: _foo |
You declared but didn't define foo, or forgot to link the .s file |
segment overflow |
Your code or data is too big for the target memory area |
range error |
A branch or address is out of reach for its instruction type |
Common mistakes
Wrong main() signature — cc65 platforms sometimes require void main(void) rather than int main(void), or vice versa. Use whatever the template shows.
Missing volatile on hardware registers — if you access hardware registers via a pointer rather than the platform struct, declare the pointer volatile:
The platform headers (like c64.h) already handle this for you.
Large local arrays — declaring char buffer[256] inside a function puts it on the cc65 C stack, which may only be 256 bytes total. Use static or global arrays instead.
Forgetting clc in inline asm before adc — same rules apply as in pure assembly. The C compiler may not clear the Carry flag before your inline block.
Official docs
The cc65 documentation is the definitive reference for the compiler, assembler, linker, and all platform libraries:
cc65.github.io — compiler reference, ca65 assembler guide, ld65 linker manual, and platform library docs
See also
- 6502 assembly — ca65 syntax and the
__fastcall__calling convention - C64 platform guide
- Atari 8-bit platform guide
- IDE getting started