U8g2图形库与STM32移植

0

简介

U8g2 是一个用于嵌入式设备的简易图形库,可以在多种 OLED 和 LCD 屏幕上,支持包括 SSD1306 等多种类型的底层驱动,并可以很方便地移植到 Arduino 、树莓派、NodeMCU 和 ARM 上。

U8g2 库同时包含了 U8x8 绘图库,两者的区别为:

  • U8g2 包含各种简单及复杂图形的绘制,并支持各种形式的字体,但需要占用一定单片机的内存作为绘图缓存
  • U8x8 只包含简单的显示文本功能,且只支持简单、定宽的字体。它直接绘制图形,没有缓存功能

U8g2 库的 GitHub 地址为:https://github.com/olikraus/u8g2 ,可以从中获取到源码与文档帮助。

移植

本次以将 U8g2 移植到 STM32 单片机与 SSD1306 通过 I2C 驱动的 128x64 OLED 为例,介绍移植的方法。不同单片机和驱动的移植可以参考这一过程,也可以参考 U8g2 的官方移植教程 https://github.com/olikraus/u8g2/wiki/Porting-to-new-MCU-platform

首先下载或克隆 U8g2 的源码,这里主要是使用 C 语言编写,所以只需要用到 csrc 目录下的文件。

下载完成后,将 csrc 目录拷贝或移动到工程目录里,并重命名为合适的目录名例如 u8g2lib

接下来,需要删除一些无用的代码,并添加底层驱动的代码。

删除无用内容

U8g2 的源码为了支持多种设备驱动,包含了许多兼容性的代码。首先,类似 u8x8_d_xxx.c 命名的文件中包含 U8x8 的驱动兼容,文件名包括驱动的型号和屏幕分辨率,因此需要删除无用的驱动文件,只保留当前设备的驱动。例如,本次使用的是 128x64 的 SSD1306 屏幕,那么只需要保留 u8x8_d_ssd1306_128x64_noname.c 文件,删除其它类似的文件即可。U8g2 支持的所有屏幕驱动可以在 https://github.com/olikraus/u8g2/wiki/u8g2setupc 找到。

同时还需要精简 u8g2_d_setup.cu8g2_d_memory.c 中 U8g2 提供的驱动兼容。

u8g2_d_setup.c 中,只需要保留 u8g2_Setup_ssd1306_i2c_128x64_noname_f() 这一个函数即可。注意,该文件内有几个命名类似的函数:命名中无 i2c 的是 SPI 接口驱动的函数,需要根据接口选择;以 1 结尾的函数代表使用的缓存空间为 128 字节,以 2 结尾的函数代表使用的缓存为 256字节,类似以 f 结尾的函数代表使用的缓存为 1024 字节。

u8g2_d_memory.c 文件也是同理,它需要根据 u8g2_d_setup.c 中的调用情况决定用到哪些函数。由于 u8g2_Setup_ssd1306_i2c_128x64_noname_f() 函数只用到 u8g2_m_16_8_f() 这一个函数,因此只需要保留它,其余函数全部删除即可。

还有一处必要的精简是字体文件 u8x8_fonts.cu8g2_fonts.c ,尤其是 u8g2_fonts.c ,该文件提供了包括汉字在内的几万个文字的多种字体,仅源文件就有 30MB ,编译后占据的内存非常大。

字体类型的变量非常多,建议先复制一个备份后将所有变量删除,之后视情况再添加字体。字体变量的命名大致遵循以下规则:

<prefix> '_' <name> '_' <purpose> <charset>

其中:

  • <prefix> 前缀基本上以 u8g2 开头;
  • <name> 字体名,其中可能包含字符大小
  • 各种 <purpose> 含义如下表所示:
名称 描述
t 透明字体形式
h 所有字符等高
m monospace 字体(等宽字体)
8 每一个字符都是 8x8 大小的
  • <charset> 是字体支持的字符集,如下表所示:
