Proof of concept - TTY to VGA

A project log for Microcoding for FPGAs

A microcode compiler developed to fit into FPGA toolchain and validated to develop CPU and text-based video controller

zpekiczpekic 05/30/2020 at 18:000 Comments

This component serves 2 purposes:

- illustrates that microcoding can easily be used for non-CPU circuits such as display, I/O, disk, or any other custom controllers

- useful in the project to trace main CPU instructions executing for debugging or illustration purposes

(screenshot tracing first 3 instructions on VGA screen: DIS (0x71), LBR (0xC0), LDI (0xF8))

Discussion below refers to:



The circuit spends most time waiting for the CPU to send it a character (8-bit ASCII) to display on the screen. While the character code is 0, it is interpreted as no printing needed, and the TTY keep the ready bit high ('=' assignment):

waitChar: ready = char_is_zero, data <= char,
                 if char_is_zero then repeat else next;

Note that at the end of the microcode cycle, the character input will be loaded into the internal data register ('<=' assignment). char_is_zero is a condition presented to the control unit which is true ('1') when char is 0, and if so, uPC (microprogram counter) won't be incremented ("repeat"). As soon as it becomes != 0, "next" will be executed, which simply means increment uPC by 1.

Right after that, we have a classic "fork" - the trick here is that ASCII code is interpreted as "instruction":

0x00 - NOP

0x01 - home (cursor to top, left)

0x02 - clear screen

0x0A - line feed

0x0D - carriage return

0x20-0x7F - printable

if true then fork else fork; // interpret the ASCII code of char in data register as "instruction"

if true then fork else fork; // interpret the ASCII code of char in data register as "instruction"

What does "fork" actually do? It is nothing more that loading uPC from a look-up table. The MCC will create this lookup table automatically by the help of .map instructions. This can be seen how the printable char routine is implemented. All locations x20 to x7F will point to the address of this routine:

.map 0b???_????; // default to printable character handler 
main: gosub printChar;

          cursorx <= inc;

          if cursorx_ge_maxcol then next else nextChar;

          cursorx <= zero,
          goto LF;

 Few tricks here:

1. character is defined as 7-bit, not 8 - bit 7 is ignored and in VGA hardware it is hooked up to produce "inverse" characters (dark font or light background). This also cuts mapper memory from 256 to 128 entries

2. map instruction is a match all - all seven bits are '?'. When MCC sees this, it will fill all mapper memory locations with the address of "main". However subsequent .map which are more specific / targeted will override those mapper locations. 

The "main" routine above executes in 4 microinstructions (= 4 clock cycles, each ';' denotes 1 cycle)

1. goto to printChar routine (there is no difference between goto and gosub, remember the built-in hardware stack)

2. increment cursorx register. "inc" has no meaning - it is just a label MCC will mantain with a value, it is up to the VDHL to interpret it correctly:

update_cursorx: process(clk, tty_cursorx, cursorx, maxcol)
if (rising_edge(clk)) then
    case tty_cursorx is
        when cursorx_zero =>
            cursorx <= X"00";
        when cursorx_inc =>
            cursorx <= std_logic_vector(unsigned(cursorx) + 1);
        when cursorx_dec =>
            cursorx <= std_logic_vector(unsigned(cursorx) - 1);
        when cursorx_maxcol =>
            cursorx <= maxcol;
        when others =>
    end case;
end if;
end process;

How this all comes together is through code.vhdl file that MCC produces:

-- L0023.cursorx: 3 values same, zero, inc, dec, maxcol default same
alias uc_cursorx: std_logic_vector(2 downto 0) is ucode(15 downto 13);
constant cursorx_same: std_logic_vector(2 downto 0) := "000";
constant cursorx_zero: std_logic_vector(2 downto 0) := "001";
constant cursorx_inc: std_logic_vector(2 downto 0) := "010";
constant cursorx_dec: std_logic_vector(2 downto 0) := "011";
constant cursorx_maxcol: std_logic_vector(2 downto 0) := "100";

This is a "package" VHDL source code file generated by MCC which should be included in the main VHDL project. Obviously, there is a dependency - after MCC runs and updates this file with new values, VHDL project must be recompiled too to pick it up.

3. Check if cursor_x reached max screen column (80 in this case for 80*60 VGA screen), if not go back to loop waiting for another printable character, and if yes then continue ("next")

4.  set cursor_x to 0 (again, "zero" has no meaning it a label resolved into value 1, and then then MUX loading the cursor_x is supposed to pass 0 at that selection), and proceed with executing the "linefeed" sequence (this may lead to screen scroll up if cursor_y is at lowers row already)

On the "outgoing" side of TTY to VGA, there is the VGA video memory. This memory can be only written too when VGA beam is outside visible range (between rows or frames). At that point the controller can take over the address and data bus from VGA controller, and have access to write to memory. This is done by simply using the VGA "busy" signal as a condition to microcode, and waiting ("repeat") until it becomes free. This is a simple but effective way of synchronizing two independent circuits:

printChar:	if memory_ready then next else repeat;

		mem = write, xsel = cursorx, ysel = cursory,

the above executes in 2 + wait cycles:

1. wait in loop until memory is ready (VGA is not using the video memory bus)

2. generate memory write signal, present data register to video memory data bus, and address will be generate by cursor x and y. 

-- memory interface
mwr <= tty_mem(1);
mrd <= tty_mem(0);

x <= cursorx when (tty_xsel = xsel_cursorx) else altx;
y <= cursory when (tty_ysel = ysel_cursory) else alty;

dout <= data;

As you have guessed it, again "write" is just a simple label with will be resolved to value "10" meaning that write signal is "1" and read signal is "0" (most FPGA logic is "positive", as opposed to many real IC devices with are "negative" ( /RD /WR /CS etc.))

In case of screen scroll-up, the video memory has to be read too - each row from 1 to max is read and written to row above. Reading video memory is similar to writing:

readMem:	if memory_ready then next else repeat;

		mem = read, xsel = cursorx, ysel = cursory, data <= memory,

The "read" label now simply resolves to "01" (write signal is asserted), and data mux label "memory" should be coded to connect to VGA memory data bus:

update_data: process(clk, tty_data, char, din)
	if (rising_edge(clk)) then
		case tty_data is
			when data_char =>
				data <= char;
			when data_memory =>
				data <= din;
			when data_space =>
				data <= X"20";
			when others =>
		end case;
	end if;
end process;

Remember that MCC programmer has to keep in mind what is "goto" vs. "gosub" - both readMem and writeMem end with "return" - that simply means that it is assumed that the execution will resume with next instruction after the invocation. That's why the invocation is written as "gosub". For the microcode control unit there is no difference - each "jump" pushes return to hardware stack (until exhausted), and it is up to code to reuse it ("return") or ignore it with another jump.