电力电子代码(二):变量

本系列文章认为读者是对电力电子有深入了解的(至少上过电力电子专业课做过电力电子项目的)、对编程有基础认知的电力电子工程师。

依上文电力电子代码(一)所述,电力电子基本功能包括采样、调制、控制、通讯、保护、状态机。因而,电力电子代码中必然包括各功能所需变量,如采样后的结果需要存储,如调制需要存储当前调制参数,如传统PI控制需要存储包括积分项、上下限、KPKI参数等一系列变量,如通讯需要存储通讯内容,如保护需要存储保护是否触发以及时间戳,如状态机需要存储状态….因而,我们可以将变量以功能分类,根据功能指出电力电子代码需要的变量们

此外,我们也可以将变量以类型分类,包括局部变量、静态变量、参数变量、全局变量。怎样尽可能的减少不必要的重复变量?怎样尽可能缩小每一个变量的作用域?怎样使用结构体把同类或者常常一块儿出现的变量包装在一起以降低理解难度和减少遗漏?哪些变量需要用 volatile 修饰符修饰?

电力电子代码对性能要求非常高,因而,合适的选择变量的数据类型是非常重要的一环。怎样评估变量的精度选择?怎样用紧凑的空间表示多个 bool类型的Flags?什么时候用 unsigned?为什么存在如 hw_types.h 和普通类型的区别?单片机算 double 为什么比算 float 慢这么多?

变量的命名规则也是需要考虑的重要一环。清晰统一的命名规则可以让代码整洁规范。一般来说,常见的代码命名规则有匈牙利命名法、驼峰式命名法、帕斯卡命名法、下划线命名法。电力电子领域有着非常多跟传统CS不同的业务需求,因此命名规则也应该因电力电子领域的代码差异而定。那么关键问题来了:电力电子领域代码有什么跟命名规则有关的特点?其需要的命名规则是什么样的?

这些问题本文不会依次详细解答,对问题本身有兴趣的同学可以寻求 dpsk 的帮助。但本文会在设计变量的时候把这些问题考虑进去,给出 Eva 的变量设计思路,并给出实际调试过的代码中的变量的功能、名称、位置…

全局变量

一般来说,在一般编程代码里,为了强调模块功能的解耦,避免作用域扩大化滥用,我们会尽可能使用 static 修饰符修饰各变量,并用 get()set() 函数进行对变量的受控访问。但考虑到电力电子的代码规模很小,而关键的变量如采样到的信号可能在控制算法、故障检测和通信等多个功能模块中都需要使用。使用全局变量可以避免频繁地通过参数传递这些变量,减少代码量和复杂度。对于调试而言,全局变量易于观察。在使用调试工具时,可以直接查看这些全局变量的值,方便工程师快速定位问题。因此,电力电子仍然广泛使用全局变量

一般来说,全局变量的处理有两大常见思路。其一,专门开辟一个全局变量管理的 .c 文件,例如 boost_bsp_global.c,在这个文件中定义所有需要的全局变量,并且为其编写初始化函数。若涉及一些特殊的数据类型,就将这些类型的定义放置在 boost_bsp_types.h 文件中。与此同时,在 boost_bsp_global.h 文件里对各全局变量进行外部声明,以方便其他文件引用。其二,这种思路有点类似C语言中的模块化设计,允许每个功能模块在其对应的文件中定义和声明自己要用的全局变量。这两种方法各有适用范围。集中式管理:适合小型或调试需求频繁的项目,方便统一管理和调试,但可能导致模块间耦合度增加。模块分散式管理:适合大型或需要高模块独立性的项目,有助于减少模块间的依赖,但调试时需要处理多个模块的全局变量。

从电力电子应用的角度来看,Eva 更倾向于将那些需要频繁访问且被多个模块所共用的变量采用集中式管理策略,而对于相对较为专一、仅在特定模块内使用的变量,则采取模块分散式管理方式。例如,像采样变量和通讯变量这类数据,除了在采样环节会对其进行写入操作外,控制模块、保护模块以及状态机等多个模块在执行过程中都会涉及到对这些采样变量的读取和使用。而对于诸如状态机当前所处状态、保护标志位等变量,由于它们主要在特定的功能模块内部起作用,与其它模块的关联性不强,因此更适合于在对应的模块内部进行分散式管理。

