8
\$\begingroup\$

I'm developing a small logic analyzer with 7 inputs. My target device is an ATmega168 with a 20MHz clock rate. To detect logic changes I use pin change interrupts. Now I'm trying to find out the lowest sample rate I can detected these pin changes. I determined a value of minimum 5.6 µs (178.5 kHz). Every signal below this rate I can't capture properly.

My code is written in C (avr-gcc). My routine looks like:

ISR()
{
    pinc = PINC; // char
    timestamp_ll = TCNT1L; // char
    timestamp_lh = TCNT1H; // char
    timestamp_h = timerh; // 2 byte integer
    stack_counter++;
}

My captured signal change is located at pinc. To localize it I have a 4 byte long timestamp value.

In the datasheet I read the interrupt service routine takes 5 clocks to jump in and 5 clocks to return to the main procedure. I'm assuming each command in my ISR() is taking 1 clock to be executed; So in sum there should be an overhead of 5 + 5 + 5 = 15 clocks. The duration of one clock should be according to the clock rate of 20MHz 1/20000000 = 0.00000005 = 50 ns. The total overhead in seconds should be then: 15 * 50 ns = 750 ns = 0.75 µs. Now I don't understand why I can't capture anything below 5.6 µs. Can anyone explain what's going on?

\$\endgroup\$
  • \$\begingroup\$ maybe 5 clocks to dispatch the ISR code, which includes the context saving and restoring epilog/prolog you don't see in the C source. Also, what is the hardware doing when the interrupt goes off? Is it in some sleep state. (I don't know AVR, but in general, interrupting processing of certain states can take longer.) \$\endgroup\$ – Kaz Apr 1 '13 at 19:01
  • \$\begingroup\$ @arminb See also this question for more ideas about how to capture external events with greater precision. Also [this appnote](www.atmel.com/Images/doc2505.pdf) might be of interest. \$\endgroup\$ – angelatlarge Apr 4 '13 at 18:20
10
\$\begingroup\$

There are a couple of issues:

  • Not all AVR commands take 1 clock to be executed: if you look at the back of the datasheet, it has the number of clocks it takes for each instruction to be executed. So, for example AND is a one-clock instruction, MUL (multiply) takes two clocks, while LPM (load program memory) is three, and CALL is 4. So, with respect to the instruction execution, it really depends on the instruction.
  • 5 clocks to jump in and 5 clocks to return can be misleading. If you look at your disassembled code, you will find that in addition to the jump and RETI instructions, the compiler adds all sorts of other code, which also takes time. For instance you might need local variables which are created on the stack and must be popped off, etc. The best thing to do to see what's actually going on is to look at the disassembly.
  • Lastly, remember that while you are in your ISR routine, your interrupts are not triggering. This means that you will not be able to get the kind of performance you are looking for from your logic analyzer, unless you know that your signal levels change at intervals longer than it takes to service your interrupt. To be clear, once you calculate the time it takes for your ISR to execute, this gives you an upper limit of how quickly you can capture one signal. If you need to capture two signals, then you start runnning into trouble. To be overly detailed about this consider the following scenario:

enter image description here

If x is the time it takes to service your interrupt, then signal B will never be captured.


