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 读取。工作原理见下图。

▲ Calibration 寄存器可写,其他寄存器只读

我们需要将采样电阻的阻值信息写入 INA226。接下来,INA226 测量采样电阻压降,获得电流;电流乘以总线电压,获得功率。

INA226 有两种测量模式,即 continuous 和 triggered。正常情况是 continuous 模式,它是多次测量取平均值。每次测量时,INA226 都测量压降、测量总线电压、计算电流和功率;测量 $n$ 次之后,将数据平均值更新到寄存器中供用户读取。用户可以自行配置 $n$ 值。triggered 模式则是即时测量一次,立即更新寄存器。

💡
所有的运算都在后台进行,寄存器始终都是可读的。对于 triggered 模式,如果我们触发一次测量并立即读取寄存器,可能寄存器还未更新,导致读出旧值。不过,我们可以轮询 Mask/Enable Register,其中有一个 bit 表示数据是否已更新,从而我们可以确保读到最新数据。

测量时长(文档中称为 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。

▲ 测量时间对 noise 的影响

接下来,考虑如何编程使用 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 支持校准,以抑制系统误差。不过,笔者并没有比 INA226 更精准的电流表,故本文不进行校准。

通讯协议方面,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, &reg, 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()

一切正常:

0:00
/

现在,我们确定 INA226 和屏幕都是可用的。接下来编写功率计代码。

0x04 软件实现

回顾我们的需求:

  • 屏幕上显示当前的负载电压、电流、功耗,刷新率最好高于 30fps
  • 与此同时,用串口输出采集到的信息,频率越高越好

来考虑代码组织。首先,我们需要用 MicroPython 驱动屏幕。笔者没兴趣详细品鉴驱动代码,因此这份代码对笔者来说是黑盒,应当与其他模块隔离。容易想到两条路线:

  1. 一个 cpu 核用于驱动屏幕,另一个核用于收数据、写串口。这条路线面临的问题是:(1)Python 效率不如 C 高;(2)需要注意并发安全问题。
  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 驱动的舵机,每隔一段时间旋转一次。屏幕显示如下:

0:00
/

把串口收集到的数据画个图:

性能方面,实测刷新 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 无需修改。

💡
笔者注意到一篇论坛讨论,说 INA226 在被测端悬空状态下输出不稳定。由此,笔者在 VBUS 与 GND 之间飞线焊接了 12 k 电阻,以构成回路,期望电流为 0.41 mA,然而实测电流 2.19 mA,比悬空时更大。

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 寄存器的原始值:

▲ 空载时测得的 Vshunt 寄存器原始值。实验时长 10s,每秒采样 10 次

寄存器平均值为 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, &reg, 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, &reg, 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);
    }
}