WangShuXian6 / blog

FE-BLOG
https://wangshuxian6.github.io/blog/
MIT License
45 stars 10 forks source link

使用 UML 状态机设计嵌入式系统 Embedded System Design using UML State Machines[进行中] #210

Open WangShuXian6 opened 2 days ago

WangShuXian6 commented 2 days ago

使用 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


WangShuXian6 commented 2 days ago

01 - 简介

004 有限状态机简介

课程内容:状态机基础

在本节课中,我们将深入理解什么是状态机(State Machine),也称为有限状态机(Finite State Machine,FSM)。状态机是一种软件计算模型,用于解决复杂的应用问题。状态机包含有限的状态,因此称为“有限”状态机。每个状态代表应用程序的不同情况,通过事件(即输入)触发状态间的转换。

状态机的基本概念

状态机的类型

状态机有多种类型,例如:

我们将在课程中进一步讨论不同类型的状态机。

使用状态机的优势

  1. 复杂场景建模:状态机可以有效描述应用程序中的不同情景和场景。
  2. 面向对象:状态机属于类,可用于建模反应性对象的生命周期。
  3. 可视化与分解:使用状态机图表(状态图)可以清晰地表示应用的流程,有助于非开发人员和开发人员的沟通。
  4. 松耦合:应用程序可以划分为多个状态机,每个状态机可以独立测试或复用。
  5. 易于调试和扩展:状态机使得问题的调试和扩展更为容易。

常见的状态机模型

  1. Mealy 状态机:输出取决于状态和输入。
  2. Moore 状态机:输出只依赖于当前状态。
  3. Harel 状态图:用于描述嵌套状态。
  4. UML 状态机:UML状态机模型基于 Harel 状态图,被广泛用于软件工程。

UML 状态机的工具

在市场上,有一些工具可以解析 UML 状态机图,并自动生成基础代码。这些工具包括:

在后续的课程中,我们将使用 Quantum Leaps 提供的 QP 框架和 QM 建模工具,来实现嵌套的层次状态机。

下节课内容

在接下来的课程中,我们将深入了解 Mealy 机器的原理,以及它与 Moore 机器的差异,同时探索 UML 规范用于绘制状态机图的标准。

005 Mealy 和 Moore 机器

课程内容:Mealy机与Moore机的区别

在本节课中,我们将深入了解 Mealy 机器Moore 机器 之间的区别。这两种状态机的区别在于 输出生成的方式

Mealy 机器

  1. 输出:Mealy 机器的输出依赖于 当前状态输入事件
  2. 输出位置:输出是在 状态转换过程中 生成的,而不是在状态内部。
  3. 符号表示:通常使用 输入/输出 的格式,例如,当状态从 state 1 转换到 state 2 时,输出会伴随转换产生。
  4. 示例:假设一个控制灯光的应用程序,包含 OffDimMedium BrightnessFull Brightness 四个状态。按下 ON 按钮时,灯光亮度会增加。输出行为(例如“调暗灯光”)是在转换过程中生成的。

Moore 机器

  1. 输出:Moore 机器的输出只依赖于 当前状态,而与输入事件无关。
  2. 输出位置:输出在 状态内部 生成,而不是在状态转换过程中。
  3. 符号表示:输出被称为 入口操作(Entry Actions),仅在进入状态时生成。
  4. 示例:对于相同的灯光控制应用,当状态机进入某一状态(如 Dim)时,输出行为会在进入该状态时产生,例如“调暗灯光”。

Mealy 机器与 Moore 机器的对比

实际应用

在下一节课中,我们将继续深入了解 UML 状态机图的绘制规范。

006 Mealy 和 Moore 状态转换表

课程内容:状态转换表和 Harel 状态图

状态转换表(以灯光控制应用为例)

  1. Mealy 机器的状态转换表

    • 多列输出:由于 Mealy 机器的输出取决于当前状态和输入事件,因此对于每个事件都有一列输出。
    • 如何读取表格:表格中的每一行代表当前状态和输入事件组合时的输出以及下一个状态。例如,当灯光处于 Off 状态并收到 ON 事件时,灯光变为 Dim 状态,输出为“调暗灯光”。如果是 Off 状态接收 Off 事件,则无输出,状态保持不变。
  2. Moore 机器的状态转换表

    • 单列输出:因为 Moore 机器的输出仅依赖于当前状态,因此只需要一列来表示输出。
    • 如何读取表格:每一行表示进入该状态时执行的操作。例如,当进入 Off 状态时,执行“关闭灯光”的入口操作,并等待输入事件。如果接收到 ON 事件,则转移到 Dim 状态;如果接收到 Off 事件,则忽略。

Harel 状态图

  1. 概述:Harel 状态图由 David Harel 在 1984 年的论文 “A Visual Formalism for Complex Systems” 中提出。

    • Harel 状态图扩展了传统的状态转换图,引入了 层级性(Hierarchy)并发性(Concurrency)通信(Communication) 的概念。
  2. Harel 状态图的特点

    • 包含 Mealy 和 Moore 机器的特性,并增加了许多新的功能:
      • 子状态与超状态(Substates and Superstates)
      • 复合状态(Composite States)
      • 历史状态(History States)
      • 并行状态(Orthogonality)
      • 状态之间的通信(Communication between State Machines)
      • 条件转换(Conditional Transitions)
      • 进入与退出操作(Entry and Exit Actions)
      • 状态内部活动(Activities within States)
      • 参数化状态(Parameterized States)
      • 重叠状态(Overlapping States)
      • 递归状态图(Recursive Statecharts)
  3. UML 状态机

    • 后来,Harel 状态图的概念被纳入 UML 状态机 中,用于软件工程的建模。
    • UML 状态机基于面向对象的 Harel 状态图形式,有少量语义上的区别。
    • 本课程将重点讲解 UML 状态机(版本 2.5.1)及其规范。

在接下来的课程中,我们将继续深入探讨 UML 状态机的绘制规范与应用。

007 练习-0001 LED 控制 Mealy 机器示例

实践示例:实现灯光控制 Mealy 状态机

1. 实验概述

在本次实验中,我们将实现一个基于 Mealy 状态机的灯光控制应用,使用 Arduino Uno 控制 LED 的亮度。

2. 所需硬件

3. 编程框架概述

Arduino 编程框架提供了控制外设的 API。在本次实验中,我们将使用 analogWrite() 来生成 PWM 信号,以控制 LED 的亮度。

4. LED 亮度控制原理

通过 PWM(脉宽调制) 控制 LED 亮度:

5. Arduino Uno 数字和 PWM 引脚

6. 代码传输及串口通信

Arduino Uno 支持 串口通信。通过 USB 连接至计算机时会创建虚拟 COM 端口,支持在代码运行时通过串口发送指令。

7. analogWrite() 函数用法

8. 项目代码编写步骤

  1. 创建新项目

    • 在 Arduino IDE 中创建新项目,并保存到项目文件夹(例如 StateMachine_Projects/LightControlMealy)。
  2. 使用 analogWrite() 函数控制 LED

    • 通过 analogWrite() 调整 LED 的亮度(占空比),从而实现 Mealy 状态机控制 LED 的亮度变化。
  3. 串口指令实现事件触发

    • 使用串口发送 ONOFF 指令来控制 LED 的亮度状态,无需按钮。

通过上述步骤,你将能够使用 Arduino Uno 实现一个基本的灯光控制应用,理解 PWM 控制和状态机在硬件中的应用。

008 练习-001 LED 控制 Mealy 机器实现部分 1

实现灯光控制 Mealy 状态机 - 使用Arduino和枚举类型

1. 实验概述

在这个实验中,我们将通过Arduino Uno实现一个灯光控制应用,使用状态机的概念来控制LED的亮度变化。本次实验的目标是通过创建多个状态和事件,并使用PWM来调节LED亮度。

2. 状态和事件定义

本实验包含以下四个状态:

事件有两个:

3. 使用枚举类型定义状态和事件

在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;

4. 状态机函数

我们将实现一个状态机处理函数,通过嵌套的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;
    }
}

5. PWM 控制亮度

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

6. 串口通信

setup()函数中初始化串口通信,以便在实验中通过串口发送ONOFF指令来控制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);
    }
}

总结

009 练习-001 LED 控制 Mealy 机器实现部分 2

使用 Arduino 实现基于串口通信的灯光控制 Mealy 状态机

1. 串口通信设置

我们使用Serial.begin()方法来配置Arduino与主机之间的UART通信速率,并通过Serial.read()方法读取主机发送的控制指令。

void setup() {
    Serial.begin(115200); // 设置波特率
    pinMode(PIN_LED, OUTPUT); // 设置LED引脚为输出模式
    Serial.println("Light control application");
}

2. 接收并处理串口数据

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 事件
        }
    }
}

3. 定义状态机函数

通过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;
    }
}

4. 通过 PWM 控制亮度

使用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

5. 测试

在Tinkercad中进行仿真:

  1. 使用面包板、Arduino Uno和LED连接电路。
  2. 运行代码,通过串口发送‘o’和‘x’指令来控制LED的亮度。
    • 发送‘o’:亮度依次从微亮、中亮、到全亮。
    • 发送‘x’:LED熄灭。

总结

下节预告

接下来,我们将稍微重构代码,将其实现为Moore状态机结构。

010 练习-002 LED 控制 Moore 机器实现

讲座内容

  1. 前言

    • 在上一节课中,我们实现了一个简单的Mealy机用于灯光控制应用。
  2. 练习目的

    • 这些练习可以视为一些“Hello World”应用。
  3. 后续复杂应用

    • 后续将使用UML的状态机图进行更复杂的应用。
  4. UML状态图

    • 我们将开始探索UML规范,以绘制状态图,并探讨其他实现状态机的方法,如状态表法和状态处理器法。
  5. 实现Moore机

    • 为了完整起见,我们将使用Moore机实现相同的应用。
  6. 状态转换

    • 你知道,在转换期间没有动作,对吗?
    • “动作”或“输出”是在状态内部产生的,即所谓的“进入动作”。每个状态有四个进入动作。
  7. 项目设置

    • 创建一个新项目,命名为002LightControlMoore
    • 代码几乎与之前相同。
  8. 代码结构

    • 包含用于捕获事件的枚举和状态的枚举,以及一些宏定义。
    • 新增了一个函数run_entry_action,根据状态执行相应的进入动作。
  9. 初始化状态

    • setup函数中初始化灯光状态机,设定初始状态为LIGHT_OFF,并调用run_entry_action执行进入动作。
  10. 状态转换

    • 当接收到事件时(例如字符‘o’),调用light_state_machine函数。
    • 检查当前状态并处理状态变化,保存当前状态到临时变量previous_state,以检测状态是否改变。
  11. 状态变化检查

    • 比较previous_state和当前状态,如果不同,则表示状态发生变化,运行新状态的进入动作。
  12. 实现方法

    • 可以创建一个内部事件ENTRY来处理进入动作,也可以直接在状态机中实现。
  13. 总结

    • Moore机的输出保持不变,下一节课将探索UML状态机图。

结束语

WangShuXian6 commented 2 days ago

02 - UML 扁平状态机及其实现

001 练习-003 生产力计时器演示

第一部分:介绍

  1. 之前的讲座中,我们实现了一个简单的 Mealy 机,用于灯光控制应用。
  2. 这些练习可以视为一些“你好,世界”应用。

第二部分:复杂应用的实现

  1. 后续将使用 UML 的状态机图来构建更复杂的应用。
  2. 我们将开始探索 UML 规范,以绘制状态图,并探索实现状态机的其他方法,比如状态表方法和状态处理器方法。

第三部分:实现 Moore 机

  1. 现在,为了完整起见,让我们使用 Moore 机实现同样的应用。
  2. 你知道,在转移过程中没有动作,对吧?
  3. “动作”或“输出”是在状态内部生成的,我们称之为“入口动作”。
  4. 这里有四个入口动作。

第四部分:创建新项目

  1. 首先,创建一个新项目并命名为 002LightControlMoore
  2. 这里已经实现的代码几乎与之前相同。

第五部分:状态机的实现

  1. 你有一个枚举,用于捕获事件和状态。全局变量也设置在这里,但我没有初始化。
  2. 新的内容是,我提供了一个新的函数 run_entry_action
  3. 对于这个函数,你只需传入状态,按照状态执行入口动作。

第六部分:设置函数

  1. 在设置函数中,我首先初始化了灯光状态机。
  2. 此函数用于设置初始状态,初始状态是 LIGHT_OFF

第七部分:状态转移与入口动作

  1. 当状态设置为这个状态时,你需要执行这个入口动作。
  2. 因此,我调用 run_entry_action,并将状态作为参数传入。

第八部分:状态转移的处理

  1. 在循环函数中,我没有修改任何内容。
  2. 当接收到字符 'o' 时,你调用这个函数 light_state_machine
  3. 假设你在这里接收到事件 'ON',当前状态是 LIGHT_OFF,那么状态会发生变化。

第九部分:检查状态变化

  1. 在这个函数的末尾,我比较之前的状态和当前状态。
  2. 如果之前的状态与当前状态不相等,则说明状态发生了变化。
  3. 如果状态发生变化,则需要运行新状态的入口动作。

第十部分:总结与结束

  1. 这就是关于 Moore 机的实现,输出保持不变。
  2. 这节课到此结束,下一节课我们将探索 UML 状态机图。

第二部分:生产力计时器(ProTimer)

  1. 在这一节中,我们将进行一个新的练习,称为生产力计时器(ProTimer)。
  2. 此应用程序可以跟踪你的生产性工作时间。

第二部分:应用程序的功能

  1. 比如,应用可以跟踪你一天中学习或工作多少小时。
  2. 在开始学习或工作之前,你设置希望学习的时间。
  3. 启动计时器,直到计时器到期,应用会跟踪你的生产性时间。

第三部分:需求说明

  1. 让我们通过小演示来说明这些需求。
  2. 每次重置应用时,应用会发出嘟嘟声,并要求你设置时间。
  3. 这个应用的几个组件连接到 Arduino Uno 板。

第四部分:组件介绍

  1. 这包括一个 16x2 LCD,三个用户按钮,几个电阻器,一个蜂鸣器,以及一个用于设置 LCD 对比度的电位器。
  2. 这三个用户按钮分别是:增时按钮、减时按钮和“开始”或“暂停”计时器的按钮。

第五部分:操作示范

  1. 按下增时按钮,分钟数增加,减时按钮则减少分钟数。
  2. 设置好时间后,可以使用开始按钮启动倒计时。
  3. 如果想要暂停,可以按同一个按钮。

第六部分:中止操作

  1. 如果要中止操作,可以同时按下两个按钮,应用会重置。
  2. 当应用处于空闲模式时,可以使用开始或暂停按钮查看生产性时间统计。

第七部分:状态机的实现

  1. 我们将首先使用平面状态机实现此应用。
  2. 状态机包括几个状态,初始状态为空闲模式。

第八部分:状态的说明

  1. 在空闲状态时,应用会发出嘟嘟声并要求用户设置时间。
  2. 按下按钮后,进入时间设置模式。

第九部分:计时器操作

  1. 进入计时器倒计时模式时,按钮事件不会影响当前状态。
  2. 计时期间按下开始或暂停按钮可进行相应的操作。

第十部分:总结与结束

  1. 不必担心电路图,我将在后续讲座中展示如何制作该应用的电路。

003 UML 简单状态和复合状态

笔记

项目需求

  1. 增减时间按钮

    • ‘+’按钮:每次按下增加分钟数。
    • ‘-’按钮:每次按下减少分钟数。
  2. 开始/暂停按钮

    • 用于启动或暂停倒计时,并显示统计信息。
  3. 计时器功能

    • 在暂停状态下,可以修改时间设置。
    • 同时按下‘+’和‘-’按钮可终止运行中的计时器。
    • 应用程序返回空闲模式时需发出20次蜂鸣声。
  4. 空闲模式操作

    • 在空闲模式下,按下开始或暂停按钮应显示统计信息1秒后自动返回空闲模式。

状态机模型

示例状态

  1. IDLE:空闲状态。
  2. TIME_SET:时间设置状态。
  3. COUNTDOWN:倒计时状态。
  4. PAUSE:暂停状态。
  5. STAT:统计信息状态。

状态的类型

UML状态机图

使用复合状态的优点

示例

这个结构和内容可以帮助您更好地理解和构建状态机图,以满足项目需求。

004 UML 状态机的内部状态活动(entry、exit、do)

欢迎回到讲座

在之前的讲座中,我们探讨了简单状态和复合状态,并理解了如何绘制简单状态及其简单部分。

内部活动区的探索

现在让我们来探索内部活动区。

在我们的状态机图中,如果你考虑这个状态,你可以在这里提到内部活动。这就是内部活动区。

什么是内部活动?

内部活动定义了状态的内部行为。每个状态可以有自己独特的内部行为。内部活动区持有与状态相关的内部行为列表。

如何表示状态的内部活动?

表示状态的内部活动非常简单。语法如下:

首先,你需要写上“行为类型标签”,即内部活动的标签,后面跟一个“/”字符,然后写上“行为表达式”。行为表达式实际上就是一个动作,可以是任何编程表达式、可执行的编程语句,或者是函数调用,诸如此类。

简单来说,就是标签/action。

标签介绍

这些标签包括‘entry’、‘exit’和‘do’,它们是UML规范中定义的内部活动标签。这些标签应该只出现在内部活动区,而不应在内部活动区之外用于表示特定应用事件。

了解‘entry’、‘exit’和‘do’标签

这些标签标识了在什么情况下,‘行为表达式’指定的行为被执行。

内部活动的必要性

