Saturday, March 31, 2012

Support for Breakpoints

I added a couple of new features to my JMON machine language monitor program.

I took the memory test code from here and added a memory test command so you can test memory. It's not too likely that JMON will run if your system has bad memory but it can give you a warm and fuzzy feeling that your system's memory is good.

Note that it is not recommended to run a memory test program like this on EEPROM as it has a limited number of write cycles before it wears out.

Here is a screen shot of the memory test running:

JMON Running Memory Test

The larger new feature is support for breakpoints. There is now a "B" (breakpoint) command that lets you set up to four breakpoints. You can set a breakpoint on an address where you want to go into the debugger (the Krusader mini-monitor). The command puts a BRK instruction there and saves the original instruction. When the BRK instruction is hit, code in the BRK handler puts the original instruction back and jumps into the mini-monitor at the address of the breakpoint. From there you can continue, single step, etc. Once hit, a breakpoint is cleared and needs to be set again. Breakpoints must be in RAM and the IRQ/BRK vector must be in RAM. The program checks and an error is displayed if not.

Here is a sample session:

Using Breakpoint
I've found the mini-monitor in Krusader to be a great tool for debugging machine language programs. The addition of breakpoints makes it even more useful as you stop at specific instructions rather than stepping through long sequences of code to get to the area you need to debug.

As always, you can download the latest version of JMON from here.

Wednesday, March 28, 2012

A 65C02 Disassembler

I just wrote a disassembler that runs on the Replica 1. I did it mostly for a personal programming challenge as there are lots of them around. Woz did one for the Apple 1 -- I think it was published in Byte and was probably the one included in the Apple II ROMs.

Years ago on my first computer (a 6502-based Ohio Scientific) I wrote one, first in BASIC and then in machine language. I remember it used a somewhat simplified format, e.g. STAX $nn for STA $nn,X and JMPI $1234 for JMP ($1344) etc.  It was written on paper and hand assembled. Once I had a disassembler it made it much easier to catch errors in hand assembled code.

My implementation is in assembler, written for the CC65 cross-assembler. It supports all 65C02 mnemonics including the Western Digital only opcodes. The output is virtually identically to Krusader's disassembler. To test it I captured the output of my disassembler and Krusader's and checked for any differences.

I have a standalone version which disassembles memory a screen at a time. I also integrated it into my JMON machine language monitor program to which I added a new U (unassemble) command.

Here is a screen shot with some sample output:

Screen Shot of Dissembler Output
And here is an example of some 65C02 instructions being disassembled:

some 65C02 Instructions

I didn't look at any other disassembler implementations, but there are only so many ways to do it. About half of the information is in data structures or tables.

I have one lookup table of all the instruction mnemonics. They are 3 characters each and there are 71 of them including all the 65C02 instructions. I have another table of all 256 possible opcodes. For each opcode, the table has two entries - the instruction (an index into the previously described table) and an entry listing the addressing mode. Thus, for a given op code, say $EA, I can look up in the table that is is a NOP instruction, using implicit addressing mode, and the mnemonic is "NOP". Another small table lists the number of instruction bytes for each addressing mode. For example, for implicit addressing it is one byte. There are 16 possible addressing modes.

I initially included the number of instruction bytes in the table of opcodes until I realized that for a given addressing mode it was always the same so I could use a small lookup table based on the addressing mode.

The major part that is hardcoded rather than in tables is the logic that displays the instruction operands appropriately given the addressing mode. The total size is about 1.5K including all utility routines of which a little over half is code and the rest is data. It will run out of ROM if desired.

If one really wanted to optimize the code for size I suppose you could reduce the size of the opcode table by taking advantage of the fact that over a quarter of it is not valid opcodes (instructions ending with hex value 3, 7, B, and F, for example) but this would complicate the logic for the table lookup, and once you add 65C02 instructions many of the invalid opcodes are used.

Adding 65C02 support did not add much code although handling the SMB, RMB, BBR, and BBS instructions was a little complex due to the funky format they have.

I put in an assemble time option so that the output can contain only the instructions and not the memory data bytes, so you could feed the output into an assembler. Here is an example of it running in that mode:

    JSR   $0540
    LDX   #$94
    LDY   #$08
    JSR   $0579
    JSR   $0540
    LDA   #$80
    STA   $37
    LDA   #$02
    STA   $38
    JSR   $0540
    LDA   #$17
    PHA
    JSR   $02B7
    PLA
    SEC
    SBC   #$01
    BNE   $029A
    LDX   #$6F
    LDY   #$08
    JSR   $0579
    JSR   $055F
    CMP   #$20
    BEQ   $0295

