“纸上所写的东西,不需要电来维持。墨水屏,也是如此。”
你一定用过 Kindle。翻页时屏幕短暂闪烁,新文字出现。关掉电源,画面依然保持。这种”接近纸张”的显示技术就是电子墨水(E-Ink),也是 CrossPoint Reader 的核心硬件之一。 这一章,我们从物理原理讲到代码——搞清楚一个像素是怎么从程序变成屏幕上的黑点的。

5.1 E-Ink 的物理原理

微胶囊里的黑白世界

想象一个透明的小球(直径约和头发丝差不多),里面装满了油,油里漂浮着两种颗粒:白色颗粒带正电,黑色颗粒带负电。
  ┌───────────────┐ ← 屏幕表面(透明)     施加正电场后:      断电后:
  │  ○ ○ ○ ○ ○ ○  │ ← 白色颗粒浮上面      ┌───────────────┐  颗粒不动
  │    油液介质     │                       │  ● ● ● ● ● ●  │  画面保持!
  │  ● ● ● ● ● ●  │ ← 黑色颗粒沉在下      │    油液介质     │
  └───────────────┘                        │  ○ ○ ○ ○ ○ ○  │
  → 你看到白色                              └───────────────┘
                                           → 你看到黑色
一块墨水屏由数十万个这样的微胶囊排列组成。CrossPoint Reader 的屏幕是 800x480,即 384,000 个独立控制的”像素点”。

双稳态:断电保持画面

颗粒被电场”赶”到位后,即使撤掉电场也不会自己移动——摩擦力和范德华力把它们”粘”在当前位置。这种特性叫双稳态(Bistable):黑和白都是稳定状态,不需要持续供电。这就是 Kindle 关机后屏幕还能显示画面的原因。
对比 LCD:手机液晶屏需要背光灯持续点亮、电场持续维持。断电即黑屏。这就是墨水屏续航远超手机的原因。

为什么刷新需要闪烁?

某些颗粒已在”正确”位置时不会响应新的电场变化,周围颗粒移动时会干扰它们,导致残影(Ghosting)。解决办法:先把所有颗粒统一重置——全推到白,再全推到黑,再全推到白——然后画新内容。这个重置过程就是你看到的”闪烁”。

5.2 三种刷新模式

// lib/hal/HalDisplay.h
enum RefreshMode {
  FULL_REFRESH,   // 全刷:先全黑再全白再画内容,最干净但最慢(~1秒)
  HALF_REFRESH,   // 半刷:较干净,较快
  FAST_REFRESH    // 快刷:最快(~200ms),但会累积残影
};
  • 全刷:完整重置流程,效果最干净但耗时~1秒且闪烁明显。适合打开书籍、进入新页面。
  • 半刷:简化重置,比全刷快,闪烁较轻,可能留轻微残影。
  • 快刷:不做重置直接覆盖,~200ms 几乎无闪烁,但残影逐渐累积。适合翻页阅读。

阅读器的刷新策略

CrossPoint Reader 默认用快刷翻页,每隔若干页(用户可设置)自动做一次全刷清除残影:
第 1-4 页 → 快刷(残影逐渐累积)
第 5 页   → 全刷(屏幕完全干净!)
第 6-9 页 → 快刷(又开始累积)...
这是”显示质量”与”翻页速度”之间的典型工程权衡(Trade-off)。

5.3 帧缓冲区(Frame Buffer)

把内容推送到屏幕前,需要先在内存中准备好完整的一帧画面。这块内存叫帧缓冲区——可以理解为一块数字画布:
  内存中:[0xFF, 0xFF, 0xA3, 0x00, ...]
  屏幕上:□□□□□□□□ □□□□□□□□ ■□■□□□■■ ■■■■■■■■ ...
         (全白)    (全白)   (黑白交替)  (全黑)

  一个字节 = 8 个像素(每个像素只需 1 bit:黑或白)