是否需要这些内部活动,取决于你的项目设计和状态机设计。所有这些都是可选的。一个状态可能没有‘entry’或‘exit’动作,也可能只具有‘do’动作,或者可能只有‘entry’动作。这些都是可以在状态内部进行的可选操作。

内部活动区示例

这里是内部活动区的一个示例。我们将在状态机图中做这个示例。

当对象在STAT状态时,这就是一个进入动作。这两个进入动作由逗号分隔。它们都用‘entry’标签标识。

当对象进入这个状态时,它会在LCD上显示一条消息,同时显示一个变量的值。而当对象离开该状态时,它会清除显示屏,这就是该状态的‘exit’动作。

同样,当对象进入IDLE状态时,它会将一些变量初始化为0。这些都是对象的属性。正如我在之前的视频中提到的,对象实际上是属性和方法(或行为)的集合。

这个主要应用对象‘mobj’是主要应用对象,这些是它的变量并被初始化,这就是该状态的‘entry’动作,同时它还显示时间为0,并显示消息‘set_time’。这些都是用逗号分隔的‘entry’动作。

当离开该状态时,这就是‘exit’动作,‘exit’动作是‘display clear’。这与其他不同的是,这里是内部转换。这不是内部活动,关于这一点我会在之后讲解。

总结

内部活动始终由标签识别。下一节课,我们将了解内部转换。下次见!

005 UML 状态机的转换类型

内部转换

在上一节课中,我们探讨了状态的内部活动,包括“进入”、“退出”和“执行”等。这些都是状态的内部活动。现在,在本节课中,让我们了解内部转换。

内部转换是执行某些操作的一种方式,这些操作由行为表达式标识。当系统中发生某个“触发”(trigger)事件,并且“守卫”(guard)条件评估为真时,如果有守卫条件的定义,内部转换便会触发。这里的“触发”实际上是一个事件或事故的原因。

内部转换的语法如下:我们可以简单地写成T(G),其中G是可选的守卫条件,后面跟着动作。如果事件的发生与内部转换的“触发”相匹配,并且“守卫”条件评估为真,则由行为表达式标识的行为将被执行,而无需退出或重新进入定义该行为的状态。

在内部转换中,对象不会离开其当前状态,因此没有退出状态的概念。

当“触发”事件发生时,如果守卫条件为真,则将在不退出或重新进入状态的情况下执行该动作。

示例

假设我们有一个状态叫做“空闲”(IDLE)。在该状态下,当触发事件发生且守卫条件为真时,将执行特定动作,而不退出或重新进入该状态。

例如,当“时间滴答”(TIME_TICK)事件发生且某变量值为5时,执行一个“滴声”(beep)动作。

转换类型

根据UML规范,转换可以分为外部转换、局部转换和内部转换。我们刚刚探讨的是内部转换。

外部转换

在外部转换中,由于触发事件的发生,源状态被退出,接着执行与转换相关的可选动作,并执行目标状态的操作(如果有的话)。外部转换标志着对象生命周期中的状态或情境的变化。

转换的执行顺序

在进行转换时,首先会执行当前状态的退出动作,然后执行转换动作,最后执行新进入状态的入口动作。这是根据UML规范定义的执行顺序。

示例

假设对象当前在“倒计时”(COUNTDOWN)状态,当接收到“启动/暂停”(START_PAUSE)事件时,发生外部转换到“暂停”(PAUSE)状态。

在这个例子中,当“倒计时”状态接收到“启动/暂停”事件时,状态变量将更新为“暂停”。

总结

在讨论转换时,内部转换不涉及状态的退出和重新进入,而外部转换则标志着状态的改变,并需遵循特定的执行顺序。通过这些概念,我们可以更好地理解状态机的行为和状态之间的关系。

006 事件和信号

事件概述

现在,在本讲中,让我们了解什么是事件,或者你也可以称之为触发器。

事件就是可以触发状态机的事件或刺激。

基本上,它们是应用程序中的异步事件或同步事件。

它们可以抽象为事件。在状态机中,事件可能导致转换,而转换可以是外部的或内部的。

微波炉示例

让我们以微波炉为例。

你打开门,这个动作就是一个事件;当这个事件发生时,会生成一个事件,并被传送到微波炉内部的固件。固件根据其当前状态可能采取一些行动,例如关闭加热器、打开灯光等。关闭门也是一个事件,可能会有其相关的动作。

例如,设置定时器。你使用烤箱控制面板上的按钮设置定时器,开始烤箱操作等。这些都是事件,事件可能导致状态机图的转换,并可能有其相关的动作。

事件的组成部分

事件通常有两个组成部分:

  1. 信号组件
  2. 信号参数(也可以称为信号值)

第二个组件实际上是可选的。

示例

考虑一个应用程序,我们有三个按钮:“加”、“减”和“开始/暂停”。按下这些按钮会生成事件。

当你按下加号按钮时,它生成一个事件。这个事件有两个组成部分:

  1. 信号组件:按下加号按钮生成的事件,其信号为“增大时间”(INC_TIME)。
  2. 信号参数:在这种情况下,该事件没有相关的信号值或参数。

这没关系,因为那是可选的。

类似地,如果你按下减号按钮,它生成另一个事件,其信号名为“减少时间”。在这种情况下,这个事件也不需要任何参数。

你也可以这样写事件:无论用户按下哪个按钮,加号按钮还是减号按钮,生成事件“时间变化”(TIME_CHANGE)。然后使用参数“方向”来指示用户实际按下了哪个按钮。

你可以创建一个枚举,使用这些值UP或DOWN进行区分。这些将成为信号值。

这个事件通过其信号属性表明用户按下了一个改变时间的按钮。信号有一个相关的参数,编码了用户按下的按钮是增加时间还是减少时间。

计算器示例

再举一个计算器的例子。

计算器有数字键盘,上面有10个数字(0到9)和几个操作符,以及结果按钮等。

那么,如何编码按下任意数字呢?你可以生成一个事件,其信号名为数字(0到9),参数则表示用户按下了哪个数字。

这样,你就可以只生成一个事件,而不是为每个数字创建一个事件,利用事件的参数组件区分用户按下了哪个数字。

类似地,你可以保持一个事件来表示按下操作符按钮。

通过这样的方式,在编程中,你可以使用结构体或枚举来建模应用程序的事件。

007 练习-003 状态和初始伪状态

生产力计时器应用概述

好吧,我们现在继续我们的练习二,即生产力计时器应用。我们已经理解了项目要求,我也已经演示过这些要求。

项目要求与状态

这些是我们要转换为状态的不同情况,我们已经在Astah软件上绘制了这些状态。现在,让我们看看将用于该应用程序的各种事件。

用户活动与事件

‘SS’的值范围从1到10,其中1表示100毫秒,10表示1秒。

扩展状态变量

我们将在该应用程序中使用一些扩展状态变量,这些变量对于捕捉数据和在状态机中做出决策至关重要:

  1. current_time:保存用户通过‘+’或‘-’按钮选择的时间。
  2. elapsed_time:保存已过去的秒数。
  3. pro_time:保存用户花费的生产时间,用于统计。

这些变量将组织在一个名为protimer_t的结构中。

状态机图

我们为该应用程序对象绘制的状态机跟踪应用程序的生命周期。

我们将从这个状态开始绘制外部转换、内部转换和活动。

伪状态

初始伪状态至关重要,因为它代表状态机的起点。它只能有一个输出转换,并且不支持触发器或守卫。初始动作将涉及初始化扩展状态变量:

mobj->curr_time = 0; 
mobj->elapsed_time = 0; 
mobj->productive_time = 0;

这些操作将把主对象的属性设置为零,作为我们启动应用程序的基础。

现在,让我们回到Astah软件中完成我们的状态机图。

008 练习-003 定义状态的 Entry 和 Exit 动作

概述

为了减少图表上的杂乱,我将用简短的变量名表示,而不是完整的变量名。

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 =0elapsed_time=0;并显示时间为 0;显示消息为“设置时间”。

这些就是进入动作。既然我们已经在这里做了 c_time=0e_time=0,那么就没有必要了,让我们删除这个。

我将在这里将 p_time=0

这就是我们的进入动作。

太好了。

现在我们完成了空闲状态的进入动作。

接下来让我们进入时间设置状态。

每当应用程序进入时间设置状态时,它应该显示当前时间。

因此,让我们为此定义一个进入动作。

我将选择这个状态,进入并将进入动作定义为“显示时间”。

你必须显示当前时间(c_time)。

因此,当应用程序进入这个状态时,它会显示时间。就这样。

现在,让我们为暂停状态编写进入动作。

每当你按下暂停按钮时,你可以看到这里倒计时停止,并显示消息“已暂停”。

现在,让我们在暂停状态下执行这一操作。

那么,这里的进入动作将是 disp_msg(“paused”)

它并不影响倒计时。所以稍后我们将看看这是如何发生的。

但是每当应用程序进入这个状态时,它应该发送消息“已暂停”。

现在,让我们进入倒计时状态。对于倒计时,它应该每秒倒计时一次。

这意味着,每当这个状态接收到 TICK 事件时,将会有一个倒计时过程。

所以,我不确定这是否需要进入动作,

如果你没有任何想法,那么你就可以不定义。

但是我不想为这个定义任何进入动作。

现在,让我们进入统计状态(STAT 状态)。如你在演示中所见,

每当你按下开始或暂停按钮时,它会显示生产时间,并且还会显示消息“生产时间”。

这表明你必须在这个状态的进入动作中做一些事情。

因此,选择这个状态,进入动作,

我们该做什么?

如你在演示中所见,它应该在显示器的第一行显示生产时间,并且在第二行发送一条消息。

这意味着我们有两个动作。

disp_time(mobj->p_time),并给一个逗号,显示消息“生产时间”。

所以,每当你有两个或更多的动作要做时,你可以做一件事,结束每个动作用分号。

这样看起来不错。

在这一行之后,这是一个动作,你给一个分号,这是第二个动作。

我将给一个分号。

这是第三和第四个动作。

所以在这里你也只需用分号结束。

现在,让我们定义一些退出动作。

从空闲状态开始。

空闲状态在进入时显示了一些内容。当应用程序接收到某些事件时,它会转到其他状态,并可能显示其他内容。

因此,我认为在显示上显示了内容的应该清除它。所以,当退出这个状态时,这个状态必须清除它在显示上显示的内容。

所以,我将定义这个状态的退出动作为:

好的,我将去退出部分。退出动作将是“清除显示”。

稍后我们将看看是否需要更多的动作,但目前我只能想到在离开这个状态时清除显示。

对于 STAT 状态,退出动作也将是“清除显示”。

那么时间设置状态呢?

可能是需要的。所以,对于时间设置状态,我暂时不会定义任何退出动作。稍后再看看。对于暂停状态

它也显示了内容“已暂停”,对吧?

所以,它应该清除它。

因此,我将为此定义一个退出动作为“disp_clr”。

对于倒计时,我也不确定是否需要定义任何退出动作,所以稍后再看看。

现在我们已经部分实现了进入和退出动作。

让我们进行一些转换。

009 练习-003 绘制状态转换

欢迎回来

在之前的讲座中,我们为各种状态编写了一些入口和出口操作。

现在让我们实现状态转换。每个状态绘制的转换数量取决于该状态处理的事件数量。我们在这个应用中有多少事件呢?

我已经向你展示过这个表格。我们有5个事件,所以每个状态最多可以有5个转换。

IDLE状态

现在,首先让我们从IDLE状态开始。你需要检查该状态是否真的处理某个特定事件。如果它不处理或不尊重该事件,那么你可以忽略它。

现在首先让我们从增量时间(INC_TIME)开始,针对IDLE状态。

当应用处于IDLE状态时,如果接收到增量时间事件,它应该转到TIME_SET状态,因为用户想要设置时间。

因此,我们在这里进行一个转换。你可以从任何地方绘制它。

这是一个转换,触发器是事件名称,即增量时间(INC_TIME)。

是否需要任何Guard?目前我没有给出任何Guard。每当按下“+”按钮时,它应该进行转换。

转换的操作

这个转换的操作是什么?

你应该选择这条线,属性窗口会弹出。操作是用户按下“+”按钮,因此我们需要增量时间细节。

操作可以是mobj->c_time += 60;,为当前时间变量增加60秒。

这就是操作。你可以考虑一些Guard,但我目前不放任何Guard以保持简单。

如果c_time已经达到最高水平,那么根据Guard评估,你可以忽略它,不进行任何转换。

TIME_TICK事件

现在让我们处理下一个事件,即TIME_TICK事件。

该应用程序必须在返回到IDLE模式时发出20次哔声。假设每500毫秒应用程序需要发出一次哔声,持续10毫秒。

所以,它必须发出20次哔声,然后进入静音模式。

每当接收到TIME_TICK事件时,动作必须被执行,但没有转换。

这意味着这实际上是一个内部转换。

IDLE状态中的内部转换

现在,让我们回去,选择IDLE模式,然后转到内部。将触发器设置为TIME_TICK。

此事件有一个参数。当SS = 5(每500毫秒)时,操作是do_beep();

或者它也可以写成e->SS,当其等于5时,发出哔声。这就是内部转换。

结论

现在我们已经覆盖了IDLE状态的所有事件。接下来让我们转到TIME_SET状态,思考一下你希望如何处理该状态中的所有事件。

下次见!

010 练习-003 实现 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;

所以,这就是这个转换发生的保护条件。如果这个条件不为真,那么这个转换就无法发生。这就是我们的开始/暂停事件。


时间滴答事件
现在下一个事件是时间滴答事件。当应用程序处于设置时间状态时,它实际上并不处理时间滴答事件。

我的意思是,没有与时间同步的动作。因为,设置时间几乎是根据用户按下的“+”或“−”按钮来修改时间变量。这就是为什么,它没有定义基于时间的动作。

所以,让我们在这个状态中忽略那个事件。现在让我们移动到下一个状态,即暂停。


你想想你在这里做了什么,尝试绘制你的步骤,我会在下节课中讲解这个。

011 练习-003 实现 PAUSE 状态

实现暂停状态

让我们来实现 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

012 练习-003 实现 STAT 状态

实现 STAT 状态

现在,让我们来实现 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 模式。

所以,我们成功完成了应用的状态机图。

请回顾一下,我已经解释了所有内容,包括状态、转移、事件、选择伪状态、初始伪状态,以及其他许多细节。

但这看起来有点乱,不是吗?

因为它是一个平面状态机,而不是一个层次结构的状态机。稍后我们会看到如何将其转换为层次结构状态机,或者我会介绍其他示例。我们还将做一个层次状态机的项目。

这只是一个开始,我的目标是通过示例介绍各种规范术语。

这就是我在这个应用中的目标。

使用层次状态机实际上可以使你的图表变得不那么混乱;你确实可以减少杂乱,我们稍后会看到这一点。

从下节课开始,我们将把这个图转化为代码,下一节课见。

013 安装 Microsoft VS Code 和 PlatformIO 扩展

实现状态机的项目设置

在之前的讲座中,我们已经完成了我们应用的状态机图。

现在,让我们创建一个新项目,并开始使用 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 扩展的步骤。

希望你能做到,我们下节课见。

WangShuXian6 commented 2 days ago

03 - 扁平状态机练习实现

001 练习-003 创建新项目

课程讲义:使用 Visual Studio Code 和 PlatformIO 进行 Arduino 开发

介绍

在上一节课中,我们完成了应用程序的状态机图。现在,我们将创建一个新项目,并开始使用 C 编程语言实现该状态机。

创建新项目

  1. 打开 Visual Studio Code
    安装 Microsoft Visual Studio Code IDE 和 PlatformIO 扩展。

  2. 启动 Visual Studio Code
    打开 IDE 后,您可能会看到欢迎页面,激活扩展可能需要一些时间。完成后,您将看到 PlatformIO 图标。

  3. 创建新项目

    • 点击 PlatformIO 图标,选择“Home”,然后点击“Open”。
    • 选择“Create New Project”。
    • 输入项目名称(如“003Protimer”),选择 Arduino Uno 板,框架选择 Arduino。
    • 更改默认位置,选择保存到之前创建的目录(如 StateMachine_Projects)。
  4. 信任作者
    创建项目时,您可能需要信任作者,以便进行网络活动。

编写代码

  1. 打开源文件
    src 文件夹中找到 main.cpp 文件,您将看到 setup()loop() 函数。

  2. 连接 Arduino 板
    将 Arduino 板连接到计算机。

  3. 编写简单程序
    loop() 函数中添加以下代码以打印“Hello world”:

    Serial.begin(9600);
    Serial.println("Hello world");
  4. 编译和上传程序

    • 保存文件。
    • 点击“Build”按钮以编译代码,确保编译成功。
    • 点击“Upload”按钮将程序上传到 Arduino。
  5. 查看输出

    • 打开串行监视器,查看输出。您将看到“Hello world”持续打印,因为它在 loop() 函数中。

配置串行监视器的波特率

  1. 更改波特率
    将波特率更改为 115200,重新编译和上传程序。

  2. 串行监视器设置
    如果出现错误提示“Access is denied”,请确保关闭串行监视器后再进行上传。

  3. 调整串行监视器波特率
    platformio.ini 文件中添加相应的波特率设置,以确保串行监视器与代码一致。

小结

到此,您已经成功创建并上传了一个基本的 Arduino 项目。接下来,我们将进行更复杂的实现。感谢您的参与,下次课再见!

002 练习-003 数据结构说明

嗨,欢迎回到讲座。

在上一节课中,我们设置了这个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 中,下一节课再见!

003 练习-003 定义初始转换函数

现在在 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 中。

看起来不错!现在,让我们尝试编译一下。

好的,编译成功。

004 实现状态机的不同方法

现在让我们开始实现状态机。

有几种不同的方法。第一种是非常简单直接的“嵌套开关方法”。我们在之前的练习中也探讨过这种方法。

