From 1c5bda37bf2097d3730c8358a89269c104055e84 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Fri, 31 Jul 2026 16:46:11 -0700 Subject: [PATCH] Drive the panel through drm and page flip between four scanout buffers --- fb.go | 464 +++++++++++++++++++++++++++++++++++++++++++++----------- main.go | 25 ++- ui.go | 6 +- 3 files changed, 389 insertions(+), 106 deletions(-) diff --git a/fb.go b/fb.go index 7454c1f..d4dec6f 100644 --- a/fb.go +++ b/fb.go @@ -1,53 +1,149 @@ package main import ( + "encoding/binary" "fmt" - "os" + "path/filepath" "unsafe" "golang.org/x/sys/unix" ) +// Drawing lands in a plain memory buffer and is blitted to a scanout buffer the +// display is not reading, which is then swapped in whole at a vertical blank. +// Writing into the live scanout buffer instead, as the fbdev interface invites, +// races the beam: the blit takes a few hundred microseconds, and whatever the +// display reads during it is part of one frame and part of the next. const ( - fbioGetVScreenInfo = 0x4600 - fbioBlank = 0x4611 - fbBlankUnblank = 0 + drmIoctlBase = 0x64 - kdSetMode = 0x4b3a - kdText = 0x00 - kdGraphics = 0x01 - consolePath = "/dev/tty0" - fbPath = "/dev/fb0" + drmModeConnected = 1 + drmModeTypePreferred = 1 << 3 + drmModePageFlipEvent = 0x01 + drmEventFlipComplete = 0x02 + + // What ADDFB means by 32 bits per pixel and 24 bits of colour. + xrgbRedShift = 16 + xrgbGreenShift = 8 + xrgbBlueShift = 0 + + // Two would be enough to stop the panel tearing, since one buffer being + // displayed while the other is drawn into is all double buffering means. + // More than two is for anything reading a frame back out: they are cycled + // in order, so a buffer is left alone for the three frames between going on + // screen and coming round again, and reading one out of uncached scanout + // memory takes a good fraction of a frame. + scanoutBuffers = 4 ) -// The variable screen info is a flat run of 40 u32s, so it is read as an array -// rather than a struct to sidestep any question of padding. -const ( - viXres = 0 - viYres = 1 - viBitsPerPixel = 6 - viRedOffset = 8 - viGreenOffset = 11 - viBlueOffset = 14 - viScreenInfoLen = 40 +func drmIO(nr uintptr) uintptr { return drmIoctlBase<<8 | nr } +func drmIOWR(nr, size uintptr) uintptr { return 3<<30 | size<<16 | drmIoctlBase<<8 | nr } + +type drmModeInfo struct { + clock uint32 + hdisplay, hsyncStart, hsyncEnd, htotal, hskew uint16 + vdisplay, vsyncStart, vsyncEnd, vtotal, vscan uint16 + vrefresh, flags, typ uint32 + name [32]byte +} + +type drmModeCardRes struct { + fbIDPtr, crtcIDPtr, connIDPtr, encIDPtr uint64 + countFBs, countCRTCs, countConns, countEncs uint32 + minWidth, maxWidth, minHeight, maxHeight uint32 +} + +type drmModeGetConnector struct { + encodersPtr, modesPtr, propsPtr, propValuesPtr uint64 + countModes, countProps, countEncoders uint32 + encoderID, connectorID, connectorType uint32 + connectorTypeID, connection uint32 + mmWidth, mmHeight, subpixel, pad uint32 +} + +type drmModeGetEncoder struct { + encoderID, encoderType uint32 + crtcID uint32 + possibleCRTCs, possibleClones uint32 +} + +type drmModeCrtc struct { + setConnectorsPtr uint64 + countConnectors uint32 + crtcID uint32 + fbID uint32 + x, y uint32 + gammaSize uint32 + modeValid uint32 + mode drmModeInfo +} + +type drmModeFBCmd struct { + fbID, width, height, pitch, bpp, depth uint32 + handle uint32 +} + +type drmModeCreateDumb struct { + height, width, bpp, flags uint32 + handle, pitch uint32 + size uint64 +} + +type drmModeMapDumb struct { + handle, pad uint32 + offset uint64 +} + +type drmModeCrtcPageFlip struct { + crtcID, fbID, flags, reserved uint32 + userData uint64 +} + +var ( + drmSetMaster = drmIO(0x1e) + drmDropMaster = drmIO(0x1f) + drmGetResources = drmIOWR(0xa0, unsafe.Sizeof(drmModeCardRes{})) + drmSetCrtc = drmIOWR(0xa2, unsafe.Sizeof(drmModeCrtc{})) + drmGetEncoder = drmIOWR(0xa6, unsafe.Sizeof(drmModeGetEncoder{})) + drmGetConnector = drmIOWR(0xa7, unsafe.Sizeof(drmModeGetConnector{})) + drmAddFB = drmIOWR(0xae, unsafe.Sizeof(drmModeFBCmd{})) + drmPageFlip = drmIOWR(0xb0, unsafe.Sizeof(drmModeCrtcPageFlip{})) + drmCreateDumb = drmIOWR(0xb2, unsafe.Sizeof(drmModeCreateDumb{})) + drmMapDumb = drmIOWR(0xb3, unsafe.Sizeof(drmModeMapDumb{})) ) +func drmIoctl(fd int, req uintptr, arg unsafe.Pointer) error { + if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg)); errno != 0 { + return errno + } + return nil +} + +type scanout struct { + fbID uint32 + mem []byte +} + // w and h are the logical canvas, which is portrait; pw and ph are the panel, // which is landscape. Every draw is turned a quarter turn on its way to memory, // so logical top lands on the panel's right edge. type framebuffer struct { - file *os.File - tty *os.File - mem []byte + fd int back []byte w int h int pw int ph int stride int - rShift uint - gShift uint - bShift uint + + bufs [scanoutBuffers]scanout + front int + crtcID uint32 + connID uint32 + + // One token per completed flip. The render loop waits on this rather than on + // a timer, so drawing is paced by the panel instead of by a guess at its rate. + flips chan struct{} } func (fb *framebuffer) offset(x, y int) int { @@ -58,88 +154,261 @@ func (fb *framebuffer) fromPanel(x, y int) (int, int) { return y, fb.pw - 1 - x } -// The kernel console draws into the same framebuffer, including a blinking -// cursor and any keyboard echo, which fights every frame we write. Putting the -// active console into graphics mode stops it touching the framebuffer at all. -func takeConsole() (*os.File, error) { - tty, err := os.OpenFile(consolePath, os.O_RDWR, 0) - if err != nil { - return nil, err +func cardResources(fd int) (crtcs, conns []uint32, err error) { + var res drmModeCardRes + if err := drmIoctl(fd, drmGetResources, unsafe.Pointer(&res)); err != nil { + return nil, nil, fmt.Errorf("get resources: %w", err) } - if _, _, errno := unix.Syscall(unix.SYS_IOCTL, tty.Fd(), - kdSetMode, kdGraphics); errno != 0 { - tty.Close() - return nil, fmt.Errorf("console to graphics mode: %w", errno) + if res.countCRTCs == 0 || res.countConns == 0 { + return nil, nil, fmt.Errorf("card has no crtcs or connectors") } - return tty, nil + crtcs = make([]uint32, res.countCRTCs) + conns = make([]uint32, res.countConns) + res.countFBs, res.countEncs = 0, 0 + res.fbIDPtr, res.encIDPtr = 0, 0 + res.crtcIDPtr = uint64(uintptr(unsafe.Pointer(&crtcs[0]))) + res.connIDPtr = uint64(uintptr(unsafe.Pointer(&conns[0]))) + if err := drmIoctl(fd, drmGetResources, unsafe.Pointer(&res)); err != nil { + return nil, nil, fmt.Errorf("get resources: %w", err) + } + return crtcs, conns, nil } -func openFramebuffer(path string) (*framebuffer, error) { - f, err := os.OpenFile(path, os.O_RDWR, 0) +// The preferred mode is the panel's native one; anything else would be the +// driver scaling a wrong-sized image onto it. +func preferredMode(fd int, connID uint32) (drmModeInfo, error) { + c := drmModeGetConnector{connectorID: connID} + if err := drmIoctl(fd, drmGetConnector, unsafe.Pointer(&c)); err != nil { + return drmModeInfo{}, err + } + if c.countModes == 0 { + return drmModeInfo{}, fmt.Errorf("connector %d reported no modes", connID) + } + modes := make([]drmModeInfo, c.countModes) + q := drmModeGetConnector{ + connectorID: connID, + countModes: c.countModes, + modesPtr: uint64(uintptr(unsafe.Pointer(&modes[0]))), + } + if err := drmIoctl(fd, drmGetConnector, unsafe.Pointer(&q)); err != nil { + return drmModeInfo{}, err + } + if q.countModes == 0 { + return drmModeInfo{}, fmt.Errorf("connector %d reported no modes", connID) + } + for _, m := range modes[:q.countModes] { + if m.typ&drmModeTypePreferred != 0 { + return m, nil + } + } + return modes[0], nil +} + +func crtcFor(fd int, c drmModeGetConnector, crtcs []uint32) (uint32, error) { + encoders := []uint32{c.encoderID} + if c.countEncoders > 0 { + list := make([]uint32, c.countEncoders) + q := drmModeGetConnector{ + connectorID: c.connectorID, + countEncoders: c.countEncoders, + encodersPtr: uint64(uintptr(unsafe.Pointer(&list[0]))), + } + if err := drmIoctl(fd, drmGetConnector, unsafe.Pointer(&q)); err == nil { + encoders = append(encoders, list[:q.countEncoders]...) + } + } + for _, id := range encoders { + if id == 0 { + continue + } + e := drmModeGetEncoder{encoderID: id} + if err := drmIoctl(fd, drmGetEncoder, unsafe.Pointer(&e)); err != nil { + continue + } + // Already driving this connector, otherwise anything it can be wired to. + if e.crtcID != 0 { + return e.crtcID, nil + } + for i, crtc := range crtcs { + if e.possibleCRTCs&(1< n { + return + } + if typ == drmEventFlipComplete { + select { + case fb.flips <- struct{}{}: + default: + } + } + off += length + } + } +} + func (fb *framebuffer) close() { - fb.fill(rgb{}) - fb.flush() - unix.Syscall(unix.SYS_IOCTL, fb.tty.Fd(), kdSetMode, kdText) - fb.tty.Close() - unix.Munmap(fb.mem) - fb.file.Close() + for i := range fb.bufs { + if fb.bufs[i].mem != nil { + unix.Munmap(fb.bufs[i].mem) + } + } + // Dropping master hands the display back to the kernel console, which + // restores its own mode. The framebuffers and dumb buffers are reclaimed + // when the last reference to the fd goes. + drmIoctl(fb.fd, drmDropMaster, nil) + unix.Close(fb.fd) } func (fb *framebuffer) pixel(c rgb) uint32 { - return uint32(c.r)<> fb.rShift) - og := uint8(old >> fb.gShift) - ob := uint8(old >> fb.bShift) + orr := uint8(old >> xrgbRedShift) + og := uint8(old >> xrgbGreenShift) + ob := uint8(old >> xrgbBlueShift) mix := rgb{ r: uint8((uint32(c.r)*a + uint32(orr)*(255-a)) / 255), g: uint8((uint32(c.g)*a + uint32(og)*(255-a)) / 255), @@ -212,6 +481,21 @@ func (fb *framebuffer) blend(x, y int, c rgb, cov uint8) { fb.back[o+3] = byte(v >> 24) } -func (fb *framebuffer) flush() { - copy(fb.mem, fb.back) +// Copies into the scanout buffer furthest from being displayed and asks for it +// at the next blank. The copy cannot tear because nothing is displaying that +// buffer, and the swap cannot tear because the hardware does it between frames. +func (fb *framebuffer) flush() error { + next := (fb.front + 1) % scanoutBuffers + copy(fb.bufs[next].mem, fb.back) + + flip := drmModeCrtcPageFlip{ + crtcID: fb.crtcID, + fbID: fb.bufs[next].fbID, + flags: drmModePageFlipEvent, + } + if err := drmIoctl(fb.fd, drmPageFlip, unsafe.Pointer(&flip)); err != nil { + return fmt.Errorf("page flip: %w", err) + } + fb.front = next + return nil } diff --git a/main.go b/main.go index 4424caa..9f923f0 100644 --- a/main.go +++ b/main.go @@ -602,14 +602,12 @@ func main() { const ( reportInterval = time.Second - // Redraw fast so the panel feels live, but measure rates over a much longer - // window than a frame, since a frame's worth of a bursty sender is noise. - displayInterval = 16 * time.Millisecond - // What a bucket covers. Deliberately not displayInterval: how often the - // counters are read is a property of the measurement, and tying it to the - // refresh would let a slow or blocked draw stretch the window it reports. - // Short enough that a step lands promptly, long enough that a bucket holds - // tens of thousands of frames at line rate and is not itself noise. + // What a bucket covers. Nothing to do with how often the panel is redrawn: + // how often the counters are read is a property of the measurement, and + // letting the refresh set it would let a slow or blocked draw stretch the + // window it reports. Short enough that a step lands promptly, long enough + // that a bucket holds tens of thousands of frames at line rate and is not + // itself noise. sampleInterval = 16 * time.Millisecond // Both how far back the shown errors reach and how many buckets the median // runs over, so a step in the rate lands half this late. @@ -778,8 +776,6 @@ func run(aName, bName, sizesArg string, close(startTx) tick := time.NewTicker(reportInterval) defer tick.Stop() - frame := time.NewTicker(displayInterval) - defer frame.Stop() views := make([]view, len(dirs)) rows := make([]view, len(dirs)) @@ -793,7 +789,8 @@ func run(aName, bName, sizesArg string, return nil case <-space: start = resetAll(dirs, stats) - case now := <-frame.C: + case <-disp.fb.flips: + now := time.Now() px, py, down := touch.get() x, y := disp.fb.fromPanel(px, py) if disp.holdReset(x, y, down, now) { @@ -806,8 +803,10 @@ func run(aName, bName, sizesArg string, if m, ok := cfg.cableMetres(views); ok { cable = fmt.Sprintf("%.1f m", m) } - disp.render(totalView(views), now.Sub(start), - target*float64(len(dirs)), cable) + if err := disp.render(totalView(views), now.Sub(start), + target*float64(len(dirs)), cable); err != nil { + return err + } case now := <-tick.C: elapsed := now.Sub(start) // Length needs both directions, so every row is sampled before any of diff --git a/ui.go b/ui.go index cebd588..c4f17a2 100644 --- a/ui.go +++ b/ui.go @@ -58,7 +58,7 @@ type display struct { } func newDisplay() (*display, error) { - fb, err := openFramebuffer(fbPath) + fb, err := openFramebuffer() if err != nil { return nil, err } @@ -250,7 +250,7 @@ func rateColor(gb, target float64) rgb { } } -func (d *display) render(v view, elapsed time.Duration, target float64, cable string) { +func (d *display) render(v view, elapsed time.Duration, target float64, cable string) error { fb := d.fb fb.fill(uiBg) @@ -267,5 +267,5 @@ func (d *display) render(v view, elapsed time.Duration, target float64, cable st d.errBlock(x, w, y+blockGap, v.since) d.drawResetButton() - fb.flush() + return fb.flush() }