名称 描述
f 只包含单字节字符
r 只包含 ASCII 范围为 32~127 的字符
u 只包含 ASCII 范围为 32~95 的字符,即不包括小写英文
n 只包含数字及一些特殊用途字符
... 还包括许多自定义的字符集,例如有一些结尾带 gb2312 或 Chinese 的字体名就包括中文

一般建议只保留需要的字体即可。

添加回调函数

U8g2 已经包含了 SSD1306 的驱动,只需要添加一个函数 u8x8_gpio_and_delay() 用于模拟时序即可。官方文件给出了一个函数的编写模板为:

uint8_t u8x8_gpio_and_delay(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) {
    switch (msg) {
        case U8X8_MSG_GPIO_AND_DELAY_INIT:  // called once during init phase of u8g2/u8x8
            break;                          // can be used to setup pins
        case U8X8_MSG_DELAY_NANO:           // delay arg_int * 1 nano second
            break;  
        case U8X8_MSG_DELAY_100NANO:        // delay arg_int * 100 nano seconds
            break;
        /* and many other cases */
        case U8X8_MSG_GPIO_MENU_HOME:
            u8x8_SetGPIOResult(u8x8, /* get menu home pin state */ 0);
            break;
        default:
            u8x8_SetGPIOResult(u8x8, 1);     // default return value
            break;
    }
    return 1;
}

以下是一个写法示例:

uint8_t u8x8_gpio_and_delay(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) {
    switch (msg) {
        case U8X8_MSG_DELAY_100NANO: // delay arg_int * 100 nano seconds
            __NOP();
            break;
        case U8X8_MSG_DELAY_10MICRO: // delay arg_int * 10 micro seconds
            for (uint16_t n = 0; n < 320; n++)
                __NOP();
            break;
        case U8X8_MSG_DELAY_MILLI:   // delay arg_int * 1 milli second
            delay_ms(1);
            break;
        case U8X8_MSG_DELAY_I2C:     // arg_int is the I2C speed in 100KHz, e.g. 4 = 400 KHz
            delay_us(5);
            break;                    // arg_int=1: delay by 5us, arg_int = 4: delay by 1.25us
        case U8X8_MSG_GPIO_I2C_CLOCK: // arg_int=0: Output low at I2C clock pin
            arg_int ? GPIO_SetBits(GPIO_B, GPIO_Pin_6) : GPIO_ResetBits(GPIO_B, GPIO_Pin_6);  
            break;                    // arg_int=1: Input dir with pullup high for I2C clock pin
        case U8X8_MSG_GPIO_I2C_DATA:  // arg_int=0: Output low at I2C data pin
            arg_int ? GPIO_SetBits(GPIO_B, GPIO_Pin_7) : GPIO_ResetBits(GPIO_B, GPIO_Pin_7);  
            break;                    // arg_int=1: Input dir with pullup high for I2C data pin
        case U8X8_MSG_GPIO_MENU_SELECT:
            u8x8_SetGPIOResult(u8x8, /* get menu select pin state */ 0);
            break;
        case U8X8_MSG_GPIO_MENU_NEXT:
            u8x8_SetGPIOResult(u8x8, /* get menu next pin state */ 0);
            break;
        case U8X8_MSG_GPIO_MENU_PREV:
            u8x8_SetGPIOResult(u8x8, /* get menu prev pin state */ 0);
            break;
        case U8X8_MSG_GPIO_MENU_HOME:
            u8x8_SetGPIOResult(u8x8, /* get menu home pin state */ 0);
            break;
        default:
            u8x8_SetGPIOResult(u8x8, 1); // default return value
            break;
    }
    return 1;
}

如果使用的引脚不是 PB6 和 PB7 ,注意在对应的位置修改;如果是使用硬件 I2C 的方式,那么可以不需要模拟时序,但是需要编写硬件驱动函数。在结尾处,会给出一个基于标准库的硬件移植方法。

