8x8 Pixel VGA Character Display
by Graeme Fricke
EM Probe Project Group

December 6, 2000 (updated December 11, 2000)

In order to program the VGA display for the EM Probe project I encountered quite a bit of difficulty in understanding how to implement characters on a VGA monitor. Other projects have used this idea (the Dice Race and Logic Analyzer teams) but their implementation, though it provided me with valuable ideas and starting points, seemed cryptic to me. In an attempt to clear things up a bit, I have created this guide. It does require some prior knowledge of VGA setup and none of the code is strictly workable without a number of extra processes, but if a full-blown, functional example is needed I encourage you to look at the EM Probe files, specifically the file probedisplay.vhd.

This application note assumes that the reader knows how to set up a VGA display, with HSync and VSync signals governed by horizontal and vertical counters (here called XCount and YCount).

The principle behind displaying 8 x 8 pixel characters on screen is that a 640 x 480 pixel monitor can be broken up into a grid of 80 x 60 characters. To do this, create new horizontal and vertical signals (call them col_address and row_address) which comprise all but the least significant three bits of XCount and YCount respectively.

Note that the high bit of YCount is actually irrelevant to the purpose of displaying anything onscreen; 480 pixels can be represented by 9 bits, so we can use row_address <= YCount(8 downto 3) instead. Whereas XCount and YCount track every pixel, each value of col_address and row_address represents 8 pixels. The bottom three bits of XCount and YCount haven't been forgotten: 8 pixels can be denoted by 3 bits, and we'll use this fact later.

Now that the screen is divided up into character blocks, it's possible to begin defining what will fill those blocks. A straightforward way to define display locations is to define the rows using a case statement, and then lay out any messages on a column by column basis. As an example, if we wanted to display a message composed of seven characters on row 13, beginning at column 32, we would write:

One case statement is therefore sufficient to define the whole screen, or at least whichever rows are required. This will save on logic cells.

We will need two ROMs, one to define words and character combinations and one to define the characters. For an example of a word ROM, see the file screenwords.mif and for an example of a character ROM, see the file chars.mif. The interfaces to the two ROMS are defined like this:

The lines in the word ROM look something like

The initial string of bits is the address of the 6-bit octal word in memory (that it's an octal word is defined at the beginning of the screenwords.mif file - this isn't necessary for all ROMs but it's convenient here); for example, feeding "00000010" into the format_address signal will request the 6 octal bits of "50". It isn't necessary to explicitly write the address of each data item in the ROM. The issue is then that of setting the format_address signal so that the desired bits will be output at format_data.

The way to accomplish this is to calculate the format address based on the column address in which the character is to appear. For example, to display the "HI-4422 PROBE" message beginning at column 15, we could write

Notice that as long as column_address is within this range, the memory address for each character block will be calculated. It just hasn't been done explicitly with hand-written calculations. This will send 6 bits out to format_data, but now what? Looking in the character ROM, you'll see that the first address of each character begins with a 3-digit number ending in 0. These match the pairs of digits presented here. For example, the letter H is represented in the character ROM by

(If this figure is skewed, try viewing it or the ROM files with a standard text editor or a wordprocessor set to Courier font).

Each row of the character begins with 07, which is what is used by the word ROM. For the least digit, we'll need to use the least three bits of the YCount signal which we had set aside earlier. Having defined the character row in which the character is to appear, we now need to define the individual pixels. The individual rows in the character ROM are accessed the same way as they were in the word ROM. The format_data signal defines six of the bits in the character ROM addresses, as we've seen, while the least three bits of the YCount signal define the other three bits. So:

I found it was best to implement the first line at the end of the process that calculates all of the format_address values based on the col_address vector. The second line is best implemented before determining the pixel colours, which we will get to next. However, I also found that what works for one person may not work for another, so a different setup may work for you.

Having defined rom_address completely, the character ROM returns the requested row of "pixels" in the rom_data signal. These "pixel" values will determine what to display, but they need to be reversed first. The problem is that the left-most bit in the rom_data signal is its most significant bit but the XCount signal counts up from the left. Thus, when the least three bits of XCount are, for example, "000", we want to display the most significant bit, bit "111", of rom_data. If this doesn't make sense, look at the character ROM "H" above: if the bit vector were displayed in count order from lowest bit to highest, the image will be backwards (admittedly not a problem for "H", but it will affect other characters more obviously).

We want to take the rom_data signal one bit at a time, in the right order, so that we can fix the pixel colour one pixel at a time. The following signal assignment will do this:

And here, the lowest three bits of the XCount return again. This function is not obvious, so I suggest stepping through a sequence of 8 pixels to see what comes out at rom_mux_output. CONV_INTEGER converts a bit vector to an integer and NOT XCount(2 downto 0) effectively reverses the pixel order; combined, they select one of the bits of rom_data (like rom_data(3), for example).

All of the above discussion boils down to determining the status of an individual pixel by examining rom_mux_output. Use the following logical conditions to define a white background or black characters (this requires the colour signals to be BOOLEAN):

When the pixel is "on", rom_mux_output = 1, so (rom_mux_output = '0') is FALSE, so each colour is off (resulting in black). When the pixel is "off", rom_mux_output = 0, so (rom_mux_output = '0') is TRUE, so each colour is on (resulting in white). This has an extra advantage that if the display is only black characters on a white background, there is no need to do anything more to define the pixel colours than state the above three lines once.

However, if more than one colour is desired, it is necessary to define the colour according to location on screen. The general structure is the same as that for determining the value of format_address from col_address: define all necessary rows with a case statement, then individual blocks of columns within that row. For example, to select a blue colour for the "HI-4422 PROBE" message beginning at column 15 of row 13, with nothing else in the row, we could write

The complete example would look (the process header is rough pseudocode only) like:

I encourage you to look at my completed probe display code (use the link given above) to get a feel for how a complete character display program looks. In it, I have clocked some of the processes to improve the signal timing, and there are a number of examples of more efficient calculations of format_address.

Graeme Fricke, EM Probe project group