文件
phonerail/.claude/s3_pin_audit.py

198 行
8.8 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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())