最后,不要忘记了初始化 I2C 对应的 GPIO 引脚。

U8g2简单使用

U8g2 的初始化可以参考如下步骤:

void u8g2_Init(u8g2_t *u8g2) {
    u8g2_Setup_ssd1306_i2c_128x64_noname_f(u8g2, U8G2_R0, u8x8_byte_sw_i2c, u8x8_gpio_and_delay);  // 初始化 u8g2 结构体
    u8g2_InitDisplay(u8g2);      // 根据所选的芯片进行初始化工作,初始化完成后,显示器处于关闭状态
    u8g2_SetPowerSave(u8g2, 0);  // 打开显示器
    u8g2_ClearBuffer(u8g2);
}

这里需要调用之前保留的 u8g2_Setup_ssd1306_128x64_noname_f() 函数,该函数的4个参数,其含义为:

  • u8g2 :需要配置的 U8g2 结构体
  • rotation :配置屏幕是否要旋转,默认使用 U8G2_R0 即可
  • byte_cb :传输字节的方式,这里使用软件 I2C 驱动,因此使用 U8g2 源码提供的 u8x8_byte_sw_i2c() 函数。如果是硬件 I2C 的话,可以参照编写自己的函数
  • gpio_and_delay_cb :提供给软件模拟 I2C 的 GPIO 输出和延时,使用之前编写的配置函数 u8x8_gpio_and_delay()

如果需要显示字符串,需要提前调用以下函数设置字体:

void u8g2_SetFont(u8g2_t *u8g2, const uint8_t *font);

U8g2 的绘制方式有 2 种,每种都有不同的特点。

首先是全屏缓存模式(Full screen buffer mode),它的特点是绘制速度快,并且所有的绘制方法都可以使用。但是这种模式需要大量的 RAM 空间,因此使用需要用到缓存为 1024 字节的初始化函数(函数名以 f 结尾)。

这种绘图的方式首先需要清除缓冲区,调用绘图 API 后绘制的内容会保留在缓存内,需要手动发送缓存的内容到屏幕上:

u8g2_t u8g2;
u8g2_ClearBuffer(&u8g2);
/* Draw Something */
u8g2_SendBuffer(&u8g2);

第二种是分页模式(Page mode),它同样可以使用所有的绘制方法,但绘制速度较慢,不过占用的 RAM 空间也少,可以使用 128 或 256 字节的缓存(函数名以 1 和 2 结尾)。

这种绘图的方式首先创建第一页,然后在一个 do...while 循环内部绘制图形,不断判断是否到达下一页,如果到达了就自动刷新缓存:

u8g2_FirstPage(&u8g2);
do {
    /* Draw Something */
} while (u8g2_NextPage(&u8g2));

可以认为分页模式是一块一块绘制的,只绘制需要的区域。

还可以使用 U8x8 的绘图模式,这种情况下需要使用 U8x8 提供的结构体以及一系列函数,这里不再说明。

绘图API

完整的 API 参考可以参见官方文档 https://github.com/olikraus/u8g2/wiki/u8g2reference/ ,里面不仅有 API 的介绍,还有绘制效果的图片演示。

U8g2 的坐标系和绝大多数 GUI 库一样,原点在左上角,(x, y) 往右下递增,坐标的单位为像素。

简单图形绘制

void u8g2_DrawPixel(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y);
void u8g2_DrawHLine(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t len);
void u8g2_DrawVLine(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t len);
void u8g2_DrawLine(u8g2_t *u8g2, u8g2_uint_t x1, u8g2_uint_t y1, u8g2_uint_t x2, u8g2_uint_t y2);

分别用于绘制像素点、根据左上角顶点 (x, y) 与长度 len 绘制水平线与垂直线,以及绘制两点之间的线段。

void u8g2_DrawFrame(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t w, u8g2_uint_t h);
void u8g2_DrawBox(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t w, u8g2_uint_t h);

