Writing assembly

Assembly is a first-class way to program a board here: a .S tab beside your sketch, or a whole project with no Arduino core under it.

Why bother

Because some things are only true one instruction at a time. A course that teaches the AVR or the Cortex-M asks for a delay loop counted in cycles, a vector table written by hand, and a register set you can read at a halt. All of that works in the browser, on the same simulated chips the sketches run on, with nothing to install.

Two ways in:

  • Assembly beside a sketch. Add a .S or .s tab and call it from C with extern "C". The Arduino core is still there, so Serial, millis() and pinMode() keep working.
  • Assembly with no Arduino core. Pick a starter under Assembly (no Arduino) in the board's Firmware panel. The board's files become one main.S, and nothing but your own code and the startup code is linked.

.S and .s tabs

Press the + on the file strip and name the file. The prompt says what it takes: "New file name, like config.h, helpers.cpp or delay.S". Anything ending .S or .s is assembly, and it compiles and links with the .ino and .cpp tabs of the same board, exactly as the Arduino IDE builds a .S next to a sketch.

The two spellings are not the same file:

  • .S, capital S, goes through the C preprocessor first. #include <avr/io.h>, #define, #if and // comments all work, and __ASSEMBLER__ is set. This is the one to use.
  • .s, lower case, is handed to the assembler as it is. No includes, no macros.

A new .S or .s tab does not start empty. It opens with one working function the sketch can call, in the board's own instruction set: AVR on the Uno, the Nano, the Mega, the Leonardo and the ATtiny85, and ARM Thumb on the Pico, the STM32 boards, the Uno R4, the micro:bit and the XIAO. The comment above it lists the calling convention, so the rules are in front of you while you write. Delete it or build on it.

Assembly tabs get syntax highlighting of their own, and the breakpoint gutter beside the line numbers, the same as a sketch.

Calling between C and assembly

A function written in assembly is a .global label, and C reaches it through extern "C" so the name is not mangled. It works both ways: assembly can call a C function the same way.

// sketch.ino
extern "C" uint8_t add_bytes(uint8_t a, uint8_t b);

void setup() {
  Serial.begin(9600);
  Serial.println(add_bytes(2, 40));   // 42
}

void loop() {}
; add.S
    .global add_bytes
add_bytes:
    add  r24, r22        ; a is in r24, b in r22
    ret                  ; the result goes back in r24

The rules are the chip's own, not Mokxi's, so a function that follows them also works on a real board.

On the AVR boards (the compiler calls this the GCC AVR calling convention):

  • Arguments go in r24, then r22, then r20, and down in twos. A two-byte argument takes a pair, low byte first: an int in r25:r24.
  • The result comes back in r24 for a byte, r25:r24 for two bytes, and r25:r22 for four.
  • r1 must be zero when you return. The compiler assumes it everywhere, so a mul that dirties it needs clr r1 after.
  • r2 to r17, r28 and r29 belong to the caller. Push them if you use them, and pop them before ret. Everything else is yours to clobber.

On the ARM boards (the standard Arm procedure call standard):

  • Arguments go in r0 to r3, and the result comes back in r0.
  • r4 to r11 belong to the caller: push them and pop them.
  • Return with bx lr, and mark the label .thumb_func.

avr/io.h in a .S file

On an AVR board #include <avr/io.h> in a .S gives the register and bit names, and it behaves exactly as avr-libc's does. That matters, because one rule catches nearly everyone:

A register name is a data address, not an I/O address. PORTB is 0x25. The I/O address is 32 lower, so in, out, sbi, cbi, sbic and sbis want _SFR_IO_ADDR(PORTB), which is 0x05:

    sbi  _SFR_IO_ADDR(DDRB), DDB5     ; PB5 is an output
    sts  TCCR1B, r16                  ; a high register, by its data address

If you would rather write the plain names everywhere, put one line above the include and they become I/O addresses instead, as avr-gcc's own startup code does:

#define __SFR_OFFSET 0
#include <avr/io.h>
    sbi  DDRB, DDB5

The rest of the header works too:

  • Bit names. DDB5, PINB5, TXEN0, WGM12, OCIE1A and the others are their bit numbers, so (1 << TXEN0) reads the way the data sheet does.
  • Vector names. TIMER1_COMPA_vect is __vector_11, so .global TIMER1_COMPA_vect followed by the label makes an interrupt handler the vector table jumps to. End it with reti.
  • __zero_reg__ is r1 and __tmp_reg__ is r0, under those names.
  • Registers past the low 64, such as UCSR0B and TIMSK1, are memory mapped: read them with lds and write them with sts.

Inline assembly

A few instructions inside a C function do not need a tab of their own. asm volatile with constraints works on the AVR and the ARM boards:

uint8_t x = 5;
asm volatile ("lsl %0" : "+r" (x));   // x is 10

The simulator counts cycles, so a hand-written delay really is as long as the data sheet says. A loop of nop, dec and brne, timed with Timer 1 at the full clock, comes out at exactly the count the ATmega328P data sheet gives its instructions: an lds, an ldi and 50 turns of the loop measure 202 cycles, not 201 and not 203. That is what makes counting cycles worth teaching here.

Assembly with no Arduino core

Open the board's properties panel and look in the Firmware section. On the Arduino Uno, the Nano, the Pro Mini at 16 MHz, the Raspberry Pi Pico and the STM32F411 Black Pill there is a group called Assembly (no Arduino) with four starters in it:

