Skip to content

Part 5 : Take control of the Amiga hardware

MKinght73 edited this page Feb 14, 2022 · 2 revisions

Amiga has a fairly sophisticated operating system that provides the programmer with all the tools to use the graphics and sound capabilities. However, historically, very few games have made use of the operating system (O.S.). Virtually all commercial games that were developed for the Amiga bypassed the operating system and took direct control of the hardware and were written in assembly language. The reason is simple: in this way it is possible to get the maximum performance from the hardware. Since we want to create a video game in assembly, programming the Amiga hardware directly, we have to take control of the Amiga hardware, stopping the O.S. in a controlled manner. Before starting to write code, we will introduce some theoretical concepts necessary for a full understanding of the code we will write.

Interrupts

An interrupt is a signal that tells the CPU to stop executing the current program and execute an interrupt handling routine. At the end of this routine, the program resumes where it was interrupted. Interrupts are usually generated by external devices such as: custom chip, disk, serial ports etc ... It is a useful mechanism for communicating asynchronously. In its absence, the cpu should do a loop in which it checks the status of external devices (polling). The MC68000 cpu has 7 interrupt levels, ranging from 1 (lowest priority) to 7 (highest priority). The Amiga chipset uses only the lowest 6 levels. An interrupt routine can itself be interrupted by a higher level (and higher priority) interrupt. How does the cpu know which interrupt handling routine to call? There is a table containing pointers to these routines, based on interrupt levels. Pointers to interrupt handling routines are called autovectors (they have a different meaning from the mathematical one). The structure of the interrupt autovectors table is as follows:

Offset Description
$64 Level 1 Interrupt
... ...
$78 Level 6 Interrupt

The base address of this table is given by the VBR register of the cpu. The original MC68000 cpu doesn't have a VBR register, so the base address is zero. When an interrupt is generated, the interrupt controller will set a flag in a register called INTREQ. If the corresponding interrupt level is enabled in another register called INTENA, then the controller will continue processing the interrupt and will call the interrupt handler routine. This routine will read the INTREQR register to determine which interrupt needs to be serviced. The handler will then clear the interrupt by clearing the interrupt's bit int the INTREQ register. The action of clearing the interrupt informs the controller that the interrupt is being handled. The following table summarizes the interrupt registers:

Name Type Offset Function
INTENAR R $01C Interrupt enable read
INTREQR R $01E Interrupt request read
INTENA W $09A Interrupt enable
INTREQ W $09C Interrupt reques

All the four registers use the format shown below:

BIT# Name Description
15 SET/CLR Set to 1 to set bits, se to 0 to clear bits (INTENA and INTREQ only)
14 INTEN When 0 all interrupts are disabled (INTENA and INTREQ only)
13 EXTER External Interrupts
12 DSKSYN Disk sync pattern matched
11 RBF Serial receiver buffer full
10 AUD3 Finished reading of a block of data from audio channel 3
9 AUD2 Finished reading of a block of data from audio channel 2
8 AUD1 Finished reading of a block of data from audio channel 1
7 AUD0 Finished reading of a block of data from audio channel 0
6 BLIT Indicates that the Blitter has finished
5 VERTB Indicates that the raster beam has reached line 0 (vertical blank)
4 COPER Interrupt generated by Copper
3 PORTS Interrupt generated by the I/O ports
2 SOFT Software generated interrupt
1 DSKBLK End of transferring a block of data from disk
0 TBE Serial port buffer empty

DMA

DMA is an acronym for "Direct Memory Access". We know that both the custom chips and the cpu can access the Amiga chip memory. To increase the level of parallelism of the Amiga, the designers have created channels that allow the custom chips to access the memory directly, without the help of the cpu, leaving it free to do other things. This is one of the strengths of the Amiga architecture. Access to the DMA channels is regulated by a "DMA Controller" present in the Agnus chip. DMA channels can be enabled or disabled via the write-only DMACON register ($dff096). There is a DMACONR read-only register ($dff002) to read the status of these channels. The following table shows the meaning of the bits of these registers:

