把吃灰 Kindle 改成独立名言屏:越狱、帧渲染、时钟叠加与触摸翻页全记录
家里有台吃灰多年的 Kindle 基础版。墨水屏、无背光刺眼、静态画面零功耗,天生就是做"桌面信息屏"的料。这个周末把它整个改造了一遍,现在是这个样子:

现在它的日常是:白天每 10 分钟换一条中外名言,顶部时钟实时走字;点左半屏回看上一条、右半屏随机换下一条;深夜自动切纯白休息页。断网、拔掉主机,一切照常——因为它完全独立运行,Kindle 自己的时钟、存储和画屏工具就是全部依赖。成本 0 元,全程不需要任何云服务。
期间踩的坑不少,值得完整记一笔。
目录
- 为什么做,以及为什么放弃浏览器方案
- 准备工作与风险提示
- 第一步:确认设备真身(eips -i)
- 第二步:LanguageBreak 越狱
- 第三步:安装 MRPI / KUAL / USBNetwork 三插件
- 第四步:USB 网络直连(SSH 通道)
- 第五步:停用 GUI 栈与 OTA
- 第六步:帧渲染管线(电脑端)
- 第七步:部署帧包与主循环 kd_show.sh
- 第八步:开机自启(upstart 双任务)
- 第九步:顶部实时时钟条 kd_clock.c
- 第十步:触摸翻页守护 kd_touchd.c
- 第十一步:导航语义——三轮迭代出来的"翻书"手感
- 踩坑速查表
- 结语与扩展方向
1. 为什么做,以及为什么放弃浏览器方案
最初的目标很朴素:让屏幕每天显示一条名言,偶尔换换。
第一版走的是"免越狱"路线:局域网里一台 Linux 主机用 PIL 把名言渲染成 PNG,Kindle 用实验性浏览器定时刷新页面。这条路能跑通,但很快撞到三堵墙:
- 屏保无解:Kindle 空闲 10 分钟就进休眠屏保,网页里的 JS 定时刷新根本重置不了计时器;
- 排版受限:浏览器工具栏占掉一大截高度,状态栏时钟去不掉;
- 依赖网络:主机一关,屏幕就废了。
结论:想做成一件"家电",必须越狱,让它完全独立运行。于是有了后面这条完整路线:越狱 → 帧预渲染 → eips 上屏 → 自研时钟叠加 → 自研触摸导航。
2. 准备工作与风险提示
你需要:
- 一台 Kindle(本文以基础版第 7 代为例)
- 一台 Linux 主机(物理机、服务器、虚拟机都行)
- 一根 USB 数据线(同时承担供电与网络通道)
软件清单(Linux 主机侧):
sudo apt install python3-pil gcc-arm-linux-gnueabihf sshpass
pip install fonttools # 字体子集化用
风险提示(务必阅读):
- 越狱仅限你自己的设备,会导致保修失效,操作失败最坏情况是变砖;
- 全程不要登录 Amazon 账号做同步;
- 后文会禁用 OTA 自动升级——这是有意为之:系统升级会清掉越狱;
- 名言内容仅用于个人设备屏显。
3. 第一步:确认设备真身(eips -i)
血泪教训放在最前面:**接手任何 Kindle 项目,第一件事是确认真实分辨率,别信型号传说。**这台设备曾被按"型号名"误判成高分辨率机型,渲染出来的图上屏后只露出一小块。正确的确认方式是 SSH 进去执行:
eips -i
输出会明确告诉我们:
xres=600 yres=800 DPI=167 rotate:3 (虚拟显存 608×5376 条带式)
即:600×800、8bpp 灰度 framebuffer、行宽 608(有 8 字节 padding)。后面所有渲染参数都以它为准。
另一个重要定论:eips -g 是 1:1 贴图,不缩放、不清屏(直接叠加)。图比屏大就裁剪,比屏小就留白。这决定了我们的渲染策略:按屏幕尺寸精确渲染。
4. 第二步:LanguageBreak 越狱
使用公开的 LanguageBreak(演示模式漏洞,无需登录 Amazon 账号),工具包从官方 GitHub 仓库(notmarek/LanguageBreak)下载。核心流程:
- 拷贝 LanguageBreak 文件到 Kindle 根目录(USB 线连接时的
us分区); - 搜索框输入
;enter_demo回车,进入演示模式(设备会重启进 demo 环境); ;demo回车进演示菜单,选 Resell 走完流程(关键:第一次拷包后选 Resell,示意图一出现立刻插 USB 线重拷第二次);- Resell 流程走到语言选择时,选简体中文——触发漏洞,屏幕滚动安装日志;
- 安装完成后安装 zh-Hans-CN 热修复(演示设置界面操作;过程中报
Error 4是正常现象,系统会自动清理 bin 文件); - 重启后
;log runme验证,越狱标志出现即成功,随后脱离演示模式。
几个细节:全程不要插着 USB 线做"Import Content"的第一次拷包(会报错);热修复装完务必安全弹出再拔线;中文热修复务必装,后面页脚和角落提示要用中文字形。
越狱成功的标志:设备上出现越狱入口(KUAL 或 MRPI 痕迹)。此时还没有 KUAL,先用下一步的 MRPI。
5. 第三步:安装 MRPI / KUAL / USBNetwork 三插件
从 MobileRead 论坛下载三个包(社区标准工具):
- MRPI(MobileRead Package Installer):冒号命令安装器,
extensions/目录整个拷到 Kindle 根; - KUAL(Kindle Unified Application Launcher):自定义菜单,安装包放进
mrpackages/; - USBNetwork:USB 虚拟网卡 + dropbear SSH(选对应机型的
pw2_and_up安装包)。
安装动作:Kindle 搜索框输入 ;log mrpi 回车,MRPI 会依次安装 mrpackages/ 里的所有包(装完后目录报 no packages found 属正常)。
USBNetwork 装完后做两件事:
- 开机自启:把
/mnt/us/usbnet/auto从DISABLED_auto改名为auto; - 切换时拔线:插线状态下切换 USBNet 会报 “must not be plugged in”;状态卡死就重启一次。
6. 第四步:USB 网络直连(SSH 通道)
USBNet 的网段是固定的 192.168.15.x。Linux 主机侧用 NetworkManager 建按网卡 MAC 绑定的 profile(手动 ip addr add 会被 NM 清掉,这是个大坑):
# 先插线,找到 Kindle 对应的网卡接口名(ip link 看,形如 enx…)
sudo nmcli con add type ethernet con-name kindle-usb \
ifname enxee4900000000 \
ipv4.method manual ipv4.addresses 192.168.15.201/24 \
ipv4.never-default yes
Kindle 侧的 IP 在 /mnt/us/usbnet/etc/config 里查看/修改。之后就能 SSH:
ssh root@<Kindle的IP> # 密码为你安装 usbnet 时所配置
运维经验:如果 ping 通但 SSH 超时,先查 NM 是不是把 profile 的 IP 清了;
never-default yes很重要,避免主机把默认路由抢走断网。
从此以后,这根 USB 线既是供电线,也是网线。
7. 第五步:停用 GUI 栈与 OTA
Kindle 的"桌面"是一整条依赖链:framework(壳)→ lab126_gui(真正画主界面和状态栏时钟的那层)→ pillow/x(依赖链下游)。我们的屏幕完全自己画,GUI 栈不但没用,还会:
- 在竖屏顶边画一个走 UTC 时间的系统时钟(去不掉、对不准);
- 抢触摸事件、抢屏幕。
停用(不是删除,改名留后路):
ssh root@<Kindle的IP>
mount -o remount,rw /
cd /etc/upstart
mv framework.conf framework.conf.disabled
mv lab126_gui.conf lab126_gui.conf.disabled
# OTA 四任务一并禁掉(防系统升级清越狱)
mv ota-update.conf ota-update.conf.disabled
mv otaupd.conf otaupd.conf.disabled
mv otav3.conf otav3.conf.disabled
mv wfmupdate.conf wfmupdate.conf.disabled
顺手把系统时区写对(Kindle 系统时钟是 UTC):
mount -o remount,rw /
echo "CST-8" > /var/local/system/tz
注意:停掉 GUI 后触摸屏的系统手势没了,但触摸控制器的事件流还在(后文自研触摸导航正是靠这一点);KUAL 也不可达了,管理全走 SSH。USBNet 有独立的 upstart 任务,不受影响。
8. 第六步:帧渲染管线(电脑端)
思路: Kindle 端只做"贴图"这一件事,所有排版、字体、断行都在电脑端预渲染成一张张静态 PNG 帧,整包拷进 Kindle。运行期零计算、零依赖。
8.1 坐标系与旋转
设备竖着是 600×800,但我们要横着放。做法:先在 800×600 横向画布上正常排版,再整体旋转 90° 得到 600×800 的"竖版缓冲图",交给 eips -g 原样贴屏。这样按顶部朝左横放时,画面正立。(摆反了就把旋转方向换掉重跑,代码里留了开关。)
顶部时间条不画进帧里——帧是静态的,时间会过时。主帧在顶部预留 52px 留白,运行期由自研工具(第九步)在 framebuffer 上叠加实时时钟,互不冲突。
8.2 排版规则(实测定稿)
- 中文语录:只显示中文(不配英文),字号自适应 66/60/54/48,取能放下的最大号;
- 英文名言:英文主文(加粗,自适应 46/42/38)+ 中文对照(26px);
- 左右留白 72px、上下留白 52px;页脚居中一行小字署名;左下/右下角各一行浅灰小提示「« 上一条 / 下一条 »」。
8.3 断行函数(含一个吞行大 bug 的修复)
中英混排断行:CJK 逐字断、英文按词断,另做行首标点禁则(,。;等不能出现在行首,回挂到上一行行尾)。这里有过一个隐蔽大坑:贪心循环结束后最后一行没有追加进结果列表,导致每次断行都吞掉末段、单行短句整行消失。修复就是循环结束后的收尾 if cur: lines.append(cur)。
8.4 渲染脚本(自包含教程版)
说明:实机部署时拆成
generate_daily.py(语录池 + 断行工具)与kd_render_frames.py(渲染)两个文件;这里为方便阅读合并为一个脚本,逻辑完全一致。语录池给出样例,按格式扩充即可。代码里的署名占位文字请替换成你自己的。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""kd_render_frames.py —— 帧预渲染器(跑在 Linux 主机上)
渲染全部语录为静态帧 PNG,整包拷到 Kindle /mnt/us/kd_frames/
画布 800x600 横向 -> 旋转 90° -> 600x800 竖版缓冲,顶部朝左横放正立
"""
import sys, random
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
NOTO_SERIF = "/usr/share/fonts/opentype/noto/NotoSerifCJK-{}.ttc" # index 2 = SC
DEJAVU = "/usr/share/fonts/truetype/dejavu/DejaVuSerif{}.ttf" # "" / "-Bold"
# ---- 语录池(样例,按格式扩充)。统一四元组 (语言, 主文, 对照文, 作者) ----
QUOTES = [ # 英文名言: (英文, 中文对照, 作者)
("The best way to predict the future is to invent it.", "预测未来的最好方式,就是去创造它。", "Alan Kay"),
("Stay hungry, stay foolish.", "求知若饥,虚心若愚。", "Steve Jobs"),
]
CN_QUOTES = [ # 中国经典: (中文, 出处)
("上善若水,水善利万物而不争。", "老子《道德经》"),
("天行健,君子以自强不息。", "《周易》"),
]
# 红楼梦 / 西游记 / 禅偈 / 古籍 / 唐诗宋词……同格式各建一个列表,加进 all_quotes()
def all_quotes():
return ([("en", en, cn, who) for en, cn, who in QUOTES]
+ [("cn", cn, "", who) for cn, who in CN_QUOTES])
def wrap_text(text, font, max_w):
"""按像素宽度贪心换行:CJK 逐字、英文按词;行首标点禁则"""
lines, cur = [], ""
units, buf = [], ""
for ch in text:
if ch > "\u2e80" or ch in ",。;、:)】》”!?—":
if buf: units.append(buf); buf = ""
units.append(ch)
elif ch == " ":
if buf: units.append(buf)
buf = ""; units.append(" ")
else:
buf += ch
if buf: units.append(buf)
for u in units:
if u == " " and not cur: continue
joiner = "" if (not cur or cur[-1] == " " or u == " "
or cur[-1] > "\u2e80" or u > "\u2e80") else " "
piece = cur + joiner + u
if font.getlength(piece) <= max_w:
cur = piece
else:
if cur: lines.append(cur)
cur = u if u != " " else ""
if cur: # 关键收尾:最后一段未满行也要入列(否则吞行!)
lines.append(cur)
PUNCT = ",。;、:)】》”!?…"
fixed = []
for ln in lines: # 行首句读标点回挂上一行行尾
if fixed and ln and ln[0] in PUNCT:
fixed[-1] += ln[0]; ln = ln[1:]
while ln and ln[0] in PUNCT:
fixed[-1] += ln[0]; ln = ln[1:]
if not ln: continue
fixed.append(ln)
return fixed
# ---- 横向画布参数
LW, LH = 800, 600 # 设备 600x800@167ppi,横放观看即 800x600
MH, MV = 72, 52 # 左右 / 上下留白(顶部 52px 留给运行期时钟条)
CONTENT_W = LW - MH * 2
FOOTER_H = 28
AVAIL_H = LH - MV * 2 - FOOTER_H
ROTATE = "CW" # CW=顶部朝左横放;摆反了改 "CCW" 重跑
def f_cn(size, weight="Regular"):
return ImageFont.truetype(NOTO_SERIF.format(weight), size, index=2)
def f_en(size, bold=False):
return ImageFont.truetype(DEJAVU.format("-Bold" if bold else ""), size)
def draw_block(d, lines, font, pitch, y, fill=0):
for ln in lines:
d.text(((LW - font.getlength(ln)) / 2, y), ln, font=font, fill=fill)
y += pitch
return y
def render_landscape(quote):
img = Image.new("L", (LW, LH), 255)
d = ImageDraw.Draw(img)
kind, q_pri, q_sec, q_who = quote
who = f"—— {q_who}"
f_who = f_cn(20)
if kind == "cn": # 中文语录:只显示中文,字号自适应
lines, size, pitch = None, 42, 63
for s in (66, 60, 54, 48):
ls = wrap_text(q_pri, f_cn(s, "Bold"), CONTENT_W)
if len(ls) * int(s * 1.5) + 44 <= AVAIL_H:
lines, size, pitch = ls, s, int(s * 1.5); break
if lines is None:
lines = wrap_text(q_pri, f_cn(48, "Bold"), CONTENT_W)
size, pitch = 48, 72
h = len(lines) * pitch + 44
y = MV + max(0, int((AVAIL_H - h) * 0.42))
y = draw_block(d, lines, f_cn(size, "Bold"), pitch, y)
d.text((LW - MH - f_who.getlength(who), y + 14), who, font=f_who, fill=90)
else: # 英文名言:英文加粗主文 + 中文对照
f_sec = f_cn(26)
cn_lines = wrap_text(q_sec, f_sec, CONTENT_W)
lines, size, pitch = None, 34, 49
for s in (46, 42, 38):
ls = wrap_text(q_pri, f_en(s, bold=True), CONTENT_W)
if len(ls) * int(s * 1.45) + len(cn_lines) * 39 + 44 <= AVAIL_H:
lines, size, pitch = ls, s, int(s * 1.45); break
if lines is None:
lines = wrap_text(q_pri, f_en(38, bold=True), CONTENT_W)
size, pitch = 38, 55
h = len(lines) * pitch + len(cn_lines) * 39 + 44
y = MV + max(0, int((AVAIL_H - h) * 0.42))
y = draw_block(d, lines, f_en(size, bold=True), pitch, y)
y = draw_block(d, cn_lines, f_sec, 39, y + 6, fill=70)
d.text((LW - MH - f_who.getlength(who), y + 8), who, font=f_who, fill=90)
foot = "你的署名" # 页脚(静态,不含日期——日期走实时时钟条)
f_foot = f_cn(18)
d.text(((LW - f_foot.getlength(foot)) / 2, LH - 36), foot, font=f_foot, fill=130)
f_hint = f_cn(16) # 半屏翻页提示
hint_l = "« 上一条"
d.text((MH, LH - 32), hint_l, font=f_hint, fill=160)
hint_r = "下一条 »"
d.text((LW - MH - f_hint.getlength(hint_r), LH - 32), hint_r,
font=f_hint, fill=160)
return img.transpose(Image.ROTATE_270 if ROTATE == "CW" else Image.ROTATE_90)
def main():
out = Path(sys.argv[1] if len(sys.argv) > 1 else "frames")
all_quotes_ = all_quotes()
# 固定种子洗牌:语录池按分类连排(红楼 26 连/宋词 22 连…观感差),
# 洗牌后类型交错;种子固定 => 重渲染序列稳定,帧号语义不变
random.Random(20260925).shuffle(all_quotes_)
out.mkdir(parents=True, exist_ok=True)
for i, q in enumerate(all_quotes_):
render_landscape(q).save(out / f"q_{i:03d}.png", "PNG", optimize=True)
Image.new("L", (LW, LH), 255).save(out / "white.png", "PNG") # 夜间休息页
print(f"rendered {len(all_quotes_)} frames + white.png -> {out}")
if __name__ == "__main__":
main()
跑完后 frames/ 下就是 q_000.png ~ q_NNN.png + white.png。发版前务必拉两张样张旋转回正方向目检一遍(字号、断行、留白),再整包上传。
9. 第七步:部署帧包与主循环 kd_show.sh
上传帧包:
cd frames && tar czf ../frames.tar.gz *.png
scp frames.tar.gz root@<Kindle的IP>:/mnt/us/
# Kindle 上:
ssh root@<Kindle的IP> "cd /mnt/us && mkdir -p kd_frames && tar xzf frames.tar.gz -C kd_frames && rm frames.tar.gz"
主循环 kd_show.sh(部署到 /mnt/us/kd_show.sh,chmod +x)负责四件事:作息(08:00–24:00 展示,夜间纯白休息页)、每 10 分钟推进一条、每分钟刷新顶部时间条、防休眠。注意它的"推进"不是自己换帧,而是给导航进程发信号(原因见第十一步):
#!/bin/sh
# kd_show.sh v5 —— Kindle 独立名言屏主循环(随机轮播版)
# 帧目录: /mnt/us/kd_frames/ (q_000.png ~ q_NNN.png + white.png)
# 帧序、随机、历史全部收敛在 kd_touchd 一处:
# · 自动每 10 分钟: USR1 => 随机下一条(排除最近 10 条)
# · 点击左半屏: 上一条(历史精确回退) / 右半屏: 下一条(随机)
# · 开机: 从 kd_state 恢复上次位置
FRAMES=/mnt/us/kd_frames
TOTAL=$(ls "$FRAMES"/q_*.png 2>/dev/null | wc -l)
[ "$TOTAL" -lt 1 ] 2>/dev/null && TOTAL=208
LOG=/mnt/us/kd_frames/kd_show.log
EIPS=/usr/sbin/eips
CLOCK=/mnt/us/kd_bin/kd_clock
STATE=/mnt/us/kd_state
PIDF=/mnt/us/kd_touchd.pid
export TZ=CST-8 # 固定北京时间,作息不受系统时区影响
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }
# ---- 防休眠:让屏保计时器失效(脚本停止后自动恢复)
/usr/bin/lipc-set-prop -i com.lab126.powerd preventScreenSaver 1 2>/dev/null \
|| log "WARN: preventScreenSaver failed"
# ---- 通知导航进程换一条(随机)
nav_next() {
pid=$(cat "$PIDF" 2>/dev/null)
case "$pid" in ''|*[!0-9]*) return 1;; esac
kill -USR1 "$pid" 2>/dev/null
}
show_idx() { # $1=idx : 上屏 + 写 state(仅开机恢复用;运行期显示统一走 kd_touchd)
frame=$(printf '%s/q_%03d.png' "$FRAMES" "$1")
if [ -f "$frame" ]; then
$EIPS -g "$frame"
echo "$1" > "$STATE"
return 0
fi
return 1
}
# ---- 读取上次位置
cur=0
if [ -f "$STATE" ]; then
cur=$(cat "$STATE" 2>/dev/null)
case "$cur" in ''|*[!0-9]*) cur=0;; esac
[ "$cur" -ge "$TOTAL" ] && cur=0
fi
h=$(date +%H); m=$(date +%M)
hh=${h#0}; mm=${m#0}
mins=$((hh * 60 + mm))
last_slot=$((mins / 10)) # 当前 10 分钟槽视为已消费
last=-1
lmin=""
log "kd_show v5 started (total=$TOTAL, resume=$cur)"
# ---- 开机立即恢复画面(展示时段显 state 帧;夜间白屏)
if [ "$mins" -ge 480 ] && [ "$mins" -lt 1440 ]; then
if show_idx "$cur"; then
log "resume idx=$cur"
last=$cur
fi
else
$EIPS -g "$FRAMES/white.png"
log "night mode (white)"
last="-2"
fi
while true; do
h=$(date +%H); m=$(date +%M)
hh=${h#0}; mm=${m#0}
mins=$((hh * 60 + mm))
if [ "$mins" -ge 480 ] && [ "$mins" -lt 1440 ]; then
# ---- 展示时段:每 10 分钟边界随机前进一条(由 kd_touchd 统一执行)
slot=$((mins / 10))
if [ "$slot" != "$last_slot" ]; then
if nav_next; then
log "advance slot=$slot (usr1)"
last="adv"
else
log "WARN: nav_next failed (no pid?)"
fi
last_slot=$slot
fi
# ---- 每分钟顶部时间条
hm=$(date +%H%M)
if [ "$hm" != "$lmin" ] && [ -x "$CLOCK" ]; then
"$CLOCK" auto
lmin=$hm
fi
else
# ---- 夜间:纯白休息页(只切一次);不推进,点击翻页仍可用
if [ "$last" != "-2" ]; then
$EIPS -g "$FRAMES/white.png"
log "night mode (white)"
last="-2"
fi
last_slot=-1 # 夜间重置,8:00 醒来立即推进
fi
sleep 20
done
eips 的三个必知点:
- 它不在默认 PATH 里,必须写全路径
/usr/sbin/eips(尤其脚本/SSH 非交互环境); eips 0 0 <文件>是"在 (0,0) 打印字符串",不是显示图片!显示图片必须eips -g <图片路径>(调试时在这上面浪费过一小时,屏幕"什么都不显示"的根因就是它);eips -k <文件>导出的是 eips 自己显示管线的 16bpp 工作缓冲,看不到 framebuffer 直写的内容(验证直写只能看真屏)。
10. 第八步:开机自启(upstart 双任务)
Kindle 5.x 用 upstart。注意主循环的启动条件是 started powerd 而不是 started framework——GUI 栈已停,framework 永远不会就绪;powerd(电源管理)先于一切启动,最稳。另外 /mnt/us 分区挂载有先后,script 块里等脚本就绪再 exec,防止 upstart 因找不到文件直接放弃。
/etc/upstart/kd-dash.conf:
description "kd-dash quote screen"
author "kindle-dash"
start on started powerd
respawn
script
N=0
while [ $N -lt 30 ] && [ ! -x /mnt/us/kd_show.sh ]; do
sleep 2
N=$((N+1))
done
exec /mnt/us/kd_show.sh
end script
/etc/upstart/kd-touchd.conf(导航守护进程,第十步编译后放入):
description "kd touch pager daemon"
author "kindle-dash"
start on started powerd
respawn
exec /mnt/us/kd_bin/kd_touchd
运维小抄:initctl status kd-dash 看状态;重启脚本进程直接 kill <pid>,respawn 会自动拉起新的;别用 pkill -f——busybox 的 ps 看不到脚本进程名(comm 是 sh),而带 -f 的 pkill 在 SSH 远程命令里会匹配到自己的命令行把自己杀掉。正确姿势:扫 /proc/*/cmdline 拿 PID 再 kill。
11. 第九步:顶部实时时钟条 kd_clock.c
需求:主帧是预渲染的静态图,但屏幕顶部要有时钟。预渲染分钟条?一天 1440 帧,且"时间写在图里"本身就是错的。恢复 GUI 时钟?位置焊死在竖屏顶边且已被我们停用。所以只剩一条路:运行期直接往 framebuffer 叠加。
先说一条弯路:社区标准画字工具 FBInk 交叉编译很容易成功,但它的 CLI 不支持文字旋转——横放画面下画出来的字侧倒 90°,只能放弃 CLI。不过它的价值依然存在:源码里的 eink/mxcfb-kindle.h 是现成的墨水屏刷新接口定义,直接拿来 include。
自研 kd_clock.c 的核心思路:
- 字体:stb_truetype 单头库渲染。系统 Noto Serif CJK 的 ttc 有几 MB,但时钟只需要
0123456789:|.- 周一二三四五六日月年这十几个字符——用 fontTools 子集化:
pip install fonttools
fonttools subset --font-number=2 \
--text="0123456789:|.- 周一二三四五六日月年" \
NotoSerifCJK-Bold.ttc --output-file=kd_font.ttf
# 产物仅 6.2KB,拷到 Kindle /mnt/us/kd_bin/kd_font.ttf
- 坐标变换:横放(顶朝左)时,横屏画面的"顶边"对应竖版 fb 的 x=599 列。先在条带缓冲
strip[hx][hy](hx∈[0,800) 横向行进,hy∈[0,52))里正常画字,再整体旋转写入 fb:fb[y=hx][x=599-hy]。 - 局部刷新:只对顶部条带区域发 mxcfb ioctl(PARTIAL + GC16_FAST),不惊动主画面,无全局闪屏。条带先整体填白再重画,零残影;主帧该区域本来就是留白,零冲突。
完整源码(交叉编译见文末,mxcfb-kindle.h 取自 FBInk 源码包的 eink/ 目录,stb_truetype.h 从 nothings/stb 仓库获取,国内环境可走 jsdelivr 镜像):
/* kd_clock.c —— Kindle 横屏顶部时间条叠加工具(运行在 Kindle 上)
*
* 设备: Kindle 基础版 7 代 (600x800 @167ppi, 8bpp fb, line_length 608)
* 画面约定: 主帧为横放(顶朝左)渲染的竖版缓冲 600x800, eips -g 1:1 贴图
* => fb 的 x=599 列对应横屏画面的顶边
* 用法: kd_clock auto [px] 取系统时间自动拼串(设 TZ=CST-8)
* kd_clock "14:23|周五 9月25日" [px]
* '|' 前为时刻(深色), 后为日期(浅色); px 默认 32
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/fb.h>
#define STB_TRUETYPE_IMPLEMENTATION
#include "stb_truetype.h"
#include "mxcfb-kindle.h" /* FBInk 自带的 Kindle mxcfb 定义 */
#define FBW 600
#define FBH 800
#define STRIP_H 52 /* 横屏顶部条带高度(px, = 主帧上边距 MV) */
#define FONT_PATH "/mnt/us/kd_bin/kd_font.ttf"
#define TEXT_DARK 60 /* 时刻文字灰度 */
#define DATE_DARK 135 /* 日期文字灰度 */
static const char *WDAY[] = { "周日", "周一", "周二", "周三", "周四", "周五", "周六" };
static int ll = 608;
static void die(const char *m)
{
fprintf(stderr, "kd_clock: %s (%s)\n", m, strerror(errno));
exit(1);
}
/* mxcfb 局部刷新 */
static void refresh(int fbfd, int left, int top, int w, int h)
{
static unsigned marker = 4200;
struct mxcfb_update_data u;
memset(&u, 0, sizeof(u));
u.update_region.top = (unsigned)top;
u.update_region.left = (unsigned)left;
u.update_region.width = (unsigned)w;
u.update_region.height = (unsigned)h;
u.waveform_mode = WAVEFORM_MODE_GC16_FAST;
u.update_mode = UPDATE_MODE_PARTIAL;
u.update_marker = ++marker;
u.hist_bw_waveform_mode = WAVEFORM_MODE_DU;
u.hist_gray_waveform_mode = WAVEFORM_MODE_GC16_FAST;
u.temp = TEMP_USE_AUTO;
u.flags = 0;
if (ioctl(fbfd, MXCFB_SEND_UPDATE, &u) < 0)
fprintf(stderr, "kd_clock: refresh ioctl: %s\n", strerror(errno));
}
int main(int argc, char **argv)
{
const char *arg = argc > 1 ? argv[1] : "auto";
int px = argc > 2 ? atoi(argv[2]) : 32;
if (px < 16) px = 16;
if (px > STRIP_H - 12) px = STRIP_H - 12;
char text[128];
if (strcmp(arg, "auto") == 0) {
setenv("TZ", "CST-8", 1);
tzset();
time_t t = time(NULL);
struct tm tmv;
localtime_r(&t, &tmv);
snprintf(text, sizeof(text), "%02d:%02d|%s %d月%d日",
tmv.tm_hour, tmv.tm_min, WDAY[tmv.tm_wday % 7],
tmv.tm_mon + 1, tmv.tm_mday);
} else {
snprintf(text, sizeof(text), "%.127s", arg);
}
/* ---- 打开 framebuffer ---- */
int fbfd = open("/dev/fb0", O_RDWR);
if (fbfd < 0) die("open /dev/fb0");
struct fb_fix_screeninfo finfo;
if (ioctl(fbfd, FBIOGET_FSCREENINFO, &finfo) == 0 && finfo.line_length > 0)
ll = (int)finfo.line_length;
size_t fblen = (size_t)ll * FBH;
unsigned char *fb = (unsigned char *)mmap(NULL, fblen, PROT_READ | PROT_WRITE,
MAP_SHARED, fbfd, 0);
if (fb == MAP_FAILED) die("mmap fb");
/* ---- 加载字体 ---- */
FILE *fp = fopen(FONT_PATH, "rb");
if (!fp) die("open " FONT_PATH);
fseek(fp, 0, SEEK_END);
long fl = ftell(fp);
fseek(fp, 0, SEEK_SET);
unsigned char *fdata = (unsigned char *)malloc((size_t)fl);
if (!fdata || fread(fdata, 1, (size_t)fl, fp) != (size_t)fl) die("read font");
fclose(fp);
stbtt_fontinfo font;
if (stbtt_InitFont(&font, fdata, 0) == 0) die("stbtt_InitFont");
float scale = stbtt_ScaleForPixelHeight(&font, (float)px);
int ascent = 0, descent = 0, lineGap = 0;
stbtt_GetFontVMetrics(&font, &ascent, &descent, &lineGap);
int apx = (int)(ascent * scale);
int dpx = (int)(-descent * scale);
/* ---- 条带缓冲 strip[hx][hy]: hx=横屏水平行进(0..799), hy=横屏纵向(0..52) ---- */
static unsigned char strip[FBH][STRIP_H];
memset(strip, 255, sizeof(strip));
int boxh = apx + dpx;
int top = (STRIP_H - boxh) / 2;
if (top < 2) top = 2;
int base = top + apx; /* 字形位图行 row 对应 hy = base + yoff + row */
/* 两遍: 先测总宽, 再画; '|' 分段(段间加空隙), 颜色深浅区分 */
long total_w = 0;
long start_x = 0;
int seg_gap = px / 3;
int npass;
for (npass = 0; npass < 2; npass++) {
long cursor;
int seg = 0;
if (npass == 0) {
cursor = 0; /* 第一遍: 累计文本宽度 */
} else {
cursor = start_x; /* 第二遍: 从水平居中处起笔 */
}
const char *p = text;
while (*p) {
unsigned int cp = (unsigned char)*p;
if (cp < 0x80) {
p += 1;
} else if ((cp & 0xE0) == 0xC0) {
cp = ((cp & 0x1F) << 6) | (p[1] & 0x3F); p += 2;
} else if ((cp & 0xF0) == 0xE0) {
cp = ((cp & 0x0F) << 12) | ((p[1] & 0x3F) << 6) | (p[2] & 0x3F); p += 3;
} else {
cp = ((cp & 0x07) << 18) | ((p[1] & 0x3F) << 12) | ((p[2] & 0x3F) << 6) | (p[3] & 0x3F);
p += 4;
}
if (cp == '|') {
cursor += seg_gap;
seg = 1;
continue;
}
if (cp == ' ') {
int aw = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font, (int)' ', &aw, &lsb);
cursor += (long)(aw * scale);
continue;
}
if (npass == 0) {
int aw = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font, (int)cp, &aw, &lsb);
cursor += (long)(aw * scale);
} else {
int bw = 0, bh = 0, xoff = 0, yoff = 0;
unsigned char *bm = stbtt_GetCodepointBitmap(&font, scale, scale,
(int)cp, &bw, &bh, &xoff, &yoff);
if (bm) {
int gx = (int)(cursor + xoff);
int gy = base + yoff;
unsigned char col = (unsigned char)(seg ? DATE_DARK : TEXT_DARK);
for (int r = 0; r < bh; r++) {
int hy = gy + r;
if (hy < 0 || hy >= STRIP_H) continue;
for (int c = 0; c < bw; c++) {
int hx = gx + c;
if (hx < 0 || hx >= FBH) continue;
unsigned char v = bm[(size_t)r * bw + c];
if (v == 0) continue;
unsigned char old = strip[hx][hy]; /* alpha 混合到白底 */
unsigned char nv = (unsigned char)((old * (255 - v) + col * v) / 255);
strip[hx][hy] = nv;
}
}
stbtt_FreeBitmap(bm, NULL);
}
int aw = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font, (int)cp, &aw, &lsb);
cursor += (long)(aw * scale);
}
}
if (npass == 0) {
total_w = cursor;
if (total_w > FBH - 20) total_w = FBH - 20; /* 防溢出 */
start_x = (FBH - total_w) / 2; /* 水平居中 */
if (start_x < 10) start_x = 10;
}
}
/* ---- 旋转写入 fb: fb[y=hx][x=599-hy], 条带 x∈[600-STRIP_H, 600) ---- */
for (int hx = 0; hx < FBH; hx++) {
unsigned char *frow = fb + (size_t)hx * ll;
for (int hy = 0; hy < STRIP_H; hy++) {
frow[FBW - 1 - hy] = strip[hx][hy];
}
}
/* ---- 局部刷新整条条带 ---- */
refresh(fbfd, FBW - STRIP_H, 0, STRIP_H, FBH);
munmap(fb, fblen);
close(fbfd);
free(fdata);
return 0;
}
交叉编译:
arm-linux-gnueabihf-gcc -static -O2 -o kd_clock kd_clock.c \
-I<FBInk源码目录>/eink -lm
# -static 很重要: Kindle 上没有对应的动态库环境
12. 第十步:触摸翻页守护 kd_touchd.c
GUI 停了,触摸控制器没停——/dev/input/event1 的事件流还在。直接读它,做成一个守护进程:点左半屏 = 上一条,右半屏 = 下一条,中间竖带无效(避免误触)。
三个关键细节:
- zforce2 触摸驱动不填 absinfo:
EVIOCGABS查回来的量程是 0..0。所以坐标换算要做兜底:量程有效(span≥100)就归一化,否则原始值就是物理坐标 0..599。不看日志永远想不到这一层——首版所有点击都被换算成越界值、方向判定全错。 - 事件节奏:触摸控制器以 SYN_REPORT 为一帧边界,在 SYN 时取最后一次 ABS_X 判定即可;加 1 秒去抖防连点。
- 为什么是守护进程而不是脚本:它同时承担"触摸导航"与"随机/历史状态管理"(见第十一步),还要接收主循环的信号,C 进程最省心。
完整源码:
/* kd_touchd.c —— 触摸翻页 + 导航守护(双向历史 + 随机)(运行在 Kindle 上)
*
* 交互: 横放(顶朝左)时, 点左半屏 = 上一条(历史回退), 点右半屏 = 下一条(随机),
* 中间竖带(native x 240..360)无效。
* 历史模型:
* hist[] = 出现过的内容序列(只增不删), pos = 当前显示下标。
* · 下一条: 随机挑一条(排除最近 10 条), 截断 pos 右侧前进历史后 append
* · 上一条: pos 左移一格, 精确重放
* · 回退到最早一条(pos==0): 按洗牌池顺序往前翻一页(确定性非随机), 前插入历史
* · 自动推进: 主循环每 10 分钟 kill -USR1 => 等价"下一条"
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#include <sys/ioctl.h>
#include <linux/input.h>
#define EVDEV "/dev/input/event1"
#define FRAMES "/mnt/us/kd_frames"
#define STATE "/mnt/us/kd_state"
#define PIDF "/mnt/us/kd_touchd.pid"
#define LOGF "/mnt/us/kd_touch.log"
#define EIPS "/usr/sbin/eips"
#define CLOCK "/mnt/us/kd_bin/kd_clock"
#define DEADL 240 /* x < 240 -> 上一条 */
#define DEADR 360 /* x > 360 -> 下一条 */
#define HIST_MAX 4096
#define AVOID 10 /* 随机时排除最近出现条数 */
static volatile sig_atomic_t req_next = 0;
static volatile sig_atomic_t req_prev = 0;
static void on_usr1(int s) { (void)s; req_next = 1; }
static void on_usr2(int s) { (void)s; req_prev = 1; }
static FILE *lg = NULL;
static int total = 208;
static int hist[HIST_MAX];
static int hn = 0;
static int pos = -1; /* 当前显示条在 hist 中的下标 */
static void ts_(char *buf, size_t n)
{
time_t t = time(NULL);
struct tm tmv;
localtime_r(&t, &tmv);
strftime(buf, n, "%F %T", &tmv);
}
static void lg2(const char *msg)
{
if (!lg) return;
char ts[32];
ts_(ts, sizeof(ts));
fprintf(lg, "%s %s\n", ts, msg);
fflush(lg);
}
/* ---- 历史: 不变式 0 <= pos < hn, hist[pos] == 当前显示条 ---- */
static void hist_shrink(void) /* 空间满了丢最老的一半 */
{
int cut = HIST_MAX / 2;
memmove(hist, hist + cut, (hn - cut) * sizeof(int));
hn -= cut;
pos -= cut;
if (pos < 0) pos = 0;
}
static void hist_append(int idx) /* 随机新条: 截断前进历史后追加 */
{
if (hn >= HIST_MAX - 1) hist_shrink();
hn = pos + 1; /* 作废"未来" */
hist[hn] = idx;
hn++;
pos = hn - 1;
}
static void hist_insert_front(int idx) /* 最早历史前插(到头继续翻页用) */
{
if (hn >= HIST_MAX - 1) hist_shrink();
memmove(hist + 1, hist, (size_t)hn * sizeof(int));
hist[0] = idx;
hn++;
pos = 0;
}
static void show_idx(int idx) /* 显示帧 idx + 写 state + 重画时间条 */
{
char cmd[256];
snprintf(cmd, sizeof(cmd), "%s -g %s/q_%03d.png", EIPS, FRAMES, idx);
if (system(cmd) != -1) {
FILE *sf = fopen(STATE, "w");
if (sf) { fprintf(sf, "%d\n", idx); fclose(sf); }
system(CLOCK " auto"); /* eips 全幅贴图会盖掉时钟条, 补一次 */
}
}
static int recent_seen(int idx, int n) /* 最近 n 条(从当前位置往回)是否出现过 idx */
{
int i, c = 0;
for (i = pos; i >= 0 && c < n; i--, c++)
if (hist[i] == idx) return 1;
return 0;
}
static int pick_random(void)
{
int cur = pos >= 0 ? hist[pos] : -1;
int tries, idx;
for (tries = 0; tries < 60; tries++) { /* 严格: 避当前+避最近 AVOID 条 */
idx = rand() % total;
if (idx != cur && !recent_seen(idx, AVOID)) return idx;
}
for (tries = 0; tries < 60; tries++) { /* 放宽: 只避当前 */
idx = rand() % total;
if (idx != cur) return idx;
}
return (cur + 1) % total; /* 兜底 */
}
static void nav_next(void)
{
int idx = pick_random();
hist_append(idx);
show_idx(idx);
{
char buf[80];
snprintf(buf, sizeof(buf), "nav next -> %d (pos=%d/%d)", idx, pos, hn);
lg2(buf);
}
}
static void nav_prev(void)
{
if (pos > 0) {
pos--; /* 精确重放 */
show_idx(hist[pos]);
{
char buf[80];
snprintf(buf, sizeof(buf), "nav prev -> %d (pos=%d/%d)", hist[pos], pos, hn);
lg2(buf);
}
} else {
/* 已回退到最早一条: 按洗牌池顺序继续往前翻一页(确定性, 非随机),
前插入历史——之后按"下一条"随机, 再按"上一条"仍精确重放 */
int idx = (hist[0] + total - 1) % total;
hist_insert_front(idx);
show_idx(idx);
{
char buf[80];
snprintf(buf, sizeof(buf), "nav prev(head) -> %d (pos=%d/%d)", idx, pos, hn);
lg2(buf);
}
}
}
int main(void)
{
setenv("TZ", "CST-8", 1);
tzset();
srand((unsigned)(time(NULL) ^ getpid()));
lg = fopen(LOGF, "a");
FILE *pp = popen("ls " FRAMES "/q_*.png 2>/dev/null | wc -l", "r");
if (pp) {
int v = 0;
if (fscanf(pp, "%d", &v) == 1 && v > 0) total = v;
pclose(pp);
}
/* 从 state 恢复: hist=[当前位置], pos=0 */
int cur = 0;
FILE *sf = fopen(STATE, "r");
if (sf) { if (fscanf(sf, "%d", &cur) != 1) cur = 0; fclose(sf); }
if (cur < 0 || cur >= total) cur = 0;
hist[0] = cur;
hn = 1;
pos = 0;
/* 关键: sigaction + flags=0 (无 SA_RESTART) —— 否则阻塞中的 read 被信号
打断后自动重启, EINTR 永不出现, 导航标志无人检查 */
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_usr1;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGUSR1, &sa, NULL);
sa.sa_handler = on_usr2;
sigaction(SIGUSR2, &sa, NULL);
}
FILE *pf = fopen(PIDF, "w");
if (pf) { fprintf(pf, "%d\n", getpid()); fclose(pf); }
int fd = open(EVDEV, O_RDONLY);
if (fd < 0) {
lg2("FATAL open event1");
return 1;
}
struct input_absinfo ax, ay;
memset(&ax, 0, sizeof(ax));
memset(&ay, 0, sizeof(ay));
ioctl(fd, EVIOCGABS(ABS_X), &ax);
ioctl(fd, EVIOCGABS(ABS_Y), &ay);
{
char buf[160];
snprintf(buf, sizeof(buf), "touchd start total=%d resume=%d xrange=%d..%d",
total, cur, ax.minimum, ax.maximum);
lg2(buf);
}
struct input_event ev;
int last_x = -1;
int got_abs = 0;
time_t last_act = 0;
for (;;) {
ssize_t r = read(fd, &ev, sizeof(ev));
if (r < 0) {
if (errno == EINTR) {
if (req_next) { req_next = 0; nav_next(); }
if (req_prev) { req_prev = 0; nav_prev(); }
continue;
}
continue;
}
if (r != (ssize_t)sizeof(ev)) continue;
if (req_next) { req_next = 0; nav_next(); }
if (req_prev) { req_prev = 0; nav_prev(); }
if (ev.type == EV_ABS &&
(ev.code == ABS_X || ev.code == 0x35 /*ABS_MT_POSITION_X*/)) {
last_x = ev.value;
got_abs = 1;
} else if (ev.type == EV_SYN && ev.code == SYN_REPORT) {
if (!got_abs) continue;
got_abs = 0;
time_t now = time(NULL);
if (now - last_act < 1) continue; /* 去抖: 1s 内只翻一次 */
int xn;
if (ax.maximum > ax.minimum && (ax.maximum - ax.minimum) >= 100) {
int span = ax.maximum - ax.minimum + 1;
xn = (last_x - ax.minimum) * 600 / span;
} else {
xn = last_x; /* 驱动未提供量程(实测 xrange=0..0), 原生 0..599 */
}
if (xn < 0) xn = 0;
if (xn > 599) xn = 599;
int dir = 0;
if (xn < DEADL) dir = -1;
else if (xn > DEADR) dir = 1;
if (dir) {
char buf[96];
snprintf(buf, sizeof(buf), "touch raw=%d xn=%d", last_x, xn);
lg2(buf);
if (dir < 0) nav_prev(); else nav_next();
last_act = time(NULL);
}
}
}
return 0;
}
编译部署:
arm-linux-gnueabihf-gcc -static -O2 -o kd_touchd kd_touchd.c
scp kd_touchd root@<Kindle的IP>:/mnt/us/kd_bin/
# 注意: 覆盖"正在运行"的二进制会报 ETXTBSY —— 先传成 kd_touchd.new 再 mv 覆盖
13. 第十一步:导航语义——三轮迭代出来的"翻书"手感
这是整个项目最值得聊的部分:“上一条/下一条"到底应该是什么行为。它前后迭代了三轮,每一轮都是被真实使用体验打脸打出来的。
第一版:顺序轮播 + 位置各记各的。 主循环按"节目单"每 10 分钟推进,点击时另行 ±1。结果两个进程各记各的位置,10 分钟边界一到,主循环按自己的老位置 +1 强制覆盖你手动浏览到的画面,“上一条"自然回不到刚看过的内容。
第二版:全收敛 + 随机 + 历史栈。 架构上做了正确决策——显示、帧序、随机、历史全部收敛到 kd_touchd 一个进程,主循环只管作息和发信号(USR1=自动换一条)。随机下一条避开最近 10 条,“上一条"按内存历史栈回退。但栈的设计有缺陷:随机走到头时"替换栈顶延伸”,把真实位置顶掉了;而且刚开机栈里只有一条,点"上一条"立刻走"没历史→随机延伸"分支——用户看到的是:每次按"上一条"都蹦出没见过的内容,跟随机没区别。
第三版:双向历史模型。 hist[] 只增不删,pos 指针永远指向当前显示条,不变式 hist[pos] == 当前显示:
- 下一条 = 随机 → 把
pos右侧的"未来"截断 → 追加; - 上一条 =
pos--精确重放——无论那条是点击、随机还是自动出现的; - 回退到最早一条 → 按洗牌池顺序确定性地往前翻一页(
hist[0]-1),前插入历史。
中间还插过一个插曲:改过一版"到头就原地停住”,结果被吐槽"点都点不动”——因为开机时指针就在头部,用户连按"上一条"全是空操作。最终定稿的语义最像"翻一本洗过牌的语录集":
上一条 = 往回翻(翻过的精确重放,翻到头就继续往前翻没看过的,绝不蹦随机内容);下一条 = 随机跳(避开最近 10 条)。
另一个隐藏大坑值得单独一节:glibc 的 signal() 默认带 SA_RESTART。触摸守护进程阻塞在 read() 上等触摸事件,主循环发来 SIGUSR1,信号处理函数把标志位置 1——然后呢?read 被信号打断后自动重启,EINTR 永远不会出现,主循环永远不会检查标志位。表面上一切正常,实际导航完全失灵。修复必须用 sigaction 且 sa_flags = 0(见源码注释)。这类问题编译器不报、运行不崩、日志无错,只能靠"信号发了但没反应"的现象反推。
为什么主循环和导航要分两个进程? 主循环管时间(作息、分钟时钟),导航进程管内容(帧序、随机、历史、触摸)。两者通过两个文件(kd_state 当前帧号、kd_touchd.pid)+ 两个信号(USR1/USR2)通信。位置的唯一真相在导航进程内存里,主循环永远不自己算帧号——这就消灭了"两个进程位置打架"这一整类 bug。
14. 踩坑速查表
| 坑 | 现象 | 解法 |
|---|---|---|
| 分辨率想当然 | 图上屏只露一小块 | 先跑 eips -i,以输出为准 |
eips 0 0 文件 | 屏幕什么都不显示 | 这是打印字符串模式,显示图片用 eips -g |
| eips 不在 PATH | 脚本/SSH 里 eips: not found | 全路径 /usr/sbin/eips |
eips -k 导出分析 | 导出的缓冲里看不到 framebuffer 直写内容 | 它只反映 eips 管线缓冲;验证直写只能看真屏 |
| FBInk CLI | 横屏下文字侧倒 | CLI 无旋转,弃用;保留其 mxcfb 头文件 |
| 手动 ip addr add | 一会儿就掉线 | NM 会清掉手动地址,用 nmcli 建按 MAC 绑定的 profile |
| GUI 时钟去不掉 | 竖屏顶边系统时钟 | 时钟是 lab126_gui 画的,禁 framework+lab126_gui 自启 |
| 系统时钟 UTC | 显示时间差 8 小时 | 运行环境统一 export TZ=CST-8;/var/local/system/tz 写 CST-8 |
| zforce2 无 absinfo | 量程 0..0,坐标换算全错 | span<100 时直接用原始值(即物理坐标) |
| glibc signal() | 信号"发了没反应" | sigaction + sa_flags=0,让 read 返回 EINTR |
| 覆盖运行中二进制 | scp 报 ETXTBSY | 先传 .new 再 mv 覆盖 |
pkill -f 杀进程 | SSH 命令把自己杀掉/杀不到 | 扫 /proc/*/cmdline 拿 PID 直接 kill |
| busybox ps | 找不到脚本进程 | 同上,或看 /proc/<pid>/cmdline |
| 贪心断行吞尾行 | 长句最后一段消失 | 循环结束后补 if cur: lines.append(cur) |
| 行首句读标点 | ,。出现在行首 | 禁则处理:回挂上一行行尾 |
| 语录分类连排 | 红楼梦 26 连 | 固定种子洗牌,类型交错且序列稳定 |
| upstart 启动条件 | 开机服务没起来 | GUI 停用后用 start on started powerd;等 /mnt/us 就绪再 exec |
15. 结语与扩展方向
最终形态一览:
- 完全独立:断网、断主机照常运行,Kindle 自身时钟 + 存储 + eips 就是全部依赖;
- 帧包仅约 1.5MB(208 帧 + 白页),8bpp 灰度 PNG 每张十几 KB;
- 交互:左半屏回翻(精确重放 + 到头继续池序前翻)、右半屏随机换、每 10 分钟自动换、顶部实时时钟、深夜自动白屏休息;
- 开机自启、免维护:插上 USB 线供电即可,SSH 通道留给改内容。
可以继续扩展的方向:
- 内容模板:除了名言,渲染器稍改就能出"每日一词"、天气卡片(需联网取数再离线渲染)、Todo 提醒页;
- 翻页手势:event1 里还有 ABS_Y,可以做上下滑动翻类目;
- 省电档位:夜间白屏改关屏 + 定时唤醒(powerd 接口),进一步省电;
- 多设备:帧包做成"主题包",换季换主题。
一台吃灰五六年的电子书,最后变成每天在桌上安安静静翻页的摆件——墨水屏翻页的那一下闪,看久了还挺治愈。折腾两天,值了。
附:本文所有代码均为实机在跑的最终版本,完整可运行。复刻中卡在某一步,欢迎留言交流。
更新记录:v1 首发版本。