SIC实验室23届第十次学习目标

SIC实验室23届第十次学习目标

三月 27, 2024


现在,我们将沿着程序的入口点,进一步学习如何通过逐飞库与自己编写的函数实现发车前的前置准备。


SIC-LOGO

- 立足培养、重在参与、鼓励探索、追求卓越 -

前言

GUGU

学习内容

自定义代码文件

在逐飞库中,有./project/code这样一个文件夹,用于储存我们自行编写的代码文件。

我们以一份用于进行一阶互补滤波姿态解算的代码文件为例,演示添加、编写和调用自行编写的代码时需要注意的地方。

./project/code目录下新建名为imu.cimu.h的文件。

我们先把代码内容摆在这里。

imu.c */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

#include "zf_common_headfile.h"
#include <math.h>

#define Ka 0.90 // 加速度解算权重
#define dt 0.005 // 采样间隔(单位:秒)

#define OFFSET_COUNT 200 // 零漂测定数据量

#define ANGLE_APPROX_COEFF 0 // 航向角逼近系数

IMU IMU_Data;

float FOCF (float acc_m, float gyro_m, float* last_angle) {
float temp_angle;
temp_angle = Ka * acc_m + (1 - Ka) * ( *last_angle + gyro_m * dt); // 角速度对采样间隔积分加上上次解算角度即为从陀螺仪中推出的角度
*last_angle = temp_angle;
return temp_angle;
}

/* @fn IMU_update
* @brief 在定时器中姿态解算
* @return void
*/
void IMU_update () {
// 数据处理
IMU_Data.Roll_a = atan2(imu963ra_acc_x, imu963ra_acc_z) / (PI / 180); // ax除以az再求反正切函数即为从加速度计中推出的角度
IMU_Data.Pitch_a = atan2(imu963ra_acc_y, imu963ra_acc_z) / (PI / 180);
IMU_Data.Roll_g = -(imu963ra_gyro_y) / 14.3; // 从陀螺仪中推出的角速度,14.3根据陀螺仪量程所得
IMU_Data.Pitch_g = -(imu963ra_gyro_x) / 14.3;

// 一阶互补滤波
IMU_Data.Roll = FOCF(IMU_Data.Roll_a, IMU_Data.Roll_g, &IMU_Data.lastRoll);
IMU_Data.Pitch = FOCF(IMU_Data.Pitch_a, IMU_Data.Pitch_g, &IMU_Data.lastPitch);

IMU_Data.Yaw += -(imu963ra_gyro_z) / 14.3 * dt;

// Yaw角修正
if (IMU_Data.Yaw < gps_tau1201.direction) {
IMU_Data.Yaw += ANGLE_APPROX_COEFF;
}
else if (IMU_Data.Yaw > gps_tau1201.direction) {
IMU_Data.Yaw -= ANGLE_APPROX_COEFF;
}
}

//陀螺仪去零漂
void IMU_offset () {
oled_clear();
oled_show_chinese(0, 0, 16, (const uint8 *)IMU_OFFSET_1, 5);
oled_show_chinese(0, 6, 16, (const uint8 *)IMU_OFFSET_2, 8);
oled_show_string(0, 3, "Count: /200");
for (int i = 0; i < OFFSET_COUNT; i++) {
oled_show_int(36, 3, i, 3);
system_delay_ms(5);
if (imu963ra_gyro_x == imu963ra_gyro_y) {
i--;
oled_show_string(0, 4, "WARNING: IMU NO DATA");
}
else {
IMU_Data.offset_gx += imu963ra_gyro_x;
IMU_Data.offset_gy += imu963ra_gyro_y;
IMU_Data.offset_gz += imu963ra_gyro_z;
}
}
IMU_Data.offset_gx /= OFFSET_COUNT;
IMU_Data.offset_gy /= OFFSET_COUNT;
IMU_Data.offset_gz /= OFFSET_COUNT;
}

