Understanding the RC2014 512k ROM 512k RAM Board

The One Where I Show How-It-Works And How To ‘Burn’ Data To The ‘ROM’

(If you want the source code without the waffle here’s the repository).

I recently upgraded my RC2014 to use the 512K ROM 512K RAM board available on Tindie). Most people use this board to run RomWbW but I have different plans for my RC2014. Those plans meant I needed to be able to ‘burn’ data to the ‘EEPROM‘, as well as needing do know how the board itself works.

Unless I’m missing something this doesn’t appear to be documented anywhere around the RC2014 ecosystem, other than a reference to the orginal version in Sergey Kiselevs Zeta 2 computer. I set out to investigate.

Photo of the RC2014 512k ROM 512k RAM board
Photo of the RC2014 512k ROM 512k RAM board

How The Board Works

Firstly, lets take a look at how the board works.

The Z80 processor, as we are all aware, can address a maximum of 64Kb of memory. In hex that’s an address space that runs from $0000 to $ffff. The board divides this memory space up into four 16Kb chunks (or banks, or pages). The first bank occupies memory space from $0000 to $3fff, the second from $4000 to $7fff, the third from $8000 to $bfff and the fourth from $c000 to $ffff.

I am calling these ‘logical banks’ and numbering them from logical bank 0 to logical bank 3 as shown in figure 1.

Fig 1: The Z80 address space divided into logical 16Kb banks.
Fig 1: The Z80 address space divided into logical 16Kb banks.

The board divides it’s 1Mb of memory into 64 banks, each of 16kb. The first 32 of these are ‘ROM’ and the next 32 are RAM. I’m referring to these as ‘physical banks’, numbered from 0 to 63 ($00 to $3f). Thus banks $00 to $1f (decimal 0 to 32) are ROM and banks $20 to$3f (decimal 32 to 63) are RAM.

Fig 2: The physical memory banks within the ROM RAM board
Fig 2: The physical memory banks within the ROM RAM board

The board allows any physical bank to be patched into any logical bank. Thus you could patch the first bank of physical ROM into the first bank of logical address space, and the first three banks of RAM into the remaining three logical banks with the mapping scheme shown in figure 3.

Fig 3: An example of mapping physical memory banks into logical memory banks
Fig 3: An example of mapping physical memory banks into logical memory banks

This mapping is done by writing physical bank numbers to output ports $78 to $7b1 where port $78 is the physical bank at logical bank zero, $79 is the physical bank at logical bank 1 and so on through the logical banks, as shown in the following table.

Port  Logical Bank  Z80 Address Space
$78     0             $0000-$3fff
$79     1             $4000-$7fff
$7a     2             $8000-$bfff
$7b     3             $c000-$ffff

There’s also one more port, $7c, which is used to enable (value 1) or disable (value 0) the banking mechanism. With the banking mechanism disabled logical banks 0 to 3 map directly to physical banks 0 to 3 which, you’ll remember, are ROM. This is important mechanism at boot up: the banking mechanism is disabled at reset, physical ROM banks $00 to $03 are paged in and the Z80 begins by executing boot code in physical ROM bank $00. (And this code will, if it has any sense, begin by mapping in some RAM so that it has a stack available if not an area for data storage).

There are a couple of side notes to this which are worth pointing out:

  1. Your code, whether based in ROM or RAM will need to be mapped into the correct logical bank in order to run properly. In other words if your code is assembled to run at address $8000 then it will need to be mapped into logical bank 2 to execute otherwise any address references (e.g in jumps and calls) within the block will be wrong.
  2. You can map the same physical bank into multiple logical banks at the same time. It’s up to you if this is useful or dangerous or neither or both.
  3. The I/O ports are strictly write only. If you want to know which physical bank you’re looking at you’ll either need to remember which banks are paged into where or have some way to derive which bank is which.

How Writing to the ‘EEPROM’ Works

I’ve been using the words ROM and EEPROM in quotes because, if we’re being pedantic, the board actually uses NOR flash. NOR flash uses different technology to traditional EEPROM, so it isn’t EEPROM, except that it’s permanent memory which you program/burn electrically, so it really is EEPROM, except that you use a different process to program it, so it isn’t EEPROM although some EEPROMs also use the same write protection methodology.

For the rest of the article I shall refer to it as ROM (or EEPROM, or NOR flash) otherwise we’ll never get anywhere.

If you look at the datasheet for the SST39SF040 chip used you can read how to program it, or you can read my explanation which, I hope, will be a little bit clearer.

