Monday, January 21, 2013

Phoenix: Memory (part 1)

Z80-based computers had memory sizes varying from 1 KB (e.g. ZX81) to 128 KB (e.g. ZX Spectrum 128). Because the Z80 only has a 16-bit address bus, addressing more than 64 KB of memory requires bank switching.

Even for relatively small memory sizes, multiple memory chips were needed. For example, 16 KB of memory could consist of 8 separate 16 kilobit memory chips, each hooked up to a data line of the CPU. For Phoenix, the situation is easier: we can just use a single 32 KB SRAM chip.

This SRAM chip has 15 address lines, 8 data lines and 3 control lines:
  • CE (Chip Enable): if this is inactive (high) the other control signals (OE and WE) are ignored.
  • OE (Output Enable): setting this active (low) does a memory read and outputs the byte on the bus.
  • WE (Write Enable): setting this active (low) does a memory write.
These signals have the exact same semantics as the Z80 MREQ (memory request), RD (read) and WR (write) signals, so we could hook up the memory directly to the CPU without any "glue" logic.

A computer with just RAM isn't very useful, so we either need to add some (programmable) ROM or find a way to fill the RAM before the CPU starts up. Again, inspired by Veronica, I chose the latter way (for now at least: the final Phoenix design does have an EEPROM).

To pre-fill the memory, I used an Atmel ATmega324 microcontroller. If you're familiar with Arduino, the ATmega324 is very similar to the ATmega328 used in the Arduino, except it has more I/O pins. The ATmega324 has a total of 32 I/O pins, more than enough to drive the 15 address lines, 8 data lines and 3 control lines.

However, the ATmega and Z80 can't both drive the memory at the same time, so we do need some glue logic after all:

On the left are the signals from the ATmega (PB0 and PB1) and from the Z80 (MREQ and WR). On the right are the signals to the memory (CE and WE).

While pre-filling the memory, the ATmega holds pin PB0 low. This has two effects: it enables the SRAM chip (through the AND gate, keep in mind that the signals are active-low), and keeps the Z80 in the reset state, so the address and data buses are kept in high-impedance state by the Z80, and can be controlled by the ATmega.

It then puts an address on PORTC and PORTD, data on PORTA, and briefly sets PB1 low to write this to the SRAM. After cycling through the entire memory contents this way, it then sets PORTA, PORTC and PORTD to high-impedance (so the Z80 can control them again), and sets PB0 and PB1 to high. Now the Z80 starts up and MREQ and WR are propagated through the AND gates to the SRAM.

We can reprogram the ATmega while it's connected to the memory and CPU: this uses pins PB5, PB6 and PB7, which are not connected to either the Z80 or the memory. During programming all I/O ports are in high-impedance state, the pull-down resistor ensures the Z80 is held in reset during this time as well.

To verify that everything works, I uploaded a trivial Z80 program (only 4 bytes). It doesn't do much useful: it just tries to write a dummy value to a nonexistent output port. At least we should be able to see the IORQ (I/O request) signal being asserted.

loop: OUT (0xFE), A
      JR loop

The full ATmega source code to pre-fill the memory:

#define F_CPU 8000000UL

#include <avr/io.h>

#include <avr/pgmspace.h>
#include <avr/power.h>
#include <avr/sleep.h>
#include <util/delay.h>

#define CE PB0

#define WE PB1

uint8_t data[] PROGMEM = {

              // loop:
  0xd3, 0xfe, //   OUT (0xFE), A
  0x18, 0xfc, //   JR loop
};

int main() {

  DDRB = _BV(CE) | _BV(WE);  // CE and WE are outputs.
  PORTB = _BV(WE);           // CE low (chip enabled), WE high (not writing).

  // CE is asserted, Z80 is in reset, we can take the bus.

  DDRA = 0xFF;
  DDRC = 0xFF;
  DDRD = 0xFF;

  _delay_ms(20);  // Wait for bus to stabilize.


  for (uint16_t address = 0; address < sizeof(data); ++address) {

    // Place address and data on the bus.
    PORTC = address >> 8;
    PORTD = address;
    PORTA = pgm_read_byte(data + address);
    // Toggle WE.
    PORTB &= ~_BV(WE);
    PORTB |= _BV(WE);
  }

  // All done - release the bus.

  PORTA = 0;
  PORTC = 0;
  PORTD = 0;
  DDRA = 0;
  DDRC = 0;
  DDRD = 0;

  // Take CE high, Z80 can take over.

  PORTB |= _BV(CE);

  // Go to sleep, our work here is done.

  power_all_disable();
  set_sleep_mode(SLEEP_MODE_PWR_DOWN);
  for (;;) {
    sleep_enable();
    sleep_bod_disable();
    sleep_cpu();
  }
}

And here is how this looks on the logic analyzer:


The first 3 signals are observed from the point of view of the SRAM. At the start the CE signal is kept low while the 4 write pulses load the program into the memory. Then execution starts as normal and we see the pattern where opcodes and operands are being fetched from memory. The OUT instruction is easily recognizable by IORQ.

And another breadboard picture:


In the front, the Z80 with on its left the 4 MHz oscillator and to the right the 74HC08 AND chip. In the second row, on the left is the big ATmega324, and on the right is the 32 KB SRAM chip. Note that at this point, I had wired up only A0 and A1, enough for the trivial 4-byte program shown above, but anything more serious requires more address lines obviously.

Phoenix is now a working computer able to execute programs, but there's no way to interact with it yet (and hooking up logic analyzer probes, fun as it may be, doesn't really count).

No comments:

Post a Comment