Skip to content

Fix invalid hardcoded page-end address in display() - #305

Open
94xhn wants to merge 2 commits into
adafruit:masterfrom
94xhn:fix/vertical-mode-page-end-address
Open

Fix invalid hardcoded page-end address in display()#305
94xhn wants to merge 2 commits into
adafruit:masterfrom
94xhn:fix/vertical-mode-page-end-address

Conversation

@94xhn

@94xhn 94xhn commented Jul 11, 2026

Copy link
Copy Markdown

Fixes #282.

The bug

Adafruit_SSD1306::display() hardcodes the "Page end address" byte of the
SSD1306's Set Page Address command (0x22) to 0xFF:

static const uint8_t PROGMEM dlist1[] = {
    SSD1306_PAGEADDR,
    0,                   // Page start address
    0xFF,                // Page end (not really, but works here)
    SSD1306_COLUMNADDR}; // Column start address

Per the SSD1306 datasheet, that field (B[2:0]) is only 3 bits wide,
valid range 0-7 — the controller silently truncates whatever is written
there to its low 3 bits. 0xFF & 0x07 == 7, which happens to be the
correct last-page value for a 64-row display (8 pages), but is wrong
for any shorter display: 32-row (4 pages), 48-row (6 pages), or 16-row (2
pages) modules, where the real last page is HEIGHT/8 - 1.

  • In Horizontal Addressing Mode (the library's default) this bug is
    invisible: display() always writes exactly WIDTH * pages bytes, and
    the controller's column address wraps before the page-end wraparound
    logic is ever exercised, so the wrong register value never actually gets
    consulted during a normal full-buffer refresh.
  • In Vertical Addressing Mode, the page-end value is consulted after
    every single byte (page increments first; only after wrapping through
    the full page range does the column advance). An incorrect page-end value
    there corrupts the entire transfer, not just the tail end — exactly what
    was reported in A source code bug creates issues when operating in Vertical memory access mode. #282.

The fix

Compute the real last page from HEIGHT instead of hardcoding 0xFF:

ssd1306_command1(((HEIGHT + 7) / 8) - 1); // Page end address

This mirrors the existing pattern already used elsewhere in begin()
(ssd1306_command1(HEIGHT - 1) for SSD1306_SETMULTIPLEX). For a 64-row
display this evaluates to 7, byte-for-byte identical to the previous
0xFF (truncated), so there is no behavior change for the most common
128x64 module.

Independent verification (no OLED hardware required)

I wrote a standalone, host-only C++ program that implements the SSD1306's
documented GDDRAM address-pointer state machine for Vertical Addressing
Mode (page increments first; on reaching page-end it wraps to page-start
and the column increments — straight from the datasheet's description of
Memory Addressing Mode 01b), and feeds it the exact byte count that
display() really transmits (WIDTH * pages bytes), once with the old
buggy page-end value and once with the fixed one, across the module sizes
this library ships examples for (128x64, 128x32, 96x16, 64x48). It requires
no Arduino headers, no I2C/SPI, and no physical display — only the
address-generation rule the SSD1306 datasheet itself specifies, which is a
hardware fact independent of this driver's implementation.

Output:

== 128x64  (HEIGHT=64 -> 8 visible pages, 1024 bytes/display()) ==
  old(buggy) bytesSent=1024 columnsTouched=128/128 (100.0%)  cellsWrittenExactlyOnce=1024  bytesLandingOnInvisiblePage=0
  fixed    bytesSent=1024 columnsTouched=128/128 (100.0%)  cellsWrittenExactlyOnce=1024  bytesLandingOnInvisiblePage=0
  -> 128x64 old==fixed (no regression on the common case): yes

== 128x32  (HEIGHT=32 -> 4 visible pages, 512 bytes/display()) ==
  old(buggy) bytesSent=512  columnsTouched= 64/128 ( 50.0%)  cellsWrittenExactlyOnce=512   bytesLandingOnInvisiblePage=256
  fixed    bytesSent=512  columnsTouched=128/128 (100.0%)  cellsWrittenExactlyOnce=512   bytesLandingOnInvisiblePage=0
  -> bug reproduced (old value leaves columns untouched): yes

== 96x16  (HEIGHT=16 -> 2 visible pages, 192 bytes/display()) ==
  old(buggy) bytesSent=192  columnsTouched= 24/96  ( 25.0%)  cellsWrittenExactlyOnce=192   bytesLandingOnInvisiblePage=144
  fixed    bytesSent=192  columnsTouched= 96/96  (100.0%)  cellsWrittenExactlyOnce=192   bytesLandingOnInvisiblePage=0
  -> bug reproduced (old value leaves columns untouched): yes

== 64x48  (HEIGHT=48 -> 6 visible pages, 384 bytes/display()) ==
  old(buggy) bytesSent=384  columnsTouched= 48/64  ( 75.0%)  cellsWrittenExactlyOnce=384   bytesLandingOnInvisiblePage=96
  fixed    bytesSent=384  columnsTouched= 64/64  (100.0%)  cellsWrittenExactlyOnce=384   bytesLandingOnInvisiblePage=0
  -> bug reproduced (old value leaves columns untouched): yes

ALL CHECKS PASSED: buggy 0xFF page-end value silently drops writes to whole
columns on any non-64-row SSD1306 display in Vertical Addressing Mode; the
height-derived fix restores full, exact, one-to-one GDDRAM coverage on every
tested size while leaving the common 128x64 case byte-for-byte identical.

With the old 0xFF value, a full-buffer display() refresh in Vertical
Addressing Mode on a 128x32 module leaves half the columns completely
untouched
(never written at all, so they retain whatever stale content
was already in GDDRAM); on a 96x16 module it's 75% of columns. With the
fix, every configuration gets full, exact, one-write-per-cell coverage
matching the number of bytes actually transferred, and the common 128x64
case is completely unaffected.

Repro source (self-contained, single file, standard library only):

#include <cstdio>
#include <vector>

// Mirrors the SSD1306 GDDRAM address pointer behavior in Vertical
// Addressing Mode, as documented in the datasheet.
struct SSD1306AddressGen {
  int pageStart, pageEnd; // 3-bit hardware fields, valid range 0-7
  int colStart, colEnd;   // column fields, valid range 0-127
  int page, col;

  SSD1306AddressGen(int pageStart_, int pageEndRaw, int colStart_,
                     int colEnd_)
      : pageStart(pageStart_ & 0x07),
        // Real hardware register is only 3 bits wide -- this masking is
        // not a simulation shortcut, it is what the SSD1306 silicon does.
        pageEnd(pageEndRaw & 0x07), colStart(colStart_), colEnd(colEnd_),
        page(pageStart), col(colStart) {}

  // Returns the (col, page) address the next data byte will be written to,
  // then advances the pointer per the datasheet rule quoted above.
  void next(int &outCol, int &outPage) {
    outCol = col;
    outPage = page;
    if (page < pageEnd) {
      page++;
    } else {
      page = pageStart;
      col = (col < colEnd) ? col + 1 : colStart;
    }
  }
};

struct Result {
  int width = 0;
  int pagesVisible = 0;
  int bytesSent = 0;
  int columnsTouched = 0;      // distinct columns that received >=1 byte
  int cellsWrittenOnce = 0;    // (col,page) cells written exactly once
  int bytesToInvisiblePages = 0; // bytes that land on a page index that
                                  // doesn't exist on the physical display
};

// Simulates exactly what Adafruit_SSD1306::display() does on the wire:
// send `width * pagesVisible` sequential data bytes while the controller
// is in Vertical Addressing Mode with page range [0, pageEndRaw & 7] and
// column range [0, width-1].
Result simulate(int width, int pagesVisible, int pageEndRaw) {
  SSD1306AddressGen gen(0, pageEndRaw, 0, width - 1);
  std::vector<std::vector<int>> hits(width, std::vector<int>(8, 0));
  int count = width * pagesVisible;

  for (int i = 0; i < count; i++) {
    int c, p;
    gen.next(c, p);
    hits[c][p]++;
  }

  Result r;
  r.width = width;
  r.pagesVisible = pagesVisible;
  r.bytesSent = count;
  for (int c = 0; c < width; c++) {
    bool touched = false;
    for (int p = 0; p < 8; p++) {
      if (hits[c][p] > 0) {
        touched = true;
        if (hits[c][p] == 1)
          r.cellsWrittenOnce++;
        if (p >= pagesVisible)
          r.bytesToInvisiblePages += hits[c][p];
      }
    }
    if (touched)
      r.columnsTouched++;
  }
  return r;
}

// main() runs `simulate()` for 128x64 / 128x32 / 96x16 / 64x48 with both
// pageEndRaw=0xFF (old) and pageEndRaw=pagesVisible-1 (fixed), and asserts
// (a) the fixed value always gives full bijective coverage, (b) it is
// identical to the old value for the 64-row case, and (c) the old value
// demonstrably drops columns for every shorter display. Full listing with
// the driver assertions is available on request; trimmed here for length.

Compatibility

No functional change for the 128x64 module (by far the most common), which
is why this has likely gone unnoticed in the wild for a while — the bug
only manifests on shorter modules (128x32, 96x16, 64x48, etc.) when the
application explicitly switches to Vertical Addressing Mode via
ssd1306_command(SSD1306_MEMORYMODE); ssd1306_command(0x01);, which isn't
the library's default.

Disclosure

I used Claude (Anthropic) to help investigate this issue, work out the
correct fix, and write the independent host-side verification program
described above. I reviewed the diagnosis, the fix, and the repro program
myself before opening this PR, and I stand behind the technical claims
made here.

94xhn added 2 commits July 11, 2026 18:23
display() sent 0xFF as the SSD1306 "Set Page Address" (0x22) end-page
byte. That field is only 3 bits wide per the datasheet (valid range
0-7), so 0xFF is silently truncated to 7 in hardware -- correct for a
64-row (8-page) display, but wrong for any shorter display (32, 48, or
16 rows), where the real last GDDRAM page is HEIGHT/8 - 1.

In Horizontal Addressing Mode this went unnoticed because display()
always writes exactly WIDTH * pages bytes, so the page-end wraparound
byte is never actually reached. In Vertical Addressing Mode the
page-end value is consulted after every byte, so the wrong value
causes large parts of the framebuffer to be written to the wrong
columns, or never written at all, as reported in adafruit#282.

Compute the real last page from HEIGHT instead of hardcoding 0xFF.

Signed-off-by: 94xhn <87560781+94xhn@users.noreply.github.com>
Match the repo's clang-format style for the shortened dlist1
initializer introduced in the previous commit (fixes the failing
"build" CI check's clang-format step).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A source code bug creates issues when operating in Vertical memory access mode.

1 participant