为何我们不统一分散式管理呢?分散式管理看似有不少优势,比如利于模块的独立开发和维护。然而,以采样变量为例,若将其定义在 adc 采样对应的文件里,其他模块在处理采样信号需访问该变量时,就必须得 #include "adc.h" 。这会把 adc 模块里诸如初始化函数等一并引入,致使编译与链接阶段冗余内容增多,平白增添了不少负担,还让头文件间的依赖关系变得复杂凌乱。此时,不如单独建一个 global.cglobal.h 来集中管理全局变量,这样既能避免重复引入无关内容,又能简化项目整体架构,高效又便捷。
为何我们不统一集中式管理呢?对于那些仅在特定模块内部使用的变量,如状态机当前所处状态、保护标志位等,将其分散在对应的模块内部管理,可以更清晰地体现这些变量的作用域和使用场景,避免不必要的全局变量污染,同时也便于后续的功能扩展和模块维护。它们完全可以写作 static ,但在调试阶段用全局变量更方便 debug。一个小技巧可以同时满足调试便利性和代码安全性。调试便利性:在调试模式下,将变量定义为全局变量可以方便调试工具访问,方便调试。代码安全性:在非调试模式下,将变量定义为 static 可以限制其作用域,避免不必要的模块间依赖。

#ifdef DEBUG
FaultFlags_t faultFlags=0;
#else
static FaultFlags_t faultFlags=0;
#endif

至此,我们对变量们有了基本的处理思路 —— 使用全局变量,在 global.c 中放频繁使用的全局变量,在各模块内部放不频繁使用的全局变量(在不调试的情况可以 static)。

Volatile 修饰符

电力电子代码需要高、中、低实时性代码间配合,有非常多的各种形式的中断,当他们都在读和写对应变量时,必然存在时序冲突问题,尤其是开了高编译器优化等级之后,代码的执行顺序可能会改变,不再完美遵循起初设计的逻辑顺序(毕竟编译器优化的时候可不理解NVIC)。

volatile 是 C 语言中的一个类型修饰符,用于修饰变量。它主要用来告诉编译器,该变量的值可能会在编译器无法预测的情况下被外部因素(如硬件设备、其他线程或中断等)改变,从而防止编译器对访问该变量的代码进行优化,确保每次使用变量时都会从内存中重新读取最新的值。常用于硬件寄存器访问、中断服务程序中的变量以及多线程通信中的简单状态标志变量等场景,但需要注意它不能保证操作的原子性。

电力电子里需要用到 volatile 修饰符的主要有两种,一个是硬件寄存器的实时读取,一个是在不同时间尺度运行涉及到的变量(比如主循环和控制定时器中断循环)。

一般来说,电力电子的采样、控制、调制跑在快循环(中断服务函数),通讯跑在慢循环(主函数)。因而同时被快循环和慢循环写入或读取的变量,需要使用 volatile 修饰符。考虑到该特点,Eva 一般会设置一个用来 Store 通讯传输信号的变量 protocolBuffer, 在快循环中将希望传输的数据存进该 protocolBuffer 中,并在慢循环将该 protocolBuffer 的数据发送给上位机。值得注意的是,由于 volatile 修饰的数组并不具有互锁性,所以直接用该修饰符修饰protocolBuffer的话,由于通讯远慢于中断服务函数速度,一个数据帧里的数据会存在较多的时间差。为了消除该影响,使用一个标志物 protocolFlag,在中断服务函数先存数据,存完了使得 protocolFlag=FINISH_STORE,在通讯开始时检测标志位为 FINISH_STORE 后,改为 START_SEND ,并开始通讯,通讯结束后把标志位改为 FINISH_SEND ,并在中断服务函数继续 START_STORE,通过该方式,使得protocolBuffer 互锁。在这种情况下,需要使用修饰符 volatile 修饰标志位 protocolFlag

说完了单片机传给上位机的数据,也要聊聊上位机传给单片机的指令。比方说输出电压参考值、输出电流参考值、状态机模式的选择、外设的控制…表面上似乎只要使用 volatile 修饰即可,但我更倾向于设置类似影子寄存器的Buffer,在每次快中断开始时检测是否存在没处理完成的数据传输,如果存在则加载对应的影子寄存器完成数据的载入,以避免在控制逻辑过程中更新如参考电压等参数,避免复制控制逻辑下突然的变量更新。同样,只需要用 volatile 修饰对应标志位即可。

总的来说,用 volatile 修饰标志位比直接修饰对应变量好。

除了不同时间尺度运行涉及到的变量外,还需要关注跟硬件寄存器有关的变量,考虑到它们会不受控的修改,在使用时需要使用 volatile 修饰符修饰。在使用TI或ST等厂商提供的库函数时,这些库函数通常已经正确地使用了 volatile 修饰符来处理与硬件寄存器相关的变量。因此,在大多数情况下,开发者在使用这些库函数时,不需要再额外考虑对硬件寄存器相关变量的 volatile 修饰问题。例如,库函数可能会通过 volatile 修饰的指针来访问硬件寄存器,以确保每次读写操作都直接作用于实际的硬件寄存器,而不是寄存器的缓存副本。

