Skip to content

Arduino pulsebox code structure

Radim Hošák edited this page Jul 21, 2021 · 2 revisions

Starting from an empty .ino file

An empty Arduino .ino source code file looks like this:

void setup() {
  // put your setup code here, to run once:

}

void loop() {
  // put your main code here, to run repeatedly:

}

The setup() function is used for Arduino configuration, whereas the loop() function is intended for code that is run on repeat indefinitely. As we will see later, we will not use the loop() function and instead define our own function sequence() for the pulse sequence.

Setting up the pulsebox with setup()

First we show the complete setup() block for triggered and continuous/repeat mode of pulsebox operation and then we discuss its key parts in detail:

/* Triggered mode */
void setup() {
   for (int i=1; i<=78; i++)
      detachInterrupt(i);
   void __disable_irq(void);
   REG_PIOC_OER = 0b11111101001111111110;
   REG_PIOC_OWER = 0b11111101001111111110;
   attachInterrupt(52, sequence, RISING);
}

/* Continuous/repeat mode */
void setup() {
   for (int i=1; i<=78; i++)
      detachInterrupt(i);
   void __disable_irq(void);
   REG_PIOC_OER = 0b11111101001111111110;
   REG_PIOC_OWER = 0b11111101001111111110;
   while(1) {
      sequence();
      delay(1);
   }
}

Disabling interrupts

A pulsebox is an application where timing accuracy is critical. However, if the Arduino received an interrupt during a pulse sequence, other code would be executed immediately and the timing of the sequence would be thrown off. To prevent this, we disable interrupts. First we iterate through all numbered Arduino pins and detach any possible interrupts:

for (int i=1; i<=78; i++)
   detachInterrupt(i);

As a next step we disable interrupts in this way:

void __disable_irq(void);

Note: whether or not the second step is redundant was not tested. During development and testing, no effect of an interrupt was observed.

Pulsebox pin configuration

Arduino Due has 79 numbered pins, from 0 to 78. The Arduino pin mapping table in the Arduino documentation shows their function. Any pin labeled 'Digital Pin' can be used as a pulsebox pin. The pin n could be set to output with the Arduino function:

pinMode(n, OUTPUT);

In principle, we could iterate over all pulsebox pins and set them to OUTPUT. We will choose a different approach, which will be especially advantageous later when we discuss writing into the digital pins.

Low-level access to digital pins of SAM3X8E

The Atmel SAM3X8E MCU (on which Arduino Due is built) divides its pin IO (PIO) into distinct PORTs: A, B, C, and D. Consulting the Arduino pin mapping table reveals for example that the Arduino pin 0 is internally pin 8 of PORT A: PA8. Similarly, Arduino pin 1 is mapped to PA9. However, Arduino pin 2 belongs to PORT B: PB25.

Each SAM3X8E PIO PORT has numerous 32-bit registers, where n-th bit of the register corresponds to the n-th pin of the given PORT. The REG_PIOC_OER is the output enable register for PORT C. If we decide that all the pulsebox pins belong to PIO PORT C (as is the case with this pulsebox implementation), we can set all relevant pins to OUTPUT mode with one instruction just by setting the appropriate REG_PIOC_OER bits to 1:

REG_PIOC_OER = 0b11111101001111111110;

Then we use the REG_PIOC_OWER output write enable register to enable writing to the same pins:

REG_PIOC_OER = 0b11111101001111111110;

See Pulsebox channel to pin mappings for the reasoning behind the specific PORT and pin choice.

Configuration of the pulsebox mode

Triggered mode

In this case we attach an interrupt to a chosen Arduino pin we will call the trigger pin. When a rising edge (e. g. change from the LOW state to HIGH state) occurs on the trigger pin, the pulse sequence, described in the sequence() function, is produced.

attachInterrupt(trigger_pin, sequence, RISING);

The trigger_pin above is specified in Arduino notation and should be between 0 and 78. The trigger pin should not be identical to any pulsebox pins.

Continuous/repeat mode