BIT# Name Description
15 SET/CLR If it is 1 then the bits at 1 indicate enabling, if it is 0 then they indicate disabling
14 BlitBusy read-only, indicates that the Blitter is busy
13 BlitZero
12 X not used
11 X not used
10 BlitPri Blitter Priority
9 Master General switch for enabling all DMA channels
8 BPLEN DMA channel for bitplanes
7 COPEN Copper DMA channel
6 BLTEN Blitter DMA channel
5 SPREN Sprites DMA channel
4 DSKEN Disk DMA channel
3 AUD3EN DMA channel for voice 3 of the audio
2 AUD2EN DMA channel for voice 2 of the audio
1 AUD1EN DMA channel for voice 1 of the audio
0 AUD0EN DMA channel for voice 0 of the audio

Amiga Operating System and libraries

The O.S. of Amiga, called AmigaOS and contained in a ROM memory called Kickstart, is composed of various libraries, which are not all present in memory at the same time, but are loaded dynamically. Its main component is called Exec and performs the functions of task scheduler, memory management, interrupt management, inter-process communication through messages, loading of dynamic libraries. At memory location $4, called ExecBase, there is the pointer to the library functions. In order to use a dynamic library, it must first be opened using the Exec's OpenLibrary function. This function returns a pointer to the base address of the library, which must be used to call all functions of the library itself. When you stop using a library, you need to close it using the CloseLibrary function.

Organization of the code

Before you start writing some code, you need to define a structure that will be followed throughout the entire tutorial series. To facilitate the reuse and maintainability of the code, it is advisable to divide it into separate files, which will contain the routines to manage a certain functionality. Then we will have a main file which will contain the main loop and will call the various modules. In addition, the definition of constants, macros is grouped into include files with the extension ".i".

Avoid multiple inclusions

When using include files for constants, it may happen that you include the same file multiple times. To avoid multiple inclusions, with consequent assembler errors, we use the IFND assembler directive which assembles the following code only if the constant specified as a parameter has not already been defined. The second line defines this HARDWARE_I constant so that, at a second inclusion of the file, the code following IFND is not included. At the end of the file must be inserted the ENDC instruction which indicates the end of the code subject to the IFND clause. An example of an include file is as follows:

                IFND    HARDWARE_I
HARDWARE_I      SET	    1

; constants and macros definitions

ENDC

Structure of a module

A typical library module will have the following structure:

  • inclusion of constants, macros, data structures
  • definition of variables
  • routines exposed publicly
  • private routines, called only internally to the module

Code documentation

It is a good idea to properly document the source code. To do this we will define some conventions. Each section will be delimited by a heading made like this:

;***************************************************************************
; SECTION NAME
;***************************************************************************

Each routine will be preceded by the following header:

;***************************************************************************
; Routine explanation
;
; Input:
; <register.size> = parameter description
;
; Output:
; <register.size> = value
;***************************************************************************
routine_name:
    istructions     ; comment
    rts

The comments explaining the operations performed will be placed on the same line as the instructions, separated by a tab.

take_system implementation

We will now write a routine that allows our program to take complete control of the Amiga hardware. It's the first routine to call in a video game, we'll call it take_system. What should this routine do? The main goal is to allow the exclusive use of hardware resources to our program, in particular the cpu, by stopping the operating system in a controlled way. This means that when our game will end, the control will return to Workbench without crashing the Amiga. We summarize the operations to be performed by the routine that takes control of the hardware in the following pseudo-code:

take_system:
    reset video mode
    disable O.S. multitasking
    disable O.S. interrupts
    disable all system interrupts
    disable all DMA channels 

Reset the video mode

The first operation we want to do is reset the video mode, in order to have a "clean" state of the registers of the custom chips. To do this, we will use the Amiga operating system. Before any function of Amiga O.S. can be used, the corresponding library must be loaded into memory, in this case we will use the "graphics.library". This operation is carried out with the "OpenLibrary" Exec function, which requires as input a string with the name of the library in a1. In output it returns a pointer to the base address of the library opened in d0. We will save the base address of the graphics.library in a variable for future use. The functions of the Exec are called by specifying offsets with respect to a base, which is located at the memory address $4, called EXEC_BASE.

    move.l  EXEC_BASE,a6            ; base address of Exec library
    lea     gfx_name(PC),a1         ; name of the library to open
    jsr     OpenLibrary(a6)         ; opens graphics.library of O.S.
    move.l  d0,gfx_base             ; saves base address of graphics.library