然而,如果开发者需要直接操作硬件寄存器(即不使用库函数提供的接口),那么就需要自己使用 volatile 修饰符来声明这些寄存器相关的变量或指针,以防止编译器优化带来的潜在问题。

变量的数据类型

在使用MCU/DSP的时候,首先要关注的便是有没有浮点运算单元。存在浮点运算单元时,计算单精度浮点数,如STM32F407算一次浮点乘法只需要1~2个时钟周期,算一次浮点除法需要14~16个时钟周期。在不存在浮点运算单元时,需通过软件模拟实现浮点运算,如STM32F103算一次浮点乘法需要11.9个时钟周期,算一次浮点除法需要89.29个时钟周期。对不存在双精度浮点运算单元的MCU/DSP来说,计算一次double乘法需要33.65个时钟周期,计算一次double除法需要1753.55个时钟周期。

当电力电子控制频率为40kHz,存在浮点运算单元的CPU主频为120MHz时,对应一个控制周期最多能有3000个时钟周期,只能算1次double除法或90次double乘法或190次float除法或1500次float乘法。一般来说,使用double是远远不够满足电力电子变换器动态响应速度性能要求的,必须用float,且尽量得用乘法。所以在 #define FREQ 的同时顺带一起 #define PERIOD ,以保证每个公式都优先使用乘法。

说到 #define 了顺带比较一下它和 const 表达常量的差异。Eva 倾向是电力电子里把全局的常量用 #define ,包括但不限于CPU频率、PWM频率(定频)、PWM周期、中断频率、中断周期,这些都定义在 {Project Name}_{epwm/interrupt/…}_hal.h 中。此外,数组长度也用 #define 如Buffer的数组长度,位置为数组变量定义位置对应的 .h 文件。把局部的常量用 const ,如PID初始化时 kp ki 的值,这种做法一个是调试起来比较方便,二个是比较好修改,虽然我更建议电力电子用上位机调参,以避免各种由于调试被干扰带来的影响。

变量的命名规则

出于文章一致性,我希望在未来单开一章介绍电力电子代码命名规则,并给出一套清晰可用的命名规则。

这里我直接给出一种结论,仿照TI例程,牺牲代码长度,提升项目分辨可读性。

全局变量 {PROJECT_NAME}_{HAL/BSP/APP/…}_someThing

局部变量 someThing

变量类型 {PROJECT_NAME}_{HAL/BSP/APP/…}_SomeType_t

定义参数 #define SOME_THING_SUCH_AS_INIT_KP 12.f

值得提出的是,随着自动补全在各大 IDE 的完善,及 copilot 等 AI 辅助工具的广泛使用,Eva 认为合适的准确的名称比代码长度更为重要,尽可能在写每一个变量的时候写清楚其真实物理意义和功能非常重要,避免使用 i a temp ,取而代之可以考虑写为 safetySuiteIndex tempVariableForAdcTest 这种形式。

至此,大致说完了变量的设计思路,让我们来进入一个实例吧!

以电压环控制的 BOOST 拓扑为例介绍电力电子程序的变量设计

image.png

针对该 Boost 拓扑,进行基础功能的代码设计。采样包括输出电压采样、电感电流峰值采样;调制采用PWM型控占空比调制;控制采用PI控制;通讯使用 UART 实时发送采样的所有信号、当前PWM占空比、当前状态机状态、保护触发情况;保护包括电感电流过流保护,输出过压保护,为简单起见保护暂时删除配置功能;状态机包括空闲、运行和保护处理。

分析清楚功能后,我们参照电力电子代码(一)前文所述,设计文件树。(顺带一提这里的 iL_over_current.c\.h 没有满足全小写原则,因为电感是大写的 L 已经深入人心了,它更像是个专有名词)

