assembler(3)

πŸ“¦ version 2.2.1, in fedora 43, fedora 44, opensuse-leap 16.0

πŸ’– assembler(3) β€” avr-libc and assembler programs πŸ’–

okay so bestie, buckle up, we're talking about assembler(3) from the avr-libc era, and it's lowkey iconic. this is the whole vibe of writing straight-up assembly for AVR microcontrollers and letting avr-libc hold your hand through it. ✨


🌸 Introduction 🌸

so like, why would anyone even write code for AVR microcontrollers using plain assembler source? valid question, here's the tea:

  • code for devices that do not have RAM and are thus not supported by the C compiler.
  • code for very time-critical applications.
  • special tweaks that cannot be done in C.

usually, all but the first could probably be done easily using the inline assembler facility of the compiler. so like, don't come for the assembler unless you need to, bestie. πŸ’…

even though avr-libc is primarily targeted to support programming AVR microcontrollers using the C (and C++) language, there's limited support for direct assembler usage as well. and the benefits? say it with me:

  • use of the C preprocessor, and thus the ability to use the same symbolic constants that are available to C programs, as well as a flexible macro concept that can use any valid C identifier as a macro (whereas the assembler's macro concept is basically targeted to use a macro in place of an assembler instruction). πŸ‘‘
  • use of the runtime framework like automatically assigning interrupt vectors. for devices that have RAM, initializing the RAM variables can also be utilized. πŸ¦‹

βš™οΈ Invoking the compiler βš™οΈ

for the purpose described in this document, the assembler and linker are usually not invoked manually, but rather using the C compiler frontend (avr-gcc) that in turn will call the assembler and linker as required.

this approach has the following advantages, no cap:

  • there is basically only one program to be called directly, avr-gcc, regardless of the actual source language used.
  • the invokation of the C preprocessor will be automatic, and will include the appropriate options to locate required include files in the filesystem.
  • the invokation of the linker will be automatic, and will include the appropriate options to locate additional libraries as well as the application start-up code (crtXXX.o) and linker script.
note that the invokation of the C preprocessor will be automatic when the filename provided for the assembler file ends in .S (the capital letter 's'). this would even apply to operating systems that use case-insensitive filesystems, since the actual decision is made based on the case of the filename suffix given on the command-line, not based on the actual filename from the file system. 🀯

as an alternative to using .S, the suffix .sx is recognized for this purpose (starting with GCC 4.3.0). this is primarily meant to be compatible with other compiler environments that have been providing this variant before, in order to cope with operating systems where filenames are case-insensitive (and, with some versions of make that could not distinguish between .s and .S on such systems).

alternatively, the language can explicitly be specified using the -x assembler-with-cpp option. slay. πŸ’…


πŸŽ€ Example program πŸŽ€

the following annotated example features a simple 100 kHz square wave generator using an AT90S1200 clocked with a 10.7 MHz crystal. pin PD6 will be used for the square wave output. it's giving main character energy fr fr. πŸ”₯

#include <avr/io.h>        ; Note [1]

work    =   16      ; Note [2]
tmp =   17

inttmp  =   19

intsav  =   0

SQUARE  =   PD6     ; Note [3]

                ; Note [4]:
tmconst= 10700000 / 200000  ; 100 kHz => 200000 edges/s
fuzz=   8           ; # clocks in ISR until TCNT0 is set

    .section .text

    .global  main                ; Note [5]
main:
    rcall   ioinit
1:
    rjmp    1b              ; Note [6]

    .global  TIMER0_OVF_vect         ; Note [7]
TIMER0_OVF_vect:
    ldi inttmp, 256 - tmconst + fuzz
    out _SFR_IO_ADDR(TCNT0), inttmp ; Note [8]

    in  intsav, _SFR_IO_ADDR(SREG)  ; Note [9]

    sbic    _SFR_IO_ADDR(PORTD), SQUARE
    rjmp    1f
    sbi _SFR_IO_ADDR(PORTD), SQUARE
    rjmp    2f
1:  cbi _SFR_IO_ADDR(PORTD), SQUARE
2:

    out _SFR_IO_ADDR(SREG), intsav
    reti

ioinit:
    sbi _SFR_IO_ADDR(DDRD), SQUARE

    ldi work, _BV(TOIE0)
    out _SFR_IO_ADDR(TIMSK), work

    ldi work, _BV(CS00)     ; tmr0:  CK/1
    out _SFR_IO_ADDR(TCCR0), work

    ldi work, 256 - tmconst
    out _SFR_IO_ADDR(TCNT0), work

    sei

    ret

    .global __vector_default     ; Note [10]
__vector_default:
    reti

    .end

πŸ“ Note [1] πŸ“

as in C programs, this includes the central processor-specific file containing the IO port definitions for the device. note that not all include files can be included into assembler sources. 🫢

πŸ“ Note [2] πŸ“

assignment of registers to symbolic names used locally. another option would be to use a C preprocessor macro instead:

#define work 16

πŸ“ Note [3] πŸ“

our bit number for the square wave output. note that the right-hand side consists of a CPP macro which will be substituted by its value (6 in this case) before actually being passed to the assembler. ✨

πŸ“ Note [4] πŸ“

the assembler uses integer operations in the host-defined integer size (32 bits or longer) when evaluating expressions. this is in contrast to the C compiler that uses the C type int by default in order to calculate constant integer expressions.

in order to get a 100 kHz output, we need to toggle the PD6 line 200000 times per second. since we use timer 0 without any prescaling options in order to get the desired frequency and accuracy, we already run into serious timing considerations: while accepting and processing the timer overflow interrupt, the timer already continues to count. when pre-loading the TCCNT0 register, we therefore have to account for the number of clock cycles required for interrupt acknowledge and for the instructions to reload TCCNT0 (4 clock cycles for interrupt acknowledge, 2 cycles for the jump from the interrupt vector, 2 cycles for the 2 instructions that reload TCCNT0). this is what the constant fuzz is for. πŸ’€

πŸ“ Note [5] πŸ“

external functions need to be declared to be .global. main is the application entry point that will be jumped to from the ininitalization routine in crts1200.o.

πŸ“ Note [6] πŸ“

the main loop is just a single jump back to itself. square wave generation itself is completely handled by the timer 0 overflow interrupt service. a sleep instruction (using idle mode) could be used as well, but probably would not conserve much energy anyway since the interrupt service is executed quite frequently. 😴

πŸ“ Note [7] πŸ“

interrupt functions can get the usual names that are also available to C programs. the linker will then put them into the appropriate interrupt vector slots. note that they must be declared .global in order to be acceptable for this purpose. this will only work if <avr/io.h> has been included. note that the assembler or linker have no chance to check the correct spelling of an interrupt function, so it should be double-checked. (when analyzing the resulting object file using avr-objdump or avr-nm, a name like __vector_N should appear, with N being a small integer number.) πŸ”

πŸ“ Note [8] πŸ“

as explained in the section about special function registers, the actual IO port address should be obtained using the macro _SFR_IO_ADDR. (the AT90S1200 does not have RAM thus the memory-mapped approach to access the IO registers is not available. it would be slower than using in / out instructions anyway.)

since the operation to reload TCCNT0 is time-critical, it is even performed before saving SREG. obviously, this requires that the instructions involved would not change any of the flag bits in SREG. ⏱️

πŸ“ Note [9] πŸ“

interrupt routines must not clobber the global CPU state. thus, it is usually necessary to save at least the state of the flag bits in SREG. (note that this serves as an example here only since actually, all the following instructions would not modify SREG either, but that's not commonly the case.)

also, it must be made sure that registers used inside the interrupt routine do not conflict with those used outside. in the case of a RAM-less device like the AT90S1200, this can only be done by agreeing on a set of registers to be used exclusively inside the interrupt routine; there would not be any other chance to 'save' a register anywhere. 😭

if the interrupt routine is to be linked together with C modules, care must be taken to follow the register usage guidelines imposed by the C compiler. also, any register modified inside the interrupt sevice needs to be saved, usually on the stack. 🧚

πŸ“ Note [10] πŸ“

as explained in Interrupts, a global 'catch-all' interrupt handler that gets all unassigned interrupt vectors can be installed using the name __vector_default. this must be .global, and obviously, should end in a reti instruction. (by default, a jump to location 0 would be implied instead.) 🌈


✨ Pseudo-ops and operators ✨

okay bestie, so the pseudo-ops your assembler actually ships with? They're all described over in the GNU assembler (gas) manual. You can find that manual online, bundled into the current binutils release, right here: http://sources.redhat.com/binutils/ 🫢

Now, because gas came from a Unix origin story, its pseudo-op vibes β€” and honestly its whole assembler syntax β€” are a little different from what other assemblers are doing. Specifically:

  • Numeric constants follow C notation (so hexadecimal constants get the prefix 0x)
  • Expressions use a C-like syntax

🌸 Some common pseudo-ops 🌸

  • .byte β€” allocates single byte constants πŸ’…
  • .ascii β€” allocates a non-terminated string of characters
  • .asciz β€” allocates a \0-terminated string of characters (aka a C string) πŸŽ€
  • .data β€” switches to the .data section (that's your initialized RAM variables)
  • .text β€” switches to the .text section (code and ROM constants) ✨
  • .set β€” declares a symbol as a constant expression (literally identical to .equ)
  • .global (or .globl) β€” declares a public symbol that's visible to the linker (think: function entry point, global variable) πŸ‘‘
  • .extern β€” declares a symbol to be externally defined; bestie, this one is effectively a comment only, because gas treats all undefined symbols it encounters as globally undefined anyway πŸ™ˆ
Heads up: .org is available in gas too, but it's a fairly pointless pseudo-op in an assembler environment that uses relocatable object files. Why? Because it's the linker that determines the final position of some object in ROM or RAM. Not you. Not .org. The linker. periodt.

πŸ¦‹ AVR-specific operators πŸ¦‹

Along with the architecture-independent standard operators, there are some AVR-specific operators available β€” which, unfortunately, are not yet described in the official documentation. The most notable ones:

  • lo8 β€” takes the least significant 8 bits of a 16-bit integer
  • hi8 β€” takes the most significant 8 bits of a 16-bit integer
  • pm β€” takes a program-memory (ROM) address and converts it into a RAM address. This implies a division by 2, because the AVR handles ROM addresses as 16-bit words (like, in an IJMP or ICALL instruction). It can also handle relocatable symbols on the right-hand side 🫢

⭐ Example ⭐

     ldi  r24, lo8(pm(somefunc))
     ldi  r25, hi8(pm(somefunc))
     call something

This passes the address of function somefunc as the first parameter to function something. iconic behavior, truly πŸ˜­πŸ’–


and that's the whole page, bestie!! you just read assembler(3), Version 2.1.0, dated Sat Jan 29 2022 β€” go forth and assemble responsibly, mwah πŸ’…πŸ’–βœ¨πŸŒˆπŸ¦‹

πŸ“¦ where this page comes from