ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103驱动LCD12864显示波形:从打点到滚动刷新的完整实现

STM32F103驱动LCD12864显示波形:从打点到滚动刷新的完整实现 简介STM32F103 驱动 LCD12864 的曲线绘制工程资源面向嵌入式开发者和单片机初学者解决屏幕显示曲线、波形及自定义图形时的驱动与数据转换问题。代码围绕 LCD12864 显示控制展开覆盖 SPI/I2C 通信配置、GPIO 初始化、显示模式设置、字模加载、坐标映射及实时打点画线等关键流程可直接复用于仪表显示、传感器数据可视化等场景。压缩包共 116 个文件以 h/c 源文件为工程主体包含 stm32f10x 系列标准外设驱动模块hex/axf 文件便于直接烧录验证o/crf 等编译产物和 uvproj/uvopt、bak 文件保留了完整的 Keil 项目配置与调试状态整体约 2.09MB。已有 1757 人学习下载。资源内附 LCD12864 底层驱动、曲线绘制算法及完整工程组织架构适合对照原理图学习 LCD 控制时序、字模提取和显示缓冲管理也能作为 STM32 显示类项目的移植模板。1. 用打点代替翻页LCD12864 曲线绘制到底动了哪里很多人拿到 LCD12864 第一反应是显示汉字和菜单觉得这就是个字符屏。实际上 ST7565、ST7920 这类控制器都支持图形模式128x64 像素足够画一条实时波形。STM32F103 驱动 LCD12864 绘制曲线的价值不在于“能显示”而在于你把它当成一个微型示波器面板采样、标定、打点、滚动、刷新每一步都在和时序与显存打交道。适合刚做完 GPIO 和串口、想往图形交互走的开发者也适合需要给传感器数据加一个低成本可视化通道的工程师。要理解这篇内容不需要会 GUI 框架只需要清楚三个东西像素坐标怎么映射、显存怎么读写、刷新策略怎么选。2. STM32F103 与 LCD12864 的接口选型GPIO 模拟和硬件 SPI 谁更合适2.1 先分清 ST7920 和 ST7565 再谈驱动LCD12864 其实是一个泛称市面上常见的控制器有两类。ST7920 内置中文字库支持 8 位并口和 SPI 串口模式字符模式下可以显示 4 行 x 8 列汉字图形模式下只能按 64x256 的显示 RAM 分两个半屏操作ST7565 是纯图形点阵控制器没有字库但显存映射更直接128x64 每一位对应一个像素用 SPI 或并口写入。STM32F103 驱动曲线绘制时推荐用 ST7565 或者兼容控制器如 ST7567、NT7534因为它不需要处理字库分区的概念直接把行地址和列地址映射到显存打点函数可以做到任意像素坐标。如果手里是 ST7920也不代表不能做曲线只是要注意 64x256 的显示 RAM 被分成左右两个 64x128 区域写 DDRAM 时需要切换 CS1 和 CS2并口模式下或者通过扩展指令集选择基本指令和绘图指令。实际项目中串口 ST7920 使用 4 线 SPI 模式绘图指令要发送0x34进入扩展模式画完再发0x30退回基本模式。这个切换动作非常容易出错很多人画不出曲线就是因为清屏之后没有正确退出扩展模式导致后续显示文本乱码。接口层面我一般优先选择 GPIO 模拟 SPI而不是硬件 SPI。原因有两个第一LCD12864 的 SPI 速率要求不高GPIO 翻转速度在几十纳秒级别完全够用模拟 SPI 方便调整相位和极性适应不同厂家模组的差异第二硬件 SPI 往往要复用芯片的 SPI1 或 SPI2 引脚如果项目里还要接 Flash、SD 卡或者传感器引脚冲突会迫使你改板子。模拟 SPI 只需要占用四个 GPIOSCK、MOSI、CS、DC如果需要另外还有一个复位引脚 RST一共五个。接线示意图如下SCK - PA5MOSI - PA7CS - PA4DC - PA3RST - PA2STM32F103 的 GPIO 配置为推挽输出速度选择 50MHz初始电平拉高。模拟 SPI 的核心是控制时钟沿和数据位写入顺序ST7920 支持 MODE 3CPOL1CPHA1也就是空闲时 SCK 为高电平数据在 SCK 下降沿被采样。模拟时序时只需要保证 MOSI 在 SCK 下降沿之前建立稳定电平即可。2.2 建立最小驱动框架先点亮一个像素动手写代码之前先定义一个底层发送函数。无论后续是画点、画线还是显示曲线所有命令和数据最终都会经过这个函数。在 Keil 或者 STM32CubeIDE 中新建工程配置 RCC 和 GPIO 后写一个LCD_SendByte函数它把每一位从高位到低位发送到 LCD内部包含一个LCD_Delay微秒级延时。void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7); } void LCD_SendByte(uint8_t byte) { for (uint8_t i 0; i 8; i) { if (byte 0x80) { LCD_MOSI_HIGH(); } else { LCD_MOSI_LOW(); } byte 1; LCD_SCK_LOW(); LCD_Delay(1); LCD_SCK_HIGH(); LCD_Delay(1); } }LCD_SendByte的逻辑很容易理解先判断当前最高位然后拉低 SCK在 SCK 低电平期间改变 MOSI 电平再拉高 SCK 完成一个时钟周期。这里的时钟极性和 ST7920 的 MODE 3 是对应的如果你的模组需要 MODE 0空闲低电平就把 SCK 的初始状态和翻转顺序对调。需要注意的是LCD_Delay的值不要盲目加大1 微秒足够过大的延时会导致整屏刷新时间明显变长曲线会出现可见的扫描拖影。有了字节发送函数就可以封装命令和数据写入。ST7920 的串口模式下命令包和数据包的格式不同命令包是0xF8 0x00 命令字数据包是0xFA 0x00 数据。每一个包由三个字节组成但真正有效的只有第三个字节前两个是握手标识和后续数据标志。下面是一个典型的控制序列示例void LCD_WriteCmd(uint8_t cmd) { LCD_CS_LOW(); LCD_SendByte(0xF8); // 前导表示命令 LCD_SendByte(0x00); // 后续数据标志命令为 0 LCD_SendByte(cmd); // 命令本身 LCD_CS_HIGH(); LCD_Delay(10); } void LCD_WriteData(uint8_t data) { LCD_CS_LOW(); LCD_SendByte(0xFA); // 前导表示数据 LCD_SendByte(0x00); // 后续数据标志数据为 0 LCD_SendByte(data); // 数据本身 LCD_CS_HIGH(); LCD_Delay(10); }命令包格式中的0xF8和0xFA是 ST7920 串口模式规定的识别码0xF8告诉控制器接下来是命令0xFA告诉控制器接下来是数据。第三个字节的0x00在图形模式下表示当前写入的 RAM 地址增量方向等信息通常固定为 0。完成这两个函数后初始化 LCD 需要依次发送复位、功能设置、显示开关、清屏等命令顺序是0x36扩展功能8 位接口、0x30基本功能、0x0C显示开光标关、0x01清屏。如果你的屏是并口方式初始化序列会不同但本文假设是 4 线 SPI 模式这也是小尺寸模组最常见的接法。初始化完成后打点函数就变得很直接。假设我们要在坐标(x, y)画一个点x范围 0~127y范围 0~63。ST7920 图形模式下的显存是按页Page划分的每页 8 像素高共 8 页每一页对应 32 个字节每个字节的 8 个 bit 从低到高分别表示页内第 0 行到第 7 行。打点前需要计算page y / 8bit y % 8然后读取当前页对应位置的字节把对应 bit 置 1 再写回。但 ST7920 没有读显存指令所以必须用系统的 shadow buffer 或直接按位计算写回。一个常见的做法是在 MCU 内存里维护 64 个元素的数组模拟整个屏幕的显存每次打点都更新数组然后同步到 LCD。uint8_t lcd_buffer[8][16]; // 8 页每页 16 字节覆盖 128x64 void LCD_SetPixel(uint8_t x, uint8_t y) { uint8_t page y / 8; uint8_t bit y % 8; if (x 127 || y 63) return; lcd_buffer[page][x / 8] | (0x01 bit); LCD_WriteCmd(0x80 | page); // 设置页地址 LCD_WriteCmd(0x80 | (x / 8)); // 设置列地址 LCD_WriteData(lcd_buffer[page][x / 8]); }这里的显存数组大小是8 x 16对应 8 页、每页 16 字节因为 LCD12864 的横向 128 像素被分成 16 个字节。LCD_SetPixel中先求出页和列再通过设置地址指令把数据写到 RAM 中。0x80 | page是页地址指令0x80 | (x/8)是列地址指令这种地址映射方式只适用于 ST7920 图形模式如果换成 ST7565地址指令变成0xB0 | page和0x10 | (col 4)的高四位、0x00 | (col 0x0F)的低四位。写错地址指令是 LCD 花屏的最高频原因建议第一版代码就把地址计算独立成函数方便后续适配不同控制器。2.3 坐标系映射与显存更新策略LCD12864 的坐标映射不是随手画的。横轴 128 像素对应时间轴纵轴 64 像素对应数据范围这一步需要做线性变换。假设传感器输出数值范围是 0~409512 位 ADC要映射到 0~63 的纵向像素范围最简单的公式是y (value * 63) / 4095。但实际中往往希望预留上下边距并且支持负值或浮点更好的方式是使用标定函数uint8_t ValueToPixel(uint16_t value, uint16_t min_val, uint16_t max_val) { if (value min_val) value min_val; if (value max_val) value max_val; return (uint8_t)((uint16_t)(value - min_val) * 63UL / (max_val - min_val)); }ValueToPixel返回 0~63 之间的像素行号0 是屏幕最上方。这里用63而非64是因为值域是 0~63 共 64 个像素乘法用63UL避免整型溢出MCU 上uint16_t乘法结果可能超过 16 位必须提升到 32 位。比较函数的边界判断也值得留意输入值低于下限时截断到下限高于上限时截断到上限否则曲线会超出屏幕区域导致写显存越界。显示曲线时通常做法是每一列对应一个数据点新数据追加到最右侧整条曲线向左平移一列。平移策略有两种一种是整体刷新把缓存数组每一页左移一个 bit 位再在最后一列打上新点另一种是只擦除最左列把其余列重绘。整体刷新的代码简单但有撕裂感推荐用后者。void ScrollCurveLeft(void) { for (uint8_t page 0; page 8; page) { for (uint8_t col 0; col 15; col) { lcd_buffer[page][col] lcd_buffer[page][col 1]; } lcd_buffer[page][15] 0; // 清掉最右侧列准备画新点 } // 全屏更新 for (uint8_t page 0; page 8; page) { LCD_WriteCmd(0x80 | page); LCD_WriteCmd(0x80); for (uint8_t col 0; col 16; col) { LCD_WriteData(lcd_buffer[page][col]); } } }ScrollCurveLeft先把二维数组的每一页向左移动一个字节注意这里的移动是字节级别不是像素级别。因为一列屏幕像素对应一个字节中的某个 bit向左平移一列意味着要把每个字节的内容按 bit 平移而当前实现只移动了字节会导致曲线形状被随后的位操作破坏。正确的做法是维护一个列偏移变量每次新数据到来时更新该变量并用位运算把新数据和历史数据合并。已经做完字节移动的你需要在下次调用打点时注意把一列拆成多个字节的对应 bit 来完成真正的像素平移。这个细节容易忽略下面会专门讲。3. 曲线数据缓冲与滚动实现用位操作代替全屏重绘3.1 环形缓冲区还是单列缓存数据流如何组织曲线绘制不是简单地把一个点写到屏上而是持续地接收 ADC 采样值并滚动显示。如果每次采样都更新整个屏幕时间开销会让曲线出现明显的卡顿。合理的数据流应该是ADC 采样 → 标定为像素坐标 → 写入显存数组 → 触发一列刷新。实时性要求高时可以使用 DMA ADC 双缓冲但大多数场景下一次 100Hz 采样、100ms 刷新一列已经足够平滑。在数据组织上推荐使用一个循环队列保存最近 128 个像素值队列满后覆盖最旧数据UI 取队列尾部连续 128 个点绘制静态曲线。#define CURVE_LEN 128 uint8_t curve_data[CURVE_LEN]; uint8_t curve_head 0; uint8_t curve_len 0; void Curve_Push(uint8_t pixel_y) { curve_data[curve_head] pixel_y; curve_head (curve_head 1) % CURVE_LEN; if (curve_len CURVE_LEN) curve_len; } void Curve_RenderAll(void) { for (uint8_t i 0; i curve_len; i) { uint8_t index (curve_head - curve_len i) % CURVE_LEN; LCD_SetPixel(i, curve_data[index]); } }Curve_Push写入一个点Curve_RenderAll按队列顺序绘制。这种实现下屏幕滚动不再是整体移动而是每次新数据到来时把新点画在最后一列并擦除最左列。擦除最左列时要注意该列可能包含了多个页的数据需要分别清除对应字节的对应 bit。环形缓冲的好处是数据不丢失即使 LCD 刷新延迟下一帧也能重绘全部曲线。坏处是每帧都要重绘 128 列对 SPI 速度有要求。如果 SPI 时钟是 1MHz传输一个字节需要 8 微秒一个页 32 字节传输需要 256 微秒8 页全部更新要 2 毫秒左右这在 100Hz 采样下是可行的。但Curve_RenderAll有一个隐患每次重绘时重复设置显存地址会导致画面闪烁。改进方式是只更新新点和被擦除点所在的显存地址。对于 ST7920 图形模式一个字节包含 8 个纵向像素擦除一列时只要将该列对应的所有字节对应 bit 清零这个操作不需要全屏刷新。下面是一个按列增量更新的函数void Curve_UpdateColumn(uint8_t col, uint8_t new_pixel_y) { for (uint8_t page 0; page 8; page) { uint8_t mask ~(0x80 (col % 8)); lcd_buffer[page][col / 8] mask; } if (new_pixel_y 64) { uint8_t page new_pixel_y / 8; uint8_t bit new_pixel_y % 8; lcd_buffer[page][col / 8] | (0x80 (col % 8)); LCD_WriteCmd(0x80 | page); LCD_WriteCmd(0x80 | (col / 8)); LCD_WriteData(lcd_buffer[page][col / 8]); } }这里有个容易出错的细节LCD 的列地址是以字节为单位的每个字节对应 8 个横向像素但 ST7920 的显示缓冲区是按 16 字节宽的也就是一页有 16 个字节每个字节存储 8 个点的横向数据。所以列col对应的显存地址是col / 8而col % 8决定该点在字节中的 bit 位置。代码中先清除整列数据再打上新点这个顺序很重要如果不先清除旧点新点会和旧点共存产生残影。增量更新将每帧操作压缩到两个字节写入但前提是主动维护了显存数组。如果没有显存数组也可以考虑让控制器维护但 ST7920 没有读显存功能所以 MCU 必须持有 shadow buffer。ST7565 支持读显存可以直接读出来再改省掉数组但会增加 SPI 总线的传输方向切换反而更麻烦。工程上常见做法就是 shadow buffer这也是多数成熟 LCD 库的做法。3.2 STM32F103 的 TIMDMA 定时采样让曲线自动推进手动调用Curve_Push只能实现静态波形实时曲线的关键在于定时采集。用 STM32F103 的定时器触发 ADC 采样再通过 DMA 搬运结果可以让 MCU 在曲线绘制的同时继续处理其他任务。常见的配置是 TIM3 产生 100Hz 更新事件触发 ADC1 规则通道转换ADC1 转换完成后通过 DMA1 通道把结果写入内存变量。void TIM3_Init(uint16_t arr, uint16_t psc) { TIM_TimeBaseInitTypeDef TIM_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_InitStructure.TIM_Period arr - 1; TIM_InitStructure.TIM_Prescaler psc - 1; TIM_InitStructure.TIM_ClockDivision 0; TIM_InitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_InitStructure); TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); TIM_Cmd(TIM3, ENABLE); }上述配置中arr是自动重装载值psc是预分频值。如果系统时钟为 72MHz预分频 720 得到 100kHz 计数频率自动重装载 1000 得到 100Hz 更新率。TIM_SelectOutputTrigger把更新事件作为 ADC 的外部触发源需要确保 ADC 的触发源改为 TIM3 的外部触发。ADC 采样完成后在 ADC 中断或者 DMA 传输完成中断里调用Curve_Push(ValueToPixel(adc_value, 0, 4095))并调用Curve_UpdateColumn刷新屏幕。这个流程下 CPU 占用极低绘制过程几乎不阻塞主循环。void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); uint8_t pixel_y ValueToPixel(adc_value, 0, 4095); static uint8_t col 0; Curve_UpdateColumn(col, pixel_y); Curve_Push(pixel_y); col (col 1) % 128; } }这段代码放在 DMA 中断里col 变量每次加一当曲线走到屏幕右边缘后回到左边缘继续覆盖。这种模式适合示波器式的循环显示。如果希望曲线从左往右推进到右边缘后清屏可以改成if (col 127) { ClearScreen(); col 0; }。ClearScreen可以是把 shadow buffer 清零并批量写入 0x00 的循环但注意 ST7920 清屏需要发送0x01命令处理起来较慢也可以自己通过显存数组快速清零。在实时性和代码可维护性之间我倾向于把 ADC 采样与曲线绘制解耦采集中断只更新 buffer主循环中再检查 buffer 是否有新数据并刷新 LCD。DMA 中断里直接操作 LCD 会延长中断时间尤其是使用模拟 SPI 时中断里进行位反转会破坏其他中断的实时性。具体做法是设置一个 ready 标志位主循环检测到标志后执行绘制。如果项目里用的是 FreeRTOS还可以把绘制放到独立任务用信号量通知绘制任务避免阻塞采样任务。这个设计在移植到 GD32F103 或者 APM32F103 时同样适用只是启动文件和库文件的差异需要额外留意。4. 常用指令与字库处理自定义汉字和坐标定位不冲突4.1 理解 ST7920 的 DDRAM 地址与图形模式的冲突如果不显示汉字很多人绕开了字符发生器但这会造成一个误区图形模式下无法显示文字。实际上图形模式和文本模式可以通过指令切换但二者共享 DDRAM切换后内容会互相干扰。直接使用文本模式显示字符串是 LCD12864 最常见的场景STM32F103 工程里通常内置LCD_ShowString函数它的核心是把 ASCII 字符码转换为字库的地址再从字符发生器读取字形数据写入显存。void LCD_ShowChar(uint8_t x, uint8_t y, char ch) { // 文本模式下 x 是列号y 是行号范围 0~7, 0~3 uint16_t addr 0x80 (y * 16) x; LCD_WriteCmd(addr); LCD_WriteData(ch - 0x20); }上面这个函数适用于 ST7920 文本模式其中地址公式0x80 y*16 x按第二行起始地址为 0x90、第三行为 0x88、第四行为 0x98 的规律计算需要对照芯片手册确认不同厂家模组的行起始地址可能不一样。注意这里的x是字符列而非像素列因为没有使用图形模式字符宽度是固定的 16 像素包含间距。如果想在曲线上叠加文字标注就要切换到图形模式把汉字或 ASCII 字符的字模直接写入显存。字模数据一般由取模软件生成每个汉字 32 字节纵向 16 点、横向 16 点。const uint8_t hanzi_model[32] { 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFC, 0x20, 0x04, // ... 共 32 字节 }; void LCD_DrawHanzi(uint8_t x, uint8_t y, const uint8_t *model) { for (uint8_t page 0; page 2; page) { for (uint8_t col 0; col 16; col) { uint8_t byte model[page * 16 col]; lcd_buffer[y/8 page][(x col) / 8] byte; } } }自定义汉字本质上是把取模工具生成的数组按页写入显存。这里二维数组lcd_buffer的维度其实应该扩展为[8][16]而不是上面示例中简化的形式。汉字占 16x16 像素会跨越两个页、两颗字节写入时需要计算页地址和列地址。如果只写一个 8x8 的 ASCII 字符则只在一页内写入字节即可。要注意的是显存更新后必须把 shadow buffer 的相应位置同步好否则后续的曲线滚动会覆盖掉文字或者文字残留不消失。这也是为什么建议把LCD_SetPixel、LCD_DrawHanzi都封装到相同的显存读写函数接口上让所有图形操作最终都落到 buffer 上。ST7920 的字库 ROM 里只有 16x16 点阵的常用汉字实际开发中自定义汉字常用于产品 logo 或特殊符号。取模方式推荐“纵向取模、字节倒序、高位在前”这样和显存位序直接对应。如果你的模组是 ST7565那就没有内置字库必须全部用字模。这意味着移植代码时字符绘制部分要做到数组抽象不要让LCD_ShowChar依赖控制器型号这样才能在 ST7920 和 ST7565 之间切换。实际工程里我见过不少人把汉字数组做成.c文件按需裁剪这样可以减少 flash 占用特别是 STM32F103C8T6 的 64KB flash 在塞大量字模时非常紧张。4.2 曲线坐标网格与数值标注的实现思路在曲线区域画网格能大幅提升可读性。实现方式是在初始化后一次性画好网格线然后让曲线数据覆盖在网格之上。网格一般是横向虚线或纵向虚线为了防止曲线刷新时擦掉网格最简单的方法是把网格也写入 shadow buffer并和曲线数据合并。在只更新单列时网格线的 bit 应该保留。比如横向网格每隔 16 像素画一条线画线时只在对应页的预定位位置置 1。void LCD_DrawDashedHLine(uint8_t x_start, uint8_t x_end, uint8_t y) { for (uint8_t x x_start; x x_end; x 2) { LCD_SetPixel(x, y); } }这是一个非常朴素的虚线函数每两个像素画一个点。它的缺点是没有写回 shadow buffer如果后续Curve_UpdateColumn在清除整列数据时会把这些网格一并清除。正确做法是把网格保存在独立的grid_buffer中每次更新曲线列时先用网格数据覆盖指定列再叠加曲线点。或者更简单把网格写入lcd_buffer并且在Curve_UpdateColumn清除列数据后再重新把网格点写回来。这样做效率略低但代码清晰。数值标注可以显示在曲线下方或右上角使用小号 6x12 数字字模。STM32F103 上做数值标注的常见思路是每秒刷新一次静态文本因为实时刷新会导致数字快速变化无法阅读。用snprintf格式化数据后逐字符取模写入显存。snprintf在 MCU 上可能占用较多 flash如果空间紧张可以直接手动转换void FormatValue(uint16_t value, char *buf) { buf[0] 0 (value / 1000) % 10; buf[1] 0 (value / 100) % 10; buf[2] 0 (value / 10) % 10; buf[3] 0 value % 10; buf[4] \0; }这段代码把 0~9999 的整数转换成四位十进制字符串不依赖标准库。这里value / 1000 % 10的运算顺序是从高位到低位逐位提取需要确保value不超过 4 位否则高位溢出。实际中 ADC 12 位最大值 4095刚好 4 位十进制数够用。在显示数值时背景色要处理一致否则数字残影会干扰曲线视觉。每次刷新数字前先清掉之前画过的区域再写入新数字或者把数字区域固定为一块 4 字符宽的空间刷新时整体重写。5. 压缩启动流程和调试方法避免花屏与刷新撕裂5.1 初始化序列为什么必须包含延时不同控制器的差异点LCD12864 的初始化如果出问题表现往往是白屏或者随机显示乱码。STM32F103 上电后LCD 模组的电压还没稳定RST 引脚拉低再拉高的复位脉冲需要至少 10ms 的低电平持续时间。初始化命令之间的延时也很容易忽略ST7920 的0x01清屏命令需要等待约 10ms 才能执行下一条指令因为控制器在清除 256 字节的 DDRAM 时要消耗时间。如果你用LCD_WriteCmd里的统一LCD_Delay(10)则清屏之后的命令会自动留有间隔问题不大但要注意模拟 SPI 翻转速度过快导致的发送怪卡。void LCD_Init(void) { GPIO_SetBits(GPIOA, GPIO_Pin_2); // RST high LCD_Delay(100); GPIO_ResetBits(GPIOA, GPIO_Pin_2); // RST low LCD_Delay(100); GPIO_SetBits(GPIOA, GPIO_Pin_2); // RST high LCD_Delay(100); LCD_WriteCmd(0x30); // 基本功能 LCD_Delay(20); LCD_WriteCmd(0x30); LCD_Delay(20); LCD_WriteCmd(0x0C); // 显示开光标关 LCD_Delay(20); LCD_WriteCmd(0x01); // 清屏 LCD_Delay(20); LCD_WriteCmd(0x06); // 光标右移 LCD_Delay(20); }这段初始化序列是通用做法。0x30连续发送两次是为了确保控制器从串口模式唤醒第一次可能无响应第二次才能正确识别命令。如果你的模组默认是 8 位并口模式需要先把 PSB 引脚拉到低电平切换为串口模式再执行上述序列。很多 STM32F103 核心板默认没有引出 PSB 引脚这就需要直接飞线。如果 PSB 悬空控制器可能处于并口模式SPI 命令完全无效这是“代码没错但屏没反应”的最常见原因。ST7565 控制器的初始化序列则不同它需要设置偏压比、ADC 方向、显示起始行等参数。对比 ST7920 和 ST7565 的启动命令ST79200x30、0x0C、0x01、0x06ST75650xA2偏压 1/9、0xA1ADC 选择、0xC0行扫描方向、0xA6显示正常、0x2F电源控制等如果从 ST7920 代码移植到 ST7565原样保留初始化序列会直接花屏。这也是我建议在代码里通过宏区分控制器型号的原因。#define LCD_TYPE_ST7920 1 // #define LCD_TYPE_ST7565 0通过条件编译来切换初始化函数和底层命令封装而不是维护两套独立工程。这种做法的实际收益是后续换屏模组时不用改动上层曲线逻辑只改底层驱动。如果是小批量项目屏幕供应商可能临时更换控制器这一层抽象能省出半天到一天的调试时间。5.2 擦除阴影与撕裂问题的根因曲线滚动时常见的问题包括旧点残留、曲线带拖影、刷新时屏幕闪烁。旧点残留往往是 shadow buffer 和实际显存不同步导致。比如调用LCD_SetPixel时只更新了 buffer 数组但实际写回 LCD 时地址算错下一次删点时删的是另一块区域。检查方法是在画点函数里打印地址参数确认页地址和列地址是否和预期一致。一般来说ST7920 图形模式的列地址范围是 0~15对应 16 个字节页地址范围是 0~7超出范围会被控制器忽略导致写入失败。拖影和闪烁主要原因是刷新整屏花费时间过长。如果使用模拟 SPI每次更新整屏需要发送约 1KB 数据在 1MHz 时钟下需要 8ms 左右如果刷新频率是 20Hz就会看到明显的像素逐步扫描过程。要避免这种效果最简单的方案是开启局部刷新只更新发生变化的区域。曲线场景中一帧数据只影响一列像素局部刷新可以做到实时且无撕裂。实现局部刷新需要以列为单位把该列的所有页都更新一遍即使这一列只有一处发生改变也要更新 8 个页的对应字节但总数据量仍然远小于全屏刷。void Curve_RefreshColumn(uint8_t col) { for (uint8_t page 0; page 8; page) { uint8_t byte lcd_buffer[page][col / 8]; LCD_WriteCmd(0x80 | page); LCD_WriteCmd(0x80 | (col / 8)); LCD_WriteData(byte); } }注意这里写入的是同一个列地址的 8 个页因为 ST7920 的图形模式中同一列地址对应 8 个页的字节这 8 个字节共同决定纵向 64 个像素。如果在page0写入数据后立即去写page1的地址要在命令间加入小的延时或注意数据/命令切换的时序ST7920 要求命令后至少等待 4 个时钟周期再发送数据否则可能丢失第一字节。模拟 SPI 下建议在切换命令和数据时增加 5~10 微秒延时实测能减少偶发花屏。5.3 用 J-Link 调试时如何判断曲线数据是否正确Keil 工程里的.uvproj.bak、.uvopt.bak和 JLink 日志文件表明开发环境使用过 J-Link 调试器。实际调试曲线显示问题时最直接的手段是在Curve_Push处打断点观察pixel_y值是否符合预期。比如 ADC 输入电压固定为 2V12 位采样值应该恒定在 2048 左右对应像素y 2048 * 63 / 4095 31。如果看到像素值波动剧烈或跳变先查硬件接线而非 LCD 驱动。J-Link 的 RTT 功能也非常适合输出调试信息。在曲线绘制进程中周期性通过 RTT 输出采样值和像素值再结合屏幕显示内容对比能快速定位是采样问题还是绘制问题。如果 RTT 输出正常但屏幕无曲线那大概率是 LCD 地址映射和显存数组不同步。还有一个小技巧把 shadow buffer 的前 16 个字节通过调试器导出成 hex 文件人工比对字模数据这比盯着屏幕猜效率高很多。STM32F103 的启动文件选择也值得注意。在 Keil 中默认使用startup_stm32f10x_hd.s如果芯片是 C8T6中等容量需要换成startup_stm32f10x_md.s否则系统时钟配置错误外设根本无法工作。.build_log.htm里如果出现Error: L6220E或Warning: L6304W多半是启动文件或库版本问题。工程备份文件本身不影响程序运行但如果你从备份恢复后编译报错优先检查库文件和芯片型号配置是否一致。6. 用坐标剪裁和显示缓冲叠加做出真正可用的嵌入式示波器界面规模化应用曲线绘制时不能只画单条波形。实际项目中往往需要同时显示参考线和实时数据甚至多条曲线。LCD12864 的 128x64 分辨率有限但依然可以通过裁剪和缓冲叠加实现多通道显示。比如双通道示波器界面把屏幕水平分为两个区域上方 32 像素画通道 A下方 32 像素画通道 B中间用虚线分隔。每个通道的采样值需要分别映射到自己的纵向范围不能共用同一个ValueToPixel函数。改造方式是引入通道上下限参数uint8_t ValueToPixel(uint16_t value, uint16_t min_val, uint16_t max_val, uint8_t y_offset, uint8_t y_range) { if (value min_val) value min_val; if (value max_val) value max_val; return y_offset (uint8_t)((uint32_t)(value - min_val) * (y_range - 1) / (max_val - min_val)); }这个函数比前文版本多了y_offset和y_range通道 A 可以设置y_offset0, y_range32通道 B 设置y_offset32, y_range32。这样两个通道互不干扰显存数组仍然是完整的 128x64 buffer。多曲线显示时必须保证每条曲线颜色不同但 LCD12864 是黑白屏唯一区分方式是利用虚线、实线、反色显示或不同灰度抖动。常见做法是通道 A 用实线通道 B 用每隔一个点显示的点划线通过打点时跳过特定像素实现。这个方案在读取多路 ADC 时同样适用。如果使用多个 ADC 通道还可以让曲线滚动对齐也可以每通道独立滚动。除了多通道光标测量也是嵌入式示波器的常见功能。64 像素的高度足够显示一个小十字光标通过按键移动光标位置在另一侧显示当前坐标对应的数值。这个功能的实现要点是光标像素必须和曲线数据在显存数组中叠加不能互斥覆盖。具体的叠加逻辑是在把曲线数据写入lcd_buffer后再单独对光标所在坐标执行一次LCD_SetPixel并确保后续曲线刷新不会清除光标区域。可以在刷新函数中判断当前列是否为光标列如果是则先画曲线点再在固定位置画光标点。到此你已经具备了让 STM32F103 驱动 LCD12864 绘制曲线的完整链路接口选型、底层时序、显存管理、数据采样、局部刷新、多通道叠加。不过有一个实际优化点把LCD_UpdateColumns和曲线滚动结合起来可以避免每次刷新时重复设置所有页地址。ST7920 的写地址指针在写数据后会自动递增所以连续写入 16 字节不需要重复发送地址命令这个特性可以用于列刷新时一次性写入一整行。具体做法是设置页地址后按顺序写入该页 16 个字节无论哪些列发生变化都写完整一行。这个方案对于 8 页显存来说每次刷新最多 8 行连续写总数据量仍比整屏重绘小很多。如果你的刷新频率超过 20Hz建议采用这种方式充分利用 ST7920 的地址自动递增特性。本文还有配套的精品资源点击获取
返回列表