Difference between revisions of "Introducing Interrupts"

From Intellivision Wiki
Jump to: navigation, search
(Critical Sections)
m (Step 2: Take a snapshot of the time)
Line 438: Line 438:
 
interrupts and read the time with interrupts off.  This copy of the time  
 
interrupts and read the time with interrupts off.  This copy of the time  
 
is guaranteed to be consistent no matter what.  For the purposes of this
 
is guaranteed to be consistent no matter what.  For the purposes of this
example, we'll read the time and put a copy of it on the stack with [[PSHR]].
+
example, we'll read the time and put a copy of it on the stack with <CODE>[[PSHR]]</CODE>.
  
 
         [[DIS]]                    ; Disable ints (begin critical section)
 
         [[DIS]]                    ; Disable ints (begin critical section)

Revision as of 09:13, 3 December 2007


Interrupts are a way for a device in the system to ask the CPU to stop what it's currently doing and do something on its behalf, or to inform the CPU that something happened. In the Intellivision, the STIC interrupts the CPU 60 times a second (NTSC) or 50 times a second (PAL/SECAM) to tell the CPU that it's done drawing the display. This lets the CPU know that the STIC is ready to accept certain commands, and provides a time base for games and programs.

In most systems, interrupts come in two flavors: "maskable interrupts" and "non-maskable interrupts." These sometimes go by the name IRQ and NMI (the common parlance, especially in the 6800/6502 world), or in the case of the CP1610, INTRM and INTR. A maskable interrupt is an interrupt the CPU can choose to ignore until it's convenient. That is, the CPU can "mask" (aka. block or postpone) the interruption. A non-maskable interrupt is an interrupt that forces the CPU to do something immediately.

The Intellivision system only has maskable interrupts, so that's all this tutorial will explore. Other systems often have more elaborate interrupt structures.

Interrupts and Interruptibility

As mentioned above, interrupt requests ask the CPU to do something other than what it's currently doing when it is convenient to do so. Interrupts are like phone calls. When the phone rings, you can either answer it immediately, answer it after several rings, or you can ignore the phone altogther.

Phone calls interrupt whatever it is you're doing when you answer the call. If you're not busy, it's likely convenient to answer the phone immediately. If you are busy doing something tricky—for example, carrying a bowl of hot soup—you might wait to answer it until you've set it down. If you're away from the phone for awhile—for instance, you're in an important meeting—you might check your voice mail (if you have it) when you get back. At any rate, the phone's unlikely to still be ringing when you do get back.

CPU interrupts are similar, as we'll see in a moment. Programs have situations that are analagous to each of the three scenarios above. The CP1610's notion of "convenient" is called "interruptible." Interruptible means "the CPU will process an interrupt that arrives during this instruction after the instruction completes." The CPU is interruptible if the following two conditions are true:

  1. Interrupts are enabled
  2. The current instruction is an "interruptible instruction."

Programs can enable (unmask) interrupts with the EIS, JE and JSRE instructions. They can disable (mask) interrupts with DIS, JD and JSRD instructions. These instructions are useful for enabling and disabling interrupts for long periods of time, or over critical groups of instructions. This is a bit like turning the ringer off on your phone when you don't want to be bothered.

Individual instructions in the CP1610 instruction set are also classified as interruptible or non-interruptible. This is a unique feature of CP1610 assembly language. We don't know why General Instrument made specific instructions non-interruptible. Nonetheless, several useful idioms arise out of their selections. The non-interruptible instructions you'll encounter most commonly are writes (MVO, MVO@, and PSHR), shifts/rotates and the various 4 cycle instructions such as EIS, DIS and SDBD. Non-interruptible instructions are akin to ignoring your phone's ringer for a moment while you get something else out of the way.

What Happens When the CPU Takes an Interrupt?

When the Intellivision responds to an interrupt, it takes the following steps:

  1. The CPU writes the current value of the program counter (R7) to the stack. This value indicates the instruction that will run when we return from the interrupt.
  2. The CPU then jumps to location $1004. The hardware determines this address, and there's no easy way to change this address on the Intellivision.
  3. The EXEC interrupt code at $1004 then saves the CPU's state on the stack. The code pushes R0 through R5 and a copy of the flags register onto the stack. Altogether, interrupts take up a minimum of 8 words of stack space.
  4. The EXEC interrupt code sets up a return address ($1014) in R5, and then jumps to the interrupt handler whose address is stored in RAM at locations $100-$101.

The sequence of events above is the same for all Intellivisions. The Intellivision I, Intellivision II, Sears Super Video Arcade, Tandyvision One, INTV System III and INTV Super Pro System all perform the same steps. (It is possible to modify the Intellivision to do something different, but why?) It makes sense then to understand this sequence exactly as it is described above.

So What are Interrupt Handlers?

Interrupt handlers (also known as Interrupt Service Routines or ISRs) are functions that get invoked by the EXEC in response to an interrupt. In the Intellivision, these functions handle tasks that must be attended to periodically, since the Intellivision's STIC triggers an interrupt 60 times a second (50 times a second on PAL/SECAM systems).

ISRs have many duties. During normal game play, an ISR might do the following:

  1. Update STIC registers
  2. Update the GRAM
  3. Update any music and/or sound effects
  4. Update any timers or counters that keep track of time
  5. Update object positions based on their "velocity"
  6. Check the hand controllers for new inputs