根据左上角的 (x, y) 坐标与宽 wh 绘制空心与实心矩形。

void u8g2_DrawRBox(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t w, u8g2_uint_t h, u8g2_uint_t r);
void u8g2_DrawRFrame(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t w, u8g2_uint_t h, u8g2_uint_t r);

绘制实行与空心圆角矩形,多了一个参数圆角半径 r

void u8g2_DrawCircle(u8g2_t *u8g2, u8g2_uint_t x0, u8g2_uint_t y0, u8g2_uint_t rad, uint8_t option);
void u8g2_DrawDisc(u8g2_t *u8g2, u8g2_uint_t x0, u8g2_uint_t y0, u8g2_uint_t rad, uint8_t option);

根据圆心 (x0, y0) 绘制直径为 rad ×2+1 的空心圆和实心圆。

option 为圆的部分选项,此参数可控制绘制圆弧或扇形:

取值 结果
U8G_DRAW_ALL 整个圆弧
U8G2_DRAW_UPPER_RIGHT 右上部分的圆弧
U8G2_DRAW_UPPER_LEFT 左上部分的圆弧
U8G2_DRAW_LOWER_LEFT 左下部分的圆弧
U8G2_DRAW_LOWER_RIGHT 右下部分的圆弧

还可以使用按位或运算符 | 连接几个部分。

void u8g2_DrawEllipse(u8g2_t *u8g2, u8g2_uint_t x0, u8g2_uint_t y0, u8g2_uint_t rx, u8g2_uint_t ry, uint8_t option);
void u8g2_DrawFilledEllipse(u8g2_t *u8g2, u8g2_uint_t x0, u8g2_uint_t y0, u8g2_uint_t rx, u8g2_uint_t ry, uint8_t option);

根据圆心 (x0, y0) 和水平半径 rx 、竖直半径 ry 绘制空心和实心椭圆。

void u8g2_DrawTriangle(u8g2_t *u8g2, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2, int16_t y2);

根据三个点绘制实心三角形(空心三角形可以使用直线达到类似效果)。

void u8g2_DrawXBM(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t w, u8g2_uint_t h, const uint8_t *bitmap);

在图形左上角 (x, y) 根据宽 wh 绘制 XBM 格式的位图。可以使用 https://tools.clz.me/image-to-bitmap-array 工具将一般图片转换为位图代码。

和 Bitmap 有关的函数还有一个:

void u8g2_SetBitmapMode(u8g2_t *u8g2, uint8_t is_transparent);

该函数用于设置 Bitmap 是否透明。

字符显示

为了显示字符串,首先要设置字体。调用以下函数可以提前设置字体:

void u8g2_SetFont(u8g2_t *u8g2, const uint8_t *font);
void u8g2_SetFontMode(u8g2_t *u8g2, uint8_t is_transparent);

字体是一种特殊的位图,因此也可以设置是否透明。所有的字体保存在 u8g2_fonts.c 源文件中,注意在移植 U8g2 库时曾经裁剪过该文件。

u8g2_uint_t u8g2_DrawStr(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, const char *str);

在左下角 (x, y) 处显示字符串。注意,这个方法只能绘制 ASCII 字符。如有需要显示 Unicode 字符,需要使用以下函数:

u8g2_uint_t u8g2_DrawGlyph(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, uint16_t encoding);
u8g2_uint_t u8g2_DrawUTF8(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, const char *str);

绘制 Unicode 字符和字符串。U8g2 支持 16 位的 Unicode 字符集,因此 encoding 的范围被限制在 65535 。该函数绘制 Unicode 字符串时还需要对应的字体也支持 Unicode 字符。

注意这几个函数都有返回值,它们返回绘制成功的字符个数。

#define u8g2_GetAscent(u8g2)
#define u8g2_GetDescent(u8g2)

这两个宏定义用于获取字体基线以上和基线以下的高度。上文提到的显示字符串的函数实际上参数 y 指的是基线高度。此外注意基线以下的高度返回的是负值。

