1. 这块屏到底是什么来头
做嵌入式这几年,经手的液晶屏少说也有几十种了,SSD1306、ST7565、ILI9341这些主流驱动芯片见得多,拿到手册基本能闭着眼写驱动。但第一次见到型号“X154-2864KSWBG01-C24”这串字符的时候,我还是愣了一下——查遍手头常备的芯片资料也没对得上号,最后翻了半天厂家给的规格书才恍然大悟:这屏用的是CH1116驱动芯片,128×64点阵,单色,白底黑字,带背光。说实话,CH1116这颗芯片在国内用的人不算多,资料比SSD1306少一大截,所以当时决定把整个驱动过程记录下来,方便后面再做同类项目时有个参考。
先把这个型号拆开讲。“X154”大概率是产品系列代号,“2864”指的是128×64分辨率——28和64这两个数字在行业内几乎是约定俗成的标识,看到它你就能知道屏幕的点阵规模;“KSWBG01-C24”后缀里,K和S可能是封装或连接方式标识,WBG要留意是不是“White Backlight Glass”之类的缩写,C24推测是排线接口、24PIN或者2.54mm间距这类结构参数。我当时确认到的是,这块屏支持8位并行、SPI、I2C三种接口模式,通过背面的电阻跳线选择,实际项目里我选了SPI模式,因为STM32的SPI外设跑起来又快又省引脚。
说实话,市面上绝大多数同类12864屏用的是SSD1306或者ST7565,CH1116的寄存器映射和这两者都不一样,哪怕你照着SSD1306的驱动框架去写,大概率也是一通乱码加白屏。所以这篇文章不会只贴一段代码让你复制了事——我会把CH1116和常见芯片的差异、初始化序列里每一条命令为什么必须写、绘图缓冲区的内存布局怎么规划,全掰开揉碎讲一遍。
这块屏适合谁来用?如果你在做一个低成本的仪器仪表界面、一个手持设备的中文菜单,或者只是想在小体积屏幕上显示传感器波形,那它足够满足需求,并且价格友好、供货也还算稳定。而这篇博文适合的读者至少需要具备一点STM32的基础——能点个灯、会配置GPIO,知道怎么用Keli或者STM32CubeMX勾几个选项。完全零基础的话,建议先跑一个GPIO输出的简单工程再来看。
2. 先搞清楚硬件再动手写代码
2.1 CH1116到底是个什么芯片
CH1116是一颗单色点阵LCD驱动控制器,最大支持128×64分辨率的COG(Chip On Glass)液晶屏,内置64行公共驱动和128列段驱动,核心工作就是接收主控发来的显示数据,写入内部显示RAM,然后持续刷新到玻璃基板的电极上。
说到这儿,很多人会拿它和SSD1306对比,毕竟SSD1306是OLED屏的主流驱动,而CH1116多用在带背光的LCD段位屏上。这俩的指令集完全不兼容,千万不要想当然地拿SSD1306的初始化数组直接往CH1116上怼。我在调试过程中还真犯过这个错误——第一次上电直接把SSD1306的代码改了个接口就烧进去,结果屏幕一片雪花,花了大半天排查才发现是初始化序列从一开始就错了。
还要提醒一个关键点:CH1116芯片本身支持1.6V到3.3V的IO电压范围,但很多模块板载了升压电路和逻辑电平转换,实测屏幕模块的供电脚往往允许3.3V到5V。我的建议是,无论你的MCU是3.3V还是5V供电,都仔细确认模块背面的跳线配置,不要盲目怼上5V到底,免得把背光LED或者逻辑部分烧了。
2.2 引脚定义和接线方案
我拿到的这块屏是24PIN排针接口,拆开丝印看了一遍引脚定义:电源正负极、背光正负极、复位、命令/数据选择、片选、串行时钟、串行数据,以及并行模式用的DB0到DB7。用CH1116驱动时,最常用的是4线SPI模式占用CS、SCL、SDA、DC四根信号线加一根复位RST,一共六根线就能驱动起来。
我实际选择的是STM32的硬件SPI1,把引脚分配为:
| 屏幕引脚 | 功能 | STM32引脚分配 |
|---|---|---|
| VDD | 模块供电正 | 3.3V(或按模块标注接5V) |
| VSS | 数字地 | GND |
| BL+ / BL- | 背光供电 | 同VDD/GND,或经三极管开关 |
| RST | 硬件复位,低有效 | PB0,推挽输出 |
| CS | 片选,低有效 | PB1,推挽输出 |
| DC | 命令/数据选择,低为命令 | PB10,推挽输出 |
| SCL | SPI时钟线 | PA5,复用SPI1_SCK |
| SDA | SPI数据线 | PA7,复用SPI1_MOSI |
这里有个很多新手容易犯的错误:SPI模式下屏幕的SDA脚是主控的MOSI单向写数据,不需要把屏幕数据回传到MCU,所以MISO引脚根本不用接。可我就是吃过亏的——某次以为SPI有来有回,把MISO连上了,结果配置GPIO时多配了一个引脚,程序死活用不了,排查半天发现是引脚复用冲突,把SPI1的MISO误配成了普通输入,导致时钟波形异常。所以SPI屏只写不读时,就老老实实把MISO空着。
另外一个实操建议:背光最好不要直接和主控电源绑死,尤其是你想实现“按一下按键屏幕亮一阵”这种省电逻辑时,用一个NPN三极管或者P-MOS管做开关控制BL+更合适。我在项目里用了S8050三极管驱动背光,基极接一个STM32引脚,高电平点亮,功耗控制效果好很多。
2.3 供电与上电时序的优先级
接线上还有个容易被忽略但极其重要的细节:LCD模块的上电时序。芯片规格书里明确写了,VDD上电后至少要等待一段时间再拉高复位引脚,如果MCU和屏幕共用一个电源轨,而STM32复位时GPIO默认状态不确定,屏幕初始化很容易失败。
我的方案是在硬件上让屏幕的RST先由MCU的一个普通GPIO控制,上电后延时10ms再把RST拉高,初始化序列里再补一个软复位。硬件上也可以把RST和MCU复位引脚连在一起,但那样软复位就没法独立控制了,调试时少了一个手段,所以个人不太推荐。
3. 驱动程序设计的分层思路
3.1 接口抽象层:让代码不绑定具体MCU
驱动代码我习惯分成三层:
- 第一层是硬件抽象,负责SPI收发、GPIO拉高拉低、延时;
- 第二层是芯片驱动,封装CH1116的命令写入、数据写入、初始化序列、设置窗口函数,对外提供基础接口;
- 第三层是功能层,实现画点、画线、画矩形、显示字符串、显示图片,基本做到通用的绘图API。
这样分层有一个明显的好处:如果你后面把主控从STM32F1换成STM32L4,甚至从STM32换到GD32或者ESP32,只需要改最底层的硬件抽象函数,芯片驱动和功能层完全不用动。这类带COG液晶屏的驱动逻辑其实高度相似,做好抽象之后复用一个驱动框架能省很多功夫。
底层我写了这么几个函数:
static void lcd_spi_write(uint8_t data) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, data); }这条函数就是把一个字节通过硬件SPI发出去。选用硬件SPI而不是GPIO模拟SPI的最直接原因是速度——STM32的SPI1在PCLK2分频后轻松跑到几个兆赫兹,刷全屏一帧128×64字节也就是毫秒级的事情;GPIO模拟SPI,即使主频72MHz,靠翻转IO推字节数据,刷一帧的速度也明显慢不少。而且硬件SPI可以配合DMA,后续你想让CPU去处理别的事情、显示数据在后台自动刷新,DMA是绕不开的选项。
3.2 命令和数据到底怎么区分
CH1116使用的是“命令/数据”双通道模式,屏幕上有一个DC引脚就是干这个用的。DC为低电平时,SPI总线上传输的字节被芯片当作控制命令,比如设置页地址、设置列地址、设置对比度;DC为高电平时,字节直接写入显示RAM,对应屏幕上具体的点阵像素。
所以驱动代码里两个最核心的函数就是:
static void lcd_write_cmd(uint8_t cmd) { LCD_DC_LOW(); lcd_spi_write(cmd); } static void lcd_write_data(uint8_t data) { LCD_DC_HIGH(); lcd_spi_write(data); }两个函数看起来简单,但是它们会贯穿整个驱动开发过程。后续所有显示操作——清屏、画点、显示字库——都是在这两个函数基础上叠加出来的。如果你在屏幕显示里看到“内容错位”或者“图形漂移”,十有八九是DC时序出了问题,或者在某次传输中DC电平状态和字节含义没有配对。
这里需要补充一个细节:ST的硬件SPI库函数在发送数据之前会检查发送缓冲区标志位,如果你用了不同的库版本或者HAL库,名称可能会变成__HAL_SPI_FLAG_STATUS之类的写法,本质都是一样的。有些项目为了省事直接上GPIO模拟SPI,但我的体会是模拟SPI在200kHz以下的低速场合也够用,而CH1116 SPI模式时钟速率可以到10MHz甚至更高,硬件SPI的上限明显更高,所以能上硬件就上硬件。
3.3 页面寻址和显示RAM的关系
CH1116的内部显示RAM有1024字节,组织方式是8页,每页对应8行像素,每页128字节对应128列。换句话说,屏幕上的一个像素点对应RAM中的一个比特位,而RAM地址是按照“页(8行垂直)+列”来编址的——这种页寻址方式在ST7565上也很常见,但跟SSD1306的页寻址细节有差别。
往里写数据时,先通过命令设置当前页地址和列地址,然后连续写入的数据会依次填满这一行的128个字节,地址指针自动递增,到本页行尾后是否自动换页取决于控制位设置。理解了这一点,写填充算法之前就能明白为什么要严格管理页地址和列地址的进退场。
举个例子:你想在屏幕左上角第一行显示一个8×8的字符,字符的每一行像素对应显示RAM的一个字节,你需要先把页地址设置为第0页,把列地址设置为0,然后连续写入8个字节。写完这8个字节,列地址自动走到8,你继续写入,就会往屏幕右侧继续排列。所以显示英文字符其实是一个非常机械的过程——把字模数据通过数据通道挪到RAM里,屏幕就给你画出对应的点阵图形。
再说说读回问题:CH1116在SPI模式下能不能读RAM?规格书里说支持读操作,但需要额外的控制时序且速度不快。大多数实际应用根本不需要读屏,因为你自己代码里维护一份同样的缓存副本就足够了。我在驱动结构体里定义了一个uint8_t screen_buffer[1024]的数组,所有绘图操作先改缓存,再刷新到屏幕或者只刷新局部区域,这样既能避免读屏的麻烦,也为后续做局部刷新、动画效果打下基础。
4. 初始化序列背后的每一个“为什么”
4.1 一条一条拆解初始化命令
CH1116的初始化说简单也简单,说坑也多。官方手册列了一串命令,很多人直接照抄,但你要是不理解每条是干嘛的,遇到问题就没法自己调。
我用的初始化序列经过反复验证,调试多块屏实测没有任何问题:
void lcd_init(void) { LCD_RST_LOW(); delay_ms(50); LCD_RST_HIGH(); delay_ms(50); lcd_write_cmd(0xE2); // 软复位 delay_ms(100); lcd_write_cmd(0xAE); // 关显示 lcd_write_cmd(0x2F); // 内部升压电路开启(如果模块不带电荷泵则跳过) lcd_write_cmd(0x81); // 设置对比度命令 lcd_write_cmd(0x3F); // 对比度值,可调范围0x00~0xFF lcd_write_cmd(0xA0); // 列地址递增方向正常 lcd_write_cmd(0xA2); // 偏置电压:1/9偏置 lcd_write_cmd(0xC8); // 列扫描方向反转,适配视觉习惯 lcd_write_cmd(0xA4); // 关闭整屏显示(显示RAM正常控制) lcd_write_cmd(0xA8); // 设置复用比 lcd_write_cmd(0x3F); // 128×64对应1/64复用 lcd_write_cmd(0xAF); // 开显示 }命令0xE2是软复位,能把芯片内部所有寄存器恢复到默认状态。这块一定要放在最前面,可以消掉上电瞬间的随机状态。0xAE和0xAF是显示开关的总阀门,初始化过程里先关显示,全部配置完毕后再打开,能避免配置过程中屏幕出现不稳定的闪烁现象。
0x81加紧随其后的0x3F是设置显示对比度。CH1116是一个幅值比较型驱动方案,对比度值越大,LCD玻璃上施加的驱动电压越高,显示越深。我用0x3F作为默认值,如果屏幕偏淡,可以试着加大到0x50甚至0x80。但不要一开始就拉到最大,太大的对比度会带来残影和拖尾,尤其在温度变化环境里问题更明显。
0xA0控制列地址递增方向和段输出方向。0xC8则是设置COM扫描方向从第一行开始还是从最后一行开始。这两个组合起来有四种效果:屏幕会镜像显示或者倒置显示,甚至可能出现你写“你好”它显示成左右颠倒的“好你”。我习惯用0xA0加0xC8这个组合,因为大多数COG模块把玻璃的引脚定义成这样的时候,显示方向正好符合肉眼习惯。如果显示出现镜像问题,优先检查这两个命令。
0xA8加0x3F设置的是复用比。128×64屏就是1/64复用。这个值如果配错,最明显的症状是画面只有一部分区域能点亮,或者整屏亮度不均匀。0xA4命令是关闭整屏显示强制点亮模式,保证RAM中数据决定显示内容而非所有像素同时亮起。0x2F命令我斟酌过几次——部分模块不包含电荷泵升压电路,这条命令写不写影响都不大;但如果模块没有逻辑转换电路、需要芯片内部升压提供LCD驱动电压,那必须打开。最好直接看模块背面的电源电路上有无电感电容组合,如果没有,跳过0x2F这类的升压开启命令反而能让屏幕更稳定。
4.2 为什么软复位和延时这么讲究
初始化命令之间的延时,看着不起眼,实际很影响上电成功率。芯片手册里虽然没有逐条标注延时要求,但电源上电后内部电路建立稳定电压需要时间,软复位命令发出之后,指令解析也需要一定时间。我实测下来,硬件复位拉低保持50ms,再拉高等50ms,然后发软复位命令再等100ms,这种节奏最稳。
另一个理由是电源质量差异。如果你用的是开关电源给整个系统供电,上电瞬间可能有一个短暂的电压爬坡过程,MCU复位完成远快于LCD模块内部电压稳定。如果你在屏幕还在抖动的时候就火急火燎地发命令,初始化序列大概率会被芯片当噪声忽略掉一部分。加延时不是玄学,是对硬件物理特性的尊重。
5. 绘图核心:从画点到显示字符串
5.1 画点和清屏的组合拳
画点的本质是操作“屏幕缓冲数组”中某一位。我先定义了一个二维数组视角:buffer[page][column],其中page从0到7,column从0到127。
画点函数实现:
void lcd_draw_point(uint8_t x, uint8_t y, uint8_t pixel_on) { uint8_t page = y / 8; uint8_t bit_mask = 0x01 << (y % 8); uint16_t index = page * 128 + x; if (x > 127 || y > 63) return; if (pixel_on) screen_buffer[index] |= bit_mask; else screen_buffer[index] &= ~bit_mask; }这里注意一个物理方向问题。坐标原点定义在屏幕左上角,x向右递增,y向下递增。但是如果直接把RAM的字节位序和屏幕行方向对应起来,不同模块的COG装配方向会导致y坐标反转。在功能层我把它做成可配置项,通过一个宏定义或者结构体参数决定是否交换COM扫描顺序,这样换屏、换模块批次时不需要改动上层代码。
清屏有两种方式:一种是直接对屏幕缓冲数组清零,然后把整个数组的内容刷新到屏幕;另一种是直接对屏幕RAM连续写入1024个0x00。我推荐先清buffer再调用lcd_refresh()全量刷新。单独清buffer效率高,然后一次刷新就能同步RAM,避免边清边写导致屏幕呈现“一半亮一半擦除”的闪烁区间。
5.2 字符显示和字模数组
显示字符串需要一张字模表。英文字符用8×8点阵就够用了,字模数据按照每行一字节、从上到下排列,一个字符占8个字节。我用的字模数组类似这样:
const uint8_t font_8x8[][8] = { {0x3E, 0x51, 0x49, 0x45, 0x3E, 0x00, 0x00, 0x00}, // '0' {0x00, 0x42, 0x7F, 0x40, 0x00, 0x00, 0x00, 0x00}, // '1' {0x42, 0x61, 0x51, 0x49, 0x46, 0x00, 0x00, 0x00}, // '2' // ... };显示函数的核心逻辑是遍历字符串每个字符,把对应字模数据复制到屏幕缓冲区:
void lcd_show_string(uint8_t x, uint8_t y, const char *str) { uint8_t ch; while (*str) { ch = *str - '0'; // 这里根据具体字模表调整 for (int i = 0; i < 8; i++) { lcd_draw_byte(x, y + i, font_8x8[ch][i]); } x += 8; str++; } lcd_refresh(); }注意这个伪代码直接把字符编码减去’0’是偷懒行为,实际做的时候要看你的字模表索引方式。如果需要显示ASCII码表全集,最好直接用ASCII - 32做索引;如果只要数字和几个符号,也可以搞一张精简表。要显示中文的话,8×8点阵不够清晰,常规做法是16×16点阵,一个汉字占32字节,字模提取用PC端取模软件就能搞定,比如某款经典的字模生成工具,左侧画图、右侧自动生成数组,注意选择“列行式”扫描方式还是“行列式”,不同方式提取出来的字节顺序完全不同,代码里必须匹配才能正确显示。
还有一个容易踩的坑:显示字符时直接用8的倍数规划坐标,比如x=0、8、16,这样整齐省事。中文字库16×16时坐标按16对齐。如果非要让字符显示在任意坐标点,需要做跨越页边界的处理,逻辑复杂度会陡增,但很多场景没必要——毕竟显示标题和数值,对位置精度要求没那么苛刻。
5.3 刷新策略:全量更新还是局部更新
全量刷新就是把buffer里1024字节全部推送到屏幕,代码很短:
void lcd_refresh(void) { for (int page = 0; page < 8; page++) { lcd_write_cmd(0xB0 + page); // 设置页地址 lcd_write_cmd(0x00); // 设置列地址低4位 lcd_write_cmd(0x10); // 设置列地址高4位 for (int col = 0; col < 128; col++) { lcd_write_data(screen_buffer[page * 128 + col]); } } }在72MHz主频下,这个全量刷新大约3到5毫秒,肉眼感知不到闪烁,完全够用。但如果你做的是动态波形显示或者菜单动画,每秒刷新几十次全屏,MCU开销还是在的。省流方案是在功能层提供一个脏矩形标记:记录哪些区域发生了修改,刷新时只重发对应页和列范围的数据。比如一个数值从“123”变到“456”,只刷新那24×8像素的区域就够了,局部刷新的速度提升非常明显。
6. 踩坑实录与排查速查表
6.1 白屏问题:先别怀疑代码,测硬件
白屏是这类屏最常见的故障,我第一次点亮这块屏时也遇到了一肚子火。屏幕白茫茫一片,什么内容都没有。排查顺序比代码本身更重要。
先量电压。VDD引脚电压必须在芯片工作范围内,很多模块标称3.3V,但如果你从STM32板子的3.3V引脚取电,而那一路被其他外设分压拖垮,屏幕就起不来。用万用表量模块背面的电容两端,如果电压低于3.0V,大概率是供电不足。换独立供电试试,LCD的VDD和背光分开供电。
然后是复位时序。如果RST一直被拉低,芯片就永远处于复位状态,屏幕当然白屏。有个速查技巧:上电后用示波器或者逻辑分析仪看RST引脚有没有出现一次低电平然后拉高的动作。没有波形就查MCU的GPIO配置是不是配错了。
最后考虑初始化序列有没有执行到0xAF。有些代码把开显示命令放在所有配置之前,其实也能工作,但如果你中途死机或者配置没跑完,屏幕就保持关闭状态。我给这种屏做量产测试时,习惯在初始化之后立刻填充一个测试图案再判断成功与否,比单独看白屏可靠得多。
6.2 花屏和乱码排查
花屏的典型症状是屏幕上出现无规律的短线、噪点或者整块区域闪动。造成花屏的原因常见的有三个方向。
第一个是SPI时序不匹配。CH1116的SPI时钟最高频率虽然标称能达到10MHz量级,但长排线、杜邦线走线环境下信号完整性很差,时钟频率一高,数据就容易错。实测用杜邦线连接时,SPI时钟分频到2.25MHz左右很稳,再高就会出现随机花屏。换成刷新频率低一些也不见好转的话,查波形上升沿是否过缓。
第二个是DC引脚和CS引脚时序没有严格区分。有些代码复用了一个引脚控制CS,忘了在每次发送字节前后把CS拉低再拉高,芯片就会把相邻命令和数据混在一起。我的习惯是CS全程拉低,依靠DC在命令和数据之间切换,只在片选切换或者多设备共总线时才操作CS,这个方式对单设备场景非常省心。
第三个是缓冲区同步问题。如果你在修改缓冲区的时候被中断打断了,而恰好此时又执行了局部刷新,就会把半截数据发送到屏上,产生画面撕裂。解决方法是刷新前临界区保护,或者用原子操作更新缓冲索引。实时性要求高的项目,我建议把刷新移到低优先级任务里,确保不会打断写缓冲的过程。
6.3 显示方向镜像和字体颠倒
刚调通显示后,我看到屏幕上显示的内容完全正常,但把整个屏旋转180度反过来看,文字变成了上下颠倒。如果你遇到显示内容镜像,也就是左右颠倒的情况,百分之九十是命令0xA0和0xC8的组合问题。
对照表:
| 现象 | 0xA0/0xA1 | 0xC0/0xC8 |
|---|---|---|
| 显示正常 | 0xA0 | 0xC8 |
| 左右镜像 | 0xA1 | 0xC8 |
| 上下颠倒 | 0xA0 | 0xC0 |
| 上下颠倒且左右镜像 | 0xA1 | 0xC0 |
同一型号的玻璃基板不同批次,可能装配方向不一样,做产品时最好用一个宏控制这个组合,别在代码里写死。我当时在第二块裸屏上就发现镜像问题,后来统一改成宏定义之后,换屏只改一个头文件,再没出过这种事。
6.4 对比度不理想和温度漂移
液晶屏的对比度受温度影响很大。嵌入式设备里,如果屏幕用在户外,冬天温度降到零下,常用对比度值下显示内容会很淡。原因在于液晶材料的阈值电压随温度变化,温度低时需要更高的驱动电压才能让笔画清晰。CH1116的偏置电压和对比度寄存器都是可调的,但没有内置温度传感器,需要主控MCU配合做补偿。
我的做法是预留一个接口函数,从温度传感器读取环境温度,通过查表映射出合适的对比度值,写入0x81命令。温度每下降10摄氏度,把对比度值上调10到20个单位,实测显示效果稳定不少。当然如果你的产品全在室内,这个功能可以不做,但它是一个很好的亮点功能。
6.5 常见问题速查表
| 故障现象 | 可能原因 | 排查方向 |
|---|---|---|
| 白屏无任何内容 | 供电不足、复位异常、未开显示 | 量电压、查RST波形、确认0xAF已发送 |
| 花屏噪点 | SPI时序不稳、供电纹波大 | 降低SPI分频、加100nF去耦电容、缩短杜邦线 |
| 显示镜像 | 扫描方向命令配置不对 | 切换0xA0/0xA1,0xC0/0xC8 |
| 文字乱码 | 字模字节序不匹配 | 核对取模软件扫描方式 |
| 局部内容闪烁 | 刷新区域未对齐字节边界 | 确保x坐标按8取整 |
| 屏幕越来越暗 | 背光LED衰减或升压电路异常 | 换背光驱动方式,检查限流电阻 |
| 上电偶尔白屏 | 时序竞争或电源建立慢 | 加长复位低电平时间到100ms |
| 画面有残影 | 对比度太高或液晶老化 | 调低对比度,检查工作电压 |
7. 扩展方向:DMA刷新和多级菜单
驱动跑通之后,很多人就不满足了,想把屏幕用得更优雅一点。这块屏在SPI模式下可以配合STM32的DMA,实现“CPU只管写缓存,DMA自动搬运到屏幕”,刷新一屏几乎不占CPU时间。需要做的是在SPI外设初始化时使能DMA发送请求,然后刷新函数改成:
void lcd_refresh_dma(void) { DMA_Cmd(DMA1_Channel3, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, 1024); DMA_Cmd(DMA1_Channel3, ENABLE); }使用DMA时,所有写入屏幕的数据来源必须是一个连续内存数组,所以我前面特意设计了screen_buffer[1024]这个结构。DMA传输过程中不能修改缓冲区内容,否则会把半新半旧的数据刷上去,这是一个很容易让人忽略的坑,我当时就因为在DMA未完成时动了缓存,导致波形图上偶尔冒出一个错误的横线。
多级菜单的实现也可以基于我这套分层设计:应用层维护一个菜单状态机,渲染函数根据当前状态写屏幕缓冲,刷新策略选用局部刷新保证按键响应不掉帧。在128×64的分辨率下,一行显示8个英文字符、4个汉字,足够做一个像样的设备菜单界面了。指针图标、倒三角符号、二级菜单缩进,这些都是点阵绘图函数顺手就能画的。
8. 写在最后的经验沉淀
CH1116这块芯片驱动说实话并不复杂,相比ILI9341那类彩色屏,它简单得像个教科书案例——接口清晰、指令有限、内存模型规整。但恰恰因为它资料少、网上示例代码稀缺,从零开始翻手册写驱动反而让我把很多平时忽略的底层细节彻底搞懂了。让我印象最深的是明白了一件事:写外设驱动,最忌讳的就是照搬别人的初始化数组而不问为什么。每一条命令背后都有硬件工作机制的约束,搞懂它,你才能真正驾驭这颗芯片,而不是靠运气点亮屏幕。
最后分享一个小技巧:调试LCD驱动时,准备一个逻辑分析仪比准备一台示波器往往更高效。SPI这种低速串行协议,逻辑分析仪的协议解码功能可以非常直观地显示每一帧数据的字节内容,命令和数据是否混发一目了然。我第一次排查花屏问题,就是靠逻辑分析仪抓到SPI总线上的一帧数据里混进了多余的0xAE命令,才定位到DC引脚配置出了错。这类工具百来块钱就能入手,做嵌入式的话迟早会用到。