SPI 总线:W25Q64 Flash 读写与 ID 识别

本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 15 篇。上一篇:I2C 总线:SSD1306 OLED 显示实战,下一篇:ADC 采样:单次、扫描与内部温度传感器,目录见文末。

SPI 总线:W25Q64 Flash 读写与 ID 识别

导读

I2C 靠两根线走天下,代价是慢;SPI 用四根线换来几十 MHz 的速率与零协议开销,是 Flash、TFT 屏、SD 卡这些"大吞吐"外设的地盘。本篇以华邦 W25Q64(8 MB SPI NOR Flash)为例走完一条完整的设备驱动开发路径:读 JEDEC ID 确认芯片在位 → 扇区擦除 → 页编程 → 读回校验,最后用"一个 u32 掉电计数器"把擦写流程串成一个实用的小应用,并与第 17 篇的内部 Flash 做成对照实验。你会看到 SPI 协议本身简单得近乎透明——复杂度全部在命令集与擦写规则里:256 字节页边界、写前必擦、状态寄存器 BUSY 轮询。microzig 的 SPI HAL 很薄(5 个方法,Config 甚至是私有类型),薄到你需要自己管片选——这恰好是最好的教学状态。

为什么需要 SPI 与它的四种模式

SPI 的四根线各司其职:SCK(时钟,主机产生)、MOSI(主出从入)、MISO(从出主入)、CS/SS(片选,每个从机一根,低有效)。协议没有地址、没有 ACK、没有帧格式——CS 拉低期间,时钟驱动的位移寄存器把 MOSI/MISO 当一条环形链转:主机每发 1 字节的同时也收 1 字节(全双工)。这个"发即收"的性质决定了 SPI 读操作的本质是"发哑字节时钟换数据",后面读 ID 的代码会直接体现。

时序由两个参数决定,共四种组合:

  • CPOL(时钟极性):空闲时 SCK 是低(IdleLow)还是高(IdleHigh)。
  • CPHA(相位):第 1 个边沿采样(FirstEdge)还是第 2 个边沿采样(SecondEdge)。
模式 CPOL CPHA 俗称
0 0 0 Mode 0
1 0 1 Mode 1
2 1 0 Mode 2
3 1 1 Mode 3

W25Q64 支持 Mode 0 和 Mode 3——数据手册时序图里 CS 下降沿后 SCK 空闲为低或高都行,但采样边沿的约定是死的。CPOL/CPHA 配错的现象很有辨识度:数据"整体错半拍",读 ID 得到 0xFF 或移位的垃圾。microzig 的 spi.Config 默认就是 phase = .FirstEdge, polarity = .IdleLow(Mode 0),对 W25Q64 来说零配置即正确。

速率由主时钟分频决定:F103 的 SPI1 挂 APB2(72 MHz),分频 2–256 得 72 MHz/2 = 36 MHz 到 281 kHz。W25Q64 本体支持 133 MHz,但上电首次通信建议低速起跑,确认无误再提速。

代码案例:识别 W25Q64 并做掉电计数器

接线按系列约定:PA5=SCK、PA6=MISO、PA7=MOSI、PA4=CS。上拉与去耦按模块说明,CS 空闲必须为高。

const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;

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 spi = stm32.spi.SPI.init(.SPI1);
const cs = gpio.Pin.from_port(.A, 4); // 软件 CS

// W25Q64 命令集(数据手册 §8.1)
const CMD_JEDEC_ID: u8 = 0x9F; // 读厂商/容量 ID
const CMD_WRITE_ENABLE: u8 = 0x06;
const CMD_PAGE_PROGRAM: u8 = 0x02; // 页编程
const CMD_READ_DATA: u8 = 0x03;    // 读数据
const CMD_SECTOR_ERASE: u8 = 0x20; // 4KB 扇区擦除
const CMD_READ_STATUS: u8 = 0x05;  // 读状态寄存器,bit0 = BUSY

const COUNTER_ADDR: u24 = 0x000000; // 计数器放在扇区 0 开头

fn cs_low() void {
    cs.put(0);
}
fn cs_high() void {
    cs.put(1);
}

