【行空板K10】自定义楷体点阵显示彩色汉字

2026-09-251

板载字库芯片只有宋体?canvasText 没法给每个字单独上色?本文带你用「点阵取模 + RGB565 转换 + canvasDrawBitmap」三步在行空板 K10 上显示楷体「中秋快乐」,每个字颜色随机、每 2 秒循环变化。

一、先说痛点

行空板 K10 自带一块 240×320 TFT 彩屏,由 LVGL 驱动,中文字形来自板载的 GT30L24A3W 汉字字库芯片。unihiker_k10 库把字体回调写死成 my_custom_font_24 → GBK_24_GetData,也就是说:

  • 默认只有宋体,想要楷体、黑体、艺术字?芯片里没有。
  • Canvas::canvasText(text, x, y, color) 一次只能给整段文本设一个颜色,没法让「中」「秋」「快」「乐」四个字各显一种颜色。

要同时解决「换字体」和「每字独立配色」两个需求,最直接的办法是:自己生成点阵,自己画到屏幕上。

二、整体思路


系统楷体 .ttf ──PIL 渲染──▶ 1-bit 单色点阵(.c 数组)
│
▼
遍历像素,前景/背景转 RGB565
│
▼
canvasDrawBitmap(RGB565 真彩色位图)

关键在于 K10 的 `canvasDrawBitmap` 吃的是 **RGB565 真彩色**位图(`LV_IMG_CF_TRUE_COLOR`,每像素 2 字节),不是单色点阵。所以中间要做一次「单色点阵 → RGB565」的转换,而且这里有个**大坑**——字节序。

## 三、第一步:生成楷体点阵数据

