CPM 2.2 for the Raspberry Pi Pico. This runs within a Zilog Z80 compatible interpreter.
Find a file
2025-11-30 05:09:59 -05:00
sdcc Thu Nov 27 07:41:23 PM EST 2025 2025-11-27 19:41:23 -05:00
src Update src/tmp/keyboard.c 2025-11-29 23:45:44 -05:00
README.md Update README.md 2025-11-30 05:09:59 -05:00
screen.png Sun Nov 23 12:06:12 AM EST 2025 2025-11-23 00:06:24 -05:00

PicoCPM22

CPM 2.2 for the Raspberry Pi Pico. This runs within a Zilog Z80 compatible interpreter.

image of CP/M running on a Pi Pico on the Legacy Pixels dumb terminal

Display Modes

Currently, three display modes are supported.

  1. Dumb terminal. You will need a MAX3232 or similar module, like this one with TX and RX connected to GP0 and GP1 respectively. This will give you an RS232 port to then connect the Pico to a dumb terminal. I recommend Legacy Pixel's dumb terminal, which gives you VGA out and PS/2 keyboard input. The BAUD rate is 115200.

  2. Serial over USB. If you are connected to the device with a USB cable, you can view it by running a command such as sudo screen /dev/ttyACM0 115200.

  3. This supports the 240×320 Waveshare 2" LCD. It either must be this exact screen, or a screen that also uses a ST7789V driver chip with the same 240×320 resolution. The screen's pins are DC, CS, DIN, CLK, RST, and BL, and these must be connected to the Pico's GP16, GP17, GP18, GP19, GP20, and GP21 respectively. This screen is powered by the 3.3v rail and not the VBUS rail.

Input Modes

  1. Dumb terminal. Again, GP0 and GP1 are used for a dumb terminal. If you hookup a dumb terminal, you can use the keyboard input on that.

  2. Serial over USB. Programs like screen will allow you to send keystrokes to the device.

  3. PS/2 keyboard. The pins should be connected such that DAT and CLK are connected to GP2 and GP3 respectively. PS/2 keyboards are designed for 5v and the Pico is only 3.3v, but in my experience just using the 3.3v rail to power the PS/2 keyboard works fine. If you want to power the PS/2 keyboard with the 5v VBUS rail, do not plug the DAT and CLK pins directly into the Pico's GPIO as it will damage them. You will need a logic level shifter. I recommend just trying the 3.3v rail first and only looking into a logic level shifter if your keyboard is not working.

Installing

Just flash the UF2 in the release. You can do this by holding the button on the Pi Pico down when you plug it into USB. It will show up as a USB drive on your computer. You then drag-and-drop the UF2 file onto the drive, and that will flash it to the Pico. You do not need to compile it or configure anything. Every input and display mode is automatically enabled on the mentioned pins, so you do not have to configure anything. Just connect it up and it will work to your desired display or input modes. If you want to compile it yourself, just build the INO file in the Arduino IDE. Move all the files in the tmp folder to /tmp/ so it can find them when building. You will NOT see any performance gains by disabling the screen or PS/2 keyboard if you are not using them, because they run solely on the second core.

RAM Drive

When you boot the device, it will mount a RAM drive to drive A. The drive contains basic utilities needed to make the operating system useful. The current default utilities are:

  • SUBMIT and XSUB: Used for shell scripts.
  • STAT: Displays file sizes and remaining space on disk.
  • ED: A basic text editor.
  • PIP: Used for copying files.
  • ASM and LOAD: Used for building assembly files.
  • DDT: A debugging tool.
  • DUMP: Used for displaying binary files.
  • REBOOT: Reboots the machine.
  • CLS: This clears the screen.
  • UNLOAD: See the section on Pico-to-Pico transfers.
  • MBASIC: The Microsoft BASIC interpreter.

Files can be added or deleted from this drive, but changes will not persist after a reboot. Rebooting the machine will restore all the original files to their original state.

If a program makes heavy use of reading and writing external data files, you may also find a speed improvement by loading it into the RAM drive before running it. Included in the RAM drive is a file called FREE.SUB. This is a shell script that will delete all the files out of the RAM drive except for PIP and REBOOT. You can use REBOOT to restore the files and use PIP to copy new files into drive A from an SD card. Although, most CP/M programs will load entirely into RAM before running anyways, and thus will not offer a speed improvement outside of the initial launch.

Onboard Flash Chip (optional)

