第一次拿到ST7567驱动的12864串口屏时,我就在想:这玩意儿和STM32搭配起来能玩出什么花样?事实证明,这对组合在低功耗嵌入式设备中简直是绝配。ST7567作为一款常见的LCD驱动芯片,配合128×64分辨率的屏幕,特别适合用在需要简单图形界面的场合,比如工业仪表、便携式设备等。
这块屏幕最大的特点就是省电。实测下来,整机工作电流可以控制在5mA以内,这对于电池供电的设备来说太重要了。记得去年做的一个环境监测项目,用这套方案续航直接提升了30%。屏幕的串行接口也特别友好,只需要5根线就能搞定通信,大大节省了宝贵的IO资源。
硬件连接方面,ST7567支持SPI和6800并行两种接口模式,但实际项目中我更推荐用SPI。不仅接线简单,STM32的硬件SPI还能帮我们减轻CPU负担。有个小技巧:如果项目对刷新速度要求不高,可以用软件模拟SPI,这样连硬件SPI引脚冲突的问题都避免了。
接线这件事看起来简单,但实际踩过不少坑。根据我的经验,ST7567的这几个引脚需要特别注意:
推荐这样连接STM32:
c复制#define LCD_CS_PIN GPIO_Pin_5
#define LCD_CS_PORT GPIOC
#define LCD_RES_PIN GPIO_Pin_0
#define LCD_RES_PORT GPIOB
#define LCD_A0_PIN GPIO_Pin_1
#define LCD_A0_PORT GPIOB
#define LCD_SCLK_PIN GPIO_Pin_2
#define LCD_SCLK_PORT GPIOB
#define LCD_SDA_PIN GPIO_Pin_7
#define LCD_SDA_PORT GPIOC
写驱动代码时,我习惯先封装几个最基本的函数:
c复制void LCD_WriteCommand(uint8_t cmd) {
GPIO_ResetBits(LCD_A0_PORT, LCD_A0_PIN); // 命令模式
GPIO_ResetBits(LCD_CS_PORT, LCD_CS_PIN);
SPI_SendData(cmd);
GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN);
}
void LCD_WriteData(uint8_t data) {
GPIO_SetBits(LCD_A0_PORT, LCD_A0_PIN); // 数据模式
GPIO_ResetBits(LCD_CS_PORT, LCD_CS_PIN);
SPI_SendData(data);
GPIO_SetBits(LCD_CS_PORT, LCD_CS_PIN);
}
初始化流程有个小技巧:严格按照芯片手册的时序来。我曾经因为复位时间不够导致屏幕显示异常,折腾了好久才发现问题。完整的初始化代码应该包含这些步骤:
在资源有限的STM32上,直接操作屏幕会很慢。我的解决方案是使用显存缓冲。虽然会占用1KB的RAM(128x64/8),但换来的是流畅的显示效果。显存可以这样定义:
c复制uint8_t frameBuffer[8][128]; // 8页 x 128列
有了显存后,所有绘图操作都先在内存中完成,最后一次性刷新到屏幕。这招在需要频繁更新的界面特别管用。比如这样实现整屏刷新:
c复制void LCD_Refresh() {
for(uint8_t page=0; page<8; page++) {
LCD_WriteCommand(0xB0 | page); // 设置页地址
LCD_WriteCommand(0x10); // 列地址高4位
LCD_WriteCommand(0x00); // 列地址低4位
for(uint8_t col=0; col<128; col++) {
LCD_WriteData(frameBuffer[page][col]);
}
}
}
有了基础驱动,就可以构建更高级的图形API了。先从最简单的画点函数开始:
c复制void LCD_DrawPixel(uint8_t x, uint8_t y, bool on) {
if(x >= 128 || y >= 64) return;
uint8_t page = y / 8;
uint8_t bit = y % 8;
if(on) {
frameBuffer[page][x] |= (1 << bit);
} else {
frameBuffer[page][x] &= ~(1 << bit);
}
}
画线函数可以用Bresenham算法实现,这是我优化过的版本:
c复制void LCD_DrawLine(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1) {
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 ? dx : -dy) / 2;
while(1) {
LCD_DrawPixel(x0, y0, 1);
if(x0 == x1 && y0 == y1) break;
int e2 = err;
if(e2 > -dx) { err -= dy; x0 += sx; }
if(e2 < dy) { err += dx; y0 += sy; }
}
}
显示中文是很多项目的刚需。我常用的PCtoLCD2002这个工具,配置时要注意:
生成的字体数据可以这样组织:
c复制const uint8_t font16x16[][32] = {
{ // 汉字"中"
0x00,0x40,0x20,0xF8,0x07,0x40,0x20,0x18,
0x0F,0x08,0xC8,0x08,0x08,0x28,0x18,0x00,
0x00,0x00,0x00,0xFF,0x00,0x00,0x08,0x04,
0x43,0x80,0x7F,0x00,0x01,0x06,0x0C,0x00
},
// 其他汉字...
};
显示函数需要考虑汉字占用的特殊宽度:
c复制void LCD_DrawChinese(uint8_t x, uint8_t y, uint8_t index) {
uint8_t i,j;
for(i=0; i<2; i++) { // 16x16汉字分上下两半
LCD_SetPosition(x, y+i);
for(j=0; j<16; j++) {
LCD_WriteData(font16x16[index][i*16+j]);
}
}
}
在STM32F103这类资源有限的芯片上,界面流畅度需要特别关注。我总结了几条经验:
一个实用的数字刷新函数可以这样写:
c复制void LCD_UpdateNumber(uint8_t x, uint8_t y, uint8_t old, uint8_t new) {
if(old == new) return;
// 擦除旧数字
LCD_DrawChar(x, y, old, 0);
// 绘制新数字
LCD_DrawChar(x, y, new, 1);
// 局部刷新
LCD_PartialRefresh(x, y, 8, 16);
}
很多嵌入式设备都需要简单的菜单交互。我设计了一个基于状态机的轻量级菜单系统:
c复制typedef struct {
const char* title;
void (*action)(void);
MenuItem* children;
uint8_t childCount;
} MenuItem;
MenuItem mainMenu[] = {
{"系统设置", NULL, settingsMenu, 3},
{"参数调整", adjustParams, NULL, 0},
{"数据查看", showData, NULL, 0}
};
void Menu_Show(MenuItem* menu, uint8_t count, uint8_t selected) {
LCD_Clear();
for(uint8_t i=0; i<count; i++) {
if(i == selected) {
LCD_DrawString(10, i*16, menu[i].title, INVERT);
} else {
LCD_DrawString(10, i*16, menu[i].title, NORMAL);
}
}
LCD_Refresh();
}
适当的动画能让界面更生动。比如实现一个进度条动画:
c复制void LCD_ShowProgressBar(uint8_t x, uint8_t y, uint8_t width, uint8_t progress) {
// 绘制边框
LCD_DrawRect(x, y, width, 8);
// 计算填充长度
uint8_t fill = (width-2) * progress / 100;
// 绘制填充
for(uint8_t i=0; i<fill; i++) {
LCD_DrawLine(x+1+i, y+1, x+1+i, y+6);
}
// 局部刷新
LCD_PartialRefresh(x, y, width, 8);
}
默认的SPI速度可能不够快,可以尝试提高时钟频率:
c复制void SPI_InitFastMode(void) {
SPI_InitTypeDef SPI_InitStructure;
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; // 9MHz @72MHz
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;
SPI_Init(SPI1, &SPI_InitStructure);
SPI_Cmd(SPI1, ENABLE);
}
如果发现SPI通信不稳定,可以尝试:
刷新时的屏幕闪烁是个常见问题。我通常这样解决:
一个实用的垂直同步技巧:
c复制void LCD_WaitVSync(void) {
while(LCD_ReadStatus() & 0x80); // 等待非显示期
}
最近完成的一个实际项目中,我用这套方案实现了完整的监测界面。核心功能包括:
主界面刷新函数大概长这样:
c复制void RefreshMainUI(void) {
static uint32_t lastUpdate = 0;
if(HAL_GetTick() - lastUpdate < 500) return;
lastUpdate = HAL_GetTick();
// 更新实时数据
LCD_DrawNumber(30, 10, sensorData.temp, 2);
LCD_DrawNumber(30, 30, sensorData.humi, 2);
// 绘制简易曲线
static uint8_t history[128];
for(int i=0; i<127; i++) {
history[i] = history[i+1];
}
history[127] = sensorData.pm25;
for(int i=0; i<127; i++) {
LCD_DrawLine(i, 50-history[i]/2, i+1, 50-history[i+1]/2);
}
// 局部刷新
LCD_PartialRefresh(0, 0, 128, 64);
}
这个项目最终在STM32F103C8T6上跑得很流畅,整个显示驱动只占用了不到3KB的Flash和1.5KB的RAM。