为获得更好的性能而为嵌入式软件编写函数时的最佳方法是什么?[关闭]


13

我已经看到了一些用于微控制器的库,它们的功能一次只能做一件事。例如,如下所示:

void setCLK()
{
    // Code to set the clock
}

void setConfig()
{
    // Code to set the config
}

void setSomethingElse()
{
   // 1 line code to write something to a register.
}

然后在它上面放其他功能,它使用此1行代码包含包含用于其他目的的功能。例如:

void initModule()
{
   setCLK();
   setConfig();
   setSomethingElse();
}

我不确定,但是我相信这样会在每次调用或退出函数时创建更多的跳转调用,并增加堆栈返回地址的开销。那样会使程序运行缓慢,对吗?

我已经搜索过,到处都在说编程的经验法则是,一个函数只能执行一个任务。

因此,如果我直接编写一个InitModule函数模块来设置时钟,添加一些所需的配置并执行其他操作而无需调用函数。编写嵌入式软件时,这是一种不好的方法吗?


编辑2:

  1. 似乎很多人已经理解了这个问题,好像我正在尝试优化程序一样。不,我无意这样做。我让编译器去做,因为它总是会(比我更好)。

  2. 选择一个代表一些初始化代码的例子都怪我。该问题无意涉及出于初始化目的而进行的函数调用。我的问题是,将特定任务分解为无限循环内运行的多行小函数(因此,行内是不可能的)是否比编写没有任何嵌套函数的长函数有任何优势?

请考虑@Jonk答案中定义的可读性。


28
如果您相信任何合理的编译器都会盲目地将所编写的代码转换为所编写的二进制文件,那么您会非常天真(不是侮辱)。大多数现代编译器都非常擅长于确定何时更好地内联例程,甚至应在何时使用寄存器与RAM的位置来保存变量。遵循两个优化规则:1)不优化。2)不要优化还没有。使您的代码具有可读性和可维护性,然后仅在对工作系统进行性能分析后,再进行优化。
akohlsmith

10
@akohlsmith IIRC 优化的三个规则是:1)不要!2)不,真的不!3)用户档案,再然后才是优化,如果你一定要 - Michael_A._Jackson
esoterik

3
请记住,“过早的优化是所有罪恶的根源(或至少大部分)在编程” - 高德纳
Mawg说起用莫妮卡

1
@Mawg:手术词还为时过早。(正如该论文的下一段所述。从字面上看,下句话是:“但是,我们不应该在这3%的临界点上放弃我们的机会。”)不要在需要之前进行优化-您不会发现速度缓慢直到您需要介绍一些内容-也不进行悲观化,例如使用公然错误的工具进行工作。
cHao

1
@Mawg我不知道为什么会得到与优化相关的答案/反馈,因为我从未提及过这个词,而且我打算这样做。问题更多的是关于如何在嵌入式编程中编写函数以获得更好的性能。
马尼雅克

Answers:


28

可以说,在您的示例中,性能并不重要,因为代码在启动时仅运行一次。

我使用的经验法则是:编写尽可能可读的代码,并且只有在发现编译器没有正确发挥其魔力时才开始进行优化。

就存储和定时而言,ISR中函数调用的成本可能与启动期间函数调用的成本相同。但是,ISR期间的时序要求可能更为关键。

此外,正如其他人已经注意到的,函数调用的成本(和“成本”的含义)因平台,编译器,编译器优化设置和应用程序要求而异。8051和cortex-m7,起搏器和电灯开关之间将有巨大的差异。


6
IMO第二段应以粗体显示在顶部。立即选择正确的算法和数据结构没有错,但是担心函数调用开销,除非您发现这是一个实际的瓶颈,绝对是过早的优化,应该避免。
基金莫妮卡的诉讼

11

我没有想到的优势(但请参阅底部的JasonS注释),将一行代码包装为一个函数或子例程。也许您可以将函数命名为“可读”。但是您也可以对这一行发表评论。而且由于在函数中包装一行代码会浪费代码存储空间,堆栈空间和执行时间,因此在我看来,这几乎适得其反。在教学情况下?这可能是有道理的。但这取决于学生的班级,他们的事先准备,课程和老师。通常,我认为这不是一个好主意。但这是我的意见。

这将我们带到了底线。几十年来,您的主要问题领域一直是一些辩论的问题,而至今仍是一些辩论的问题。因此,至少在我阅读您的问题时,在我看来,这是一个基于观点的问题(正如您所问的那样)。

如果您要更详细地了解情况并仔细描述您的首要目标,则可以脱离基于观点的观点。您定义的测量工具越好,答案可能越客观。