Here we simply use an endless loop to run the sequence() function. An optional delay is inserted between the consecutive runs of sequence(). This delay is facilitated by the Arduino delay(n) function, which produces a delay of n milliseconds:

while(1) {
   sequence();
   delay(delay_milliseconds);
}

Note: If more accurate delay between sequences is needed, a different approach to delay should be employed.

The pulsebox sequence: sequence()

The contents of the sequence() function can be understood from this trivial example of a single-channel, single-pulse sequence:

void sequence()  {
   REG_PIOC_ODSR = 0b10;  /* pulsebox channel 1 (PORT C pin 2) HIGH */
   asm volatile (
      "MOVW R1, #0xa\n"  /* 10 iterations */
      "MOVT R1, #0x0\n"
      "LOOP0:\n\t"
      "NOP\n\t"
      "SUB R1, #1\n\t"
      "CMP R1, #0\n\t"
      "BNE LOOP0\n"
   );
   REG_PIOC_ODSR = 0b0; /* pulsebox channel 1 (PORT C pin 2) LOW */
}

Any multi-channel digital pulse sequence can be broken down into two parts (or events):

  1. State change: when the state (HIGH/LOW) of one or more output channels is changed
  2. Delay: A period of time between state changes

State change

Here we use the low-level SAM3X8E register REG_PIOC_ODSR to enable changing the status of all the pulsebox pins at once. For example, to set channels 1 and 2 to HIGH, we use the following C statement:

REG_PIOC_ODSR = 0b101;

For our example, channel 1 is mapped to PORTC bit 1 and channel 2 to bit 3, according to the Pulsebox channel to pin mappings.

Delay

If our pulsebox code consisted only of state change code blocks, we would find that the actual state changes, when measured on an oscilloscope, would be separated by a two-clock interval (for the default 84 MHz master clock of Arduino Due, this is about 24 ns). If we wish to have more control over the delay between state changes, we need to add a delay code block. Here we present two approaches, each suitable for a different scenario.

No-operation (NOP) delay

By repeatedly using the NOP assembly instruction between two output changes, it is possible to increment the delay by one-clock steps (12 ns for the default Arduino clock):

REG_PIOC_ODSR = 0b1;  /* set channel 1 to HIGH */
asm volatile("NOP\n");  /* + 12 ns */
asm volatile("NOP\n");  /* + 12 ns */
asm volatile("NOP\n");  /* + 12 ns */
REG_PIOC_ODSR = 0b0;  /* set channel 1 to LOW */

With three NOP instructions used in the example, we added three clocks (36 ns) to the always present two-clock (24 ns) interval, making the total duration between output changes 24 ns + 36 ns = 60 ns.

Loop delay

For longer delays, a certain number of iterations through a delay loop is performed. The higher the iteration count, the longer the delay.

Illustration: C implementation

The delay loop can be understood as an empty C for loop:

REG_PIOC_ODSR = 0b1;  /* set channel 1 to HIGH */