To reset the video mode, we need to call the function LoadView. As a parameter it expects a pointer to a structure that describes the view in a1. In case you want to reset the video mode, a1 must be zero. Before resetting the video mode, it is necessary to save the current value of the view in a variable, so that it can then be restored when the program is exited. After resetting the video mode, we wait for a couple of vertical blanks, using the WaitOf function, to be sure that the view is active. The vertical blank is the signal generated when the raster beam has finished drawing the screen.

    move.l  gfx_base(PC),a6         ; base address of graphics.library in a6
    move.l  $22(a6),wb_view         ; saves current view
    sub.l   a1,a1                   ; null view to reset video mode
    jsr     LoadView(a6)            ; resets video mode
    jsr     WaitOf(a6)              ; waits a vertical blank
    jsr     WaitOf(a6)

Disable multitasking and interrupts of the O.S.

The second operation to do is to disable multitasking and interrupts of the operating system. Multitasking is a technique that splits the cpu time between multiple processes. An operating system component, called Scheduler, assigns the CPU to processes for a given amount of time. This gives the user the impression that it is possible to run multiple processes simultaneously. Since we are making a video game, we want to avoid giving the cpu to other processes, for this reason we disable the multitasking functionality of the operating system. But it is still not enough, in fact the cpu could be stolen from our game also by the execution of the operating system interrupts. To avoid this, we disable the interrupts generated by the operating system. To do this we use the functions of Exec Forbid, which disables multitasking and Disable, which disables interrupts. In this way we are sure that our video game will use all the available CPU time, avoiding giving it to other processes or interrupts management routines.

    move.l  EXEC_BASE,a6            ; base address of Exec library
    jsr     Forbid(a6)              ; disable O.S. multitasking
    jsr     Disable(a6)             ; disable O.S. interrupts

Disable system interrupts and DMA channels

The third operation to do is the most critical one, in fact it is the one that disables all system interrupts and all DMA channels. Disabling system interrupts prevents the game from any interruptions that could steal CPU cycles. Disabling DMA channels avoids wasting DMA cycles for unwanted functions. We will see that in the initialization phase of the game we will have to re-enable only the DMA channels that we will use. Before disabling anything, let's save the state of the registers in appropriate variables: old_intena, old_intreq, old_adkcon, old_dma. So we disable the interrups by writing to the INTENA register. And then we disable the DMA channels by writing to the DMACON register. The following code snippet implements the above:

    lea     CUSTOM,a5               ; base address of custom chips registers
    move.w  INTENAR(a5),old_intena  ; save interrupts state
    move.w  INTREQR(a5),old_intreq
    move.w  ADKCONR(a5),old_adkcon  ; save ADKCON
    move.w  #$7fff,INTENA(a5)       ; disable all interrupts
    move.w  #$7fff,INTREQ(a5)
    move.w  DMACONR(a5),old_dma     ; saves state of DMA channels
    move.w  #$7fff,DMACON(a5)       ; disables all DMA channels

Definition of variables

Now let's see how to define some variables in assembly language. We will start by defining a variable of type string containing the name of the library we want to load, that is the "graphics.library". To do this, we use the Assembler directive dc.b, which defines a memory block containing byte-type constants, that is a string. Be careful that an assembly string must always be terminated by byte 0. Furthermore, the MC68000 cpu needs addresses aligned to 16 or 32 bits and therefore the addresses of the variables must be aligned to 16 bits. For this we use the EVEN directive which ensures that the next label will start to an even address, adding bytes cleared to zero. To define variables of the word or long type, it is sufficient to use the dc.w (for words) or dc.l (for longs) directive of the Assembler, which defines a variable of the word or long type, at 16 or 32 bits , and allows you to assign its initial value. The following code snippet shows the definition of the variables:

    ;***************************************************************************
    ; VARIABLES
    ;***************************************************************************

    gfx_name        dc.b    "graphics.library",0    ; name of graphics.library of Amiga O.S.
                    even
    gfx_base        dc.l    0                       ; base address of graphics.library
    old_dma         dc.w    0                       ; saved state of DMACON
    old_intena      dc.w    0                       ; saved value of INTENA
    old_intreq      dc.w    0                       ; saved value of INTREQ
    old_adkcon      dc.w    0                       ; saved value of ADKCON
    return_msg      dc.l    0
    wb_view         dc.l    0

release_system implementation

At the end of our program, we will have to return control of the hardware to the Amiga O.S., so that you can return to the Workbench without locking the machine. To do this we will write a second routine, which we will call release_system. Let's see what this routine will have to do. In practice, it will have to carry out the reverse of the operations performed by take_system.
We summarize the operations that our routine must perform in the following pseudo-code:

release_system:
    restore saved DMA channels
    restore saved interrupts state
    enable O.S. multitasking
    enable O.S. interrupts
    restore saved view
    restore system copperlists
    close graphics.library

Above we have introduced the concept of copperlist, which is nothing more than a program for the Copper coprocessor, containing instructions for displaying the screen. Since our game will use its own copperlist, which we will define in the future, at the end we have to restore the system ones.

The first step is to restore the DMA channels to their previous state. To set the DMA channels, bit 15 of the DMACON register must be set. This is done with the OR statement. At this point it is possible to write the value previously saved in the DMACON register.

    lea     CUSTOM,a5               ; base address of custom chips registers
    or.w    #$8000,old_dma          ; sets bit 15
    move.w  old_dma,DMACON(a5)      ; restores saved DMA state

The second operation is to restore the state of the interrupts. Before modifying the interrupt handling registers, they must all be disabled. Then we set bit 15 in the saved values, in order to be able to set these values ​​in the write registers. Then we restore the values ​​saved in the black variables INTENA, INTREQ and ADKCON registers.

    move.w  #$7fff,INTENA(a5)       ; disable all interrupts
    move.w  #$7fff,INTREQ(a5)
    move.w  #$7fff,ADKCON(a5)       ; clears ADKCON
    or.w    #$8000,old_intena       ; sets bit 15
    or.w    #$8000,old_intreq
    or.w    #$8000,old_adkcon
    move.w  old_intena,INTENA(a5)   ; restores saved interrupts state
    move.w  old_intreq,INTREQ(a5)
    move.w  old_adkcon,ADKCON(a5)   ; restores old value of ADKCON

At this point we re-enable multitasking by recalling the Exec Permit function and subsequently the interrupts of the S.O. via the Enable function.

    move.l  EXEC_BASE,a6
    jsr     Permit(a6)              ; enables O.S. multitasking
    jsr     Enable(a6)              ; enables O.S. interrupts

Now, we just have to restore the view to the state prior to resetting the video mode. To do this we set the pointer to the view saved in a1 and call the LoadView function of the graphics.library. Be careful to load the pointer to the base address of the graphics.library in a6.

    move.l  gfx_base,a6             ; base address of graphics.library
    move.l  wb_view,a1              ; saved workbench view
    jsr     LoadView(a6)            ; restores the workbench view

The next step is to restore the system copperlists. The default values ​​are found in the data structure of the graphics.library itself, at the offsets given by the constants sys_cop1 ($26) and sys_cop2 ($32). These values ​​must be entered in the COP1LC and COP2LC registers of the custom chips, paying attention to load a5 with the base address of the CUSTOM custom registers ($dff000).

    move.l  gfx_base,a1             ; base address of graphics.library
    move.l  sys_cop1(a1),COP1LC(a5) ; restores the system copperlist 1
    move.l  sys_cop2(a1),COP2LC(a5) ; restores the system copperlist 2

The last step is to close the graphics.library using the Exec's CloseLibrary function.

    jsr     CloseLibrary(a6)        ; closes graphics.library

Main loop implementation

The main flow of our program will be implemented in the "main.s" file. We will initially find the include files. So there will be an initialization section where there will be the call to take_system to take control of the hardware. At this point we will enter the "main loop" that is the main cycle of the program. For the moment we will just check if the left mouse button is pressed, in which case it exits the loop. At the exit of the loop we will call the release_system to give back control of the hardware to the S.O. and we will finish the program. At the end we find the inclusion of the various modules, for the moment only the "hw_takeover.s" module. Below is the source code of the main.s module:

;***************************************************************************
; MAIN
;***************************************************************************

    include "hardware.i"

main:
    lea     CUSTOM,a5               ; base address of custom chips
    bsr     take_system
    
mainloop:
    btst    #6,CIAAPRA              ; if left mouse button is pressed, exits
    bne.s   mainloop

    bsr     release_system
    rts
    
    include	"hw_takeover.s"

Execution and conclusions

At this point you can try to assemble the source code. If we are using AsmOne or AsmPro, just type the command "a". If there are no compilation errors, you can run the code. To do this, with AsmOne / AsmPro we will use the "j" command. We will only see a black screen. Pressing the left mouse button will take you back to the assembler screen. The result of so much programming effort isn't very exciting at the moment, but everything works as expected. In the next installments we will start adding code to implement our video game and the result of the code execution will start to be more rewarding, because images will be displayed and then you can interact with them.

The full source code is available here.