fn wait_not_busy() void {
    var st: [1]u8 = undefined;
    while (true) {
        cs_low();
        spi.write_blocking(&.{CMD_READ_STATUS});
        spi.read_blocking(&st);
        cs_high();
        if (st[0] & 1 == 0) break; // BUSY = 0
        time.sleep_ms(1);
    }
}

fn sector_erase(addr: u24) void {
    cs_low();
    spi.write_blocking(&.{ CMD_WRITE_ENABLE });
    cs_high();
    cs_low();
    spi.write_blocking(&.{ CMD_SECTOR_ERASE, @truncate(addr >> 16), @truncate(addr >> 8), @truncate(addr) });
    cs_high();
    wait_not_busy(); // 典型 45ms,最长 400ms
}

fn page_program(addr: u24, data: []const u8) void {
    cs_low();
    spi.write_blocking(&.{CMD_WRITE_ENABLE});
    cs_high();
    cs_low();
    spi.write_blocking(&.{ CMD_PAGE_PROGRAM, @truncate(addr >> 16), @truncate(addr >> 8), @truncate(addr) });
    spi.write_blocking(data); // 数据与命令在同一 CS 拉低期间连续发出
    cs_high();
    wait_not_busy(); // 典型 0.4ms
}

fn read_data(addr: u24, buf: []u8) void {
    cs_low();
    spi.write_blocking(&.{ CMD_READ_DATA, @truncate(addr >> 16), @truncate(addr >> 8), @truncate(addr) });
    spi.read_blocking(buf);
    cs_high();
}

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(.GPIOA);
    rcc.enable_clock(.SPI1);
    rcc.enable_clock(.USART1);

    // PA5/PA7 复用推挽,PA6(MISO)浮空或上拉输入,PA4 推挽输出做 CS
    const sck = gpio.Pin.from_port(.A, 5);
    const miso = gpio.Pin.from_port(.A, 6);
    const mosi = gpio.Pin.from_port(.A, 7);
    sck.set_output_mode(.alternate_function_push_pull, .max_50MHz);
    mosi.set_output_mode(.alternate_function_push_pull, .max_50MHz);
    miso.set_input_mode(.floating);
    cs.set_output_mode(.general_purpose_push_pull, .max_50MHz);
    cs_high(); // 空闲高电平

    // apply 的 config 不是 comptime;Config 类型私有,用匿名结构体字面量
    spi.apply(.{
        .prescaler = .Div128, // 72MHz/128 = 562 kHz,识别阶段稳妥起步
        .phase = .FirstEdge,  // Mode 0
        .polarity = .IdleLow,
    });

    try uart.apply_runtime(.{ .clock_speed = rcc.get_clock(.USART1) });
    stm32.uart.init_logger(&uart);

    // 1) 读 JEDEC ID:发 0x9F 后收 3 字节
    var id: [3]u8 = undefined;
    cs_low();
    spi.write_blocking(&.{CMD_JEDEC_ID});
    spi.read_blocking(&id);
    cs_high();
    std.log.info("JEDEC: {X:0>2} {X:0>2} {X:0>2}", .{ id[0], id[1], id[2] });
    // 期望 EF 40 17:EF=Winbond, 40=SPI 系列, 17=容量 2^23 = 8MB

    // 2) 读回掉电计数器(4 字节,大端)
    var cnt_raw: [4]u8 = undefined;
    read_data(COUNTER_ADDR, &cnt_raw);
    const cnt: u32 = (@as(u32, cnt_raw[0]) << 24) | (@as(u32, cnt_raw[1]) << 16) |
        (@as(u32, cnt_raw[2]) << 8) | cnt_raw[3];
    std.log.info("power cycles: {d}", .{cnt});

    // 3) 计数 +1 写回:擦除 -> 编程 -> 读回校验
    const new_cnt = cnt +% 1;
    sector_erase(COUNTER_ADDR);
    var buf: [4]u8 = .{
        @truncate(new_cnt >> 24), @truncate(new_cnt >> 16),
        @truncate(new_cnt >> 8),  @truncate(new_cnt),
    };
    page_program(COUNTER_ADDR, &buf);

    var verify: [4]u8 = undefined;
    read_data(COUNTER_ADDR, &verify);
    if (std.mem.eql(u8, &buf, &verify)) {
        std.log.info("write verified, next boot = {d}", .{new_cnt});
    } else {
        std.log.err("verify FAILED", .{});
    }

    while (true) {
        time.sleep_ms(1000);
    }
}

