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
.Sor.stab and call it from C withextern "C". The Arduino core is still there, soSerial,millis()andpinMode()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,#ifand//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, thenr22, thenr20, and down in twos. A two-byte argument takes a pair, low byte first: anintinr25:r24. - The result comes back in
r24for a byte,r25:r24for two bytes, andr25:r22for four. r1must be zero when you return. The compiler assumes it everywhere, so amulthat dirties it needsclr r1after.r2tor17,r28andr29belong to the caller. Push them if you use them, and pop them beforeret. Everything else is yours to clobber.
On the ARM boards (the standard Arm procedure call standard):
- Arguments go in
r0tor3, and the result comes back inr0. r4tor11belong 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,OCIE1Aand the others are their bit numbers, so(1 << TXEN0)reads the way the data sheet does. - Vector names.
TIMER1_COMPA_vectis__vector_11, so.global TIMER1_COMPA_vectfollowed by the label makes an interrupt handler the vector table jumps to. End it withreti. __zero_reg__isr1and__tmp_reg__isr0, under those names.- Registers past the low 64, such as
UCSR0BandTIMSK1, are memory mapped: read them withldsand write them withsts.
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.ois toavr-gcc main.S. It is the vector table with every__vector_na weakretiyou can override, thenr1cleared, the stack set to RAMEND,.datacopied out of flash,.bsscleared, and acallto yourmain. So you writemainand whichever handlers you want, under avr-libc's vector names. If your file defines its own.vectorssection, 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_topon the Pico or.word _estackon 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
sbicto skip a line. - AVR UART sets USART0 to 9600 baud with
UBRR103, keeps the text in.progmemand reads it a byte at a time withlpm. - AVR timer runs Timer 1 in CTC mode at the clock over 256 with
OCR1Aat 31,249, which is every 0.5 s, and toggles the LED inTIMER1_COMPA_vect. - Pico takes
IO_BANK0andPADS_BANK0(andUART0for the UART one) out ofRESETS, drives GPIO25 through SIO at0xD0000000, delays onTIMERAWL, 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 withBRR729, 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, 5says LDI works only on the upper registers, r16 to r31, and tomovthe value down afterwards if you need it low.lid r16, 5says"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, 300does not fit in a byte. Load the halves withlo8()andhi8().sbi PORTB, 5uses the data address0x25wheresbiwants an I/O address. Write_SFR_IO_ADDR(PORTB).add r16, 5reads asadd r16, r5, because ADD takes two registers. There is no ADDI: subtract the negative withsubi r16, -5.out PORTB, r16without__SFR_OFFSET 0is 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
.Sand.stabs 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.
Related
- The code editor and compiling
- The debugger
- The properties panel
- AVR assembly in the browser: the blink starter line by line, cycle counting and a timer interrupt
- Compiling in the browser: how the
.Sis preprocessed, assembled and linked