DTU-HMI
PC 端 HMI 菜单逻辑模拟程序,纯 C 实现,用于在电脑上模拟现场 DTU 设备的人机界面(HMI)行为。
- 菜单树:按真实装置菜单还原,支持多级嵌套、首尾成环遍历
- LCD 显示:模拟 128×64 单色 LCD(点阵缓冲区 + 绘制函数)
- 键盘输入:用 PC 键盘按键映射嵌入式按键(上/下/左/右/确认/退出)
- TCP 远程显示:实现 RemoDispBus 协议,可把 LCD 显示通过网络推送到上位机
- UTF‑8 汉字库:内置 12×12 点阵汉字库,可扩展
1. 快速开始(构建 & 运行)
1.1 构建可执行程序
mkdir build
cd build
cmake ..
cmake --build .
生成可执行文件:
- Windows:
build/DTU-HMI.exe - Linux/macOS:
build/DTU-HMI
1.2 直接在本机运行
在 build 目录中执行:
./DTU-HMI.exe # Windows PowerShell
./DTU-HMI # Linux / macOS
终端中会出现类似“LCD 文本界面 + 菜单内容”的输出,使用键盘控制菜单。
3. 目录结构
DTU-HMI/
├── CMakeLists.txt
├── gen_utf8_hz12.py # 12×12 UTF-8 汉字库生成脚本
├── remo_disp_server.py # 远程显示 Python 服务端
├── include/
│ └── types.h
├── src/
│ ├── main.c
│ ├── thread_utils.c/h
│ ├── remoteDisplay.c/h
│ ├── TCP/tcp.c, tcp.h
│ └── Drv/
│ ├── menu.c/h
│ ├── display.c/h
│ ├── lcd/
│ │ ├── lcd.c/h
│ │ ├── lcd_draw.c/h
│ │ ├── lcd_text.c/h
│ │ ├── text_codec.c/h
│ │ └── ascii.c/h
│ └── utf8_hz12_data.c/h # 由脚本生成
└── build/
4. 环境要求
- CMake 3.10+
- 编译器:Windows 默认 MSVC;Linux 需 GCC/Clang
- 编码:源文件 UTF-8,CMake 已配置 MSVC
/utf-8
5. 构建步骤
5.1 Windows
mkdir build
cd build
cmake ..
cmake --build .
5.2 Linux / macOS
mkdir build
cd build
cmake ..
cmake --build .
6. TCP 远程显示(RemoDispBus)
程序内部通过 remoteDisplay.c + tcp.c 实现 RemoDispBus 协议通讯,可把 LCD 显示内容实时推送给远程上位机。
- 默认监听 端口:7003
- 协议命令:
CMD_INIT:初始化/握手CMD_LCDMEM:下发/上传 LCD 显存缓冲区CMD_KEY:上位机模拟按键下发给设备CMD_KEEPLIVE:心跳保持连接
6.1 使用 Python 远程显示服务端
在 PC 上运行模拟程序的同时,可以用仓库中的 remo_disp_server.py 来查看 LCD 的显示图像:
python remo_disp_server.py # 需要 Python 3 + 标准库,具体依赖参考脚本头部说明
脚本会作为 “远程显示上位机”:
- 主动连接 DTU-HMI 程序监听的端口(默认 7003)
- 接收并解析
CMD_LCDMEM,在一个窗口中实时绘制 LCD 内容 - 也可以把 GUI 上的按键事件转换为
CMD_KEY下发给 DTU-HMI 程序
7. 菜单系统
7.1 概述
菜单由静态表 g_tMenuModelTab 定义,经 Menu_Main_Creat_01 构建为可遍历树 g_tMenuItem[]。
运行时由 menu.c 负责:
- 把静态表解析成树 + 循环双向链表
- 根据按键事件更新当前指针
g_tMenuCtrl.ptCurrent - 调用一系列“坐标计算函数”给每一级菜单计算矩形框位置
- 最终调用
display.c/lcd.c完成绘制
7.2 数据结构
7.2.1 静态菜单定义(tagMenuModel)
typedef struct
{
uint8_t byClass; // 菜单分级标志 0/1/2/3
uint8_t byName[50]; // 菜单字符串
uint8_t byTip[50]; // 菜单提示文本
uint8_t byAttrib; // 菜单属性
uint16_t wPassword; // 访问密码,0x0000 表示无密码
uint16_t wPara; // 菜单执行函数参数
FUNCPTR pfnWinProc; // 界面执行函数指针
} tagMenuModel, *tagPMenuModel;
/* 注意:表定义时顺序不能乱,需从 0 级开始,一级一级按顺序写入 */
const tagMenuModel g_tMenuModelTab[] = { ... };
7.2.2 菜单遍历树(tagMenuItem)
/* 每个菜单包含:一、上下前后等级关系;二、属性与内容;三、显示坐标 */
typedef struct _MENU_ITEM_
{
struct _MENU_ITEM_ *ptHigher; // 上级菜单指针
struct _MENU_ITEM_ *ptLower; // 下级菜单指针
struct _MENU_ITEM_ *ptBefore; // 同级上方指针
struct _MENU_ITEM_ *ptBehind; // 同级下方指针
uint8_t byClass;
uint8_t byName[50];
uint8_t byTip[50];
uint8_t byAttrib;
uint16_t wPassword;
uint16_t wPara;
FUNCPTR pfnWinProc;
uint16_t wPos, wNum;
uint16_t wSPosX, wSPosY, wEPosX, wEPosY;
} tagMenuItem, *tagPMenuItem;
tagMenuItem g_tMenuItem[300]; /* 所有菜单存储于此数组 */
7.3 菜单构建示例
7.3.1 静态表(g_tMenuModelTab)
const tagMenuModel g_tMenuModelTab[] =
{
{ 0, " ", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 1, "装置信息", "查看装置信息", 0, 0x0000, EN_ANA_0, (FUNCPTR)MenuProc_See_AppInfo },
{ 1, "实时数据", "装置实时数据", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "交流量", "查看遥测交流量", EN_MEA_AC, 0x0000, EN_ANA_0, (FUNCPTR)MenuProc_See_YC },
{ 2, "直流量", "查看遥测直流量", EN_MEA_DC, 0x0000, EN_ANA_0, (FUNCPTR)MenuProc_See_YC },
{ 2, "遥信量", "查看遥信开入量", EN_INPUT_RLY_ALL, 0x0000, EN_INPUT_0, (FUNCPTR)MenuProc_See_Input },
{ 1, "参数定值", "保护参数查看与修改", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "整定", "整定装置保护参数", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 3, "参数", "查看设备参数定值", EN_FIGURE_SET, CN_USER_PWD, EN_SIDE_BASIC, (FUNCPTR)MenuProc_Set_Value },
{ 3, "定值", "设置装置数值定值", EN_FIGURE_SET, CN_USER_PWD, EN_SIDE_DEF, (FUNCPTR)MenuProc_Set_Value },
{ 3, "控制字", "设置装置控制字", EN_SOFT_SET, CN_USER_PWD, EN_SIDE_DEF, (FUNCPTR)MenuProc_Set_Value },
{ 3, "软压板", "设置软压板", 0, CN_USER_PWD, EN_SOFT_PRO, (FUNCPTR)MenuProc_Set_Soft },
{ 2, "查看", "查看装置保护参数", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 3, "参数", "设置设备参数定值", EN_FIGURE_SET, 0x0000, EN_SIDE_BASIC, (FUNCPTR)MenuProc_See_Set },
{ 3, "定值", "查看数值型定值", EN_FIGURE_SET, 0x0000, EN_SIDE_DEF, (FUNCPTR)MenuProc_See_Set },
{ 3, "控制字", "查看控制字定值", EN_SOFT_SET, 0x0000, EN_SIDE_DEF, (FUNCPTR)MenuProc_See_Set },
{ 3, "软压板", "查看软压板", 0, 0x0000, EN_SOFT_PRO, (FUNCPTR)MenuProc_See_Soft },
{ 1, "三遥设置", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "遥测死区", "设置遥测量死区门槛", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YC_SetSqValue },
{ 2, "遥测系数", "设置遥测量微调系数", EN_MEA_ADJ, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YC_SetAdjCoe },
{ 2, "遥信类型", "设置遥信类型", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YX_SetCommType },
{ 2, "遥信防抖", "设置遥信防抖时间", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YX_SetWidth },
{ 2, "双点遥信", "设置双点遥信虚端子", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YX_SetTwin },
{ 1, "装置维护", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "时钟设置", "设置系统时钟", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_Time },
{ 2, "强制复归", "可复归未返回事件", EN_REV_FORCE, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_RevEvent },
{ 2, "手动录波", "启动手动录波", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_ManualWave },
{ 2, "清除记录", "", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_ClrRec },
{ 1, "通讯参数", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "通讯设置", "外部通讯设置", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_ComPara },
{ 2, "网口设置", "", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_EditIP },
{ 2, "SNTP设置", "", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_EditSntp },
{ 1, "记录查询", "查看各种装置记录", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "SOE记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecSOE },
{ 2, "事故记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecAct },
{ 2, "操作记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecOpt },
{ 2, "保护告警", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecAlm },
{ 2, "保护启动", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecStart },
{ 2, "遥控记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecYK },
{ 2, "自检记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecChk },
{ 2, "运行记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecRun },
{ 2, "运行报告", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecFault },
{ 0, "厂家设置", "设置装置相关参数", 0, CN_COP_PWD, 0, (FUNCPTR)Menu_NonPfunc },
{ 1, "元件配置", "配置元件配置", 0, CN_COP_PWD, EN_FACTORY_PASSWORD,(FUNCPTR)MenuProc_Cfg_CellConf },
{ 1, "恢复默认", "恢复默认元件定值参数", 0, CN_COP_PWD, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "全部恢复", "全部参数恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_AllInf_Default },
{ 2, "默认参数", "当前参数恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Para_Default },
{ 2, "默认定值", "当前定值区恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Set_Default },
{ 2, "软压板", "当前软压板恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Resume_Soft },
{ 2, "元件配置", "元件配置恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Cfg_CellDef },
{ 1, "交流显示", "交流显示方式设置", 0, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Cfg_ShowAnaType },
{ 1, "装置调试", "调试装置", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc },
{ 2, "虚拟遥信", "设置虚拟遥信值", 0, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_XuYX },
{ 2, "交流虚遥测", "设置虚拟交流遥测值", EN_MEA_AC, CN_COP_PWD, EN_ANA_0, (FUNCPTR)MenuProc_Dbg_XuYC },
{ 2, "直流虚遥测", "设置虚拟直流遥测值", EN_MEA_DC, CN_COP_PWD, EN_ANA_0, (FUNCPTR)MenuProc_Dbg_XuYC },
{ 2, "电度虚遥测", "设置虚拟电度遥测值", EN_MEA_POWER, CN_COP_PWD, EN_ANA_0, (FUNCPTR)MenuProc_Dbg_XuYC },
{ 2, "动作虚事件", "设置虚拟动作事件", EN_ACT_REC, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_XuEvent },
{ 2, "告警虚事件", "设置虚拟告警事件", EN_ALM_REC, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_XuEvent },
{ 2, "动作出口", "进入此菜单保护退出", EN_OUTPUT_TRIP, CN_COP_PWD, EN_INPUT_0, (FUNCPTR)MenuProc_Dbg_Relay },
{ 2, "信号出口", "进入此菜单保护退出", EN_OUTPUT_SIGN, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_Relay },
{ 1, "版本信息", "查看板件版本信息", 0, CN_COP_PWD, 0, (FUNCPTR)MenuProc_See_VersionBoard },
};
7.3.2 解析后的树形结构
根据上表,Menu_Main_Creat_01 解析后的菜单树为:
0 级目录 {
1.装置信息
1.实时数据 {
2.交流量
2.直流量
2.遥信量
}
1.参数定值 {
2.整定 {
3.参数
3.定值
3.控制字
3.软压板
}
2.查看 {
3.参数
3.定值
3.控制字
3.软压板
}
}
1.三遥设置 {
2.遥测死区
2.遥测系数
2.遥信类型
2.遥信防抖
2.双点遥信
}
1.装置维护 {
2.时钟设置
2.强制复归
2.手动录波
2.清除记录
2.通讯参数
2.通讯设置
2.网口设置
2.SNTP设置
}
1.记录查询 {
2.SOE记录
2.事故记录
2.操作记录
2.保护告警
2.保护启动
2.遥控记录
2.自检记录
2.运行记录
2.运行报告
}
}
0.厂家设置 {
1.元件配置
1.恢复默认 {
2.全部恢复
2.默认参数
2.默认定值
2.软压板
2.元件配置
}
1.交流显示
1.装置调试 {
2.虚拟遥信
2.交流虚遥测
2.直流虚遥测
2.电度虚遥测
2.动作虚事件
2.告警虚事件
2.动作出口
2.信号出口
}
1.版本信息
}
7.3.3 单例解析示例:直流量
以 {2,"直流量","查看遥测直流量",EN_MEA_DC,0x0000,EN_ANA_0,(FUNCPTR)MenuProc_See_YC} 为例,构建后该菜单项的指针与属性为:
{
ptHigher = 1.实时数据;
ptLower = NULL;
ptBefore = 2.交流量;
ptBehind = 2.遥信量;
byClass = 2;
byName = 直流量;
byTip = 查看遥测直流量;
byAttrib = EN_MEA_DC;
wPassword = 0x0000;
wPara = 0;
pfnWinProc = MenuProc_See_YC;
}
7.3.4 同级首尾成环
同级菜单中,首尾通过 ptBefore/ptBehind 相连形成环。例如 2 级子菜单:
2.虚拟遥信
2.交流虚遥测
2.直流虚遥测
2.电度虚遥测
2.动作虚事件
2.告警虚事件
2.动作出口
2.信号出口
其中:
2.虚拟遥信的ptBefore指向2.信号出口(首的前一个是尾)2.信号出口的ptBehind指向2.虚拟遥信(尾的后一个是首)
7.4 构建流程(Menu_Main_Creat_01)
按 g_tMenuModelTab 的定义顺序逐项处理,通过比较 byCurClass 与 byNextClass 设置每个菜单的层级指针。
7.4.1 情况 1:byCurClass < byNextClass(下一项更深,进入子菜单)
ptCurrent ptNextNode
(当前) (下一项,更深一级)
│ │
│ ptLower ──────────────────►│
│◄───────────────── ptHigher │
│ │
该级尾不变 新一级的 首=尾=ptNextNode
7.4.2 情况 2:byCurClass == byNextClass(同级,并列)
ptCurrent ─── ptBehind ──► ptNextNode
│ │
│ ptBefore ◄───────────┤
│ │
└──── ptHigher (同) ────────┘
该级 尾 更新为 ptNextNode
7.4.3 情况 3:byCurClass > byNextClass(下一项更浅,回到上层)
... byCurClass 级 ... byNextClass 级 ...
ptLast[byNextClass] 已存在
│
│ ptBehind ──► ptNextNode(新)
│ │
│◄── ptBefore ────┤
│ ptHigher = 该级尾的 ptHigher
同时:从 byCurClass 到 byNextClass+1 各级首尾成环
ptLast[级]──►ptFirst[级],ptFirst[级]──►ptLast[级]
7.4.4 最后:各级首尾成环
表遍历完后,从 0 级到当前结点所在级,把该级首尾连成环:
ptFirst[级] ◄──────────────► ptLast[级]
│ │
└──── ptBehind ───────────────┘
◄──────── ptBefore ────────────┘
7.5 menu.c 运行时整体逻辑
menu.c 在运行时主要做三件事:
- 构建菜单树:
Menu_Main_Creat/Menu_0LevelNumCal - 根据按键路由当前菜单:
Menu_Route - 计算坐标并显示:
Menu_Show_Proc+ 一系列Menu_*PosCal函数
可以把主循环理解为下面这个“数据流”:
键盘按键 (key.c) ──► 菜单路由 (Menu_Route)
│
▼
当前菜单指针 g_tMenuCtrl.ptCurrent
│
▼
坐标计算 (Menu_Sub1PosCal / Menu_PosCal_0Level ...)
│
▼
显示绘制 (Menu_Show_Proc + display.c/lcd.c)
7.5.1 主循环 & 菜单刷新时序
在 main.c 的主循环中,大致时序可抽象成:
while (1) {
// 1. 采集按键状态(key.c)
Key_Scan();
// 2. 依据当前按键更新菜单当前位置(menu.c)
Menu_Route();
// 3. 根据当前位置和菜单树,计算各级菜单的坐标
Menu_Show_Proc();
// 4. 刷新 LCD 显示(lcd.c / display.c)
LCD_Refresh();
}
你可以把 Menu_Route 看成“控制层”,把 Menu_Show_Proc 看成“视图层布局计算”。
7.5.2 按键如何驱动菜单移动(Menu_Route)
Menu_Route 的核心思想是:
根据按键,沿着当前层的循环链表移动 ptCurrent,或在父/子指针之间跳转。
简化后的逻辑可以画成下面这样的状态机图:
┌─────────────┐
│ 当前菜单项 │ (g_tMenuCtrl.ptCurrent)
└─────┬───────┘
▲ 上键/W │ 下键/S ▼
│ │
│ │
ptBefore ptBehind
(同级上一个) (同级下一个)
左键/A 或 退出键/Esc 右键/D 或 Enter
│ │
▼ ▼
ptHigher(父菜单) ptLower(子菜单)
更具体地:
- 上键 /
W:ptCurrent = ptCurrent->ptBefore;- 由于同级首尾成环,从首再往上就会回到尾
- 下键 /
S:ptCurrent = ptCurrent->ptBehind;- 从尾再往下会回到首,实现循环菜单
- 右键 /
D或Enter:- 若当前有子菜单:
ptCurrent = ptCurrent->ptLower; - 同时更新
g_tMenuCtrl.ptRoute[层级],记录层级路径
- 若当前有子菜单:
- 左键 /
A或Esc:- 若存在上级:
ptCurrent = ptCurrent->ptHigher; - 层级回退,路径栈
ptRoute[]随之更新
- 若存在上级:
伪代码示意(省略防抖、长按等细节):
void Menu_Route(void)
{
tagPMenuItem ptCur = g_tMenuCtrl.ptCurrent;
if (KEY_UP_PRESSED()) {
ptCur = ptCur->ptBefore;
} else if (KEY_DOWN_PRESSED()) {
ptCur = ptCur->ptBehind;
} else if (KEY_RIGHT_PRESSED() || KEY_ENTER_PRESSED()) {
if (ptCur->ptLower != NULL) {
ptCur = ptCur->ptLower;
}
} else if (KEY_LEFT_PRESSED() || KEY_ESC_PRESSED()) {
if (ptCur->ptHigher != NULL) {
ptCur = ptCur->ptHigher;
}
}
g_tMenuCtrl.ptCurrent = ptCur;
}
实际代码里还会配合 ptCurBak 判断“是否移动了”,以决定是否需要重算坐标和刷新显示。
7.5.3 坐标字段含义(wSPosX/Y、wEPosX/Y)
tagMenuItem 中与坐标相关的字段:
wSPosX, wSPosY:本菜单的下一级子菜单框的左上角坐标wEPosX, wEPosY:本菜单的下一级子菜单框的右下角坐标
可以类比成:
┌──────────────── LCD 屏幕 ────────────────┐
│ │
│ ┌───── 本级菜单框 (父) ─────┐ │
│ │ │ │
│ │ ● 当前高亮菜单项 │ │
│ │ │ │
│ └───────────────────────────┘ │
│ ▲ wSPosY │
│ │ │
│ ▼ wEPosY │
│ ┌───── 子菜单框 (ptLower) ─────┐ │
│ │ ← wSPosX → wEPosX │ │
│ │ 子菜单项1 │ │
│ │ 子菜单项2 │ │
│ │ ... │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────────┘
这样设计的好处是:
每个菜单项都“携带”了自己的子菜单应该出现在哪一块区域的信息,绘制时只需要看当前指针和这些坐标就能把所有框画出来。
7.5.4 菜单位置如何计算(以 Menu_Sub1PosCal 为例)
用于计算二级菜单(以及更深层子菜单)矩形框位置的典型函数是 Menu_Sub1PosCal,高度概括为:
- 统计当前菜单的同级菜单数量和下一级菜单的最大标题长度
- 根据父菜单的框位置 + 当前菜单在本层中的序号,确定 Y 起点
- 根据子菜单项数和行高
LINE_HEIGHT,确定 Y 终点,并做“是否超屏”判断 - 若超出底部,则整体向上平移一段,避免越界
简化后的“计算流程图”:
输入:
- bylevel 当前层级(例如 1 表示一级菜单)
- ptFirst[bylevel] 本级菜单首节点
- ptIndex[bylevel] 遍历用当前指针
步骤:
1) 统计下一级菜单:
byMaxLen = Menu_charLenCal(...)
→ 得到最长标题字符数 byMaxLen
→ 得到各层级菜单项数 byMenuNum[]
2) 计算 X 方向:
wSPosX = 父菜单 wEPosX
wEPosX = wSPosX + byMaxLen * MENU_WITDTH + MENU_XADD
3) 计算 Y 起点:
byMenuPos = 当前菜单在本级中的位置 (wPos)
byItemNum = 本级菜单总项数 (byMenuNum[bylevel])
如本级菜单超过一屏,修正 byMenuPos 做翻页映射
wSPosY = 父菜单 wSPosY + (byMenuPos - 1) * LINE_HEIGHT
4) 计算 Y 终点:
byItemNum = 子菜单项数 (byMenuNum[bylevel + 1])
wEPosY = wSPosY + byItemNum * LINE_HEIGHT + MENU_YADD
5) 越界修正:
如果 wEPosY > MENU_YMAX:
a) 尝试从当前项往上展开,使尾部对齐当前项
b) 若仍越界,则限制在 [0, MENU_YMAX] 范围内
6) 写回结果:
ptIndex[bylevel]->wSPosX/Y = wSPosX/Y
ptIndex[bylevel]->wEPosX/Y = wEPosX/Y
可以把它想象成:
“拿到父菜单的框,把子菜单框贴在它右侧,再根据当前高亮位置往上或往下展开一串子项,同时保证整块框不会跑出屏幕。”
7.5.5 多级菜单同时显示时的布局
当当前指针在三级菜单时,屏幕上通常会同时显示:
┌──────── 0 级菜单框 ────────┐
│ ... │
└───────────────────────────┘
│
▼
┌──────── 1 级菜单框 ────────┐
│ ... 当前所在的一级菜单 │
└───────────────────────────┘
│
▼
┌── 2 级菜单框 ──┐
│ 子项1 │
│ 子项2(高亮) │ ← g_tMenuCtrl.ptCurrent
│ 子项3 │
└──────────────┘
Menu_Show_Proc 会依次遍历 g_tMenuCtrl.ptRoute[] 中记录的每一级菜单指针:
- 对每一级调用对应的“位置计算函数”(0 级用
Menu_0LevelPosCal,1/2/3 级用Menu_Sub1PosCal等) - 根据计算出的矩形框坐标,在
display.c中画出边框和文字内容 - 对当前高亮项增加反显/反色效果
这样即可实现类似真实 HMI 上的“多级弹出菜单”效果。
9. UTF‑8 汉字库(12×12 点阵)
项目中通过 utf8_hz12_data.c/h 存储 12×12 点阵的汉字库,供 LCD 绘制函数使用。
- 当需要增加新的汉字时,可以:
- 修改或扩展
gen_utf8_hz12.py中的字符集/输入文本 - 运行脚本,重新生成
utf8_hz12_data.c/h - 重新编译项目
- 修改或扩展
- LCD 显示模块会在绘制字符串时,根据 UTF‑8 编码在该表中查找对应点阵。
具体使用方式可以参考:
src/Drv/lcd/ascii.c:ASCII/字母数字字符绘制src/Drv/lcd/lcd.c:LCD 核心上下文与像素接口src/Drv/lcd/lcd_draw.c:图元绘制接口src/Drv/lcd/lcd_text.c:文本渲染接口src/Drv/lcd/text_codec.c:UTF-8 解码接口
10. 调试与日志
-
CMake 中提供了一个
ENABLE_DEBUG选项,用于开启调试输出:cmake -DENABLE_DEBUG=ON .. cmake --build . -
打开后会自动定义
DEBUG宏,部分代码(例如Menu_Sub1PosCal等)会通过printf输出调试信息,方便观察菜单坐标计算、页滚动等细节。
如果你希望在调试时看到更多状态信息,可以:
- 在关键流程(如
Menu_Route、Menu_Show_Proc、remoteDisplay收发函数)增加#ifdef DEBUG包裹的日志 - 使用 GDB / VS 调试器在
main.c/menu.c等处打断点,单步查看菜单树和坐标计算过程
11. 单元测试与门禁
11.1 运行单元测试
# 1. 配置:生成构建文件(如 Makefile)到 build 目录
cmake -S . -B build
# 2. 编译:真正编译代码
cmake --build build
# 3. 进行测试
# ctest 用 CTest 测试运行器(CMake 自带)
# --test-dir build 指定测试目录是 build。
# -C Debug 指定运行 Debug 配置下编译出的测试程序。
# --output-on-failure 只有测试失败时,才输出该测试的 stdout/stderr 详细信息
ctest --test-dir build -C Debug --output-on-failure
当前已落地测试集合:
test_smoke:测试框架烟雾验证test_p0_remote_display:calc_crc、parse_frame协议解析核心test_p0_utf8_next:UTF-8 解码边界场景test_p0_utf8_hz12_get:汉字字库二分查找命中/未命中test_p1_key:按键状态消费语义test_p1_lcd_basic:像素读写、填充、反显test_p1_menu:菜单长度计算、层级初始化test_p2_tcp_loopback:TCP 本机回环收发(集成)
11.2 质量门禁(建议)
- 提交前最低门禁:
ctest --test-dir build -C Debug --output-on-failure必须全绿。 - 新增或修改纯逻辑函数时,必须同时补充对应单元测试。
- 修复缺陷时,必须附带回归测试用例,避免问题再次出现。
11.3 覆盖率目标(阶段性)
- P0 核心模块(协议解析/字符解析)行覆盖率建议 >= 70%。
- P0 核心模块分支覆盖率建议 >= 60%。
- P1/P2 模块可按迭代逐步提升,不要求一次到位。