How To Embed Assembly Code Into Arduino Sketches
2026-08-12 | By Maker.io Staff
Arduino C++ code is convenient to write, easy to understand, and provides a beginner-friendly way to interact with hardware. However, it does not expose all low-level features, and the abstraction can introduce overhead that affects performance and precise timing. Read on to learn how to use inline assembly in Arduino sketches to combine both worlds: cycle-level control where it matters and high-level convenience everywhere else.

Benefits and Drawbacks of Assembly Code
Probably the biggest motivation to use assembly in embedded programming is the cycle-level control it offers. Regular high-level code introduces overhead that can make it unsuitable for generating short and precise pulse timings and deterministic delays, for example, needed to implement some bit-banging protocols.
Next, assembly language allows direct hardware access and register control. Although Arduino C++ exposes some registers through abstraction, using assembly gives developers more granular control over how operations are executed. This can be important when working with hardware peripherals, such as timers, interrupts, and I/O ports.
Writing code in assembly also introduces a few unique challenges. What probably suffers most is the readability and maintainability of a project. Assembly language code is uncommon, difficult to write, and even more difficult to understand after some time has passed. Debugging can also be more challenging due to the lack of a high-level structure. Further, assembly is inherently architecture-specific, meaning code written for one microcontroller family is not easily portable to others. Lastly, assembly code might not play nice with the C++ compiler, which may make incorrect assumptions about side effects, resulting in hard-to-trace bugs. Therefore, assembly is best used in controlled, small, specific doses where precise low-level control is required.
Assembly Code in Arduino Sketches
The most common way to include assembly code in an Arduino sketch is to use inline asm blocks, which allow small snippets of assembly to be embedded directly into a regular C++ program. This approach is easy to use and well-suited for short sections that benefit from fine-grained control. The rest of the firmware can be written in regular C++, with all the benefits it offers, from improved readability to access to Arduino libraries and higher-level functions.
Consider the following simple C++ sketch that configures pin eight as an output and then toggles it as fast as possible:
const int pin = 8;
void setup() {
pinMode(pin, OUTPUT);
}
void loop() {
digitalWrite(pin, HIGH);
digitalWrite(pin, LOW);
}
When reading this snippet, most people would reasonably expect this code to produce a clean, periodic square wave with evenly long HIGH and LOW phases. However, in practice, the signal tends to be irregular on real hardware, because each digitalWrite call introduces invisible overhead and can be interrupted by other code. As a result, the pin does not toggle as fast as possible:

This image shows the result of toggling a GPIO pin on an Arduino Nano 33 IoT quickly through C++. The waveform gets interrupted occasionally, and the maximum frequency is around 330 kHz, although it fluctuates a bit.
This is where assembly can help developers regain control over the exact execution path by replacing the two digitalWrite calls from the previous example with an asm block that contains simple set and clear instructions:
void setup() {
pinMode(8, OUTPUT);
}
void loop() {
asm volatile (
"sbi 0x05, 0 \n\t" // PORTB0 (pin 8) HIGH
"cbi 0x05, 0 \n\t" // LOW
);
}
Note that asm blocks are usually marked as volatile, which instructs the compiler not to remove or reorder a block, even if it thinks it’s safe to do so. However, volatile alone does not address input, output, and clobber constraints.
The resulting waveform has a much higher frequency, even when running on weaker hardware:

Using pure assembly, even a much slower Arduino Uno can toggle the pin with a frequency of around 2MHz, or around 500% faster than on the Nano 33 IoT through C++. The resulting waveform is also much more stable.
Assembly and Compiler Constraints
Using inline assembly in C++ code can quickly turn into a headache when compiler constraints are not taken into account. These constraints tell the C++ compiler how the assembly code interacts with the rest of the C++ code. Without them, the compiler assumes that it can optimize around asm blocks, which can introduce bugs if the compiler decides to remove or cache variables or reorder instructions. In Arduino C++, the constraints are part of the extended inline assembly syntax, and they are written inside an asm block after the instructions:
asm volatile (
"instructions"
: output_constraints
: input_constraints
: clobbers
);
Output constraints describe the values produced by the asm block that are written back to general-purpose registers. These are effectively the values returned from assembly to C. The input constraints define which values are passed into the assembly block. Declaring them correctly lets the compiler know that the asm block depends on certain values being available.
Clobbers inform the compiler that the assembly block modifies certain registers or memory and that it should avoid using those for other variables, since it can no longer assume that values are still valid or unchanged after the assembly instructions run. If this part is omitted, the compiler might reuse registers that have been destroyed, which can result in seemingly random bugs or crashes. However, several other less severe data glitches can occur, including stale values, missing updates, overwritten updates, or register corruption, where completely unrelated variables suddenly break or have different values.
The constraint section in inline asm is split into three parts, separated by colons: outputs, inputs, and clobbers. If a section is not needed, it is simply left empty, but the colon is still kept to preserve the structure:
asm volatile ( "nop" : : : "memory" ); // or, shorter: asm volatile ( "nop" : : : "memory" );
If later constraint sections are unused, they can be omitted entirely, as long as nothing follows them. For example, the following snippet omits the clobbers but keeps output and input constraints:
// Only output constraints: can omit inputs and clobbers: asm volatile ( "instructions" : output_constraints ); // Output + input constraints: can omit clobbers asm volatile ( "instructions" : output_constraints : input_constraints );
The whole constraint section can also be omitted entirely if no constraints exist.
Each of the sections contains a comma-separated list of constraint operands. Each operand has an optional name, given in brackets, followed by a constraint identifier and optional modifiers in double quotes, and then the actual C++ variable or expression:
[optional_name] "constraint" (variable)
An optional name can be used to reference the operand inside the assembly code with %[name]. If no name is given, the operand is referenced by its index. For example, %1 references the second variable. The constraint string tells the compiler how the value should be passed into or out of the assembly code, for example, as a register, memory location, or immediate value. The variable is the actual C++ value that is connected to the assembly operand:
uint8_t a = 3;
uint8_t b = 5;
uint8_t result;
uint8_t temp;
asm volatile (
"mov %[out], %[in1] \n\t"
"add %[out], %[in2] \n\t"
"mov %[t], %[in2] \n\t"
"inc %[t] \n\t"
: [out] "=&r"(result),
[t] "=&r"(temp)
: [in1] "r"(a),
[in2] "r"(b)
: "memory"
);
This code example defines an asm block with two output constraints, two input constraints, and a single clobber constraint. The memory keyword in the clobbers list is a special keyword that lets the compiler know that the assembly code performs memory operations other than those listed in the input and output operands. It informs the compiler that it should assume that memory may have changed.
The example demonstrates that the variable names inside the asm block can differ from the ones used in the C++ code. It also shows how variables inside the asm block can be referenced by their name or index. It’s always possible to use the index, even when referencing named variables.
Common Constraint Letters and Modifiers
Constraints specify the operand type and its possible values. Therefore, the constraint must match the expected type of the assembly instructions in which it appears. The simplest operand allowed is a string of letters, most commonly a single character. While many more different constraints exist, there are only a few that are commonly used.
A single letter “r” denotes that the constraint allows a general-purpose register. Most commonly, these map to regular C++ variables in code. The “i” operand allows a single immediate integer operand, including symbolic constants whose values are known at assembly time or later. In Arduino sketches, this could, for example, be a constant pin number. The “m” constraint allows a memory operand with any kind of address that the machine supports. Lastly, “x” allows any type of operand.
Finally, there are a few modifiers that can appear in the constraint string, most notably the plus (+) and equals (=) signs. These are both prepended to the constraint letters they affect, for example, “+r”, if the modifier affects a register. The “+” modifier means that an operand is both read and written by the assembly code, and the “=” modifier lets the compiler know that an operand is updated in the assembly block. Similar to before, more options exist. However, discussing them would go beyond the scope of this article.
Putting It All Together
The following code snippet combines a regular Arduino C++ sketch with assembly code to perform a simple task. The code attaches an interrupt to pin two, which is connected to a push button, and it increments a counter each time the button is pressed.
The assembly block in the loop then compares the counter value to a predefined limit, which is ten in this example. If the counter value is below that limit, the assembly code skips to the end of the block without changing the counter. However, if it’s ten or more, the asm block resets the counter value to zero, effectively implementing a more complicated modulo operator.
The remainder of the loop function checks if the counter value changed, and it prints the new value to the console if it did.
#define BTN_PIN 2
volatile uint8_t counter = 0;
uint8_t previousValue = -1;
const uint8_t limit = 10;
void incrementCounter() {
counter++;
}
void setup() {
Serial.begin(9600);
pinMode(BTN_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(2), incrementCounter, FALLING);
}
void loop() {
asm volatile (
"cpi %[cnt], %[limit] \n\t" // compare counter with limit
"brlo skip \n\t" // if counter < limit, skip reset
"clr %[cnt] \n\t" // otherwise reset to 0
"skip:" // empty jump target
: [cnt] "+m" (counter)
: [limit] "i" (limit)
: "cc", "memory"
);
if (previousValue != counter) {
Serial.print("New counter value: ");
Serial.println(counter);
previousValue = counter;
}
}
The input, output, and clobber constraints demonstrate how to use the “+” modifier and the immediate constraint “i”. The counter appears as an output of type “+m” since it is stored in memory and the value is read and potentially written. The limit value is a constant, immediate integer with value ten, and it is marked with an “i”. The “cc” flag in the clobbers list tells the compiler that the assembly code modifies the CPU’s condition register. This is necessary because the assembly instructions modify the CPU status register, for example, through compare and branch instructions.

Conclusion
Inline assembly is a practical way to combine high-level Arduino code with precise, low-level control. While regular C++ code is easy to read and portable, it introduces abstraction that can interfere with exact timing or direct hardware interaction. In these cases, short asm blocks let developers control the exact instructions being executed.
However, this control comes with trade-offs. Assembly code is harder to read, maintain, and debug, and it is tightly coupled to a specific architecture. More importantly, inline assembly must be used carefully to avoid bugs related to the C++ compiler. This is where constraints become essential, as they tell the compiler how data flows between C++ and assembly.
In practice, inline assembly should be used sparingly and only where it provides a clear benefit. Keeping asm blocks short and properly constrained allows developers to take advantage of low-level control without sacrificing reliability and maintainability.

