# -*- coding: utf-8 -*- u"""ESP32-S3 引脚占用审计 —— 直接读 `五路直供电源板设计.md` §8.1 的 GPIO 表。 /c/Python314/python .claude/s3_pin_audit.py /c/Python314/python .claude/s3_pin_audit.py --octal # 按八线 PSRAM(R8) 严格判 /c/Python314/python .claude/s3_pin_audit.py --free # 顺便列出还空着的脚 ## 为什么自己写而不是用现成的 社区那个 `agodianel/esp32-claude-workbench` 的 `esp32_pin_audit`(78★) **判据全是 ESP32-classic 的,全仓库没有芯片型号分支**,放到 S3 上双向都错: FORBIDDEN_PINS = {6,7,8,9,10,11} # classic 的内部 flash;S3 上是普通 IO → 假警报 STRAPPING_PINS = {0,2,12,15} # S3 是 {0,3,45,46} INPUT_ONLY_PINS = {34,...,39} # S3 **没有**仅输入脚 → 假警报 (S3 真正的 flash/PSRAM 脚 26~32 它一个都不查 → **静默漏报,这条最要命**) 一个自信地给错答案的检查器比没有检查器更糟,所以这里按 Espressif 官方文档重写。 ## 判据出处 `docs.espressif.com/.../esp32s3/api-reference/peripherals/gpio.html`(2026-09-07 核对): · 物理存在 GPIO0~21 与 GPIO26~48,**22~25 不存在** · GPIO26~32 供 SPI flash / PSRAM,不建议他用 · 用八线(Octal) flash 或 PSRAM 时 **GPIO33~37 也被占**(接 SPIIO4~7 / SPIDQS) · strapping 脚是 **GPIO0 / 3 / 45 / 46** · GPIO19 / 20 默认给 USB-Serial-JTAG,改作普通 IO 则 USB-JTAG 失效 · ADC1 = GPIO1~10,ADC2 = GPIO11~20(**ADC2 在 Wi-Fi 工作时不可用**) · **没有任何仅输入脚**(这一条与 ESP32-classic 不同) """ from __future__ import print_function import argparse import io import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) DOC = os.path.join(HERE, '..', u'五路直供电源板设计.md') # --- ESP32-S3 事实表(改这里要附官方出处)---------------------------------- EXISTS = set(range(0, 22)) | set(range(26, 49)) # 22~25 不存在 FLASH_PSRAM = set(range(26, 33)) # 26~32 恒占 OCTAL_ONLY = set(range(33, 38)) # 33~37 仅八线版占用 STRAPPING = {0: u'boot 模式', 3: u'JTAG 源选择', 45: u'VDD_SPI 电压选择', 46: u'boot / ROM 日志'} USB_JTAG = {19: u'USB D−', 20: u'USB D+'} ADC1 = set(range(1, 11)) ADC2 = set(range(11, 21)) UART0 = {43: u'U0TXD', 44: u'U0RXD'} def parse_doc_pins(path): u"""从设计文档的 §8.1 表里抓 `| ... | IOxx | ... | `信号` | ...` 行。 表里有历史版本的重复表格,同一个 IO 可能出现多次 —— 以**最后一次**为准 (文档是顺序追加的,后面的是改版后的定稿)。 """ if not os.path.exists(path): return None, u'找不到设计文档 %s' % path txt = io.open(path, encoding='utf-8', errors='replace').read() pins = {} # 表列:| 模块脚 | GPIO | 方向/边 | 信号 | 去哪儿 | # ⚠️ 不能用「一行一个 IO」的正则:`| 13 / 14 | IO19 / IO20 | 东 | # `USB_DM` / `USB_DP` |` 这种**一行两脚**会整行匹配失败 —— # 结果 IO19/20 既不在已分配里、又被算进「空闲脚」,正是静默漏报。 # 所以按列切开,把该列里的 IO 号和信号名成对取出。 for line in txt.splitlines(): if not line.startswith(u'|'): continue cells = line.split(u'|') if len(cells) < 6: continue nums = [int(x) for x in re.findall(r'IO(\d{1,2})\b', cells[2])] if not nums: continue sigs = re.findall(r'`([^`]+)`', cells[4]) if not sigs: # 信号列没打反引号的,退回整格文本 s = re.sub(r'[*\s]', u'', cells[4]) sigs = [s] if s else [] for i, n in enumerate(nums): sig = sigs[i] if i < len(sigs) else (sigs[-1] if sigs else u'') sig = sig.strip().strip(u'*') if sig and sig not in (u'同', u'—'): pins[n] = sig return pins, None def audit(pins, octal): findings = [] # (等级, GPIO, 信号, 说明) reserved = set(FLASH_PSRAM) if octal: reserved |= OCTAL_ONLY for n in sorted(pins): sig = pins[n] if n not in EXISTS: findings.append((u'ERROR', n, sig, u'ESP32-S3 上**没有** GPIO%d(22~25 是空缺)' % n)) continue if n in FLASH_PSRAM: findings.append((u'ERROR', n, sig, u'SPI flash / PSRAM 恒占(26~32),拿来做 IO 会起不来')) elif n in OCTAL_ONLY: lvl = u'ERROR' if octal else u'WARN' findings.append((lvl, n, sig, u'八线(R8/Octal) flash/PSRAM 版占用 33~37;' u'换 R8 料就冲突%s' % (u'' if octal else u'(当前按非八线判)'))) if n in STRAPPING: findings.append((u'WARN', n, sig, u'strapping 脚(%s)—— 外部电路不能在上电瞬间把它拉错' % STRAPPING[n])) if n in USB_JTAG: findings.append((u'INFO', n, sig, u'USB-Serial-JTAG 的 %s,占了就没有原生 USB 调试口' % USB_JTAG[n])) if n in UART0: findings.append((u'WARN', n, sig, u'ROM 默认 UART0 %s,占了会跟启动日志抢线' % UART0[n])) # ADC2 只在真的当模拟输入用时才有 Wi-Fi 冲突 if n in ADC2 and re.search(r'NTC|ADC|AIN|VSENSE|TEMP', sig, re.I): findings.append((u'ERROR', n, sig, u'落在 ADC2(GPIO11~20),**Wi-Fi 一开 ADC2 就读不了**;' u'模拟量要挪到 ADC1 = GPIO1~10')) return findings, reserved def main(): ap = argparse.ArgumentParser() ap.add_argument('--octal', action='store_true', help=u'按八线 PSRAM(WROOM-1 R8)严格判,33~37 视为占用') ap.add_argument('--free', action='store_true', help=u'列出还空着的脚') ap.add_argument('--doc', default=DOC) a = ap.parse_args() pins, err = parse_doc_pins(a.doc) if err: print(err) return 2 if not pins: print(u'在文档里没抓到 IOxx 行 —— §8.1 的表格式可能改了,先看一眼再说。') return 2 print(u'从 %s 抓到 %d 个已分配 GPIO:' % (os.path.basename(a.doc), len(pins))) print(u' ' + u', '.join(u'IO%d=%s' % (n, pins[n]) for n in sorted(pins))) print() findings, reserved = audit(pins, a.octal) order = {u'ERROR': 0, u'WARN': 1, u'INFO': 2} findings.sort(key=lambda f: (order[f[0]], f[1])) if not findings: print(u'✓ 没有发现问题。') else: icon = {u'ERROR': u'❌', u'WARN': u'⚠️ ', u'INFO': u'ℹ️ '} for lvl, n, sig, msg in findings: print(u'%s [%-5s] IO%-2d %-16s %s' % (icon[lvl], lvl, n, sig, msg)) ne = sum(1 for f in findings if f[0] == u'ERROR') nw = sum(1 for f in findings if f[0] == u'WARN') print(u'\n合计:ERROR %d,WARN %d,INFO %d' % (ne, nw, len(findings) - ne - nw)) # 冲突:同一个脚被分给两个信号(parse 已经按最后一次覆盖,这里查文档正文里 # 是否还有别的表把同一信号挂到两个脚上) rev = {} for n, s in pins.items(): rev.setdefault(s, []).append(n) dup = {s: v for s, v in rev.items() if len(v) > 1} if dup: print(u'\n⚠️ 同一信号出现在多个脚上(多半是文档里的历史表没清):') for s, v in sorted(dup.items()): print(u' %-16s → %s' % (s, ', '.join(u'IO%d' % x for x in sorted(v)))) if a.free: used = set(pins) free = sorted(EXISTS - used - reserved - set(STRAPPING) - set(UART0)) print(u'\n还空着的脚(已排除 flash/PSRAM %s、strapping、UART0):' % (u'26~37' if a.octal else u'26~32')) print(u' ' + u', '.join(u'IO%d' % n for n in free)) # ⚠️ ADC1 里也要把 strapping 剔掉:IO3 是 ADC1_CH2,但它是 JTAG 源选择 # strapping 脚,挂个 NTC 分压上去会在上电瞬间把它拉到中间电平。 adc1_free = sorted(ADC1 - used - set(STRAPPING) - reserved) adc1_strap = sorted((ADC1 - used) & set(STRAPPING)) print(u' 其中 ADC1(能在 Wi-Fi 下用的模拟脚)还剩:%s' % (u', '.join(u'IO%d' % n for n in adc1_free) if adc1_free else u'无')) if adc1_strap: print(u' (%s 也是 ADC1,但同时是 strapping 脚,接模拟量要先确认上电电平)' % u', '.join(u'IO%d' % n for n in adc1_strap)) return 1 if ne else 0 if __name__ == '__main__': sys.exit(main())