本文基于江协的代码,以及普中 A2。(虽然江协的代码质量让人一言难尽)
本文面向具有一定知识基础的 C51 初学者。本人也是刚学,喜欢擅自制造概念,若有内容有疏漏请指出。
本文先介绍什么是页面生命周期,再介绍若干种基本的或简单的页面生命周期关系。
转载须注明出处。
1 main() 函数提供入口页面
在主循环前,初始化 LCD1602,就可以在此后通过 LCD1602 来构建文本页面。
#include <REGX52.H>
#include "LCD1602.h"
#include "Delay.h"
unsigned int time_count;
void main
{
LCD_Init();
// 可以在初始化后调用 LCD1602 的方法
LCD_ShowString(1, 1, "Hello, World!");
while (1)
{
// 可以在主循环调用 LCD1602 的方法
LCD_ShowNum(2, 1, time_count % 10, 1);
Delay(1000);
time_count++;
}
}
这里所谓的 页面,就是让单片机处于所设计的一个特殊状态,并通过 LCD1602 这个显示屏将状态表达出来。输入的数据可以来自按键,传感器等;输出的数据可以指向蜂鸣器,电动机等。
主要的页面构建方法有两种,一个是在指定位置显示文本,一个是在指定位置显示数字。通过在指定位置显示空字符串,可以实现字符位清除的效果。
2 函数是页面生命周期的一种形式
比如说在主循环里写了这么一个业务,按下 K3 时,LCD1602 上显示 “K3 on”,延时 1000 毫秒后清除这一行字,那么就相当于存在了一个页面。
#include <REGX52.H>
#include "LCD1602.h"
#include "Key.h"
unsigned char key_active;
void main() {
LCD_Init();
while (1) {
key_active = Key();
while (key_active == 3) {
LCD_ShowString(1, 1, "K3 on");
Delay(1000);
LCD_ShowString(1, 1, " ");
}
}
}
这里所谓的生命周期,就是一个设计对象可以被提及的范围,包括作用域,状态事项,业务逻辑,接口等。生命周期从对象被单片机的软件环境创建开始,至软件环境将它销毁而结束。举个例子,上述页面被关闭的时候,要记得把 K3 on 这一行字给销毁掉,不要留给别的页面。
页面的生命周期开始于 K3 被按下,结束于延时结束然后那行字被清除。 为了体现这个页面的存在性,以至于方便进一步的构建,可以把相关业务逻辑给提出来,放到一个函数里。
当执行到一个函数的时候,线程被这个函数中断,当页面的生命周期后中断才会被返回。
#include <REGX52.H>
#include "LCD1602.h"
#include "Key.h"
void main() {
LCD_Init();
while (1) {
key_active = Key();
while (key_active == 3) {
// 中断发生
page();
// 中断返回
}
}
}
page() {
// page 生命周期开始
LCD_ShowString("K3 on");
Delay(1000);
// page 生命周期结束
LCD_ShowString(" ");
}
这样子分开,有一个好处。比如说有一个地方要改动,可以很快地找到应该改哪里,而不是在 main() 里面到处找。另一方面,page() 将可以复用,就像 Delay() 这个函数可以复用。
3 页面生命周期的分支构型与注册构型
在上一个例子,构建了一个简单的结构:
graph LR
A[entry] --> B[loop]
B --> C[page]
C --> B
如果结构复杂一点,比如说加了一个新的页面,并且由一个新的按键作为入口:
graph LR
A[entry] --> B[loop]
B --> C[page 1]
C --> B
B --> D[page 2]
D --> B
可以这么说,分支构型就是在主循环中通过条件判断来决定进入哪个页面。这是最直观的实现方式:
#include <REGX52.H>
#include "LCD1602.h"
#include "Key.h"
unsigned char key_active;
void page1() {
// page 1 生命周期开始
LCD_ShowString(1, 1, "Page 1 ");
Delay(1000);
// page 1 生命周期结束
LCD_ShowString(1, 1, " ");
}
void page2() {
LCD_ShowString(1, 1, "Page 2 ");
Delay(1000);
LCD_ShowString(1, 1, " ");
}
void main() {
LCD_Init();
while (1) {
key_active = Key();
// 分支判断
if (key_active == 1) {
page1();
} else if (key_active == 2) {
page2();
}
// 此处可以继续写
}
}
这种方式简单直接,但随着页面增多,main() 函数会变得冗长,形如ξ 这个希腊字母。此时可以考虑注册构型,通过一个中转函数来管理页面跳转:
#include <REGX52.H>
#include "LCD1602.h"
#include "Key.h"
// 页面函数声明
void page1();
void page2();
void page3();
// 页面跳转中转函数
void page_router(unsigned char page_id) {
switch(page_id) {
case 1:
page1();
break;
case 2:
page2();
break;
case 3:
page3();
break;
default:
// 默认页面或错误处理
break;
}
}
void main() {
LCD_Init();
unsigned char key_active;
while (1) {
key_active = Key();
// 只有有效按键才触发页面跳转
if (key_active != 0) {
page_router(key_active);
}
}
}
// 页面函数定义
void page1() {
LCD_ShowString(1, 1, "Page 1 Active ");
Delay(800);
LCD_ShowString(1, 1, " ");
}
void page2() {
LCD_ShowString(1, 1, "Page 2 Active ");
Delay(800);
LCD_ShowString(1, 1, " ");
}
void page3() {
LCD_ShowString(1, 1, "Page 3 Active ");
Delay(800);
LCD_ShowString(1, 1, " ");
}
注册构型的本质是在实现解耦,即所有页面跳转逻辑在一个函数中,而不是和主循环的其它部分混在一起。如果要添加或减去生命周期起点,只需要修改注册函数。
有些人可能会联想到 hook,中断向量表,Hash 谱,注册表,监听器之类的东西。同理。
4 页面生命周期的递归构型与线性构型
4.1 递归构型
递归构型指的是页面可以调用自身或其他页面,形成调用链。这种方式在菜单系统、状态机中常见:
void main_menu() {
LCD_ShowString(1, 1, "Main Menu ");
LCD_ShowString(2, 1, "1.Set 2.View ");
unsigned char key = 0;
while (key == 0) {
key = Key();
}
if (key == 1) {
settings_menu(); // 进入设置菜单
} else if (key == 2) {
view_data(); // 进入数据查看
}
// 返回后重新显示主菜单
main_menu(); // 递归调用
}
void settings_menu() {
LCD_ShowString(1, 1, "Settings ");
LCD_ShowString(2, 1, "1.Time 2.Back ");
unsigned char key = 0;
while (key == 0) {
key = Key();
}
if (key == 1) {
set_time(); // 进入时间设置
}
// key == 2 时自然返回
}
递归构型容易去设计,符合思维习惯,但容易造成内存方面的诡异行为(此处不作介绍)。
4.2 线性构型
线性构型通过状态变量控制页面流转,避免深层递归。线性构型的设计受到了一定的约束,但设计充分后容易去实现。
这里提供一个更合适的线性构型示例:
// 页面状态定义
#define PAGE_MAIN 0
#define PAGE_SETTINGS 1
#define PAGE_SET_TIME 2
#define PAGE_VIEW_DATA 3
unsigned char current_page = PAGE_MAIN;
void main() {
LCD_Init();
while (1) {
unsigned char key = Key();
// 根据当前页面和按键决定下一个页面
switch(current_page) {
case PAGE_MAIN:
if (key == 1) {
LCD_ShowString(1, 1, "Settings Page ");
current_page = PAGE_SETTINGS;
} else if (key == 2) {
LCD_ShowString(1, 1, "Data View ");
current_page = PAGE_VIEW_DATA;
}
break;
case PAGE_SETTINGS:
if (key == 1) {
LCD_ShowString(1, 1, "Set Time ");
current_page = PAGE_SET_TIME;
} else if (key == 2) {
LCD_ShowString(1, 1, "Main Menu ");
current_page = PAGE_MAIN;
}
break;
case PAGE_SET_TIME:
if (key == 2) {
LCD_ShowString(1, 1, "Settings ");
current_page = PAGE_SETTINGS;
}
break;
case PAGE_VIEW_DATA:
if (key == 2) {
LCD_ShowString(1, 1, "Main Menu ");
current_page = PAGE_MAIN;
}
break;
}
}
}
5 页面的语境捕获
页面对象获取数据的形式主要有:
- 向页面的实现函数传入参数
- 与别的设计对象共享内存,例如借助 AT24C02 保存密码
- 与别的设计对象通信,例如借助 DS18B20 读取当前温度
- 在函数内部指定字面量,例如之前的 "K3 on" 这个字面量。
- 函数对全局变量的访问,例如 REGX52.H 指定的引脚别名。
下面是一个更合适的语境捕获示例,展示如何通过参数传递和全局变量共享数据:
#include <REGX52.H>
#include "LCD1602.h"
#include "DS18B20.h"
// 全局变量 - 共享数据
int current_temperature = 0;
// 通过参数传递页面配置
void display_page(unsigned char row, unsigned char col, char* message, unsigned int duration) {
LCD_ShowString(row, col, message);
Delay(duration);
// 清理显示
LCD_ShowString(row, col, " ");
}
// 使用全局变量获取数据
void temperature_page() {
char temp_str[16];
// 从DS18B20读取温度
current_temperature = DS18B20_ReadT();
// 格式化显示
sprintf(temp_str, "Temp: %d.%dC", current_temperature/10, current_temperature%10);
display_page(1, 1, temp_str, 2000);
}
// 主函数
void main() {
LCD_Init();
DS18B20_Init();
while (1) {
if (P3_0 == 0) { // 按键K1按下
temperature_page();
}
}
}
良好的语境设计可以使页面既独立又协同,既能完成特定功能,又能与其他模块有效通信。
6 退化规划
在复杂系统中,有时需要简化页面关系。退化规划的核心思想是:将复杂的页面关系退化为简单关系。注意,过度的关系简化可能会让具体的函数实现变得复杂。
下面是一个更实际的退化规划示例,展示如何将多级菜单简化为扁平结构:
// 原始的多级菜单系统(复杂)
void complex_system() {
while (1) {
main_menu(); // 主菜单
// 主菜单调用二级菜单
// 二级菜单调用三级菜单
// 形成深层调用链
}
}
// 退化后的扁平结构(简化)
void simple_system() {
unsigned char page_id = 0; // 当前页面ID
unsigned char param = 0; // 页面参数
while (1) {
unsigned char key = Key();
// 扁平化的页面处理
switch(page_id) {
case 0: // 主页面
LCD_ShowString(1, 1, "Main:1-3 Select");
if (key == 1) page_id = 1;
if (key == 2) page_id = 2;
if (key == 3) page_id = 3;
break;
case 1: // 页面1
LCD_ShowString(1, 1, "Page 1 Active ");
if (key == 4) {
param = 10; // 设置参数
page_id = 0; // 返回主页
}
break;
case 2: // 页面2
LCD_ShowString(1, 1, "Page 2 Active ");
if (key == 4) page_id = 0;
break;
case 3: // 页面3
LCD_ShowString(1, 1, "Page 3 Active ");
if (key == 4) page_id = 0;
break;
}
}
}
退化规划的注意事项:
- 目标是降低复杂度,不是提高复杂度
- 尽量避免多层页面嵌套
- 所有页面使用相同的调用方式
- 页面状态由外部管理,页面本身无状态
7 反组合规划
反组合规划指的是,先构建整体框架,再填充结构无关的功能。反组合规划有利于核心架构的早期确定。
下面是一个更适合C51环境的反组合规划示例:
// 第一步:定义核心框架
typedef struct {
void (*show)(void); // 显示函数
void (*handle_key)(unsigned char key); // 按键处理函数
unsigned char id; // 页面ID
} Page;
// 第二步:声明页面数组(最大支持8个页面)
Page pages[8];
unsigned char page_count = 0;
unsigned char current_page = 0;
// 第三步:页面注册函数
void register_page(void (*show)(void), void (*handle_key)(unsigned char), unsigned char id) {
if (page_count < 8) {
pages[page_count].show = show;
pages[page_count].handle_key = handle_key;
pages[page_count].id = id;
page_count++;
}
}
// 第四步:主框架
void main() {
LCD_Init();
// 初始化框架
init_framework();
while (1) {
// 显示当前页面
if (pages[current_page].show != NULL) {
pages[current_page].show();
}
// 处理按键
unsigned char key = Key();
if (key != 0 && pages[current_page].handle_key != NULL) {
pages[current_page].handle_key(key);
}
Delay(50); // 简单延时,避免CPU过载
}
}
// 第五步:最后实现具体页面功能
void home_show() {
LCD_ShowString(1, 1, "Home Page ");
LCD_ShowString(2, 1, "Press K1-K3 ");
}
void home_handle_key(unsigned char key) {
if (key == 1) {
current_page = 1; // 跳转到页面1
}
}
// 框架初始化
void init_framework() {
// 注册主页
register_page(home_show, home_handle_key, 0);
// 这里可以注册更多页面...
// 每个页面的具体实现可以稍后完成
}
8 实例
环境检测系统
- 上电显示输入 8 位密码,密码错误则重新输入,密码正确进入主界面,按下独立按键 K1 确定,按下独立按键 k2 清除
- 主界面显示实时温度显示小数点后一位),光照每 2 秒更新一次
- 按下独立按键 K1 进入设置界面,菜单选择温度或密码设置 温度界面可设置温度的最大值,超过阈值蜂鸣器报警 密码设置界面可设置 8 位密码,关机重启密码变为重新设置的新密码
- 最多设置 3 位独立按键用来进行页面切换矩阵键盘只能用于数字输入
- 设置完成后可返回主界面
我不想在这里讨论,怎么去实现。简单阐述一种架构思路吧。
首先,要有 5 个页面:
graph TD
A[密码输入页面] --> B[主界面页面]
B --> C[设置菜单页面]
C --> D[温度设置页面]
C --> E[密码设置页面]
D --> B
E --> B
然后,密码输入和主页面,可以退化到 main() 函数里。使用线性构型管理页面流转:
// 页面定义
#define PAGE_PASSWORD 0 // 密码输入页
#define PAGE_MAIN 1 // 主界面
#define PAGE_SETTINGS 2 // 设置菜单
#define PAGE_SET_TEMP 3 // 温度设置
#define PAGE_SET_PASS 4 // 密码设置
unsigned char current_page = PAGE_PASSWORD;
unsigned char password[8] = {1,2,3,4,5,6,7,8}; // 默认密码
// 主框架
void main() {
init_all(); // 初始化所有硬件
while (1) {
switch(current_page) {
case PAGE_PASSWORD:
handle_password_page();
break;
case PAGE_MAIN:
handle_main_page();
break;
case PAGE_SETTINGS:
handle_settings_page();
break;
case PAGE_SET_TEMP:
handle_temp_set_page();
break;
case PAGE_SET_PASS:
handle_pass_set_page();
break;
}
}
}
最后再去实现细枝末节,比如密码保存(用AT24C02),高温报警(用蜂鸣器),温度读取(用DS18B20),光照检测(用光敏电阻)等等。
没有绝对的最佳实践,只有最适合当前项目的设计选择。差不多得了。