void IMU_get_data(){
imu963ra_get_acc(); // 获取 IMU963RA 的加速度测量值
imu963ra_get_gyro(); // 获取 IMU963RA 的角速度测量值
imu963ra_gyro_x -= IMU_Data.offset_gx;
imu963ra_gyro_y -= IMU_Data.offset_gy;
imu963ra_gyro_z -= IMU_Data.offset_gz;
}
imu.h */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

#ifndef _IMU_H_
#define _IMU_H_

#include "zf_common_headfile.h"

typedef struct {
float Roll; // 解算所得角度
float Pitch;
float Yaw;

float Roll_a; // 加速度计算得到的角度
float Pitch_a;
float Roll_g; // 陀螺仪计算得到的角速度
float Pitch_g;

float lastRoll; // 上次的解算角度
float lastPitch;

int offset_gx; // 陀螺仪零漂值
int offset_gy;
int offset_gz;
} IMU;

extern IMU IMU_Data;

void IMU_update();
void IMU_offset();
void IMU_get_data();

#endif /* _IMU_H_ */

包含头文件

C语言通过预处理器提供了一些语言功能。从概念上讲,预处理器是编译过程中单独执行的第一个步骤。两个最常用的预处理器指令是:#include指令(用于在编译期间把指定文件的内容包含进当前文件中)和#define指令(用任意字符序列替代一个标记)。

源文件的开始处通常都会有多个#include指令,它们用以包含常见的#define语句和extern声明,或从头文件中访问库函数的函数原型声明。(严格地说,这些内容没有必要单独存放在文件中;访问头文件的细节同具体的实现有关。)

在大的程序中,#include指令是将所有声明捆绑在一起的较好的方法。它保证所有的源文件都具有相同的定义与变量声明,这样可以避免出现一些不必要的错误。很自然,如果某个包含文件的内容发生了变化,那么所有依赖于该包含文件的源文件都必须重新编译。

——C程序设计语言(Kernighan, Ritchie)

./libraries/zf_common/目录下,有一个名为zf_common_headfile.h的文件,该文件下包含逐飞库中所有的头文件,进而声明了逐飞库中可以由用户调用的函数、变量与数据类型。此外,文件中也预留了“用户自定义文件”的位置,你可以在这个位置进行编辑来将你的代码包含在内

用户自定义文件

一旦你在自己的代码文件中包含了zf_common_headfile.h,你将可以在这个文件中调用逐飞库当中声明的函数、变量与数据类型。

保护宏

在C语言中,保护宏(Guard Macros)是一种常用的技术,用于避免头文件被多次包含。在大型项目中,一个头文件可能会被多个其他源文件引用,如果不使用保护宏,可能会导致同一头文件被多次包含,从而导致重复定义的问题。为了解决这个问题,可以使用保护宏来确保头文件只被包含一次。

保护宏通常使用#ifndef、#define、#endif等预处理指令来实现。

在我们用以示范的imu.h中,是这样实现的:

1
2
3
4
5
6
#ifndef _IMU_H_
#define _IMU_H_

/* 代码内容 */

#endif /* _IMU_H_ */

在示例中:

  • _IMU_H_是一个自定义的标识符,通常由头文件名加上下划线避免重复。逐飞库中主要有三种命名风格:__IMU_H、_IMU_H或者_IMU_H_,本文使用最后一种风格。
  • #ifndef(如果未定义)、#define(定义)和 #endif(结束)组合在一起,形成了一个条件编译的区域。
  • 如果_IMU_H_未定义,则#ifndef下的代码块会被执行,此时会定义_IMU_H_,并包含头文件内容。
  • 如果_IMU_H_已经定义,则#ifndef下的代码块会被忽略,避免了头文件被重复包含。

这种技术确保了头文件只被包含一次,从而避免了重定义的问题,提高了代码的可移植性和可维护性。

extern关键字

extern关键字在C语言中应用于C变量(数据对象)和C函数。基本上,extern关键字扩展了C变量和C函数的可见性。

为了保险起见,让我们再次强调一下变量或函数的“声明”和“定义”的区别。

