一、实验目的

  1. 掌握嵌入式 Linux 系统下 Framebuffer(帧缓冲) 的编程方法,理解显存直接操作原理。
  2. 学习 BMP 图片文件 的二进制解析、BGR→RGB 色彩通道转换及最近邻缩放算法。
  3. 掌握 Alpha 混合 半透明叠加技术,实现文字签名的透明渲染。
  4. 理解 点阵字模 数据结构,实现汉字手写签名的逐像素绘制。
  5. 实现 百叶窗切换动画 特效,理解帧动画与时间片控制。
  6. 培养嵌入式图形编程的调试能力,解决渲染、位置、透明度等实际问题。

二、实验设备

设备 型号/说明
嵌入式开发主板 迅为 iTOP-4412(精英版),Exynos 4412 ARM Cortex-A9 四核
LCD 显示屏 7 寸 / 9.7 寸电容触摸屏,分辨率由内核自适应
图片素材 4 张 BMP 格式照片,存放于 SD 卡
调试工具 Xshell 8(串口终端,波特率 115200)
开发主机 Windows 11,交叉编译工具链 arm-none-linux-gnueabihf-gcc

三、实验原理

3.1 系统架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌─────────────────────────────────────────────────┐
│ 应用层程序 │
│ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ BMP 解析 │ │ 缩放渲染 │ │ 百叶窗动画 │ │
│ │ BGR→RGB │ │ 最近邻插值 │ │ 20条叶片 │ │
│ └──────────┘ └───────────┘ └──────────────┘ │
│ │ │ │ │
│ └─────────────┼───────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ 点阵字模渲染 │ │
│ │ 16×16 宋体 + │ │
│ │ Alpha 混合描边 │ │
│ └────────┬────────┘ │
│ │ │
│ ioctl + mmap │
│ │ │
│ ┌────────▼────────┐ │
│ │ /dev/fb0 │ │
│ │ 帧缓冲设备 │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ LCD 控制器 │ │
│ │ (物理显存) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────┘

3.2 Framebuffer 工作原理

Linux 将 LCD 显存抽象为 /dev/fb0 设备文件,应用程序通过 mmap 将显存映射到用户空间后,直接写入像素数据即可驱动屏幕刷新。

1
2
3
4
5
6
7
8
9
// ioctl 获取屏幕参数
struct fb_var_screeninfo vinfo; // 可变参数:分辨率、色深
struct fb_fix_screeninfo finfo; // 固定参数:显存大小、行长度

ioctl(fbfd, FBIOGET_VSCREENINFO, &vinfo); // 获取可变信息
ioctl(fbfd, FBIOGET_FSCREENINFO, &finfo); // 获取固定信息

// mmap 映射显存
fbp = mmap(0, finfo.smem_len, PROT_READ | PROT_WRITE, MAP_SHARED, fbfd, 0);

像素寻址公式(32 位色深):

1
2
3
4
像素地址 = x × (bits_per_pixel/8) + y × line_length
= x × 4 + y × line_length

写入格式:0x AARRGGBB(ARGB,A=0xFF 不透明)

3.3 BMP 文件解析

BMP 文件结构(24 位真彩色):

1
2
3
4
5
6
7
8
9
偏移量    字段           大小     说明
0x00 "BM" 2B 文件标识
0x02 文件总大小 4B
0x0A 像素数据偏移 4B 从文件头到实际像素的偏移
0x12 宽度 4B 像素
0x16 高度 4B 像素(正数 = 底部向上存储)
0x1C 色深 2B 24 = 每个像素 3 字节

像素数据存储格式:行末 4 字节对齐,每像素按 B-G-R 顺序

关键处理:BMP 的 BGR 顺序需要转换为 framebuffer 的 RGB 顺序:

1
2
3
bmp->rgb[dst + 0] = row[src + 2];  // B → R 通道
bmp->rgb[dst + 1] = row[src + 1]; // G → G 通道(不变)
bmp->rgb[dst + 2] = row[src + 0]; // R → B 通道

3.4 最近邻缩放算法

图片分辨率与屏幕分辨率不匹配时,采用最近邻插值进行缩放:

1
2
3
4
5
6
7
8
9
void draw_scaled(Bitmap *bmp) {
for (int y = 0; y < screen_h; y++) {
for (int x = 0; x < screen_w; x++) {
int sx = x * bmp->w / screen_w; // 源图 X 坐标
int sy = y * bmp->h / screen_h; // 源图 Y 坐标
// 从源图 (sx, sy) 取像素 → 绘制到屏幕 (x, y)
}
}
}