The code is licensed under Apache license so you are free to use it if you wish.  This first version can be considered beta -- it is complete but may still have bugs.

The standalone version and the version of JMON with the disassembler can be downloaded from these link:

https://docs.google.com/open?id=0B54TLlZjWIytQ2duZThPR1hSLWV4ekllencwV29VZw

Monday, March 26, 2012

Adding a Power On Reset Circuit

The lack of a power on reset for the Replica 1 is a little annoying. After being powered on you need to press the reset button. This is faithful to the original Apple 1 which had the same behaviour, and in fact the early Apple II systems also had no power on reset circuit.

I decided to add one to my Replica 1 as a small a hardware enhancement. I've seen few methods, such as using a dedicated power on reset chip like the MAX1232 or TL7705 . I wanted to use a circuit that used parts available from the time of the Replica 1 and preferably something simple that could be made with parts from my junk box.

The 65C02 version of the CPU can actually handle a pulse generated from a simple RC circuit, so that is one approach but, as well as not working with the original 6502, it wouldn't properly reset other devices connected to the reset line like the PIA on the Replica 1 and the VIA and ACIA on the Multi I/O board.

The Apple II had a power on reset circuit built into the Disk II floppy disk controller interface card. If you had a floppy drive (and most people eventually did) you got a power on reset function. The schematic for the circuit is available, most notably included in the Apple II DOS Manual. It uses a 556 dual timer chip, which has been around since the 1970s and is still produced. Here is the relevant part of the circuit:

Portion of the Disk II Interface Showing Reset Circuit
The power on reset only uses half of the 556 chip, so I adapted it to use the 555 chip which has one timer. We also need an inverter chip to invert the reset pulse and it needs to be an open collector output since other devices can also drive the RESET line low (such as the reset button). The circuit produces a negative reset pulse of about 110 milliseconds on power up.
Quick Hand Drawn Schematic of Reset Circuit
I breadboarded the circuit and tested it. I had lots of 555 timer chips in my parts inventory. I didn't have a 74LS05 chip on hand but I did have a similar 7406. I tested it first on it's own using an oscilloscope. Then I hooked the breadboarded version up to the Replica 1 and confirmed that it worked. On power up I no longer needed to hit the reset button before being able to use the Woz monitor.

Breadboarded Version of Reset Circuit
I then installed the circuit on the prototype area of the Replica 1, using two sockets and wiring it to the appropriate locations on the board. After careful checking I powered it up and it worked just like on the breadboard.

Final Version on the Replica 1 Board (Top Middle)
I still expect to regularly need to hit the reset button on my Replica 1 but the power on reset makes a nice hardware enhancement. I like to think that it is a modification that an original Apple 1 user might have made to their system.

Saturday, March 24, 2012

The 6551 ACIA


There is one more piece of hardware on the Replica 1 Multi I/O board that we haven't examined, the 6551 Asynchronous Communication Interface Adapter.  It provides a single serial port with programmable baud rate and the usual settings for serial ports: baud rate, parity, stop bits, etc.

The 6551 on my Multi I/O Board

Between the introduction to it in the Multi I/O board manual, and the data sheet (which is only 8 pages) it's a pretty simple chip to understand.

For it's time it was quite a good ACIA, providing an on-chip baud rate generator. It uses TTL (5V) levels so you need a level shifter to convert to and from the RS-232 levels. On the Multi I/O board this is done with a MAX 232 chip.The frequency of the ACIA is based on the 1.8432 MHz crystal connected to it, so it is not dependent on the CPU frequency or PH2 clock.

It is lacking a few features. Some ACIAs support two serial ports. The maximum baud rate is 19,200 bps which was good for its time but many systems later went up 115,200. Some ACIAs also supported a larger buffer than one character.

You can set the baud rate using an external clock signal. You could potentially drive this from the VIA, for example, but to support 115200bps the frequency would need be 1.8432 MHz, too high for the VIA to generate.

On the Multi I/O board the RTS and CTS handshaking lines do not go to the serial port so it can't support hardware handshaking (this would be an easy modification though). You can run it in an interrupt driven mode.

Basically it has four registers. A Control Register sets the serial mode including baud rate, word length and stop bits. A Command Register sets some transmit and receive functions such as parity, DTR, RTS, and CTS settings. A Status Register reports on the status of characters sent and received. The Transmit Data Register is used to send characters and the Receive Registers returns received characters.

