FPGABee v3.0

Disk Controller

Next up for FPGABee is the ability read/write floppy and hard disk images from the SD card. The hard disk based Microbees used a WD1002 disk controller and replicating that is the topic of this post.

Overview of the WD1002

The WD1002 supports up to 3 hard drives and 4 floppy drives, although the Microbee operating systems only seem to support 1 hard disk and 2 floppy drives.

The Microbee interfaces to the WD1002 disk controller through 8 I/O ports from 0x40 to 0x47. The ports map onto the WD1002 as follows:

  • Port 0x40 - Reads drain the WD1002's sector buffer. Writes fill it.
  • Port 0x41 - Read error register
  • Port 0x42 - Read/write sector count
  • Port 0x43 - Read/write sector number
  • Port 0x44 - Read/write track number (lo byte)
  • Port 0x45 - Read/write track number (hi byte)
  • Port 0x46 - Read/write SDH register
  • Port 0x47 - Write invokes commands, reads return the status register

The SDH register is the Size/Disk/Head register and specifies the sector size, the disk to read/write and the head number. The size bits are ignored in FPGABee and all reads/writes are 512 (no Microbee disk formats support sector sizes other than 512 bytes).

The meaning of some of the bits in this register change slightly according to whether a hard drive or floppy drive is being accessed. For hard drives:

  • Bit 7 - Enables error correction
  • Bits 6-5 - Specifies sector size
  • Bits 4-3 - Specifies HDD number (0-2), or when 3 (11)
  • Bits 2-0 - Hard drive head number (0-7)

Floppy access is indicated by setting the HDD number to 3 and the SDH register works as follows:

  • Bit 7 - Enables error correction
  • Bits 6-5 - Specifies sector size
  • Bits 4-3 - 3 or (11b) to indicate floppy acces
  • Bits 2-1 - Floppy drive number (0-3)
  • Bit 0 - Head number

To perform a read operation:

  1. Setup the track, sector and SDH register for the sector to be read
  2. Send the read command 0x20 to port 0x47
  3. Poll the status register (0x47) until the busy bit (bit 7) goes low
  4. Read 512 bytes from the data port (0x40)

To perform a write operation:

  1. Setup the track, sector and SDH register for the sector to be written
  2. Send the write command 0x30 to port 0x47
  3. Write 512 bytes to the data port (0x40) (at which point the controller starts writing to disk)
  4. Poll the status register (0x47) until the busy bit (bit 7) goes low

The WD1002 controller also supports commands for multiple sector read/writes and formatting sectors. The multiple sector commands I'm not going to implement since the documentation for the WD1002 says that it requires the use of DMA and interrupts - which aren't wired up in a Microbee (as best I can tell). The format command I'll implement later.

Implementation Overview

The disk controller is implemented as a few key components:

  1. The sector buffer
  2. The CPU port interface
  3. The command execution unit
  4. The disk geometry unit
  5. The SD card controller

The disk geometry unit was the topic of the previous post. The SD card controller is an open source implementation that I downloaded and won't be discussed here except to say it handles all the SPI signalling between the FPGA chip and the SD card and provides and interface that can read/write single blocks via a shared dual port RAM block.

The Sector Buffer

The sector buffer is a true dual port block RAM of 512 bytes. One port is used for the CPU port side of the disk controller. The other port is connected to the SD card controller. Unlike the other dual port RAMs that I've used in FPGABee, this one provides read and write access on both ports. It's implemented using inferred block RAMs:

-- Entity RamTrueDualPort

library ieee;
use ieee.std_logic_1164.ALL;
use ieee.numeric_std.ALL;
use std.textio.all;
use ieee.std_logic_textio.all;

entity RamTrueDualPort is
		ADDR_WIDTH : integer;
		DATA_WIDTH : integer := 8
		-- Port A
		clock_a : in std_logic;
		addr_a : in std_logic_vector(ADDR_WIDTH-1 downto 0);
		din_a : in std_logic_vector(DATA_WIDTH-1 downto 0);
		dout_a : out std_logic_vector(DATA_WIDTH-1 downto 0);
		wr_a : in std_logic;

		-- Port B
		clock_b : in std_logic;
		addr_b : in std_logic_vector(ADDR_WIDTH-1 downto 0);
		din_b : in std_logic_vector(DATA_WIDTH-1 downto 0);
		dout_b : out std_logic_vector(DATA_WIDTH-1 downto 0);
		wr_b : in std_logic
end RamTrueDualPort;

architecture behavior of RamTrueDualPort is 
	constant MEM_DEPTH : integer := 2**ADDR_WIDTH;
	type mem_type is array(0 to MEM_DEPTH) of std_logic_vector(DATA_WIDTH-1 downto 0);
	shared variable ram : mem_type;

	process (clock_a)
		if rising_edge(clock_a) then

			if wr_a = '1' then
				ram(to_integer(unsigned(addr_a))) := din_a;
			end if;

			dout_a <= ram(to_integer(unsigned(addr_a)));

		end if;
	end process;

	process (clock_b)
		if rising_edge(clock_b) then

			if wr_b = '1' then
				ram(to_integer(unsigned(addr_b))) := din_b;
			end if;

			dout_b <= ram(to_integer(unsigned(addr_b)));

		end if;
	end process;


