优化这段C(AVR)代码。

10
我有一个中断处理程序,但速度不够快。基本上,我使用它通过从查找表中输出值到AVR微控制器上的端口来生成正弦波。不幸的是,这并不够快,以至于我无法获得所需的波频率。有人告诉我应该尝试用汇编语言实现它,因为编译器生成的汇编代码可能稍微低效,并且可以被优化,但是在查看了汇编代码后,我真的看不出我能做得更好的地方。
以下是C代码:
const uint8_t amplitudes60[60] = {127, 140, 153, 166, 176, 191, 202, 212, 221, 230, 237, 243, 248, 251, 253, 254, 253, 251, 248, 243, 237, 230, 221, 212, 202, 191, 179, 166, 153, 140, 127, 114, 101, 88, 75, 63, 52, 42, 33, 24, 17, 11, 6, 3, 1, 0, 1, 3, 6, 11, 17, 24, 33, 42, 52, 63, 75, 88, 101, 114};
const uint8_t amplitudes13[13] = {127, 176,  221, 248,  202, 153, 101, 52, 17,  1, 6,  33,  75};
const uint8_t amplitudes10[10] = {127, 176,   248,  202, 101, 52, 17,  1,  33,  75};

volatile uint8_t numOfAmps = 60;
volatile uint8_t *amplitudes = amplitudes60;
volatile uint8_t amplitudePlace = 0; 

ISR(TIMER1_COMPA_vect) 
{
    PORTD = amplitudes[amplitudePlace];

    amplitudePlace++; 

    if(amplitudePlace == numOfAmps)
    {
        amplitudePlace = 0;
    }

}

amplitudes 和 numOfAmps 都会被另一个比这个更慢的中断例程改变(基本上是为了改变正在播放的频率)。最终我将不使用这些确切的数组,但是设置将非常相似。我可能会有一个包含60个值的数组和另一个只有30个值的数组。这是因为我正在构建频率扫描器,并且在较低的频率下,我可以负担更多的样本,因为我有更多的时钟周期来处理,但在较高的频率下,我非常缺乏时间。

我确实意识到可以使用更低的采样率使其工作,但我不想低于每个周期30个样本。我认为将指向该数组的指针不会使它变得更慢,因为从数组获取值的汇编语言和从数组指针获取值的汇编语言似乎是相同的(这很合理)。

在我必须产生的最高频率下,我被告知应该能够以每个正弦波周期约30个样本的速度运行。目前,在30个样本的情况下,最快运行的速度约为所需最大频率的一半,这意味着我的中断需要运行两次更快。

因此,当模拟该代码时,需要65个周期才能完成。同样,我被告知应该能够将其最佳降至约30个周期。

这是生成的ASM代码,并且我的想法是在每行旁边说明它执行的操作:

ISR(TIMER1_COMPA_vect) 
{
push    r1
push    r0
in      r0, 0x3f        ; save status reg
push    r0
eor     r1, r1      ; generates a 0 in r1, used much later
push    r24
push    r25
push    r30
push    r31         ; all regs saved


PORTD = amplitudes[amplitudePlace];
lds     r24, 0x00C8     ; r24 <- amplitudePlace I’m pretty sure
lds     r30, 0x00B4 ; these two lines load in the address of the 
lds     r31, 0x00B5 ; array which would explain why it’d a 16 bit number
                    ; if the atmega8 uses 16 bit addresses


add     r30, r24            ; aha, this must be getting the ADDRESS OF THE element 
adc     r31, r1             ; at amplitudePlace in the array.  

ld      r24, Z              ; Z low is r30, makes sense. I think this is loading
                            ; the memory located at the address in r30/r31 and
                            ; putting it into r24

out     0x12, r24           ; fairly sure this is putting the amplitude into PORTD

amplitudePlace++; 
lds     r24, 0x011C     ; r24 <- amplitudePlace
subi    r24, 0xFF       ; subi is subtract imediate.. 0xFF = 255 so I’m
                        ; thinking with an 8 bit value x, x+1 = x - 255;
                        ; I might just trust that the compiler knows what it’s 
                        ; doing here rather than try to change it to an ADDI 

sts     0x011C, r24     ; puts the new value back to the address of the
                        ; variable

if(amplitudePlace == numOfAmps)
lds     r25, 0x00C8 ; r24 <- amplitudePlace
lds     r24, 0x00B3 ; r25 <- numOfAmps 

cp      r24, r24        ; compares them 
brne    .+4             ; 0xdc <__vector_6+0x54>
        {
                amplitudePlace = 0;
                    sts     0x011C, r1 ; oh, this is why r1 was set to 0 earlier
        }


}