变量或函数的声明只是声明该变量或函数在程序的某个地方存在,但并未为它们分配内存。变量或函数的声明起着重要的作用——它告诉程序其类型将是什么。在函数声明的情况下,它还告诉程序参数、它们的数据类型、这些参数的顺序以及函数的返回类型。所以这就是声明的全部内容。

而当我们定义一个变量或函数时,除了声明的所有内容外,程序还为该变量或函数分配内存。因此,我们可以将定义视为声明的超集(或将声明视为定义的子集)。

虽然没什么必要,但提一下,extern是external的缩写。

当我们需要从另一个文件访问被定义的变量时,会使用extern关键字,像这样:

1
2
3
4
5
extern data_type variable_name;

/* 或者用我们的示范代码: */

extern IMU IMU_Data;

当我们写下extern some_data_type some_variable_name时,不会分配内存。只是宣布了变量的属性。extern变量告诉编译器:“离开我的范围,你会找到我声明的变量的定义。”编译器相信extern变量所说的一切都是真实的,并且不会产生错误。此外当链接器发现不存在这样的变量时会抛出错误。

在编写C语言代码时,我们需要注意:

  • 声明可以进行任意次数,但定义只能进行一次。
  • extern关键字用于扩展变量/函数的可见性。
  • 由于函数在整个程序中默认是可见的,因此在函数的声明或定义中不需要使用extern关键字。它的使用是隐式的。
  • 当extern与变量一起使用时,只是声明,而不是定义
  • 作为例外,当使用初始化声明 extern 变量时,它也被视为变量的定义

此外,虽然内容不构成一节,但还是有必要提醒一个常见的错误——不要在声明结构体的时候初始化变量。

屏幕、按键或者其他外设

看完这些理论性质的东西,我们把目光重新聚焦在逐飞库上。

逐飞库下的zf_device文件夹中提供了各种可能使用的外设硬件的驱动代码。

利用精妙的设计与特性,逐飞库提供了一种类似面向对象编程(Object-Oriented Programming,简称OOP)的体验。如果我们将每个外设各视为一个实体(Entity),则逐飞库将各个外设分别抽象(Abstraction)为一个对象(Object),每一组关于该外设的文件即是一个类(Class),通过将对象的状态State——属性Attributes和行为Behaviors——方法Method打包在一起,形成逻辑独立的单元,并隐藏对象的内部细节,只暴露必要的接口(Interface)。逐飞库甚至一定程度上实现了多态(Polymorphism),例如对DEBUG信息的处理。

这使我们在使用外设时,不用(或极少)关心外设在硬件层面上的实现(也可以说:你不必去了解你要怎么处理寄存器,或者更麻烦的东西)甚至是一些软件层面上的实现。

通常来说,你在购买逐飞公司开发的外设或者学习板时,将会得到附带的使用例程。当我们翻阅了足够多的例程,我们容易注意到这些例程的共通之处——它们都会在程序的开头进行初始化。出于维护性的考虑,我们可以把这些初始化的过程集中在一个函数中,同时你还可以为它们编写一个图形加载页面。

用户界面

用户界面是介于用户与硬件而设计彼此之间交互沟通相关软件,目的在使得用户能够方便有效率地去操作硬件以达成双向之交互,完成所希望借助硬件完成之工作,用户界面定义广泛,包含了人机交互与图形用户界面,凡参与人类与机械的信息交流的领域都存在着用户界面。

——维基百科,自由的百科全书

我们在开发与调试智能车时,常常需要查看各种外设反馈的数据和算法的计算结果,有时还需要调整算法中的各个参数。在使用GPS的组别中,可能还需要录入不同的GPS点位。显然,我们不可能在每次读取和修改参数时都连接电脑进行烧录。这便要求我们编写一个程序,通过屏幕与按键完成上述的任务。我们称之为用户页面(UI)。(在本实验室,有时也称为菜单)

状态机与基于状态机编程

为了便于理解UI的编写方法,我们先引入状态机这个概念。