800x480 单色屏:800 × 480 ÷ 8 = 48,000 字节 ≈ 47KB,占 380KB 内存的 12%
// lib/hal/HalDisplay.h
static constexpr uint16_t DISPLAY_WIDTH = 800;
static constexpr uint16_t DISPLAY_HEIGHT = 480;
void clearScreen(uint8_t color = 0xFF) const;         // 清空画布(默认全白)
void displayBuffer(RefreshMode mode = FAST_REFRESH, bool turnOffScreen = false);
uint8_t* getFrameBuffer() const;
工作流程:clearScreen() → 在缓冲区上画内容 → displayBuffer() 推送到屏幕。 注意 clearScreen() 默认填充 0xFF(全 1),即 bit=1 是白色,bit=0 是黑色

5.4 像素操作:一个 bit 的旅程

// lib/GfxRenderer/GfxRenderer.cpp
void GfxRenderer::drawPixel(int x, int y, bool state) const {
  int phyX, phyY;
  rotateCoordinates(orientation, x, y, &phyX, &phyY, panelWidth, panelHeight);  // ① 坐标旋转
  if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) return; // ② 边界检查
  const uint32_t byteIndex = phyY * panelWidthBytes + (phyX / 8);  // ③ 定位字节
  const uint8_t bitPosition = 7 - (phyX % 8);                      // ④ 定位 bit
  if (state) frameBuffer[byteIndex] &= ~(1 << bitPosition);  // ⑤ 画黑(清零)
  else       frameBuffer[byteIndex] |= 1 << bitPosition;     //    画白(置一)
}

坐标到字节的映射(第 ③ 步)

帧缓冲区是线性内存,屏幕是二维的。映射规则:
  byteIndex = y * panelWidthBytes + (x / 8)
              │                      └── 同一行内,每 8 像素占 1 字节
              └── 跳过前 y 行(每行 100 字节,因为 800/8=100)

  例:像素 (19, 3) → byteIndex = 3×100 + 19/8 = 302

MSB-First 位顺序(第 ④ 步)

一个字节的 8 个 bit 对应 8 个连续像素,最高位对应最左像素
  bit 编号:  7    6    5    4    3    2    1    0
  对应像素: x=0  x=1  x=2  x=3  x=4  x=5  x=6  x=7

  bitPosition = 7 - (x % 8)
  例:x=19 → 19%8=3 → bitPosition=4

位运算画像素(第 ⑤ 步)

一个字节存了 8 个像素,不能直接赋值(会破坏其他 7 个像素),必须用位运算”精确手术”:
  画白(OR 置 1):    原始 10110100 | 00010000 = 10110100  → bit4 变 1
  画黑(AND 清 0):   原始 10110100 & 11101111 = 10100100  → bit4 变 0

5.5 屏幕旋转:坐标变换

墨水屏的物理像素排列是固定的,但用户可能横着用或竖着用。软件需要把逻辑坐标自动转换成物理坐标
static inline void rotateCoordinates(Orientation orientation, int x, int y,
                                     int* phyX, int* phyY, uint16_t w, uint16_t h) {
  switch (orientation) {
    case Portrait:              *phyX = y;         *phyY = h - 1 - x; break;
    case LandscapeClockwise:    *phyX = w - 1 - x; *phyY = h - 1 - y; break;
    case PortraitInverted:      *phyX = w - 1 - y; *phyY = x;         break;
    case LandscapeCounterCW:    *phyX = x;         *phyY = y;         break;
  }
}
Portrait(竖屏)为例——用户认为屏幕是 480 宽 x 800 高,但物理屏幕仍是 800 宽 x 480 高:
  转换:phyX = y,  phyY = h - 1 - x
  逻辑 (0, 0)   → 物理 (0,   479)   逻辑左上角 → 物理左下角
  逻辑 (479, 0) → 物理 (0,   0)     逻辑右上角 → 物理左上角
上层代码只管用逻辑坐标画画,drawPixel 内部自动变换——又是一个抽象层的例子。

5.6 灰度抖动(Dithering)

墨水屏每个像素只能黑或白。怎么显示”灰色”?用印刷界的老技巧——抖动:在区域内按一定密度交替放黑白点,远看像灰色。
  浅灰(25% 填充)     深灰(50% 棋盘格)    黑色(100%)
  ●□●□●□              ●□●□●□              ●●●●●●
  □□□□□□              □●□●□●              ●●●●●●
  ●□●□●□              ●□●□●□              ●●●●●●