pop     r31             ; restores the registers
pop     r30
pop     r25
pop     r24
pop     r19
pop     r18
pop     r0
out     0x3f, r0        ; 63
pop     r0
pop     r1
reti
除了在中断中使用更少的寄存器以便减少推/弹操作外,我真的看不出这段汇编代码在哪里效率低下。
我唯一的想法是,如果我可以找到一种方法在 C 中获取 n 位整数数据类型,那么可能可以摆脱 if 语句,并且使数字在达到末尾时回绕?我的意思是,我将有 2^n-1 个样本,然后让 amplitudePlace 变量继续计算,直到它达到 2^n 就会溢出并重新设置为零。
我确实尝试过完全删除 if 语句模拟代码,虽然它提高了速度,但只能减少约 10 个周期,因此每次执行大约需要 55 个周期,这仍然不够快,所以我需要进一步优化代码,这很难考虑到没有它只有两行!
我唯一的其他想法是看看是否可以将静态查找表存储在某个需要更少的时钟周期来访问的位置?我认为它使用的用于访问数组的 LDS 指令都需要 2 个周期,因此在那里我可能并不会节省太多时间,但在这个阶段,我愿意尝试任何事情。
我完全不知道从这里开始。我看不出如何使我的 C 代码更加高效,但我在这方面只是一个新手,所以可能会遗漏些什么。我需要任何形式的帮助...我意识到这是一个相当特殊和涉及问题,通常我会尝试避免在这里问这些问题,但我已经在这个问题上工作了很长时间,完全不知所措,所以我真的需要任何可以得到的帮助。

我不了解AVR,所以我想知道以下问题的误解在哪里:为什么在ISR结束时弹出r19r18,而它们没有被推入(或以其他方式使用)?为什么amplitudePlace有时显然位于地址0x00c8,有时又位于0x011c? - Michael Burr
只是出于好奇:当这个问题得到解答并且你的项目完成时,您是否介意将最终的源代码和生成的汇编作为答案发布? - ndim
@Michael - 这是因为我一边优化C代码,一边手动将生成的汇编更改添加到自己的文档中,以便我可以准确地了解发生了什么。但是,你说得对,汇编应该是正确的! :) - JimR
@ndim - 当然没问题!我真的希望能将其降至30个周期,但我们会看情况而定哈哈。 :) - JimR
@Sam:不要忘记,如果你有一个16MHz的AVR(20MHz型号则为5个周期),进入和离开ISR每个需要4个周期。 - ndim
使用32MHz的XMEGA怎么样? - avra
6个回答

6

我可以看到几个需要开始处理的领域,没有特定的顺序:

1. 减少需要推送的寄存器数量,因为每个推送/弹出对需要四个周期。例如,avr-gcc 允许您从其寄存器分配器中删除一些寄存器,因此您可以仅在单个ISR中将它们用作寄存器变量,并确保它们仍然包含上次的值。如果您的程序从不将 r1 设置为除 0 以外的任何值,则还可以摆脱 r1eor r1,r1 的推送。

2. 使用本地临时变量保存数组索引的新值,以避免对该易失性变量进行不必要的加载和存储指令。像这样的东西:

volatile uint8_t amplitudePlace;

ISR() {
    uint8_t place = amplitudePlace;
    [ do all your stuff with place to avoid memory access to amplitudePlace ]
    amplitudePlace = place;
}

3. 从59倒数到0而不是从0到59,这样可以避免使用单独的比较指令(在减法中本来就会与0进行比较)。伪代码如下:

     sub  rXX,1
     goto Foo if non-zero
     movi rXX, 59
Foo:

替代

     add  rXX,1
     compare rXX with 60
     goto Foo if >=
     movi rXX, 0
Foo:

4. 可以考虑使用指针和指针比较(预先计算好值!)而不是数组索引。需要检查倒数计数哪个更有效。也许将数组对齐到256字节边界,并仅使用8位寄存器来保存指针,以便在加载和保存地址的高8位时节省空间。(如果您的SRAM用完了,您仍然可以将这60个字节数组中的4个的内容放入一个256字节的数组中,并且仍然可以获得所有地址由8个常量高位和8个可变低位组成的优势。)

uint8_t array[60];
uint8_t *idx = array; /* shortcut for &array[0] */
const uint8_t *max_idx = &array[59];

ISR() {
    PORTFOO = *idx;
    ++idx;
    if (idx > max_idx) {
        idx = array;
    }
}

