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.
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.
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.
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.
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 bank 0 which, you’ll remember, is ROM. This is an important mechanism at boot up: the banking mechanism is disabled at reset, physical ROM bank $00 is paged in and the Z80 begins by executing boot code in that ROM $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:
- 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.
- 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.
- 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.
A couple of important points to note:
- 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)
- 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.
- 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
- 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.
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
;!!!!!!ANY READS FROM OR WRITES TO ROM DURING THIS PROCESS WILL ABORT THE PROCESS!!!!!
;!!!!!!THIS INCLUDES RUNNING CODE FROM THE ROM SUCH AS A ROM BASED MONITOR!!!!!!
;************************
norflash_sector_erase:
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))
rlca
rlca
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.
norflash_erase_busy_loop:
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
ret
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
;!!!!!!ANY READS FROM OR WRITES TO ROM DURING THIS PROCESS WILL ABORT THE PROCESS!!!!!
;!!!!!!THIS INCLUDES RUNNING CODE FROM THE ROM SUCH AS A ROM BASED MONITOR!!!!!!
;*****************************
norflash_write_byte:
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
norflash_write_busy_loop:
ld a,(hl) ;Fetch written data
xor b ;XOR bit 7 - will be zero when done
rlca
jr c,norflash_write_busy_loop
And we can finish after retrieving our saved registers.
pop hl
pop de
pop bc
ret
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.
Addenda
Updated May 2024 to reflect that, when banking is disabled (ie. at startup), all logical banks are mapped to physical bank $00. Thanks to Roman for pointing this out.
Footnotes
- 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.
- 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.
- 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.
- 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.
2 Replies to “Understanding the RC2014 512k ROM 512k RAM Board”
Wow, for days, I was trying to wrap my head around how the bank switching/paging is working on the ROM/RAM modules that work with ROMWBW.
I am so glad that I found your article – I did not manage to understand how the four-word registers on these cards work, but now, everything falls into place.
Thanks so much!
Comments are closed.