为嵌入式软件编写函数以获得更好的性能时,最好的方法是什么?

电器工程 微控制器 C
2022-01-16 12:55:22

我见过一些微控制器库,它们的功能一次只做一件事。例如,像这样:

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 的答案中定义的可读性。

4个回答

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

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

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

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

我想不出任何优势(但请参阅底部对 JasonS 的注释),将一行代码包装为函数或子例程。除非您可以将函数命名为“可读”。但是您也可以评论该行。而且由于在函数中封装一行代码会消耗代码内存、堆栈空间和执行时间,在我看来,这大多会适得其反。在教学情况下?这可能有点道理。但这取决于学生的班级、他们的事先准备、课程和老师。大多数情况下,我认为这不是一个好主意。但这是我的看法。

这给我们带来了底线。几十年来,您的广泛问题领域一直是一个争论的问题,直到今天仍然是一个争论的问题。因此,至少在我阅读您的问题时,在我看来这是一个基于意见的问题(正如您所问的那样。)

如果您要更详细地了解情况并仔细描述您作为主要目标的目标,它可以不再像现在这样基于意见。您对测量工具的定义越好,答案可能就越客观。


从广义上讲,您希望对任何编码执行以下操作。(对于下文,我将假设我们正在比较所有实现目标的不同方法。显然,任何未能执行所需任务的代码都比成功的代码更糟糕,无论它是如何编写的。)

  1. 与您的方法保持一致,以便另一个阅读您的代码的人可以了解您如何处理编码过程。不一致可能是最糟糕的犯罪。它不仅让其他人感到困难,而且让自己在多年后重新回到代码中变得困难。
  2. 在可能的范围内,尝试安排一些事情,以便可以在不考虑顺序的情况下执行各种功能部分的初始化。在需要排序的情况下,如果是由于两个高度相关的子函数的紧密耦合,则考虑对两者进行一次初始化,以便可以重新排序而不会造成损害。如果这不可能,则记录初始化订购要求。
  3. 如果可能,将知识封装在一个地方。常量不应在代码中到处重复。求解某个变量的方程应该只存在于一处。等等。如果您发现自己复制并粘贴了一组在不同位置执行某些所需行为的行,请考虑一种在一个地方捕获该知识并在需要时使用它的方法。例如,如果您有一个必须以特定方式行走的树结构,请不要在需要遍历树节点的每个地方复制树遍历代码。相反,在一个地方捕获 tree-walking 方法并使用它。这样,如果树发生变化,行走方法发生变化,您只需要担心一个地方,所有其余代码都“正常工作”。
  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++ 代码,而无需为项目编译任何其他相关代码。为了内联代码,假设编译器可能会选择这样做,它不仅必须具有“范围内”的声明,而且还必须具有定义。通常,如果他们使用“内联”,程序员会努力确保是这种情况。但是很容易出现错误。

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

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


C++

对于那些感兴趣的人,这里有一个例子说明为什么我在编写嵌入式应用程序时对 C++ 保持相当谨慎,尽管它现在已经可用。我将抛出一些我认为所有嵌入式 C++ 程序员都需要了解的术语

  • 部分模板特化
  • 虚拟表
  • 虚拟基础对象
  • 激活框架
  • 激活框架展开
  • 在构造函数中使用智能指针,以及为什么
  • 返回值优化

这只是一个简短的清单。如果您还不了解这些术语的所有内容以及我列出它们的原因(还有更多我没有在此处列出),那么我建议不要将 C++ 用于嵌入式工作,除非它不是项目的选项.

让我们快速浏览一下 C++ 异常语义,了解一下。

当 C++ 编译器完全不知道在单独的编译单元 \$B\$ 中可能需要什么样的异常处理时,它必须为编译单元 \$A\$ 生成正确的代码,在不同的时间单独编译。

以这个代码序列为例,它是某个编译单元 \$A\$ 中某个函数的一部分:

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

出于讨论目的,编译单元 \$A\$ 不在其源代码的任何地方使用“try..catch”。它也不使用“投掷”。事实上,假设它不使用任何 C 编译器无法编译的源代码,除了它使用 C++ 库支持并且可以处理像 String 这样的对象。这段代码甚至可能是一个 C 源代码文件,它稍作修改以利用一些 C++ 功能,例如 String 类。

此外,假设 foo() 是位于编译单元 \$B\$ 中的外部过程,并且编译器有它的声明,但不知道它的定义。

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

但是一旦 String s 被创建,C++ 编译器就知道必须在允许展开帧之前正确地销毁它,如果以后发生异常的话。所以对 foo() 的第二次调用在语义上与第一次不同。如果对 foo() 的第二次调用抛出异常(它可能会或可能不会),编译器必须在让通常的帧展开发生之前放置旨在处理 String 的破坏的代码。与第一次调用 foo() 所需的代码不同。

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

与 C 的 malloc 不同,C++ 的 new 在无法执行原始内存分配时使用异常来发出信号。“dynamic_cast”也是如此。(参见 Stroustrup 的第 3 版,C++ 编程语言,第 384 和 385 页了解 C++ 中的标准异常。)编译器可能允许禁用此行为。但总的来说,由于生成的代码中正确形成的异常处理序言和尾声,您会产生一些开销,即使异常实际上没有发生,即使正在编译的函数实际上没有任何异常处理块。(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 类有自己的需要构造的对象,这可能会造成灾难性的影响。(AC 程序员会避免大部分这种垃圾,手工优化,因为 C 程序员没有这么方便的语法,必须一次表达所有细节。)

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

最后,给 C 程序员的简短说明。longjmp() 在 C++ 中没有可移植的行为。(一些 C 程序员将此用作一种“异常”机制。)一些 C++ 编译器实际上会尝试设置一些东西以在采用 longjmp 时进行清理,但这种行为在 C++ 中是不可移植的。如果编译器确实清理了构造的对象,则它是不可移植的。如果编译器不清理它们,那么如果代码由于 longjmp 而离开构造对象的范围并且行为无效,则对象不会被破坏。(如果在 foo() 中使用 longjmp 不会留下作用域,那么行为可能没问题。)C 嵌入式程序员不经常使用它,但他们应该在使用它们之前让自己意识到这些问题。

1) 代码的可读性和可维护性优先。任何代码库最重要的方面是结构良好。写得很好的软件往往有更少的错误。您可能需要在几周/几个月/几年内进行更改,如果您的代码易于阅读,这将大有帮助。或者也许其他人必须做出改变。

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

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

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

当一个函数只在一个地方(甚至在另一个函数内部)被调用时,编译器总是把代码放在那个地方而不是真正调用这个函数。如果函数在很多地方被调用,那么至少从代码大小的角度来看,使用函数是有意义的。

编译后代码不会有多次调用,可读性会大大提高。

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

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

速度优化将在尽可能多的地方内联函数,代码大小的优化将调用函数,但是,当一个函数只在一个地方被调用时,就像你的情况一样,它总是“内联”的。

像这样的代码:

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++ 或编程部分,问题是关于微控制器编程,任何体面的编译器默认情况下都会进行上述优化。

所以请停止投反对票,因为在极少数情况下,这可能不是真的。