Skip to main content

Running Go on a PocketBook

Inkshelf is one static ARM program, built by a plain go build, that opens the screen itself, draws on a canvas, and copies it, turned a quarter, into the framebuffer that the panel shows.

PocketBook sells its readers with an SDK: a C library, libinkview, that draws the screen, reads the buttons and talks to the reader's system. Apps for the reader are normally written against it. Inkshelf is not. This chapter explains why, and what it does instead, from the build on your computer to the first pixel on the glass.

Using the SDK is like travelling with a heavy adapter kit: it works, but you must carry it everywhere and it only fits one plug. Inkshelf learned the local plugs instead, and travels with nothing but itself.

Why no SDK​

The SDK is written in C. Go can call C, through cgocgo: Go's bridge to C code. Inkshelf does not use it, so a plain go build makes the reader's program on any computer., but the price is high:

  • cgo needs a C compiler that makes code for the reader's ARM processor: the SDK's cross-compiler, with the reader's C headers and libraries, installed on every computer that builds the app;
  • the program is then tied to a C library, libinkview, that must be on the reader in the right version.

Without C, all of that disappears. Go's own compiler makes ARM code from any computer, Mac, Linux or Windows. The Makefile · build target is a single line:

Makefile
TARGET := CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7
$(TARGET) go build -trimpath -ldflags="-s -w" -o build/inkshelf.app ./cmd/inkshelf

GOOS=linux GOARCH=arm names the target, GOARM=7 the reader's Cortex-A7, and CGO_ENABLED=0 promises there is no C anywhere. The result, build/inkshelf.app, is an ordinary Linux executable (an ELF file) for ARM, statically linked: everything it needs is inside.

InteractiveTwo ways to build for the reader
Go code that talks to the system itself
package syscall and unsafe: open, ioctl, mmap, msgrcv…
↓
One command, on any computer
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build
↓
Nothing to link but Go
-trimpath -ldflags="-s -w": stripped, no build paths
↓
build/inkshelf.app: one static ARM ELF
copy it to applications/ on the reader, done
stage 1/4
Inkshelf does what libinkview does in Go: it opens /dev/fb0, maps it and calls the kernel directly (internal/device/pocketbook).
To installGo
C in the buildnone (CGO_ENABLED=0)
Outputone static ELF
Try this: Play both ways. Count what must be installed on the build computer, and what must be on the reader.

The catch: libinkview's work has to be done by hand. That is the package internal/device/pocketbook. What libinkview does was learned by disassembling it and by running small probe programs on the reader. The next chapters show what it found.

Getting the app onto the reader​

The reader shows up as a USB disk when you plug it in and choose "PC link". make deploy runs deploy.sh, which finds the reader's volume under /Volumes (a folder holding both applications and system), copies build/inkshelf.app into applications/, checks the copy byte for byte, copies the launcher icons, then ejects.

One detail shows how small things bite. macOS likes to write hidden ._name files next to what it copies, to store extra metadata. The reader's launcher lists every file in applications/ as an app, so each ._inkshelf.app would appear as a broken app. deploy.sh sets COPYFILE_DISABLE=1, copies with cp -X, and deletes any ._ files left over.

The launcher's menu is a JSON file on the reader, system/config/desktop/view.json: an entry there, edited once by hand, points at /mnt/ext1/applications/inkshelf.app and its icons.

From a tap on the icon to the first screen​

When you tap the icon, the firmware starts the program as a new taskTask: What the PocketBook firmware calls a running app. It knows each one by its process group id (pgid).. main then sets up the log, builds the app, and hands it to pocketbook.Run, which opens everything the reader shares with apps.

InteractiveStart-up, one call at a time
inkshelf.log
stdout, stderr and the log
the App
state and screens, not drawn yet
/dev/fb0
framebuffer, mapped
shm 0xa12302
firmware’s global state
msg queue 0xa1230f
task messages
/hwevent
input queue
task canvas
1404×1872 shm
event loop
ticks every 20 ms
the launcher
/mnt/ext1/applications/inkshelf.app
step 1/12
You tap the icon. The firmware’s launcher starts the program file, as a new task in its own process group.
Try this: Step through. Watch which resources light up, and note which ones the app can live without.

Three of those objects (the global state, the task queue, /hwevent) are how the app and the firmware talk: they are the subject of the next chapter. The other two, the framebuffer and the canvas, are how pixels reach the panel.

Three layers: canvas, framebuffer, panel​

A framebufferFramebuffer: The block of memory (/dev/fb0) the panel controller reads pixels from. Writing to it changes nothing on screen until a refresh. is memory whose bytes are the screen's pixels. On the InkPad 3 it is /dev/fb0, one byte per pixel, 0 for black and 255 for white. On an LCD, writing there would change the screen at once. On e-inkE-ink: A display made of tiny capsules of black and white pigment, moved by electric fields. It keeps its image without power. it does not: the panel only changes when it is told to refresh an area, with an ioctlioctl: A system call that sends a device-specific command to a driver, here to ask the panel for a refresh (MXCFB_SEND_UPDATE). to its driver.

The app does not even write into the framebuffer. It draws on a canvasCanvas: Where Inkshelf draws: a 1404×1872 gray image in shared memory, upright. A refresh copies it, rotated, to the framebuffer.: a 1404 × 1872 gray image, upright, in shared memory the firmware gave the task (its task switcher shows it as a thumbnail). Fill, DrawImage and the text drawing only change the canvas. Then the app calls Refresh(rect, waveform), and only then:

  1. flush checks visible(): an app in the background must not touch the panel;
  2. it waits, if the previous refresh is still running over the same area;
  3. copyRotated copies the rectangle from the canvas into the framebuffer, turning it;
  4. the ioctl MXCFB_SEND_UPDATE asks the panel to show that rectangle with a waveformWaveform: The sequence of voltages the panel applies to change pixels. Fast ones show few grays; slow ones show 16 and clean ghosts.. It returns at once; the panel takes about half a second.
