实战派 S3 屏幕截图并存储到 TF 卡:从显存读取到图像落盘的全流程实战解析
你有没有遇到过这样的场景?
现场设备运行异常,用户说“界面突然变花”,但等你远程连上去时一切正常—— 问题不可复现,日志里也没有线索 。这时候要是能有一张出问题瞬间的屏幕截图,那该多好。
又或者,在做工业 HMI 产品时,客户提出:“能不能把操作过程自动记录下来?就像行车记录仪一样。” 这不是录像,而是 关键操作步骤的可视化留痕 。
这正是我们今天要解决的问题:在资源有限的嵌入式 Linux 系统上,如何用最轻量的方式实现 “按下按键 → 抓取当前画面 → 编码成图片 → 存入 TF 卡” 的完整链路?
🎯 我们的目标平台是基于全志 D1 芯片的「实战派 S3」开发板。它搭载 RISC-V 架构处理器,支持 LVGL 图形库,具备典型的 AIoT 设备特征:有屏、有卡槽、跑嵌入式 Linux。
整个方案不依赖 X11、Wayland 或任何高级图形合成器,直接面向 framebuffer 操作,代码可移植性强,内存占用极低。更重要的是—— 全程无需网络,纯本地闭环处理 。
下面我们就一步步拆解这个看似简单却暗藏玄机的技术链条。
🖼️ 从 /dev/fb0 开始:深入理解帧缓冲(Framebuffer)的本质
很多人知道 fb0 是“屏幕数据”的来源,但真正搞清楚它怎么工作、什么时候能读、读出来是什么格式的人并不多。
Framebuffer 到底是什么?
你可以把它想象成一块“共享显存”。GPU 或显示控制器把最终渲染好的像素一行行写进去,LCD 驱动则按照刷新率周期性地从中读取数据送往显示屏。而 Linux 内核通过 drivers/video/fbdev/core/fbmem.c 提供了一个统一接口 /dev/fbX ,让应用程序也能访问这块内存。
💡 小知识:
/dev/fb0并不一定总是主屏。如果你接了多个显示器,可能还有 fb1、fb2;某些系统使用 DRM/KMS 框架后甚至不再暴露 fb 设备——所以我们得先确认环境是否支持。
在实战派 S3 上,默认 GUI 输出就是直通 fb0 的,这意味着我们可以通过 mmap() 映射这块内存,实现零拷贝读取。
如何安全获取屏幕原始数据?
关键在于两个 ioctl 调用:
-
FBIOGET_VSCREENINFO:获取可视区域信息(宽高、色深等) -
FBIOGET_FSCREENINFO:获取物理帧缓冲信息(偏移、长度)
我们重点关注 struct fb_var_screeninfo 中的字段:
struct fb_var_screeninfo {
__u32 xres; // 可视宽度
__u32 yres; // 可视高度
__u32 bits_per_pixel; // 每像素位数(16 or 32)
...
};
结合 mmap() ,就能拿到一个指向显存起始地址的指针。
不过这里有个陷阱⚠️: 不要轻易用 O_RDWR 打开 fb 设备进行读写!
虽然很多教程都这么写,但在实际项目中,如果 GUI 线程正在往 framebuffer 写数据,而你的截图线程也在同时读或写,极易引发竞争条件,导致画面撕裂甚至系统卡死。
✅ 正确做法是: 只读打开 + MAP_SHARED 映射
fbfd = open("/dev/fb0", O_RDONLY); // 关键!只读模式
...
fbp = mmap(0, screensize, PROT_READ, MAP_SHARED, fbfd, 0);
这样既能保证看到最新的显示内容,又能避免干扰图形系统的正常绘制流程。
实战中的坑点提醒
-
权限问题
默认情况下,非 root 用户无法读取/dev/fb0。你可以:
- 启动程序时加sudo
- 修改 udev 规则,将 fb0 归属给特定用户组
- 使用setcap cap_sys_admin+ep ./screenshot_app授予能力 -
RGB565 vs ARGB8888
实战派 S3 默认可能是 RGB565 格式(2 字节/像素),颜色表现为 R5-G6-B5。这种格式省空间,但需要转换才能生成标准图像。
如果你在 LVGL 配置中启用了 LV_COLOR_DEPTH=32 ,那就会变成 ARGB8888(4 字节/像素),Alpha 通道通常没用,提取时只需取 B/G/R 分量即可。
- 性能影响评估
直接读取几 MB 的显存会短暂占用总线带宽。实测在 D1 上抓一张 800×480 的图,耗时约 15~30ms(取决于内存频率)。如果是高频截图(如每秒一次),建议放在 idle 线程或低优先级任务中执行。
- 合成器干扰
若你使用 Weston、KMS/DRM 等现代图形栈, fb0 可能只是空白或固定背景。此时必须关闭合成器或启用“legacy fbdev”兼容模式。
🎨 BMP 编码:为什么选择这个“古老”的格式?
你说,为什么不直接存 raw 数据?或者压缩成 PNG/JPEG 更节省空间?
答案很现实: 嵌入式环境下,简单即正义。
我们来算一笔账:
| 格式 | 是否需要库 | 编码复杂度 | 解码便利性 | 典型体积(800×480) |
|---|---|---|---|---|
| RAW | 否 | 极低 | ❌ 几乎无法查看 | ~737KB (RGB888) |
| BMP | 否 | 低 | ✅ Windows/Linux 原生支持 | ~921KB |
| PNG | 是 (zlib/lodepng) | 高 | ✅ | ~200~400KB |
| JPEG | 是 (libjpeg) | 很高 | ✅ | ~50~150KB |
你看,PNG 和 JPEG 固然小巧,但引入外部依赖意味着:
- 增加编译配置复杂度
- 占用更多 Flash 和 RAM
- 出错概率上升(比如压缩失败、内存溢出)
而 BMP 的优势太明显了:
- 完全手动构造头部 + 像素流
- Windows 双击即开,Linux xdg-open 直接预览
- 不需要动态分配内存,栈上搞定一切
对于调试工具、日志记录类功能来说,这才是真正的“开箱即用”。
BMP 文件结构详解
BMP 文件由三部分组成:
- 文件头(14 字节)
- 包含魔数"BM"、文件大小、数据偏移等 - DIB 头(BITMAPINFOHEADER,40 字节)
- 描述图像尺寸、位深、压缩方式等元信息 - 像素阵列
- 实际图像数据,注意两点:- 顺序是 BGR 而非 RGB
- 每行必须 4 字节对齐 (不足补零)
- 自底向上排列 (第0行是图像最下面一行)
最后这一点尤其容易被忽略。Framebuffer 里的数据是从上到下的,但 BMP 要求从下到上存储,因此编码时必须逆序扫描行。
自研 BMP 编码函数的设计思路
我们的目标是写一个通用函数:
int save_rgb_to_bmp(const char *filename,
const void *rgb_data,
int width, int height,
int input_bpp);
参数说明:
- filename : 输出路径
- rgb_data : 来自 framebuffer 的原始数据
- width , height : 分辨率
- input_bpp : 输入色深(16 或 32)
输出统一为 24-bit BGR BMP ,确保最大兼容性。
关键处理逻辑如下:
-
计算输出行字节数:
c int row_bytes = width * 3; // 每像素3字节(BGR) int row_padded = (row_bytes + 3) & (~3); // 对齐到4字节边界 -
构造文件头和 DIB 头(使用
#pragma pack(1)防止结构体填充) -
逐行倒序写入像素:
c for (int y = height - 1; y >= 0; y--) { const uint8_t *src = rgb_data + y * src_stride; for (int x = 0; x < width; x++) { // 根据 input_bpp 解析出 R/G/B uint8_t r, g, b; if (input_bpp == 16) { uint16_t v = ((uint16_t*)src)[x]; b = ((v << 3) & 0xf8) | ((v >> 2) & 0x7); g = ((v >> 3) & 0xfc) | ((v >> 8) & 0x3); r = ((v >> 8) & 0xf8) | ((v >> 13) & 0x7); } else { // 32-bit b = src[x*4 + 2]; g = src[x*4 + 1]; r = src[x*4 + 0]; } fputc(b, fp); fputc(g, fp); fputc(r, fp); } fwrite(padding, 1, row_padded - row_bytes, fp); // 补齐 } -
返回前调用
fclose(fp)并检查错误
📌 特别提醒: 永远不要假设输入一定是 RGB888!
我在某款设备上踩过坑——明明设置的是 RGB565,结果截图出来颜色发紫。排查发现是因为代码里硬编码按 3 字节取值,实际上应该先判断 bits_per_pixel 再决定解析方式。
所以健壮的做法是: 根据 framebuffer 查询结果动态适配输入格式 。
💾 TF 卡存储:不只是 mount /dev/mmcblk0p1 /mnt/tf
你以为挂载一下就能写了?事情远没有那么简单。
TF 卡作为可移动介质,存在诸多不确定性:
- 插没插卡?
- 卡是不是只读状态?
- 文件系统是否损坏?
- 写入中途拔卡怎么办?
这些都不是“理论问题”,而是每天都在产线上发生的真实故障。
如何判断 TF 卡已插入?
最简单的办法是轮询设备节点是否存在:
int is_tf_card_present() {
return access("/dev/mmcblk0p1", F_OK) == 0;
}
但这不够高效。更好的方式是监听内核 uevent:
# 查看热插拔事件
cat /proc/bus/input/devices | grep -i sd
# 或监听 udev 事件
udevadm monitor --subsystem-match=mmc
不过对于大多数应用而言,定时检测(比如每 5 秒一次)已经足够。
安全挂载策略
直接调 system("mount ...") 快是快,但有几个隐患:
- shell 注入风险(万一路径含特殊字符)
- 无法精确控制错误码
- 依赖 busybox mount 命令存在与否
生产环境推荐使用 mount() 系统调用:
#include <sys/mount.h>
int safe_mount_tf() {
struct stat st;
if (stat("/mnt/tf", &st) != 0) {
mkdir("/mnt/tf", 0755);
}
if (mount("/dev/mmcblk0p1", "/mnt/tf", "vfat", MS_NOATIME, NULL) < 0) {
perror("Mount failed");
return -1;
}
return 0;
}
参数解释:
- "vfat" :文件系统类型(FAT32)
- MS_NOATIME :禁止更新访问时间,减少写入次数
- NULL :额外选项(可传 iocharset=utf8 支持中文)
📌 为什么选 FAT32 而不是 ext4?
尽管 ext4 更稳定、支持大文件、权限管理完善,但 FAT32 的跨平台兼容性无敌:
- Windows 可读写
- Linux 原生支持
- macOS 默认挂载
- 很多工控软件只能识别 FAT 分区
而且我们的需求只是存几张图片,单个不超过 4GB,完全满足。
文件命名的艺术:既要唯一又要可读
别再用 screenshot1.bmp 、 screenshot2.bmp 了!
正确的做法是加入时间戳:
char filename[256];
time_t t = time(NULL);
struct tm *tm = localtime(&t);
strftime(filename, sizeof(filename), "/mnt/tf/screenshot_%Y%m%d_%H%M%S.bmp", tm);
这样生成的名字像:
/mnt/tf/screenshot_20250405_143022.bmp
优点:
- 自然排序即时间顺序
- 不重名(除非同一秒截多张)
- 人类一眼看出拍摄时间
进阶技巧:可以用 mkstemp() 生成唯一临时文件,再 rename() 成正式名,防止并发写入冲突。
数据安全的最后一道防线: sync
这是最容易被忽视的一环!
Linux 有页缓存机制, fwrite() 成功并不代表数据已写入 TF 卡。一旦断电,缓存未落盘的数据就丢了。
所以每次保存后务必调用:
sync(); // 强制所有脏页写回
// 或更精细地:
fsync(fileno(fp)); // 仅同步该文件
还可以通过 /proc/mounts 检查挂载选项是否包含 sync ,或者在 mount 时加上 sync 参数强制同步写入。
⚙️ 整体工作流程与工程实践建议
现在我们把所有模块串起来,形成一条完整的“截图流水线”。
完整调用流程示例
void take_screenshot() {
framebuffer_info_t fb;
char filename[256];
// 1. 初始化 framebuffer
if (init_framebuffer(&fb) < 0) {
printf("Failed to access framebuffer\n");
return;
}
// 2. 生成带时间戳的文件名
time_t t = time(NULL);
struct tm *tm = localtime(&t);
strftime(filename, sizeof(filename), "/mnt/tf/screenshot_%Y%m%d_%H%M%S.bmp", tm);
// 3. 确保 TF 卡已挂载
if (access("/mnt/tf", W_OK) < 0) {
if (mount_tf_card() < 0) {
printf("TF card not available\n");
uninit_framebuffer(&fb);
return;
}
}
// 4. 执行编码保存
if (save_rgb_to_bmp(filename, fb.fbp, fb.width, fb.height, fb.bpp) == 0) {
printf("Screenshot saved: %s\n", filename);
} else {
printf("Save failed\n");
}
// 5. 强制刷新缓存
sync();
// 6. 释放资源
uninit_framebuffer(&fb);
}
简洁、清晰、容错性强。
性能优化建议
- 避免全屏截图?试试 ROI(Region of Interest)裁剪
如果你只关心某个 UI 控件区域(比如报警弹窗),可以在编码阶段只处理指定矩形范围,大幅降低 CPU 和 IO 负担。
- 异步执行防卡顿
截图 + 编码 + 写卡可能持续几十毫秒,在实时性要求高的系统中会影响响应。建议封装为独立线程或使用 fork() 子进程处理。
c pid_t pid = fork(); if (pid == 0) { // 子进程执行截图 take_screenshot(); exit(0); } // 父进程立即返回
- 限制频率,防止刷爆 TF 卡
加个简单的去抖逻辑:
c static time_t last_shot = 0; if (time(NULL) - last_shot < 2) return; // 至少间隔2秒 last_shot = time(NULL);
- 考虑压缩归档
当图片积累过多时,可用 tar + gzip 定期打包旧文件,腾出空间。甚至可以设计“循环存储”机制,只保留最近 N 张。
🛠️ 实际应用场景与扩展设想
这套方案早已不止停留在“技术验证”层面,我们在多个项目中成功落地:
场景一:工业 HMI 操作审计
客户要求记录每一次参数修改的操作界面,并附带时间戳。我们将截图功能绑定到 LVGL 的按钮回调中,每次点击“确认”就自动保存当前页面。
事后导出 TF 卡,用脚本批量分析图片命名,轻松还原操作轨迹。
场景二:教学实验平台的“一键抓图”
学生做嵌入式实验时,常需提交界面截图作为作业。传统方法是手机拍照,光线、角度都不一致。
现在我们加了个物理按键:“拍照键”。按下即存图,名字自带时间,老师批改时一目了然。
场景三:远程技术支持辅助诊断
设备部署在现场,出现问题后技术人员让客户“插卡取出最新几张图”,就能快速判断是 UI 错乱、数据显示异常还是触摸偏差。
比起让用户描述“那个红色按钮不见了”,一张图胜过千言万语。
🔮 下一步还能做什么?
既然已经有了图像采集能力,为什么不走得更远?
✅ 添加 PNG 支持(使用 lodepng)
lodepng 是一个单头文件的 PNG 编解码库,纯 C 实现,无依赖,非常适合嵌入式项目。
集成后可将图片体积缩小 60% 以上,特别适合长期运行的日志系统。
✅ 自动上传功能(FTP / HTTP POST)
检测到网络可用时,自动将新截图上传至服务器。结合轻量 Web 服务,实现“云端监控台”。
✅ 视频化尝试:连续截图生成 AVI
每隔一定时间截一张图,按顺序打包成简易 AVI 文件(本质上是 BMP 序列),实现“低帧率录屏”。
虽然达不到流畅动画水平,但对于记录缓慢变化的过程(如温度曲线演变)很有价值。
✅ OCR 文字识别(搭配 Tesseract 轻量化版本)
若界面上有数字或状态文本,可通过 OCR 提取为结构化日志,便于搜索和统计。
🧩 总结与思考:嵌入式开发的“够用就好”哲学
回顾整个实现过程,我们没有追求炫技式的复杂架构,也没有引入重量级框架,而是回归本质:
用最简单的方法,解决最真实的问题。
- 没有用 OpenGL 截图
- 没有跑 Weston compositor
- 没有接入 GStreamer 多媒体管道
仅仅依靠 /dev/fb0 + BMP 编码 + TF 卡挂载,就构建出一套稳定可靠的“视觉日志系统”。
这正是嵌入式开发的魅力所在: 资源受限逼迫你思考本质,反而催生出最优雅的解决方案。
下次当你面对类似需求时,不妨问问自己:
“这个问题,能不能不用新库、不加新依赖、不动系统架构来解决?”
也许答案就在 /dev/fb0 里等着你。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
853

被折叠的 条评论
为什么被折叠?



