summaryrefslogtreecommitdiff
path: root/src/hal/gpio.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/hal/gpio.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/hal/gpio.zig')
-rw-r--r--src/hal/gpio.zig471
1 files changed, 471 insertions, 0 deletions
diff --git a/src/hal/gpio.zig b/src/hal/gpio.zig
new file mode 100644
index 0000000..88a8675
--- /dev/null
+++ b/src/hal/gpio.zig
@@ -0,0 +1,471 @@
+//! GPIO and the IO MUX.
+//!
+//! The P4 has 57 pins (GPIO0-56) and every whole-bank register is therefore split in two: `out`
+//! covers 0-31 and `out1` covers 32-56. Getting that split wrong is the classic P4 GPIO bug - a
+//! write to `out` with a shift of 40 lands on pin 8 - so the bank arithmetic lives in exactly one
+//! place here (`Bank`) and every operation goes through it.
+//!
+//! Levels and enables are driven through the `_W1TS`/`_W1TC` (write-1-to-set / write-1-to-clear)
+//! aliases rather than read-modify-write on `out`/`enable`. That is what ESP-IDF's LL does, and it
+//! is not a style choice: a read-modify-write of a whole bank races anything else touching another
+//! pin in the same bank, and there is no lock here to prevent it.
+//!
+//! Pad configuration (direction of the *input* buffer, pulls, drive strength, function select) is
+//! not in the GPIO peripheral at all - it is in the IO MUX, one register per pad. The two must be
+//! kept in step: a pin driven by `enable` but with `fun_ie` clear cannot be read back, which is the
+//! single most common "my GPIO does not work" on this part.
+
+const std = @import("std");
+const regs = @import("regs");
+const mmio = @import("mmio");
+
+const Reg = mmio.Reg;
+const Field = mmio.Field;
+
+/// GPIO0-56. 57 pins, and the last five (52-56) exist only on some packages.
+pub const max_pin = 56;
+pub const pin_count = max_pin + 1;
+
+/// Which half of a split bank register a pin lives in, and its bit inside that half.
+const Bank = struct {
+ high: bool,
+ bit: u5,
+
+ inline fn of(pin: u8) Bank {
+ std.debug.assert(pin <= max_pin);
+ return if (pin < 32)
+ .{ .high = false, .bit = @intCast(pin) }
+ else
+ .{ .high = true, .bit = @intCast(pin - 32) };
+ }
+
+ inline fn mask(self: Bank) u32 {
+ return @as(u32, 1) << self.bit;
+ }
+
+ inline fn pick(self: Bank, lo: Reg, hi: Reg) Reg {
+ return if (self.high) hi else lo;
+ }
+};
+
+// The whole-bank registers. `_W1TS`/`_W1TC` are separate addresses that set or clear only the bits
+// written as 1, which is what makes a single-pin update atomic against the rest of the bank.
+const out = Reg.at(regs.GPIO_OUT_REG);
+const out1 = Reg.at(regs.GPIO_OUT1_REG);
+const out_w1ts = Reg.at(regs.GPIO_OUT_W1TS_REG);
+const out1_w1ts = Reg.at(regs.GPIO_OUT1_W1TS_REG);
+const out_w1tc = Reg.at(regs.GPIO_OUT_W1TC_REG);
+const out1_w1tc = Reg.at(regs.GPIO_OUT1_W1TC_REG);
+const enable_w1ts = Reg.at(regs.GPIO_ENABLE_W1TS_REG);
+const enable1_w1ts = Reg.at(regs.GPIO_ENABLE1_W1TS_REG);
+const enable_w1tc = Reg.at(regs.GPIO_ENABLE_W1TC_REG);
+const enable1_w1tc = Reg.at(regs.GPIO_ENABLE1_W1TC_REG);
+const enable = Reg.at(regs.GPIO_ENABLE_REG);
+const enable1 = Reg.at(regs.GPIO_ENABLE1_REG);
+const in = Reg.at(regs.GPIO_IN_REG);
+const in1 = Reg.at(regs.GPIO_IN1_REG);
+
+/// One IO MUX register per pad, stride taken from two consecutive macros rather than assumed.
+const pad = mmio.RegArray(
+ regs.PERIPHS_IO_MUX_U_PAD_GPIO0,
+ regs.PERIPHS_IO_MUX_U_PAD_GPIO1,
+ pin_count,
+);
+
+// Pad fields. These macros are unprefixed globals in io_mux_reg.h - they describe every pad, not
+// one - which is why they read as bare `MCU_SEL` rather than `IO_MUX_GPIO7_MCU_SEL`.
+const fun_ie = Field.of(regs.FUN_IE_S, regs.FUN_IE_V);
+const fun_drv = Field.of(regs.FUN_DRV_S, regs.FUN_DRV_V);
+const mcu_sel = Field.of(regs.MCU_SEL_S, regs.MCU_SEL_V);
+// io_mux_reg.h defines no macros for the two pull bits; io_mux_struct.h documents them as
+// `fun_wpd : R/W; bitpos: [7]` and `fun_wpu : R/W; bitpos: [8]`.
+const fun_wpd = Field.bit(7);
+const fun_wpu = Field.bit(8);
+
+/// IO MUX function for a pad. Function 1 is plain GPIO on every P4 pad; the others select a
+/// peripheral wired directly to that pad, and anything not on this list has to go through the GPIO
+/// matrix instead.
+pub const Function = enum(u3) {
+ f0 = 0,
+ /// Plain GPIO - the GPIO peripheral drives and samples the pad.
+ gpio = 1,
+ f2 = 2,
+ f3 = 3,
+ f4 = 4,
+ f5 = 5,
+ f6 = 6,
+ f7 = 7,
+};
+
+pub const Drive = enum(u2) {
+ /// ~5 mA
+ weakest = 0,
+ /// ~10 mA
+ weak = 1,
+ /// ~20 mA, the reset value
+ medium = 2,
+ /// ~40 mA
+ strong = 3,
+};
+
+pub const Pull = enum { none, up, down };
+
+// ------------------------------------------------------------------------------------- levels
+
+/// Drive a pin high or low. Uses the write-1-to-set/clear alias, so no other pin in the bank is
+/// disturbed and no read is needed.
+pub inline fn setLevel(pin: u8, level: u1) void {
+ const b = Bank.of(pin);
+ const r = if (level == 1)
+ b.pick(out_w1ts, out1_w1ts)
+ else
+ b.pick(out_w1tc, out1_w1tc);
+ r.writeRaw(b.mask());
+}
+
+pub inline fn setHigh(pin: u8) void {
+ setLevel(pin, 1);
+}
+
+pub inline fn setLow(pin: u8) void {
+ setLevel(pin, 0);
+}
+
+pub inline fn toggle(pin: u8) void {
+ const b = Bank.of(pin);
+ if (b.pick(out, out1).raw() & b.mask() != 0) setLow(pin) else setHigh(pin);
+}
+
+/// Sample the pad. Reads the *input* register, so it reports what the pin is actually at - which
+/// for an open-drain or externally driven pin is not necessarily what was last written to `out`.
+/// Requires the pad's input buffer to be enabled (`setInputEnable`).
+pub inline fn getLevel(pin: u8) u1 {
+ const b = Bank.of(pin);
+ return @intCast((b.pick(in, in1).raw() >> b.bit) & 1);
+}
+
+/// What was last driven, from the output register rather than the pad.
+pub inline fn getDrivenLevel(pin: u8) u1 {
+ const b = Bank.of(pin);
+ return @intCast((b.pick(out, out1).raw() >> b.bit) & 1);
+}
+
+// -------------------------------------------------------------------------------- direction
+
+pub inline fn outputEnable(pin: u8) void {
+ const b = Bank.of(pin);
+ b.pick(enable_w1ts, enable1_w1ts).writeRaw(b.mask());
+}
+
+pub inline fn outputDisable(pin: u8) void {
+ const b = Bank.of(pin);
+ b.pick(enable_w1tc, enable1_w1tc).writeRaw(b.mask());
+}
+
+pub inline fn isOutputEnabled(pin: u8) bool {
+ const b = Bank.of(pin);
+ return b.pick(enable, enable1).raw() & b.mask() != 0;
+}
+
+/// The pad's input buffer. Independent of the output driver: both can be on at once, which is how a
+/// pin is read back while being driven.
+pub inline fn setInputEnable(pin: u8, on: bool) void {
+ pad.at(pin).modify(.{fun_ie.is(@intFromBool(on))});
+}
+
+/// Whether the pad's input buffer is on. The counterpart of `setInputEnable`, and worth having
+/// because a routed input with `fun_ie` clear is indistinguishable from a card that never drove
+/// the pin: both read as a constant.
+pub inline fn isInputEnabled(pin: u8) bool {
+ return pad.at(pin).get(fun_ie) != 0;
+}
+
+// -------------------------------------------------------------------------------- pad config
+
+pub inline fn setFunction(pin: u8, f: Function) void {
+ pad.at(pin).modify(.{mcu_sel.is(@intFromEnum(f))});
+}
+
+pub inline fn setDrive(pin: u8, d: Drive) void {
+ pad.at(pin).modify(.{fun_drv.is(@intFromEnum(d))});
+}
+
+/// Internal pull resistors. Setting one direction always clears the other in the same store: a pad
+/// with both enabled is a fight between two resistors, and it is easy to reach by two calls.
+pub inline fn setPull(pin: u8, p: Pull) void {
+ pad.at(pin).modify(.{
+ fun_wpu.is(@intFromBool(p == .up)),
+ fun_wpd.is(@intFromBool(p == .down)),
+ });
+}
+
+/// What `setPull` last left, read back from the pad. A pad with both resistors enabled cannot be
+/// reached through `setPull`, but the reset value or another driver can leave one that way, so the
+/// contradictory case is reported as `.none` rather than picking a winner.
+pub inline fn getPull(pin: u8) Pull {
+ const w = pad.at(pin).raw();
+ const up = w & fun_wpu.mask() != 0;
+ const down = w & fun_wpd.mask() != 0;
+ if (up and !down) return .up;
+ if (down and !up) return .down;
+ return .none;
+}
+
+/// Open-drain: the pad drives low and releases high instead of driving both rails.
+///
+/// This one is not in the IO MUX with the other pad properties - it is `GPIO_PINn_PAD_DRIVER`, bit
+/// 2 of the GPIO peripheral's per-pin register (`gpio_reg.h:363-368`, "1:open-drain. 0:normal"),
+/// which is a different register file from `PERIPHS_IO_MUX_U_PAD_GPIOn`. A shared bus - I2C, or any
+/// wired-AND signal - needs this on both pads *and* an external pull-up; the internal pull-up is
+/// too weak for anything but a short trace at a low bit rate.
+pub inline fn setOpenDrain(pin: u8, on: bool) void {
+ pin_cfg.at(pin).modify(.{pad_driver.is(@intFromBool(on))});
+}
+
+/// The GPIO peripheral's per-pin configuration register, one per pad. Not the IO MUX: this file
+/// holds the open-drain select, the interrupt configuration and the input synchroniser bypasses.
+const pin_cfg = mmio.RegArray(regs.GPIO_PIN0_REG, regs.GPIO_PIN1_REG, pin_count);
+const pad_driver = Field.of(regs.GPIO_PIN0_PAD_DRIVER_S, regs.GPIO_PIN0_PAD_DRIVER_V);
+
+// -------------------------------------------------------------------------- pin interrupts
+
+/// How a pad raises its interrupt. `gpio_reg.h:377-381`: "0:disable GPIO interrupt. 1:trigger at
+/// posedge. 2:trigger at negedge. 3:trigger at any edge. 4:valid at low level. 5:valid at high
+/// level".
+pub const IntrType = enum(u3) {
+ disable = 0,
+ posedge = 1,
+ negedge = 2,
+ anyedge = 3,
+ low_level = 4,
+ high_level = 5,
+};
+
+const int_type = Field.of(regs.GPIO_PIN0_INT_TYPE_S, regs.GPIO_PIN0_INT_TYPE_V);
+/// Five bits, one per consumer of the pad's interrupt, not a boolean. `gpio_reg.h:400-402` says
+/// "set bit 13 to enable CPU interrupt, set bit 14 to enable CPU(not shielded) interrupt", and
+/// `gpio_ll.h:41,213` names bit 0 of the field `GPIO_LL_INTR0_ENA` and writes exactly that to
+/// route a pad to the `gpio_intr0` source. Writing 1 here means "line 0", not "enabled".
+const int_ena = Field.of(regs.GPIO_PIN0_INT_ENA_S, regs.GPIO_PIN0_INT_ENA_V);
+
+/// Which of the P4's four GPIO interrupt outputs a pad drives. Each is a separate entry in the
+/// interrupt matrix (`hal.intr.Source.gpio_intr0` .. `gpio_intr3`), and each has its own status
+/// register pair. ESP-IDF only ever uses line 0 - `gpio_ll_intr_enable_on_core` hard-codes
+/// `GPIO_LL_INTR0_ENA` with a "TODO: IDF-7995" beside it - so line 0 is the tested path.
+pub const IntrLine = enum(u3) {
+ line0 = 0,
+ line1 = 1,
+ line2 = 2,
+ line3 = 3,
+};
+
+/// Per-line status, gated by `int_ena`. Reading `status`/`status1` instead would report pads whose
+/// interrupt is configured but routed to a different line. `gpio_reg.h:277,291` for line 0,
+/// `:302,316` for line 1; lines 2 and 3 continue the same +0x8 stride.
+const intr_status = mmio.RegArray(regs.GPIO_INTR_0_REG, regs.GPIO_INTR_1_REG, 4);
+const intr_status1 = mmio.RegArray(regs.GPIO_INTR1_0_REG, regs.GPIO_INTR1_1_REG, 4);
+
+/// Status is cleared through a shared write-1-to-clear register, not a per-line one: one pad has
+/// one latch however many lines observe it. `gpio_reg.h:233,269`.
+const status_w1tc = Reg.at(regs.GPIO_STATUS_W1TC_REG);
+const status1_w1tc = Reg.at(regs.GPIO_STATUS1_W1TC_REG);
+
+/// Arm a pad's interrupt and route it to one of the four GPIO interrupt outputs.
+///
+/// This is the GPIO peripheral's half only. The other half is `hal.intr`: the chosen line still
+/// has to be routed from `Source.gpio_intr0`+n to a CLIC line and given a handler. Doing it in two
+/// calls is deliberate - one pad's interrupt and one CPU line are not the same resource, and
+/// several pads normally share a line.
+///
+/// Stale latched status is cleared first. A pad that saw an edge before its interrupt was armed
+/// otherwise fires immediately on enable, which looks exactly like a real event.
+pub fn setInterrupt(pin: u8, t: IntrType, line: IntrLine) void {
+ std.debug.assert(pin <= max_pin);
+ clearInterrupt(pin);
+ pin_cfg.at(pin).modify(.{
+ int_type.is(@intFromEnum(t)),
+ int_ena.is(if (t == .disable) 0 else @as(u32, 1) << @intFromEnum(line)),
+ });
+}
+
+/// Disarm, leaving the trigger type alone so it can be re-enabled unchanged.
+pub fn disableInterrupt(pin: u8) void {
+ pin_cfg.at(pin).modify(.{int_ena.is(0)});
+}
+
+pub fn interruptPending(pin: u8, line: IntrLine) bool {
+ const b = Bank.of(pin);
+ const i: u32 = @intFromEnum(line);
+ return b.pick(intr_status.at(i), intr_status1.at(i)).raw() & b.mask() != 0;
+}
+
+/// Every pad currently interrupting on `line`, as a 57-bit mask in two halves. One read of each
+/// register, so a handler can dispatch the whole set without re-reading between pads.
+pub fn pendingMask(line: IntrLine) struct { low: u32, high: u32 } {
+ const i: u32 = @intFromEnum(line);
+ return .{ .low = intr_status.at(i).raw(), .high = intr_status1.at(i).raw() };
+}
+
+pub fn clearInterrupt(pin: u8) void {
+ const b = Bank.of(pin);
+ b.pick(status_w1tc, status1_w1tc).writeRaw(b.mask());
+}
+
+pub fn clearInterrupts(low: u32, high: u32) void {
+ if (low != 0) status_w1tc.writeRaw(low);
+ if (high != 0) status1_w1tc.writeRaw(high);
+}
+
+/// Everything a pin needs to be a plain push-pull output, in the order the hardware wants: select
+/// the pad's function before enabling the driver, so the pin never spends a moment driven by
+/// whatever peripheral the IO MUX happened to be pointing at.
+pub fn configureOutput(pin: u8, opts: struct {
+ drive: Drive = .medium,
+ /// Enable the input buffer too, so the pin can be read back.
+ readback: bool = false,
+}) void {
+ setFunction(pin, .gpio);
+ // Point the matrix at the GPIO peripheral: a pad left routed to whatever signal was there
+ // before is the failure this line prevents.
+ func_out_sel.at(pin).modify(.{ out_sel.is(matrix_gpio_signal), oen_sel.is(0) });
+ pad.at(pin).modify(.{
+ fun_drv.is(@intFromEnum(opts.drive)),
+ fun_ie.is(@intFromBool(opts.readback)),
+ fun_wpu.is(0),
+ fun_wpd.is(0),
+ });
+ outputEnable(pin);
+}
+
+/// A plain input: driver off, input buffer on, optional pull.
+pub fn configureInput(pin: u8, opts: struct { pull: Pull = .none }) void {
+ outputDisable(pin);
+ setFunction(pin, .gpio);
+ pad.at(pin).modify(.{
+ fun_ie.is(1),
+ fun_wpu.is(@intFromBool(opts.pull == .up)),
+ fun_wpd.is(@intFromBool(opts.pull == .down)),
+ });
+}
+
+// ------------------------------------------------------------------------------- GPIO matrix
+
+/// The GPIO matrix: 256 peripheral output signals, any of which can be routed to any pad. This is
+/// how a UART reaches a pin that has no direct IO MUX function for it.
+const func_out_sel = mmio.RegArray(
+ regs.GPIO_FUNC0_OUT_SEL_CFG_REG,
+ regs.GPIO_FUNC1_OUT_SEL_CFG_REG,
+ pin_count,
+);
+// The input side of the matrix, indexed by *signal* rather than by pad: GPIO_FUNCn_IN_SEL_CFG
+// selects which pad feeds peripheral input signal n. That is the opposite indexing from
+// `func_out_sel` above, and it is why the two arrays exist separately.
+//
+// The base is FUNC1's address minus one word, not FUNC1's address. gpio_struct.h:849 declares
+// `func_in_sel_cfg[256]` and notes func0 is reserved, so ESP-IDF's register header defines no
+// GPIO_FUNC0_IN_SEL_CFG_REG at all - the array starts at +0x158 with a name-less word. Anchoring
+// on FUNC1 with a count of 256 is off by one in both directions: `at(n)` would configure signal
+// n+1, and `at(255)` would land on GPIO_FUNC0_OUT_SEL_CFG_REG (+0x558) and start driving a pad.
+// Bounds checked against the headers: FUNC255_IN_SEL_CFG_REG is +0x554 = 0x158 + 4*255.
+const func_in_sel = mmio.RegArray(
+ regs.GPIO_FUNC1_IN_SEL_CFG_REG - 4,
+ regs.GPIO_FUNC1_IN_SEL_CFG_REG,
+ 256,
+);
+
+const out_sel = Field.of(regs.GPIO_FUNC0_OUT_SEL_S, regs.GPIO_FUNC0_OUT_SEL_V);
+const oen_sel = Field.of(regs.GPIO_FUNC0_OEN_SEL_S, regs.GPIO_FUNC0_OEN_SEL_V);
+
+// The input side's three fields. All of GPIO_FUNCn_IN_SEL_CFG's fields share these shifts, so as
+// with the pad registers one macro triple describes all 256.
+const in_sel = Field.of(regs.GPIO_FUNC1_IN_SEL_S, regs.GPIO_FUNC1_IN_SEL_V);
+const in_inv_sel = Field.of(regs.GPIO_FUNC1_IN_INV_SEL_S, regs.GPIO_FUNC1_IN_INV_SEL_V);
+/// 1 = take this signal from the GPIO matrix, 0 = from the pad's direct IO MUX function.
+const sig_in_sel = Field.of(regs.GPIO_SIG1_IN_SEL_S, regs.GPIO_SIG1_IN_SEL_V);
+
+/// Writing this value instead of a peripheral signal index means "the GPIO peripheral drives this
+/// pad", which is the matrix's way of expressing plain GPIO output. It comes from IDF's own signal
+/// map because it is chip-specific: 256 here, 128 on the ESP32-S3.
+pub const matrix_gpio_signal: u32 = regs.SIG_GPIO_OUT_IDX;
+
+/// Route a peripheral output signal to a pad through the matrix, and let that peripheral own the
+/// pad's output enable.
+///
+/// `OEN_SEL` reads backwards from its name, and the differential test against ESP-IDF's LL is what
+/// caught it: 1 means "use GPIO_ENABLE_REG[n] as the output enable", 0 means "use the peripheral's
+/// own output enable signal" (gpio_reg.h, GPIO_FUNC0_OEN_SEL). A routed peripheral must have 0 - its
+/// OE is part of the signal being routed. The first version of this function set 1 and then set the
+/// matching GPIO_ENABLE bit to compensate, which worked by the wrong mechanism and left the pad
+/// latently output-enabled: clear OEN_SEL later and the pin would start driving on its own.
+pub fn matrixOut(pin: u8, signal: u32) void {
+ std.debug.assert(pin <= max_pin);
+ setFunction(pin, .gpio);
+ func_out_sel.at(pin).modify(.{ out_sel.is(signal), oen_sel.is(0) });
+}
+
+/// Route a pad to a peripheral *input* signal through the matrix.
+///
+/// Indexed by signal, not by pin, which is the opposite of `matrixOut`: one pad may feed any number
+/// of input signals, but a signal has exactly one source. The three writes are one word, where
+/// gpio_ll.h:613-618 uses three bitfield stores; the resulting word is identical and nothing here
+/// depends on the intermediate states, whereas a driver that read the register back between them
+/// could observe a signal sourced from the wrong pad.
+///
+/// This does not enable the pad's input buffer - `setInputEnable` does, and a routed input with
+/// `fun_ie` clear reads as a constant. Callers that want the pad readable must do both.
+pub fn matrixIn(pin: u8, signal: u32) void {
+ std.debug.assert(pin <= max_pin or pin == matrix_const_zero or pin == matrix_const_one);
+ std.debug.assert(signal < 256);
+ func_in_sel.at(signal).modify(.{
+ in_sel.is(pin),
+ in_inv_sel.is(0),
+ sig_in_sel.is(1),
+ });
+}
+
+/// Where a peripheral input signal is sourced from. The read side of `matrixIn`, for a diagnostic
+/// that has to distinguish "routed to the wrong pad" from "not routed at all" - the two look the
+/// same from the peripheral's end.
+pub const MatrixIn = struct {
+ /// A pad index, or `matrix_const_zero`/`matrix_const_one`. Meaningless when `from_matrix` is
+ /// false: the field keeps its reset value in that case, which can read like a deliberate
+ /// tie-high and is not one.
+ pin: u8,
+ inverted: bool,
+ /// `sig_in_sel`. False means the matrix is bypassed entirely and the signal comes from the
+ /// pad's direct IO MUX function - which for a peripheral that has none is undefined.
+ from_matrix: bool,
+};
+
+pub fn matrixInSource(signal: u32) MatrixIn {
+ std.debug.assert(signal < 256);
+ const w = func_in_sel.at(signal).raw();
+ return .{
+ .pin = @intCast((w >> in_sel.shift) & in_sel.unshiftedMask()),
+ .inverted = w & in_inv_sel.mask() != 0,
+ .from_matrix = w & sig_in_sel.mask() != 0,
+ };
+}
+
+/// Two values of `matrixIn`'s `pin` that are not pins: they tie the signal to a constant level
+/// inside the matrix. `gpio_reg.h:3717-3719` documents the encoding on the register itself -
+/// "s=0-56: connect GPIO[s] to this port. s=0x3F: set this port always high level. s=0x3E: set
+/// this port always low level" - and `soc/gpio_pins.h:13-14` gives them the names ESP-IDF's
+/// drivers use. They are chip-specific: 0x38/0x30 on the ESP32, 0x1E/0x1F on the C3.
+///
+/// This is how an unwired peripheral input gets a defined level. Leaving one alone is not
+/// equivalent: `in_sel` does default to 0x3F, but `sig_in_sel` defaults to 0, which bypasses the
+/// matrix entirely and takes the signal from the pad's direct IO MUX function - which for a
+/// peripheral that has none is not a constant anything. `hal/sdmmc.zig` needs both of these for
+/// slot 1's card-detect and card-interrupt inputs.
+pub const matrix_const_one: u8 = 0x3f;
+pub const matrix_const_zero: u8 = 0x3e;
+
+test "bank arithmetic splits at 32, which is where the P4's second register begins" {
+ try std.testing.expectEqual(@as(u5, 20), Bank.of(20).bit);
+ try std.testing.expect(!Bank.of(20).high);
+ try std.testing.expectEqual(@as(u5, 0), Bank.of(32).bit);
+ try std.testing.expect(Bank.of(32).high);
+ try std.testing.expectEqual(@as(u5, 24), Bank.of(56).bit);
+ try std.testing.expectEqual(@as(u32, 1) << 24, Bank.of(56).mask());
+}