u8g2_uint_t u8g2_GetStrWidth(u8g2_t *u8g2, const char *s);
u8g2_uint_t u8g2_GetUTF8Width(u8g2_t *u8g2, const char *str);

获取当前字体下,字符串和 UTF-8 字符串的宽度,单位为像素。

void u8g2_SetFontDirection(u8g2_t *u8g2, uint8_t dir);

设置文字朝向,根据参数不同分别设置为正常朝向的顺时针旋转 dir ×90° 。

其它绘图相关API

void u8g2_SetClipWindow(u8g2_t *u8g2, u8g2_uint_t clip_x0, u8g2_uint_t clip_y0, u8g2_uint_t clip_x1, u8g2_uint_t clip_y1);

设置采集窗口大小,设置后绘制的图形只在该窗口范围内显示。设置后可以使用 u8g2_SetMaxClipWindow() 函数去掉该限制。

示例代码

以下官方示例代码可以在 OLED 上显示该库的 logo :

u8g2_t u8g2;
u8g2_FirstPage(&u8g2);
do {
    u8g2_SetFontMode(&u8g2, 1);
    u8g2_SetFontDirection(&u8g2, 0);
    u8g2_SetFont(&u8g2, u8g2_font_inb24_mf);
    u8g2_DrawStr(&u8g2, 0, 20, "U");
    u8g2_SetFontDirection(&u8g2, 1);
    u8g2_SetFont(&u8g2, u8g2_font_inb30_mn);
    u8g2_DrawStr(&u8g2, 21, 8, "8");
    u8g2_SetFontDirection(&u8g2, 0);
    u8g2_SetFont(&u8g2, u8g2_font_inb24_mf);
    u8g2_DrawStr(&u8g2, 51, 30, "g");
    u8g2_DrawStr(&u8g2, 67, 30, "\xb2");
    u8g2_DrawHLine(&u8g2, 2, 35, 47);
    u8g2_DrawHLine(&u8g2, 3, 36, 47);
    u8g2_DrawVLine(&u8g2, 45, 32, 12);
    u8g2_DrawVLine(&u8g2, 46, 33, 12);
    u8g2_SetFont(&u8g2, u8g2_font_4x6_tr);
    u8g2_DrawStr(&u8g2, 1, 54, "github.com/olikraus/u8g2");
} while (u8g2_NextPage(&u8g2));

附录:使用硬件I2C移植U8g2

硬件 I2C 效率上比软件 I2C 快了非常多,因此特别适合 U8g2 这种大型 UI 框架。下面基于标准库介绍硬件 I2C 的移植方式。

如果使用硬件 I2C ,需要在调用该函数(或类似函数)时,使用自己的硬件读写函数:

void u8g2_Setup_ssd1306_i2c_128x64_noname_f(u8g2_t *u8g2, const u8g2_cb_t *rotation, u8x8_msg_cb byte_cb, u8x8_msg_cb gpio_and_delay_cb);

首先还是需要编写一个 gpio_and_delay() 回调函数。不过由于这里是使用硬件 I2C ,因此不再需要提供 GPIO 和时序操作的支持,只需要提供一个毫秒级的延时即可:

