简介:面向嵌入式初学者与中级开发者的STM32驱动19264液晶屏工程,围绕KS0108/KS0107并行控制器的GPIO时序、初始化命令、显存缓冲与图形绘制展开,可用于学习MCU与LCD模块的并行接口调试及显示功能移植。压缩包共151个文件,约2.36MB,以36个C源文件与36个头文件为核心,辅以Keil工程配置、编译中间文件、HEX烧录文件、MAP映射及启动与分散加载文件,保留从源码到构建产物的较完整链路。已有747人学习,适合对照理解工程组织与编译配置。工程内含LCD初始化、点绘、字符与字符串显示、缓冲区批量刷新,以及直线、矩形、圆形和Bresenham算法等实现线索,便于提取显示驱动框架、时序控制思路和图形功能代码,作为课程设计、毕业设计或嵌入式显示模块开发的参考。
1. 一块19264并口屏,为什么值得用STM32手动喂时序
前阵子接手一台老设备的显示改造,原板子上挂着一块19264点阵液晶,控制器是KS0108加KS0107那一套,驱动代码还是十几年前的汇编风格,换块主控就全废。这块屏的分辨率是192×64,内部拆成三片KS0108列驱动,每片管64列,行驱动交给KS0107,接口是标准的8位并口。它不难,但特别考验时序细节:E、R/W、A0、三根片选CS1到CS3,任何一根慢半拍就是花屏或者整屏不亮。适合手上已经有STM32最小系统、想让一块老屏先亮起来跑字跑图的开发者,也适合想把GPIO模拟时序这套基本功打扎实的人。
2. 硬件连接与工程骨架:三片KS0108怎么分掉192列
先把这个屏的内部结构说清楚,不然代码写起来全是猜。19264这个命名不是随便起的:横向192列像素,纵向64行像素。KS0108一片只能驱动64列×64行,所以屏上通常并着三片KS0108,靠片选信号分工,左中右各64列。而纵向那64行的扫描驱动不在KS0108身上,是旁边那颗KS0107在干活——它负责把行电压一路一路推上去,KS0108只负责把每个像素该亮还是该灭的数据锁存到列电极上。驱动代码里你几乎不会直接碰KS0107,它没有指令寄存器,上电给电、给对比度电压就自己跑,真正要写的是三片KS0108的寄存器操作。
这一点挺关键:搜KS0107的人多半在查行驱动电压或者对比度电路,搜KS0108的人才是来写代码的。你手上这块屏如果要改硬件,KS0107那边只需要确认V0、VEE这几个负压引脚正常,剩下的都在KS0108的指令集里。
2.1 引脚分配:把数据口挤到一个GPIO端口上
接线我一般按“数据线整端口、控制线随便放”的原则来。数据线D0到D7必须挂在同一个GPIO端口的连续8位上,这样后面可以用一次ODR写操作把整个字节甩出去,省掉8次位操作。STM32F103C8T6的PB0到PB7正好连续,就用它。控制线放PB8到PB14,每个引脚功能单一,方便宏封装。
| LCD引脚 | 功能 | STM32引脚 | GPIO配置 |
|---|---|---|---|
| D0-D7 | 8位数据总线 | PB0-PB7 | 推挽输出 / 浮空输入复用 |
| A0 | 指令(0)/数据(1)选择 | PB8 | 推挽输出 |
| R/W | 读写选择,低写高读 | PB9 | 推挽输出 |
| E | 使能,上升沿锁存 | PB10 | 推挽输出 |
| CS1 | 左64列片选 | PB11 | 推挽输出 |
| CS2 | 中64列片选 | PB12 | 推挽输出 |
| CS3 | 右64列片选 | PB13 | 推挽输出 |
| RST | 复位,低有效 | PB14 | 推挽输出 |
| V0 | 对比度调节 | 外部电位器/负压 | 模拟,不接MCU |
| VDD/VSS | 逻辑电源5V/地 | 5V、GND | 电源 |
数据线这里有个坑要提前说:写的时候是推挽输出,读忙标志的时候数据口要切成输入,读完再切回输出。很多人图省事把数据口配成开漏输出加外部上拉,读的时候靠上拉拉高,能跑但速率上不去,读状态也不稳。老老实实做输入输出切换,代码多几行,后面省很多事。
提示:如果你的STM32是5V容忍引脚(PB口在F103上大部分是FT),可以直接和5V的KS0108对接;PA口里有几个不是FT的,别往那上面接。
2.2 工程目录:从BSP到APP的四层拆法
我拆这种屏驱动,习惯分四层,好处是换一块屏只改port层,换一个主控只改port层,上层显存和绘图逻辑一行不动。目录大概长这样:
Project/ ├── Core/ │ ├── Inc/ main.h, stm32f1xx_hal_conf.h │ └── Src/ main.c, stm32f1xx_it.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── BSP/ │ ├── lcd19264_port.h // 引脚宏、延时、数据口读写 │ ├── lcd19264_port.c │ ├── lcd19264.h // 指令定义、显存、绘图API │ ├── lcd19264.c │ ├── font_ascii.h // 8x16 ASCII字库 │ └── font_hz.h // 16x16汉字字库 └── App/ └── ui_demo.c // 开机画面、字符串滚动之类的业务port层只干三件事:拉引脚、切数据口方向、微秒级延时。core层认的是“往第cs片写一个命令字节”这种抽象,不关心引脚是PB还是PC。App层连页和列都不需要知道,只调lcd_draw_point和lcd_show_string。这个分法不新鲜,但真的落地时,很多人把KS0108的指令直接写进main函数里,后面想换屏就得全删。
2.3 上电时序与对比度电路
硬件上有两件事必须在代码跑起来之前确认,不然你调一整天代码也看不到一个像素。
第一是复位。KS0108的RST是低电平有效,上电后必须给一个明显的低脉冲,再拉高,然后等内部稳定。我一般这么写:
void lcd_hard_reset(void) { HAL_GPIO_WritePin(GPIOB, LCD_RST_PIN, GPIO_PIN_RESET); HAL_Delay(20); /* 低电平保持20ms,足够内部寄存器清零 */ HAL_GPIO_WritePin(GPIOB, LCD_RST_PIN, GPIO_PIN_SET); HAL_Delay(50); /* 释放后等50ms,等电荷泵和偏压稳定 */ }20ms和50ms这两个数不是随便写的。短了控制器可能停在半复位状态,现象是屏幕亮着但所有指令都不响应,读状态寄存器一直返回0xFF。长一点没坏处,反正只在上电跑一次。
第二是对比度。V0这个引脚要接一个负压,典型值在-8V到-12V之间,具体看屏的规格。有些屏自带升压电路,你只需要在V0和地之间串一个10K到20K的电位器调分压;有些屏不带,就得外部给负压。判断方法很简单:万用表直流档量V0对地,如果接近0V,液晶没有驱动压差,表现就是整屏均匀的黑或者均匀的白,背光再亮也没用。这是新手最容易翻车的地方,代码查三天,最后发现是一根线没接。
3. 底层时序实现:写命令、写数据、读忙标志的GPIO模拟
KS0108的并口时序不复杂,一共就三根控制线配合数据总线:A0定这次传的是指令还是显示数据,R/W定方向,E是使能脉冲,数据在E的下降沿被锁进芯片。麻烦的是时间参数,手册上写的tASU、tEW、tDHW这些都在几百纳秒量级,而STM32F103跑72MHz,一条指令几十纳秒,不加延时的话E的高电平可能只有十几纳秒,处于临界状态,表现就是“大部分时候正常,偶尔抽一下”。
3.1 一次ODR写8位:数据口的最省事写法
先看数据口的封装。传统写法是循环8次,每次调HAL_GPIO_WritePin,一个字节要8次函数调用,刷一屏1536字节就是一万多次调用,时间全花在函数开销上。既然数据线在PB0到PB7,直接操作ODR寄存器最干脆:
/* 把低8位整体替换成d,高8位的控制线原样保留 */ static inline void lcd_data_out(uint8_t d) { GPIOB->ODR = (GPIOB->ODR & 0xFF00U) | (uint16_t)d; } /* 数据口切成浮空输入,准备读状态 */ static inline void lcd_data_dir_in(void) { GPIOB->CRL = 0x44444444U; /* PB0-PB7 全部配成输入(浮空),CNF=01 MODE=00 */ } /* 切回推挽输出,50MHz档 */ static inline void lcd_data_dir_out(void) { GPIOB->CRL = 0x33333333U; /* PB0-PB7 输出推挽,CNF=00 MODE=11 */ }这里说明三件事。第一,(GPIOB->ODR & 0xFF00U) | d这个写法保证只改低8位,PB8以上的控制线状态不会被破坏,这样你在一次传输中途不需要重设A0、R/W。第二,CRL寄存器一个引脚占4位,0x3表示输出推挽50MHz,0x4表示输入浮空,8个引脚正好32位,所以用0x33333333和0x44444444这种整齐的模式。第三,读写切换必须成对出现,读之前切输入,读完立刻切回输出,漏掉一次后面所有写操作都是无效的,因为引脚还在输入态。
注意:直接在CRL里写死模式,意味着PB0-PB7不能拿去做别的复用功能。工程里如果还用了SPI1或者TIM3的部分通道,先在CubeMX里避开这几个脚,别等编译过了运行才发现引脚打架。
3.2 三根控制线的时序窗口
控制线我用宏封装,读起来接近手册里的时序图:
#define LCD_A0_CMD() HAL_GPIO_WritePin(GPIOB, LCD_A0_PIN, GPIO_PIN_RESET) #define LCD_A0_DAT() HAL_GPIO_WritePin(GPIOB, LCD_A0_PIN, GPIO_PIN_SET) #define LCD_RW_WR() HAL_GPIO_WritePin(GPIOB, LCD_RW_PIN, GPIO_PIN_RESET) #define LCD_RW_RD() HAL_GPIO_WritePin(GPIOB, LCD_RW_PIN, GPIO_PIN_SET) #define LCD_E_HIGH() HAL_GPIO_WritePin(GPIOB, LCD_E_PIN, GPIO_PIN_SET) #define LCD_E_LOW() HAL_GPIO_WritePin(GPIOB, LCD_E_PIN, GPIO_PIN_RESET)有了这些,写命令的函数就顺理成章:
void lcd_write_cmd(uint8_t cs, uint8_t cmd) { lcd_cs_select(cs); LCD_A0_CMD(); /* A0=0,本次传输是指令 */ LCD_RW_WR(); /* R/W=0,写方向 */ lcd_data_dir_out(); lcd_data_out(cmd); /* 数据先摆上总线,满足tASU数据建立时间 */ LCD_E_HIGH(); lcd_delay_us(1); /* E高电平宽度,手册要求>=450ns,给1us留余量 */ LCD_E_LOW(); lcd_delay_us(1); /* E下降沿后数据保持时间tDHW */ lcd_cs_release(); }顺序不能乱:片选先拉低,A0和R/W先定好,数据最后摆上去,然后才给E脉冲。KS0108是在E的下降沿采样总线的,所以数据只要在E拉低之前稳定住就行。我把lcd_data_out放在LCD_E_HIGH之前,就是为了满足数据建立时间。反过来先给E再改数据,看起来也能跑,但在高低温或者长排线下就会丢字节。
写数据和写命令只差一个A0电平:
void lcd_write_data(uint8_t cs, uint8_t dat) { lcd_cs_select(cs); LCD_A0_DAT(); /* A0=1,本次传输是显示数据 */ LCD_RW_WR(); lcd_data_dir_out(); lcd_data_out(dat); LCD_E_HIGH(); lcd_delay_us(1); LCD_E_LOW(); lcd_delay_us(1); lcd_cs_release(); }刷全屏时这个函数会被调用1536次,每次都进出片选。如果追求速度,可以把片选和A0提到循环外面,循环里只翻转E和写数据,能快一倍左右。但先别急着优化,等画面正确了再说。
3.3 读忙标志与超时保护
KS0108每次接收指令或者数据后,内部要花一点时间处理,这段时间Busy标志(状态字节的D7位)是1。手册要求每次操作前读一次状态,等它变0再发下一条。严格照做的代码是这样:
uint8_t lcd_read_status(uint8_t cs) { uint8_t sta; lcd_cs_select(cs); LCD_A0_CMD(); /* 读状态时A0=0 */ LCD_RW_RD(); /* R/W=1,读方向 */ lcd_data_dir_in(); /* 数据口切输入,否则总线冲突 */ LCD_E_HIGH(); lcd_delay_us(1); sta = (uint8_t)(GPIOB->IDR & 0x00FFU); LCD_E_LOW(); lcd_data_dir_out(); lcd_cs_release(); return sta; } void lcd_wait_idle(uint8_t cs) { uint32_t guard = 0; /* D7是Busy标志,D5是ON/OFF,D4是Reset,这里只关心D7 */ while ((lcd_read_status(cs) & 0x80U) != 0U) { if (++guard > 20000U) { /* 大约几十毫秒,超了就认为屏没接或者坏了 */ break; } } }那个guard计数器是血泪经验。早期版本写的是纯while,某次排线松了,程序卡在等忙标志里,看门狗还没开,整机死在那儿,现场复位都救不回来。加了超时之后,最坏情况是画面缺一块,但主循环还活着,能上报错误。这种“宁可显示错也不能死机”的思路,在嵌入式里比什么优雅写法都实用。
实际工程里还有个折中:每条指令后等忙,连续写显示数据时不等。因为数据写入是顺序的,KS0108内部有缓冲,只要E脉冲宽度够,连续写不会丢。这样刷一屏能从几十毫秒降到十几毫秒。我的做法是先全部等忙,确认画面正确,再把数据写入路径上的lcd_wait_idle去掉,对比前后效果,没问题就保留。
3.4 指令集与初始化序列
KS0108的指令少得可怜,常用的就四条:
| 指令字节 | 含义 | 参数说明 |
|---|---|---|
| 0x3F / 0x3E | 开显示 / 关显示 | 无参数 |
| 0xC0 | 设置起始行 | 低6位是行号,一般写0xC0 |
| 0xB8 | page | 设置页地址 | page取0-7,对应屏幕纵向8页 |
| 0x40 | col | 设置列地址 | col取0-63,是片内列号,不是全局列号 |
初始化就是把这三片各自唤一遍:
void lcd_init(void) { lcd_hard_reset(); /* RST低脉冲 + 稳定等待 */ for (uint8_t cs = 0; cs < 3; cs++) { lcd_wait_idle(cs); lcd_write_cmd(cs, 0x3F); /* 开显示 */ lcd_write_cmd(cs, 0xC0); /* 起始行归零,防止画面整体上下滚动 */ lcd_write_cmd(cs, 0xB8); /* 页地址回到第0页 */ lcd_write_cmd(cs, 0x40); /* 列地址回到第0列 */ } lcd_clear(); /* 显存和屏内RAM一起清,避免上电雪花 */ }0xC0这条容易被忽略。起始行寄存器决定从显存的哪一行开始往屏上扫,上电是随机值,如果不显式清零,画面会整体上下错位几行,看起来像“字被切了一半”。有人以为是取模问题,其实是起始行没归零。
4. 显示层搭建:页地址映射、画点、字符与图片
时序通了,屏能亮能清,接下来才是真正决定好不好用的部分:怎么把“第x列第y行的像素”翻译成“第几片、第几页、第几列、第几位”。这一层设计得好,上层画图写字就是调函数;设计得差,每个显示需求都要手算地址,代码里全是魔数。
4.1 192列怎么切成三段64列
KS0108的显存是“页”结构:纵向64行分成8页,每页8行,每页有64列,每列一个字节,字节的bit0到bit7对应这一页里从上到下的8个像素。一整片KS0108就是8页×64列=512字节,三片合起来1536字节。
全局坐标到硬件坐标的映射只有一处:
/* x: 0-191, y: 0-63 */ static void lcd_map(uint16_t x, uint8_t y, uint8_t *cs, uint8_t *page, uint8_t *col, uint8_t *bit) { *cs = (uint8_t)(x / 64U); /* 0=左 1=中 2=右 */ *col = (uint8_t)(x % 64U); /* 片内列号 0-63 */ *page = (uint8_t)(y / 8U); /* 页号 0-7 */ *bit = (uint8_t)(y % 8U); /* 页内行号 0-7 */ }这段代码谁都会写,真正的分歧在于bit和字节内位序的对应。如果取模软件出的字模是bit0对应最上面一行,那这里就按1 << bit去置位;如果取模软件反过来,bit7对应最上面一行,那就要用1 << (7 - bit)。两边必须统一,否则字就是上下颠倒的。我习惯从头到尾固定“bit0在上”,取模软件也按这个设,后面所有字库和图片资源都按这个约定来,不会乱。
4.2 画点:读-改-写与影子显存
KS0108不像TFT那样有显存可以随便读,它支持读显示数据,但读操作要和读忙标志共用总线,时序上容易打架。所以画点一般走两条路:一是直接读屏内RAM,改一位再写回去;二是在MCU里开一块影子显存,所有绘图操作只改影子,最后统一刷到屏上。
我选第二条。192×64÷8=1536字节,STM32F103C8T6有20KB RAM,放得下,换来的好处是绘图逻辑完全不用关心硬件时序,画点、画线、贴图都是内存操作,快且不会出错。
/* 影子显存,按页组织:[页0-7][列0-191] */ static uint8_t lcd_gram[8][192]; void lcd_draw_point(uint16_t x, uint8_t y, uint8_t color) { uint8_t page, bit; if (x >= 192U || y >= 64U) { return; /* 越界直接丢,不要试图裁剪,裁剪逻辑放上层 */ } page = (uint8_t)(y >> 3); bit = (uint8_t)(y & 0x07U); if (color != 0U) { lcd_gram[page][x] |= (uint8_t)(1U << bit); } else { lcd_gram[page][x] &= (uint8_t)~(1U << bit); } }有了画点,画线、画矩形、画圆都是标准算法往上套,不用管硬件。缺点是改一个点要等一次全屏刷新才可见,所以要有脏页标记,只刷改过的页。这个放到最后一章讲。
刷新的核心是把影子显存的每一页连续写进对应的那片KS0108:
static void lcd_flush_page(uint8_t page) { for (uint8_t cs = 0; cs < 3U; cs++) { lcd_write_cmd(cs, (uint8_t)(0xB8U | page)); /* 定位到该页 */ lcd_write_cmd(cs, 0x40); /* 列地址从头开始 */ for (uint8_t col = 0; col < 64U; col++) { lcd_write_data(cs, lcd_gram[page][cs * 64U + col]); } } } void lcd_flush(void) { for (uint8_t page = 0; page < 8U; page++) { lcd_flush_page(page); } }注意列地址是每片自己的0到63,写入前必须重新发0x40,否则第二片会从第一片停下的列号继续往下数,画面就整体错位。这是很容易漏的一步,现象是屏幕上每隔64列出现一段重复内容。
4.3 字模:取模软件的三个勾选项
字库是另一个高频翻车点。8×16的ASCII和16×16的汉字,用取模软件生成数组时,三个选项决定代码怎么写:
| 选项 | 我固定的值 | 对应代码行为 |
|---|---|---|
| 阴码/阳码 | 阴码 | 1表示点亮,画点时置位 |
| 取模走向 | 逐列式 | 一个字节对应纵向8个像素,和页结构一致 |
| 字节内位序 | 顺向,低位在前 | bit0对应最上面一行 |
ASCII字符是8×16,正好占两页:
void lcd_show_char(uint16_t x, uint8_t page, char ch) { const uint8_t *p = font_ascii_8x16[(uint8_t)ch - 0x20U]; for (uint8_t i = 0; i < 8U; i++) { lcd_gram[page][x + i] = p[i]; /* 上半页,第0-7行 */ lcd_gram[page + 1U][x + i] = p[i + 8U]; /* 下半页,第8-15行 */ } }汉字16×16同理,每列2字节,横向占16列,纵向占两页。这里有个硬约束:起始页必须是偶数。因为KS0108的页边界是固定的,一个16像素高的字如果从第1页开始画,下半截会跨到第2页的上半部分,和别的字符叠在一起。所以我把所有文本接口的y坐标都强制成page * 8,只允许按页定位,不允许任意y。
void lcd_show_hanzi(uint16_t x, uint8_t page, uint16_t idx) { const uint8_t *p = font_hz_16x16[idx]; page &= 0xFEU; /* 强制页对齐到偶数页 */ for (uint8_t i = 0; i < 16U; i++) { lcd_gram[page][x + i] = p[i * 2U]; lcd_gram[page + 1U][x + i] = p[i * 2U + 1U]; } }16×16汉字字库按GB2312常用的3500字算,每个字32字节,差不多110KB,放在F103C8T6的64KB Flash里放不下。所以实际工程里要么只取用到的几十个字,要么外挂SPI Flash,要么换容量大一点的型号。这是选型阶段就得算清楚的账,别写到一半发现装不下。
4.4 图片与批量刷新
图片显示就是把一张192×64的单色位图按页切好,直接铺到影子里再刷出去。取模时选择“横向取模、字节倒序”也可以,但为了和字库共用一套刷新逻辑,我统一用纵向取模,生成的数组长度正好1536字节,和lcd_gram一一对应。
/* pic按 [页][列] 组织,长度1536,直接memcpy进影子显存 */ void lcd_show_pic(const uint8_t *pic) { for (uint8_t page = 0; page < 8U; page++) { for (uint16_t col = 0; col < 192U; col++) { lcd_gram[page][col] = pic[page * 192U + col]; } } lcd_flush(); }如果要显示小图标,就不要用lcd_flush整屏刷,只把图标占用的页刷一次,几十毫秒的事。批量刷新的诀窍在于“减少指令次数”:每页每片只发两次定位指令,然后连续写64个数据字节,中间不等忙。这样一页的写入大概是64×3=192次E脉冲,全屏1536次,72MHz下用HAL库跑大约20到30毫秒,肉眼看不到闪烁。
5. 避坑排查:屏不亮、花屏、偏一列的5条血泪记录
这一章全是实际调过的坑,每条都按“现象、原因、解决”写。你照着对号入座,能省掉大半天。
5.1 整屏全黑或全白,程序跑得很正常
现象:程序下载进去,串口打印正常,逻辑分析仪抓E和D0-D7都有波形,但屏幕就是一片均匀的黑或者均匀的白,连一个随机点都没有。
原因:九成是V0对比度没接对。KS0107负责行扫描,它需要V0、VEE这几个负压引脚提供液晶驱动压差,压差不够时液晶分子不翻转,背光再亮也是全黑或全白。另一个可能是RST上电没有拉低,KS0108停在复位态,所有指令都不响应。
解决:先量V0对地电压,正常在-8V到-12V之间;接近0就查负压电路和电位器。再确认RST的上电时序,低电平保持至少10ms,拉高后等50ms再发第一条指令。两条都排除之后,用示波器看CS1在写第一字节时有没有拉低,片选没拉低的话数据全被忽略。
5.2 静态画面正常,偶尔抖出几列噪点
现象:显示一段文字,大部分时候正常,隔几秒闪一下,出现几列竖条噪点,重新上电又好了,很难复现。
原因:E的脉宽卡在临界值。72MHz的F103执行一条HAL_GPIO_WritePin是几百纳秒量级,如果中间没有任何延时,E的高电平时间就在500ns附近摇摆,而KS0108手册要求最小450ns,温度一变、电压一抖就丢字节。另一个帮凶是杜邦线太长,地线只有一根,回流路径阻抗大,E的边沿有振铃。
解决:E拉高后加1us延时,用DWT周期计数器或者定时器做精确延时,别用HAL_Delay。排线控制在15cm以内,数据线旁边并一根地线一起走。如果必须用长线,在E和CS线上串100Ω电阻,抑制振铃。
5.3 只有中间64列有显示,左右两片全黑
现象:画点测试,x在64到127之间正常,0到63和128到191完全没反应,但读状态寄存器三片都能正常返回0x00。
原因:CS极性问题或者片选接错。KS0108的CS是低电平选中,很多人按高有效写,结果三片永远是高电平,谁都没被选中;但读状态时因为另外几根线的关系又能返回数据,造成“控制器活着”的错觉。另一种情况是排线上三根CS和屏的连接顺序对调,代码里cs=0实际选中了右边那片。
解决:把片选函数写死成一个switch,一次只拉低一根,然后立刻读该片状态,读到0xFF说明没选中。用蜂鸣档对排线,确认每个CS对应唯一的屏内芯片。如果顺序确实反了,在lcd_map里把cs = x / 64改成cs = 2 - x / 64,一处改完,上层不用动。
5.4 字符上下颠倒或者左右镜像
现象:字能显示,位置也对,但整个字是上下翻过来的,或者上半页和下半页对调,看着像照镜子。
原因:取模设置和显存位序不匹配。取模软件勾了“高位在前”,代码里按1 << bit置位,两边正好反了;或者汉字显示时page和page+1写反,上半页数据写到了下半页。
解决:固定一套约定,阴码、逐列式、低位在前,代码里统一1 << (y & 7)。调试时先画一个8×8的测试块,数据写0x01,如果亮的是最上面一行,说明位序对;亮的是最下面一行,就把取模选项反过来或者代码里改成1 << (7 - (y & 7))。汉字跨页写反的问题,用两行不同数据测:上半页填0xAA,下半页填0x55,一眼就能看出哪半写错了。
5.5 换一批屏之后内容整体偏移一列
现象:同一份代码、同一块板子,旧屏显示正常,换了一批新屏之后所有内容往左或往右错一列,边缘的字被切掉一条。
原因:不同厂家19264的列驱动接线顺序不一样,S1到S192和CS1到CS3的对应关系可能有差别,有些把CS1放在最右边,有些前4列是空列。KS0107那边只管行扫描,列的顺序完全由屏厂排线决定。
解决:写一个定位测试,在第0、63、64、127、128、191列各画一条竖线,看亮的位置。如果整体反向,把cs映射反过来;如果整体偏移固定的几列,在lcd_map里加一个偏移量做补偿。这个偏移量最好做成宏,换屏时只改一个数,不要散落在代码里。
注意:定位测试不要用文字,用单像素竖线最直观。文字有字模方向的问题干扰,会掩盖真正的列偏移。
6. 二次开发:把模拟时序换成FSMC、把全屏刷新改成脏页
板子跑通之后,下一步通常是提升刷新速度或者换主控。这里给两条改造路线,成本不一样,收益也不一样。
第一条是硬件加速。如果换到带FSMC的型号,比如STM32F103VET6或者F407,可以把KS0108的并口直接挂到FSMC的某个Bank上,配成SRAM模式A,让硬件去产生E、R/W这些时序,CPU只负责往一个地址写数据。改造的关键是把A0接到某根地址线上,比如A16,这样写*(volatile uint8_t*)(0x60000000)是写指令,写*(volatile uint8_t*)(0x60020000)是写数据,片选则用FSMC的NE1到NE3分别对三片。时钟、地址建立时间、数据保持时间在FSMC的时序寄存器里配,配一次就不用再管,刷全屏能压到几毫秒。代价是引脚占用多,PCB要改,不适合已经量产的老板子。
第二条是软件优化,不动硬件。核心是把全屏刷新改成脏页刷新:
static uint8_t lcd_dirty; /* bit0-bit7对应8页,1表示该页有改动 */ void lcd_mark_dirty(uint8_t page) { lcd_dirty |= (uint8_t)(1U << page); } void lcd_flush_dirty(void) { for (uint8_t page = 0; page < 8U; page++) { if ((lcd_dirty & (1U << page)) != 0U) { lcd_flush_page(page); lcd_dirty &= (uint8_t)~(1U << page); } } }画点的时候顺手置一下脏位,主循环里调lcd_flush_dirty,只刷真正改动过的页。显示一个变化的数字,从刷8页变成刷2页,耗时直接降到四分之一。再进一步,可以把脏位细化到“页+片”,三片分开刷,改左边一个字符不影响中间和右边。
还有一个常被提起的方向是移植现成的图形库,比如u8g2,它有KS0108的驱动模板,好处是自带各种字体和绘图API。但要留意它的页缓存策略和你的影子显存是两套东西,直接套会导致双重缓冲,RAM吃紧不说,刷新时序也不一定适配你的cs映射。我的建议是只在需要复杂字体的时候接它的字库数据,绘图和刷新还是用自己的,边界清楚。
调试这类改造,我养成了一个习惯:任何一次改动之后,先跑定位测试那条竖线序列,再跑一遍全屏填充,最后才看正常画面。因为定位测试能同时验证片选映射、列偏移和位序,三项里错任何一项,竖线位置就不对;全屏填充能验证连续写的稳定性。跳过这两步直接看文字,出问题你分不清是字模错了还是时序错了。从那以后我每次换屏或者改时序参数,都强制走一遍这套最小验证,再进业务代码。
希望帮到你。
本文还有配套的精品资源,点击获取