电量计 -- 低功耗与响应性能设计(一):I2C中断、低功耗与RISC-V底层实现

低功耗与响应性能设计(一):I2C中断、低功耗与RISC-V底层实现

电量计芯片的固件面临三重约束:低功耗(电池供电,待机电流μA级)、实时响应(I2C从机必须在协议时序内应答)、资源受限(RISC-V小核,KB级RAM)。这三者相互矛盾——低功耗要求CPU尽量睡眠,实时响应要求CPU随时可用,资源受限要求代码路径尽可能短。

本文以purdy_g2电量计固件为例,展示如何在RISC-V平台上做这些工程权衡。该芯片基于HBird E200 RISC-V内核,32MHz主频,配备PLIC中断控制器、硬件I2C从机、VADC/CADC双ADC子系统。

I2C从机中断驱动架构

电量计通过I2C/SMBus与主机通信,遵循SBS(Smart Battery Specification)协议。主机随时可能发起读写请求,固件必须在I2C时钟的SCL周期内完成应答,否则总线超时。

状态机设计

I2C从机采用三态状态机,完全由中断驱动:

1
2
3
4
5
6
stateDiagram-v2
[*] --> IDLE
IDLE --> SLVRCV : "地址匹配 (写方向)"
SLVRCV --> SLVSND : "重复START (读方向)"
SLVRCV --> IDLE : "STOP / NACK"
SLVSND --> IDLE : "STOP / NACK"

典型的SMBus读事务流程:

  1. 主机发START + 从机地址(W) → 进入SLVRCV
  2. 主机发命令字节 → 从机查表确定数据长度
  3. 主机发重复START + 从机地址(R) → 进入SLVSND
  4. 从机逐字节发送数据 + PEC校验字节
  5. 主机发NACK + STOP → 回到IDLE

中断处理核心逻辑

1
2
3
4
5
6
7
8
void I2C_IRQHandler(void)
{
if ((i2cif_p->i2c_state == I2C_XFER_MSTSND) ||
(i2cif_p->i2c_state == I2C_XFER_MSTRCV))
i2cif_master_handle();
else
i2cif_slave_handle(); // 正常路径:始终为从机模式
}

i2cif_slave_handle() 是状态机的核心,约300行代码处理所有I2C事件:

地址匹配 → 进入接收状态:

1
2
3
4
5
6
// START检测,写方向
i2cif_p->i2c_state = I2C_XFER_SLVRCV;
i2cif_p->rx_idx = 0;
i2cif_p->tx_idx = 0;
i2cif_p->i2c_cmd = 0;
i2cif_p->i2c_pec = i2cif_calc_pec((uint8_t)i2cif_p->slv_addr, 0x00);

第一个数据字节是命令码,查表确定协议参数:

1
2
3
4
5
6
7
8
9
10
11
12
if (i2cif_p->rx_idx == 0) {
i2cif_p->i2c_cmd = (uint8_t)i2cif_p->data;
// 线性查找命令定义表
do {
if (((sbsd_cmd_def[i2cif_p->sbd_idx] & SBSD_CMD_Msk)
>> SBSD_CMD_Pos) == i2cif_p->i2c_cmd)
break;
} while ((i2cif_p->sbd_idx++) < SBSD_CMD_MAX);
// 从命令定义中提取数据长度
i2cif_p->rx_size = ((sbsd_cmd_def[i2cif_p->sbd_idx]
& SBSD_LEN_Msk) >> SBSD_LEN_Pos) + 1;
}

接收完成后通过回调通知业务层:

1
2
3
4
5
if (i2cif_p->rx_idx == i2cif_p->rx_size && !i2cif_p->i2c_pec_enable) {
i2cif_p->i2c_status = I2C_STATUS_RXDONE;
if (i2cif_p->i2c_callback_f)
i2cif_p->i2c_callback_f(i2cif_p->i2c_status, i2cif_p->sbd_idx);
}

命令定义表

每个SBS命令用一个32位整数编码所有协议信息:

1
2
3
4
5
// sbsd_cmd_def[] 每个条目的位域编码:
// [7:0] 命令号 (如 0x09 = Voltage, 0x0A = Current)
// [11:8] 数据长度 (字节数 - 1)
// [13:12] 方向 (读/写/读写)
// [15:14] 类型 (word/block/string)

这种紧凑编码使得整个命令表只占几百字节Flash,查表时一次内存访问即可获取所有协议参数。

PEC校验

SMBus的PEC(Packet Error Checking)是CRC-8校验,覆盖从地址字节到最后一个数据字节的所有内容。固件在每个字节收发时递增计算PEC:

1
2
3
// 发送阶段:数据发完后追加PEC字节
if (tx_idx == tx_size && i2cif_p->i2c_pec_enable)
data = i2cif_p->i2c_pec; // PEC作为最后一个字节发出

I2C活动对睡眠的影响

I2C通信会清除睡眠准备标志,推迟CPU进入低功耗模式:

1
2
3
4
5
// sbs_callback_i2c_slave() 中
if (status == I2C_STATUS_TXDATA) {
stm_p->stm_flag &= (~STMFLAG_SLEEP_PREPARE);
stm_p->stm_sleep_delay = DEFAULT_TIMER_VAL; // 重置睡眠延迟
}

这确保了一次I2C事务的多个阶段(写命令→读数据)不会被睡眠打断。

低功耗设计

睡眠模式策略

固件支持两级睡眠:Sleep(浅睡眠)和Deep Sleep(深度睡眠)。两者的区别在于哪些硬件模块保持运行:

模式 CPU MTIMER VADC CADC I2C唤醒
Active 运行 运行 运行 运行 -
Sleep 停止 运行 自动扫描 运行 支持
Deep Sleep 停止 停止 停止 可选 支持

睡眠进入由状态机控制,不是简单的空闲检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// stm_translate_pwrmd() — 睡眠延迟倒计时
if (stm_p->stm_flag & STMFLAG_SLEEP_PREPARE) {
// 首次设置:启动延迟计时器
if (stm_p->stm_sleep_delay == DEFAULT_TIMER_VAL) {
stm_p->stm_sleep_delay = timer_set_target(
param_board_cfg[PARM_BCFG_IDLETOSLEEP] - 1);
}
// 延迟到期:进入睡眠
if (systime_get_sec() >= stm_p->stm_sleep_delay) {
stm_p->pwrmd = STM_PWRMD_SLEEP_START;
uSleepTime = stm_drive_sleep(uSleepTime, STM_SLPMD_SLEEP);
stm_p->stm_flag &= (~STMFLAG_SLEEP_PREPARE);
stm_p->stm_sleep_delay = DEFAULT_TIMER_VAL;
}
}

stm_drive_sleep() 是实际进入硬件睡眠的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
uint32_t stm_drive_sleep(uint32_t slptime, STM_SLPMD_T slpmd_ctrl)
{
// 安全检查:充电满电校准期间不允许睡眠
if (g_internal_charge_full_flag == 1) return 0;
if (calibration_flag == 1) return 0;

// 配置唤醒源
Chip_DFE_ClearWakeupFLAG(WAKEUP_SOURCE_ALL);
wake_source = PROT_WKUP_SAFETY_ENABLE | PROT_WKUP_CADC_ENABLE
| PROT_WKUP_TIMER1_ENABLE | PROT_WKUP_I2C_ENABLE
| PROT_WKUP_STP_ENABLE;
Chip_DFE_SetWakeupOption(wake_source);

// 设置Timer1作为睡眠时长看门狗
timer_setup_timer1((slptime) - 1, 15); // mark=15 → 1Hz时钟
Chip_Timer_reload(CH_TIMER1);

// 进入睡眠
if (slpmd_ctrl == STM_SLPMD_DEEPSLP)
syshw_enter_deepslp();
else
syshw_enter_sleep();
// ... 唤醒后继续执行
}

主循环与WFI

