打算用一个存储空间不大的Arduino 芯片做点简单的文字和图形显示,屏幕芯片SSD1316,感觉u8g2 占用还是太大,想裁剪别人的现成代码又感觉无从下手,所以就基本上重写了一个OLED 显示库,仓库地址:gitee.com/etberzin/oled_basic。
先说重点:
如果使用PlatformIO 环境,安装库只要在platformio.ini
配置文件里加上:
lib_deps =
oled_basic=https://gitee.com/etberzin/oled_basic.git
用Arduino IDE 的话,就把文件夹下载下来,然后扔到库的路径底下。
用Arduino 的硬件SPI 库驱动SSD1316;6x8 的字体,覆盖字母大小写、数字和大部分标点符号;基于8 位AVR 架构的ATmega128 单片机,在屏幕上显示几个字母,程序编译后总大小约2.2kB。内置了裁剪掉小写字母部分的字库,可以再精简到2kB。加上整数显示功能,从2kB 增加到了2.5kB,以后可能会再优化一下,但是应该没什么余地。
硬件I2C 驱动库Wire 本身体积比较大,所以用I2C 驱动SSD1306 屏幕,体积膨胀了不少。其他条件不变,编译后大约4kB。想精简体积,可以考虑换用极简的软件I2C,不检查ACK,就愣发送的那种。
以下是硬件SPI 驱动SSD1316 的代码,效果是在屏幕上显示几个8x16 的大写字母,以及12x24 的大号数字。显示字母用的8x16 字库裁剪到只剩下大写字母,数字对应的12x24 字库离只有十个数字加负号、小数点和斜杠。编译下来大小2.9k。核心功能就这么些,代码上的注释写的应该够详细了。
// Copyright (c) 2023 刻BITTER
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
#include
#include "oled_basic_v1.h"
// 具体硬件的底层驱动,只选择一个要用的include 进来,其他驱动的依赖就不会被引入。比如只用SPI 通信,就不需要Wire 库。
#include "oled_basic_1316_arduino_spi.h"
// #include "oled_basic_1306_arduino_wire.h"
// 定义SPI 操作引脚,SCK 和MOSI引脚由硬件SPI 自己管理
constexpr auto OLED_CS = 7;
constexpr auto OLED_DC = 8;
// 选择具体屏幕的底层驱动,同时传入SPI 引脚。ArduinoSpi1316_12832 对应12832 的屏幕,
// 如果有多个驱动方式相同的不同尺寸屏幕,都会放在同一个头文件里
using LowLevel = oled_basic::ArduinoSpi1316_12832<OLED_CS, OLED_DC>;
// 实例化顶层驱动
using Oled = oled_basic::OledDriver<LowLevel>;
// 如果用I2C 总线,就不需要传入其他的引脚作为参数,全部由Wire 库管理
// using Oled = oled_basic::OledDriver;
// 选择待会用来显示文本的字体,用一个常量表示,方便修改。
// ascii_8x16_upper_case_only 是ascii_8x16 字库裁剪掉的只剩下26 个大写字母的部分。
// 字体的命名规则是三段式,ascii 表示名称,8x16 表示单个符号的宽度和高度,其中高度必须是8 的倍数。
// 如果字体还有一些别的特征,而且没有体现在字体名称中,则用后缀标记。upper_case_only 就是只有大写字母。
constexpr auto &TEXT_FONT = oled_basic::ascii_8x16_upper_case_only;
void setup() {
pinMode(OLED_CS, OUTPUT);
pinMode(OLED_DC, OUTPUT);
// 底层驱动使用SPI 库时不管初始化部分,需要手动写
// 通信时的参数是自动设置的
SPI.begin();
// Wire.begin();
// 执行屏幕对应的初始化代码
Oled::run_init_sequence();
// 显示两个文本,0,1 表示x,y 坐标,或者说是列和页地址。
// 对于12832 的屏幕,列地址从0 到127,可以单像素点定位;
// 页地址从0 到3,每个页代表屏幕上8 个像素高度的横条,其中的每列8 个像素就对应显存中的一个字节。
// 向屏幕传送显示数据时,只能对应显存里的字节一个一个传,所以一次传送至少覆盖某个页中的一列8 个像素,也可以说是一行中的8 个像素。
// 如果想实现高度上的单像素点定位,需要做不少额外的处理,可能需要个显示缓冲区,跟这个精简OLED 库的目标不符。
Oled::put_str("NUM", TEXT_FONT, 0, 1);
Oled::put_str("NUM", TEXT_FONT, 20, 0);
int8_t num = -100;
while (1) {
// 用12x24 的大号数字字体显示数字,24 / 8 = 3,也就是要占用3 页的高度。
// 数字字体里包含十个数字,加上小数点、负号和斜杠,一共13 个符号。字体本身占用的空间是3 * 12 * 13 = 468 字节。
// 最后一个参数oled_basic::write_mode::inverse 是可选参数,表示反色显示,默认不反色。
// put_str 最后也可以加上反色参数。
// 返回值表示显示内容后的下一列地址。如果在第0 列显示了一个12x24 的数字,返回值就是0 + 12 = 12。
uint8_t col = Oled::put_signed(num++, oled_basic::big_digit_12x24, 59, 1, oled_basic::write_mode::inverse);
// num 范围是-128 到127,所以最多显示三位数加一个负号,文字占用12 * 4 的宽度。
// 显示了较大数字后,再显示小数字,必须清空后面空出来的位置
// 比如先显示-100,然后变成-99,如果不做清空,实际显示出来就是-990。
// 整体清屏再重写会产生一个空白帧,导致闪烁,所以只清空数字后面多出来的部分
if(col < 59 + 12 * 4) {
Oled::fill_area(0, col, 1, 59 + 12 * 4 - col, 3);
}
// fill_area 的功能是用指定的一个字节数据重复填充屏幕上一块矩形区域,
// 如果指定的数据是0,效果就是清空区域
delay(500);
}
}
void loop() {}
示例代码的显示效果如下:
反色显示数字演示 - 精简体积的Arduino OELD
内置的每个字体由两部分组成:
描述信息是必要的,否则就只能把这些信息写死在OLED 驱动的代码里,太不灵活。上面示例代码里的oled_basic::big_digit_12x24
和oled_basic::ascii_8x16_upper_case_only
就是字体的描述结构体,内部代码根据描述去找字模数据。这样一来,没用到的字体也能被编译器删除掉,不会占用资源。和u8g2 库的做法不一样,描述结构体和字模是独立的,而不是整体混合编码成一个二进制。好处是结构很直观,可以轻松手写修改,而且去掉了耦合,修改描述信息不会影响字模,反之亦然。
结构体的声明在头文件oled_basic_font.h
中,里面还有几个内置字体的结构体声明:
/**
* @brief 存储字体的元信息,方便使用
*
* 字体数组中,单个符号从左到右,按列切分成多个字节,对于宽度6,高度8 的符号,在数组中切分成6 个字节。
* 宽度8,高度16 的符号占用两页,数组中,从上到下按页拆分成2 组,显示第一页的8 个字节,然后第二页。
*
* 所以,如果想访问数组中的第n 个符号,先减去offset:
* n -= offset;
*
* 然后乘以每个符号占用的字节数:
* pos = n * width * height;
*
* OLED 库如果想省资源,大头都在字库上,所以采用这种直观的存储方式,字库数组和元信息分开,
* 不需要折腾编码和二进制格式,方便裁剪字库。
*
*/
struct FontMeta {
const uint8_t* font_data; // 指向字体数组的指针
uint8_t width; // 字体占用的列数
uint8_t height; // 字体占用的页数,如果字体是6x8,则宽、高分别是6,1
uint8_t offset; // 字体数组中第一个元素对应的ascii 码
// uint8_t glyph_size; // width * height
};
举例,oled_basic::ascii_8x16_upper_case_only
的描述是:
const FontMeta ascii_8x16_upper_case_only = {
.font_data = reinterpret_cast<const uint8_t*>(_ascii_1608_upper_case_only), // 指向字模数组的指针
.width = 8, // 字体宽度,以列为单位
.height = 2, // 字体高度,以页为单位,实际的像素高度为2 * 8 = 16
.offset = 'A'}; // 字库包含的第一个符号对应的ASCII 码,这个字体被裁剪到只包含26 个大写字母,所以第一个符号就是A
如果要显示一个动画,可以先把动画按帧分解成图片,如果每个图片的尺寸是48x48,就可以把动画做成一个48x48 的字体,如下:
// 动画字体的描述信息:动画是用gif 转换来的;尺寸48x48;占用的空间巨大。
const FontMeta gif_48x48_巨 = {
.font_data = reinterpret_cast<const uint8_t*>(_gif_4848), // 指向图片数组的指针
.width = 48, // 宽度48
.height = 6, // 高度48 / 8 = 6
.offset = 0}; // 动画当然是从第0 帧开始的,不用像ASCII 码字体那样加offset
想显示中文也可以用相似的方式。要注意,动画的总帧数不能超过256。另外还有两个不太方便的问题:
如果要做循环动画,没有总帧数字段就表示要硬编码进显示动画的代码里,不过可以修改结构体的声明,尾部追加一个成员进去,不会影响已有的代码。或者自定义一个结构体,继承内置的FontMeta
,尾部追加一个成员。图片压缩的话,不确定是否实用,反正显示普通图标和文字时基本没用。
字模就是通用的数组格式,内置字模和字体描述都放在oled_basic_font.cpp
这个文件里,以6x8 字体为例:
const unsigned char _ascii_0806[][6] PROGMEM = {
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // sp
{0x00, 0x00, 0x00, 0x2f, 0x00, 0x00}, // !
{0x00, 0x00, 0x07, 0x00, 0x07, 0x00}, // "
{0x00, 0x14, 0x7f, 0x14, 0x7f, 0x14}, // #
{0x00, 0x24, 0x2a, 0x7f, 0x2a, 0x12}, // $
{0x00, 0x62, 0x64, 0x08, 0x13, 0x23}, // %
{0x00, 0x36, 0x49, 0x55, 0x22, 0x50}, // &
{0x00, 0x00, 0x05, 0x03, 0x00, 0x00}, // '
{0x00, 0x00, 0x1c, 0x22, 0x41, 0x00}, // (
{0x00, 0x00, 0x41, 0x22, 0x1c, 0x00}, // )
{0x00, 0x14, 0x08, 0x3E, 0x08, 0x14}, // *
{0x00, 0x08, 0x08, 0x3E, 0x08, 0x08}, // +
// ...
};
每行是对应一个符号的二维数组,字体宽度是6,对应6 列,也就是6 个字节。6x8 字体每个字占用一页,所以占用的字节数就是6 x 1 = 6。如果是8x16 的字体,宽度8,高度16 / 8 = 2,每个字就是8 x 2 = 16字节。
二维数组是直接用取模软件生成,然后复制粘贴过来的,参考其他取模教程。如果用的工具是经典的PCtoLCD2002,生成字模的选项是这样:
TODO:看情况,之后再更新
这招是从Arduino 库SSD1306Ascii 那里学来的,那也是个占用比较小的库,缺点是,我不想浪费很多时间读别人的陈年代码[doge]。那个库只支持SSD1306,想扩展SSD1316 的话,得研究一番他的实现;他的字库也是描述和字模混在一起的,还整了一些条件编译和宏,实在不想研究要怎么裁剪。