返回首页

把吃灰 Kindle 改成独立名言屏:越狱、帧渲染、时钟叠加与触摸翻页全记录

2026-09-26 折腾

家里有台吃灰多年的 Kindle 基础版。墨水屏、无背光刺眼、静态画面零功耗,天生就是做"桌面信息屏"的料。这个周末把它整个改造了一遍,现在是这个样子:

成品:横放的 Kindle 名言屏,顶部实时时钟,左右角触摸翻页

现在它的日常是:白天每 10 分钟换一条中外名言,顶部时钟实时走字;点左半屏回看上一条、右半屏随机换下一条;深夜自动切纯白休息页。断网、拔掉主机,一切照常——因为它完全独立运行,Kindle 自己的时钟、存储和画屏工具就是全部依赖。成本 0 元,全程不需要任何云服务。

期间踩的坑不少,值得完整记一笔。

目录

  1. 为什么做,以及为什么放弃浏览器方案
  2. 准备工作与风险提示
  3. 第一步:确认设备真身(eips -i)
  4. 第二步:LanguageBreak 越狱
  5. 第三步:安装 MRPI / KUAL / USBNetwork 三插件
  6. 第四步:USB 网络直连(SSH 通道)
  7. 第五步:停用 GUI 栈与 OTA
  8. 第六步:帧渲染管线(电脑端)
  9. 第七步:部署帧包与主循环 kd_show.sh
  10. 第八步:开机自启(upstart 双任务)
  11. 第九步:顶部实时时钟条 kd_clock.c
  12. 第十步:触摸翻页守护 kd_touchd.c
  13. 第十一步:导航语义——三轮迭代出来的"翻书"手感
  14. 踩坑速查表
  15. 结语与扩展方向

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)下载。核心流程:

  1. 拷贝 LanguageBreak 文件到 Kindle 根目录(USB 线连接时的 us 分区);
  2. 搜索框输入 ;enter_demo 回车,进入演示模式(设备会重启进 demo 环境);
  3. ;demo 回车进演示菜单,选 Resell 走完流程(关键:第一次拷包后选 Resell,示意图一出现立刻插 USB 线重拷第二次);
  4. Resell 流程走到语言选择时,选简体中文——触发漏洞,屏幕滚动安装日志;
  5. 安装完成后安装 zh-Hans-CN 热修复(演示设置界面操作;过程中报 Error 4 是正常现象,系统会自动清理 bin 文件);
  6. 重启后 ;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 装完后做两件事:

  1. 开机自启:把 /mnt/us/usbnet/auto 从 DISABLED_auto 改名为 auto;
  2. 切换时拔线:插线状态下切换 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 的核心思路:

  1. 字体: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
  1. 坐标变换:横放(顶朝左)时,横屏画面的"顶边"对应竖版 fb 的 x=599 列。先在条带缓冲 strip[hx][hy](hx∈[0,800) 横向行进,hy∈[0,52))里正常画字,再整体旋转写入 fb:fb[y=hx][x=599-hy]。
  2. 局部刷新:只对顶部条带区域发 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 的事件流还在。直接读它,做成一个守护进程:点左半屏 = 上一条,右半屏 = 下一条,中间竖带无效(避免误触)。

三个关键细节:

  1. zforce2 触摸驱动不填 absinfo:EVIOCGABS 查回来的量程是 0..0。所以坐标换算要做兜底:量程有效(span≥100)就归一化,否则原始值就是物理坐标 0..599。不看日志永远想不到这一层——首版所有点击都被换算成越界值、方向判定全错。
  2. 事件节奏:触摸控制器以 SYN_REPORT 为一帧边界,在 SYN 时取最后一次 ABS_X 判定即可;加 1 秒去抖防连点。
  3. 为什么是守护进程而不是脚本:它同时承担"触摸导航"与"随机/历史状态管理"(见第十一步),还要接收主循环的信号,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 通道留给改内容。

可以继续扩展的方向:

  1. 内容模板:除了名言,渲染器稍改就能出"每日一词"、天气卡片(需联网取数再离线渲染)、Todo 提醒页;
  2. 翻页手势:event1 里还有 ABS_Y,可以做上下滑动翻类目;
  3. 省电档位:夜间白屏改关屏 + 定时唤醒(powerd 接口),进一步省电;
  4. 多设备:帧包做成"主题包",换季换主题。

一台吃灰五六年的电子书,最后变成每天在桌上安安静静翻页的摆件——墨水屏翻页的那一下闪,看久了还挺治愈。折腾两天,值了。

附:本文所有代码均为实机在跑的最终版本,完整可运行。复刻中卡在某一步,欢迎留言交流。

更新记录:v1 首发版本。