
简介本资源是一套基于51单片机与1.44寸TFT LCD屏实现的嵌入式菜单栏系统面向嵌入式初学者及单片机开发工程师解决小型人机交互界面开发中菜单结构设计、图形驱动适配与跨平台移植等核心问题。压缩包共31个文件含5个C源文件如menu.c、key.c、TFT.c、tft_driver.c、main.c、5个头文件TFT.h、menu.h、key.h等、6个编译生成的OBJ目标文件、6个LST列表文件及工程配置文件uvproj、uvopt等整体仅89KB轻量紧凑便于学习与裁剪。已有754人下载学习适合在Keil C51环境下快速构建可运行的GUI菜单原型。读者可直接获取完整可编译工程掌握TFT底层驱动封装、树状菜单状态机设计、按键消抖与导航逻辑实现并通过模块化代码结构分离显示、输入、菜单、主控理解如何将该方案高效移植至STM32等平台——所有关键接口均已抽象移植时仅需重写GPIO与延时函数即可复用主体逻辑。 做单片机人机界面多了你会发现一个规律界面越复杂代码越容易糊成一锅粥。去年我帮朋友做一台小设备主控是51屏幕是一块2.4寸TFT需求就是上电显示主菜单能往下翻两级能进参数设置能返回。听起来不复杂但真正动手写就发现一级菜单、二级菜单、焦点切换、按键消抖、页面重绘全堆在一起写到最后自己都看不懂逻辑。后来我花了一个周末把整个菜单框架推倒重写把菜单部分做成了和设备无关的模块之后换到STM32上跑的时候只替换了底层SPI驱动和延时函数两天就调通。所以这套基于51和TFT屏的菜单栏设计我特别想分享出来尤其是可移植这件事比功能本身更值得研究。这篇东西适合谁看如果你正准备给51、STM32这类单片机加一块TFT屏要做多级中文菜单或者已经在做但被界面逻辑折腾得焦头烂额那这篇文章就是给你写的。我从屏幕选型讲到代码结构从菜单数据结构讲到状态机最后再详细记录一次从51移植到STM32的完整过程。你不需要一开始就懂状态机、分层架构这些术语跟着读下来自然就明白了。1. 菜单栏设计思路与整体方案选型做菜单栏之前先别急着写代码。菜单栏的本质是人机交互入口它的任务是用有限的按键把参数查看、参数修改、状态切换这些功能组织起来。单片机资源有限不像手机能随便new一个页面所以设计第一原则就是够用就好。在这个前提下我建议先想清楚三件事用什么屏幕、用什么接口、代码怎么分层。1.1 为什么自己写菜单而不是用串口屏市面上串口屏确实很香拖拖拽拽就能出界面开发周期短。但实际用了两次之后我基本放弃了在低成本项目里用串口屏的念头。主要原因有三块一是成本一块像样的串口屏价格是裸屏的两三倍量产时这个差异很扎眼二是交互逻辑依然要MCU通过串口指令去维护菜单一多指令协议本身就变成一个需要维护的复杂模块三是串口屏的刷新机制和控件回调是封闭的想做一些特殊交互时反而束手束脚。裸屏加自己写菜单优势在于底层完全可控。屏幕要显示什么、什么时机刷新、焦点怎么移动每条路径都能自己掌控。缺点也很明显要处理底层驱动还要设计一套菜单数据结构。但这件事一旦沉淀下来后续新项目基本是复制粘贴加微调长期收益很高。另外从学习角度讲自己从驱动写到菜单能把SPI通信、字模提取、状态机、内存布局全部串起来这些底层知识比调试串口屏学到的扎实得多。1.2 TFT屏选型驱动IC、分辨率与接口TFT裸屏选型有个很容易踩的坑很多人只看尺寸和分辨率忽略屏幕主控IC。驱动能不能跑起来、资料多不多全靠主控IC决定。常见的小尺寸屏主控有ST7735、ST7789、ILI9341、ILI9488这几类。其中ILI9341最经典网上资料最多我这次用的就是一块2.4寸240x320、主控ILI9341的屏幕SPI接口整个驱动代码不到300行。接口方面我强烈建议选SPI接口。它省引脚51这种引脚资源紧张的单片机也能带得动。并口屏虽然刷屏速度快但8位并口占十几个IO移植起来也麻烦除非你要做高速刷图否则不推荐。值得一提的是如果你用的是HC32L072这类自带硬件SPI的单片机底层驱动还可以写得比51模拟SPI更简洁性能也更高。为了让你在选型时有个直观参考我把平时接触到的几款主流主控整理成了一张表。实际选购时不要光看店家页面写的分辨率要问清楚主控型号因为同一尺寸的屏幕可能用不同主控驱动代码不通用主控IC常见分辨率接口特点ST7735128x128SPI小尺寸屏常用便宜ST7789240x320SPI色彩鲜艳性价比高ILI9341240x320SPI / 8位并口最经典教程最多ILI9488320x480SPI / 16位并口大屏刷新相对慢1.3 可移植性的起点分层架构我第一次写菜单时没做分层所有代码直接操作寄存器菜单函数里还写死了引脚结果换一块屏幕就得重写一遍非常痛苦。后来我把整个工程分成了三层底层驱动BSP、图形接口GUI、菜单框架Menu。菜单框架只依赖图形接口提供的画矩形、字符串、填充等函数完全不关心屏幕型号和主控型号。这样移植的时候只需要改底层驱动那一层菜单框架一行都不用动。分层这件事听起来像软件工程的空话但在单片机上同样适用。实际做下来的目录结构是这样project/ ├── drv/ // 底层硬件驱动 │ ├── lcd_driver.c │ ├── lcd_driver.h │ └── spi_driver.c ├── gui/ // 图形接口 │ ├── gui_draw.c │ └── gui_font.c ├── menu/ // 菜单状态机与渲染 │ ├── menu_core.c │ └── menu_items.c └── app/ └── main.c可能有人觉得这是过度设计但实际写一遍就知道分层带来的好处是渐进显现的。比如在51上调好的界面移植到STM32时只需要重新实现drv里的SPI接口连GUI层几乎都不动。这一层薄薄的抽象就是整个项目可移植的底气。2. 51平台上的底层驱动与画布接口实现到了具体写代码的阶段。我的主控是STC89C52RC12MHz晶振。STC89C52没有硬件SPI所以屏幕通信只能用GPIO模拟。虽然速度不算快但菜单这种低频刷新场景完全够用。这一节把驱动层面的几个关键点讲清楚后面移植到STM32时也需要对应修改这几块。2.1 引脚连接与初始化时序ILI9341这类屏幕的引脚不少但真正必须接的就那几个RST、DC、CS、SCL、SDA、BL、VCC、GND。我用的接线如下表LCD引脚51单片机引脚VCC5VGNDGNDSCLP1.0SDAP1.1CSP1.2DCP1.3RSTP1.4BLP1.5这里有个细节51的IO输出高电平能力有限如果直接把屏幕背光接在IO上亮度会不够画面能显示但整体发暗。我一开始就踩了这个坑后来加了一个SS8550三极管做背光开关才解决。如果是STM32平台3.3V供电的屏可以直接用推挽IO驱动背光但5V供电的屏还是要加三极管或MOS管。初始化时序这块所有SPI TFT都差不多上电后拉低RST至少10ms再拉高解除复位然后发送初始化寄存器指令。ILI9341的初始化序列一般几十条网上的现成工程都有重点是注意延时特别是退出Sleep和打开Display之间要留够时间。下面这段是移植到51上的核心初始化片段用的是模拟SPIvoid LCD_Init(void) { LCD_RESET_H(); delay_ms(10); LCD_RESET_L(); delay_ms(10); LCD_RESET_H(); delay_ms(120); LCD_WriteCmd(0x01); // Soft Reset delay_ms(120); LCD_WriteCmd(0x11); // Sleep Out delay_ms(120); LCD_WriteCmd(0x36); // Memory Access Control LCD_WriteData(0x48); // 根据屏幕接法调整方向 LCD_WriteCmd(0x3A); // Pixel Format LCD_WriteData(0x55); // 16bit color LCD_WriteCmd(0x29); // Display ON }初始化里最容易翻车的是0x36这个寄存器它控制显存的扫描方向。屏幕接法不同、面板不同这个值都可能不同。如果显示镜像或者上下颠倒改这个值就行不需要动其他代码。2.2 最小图形接口画点、矩形、字符串菜单渲染的根基说到底是画点。只要画点函数可靠矩形、字符、图标全都能垒起来。51上实现画点核心是设置窗口然后写入像素数据void LCD_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { if (x LCD_WIDTH || y LCD_HEIGHT) return; LCD_SetWindow(x, y, x, y); LCD_WriteData16(color); }有了画点再封装填充矩形和显示字符串。字符串显示分两种字模一种是16x16的汉字一种是8x16的ASCII字符。汉字一个字的字模占32字节ASCII字符一个占16字节。51的内部RAM只有256字节字模数组必须全部放到flash里用code关键字声明这一点特别关键。不然你定义几个菜单标题数组RAM就爆了程序跑起来各种诡异。这里忍不住多说一句很多新手喜欢把显示函数写得特别复杂其实菜单用到的图形接口就四个填充矩形、画字符串、画图标、画分割线。把这几个接口稳定下来菜单框架就可以不管屏幕细节了。2.3 汉字取模与资源管理汉字取模我用的是PCtoLCD2002选阴码、逐行式、顺向生成16x16点阵数据。取模方向必须和显示坐标对应好否则会出现汉字左右颠倒或者上下颠倒。取模的配置尽量固定一套不要今天用这个软件明天用那个软件否则后面排查乱码会非常痛苦。菜单里除了文字还需要一些简单图标比如右侧的三角箭头、选中的高亮背景条。这些其实不需要提前做图片直接用画矩形函数实时画就行。高亮选中项我用的是反色处理普通菜单项是白字黑底选中项画一个蓝色背景矩形再画白字看起来就很清晰。这样既省了flash空间又方便换主题颜色。3. 菜单状态机设计——从单级到多级这是整套代码的核心。菜单的本质是一个状态机当前显示哪个页面按下按键后跳到哪个页面。如果直接把页面ID和按键判断写在ifelse里五六个页面还能忍超过十个页面绝对崩溃。正确做法是用结构体数组描述菜单项按键操作统一走状态迁移。3.1 菜单项的数据结构设计菜单数据结构前我把需求列了一下支持多级菜单、支持返回父菜单、支持进入参数设置页。基于这些我把每个可操作元素定义成结构体typedef struct MenuItem { uint8_t id; // 菜单项ID用于跳转 uint8_t parent; // 父菜单ID返回时使用 const char* label; // 显示文字 void (*action)(void); // 进入该菜单项时执行的动作 } MenuItem; typedef struct MenuPage { uint8_t count; // 当前页的菜单项数量 uint8_t selected; // 当前选中的索引 const MenuItem* items; // 指向菜单项数组 } MenuPage;结构体里放函数指针RAM占用会多一点但换来的是到处可复用的灵活性非常值。菜单表本身放在flash里用const声明不占RAM。比如const MenuItem mainMenu[] { {0, 0xFF, 温度设置, action_temp_setting}, {1, 0xFF, 湿度设置, action_hum_setting}, {2, 0xFF, 关于设备, action_about}, };这里0xFF表示没有父菜单返回的时候忽略。实际工程里菜单项数组会稍微长一点但结构完全一样。3.2 按键扫描与导航逻辑按键扫描我建议放到10ms定时中断里做这样主循环不用阻塞等按键。虽然51教材里讲定时器计数器的原理讲得很多但工程里真正要做的是给系统一个稳定时基。我用定时器010ms中断一次在中断里做按键状态读取和消抖。消抖很朴素连续两次读到相同电平并间隔20ms才认为按键有效。菜单导航的基本操作是上移、下移、确认、返回。统一封装成一个函数void Menu_HandleKey(uint8_t key) { switch (key) { case KEY_UP: Menu_MovePrevious(); break; case KEY_DOWN: Menu_MoveNext(); break; case KEY_ENTER: Menu_Enter(); break; case KEY_BACK: Menu_Back(); break; } }有了这个接口按键扫描那边只需要把键值传进来菜单内部自己处理状态迁移。增删菜单项只需要在数组里增删一条记录逻辑代码完全不用动。这就是结构体数组方案比ifelse方案强的核心原因。3.3 渲染与局部刷新策略菜单刷新最偷懒的写法是每次按键都全屏重绘。实测下来240x320分辨率全屏填充一次在51上大约要40到80毫秒肉眼能明显感觉到闪烁和迟钝。所以必须做局部刷新。我的策略是只重绘当前页面中变化的部分。焦点上移或下移时只需要把旧选中项恢复成普通颜色再给新选中项画高亮背景两个矩形块刷新一下就行。文字内容只有在进入新页面时才整体重绘。代码逻辑大概是这样void DrawMenuItem(uint8_t index, uint8_t selected) { uint16_t y MENU_TOP index * ITEM_HEIGHT; if (selected) { LCD_FillRect(MENU_LEFT, y, MENU_RIGHT, y ITEM_HEIGHT, BG_SELECTED); } else { LCD_FillRect(MENU_LEFT, y, MENU_RIGHT, y ITEM_HEIGHT, BG_NORMAL); } GUI_PrintString(MENU_LEFT 8, y 4, menu_current-items[menu_current-selected index].label, selected ? COLOR_WHITE : COLOR_GRAY); }这样按一次方向键只刷新两个矩形框视觉上基本没有闪烁手感也顺滑很多。局部刷新听上去高大上本质就是少画没变化的东西。4. 移植到STM32的完整实操记录这个项目最终是要上到STM32的。我当时目标是STM32F103C8T6因为后续还要跑更多外设。整个移植过程说穿了是一次函数替换但里面有几个坑值得单独拿出来讲。4.1 移植前的准备引脚映射与SPI配置在STM32上我继续用SPI接口但用了硬件SPI。STM32F103的SPI1引脚是PA5(SCK)、PA6(MISO)、PA7(MOSI)屏幕只需要SCK和MOSI所以我选PA5和PA7CS用PA4DC用PA3RST用PA2背光用PA1。移植第一件事就是按新引脚分配重写spi_driver.c。这里有个纠结的点要不要保留模拟SPI我建议保留一份模拟SPI版本作为调试后备。万一硬件SPI配置有问题插上逻辑分析仪对比两种模式的时序能很快定位问题。我曾经在STM32上用模拟SPI做过对比速度比硬件SPI慢不少但作为排查手段非常有价值。正式用硬件SPI后能明显感觉到菜单切换更快尤其进入二级页面时几乎没有等待感。4.2 底层驱动替换标准库与HAL库的差异STM32的SPI驱动有两种常见写法标准库和HAL库。我习惯用标准库代码短直接操作寄存器调试时一眼能看明白。但很多新项目用CubeMX生成HAL代码迁移时只需要把drv层的SPI读写函数换掉上层图形接口和菜单接口完全不用动。这就是当初分层带来的直接好处。标准库下的SPI初始化和收发代码大概是这样的void SPI1_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); gpio.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); spi.SPI_Direction SPI_Direction_2Lines_FullDuplex; spi.SPI_Mode SPI_Mode_Master; spi.SPI_DataSize SPI_DataSize_8b; spi.SPI_CPOL SPI_CPOL_Low; spi.SPI_CPHA SPI_CPHA_1Edge; spi.SPI_NSS SPI_NSS_Soft; spi.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; spi.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, spi); SPI_Cmd(SPI1, ENABLE); } uint8_t SPI_ReadWriteByte(uint8_t dat) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, dat); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET); return SPI_I2S_ReceiveData(SPI1); }SPI参数里最关键的是CPOL和CPHA必须和ILI9341的要求一致。ILI9341支持Mode0和Mode3我统一用Mode0也就是CPOL为Low、CPHA为1Edge。如果屏幕一直花屏先把这两位的四种组合轮一遍多半能解决。4.3 移植后必须检查的三个差异点第一是延时函数。51的delay_ms是用定时器或循环掐出来的STM32换成SysTick之后原来的延时函数必须重写。很多人移植后白屏直接原因就是初始化序列里的延时不对屏幕还没准备好就被继续操作了。所有LCD驱动里的延时务必全部替换成STM32的毫秒延时。第二是内存模型。51的code段和data段是分开的常量直接扔code里STM32是统一编址常量和变量都有各自的布局。从51工程拷字模数组时要记得去掉code关键字改用const并确认编译器把const放到flash而不是RAM里。如果不小心把大片字模放到RAMSTM32F103C8T6只有20KB RAM跑几个菜单页面就爆了甚至直接HardFault。第三是中断优先级。51的定时器中断逻辑很简单但STM32如果开了多个中断按键定时器和SPI收发之间要分好优先级。我的做法是按键消抖定时器设为最高优先级SPI不用中断纯查询发送避免菜单界面刚跳转就被SPI中断打断导致状态异常。再提醒一个很多人会问的点Keil5怎么兼容51和32其实C51和MDK是两套独立的工具链需要在Keil中同时安装。新建工程时选择对应芯片型号不要尝试在一个工程里同时编51和STM32的代码。我平时就分开建两个工程文件可以互相复制但工程配置各用各的这样最省心。5. 常见问题与排查技巧实录整个项目做下来踩过的坑确实不少。把这些典型问题整理成速查表希望大家遇到的时候能少走弯路。5.1 白屏、花屏的排查流程白屏问题99%出在初始化失败或SPI通信异常。排查顺序我总结成了四个步骤先量电压屏幕VCC要符合规格背光引脚有没有电平再看波形用逻辑分析仪看SCL和SDA上有没有信号完全没有就是主控侧配置有问题接着查RST和DC时序复位时序不严格会导致屏幕不响应初始化命令最后查初始化序列看看是不是少了Sleep Out之后的延时。花屏和雪花点多半是SPI的时钟极性和相位不对。把CPOL和CPHA四种组合各试一遍基本能定位。如果还不行检查是不是接线过长导致信号畸变。杜邦线接触不良这个问题我遇到过好几次屏幕显示一闪一闪的换短线或者直接焊接之后就好了。5.2 菜单跳转异常与按键抖动处理菜单跳转异常有两类一类是按键响应不灵敏按一下跳两行另一类是返回逻辑不对回退到错误的页面。前者是消抖不够严格后者是状态机设计问题。消抖的常规做法前面已经说过10ms扫描加20ms确认基本能解决大部分抖动。如果还出现连跳多半是按键松开时产生了额外的边沿触发可以松手检测时也加16到20毫秒稳定期。返回逻辑出错最常见原因是父菜单ID写错。子菜单的parent字段如果指向一个不存在的页面返回时就乱了。另一个技巧是维护一个菜单路径栈进入子菜单时压栈返回时出栈。这样即使嵌套到三四级也不会乱。51资源紧张栈深度设置8就够。5.3 汉字乱码、花块与内存规划汉字乱码通常是取模方向或字节序不对。我在PCtoLCD2002里生成的字模在51上正常搬到STM32后发现乱码排查到最后是字模解析格式和代码里写的不一致。所以取模参数必须固定一套阴码、逐行、顺向GUI层统一按这套格式解析不要混用不同软件生成的字模。另一个和内存有关的是不要用全屏缓冲。240x320分辨率、16位色一个全屏缓冲要150KB51放不下STM32F103C8T6也放不下。小屏应该直接写显存配合局部刷新速度完全可以接受。如果确实需要临时缓冲区一行或者一个小矩形就够了。RAM是很宝贵的资源省着用。5.4 性能优化与稳定性心得菜单跑起来之后我做了几轮优化。第一把SPI发送改写为连续多字节发送。ILI9341写GRAM时支持连续写一次设置窗口然后连续发送整行或整块数据中途不用重复发命令这个优化对刷新速度提升非常明显。第二减少无关刷新。按键没有变化时不要给屏幕发任何数据让屏幕保持静态。很多人喜欢每秒强制刷新一次其实没必要反而搞出闪屏。第三合理存放字模。如果用了大字号、多语言等功能字模体积会膨胀很快尽量把const数据放到flash里SRAM只留给状态量和局部变量。移植到STM32后我还加了一个小技巧按键扫描放在定时器中断里显示刷新放在主循环两者通过事件标志位通信。这样页面重绘不会被按键扫描里的延时打断操作手感很顺滑。后来我又在这套框架上接了RTOS界面刷新任务和业务逻辑任务分开跑依然没有遇到障碍。可见只要分层清晰这套菜单框架的扩展空间比想象中大得多。到这里这套基于51和TFT屏的菜单栏设计就完整复盘了一遍。实际做下来我最深的体会是菜单栏本身的技术难度不大真正的门槛在代码组织方式。如果你一开始就按底层驱动、图形接口、菜单逻辑三层来分后面无论是从51换到STM32还是从裸机换到带RTOS的环境都能快速复用。最后再提一个小建议做这类界面时千万不要图省事把功能全部堆在main函数里哪怕只用最简单的结构体数组也会让整个项目清爽很多。本文还有配套的精品资源点击获取