This list isn't perfect, but it is representative. The exact demands placed on an ISR depend on the nature of the game program and what that program needs at the time.

The first step is probably the most important one. By default, the STIC will blank the display unless the CPU specifically asks it to display the next frame. This is referred to as the "STIC handshake." To keep the display active (non-blank), programs must write to location $0020 within about 2000 cycles of the STIC generating an interrupt. If the CPU does not write to this location, the STIC will blank the display during the next frame.

The remaining steps really depend on how your game is structured. Future tutorials will cover these needs in more depth.

Setting up an Interrupt Handler

Programs can set up an interrupt handler by writing its address to locations $100 and $101. The address is stored in Double Byte Data format. The following code shows one way of setting up "MYISR" as the interrupt handler:

   MVII    #MYISR, R0
   MVO     R0,     $100    ; write out lower 8 bits of ISR address
   SWAP    R0              ; swap lower/upper halves
   MVO     R0,     $101    ; write out upper 8 bits of ISR address

There are other ways to do this, but this is adequate for most purposes. This sequence is also non-interruptible,, which is important, as we will see below.

Interrupt handlers behave just like any other function. The EXEC sets up R5 to point to the return address, $1014. Since the return address is always $1014, some interrupt handlers (such as those in Space Patrol) simply branch directly to $1014 rather than keeping the value of R5 around.

Reasons for Turning Off Interrupts

Normally, programs leave interrupts enabled, and don't pay much attention to which instructions are interruptible. They let interrupts happen when they happen. Sometimes, though, programs have good reason to turn off interrupts.

Critical Sections

To the program, interrupts look like they occur between two other instructions. The CPU stops doing what it's doing, processes the interrupt, and then resumes what it had been doing. Most of the time, this is ok: What the interrupt code does has little to do with what the interrupted code is doing. That isn't always true though.

Suppose, for example, you're trying to increment a variable. This is a three step process:

   MVI     VAR,    R0
   INCR    R0
   MVO     R0,     VAR

Suppose also that an interrupt handler tries to decrement this same variable:

   MVI     VAR,    R1
   DECR    R1
   MVO     R1,     VAR

This can lead to disaster. Imagine if an interrupt happens in the middle of the first instruction sequence. The CPU ends up executing the following instruction sequence with respect to VAR:

   MVI     VAR,    R0
   INCR    R0
   ; ---> interrupt occurs
       MVI     VAR,    R1
       DECR    R1
       MVO     R1,     VAR
   ; <--- return from interrupt
   MVO     R0,     VAR

What value gets left in VAR? In this case, it looks like the decrement never happened. That's because the MVO R0, VAR writes the incremented value after MVO R1, VAR wrote the decremented value. Also, the decremented value isn't even the right value.

This type of code is referred to as a "critical section." The MVI/INCR/MVO sequence is critical because it needs to execute without interruption. Using the phone call analogy above, this is similar to carrying a hot bowl of soup: You don't want to spill the soup, and carrying it requires your full attention, so you want to do so without interruption until you safely set down the soup.

To fix the example above, you can wrap the critical code with EIS and DIS instructions:

   DIS
   MVI     VAR,    R0
   INCR    R0
   MVO     R0,     VAR
   EIS

What this does is prevent the CPU from servicing an interrupt while executing this code. Now the interrupt handler and the main program can increment and decrement and the right thing always happens.

A Useful Idiom: MVO/SWAP/MVO

Because some of CP1610's instructions are non-interruptible, certain very useful idioms are non-interruptible. Perhaps the most important idiom is the one for writing Double Byte Data. The following sequence is not interruptible, because MVO@ and SWAP are not interruptible:

   MVO@    R0,     R4      ; write lower 8 bits
   SWAP    R0 
   MVO@    R0,     R4      ; write upper 8 bits

This idiom is very useful for setting up the interrupt handler address. If the sequence were interruptible, programs would have to manually disable interrupts while setting up a new interrupt handler address. Otherwise, if an interrupt happened before the address was completely set up, the program could crash.

