Introducing Interrupts
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.
Contents
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:
- Interrupts are enabled
- 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:
- 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.
- 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.
- 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.
- 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:
- Update STIC registers
- Update the GRAM
- Update any music and/or sound effects
- Update any timers or counters that keep track of time
- Update object positions based on their "velocity"
- 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 its
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.
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 Using Interrupts: A simple timer
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 timer onscreen 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:
- Increment the number of tics per second. If that number is less than 60, then we're done updating the time so skip to the end.
- 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.
- 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.
- 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:
-
Wait for the time to change. We can do this by waiting for the
value of
TIC
to change. - 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.
- Print out the various time components: Hours, Minutes, Seconds and Tics.
- 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 timer 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 timer.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, "Timer 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 timer -l timer.lst timer.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 time every timer tic.
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
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?