You can use the onboard flash chip as well, but it is not enabled by default for several reasons. First, these flash chips only have ~100,000 write cycles before dying, and you cannot (practically) replace your Pico onboard microcontroller. Second, the functionality was added using LittleFS, and this is kind of a buggy library that breaks with large files, so you only get a 0.25MB virtual disk, and it is incredibly slow.

I do not recommend this option and I instead most recommend you use an SD module for persistent storage (see the section called SD Card). If you insist on using the onboard flash, you have to write values to some of the ports. See the Virtual Ports section for more info. You can create an assembly program as shown below, assemble it, then run it, and fully power cycle the Pico. The REBOOT command is not enough, you must cut power to it completely.

A>ed flash.asm
* i
1: org 100h
2: mvi a, 249
3: out 255
4: mvi a, 251
5: out 255
6: ret
7: ^Z
* e
A>asm flash
A>load flash
A>flash

This will enable the onboard flash with a 0.25MB virtual disk on drive A. You will need to power cycle the Pico for it to take effect.

External Flash Chips (optional)

You can use an external flash chip if it is an W25Q80, W25Q32, or W25Q64. It will automatically detect the chip and its size if connected properly. Annoyingly, I cannot figure out how to get the SD card library to share a SPI bus with anything else, so you need to use a separate SPI bus. These are mapped to pins GP22, GP26, GP27, and GP28, being CS, SCK, MISO, and MOSI respectively.

If the flash chip is correctly connected and detected, it will show up as drive B. If the onboard flash is also enabled, then the external flash chip will be pushed from drive B to drive C.

If the flash chip is not working because it has bad data on it that CP/M does not like and running era *.* does not fix it, you can force erase the whole flash chip. This is a very slow process but it should fix those kinds of issues. See the section on Virtual Ports.

SD Card (optional)

You can add persistant storage with an SD card. The SD card must be connected such that GP10, GP11, GP12, and GP13 are SCK, MOSI, MISO, and CS respectively. The SD card should contain a CPM disk image named CPMDISK.IMG. The SD card must be formatted as FAT32.

A disk image can be created using CPMTools. This is a Linux application, but it can be installed in Windows under WSL.

sudo apt install cpmtools

Add this disk format entry to /etc/cpmtools/diskdefs for a 2MB disk image.

diskdef pico
  seclen 128
  tracks 256
  sectrk 64
  blocksize 2048
  maxdir 257
  skew 1
  boottrk 1
  os 2.2
end

You can then initialize a disk image as shown below.

dd if=/dev/zero of=CPMDISK.IMG bs=2097152 count=1
mkfs.cpm -f pico CPMDISK.IMG

Finally, you can manage the disk image using the commands shown below.

#list files in the disk image
cpmls -f pico CPMDISK.IMG
#copy a file into the disk image
cpmcp -f pico CPMDISK.IMG TEST.COM 0:TEST.COM
#copy a file out of the disk image
cpmcp -f pico CPMDISK.IMG 0:TEST.COM TEST.COM

Be sure to fully power down the Pico before swapping SD cards. They are not hot swappable. If the Pico is booted with an SD card pre-inserted containing a file titled CPMDISK.IMG, it will be automatically mounted as drive B unless a flash chip is also connected/enabled, then it will be mounted as drive C. If both the onboard and an external flash chip are both in use, then it will be pushed to drive D.

The disk image does not need to be 2MB, but it must be a size that is a multiple of 8192. That is because the BIOS expects disks that have a fixed sector-per-track value of 64 and each sector is fixed to size 128 by the CP/M operating system. The only thing you can vary is the number of tracks, and thus units of 8192 in size.

The first track of the virtual disk is reserved for metadata. You can place any metadata you wish into the first track (the first 8192 bytes) and it will not interfere with the operating system.

Transfering Files over USB / Serial

You can copy files between your computer and the Pico without putting them on the SD card. This is useful if you are not using the SD card module, as it can be used to get files onto your RAM drive and onto a flash chip.

To send a text file, on the Pico, type ed foo.txt and then type i and press enter to begin a new text file. Then, on your PC, you can send a file out the USB serial port. This will have the effect of typing it out as keystrokes into the text editor. You can then press Ctrl+Z followed by e to save that text file to the device. Note that you need to use CP/M 2.2 style line breaks which consist only of the 0x0D character. That means, on Linux platforms that use 0x0A for line breaks, replacing the 0x0A with 0x0D before transferring it.

Below is an example of how you can transmit a text file over USB.