For example, suppose $100/$101 point to an interrupt handler at location $5678. This means location $100 holds $78, and location $101 holds $56. (See Double Byte Data if you're unsure why.) Now we're setting up a new interrupt handler "ISR2" that happens to be at location $FEDC. The code for this might look like so:

   MVII    #ISR2,  R0
   MVO     R0,     $100    ; Write out lower 8 bits ($DC in this example)
   SWAP    R0
   MVO     R0,     $101    ; Write out upper 8 bits ($FE in this example)

Next, suppose the MVO/SWAP/MVO sequence is interruptible. If an interrupt occurs after the first MVO but before the second MVO, what would happen?

The answer is simple: The EXEC would branch to whatever code or data is at location $56DC. Who knows what's at this address? This address is a meaningless mixture of $5678 (the old ISR) and $FEDC (the new ISR). The most likely outcome is that the program crashes mysteriously.

Fortunately, because MVO and SWAP are both non-interruptible on the CP1610, this isn't possible. The code does safely update the interrupt handler address. Any interrupt that arrives during this sequence will be ignored until after the sequence completes.

Program Initialization

Sometimes, programs need to turn off (or simply find it convenient to turn off) interrupts for extended period of time. This happens most often during initialization. (By initialization, I mean any major change in machine and game setup, not just the setup that happens right after reset.)

During initialization, the program may be setting up large bits of memory, such as the contents of GRAM and so on. This may take more than the time the STIC usually allots for this. Thus, such code may disable interrupts to simplify program design and prevent misbehavior.

This is analagous to being "away from the phone" in the metaphor above, only there is no "voice mail." If the CPU doesn't respond to an interrupt in about 1600 cycles, the interrupt simply gets dropped. This is like what happens when someone hangs up after letting the phone ring for awhile. The next interrupt still occurs though.

Example 1: A simple "elapsed time" clock

The following example attempts to demonstrate how interrupts work without getting caught up in too many details of how the rest of the system works. This example shows a simple elapsed-time clock on screen that shows how many hours, minutes, seconds and 60ths of a second (tics) have elapsed since the program started. It uses an interrupt handler to keep track of the time and to keep the screen from going blank.

Let's build this up by pieces.

The Interrupt Handler

The interrupt handler has two major responsibilities: Keep the screen on, and update the time. Updating the time has several smaller steps:

  1. Increment the number of tics that have occurred this second. If that number is less than 60, then we're done updating the time so skip to the end.
  2. Zero out the # of tics/second and increment the number of seconds. If this number is less than 60, then we're done updating the time, so skip to the end.
  3. Zero out the # of seconds and increment the number of minutes. If this number is less than 60, then we're done updating the time, so skip to the end.
  4. Zero out the number of minutes and increment the number of hours.

The following code shows one way to implement the ISR. The first statement, MVO R0, $20 keeps the screen enabled. The rest of the code updates the elapsed time. There are more clever ways to do this. This code is written for clarity. (In general, write for clarity first, and get fancy later only if you need to.)

MYISR   PROC
 
        MVO     R0,     $20     ; Keep screen enabled.
 
        MVI     TIC,    R0      ; Get current tic count into R0
        MVI     SEC,    R1      ; Get current seconds count into R1
        MVI     MIN,    R2      ; Get current minutes count into R2
        MVI     HOUR,   R3      ; Get current hours count into R3
 
        INCR    R0              ; Increment tic count
        CMPI    #60,    R0      ; Tic < 60?
        BLT     @@time_done     ; Yes:  Done updating time
 
        CLRR    R0              ; Reset tic count
        INCR    R1              ; Increment seconds count
        CMPI    #60,    R1      ; Seconds < 60?
        BLT     @@time_done     ; Yes:  Done updating time
 
        CLRR    R1              ; Reset seconds count
        INCR    R2              ; Increment minutes count
        CMPI    #60,    R2      ; Minutes < 60?
        BLT     @@time_done     ; Yes:  Done updating time
 
        CLRR    R2              ; Reset minutes count
        INCR    R3              ; Increment hours count
 
@@time_done:
        MVO     R0,     TIC     ; Store updated tic count
        MVO     R1,     SEC     ; Store updated seconds count
        MVO     R2,     MIN     ; Store updated minutes count
        MVO     R3,     HOUR    ; Store updated hours count
 
        JR      R5
        ENDP

This interrupt handler is fairly straightforward. It's all that this particular example needs.

The Main Loop

The main loop merely needs to print out the time whenever it changes. The loop runs "forever." The main steps are:

  1. Wait for the time to change. We can do this by waiting for the value of TIC to change.
  2. Take a snapshot of the current time. While it might not be immediately obvious, this is a critical section. We don't want the time to change on us while we're reading it.
  3. Print out the various time components: Hours, Minutes, Seconds and Tics.
  4. Go back to step 1.

Let's take a look of each of these steps in turn.

Step 1: Wait for the time to change

We can tell if the time has changed by looking at the TIC variable and seeing if its value is different than the last time we displayed the time. This works because the value of TIC changes every time the time changes, even if the other time components don't change.

The safest and easiest way to do this is to store a copy of the previous value of TIC somewhere. In this example, we'll store it in a place called PTIC. The code to do this might look like so:

@@wait_time:
        MVI     TIC,    R0      ; Get current value of TIC
        CMP     PTIC,   R0      ; Has the time changed?
        BEQ     @@wait_time     ; No:  Keep looping

When the loop above exits, we know the time has changed since the last time we displayed it.

Step 2: Take a snapshot of the time

Because the interrupt handler updates the time inside the interrupt handler, we need to be careful when we read the time so that we get a consistent view of the time. Otherwise, if an interrupt occurs while we're reading the time, we could get a scrambled copy. For example, suppose the current time is 11:59:59:59 and is about to roll over to 12:00:00:00. If an interrupt comes in after we've read tics, seconds and minutes but before we read the hours, we might see 12:59:59:59, which is completely wrong. Even worse, on our next tic we will likely see 12:00:00:01.

If this did happen, time will appear to have jumped forward and then backward by very large amouints, which is bad. We want to see either 11:59:59:59 or 12:00:00:00 in this case, not some mixture of the two. If we were doing much more with the time than just displaying it, such an error could cause all sorts of strange problems.

Now, in this particular example program, such an error is unlikely to happen and if it did happen, the consequences are minor. In more complex programs, though, it could happen that by the time we get around to displaying the time again, the time is about to change. Therefore, rather than write risky code that happens to work for this example, it's better to write correct code that will continue to work even if it gets reused in a much more complex program.

The most straightforward way to read the time, then, is to disable the interrupts and read the time with interrupts off. This copy of the time is guaranteed to be consistent no matter what. For the purposes of this example, we'll read the time and put a copy of it on the stack with PSHR.

        DIS                     ; Disable ints (begin critical section)
        MVI     TIC,    R0      ; Read tic count
        MVO     R0,     PTIC    ; Remember TIC in PTIC for next time
        PSHR    R0              ; Save tic count on the stack
        MVI     SEC,    R0      ; Read seconds count
        PSHR    R0              ; Save seconds count on the stack
        MVI     MIN,    R0      ; Read minutes count
        PSHR    R0              ; Save minutes count on the stack
        MVI     HOUR,   R0      ; Read hours count
        EIS                     ; Enable ints (end critical section)

Notice that we re-read TIC and copy its value to PTIC inside this critical section. This is perhaps overkill, in that it's highly unlikely that the current time will change between when the @@wait_time loop completes and when we snapshot the time. It would require our code to be running way behind compared to the 60Hz (or 50Hz) interrupt clock. That said, the above way of writing the code ensures that we only start a new loop when the current time is different than the displayed time, no matter what, and the displayed time is always 100% consistent.

Step 3: Displaying the time

To display the time, we'll use the SDK-1600 routine PRNUM16, found here. The PRNUM16.z routine prints the number in R0 in a fixed width field, complete with leading zeroes. It prints the value to the screen, starting at the display location specified in R4. It returns with R4 pointing to the first position after the displayed number. This is exactly what we want.

The PRNUM16 function describes its inputs and outputs as follows (edited slightly for clarity):

 INPUTS
     R0  Number to print.                               
     R2  Width of field. 
     R3  Format word, added to digits to set the color. 
     R4  Pointer to location on screen to print number  
                                                        
 OUTPUTS                                                
     R0  Zeroed                                         
     R1  Unmodified                                     
     R2  Unmodified                                     
     R3  Unmodified                                     
     R4  Points to first character after field.         

This is perfect for our needs. The example program will display the time as four 2 digit fields separated by an empty space. To keep the example simple, we won't cover just yet how to display colons between the time segments. Later tutorials on the STIC will cover this.

The time string will take up 8 characters for the time digits, and then another 3 characters for the spaces between them. This makes the time 11 characters long. To display this approximately centered in the screen, we can display it in Row 5, Column 5. Recall that the BACKTAB is a 20 x 12 character grid. This means that the first character will be at location $0200 + 5*20 + 5. (See the Hello World Tutorial for a refresher on how this calculation works.)

The display format word controls what color the number appears in, and in fancier cases, what font. Such details are beyond the scope of this tutorial, but rest assured we'll cover them eventually. For now, we'll display the time in white. White is color #7, and so our format word will be 7.

With that in mind, the following code shows how to display the time. Recall that when we start this code, R0 already holds a copy of HOUR and the stack holds MIN, SEC and TIC.

        MVII    #2,     R2      ; Set our field width to 2
        MVII    #7,     R3      ; Set our format word to "white"
        MVII    #$200 + 5*20 + 5, R4    ; Start in row 5, column 5

        CALL    PRNUM16.z       ; Display the hours
        INCR    R4              ; Leave a blank space after hours

        PULR    R0              ; Get minutes
        CALL    PRNUM16.z       ; Display the minutes
        INCR    R4              ; Leave a blank space after minutes
 
        PULR    R0              ; Get seconds
        CALL    PRNUM16.z       ; Display the seconds
        INCR    R4              ; Leave a blank space after seconds
 
        PULR    R0              ; Get tics
        CALL    PRNUM16.z       ; Display the tics

Notice how PULR gets used to recall the previously saved values of HOUR, MIN and SEC. The PULR instructions mirror the PSHR instructions we used earlier to save these values. With the stack, every thing that gets pushed on the stack needs to be pulled off later. Furthermore, what gets pushed onto the stack gets pulled off in reverse order. In this case, we pushed the snapshot of TIC followed by the snapshot of SEC followed by the snapshot of MIN. When we displayed the time, these came off the stack in reverse order—minutes, seconds and then tics.

Step 4: Go back to step 1

This is perhaps the easiest step. Since our code has nothing better to do than display the time, all it needs to do is jump back to the top of the loop. To do this, we can put a label before step 1, and branch to it. The resulting loop looks like, from end to end:


@@outer_loop:                       ; outermost loop

        ;; Step 1:  Wait for time to change
@@wait_time:
        MVI     TIC,    R0      ; Get current value of TIC
        CMP     PTIC,   R0      ; Has the time changed?
        BEQ     @@wait_time     ; No:  Keep looping
 
        ;; Step 2:  Snapshot the time 
        DIS                     ; Disable ints (begin critical section)
        MVI     TIC,    R0      ; Read tic count
        MVO     R0,     PTIC    ; Remember TIC in PTIC for next time
        PSHR    R0              ; Save tic count on the stack
        MVI     SEC,    R0      ; Read seconds count
        PSHR    R0              ; Save seconds count on the stack
        MVI     MIN,    R0      ; Read minutes count
        PSHR    R0              ; Save minutes count on the stack
        MVI     HOUR,   R0      ; Read hours count
        EIS                     ; Enable ints (end critical section)

        ;; Step 3:  Display the updated time
        MVII    #2,     R2      ; Set our field width to 2
        MVII    #7,     R3      ; Set our format word to "white"
        MVII    #$200 + 5*20 + 5, R4    ; Start in row 5, column 5

        CALL    PRNUM16.z       ; Display the hours
        INCR    R4              ; Leave a blank space after hours

        PULR    R0              ; Get minutes
        CALL    PRNUM16.z       ; Display the minutes
        INCR    R4              ; Leave a blank space after minutes

        PULR    R0              ; Get seconds
        CALL    PRNUM16.z       ; Display the seconds
        INCR    R4              ; Leave a blank space after seconds

        PULR    R0              ; Get tics
        CALL    PRNUM16.z       ; Display the tics

        ;; Step 4:  Go back to step 1.
        B       @@outer_loop

Now, you might wonder why we added a second label, @@outer_loop, when there's no code between @@outer_loop and @@wait_time. After all, it's not strictly necessary. The answer is pragmatism.

While it may be true at the moment that the additional label is redundant, that may not be true forever. At some point, we might want to add code that runs before we wait for the time sync-up. That code will need to go between the two labels. After all, displaying the updated time might be the last thing we do, as opposed to the only thing we do.

Also, it's simply clearer. Our last step is to "do everything again." This has different meaning than "wait for the time to change." Descriptive labels make it clearer to the reader what the purpose of a given branch is. Labels are free. Don't be afraid to add labels if it makes the code clearer.

Setting Things Up

So far, we've looked at the meat of the problem—the interrupt handler and the time display—but we've skipped entirely over the problem of setting things up. Most of that is pretty easy. Four steps remain: We must find a place to put the time, set the initial time to zero, clear the screen, and make our ISR the current interrupt handler.

Allocating Our Variables

You might be thinking "We already have a place to put the time!" Well, yes and no. In the fragments of code above, I've used the names PTIC, TIC, SEC, MIN and HOUR to refer to the various variables this program needs. Those variables do indeed hold the current time, as well as the previous value of TIC. I haven't told the assembler where to put these though. If you were to try to assemble the code above, you'd get errors for this reason. One way to rectify this is to assign addresses to each of these names with the EQU directive.

Each of these quantities fits in 8 bits, so it's natural to allocate these values in 8 bit Scratchpad RAM. This memory resides at addresses $0100 - $01EF in the Intellivision memory map. Locations $0100 - $0101 hold the interrupt handler address. The remaining locations are available for our use when we avoid using the EXEC. (The EXEC uses some of these locations for its internal bookkeeping.)

In this example, we do not use the bulk of the EXEC, and so we have the entire Scratchpad available to us. Therefore, the following code snippet works just fine. (There are better ways to do this in large programs. We will look at those later.)

PTIC    EQU     $102    ; Previous value of 'tic'
TIC     EQU     $103    ; Current number of tics (0..59)
SEC     EQU     $104    ; Current number of seconds (0..59)
MIN     EQU     $105    ; Current number of minutes (0..59)
HOUR    EQU     $106    ; Current number of hours (0..59)

There are many ways we might initialize these values. The most straightforward is just to zero out all of the Scratchpad RAM, except for locations $100 and $101. The SDK-1600 routine FILLZERO is perfect for this task:

        MVII    #$102,  R4      ; Start filling at location $102
        MVII    #$0EE,  R1      ; Fill $EE locations (up through $1EF)
        CALL    FILLZERO

This will clear out all of the Scratchpad memory, and thus set all five of our variables—PTIC, TIC, SEC, MIN, and HOUR—to zero. Hold that thought a moment, because we'll come back to it.

Clearing the Screen

The SDK-1600 function CLRSCR does a fine job of clearing the screen. This function resides in the same file as FILLZERO and FILLMEM. This is because the three functions are very closely related. This makes it tempting to think a little harder about what exactly we're doing.

The straightforward next step in program initialization would be to CALL CLRSCR after clearing out the Scratchpad RAM. The screen resides at $0200 - $02EF, and this fills these locations with 0. If you recall, the code above just got done filling locations $0102 - $01EF with 0 as well. It seems like we should be able to combine the two acts.

It turns out we can. Addresses $01F0 - $01FF refer to the [[Programmable Sound Generator]] (aka. PSG). We can safely write 0s to all of its locations. In fact, this is the preferred way to initialize the PSG when its state is otherwise unknown. Thus, we can combine the FILLZERO and CLRSCR calls into a single FILLZERO call that zeros $0102 - $02EF. The combined code looks like so:

        MVII    #$102,  R4      ; Start filling at location $102
        MVII    #$1EE,  R1      ; Fill $1EE locations (up through $2EF)
        CALL    FILLZERO

This kills two birds with one stone. (Three birds if you count initializing the PSG, although in this example it happens that the EXEC has done that for us already.)

Setting up the Interrupt Handler

Finally, all that's left is setting up the interrupt handler to point to the time counting code we wrote above. Recall that our routine is called MYISR. The following code sets that up:

        MVII    #MYISR, R0      ; \
        MVO     R0,     $100    ;  |_ Write out "MYISR" to $100-$101
        SWAP    R0              ;  |  Double Byte Data.
        MVO     R0,     $101    ; /
        EIS                     ; Make sure interrupts are enabled



Putting it All Together

No Intellivision program is complete without an appropriate ROM header and INCLUDE directives for all of the library functions. This particular example needs fillmem.asm and prnum16.asm from SDK-1600. Download these files and put them in a directory. Then put the following source code in a new file named elapsed.asm in the same directory. This source code contains all of the snippets from above, along with a ROM header to make it work. The added portions are in bold. As you can see, the additions are minor and are largely boilerplate you've seen before from the Hello World Tutorial.


PTIC    EQU     $102    ; Previous value of 'tic'
TIC     EQU     $103    ; Current number of tics (0..59)
SEC     EQU     $104    ; Current number of seconds (0..59)
MIN     EQU     $105    ; Current number of minutes (0..59)
HOUR    EQU     $106    ; Current number of hours


        ROMW    16      ; 16-bit ROM
        ORG     $5000   ; Standard ROM memory map starts at $5000

;----------------------------------------------------------------------------
; EXEC-friendly ROM header.
;----------------------------------------------------------------------------
ROMHDR: BIDECLE ZERO            ; MOB picture base   (points to NULL list)
        BIDECLE ZERO            ; Process table      (points to NULL list)
        BIDECLE MAIN            ; Program start address
        BIDECLE ZERO            ; Bkgnd picture base (points to NULL list)
        BIDECLE ONES            ; GRAM pictures      (points to NULL list)
        BIDECLE TITLE           ; Cartridge title/date
        DECLE   $03C0           ; Flags:  No ECS title, run code after title,
                                ; ... no clicks
ZERO:   DECLE   $0000           ; Screen border control
        DECLE   $0000           ; 0 = color stack, 1 = f/b mode
ONES:   DECLE   1, 1, 1, 1, 1   ; Color stack initialization
;----------------------------------------------------------------------------

TITLE   STRING  $107, "Elapsed Time Demo", 0  ; Title string and date (2007)

MAIN    PROC

        MVII    #$102,  R4      ; Start filling at location $102
        MVII    #$1EE,  R1      ; Fill $1EE locations (up through $2EF)
        CALL    FILLZERO

        MVII    #MYISR, R0      ; \
        MVO     R0,     $100    ;  |_ Write out "MYISR" to $100-$101
        SWAP    R0              ;  |  Double Byte Data.
        MVO     R0,     $101    ; /
        EIS                     ; Make sure interrupts are enabled

@@outer_loop:                       ; outermost loop

        ;; Step 1:  Wait for time to change
@@wait_time:
        MVI     TIC,    R0      ; Get current value of TIC
        CMP     PTIC,   R0      ; Has the time changed?
        BEQ     @@wait_time     ; No:  Keep looping

        ;; Step 2:  Snapshot the time 
        DIS                     ; Disable ints (begin critical section)
        MVI     TIC,    R0      ; Read tic count
        MVO     R0,     PTIC    ; Remember TIC in PTIC for next time
        PSHR    R0              ; Save tic count on the stack
        MVI     SEC,    R0      ; Read seconds count
        PSHR    R0              ; Save seconds count on the stack
        MVI     MIN,    R0      ; Read minutes count
        PSHR    R0              ; Save minutes count on the stack
        MVI     HOUR,   R0      ; Read hours count
        EIS                     ; Enable ints (end critical section)

        ;; Step 3:  Display the updated time
        MVII    #2,     R2      ; Set our field width to 2
        MVII    #7,     R3      ; Set our format word to "white"
        MVII    #$200 + 5*20 + 5, R4    ; Start in row 5, column 5

        CALL    PRNUM16.z       ; Display the hours
        INCR    R4              ; Leave a blank space after hours

        PULR    R0              ; Get minutes
        CALL    PRNUM16.z       ; Display the minutes
        INCR    R4              ; Leave a blank space after minutes

        PULR    R0              ; Get seconds
        CALL    PRNUM16.z       ; Display the seconds
        INCR    R4              ; Leave a blank space after seconds

        PULR    R0              ; Get tics
        CALL    PRNUM16.z       ; Display the tics

        ;; Step 4:  Go back to step 1.
        B       @@outer_loop

        ENDP

MYISR   PROC
 
        MVO     R0,     $20     ; Keep screen enabled.
 
        MVI     TIC,    R0      ; Get current tic count into R0
        MVI     SEC,    R1      ; Get current seconds count into R1
        MVI     MIN,    R2      ; Get current minutes count into R2
        MVI     HOUR,   R3      ; Get current hours count into R3
 
        INCR    R0              ; Increment tic count
        CMPI    #60,    R0      ; Tic < 60?
        BLT     @@time_done     ; Yes:  Done updating time
 
        CLRR    R0              ; Reset tic count
        INCR    R1              ; Increment seconds count
        CMPI    #60,    R1      ; Seconds < 60?
        BLT     @@time_done     ; Yes:  Done updating time
 
        CLRR    R1              ; Reset seconds count
        INCR    R2              ; Increment minutes count
        CMPI    #60,    R2      ; Minutes < 60?
        BLT     @@time_done     ; Yes:  Done updating time
 
        CLRR    R2              ; Reset minutes count
        INCR    R3              ; Increment hours count
 
@@time_done:
        MVO     R0,     TIC     ; Store updated tic count
        MVO     R1,     SEC     ; Store updated seconds count
        MVO     R2,     MIN     ; Store updated minutes count
        MVO     R3,     HOUR    ; Store updated hours count
 
        JR      R5
        ENDP

;; =======================================================================
;;  Include SDK-1600 library functions
;; =======================================================================
        INCLUDE     "prnum16.asm"
        INCLUDE     "fillmem.asm"

To assemble the code above, type:

   as1600 -o elapsed -l elapsed.lst elapsed.asm

And that's it.

The cartridge header tells the EXEC to minimize its initialization work and simply run the code that appears immediately after the title string. That code is our initialization code. Once that code runs, it drops into the main program loop.

In the background, the interrupt handler updates the elapsed time with every interrupt from the STIC. This causes the @@wait_time loop in the main program to exit, and the rest of the code to update the displayed time. Wash, rinse, repeat.

Further Things to Contemplate About the Elapsed Time Demo

This timer is a pretty simple piece of code. It's also specific to NTSC systems, insofar as its notion of hours, minutes and seconds are built around 60 tics/second. How would you change the code to keep time correctly on a PAL/SECAM system that generates 50 interrupts per second? How might you detect whether a given system is NTSC or PAL/SECAM at run time?

The timer currently does not display colons between the various time components. Where might you insert code to do this? Is that the best place for this code? Why or why not?

The "tics" display goes by very quickly, so quickly that it's mostly a blur. Typically, humans aren't very concerned about time displays with resolution smaller than a second, except maybe on stopwatches. How would you change this code to only update once a second?

What happens when this program runs for more than 99 hours? More than 256 hours? (That is, other than your Intellivision overheating . . . .) With that in mind, how would you add a days indicator to the program?

The background color behind the timer display is brown. Why is this? How might you change this?

Example 2: A simple "wait timer"

The purpose of this example is to demonstrate how to introduces pauses in a program. The "wait timer" consists of two parts: A short bit of code in the ISR that decrements a count if it's non-zero, and a separate function that sets the count and waits for it to become zero.

The wait-timer serves two purposes:

  • Long waits can space events out. This can be useful if you want to display a message for a moment or otherwise choreograph events.
  • Short waits (such as 1 tic) are useful for waiting to ensure that the screen has updated since changing something that the interrupt handler might have acted on. That is, it's a way of figuring out that an interrupt has occurred.

We'll see an example of the first way of using the wait timer in this example. Future tutorials will reuse this mechanism for both purposes, which is the main reason I'm introducing it here.

The Interrupt Handler Code

The wait timer code can be inserted into any interrupt handler. The code for the wait timer is very simple. It decrements the value of the variable WTIMER if it's non-zero, as shown below:

        MVI     WTIMER, R0      ; Get current timer count
        DECR    R0              ; Decrement it
        BMI     @@expired       ; If it went negative, it's expired
        MVO     R0,     WTIMER  ; Store updated count
@@expired:


This code can be inserted into any ISR easily. Alternately, you can set up many ISRs to call this at the end of their run before exiting. That's entirely up to how you want your program to work. In this example, we'll have a simple ISR that enables the screen, counts down the wait timer, and then exits. The entire ISR is shown below:


;; ======================================================================== ;;
;;  MYISR -- A simple interrupt service routine                             ;;
;; ======================================================================== ;;
MYISR   PROC
        MVO     R0,     $20     ; Enable the display
         
        MVI     WTIMER, R0      ; Get current timer count
        DECR    R0              ; Decrement it
        BMI     @@expired       ; If it went negative, it's expired
        MVO     R0,     WTIMER  ; Store updated count
@@expired:
        
        JR      R5              ; return from interrupt
        ENDP

A more complicated program would clearly have more stuff in its interrupt handler. We'll see this in future tutorials.

The Wait Code

Programs will call the following function to actually wait for something to happen. This function has two entry points. WAIT will wait for the number of tics specified in the word after the CALL. (See the branch tutorial for an explanation of this technique.) The second entry point expects the number of tics to wait to be in R0.

;; ======================================================================== ;;
;;  WAIT -- Wait for some number of tics                                    ;;
;;                                                                          ;;
;;  INPUTS for WAIT                                                         ;;
;;      1 decle after call:  Number of tics to wait                         ;;
;;                                                                          ;;
;;  INPUTS for WAIT.1                                                       ;;
;;      R0  Number of tics to wait                                          ;;
;;                                                                          ;;
;;  OUTPUTS                                                                 ;;
;;      R0  Zeroed                                                          ;;
;;                                                                          ;;
;;  NOTE                                                                    ;;
;;      Requires WTIMER code in ISR, and interrupts enabled.                ;;
;;                                                                          ;;
;; ======================================================================== ;;
WAIT    PROC
        MVI@    R5,     R0          ; Get # of tics to wait from after CALL
@@1:    MVO     R0,     WTIMER      ; Set up wait timer
 
        CLRR    R0                  ; \
@@wait: CMP     WTIMER, R0          ;  |- Wait for WTIMER = 0
        BNEQ    @@wait              ; /
 
        JR      R5                  ; return
        ENDP


The Rest

For this example, we'll display "Hello" and "World" about one second apart in as we did previously in the Hello World Tutorial. We'll also use the an infinite loop. We'll reuse SDK-1600's PRINT and CLRSCR functions. This results in the following code:

MAIN    PROC
 
        CALL    CLRSCR              ; Clear the screen
 
        MVII    #MYISR, R0          ; \
        MVO     R0,     $100        ;  |_ Write out "MYISR" to $100-$101
        SWAP    R0                  ;  |  Double Byte Data.
        MVO     R0,     $101        ; /
        EIS                         ; Make sure interrupts are enabled
 
@@loop:
        ;; Print "Hello" at row #5, column #7.  
        CALL    PRINT.fls
        DECLE   7, $200 + 5*20 + 7
        STRING  "Hello", 0
 
        ;; Wait for 1 second
        CALL    WAIT
        DECLE   60
 
        ;; Print "World" at row #5, column #7.  
        CALL    PRINT.fls
        DECLE   7, $200 + 5*20 + 7
        STRING  "world", 0
 
        ;; Wait for 1 second
        CALL    WAIT
        DECLE   60
 
        ;; Do it again
        B   @@loop
 
        ENDP


There really isn't much to this example, is there?

Putting it All Together

The source listing below puts all the fragments above into a complete program. Mainly, this just adds the cartridge header, the INCLUDE directives to include print.asm and fillmem.asm from SDK-1600, and assigns WTIMER a location in 8-bit memory. The bold portions are the new portions.


WTIMER  EQU     $102    ; Put WTIMER into 8-bit memory

        ROMW    16      ; 16-bit ROM
        ORG     $5000   ; Standard ROM memory map starts at $5000

;----------------------------------------------------------------------------
; EXEC-friendly ROM header.
;----------------------------------------------------------------------------
ROMHDR: BIDECLE ZERO            ; MOB picture base   (points to NULL list)
        BIDECLE ZERO            ; Process table      (points to NULL list)
        BIDECLE MAIN            ; Program start address
        BIDECLE ZERO            ; Bkgnd picture base (points to NULL list)
        BIDECLE ONES            ; GRAM pictures      (points to NULL list)
        BIDECLE TITLE           ; Cartridge title/date
        DECLE   $03C0           ; Flags:  No ECS title, run code after title,
                                ; ... no clicks
ZERO:   DECLE   $0000           ; Screen border control
        DECLE   $0000           ; 0 = color stack, 1 = f/b mode
ONES:   DECLE   1, 1, 1, 1, 1   ; Color stack initialization
;----------------------------------------------------------------------------

TITLE   STRING  $107, "Wait Timer Demo", 0  ; Title string and date (2007)

MAIN    PROC

        CALL    CLRSCR              ; Clear the screen
 
        MVII    #MYISR, R0          ; \
        MVO     R0,     $100        ;  |_ Write out "MYISR" to $100-$101
        SWAP    R0                  ;  |  Double Byte Data.
        MVO     R0,     $101        ; /
        EIS                         ; Make sure interrupts are enabled
 
@@loop:
        ;; Print "Hello" at row #5, column #7.  
        CALL    PRINT.FLS
        DECLE   7, $200 + 5*20 + 7
        STRING  "Hello", 0
 
        ;; Wait for 1 second
        CALL    WAIT
        DECLE   60
 
        ;; Print "World" at row #5, column #7.  
        CALL    PRINT.FLS
        DECLE   7, $200 + 5*20 + 7
        STRING  "world", 0
 
        ;; Wait for 1 second
        CALL    WAIT
        DECLE   60
 
        ;; Do it again
        B   @@loop
 
        ENDP
 
;; ======================================================================== ;;
;;  MYISR -- A simple interrupt service routine                             ;;
;; ======================================================================== ;;
MYISR   PROC
        MVO     R0,     $20     ; Enable the display
         
        MVI     WTIMER, R0      ; Get current timer count
        DECR    R0              ; Decrement it
        BMI     @@expired       ; If it went negative, it's expired
        MVO     R0,     WTIMER  ; Store updated count
@@expired:
        
        JR      R5              ; return from interrupt
        ENDP
 
;; ======================================================================== ;;
;;  WAIT -- Wait for some number of tics                                    ;;
;;                                                                          ;;
;;  INPUTS for WAIT                                                         ;;
;;      1 decle after call:  Number of tics to wait                         ;;
;;                                                                          ;;
;;  INPUTS for WAIT.1                                                       ;;
;;      R0  Number of tics to wait                                          ;;
;;                                                                          ;;
;;  OUTPUTS                                                                 ;;
;;      R0  Zeroed                                                          ;;
;;                                                                          ;;
;;  NOTE                                                                    ;;
;;      Requires WTIMER code in ISR, and interrupts enabled.                ;;
;;                                                                          ;;
;; ======================================================================== ;;
WAIT    PROC
        MVI@    R5,     R0          ; Get # of tics to wait from after CALL
@@1:    MVO     R0,     WTIMER      ; Set up wait timer
 
        CLRR    R0                  ; \
@@wait: CMP     WTIMER, R0          ;  |- Wait for WTIMER = 0
        BNEQ    @@wait              ; /
 
        JR      R5                  ; return
        ENDP
 
;; ======================================================================== ;;
;;  Library includes                                                        ;;
;; ======================================================================== ;;
        INCLUDE     "fillmem.asm"
        INCLUDE     "print.asm"

Copy this out to a file, wtdemo.asm, along with copies of print.asm and fillmem.asm that were linked to above. Then assemble with:

   as1600 -o wtdemo -l wtdemo.lst wtdemo.asm

Ta da! When you run this program, you should get a display that alternates between "Hello" and "World" in the middle of the screen with a 1 second delay between each.