Let's look at a simple example program. I created an include file, 6551.inc, to define the chip registers and their addresss on the Multi I/O board. Here is the file:

; 6551 Chip registers
        TXDATA = $C300
        RXDATA = $C300
        STATUSREG = $C301
        CMDREG = $C302
        CTLREG = $C303

Our example program will write a short message ("OK" followed by a carriage return) to the serial port. Then we will read characters from the port and echo them back. If the user presses 'Q' we will return (e.g. to the Woz monitor if that was where we were run from). Here is the listing:

       .include "6551.inc"
       CR = $0A ; carriage return
; Set 1 stop bit, 8 bit data, internal clock, 19200bps
       LDA #011111
       STA CTLREG
; Set no parity, no echo, no TX interrupts, RTS low, no RX interrupts, DTR low  
      LDA #001011
      STA CMDREG
; Display OK\n
      LDA #'O'
      JSR ECHO
      LDA #'K'
      JSR ECHO
      LDA #CR
      JSR ECHO
; Now get a character and echo it back
; Quit if it is 'Q'
LOOP: JSR GETCHAR
      CMP #'Q'
      BEQ DONE
      JSR ECHO
      JMP LOOP
DONE: RTS
; Send character in A out serial port
ECHO:   PHA
        LDA #$10
TXFULL: BIT STATUSREG ; wait for TDRE bit = 1
        BEQ TXFULL
        PLA
        STA TXDATA
        RTS
; Read character from serial port and return in A
GETCHAR: LDA #$08
RXFULL: BIT STATUSREG
        BEQ RXFULL
        LDA RXDATA
        RTS

To test it, load it into the Woz monitor (probably using the serial port on the Replica 1). Then connect the serial port of your computer to the Multi I/O board serial port. Set your communication program to 19200bps, 8N1, and no hardware handshaking. You should see the "OK" message and any characters you type will be echoed back. Pressing Q will return to the Woz monitor.

The ECHO and GETCHAR routines above can be used to do serial I/O in your programs rather than the normal Replica 1 keyboard. As an experiment I modified my JMON machine language monitor program to do serial I/O.

6522 VIA Experiment #6

In this last instalment of my series on the 6522 VIA we'll use a little more hardware to show how to generate analog waveforms using a simple digital to analog converter.


You can read all about the theory of digital to analog conversion elsewhere (such as here ). In our example we'll use a simple digital analog converter called a resistor ladder, specifically an R-2R ladder.

All we need is some resistors connected to the digital output pins of one of the 6522 VIA ports. With 8 pins we could make an 8-bit D/A converter, or even a 16-but using both ports, but to simplify the circuit I'll just use a 4-bit ladder. That will require 8 resistors. The basic circuit is shown below.

4 Bit Resistor Ladder
With 4 bits we have 2^4 or 16 possible output values. The analog voltage from the D/A converter is proportional to the value we write, ranging from 0 to 5 volts as we write the values 0 through 15.

I chose to use R = 10K (or 10,000 ohms). I used standard resistor values of 12K and 20K for R and 2R. The four low order pins of the 6522 VIA port A are used. It was quickly wired up on solderless breadboard.
Resistor Ladder Circuit on Breadboard
The code is very simple. We need to set the appropriate pins as output. We repeatedly write out samples from a table in memory. I chose to use a table with 16 samples. We simply loop, writing subsequent table values to the port and repeat when we get to the end of the table.

We can generate various waveforms depending on the values in the table. I chose three common ones. A ramp is a waveform that increases linearly from zero to the maximum value and then repeats. With 16 data samples we simply use the values from 0 to 15. A triangle increases linearly from a minimum value to a maximum value and then decreases linearly back to the minimum. The data samples for this were trivial to choose.

A sine wave is a little trickier. We want to use values corresponding to a sine curve, but we need to scale them to the range of data we have (0 to 15) and round them to integer values. To do this I calculated the values in a spreadsheet. Here are the values in my spreadsheet:

Sample Value        Rounded
0 7.5        7
1 10.3701257427 10
2 12.8033008589 12
3 14.4290964938 14
4 15        15
5 14.4290964938 14
6 12.8033008589 12
7 10.3701257427 10
8 7.5        7
9 4.6298742573 4
10 2.1966991411 2
11 0.5709035062 0
12 0        0
13 0.5709035062 0
14 2.1966991411 2
15 4.6298742573 4

The formula for the values was 7.5*SIN(2*PI() * n/16) + 15/2 where n is the sample number. The rounded values are the integer value of these (i.e. the INT() function).