int iters = 10;  /* we will iterate the loop 10 times */
int i;  /* we will hold the current iteration count here */
for(i = 0; i < iters; i++) {
   ;  /* do nothing
}

REG_PIOC_ODSR = 0b0;  /* set channel 1 to LOW */

It should be noted that the C compiler will try to optimize the code and eliminate parts it considers unnecessary. As nothing effectively happens in the loop above, there is a significant chance that it will be eliminated and the desired delay will not be produced. This was the result of our testing, even after changing the optimization flags of the gcc compiler.

Assembly implementation

Our approach relies on the inline assembly implementation of the example above:

REG_PIOC_ODSR = 0b1;  /* set channel 1 to HIGH */

asm volatile (
   "MOVW R1, #0x14\n"  // 20 iterations, approx 1.2 microsec delay
   "MOVT R1, #0x0\n"
   "LOOP0:\n\t"
   "NOP\n\t"
   "SUB R1, #1\n\t"
   "CMP R1, #0\n\t"
   "BNE LOOP0\n"
);

REG_PIOC_ODSR = 0b0;  /* set channel 1 to LOW */

This loop, step by step, does the following:

  1. Setting the 32-bit iteration variable into a general-purpose register R1. This is done in two steps. The lower 16 bits are stored using MOVW R1, #0x14 and the upper 16 bits with MOVT R1, #0x0. In our example, we wish to set the iteration count to 20, which is 0x14 in hexadecimal. The lower 16 bits are 0x0014 and the upper 16 bits are 0x0000.

  2. The delay loop

    2.1. Starting the loop. LOOP0: is the loop label. After the end of each loop iteration, program execution returns back here, until the number of performed iteration matches the desired iteration count.

    2.2. Do nothing. NOP is a no-operation instruction.

    2.3. Decrement the iteration count by one: SUB R1, #1

    2.4. Check if iteration counter reached zero: CMP R1, #0

    2.5. If the iteration counter is non-zero, go back to the start of the loop: BNE LOOP0

  3. End of the loop: If the value of the iteration counter was zero in step 2.5., the loop finishes.

Some important points about the ASM loops
  • When using multiple ASM loops in one .ino source code you must ensure that each delay loop has a unique label. We choose to use the LOOP# format for the loop labels, with the # being a unique number starting from zero and incrementing as loops are added.
  • Limiting the iteration count variable to 16 bits can get around the process of writing into the R1 register in two steps. In such case it is possible to use only the MOVW or MOV instruction.
  • Alternative ASM loops have been experimented with during development. Choosing a different ASM loop can have two notable effects: (1) different minimal delay (e. g. delay obtained with a one-iteration loop), and (2) different delay granularity (e. g. the delay time increment accopmanied by increasing the iteration count by one). The ASM loop presented above was chosen because its linearity — changing the iteration count from 1 to 2 prolonged the delay by the same amount as when going from 100.000 to 100.001 iterations. This was not necessarily observed for the other variants, where linearity was broken, especially for very low iteration counts. Still, the alternative ASM delay loops are presented below:
/* no-NOP loop */
asm volatile(
   "MOVW R1, #0x14\n"
   "MOVT R1, #0\n"
   "LOOP0:\n\t"
   "SUB R1, #1\n\t"
   "CMP R1, #0\n\t"
   "BNE LOOP0\n"
);

/* SUBS, no-NOP loop */
asm volatile(
   "MOVW R1, #0x14\n"
   "MOVT R1, #0\n"
   "LOOP0:\n\t"
   "SUBS R1, #1\n\t"
   "BNE LOOP0\n"
);

/* SUBS, no-NOP, 16-bit only loop */
asm volatile(
   "MOV R1, #0x14\n"
   "LOOP0:\n\t"
   "SUBS R1, #1\n\t"
   "BNE LOOP0\n"
);
Resources for the assembly language for Arduino Due's Atmel SAM3X8E MCU.

The manual for the used MCU---with details on the ASM instruction set---is a useful reference when playing around with the ASM delay loops. Arduino Due is based on the Atmel SAM3X8E MCU. Here is the link to the datasheet. The instruction set summary starts on page 81.

Putting it all together

The following working .ino code produces a simple single-channel, single-pulse sequence running repeatedly (in the continuous mode) with a delay of 1 ms between repetitions.

void setup() {
   for (int i=1; i<=78; i++)
      detachInterrupt(i);
   void __disable_irq(void);
   REG_PIOC_OER = 0b11111101001111111110;
   REG_PIOC_OWER = 0b11111101001111111110;
   while(1) {
      sequence();
      delay(1);
   }
}

void sequence()  {
   REG_PIOC_ODSR = 0b1010;
   asm volatile (
      "MOVW R1, #0xa\n"
      "MOVT R1, #0x0\n"
      "LOOP0:\n\t"
      "NOP\n\t"
      "SUB R1, #1\n\t"
      "CMP R1, #0\n\t"
      "BNE LOOP0\n"
   );
   REG_PIOC_ODSR = 0b0;
}

void loop() {
   ;
}

Handy tools to make creating .ino sequence source code easier

TODO: link to codeblocks