问题在于指针是16位的,而你的简单数组索引以前只有8位大小。如果你设计数组地址使得地址的高8位是常量(在汇编代码中为hi8(array)),并且你只处理实际上在ISR中改变的低8位,那么帮助解决这个问题可能会很棘手。不过,这意味着需要编写汇编代码。上面生成的汇编代码可能是编写该版本ISR的很好的起点。
5. 如果从时间角度可行,将样本缓冲区大小调整为2的幂次方,以用简单的i = (i+1) & ((1 << POWER)-1);替换if-reset-to-zero部分。如果你想采用第4条提出的8位/8位地址分割,甚至可以将幂次方增加到256(并根据需要复制样本数据以填充256字节缓冲区),这将节省在ADD之后的AND指令。
6. 如果ISR只使用不影响状态寄存器的指令,请停止推送和弹出SREG
通用
以下内容对于手动检查所有其他汇编代码的假设可能特别有用:
firmware-%.lss: firmware-%.elf
        $(OBJDUMP) -h -S $< > $@

这将生成整个固件映像的完整汇编代码清单,其中包含注释。您可以使用它来验证寄存器(非)使用情况。请注意,启动代码只在您首次启用中断之前运行一次,不会干扰后来 ISR 独占使用寄存器。
如果您决定不直接使用汇编代码编写该 ISR,则建议您编写 C 代码,并在每次编译后检查生成的汇编代码,以便立即观察您的更改最终生成了什么。
您可能最终会编写十几个 C 和汇编 ISR 变体,将每个变体的周期相加,然后选择最佳的一个。
注意:如果没有进行任何寄存器保留,ISR 的周期大约为 31 个周期(不包括进入和离开,这会再增加 8 或 10 个周期)。完全消除寄存器推送将使 ISR 减少到 15 个周期。更改为具有恒定大小为 256 字节的示例缓冲区并允许 ISR 独占使用四个寄存器可使 ISR 中花费的时间降至 6 个周期(加上进入/离开的 8 或 10 个周期)。

1
你不应该费心去操纵像是推哪些寄存器或写倒计时循环这样的事情。任何半靠谱的编译器都应该能够为你完成这些工作。对我来说,解决方案就是增加编译器优化设置,如果这还不起作用,那就换一个新的编译器。 - Lundin
4
在一个有大量出色编译器的理想世界中,我会同意这个观点。然而在现实世界中,新编译器会展示一系列你需要解决的不同问题,可能会过于昂贵,或者出现其它问题。这种 C 语言源代码优化可以是同时兼顾 C 和汇编语言的好办法,而不必直接编写汇编源代码。并且对于 AVR 项目通常具有受限代码大小的情况,仍然可以处理相当低层次的 C 源代码。 - ndim
哈哈,大部分的内容都超出了我的理解范围...你能推荐一些我可以阅读的地方吗?我知道指针是什么,但从未听说过指针比较。不过还是谢谢你的建议,非常有用! - JimR
@ndim,但编译器确实应该能够更好地处理这些简洁的C代码,特别是你指出的寄存器推入/弹出。虽然我没有使用过Atmel,但它绝不是一个罕见的平台,肯定有很多可供选择的编译器。 - Lundin
@Sam:*.lss 文件 objdump 可以生成固件中所有代码的汇编语言列表。然后,您可以通过该代码 grep 查找 rN。我认为我已经在上次编辑答案中回答了其他问题。 - ndim
显示剩余2条评论

3

我认为最好的方法是使用纯汇编语言编写ISR。代码非常简短和简单,而且你有现有的反汇编器来指导你。但对于这种情况,你应该能够做得更好:例如,使用更少的寄存器来减少pushpop操作;重新设计代码结构,使其不需要从内存中分别加载三次amplitudePlace等。


啊,真不敢相信我居然没有意识到我可以将amplitudePlace变量存储在同一个寄存器中...这是个好建议,谢谢。希望我能够减少它使用的寄存器数量...我刚刚意识到,每个被清除的寄存器都可以节省4个时钟周期。 :) - JimR

3

你必须与程序的其余部分共享所有这些变量吗?由于每个这样的变量都必须是易失性的,编译器不允许对其进行优化。至少,amplitudePlace看起来可以改为本地静态变量,然后编译器可能能够进一步优化它。


再次强调,我不确定如何将其变成本地变量...它不是必须全局的,这样我才能记住下一次ISR被调用时我在哪里吗?还是我的想法有误?但你说得对,amplitudePlace只需要被这个特定的ISR读取/修改,但我们强调任何从ISR修改的变量都应该设置为volatile,以便编译器在优化时不会假设它知道它将保持不变。根据你所说,这似乎可能过于简单化了? - JimR
1
@Sam:它需要具有静态存储期(这是全局变量的唯一选项),但不需要在全局可见。您可以将其放入ISR并使用static关键字将其变为具有静态存储期的局部变量(意味着它从一次迭代到下一次迭代保持其值)。如果它仅由ISR使用,则也不需要是volatile。该要求仅适用于被ISR和程序其他部分同时使用的变量。 - caf
在ISR内声明为静态变量和在模块级别声明将具有相同的性能。去掉volatile修饰符可以帮助提高性能,同时仍允许在ISR外部更新它 - 假设它是机器字大小或在更新期间关闭了中断。 - phkahler