第二种是状态表方法。还有一种非常高效的优秀方法,这种方法在 Miro Samek 撰写的《Practical UML Statecharts in C/C++》一书中提到。这种状态处理器方法实际上基于函数指针。每个状态都有自己的处理器,而这个处理器本身也被视为一个状态。

状态处理器方法的核心在于函数指针的使用。我们将学习函数指针在状态处理器方法中的用法,稍后会深入探讨。因此,首先我们将探索嵌套开关方法,然后再用相同的应用程序探索状态处理器方法,最后将探讨状态表方法。

WangShuXian6 commented 2 days ago

04 - 嵌套 switch 技术实现状态机

001 练习-003 嵌套 switch 实现 FSM 第 1 部分

代码实现状态机

现在,让我们继续编写代码。接下来,我们需要编写一个函数来实现状态机。

首先,打开文件 protimer_state_mach.cpp,并创建一个函数。这个函数我们称为 state_machine

这个 state_machine 用于应用程序对象,接收 protimer_t,即主应用程序对象 mobj,并接收事件。事件的通用结构是指向 event_t 的指针。

这里我们将使用嵌套的 switch 语句,使用 switch case 来切换不同的状态。

switch (mobj->active_state) {
    // ...
}

我们将实现不同的 case。例如,如果当前的 active_stateIDLE,则我们在这里调用一个函数。

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;
    // 其他信号
}

在实现 ENTRYEXIT 的时候,记得返回 EVENT_HANDLED,如果处理信号导致状态转移,则返回 EVENT_TRANSITION

定义 event_status

接下来,在 main.h 中定义 event_status_t

typedef enum {
    EVENT_HANDLED,
    EVENT_IGNORED,
    EVENT_TRANSITION
} event_status_t;

入口操作和退出操作

例如,入口操作需要将 c_timee_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。这样就完成了状态机的事件处理逻辑。

002 练习-003 嵌套 switch 实现 FSM 第 2 部分

继续实现这些处理程序

在之前的课程中,我要求你们实现这些处理程序,我相信你们已经完成了。

请注意,在图中,每当你看到一个 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 板的连接。

003 练习-003 硬件连接

硬件连接

现在让我们来讨论硬件连接。

有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轨道。记住,电源轨道的部分在真实的面包板上通常是没有连接的,所以最好短接这些部分。

连接就完成了!接下来,你可以将你写的代码粘贴到文本部分进行仿真。

在下节课中,我们将讨论读取按钮状态。下次见!

004 练习-003 实现事件生成代码

讲座回顾

欢迎回来,今天我们来实现循环功能,接下来是第一个任务:读取按钮面板的状态。

为了实现这个功能,我会创建几个变量。

首先,定义三个变量: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 事件的代码,稍后再看。

这就是发送用户事件的全部内容。

005 练习-003 分发时间滴答事件

讲座回顾

在之前的讲座中,我们实现了分发用户事件的代码,现在我们需要实现这个功能。

让我们编写一个小代码来分发时间滴答事件。为此,我们将使用 Arduino 框架提供的 millis() 函数。

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 毫秒的时间滴答事件。

编译代码

现在,让我们尝试编译这个代码。

没有错误,但有几个警告。编译器不识别这种初始化类型,提示未实现。没关系,我们将去掉它。

在下一次讲座中,我们将了解按钮抖动,并尝试实现这个功能。下次见!

006 按钮抖动解释

按钮抖动与消抖技术

在本次讲座中,我们将讨论按钮抖动,并稍后讨论如何使用软件进行消抖。

我们将按钮与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毫秒。

这就是引脚上的活动情况。你可以将引脚的不同状态命名为:未按下状态、抖动状态、按下状态等。

这实际上是按钮被按下的状态;而这就是按钮释放的状态。

现在,我们通过软件解决这个问题。为此,我们将使用软件消抖技术。

也有硬件消抖技术来获得干净的过渡,需要在开关和数字引脚之间连接消抖电路。

但在这个练习中,我们将使用软件消抖,如何做到这一点我将在下次讲座中展示。

007 练习-003 按钮软件防抖实现

讲座回顾

嘿,欢迎回来参加讲座。

在本讲座中,我们将实现软件按钮消抖。在上一次讲座中,你看到当按键被按下和释放时,针脚的活动情况。

你可以将这些情况视为不同的状态,因此针脚的不同状态可以通过状态机图表示。

状态机图

我为按钮消抖写了一个简单的状态机图。你可以在这里创建任意数量的模型,只需右键单击,选择“创建图表”,然后选择状态机图。

我们将不按照标准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。我们将在硬件上测试这个项目。

我下次见!

008 在 PlatformIO 项目中添加 Arduino 库

欢迎回来

欢迎回来参加讲座。

Arduino官方LCD库

在这次讲座中,让我们探讨Arduino的官方LCD库。为此,你需要访问文档,进入参考部分。在参考中,你可以找到库,点击它。在这里点击显示器,然后选择官方库。

LiquidCrystal库

点击LiquidCrystal。这是一个库,允许与字母数字液晶显示器(LCD)进行通信。点击阅读文档,了解更多关于这个库的信息。它适用于基于日立HD44780(或兼容)芯片组的液晶显示器,我们使用的是同样芯片组的16x2 LCD。

示例代码和方法

你可以在这里找到各种示例代码。在右侧,你可以看到LiquidCrystal库的不同方法或函数。在我们的项目中,由于我们使用的是PlatformIO扩展,我们首先需要安装这个库。我将向你展示如何安装这个库,非常简单。

安装库的步骤

转到主页。如果你不知道如何访问主页,可以在这里找到主页按钮,或者在PlatformIO中有一个主页按钮。快速访问,打开主页,然后进入库。在库中搜索这个名称,复制并粘贴到搜索框中。

这应该是由Arduino提供的,因为这是官方的库。点击它,然后选择“添加到项目”。像这样添加到项目中,选择你的项目。这个是我的项目,我将选择它并添加。

这是一种项目级别的库添加方式。你也可以选择工作区级的添加。我们将进行项目级的添加。

添加库

一旦添加成功,你可以在platformio.ini文件中看到它,作为库依赖项添加。

使用这个LCD库,你可以实现所有这些功能。如果你想在LCD上打印文本,可以使用print方法。如果你想了解如何使用这个print方法,只需打开文档,它会为你解释如何使用。

创建LCD对象

如果你想在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中实现所有这些函数,并从我们的主源文件中调用这些函数。下次见!

009 练习-003 实现 LCD 功能 第 1 部分

实现lcd.cpp

现在,让我们实现lcd.cpp。

你需要复制所有这些内容,然后粘贴,并逐一实现函数。

引入库和创建LCD对象

在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。

就这样,完成所有这些函数,下次课见!

010 练习-003 实现 LCD 功能 第 2 部分

设置光标函数

要使用设置光标函数,首先要提及列和行。

自动滚动

在此之后,必须调用不自动滚动的方法 noAutoScroll()

LCD初始化

对于 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

LCD主函数

接下来,在 main.cpp 中完成 display_init 函数。在调用LCD的任何方法之前,首先需要初始化LCD。

有很多初始化命令需要在使用其他功能之前发送给LCD,比如发送命令或文本。

清除LCD

清除LCD非常简单,只需调用 lcd_display_clear。但在这里它不可访问,因此我们将添加 lcd.h 并调用 lcd_clear

显示时间

时间应以分钟和秒的形式显示。没有小时的信息,格式是 mmm:ss。时间应显示在第0行,开始于第5列,结束于第10列。

当应用程序处于空闲状态时,应显示“设置时间”,计时器设置为100分钟0秒。当进入暂停状态时,消息“paused”应出现在特定坐标。

时间转换

要将当前时间变量转换为分钟和秒很简单。假设当前时间为125秒:

因此,应该显示为 002:05

显示消息

为了显示消息,消息的坐标是依赖的,因此需要在 display_message 函数中接收坐标。修改该函数以接受列号和行号。

蜂鸣器功能

可以使用Arduino的 tone 函数来实现蜂鸣器。只需指定连接到蜂鸣器的引脚、频率和持续时间。

总结

接下来的课上我会展示实现方法,但在此之前,请尝试实现这些函数。下次课见!

011 练习-003 辅助函数实现

实现代码

这里是实现的代码。

显示消息函数

首先,让我给你展示 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应用程序上立即进行测试。

012 练习-003 实现初始转换动作

测试应用程序

让我们测试这个应用程序。在测试之前,先看看我们的 protimer_init 函数。

初始过渡

这里,这是一个初始的过渡。

它正在过渡到 IDLE 状态。但 IDLE 状态有其进入动作,进入活动就在这里。

因此,每当你在这里将状态设置为 IDLE 时,都必须调用其进入动作。

我们实际上忘记了这一步。

添加进入动作

现在让我们来做。

我会创建一个临时变量 event entry action,将 ee.sig 设置为 ENTRY,然后需要从 protimer_state_machine 调用这个动作。

因为你需要执行这个进入动作。

你必须将进入事件发送到 IDLE 状态,传入 mobj 以及这个变量的地址。

这个函数只使用一次。

重新测试

现在让我们进行测试。

013 练习-003 硬件测试

测试应用程序硬件

好的,现在让我们在实际硬件上测试这个应用程序。

硬件设置

我在这里有设置。

如您所见,我有 LCD,这个是蜂鸣器,电位器用于设置对比度,按钮面板,以及这些是下拉电阻。

这些连接完全按照我在 Tinkercad 视频中解释的方式进行。

启动应用程序

没有任何变化,你必须完全这样连接。

现在,让我们启动我们的应用程序。

这是我们的应用程序。编译正常。让我们再编译一次。

编译是好的,现在让我们上传。

它正在上传到板子上。

输出和功能测试

这里你可以看到,我们实际上看到了输出。你可以使用电位器来调整对比度。

现在它处于 IDLE 状态,行为正如我们预期的那样。它显示时间,设置时间,还有一个蜂鸣器每 500 毫秒鸣响一次。

功能测试

现在让我们测试功能。首先让我检查增量时间。

你可以看到,每当我按这个按钮时,分钟就会增加。这是减时间按钮,你可以观察到按钮按下的消抖。

可以看到。

这是减时间。是的,它的行为如预期。

它可以一直减到 0。

现在让我开始倒计时。

它不会开始,因为它是 0。

现在,让我们开始倒计时。

你可以看到,它正在倒计时。

你可以暂停它。我刚刚暂停了。

当它暂停时,你可以减少或增加时间。然后再开始。

在此之后,你可以中止这个倒计时。

你必须同时按下这两个按钮,让我们一起按下。现在它被中止了。

现在它处于 IDLE 状态。

再次开始倒计时

现在让我们再次开始倒计时。

现在让我们暂停,减时间,开始,好吗?中止。

检查 STAT 状态

现在让我们检查 STAT 状态。

当它在 IDLE 模式下时,你必须按下开始/暂停按钮才能进入 STAT 状态。

如你所见,当我按下这个时,它进入 STAT 状态,显示 STAT,然后自动返回到 IDLE 状态。

BUG 检查与修复

在 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。

你可以实现这样的逻辑。

同样的逻辑也适用于减时间按钮。

好的,基于这个点,我想结束这节课。在下一节课中,我们将探讨函数指针,并使用状态处理程序方法重新做这个练习。

下次见!

WangShuXian6 commented 2 days ago

05 - 'C' 中的函数指针

001 'C' 中的函数指针

C语言中的函数指针讲座

大家好,欢迎回到讲座。

在上一节课中,我们探讨了嵌套switch方法来实现状态机。在这一节课中,让我们理解状态处理器方法。之后,我们可以探索状态表方法。状态表和状态处理器方法都利用了函数指针的概念。

C语言中的函数指针

首先,让我们探讨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语言中,函数指针的概念非常强大,允许灵活的程序设计,尤其是在实现状态机和处理回调时。

002 作为函数参数传递函数指针

在本讲中,我们将看看如何将函数指针作为参数传递给另一个函数。

接下来,我们将进行一个小练习。

假设你有一个命令变量。

根据这个命令值,你需要采取某些行动或执行一些代码。

假设,如果命令值为0,你需要执行a+b

如果命令值为1,那么执行a-b

如果命令值为2,那么执行a*b。那么,你该如何实现这一点?

假设你有两个变量ab

假设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

它还接受ab(整数值)。之后,这个操作的作用是,它是一个通用函数,

它只是跳转到这个指针指向的函数,并使用参数ab。或者你也可以

简单地这样写,也没有问题。

这两者是一样的。

请注意,p是这种类型的函数指针变量,它是typedef到这个定义。

现在,在主函数中,让我们创建一个函数指针数组来存储这些函数地址。

所以,我将再次使用oper_t,创建一个函数指针数组变量。假设,如果你想创建

一个字符指针,你该怎么做?

这是一个字符指针。

现在,如果你想将其转换为字符指针数组,你该怎么做?所以,字符指针数组。

p是一个指针数组。

同样,这里,你只需使用这个

typedef名称,创建一个变量,称之为oper

并初始化为

函数指针addsubtractionmultiplication

你可以使用类似这样的语句。

这是一个函数指针数组,它初始化为这些函数地址。

现在你可以

做类似这样的事情。result = operation();

调用这个通用函数operation,传入

任何由这个命令值指向的地址。我们可以在这里使用命令值和ab

这里发生了什么?

这个通用函数被调用,带有3个参数。

这些是整数值,而这个是一个函数指针。那个函数

指针在这里接收到p

这就是你如何将

函数指针从一个地址传递到另一个地址。

抱歉,这里是int a, int b

这里只需返回结果。

因此,函数指针在我们的状态机实现中可能会非常有用。因为在我们的状态

机实现中,我们有不同的处理器,状态处理器。状态处理器函数中充满了

switch case语句。

它表示一组独特的指令。

所有这些不同的案例可以被分离为不同的函数。

你可以把这个看作是一个事件。根据接收到的事件,你可以传递与之关联的事件处理

函数

到通用分派代码中。你可以把这个看作是一个分派代码,

它在这里接收事件处理器地址并执行事件处理函数。

你可以把这个看作是一个包含各种函数指针的表,

这些指针就是不同事件处理函数的地址。这种方法

我们实际上在状态机实现中使用,称为状态表方法。在状态表方法中,

有一个表,里面充满了函数指针,每个表中的地址就是事件处理函数的地址。

我们将在覆盖状态表方法时看到这一点。无论你使用状态表

方法还是状态处理器方法,函数指针的

知识都是必需的。

这就是为什么我会简单介绍C语言中函数指针的基础知识。在下一节课中,

让我们探索状态处理器方法。

WangShuXian6 commented 2 days ago

06 - 状态处理器技术实现状态机

001 练习-004 使用状态处理器方法实现

讲座欢迎词

大家好,欢迎回到讲座。在这一讲中,我们将理解状态处理器的方法。

状态处理器的介绍

这个方法与之前的类似,但重要的区别是,如果你还记得,我们在主应用程序结构中使用了 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 未初始化,可能是因为函数原型定义的顺序问题。调整代码顺序后重新编译。

总结

状态处理器的方法通过使用函数指针简化了状态管理,使得代码更易于维护和扩展。

WangShuXian6 commented 2 days ago

07 - 状态表技术实现状态机

001 练习-004 状态表实现 FSM 第 1 部分

状态表方法

介绍

在本讲中,我们将学习状态表方法。在之前的讲座中,我们讨论了状态处理器方法和嵌套开关方法。

状态表方法

在状态表方法中,我们将使用一个状态表,因此得名状态表方法。让我用一个例子来解释。

在前两种方法中,无论是状态处理器方法还是嵌套开关方法,我们都使用了许多比较。这些比较是通过使用开关语句来实现的。这里有多个开关语句和一些情况,进行了大量的比较。

在这种方法中将不会有比较。当事件发生时,相关的函数会被执行,我们可以通过函数指针来实现这一点。状态表实际上就是一个包含各种处理程序的表,或者可以称之为事件处理程序的表。

例如,假设在您的应用程序中,当应用程序处于倒计时状态时,如果发生 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,并将这些代码包含在其中。

我希望您能做到。之后,您只需删除这些状态处理程序。这些不再需要。因此,我也将删除这些函数原型。这些也是不需要的,因为我们将删除它们。

完成这些,我将在下节课见到您。

002 练习-004 状态表实现 FSM 第 2 部分

讲座回顾

欢迎回来。在这节课中,我为不同的状态创建了不同的事件处理程序。您可以看到,我们有很多函数。现在,这些处理程序的地址应该存储在状态表中。

状态表的创建

因此,您需要与 main.cpp 共享这些函数的名称,我们将在其中实现状态表。您需要为所有这些函数创建原型,并将其包含在 main.h 中。

现在我们进入 main.h,在这里粘贴原型。

接下来,您需要创建这个表。在表中,您需要提到两个重要的细节:首先,您将调用哪个事件处理程序,其次,当事件发生时,下一状态是什么。

然后,我们需要将这个状态表转换为二维数组。

二维数组

在下一节课中,我将介绍什么是二维数组,如何访问二维数组的不同元素,以及二维数组的内存组织是怎样的。如果您已经熟悉二维数组,则无需观看下一个视频。这只是为新接触 C 语言的初学者准备的。

在之前的课程中,我没有涵盖二维数组,因此我将在下一个视频中进行讲解。如果您对此已经了解,您可以直接跳过下一个视频。下节课见!

003 'C' 中的二维数组

二维数组在C语言中的介绍

一维数组的复习

你已经了解了一维数组的概念。这里有一个一维数组的例子,包含3个元素,类型为uint8,每个元素占用1个字节。

索引一维数组

例如,索引0表示第一个元素,索引1表示第二个元素,索引2表示第三个元素。通过索引可以获取相应的值,例如:

二维数组的定义

二维数组是C语言中的一种数据结构,适用于表示表格形式的数据。表格由行和列组成。

初始化二维数组

初始化时,第一行的值放在大括号内,用逗号分隔。例如:

int scores[2][3] = {{10, 20, 30}, {22, 45, 33}};

索引二维数组

访问二维数组中的元素,可以通过两个索引值来定位。例如:

使用二维数组的好处

考虑一下成绩单的情况,假设有4个学生和4门课程的成绩。可以使用二维数组存储这些信息:

int marks[4][4]; // 4个学生,4门课程

在这种情况下,使用二维数组比使用一维数组更直观,避免了通过复杂的索引计算来获取特定元素。

内存存储方式

二维数组在内存中的存储方式与一维数组相似,都是连续分配内存。对于一个二维数组,数据按行存储,第一行的元素先存储,接着是第二行,依此类推。

这样,使用二维数组使得数据的组织和访问更为高效和直观。

004 练习-004 状态表实现 FSM 第 3 部分

让我们回到项目中。

进入 main.cpp。在设置函数中,让我们调用一个名为 protimer_state_table_init 的函数。

让我们在这里创建一个函数。

让我们在事件分发器下实现这个函数。它接收指向主对象的指针。

静态函数并且返回空。

现在,我们需要创建一个二维数组。我将其命名为 protimer_state_table[MAX_STATES][MAX_EVENTS or SIGNALS]

让我们去 main.h。

这里有状态。只需在此处添加一个条目,MAX_STATES。您还可以添加一个条目,MAX_SIGNALS。返回程序,这实际上是一个类型为函数指针的二维数组。

我们现在要做的是...

main.h,创建一个 typedef 函数指针定义。我将在这里做,定义为 typedef event_status (*e_handler_t)。这实际上接受这些类型的参数。

TypeDef

这是事件处理程序的函数指针类型。我们将使用此类型来创建数组:一个该类型的函数指针数组。之后,让我们初始化这个表。

现在,您需要用指针初始化这个二维数组。

您必须准确地像这样初始化它。第一行是 IDLE 状态,第一个事件是这个,第二个事件是这个,依此类推。在进行二维数组初始化时,您还可以这样做。

假设...

有两行,两列,您也可以这样做。首先,您写下行号。这表示您正在初始化第 0 行 = 然后使用大括号来初始化该行。您也可以这样做。这也是有效的初始化。

让我编译一下。

这表示您正在初始化第 0 行。这表示您正在初始化第 1 行。所以,我会使用类似的方法,这样会更清晰。

让我们回到这里。

首先,让我为 IDLE 状态初始化第 0 行 = 这是第一行初始化。接下来为 TIME_SET,[TIME_SET] = {},接下来为 COUNTDOWN,接下来为 PAUSE,接下来为 STAT。就是这样。

这就是 5 行。

之后,每行应有 7 个条目。有些是 NULL,因为这表示该事件在该状态下没有处理。

首先,您必须提到...

在 IDLE 状态下递增时间的事件处理程序。您必须提到它的地址。IDLE_Inc_time,下一个是 NULL。接下来是 tick time,IDLE_Time_tick 的地址。之后是 IDLE_Start_pause,之后是 NULL,接着是 IDLE_EntryIDLE_Exit 的地址。

MAX_SIGNALS 值为 7。

这里应该有 7 个条目。类似地,您为 TIME_SET、COUNTDOWN、PAUSE 和 STAT 做这个初始化。一旦您完成这个初始化,接下来您要做的是,将这个状态表指针存储在我们的主应用对象中。因为我们在分发函数中需要它。

我们必须访问这个状态表...

以获取适当的处理地址。这就是为什么,让我们去主应用结构,在这里创建一个指向状态表的指针。指针类型应该是什么?如果您对此有疑问,那就没问题。

只需输入 UPTR 并选择 uintptr_t,

然后我们创建一个变量 state_table,它是一个指针变量,仅用于保存该数组的地址。让我们回去。在这个函数中,让我们将其设为静态,因为它不能是局部变量。

它应该是一个静态局部变量,

因为这个变量的地址必须是持久的。在主对象中,获取指针 state_table 并在这里存储。这个表的地址。

让我们存储这个表的基地址...

或者说数组的基地址。基地址就是第一个元素的地址,即 [0][0]。您只需使用 &。指针的类型是这个。因此,您可能需要将其强制转换为 uintptr_t

希望您能完成这些行。

我已经附上了状态表文件,您可以下载,并根据该文件进行初始化。似乎有一个错误。这是 *。所以,现在它是好的。

在此基础上,

我想结束这节课,我们下节课再见。

005 练习-004 状态表实现 FSM 第 4 部分

继续我们的编码。

现在,让我们继续这个编码。

我们刚刚完成了这个函数。

现在,让我们在 main.cpp 中给出这个函数的原型,也许在这里。

当控制第一次进入设置函数时,

它会调用 state_table_init,并传入主应用结构的地址。

现在,我们已经完成了这一步。

现在我们的状态表在 mobjstate_table 变量中准备好了。

让我们回到这里。

现在,让我们先修复这个函数 protimer_init

让我们去那里。

protimer_init。现在,我们没有这个函数 protimer_state_machine,所以让我们把它移除。

首先,我们在这里做什么?

我们设置一个事件,active_state 设置为 IDLEpro_time 变量设置为 0,然后你需要调用 IDLE 状态的 ENTRY 函数。你需要从 mobjstate_table_pointer 中获取这个。

如何获取这个?

让我们创建一个变量。

e_handler,我会称它为事件处理程序。

事件处理程序是此类型的函数指针变量。这实际上是在 main.h 中定义的,希望你还记得这一点。

现在,e_handler,我将在这里顶部创建它。

e_handler

= 你如何从 mobj->state_table 指针中获取地址。

记住,这是一种指针变量

类型为 int *。这必须视为一维数组的指针。如何获取 IDLE 状态的条目地址?就是这个值。

如何获取?

首先,你必须到达这个位置。

如何到达这个位置?

行号是 IDLEIDLE 乘以 MAX_SIGNALSMAX_COLUMNS

IDLE 为 0,

它的值为 0;0 乘以 7 还是 0。

你到达这里。

对于这个,你必须加上它的值。

所以,你去那里。

+1,+1,+1,+1,+1,一个,两个,三个,四个,五个,你到达那里。

就这样,你必须解引用这个指针。

现在,如果你这样做,会发生什么?

存储在这里的值将被获取,

并分配给这个,实际上就是一个函数指针或函数的地址。

它的类型是这个。

我们可能需要进行类型转换。

就这样。

然后,直接跳转到这个函数指针。你知道,如何做到这一点。

e_handler

并且你必须传递 mobjee 的地址。

现在我们修复了初始化函数,

现在让我们回到 main.cpp,我们将为事件调度器编写代码。

让我们去事件调度器。

这里我们有状态,这没问题。源和目标,这也没问题。

active_state 变量,这也是可以的。

现在这里发生了什么?你调用状态机并传入这个事件。

我们必须像这样做,

status = mobj 获取状态表,active_stateactive_state 乘以 MAX_SIGNALS +

信号名称,我们可以从 e->sig 中获取。

让我们做一件事。

对不起,在这里你必须创建一个变量,称为 e_handler

你必须首先获取事件处理程序的地址

与该状态的此事件相关。

所以,让我们进行类型转换。

现在,这不是必须的,移除它。

这只是将 active_state 保存到某个临时变量中。 status = 你必须跳转到该事件处理函数,你必须提到 mobje

所以,如果 status = EVENT_TRANSITION

那么你就把它保存在目标中,这没问题。这里 EXIT

在这里,你再次不需要做所有这些事情。

只需移除这个。

所以,你必须为源状态运行退出操作。

只需移除这个。源状态的退出操作。

获取 e_handler = mobj->

state_table,为源状态运行退出操作。

所以,源

乘以 MAX_SIGNALS + EXIT

然后,再次像这样调用。

类似地,你必须复制并粘贴到这里。

这是 ENTRY

这是目标。

这里,不是 e

&ee

现在,我们可能会遇到 NULL 条目。

所以,我认为最好在这里检查 NULL。

因此,你必须在这里放置一个条件 if(e_handler)

无论何时你解引用那个指针,

函数指针,你必须放置 if 条件。

现在,让我们编译。

你可以看到这里,它正在构建当前项目,

当前项目是打开的。

有时,如果你想选择不同的项目来构建,

我会告诉你如何做到这一点。

当前正在构建,你可以在这里看到。

显示,这就是当前项目。

这是成功的。

现在,假设,我想在这个工作区中构建一个不同的项目。

我现在想构建这个项目。

你需要做的就是,在这里选择那个项目。

我会选择 004Protimer_SH

然后,它会切换到这个。当你构建时,

它实际上构建那个项目。

就这样,你可以在不同项目之间切换。

我会回到 ST

由于我们的项目编译良好。

我们将检查硬件,以查看输出是否与之前相同。

我只是

将这个项目加载到板上。

因此,项目表现正常,完全如之前一样。

尝试在你的桌子上重现这一点并重新验证。

以此为结束,我想结束这一讲,

关于使用状态表方法实现状态机。

WangShuXian6 commented 2 days ago

08 - UML 分层状态机和 QP™ 框架

001 分层状态机 (HSM)

讲座回顾

欢迎回来,今天我们将探讨HSMs(层次状态机)。

平面状态机回顾

在之前的练习中,我们使用了平面状态机的方法。平面状态机意味着没有组合状态。状态图中只包含几个状态,彼此之间通过箭头相连,这些都是外部转移。

随着引入更多功能,状态数量增加,状态机图的复杂性也随之增加,维护和可视化变得繁琐。我们将探讨如何使用层次状态机(HSMs)来解决这一问题。

使用平面状态机的缺点

随着状态数量的增加,转移的数量也随之增加,复杂性加大。平面状态机难以可视化、绘制和排查,遗漏转移的风险也随之增加,可能导致代码重复和错误的引入。

什么是层次状态机?

层次一词在日常生活中随处可见,例如在企业中存在严格的层次结构。在这个结构下,所有员工的工作都是有层次的。在状态机中,我们也可以绘制类似的层次。

例如,有一个状态S1,下面有几个子状态。这种方式允许我们创建多个层级的状态。

HSM的示例

考虑有3个状态S1、S2和S3,它们共享相同的行为——当触发器T1发生时,状态会转移到S0。这提示我们可以将这个状态机图转换为层次结构。

在这个例子中,SS1是一个复合状态,包含三个子状态S1、S2和S3。它继承了这些状态的共同行为。当T1发生时,状态机从SS1转移到S0。

HSM的优点

在层次状态机中,当状态为S1时,T1的处理被传递到其父状态SS1,由其处理。这样的结构简化了状态机图的复杂性,并减少了代码重复。

在平面状态机中,可能需要在多个地方定义转移行为,而在HSM中,只需在一个地方定义。

未来的学习

接下来的讲座中,我们将通过代码示例深入了解HSM。

002 逐步完成和 QP™ 框架

在这个应用程序中,您可以看到生成事件的代码。这是事件生成器代码。事件生成器将事件发送到事件调度器,调度器函数,然后调度器函数将事件发布到状态机。

状态机执行该事件的行为操作。然后,状态机返回给事件调度器,事件调度器再返回给超级循环。

这是我们的Arduino循环函数。我们可以将整个步骤总结如下:

  1. 代码检查事件是否可用,如果事件可用,
  2. 则将事件发布到状态机,
  3. 状态机执行与该事件相关的操作,然后状态机返回给超级循环。