概括地说,您希望对任何编码执行以下操作。(下面,我将假设我们正在比较所有实现目标的不同方法。显然,无论编写方式如何,任何无法执行所需任务的代码都比成功的代码差。)

  1. 对您的方法保持一致,以便其他人阅读您的代码可以加深对您如何进行编码过程的理解。不一致可能是最严重的犯罪。这不仅使他人感到困难,而且使您自己在数年后重新回到代码中变得困难。
  2. 尽可能尝试安排事物,以便可以在不考虑顺序的情况下执行各种功能部分的初始化。在需要排序的情况下,如果是由于两个高度相关的子函数的紧密耦合而引起的,那么请考虑对两个函数进行一次初始化,以便可以对其进行重新排序而不会造成损害。如果不可能,请记录初始化订购要求。
  3. 尽可能将知识封装在一个地方。常量不应在代码的各处重复。解决某个变量的方程式应该只存在于一个位置。等等。如果您发现自己在不同位置复制并粘贴了一些行,这些行在某些位置执行了某些所需的行为,请考虑一种在一个位置捕获该知识并在需要时使用它的方法。例如,如果您具有必须以特定方式行走的树结构,则不要在您需要遍历树节点的每个位置复制树遍历代码。取而代之的是,将树走法捕获到一个地方并加以使用。这样,如果树变了,而步行方法也变了,那么您只需要担心一个地方,其余所有代码“都可以正常工作”。
  4. 如果您将所有例程分散到一张巨大的平板纸上,并用箭头将它们连接起来,就像其他例程所调用的那样,您会发现在任何应用程序中都会有包含大量箭头的例程的“簇”彼此之间,但小组外只有几支箭。因此,紧密耦合的例程之间存在自然界线,而其他紧密耦合的例程组之间则存在松散耦合的连接。利用这一事实将代码组织到模块中。这将极大地限制代码的外观复杂性。

以上对于所有编码通常都是正确的。我没有讨论参数,局部或静态全局变量等的使用。原因是对于嵌入式编程,应用程序空间通常会放置极端且非常重要的新约束,并且不讨论每个嵌入式应用程序就不可能讨论所有这些约束。无论如何,这不是在这里发生。

这些限制可能是以下任何(或更多)限制:

  • 严重的成本限制要求极其原始的MCU具有极小的RAM,几乎没有I / O引脚数。对于这些,将应用全新的规则集。例如,由于没有太多的代码空间,您可能必须编写汇编代码。您可能只需要使用静态变量,因为使用局部变量过于昂贵且耗时。您可能必须避免过多使用子例程,因为(例如,某些Microchip PIC部件)只有4个硬件寄存器可存储子例程返回地址。因此,您可能必须大幅“展平”您的代码。等等。
  • 严重的功率限制要求精心设计的代码来启动和关闭大多数MCU,并且在全速运行时对代码的执行时间设置了严格的限制。同样,这有时可能需要一些汇编代码。
  • 严格的计时要求。例如,有时候我必须确保漏极开路0的传输必须与1的传输完全相同的周期数。并且还必须对同一条线进行采样与此时间有一个确切的相对相位。这意味着在这里不能使用C。唯一可以保证的方法就是精心编写汇编代码。(即使如此,并非所有ALU设计都总是如此。)

等等。(生命攸关的医疗仪器的接线代码也拥有自己的整个世界。)

这里的结果是,嵌入式编码通常不是全部免费的,您可以像在工作站上那样在其中进行编码。往往存在严重的竞争性原因,造成许多非常困难的限制。而这些可能会强烈反对将更多的传统股票的答案。


关于可读性,我发现如果以一致的方式编写代码,并且在阅读时可以学习,那么代码是可读的。在没有故意混淆代码的地方。确实并不需要太多。

可读的代码效率很高,并且可以满足我已经提到的所有上述要求。最主要的是,您在编写代码时完全了解在汇编或机器级别编写的每一行代码会产生什么。C ++在这里给程序员带来了沉重的负担,因为在许多情况下,相同的C ++代码片段实际上会生成性能各不相同的不同的机器代码片段。但是,一般而言,C通常是一种“所见即所得”的语言。因此在这方面更安全。


根据JasonS编辑:

我从1978年开始使用C,从1987年以来一直使用C ++,并且在大型机,小型计算机和(主要是)嵌入式应用程序上使用这两种方法都有很多经验。

Jason提出了有关使用“内联”作为修饰语的评论。(在我看来,这是一个相对“新”的功能,因为使用C和C ++可能在我生命的一半或更长时间中根本不存在。)使用内联函数实际上可以进行这样的调用(即使是一行调用)。代码)非常实用。而且由于编译器可以应用的类型,它比使用宏要好得多。

但是也有局限性。首先,您不能依靠编译器来“获取提示”。它可能会也可能不会。并且有充分的理由不接受提示。(举一个明显的例子,如果使用了函数的地址,则需要实例化函数,并且使用该地址进行调用将...需要调用。然后无法内联代码。)其他原因也是如此。编译器可能有各种各样的标准来判断如何处理提示。作为程序员,这意味着您必须花一些时间来了解编译器的这一方面,否则您可能会基于有缺陷的想法做出决策。因此,这既增加了代码编写者的负担,也增加了任何读者以及计划将代码移植到其他编译器的人员的负担。

