实战派 S3 屏幕截图并存储到 TF 卡

AI助手已提取文章相关产品:

实战派 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);

这样既能保证看到最新的显示内容,又能避免干扰图形系统的正常绘制流程。

实战中的坑点提醒

  1. 权限问题
    默认情况下,非 root 用户无法读取 /dev/fb0 。你可以:
    - 启动程序时加 sudo
    - 修改 udev 规则,将 fb0 归属给特定用户组
    - 使用 setcap cap_sys_admin+ep ./screenshot_app 授予能力

  2. RGB565 vs ARGB8888

实战派 S3 默认可能是 RGB565 格式(2 字节/像素),颜色表现为 R5-G6-B5。这种格式省空间,但需要转换才能生成标准图像。

如果你在 LVGL 配置中启用了 LV_COLOR_DEPTH=32 ,那就会变成 ARGB8888(4 字节/像素),Alpha 通道通常没用,提取时只需取 B/G/R 分量即可。

  1. 性能影响评估

直接读取几 MB 的显存会短暂占用总线带宽。实测在 D1 上抓一张 800×480 的图,耗时约 15~30ms(取决于内存频率)。如果是高频截图(如每秒一次),建议放在 idle 线程或低优先级任务中执行。

  1. 合成器干扰

若你使用 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 文件由三部分组成:

  1. 文件头(14 字节)
    - 包含魔数 "BM" 、文件大小、数据偏移等
  2. DIB 头(BITMAPINFOHEADER,40 字节)
    - 描述图像尺寸、位深、压缩方式等元信息
  3. 像素阵列
    - 实际图像数据,注意两点:
    • 顺序是 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 ,确保最大兼容性。

关键处理逻辑如下:
  1. 计算输出行字节数:
    c int row_bytes = width * 3; // 每像素3字节(BGR) int row_padded = (row_bytes + 3) & (~3); // 对齐到4字节边界

  2. 构造文件头和 DIB 头(使用 #pragma pack(1) 防止结构体填充)

  3. 逐行倒序写入像素:
    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); // 补齐 }

  4. 返回前调用 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);
}

简洁、清晰、容错性强。

性能优化建议

  1. 避免全屏截图?试试 ROI(Region of Interest)裁剪

如果你只关心某个 UI 控件区域(比如报警弹窗),可以在编码阶段只处理指定矩形范围,大幅降低 CPU 和 IO 负担。

  1. 异步执行防卡顿

截图 + 编码 + 写卡可能持续几十毫秒,在实时性要求高的系统中会影响响应。建议封装为独立线程或使用 fork() 子进程处理。

c pid_t pid = fork(); if (pid == 0) { // 子进程执行截图 take_screenshot(); exit(0); } // 父进程立即返回

  1. 限制频率,防止刷爆 TF 卡

加个简单的去抖逻辑:

c static time_t last_shot = 0; if (time(NULL) - last_shot < 2) return; // 至少间隔2秒 last_shot = time(NULL);

  1. 考虑压缩归档

当图片积累过多时,可用 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),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值