Here is the entire code:
   
       .include "6522.inc"
       SAMPLES = 16    ; Number of samples in table
       LDA #001111  ; Set low 4 bits of port A to all outputs
       STA DDRA
START: 
       LDX #0
LOOP:
       LDA SINE,X      ; Get value. Select SINE, RAMP, or TRIANGLE
       STA PORTA       ; Write to port
       NOP             ; Can add more NOPS to slow down frequency
       INX             ; increment index
       CPX #SAMPLES    ; are we at end?
       BNE LOOP        ; if not, continue
       JMP START       ; otherwise restart
; Sine values calculated using spreadsheet
SINE:
       .byte 7,10,12,14,15,14,12,10,7,4,2,0,0,0,2,4
RAMP:
       .byte 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
TRIANGLE:
       .byte 0,2,4,6,8,10,12,14,15,14,12,10,8,6,4,2

You can select which waveform to generate by changing the line

       LDA SINE,X      ; Get value. Select SINE, RAMP, or TRIANGLE

to use the appropriate table.

With a 2MHz CPU clock I measured a sine wave of 7.245 KHz. Below you can see the three waveforms displayed on an oscilloscope.

Sine Wave

Ramp Wave

Triangle Wave

How could we extend this further? With more D/A bits we could generate more accurate waveforms. 8-bits is pretty good. CD audio uses 16-bits, which we could do with both VIA ports but we likely couldn't get it running at the 44KHz rate that CD audio uses and we'd quickly run out of memory to store the samples.

You can imagine using this scheme for generating simple sounds. To drive a real device you'd want to add some buffering using an Op Amp or similar. You could then drive amplified speakers, for example.

The code takes all the CPU resources. It could be made interrupt driven, as in the previous article in this series. However, any significant sample rate would take a lot of CPU resources even if run interrupt driven.



I hope you enjoyed this series on the 6522 VIA. There are other features of the 6522 VIA chip, such as the shift register, that we did not explore. I encourage you to study the data sheet and see what applications you can come up with.

I appreciate any feedback on this series and I'd like to hear if you tried any of these experiments yourself.

The source code and Woz monitor binaries for this series can all be found here.

Friday, March 23, 2012

6522 VIA Experiment #5

In this experiment we will play with interrupts and show how to implement a time of day clock using the 6522 VIA.

Interrupts are a feature of most microprocessors that allows the normal flow of execution to be, well, interrupted. It is normally triggered by a hardware line.

Rather than delve into interrupts in detail I refer you to the 6502 data sheet as well as this good tutorial .

The timers on the 6522 can generate interrupts when a timer counts down to zero. In our example we'll implement a time of day clock by having the timer regularly generate interrupts, and incrementing memory locations in the interrupt service routine to track the time of day.

Because this is entirely interrupt driven, it won't (or shouldn't) affect programs running on the machine if they don't disable interrupts or conflict with the memory locations we use.

There will be two parts to our code:
  1. The code to set up the timer and interrupt service routine.
  2. The interrupt service routine itself.

The set up routine needs to first disable interrupts while we set things up. The interrupt vector (address) on the Replica 1 is $0100, which is in RAM. We want to point this to our own code, so we write a JMP instruction there to transfer control to our interrupt routine.

I decided to run the timer to generate interrupts every 100th of a second (a time interval sometimes called a jiffy). We'll need a memory location to count the number of jiffies. We'll also count seconds, minutes, and hours. Each of these can fit in an 8-bit memory location.

To set up the 6522 VIA, we enable interrupts from timer 1 in the Interrupt Enable Register. We set timer 1 to continuous mode with the PB7 output disabled. Now we can re-enable interrupts on the processor. We write the low and high bytes of the timer with the value that corresponds to a 1/100th of a second, which starts the timer. Now, whenever the timer counts down to zero it will generate an interrupt on the IRQ line of the 6522 chip which is wired to the IRQ line on the 6502.

Our interrupt routine needs to do the following:
  1. First save on the stack any registers we will be using so we can restore them. In our case only the accumulator.
  2. Read the Timer 1 low byte. This clears the interrupt.
  3. Now we increment the jiffies count. If we reach 100 we want to roll it over to zero and add one to the minutes If the minutes were incremented, we check if they reached 60. If so, we roll them over to zero and increment the hours count.
  4. Similarly we check if hours reached 24, in which case it rolls over to zero.
  5. When done we restore the accumulator from the stack and return. Note that we use an RTI (Return From Interrupt) instruction and not RTS for this.