xxd foo.txt | sed -e 's/ 0a/ 0d/g' -e 's/0a /0d /g' | xxd -r > /dev/ttyACM1

To send a binary file, you can first convert it into an Intel-style hex file. These are text files which, on the Pico side, can be converted into binary files using the LOAD program. The program srecord can be used to generate Intel-style hex files. The rest of the process is identical to transferring a text file.

srec_cat foo.bin -binary -offset 0x100 -o - -intel -address-length=2 \
	xxd | sed -e 's/0a /0d /g' -e 's/ 0a/ 0d/g' | xxd -r > /dev/ttyACM1

If the program you are transferring is too large to fit into the Pico's memory, you can transfer it in pieces as PIP has the ability to combine files.

pip combined.bin=foo.bin,bar.bin

To transfer a file from the Pico to the PC, on the Pico side, type type foo.txt but do not press enter, and on the PC side, type cat /dev/ttyACM1 > foo.txt and press enter. Then, press enter on the Pico side. This will dump the contents of foo.txt on the Pico to foo.txt on the PC over the USB serial cable.

For binary files, you can instead use dump file.bin. This will output the contents of a binary file in hexadecimal format. You can use awk to convert that into xxd format, which then xxd can convert that into a binary file.

Below is a single command that will read in a binary file over serial. The first grep removes the final A> prompt after the transfer finishes. The second grep removes any empty lines. The awk statement converts the output of dump file.bin into xxd format. The final xxd statement then converts that hex dump into a binary file.

cat /dev/ttyACM1 \
	| grep -v '>' \
	| grep -v '^[[:space:]]*$' \
	| awk '{printf "0000" $1 ": " $2 $3 " " $4 $5 " " $6 $7 " " $8 $9 " " $10 $11 " " $12 $13 " " $14 $15 " " $16 $17 "\n"}' \
	| xxd -r \
> file.bin

The transfer methods are decently fast as the baud rate is fixed to 115200.

Pico-to-Pico File Transfers

Alongside the serial enabled on GP0 and GP1, serial is also enabled on GP4 and GP5. The purpose of this is for Pico-to-Pico file transfers. By default, GP4 and GP5 are in transfer mode X. In this mode, it behaves identically to GP0 and GP1, and thus can also be used for a dumb terminal. However, writing a character to port 0xFE can change the transfer mode. In transfer mode R, the Pico will stop transmitting data from those ports, and in transfer mode T, the Pico will stop receiving data from those ports.

If two Picos have their GP4 and GP5 connected with a channel-swapped cable (this means the GP4 on one will be connected to the GP5 on the other, and vice-versa), then you can use this to transmit data from the Pico in transfer mode T to the one in transfer mode R. The receiving Pico stops transmitting data, so it will not interfere with the transmitting Pico.

If one Pico is placed into mode T and the other into mode R, then if they are connected together by channel-swapped cable, any characters displayed on the screen of the transmitting Pico will be typed out as keystrokes on the receiving Pico, but not vice-versa. This can then be used for transfering files from one Pico to the other.

To transfer a text file, prior to connecting the two Picos, on the sending Pico, write type foo.txt but do not press enter. On the receiving Pico, type ed foo.txt followed by i to enter typing mode. Then, connect the two Picos and press enter on the sending Pico, and foo.txt will be typed out into the text editor of the receiving Pico, which can then save the file.

Binary files can also be transfered by using the UNLOAD program. This is the opposite of LOAD which converts Intel-style hex files into binary files. UNLOAD will convert a binary file into an Intel-style hex file. You can use UNLOAD to convert a binary file into a text file, and then transfer it using the previous method for transfering text files, and then the receiving Pico can use LOAD to convert the text file back into a binary file.

Memory Mapped GPIO

Currently, there are 5 pins that are mapped to memory. This allows you to easily access them via poke and peek commands in Microsoft BASIC.

  • E300: LED_BUILTIN (GP64)
  • E301: OUT1 (GP6)
  • E302: OUT2 (GP7)
  • E303: IN1 (GP8)
  • E304: IN2 (GP9)

In Microsoft BASIC, you can turn on the Pico's LED like so.

poke &HE300, 1

Virtual Ports

In assembly, you can get additional hardware features through the ports.

Writing 0xFF to port 0xFF will cause the system to reboot.

Writing 0xFE to port 0xFF will clear the screen.

Writing 0xFD to port 0xFF will unlock the external flash chip formatter.

