本文对应
moqian 驱动演示工程、epd_gui.h和font.h,行号按 2026-10-25 静态快照记录。本文没有运行图形代码或观察屏幕;文中计算用于理解现有实现,练习另行标明。
一、画布是内存,屏幕是另一端的器件
这里的图形库没有使用 LVGL。epd_gui.c 自己管理一块像素缓冲区,提供画点、画线、矩形、圆、字符和位图等函数。调用 epd_draw_pixel() 只会改变内存;必须再调用 epd_display_full() 等驱动函数,数据才会被送往显示控制器并触发刷新。
把画布理解为一张由位组成的纸:先在纸上写好整幅画面,再交给驱动输出。这样既能避免每画一个点就刷新电泳屏,也能让文字、线条和图片共用统一的像素格式。最终数据路径是:
- 文字或几何参数
- 计算需要修改的像素
- 坐标旋转与边界判断
- 修改画布中的位
- 驱动传输并刷新
查看原始 Mermaid 源码
flowchart LR
A[文字或几何参数] --> B[计算需要修改的像素]
B --> C[坐标旋转与边界判断]
C --> D[修改画布中的位]
D --> E[驱动传输并刷新]
二、为什么四百乘三百只需要一万五千字节
epd_ssd1683.h:17–21 定义物理宽高和缓冲大小:
#define EPD_WIDTH 400
#define EPD_HEIGHT 300
#define EPD_BUF_SIZE ((EPD_WIDTH * EPD_HEIGHT) / 8)
单色画面每个像素只需要 1 位,0 表示黑,1 表示白;一字节可表示相邻 8 个像素。总计 400×300=120000 位,除以 8 得到 15000 字节,约 14.65 KiB。每行 400/8=50 字节,300 行正好组成一帧。
这与“一像素一字节”不同,也与彩色显示常用的双字节像素格式不同。若分辨率宽度不是 8 的倍数,还必须先明确每行补齐规则,通常使用 (width+7)/8 计算行字节数,不能无条件把总像素数除以 8。当前物理宽度 400 可以整除 8,所以现有像素索引在未旋转时能够按 50 字节一行理解。
epd_get_buffer():23–39 延迟分配这块内存:首次调用先尝试 heap_caps_malloc(..., MALLOC_CAP_SPIRAM),失败再调用 malloc();成功后用 0xFF 填满,得到全白画布,之后重复调用返回同一指针。实际是否分配到外部内存取决于配置与运行结果,不能只看函数调用便认定已经成功使用外部内存。
分配失败返回 NULL,主程序必须检查;epd_clear_buffer():41–45 遇到未分配画布直接返回。当前没有配套释放函数,适合这份一次分配、持续复用的演示结构,但不意味着未来可以反复创建任意数量画布。
三、从坐标算出字节位置和位位置
epd_draw_pixel():98–111 是整个绘图库的核心:
uint32_t byte_idx = (y * EPD_WIDTH + x) / 8;
uint8_t bit_pos = 7 - (x % 8);
if (color == EPD_BLACK) {
frame_buf[byte_idx] &= ~(1 << bit_pos);
} else {
frame_buf[byte_idx] |= (1 << bit_pos);
}
先把二维坐标展开成线性像素序号,再除以 8 找到对应字节。位序从最高位开始,因此一字节最左侧像素对应第 7 位,最右侧对应第 0 位。按位与一个清零掩码可以只改目标位;按位或可以只置目标位,不会覆盖同字节的其他 7 个像素。
| 坐标 | 字节下标 | 位位置 | 从全白画布画成黑点后的该字节 |
|---|---|---|---|
(0,0) |
0 | 7 | 0x7F |
(7,0) |
0 | 0 | 若单独画此点则为 0xFE |
(8,0) |
1 | 7 | 0x7F |
(13,2) |
101 | 2 | 0xFB |
(399,299) |
14999 | 0 | 0xFE |
例如 (13,2):(2×400+13)/8=101,7-(13%8)=2,清掉掩码 0x04 对应的一位,所以 0xFF 变成 0xFB。若连续画 (0,0) 和 (7,0) 两点,第 0 字节最终是 0x7E,不是后一次操作覆盖前一次的 0xFE。
注意 epd_clear_buffer(EPD_WHITE) 只清内存,而 epd_clear() 是驱动里的实际全屏清白操作。两者名字相近,职责不同。
四、旋转先变换坐标,再访问原来的画布
epd_set_rotation():51–54 只保存方向值;epd_get_width()、epd_get_height():56–66 在九十度和二百七十度时返回 300×400。实际分配的画布仍是 400×300,没有重新分配,绘制前由 transform_coords():69–92 把逻辑坐标映射回物理坐标。
应当同时验证“逻辑范围”和“变换后范围”。当前九十度分支为 tx=y; ty=399-x。逻辑点 (0,0) 按该分支得到 (0,399),随后因为 ty>=300 被拒绝;但 (0,0) 明明在报告的 300×400 逻辑画布内。二百七十度分支 tx=299-y; ty=x 对合法逻辑点 (0,399) 又会产生负值,并在无符号转换后越界。这是静态可以算出的旋转边界问题,不能把提供了四个枚举值等同于四个方向全部验证通过。
未旋转演示没有自动证明旋转正确。后续修复需要明确顺逆时针定义,画四角编号并核对四个方向;本次只记录证据,没有修改公式。weixue/main/gui_paint.c 是另一套实现,其中 Paint_DrawPoint():123–130 直接映射坐标,不能把两份库的旋转行为混为一谈。
五、线、矩形和圆怎样复用画点
epd_draw_line():127–145 使用整数误差累积来决定下一步走横向、纵向或同时走两者,可以用来理解课堂上的直线光栅化:理想直线连续,像素网格离散,需要决定哪些格点最接近直线。这里最终仍反复调用 epd_draw_pixel()。
epd_draw_rect():147–154 调用四次画线,终点使用 x+w-1、y+h-1,所以宽 10 表示包括起点在内的 10 列;epd_fill_rect():156–164 则双层循环逐点填色。空心圆 166–191 利用八方向对称;实心圆 193–202 按每行横截面计算可填充的横向范围。
这几种写法清晰易学,但要注意参数边界:宽高为零时,矩形端点减一会产生不合理坐标;无符号坐标也不能直接表达画布左上方的负坐标。底层画点会拒绝部分越界点,却不等于所有上层循环和算术已经安全。当前演示使用的是固定合法尺寸,扩展接口时需要独立检查输入。
六、一个字符怎样变成一百二十八个像素位置
font.h:11–15 定义 FONT_WIDTH=8、FONT_HEIGHT=16,并存放从空格 0x20 到 ~ 的 95 个字符,每字 16 字节。每字节代表一行,每行 8 位,所以字模占 8×16/8=16 字节,整个字模表的图形数据为 95×16=1520 字节。
epd_draw_char():208–223 的过程是:先检查字符范围,超范围替换成 ?;用 ch-0x20 找到字模;按行取出一个字节;用 0x80>>col 检查每一列;遇到字模中的 1,就在画布上画指定颜色的像素。
if (ch < 0x20 || ch > 0x7E) ch = '?';
const uint8_t *glyph = font8x16[ch - 0x20];
for (int row = 0; row < FONT_HEIGHT; row++) {
uint8_t bits = glyph[row];
for (int col = 0; col < FONT_WIDTH; col++) {
if (bits & (0x80 >> col)) {
epd_draw_pixel(x + col, y + row, color);
}
}
}
字模中的 0 不会自动擦成背景色,因此当前字符绘制是只画笔画的方式。在同一个位置把“888”改为“111”,旧笔画可能留下;应先清理足够大的区域,再画新字。main.c 的计数器演示每次先清白整块缓冲区,局刷演示则逐点清白内部区域。
epd_draw_string():225–239 每处理一个字节横向前进 8 像素,遇到换行符回到起始横坐标并向下移动 16 像素;没有自动折行。居中函数 241–248 先统计首行字节数,再计算 (有效宽度-字符数×8)/2。如果文本比画布宽,不能期待它自动裁剪并正确居中,还需检查无符号坐标转换。
七、当前字库并不支持中文
源码里有中文注释、笔记标题用中文,都不代表屏幕能显示中文。当前绘制接口逐个 char 处理字符串,没有 UTF-8 解码,也没有汉字点阵表。常见 UTF-8 汉字占多个字节,这些字节超出 0x20–0x7E,会分别走替换字符路径。把字符串直接改成“温度”不会自动得到两个汉字。
完整中文显示至少需要三个环节:把输入字节序列解码为字符编号;根据编号找到对应字模;按字模尺寸绘制并计算排版。若只需要少量固定汉字,也可以预生成所需字模或图片,但那是后续扩展方案,不是当前源码已有能力。对照工程 weixue/main/gui_paint.c:94–102 的索引映射主要也是数字、部分符号和英文字母,不能据它补写当前工程已经具备中文终端。
八、位图输入也要核对行步长
epd_draw_image():254–269 用 (row*w+col)/8 找字节,用 7-(col%8) 找位,并且只绘制输入中为 1 的像素。位图中的 1 是“需要画笔画”的掩码,而输出画布中的 1 是“白色像素”,两者语义要分开。
当前计算在图片宽度为 8 的倍数时容易保持一致;若宽度不是 8 的倍数,字节下标按连续像素计算,位位置却每行从最高位重新开始,不能同时满足常见的按行补齐格式或连续打包格式。例如宽 10 的第二行首点,连续打包应落在第二字节的第 5 位,当前却使用第 7 位。准备图片时必须明确格式和行步长,不能随意从任意图片工具导出就认为能显示。
九、可验证练习
- 手算两点。 从全白画布开始,依次画
(0,0)、(7,0)为黑。验收:第 0 字节为0x7E,第 1 字节不变;解释两次按位操作为什么不会互相覆盖。 - 手画字模一行。 假设某行字节为
0x81,标出 8 个像素中会被绘制的位置。验收:最左和最右两点被绘制,中间不改动。 - 追踪文本布局。 起点
(16,20)绘制AB\nC。验收:三个字符左上角分别为(16,20)、(24,20)、(16,36)。 - 纸面验证旋转。 把四个方向的逻辑四角代入当前变换。验收:记录哪些点被拒绝,并能指出问题发生在坐标变换阶段,而不是串行总线阶段。
- 后续实物练习,尚未执行。 在单独验证版本中画四角标记、8 像素宽条纹、英文与中文输入;记录真实画面并与字节计算对照。先验证零度、固定尺寸和英文,再逐项扩展。
十、官方参考
- 微雪主手册中的图像格式说明:单色一位一像素、最高位优先等概念。页面还介绍其他版本的灰阶,当前
moqian画布并未实现灰阶。 - 乐鑫内存能力分配文档:用于核对
heap_caps_malloc()和分配能力标志。画布最终所在内存和串行传输时是否需要临时复制,应以运行证据为准。 - 乐鑫串行总线主机驱动文档:缓冲区与直接内存访问要求。不能把“优先申请外部内存”直接等同于“已实现无复制传输”。
来源与关联索引
- 来源
- 嵌入式比赛
- 原笔记
- 项目/墨嵌墨水屏/知识讲解/03-画布像素与文字显示.md
- 资料性质
- 实践指南。原文的版本、来源与验证边界继续保留。