Well, I’ve gone and made another 6502 project.

Instead of doing it on a breadboard or making a custom circuit board, I decided this time I would utilize an FPGA for everything except the actual CPU. Having everything in the FPGA means that things like IO and the boot ROM can be changed all in the computer.

To hook up the processor to the FPGA, I put together a little adapter board which consists of a 40 pin socket and a 40 pin female pin header. This then plugs directly into the expansion header of the DE10-lite, although the headers are the same on my DE0 so it should work there too.

The basic setup is as usual, 32k of ram in the bottom half, and 32k of rom in the upper half. There is one quirk though, and that is that the 6502 is designed for non-synchronous memory, while the memory inside the fpga is synchrnous. The way around this was to just half the speed of the cpu clock compared to the memory clock. The 6502 asserts addresses/data on the falling edge, then reads data on the next falling edge. By doubling the memory clock, the falling edge of the cpu clock is a rising edge of the memory clock, so the memory will get two rising edges before the cpu will move on.

The biggest advantage of this project for me is the IO though, the worst part of building the previous systems was address decoding and trying to add new devices. With the FPGA though, address decoding is only a single line of code.

The board includes 6 7-segment displays, which were the first things that I added. The verilog code is pretty simple:

assign hex_cs = addr >= 16'h7ff0 && addr < 16'h7ff4;

This is the address decoding logic, as you can see it is super simple.

logic [7:0] _data [3:0];

always_ff @(posedge clk) begin
    if (rst)
        _data = '{default:'0};
    if (~rw & cs)
        _data[addr] <= data;
end


logic [3:0] hex_4[5:0];

assign {hex_4[5], hex_4[4]} = _data[2];
assign {hex_4[3], hex_4[2]} = _data[1];
assign {hex_4[1], hex_4[0]} = _data[0];

logic [6:0] _HEX0, _HEX1, _HEX2, _HEX3, _HEX4, _HEX5;

HexDriver hex_drivers[5:0] (hex_4, {_HEX5, _HEX4, _HEX3, _HEX2, _HEX1, _HEX0});

assign HEX0 = _HEX0 | {7{~_data[3][0]}};
assign HEX1 = _HEX1 | {7{~_data[3][1]}};
assign HEX2 = _HEX2 | {7{~_data[3][2]}};
assign HEX3 = _HEX3 | {7{~_data[3][3]}};
assign HEX4 = _HEX4 | {7{~_data[3][4]}};
assign HEX5 = _HEX5 | {7{~_data[3][5]}};

The code here is also pretty simple. _data are the internal registers, of which there are 4 of. The first 3 represent the 3 pairs of digits, and the fourth one is a mask. If we try and write to the registers, it writes to the correct one.

We then create the hex drivers using the hex values, and then mask them. Note the the mas is using bitwise or, and the mask bit is inverted since the hex digits are active low, so we want to set all of the bits to high to turn it off.

The software to drive them is written in assembly, since it will be faster than C.

The first function takes in a byte to display and the index to display at:

uint8_t hex_set_8(uint8_t val, uint8_t idx);
_hex_set_8:
        phx
        cmp #$3         ; If idx >= 3 then fail
        bcc @1
        plx
        lda #$1
        rts
@1:     tax             ; Move idx into x
        jsr popa        ; put val into a
        sta SEVEN_SEG,x ; write to val
        lda #$0
        plx
        rts

As I discussed in my previous post about cc65 calling conventions, the rightmost argument is present in the primary register, which would be A for an 8 bit value, and the rest of the arguments are on the parameter stack. The first thing is we check to see if the index is greater than or equal to 3, which would be an invalid index. If it is valid, then we write the value to SEVEN_SEG,x which would be the address of the display plus the index.

The next one is even simpler:

uint8_t hex_set_16(uint16_t val);
_hex_set_16:
        sta SEVEN_SEG
        stx SEVEN_SEG+1
        lda #$0
        rts

16 bit values are passed with the low byte in A and the high byte in X, so we just write the values in A and X to the display.

uint8_t hex_set_24(uint32_t val);
_hex_set_24:
        sta SEVEN_SEG
        stx SEVEN_SEG+1
        lda sreg
        sta SEVEN_SEG+2
        lda #$0
        rts

This one is pretty similar, but shows off how 32 bit arguments are passed. The low 16 bits are present in A/X as before, but the high 16 bits are present in a zero-page location called sreg. Since there are only 6 digits, we don’t care about the high 8 bits which is why we don’t read sreg+1 as you might otherwise.

void hex_enable(uint8_t mask);
_hex_enable:
        sta SEVEN_SEG+3
        rts

And finally the simplest of them all, the mask function just writes the value in A, no nonsense.

That is all that I have done so far, but I hope to eventually add USB through the MAX3421E that we got for 385. I have a bunch of code for it, but it needs to be rewritten in the style that cc65 expects (that is, not C99).

I am having a lot of fun going back to my roots though.

View on my GitLab