简介:一套面向UC1698液晶控制器的电表显示方案资料,适合嵌入式开发者和Arduino/STM32爱好者,用于实现电表图形界面的驱动与定制,尤其适用于智能电表、工业仪表等需要图形显示的场景。方案将UC1698与Arduino、STM32平台结合,覆盖显示初始化、绘图、文本更新、串行/并行通信等关键环节,可帮助读者快速掌握点阵液晶屏在仪表类设备中的集成方法,并理解UC1698的寄存器配置与显示时序控制。压缩包共183个文件,约1.15MB,以91个.h头文件、69个.c源文件、15个汇编启动文件为主,另有Keil工程文件(.uvprojx/.uvoptx)、STM32CubeMX配置(.ioc/.mxproject)及Arduino示例.ino,目录结构清晰,适合直接移植或二次开发。目前已有454人学习下载,对于想要深入理解UC1698驱动原理、对比Arduino与STM32两种控制方式、或搭建电表显示界面的开发者,是一份高性价比的参考资料;也可作为智能电表、工业仪表等人机界面开发的入门案例。
1. uc1698 与 Arduino、电表搭在一起,先解决的不是代码,而是一块屏的像素是谁在管
UC1698 不是一块屏,而是一片 LCD 控制器芯片;电表项目里说的“uc1698_arduino_电表”,通常指一块以 UC1698 作为主控的 128x64(或更大)单色点阵屏,接在 Arduino 上显示电压、电流、电量这类参数。很多人一上来先翻 SPI 示例,把 MOSI、SCK 接好就以为能出字,结果要么全黑,要么淡得看不清。我见过最快的定位方式恰恰相反:先问自己手上模组的 RAM 排列是列扫描还是行扫描,再把控制器、模组、驱动三层拆开。这篇文章按我自己做电表显示时的顺序讲:选型依据、接线和初始化、自绘画点、数字与中文刷新,最后是五条容易翻车的批量一致性检查点。新手可以照着接线跑通,做过多年的工程师也能在刷新策略和对比度调参上省下一两次返工。
2. UC1698 控制器、点阵模组与电表屏幕选型:先分清谁在发指令
2.1 控制器还是模组:引脚、初始化序列到底该以谁为准
UC1698 是一片支持较大分辨率、驱动段数较多的单色 STN 控制器。做成模组之后,玻璃、偏光片、背光、FPC 以及升压/偏压电路都被屏厂集成在了一起。所以在 Arduino 侧看到的是 VDD、GND、CS、DC、RST、SCL、SDIN、LEDA 这种接口,而不是芯片本身的段/公共端引脚。同是“UC1698 屏”,128x64 和 160x160、1.8 寸和 2.7 寸,FPC 引脚顺序可能完全不同。买屏时最应该先确认的是模组规格书里写的那套电源电压、逻辑电平和 SPI 时序参数,而不是去背 UC1698 datasheet 里所有寄存器。项目里常出现“按另一个型号初始化结果白屏”的情况,就是因为把控制器级初始化直接套在了模组级电路上。
我一般拿到一块新屏会先做两件事:查规格书里推荐的上电时序,比如 RST 低电平保持时间、VDD 稳定到 CS 片选有效的最小间隔;再找厂商给的初始化例程,哪怕它是 STM32 或 51 的也没关系,翻译成 Arduino 时只改 SPI 发送函数,初始化序列基本原样保留。UC1698 这类控制器对偏压比、温度补偿这类专有命令比较敏感,显示淡或者有残影,往往不是代码写错,而是这几条命令和屏厂玻璃不匹配。控制器 datasheet 里写的是“最大可支持”,屏厂规格书里写的才是“这块玻璃必须这么驱动”,优先级要清楚。
2.2 电表场景为什么用这种单色点阵屏,而不是 OLED 或 TFT
如果只在室内做原型,OLED 更省事:I2C 两线、亮度高、示例多。但往电表场景走,UC1698 这类单色 STN 点阵屏有三个实实在在的优势。第一是静态功耗,STN 在画面不变时只需维持行列电压,OLED 每个像素仍然靠 PWM 持续驱动,静态显示还会伴随老化差异;第二是阳光可读性,配电房、台区表箱外常有强光,带背光的 TFT 在户外表现不如反射型或半透型单色屏;第三是内容改版灵活度,段式 LCD 要改一句文字就要重新开模,点阵屏只要改字库和坐标就能改界面。电表需要长时间固定显示电压、电流、功率、电量,单位标签几乎不变,只有数字在跳,这条边界正好适合 128x64 这类分辨率。
成本上也存在梯度:TFT 彩色模组比同尺寸单色点阵贵,OLED 在长寿命、高低温循环上不如成熟 STN 工艺稳妥。当然,UC1698 模组多数是 COG 封装,对装配、压屏的要求比老式 COB 段式屏高,在价格敏感的计量产品里还要综合评估。这个选型结论反过来也解释了为什么“uc1698_arduino_电表”这类项目会反复出现:需求是真实存在的,但现成的完整方案不多,大部分人要从画点和初始化开始拼。
2.3 驱动方案的选型边界:U8g2、屏厂驱动代码和自绘驱动从哪里介入
选驱动方案时,我会按项目阶段分三条路走。快速原型阶段,先看 U8g2 库的设备列表里有没有现成的 uc1698 条目。U8g2 支持大量不同控制器和总线组合,页面模式适合 RAM 小的板子,全缓冲适合对刷新有要求的板子。如果库管理器里搜索不到对应构造,或者屏厂给的初始化代码里有 U8g2 没暴露的专有命令,就要走第二条路:把屏厂例程的初始化数组原样搬到 Arduino,配上最基本的画点函数。第三条路是自绘驱动,自己做 page/column 到显存地址的映射,适用场景是电表显示内容固定、刷新频率明确,不需要一个膨胀的 GUI 层。
这个选型顺序也解释了为什么先要读懂接线和控制器,而不是一上来就 include 库。U8g2 长在“控制器抽象”上,但它不会知道你屏厂在初始化里加了几条专用偏压参数;自绘驱动长在“像素排列”上,但需要你在取模和刷新上多花时间。电表这种产品,长时间跑、显示内容有限,我一般会以自绘为核心,U8g2 只在前期对比字体或快速出图时用。二者并不互斥,后面第 4 章的界面代码会从自绘和库两个入口都讲一版。
3. 用 Arduino 点亮 UC1698:最小接线、画点函数与初始化排障
3.1 引脚定义与 Uno/ESP32 接线表
最常见的 UC1698 模组引脚可以归纳为:电源、地、片选 CS(有的标 CHIP SELECT 或 CSB)、命令数据选择 DC(有的标 A0、D/C)、复位 RST、时钟 SCL/SCK、数据 SDIN/SDI,以及背光 LEDA。极少数模组支持并口或 3-wire SPI,这时没有 DC 引脚,命令/数据切换靠发送字节序列控制,接线上更简单,但初始化逻辑不同。下面这张表以 4 线 SPI 为例:
| 模组引脚 | 含义 | Arduino Uno | ESP32/ESP32-S3 |
|---|---|---|---|
| VDD | 逻辑电源 | 接 3.3V(以规格书为准) | 3.3V |
| GND | 地 | GND | GND |
| CS | 片选 | D10 | GPIO5 |
| DC | 命令/数据选择 | D9 | GPIO17 |
| RST | 复位 | D8 | GPIO16 |
| SCL | SPI 时钟 | D13 | GPIO18 |
| SDIN | SPI 数据 | D11 | GPIO23 |
| LEDA | 背光正极 | 串 100~220Ω 后接 5V | 串 100~220Ω 后接 3.3V |
这里有几个容易踩的细节。Uno 的 D13/D11 是硬件 SPI 引脚,如果你在代码里用 Software SPI,任意数字 IO 都可以;但建议一开始就用硬件 SPI,稳定性好。ESP32 的 SPI 引脚可映射,上表只是常用默认,具体以你板子固件为准。LEDA 是背光,很多屏直接接了会拉低电压导致启动瞬间复位,这在最后一章还会专门讲。VDD 电压必须看模组丝印,标 3.3V 的接 5V 可能直接烧控制器,标 5V 的接 3.3V 则可能出现显示极淡、驱动不足。
CS 单独占用一个引脚很重要:如果把它直接接地,意味着片选一直有效,控制器会把所有外部 SPI 数据都当成本屏的数据,其他传感器会串联干扰。DC 引脚决定了命令和数据的分流,初始化时第一帧发错,后面整屏内容都会错位。接线完成后先别写长代码,用下面的最小初始化函数,先做“关显示、开显示”测试,能切黑底和屏幕亮底,说明 SPI 通路基本通了。
3.2 一个不依赖库的画点与区域刷新代码
下面这段代码绕开库抽象,把 UC1698 当作 128x64 的“按页寻址”像素阵列来驱动。适用于你已经拿到模组初始化序列的场景。
#define CS 10 #define DC 9 #define RST 8 #define SCLK 13 #define SDIN 11 uint8_t fb[128][8]; // 128 列 x 8 页,每页 8 像素,总共 1KB void lcdWriteCmd(uint8_t c) { digitalWrite(DC, LOW); // DC=0 表示命令 lcdWriteSpi(c); } void lcdWriteData(uint8_t d) { digitalWrite(DC, HIGH); // DC=1 表示数据 lcdWriteSpi(d); } void lcdWriteSpi(uint8_t v) { digitalWrite(CS, LOW); for (int i = 7; i >= 0; i--) { digitalWrite(SCLK, LOW); digitalWrite(SDIN, (v >> i) & 0x01); digitalWrite(SCLK, HIGH); } digitalWrite(CS, HIGH); } void lcdSetPage(uint8_t page) { lcdWriteCmd(0xB0 | (page & 0x07)); // 页地址 0~7,对应纵向每 8 像素一组 } void lcdSetColumn(uint8_t col) { lcdWriteCmd(0x10 | ((col >> 4) & 0x0F)); // 列地址高 4 位 lcdWriteCmd(col & 0x0F); // 列地址低 4 位 } void lcdSetPixel(uint8_t x, uint8_t y, bool on) { if (x >= 128 || y >= 64) return; uint8_t page = y >> 3; uint8_t bit = y & 0x07; if (on) fb[x][page] |= (1 << bit); else fb[x][page] &= ~(1 << bit); } void lcdRefreshArea(uint8_t x0, uint8_t y0, uint8_t w, uint8_t h) { uint8_t startPage = y0 >> 3; uint8_t endPage = (y0 + h - 1) >> 3; for (uint8_t p = startPage; p <= endPage; p++) { lcdSetPage(p); lcdSetColumn(x0); for (uint8_t x = x0; x < x0 + w; x++) { lcdWriteData(fb[x][p]); } } }lcdWriteCmd 和 lcdWriteData 的区别在于 DC 引脚电平,这是控制芯片识别命令还是数据的关键。fb 数组是纵向 8 像素打包成一页,屏幕上一列的第 0~7 位对应 fb[列][页] 的 bit0~bit7;我这里从低到高排列,不同屏的字节序可能反过来,如果刷出来图像上下颠倒,把像素位序反转再试。lcdSetColumn 的 0x10 与 0x0F 拼字节方式是按常见 UC1698 的列地址格式写的,如果你的模组列数超过 128,比如 160 宽,屏厂数据手册会把高四位对应位数替换掉,移植时务必核对 lcdSetColumn 而不是直接抄。
lcdRefreshArea 是局部刷新的核心。它只把需要变化的区域逐页发送,电表上一秒一次的数字刷新,通常只刷三四十列,不需要重复发送整个 1KB buffer。这里必须注意:发送数据时从 x0 到 x0+w-1 连续发,模组内部列地址会自动递增;如果你的初始化序列里开启过窗口模式或设置有列翻转,这段连续写会错位,那就改写为每列调用一次 lcdSetColumn,确保地址不乱。
3.3 点不亮时的三步判断:开关屏、对比度、电源
点亮失败时不要急着换屏幕。第一步判断控制器是否活着:把初始化里所有功能命令注释掉,只发 0xAE 关显示、0xAF 开显示。如果屏幕能由深变浅或由浅变深,说明 SPI 时序和控制器基本正确。第二步调对比度,常见的是命令 0x81 后跟一个字节,取 0xFF 时对比度拉满,但可能出现残影或底色过重,取 0x20 时可能淡得看不见;从 0x80 往下步进,每步 0x10 观察一次。第三步量电流:不带背光时单色 STN 模组整机电流应在几毫安到十几毫安,如果上电就几十毫安且屏体发烫,优先查 VDD 极性、CS 是否被固定拉低、DC 和 RST 是否被复用成其他外设。
这三步能过滤掉绝大多数“驱动代码重新抄了三遍还是白屏”的求助。控制器初始化序列里像偏压比、温度补偿这类命令,不同厂家定义差异较大,我会直接沿用屏厂例程里的那一段,不在自绘驱动里重新发明。这一点算我踩过最深的坑:UC1698 的数据手册是控制器的,但玻璃是屏厂蒸镀的,初始化序列里任何一个和偏压有关的字节,都该以屏厂验证结果为准。
4. 电表显示界面落地:数字字段、中文标签与局部刷新策略
4.1 先定刷新边界:哪些画一次,哪些每秒刷一次
电表界面可以拆成三类元素。静态标签:电压、电流、电量、单位符号,上电画一次;动态数字:具体数值,按刷新周期变化;状态图标:充电、报警、通讯状态这类,反白或闪烁时变化。刷新策略按这个分类定制,最忌在 loop 里反复 clearDisplay、全屏重画。全屏重画在人眼看来会有轻微闪烁,在电表这类长期显示的设备上,持续全阵列写入还会带来不必要的射频噪声。
我常用的策略见下表:
| 元素 | 刷新周期 | 最小刷新区域 | 手段 |
|---|---|---|---|
| “电压”等中文标签 | 上电一次 | 不刷新 | drawXBM / 自绘 bitmap |
| 电压数值 | 1s | 只覆盖数字所在矩形 | 清矩形 → 画新数字 |
| 电流小数位 | 500ms | 只更新右下角 2~3 个字符 | 同上 |
| 通讯指示灯 | 状态变化 | 8x8 像素 | 直接翻转该像素块 |
| 电量累计值 | 掉电或每分钟 | 数值区域 | 写 buffer 后 commit |
这里“清矩形”不要做成整屏 fillRect 黑底,那会把同一行的静态标签一并盖掉。正确做法是记住每个字段的矩形坐标,用 fillRect 清字段背景色,再在新坐标写数字。坐标一旦排好就尽量不改,既减少 buffer 写入量,也让测试回归时的界面差异更容易用肉眼判断。
4.2 数字字段刷新代码:把电压每秒画一次
这里用第 3 章的 fb 和 lcdRefreshArea 实现一个简单的数字绘制函数。假设一套 8x16 点阵字符字库放在 font8x16 数组中,每字符占 16 字节,每字节是横向一行的 8 个像素。
void drawChar8x16(int16_t x, int16_t y, char c, bool invert) { const uint8_t* g = &font8x16[(uint8_t)c * 16]; for (int row = 0; row < 16; row++) { for (int bit = 0; bit < 8; bit++) { bool px = (g[row] >> (7 - bit)) & 1; if (invert) px = !px; if (px) lcdSetPixel(x + bit, y + row, true); else lcdSetPixel(x + bit, y + row, false); } } } void drawVoltage(float v) { // 显示区域固定在 x=8..56, y=24..40 for (int x = 0; x < 48; x++) for (int y = 0; y < 16; y++) lcdSetPixel(8 + x, 24 + y, false); // 清字段底色 char buf[8]; dtostrf(v, 5, 1, buf); // 220.5 -> "220.5" for (int i = 0; buf[i] && i < 6; i++) { drawChar8x16(8 + i * 8, 24, buf[i], false); } lcdRefreshArea(8, 24, 48, 16); }dtostrf 来自 AVR 的 stdlib,在 Uno 上把 float 转成定宽字符串比较省事;ESP32 上可以换成 snprintf(buf, sizeof(buf), "%.1f", v)。清除底色用双重循环逐像素写 false,直观但慢;如果字段多、刷新频率高,改成对 fb 区域做 memset 会快很多,因为不需要逐位判断。批量清空后再画字符,最后只调一次 lcdRefreshArea,整块显示区只发一次 SPI 数据,而不是画一个点发一次。这正是第 3 章 framebuffer 的价值:所有画点先改内存,最后一次性提交。
把电压、电流、电量三个字段用同一套 drawChar8x16 接起来,每个字段维护自己的矩形区域和更新时间,loop 里的代码就能保持干净。需要注意 x 坐标按 8 像素对齐,不然字符之间会出现多余空白或重叠;如果换成 6x12 字库,每个字符占 6 像素宽,字段矩形宽度要按字库实际宽度算,不能照抄 48 这个数。
4.3 中文标签的 XBM 取模与 U8g2 的两种缓冲模式
中文在单色屏上直接写字符串不方便,常见做法是把每个标签在 PCtoLCD2002 这类软件里转成数组。导出 XBM 或 Bitmap 格式,本质上是一个 const uint8_t 数组。我通常用 16x16 点阵显示“电压”“电量”,用 12x12 或 16x16 做“kW·h”这种单位,导出后放进一个 labels.h 头文件。
// 以 XBM 格式为例,宽 16、高 16 const uint8_t label_dianya[] = { 0x00, 0x00, 0xFC, 0x7F, 0x04, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0xFC, 0x7F, 0x00, 0x00 // 这里只是示意,实际内容以取模工具输出为准 }; void drawLabel(const uint8_t* bitmap, int16_t x, int16_t y) { for (int row = 0; row < 16; row++) { for (int col = 0; col < 2; col++) { uint8_t b = pgm_read_byte(&bitmap[row * 2 + col]); for (int bit = 0; bit < 8; bit++) { if (b & (0x80 >> bit)) { lcdSetPixel(x + col * 8 + bit, y + row, true); } } } } }XBM 的每行按 8 像素一组的字节排列,16 宽就是每行 2 字节,先左后右。pgm_read_byte 是 AVR 上读取 PROGMEM 数组的方法,ESP32 直接访问数组即可。如果换用 U8g2,这段 drawLabel 可以替换成 u8g2.drawXBM(x, y, 16, 16, label_dianya),库内部自动处理取模方向和页寻址。U8g2 的构造里要注意1和f后缀:页面缓冲模式只保留当前页的一小块缓冲,适合 RAM 极小的板子;全缓冲模式会吃 1KB 左右 SRAM,Uno 还能撑住,Nano 也可以,但再叠加大的取模数组时就要小心 SRAM 溢出。做电表这种界面固定的项目,我的建议是全缓冲加局部刷新,不要用页面模式的逐页裁切,后者在数字跳变时更容易看到吞字。
取模方向最容易出错的是“左右镜像”和“上下颠倒”。XBM 导出时如果勾选了纵向取模,数组顺序会按页不是按行排,和 drawLabel 里的双重循环就不匹配。判断方法很直接:先画一个非对称字符,比如字母“L”,如果镜像就把 0x80 >> bit 改成 0x01 << bit,如果上下颠倒就把 row 反过来读,在实验室里花一分钟验证,比在产线上猜快得多。
4.4 一秒刷新五次与一次刷新一秒:显示节奏和主循环的关系
电表显示节奏要跟硬件计量数据源节奏分开。如果计量芯片 1 秒推一次有效值,刷新周期就定 1 秒;如果读数是异步的,可能一秒钟读到 3~4 次,界面全部重画反而会抖。一般做法是每次读数后只更新对应数值缓冲区,用 millis() 检查距上次 commit 是否超过 500ms,是才真正写屏。这个方法还能合并掉多次中间态,避免屏上出现瞬时毛刺,比如 0.5ms 内电压值跳了 0.01,就不必闪一下。
如果要先用仿真验证布局,可以在 wokwi 仿真平台 arduino 模型上按同像素屏的坐标布一遍,但要注意仿真平台里的模型即使标注了同名控制器,RAM 扫描方向和真实模组也可能不同,仿真空屏或上下颠倒不代表实屏故障。我把这个技巧放在这里,是因为很多人会在仿真上花时间调坐标,结果实际接线后又要反一遍,提前知道这一差别能省一晚上。
5. 对比度、反白和批量一致性:UC1698 显示稳定性检查点
UC1698 屏的问题很少是“完全不显示”,更多是淡、残影、闪烁、以及批量之间深浅不一。以下五个检查点按排查顺序列出来:
第一,对比度要按批次调,而不是按代码调死。同一型号模组,玻璃厚度和偏光片批次不同,0x81 命令的最优值可能差 0x20~0x40。批量测试时不要只目测,用 0x00~0xFF 的阶度条图案让产线拍照,把每块屏最优的对比度寄存器值写进 EEPROM。上电初始化时先读这个值再发 0x81,屏与屏的观感就能统一。要顺手把批次号也存进去,后续追溯时能直接看出哪个批次偏压有漂移。
第二,黑屏先看背光电路。很多“arduino 上传超时”其实是背光启动瞬间把 USB 5V 拉低导致的,屏本身没有坏。给 LEDA 串一只 100~220Ω 电阻,或者用外部 5V 单独给背光供电,上传成功率立马上来。Uno 的 +5V 引脚这时不要同时带屏背光和 WiFi 模块,电表调试台上这一点最容易被甩锅给代码。
第三,RST 低电平时间要足够。Uno 上电时默认 IO 为输入状态,RST 引脚悬浮可能让屏在上电瞬间进不确定态。驱动初始化里把 RST 拉低延时从 1ms 提到 10ms,释放后再等 50ms,能减少不少偶发白屏。注意顺序:先拉低、等待、再拉高,不能在设置引脚模式之前就把 RST 当作普通 IO 操作。
第四,SPI 时钟降下来看随机乱码是否消失。Uno 的硬件 SPI 默认分频在 4MHz 左右,部分长引线模组在 4MHz 下会出现隔几个字节丢一个的随机花点。把 SPI 时钟降到 1MHz 或 2MHz,乱码一般消失。量产板布线短时可以往上提,但建议固件保留一份慢速模式用于产线自检,切换方式就是一个条件编译宏的事。
第五,反白和闪烁要按字段做。状态变化时不要整屏取反,用 drawBox 填充状态图标所在的 8x8 或 16x16 区域,翻转该区域而不是全屏。STN 的响应速度本身比 OLED 慢,全屏反白在电表上看起来像页面闪断。图标状态写进 buffer 后调用一次 lcdRefreshArea(区域坐标) 即可,下一次再翻转回来。把对比度读回值和批次号写进 EEPROM 头部,初始化时先读后发,批量观感就能稳定在同一阶度区间。
本文还有配套的精品资源,点击获取