Firstly, the memory is divided up into 4Kb ‘sectors’. Before you can write any data you have to erase the entire sector you want to write to. This is done by sending a command to the chip. To protect the chip from being accidentally erased this command needs to be preceded by a specific sequence of bytes being written to a specific sequence of memory addresses.2

Once the sector has been erased you can then program data into that sector. Each single byte data write again has to be preceded, again, by a specific sequence of bytes written to a specific sequence of memory addresses. An important point: every individual byte written to the device needs to be preceded by this write protection override sequence. There is no form of ‘block write’ process.

There are also some other commands available such as erasing the entire IC. These use a similar process. I’ve not implemented them and don’t describe them here as I don’t have a use case for them. Here is a full list of available commands and command sequences from the datasheet.

Fig 5: Software command sequences for programming the SST39SF040 NOR flash chip
Fig 5: Software command sequences for programming the SST39SF040 NOR flash chip

A couple of important points to note:

  1. Once you initiate a command sequence any read or write other than the rest of the command sequence will cause the command sequence to abort. This means that any code to program the ROM cannot reside in the ROM. You must run your code from RAM (or a different ROM chip, if you have one in the system)
  2. The commands take a short while to execute and the ROM is not available for reading or writing until the operation is complete. This also means that the processor can’t read code stored in ROM until the operation is complete. You will need to implement a busy-loop before beginning the next operation and also before returning to code which may be stored in ROM or could access the ROM.
  3. You need to erase a sector before you can write data to it. Thus you need to do a <erase sector> followed by a series of <write byte> operations. Bytes can be written in any order and there is no requirement to write every (or indeed any) byte in the sector after erasure.34
  4. All of which means that you can’t single step over the code using a ROM based monitor, because this will be reading from ROM between the command sequence writes and during the busy cycle (this was a lesson learnt the hard way, and made debugging the code a ‘challenge’. I would appreciate your sympathies here. Thank you).

Writing the ROM on the RC2014

So now we get to write some actual code. Or at least we will once we get our heads around the banking system. As you’ll note from above we need to write to specific memory addresses. This means we need to page those banks into the logical memory space. The code I’m presenting here pages any ROM banks into logical bank 1 and, therefore, logical addresses $4000 to $7fff.

But during the command sequences we need to write to physical address $2aaa which isn’t between $4000 to $7fff. Ugh. We will need to map any addresses in physical memory to addresses in logical memory. Our 16Kb memory banking system uses 14 bit addresses ($0000 to $3fff) within each bank. Address bits 15 and 14 will thus reference the logical bank into which our physical bank has been paged.

Thus, when using logical bank 1, we need to mask out physical address bits 15 and 14, and set them to zero and one respectively. Personally, this messes with my head, so if this isn’t clear it may just be my own confusion (and if it’s blindingly obvious please excuse my incapacity).

Anyway, to cut a long story short, we write to address $2aaa by paging physical bank zero into logical bank one and writding to address $6aaa. We then write to address $5555 by paging in physical bank 1 to logical bank 1 and writing to address $5555 (unchanged because it just happens to be in the correct place).

And when we write our actual data byte we will (hopefully) only have a 14 bit address and bank number, so we can map said physical bank into logical bank 1 and set address bits 15 and 14 as described.

Erasing Sectors

That covers the addresses we send when writing data. We also need to understand the address to send when we’re erasing sectors. The chip uses 19 bit addresses for it’s 512Kb of ROM and, you’ll remember, this 512Kb is divided into 4Kb sectors. Thus we have 12-bit addresses within each sector.

When erasing a sector we need to give the chip any any address within the sector we want to erase. Thus, the lower 12 address lines (A11 to A0) are ignored. We give our sector address in A18 to A12.

With reference to figure 6, of the logical 16-bit address we send from our Z80, A15 and A14 go to the bank mapping hardware. If they specify the bank which points to our ROM (bank 1) then they will correctly be mapped into physical address bits A18 to A14. Address lines A13 and A12 of our logical address are passed directly to A13 and A12 of the chip. Logical address lines A11 and lower are not use.

Phrasing that another way, of the logical address we send only the top four bits are relevant. A15 and A14 are the logical bank address (which must be zero and one respectively because we’re doing all programming in bank 1) with A13 and A12 specifying which of the 4Kb sectors within that bank we will be erasing.

Fig 6: How sector addresses are created when erasing sectors
Fig 6: How sector addresses are created when erasing sectors

And the Busy Loop?

Before we get down to some coding lets take a look at how we can determine if the ROM chip is busy after sending a command. The datasheet actually provides three ways of doing this. I’m going to go with the one which feels the easiest to implement.