// lib/GfxRenderer/GfxRenderer.cpp
// 浅灰:每 4 个像素只有 1 个黑点(25% 填充率)
template <>
void GfxRenderer::drawPixelDither<Color::LightGray>(int x, int y) const {
  drawPixel(x, y, x % 2 == 0 && y % 2 == 0);
}
// 深灰:棋盘格(50% 填充率)
template <>
void GfxRenderer::drawPixelDither<Color::DarkGray>(int x, int y) const {
  drawPixel(x, y, (x + y) % 2 == 0);
}
浅灰x%2==0 && y%2==0,只有 x 和 y 都是偶数时画黑点,每 2x2 方块中 1 个黑点。 深灰(x+y)%2==0,x+y 为偶数时画黑——经典棋盘格,恰好半黑半白。
为什么用模板特化? 颜色在编译时确定,编译器为每种颜色生成优化的机器码,避免运行时条件判断——在每秒调用几十万次的像素操作中,这点优化很有意义。

5.7 灰度显示:4 级灰度

抖动是”视觉欺骗”,凑近看仍能看到黑白网格。通过精确控制电场强度和持续时间,可以让颗粒停在中间位置,实现真正的灰色——4 级灰度,每像素需要 2 bit。 CrossPoint Reader 用两个独立帧缓冲区(LSB 和 MSB)叠加实现:
  MSB  LSB  灰度值  显示          两张"透明胶片"叠加
   0    0    00     白色          → 4 种组合 = 4 级灰度
   0    1    01     浅灰
   1    0    10     深灰
   1    1    11     黑色
// lib/hal/HalDisplay.h
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
void displayGrayBuffer(bool turnOffScreen = false);
主要用途:抗锯齿字体(灰色像素平滑文字边缘)和 2-bit 漫画(XTC 格式)。
  无抗锯齿:  □□■■■□□     有抗锯齿:  □□■■■□□
             □■■□■■□                □▓■□■▓□   ▓ = 灰色像素
             □■□□□■□                ▓■□□□■▓   边缘更平滑
内存代价:灰度模式需双倍缓冲区(~94KB),因此 CrossPoint Reader 只在需要时启用。

5.8 完整的显示流程

  1. clearScreen()       → 帧缓冲区全部填 0xFF(白)
  2. 调用绘图函数          → drawPixel() 逐像素修改缓冲区
     ├── rotateCoordinates() 坐标变换
     ├── 计算 byteIndex / bitPosition
     └── 位运算修改目标 bit
  3. displayBuffer()      → SPI 发送 48000 字节到屏幕控制器
                          → 控制器逐像素施加电场
                          → 微胶囊颗粒移动 → 画面出现!
最耗时的不是内存计算(几毫秒),而是物理颗粒的移动——这是墨水屏刷新慢于液晶屏的根本原因。

本章要点

  1. E-Ink 原理:微胶囊中的黑白带电颗粒在电场控制下上浮/下沉。断电后颗粒不动(双稳态),画面自然保持。
  2. 三种刷新模式:全刷最干净但最慢(~1s),快刷最快但累积残影(~200ms)。阅读器翻页用快刷,定期全刷清残影。
  3. 帧缓冲区:线性内存,每 bit 对应一像素。800x480 屏需 47KB。先画好再一次性推送。
  4. 像素操作byteIndex = y*行宽字节数 + x/8 定位字节,bitPosition = 7 - x%8(MSB-First)定位 bit,位运算精确修改。
  5. 坐标旋转:通过数学变换实现多方向显示,上层无需关心物理方向。
  6. 灰度抖动:不同密度黑白点阵模拟灰色(25% 浅灰,50% 棋盘格深灰)。
  7. 4 级灰度:LSB/MSB 双缓冲,每像素 2 bit,用于抗锯齿和灰度漫画,代价是双倍内存。

下一章预告:第 6 章我们将进入存储系统——SD 卡怎么通过 SPI 连接?文件系统怎么工作?多任务同时读写时怎么防冲突?