这实际上是完成运行模式(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,以使用该框架。

所以,我稍后会给你展示。现在,让我们下载这两个东西。

003 下载 QP™ Nano Arduino 库

安装QP-nano Arduino库

现在,让我们下载QP-nano Arduino库。为此,前往资源部分并选择Arduino。

下载QP Arduino

下载适合你机器的QP Arduino,我将选择Windows。

安装QP框架

首先,让我们安装QP框架。只需双击它。

选择安装目录

选择目录,默认情况下会安装在“C”目录下。这是可以的。

取消不需要的选项

在这里,你可以取消不需要的选项。例如,我们在这里不使用ARM处理器,因此可以取消这些选项。我们也不使用QP/C++,所以也可以取消该选项。但我会保留所有选项,因为我有足够的磁盘空间。

完成安装

我将点击下一步,然后下一步,最后安装。这需要一些时间。现在,让我们点击完成。

安装QP Arduino库

安装完成后,现在让我们安装QP Arduino库。返回下载页面并解压缩它。

复制库到Arduino

现在,你需要复制所有文件并将其粘贴到Arduino草图书位置。为此,打开Arduino IDE。

打开Arduino IDE

在Arduino IDE中,点击文件,然后选择首选项。复制该位置。

打开文件资源管理器

打开文件资源管理器并进入该位置。这是我的草图书位置。在这里,你需要粘贴刚刚复制的QP Arduino库。

粘贴库文件

只需复制QP Arduino库,并在草图书位置中粘贴即可。

替换文件

我已经完成了这一步,所以它询问是否替换某些文件。我将选择替换目标位置中的文件。

文件已复制

现在,你可以看到文件已在此处复制。如果你查看库中,可以看到两个库:qpcpp(适用于ARM架构的Arduino板)和qpn(即QP-nano框架,适用于AVR架构的Arduino板)。

整合Arduino库成功

这包含了所有框架相关的源文件。我们成功集成了Arduino库。

下一讲内容

在下一讲中,我们将测试Arduino板上的转换执行序列和事件传播。这实际上是一个示例,用于理解各种转换执行序列和嵌套层次状态机图中的事件传播。

示例已在QP框架中

这个示例已包含在你下载和安装的QP框架中。它位于特定位置,因此你可以直接在你的机器上测试该应用程序。

下次实验

在下一讲中,我们将在Arduino板上进行测试,从而学习如何将这个工具与Visual Code和PlatformIO扩展结合使用。将会在下次讲解中详细介绍。

004 HSM 转换执行顺序测试

课程回顾

欢迎回到讲座。在上一节课中,我们安装了QP框架,这同时也安装了QM工具,一个图形建模工具。此外,我们还安装了QP-nano Arduino库。

理解状态机

在本节课中,我们将理解嵌套层次状态机中的各种转换执行序列,并了解事件传播。当事件发送到嵌套层次状态机时,状态之间是如何转换的,以及各种行为操作的执行顺序是什么。

示例代码

为了理解这一点,已经有一个示例代码可在指定位置获取。你可以直接在Visual Studio中启动该应用程序进行测试。

无需连接板子

这不需要连接任何硬件。你只需前往QP安装文件夹。这个文件夹安装在“C”驱动器下的“qp”目录中,然后进入“qpc”,接着进入“examples”和“workstation”,在这里打开“qhsmtst”。

启动项目

打开这个项目,并启动这个Visual Studio解决方案。为此,你需要先安装Visual Studio,我已经安装好了。让我们打开它。

打开QM模型文件

项目已打开,接下来我们回到QM模型文件。这需要使用你在上一节中安装的QM工具来打开。

启动QM工具

在上一节中安装的QP框架中也包括了QM工具。现在让我们启动它。

打开模型文件

使用QM工具打开这个模型文件。我会复制这个路径,接着打开文件,选择“打开模型”。

扩展包

打开后,展开这个包,双击以打开该类的状态机。我们稍后会理解如何从头开始创建所有这些内容,例如创建类、添加属性、添加操作和状态机等。

嵌套层次状态机

这是该项目的嵌套层次状态机。可以看到,它接收各种事件,事件被命名为A、B、C,一直到I。这些事件发送到该项目以观察各种转换。

测试项目

现在,在机器上测试这个项目。你已经在Visual Studio中打开它,只需编译即可。

编译与运行

编译并运行,它将开始运行。现在,你可以发送各种事件。

发送事件

例如,代码当前处于状态s211。让我们发送一个事件,比如说发送G事件。

观察状态变化

发送事件G后,状态机的状态从s211转换为s11。这意味着状态机成功地完成了从s211到s11的转换。

执行顺序

这就是事件G的转换执行序列。在状态机状态为s211时接收到事件G,执行了各种动作的顺序。

集成Arduino项目

在此之前,我们将了解如何将该项目与Arduino集成,并学习如何创建文件,以及使用QP框架的代码生成指令生成各种函数声明和定义。如果你不想尝试Arduino,也可以按照我刚才解释的方式直接在计算机上进行尝试。

结束语

这节课到此为止,你可以向该程序发送各种事件,以观察转换执行序列。

WangShuXian6 commented 2 days ago

09 - UML HSM 转换执行顺序

001 练习-006 在 Arduino 上测试 HSM 转换执行顺序

创建Arduino项目

欢迎回到讲座。在本节课中,我们将创建一个新的Arduino项目来测试已经提供的模型qhsmtst.qm,目的是通过发送各种事件来理解嵌套层次状态机中的各种转换序列。

打开Visual Code IDE

首先,让我们关闭Visual Studio,并打开Visual Code IDE。

创建新项目

要创建新项目,进入PlatformIO首页,点击“打开”,然后选择“新项目”。

设置项目名称

项目名称设为006QHsmTest,选择板子为Uno,框架选择Arduino,位置使用之前的目录,点击完成。

创建新文件夹

新项目QHsmTest已创建,在这里创建一个新文件夹,命名为qm,用于存放qm模型。

下载模型文件

现在非常重要的是,下载与本节课相关的qm模型文件,并将其保存在qm文件夹中。

粘贴模型文件

我将把模型文件粘贴到该目录的qm文件夹中。若无法直接粘贴,可以在文件资源管理器中打开qm文件夹,进行粘贴。

完成步骤

完成上述步骤后,模型文件就会成功放置在qm文件夹中。

002 在 QM 工具中添加文件

生成QM模型文件代码的步骤

  1. 打开QM模型文件

    • 使用QM工具打开QM模型文件。
  2. 确认文件格式

    • 文件应显示为qpn格式,而不是qpc。若显示为qpc,可以忽略。
  3. 自动生成代码

    • 在项目中,可以指定生成代码的文件名。项目加载时,已经有两个文件:一个C头文件和一个C文件,这些文件包含了QP框架的代码生成指令。
  4. 代码生成指令

    • 你可以使用不同的指令来控制生成的代码类型:
      • 使用declare指令生成函数声明。
      • 使用define指令生成函数定义。
  5. 创建新的目录和文件

    • 创建一个新的目录并指定路径,如果输入.,则表示当前模型文件所在目录。
    • 添加新文件,比如命名为file.cpp
  6. 生成代码

    • 点击生成代码,查看生成的文件。
  7. 编辑生成的文件

    • 生成的文件默认是只读的,可以通过外部IDE进行编辑。
    • 标记文件为“External”,以便在外部IDE中打开和编辑。
  8. 理解代码生成指令

    • 参考文档中的代码生成指令,了解如何细化要生成的代码内容。
    • 主要指令包括declaredefinedefine1
  9. 添加状态处理函数的声明

    • .cpp文件中使用代码生成指令,生成状态处理函数的声明。
    • 使用declare指令,指定包名和类名,完成状态机的函数签名生成。
  10. 总结

    • 了解QM工具的操作和代码生成指令,可以帮助你有效地生成和管理项目中的代码。

003 使用 QM 工具向文件添加代码

欢迎回来

现在,让我们为 Test.h 编码

我将其标记为外部,但暂时让我去掉这个限制,将其设为内部。

首先,添加包含保护

这里,我们创建一个枚举,包含状态机的所有事件。如果你点击那个状态机,可以看到它实际上接受许多事件,事件从 '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 头文件

现在,让我们包括这个头文件 'bsp.h'。并生成代码。它在这里。

转到 main.cpp

让我们去 main.cpp。main.cpp 产生事件。它接收事件或生成事件,并将其发送到状态机。因此,它必须知道支持哪些事件。这就是为什么让我们包含 QHSM_Test.h。

编译

现在让我们编译。我们将查看它是否可以正常构建。

选择项目

我实际上正在编译旧项目。所以,我必须选择项目,活动项目是 006 QHsmTest。你可以看到错误消失了。

继续编译

现在,让我们编译。有一个错误。让我们检查一下。这个函数未使用,这没关系。我们稍后会用到。它说这个宏在这个作用域中未声明。我们实际上在这个头文件中使用了它。你要做的是,在 main.cpp 中,在包含这个头文件之前,先包含 'qpn.h'。

编译成功

让我们编译。现在,它构建成功。

总结

到目前为止,我们完成了所有这些步骤。在下节课中,我们将探索 QP-Nano 的一些 API,以将事件发送到状态机。我会在下节课见到你。

004 添加类属性

事件发布到状态机

在接下来的工作中,我们需要将事件发布到状态机。为此,我们需要探索 QP Nano 的 API。可以访问 state-machine.com/qpn 查看 API 参考。以下是我们需要使用的 API。

状态机的初始化

我们首先调用分层状态机的构造函数 QHsm_ctor(),这是构造函数。之后调用 QHSM_INIT,如果有任何事件,可以使用宏 QHSM_DISPATCH 来分派事件。还有一些辅助函数可以获取当前状态等。需要按以下顺序调用这些操作:

调用顺序

  1. 调用构造函数 QHsm_ctor
  2. 初始化状态机 QHSM_INIT

框架基于超类 QHsm,这是一种超类,包含一些属性或数据字段,基本上是一个结构体。我们的应用程序结构或类派生自这个超类。

嵌入超类到应用结构体

我们已经在应用结构中嵌入了超类结构,这意味着现在我们的结构继承了这个超类的属性。该结构体包含一个重要字段——状态处理器,用于保存当前活动状态处理器的指针,这是一个状态变量。

如何初始化状态变量

要初始化状态变量,可以使用构造函数 QHsm_ctor(),它的作用是通过将初始伪状态分配给状态机的当前活动状态来完成 HSM 初始化的第一步。

  1. 可以在 main.cpp 文件中调用这个函数,或者从设置函数中调用。
  2. 在调用此 API 时,需要提供两个参数:
    • me 指针,指向超类的指针。
    • 状态机初始状态处理器的地址。

在 IDE 中调用构造函数

可以在 main.cpp 中调用构造函数 QHsm_ctor。由于我们没有为此结构体创建任何实例,需要在工具中创建实例。可以通过右键点击并选择“添加属性”来添加一个实例,也可以手动添加。

属性类型

在工具中添加属性时,可以选择两种类型的属性:

  1. 类属性:直接将属性添加到类上。
  2. 自由属性:将属性添加到包级别。

类属性的静态与非静态

类属性有两种类型:

实验工具中的静态与非静态

可以在工具中实验选择静态和非静态属性,以了解其行为。例如,可以创建一个非静态类属性 foo,然后查看其生成代码。选择静态选项后,该属性会从类结构中消失,变成文件级别的静态变量。

自由属性

自由属性可以在文件范围内定义,既可以是静态变量,也可以是全局变量。可以通过在工具中点击包并选择“添加属性”来创建自由属性。

005 添加类操作

构造函数的创建和操作的添加

这是我们类的构造函数,作为一个操作添加到模型中。在右侧可以看到它被称为自由操作。在之前的课程中,我们已经了解了如何添加类属性和自由属性。现在,让我们学习如何添加类操作。

类操作

类操作是添加到类中的方法或函数。通常有两种方法可以在类中创建:静态方法和非静态方法。这些术语来源于 C++。在 C 中,也可以理解为添加到结构体的操作,但不能在结构体中放置任何方法。而在 C++ 中,可以实现。例如,有一个类 SomeClass,在类中可以创建方法,比如 void setdata,这是一个非静态操作或方法。它可以访问类的属性,例如 somedata 是一个非静态属性。该方法可以访问该变量并将其初始化为其他值。或者也可以使用 this 指针,这是指向类当前实例的指针。在非静态操作中 this 指针是可用的,而在 C 中的等效指针是 me 指针,由 QM 模型创建。

静态与非静态操作

在静态操作中,this 指针不可用,意味着无法访问类的非静态属性,但可以访问静态属性。静态属性在所有类的对象中只有一份,而非静态属性则是每个对象独有的。如果创建多个对象,则每个对象都会有自己的非静态属性副本。

使用工具创建操作

可以使用工具添加静态类操作、非静态类操作,以及类构造函数。

  1. 静态类操作:在 C 中,没有 me 指针的函数,相当于在 C++ 中没有 this 指针的函数。创建静态类操作是为了访问或查询静态变量,因为在静态操作中无法访问非静态属性。
  2. 非静态类操作:在 C 中,非静态类操作对应于接受 me 指针的函数,用于访问主应用结构体的实例。类似于在 C++ 中,非静态操作可以访问 this 指针。
  3. 类构造函数:构造函数是用于初始化结构体实例的函数,返回类型必须为 void

创建静态与非静态操作

在工具中,可以通过选择“添加操作”选项来添加类操作。要创建静态操作,需要勾选“静态”选项。例如,添加一个名为 this_is_static_operation 的静态操作并生成代码。可以看到,生成的代码中没有 me 指针。

然后,可以创建一个非静态操作 this_is_non_static_operation,将其设置为私有,并生成代码。这次可以看到该操作接收 me 指针,并可以使用该指针访问结构体的成员元素。

自由操作

可以通过点击包并选择“添加操作”来添加自由操作。自由操作的返回类型为 void,可以设置为文件范围或全局范围。如果希望其他文件共享该操作,则可以设置为全局。

构造函数的实现

构造函数作为自由操作添加,返回类型必须为 void,并设置为全局,以便在 main.cpp 中共享该构造函数。然后,可以在 .h 文件中声明构造函数并在 .cpp 文件中定义它。

006 添加断言失败回调

处理 Q_onAssert 错误

我们遇到了 Q_onAssert 错误。让我们深入探讨这个错误。需要检查文件 qassert.h,找到并展开 Q_onAssertQ_onAssert 是一个回调函数,当断言失败时会被调用。

在框架的任何 API 中,如果由于无效参数或过多的分层状态机嵌套导致断言失败,就会发生断言错误。断言失败的原因可能有很多种。如果发生断言失败,将通过此回调函数通知您的应用程序。需要在 main.cpp 中实现该函数。

在 main.cpp 中实现 Q_onAssert

main.cpp 中实现该函数,传入的参数包含以下信息:

  1. 模块名称:第一个参数为发生断言失败的文件或模块的名称,这是一个以空字符结尾的 C 字符串,可以直接打印该字符串。

我们可以使用 Serial.println 来打印模块名称。实现步骤如下:

  1. 打印断言信息,例如 "Assertion failure"。
  2. 打印发生断言失败的模块名称。
  3. 打印断言发生的位置,并在此暂停程序执行。

编译并验证

完成上述步骤后,编译代码。构建已成功。

后续内容

在下一节中,我们将探索其他 API,包括 QHSM_INITQHSM_DISPATCH。我们下节见。

007 QHSM_INIT() 和 QHSM_DISPATCH() API

处理 Q_onAssert 错误

我们遇到了 Q_onAssert 错误。接下来让我们深入了解这个错误。需要查看文件 qassert.h,找到并展开 Q_onAssertQ_onAssert 是一个回调函数,当断言失败时会被调用。

在框架的任何 API 中,如果由于无效参数或过多的分层状态机嵌套导致断言失败,就会发生断言错误。断言失败的原因可能有很多种。如果发生断言失败,将通过此回调函数通知您的应用程序。需要在 main.cpp 中实现该函数。

在 main.cpp 中实现 Q_onAssert

main.cpp 中实现该函数,传入的参数包含以下信息:

  1. 模块名称:第一个参数为发生断言失败的文件或模块的名称,这是一个以空字符结尾的 C 字符串,可以直接打印该字符串。

我们可以使用 Serial.println 来打印模块名称。实现如下:

  1. 打印断言信息,例如 "Assertion failure"。
  2. 打印发生断言失败的模块名称。
  3. 打印位置,并在这里暂停程序执行。

接下来,编译代码。构建成功。

后续内容

在下一节中,我们将探索其他 API,包括 QHSM_INITQHSM_DISPATCH。我们下节见。

008 练习-006 测试

状态机测试和事件处理分析

欢迎回到课程。在之前的课程中,我们完成了该练习,现在让我们测试这个状态机。在 setup 函数中添加配置 UART 波特率的代码,并发送一条消息。接下来,编译代码并下载到开发板上。打开 Arduino IDE 的串行监视器(选择工具中的串行监视器),确保选择的是 Uno 板。

分析初始转换

可以看到应用程序已启动,并且执行了与初始转换相关的操作。让我们分析这些步骤。

  1. 模型从外层状态开始。最外层执行的是初始转换的操作,并打印 "top-INIT"。
  2. 然后进入 s2 状态。按照课程中的介绍,进入嵌套状态时,ENTRY 操作会从最外层状态依次到最内层状态执行。
    • 最外层状态是 s,因此首先执行 s-ENTRY 操作。
    • 接着,状态机进入 s2,执行 s2-ENTRY 操作。
  3. 然后,s2 的初始伪状态指向 s21,因此进入 s21 并执行 s21-ENTRY 操作。
  4. 最后进入 s211 状态并执行 s211-ENTRY 操作。此时状态机停留在 s211

事件 D 的处理

假设状态机在 s211,然后我们发送事件 D。以下是处理过程:

  1. 事件 D 触发了从 s211 的外部过渡。首先执行与 D 相关的操作 s211-D
  2. 然后,由于这是一个外部过渡,接下来执行 s211-EXIT 操作。
  3. 状态机进入 s21,此时不再执行 s21 的 ENTRY 操作,因为它是 s211 的直接父状态。
  4. 然后,s21 的初始伪状态指向 s211,因此再次进入 s211 并执行 s211-ENTRY 操作。

事件 B 的处理

如果状态机在 s211 并收到事件 B

  1. 因为 s211 没有处理 B,事件会向上传播到 s21
  2. s21 处理事件 B,触发一个从 s21s211 的局部过渡。
  3. 首先执行 s21-B 操作,然后 s211 执行 EXIT 操作,再次进入并执行 ENTRY 操作。
  4. 最终状态机停留在 s211

事件 A 的处理

假设状态机在 s211 并收到事件 A

  1. A 未被 s211 处理,事件向上传播至 s21
  2. s21 处理事件 A,这是一个自我过渡。
  3. 执行 EXIT 操作,从最内层的 s211 到外层的 s21
  4. 然后按从外层到内层的顺序执行 ENTRY 操作,最终状态机停留在 s211

事件 I 的守卫条件

发送事件 I 时,状态机会逐层检查每个状态的守卫条件:

  1. 初始状态 s211 无法处理 I,事件向上传播。
  2. 到达 s2 时,检查 foo 变量的值:
    • 初始时 foo 为 0,因此守卫条件成立,执行 s2-I 操作。
    • foo 设置为 1,再次发送事件 I 时守卫条件不成立,因此事件继续向上传播到 s 并执行 s-I 操作。
  3. 此时状态机交替执行 s2-Is-I,取决于 foo 的值。

事件 C 的处理

假设状态机在 s211 并收到事件 C

  1. s211s21 均未处理事件 C,事件向上传播至 s2
  2. s2 处理事件 C,执行相关的过渡操作 s2-C
  3. 由于这是一个外部过渡,依次执行从 s211s2 的 EXIT 操作。
  4. 状态机转向 s1 并执行 ENTRY 操作,最终进入 s11

事件 G 的处理

s11 状态下发送事件 G

  1. 如果 s11 无法处理事件 G,事件向上传播至 s21
  2. s21 处理事件 G,触发从 s2s 的过渡。
  3. 执行 s2 的 EXIT 操作,并进入 s,然后根据伪状态指向的状态进入最终的 s11

009 练习-006 测试历史状态

浅历史和深历史状态

历史状态有两种类型:浅历史和深历史。在状态图中显示的 "H+" 表示深历史。当首次重置板子时,状态机会停留在 s211。最初 s1 没有历史状态,因为从未进入过 s1,所以历史状态为空。

无历史状态的情况

首先分析没有历史状态的情况。打开模型并在 s1 中添加一个新状态 s12,并定义其 ENTRY 和 EXIT 操作为 s12-ENTRYs12-EXIT。重置板子后,状态机将进入 s211,而 s1 没有历史状态。此时,发送事件 F

  1. F 事件没有被 s211 处理,因此事件向上传播到 s21s2
  2. s2 处理 F 事件,执行 s2-F 操作。
  3. 随后依次执行 s211s21s2 的 EXIT 操作。
  4. 因为 s1 没有历史状态,所以采取默认路径,进入 s1 的默认状态 s11

有历史状态的情况

当状态机访问过 s1 后,会记录 s1 的历史状态。例如,当状态停留在 s12 并返回 s211 时,历史状态记录为 s12

  1. 如果在 s211 状态再次发送 FF 事件会触发到 s2
  2. 执行 s2-F 操作,随后依次执行 EXIT 操作。
  3. 然后根据 s1 的历史状态进入 s12 而非 s11,因为历史状态指向 s12

事件处理示例

事件 E 的处理

假设状态机停留在 s11,并接收事件 E

  1. E 未被 s11s1 处理,事件传播到 s
  2. s 处理 E,这是一个局部过渡。
  3. 执行所有 EXIT 操作,然后重新进入 s11

事件 D 的处理

假设状态机在 s11foo 变量的初始值为 0,然后发送事件 D

  1. s11 处理事件 D,但由于守卫条件 foo 值为 0,守卫失败。
  2. 事件 D 向上传播到 s1,守卫条件成立,foo 设置为 1,并执行 s1-D 操作。
  3. 状态机过渡到 s,并通过伪状态进入 s11,依次执行 s-INITs1-ENTRYs11-ENTRY

重新发送事件 D

foo 值为 1 的情况下再次发送事件 D

  1. s11 成功处理 D,执行 s11-D 操作,进行局部过渡回到 s1
  2. 执行 s11 的 EXIT 操作,然后重新进入 s11

总结

通过不同的事件和历史状态的处理,我们可以理解状态机的过渡执行顺序和事件传播机制。如果有疑问,不用担心,随着绘制状态图和完成练习,这些概念将会逐步清晰。

以上就是本节课程的内容,期待在下节课程中继续学习。

WangShuXian6 commented 2 days ago

10 - 使用 QM 工具的 UML HSM 练习

001 练习-007 闹钟简介

练习 007:ClockAlarm

在这个练习中,我们将使用软件实现一个实时时钟(RTC),不使用任何 RTC 芯片。本项目的需求如下:

  1. 向用户显示当前时间。
  2. 允许用户修改时钟,包括设置时间、设置闹钟、闹钟通知。
  3. 显示日期、月份、年份和星期几。
  4. 提供日期信息的设置和修改功能(这个部分我还没有实现,可以作为您的拓展作业)。

电路图

该项目的电路图与上一个项目非常相似,但有以下两个变化:

  1. 按钮

    • 按钮 B1:多功能按钮,可用于 SETCLOCK_SET 功能。用于进入时钟设置模式。
    • 按钮 B2:也是多功能按钮,用于确认(OK)。在完成某些设置后,可以使用此按钮进行确认。该按钮也可用于进入闹钟设置模式。
  2. LCD 背光控制:Arduino 的数字引脚 4 通过一个电阻连接到 LCD 背光的阳极,以控制 LCD 背光的亮度。

项目演示

接下来,我们将展示该应用程序的演示,以便更清晰地了解应用程序的界面和项目的其他需求。

002 练习-007 闹钟演示

练习 007:ClockAlarm 应用演示

接下来,我们将演示该应用的功能。通过演示,您将更清晰地了解该应用的需求。我们将使用分层状态机的方法来实现此应用。

在该项目中,我重新使用了之前的电路和组件。不同的是,当前应用只需使用 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 后返回计时模式。

中途退出和恢复

在进行设置过程中,如果需要中途退出并恢复到先前的状态(如闹钟触发时),应用会记录当前状态,待闹钟提醒结束后自动回到设置状态。无论是在时钟设置还是闹钟设置模式下,都可支持这种“历史状态”功能。

快照功能

进入时钟设置模式时,系统会捕捉当前时间,用户可以从该时间点开始逐位调整时间。

003 练习-007 使用的状态、信号和数据结构

应用状态机的设计和实现

您刚刚看到了应用的演示,通过演示您可以想象出应用中的一些状态。例如,应用有一个展示当前时间的状态,我们称之为计时模式(或计时状态)。用户可以进入时钟设置模式,因此可以定义一个时钟设置状态。此外,用户也可以进入闹钟设置模式,因此还需要一个闹钟设置状态。在开始建模之前,可以将这些状态写在纸上。

状态机的初始状态

状态转换

  1. 在计时模式下,有两个主要事件:“SET”事件和“OK”事件。

    • “SET”事件将应用程序从计时模式切换到时钟设置模式。
    • “OK”事件将应用程序从计时模式切换到闹钟设置模式。
  2. 当应用处于时钟设置或闹钟设置模式时,用户可以随时取消设置,因此可以定义一个通用的Abort转换,从设置状态返回到计时模式。通过定义一个包含这两个设置状态的超级状态,并提供一个从设置状态到计时状态的通用转换,可以实现这一点。

  3. 如果用户完成设置并按下“OK”按钮,应用程序将保存设置并返回计时模式。与Abort的区别在于,“OK”按钮会保存设置,而Abort则不会。

状态的超级状态设计

可以创建一个包含所有状态的超级状态,称为Clock超级状态。在这个超级状态中定义一个“Alarm”事件,该事件将应用程序切换到闹钟通知状态。无论应用处于哪个状态,只要闹钟响起,都会进入闹钟通知状态。当用户按下“OK”按钮后,闹钟通知结束,应用将返回到之前的历史状态(Clock超级状态中的上一次状态),即用户可能正在进行的时钟设置或闹钟设置。

子状态设计

在时钟设置模式中,需要定义更多的子状态,以配置小时、分钟、秒等信息。这部分将在后续课程中详细讨论。

主应用结构的设计

需要创建一个主应用结构来绘制状态机图。此主应用结构将继承自 QHSM,因为这是一个分层状态机。此外,我们将向主结构中引入一些属性:

将根据状态机的需求逐步添加其他变量。

信号和事件

在状态机中使用以下信号和事件:

本节课的任务

  1. 创建一个新项目 007ClockAlarm
  2. lcd.clcd.h 文件(与本课附带的文件相同)复制到项目文件夹中的 Src 文件夹。这些文件与之前的练习中使用的 LCD 文件相同。

下一节课程我们将开始实现此应用。

004 练习-007 绘制 HSM

创建 ClockAlarm 状态机模型

欢迎回到本节课程。我们继续进行练习 007。首先,创建一个新文件夹,命名为 'qm'。在该文件夹中存放即将使用 qm 工具创建的新模型文件。

创建新模型

  1. 启动 qm 工具。
  2. 选择“文件”->“新建模型”,选择“qpn”,None,并为项目或模型命名为 ClockAlarm
  3. 指定路径为刚创建的 'qm' 文件夹。

创建包

在创建状态机模型之前,首先需要创建一个包。右键点击选择“添加包”。包用于将不同元素分组并提供命名空间。项目可以包含多个包,若需要在不同包中复用变量名或函数名,可以指定不同的命名空间。选择“组件”作为类型,为包命名为 HSMs

创建类

在包中创建类,右键点击选择“添加类”,将类命名为 Clock_Alarm。选择 QHsm 作为超类,因为 Clock_Alarm 类派生自 QHsm。保存。

添加属性

Clock_Alarm 类中添加以下属性:

  1. current_time:类型 uint32_t,私有非静态属性。
  2. temp_time:类型 uint32_t,私有非静态属性。

可以继续添加其他属性。

添加目录和文件

在包中添加一个目录,路径为 ../src,用于存放生成的源文件。然后添加两个文件:

确保头文件中包含 include guards。保存并生成代码,接受 GPL 许可,生成的文件将显示在指定目录中。

创建状态机

在类中添加状态机,右键点击选择“添加状态机”。双击打开状态机画布,在画布中绘制状态和转换。

  1. 创建三个简单状态(如“计时状态”、“时钟设置状态”和“闹钟设置状态”)。
  2. 添加一个超级状态,用于包含上述状态。
  3. 设置状态名称:
    • Ticking
    • Clock
    • Settings
    • Clock_Setting
    • Alarm_Setting

绘制转换

  1. 初始状态:将应用的初始状态设为 Ticking
    • 使用初始转换组件,从初始伪状态指向 Ticking 状态。
  2. 状态转换:
    • 当信号 SET 到达时,从 Ticking 过渡到 Clock_Setting
    • 当信号 OK 到达时,从 Ticking 过渡到 Alarm_Setting
    • 定义 SettingsTicking 的 ABRT(中止)转换。
    • Clock 状态中添加 Alarm_Notify 状态,用于闹钟通知。

设置历史状态

添加 Clock 的深历史状态(Deep History),用于在闹钟通知结束后返回之前的状态。添加一个转换从 Alarm_NotifyClock 的历史状态,事件为 OK

保存项目并完成当前步骤。我们将在下节课程中继续完善。

005 练习-007 添加主应用对象和构造函数

生成代码并定义信号

  1. 打开 ClockAlarm_SM.h 文件,首先定义信号。

    • 创建 ClockAlarm_Signals 枚举,并初始化第一个信号为 Q_USER_SIG
  2. 接下来,打开 ClockAlarm_SM.cpp 文件,声明所需的内容。

    • 将类名拖放到声明区域。
    • 添加必要的头文件:
      #include <Arduino.h>
      #include "qpn.h"
      #include "Lcd.h"
      #include "ClockAlarm_SM.h"
  3. 生成代码,检查生成结果。

    • SM.h 文件中可以看到信号枚举。
    • .cpp 文件中可以看到结构体声明和所有状态处理程序的签名,目前还没有实现定义。

解决错误并设置库路径

  1. 参考之前的项目,打开 platformio.ini 文件。

    • 将 LCD 库路径从上一个项目复制过来,并添加到当前项目的 platformio.ini 文件中。
    • 确保双引号正确闭合。
  2. 再次生成代码并编译,确认构建成功。

定义状态处理程序和创建主对象

  1. 生成并保存所有状态处理程序的定义。

  2. 创建主应用对象,将其作为静态类属性添加:

    • 在类上右键点击,选择“添加属性”。
    • 将属性命名为 obj,类型为 Clock_Alarm,设置为静态变量,访问权限为私有。
    • 保存并生成代码,确认已成功创建 Clock_Alarm 类型的对象。

添加构造函数

  1. 为类添加构造函数,构造函数将作为自由操作添加。

    • 在包中选择“添加操作”,命名为 Clock_Alarm_ctor,返回类型设为 void(构造函数必须为 void)。
    • 将构造函数设为全局函数,并在代码部分调用超类的构造函数 QHsm_ctor
  2. 在构造函数中初始化超类:

    • 使用 Clock_Alarm_obj.super 作为第一个参数。
    • 使用 Q_STATE_CAST 宏将初始状态处理程序的地址作为第二个参数。
  3. 可选:构造函数可以接受参数,以便主函数传递初始值用于初始化主类对象的属性。

  4. 保存并生成代码:

    • SM.h 中声明构造函数。
    • SM.cpp 中定义构造函数,实现所需的初始化逻辑。

生成代码后,您将在 ClockAlarm_SM.cpp 中看到构造函数的实现。

006 Atmega328p 计时器外设说明

ATmega328P 微控制器的 Timer 外设

在本节课中,我们将深入了解 ATmega328P 微控制器的 Timer 外设,因为我们将在应用中使用 Timer ISR 来跟踪时间。

时间变量与计时机制

在我们的主结构中,current_time 变量用于存储时间,以 100 毫秒为单位递增。例如:

请注意,current_time 使用 24 小时制,取值范围为 0 到 864000(即 24 小时,以 100 毫秒为单位)。

其他时间变量

Timer 外设概览

ATmega328P 具有三个 Timer/Counter 外设:

  1. Timer/Counter0:8 位定时器,Arduino 框架用于 millis 功能,不在本项目中使用。
  2. Timer/Counter1:16 位定时器,我们将在本项目中使用。
  3. Timer/Counter2:8 位定时器。

使用 Timer1

我们将使用 Timer/Counter1 作为 16 位定时器。定时器包含两个比较寄存器,当计数器的值与比较寄存器的值匹配时,会触发比较中断。通过该中断来实现变量的递增,从而实现计时功能。

ATmega328P 的系统时钟与 Timer 分频

在 Arduino Uno 板上,ATmega328P 使用 16 MHz 的外部晶振作为主系统时钟。我们可以使用定时器的分频器减慢时钟以控制计数速率。TCCR1B 寄存器中的分频设置如下:

输出比较匹配值计算

假设我们希望生成 100 毫秒的时间基准。按以下方式计算输出比较匹配值:

  1. Tick 分辨率为 16 微秒。
  2. 需要生成 100 毫秒的时间基准,所需的计数为 100 ms / 16 µs = 6250

因此,输出比较匹配值应设置为 6250 - 1,因为计数从 0 开始。

Timer1 的 CTC 模式

我们将以“CTC 模式”运行定时器,即在比较匹配时自动清零计数器并触发中断:

  1. 每当 TCNT1 寄存器的值与输出比较寄存器(如 OCR1A)匹配时,计数器将自动清零并生成中断。
  2. 为此,我们将在代码中定义一个 Timer1 比较 ISR。ISR 将在 ClockAlarm_SM.cpp 文件中定义。

定义 Timer1 ISR

ClockAlarm_SM.cpp 中使用 ISR 宏和中断向量定义 Timer1 比较 ISR。中断向量为 TIMER1_COMPA_vect,需按以下格式编写:

ISR(TIMER1_COMPA_vect) {
    // ISR 代码
}

这将实现定时器的中断服务例程,用于在比较匹配时触发。

007 Atmega328p 计时器寄存器和设置代码

在 main.cpp 配置 Timer1 外设

main.cpp 文件中添加了一个 Timer1_setup 函数,用于配置微控制器的 Timer1 外设,以每 100 毫秒生成一次中断。按照前面的课程所述,我们将以 CTC 模式配置 Timer1。

配置 CTC 模式

要将定时器配置为 CTC 模式,首先需要查看寄存器描述。以下是配置步骤:

  1. TCCR1A 寄存器(控制寄存器 A):

    • 0 和 1 位:用于选择波形生成模式 (WGM)。
      • 将这些位设为 0,以选择 CTC 模式。
    • 4 到 7 位:控制通道 B 的输出模式(OC1A 和 OC1B 引脚)。由于我们不需要生成波形,将这些位设为 0,即关闭输出比较功能。

    在代码中,将 TCCR1A 寄存器值设置为 0:

    TCCR1A = 0;
  2. TCCR1B 寄存器(控制寄存器 B):

    • WGM12 位:设为 1 以启用 CTC 模式。
    • WGM13 位:设为 0。
    • CS 位(时钟选择):设置分频器值,以便将主时钟 CLKI/O(16 MHz)除以 256,从而放慢计数速度。
      • 设置 CS12 位为 1,以选择分频器值 256。

    在代码中,配置 TCCR1B 寄存器:

    TCCR1B = (1 << WGM12) | (1 << CS12);
  3. 启用输出比较中断

    • 打开 Timer/Counter 中断屏蔽寄存器 (TIMSK1) 中的输出比较中断使能位 (OCIE1A)。

    在代码中,将该位设为 1:

    TIMSK1 = (1 << OCIE1A);
  4. 设置比较匹配值

    • 将计算得到的输出比较匹配值写入 OCR1A 寄存器。

    在代码中,将匹配值写入 OCR1A:

    OCR1A = 6249;  // 100 ms 的匹配值

定义 ISR 中断服务程序

SM.cpp 中实现 ISR。使用 ISR 宏并指定中断向量地址:

  1. 打开 ATmega328P 数据手册中的中断向量表,找到 Timer1 Compare A 的中断向量(TIMER1_COMPA_vect)。
  2. 在代码中定义 ISR:

    ISR(TIMER1_COMPA_vect) {
       // ISR 代码
    }

该 ISR 将在每次比较匹配时触发,从而实现定时中断。

008 练习-007 添加类操作

current_time 设置为静态变量

我们在 ISR(中断服务例程)中需要递增 current_time 变量,但当前 current_time 是对象的私有属性,且不是静态变量。为了确保所有对象共享同一个时间值,我们将 current_time 变量设为静态变量,这样所有主结构对象都可以访问同一个时间。

为什么需要静态变量

current_time 设置为静态变量并生成代码

  1. 选择 current_time 变量,并将其设置为静态。
  2. 添加一个静态类操作 get_current_time,用于获取当前时间。
    • 返回类型:uint32_t
    • 设置为公有访问权限,返回 current_time 的值。
  3. 生成代码后,确认 current_time 已作为静态变量移出类对象。

在代码中,您将看到 get_current_time 函数,返回 current_time 的值。接下来,将实现该函数。

添加 update_curr_time 静态函数

  1. 创建一个静态类操作 update_curr_time

    • 返回类型:void
    • 因为该函数将操作静态变量 current_time,并不需要访问对象的私有属性。
    • 注意:静态函数无法访问 me 指针。
  2. 在代码中实现 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 函数中实现递增和重置逻辑。

在 ISR 中调用 update_curr_time

  1. 在中断服务例程中调用 Clock_Alarm_update_curr_time,以更新 current_time
  2. update_curr_time 函数中添加代码,由于代码由工具管理,实际代码将保存在 qm 工具中。

构建代码

现在,每当中断发生时,current_time 变量将会更新。

009 练习-007 定义初始转换动作

设置初始时间并定义初始转换的操作

在上一次的代码中我们已经完成了定时器部分的代码。在本节课中,我们将初始化一些时间变量并在 LCD 上显示信息。以下是具体步骤:

定义初始转换的操作

  1. 添加初始转换代码:在模型中添加初始转换的实际代码部分。此处编写的代码将被复制到文件中,并在代码生成时执行。

  2. 设置 current_time 的初始值

    • 创建一个静态类操作 set_curr_time,用于设置 current_time 的值。
    • 设置返回类型为 void,并将访问权限设为公共。
    • 为此函数添加一个参数 new_curr_time,类型为 uint32_t
  3. 编写 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;           // 恢复状态
  4. 将代码添加到模型中:将 set_curr_time 函数代码粘贴到模型中的相应位置。

设置初始变量

  1. 定义初始值

    • 调用 set_curr_time 函数,将 current_time 设为 INITIAL_CURR_TIME
    • 设置 alarm_timeINITIAL_ALARM_TIME
    • 设置 time_mode 初始值为 12H 模式。
    • alarm_status 初始设为 ALARM_OFF
  2. 在 SM.h 中创建宏和枚举

    • 定义 time_mode 枚举,包含 MODE_24HMODE_12H
    • 定义 alarm_status 枚举,包含 ALARM_OFFALARM_ON
    • 定义初始时间宏 INITIAL_CURR_TIMEINITIAL_ALARM_TIME,初始时间可自定义。
    #define INITIAL_CURR_TIME (10 * 3600 + 10 * 60 + 10) * 10  // 初始时间:10 小时 10 分钟 10 秒
    #define INITIAL_ALARM_TIME (8 * 3600)                       // 初始闹钟时间:8 小时
  3. 生成代码:保存并生成代码。在生成的代码中查看初始转换函数的实现,确保所有初始化代码都已生成。

在下节课中,我们将实现如何将这些时间信息显示在 LCD 上。

010 练习-007 为 TICKING 状态编写代码

编写 Ticking 状态的代码

在上一次课程中,我们完成了初始转换操作的代码。在本节课程中,我们将为 Ticking 状态编写代码,以在 LCD 显示当前时间。

Ticking 状态的 Entry 动作

  1. 设置 Entry 动作

    • 当进入 Ticking 状态时,我们需要显示当前时间。
    • 点击状态,在右侧可以看到 EntryExit 部分。在 Entry 部分添加实际代码。
  2. 调用 display_curr_time 函数

    • display_curr_time 函数用于将当前时间转换为字符串,并显示在 LCD 上。
    • 下载并查看 ClockAlarm_SM_TODO.cpp 文件,其中包含显示时间的各种辅助函数和代码片段。
    • display_curr_time 函数首先从 current_time 中获取当前时间,将其转换为字符串格式并显示。

display_curr_time 函数实现

  1. 获取当前时间

    • 使用 get_current_time 函数获取当前时间,并存储在 time24h 变量中。
    • 如果时间模式是 24 小时制,直接显示;如果是 12 小时制,则将 24 小时制格式转换为 12 小时制。
  2. 转换为字符串

    • 调用 integertime_to_string 辅助函数,将整数时间转换为字符串格式(hh:mm:ss)。
    • 使用 GET_HOURGET_MINGET_SEC 宏分别提取小时、分钟和秒数。
    • 拼接子秒字段和 AM/PM 信息(如果是 12 小时制)。
  3. 显示时间

    • 调用 display_write 辅助函数,将格式化好的时间字符串显示在 LCD 上指定的行和列位置。
    • display_write 函数设置 LCD 光标位置并打印字符串。

创建 display_curr_time 函数

  1. 在模型中添加 display_curr_time 函数:

    • 定义为非静态类操作,以便访问 me 指针。
    • 返回类型:void,并添加行列参数 rowcolumnuint8_t 类型)。
  2. 将代码粘贴到 display_curr_time 函数,并生成代码。

  3. Ticking 状态的 Entry 动作中调用 display_curr_time

    • 使用 me 指针,并提供行列参数。
    • 定义宏以指定 Ticking 状态下时间显示的行和列,例如:
    #define TICKING_CURR_TIME_ROW 0
    #define TICKING_CURR_TIME_COL 3
  4. 生成代码:确保所有代码生成正常,并将 display_curr_time 中调用的辅助函数作为独立的自由操作来实现。

