简介
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.c
和 u8g2_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.c
和 u8g2_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
) 坐标与宽 w
高 h
绘制空心与实心矩形。
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
) 根据宽 w
高 h
绘制 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);
硬件移植过程完毕。