逐段解析

SPI.init 与那个"私有 Config"的调用门道

stm32.spi.SPI.init(.SPI1) 返回 SPI 结构体(内含寄存器指针)。apply(config) 的特别之处有二:一是 config 不是 comptime(与 uart/i2c 相反,传运行期值没问题,函数也不做任何校验);二是 Config 类型在 spi.zig 里没有 pub——但匿名结构体字面量 spi.apply(.{ .prescaler = .Div128 }) 依然合法,Zig 会把字面量按形参类型推导。所以规则是"能调、不能显式命名":写 stm32.spi.Config{...} 会报 Config is private。

Config 的全部字段(spi.zig:8-21 逐字):phase(.FirstEdge/.SecondEdge)、polarity(.IdleLow/.IdleHigh)、chip_select(.NSS 硬件从选 / .GPIO 软件片选)、frame_format(.MSBFirst)、data_size(.Bits8)、prescaler。默认值组合正好是"Mode 0 + MSB + 8 位 + 硬件 NSS + Div2"。注意 apply 的第一行是 self.spi.CR1.raw = 0——先整体清空再配置,所以它不保留任何上一次的设置。prescaler 枚举是 regz 生成物,仓库内唯一逐字核对过的取值是 .Div128(上游示例在用),Div2/4/8/16/32/64/256 是 BR[2:0] 的标准编码,具体名字以你本地的生成物为准。

为什么用软件 CS(PA4 手动 put(0)/put(1)):硬件 NSS 模式是为主从切换设计的,多从机场景必须每从机一根 CS 线,软件控制最直接。更重要的原因是命令与数据的完整性:W25Q64 的每条命令都要求"命令 + 地址 + 数据在同一个 CS 拉低窗口内完成",CS 上升沿是命令的"提交键"。microzig 的 write_blocking/read_blocking 每次调用之间没有事务边界概念,把 CS 的控制权留给你恰好是对的分工——示例里 cs_low() 与 cs_high() 成对出现的纪律就是这个原因。CS 空闲为高也必须显式做:复位后 PA4 是浮空输入,Flash 的 CS 悬空可能拾取噪声导致误触发。

JEDEC ID:三字节读出的三段信息

0x9F 是 JEDEC 标准命令(所有 SPI NOR 通用),返回 3 字节:厂商 ID、类型、容量。EF 40 17 的解读:

  • EF = Winbond 的 JEDEC 分配码(金士顿=0x37、兆易创新 GigaDevice=0xC8——兼容片读出来不一样,这也是识别"换芯"的第一手段)。
  • 40 = SPI 系列(SPINOR),另一族 0x46 是 QSPI。
  • 17 = 容量:0x17 = 23,2^23 = 8,388,608 字节 = 8 MB。这个"容量 = 2^(ID 低字节)"的规律是 JEDEC 规范定的,W25Q16 是 0x15(2MB)、W25Q128 是 0x19(16MB)。

读 ID 是一切 SPI 驱动的第一步,原因很工程化:它不破坏任何数据(纯读命令)、不需要写使能、不触发擦写延迟,一条命令就能验证"接线、模式、速率、片选"四个环节全部正确。ID 读不对,后面全部停手先查硬件。

代码结构上注意 write_blocking(&.{CMD_JEDEC_ID}) 与 read_blocking(&id) 是两次调用但同一个 CS 窗口——这利用了 SPI 的"发即收":发命令的 8 个时钟里 MISO 上是垃圾(没人驱动),收阶段的 8×3 个时钟才是数据。另一种写法是 transceive_blocking,它的等长语义值得原文引用(源码注释逐字):接收缓冲比发送缓冲大时补发哑字节(0x00)直到收满;发送缓冲更大时多余字节被丢弃。所以"发 1 字节命令收 3 字节 ID"用 transceive 写法是 transceive_blocking(&.{0x9F}, &.{ 0, 0, 0, 0 })——发 4 收 4,取后 3。两种写法都对,选一个团队统一即可。

