I2C 总线:SSD1306 OLED 显示实战
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 14 篇。上一篇:DMA 传输:从外设搬运到内存,下一篇:SPI 总线:W25Q64 Flash 读写与 ID 识别,目录见文末。
I2C 总线:SSD1306 OLED 显示实战
导读
串口是"点对点",I2C 是"一条总线挂一串设备":两根线(SCL/SDA)、7 位地址、应答机制,是传感器与小屏的世界通用语。本篇把 I2C 的时序逐拍拆开——为什么 SDA 必须开漏、上拉电阻凭什么不可或缺、START 与 STOP 怎么在"数据有效期间不许动时钟"的约束下被造出来——然后用 I2C1(PB6/PB7)点亮一块 SSD1306 OLED(128×64,地址 0x3C)。SSD1306 是微控制器世界的"Hello World 显示器":初始化序列一板一眼、显存布局一页一页,把它跑通,你就掌握了"裸机驱动一块 I2C 外设"的完整方法论。microzig 在这一篇反而是配角——HAL 的 I2C 齐整好用,倒是 drivers 层有一个著名 bug(write_then_read 调用了不存在的方法),我们用 writev_then_readv 绕行。
为什么需要 I2C 与它的一条铁律
先说总线本身。I2C 一条总线上可以挂几十个设备,靠 7 位地址区分;速率标准模式 100 kHz、快速模式 400 kHz(F103 的 I2C 外设最高 400 kHz)。一次完整的传输长这样:
START ADDR(7bit)+R/W ACK DATA... STOP
SDA: ──\______/ /══════════════\__/══\______/═══...\______/══\____
SCL: ──────\____/ ──┐ ┌──┐ ┌──┐ ────┐ ┌─── ──┐ ┌──┐ ┌──...──┐ ┌──
- START:SCL 高电*期间 SDA 由高变低;STOP:SCL 高电*期间 SDA 由低变高。这两个条件之所以被定义为"违反常规"(数据正常只在 SCL 低电*时变化),正是为了让它们在数据流中独一无二、绝无歧义。
- 地址帧 + R/W 位:9 个时钟——7 位地址、1 位方向(0 写 / 1 读)、1 位 ACK。
- ACK:第 9 个时钟,接收方把 SDA 拉低表示"我在听"。主机发完地址要回头采样这一位——这正是引出铁律的伏笔。
铁律:SDA 必须开漏(open-drain)+ 上拉电阻。三个角色都需要"放手"的能力:
- 多设备仲裁:多个主机同时发起传输时,输出 0 的设备赢了总线(线与逻辑)。推挽输出的两个 0/1 对撞是短路;开漏对撞只是"谁拉低谁赢"。
- 从机 ACK:主机在发地址时是输出者,到第 9 拍要变成输入者听 ACK。推挽模式下"从输出切输入再切回来"存在两台设备同时驱动的窗口;开漏模式下主机只需松手(释放总线),上拉电阻把线拉回高电*,从机想应答就拉低——切换无缝。
- 时钟 stretching:慢速从机可以把 SCL 也拉住不放(SCL 同样是开漏),强迫主机等待。I2C 是极少数从机能"反过来掐住主机"的总线,这份能力同样建立在开漏之上。
上拉电阻(典型 4.7 kΩ)负责在所有人都松手时把总线恢复到高电*。没有上拉,总线悬空、电*不定,主机读到的是噪声。板上必须外接上拉——F103 的内部上拉(约 30–50 kΩ)太弱,400 kHz 下边沿上升时间过长,通信会间歇性失败。接线表里 I2C1 的 4.7 kΩ × 2 就是为此。
microzig 的 F103 I2C HAL(STM32F103/i2c.zig,446 行)覆盖了主模式收发、错误集(ArbitrationLoss/BusError/BusTimeout/UnrecoverableError)与错误清除。它的 Error 定义里逐字引用了 ST 的勘误文档编号(errata CD00197763)——F103 的 I2C v1 外设有已知硬件缺陷,START/STOP 时序在特定条件下会卡死,需要复位外设恢复,本篇"常见问题"会给出处理套路。
代码案例:SSD1306 点屏 + 5x7 字模显示
const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;
const drivers = microzig.drivers;
const lcd_driver = drivers.display.SSD1306_I2C;
const I2C = stm32.i2c;
const I2C_DatagramDevice = stm32.drivers.I2C_DatagramDevice;
pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{ .logFn = stm32.uart.log });
comptime {
_ = microzig.export_startup();
}
const uart = stm32.uart.UART.init(.USART1);
const i2c = I2C.I2C.init(.I2C1);
const SCL = gpio.Pin.from_port(.B, 6);
const SDA = gpio.Pin.from_port(.B, 7);
// pclk 必须是编译期常量:本系列 72 MHz 配置下 APB1 = 36 MHz
const i2c_config = I2C.Config{
.pclk = 36_000_000,
.speed = 400_000,
.mode = .fast, // 400 kHz 必须 fast;100 kHz 用 .standard
};
const i2c_device = I2C_DatagramDevice.init(i2c, I2C.Address.new(0x3C), null);
// ---------- 最简 5x7 字模(列式,每列 1 字节,bit0 在上) ----------
const FONT_W = 5;
const font_H = [_]u8{ 0x7E, 0x11, 0x11, 0x11, 0x7E }; // 'H'
const font_E = [_]u8{ 0x7F, 0x49, 0x49, 0x49, 0x41 }; // 'E'
const font_L = [_]u8{ 0x7F, 0x01, 0x01, 0x01, 0x01 }; // 'L'
const font_O = [_]u8{ 0x3E, 0x41, 0x41, 0x41, 0x3E }; // 'O'
fn draw_glyph(fb: *[1024]u8, x: usize, glyph: *const [5]u8) void {
for (glyph, 0..) |col, i| {
fb[x + i] = col; // 每列字节直接落在第 0 页:bit0 = 顶行
}
}
pub fn main() !void {
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.RTCClockSelection = .LSI,
.flags = .{ .HSEOscillator = true, .RTCUsed_ForRCC = true },
});
time.init_timer(.TIM4);
rcc.enable_clock(.GPIOB);
rcc.enable_clock(.I2C1);
rcc.enable_clock(.USART1);
// I2C 引脚:复用开漏,上拉交给板上的 4.7k 电阻
SCL.set_output_mode(.alternate_function_open_drain, .max_50MHz);
SDA.set_output_mode(.alternate_function_open_drain, .max_50MHz);
i2c.apply(i2c_config); // config 是文件级 const,可走 comptime 校验的 apply
const tx = gpio.Pin.from_port(.A, 9);
tx.set_output_mode(.alternate_function_push_pull, .max_50MHz);
try uart.apply_runtime(.{ .clock_speed = rcc.get_clock(.USART1) });
stm32.uart.init_logger(&uart);
// drivers 层的 SSD1306:init 内部发完整初始化序列并开显示
const lcd: lcd_driver = try lcd_driver.init(i2c_device.datagram_device());
// 显存:128 列 x 8 页,每页 128 字节,共 1024
var fb: [1024]u8 = @splat(0);
draw_glyph(&fb, 10, &font_H);
draw_glyph(&fb, 17, &font_E);
draw_glyph(&fb, 24, &font_L);
draw_glyph(&fb, 31, &font_L);
draw_glyph(&fb, 38, &font_O);
try lcd.write_full_display(&fb); // 参数类型就是 *const [128*8]u8
std.log.info("oled ready", .{});
while (true) {
time.sleep_ms(1000);
}
}
逐段解析
Config 的三个数字:pclk、speed、mode 的绑定关系
I2C.Config 逐字字段:pclk(usize)、speed(usize)、mode(.standard/.fast)、enable_duty(bool,fast 模式下的占空比选择)、address_mode(.@"7bits"/.@"10bits")。三个数字互相咬合:
- pclk 是 I2C 外设的输入时钟,即 APB1。本系列 72 MHz 配置下 APB1 = 36 MHz(
Div2)。HAL 用它计算两个分频:CR2 的 FREQ(pclk/MHz)与 CCR(pclk/speed 的比值)。 - speed 决定 CCR:36 MHz / 400 kHz = 90 → CCR 写 90 减去协议开销修正。100 kHz 则是 360。
- mode 必须与 speed 匹配:400 kHz 必须
.fast(CCR 进快速档,还可能用到enable_duty选 2:1 或 16:9 的占空比);100 kHz 用.standard。mode 与 speed 打架不会报错,但波形时序不达标,从机可能间歇性 NACK。
一个 comptime 陷阱:i2c.apply(config) 的 config 参数是 comptime 的(与 uart.apply 同款设计),所以 .pclk = 36_000_000 必须是字面量或 comptime 常量。运行期才确定的时钟值要用 i2c.runtime_apply(config)(注意函数名与 uart 的 apply_runtime 词序相反)。示例把 i2c_config 提成文件级 const,两种用法都兼容。
地址用 I2C.Address.new(0x3C):参数是 u7,即 7 位地址本身(0x3C),不需要左移——HAL 内部处理 R/W 位与移位。买模块时商家常写"0x78",那是 8 位写地址(0x3C << 1),除以 2 才是 new 要的值。
驱动分层:从 I2C 到 SSD1306 要穿三层
microzig 的抽象链路在本篇完整走一遍:
I2C(HAL):芯片相关的总线实现,write_blocking(addr, data, timeout)/read_blocking/writev_blocking/readv_blocking,timeout 类型是?Duration(microzig.drivers.time.Duration,如Duration.from_ms(100);null是无限等——给阻塞 I2C 操作设超时是好习惯)。I2C_DatagramDevice(hal.drivers 适配器):init(bus, address, timeout)把总线 + 地址打包成DatagramDevice(vtable 接口),让上层驱动不知道芯片型号。writev(&.{ &.{reg}, data }, null)还能把"寄存器地址 + 数据"拼成一次连续写。SSD1306_I2C(drivers.display):init(device)只有一个参数(I2C 模式在类型里就固定了),返回!SSD1306_I2C;write_full_display(data: *const [128 * 8]u8)一次刷全屏。
上游 hd44780.zig 示例展示了这条链的完全体:I2C → I2C_Device → PCF8574(IO 扩展芯片)→ Digital_IO → HD44780(1602 LCD),三层套娃靠 vtable 逐级解耦——第 21 篇会用它讲 Zig 的接口模式。
必须点名的坑(上游 bug P-02):hal.drivers.I2C_Device 的 write_then_read 内部调用了 dev.bus.write_then_read_blocking(...)——这个方法在 F103 的 I2C 里不存在。Zig 惰性编译让你只要不调用它就没事,一调用就是 no field or member function named 'write_then_read_blocking' in struct 'I2C'。要用"写寄存器地址再读数据"的两段式,用 writev_then_readv(它内部走 writev_blocking + readv_blocking,都存在)。SSD1306 是只写设备,本篇用不上读,但传感器驱动(MPU6050 等)天天要两段式——记住这条绕行路。
SSD1306 初始化序列逐条讲
SSD1306_I2C.init 内部执行一段标准序列(drivers/src/display/ssd1306.zig 的 execute_init_sequence),每条命令都值得认识:
| 命令字节 | 含义 | 驱动里的设置 |
|---|---|---|
| 0xAE | 关显示 | 序列开头先关,防半配置状态下花屏 |
| 0x2E | 停止滚动 | 关掉可能残留的滚动状态 |
| 0xA1 / 0xC8 | 段/行扫描方向反转 | 让坐标系的 (0,0) 落在左上角 |
| 0xA6 | 正常显示(非反色) | 0xA7 是反色 |
| 0x81 0xFF | 对比度 255 | 后面可调 |
| 0x8D 0x14 | 电荷泵开 | 最重要的一条:OLED 无背光,升压电路从 VCC 生成 12V+ 面板电压;不发电荷泵屏幕永远全黑且无任何报错 |
| 0xA8 0x3F | 复用率 63 | 64 行 = 63 + 1 |
| 0xD5 0xF0 | 时钟分频/振荡频率 | 面板刷新率来源 |
| 0xD9 0x11 | 预充电周期 | 与对比度共同影响亮度 |
| 0xDB 0x20 | VCOMH 去选电* | 0x4 档 |
| 0xDA 0x12 | COM 引脚硬件配置 | 128x64 交替 COM 布局 |
| 0xD3 0x00 / 0x40 | 显示偏移 / 起始行 | *移与裁剪用 |
| 0xA4 | 显示跟随 RAM 内容 | 0xA5 是全亮(测试屏用) |
| 0xAF | 开显示 | 一切就绪后最后一条 |
记住三条就够排障:0xAE 关、0x8D+0x14 电荷泵、0xAF 开。网上 80% 的"OLED 全黑"帖子漏的是电荷泵;漏 0xAE/0xAF 的次之。
I2C 模式下每条命令的数据包是 [0x00, cmd]——首字节是控制字节:0x00 表示后面是命令、0x40 表示后面是显存数据。驱动把这个细节封装掉了;若你手写裸 I2C 发命令,write_blocking(addr, &.{0x00, 0xAF}, null) 就是"开屏"。
显存布局:8 个页、1024 字节与位方向
128×64 单色屏的显存是 128 × 8 页(page)结构:把 64 行切成 8 个"页",每页高 8 像素、宽 128 列;每列 1 字节,bit0 是页内最上面一行、bit7 是最下面一行(纵向字节)。整屏 128 × 8 = 1024 字节,write_full_display 收 *const [1024]u8(源码签名逐字是 data: *const [128 * 8]u8,128 * 8 就是 1024)。
这个布局直接决定了画图的写法。本篇的 5x7 字模是"列式"编码——每个字符 5 列、每列 1 字节的 7 行位图——恰好与显存的纵向字节同构,所以 draw_glyph 只是把 5 个字节抄进 fb 的对应列(显示在第 0 页,即屏幕顶部 8 行)。若字模是常见的"行式"编码(每行 1 字节),就得逐位拆装转置——不少开源字模库带一个 rotate 函数就是为这个。
write_full_display 内部走的是水*寻址模式(0x20 选 horizontal + 0x21/0x22 设列页窗口),控制器自动跨页递增——你只管把 1024 字节按行序灌进去。部分刷新(只刷一行字)可以自己发 0x21/0x22 窗口再写小段数据,用 drivers 层的 writev 拼 [0x40, data...] 控制字节数据包。
运行现象
烧录后 OLED 左上角第一页出现 HELLO(每字符 5 列 + 列间距,5 个字符占 35 列宽)。串口打印 oled ready。
逐项验证:
- 全屏擦写:把 fb 用
@memset(&fb, 0xFF)填满再刷新——全屏点亮(OLED 上是真"全黑反转",因为是自发光)。再@splat(0)清空——全屏熄灭。两步都成功说明 I2C 链路与显存映射完好。 - 页边界理解:把
draw_glyph的写入位置改成fb[128 + x + i](第 1 页),HELLO 会整体下移 8 行。 - 逻辑分析仪挂 SCL/SDA:400 kHz 方波清晰;START 条件(SCL 高时 SDA 下降)与地址帧 0x78(写地址)可见;每帧第 9 个时钟有从机拉低的 ACK。对比 100 kHz 配置可直观感受两种模式。
- 拔掉上拉电阻再烧:SCL/SDA 悬空,多半卡在总线或报
UnrecoverableError——上拉不是可选项。
常见问题
Q1:OLED 一片漆黑,串口也没报错。
三步定位:① 电荷泵——确认驱动初始化含 0x8D 0x14(SSD1306_I2C 默认有,手写裸驱动的十有八九漏这条);② 电源——有些模块的 VCC 要 5V、GND 别接反,OLED 烧了也是全黑;③ I2C 地址——扫总线(对 1–127 每个地址写 1 字节、有 ACK 就是存在,上游 i2c_bus_scan.zig 是现成范本),确认设备真的在 0x3C 响应。若 ACK 都收不到,回到硬件:上拉焊没焊、PB6/PB7 是不是被别的功能占了。
Q2:I2C 卡死:SCL 高、SDA 一直低,之后所有传输报 UnrecoverableError。
经典的"从机拉着 SDA 不放":主机在传输中途复位或从机状态机错位后,从机还以为自己在应答,把 SDA 钳在低电*,主机永远等不到总线空闲(HAL 里 is_busy 永远 true)。标准恢复:手动把 SCL 切成普通 GPIO 推挽输出,翻转 9 个时钟让从机的位计数器走完一个字节释放 SDA,再发一个 STOP;或者干脆复位 I2C 外设(HAL 的错误处理里 UnrecoverableError 分支调 i2c2.apply(config) 重新配置外设,就是这个思路的简化版——上游示例 i2c.zig:68-77 逐字可查)。根本对策:所有 I2C 调用都带 Duration 超时,捕获 UnrecoverableError 走恢复流程,绝不让总线操作无限阻塞。
Q3:编译报 no field or member function named 'write_then_read_blocking'。
撞上了上游 bug P-02:hal.drivers.I2C_Device.write_then_read 内部调用 F103 I2C 里不存在的方法。改用 writev_then_readv(语义等价:先写后读,两段式事务),或者直接用 HAL 的 write_blocking + read_blocking 自己拼。这个 bug 只在"调用时"爆发——Zig 惰性分析让没被调用的坏函数安静躺着。不要试图修上游,本地包一层即可。
Q4:400 kHz 通信间歇失败,降到 100 kHz 又好了。
三个嫌疑按序查:① 上拉阻值——400 kHz 要求上升时间 ≤300 ns,4.7 kΩ 配 100 pF 左右总线电容刚好;上拉太弱(10 kΩ+)或走线太长(电容大)都会让边沿爬坡缓慢,采样点附*电*不达标;② mode 忘了 .fast——speed 给了 400_000 但 mode 还是 standard,CCR 计算出的实际时序不达标;③ pclk 数字错——apply 是 comptime 校验,pclk 错了 CCR 就错,波形整体变形。终极手段:逻辑分析仪量 SCL 实际频率与上升时间,一切以波形为准。
Q5:为什么 I2C 引脚不能配推挽复用?SPI 就可以。
I2C 的仲裁、ACK、时钟 stretching 全部依赖"线与"——多个开漏输出并联,任何一方拉低即整体为低;推挽输出并联对撞是电源对地的直通短路。SPI 是点对点主从架构,主机独占 SCK/MOSI 驱动权、从机只在 MISO 上输出,没有多方驱动同一根线的场景,所以可以推挽全速跑几十 MHz。这也是两条总线速率差两个数量级的根本原因之一:I2C 的开漏+上拉是电阻充电,边沿慢;SPI 的推挽是晶体管驱动,边沿快。
Q6:drivers 层的 SSD1306_I2C.Framebuffer(set_pixel 等)能直接用吗?为什么示例自己画?
能。驱动内置 Framebuffer(width = 128、height = 64、pixel_data: [8 * 128]u8,提供 init/clear/set_pixel(x: u7, y: u6, color)/bit_stream(),bit_stream() 返回值可直接喂给 write_full_display)。示例自己维护 fb 有两个原因:一是教学——让你直面"显存就是 1024 个纵向字节"的物理事实;二是注意坐标类型是 u7/u6,传 u8 变量要 @intCast,越界会 panic,这是 Zig 用整数宽度表达取值范围的典型设计。产品代码建议用驱动自带 Framebuffer,省得重复造轮子。
面试考点
Q1:画一次完整 I2C 写传输的时序(START 到 STOP),说明 ACK 位的产生方与采样方。
参考答案:START(SCL 高时 SDA 由高变低)→ 9 个时钟的地址帧:7 位地址 + 1 位 R/W(0=写)+ 第 9 拍 ACK(接收方拉低 SDA、发送方释放总线并采样)→ 若干 8 位数据帧,每帧后跟 ACK → STOP(SCL 高时 SDA 由低变高)。关键角色切换:主机发地址时是发送方,第 9 拍变接收方;从机相反。ACK 缺失(NACK)的含义:地址无设备响应或数据被拒收,主机应据此终止或重试。START/STOP 定义为"数据有效窗口之外"的 SDA 跳变,保证与数据流无歧义。
Q2:为什么 I2C 的 SDA/SCL 必须开漏 + 上拉?列举三个依赖此设计的机制。
参考答案:开漏 = 只能拉低不能主动拉高,高电*由上拉电阻恢复。依赖它的机制:① 多主机仲裁——线与逻辑让发 0 者赢,两个推挽输出对撞是短路;② 从机 ACK——主机在第 9 拍必须从输出切输入,开漏下只需释放总线,无缝切换;③ 时钟 stretching——从机拉住 SCL 强迫主机等待,主机开漏无法对抗。附加收益:多电压共存(上拉到谁的电*就跑谁的电压)。代价:电阻充电的边沿慢,速率上限低(快速模式 400 kHz、高速模式需额外电流增强)。
Q3:SSD1306 初始化序列里哪条命令最容易被漏掉、后果是什么?显存为什么是 1024 字节、位序如何?
参考答案:最容易漏的是电荷泵命令 0x8D + 参数 0x14——OLED 的升压电路不启动,面板无高压,屏幕全黑且 I2C 通信完全正常、无任何错误返回,是最迷惑人的故障。其次是 0xAE/0xAF 的关/开显示对。显存:128×64 像素按 8 个页组织,每页 128 列 × 1 字节,每字节纵向编码(bit0 在页内最上),共 128×8 = 1024 字节;write_full_display(data: *const [128 * 8]u8) 一次刷新全屏。行式字模需要位转置才能正确落显存。
Q4:I2C 总线被从机钳住 SDA(BUSY 卡死)的成因与恢复流程?
参考答案:成因:主机在传输中途复位(或从机固件/硬件异常),从机的位计数器停在"该我应答"的状态,持续把 SDA 拉低;主机侧总线判定忙,后续传输全部失败。恢复流程:① 把 SCL 从 I2C 复用切回 GPIO 推挽;② 手动产生最多 9 个时钟脉冲,让从机的移位计数器走完一个字节、到达"非应答位"而释放 SDA;③ 产生一个 STOP 条件复位从机状态机;④ 把引脚切回复用开漏、重新初始化 I2C 外设。预防:所有事务带超时(Duration),捕获 UnrecoverableError 走上述恢复——F103 的 I2C v1 有官方勘误(CD00197763),这条恢复路径属于必备工程实践。
Q5:microzig drivers 层的 write_then_read 有什么问题?为什么 Zig 的编译模型让这类 bug 具有隐蔽性?
参考答案:该函数内部调用 write_then_read_blocking,而 F103 的 I2C HAL 根本没有这个方法——上游 bug(P-02)。隐蔽性的根源是 Zig 的惰性分析:函数体只在被调用时才进行语义分析,bug 函数只要无人调用就永不报错,测试覆盖率稍低就会把地雷带上线。同类现象也见于 HAL 的死字段(如 PWM 的 invert)与 DMA 的 assert——"编译通过"从来不是正确性的证据。防御手段:接口层写冒烟测试、代码评审关注跨层调用、升级依赖时跑全量示例。
Q6:I2C 与 SPI 在引脚数、速率、拓扑、适用场景上如何取舍?
参考答案:I2C 两根线挂多设备(地址寻址),速率 100k/400k(快速模式),有 ACK 与仲裁,适合低速多传感器总线(温湿度、OLED、EEPROM);代价是开漏+上拉的边沿慢、协议开销大(每字节 9 拍)、地址冲突与总线卡死问题。SPI 四线(SCK/MOSI/MISO/CS),每从机多一根 CS(或菊花链),几十 MHz 无压力,全双工、协议零开销,适合高吞吐(Flash、屏幕、SD 卡);代价是引脚多、无应答机制(可靠性靠协议层自证)、多点拓扑弱。选择直觉:慢速多设备 I2C、高速大数据 SPI;下一篇的 W25Q64 就是 SPI 阵营的代表选手。
小结
- I2C 时序五要素:START/ADDR+R/W/ACK/DATA/STOP;开漏 + 4.7 kΩ 上拉是仲裁、ACK、时钟 stretching 的共同前提,不是可选件。
I2C.init(.I2C1)+Config{ .pclk = 36_000_000, .speed = 400_000, .mode = .fast };apply是 comptime(pclk 写字面量),运行期值用runtime_apply(注意与 uart 的apply_runtime词序相反)。- 地址
I2C.Address.new(0x3C)收 7 位地址;商家给的 0x78 是 8 位写地址。 - drivers 层
write_then_read是坏的上游 bug(P-02),两段式用writev_then_readv。 - SSD1306 初始化序列记三条:0xAE 关、0x8D+0x14 电荷泵、0xAF 开;I2C 模式控制字节 0x00=命令、0x40=数据。
- 显存 128×8 页 = 1024 字节,纵向字节 bit0 在上;
write_full_display(data: *const [128*8]u8)全屏刷新。 - 列式 5x7 字模与显存同构,画字符就是抄 5 个字节;行式字模要位转置。
- 总线卡死用"9 个 SCL 时钟 + STOP"恢复,所有事务带 Duration 超时。
- 下一篇 SPI:更快的四线总线,把 W25Q64 的 8 MB Flash 接进来。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与*台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号