辅助函数定义

ClockAlarm_SM_TODO.cpp 文件中提供了完整的代码示例,请根据需要将辅助函数整合到项目中。

011 练习-007 添加自由操作

快速添加自由操作代码

为了加快自由操作函数的添加流程,以下是详细步骤:

将代码添加到 SM.cpp

  1. 复制所有自由操作代码

    • 将需要添加的自由操作函数代码从 ClockAlarm_SM_TODO.cpp 文件中复制出来。
  2. SM.cpp 中粘贴代码

    • 打开 SM.cpp 文件,确保不要修改标记(marker)之间的代码区域。在文件开头,标记区域中会提示“请勿编辑标记之间的代码”。
    • 将复制的代码粘贴在标记区域之外,可以选择在文件的末尾粘贴,以避免对生成代码的干扰。
  3. 添加函数原型

    • 在文件开头的标记区域之外添加所有自由操作函数的原型。
    • 复制函数定义的声明部分(即函数名和参数类型),并粘贴为函数原型。例如,将所有函数原型粘贴在标记区域之后。
  4. 生成代码:确保所有代码生成正常,保存并编译项目。

测试 LCD 输出

在下一节课程中,我们将在 LCD 上测试显示效果,以确保所有操作按预期进行。

012 练习-007 通过类操作读取 curr_time