此外,C和C ++编译器支持单独的编译。这意味着他们可以编译一段C或C ++代码,而无需编译该项目的任何其他相关代码。为了内联代码,假设编译器可能选择这样做,则它不仅必须具有声明“ in scope”,而且还必须具有定义。通常,程序员在使用“内联”时会努力确保确实如此。但是错误很容易蔓延。

通常,虽然我在我认为合适的地方也使用内联,但是我倾向于认为我不能依赖它。如果性能是一个重要的要求,并且我认为OP已经清楚地写到,当他们采用更“实用”的路线时会对性能造成重大影响,那么我当然会选择避免将内联作为编码实践来使用,并且而是遵循稍微不同但完全一致的代码编写模式。

关于“内联”和定义在单独的编译步骤“范围内”的最后说明。有可能(并非始终可靠)在链接阶段执行工作。仅当C / C ++编译器将足够的详细信息嵌入到目标文件中以允许链接器对“内联”请求执行操作时,才会发生这种情况。我个人还没有体验过支持此功能的链接器系统(Microsoft之外)。但是它会发生。同样,是否应依赖它取决于情况。但是我通常认为这还没有被应用到链接器中,除非我有充分的证据可以知道。如果我确实依靠它,它将被记录在显眼的地方。


C ++

对于那些感兴趣的人,这是一个示例,说明为什么尽管嵌入式C ++现已可用,但我在对C ++进行编码时仍然非常谨慎。我会抛出一些我认为所有嵌入式C ++程序员都需要了解Cold的术语:

  • 部分模板专业化
  • 虚拟表
  • 虚拟基础对象
  • 激活框
  • 激活框架展开
  • 在构造函数中使用智能指针的原因
  • 收益优化

那只是一个简短的清单。如果您还不了解这些术语的全部内容以及为什么我列出它们(以及更多我未在此处列出的术语),那么我建议您不要在嵌入式工作中使用C ++,除非它不是该项目的选择。

让我们快速看一下C ++异常语义,以弄清味道。

当C ++编译器完全不知道在单独的编译单元(在不同的时间编译)中可能需要哪种异常处理时,它必须为编译单元生成正确的代码。 AB

采取以下代码序列,这些代码是某些编译单元中某些功能的一部分:A

   .
   .
   foo ();
   String s;
   foo ();
   .
   .

出于讨论目的,编译单元在其源代码中的任何地方都不使用“ try..catch” 。它也不使用“ throw”。实际上,可以说它不使用C编译器无法编译的任何源,除了它使用C ++库支持并且可以处理诸如String之类的对象外。该代码甚至可能是经过稍微修改以利用一些C ++功能(例如String类)的C源代码文件。A

另外,假定foo()是位于编译单元的外部过程,并且编译器对此进行了声明,但不知道其定义。B

C ++编译器会看到对foo()的首次调用,并且如果foo()引发异常,则可以只允许正常激活帧展开。换句话说,C ++编译器知道此时不需要额外的代码来支持异常处理中涉及的帧展开过程。

但是一旦创建String,C ++编译器就会知道,如果以后发生异常,则必须先将其正确销毁,然后才能允许帧展开。因此,对foo()的第二次调用在语义上与第一次调用不同。如果对foo()的第二次调用引发异常(它可能会也可能不会),则编译器必须先放置旨在处理String的销毁的代码,然后再释放通常的帧。这与第一次调用foo()所需的代码不同

(可以在C ++中添加其他修饰来帮助限制此问题。但是事实是,使用C ++的程序员必须更加清楚自己编写的每一行代码的含义。)

与C的malloc不同,C ++的新函数使用异常来表示何时无法执行原始内存分配。“ dynamic_cast”也是如此。(有关C ++中的标准异常,请参见Stroustrup的第三版,C ++编程语言,第384页和第385页。)编译器可以允许禁用此行为。但是总的来说,由于生成的代码中格式正确的异常处理序言和结语,即使实际上并没有发生异常,甚至在编译的函数实际上没有任何异常处理块的情况下,也会招致一些开销。(Stroustrup公开感叹了这一点。)

如果没有部分模板专门化(并非所有C ++编译器都支持),模板的使用会给嵌入式编程带来灾难。没有它,代码泛滥将是一个严重的风险,可能会在一瞬间杀死一个小内存嵌入式项目。

当C ++函数返回一个对象时,将创建并销毁一个未命名的编译器临时文件。如果在return语句中使用对象构造函数而不是局部对象,则某些C ++编译器可以提供高效的代码,从而减少了一个对象的构造和销毁需求。但是,并不是每个编译器都这样做,而且许多C ++程序员甚至都不知道这种“返回值优化”。