For debug purposes I added code that will write the characters "S", "M", and "H" when the seconds, minutes, and hours are incremented. You can comment out this code when not testing the software. The complete code is below.

 .include "6522.inc"

    ECHO     = $FFEF    ; Woz monitor
    COUNT    = 19998    ; 100 Hz sample rate (10 msec interrupts) assuming 2 MHz CPU clock
    IRQ      = $0100    ; IRQ vector

    JIFFIES  = $0403    ; 100ths of seconds
    SECONDS  = $0402    ; counts seconds
    MINUTES  = $0401    ; counts minutes
    HOURS    = $0400    ; counts hours

    SEI                 ; mask interrupts
    LDA #$4C            ; JMP ISR instruction
    STA IRQ             ; Store at interrupt vector
    LDA #<ISR
    STA IRQ+1
    LDA #>ISR
    STA IRQ+2

    LDA #0              ; Set clock to zero
    STA JIFFIES
    STA SECONDS
    STA MINUTES
    STA HOURS

    LDA #%11000000
    STA IER             ; enable T1 interrupts
        
    LDA #%01000000
    STA ACR             ; T1 continuous, PB7 disabled

    CLI                 ; enable interrupts

    LDA #<COUNT
    STA T1CL            ; Set low byte of count
    LDA #>COUNT
    STA T1CH            ; Set high byte of count
    RTS                 ; Done

; Interrupt service routine
ISR:
    PHA                 ; save A

    BIT T1CL            ; Clears interrupt

    LDA JIFFIES
    CLC
    ADC #1
    STA JIFFIES
    CMP #100            ; reached 1 second?
    BNE DONE            ; if not, done for now

    LDA #'S'            ; for test purposes
    JSR ECHO

    LDA #0              ; reset jiffies
    STA JIFFIES
    LDA SECONDS         ; increment seconds
    CLC
    ADC #1
    STA SECONDS
    CMP #60             ; reached 1 minute?
    BNE DONE            ; if not, done for now

    LDA #'M'            ; for test purposes
    JSR ECHO

    LDA #0              ; reset seconds
    STA SECONDS
    LDA MINUTES         ; increment minutes
    CLC
    ADC #1
    STA MINUTES
    CMP #60             ; reached 1 hour?
    BNE DONE            ; if not, done for now
        
    LDA #'H'            ; for test purposes
    JSR ECHO

    LDA #0              ; reset minutes
    STA MINUTES
    LDA HOURS           ; increment hours
    CLC
    ADC #1
    STA HOURS
    CMP #24             ; reached 24 hours?
    BNE DONE            ; if not, done for now

    LDA #0              ; reset hours
    STA HOURS

DONE:
    PLA                 ; restore A
    RTI                 ; and return

When run, it sets up the interrupt handler and then returns to the Woz monitor. You can examine the time of day values by dumping memory in the Woz monitor, e.g.

  401.404

and see the locations that store the hours, minutes, seconds, and jiffies. Do it a few times to satisfy yourself that it is counting. If you want you can manually write the current time in hours minutes and seconds to set the clock to the correct time. I ran it overnight and it was still within one second of the correct time.

Here is a picture of the IRQ line on an oscilloscope showing the regularly spaced pulses every 10 milliseconds.

Scope Probe on Pin 4 (IRQ) of the 6502

100msec Interrupts from the 6522
If you left in the debug code you should also see "S", "M", and "H" characters appearing (which is a little annoying when you are using the Woz monitor).

It's fortunate that the interrupt vector in the Replica 1 points to RAM, as it lets us put our handler routine there. The vector for NMI also points to RAM but the NMI line is not connected to any devices. The reset vector points to ROM, the Woz Monitor, as it should.

Unfortunately $0100 is not a great choice as on the 6502 the stack sits in page 1 of memory. There is a chance that the JMP to our interrupt handler will get corrupted if the stack pointer reaches that location and data pushed on the stack writes over it. We could improve our example program by initializing the stack pointer to somewhere away from $0100 to reduce the chances of this but it could still happen. If you want to make use of interrupts on the Replica 1 you should probably reprogram your EEPROM to point the IRQ vector somewhere else.

To show that the clock routine runs independently of the main code executing on the processor, we can run BASIC and still access the time of day. Here is a simple BASIC program that shows the time of day by PEEKing the appropriate memory locations.