After writing data (or a command) we read back from the same address (any address if erasing). Bit 7 of the returned data will be inverted until the operation is complete (erasing writes 1s to every bit, so we get zeros back until an erase is complete).

Thus, when writing data we repeatedly read back from the same address, xor the byte being sent with that returned and loop until bit 7 is zero.

When erasing we simply keep re-reading and loop until we get a 1 returned in bit 7.

The Code to Clear a Sector

And, finally, we can start writing some code. Let’s start with some code to clear a sector.

We’ll start by declaring the constants we need. Note here that I’m hard coding the logical bank (1) that I’m paging banks into in order to keep the code simpler and faster. The value can be changed but this hasn’t been tested.

base_bank_port equ $78	;First of the four sequential port addresses
logical_bank equ 1		;The logical bank we'll be using to address ROM
			; !!!I haven't tested the code against other banks!!!
bank_port equ base_bank_port + logical_bank	;The port address we'll be using

logical_bank_mask equ $b000		;Mask for A15 and A14 - logical bank number
logical_address_mask equ $3fff	;Mask for A13 to A0 - address within a bank
logical_bank_address equ logical_bank << 14	;The logical bank as a logical address

;Software protection override constants/command sequences.
;Each step needs a physical bank number, logical address within the bank and data byte
norflash_bank1 equ $5555 >> 14	;The physical bank containing our address
norflash_addr1 equ $5555 and logical_address_mask or logical_bank_address
norflash_data1 equ $aa

norflash_bank2 equ $2aaa >> 14
norflash_addr2 equ $2aaa and logical_address_mask or logical_bank_address
norflash_data2 equ $55

norflash_bank3 equ norflash_bank1	;Address $5555 again
norflash_addr3 equ norflash_addr1
norflash_data3_write equ $a0	;For writing a byte
norflash_data3_erase equ $80	;For erasing a sector

norflash_bank4 equ norflash_bank1	;Address $5555 again
norflash_addr4 equ norflash_addr1
norflash_data4 equ $aa

norflash_bank5 equ norflash_bank2	;Address $2aaa again
norflash_addr5 equ norflash_addr2
norflash_data5 equ $55

norflash_data6_erase equ $30	;Erase command to be sent along with the sector address

Now we need to define our subroutine, and we’ll be nice to our caller by preserving some registers (I tend to prefer saved registers in my subroutines unless I have a good reason not to. It leads to less surprises for the innocent traveller). I’m also preserving DE for later use.

; Sector Erase for NOR flash
; Sectors are 4kb in size
; Entry: D = Logical ROM bank to write to (0..$1f). E = index of sector within the bank (0..3)
; I.e. sector 0 = addresses 0..0fff, 1=1000..1fff, 2=2000..2fff, 3=3000..3fff
	push af
	push bc
	push hl
	push de

Now we can send our command sequence. This consists of laboriously paging in each bank in turn, setting up registers and writing data. I’d love to optimise this somehow but I’m not sure there’s a more effective way to do it.

	;Memory bank we'll be switching blocks into
	ld c,bank_port	
	;Overcoming write protection means writing some specific bytes to specific memory addresses in a specified sequence
	;Write data byte 1
	ld e,norflash_bank1		;Switch the phsical bank into the logical bank
	out (c),e
	ld hl,norflash_addr1	;Address to write to
	ld (hl),norflash_data1	;Write

	;Write data byte 2
	ld e,norflash_bank2
	out (c),e
	ld hl,norflash_addr2
	ld (hl),norflash_data2
	;Write data byte 3
	ld e,norflash_bank3
	out (c),e
	ld hl,norflash_addr3
	ld (hl),norflash_data3_erase
	;Write data byte 4
	ld e,norflash_bank4
	out (c),e
	ld hl,norflash_addr4
	ld (hl),norflash_data4
	;Write data byte 5
	ld e,norflash_bank5
	out (c),e
	ld hl,norflash_addr5
	ld (hl),norflash_data5

Next we can recover our bank and sector addresses back to DE and switch in the required bank.

	pop de		;Retrieve
	;Set sector to erase
	ld a,d		;Physical bank index
	and $1f		;Validate it
	out (c),a	;Switch bank in

And now we need to convert our two bit sector address in bits one and zero of A into our address in HL (bits 5 and 4 of H) whilst setting the logical bank address into bits 7 and 6 of H.

	;Convert 2-bit sector address in E to bits 13 and 12 of HL
	ld a,e		;Move sector address into A
	and $03		;And make sure it's valid
	rlca		;Sector index is in A register, valid range 0..3, (bits 1 and 0)
	rlca		;Move bits 1,0 to bits 5,4 (two bit sector number to address 0x(xx)..3x(xx))
	or logical_bank << 6	;Map to address in logical bank
	ld h,a		;Move to a 16-but address in HL
				;Lowest 12 bits of address are ignored, so no need to set L

