[index] [home]

Tweet

64tass learn-by-example



This is a visually boring page about my effort reading the 64tass (also called "tass64") 65xx cross-assembler's reference manual (currently covering v1.51 r992), and translating it into something like a CheatSheet with many examples.

This page is by no means a substitute, and only covers a small subset of the reference manual.

About examples on this page

Each example here consist of a code-snippet, which is then assembled. The result of assembly is then given in various forms (machine-code, mnemonics, source and hex-dump), as shown here:

Original source-snippets are only explicitly given if they contain assembler-directives (such as macros, functions or sections) that are resolved during during assembly (i.e. would not be visible in the result).

Unless stated otherwise, the following GNUmakefile takes care of assembly:

    all: x.asm
            tass64 -q -B -b -L x.lst --tab-size=1 -o x.bin x.asm

Each example's output is generated from list- and binary files as follows:

    make &&
    ( sed '/^;/d;/^$/d' x.lst;
      perl -e 'print "-"x80 . "\n"';
      hexdump -C x.bin ) | less

(This removes comment from the list-file, and appends a canonical hex-dump of the image.)

Some useful command-line options

In the GNUmakefile-snippet, the following 64tass command-line options are used:

Specifying data to be stored in the image

Immediate vs 8-/16-bit direct operands in hex, decimal and binary

Numeric operands can be immediate or direct values (e.g. literal constant vs address), depending on the opcode. Immediate values are prefixed with a "#"; direct values have no prefix.

Furthermore, numeric operands can be prefixed with "$" or "%" for hex, respectively binary literals. Missing prefix corresponds to decimal value.

64tass will normally use the width of numeric operand(s) to determine the opcode to use. (E.g. use of a zeropage-address results in different opcode than using non-zeropage address.) You can explicitly specify 16-bit operand width by prefixing it with "@w".

Example showing all of the above:

    .0000            a9 10          lda #$10        lda #16     ; immediate, decimal
    .0002            a9 10          lda #$10        lda #$10    ; immediate, hex
    .0004            a9 10          lda #$10        lda #%10000 ; immediate, binary
    .0006            ad 00 c0       lda $c000       lda 49152   ; direct, 16-bit address
    .0009            a5 10          lda $10         lda 16      ; direct, zeropage-address (8-bit)
    .000b            ad 10 00       lda $0010       lda @w16    ; direct, forced 16-bit address
    --------------------------------------------------------------------------------
    00000000  a9 10 a9 10 a9 10 ad 00  c0 a5 10 ad 10 00        |..............|
    0000000e

Storing simple literal data

