精简体积的OLED 基础驱动库 - OLED_BASIC

打算用一个存储空间不大的Arduino 芯片做点简单的文字和图形显示,屏幕芯片SSD1316,感觉u8g2 占用还是太大,想裁剪别人的现成代码又感觉无从下手,所以就基本上重写了一个OLED 显示库,仓库地址:gitee.com/etberzin/oled_basic。

先说重点:

  1. 目前支持SPI 接口的SSD1316 和I2C 接口的SSD1306,想扩展支持相似的其他SSD 这一家子型号难度应该不大,似乎主要是初始化代码上有区别;
  2. 不是u8g2 库的替代;
  3. 不需要显示缓冲区,几乎没有额外的RAM 占用;
  4. 只支持文本和整数输出函数,不支持矩形以外的绘图功能;
  5. 想显示图片的话,只要把图片当作大一点字符来用就行,自定义一个单独的字库放进去;
  6. 自定义字库的部分特意做的很简单,不用像U8G2 库那样还要整一堆编码上的劳什子,取模软件输出的数组直接放进代码就能用;
  7. 没用到的内置字库和底层驱动不会占地方;
  8. 只适合驱动单个屏幕的场合,想驱动多个也不是不行,得付出一点效率上的代价;

安装

如果使用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

自定义字体

字体描述

内置的每个字体由两部分组成:

  1. 字模,就是由取模软件直接生成的数组,包含每个字的显示数据;
  2. 描述,就是个结构体,里面放着字体的宽度、高度等信息;

描述信息是必要的,否则就只能把这些信息写死在OLED 驱动的代码里,太不灵活。上面示例代码里的oled_basic::big_digit_12x24oled_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。另外还有两个不太方便的问题:

  1. 结构体里没有总帧数字段;
  2. 没有图片数据压缩功能;

如果要做循环动画,没有总帧数字段就表示要硬编码进显示动画的代码里,不过可以修改结构体的声明,尾部追加一个成员进去,不会影响已有的代码。或者自定义一个结构体,继承内置的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,生成字模的选项是这样:

精简体积的OLED 基础驱动库 - OLED_BASIC_第1张图片

扩展OLED 型号和通讯方式

TODO:看情况,之后再更新

初始化指令序列

这招是从Arduino 库SSD1306Ascii 那里学来的,那也是个占用比较小的库,缺点是,我不想浪费很多时间读别人的陈年代码[doge]。那个库只支持SSD1306,想扩展SSD1316 的话,得研究一番他的实现;他的字库也是描述和字模混在一起的,还整了一些条件编译和宏,实在不想研究要怎么裁剪。

底层驱动

底层驱动可选功能

你可能感兴趣的:(Note,嵌入式硬件)