Write the erase command to the sector address.

	ld (hl),norflash_data6_erase	;Send the sector address and protection override data byte

Then the busy loop where we just read back from the same address and repeat until bit 7 is set. We use RLCA to move bit 7 into the carry flag to make it easier to test.

	ld a,(hl)	;Read back data
	rlca		;Bit 7 will return a zero until done
	jr nc,norflash_erase_busy_loop

Finally we can restore registers and return.

	pop hl
	pop bc
	pop af

Writing a Byte

Now let’s write some code to write a single byte to the NOR flash, starting with a subroutine header and saving some registers. Note that HL is pushed twice as we need it again later.

; Write a single byte to NOR flash
; Entry: HL = Address to write to within the 16kb bank (0 - $3FFF) - bits 15 and 14 will be ignored.
;        A = Byte to write. D = Logical ROM bank to write to (0..$1f)
; Exit: Corrupt: AF. All other registers preserved
	push bc		;Preserve
	push de
	push hl		;Push HL to preserve return value
	push hl		;Push HL again so we can retrieve it

The command sequence/write protection override code is similar to that for erasing a sector but shorter.

	;Memory bank we'll be switching blocks into
	ld c,bank_port	
	;Overcoming write protection means writing some specific bytes to specific memory addresses in a specified sequence
	;Write data byte 1
	ld e,norflash_bank1	;Switch bank in
	out (c),e
	ld hl,norflash_addr1	;Address to program
	ld (hl),norflash_data1	;Byte to program

	;Write data byte 2
	ld e,norflash_bank2
	out (c),e
	ld hl,norflash_addr2
	ld (hl),norflash_data2
	;Write data byte 3
	ld e,norflash_bank3
	out (c),e
	ld hl,norflash_addr3
	ld (hl),norflash_data3_write

Swap in the physical bank we’ll be writing to, and retrieve the address back into HL.

	out (c),d	;Switch bank in
	pop hl		;Retrieve address

Then massage the address in HL into a 14-bit address in the logical bank we’re using. The data byte we’ll be writing is in A, so we need to keep it safe.

	ld e,a		;Preserve A
	ld a,logical_address_mask >> 8	;Mask to a 14 bit address
	and h
	or logical_bank << 6	;Map to address block $4000..$7fff - memory bank 1
	ld h,a
	ld a,e		;Retrieve data

After all that writing the actual data byte feels a bit underwhelming!

	ld (hl),a	;Write our new byte

And thence to the busy loop. We’ll put the byte we’ve written into the B register ready for the comparison (XOR).

	;Bit 7 will be inverted until operation is complete
	ld b,a		;Preserve
	ld a,(hl)	;Fetch written data
	xor b       ;XOR bit 7 - will be zero when done
	jr c,norflash_write_busy_loop

And we can finish after retrieving our saved registers.

	pop hl
	pop de
	pop bc

More Subroutines

I’ve also written code to write (copy) a 4Kb sector and an entire 16Kb bank. And the codebase includes a few optional test/example routines. At the time of writing you’ll need to look a the symbol table or disassembly to find the entry points, or you can comment out and reassemble. I’d suggest poking different initial values when experimenting.

Source, binaries and hex is available in my GitHub repository. Enjoy. Writing this article and the associated code has involved quite a few firsts for me so, as ever, any feedback is gratefully appreciated.


  1. The RC2014 board ignores address line A3 so you could also use ports $70 to $73 (with $74 for enable/disable) but the original machine did and compatible boards may also check so I think it’s worth keeping with the standard here.
  2. This is known as ‘software write protection’. The chip also has ‘hardware write protection’ which, in this case, means sending the correct signals to the /WE, /OE and /CE pins, which the board already does correctly.
  3. Saying you need to erase a sector before writing to it isn’t entirely true. Erasing a sector resets every bit to a ‘1’. Burning turns ‘1’ bytes to ‘0’s as needed but can’t change ‘0’s back to ‘1’s. So, technically, you could write overwrite single bytes without erasing as long as this only ever meant clearing bits. However, the code I have presented here doesn’t have any form of timeout in the busy loops. Thus if you try and write a previously written byte in error then it’s highly likely that the code will lock up. This may be something to consider for a future update.
  4. And there may be valid reasons for writing a single bye is such a way, such as tracking a usage counter, but if repeatedly writing the same bytes you’ll need to be wary of the maximum number of write cycles of the memory.

Leave a Reply

Your email address will not be published. Required fields are marked *