src
│  ReadMe.md              # 项目总说明文档
│  main.c                 # 程序入口,初始化系统并启动主循环
│
├─app                     # 应用层,存放业务逻辑代码
│      boost_app_isr.c        # 应用层主逻辑实现
│      boost_app.h        # 应用层头文件,定义接口和数据结构
│
├─bsp                      # 中间层
│  │  boost_bsp.c         # BSP层核心实现
│  │  boost_bsp.h         # BSP层头文件
│  │  boost_bsp_types.h   # BSP层变量类型
│  │
│  ├─control                # 控制算法相关代码
│  │  │  boost_control.c  # 控制逻辑实现
│  │  │  boost_control.h  # 控制逻辑头文件
│  │  │
│  │  └─pid                # PID控制算法实现
│  │          pid.c         # PID算法核心实现
│  │          pid.h         # PID算法头文件
│  │          ReadMe.md     # PID算法说明文档
│  │
│  ├─fsm                    # 有限状态机(FSM)相关代码
│  │  │  fsm_core.c         # 状态机核心逻辑实现
│  │  │  fsm_core.h         # 状态机核心逻辑头文件
│  │  │  ReadMe.md          # 状态机说明文档
│  │  │
│  │  └─stateSuite         # 状态机具体状态实现
│  │          fsm_state_fault.c    # 故障状态实现
│  │          fsm_state_idle.c     # 空闲状态实现
│  │          fsm_state_run.c      # 运行状态实现
│  │          fsm_transition.c     # 状态转换逻辑实现
│  │          state_suite.c        # 状态机套件核心实现
│  │          state_suite.h        # 状态机套件头文件
│  │          state_suite_api.h    # 状态机套件对外接口
│  │
│  └─safety                # 安全相关代码
│      │  ReadMe.md        # 安全模块说明文档
│      │  safety_core.c    # 安全核心逻辑实现
│      │  safety_core.h    # 安全核心逻辑头文件
│      │
│      ├─logManager        # 安全日志管理
│      │      safety_log_manager.c     # 安全日志管理实现
│      │      safety_log_manager.h     # 安全日志管理头文件
│      │
│      └─safetySuite      # 安全套件
│              safety_suite.c         # 安全套件核心实现
│              safety_suite.h         # 安全套件头文件
│              vout_over_voltage.c    # 输出过压
│              vout_over_voltage.h    # 输出过压头文件
│              iL_over_current.c      # 电感电流过流
│              iL_over_current.h      # 电感电流过流头文件
│
└─hal                      # 硬件抽象层,与硬件直接交互的代码
        boost_hal.c      # HAL层核心实现
        boost_hal.h      # HAL层头文件
        boost_hal_adc.c  # ADC外设驱动实现
        boost_hal_asysctl.c  # 系统控制外设驱动实现
        boost_hal_board.h    # 硬件板级配置头文件
        boost_hal_epwm.c # EPWM外设驱动实现
        boost_hal_epwm.h # EPWM外设驱动头文件
        boost_hal_gpio.c # GPIO外设驱动实现
        boost_hal_gpio.h # GPIO外设驱动头文件
        boost_hal_interrupt.c  # 中断处理实现
        boost_hal_sci.c  # SCI外设驱动实现
        boost_hal_sync.c # 同步外设驱动实现
        boost_hal_timer.c  # 定时器外设驱动实现
                boost_hal_system_time.c # system_time
                boost_hal_system_time.h # system_time
        ReadMe.md          # HAL层说明文档

在有了这样一个文件树骨架后,我们就可以开始往里面填内容物了。

我认为在电力电子的代码撰写中,虽然自底向上更为简单,但容易引入许多不必要的功能函数。我更倾向于自顶向下。

本文着重于变量,不会给出所有代码细节,只会在必要的时候给出一些。

首先,让我们写 main.c

int main(){

    setupDevice();//基本的时钟、中断、外设
    globalVariablesInit();//全局变量初始化
    delay(1000U);//适当延时
    startInterrupt();//开始运行中断控制函数

    while(1){

    ledSparkle(); //一般来说,电力电子经常会遇到因为不小心手误(比如不小心引入double乘法)导致算不过来的bug
                                //毕竟电力电子是需要超快响应速度的,如果在main()里加一个Led闪烁程序,当计算性能有盈余的时候
                                //就会正常运行main()的代码,led灯就正常的闪烁,就能判断有没有该bug
                                //led闪烁程序也依赖于一个自定义的时钟,我倾向于可以就用定时器中断,也可以借此判断定时器周期大致是否准确
    fsm_slowlyProcessState(&BOOST_fsm);//运行状态机的慢速运行函数,该函数根据函数指针指向运行对应函数。
    uart_sendData();//send Data
    }
}

其次,写中断函数 boost_app_isr.c

__interrupt void INT_AD_ISR(void){//定时器触发采样,ADC EOC 触发该中断
    increaseSystemTime(1);
    updateSensedParameters();
    uart_storeData();
    fsm_processState(&Boost_fsm);
    clearInterruptStatus();//清除中断标志位
}

__interrupt void UART_RX_ISR(void){...}//中断存储通讯数据(且不在这里处理)

