DIY:usb 功率计
0x00 项目动机
笔者上月收拾细软离开哈尔滨时,忘了将 usb 功率计带走。重购一个之后,注意到几个以前未曾发现的问题:第一,这些功率计不会记录或输出数据,因此我们无法考察功耗随时间的变化情况;第二,帧率太低(目测每秒两帧),响应迟缓。笔者需要一个更好的功率计,正巧这段时间在学习 RP2040,于是试着自行开发。此为背景。
不妨先考虑如何仅利用 MCU 测功率。一般 MCU 上都有 ADC 可以测量对地电压,因此我们在负载之后接一个固定阻值的采样电阻 $R$,测出其对 GND 的电压,即可推算出电流,再乘以负载两端的电压,即为负载的功耗。电路设计如下图。
然而,这个方案存在几个缺点:
- MCU 毕竟不是专业 ADC 芯片,其测量存在较大的误差。按照 datasheet 中的测试,RP2040 最大积分非线性误差达到 7 LSB,微分非线性误差是 1 LSB(但在 512、1536、2560 和 3584 这四个测量点上达到 7~9 LSB)。
- RP2040 的 ADC 是 12-bit 的,只有 4096 个等级。与此同时,我们的采样电阻必须非常小(例如小于 1 ohm),才能不分走过大的电压,使负载电压维持在 3.3V 左右。做个计算:参考电压为 3.3V,则 1 LSB 表示 3.3 V / 4096 $\approx$ 0.8 mV 电压;假如使用 0.1 ohm 的采样电阻,那么电流的分辨率仅为 8mA,过于粗糙了。100mA 的期望 ADC 读数为 12.41,200mA 的期望读数为 24.82,而绝大部分量程被浪费掉。
- 我们在计算功耗时,假定了 VCC 电压是 3.3V。然而,VCC 电压能否维持稳定,也是一个问题。一方面,较大的负载可能使电压源无法维持其标称的 VCC 电压;另一方面,当负载电流突变时,电压也会随之突变(可以参考 B 站「工科男孙老师」的 LDO 评测视频)。就算以上两个问题都不碍事,电压源仍然有自身的波纹。
所以,仅依靠单片机 ADC 的方案并不实用。我们需要依赖外部器件,本文选择了德州仪器的 INA226。
0x01 INA226 数据手册阅读
INA226 的数据手册称,它可以测量 0~36V 的电压,最大增益误差为 0.1%,最大偏移为 10uV。它不仅测量采样电阻的压降,还测量总线电压,我们可以通过 I2C 协议读取电压、电流、功率。典型应用电路如下:
在上图电路中,+12V 首先经过 0.002 ohm 的采样电阻,然后连接到负载。采样电阻两端分别连接到 INA226 的 VIN+ 和 VIN- 管脚,VBUS 也连接到 VIN-。在 INA226 中,有一个 ADC 负责测量电压,要么测量 VBUS 的对地电压,要么测量 VIN+ 和 VIN- 之间的压差。测量结果会写入寄存器,由乘法器算出功耗,这些数据都能通过 I2C 读取。工作原理见下图。
我们需要将采样电阻的阻值信息写入 INA226。接下来,INA226 测量采样电阻压降,获得电流;电流乘以总线电压,获得功率。
INA226 有两种测量模式,即 continuous 和 triggered。正常情况是 continuous 模式,它是多次测量取平均值。每次测量时,INA226 都测量压降、测量总线电压、计算电流和功率;测量 $n$ 次之后,将数据平均值更新到寄存器中供用户读取。用户可以自行配置 $n$ 值。triggered 模式则是即时测量一次,立即更新寄存器。
测量时长(文档中称为 conversion time,$t_{CT}$,范围是 140us ~ 8.244ms)和平均值样本数 $n$ 都是可编程的。文档举了个例子:假如我们每 5ms 需要读一次数据,那么可以:
- 压降和总线电压的测量时长都设为 588 us,样本数设为 4。一个测量周期耗时 4.704 ms。
- 压降测量时长设为 4.156 ms,总线电压测量时长设为 588us,样本数取 1。一个测量周期耗时 4.744 ms。
显然,这两个参数都是与测量精度正相关的,开发者需要在测量时长与样本数之间进行权衡。按笔者的理解,测量时长相当于硬件滤波,样本数相当于软件滤波。默认情况下,测量时长为 1.1 ms,样本数为 1。
接下来,考虑如何编程使用 INA226。首先,我们需要把采样电阻的阻值等信息告知 INA226,即写入 Calibration 寄存器。其计算方式为:$$\texttt{Current_LSB} = \frac{最大预期电流}{2 ^ {15}}$$ $$\texttt{CalibrationReg} = \frac{0.00512}{\texttt{Current_LSB} \cdot R}$$
可以看到,INA226 允许用户自行指定电流分辨率,电流的 LSB 即为最大预期电流的 $1 / 2 ^ {15}$。在写入 Calibration 寄存器之后,电流和功率寄存器便可以开始更新了(否则恒为 0)。
文档举了一个例子:总线电压为 12 V,负载大约 10 A,采样电阻为 0.002 ohm。如果取最大预期电流为 15 A,除以 $2^{15}$,得到最细的分辨率 0.4577 mA/bit,不妨取 1mA/bit。代入公式,算得 Calibration 寄存器应该设为:$$0.00512 / (0.001 * 0.002) = 2560$$因此,我们将 Calibration 寄存器写为 2560(即 0xa00
)。INA 通过以下公式来生成电流寄存器值:$$\texttt{CurrentReg} = \frac{\texttt{ShuntVoltageReg} \cdot \texttt{CalibrationReg}}{2048}$$其中 $\texttt{ShuntVoltageReg}$ 的单位是 0.0025 mV。我们想要获取电流时,只需读出 $\texttt{CurrentReg}$ 的值,然后乘以 $\texttt{Current_LSB}$。现在来解释一下为何这样做是可行的,推个式子:$$\begin{aligned}\texttt{Current_LSB} \cdot \texttt{CurrentReg} & = \texttt{Current_LSB} \cdot \frac{\texttt{ShuntVoltageReg} \cdot \texttt{CalibrationReg}}{2048}\\ & = \texttt{Current_LSB} \cdot \frac{\texttt{ShuntVoltageReg}}{2048}\cdot \frac{0.00512}{\texttt{Current_LSB} \cdot R} \\ &= \frac{\texttt{ShuntVoltageReg}}{2048}\cdot \frac{0.00512}{ R} \\ &= \frac{压降 / 0.0000025}{2048}\cdot \frac{0.00512}{ R} \\ & = \frac{压降}{R}\end{aligned}$$
总线电压寄存器的 LSB 固定为 1.25mV。INA226 生成功率寄存器的方法是:$$\texttt{PowerReg} = \frac{\texttt{CurrentReg} \cdot \texttt{BusVoltageReg}}{20000}$$简单换算可知,$\texttt{PowerReg}$ 的 LSB(W/bit)为 $\texttt{Current_LSB}$(A/bit)的 25 倍。本例中 $\texttt{Current_LSB}$ 为 1mA,于是 $\texttt{PowerReg}$ 的 LSB 为 25mW。
通讯协议方面,INA226 支持 I2C fast mode(1 ~ 400 kHz)和 high-speed mode(1kHz ~ 2.94MHz)。默认情况(即 A0、A1 均接地)下,设备地址为 0b1000000
。
写入寄存器时,先发送 register pointer,再发送数据:
读寄存器时,并不指定地址,而是从上一次的 register pointer 读取:
可以通过写入来重新配置 register pointer:
文档还介绍了 high-speed 通讯模式,不过我们不需要那样快的通讯,所以略过。最后是 I2C 寄存器表格:
到此为止,我们阅读完了文档的主要部分。接下来,该开始设计 PCB 了。
0x02 PCB 设计
笔者的计划是提供屏幕输出和串口输出,其中屏幕使用 ST7789V3 驱动的 SPI TFT 屏幕。功率计相关电路设计如下:
- 与市场上的 usb 功率计一样,PCB 上提供 usb type A 公口,连接电脑或电源适配器,作为 5V 电压源;提供 usb type A 母口,用于连接负载
- 5V 先经过 0.1 ohm 采样电阻,再连接到 VIN-、VBUS 和负载
- 给 INA226 本身提供 3.3V 电压
最终原理图如下:
考虑 layout。笔者打算将功率计做成上下两层,其中上层是屏幕,下层是 type A 公口、type A 母口、swd、串口。两层之间用 M2 螺柱和螺丝连接。为了缩小体积,采用 0402 阻容。layout 如下:
焊接过程就是跟 0402 阻容斗智斗勇的过程,最终好歹是焊完了。实物如下:
0x03 冒烟测试
我们用到了 SPI 屏幕和 INA226 组件,需要对它们进行冒烟测试(smoke testing)。先来考虑 INA226 的测试:由于总线电压在不配置 Calibration 寄存器的情况下也能读取,故我们直接读取总线电压,如果值在 5.0V 左右,则可以认为 INA226 工作正常。代码:
#include "pico/stdlib.h"
#include "hardware/i2c.h"
#include <cstdio>
#include <initializer_list>
class InaDriver {
private:
const uint8_t ina_addr = 0b1000000;
i2c_inst_t* i2c_inst;
public:
InaDriver(i2c_inst_t* i2c_inst, uint pin_sda, uint pin_scl) {
this->i2c_inst = i2c_inst;
i2c_init(i2c_inst, 100 * 1000);
for(auto pin: {pin_sda, pin_scl}) {
gpio_set_function(pin, GPIO_FUNC_I2C);
gpio_pull_up(pin);
}
}
uint16_t read(uint8_t reg) {
static uint8_t buf[2];
// 读取之前,要先设置 register pointer
i2c_write_blocking(i2c_inst, ina_addr, ®, 1, false);
i2c_read_blocking(i2c_inst, ina_addr, buf, 2, false);
return (buf[0] << 8) | buf[1];
}
};
int main() {
stdio_init_all();
auto ina_driver = InaDriver(i2c0, 24, 25);
printf("Manufacturer ID: %x\n", ina_driver.read(0xfe));
printf("Die ID: %x\n", ina_driver.read(0xff));
auto vbus_reg = ina_driver.read(0x02);
printf("VCC: %lf V\n", 1.25 * vbus_reg / 1000);
while(true) {
tight_loop_contents();
}
}
输出如下:
Manufacturer ID: 5449
Die ID: 2260
VCC: 5.106250 V
结果符合预期(电源是取自电脑 usb 接口,万用表量出 5.12 V),判断 INA226 工作正常。
下面来测试 SPI 屏幕。首先,按照 python sdk 文档中的指引,编译一份 MicroPython 固件,打开 uart REPL(因为我们没有引出 usb 数据线)。
git clone https://github.com/micropython/micropython.git --branch master --depth=1
cd micropython
make -C ports/rp2 submodules
make -C mpy-cross
cd ports/rp2
修改 ports/rp2/mpconfigport.h
代码:
#define MICROPY_HW_ENABLE_UART_REPL (1) // useful if there is no USB
编译并烧录:
make -j16
openocd -f interface/cmsis-dap.cfg -f target/rp2040.cfg -c 'adapter speed 5000' -c "program firmware.elf verify reset"
现在串口 REPL 已经可用了:
接下来,用 MicroPython 驱动 ST7789V3。我们使用 russhughes 的驱动程序,编译进 MicroPython:
git clone https://github.com/russhughes/st7789_mpy.git --depth=1
# 前往 micropython/ports/rp2
# 这句不能省略
make clean
# 字体
cp -r ../../../st7789_mpy/fonts/ modules/
# 带上 st7789 驱动,编译 MicroPython
make USER_C_MODULES=../../../st7789_mpy/st7789/micropython.cmake -j16
看一眼 uf2 文件:
# ../../../picotool/build/picotool info -a ./build-RPI_PICO/firmware.uf2
File build-RPI_PICO/firmware.uf2:
Program Information
name: MicroPython
version: ee10360-dirty
features: thread support
USB REPL
UART REPL
frozen modules: neopixel, dht, ds18x20, onewire, uasyncio, asyncio/stream, asyncio/lock, asyncio/funcs, asyncio/event, asyncio/core,
asyncio, fonts/vector/uppmat, fonts/vector/symbol, fonts/vector/scripts, fonts/vector/scriptc, fonts/vector/romant,
fonts/vector/romans, fonts/vector/romanp, fonts/vector/romand, fonts/vector/romancs, fonts/vector/romanc,
fonts/vector/music, fonts/vector/meteo, fonts/vector/marker, fonts/vector/lowmat, fonts/vector/italict,
fonts/vector/italiccs, fonts/vector/italicc, fonts/vector/greeks, fonts/vector/greekp, fonts/vector/greekcs,
fonts/vector/greekc, fonts/vector/gothita, fonts/vector/gothger, fonts/vector/gotheng, fonts/vector/cyrilc,
fonts/vector/astrol, fonts/truetype/NotoSerif_32, fonts/truetype/NotoSans_32, fonts/truetype/NotoSansMono_32,
fonts/bitmap/vga2_bold_16x32, fonts/bitmap/vga2_bold_16x16, fonts/bitmap/vga2_8x8, fonts/bitmap/vga2_8x16,
fonts/bitmap/vga2_16x32, fonts/bitmap/vga2_16x16, fonts/bitmap/vga1_bold_16x32, fonts/bitmap/vga1_bold_16x16,
fonts/bitmap/vga1_8x8, fonts/bitmap/vga1_8x16, fonts/bitmap/vga1_16x32, fonts/bitmap/vga1_16x16, _boot_fat, _boot, rp2
binary start: 0x10000000
binary end: 0x100939d4
embedded drive: 0x100a0000-0x10200000 (1408K): MicroPython
Fixed Pin Information
0: UART0 TX
1: UART0 RX
Build Information
sdk version: 1.5.1
pico_board: pico
boot2_name: boot2_w25q080
build date: Jul 15 2024
build attributes: MinSizeRel
现在我们已经编译好了带 ST7789 驱动的、打开串口 REPL 的 MicroPython 固件。跑一个测试:
import machine
import random
import utime
import st7789
import fonts.bitmap.vga2_bold_16x32 as font
spi = machine.SPI(0, baudrate=10000000, polarity=0, sck=machine.Pin(22), mosi=machine.Pin(19))
tft = st7789.ST7789(
spi,
240,
300,
reset=machine.Pin(23, machine.Pin.OUT),
dc=machine.Pin(20, machine.Pin.OUT),
backlight=machine.Pin(18, machine.Pin.OUT),
cs=machine.Pin(21, machine.Pin.OUT)
)
def center(text):
length = 1 if isinstance(text, int) else len(text)
tft.text(
font,
text,
tft.width() // 2 - length // 2 * font.WIDTH,
tft.height() // 2 - font.HEIGHT //2,
st7789.WHITE,
st7789.RED)
def main():
tft.init()
tft.fill(st7789.RED)
center(b'\xAEHello\xAF')
utime.sleep(2)
tft.fill(st7789.BLACK)
while True:
for rotation in range(4):
tft.rotation(rotation)
tft.fill(0)
col_max = tft.width() - font.WIDTH*6
row_max = tft.height() - font.HEIGHT
for _ in range(128):
tft.text(
font,
b'Hello!',
random.randint(0, col_max),
random.randint(0, row_max),
st7789.color565(
random.getrandbits(8),
random.getrandbits(8),
random.getrandbits(8)),
st7789.color565(
random.getrandbits(8),
random.getrandbits(8),
random.getrandbits(8)))
main()
一切正常:
现在,我们确定 INA226 和屏幕都是可用的。接下来编写功率计代码。
0x04 软件实现
回顾我们的需求:
- 屏幕上显示当前的负载电压、电流、功耗,刷新率最好高于 30fps
- 与此同时,用串口输出采集到的信息,频率越高越好
来考虑代码组织。首先,我们需要用 MicroPython 驱动屏幕。笔者没兴趣详细品鉴驱动代码,因此这份代码对笔者来说是黑盒,应当与其他模块隔离。容易想到两条路线:
- 一个 cpu 核用于驱动屏幕,另一个核用于收数据、写串口。这条路线面临的问题是:(1)Python 效率不如 C 高;(2)需要注意并发安全问题。
- 一个 cpu 核用于驱动屏幕,另一个核闲置,用 PIO + DMA 收数据、写串口。这个方案的优点在于延迟可控,缺点在于代码复杂:用一个状态机与 INA226 连续通讯(可能要用 PIO 自行实现一份 I2C 协议),用 DMA 把读到的数据转发给另一个状态机,该状态机负责把数据原样写入串口(串口也要自行用 PIO 实现),并每隔 $k$ 个样本取一个送进 fifo,由 Python 代码读取这个状态机的 fifo。
前文提到,默认配置下,INA226 的一个测量周期是 1.1ms。这是很慢的频率,显然上述两种方案都可行。我们采用代码难度更低的第一种方案。
接下来考虑 INA226 设置。我们 usb 功率计的主要用途是测量 MCU 和外围设备的总功耗,一般而言是 100mA 量级,可以认为不会超过 2A。采样电阻阻值为 0.1 ohm,所以:$$\begin{aligned}\texttt{Current_LSB} &= \frac{2}{2 ^ {15}} \approx 6.10 \times 10^{-5} \\ \texttt{CalibrationReg} &= \frac{0.00512}{1/ 2 ^{14} \times 0.1} \approx 838.86 \end{aligned}$$微调一下,让 $\texttt{CalibrationReg}$ 为整数:$$\begin{aligned}\texttt{CalibrationReg} &= 840 \\ \texttt{Current_LSB} &= \frac{0.00512}{\texttt{CalibrationReg}\cdot R} = \frac{0.00512}{84} \approx 6.0952 \times 10 ^ {-5}\end{aligned}$$
代码如下:
import machine
import random
import time
import struct
import st7789
import _thread
import collections
import fonts.bitmap.vga2_16x32 as font
current_lsb = 0.00512 / 84
class InaDriver():
def __init__(self):
self.i2c = machine.I2C(0, sda=machine.Pin(24), scl=machine.Pin(25), freq=300_000)
self.device_addr = 0b1000000
assert self.read_reg(0xff) == 0x2260
assert self.read_reg(0xfe) == 0x5449
self.write_reg(0x05, 840)
def write_reg(self, reg, val):
payload = struct.pack('>BH', reg, val)
self.i2c.writeto(self.device_addr, payload)
def read_reg(self, reg):
self.i2c.writeto(self.device_addr, struct.pack('>B', reg))
res = self.i2c.readfrom(self.device_addr, 2)
return struct.unpack('>H', res)[0]
def read_all_data(self):
self.i2c.writeto(self.device_addr, b'\x02')
reg_bus_voltage = struct.unpack('>H', self.i2c.readfrom(self.device_addr, 2))[0]
self.i2c.writeto(self.device_addr, b'\x03')
reg_power = struct.unpack('>H', self.i2c.readfrom(self.device_addr, 2))[0]
self.i2c.writeto(self.device_addr, b'\x04')
reg_current = struct.unpack('>H', self.i2c.readfrom(self.device_addr, 2))[0]
return (
time.ticks_us(),
reg_bus_voltage * 1.25e-3,
reg_power * current_lsb * 25 * 1000,
reg_current * current_lsb * 1000
)
class TftDisplay():
def __init__(self):
spi = machine.SPI(0, baudrate=100_000_000, polarity=0, sck=machine.Pin(22), mosi=machine.Pin(19))
self.display = st7789.ST7789(
spi,
240,
300,
reset=machine.Pin(23, machine.Pin.OUT),
dc=machine.Pin(20, machine.Pin.OUT),
backlight=machine.Pin(18, machine.Pin.OUT),
cs=machine.Pin(21, machine.Pin.OUT)
)
self.display.init()
self.display.fill(st7789.BLACK)
self.latest_data = None
self.line_pos = 0
self.lock = _thread.allocate_lock()
def update_ina_data(self, data):
with self.lock:
self.latest_data = data
def show_status(self):
with self.lock:
if self.latest_data is None:
return
reg_bus_voltage, reg_power, reg_current = self.latest_data[1:]
status = {
'bus_voltage': '{:9.4f} V'.format(reg_bus_voltage),
'current': '{:9.4f} mA'.format(reg_current),
'power': '{:9.4f} mW'.format(reg_power),
}
self.display.text(
font,
status['bus_voltage'],
10,
40,
st7789.RED,
st7789.BLACK)
self.display.text(
font,
status['current'],
10,
80,
st7789.GREEN,
st7789.BLACK)
self.display.text(
font,
status['power'],
10,
120,
st7789.CYAN,
st7789.BLACK)
self.display.vline(10 + self.line_pos, 180, 100, st7789.BLACK)
h = min(round(reg_power), 2000) // 20
self.display.vline(10 + self.line_pos, 280 - h, h, st7789.YELLOW)
self.line_pos = (self.line_pos + 1) % 220
self.display.fill_rect(10 + self.line_pos, 180, min(3, 220-self.line_pos), 100, st7789.RED)
ina = InaDriver()
display = TftDisplay()
time.sleep_ms(1000)
def task_display(display):
while True:
display.show_status()
_thread.start_new_thread(task_display, [display])
while True:
data = ina.read_all_data()
display.update_ina_data(data)
print(data, end=',\n')
0x05 测试
我们使用如下负载来测试功率计:一个由 Pi Pico W 驱动的舵机,每隔一段时间旋转一次。屏幕显示如下:
把串口收集到的数据画个图:
性能方面,实测刷新 1000 次耗时 15378825 us,帧率为 65.02 fps。串口平均每秒收到 281.92 条数据,每条数据采集和传输耗时 3.546ms。我们达成了本文开头提出的目标。
用功率计测量了一些开发板在运行 MicroPython 时的功耗,供参考:
MCU | 开发板 | 任务 | 频率 | 电流 | 功耗 | 备注 |
---|---|---|---|---|---|---|
RP2040 | Pi Pico | 闲置 | 125MHz | 25mA | 128mW | |
RP2040 | Pi Pico | 数值运算 | 125MHz | 31mA | 156mW | 循环运行 a+=1 |
RP2040 | Pi Pico | 闲置 | 200MHz | 36mA | 183mW | |
RP2040 | Pi Pico | 数值运算 | 200MHz | 42mA | 210mW | 循环运行 a+=1 |
RP2040 | Pi Pico | 闲置 | 20MHz | 12mA | 62mW | |
RP2040 | Pi Pico | 数值运算 | 20MHz | 13mA | 65mW | 循环运行 a+=1 |
RP2040 | 微雪 RP2040-Zero | 闲置 | 125MHz | 33mA | 167mW | |
RP2040 | 微雪 RP2040-Matrix | 点亮 25 颗 WS2812 | 125MHz | 527mA | 2444mW | usb 电压降到了 4.63V |
RP2040 | 合宙 Pico 兼容板 | 闲置 | 125MHz | 33mA | 165mW | |
RP2040 | 笔者自制-1 | 闲置 | 125MHz | 34mA | 172mW | LDO 选用 RT9013 |
RP2040 | Pi Pico W | 闲置 | 125MHz | 30mA | 150mW | |
RP2040 | Pi Pico W | CYW43 启动后闲置 | 125MHz | 54mA | 270mW | |
RP2040 | Pi Pico W | WiFi 扫描 | 125MHz | 80mA | 400mW | 连接到 wifi 后保持此功耗 |
ESP32-C3 | ABrobot 彩色开发板 | 闲置 | 160MHz | 28mA | 140mW | |
ESP32-C3 | ABrobot 彩色开发板 | 闲置 | 20MHz | 15mA | 75mW | |
ESP32-C3 | 合宙 ESP32C3-CORE | 闲置 | 160MHz | 29mA | 147mW | |
ESP32-C3 | 合宙 ESP32C3-CORE | 闲置 | 20MHz | 15mA | 77mW | |
ESP32-C3 | 合宙 ESP32C3-CORE | WiFi 扫描 | 160MHz | 109mA | 540mW |
0x06 考虑改进
注意到功率计在不接任何负载的情况下,测出 1.58mA 的电流、7.62 mW 功率。更换 INA226 芯片之后,仍然是这个状态。考虑到量程是 2A,这个结果在误差允许范围内。现在来讨论如何改进。
先考虑硬件误差,笔者使用的采样电阻的精度只有 1%,可以更换成 0.1% 的电阻。另外,鉴于压降的分辨率是固定的 2.5uV,我们可以提升采样电阻的阻值,从而提高采样电阻获得的分压,以提升测量精度。简单计算:对于 0.1 ohm 的采样电阻,电流分辨率为 25 uA;对于 1 ohm 的采样电阻,电流分辨率则为 2.5 uA,精细了十倍。当然,提升采样电阻的阻值是有代价的,原本 0.1 ohm 在 1A 负载下只分走 0.1V 电压,而现在 1 ohm 采样电阻要分走 1V 电压。不过,对于低负载情况(100 mA 以下量级),1 ohm 采样电阻可以接受。
再考虑 INA226 本身的误差。事实上,它有一个升级版本 INA228,也采用 I2C 协议,主要区别如下:
- INA228 支持 85V 电压(INA226 是 36V)
- INA228 的 ADC 精度为 20 bit(INA226 是 16 bit)
- INA228 的增益误差为 0.05%(INA226 是 0.1%)
- INA228 最大偏移为 1 uV(INA226 是 10 uV)
- INA228 拥有内置温度传感器,可以自动补偿电阻温漂
因此,我们考虑把芯片换成 INA228,pcb 无需修改。
0x07 改用 INA228
INA228 的引脚布局与 INA226 相同,也采用 I2C 协议,但通讯方式有很大区别。INA226 的所有寄存器都是 16 bit 的,而 INA228 则不然。寄存器清单如下:
INA226 对于压降,有两种量程。默认情况下 ADCRANGE=0
,量程为 ±163.84 mV,分辨率 312.5 nV/LSB;否则量程为 ±40.96 mV,分辨率 78.125 nV/LSB。
稍加计算:我们使用 0.1 ohm 的电阻,在 ADCRANGE=0
模式,电流量程为 1638.4 mA;在 ADCRANGE=1
模式,量程为 409.6 mA。使用 0.5 ohm 电阻,ADCRANGE=0
模式电流量程为 327.68 mA,比较合适。
Cal 寄存器计算方式:$$\texttt{Current_LSB} = \frac{最大预期电流}{2 ^ {19}}$$ $$\texttt{ShuntCalibrationReg} = 13107.2 \times 10 ^ 6 \cdot \texttt{Current_LSB} \cdot R$$
测出的电流的值即为 $\texttt{CurrentReg}$ 乘以 $\texttt{Current_LSB}$,单位为 A;功率值为 $\texttt{PowerReg} \cdot 3.2 \cdot \texttt{Current_LSB}$,单位为 W。
现在计算 Cal 寄存器。最大预期电流为 327.68 mA,于是:$$\texttt{Current_LSB} = \frac{327.68 \times 10 ^ {-3}}{2 ^ {19}} = 6.25\times 10 ^ {-7}$$ $$\texttt{ShuntCalibrationReg} = 13107.2 \times 10 ^ 6 \times 7.8125\times 10 ^ {-7} \times 0.5 = 4096$$
代码:
import machine
import random
import time
import struct
import st7789
import _thread
import collections
import fonts.bitmap.vga2_16x32 as font
class Ina228Driver():
def __init__(self):
self.i2c = machine.I2C(0, sda=machine.Pin(24), scl=machine.Pin(25), freq=300_000)
self.device_addr = 0b1000000
assert self.read_reg(0x3e, 16) == 0x5449
assert self.read_reg(0x3f, 16) == 0x2281
self.write_reg(0x02, struct.pack('>H', 4096))
assert self.read_reg(0x02, 16) == 4096
def write_reg(self, reg, val):
self.i2c.writeto(self.device_addr, struct.pack('>B', reg) + val)
def read_reg(self, reg, bits):
self.i2c.writeto(self.device_addr, struct.pack('>B', reg))
n = bits // 8
if bits % 8 != 0:
n += 1
res = self.i2c.readfrom(self.device_addr, n)
res = int.from_bytes(res, 'big')
return res >> (n*8 - bits)
def read_all_data(self):
return (
time.ticks_us(),
self.read_reg(0x05, 20) * 0.1953125 * 1e-3,
self.read_reg(0x08, 24) * 3.2 * 6.25e-04,
self.read_reg(0x07, 20) * 6.25e-04,
)
空载场景下,测得电流 0.31 mA,显著优于改进前的方案。不过,这个精度提升主要是电阻从 0.1 ohm 更换到 0.5 ohm 带来的。实验表明,如果采用 0.1 ohm 的采样电阻,则 INA228 的测量结果与 INA226 几乎一致。
0x08 进一步调试
既然换到 INA228 仍然没有提升,我们需要重新仔细考虑问题。为何会在空载状态下测出电流?是因为 INA226 测出采样电阻两端的电压不为 0,做个乘法之后自然会产生非 0 的结果。现在我们对 INA226 功率计进行实验,观察 Vshunt 寄存器的原始值:
寄存器平均值为 62.95,而 Vshunt 分辨率为 0.0025 mV,也就是说,平均测得 0.1574 mV 电压,除以 0.1 ohm 电阻,得到 1.574 mA 电流。与我们先前的观测一致。
事有凑巧,笔者手头有一个 INA226 模块。用 STM32G0 驱动它:
class Ina226Driver {
private:
I2C_HandleTypeDef *i2c;
public:
explicit Ina226Driver() {
this->i2c = &hi2c2;
}
void write_reg(uint8_t reg, uint16_t data) {
uint8_t buf[3] = {reg, (uint8_t)(data >> 8), (uint8_t)(data & 0xff)};
HAL_I2C_Master_Transmit(i2c,INA226_I2C_ADDR,buf,3,HAL_MAX_DELAY);
}
uint16_t read_reg(uint8_t reg) {
uint8_t buf[2];
HAL_I2C_Master_Transmit(i2c, INA226_I2C_ADDR, ®, 1, HAL_MAX_DELAY);
HAL_I2C_Master_Receive(i2c, INA226_I2C_ADDR, buf, 2, HAL_MAX_DELAY);
return (buf[0] << 8) | buf[1];
}
};
对比如下:
INA226 模块测得的寄存器值分布在 $[-2, 2]$ 之间,极为精确。显然 INA226 拥有精确测量的能力。是否我们的测量电路不合理,导致 INA226 在我们的 PCB 上工作异常?模块商家没有提供原理图,笔者只能抄板。最后发现模块的原理图与笔者的设计完全一致。
现在考虑 INA226 芯片本身是否有问题。先用立创商城购买的 INA226 替换模块上的 INA226,发现模块精度仍然很高;将模块上的 INA226 转移到功率计上,发现功率计精度提升,但未达到模块水平:
由于两块 INA226 芯片在模块上都表现良好,推测 INA226 芯片没有问题、我们的 PCB 存在设计缺陷。既然原理图与模块是一致的,那应该问题就在于 layout 了。模块的 layout 如下:
而笔者功率计的 layout 如下:
INA226 文档中的布线要求是:
使用四线开尔文连接法(Kelvin connection)将输入引脚(IN+和IN-)连接到检测电阻。这些连接技术确保在输入引脚之间只检测到电流检测电阻的阻抗。电流检测电阻的不良布线通常会导致输入引脚之间存在额外的电阻。考虑到电流检测电阻的欧姆值非常低,任何额外的大电流承载阻抗都会导致显著的测量误差。
我们的 VIN+ 引脚走了很远的细线才连到采样电阻上,这可能是误差的来源。如果这个推测成立,那么,飞线连接 VIN+ 引脚和采样电阻的 VIN+ 端,可以提供阻抗更小的通路,应当能提升测量精度。笔者测试了两种飞线方式:细铜丝(电阻较大)和铁针(电阻较小)。焊接如下:
测量结果:
可见,细铜丝飞线后,测量误差有所下降;换成铁针后,测量误差几乎与模块处于同一水平。所以,问题确实出在 layout 上。我们应该改善布线方式。
0x09 布线改进
新的版本:
我们本次使用 INA228,配合 0.1 ohm 电阻。使用 ADCRANGE=0
模式,电流量程为 1638.4 mA。在改进 layout 之后,INA228 在空载状态下测得平均电压为 6.25 uV,对应电流 62.5 uA,效果已经足够好。
接下来配置 INA228,以便读取电流和功率寄存器。计算:$$\texttt{Current_LSB} = \frac{1.6384}{2 ^ {19}} = 3.125 \times 10 ^ {-6}$$ $$\texttt{ShuntCalibrationReg} = 13107.2 \times 10 ^ 6 \cdot 3.125 \times 10 ^ {-6} \times 0.1 = 4096$$
最终代码:
import machine
import random
import time
import struct
import st7789
import _thread
import collections
import fonts.bitmap.vga2_16x32 as font
class Ina228Driver():
def __init__(self):
self.i2c = machine.I2C(0, sda=machine.Pin(24), scl=machine.Pin(25), freq=300_000)
self.device_addr = 0b1000000
assert self.read_reg(0x3e, 16) == 0x5449
assert self.read_reg(0x3f, 16) == 0x2281
self.write_reg(0x02, struct.pack('>H', 4096))
assert self.read_reg(0x02, 16) == 4096
reg_adc_config = 0b1111_100_110_000_001
self.write_reg(0x01, struct.pack('>H', reg_adc_config))
assert self.read_reg(0x01, 16) == reg_adc_config
def write_reg(self, reg, val):
self.i2c.writeto(self.device_addr, struct.pack('>B', reg) + val)
def read_reg(self, reg, bits, is_signed=False):
self.i2c.writeto(self.device_addr, struct.pack('>B', reg))
n = bits // 8
if bits % 8 != 0:
n += 1
res = self.i2c.readfrom(self.device_addr, n)
res = int.from_bytes(res, 'big')
res = res >> (n*8 - bits)
if is_signed == False:
return res
# 补码
sign_bit = res >> (bits - 1)
if sign_bit == 0:
return res
else:
return -((1 << bits) - res)
def read_all_data(self):
return (
time.ticks_us(),
self.read_reg(0x05, 20, is_signed=True) * 0.1953125 * 1e-3,
self.read_reg(0x08, 24, is_signed=True) * 3.2 * 3.125e-3,
self.read_reg(0x07, 20, is_signed=True) * 3.125e-3,
)
class TftDisplay():
def __init__(self):
spi = machine.SPI(0, baudrate=100_000_000, polarity=0, sck=machine.Pin(22), mosi=machine.Pin(19))
self.display = st7789.ST7789(
spi,
240,
300,
reset=machine.Pin(23, machine.Pin.OUT),
dc=machine.Pin(20, machine.Pin.OUT),
backlight=machine.Pin(18, machine.Pin.OUT),
cs=machine.Pin(21, machine.Pin.OUT)
)
self.display.init()
self.display.fill(st7789.BLACK)
self.latest_data = None
self.line_pos = 0
self.lock = _thread.allocate_lock()
self.update_cnt = 0
def update_ina_data(self, data):
with self.lock:
self.latest_data = data
def show_status(self):
with self.lock:
if self.latest_data is None:
return
reg_bus_voltage, reg_power, reg_current = self.latest_data[1:]
status = {
'bus_voltage': '{:9.4f} V'.format(reg_bus_voltage),
'current': '{:9.4f} mA'.format(reg_current),
'power': '{:9.4f} mW'.format(reg_power),
}
self.update_cnt += 1
if self.update_cnt == 20:
self.update_cnt = 0
self.display.text(
font,
status['bus_voltage'],
10,
40,
st7789.RED,
st7789.BLACK)
self.display.text(
font,
status['current'],
10,
80,
st7789.GREEN,
st7789.BLACK)
self.display.text(
font,
status['power'],
10,
120,
st7789.CYAN,
st7789.BLACK)
self.display.vline(10 + self.line_pos, 180, 100, st7789.BLACK)
h = max(0, min(100, round(reg_power / 2000 * 100)))
self.display.vline(10 + self.line_pos, 280 - h, h, st7789.YELLOW)
self.line_pos = (self.line_pos + 1) % 220
self.display.fill_rect(10 + self.line_pos, 180, min(3, 220-self.line_pos), 100, st7789.RED)
time.sleep(.01)
def work():
ina = Ina228Driver()
display = TftDisplay()
def task_display(display):
while True:
display.show_status()
_thread.start_new_thread(task_display, [display])
while True:
data = ina.read_all_data()
display.update_ina_data(data)
# print(data, end=',\n')
work()
0x10 后记
笔者在实验中发现,插入 LCD 屏幕会使得测量准确度严重下降。于是笔者制作了一个采样电阻为 0.5 ohm、不插入屏幕,仅通过串口输出的功率计。这个功率计非常精确,空载状态下测得电流均值仅为 4.034 uA。与 INA226(采样电阻 0.1 ohm)的对比图:
代码:
#include "pico/stdlib.h"
#include "hardware/i2c.h"
#include <cstdio>
#include <initializer_list>
class InaDriver {
private:
const uint8_t ina_addr = 0b1000000;
i2c_inst_t* i2c_inst;
public:
InaDriver(i2c_inst_t* i2c_inst, uint pin_sda, uint pin_scl) {
this->i2c_inst = i2c_inst;
i2c_init(i2c_inst, 100 * 1000);
for(auto pin: {pin_sda, pin_scl}) {
gpio_set_function(pin, GPIO_FUNC_I2C);
gpio_pull_up(pin);
}
assert(read(0x3e, 2) == 0x5449);
assert(read(0x3f, 2) == 0x2281);
uint16_t adc_config = 0b1111'111'111'000'000;
write_16(0x01, adc_config);
assert(read(0x01, 2) == adc_config);
write_16(0x02, 4096);
assert(read(0x02, 2) == 4096);
}
uint32_t read(uint8_t reg, size_t n_bytes) {
uint32_t rx = 0;
i2c_write_blocking(i2c_inst, ina_addr, ®, 1, false);
i2c_read_blocking(i2c_inst, ina_addr, (uint8_t *)(&rx), n_bytes, false);
return __builtin_bswap32(rx) >> (32 - 8 * n_bytes);
}
void write_16(uint8_t reg, uint16_t val) {
uint8_t buf[3] = {reg, (uint8_t)(val >> 8), (uint8_t)(val & 0xff)};
i2c_write_blocking(i2c_inst, ina_addr, buf, 3, false);
}
};
int main() {
// stdio_init_all();
auto ina_driver = InaDriver(i2c0, 24, 25);
auto uint24_to_int = [](uint32_t x) -> int32_t {
return (int32_t)(x << 8) >> 12;
};
gpio_set_function(0, GPIO_FUNC_UART);
gpio_set_function(1, GPIO_FUNC_UART);
uart_init(uart0, 115200);
while(true) {
int32_t vbus_raw = uint24_to_int(ina_driver.read(0x05, 3));
double vbus_mV = (double)(vbus_raw) * 0.1953125;
int32_t vshunt_raw = uint24_to_int(ina_driver.read(0x04, 3));
double vshunt_uV = (double)(vshunt_raw) * 0.3125;
int32_t current_raw = uint24_to_int(ina_driver.read(0x07, 3));
double current_mA = (double)(current_raw) * 6.25e-4;
// power 是 24 bit 正数
int32_t power_raw = int(ina_driver.read(0x08, 3));
double power_mW = (double)(power_raw) * 3.2 * 6.25e-4;
char buf[1024];
int len = sprintf(buf,
"{"
"\"timestamp_ms\":%lu,"
"\"vbus_raw\":%ld,"
"\"vbus_mV\":%.8lf,"
"\"vshunt_raw\":%ld,"
"\"vshunt_uV\":%.8lf,"
"\"current_raw\":%ld,"
"\"current_mA\":%.8lf,"
"\"power_raw\":%ld,"
"\"power_mW\":%.8lf"
"},",
to_ms_since_boot(get_absolute_time()), vbus_raw, vbus_mV, vshunt_raw, vshunt_uV, current_raw, current_mA, power_raw, power_mW);
uart_write_blocking(uart0, (uint8_t *)(buf), len);
sleep_ms(100);
}
}