有限状态机(英语:finite-state machine,缩写:FSM)又称有限状态自动机(英语:finite-state automaton,缩写:FSA),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型。它是一种抽象机器,在任何给定时间内只能处于有限数量的状态之一。有限状态机可以根据一些输入从一个状态变换到另一个状态;从一个状态到另一个状态的变换称为转换。一个FSM由其状态列表、其初始状态和触发每个转换的输入定义。有限状态机有两种类型——确定有限状态机和非确定有限状态机。对于任何非确定性有限状态机,都可以构造一个等价的确定性有限状态机。

状态机的行为可以在现代社会的许多设备中观察到,这些设备根据它们所接收到的事件序列执行预先确定的一系列动作。简单的例子包括:自动售货机,在适当的硬币组合存入时提供产品;电梯,其停止的顺序由乘客请求的楼层确定;交通信号灯,在车辆等待时更改序列;密码锁,需要按正确顺序输入一系列数字。

有限状态机的优点之一是它可以帮助我们清晰地理解系统的行为,以及在不同状态下可能发生的事件和转换。这使得在设计和调试系统时更加容易。


现在我们看看基于自动机编程

自动机编程(英语:Automata-based programming)是编程范型中的一种,是指程序或其中的部分是以有限状态机(FSM)为模型的程序,有些程序则会用其他型式(也更复杂)的自动机为其模型。

有限状态机编程(英语:FSM-based programming)大致上等同于自动机编程,但有限状态机编程专指以有限状态机为模型的程序。

自动机编程有以下的二项特征:

  • 程序执行的时间中可以清楚划分成数个自动机的步骤(step),每一个步骤即为一个程序区段,有单一的进入点,可以是一个函数或其他程序。若有需要时,程序区段可以再依其状态的不同,划分为子区段。
  • 不同步骤的程序区段只能透过一组清楚标示的变量交换信息,这些变量称为状态(state),使用自动机编程的程序不能用其他不显然可见的方式标示状态,例如区域变量的数值、回传地址、目前程序指针的位置等。因此一程序在任二个不同时间下的差异,只有状态数值的不同,其余都相同。

自动机编程的执行过程是一个由自动机步骤形成的循环。

自动机编程中处理问题的思考方式很类似在利用图灵机、马尔可夫算法处理问题时的思考方式。

例如,考虑一个C语言的程序,由标准输入流一行一行的读取资料,打印各一行的第一个英文单词。因此一开始需确认第一个英文单词之前是否有空白,若有,需读取所有空白后略过不打印,读取第一个英文单词然后打印,之后读取其他内容略过不打印,直到读到换行符号为止。任何情形下只要读到换行符号,就重新开始此算法,任何情形下只要读到文件结束(end-of-file)的符号,就退出程序。

以下是传统指令式编程的C语言程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
int main(void)
{
int c;
do {
c = getchar();
while(c == ' ')
c = getchar();
while(c != EOF && c != ' ' && c != '\n') {
putchar(c);
c = getchar();
}
putchar('\n');
while(c != EOF && c != '\n')
c = getchar();
} while(c != EOF);
return 0;
}

上述问题也可以用有有限状态机的方式处理,此程序有三个不同的阶段:读取并跳过第一个单词前的空白、读取第一个单词并且打印、跳过后续的所有字符。以下将这三个阶段定义为三个状态before、inside及after。自动机编程的程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
int main(void)
{
enum states {
before, inside, after
} state;
int c;
state = before;
while((c = getchar()) != EOF) {
switch(state) {
case before:
if(c == '\n') {
putchar('\n');
} else
if(c != ' ') {
putchar(c);
state = inside;
}
break;
case inside:
switch(c) {
case ' ': state = after; break;
case '\n':
putchar('\n');
state = before;
break;
default: putchar(c);
}
break;
case after:
if(c == '\n') {
putchar('\n');
state = before;
}
}
}
return 0;
}

虽然此程序较长,至少有一个明显的好处,程序中只调用一个读取字符的getchar()函数,而且程序中只有一个循环,不像之前程序使用四个循环。

此程序中while循环内的程序即为自动机的步骤,而循环本身即可重复的执行自动机的程序。