为对象构造函数提供单个参数类型可能允许C ++编译器以完全不希望给程序员的方式在两种类型之间找到转换路径。这种“聪明”的行为不是C的一部分。

指定基本类型的catch子句将“切片”抛出的派生对象,因为抛出的对象是使用catch子句的“静态类型”而不是对象的“动态类型”复制的。异常苦难的根源并不罕见(当您认为您甚至可以负担嵌入式代码中的异常时)。

C ++编译器可以为您自动生成构造函数,析构函数,复制构造函数和赋值运算符,而产生意想不到的结果。详细了解此过程需要花费时间。

将派生对象数组传递给接受基础对象数组的函数,很少生成编译器警告,但几乎总是会产生错误的行为。

由于在对象构造函数中发生异常时C ++不会调用部分构造的对象的析构函数,因此在构造函数中处理异常通常会强制使用“智能指针”,以确保如果确实发生异常,则构造函数中的构造片段会被正确销毁。(请参阅Stroustrup,第367和368页。)这是用C ++编写好的类的常见问题,但由于C没有内置构造和销毁的语义,因此在C中当然可以避免。编写适当的代码来处理构造对象内子对象的存在意味着编写必须解决C ++中这种独特语义问题的代码;换句话说,“写” C ++语义行为。

C ++可以复制传递给对象参数的对象。例如,在以下片段中,调用“ rA(x);” 可能会导致C ++编译器调用参数p的构造函数,以便随后调用复制构造函数将对象x传递到参数p,然后是函数rA的返回对象(一个未命名的临时对象)的另一个构造函数,这当然是从参数p复制。更糟糕的是,如果A类拥有自己的需要构造的物体,这可能会带来灾难性的伸缩。(由于C程序员没有这样方便的语法,并且一次只能表达所有细节,因此AC程序员可以避免大部分这种浪费的手工优化工作。)

    class A {...};
    A rA (A p) { return p; }
    // .....
    { A x; rA(x); }

最后,是C程序员的简短说明。longjmp()在C ++中没有可移植的行为。(某些C程序员将其用作一种“异常”机制。)某些C ++编译器实际上会尝试采用longjmp进行清理,但这种行为在C ++中是不可移植的。如果编译器确实清除了构造的对象,则它是不可移植的。如果编译器没有清除它们,则由于longjmp导致代码离开了构造对象的范围,并且行为无效,因此不会破坏对象。(如果在foo()中使用longjmp没有超出范围,则该行为可能很好。)C嵌入式程序员不太经常使用此方法,但在使用它们之前,他们应该使自己意识到这些问题。


4
这种仅使用一次的函数永远不会被编译为函数调用,而只是将代码放置在此处而无需任何调用。
多里安

6
@Dorian-您的评论在某些情况下对于某些编译器可能是正确的。如果函数在文件中是静态的,则编译器可以选择使代码内联。如果从外部可见,那么即使从未真正调用过它,也必须有一种方法可以调用。
uɐɪ

1
@jonk-您没有在好的答案中提到的另一个技巧是编写简单的宏函数,这些宏函数将初始化或配置作为扩展的内联代码执行。这在RAM /堆栈/函数调用深度受限制的非常小的处理器上特别有用。
uɐɪ

@ʎəʞouɐɪ是的,我错过了在C中讨论宏的知识。C++不赞成使用这些宏,但是对此进行讨论可能会很有用。如果我能找到有用的东西来写,我可能会解决。
jonk

1
@jonk-我完全不同意你的第一句话。像inline static void turnOnFan(void) { PORTAbits &= ~(1<<8); }在很多地方都被称为这样的例子是一个完美的选择。
詹森·S

8

1)首先是可读性和可维护性的代码。任何代码库最重要的方面是其结构良好。编写良好的软件往往会减少错误。您可能需要在几周/几月/几年内进行更改,如果您的代码可读性很好,那么它会大有帮助。也许其他人必须做出改变。

2)运行一次的代码的性能无关紧要。注重风格,而不是表现

3)即使是紧密循环中的代码也必须首先正确。如果遇到性能问题,请在代码正确后进行优化。

4)如果需要优化,就必须衡量!您认为还是有人告诉您static inline只是对编译器的建议都没有关系。您必须看一下编译器的功能。您还必须衡量内联是否确实提高了性能。在嵌入式系统中,由于代码存储空间通常非常有限,因此您还必须测量代码大小。这是区分工程与猜测的最重要规则。如果您没有测量它,它就没有帮助。工程正在测量。科学正在写下来;)


2
对于您的其他出色帖子,我唯一的批评是第2点。的确,初始化代码的性能无关紧要-但是在嵌入式环境中,大小可能很重要。(但这并不会覆盖第1点;需要时(而不是之前)开始进行尺寸优化)
Martin Bonner支持Monica

2
起初,初始化代码的性能可能无关紧要。当您添加低功耗模式并希望快速恢复以处理唤醒事件时,它就变得很重要。
berendi-抗议