Writing 0xFC to port 0xFF will format the external flash chip if one is connected. This is a very slow process and progress is only displayed over USB serial.

Writing 0xF9 to port 0xFF will unlock the onboard flash enabler.

Writing 0xFB to port 0xFF will enable onboard flash. You can also write 0xFA to disable onboard flash.

Ports 0xF0-0xF3 are used for accessing the Pico's millisecond timer. This is because the Z80 compatible interpreter is not clock cycle accurate, so if you need timings, you cannot rely on clock cycle counts. Writing anything to port 0xF0 will cause a snapshot of the current millisecond timer to be recorded, and then reading from ports 0xF0-0xF3 will read out the 32-bit timer snapshot value with 0xF0 being the least significant byte.

Ports 0x00-0x0F are reserved for the operating system for character and disk I/O. You may crash the system or even corrupt your SD card if you try to mess with those ports directly. If you want character and disk I/O, use the CP/M operating system calls. Let the operating system do that for you, don't try to do it yourself.

Port 0xFD can adjust the interrupt callback timer. See the section on interrupts. The value is a multiple of 10 milliseconds. The default is 100, meaning, 1 second. Writing 10 to the port will drop the interrupt callback timer to 0.1 seconds.

Port 0xFE can be used to set the transfer mode. See the section on Pico-to-Pico transfers.

Interrupts

You can use interrupt mode #2. Interrupt mode #0 is not supported, and while #1 technically works, it causes a jump to memory address 0x0038 which is not valid in CP/M 2.2, and so it is not recommended to use interrupt mode #1. CP/M is also not re-entrant, which is a fancy was of saying that all interrupts, even interrupt mode #2, will break CP/M. You can still use interrupts, but you can only use them in the confines of your own program. You cannot make operating system calls or exit the program while interrupts are running. Any interrupts started in the program must be cleaned up when the program is complete. Since you cannot use operating system calls, you will have to write/read directly from port 0x02 for character I/O, and file I/O is not available.

If interrupts are enabled, they will fire every second on a timer. This can be useful if you want some code that runs in the background. Every second, the processor will stop what it is doing to go run your interrupt code, then it will return to its normal routine. Note that the process must be short enough to complete within one second, or you will cause the system to freeze.

In interrupt mode #2, the processor executes this instruction whenever an interrupt is fired.

call memory[(i << 8) | {random}]

The value {random} is an 8-bit integer that comes from the fact that, on a real Z80 processor, you would have a hardware peripheral control the interrupt address, but in the virtual machine we are treating it as if it is floating. This is similar to how it is setup on some physical Z80 machines like TI-83/4+. Since the lower byte of the address is random, you can only control the upper byte. This then leads us into what is typically called the vector table. The vector table is a 257 byte table that contains the same byte repeated over and over.

For example, consider that we create a vector table from address 0x8000 to 0x8100 and we fill it with the bytes 0x81. We then set the i register to the value of 0x80 and enable interrupt mode #2. When the interrupt fires, it will first fetch a pointer from 0x8000 | {random}. Given that we placed a 257 byte table consisting solely of the bytes 0x81 at that address, it is guaranteed that the value it will read is 0x8181. The processor will then call 0x8181, and thus we can put the code for our interrupt at memory address 0x8181.

It is recommend to begin any interrupt routine with the two instructions ex af, af' and exx and also to end the interrupt routine with those same two instructions followed by ret. This avoids clobbering registers that the CPU may have been in the middle of using before the interrupt was called. If your interrupt needs to use the stack, you would also want to save the stack pointer somewhere at the beginning of the interrupt and then change it to an internal address, and then set it back at the end of the interrupt.

C

It is possible to compile C code to run within CP/M using the Small Device C Compiler.

sudo apt install sdcc srecord

You will need to create a crt0.s file. This is an assembly file that establishes the context/environment in which the C code will be executed. Below is a simple example of the most basic crt0.s file. This will call the main() function and then jump back to address 0x0000, which is how many CP/M programs would complete. It also defines putchar() and getchar() according to the respective CP/M system calls.

	.globl _main
	.area _HEADER (ABS)
	.org 0x0100
	call _main
	rst 0
_putchar::
	ld c, #2
	ld e, l
	call 5
	ret
_getchar::
	ld c, #1
	call 5
	ld e, a
	ret

Creating a crt0.s file not only requires knowledge of the operating system you are compiling for, but also for the Small Device C Compiler itself. For example, it requires knowing that single-byte function calls will hold the input byte in the l register and should return the output byte in the e register.

