From a serial protocol to an OLED on the SG2000 / Pine64 Oz64
2026-10-07
We follow one bus and one device the whole way: the ELEGOO SSD1306 OLED on the SG2000 / Pine64 Oz64.
Motivation & Protocol
The Platform
The Device & The Code
A CPU needs to talk to sensors, memory, converters, displays. The naive answer is a dedicated set of wires per device:
Pins and PCB traces are expensive. A bus shares one set of wires among many devices and relies on addressing to pick the listener.
A bus trades wires for protocol: fewer pins, but now every transfer needs rules about who is talking and when.
| Parallel | Serial | |
|---|---|---|
| Wires | one per bit (+ control) | one or two for data |
| Throughput | high (wide) | lower per edge, high per wire |
| Cost / pins | high | low |
| Skew & crosstalk | worse (many lines must stay in step) | better |
| Distance | short | longer |
At modern clock rates, keeping 8–32 parallel lines bit-aligned is hard, so almost everything off-chip today is serial — PCIe, USB, SATA, Ethernet, I²C, SPI. “Serial” does not mean “slow”.
A serial link must solve one problem: where are the bit boundaries?
| UART | I²C | SPI | |
|---|---|---|---|
| Clock wire | no | yes (SCL) | yes |
| Data wires | 1 TX + 1 RX | 1 SDA | 2 (MOSI/MISO) + CS per device |
| Devices per link | 2 | many | many |
| Addressing | none | 7-bit on the wire | chip-select wire |
I²C’s trick: a clock and an address, on two wires, shared by everyone.
Keep this list. Every section of the protocol is one of these answers.
Inter-Integrated Circuit — Philips, 1982 (now NXP). Designed to connect a processor to slow, cheap peripherals on the same board over two wires.
| Signal | Name | Role |
|---|---|---|
| SCL | Serial CLock | master drives the clock |
| SDA | Serial DAta | both directions, half-duplex |
Both SDA and SCL are open-drain: a device can only pull a line low or release it. External pull-up resistors bring a released line high.
Release = high-Z = the line is pulled high by everyone’s resistor. Any device pulling low wins over any number of releases — a wired-AND.
This is why I²C has no contention: two devices can never fight by driving opposite levels on the same wire. It is also why the idle state is high.
Every slave has a 7-bit address fixed by the chip or set by strapping pins.
| Range | Use |
|---|---|
0x00 |
general call |
0x01–0x07 |
reserved (CBUS, high-speed, …) |
0x08–0x77 |
usable device addresses |
0x78–0x7B |
reserved (10-bit addressing) |
0x7C–0x7F |
reserved |
Our panel is at 0x3C. Its sibling address 0x3D exists because one module pin (SA0) shifts the address, so you can put two on one bus.
0x3C vs 0x78 GotchaManuals for OLED modules often print 0x78. That is not the bus address. It is the 8-bit “write address”: 7 address bits shifted left, with R/W = 0.
| Where you see it | Value |
|---|---|
| Datasheet / most libraries | 7-bit 0x3C |
| Logic-analyser trace / “scanner” | 8-bit 0x78 (write), 0x79 (read) |
On the wire,
0x78is “address 0x3C, write”. In code you pass the 7 bits:0x3C.
| I²C | SPI | UART | |
|---|---|---|---|
| Wires (n devices) | 2 | 3 + n chip-selects | 2 per link |
| Addressing | in-band (7-bit) | chip-select wire | none (point-to-point) |
| Duplex | half | full | full |
| Speed | 100 k–3.4 M | 10–100 M | fixed pair rate |
| Multi-master | yes | no | no |
| Best for | many slow chips, few pins | fast, few chips | two-chip links, consoles |
Pick I²C when pins are scarce and devices are slow. Pick SPI when you need speed. Pick UART when you need two devices, simply.
A message is bracketed by two conditions that cannot occur during normal data transfer — SDA may only change while SCL is low:
After START, data goes one byte at a time, MSB first, with an ACK slot:
ACK = receiver pulls SDA low on the 9th clock. NACK = it releases SDA (high). A master NACKs the last read byte to say “stop sending”.
Every transfer begins with an address/R‑W byte:
0x3C << 1 | 0 = 0x78 on the wire.The first byte of every I²C transaction is addressing. There is no separate “select” wire — the address is the select, and it is the same 9-bit frame shape as data.
Many devices (and the SSD1306’s cousins) use a register pointer: write the register index, then read. That needs a direction change without dropping the bus — a repeated START instead of STOP+START.
The clock is nominally the master’s, but a slave may hold SCL low to buy time. The master must wait and not start the next high phase.
Clock stretching is the electrical layer showing through the protocol: because the clock line is open-drain, “the master owns the clock” is only mostly true.
I²C allows several masters. They may start together; wired-AND resolves it without damage:
| Layer | Rule | Why it exists |
|---|---|---|
| Electrical | open-drain + pull-ups, idle high | share wires safely |
| Framing | START / STOP (SDA edges while SCL high) | mark message boundaries |
| Addressing | 7-bit address + R/W, first byte | pick a listener, in-band |
| Data | MSB first, 9 clocks/byte, ACK | reliable byte transfer |
| Direction | repeated START | change master/slave without losing the bus |
| Time | clock stretching | let slow slaves keep up |
| Sharing | arbitration | many masters, no corruption |
Every property is a direct consequence of the electrical layer.
The SG2000 has five I²C controllers, all Synopsys DesignWare (snps,designware-i2c):
| Linux | Base | Pins (typical) | Notes |
|---|---|---|---|
i2c-0 |
0x04000000 |
— | disabled in the tree |
i2c-1 |
0x04010000 |
header, varies by board | |
i2c-2 |
0x04020000 |
header / camera | |
i2c-3 |
0x04030000 |
Oz64 header pin 3/5 | our bus |
i2c-4 |
0x04040000 |
DSI touch, header on Duo S | has gt9xx@14 |
Key registers (same layout on every instance):
| Offset | Name | Purpose |
|---|---|---|
0x00 |
IC_CON |
master/slave, speed, restart, slave-disable |
0x04 |
IC_TAR |
target (slave) address |
0x10 |
IC_DATA_CMD |
push a byte; bit 9 = STOP, bit 8 = read |
0x6C |
IC_ENABLE |
enable/disable the block |
0x70 |
IC_STATUS |
TX FIFO not full, master-active, … |
0x80 |
IC_TX_ABRT_SOURCE |
why the last transaction aborted |
The kernel’s i2c_designware driver claims each controller and exposes it as a character device, plus a sysfs device per attached chip.
ioctl(fd, I2C_SLAVE, 0x3C), then write()/read().-r uses a read probe; some devices only ACK a quick write (-y).i2c-tools lives in /usr/sbin, not /usr/bin.
i2cdetectis the first thing to run, and the fastest way to tell pinmux from wiring from address problems. No3c= nothing is answering.
Two separate blocks decide whether I²C works, and mixing them up is the #1 source of confusion:
0x03001000): which function drives each pad (GPIO, UART, SPI, IIC…). Tools: cvi-pinmux, duo-pinmux (write via /dev/mem).0x04030000 for IIC3): the protocol engine (shift, ACK, START/STOP). Managed by the kernel driver.A controller can be enabled and a bus can be working — and still not reach a pin, because the pad is muxed to something else. That is not a bug; it is the design.
The 26‑pin “GPIO” header (J47, “14 Pi‑2 connector”). The connector symbol’s printed names are generic Raspberry‑Pi names — ignore them; the real nets are the XGPIOB* ones.
| Header pin | SoC pad | IIC3 function | IIC4 function |
|---|---|---|---|
| 1 | — | 3V3 | 3V3 |
| 3 | XGPIOB[20] (VIVO_D1) |
IIC3_SDA | IIC4_SCL |
| 5 | XGPIOB[21] (VIVO_D0) |
IIC3_SCL | IIC4_SDA |
| 7 | XGPIOB[18] |
— | — |
| 9 | — | GND | GND |
So our OLED is on IIC3: pin 3 = SDA, pin 5 = SCL → /dev/i2c-3, addr 0x3C.
At boot, the FSBL muxed IIC3 to pads A5/A6 (not on the header). The controller was “enabled”. The OLED did nothing.
The fix is to free A5/A6 and route IIC3 to the header pads:
Two pad groups driving one controller is worse than none. Both muxed = the bus does not work. Freeing the default pads fixed it, not “enabling IIC3”.
The same IIC3 pads can be driven by either core, but not both at once:
| Big core (C906/C920) | Little core (C906L) | |
|---|---|---|
| Runs | Linux | bare-metal / FreeRTOS |
| Access | /dev/i2c-3, kernel driver |
direct MMIO to 0x04030000 |
| Pinmux | cvi-pinmux / duo-pinmux |
writes the pad regs itself |
| Clock | kernel runtime-PM manages it | you must ungate it |
On the Oz64 image we have, the little core is not exposed via remoteproc (the DTB has no cv181x-c906_1 node, only rtos_cmdqu) — so the Arduino/FreeRTOS rungs build but cannot be loaded here. We mark them accordingly.
Trying to mmap 0x04030000 from Linux and drive the registers directly:
IC_STATUS reads 0 — because the kernel driver runtime‑gates the block’s clock when idle. The register file is dark until something wakes the controller.
Consequences:
“The hardware is memory-mapped” is necessary, not sufficient — the clock is part of the interface.
| Property | Value |
|---|---|
| Controller | SSD1306 (Solomon) |
| Resolution | 128 × 64, 1 bpp mono |
| Interface | I²C (4 pins: GND, VCC, SCL, SDA) |
| Address | 0x3C 7-bit (0x78 on the wire) |
| Logic level | 3.3 V (VCC 3.3–5 V on the module) |
| RAM (GDDRAM) | 128 × 64 bits = 1024 bytes |
It is a write-mostly device: you send a command stream and a framebuffer. There is no register read-back you need.
0x00 and 0x40After the address+W byte, the first byte of every transaction is a control byte:
| Control byte | Meaning |
|---|---|
0x00 |
following bytes are commands |
0x40 |
following bytes are pixel data |
So the “transport” for our OLED is trivially thin:
static const uint8_t OLED_INIT[] = {
0xAE, /* display off */
0xD5, 0x80, /* clock divide / osc */
0xA8, 0x3F, /* multiplex ratio = 64 */
0xD3, 0x00, /* display offset 0 */
0x40, /* start line 0 */
0x8D, 0x14, /* charge pump on */
0x20, 0x00, /* memory mode = horizontal */
0xA1, /* segment remap */
0xC8, /* COM scan remapped */
0xDA, 0x12, /* COM pins config */
0x81, 0xCF, /* contrast */
0xD9, 0xF1, /* pre-charge */
0xDB, 0x40, /* VCOMH deselect */
0xA4, 0xA6, 0x2E, 0xAF,
};Sent once as a single 0x00-prefixed write. Same bytes on every platform.
The RAM is organised column-major within 8 pages, each page 8 pixels tall:
In horizontal addressing mode (0x20 0x00), after each byte the column pointer auto-increments and wraps to the next page — so you can stream all 1024 bytes in one go.
To show a frame:
0x21 0 127 (columns), 0x22 0 7 (pages)0x40 dataThat is exactly oled_flush().
Same bus, same device, same protocol — different who and how safely:
| Rung | Runs on | Mechanism | Safety | Status |
|---|---|---|---|---|
i2c-dev |
Linux userspace | kernel driver, /dev/i2c-3 |
arbitrated | verified |
| raw mmap | Linux userspace | bit-bang B20/B21 via /dev/mem |
none | verified |
| Arduino (bit-bang) | little C906L | direct MMIO, no OS | none | built |
Arduino (Wire) |
little C906L | vendor csi_iic |
none | reasoned |
| FreeRTOS (DesignWare) | little C906L | MMIO to 0x04030000 + task |
none | reasoned |
| FreeRTOS (bit-bang) | little C906L | MMIO bit-bang + task | none | reasoned |
The abstraction changes who writes the registers and how safely, not what the hardware does. i2c-dev and a bit-bang end with the same 0x78 on the wire.
examples/i2c-c/ssd1306.h knows the SSD1306 and nothing about the transport:
typedef void (*oled_write_fn)(void *ctx, const uint8_t *buf, size_t len,
int is_data);
static inline void oled_init (oled_t *o, oled_write_fn w, void *ctx); /* 0x00 cmds */
static inline void oled_flush(oled_t *o); /* window + 1024 bytes @ 0x40 */
/* drawing: oled_clear, oled_pixel, oled_text, oled_draw_lines */Every rung below supplies a different oled_write_fn. The upper half of the code is byte-for-byte identical on Linux and on the little core.
Good driver layering: the device logic is transport-agnostic; the transport is device-agnostic. Only the thin middle changes.
i2c-devexamples/i2c-c/linux-i2c-dev.c (verified):
int fd = open("/dev/i2c-3", O_RDWR);
ioctl(fd, I2C_SLAVE, OLED_ADDR); /* 0x3C */
static void i2c_write_cb(void *ctx, const uint8_t *buf, size_t len,
int is_data) {
uint8_t chunk[1 + 32];
for (size_t off = 0; off < len; off += 32) {
size_t n = len - off; if (n > 32) n = 32;
chunk[0] = is_data ? 0x40 : 0x00; /* the SSD1306 control byte */
memcpy(chunk + 1, buf + off, n);
write(fd, chunk, n + 1); /* one I²C transaction */
}
}examples/i2c-c/linux-mmap-bitbang.c (verified) — no kernel I²C at all:
#define MUX_BASE 0x03001000UL /* pad function select */
#define GPIOB_BASE 0x03021000UL
#define SDA 20 /* XGPIOB[20], pin 3 */
#define SCL 21 /* XGPIOB[21], pin 5 */
mux[0x158/4] = (mux[0x158/4] & ~0x7u) | 3u; /* B20 -> GPIO */
mux[0x15C/4] = (mux[0x15C/4] & ~0x7u) | 3u; /* B21 -> GPIO */
/* open-drain: drive low = output low, release = input (pull-up wins) */Then start/stop/wbit/rbit/wbyte on the two data/direction/input registers. Why bit-bang and not the IIC controller? Because the controller’s clock is kernel-gated (previous slide); the GPIO block’s is not.
Same 1024-byte oled_flush(); only the transport swapped. This is gpio-software.md applied to a two-wire protocol.
examples/i2c-arduino/oled-bitbang/ (built, not run here). It reuses the same ssd1306.h and does the same bit-bang, but in M-mode with no /dev/mem:
Wire (and Why It Doesn’t Fit)The idiomatic sketch is Wire4.begin(); Wire4.beginTransmission(0x3C); …, but on the duos variant it cannot reach our OLED:
Wire instance |
Bus | Pins | Usable here? |
|---|---|---|---|
Wire0 |
IIC0 | 0xff |
no pins |
Wire1 |
IIC1 | pins 11/13 | wrong pads |
Wire2 |
IIC2 | pins 21/19 | wrong pads |
Wire3 |
IIC3 | 0xff — unimplemented |
no |
Wire4 |
IIC4 | pins 5/3 | SDA/SCL swapped vs our wiring |
Wire4 drives XGPIOB[20] as IIC4_SCL and XGPIOB[21] as IIC4_SDA — the reverse of the header’s pin‑3=SDA/pin‑5=SCL wiring. It would only work if the OLED were rewired.
Lesson: the same peripheral name means different pads on different boards.
Wire4is “I²C” and correct for a Duo S — and wrong for our Oz64 wiring.
examples/i2c-freertos/oled_task.c (reasoned). A task owns the display and blocks between refreshes:
Transport is i2c_dw_write() — the DesignWare registers, direct MMIO — with the two things userspace could not do: clock enable and pad mux:
examples/i2c-freertos/i2c_bitbang.c (reasoned). The same bit-bang as the Arduino rung, wrapped so the task model is visible:
Two transports, one
oled_flush(): register engine when the clock is yours; bit-bang when it is not.
| Artifact | Builds | Runs on Oz64 | Evidence |
|---|---|---|---|
linux-i2c-dev.c |
✅ riscv64 | ✅ | 3c found; panel shows text |
linux-mmap-bitbang.c |
✅ riscv64 | ✅ | panel flushed; pads restored |
| raw controller mmap | ✅ | ❌ | IC_STATUS=0 (clock gated) |
oled-bitbang.ino |
✅ duos |
⚠️ blocked | entry 0x9fe00000; no remoteproc on image |
Wire4 sketch |
— | ❌ by design | IIC4 pads swapped vs wiring |
| FreeRTOS (both) | — | not attempted | reasoned; clock-enable documented |
In this course: say which is which. A verified artifact has a command and an observed result; a reasoned one is a hypothesis with the reasoning shown.
[START][addr+R/W][ACK][data][ACK]…[STOP]; the address is the select.Lab ladder (climb it on the Oz64):
i2cdetect -y -r 3 → find 3c; explain why -r and -y differ../linux-i2c-dev /dev/i2c-3 "your text"; then read ssd1306.h and change it../linux-mmap-bitbang "…"; compare with the i2c-dev run on an analyser.duo-pinmux -w B20/B20) and predict the failure mode.Discuss:
CS 4250 · I²C on the SG2000