简介:面向嵌入式学习者与 STM32F03RBT6 开发者的 19264 点阵 LCD 驱动工程,围绕 KS0107/KS0108 类控制芯片的并行接口展开,可用于掌握 GPIO 推挽输出、读写时序、显示开关与光标控制、显存缓冲区管理,以及点、线、矩形、圆和简单动画等图形绘制思路。压缩包共 151 个文件,约 2.36MB,包含 36 个 .h 与 36 个 .c 源文件、Keil 工程文件 .uvproj/.uvopt,以及 .o、.d、.crf、.axf、.hex、.map 等编译中间文件和可烧录产物,便于直接打开工程对照源码与构建配置。已有 747 人学习,适合需要从底层接口、时序调试到显示优化完整实践 LCD 驱动的读者参考。
1. 19264LCD驱动工程的第一印象:三片KS0108加一片KS0107怎么被STM32带起来
STM32驱动19264LCD的工程不少,但把KS0107/KS0108时序、三片选和显存映射摊开讲的驱动工程不多。19264屏是192×64点阵,常见结构是3片KS0108管列、1片KS0107管行,STM32用GPIO模拟8080并口写命令和数据。它适合刚接手点阵屏的嵌入式开发者,也适合要把老设备上的黑白菜单迁移到新板子的人。
工程不依赖显存,所有像素都靠读改写显存完成,调通后能显示字符、边框、进度条和小图标。真正容易翻车的不是C语法,而是片选、忙等待和读改写顺序。
2. 工程结构与硬件连接:GPIO模拟8080并口的分层设计
这份19264驱动工程我一般会按“总线层、面板层、绘图层、应用层”拆。总线层只负责GPIO电平翻转和数据口方向切换,面板层负责KS0108的命令、页地址、列地址和三片选,绘图层负责画点、字符、矩形,应用层不直接碰任何LCD引脚。这样拆的好处很直接:换一块STM32,只改总线层;换一块同控制器的屏,只改引脚表;菜单和界面代码不用跟着重写。
2.1 目录划分与文件职责
工程目录不需要很花哨,但每个文件要有明确边界。下面这种结构适合直接放进常见ARM工程里,编译时把Src加入源文件搜索路径,把头文件路径加入include路径。
19264_lcd_driver/ ├─ Core/ │ ├─ Inc/ │ │ ├─ lcd19264.h │ │ └─ lcd_port.h │ └─ Src/ │ ├─ lcd19264.c │ └─ lcd_port.c ├─ Fonts/ │ ├─ font8x16.c │ └─ font8x16.h ├─ App/ │ └─ main.c ├─ Docs/ │ └─ pinmap.md └─ README.mdlcd_port.c里只放GPIO初始化、RS/RW/E/CS/RST的宏、微秒延时和数据总线方向切换。lcd19264.c里放写命令、读状态、读数据、页列设置、清屏、画点、字符。font8x16.c只存字模数组。main.c只做上电初始化和最小验证。引脚表单独放Docs/pinmap.md,因为接线错误是这块屏最常见的翻车点,血泪经验是不要只把引脚写在代码注释里,改板时很容易漏看。
lcd19264.h对外暴露的函数尽量少:lcd_init、lcd_clear、lcd_draw_point、lcd_show_char、lcd_show_string。片选、页地址、列地址这些细节不要暴露给应用层,否则每个写界面的人都要重新理解KS0108的显存结构,后期维护会变成黑匣子。
2.2 19264屏的引脚映射和片选逻辑
19264模块外部一般会引出D0-D7、RS、RW、E、RST、CS1、CS2、CS3、V0、A/K。CS1到CS3分别对应内部三片KS0108,每片管64列。KS0107是行驱动,模块内部已经接好,外部不需要给它发命令。下面的引脚表是示例,实际板子按原理图改,但片选低有效这一点不要弄反。
| LCD信号 | 方向 | 示例接到 | 说明 |
|---|---|---|---|
| D0-D7 | 双向 | PA0-PA7 | 8位并口数据,读状态时切输入 |
| RS | 输出 | PB0 | 1选数据,0选命令 |
| RW | 输出 | PB1 | 1读,0写 |
| E | 输出 | PB2 | 使能脉冲,下降沿锁存写数据 |
| RST | 输出 | PB3 | 低电平复位 |
| CS1 | 输出 | PB4 | 第一片KS0108,低有效 |
| CS2 | 输出 | PB5 | 第二片KS0108,低有效 |
| CS3 | 输出 | PB6 | 第三片KS0108,低有效 |
| BL | 输出 | PB7 | 背光控制,经三极管或限流电阻 |
| V0 | 模拟 | 电位器中间抽头 | 对比度调节,不要悬空 |
数据口用PA0-PA7是为了后面直接操作GPIOA->ODR和GPIOA->CRL,速度比逐个HAL_GPIO_WritePin快,代码也短。控制脚单独放在PB0-PB7,方便用宏翻转。V0不要直接接电源,也不要悬空,19264屏对比度不对时经常表现为全黑或全白,看起来像驱动没跑起来。背光A/K要按模块电流加限流,不要拿STM32的IO直接灌背光。
片选逻辑是:先把CS1、CS2、CS3全拉高,再根据chip号拉低对应片选。三片都高时总线无效,切换片选后原来的页地址和列地址不会自动保留,所以每次换片后都要重新设页和列。这个细节在画跨片横线时特别重要,否则会出现左边正常、右边错位的现象。
2.3 8080时序为什么用GPIO模拟
KS0108是慢速并口器件,E脉冲宽度常见在几百纳秒到微秒级。STM32的GPIO翻转速度足够快,但快不等于可以不加延时。写命令的时序是:RS和RW先建立,数据上总线,E拉高保持一段时间,E下降沿把数据锁存进控制器。读状态和读数据时,RW拉高,E拉高后数据口有效,E拉低结束。下面这组宏把控制脚和延时收在端口层,应用层不直接写GPIO。
#define LCD_RS_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET) #define LCD_RS_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET) #define LCD_RW_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET) #define LCD_RW_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET) #define LCD_E_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_2, GPIO_PIN_SET) #define LCD_E_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_2, GPIO_PIN_RESET) #define LCD_RST_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_3, GPIO_PIN_SET) #define LCD_RST_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_3, GPIO_PIN_RESET) #define LCD_CS1_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_SET) #define LCD_CS2_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_SET) #define LCD_CS3_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET) #define LCD_CS1_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_RESET) #define LCD_CS2_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_RESET) #define LCD_CS3_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET) static inline void lcd_delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks) { __NOP(); } }lcd_delay_us用DWT周期计数器做微秒延时,比空循环稳定。用之前要在初始化里使能DWT,常见写法是置位CoreDebug->DEMCR的TRCENA,再清零DWT->CYCCNT并启动。参数us按实际E脉冲宽度调,先给1到2微秒,拿示波器看E高电平时间,再决定要不要缩短。没有DWT的芯片可以换成定时器或标定过的空循环,但不要用HAL_Delay(1)代替微秒延时,那个通常是1毫秒,刷一屏会慢到怀疑人生。
为什么不用FSMC?19264是5V慢速屏,FSMC的地址线和数据线复用关系复杂,引脚也不一定刚好凑得齐,GPIO模拟的可移植性更好。FSMC适合大屏TFT,19264这种小点阵屏用GPIO反而容易调。
2.4 驱动分层与接口边界
分层之后,lcd19264.c里不应该出现HAL_GPIO_WritePin(GPIOA,...)这种具体端口代码,所有端口动作都走lcd_port.h的宏或函数。绘图层里不应该直接写0xB8、0x40这种命令,命令只在面板层出现。应用层只调lcd_show_string和lcd_draw_point,不关心当前选中哪一片KS0108。
这个边界带来的实际好处是:把工程从一块板子搬到另一块板子时,先改lcd_port.c里的引脚宏和数据口寄存器,再改lcd_delay_us的时钟基准,最后跑三张测试图。三张图分别是全亮、棋盘格、边框。全亮看显存写和对比度,棋盘格看页内bit顺序,边框看三片拼接和边界点。不要一上来就显示菜单,菜单出问题时很难判断是驱动错还是界面坐标错。
3. 核心驱动拆解:写命令、读忙、画点三条链路
19264的驱动看起来函数很多,真正关键的就三条链路:写命令链路、读忙链路、读改写画点链路。写命令链路把KS0108的页地址、列地址、显示开关送进去;读忙链路保证每次写入前控制器已经消化完上一条命令;画点链路从显存读出当前字节,改一个bit,再写回去。三片KS0108的显存是分开的,所以每条链路都要带片选。
3.1 数据总线方向切换与写命令
数据口PA0-PA7在写的时候是输出,在读状态和读数据的时候是输入。STM32F1的GPIOA->CRL控制PA0-PA7,每个引脚4个配置位。推挽输出50MHz对应每个nibble写0x3,浮空输入对应0x4。下面的写法把低8位配置整体替换,保留PA8-PA15不变。
static inline void lcd_bus_output(void) { /* PA0-PA7 推挽输出,50MHz */ GPIOA->CRL = (GPIOA->CRL & 0xFFFF0000U) | 0x33333333U; } static inline void lcd_bus_input(void) { /* PA0-PA7 浮空输入,读LCD状态和数据 */ GPIOA->CRL = (GPIOA->CRL & 0xFFFF0000U) | 0x44444444U; } static inline void lcd_bus_write(uint8_t data) { /* 只改低8位,保留PA8-PA15 */ GPIOA->ODR = (GPIOA->ODR & 0xFF00U) | data; } static inline uint8_t lcd_bus_read(void) { return (uint8_t)(GPIOA->IDR & 0x00FFU); } void lcd_write_cmd(uint8_t cmd) { lcd_wait_busy(); /* 等当前片KS0108空闲 */ lcd_bus_output(); LCD_RS_L(); /* 命令 */ LCD_RW_L(); /* 写 */ lcd_bus_write(cmd); lcd_delay_us(1); LCD_E_H(); lcd_delay_us(2); LCD_E_L(); /* 下降沿锁存 */ lcd_delay_us(1); }lcd_bus_output和lcd_bus_input是方向切换的核心,读之前必须切输入,读完必须切回输出,否则下一次写命令时数据口还是高阻,屏上会收到随机数据。lcd_bus_write用ODR整体写低8位,比循环写8个引脚快很多,但前提是数据线确实接在同一个端口连续8位上。lcd_write_cmd里的cmd是KS0108命令字节,比如0xB8|page设置页地址,0x40|col设置列地址,0x3F开显示,0x3E关显示。E高电平保持2微秒是保守值,实际可以按屏手册缩到几百纳秒,但先用慢一点把功能跑通。
写数据函数和写命令只差RS电平,RS拉高表示数据。写数据前同样要等忙,写完后列地址会自动加1,这一点在连续清屏时可以直接利用。
void lcd_write_data(uint8_t data) { lcd_wait_busy(); lcd_bus_output(); LCD_RS_H(); /* 数据 */ LCD_RW_L(); /* 写 */ lcd_bus_write(data); lcd_delay_us(1); LCD_E_H(); lcd_delay_us(2); LCD_E_L(); lcd_delay_us(1); }data是显存字节,一个字节控制同一页内纵向8个像素。连续写64个字节会填满当前片的一页,写满64列后列地址会回到0还是停在63取决于控制器行为,所以换页时不要依赖自动回绕,重新设页和列更稳。
3.2 读状态与忙等待超时
KS0108的状态字节最高位D7是忙标志,1表示控制器还在处理上一条命令,0表示可以接收下一条。读状态前要先选片,再切数据口输入,RS=0、RW=1,E拉高后读数据。读完把RW拉低,数据口切回输出。
static uint8_t lcd_read_status(void) { uint8_t status; lcd_bus_input(); LCD_RS_L(); /* 状态寄存器 */ LCD_RW_H(); /* 读 */ lcd_delay_us(1); LCD_E_H(); lcd_delay_us(2); status = lcd_bus_read(); LCD_E_L(); LCD_RW_L(); lcd_bus_output(); return status; } static void lcd_wait_busy(void) { uint32_t timeout = 20000U; while ((lcd_read_status() & 0x80U) && --timeout) { /* 空循环等D7清零 */ } if (timeout == 0U) { g_lcd_error = LCD_ERR_BUSY_TIMEOUT; } }lcd_read_status里的status只需要D7,其他位可以先忽略。lcd_wait_busy一定要有超时,不能写成while(lcd_read_status() & 0x80);,否则屏没接好、片选错、电平不兼容时程序会卡死,现场看起来就是“程序跑飞了”。timeout按主频调,20000次读状态在72MHz下通常够用。g_lcd_error是全局错误码,调试时看它比看黑匣子强。
读状态函数不负责选片,调用前必须已经lcd_select_chip(chip)。这一点容易写错:有人在lcd_wait_busy里又选了一次片,结果把上层刚设好的片选覆盖掉,画点就会出现随机片错位。
3.3 页地址、列地址与三片显存映射
19264的显存按页组织,每片KS0108有8页,每页64列,每列8个像素。整屏坐标(x,y)到控制器地址的映射是:chip = x / 64,col = x % 64,page = y / 8,bit = y % 8。x从0到191,y从0到63。片0管x0-63,片1管x64-127,片2管x128-191。
static void lcd_select_chip(uint8_t chip) { LCD_CS1_H(); LCD_CS2_H(); LCD_CS3_H(); if (chip == 0U) { LCD_CS1_L(); } else if (chip == 1U) { LCD_CS2_L(); } else { LCD_CS3_L(); } } static void lcd_set_page_col(uint8_t page, uint8_t col) { lcd_write_cmd(0xB8U | (page & 0x07U)); lcd_write_cmd(0x40U | (col & 0x3FU)); }lcd_select_chip先把三片全关再开目标片,避免两片同时选通。lcd_set_page_col里page只取低3位,col只取低6位,这是KS0108命令格式决定的。0xB8是设置页地址的基命令,0x40是设置列地址的基命令。起始行命令0xC0一般初始化时发一次,不需要每页都发。KS0107负责行扫描,外部不用单独设行地址。
三片拼接时,画横线跨过x=63、x=127会触发片切换。切换片后必须重新设页和列,不能沿用上一片的列地址。画竖线跨页时,每页的bit位置不同,需要读改写。下面这张映射表在调试时很有用,可以贴在工位上。
| 坐标范围 | 片选 | 列地址 | 页地址 | 页内bit |
|---|---|---|---|---|
| x0-63 | CS1 | x | y/8 | y%8 |
| x64-127 | CS2 | x-64 | y/8 | y%8 |
| x128-191 | CS3 | x-128 | y/8 | y%8 |
3.4 读改写画点与字符显示
KS0108没有“设置像素”命令,画一个点必须先把该点所在字节读出来,改对应bit,再写回去。读显存有一个坑:第一次读出来的是空数据,第二次读才是有效数据,这就是常
本文还有配套的精品资源,点击获取