5

当一个函数仅在一个位置(甚至在其他函数内部)被调用时,编译器始终将代码放在该位置,而不是真正地调用该函数。如果在很多地方都调用了该函数,则至少从代码大小的角度来看,使用一个函数才有意义。

编译后的代码将不会有多个调用,而是大大提高了可读性。

此外,您还希望将ADC初始化代码与其他c函数(而不是主c文件)放在同一库中。

许多编译器允许您为速度或代码大小指定不同级别的优化,因此,如果您在许多地方调用了一个小的函数,则该函数将被“内联”,复制到此处而不是调用。

速度的优化将在尽可能多的位置内联函数,代码大小的优化将调用该函数,但是,仅在一个位置调用函数时(如您所愿),它将始终被“内联”。

像这样的代码:

function_used_just_once{
   code blah blah;
}
main{
  codeblah;
  function_used_just_once();
  code blah blah blah;
{

将编译为:

main{
 code blah;
 code blah blah;
 code blah blah blah;
}

不打任何电话

问题的答案,在您的示例或类似示例中,代码的可读性不影响性能,速度或代码大小没有多大关系。通常使用多个调用只是为了使代码可读,最后将它们编译为嵌入式代码。

更新以指定上面的语句对于故意削弱的免费版本编译器(如Microchip XCxx免费版本)无效。这种函数调用是Microchip的金矿,它可以显示付费版本有多好,如果您对此进行编译,则可以在ASM中找到与在C代码中一样多的调用。

也不适合那些希望使用指向内联函数的指针的愚蠢程序员。

这是电子学部分,而不是一般的C C ++或编程部分,问题是关于微控制器编程的问题,在这种情况下,任何体面的编译器默认都会进行上述优化。

因此,请停止投票,仅因为在极少数罕见的情况下这可能不是事实。


15
代码是否内联是编译器供应商实现的特定问题;即使使用inline关键字也不能保证内联代码。这是对编译器的提示。好的编译器如果知道内联函数,肯定会内联一次。但是,如果范围内有任何“易失”对象,通常不会这样做。
彼得·史密斯

9
这个答案是不正确的。正如@PeterSmith所说,根据C语言规范,编译器可以选择内联代码,但可以不进行内联,并且在许多情况下不会这样做。世界上有这么多针对不同目标处理器的编译器,它们在此答案中做出了笼统的声明,并假设所有编译器在只能选择to时都将内联代码。
uɐɪ

2
@ʎəʞouɐɪ您指出了不可能的罕见情况,不首先调用函数将是一个坏主意。在OP给出的简单示例中,我从未见过如此笨拙的编译器能够真正使用调用。
多里安

6
在这些函数仅被调用一次的情况下,优化函数调用几乎是没有问题的。在设置过程中,系统是否真的需要收回每个时钟周期?就像在任何地方进行优化一样,请编写可读代码,并且仅在分析表明需要时才进行优化。
Baldrickk

5
@MSalters我不关心编译器最终在这里做什么-而是在程序员如何处理它方面。如问题所示,中断初始化不会对性能造成影响,也可以忽略不计。
Baldrickk

2

首先,没有好坏之分。这都是意见问题。您非常正确地认为这效率低下。是否可以进行优化?这取决于。通常,您会在单独的文件/目录中看到这些类型的功能,时钟,GPIO,计时器等。编译器通常无法跨这些差距进行优化。我可以知道的一种东西,但并没有广泛用于这种东西。

单个文件:

void dummy (unsigned int);

void setCLK()
{
    // Code to set the clock
    dummy(5);
}

void setConfig()
{
    // Code to set the configuration
    dummy(6);
}

void setSomethingElse()
{
   // 1 line code to write something to a register.
    dummy(7);
}

void initModule()
{
   setCLK();
   setConfig();
   setSomethingElse();
}

选择一个目标和编译器以进行演示。

Disassembly of section .text:

00000000 <setCLK>:
   0:    e92d4010     push    {r4, lr}
   4:    e3a00005     mov    r0, #5
   8:    ebfffffe     bl    0 <dummy>
   c:    e8bd4010     pop    {r4, lr}
  10:    e12fff1e     bx    lr

00000014 <setConfig>:
  14:    e92d4010     push    {r4, lr}
  18:    e3a00006     mov    r0, #6
  1c:    ebfffffe     bl    0 <dummy>
  20:    e8bd4010     pop    {r4, lr}
  24:    e12fff1e     bx    lr

00000028 <setSomethingElse>:
  28:    e92d4010     push    {r4, lr}
  2c:    e3a00007     mov    r0, #7
  30:    ebfffffe     bl    0 <dummy>
  34:    e8bd4010     pop    {r4, lr}
  38:    e12fff1e     bx    lr

0000003c <initModule>:
  3c:    e92d4010     push    {r4, lr}
  40:    e3a00005     mov    r0, #5
  44:    ebfffffe     bl    0 <dummy>
  48:    e3a00006     mov    r0, #6
  4c:    ebfffffe     bl    0 <dummy>
  50:    e3a00007     mov    r0, #7
  54:    ebfffffe     bl    0 <dummy>
  58:    e8bd4010     pop    {r4, lr}
  5c:    e12fff1e     bx    lr

这就是这里的大多数答案告诉您的,您是天真的,并且所有这些都得到了优化,并且删除了功能。好吧,因为默认情况下它们是全局定义的,所以它们不会被删除。如果不需要此文件,我们可以将其删除。

void dummy (unsigned int);

static void setCLK()
{
    // Code to set the clock
    dummy(5);
}

static void setConfig()
{
    // Code to set the configuration
    dummy(6);
}

static void setSomethingElse()
{
   // 1 line code to write something to a register.
    dummy(7);
}

void initModule()
{
   setCLK();
   setConfig();
   setSomethingElse();
}

立即删除它们,因为它们已内联。

Disassembly of section .text:

00000000 <initModule>:
   0:    e92d4010     push    {r4, lr}
   4:    e3a00005     mov    r0, #5
   8:    ebfffffe     bl    0 <dummy>
   c:    e3a00006     mov    r0, #6
  10:    ebfffffe     bl    0 <dummy>
  14:    e3a00007     mov    r0, #7
  18:    ebfffffe     bl    0 <dummy>
  1c:    e8bd4010     pop    {r4, lr}
  20:    e12fff1e     bx    lr

但是现实是当您采用芯片供应商或BSP库时,

Disassembly of section .text:

00000000 <_start>:
   0:    e3a0d902     mov    sp, #32768    ; 0x8000
   4:    eb000010     bl    4c <initModule>
   8:    eafffffe     b    8 <_start+0x8>

0000000c <dummy>:
   c:    e12fff1e     bx    lr

00000010 <setCLK>:
  10:    e92d4010     push    {r4, lr}
  14:    e3a00005     mov    r0, #5
  18:    ebfffffb     bl    c <dummy>
  1c:    e8bd4010     pop    {r4, lr}
  20:    e12fff1e     bx    lr

00000024 <setConfig>:
  24:    e92d4010     push    {r4, lr}
  28:    e3a00006     mov    r0, #6
  2c:    ebfffff6     bl    c <dummy>
  30:    e8bd4010     pop    {r4, lr}
  34:    e12fff1e     bx    lr

00000038 <setSomethingElse>:
  38:    e92d4010     push    {r4, lr}
  3c:    e3a00007     mov    r0, #7
  40:    ebfffff1     bl    c <dummy>
  44:    e8bd4010     pop    {r4, lr}
  48:    e12fff1e     bx    lr

0000004c <initModule>:
  4c:    e92d4010     push    {r4, lr}
  50:    ebffffee     bl    10 <setCLK>
  54:    ebfffff2     bl    24 <setConfig>
  58:    ebfffff6     bl    38 <setSomethingElse>
  5c:    e8bd4010     pop    {r4, lr}
  60:    e12fff1e     bx    lr

您绝对肯定会开始增加开销,这会显着降低性能和空间成本。每种功能的百分之几到百分之五,取决于每个功能的大小。

为什么要这样做?其中一些规则是教授会或仍然会教给简化评分代码的规则。函数必须放在页面上(当您在纸上打印出作品时返回),不要这样做,不要这样做等等。其中很多是为了使库具有用于不同目标的通用名称。如果您有数十个微控制器系列,其中一些共享外围设备,而有些则不共享,那么可能会在这些系列中混合使用三种或四种不同的UART版本,不同的GPIO,SPI控制器等。您可以使用通用gpio_init()函数, get_timer_count()等。并将这些抽象用于不同的外围设备。

它成为主要是可维护性和软件设计的情况,并且具有一些可读性。您无法拥有的全部可维护性,可读性和性能;您一次只能选择一两个,而不能同时选择三个。

这很大程度上是一个基于意见的问题,上面显示了可以通过三种主要方式进行操作。至于最好的走法是严格的意见。所有工作都在一个功能中完成吗?一个基于观点的问题,一些人倾向于性能,一些人将模块化及其可读性定义为BEST。很多人称之为可读性的有趣问题非常痛苦。要“查看”代码,您必须一次打开50-10,000个文件,并以某种方式尝试按执行顺序线性查看功能,以查看发生了什么。我发现与可读性相反,但是其他人发现可读性很强,因为每个项目都适合屏幕/编辑器窗口,并且在记住要调用的功能和/或具有可以弹出和弹出的编辑器后可以整体使用项目中的每个功能。

当您看到各种解决方案时,这是另一个重要因素。文本编辑器,IDE等非常个人化,它超越了vi vs Emacs。如果您对所使用的工具感到满意且高效,那么编程效率,每天/每月的行数都会增加。该工具的功能可以/将有意或无意地倾向于该工具的爱好者如何编写代码。因此,如果一个人正在编写这些库,则该项目在某种程度上反映了这些习惯。即使是团队,首席开发人员或老板的习惯/偏好也可能被强加给团队的其他成员。

其中隐藏了许多个人喜好的编码标准,非常具有宗教色彩的vi与Emacs,制表符与空间,如何排列括号等,这些在某种程度上影响了库的设计方式。

你应该怎么写你的?但是,如果您想要的话,它的功能确实没有错误的答案。肯定有不好的代码或有风险的代码,但是如果编写的代码能够根据需要进行维护,则它可以满足您的设计目标,如果性能很重要,则可以放弃可读性和某些可维护性,反之亦然。您是否喜欢简短的变量名,以便单行代码适合编辑器窗口的宽度?或使用过长的描述性名称以避免混淆,但可读性下降,因为您无法在页面上一行。现在它在视觉上被分解,弄乱了水流。

您不会在第一次打本垒打。可能/应该花费数十年才能真正定义您的样式。同时,在那段时间里,您的风格可能会发生变化,以一种方式倾斜一段时间,然后以另一种方式倾斜。

您将听到很多不优化,永不优化和过早优化的信息。但是如图所示,像这样的设计从一开始就产生了性能问题,然后您开始看到解决这些问题的技巧,而不是从一开始就进行重新设计。我同意在某些情况下,一个函数只有几行代码,您可能会担心担心编译器将要执行的其他操作,从而尝试对其进行操作(请注意,这种编码变得轻松自然,在知道编译器将如何编译代码的同时进行优化),然后在攻击它之前先确认周期盗窃者的实际位置。

您还需要在某种程度上为用户设计代码。如果这是您的项目,那么您是唯一的开发人员;这就是你想要的。如果您要使一个库能够被赠送或出售,则可能要使您的代码看起来像所有其他库一样,成百上千个具有微小功能,长函数名和长变量名的文件。尽管存在可读性问题和性能问题,但IMO仍将使更多人能够使用该代码。


4
真?请问您使用什么“某些目标”和“某些编译器”?
多里安

在我看来,它更像是32/64位的ARM8,可能是从狂暴的PI到通常的微控制器。您看过问题的第一句话了吗?
多里安

好的,编译器不会删除未使用的全局函数,但是链接器会删除。如果配置和使用正确,它们将不会显示在可执行文件中。
berendi-抗议

如果有人想知道哪个编译器可以跨文件间隙进行优化:IAR编译器支持多文件编译(即所谓的多文件编译),从而可以进行跨文件优化。如果一次性将所有c / cpp文件丢给它,那么您最终将得到一个包含一个功能的可执行文件:main。性能上的好处是非常深刻的。
阿森纳

3
@Arsenal当然,即使调用正确,gcc也支持内联,即使跨编译单元也是如此。请参阅gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html,并查找-flto选项。
彼得-恢复莫妮卡

1

非常通用的规则-编译器可以比您优化。当然,如果您要处理非常耗时的循环,则会有例外,但是总的来说,如果您想对速度或代码大小进行良好的优化,请明智地选择编译器。


遗憾的是,对于当今的大多数程序员而言,这都是事实。
多里安

0

当然,这取决于您自己的编码样式。一条通用的规则是,变量名和函数名应尽可能清晰明了。您在一个函数中放置的子调用或代码行越多,为该函数定义清晰任务的难度就越大。在您的例子中,你有一个功能initModule()初始化的东西,并调用子程序,然后设置时钟设置的配置。您只需阅读函数名称即可知道。如果initModule()直接将子例程中的所有代码放入您的程序中,则该函数实际执行的操作变得不太明显。但是通常,这只是一个准则。


谢谢您的回复。如果需要性能,我可能会更改样式,但是这里的问题是代码的可读性会影响性能吗?
MaNyYaCk

函数调用将导致调用或jmp命令,但是我认为这是微不足道的资源牺牲。如果使用设计模式,有时在到达实际的代码片段之前,最终会经过十几个函数调用。
po.pe

@Humpawumpa -如果你只用256或64字节的RAM则函数调用十几层写微控制器是不可忽视的牺牲,这是不可能的
uɐɪ

是的,但这是两个极端……通常您有256个字节以上,并且使用的层数少于十二个-希望如此。
po.pe

0

如果一个函数实际上只做一件很小的事情,请考虑将其做成static inline

将其添加到头文件而不是C文件,并使用以下单词static inline进行定义:

static inline void setCLK()
{
    //code to set the clock
}

现在,如果函数甚至更长一些(例如超过3行),则最好避免static inline将其添加到.c文件中。毕竟,嵌入式系统的内存有限,您不想过多增加代码大小。

同样,如果您在中定义函数file1.c并从中使用它file2.c,则编译器不会自动内联它。但是,如果将其定义file1.hstatic inline函数,则编译器可能会内联它。

这些static inline功能在高性能编程中非常有用。我发现它们通常可以将代码性能提高三倍以上。


“例如超过3行”-行数与之无关;内联成本与之相关。我可以编写一个非常适合内联的20行函数,以及一个可怕的适合内联的3行函数(例如,调用functionB()3次的functionA(),调用functionC()3次的functionB()和其他几个级别)。
杰森S

另外,如果您在 file1.c并从中使用它file2.c,则编译器不会自动内联它。 错误的。参见例如-flto在gcc或clang中。
berendi-抗议

0

尝试为微控制器编写高效且可靠的代码的一个困难是,除非代码使用编译器特定的指令或禁用许多优化,否则某些编译器无法可靠地处理某些语义。

例如,如果有一个带有中断服务例程的单核系统[由计时器滴答声或其他方式运行]:

volatile uint32_t *magic_write_ptr,magic_write_count;
void handle_interrupt(void)
{
  if (magic_write_count)
  {
    magic_write_count--;
    send_data(*magic_write_ptr++)
  }
}

应该可以编写函数来启动后台写操作或等待其完成:

void wait_for_background_write(void)
{
  while(magic_write_count)
    ;
}
void start_background_write(uint32_t *dat, uint32_t count)
{
  wait_for_background_write();
  background_write_ptr = dat;
  background_write_count = count;
}

然后使用以下代码调用此类代码:

uint32_t buff[16];

... write first set of data into buff
start_background_write(buff, 16);
... do some stuff unrelated to buff
wait_for_background_write();

... write second set of data into buff
start_background_write(buff, 16);
... etc.

不幸的是,启用了完全优化后,像gcc或clang这样的“聪明”编译器将无法确定第一组写操作对程序的可观察性没有任何影响,因此可以对其进行优化。icc如果设置中断并等待完成的行为涉及易失性写入和易失性读取(例如此处的情况),那么像这样的高质量编译器就不太容易这样做(但是在此情况下),icc嵌入式系统所针对的平台并不那么受欢迎。

该标准故意忽略了实现质量问题,认为可以采用几种合理的方式来处理上述构造:

  1. 专用于高端数字运算等领域的质量实现可以合理地期望为此类领域编写的代码不会包含上述构造。

  2. 质量实现可以volatile将对对象的所有访问都视为可能触发将访问外部可见的任何对象的动作。

  3. 适用于嵌入式系统的简单但质量不错的实现可能会将所有未标记为“内联”的函数的调用视为访问所有暴露给外界的对象,即使它们未按volatile#中所述进行处理。 2。

该标准没有试图建议上述哪种方法最适合于质量实施,也没有要求“符合”实施具有足够好的质量以用于任何特定目的。因此,某些编译器(例如gcc或clang)有效地要求任何要使用此模式的代码都必须在禁用许多优化的情况下进行编译。

在某些情况下,确保I / O函数位于单独的编译单元中,并且编译器别无选择,只能假设它们可以访问暴露给外界的对象的任意子集,这可能是合理的,至少-邪恶的方式来编写可与gcc和clang一起可靠使用的代码。但是,在这种情况下,目标不是要避免不必要的函数调用的额外开销,而是要接受不必要的开销以换取所需的语义。


“确保I / O功能在单独的编译单元中”不是防止此类优化问题的肯定方法。至少LLVM和我相信GCC在许多情况下会执行整个程序优化,因此即使它们在单独的编译单元中,也可以决定内联IO功能。
朱尔斯

@Jules:并非所有实现都适合编写嵌入式软件。禁用整个程序优化可能是强制gcc或clang充当适合该目的的质量实现的最便宜的方法。
超级猫

@Jules:适用于嵌入式或系统编程的高质量实现应可配置为具有适合该目的的语义,而不必完全禁用整个程序的优化(例如,通过选择将volatile访问视为可能潜在地触发的选项)任意访问其他对象),但是无论出于何种原因,gcc和clang都宁愿将实现质量问题视为以无用的方式进行行为的邀请。
超级猫

1
即使是“最高质量”的实现也无法纠正错误的代码。如果buff未声明volatile,则不会将其视为volatile变量,如果稍后显然不使用它,则对其的访问可能会重新排序或完全优化。规则很简单:将所有可能在常规程序流之外访问的变量(如编译器所见)标记为volatilebuff访问的内容是否在中断处理程序中?是。那应该是volatile
berendi-抗议

@berendi:编译器可以提供超出标准要求的保证,高质量的编译器可以提供保证。嵌入式系统使用的高质量独立实现将允许程序员合成互斥体构造,这实际上就是代码所做的事情。当magic_write_count为零时,存储由主线拥有。当它不为零时,由中断处理程序拥有。使buff挥发性将要求经营后,它使用每个功能的任何地方volatile-qualified指针,这将远远超过其编译器优化损害...
supercat
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.