Open WangShuXian6 opened 2 weeks ago
在本节课中,我们将深入理解什么是状态机(State Machine),也称为有限状态机(Finite State Machine,FSM)。状态机是一种软件计算模型,用于解决复杂的应用问题。状态机包含有限的状态,因此称为“有限”状态机。每个状态代表应用程序的不同情况,通过事件(即输入)触发状态间的转换。
状态机有多种类型,例如:
我们将在课程中进一步讨论不同类型的状态机。
在市场上,有一些工具可以解析 UML 状态机图,并自动生成基础代码。这些工具包括:
在后续的课程中,我们将使用 Quantum Leaps 提供的 QP 框架和 QM 建模工具,来实现嵌套的层次状态机。
在接下来的课程中,我们将深入了解 Mealy 机器的原理,以及它与 Moore 机器的差异,同时探索 UML 规范用于绘制状态机图的标准。
在本节课中,我们将深入了解 Mealy 机器 和 Moore 机器 之间的区别。这两种状态机的区别在于 输出生成的方式。
输入/输出
的格式,例如,当状态从 state 1
转换到 state 2
时,输出会伴随转换产生。Off
、Dim
、Medium Brightness
和 Full Brightness
四个状态。按下 ON
按钮时,灯光亮度会增加。输出行为(例如“调暗灯光”)是在转换过程中生成的。Dim
)时,输出行为会在进入该状态时产生,例如“调暗灯光”。在下一节课中,我们将继续深入了解 UML 状态机图的绘制规范。
Mealy 机器的状态转换表:
Off
状态并收到 ON
事件时,灯光变为 Dim
状态,输出为“调暗灯光”。如果是 Off
状态接收 Off
事件,则无输出,状态保持不变。Moore 机器的状态转换表:
Off
状态时,执行“关闭灯光”的入口操作,并等待输入事件。如果接收到 ON
事件,则转移到 Dim
状态;如果接收到 Off
事件,则忽略。概述:Harel 状态图由 David Harel 在 1984 年的论文 “A Visual Formalism for Complex Systems” 中提出。
Harel 状态图的特点:
UML 状态机:
在接下来的课程中,我们将继续深入探讨 UML 状态机的绘制规范与应用。
在本次实验中,我们将实现一个基于 Mealy 状态机的灯光控制应用,使用 Arduino Uno 控制 LED 的亮度。
Arduino 编程框架提供了控制外设的 API。在本次实验中,我们将使用 analogWrite()
来生成 PWM 信号,以控制 LED 的亮度。
通过 PWM(脉宽调制) 控制 LED 亮度:
analogWrite()
函数生成 PWM 信号,参数范围为 0
到 255
,对应 0% 到 100% 的占空比。Arduino Uno 支持 串口通信。通过 USB 连接至计算机时会创建虚拟 COM 端口,支持在代码运行时通过串口发送指令。
analogWrite()
函数用法analogWrite(pin, value)
:
pin
:指定 PWM 引脚。value
:控制 PWM 占空比,范围为 0
(0% 占空比)到 255
(100% 占空比)。创建新项目:
StateMachine_Projects/LightControlMealy
)。使用 analogWrite()
函数控制 LED:
analogWrite()
调整 LED 的亮度(占空比),从而实现 Mealy 状态机控制 LED 的亮度变化。串口指令实现事件触发:
ON
和 OFF
指令来控制 LED 的亮度状态,无需按钮。通过上述步骤,你将能够使用 Arduino Uno 实现一个基本的灯光控制应用,理解 PWM 控制和状态机在硬件中的应用。
在这个实验中,我们将通过Arduino Uno实现一个灯光控制应用,使用状态机的概念来控制LED的亮度变化。本次实验的目标是通过创建多个状态和事件,并使用PWM来调节LED亮度。
本实验包含以下四个状态:
LIGHT_OFF
:灯关LIGHT_DIM
:微亮LIGHT_MEDIUM
:中等亮度LIGHT_FULL
:全亮事件有两个:
ON
:开启事件OFF
:关闭事件在Arduino代码中,我们将使用C语言中的enum
枚举类型来定义状态和事件,以便程序更具可读性和结构性。
// 定义事件
typedef enum {
ON,
OFF
} event_t;
// 定义状态
typedef enum {
LIGHT_OFF,
LIGHT_DIM,
LIGHT_MEDIUM,
LIGHT_FULL
} light_state_t;
// 初始化当前状态
light_state_t current_state = LIGHT_OFF;
我们将实现一个状态机处理函数,通过嵌套的switch-case
结构来处理不同的状态和事件。每当接收到事件时,状态机会根据当前状态和事件触发相应的响应,并且更新LED的亮度。
void light_state_machine(event_t event) {
switch(current_state) {
case LIGHT_OFF:
if (event == ON) {
change_intensity(PIN_LED, DIM_BRIGHTNESS);
current_state = LIGHT_DIM;
}
break;
case LIGHT_DIM:
if (event == ON) {
change_intensity(PIN_LED, MEDIUM_BRIGHTNESS);
current_state = LIGHT_MEDIUM;
} else if (event == OFF) {
change_intensity(PIN_LED, OFF_BRIGHTNESS);
current_state = LIGHT_OFF;
}
break;
case LIGHT_MEDIUM:
if (event == ON) {
change_intensity(PIN_LED, FULL_BRIGHTNESS);
current_state = LIGHT_FULL;
} else if (event == OFF) {
change_intensity(PIN_LED, OFF_BRIGHTNESS);
current_state = LIGHT_OFF;
}
break;
case LIGHT_FULL:
if (event == OFF) {
change_intensity(PIN_LED, OFF_BRIGHTNESS);
current_state = LIGHT_OFF;
}
break;
}
}
change_intensity
函数用于设置LED亮度,通过PWM控制亮度等级。
void change_intensity(int pin, int brightness) {
analogWrite(pin, brightness);
}
亮度值定义为宏:
#define OFF_BRIGHTNESS 0
#define DIM_BRIGHTNESS 64
#define MEDIUM_BRIGHTNESS 128
#define FULL_BRIGHTNESS 255
在setup()
函数中初始化串口通信,以便在实验中通过串口发送ON
和OFF
指令来控制LED亮度。
void setup() {
Serial.begin(115200);
pinMode(PIN_LED, OUTPUT);
}
void loop() {
if (Serial.available()) {
char command = Serial.read();
if (command == '1') light_state_machine(ON);
else if (command == '0') light_state_machine(OFF);
}
}
PWM
控制LED亮度,通过串口通信实现了控制事件的触发。switch-case
结构实现状态和事件的处理,可以扩展和维护应用程序的复杂逻辑。我们使用Serial.begin()
方法来配置Arduino与主机之间的UART通信速率,并通过Serial.read()
方法读取主机发送的控制指令。
void setup() {
Serial.begin(115200); // 设置波特率
pinMode(PIN_LED, OUTPUT); // 设置LED引脚为输出模式
Serial.println("Light control application");
}
在loop()
循环中,通过Serial.available()
检查是否有数据可以读取。如果有数据,则使用Serial.read()
方法读取字节,并根据输入控制LED的亮度。
void loop() {
if (Serial.available() > 0) { // 检查是否有数据可读取
char event = Serial.read(); // 读取一个字节
if (event == 'o') {
light_state_machine(ON); // ‘o’ 表示 ON 事件
} else if (event == 'x') {
light_state_machine(OFF); // ‘x’ 表示 OFF 事件
}
}
}
通过Mealy状态机结构,我们实现了灯光的不同亮度状态切换:
void light_state_machine(event_t event) {
switch (current_state) {
case LIGHT_OFF:
if (event == ON) {
change_intensity(PIN_LED, DIM_BRIGHTNESS);
current_state = LIGHT_DIM;
}
break;
case LIGHT_DIM:
if (event == ON) {
change_intensity(PIN_LED, MEDIUM_BRIGHTNESS);
current_state = LIGHT_MEDIUM;
} else if (event == OFF) {
change_intensity(PIN_LED, OFF_BRIGHTNESS);
current_state = LIGHT_OFF;
}
break;
case LIGHT_MEDIUM:
if (event == ON) {
change_intensity(PIN_LED, FULL_BRIGHTNESS);
current_state = LIGHT_FULL;
} else if (event == OFF) {
change_intensity(PIN_LED, OFF_BRIGHTNESS);
current_state = LIGHT_OFF;
}
break;
case LIGHT_FULL:
if (event == OFF) {
change_intensity(PIN_LED, OFF_BRIGHTNESS);
current_state = LIGHT_OFF;
}
break;
}
}
使用analogWrite()
函数实现PWM调节LED亮度:
void change_intensity(int pin, int brightness) {
analogWrite(pin, brightness); // 调节PWM占空比控制亮度
}
亮度的宏定义:
#define OFF_BRIGHTNESS 0
#define DIM_BRIGHTNESS 64
#define MEDIUM_BRIGHTNESS 128
#define FULL_BRIGHTNESS 255
在Tinkercad中进行仿真:
接下来,我们将稍微重构代码,将其实现为Moore状态机结构。
前言
练习目的
后续复杂应用
UML状态图
实现Moore机
状态转换
项目设置
002LightControlMoore
。 代码结构
run_entry_action
,根据状态执行相应的进入动作。初始化状态
setup
函数中初始化灯光状态机,设定初始状态为LIGHT_OFF
,并调用run_entry_action
执行进入动作。状态转换
light_state_machine
函数。previous_state
,以检测状态是否改变。状态变化检查
previous_state
和当前状态,如果不同,则表示状态发生变化,运行新状态的进入动作。实现方法
ENTRY
来处理进入动作,也可以直接在状态机中实现。总结
002LightControlMoore
。run_entry_action
。LIGHT_OFF
。run_entry_action
,并将状态作为参数传入。light_state_machine
。LIGHT_OFF
,那么状态会发生变化。增减时间按钮:
开始/暂停按钮:
计时器功能:
空闲模式操作:
状态的定义:
确定状态的方法:
这个结构和内容可以帮助您更好地理解和构建状态机图,以满足项目需求。
在之前的讲座中,我们探讨了简单状态和复合状态,并理解了如何绘制简单状态及其简单部分。
现在让我们来探索内部活动区。
在我们的状态机图中,如果你考虑这个状态,你可以在这里提到内部活动。这就是内部活动区。
内部活动定义了状态的内部行为。每个状态可以有自己独特的内部行为。内部活动区持有与状态相关的内部行为列表。
表示状态的内部活动非常简单。语法如下:
首先,你需要写上“行为类型标签”,即内部活动的标签,后面跟一个“/”字符,然后写上“行为表达式”。行为表达式实际上就是一个动作,可以是任何编程表达式、可执行的编程语句,或者是函数调用,诸如此类。
简单来说,就是标签/action。
这些标签包括‘entry’、‘exit’和‘do’,它们是UML规范中定义的内部活动标签。这些标签应该只出现在内部活动区,而不应在内部活动区之外用于表示特定应用事件。
这些标签标识了在什么情况下,‘行为表达式’指定的行为被执行。
entry:标识的行为在对象进入状态时执行。使用‘entry’关键字表示状态有一个进入动作。进入动作是指在对象进入状态时执行的动作,且为一次性执行。
exit:标识的行为在对象退出状态时执行。使用‘exit’关键字表示状态有一个退出动作。每当对象离开该状态时,‘exit’动作将被执行。
do:标识的行为在对象处于该状态时执行,或直到行为表达式指定的计算完成。‘do’标签代表持续的行为,而‘entry’和‘exit’动作只执行一次。‘do’标签代表在对象处于该状态时持续进行的动作。
是否需要这些内部活动,取决于你的项目设计和状态机设计。所有这些都是可选的。一个状态可能没有‘entry’或‘exit’动作,也可能只具有‘do’动作,或者可能只有‘entry’动作。这些都是可以在状态内部进行的可选操作。
这里是内部活动区的一个示例。我们将在状态机图中做这个示例。
当对象在STAT状态时,这就是一个进入动作。这两个进入动作由逗号分隔。它们都用‘entry’标签标识。
当对象进入这个状态时,它会在LCD上显示一条消息,同时显示一个变量的值。而当对象离开该状态时,它会清除显示屏,这就是该状态的‘exit’动作。
同样,当对象进入IDLE状态时,它会将一些变量初始化为0。这些都是对象的属性。正如我在之前的视频中提到的,对象实际上是属性和方法(或行为)的集合。
这个主要应用对象‘mobj’是主要应用对象,这些是它的变量并被初始化,这就是该状态的‘entry’动作,同时它还显示时间为0,并显示消息‘set_time’。这些都是用逗号分隔的‘entry’动作。
当离开该状态时,这就是‘exit’动作,‘exit’动作是‘display clear’。这与其他不同的是,这里是内部转换。这不是内部活动,关于这一点我会在之后讲解。
内部活动始终由标签识别。下一节课,我们将了解内部转换。下次见!
在上一节课中,我们探讨了状态的内部活动,包括“进入”、“退出”和“执行”等。这些都是状态的内部活动。现在,在本节课中,让我们了解内部转换。
内部转换是执行某些操作的一种方式,这些操作由行为表达式标识。当系统中发生某个“触发”(trigger)事件,并且“守卫”(guard)条件评估为真时,如果有守卫条件的定义,内部转换便会触发。这里的“触发”实际上是一个事件或事故的原因。
内部转换的语法如下:我们可以简单地写成T(G)
,其中G是可选的守卫条件,后面跟着动作。如果事件的发生与内部转换的“触发”相匹配,并且“守卫”条件评估为真,则由行为表达式标识的行为将被执行,而无需退出或重新进入定义该行为的状态。
在内部转换中,对象不会离开其当前状态,因此没有退出状态的概念。
当“触发”事件发生时,如果守卫条件为真,则将在不退出或重新进入状态的情况下执行该动作。
假设我们有一个状态叫做“空闲”(IDLE)。在该状态下,当触发事件发生且守卫条件为真时,将执行特定动作,而不退出或重新进入该状态。
例如,当“时间滴答”(TIME_TICK)事件发生且某变量值为5时,执行一个“滴声”(beep)动作。
根据UML规范,转换可以分为外部转换、局部转换和内部转换。我们刚刚探讨的是内部转换。
在外部转换中,由于触发事件的发生,源状态被退出,接着执行与转换相关的可选动作,并执行目标状态的操作(如果有的话)。外部转换标志着对象生命周期中的状态或情境的变化。
在进行转换时,首先会执行当前状态的退出动作,然后执行转换动作,最后执行新进入状态的入口动作。这是根据UML规范定义的执行顺序。
假设对象当前在“倒计时”(COUNTDOWN)状态,当接收到“启动/暂停”(START_PAUSE)事件时,发生外部转换到“暂停”(PAUSE)状态。
在这个例子中,当“倒计时”状态接收到“启动/暂停”事件时,状态变量将更新为“暂停”。
在讨论转换时,内部转换不涉及状态的退出和重新进入,而外部转换则标志着状态的改变,并需遵循特定的执行顺序。通过这些概念,我们可以更好地理解状态机的行为和状态之间的关系。
现在,在本讲中,让我们了解什么是事件,或者你也可以称之为触发器。
事件就是可以触发状态机的事件或刺激。
基本上,它们是应用程序中的异步事件或同步事件。
它们可以抽象为事件。在状态机中,事件可能导致转换,而转换可以是外部的或内部的。
让我们以微波炉为例。
你打开门,这个动作就是一个事件;当这个事件发生时,会生成一个事件,并被传送到微波炉内部的固件。固件根据其当前状态可能采取一些行动,例如关闭加热器、打开灯光等。关闭门也是一个事件,可能会有其相关的动作。
例如,设置定时器。你使用烤箱控制面板上的按钮设置定时器,开始烤箱操作等。这些都是事件,事件可能导致状态机图的转换,并可能有其相关的动作。
事件通常有两个组成部分:
第二个组件实际上是可选的。
考虑一个应用程序,我们有三个按钮:“加”、“减”和“开始/暂停”。按下这些按钮会生成事件。
当你按下加号按钮时,它生成一个事件。这个事件有两个组成部分:
这没关系,因为那是可选的。
类似地,如果你按下减号按钮,它生成另一个事件,其信号名为“减少时间”。在这种情况下,这个事件也不需要任何参数。
你也可以这样写事件:无论用户按下哪个按钮,加号按钮还是减号按钮,生成事件“时间变化”(TIME_CHANGE)。然后使用参数“方向”来指示用户实际按下了哪个按钮。
你可以创建一个枚举,使用这些值UP或DOWN进行区分。这些将成为信号值。
这个事件通过其信号属性表明用户按下了一个改变时间的按钮。信号有一个相关的参数,编码了用户按下的按钮是增加时间还是减少时间。
再举一个计算器的例子。
计算器有数字键盘,上面有10个数字(0到9)和几个操作符,以及结果按钮等。
那么,如何编码按下任意数字呢?你可以生成一个事件,其信号名为数字(0到9),参数则表示用户按下了哪个数字。
这样,你就可以只生成一个事件,而不是为每个数字创建一个事件,利用事件的参数组件区分用户按下了哪个数字。
类似地,你可以保持一个事件来表示按下操作符按钮。
通过这样的方式,在编程中,你可以使用结构体或枚举来建模应用程序的事件。
好吧,我们现在继续我们的练习二,即生产力计时器应用。我们已经理解了项目要求,我也已经演示过这些要求。
这些是我们要转换为状态的不同情况,我们已经在Astah软件上绘制了这些状态。现在,让我们看看将用于该应用程序的各种事件。
‘SS’的值范围从1到10,其中1表示100毫秒,10表示1秒。
我们将在该应用程序中使用一些扩展状态变量,这些变量对于捕捉数据和在状态机中做出决策至关重要:
这些变量将组织在一个名为protimer_t
的结构中。
我们为该应用程序对象绘制的状态机跟踪应用程序的生命周期。
我们将从这个状态开始绘制外部转换、内部转换和活动。
初始伪状态至关重要,因为它代表状态机的起点。它只能有一个输出转换,并且不支持触发器或守卫。初始动作将涉及初始化扩展状态变量:
mobj->curr_time = 0;
mobj->elapsed_time = 0;
mobj->productive_time = 0;
这些操作将把主对象的属性设置为零,作为我们启动应用程序的基础。
现在,让我们回到Astah软件中完成我们的状态机图。
为了减少图表上的杂乱,我将用简短的变量名表示,而不是完整的变量名。
c_time、e_time 和 p_time。
我将只使用简短的名称。
现在,让我们设计一个空闲状态。
根据演示,当你为应用程序供电时,LCD 显示器应该显示“设置时间”。时间组件显示为 0,对吧?0 秒,0 分钟。
这就是为什么,每当应用程序进入空闲状态时,它应该显示某些内容。
因此,我们将为这个空闲状态定义一个进入活动。
只需点击该状态,在左侧可以看到进入/执行/退出。
现在让我们定义进入动作。
一个进入动作可以是:
在 LCD 上显示时间,我将称其为“显示时间”。显示时间是一个辅助动作,它是一个函数,用于显示时间。
但最初,当应用程序处于空闲状态时,它应该显示 0。
所以,传入的参数是 0。
显示时间函数根据传递的参数显示时间。传递的参数是 0(disp_time)。同时,如你所见,它还向用户显示一条消息,对吧?“设置时间”;显示消息也是另一个辅助动作函数。
在这里,我将发送消息“设置时间”。
所以,这就是两个进入动作。
接下来的进入动作是什么?
我们还应该做一个 mobj->curr_time=0
;还有,mobj->elapsed_time=0
;
因为它处于空闲状态。所以,我认为这些就是进入动作。
mobj->current_time =0
;elapsed_time=0
;并显示时间为 0;显示消息为“设置时间”。
这些就是进入动作。既然我们已经在这里做了 c_time=0
,e_time=0
,那么就没有必要了,让我们删除这个。
我将在这里将 p_time=0
。
这就是我们的进入动作。
太好了。
现在我们完成了空闲状态的进入动作。
接下来让我们进入时间设置状态。
每当应用程序进入时间设置状态时,它应该显示当前时间。
因此,让我们为此定义一个进入动作。
我将选择这个状态,进入并将进入动作定义为“显示时间”。
你必须显示当前时间(c_time)。
因此,当应用程序进入这个状态时,它会显示时间。就这样。
现在,让我们为暂停状态编写进入动作。
每当你按下暂停按钮时,你可以看到这里倒计时停止,并显示消息“已暂停”。
现在,让我们在暂停状态下执行这一操作。
那么,这里的进入动作将是 disp_msg(“paused”)
,
它并不影响倒计时。所以稍后我们将看看这是如何发生的。
但是每当应用程序进入这个状态时,它应该发送消息“已暂停”。
现在,让我们进入倒计时状态。对于倒计时,它应该每秒倒计时一次。
这意味着,每当这个状态接收到 TICK 事件时,将会有一个倒计时过程。
所以,我不确定这是否需要进入动作,
如果你没有任何想法,那么你就可以不定义。
但是我不想为这个定义任何进入动作。
现在,让我们进入统计状态(STAT 状态)。如你在演示中所见,
每当你按下开始或暂停按钮时,它会显示生产时间,并且还会显示消息“生产时间”。
这表明你必须在这个状态的进入动作中做一些事情。
因此,选择这个状态,进入动作,
我们该做什么?
如你在演示中所见,它应该在显示器的第一行显示生产时间,并且在第二行发送一条消息。
这意味着我们有两个动作。
disp_time(mobj->p_time)
,并给一个逗号,显示消息“生产时间”。
所以,每当你有两个或更多的动作要做时,你可以做一件事,结束每个动作用分号。
这样看起来不错。
在这一行之后,这是一个动作,你给一个分号,这是第二个动作。
我将给一个分号。
这是第三和第四个动作。
所以在这里你也只需用分号结束。
现在,让我们定义一些退出动作。
从空闲状态开始。
空闲状态在进入时显示了一些内容。当应用程序接收到某些事件时,它会转到其他状态,并可能显示其他内容。
因此,我认为在显示上显示了内容的应该清除它。所以,当退出这个状态时,这个状态必须清除它在显示上显示的内容。
所以,我将定义这个状态的退出动作为:
好的,我将去退出部分。退出动作将是“清除显示”。
稍后我们将看看是否需要更多的动作,但目前我只能想到在离开这个状态时清除显示。
对于 STAT 状态,退出动作也将是“清除显示”。
那么时间设置状态呢?
可能是需要的。所以,对于时间设置状态,我暂时不会定义任何退出动作。稍后再看看。对于暂停状态
它也显示了内容“已暂停”,对吧?
所以,它应该清除它。
因此,我将为此定义一个退出动作为“disp_clr”。
对于倒计时,我也不确定是否需要定义任何退出动作,所以稍后再看看。
现在我们已经部分实现了进入和退出动作。
让我们进行一些转换。
在之前的讲座中,我们为各种状态编写了一些入口和出口操作。
现在让我们实现状态转换。每个状态绘制的转换数量取决于该状态处理的事件数量。我们在这个应用中有多少事件呢?
我已经向你展示过这个表格。我们有5个事件,所以每个状态最多可以有5个转换。
现在,首先让我们从IDLE状态开始。你需要检查该状态是否真的处理某个特定事件。如果它不处理或不尊重该事件,那么你可以忽略它。
现在首先让我们从增量时间(INC_TIME)开始,针对IDLE状态。
当应用处于IDLE状态时,如果接收到增量时间事件,它应该转到TIME_SET状态,因为用户想要设置时间。
因此,我们在这里进行一个转换。你可以从任何地方绘制它。
这是一个转换,触发器是事件名称,即增量时间(INC_TIME)。
是否需要任何Guard?目前我没有给出任何Guard。每当按下“+”按钮时,它应该进行转换。
这个转换的操作是什么?
你应该选择这条线,属性窗口会弹出。操作是用户按下“+”按钮,因此我们需要增量时间细节。
操作可以是mobj->c_time += 60;
,为当前时间变量增加60秒。
这就是操作。你可以考虑一些Guard,但我目前不放任何Guard以保持简单。
如果c_time
已经达到最高水平,那么根据Guard评估,你可以忽略它,不进行任何转换。
现在让我们处理下一个事件,即TIME_TICK事件。
该应用程序必须在返回到IDLE模式时发出20次哔声。假设每500毫秒应用程序需要发出一次哔声,持续10毫秒。
所以,它必须发出20次哔声,然后进入静音模式。
每当接收到TIME_TICK事件时,动作必须被执行,但没有转换。
这意味着这实际上是一个内部转换。
现在,让我们回去,选择IDLE模式,然后转到内部。将触发器设置为TIME_TICK。
此事件有一个参数。当SS = 5
(每500毫秒)时,操作是do_beep();
。
或者它也可以写成e->SS
,当其等于5时,发出哔声。这就是内部转换。
现在我们已经覆盖了IDLE状态的所有事件。接下来让我们转到TIME_SET状态,思考一下你希望如何处理该状态中的所有事件。
下次见!
现在让我们处理这个状态的所有事件。考虑这个状态。
现在应用程序处于设置时间状态,并假设接收到增量时间事件。
增量时间意味着
你必须增加时间,变量是 c_time
。让我们来做这个。
所以,现在的增量时间应该在这个状态内发生。这是一个负责设置时间的状态。因此,显然它不能过渡,它应该在内部处理。
因此,增量时间和减量时间将是此状态的内部转换。它不能请求其他人来进行增量时间或减量时间。这是这个状态的责任。
选择这个并转到内部,触发器是增量时间(INC_TIME
)。保护条件,好的,暂时忘记保护条件。动作是什么?动作是与这个相同的动作。
还有一个动作,那就是应该在显示器上显示增加后的时间。所以,disp_time (mobj->c_time)
。让我暂时去掉这个保护条件。那不是必需的。
每当接收到增量时间时,它就会对 c_time
进行加法,然后显示修改后的时间。就是这样。
非常简单,对吧?现在,让我们处理减量时间。
减量时间也是一个内部转换。现在让我添加一个内部转换,减量时间(DEC_TIME
)。保护条件是什么呢?我们稍后再考虑这个。
动作是什么?你必须在这里做“−”。
mobj->c_time -= 60;
你修改了时间,所以要显示这个时间。
mobj->c_time;
就这样。
那么,这里的保护条件可能是什么?
你应该确保不低于0,对吧?所以,保护条件是
mobj->c_time 应该大于或等于 60;
这就是保护条件。
好的?所以这是一个布尔表达式,如果 c_time
小于 60,则布尔表达式为假。
请注意,使用增量时间和减量时间,你只能增减分钟,而不是秒。所以,这对秒没有影响。希望你能理解这一点。
现在我们处理了两个事件,让我们移动到下一个事件,即中止。
中止事件
现在,你已经设置了一些东西,并且你想中止所有内容,回到空闲模式。你可以做到这一点。当发生中止事件时,它就回到了空闲状态。我是说,没有保护条件,没有动作,什么都没有。
所以,简单地中止。当接收到中止事件时,它进入空闲状态,但它显示了某些内容,对吧?这需要清除。
这就是为什么你可以在这里给一个退出动作。退出动作是
disp_clr();
当它进入空闲模式时,它会将一切设置为0并显示0,同时发送消息“设置时间”。
开始/暂停事件
现在下一个事件是开始/暂停,对吧?现在你已经完成了时间设置,用户按下开始/暂停按钮,倒计时必须开始,对吧?这发生在倒计时状态。这就是为什么,现在会有一个从设置时间状态到倒计时状态的转换。
让我在这里写一个转换。让我在这里实现这个。
我将把这个触发器称为开始/暂停。那么保护条件是什么?
如果分钟为0,倒计时无法开始。这就是为什么保护条件是
mobj->c_time 应该大于(>)或等于 60;
所以,这就是这个转换发生的保护条件。如果这个条件不为真,那么这个转换就无法发生。这就是我们的开始/暂停事件。
时间滴答事件
现在下一个事件是时间滴答事件。当应用程序处于设置时间状态时,它实际上并不处理时间滴答事件。
我的意思是,没有与时间同步的动作。因为,设置时间几乎是根据用户按下的“+”或“−”按钮来修改时间变量。这就是为什么,它没有定义基于时间的动作。
所以,让我们在这个状态中忽略那个事件。现在让我们移动到下一个状态,即暂停。
你想想你在这里做了什么,尝试绘制你的步骤,我会在下节课中讲解这个。
让我们来实现 PAUSE 状态。
在项目需求中提到,当倒计时暂停时,可以修改时间。这意味着,当应用处于暂停状态时,时间可以被修改。
这意味着它会尊重增量时间和减量时间事件。
所以,当我们处于 PAUSE 状态时,假设我们接收到增量时间事件,那么我们应该处理它,对吗?我们应该进入 TIME_SET 状态。我将其称为增量时间(INC_TIME)。
那么,行动是什么?mobj->c_time += 60
。
明白吗?这就是转移过程中的一个动作。
还有一个转移。让我们再画一个从这里到这里的转移,用于减量时间。
这个触发器是 DEC_TIME。保护条件是你已经知道了,mobj->c_time
应该大于或等于 60。
而动作是 mobj->c_time -= 60
。这就是动作。
好的,这就是 PAUSE 状态处理增量时间和减量时间事件的方式。接下来是 START_PAUSE。
那么,什么是 PAUSE?PAUSE 就是倒计时已暂停。当倒计时已暂停时,应用处于 PAUSE 状态。
如果它再次接收到 START_PAUSE 事件,那么它应该恢复倒计时,对吧?所以,它应该回到 COUNTDOWN 状态。
所以,触发器就是 START_PAUSE。就这样。
那么 Abort 事件呢?所有事情都必须被中止。它应该回到 IDLE 模式。
所以,让我们从这里到这里再画一个转移,可以画成这样。
或者这样。好的,这就是 Abort。
看起来不错。所以,当应用处于暂停状态时,它不处理 TIME_TICK 事件。这就是为什么它会忽略 TIME_TICK 事件。
现在,让我们进入下一个状态 COUNTDOWN。
所以,当时间倒计时时,你不能修改时间。这就是为什么在这个状态下不处理增量时间和减量时间事件。我们可以忽略它们。
下一个是 START_PAUSE 事件。这个事件实际上是会被处理的。每当它接收到 START_PAUSE 事件时,它必须进入 PAUSE 状态。
所以,这就是为什么必须有一个转移从这里到这里。这就是 START_PAUSE。
那么 Abort 呢?你可以中止它。所以,画一个转移在这里。这就是 Abort。
那么,TIME_TICK 事件在 COUNTDOWN 状态下呢?每当发生 TIME_TICK 事件时,当子秒字段为 10(这意味着已经过 1000 毫秒),那么我们应该将当前时间减少 1 秒。
所以,COUNTDOWN 每 1 秒发生一次。现在,让我们这样做。
那么首先,你会将其实现为外部转移还是内部转移?实际上我们会选择内部转移,好吗?我会在这里选择内部转移。
让我添加一个内部转移 TIME_TICK。
所以,保护条件是,当事件参数子秒=10。那么,什么是动作?
我们必须减少 mobj->c_time
,它正在倒计时。所以,这就是,mobj--
,我想是。
抱歉,其实不太清晰,所以 c_time
被减少。然后你需要给 elapsed_time
加 1,并且你需要显示 mobj->c_time
。
现在,让我们来实现 STAT 状态。
首先,我们来看一下项目需求。
要求是,当应用处于 IDLE 模式时,按下 START/PAUSE 按钮应显示 STAT 1 秒,并自动返回到 IDLE 模式。
现在,让我们回到软件。当应用处于 IDLE 模式时,按下 START_PAUSE 按钮应该显示 STAT。
所以,我们需要在这里定义一个从 IDLE 到 STAT 的转移。
这就是 START_PAUSE。
没有保护条件,也没有动作。
进入这个状态时,它会显示生产时间,并发送一条消息,然后在 1 秒后自动返回到 IDLE 模式。
首先,假设我们的应用在 STAT 状态。
在 STAT 状态下,它不处理增量时间事件、减量时间事件,也不处理 START_PAUSE 事件和 Abort 事件。它会显示某些内容,然后在 1 秒后自动返回。
让我们在这里再画一个转移,从 STAT 到 IDLE。
我们称之为 TIME_TICK。
TIME_TICK 事件的保护条件是,当“ss”参数等于 10 时。当此事件发生且 (ss=10) 时,它将自动返回到 IDLE 模式。
所以,我们成功完成了应用的状态机图。
请回顾一下,我已经解释了所有内容,包括状态、转移、事件、选择伪状态、初始伪状态,以及其他许多细节。
但这看起来有点乱,不是吗?
因为它是一个平面状态机,而不是一个层次结构的状态机。稍后我们会看到如何将其转换为层次结构状态机,或者我会介绍其他示例。我们还将做一个层次状态机的项目。
这只是一个开始,我的目标是通过示例介绍各种规范术语。
这就是我在这个应用中的目标。
使用层次状态机实际上可以使你的图表变得不那么混乱;你确实可以减少杂乱,我们稍后会看到这一点。
从下节课开始,我们将把这个图转化为代码,下一节课见。
在之前的讲座中,我们已经完成了我们应用的状态机图。
现在,让我们创建一个新项目,并开始使用 C 编程语言实现这个状态机。
为了这次练习和未来的练习,我将不使用 Arduino IDE 来创建基于 Arduino 的项目。因为在这个应用中,我们使用了很多结构和其他内容,我们需要像代码提示、自动补全等功能,这些功能显然会加快代码编写,但 Arduino IDE 缺乏这些特性。
所以,这次练习以及未来的练习,我将介绍一个新的设置,使用 Microsoft Visual Studio Code IDE,并配合 PlatformIO 扩展。
Microsoft 的 IDE 是一个很棒的 IDE,支持所有操作系统平台,如 Windows、Linux 和 Mac,因此在安装这些软件时不会遇到任何问题。
现在,我将解释如何在 Microsoft Visual Studio Code 中安装 PlatformIO 扩展。然后我们将在其上创建一个 Microsoft 项目,并实现这个应用。
好的,现在我将向你展示如何安装 Microsoft Visual Studio Code。
非常简单。
只需访问这个网站 code.visualstudio.com
。
下载适合你的机器的版本并安装。安装后,打开 Visual Studio Code 应用。
好的,这里是我的一些之前的工作区,没有问题。
一旦打开,转到扩展,那里有搜索扩展的框。
在这里输入 PlatformIO。
点击这个 PlatformIO IDE 并进行安装。
就这样。对我来说,已经安装好了,所以显示的是卸载选项。
但对你来说,应该看到安装按钮,只需安装即可。
安装完成后,关闭 IDE,然后重新打开或重启 IDE。
此时,你会看到一个“激活扩展”的消息。
这将激活你刚刚安装的 PlatformIO 扩展。
这就是如何安装 Visual Studio Code 和 PlatformIO 扩展的步骤。
希望你能做到,我们下节课见。
在上一节课中,我们完成了应用程序的状态机图。现在,我们将创建一个新项目,并开始使用 C 编程语言实现该状态机。
打开 Visual Studio Code
安装 Microsoft Visual Studio Code IDE 和 PlatformIO 扩展。
启动 Visual Studio Code
打开 IDE 后,您可能会看到欢迎页面,激活扩展可能需要一些时间。完成后,您将看到 PlatformIO 图标。
创建新项目
StateMachine_Projects
)。信任作者
创建项目时,您可能需要信任作者,以便进行网络活动。
打开源文件
在 src
文件夹中找到 main.cpp
文件,您将看到 setup()
和 loop()
函数。
连接 Arduino 板
将 Arduino 板连接到计算机。
编写简单程序
在 loop()
函数中添加以下代码以打印“Hello world”:
Serial.begin(9600);
Serial.println("Hello world");
编译和上传程序
查看输出
loop()
函数中。更改波特率
将波特率更改为 115200
,重新编译和上传程序。
串行监视器设置
如果出现错误提示“Access is denied”,请确保关闭串行监视器后再进行上传。
调整串行监视器波特率
在 platformio.ini
文件中添加相应的波特率设置,以确保串行监视器与代码一致。
到此,您已经成功创建并上传了一个基本的 Arduino 项目。接下来,我们将进行更复杂的实现。感谢您的参与,下次课再见!
在上一节课中,我们设置了这个IDE,而在这一节课中,让我们开始实现。
第一步是为我们的项目创建更多的头文件和源文件。我们有 main.cpp
,这是一个C++文件,因为Arduino框架使用C++语言。不过,我们将使用C语言的语法进行编码。
现在,让我们再创建一个文件——一个头文件。右键选择“新建文件”,并命名为 main.h
。由于我们在这个项目中使用LCD,所以让我们也创建 lcd.cpp
,以存放与LCD相关的代码,以及另一个文件 lcd.h
。
接下来,我们将状态机的实现分隔到一个单独的文件中,我将把它命名为 protimer_state_machine.cpp
。这个文件将包含所有状态机的实现,这样可以保持代码的组织,而不是把所有内容都放在 main.cpp
中。
现在我们有了 main.h
。在 main.cpp
中,只需包含 main.h
,并记得包含 arduino.h
,这是使用PlatformIO扩展时所必需的。
在 main.h
中,让我们添加包含保护:
#ifndef MAIN_H
#define MAIN_H
#endif
这将防止头文件被多次包含。
接下来,我们还需要包含 lcd.h
,并使用类似的包含保护:
#ifndef LCD_H
#define LCD_H
#endif
看起来不错。现在,让我们继续为我们的应用程序添加几个数据结构。我们将使用枚举定义各种信号状态,然后创建主应用程序结构,包含主应用程序的属性。
我们需要结构来表示事件,因为结构有两个组成部分:信号组件和相关参数。让我们使用结构来表示事件。
在 main.h
中,我将定义一个枚举来表示各种信号和应用程序的状态。这也将包括表示内部活动信号的入口和出口枚举器。
之后,我将创建一个主应用程序的结构,包含属性,如当前时间、经过时间和状态变量,这些变量表示应用程序的当前活动状态。
对于事件,我们有两种类型:用户生成的事件(仅具有信号组件)和滴答事件(系统生成的,具有信号和相关参数)。
为了更有效地处理事件,我们可以创建一个通用事件结构 event_t
,它包含信号值。这个结构可以嵌入到特定于应用程序的事件结构中,从而简化管理和代码的清晰性。
这种方法可以被视为结构嵌入,或者用OOP术语来说是继承,其中一个结构从另一个结构派生属性。这种方法的优势在于,您可以发送成员元素的指针,并向下转型以获取父结构的地址,从而简化状态机中的事件处理。
因此,请将所有这些结构定义保存在 main.h
中,下一节课再见!
protimer_state_mach.cpp
中,我们将实现状态机。首先,让我们从我们的初始转换开始。我将创建一个函数,命名为 protimer_init
,这是状态机初始化的函数。
该函数的参数将指向主应用程序结构 protimer_t
,我将给指针变量命名为 mobj
。这个变量将在整个状态机图中使用。
现在,mobj
的当前活动状态设置为 IDLE
。我们有一个关联的动作,那就是将 p_time
初始化为 0。就这样。
你需要在 main.cpp
中调用这个函数。首先,在 main.cpp
中创建一个主应用程序对象作为全局变量 protimer
。这个主应用程序对象可以用 static
关键字保护,然后将其地址传递给 protimer_init
。
现在,你需要在 main.h
中共享这个函数的原型。为此,复制这个函数的声明,并粘贴到 main.h
中。
看起来不错!现在,让我们尝试编译一下。
好的,编译成功。
有几种不同的方法。第一种是非常简单直接的“嵌套开关方法”。我们在之前的练习中也探讨过这种方法。
第二种是状态表方法。还有一种非常高效的优秀方法,这种方法在 Miro Samek 撰写的《Practical UML Statecharts in C/C++》一书中提到。这种状态处理器方法实际上基于函数指针。每个状态都有自己的处理器,而这个处理器本身也被视为一个状态。
状态处理器方法的核心在于函数指针的使用。我们将学习函数指针在状态处理器方法中的用法,稍后会深入探讨。因此,首先我们将探索嵌套开关方法,然后再用相同的应用程序探索状态处理器方法,最后将探讨状态表方法。
现在,让我们继续编写代码。接下来,我们需要编写一个函数来实现状态机。
首先,打开文件 protimer_state_mach.cpp
,并创建一个函数。这个函数我们称为 state_machine
。
这个 state_machine
用于应用程序对象,接收 protimer_t
,即主应用程序对象 mobj
,并接收事件。事件的通用结构是指向 event_t
的指针。
这里我们将使用嵌套的 switch 语句,使用 switch case 来切换不同的状态。
switch (mobj->active_state) {
// ...
}
我们将实现不同的 case。例如,如果当前的 active_state
是 IDLE
,则我们在这里调用一个函数。
case IDLE:
state_handler_protimer_state_handler_INIT(mobj, e);
break;
需要为每个状态实现不同的 case,并在这个函数内部实现另一个 switch case,切换不同的事件以采取不同的动作。
我们将使用返回值来返回事件处理的状态,状态可能是事件是否被处理、被忽略,或事件是否导致了状态转移。
因此,我们将返回类型设置为 event_status
,每个事件处理程序也应返回 event_status
。
以下是为不同状态实现的示例:
case TIME_SET:
state_handler_protimer_state_handler_TIME_SET(mobj, e);
break;
现在,让我们实现这些单独的状态处理程序。
这些处理程序接收指向主应用程序对象的指针和指向事件的指针,并返回 event_status
。
event_status idle_state_handler(protimer_t *mobj, event_t *e) {
// 实现 IDLE 状态的处理逻辑
}
IDLE 状态处理不同的信号,例如时间递增、TIME_TICK 和 START_PAUSE。它还处理内部活动的入口和出口,我们将它们视为信号。
switch(e->sig) {
case ENTRY:
// 处理入口信号
break;
case EXIT:
// 处理退出信号
break;
// 其他信号
}
在实现 ENTRY
和 EXIT
的时候,记得返回 EVENT_HANDLED
,如果处理信号导致状态转移,则返回 EVENT_TRANSITION
。
event_status
接下来,在 main.h
中定义 event_status_t
:
typedef enum {
EVENT_HANDLED,
EVENT_IGNORED,
EVENT_TRANSITION
} event_status_t;
例如,入口操作需要将 c_time
和 e_time
设置为 0,并调用 display_time(0)
和 display_message("set time")
。实现后,记得返回 EVENT_HANDLED
。
mobj->curr_time = 0;
mobj->elapsed_time = 0;
display_time(0);
display_message("set time");
return EVENT_HANDLED;
对于 EXIT
信号,简单地调用 display_clear()
并返回 EVENT_HANDLED
。
对于 INC_TIME
信号,增加 curr_time
60 秒并返回 EVENT_HANDLED
。如果信号导致状态转移,返回 EVENT_TRANSITION
。
在处理 TIME_TICK
信号时,需要判断 ss
字段的值。如果等于 5,调用 do_beep()
,然后返回 EVENT_HANDLED
;否则,返回 EVENT_IGNORED
。
在 switch 的结尾,如果状态接收到任何其他事件,则返回 EVENT_IGNORED
。这样就完成了状态机的事件处理逻辑。
在之前的课程中,我要求你们实现这些处理程序,我相信你们已经完成了。
请注意,在图中,每当你看到一个 Guard 条件,这意味着你在代码中实现时必须使用 “if” 语句。因此,Guard 条件被转换为任何编程语言中的决策语句,比如 “if” 语句。
我们已经得到了所有这些处理程序。让我解释一下这个的实现。在这里,这实际上是一个内部转换,有时在评估这个 Guard 时可能是外部转换。
这里有一个选择节点。让我们看看这在代码中是如何实现的。
你在这里看到,case TICK,这是一个 Guard 条件,它被转换为一个 “if” 语句。如果 (ss == 10)。
在这里你收到一个通用指针,即 “e”,必须将其向下转换为这个类型。
所以,我会把这个分开。
你可以看到,这个指针被向下转换为这个类型。在那之后再加一个括号,然后你可以访问这个结构的成员元素。就是这样。
如果 (ss == 10),那么你必须采取行动。
所以,行动在这里进行。
当前时间减去,经过的时间增加。然后显示时间,display_time。
在这之后,这是评估 c_time,另一个 “if” 条件。
如果它真的为 0,那么这个将为真;如果它为真,那么这实际上是外部转换到 IDLE 状态。
如果这不是真的,那么它将保持为内部转换。
所以,我在这里返回 EVENT_HANDLED。如果这不是真的,那么我们只返回 EVENT_IGNORED。
因此,你必须实现其余的代码。在 STAT 中,我还没有实现这一点。在 STAT 中,进入动作、退出动作,然后 TIME_TICK 是外部转换。
所以,这就是一个 Guard。
如何实现这个?
如果你获取 “e”,进行向下转换。因为,我必须访问 “ss” 成员元素,protimer_tick_event*,
然后把它放在一个括号中。
访问 ss,如果等于 10,那么你必须进行一个转换;
active_state = IDLE。
并返回 EVENT_TRANSITION。
否则,你必须返回 EVENT_IGNORED。就是这样。
我们已经完成了所有状态处理程序的实现,现在让我们实现事件调度器。
事件调度器将事件调度到状态机实现。事件调度器是我们将在 main.cpp 中编写的小函数。
同时,我们还必须实现其他待办的辅助函数。
我们使用了许多辅助函数。我们还必须实现其他特定于外设的函数,比如 LCD 函数等。我们稍后再实现这些。
现在,让我们实现事件调度器。
让我们回到代码并尝试编译。我们看看它是否能构建成功。
构建成功,没有问题。
现在让我们去 main.cpp。在这里,让我们实现事件调度器。
我将其命名为 protimer_event_dispatcher。
它还接收一个指向应用程序对象 protimer_t 的指针,main obj 和 event_t *e。
这是一个事件调度器。所以,让我们把这个定义为 const。
这也是 const,这也是 const。
让我们在顶部给这个函数一个原型。
我们也可以把这个定义为 “static”,没问题。
它接收事件并将其调度到状态机实现。
我们要做的是直接调用这个函数 protimer_state_machine。
你现在必须复制这个函数的声明并转到 main.h 并在这里给出原型。
因为这是从 main.cpp 调用的。
所以,让我们在这里使用这个函数并仅仅调度事件,mobj 和事件。并捕获它返回的状态。我会称之为状态等于。让我们创建状态变量,event_status status。
如果 (status == EVENT_TRANSITION),这表示发生了转换。如果发生了转换,
首先我们必须运行退出动作,然后运行进入动作;
目标状态的进入动作。
或者我会称其为运行目标状态的进入动作,运行源状态的退出动作。
运行目标的进入动作。 我们做一件事;我们在这里创建一个变量 event_t e。首先,我们将编写这个。运行目标状态的进入动作。 e.sig = ENTRY,
然后你必须再次调用这个函数。
mobj,我们将其称为 ‘ee’(进入事件或退出事件)。‘ee’,
并发送它的地址。因此在这里,
ee.sig = EXIT。
当转换发生时,这是接收到的事件,
这是接收到的事件。
你将那个事件发送到状态机,
但在此之前,你必须记录源状态。
这就是为什么我们将创建一个虚拟变量 protimer_state source。
在这个变量中,我们将记录源状态或当前状态,mobj 的 active_state。
所以,这只是为了记住源状态。这个将在只有当状态 = EVENT_TRANSITION 时才会执行,如果发生了转换。如果发生了转换,那么你就必须调用退出和进入动作。
如果真的发生了转换,那么结构内部的 active_state 一定会被更新,对吧?
这就是为什么,我们调用那个进入动作。
但我们也应该调用那个旧状态或源状态的退出动作,我们在这里保存。
这就是你现在需要做的,你必须做
active_state = source,
然后你必须在这里再次调用它作为退出动作。
我希望这有意义。
所以,让我再解释一次。
首先,在这里接收到一个新事件,并将新事件发送到状态机。
但在此之前,我们暂时保存当前活动状态。
新事件被发送到状态机,如果没有转换,那么没有什么可处理的。
就这样。那个事件被处理或被忽略,我们不在乎。但是,
如果这个状态是 EVENT_TRANSITION,这意味着发生了转换,并且这个对象的 active_state 已经更新。
根据规范,我们必须首先调用退出动作,然后是转换动作,
但在这里我们稍微修改了这一点。首先,转换动作已经在这个 state_machine 函数内部执行。
在那之后,我们为我们保存的源状态运行退出动作。
而对于进入动作,
在这里你必须更新状态变量 = 目标。
因此,我们尚未创建目标变量。
所以,让我在这里创建一个目标变量并在此初始化;
目标 = mobj->active_state
或者我只是将在这里初始化该变量。
如果确实发生了转换,那么新状态或目标状态将保存在目标变量中
并且用于执行进入动作。
但源状态,你在处理事件或在发送事件之前保存的那个,将用于运行退出动作。
现在,我们已经完成了 protimer_event_dispatcher 代码,这个事件调度器从循环函数中调用。在循环函数中,我们将进行这三个重要任务。
第一个是,我们必须读取按钮垫状态。
因此,我们有 3 个按钮,
我们将其称为按钮垫,
我们必须读取其状态。然后生成一个事件。
将按钮垫状态转换为事件结构的实例,即生成事件。然后
将其发送到事件调度器。
现在为了实现第一个任务读取按钮垫状态,我们必须了解按钮如何
连接到 Arduino,并且我们还必须讨论软件按钮消抖。在下节课中,
我们将探讨硬件连接。比如按钮垫的连接,我还将讨论 LCD 与 Arduino 板的连接。
现在让我们来讨论硬件连接。
有3个按钮:按钮1、按钮2和按钮3。第一个按钮用于增加时间,第二个用于减少时间,第三个是START/PAUSE按钮。这些按钮连接到Arduino板上的特定引脚。此外,还有一个蜂鸣器,可以是有源或无源,连接到引脚12。项目还使用了16x2 LCD,这里是LCD引脚和Arduino引脚之间的连接。
现在,我们来进行连接。我会向你展示如何设置。首先,打开Tinkercad,拿一个面包板和一个Uno板。我们先把这个旋转过来,然后使用几个按钮。
我会用3个按键。硬件连接需要完全按照这个方式进行,这样可以帮助我们排查任何问题。
我会把按钮以下拉配置的方式接口到Arduino板上。连接方式是,我会使用两个引线连接到板上。一个引线连接到Vcc。
让我们把这些按钮的引线连接到Vcc。你需要使用一个10千欧的下拉电阻。
这是一个10千欧的下拉电阻。之后,把3个按钮连接到Arduino板,按照这个表格。按钮1应该连接到Arduino引脚2。从按钮1连接到引脚2。
按钮2连接到引脚3,按钮3连接到引脚4。我会用红色的导线来表示这些Vcc连接。
接下来,我们需要LCD。把LCD放在这里。LCD_RS应该连接到引脚5。我会用绿色的导线连接。
LCD的使能引脚应该连接到引脚7。之后,我们将使用4位模式进行LCD数据通信。在4位模式下,D0、D1、D2和D3数据线不能使用,因此不连接。需要将D4连接到引脚8,D5连接到引脚9,D6连接到引脚10,D7连接到引脚11。
这是LCD背光LED的阳极,阳极应该通过一个220欧的电阻连接到Vcc。LED的阴极,负极,应该连接到地。
接下来,你还需要一个电位器用于LCD的对比度设置,最好是100千欧。把它放在合适的位置。
我们还需要一个压电蜂鸣器。这个是正极,那个是负极。将蜂鸣器的正极连接到引脚12,负极连接到地。
最后,把Arduino板的地连接到面包板的地,并将5V引脚连接到面包板的5V轨道。记住,电源轨道的部分在真实的面包板上通常是没有连接的,所以最好短接这些部分。
连接就完成了!接下来,你可以将你写的代码粘贴到文本部分进行仿真。
在下节课中,我们将讨论读取按钮状态。下次见!
欢迎回来,今天我们来实现循环功能,接下来是第一个任务:读取按钮面板的状态。
为了实现这个功能,我会创建几个变量。
首先,定义三个变量:uint8_t b1, b2, b3
,用于保存每个按钮的状态。
如何读取按钮状态呢?
我们知道,b1
要通过 digitalRead
来读取,因为按钮连接在 Arduino 的数字引脚上。
这里没有模拟输入,所以只是 digitalRead
。我们会为引脚定义几个宏。
在顶部定义宏:
#define PIN_BUTTON1 2
#define PIN_BUTTON2 4
#define PIN_BUZZER 12
对于 LCD 连接:
#define PIN_LCD
首先给 RS(寄存器选择)定义宏。
按钮面板的真值表如下:
如果按钮1状态为0,按钮2状态为0,而按钮3被按下,由于我们在按钮接口中使用了下拉配置。
我们使用的是下拉电阻,未按下按钮时引脚接地。
当按钮未按下时,状态为接地。
当按钮被按下时,按钮会连接到 Vcc。
这意味着当按钮被按下,引脚状态为高。
如果按钮3为高,表示“开始/暂停”按钮被按下。
如果按钮面板状态为1,表示“开始/暂停”。
如果按钮面板状态为2,表示“减少时间”。
如果按钮面板状态为4(二进制4),表示“增加时间”。
如果状态为6,则表示“中止”。
其他值无关紧要。
我们在程序中定义这些值。
我将这称为按钮面板值或按钮面板增加时间为4。
按钮面板值减少时间为2。
让我们在程序中使用这些值。
b1 = digitalRead(PIN_BUTTON1);
b2 = digitalRead(PIN_BUTTON2);
b3 = digitalRead(PIN_BUTTON3);
接下来创建一个变量:
btn_pad_value = (b1 << 2) | (b2 << 1) | b3;
我们需要合并所有这些状态以形成一个值,因此可以使用位或运算。
这样我们得到了按钮面板值,但需要处理按钮抖动。
如何处理按钮抖动?我们稍后会在软件中看到。
现在我们先给这个处理函数命名为 process_button_pad_value
。
传入按钮面板值,接收一个处理后的值。
这是为了处理按钮的抖动。
得到的去抖动值是 btn_pad_value
,然后你需要比较这个值。
如果 btn_pad_value
不为零,表示按钮面板上有活动。
如果此函数返回0,表示没有活动,没按下任何按钮。
如果没有按钮被按下,值为0。
因此,我们只在 btn_pad_value
非零时处理它。
如果 btn_pad_value
等于 BTN_PAD_VALUE_ABRT
或者从增加时间(INC_TIME
)开始。
这表示我们需要发送增加时间事件,因此我们创建一个事件变量。
user_event super.sig = INC_TIME;
仅此而已。
否则,如果 btn_pad_value
等于 BTN_PAD_VALUE_DEC_TIME
,则需要填充信号变量为减少时间的枚举值。
这样你就创建了事件。
接下来是第二个任务:创建一个事件。
接下来,如果是“开始/暂停”或者“中止”,则发送事件到调度器。
我们需要发送主应用程序对象 protimer
和用户事件的地址。
因此,参数应为时间,不应直接发送,应发送此事件类型的指针。
所以要发送 &ue.super
的地址。
让我们现在实现这个函数。
我们将在下一节课中实现。
定义函数 uint8_t btn_pad_value
,暂时返回0。
这个函数也可以设置为静态。
将其发送到事件调度器,这是第三个任务。
现在我们已经完成了发送用户事件。
接下来,我们需要实现发送 TICK 事件的代码。
我们还没有实现发送 TICK 事件的代码,稍后再看。
这就是发送用户事件的全部内容。
在之前的讲座中,我们实现了分发用户事件的代码,现在我们需要实现这个功能。
让我们编写一个小代码来分发时间滴答事件。为此,我们将使用 Arduino 框架提供的 millis()
函数。
millis()
返回自 Arduino 板开始运行当前程序以来经过的毫秒数。
基本上,它返回经过的毫秒数。这个数字大约在 50 天后会溢出,因为它的数据类型是无符号长整型,宽度为 4 字节。
这里是一个示例代码:
在这段代码中,他们使用了 millis()
,并将当前时间存储到一个变量中。当前时间不是时钟时间,而是自 Arduino 板开始运行程序以来经过的毫秒数。
也就是说,这个值是在执行这条语句之前经过的毫秒数。
现在,让我们回到我们的项目。在这里,我们将实现“每 100 毫秒分发一次时间滴答事件”,因为这是我们决定的状态机接收时间滴答事件的频率。
我将创建一个局部静态变量 current_time
,用于存储当前时间。其数据类型为 uint32_t
,最初我将使用 millis()
函数来存储当前时间。
因为这是一个静态变量,所以第一次控制到达这个循环时,该变量将仅初始化一次。
接下来,我将编写一个小逻辑:
if(millis()) {
// 获取实际当前时间
}
这里,实际运行的 current_time
减去保存的 current_time
。
如果这个差值等于或大于 100 毫秒,那么就意味着已经经过了 100 毫秒。
在这种情况下,我们应该首先重置 current_time
,以便为下一个事件生成准备好新的值。
接着创建一个变量 protimer_tick_event
,我将其称为 te
。
您甚至可以将 ss
字段设为 0,因为我们需要使用子秒字段。
接下来,我们需要创建一个事件:te.super.sig = TIME_TICK;
。
每当经过 100 毫秒时,我们要递增 te.ss
,其值应从 1 到 10 变化。
如果 te.ss
大于 10,则需要将其重置为 1。
// 确保 te 是一个静态变量,以防止每次循环初始化为 0
这看起来不错。当其变为 11 时,会重置为 1。接下来,我们需要调用 event_dispatcher(te.super)
,这就是将时间滴答事件分发到状态机的过程。
你也可以使用定时器中断来生成每 100 毫秒的时间滴答事件。
现在,让我们尝试编译这个代码。
没有错误,但有几个警告。编译器不识别这种初始化类型,提示未实现。没关系,我们将去掉它。
在下一次讲座中,我们将了解按钮抖动,并尝试实现这个功能。下次见!
在本次讲座中,我们将讨论按钮抖动,并稍后讨论如何使用软件进行消抖。
我们将按钮与Arduino板进行接口。我们实际上使用下拉电阻来连接按钮,也可以使用上拉电阻。
这就是我们接口的样子。这里有一个开关,旁边是连接到地面的10k下拉电阻。
这是按钮的实际图片。它的内部结构大致如下:
当开关未按下时,开关处于打开状态,可以看到,该引脚通过下拉电阻连接到地。
这意味着Vin(输入电压)为0伏特。如果你在Vin点测量电压,万用表显示为0伏特。这是+5伏特。
假设你关闭这个开关,Vin点就连接到5伏特。如果现在测量这个点的电压,它会显示5伏特。
电路看起来大致如下:有一个下拉电阻连接到地,5伏特的电池,Vin引脚连接到这个点。
当微控制器的数字引脚处于输入状态时,它的输入阻抗非常高,通常以兆欧为单位。这就是为什么当你关闭开关时,没有电流突然增加。
电流不会流入微控制器的引脚,因为它的输入阻抗非常高。所以实际上不会有电流流动,除了少量泄漏电流。因此,你不需要担心大量电流流入引脚。
但是如果你的代码错误地将数字引脚配置为输出模式,那么输入阻抗突然下降,大量电流可能会流入引脚,可能会损坏引脚。
因此,最好在这里引入一个电流限制电阻,可能大约220欧姆。
这就是我们的接口。当按钮未按下时,引脚状态为0伏特;当按钮按下时,引脚状态变为5伏特。
但实际上,转换过程并不是这么平滑的。在引脚上,从0伏特到5伏特或5伏特到0伏特的过渡看起来是这样的。
这称为按钮抖动。在0伏特和5伏特之间会反复抖动几次,然后才稳定在5伏特或0伏特。为什么会发生这种抖动?
这发生在机械接触之间。开关实际上就是机械接触。
这是一个机械接触或金属接触。当你将一个金属接触按在另一个金属接触上时,一个金属接触会弹跳到另一个金属接触上。
它们之间会有轻微的分离。当你将这个金属接触与另一个金属接触相接触时,它们会相互弹跳。也就是说,它们之间会有短暂的分离和接合,这导致电压在0伏特和5伏特之间抖动。
这种分离的时间非常短,可能在微秒级或在某些开关中,可能在毫秒级。不同的开关有不同的抖动时间。
对于这个应用,我们粗略地将其视为50毫秒。严格来说,你需要在示波器上检查抖动周期或抖动时间。你需要检查你所用开关的抖动周期,才能在你的应用中使用,但我们粗略地将其视为50毫秒。
这就是引脚上的活动情况。你可以将引脚的不同状态命名为:未按下状态、抖动状态、按下状态等。
这实际上是按钮被按下的状态;而这就是按钮释放的状态。
现在,我们通过软件解决这个问题。为此,我们将使用软件消抖技术。
也有硬件消抖技术来获得干净的过渡,需要在开关和数字引脚之间连接消抖电路。
但在这个练习中,我们将使用软件消抖,如何做到这一点我将在下次讲座中展示。
嘿,欢迎回来参加讲座。
在本讲座中,我们将实现软件按钮消抖。在上一次讲座中,你看到当按键被按下和释放时,针脚的活动情况。
你可以将这些情况视为不同的状态,因此针脚的不同状态可以通过状态机图表示。
我为按钮消抖写了一个简单的状态机图。你可以在这里创建任意数量的模型,只需右键单击,选择“创建图表”,然后选择状态机图。
我们将不按照标准UML规范实现这个状态机,而是作为一个普通的函数来实现。不过我想让你知道,针脚的消抖也可以用状态机图表示。
在这里,我有三个状态:未按下、已按下、抖动。假设这个过程被实现为一个函数,函数接收按钮垫的值。
首先,针脚将处于未按下状态。当按钮垫的值非零时,这意味着有人按下了按钮,因此将转换到抖动状态。此时的动作是设置当前时间,我们稍后会讨论这个“设置当前时间”的具体内容。
关键点是,当针脚处于未按下状态,如果按钮垫的值变为非零,则状态机会转到抖动状态。在抖动状态中,它必须等待50毫秒,这是抖动的时间段。
50毫秒的开始时间由“设置当前时间”标记。我们可以使用 millis() 函数来实现这一点,稍后我会展示给你看。
在抖动状态期间,50毫秒后,它必须重新验证按钮垫的值。如果值为0,表示没有人按下按钮,因此返回未按下状态。
但如果按钮垫的值在50毫秒后仍为非零,则转到已按下状态,这表明有人确实按下了按钮。
在已按下状态期间,它将保持该状态,直到按钮垫的值返回为0。当有人释放按钮时,按钮垫的值变为0,这表明有人释放了按钮。因此,转换回抖动状态,因为也需要消抖释放状态。
这时时间的开始也由设置当前时间函数标记。就是这样。
现在我们将这个过程实现为一个小函数。让我们回到代码中。
我们已经定义了一个名为 process_button_pad_value 的函数。在这个函数内,我们将实现上述逻辑。
首先,我将定义一个静态变量,称为 button_statemachine_state(btn_sm_state)。初始值为未按下。
接下来,我们将使用 switch 语句来处理不同的状态。在未按下状态时,如果 btn_pad_value 非零,则会有状态转换。
我们将使用一个变量来存储当前时间,初始化为 millis()。这是设置当前时间,这将在抖动状态中用于50毫秒的超时。
在抖动状态中,不应进行任何操作,直到50毫秒过去。现在,重新检查按钮垫的值。如果值为非零,则转到已按下状态;否则返回未按下状态。
在已按下状态中,如果 btn_pad_value 为0,则需要转到抖动状态。在转到抖动状态时,必须重新初始化当前时间,以便重新计算50毫秒。
这个函数在完成后会返回 btn_pad_value。如果在消抖后返回的值为0,则表示未处理;如果返回非零值,则表示按键有效,并将事件发送到调度器。
在接下来的讲座中,我们将实现 lcd.c 和 lcd.h 文件。这将是我们的最后一步实现,完成后我们可以进行测试。
首先,我们将编写 setup 函数。在 setup 函数中,我们需要做各种设置,包括初始化 LCD 和串口监视器(如果使用的话),以及设置数字引脚模式。
我会写一个函数 display_init 来初始化 LCD 显示器,稍后我们将实现它。
接下来,我们需要使用 Arduino 的 pinMode 函数来配置按钮的模式。有关 pinMode() 函数,你需要提供引脚和模式,模式可以是 INPUT、OUTPUT 或 INPUT_PULLUP(带上拉输入)。
使用 INPUT_PULLUP 选项意味着你不需要使用外部上拉电阻器。
我将回到代码中,对 PIN_BUTTON1 使用 pinMode,并设置为 INPUT 模式。你需要复制粘贴三次,然后调用这个函数。
编译代码时,发现了一个警告,说明变量 b2 被设置但未使用。现在让我们修改这个错误并再次编译。
编译完成。在下次讲座中,我们将探索 Arduino LCD 库,并尝试将其方法应用于我们的项目,以驱动 16X2 LCD。我们将在硬件上测试这个项目。
我下次见!
欢迎回来参加讲座。
在这次讲座中,让我们探讨Arduino的官方LCD库。为此,你需要访问文档,进入参考部分。在参考中,你可以找到库,点击它。在这里点击显示器,然后选择官方库。
点击LiquidCrystal。这是一个库,允许与字母数字液晶显示器(LCD)进行通信。点击阅读文档,了解更多关于这个库的信息。它适用于基于日立HD44780(或兼容)芯片组的液晶显示器,我们使用的是同样芯片组的16x2 LCD。
你可以在这里找到各种示例代码。在右侧,你可以看到LiquidCrystal库的不同方法或函数。在我们的项目中,由于我们使用的是PlatformIO扩展,我们首先需要安装这个库。我将向你展示如何安装这个库,非常简单。
转到主页。如果你不知道如何访问主页,可以在这里找到主页按钮,或者在PlatformIO中有一个主页按钮。快速访问,打开主页,然后进入库。在库中搜索这个名称,复制并粘贴到搜索框中。
这应该是由Arduino提供的,因为这是官方的库。点击它,然后选择“添加到项目”。像这样添加到项目中,选择你的项目。这个是我的项目,我将选择它并添加。
这是一种项目级别的库添加方式。你也可以选择工作区级的添加。我们将进行项目级的添加。
一旦添加成功,你可以在platformio.ini文件中看到它,作为库依赖项添加。
使用这个LCD库,你可以实现所有这些功能。如果你想在LCD上打印文本,可以使用print方法。如果你想了解如何使用这个print方法,只需打开文档,它会为你解释如何使用。
如果你想在LCD上打印“HelloWorld”,首先需要创建LCD对象。这是一个LCD对象;它是一个类。这个是LiquidCrystal类,我们创建的对象。这些是Arduino板的引脚编号,用于连接LCD到Arduino板,所以你需要仔细地指定这些引脚。文档解释了如何指定这些引脚的顺序。
你可以看到这里的LiquidCrystal()函数。它说你需要创建对象或向对象传递参数,顺序如下:首先是RS、enable、d4、d5、d6、d7。如果你使用RW,即读写引脚,那么读写引脚必须在第二位。这两个在我们的应用中没有使用。这是8位通信,我们将使用这个。
我将向你展示如何在编码时使用。如果你感到困惑,不用担心。
现在我们做一件事,去项目的lcd.h文件中添加所有这些包装函数。我们将支持所有这些功能来处理LCD。lcd_clear()函数用于清除LCD。如果你想在LCD上打印一个字符,可以使用这个函数。这个函数用于滚动LCD显示,向左滚动和向右滚动。
这是将光标设置在显示器的不同位置,如设置光标在给定的行号和列号。这是停止自动滚动的函数,这是LCD初始化。
我们将提供各种包装函数。如果你在想我如何确定这些函数的,我只是去查看方法。这些是不同的方法,实际上是这些方法的包装函数。
在下一节课中,我们将在lcd.cpp中实现所有这些函数,并从我们的主源文件中调用这些函数。下次见!
现在,让我们实现lcd.cpp。
你需要复制所有这些内容,然后粘贴,并逐一实现函数。
在lcd.cpp中,首先包含“lcd.h”。在顶部,你需要为Arduino的Liquid Crystal库创建LCD对象。你可以查看一个用法示例。首先,你需要创建LCD对象。让我们来做这个。
输入LiquidCrystal和对象名称,这里是类名,我们将对象命名为lcd()。然后,你需要传递引脚详细信息,Arduino引脚详细信息是我们连接LCD引脚的。
首先是RS,我们有宏定义LCD_RS。这在这里不可访问。让我们做一件事,也在这里包含“main.h”。LCD_RS是PIN_LCD_RS,接下来是Enable,PIN_LCD_EN。
我们还连接了LCD的读写引脚,即RW引脚。因此,我们也需要使用它,这个引脚的位置是第二,读写引脚。因此,第二个参数是PIN_LCD_RW,然后是PIN_LCD_EN,再然后是d4、d5、d6、d7。
我们刚刚创建了对象。这实际上调用了这个类的构造函数,并初始化了引脚。由于我们使用了这个类,所以需要添加它的头文件,头文件是LiquidCrystal.h。
现在你可以使用这个对象来访问它的各种方法。对于lcd_clear,你只需调用lcd.clear()。就这样。对于lcd_print_char,你可以使用lcd_print(),传递字符。对于lcd_scroll_left,你可以使用lcd.scrollDisplayLeft。
就这样,完成所有这些函数,下次课见!
要使用设置光标函数,首先要提及列和行。
在此之后,必须调用不自动滚动的方法 noAutoScroll()
。
对于 lcd begin
,这个方法初始化LCD屏幕的接口,并指定显示的宽度和高度。必须在任何其他LCD库命令之前调用 begin()
。
在使用任何方法之前,应首先调用此方法,并传入两个参数:LCD的列数和行数。稍作修改,我将使用 uint8_t cols, uint8_t rows
来表示列和行。
调用此功能时,光标将从右向左移动。当你在显示器上打印字符时,如果想要光标从左向右移动,可以配置此功能。
如果不想在显示上看到光标,可以调用 cursor off
,这会调用LCD对象的 noCursor()
方法。如果不想让光标闪烁,则可以使用 noBlink()
。
要打印任何数字,可以使用 lcd_print
,参数为整数,或称为 lcd_print_number
。无论要打印字符、字符串还是数字,只有一个方法 print()
。
让我们编译一下,看看是否能成功编译。要注意字符串未被识别,需要添加 Arduino.h
。
接下来,在 main.cpp
中完成 display_init
函数。在调用LCD的任何方法之前,首先需要初始化LCD。
有很多初始化命令需要在使用其他功能之前发送给LCD,比如发送命令或文本。
清除LCD非常简单,只需调用 lcd_display_clear
。但在这里它不可访问,因此我们将添加 lcd.h
并调用 lcd_clear
。
时间应以分钟和秒的形式显示。没有小时的信息,格式是 mmm:ss
。时间应显示在第0行,开始于第5列,结束于第10列。
当应用程序处于空闲状态时,应显示“设置时间”,计时器设置为100分钟0秒。当进入暂停状态时,消息“paused”应出现在特定坐标。
要将当前时间变量转换为分钟和秒很简单。假设当前时间为125秒:
curr_time % 60
,得5秒。curr_time / 60
,得2分钟。因此,应该显示为 002:05
。
为了显示消息,消息的坐标是依赖的,因此需要在 display_message
函数中接收坐标。修改该函数以接受列号和行号。
可以使用Arduino的 tone
函数来实现蜂鸣器。只需指定连接到蜂鸣器的引脚、频率和持续时间。
接下来的课上我会展示实现方法,但在此之前,请尝试实现这些函数。下次课见!
这里是实现的代码。
首先,让我给你展示 display_message
函数。非常简单。
只需先调用 lcd_set_cursor
,然后调用 lcd_print_string
。
对于蜂鸣器,你需要调用 tone
函数,并指定蜂鸣器的引脚。
这个持续时间是25毫秒,意思是蜂鸣声会持续25毫秒。
这是一个短促的蜂鸣声,生成的方波频率是4 kHz。
接下来,我们来看 display_time
函数。
在这里,你接收当前时间。将当前时间除以60以获取分钟,然后用60取模获取秒数。
然后,我们将这些信息转换为字符串。
为此,我们将使用 sprintf
函数,将字符串存储在字符缓冲区中。
这些是使用的格式说明符。
这将把这些数字转换为字符串格式,字符串将存储在缓冲区中。
我使用了%03d
来表示分钟,因为我需要宽度为3个字符的分钟数。
这个格式实际上生成宽度为3的整数。
未使用的字符将用0填充。
同样的道理也适用于秒数。然后将缓冲区转换为字符串格式。
这由Arduino框架提供,只需进行类型转换,然后设置光标。
在打印时间信息之前,首先设置光标。列为5,行数为0。
然后调用 lcd_print_string
。就这样。让我们编译一下。
编译时出现了一些问题,我需要在这里进行一些更改。
对于此消息,列为1,行数为1。
让我们再次编译。
编译没有问题,现在我认为我们已经完成了这个应用程序所需的几乎所有代码。
在下一节课中,让我们在真实硬件上进行测试。我也给你提供了这个项目的 .ino 文件(单个文件)。
使用这个文件,你可以在Tinkercad应用程序上立即进行测试。
让我们测试这个应用程序。在测试之前,先看看我们的 protimer_init
函数。
这里,这是一个初始的过渡。
它正在过渡到 IDLE 状态。但 IDLE 状态有其进入动作,进入活动就在这里。
因此,每当你在这里将状态设置为 IDLE 时,都必须调用其进入动作。
我们实际上忘记了这一步。
现在让我们来做。
我会创建一个临时变量 event entry action
,将 ee.sig
设置为 ENTRY
,然后需要从 protimer_state_machine
调用这个动作。
因为你需要执行这个进入动作。
你必须将进入事件发送到 IDLE 状态,传入 mobj
以及这个变量的地址。
这个函数只使用一次。
现在让我们进行测试。
好的,现在让我们在实际硬件上测试这个应用程序。
我在这里有设置。
如您所见,我有 LCD,这个是蜂鸣器,电位器用于设置对比度,按钮面板,以及这些是下拉电阻。
这些连接完全按照我在 Tinkercad 视频中解释的方式进行。
没有任何变化,你必须完全这样连接。
现在,让我们启动我们的应用程序。
这是我们的应用程序。编译正常。让我们再编译一次。
编译是好的,现在让我们上传。
它正在上传到板子上。
这里你可以看到,我们实际上看到了输出。你可以使用电位器来调整对比度。
现在它处于 IDLE 状态,行为正如我们预期的那样。它显示时间,设置时间,还有一个蜂鸣器每 500 毫秒鸣响一次。
现在让我们测试功能。首先让我检查增量时间。
你可以看到,每当我按这个按钮时,分钟就会增加。这是减时间按钮,你可以观察到按钮按下的消抖。
可以看到。
这是减时间。是的,它的行为如预期。
它可以一直减到 0。
现在让我开始倒计时。
它不会开始,因为它是 0。
现在,让我们开始倒计时。
你可以看到,它正在倒计时。
你可以暂停它。我刚刚暂停了。
当它暂停时,你可以减少或增加时间。然后再开始。
在此之后,你可以中止这个倒计时。
你必须同时按下这两个按钮,让我们一起按下。现在它被中止了。
现在它处于 IDLE 状态。
现在让我们再次开始倒计时。
现在让我们暂停,减时间,开始,好吗?中止。
现在让我们检查 STAT 状态。
当它在 IDLE 模式下时,你必须按下开始/暂停按钮才能进入 STAT 状态。
如你所见,当我按下这个时,它进入 STAT 状态,显示 STAT,然后自动返回到 IDLE 状态。
在 STAT 函数中,将 ‘ss’ 值与 10 进行比较似乎不太好。
因为,当应用程序进入 STAT 处理程序时,第一次收到 TIME_TICK 时,‘ss’ 值可能是任何值。
它不需要是 0。
在这种情况下,它会立即返回到 IDLE 状态。
但我们的要求是,STAT 必须至少显示 1 秒。
所以这并不好。
我打算去掉这个逻辑。
取而代之的是我会添加一个变量 tick_count
,
如果这是 10。如果收到 TIME_TICK 事件 10 次,我将使 tick_count = 0
,我会重置它。
这是一个静态函数,实际上是一个静态扩展变量。
让我们在这里定义它。
Static uint8_t tick_count;
你可以像这样修改。
你可以在处理 TIME_TICK 的地方使用相同的逻辑。
现在我们进行这个更改后,让我们再次测试这个代码。
让我们这样做。将其设置为 20 或 30,而不是 10。
让它在 STAT 状态下停留 3 秒。
让我们编译。
让我们下载这个代码。
让我开始倒计时。
让我中止这个。
让我按下开始按钮,你可以看到它停留了 3 秒。很好。
尝试复现这个应用程序,如果你发现任何错误,你应该能够修复它,也可以尝试为这个应用程序添加更多功能。
你可以引入新的事件或新的状态,以引入不同的功能。
我希望看到的一个变化是,你看到如果我想将时间设置为 30。例如,我必须按按钮 30 次。所以这不够人性化。
你可以修改这个逻辑。假设,如果我按住这个按钮,那么每秒至少应该增加 4。
你可以实现这样的逻辑。
同样的逻辑也适用于减时间按钮。
好的,基于这个点,我想结束这节课。在下一节课中,我们将探讨函数指针,并使用状态处理程序方法重新做这个练习。
下次见!
大家好,欢迎回到讲座。
在上一节课中,我们探讨了嵌套switch方法来实现状态机。在这一节课中,让我们理解状态处理器方法。之后,我们可以探索状态表方法。状态表和状态处理器方法都利用了函数指针的概念。
首先,让我们探讨C语言中的函数指针。函数指针允许我们将函数的地址存储在一个变量中。
例如,考虑一个变量定义:
int p;
在这里,p
是一个存储int
类型数据的变量。现在,如果我写int *q
,这也是一个变量定义,其中q
是一个指针变量,指向int
类型数据的地址。
现在,假设我有一个叫做bar
的函数,它是一个void
类型的函数,不接受参数且不返回任何值。当这个函数加载到微控制器时,它会存储在内存中,具体来说是在Flash内存中,这意味着该函数有自己的地址。
你可以通过使用函数的名称在程序中表示函数的地址。例如:
q = &p;
这将p
的地址赋值给q
。类似地,你可以在函数名之前使用&
来获取其地址:
q = &bar; // 这会因为指针类型不兼容而发出警告
警告的发生是因为q
设计用于存储数据类型的地址,而&bar
指向的是代码。因此,我们需要一个特殊的指针变量来存储函数的地址。
要创建一个函数指针,可以这样定义:
void (*f)(); // f是一个指向没有参数并返回void的函数的函数指针。
这个声明指定f
可以存储一个不接受参数且不返回值的函数的地址。你可以将bar
的地址赋值给f
:
f = &bar; // 或简单地 f = bar;
要通过指针调用函数,可以对其解引用:
(*f)(); // 调用指向f的函数
你也可以更简洁地写成:
f(); // 调用指向f的函数
如果函数接受参数,例如:
void bar(int i);
你需要修改函数指针的声明:
void (*f)(int); // f现在指向一个接受int并返回void的函数。
请记住,在定义函数指针时,需要使用括号来指明这是一个指向函数的指针。同时,正确指定返回类型和参数类型,以避免指针类型不兼容的错误。
在C语言中,函数指针的概念非常强大,允许灵活的程序设计,尤其是在实现状态机和处理回调时。
接下来,我们将进行一个小练习。
假设你有一个命令变量。
根据这个命令值,你需要采取某些行动或执行一些代码。
假设,如果命令值为0,你需要执行a+b
。
如果命令值为1,那么执行a-b
。
如果命令值为2,那么执行a*b
。那么,你该如何实现这一点?
假设你有两个变量a
和b
。
假设a=10; b=15;
,而命令值假设为2。
你该怎么办?
你可以使用switch语句在这个变量上实现各种操作,
或者可以使用if-else-if结构。
如果我告诉你,你不应该使用任何比较语句,比如switch或if,
那么你该如何实现?根据命令的值,你必须采取这些行动,而不使用
if或switch语句。这里,
这个问题可以使用函数指针来解决。
让我来给你展示如何做。让我们将所有这些操作,
不同的操作或代码转换为单独的函数。
我这里提供3个函数。
add(int a, int b)
,它返回a+b
。同样,对于减法和乘法也是如此。
现在,你可以这样做,创建一个通用函数
operation
。这个函数的作用是,它接收一个函数指针作为参数。首先,让
我创建一个typedef版本的函数指针变量定义。
我将使用typedef int(* oper_t)
,参数列表类型为(int, int)
。
因此,这个函数operation
接受这种类型的函数指针作为参数。
oper_t
有一个变量,我将其命名为p
。
它还接受a
和b
(整数值)。之后,这个操作的作用是,它是一个通用函数,
它只是跳转到这个指针指向的函数,并使用参数a
和b
。或者你也可以
简单地这样写,也没有问题。
这两者是一样的。
请注意,p
是这种类型的函数指针变量,它是typedef到这个定义。
现在,在主函数中,让我们创建一个函数指针数组来存储这些函数地址。
所以,我将再次使用oper_t
,创建一个函数指针数组变量。假设,如果你想创建
一个字符指针,你该怎么做?
这是一个字符指针。
现在,如果你想将其转换为字符指针数组,你该怎么做?所以,字符指针数组。
p
是一个指针数组。
同样,这里,你只需使用这个
typedef名称,创建一个变量,称之为oper
并初始化为
函数指针add
、subtraction
、multiplication
。
你可以使用类似这样的语句。
这是一个函数指针数组,它初始化为这些函数地址。
现在你可以
做类似这样的事情。result = operation();
调用这个通用函数operation
,传入
任何由这个命令值指向的地址。我们可以在这里使用命令值和a
、b
。
这里发生了什么?
这个通用函数被调用,带有3个参数。
这些是整数值,而这个是一个函数指针。那个函数
指针在这里接收到p
。
这就是你如何将
函数指针从一个地址传递到另一个地址。
抱歉,这里是int a, int b
。
这里只需返回结果。
因此,函数指针在我们的状态机实现中可能会非常有用。因为在我们的状态
机实现中,我们有不同的处理器,状态处理器。状态处理器函数中充满了
switch case语句。
它表示一组独特的指令。
所有这些不同的案例可以被分离为不同的函数。
你可以把这个看作是一个事件。根据接收到的事件,你可以传递与之关联的事件处理
函数
到通用分派代码中。你可以把这个看作是一个分派代码,
它在这里接收事件处理器地址并执行事件处理函数。
你可以把这个看作是一个包含各种函数指针的表,
这些指针就是不同事件处理函数的地址。这种方法
我们实际上在状态机实现中使用,称为状态表方法。在状态表方法中,
有一个表,里面充满了函数指针,每个表中的地址就是事件处理函数的地址。
我们将在覆盖状态表方法时看到这一点。无论你使用状态表
方法还是状态处理器方法,函数指针的
知识都是必需的。
这就是为什么我会简单介绍C语言中函数指针的基础知识。在下一节课中,
让我们探索状态处理器方法。
大家好,欢迎回到讲座。在这一讲中,我们将理解状态处理器的方法。
这个方法与之前的类似,但重要的区别是,如果你还记得,我们在主应用程序结构中使用了 active state
变量来在不同状态之间切换。active state
变量实际上显示了当前的活动状态。在状态处理器的方法中,这个变量将是一个函数指针,通过这个变量,我们可以直接在项目中切换不同的状态处理器。
在状态处理器中,实际上使用 switch case
语句来在不同信号之间切换。这种方法的一个优点是,你可以去掉外层的 switch
语句。在我们的项目中,有两个 switch
语句。一个是在状态处理器函数内部的 switch
语句,另一个是外层的 switch
语句。
在状态处理器的方法中,你不需要这样做,因为它包含指向状态处理器函数本身的指针。因此,你可以直接解引用这个变量并调用当前的状态处理器函数。
接下来,我们将创建一个新项目,因为我们不想在已有项目中做修改。让我们在 PlatformIO 中创建一个新项目,命名为 004Protimer_SH
并选择相应的开发板。
项目创建后,打开项目文件夹,将之前项目的所有代码复制到新项目中。确保新项目的文件能够正常显示。
在编译之前,我们需要添加 LCD 库,因为在之前的项目中我们已经添加过,但这是特定于项目的。找到并添加适合的液晶库。
进入 main.h
文件,移除不必要的部分,因为现在我们不再使用这些值来存储和解码变量。我们将使用函数指针替代。
在 protimer_init
中,我们需要初始化 active_state
变量,直接将其初始化为 IDLE
处理器的地址。
我们需要修改调度代码,尽管在循环函数中的逻辑保持不变。首先,获取 active_state
的值并保存目标状态。之后,执行退出动作,调用当前的 active_state
处理器。
在尝试编译时,如果遇到错误,比如 event_status_t
未初始化,可能是因为函数原型定义的顺序问题。调整代码顺序后重新编译。
状态处理器的方法通过使用函数指针简化了状态管理,使得代码更易于维护和扩展。
在本讲中,我们将学习状态表方法。在之前的讲座中,我们讨论了状态处理器方法和嵌套开关方法。
在状态表方法中,我们将使用一个状态表,因此得名状态表方法。让我用一个例子来解释。
在前两种方法中,无论是状态处理器方法还是嵌套开关方法,我们都使用了许多比较。这些比较是通过使用开关语句来实现的。这里有多个开关语句和一些情况,进行了大量的比较。
在这种方法中将不会有比较。当事件发生时,相关的函数会被执行,我们可以通过函数指针来实现这一点。状态表实际上就是一个包含各种处理程序的表,或者可以称之为事件处理程序的表。
例如,假设在您的应用程序中,当应用程序处于倒计时状态时,如果发生 START_PAUSE 事件,则会调用这个处理程序。这个处理程序负责处理当状态为倒计时时的事件。因此,您需要创建一个表,对于每个事件,您必须根据状态编写自己的事件处理程序。因此,对于我们的应用程序,将会有一个包含五行的表,行表示状态的数量。我们有五个状态,所以五行;事件则表示列。在我们的例子中,表将有七列,因为我们有七个事件。
之后,您将把这个表转换成程序中的某种数据结构。您可以使用二维数组来保存这些信息。然后,您可以使用这个二维数组来执行不同的事件处理程序。稍后我会给您演示。
现在,我们将创建一个新项目,删除所有的开关语句,并将每个情况转换为不同的事件处理函数。让我们回到项目中。
现在,让我们实现状态表方法。创建一个新项目。我刚刚创建了一个新项目 005Protimer_ST。之后,进入项目文件夹,进入 003 项目,重新使用这个项目的文件。复制所有这些内容,然后去到 ST 目录下粘贴,替换目标中的文件。
让我们去 IDE。现在我们进入 protimer_state_machine.cpp。这是我们的嵌套开关实现,现在将不再有开关语句。
首先,您需要做的是删除这个函数,因为它不再需要。现在我们只有状态处理程序。您需要为每个情况创建一个函数,函数原型保持与之前相同。
让我创建一个函数来处理 IDLE 状态的 ENTRY 情况。我会写成 IDLE_Entry。这与之前的函数返回类型相同。然后,这里是两个输入参数。这是一个非静态函数,您不需要将其设置为静态,因为我们需要与 main.cpp 共享它,以便包含在状态表中。因此,这不能是一个静态函数。
接下来,您只需复制这个情况中的所有指令并粘贴到这里。就这样。再创建一个函数。下一个情况是 EXIT,所以创建一个 EXIT 函数,复制 EXIT 情况中的所有内容并粘贴到这里。
同样,您需要为所有状态和事件覆盖进行类似操作。之后,您去到 TIME_SET 处理程序并创建类似的函数。例如,您可能需要创建 TIME_SET_ENTRY,并将这些代码包含在其中。
我希望您能做到。之后,您只需删除这些状态处理程序。这些不再需要。因此,我也将删除这些函数原型。这些也是不需要的,因为我们将删除它们。
完成这些,我将在下节课见到您。
欢迎回来。在这节课中,我为不同的状态创建了不同的事件处理程序。您可以看到,我们有很多函数。现在,这些处理程序的地址应该存储在状态表中。
因此,您需要与 main.cpp 共享这些函数的名称,我们将在其中实现状态表。您需要为所有这些函数创建原型,并将其包含在 main.h
中。
现在我们进入 main.h
,在这里粘贴原型。
接下来,您需要创建这个表。在表中,您需要提到两个重要的细节:首先,您将调用哪个事件处理程序,其次,当事件发生时,下一状态是什么。
然后,我们需要将这个状态表转换为二维数组。
在下一节课中,我将介绍什么是二维数组,如何访问二维数组的不同元素,以及二维数组的内存组织是怎样的。如果您已经熟悉二维数组,则无需观看下一个视频。这只是为新接触 C 语言的初学者准备的。
在之前的课程中,我没有涵盖二维数组,因此我将在下一个视频中进行讲解。如果您对此已经了解,您可以直接跳过下一个视频。下节课见!
一维数组的复习
你已经了解了一维数组的概念。这里有一个一维数组的例子,包含3个元素,类型为uint8
,每个元素占用1个字节。
array
,初始化时包含3个元素。我们称之为一维数组,因为只能在一个维度上索引这个数组,即x维度。索引一维数组
例如,索引0表示第一个元素,索引1表示第二个元素,索引2表示第三个元素。通过索引可以获取相应的值,例如:
array[0]
的值为10array[1]
的值为20二维数组的定义
二维数组是C语言中的一种数据结构,适用于表示表格形式的数据。表格由行和列组成。
int scores[2][3]; // 2行3列的二维数组
初始化二维数组
初始化时,第一行的值放在大括号内,用逗号分隔。例如:
int scores[2][3] = {{10, 20, 30}, {22, 45, 33}};
索引二维数组
访问二维数组中的元素,可以通过两个索引值来定位。例如:
scores[0][1]
访问的是第0行第1列的值,即20。scores[1][2]
访问的是第1行第2列的值,即45。使用二维数组的好处
考虑一下成绩单的情况,假设有4个学生和4门课程的成绩。可以使用二维数组存储这些信息:
int marks[4][4]; // 4个学生,4门课程
在这种情况下,使用二维数组比使用一维数组更直观,避免了通过复杂的索引计算来获取特定元素。
内存存储方式
二维数组在内存中的存储方式与一维数组相似,都是连续分配内存。对于一个二维数组,数据按行存储,第一行的元素先存储,接着是第二行,依此类推。
这样,使用二维数组使得数据的组织和访问更为高效和直观。
进入 main.cpp
。在设置函数中,让我们调用一个名为 protimer_state_table_init
的函数。
让我们在事件分发器下实现这个函数。它接收指向主对象的指针。
现在,我们需要创建一个二维数组。我将其命名为 protimer_state_table[MAX_STATES][MAX_EVENTS or SIGNALS]
。
这里有状态。只需在此处添加一个条目,MAX_STATES
。您还可以添加一个条目,MAX_SIGNALS
。返回程序,这实际上是一个类型为函数指针的二维数组。
去 main.h
,创建一个 typedef 函数指针定义。我将在这里做,定义为 typedef event_status (*e_handler_t)
。这实际上接受这些类型的参数。
这是事件处理程序的函数指针类型。我们将使用此类型来创建数组:一个该类型的函数指针数组。之后,让我们初始化这个表。
您必须准确地像这样初始化它。第一行是 IDLE 状态,第一个事件是这个,第二个事件是这个,依此类推。在进行二维数组初始化时,您还可以这样做。
有两行,两列,您也可以这样做。首先,您写下行号。这表示您正在初始化第 0 行 = 然后使用大括号来初始化该行。您也可以这样做。这也是有效的初始化。
这表示您正在初始化第 0 行。这表示您正在初始化第 1 行。所以,我会使用类似的方法,这样会更清晰。
首先,让我为 IDLE 状态初始化第 0 行 = 这是第一行初始化。接下来为 TIME_SET,[TIME_SET] = {}
,接下来为 COUNTDOWN,接下来为 PAUSE,接下来为 STAT。就是这样。
之后,每行应有 7 个条目。有些是 NULL,因为这表示该事件在该状态下没有处理。
在 IDLE 状态下递增时间的事件处理程序。您必须提到它的地址。IDLE_Inc_time
,下一个是 NULL。接下来是 tick time,IDLE_Time_tick
的地址。之后是 IDLE_Start_pause
,之后是 NULL,接着是 IDLE_Entry
和 IDLE_Exit
的地址。
这里应该有 7 个条目。类似地,您为 TIME_SET、COUNTDOWN、PAUSE 和 STAT 做这个初始化。一旦您完成这个初始化,接下来您要做的是,将这个状态表指针存储在我们的主应用对象中。因为我们在分发函数中需要它。
以获取适当的处理地址。这就是为什么,让我们去主应用结构,在这里创建一个指向状态表的指针。指针类型应该是什么?如果您对此有疑问,那就没问题。
然后我们创建一个变量 state_table
,它是一个指针变量,仅用于保存该数组的地址。让我们回去。在这个函数中,让我们将其设为静态,因为它不能是局部变量。
因为这个变量的地址必须是持久的。在主对象中,获取指针 state_table
并在这里存储。这个表的地址。
或者说数组的基地址。基地址就是第一个元素的地址,即 [0][0]
。您只需使用 &
。指针的类型是这个。因此,您可能需要将其强制转换为 uintptr_t
。
我已经附上了状态表文件,您可以下载,并根据该文件进行初始化。似乎有一个错误。这是 *
。所以,现在它是好的。
我想结束这节课,我们下节课再见。
现在,让我们继续这个编码。
现在,让我们在 main.cpp
中给出这个函数的原型,也许在这里。
它会调用 state_table_init
,并传入主应用结构的地址。
现在我们的状态表在 mobj
的 state_table
变量中准备好了。
现在,让我们先修复这个函数 protimer_init
。
protimer_init
。现在,我们没有这个函数 protimer_state_machine
,所以让我们把它移除。
我们设置一个事件,active_state
设置为 IDLE
,pro_time
变量设置为 0,然后你需要调用 IDLE
状态的 ENTRY
函数。你需要从 mobj
的 state_table_pointer
中获取这个。
让我们创建一个变量。
e_handler
,我会称它为事件处理程序。事件处理程序是此类型的函数指针变量。这实际上是在 main.h
中定义的,希望你还记得这一点。
e_handler
,我将在这里顶部创建它。e_handler
= 你如何从 mobj->state_table
指针中获取地址。
类型为 int *
。这必须视为一维数组的指针。如何获取 IDLE
状态的条目地址?就是这个值。
首先,你必须到达这个位置。
行号是 IDLE
,IDLE
乘以 MAX_SIGNALS
或 MAX_COLUMNS
。
IDLE
为 0,它的值为 0;0 乘以 7 还是 0。
对于这个,你必须加上它的值。
ENTRY
你要加上。ENTRY
是 5。现在,如果你这样做,会发生什么?
并分配给这个,实际上就是一个函数指针或函数的地址。
我们可能需要进行类型转换。
然后,直接跳转到这个函数指针。你知道,如何做到这一点。
e_handler
并且你必须传递 mobj
和 ee
的地址。
现在让我们回到 main.cpp
,我们将为事件调度器编写代码。
这里我们有状态,这没问题。源和目标,这也没问题。
active_state
变量,这也是可以的。现在这里发生了什么?你调用状态机并传入这个事件。
status = mobj
获取状态表,active_state
,active_state
乘以 MAX_SIGNALS
+
e->sig
中获取。对不起,在这里你必须创建一个变量,称为 e_handler
。
与该状态的此事件相关。
这只是将 active_state
保存到某个临时变量中。 status =
你必须跳转到该事件处理函数,你必须提到 mobj
,e
。
status = EVENT_TRANSITION
,那么你就把它保存在目标中,这没问题。这里 EXIT
。
只需移除这个。
e_handler = mobj->
state_table
,为源状态运行退出操作。MAX_SIGNALS
+ EXIT
。ENTRY
e
,&ee
。所以,我认为最好在这里检查 NULL。
if(e_handler)
。当前项目是打开的。
我会告诉你如何做到这一点。
004Protimer_SH
。ST
。我们将检查硬件,以查看输出是否与之前相同。
关于使用状态表方法实现状态机。
欢迎回来,今天我们将探讨HSMs(层次状态机)。
在之前的练习中,我们使用了平面状态机的方法。平面状态机意味着没有组合状态。状态图中只包含几个状态,彼此之间通过箭头相连,这些都是外部转移。
随着引入更多功能,状态数量增加,状态机图的复杂性也随之增加,维护和可视化变得繁琐。我们将探讨如何使用层次状态机(HSMs)来解决这一问题。
随着状态数量的增加,转移的数量也随之增加,复杂性加大。平面状态机难以可视化、绘制和排查,遗漏转移的风险也随之增加,可能导致代码重复和错误的引入。
层次一词在日常生活中随处可见,例如在企业中存在严格的层次结构。在这个结构下,所有员工的工作都是有层次的。在状态机中,我们也可以绘制类似的层次。
例如,有一个状态S1,下面有几个子状态。这种方式允许我们创建多个层级的状态。
考虑有3个状态S1、S2和S3,它们共享相同的行为——当触发器T1发生时,状态会转移到S0。这提示我们可以将这个状态机图转换为层次结构。
在这个例子中,SS1是一个复合状态,包含三个子状态S1、S2和S3。它继承了这些状态的共同行为。当T1发生时,状态机从SS1转移到S0。
在层次状态机中,当状态为S1时,T1的处理被传递到其父状态SS1,由其处理。这样的结构简化了状态机图的复杂性,并减少了代码重复。
在平面状态机中,可能需要在多个地方定义转移行为,而在HSM中,只需在一个地方定义。
接下来的讲座中,我们将通过代码示例深入了解HSM。
在这个应用程序中,您可以看到生成事件的代码。这是事件生成器代码。事件生成器将事件发送到事件调度器,调度器函数,然后调度器函数将事件发布到状态机。
状态机执行该事件的行为操作。然后,状态机返回给事件调度器,事件调度器再返回给超级循环。
这是我们的Arduino循环函数。我们可以将整个步骤总结如下:
这实际上是完成运行模式(RTC模式)。什么是RTC?它代表运行到完成,当前事件的处理必须先完成,然后再处理下一个事件。
这就是我们所称的运行到完成的范例。请注意,如果您使用运行到完成的范例,则不应在这些步骤中放置任何阻塞代码,例如Arduino的delay()函数,这会违反RTC范例。
请注意,UML状态机的实现遵循运行到完成的范例。这是规范所说的。
运行到完成意味着,在没有异常或状态机的异步销毁执行的情况下,只有在完成先前发生的处理并达到稳定状态配置后,才会分派待处理事件的发生。
换句话说,当前事件的处理必须首先完成,然后才能处理下一个事件。
也就是说,当状态机执行正在处理先前事件时,不会调度事件的发生。选择这种行为范例是为了避免由于状态机试图响应多个并发或重叠事件而引起的并发冲突的复杂性。
因此,请记住,UML状态机应以运行到完成的方式实现。
事件驱动 + RTC范例提供了更好的能效。因为,当事件缺失时,您可以利用微控制器的低功耗模式将微控制器置于睡眠模式以节省一些电力。
在这种事件驱动架构中,微控制器只在执行RTC步骤期间处于活动状态。
这是一个RTC步骤。当RTC步骤完成后,微控制器可以休眠,直到另一个事件唤醒它。
现在,要解决分层状态机或实现分层状态机,我们需要一个事件处理器代码。
事件处理器实际上解决了嵌套分层状态机的遍历问题。也就是说,它遍历嵌套在分层状态机中的各种状态,并调用适当的状态处理程序。
它负责根据UML规范执行各种转换序列。为此,我们需要一个背景事件处理器。在之前的练习中,我们实际上使用了一个平面状态机,并且没有使用任何事件处理器。一个通用代码可以处理各种状态处理程序的调用,并根据规范解决各种状态转换。
但是,当您开始使用嵌套的分层状态机时,手动实现各种状态遍历和维护转换序列的执行将变得繁琐。
因此,您需要一个框架或使用一些通用事件处理器代码,这可以帮助您在项目中实现分层状态机。
因此,在本课程中,我将介绍由Quantum Leaps, LLC提供的QP实时嵌入式框架和QM工具。
这是一个著名的框架,它还带有一个称为QP Modeler的图形建模工具。这在现代嵌入式系统编程中使用事件驱动架构方面非常有名。
您可以通过这些链接探索更多关于此的内容。
最后,我们要特别感谢Quantum Leaps, LLC的创始人兼首席执行官Miro Samek,感谢他允许我们在本课程中使用QP框架及其相关组件。
您将使用QP框架、QP Modeler工具、QHSM事件处理器和QP Nano Arduino库来实现事件驱动的分层状态机项目。
QP框架的一些特性是,它提供运行到完成和事件驱动架构。并且它支持使用UML状态图实现嵌套的分层状态机。
使用称为QP Modeler的工具,您可以图形建模您的应用程序,这实际上是一个图形建模工具,我们将在本节中使用该工具。您可以自动生成代码,将UML状态图转换为可追踪的C和C++代码。
我们稍后将看到这一点。它还支持主动对象设计模式。
在本节中,我将不会覆盖主动对象,但该框架支持主动对象或演员设计模式。当在应用程序中使用主动对象以调度各种主动对象时,您可能需要一个内核。各种轻量级内核提供,QV、QK、QXK。
QV是一个简单的合作内核,框架本身就有,因此您无需拉取其他实时操作系统。QK是一个非阻塞的抢占式运行到完成内核。QXK是一个抢占式双重模式,您可以将其用作运行到完成或阻塞RTOS内核。主动对象还具有其他特性,例如它们带有自己的事件队列和各种数据封装技术。
因此,您可以通过Quantum Leaps提供的主动对象文档进行更多探索。
该框架还支持一个名为QSpy的跟踪工具,可以使用QSpy进行实时跟踪,还有一个名为QUTest的工具,用于进行单元测试。许可非常友好,采用双重许可;
提供开放和封闭许可。请通过此链接了解不同的许可方案。并且该框架符合MISRA-C和MISRA-C++编码标准。
有关更多信息,请访问此链接。
之后,QP框架有三种版本。
一种是QP/C,代表C语言的量子平台。该框架支持用C语言实现基于UML的FSM和嵌套HSM实现。QP C++代表C++语言的量子平台。
因此,如果您在项目中使用C++,那么可以使用QP C++,还有一种称为QP-nano的版本,代表量子平台Nano,这是一个轻量级的框架。
这实际上是为RAM和ROM大小非常有限的低8位或16位微控制器(例如AVR、MSP430或8051)设计的。
这是该框架的框图。这张图片我来自statemachine.com。
这是整个框架的图片。在本节中,我们将使用这个分层事件处理器。
请注意,QP框架也以Arduino库的形式提供。您可以将该库复制粘贴到Arduino库文件夹中,开始使用该框架。
对于基于AVR的Arduino,例如Uno和Nano,QP-nano Arduino库已可用。
您只需下载即可开始使用。对于基于ARM的Arduino,例如Arduino Duo,它基于ARM Cortex M架构,QP/C++ Arduino库已可用。因此,您可以在这些板上使用该库。
或者,您也可以在这些板上使用QP/C框架。
但是,对于Arduino Uno板或Nano板,我们必须使用QP-nano框架,这实际上是QP/C的轻量级版本。您可以在此链接中获取有关QP Arduino库的更多信息。
在本课程中,由于我们使用Arduino Uno板,因此我们将使用QP-nano Arduino库。
但不建议在新设计中使用,因为QP-nano框架已被停止使用,如该网页所述。对于较新的设计,您必须将硬件升级到更新的硬件,例如Arduino DUE或Arduino ZERO,
这些都是基于ARM架构的Arduino。在这种情况下,您可以使用QP/C++框架或QP/C框架。
让我们继续。现在我们需要下载几个东西。
首先,我们需要QP框架,您可以从这里下载,也要下载QM图形建模工具,
所有内容都可以通过一个下载获得。我会给您演示。
我们还需要下载QP Arduino库。
我们还需要参考QP-nano框架的一些API,以使用该框架。
所以,我稍后会给你展示。现在,让我们下载这两个东西。
现在,让我们下载QP-nano Arduino库。为此,前往资源部分并选择Arduino。
下载适合你机器的QP Arduino,我将选择Windows。
首先,让我们安装QP框架。只需双击它。
选择目录,默认情况下会安装在“C”目录下。这是可以的。
在这里,你可以取消不需要的选项。例如,我们在这里不使用ARM处理器,因此可以取消这些选项。我们也不使用QP/C++,所以也可以取消该选项。但我会保留所有选项,因为我有足够的磁盘空间。
我将点击下一步,然后下一步,最后安装。这需要一些时间。现在,让我们点击完成。
安装完成后,现在让我们安装QP Arduino库。返回下载页面并解压缩它。
现在,你需要复制所有文件并将其粘贴到Arduino草图书位置。为此,打开Arduino IDE。
在Arduino IDE中,点击文件,然后选择首选项。复制该位置。
打开文件资源管理器并进入该位置。这是我的草图书位置。在这里,你需要粘贴刚刚复制的QP Arduino库。
只需复制QP Arduino库,并在草图书位置中粘贴即可。
我已经完成了这一步,所以它询问是否替换某些文件。我将选择替换目标位置中的文件。
现在,你可以看到文件已在此处复制。如果你查看库中,可以看到两个库:qpcpp(适用于ARM架构的Arduino板)和qpn(即QP-nano框架,适用于AVR架构的Arduino板)。
这包含了所有框架相关的源文件。我们成功集成了Arduino库。
在下一讲中,我们将测试Arduino板上的转换执行序列和事件传播。这实际上是一个示例,用于理解各种转换执行序列和嵌套层次状态机图中的事件传播。
这个示例已包含在你下载和安装的QP框架中。它位于特定位置,因此你可以直接在你的机器上测试该应用程序。
在下一讲中,我们将在Arduino板上进行测试,从而学习如何将这个工具与Visual Code和PlatformIO扩展结合使用。将会在下次讲解中详细介绍。
欢迎回到讲座。在上一节课中,我们安装了QP框架,这同时也安装了QM工具,一个图形建模工具。此外,我们还安装了QP-nano Arduino库。
在本节课中,我们将理解嵌套层次状态机中的各种转换执行序列,并了解事件传播。当事件发送到嵌套层次状态机时,状态之间是如何转换的,以及各种行为操作的执行顺序是什么。
为了理解这一点,已经有一个示例代码可在指定位置获取。你可以直接在Visual Studio中启动该应用程序进行测试。
这不需要连接任何硬件。你只需前往QP安装文件夹。这个文件夹安装在“C”驱动器下的“qp”目录中,然后进入“qpc”,接着进入“examples”和“workstation”,在这里打开“qhsmtst”。
打开这个项目,并启动这个Visual Studio解决方案。为此,你需要先安装Visual Studio,我已经安装好了。让我们打开它。
项目已打开,接下来我们回到QM模型文件。这需要使用你在上一节中安装的QM工具来打开。
在上一节中安装的QP框架中也包括了QM工具。现在让我们启动它。
使用QM工具打开这个模型文件。我会复制这个路径,接着打开文件,选择“打开模型”。
打开后,展开这个包,双击以打开该类的状态机。我们稍后会理解如何从头开始创建所有这些内容,例如创建类、添加属性、添加操作和状态机等。
这是该项目的嵌套层次状态机。可以看到,它接收各种事件,事件被命名为A、B、C,一直到I。这些事件发送到该项目以观察各种转换。
现在,在机器上测试这个项目。你已经在Visual Studio中打开它,只需编译即可。
编译并运行,它将开始运行。现在,你可以发送各种事件。
例如,代码当前处于状态s211。让我们发送一个事件,比如说发送G事件。
发送事件G后,状态机的状态从s211转换为s11。这意味着状态机成功地完成了从s211到s11的转换。
这就是事件G的转换执行序列。在状态机状态为s211时接收到事件G,执行了各种动作的顺序。
在此之前,我们将了解如何将该项目与Arduino集成,并学习如何创建文件,以及使用QP框架的代码生成指令生成各种函数声明和定义。如果你不想尝试Arduino,也可以按照我刚才解释的方式直接在计算机上进行尝试。
这节课到此为止,你可以向该程序发送各种事件,以观察转换执行序列。
欢迎回到讲座。在本节课中,我们将创建一个新的Arduino项目来测试已经提供的模型qhsmtst.qm
,目的是通过发送各种事件来理解嵌套层次状态机中的各种转换序列。
首先,让我们关闭Visual Studio,并打开Visual Code IDE。
要创建新项目,进入PlatformIO首页,点击“打开”,然后选择“新项目”。
项目名称设为006QHsmTest
,选择板子为Uno,框架选择Arduino,位置使用之前的目录,点击完成。
新项目QHsmTest
已创建,在这里创建一个新文件夹,命名为qm
,用于存放qm模型。
现在非常重要的是,下载与本节课相关的qm
模型文件,并将其保存在qm
文件夹中。
我将把模型文件粘贴到该目录的qm
文件夹中。若无法直接粘贴,可以在文件资源管理器中打开qm
文件夹,进行粘贴。
完成上述步骤后,模型文件就会成功放置在qm
文件夹中。
打开QM模型文件
确认文件格式
qpn
格式,而不是qpc
。若显示为qpc
,可以忽略。自动生成代码
代码生成指令
declare
指令生成函数声明。define
指令生成函数定义。创建新的目录和文件
.
,则表示当前模型文件所在目录。file.cpp
。生成代码
编辑生成的文件
理解代码生成指令
declare
、define
和define1
。添加状态处理函数的声明
.cpp
文件中使用代码生成指令,生成状态处理函数的声明。declare
指令,指定包名和类名,完成状态机的函数签名生成。总结
我将其标记为外部,但暂时让我去掉这个限制,将其设为内部。
这里,我们创建一个枚举,包含状态机的所有事件。如果你点击那个状态机,可以看到它实际上接受许多事件,事件从 'A' 到 'I',还有一个叫做终止事件的事件。
A_SIG,你必须在事件后面加上 _SIG。因为当它生成状态机模型的代码时,你可以看到,这个框架使用了 _SIG。因此,每个事件都要使用 _SIG 后缀。
事件从 'A' 到 'I'。在提到所有事件后,最后你必须提到一个宏 MAX_SIGNAL。这个枚举常量实际上表示应用程序支持的事件总数,所以要把它放在最后。这个框架可能会使用这个宏,我不知道,但这是必要的。
这是第一个用户事件。在文档中,你可以查看 QP-nano 文档文件,这里在 'qpn.h' 中有一些框架保留信号,比如 Q_ENTRY_SIG、EXIT_SIG、INIT_SIG,以及与超时相关的信号等。Q_USER_SIG 是一个宏,表示用户信号或用户事件的开始。因此,你必须将这个事件等于 Q_USER_SIG。它的值实际上是 8,8 被分配给这个,它标记应用程序信号或用户信号的开始。
这是为了区分保留信号,因为第一个保留信号从 1 开始,这就是 Q_ENTRY_SIG。如果你不这样做,那么值将与这些保留信号冲突。
现在,让我们生成代码。我刚刚生成了代码。让我们去 .h 文件,你可以看到,它已经在这里了。
现在,让我们解决一些错误。首先,去 .cpp 文件,这里你需要包括一些头文件。第一个需要安装的头文件是 #include
。然后是 Arduino 框架相关的头文件 Arduino.h,接下来是与 QP-Nano 框架相关的头文件 'qpn.h',还有这个头文件 'QHsm_Test.h'。
现在,让我们生成代码。它出现了,你可以看到,我们解决了大部分错误。现在还有一个错误。我不确定为什么它会显示这个错误提示,因为我已经在这里包含了库。我们将在构建时查看,它可能会消失。
现在,在模型中,如果你检查模型,状态机模型,你可以看到,它为不同的转换使用不同的动作。例如,如果你考虑这个状态,那么模型定义了 ENTRY 动作和 EXIT 动作。现在我们需要给这些函数的实现 'BSP_display'。
让我们这样做。为此,让我在这里创建一个文件。我将其称为 'bsp.cpp'。另一个文件,我可以将其命名为 'bsp.h',在这里你可以保留任何与外设相关或 BSP 相关的代码。QM 工具没有生成这些代码。这是我们的 BSP 代码,用于驱动各种外设。
在 'bsp.h' 中,添加包含保护。我们来创建这两个函数 BSP_display() 和 BSP_exit()。还有一个函数你需要给出,BSP_exit()。只需写这个字符串,它不会返回任何东西。只需在这里给出声明,让我们在 cpp 中实现它。
所以,只需包含 Arduino 头文件。然后我们将使用 serial.print()。因此,我们将在串行终端上打印一些文本。
现在,让我们包括这个头文件 'bsp.h'。并生成代码。它在这里。
让我们去 main.cpp。main.cpp 产生事件。它接收事件或生成事件,并将其发送到状态机。因此,它必须知道支持哪些事件。这就是为什么让我们包含 QHSM_Test.h。
现在让我们编译。我们将查看它是否可以正常构建。
我实际上正在编译旧项目。所以,我必须选择项目,活动项目是 006 QHsmTest。你可以看到错误消失了。
现在,让我们编译。有一个错误。让我们检查一下。这个函数未使用,这没关系。我们稍后会用到。它说这个宏在这个作用域中未声明。我们实际上在这个头文件中使用了它。你要做的是,在 main.cpp 中,在包含这个头文件之前,先包含 'qpn.h'。
让我们编译。现在,它构建成功。
到目前为止,我们完成了所有这些步骤。在下节课中,我们将探索 QP-Nano 的一些 API,以将事件发送到状态机。我会在下节课见到你。
在接下来的工作中,我们需要将事件发布到状态机。为此,我们需要探索 QP Nano 的 API。可以访问 state-machine.com/qpn 查看 API 参考。以下是我们需要使用的 API。
我们首先调用分层状态机的构造函数 QHsm_ctor()
,这是构造函数。之后调用 QHSM_INIT
,如果有任何事件,可以使用宏 QHSM_DISPATCH
来分派事件。还有一些辅助函数可以获取当前状态等。需要按以下顺序调用这些操作:
QHsm_ctor
QHSM_INIT
框架基于超类 QHsm
,这是一种超类,包含一些属性或数据字段,基本上是一个结构体。我们的应用程序结构或类派生自这个超类。
我们已经在应用结构中嵌入了超类结构,这意味着现在我们的结构继承了这个超类的属性。该结构体包含一个重要字段——状态处理器,用于保存当前活动状态处理器的指针,这是一个状态变量。
要初始化状态变量,可以使用构造函数 QHsm_ctor()
,它的作用是通过将初始伪状态分配给状态机的当前活动状态来完成 HSM 初始化的第一步。
main.cpp
文件中调用这个函数,或者从设置函数中调用。me
指针,指向超类的指针。可以在 main.cpp
中调用构造函数 QHsm_ctor
。由于我们没有为此结构体创建任何实例,需要在工具中创建实例。可以通过右键点击并选择“添加属性”来添加一个实例,也可以手动添加。
在工具中添加属性时,可以选择两种类型的属性:
类属性有两种类型:
可以在工具中实验选择静态和非静态属性,以了解其行为。例如,可以创建一个非静态类属性 foo
,然后查看其生成代码。选择静态选项后,该属性会从类结构中消失,变成文件级别的静态变量。
自由属性可以在文件范围内定义,既可以是静态变量,也可以是全局变量。可以通过在工具中点击包并选择“添加属性”来创建自由属性。
这是我们类的构造函数,作为一个操作添加到模型中。在右侧可以看到它被称为自由操作。在之前的课程中,我们已经了解了如何添加类属性和自由属性。现在,让我们学习如何添加类操作。
类操作是添加到类中的方法或函数。通常有两种方法可以在类中创建:静态方法和非静态方法。这些术语来源于 C++。在 C 中,也可以理解为添加到结构体的操作,但不能在结构体中放置任何方法。而在 C++ 中,可以实现。例如,有一个类 SomeClass
,在类中可以创建方法,比如 void setdata
,这是一个非静态操作或方法。它可以访问类的属性,例如 somedata
是一个非静态属性。该方法可以访问该变量并将其初始化为其他值。或者也可以使用 this
指针,这是指向类当前实例的指针。在非静态操作中 this
指针是可用的,而在 C 中的等效指针是 me
指针,由 QM 模型创建。
在静态操作中,this
指针不可用,意味着无法访问类的非静态属性,但可以访问静态属性。静态属性在所有类的对象中只有一份,而非静态属性则是每个对象独有的。如果创建多个对象,则每个对象都会有自己的非静态属性副本。
可以使用工具添加静态类操作、非静态类操作,以及类构造函数。
me
指针的函数,相当于在 C++ 中没有 this
指针的函数。创建静态类操作是为了访问或查询静态变量,因为在静态操作中无法访问非静态属性。me
指针的函数,用于访问主应用结构体的实例。类似于在 C++ 中,非静态操作可以访问 this
指针。void
。在工具中,可以通过选择“添加操作”选项来添加类操作。要创建静态操作,需要勾选“静态”选项。例如,添加一个名为 this_is_static_operation
的静态操作并生成代码。可以看到,生成的代码中没有 me
指针。
然后,可以创建一个非静态操作 this_is_non_static_operation
,将其设置为私有,并生成代码。这次可以看到该操作接收 me
指针,并可以使用该指针访问结构体的成员元素。
可以通过点击包并选择“添加操作”来添加自由操作。自由操作的返回类型为 void
,可以设置为文件范围或全局范围。如果希望其他文件共享该操作,则可以设置为全局。
构造函数作为自由操作添加,返回类型必须为 void
,并设置为全局,以便在 main.cpp
中共享该构造函数。然后,可以在 .h
文件中声明构造函数并在 .cpp
文件中定义它。
我们遇到了 Q_onAssert
错误。让我们深入探讨这个错误。需要检查文件 qassert.h
,找到并展开 Q_onAssert
。Q_onAssert
是一个回调函数,当断言失败时会被调用。
在框架的任何 API 中,如果由于无效参数或过多的分层状态机嵌套导致断言失败,就会发生断言错误。断言失败的原因可能有很多种。如果发生断言失败,将通过此回调函数通知您的应用程序。需要在 main.cpp
中实现该函数。
在 main.cpp
中实现该函数,传入的参数包含以下信息:
我们可以使用 Serial.println
来打印模块名称。实现步骤如下:
完成上述步骤后,编译代码。构建已成功。
在下一节中,我们将探索其他 API,包括 QHSM_INIT
和 QHSM_DISPATCH
。我们下节见。
我们遇到了 Q_onAssert
错误。接下来让我们深入了解这个错误。需要查看文件 qassert.h
,找到并展开 Q_onAssert
。Q_onAssert
是一个回调函数,当断言失败时会被调用。
在框架的任何 API 中,如果由于无效参数或过多的分层状态机嵌套导致断言失败,就会发生断言错误。断言失败的原因可能有很多种。如果发生断言失败,将通过此回调函数通知您的应用程序。需要在 main.cpp
中实现该函数。
在 main.cpp
中实现该函数,传入的参数包含以下信息:
我们可以使用 Serial.println
来打印模块名称。实现如下:
接下来,编译代码。构建成功。
在下一节中,我们将探索其他 API,包括 QHSM_INIT
和 QHSM_DISPATCH
。我们下节见。
欢迎回到课程。在之前的课程中,我们完成了该练习,现在让我们测试这个状态机。在 setup
函数中添加配置 UART 波特率的代码,并发送一条消息。接下来,编译代码并下载到开发板上。打开 Arduino IDE 的串行监视器(选择工具中的串行监视器),确保选择的是 Uno 板。
可以看到应用程序已启动,并且执行了与初始转换相关的操作。让我们分析这些步骤。
s2
状态。按照课程中的介绍,进入嵌套状态时,ENTRY 操作会从最外层状态依次到最内层状态执行。
s
,因此首先执行 s-ENTRY
操作。s2
,执行 s2-ENTRY
操作。s2
的初始伪状态指向 s21
,因此进入 s21
并执行 s21-ENTRY
操作。s211
状态并执行 s211-ENTRY
操作。此时状态机停留在 s211
。D
的处理假设状态机在 s211
,然后我们发送事件 D
。以下是处理过程:
D
触发了从 s211
的外部过渡。首先执行与 D
相关的操作 s211-D
。s211-EXIT
操作。s21
,此时不再执行 s21
的 ENTRY 操作,因为它是 s211
的直接父状态。s21
的初始伪状态指向 s211
,因此再次进入 s211
并执行 s211-ENTRY
操作。B
的处理如果状态机在 s211
并收到事件 B
:
s211
没有处理 B
,事件会向上传播到 s21
。s21
处理事件 B
,触发一个从 s21
到 s211
的局部过渡。s21-B
操作,然后 s211
执行 EXIT
操作,再次进入并执行 ENTRY
操作。s211
。A
的处理假设状态机在 s211
并收到事件 A
:
A
未被 s211
处理,事件向上传播至 s21
。s21
处理事件 A
,这是一个自我过渡。s211
到外层的 s21
。s211
。I
的守卫条件发送事件 I
时,状态机会逐层检查每个状态的守卫条件:
s211
无法处理 I
,事件向上传播。s2
时,检查 foo
变量的值:
foo
为 0,因此守卫条件成立,执行 s2-I
操作。foo
设置为 1,再次发送事件 I
时守卫条件不成立,因此事件继续向上传播到 s
并执行 s-I
操作。s2-I
和 s-I
,取决于 foo
的值。C
的处理假设状态机在 s211
并收到事件 C
:
s211
和 s21
均未处理事件 C
,事件向上传播至 s2
。s2
处理事件 C
,执行相关的过渡操作 s2-C
。s211
到 s2
的 EXIT 操作。s1
并执行 ENTRY 操作,最终进入 s11
。G
的处理在 s11
状态下发送事件 G
:
s11
无法处理事件 G
,事件向上传播至 s21
。s21
处理事件 G
,触发从 s2
到 s
的过渡。s2
的 EXIT 操作,并进入 s
,然后根据伪状态指向的状态进入最终的 s11
。历史状态有两种类型:浅历史和深历史。在状态图中显示的 "H+" 表示深历史。当首次重置板子时,状态机会停留在 s211
。最初 s1
没有历史状态,因为从未进入过 s1
,所以历史状态为空。
首先分析没有历史状态的情况。打开模型并在 s1
中添加一个新状态 s12
,并定义其 ENTRY 和 EXIT 操作为 s12-ENTRY
和 s12-EXIT
。重置板子后,状态机将进入 s211
,而 s1
没有历史状态。此时,发送事件 F
。
F
事件没有被 s211
处理,因此事件向上传播到 s21
和 s2
。s2
处理 F
事件,执行 s2-F
操作。s211
、s21
和 s2
的 EXIT 操作。s1
没有历史状态,所以采取默认路径,进入 s1
的默认状态 s11
。当状态机访问过 s1
后,会记录 s1
的历史状态。例如,当状态停留在 s12
并返回 s211
时,历史状态记录为 s12
。
s211
状态再次发送 F
,F
事件会触发到 s2
。s2-F
操作,随后依次执行 EXIT 操作。s1
的历史状态进入 s12
而非 s11
,因为历史状态指向 s12
。假设状态机停留在 s11
,并接收事件 E
:
E
未被 s11
和 s1
处理,事件传播到 s
。s
处理 E
,这是一个局部过渡。s11
。假设状态机在 s11
且 foo
变量的初始值为 0,然后发送事件 D
:
s11
处理事件 D
,但由于守卫条件 foo
值为 0,守卫失败。D
向上传播到 s1
,守卫条件成立,foo
设置为 1,并执行 s1-D
操作。s
,并通过伪状态进入 s11
,依次执行 s-INIT
、s1-ENTRY
和 s11-ENTRY
。在 foo
值为 1 的情况下再次发送事件 D
:
s11
成功处理 D
,执行 s11-D
操作,进行局部过渡回到 s1
。s11
的 EXIT 操作,然后重新进入 s11
。通过不同的事件和历史状态的处理,我们可以理解状态机的过渡执行顺序和事件传播机制。如果有疑问,不用担心,随着绘制状态图和完成练习,这些概念将会逐步清晰。
以上就是本节课程的内容,期待在下节课程中继续学习。
在这个练习中,我们将使用软件实现一个实时时钟(RTC),不使用任何 RTC 芯片。本项目的需求如下:
该项目的电路图与上一个项目非常相似,但有以下两个变化:
按钮:
SET
和 CLOCK_SET
功能。用于进入时钟设置模式。OK
)。在完成某些设置后,可以使用此按钮进行确认。该按钮也可用于进入闹钟设置模式。LCD 背光控制:Arduino 的数字引脚 4 通过一个电阻连接到 LCD 背光的阳极,以控制 LCD 背光的亮度。
接下来,我们将展示该应用程序的演示,以便更清晰地了解应用程序的界面和项目的其他需求。
接下来,我们将演示该应用的功能。通过演示,您将更清晰地了解该应用的需求。我们将使用分层状态机的方法来实现此应用。
在该项目中,我重新使用了之前的电路和组件。不同的是,当前应用只需使用 2 个按钮,以及 LCD 显示屏。LCD 与 Arduino 板之间的连接与之前相同,此外还可以选择性地加入一个蜂鸣器以及用于调节 LCD 对比度的电位器。
我已将应用程序下载到 Arduino 板并运行。现在重置 Arduino 板,应用程序将显示当前时间,格式为时、分、秒和亚秒。同时,显示时间格式(24 小时制或 12 小时制)以及闹钟符号,表示闹钟当前已开启。这被称为“计时模式”,即应用程序实时显示当前时间。
当应用处于计时模式时,按下第一个按钮(SET/CLOCK_SET)即可进入时钟设置模式。在时钟设置模式下,LCD 光标会闪烁,指示当前正在修改的字段。可以按位修改小时、分钟等信息,每次按下“SET”按钮即切换数值。如果数值正确,可以按“OK”确认,光标会移动到下一个字段。如果希望取消整个操作,可以同时按下两个按钮回到计时模式,而时钟将继续在后台运行。
进入时钟设置模式后,您可以设置为 24 小时制或 12 小时制。如果选择 12 小时制,还可以设置为 AM 或 PM。当设置不匹配(如 24 小时制设置为 AM/PM)时,会提示“错误”。在此情况下,OK 按钮无效,必须通过 SET 按钮重新调整设置。
当应用处于计时模式时,按下“OK/ALARM_SET”按钮进入闹钟设置模式。第一行显示当前的闹钟设置,第二行显示当前时间。在闹钟设置模式中,按“SET”按钮可逐步设置闹钟时间和 AM/PM,并设置闹钟开启或关闭。当时间接近设定的闹钟时间时,闹钟会响起,同时屏幕显示闹钟提醒。按下 OK 后返回计时模式。
在进行设置过程中,如果需要中途退出并恢复到先前的状态(如闹钟触发时),应用会记录当前状态,待闹钟提醒结束后自动回到设置状态。无论是在时钟设置还是闹钟设置模式下,都可支持这种“历史状态”功能。
进入时钟设置模式时,系统会捕捉当前时间,用户可以从该时间点开始逐位调整时间。
您刚刚看到了应用的演示,通过演示您可以想象出应用中的一些状态。例如,应用有一个展示当前时间的状态,我们称之为计时模式(或计时状态)。用户可以进入时钟设置模式,因此可以定义一个时钟设置状态。此外,用户也可以进入闹钟设置模式,因此还需要一个闹钟设置状态。在开始建模之前,可以将这些状态写在纸上。
在计时模式下,有两个主要事件:“SET”事件和“OK”事件。
当应用处于时钟设置或闹钟设置模式时,用户可以随时取消设置,因此可以定义一个通用的Abort转换,从设置状态返回到计时模式。通过定义一个包含这两个设置状态的超级状态,并提供一个从设置状态到计时状态的通用转换,可以实现这一点。
如果用户完成设置并按下“OK”按钮,应用程序将保存设置并返回计时模式。与Abort的区别在于,“OK”按钮会保存设置,而Abort则不会。
可以创建一个包含所有状态的超级状态,称为Clock超级状态。在这个超级状态中定义一个“Alarm”事件,该事件将应用程序切换到闹钟通知状态。无论应用处于哪个状态,只要闹钟响起,都会进入闹钟通知状态。当用户按下“OK”按钮后,闹钟通知结束,应用将返回到之前的历史状态(Clock超级状态中的上一次状态),即用户可能正在进行的时钟设置或闹钟设置。
在时钟设置模式中,需要定义更多的子状态,以配置小时、分钟、秒等信息。这部分将在后续课程中详细讨论。
需要创建一个主应用结构来绘制状态机图。此主应用结构将继承自 QHSM
,因为这是一个分层状态机。此外,我们将向主结构中引入一些属性:
temporary_time
复制到 current_time
。将根据状态机的需求逐步添加其他变量。
在状态机中使用以下信号和事件:
007ClockAlarm
。lcd.c
和 lcd.h
文件(与本课附带的文件相同)复制到项目文件夹中的 Src
文件夹。这些文件与之前的练习中使用的 LCD 文件相同。下一节课程我们将开始实现此应用。
欢迎回到本节课程。我们继续进行练习 007。首先,创建一个新文件夹,命名为 'qm'。在该文件夹中存放即将使用 qm 工具创建的新模型文件。
ClockAlarm
。在创建状态机模型之前,首先需要创建一个包。右键点击选择“添加包”。包用于将不同元素分组并提供命名空间。项目可以包含多个包,若需要在不同包中复用变量名或函数名,可以指定不同的命名空间。选择“组件”作为类型,为包命名为 HSMs
。
在包中创建类,右键点击选择“添加类”,将类命名为 Clock_Alarm
。选择 QHsm
作为超类,因为 Clock_Alarm
类派生自 QHsm
。保存。
在 Clock_Alarm
类中添加以下属性:
current_time
:类型 uint32_t
,私有非静态属性。temp_time
:类型 uint32_t
,私有非静态属性。可以继续添加其他属性。
在包中添加一个目录,路径为 ../src
,用于存放生成的源文件。然后添加两个文件:
ClockAlarm_SM.cpp
(源文件)ClockAlarm_SM.h
(头文件)确保头文件中包含 include guards。保存并生成代码,接受 GPL 许可,生成的文件将显示在指定目录中。
在类中添加状态机,右键点击选择“添加状态机”。双击打开状态机画布,在画布中绘制状态和转换。
Ticking
Clock
Settings
Clock_Setting
Alarm_Setting
Ticking
。
Ticking
状态。SET
到达时,从 Ticking
过渡到 Clock_Setting
。OK
到达时,从 Ticking
过渡到 Alarm_Setting
。Settings
到 Ticking
的 ABRT(中止)转换。Clock
状态中添加 Alarm_Notify
状态,用于闹钟通知。添加 Clock
的深历史状态(Deep History),用于在闹钟通知结束后返回之前的状态。添加一个转换从 Alarm_Notify
到 Clock
的历史状态,事件为 OK
。
保存项目并完成当前步骤。我们将在下节课程中继续完善。
打开 ClockAlarm_SM.h
文件,首先定义信号。
ClockAlarm_Signals
枚举,并初始化第一个信号为 Q_USER_SIG
。接下来,打开 ClockAlarm_SM.cpp
文件,声明所需的内容。
#include <Arduino.h>
#include "qpn.h"
#include "Lcd.h"
#include "ClockAlarm_SM.h"
生成代码,检查生成结果。
SM.h
文件中可以看到信号枚举。.cpp
文件中可以看到结构体声明和所有状态处理程序的签名,目前还没有实现定义。参考之前的项目,打开 platformio.ini
文件。
platformio.ini
文件中。再次生成代码并编译,确认构建成功。
生成并保存所有状态处理程序的定义。
创建主应用对象,将其作为静态类属性添加:
obj
,类型为 Clock_Alarm
,设置为静态变量,访问权限为私有。Clock_Alarm
类型的对象。为类添加构造函数,构造函数将作为自由操作添加。
Clock_Alarm_ctor
,返回类型设为 void
(构造函数必须为 void
)。QHsm_ctor
。在构造函数中初始化超类:
Clock_Alarm_obj.super
作为第一个参数。Q_STATE_CAST
宏将初始状态处理程序的地址作为第二个参数。可选:构造函数可以接受参数,以便主函数传递初始值用于初始化主类对象的属性。
保存并生成代码:
SM.h
中声明构造函数。SM.cpp
中定义构造函数,实现所需的初始化逻辑。生成代码后,您将在 ClockAlarm_SM.cpp
中看到构造函数的实现。
在本节课中,我们将深入了解 ATmega328P 微控制器的 Timer 外设,因为我们将在应用中使用 Timer ISR 来跟踪时间。
在我们的主结构中,current_time
变量用于存储时间,以 100 毫秒为单位递增。例如:
current_time = 1
:表示经过了 100 毫秒,显示的时间格式为:小时 0,分钟 0,秒 0,亚秒显示为 1(即 100 毫秒)。current_time = 10
:表示经过 1000 毫秒(1 秒),亚秒显示为 0,秒显示为 1。current_time = 605
:表示 60.5 秒,分钟显示为 1,亚秒显示为 5(即 500 毫秒)。请注意,current_time
使用 24 小时制,取值范围为 0 到 864000(即 24 小时,以 100 毫秒为单位)。
time_mode
变量。ATmega328P 具有三个 Timer/Counter 外设:
millis
功能,不在本项目中使用。我们将使用 Timer/Counter1
作为 16 位定时器。定时器包含两个比较寄存器,当计数器的值与比较寄存器的值匹配时,会触发比较中断。通过该中断来实现变量的递增,从而实现计时功能。
在 Arduino Uno 板上,ATmega328P 使用 16 MHz 的外部晶振作为主系统时钟。我们可以使用定时器的分频器减慢时钟以控制计数速率。TCCR1B 寄存器中的分频设置如下:
假设我们希望生成 100 毫秒的时间基准。按以下方式计算输出比较匹配值:
Tick
分辨率为 16 微秒。100 ms / 16 µs = 6250
。因此,输出比较匹配值应设置为 6250 - 1
,因为计数从 0 开始。
我们将以“CTC 模式”运行定时器,即在比较匹配时自动清零计数器并触发中断:
TCNT1
寄存器的值与输出比较寄存器(如 OCR1A)匹配时,计数器将自动清零并生成中断。ClockAlarm_SM.cpp
文件中定义。在 ClockAlarm_SM.cpp
中使用 ISR 宏和中断向量定义 Timer1 比较 ISR。中断向量为 TIMER1_COMPA_vect
,需按以下格式编写:
ISR(TIMER1_COMPA_vect) {
// ISR 代码
}
这将实现定时器的中断服务例程,用于在比较匹配时触发。
在 main.cpp
文件中添加了一个 Timer1_setup
函数,用于配置微控制器的 Timer1 外设,以每 100 毫秒生成一次中断。按照前面的课程所述,我们将以 CTC 模式配置 Timer1。
要将定时器配置为 CTC 模式,首先需要查看寄存器描述。以下是配置步骤:
TCCR1A 寄存器(控制寄存器 A):
在代码中,将 TCCR1A 寄存器值设置为 0:
TCCR1A = 0;
TCCR1B 寄存器(控制寄存器 B):
在代码中,配置 TCCR1B 寄存器:
TCCR1B = (1 << WGM12) | (1 << CS12);
启用输出比较中断:
在代码中,将该位设为 1:
TIMSK1 = (1 << OCIE1A);
设置比较匹配值:
在代码中,将匹配值写入 OCR1A:
OCR1A = 6249; // 100 ms 的匹配值
在 SM.cpp
中实现 ISR。使用 ISR
宏并指定中断向量地址:
TIMER1_COMPA_vect
)。在代码中定义 ISR:
ISR(TIMER1_COMPA_vect) {
// ISR 代码
}
该 ISR 将在每次比较匹配时触发,从而实现定时中断。
current_time
设置为静态变量我们在 ISR(中断服务例程)中需要递增 current_time
变量,但当前 current_time
是对象的私有属性,且不是静态变量。为了确保所有对象共享同一个时间值,我们将 current_time
变量设为静态变量,这样所有主结构对象都可以访问同一个时间。
Object1
和 Object2
,分别用于显示时区 X 和时区 Y 的时间。current_time
作为静态变量,使所有对象可以访问相同的时间值,从而避免不一致。current_time
设置为静态变量并生成代码current_time
变量,并将其设置为静态。get_current_time
,用于获取当前时间。
uint32_t
current_time
的值。current_time
已作为静态变量移出类对象。在代码中,您将看到 get_current_time
函数,返回 current_time
的值。接下来,将实现该函数。
update_curr_time
静态函数创建一个静态类操作 update_curr_time
:
void
current_time
,并不需要访问对象的私有属性。me
指针。在代码中实现 update_curr_time
函数逻辑:
current_time
变量。current_time
达到 MAX_TIME
(24 小时),将其重置为 0。MAX_TIME
定义在 ClockAlarm_SM.h
中,其值为 24 * 3600 * 10
。在 SM.h
文件中定义 MAX_TIME
宏。在 update_curr_time
函数中实现递增和重置逻辑。
update_curr_time
Clock_Alarm_update_curr_time
,以更新 current_time
。update_curr_time
函数中添加代码,由于代码由工具管理,实际代码将保存在 qm 工具中。现在,每当中断发生时,current_time
变量将会更新。
在上一次的代码中我们已经完成了定时器部分的代码。在本节课中,我们将初始化一些时间变量并在 LCD 上显示信息。以下是具体步骤:
添加初始转换代码:在模型中添加初始转换的实际代码部分。此处编写的代码将被复制到文件中,并在代码生成时执行。
设置 current_time
的初始值:
set_curr_time
,用于设置 current_time
的值。void
,并将访问权限设为公共。new_curr_time
,类型为 uint32_t
。编写 set_curr_time
函数的代码:
current_time
变量会被 ISR 共享,在修改前需先禁用中断,修改完后再重新启用。SREG
寄存器(状态寄存器)的 Bit 7
控制全局中断。SREG
状态,禁用中断,然后设置新的 current_time
值,再恢复 SREG
状态。uint8_t save_sreg = SREG; // 保存状态寄存器状态
cli(); // 禁用中断
Clock_Alarm_curr_time = new_curr_time; // 设置新的 current_time 值
SREG = save_sreg; // 恢复状态
将代码添加到模型中:将 set_curr_time
函数代码粘贴到模型中的相应位置。
定义初始值:
set_curr_time
函数,将 current_time
设为 INITIAL_CURR_TIME
。alarm_time
为 INITIAL_ALARM_TIME
。time_mode
初始值为 12H
模式。alarm_status
初始设为 ALARM_OFF
。在 SM.h 中创建宏和枚举:
time_mode
枚举,包含 MODE_24H
和 MODE_12H
。alarm_status
枚举,包含 ALARM_OFF
和 ALARM_ON
。INITIAL_CURR_TIME
和 INITIAL_ALARM_TIME
,初始时间可自定义。#define INITIAL_CURR_TIME (10 * 3600 + 10 * 60 + 10) * 10 // 初始时间:10 小时 10 分钟 10 秒
#define INITIAL_ALARM_TIME (8 * 3600) // 初始闹钟时间:8 小时
生成代码:保存并生成代码。在生成的代码中查看初始转换函数的实现,确保所有初始化代码都已生成。
在下节课中,我们将实现如何将这些时间信息显示在 LCD 上。
在上一次课程中,我们完成了初始转换操作的代码。在本节课程中,我们将为 Ticking
状态编写代码,以在 LCD 显示当前时间。
设置 Entry 动作:
Ticking
状态时,我们需要显示当前时间。Entry
和 Exit
部分。在 Entry
部分添加实际代码。调用 display_curr_time
函数:
display_curr_time
函数用于将当前时间转换为字符串,并显示在 LCD 上。ClockAlarm_SM_TODO.cpp
文件,其中包含显示时间的各种辅助函数和代码片段。display_curr_time
函数首先从 current_time
中获取当前时间,将其转换为字符串格式并显示。display_curr_time
函数实现获取当前时间:
get_current_time
函数获取当前时间,并存储在 time24h
变量中。转换为字符串:
integertime_to_string
辅助函数,将整数时间转换为字符串格式(hh:mm:ss
)。GET_HOUR
、GET_MIN
、GET_SEC
宏分别提取小时、分钟和秒数。显示时间:
display_write
辅助函数,将格式化好的时间字符串显示在 LCD 上指定的行和列位置。display_write
函数设置 LCD 光标位置并打印字符串。display_curr_time
函数在模型中添加 display_curr_time
函数:
me
指针。void
,并添加行列参数 row
和 column
(uint8_t
类型)。将代码粘贴到 display_curr_time
函数,并生成代码。
在 Ticking
状态的 Entry 动作中调用 display_curr_time
:
me
指针,并提供行列参数。Ticking
状态下时间显示的行和列,例如:#define TICKING_CURR_TIME_ROW 0
#define TICKING_CURR_TIME_COL 3
生成代码:确保所有代码生成正常,并将 display_curr_time
中调用的辅助函数作为独立的自由操作来实现。
在 ClockAlarm_SM_TODO.cpp
文件中提供了完整的代码示例,请根据需要将辅助函数整合到项目中。
为了加快自由操作函数的添加流程,以下是详细步骤:
SM.cpp
复制所有自由操作代码:
ClockAlarm_SM_TODO.cpp
文件中复制出来。在 SM.cpp
中粘贴代码:
SM.cpp
文件,确保不要修改标记(marker)之间的代码区域。在文件开头,标记区域中会提示“请勿编辑标记之间的代码”。添加函数原型:
生成代码:确保所有代码生成正常,保存并编译项目。
在下一节课程中,我们将在 LCD 上测试显示效果,以确保所有操作按预期进行。
在 display_curr_time
函数中,我们使用未实现的函数来获取当前时间。
编写代码:
get_curr_time
函数中,直接返回当前时间变量。步骤:
temp
。temp
。get_curr_time
的实现与功能一致。保存并生成代码:
get_curr_time
函数中粘贴代码后,保存并生成代码。在下一节课程中,我们将在 LCD 上测试此代码。测试前,我们还需添加 LCD 初始化代码,具体操作将在下一节中完成。
在这一讲中,我们将向状态机发送 TICK
事件,并使用实时数据更新显示屏。
创建内部转换:
Ticking
状态下绘制内部转换。TICK
,并确保目标保持为“内部”,即事件不引发状态改变。定义事件行为:
TICK
事件选择 display_curr_time
函数,以便在接收到事件时更新显示。在主程序中定义变量:
tick_time
并初始化为 millis()
。在循环中发送 TICK 事件:
tick_time
是否达到 50 毫秒,如果是,则发送 TICK
事件。Q_SIG
的值为 TICK_SIG
并调用 QHSM_DISPATCH
,实现事件分发。重置 tick_time
:
tick_time
重置为 millis()
。定位代码:
修复问题:
TCCR1B
寄存器的初始值为 0,确保符合数据表描述。display_curr_time
中的逻辑,先提取子秒字段,再将其转换为秒数。测试硬件:
完成以上步骤后,我们将在下一讲中继续讲解。
在本讲中,我们将详细讲解 Clock_Setting
状态的结构。在 Clock_Setting
中,用户可以通过按钮设置时、分、秒的各个位数。
Ticking
。SET
信号时,将从 Ticking
状态转移到 Clock_Setting
状态。SET
和 OK
事件用于在时钟设置中导航和修改时间字段。先前已经讨论了按钮去抖动的实现,可以复用该函数来发送 SET
和 OK
信号。在 Clock_Setting
状态中,我们将为小时、分钟和秒的各个位数定义子状态。
创建子状态:
cs_hour_digit1
,用于修改小时字段的第一个数字(个位)。cs_hour_digit2
。cs_hour_digit1
设为初始子状态。定义内部转换:
SET
信号时,应当执行内部转换以修改当前位的数值。确保 SET
定义为内部转换而非外部转换。SET
,在每次接收到 SET
时,更新当前数字。定义 OK 转换:
OK
时,应从当前位转移到下一个位。cs_hour_digit1
中接收到 OK
时,将进入 cs_hour_digit2
。OK
转换,将设置顺序推进到分钟位和秒位。Clock_Setting
状态的其余转换。
请根据以上指导完成 Clock_Setting
的其余状态转换图,下一讲我们将继续讨论如何实现该功能。
在这一讲中,我们详细介绍了 Clock_Setting
状态的子状态结构,并为各个字段的每个位(小时、分钟、秒)配置了修改逻辑。
SET
信号,则进入 Clock_Setting
状态。子状态的划分:
cs_hour_digit1
:修改小时的第一位(十位)。cs_hour_digit2
:修改小时的第二位(个位)。初始子状态和内部转换:
Clock_Setting
状态中,定义初始子状态和相应的内部转换。SET
信号的内部转换来修改当前位的数值。OK
信号在字段之间导航。伪代码实现要点:
SET
转换操作中,将 current_time
复制到 temp_time
。Clock_Setting
状态后,显示 temp_time
并打开光标和闪烁效果。SET
信号修改相应位的数值(如 temp_time
的小时字段的个位和十位)。代码示例:
使用伪代码定义转到 Clock_Setting
状态时的操作:
// Clock_Setting 入口操作
display_clock_setting_time(me, CLOCK_SETTING_TIME_ROW, CLOCK_SETTING_TIME_COL);
display_cursor_on_blinkon();
光标设置:
display_set_cursor(me, CLOCK_SETTING_TIME_ROW, CLOCK_SETTING_TIME_HOUR_D1_COL);
临时字段更新和显示:
me->temp_digit = DIGIT1(GET_HOUR(me->temp_time));
me->temp_digit++;
me->temp_digit %= 3;
me->temp_time -= (DIGIT1(GET_HOUR(me->temp_time)) * 10 * 3600);
me->temp_time += (me->temp_digit * 10 * 3600);
display_clock_setting_time(me, CLOCK_SETTING_TIME_ROW, CLOCK_SETTING_TIME_COL);
display_set_cursor(me, CLOCK_SETTING_TIME_ROW, CLOCK_SETTING_TIME_HOUR_D1_COL);
TODO:
完成字段和位的设置逻辑后,我们将继续测试硬件配置,确保显示和更新逻辑的正确性。在测试完成后,我们会进一步扩展到格式设置等功能。
在这一讲中,我们进行了 Clock_Setting
状态的进一步实现,解决了部分清理(cleanup)和退出(EXIT)操作的问题,并完成了临时时间设置的功能。
更改位数逻辑:
SET
按钮可以更改相应字段(小时、分钟、秒)的第一位和第二位。mod
运算确保数值范围(例如小时的十位只能为 0
到 2
)。temp_time
中,并立即更新显示。清理与退出操作:
Clock_Setting
状态返回到 Ticking
状态时,LCD 没有完全清理。Clock_Setting
及其父状态的 EXIT
操作中调用 display_clear
函数来解决这一问题。display_clear
使用 lcd_clear
函数清空显示。解决代码生成冲突:
Clock_Alarm_display_clock_setting_time
函数设置正确的前缀。SET
按钮可以切换并修改小时、分钟和秒字段的个位和十位,更新值会自动在LCD上显示。OK
按钮可以切换到下一个字段。Ticking
状态,同时时间显示不会中断。在下一个讲解中,我们将:
Clock_Setting
中的格式转换(12H/24H)。在本讲中,我们为时钟设置模式下的格式选择(AM、PM、24H)和无效时间输入的错误处理功能编写了代码。
时钟设置格式:
temp_format
变量显示当前时间格式。SET
信号后,循环切换时间格式(AM、PM、24H)。temp_format
用于存储用户选择的格式。使用选择状态实现条件判断(Guard Condition):
OK
转换的条件,我们使用选择状态(Choice State)附加了条件判断。is_time_set_error
根据 temp_time
和 temp_format
验证输入时间的有效性。如果时间有效,OK
信号会传播到超状态以退出时钟设置模式;如果无效,则会转移到错误状态。错误状态:
err_on
和 err_off
切换显示和清除错误消息,创建闪烁效果。TICK
信号,timeout
变量递增,当达到 500ms 时,显示在 err_on
和 err_off
状态之间交替切换。SET
键,状态机将返回到 Clock_Setting
,允许用户重新设置时间。辅助函数:
display_erase_block
:通过用空格替换字符来清除 LCD 上的特定块。is_time_set_error
:基于特定规则验证输入时间的有效性,例如检查 hour
是否在所选格式的有效范围内。Clock_Setting
和 error
状态的退出操作中添加了 display_clear
,以确保在退出时清理显示。在下一讲中,我们将进一步完善错误处理,并在状态机的不同部分中管理 timeout
变量,以处理用户输入的任何不一致。
请确保在硬件上测试这些步骤以验证完整功能。
timeout
属性与时钟设置错误处理本讲的目标是完善时钟设置模式中的格式选择(AM、PM、24H)和无效输入的错误处理功能。我们还将添加 timeout
属性,用于控制显示消息的闪烁间隔。
timeout
属性timeout
属性,类型为 uint8_t
,用于记录时间计数。Clock_Setting
超状态下定义初始状态,以确保进入时计数从零开始。display_cursor_off_blinkoff
函数,关闭光标的闪烁效果。display_erase_block
函数来清除错误消息。OK
键,系统将忽略该操作,不退出错误状态。SET
键时,系统将重新进入时钟设置模式,以便用户重新输入有效的时间。OK
按键为内部转换,即虽然系统接收到 OK
信号,但不会触发任何操作,也不会传播到超状态。这确保了错误状态下的 OK
按键无效。SET
键后,系统会正确地返回到时钟设置模式,并从当前设置开始。这表明功能逻辑正常。在下一讲中,我们将更新当前时间以保存用户的设置,并处理其他潜在问题。
在本讲中,我们完善了时钟设置模块,使其能够将用户输入的时间更新到当前时间变量中,并进行了各种输入有效性测试,包括处理错误状态和更新显示格式。具体步骤如下:
在用户完成格式选择后,若输入无误,OK
信号将上浮至 settings
超状态,并在此状态中进行处理:
temp_time
转换为 24 小时制格式。我们通过一个帮助函数来完成此转换,以便将时间以 24 小时制格式存储到 current_time
变量中。time_mode
设置为 12 小时制或 24 小时制。temp_time
转换为以 100 毫秒为单位的计时格式,即 temp_time * 10
,然后将其赋值给 current_time
变量。current_time
是一个全局变量,修改前应禁用计时器中断以避免冲突。我们通过停止 TIMER1
并清零计数寄存器来实现。TCCR1B
寄存器的预分频器为 256,恢复计时器的时钟源,使计时器重新开始。将此代码块添加到 Settings
状态的 OK
信号处理逻辑中,同时调用 Clock_Alarm_set_curr_time
函数以更新当前时间。
在 settings
状态退出时,应关闭光标显示及闪烁效果。因此,我们在 settings
状态的 EXIT 函数中调用 display_cursor_off_blinkoff
函数,以确保用户退出时钟设置后,光标和闪烁效果已被禁用。
为了进一步完善错误状态逻辑,我们确保在错误状态下按下 OK
不会产生任何效果。用户需要按下 SET
以返回时钟设置并重新输入时间。
save_sreg
变量以解决中断寄存器保存问题,然后生成代码并编译。23:59
并切换 24 小时制)。26:00
或 AM/PM 信息错误),确认系统进入错误状态并在 OK
下无响应,SET
触发返回设置界面。至此,时钟设置的时间更新和错误处理已经完善。在下一讲中,我们将实现闹钟设置功能,进一步丰富该应用的功能。
现在的任务是实现 Alarm_Setting
(闹钟设置)状态。这一部分的功能与之前实现的 Clock_Setting
状态相似,因此可以复用相同的子状态结构。以下是具体的实现要求:
Alarm_Setting
状态时,界面上方的第一行用于显示并允许用户调整闹钟时间,下方第二行则显示当前的实时时间,用户可以通过按键设置闹钟的小时、分钟信息。Alarm_Setting
状态中的内部状态与 Clock_Setting
中的结构类似,可以通过嵌套子状态来分别处理小时和分钟的每个数字设置。由于 Alarm_Setting
和 Clock_Setting
状态结构类似,理论上可以将 Clock_Setting
状态抽象为一个“子状态机”(Sub-machine)来复用。这需要使用子状态机来简化代码和逻辑,但该功能在免费版工具中不可用,需获得商业授权许可。
子状态机的作用:
Clock_Setting
状态中的小时和分钟子状态结构可被提取为一个子状态机模块,从而在 Alarm_Setting
状态中直接复用。如何创建子状态机(商业许可情况下):
Clock_Setting
状态,选择 “Add Sub-Machine from State” 创建子状态机。Alarm_Setting
状态中,添加此子状态机的实例,从而复用 Clock_Setting
的子状态结构。由于此功能需要商业版授权,此处仅作示范。如果有机会获得商业版,可以参阅用户手册以了解更多子状态机的使用。
Alarm_Setting
状态中,重复 Clock_Setting
状态的子状态结构,实现小时和分钟的设置功能。alarm_time
变量。请完成以上任务,下一讲将进行代码测试与验证。
在上一讲中,我给大家布置了实现 Alarm_Setting
状态的作业,希望大家已经完成了。在本讲中,我会详细解释该状态的实现,并讲解我所做的一些调整。
在 Clock_Setting
状态中,当处于 cs_format
状态时,如果 OK
信号到达且 is_time_set_error
函数返回 false
(即没有错误),那么 OK
信号会被传递给上级状态 settings
。在 settings
状态中会更新 current time
变量并进行状态转换。然而,这种实现并不合适,因为 Clock_Setting
状态和 Alarm_Setting
状态在接收到最终 OK
信号时需要采取不同的动作。
为了解决这个问题,我将代码从 settings
状态的 OK
信号处理移到 Clock_Setting
的内部过渡中。这样,每当最终 OK
信号到达 Clock_Setting
状态时,可以在此处执行更新 current time
的操作,而在 Alarm_Setting
状态中则执行不同的操作。
在 Alarm_Setting
状态中,我复用了 Clock_Setting
的子状态结构,只是修改了状态名称以避免命名冲突。例如,cs_hour_d1
变成 as_hour_d1
。其余逻辑基本相同。
在 alarm_on_off
状态中,用户可以选择是否启用闹钟。当用户在此状态按下 SET
按钮时,可以在“ALARM ON”和“ALARM OFF”之间切换。
else
分支实现无动作在模型中,当我们处理 OK
信号且 is_time_set_error
返回 false
时,我使用了一个虚拟的 else
分支来确保 OK
信号传递至 settings
状态。具体做法是使用 choice
伪状态,在其上添加一个 else
分支,以确保条件失败时 OK
信号可以被上级状态捕获。
在 Alarm_Setting
状态中,每次 TICK
信号到达时,都会更新当前时间并显示在第二行,同时在第一行显示闹钟时间,游标会在当前需要设置的数字位置闪烁。我们使用 QHsm_state
宏来获取当前状态,并将其与各子状态的地址进行比较,以决定游标的位置。
以下是关键代码的片段,帮助大家理解具体实现:
// 设置闹钟状态中 OK 信号的处理
void Alarm_Setting_OK_Handler() {
if (me->temp_format != FORMAT_24H) {
// 如果格式不是 24 小时制,则将时间转换为 24 小时制
convert_12h_to_24h(&me->temp_time);
}
// 更新闹钟时间
me->alarm_time = me->temp_time;
me->alarm_status = me->temp_digit == 1 ? ALARM_ON : ALARM_OFF;
}
// TICK 信号处理,用于显示当前时间和更新游标位置
void Alarm_Setting_TICK_Handler() {
display_current_time(1, 0); // 在第二行显示当前时间
if (QHsm_state(&me->super) == as_hour_d1) {
display_set_cursor(0, 0); // 游标设置在小时的第一位
} else if (QHsm_state(&me->super) == as_hour_d2) {
display_set_cursor(0, 1); // 游标设置在小时的第二位
}
// 其他状态的游标位置设置
}
在下一讲中,我们将讨论如何实现 Alarm_Notify
状态,以处理闹钟响起的通知。请确保已完整实现 Alarm_Setting
状态,并理解其中的逻辑。
在本讲中,我们将了解如何在本项目中显示闹钟通知。为此,我们使用一个单独的状态 Alarm_Notify
。在此状态中,每隔 500 毫秒会有一个闪烁的闹钟信息。以下是具体的实现细节。
Alarm_Notify
状态中,使用一个名为 timeout
的变量。TICK
事件时,增加 timeout
变量的值,并通过一个条件监视 timeout
的值。timeout
等于 10 时(即 10 次 50 毫秒的 TICK,约为 500 毫秒),状态机会转到 Alarm_Notify
的另一个状态,并在该状态的入口动作中清除闹钟信息。这种状态切换就可以实现闪烁效果。OK
按钮,状态机会转到 Clock
状态的历史状态(即在闹钟触发时所在的状态)。OK
按钮,闹钟通知不应一直显示,因此设定一个 alarm_timeout
变量,用来监控通知的显示时间。每次收到 TICK
事件时,增加 alarm_timeout
值,当其值达到 200 时(约 10 秒),则自动退出 Alarm_Notify
状态,返回 Ticking
状态。闹钟事件由 main.cpp
中的循环函数定期触发,每隔 500 毫秒检查是否需要触发闹钟。检查的条件为 alarm_time
是否等于 current_time
且 alarm_status
是否为 ON。如果满足条件,则触发闹钟事件,进入 Alarm_Notify
状态。
以下是关键代码的片段:
// 进入 Alarm_Notify 状态时的操作
void Alarm_Notify_Entry_Action() {
display_write("Alarm!", ROW, COL); // 显示闹钟信息
timeout = 0; // 初始化 timeout 计数器
}
// TICK 信号处理
void Alarm_Notify_Tick_Handler() {
display_current_time(); // 显示当前时间
timeout++; // 增加 timeout
alarm_timeout++; // 增加 alarm_timeout
if (timeout == 10) {
// 切换到清除闹钟信息状态
display_clear_alarm_message();
timeout = 0;
}
if (alarm_timeout == 200) {
// 超时,退出 Alarm_Notify 状态,返回 Ticking 状态
transition_to_ticking();
}
}
// OK 信号处理
void Alarm_Notify_OK_Handler() {
// 进入 Clock 状态的历史状态
transition_to_history_of_clock();
}
在测试过程中,发现当用户在 Alarm_Setting
状态下进入错误状态(Error)时,可能会触发一个 assertion failure
错误。原因在于状态嵌套的深度超过了默认允许的最大值(5),导致状态机算法的 ip
值超出限制。
QHSM_MAX_NEST_DEPTH
的值增加到 6。// 在 qepn.h 中将最大嵌套深度增加到 6
#define QHSM_MAX_NEST_DEPTH 6
OK
按钮,确认返回到闹钟触发前的状态。在本讲中,我们实现并测试了闹钟通知的显示效果及其相关逻辑。所有源代码和模型文件已上传至 Git 仓库,可以根据需要参考代码。
在本讲中,我们将探讨如何在应用程序中使用 Active Object(活动对象)设计范式,并逐步了解其应用。首先,我们来讨论在当前应用程序中可能存在的一些设计问题,并探讨如何通过 Active Object 范式进行优化。
TICK
事件)时漏掉按钮事件,因为轮询无法及时响应用户按键操作。为了避免漏掉事件并保持 RTC 语义,我们可以引入一个事件队列。
如果应用程序包含多个状态机,每个状态机有自己的事件队列,允许各个对象在独立的线程中并行处理事件。
在 QP 框架中,Active Object 是一个拥有自己线程控制的对象。各对象的状态机行为完全由其当前状态决定,不受其他对象的影响。Active Object 设计范式中的特征包括:
QP 框架提供了两种事件传递方法:
qv
协作内核QP 框架提供了一个简单的协作内核(qv kernel),实现了一个巨大的循环来遍历处理 Active Object。内核根据优先级依次处理不同对象的事件队列。
我们将之前的 Clock_Alarm
类转换为一个 Active Object,并新增一个名为 BUTTON
的 Active Object,用于处理按钮事件。此外,我们将 Clock_Alarm
设计为正交状态机,并在其内部实现两个独立的区域 r1
和 r2
,其中 r1
表示时钟相关状态,r2
表示闹钟相关状态。两个区域独立运行并响应相同的事件。
Clock_Alarm
状态中,r1
和 r2
区域将并行处理 TICK
事件,实现并行的独立功能。这种设计有助于在不影响其他状态的情况下处理闹钟通知。通过 Active Object 设计范式,我们可以:
在接下来的课程中,我们将通过实际代码实现上述设计。
在本节课中,我们将深入探讨正交组件状态模式,并了解如何使用这种设计模式在状态机中实现正交区域。通过这种模式,我们将使用容器(Container)和组件(Component)之间的关系,以实现强聚合(Composition)——一种类的组合技术。
正交组件状态模式用于实现正交区域(Orthogonal Regions),即状态机中可并行的独立子状态区域。我们可以将应用中的独立组件识别出来,并通过类的组合来实现。组合(Composition)是面向对象编程(OOP)中的一种强聚合关系,适用于容器和组件的关系。
组合是一种强聚合关系,意味着组件的生命周期依赖于容器的生命周期:
在我们的项目中,我们可以通过这种组合关系,将 Clock_Alarm
类设计成容器,并将 Alarm
设计成它的组件。Clock_Alarm
类和 Alarm
类将通过组合技术连接在一起:
Clock_Alarm
的状态机将会有两个区域:一个是 Clock
区域(r1),另一个是 Alarm
区域(r2)。Alarm
作为 Clock_Alarm
的组件,从而实现独立的区域逻辑。在这种容器和组件的架构中,容器和组件之间的通信可以分为同步和异步两种方式。
容器到组件的通信:容器状态机可以直接调用组件的调度(dispatch)函数,将事件传递给组件。在这种情况下,容器会等待组件完成当前事件的处理(即 RTC 步骤),然后再继续自身的 RTC 步骤。由于容器必须等待组件完成 RTC,这种通信称为同步通信。
组件到容器的通信:组件在容器的线程上下文中运行,因此组件不能直接调用容器的调度函数,否则会破坏 RTC 语义。相反,组件可以将事件发布到容器的事件队列中,当容器的 RTC 步骤完成后,容器会处理事件队列中的事件。
我们将在项目中实现以下设计:
Clock_Alarm
作为一个容器类,它包含两个并行区域 Clock
和 Alarm
。Alarm
组件作为 Clock_Alarm
的组成部分,并为它实现独立的状态机逻辑。Clock_Alarm
和 Alarm
之间的通信。Clock_Alarm
和 Button
类创建各自的事件队列,以独立管理事件。008ClockAlarm_AO
,我们将使用 Active Object 和正交组件状态模式来实现。QM
的文件夹,并下载附带的 QM 模型文件,放置在该文件夹中。在下一节课中,我们将进一步解释模型文件的内容及其使用方式。在下一节课中,我们将继续设置该项目,并深入代码实现细节,逐步完成正交组件状态模式的实际编写。
在本节课中,我们简化了之前的 Clock_Alarm
状态机模型,并介绍了如何使用变量来区分时钟设置和闹钟设置。以下是具体步骤和修改说明:
在之前的模型文件 007
中,Clock_Alarm
类包含了多个独立的设置状态(如 Clock_Setting
和 Alarm_Setting
)。然而,这样的复杂设计并不总是必要的。我们可以使用一个通用的 setting
状态,并通过变量 curr_setting
来区分是时钟设置还是闹钟设置。
在新的模型文件 008
中,Clock_Alarm
类只包含一个 setting
状态。具体修改如下:
curr_setting
,用于标识当前的设置模式。SET
按钮时,将 curr_setting
设置为 CLOCK_SETTING
。OK
按钮时,将 curr_setting
设置为 ALARM_SETTING
。在 settings
状态中,根据 curr_setting
的值确定操作的执行内容:
curr_setting
是 CLOCK_SETTING
,则更新当前时间。curr_setting
是 ALARM_SETTING
,则更新闹钟时间。setting
状态下包含小时、分钟、秒钟以及时钟格式的设置。无论是时钟还是闹钟设置,错误状态都共用一个通用的 error
状态。这样设计更为简洁和高效。
在 clock_format
设置时,当用户按下 OK
时,根据设置值执行不同的操作:
error
状态显示错误信息。curr_setting
是 ALARM_SETTING
,则转到 alarm_on_off
状态。在新模型文件中,移除了 Clock_Alarm
类中的闹钟相关属性,如 alarm_time
和 alarm_status
,因为这些属性将成为闹钟组件的一部分。这使得 Clock_Alarm
更专注于时钟设置,而闹钟设置将由独立的组件来管理。
在下一节课中,我们将继续创建两个活动对象(Active Objects):Clock_Alarm
和 Button
。同时,我们还将创建 alarm
组件,演示如何在容器对象和组件对象之间传递事件,实现更高效的正交组件模式。
我们还将介绍事件从容器对象传递到组件对象,以及从组件对象传递回容器对象的方法。
在本节课中,我们将 Clock_Alarm
类转换为活动对象(Active Object)并创建 Button
活动对象和 Alarm
组件类。以下是具体步骤:
Clock_Alarm
类转换为活动对象Clock_Alarm
类。QHSM
更改为 QActive
,以将其转换为活动对象。Clock_Alarm
类成为一个活动对象类,QActive
提供了事件队列和上下文管理的方法,使我们可以向事件队列中发布事件。Button
活动对象Button
,并将超类设置为 QActive
。Button
活动对象。Alarm
组件类Alarm
,并将超类设置为 QHsm
。Alarm
是 Clock_Alarm
的一个组件,因此我们使用类的组合来实现这种关系。这意味着 Clock_Alarm
作为容器类包含 Alarm
作为其组件。Clock_Alarm
中创建 Alarm
组件的实例Clock_Alarm
类,添加一个属性,命名为 alarm
,类型为 Alarm
,可见性为 private
。Clock_Alarm
的实例创建时,Alarm
组件也会被创建。Clock_Alarm
和 Button
的全局指针:
AO_ClockAlarm
,类型为 QActive *const
,可见性为 global
。AO_Button
,类型为 QActive *const
,可见性为 global
。super
属性。例如:
AO_ClockAlarm
的初始化器为 =& Clock_Alarm_obj.super
。AO_Button
的初始化器为 =& Button_obj.super
。Button
创建静态对象实例Button
类中创建一个名为 obj
的静态实例,以便用于全局指针初始化。obj
的类型为 button
,选择 static
,可见性为 private
。在下一节课中,我们将为 Button
活动对象和 Alarm
组件创建构造函数,并实现其初始化流程。
在本节课中,我们将为 Clock_Alarm
、Button
和 Alarm
类创建构造函数(ctor),并更新 Clock_Alarm
的构造函数代码。具体步骤如下:
Clock_Alarm
构造函数代码Clock_Alarm
类中的构造函数。QHsm_ctor
,但现在由于 Clock_Alarm
已是活动对象,因此我们应使用 QActive_ctor
。QActive_ctor
,并传递初始状态处理程序的地址。代码示例如下:
QActive_ctor(&AO_ClockAlarm->super, Clock_Alarm_initial);
Button
创建构造函数Button
类,添加一个全局函数操作,命名为 Button_ctor
,返回类型为 void
。Clock_Alarm
构造函数,使用 QActive_ctor
初始化 Button
的超类。代码示例如下:
QActive_ctor(&AO_Button->super, Button_initial);
Button
添加状态机。Alarm
创建构造函数Alarm
类,添加一个全局函数操作,命名为 Alarm_ctor
,返回类型为 void
。Alarm
继承自 QHsm
,因此应使用 QHsm_ctor
初始化。构造函数代码示例如下:
QHsm_ctor(me, Alarm_initial);
Alarm_ctor
添加一个参数 me
,类型为 Alarm * const
,以便从 Clock_Alarm
容器传递 Alarm
的地址。在下一节课中,我们将绘制 Button
和 Alarm
的状态机,并在 Alarm
构造函数中完成初始化步骤。完成上述内容后,继续进行,课程内容将逐步深入实现项目的完整功能。
在本节课中,我们将生成代码并进行一些文件和路径的配置,以便设置 ClockAlarm
和 Button
的状态机及其关联的组件 Alarm
。以下是具体的步骤:
source
文件夹后,可以看到多个文件:
ClockAlarm_SM.cpp
:实现 ClockAlarm
活动对象的状态机。ClockAlarm_SM.h
:ClockAlarm
的头文件,新增了信号 ALARM_SIG
和 ALARM_CHECK_SIG
。alarm.cpp
和 alarm.h
:分别用于实现 Alarm
组件的状态机及其头文件。Button_SM.cpp
和 Button_SM.h
:实现 Button
活动对象的状态机及其头文件。ClockAlarm_SM.h
中还新增了一个枚举 setting
,包含 NO_SETTING
、CLOCK_SETTING
和 ALARM_SETTING
。alarm.cpp
alarm.cpp
,并包含所需的头文件:
#include "Arduino.h"
#include "qpn.h"
#include "ClockAlarm_SM.h"
$define
代码生成指令定义 Alarm
类,并定义构造函数 Alarm_ctor
。button_SM.cpp
button_SM.cpp
,并包含以下头文件:
#include "Arduino.h"
#include "qpn.h"
#include "Button_SM.h"
alarm.h
,因为不需要此头文件。Button
类并添加 Button_ctor
构造函数。button.h
中声明 Button_ctor
。AOs
,以匹配当前项目结构。ClockAlarm_SM.cpp
和 button_SM.cpp
中,使用 $define
代码生成指令定义全局指针变量 AO_ClockAlarm
和 AO_Button
,并确保在各自的头文件 ClockAlarm_SM.h
和 Button_SM.h
中声明这些全局变量。platformio.ini
和库文件007
)中的 platformio.ini
配置,并粘贴到当前项目(008
)的 platformio.ini
文件中。lcd.cpp
和 lcd.h
文件到当前项目的 source
文件夹中,以确保所有必要的库文件可用。完成上述配置后,我们将在下一节课继续调整和实现剩余的功能代码。请确保所有文件和路径正确配置,生成的代码无误,然后继续进行下一步的实现。
main.c
的代码实现在本节课中,我们将配置 main.c
文件,以适配两个活动对象 Clock_Alarm
和 Button
,并设置按钮中断。以下是具体步骤:
main.cpp
和 main.h
文件,并粘贴到当前项目的 src
文件夹。main.cpp
中,移除不再需要的内容:
button_pad_value
函数,因为我们现在有独立的 Button
状态机,并将使用按钮中断。loop
函数中的代码,因为事件将通过活动对象的事件队列异步发送,不再需要轮询。setup
函数中不需要的内容,保留 display_init
。Clock_Alarm
类的构造函数,并添加对 Button
类构造函数的调用。button_SM.h
,这样可以调用 Button_ctor
函数。Clock_Alarm
类中的组件构造函数,确保引入 alarm.h
,并在 Clock_Alarm_ctor
中调用 Alarm_ctor
。Clock_Alarm
的构造函数中,调用 Alarm_ctor
。代码如下:
Alarm_ctor(&Clock_Alarm_obj.alarm);
Clock_Alarm
构造函数进行修改,添加上述代码,然后保存并生成代码。Alarm_ctor
调用 QHsm_ctor
,并将初始状态处理函数 Alarm_initial
的地址赋给状态变量。QHSM_INIT
来执行 Alarm_initial
函数,从而执行初始转换动作。Clock_Alarm
的初始状态转换中调用组件的 init
函数 Alarm_init
。init
,并在其中调用 QHSM_INIT
:
QHSM_INIT(&me->super);
Alarm_init
函数,在初始转换中调用此函数以初始化 Alarm
组件。Button_ctor
调用,并实现按钮的中断处理程序,以便通过中断捕获按钮按下。attachInterrupt
实现按钮中断:
attachInterrupt(digitalPinToInterrupt(PIN_BUTTON1), SET_handler, RISING);
attachInterrupt(digitalPinToInterrupt(PIN_BUTTON2), OK_handler, RISING);
实现中断处理函数 SET_handler
和 OK_handler
,并通过事件队列向 Button
活动对象发送事件。
void SET_handler() {
// 将 SET 事件发送到 Button 活动对象的事件队列
}
void OK_handler() {
// 将 OK 事件发送到 Button 活动对象的事件队列
}
在完成这些设置后,下一节课中我们将继续实现剩余代码,配置事件队列并处理按钮中断事件。请确保所有文件正确配置并生成代码无误,然后继续进行下一步。
在本次课程中,我们将逐步实现 QP-Nano 活动对象框架的配置步骤,包括创建事件队列、初始化活动对象控制块等,适配 Clock_Alarm
和 Button
两个活动对象。以下是完整步骤:
在 main.cpp
中创建两个事件队列,分别用于 ClockAlarm
和 Button
:
QEvt ClockAlarmQueue[5];
QEvt ButtonQueue[5];
创建 QActiveCB
数组 QF_active
,并进行初始化。控制块用于管理每个活动对象的事件队列、对象指针及队列长度。
const QActiveCB QF_active[] = {
{ nullptr, nullptr, 0 }, // 空条目
{ reinterpret_cast<QActive*>(&AO_ClockAlarm), ClockAlarmQueue, Q_DIM(ClockAlarmQueue) },
{ reinterpret_cast<QActive*>(&AO_Button), ButtonQueue, Q_DIM(ButtonQueue) }
};
调用 QF_init
并传入活动对象数量,初始化框架:
QF_init(Q_DIM(QF_active));
在 loop
函数中调用 QF_run
,这是一个无限循环的调度程序:
void loop() {
QF_run();
}
在空闲时使处理器进入睡眠模式。可使用 QV_CPU_SLEEP
宏来调用:
extern "C" void QV_onIdle(void) {
QV_CPU_SLEEP();
}
在 main.h
中定义宏,以便为定时器设置周期性中断:
#define F_CPU 16000000UL
#define TICKS_PER_SECOND 1000
#define MS_PER_TICK (1000 / TICKS_PER_SECOND)
#define TIMER1_OC_MATCH_VALUE ((F_CPU * MS_PER_TICK) / 1000 / 1) // 1 为预分频
更新 Timer1_setup
函数为 sys_tick_init
并生成每 1 毫秒的中断:
void sys_tick_init() {
TCCR1B = (1 << WGM12) | (1 << CS10); // CTC 模式,无分频
OCR1A = TIMER1_OC_MATCH_VALUE;
TIMSK1 = (1 << OCIE1A); // 启用输出比较中断
}
在定时器中断服务程序中调用 QF_tickXISR(0)
,更新所有计时事件。
使用 attachInterrupt
为 SET
和 OK
按钮连接中断:
void attach_button_interrupts() {
attachInterrupt(digitalPinToInterrupt(PIN_BUTTON1), SET_handler, RISING);
attachInterrupt(digitalPinToInterrupt(PIN_BUTTON2), OK_handler, RISING);
}
在中断处理程序中,使用 QActive_armX
为按钮去抖动设置单次计时器事件:
void SET_handler() {
QF_INT_DISABLE();
if (report_button_press) {
report_button_press = 0;
QActive_armX(AO_Button, 0, MS_TO_TICKS(50), 0); // 单次计时器
}
QF_INT_ENABLE();
}
OK_handler
的实现与 SET_handler
类似。
Button
状态机在 button_SM.cpp
中定义 Button
状态机逻辑,处理去抖动完成后的按钮状态解码:
case Q_TIMEOUT: {
uint8_t value = button_pad_value();
if (value == BTN_PAD_VALUE_SET) {
QACTIVE_POST(AO_ClockAlarm, SET_SIG, 0);
} else if (value == BTN_PAD_VALUE_OK) {
QACTIVE_POST(AO_ClockAlarm, OK_SIG, 0);
} else if (value == BTN_PAD_VALUE_ABORT) {
QACTIVE_POST(AO_ClockAlarm, ABRT_SIG, 0);
}
report_button_press = 1;
}
完成这些配置后,下一节将实现 Alarm
状态机的代码,实现基于活动对象的异步消息处理和独立状态管理。
本次课程将实现 Alarm
组件的状态机,并将其集成到 Clock_Alarm
容器的状态机中,使得 Alarm
可以接收定期的时间检查信号,并在设定的闹钟时间到达时触发提醒。以下是具体步骤:
在 Alarm
类中创建状态机:
Alarm
,选择 Add State Machine,然后双击进入。ALARM
的状态,并添加一个内部过渡 ALARM_CHECK
。此 ALARM_CHECK
信号将由 Clock_Alarm
容器触发,容器定期将当前时间发送给 Alarm
组件,用于闹钟检查。
Alarm
组件需要提供一个 dispatch
函数,以便 Clock_Alarm
容器可以将 ALARM_CHECK
信号派发给它。步骤如下:
Alarm
类,添加 Operation,命名为 dispatch
,返回类型为 void
。dispatch
中调用 QHSM_DISPATCH
,使用指向 Alarm
类的 QHsm
超类的指针。void Alarm_dispatch(Alarm * const me, QEvt const * const e) {
QHSM_DISPATCH(&me->super, e);
}
在 Clock_Alarm
的 TICK
信号处理中,调用 Alarm_dispatch
函数来检查是否达到设定的闹钟时间:
ALARM_CHECK
信号和当前时间参数。Q_SIG
宏将信号传递给 Alarm
组件。Q_SIG(&me->alarm.super) = ALARM_CHECK;
Q_PAR(&me->alarm.super) = Clock_Alarm_get_curr_time() / 10; // 去除毫秒信息
Alarm_dispatch(&me->alarm, &me->alarm.super);
当 Alarm
组件接收到 ALARM_CHECK
信号时,执行以下步骤:
ALARM_SIG
信号给 Clock_Alarm
活动对象,通知闹钟事件发生。alarm_time
和 alarm_status
属性,并添加设置和获取方法。if (current_time == me->alarm_time) {
QACTIVE_POST(AO_ClockAlarm, ALARM_SIG, 0);
}
在 Alarm
类中添加 alarm_time
和 alarm_status
属性,以及设置方法:
alarm_time
和 alarm_status
属性,类型分别为 uint32_t
和 uint8_t
。set_alarm_time
和 set_status
函数,以设置闹钟时间和状态。void Alarm_set_alarm_time(Alarm * const me, uint32_t alarm_time) {
me->alarm_time = alarm_time;
}
void Alarm_set_status(Alarm * const me, uint8_t status) {
me->alarm_status = status;
}
在 Clock_Alarm
的 Settings
状态的 OK
信号处理处,根据 curr_setting
确定是时钟设置还是闹钟设置。如果是闹钟设置,则调用 Alarm_set_alarm_time
和 Alarm_set_status
:
if (me->curr_setting == ALARM_SETTING) {
Alarm_set_alarm_time(&me->alarm, me->temp_time);
Alarm_set_status(&me->alarm, me->temp_digit); // temp_digit 用于存储闹钟开关状态
}
在 Clock_Alarm
的初始转换中,为 Alarm
设置初始值,如默认的闹钟时间和状态。
到目前为止,我们已经实现了 Alarm
组件的状态机,并将其集成到 Clock_Alarm
的容器状态机中。这样,Clock_Alarm
可以定期触发 ALARM_CHECK
信号,Alarm
组件可以检查是否达到设定的闹钟时间,并在合适的时间发送 ALARM_SIG
通知。
在上一讲中,我们实现了 Alarm
状态机和 Button
状态机,但在 Clock_Alarm
状态机中还有一些链接和错误状态未完成。本节将完善 Clock_Alarm
状态机,优化定时功能,通过框架的 timeout
函数替代手动计时变量,从而简化代码结构。
在 Clock_Alarm
状态机的 Error
状态中,我们之前通过变量实现了错误消息的每 500 毫秒闪烁一次的功能。本次将用框架提供的 timeout
函数替代该逻辑:
Error
状态时,在入口动作中为定时器设置一个 500 毫秒的周期性定时。QActive_disarm
函数解除定时。// 在 Error 状态的入口动作中启动定时器
QActive_armX(AO_ClockAlarm, 0, MS_TO_TICKS(500), MS_TO_TICKS(500));
// 在 Error 状态的退出动作中解除定时
QActive_disarm(AO_ClockAlarm, 0);
TICK
信号的处理需要通知 Alarm
类当前时间,以便 Alarm
组件可以检查是否达到闹钟时间。将之前的通知代码放入 TICK
处理函数中。
Q_SIG(&me->alarm) = ALARM_CHECK;
Q_PAR(&me->alarm) = Clock_Alarm_get_curr_time() / 10; // 去除毫秒信息
Alarm_dispatch(&me->alarm, &me->alarm.super);
在 Alarm_Notify
状态中,我们通过变量实现了定时闪烁。本次同样用框架的定时功能替换变量:
Alarm_Notify
状态时启动定时器。Alarm_Notify
状态时解除定时。// 在 Alarm_Notify 状态的入口动作中启动定时器
QActive_armX(AO_ClockAlarm, 0, MS_TO_TICKS(500), MS_TO_TICKS(500));
// 在 Alarm_Notify 状态的退出动作中解除定时
QActive_disarm(AO_ClockAlarm, 0);
dispatch
函数Alarm
组件需要实现 dispatch
函数,使得 Clock_Alarm
可以调用该函数并将 ALARM_CHECK
信号派发给它。具体代码如下:
void Alarm_dispatch(Alarm * const me, QEvt const * const e) {
QHSM_DISPATCH(&me->super, e);
}
Clock_Alarm
中定期调用 Alarm_dispatch
在 Clock_Alarm
的状态机中,每次接收到 TICK
信号时调用 Alarm_dispatch
,并传递当前时间给 Alarm
组件以便检查闹钟时间:
Q_SIG(&me->alarm.super) = ALARM_CHECK;
Q_PAR(&me->alarm.super) = Clock_Alarm_get_curr_time() / 10;
Alarm_dispatch(&me->alarm, &me->alarm.super);
使用定时和选择节点处理 Error 状态和 Alarm Notify 状态中的过渡条件,代码如下:
// 使用选择节点控制状态过渡
if (me->timeout > 0) {
--me->timeout;
// 若非零,则进入下一个状态
return Q_RET_TRAN;
} else {
// 若零,则超时事件传递至父状态
return Q_RET_SUPER;
}
在 main.cpp
中删除不再需要的代码,并完善初始化和中断代码。
通过以上优化,我们使用框架的定时功能替代了手动计时变量,从而使代码更加简洁和高效。
我们的项目已经基本完成,但需要在 tick
中断服务程序 (ISR) 中做一个小的改动。
tick
ISR在 tick
ISR 中,每 100 毫秒调用一次 QF_tickXISR
函数来更新当前时间并发送 TICK
信号给 ClockAlarm
状态机。
TICK
事件。void TIMER1_COMPA_vect() {
static uint8_t tick_count = 0;
if (++tick_count == 100) {
// 每100毫秒更新当前时间
Clock_Alarm_set_curr_time();
// 向ClockAlarm状态机发送TICK事件
QACTIVE_POST_ISR(AO_ClockAlarm, TICK_SIG, 0);
tick_count = 0;
}
QF_tickXISR(0); // 调用QF_tickXISR
}
set_curr_time
函数在 set_curr_time
函数中,需要更新 TCCR1B
寄存器,确保定时器配置正确。
void Clock_Alarm_set_curr_time() {
// 设置 TCCR1B 寄存器
TCCR1B |= (1 << CS12) | (0 << CS11) | (0 << CS10);
// 更新当前时间的其他代码
}
完成代码更改后:
如果遇到问题,可以在课程的 Q&A 论坛中提出问题。希望这些步骤能够帮助你顺利完成项目!
使用 UML 状态机设计嵌入式系统 Embedded System Design using UML State Machines
01 - 简介 004 有限状态机简介
005 Mealy 和 Moore 机器
006 Mealy 和 Moore 状态转换表
007 练习-0001 LED 控制 Mealy 机器示例
008 练习-001 LED 控制 Mealy 机器实现部分 1
009 练习-001 LED 控制 Mealy 机器实现部分 2
010 练习-002 LED 控制 Moore 机器实现
02 - UML 扁平状态机及其实现 001 练习-003 生产力计时器演示
003 UML 简单状态和复合状态
004 UML 状态机的内部状态活动(entry、exit、do)
005 UML 状态机的转换类型
006 事件和信号
007 练习-003 状态和初始伪状态
008 练习-003 定义状态的 Entry 和 Exit 动作
009 练习-003 绘制状态转换
010 练习-003 实现 TIME_SET 状态
011 练习-003 实现 PAUSE 状态
012 练习-003 实现 STAT 状态
013 安装 Microsoft VS Code 和 PlatformIO 扩展
03 - 扁平状态机练习实现 001 练习-003 创建新项目
002 练习-003 数据结构说明
003 练习-003 定义初始转换函数
004 实现状态机的不同方法
04 - 嵌套 switch 技术实现状态机 001 练习-003 嵌套 switch 实现 FSM 第 1 部分
002 练习-003 嵌套 switch 实现 FSM 第 2 部分
003 练习-003 硬件连接
004 练习-003 实现事件生成代码
005 练习-003 分发时间滴答事件
006 按钮抖动解释
007 练习-003 按钮软件防抖实现
008 在 PlatformIO 项目中添加 Arduino 库
009 练习-003 实现 LCD 功能 第 1 部分
010 练习-003 实现 LCD 功能 第 2 部分
011 练习-003 辅助函数实现
012 练习-003 实现初始转换动作
013 练习-003 硬件测试
05 - 'C' 中的函数指针 001 'C' 中的函数指针
002 作为函数参数传递函数指针
06 - 状态处理器技术实现状态机 001 练习-004 使用状态处理器方法实现
07 - 状态表技术实现状态机 001 练习-004 状态表实现 FSM 第 1 部分
002 练习-004 状态表实现 FSM 第 2 部分
003 'C' 中的二维数组
004 练习-004 状态表实现 FSM 第 3 部分
005 练习-004 状态表实现 FSM 第 4 部分
08 - UML 分层状态机和 QP™ 框架 001 分层状态机 (HSM)
002 逐步完成和 QP™ 框架
003 下载 QP™ Nano Arduino 库
004 HSM 转换执行顺序测试
09 - UML HSM 转换执行顺序 001 练习-006 在 Arduino 上测试 HSM 转换执行顺序
002 在 QM 工具中添加文件
003 使用 QM 工具向文件添加代码
004 添加类属性
005 添加类操作
006 添加断言失败回调
007 QHSM_INIT() 和 QHSM_DISPATCH() API
008 练习-006 测试
009 练习-006 测试历史状态
10 - 使用 QM 工具的 UML HSM 练习 001 练习-007 闹钟简介
002 练习-007 闹钟演示
003 练习-007 使用的状态、信号和数据结构
004 练习-007 绘制 HSM
005 练习-007 添加主应用对象和构造函数
006 Atmega328p 计时器外设说明
007 Atmega328p 计时器寄存器和设置代码
008 练习-007 添加类操作
009 练习-007 定义初始转换动作
010 练习-007 为 TICKING 状态编写代码
011 练习-007 添加自由操作
012 练习-007 通过类操作读取 curr_time
013 练习-007 在 TICKING 状态处理 TICK 事件并测试
014 练习-007 绘制 CLOCK_SETTING 状态
015 练习-007 实现 CLOCK_SETTING 状态 第 1 部分
016 练习-007 实现 CLOCK_SETTING 状态 第 2 部分
017 练习-007 实现 CLOCK_SETTING 状态 第 3 部分
018 练习-007 实现 CLOCK_SETTING 状态 第 4 部分
020 练习-007 更新实时
021 练习-007 ALARM_SETTING 状态
022 练习-007 实现 ALARM_SETTING 状态
023 练习-007 实现 ALARM_NOTIFY 状态
11 - 活动对象 001 活动对象
002 正交状态模式
003 练习-008 实现 第 1 部分
004 练习-008 实现 第 2 部分
005 练习-008 实现 第 3 部分
006 练习-008 实现 第 4 部分
007 练习-008 实现 第 5 部分
009 练习-008 实现 第 6 部分
010 练习-008 实现 第 7 部分
011 练习-008 实现 第 8 部分
012 练习-008 实现 第 9 部分
01 - Introduction 004 Introduction to Finite State Machine 005 Mealy and Moore machine 006 Mealy and Moore State Transition Table 007 Exercise-0001 LED control Mealy machine example 008 Exercise-001 LED control Mealy machine implementation part 1 009 Exercise-001 LED control Mealy machine implementation part 2 010 Exercise-002 LED control Moore machine implementation
02 - UML Flat state machine and Implementation 001 Exercise-003 Productivity Timer demo 003 UML Simple and Composite states 004 UML state machine internal state activities(entryexitdo) 005 UML state machine types of Transitions 006 Events and Signals 007 Exercise-003 States and Initial Psuedostates 008 Exercise-003 Defining states Entry and Exit actions 009 Exercise-003 Drawing state transitions 010 Exercise-003 Implementing TIME_SET state 011 Exercise-003 Implementing PAUSE state 012 Exercise-003 Implementing STAT state 013 Installing Microsoft VS Code and PlatformIO extension
03 - Flat state machine exercise implementation 001 Exercise-003 Create new project 002 Exercise-003 Data structure explanation 003 Exercise-003 Defining initial transition function 004 Different approach to implement state machine
04 - Nested switch technique to implement State Machine 001 Exercise-003 Nested switch implementation of an FSM part 1 002 Exercise-003 Nested switch implementation of an FSM part 2 003 Exercise-003 Hardware connections 004 Exercise-003 Implementing event producer code 005 Exercise-003 Dispatching time tick event 006 Button bouncing explanation 007 Exercise-003 Button software de-bouncing implementation 008 Adding arduino Library to project in platformIO 009 Exercise-003 Implementing LCD functions Part 1 010 Exercise-003 Implementing LCD functions Part 2 011 Exercise-003 Helper function implementation 012 Exercise-003 Implementing initial transition actions 013 Exercise-003 Testing on hardware
05 - Function pointers in 'C' 001 Function pointers in C 002 Passing function pointers as function arguments
06 - State handler technique to implement State Machine 001 Exercise-004 Implementation using state handler approach
07 - State table technique to implement State Machine 001 Exercise-004 State table approach for implementation of an FSM part-1 002 Exercise-004 State table approach for implementation of an FSM part-2 003 2D arrays in C 004 Exercise-004 State table approach for implementation of an FSM part-3 005 Exercise-004 State table approach for implementation of an FSM part-4
08 - UML Hierarchical State Machines and QP™ framework 001 Hierarchical State Machines(HSMs) 002 Run-to-completion and QP™ framework 003 Download QP™ Nano Arduino library 004 HSM transition execution sequence testing
09 - UML HSM transition execution sequences 001 Exercise-006 Test HSM transition execution sequence on Arduino 002 Adding files in QM tool 003 Adding codes to files using QM tool 004 Adding a class attribute 005 Adding class operation 006 Adding assertion failure callback 007 QHSM_INIT() and QHSM_DISPATCH() APIs 008 Exercise-006 Testing 009 Exercise-006 Testing History state
10 - UML HSM exercise using QM tool 001 Exercise-007 Clock Alarm Introduction 002 Exercise-007 Clock Alarm demo 003 Exercise-007 States, Signals and Data structure used 004 Exercise-007 Drawing an HSM 005 Exercise-007 Adding main application object and constructor 006 Atmega328p Timer peripheral explanation 007 Atmega328p Timer registers and setup code 008 Exercise-007 Adding class operations 009 Exercise-007 Defining initial transition actions 010 Exercise-007 Coding for the TICKING state 011 Exercise-007 Adding free operations 012 Exercise-007 Reading curr_time through class operation 013 Exercise-007 Handling TICK event in TICKING state and testing 014 Exercise-007 Drawing CLOCK_SETTING state 015 Exercise-007 Implementing CLOCK_SETTING state part-1 016 Exercise-007 Implementing CLOCK_SETTING state part-2 017 Exercise-007 Implementing CLOCK_SETTING state part-3 018 Exercise-007 Implementing CLOCK_SETTING state part-4 020 Exercise-007 Updating real time 021 Exercise-007 ALARM_SETTING state 022 Exercise-007 Implementing ALARM_SETTING state 023 Exercise-007 Implementing ALARM_NOTIFY state
11 - Active Objects 001 Active Objects 002 Orthogonal state pattern 003 Exercise-008Implementation part 1 004 Exercise-008Implementation part 2 005 Exercise-008Implementation part 3 006 Exercise-008Implementation part 4 007 Exercise-008Implementation part 5 009 Exercise-008Implementation part 6 010 Exercise-008Implementation part 7 011 Exercise-008Implementation part 8 012 Exercise-008Implementation part 9