主循环采用经典的超级循环(super loop)模式,每次迭代末尾执行__WFI()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void main_loop()
{
systime_update_EX();

if (systime_passed_sec()) { // 1秒周期任务
dacq_update_cadc(); // 读取CADC数据
syshw_feed_wdt(); // 喂狗
dacq_update(); // 更新ADC通道
sbsif_update(); // 更新SBS数据
stm_check_imode(); // 判断充电/放电/静置

sleep_time = stm_update(); // 状态机,可能进入睡眠
}

__WFI(); // CPU停止,等待下一个中断(125ms mtime tick)
}

__WFI()是RISC-V的Wait For Interrupt指令,CPU时钟门控但不关断电源域。mtime每125ms产生一次中断唤醒CPU,systime_passed_sec()在8次125ms tick后返回true,触发1秒周期的业务逻辑。

定时器与唤醒

MTIMER是RISC-V标准的机器级定时器,由32.768kHz低频振荡器驱动,在Sleep模式下保持运行:

1
2
3
#define MTIMER_FREQ     32768          // 32kHz时钟源
#define INTERVAL_125MS (MTIMER_FREQ / 8) // = 4096 ticks
#define TICKRATE_1S 8 // 8 × 125ms = 1秒

每次mtime中断触发后,重新设置下一次比较值:

1
2
3
4
__STATIC_INLINE void MTimer_Config()
{
Set_mtimecmp_Value(Get_mtime_Value() + INTERVAL_125MS);
}

进入Deep Sleep时,通过自定义CSR mcounterstop(地址0xBFF)停止所有计数器:

1
2
3
4
5
6
7
8
9
// 停止mtime + mcycle + minstret,节省时钟树功耗
void SysCounter_Stop()
{
asm volatile(
".equ mcounterstop, 0xBFF ;\
li a0, 0x07 ;\
csrw mcounterstop, a0"
);
}

CSR 0xBFF是HBird E200内核的厂商扩展:bit0控制mcycle,bit1控制mtime,bit2控制minstret。写入0x07一次性停止三个计数器。

睡眠期间ADC自动采集

电量计即使在睡眠状态也需要持续监测电池状态(电压、电流、温度),否则无法检测过压/过流等安全事件。硬件ADC子系统支持自主运行:

VADC自动扫描:

1
2
3
4
5
6
7
// 根据电芯数量配置扫描通道
if (param_board_cfg[PARM_BCFG_CELLNUM] == PARAM_CELL02_VAL)
Chip_ADC_SetupAutoScan(AUTOSCAN_2CELLS_1THM,
AUTOSCAN_RATE_01SEC, 1, 1);
else
Chip_ADC_SetupAutoScan(AUTOSCAN_1CELL_1THM,
AUTOSCAN_RATE_01SEC, 1, 1);

AUTOSCAN模式下,硬件按配置的周期(1~31秒可选)自动完成采样序列,结果写入寄存器。CPU唤醒后直接读取,零等待延迟。

CADC(库仑计ADC)硬件滤波链:

1
2
3
4
5
6
7
8
9
10
Chip_ADC_SetupCADCCtrl(
CADC_SW_FS_144MV // 满量程±144mV(采样电阻压降)
| CADC_CLK_SEL_32K // 32kHz采样时钟
| CADC_SW_AVG_16 // 16次硬件平均
| CADCSCAN_SWDITHER_ENABLE // 抖动使能(提升有效位数)
| CADC_SW_OSR_2048 // 过采样率2048
| CADC_LPF_FILTER_ENABLE // 低通滤波器
| CADC_MEAN_FILTER_ENABLE // 均值滤波器
| CADCSCAN_SCAN_CC_ENABLE // 库仑计数使能
);

CADC的信号链:原始ADC → 16次平均 → LPF低通 → 均值滤波 → 输出。OSR 2048意味着每个输出样本由2048个原始采样累加,有效分辨率远超ADC本身位数。整个滤波链在硬件中完成,CPU完全不参与。

RISC-V中断与异常处理

Trap入口

RISC-V的所有中断和异常统一通过trap入口处理。entry.S放置在DTCM(紧耦合数据存储器)中以获得最低访问延迟:

1
2
3
4
5
6
7
8
9
10
11
  .section .dtcm
.align 2
.global trap_entry
trap_entry:
TRAP_ENTRY // 宏:保存寄存器到栈
csrr a0, mcause // 参数1:中断/异常原因
csrr a1, mepc // 参数2:被中断的PC
csrr a2, mtval // 参数3:异常附加信息
call handle_trap // 调用C分发函数
csrw mepc, a0 // handle_trap可能修改返回地址
TRAP_EXIT // 宏:恢复寄存器,mret返回

TRAP_ENTRY宏只保存x1~x15(caller-saved寄存器):

1
2
3
4
5
6
7
8
.macro TRAP_ENTRY
addi sp, sp, -16*REGBYTES
STORE x1, 1*REGBYTES(sp)
STORE x5, 5*REGBYTES(sp)
STORE x6, 6*REGBYTES(sp)
...
STORE x15, 15*REGBYTES(sp)
.endm

为什么不保存x16x31?RISC-V ABI规定x16x31是callee-saved寄存器,如果handle_trap()内部调用的C函数使用了这些寄存器,编译器会自动在函数序言/尾声中保存和恢复。中断入口只需保存caller-saved部分,将栈帧从32个寄存器缩减到15个,中断响应延迟减少约一半

Trap分发

handle_trap()根据mcause的最高位区分中断和异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uintptr_t handle_trap(uintptr_t mcause, uintptr_t epc, uintptr_t adaddr)
{
if ((mcause & MCAUSE_INT) && ((mcause & MCAUSE_CAUSE) == IRQ_M_EXT))
handle_m_ext_interrupt(); // 外部中断 → PLIC
else if ((mcause & MCAUSE_INT) && ((mcause & MCAUSE_CAUSE) == IRQ_M_TIMER))
handle_m_time_interrupt(); // 定时器中断
else if ((mcause & MCAUSE_INT) && ((mcause & MCAUSE_CAUSE) == IRQ_M_SOFT))
handle_m_sotfware_interrupt(); // 软件中断
else {
// 未处理的异常:打印调试信息并停机
dbg_print(debug_str, mcause, epc, adaddr);
while (1);
}
return epc;
}

PLIC中断控制器

PLIC(Platform-Level Interrupt Controller)是RISC-V标准的外部中断管理器。所有外设中断(I2C、ADC、Timer等)都通过PLIC路由到CPU:

1
2
3
4
5
6
7
8
9
flowchart LR
A["I2C中断"] --> D["PLIC"]
B["CADC中断"] --> D
C["Timer中断"] --> D
D --> E["CPU trap_entry"]
E --> F["handle_m_ext_interrupt"]
F --> G["PLIC_claim"]
G --> H["查handler表分发"]
H --> I["PLIC_complete"]

PLIC的Claim/Complete协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void handle_m_ext_interrupt()
{
// Claim:获取最高优先级的pending中断号
plic_source int_num = PLIC_claim_interrupt(&g_plic);

if (int_num == INT_CADC)
interrupt_nest_mode_start(); // CADC允许嵌套

// 分发:通过函数指针表调用对应handler
if ((int_num >= 1) && (int_num < PLIC_NUM_INTERRUPTS))
g_ext_interrupt_handlers[int_num]();

if (int_num == INT_CADC)
interrupt_nest_mode_end();

// Complete:通知PLIC该中断已处理完毕
PLIC_complete_interrupt(&g_plic, int_num);
}

嵌套中断实现

RISC-V默认不支持中断嵌套——进入trap后mstatus.MIE自动清零,屏蔽所有中断。但电量计的安全中断(过压/过流/过温)必须能打断正在处理的低优先级中断。

实现方式:在特定ISR内手动提升PLIC threshold并重新开启MIE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define NEST_PLIC_THRE  6    // 阈值:只允许优先级>6的中断通过
#define DEF_PLIC_THRE 0 // 默认:所有优先级都通过

