巧用图形,仅需几 KB 闪存和几乎没有 RAM





5.00/5 (3投票s)
有一个带有屏幕的嵌入式或物联网小工具,但几乎没有内存或闪存空间?阅读本文。
引言
我正在和朋友们一起开发一款类似“宠物养成游戏”的游戏,我们计划将其植入到带有小型单色OLED屏幕的PC键盘中。具体来说,这款键盘是Boardsource Lulu。我们希望它能与现有固件并行运行,以确保键盘的全部功能得以保留。Lulu有多种型号,我们目前使用的是AVR型号,它拥有2.5KB的RAM和32KB的闪存,其中约18KB的闪存已被现有代码占用。我不太确定现有固件的RAM使用情况,但我们未来的需求将不会真正需要太多RAM。
我不指望你会拥有这样一款键盘。这太不厚道了,因为它们是相当昂贵、小众的设备。相反,我使用ESP32-S3或其他兼容Arduino的设备,构建了一个PlatformIO**项目。你需要这样的设备,以及一块通过I2C连接的SSD1306屏幕。在ESP32-S3上,我使用了SDA引脚16和SCL引脚17。
** 抱歉那些还在使用Arduino IDE的朋友们。我因为其功能限制太大而放弃了它,我强烈建议安装Platform IO,即使你不是一直都使用它,因为它对于包含多个源文件或需要支持多种设备的项目来说,更加实用。
代码默认配置为驱动一个128x32的“创可贴”式屏幕,但如果你使用的是128x64型号,请在代码中将SSD1306_HEIGHT
宏定义更改为相应值。你还需要使用cigen工具生成更大的图像,因为当前的图像是128x32的。
背景
首先需要记住的是,这些小显示器的帧缓冲格式很奇怪,即便这么说也很宽容了。它是一个单色显示器,每8个像素占用一个字节,但像素是垂直而不是水平地打包到这些字节中的。然而,字节的排列方式是传统的从左到右,从上到下,所以(0,0)-(0-7)是第一个字节,(1,0)-(1-7)是第二个字节。
此外,由于它是单色的,而且是只写模式,你必须保留一个512-1024字节的帧缓冲(取决于你的显示硬件分辨率),或者你可以直接将帧缓冲从闪存通过I2C流式传输到显示器,这样就不需要RAM,但会限制你只能显示静态图像。
对于我们的项目,我们总共只有2.5KB的RAM,而且它还与其他固件组件共享,所以我们选择了后一种方法。我们不需要任何动态渲染,而且也没有足够的闪存空间来存储那种逻辑。
为了节省更多闪存空间,我们可以使用游程编码来压缩图像。这段代码实际上允许不压缩或3种样式的RLE,因此可以在生成图像时选择哪种方法能产生最小的图像。游程编码简单、轻量级,并且对于此类数据,通常非常有效。
我们需要一个应用程序,它可以接受图像并生成RLE压缩的uint8_t[]
数组,然后是一些将这些数组输出到显示器的代码。
Cigen应用程序
Cigen应运而生。这是一个小的C#命令行应用程序,它接受一系列图像,并生成包含每个输入图像的帧缓冲的RLE压缩C数组内容。
cigen v1.0 Copyright c 2024 by honey the codewitch
Usage: cigen {<infile1> [<infileN>]} [/output <outfile>] [/threshold <threshold>]
<infile> The input files
<outfile> The output file - defaults to <stdout>
<threshold> The luminosity threshold (0-255, defaults to 127)
- or -
/help Displays this screen and exits
它使用起来很简单。你可以传递一系列与你的LCD面板相同大小的图像(在本演示中是128x32),一个可选的<output>
文件,以及一个可选的<threshold>
值。阈值只是像素的亮度,应用程序将该亮度高于此值的像素视为“白色”而非“黑色”。
当你使用项目提供的Debug参数运行时,你会得到以下输出:
#ifndef OUTPUT_H
#define OUTPUT_H
#include <stdint.h>
#include "progmem.h"
const uint8_t output_frame_1[] PROGMEM = {
0xff, 131, 0x3f, 0x9f, 0xcf, 0xef,
0xe7, 0xe7, 0xf3, 0xf3, 0xfb, 0xfb,
0xf9, 0xf9, 0xf9, 0xf9, 0xf9, 0xf9,
0xf9, 0xfb, 0xfb, 0xf3, 0xf3, 0xe7,
0xe7, 0xef, 0xcf, 0x9f, 0x3f, 0x7f,
0xff, 98, 0x00, 2, 0xff, 6, 0x83,
0x01, 0x83, 0xc7, 0xff, 7, 0x83,
0x01, 0x83, 0xc7, 0xff, 7, 0x00, 2,
0xff, 97, 0xfc, 0xf9, 0xf3, 0xe7,
0xef, 0xcf, 0xcf, 0x9f, 0x9f, 0xbf,
0xbf, 0x3f, 0x3f, 0x3f, 0x3f, 0x3f,
0x3f, 0x3f, 0xbf, 0xbf, 0x9f, 0x9f,
0xcf, 0xcf, 0xef, 0xe7, 0xf3, 0xf9,
0xfc, 0xfe, 0xff, 96
};
#define OUTPUT_FRAME_1_COMPRESSION 3
// [Compressed to 16.40625% of original. Len = 84 vs 512]
const uint8_t output_frame_2[] PROGMEM = {
0xff, 166, 0x3f, 0x3f, 0xbf, 0x9f,
0x9f, 0xdf, 0xdf, 0xdf, 0xcf, 0xcf,
0xcf, 0xcf, 0xcf, 0xdf, 0xdf, 0xdf,
0x9f, 0x9f, 0xbf, 0x3f, 0x3f, 0x7f,
0xff, 101, 0x03, 0xf1, 0xfc, 0xfe,
0xfe, 0xff, 3, 0x0f, 0x07, 0x0f,
0x9f, 0xff, 7, 0x0f, 0x07, 0x0f,
0x9f, 0xff, 4, 0xfe, 0xfc, 0xf1,
0x03, 0x0f, 0xff, 97, 0xf8, 0xf3,
0xf7, 0xe7, 0xcf, 0xcf, 0xdf, 0x9f,
0x9e, 0xbf, 0xbf, 0xbf, 0x3f, 0x3f,
0x3f, 0x3f, 0x3f, 0xbf, 0xbf, 0xbe,
0x9f, 0x9f, 0xdf, 0xcf, 0xcf, 0xe7,
0xf7, 0xf3, 0xf8, 0xfc, 0xff, 64
};
#define OUTPUT_FRAME_2_COMPRESSION 3
// [Compressed to 16.40625% of original. Len = 84 vs 512]
const uint8_t output_frame_3[] PROGMEM = {
0xff, 73, 0x7f, 0x3f, 0xbf, 0xbf,
0x9f, 0x9f, 0x9f, 0x9f, 0x9f, 0x9f,
0x9f, 0xbf, 0xbf, 0x3f, 0x7f, 0x7f,
0xff, 105, 0x8f, 0xe7, 0xf3, 0xf9,
0xfc, 0xfe, 0xfe, 0xff, 2, 0xff, 11,
0xff, 3, 0xfe, 0xfc, 0xf9, 0xf3,
0xe7, 0x8f, 0x1f, 0xff, 97, 0x00, 2,
0xff, 6, 0xe0, 0xc0, 0xe0, 0xf1,
0xff, 7, 0xe0, 0xc0, 0xe0, 0xf1,
0xff, 7, 0x00, 2, 0xff, 98, 0xfc,
0xf9, 0xf3, 0xe7, 0xef, 0xcf, 0xdf,
0x9f, 0x9f, 0xbf, 0x3f, 0x3f, 0x3f,
0x3f, 0x3f, 0x3f, 0x3f, 0xbf, 0x9f,
0x9f, 0xdf, 0xcf, 0xef, 0xe7, 0xf3,
0xf9, 0xfc, 0xfe, 0xff, 33
};
#define OUTPUT_FRAME_3_COMPRESSION 3
// [Compressed to 17.96875% of original. Len = 92 vs 512]
const uint8_t output_frame_4[] PROGMEM = {
0xff, 239, 0x7f, 0x7f, 0x7f, 0x7f,
0xff, 110, 0x0f, 0xe7, 0xe7, 0xf3,
0xf9, 0xf9, 0xfd, 0x3c, 0x1c, 0x1e,
0x1e, 0x3e, 0xfe, 0xfe, 0xfe, 0xfe,
0xfe, 0xfe, 0x3e, 0x1e, 0x1e, 0x1e,
0x3c, 0xfc, 0xfd, 0xf9, 0xf9, 0xf3,
0xe7, 0xe7, 0x0f, 0xff, 97, 0xf8,
0xf3, 0xf3, 0xe7, 0xcf, 0xcf, 0xdf,
0x9e, 0x9c, 0xbc, 0xbc, 0xbe, 0xbf,
0x3f, 0x3f, 0x3f, 0x3f, 0x3f, 0xbe,
0xbc, 0xbc, 0xbc, 0x9e, 0x9f, 0xdf,
0xcf, 0xcf, 0xe7, 0xf3, 0xf3, 0xf8
};
#define OUTPUT_FRAME_4_COMPRESSION 3
// [Compressed to 14.0625% of original. Len = 72 vs 512]
const uint8_t* output_images[] = {
output_frame_1,
output_frame_2,
output_frame_3,
output_frame_4
};
const int output_images_compression[] = {
OUTPUT_FRAME_1_COMPRESSION,
OUTPUT_FRAME_2_COMPRESSION,
OUTPUT_FRAME_3_COMPRESSION,
OUTPUT_FRAME_4_COMPRESSION
};
#endif // OUTPUT_H
此头文件适用于QMK,但你可以直接复制代码中你需要的部分,例如数组,到你自己的程序中。
该应用程序功能的核心在Program.cs文件的Run()
方法中。总的来说,它将所有输入加载到System.Drawing.Bitmap
实例中(这就是为什么这是一个.NET Framework应用程序,因为它依赖于GDI+,而GDI+是“仅限Windows”的,尽管我认为?Mono也可以在Linux上运行它)。
一旦有了这些位图,它会创建一个与每个位图尺寸相对应的字节数组,并将位图数据打包为单色像素。它以SSD1306使用的奇怪格式进行,这样我们就无需进行任何后处理。为了转换为单色,每个像素都会计算其亮度,然后与Threshold
值(通常是127)进行比较。
现在,有了单色位图数据,该应用程序会尝试使用3种不同的RLE变体之一来压缩数据,并选择产生最小尺寸的变体,如果所有压缩方法都比原始尺寸大,则保持不压缩。一种变体中,黑色和白色运行都会被编码。另一种只编码白色运行。最后一种只编码黑色运行。
一旦这些数据被处理完,生成实际的头文件文本就很容易了。
Arduino原型固件
正如我所说,我不会强迫你使用QMK和Lulu键盘。相反,我们使用了一个兼容Arduino的开发套件来原型化这个项目,连接了相同的屏幕,但连接到了ESP32-S3而不是Lulu的AVR Atmega32U4。用这种方式工作的主要注意事项是,QMK是C语言,而Arduino是C++,所以要相应地编写代码,以便你的代码可以移植到你最终的环境中。如果你没有ESP32-S3,可以使用其他Arduino板。我手头有太多的ESP32-S3,所以使用这个芯片很有意义。你只需要在platformio.ini中更改板卡设置以匹配你的硬件。
这段代码是基础的。重点在于小尺寸,而不是优雅的抽象。我避免了所有非功利性的抽象,因为我不想在它们上面浪费闪存空间。
#include <Arduino.h>
#include <Wire.h>
#ifdef ESP32
#define I2C_SDA 16
#define I2C_SCL 17
#endif
#define SSD1306_HEIGHT 32
const uint8_t output_frame_1[] PROGMEM = {
0xff, 131, 0x3f, 0x9f, 0xcf, 0xef,
0xe7, 0xe7, 0xf3, 0xf3, 0xfb, 0xfb,
0xf9, 0xf9, 0xf9, 0xf9, 0xf9, 0xf9,
0xf9, 0xfb, 0xfb, 0xf3, 0xf3, 0xe7,
0xe7, 0xef, 0xcf, 0x9f, 0x3f, 0x7f,
0xff, 98, 0x00, 2, 0xff, 6, 0x83,
0x01, 0x83, 0xc7, 0xff, 7, 0x83,
0x01, 0x83, 0xc7, 0xff, 7, 0x00, 2,
0xff, 97, 0xfc, 0xf9, 0xf3, 0xe7,
0xef, 0xcf, 0xcf, 0x9f, 0x9f, 0xbf,
0xbf, 0x3f, 0x3f, 0x3f, 0x3f, 0x3f,
0x3f, 0x3f, 0xbf, 0xbf, 0x9f, 0x9f,
0xcf, 0xcf, 0xef, 0xe7, 0xf3, 0xf9,
0xfc, 0xfe, 0xff, 96
};
#define OUTPUT_FRAME_1_COMPRESSION 3
// [Compressed to 16.40625% of original. Len = 84 vs 512]
const uint8_t output_frame_2[] PROGMEM = {
0xff, 166, 0x3f, 0x3f, 0xbf, 0x9f,
0x9f, 0xdf, 0xdf, 0xdf, 0xcf, 0xcf,
0xcf, 0xcf, 0xcf, 0xdf, 0xdf, 0xdf,
0x9f, 0x9f, 0xbf, 0x3f, 0x3f, 0x7f,
0xff, 101, 0x03, 0xf1, 0xfc, 0xfe,
0xfe, 0xff, 3, 0x0f, 0x07, 0x0f,
0x9f, 0xff, 7, 0x0f, 0x07, 0x0f,
0x9f, 0xff, 4, 0xfe, 0xfc, 0xf1,
0x03, 0x0f, 0xff, 97, 0xf8, 0xf3,
0xf7, 0xe7, 0xcf, 0xcf, 0xdf, 0x9f,
0x9e, 0xbf, 0xbf, 0xbf, 0x3f, 0x3f,
0x3f, 0x3f, 0x3f, 0xbf, 0xbf, 0xbe,
0x9f, 0x9f, 0xdf, 0xcf, 0xcf, 0xe7,
0xf7, 0xf3, 0xf8, 0xfc, 0xff, 64
};
#define OUTPUT_FRAME_2_COMPRESSION 3
// [Compressed to 16.40625% of original. Len = 84 vs 512]
const uint8_t output_frame_3[] PROGMEM = {
0xff, 73, 0x7f, 0x3f, 0xbf, 0xbf,
0x9f, 0x9f, 0x9f, 0x9f, 0x9f, 0x9f,
0x9f, 0xbf, 0xbf, 0x3f, 0x7f, 0x7f,
0xff, 105, 0x8f, 0xe7, 0xf3, 0xf9,
0xfc, 0xfe, 0xfe, 0xff, 2, 0xff, 11,
0xff, 3, 0xfe, 0xfc, 0xf9, 0xf3,
0xe7, 0x8f, 0x1f, 0xff, 97, 0x00, 2,
0xff, 6, 0xe0, 0xc0, 0xe0, 0xf1,
0xff, 7, 0xe0, 0xc0, 0xe0, 0xf1,
0xff, 7, 0x00, 2, 0xff, 98, 0xfc,
0xf9, 0xf3, 0xe7, 0xef, 0xcf, 0xdf,
0x9f, 0x9f, 0xbf, 0x3f, 0x3f, 0x3f,
0x3f, 0x3f, 0x3f, 0x3f, 0xbf, 0x9f,
0x9f, 0xdf, 0xcf, 0xef, 0xe7, 0xf3,
0xf9, 0xfc, 0xfe, 0xff, 33
};
#define OUTPUT_FRAME_3_COMPRESSION 3
// [Compressed to 17.96875% of original. Len = 92 vs 512]
const uint8_t output_frame_4[] PROGMEM = {
0xff, 239, 0x7f, 0x7f, 0x7f, 0x7f,
0xff, 110, 0x0f, 0xe7, 0xe7, 0xf3,
0xf9, 0xf9, 0xfd, 0x3c, 0x1c, 0x1e,
0x1e, 0x3e, 0xfe, 0xfe, 0xfe, 0xfe,
0xfe, 0xfe, 0x3e, 0x1e, 0x1e, 0x1e,
0x3c, 0xfc, 0xfd, 0xf9, 0xf9, 0xf3,
0xe7, 0xe7, 0x0f, 0xff, 97, 0xf8,
0xf3, 0xf3, 0xe7, 0xcf, 0xcf, 0xdf,
0x9e, 0x9c, 0xbc, 0xbc, 0xbe, 0xbf,
0x3f, 0x3f, 0x3f, 0x3f, 0x3f, 0xbe,
0xbc, 0xbc, 0xbc, 0x9e, 0x9f, 0xdf,
0xcf, 0xcf, 0xe7, 0xf3, 0xf3, 0xf8
};
#define OUTPUT_FRAME_4_COMPRESSION 3
// [Compressed to 14.0625% of original. Len = 72 vs 512]
const uint8_t* output_images[] = {
output_frame_1,
output_frame_2,
output_frame_3,
output_frame_4
};
const int output_images_compression[] = {
OUTPUT_FRAME_1_COMPRESSION,
OUTPUT_FRAME_2_COMPRESSION,
OUTPUT_FRAME_3_COMPRESSION,
OUTPUT_FRAME_4_COMPRESSION
};
#if SSD1306_HEIGHT == 32
const uint8_t ssd1306_init[] PROGMEM = {
17,
0xAE, 0,
0xA8, 1, 0x1F,
0x20, 1, 0x00,
0x40, 0,
0xD3, 1, 0x00,
0xA1, 0,
0xC8, 0,
0xDA, 1, 0x02,
0x81, 1, 0x7F,
0xA4, 0,
0xA6, 0,
0xD5, 1, 0x80,
0xD9, 1, 0xc2,
0xDB, 1, 0x20,
0x8D, 1, 0x14,
0x2E, 0,
0xAF, 0};
#endif
#if SSD1306_HEIGHT == 64
const uint8_t ssd1306_init[] PROGMEM = {
17,
0xAE, 0,
0xA8, 1, 0x3F,
0x20, 1, 0x00,
0x40, 0,
0xD3, 1, 0x00,
0xA1, 0,
0xC8, 0,
0xDA, 1, 0x12,
0x81, 1, 0x7F,
0xA4, 0,
0xA6, 0,
0xD5, 1, 0x80,
0xD9, 1, 0xc2,
0xDB, 1, 0x20,
0x8D, 1, 0x14,
0x2E, 0,
0xAF, 0};
#endif
void ssd1306_send_screen(int index)
{
const uint8_t *data = output_images[index];
int comp = output_images_compression[index];
Wire.beginTransmission(0x3C);
Wire.write(0x00);
Wire.write(0x22);
Wire.write(0x00);
Wire.write(0xFF);
Wire.write(0x00);
Wire.write(0x21);
Wire.write(0x00);
Wire.write(0x7F);
Wire.endTransmission();
size_t rem = I2C_BUFFER_LENGTH - 1;
int len = 0;
Wire.beginTransmission(0x3C);
Wire.write(0x40);
while (len < (SSD1306_HEIGHT * 16))
{
uint8_t b = pgm_read_byte(data++);
uint8_t count = 1;
if (((comp == 1 || comp == 3) && b == 0) ||
((comp == 2 || comp == 3) && b == 255))
{
count = pgm_read_byte(data++);
}
while (count--)
{
Wire.write(b);
++len;
--rem;
if (rem == 0)
{
rem = I2C_BUFFER_LENGTH - 1;
Wire.endTransmission();
Wire.beginTransmission(0x3C);
Wire.write(0x40);
}
}
}
Wire.endTransmission();
}
void setup()
{
#ifdef ESP32
Wire.begin(I2C_SDA, I2C_SCL, 800 * 1000);
#else
Wire.begin();
#endif
Serial.begin(115200);
Wire.beginTransmission(0x3C);
const uint8_t *init = ssd1306_init;
uint8_t len = pgm_read_byte(init);
const uint8_t *p = init + 1;
while (len--)
{
Wire.write(0x00);
Wire.write(pgm_read_byte(p++));
uint8_t arglen = pgm_read_byte(p++);
while (arglen--)
Wire.write(pgm_read_byte(p++));
}
Wire.endTransmission();
}
void loop()
{
static int index = 0;
ssd1306_send_screen(index++);
delay(100);
if (index == 4)
{
index = 0;
}
}
这里最主要的是ssd1306_send_screen()
。这个例程负责处理我们图像的内容,必要时进行解压缩,并将它们直接发送到屏幕。除了堆栈帧使用的空间外,它实际上不占用SRAM,因为我们直接将所有内容解压到显示器。正如你所见,解压缩非常简单,允许我们通过一个简单的if()
判断来支持所有方法。更复杂的部分是确保我们不会溢出Arduino中的I2C传输缓冲区。如果即将溢出,我们只需开始新的传输。
根据构建统计数据,在ESP32-S3上,这段代码(包括4个嵌入的图像)占用了3.5KB的闪存。我通过比较一个空项目与包含此代码的空项目的构建大小得出了这个数字。
(empty project)
RAM: [= ] 5.8% (used 18880 bytes from 327680 bytes)
Flash: [= ] 8.2% (used 274181 bytes from 3342336 bytes)
(project with code)
RAM: [= ] 5.8% (used 18904 bytes from 327680 bytes)
Flash: [= ] 8.3% (used 277765 bytes from 3342336 bytes)
历史
- 2024年2月24日 - 初次提交