编译用于高放射性环境的应用程序

我们正在编译一个嵌入式 C / C ++ 应用程序,该应用程序部署在受到电离辐射轰击的环境中的屏蔽设备中。我们正在使用 GCC 并为 ARM 进行交叉编译。部署后,我们的应用程序会生成一些错误数据,并且崩溃的次数比我们想要的要多。硬件是为此环境设计的,我们的应用程序已在该平台上运行了几年。

我们是否可以对代码进行更改,或者可以对编译时进行改进,以识别 / 纠正由单个事件引发的软错误和内存损坏?其他开发人员是否在减少软错误对长期运行的应用程序的有害影响方面取得了成功?

答案

我致力于软件 / 固件开发和小型卫星的环境测试 * 大约 4-5 年,我想在这里分享我的经验。

*(由于小型卫星的电子部件尺寸相对较小且尺寸有限,因此小型卫星比大型卫星更容易发生单事件失败

非常简洁和直接:没有机制把由软件检测,错误的情况下恢复 / 固件本身没有 ,至少,一个用于恢复目的的软件 / 固件地方最低工作版本 复制 - 与硬件支持恢复 (功能性)。

现在,通常在硬件和软件级别都可以处理这种情况。在这里,根据您的要求,我将分享我们在软件级别上可以做的事情。

  1. ... 恢复目的...提供在真实环境中更新 / 重新编译 / 重新刷新软件 / 固件的功能。对于高度电离的环境,这是几乎所有软件 / 固件都必须具备的功能。 否则 ,您可能会拥有任意数量的冗余软件 / 硬件,但有一点,它们都将崩溃。因此,准备此功能!

  2. ... 最低工作版本...在代码中具有响应性的多个副本,最低版本的软件 / 固件。这就像 Windows 中的安全模式。拥有一个最低功能版本的软件 / 固件,而不是仅拥有一个功能完整的软件版本。最小副本的大小通常比完整副本小得多,并且几乎总是只有以下两个或三个功能:

    1. 能够听取来自外部系统的命令,
    2. 能够更新当前的软件 / 固件,
    3. 能够监视基本操作的内务处理数据。
  3. ... 在某处复制... 在某处有冗余软件 / 固件。

    1. 无论有没有冗余硬件,您都可以尝试在 ARM uC 中拥有冗余软件 / 固件。通常,这是通过在单独的地址中使用两个或多个相同的软件 / 固件来相互发送心跳来完成的,但是一次只能激活一个。如果已知一个或多个软件 / 固件无响应,请切换到其他软件 / 固件。使用这种方法的好处是,发生错误后,我们可以立即进行功能更换 - 无需与负责检测和修复错误的任何外部系统 / 当事方进行任何联系(在卫星情况下,通常是任务控制中心( MCC))。

      严格来说,没有冗余硬件,这样做的缺点是您实际上无法消除所有单点故障。至少,您仍然会有一个单点故障,这就是开关本身 (或者通常是代码的开头)。但是,对于在高度电离的环境中受尺寸限制的设备(例如,微微 / 毫微微卫星),仍然需要考虑将单点故障减少到一个点而无需额外的硬件。此外,用于切换的代码段肯定会比整个程序的代码段少得多 - 大大降低了获得单个事件的风险。

    2. 但是,如果您不这样做,则您的外部系统中至少应有一个副本,可以与该设备联系并更新软件 / 固件(在卫星情况下,它又是任务控制中心)。

    3. 您还可以将副本保存在设备的永久存储器中,以触发该副本以还原正在运行的系统的软件 / 固件
  4. ... 可检测的错误情况。错误必须是可检测的 ,通常是通过硬件错误校正 / 检测电路或一小段代码进行错误校正 / 检测。最好将此类代码缩小,多个并独立于主软件 / 固件。其主要任务用于检查 / 更正。如果硬件电路 / 固件可靠 (例如,其辐射辐射比其余部分更坚固 - 或具有多个电路 / 逻辑),那么您可以考虑对其进行纠错。但是,如果不是这样,最好将其作为错误检测。可以通过外部系统 / 设备进行更正。对于纠错,您可以考虑使用诸如 Hamming / Golay23 之类的基本纠错算法,因为它们可以在电路 / 软件中更轻松地实现。但这最终取决于您团队的能力。对于错误检测,通常使用 CRC。

  5. ... 支持恢复的硬件现在,这是这个问题上最困难的方面。最终,恢复需要负责恢复的硬件至少能够正常运行。如果硬件永久损坏(通常在其总电离剂量达到一定水平后发生),则该软件将(很难)无法帮助恢复。因此,对于暴露于高辐射水平的设备(例如卫星),硬件无疑是最重要的问题。

除了上述可预见的由于单事件失败而导致的固件错误的建议之外,我还建议您具有:

  1. 子系统间通信协议中的错误检测和 / 或错误校正算法。为了避免从其他系统接收到不完整 / 错误的信号,这是另一个几乎必须具备的条件

  2. 过滤 ADC 读数。 不要使用 ADC 直接读取。用中值过滤器,均值过滤器或任何其他过滤器过滤它 - 永远不要相信单个读数值。采样更多而不是更少 - 合理。

美国国家航空航天局(NASA)发表了一篇关于辐射增强软件的论文。它描述了三个主要任务:

  1. 定期监视内存中的错误,然后清除这些错误,
  2. 强大的错误恢复机制,以及
  3. 如果某些东西不再起作用,则可以重新配置。

请注意,内存扫描速率应足够频繁,以至于很少会发生多位错误,因为大多数ECC内存可以从单位错误而非多位错误中恢复。

强大的错误恢复包括控制流传输(通常在错误发生之前的某个时刻重新启动进程),资源释放和数据恢复。

他们对数据恢复的主要建议是,通过将中间数据视为临时数据,从而避免了对数据的需求,以便在错误之前重新启动也会将数据回滚到可靠状态。这听起来类似于数据库中 “事务” 的概念。

他们讨论了特别适用于面向对象语言(例如 C ++)的技术。例如

  1. 连续内存对象的基于软件的 ECC
  2. 按合同编程 :验证前提条件和后置条件,然后检查对象以确认其仍然处于有效状态。

而且,正是这种情况,NASA 已将 C ++ 用于诸如Mars Rover 之类的大型项目。

C ++ 类抽象和封装可在多个项目和开发人员之间进行快速开发和测试。

他们避免使用某些可能导致问题的 C ++ 功能:

  1. 例外情况
  2. 范本
  3. iostream(无控制台)
  4. 多重继承
  5. 运算符重载(除了newdelete
  6. 动态分配(使用了专用的内存池和new分配以避免系统堆损坏的可能性)。

这里有一些想法和想法:

更创造性地使用 ROM。

将任何可以存储的内容存储在 ROM 中。无需计算内容,而是将查找表存储在 ROM 中。 (确保您的编译器将查询表输出到只读部分!在运行时打印出内存地址以进行检查!)将中断向量表存储在 ROM 中。当然,请运行一些测试以查看 ROM 与 RAM 相比的可靠性。

将最佳 RAM 用于堆栈。

堆栈中的 SEU 可能是最有可能导致崩溃的原因,因为索引变量,状态变量,返回地址和各种指针通常都存在于此。

实现计时器滴答和看门狗计时器例程。

您可以在每个计时器滴答时运行 “健全性检查” 例程,以及用于处理系统锁定的看门狗例程。您的主代码还可以定期增加一个计数器来指示进度,并且完整性检查例程可以确保这种情况已经发生。

在软件中实施纠错代码

您可以为数据添加冗余,以便能够检测和 / 或纠正错误。这将增加处理时间,可能会使处理器长时间暴露在辐射下,从而增加出错的机会,因此您必须权衡取舍。

记住缓存。

检查您的 CPU 缓存的大小。您最近访问或修改的数据可能会在缓存中。我相信您可以禁用至少某些缓存(以较高的性能代价);您应该尝试这样做以查看缓存对 SEU 的敏感程度。如果缓存比 RAM 硬,那么您可以定期读取和重写关键数据,以确保它们保留在缓存中并使 RAM 恢复正常。

聪明地使用页面错误处理程序。

如果将内存页面标记为不存在,则当您尝试访问该页面时,CPU 将发出页面错误。您可以创建一个页面错误处理程序,在处理读取请求之前进行一些检查。 (PC 操作系统使用它来透明地加载已交换到磁盘的页面。)

使用汇编语言处理关键的事情(可能是所有事情)。

使用汇编语言,您知道寄存器中的内容和 RAM 中的内容。您知道 CPU 使用的是什么特殊的 RAM 表,并且可以通过回旋方式进行设计以降低风险。

使用objdump实际查看生成的汇编语言,并计算出每个例程占用多少代码。

如果您使用的是像 Linux 这样的大型操作系统,那您就麻烦了;有这么多的复杂性和很多错误要解决。

请记住,这是一场概率游戏。

评论者说

您编写的每个捕获错误的例程都可能因相同的原因而失败。

虽然这是事实,但检查例程正常运行所需的(例如)100 字节代码和数据中的错误几率比其他地方的错误几率小得多。如果您的 ROM 非常可靠,并且几乎所有代码 / 数据实际上都在 ROM 中,那么您的几率甚至更高。

使用冗余硬件。

使用 2 个或更多具有相同代码的相同硬件设置。如果结果不同,则应触发重置。对于 3 台或 3 台以上的设备,您可以使用 “投票” 系统来尝试确定哪些设备已受到威胁。

您可能也对关于算法容错的丰富文献感兴趣。这包括旧的赋值:写一个排序,当恒定数量的比较将失败时(或者,当渐进失败的比较的渐近数量缩放为log(n) n比较的log(n)时,对输入进行正确排序)。

开始阅读的地方是 Huang 和 Abraham 在 1984 年发表的论文 “ 矩阵运算的基于算法的容错 ”。他们的想法大概与同态加密计算相似(但是实际上并不太一样,因为他们正在尝试在操作级别进行错误检测 / 纠正)。

该论文的最新版本是 Bosilca,Delmas,Dongarra 和 Langou 的 “ 基于算法的容错应用于高性能计算 ”。

为放射性环境编写代码与为任何关键任务应用程序编写代码实际上没有什么不同。

除了已经提到的内容以外,这里还有一些其他提示:

  • 使用任何半专业嵌入式系统都应采用的日常 “面包和黄油” 安全措施:内部看门狗,内部低电压检测,内部时钟监视器。这些事情甚至都不需要在 2016 年提及,它们几乎是所有现代微控制器的标准配置。
  • 如果您具有安全和 / 或面向汽车的 MCU,它将具有某些看门狗功能,例如给定的时间窗口,您需要在其中刷新看门狗。如果您具有关键任务实时系统,则这是首选。
  • 通常,使用适合此类系统的 MCU,而不要使用一包玉米片中收到的一些通用主流绒毛。如今,几乎每个 MCU 制造商都具有专门为安全应用(TI,Freescale,Renesas,ST,Infineon 等)设计的 MCU。它们具有许多内置的安全功能,包括锁步内核:这意味着有 2 个 CPU 内核执行相同的代码,并且它们必须彼此一致。
  • 重要信息:您必须确保内部 MCU 寄存器的完整性。所有可写的硬件外设的控制和状态寄存器都可能位于 RAM 内存中,因此容易受到攻击。

    为了防止寄存器损坏,最好选择具有内置 “一次性写入” 功能的微控制器。此外,您需要将所有硬件寄存器的默认值存储在 NVM 中,并定期将这些值复制到您的寄存器中。您可以用相同的方式确保重要变量的完整性。

    注意:始终使用防御性编程。这意味着您必须设置 MCU 中的所有寄存器,而不仅仅是应用程序使用的寄存器。您不希望某些随机的硬件外设突然唤醒。

  • 检查 RAM 或 NVM 中的错误的方法有很多种,包括校验和,“移动模式”,软件 ECC 等。当今最好的解决方案是不使用其中任何一种,而要使用具有内置 ECC 和类似的检查。由于在软件中执行此操作很复杂,因此错误检查本身可能会引入错误和意外问题。

  • 使用冗余。您可以将易失性和非易失性存储器都存储在两个相同的 “镜像” 段中,这些段必须始终相等。每个段可以附加一个 CRC 校验和。
  • 避免在 MCU 外部使用外部存储器。
  • 为所有可能的中断 / 异常实现默认的中断服务例程 / 默认的异常处理程序。甚至那些您不使用的。默认例程除了关闭其自己的中断源外不执行任何操作。
  • 了解并接受防御性编程的概念。这意味着您的程序需要处理所有可能的情况,即使是理论上不可能发生的情况。 例子

    高质量的关键任务固件会检测到尽可能多的错误,然后以安全的方式将其忽略。

  • 切勿编写依赖于行为不当的程序。由于辐射或 EMI 导致硬件意外更改,此类行为可能会发生巨大变化。确保您的程序不受此类废话的最好方法是使用像 MISRA 这样的编码标准,以及一个静态分析器工具。这也将有助于防御性编程和清除错误(为什么您不希望在任何类型的应用程序中检测到错误?)。
  • 重要说明:不要依赖静态存储持续时间变量的默认值。也就是说,不要相信.data.bss的默认内容。从初始化到实际使用变量之间可能有任何时间,可能会有很多时间使 RAM 损坏。而是编写程序,以便在运行时从 NVM 设置所有此类变量,就在首次使用此类变量之前。

    在实践中,这意味着如果在文件作用域中声明了变量或将其声明为static ,则永远不要使用=进行初始化(否则可以,但是它毫无意义,因为您无论如何都不能依赖该值)。始终在使用前在运行时进行设置。如果可以从 NVM 重复更新此类变量,则可以这样做。

    同样在 C ++ 中,不要依赖于构造函数来获取静态存储持续时间变量。让构造函数调用公共的 “设置” 例程,您也可以稍后在运行时直接从调用者应用程序中调用该例程。

    如有可能,请删除用于完全初始化.data.bss的 “复制” 启动代码(并调用 C ++ 构造函数),以便在编写依赖此代码的代码时出现链接器错误。许多编译器可以跳过此选项,通常称为 “最小 / 快速启动” 或类似的选项。

    这意味着必须检查所有外部库,以便它们不包含任何此类依赖。

  • 为程序实现并定义一个安全状态,以防万一出现严重错误,您将还原到该状态。

  • 实施错误报告 / 错误日志系统总是有帮助的。

仅在大多数形式的编译器优化被禁用的情况下,才可以使用 C 编写在这种环境下表现良好的程序。优化的编译器旨在用 “更有效的” 编码模式替换许多看似冗余的编码模式,并且可能不知道当编译器知道x不可能容纳任何其他内容时,程序员测试x==42的原因是因为程序员希望阻止x持有其他值的情况下执行某些代码 - 即使在唯一可以保存该值的方式是系统收到某种电子故障的情况下。

将变量声明为volatile通常会有所帮助,但可能不是万能药。特别重要的是,请注意,安全编码通常要求危险的操作具有硬件互锁,需要多个步骤才能激活,并且使用以下模式编写代码:

... code that checks system state
if (system_state_favors_activation)
{
  prepare_for_activation();
  ... code that checks system state again
  if (system_state_is_valid)
  {
    if (system_state_favors_activation)
      trigger_activation();
  }
  else
    perform_safety_shutdown_and_restart();
}
cancel_preparations();

如果编译器以相对原义的方式转换代码,并且在prepare_for_activation()之后重复了对系统状态的所有检查,则该系统可以抵抗几乎任何可能的单个故障事件,甚至那些会任意破坏程序计数器和堆。如果在调用prepare_for_activation()之后发生了小故障,则意味着激活是适当的(因为没有其他原因会在小故障之前调用prepare_for_activation() )。如果故障导致代码不适当地到达prepare_for_activation() ,但没有后续故障事件,那么代码将无法随后到达trigger_activation()而无需先通过验证检查或先调用 cancel_preparations [如果堆栈发生故障,在调用prepare_for_activation()返回的上下文之后,执行可能会在trigger_activation()之前执行,但是对cancel_preparations()的调用会在对prepare_for_activation()trigger_activation()的调用之间发生,因此使后者的调用无害。

这样的代码在传统 C 语言中可能是安全的,但在现代 C 编译器中则不是。这样的编译器在那种环境下可能非常危险,因为激进的编译器会努力只包含与某些情况相关的代码,这些情况可能通过某种定义良好的机制发生,并且其后果也将得到很好的定义。在某些情况下,目的是要在故障后进行检测和清除的代码可能最终使情况变得更糟。如果编译器确定尝试进行的恢复在某些情况下将调用未定义的行为,则它可以推断出在这种情况下可能需要进行这种恢复的条件不可能发生,从而消除了要检查它们的代码。

这是一个极其广泛的主题。基本上,您无法真正从内存损坏中恢复,但是至少可以尝试立即失败 。您可以使用以下几种技巧:

  • 校验和常量数据 。如果您有任何长时间保持不变的配置数据(包括已配置的硬件寄存器),请在初始化时计算其校验和并定期进行验证。当看到不匹配时,该重新初始化或重置了。

  • 冗余存储变量 。如果您有一个重要的变量x ,则将其值写入x1x2x3并读为(x1 == x2) ? x2 : x3

  • 实施程序流监控 。在主循环调用的重要函数 / 分支中,对具有唯一值的全局标志进行 XOR。在测试覆盖率接近 100%的无辐射环境中运行程序,应在周期结束时为您提供标志的可接受值列表。如果发现偏差,请重设。

  • 监视堆栈指针 。在主循环的开始,将堆栈指针与其预期值进行比较。偏差重设。

可以帮助您的是看门狗 。看门狗在 1980 年代被广泛用于工业计算。当时,硬件故障要普遍得多 - 另一个答案也就是那个时期。

看门狗是硬件 / 软件的组合功能。硬件是一个简单的计数器,它从一个数字(例如 1023)递减到零。可以使用TTL或其他逻辑。

该软件的设计使得一个例程可以监视所有基本系统的正确运行。如果此例程正确完成 = 发现计算机运行正常,则它将计数器设置回 1023。

总体设计是,在正常情况下,软件可防止硬件计数器达到零。如果计数器达到零,则计数器的硬件将执行其唯一任务并重置整个系统。从计数器的角度来看,零等于 1024,并且计数器再次继续递减计数。

该监视程序可确保在许多很多情况下都可以重新启动连接的计算机。我必须承认,我对能够在当今计算机上执行此类功能的硬件不熟悉。与外部硬件的接口现在比以前复杂得多。

看门狗的一个固有缺点是,从系统出现故障直到看门狗计数器达到零 + 重新启动时间,系统才可用。尽管该时间通常比任何外部或人工干预要短得多,但在该时间范围内,受支持的设备将需要能够在没有计算机控制的情况下继续运行。

该答案假定您关心的是拥有一个正常运行的系统,而不是拥有成本最低或速度最快的系统。大多数玩放射性物质的人都重视正确性 / 安全性,而不是速度 / 成本

有几个人建议您可以进行硬件更改(很好 - 答案中已经有很多不错的东西,我不打算重复全部),还有其他人建议了冗余(原则上很好),但是我不认为任何人都建议过这种冗余在实践中将如何工作。您如何进行故障转移?您怎么知道什么时候 “出了错”?许多技术都是在一切正常的基础上工作的,因此失败是一件棘手的事情。但是,一些为规模化而设计的分布式计算技术会出现故障(毕竟具有足够的规模,对于单个节点而言,任何 MTBF 都会不可避免地导致多个节点中的一个发生故障);您可以利用它来适应您的环境。

这里有一些想法:

  • 确保将整个硬件复制n次(其中n大于 2,最好是奇数),并且每个硬件元素都可以与其他硬件元素进行通信。以太网是实现此目的的一种显而易见的方法,但是还有许多其他更简单的路由可以提供更好的保护(例如 CAN)。尽量减少常见组件(甚至电源)。例如,这可能意味着在多个位置对 ADC 输入进行采样。

  • 确保您的应用程序状态位于单个位置,例如在有限状态机中。尽管不排除稳定的存储空间,但这可以完全基于 RAM。因此它将被存储在多个位置。

  • 采用法定协议更改状态。例如,参见RAFT 。当您使用 C ++ 时,有许多众所周知的库。仅当大多数节点同意时,才对 FSM 进行更改。对协议栈和仲裁协议使用已知的好的库,而不要自己动手,否则仲裁协议挂起时,您在冗余方面的所有好的工作都会被浪费。

  • 确保您对 FSM 进行校验和(例如 CRC / SHA),并将 CRC / SHA 存储在 FSM 自身中(以及在消息中传输和对消息本身进行校验和)。获取节点,以根据这些校验和,接收消息的校验和,定期检查其 FSM,并检查其校验和是否与仲裁的校验和匹配。

  • 在您的系统中尽可能多地构建其他内部检查,使检测到自身故障的节点重新启动(这比在您有足够节点的情况下进行一半工作要好)。尝试在重新引导过程中让它们从仲裁中彻底删除,以防它们再次出现。重新启动后,请他们对软件映像进行校验和(以及它们加载的其他任何内容),并在将自己重新引入仲裁之前进行完整的 RAM 测试。

  • 使用硬件为您提供支持,但请务必谨慎。例如,您可以获得 ECC RAM,并定期对其进行读 / 写操作以更正 ECC 错误(如果错误无法纠正,则会出现恐慌)。但是(从内存中)的静态 RAM 是电离辐射比 DRAM 是摆在首位,因此它可能是更好的使用静态 DRAM,而不是更为宽容。也请参见 “我不会做的事情” 下的第一点。

假设您有一天之内任何给定节点发生故障的可能性为 1%,并且假设您可以使故障完全独立。如果有 5 个节点,则一天之内将需要 3 个节点发生故障,这是 0.00001%的机会。有了更多,那么,您就知道了。

不会做的事情:

  • 低估了开始时没有问题的价值。除非担心重量问题,否则设备周围的大量金属将是比一组程序员所能想到的便宜得多,更可靠的解决方案。 EMI 输入的同向光耦合是一个问题,等等。无论如何,在采购组件时要尝试采购最能抵抗电离辐射的组件。

  • 推出自己的算法 。人们以前做过这些东西。用他们的工作。容错和分布式算法很难。尽可能使用他人的作品。

  • 天真的使用复杂的编译器设置,希望您能发现更多的故障。如果幸运的话,您可能会发现更多的故障。更有可能的是,您将在编译器中使用未经测试的代码路径,特别是如果您自己滚动代码路径。

  • 使用未经您环境测试的技术。大多数编写高可用性软件的人都必须模拟故障模式以检查其 HA 是否正常工作,从而错过许多故障模式。您处于经常出现按需故障的 “幸运” 位置。因此,请测试每种技术,并确保其应用实际将 MTBF 的提高幅度超过了引入它的复杂性(复杂性会带来 bug)。尤其将其应用于我的建议定额算法等。

由于您专门要求软件解决方案,并且您正在使用 C ++,为什么不使用运算符重载来创建自己的安全数据类型?例如:

而不是使用uint32_t (和doubleint64_t等),而是制作自己的SAFE_uint32_t ,其中包含 uint32_t 的倍数(最小值为 3)。重载要执行的所有操作(* +-/ <<>> = ==!= 等),并使重载的操作对每个内部值独立执行,即,不要一次执行并复制结果。在之前和之后,请检查所有内部值是否匹配。如果值不匹配,则可以将错误的一个更新为最常见的一个。如果没有最常用的值,则可以安全地通知您有错误。

这样,无论 ALU,寄存器,RAM 或总线上是否发生损坏,您都将有多次尝试,而且很有可能捕获错误。但是请注意,尽管这仅适用于您可以替换的变量 - 例如,您的堆栈指针仍然容易受到影响。

附带说明:我在旧的 ARM 芯片上也遇到了类似的问题。原来,这是一个使用旧版 GCC 的工具链,它与我们使用的特定芯片一起在某些极端情况下触发了一个错误,该错误会(有时)破坏值传递给函数。在将其归咎于放射性之前,请确保您的设备没有任何问题,是的,有时它是编译器错误 =)