此程序实现如下图所示的有限状态机,其中N表示换行字符、S表示空白、A表示其他的字符。自动机依目前状态及读取的字符不同,会执行图中一个箭头所示的动作,可能是由一个状态跳到下一个状态,也者停在原来的状态。其中有些箭头有标示星号,表示需打印读到的字符。

Automata
图片来自English Wikipedia 用户 DrCrocoCC BY-SA 3.0链接

自动机编程中,不一定要为每一个状态撰写独立的进程,而且有时状态是由许多变量组成,无法针对每一个状态规划个别的进程。此想法有时有助于程序的精简,例如在上述程序中,不论是在哪一个状态,针对换行字符的处理都一様,因此程序可以先处理换行字符,其他输入字符时才依不同状态进行处理,简化后变成以下的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
int main(void)
{
enum states {
before, inside, after
} state;
int c;
state = before;
while((c = getchar()) != EOF) {
if(c == '\n') {
putchar('\n');
state = before;
} else
switch(state) {
case before:
if(c != ' ') {
putchar(c);
state = inside;
}
break;
case inside:
if(c == ' ') {
state = after;
} else {
putchar(c);
}
break;
case after:
break;
}
}
return 0;
}

此例清楚的呈现自动机编程程序的基本特点:

  • 各自动机步骤程序的执行时间不互相重叠。
  • 前一个步骤和下一个步骤之间所交换的资料只有标示为“自动机状态”的变量(此例中为变量state)。

分析一个基本UI的运行逻辑

如果你有其他的设计方法,那就更好了。

流程图

如图所示。除去初始化外,一个基本UI大致可以分为三个部分——显示、输入和执行,如果把输入算作执行的一部分,那就是两个。(这里分出来一个等待输入是因为Obsidian画框图不能自己连到自己。)

具体的技术细节我们会在下一小节继续讨论,这一小节先展示这个程序是如何运作的。

首先,很容易想到,我们的UI应该以页面(Page)作为一组数据的基本单位。这个单位当中包含屏幕应该显示的内容和程序应该执行的操作,我们可以认为这些数据构成了一个完整的步骤(Step)。程序在一个页面到另一个页面之间跳转,就是从一个步骤转换到了另一个步骤。如果我们使用数组或者枚举来组织这些页面,那么现在我们就有了代表当前页面——步骤的状态——变量page_index

当我们进行主循环,UI主程序第一个需要的是判断它应该在屏幕上显示什么。这有两种实现方式,取决于你的数据是如何保存的:

  1. 函数——显而易见的方法是为每一个页面编写一个显示函数,好处在于灵活的表现形式与简单易懂的编写方法,缺点在于需要编写大量重复的繁琐代码。
    imu-show
  2. 结构体——你也可以选择用复杂的结构体数组来保存数据,并通过一个/一组通用的渲染函数来将其显示在屏幕上。你可能会发现这在实现上明显地更复杂了,但是这实现了代码的复用,而且能大幅简化页面的编写工作。
    imu-show

要注意到我们页面中大部分的内容由选项构成,这是为了最大化利用屏幕与按键。因此,我们要声明一个变量用于表示我们正在选择的选项;我们还需要在屏幕上将这个选择表示出来。

接下来程序将检测按键的输入。在执行的部分,UI根据我们的输入做出(或不做出)不同的反应。为了便于我们操作,我们可以将按下按键的动作——事件细分为短按与长按,这可以通过计时来实现。

值得一提的是,我们预见到两种不同的交互情景——

  1. 选单、调参之类的页面。这类页面的特点在于,只要我们不进行输入,画面上展示的内容就不会发生变化。我们可以允许UI程序在这里等待输入,来减少页面刷新的频率。
  2. 实时反馈数据的页面。这类页面和上面的不同,它需要页面快速的刷新以实时反馈最新的数据。这时,我们就不能允许UI长时间等待按键的输入导致屏幕的停滞。

这两个冲突需要通过简单的条件判断解决。此外,当我们编写图像处理、获取GPS数据之类需要等待数据输入与处理的页面,屏幕的帧率并不是固定的,而取决于数据处理的的速度。因此在主循环中检测按键按下不太现实,我们需要通过中断来解决按键读取的问题。