3.5 点阵字模与汉字渲染

每个汉字使用 16×16 点阵(32 字节),逐行取模、高位在前:

1
2
3
4
5
6
7
16 像素 →
┌────────────────┐
│ byte0 byte1 │ ← 第 0 行(8bit + 8bit = 16bit)
│ byte2 byte3 │ ← 第 1 行
│ ... ... │
│ byte30 byte31 │ ← 第 15 行
└────────────────┘

解析一行:

1
2
3
unsigned short row = (data[offset + dy*2] << 8) | data[offset + dy*2 + 1];
// row 的 bit15=最左像素, bit0=最右像素
if (row & (0x8000 >> dx)) ← 该位为1,绘制该像素

3.6 Alpha 混合与描边效果

1
2
3
4
5
6
半透明混合公式:
result = background × (1 - α) + foreground × α

描边方案:
步骤1: 在 (px+1, py+1) 位置用暗色 × α_s 画阴影
步骤2: 在 (px, py) 位置用白色 × α 画正文

参数设置:

  • 正文字体:白色 (255, 255, 255),不透明度 α = 0.65
  • 描边阴影:暗色叠加,不透明度 α_s = 0.45

3.7 百叶窗切换动画

将屏幕分为 20 条垂直叶片,逐帧增宽已翻开区域:

1
2
3
4
5
6
7
8
帧 1:  ████████░░░░  (progress 0.1)
帧 5: ████████████████████░░ (progress 0.5)
帧 10: ██████████████████████████████ (progress 1.0)

█ = 新图片区域 ░ = 旧图片区域

每条叶片 width = screen_w / 20
当前帧的翻开像素 = strip_offset < progress × strip_width

四、实验步骤

4.1 图片素材准备

  1. 选取 4 张 BMP 格式照片,命名为 photo1.bmp ~ photo4.bmp
  2. 建议尺寸:与 LCD 分辨率接近(如 1024×768),过大的图片会增加加载时间
  3. 将图片文件和编译好的可执行文件一同放入 SD 卡

4.2 签名字模准备

用字模提取工具(如 PCtoLCD2002),设置参数:

  • 字体:宋体,16×16 点阵
  • 取模方式:逐行,高位在前

提取 “蒋先宇” 三个字的字模数据,嵌入程序常量数组。

4.3 核心程序开发

程序流程:

1
2
3
4
5
6
7
8
9
10
main()
├─ open("/dev/fb0") ← 打开帧缓冲设备
├─ ioctl() × 2 ← 获取屏幕参数
├─ mmap() ← 映射显存
├─ load_bmp() × 4 ← 加载 4 张 BMP
└─ while(1) 轮播循环
├─ draw_scaled() ← 缩放绘制当前图片
├─ draw_name() ← 叠加半透明签名
├─ sleep(3) ← 显示 3 秒
└─ shutter_switch() ← 百叶窗切换到下一张

4.4 交叉编译

1
arm-none-linux-gnueabihf-gcc -static -o t8 t8_photo_frame.c

-static 静态链接确保在精简 Linux 环境也能运行。

4.5 部署运行

1
2
3
cp /mnt/usb/photo*.bmp /mnt/usb/t8 /tmp/
cd /tmp
./t8

五、核心代码及注释

5.1 点阵签名字模

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 16×16 宋体,逐行取模,高位在前,每字 32 字节
const unsigned char name_data[] = {
// 蒋
0x08,0x20, 0x08,0x20, 0xFF,0xFE, 0x08,0x20,
0x00,0x80, 0x08,0xF8, 0x49,0x10, 0x2A,0xA0,
0x28,0x50, 0x08,0x90, 0x1B,0xFE, 0x29,0x10,
0x48,0x90, 0x88,0x90, 0x08,0x10, 0x08,0x30,
// 先
0x01,0x00, 0x11,0x00, 0x11,0x00, 0x1F,0xF8,
0x21,0x00, 0x41,0x00, 0x01,0x00, 0xFF,0xFE,
0x04,0x40, 0x04,0x40, 0x04,0x40, 0x08,0x40,
0x08,0x42, 0x10,0x42, 0x20,0x3E, 0xC0,0x00,
// 宇
0x02,0x00, 0x01,0x00, 0x7F,0xFE, 0x40,0x02,
0x80,0x04, 0x3F,0xF8, 0x01,0x00, 0x01,0x00,
0x01,0x00, 0xFF,0xFE, 0x01,0x00, 0x01,0x00,
0x01,0x00, 0x01,0x00, 0x05,0x00, 0x02,0x00,
};

