从零构建STM32驱动的ST7567G液晶显示系统:自定义UI实战指南
在嵌入式开发中,液晶显示屏的人机交互界面设计往往是项目成败的关键一环。ST7567G驱动的128x64点阵屏以其高性价比和SPI接口的简洁性,成为许多STM32开发者的首选。本文将带你从SPI外设配置开始,逐步实现一个完整的显示系统,包括汉字显示、图标绘制和动态数据刷新。
1. 硬件准备与SPI基础配置
ST7567G液晶屏与STM32的硬件连接通常只需要4根信号线(CS、RST、A0和SCLK/MOSI)。我们先从SPI外设的初始化开始:
// SPI1初始化配置(以STM32F103为例) void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; // 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // 配置SCK和MOSI引脚 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // SPI参数配置 SPI_InitStructure.SPI_Direction = SPI_Direction_1Line_Tx; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }注意:ST7567G的SPI时钟频率建议不超过10MHz,过高的速率可能导致显示异常。如果使用硬件SPI遇到问题,可以尝试改用GPIO模拟SPI时序。
屏幕控制引脚的定义和初始化同样重要:
// 控制引脚定义 #define LCD_CS_PIN GPIO_Pin_4 #define LCD_CS_PORT GPIOA #define LCD_RST_PIN GPIO_Pin_3 #define LCD_RST_PORT GPIOA #define LCD_A0_PIN GPIO_Pin_2 #define LCD_A0_PORT GPIOA void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 配置CS、RST、A0为推挽输出 GPIO_InitStructure.GPIO_Pin = LCD_CS_PIN | LCD_RST_PIN | LCD_A0_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 初始状态设置 GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN); GPIO_SetBits(LCD_A0_PORT, LCD_A0_PIN); GPIO_ResetBits(LCD_RST_PORT, LCD_RST_PIN); Delay_ms(10); GPIO_SetBits(LCD_RST_PORT, LCD_RST_PIN); Delay_ms(10); }2. ST7567G驱动层实现
ST7567G的驱动核心在于正确理解其显存结构和指令集。这款LCD的显存被分为8页(Page0-Page7),每页对应8行像素,包含128列。这种结构意味着我们需要特别注意Y坐标的页寻址方式。
屏幕初始化序列是确保正常显示的第一步:
void ST7567_Init(void) { // 硬件复位 GPIO_ResetBits(LCD_RST_PORT, LCD_RST_PIN); Delay_ms(100); GPIO_SetBits(LCD_RST_PORT, LCD_RST_PIN); Delay_ms(100); // 发送初始化命令序列 LCD_WriteCmd(0xE2); // 系统复位 LCD_WriteCmd(0xA2); // 偏置设置(1/9) LCD_WriteCmd(0xA0); // 段方向正常 LCD_WriteCmd(0xC8); // 行方向反向 LCD_WriteCmd(0x24); // 内部电阻比 LCD_WriteCmd(0x81); // 电子音量设置 LCD_WriteCmd(0x20); // 电子音量值 LCD_WriteCmd(0x40); // 显示起始行设为0 LCD_WriteCmd(0xA6); // 正常显示(非反显) LCD_WriteCmd(0xA4); // 正常显示(非全亮) LCD_WriteCmd(0xAF); // 开启显示 // 清空显存 ST7567_Clear(); ST7567_Update(); }基本的数据写入函数实现:
void LCD_WriteCmd(uint8_t cmd) { GPIO_ResetBits(LCD_CS_PORT, LCD_CS_PIN); GPIO_ResetBits(LCD_A0_PORT, LCD_A0_PIN); // A0=0表示命令 while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, cmd); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN); } void LCD_WriteData(uint8_t data) { GPIO_ResetBits(LCD_CS_PORT, LCD_CS_PIN); GPIO_SetBits(LCD_A0_PORT, LCD_A0_PIN); // A0=1表示数据 while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, data); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN); }显存管理是驱动开发中的关键环节。我们通常会在MCU内部建立一个与LCD物理显存对应的缓冲区:
uint8_t LCD_Buffer[8][128]; // 8页 x 128列 void ST7567_Clear(void) { memset(LCD_Buffer, 0x00, sizeof(LCD_Buffer)); } void ST7567_Update(void) { for(uint8_t page = 0; page < 8; page++) { LCD_WriteCmd(0xB0 | page); // 设置页地址 LCD_WriteCmd(0x10); // 列地址高4位设为0 LCD_WriteCmd(0x00); // 列地址低4位设为0 for(uint8_t col = 0; col < 128; col++) { LCD_WriteData(LCD_Buffer[page][col]); } } }3. 图形绘制与字体显示技术
在嵌入式系统中显示汉字和图形,通常需要先将图像数据转换为位图格式。PCtoLCD2002是一款常用的取模软件,它可以将汉字和图标转换为C语言数组。
16x16点阵汉字的取模示例:
// "中"字的16x16点阵数据 const uint8_t Chinese_16x16[] = { 0x00,0x00,0x23,0xF8,0x12,0x08,0x12,0x08, 0x83,0xF8,0x42,0x08,0x42,0x08,0x13,0xF8, 0x10,0x00,0x27,0xFC,0xE4,0xA4,0x24,0xA4, 0x24,0xA4,0x24,0xA4,0x2F,0xFE,0x00,0x00 };在屏幕上显示这个汉字的函数实现:
void Draw_Chinese(uint8_t x, uint8_t y, const uint8_t *font) { for(uint8_t page = 0; page < 2; page++) { // 16像素高度=2页 for(uint8_t col = 0; col < 16; col++) { // 16像素宽度 if(x + col < 128) { // 防止越界 LCD_Buffer[y + page][x + col] = font[page * 16 + col]; } } } }ASCII字符的显示原理类似,但通常使用8x16点阵:
void Draw_ASCII(uint8_t x, uint8_t y, const uint8_t *font) { for(uint8_t page = 0; page < 2; page++) { for(uint8_t col = 0; col < 8; col++) { if(x + col < 128) { LCD_Buffer[y + page][x + col] = font[page * 8 + col]; } } } }图形绘制的基础函数包括画点、画线和画矩形等:
void Draw_Pixel(uint8_t x, uint8_t y, uint8_t color) { if(x >= 128 || y >= 64) return; uint8_t page = y / 8; uint8_t bit = y % 8; if(color) { LCD_Buffer[page][x] |= (1 << bit); } else { LCD_Buffer[page][x] &= ~(1 << bit); } } void Draw_Line(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1, uint8_t color) { int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; while(1) { Draw_Pixel(x0, y0, color); if(x0 == x1 && y0 == y1) break; int e2 = 2 * err; if(e2 > -dy) { err -= dy; x0 += sx; } if(e2 < dx) { err += dx; y0 += sy; } } }4. 构建完整UI界面:温湿度监测实例
现在我们将前面开发的功能组合起来,构建一个完整的温湿度监测界面。这个界面将包含以下元素:
- 顶部标题栏
- 温湿度数据显示区
- 历史数据趋势图
- 底部状态栏
首先定义界面布局:
#define TITLE_HEIGHT 16 #define STATUS_HEIGHT 8 #define CHART_HEIGHT (64 - TITLE_HEIGHT - STATUS_HEIGHT - 8) #define CHART_ORIGIN_Y (TITLE_HEIGHT + 4)界面刷新函数实现:
void Refresh_UI(float temp, float humi) { ST7567_Clear(); // 绘制标题栏 Draw_Rect(0, 0, 127, TITLE_HEIGHT-1, 1); Draw_String_Center(0, "环境监测系统", 1); // 绘制温湿度数据 char str[16]; sprintf(str, "温度: %.1fC", temp); Draw_String(5, TITLE_HEIGHT + 5, str, 1); sprintf(str, "湿度: %.1f%%", humi); Draw_String(5, TITLE_HEIGHT + 20, str, 1); // 绘制趋势图边框 Draw_Rect(70, CHART_ORIGIN_Y, 126, CHART_ORIGIN_Y + CHART_HEIGHT, 1); // 更新趋势图数据 static float temp_history[30] = {0}; static uint8_t index = 0; temp_history[index] = temp; index = (index + 1) % 30; // 绘制趋势图 for(uint8_t i = 0; i < 29; i++) { uint8_t x1 = 70 + i * 2; uint8_t y1 = CHART_ORIGIN_Y + CHART_HEIGHT - (uint8_t)(temp_history[i] * 2); uint8_t x2 = 70 + (i + 1) * 2; uint8_t y2 = CHART_ORIGIN_Y + CHART_HEIGHT - (uint8_t)(temp_history[i + 1] * 2); Draw_Line(x1, y1, x2, y2, 1); } // 绘制状态栏 Draw_Line(0, 63 - STATUS_HEIGHT, 127, 63 - STATUS_HEIGHT, 1); Draw_String(5, 63 - STATUS_HEIGHT + 1, "更新:", 1); // 显示更新时间 RTC_TimeTypeDef RTC_Time; RTC_GetTime(RTC_Format_BIN, &RTC_Time); sprintf(str, "%02d:%02d", RTC_Time.RTC_Hours, RTC_Time.RTC_Minutes); Draw_String(40, 63 - STATUS_HEIGHT + 1, str, 1); ST7567_Update(); }提示:在实际项目中,建议将UI元素拆分为多个函数实现,并建立良好的数据结构来管理显示内容。这样当需要修改界面布局时,只需调整少量代码。
为了优化显示效果,我们可以添加一些视觉增强功能:
// 反色显示区域 void Invert_Area(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2) { for(uint8_t page = y1 / 8; page <= y2 / 8; page++) { for(uint8_t col = x1; col <= x2; col++) { LCD_Buffer[page][col] = ~LCD_Buffer[page][col]; } } } // 半透明效果(或运算) void Overlay_Area(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2) { for(uint8_t page = y1 / 8; page <= y2 / 8; page++) { for(uint8_t col = x1; col <= x2; col++) { LCD_Buffer[page][col] |= 0xAA; // 棋盘格图案 } } }5. 性能优化与高级技巧
当显示内容变得复杂时,直接刷新整个屏幕会导致明显的闪烁。以下是几种优化策略:
局部刷新技术:只更新发生变化的部分显示区域
void Partial_Update(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2) { uint8_t page_start = y1 / 8; uint8_t page_end = y2 / 8; for(uint8_t page = page_start; page <= page_end; page++) { LCD_WriteCmd(0xB0 | page); // 设置页地址 LCD_WriteCmd(0x10 | ((x1 & 0xF0) >> 4)); // 列地址高4位 LCD_WriteCmd(x1 & 0x0F); // 列地址低4位 for(uint8_t col = x1; col <= x2; col++) { LCD_WriteData(LCD_Buffer[page][col]); } } }双缓冲技术:在内存中维护两个缓冲区,一个用于绘制,一个用于显示
uint8_t LCD_Buffer1[8][128]; uint8_t LCD_Buffer2[8][128]; uint8_t *draw_buffer = LCD_Buffer1; uint8_t *display_buffer = LCD_Buffer2; void Swap_Buffers(void) { uint8_t *temp = draw_buffer; draw_buffer = display_buffer; display_buffer = temp; // 只将变化的部分复制到显示缓冲区 for(uint8_t page = 0; page < 8; page++) { if(memcmp(draw_buffer[page], display_buffer[page], 128) != 0) { memcpy(display_buffer[page], draw_buffer[page], 128); Partial_Update(0, page*8, 127, page*8+7); } } }显示动画:通过快速连续刷新实现平滑的视觉效果
void Scroll_Animation(uint8_t direction, uint8_t speed) { for(uint8_t i = 0; i < 64; i += speed) { LCD_WriteCmd(0x40 | (direction ? (63 - i) : i)); // 设置显示起始行 Delay_ms(20); } }字体优化方面,可以考虑使用压缩算法存储字库:
// 简单的RLE压缩解压示例 void Draw_Compressed_Font(uint8_t x, uint8_t y, const uint8_t *font, uint16_t size) { uint16_t pos = 0; uint8_t page = y / 8; uint8_t col = x; while(pos < size) { uint8_t count = font[pos++]; uint8_t value = font[pos++]; for(uint8_t i = 0; i < count; i++) { if(col >= 128) { col = x; page++; if(page >= 8) return; } LCD_Buffer[page][col++] = value; } } }6. 调试技巧与常见问题解决
ST7567G驱动开发中常见的问题及解决方案:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 屏幕全白或全黑 | 初始化序列不正确 | 检查复位时序和初始化命令 |
| 显示内容上下颠倒 | 扫描方向设置错误 | 调整0xC0/0xC8命令 |
| 显示内容左右颠倒 | 段方向设置错误 | 调整0xA0/0xA1命令 |
| 显示有随机噪点 | 电源不稳定 | 增加电源滤波电容 |
| SPI通信失败 | 时序不匹配 | 降低SPI时钟频率或改用软件SPI |
| 显示内容错位 | 显存管理错误 | 检查页和列的寻址逻辑 |
调试时可以使用的工具函数:
void Test_Pattern(void) { // 棋盘格测试图案 for(uint8_t page = 0; page < 8; page++) { for(uint8_t col = 0; col < 128; col++) { LCD_Buffer[page][col] = (col % 2 == page % 2) ? 0xAA : 0x55; } } ST7567_Update(); } void Check_Connection(void) { // 依次点亮每个控制引脚测试连接 GPIO_ResetBits(LCD_CS_PORT, LCD_CS_PIN); Delay_ms(500); GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN); GPIO_ResetBits(LCD_RST_PORT, LCD_RST_PIN); Delay_ms(500); GPIO_SetBits(LCD_RST_PORT, LCD_RST_PIN); GPIO_ResetBits(LCD_A0_PORT, LCD_A0_PIN); Delay_ms(500); GPIO_SetBits(LCD_A0_PORT, LCD_A0_PIN); }功耗优化建议:
- 在不需要显示时进入睡眠模式(发送0xAE命令)
- 降低刷新频率
- 使用局部刷新代替全局刷新
- 合理设计UI,减少动态元素
7. 扩展应用:多级菜单系统实现
对于更复杂的应用,可以设计一个菜单系统。下面是一个简单的实现框架:
typedef struct { const char *text; void (*action)(void); const MenuItem *submenu; } MenuItem; const MenuItem main_menu[] = { {"温度设置", NULL, temp_submenu}, {"时间设置", NULL, time_submenu}, {"系统信息", show_system_info, NULL}, {NULL, NULL, NULL} // 结束标记 }; void Draw_Menu(const MenuItem *menu, uint8_t selected) { ST7567_Clear(); uint8_t y = 0; for(uint8_t i = 0; menu[i].text != NULL; i++) { if(i == selected) { Draw_Rect(0, y, 127, y + 15, 1); Draw_String(5, y + 4, menu[i].text, 0); // 反色显示 } else { Draw_String(5, y + 4, menu[i].text, 1); } y += 16; } ST7567_Update(); } void Menu_Handler(void) { static uint8_t current_selection = 0; static const MenuItem *current_menu = main_menu; while(1) { Draw_Menu(current_menu, current_selection); // 等待按键输入(示例) uint8_t key = Get_Key(); if(key == KEY_UP) { if(current_selection > 0) current_selection--; } else if(key == KEY_DOWN) { if(current_menu[current_selection + 1].text != NULL) current_selection++; } else if(key == KEY_ENTER) { if(current_menu[current_selection].action != NULL) { current_menu[current_selection].action(); } else if(current_menu[current_selection].submenu != NULL) { current_menu = current_menu[current_selection].submenu; current_selection = 0; } } else if(key == KEY_BACK) { if(current_menu != main_menu) { current_menu = main_menu; current_selection = 0; } } } }对于资源受限的系统,可以使用更紧凑的菜单实现方式:
typedef struct { uint8_t menu_id; uint8_t parent_id; uint8_t text_id; uint8_t action_id; } CompactMenuItem; const CompactMenuItem compact_menu[] = { {0, 255, 0, 0}, // 主菜单 {1, 0, 1, 0}, // 温度设置 {2, 0, 2, 0}, // 时间设置 {3, 0, 3, 1}, // 系统信息 {4, 1, 4, 0}, // 温度上限 {5, 1, 5, 0}, // 温度下限 {6, 2, 6, 0}, // 设置时间 {7, 2, 7, 0} // 设置日期 }; const char *menu_texts[] = { "主菜单", "温度设置", "时间设置", "系统信息", "温度上限", "温度下限", "设置时间", "设置日期" };在实际项目中,我发现菜单系统的响应速度很大程度上取决于显示刷新效率。通过预渲染菜单项和只更新变化部分,可以显著提升用户体验。