display_curr_time 函数

display_curr_time 函数中,我们使用未实现的函数来获取当前时间。

实现 get_curr_time 函数

  1. 编写代码

    • get_curr_time 函数中,直接返回当前时间变量。
    • 读取此变量前需禁用中断,以确保数据一致性。
  2. 步骤

    • 禁用中断,将当前时间赋值给临时变量 temp
    • 返回 temp
    • 在模型中也需加入相同的代码,以确保 get_curr_time 的实现与功能一致。
  3. 保存并生成代码

    • get_curr_time 函数中粘贴代码后,保存并生成代码。
    • 编译代码,确保无误。

下一步:测试代码

在下一节课程中,我们将在 LCD 上测试此代码。测试前,我们还需添加 LCD 初始化代码,具体操作将在下一节中完成。

013 练习-007 在 TICKING 状态处理 TICK 事件并测试

发送 Tick 事件并更新显示

在这一讲中,我们将向状态机发送 TICK 事件,并使用实时数据更新显示屏。

内部转换的实现

  1. 创建内部转换

    • 使用工具在 Ticking 状态下绘制内部转换。
    • 将事件命名为 TICK,并确保目标保持为“内部”,即事件不引发状态改变。
  2. 定义事件行为

    • TICK 事件选择 display_curr_time 函数,以便在接收到事件时更新显示。

发送 TICK 事件

  1. 在主程序中定义变量

    • 定义静态变量 tick_time 并初始化为 millis()
  2. 在循环中发送 TICK 事件

    • 检查当前时间减去 tick_time 是否达到 50 毫秒,如果是,则发送 TICK 事件。
    • 通过设置 Q_SIG 的值为 TICK_SIG 并调用 QHSM_DISPATCH,实现事件分发。
  3. 重置 tick_time

    • 每次发送事件后,将 tick_time 重置为 millis()

修改代码并测试

  1. 定位代码

    • 在工具中选择转换元素,使用“复制链接”定位代码块。
  2. 修复问题

    • 更新 TCCR1B 寄存器的初始值为 0,确保符合数据表描述。
    • 修正 display_curr_time 中的逻辑,先提取子秒字段,再将其转换为秒数。
  3. 测试硬件

    • 编译并下载代码,观察显示屏每 50 毫秒更新。

完成以上步骤后,我们将在下一讲中继续讲解。

014 练习-007 绘制 CLOCK_SETTING 状态

配置 Clock_Setting 状态和子状态

在本讲中,我们将详细讲解 Clock_Setting 状态的结构。在 Clock_Setting 中,用户可以通过按钮设置时、分、秒的各个位数。

状态和事件概览

Clock_Setting 状态结构

Clock_Setting 状态中,我们将为小时、分钟和秒的各个位数定义子状态。

  1. 创建子状态

    • 绘制一个子状态代表时钟设置的第一个小时位,命名为 cs_hour_digit1,用于修改小时字段的第一个数字(个位)。
    • 复制并粘贴此状态创建第二个位的状态,命名为 cs_hour_digit2
    • cs_hour_digit1 设为初始子状态。
  2. 定义内部转换

    • 在每个位数的子状态中,接收到 SET 信号时,应当执行内部转换以修改当前位的数值。确保 SET 定义为内部转换而非外部转换。
    • 信号名为 SET,在每次接收到 SET 时,更新当前数字。
  3. 定义 OK 转换

    • 当按下 OK 时,应从当前位转移到下一个位。
    • 例如,在 cs_hour_digit1 中接收到 OK 时,将进入 cs_hour_digit2
    • 继续定义 OK 转换,将设置顺序推进到分钟位和秒位。

后续步骤

  1. 完成 Clock_Setting 状态的其余转换。
    • 从小时的个位到十位,十位到分钟的个位,依次类推。
  2. 保存并生成代码。

请根据以上指导完成 Clock_Setting 的其余状态转换图,下一讲我们将继续讨论如何实现该功能。

015 练习-007 实现 CLOCK_SETTING 状态 第 1 部分

配置 Clock_Setting 状态和子状态

在这一讲中,我们详细介绍了 Clock_Setting 状态的子状态结构,并为各个字段的每个位(小时、分钟、秒)配置了修改逻辑。

逻辑概览

状态配置和代码实现

  1. 子状态的划分

    • cs_hour_digit1:修改小时的第一位(十位)。
    • cs_hour_digit2:修改小时的第二位(个位)。
    • 以此类推,分别为分钟和秒字段定义子状态。
  2. 初始子状态和内部转换

    • Clock_Setting 状态中,定义初始子状态和相应的内部转换。
    • 使用 SET 信号的内部转换来修改当前位的数值。
    • 使用 OK 信号在字段之间导航。
  3. 伪代码实现要点

    • 初始化和时间复制:在 SET 转换操作中,将 current_time 复制到 temp_time
    • 显示和光标控制:进入 Clock_Setting 状态后,显示 temp_time 并打开光标和闪烁效果。
    • 子状态的处理:在每个子状态中,设置光标位置,并根据 SET 信号修改相应位的数值(如 temp_time 的小时字段的个位和十位)。
  4. 代码示例

    • 使用伪代码定义转到 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);
  5. TODO

    • 根据给出的逻辑完成分钟和秒字段的状态转换,并定义相应的光标和显示更新操作。

下一步

完成字段和位的设置逻辑后,我们将继续测试硬件配置,确保显示和更新逻辑的正确性。在测试完成后,我们会进一步扩展到格式设置等功能。

016 练习-007 实现 CLOCK_SETTING 状态 第 2 部分

Clock_Setting 状态的改进与问题解决

在这一讲中,我们进行了 Clock_Setting 状态的进一步实现,解决了部分清理(cleanup)和退出(EXIT)操作的问题,并完成了临时时间设置的功能。

实现要点与问题解决

  1. 更改位数逻辑

    • 在每个子状态中,通过 SET 按钮可以更改相应字段(小时、分钟、秒)的第一位和第二位。
    • 使用 mod 运算确保数值范围(例如小时的十位只能为 02)。
    • 将新的数值更新到 temp_time 中,并立即更新显示。
  2. 清理与退出操作

    • 在测试时发现,当从 Clock_Setting 状态返回到 Ticking 状态时,LCD 没有完全清理。
    • 通过在 Clock_Setting 及其父状态的 EXIT 操作中调用 display_clear 函数来解决这一问题。display_clear 使用 lcd_clear 函数清空显示。
  3. 解决代码生成冲突

    • 由于代码生成器会覆盖某些代码,确保在模型工具中更改所有必要的代码,例如在模型中为 Clock_Alarm_display_clock_setting_time 函数设置正确的前缀。
    • 如果发现错误,可以复制错误链接并在模型中进行修正,然后重新生成代码。

测试结果

下一步

在下一个讲解中,我们将:

017 练习-007 实现 CLOCK_SETTING 状态 第 3 部分

时钟设置格式与错误处理实现

在本讲中,我们为时钟设置模式下的格式选择(AM、PM、24H)和无效时间输入的错误处理功能编写了代码。

主要步骤与逻辑

  1. 时钟设置格式

    • 进入操作:在特定位置设置光标,并使用 temp_format 变量显示当前时间格式。
    • SET 信号处理:每次接收到 SET 信号后,循环切换时间格式(AM、PM、24H)。temp_format 用于存储用户选择的格式。
    • OK 信号处理:在退出时钟设置模式之前,检查输入的时间是否有效。如果无效,则会转移到错误状态。
  2. 使用选择状态实现条件判断(Guard Condition)

    • 为管理 OK 转换的条件,我们使用选择状态(Choice State)附加了条件判断。
    • 条件判断:一个辅助函数 is_time_set_error 根据 temp_timetemp_format 验证输入时间的有效性。如果时间有效,OK 信号会传播到超状态以退出时钟设置模式;如果无效,则会转移到错误状态。
  3. 错误状态

    • 错误消息显示:两个子状态 err_onerr_off 切换显示和清除错误消息,创建闪烁效果。
    • 超时处理:每 50ms 收到一个 TICK 信号,timeout 变量递增,当达到 500ms 时,显示在 err_onerr_off 状态之间交替切换。
    • 错误状态下的 SET 信号处理:如果用户在错误状态下按下 SET 键,状态机将返回到 Clock_Setting,允许用户重新设置时间。
  4. 辅助函数

    • display_erase_block:通过用空格替换字符来清除 LCD 上的特定块。
    • is_time_set_error:基于特定规则验证输入时间的有效性,例如检查 hour 是否在所选格式的有效范围内。

测试与清理

下一步

在下一讲中,我们将进一步完善错误处理,并在状态机的不同部分中管理 timeout 变量,以处理用户输入的任何不一致。

请确保在硬件上测试这些步骤以验证完整功能。

018 练习-007 实现 CLOCK_SETTING 状态 第 4 部分

添加 timeout 属性与时钟设置错误处理

本讲的目标是完善时钟设置模式中的格式选择(AM、PM、24H)和无效输入的错误处理功能。我们还将添加 timeout 属性,用于控制显示消息的闪烁间隔。

1. 添加 timeout 属性

  1. 定义属性:添加 timeout 属性,类型为 uint8_t,用于记录时间计数。
  2. 设定初始状态:在 Clock_Setting 超状态下定义初始状态,以确保进入时计数从零开始。

2. 错误状态的逻辑修改

  1. 显示错误消息:当用户输入不符合规范时,系统进入错误状态,并在 LCD 上显示“Error!”消息。消息会以 500 毫秒的频率闪烁。
  2. 消除闪烁问题:在进入错误状态时,调用 display_cursor_off_blinkoff 函数,关闭光标的闪烁效果。
  3. 清除错误消息:在错误状态退出时,仅清除错误消息区域,而不是整个屏幕,调用 display_erase_block 函数来清除错误消息。
  4. 忽略 OK 按键:在错误状态下,如果用户按下 OK 键,系统将忽略该操作,不退出错误状态。

3. 解决错误状态的退出问题

4. 代码生成与测试

  1. 生成代码:生成代码后,下载至硬件进行测试。
  2. 硬件测试:通过模拟不同的输入来测试错误状态及其处理逻辑。例如,尝试输入无效的时间(如小时超过 24,或在 24 小时制下指定 AM/PM 信息),观察系统如何处理。
  3. 问题修复:在错误状态中按下 SET 键后,系统会正确地返回到时钟设置模式,并从当前设置开始。这表明功能逻辑正常。

下一步

在下一讲中,我们将更新当前时间以保存用户的设置,并处理其他潜在问题。

020 练习-007 更新实时

更新当前时间变量与错误处理

在本讲中,我们完善了时钟设置模块,使其能够将用户输入的时间更新到当前时间变量中,并进行了各种输入有效性测试,包括处理错误状态和更新显示格式。具体步骤如下:

1. 时钟设置中的 OK 处理逻辑

在用户完成格式选择后,若输入无误,OK 信号将上浮至 settings 超状态,并在此状态中进行处理:

  1. 检查时间格式:如果用户选择的时间格式不是 24 小时制,则需将 temp_time 转换为 24 小时制格式。我们通过一个帮助函数来完成此转换,以便将时间以 24 小时制格式存储到 current_time 变量中。
  2. 设置显示格式:根据用户选择,将 time_mode 设置为 12 小时制或 24 小时制。
  3. 更新当前时间:将 temp_time 转换为以 100 毫秒为单位的计时格式,即 temp_time * 10,然后将其赋值给 current_time 变量。
  4. 禁用计时器中断:由于 current_time 是一个全局变量,修改前应禁用计时器中断以避免冲突。我们通过停止 TIMER1 并清零计数寄存器来实现。
  5. 重新启动计时器:设置 TCCR1B 寄存器的预分频器为 256,恢复计时器的时钟源,使计时器重新开始。

将此代码块添加到 Settings 状态的 OK 信号处理逻辑中,同时调用 Clock_Alarm_set_curr_time 函数以更新当前时间。

2. 添加 EXIT 动作:关闭光标与闪烁

settings 状态退出时,应关闭光标显示及闪烁效果。因此,我们在 settings 状态的 EXIT 函数中调用 display_cursor_off_blinkoff 函数,以确保用户退出时钟设置后,光标和闪烁效果已被禁用。

3. 错误状态的处理

为了进一步完善错误状态逻辑,我们确保在错误状态下按下 OK 不会产生任何效果。用户需要按下 SET 以返回时钟设置并重新输入时间。

4. 代码生成与测试

结论

至此,时钟设置的时间更新和错误处理已经完善。在下一讲中,我们将实现闹钟设置功能,进一步丰富该应用的功能。

021 练习-007 ALARM_SETTING 状态

闹钟设置模块实现作业

现在的任务是实现 Alarm_Setting(闹钟设置)状态。这一部分的功能与之前实现的 Clock_Setting 状态相似,因此可以复用相同的子状态结构。以下是具体的实现要求:

1. 闹钟设置功能概述

2. 子状态复用与子状态机(Sub-machine)

由于 Alarm_SettingClock_Setting 状态结构类似,理论上可以将 Clock_Setting 状态抽象为一个“子状态机”(Sub-machine)来复用。这需要使用子状态机来简化代码和逻辑,但该功能在免费版工具中不可用,需获得商业授权许可。

子状态机的作用

如何创建子状态机(商业许可情况下):

  1. 右键点击 Clock_Setting 状态,选择 “Add Sub-Machine from State” 创建子状态机。
  2. 为该子状态机命名,并添加入口和出口,以定义接口。
  3. Alarm_Setting 状态中,添加此子状态机的实例,从而复用 Clock_Setting 的子状态结构。

由于此功能需要商业版授权,此处仅作示范。如果有机会获得商业版,可以参阅用户手册以了解更多子状态机的使用。

3. 具体任务

4. 额外任务(可选)

请完成以上任务,下一讲将进行代码测试与验证。

022 练习-007 实现 ALARM_SETTING 状态

闹钟设置状态实现讲解

在上一讲中,我给大家布置了实现 Alarm_Setting 状态的作业,希望大家已经完成了。在本讲中,我会详细解释该状态的实现,并讲解我所做的一些调整。

1. Clock_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 状态中则执行不同的操作。

2. Alarm_Setting 状态的实现

Alarm_Setting 状态中,我复用了 Clock_Setting 的子状态结构,只是修改了状态名称以避免命名冲突。例如,cs_hour_d1 变成 as_hour_d1。其余逻辑基本相同。

alarm_on_off 状态中,用户可以选择是否启用闹钟。当用户在此状态按下 SET 按钮时,可以在“ALARM ON”和“ALARM OFF”之间切换。

3. 使用 else 分支实现无动作

在模型中,当我们处理 OK 信号且 is_time_set_error 返回 false 时,我使用了一个虚拟的 else 分支来确保 OK 信号传递至 settings 状态。具体做法是使用 choice 伪状态,在其上添加一个 else 分支,以确保条件失败时 OK 信号可以被上级状态捕获。

4. 显示当前时间和闹钟状态

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 状态,并理解其中的逻辑。

023 练习-007 实现 ALARM_NOTIFY 状态

闹钟通知功能的实现

在本讲中,我们将了解如何在本项目中显示闹钟通知。为此,我们使用一个单独的状态 Alarm_Notify。在此状态中,每隔 500 毫秒会有一个闪烁的闹钟信息。以下是具体的实现细节。

1. 闹钟通知的闪烁效果

2. 退出闹钟通知的条件

3. 如何触发闹钟事件

闹钟事件由 main.cpp 中的循环函数定期触发,每隔 500 毫秒检查是否需要触发闹钟。检查的条件为 alarm_time 是否等于 current_timealarm_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();
}