擦写循环:写使能、页边界与 BUSY 轮询

NOR Flash 的写入不是"直接写",而是一个三段式仪式,缺一不可:

  1. 写使能(0x06)。Flash 上电默认写保护,每条擦/写命令前都要先发 WREN,状态寄存器的 WEL 位随之置 1(且每次擦写完成后自动清零)。所以示例里 sector_erase 和 page_program 的第一拍都是"CS 拉低发 0x06、CS 拉高"。
  2. 命令 + 24 位地址。W25Q64 用 3 字节地址(8MB 恰好 24 位)。地址按大端发送——@truncate(addr >> 16) 先发最高字节。Flash 的地址是字节序无关的裸数字,协议约定 MSB first。
  3. 等 BUSY。擦除与编程是内部高压操作,命令发出后芯片进入忙状态,状态寄存器(0x05)bit0 的 BUSY 置 1。BUSY 期间一切其他命令无效(读状态除外)。扇区擦除典型 45 ms、最长 400 ms;页编程典型 0.4 ms。轮询节奏:CS 拉低 → 发 0x05 → 读 1 字节 → CS 拉高 → 睡 1 ms 再来。注意每次轮询都是完整的 CS 事务——BUSY 状态在 CS 拉高后才更新到位,别在同一个 CS 窗口里死循环读。

两条铁律:

  • 页边界(256 字节)。页编程命令的地址指定"页起始 + 页内偏移",写入在页内按地址递增,到页末会回卷到本页开头覆写——而不是跨到下一页。所以 page_program 的调用者必须保证 addr % 256 + data.len <= 256,跨页数据切成两次编程。这条规则忘掉的后果是数据被"环形覆写",前半段数据静默丢失。
  • 写前必擦。NOR Flash 的编程只能把 1 变 0(物理上是电子注入浮栅),擦除把整个扇区(4 KB)恢复为全 1(0xFF)。想改一个字节,得擦整个扇区再重写。示例的掉电计数器流程"读 → 擦 → 写 → 验"就是这条铁律的最小完整实现。读回校验(verify)不是多余仪式——SPI Flash 的写入失败是静默的,只有读回能确认。

一个 u32 计数器的磨损账本

扇区 0 的擦写寿命是 10 万次(W25Q64 数据手册典型值)。每次上电擦 + 写一个扇区 = 1 次磨损,每天开关机 50 次 → 5.5 年磨平一个扇区。8 MB 的芯片有 2048 个扇区,把计数器"轮着写"(每次换一个扇区,读的时候找最大值)能把寿命延长三个数量级——这就是磨损均衡(wear leveling)的最朴素形式,第 17 篇的内部 Flash 双页轮换是同一个思想在小容量上的镜像。

外置 SPI Flash 与内部 Flash 的取舍在此也顺带讲清:W25Q64 免块等待的读(0x03 命令即发即读,不影响取指——CPU 与 SPI 总线互不干扰)、容量大(8 MB vs 512 KB)、擦写寿命同量级但空间大得多,代价是多 4 根线、多一个器件、慢一点(SPI 时钟 vs 内部并行总线)。第 17 篇会给出两者逐项对比表。

运行现象

烧录后串口输出:

