psplash源码解析:framebuffer开机动画与嵌入式启动排障
简介:psplash源码是一套面向ARM架构与嵌入式Linux的开源启动画面实现,适合树莓派、开发板等设备在系统启动或更新阶段展示进度条与动画界面,帮助开发者理解并定制更友好的开机视觉体验。压缩包共79个文件,约2.67MB,以C源码和头文件为核心,辅以目标文件、本地化po、PNG图像资源以及构建脚本和说明文档,便于整体把握工程结构与编译脉络。已有667人学习下载。读者可从中梳理启动接管、图形绘制、framebuffer显示后端、配置裁剪与交叉编译集成等关键环节,也可据此调整颜色、字体、进度条样式和动画效果,针对特定硬件做调试优化,或扩展错误提示与系统状态信息,从而提升嵌入式Linux启动界面开发与问题排查能力。
1. 板子加电屏幕却是黑的那几秒,psplash 管的就是这块屏幕
板子加电,串口日志刷得飞快,屏幕却一直黑着——这种情况我遇到过不止一次,最后查下来内核没挂,只是没人接管那块 framebuffer。psplash 补的就是这个位置:一个几百 KB 的常驻进程,打开/dev/fb0、mmap 显存、贴一张 logo,然后挂一条 FIFO 等启动脚本把进度报过来。它不依赖 X、不依赖 Wayland、不需要 Qt,内核把 framebuffer 驱动一注册它就能跑,所以从 initramfs 阶段就能上屏。适合两类人:正在给定制板子做开机体验的嵌入式工程师,以及想找一个「能一口气读完」的 framebuffer 绘图样本的 C 语言学习者。代码量小到一晚上能看完,但真上板子时踩的坑,一晚上不一定填得完。
2. 技术栈与目录结构:几百 KB 的进程凭什么能在 initramfs 里跑起来
2.1 选型逻辑:直写 /dev/fb0,绕开整条图形栈
这个项目的技术栈可以说是「几乎没有技术栈」。它只依赖 libc、ioctl、mmap和 POSIX 的mkfifo/select,编译产物是两三个可执行文件——一个主进程psplash,一个小工具psplash-write。没有图形库,没有字体引擎,没有窗口系统。带来的是启动时序上的巨大优势:在 init 进程还没把 udev、dbus、显示服务拉起来的时候,只要内核的 framebuffer 驱动 probe 成功,/dev/fb0就在,psplash 就能画。
代价也很明确,它的能力边界就是「一张静态图 + 一条进度条 + 一行可选文字」。没有缩放、没有旋转之外的合成、没有抗锯齿、没有透明混合。图片是编译期以 C 数组形式烧进.rodata的,运行时不做任何解码。这一点直接影响你的镜像体积:换一张 1920×1080 的 RGBA 图,就是 1920×1080×4 约 8MB 直接进二进制,很多人第一次换图发现 rootfs 突然大了一截,原因就在这。
刷新策略也值得说清楚:它直接往 mmap 出来的显存里写,没有双缓冲、没有 vsync 等待,靠的是「只重绘变化的小矩形」来避免整屏撕裂。psplash_fb_flush在不同分支里实现不一样,有的版本是空实现(fbdev 本身就即时生效),有的版本会调FBIO_WAITFORVSYNC或者msync。所以你在桌面 Linux 上跑它,看到的可能是闪一下;在真正的嵌入式屏上,反而稳得多。
还有一层是编译系统。仓库用的是 autotools:configure.ac+Makefile.am,交叉编译时靠--host传工具链前缀。对只改图的人来说这层可以完全无视;但要做产品化裁剪(去掉进度条、改 FIFO 路径、换默认图),就得从configure.ac里的AC_ARG_ENABLE一路看到psplash-config.h,因为这些开关最终都是以-D宏的形式传进编译单元的。
2.2 目录里每个文件到底该不该动
刚拿到源码的人容易犯一个错:以为psplash.c是全部。其实真正干活的绘图代码在psplash-fb.c和psplash-draw.c里,psplash.c只负责「开资源、进循环、分发命令」。分清这三个文件的职责,后面改起来会顺很多。
| 文件 | 职责 | 改动频率 |
|---|---|---|
psplash.c | main、FIFO 事件循环、命令分发、退出清理 | 极低,一年动一两次 |
psplash-fb.c | 打开/dev/fb0、ioctl取参数、mmap、画点/画矩形/贴图、flush | 换屏、换色深时动 |
psplash-draw.c | 业务层绘图:铺底图、画进度条、画文字、背景保存与恢复 | 改视觉时动 |
psplash-write.c | 客户端小工具,把PROGRESS 50这类参数编码成字节流写进 FIFO | 基本不动 |
psplash-console.c | VT 模式切换、光标隐藏、键盘关闭与恢复 | 只在排查时看 |
psplash-colors.h | 背景色、进度条填充色、边框色、文字色 | 经常改 |
psplash-config.h | FIFO 路径、进度条尺寸等编译期宏 | 偶尔改 |
| 图片头文件 | 由 PNG 转出来的 C 数组,含宽高/行跨度/像素格式宏 | 每次换图都动 |
图片头文件的名字各家不一样,有的叫psplash-image.h,有的按发行版叫别的名字,但结构是统一的:一堆IMAGE_WIDTH、IMAGE_HEIGHT、IMAGE_BYTES_PER_PIXEL、IMAGE_ROWSTRIDE、IMAGE_PIXEL_FORMAT、IMAGE_BYTES宏,加上一个巨大的unsigned char IMAGE[]。这个宏集合就是绘图代码和图片之间唯一的契约,换图时这几个宏必须和实际数据严格对齐,否则就是典型的花屏——后面排查章节会专门讲这个。
Makefile.am里通常还会装一个把 PNG 转成这种头文件的小脚本,内部调的是gdk-pixbuf-csource之类的工具。如果你的构建环境里没有这个工具,自己用 ImageMagick 导一段 RGBA 原始数据、再拼成 C 数组也完全等价,最后一章我给一份能直接抄的转换脚本。
3. 主流程拆解:一次开机从 main 到第一帧像素
3.1 main 里的初始化顺序,每一步都有讲究
main不长,但顺序不能乱:先建 FIFO 再开 fb,先存 VT 状态再画图,先画完底图再进事件循环。下面这段是按源码结构整理的骨架,去掉了几处平台分支和错误处理分支。
int main (int argc, char **argv) { PSplashFB *fb; int pipe_fd, orientation = 0; char buf[2048]; /* 1. 建 FIFO 并立刻以「非阻塞读」打开: 如果先阻塞打开读端,在没有写端之前进程会挂在这里, 后面所有初始化都执行不到,屏幕就一直黑着。 */ mkfifo (PSPLASH_FIFO, 0777); pipe_fd = open (PSPLASH_FIFO, O_RDONLY | O_NONBLOCK); /* 2. 存下当前控制台(VT)状态,切图形模式、隐藏光标、关键盘 */ psplash_console_save_and_reset (); /* 3. 打开 /dev/fb0,拿分辨率/位深/行跨度,mmap 出显存指针 */ fb = psplash_fb_new (orientation); if (fb == NULL) { psplash_console_reset (); return 1; } /* 4. 铺底色 + 把编译进来的 logo 贴到屏幕中间 */ psplash_draw_splash (fb); /* 5. 事件循环:等 FIFO 可读,读 1 字节命令再决定画什么 */ for (;;) { fd_set fdset; struct timeval tv = { 5, 0 }; FD_ZERO (&fdset); FD_SET (pipe_fd, &fdset); if (select (pipe_fd + 1, &fdset, NULL, NULL, &tv) > 0) { char cmd; if (read (pipe_fd, &cmd, 1) == 1) { switch (cmd) { case PSPLASH_CMD_MSG: read_arg (pipe_fd, buf, sizeof (buf)); psplash_draw_msg (fb, buf); break; case PSPLASH_CMD_PROGRESS: read_arg (pipe_fd, buf, sizeof (buf)); psplash_draw_progress (fb, atoi (buf)); break; case PSPLASH_CMD_QUIT: goto done; } } } } done: psplash_console_reset (); return 0; }几个参数和细节要说清楚。PSPLASH_FIFO是一个编译期宏,常见值是/tmp/psplash_fifo或者/run/psplash_fifo,早期版本放在/tmp是因为 initramfs 阶段/run可能还没挂 tmpfs;如果你的 rootfs 在切根时会把/tmp清掉,FIFO 就"消失"了,表现为切根之后所有命令都没反应——这是很隐蔽的一个坑。orientation是从命令行参数解析出来的旋转角度(0/90/180/270),只有带旋转需求的屏才用得上。
select的 5 秒超时不是为了做心跳,而是给进程一个周期性醒来的机会,某些分支会借这个时机检查父进程状态或处理被信号打断的EINTR。read_arg是我为了突出主线抽出来的辅助函数,实际源码里是原地循环read直到读满或者读到'\0',逻辑等价。
3.2 framebuffer 打开与像素写入:所有花屏都出在这二十行
psplash_fb_new是这个项目真正的"硬件适配层",它的核心只有四个调用:open、两个ioctl、一个mmap。难点不在调用,而在于你不能假设任何事——分辨率、位深、像素通道顺序、行跨度,全部要从fb_var_screeninfo和fb_fix_screeninfo里问出来。
PSplashFB *psplash_fb_new (int angle) { struct fb_var_screeninfo var; struct fb_fix_screeninfo fix; PSplashFB *fb = calloc (1, sizeof (PSplashFB)); fb->fd = open ("/dev/fb0", O_RDWR); if (fb->fd < 0) { free (fb); return NULL; } ioctl (fb->fd, FBIOGET_VSCREENINFO, &var); /* 可见分辨率、bpp、通道偏移 */ ioctl (fb->fd, FBIOGET_FSCREENINFO, &fix); /* 显存长度、行跨度 line_length */ fb->width = var.xres; fb->height = var.yres; fb->bpp = var.bits_per_pixel; fb->rowstride = fix.line_length; /* 关键:不是 width * bpp / 8 */ /* 通道顺序以驱动上报为准,别写死 RGB */ fb->red_offset = var.red.offset; fb->green_offset = var.green.offset; fb->blue_offset = var.blue.offset; fb->data = mmap (NULL, fix.smem_len, PROT_READ | PROT_WRITE, MAP_SHARED, fb->fd, 0); if (fb->data == MAP_FAILED) { close (fb->fd); free (fb); return NULL; } return fb; }rowstride这一行是整段里最容易出事的地方。很多屏的xres_virtual比xres大,硬件为保证 DMA 效率会把每行补齐到 32 字节甚至 64 字节对齐,于是line_length大于width * bpp / 8。如果你按width * bpp / 8算偏移,画面就会随着行号增加逐渐向右斜,形成经典的"斜切"花屏。用fix.line_length就永远不会错。
真正的画点函数反而简单:算偏移、按位深写、按通道偏移移位。
void psplash_fb_plot_pixel (PSplashFB *fb, int x, int y, uint8_t red, uint8_t green, uint8_t blue) { unsigned int off; /* 越界直接丢,省掉上层所有调用者的边界判断 */ if (x < 0 || x >= fb->width || y < 0 || y >= fb->height) return; off = y * fb->rowstride + x * (fb->bpp / 8); switch (fb->bpp) { case 32: { uint32_t px = (red << fb->red_offset) | (green << fb->green_offset) | (blue << fb->blue_offset); *(uint32_t *)(fb->data + off) = px; break; } case 16: { /* RGB565 常见,通道位宽由驱动上报,这里按 5/6/5 压缩 */ uint16_t px = ((red >> 3) << fb->red_offset) | ((green >> 2) << fb->green_offset) | ((blue >> 3) << fb->blue_offset); *(uint16_t *)(fb->data + off) = px; break; } default: break; } }参数上要注意,red/green/blue传的是 0~255 的满量程值,16bpp 分支里必须右移截断,否则高位溢出会把相邻通道污染成紫色或者绿色噪点。off用unsigned int而不是int,是因为大分辨率下height * rowstride可能超过 2GB 的一半,虽然实践中不会,但类型选对省事。所有矩形、图片、文字的绘制最终都落到这个函数上,所以它的性能就是整个 psplash 的性能——一帧 1080p 全屏重绘大概两百万次调用,在 1GHz 的 ARM 上会明显卡顿,这也是为什么 psplash 坚持局部重绘。
3.3 进度条绘制:先存背景,再画新值
进度条的逻辑看着简单,但它要解决一个具体问题:每次进度变化时,怎么把上一次画的内容擦干净,又不影响周围的 logo 和背景。psplash 的做法是"保存-恢复"——第一次画之前把进度条那个矩形区域的原始像素拷进内存缓冲,之后每次重绘都先贴回背景再画新的。
void psplash_draw_progress (PSplashFB *fb, int value) { int x, y, w, h; /* 夹紧到 0~100,防止启动脚本传进来 -1 或者 120 把矩形画穿 */ if (value < 0) value = 0; if (value > 100) value = 100; x = (fb->width - BAR_WIDTH) / 2; y = (fb->height - BAR_HEIGHT) / 2 + BAR_VERTICAL_OFFSET; /* 用存下来的背景把旧进度条擦掉 */ psplash_draw_restore_bg (fb, x, y, BAR_WIDTH, BAR_HEIGHT); /* 外边框:1px 的矩形轮廓 */ psplash_fb_draw_rect (fb, x, y, BAR_WIDTH, BAR_HEIGHT, PSPLASH_BAR_BORDER_COLOR); if (value > 0) { w = (BAR_WIDTH - 2) * value / 100; h = BAR_HEIGHT - 2; psplash_fb_draw_rect (fb, x + 1, y + 1, w, h, PSPLASH_BAR_COLOR); } psplash_fb_flush (fb); }BAR_WIDTH/BAR_HEIGHT来自psplash-config.h或psplash-colors.h,是编译期常量,改进度条长短就是改这两个宏重编。psplash_draw_restore_bg依赖前面某次psplash_draw_save_bg已经把这块区域的像素存好,如果调用顺序错了(比如底图还没贴就存背景),恢复出来的就是一片纯色,表现为进度条周围有一块明显的色块边界。
注意w = (BAR_WIDTH - 2) * value / 100这个写法——先乘后除,是为了避免整数除法过早截断导致进度条前 30% 完全看不出来。这种小细节在嵌入式代码里到处都是,读源码时值得停下来想一下作者为什么这么写。
3.4 FIFO 通信协议:一字节命令 + 一个以 \0 结尾的字符串
主进程和客户端之间的协议极简:先写 1 个字节的命令字,再写参数字符串,字符串以'\0'结尾。命令字本身是可打印字符,MSG是空格,PROGRESS是+,QUIT是Q。客户端psplash-write做的就是把这套编码封起来:
int main (int argc, char **argv) { int fd; if (argc < 2) return 1; /* 这里是阻塞打开:没有主进程在跑时会卡住, 所以启动脚本里一定不能在 psplash 起来之前调用它 */ fd = open (PSPLASH_FIFO, O_WRONLY); if (fd < 0) return 1; if (!strcmp (argv[1], "PROGRESS") && argc > 2) { char cmd = PSPLASH_CMD_PROGRESS; write (fd, &cmd, 1); write (fd, argv[2], strlen (argv[2]) + 1); /* +1 把 '\0' 也写进去 */ } else if (!strcmp (argv[1], "MSG") && argc > 2) { char cmd = PSPLASH_CMD_MSG; write (fd, &cmd, 1); write (fd, argv[2], strlen (argv[2]) + 1); } else if (!strcmp (argv[1], "QUIT")) { char cmd = PSPLASH_CMD_QUIT; write (fd, &cmd, 1); } close (fd); return 0; }两个参数细节决定成败。第一,strlen(argv[2]) + 1里的+1不能省——主进程是靠'\0'判断参数字符串结束的,少了它读端会一直等下一个字节,事件循环就卡在那条命令上,表现为"发完这条命令之后所有进度都不动了"。第二,open用的是阻塞模式,如果 psplash 主进程已经 QUIT 退出,客户端会永远挂在open上;所以启动脚本里必须先确认主进程在跑,或者干脆给open加个O_NONBLOCK并在失败时静默退出。
至于那条MSG命令能不能真的显示出文字,取决于你手上这份源码有没有编进内置的位图字体。有这个能力的分支会在屏幕底部画一行字;没有的分支里psplash_draw_msg可能是个空函数或者只做背景擦除。拿到源码先grep -rn "draw_msg"看一眼实现,比猜快得多。
4. 编译与集成:先在 x86 上跑通,再谈上板
4.1 桌面环境先验证逻辑
拿到源码别急着交叉编译,先在开发机上跑一遍,确认能编译、能画、能响应命令,把工具链问题排除掉。
# 仓库里如果只有 configure.ac 和 Makefile.am,先生成 configure autoreconf -fi # 默认配置,产物是 ./psplash 和 ./psplash-write ./configure make -j$(nproc)编译通过之后,别在图形界面的终端里直接跑。桌面环境的 X 或 Wayland 合成器已经占着显卡,/dev/fb0在这种机器上往往是 fbdev 兼容层甚至根本不存在,你写进去的像素不会出现在任何窗口里。正确姿势是切到纯文本 TTY:Ctrl+Alt+F3登录,然后以 root 执行./psplash,屏幕上应该立刻出现 logo 和进度条框架。这时开另一个 TTY 用./psplash-write "PROGRESS 50"喂进度,能看到进度条跟着走,说明整条链路是通的。
如果切到 TTY 还是黑屏,先确认/proc/fb里有内容、/dev/fb0存在且权限对。这一步排除掉,后面交叉编译才有意义。
4.2 交叉编译与 rootfs 里的启动时序
交叉编译就一行--host,真正需要动脑的是裁剪开关。configure.ac里那些AC_ARG_ENABLE决定了要不要进度条、FIFO 放哪、默认用哪张图,用./configure --help翻一遍比读源码快。
# 以 aarch64 为例,按自己工具链前缀替换 ./configure --host=aarch64-linux-gnu \ CC=aarch64-linux-gnu-gcc \ CFLAGS="-O2 -march=armv8-a" \ --prefix=/usr make -j8 make DESTDIR=$PWD/_install installCFLAGS里的-O2别省,画点函数是调用热点,不开优化在低端 ARM 上进度条动画会一顿一顿的;--prefix=/usr决定装到 rootfs 的哪个位置,后面写启动脚本时要和它对齐。make install通常只装psplash、psplash-write两个二进制,图片是编进去的,没有额外的资源文件。
时序才是集成的重点。psplash 必须在"屏幕还是空的时候"尽早起来,抢在 Qt、GStreamer、显示服务之前占住 framebuffer。
#!/bin/sh # rootfs 的 init 脚本片段:尽早拉起来,占住 framebuffer /usr/bin/psplash & # 首次画进度前稍等一下,避免主进程还没 mmap 完 sleep 0.2 /usr/bin/psplash-write "PROGRESS 10" # ……这里是真正耗时的初始化:挂载、加载模块、起服务…… /usr/bin/psplash-write "PROGRESS 80" # 界面程序接管屏幕之前,务必先让 psplash 退出 /usr/bin/psplash-write "QUIT"这里的sleep 0.2不是迷信,是血泪经验。psplash-write用阻塞方式打开 FIFO,如果主进程还没创建 FIFO 就已经被调用,客户端会一直挂在那;短 sleep 是最省事的规避方式,更严谨的做法是让 init 脚本轮询 FIFO 文件是否存在。
用 systemd 的发行版可以写成 unit,关键是DefaultDependencies=no和Before=getty@tty1.service:
[Unit] Description=Boot splash DefaultDependencies=no After=systemd-vconsole-setup.service Before=getty@tty1.service [Service] ExecStart=/usr/bin/psplash RemainAfterExit=yes [Install] WantedBy=sysinit.targetRemainAfterExit=yes是为了让 psplash 退出后 unit 状态仍算 active,否则QUIT之后 systemd 可能把它判成失败并触发重启策略,屏幕会在启动末尾诡异闪一下 logo。
4.3 不写代码也能验证协议
调试阶段最省事的办法是绕开psplash-write,直接用 shell 往 FIFO 里灌字节。协议就两种东西:1 字节命令 + 以'\0'结尾的参数。
# PROGRESS 命令字是 '+',参数以 NUL 结尾 printf '%s\0' '+30' > /run/psplash_fifo # MSG 的命令字是空格字符,别漏了那个空格 printf '%s\0' ' Booting...' > /run/psplash_fifo # QUIT 只写一个字节,没有参数 printf 'Q' > /run/psplash_fifoprintf '%s\0'这个写法比printf '+30\0'安全,因为部分 shell 会把\0后面跟的数字当成八进制转义序列吃掉,最后一个字节就变成了别的值,主进程收到未知命令直接忽略,你会以为 FIFO 坏了。用%s占位再补\0,行为在所有 POSIX shell 里都一致。
5. 避坑与排查:黑屏、花屏、进度条卡死这几类现场
5.1 现象:进程在跑,屏幕全黑
现象:ps能看到psplash进程,/dev/fb0也存在,屏幕就是没反应,串口也不报错。
原因:最常见的两种情况。一是写进去的不是真正在扫描输出的那块缓冲——现在很多 SoC 的显示通路走 DRM/KMS,fbdev 只是个模拟层,/dev/fb0的写入可能映射到一块从不被扫描的 shadow buffer;二是另一个进程(Qt 的 EGLFS 后端、某些视频输出插件)已经把 KMS 接管了,你先写的像素被它后来的一次 modeset 全清掉。
解决:先用dd if=/dev/urandom of=/dev/fb0打一屏噪点,看屏幕花不花。花了说明 fbdev 这条通路有效,问题在时序(被后来的进程抢了),把 psplash 的启动点提前到该进程之前即可;不花说明这条路根本不通,要么改用 DRM 直写的方案,要么在设备树里把 fbdev 模拟层配成实际输出。判断依据看/proc/fb里列出的设备名,以及/sys/class/graphics/下有没有对应的fb0。
5.2 现象:logo 出来了,但颜色不对或者画面斜切
现象:图能显示,但红蓝反了、整体偏色,或者越往右下越往右偏,边缘出现斜的错位。
原因:偏色是像素通道顺序对不上。图片头文件里声明的是 RGB 或 RGBA,而屏幕上fb_var_screeninfo上报的red.offset/blue.offset是反的(BGRA),绘图代码如果按写死的移位来 pack 就会红蓝互换。斜切则是行跨度算错,用了width * bpp / 8而不是fix.line_length,一行差几个字节,几百行累积下来就是肉眼可见的斜坡。
解决:先用fbset -i把真实的xres、xres_virtual、line_length、bits_per_pixel打出来,和代码里用的值逐个核对。通道顺序一律以var.red.offset为准,别写死。图片转换时统一成 RGBA8888,让程序自己按驱动上报的偏移去 pack,这样同一份图片换到不同色深的屏上都不用重导。
5.3 现象:发了 PROGRESS 命令,进度条停在第一次的值不动
现象:第一次PROGRESS 10有效,之后无论发什么都毫无反应,psplash-write也不报错。
原因:主进程的事件循环卡在了读参数上。它收到命令字后会一直读直到遇到'\0',如果客户端写参数时漏了结尾 NUL(比如自己用echo -n拼的字节流),读端就会永远等待下一个字节,select再也不会被重新执行。另一种可能是参数超过缓冲区长度,读端填满buf后剩下的字节留在了 FIFO 里,下一条命令的解析从此全部错位。
解决:客户端一律用psplash-write,别自己echo拼字节;确实要手写就用printf '%s\0'。MSG的内容控制在几十字节以内,长文本不要指望它。调试时用strace -p <pid>看主进程是不是停在read上,一秒钟就能定位。
5.4 现象:QUIT 之后屏幕残留、光标闪烁、getty 的输出糊在 logo 上
现象:发完QUIT,logo 还留在屏幕上,或者光标开始闪,串口登录提示直接打在 logo 中间。
原因:psplash 启动时调了psplash_console_save_and_reset,做了切图形模式(KD_GRAPHICS)、隐藏光标、关键盘这几件事,退出时必须一一恢复。如果进程是被SIGTERM直接杀掉的,根本没走到done标签后面的psplash_console_reset,VT 就一直停在图形模式。
解决:在主进程里装SIGTERM/SIGINT的处理函数,置一个标志位或者直接跳转到清理流程。产品上更稳的做法是让界面程序在接管屏幕前先chvt切一次 VT,把 VT 状态强制刷一遍。另外QUIT之后屏幕内容不会自动清空,这是 fbdev 的固有行为,需要在退出前自己铺一遍背景色。
6. 二次开发:换图、改视觉
本文还有配套的精品资源,点击获取