Once we have defined a crt0.s file, we can then write our C code as normal.

#include <stdio.h>

int main(void)
{
  printf("Hello, World!\n");
  return 0;
}

In the sdcc folder in this repository, there is an included Makefile that shows how to compile this into a HELLO.COM file.

I also included "cpmfiles.h" which provides rudimentary file operation functions, those being fopen, fclose, fgetc, fputc, and fflush. Note that as it is currently coded, you cannot open a file both for reading and writing, only one or the other, and there is no fseek. You also cannot open more than one file at a time.

Below is an example of creating a file.

#include <stdio.h>

int main(void)
{
	printf("Creating: test.txt\r\n");
	FILE *f = fopen("test.txt", "w");
	if (!f)
	{
		printf("Failed to open file.\r\n");
		return 1;
	}
	char data[] = "This is a test.\r\n";
	for (int i = 0; i < sizeof(data); i++)
	{
		fputc(data[i], f);
	}
	fclose(f);
	printf("Done.\r\n");
	return 0;
}

Below is an example of reading a file.

#include <stdio.h>

int main(void)
{
	printf("Opening: test.txt\r\n");
	FILE *f = fopen("test.txt", "r");
	if (!f)
	{
		printf("Failed to open file.\r\n");
		return 1;
	}
	printf("--------------------\r\n");
	int c;
	while ((c = fgetc(f)) != EOF)
	{
		if (c == 0x1A || c == 0) break;
		putchar(c);
	}
	printf("--------------------\r\n");
	fclose(f);
	printf("Done.\r\n");
	return 0;
}

It is actually possible to compile C code on the Pico itself, however, even on a Pico 2 it is fairly slow. You need to use the Hi-Tech C compiler, which is a Z80 C compiler written specifically for CP/M. If you check the firmware directory, there is a disk image containing the Hi-Tech C compiler. Load this onto your SD card and then you can run the C compiler.

D>type test.c
#include <stdio.h>
void main(void)
{
  printf("Hello, World!\r\n");
  return;
}

D>c -v test.c
HI-TECH C COMPILER (CP/M-80) V3.09
Copyright (C) 1984-87 HI-TECH SOFTWARE
0:CPP -DCPM -DHI_TECH_C -Dz80 -I TEST.C $CTMP1.$$$
0:P1 $CTMP1.$$$ $CTMP2.$$$ $CTMP3.$$$
0:CGEN $CTMP2.$$$ $CTMP1.$$$
0:ZAS -N -oTEST.OBJ $CTMP1.$$$
ERA $CTMP1.$$$
ERA $CTMP2.$$$
ERA $CTMP3.$$$
0:LINK -Z -Ptext=0,data,bss -C100H -OTEST.COM CRTCPM.OBJ TEST.OBJ LIBC.LIB
ERA TEST.OBJ
ERA $$EXEC.$$$

D>test
Hello, World!

D>

Updates

  • 0.6:
    • Initial experimental release
  • 0.7:
    • Fixed freezes if you exceed the capacity of a drive
    • The SD card image now is no longer fixed to 2MB
    • Enabled Serial GP4 and GP5 for Pico-to-Pico file transfers
  • 0.8:
    • Drastically increased size of the RAM drive
    • Added more programs to the RAM drive
    • Ctrl+C now forces a warm boot
    • Improved Z80 microcode
  • 0.9:
    • Added support for W25Q_ flash chips
    • Added interrupt support
    • Improved Z80 microcode
  • 1.0:
    • Pico 2 support
    • Ability to mount onboard flash as a drive
    • Ability to configure transfer mode
    • Fixed BIOS bug

Planned Features

  • ANSI control codes for the 2" display. These can be used to do things like move the cursor around or change the color of the text.
  • Keypad support. Since there is support for a small display, I kind of want there to be support for a small keyboard. Sadly, none exist that are consumer-available. However, 4x4 keypads are very common and easy to find. If the last column is used to choose a layer, then all major keyboard keys can be accessed via the keypad.

If you are compiling this yourself, you will want to compile it with the Arduino IDE with some recommended options in the Tools dropdown.

  • Flash Size: FS: 1MB
    • This is needed if you want to mount the onboard flash as a drive.
  • CPU Speed: 276 MHz (Overclock)
    • I own three Picos and they all seem stable on this, and it makes the operating system feel way snappier to use.
  • Optimize: Optimize even more (-O3)
    • Doesn't make much of a difference but doesn't hurt either.