主循环的最后是执行部分,无论是否发生输入,程序都需要知道它们要执行什么操作。跳转到另一个页面、减小某个参数的大小、决定发出汽车、或者干脆什么也不做,直接渲染下一帧,这些都由我们决定。同样的,这里有两种实现方式:

  1. 函数——对。通过switch对输入进行判断,不同的按键方式导向不一样的动作。这听起来很简单,事实也是如此。
  2. 事件编号。通过不同的编号触发不同的动作。或许是个不错的尝试。

这些听起来可能有些复杂,但实践起来并不困难。现在我们就着实例代码,来看看具体的实现方法。

定义与初始化

稍微观察一下屏幕就能发现,一个字符的宽高是8*16。因此,为了方便编写显示代码,我们可以在文件的顶部进行下面两个宏定义:

1
2
#define column(x) 8*x // 列(竖排)
#define line(x) 16*x //行(横排)

接下来,正如上节所述,我们需要定义一个枚举类型,为每个页面——步骤分配一个常量,作为状态的数据类型。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 页面枚举
typedef enum OGAS_PAGE {
OGAS_MASTER,
OGAS_START,
EXEQ,
OGAS_CAMREA,
OGAS_GYROSCOPE,
FLASH_SET,
} OGAS_PAGE;

...

OGAS_PAGE page_now = OGAS_MASTER; // OGAS主循环需要显示的页面。
OGAS_PAGE page_last; // 上一个加载的页面。

为什么名字不是UI而是是OGAS?这是因为写这些代码的时候正好在听《Automation From Mao’s Legacy》,遂决定用这个名字致敬Общегосударственная автоматизированная система учёта и обработки информации。

接下来,我们还要定义一个变量,用来表示页面中当前被选中的选项。例如:

1
int page_target = 0; // 各页面的选择目标。

通过上述步骤我们基本完成了UI的初始化。现在我们试着写一个漂亮的进入动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* @brief 欢迎页面。
*/
void ogas_welcome() {
// tft180_show_rgb565_image(0, 0, (const uint16 *)gImage_EGM, 128, 160, 128, 160, 0);
tft180_full(RGB565_BLACK);
for (int i = 0; i < 128; i += 4) {
tft180_draw_line(i, 0, 127 - i, 159, RGB565_WHITE);
system_delay_ms(10);
}
for (int i = 159; i >= 0; i -= 4) {
tft180_draw_line(0, i, 127, 159 - i, RGB565_WHITE);
system_delay_ms(10);
}
for (int i = 0; i < 15; i++) {
int x = (i < 4) ? 112 : ((i < 10) ? 96 : 80);
int y = 16 * i + ((i < 4) ? 0 : ((i < 10) ? -64 : -160));

tft180_show_chinese(x, y, 16, WHO[2 * i], 1, RGB565_GRAY);
system_delay_ms(67);

tft180_show_chinese(x, y, 16, WHO[2 * i], 1, RGB565_BLACK);
system_delay_ms(67);
}
system_delay_ms(500);
}

这些代码会在屏幕上画出一些反射状分布的线条,然后在页面右上角显示一些中文,最后消失。

逐飞库中并不包含中文字库,想在屏幕上显示中文,我们需要自己提取字模。这需要用到PCtoLCD2002这个工具。

主循环

现在,程序进入到主循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
*
* @brief 人机交互界面的主循环程序。 -致瞬息万变之物,及亘古不变之物-
*
*/
void OGAS(void) {
ogas_welcome();

while (!ogas_ready) {
tft180_clear();

if (page_now != page_last) {
page_target = 0;
}

page_last = page_now;

switch (page_now) {
case OGAS_MASTER:
ogas_page_master();
break;
case EXEQ:
ogas_page_exeq();
break;
case OGAS_CAMREA:
ogas_page_camera();
break;
case OGAS_GYROSCOPE:
ogas_page_gyroscope();
break;
case FLASH_SET:
ogas_page_flsah();
break;
default:
ogas_page_error();
}
}
}