JEDEC: EF 40 17
power cycles: 0
write verified, next boot = 1
  • 反复断电重启:power cycles 每次加 1——数据穿过了断电,这就是非易失存储的全部意义。第 10 次上电读到 9、写出 10,永远是这个节奏。
  • 逻辑分析仪:识别阶段的波形只有 4 拍命令 + 24 拍数据,速率 562 kHz;把 prescaler 逐步提到 Div8(9 MHz)再测,同一段波形缩短 16 倍,ID 依然正确——W25Q64 的余量远大于 F103 的上限。
  • 故意拔掉 MISO:ID 读出 FF FF FF——MISO 悬空被上拉到高;拔掉 CS 线则通常读出全 0 或随机数。故障指纹很有用:全 FF = MISO/CS 问题,全 00 = MOSI/SCK 问题。
  • 页边界实验:把 page_program 的调用改成 250 字节偏移写 10 字节,读回会发现开头 6 字节被回卷覆写——亲手复现一次"忘切页"的经典事故。

常见问题

Q1:读 JEDEC ID 全是 0xFF 或 0x00。

全 FF:MISO 悬空/断线/上拉主导——主机发了命令但从机没有把数据送上总线,或 CS 根本没拉低(从机不响应)。全 00:MOSI 或 SCK 断线——从机收不到命令。系统性排查:① CS 空闲是否为高、通信时是否真的拉低(示波器看 PA4);② CPOL/CPHA 是否 Mode 0(phase/polarity 默认值别乱改);③ rcc.enable_clock(.SPI1) 有没有调(SPI 时钟没开,寄存器写入无效,MOSI 恒平);④ 模块的 VCC/GND 与 MISO 方向有没有接反。四项查完,99% 的"读 ID 失败"落网。

Q2:写数据读回来是 0xFF,或者只有开头一段对。

按概率:① 忘了 WREN——写使能没开,编程命令被静默忽略(WEL 是单次性的,每条擦/写前都要重新使能);② 忘了擦除—— NOR 编程只能 1→0,原来的 0 位永远变不回 1,数据"写不进"或"写歪";③ BUSY 没等——编程没完成就读,读到旧值或中间态;④ 页边界回卷——addr % 256 + len > 256 时尾部数据覆写回本页开头。诊断顺序:先只做"擦除 → 读回",确认读出全 0xFF(擦除成功);再做"编程 → 读回",哪一步断就查哪一步的配套(WREN/BUSY)。

Q3:spi.apply 传的配置"没生效"或者和预期不符。

三个检查点:① apply 第一行整体清空 CR1,所以它不是增量配置——所有想要的行为都要在字面量里写全,靠"上次配过"的假设会翻车;② Config 类型是私有的,别试图 stm32.spi.Config 显式命名类型,用匿名 .{...};③ prescaler 枚举名以本地生成物为准,仓库里只逐字验证过 .Div128,别的档位名猜错就是编译错误(不会静默)。另外 apply 不做任何校验:speed 给一个不存在的值、phase/polarity 组合打错,都不报错——SPI 配置正确性最终要靠逻辑分析仪裁决。

Q4:transceive_blocking 的两个缓冲长度不等时会怎样?

源码注释逐字回答了:接收缓冲更大时,发送端补发哑字节(0x00)直到收满;发送缓冲更大时,多出的发送字节被丢弃。所以 transceive_blocking(&.{0x9F}, &id3b)(发 1 收 3)会自动补 2 个哑字节,行为正确但容易看不懂;显式写成发 4 收 4 再丢弃首字节更直白。要点是理解这层语义是"哑字节填充"而不是报错——写驱动时靠这个特性可以偷懒,但代码可读性自己负责。

Q5:BUSY 轮询会不会把 CPU 卡死?扇区擦除最长 400ms 怎么办?

轮询的最坏等待就是擦除超时(400 ms),期间 CPU 空转——单任务固件通常可接受,但要设超时上限(比如 500 ms)防硬件异常导致的永久忙:超时后走错误路径(重试或上报),而不是死等。讲究一点的做法是异步化:发出擦除命令后干别的事,隔一段时间回来查一次状态;或者给 Flash 的中断脚接 EXTI(部分型号支持)在就绪时触发中断。本篇为了聚焦协议流程用了同步轮询,生产代码至少要加超时。

Q6:为什么掉电计数器用外部 SPI Flash 而不用内部 Flash?什么时候反过来?