我用了一个开源取模脚本 `font_converter.py`(来自[ `chinese-font-ssd1306` 技能](https://gitcode.com/zealsoft/chinese-font-ssd1306),原本针对 SSD1306 OLED,但取模逻辑通用)。它用 Pillow 把系统字体渲染成 **1-bit、MSB first、行优先**的点阵,输出 C 代码。

命令一行:

```bash
python font_converter.py -t "中秋快乐" -f 楷体 -s 40 --prefix kaiti -o kaiti_font.c

参数说明:

参数 含义
-t 要转换的文本
-f 字体名(PIL 能识别的系统字体名,如 楷体、宋体、黑体)
-s 字号,单位像素(这里 40,即 40×40)
--prefix 输出符号前缀,避免重名
-o 输出 C 文件名

生成的 kaiti_font.c 长这样:

typedef struct {
    uint16_t char_code;    // Unicode 编码
    uint8_t width;         // 字符宽度
    uint8_t height;        // 字符高度
    const uint8_t *data;   // 点阵数据指针
} CharInfo;

const unsigned char kaiti_char_0000[200] = { 0x00, 0x00, ... };  // '中'
const unsigned char kaiti_char_0001[200] = { ... };              // '秋'
const unsigned char kaiti_char_0002[200] = { ... };              // '快'
const unsigned char kaiti_char_0003[200] = { ... };              // '乐'

const CharInfo kaiti_chars[4] = {
    { 0x4E2D, 40, 40, kaiti_char_0000 },  // 中
    { 0x79CB, 40, 40, kaiti_char_0001 },  // 秋
    { 0x5FEB, 40, 40, kaiti_char_0002 },  // 快
    { 0x4E50, 40, 40, kaiti_char_0003 },  // 乐
};

const CharInfo* kaiti_find_char(uint16_t code) { ... }

40×40 的字,每行 (40+7)/8 = 5 字节,共 40×5 = 200 字节,正好对上。

四、第二步:理解点阵数据格式

点阵是 1-bit、MSB first、行优先排列。要取像素 (x, y):

int rowBytes = (width + 7) / 8;          // 每行字节数
uint8_t byteVal = data[y * rowBytes + x / 8];
bool pixel = (byteVal >> (7 - x % 8)) & 1;  // MSB 在前,所以高位对应 x=0

pixel == 1 是前景(字的笔画),pixel == 0 是背景。

五、第三步:单色点阵转 RGB565(重点 + 大坑)

5.1 canvasDrawBitmap 要什么

看库源码 unihiker_k10.cpp:680:

void Canvas::canvasDrawBitmap(int16_t x, int16_t y, int16_t w, int16_t h, const uint8_t* bitmap) {
    lv_img_dsc_t image;
    image.data_size = w * h * 2;              // 每像素 2 字节
    image.header.cf = LV_IMG_CF_TRUE_COLOR;  // 真彩色
    image.data = bitmap;
    this->canvasDrawImage(x, y, &image);
}

每像素 2 字节,格式 LV_IMG_CF_TRUE_COLOR,即数据要和 LVGL 内部 lv_color_t 的内存布局一致。

5.2 LV_COLOR_16_SWAP = 1 的坑

打开 lv_conf.h:

#define LV_COLOR_DEPTH 16
#define LV_COLOR_16_SWAP 1   // ← 关键!

LV_COLOR_16_SWAP = 1 是给 SPI 屏用的——ESP32 是小端,SPI 发送是大端,所以 LVGL 在内存里把 RGB565 的两个字节交换着存。这意味着 LV_IMG_CF_TRUE_COLOR 的位图数据也必须按交换后的字节序写。

推导结论:位图缓冲里每个像素 2 字节,要按大端存 RGB565(高字节在前):

uint16_t v = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);  // 标准 RGB565
buf[2*i]     = (v >> 8) & 0xFF;   // 高字节在前
buf[2*i + 1] = v & 0xFF;          // 低字节在后

如果你忘了交换,红蓝会互换、颜色全乱;如果按小端存,屏幕会花屏。这是整个流程里最容易翻车的地方。

5.3 转换函数

把上面两点合起来,单色点阵 → RGB565 大端位图:

uint16_t toRgb565(uint8_t r, uint8_t g, uint8_t b) {
    return ((uint16_t)(r >> 3) << 11) | ((uint16_t)(g >> 2) << 5) | (b >> 3);
}

void drawCharBitmap(const CharInfo* ci, int x, int y, uint32_t color) {
    uint8_t r = (color >> 16) & 0xFF;
    uint8_t g = (color >> 8) & 0xFF;
    uint8_t b = color & 0xFF;
    uint16_t fg = toRgb565(r, g, b);   // 前景色
    uint16_t bg = toRgb565(0, 0, 0);   // 背景色(黑)
    int rowBytes = (ci->width + 7) / 8;
    int idx = 0;
    for (int row = 0; row < ci->height; row++) {
        for (int col = 0; col < ci->width; col++) {
            uint8_t byteVal = ci->data[row * rowBytes + col / 8];
            bool pixel = (byteVal >> (7 - col % 8)) & 1;
            uint16_t v = pixel ? fg : bg;
            bitmapBuf[idx++] = (v >> 8) & 0xFF;   // 大端:高字节先
            bitmapBuf[idx++] = v & 0xFF;
        }
    }
    k10.canvas->canvasDrawBitmap(x, y, ci->width, ci->height, bitmapBuf);
}

六、第四步:完整代码与效果

6.1 文件结构

src/
├── main.cpp        // 主程序
├── kaiti_font.h    // 点阵声明(extern "C" 包装)
└── kaiti_font.c    // 点阵数据(取模脚本生成)

kaiti_font.h 用 extern "C" 包一下,让 C++ 能调 C 编译的点阵数据:

#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
    uint16_t char_code;
    uint8_t width;
    uint8_t height;
    const uint8_t *data;
} CharInfo;

extern const CharInfo kaiti_chars[4];
const CharInfo* kaiti_find_char(uint16_t code);

#ifdef __cplusplus
}
#endif

6.2 main.cpp

#include "unihiker_k10.h"
#include "kaiti_font.h"

UNIHIKER_K10 k10;
uint8_t screenDir = 2;  // 0/2 竖屏 240×320

const uint16_t charCodes[4] = {0x4E2D, 0x79CB, 0x5FEB, 0x4E50};  // 中秋快乐
const int charCount = 4;
const int fontSize = 40;
const int charGap = 16;
const int textY = (320 - fontSize) / 2;  // 垂直居中

uint8_t bitmapBuf[fontSize * fontSize * 2];

uint16_t toRgb565(uint8_t r, uint8_t g, uint8_t b) {
    return ((uint16_t)(r >> 3) << 11) | ((uint16_t)(g >> 2) << 5) | (b >> 3);
}

uint32_t randomBrightColor() {
    uint8_t r = random(0x40, 0x100);
    uint8_t g = random(0x40, 0x100);
    uint8_t b = random(0x40, 0x100);
    return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
}

void drawCharBitmap(const CharInfo* ci, int x, int y, uint32_t color) {
    uint8_t r = (color >> 16) & 0xFF;
    uint8_t g = (color >> 8) & 0xFF;
    uint8_t b = color & 0xFF;
    uint16_t fg = toRgb565(r, g, b);
    uint16_t bg = toRgb565(0, 0, 0);
    int rowBytes = (ci->width + 7) / 8;
    int idx = 0;
    for (int row = 0; row < ci->height; row++) {
        for (int col = 0; col < ci->width; col++) {
            uint8_t byteVal = ci->data[row * rowBytes + col / 8];
            bool pixel = (byteVal >> (7 - col % 8)) & 1;
            uint16_t v = pixel ? fg : bg;
            bitmapBuf[idx++] = (v >> 8) & 0xFF;
            bitmapBuf[idx++] = v & 0xFF;
        }
    }
    k10.canvas->canvasDrawBitmap(x, y, ci->width, ci->height, bitmapBuf);
}

void drawGreeting() {
    int totalWidth = charCount * fontSize + (charCount - 1) * charGap;
    int startX = (240 - totalWidth) / 2;  // 水平居中

    k10.canvas->canvasClear();
    for (int i = 0; i < charCount; i++) {
        const CharInfo* ci = kaiti_find_char(charCodes[i]);
        if (ci) {
            uint32_t color = randomBrightColor();
            drawCharBitmap(ci, startX + i * (fontSize + charGap), textY, color);
        }
    }
    k10.canvas->updateCanvas();
}

void setup() {
    k10.begin();
    k10.initScreen(screenDir);
    k10.creatCanvas();
    k10.setScreenBackground(0x000000);
    randomSeed(millis());
    drawGreeting();
}

void loop() {
    delay(2000);
    drawGreeting();
}

6.3 效果

屏幕中央显示楷体「中秋快乐」四个大字,每个字一种随机亮色(避免太暗看不清,RGB 下限取 0x40),黑色背景。每 2 秒 canvasClear + 重绘 + updateCanvas,颜色循环刷新。

【行空板K10】自定义楷体点阵显示彩色汉字_image_1.webp

七、踩坑记录

坑 1:LV_COLOR_16_SWAP 字节序

最隐蔽的坑。canvasDrawBitmap 传 LV_IMG_CF_TRUE_COLOR 数据,必须和 lv_color_t 内存布局一致。K10 的 lv_conf.h 里 LV_COLOR_16_SWAP = 1,所以位图要按大端存 RGB565(高字节在前)。按小端存会花屏,忘了交换会红蓝对调。

排查方法:先填一个纯红 RGB565 = 0xF800 的全屏位图,看屏幕是不是红;如果是蓝,就是字节序反了。

坑 2:C/C++ 混编要 extern "C"

kaiti_font.c 是 C 文件,main.cpp 是 C++。直接 extern 声明会因 C++ 的 name mangling 链接失败。用 extern "C" 包住声明即可,PlatformIO 会分别用 C 和 C++ 编译器编译对应文件。

坑 3:点阵位序 MSB first

不同取模工具的位序不同(有的 LSB first)。font_converter.py 是 MSB first,即 x=0 对应字节最高位。用 (byte >> (7 - x % 8)) & 1 取像素。如果你的点阵是 LSB first,要改成 (byte >> (x % 8)) & 1,否则字会左右镜像。

坑 4:工程路径不能含中文

PlatformIO 用 xtensa 链接器,中文路径会导致 ld.exe: cannot open map file .../firmware.map: No such file or directory。工程要放在纯英文路径下。

八、总结

需求 默认方案 本文方案
换字体(楷体等) 不支持,字库芯片只有宋体 取模脚本生成任意字体点阵
每字独立配色 canvasText 只能整段一色 逐字 canvasDrawBitmap
显示任意图形文字 受限 点阵即像素,自由度最高

核心就三步:取模 → 转 RGB565(注意字节序)→ canvasDrawBitmap。这套方法不依赖字库芯片,理论上任何能取模的字体(楷体、黑体、甚至手写体)都能上 K10 屏幕,每个字还能单独配色。中秋佳节,给 K10 换个楷体「中秋快乐」,仪式感拉满。


参考

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -