Skip to content

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

  1. Choose a 6502 platform (C64, Atari 800, Apple II, etc.) and select the C / cc65 toolchain.
  2. The template gives you a main.c with the correct #includes and main() signature for that platform.
  3. Edit and Build — errors from the C compiler appear first, followed by linker errors if any.
  4. 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:

#include <c64.h>       // VIC, SID, CIA structs
#include <cbm.h>       // CBM-DOS, Kernal calls
#include <stdio.h>     // printf, scanf
#include <stdlib.h>    // malloc, rand, abs
#include <string.h>    // memcpy, strlen, strcmp
#include <conio.h>     // clrscr, gotoxy, cgetc, cputc
#include <atari.h>     // GTIA, POKEY, ANTIC, PIA structs
#include <stdio.h>
#include <stdlib.h>
#include <conio.h>
#include <apple2.h>    // Apple II specifics
#include <apple2enh.h> // Enhanced IIe extras
#include <stdio.h>
#include <conio.h>
/* NES support in cc65 is limited — use ca65 for most NES work.
   For cc65-based NES starters, the template includes nes.h. */
#include <nes.h>

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:

register unsigned char i;
for (i = 0; i < 100; i++) { /* ... */ }

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:

volatile unsigned char* border = (unsigned char*)0xD020;
*border = 2;   /* Won't be optimized away */

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