我们可以声明一个变量ogas_ready用于决定我们应该什么时候退出这个循环——也就是决定将要发车的时候。它的初始值应该为1。

在每次循环的开始,我们先调用屏幕的clear函数清除屏幕上的所有内容,避免屏幕上原有的内容影响显示效果。

接下来,我们判断上一个循环是否切换了页面,如果发生了切换,代码中就把表示当前选中选项的变量ogas_target归为0,这是为了避免发生不可预见的问题。

最重要的部分在于中间很大的一个switch判断,它构成了状态机的核心——实现步骤判断。

我们前面已经提到,每一个页面都可以认为是一个步骤。接下来我们研究,在步骤的内部,我们的UI是如何实现的。

显示部分

首先是显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* @brief MASTER页面。
*/
void ogas_page_master() {
// 显示部分
tft180_show_string(0, 0, "408 Lab. cWUI-V2");
tft180_show_string(0, 16, "<MAETSR>");
tft180_show_string(0, 48, " START");
tft180_show_string(0, 64, " ex.eq. Input");
tft180_show_string(0, 80, " FLASH edit");
tft180_show_string(0, 112, " (c)2023");
tft180_show_string(0, 144, "[UP/DOWN/COF/RT]");

tft180_show_string(0, 48 + 16 * (page_target == 3 ? page_target + 1 : page_target), " ->");

// 执行部分
switch (ogas_get_key(1)) {
case K1_S:
page_target = (page_target == 0) ? 3 : page_target - 1;
break;
case K2_S:
page_target = (page_target == 3) ? 0 : page_target + 1;
break;
case K3_S:
switch (page_target) {
case 0:
page_now = OGAS_START;
break;
case 1:
page_now = EXEQ;
break;
case 2:
page_now = FLASH_SET;
break;
}
break;
case K4_S:
ogas_welcome();
break;
default:break;
}
}

早期编写的代码未使用前面提到的那两个宏定义,显然就没那么直观了。

显示部分非常直观,不需要过多解释。我们注意这当中表示选中项的方法看起来可能有些曲折,还用到了三元运算符。这是因为最下方的选项和其他选项没有连在一起。在处理一些特殊的情况时,我们可以使用这种方法来进行实现。

执行部分

很容易注意到,这其实就是一个小状态机,不同的case就是不同的步骤,接受按键的输入作为状态。

首先关心按键读取的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef enum OGAS_KEY {
EMPTY,
K1_S, K1_L,
K2_S, K2_L,
K3_S, K3_L,
K4_S, K4_L,
} OGAS_KEY;

/**
* @brief 获取各按键状态。
*
* @param wait 是否循环等待直到按键按下
* @return 按键状态
*/
OGAS_KEY ogas_get_key(int wait) {
do {
if (key_get_state(KEY_1)) { return key_get_state(KEY_1) + 0; }
if (key_get_state(KEY_2)) { return key_get_state(KEY_2) + 2; }
if (key_get_state(KEY_3)) { return key_get_state(KEY_3) + 4; }
if (key_get_state(KEY_4)) { return key_get_state(KEY_4) + 6; }
} while (wait);
return EMPTY;
}

这里调用的是逐飞库zf_device_key.c中定义的函数,我们后面一点再讨论。之前有同学对这段代码的实现很困惑,所以在这里简单解释一下:

在C语言中,枚举类型是被当做int或者unsigned int类型来处理的。如果我们对其进行强制类型转换,你可以看到隐藏在字符背后的数值。现代编辑器通常也都能让用户直接查看。

任何一个按键无非三种状态——未按下(释放)、短按和长按,也即0、1和2。当我们调用逐飞库的key_get_state函数,它总是给我们反馈指定按键的上述三个状态之一。如果按键被释放(为0),则不满足if判断的成立条件(非0),因而不会进入到内部的处理,继续进行下一个if的判断。假若直到退出循环,没有任何一个if判断成立,即没有任何按键被按下,函数返回EMPTY,即0。如果有按键被按下,就有一个if判断的条件被满足,进入其内部的处理,函数将返回按键的状态加上一个常数。想起来短按和长按分别为1和2,这个常数实际上形成一个偏移量,它对不同按键的同一状态进行偏移,使得每个按键的每种状态都有唯一对应的常量。