void interrupt_nest_mode_start(void)
{
PLIC_set_threshold(&g_plic, NEST_PLIC_THRE); // 屏蔽优先级≤6
Chip_Int_INTGerenalSet(1); // 重新开启MIE
}

void interrupt_nest_mode_end(void)
{
Chip_Int_INTGerenalSet(0); // 关闭MIE
PLIC_set_threshold(&g_plic, DEF_PLIC_THRE); // 恢复阈值
}

当CADC中断正在处理时(优先级6),如果发生优先级>6的安全中断,CPU会再次进入trap_entry,形成一级嵌套。栈空间需要预留两层中断帧(2 × 16 × 4 = 128字节)。

mtime中断处理

mtime是系统心跳,每125ms触发一次:

1
2
3
4
5
6
7
void handle_m_time_interrupt()
{
clear_csr(mie, MIP_MTIP); // 屏蔽timer中断(防止重入)
system_tick = (system_tick + 1) & (TICKTIMERLEN - 1);
MTimer_Config(); // 重载mtimecmp += 4096
set_csr(mie, MIP_MTIP); // 重新使能timer中断
}

主循环架构与状态机

超级循环模式

固件不使用RTOS,采用经典的超级循环(super loop)+ 中断驱动模式。所有业务逻辑在主循环中按时间片执行:

1
2
3
4
5
6
7
8
9
10
flowchart TD
A["main_loop()"] --> B["systime_update_EX()"]
B --> C{"systime_passed_sec()?"}
C -->|"否"| H["__WFI()"]
C -->|"是"| D["dacq_update - ADC数据采集"]
D --> E["sbsif_update - SBS数据更新"]
E --> F["fg_update - 电量算法"]
F --> G["stm_update - 状态机/睡眠决策"]
G --> H
H --> A

时间层次:

  • 125ms:mtime中断唤醒CPU,更新system_tick
  • 1秒:8次tick后触发业务逻辑(ADC采集、电量计算、状态机)
  • 分钟/小时:SOH计算、温度趋势分析等慢任务

功耗状态转换

1
2
3
4
5
6
7
8
flowchart LR
A["Active"] -->|"空闲超时"| B["Sleep Prepare"]
B -->|"延迟到期"| C["Sleep"]
C -->|"mtime/I2C唤醒"| A
A -->|"主机命令"| D["Deep Sleep Prepare"]
D -->|"延迟到期"| E["Deep Sleep"]
E -->|"I2C唤醒"| A
B -->|"I2C活动"| A

关键设计:Sleep Prepare状态下如果检测到I2C活动,立即取消睡眠准备,回到Active。这避免了”正在进入睡眠时主机发来请求”的竞态条件。

性能与功耗的权衡总结

设计决策 性能收益 功耗代价 权衡点
中断驱动I2C 零轮询开销,响应延迟<10μs 中断唤醒功耗 I2C活动稀疏时净省电
ADC自动扫描 CPU完全释放 模拟前端持续运行 扫描率越低越省电(1s vs 31s)
CADC硬件滤波 无需软件DSP 模拟电路常开 OSR 2048换取高精度
嵌套中断 安全事件响应<1μs 额外128字节栈空间 仅CADC ISR允许被嵌套
只保存caller-saved 中断帧60字节→减半 RISC-V ABI保证正确性
mcounterstop 省去计数器时钟树功耗 仅Deep Sleep时停止
125ms tick周期 1秒内最多8次唤醒 每次唤醒消耗~10μs 比1ms tick省电8倍

核心思想:让硬件做硬件擅长的事。ADC采样、电流积分、数据滤波全部交给专用硬件,CPU只在需要做决策(电量计算、状态判断)时才醒来。醒来后尽快完成计算,然后立即回到WFI等待下一个事件。

系列文章

  • 本文:MCU固件架构 - I2C中断、低功耗与RISC-V底层实现
  • 相关:ARM平台固件架构 - I2C中断、低功耗与Cortex-M0实现
  • 相关:阻抗追踪的嵌入式实现 - 定点运算与牛顿迭代
  • 相关:SOC电量算法架构 - 库仑积分、开路电压与电量追赶机制