Starter What it does
Blink in assembly Toggles the board's LED every 250 ms from a counted delay loop.
Button to LED in assembly The LED follows a button held to ground by its pull-up.
UART hello in assembly Prints a line on the serial monitor twice a second.
Timer interrupt in assembly A timer interrupt toggles the LED, and the main loop only idles.

Pick one and the board's files become a single main.S. If there is work of your own on the board, you are asked first. After that the panel also offers Your assembly (main.S), so the board stays in assembly when you come back.

There is no Arduino core in the build: no setup(), no loop(), no millis() tick and no Serial. What gets linked is only this:

  • On an AVR board, your file plus the startup code, which is what avr-libc's gcrt1.o is to avr-gcc main.S. It is the vector table with every __vector_n a weak reti you can override, then r1 cleared, the stack set to RAMEND, .data copied out of flash, .bss cleared, and a call to your main. So you write main and whichever handlers you want, under avr-libc's vector names. If your file defines its own .vectors section, you get no startup code at all and the whole program is yours.
  • On the Pico and the Black Pill, nothing but your file. Your file is the vector table: .word _stack_top on the Pico or .word _estack on the Black Pill, then .word Reset_Handler, then the handlers you want, each one .thumb_func.

What the starters set up, if you want to read the registers along with them:

  • AVR blink toggles PB5 by writing a 1 to PINB5, which the ATmega328P reads as "toggle", and delays with an outer and an inner loop: "250 ms at 16 MHz is 4,000,000 cycles: 40 times 100,000", and the inner loop is four cycles a turn.
  • AVR button reads PD2 with its pull-up on, so a press reads 0, and uses sbic to skip a line.
  • AVR UART sets USART0 to 9600 baud with UBRR 103, keeps the text in .progmem and reads it a byte at a time with lpm.
  • AVR timer runs Timer 1 in CTC mode at the clock over 256 with OCR1A at 31,249, which is every 0.5 s, and toggles the LED in TIMER1_COMPA_vect.
  • Pico takes IO_BANK0 and PADS_BANK0 (and UART0 for the UART one) out of RESETS, drives GPIO25 through SIO at 0xD0000000, delays on TIMERAWL, talks on UART0 on GPIO0 at 115200, and uses the TIMER's alarm 0 IRQ for the timer starter.
  • Black Pill turns on the clocks in RCC_AHB1ENR, drives PC13, which is lit when low, reads PA0 with its pull-up, prints on USART1 on PA9 at 115200 with BRR 729, and runs SysTick at 1 kHz.

Press Run and the board runs it. The serial monitor, the scope, the logic analyzer and the parts on the canvas all work as usual, because the simulated chip does not know or care what language made the image.

When it does not assemble

Assembler errors land on the right tab and the right line, with a sentence in plain English under them saying what to write instead. A few of them:

  • ldi r3, 5 says LDI works only on the upper registers, r16 to r31, and to mov the value down afterwards if you need it low.
  • lid r16, 5 says "lid" is not an AVR instruction, and reminds you that a label ends in a colon and a directive starts with a dot.
  • A label written without its colon is named back to you with the colon added.
  • A conditional branch that cannot reach its target is placed on its line by the linker, with the fix: brne 1f, rjmp far, 1:.

Four of them are checks avr-as makes that LLVM's assembler does not, so Mokxi makes them itself, before any tool runs. Without them a mistake would assemble quietly into the wrong program:

  • ldi r16, 300 does not fit in a byte. Load the halves with lo8() and hi8().
  • sbi PORTB, 5 uses the data address 0x25 where sbi wants an I/O address. Write _SFR_IO_ADDR(PORTB).
  • add r16, 5 reads as add r16, r5, because ADD takes two registers. There is no ADDI: subtract the negative with subi r16, -5.
  • out PORTB, r16 without __SFR_OFFSET 0 is a warning rather than an error, because it does assemble: it just writes a different register than the one named.

in and out past I/O address 63, sbiw and adiw past 63, and a bit number past 7 are caught the same way, each with the instruction to use instead.

On the Thumb boards the common ones are a high register on a Cortex-M0+, which reaches only r0 to r7, a 32-bit Thumb-2 form on a Cortex-M0+, which has only the 16-bit set, a branch out of range, a missing Reset_Handler and a missing main.

Stepping through instructions

Everything in the debugger works on assembly. Breakpoints go in the gutter of a .S tab and stop on that line, because the line numbers the assembler records are the tab's own, not the preprocessed copy's.

The Debug tab has a Disassembly section. At a halt it lists the instructions around the program counter: the address, the bytes, the instruction in llvm-objdump's spelling, and the source line each one starts, which is a button that takes you to it. The instruction about to run is marked. Step instruction runs exactly one machine instruction, where Step over and Step into still move a whole source line at a time.

Registers read as the core's own. sreg on an AVR is also spelled out as its flags, ITHSVNZC, with a dot for each one that is clear, and xpsr on an ARM is spelled out as N Z C V Q, because eight bits are not a number.

Limits

  • Assembly with no Arduino core is offered on the Uno, the Nano, the Pro Mini, the Pico and the STM32F411 Black Pill. Every other board still takes .S and .s tabs beside a sketch.
  • The ESP32 family is not included. Clang's own Xtensa assembler is not complete for hand-written files, so those boards compile C and C++ only.
  • The Pico W has no starters. Its LED is on the wireless chip rather than a GPIO pin, and that chip is not simulated. Use a plain Pico.
  • A project is flat. File names carry no directories, so an include path is one directory deep.