LOMEM=1100 : REM TO MAKE SURE WE DON'T WRITE OVER OUR ISR
10 H=PEEK (1024) : REM $0400
20 M=PEEK (1025) : REM $0401
30 S=PEEK (1026) : REM $0402
40 PRINT H;":";M;":";S
50 IF PEEK (1026)=S THEN 50 : WAIT FOR SECONDS TO CHANGE
60 GOTO 10

Typical output looks like the screen below:

Output of BASIC Program Showing Time
Are there any limitations of our little real time clock? Well, yes. We only count time. You could easily imagine extending it to track the day, month, and year. The time gets lost if the system is powered down or even reset (this stops the timer on the VIA). If any software disables interrupts, we will stop counting time during that period. If there are interrupts from other devices, our code doesn't handle that. Finally, there is the possible stack corruption issue described earlier.

There are dedicated hardware real-time clock (RTC) chips that do a better job, but of course they aren't free.

6522 VIA Experiment #4

This time we're going to extend the pulse counting example from the last instalment to be able to measure frequency. The code is short but a little complex.

To measure frequency we need to count pulses over a known period of time. We saw how Timer 2 can count pulses. We can also use Timer 1 to count a period of time for us.

We'll use Timer 2 in the same pulse counting mode as before where it counts transitions on line PB6. We'll put Timer 1 in the single shot mode where it counts down to zero from a known value at the system clock rate.

We'll count the number of pulses we see over this period of time and display it. Then we'll repeat forever. This is essentially what a device called a digital frequency counter does.

We poll the timer to see when it reaches zero. This is not very efficient and a nice enhancement would be to make this interrupt driven but we want to keep it simple for now. We will look at an interrupt driven example later in this series.

Ideally we would like to convert the result to an actual frequency. To do that we need to divide the number of pulses by the time period (i.e. to get cycles per second or Hertz). If we did this, a good sample rate might be one that is one over a multiple of 2, e.g. 1/16, 1/32, or 1/64 of a second since we can do this calculation by shifting the data by a number of bits rather than doing a full divide operation. For this example I used a 20 Hertz sample rate and didn't calculate the frequency. I used the one's complement trick described in the last experiment to convert the count down to a count up.

The hardware setup here is a little messy. We need a source of pulses, i.e. a square wave at the appropriate TTL (5V) level. Initially I used the calibrate output of my oscilloscope as it puts out 2 volts peak to peak at around 1 KHz. This worked well enough to confirm that I got the right results.

I then used an audio oscillator (an old EICO model 377) to give me a source that I could vary in frequency. I set it to square wave output and then drove it through a 74LS04 inverter chip. I was concerned about possibly damaging the VIA chip if I inadvertently set the output voltage too high. This way I only risked damaging an inexpensive IC from my junk box.

You can see the setup below.

EICO Signal Generator
Breadboarded Circuit with 74LS04 Chip

The source code is below. Based on the information above and the comments in the code is should be self-explanatory.

 .include "6522.inc"

    ECHO     = $FFEF    ; Woz monitor
    PRBYTE   = $FFDC    ; Woz monitor
    CR       = $0D      ; Carriage return
    COUNT    = 49998    ; 20 Hz sample rate

    LDA #$00
    STA IER             ; disable all interrupts

    LDA #%00100000
    STA ACR             ; T1 single shot PB7 disabled, T2 pulse count mode

LOOP:
    LDA #
    STA T1CL            ; Set low byte of count
    LDA #>COUNT
    STA T1CH            ; Set high byte of count

    LDA #$FF            ; Set count for T2
    STA T2CL            ; Set low byte of count
    LDA #$FF
    STA T2CH            ; Set high byte of count

WAIT:
    LDA T1CH            ; wait for timer T1 to count down to zero
    BNE WAIT
    LDA T1CL
    BNE WAIT

    LDA T2CH            ; get high byte of T2 count
    EOR #$FF            ; take 1's complement
    JSR PRBYTE          ; print it
    LDA T2CL            ; get low byte of T2 count
    EOR #$FF            ; take 1's complement
    JSR PRBYTE          ; print it
    LDA #CR
    JSR ECHO            ; print newline
    JMP LOOP            ; repeat forever

As one example (the one shown on the display in the picture below), the input was at about 100KHz. I counted about $13AE pulses, or 5038 decimal. I used a 20 Hz sampling rate. 5038 * 20 Hz = 10,076 Hz. So my reading looks about right.

Signal Generator Output on Oscilloscope

Output of Program

My signal generator only went up to about 200KHz and I was able to measure this frequency.

You can imagine building a digital frequency counter using a 6502 and a VIA chip. In fact some modern counters a simple processor (e.g. a PIC) and some kind of display and not much more.