InteractiveNothing shows until Refresh
1 · Canvas (click to draw)
→ copyRotated →
2 · Framebuffer (/dev/fb0)
→ ioctl →
3 · Panel (what you see)
Changed since last refresh: rect (0,1)-(6,7)
The app drew an arrow on the canvas. The panel shows nothing yet: nobody asked for a refresh.
Try this: Click the canvas to draw. Nothing else changes. Then press Refresh and follow the columns.

Keeping the canvas separate means the app can draw a whole page in many small steps and show it in one refresh, and that a page can be ready on the canvas before the panel is free.

The panel is mounted sideways​

The panel's natural orientation is landscape: its framebuffer is 1872 pixels wide and 1404 high. You hold the reader in portrait, 1404 × 1872. So every refresh turns the picture a quarter: screen pixel (x, y) is framebuffer column y, row 1403 − x.

InteractiveWhere each pixel lands
Canvas, as the app draws (4 × 6)
abcdefghijklmnopqrstuvwx
⟳ a quarter turn →
Framebuffer, as the panel reads (6 × 4)
dhlptxcgkoswbfjnrvaeimqu
canvas (x 1, y 0) → framebuffer column y = 0, row (4 − 1) − x = 2

The canvas’s top row a b c d becomes the framebuffer’s left column, read from the bottom up. Each canvas row becomes a framebuffer column.

The same rule on the InkPad 3 (canvas 1404 × 1872, framebuffer 1872 × 1404)
screen (100, 300) → framebuffer column 300, row 1403 − 100 = 1303
Try this: Hover a letter on either side. Then find where the canvas's top row went.

A row of the canvas becomes a column of the framebuffer. Copying means reading along one and writing down the other, which is where the time goes.

Copying fast: 4 × 4 blocks​

The copy runs on every page turn, and framebuffer memory is slow: on an InkPad 3, about 19 ns a byte when written a byte at a time. A full page is 1404 × 1872 bytes, about 2.6 million. Worse, Go on 32-bit ARM never merges byte stores into word stores by itself.

So copyRotated moves pixels four at a time. It cuts the rectangle into 32 × 32 tiles (so the rows it reads and the rows it writes stay in the cache), and each tile into 4 × 4 blocks. A block is read as four 32-bit words, one per row, transposed into four words, one per column, with shifts and masks, and written as four words. The result: about 22 ms a page turn, against 48 ms byte by byte.

InteractiveTransposing a 4 × 4 block in two moves
Canvas block
abcdefghijklmnop
Framebuffer block
Each word: byte 0 (low) on the left, byte 3 (high) on the right; its value in hex below.
row0abcd0x64636261
row1efgh0x68676665
row2ijkl0x6c6b6a69
row3mnop0x706f6e6d
row0 := *(*uint32)(source) row1 := *(*uint32)(unsafe.Add(source, canvasStride)) // and row2, row3
step 1/7
Four loads read the block’s four canvas rows, four pixels each. ARM is little-endian: the first pixel, a, lands in the low byte, so row0 prints as 0x64636261, “dcba” backwards.
Try this: Step through and follow the letter f from row1 to column1. Notice the bytes are written low byte first.
internal/device/pocketbook/framebuffer_copy.go
even01, odd01 := interleaveBytes(row0, row1)
even23, odd23 := interleaveBytes(row2, row3)
column0, column2 := interleaveHalves(even01, even23)
column1, column3 := interleaveHalves(odd01, odd23)
Going deeper
  • Edges. Only whole, word-aligned blocks go through the fast path: wordBlocks finds them (a canvas column whose address is a multiple of 4, a framebuffer column y that is a multiple of 4). The strips around them, above, below, left and right, are copied byte by byte by copyRotatedBytes. If the memory is not laid out for words at all, everything goes byte by byte.
  • A hot path. copyRotated is one of the functions marked HOT PATH in the code. Its shape was tuned against the ARM assembly: interleaveBytes, interleaveHalves and the tile iterator must be inlined. A change to it needs BenchmarkCopyRotated before and after, and the same inlining list from go build -gcflags=-m for GOOS=linux GOARCH=arm.
  • The rectangle the panel gets. framebufferRect turns the screen rectangle the same way: Rect(r.Min.Y, 1404 − r.Max.X, r.Max.Y, 1404 − r.Min.X). That is what MXCFB_SEND_UPDATE receives, as top, left, width and height.
  • Overlapping refreshes. flush remembers the marker number and area of the last refresh. It waits for it (MXCFB_WAIT_FOR_UPDATE_COMPLETE) only if the new area overlaps it, so refreshes of different areas run side by side. Each refresh logs its wait, copy and send times to applications/inkshelf.log: that is how page turns are timed on the reader.

Where it lives in the code​

The full story: E-ink and the firmware.

Next: the app is not alone on the reader. Talking to the firmware shows how it shares the device with the firmware and the other apps.

Check yourself​

Why can Inkshelf be built on a Mac with just Go installed?
The app has just called Fill on part of the screen. What does the panel show?
Screen pixel (10, 500) lands where in the framebuffer?
Why does copyRotated work on 4×4 blocks of 32-bit words?