外部 SPI Flash 的优势:擦写期间 CPU 完全不受影响(写 W25Q64 时代码照常从内部 Flash 取指);容量 8 MB 便于放日志/固件备份/字库;磨损均衡的空间充裕。内部 Flash 的优势:零额外器件、零布线、访问快(零拷贝,直接指针读)。选择准则:高频擦写、大容量 → 外置;只存几十字节的配置参数、且不想加器件 → 内部 Flash(第 17 篇),但必须接受"擦写期间 CPU 停顿"的代价与 2 KB 页的管理复杂度。两个都上(配置存内部、日志存外部)也是常见架构。

面试考点

Q1:解释 CPOL/CPHA 四种模式,W25Q64 用哪种,配错的典型现象是什么?

参考答案:CPOL 决定空闲电平(0=低/1=高),CPHA 决定第几个边沿采样(0=首个边沿/1=次个边沿),组合成 Mode 0–3。W25Q64 支持 Mode 0 与 Mode 3(数据手册时序图的两种画法)。配错的指纹:数据错半拍——读到移位后的值、0xFF 或垃圾,且错误率 100% 而非偶发(与时序余量不足的偶发错误区分)。定位方法:逻辑分析仪对齐 CS 下降沿后的第一个 SCK 边沿,核对采样点是否落在数据稳定窗口中央。

Q2:为什么 NOR Flash 编程前必须擦除?擦除的最小单位是什么,这决定了什么设计约束?

参考答案:NOR Flash 的存储单元是浮栅晶体管,编程(电子注入)只能让位从 1 变 0;恢复 0→1 唯一途径是擦除(紫外/高压释放电子),而擦除作用在整块区域上。W25Q64 的擦除单位是 4 KB 扇区(还有 32/64 KB 块与整片擦除)。设计约束:① 写前必擦,改 1 字节 = 擦 4 KB + 重写整个扇区;② 擦写寿命按扇区计(10 万次),热数据会磨穿固定扇区,需要磨损均衡;③ 突发掉电时擦除中途状态是"部分擦除",关键数据要配 CRC 或双副本。

Q3:描述 SPI 页编程命令的执行流程,页边界回卷行为的后果是什么?

参考答案:流程:WREN(0x06)→ CS 拉低 → 发 0x02 + 24 位地址(MSB first)→ 连续发数据字节 → CS 拉高(提交)→ 轮询状态寄存器 BUSY(0x05 bit0)清零。页约束:编程地址指定页内起点,数据在 256 字节页内顺序写入,到页末回卷到页首覆写而非跨页。后果:调用者不切页时,尾部数据静默覆写开头数据——无任何错误标志。正确做法:addr % 256 + len <= 256,跨页拆成多次编程。

Q4:JEDEC ID 命令返回什么?厂商 ID 与容量字段在工程上有什么实际用途?

参考答案:0x9F 返回 3 字节:厂商 ID、存储类型、容量。EF 40 17 = Winbond / SPI NOR / 2^23 = 8MB。用途:① 上电自检——ID 不对则拒绝启动(接线错、芯片缺焊、兼容片替换都能第一时间暴露,GD 的 GigaDevice ID 是 C8);② 容量自适应——同一驱动支持 W25Q16/64/128,按 ID 低字节的 2^n 算容量、动态确定地址范围;③ 量产一致性校验。它是唯一"零副作用、零前提"的命令(无需 WREN、不触发 BUSY),因此也是驱动开发的第一探针。

Q5:SPI 没有 ACK 机制,数据完整性怎么保证?

参考答案:SPI 协议层确实无应答——主机发了什么、从机收没收到,总线不说话。保证手段全部上移到系统层:① 命令后读回校验(本篇的 verify);② 关键数据带 CRC/魔数(第 17 篇的配置结构体);③ 状态寄存器轮询(BUSY/WEL 提供写入成功的间接证据);④ 高层协议加序列号与重传。与 I2C 对比:I2C 的 ACK 是物理层确认"字节收到了",SPI 把这一责任完全交给软件。这是 SPI 换取速率与简单性的代价之一,设计存储类系统时必须显式补偿。

Q6:microzig SPI 的 Config 是私有类型,为什么匿名结构体字面量仍能传参?这个设计给你什么启发?