If we take your ISR code, stick it into an ISR routine (I used ISR(PCINT0_vect)) routine, declare all the variables volatile, and compile for ATmega168P, the disassembled code looks as follows (see @jipple's answer for more info) before we get to the code that "does something"; in orther words the prologue to your ISR is as follows:

  37                    .loc 1 71 0
  38                    .cfi_startproc
  39 0000 1F92              push r1
  40                .LCFI0:
  41                    .cfi_def_cfa_offset 3
  42                    .cfi_offset 1, -2
  43 0002 0F92              push r0
  44                .LCFI1:
  45                    .cfi_def_cfa_offset 4
  46                    .cfi_offset 0, -3
  47 0004 0FB6              in r0,__SREG__
  48 0006 0F92              push r0
  49 0008 1124              clr __zero_reg__
  50 000a 8F93              push r24
  51                .LCFI2:
  52                    .cfi_def_cfa_offset 5
  53                    .cfi_offset 24, -4
  54 000c 9F93              push r25
  55                .LCFI3:
  56                    .cfi_def_cfa_offset 6
  57                    .cfi_offset 25, -5
  58                /* prologue: Signal */
  59                /* frame size = 0 */
  60                /* stack size = 5 */
  61                .L__stack_usage = 5

so, PUSH x 5, in x 1, clr x 1. Not as bad as jipple's 32-bit vars, but still not nothing.

Some of this is necesary (expand the discussion in the comments). Obviosely, since the ISR routine can occur at any time, it must preseve the registers it uses, unless you know that no code where an interrupt can occur uses the same register as your interrupt routine. For example the following line in the disassembled ISR:

push r24

Is there because everything goes through r24: your pinc is loaded there before it goes into memory, etc. So you must have that first. __SREG__ is loaded into r0 and then pushed: if this could go through r24 then you could save yourself a PUSH


Some possible solutions:

  • Use a tight polling loop as suggested by Kaz in the comments. This is probably going to be the fastest solution, whether you write the loop in C or assembly.
  • Write your ISR in assembly: this way you can optimize the register usage in such a way that the fewest number of them need to be saved during the ISR.
  • Declare your ISR routines ISR_NAKED, though this turns out to be more of a red herring solution. When you declare ISR routines ISR_NAKED, gcc does not generate prologue/epilogue code, and you are responsible for saving any registers your code modifies, as well as calling reti (return from an interrupt). Unfortunately, there is no way of using registers in avr-gcc C directly (obviously you can in assembly), however, what you can do is bind variables to specific registers with the register + asm keywords, like this: register uint8_t counter asm("r3");. If you do that, for the ISR you'll know what registers you are using in the ISR. The problem then is that there is no way to generate push and pop to save the used registers without inline assembly (cf. point 1). To ensure having to save fewer registers, you can also bind all the non-ISR variables to specific registers as well, however, no you run into a problem that gcc uses registers for shuffling data to and from memory. This means that unless you look at the disassembly you will not know what registers your main code uses. So if you are considering ISR_NAKED, you might as well write the ISR in assembly.
\$\endgroup\$
  • \$\begingroup\$ Thanks, so my C code makes the huge overhead? Would it be faster if I write it in assembler? About the second thing, I was aware about that. \$\endgroup\$ – arminb Apr 1 '13 at 18:48
  • \$\begingroup\$ @arminb: I don't know enough to answer that question. My assumption would be is that the compiler is reasonably smart, and it does what it does for a reason. Having said that I am sure that if you spent some time with assembly you could squeeze a few more clock cycles out of your ISR routine. \$\endgroup\$ – angelatlarge Apr 1 '13 at 18:56
  • 1
    \$\begingroup\$ I think that if you want the fastest response, you generally avoid interrupts and poll the pins in a tight loop. \$\endgroup\$ – Kaz Apr 1 '13 at 19:07
  • 1
    \$\begingroup\$ With specific goals in mind it is possible to optimize the code by using assembler. For example the compiler starts with pushing all registers used onto stack, then starts executing the actual routine. If you have timing critical stuff, you can move some of the push's back and pull time critical stuff forward. So yes you can optimize by using assembler, but the compiler in itself is pretty smart too. I like to use the compiled code as a start point and modify that manually for my specific requirements. \$\endgroup\$ – jippie Apr 1 '13 at 19:15
  • 1
    \$\begingroup\$ Really nice answer. I'll add that the compiler adds all sorts of register storage and restorage to suit most users' needs. It is possible to write your own bare-bones interrupt handler-- if you don't need all that overhead. Some compilers may even offer an option to create a "fast" interrupt, leaving much of the "bookkeeping" to the programmer. I wouldn't necessarily go right to a tight loop with no ISR if I couldn't meet my schedule. First I'd consider a faster uC, and then I'd figure out if I could use some sort of glue hardware, like a latch and RTC. \$\endgroup\$ – Scott Seidman Apr 1 '13 at 23:01
2
\$\begingroup\$

There is a lot of PUSH'ing and POP'ing registers to stack going on before your actual ISR starts, that is on top of the 5 clock cycles you mention. Take a look at the disassembly of the generated code.

Depending on the toolchain you use, dumping the assembly listing us done in various ways. I work on Linux command line and this is the command I use (it requires the .elf file as input):

avr-objdump -C -d $(src).elf

Take a look at a code sniplet I recently used for an ATtiny. This is what the C-code looks like:

ISR( INT0_vect ) {
        uint8_t myTIFR  = TIFR;
        uint8_t myTCNT1 = TCNT1;

And this is the generated assembly code for it:

00000056 <INT0_vect>:
  56:   1f 92           push    r1
  58:   0f 92           push    r0
  5a:   0f b6           in      r0, SREG        ; 0x3f
  5c:   0f 92           push    r0
  5e:   11 24           eor     r1, r1
  60:   2f 93           push    r18
  62:   3f 93           push    r19
  64:   4f 93           push    r20
  66:   8f 93           push    r24
  68:   9f 93           push    r25
  6a:   af 93           push    r26
  6c:   bf 93           push    r27
  6e:   48 b7           in      r20, TIFR       ; uint8_t myTIFR  = TIFR;
  70:   2f b5           in      r18, TCNT1      ; uint8_t myTCNT1 = TCNT1;

To be honest, my C routine uses couple more variables that cause all these push'es and pop's, but you get the idea.

Loading a 32 bit variable looks like this:

  ec:   80 91 78 00     lds     r24, 0x0078
  f0:   90 91 79 00     lds     r25, 0x0079
  f4:   a0 91 7a 00     lds     r26, 0x007A
  f8:   b0 91 7b 00     lds     r27, 0x007B

Increasing a 32 bit variable by 1 looks like this:

  5e:   11 24           eor     r1, r1
  d6:   01 96           adiw    r24, 0x01       ; 1
  d8:   a1 1d           adc     r26, r1
  da:   b1 1d           adc     r27, r1

Storing a 32-bit variable looks like this:

  dc:   80 93 78 00     sts     0x0078, r24
  e0:   90 93 79 00     sts     0x0079, r25
  e4:   a0 93 7a 00     sts     0x007A, r26
  e8:   b0 93 7b 00     sts     0x007B, r27

Then of course you have to pop the old values once you leave the ISR:

 126:   bf 91           pop     r27
 128:   af 91           pop     r26
 12a:   9f 91           pop     r25
 12c:   8f 91           pop     r24
 12e:   4f 91           pop     r20
 130:   3f 91           pop     r19
 132:   2f 91           pop     r18
 134:   0f 90           pop     r0
 136:   0f be           out     SREG, r0        ; 0x3f
 138:   0f 90           pop     r0
 13a:   1f 90           pop     r1
 13c:   18 95           reti

According to the instruction summary in the datasheet, most instructions are single cycle, but PUSH and POP are dual cycle. You get the idea where the delay comes from?

\$\endgroup\$
  • \$\begingroup\$ Thanks for your answer! Now I'm aware about what's happening. Especially thank you for the command avr-objdump -C -d $(src).elf! \$\endgroup\$ – arminb Apr 1 '13 at 19:02
  • \$\begingroup\$ Take a few moments to understand the assembly instructions that avr-objdump spits out, they are briefly explained in the datasheet under Instruction Summary. In my opinion it is good practice to get familiar with the mnemonics as it can help a lot when debugging your C code. \$\endgroup\$ – jippie Apr 1 '13 at 19:05
  • \$\begingroup\$ In fact, the disassembling is useful to have as a part of your default Makefile: so whenever you build your project it is automagically disassembled as well so you don't have to think about it, or remember how to do it manually. \$\endgroup\$ – angelatlarge Apr 1 '13 at 19:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.