有一个 increaseSystemTime(1) 函数,代表每次运行该中断使得 systemTime+=1,涉及变量 systemTime

Eva 倾向把该函数写在 boost_hal_system_time.c 中。why?首先系统时间是一个非常常见的广泛调用项,包括但不限于开一个磁饱和继电器需要GPIO导通100ms,假设我们把该函数写在 boost_hal_gpio.c 中,它必然依赖对应时间分析。

我们可以写boost_hal_system_time.c

#include "boost_hal_system_time.h"

static long long int system_time=0;//2^63-1,假设滴答频率(控制频率)20kHz,可以表示最长时间14M年。

void increaseSystemTime(int increment){
    system_time+=increment;
    return;
}

long long int readSystemTime(void){
    return system_time;
}

电力电子有一个很常见的需求叫做waitThenRun(),即在某一指定时刻过一段固定时长然后run一个函数。在第一次运行该函数的时候需要存储startTime,然后根据waitTime,当当前systemTime>=waitTime+startTime时触发一次对应函数指针function。有一种思路是定义结构体,用一个数组记录函数指针、开始时间和结束时间,设计写入函数和执行函数。但这并不方便。
更为方便的形式是在需要用到的位置每一次写一遍延时处理函数,用static long long int runTime 记录开始时间+等待时间的值,并在触发一次后将runTime=0。题外话了,让我们回到变量。

对状态机来说,会有一个全局变量 volatile FSM_t BOOST_fsm ,该结构体适合在 fsm_core.h 定义,该变量适合在 boost_bsp_global.c 定义后在 boost_bsp_global.h 声明。

对串口来说,需要设置一个二维数组连续存储需要上传给上位机的多个数据。这种都可以以 static 修饰符修饰,根据模块的不同写在各个文件内部的变量,Eva这里便不再赘述,在后续介绍功能模块时再详细说明。

重点介绍的是 boost_bsp.c

#include "boost_bsp.h"

BOOST_SensedParas_t BOOST_sensedParas;//采样的电压电流
BOOST_ControlParas_t BOOST_controlParas;//控制参数,包括PID、PWM是否开通、实际占空比
BOOST_GuiParas_t BOOST_guiParas;//用户设定的工作模式、开环时的占空比、闭环时的参考给定值

void globalParasInit(){...}//根据实际情况设定全局变量初始值
void globalParasReinit(){...}//根据实际情况再次设定全局变量初始值(用来在比如模式切换、用户清除fault后重启使用)
void updateSensedData(){...}//ADC MAP

boost_bsp.h 就是把以上全局变量全部extern,函数全部声明。不再赘述。

值得一提的是 boost_bsp_types.h

#include "stdint.h"
#include "inc/hw_types.h"
#include "hal/boost_hal.h"
#include "control/pid/pid.h"
#include "fsm/fsm_core.h"
#include "safety/safety_core.h"
#include "control/boost_control.h"

// Measurement
 typedef struct{
    // 输出电压和电感电流峰值
    float32_t vOut; // 通道: ADCx_CHx_ADCINx GPIOx
        float32_t iLPeak;
}BOOST_SensedParas_t;

// Control
typedef struct{
    // 输出电压控制参数
    PID_t PI_vOut; // 电压PID
    uint8_t isPWMsOn;//PWMs是否在正常输出
    float32_t duty; // 占空比
}BOOST_ControlParas_t;

typedef enum {
    CHOOSE_STOP =0,       // Choose to stop (idle)
    CHOOSE_RUN,        // Choose to run
    CLEAR_FAULT,       // Clear fault to run
} BOOST_OperationMode_t;

//Debug Mode
typedef enum {
    IDLE=0, //默认
    OPEN_LOOP, // 开环控制
    VOLTAGE_LOOP, // 电压环控制
    //else unit tests
} BOOST_RunMode_t;

// Gui
typedef struct{
    BOOST_OperationMode_t operationMode;
    BOOST_RunMode_t runMode;
    uint8_t driverMode;       // 驱动模式 用uint8_t可以在未来驱动数量更多的时候用位表示驱动
    //控制参数
    float32_t openLoop_duty;//开环模式下用户设置的占空比
    float32_t voltageLoop_vOutRef; // 电压环设定值
}BOOST_GuiParameters_t;

此外还有一些比较适合 define 的量,比如CPU频率,比如控制频率,比如控制周期,比如PWM周期,比如PWM频率等。这些适合定义在 boost_hal.h 中。

至此,核心的变量们就讲完啦!有非常非常多没有提到的变量,尤其是模块内部的变量,如通讯…这些就在讲具体模块的时候再说~

发表回复