CPU Port Interface

Implementing the CPU port side of the disk controller was fairly straight forward, although it needs to do edge detection the read/write flag signals. Since the Z80 takes multiple clock cycles to perform a single I/O operation, the disk controller needs to ensure it only executes one command or data read/write per I/O operation. It does this by tracking the state of these signals and watching for a rising edge

-- Track read/write edges
wr_prev <= wr;
rd_prev <= rd;

if wr='1' and wr_prev='0' then
	-- etc...

if rd='1' and rd_prev='0' then
	-- etc...

Ports 0x41 through 0x46 map onto an array of registers in the disk controller:

when "0001" | "0010" | "0011" | "0100" | "0101" | "0110" => 
	-- register write
	regfile(to_integer(unsigned(cpu_port))) <= din;

Reading port 0x40 - the data port - returns the output of the sector buffer block ram and sets a flag to increment it's address register on the next clock cycle:

when "0000" =>
	dout <= secram_dout;
	secram_addr_inc <= '1';
	if secram_addr_plus1(9)='1' then
		reg_status_drq <= '0';
	end if;

Writing the data port is similar except when the buffer is full it needs to trigger the command execution unit to do the write (more about this later).

when "0000" =>
	secram_din <= din;
	secram_addr_inc <= '1';
	secram_we <= '1';
	if secram_addr_plus1(9)='1' then
		-- Buffer full, trigger the write
		reg_status_drq <= '0';
		exec_cmd <= pending_cmd;
		exec_request <= '1';
		reg_status_busy <= '1';
	end if;

The status and error registers are implemented by combining various status and error bits into a suitable response:

when "0111" =>
	-- STATUS register
	dout <= reg_status_busy & reg_status_ready & "0" & reg_status_sc & reg_status_drq & "00" & reg_status_error;

That covers most of the CPU port side of the disk controller.

Command Execution Unit

The CPU port side of the disk controller doesn't handle any of the actual work of reading/writing the SD card. Rather it issues commands to the command execution unit (which is just another VHDL process in the same entity) and awaits a response. While awaiting a response the disk controller returns a busy status and ignores attempts to invoke other commands.

To invoke a command the CPU port interface pulses the exec_request signal. When a command has completed the command execution unit pulses the exec_response signal. There are a few other signals that are used to pass the desired command to the execution unit and for returning the result of the command to the CPU port interface.

The command execution unit spends most of it's time in the ready state waiting for something to do, at which point it invokes the DiskGeometry unit to start calculation of the block number and then switches to a state where it waits for the result:


	if exec_request='1' then
		geo_invoke <= '1';
		exec_result <= RESULT_OK;
		exec_state <= STATE_GEO_WAIT_START;
	end if;

	-- The DiskGeometry component requires one cycle after being
	-- invoked before it leaves the ready state.  So pause one
	-- cycle before checking if the calculation has finished.
	exec_state <= STATE_GEO_WAIT;

Once the block number has been calculated it adds the calculated block number to the base block number of the disk image and starts the SD operation. If there was an error calculating the block number it reports a geometry error:


	if geo_ready='1' then

		if geo_error='1' then

			-- Abort with geometry error
			exec_result <= RESULT_GEO_ERROR;
			exec_state <= STATE_READY;
			exec_response <= '1';


			-- Start the read/write request
			sd_op_block_number <= std_logic_vector(unsigned(diskimage_base_block_number) + unsigned(geo_cluster));
			sd_op_cmd <= exec_cmd;
			sd_op_wr <= '1';
			exec_state <= STATE_SD_WAIT;

		end if;

	end if;

It then waits for the SD controller to finish its work:


	if sd_status(2)='0' then

		if sd_status(1)='1' then
			exec_result <= RESULT_SD_ERROR;
			exec_result <= RESULT_OK;
		end if;

		exec_state <= STATE_READY;
		exec_response <= '1';

	end if;

Disk Image Information

The only other bit that's missing from all of the above is how the disk controller knows the type of disk image and it's location on the SD card for each of the different drives. Well eventually this will be supplied by a peripheral control unit (PCU), but for the moment is hard coded to assume that HDD1 is a .hd0 format image at block 0

-- Fake drive type generation (for now)
process (reg_drive)

	case reg_drive is

		when "001" =>
			-- HDD1
			geo_disk_type <= DISK_HD0;
			diskimage_base_cluster <= x"00000000";

		when others =>
			geo_disk_type <= DISK_NONE;
			diskimage_base_cluster <= (others=>'0');

	end case;

end process;

Ready to boot?

I tested all of the above using a similar approach to the video controller and memory mapping - a custom boot ROM that exercised all the ports and successfully executed read and write operations.

With all that in place, FPGABee should be ready to boot and that will be the topic of the next post.

Subscribe for more like this. No spam, just fun tech stuff :)

Or, find me on Twitter: @toptensoftware.