5.2 像素绘制函数

1
2
3
4
5
6
7
8
9
10
11
12
13
void draw_pixel(int x, int y, unsigned char r, unsigned char g, unsigned char b) {
// 显存寻址:x*像素字节数 + y*行长度
int loc = x * (vinfo.bits_per_pixel / 8) + y * finfo.line_length;

if (vinfo.bits_per_pixel == 32) {
// 32位 ARGB 格式:0xAARRGGBB
*(unsigned int *)(fbp + loc) = (0xFF << 24) | (r << 16) | (g << 8) | b;
} else if (vinfo.bits_per_pixel == 16) {
// 16位 RGB565 格式
*(unsigned short *)(fbp + loc) =
((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
}
}

5.3 BMP 文件加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int load_bmp(const char *filename, Bitmap *bmp) {
FILE *fp = fopen(filename, "rb");
unsigned char header[54];
fread(header, 1, 54, fp);

// 校验 BMP 魔数
if (header[0] != 'B' || header[1] != 'M') return -1;

// 提取关键参数
bmp->w = *(int *)(header + 18); // 宽度
bmp->h = *(int *)(header + 22); // 高度
int data_offset = *(int *)(header + 10); // 像素数据偏移

bmp->rgb = malloc(bmp->w * bmp->h * 3);
fseek(fp, data_offset, SEEK_SET);

// BMP 行末对齐到 4 字节边界
int row_pad = (bmp->w * 3 + 3) & (~3);
unsigned char *row = malloc(row_pad);

for (int y = 0; y < bmp->h; y++) {
fread(row, 1, row_pad, fp);
for (int x = 0; x < bmp->w; x++) {
int dst = (y * bmp->w + x) * 3;
int src = x * 3;
bmp->rgb[dst + 0] = row[src + 2]; // B → R
bmp->rgb[dst + 1] = row[src + 1]; // G → G
bmp->rgb[dst + 2] = row[src + 0]; // R → B
}
}
free(row);
fclose(fp);
return 0;
}

5.4 半透明签名绘制(核心算法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void draw_name(int x0, int y0) {
float alpha = 0.65f; // 正文白色不透明度(适当提高显眼度)
float alpha_s = 0.45f; // 描边阴影不透明度
int char_count = 3; // 蒋先宇 = 3 个字

for (int ch = 0; ch < char_count; ch++) {
int offset = ch * 32; // 每个字 32 字节,跳到当前字数据

for (int dy = 0; dy < 16; dy++) {
// 读取一行 16 位:2 字节拼接,高位在前
unsigned short row = (name_data[offset + dy*2] << 8)
| name_data[offset + dy*2 + 1];

for (int dx = 0; dx < 16; dx++) {
// 位为 0 的像素跳过
if (!(row & (0x8000 >> dx))) continue;

// 关键:每个字水平偏移 16 像素(3 字并排)
int px = x0 + ch * 16 + dx;
int py = y0 + dy;

// === 步骤1:右下方描边(暗色阴影)===
int sx = px + 1, sy = py + 1;
// 绘制暗色像素:(背景色 × 0.55) → 变暗效果
draw_pixel(sx, sy,
(unsigned char)(br * (1.0f - alpha_s)),
(unsigned char)(bg_g * (1.0f - alpha_s)),
(unsigned char)(bb * (1.0f - alpha_s)));

// === 步骤2:正文白色 Alpha 混合 ===
// 公式:result = background × (1-α) + white × α
draw_pixel(px, py,
(unsigned char)(br * (1.0f-alpha) + 255 * alpha),
(unsigned char)(bg_g * (1.0f-alpha) + 255 * alpha),
(unsigned char)(bb * (1.0f-alpha) + 255 * alpha));
}
}
}
}

5.5 百叶窗切换动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void shutter_switch(Bitmap *old_bmp, Bitmap *new_bmp) {
int strips = 20; // 叶片数量
int frames = 10; // 动画总帧数
int strip_w = sw / strips;

for (int f = 0; f <= frames; f++) {
float progress = (float)f / frames; // 0.0 → 1.0

for (int y = 0; y < sh; y++) {
for (int x = 0; x < sw; x++) {
int strip_idx = x % strips; // 当前在第几条叶片
int strip_off = x / strips; // 叶片内偏移

if (strip_off < (int)(progress * strip_w))
draw_new_pixel(x, y); // 已翻开 → 新图
else
draw_old_pixel(x, y); // 未翻开 → 旧图
}
}
usleep(60000); // 帧间隔 60ms,共 10 × 60ms = 600ms
}
}

5.6 主循环时序

1
2
3
4
5
6
7
while (1) {
draw_scaled(&pics[cur]); // 缩放绘制
draw_name((vinfo.xres - 48) / 2, vinfo.yres - 50); // 居中叠加签名
sleep(3); // 展示 3 秒
shutter_switch(&pics[cur], &pics[next]); // 百叶窗过渡
cur = next;
}

六、实验结果

测试项 预期结果 实际结果
BMP 文件加载 正确解析 4 张 BMP ✅ 正确,尺寸正确显示
图片缩放 适配屏幕分辨率,无变形 ✅ 正确,最近邻缩放无误
签名 “蒋先宇” 三字 3 个字在图片底部居中并排 ✅ 正确(修复后)
签名半透明效果 透明叠加,不遮挡图片 ✅ 正确,α=0.65 效果理想
签名描边效果 右下有暗色阴影,增强可读性 ✅ 正确,立体感明显
百叶窗切换 20 条叶片逐帧翻开 ✅ 正确,动画流畅(约 600ms)
自动轮播 3 秒一张,无限循环 ✅ 正确

七、踩坑记录与 Bug 修复

7.1 Bug #1:只显示 “蒋” 字,后两字不显示

现象:签名位置只绘制了第一个字 “蒋”,图片上看不到 “先” 和 “宇”。

原因draw_name() 函数只读取了 name_data[] 的前 32 字节(一个字),缺少外层的三个字循环。

修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 修复前:只处理一个字
for (int dy = 0; dy < 16; dy++) {
unsigned short row = (name_data[dy*2] << 8) | name_data[dy*2+1];
// ...只渲染了 name_data[0..31](蒋)
}

// 修复后:添加外层字符循环
for (int ch = 0; ch < 3; ch++) {
int offset = ch * 32; // 每字32字节偏移
for (int dy = 0; dy < 16; dy++) {
unsigned short row = (name_data[offset + dy*2] << 8)
| name_data[offset + dy*2 + 1];
// X 坐标偏移:px = x0 + ch * 16 + dx
}
}

7.2 Bug #2:签名过于模糊,几乎看不见

现象:原始方案 α = 0.35,白色文字几乎与背景融为一体,肉眼难以辨识。

原因:半透明度过低,白色叠加量不足以在复杂背景上形成可辨识的对比度。

修复

  1. 将正文不透明度从 0.35 提高到 0.65
  2. 增加描边阴影效果:在 (px+1, py+1) 位置先用暗色画出阴影,再在 (px, py) 画白色正文
  3. 这是典型的”描边文字”方案,立体感强且适应任何背景颜色

7.3 关键经验:Linux Framebuffer 调试

  • 无 X/Wayland 环境的 framebuffer 编程与桌面 GUI 编程完全不同,像素数据直接写入显存,没有窗口管理器。
  • line_length(行字节数)可能大于 xres × 4,因为硬件可能有多余的对齐字节。务必用 finfo.line_length 而非自己计算行偏移
  • 32 位色深下,bits_per_pixel = 32 但实际 RGB 只占低 24 位,高 8 位是 Alpha 通道(framebuffer 忽略)。

八、实验总结

8.1 技术收获

技术点 掌握程度
Framebuffer 显存直接操作 ✅ mmap + ioctl + 像素寻址
BMP 二进制格式解析 ✅ 文件头解析 + BGR→RGB + 行对齐
最近邻图像缩放 ✅ 源坐标映射公式
点阵字模渲染 ✅ 逐行取模 + 高位在前解析
Alpha 混合半透明 ✅ 背景× (1-α) + 前景× α
描边阴影效果 ✅ 偏移 + 双层叠加
百叶窗切换动画 ✅ 叶片分帧 + 进度控制

8.2 改进方向

  1. 双缓冲:当前直接写显存可能有撕裂感,可增加一个后台 buffer,用 FBIOPAN_DISPLAY 做页面翻转
  2. PNG/JPEG 支持:引入 libpng/libjpeg 解码库,支持更多图片格式
  3. 触摸屏交互:结合触摸屏驱动实现手指滑动切换、点击暂停等操作
  4. 更多切换特效:淡入淡出、翻转、缩放等
  5. FPS 优化:memcpy 整行替换逐像素绘制,减少 CPU 开销

实验日期:2026年6月15日

开发板:iTOP-4412(Exynos 4412)

工具链:arm-none-linux-gnueabihf-gcc 14.2

源代码t8_photo_frame.c