uint8_t u8x8_gpio_and_delay_hw(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) {
    switch (msg) {
        case U8X8_MSG_DELAY_100NANO: // delay arg_int * 100 nano seconds
            break;
        case U8X8_MSG_DELAY_10MICRO: // delay arg_int * 10 micro seconds
            break;
        case U8X8_MSG_DELAY_MILLI: // delay arg_int * 1 milli second
            Delay_ms(1);
            break;
        case U8X8_MSG_DELAY_I2C: // arg_int is the I2C speed in 100KHz, e.g. 4 = 400 KHz
            break;                    // arg_int=1: delay by 5us, arg_int = 4: delay by 1.25us
        case U8X8_MSG_GPIO_I2C_CLOCK: // arg_int=0: Output low at I2C clock pin
            break;                    // arg_int=1: Input dir with pullup high for I2C clock pin
        case U8X8_MSG_GPIO_I2C_DATA:  // arg_int=0: Output low at I2C data pin
            break;                    // arg_int=1: Input dir with pullup high for I2C data pin
        case U8X8_MSG_GPIO_MENU_SELECT:
            u8x8_SetGPIOResult(u8x8, /* get menu select pin state */ 0);
            break;
        case U8X8_MSG_GPIO_MENU_NEXT:
            u8x8_SetGPIOResult(u8x8, /* get menu next pin state */ 0);
            break;
        case U8X8_MSG_GPIO_MENU_PREV:
            u8x8_SetGPIOResult(u8x8, /* get menu prev pin state */ 0);
            break;
        case U8X8_MSG_GPIO_MENU_HOME:
            u8x8_SetGPIOResult(u8x8, /* get menu home pin state */ 0);
            break;
        default:
            u8x8_SetGPIOResult(u8x8, 1); // default return value
            break;
    }
    return 1;
}

如果是使用硬件 I2C ,那么需要自行编写硬件驱动函数,向 OLED 写入字节。这个函数的编写可以参考官方提供的软件驱动函数 u8x8_byte_sw_i2c() ,一个编写示例为:

uint8_t u8x8_byte_hw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) {
    uint8_t* data = (uint8_t*) arg_ptr;
    switch(msg) {
        case U8X8_MSG_BYTE_SEND:
            while( arg_int-- > 0 ) {
                I2C_SendData(I2C1, *data++);
                while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED))
                    continue;
            }
            break;
        case U8X8_MSG_BYTE_INIT:
        /* add your custom code to init i2c subsystem */
            RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
            I2C_InitTypeDef I2C_InitStructure = {
                .I2C_Mode = I2C_Mode_I2C,
                .I2C_DutyCycle = I2C_DutyCycle_2,
                .I2C_OwnAddress1 = 0x10,
                .I2C_Ack = I2C_Ack_Enable,
                .I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit,
                .I2C_ClockSpeed = 400000
            };
            I2C_Init(I2C1, &I2C_InitStructure);
            I2C_Cmd(I2C1, ENABLE);  
            break;
        case U8X8_MSG_BYTE_SET_DC:
        /* ignored for i2c */
            break;
        case U8X8_MSG_BYTE_START_TRANSFER:
            while(I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY));
            I2C_GenerateSTART(I2C1, ENABLE);
            while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT))
                continue;
            I2C_Send7bitAddress(I2C1, 0x78, I2C_Direction_Transmitter);
            while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED))
                continue;
            break;
        case U8X8_MSG_BYTE_END_TRANSFER:
            I2C_GenerateSTOP(I2C1, ENABLE);
            break;
        default:
            return 0;
    }
    return 1;
}

从各个 case 标签可以很明白地看出一个 I2C 的读写过程:U8X8_MSG_BYTE_INIT 标签下需要初始化 I2C 外设,U8X8_MSG_BYTE_START_TRANSFER 标签产生起始信号并发出目标地址,U8X8_MSG_BYTE_SEND 标签开始发送字节,并且发送的字节存储在 *arg_ptr 参数中,arg_int 是字节的总长度( U8g2 库似乎一次不会传输多余 32 字节的信息)。最后,U8X8_MSG_BYTE_END_TRANSFER 标签处产生停止信号。

注意在使用硬件 I2C 时,GPIO 需要设置为复用开漏输出模式 GPIO_Mode_AF_OD

最后一步,用以上编写的硬件函数初始化 U8g2 驱动:

u8g2_Setup_ssd1306_i2c_128x64_noname_f(u8g2, U8G2_R0, u8x8_byte_hw_i2c, u8x8_gpio_and_delay_hw);

硬件移植过程完毕。

京ICP备2021034974号
contact me by hello@frozencandles.fun