参考答案:Zig 的函数实参按形参类型做 coerce,匿名结构体字面量 .{...} 会被推导为形参的私有类型并逐字段匹配,所以调用合法;但外部代码无法写出该类型的名字(stm32.spi.Config 是非 pub 的),也就无法存变量、无法写以它为参数的函数签名。效果是"可调用但不可显式依赖"——上游保留了内部重构自由(改字段名只影响调用点),调用者被迫就地传字面量。启发:这是 Zig 社区"最小暴露面"的接口设计手法;反过来写库时,若希望用户能持有配置,就把类型 pub,若希望配置"即用即弃",私有 + 匿名字面量是低维护成本的方案。

小结

  • SPI 四线全双工,CPOL/CPHA 四模式,W25Q64 用 Mode 0/3;microzig 默认配置即 Mode 0。
  • SPI.init(.SPI1) + apply(.{ .prescaler = .Div128, ... }):config 非 comptime、类型私有(匿名 .{...} 可传不可命名)、apply 先清空 CR1 全量重配、无任何校验。
  • 软件 CS(PA4 put(0)/put(1))手动划事务边界;每条命令 = 同一 CS 拉低窗口内的"命令 + 地址 + 数据"。
  • 0x9F 读 JEDEC ID:EF 40 17 = Winbond/SPI/8MB;零副作用,是驱动开发第一探针,兼做兼容片识别。
  • 擦写仪式:WREN(每次都要)→ 命令 + 24 位大端地址 → 数据 → 轮询 0x05 的 BUSY(每次轮询是完整 CS 事务)。
  • 铁律:256 字节页边界(回卷覆写)、写前必擦(编程只能 1→0,擦除恢复 0xFF)、读回校验。
  • write_blocking/read_blocking/transceive_blocking(哑字节补发语义,等长最直观)。
  • u32 掉电计数器 = 读、擦、写、验的最小完整闭环;磨损均衡思想通向第 17 篇。
  • 下一篇回到片上:ADC 的 12 位世界,单次、扫描与内部温度传感器。

系列目录

  1. 系列总览:Zig 裸机开发的定位、生态与平台全景
  2. 开发环境搭建:Zig 0.17、microzig 与 probe-rs
  3. 工程结构剖析:build.zig、MicroBuild 与固件产物
  4. 第一个固件:启动流程、向量表与 GPIO 点灯
  5. 寄存器级编程:PAC、MMIO 与 volatile 语义
  6. GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
  7. 时钟树配置:HSE、PLL 与总线分频
  8. 中断系统:NVIC、优先级分组与向量注册
  9. USART 串口通信:轮询收发与日志重定向
  10. 定时器基础:时基、PWM 与呼吸灯
  11. 输入捕获:频率与占空比测量
  12. 中断驱动串口:环形缓冲区与临界区设计
  13. DMA 传输:从外设搬运到内存
  14. I2C 总线:SSD1306 OLED 显示实战
  15. SPI 总线:W25Q64 Flash 读写与 ID 识别
  16. ADC 采样:单次、扫描与内部温度传感器
  17. 内部 Flash 编程:参数持久化与页擦除
  18. 独立看门狗:复位诊断与可靠性设计
  19. 低功耗模式:Sleep、Stop、Standby 与唤醒
  20. 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
  21. Zig 语言进阶:comptime、错误处理与零成本抽象
  22. 并发模型:中断与主循环的协作与临界区
  23. 协作式调度器:状态机与时间片轮转
  24. 实时性分析:中断延迟、抖动与 WCET
  25. RTOS 内核实现(上):任务控制块与上下文切换
  26. RTOS 内核实现(下):信号量、消息队列与优先级继承
  27. USB 设备开发:CDC 虚拟串口
  28. CAN 总线:bxCAN 过滤器与环回自测
  29. 调试与测试:probe-rs、RTT 与硬件在环
  30. 收官:分层架构、可移植性与 Zig 生态展望
posted @ 2026-10-27 08:53  navysummer_hardware  阅读(21)  评论(0)    收藏  举报