函数主体使用do-while循环,使循环至少被执行一次。函数接受的wait参数允许循环循环反复执行,直到某个按键被按下才返回而退出循环;或者也可以只执行一次就退出,让UI进行下一步动作。

现在让我们关注逐飞库。在调用key_init函数进行初始化时,函数要求我们提供一个参数作为按键扫描周期,单位为毫秒。在这个函数上方还存在一个名为key_scanner的函数,注释要求我们在主循环或者PIT中断中调用该函数。但考虑到按键扫描周期为定值,而每个页面的处理时间不尽相同,在PIT中断中调用成为了唯一的选择。

我们顺便提一下PIT中断。不同芯片的逐飞库对其实现略有差别,需要参阅例程。以笔者本人使用的逐飞科技CH32V307开源库为例,其将具体的中断执行函数储存在名为isr.c的文件内。例如:

1
2
3
4
5
6
7
8
void TIM5_IRQHandler(void)
{
if(TIM_GetITStatus(TIM5, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM5, TIM_IT_Update );
key_scanner();
}
}

TC377库的中断处理函数和这个看起来很不一样,但原理是相同的。

但MCU此时并不知道何时应该调用此函数。逐飞库提供了zf_driver_pit帮助我们进行PIT初始化。在该名字的.h文件中有如下宏定义函数:

1
#define pit_ms_init(pit_n, ms) (pit_init((pit_n), (ms) * (system_clock / 1000)))

其中pit_n是我们需要使用的PIT通道,ms则是PIT周期。我们通过调用此函数,就能完成PIT中断的初始化,而无需像学习89C51那样关心对MCU各寄存器的配置,这正是上文所述的逐飞库的优点。

这样,我们就实现了对按键输入的读取。

将按键的输入视作状态,并根据不同的状态执行不同的步骤我们应该已经熟悉了。这里我们讨论一个特别的动作。

FLASH

本节还没有完成,但已经超过理应发布时间一星期了……

我们现在已经知道,我们程序运行时定义的变量储存在内存中,断电即丢失。这就存在一个很不方便的情况——假若我们临时修改了某个参数,但条件又不允许我们频繁烧录,我们就不得不在每一次上电后再手动对其进行更改。如果我们要管理一大组参数,这种维护工作就变得艰难直至不可行了。

为解决这一矛盾,我们使用FLASH在掉电时储存数据。FLASH的特点在于需要整页存取,因此读取和写入的时间比较长。使用方法逐飞库中已经提供详细的例程,这里不进行赘述。我们关注如何在用户页面中编辑和储存FLASH数据。

任务目标

各组基于逐飞TC377库,参考Example/E05_pit_demo、Example/E08_eeprom_demo、tc377_main_board_demo/E06_01_oled_display_demo实现一个用户页面。代码应该易于维护和扩展,并至少包含下面的页面层次:

1
2
3
4
5
6
7
8
9
10
.主页
├── 发车
├── 数据查看
| ├── GPS
| ├── MPU6050
| ├── CAMERA
| ├── ENCODER
| ├── MOTO_PID
| └── WIFI
└── FLASH

数据查看中各个三级页面可以先留空,但应该实现退出页面的功能。FLASH页面中需要实现编辑和储存的功能,至少可以编辑5个示例变量。选中“发车”时结束用户页面。

将所有源代码上传到仓库中。并在2024年5月19日(星期日)20:40之前将仓库首页的链接发送给我。

参考资料

  1. 有限状态机 - 维基百科,自由的百科全书
  2. 状态机编程 - 维基百科,自由的百科全书

以上是正文内容。

除非另有声明,本站作品均根据 CC BY-NC-SA 4.0 协议进行许可。



除非另有声明,本站作品均根据 CC BY-NC-SA 4.0 协议进行许可。


ICP Icon 萌ICP备20246280号 | Travel Icon 异次元之旅