硬件模块与通信协议基础
一句话结论
所有协议的本质都是"用电平变化表达比特"。吃透 GPIO / UART / I2C / SPI 这四条,90% 的模块都能接;剩下 10% 是 PWM(输出模拟量)、ADC(读模拟量)和 CAN(长距离抗干扰)。遇到模块先问三句:几根线、什么电平、有没有上拉——这三句话能解决大半接线问题。
一、GPIO:一切的基础
1.1 原理速览
GPIO(General Purpose Input/Output)是软件可控的数字引脚,只有两种状态:高电平(逻辑 1,约等于 VCC)与低电平(逻辑 0,约等于 0 V)。
| 概念 | 含义 | 什么时候必须关心 |
|---|---|---|
| 推挽输出(Push-Pull) | 内部两个 MOS 管,能主动拉高也能主动拉低 | 驱动 LED、继电器控制端、绝大多数普通输出 |
| 开漏输出(Open-Drain) | 只能主动拉低,拉高靠外部上拉电阻 | I2C 必须开漏(多设备共享总线,避免两设备一个拉高一个拉低直接短路) |
| 上拉 / 下拉 | 通过电阻把引脚默认拉到 VCC / GND | 按键输入、开漏总线、避免上电瞬间浮空误触发 |
| 浮空输入(Floating) | 什么都不接,电平不确定 | 要避免:浮空 IO 会随机翻转、增加功耗、EMC 差 |
| 灌电流(Sink) | 电流从外部流进引脚再到 GND(引脚拉低时点亮 LED) | 多数 MCU 的灌电流能力强于拉电流 |
| 拉电流(Source) | 电流从引脚流出驱动负载(引脚拉高时点亮 LED) | 驱动能力通常弱一些,以芯片数据手册为准 |
⚠️ 注意:单个 GPIO 的输出电流典型为 8–20 mA,整片芯片所有 IO 的总电流也有限制(常见 100–200 mA 量级,以数据手册为准)。绝不能用 GPIO 直接驱动电机、继电器线圈、大功率灯珠——要么烧 IO,要么把整片拉复位。驱动大负载一律走三极管/MOS/驱动芯片,并且独立供电、只共地。
1.2 典型接线
| 场景 | 接线 | 说明 |
|---|---|---|
| LED(灌电流接法,推荐) | VCC → 电阻(220 Ω–1 kΩ)→ LED 正极 → LED 负极 → GPIO | GPIO 输出低电平时亮 |
| LED(拉电流接法) | GPIO → 电阻 → LED 正极 → LED 负极 → GND | GPIO 输出高电平时亮 |
| 按键(上拉输入) | GPIO 内部上拉 → 按键 → GND | 未按下为高,按下为低 |
| 按键(外部下拉) | VCC → 按键 → GPIO,同时 GPIO → 10 kΩ → GND | 少用,但抗干扰更好 |
| 继电器模块(带光耦的低电平触发模块) | VCC→5 V、GND→GND、IN→GPIO | 继电器线圈由模块独立供电,不能从 MCU 取电 |
LED 限流电阻计算:R = (VCC - Vf) / I。5 V 供电、红色 LED(Vf≈2.0 V)、想要 10 mA:R = (5 - 2.0) / 0.01 = 300 Ω,取标称 330 Ω。
1.3 代码示例
// Arduino C++:按键控制 LED(内部上拉 + 软件消抖)
const uint8_t PIN_LED = 2; // ESP32 DevKitC 板载 LED
const uint8_t PIN_KEY = 4;
volatile bool keyPressed = false;
uint32_t lastMs = 0;
void IRAM_ATTR isrKey() { // 中断服务函数要短,只做标记
keyPressed = true;
}
void setup() {
pinMode(PIN_LED, OUTPUT);
pinMode(PIN_KEY, INPUT_PULLUP); // 内部上拉:未按下=HIGH,按下=LOW
attachInterrupt(digitalPinToInterrupt(PIN_KEY), isrKey, FALLING);
Serial.begin(115200);
}
void loop() {
// 轮询消抖:连续两次读到低电平且间隔 > 20 ms 才认为按下
static int lastStable = HIGH;
int cur = digitalRead(PIN_KEY);
if (cur != lastStable) {
if (millis() - lastMs > 20) { // 20 ms 消抖窗口,机械按键一般 5–20 ms
lastStable = cur;
if (cur == LOW) Serial.println("key pressed");
}
lastMs = millis();
}
if (keyPressed) {
keyPressed = false;
digitalWrite(PIN_LED, !digitalRead(PIN_LED)); // 翻转
}
}
# MicroPython(ESP32 / RP2040 / 部分 STM32)
from machine import Pin
import time
led = Pin(2, Pin.OUT)
key = Pin(4, Pin.IN, Pin.PULL_UP) # 内部上拉
last = 1
last_ms = 0
while True:
cur = key.value()
now = time.ticks_ms()
if cur != last:
if time.ticks_diff(now, last_ms) > 20: # 20 ms 消抖
last = cur
if cur == 0:
print("key pressed")
led.value(not led.value())
last_ms = now
time.sleep_ms(5)
# 树莓派 / Linux(libgpiod v2.x,推荐)
import gpiod, time
from gpiod.line import Direction, Bias, Edge
chip = gpiod.Chip('/dev/gpiochip0')
req = chip.request_lines(
consumer="key-led",
config={
17: gpiod.LineSettings(direction=Direction.OUTPUT),
27: gpiod.LineSettings(direction=Direction.INPUT,
bias=Bias.PULL_UP,
edge_detection=Edge.FALLING),
}
)
while True:
# 阻塞等待边沿事件,超时 1 秒;比轮询省 CPU
if req.wait_edge_events(timeout=1):
for ev in req.read_edge_events():
print("falling on line", ev.line_offset)
req.set_values({17: gpiod.line.Value(1)})
time.sleep(0.2)
req.set_values({17: gpiod.line.Value(0)})
1.4 GPIO 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| LED 不亮 | 极性反了 / 电阻太大 / 引脚选错 | LED 长脚为正极;换 330 Ω 试;直接 digitalWrite 量引脚电压 |
| 按键不触发或疯狂触发 | 没上拉,引脚浮空 / 没消抖 | 用 INPUT_PULLUP;加 20 ms 消抖;按键线上并联 100 nF 也可硬件消抖 |
| GPIO 输出高电平只有 2 V 左右 | 负载太重(拉电流超限) | 改灌电流接法;或加三极管/MOS 驱动 |
| 引脚电平固定,改不动 | 该引脚是 strapping 引脚 / 被外设占用 / 烧了 | ESP32 的 GPIO0/2/12/15 等是 strapping 脚,避开;换引脚 |
| 一接继电器/Motor 就复位 | 大电流拉垮电源 / 没有续流二极管 | 独立供电 + 共地;继电器线圈并联续流二极管 |
| 芯片发热 | IO 短路 / 电源反接 | 立刻断电,蜂鸣档查 VCC-GND 是否短路 |
二、UART:最常用也最容易踩坑
2.1 原理速览
UART(Universal Asynchronous Receiver/Transmitter)是异步串行通信:没有时钟线,靠双方约定同样的波特率来采样数据。
一帧数据的格式:起始位(1 bit,低电平)+ 数据位(5–9 bit,通常 8)+ 校验位(0 或 1 bit)+ 停止位(1/1.5/2 bit,高电平)。
| 参数 | 常见值 | 要点 |
|---|---|---|
| 波特率 (Baud) | 9600、19200、38400、57600、115200、230400、460800、921600 | 双方必须一致。误差一般要求 < 2–3%,所以两边不能是"相近"的值。ESP8266 上电时会以 74880 波特率打印启动信息,这是它的特殊之处 |
| 数据位 | 8(最常用)、7(ASCII 文本)、9(部分多机通信) | 一般是 8 |
| 校验位 | None / Odd / Even / Mark / Space | 多数场景 None;Modbus 常用 Even |
| 停止位 | 1(常用)、1.5、2 | 一般 1 |
| 流控 | None / RTS-CTS / XON-XOFF | 高速或长数据时才需要硬件流控,多数模块不接 |
常见配置写法:115200 8N1 = 波特率 115200、8 数据位、无校验、1 停止位。
2.2 电平标准:TTL / RS232 / RS485 / RS422
| 标准 | 电平定义 | 传输距离(典型) | 拓扑 | 接线 | 典型芯片 | 用途 |
|---|---|---|---|---|---|---|
| TTL UART | 0 V = 逻辑 0,3.3 V 或 5 V = 逻辑 1(以 VCC 为参考) | < 1 m(板上/设备内) | 点对点 | TX、RX、GND(3 线) | 无(MCU 直出) | MCU 与模块、调试串口 |
| RS232 | -3…-15 V = 逻辑 1,+3…+15 V = 逻辑 0(负逻辑) | 10–15 m | 点对点 | TX、RX、GND | MAX232 / SP3232 | 老式工控设备、老 PC 串口 |
| RS422 | 差分 ±2 V 以上判定 | 约 1200 m | 点对多点(一发多收) | A/B、Y/Z 两对差分(4 线全双工) | MAX485 系列不行,需 MAX488/490 | 工业全双工长线 |
| RS485 | 差分:B>A 且差值 > +200 mV = 逻辑 1 | 约 1200 m(速率越低越远) | 多点总线(可 32 个节点起,用高阻抗收发器可到 128/256) | A(D+)、B(D-)一对差分(2 线半双工) | MAX485 / SN75176 / SP3485 | Modbus、工业现场总线、楼宇自控 |
为什么差分能抗干扰:TTL/RS232 用单端信号(对 GND 的电压),长线上 GND 压降和空间耦合的共模噪声会直接叠加到信号上;RS485/RS422 用两条线的电压差表示逻辑,干扰同时耦合到 A、B 两线上(共模),差值基本不变,接收器只认差值,于是噪声被抵消。这也是它能跑上千米的根本原因。
⚠️ 注意:RS232 的 ±12 V 直接接到 MCU 的 TTL 引脚会瞬间烧毁。RS485 的差分电平同样不能直连 MCU。必须经过 MAX232 / MAX485 这类**收发器(transceiver)**转换,且在 MCU 一侧测出来的是 TTL 电平。
2.3 典型接线
| 场景 | MCU 侧 | 模块侧 | 说明 |
|---|---|---|---|
| MCU ↔ 串口模块(GPS/蓝牙/LoRa) | TX | RX | 必须交叉 |
| RX | TX | 必须交叉 | |
| GND | GND | 必须共地,否则电平无参考 | |
| (VCC) | (VCC) | 电平一致才接;5 V 模块接 3.3 V MCU 的 RX 需分压/电平转换 | |
| MCU ↔ USB-TTL 下载器 | TX→RX、RX→TX、GND→GND | 同左 | 一般不要接 VCC,避免两个电源打架 |
| MCU ↔ RS485 模块 | TX/RX/DE(方向控制)/RO | MAX485 模块 | 半双工需一个 GPIO 控制收发方向 |
| MCU ↔ RS232 设备 | TX/RX | MAX232 模块 | 注意 DB9 的 2/3 脚定义也可能被做成直连 |
RS485 半双工方向控制:DE 和 RE(通常短接)拉高 = 发送,拉低 = 接收。发送前拉高,发完(等最后一个字节发完)再拉低。
2.4 代码示例
// Arduino C++:ESP32 使用 Serial2 与 GPS/蓝牙模块通信
// 硬件:Serial2 默认 RX=GPIO16, TX=GPIO17(ESP32);ESP32-S3/C3 需显式指定引脚
void setup() {
Serial.begin(115200); // USB 调试串口
Serial2.begin(9600, SERIAL_8N1, RXD2, TXD2); // 模块串口:9600 8N1
Serial.println("UART bridge start");
}
void loop() {
// USB 串口收到数据 → 转发给模块
while (Serial.available()) {
Serial2.write(Serial.read());
}
// 模块返回数据 → 转发到 USB 串口
while (Serial2.available()) {
Serial.write(Serial2.read());
}
}
// Arduino C++:RS485 半双工发送(Modbus RTU 请求帧示例)
HardwareSerial rs485(2);
// 读保持寄存器:从机地址 0x01,功能码 0x03,起始地址 0x0000,数量 0x0002
uint8_t req[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B}; // CRC 需按实际计算
void setup() {
pinMode(DE_PIN, OUTPUT);
digitalWrite(DE_PIN, LOW); // 默认接收
rs485.begin(9600, SERIAL_8N1, RXD2, TXD2);
}
void sendRequest() {
digitalWrite(DE_PIN, HIGH); // 切到发送
delayMicroseconds(10);
rs485.write(req, sizeof(req));
rs485.flush(); // 等发送完成(等移位寄存器空)
delayMicroseconds(500); // 按波特率留足够时间,115200 可更短
digitalWrite(DE_PIN, LOW); // 切回接收
}
void loop() {
sendRequest();
uint32_t t0 = millis();
while (millis() - t0 < 500) {
if (rs485.available()) Serial.printf("%02X ", rs485.read());
}
Serial.println();
delay(1000);
}
# MicroPython:UART 收发与 Modbus CRC16 计算
from machine import UART, Pin
import time
uart = UART(2, baudrate=9600, tx=17, rx=16, bits=8, parity=None, stop=1, timeout=100)
de = Pin(5, Pin.OUT, value=0) # RS485 方向控制
def crc16(data: bytes) -> bytes:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc.to_bytes(2, 'little') # Modbus 是低字节在前
def modbus_read(addr, reg, count):
pdu = bytes([addr, 0x03]) + reg.to_bytes(2, 'big') + count.to_bytes(2, 'big')
frame = pdu + crc16(pdu)
de.value(1)
time.sleep_us(10)
uart.write(frame)
time.sleep_us(500)
de.value(0)
return uart.read()
while True:
r = modbus_read(1, 0x0000, 2)
print("resp:", r.hex() if r else "timeout")
time.sleep(1)
# 树莓派 / Linux 上用 Python(pyserial)
# pip install pyserial
import serial, serial.tools.list_ports
# 列出所有串口(找设备最快的办法)
for p in serial.tools.list_ports.comports():
print(f"{p.device}\t{p.description}\tVID:PID={p.vid:04X}:{p.pid:04X}" if p.vid else p.device)
ser = serial.Serial(
port='/dev/ttyUSB0',
baudrate=115200,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1, # 读超时,避免 read() 永久阻塞
rtscts=False,
dsrdtr=False,
)
# ⚠️ pyserial 打开串口时 DTR 会拉低,导致 ESP32/Arduino 复位。不想要就:
# ser = serial.Serial(..., dsrdtr=False)
# 或在 open 后立即:ser.setDTR(False); ser.setRTS(False)
ser.write(b"AT\r\n")
line = ser.readline()
print("recv:", line)
# 非阻塞读 + 十六进制打印(调试二进制协议时用)
while True:
if ser.in_waiting:
data = ser.read(ser.in_waiting)
print(data.hex(' '))
ser.close()
2.5 UART 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 串口全是乱码 | 波特率不一致(第一嫌疑) | 逐个试 9600/19200/38400/57600/115200;用逻辑分析仪量一位宽度反推 |
| 乱码但有规律(比如字符重复) | 波特率接近但不相等 | 确认两边都是标准值;检查时钟源配置(STM32 的 HSE_VALUE) |
| 完全收不到数据 | TX/RX 没交叉 / 没共地 / 模块没上电 | 交叉接;接 GND;量模块 VCC |
| 只收不发(或只发不收) | 有一根线断了 / 引脚被占用 | 蜂鸣档量线;换引脚 |
| 数据偶尔少字节 | 波特率过高 + 线太长 / 没流控 | 降波特率;缩短线;加硬件流控 |
| 接上后板子不断复位 | USB-TTL 的 DTR 触发了复位电路 | pyserial 里关 DTR/RTS;或断开 DTR 线 |
| ESP32 收不到但 Arduino 能收 | 引脚被 strapping 或 Flash 占用 | ESP32 的 GPIO6–11 接 Flash,禁用;换 GPIO |
| 3.3 V MCU 与 5 V 模块通信异常 | 电平不匹配,5 V 的高电平灌进 3.3 V IO | 模块 TX → MCU RX 用 2 kΩ + 1 kΩ 分压,或用 MOS 电平转换模块 |
| 长距离通信误码 | 用了 TTL 跑几米 | 改 RS485,两端加 120 Ω 终端电阻 |
三、I2C:两根线挂一堆设备
3.1 原理速览
I2C(Inter-Integrated Circuit,读"I-squared-C"或"I-two-C")是同步、半双工、多主多从的两线串行总线。
| 特性 | 说明 |
|---|---|
| 信号线 | SDA(数据)+ SCL(时钟),都需上拉 |
| 输出结构 | 必须开漏(Open-Drain),靠外部上拉电阻拉高 |
| 上拉电阻 | 典型 4.7 kΩ(100 kHz 标准模式);高速模式或总线电容大时可降到 2.2 kΩ 甚至 1 kΩ;上拉接到总线电压(3.3 V 或 5 V,谁的电压低接谁更保险) |
| 地址 | 7 位地址(最常用,范围 0x08–0x77),另有 10 位地址扩展(少见) |
| 8 位地址字节 | 硬件地址左移 1 位后加上读写位:写 = `addr<<1 |
| 速率 | 标准 100 kHz、快速 400 kHz、快速+ 1 MHz、高速 3.4 MHz |
| 传输距离 | 通常 < 1 m,板级总线;长距离需 I2C 缓冲器(如 P82B715)或转差分 |
| 仲裁 | 多主同时发送时靠"线与"仲裁(谁先发 1 却读到 0 就退出) |
为什么必须开漏 + 上拉:如果多个设备用推挽输出,A 设备输出高(内部拉到 VCC)、B 设备输出低(内部拉到 GND),电源会直接通过两个 MOS 管短路。开漏只拉低不拉高,配合上拉电阻形成"线与"逻辑:任何一设备拉低,总线就是低——这正是多设备共享总线和仲裁的基础。
3.2 典型接线
| 信号 | 说明 |
|---|---|
| SDA | MCU SDA ↔ 所有从机 SDA(并联) |
| SCL | MCU SCL ↔ 所有从机 SCL(并联) |
| GND | 必须共地 |
| VCC | 按模块电压接(3.3 V 或 5 V)。模块数量多时注意总电流 |
| 上拉 | SDA→VCC 4.7 kΩ,SCL→VCC 4.7 kΩ。总线上只需一组上拉,但很多模块自带(常为 10 kΩ 或 4.7 kΩ),多个模块并联后等效电阻变小,一般还能工作;若通信不稳可拆掉多余上拉 |
⚠️ 注意:3.3 V 与 5 V 模块混挂时,上拉电阻接到哪一边要谨慎。上拉到 5 V 会把 3.3 V 设备的 IO 拉到 5 V,可能损坏。统一上拉到 3.3 V,并确认 5 V 设备能识别 3.3 V 为高电平(多数 5 V CMOS 可以,但裕量小),必要时用双向 MOS 电平转换模块。
3.3 代码:地址扫描 + 读写
// Arduino C++:I2C 地址扫描(拿到任何 I2C 模块先跑这个)
void setup() {
Serial.begin(115200);
Wire.begin(SDA_PIN, SCL_PIN); // AVR 上写 Wire.begin()
Wire.setClock(100000); // 100 kHz,扫不到就降速试
Serial.println("\nI2C Scanner");
}
void loop() {
int found = 0;
Serial.println("Scanning...");
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
uint8_t err = Wire.endTransmission(); // 0 = 有应答
if (err == 0) {
Serial.printf(" found 0x%02X (%u)\n", addr, addr);
found++;
} else if (err == 4) {
Serial.printf(" error at 0x%02X\n", addr);
}
}
Serial.printf("total: %d device(s)\n\n", found);
delay(5000);
}
// Arduino C++:读写 I2C 设备寄存器(以 SSD1306 / 通用 EEPROM 为例)
// 写:设备地址 → 寄存器地址 → 数据
void i2cWriteReg(uint8_t addr, uint8_t reg, uint8_t val) {
Wire.beginTransmission(addr);
Wire.write(reg);
Wire.write(val);
uint8_t err = Wire.endTransmission();
if (err) Serial.printf("write err %u\n", err);
}
// 读:先写寄存器地址(不停止),再重新起始并读 N 字节
uint8_t i2cReadReg(uint8_t addr, uint8_t reg) {
Wire.beginTransmission(addr);
Wire.write(reg);
Wire.endTransmission(false); // false = Repeated Start,不释放总线
Wire.requestFrom(addr, (uint8_t)1);
if (Wire.available()) return Wire.read();
return 0xFF;
}
// 连续读多字节
void i2cReadBytes(uint8_t addr, uint8_t reg, uint8_t *buf, size_t len) {
Wire.beginTransmission(addr);
Wire.write(reg);
Wire.endTransmission(false);
Wire.requestFrom(addr, (uint8_t)len);
for (size_t i = 0; i < len && Wire.available(); i++) buf[i] = Wire.read();
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
i2cWriteReg(DEV_ADDR, 0x00, 0xAE); // SSD1306 关显示命令
uint8_t v = i2cReadReg(DEV_ADDR, 0x00);
Serial.printf("reg 0x00 = 0x%02X\n", v);
}
void loop() {}
# MicroPython:I2C 扫描 + 读写(machine.I2C 与软 I2C 都支持)
from machine import Pin, I2C
import time
# 硬件 I2C
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000)
# 软件 I2C(任意引脚,速率低但灵活)
# i2c = I2C(-1, scl=Pin(22), sda=Pin(21), freq=100000)
print("scan:", [hex(a) for a in i2c.scan()]) # 返回 7 位地址列表
ADDR = 0x3C
# 写
i2c.writeto(ADDR, bytes([0x00, 0xAE])) # 控制字节 0x00 表示后续是命令
# 写寄存器再读(带 repeated start)
i2c.writeto(ADDR, bytes([0x00]), False) # False = 不发送 STOP
data = i2c.readfrom(ADDR, 1)
print("read:", data.hex())
# 直接读/写内存地址(多数传感器用这个)
# data = i2c.readfrom_mem(ADDR, 0x75, 1) # 读 WHO_AM_I
# i2c.writeto_mem(ADDR, 0x6B, bytes([0x00])) # 唤醒 MPU6050
# 树莓派 / Linux:命令行工具(不用写代码就能验证)
# sudo apt install i2c-tools
i2cdetect -y 1 # 扫描总线 1(老 Pi 是 0)
i2cdetect -y 1 0x20 0x27 # 只扫一段地址
i2cdump -y 1 0x68 # dump 整个设备的寄存器(MPU6050)
i2cget -y 1 0x68 0x75 # 读单个寄存器(WHO_AM_I,应返回 0x68)
i2cset -y 1 0x68 0x6B 0x00 # 写寄存器(唤醒 MPU6050)
i2cdetect -l # 列出所有 I2C 总线
# 树莓派 / Linux:Python(smbus2,比老 smbus 好用)
# pip install smbus2
from smbus2 import SMBus, i2c_msg
with SMBus(1) as bus: # 1 = /dev/i2c-1
# 扫地址
found = []
for addr in range(0x03, 0x78):
try:
bus.read_byte(addr)
found.append(hex(addr))
except OSError:
pass
print("found:", found)
# 写寄存器
bus.write_byte_data(0x68, 0x6B, 0x00) # addr, reg, value
# 读寄存器
who = bus.read_byte_data(0x68, 0x75)
print(f"WHO_AM_I = 0x{who:02X}") # MPU6050 应为 0x68
# 连续读(block read,注意部分设备需要 repeated start)
msg_w = i2c_msg.write(0x68, [0x3B])
msg_r = i2c_msg.read(0x68, 14) # 加速度+温度+陀螺仪 14 字节
bus.i2c_rdwr(msg_w, msg_r)
print(list(msg_r))
3.4 I2C 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 扫不到任何地址 | 没接上拉电阻(最最常见)→ SDA/SCL 接反 → 没共地 → 设备没上电 | SDA/SCL 各接 4.7 kΩ 到 VCC;交换 SDA/SCL;量设备 VCC |
| 只扫到部分设备 | 地址冲突(两个同型号模块地址相同) | 改模块地址跳线(A0/A1/A2 焊盘);或用多路复用器 TCA9548A(一路 I2C 转 8 路) |
| 扫到的地址和数据手册不一致 | 手册给的是 8 位地址(含读写位) | 右移 1 位:0x78 >> 1 = 0x3C |
| 通信时好时坏 / 偶发 NACK | 上拉太弱(总线电容大)→ 线太长 → 速率太高 | 上拉换 2.2 kΩ;缩短线(< 30 cm);降速到 50 kHz 试 |
| 波形上升沿很缓(逻辑分析仪可见) | 上拉电阻太大 + 总线电容大 | 减小上拉;减少挂载设备数 |
| 单个设备工作,挂第二个就都不工作 | 地址冲突,或两个设备的上拉并联后过强导致低电平抬不够低 | 检查地址;上拉总等效不宜低于约 1 kΩ |
| 3.3 V/5 V 混挂后设备发烫 | 上拉到 5 V 灌入 3.3 V 设备 IO | 统一上拉到 3.3 V;加双向电平转换 |
程序卡死在 Wire.endTransmission() |
从机拉住 SCL 不释放(clock stretching 超时或异常) | 断电重启从机;给总线加复位;检查供电 |
| 长距离(> 1 m)完全不行 | I2C 本就不是为长线设计的 | 改 RS485;或用专用 I2C 差分/缓冲芯片 |
四、SPI:速度最快,线也最多
4.1 原理速览
SPI(Serial Peripheral Interface)是同步、全双工、主从结构的四线总线,由 Motorola 提出,速度远快于 I2C。
| 信号 | 全称 | 方向(主→从) | 说明 |
|---|---|---|---|
| SCK / SCLK | Serial Clock | 主出 | 主机提供时钟,速率可达数 MHz 到数十 MHz |
| MOSI | Master Out Slave In | 主出从入 | 主机发数据 |
| MISO | Master In Slave Out | 主入从出 | 从机回数据 |
| CS / NSS | Chip Select / Slave Select | 主出,低电平有效 | 每个从机一根,谁被选中谁应答 |
| DC / D-C(可选) | Data/Command | 主出 | 显示屏用:区分"命令"与"数据"(SSD1306、ILI9341) |
| IRQ / INT(可选) | Interrupt | 从出主入 | 从机主动通知主机(如触摸芯片、无线模块) |
| RESET(可选) | Reset | 主出 | 部分模块有 |
全双工意味着:主机每发一个字节,从机同时回一个字节。所以"读"操作本质上是"发一个哑元字节(通常 0x00 或 0xFF)换回一个真实字节"。这是 SPI 与 I2C 最大的思维差异。
多从机:所有从机共享 SCK/MOSI/MISO,各自独占一根 CS。同一时刻只能拉低一根 CS。被选中的从机才驱动 MISO,其他从机的 MISO 必须是高阻态(否则会打架)。
4.2 四种模式:CPOL 与 CPHA
| 模式 | CPOL(时钟极性) | CPHA(时钟相位) | 空闲时 SCK | 数据采样时刻 | 常见设备 |
|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 上升沿(第 1 个边沿) | 最常用:SD 卡、nRF24L01、多数传感器、SSD1306 |
| Mode 1 | 0 | 1 | 低电平 | 下降沿(第 2 个边沿) | 部分 ADC、LoRa(SX127x 常用 Mode 0) |
| Mode 2 | 1 | 0 | 高电平 | 下降沿(第 1 个边沿) | 少见 |
| Mode 3 | 1 | 1 | 高电平 | 上升沿(第 2 个边沿) | SD 卡(部分阶段)、MAX31855、部分显示屏、W25Qxx Flash |
⚠️ 注意:模式配错的典型症状不是"完全不通",而是"数据整体错位一位"(比如读出来全差一半,或者最高位丢失)。这是 SPI 故障里最好认也最容易被误判为"模块坏了"的现象。不确定就四种模式全试一遍。
另外还有 MSB First / LSB First(位序)与 8 位 / 16 位数据帧 两个参数,错位时也要排查。
4.3 典型接线(以 ESP32 + SSD1306 或 W25Qxx 为例)
| 信号 | ESP32(默认 VSPI) | Arduino Uno | 树莓派(SPI0) | 模块 |
|---|---|---|---|---|
| SCK | GPIO18 | D13 | GPIO11 (SCLK) | SCK / CLK / SCL |
| MISO | GPIO19 | D12 | GPIO9 (MISO) | MISO / SDO / SO / DO |
| MOSI | GPIO23 | D11 | GPIO10 (MOSI) | MOSI / SDI / SI / DI |
| CS | GPIO5(自选) | D10 | GPIO8 (CE0) | CS / NSS / CE |
| DC | GPIO4(显示屏用) | D9 | GPIO24(自选) | DC / D/C / A0 |
| RST | GPIO2(可选) | D8 | GPIO25(自选) | RES / RST |
| VCC | 3.3 V | 3.3 V(需电平转换给 3.3 V 模块) | 3.3 V | VCC |
| GND | GND | GND | GND | GND |
4.4 代码示例
// Arduino C++:SPI 读写(以 W25Qxx Flash 读 JEDEC ID 为例,Mode 0)
SPISettings spiCfg(1000000, MSBFIRST, SPI_MODE0); // 1 MHz, MSB 优先, Mode 0
void setup() {
Serial.begin(115200);
pinMode(PIN_CS, OUTPUT);
digitalWrite(PIN_CS, HIGH); // 片选默认拉高(不选中)
SPI.begin(PIN_SCK, PIN_MISO, PIN_MOSI, PIN_CS);
}
// 通用传输:发一个字节换回一个字节
uint8_t spiXfer(uint8_t tx) {
SPI.beginTransaction(spiCfg);
digitalWrite(PIN_CS, LOW);
uint8_t rx = SPI.transfer(tx);
digitalWrite(PIN_CS, HIGH);
SPI.endTransaction();
return rx;
}
// 读 JEDEC ID:发 0x9F,接着读 3 字节(厂商 + 容量类型 + 容量)
void readJedec(uint8_t *id) {
SPI.beginTransaction(spiCfg);
digitalWrite(PIN_CS, LOW);
SPI.transfer(0x9F);
id[0] = SPI.transfer(0x00); // 哑元字节换数据
id[1] = SPI.transfer(0x00);
id[2] = SPI.transfer(0x00);
digitalWrite(PIN_CS, HIGH);
SPI.endTransaction();
}
void loop() {
uint8_t id[3];
readJedec(id);
Serial.printf("JEDEC: %02X %02X %02X (容量约 %u MB)\n",
id[0], id[1], id[2], 1UL << (id[2] - 20)); // 容量编码以数据手册为准
delay(2000);
}
// Arduino C++:SPI 多从机(两个设备各一根 CS,切 CS 前必须 beginTransaction)
SPISettings cfgFlash(4000000, MSBFIRST, SPI_MODE0);
SPISettings cfgDisp (8000000, MSBFIRST, SPI_MODE3); // 不同设备可不同速率/模式
void selectDevice(uint8_t cs, SPISettings cfg) {
SPI.beginTransaction(cfg);
digitalWrite(cs, LOW);
}
void deselectDevice(uint8_t cs) {
digitalWrite(cs, HIGH);
SPI.endTransaction(); // 释放总线,允许下一个设备用不同配置
}
void setup() {
pinMode(CS_FLASH, OUTPUT); digitalWrite(CS_FLASH, HIGH);
pinMode(CS_DISP, OUTPUT); digitalWrite(CS_DISP, HIGH);
SPI.begin(18, 19, 23);
}
void loop() {
selectDevice(CS_FLASH, cfgFlash);
SPI.transfer(0x9F);
uint8_t mf = SPI.transfer(0x00);
deselectDevice(CS_FLASH);
selectDevice(CS_DISP, cfgDisp);
SPI.transfer(0xAA);
deselectDevice(CS_DISP);
delay(1000);
}
# MicroPython:SPI 读写
from machine import Pin, SPI
import time
spi = SPI(2, baudrate=1_000_000, polarity=0, phase=0,
bits=8, firstbit=SPI.MSB, sck=Pin(18), mosi=Pin(23), miso=Pin(19))
cs = Pin(5, Pin.OUT, value=1)
def xfer(tx: bytes) -> bytes:
cs.value(0)
rx = spi.read(len(tx), tx[0]) if len(tx) == 1 else bytearray(len(tx))
cs.value(1)
return rx
# 读写混合:write_readinto 是最常用的 API
def read_jedec():
cs.value(0)
out = bytearray(4)
spi.write_readinto(b'\x9f\x00\x00\x00', out)
cs.value(1)
return out[1:4]
print("JEDEC:", read_jedec().hex())
# 树莓派 / Linux:spidev
# sudo raspi-config → Interface Options → SPI → Enable;确认 /dev/spidev0.0 存在
# pip install spidev
import spidev
spi = spidev.SpiDev()
spi.open(0, 0) # bus 0, device 0(CE0)
spi.max_speed_hz = 1_000_000
spi.mode = 0 # CPOL=0, CPHA=0
spi.bits_per_word = 8
spi.lsbfirst = False
# 全双工传输:发几个字节就收回几个字节
resp = spi.xfer2([0x9F, 0x00, 0x00, 0x00])
print("JEDEC:", bytes(resp[1:4]).hex())
spi.close()
# 命令行验证 SPI 是否启用
ls /dev/spidev*
lsmod | grep spi_bcm2835
dmesg | grep -i spi
4.5 SPI 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
读到全是 0x00 |
MISO 没接 / 从机没被选中 / 从机没上电 | 量 CS 是否被拉低;查 MISO 接线;量从机 VCC |
读到全是 0xFF |
MISO 被上拉、从机没响应;或 MOSI 没接导致命令没送达 | 查 MOSI;确认命令码正确;确认 CS 时序 |
| 数据整体错位/位移 | SPI 模式(CPOL/CPHA)不对 | 四种模式全试 |
| 数据位序颠倒(如 0x68 变成 0x16) | MSB/LSB First 设反 | 改 firstbit / lsbfirst |
| 高速时不稳,低速正常 | 线太长 / 面包板寄生电容 / 未共地 | 缩短线(< 20 cm);加 GND 伴线;降速;加 22–100 Ω 串联电阻阻尼 |
| 多从机互相干扰 | 未选中的从机仍在驱动 MISO(其 CS 未拉高) | 确认所有 CS 初始化为高;检查从机是否要求 CS 全程保持低 |
| 显示屏白屏/花屏 | DC 引脚接错或未控制 / 复位时序不对 | 确认 DC 高低对应命令/数据;加复位延时 |
| SD 卡初始化失败 | 上电瞬间需 Mode 0 且速率 < 400 kHz | 初始化阶段降速到 250–400 kHz,初始化完再提速 |
五、PWM:用数字引脚输出"模拟量"
5.1 原理速览
PWM(Pulse Width Modulation)靠快速开关来改变一个周期内高电平所占的比例。
| 参数 | 定义 | 关系 |
|---|---|---|
| 频率 (Hz) | 每秒多少个完整周期 | T(周期) = 1 / f |
| 周期 (T) | 一个完整循环的时间 | 50 Hz → T = 20 ms;20 kHz → T = 50 µs |
| 占空比 (%) | 高电平时间 / 周期 | 占空比 0% = 常低,100% = 常高 |
| 脉宽 (ms/µs) | 高电平持续的实际时间 | 脉宽 = T × 占空比 |
| 分辨率 (bit) | 占空比能分多少档 | 8 位 = 256 档;10 位 = 1024 档;12 位 = 4096 档 |
5.2 两类典型参数的差异(最容易搞混的地方)
| 应用 | 频率 | 周期 | 有效脉宽/占空比范围 | 为什么 |
|---|---|---|---|---|
| 舵机 SG90 / MG996R | 50 Hz(固定) | 20 ms | 0.5 ms(0°)~ 2.5 ms(180°),中位 1.5 ms;对应占空比 2.5%~12.5% | 舵机内部电路只认脉宽宽度,不认占空比。频率错了舵机不转或抖动 |
| LED 调光 | 1 kHz – 20 kHz | 1 ms – 50 µs | 占空比 0–100% 全范围 | 频率太低(< 100 Hz)人眼会看到闪烁;太高(> 20 kHz)开关损耗增加、驱动响应跟不上。5 kHz 左右是常见折中 |
| 直流电机调速 | 5 kHz – 20 kHz | 200 µs – 50 µs | 0–100% | 太低会听到线圈"滋滋"声( audible);太高 MOS 管开关损耗大。10 kHz 常见 |
| 蜂鸣器(无源) | 1 kHz – 5 kHz | 按音调选 | 50% 方波 | 频率决定音高,占空比影响音量 |
| DAC 替代(RC 滤波) | 越高越好 | — | — | 频率远高于 RC 截止频率才能滤出平滑直流 |
5.3 代码示例
// Arduino C++:ESP32 的 LEDC 硬件 PWM(舵机 + LED 调光各一路)
// ESP32 Arduino 3.x 新版 LEDC API
const int SERVO_FREQ = 50; // 舵机必须 50 Hz
const int SERVO_RES = 14; // 14 位分辨率:(2^14 - 1) 对应 20 ms
const int LED_FREQ = 5000; // LED 调光 5 kHz
const int LED_RES = 10; // 10 位 = 1024 档
// 角度 → 占空比计数:脉宽 us → duty
uint32_t angleToDuty(int angle) {
// 0.5 ms ~ 2.5 ms 映射 0° ~ 180°
float us = 500.0 + (float)angle * (2000.0 / 180.0);
return (uint32_t)(us * (1 << SERVO_RES) / (1000000.0 / SERVO_FREQ));
}
void setup() {
Serial.begin(115200);
ledcAttach(SERVO_PIN, SERVO_FREQ, SERVO_RES);
ledcAttach(LEDC_PIN, LED_FREQ, LED_RES);
}
void loop() {
// 舵机 0° → 90° → 180° 扫动
for (int a = 0; a <= 180; a += 10) {
ledcWrite(SERVO_PIN, angleToDuty(a));
delay(200);
}
// LED 呼吸灯
for (int d = 0; d < 1024; d += 8) { ledcWrite(LEDC_PIN, d); delay(5); }
for (int d = 1023; d >= 0; d -= 8) { ledcWrite(LEDC_PIN, d); delay(5); }
}
// Arduino C++:Arduino Uno(AVR)的 Servo 库写法
Servo myservo;
void setup() {
myservo.attach(9); // D9/D10 支持 Servo 库(用 16 位定时器)
myservo.write(90); // 直接写角度,库内部处理 50 Hz 与脉宽
}
void loop() {
myservo.write(0); delay(1000);
myservo.write(90); delay(1000);
myservo.write(180); delay(1000);
}
# MicroPython:PWM(舵机 + LED)
from machine import Pin, PWM
import time
servo = PWM(Pin(18), freq=50, duty_u16=0)
led = PWM(Pin(19), freq=5000, duty_u16=0)
# duty_u16 是 0–65535 的 16 位值
def angle_to_duty(a):
us = 500 + a * (2000 / 180) # 0.5–2.5 ms
return int(us * 65535 / 20000) # 周期 20 ms = 20000 us
while True:
for a in range(0, 181, 10):
servo.duty_u16(angle_to_duty(a))
time.sleep(0.2)
for d in range(0, 65536, 1000): # 呼吸灯
led.duty_u16(d)
time.sleep_ms(5)
for d in range(65535, -1, -1000):
led.duty_u16(d)
time.sleep_ms(5)
# 树莓派:硬件 PWM 只有 GPIO12/13/18/19(PWM0/PWM1),
# gpiozero 的 PWMLED 是软件 PWM,舵机抖动大;精确舵机建议用 pigpio
from gpiozero import PWMLED, AngularServo
from time import sleep
led = PWMLED(17)
for i in range(100):
led.value = i / 100
sleep(0.02)
# Servo:min_pulse_width / max_pulse_width 单位是秒
s = AngularServo(18, min_angle=0, max_angle=180,
min_pulse_width=0.5/1000, max_pulse_width=2.5/1000)
s.angle = 0; sleep(1)
s.angle = 90; sleep(1)
s.angle = 180; sleep(1)
s.detach() # 停止发脉冲,舵机不再维持力矩(省电、防抖动)
5.4 PWM 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 舵机不转 | 频率不是 50 Hz / 脉宽不对 / 没共地 | 确认 50 Hz;脉宽 0.5–2.5 ms;GND 共地 |
| 舵机抖动、发烫 | 频率不对;软件 PWM 抖动;电源电流不足;堵转 | 用硬件 PWM(ESP32 LEDC / pigpio);舵机独立 5 V 供电;别让它顶着限位硬转 |
| 舵机转到一半卡住并有异响 | 机械限位,超出行程 | 减小角度范围;别用 0–180 全行程 |
| LED 调光看起来闪烁 | 频率低于 100 Hz | 提高到 1 kHz 以上 |
| LED 亮度不随占空比线性变化 | 人眼对亮度是对数/幂律感知 | 用查表做 gamma 校正(duty = max * (t^2.2)) |
| 占空比改了但没变化 | 引脚不支持 PWM / 未 attach / 定时器被占用 | 换引脚;ESP32 的 LEDC 有 16 通道限制 |
| 一开 PWM 就重启 | 负载电流太大拉垮电源 | 独立供电;加电容 |
| 电机发出滋滋声 | PWM 频率落在人耳听觉范围 | 提高到 15–20 kHz 以上 |
六、ADC:把模拟世界读进数字系统
6.1 原理速览
| 参数 | 含义 | 典型值 |
|---|---|---|
| 分辨率 | 用多少位表示一个采样值 | 8 位(256 档)、10 位(1024 档)、12 位(4096 档,STM32)、16 位(外接 ADS1115) |
| 参考电压 (Vref) | 满量程对应的电压 | 3.3 V、5 V、内部 1.1 V/2.048 V 基准、外部基准 |
| LSB | 最小可分辨电压 | LSB = Vref / 2^分辨率。3.3 V + 12 位 → 0.806 mV/档 |
| 采样率 (SPS) | 每秒采样次数 | Arduino Uno 约 10 kSPS(理论,实际更低);STM32 可达 MSPS 级;ADS1115 只有 860 SPS(但 16 位精度高) |
| 输入阻抗 | ADC 输入对信号源的要求 | 信号源阻抗高时需加运放跟随,否则读数偏低 |
换算公式:电压 = 读数 / (2^分辨率 - 1) × Vref(严格说是 读数 / 2^分辨率 × Vref,两者差异可忽略)。
6.2 用电阻分压测高于 VCC 的电压
V_in ── R1 ──┬── 到 ADC 引脚
│
R2
│
GND
V_adc = V_in × R2 / (R1 + R2)
V_in = V_adc × (R1 + R2) / R2
例:测 0–12 V,R1 = 30 kΩ、R2 = 7.5 kΩ → 分压比 7.5/(30+7.5) = 1/5,12 V 输入时 ADC 看到 2.4 V(在 3.3 V 量程内)。反算:V_in = V_adc × 5。
实用要点:
| 要点 | 说明 |
|---|---|
| 电阻精度 | 用 1% 精度的金属膜电阻,5% 碳膜会让测量误差大到没法用 |
| 电阻取值 | 太大(MΩ 级)受 ADC 输入阻抗影响读数偏低;太小(几百 Ω)白白耗电、发热。常用 10 kΩ – 100 kΩ 量级 |
| 加保护 | ADC 引脚并联一个 3.3 V 的钳位二极管/TVS,或在 ADC 与 GND 间加 5.1 V 稳压管,防止输入过压烧 IO |
| 加滤波 | ADC 引脚到 GND 并 100 nF 陶瓷电容,抑制噪声 |
| 校准 | 实际测一个已知电压(如万用表量的真实值),把比例系数写进代码 |
⚠️ 注意:分压电阻一旦失效(虚焊、错值),高压会直接灌进 ADC 引脚。用于测动力电/电池组的分压电路,务必加钳位保护并留足功率余量。
6.3 ESP32 的 ADC 非线性(必知坑)
| 坑 | 说明 | 应对 |
|---|---|---|
| 非线性 | ESP32 内置 ADC 在接近 0 V 和接近 Vref 两端误差明显,中间段相对线性 | 需要精度时用查表/分段线性校准;乐鑫提供 esp_adc_cal 库用 eFuse 里存的校准值做补偿 |
| ADC2 与 WiFi 冲突 | 开启 WiFi 时 ADC2(GPIO0/2/4/12-15/25-27)不可用,读出来是无效值 | 优先用 ADC1(GPIO32–39);或采样时短暂关 WiFi |
| 默认量程窄 | ESP32 原生 ADC 有效量程大约 0–1 V 附近(不同衰减档不同),需配置衰减 | 用 analogReadMilliVolts() 或 adc1_config_channel_atten() 配 ADC_ATTEN_DB_11(对应量程约 0–3.3 V 量级,具体以数据手册为准) |
| 噪声大 | 片上 ADC 受电源与数字电路干扰,读数跳动几 LSB 属正常 | 多次采样取平均;加去耦电容;采样瞬间避免大电流动作 |
| GPIO39 等仅输入 | ADC1 的部分引脚(GPIO34/35/36/39)只能输入,不能输出,且无内部上拉 | 别当普通 IO 用 |
6.4 代码示例
// Arduino C++:ESP32 ADC 采样 + 分压换算 + 滑动平均
const float DIV = (R1 + R2) / R2; // 分压倍数 = 5.0
void setup() {
Serial.begin(115200);
analogReadResolution(12); // 12 位 → 0–4095
analogSetAttenuation(ADC_11db); // 扩展量程(约 0–3.3 V,以数据手册为准)
}
uint32_t readAvg(int pin, int n) {
uint64_t sum = 0;
for (int i = 0; i < n; i++) {
sum += analogRead(pin);
delayMicroseconds(100);
}
return sum / n;
}
void loop() {
uint32_t raw = readAvg(ADC_PIN, SAMPLES);
uint32_t mv = analogReadMilliVolts(ADC_PIN); // ESP32 提供的校准后电压(mV)
float v_adc = mv / 1000.0;
float v_in = v_adc * DIV; // 还原真实电压
Serial.printf("raw=%u ADC=%u mV (%.3f V) Vin=%.3f V\n",
raw, mv, v_adc, v_in);
delay(500);
}
// Arduino C++:STM32(HAL)ADC 采样 + 内部温度传感器思路
// CubeMX 里把 ADC1 的通道配成对应引脚,生成代码后:
uint32_t readAdc(ADC_HandleTypeDef *hadc) {
HAL_ADC_Start(hadc);
if (HAL_ADC_PollForConversion(hadc, 100) == HAL_OK) {
uint32_t v = HAL_ADC_GetValue(hadc);
HAL_ADC_Stop(hadc);
return v;
}
HAL_ADC_Stop(hadc);
return 0xFFFFFFFF;
}
// 调用:float volt = readAdc(&hadc1) * 3.3f / 4095.0f; // 12 位,Vref=3.3V
# MicroPython:ADC 采样(注意 ESP32 需配置衰减)
from machine import ADC, Pin, atten
import time
adc = ADC(Pin(34))
adc.atten(ADC.ATTN_11DB) # 扩展量程到约 3.3 V(具体范围以数据手册为准)
adc.width(ADC.WIDTH_12BIT) # 0–4095
DIV = 5.0
while True:
raw = adc.read() # 12 位原始值
mv = adc.read_uv() // 1000 # 微伏转毫伏(校准后的值)
print(f"raw={raw} {mv} mV Vin={mv/1000*DIV:.3f} V")
time.sleep(0.5)
# 树莓派:外接 ADS1115(16 位 I2C ADC),树莓派本身没有 ADC
# pip install adafruit-circuitpython-ads1x15
import board, busio, time
import adafruit_ads1x15.ads1115 as ADS
from adafruit_ads1x15.analog_in import AnalogIn
i2c = busio.I2C(board.SCL, board.SDA)
ads = ADS.ADS1115(i2c, address=0x48)
ads.gain = 1 # ±4.096 V 量程;gain=2/3 → ±6.144 V
chan = AnalogIn(ads, ADS.P0) # A0 通道
while True:
print(f"raw={chan.value:6d} {chan.voltage:.4f} V")
time.sleep(0.5)
6.5 ADC 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 读数一直是 0 | 引脚接 GND / 传感器没供电 / 引脚配成输出 | 量引脚电压;查供电 |
| 读数一直是满量程(4095) | 输入超过 Vref / 引脚悬空(浮空会漂到满量程) | 确认输入在量程内;悬空引脚接下拉 |
| 读数乱跳 | 没滤波 / 电源纹波 / 信号源阻抗太高 | 加 100 nF 电容;多次平均;加电压跟随器(运放) |
| 读数有固定比例的偏差 | Vref 不准(USB 5 V 经 LDO 后可能只有 3.2 V) | 用万用表实测 Vref 并写进代码;或用内部基准/外部基准 |
| 接上 WiFi 后读数归零 | ESP32 用了 ADC2 通道 | 换到 ADC1(GPIO32–39) |
| 测高压时不准确甚至烧片 | 分压电阻精度差 / 无钳位保护 | 换 1% 金属膜电阻;加 TVS/稳压管钳位 |
| 采样速率上不去 | ADC 时钟分频、转换时间限制 | 降分辨率换速度;用 DMA + 定时器触发(STM32) |
| NTC/光敏读数与实际温度/光强不符 | 传感器本身非线性 | 用查表或 Steinhart-Hart 公式(NTC)换算 |
七、CAN:车载与工业的总线
7.1 原理速览
CAN(Controller Area Network)由 Bosch 为汽车设计,是差分、多主、带仲裁与错误检测的串行总线。
| 特性 | 说明 |
|---|---|
| 信号 | CAN_H 与 CAN_L 差分对。显性(逻辑 0):CAN_H ≈ 3.5 V,CAN_L ≈ 1.5 V(差值约 2 V);隐性(逻辑 1):两者都 ≈ 2.5 V(差值约 0 V)。显性覆盖隐性(线与) |
| 终端电阻 | 总线两端各一只 120 Ω(并联后总线等效 60 Ω)。少接、多接、接错位置都会导致通信失败 |
| 速率 | 125 kbps / 250 kbps / 500 kbps(车载常见)/ 1 Mbps(最高,距离最短)。距离与速率成反比:1 Mbps 约 40 m,125 kbps 约 500 m(以实际线缆与收发器数据手册为准) |
| 帧 ID | 标准帧 11 位 ID(0x000–0x7FF),扩展帧 29 位 ID。ID 越小优先级越高 |
| 数据长度 | 经典 CAN:0–8 字节;CAN FD:最多 64 字节 |
| 拓扑 | 线性总线(干线 + 短支线,支线要短,一般 < 0.3 m),不能做成星形 |
| 可靠性 | CRC 校验、位填充、错误帧、自动重发、错误计数器与总线关闭(bus-off)机制 |
| 应用 | 汽车 OBD-II、BMS、工业 PLC、机器人关节、AGV |
7.2 接线
| 组件 | 说明 |
|---|---|
| MCU + CAN 控制器 | ESP32 内置 TWAI(兼容 CAN 2.0);STM32F103/F4 内置 bxCAN;树莓派等需外接 MCP2515(SPI 转 CAN) |
| CAN 收发器 | 必须有。常用 TJA1050、SN65HVD230(3.3 V)、MCP2551、VP230 |
| 终端电阻 | 总线两端各 120 Ω。收发器模块上一般有跳线帽,中间节点要拔掉 |
| 线缆 | 双绞线(绞距越密抗干扰越好),特性阻抗约 120 Ω |
| 共地 | 长距离时建议加一根 GND 线把各节点地电位拉齐 |
节点A 节点B
MCU ── CAN_TX ──> 收发器 TXD 收发器 TXD <── CAN_TX ── MCU
<── CAN_RX ── RXD RXD ──> CAN_RX
CAN_H ───────────────────── CAN_H
CAN_L ───────────────────── CAN_L
├─ 120Ω(仅两端节点接)─┤
⚠️ 注意:CAN 收发器的 CAN_H/CAN_L 不能直接接 MCU 引脚(差分电平不兼容且电压可能超出 IO 耐压)。MCU 侧永远是 TTL 的 CAN_TX/CAN_RX,经收发器转成差分。
7.3 代码示例
// Arduino C++:ESP32 TWAI(内置 CAN 控制器)发送 + 接收
void setup() {
Serial.begin(115200);
twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT(
(gpio_num_t)CAN_TX, (gpio_num_t)CAN_RX, TWAI_MODE_NORMAL);
twai_timing_config_t t_config = TWAI_TIMING_CONFIG_500KBITS(); // 500 kbps
twai_filter_config_t f_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();
if (twai_driver_install(&g_config, &t_config, &f_config) == ESP_OK) {
Serial.println("TWAI driver installed");
}
if (twai_start() == ESP_OK) Serial.println("TWAI started");
}
void loop() {
// —— 发送:标准帧 ID=0x123,数据 8 字节 ——
twai_message_t tx;
tx.identifier = 0x123;
tx.extd = 0; // 0 = 标准帧(11 位 ID)
tx.rtr = 0; // 0 = 数据帧
tx.data_length_code = 8;
for (int i = 0; i < 8; i++) tx.data[i] = i;
if (twai_transmit(&tx, pdMS_TO_TICKS(100)) == ESP_OK) {
Serial.println("TX ok");
} else {
Serial.println("TX fail / bus off?");
}
// —— 接收 ——
twai_message_t rx;
if (twai_receive(&rx, pdMS_TO_TICKS(100)) == ESP_OK) {
Serial.printf("RX ID=0x%03X len=%d data=", rx.identifier, rx.data_length_code);
for (int i = 0; i < rx.data_length_code; i++) Serial.printf("%02X ", rx.data[i]);
Serial.println();
}
delay(1000);
}
# MicroPython:ESP32 的 TWAI 支持取决于固件版本;
# RP2040 / STM32 常用 MicroPython 的 pyb.CAN 或 machine.CAN(看端口实现)
# 下面以 STM32(pyb)为例
import pyb
can = pyb.CAN(1, pyb.CAN.NORMAL, baudrate=500000,
extframe=False, prescaler=1)
can.setfilter(0, pyb.CAN.MASK16, 0, (0, 0, 0, 0)) # 接收所有
can.send([1, 2, 3, 4, 5, 6, 7, 8], id=0x123, timeout=100)
if can.any(0):
data = can.recv(0)
print("id=%03X data=%s" % (data[0], bytes(data[3]).hex()))
# Linux(树莓派 + MCP2515 或 USB-CAN 适配器):SocketCAN 工具链
# sudo apt install can-utils
# 1) 配置 MCP2515(在 /boot/firmware/config.txt 加 dtoverlay,重启)
# dtoverlay=mcp2515-can0,oscillator=8000000,interrupt=25
# dtoverlay=spi-bcm2835
sudo ip link set can0 up type can bitrate 500000
ifconfig can0
# 2) 抓包(等同于 tcpdump)
candump can0
candump can0,123:7FF # 只显示 ID 0x123
candump -l can0 # 记录到文件(candump-*.log)
# 3) 发送
cansend can0 123#1122334455667788
cansend can0 123#R # 远程帧
# 4) 生成随机负载(压力测试)
cangen can0 -g 10 -I 123 -L 8
# 5) 回放
canplayer -I candump-*.log
# 6) 状态与错误统计(排查总线问题必看)
ip -details -statistics link show can0
# 关注:state ERROR-ACTIVE / bus-error / restart-ms / rx-over-errors
# Python(Linux SocketCAN)
# pip install python-can
import can
bus = can.interface.Bus(channel='can0', interface='socketcan', bitrate=500000)
msg = can.Message(arbitration_id=0x123, data=[1,2,3,4,5,6,7,8], is_extended_id=False)
bus.send(msg)
for m in bus:
print(f"ID=0x{m.arbitration_id:X} len={m.dlc} data={m.data.hex(' ')}")
7.4 CAN 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 完全收不到,一直重发 | 终端电阻缺失或多了 → 收发器没供电 → CAN_H/CAN_L 接反 | 两端各 120 Ω,用万用表量 CAN_H–CAN_L 应为约 60 Ω(断电测量) |
报 bus-off |
错误计数器溢出(波特率不一致、干扰大、电阻错) | 检查所有节点波特率一致;查电阻;ip link set can0 down && up 重启接口 |
ack 错误(发出去没人应答) |
总线上没有其他节点,或对方不在同一波特率 | 至少两个节点(且都要上电并已 start);核对波特率与采样点 |
| 干扰大、误码 | 支线太长 / 没用双绞线 / 靠近电机电源 | 缩短支线;用双绞线;远离动力线 |
| 量 CAN_H–CAN_L 不是 60 Ω | 终端电阻数量不对 | 应只有两端接,中间节点的跳线帽拔掉 |
| 只有 3.3 V 收发器却接 5 V 网络 | 电平裕量问题 | 统一使用同一电压等级的收发器(SN65HVD230 为 3.3 V) |
| 通信时断时续 | 地电位差过大(长距离) | 增加一根 GND 线;或用隔离型 CAN 收发器(如 ISO1050 + 隔离电源) |
八、1-Wire:一根线传数据与供电
8.1 原理与接线
1-Wire(Dallas/Maxim)用单根数据线 + 地线通信,靠严格的时隙(time slot)时序区分 0 和 1。
| 特性 | 说明 |
|---|---|
| 信号 | 单根 DQ,必须外接 4.7 kΩ 上拉到 VCC(开漏,同 I2C 原理) |
| 地址 | 每个器件有出厂 64 位全球唯一 ROM ID(含 8 位家族码 + 48 位序列号 + 8 位 CRC)→ 可在一根线上挂很多 DS18B20 |
| 供电 | 常规 3 线(VCC/GND/DQ);或寄生供电(VCC 接 GND,从 DQ 取电,长线/多节点时不推荐) |
| 速率 | 标准约 15.4 kbps;过驱动模式更快(以数据手册为准) |
| 距离 | 数米到数十米(取决于线缆电容与上拉);不适合长距离多节点 |
| 典型器件 | DS18B20 温度传感器、DS2401 序列号芯片、DS2431 EEPROM、iButton |
| 引脚(DS18B20,平面朝自己、引脚朝下,从左到右) | 接线 |
|---|---|
| 1 (GND) | GND |
| 2 (DQ) | MCU GPIO + 4.7 kΩ 上拉到 VCC |
| 3 (VDD) | 3.3 V 或 5 V(寄生供电时接 GND) |
8.2 代码示例
// Arduino C++:多个 DS18B20 按 ROM 地址读取
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
void setup() {
Serial.begin(115200);
sensors.begin();
Serial.printf("found %d device(s)\n", sensors.getDeviceCount());
// 打印每个器件的 64 位地址(做固定编号时很有用)
DeviceAddress addr;
for (uint8_t i = 0; i < sensors.getDeviceCount(); i++) {
if (sensors.getAddress(addr, i)) {
Serial.printf("dev %d: ", i);
for (uint8_t j = 0; j < 8; j++) Serial.printf("%02X", addr[j]);
Serial.println();
}
}
sensors.setResolution(12); // 9–12 位,12 位对应 0.0625°C,转换约 750 ms
sensors.setWaitForConversion(false); // 非阻塞:发命令后自己等
}
void loop() {
sensors.requestTemperatures();
delay(800); // 12 位分辨率需约 750 ms 转换时间
for (uint8_t i = 0; i < sensors.getDeviceCount(); i++) {
float t = sensors.getTempCByIndex(i);
Serial.printf("sensor %d = %.2f C\n", i, t);
}
delay(2000);
}
# MicroPython:DS18B20(内置驱动)
import time, onewire, ds18x20
from machine import Pin
ow = ds18x20.DS18X20(onewire.OneWire(Pin(4)))
roms = ow.scan()
print("devices:", [r.hex() for r in roms])
while True:
ow.convert_temp()
time.sleep_ms(750) # 等转换完成
for rom in roms:
print(rom.hex(), ow.read_temp(rom))
time.sleep(2)
# 树莓派:1-Wire 走内核模块(DS18B20 最省事的用法)
sudo raspi-config nonint do_onewire 0 && sudo reboot
# 或在 /boot/firmware/config.txt 加:dtoverlay=w1-gpio,gpiopin=4
# 加载模块
sudo modprobe w1-gpio
sudo modprobe w1-therm
ls /sys/bus/w1/devices/ # 28-xxxxxxxxxxxx 就是 DS18B20
cat /sys/bus/w1/devices/28-*/w1_slave
# 输出里 t=25312 表示 25.312 °C
8.3 1-Wire 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 找不到设备 | 没接 4.7 kΩ 上拉(第一嫌疑)→ 寄生供电没接好 → 线太长 | 加/换上拉电阻;改 3 线供电;缩短线 |
| 读出 85.0 °C | 上电后的默认值,转换还没开始或已失败 | 等转换完成(12 位需约 750 ms);检查供电 |
| 读出 -127 °C | 读取失败(开路 / 通信错误) | 查接线与上拉;降低上拉到 2.2 kΩ |
| 多个设备地址扫描不全 | 总线电容过大 | 减小上拉电阻;缩短线;用 DS2482(I2C 转 1-Wire 桥接芯片) |
| 温度明显偏高 | 传感器自热(转换时电流)或紧贴发热元件 | 降低采样频率;物理远离热源 |
九、USB 设备类:让 MCU 变成"某种 USB 设备"
9.1 三种常见设备类
| 类 | 全称 | 识别成什么 | 是否需要驱动 | 典型用途 | 速度瓶颈 |
|---|---|---|---|---|---|
| CDC / CDC-ACM | Communications Device Class | 虚拟串口(/dev/ttyACM*、COMx) |
Linux/macOS 免驱;Windows 10+ 多数免驱(部分需 .inf) | 打印日志、命令行控制台、固件升级 | 不受波特率限制(波特率设置被忽略),实际取决于 USB 速率 |
| HID | Human Interface Device | 键盘/鼠标/游戏手柄/自定义 HID | 全平台免驱 | 模拟键盘鼠标(BadUSB 类研究)、自定义控制面板、宏键盘 | 中断传输,全速 12 Mbps 下每 1 ms 一包(64 字节),低速更慢 |
| MSC | Mass Storage Class | U 盘 | 全平台免驱 | 数据导出、拖拽式固件升级(UF2)、虚拟 U 盘 | 批量传输,全速理论 ~1 MB/s 级 |
其他常见类:DFU(Device Firmware Upgrade,固件升级)、MIDI、Audio、WebUSB(浏览器直接访问)、RNDIS/ECM/NCM(USB 网卡)。
9.2 平台能力对照
| 平台 | 原生 USB | 可做的设备类 | 关键配置 |
|---|---|---|---|
| ESP32-S2 / S3 | 有(USB OTG) | CDC、HID、MSC、DFU、MIDI | Arduino 里 Tools → USB CDC On Boot: Enabled;ESP-IDF 用 TinyUSB 组件 |
| ESP32-C3 | 有(部分型号为 USB Serial/JTAG 外设) | CDC、HID | ARDUINO_USB_CDC_ON_BOOT=1 |
| ESP32(初代)/ ESP8266 | 无(需外挂 CH340/CP210x) | — | 只能用外挂串口芯片 |
| RP2040(Pico) | 有 | CDC、HID、MSC、UF2 | TinyUSB 已集成,Arduino/MicroPython 都支持 |
| STM32F103 / F4(带 USB 的型号) | 有(FS/HS) | CDC、HID、MSC、DFU | CubeMX 里 USB → Device (FS) + Middleware → USB_DEVICE,选 Class |
| Arduino Leonardo / Pro Micro(ATmega32U4) | 有 | CDC、HID(键盘鼠标库成熟) | Keyboard.h / Mouse.h |
| Arduino Uno R3(ATmega328P + 16U2) | 无(16U2 只做串口桥) | — | 需重刷 16U2 固件才能做 HID |
9.3 代码示例
// Arduino C++:ESP32-S3 用 TinyUSB 做 HID 键盘(会自动敲字,谨慎运行)
USBHIDKeyboard Keyboard;
void setup() {
USBSerial.begin(115200);
Keyboard.begin();
USBSerial.println("HID keyboard ready, typing in 5s...");
delay(5000);
Keyboard.println("hello from esp32-s3");
}
void loop() {
USBSerial.println("cdc tick");
delay(1000);
}
// Arduino C++:RP2040(Pico)HID 键盘 —— 需在 Tools → USB Stack 选 "Adafruit TinyUSB"
void setup() {
Keyboard.begin();
pinMode(0, INPUT_PULLUP); // GP0 接按键到 GND
}
void loop() {
if (digitalRead(0) == LOW) {
Keyboard.print("pico says hi");
delay(500); // 防止按一次输出一串
}
}
// MSC 虚拟 U 盘(Pico):Tools → Flash Size 里选带 "with FATFS" 或 "2MB (Sketch, 1MB FS)"
// #include <Adafruit_TinyUSB.h>
// #include <Adafruit_USBD_MSC.h>
// #include <FatFS.h>
# CircuitPython:把 Pico 变成 HID + CDC 同时工作(配置文件 boot.py)
# boot.py
import usb_hid, usb_cdc
usb_cdc.enable(console=True, data=True) # 开 CDC(控制台 + 数据通道)
usb_hid.enable((usb_hid.Device.KEYBOARD, usb_hid.Device.MOUSE))
# CircuitPython:code.py —— 读按键发键盘事件
import time, board, digitalio, usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode
kbd = Keyboard(usb_hid.devices)
btn = digitalio.DigitalInOut(board.GP0)
btn.switch_to_input(pull=digitalio.Pull.UP)
while True:
if not btn.value:
kbd.send(Keycode.A)
time.sleep(0.3)
# 主机侧验证 USB 设备类
lsusb # 列出设备与 VID:PID
lsusb -v -d 303a:8001 2>/dev/null | head -40 # 看描述符(Class 字段)
dmesg | tail -20 # 插入时的内核日志
ls -la /dev/ttyACM* # CDC 是否出现
ls /dev/input/by-id/ # HID 是否出现
udevadm info -q property -n /dev/ttyACM0 | grep -i id_
9.4 USB 故障排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 设备完全不被识别 | 数据线无数据功能 → 芯片无原生 USB → 端口坏了 | 换线;确认芯片支持原生 USB |
| 识别为"未知设备" | 描述符错误 / 供电不足 | 检查描述符;用带外接供电的 HUB |
| CDC 出现但打不开 | 另一程序占用(如串口监视器) | 关掉其他串口工具 |
| CDC 出现又消失 | 程序崩溃重启 / 供电不足 | 看日志;换口 |
| HID 键盘不输入 | 主机焦点不在文本输入框 / Keyboard.begin() 未调用 |
点开记事本再试;确认初始化 |
| Windows 提示驱动安装失败 | CDC 需 Zadig 装 WinUSB 驱动(部分场景) | 用 Zadig 给接口 0 装 WinUSB/USB Serial |
| MSC U 盘提示要格式化 | 文件系统未初始化 | 先跑一次格式化示例(FatFS 的 mkfs) |
| 上传程序后 USB 口消失 | 程序把 USB 引脚复用成 GPIO 了 | 按住 BOOT 重新下载;别占用 USB 专用引脚 |
十、协议横向对比与选型
| 协议 | 线数 | 速率(典型) | 距离 | 拓扑 | 全/半双工 | 寻址方式 | 上拉/终端 | 最适场景 | 主要短板 |
|---|---|---|---|---|---|---|---|---|---|
| GPIO | 1 | 翻转可达 MHz 级 | < 0.3 m | 点对点 | 单向/双向 | 引脚号 | 上/下拉按需 | 开关、按键、LED、使能 | 只传 1 bit |
| UART | 2(TX/RX)+GND | 9600 – 921600 bps | < 1 m(TTL)/ 1200 m(RS485) | 点对点(RS485 可多点) | 全双工(RS485 半双工) | 无(靠接线) | 无 | 调试口、GPS、蓝牙/LoRa/WiFi 模块、Modbus | 无时钟、无寻址、速率与距离矛盾 |
| I2C | 2(SDA/SCL) | 100 k / 400 k / 1 M | < 1 m | 多主多从 | 半双工 | 7 位地址(最多 112 个可用) | 4.7 kΩ 上拉 | 板上挂一堆传感器、OLED、RTC、EEPROM | 速度慢、距离短、地址易冲突 |
| SPI | 4 + 每从机 1 根 CS | 1 M – 80 Mbps | < 0.3 m | 一主多从 | 全双工 | 硬件 CS | 无(推挽) | 显示屏、SD 卡、Flash、高速 ADC、无线模块 | 线多、无应答无寻址、距离短 |
| PWM | 1 | 频率 1 Hz – 数十 MHz | < 1 m | 点对点 | 单向输出 | 引脚号 | 无 | 舵机、调光、电机调速、DAC 替代 | 只输出模拟效果,不能传数据 |
| ADC | 1 | 采样率 kSPS – MSPS | < 0.1 m(模拟信号极易受干扰) | 点对点 | 单向输入 | 通道号 | 无 | 电池电压、电位器、模拟传感器 | 精度受参考电压与噪声限制 |
| CAN | 2(差分) | 125 k – 1 Mbps | 40 m – 500 m+ | 多主总线 | 半双工 | 报文 ID(11/29 位) | 两端 120 Ω | 汽车、BMS、工业现场、机器人 | 数据只有 8 字节(经典)、需收发器 |
| 1-Wire | 1 + GND | ~15 kbps | 数米 – 数十米 | 多从 | 半双工 | 64 位 ROM ID | 4.7 kΩ 上拉 | DS18B20 多点测温、身份识别 | 慢、时序严格、长线麻烦 |
| USB | 4(含电源) | 12 Mbps(FS)/ 480 Mbps(HS) | < 5 m(无源) | 主从(星型,需 HUB) | 半双工(分时) | 设备地址 + 端点 | 差分 90 Ω 阻抗 | 连接 PC、HID、MSC、摄像头 | 协议栈复杂、要主机 |
选型口诀:
调试与人机 → UART(串口)
板上多传感器,引脚紧张 → I2C
要速度、要屏幕、要存储 → SPI
输出连续量(亮度/角度/转速)→ PWM
读连续量(电压/温度/光强)→ ADC
长距离、抗干扰、多节点 → CAN(或 RS485)
一根线多点测温 → 1-Wire
连电脑且要免驱 → USB HID / CDC
十一、常用模块速查表
11.1 基础执行器与输入器件
| 模块 | 接口 | 供电 | 关键参数 | 接线要点 | 常见坑 |
|---|---|---|---|---|---|
| LED(直插/贴片) | GPIO | — | Vf:红约 1.8–2.0 V、绿/黄约 2.0–2.2 V、蓝/白约 3.0–3.3 V | 串 220 Ω–1 kΩ 限流电阻 | 极性反了不亮(长脚/内部小片为正);不能不加电阻直连 IO,会烧 IO 和 LED |
| RGB LED(共阴/共阳) | 3×PWM | 5 V 或 3.3 V | 共阴:公共端接 GND,高电平点亮;共阳相反 | 每路各串电阻(R 路阻值要略大) | 务必确认共阴还是共阳,买错了逻辑全反 |
| 按键/轻触开关 | GPIO | — | 4 脚实际是两两相通的 2 脚 | 一端 GPIO(内部上拉),一端 GND | 必须上拉或下拉;加 20 ms 消抖 |
| 继电器模块(光耦版) | GPIO | 5 V(模块供电) | 触点容量常见 10 A/250 VAC 或 10 A/30 VDC | VCC/GND/IN 三线;触点侧与控制侧完全隔离 | 强电侧接线务必断电操作并加绝缘;控制端触发方式可能是高/低电平,看模块丝印 |
| 蜂鸣器(有源) | GPIO | 3.3/5 V | 内部含振荡源,给电就响,固定频率 | 串 NPN 三极管或 MOS 驱动(电流 > 20 mA) | 有源/无源买错:无源需要 PWM 才能响 |
| 蜂鸣器(无源) | PWM | 3.3/5 V | 需外部给 2–5 kHz 方波 | 同上有源,但信号脚给 PWM | 直流电不会响 |
⚠️ 注意:继电器控制 220 V 市电时,强电部分必须做绝缘处理(热缩管/电工胶带/专用外壳),接线前确认断电,通电后不要用身体任何部位触碰。新手建议先用低压直流(12 V 灯带)练手。
11.2 温湿度与环境传感器
| 模块 | 接口 | 精度(典型,以数据手册为准) | 量程 | 供电 | 采样间隔 | 特点 |
|---|---|---|---|---|---|---|
| DHT11 | 单总线(专用协议,非 1-Wire) | 湿度 ±5%RH,温度 ±2 °C | 湿度 20–90%RH,温度 0–50 °C | 3.3–5 V | ≥ 1 s(有的资料写 2 s) | 最便宜(¥3–5),精度差,整数输出。已属"能用但不推荐" |
| DHT22 / AM2302 | 单总线(同 DHT11 协议族) | 湿度 ±2%RH,温度 ±0.5 °C | 湿度 0–100%RH,温度 -40–80 °C | 3.3–5 V | ≥ 2 s | 性价比最好(¥10–18),精度够用 |
| SHT30 / SHT31 | I2C(0x44 或 0x45) | 湿度 ±2%RH,温度 ±0.3 °C | 湿度 0–100%RH,温度 -40–125 °C | 2.4–5.5 V | 可连续、最快约 1 ms 级 | 推荐:数字接口、精度高、响应快、有 CRC 校验 |
| BME280 | I2C(0x76/0x77)或 SPI | 温度 ±1 °C,湿度 ±3%RH,气压 ±1 hPa | 温度 -40–85 °C | 3.3 V | — | 温湿压三合一,可算海拔 |
| DS18B20 | 1-Wire | ±0.5 °C(-10–85 °C 区间) | -55–125 °C | 3.0–5.5 V | 12 位需约 750 ms | 多点测温首选,一根线挂十几个,防水探头版可测液体 |
| NTC 热敏电阻 | ADC | 需标定 | 常见 10 kΩ/3950 | — | — | 最便宜,但要自己做查表与分压 |
| 光敏电阻 / 光敏二极管 | ADC | 非线性 | — | — | — | 只做明暗判断,不做精确测量 |
| BH1750 | I2C(0x23/0x5C) | 数字光照度(lx) | 1–65535 lx | 3.3 V | — | 要测真实 lux 就用它 |
// Arduino C++:DHT22 读取(Adafruit 库)
DHT dht(DHTPIN, DHTTYPE);
void setup() { Serial.begin(115200); dht.begin(); }
void loop() {
float h = dht.readHumidity();
float t = dht.readTemperature();
if (isnan(h) || isnan(t)) { Serial.println("read fail"); }
else Serial.printf("T=%.1fC H=%.1f%%\n", t, h);
delay(2000); // DHT22 至少 2 秒
}
# MicroPython:SHT30(I2C,比 DHT 稳得多)
from machine import Pin, I2C
import time
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100000)
ADDR = 0x44
def read_sht30():
i2c.writeto(ADDR, b'\x2C\x06') # 高重复性单次测量,clock stretching 关闭
time.sleep_ms(20)
d = i2c.readfrom(ADDR, 6)
# 数据格式:temp_MSB temp_LSB temp_CRC hum_MSB hum_LSB hum_CRC
t_raw = (d[0] << 8) | d[1]
h_raw = (d[3] << 8) | d[4]
t = -45 + 175 * t_raw / 65535.0
h = 100 * h_raw / 65535.0
return t, h
while True:
t, h = read_sht30()
print(f"T={t:.1f}C H={h:.1f}%")
time.sleep(2)
11.3 显示模块
| 模块 | 接口 | 分辨率/规格 | 供电 | 地址/引脚 | 库(Arduino) | 特点 |
|---|---|---|---|---|---|---|
| OLED SSD1306 0.96" | I2C(0x3C 或 0x3D)或 SPI | 128×64 单色 | 3.3–5 V(多数模块带 LDO) | I2C:SDA/SCL | Adafruit SSD1306 + Adafruit GFX |
入门首选:自发光、对比度高、功耗低、3.3/5 V 通吃 |
| OLED SSD1306 1.3" | I2C/SPI | 128×64(SH1106 驱动也常见) | 3.3–5 V | 同上 | SH1106 需换 Adafruit SH110X 库 |
注意驱动芯片可能是 SH1106,用错库会显示偏移 |
| LCD1602 + I2C 转接板 | I2C(PCF8574,0x27 或 0x3F) | 16 字符 × 2 行 | 5 V(3.3 V 可能偏暗) | SDA/SCL | LiquidCrystal_I2C |
便宜(¥10–15),必须 5 V 供电;地址不对就扫 I2C |
| LCD2004 | I2C | 20 字符 × 4 行 | 5 V | 同上 | 同上 | 信息量大一些 |
| TFT ILI9341 2.4" | SPI | 240×320 彩色 | 3.3 V(需电平转换接 5 V MCU) | SCK/MOSI/CS/DC/RST | Adafruit ILI9341 / TFT_eSPI |
彩屏入门,必须 SPI,刷新率受限于 MCU |
| TFT ST7735 1.8" | SPI | 128×160 彩色 | 3.3 V | 同上 | Adafruit ST7735 |
体积小,适合穿戴 |
| 墨水屏(SSD1680 等) | SPI | 常见 2.13"/2.9" | 3.3 V | 同上 + BUSY | GxEPD2 |
断电保持显示、极省电;刷新慢(秒级)、不能频繁局部刷新 |
| 8×8 点阵 / MAX7219 模块 | SPI(级联) | 8×8 每块,可级联 | 5 V | DIN/CS/CLK | MD_MAX72xx |
做时钟、滚动字幕 |
| 数码管 TM1637 4 位 | 两线(类 I2C 非标准) | 4 位 7 段 | 5 V | CLK/DIO | TM1637Display |
显示数字最省事 |
// Arduino C++:SSD1306 I2C 显示(先跑 I2C 扫描确认地址)
Adafruit_SSD1306 oled(SCREEN_W, SCREEN_H, &Wire, -1);
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // 地址不对改 0x3D
Serial.println("SSD1306 alloc fail");
for (;;);
}
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(0, 0);
oled.println("Hello OLED!");
oled.setTextSize(2);
oled.println("128x64");
oled.display(); // 必须调用 display() 才会真正刷新到屏幕
}
void loop() {}
# MicroPython:SSD1306(I2C)
from machine import Pin, I2C
import ssd1306
i2c = I2C(0, scl=Pin(22), sda=Pin(21))
oled = ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C)
oled.fill(0)
oled.text("Hello", 0, 0)
oled.text("MicroPython", 0, 16)
oled.show() # 同样需要 show()
# 树莓派:LCD1602 + PCF8574 I2C 转接板
# sudo raspi-config → 启用 I2C;pip install smbus2;i2cdetect -y 1 确认地址
import smbus2, time
BUS = smbus2.SMBus(1)
ADDR = 0x27 # 常见 0x27 或 0x3F
BL = 0x08 # 背光位
EN = 0x04
RS = 0x01
def write_nibble(bus, data):
bus.write_byte(ADDR, data | BL | EN)
time.sleep(0.0005)
bus.write_byte(ADDR, (data | BL) & ~EN)
time.sleep(0.0001)
def send_byte(bus, data, mode):
write_nibble(bus, mode | (data & 0xF0))
write_nibble(bus, mode | ((data << 4) & 0xF0))
def lcd_init(bus):
for _ in range(3):
write_nibble(bus, 0x30); time.sleep(0.005)
write_nibble(bus, 0x20)
for cmd in (0x28, 0x0C, 0x01, 0x06): # 4bit/2行, 显示开, 清屏, 光标右移
send_byte(bus, cmd, 0)
time.sleep(0.002)
def lcd_text(bus, s, line):
send_byte(bus, 0x80 if line == 0 else 0xC0, 0)
for ch in s:
send_byte(bus, ord(ch), RS)
lcd_init(BUS)
lcd_text(BUS, "Raspberry Pi", 0)
lcd_text(BUS, "LCD1602 I2C", 1)
11.4 测距与姿态
| 模块 | 接口 | 量程 | 精度 | 供电 | 要点 |
|---|---|---|---|---|---|
| HC-SR04 超声波 | 2×GPIO(TRIG/ECHO) | 2 cm – 4 m(实际约 2–3 m 可靠) | 约 ±3 mm(理想条件) | 5 V | ECHO 输出 5 V,接 3.3 V MCU 必须分压(2 kΩ+1 kΩ);测距需 脉宽(us) / 58 ≈ cm;表面吸音(布、海绵)测不准 |
| VL53L0X / VL53L1X | I2C(0x29) | 约 2 m / 4 m | mm 级 | 3.3 V | ToF 激光测距,比超声波好用太多;多个时需改地址或用 XSHUT 引脚逐个启停 |
| MPU6050 | I2C(0x68 或 0x69) | 加速度 ±2/4/8/16 g,陀螺 ±250/500/1000/2000 °/s | 16 位输出 | 3.3–5 V(模块带 LDO) | 六轴(3 轴加速度 + 3 轴陀螺);上电需写 0x6B=0 唤醒;WHO_AM_I(0x75) 应返回 0x68 |
| MPU9250 / MPU6500 | I2C/SPI | 同上 + 磁力计 | — | 3.3 V | 九轴(六轴 + 三轴磁力计),可做航向 |
| ADXL345 | I2C/SPI | ±2/4/8/16 g | 13 位 | 3.3 V | 纯加速度计,便宜好上手 |
| HMC5883L / QMC5883L | I2C | 磁力计 | — | 3.3 V | 电子罗盘;QMC5883L 常冒充 HMC5883L,寄存器不同,库要选对 |
| BNO055 | I2C | 九轴 + 片上姿态解算 | — | 3.3 V | 自带卡尔曼滤波,直接输出四元数/欧拉角,不用自己写融合算法,但贵(¥60–100) |
加速度计 / 陀螺仪 / 磁力计的区别(面试和选型都常问):
| 传感器 | 测什么 | 单位 | 静态可用? | 典型误差来源 |
|---|---|---|---|---|
| 加速度计 | 比力(含重力) | g 或 m/s² | 是(静止时读重力方向 → 可算倾角) | 振动敏感、有零偏 |
| 陀螺仪 | 角速度 | °/s 或 rad/s | 否(静止输出零偏,积分会漂) | 零偏漂移,积分角度随时间发散 |
| 磁力计 | 地磁场强度 | µT 或 Gauss | 是(提供绝对航向参考) | 硬磁/软磁干扰,必须做校准 |
| 组合(IMU) | 六轴/九轴融合 | — | 互补滤波 / 卡尔曼滤波融合三者 | 单一传感器都有短板,融合才能互补 |
// Arduino C++:MPU6050 读取原始六轴数据(不依赖库,直接读寄存器,最稳)
void writeReg(uint8_t r, uint8_t v) {
Wire.beginTransmission(MPU); Wire.write(r); Wire.write(v); Wire.endTransmission(true);
}
void readBytes(uint8_t r, uint8_t n, uint8_t *buf) {
Wire.beginTransmission(MPU); Wire.write(r); Wire.endTransmission(false);
Wire.requestFrom(MPU, n);
for (uint8_t i = 0; i < n && Wire.available(); i++) buf[i] = Wire.read();
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
writeReg(0x6B, 0x00); // 退出睡眠(PWR_MGMT_1)
writeReg(0x1B, 0x00); // 陀螺 ±250 °/s
writeReg(0x1C, 0x00); // 加速度 ±2 g
uint8_t who = 0;
readBytes(0x75, 1, &who);
Serial.printf("WHO_AM_I = 0x%02X (应为 0x68)\n", who);
}
void loop() {
uint8_t b[14];
readBytes(0x3B, 14, b); // 0x3B 起连续 14 字节:accel/temp/gyro
int16_t ax = (b[0] << 8) | b[1];
int16_t ay = (b[2] << 8) | b[3];
int16_t az = (b[4] << 8) | b[5];
int16_t gx = (b[8] << 8) | b[9];
int16_t gy = (b[10] << 8) | b[11];
int16_t gz = (b[12] << 8) | b[13];
Serial.printf("accel: %6d %6d %6d gyro: %6d %6d %6d\n", ax, ay, az, gx, gy, gz);
delay(200);
}
# MicroPython:MPU6050 最小读取
from machine import Pin, I2C
import time
from imu import MPU6050 # MicroPython 标准库(部分端口自带)
i2c = I2C(0, scl=Pin(22), sda=Pin(21))
imu = MPU6050(i2c)
while True:
ax, ay, az = imu.accel.x, imu.accel.y, imu.accel.z
gx, gy, gz = imu.gyro.x, imu.gyro.y, imu.gyro.z
print(f"accel=({ax:+.2f},{ay:+.2f},{az:+.2f})g gyro=({gx:+.1f},{gy:+.1f},{gz:+.1f})deg/s")
time.sleep(0.2)
// Arduino C++:HC-SR04 超声波测距(ECHO 需分压到 3.3 V)
void setup() {
Serial.begin(115200);
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
digitalWrite(TRIG, LOW);
}
void loop() {
digitalWrite(TRIG, LOW); delayMicroseconds(2);
digitalWrite(TRIG, HIGH); delayMicroseconds(10); // 至少 10 us 触发脉冲
digitalWrite(TRIG, LOW);
long dur = pulseIn(ECHO, HIGH, 30000L); // 30 ms 超时 ≈ 5 m
float cm = dur * SOUND_SPEED / 2.0; // 往返所以要除以 2
// 简化公式:cm ≈ dur / 58
if (dur == 0) Serial.println("out of range");
else Serial.printf("dist = %.1f cm\n", cm);
delay(200);
}
11.5 GPS 模块与 NMEA 解析
GPS 模块统一通过 UART 输出 NMEA-0183 文本语句,波特率常见 9600(部分模块默认 38400 或 115200,以模块手册为准)。
| 语句 | 含义 | 关键字段示例 |
|---|---|---|
$GPGGA |
定位信息(时间、纬度、经度、卫星数、海拔) | $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 |
$GPRMC |
推荐最小定位信息(时间、日期、速度、航向) | $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A |
$GPGSV |
可见卫星详情(每条最多 4 颗,分多条发) | 用于看信号强度 SNR |
$GPGSA |
定位精度因子(PDOP/HDOP/VDOP)与参与定位的卫星 | HDOP 越小越好,< 1.5 优秀 |
$GPVTG |
地面速度与航向 | 单位节(knot)与 km/h |
坐标格式是"度分":4807.038,N = 48°07.038′ = 48 + 7.038/60 = 48.1173°。这是新手最常踩的解析坑。
// Arduino C++:GPS NMEA 简易解析(只取 GGA 的时间/经纬度/卫星数)
void setup() {
Serial.begin(115200);
Serial2.begin(9600, SERIAL_8N1, RXD2, TXD2); // GPS 默认多为 9600
}
// NMEA 校验和:'$' 与 '*' 之间所有字符异或
bool nmeaCrc(const String &s) {
int star = s.indexOf('*');
if (star < 0) return false;
uint8_t crc = 0;
for (int i = 1; i < star; i++) crc ^= s[i];
return strtoul(s.substring(star + 1).c_str(), NULL, 16) == crc;
}
// "4807.038" (度分) → 十进制度
float dmToDeg(const String &dm) {
int dot = dm.indexOf('.');
if (dot < 3) return 0;
float mins = dm.substring(dot - 2).toFloat(); // 最后两位是分的小数部分
float degs = dm.substring(0, dot - 2).toFloat();
return degs + mins / 60.0;
}
void loop() {
static String line;
while (Serial2.available()) {
char c = Serial2.read();
if (c == '\n') {
line.trim();
if (line.startsWith("$GPGGA") || line.startsWith("$GNGGA")) {
if (nmeaCrc(line)) {
// $GNGGA,time,lat,N/S,lon,E/W,quality,sats,hdop,alt,...
int p[12], n = 0;
for (int i = 0; i < line.length() && n < 12; i++)
if (line[i] == ',') p[n++] = i;
if (n > 9) {
String time = line.substring(p[0] + 1, p[1]);
String lat = line.substring(p[1] + 1, p[2]);
String ns = line.substring(p[2] + 1, p[3]);
String lon = line.substring(p[3] + 1, p[4]);
String ew = line.substring(p[4] + 1, p[5]);
int sats = line.substring(p[6] + 1, p[7]).toInt();
Serial.printf("UTC %s lat %.6f %s lon %.6f %s sats %d\n",
time.c_str(),
dmToDeg(lat) * (ns == "S" ? -1 : 1), ns.c_str(),
dmToDeg(lon) * (ew == "W" ? -1 : 1), ew.c_str(),
sats);
}
}
}
line = "";
} else if (c != '\r') line += c;
}
}
# 树莓派 / PC:GPS 解析(pynmea2)
# pip install pyserial pynmea2
import serial, pynmea2
ser = serial.Serial('/dev/ttyUSB0', baudrate=9600, timeout=1)
while True:
line = ser.readline().decode('ascii', errors='replace').strip()
if not line.startswith('$'):
continue
try:
msg = pynmea2.parse(line)
except pynmea2.ParseError:
continue
if isinstance(msg, pynmea2.GGA):
# pynmea2 已把度分自动转成十进制度
print(f"lat={msg.latitude:.6f} lon={msg.longitude:.6f} "
f"alt={msg.altitude}m sats={msg.num_sats} hdop={msg.horizontal_dil}")
GPS 常见坑:
| 现象 | 原因 | 解法 |
|---|---|---|
一直没定位(全是 ,0,,,,,, 或空字段) |
室内/窗边没信号;冷启动需 30 s – 数分钟 | 放室外开阔处;首次定位(冷启动)耐心等;有 VBAT 引脚接纽扣电池可保存星历加快热启动 |
| 有数据但校验和不通过 | 波特率不对 / 线太长 | 试 9600/38400;缩短线 |
| 定位漂移几百米 | 卫星数少、HDOP 大 | 等卫星数 > 6、HDOP < 2;多星座(GPS+北斗+GLONASS)模块更快 |
| 解析出的经纬度差很多 | 没做度分到十进制的转换 | 见 dmToDeg() |
| 一直在输出但没有 GGA | 模块被配成只输出部分语句 | 用厂商配置工具(如 u-center)改 NMEA 语句输出 |
11.6 电机与驱动
| 类型 | 驱动芯片/模块 | 供电 | 控制信号 | 特点 | 选型建议 |
|---|---|---|---|---|---|
| 舵机 SG90(9 g) | 内置驱动与减速组 | 4.8–6 V | PWM 50 Hz,脉宽 0.5–2.5 ms | 位置伺服,约 180° 行程;扭矩约 1.5–2.5 kg·cm(5 V,以规格书为准) | 云台、机械臂关节、门锁。必须独立 5 V 供电 |
| 舵机 MG996R | 同上,金属齿 | 4.8–7.2 V | 同 50 Hz | 扭矩约 10 kg·cm 级,峰值电流可达 1–2 A | 需要大力矩时;电源必须 2 A 以上 |
| 直流减速电机(TT 马达) | L298N / TB6612 / DRV8833 | 6–12 V | 2×GPIO(方向)+ 1×PWM(速度),或 IN1/IN2 双 PWM | 便宜,需编码器才能闭环 | 小车底盘 |
| 步进电机 28BYJ-48 + ULN2003 | ULN2003 驱动板 | 5 V | 4×GPIO(8 拍序列) | 便宜、扭矩小、精度低(内置 1/64 减速)、开环 | 教学、低精度定位 |
| 步进电机 42(NEMA17)+ A4988 | A4988 | 8–35 V(电机侧 12 V 常见) | STEP + DIR + EN | 1/16 细分,峰值约 2 A(需散热片) | 3D 打印机、CNC 入门 |
| 步进电机 42 + DRV8825 | DRV8825 | 8–45 V | STEP + DIR + EN | 1/32 细分,约 2.5 A 峰值 | 要更高细分和电压 |
| 步进电机 + TMC2208/2209 | TMC 系列 | — | UART 或 STEP/DIR | 静音、防抖动(StealthChop)、堵转检测 | 3D 打印机升级,贵但体验好 |
| 无刷电机(BLDC)+ ESC | 电调 | 按电机额定 | PWM 50 Hz,脉宽 1–2 ms | 高转速大扭矩,需专用电调 | 航模、无人机 |
| 驱动模块 | 持续/峰值电流 | 电压范围 | 压降/导通电阻 | 特点 |
|---|---|---|---|---|
| L298N | 约 2 A/通道(需大散热片) | 5–35 V | 双极性晶体管,压降可达 2 V 以上,发热大效率低 | 便宜但落后,效率低、压降大 |
| TB6612FNG | 约 1.2 A/通道(峰值 3.2 A) | 2.5–13.5 V | MOSFET,导通电阻低 | 同价位首选,效率高、发热小、体积小 |
| DRV8833 | 约 1.5 A/通道 | 2.7–10.8 V | MOSFET | 小体积,适合低压小电机 |
| BTS7960 | 可达 43 A | 5.5–27 V | MOSFET | 大电流单路,做强力驱动 |
⚠️ 注意:电机是感性负载,断电瞬间会产生反向高压。必须在驱动芯片按规定接续流路径(多数模块已内置),并在电机电源输入端并联大电容(100–470 µF 电解 + 0.1 µF 陶瓷)。电机电源与 MCU 逻辑电源必须分开、只共地,否则电机启动瞬间 MCU 必然复位。
// Arduino C++:A4988 驱动 42 步进电机(STEP/DIR 方式)
void setup() {
pinMode(STEP_PIN, OUTPUT);
pinMode(DIR_PIN, OUTPUT);
pinMode(EN_PIN, OUTPUT);
digitalWrite(EN_PIN, LOW); // A4988 的 EN 是低电平使能
}
// 按步数转动。stepsPerRev 取决于细分设置:
// 1.8°/步 的电机 = 200 步/圈;1/16 细分 → 3200 步/圈
void rotate(long steps, uint16_t usDelay = 500) {
digitalWrite(DIR_PIN, steps > 0 ? HIGH : LOW);
for (long i = 0; i < abs(steps); i++) {
digitalWrite(STEP_PIN, HIGH);
delayMicroseconds(usDelay); // 脉冲宽度决定转速
digitalWrite(STEP_PIN, LOW);
delayMicroseconds(usDelay);
}
}
void loop() {
rotate(3200, 400); // 1/16 细分下转一整圈
delay(1000);
rotate(-3200, 400); // 反转一圈
delay(1000);
}
# MicroPython:TB6612 驱动直流电机(PWM 调速 + 方向)
from machine import Pin, PWM
import time
pwma = PWM(Pin(18), freq=10000) # 10 kHz,避开人耳
ain1 = Pin(16, Pin.OUT)
ain2 = Pin(17, Pin.OUT)
stby = Pin(19, Pin.OUT, value=1) # TB6612 的 STBY 必须拉高
def run(speed): # speed: -1.0 ~ 1.0
duty = int(abs(speed) * 65535)
pwma.duty_u16(duty)
if speed > 0: ain1.value(1); ain2.value(0)
elif speed < 0: ain1.value(0); ain2.value(1)
else: ain1.value(0); ain2.value(0) # 短路刹车
for s in [0.3, 0.6, 1.0, 0, -0.5, -1.0, 0]:
print("speed", s)
run(s)
time.sleep(1)
11.7 隔离、电平转换与存储
| 模块 | 用途 | 接口 | 关键参数 | 要点 |
|---|---|---|---|---|
| 光耦隔离模块(PC817) | 电气隔离、电平转换、抗干扰 | 输入 LED + 输出光敏三极管 | 隔离电压常见 2500–5000 Vrms | 输入侧需串限流电阻(约 1 kΩ 用于 5 V);速度慢(kHz 级),不能传高速信号 |
| 双向 MOS 电平转换模块(BSS138 方案) | 3.3 V ↔ 5 V 双向转换 | 4 通道(HV/LV + 2 路信号) | 支持 I2C 等开漏总线(有上拉,可双向自动换向) | 必须两侧都接 VCC 与 GND;速度约几百 kHz,太快(> 1 MHz SPI)会失真 |
| 单向电平转换(分压电阻) | 5 V → 3.3 V 单向 | 2 个电阻 | 2 kΩ + 1 kΩ → 5 V 变 3.33 V | 只能降压,只能单向,速度受 RC 限制;UART/GPIO 够用 |
| MOS 管模块(IRF520 等) | 大电流开关 | GPIO 驱动栅极 | 注意逻辑电平 MOS(Vgs(th) 低) | 普通 IRF520 在 3.3 V 驱动下可能不完全导通,要选 IRL 系列逻辑电平 MOS |
| SD 卡模块 | SPI | 3.3 V(模块带 LDO 与电平转换才能接 5 V MCU) | SPI Mode 0,初始化 ≤ 400 kHz | FAT32 格式化(> 32 GB 需特殊处理);SPI 模式速率远低于 SDIO |
| EEPROM AT24C32 / AT24C256 | I2C | 3.3–5 V | 32 Kbit = 4 KB / 256 Kbit = 32 KB;地址 0x50–0x57(由 A0/A1/A2 决定) | 页写有大小限制(常见 32 字节/页),跨页要分次写;写后需延时约 5 ms 等待写入完成 |
| RTC DS3231 | I2C(0x68) | 3.3–5 V | 精度约 ±2 ppm(约每月 1 分钟量级,以数据手册为准) | 带温度补偿,比 DS1307 准得多;带纽扣电池座,断电走时 |
| RTC DS1307 | I2C(0x68) | 5 V | 精度依赖外部晶振,误差较大 | 便宜但精度差,新项目选 DS3231 |
| W25Qxx SPI Flash | SPI | 3.3 V | 常见 4 MB(W25Q32)、16 MB(W25Q128) | 必须先擦除(最小扇区 4 KB)再写入;寿命约 10 万次擦写 |
| FRAM(MB85RC 系列) | I2C | 3.3 V | 读写无延时、寿命 10^12 次 | 高频数据记录首选,但贵且容量小 |
⚠️ 注意:AT24C 与 DS3231 的地址都是 0x68/0x50 这类固定值,多个同类器件挂同一条 I2C 总线必然冲突。AT24C 可通过 A0/A1/A2 引脚改地址(0x50–0x57);DS3231 不能改地址,一条总线只能有一个。
// Arduino C++:AT24C32 EEPROM 页写与读取
void eepromWrite(uint16_t addr, uint8_t val) {
Wire.beginTransmission(EEPROM_ADDR);
Wire.write(addr >> 8); // 高字节地址
Wire.write(addr & 0xFF); // 低字节地址
Wire.write(val);
Wire.endTransmission();
delay(5); // 写周期,约 5 ms(以数据手册为准)
}
uint8_t eepromRead(uint16_t addr) {
Wire.beginTransmission(EEPROM_ADDR);
Wire.write(addr >> 8);
Wire.write(addr & 0xFF);
Wire.endTransmission();
Wire.requestFrom(EEPROM_ADDR, (uint8_t)1);
return Wire.available() ? Wire.read() : 0xFF;
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
eepromWrite(0x0010, 0xAB);
Serial.printf("read back: 0x%02X\n", eepromRead(0x0010));
}
void loop() {}
// Arduino C++:DS3231 RTC 读时间(读寄存器,不依赖库)
uint8_t bcd2dec(uint8_t v) { return ((v >> 4) * 10) + (v & 0x0F); }
void readTime() {
Wire.beginTransmission(DS3231);
Wire.write(0x00); // 秒寄存器地址
Wire.endTransmission();
Wire.requestFrom(DS3231, 7); // 秒/分/时/星期/日/月/年
if (Wire.available() < 7) { Serial.println("read fail"); return; }
uint8_t sec = bcd2dec(Wire.read() & 0x7F);
uint8_t min = bcd2dec(Wire.read());
uint8_t hr = bcd2dec(Wire.read() & 0x3F);
Wire.read(); // 跳过星期
uint8_t day = bcd2dec(Wire.read());
uint8_t mon = bcd2dec(Wire.read() & 0x1F);
uint16_t yr = 2000 + bcd2dec(Wire.read());
Serial.printf("%04u-%02u-%02u %02u:%02u:%02u\n", yr, mon, day, hr, min, sec);
}
void setup() { Serial.begin(115200); Wire.begin(21, 22); }
void loop() { readTime(); delay(1000); }
11.8 无线模块对比表
| 模块 | 频段 | 速率 | 距离(空旷,典型) | 功耗 | 接口 | 拓扑 | 成本 | 选型建议 |
|---|---|---|---|---|---|---|---|---|
| ESP32 内置 WiFi | 2.4 GHz | 最高 150 Mbps(PHY 802.11n) | 室内 20–50 m | 峰值 200–300 mA | 片上 | 星型(连 AP) | 0(已含在芯片里) | 有 WiFi 覆盖、要传大量数据/上云 → 首选 |
| ESP32 内置 BLE | 2.4 GHz | 1–2 Mbps(BLE 5 可更高) | 10–50 m | 极低,µA 级休眠 | 片上 | 星型/广播/Mesh | 0 | 电池传感器节点、手机配网、信标 |
| nRF24L01+ / SI24R1 | 2.4 GHz | 250 k / 1 M / 2 Mbps | 空旷 100 m+(PA/LNA 版可达 1 km) | 发射约 12 mA,休眠 µA | SPI(注意:SI24R1 是国产兼容,寄存器大体兼容但参数以手册为准) | 点对点/星型(需自己组网) | ¥3–8 | 多点低成本私有协议首选;缺点是没协议栈,可靠性要自己写 |
| LoRa(SX1278/SX1262 + MCU) | Sub-GHz(433/470/868/915 MHz,按国家法规选频段) | 0.3–37.5 kbps(速率可调换距离) | 城区 2–5 km,空旷 10 km+ | 发射约 100 mA(瞬时),休眠 µA | SPI | 点对点/星型/LoRaWAN | ¥15–40 | 远距离、低速、电池供电 → 唯一选择;传图片/音频不行 |
| LoRaWAN 网关 | Sub-GHz | — | — | — | — | 星型(接 TTN/ChirpStack) | ¥300–2000 | 要建网络才需要;自己玩点对点不用网关 |
| Zigbee(CC2530 / EFR32 / JN5189) | 2.4 GHz | 250 kbps | 单跳 10–75 m | 低 | UART(AT 固件)或 SPI | Mesh 自组网 | ¥20–60 | 智能家居 Mesh、需要自愈网络;Zigbee2MQTT 生态成熟 |
| ESP32-C6 / H2 | 2.4 GHz | — | — | 低 | 片上 | WiFi 6 + Thread/Zigbee + Matter | ¥20–35 | 想做 Matter over Thread 的新项目 |
| BLE Mesh / nRF52840 | 2.4 GHz | — | — | 极低 | 片上 | Mesh | ¥20–40 | Nordic 生态,Zephyr 开发 |
| Sub-1G 简单收发(CC1101 / SI4432) | 315/433/868/915 MHz | 数 kbps – 数百 kbps | 数百 m – 数 km | 低 | SPI | 点对点 | ¥8–20 | 比 LoRa 便宜、比 2.4G 传得远,但没有 LoRa 的扩频增益 |
| 4G Cat.1 / NB-IoT | 运营商频段 | Cat.1 约 10 Mbps 下行 | 有运营商信号即可 | 高(峰值 2 A 瞬时) | UART(AT 指令) | 蜂窝 | 模组 ¥30–80 + 流量费 | 无 WiFi 覆盖的户外/车载;电源要能扛住 2 A 脉冲 |
选型决策:
要上云 / 传数据量大 / 已有 WiFi → ESP32 内置 WiFi
电池供电 + 手机交互 → BLE
多点自组网、设备间通信、成本敏感 → nRF24L01+
几公里距离、少量数据、电池 → LoRa
智能家居、需要自愈 Mesh、对接 HA → Zigbee(Zigbee2MQTT)
新项目做 Matter → ESP32-C6 / H2(Thread)
户外无 WiFi、要上云 → 4G Cat.1(注意电源)
⚠️ 注意:无线电频段受各国法规管制。433 MHz、868 MHz、915 MHz 在不同地区允许的功率与用途不同,中国境内的民用微功率设备频段与发射功率限制以国家无线电管理机构的规定为准。使用 Sub-GHz 模块前务必确认所选频段与功率合法合规。
11.9 摄像头与其他
| 模块 | 接口 | 规格 | 平台 | 要点 |
|---|---|---|---|---|
| OV2640 | DCMI/SCCB(I2C 配置 + 并行数据) | 200 万像素(1600×1200),支持 JPEG 输出 | ESP32-CAM / ESP32-S3 | 输出 JPEG 时 MCU 才扛得住;需要 PSRAM 才能缓存帧 |
| OV7670 | 并行 + SCCB | 30 万像素(640×480),无 JPEG | STM32F4(带 DCMI)、部分 MCU | 需要 FIFO(AL422B)配合,否则 MCU 跟不上 |
| GC0308 / GC032A | 并行/DVP | 30 万像素 | 廉价模组 | 比 OV7670 便宜,资料少 |
| 树莓派 CSI 摄像头(OV5647 / IMX219 / IMX477 / IMX708) | MIPI CSI-2(15 pin 排线) | 500 万 – 1200 万像素 | 仅树莓派(Pi 5 用 22 pin 转接) | 用 libcamera 栈;排线方向别插反(金属触点朝向以板卡丝印为准) |
| USB 摄像头 | USB | 取决于设备 | 树莓派/Jetson/PC | 兼容性最好;用 OpenCV 或 v4l2-ctl |
| 热成像 MLX90640 / AMG8833 | I2C | 32×24 / 8×8 像素温度阵列 | 任意 MCU | 分辨率低但能测温,做人体感应/设备过热检测 |
# 树莓派摄像头验证(Bookworm 起统一 libcamera)
libcamera-hello --list-cameras
libcamera-still -o test.jpg -t 2000
libcamera-vid -t 5000 -o test.h264 --width 1280 --height 720
# USB 摄像头验证
v4l2-ctl --list-devices
v4l2-ctl -d /dev/video0 --list-formats-ext
ffplay /dev/video0 # 或 ffplay -f v4l2 /dev/video0
十二、电源基础:90% 的玄学故障根源
12.1 LDO vs DC-DC
| 维度 | LDO(线性稳压) | DC-DC 降压(Buck) |
|---|---|---|
| 原理 | 通过调整管"烧掉"多余电压 | 开关 + 电感 + 电容储能转换 |
| 效率 | η ≈ Vout / Vin。12 V→3.3 V 效率仅约 27% | 85%–95%,与压差关系小 |
| 发热 | P = (Vin - Vout) × I。压差大 + 电流大会严重发热 |
发热小 |
| 纹波 | 极低(µV–mV 级),噪声小 | 较高(几十 mV 级),有开关尖峰 |
| EMI | 无 | 有开关噪声,可能干扰 ADC / 射频 |
| 外围 | 只需 2 个电容,简单便宜 | 需电感 + 输入/输出电容 + 反馈电阻,布局有讲究 |
| 成本/面积 | 低 / 小 | 略高 / 略大 |
| 最小压差 | 有(Dropout)。AMS1117 约 1.1–1.3 V(以数据手册为准) | 无此限制(但有最小/最大占空比限制) |
选型经验:
Vin - Vout < 1 V 且电流 < 300 mA → LDO(简单、干净)
Vin - Vout 大(如 12 V → 3.3 V) → 必须 DC-DC(LDO 会烫到不能摸)
电流 > 500 mA → DC-DC
给 ADC / 传感器 / 射频 / 音频供电 → LDO(或 DC-DC 后级再串 LDO)
电池供电、追求续航 → DC-DC(效率就是续航)
空间极小、成本极敏感、电流很小 → LDO
压降计算实例:12 V 输入、AMS1117-3.3 输出、负载 300 mA → 耗散 (12-3.3) × 0.3 = 2.61 W。SOT-223 封装在无额外散热时很难扛住,会烫手甚至触发过热保护。这种情况必须换 DC-DC。
12.2 常用芯片速查
| 芯片 | 类型 | 输入范围 | 输出 | 最大电流 | 特点/注意 |
|---|---|---|---|---|---|
| AMS1117-3.3 / 5.0 | LDO | 最高约 15 V | 固定 3.3 V / 5.0 V,也有 adjustable 版 | 约 1 A(受散热限制,实际远小于此,以数据手册为准) | 最常见也最被滥用。压降大、发热大;必须用钽电容或低 ESR 电容做输出滤波(用普通电解可能振荡);很多廉价模块是假货,实际电流能力打折 |
| AMS1117-1.8 / 2.5 | LDO | 同上 | 1.8 V / 2.5 V | 同上 | 给某些传感器/FPGA 内核供电 |
| LM1117 | LDO | 类似 | 固定/可调 | 约 800 mA – 1 A | AMS1117 的原版对照,参数以 TI 手册为准 |
| XC6206 / ME6211 | LDO | 最高 6 V 左右 | 3.3 V 等固定值 | 200–300 mA 级 | 小封装(SOT-23)、低静态电流,适合小电流电池设备 |
| HT7333 / HT7833 | LDO | 高耐压(可达 20 V 级以上,以手册为准) | 3.3 V | 约 250 mA | 静态电流极低(µA 级),电池长期待机首选 |
| MP2359 | DC-DC 同步降压 | 4.5–24 V | 可调(反馈电阻设定) | 约 1.2 A | 常见小型降压模块方案,效率高,SOT23-6 封装 |
| SY8303 | DC-DC 同步降压 | 约 4.5–40 V 量级(以手册为准) | 可调 | 约 3 A | 车载/大电流场景常见,需电感与散热设计 |
| LM2596 | DC-DC 降压(非同步) | 4.5–40 V | 可调/固定 | 约 3 A | 最常见的可调降压模块;非同步效率略低(约 70–85%),但便宜耐用 |
| MP1584 / MP2307 | DC-DC | — | 可调 | 2–3 A | 迷你降压模块常用 |
| XL4015 / XL4016 | DC-DC | — | 可调 | 5–8 A | 大电流降压,注意电感与散热 |
| MT3608 / SX1308 | DC-DC 升压(Boost) | 2–24 V | 可调(最高约 28 V,以手册为准) | 约 2 A(开关电流,实际输出能力更低) | 单节锂电升 5 V 的常用方案 |
| TP4056 | 锂电充电管理 | 4.5–8 V(USB 5 V) | 4.2 V 恒流恒压 | 充电电流由 PROG 电阻设定,约 1 A | 必须配带保护板的锂电池;充电电流别设太大(建议 ≤ 0.5C) |
| DW01 + 8205A | 锂电保护 | — | — | — | 过充/过放/过流保护,配合锂电使用 |
⚠️ 注意:以上电流/电压参数为常见规格的大致范围,不同厂家、不同批次存在差异。设计产品时以具体型号的最新数据手册为准,并留足 50% 以上的降额。
12.3 去耦电容:不是可选项
| 电容 | 位置 | 作用 | 典型值 |
|---|---|---|---|
| 去耦电容(Decoupling) | 每个芯片的 VCC 引脚到 GND,越近越好 | 提供瞬时电流、滤除芯片自身产生的高频噪声 | 0.1 µF(100 nF)陶瓷,是每个 IC 的标配 |
| 旁路电容(Bypass) | 电源入口 | 滤除电源线上的高频干扰 | 0.1 µF + 10 µF 并联 |
| 储能/滤波电容(Bulk) | 电源输入端、板级 | 应对负载突变、平滑低频波动 | 10–470 µF 电解或钽电容 |
| 高频去耦 | 高速/RF 芯片旁 | 滤除更高频噪声(0.1 µF 在高频会因 ESL 失效) | 0.1 µF + 0.01 µF(10 nF)并联,或用 1 nF |
为什么 0.1 µF 要紧挨芯片:电容到芯片之间哪怕几厘米的走线也有寄生电感,高频下阻抗很大,电容"来不及"供电。ESL(等效串联电感)决定了电容的高频性能,这是小封装(0402/0603)比大封装在高频表现更好的原因。
布局要点:
1) 0.1 µF 陶瓷电容放在芯片 VCC 引脚旁,走线尽量短、直,先经过电容再到芯片引脚。
2) 电容的 GND 端直接就近打过孔接到地平面(不要绕远路)。
3) DC-DC 的电感下方不要走信号线(磁场干扰),反馈走线远离电感与开关节点。
4) 大电流路径(电源、电机、地)走线要宽;1 oz 铜厚下 1 mm 线宽约走 1 A(粗估,
以 PCB 载流标准为准,最好用在线线宽计算器)。
5) 模拟与数字地分开走,在一点汇合(星型接地/单点接地)。
6) 大电容与小电容并联使用:大的管低频,小的管高频。
12.4 纹波测量
用示波器测纹波的正确姿势:
1) 探头打到 1×(不是 10×,10× 会放大噪声底)——或者用 10× 但接受噪底
2) 开启探头的带宽限制(20 MHz Bandwidth Limit),滤掉开关尖峰的辐射拾取
3) 用"探头尖端 + 接地弹簧"就近测量,
★ 绝对不要用示波器标配的长鳄鱼夹地线(会拾取大量开关噪声,测出来虚高数倍)
4) 示波器耦合方式设为 AC,垂直刻度调到 10–50 mV/div
5) 测量点选输出电容两端,不是远处的负载端
6) 在额定负载下测(空载纹波通常偏小,不具代表性)
判定参考(经验值,具体看后端器件要求):
LDO 输出 < 几 mV 级
DC-DC 输出 < 50 mVpp 通常可接受;给 ADC/RF 供电要 < 20 mVpp
给 ESP32 供电 < 100 mVpp 一般能稳定工作(以实际测试为准)
没有示波器时的替代判断:
1) 万用表交流电压档(AC mV)串一只 10 µF 电容测 DC-DC 输出端,
能粗略看出纹波量级(读数是 RMS,不是峰峰值,但能比较大小)。
2) 听:DC-DC 电感在轻载时会"啸叫"(陶瓷电容压电效应 + 电感磁致伸缩),
通常无害,但说明工作在轻载跳周期模式。
3) 最实用的验证:直接带满负载长时间运行,看系统是否重启/通信是否误码。
12.5 "莫名其妙重启"的电源排查清单
□ 1. 电源功率够吗?
ESP32 峰值 300 mA+;WiFi 发射瞬间更高。树莓派 4B 要 3 A、5 要 5 A。
→ 换更大电流的电源,别用电脑 USB 口带大负载。
□ 2. 线缆压降?
细长 USB 线内阻大,1 A 电流下能掉 0.3–0.5 V。
→ 换粗短线,或提高电源电压(对有 LDO 的板子有效)。
□ 3. 瞬时电流扛得住吗?
电机启动、继电器吸合、WiFi 发射都是脉冲电流,电源响应速度不够就会跌落。
→ 在 MCU 的 VCC-GND 间加 100–470 µF 电解 + 0.1 µF 陶瓷。
□ 4. 大负载有没有和 MCU 抢电?
舵机/电机/继电器绝对不能从 MCU 的 3.3 V 或 5 V 引脚取电。
→ 独立供电,只把 GND 连在一起(共地)。
□ 5. 地去干净了吗?
多个模块串联接地(菊花链)会造成地电位差,形成"地弹"。
→ 星型接地,所有 GND 汇到一点。
□ 6. 有没有反电动势?
电机、继电器线圈断电时的反向高压会串回电源。
→ 续流二极管(继电器/直流电机),RC 吸收(交流负载)。
□ 7. 看日志确认:
ESP32 → 串口出现 "Brownout detector was triggered" 或 rst:0x1 / 0xF
ESP-IDF→ rst:0x1 (POWERON), rst:0x7 (TG0WDT), rst:0xF (BROWNOUT)
树莓派 → vcgencmd get_throttled 非 0x0;dmesg 有 under-voltage
STM32 → 看 RCC_CSR 的复位标志位(POR/PIN/BOR 等)
□ 8. 实测:万用表电压档点住 MCU 的 VCC 引脚,触发负载的瞬间看跌到多少。
跌到 3.0 V 以下(3.3 V 系统)基本就是电源问题。
十三、焊接与工具
13.1 电烙铁温度设定
| 焊接对象 | 推荐温度 | 说明 |
|---|---|---|
| 普通锡铅焊锡(Sn63/Pb37,熔点 183 °C) | 300–330 °C | 最容易焊,新手优先用有铅锡 |
| 无铅焊锡(SAC305,熔点约 217 °C) | 340–370 °C | 流动性差,需要更高温度和更多助焊剂 |
| 细引脚 / 排针 / 杜邦线 | 320–350 °C | 烙铁头选刀头或马蹄头,不要用尖头(导热差) |
| QFP / SOP 贴片(拖焊) | 330–350 °C + 大量助焊剂 | 拖焊靠助焊剂和表面张力,不是靠"抹" |
| QFN / BGA / 大焊盘接地 | 350–380 °C 或热风枪/加热台 | 大铜箔散热快,烙铁功率不足就焊不化 |
| 拆焊(吸锡器/吸锡带) | 350–380 °C | 吸锡带要先沾助焊剂 |
实操要点:
1) 烙铁头先上锡(吃锡),看到亮银色的"锡帽"再焊,氧化发黑的头不导热。
2) 同时加热"焊盘 + 引脚"两个面,再把锡丝送到接触点,不要送到烙铁头上。
3) 单点加热时间 < 2–3 秒,焊不动就检查温度/助焊剂,不要死按(会烫掉焊盘)。
4) 助焊剂是灵魂:焊锡丝里的助焊剂往往不够,额外备一瓶松香/免洗助焊剂。
5) 焊完检查:好的焊点是圆锥形、表面光亮;灰暗粗糙 = 冷焊(虚焊),必须重焊。
6) 每次用完在湿海绵/铜丝球上擦净烙铁头,并重新上一层锡再断电(防氧化)。
7) 排针焊接技巧:先在面包板上把排针插正(借助面包板定位),再放板子焊。
⚠️ 注意:烙铁温度 300 °C 以上,烫伤只在一瞬间。必须使用烙铁架,不要随手放桌上。焊接时保持通风(助焊剂烟雾有害),FPGA/CMOS 芯片焊接时注意防静电(戴防静电手环或先摸一下接地金属)。
13.2 万用表实用技巧(补遗)
1) 快速判断杜邦线好坏:蜂鸣档测两端。这是面包板项目最常见的故障点,
建议新买的线先全部测一遍(约 5–10% 的低价线是断的)。
2) 判断 IO 是否真的在输出:
直流电压档测引脚对 GND。
恒定 0 V 或 3.3 V → 电平没翻转(代码没跑 / 引脚配错)
读数在 1~2 V 之间跳动 → 在输出 PWM 或在通信(对的)
读数 0 V 但代码写了 HIGH → 引脚被配置为输入、或负载短路、或 IO 已烧
3) 找短路:断电 → 蜂鸣档测 VCC 与 GND。
导通(响)说明有短路 → 逐个拔掉外围模块,拔到哪个不响了就是它的问题。
4) 测静态功耗(排查电池续航):
电源正极 → [万用表电流档] → 板子 VCC 输入端。
⚠ 先插 "A" 大电流孔再上电,确认读数小后再换到 mA 档(否则烧保险丝)。
5) 判断芯片是否虚焊:
蜂鸣档测芯片引脚与对应焊盘/走线端(不是测引脚之间)。
不导通 = 虚焊或断线。
6) 判断电容是否失效:
电容档测(需焊下一端)。容量严重偏小 = 失效;
DC-DC 输出电容失效会导致纹波剧增、系统随机重启。
7) 测电阻分压中点电压:确认分压电路是否按预期比例工作。
13.3 杜邦线与面包板:接触不良的排查
杜邦线问题(面包板项目第一大故障源):
| 问题 | 表现 | 解法 |
|---|---|---|
| 线断(内部断丝) | 时通时不通,晃动时好时坏 | 蜂鸣档逐根测,一次性淘汰坏线 |
| 插头松动 | 插进面包板/排针后很松、一碰就掉 | 换线;或把金属端子用尖嘴钳轻轻夹一下(增加接触压力) |
| 线序接错 | 通信完全不通 | 按颜色规范:红=VCC、黑=GND、其他=信号;接线后逐根核对 |
| 线太长 | 高速信号(SPI/I2C 高速)出错 | 缩短到 10–20 cm;加 GND 伴线 |
| 用铁氟龙/镀锡线当杜邦线 | 压接不牢 | 用正规杜邦线(带冷压端子) |
面包板问题:
| 问题 | 影响 | 说明 |
|---|---|---|
| 寄生电容 | 高速信号失真 | 相邻插孔间约 2–5 pF 量级的电容(以实际测量为准),加上长杜邦线,总电容可达数十 pF。I2C 在 400 kHz、SPI 在数 MHz 时,RC 上升时间变长,波形变缓、边沿不够陡,导致采样错误、数据错位 |
| 接触电阻 | 模拟信号误差 | 氧化或松动的插孔接触电阻可达欧姆级,测小信号/做精密分压时引入误差 |
| 串扰 | 相邻信号互相干扰 | 高速信号线要隔开,中间插一根 GND 线做屏蔽 |
| 弹片疲劳 | 反复插拔后接触不良 | 面包板是消耗品,用久了要换 |
什么时候必须放弃面包板:
□ SPI 速率 > 几 MHz(尤其 > 8 MHz)
□ I2C 跑 400 kHz 且挂了多个设备、线又长
□ 涉及模拟小信号(mV 级、高阻抗)
□ 涉及射频(WiFi/BLE/LoRa 的 RF 部分绝不能走面包板)
□ 电机等大电流(面包板插孔过流能力有限,通常 < 1 A)
□ 需要长期可靠运行的设备
→ 改洞洞板焊接,或直接打样 PCB(现在打样 5 块 10×10 cm 双面板约 ¥20–50)
排查接触不良的系统方法:
1) 先不动程序,把所有杜邦线重新插拔一遍(约能解决 30% 的"时好时坏")。
2) 用万用表蜂鸣档,从 MCU 引脚一路测到模块引脚,逐根确认导通。
3) 用逻辑分析仪看波形:如果 SCL 上升沿很缓(斜坡而不是直角),
就是上拉太弱 + 总线电容太大 → 减小上拉电阻或缩短线。
4) 怀疑面包板孔位氧化:换个位置插,对比现象是否消失。
5) 终极办法:直接焊接(洞洞板或 PCB),排除一切接触问题。
十四、从安全视角看:硬件接口就是攻击面
⚠️ 注意:本节内容仅用于自己拥有或已明确授权测试的设备的安全加固与风险评估。对他人设备做未授权的调试口接入、固件提取、口令爆破属于违法行为。研究攻击面的目的是为了堵住它。
14.1 UART:IoT 设备最常见的 root shell 入口
为什么是 UART:厂商生产时需要调试、测试时需要日志,于是板子上留了 3–4 pin 的排针(VCC/GND/TX/RX)。量产后排针没拆、console 没关,攻击者接上 USB-TTL 就能看到完整启动日志,甚至直接拿到 root shell 且不需要任何认证。
找 UART 引脚:
1) 目视:找板边 3–4 pin 的排针或裸露焊盘,丝印可能标 VCC/GND/TX/RX/TXD/RXD/DEBUG/CONSOLE。
2) 万用表(断电):
- 蜂鸣档找 GND:与电源接口负极、USB 外壳、屏蔽罩导通的那个就是 GND。
- 电压档(上电):VCC 是恒定 3.3 V 或 5 V(对 GND)。
- TX:上电瞬间电压会跳动(在发启动日志),静态时多为高电平(3.3 V 或 5 V)。
- RX:通常也是高电平,但不动(不发数据)。
- 判定技巧:TX 在上电 1–2 秒内会有明显的电平抖动,RX 相对安静。
3) 逻辑分析仪:多通道同时接候选引脚,直接看谁有规律的方波(最好配合解码器)。
4) 注意:找到 TX 和 GND 就能单向读日志,已经足够获取大量信息。
★ 只接 GND + TX 是最安全的做法(不会因为你发送字符而改变设备状态)。
未知波特率自动探测:
#!/usr/bin/env python3
"""
UART 波特率自动探测:逐个尝试常见波特率,用"可打印字符比例"判断哪个是对的。
用法:python3 baudscan.py /dev/ttyUSB0
"""
import sys, time, serial
CANDIDATES = [1200, 2400, 4800, 9600, 19200, 38400, 57600, 74880,
115200, 230400, 460800, 500000, 921600, 1000000, 1500000, 2000000]
def score(data: bytes) -> float:
"""可打印 ASCII + 常见控制字符的比例,作为"像不像文本"的打分"""
if not data:
return 0.0
good = sum(1 for b in data
if 0x20 <= b < 0x7F or b in (0x0A, 0x0D, 0x09, 0x1B))
return good / len(data)
def probe(port: str, baud: int, seconds: float = 2.5) -> float:
try:
s = serial.Serial(port, baudrate=baud, timeout=0.2,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE)
except Exception as e:
print(f" {baud:>8}: open failed ({e})")
return 0.0
# 关闭 DTR/RTS,避免把设备复位掉
try:
s.setDTR(False); s.setRTS(False)
except Exception:
pass
buf = bytearray()
end = time.time() + seconds
try:
while time.time() < end:
chunk = s.read(256)
if chunk:
buf += chunk
else:
time.sleep(0.02)
finally:
s.close()
sc = score(bytes(buf))
preview = bytes(buf[:60]).decode('ascii', errors='replace').replace('\n', ' ')
print(f" {baud:>8}: score={sc:.2f} len={len(buf):5d} {preview!r}")
return sc
def main():
port = sys.argv[1] if len(sys.argv) > 1 else '/dev/ttyUSB0'
print(f"scanning {port} ... (若设备需重启才有输出,请在扫描期间重启它)")
best, best_baud = 0.0, None
for baud in CANDIDATES:
sc = probe(port, baud)
# 用"有数据 + 像文本"两个条件一起判断
if sc > best:
best, best_baud = sc, baud
if sc > 0.85:
print(f"\n[+] likely baud = {baud} (score {sc:.2f})")
break
print(f"\nbest: {best_baud} @ score {best:.2f}")
print(f"下一步:picocom -b {best_baud} {port}")
if __name__ == '__main__':
main()
# 使用方法
# pip install pyserial
python3 baudscan.py /dev/ttyUSB0
# 关键:脚本运行期间给设备重新上电,才能抓到 U-Boot / kernel 的启动日志
# 拿到波特率后连接(推荐 picocom,退出键 Ctrl+A Ctrl+X)
picocom -b 115200 /dev/ttyUSB0 --imap lfcrlf
# 或想保存完整日志
picocom -b 115200 /dev/ttyUSB0 --logfile boot.log
另一种思路(更精确):用逻辑分析仪/示波器量单个位的时间宽度,波特率 = 1 / 位宽。抓启动日志时最短的那个脉冲宽度即 1 位的时间。115200 对应约 8.68 µs,9600 对应约 104 µs。
拿到 shell 后的常规动作(信息收集):
# —— 系统指纹 ——
uname -a
cat /proc/version
cat /etc/os-release
cat /proc/cpuinfo
ls /etc/*release*
# —— 账号与权限 ——
id; whoami
cat /etc/passwd
cat /etc/shadow # 拿到的话离线 john/hashcat 爆破
sudo -l # 可无密执行的命令 = 提权捷径
find / -perm -4000 -type f 2>/dev/null # SUID 文件
cat /etc/sudoers
# —— 网络与配置 ——
ifconfig -a / ip a
cat /etc/resolv.conf
netstat -tulnp / ss -tulnp
ps aux
cat /proc/mounts
cat /etc/fstab
# —— 应用配置里找硬编码凭据 ——
ls -la /etc/ /etc/config/ /opt/ /usr/local/ 2>/dev/null
cat /etc/config/* 2>/dev/null # OpenWrt 的 UCI 配置
find / -name "*.conf" -o -name "*.cfg" -o -name "*.ini" -o -name "*.yaml" 2>/dev/null | head -50
grep -rIl -E "password|passwd|secret|api[_-]?key|token|aws_|private_key" \
/etc /opt /usr/local /root /home 2>/dev/null | head -40
# —— 固件与更新机制 ——
cat /proc/mtd # MTD 分区(SPI Flash 布局)
cat /proc/partitions
dmesg | head -60
ls -la /dev/mtd* /dev/mtdblock*
cat /etc/crontabs/* 2>/dev/null # 定时任务(常见后门驻留点)
ls -la /etc/init.d/ /etc/rc.local 2>/dev/null
# —— 密钥与证书 ——
find / -name "*.pem" -o -name "*.key" -o -name "id_rsa*" 2>/dev/null | head -20
cat /etc/ssl/private/* 2>/dev/null
ls -la ~/.ssh/
# —— 外传(若已拿到网络)——
# nc / tftp / wget 上传 busybox 静态二进制(很多设备 busybox 是裁剪版,缺 nc/curl)
另一种常见场景:中断 U-Boot 拿到 bootloader shell。在启动日志出现 Hit any key to stop autoboot 时按任意键,进 U-Boot 命令行后可以:
printenv # 看环境变量(含 bootargs,可能有 console= 与 root=)
setenv bootargs ... # 改启动参数,如加 init=/bin/sh 直接进 shell(绕过密码)
md.b 0x9f000000 0x10000 # 从内存 dump(配合 sf read 可提取 Flash 内容)
sf probe 0; sf read 0x82000000 0x0 0x400000 # 读 SPI Flash 到内存
help # 看支持哪些命令
⚠️ 注意:上述所有命令只应在你自己的设备或书面授权范围内执行。IoT 设备的 UART shell 往往是无认证的 root,这也是厂商应该在量产时关闭它的原因。
厂商侧的加固:
1) 物理移除:量产版本不焊接排针,或用灌胶/点漆覆盖测试点。
2) 软件禁用:内核命令行去掉 console=;禁用登录 shell(/etc/inittab 里注释掉
对应 tty 的 getty);U-Boot 设置 bootdelay=0 并锁定环境变量。
3) 完整性:Secure Boot + Flash/磁盘加密,阻止替换固件与离线读密钥。
4) 日志:生产版本关闭调试日志级别(日志泄露的路径信息、密钥片段都很有价值)。
5) 认证:即便保留串口,也要加口令,且口令不可硬编码在明文配置里。
14.2 SWD / JTAG:能读能写能绕过
暴露的风险:
| 能力 | 说明 |
|---|---|
| halt CPU | 全速暂停处理器,冻结当前状态 |
| 读/写 Flash | 完整 dump 固件,或直接写入改过的固件(植入后门) |
| 读/写 SRAM | 内存中可能有明文密钥、解密后的固件、运行时凭据 |
| 读寄存器 | 读取外设、调试寄存器状态 |
| 绕过软件校验 | 程序里的密码校验、License 检查可以被 halt + 改内存/改 PC 指针直接跳过;也可以改 Flash 里的校验逻辑 |
| 绕过读保护(部分型号) | 见下文 |
常见调试接口:
| 接口 | 引脚 | 常见于 |
|---|---|---|
| SWD(ARM 主流,2 线) | SWDIO、SWCLK(+ GND,可选 VCC、NRST、SWO) | STM32 全系、nRF、多数 Cortex-M |
| JTAG(4–5 线) | TMS、TCK、TDI、TDO(+ nTRST) | FPGA、老 ARM、Jetson、部分路由器 SoC |
| cJTAG / SWJ-DP | 兼容两种 | 现代 Cortex-M 多支持 |
RDP(Readout Protection,STM32 为例):
| 级别 | 含义 | 解除方式 |
|---|---|---|
| Level 0 | 无保护,随便读 | — |
| Level 1 | 调试器连接后 Flash 被禁止读取(但可整片擦除) | 降回 Level 0 会触发全片擦除,数据不可恢复。这是"防君子"级别 |
| Level 2 | 永久锁死调试接口,SWD/JTAG 彻底失效 | 不可逆,无法降回、无法再烧录。代价是永远不能再用调试器 |
绕过读保护的公开思路(概述,不展开细节):
1) 故障注入(Glitching):
- 电压毛刺(Voltage Glitching):在芯片校验读保护的瞬间注入短暂欠压,
让校验指令执行失败或被跳过。
- 时钟毛刺(Clock Glitching):注入超短时钟脉冲,跳过某条指令。
- 电磁故障注入(EMFI):用线圈在芯片上方产生强脉冲磁场。
→ 这类攻击需要精确的时间窗口,通常需要先找到"校验发生的时刻"
(用功耗/电磁侧信道定位),属于有门槛但公开可复现的方法。
2) 侧信道与功耗分析(DPA/CPA):
通过大量采集加密操作时的功耗曲线,统计推断出密钥位。
→ 对未做防护的 AES/SM4 软件实现有效。
3) 物理层:
- 开盖(Decapsulation)+ 显微镜找到熔丝位,用激光/离子束改写。
- 把 Flash 芯片拆下来放编程器读(绕过 MCU 内部的读保护,
因为保护是在 MCU 内部,管不了外部 Flash)。
→ 成本最高,属于专业实验室级别。
4) 已知 SDK/协议漏洞:
某些厂商的 bootloader / 下载协议存在越权读命令,可直接读出 Flash。
⚠️ 注意:以上仅为公开的攻击面概述,用于理解"为什么生产时要熔断调试口"。不要对未授权设备实施。
厂商为什么熔断调试口:
1) 阻止固件提取:固件里往往有硬编码密钥、私有算法、可被挖掘的漏洞。
拿到固件 = 拿到完整的设计图纸。
2) 阻止固件篡改:可写 Flash 就意味着可以植入持久化后门。
3) 阻止绕过安全校验:调试器能改内存,任何软件层的认证都可被跳过。
4) 合规要求:金融、车规、支付类设备的安全认证(如 PCI、CC EAL)
通常明确要求调试接口在量产后不可用。
5) 代价与权衡:熔断后无法返修、无法在线调试,产线必须保证一次烧录成功,
且要保留"死片"的处理流程。因此常见做法是:
- 开发阶段:Level 0,随便调试
- 产线烧录后:设 Level 1
- 出货前/安全敏感产品:Level 2 或熔断 eFuse
- ESP32 侧:烧 eFuse 的 DIS_DOWNLOAD_MODE、JTAG_DISABLE(不可逆,务必先在小批量上验证)
14.3 固件提取:从 Flash 拿到完整镜像
方法一:在线夹取(不拆焊,首选)
适用:板上有 SOIC-8 / SOP-8 封装的 SPI Flash(25Qxx、W25Qxx、MX25Lxx 系列)
工具:
- SOIC8 测试夹(Pomona 5250 或国产兼容,¥20–60)
- 编程器:CH341A(¥15–30,最便宜) / FT2232H(更快更稳) / 树莓派 GPIO(用 flashrom 的 linux_spi)
- 或者直接用树莓派/ESP32 的 SPI 接口(仅 3.3 V!)
步骤:
1) 断电,看清 Flash 芯片丝印(型号 + 容量,如 "25Q128" = 128 Mbit = 16 MB)。
2) 夹上测试夹,注意 1 脚方向(芯片上有个圆点/凹槽标记 1 脚)。
3) ★ 关键:给目标板断电,只由编程器给 Flash 供电;
若目标板还在上电,MCU 会与编程器争抢 SPI 总线,导致读出来全是 0x00/0xFF 乱码。
有些板子需要在读取时"按住复位"让 MCU 保持高阻态。
4) 读取 → 校验(读两遍比对)→ 分析。
# ===== CH341A 编程器(Linux)=====
# 安装工具(ch341a 支持已并入较新版本 flashrom)
sudo apt install flashrom
# 或用开源 ch341a 工具:sudo apt install ch341eeprom / pip install ch341a-...
# 探测芯片(这一步能识别就成功一半)
sudo flashrom -p ch341a_spi
# 期望输出:Found ... flash chip "W25Q128.V" (16384 kB, SPI)
# 读取(连读 3 次,比对 MD5 确认稳定)
for i in 1 2 3; do sudo flashrom -p ch341a_spi -r fw_$i.bin; done
md5sum fw_1.bin fw_2.bin fw_3.bin # 三者一致才可信
# ===== 用树莓派 SPI 读(适合没有编程器时)=====
# sudo raspi-config → 启用 SPI
# 接线(务必确认是 3.3 V Flash!):
# Flash Pin1(CS) → Pi CE0 (GPIO8)
# Flash Pin2(MISO) → Pi MISO (GPIO9)
# Flash Pin3(WP) → 3.3V (或悬空,建议接到 VCC 避免写保护异常)
# Flash Pin4(GND) → Pi GND
# Flash Pin5(MOSI) → Pi MOSI (GPIO10)
# Flash Pin6(CLK) → Pi SCLK (GPIO11)
# Flash Pin7(HOLD) → 3.3V
# Flash Pin8(VCC) → Pi 3.3V ★ 只用 3.3 V,接 5 V 会烧掉 Flash
sudo modprobe spi_bcm2835
sudo flashrom -p linux_spi:dev=/dev/spidev0.0,spispeed=1000 -r fw_pi.bin
# spispeed 单位 kHz,先慢后快;读不稳就降到 500 或 200
# ===== 读出后分析 =====
file fw.bin # 看文件类型
md5sum fw.bin; sha256sum fw.bin
binwalk -E fw.bin # 熵分析(区分压缩/加密段)
binwalk fw.bin # 识别文件系统、内核、压缩包等可识别块
binwalk -Me fw.bin # 递归提取(-M 递归,-e 自动提取)
# 手工查看
xxd fw.bin | head -50
strings -n 6 fw.bin | head -100 # ASCII 字符串
strings -el fw.bin | head -50 # UTF-16 小端(某些设备的配置是宽字符)
# 找文件系统
binwalk fw.bin | grep -iE "squashfs|jffs2|cramfs|ubifs|cpio|ext|lzma|gzip|xz"
# 提取 SquashFS
# 找到偏移量后(假设 0x100000):
dd if=fw.bin of=rootfs.squashfs bs=1 skip=$((0x100000))
unsquashfs rootfs.squashfs # 解出完整根文件系统
# 找硬编码密钥
grep -rEa "(BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY)" .
grep -rEa "AKIA[0-9A-Z]{16}" . # AWS Access Key 特征
grep -rEa "['\"][A-Za-z0-9/+]{32,}['\"]" . | head -40 # 长 base64 串
grep -rEia "password|secret|apikey|api_key|token|passwd" . 2>/dev/null | head -60
grep -rEa "[0-9a-fA-F]{32,}" . | head -40 # 长十六进制串
# 常用工具链安装
sudo apt install binwalk file xxd sleuthkit squashfs-tools unsquashfs \
jefferson cramfsprogs lzop lzma xz-utils \
firmware-mod-kit mtd-utils u-boot-tools
# 高级(可选)
pip install ubi_reader # 解 UBIFS
git clone https://github.com/ReFirmLabs/binwalk && cd binwalk && sudo ./deps.sh # 完整版 binwalk(含各种解包器)
# 全功能固件分析平台:
# https://github.com/fkie-cad/FACT_core (Docker 部署,自动化分析)
# https://github.com/EMBA-support/EMBA (固件安全扫描)
方法二:拆焊读取(Flash 读不出来时的最后手段)
适用:
- 在线读取被 MCU 干扰(一直抢总线)
- 芯片是 BGA/WSON 封装,夹子夹不上
- Flash 已被读保护(取出后放编程器仍读不出,但换成新片可以排除软件层干扰)
工具:热风枪(¥150–400)或加热台、镊子、助焊剂、吸锡带、植锡网(回焊时用)
步骤:
1) 芯片四周涂助焊剂,热风枪 320–350 °C,风量中等,均匀加热,
用镊子轻推,锡熔化即可取下(不要硬撬,会扯掉焊盘)。
2) 清理焊盘与芯片引脚上的残锡(吸锡带 + 助焊剂)。
3) 放到 SOIC-8 测试座/编程器上读取。
4) 若想复原:植锡或直接用新片(Flash 很便宜),焊回去。
风险:热风会吹飞周围的小元件(用锡箔纸遮挡)、损坏塑料连接器、
焊盘脱落、ESD 损伤芯片。非必要不拆焊。
其他提取路径(不用碰 Flash 芯片):
| 路径 | 方法 | 说明 |
|---|---|---|
| UART shell | 拿到 shell 后 cat /proc/mtd + dd if=/dev/mtdX |
最省事,前提是串口没关 |
| U-Boot | sf read 读 SPI Flash 到内存再导出 |
需能进 bootloader 命令行 |
| SWD/JTAG | st-flash read / OpenOCD dump_image |
前提是没开读保护 |
| 网络 OTA | 抓固件升级包(HTTP/MQTT 明文最常见) | 抓包即可,最"文明"的方式 |
| 官方渠道 | 厂商官网下载固件包、App 里提取 | 有时官网的升级包就是完整固件,省得拆机 |
| SD 卡 / eMMC | 直接拔下卡用读卡器读 | 树莓派等板卡等于明文硬盘 |
| DFU / Recovery | USB 下载模式直接读 | 如瑞芯微的 maskrom、全志的 FEL |
14.4 硬件安全加固清单
生产层面:
□ 移除/不焊接调试排针;测试点放到 BGA 下方或灌胶
□ U-Boot:bootdelay=0,锁定环境变量,禁用串口 console
□ 内核:去掉 console= 或改为受控输出;关闭详细日志
□ 系统:删除 /etc/shadow 里的默认口令,禁用不必要的服务与端口
□ STM32:设 RDP Level 1(或 Level 2,但不可逆)
□ ESP32:烧 eFuse(Secure Boot V2 + Flash Encryption + JTAG_DISABLE)
★ 先在几片测试板上完整验证流程,eFuse 一旦烧写无法回退
□ Jetson / 高端 SoC:熔断 ODM fuse、启用 PKC/SBK 安全启动
□ 树莓派:全盘加密(LUKS)+ 安全启动 + 禁 USB 启动 + 物理封口
设计层面:
□ 固件里不要硬编码密钥(用芯片内安全存储/HSM/每设备唯一派生)
□ 串口/调试口在 PCB 上做焊盘而非排针,量产不焊接
□ 关键芯片(Flash)考虑用带加密的型号或 BGA 封装增加拆解难度
□ PCB 上不要有清晰的调试口丝印(VCC/GND/TX/RX 直接标出来等于指路)
□ 保留返修通道但做权限分级(如需要物理授权才能解锁调试口)
流程层面:
□ 出厂固件不含调试后门账号、不含测试用的 SSH 私钥
□ 升级包签名校验,且校验逻辑在 bootloader 里(不可被应用层绕过)
□ 建立漏洞响应与固件更新机制(能修 bug 比锁死更重要)
十五、本系列导航
- 本系列第 1 篇:《常见开发板选型与信息速查》——买什么板、各板规格与坑
- 本系列第 2 篇:《嵌入式开发环境配置实战》——Arduino IDE / PlatformIO / ESP-IDF / STM32Cube / 树莓派 / Jetson 环境搭建与排错
最后一条通用方法论:任何模块拿到手,按这个顺序走,能省掉大量无效调试时间——
1) 先查数据手册,确认:供电电压、接口类型、信号电平、典型时序
2) 供电,用万用表确认 VCC 引脚真的有电、GND 真的共地
3) 跑 I2C 地址扫描 / 读 WHO_AM_I / 读芯片 ID,确认通信链路
4) 跑厂商或社区提供的最小示例(不要自己先写)
5) 示例通了再改造成自己的代码
6) 出问题先怀疑:接线(40%)、电源(30%)、时序/配置(20%)、代码(10%)