2
为了澄清,您的中断应该是这样的:
ISR(TIMER1_COMPA_vect) 
{
    PORTD = amplitudes[amplitudePlace++];
    amplitudePlace &= 63;
}

这将需要您的表格长度为64个条目。如果您可以选择表格的地址,您可以只使用一个指针,增加它,并与0xffBf进行“&”操作。

如果使用变量而不是固定常量会减慢速度,您可以用一个特定的数组替换指针变量:

PORTD = amplitudes13[amplitudePlace++];

然后,您需要更改中断指针以使用不同的函数来处理每个波形。虽然这不太可能节省很多,但我们正在将其降至总共10个周期左右。
至于寄存器使用问题。一旦您获得了一个非常简单的ISR,您可以检查ISR的prolog和epilog,它们会推送和弹出处理器状态。如果您的ISR仅使用1个寄存器,则可以使用汇编语言完成,并仅保存和恢复该一个寄存器。这将减少中断开销,而不影响程序的其余部分。某些编译器可能会为您完成此操作,但我怀疑这一点。
如果有时间和空间,您还可以创建一个长表格,并将++替换为+=freq,其中freq将使波形成为基础频率的整数倍(2x、3x、4x等),通过跳过那么多样本来实现。

选择您的表地址,使得(table & 64) == 0,这样您就可以使用单个指针,将其递增,并与~64进行&运算。 - ndim
还要注意,在AVR上更改中断指针并不是一件简单的事情,因为它需要你在Flash中更改程序存储器,即你需要处理闪存页擦除等问题。同样不容易的是将新波形的数据仅复制到单个SRAM波形采样缓冲区中 - 但是你可以将新波形保留在程序存储器(也称为闪存),这比SRAM更加广泛! - ndim
avr-gcc 只会推送其生成的代码实际上会破坏的寄存器。因此,通过手动编写汇编语言来节省 push/pop 指令的任何节约都必须在更高效的寄存器代码中进行,而不仅仅是删除一堆无用的 push/pop 指令。 - ndim
我喜欢 i = (i+1) & ~64; 这个想法,以及跳过样本的想法。只有 freq 值(对于 i = (i+freq) & ~64)是2的幂次方,才能避免在某些 freq/timer 组合中变得明显的舍入误差。 - ndim
当然,为了使“& ~64”技巧起作用,必须满足freq<=64的条件。 - ndim
@ndim:如果频率超过N/8,输出会出现非常严重的混叠。感谢指出AVR的具体问题-中断请求指针在闪存中,GCC确实最小化了推入/弹出操作。我自己不是AVR用户,之前并不知道这些。 - phkahler

1

你有没有考虑过将问题反转,以固定的中断频率变化步进速度,而不是以不同的中断频率逐个遍历表格?这样ISR本身会更重,但你可以承受更低的运行速度。此外,通过一些固定点算术,你可以轻松生成更广泛的频谱,而不必使用多个表格。

无论如何,在这种类型的问题上,有一百种方法可以欺骗以节省周期,如果你能够稍微调整硬件要求。例如,你可以将计时器的输出链接到另一个硬件计时器的时钟,并使用第二个计时器的计数器作为表格索引。你可以保留全局寄存器或滥用未使用的I/O来存储变量。你可以在COMPA中断中同时查找两个条目(或插值),并设置一个小的第二个COMPB中断来发出缓冲的条目。等等,等等。

通过一些硬件滥用和精心制作的汇编代码,你应该能够在15个周期左右完成这个任务,而不会遇到太多麻烦。是否能使其与系统的其他部分协调良好是另一个问题。


0
也许可以通过使用算术表达式来完全摆脱条件和比较。
ISR(TIMER1_COMPA_vect) 
{
        PORTD = amplitudes[amplitudePlace];

        amplitudePlace = (amplitudePlace + 1) % numOfAmps;
}

如果您的CPU以合理的速度执行模数运算,那么这应该会快得多。如果仍然不够,请尝试使用汇编语言编写此版本。

AVR没有除法操作。 - ndim
啊,那可能会节省我几个时钟周期 - 谢谢! 我想我需要检查一下加和模数是否比比较和分支更快。 我有一种感觉它们将非常相似,但值得尝试吧。 :) - JimR
4
通过添加取模运算符来优化代码通常不是一个好主意。在大多数CPU上,除法运算非常缓慢。 - Lundin
啊,好的。有没有一种方法可以使用某种位掩码操作来实现它,以便在达到某个值时重置,但否则不改变?如果可以的话,我认为这可能是一种方法。 - JimR
哦,我觉得对于那种情况,只需要使用0111的AND运算就可以了吧?今天我有点慢。 :) - JimR
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接