You can emit literal bytes, words, text, nul-terminated text, and other formats using a number of directives:

    >0000            01 02 03                           .byte 1, 2, 3   ; explicit byte-values
    >0003            01 00 34 12                    foo .word 1, $1234  ; explicit word-values, little-endian
    >0007                                               .byte ?         ; uninitialised data (or zero'd in ROM-image)
    >0008            66 6f 6f                           .text "foo"     ; non-nul-terminated string
    >000b            66 6f 6f 00                        .null "foo"     ; nul-terminated string
    >000f            03                                 .byte <foo      ; lo-byte of symbol 'foo'
    >0010            00                                 .byte >foo      ; hi-byte of symbol 'foo'
    --------------------------------------------------------------------------------
    00000000  01 02 03 01 00 34 12 00  66 6f 6f 66 6f 6f 00 03  |.....4..foofoo..|
    00000010  00                                                |.|
    00000011

Note that "?" is used for uninitialised data (i.e. only to reserve space in the image), but when outputting a ROM-image, this data occurs as zeroes.

Store generated data

The range-directive is very powerful when generating data. Together with a variety of built-in functions like sin(), 64tass can generate complex tables.

    >0000            03 05 07 09                    .byte range( 3, 10, 2 ) ; format: "range( start, end, step )"
    --------------------------------------------------------------------------------
    00000000  03 05 07 09                                       |....|
    00000004

Tuples and lists are related mechanisms, not covered here.

Including assembly-source or raw data

Assembly-source can be included in 2 similar ways; furthermore, raw binary data can be included.

Example:

    .include "inc.inc"  ; simple include; possible symbol-clash                                                         
    .binclude "inc.inc" ; include using local block/namespace
    .binary "raw.raw"   ; include raw data as-is

Contents of "inc.inc":

    lda #10

Hex-dump of "raw.raw":

    00000000  00 00 00                                          |...|
    00000003

Resulting ROM-image:

    .0000            a9 0a          lda #$0a        lda #10
    .0002            a9 0a          lda #$0a        lda #10
    >0004            00 00 00                       .binary "raw.raw"   ; include raw data as-is
    --------------------------------------------------------------------------------
    00000000  a9 0a a9 0a 00 00 00                              |.......|
    00000007

The .binary-directive is very powerful. You can for example choose to only include specific sections from a raw binary image.

Assembly-time repetition

There are generally 2 mechanisms for assembly-time repetition: .rept and .for.

Example:

    .rept 3
            nop
    .next

    .for var = 2, var < 10, var = var + 2                                                                               
            lda #var
            sta $d020
    .next

Result:

    .0000            ea             nop                     nop
    .0001            ea             nop                     nop
    .0002            ea             nop                     nop
    .0003            a9 02          lda #$02                lda #var
    .0005            8d 20 d0       sta $d020               sta $d020
    .0008            a9 04          lda #$04                lda #var
    .000a            8d 20 d0       sta $d020               sta $d020
    .000d            a9 06          lda #$06                lda #var
    .000f            8d 20 d0       sta $d020               sta $d020
    .0012            a9 08          lda #$08                lda #var
    .0014            8d 20 d0       sta $d020               sta $d020
    --------------------------------------------------------------------------------
    00000000  ea ea ea a9 02 8d 20 d0  a9 04 8d 20 d0 a9 06 8d  |...... .... ....|
    00000010  20 d0 a9 08 8d 20 d0                              | .... .|
    00000017

Note that when using .for, the iteration-clause ("var = var + 2" in the above example) must have that exact form - "<var> = ..."; you cannot use constructs like "<var> -= 2".

Passing symbol-values from command-line

It makes sense to pass symbol-values from the build-script to the assembler. This can be done with the "-D" command-line option.

Example:

    lda #colour
    sta address                                                                                                     

Command-line:

    tass64 ... -D colour=12 -D 'address=$d020' ...

...or when used from a Makefile (note the double "$"):

    tass64 ... -D colour=12 -D 'address=$$d020' ...

Result:

    =12                                             colour=12
    =$d020                                          address=$d020
    .0000            a9 0c          lda #$0c            lda #colour
    .0002            8d 20 d0       sta $d020           sta address
    --------------------------------------------------------------------------------
    00000000  a9 0c 8d 20 d0                                    |... .|
    00000005

Filling data-regions

Instead of having to use e.g. ".byte" repeatedly, you can fill regions of image-memory using the ".fill"- directive:

    >0000            00 00 00                       .fill 3, 0
    >0003            0b 0b 0b 0b                    .fill 4, 11
    >0007            55 aa 55 aa 55                 .fill 5, [ $55, $aa ]
    --------------------------------------------------------------------------------
    00000000  00 00 00 0b 0b 0b 0b 55  aa 55 aa 55              |.......U.U.U|
    0000000c

Note that when filling with a pattern (like "$55, $aa, $55, ..." above), the pattern is emitted until the desired region is filled. Thus, like above, the last pattern might be emitted partially.

In the previous example, the first .fill-directive explicitly specified zero-bytes.

When not explicitly specifying values for the first .fill-directive in the image, in combination with the -b ("nostart") command-line option, the initial fill-region will be omitted from the image, since the nostart-option strips these:

    >0000                                           .fill 3
    >0003            0b 0b 0b 0b                    .fill 4, 11
    >0007            55 aa 55 aa 55                 .fill 5, [ $55, $aa ]
    --------------------------------------------------------------------------------
    00000000  0b 0b 0b 0b 55 aa 55 aa  55                       |....U.U.U|
    00000009

However, they are emitted when using the -f ("flat") command-line option:

    >0000                                           .fill 3
    >0003            0b 0b 0b 0b                    .fill 4, 11
    >0007            55 aa 55 aa 55                 .fill 5, [ $55, $aa ]
    --------------------------------------------------------------------------------
    00000000  00 00 00 0b 0b 0b 0b 55  aa 55 aa 55              |.......U.U.U|
    0000000c

The ".align"-directive is similar to .fill in a way: it pads until the next alignment-boundary.

Specifying logical/physical location of code and data

In the reference-manual, the ROM-offset from start-of-image is called compile-offset, which may not always be the same as the corresponding program-counter (PC).

Consider for example a 1 kb ROM-image, mapped at $c000 (where compile-offset '0' corresponds to PC '$c000'), or code intended for relocation (where the location of the code after relocation may be very different from its original location/offset in the ROM-image).

"*"-symbol as PC

The *-symbol holds the current PC:

    .0000            a5 00          lda $00         lda *
    .0002            a6 02          ldx $02         ldx *
    .0004            a4 04          ldy $04         ldy *
    --------------------------------------------------------------------------------
    00000000  a5 00 a6 02 a4 04                                 |......|
    00000006

This is the same as referencing explicit labels:

    .0000            a5 00          lda $00             a   lda a
    .0002            a6 02          ldx $02             x   ldx x
    .0004            a4 04          ldy $04             y   ldy y
    --------------------------------------------------------------------------------
    00000000  a5 00 a6 02 a4 04                                 |......|
    00000006

Adjust both PC and compile-offset: assigning to "*"-symbol

Assigning to the *-symbol adjusts the compile-offset so that the next instruction to be assembled corresponds to the address given in the assignment.

To put it another way: at any time, compile-offset and PC differ by a distance N (which is not necessarily 0). Assigning to the *-symbol adjusts the compile-offset so that the PC gets the value given in the assignment. (Thus, compile-offset is adjusted by the same value as is the PC.)

Example:

            lda *

    * = $10
            ldx *

    * = $8                                                                              

            ldy *

Result:

    .0000            a5 00          lda $00                 lda *
    .0010            a6 10          ldx $10                 ldx *
    .0008            a4 08          ldy $08                 ldy *
    --------------------------------------------------------------------------------
    00000000  a5 00 00 00 00 00 00 00  a4 08 00 00 00 00 00 00  |................|
    00000010  a6 10                                             |..|
    00000012

Note that the "lda"-opcode ($a5) occurs first in the image, at image-offset $0000, then "ldy"-opcode ($a4) at offset $0008, and finally the "ldx"-opcode ($a6) at offset $0010.

(Nothing out of the ordinary in this image, in that compile-offsets still correspond to PC-values at all times.)

Temporarily adjust PC only: ".logical" ... ".here"

For e.g. relocatable code, it is useful to not have compile-offset correspond to PC.

In the following (convoluted) example, the "lda"-instruction is assumed to run at address 0, and is located in the ROM-image at offset 0.

However, the "ldx"-instruction is assumed to run at $1000, but is located in the ROM-image at offset 2, right after the "lda"-instruction.

The "ldy"-instruction once again has corresponding PC and compile-offset.

Example:

            lda *                                                                       

    .logical $1000
            ldx *
    .here

            ldy *

Result:

    .0000            a5 00          lda $00                 lda *
    .0002   1000     ae 00 10       ldx $1000               ldx *
    .0005            a4 05          ldy $05                 ldy *
    --------------------------------------------------------------------------------
    00000000  a5 00 ae 00 10 a4 05                              |.......|
    00000007

(This makes more sense for actual relocatable code; the contents of a .logical ... .here block would be copied to another address, and then executed there.)

Combination: example-ROM mapped at $c000

Another (equally convoluted) example is given below, both assigning to the *-symbol and adjusting the PC using a .logical ... .here block. The lines indicated with (A) ... (D) will be discussed below.

Example:

    * = $c000

            lda *   ; (A)

    .logical $1000

            ldx *   ; (B)

    * = $1200                                                                           

            ldy *   ; (C)
    .here

            lda *   ; (D)

The resulting image-data differs between choosing nostart (-b) or flat (-f) output-modes; each result is discussed in turn.

(In the displayed result, the leftmost column above the dashed line indicates compile-offset, while the column next to it indicates PC-value. In case PC-value is not given (as per default), it is identical to the compile-offset.)

In case of "flat" output-mode

When using the flat output-mode (-f), a region of $c000 bytes is allocated at the start of the image, thereby making compile-offset match the PC-value ($c000) when the instruction at line (A) is assembled.

When line (B) is being assembled, the PC has been adjusted to $1000 using the .logical-directive. However, the compile-offset is not altered. Therefore, the instruction from line (B) occurs in the ROM-image right after the instruction from line (A).

Within the .logical ... .here region, there is a new difference N between compile-offset and PC, namely $c003 (compile-offset) - $1000 (PC).

When, within that same region, the -symbol is assigned to once more - now to $1200, the compile-offset is adjusted so that the corresponding PC gets the value given in that assignment, being $1200. The same semantics apply as with section "Adjust both PC and compile-offset: assigning to -symbol", above.

The desired new PC-value is $1200; it was $1003 after assembling line (B) (since a 3-byte opcode was emitted since the previous assignment). Therefore, the PC has to be incremented by $1200 - $1003, and in order to do so, so has the compile-offset. Thus, about $200 bytes are thus skipped in the ROM-image.

Between lines (C) and (D), the difference between compile-offset and PC reverts to its value from before the .logical ... .here block. This difference was 0 (compile-offset equaled PC-value). The compile-offset is not altered when entering or leaving a .logical ... .here block, and therefore, PC reverts back to compile-offset, the way it was prior to entering the block.

This can be seen at the "lda"-instruction from line (D) being located at compile-offset $c206, and corresponding to PC-value $c206 as well ("lda *" assembles to "lda $c206").

Result (flat output):

    .c000            ad 00 c0       lda $c000               lda *
    .c003   1000     ae 00 10       ldx $1000               ldx *
    .c203   1200     ac 00 12       ldy $1200               ldy *
    .c206            ad 06 c2       lda $c206               lda *
    --------------------------------------------------------------------------------
    00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
    *
    0000c000  ad 00 c0 ae 00 10 00 00  00 00 00 00 00 00 00 00  |................|
    0000c010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
    *
    0000c200  00 00 00 ac 00 12 ad 06  c2                       |.........|
    0000c209

In case of "nostart" output-mode

Now, when using the nostart-output option (-b), leading undefined bytes are omitted from the ROM-image. Therefore, the leading $c000 bytes are missing. This may be a bit less intuitive, but it's mostly what one wants: useful contents start at ROM-image 0, but are mapped to non-zero when deployed in a system.

Result (nostart-output):

    .c000            ad 00 c0       lda $c000               lda *
    .c003   1000     ae 00 10       ldx $1000               ldx *
    .c203   1200     ac 00 12       ldy $1200               ldy *
    .c206            ad 06 c2       lda $c206               lda *
    --------------------------------------------------------------------------------
    00000000  ad 00 c0 ae 00 10 00 00  00 00 00 00 00 00 00 00  |................|
    00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
    *
    00000200  00 00 00 ac 00 12 ad 06  c2                       |.........|
    00000209

Note that omitting these leading bytes is an artifact of the output mode - the list-file (the region above the dashed line) is identical for both output-modes!

Sections

Instead of having to specify compile-offset and PC when writing code, location and code-contents can be decoupled by making use of named sections, using the .section ... .send directive.

Contents of section-fragments (thus, code contained therein) with the same name will be concatenated, and given a compile-offset and PC-value defined in a .dsection-directive ("dump section"..?) with the same section-name.

Example:

    .section bank1
            .byte $10
    .send

    .section bank2
            .byte $20
    .send

    .section bank1
            .byte $11
    .send

    .section bank2
            .byte $21                                                                                                   
    .send

    .dsection bank1
    * = $10
    .dsection bank2

Result:

    >0000            10                                     .byte $10
    >0010            20                                     .byte $20
    >0001            11                                     .byte $11
    >0011            21                                     .byte $21
    --------------------------------------------------------------------------------
    00000000  10 11 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
    00000010  20 21                                             | !|
    00000012

Note that although not given in that order in the source, all code in section-fragments named "bank1" is located from $0000 onwards, while all code in section-fragments named "bank2" is located from $0010 onwards.

.dsection-directives can be nested within .section ... .send directives.

Miscellaneous

Position of labels in source

Labels (symbols corresponding to the logical address / PC of a code- or data-item) can be placed in front of the corresponding item, or above it. (In the latter case, the assembler warns if labels are not situated at the leftmost column in the line.)

    .0000                                           foo
    .0000            a9 10          lda #$10            lda #16
    .0002            a9 10          lda #$10        bar lda #16
    --------------------------------------------------------------------------------
    00000000  a9 10 a9 10                                       |....|
    00000004

Scopes

Some constructs create a scope for symbols, avoiding symbol-clashes:

Symbol-lookup happens from inner-most to outer-most (global) scope and stops when a match is found. Thus, it is possible for inner scopes to shadow symbols defined in outer scopes.

Macros

Macros are parameterised blocks of code, to be instantiated elsewhere.

Values can be passed through positional or named parameters (such as in this example). Parameters can have default values by specifying these defaults in the macro-definition.

Macros can be instantiated by prefixing the macro-name with either "#" or ".".

Example:

    my_init .macro a, x=10, y=20
            lda #\a
            ldx #\x
            ldy #\y
            .endm                                                                                                       

            .my_init 1, 2, 3    ; explicitly specify all arguments

            nop

            #my_init 4          ; use some default args

Result:

    .0000            a9 01          lda #$01                lda #1
    .0002            a2 02          ldx #$02                ldx #2
    .0004            a0 03          ldy #$03                ldy #3
    .0006            ea             nop                     nop
    .0007            a9 04          lda #$04                lda #4
    .0009            a2 0a          ldx #$0a                ldx #10
    .000b            a0 14          ldy #$14                ldy #20
    --------------------------------------------------------------------------------
    00000000  a9 01 a2 02 a0 03 ea a9  04 a2 0a a0 14           |.............|
    0000000d

Custom functions

Functions are meant for frequently-used calculations, although they can be used to generate code - which I didn't fully understand. (Or rather, I didn't see the reason for this, since macros already take care of that.)