4. 测试代码及修复 BUG

在测试过程中,发现当用户在 Alarm_Setting 状态下进入错误状态(Error)时,可能会触发一个 assertion failure 错误。原因在于状态嵌套的深度超过了默认允许的最大值(5),导致状态机算法的 ip 值超出限制。

解决方法
  1. 可以通过 "拉平" 状态结构来减少嵌套深度,但可能影响代码的可读性。
  2. 或者临时将 QHSM_MAX_NEST_DEPTH 的值增加到 6。
// 在 qepn.h 中将最大嵌套深度增加到 6
#define QHSM_MAX_NEST_DEPTH 6

测试步骤

  1. 设置一个闹钟时间,例如 10:10:50,并启用闹钟。
  2. 验证闹钟时间到达时是否显示闹钟通知。
  3. 按下 OK 按钮,确认返回到闹钟触发前的状态。
  4. 尝试设置无效闹钟时间,进入 Error 状态,并确认闹钟通知的正确显示和返回逻辑。

总结

在本讲中,我们实现并测试了闹钟通知的显示效果及其相关逻辑。所有源代码和模型文件已上传至 Git 仓库,可以根据需要参考代码。

WangShuXian6 commented 2 days ago

11 - 活动对象

001 活动对象

探索 Active Object 设计范式

在本讲中,我们将探讨如何在应用程序中使用 Active Object(活动对象)设计范式,并逐步了解其应用。首先,我们来讨论在当前应用程序中可能存在的一些设计问题,并探讨如何通过 Active Object 范式进行优化。

1. 应用程序中的问题

2. 引入事件队列

为了避免漏掉事件并保持 RTC 语义,我们可以引入一个事件队列。

3. 多状态机场景

如果应用程序包含多个状态机,每个状态机有自己的事件队列,允许各个对象在独立的线程中并行处理事件。

4. Active Object 的定义

在 QP 框架中,Active Object 是一个拥有自己线程控制的对象。各对象的状态机行为完全由其当前状态决定,不受其他对象的影响。Active Object 设计范式中的特征包括:

5. 事件发布与订阅

QP 框架提供了两种事件传递方法:

6. 使用 qv 协作内核

QP 框架提供了一个简单的协作内核(qv kernel),实现了一个巨大的循环来遍历处理 Active Object。内核根据优先级依次处理不同对象的事件队列。

实现练习

我们将之前的 Clock_Alarm 类转换为一个 Active Object,并新增一个名为 BUTTON 的 Active Object,用于处理按钮事件。此外,我们将 Clock_Alarm 设计为正交状态机,并在其内部实现两个独立的区域 r1r2,其中 r1 表示时钟相关状态,r2 表示闹钟相关状态。两个区域独立运行并响应相同的事件。

正交状态(Orthogonal State)

总结

通过 Active Object 设计范式,我们可以:

在接下来的课程中,我们将通过实际代码实现上述设计。

002 正交状态模式

正交组件状态模式(Orthogonal Component State Pattern)

在本节课中,我们将深入探讨正交组件状态模式,并了解如何使用这种设计模式在状态机中实现正交区域。通过这种模式,我们将使用容器(Container)和组件(Component)之间的关系,以实现强聚合(Composition)——一种类的组合技术。

1. 正交组件状态模式的概念

正交组件状态模式用于实现正交区域(Orthogonal Regions),即状态机中可并行的独立子状态区域。我们可以将应用中的独立组件识别出来,并通过类的组合来实现。组合(Composition)是面向对象编程(OOP)中的一种强聚合关系,适用于容器和组件的关系。

2. 组合(Composition)的定义

组合是一种强聚合关系,意味着组件的生命周期依赖于容器的生命周期:

3. 项目中组件的实现

在我们的项目中,我们可以通过这种组合关系,将 Clock_Alarm 类设计成容器,并将 Alarm 设计成它的组件。Clock_Alarm 类和 Alarm 类将通过组合技术连接在一起:

4. 容器与组件的通信

在这种容器和组件的架构中,容器和组件之间的通信可以分为同步和异步两种方式。

5. 实现示例

我们将在项目中实现以下设计:

  1. Clock_Alarm 作为一个容器类,它包含两个并行区域 ClockAlarm
  2. 创建 Alarm 组件作为 Clock_Alarm 的组成部分,并为它实现独立的状态机逻辑。
  3. 使用事件队列管理 Clock_AlarmAlarm 之间的通信。
  4. 引入活动对象(Active Object)设计范式,为 Clock_AlarmButton 类创建各自的事件队列,以独立管理事件。

创建项目

  1. 创建新项目:创建一个新的项目 008ClockAlarm_AO,我们将使用 Active Object 和正交组件状态模式来实现。
  2. QM 模型文件:在项目文件夹内新建一个名为 QM 的文件夹,并下载附带的 QM 模型文件,放置在该文件夹中。在下一节课中,我们将进一步解释模型文件的内容及其使用方式。

在下一节课中,我们将继续设置该项目,并深入代码实现细节,逐步完成正交组件状态模式的实际编写。

003 练习-008 实现 第 1 部分

课时内容:使用变量合并设置状态,并创建正交组件

在本节课中,我们简化了之前的 Clock_Alarm 状态机模型,并介绍了如何使用变量来区分时钟设置和闹钟设置。以下是具体步骤和修改说明:


1. 简化设置状态

在之前的模型文件 007 中,Clock_Alarm 类包含了多个独立的设置状态(如 Clock_SettingAlarm_Setting)。然而,这样的复杂设计并不总是必要的。我们可以使用一个通用的 setting 状态,并通过变量 curr_setting 来区分是时钟设置还是闹钟设置。

2. 使用变量区分时钟和闹钟设置

在新的模型文件 008 中,Clock_Alarm 类只包含一个 setting 状态。具体修改如下:

3. 更新变量并执行操作

settings 状态中,根据 curr_setting 的值确定操作的执行内容:

4. 重新定义通用状态

setting 状态下包含小时、分钟、秒钟以及时钟格式的设置。无论是时钟还是闹钟设置,错误状态都共用一个通用的 error 状态。这样设计更为简洁和高效。

5. 条件分支(Choice Segment)和守卫条件(Guard Condition)

clock_format 设置时,当用户按下 OK 时,根据设置值执行不同的操作:

6. 更新类设计

在新模型文件中,移除了 Clock_Alarm 类中的闹钟相关属性,如 alarm_timealarm_status,因为这些属性将成为闹钟组件的一部分。这使得 Clock_Alarm 更专注于时钟设置,而闹钟设置将由独立的组件来管理。


下节课内容

在下一节课中,我们将继续创建两个活动对象(Active Objects):Clock_AlarmButton。同时,我们还将创建 alarm 组件,演示如何在容器对象和组件对象之间传递事件,实现更高效的正交组件模式。

我们还将介绍事件从容器对象传递到组件对象,以及从组件对象传递回容器对象的方法。

004 练习-008 实现 第 2 部分

课时内容:将类转换为活动对象并创建组件类

在本节课中,我们将 Clock_Alarm 类转换为活动对象(Active Object)并创建 Button 活动对象和 Alarm 组件类。以下是具体步骤:


1. 将 Clock_Alarm 类转换为活动对象

2. 创建 Button 活动对象

3. 创建 Alarm 组件类

4. 在 Clock_Alarm 中创建 Alarm 组件的实例

5. 创建全局指针访问活动对象

6. 设置初始化器

7. 为 Button 创建静态对象实例


下节课内容

在下一节课中,我们将为 Button 活动对象和 Alarm 组件创建构造函数,并实现其初始化流程。

005 练习-008 实现 第 3 部分

课程内容:构造函数(ctor)的创建

在本节课中,我们将为 Clock_AlarmButtonAlarm 类创建构造函数(ctor),并更新 Clock_Alarm 的构造函数代码。具体步骤如下:


1. 更新 Clock_Alarm 构造函数代码

2. 为 Button 创建构造函数

3. 为 Alarm 创建构造函数


下节课内容

在下一节课中,我们将绘制 ButtonAlarm 的状态机,并在 Alarm 构造函数中完成初始化步骤。完成上述内容后,继续进行,课程内容将逐步深入实现项目的完整功能。

006 练习-008 实现 第 4 部分

课程内容:生成和配置代码

在本节课中,我们将生成代码并进行一些文件和路径的配置,以便设置 ClockAlarmButton 的状态机及其关联的组件 Alarm。以下是具体的步骤:


1. 生成代码文件

2. 修改 alarm.cpp

3. 修改 button_SM.cpp

4. 更新包名和重新生成代码

5. 定义全局指针属性

6. 配置 platformio.ini 和库文件


下节课内容

完成上述配置后,我们将在下一节课继续调整和实现剩余的功能代码。请确保所有文件和路径正确配置,生成的代码无误,然后继续进行下一步的实现。

007 练习-008 实现 第 5 部分

课程内容:配置 main.c 的代码实现

在本节课中,我们将配置 main.c 文件,以适配两个活动对象 Clock_AlarmButton,并设置按钮中断。以下是具体步骤:


1. 复制和修改文件

2. 调整构造函数

3. 调整构造代码

4. 初始化组件

5. 实现按钮中断


下节课内容

在完成这些设置后,下一节课中我们将继续实现剩余代码,配置事件队列并处理按钮中断事件。请确保所有文件正确配置并生成代码无误,然后继续进行下一步。

009 练习-008 实现 第 6 部分

实现 QP-Nano 活动对象框架中的步骤

在本次课程中,我们将逐步实现 QP-Nano 活动对象框架的配置步骤,包括创建事件队列、初始化活动对象控制块等,适配 Clock_AlarmButton 两个活动对象。以下是完整步骤:


1. 创建事件队列

main.cpp 中创建两个事件队列,分别用于 ClockAlarmButton

QEvt ClockAlarmQueue[5];
QEvt ButtonQueue[5];

2. 创建并初始化活动对象控制块

创建 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) }
};

3. 调用 QF_init 初始化活动对象框架

调用 QF_init 并传入活动对象数量,初始化框架:

QF_init(Q_DIM(QF_active));

4. 调用 QF_run 启动调度程序

loop 函数中调用 QF_run,这是一个无限循环的调度程序:

void loop() {
    QF_run();
}

5. 实现 Idle Hook 函数

在空闲时使处理器进入睡眠模式。可使用 QV_CPU_SLEEP 宏来调用:

extern "C" void QV_onIdle(void) {
    QV_CPU_SLEEP();
}

6. 配置周期性中断(Tick ISR)

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),更新所有计时事件。

7. 配置按钮中断处理

使用 attachInterruptSETOK 按钮连接中断:

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 类似。

8. 配置 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 状态机的代码,实现基于活动对象的异步消息处理和独立状态管理。

010 练习-008 实现 第 7 部分

实现 Alarm 组件的状态机与时钟主容器的集成

本次课程将实现 Alarm 组件的状态机,并将其集成到 Clock_Alarm 容器的状态机中,使得 Alarm 可以接收定期的时间检查信号,并在设定的闹钟时间到达时触发提醒。以下是具体步骤:


1. 创建 Alarm 组件的状态机

Alarm 类中创建状态机:

  1. 右键点击 Alarm,选择 Add State Machine,然后双击进入。
  2. 创建一个名为 ALARM 的状态,并添加一个内部过渡 ALARM_CHECK

ALARM_CHECK 信号将由 Clock_Alarm 容器触发,容器定期将当前时间发送给 Alarm 组件,用于闹钟检查。

2. 创建 Dispatch 函数

Alarm 组件需要提供一个 dispatch 函数,以便 Clock_Alarm 容器可以将 ALARM_CHECK 信号派发给它。步骤如下:

  1. 右键点击 Alarm 类,添加 Operation,命名为 dispatch,返回类型为 void
  2. dispatch 中调用 QHSM_DISPATCH,使用指向 Alarm 类的 QHsm 超类的指针。
void Alarm_dispatch(Alarm * const me, QEvt const * const e) {
    QHSM_DISPATCH(&me->super, e);
}

3. 在 Clock_Alarm 状态机中定期检查闹钟

Clock_AlarmTICK 信号处理中,调用 Alarm_dispatch 函数来检查是否达到设定的闹钟时间:

  1. 填写 ALARM_CHECK 信号和当前时间参数。
  2. 使用 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);

4. 在 Alarm 状态机中处理 ALARM_CHECK 信号

Alarm 组件接收到 ALARM_CHECK 信号时,执行以下步骤:

  1. 比较当前时间和闹钟时间,如果匹配,则发送 ALARM_SIG 信号给 Clock_Alarm 活动对象,通知闹钟事件发生。
  2. 创建 alarm_timealarm_status 属性,并添加设置和获取方法。
if (current_time == me->alarm_time) {
    QACTIVE_POST(AO_ClockAlarm, ALARM_SIG, 0);
}

5. 在 Alarm 类中添加属性和方法

Alarm 类中添加 alarm_timealarm_status 属性,以及设置方法:

  1. 创建 alarm_timealarm_status 属性,类型分别为 uint32_tuint8_t
  2. 添加 set_alarm_timeset_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;
}

6. 在 Clock_Alarm 的 Settings 状态中设置闹钟

Clock_AlarmSettings 状态的 OK 信号处理处,根据 curr_setting 确定是时钟设置还是闹钟设置。如果是闹钟设置,则调用 Alarm_set_alarm_timeAlarm_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 用于存储闹钟开关状态
}

7. 在初始化转换中设置默认值

Clock_Alarm 的初始转换中,为 Alarm 设置初始值,如默认的闹钟时间和状态。


总结

到目前为止,我们已经实现了 Alarm 组件的状态机,并将其集成到 Clock_Alarm 的容器状态机中。这样,Clock_Alarm 可以定期触发 ALARM_CHECK 信号,Alarm 组件可以检查是否达到设定的闹钟时间,并在合适的时间发送 ALARM_SIG 通知。

011 练习-008 实现 第 8 部分

课程概述

在上一讲中,我们实现了 Alarm 状态机和 Button 状态机,但在 Clock_Alarm 状态机中还有一些链接和错误状态未完成。本节将完善 Clock_Alarm 状态机,优化定时功能,通过框架的 timeout 函数替代手动计时变量,从而简化代码结构。


1. 更新 Error 状态中的定时器

Clock_Alarm 状态机的 Error 状态中,我们之前通过变量实现了错误消息的每 500 毫秒闪烁一次的功能。本次将用框架提供的 timeout 函数替代该逻辑:

  1. 进入 Error 状态时,在入口动作中为定时器设置一个 500 毫秒的周期性定时。
  2. 在状态退出时,使用 QActive_disarm 函数解除定时。

代码实现:

// 在 Error 状态的入口动作中启动定时器
QActive_armX(AO_ClockAlarm, 0, MS_TO_TICKS(500), MS_TO_TICKS(500));

// 在 Error 状态的退出动作中解除定时
QActive_disarm(AO_ClockAlarm, 0);

2. 处理 TICK 信号

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);

3. 设置 Alarm Notify 状态的定时

Alarm_Notify 状态中,我们通过变量实现了定时闪烁。本次同样用框架的定时功能替换变量:

  1. 进入 Alarm_Notify 状态时启动定时器。
  2. 退出 Alarm_Notify 状态时解除定时。

代码实现:

// 在 Alarm_Notify 状态的入口动作中启动定时器
QActive_armX(AO_ClockAlarm, 0, MS_TO_TICKS(500), MS_TO_TICKS(500));

// 在 Alarm_Notify 状态的退出动作中解除定时
QActive_disarm(AO_ClockAlarm, 0);

4. 实现 Alarm 组件的 dispatch 函数

Alarm 组件需要实现 dispatch 函数,使得 Clock_Alarm 可以调用该函数并将 ALARM_CHECK 信号派发给它。具体代码如下:

void Alarm_dispatch(Alarm * const me, QEvt const * const e) {
    QHSM_DISPATCH(&me->super, e);
}

5. 在 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);

6. 在 Error 状态和 Alarm Notify 状态之间设置过渡条件

使用定时和选择节点处理 Error 状态和 Alarm Notify 状态中的过渡条件,代码如下:

// 使用选择节点控制状态过渡
if (me->timeout > 0) {
    --me->timeout;
    // 若非零,则进入下一个状态
    return Q_RET_TRAN;
} else {
    // 若零,则超时事件传递至父状态
    return Q_RET_SUPER;
}

7. 修正主函数中的错误

main.cpp 中删除不再需要的代码,并完善初始化和中断代码。


代码示例总结

通过以上优化,我们使用框架的定时功能替代了手动计时变量,从而使代码更加简洁和高效。

012 练习-008 实现 第 9 部分

测试项目及小改动

我们的项目已经基本完成,但需要在 tick 中断服务程序 (ISR) 中做一个小的改动。


1. 更新 tick ISR

tick ISR 中,每 100 毫秒调用一次 QF_tickXISR 函数来更新当前时间并发送 TICK 信号给 ClockAlarm 状态机。

实现方法:
  1. 定义一个静态变量用于计时。
  2. 当该变量等于 100 毫秒时,更新当前时间变量并发送 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
}

2. 修改 set_curr_time 函数

set_curr_time 函数中,需要更新 TCCR1B 寄存器,确保定时器配置正确。

void Clock_Alarm_set_curr_time() {
    // 设置 TCCR1B 寄存器
    TCCR1B |= (1 << CS12) | (0 << CS11) | (0 << CS10);
    // 更新当前时间的其他代码
}

代码生成与测试

完成代码更改后:

  1. 生成代码。
  2. 在硬件上进行测试,确保项目工作正常。

如果遇到问题,可以在课程的 Q&A 论坛中提出问题。希望这些步骤能够帮助你顺利完成项目!