C++ 中的状态机
一个示例状态机框架,它使用 Doxygen 自动绘制实际代码的行为。
引言
本文试图展示如何解决使用状态机的主要问题——调试和测试。简单的状态机(按钮消抖、管理 SQL 事务)几乎可以在任何框架中轻松实现。当状态机用于处理几十个事件和传感器在 10-12 种不同的操作模式下时,它会达到这样一个程度:单个状态机组件易于描述和编码,但整个系统却难以可视化。通常,一个状态的并发行为与另一个状态机的并行行为变得过于复杂。然后,“修复”问题常常会把问题从一个状态追到下一个状态。
对于复杂的状态机,问题在于:
如何维护/更改代码?
如何证明代码符合需求并在该场景下得到了正确的执行?
作者在此提出的解决方案已在许多项目中应用:让代码告诉你它能做什么,并在运行时告诉你它做了什么,并且以图形化的方式呈现。
大约 20 年前,这种解决方案是通过记录带有时间戳的状态编号和事件转换,然后自动生成描述系统图的图表来实现的。这里的框架利用了 Doxygen 的一项内置功能,该功能允许代码编写(创建时或作为日志)可以描述状态机图的文本。作者还使用过 SVG 和其他基于文本的序列图工具,但觉得对于这个示例……Doxygen 特别棒,而 AT&T Research 的 graphviz 项目具有开创性。
对于嵌入式系统,当不需要测试或调试时,这里提供的代码可以去除字符串以减少开销,或者可以将其替换为 4 个字母的符号(可以作为 32 位数字记录)。
背景
状态机是处理复杂问题的线程的良好替代方案,因为:它们按状态将行为分组;允许在许多线程中不太实用的步骤处重入;并且它们可以根据需要转换到不同甚至并发的行为集。
在实践中,软件工程在很大程度上是关于在快速实现质量目标方面的可行性,以及可维护的代码,这些代码可以廉价地进行文档编写和测试。近年来,大多数框架似乎都专注于通过允许用户绘制状态然后自动生成代码来简化设计和框架的采用。
作者认为这简化了开发中简单且不怎么昂贵的部分。有很多工具可以绘制状态图,软件工程师也知道如何编写代码。我通常在餐巾纸或废纸上画草图,然后花几分钟编写代码,花最初一个小时调试,然后花几天时间弄清楚为什么当它与这个或那个边界情况结合使用时它不起作用。维护、修复、记录和测试软件通常占总成本的一半以上。
作者在桌面和嵌入式开发方面都做了大量工作。当无法承受线程或足够多线程的开销时,状态机通常是最佳解决方案。当可以将系统不同部分的行为分段为状态时,它们也是理想的选择。它们通常是嵌入式软件的首选方式,但真正的优势是什么?线程总是能更好地叙述一条程序执行线程的工作方式。在嵌入式系统中,通常每个传感器(数十个)都有不同的程序叙述来读取或设置传感器。拥有如此多的线程在嵌入式软件中需要过多的开销,它占用内存并需要上下文切换。分离的线程随后会导致互斥锁,因为代码可以以各种方式交换,而在状态机中则不然,因为它们可以在同一线程上无中断地顺序运行多个类似线程的操作。线程在软件中的优势在于提供代码中的叙述,向编写者/维护者明确展示代码的工作原理,但这有点无用,如果根本没有叙述,而是基于按钮按下而在该模式下执行此操作,而在该模式下不执行的话。在这种情况下,状态机可以为行为提供功能分解方法,从而简化代码。
关键在于,这两种架构思维(线程、状态)都有道理,应该做出平衡的决策来使用(或不使用)状态机框架。在决定框架时,存在一些选择。
线程更好吗(通常是这样)?
行为是事件驱动的,还是由状态(以不同方式)监控值?
是在状态期间执行操作,还是在转换期间执行,还是两者都执行?
如果状态机最适合,如果大部分逻辑是离散事件驱动的(UI 倾向于属于此类),那么事件处理应该是中心主题。如果是过程驱动的,那么允许在状态中进入、退出和执行某些操作的东西会更有用。这里的框架同样提供了这两种方法,因为大多数问题都需要某种混合。框架作为基类存在,因为作者希望您考虑包装框架/修改它并识别您问题的用例。让它成为你自己的。
这里提出的框架生成的图表足以用于测试、从代码本身进行调试。该框架非常通用,应至少稍微修改以适应特定项目的需求,但代码是一个很好的起点。
框架具有以下特点:
-
状态是类的实例(同一逻辑的多个实例)
-
状态是可配置的
-
状态机可以嵌套运行和并行运行
-
状态可以继承其他状态的行为
-
状态可以覆盖进入、退出、在给定状态等待的行为
-
状态可以响应事件,嵌套的超状态可以处理子状态的通用事件。
-
状态图可以是状态机编码的目标(状态图)
-
状态可以(图形化地)记录实际的转换
-
它还可以被修改以在编译时移除字符串,用于嵌入式软件。
这涵盖了大多数常见的状态机样式(在转换时工作 vs. 在状态时工作,以及响应事件或监控抽象数据集的状态机)。它旨在成为快速修改特定产品的起点。它利用 DOT 语言生成图表,这是 Doxygen 的一项功能1,Doxygen 是一个广泛使用的代码文档工具,它可以解析注释并生成 HTML 文档,包含图表和图像,类似于 Java 的 Javadoc 或 C# 的 XML 标记。
1https://en.wikipedia.org/wiki/DOT_(graph_description_language)
Graphviz 工具(dotty)允许 Doxygen 从嵌入在代码中的文本生成图表。例如:
/** digraph mygraphname { state_a[label="a" shape=box]; state_b[label="b"]; // use default shape. state_a->state_b[label="my transition"]; } */
被转换成一个图
洗衣机示例
此示例描述了一个简化的洗衣机,它需要响应启动按钮、取消按钮,进行洗涤和漂洗,并在需要时排水和播放一些声音。
以下是洗衣机代码生成的 SM。
当使用一些事件运行它时,可以进行执行线程测试,并将结果绘制成图。这是状态(框)和驱动器调用(椭圆)的图。
使用代码
源代码(而非图表)可以被工具解析、审查,并可以进行静态分析,因此虽然有图表很好,但纸张是我们的产品。代码必须易于阅读、审查和编写。为此,使用了一些 C++ 语法技巧。
在大多数情况下,代码是自解释的。提供了源代码的 Doxygen 文档。
一个很好的例子是“排水”状态如何与“搅拌”状态协同工作。有两个排水实例,一个在洗涤后,一个在漂洗后,所以行为定义在状态定义中,而转换被分配给实例。类定义行为,但实例化定义相对顺序和链接。
// Agitate, wait 10 min to be done, handle cancel and error signals. DO_STATE(Agitate) { // Macro forms a class w/ a doState() { started. // Called while in the state. if (gpDriver->timeNow(this) > mTime + TEN_MINUTES_MSEC) setState(next()); } void enter() { gpDriver->startAgitate(this); mTime = gpDriver->timeNow(this); } void exit() { gpDriver->stopAgitate(this); void handleSignal(uint32_t signal) { if (signal == SIGNAL_CANCEL) setState(cancel()); if (signal == SIGNAL_ERROR) setState(error()); } }; // Finish declaring the class.
它定义了一个名为Agitate的类,该类具有 doState()、enter()、exit() 和 handleSignal() 调用。在进入时,它启动搅拌并记录时间。然后,当时间过期时,它会转换到next(该实例指定的下一个状态)”。退出函数在状态退出时始终停止搅拌,并且有一个信号处理程序来捕获取消和错误信号并导向不同的目标状态。
这个宏(宏通常是坏的)通过声明大部分类类型,使代码显着减小且易于理解。
#define DO_STATE(n) class n: public SequenceState { public: n(const std::string &name, State *parent=NULL) : SequenceState(name, parent) {} virtual void doState()
这些对象的实例是
Agitate * agitate1 = new Agitate("Agitate1", s); Agitate * agitate2 = new Agitate("Agitate2", s); ... // Use the instances to get the behavior but assign different // "next" states for agitate1 and agitate2. agitate1 << Next(drain1); // Make the "next" state go to the drain1 instance in agitate1. agitate1 << Cancel(drain3, "Cancelling"); // On cancel goto drain3, and logg cancelling. agitate1 << Error(drain4, "Error"); agitate2 << Next(drain2); // Make the "next" state go to the drain2 instance in agitate2. agitate2 << Cancel(drain3, "Cancelling"); // On cancel goto drain3, and logg cancelling. agitate2 << Error(drain4, "Error");
实例总是排水,但这些排水会在返回就绪状态之前播放不同的声音(错误声音、取消声音或完成声音),因为结构连接独立于类行为。
历史
修复了一些格式问题,在编辑器中看起来可以,但在实际发布的 HTML 中却不行。