An example of a function taken straight from the reference manual:

Example:

    wpack   .function a, b=0
            .endf a+b*256

            .word wpack( 1 )        ; use default argument
            .word wpack( 2, 3 )     ; explicitly specify all arguments                                                  

Result:

    >0000            01 00                                  .word wpack( 1 )
    >0002            02 03                                  .word wpack( 2, 3 )
    --------------------------------------------------------------------------------
    00000000  01 00 02 03                                       |....|
    00000004

Variables and constants

Both variables and constants are user-defined symbols.

A constant can not be redefined, but can be used before its definition is encountered.

Variables can be redefined, but using them before their definition is not allowed.

In this example, "border" is a constant, while "colour" is a variable:

    =$d020                                          border =  $d020
    =1                                              colour := 1
    .0000            a5 01          lda $01                 lda colour
    .0002            8d 20 d0       sta $d020               sta border
    =4                                              colour += 3
    .0005            a5 04          lda $04                 lda colour
    .0007            8d 20 d0       sta $d020               sta border
    --------------------------------------------------------------------------------
    00000000  a5 01 8d 20 d0 a5 04 8d  20 d0                    |... .... .|
    0000000a

Checks on e.g. segment-size overflow

A useful feature is the ability to verify that certain data does not overflow its allocated region:

    * = 10
    data                                                                                                                
        .byte 11, 22, 33 ; (we have room for 2 more bytes)

    .cerror ( * - data ) > 5, "data-section more than 5 bytes long"

(Obviously, the .cerror-directive can do much more, but this seems like a very practical use.)


Delivered to you by Vim, GNU Make, MultiMarkdown, bozohttpd, NetBSD, and 1 human.