Something Boring

生命不息,折腾不止

STM32F103RBTx.jpg

一些哔哔

去楼上实验室Van♂的时候学弟跟我提了一嘴STM32U575标称功耗的事,顺便展示了以下他参加众筹的合宙IoT Power,遂临时起意,测了以下手头有的一些常见MCU的功耗,于是有了本文

测试结果

首先声明,受时间和测试条件限制,我并不能测到非常精确的功耗数据,以下的数据仅有参考价值:

MCU型号 板卡 备注 电流消耗
ESP32-S NodeMCU 满主频 WiFi + BLE + CH340 外部晶振 59.40mA
ESP32-S3 CORE-ESP32S3-A10 满主频 WiFi + BLE + CP2102 外部晶振 114.2mA
ESP8266 unknown 满主频 WiFi + CH340 外部晶振 81.66mA
ESP8266 ESP-01F 满主频 WiFi 外部晶振 79.97mA
GW1N-4 TangNano-4K 流水灯程序 57.88mA
MSP430G2553 LaunchPad 满主频 无外设 108.4uA
MSP432E401 LaunchPad 满主频 ADC满速轮询 XDS110电源已断开 8.198mA
RP2040 Pico 满主频 无外设 22.17mA
STM32F103RBTx NUCLEO 已断开STLink电源,满主频 + ADC 未设置未使用IO为GPIO Analog 4.056mA
STM32F103C8Tx BluePill 满主频 + ADC + 全TIM + 双外部晶振 未设置未使用IO为GPIO Analog 10.60mA
STM32F407ZGTx FK407M2 满主频 无外设 双外部晶振 未设置未使用IO为GPIO Analog 69.43mA
STM32G474RETx NUCLEO 满主频 无外设 41.04mA
STM32H723VGTx Boring 满主频 无外设 双外部晶振 未设置未使用IO为GPIO Analog 39.58mA
STM32H723VGTx Boring 满主频 + 1.16寸LCD 双外部晶振 未设置未使用IO为GPIO Analog 152.66mA
STM32H743ZITx NUCLEO V1板卡 无外设 未设置未使用IO为GPIO Analog 83.89mA
STM32H753 OpenMV4 Plus OpenMV 板卡功耗 对MCU功耗无参考价值 未开启LED 210.6mA
STM32H750VBTx FK750M1 满主频 无外设 双外部晶振 未设置未使用IO为GPIO Analog 193.5mA
STM32U575ZITx NUCLEO 满主频 + DAC Normal Node 全速轮询 未设置未使用IO为GPIO Analog 14.05mA
TMS320F28379D LaunchPad 官方Blink例程(据说外设全部初始化) 193.9mA
XC7A35T E-Element 仅供电3V3,LED + FPGA 晶振 + 流水灯程序 49.53mA
XC7Z020 小熊猫 5V供电 Cortex-A满速 + PL无程序 123.6mA

测试照片 (多图)

ESP32-S.jpg
MSP432E401Y.jpg
OpenMV4 Plus
STM32F103C8Tx.jpg
STM32F103RBTx.jpg
STM32F407ZGTx.jpg
STM32H723VGTx.jpg
TangNano-4K.jpg
XC7A35T.jpg

写在结尾

看起来低功耗的王者还是MSP430G2553(bushi),如果说在能用的情况下的低功耗,ST的STM32U575和TI的MSP432E401似乎不错。可惜前几天老师把MSPM0L1306收回去了,不然测试一下也是好的。C2000系列的功耗一开始吓我一跳,后来学弟告诉我TI的例程把所有外设都初始化了,不管例程用不用....顺便吐槽下TI的嵌入式软件。STM32整体表现还不错,但H750.....一枝独秀了属于是,电表倒转。一个令我惊喜的MCU是ESP32-S,对于一个启用了WiFi的MCU,我对他的预计功耗在100mA以上,没想到只有40mA上下,而且ESP8266和ESP32-S3功耗都显著高于它(或许是我记错里面是啥程序了?)回头再写个空程序测试一下。如果手头拿到新板子的话大概会更新吧(咕咕咕)....

TSL1401

Why TSL1401?

校赛的时候由于连熬几个大夜,赛前忘了调红外阈值,开局跑飞。遂下定决心,扔掉红外,换一种循迹方案。正好老师买了TSL1401CL,于是便有了本文。

驱动时序

这是从TSL1401的DataSheet上截取的时序图:

TSL1401TimingGeneral
TSL1401Timing

可以看到,TSL1401的时序还是相对简单的,连续给129个CLK,在起止周期分别给SI脉冲就可以了。但实际上这里有一点坑, 曝光时间是固定的110个周期,如果要做自动曝光的话,就需要调节时钟频率来控制曝光时间。

第一版代码

由于TSL1401的时序并不复杂,我们可以直接用GPIO模拟时序。代码大概是这样:

void TSL1401Initialize(void) {
    AverageBrightness = 0;
    memset(FrameBuffer, 0, 128);
    HAL_SYSTICK_Config(HAL_RCC_GetHCLKFreq() / 1000000);
}
uint16_t TSL1401ReadOut(uint16_t* buffer, uint16_t HalfClockPeriod) {
    uint32_t AverageBrightness = 0;
    HAL_GPIO_WritePin(TSL_CLK_GPIO_Port, TSL_CLK_Pin, GPIO_PIN_SET);
    HAL_GPIO_WritePin(TSL_SI_GPIO_Port, TSL_SI_Pin, GPIO_PIN_RESET);
    HAL_Delay(1);
    HAL_GPIO_WritePin(TSL_CLK_GPIO_Port, TSL_CLK_Pin, GPIO_PIN_RESET);
    HAL_GPIO_WritePin(TSL_SI_GPIO_Port, TSL_SI_Pin, GPIO_PIN_SET);
    HAL_Delay(1);
    HAL_GPIO_WritePin(TSL_CLK_GPIO_Port, TSL_CLK_Pin, GPIO_PIN_SET);
    HAL_GPIO_WritePin(TSL_SI_GPIO_Port, TSL_SI_Pin, GPIO_PIN_RESET);
    for(uint8_t i = 0; i < 128; i++) {
        HAL_GPIO_WritePin(TSL_CLK_GPIO_Port, TSL_CLK_Pin, GPIO_PIN_RESET);
        HAL_ADC_PollForConversion(&TSL_ADC_HANDLE, HAL_MAX_DELAY);
        buffer[i] = HAL_ADC_GetValue(&TSL_ADC_HANDLE);
        AverageBrightness = AverageBrightness + buffer[i];
        HAL_Delay(HalfClockPeriod);
        HAL_GPIO_WritePin(TSL_CLK_GPIO_Port, TSL_CLK_Pin, GPIO_PIN_SET);
    }
    AverageBrightness = AverageBrightness / 128;
    return AverageBrightness;
}

void TSL1401AutoExposure(void) {
    uint16_t avg = 0;
    uint16_t frameBuffer[128];
    uint16_t halfClockPeriod = 90;
    while ((avg < 1000 && halfClockPeriod < 500) || (avg > 3500 && halfClockPeriod > 5)) {
        avg = TSL1401ReadOut(frameBuffer);
        if (avg < 1000)
            halfClockPeriod = halfClockPeriod + 5;
        else if (avg > 3500)
            halfClockPeriod = halfClockPeriod - 5;
    }
}

写起来非常的轻松愉快,可谓是一气呵成。但就在我写完的一瞬间,我意识到了一个问题:这个程序是阻塞的。这也就意味着这个程序在执行的时候将把整个程序卡住。这玩意可是要整合上PID + 循迹来调小车的,这要是卡个100mS(最大曝光时间)我的PID不得飞到天上去,还是得重写一版非阻塞的驱动。

非阻塞版驱动

API设计

考虑到是非阻塞式的API,最简单的实现方式就是采用观察者模式,即当读取完成时通知。而在MCU裸机编程中,最简单的方式就是Callback,于是我们有了第一个API:

TSL1401ReadCpltCallback()

那么参数和返回值该如何定义呢?Callback将由驱动调用,返回值在这个驱动中似乎是不必要的。而我们希望驱动能将读取完的FrameBuffer传递给外界,于是就定义成这样:

void TSL1401ReadCpltCallback(uint16_t* frameBuffer);

同时,为了在默认情况下过编译,定义一个weak的实现:

__attribute__((weak)) void TSL1401ReadCpltCallback(uint16_t* frameBuffer) {

}

其实这里还塞了其他的东西,暂且按下不表。
由于这里还涉及了ADC读取的问题,既然都非阻塞了,那就全部非阻塞好了,ADC配置成中断模式,当ADC读取完成的时候通知一下驱动就好了,于是我们再设计一个回调服务函数,在ADC读取完成回调中调用:

void __ADCCallbackService_TSL1401(ADC_HandleTypeDef* hadc);
```  

剩下的部分其实就比较常规了,无非是功能的启用禁用、曝光时间修改、触发单次曝光和初始化。依然由于是非阻塞的关系,我们需要做一个循环被调用的函数,不妨按OS编程的叫法,叫他Service,于是整体的API就出来了,大概是这样:

```c
void TSL1401ReadCpltCallback(uint16_t* frameBuffer);
void __ADCCallbackService_TSL1401(ADC_HandleTypeDef* hadc);
void TSL1401Initialize(void);
void TSL1401EnableAutoExposure(void);
void TSL1401DisableAutoExposure(void);
void TSL1401EnableContinouosExposure(void);
void TSL1401DisableContinouosExposure(void);
void TSL1401SetExposureTime(uint32_t exposureTime_uS);
void TSL1401BurstReadAsync(void);
void TSL1401ServiceFunction(void);

注意我给回调服务函数加了两个下划线作为前缀,这是笔者的代码习惯,通常只希望在内部或某个特定地点调用的函数或是变量,笔者都会加上这个前缀。

驱动实现

笔者习惯于将一个c文件内部的变量pack成结构体(面向对象后遗症属于是),先把变量定义下:

struct {
    uint8_t     NextBurstReadFlag:1;
    uint8_t     AutoExposureEnable:1;
    uint8_t     AdcDataAvailableFlag:1;
    uint8_t     ContinuousExposureEnable:1;
    uint8_t     ExposureAndReadOutState:4;
    uint16_t    HalfClockPeroid;
    uint8_t     ClockCycleCounter;
    uint32_t    AverageBrightness;
    uint32_t    OperationStartTick;
} __TSL1401DriverVariablesPack = { 0 };

uint16_t __TSL1401FrameBuffer[128] = { 0 };

然后把只需要动配置的函数都实现了:

__attribute__((weak)) void TSL1401ReadCpltCallback(uint16_t* frameBuffer) {

}

void __ADCCallbackService_TSL1401(ADC_HandleTypeDef *hadc) {
    if (hadc->Instance = TSL_ADC.Instance)
        __TSL1401DriverVariablesPack.AdcDataAvailableFlag = 1;
}

void TSL1401Initialize(void) {
    __TSL1401DriverVariablesPack.NextBurstReadFlag = 0;
    __TSL1401DriverVariablesPack.AutoExposureEnable = 1;
    __TSL1401DriverVariablesPack.AdcDataAvailableFlag = 0;
    __TSL1401DriverVariablesPack.ContinouosExposureEnable = 1;
    __TSL1401DriverVariablesPack.ExposureAndReadOutState = 0;
    __TSL1401DriverVariablesPack.HalfClockPeriod = 90;
    __TSL1401DriverVariablesPack.ClockCycleCounter = 0;
    __TSL1401DriverVariablesPack.AverageBrightness = 0;
    __TSL1401DriverVariablesPack.OperationStartTick = 0;
    HAL_SYSTICK_Config(HAL_RCC_GetHCLK() / 1000000);
}

void TSL1401EnableAutoExposure(void) { __TSL1401DriverVariablesPack.AutoExposureEnable = 1; }

void TSL1401DisableAutoExposure(void) { __TSL1401DriverVariablesPack.AutoExposureEnable = 0; }

void TSL1401EnableContinouosExposure(void) { __TSL1401DriverVariablesPack.ContinouosExposureEnable = 1; }

void TSL1401DisableContinouosExposure(void) { __TSL1401DriverVariablesPack.ContinouosExposureEnable = 0; }

void TSL1401SetExposureTime(uint32_t exposureTime_uS) { __TSL1401DriverVariablesPack.HalfClockPeriod = exposureTime_uS / 220; }

void TSL1401BurstReadAsync(void) {
    if (__TSL1401DriverVariablesPack.ContinouosExposureEnable)
        return;
    __TSL1401DriverVariablesPack.NextBurstReadFlag = 1;
}

值得注意的是笔者将SysTick配置到了1uS来提供时钟源(因为小车上的定时器不够用了),这将导致HAL_Delay()的延时单位从mS变成uS,如果需要HAL_Delay()的读者需要自己配置一个1uS的tick来提供时钟源。
接下来就是重头戏——Service的编写。其实从__TSL1401DriverVariablesPack的定义中就能看出我的意图——状态机。对着时序图切分状态,我们不妨定义一个枚举类型:

typedef enum {
    SIPulseGenerate_ClkHighSILow_E  = 0x0,
    SIPulseGenerate_ClkLowSIHigh_E  = 0x1,
    ReadOut_ClkLowAdcStart_E        = 0x2,
    ReadOut_ClkLowAdcConvCplt_E     = 0x3,
    ReadOut_ClkHigh_E               = 0x4,
    ReadOut_AllDone_E               = 0x5,
    Idle_E                          = 0xF
} ExposureAndReadOutState_E;

然后把__TSL1401DriverVariablesPack改写成这样:

struct {
    uint8_t     NextBurstReadFlag:1;
    uint8_t     AutoExposureEnable:1;
    uint8_t     AdcDataAvailableFlag:1;
    uint8_t     ContinuousExposureEnable:1;
    uint8_t     reservedBits:4;
    uint16_t    HalfClockPeroid;
    uint8_t     ClockCycleCounter;
    uint32_t    AverageBrightness;
    uint32_t    OperationStartTick;
    ExposureAndReadOutState_E   ExposureAndReadOutState;
} __TSL1401DriverVariablesPack = { 0 };

同时改写下初始化:

void TSL1401Initialize(void) {
    __TSL1401DriverVariablesPack.NextBurstReadFlag = 0;
    __TSL1401DriverVariablesPack.AutoExposureEnable = 1;
    __TSL1401DriverVariablesPack.AdcDataAvailableFlag = 0;
    __TSL1401DriverVariablesPack.ContinouosExposureEnable = 1;
    __TSL1401DriverVariablesPack.ExposureAndReadOutState = SIPulseGenerate_ClkHighSILow_E;
    __TSL1401DriverVariablesPack.HalfClockPeriod = 90;
    __TSL1401DriverVariablesPack.ClockCycleCounter = 0;
    __TSL1401DriverVariablesPack.AverageBrightness = 0;
    __TSL1401DriverVariablesPack.OperationStartTick = 0;
    HAL_SYSTICK_Config(HAL_RCC_GetHCLK() / 1000000);
}

然后开始对着时序图画一个状态机:

TSL1401State.png

接下来对着状态机写出函数即可:

void TSL1401ServiceFunction(void) {
    switch(__TSL1401DriverVariablesPack.ExposureAndReadOutState) {
        case SIPulseGenerate_ClkHighSILow_E:
            if(__TSL1401DriverVariablesPack.ClockCycleCounter == 0) {
                if(HAL_GetTick() > __TSL1401DriverVariablesPack.OperationStartick + __TSL1401DriverVariablesPack.HalfClockPeriod){
                    HAL_GPIO_WritePin(TSL_CLK_GPIP_Port, TSL_CLK_Pin, GPIO_PIN_SET);
                    HAL_GPIO_WritePin(TSL_SI_GPIO_Port, TSL_SI_Pin, GPIO_PIN_RESET);
                    __TSL1401DriverVariablesPack.OperationStartick = HAL_GetTick();
                    __TSL1401DriverVariablesPack.ExposureAndReadOutState = SIPulseGenerate_ClkLowSIHigh_E;
                }
            } else {
                __TSL1401DriverVariablesPack.ExposureAndReadOutState = ReadOut_ClkLowAdcStart_E;
            }
            break;
        case SIPulseGenerate_ClkLowSIHigh_E:
            if(HAL_GetTick() > __TSL1401DriverVariablesPack.OperationStartick + __TSL1401DriverVariablesPack.HalfClockPeriod) {
                HAL_GPIO_WritePin(TSL_CLK_GPIP_Port, TSL_CLK_Pin, GPIO_PIN_RESET);
                HAL_GPIO_WritePin(TSL_SI_GPIO_Port, TSL_SI_Pin, GPIO_PIN_SET);
                __TSL1401DriverVariablesPack.ClockCycleCounter += 1;
                __TSL1401DriverVariablesPack.OperationStartick = HAL_GetTick();
                __TSL1401DriverVariablesPack = SIPulseGenerate_ClkHighSILow_E;
            }
            break;
        case ReadOut_ClkLowAdcStart_E:
            if(__TSL1401DriverVariablesPack.ClockCycleCounter < 129) {
                if(HAL_GetTick() > __TSL1401DriverVariablesPack.OperationStartick + __TSL1401DriverVariablesPack.HalfClockPeriod) {
                    HAL_GPIO_WritePin(TSL_CLK_GPIP_Port, TSL_CLK_Pin, GPIO_PIN_RESET);
                    __TSL1401DriverVariablesPack.AdcDataAvailableFlag = 0;
                    __TSL1401DriverVariablesPack.OperationStartTick = HAL_GetTick();
                    HAL_ADC_Start_IT(&TSL_ADC);
                    __TSL1401DriverVariablesPack.ExposureAndReadOutState = ReadOut_ClkLowAdcConvCplt_E;
                }
            } else {
                __TSL1401DriverVariablesPack.ExposureAndReadOutState = ReadOut_AllDone_E;
            }
            break;
        case ReadOut_ClkLowAdcConvCplt_E:
            __TSL1401DriverVariablesPack.AverageBrightness += HAL_ADC_GetValue(&TSL_ADC);
            __TSL1401FrameBuffer[__TSL1401DriverVariablesPack.ClockCycleCounter - 1] = HAL_ADC_GetValue(&TSL_ADC);
            __TSL1401DriverVariablesPack.ExposureAndReadOutState = ReadOut_ClkHigh_E;
            break;
        case ReadOut_ClkHigh_E:
            if(HAL_GetTick() > __TSL1401DriverVariablesPack.OperationStartick + __TSL1401DriverVariablesPack.HalfClockPeriod) {
                HAL_GPIO_WritePin(TSL_CLK_GPIP_Port, TSL_CLK_Pin, GPIO_PIN_SET);
                __TSL1401DriverVariablesPack.OperationStartTick = HAL_GetTick();
                __TSL1401DriverVariablesPack.ExposureAndReadOutState = ReadOut_ClkLowAdcStart_E;
            }
            break;
        case ReadOut_AllDone_E:
            if(__TSL1401DriverVariablesPack.AutoExposureEnable) {
                if(__TSL1401DriverVariablesPack.AverageBrightness < 1000 && __TSL1401DriverVariablesPack.HalfClockPeriod < 1000) {
                    __TSL1401DriverVariablesPack.HalfClockPeriod += 50;
                    __TSL1401DriverVariablesPack.ExposureAndReadOutState = SIPulseGenerate_ClkHighSILow_E;
                } else if (__TSL1401DriverVariablesPack.AverageBrightness > 3500 && __TSL1401DriverVariablesPack.HalfClockPeriod > 100) {
                    __TSL1401DriverVariablesPack.HalfClockPeriod -= 50;
                    __TSL1401DriverVariablesPack.ExposureAndReadOutState = SIPulseGenerate_ClkHighSILow_E;
                } else {
                    TSL1401ReadCpltCallback(__TSL1401FrameBuffer);
                    if(__TSL1401DriverVariablesPack.ContinouosExposureEnable) {
                        __TSL1401DriverVariablesPack.ExposureAndReadOutState = SIPulseGenerate_ClkHighSILow_E;
                    } else {
                        __TSL1401DriverVariablesPack.ExposureAndReadOutState = Idle_E;
                    }
                }
            } else {
                if(__TSL1401DriverVariablesPack.ContinouosExposureEnable) {
                    __TSL1401DriverVariablesPack.ExposureAndReadOutState = SIPulseGenerate_ClkHighSILow_E;
                } else {
                    __TSL1401DriverVariablesPack.ExposureAndReadOutState = Idle_E;
                }
            }
            break;
        case Idle_E:
            if(__TSL1401DriverVariablesPack.NextBurstReadFlag) {
                __TSL1401DriverVariablesPack.NextBurstReadFlag = 0;
                __TSL1401DriverVariablesPack.ExposureAndReadOutState = SIPulseGenerate_ClkHighSILow_E;
            }
            break;
    }
}

至此,我们的TSL1401驱动算是编写完毕了,进入接下来的话题——状态机。

状态机编程

什么是状态机

状态机是对于事务运行规则的一种抽象,对于状态机而言,有四大基础概念

  • 状态:即系统的状态
  • 事件:执行某个操作所需要的触发条件
  • 动作:事件发生后执行的操作
  • 转移:从一个状态切换到另一个状态

以上面的TSL1401驱动为例,转移图中的判断就是“事件”,而状态转移和Flag、配置变量就是“动作”。
事实上,在FPGA中使用状态机编程更多,而这里之所以用了状态机,是因为我们希望整个系统是异步的,而使用了Visitor设计模式。在Visitor设计模式中,操作完成会触发一个“事件”,为了管理这种“事件”触发的“动作”,我们便需要引入状态机。

Why State Machine?

前文提到,要异步就得状态机,所以为什么选择状态机可以换一个问法,为什么用异步编程?其实说来说去也无非一句话——最大化执行效率。如果使用传统的同步(Synchronous)编程,IO方法事实上是阻塞的,这也就意味着,在等待IO操作完成的过程之中,CPU是空转的,效率非常低下。事实上,在新版本的Cpp标准(C++ 20)里引入了co_await和co_async关键字,可以简单的实现异步操作,然而嵌入式编译器一般跟进标准非常慢....并且在嵌入式里使用C++并不是什么明智的决定(会带来更大的RAM和ROM资源开销且g++等c++编译器的行为并不稳定),所以在大部分时候我们还是倾向于使用状态机。

Why VSCode?

首先,Keil、IAR之流虽然不是不能用,但是UI看着有一种复古的美(逃),并且.....笔者习惯于在Linux下开发,这俩货都不支持Windows之外的任何平台,所以这俩玩意被pass了。其次,笔者早年确实折腾过CubeIDE等一系列基于Eclipse的IDE。确实能跨平台,魔改完CDT的补全触发机制之后功能也算可用。但是,eclipse的内存泄漏问题不可谓不恶名昭彰。笔者那台8G RAM的开发机,如果长时间开着eclipse + Firefox开上十几个tab,RAM就不够了...所以,eclipse系也不行。最终我把目光转向了VSCode,一番折腾之下,搞出了一套笔者自己用着挺顺手的方案。

依赖安装

Linux篇

以笔者用的Linux Mint为例,首先安装gcc,make这些编译环境

sudo apt-get update
sudo apt-get install gcc-arm-none-eabi libnewlib-arm-none-eabi binutils-arm-none-eabi gdb-multiarch make cmake
然后安装Language Server和生成CompileDB的工具
sudo apt-get install ccls bear
最后安装OpenOCD:
sudo apt-get install openocd
注意: 某些MCU可能并不被主干OpenOCD支持,需要自行编译下游OpenOCD

Windows篇

先安装MSYS2,在MSYS2官网下载安装包安装即可。然后打开mingw64环境,更新一下系统包

pacman -Syyu
再安装make等编译工具

pacman -S make cmake mingw-w64-x86_64-arm-none-eabi-gcc mingw-w64-x86_64-arm-none-eabi-newlib mingw-w64-x86_64-arm-none-eabi-binutils

然后,安装clangd和Python3

pacman -S mingw-w64-clang-x86_64-clang-tools-extra python

安装pip(可能需要魔法上网)

wget https://bootstrap.pypa.io/get-pip.py
python get-pip.py
安装compiledb:
pip3 install compiledb -i https://pypi.tuna.tsinghua.edu.cn/simple
### macOS篇 其实非常类似Linux。。。

curl https://bootstrap.pypa.io/get-pip.py | python3
pip3 install compiledb -i https://pypi.tuna.tsinghua.edu.cn/simple
brew install make cmake ccls
brew install --cask gcc-arm-embedded

似乎brew里没有OpenOCD,所以....只能自食其力了,先安装brew里有的依赖和编译环境

brew install capstone hidapi libusb
brew install pkg-config libtool autoconf automake

手动编译安装libjaylink

git clone https://github.com/syntacore/libjaylink.git --depth=1
cd libjaylink
./autogen.sh
./configure
make -j
sudo make install
make clean

编译安装OpenOCD

git clone git://git.code.sf.net/p/openocd/code openocd-code
cd openocd-code
./bootstrap
./configure
make -j 
sudo make install
mak clean

不要乱删源码,卸载的时候需要makefile

VSCode插件安装

Linux 和 Mac的插件列表:

插件 功能 是否必须 备注
Arm Assembly 为ARM汇编提供高亮 可选 -
C/C++ vcFormat工具人 可选 vcFormat配置起来比较方便,而且功能比较强大
ccls LSP客户端,为VSCode提供C/Cpp语言支持 必须 比MS官方的C/C++插件提供的功能强大,并且速度更快
Coretex-Debug 为MCU提供调试功能 必须 其实用C/C++那个cppdbg也能配置出来,只是极其麻烦
Doxygen Doxygen支持 可选 Doxygen可以说是文档神器了2333
Doxygen Documentation Generator Doxygen格式注释模板自动生成 可选 再也不用手动敲doxygen模板辣
GieLens 更强大的Git集成 可选 不用Git的可以忽略
ident-rainbow 渲染缩进时上色,便于分辨缩进 可选 -
Makefile Tools 提供Makefile语言支持和自动配置C/C++ IntelliSense 必须 如果不配置IntelliSense C/C++会给你渲染很多并不存在的Error
MemoryView 提供内存监视功能 可选 -
RTOS Views 为调试RTOS提供了一些诸如task监视的功能 可选 -
Todo Tree 高亮注释中的TODO和FIXME,并且生成树状图 可选 -

Windows插件列表

插件 功能 是否必须 备注
Arm Assembly 为ARM汇编提供高亮 可选 -
C/C++ vcFormat工具人 可选 vcFormat配置起来比较方便,而且功能比较强大
clangd LSP客户端,为VSCode提供C/Cpp语言支持 必须 稍稍逊色于ccls,但比MS官方那坨东西强了很多
Coretex-Debug 为MCU提供调试功能 必须 其实用C/C++那个cppdbg也能配置出来,只是极其麻烦
Doxygen Doxygen支持 可选 Doxygen可以说是文档神器了2333
Doxygen Documentation Generator Doxygen格式注释模板自动生成 可选 再也不用手动敲doxygen模板辣
GieLens 更强大的Git集成 可选 不用Git的可以忽略
ident-rainbow 渲染缩进时上色,便于分辨缩进 可选 -
Makefile Tools 提供Makefile语言支持和自动配置C/C++ IntelliSense 必须 如果不配置IntelliSense C/C++会给你渲染很多并不存在的Error
MemoryView 提供内存监视功能 可选 -
RTOS Views 为调试RTOS提供了一些诸如task监视的功能 可选 -
Todo Tree 高亮注释中的TODO和FIXME,并且生成树状图 可选 -

Happy Coding(以STM32为例)

先生成makefile工程,just like this:

Makefile_Project_Generate.png

然后cd到工程目录,Linux用户执行:

bear -- make -j

macOS/Windows用户执行:

compiledb make -n

这一步是为了生成compild_commands.json,无论是clangd还是ccls都需要用它来配置includePath等工程信息。(对于使用cmake的工程直接加上-DCMAKE_EXPORT_COMPILE_COMMANDS=on即可生成,不需要外部工具)。

然后用VSCode打开工程目录,就可以Happy Coding辣。

VSCode.png

注意:Windows用户需在mingw64中启动code,否则环境变量不全会导致奇奇怪怪的问题

Debug配置

在工作目录下建立.vscode文件夹,并在.vscode中建立tasks.json和launch.json两个文件。我的tasks.json如下

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "type": "process",
            "command": "make",
            "args": ["-j"]
        }
    ]
}

tasks.json可以直接抄过去,但接下来的launch.json需要根据实际情况配置,我的一个工程的launch.json如下:

{
    "version": "0.2.0",
    "configurations": [
        {
            "request": "launch",
            "type": "cortex-debug",
            "servertype": "openocd",
            "preLaunchTask": "build",
            "runToEntryPoint": "main",
            "gdbPath": "gdb-multiarch",
            "cwd": "${workspaceFolder}",
            "name": "Debug with OpenOCD",
            "showDevDebugOutput": "both",
            "objdumpPath": "arm-none-eabi-objdump",
            "executable": "${workspaceFolder}/build/${workspaceFolderBasename}.elf",
            "svdFile": "/media/jinyi/Jinyi/Software/CubeProgrammer/SVD/STM32F103.svd",
            "configFiles": [
                "interface/stlink.cfg",
                "target/stm32f1x.cfg"
            ],
        }
    ]
}

对于macOS用户,需要把gdbPath改为arm-none-eabi-gdb。如果没有对应芯片的SVD请直接删除svdFile一项。如果是自己使用CubeMX直接生成的文件夹可以不动executable,如果是从网上下载/Clone的代码,请将executable替换为你的elf文件路径。最后,configFiles需要根据自己的调试器和MCU配置,可以在/usr/local/share/openocd/scripts或者/usr/share/openocd/scripts/下找到target和interface文件夹,在里面选择自己使用的配置即可。需要注意的是configFiles中两个配置项的顺序 不能调换 ,否则会导致无法调试。

题目要求

  1. 使用单片机UART或LCD屏显示测量结果
  2. 测量范围10nF~100nF
  3. 测量绝对误差不大于1nF

参考方案分析

老师给我们提供了一个参考方案——用555搭建震荡器,通过测555输出频率来求解电容量。虽然笔者并不打算用这个方案,但还是分析一下好了 (不然怎么能体现出我方案的优越性呢)。这个方案的电路大概长这样:
NE555_Osc_Circuit.png
(电路图来源于网络,侵删)

讲道理,这玩意简单到我懒得分析原理了,但不分析原理好像又不太能讲明白为啥不用....
简单分析一下好了。首先,555的内部结构长这样:

NE555_Internal_Circuit.png

如果把这玩意替换到上面的图里,555无稳态振荡器的原理就很明了了:

  • Stage1: 上电初期,RS触发器的输出为0,DISCH和GND间的BJT关断,此时 \(V_{cc}\) 通过 \(R_{1}\)\(R_{2}\)给C充电
  • Stage2: 充电至\(U_{C} > \frac{1}{3}V_{cc}\), 此时,下面的比较器输出低电平,S端被置0,但R端依然为0,RS触发器状态不变
  • Stage3: 充电至\(U_{C} > \frac{2}{3}V_{cc}\),此时,上面的比较器输出高电平,R端被置1,而S端在Stage2中就成了0,RS触发器的输出为1,此时DISCH和GND间BJT导通,电容通过\(R_{2}\)放电
  • Stage4: 放电至\(U_{C} < \frac{2}{3}V_{cc}\),此时,上面的比较器输出低电平,R端被置0,此时S依然为0,RS触发器输出仍然为1
  • Stage5: 放电至\(U_{C} < \frac{1}{3}V_{cc}\),此时,下面的比较器输出高电平,S端被置1,此时RS触发器状态变化,输出翻转到0,状态回到Stage1

根据上面的过程,我们可以很容易的计算NE555的输出频率和占空比。由于稳定下来之后\(U_{C}\)一直介于\(\frac{1}{3}V_{cc}\)\(\frac{2}{3}V_{cc}\)之间,对于充电过程,有: \[ t_{charge} = (R_{1} + R_{2})C\ln{\frac{\frac{2}{3}V_{cc} - V_{cc}}{\frac{1}{3}V_{cc} - V_{cc}}} \] \[ t_{charge} = (R_{1} + R_{2})C\ln{2} \] 同理可得: \[ t_{discharge} = R_{2}C\ln{2} \] 所以有: \[ f = \frac{1}{t_{charge} + t_{discharge}} = \frac{1}{(R_{1} + R_{2})C\ln{2} + R_{2}C\ln{2}}\] \[ DutyCycle = \frac{t_{charge}}{t_{discharge} + t_{charge}} = \frac{(R_{1} + R_{2})C\ln{2}}{(R_{1} + R_{2})C\ln{2} + R_{2}C\ln{2}} = \frac{R_{1} + R_{2}}{R_{1} + 2R_{2}}\]

Seems good! 然而这个电路有其不可避免的问题: - 首先,NE555本身飘的很厉害。 - 其次,这个电路里出现了\(\ln{2}\),这是个无理数。在处理数据时必然存在浮点数精度丢失的问题。

于是——就引出了我的方案:V-I变换器恒流充电 + STM32模拟看门狗

我的方案

硬件设计 (V-I变换器)

先设计好V-I变换器的拓扑,笔者使用双反馈结构的V-I变换器,电路如下:

V-IConverterWithTrim.png

事实上,这个电路依然非常简单。\(V_{Trim}\)是用来调零的信号,在分析阶段我们可以认为它就是0(不知道什么是调零的戳这里)。我们先约定一些记号:

符号 含义
\(U_{in}\) \(V_{input+}\) 节点电压
\(U_{1}A_{-}\) \(U_{1}A\) 反相端电压
\(U_{1}A_{+}\) \(U_{1}A\) 同相端电压
\(U_{1}A_{Out}\) \(U_{1}A\) 输出电压
\(U_{1}B_{+}\) \(U_{1}B\) 同相端电压
\(U_{out}\) \(V_{output}\) 节点电压
\(I_{out}\) \(R_{5}\)\(C_{3}\) 的电流

对于这个电路,由运放定律可知,在稳态下,必然有:

\[ \begin{equation} U_{1}A_{+} = U_{1}A_{-} \end{equation} \]

我们分析 \(U_{1}A_{-}\),很容易有(电阻分压):

\[ \begin{equation} U_{1}A_{-} = U_{1}A_{Out} \times \frac{R_2}{R_2 + R_4} \end{equation} \]

然后看 \(U_{1}A_{+}\),同理有:

\[ \begin{equation} U_{1}A_{+} = \left(U_{1}B_{Out} - U_{in}\right) \times \frac{R_1}{R_1 + R_3} \end{equation} \]

由于 \(U_{1}B\) 是跟随器,有:

\[ \begin{equation} U_{1}B_{Out} = U_{Out} \end{equation} \]

代回式(3),有:

\[ \begin{equation} U_{1}A_{+} = \left(U_{Out} - U_{in}\right) \times \frac{R_1}{R_1 + R_3} \end{equation} \]

联立式(1)(2)(5),有:

\[ \begin{equation} U_{1}A_{Out} \times \frac{R_2}{R_2 + R_4} = \left( U_{Out} - U_{in} \right) \times \frac{R_1}{R_1 + R_3} \end{equation} \]

对于我们的电路而言:

\[ R_1 = R_2 = R_3 = R_4 = 1K\Omega \]

故而,式(6)稍作整理有:

\[ \begin{equation} U_{1}A_{Out} = U_{out} - U_{in} \end{equation} \]

对于\(R_5\)有:

\[ \begin{equation} I_{out} = \frac{U}{I} = \frac{U_{1}A_{Out} - U_{out}}{R_5} \end{equation} \]

(7)带入(8),有: \[ I_{Out} = \frac{U_{in}}{R_5} \]

至此,V-I变换器的理论计算算是完成了,接下来就是\(R_5\)取值问题。这个电路的\(V_{input}\)将连接到STM32F407的DAC上,故而\(U_{in} \in \left[ 0, 3.3 \right] V\) 。对于本题,笔者并不满足于题目的要求,笔者希望电路至少能测量1nF~1uF的电容,并且充电时间介于1mS和60mS之间(充电时间限制来自于软件,后文会提到)。很容易得出充电电流应该在 1uA到55uA之间。不妨取3.3V时满幅输出为60mA,则\(R_5\)应为\(55K\Omega\),在\(E_{24}\) 系列电阻中取最接近的 \(56K\Omega\),满幅输出约为58.93uA,仍然满足要求。在输出为1uA时,DAC输出是560mV不算过小。所以,\(R_5\)\(56K\Omega\)是合适的。
至此,我们的硬件设计完成了。接下来就是Happy Coding。

软件设计

软件部分相对简单,直接给出设计思路好了。开启一个TIM提供1uS的定时中断,并设置一个累加器记录从充电开始到充电结束的时间。ADC开启模拟看门狗,上界设置为4095,这样在ADC输入达到3.3V时将触发模拟看门狗中断,在这个中断里关闭TIM的定时中断,并通过累加器的值计算电容量。

初识STM32U5

关于STM32U5

STM32U5系列是ST新推出的Cortex-M33架构的MCU产品线,主打超低功耗。由于架构的优化,Cortex-M33事实上也是一种高性能的架构。听起来好像很不错的样子,但是, there are no silver bullet。兼顾超低功耗和高性能必然带来一个结果——编程繁琐且容易出错。

STM32U5的电源域与时钟树

电源域

对于一个现代的MCU而言,电源域的分割是必要的。一方面是某些模块对于供电有特殊要求,例如ADC、DAC和比较器这种模拟外设需要尽可能"纯净"的电源,把它们和数字部分的电源划分在一起显然是不合适的。另一方面是不同模块之间的电压不尽相同,例如在STM32U5中,\(V_{IOBank}\)需要3V3,而\(V_{Core}\)却只能接受1V8。U5的电源大致有这么几个域:

  • Core Domain
  • \(V_{DD}\) Domain
  • Backup Domain (\(V_{BAT}\))
  • Analog Domain (\(V_{DDA}\))
  • SMPS Power Stage (这个电源域只有内置DC-DC的型号有)
  • \(V_{DDIO2}\) Domain
  • \(V_{DDUSB}\) Domain

看名称应该也能猜出来各个电源域负责什么东西,我们踩的一个大坑就和Power Domain有关,暂且按下不表。

时钟树

在一个规模稍大的数字系统中,一个时钟往往是不够用的。而我们不希望各个时钟都需要一个独立的震荡器,因为这显然过于消耗IO了。于是,我们在片内做上一系列的PLL,通过它们把输入的主时钟通过分频和倍频调整到合适的频率送给各个模块。我们将片内的PLL和MUX结构称为时钟树。这是从STM32U575手册中截取的时钟树示意图:
STM32U5ClockTree.png
可以看到,U5的时钟树比较复杂。我们本次踩坑涉及的部分主要是DAC的时钟问题,这个留到下文再说。

点个灯吧

讲了这么多,怎么能少了电子界的Hello World——Blink呢?由于笔者使用的NUCLEO-U575ZI-Q,CubeMX自带了BSP,笔者就直接从Board Selector中生成工程了。 其实直接使用默认配置即可,但笔者很不喜欢不开ICache的时候生成代码弹出的那个Warning,遂启用ICache:
NUCLEO-U575-ZI-QBlinkCubeMX.png (反正ICache也没有啥需要配置的东西还能增强性能,何乐而不为呢)
然后生成代码,打开工程,在初始化代码里加上:

uint8_t i = 0;
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);

然后在循环里加上:

switch (i) {
    case 0:
        i++;
        HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);
        HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);
        break;
    case 1:
        i++;
        HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_SET);
        HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);
        break;
    case 2:
        i++;
        HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_SET);
        break;
    case 3:
        i++;
        HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_SET);
        HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);
        break;
    case 4:
        i = 0;
        HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);
        HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
        HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);
        break;
    default:
        break;
}
HAL_Delay(200);

然后直接编译下载即可。

玩玩模拟外设

STM32U575的模拟外设还是挺强悍的ADC1 14Bit 2.5Msps,ADC2 12Bit 2.5Msps。DAC两个通道12Bit,带可开启的SHA。但是有个学弟说U5的ADC有问题,怎么采都是0,笔者遂决定调一下ADC,用默认生成的代码,加上轮询,果然是0,于是就有了本文23333。

ADC调试

我先是按其他系列的配置方式把ADC配置为轮询单次采样,CubeMX生成的代码大概是这样:

void MX_ADC1_Init(void){
/* USER CODE BEGIN ADC1_Init 0 */
/* USER CODE END ADC1_Init 0 */
/* USER CODE BEGIN ADC1_Init 1 */
/* USER CODE END ADC1_Init 1 */
/** Common config*/
 hadc1.Instance = ADC1;
 hadc1.Init.ClockPrescaler = ADC_CLOCK_ASYNC_DIV1;
 hadc1.Init.Resolution = ADC_RESOLUTION_14B;
 hadc1.Init.GainCompensation =   0;
 hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE;
 hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV;
 hadc1.Init.LowPowerAutoWait = DISABLE;
 hadc1.Init.ContinuousConvMode = DISABLE;
 hadc1.Init.NbrOfConversion =   1;
 hadc1.Init.DiscontinuousConvMode = DISABLE;
 hadc1.Init.DMAContinuousRequests = DISABLE;
 hadc1.Init.TriggerFrequencyMode = ADC_TRIGGER_FREQ_HIGH;
 hadc1.Init.Overrun = ADC_OVR_DATA_PRESERVED;
 hadc1.Init.LeftBitShift = ADC_LEFTBITSHIFT_NONE;
 hadc1.Init.ConversionDataManagement = ADC_CONVERSIONDATA_DR;
 hadc1.Init.OversamplingMode = DISABLE;
 if   (HAL_ADC_Init(&hadc1) != HAL_OK) {
 Error_Handler();
 }
/* USER CODE BEGIN ADC1_Init 2 */
/* USER CODE END ADC1_Init 2 */
}

看上去很合理对吧,我们在主程序里添加轮询代码:

HAL_ADC_Start(&hadc1);
volatile uint16_t adcValue;
volatile uint16_t retValue;
while(1) {
    retValue = HAL_ADC_PollForConversion(&hadc1, 50);
    adcValue = HAL_ADC_GetValue(&hadc1);
}

这里随手定义的retValue在后面的调试给我帮了大忙,后面会提到。不如直接开始编译调试吧,然后我就得到了这样的结果:

Debug_ScreenShot.png
retValue是3,adcValue是0。retValue不是HAL_OK (也就是0),这中间肯定出了问题,先找找3是什么意思吧。在一番查找之后,可以在stm32u5xx_hal_def.h中找到:

typedef enum {
    HAL_OK = 0x00,
    HAL_ERROR = 0x01,
    HAL_BUSY = 0x02,
    HAL_TIMEOUT = 0x03
} HAL_StatusTypeDef;

retValue是3,这意味着Time Out。但是50mS的时间怎么都该足够让一个2.5Msps的ADC完成一次采样了,百思不得其解。遂找一调过U5的学弟询问此事,学弟答:需要在初始化里加入如下代码:

SET_BIT(RCC->AHB3ENR, RCC_AHB3ENR_PWREN);
HAL_Delay(1);
SET_BIT(PWR->SVMCR, PWR_SVMCR_AVM1EN);
HAL_Delay(1);
while (READ_BIT(PWR->SVMSR, PWR_SVMSR_VDDA1RDY) == 0); 
SET_BIT(PWR->SVMCR, PWR_SVMCR_ASV); 
CLEAR_BIT(RCC->AHB3ENR, RCC_AHB3ENR_PWREN);

怎么是手动操作寄存器,赣!遂在工程中一个一个搜索Mask,功夫不负有心人。我在stm32u5xx_hal_pwr_ex.c中找到了这样一个函数:

/**
* @brief Enable VDDA supply.
* @note Remove VDDA electrical and logical isolation, once VDDA supply is
* present for consumption saving.
* @retval None.
*/
void HAL_PWREx_EnableVddA(void) {
 SET_BIT(PWR->SVMCR, PWR_SVMCR_ASV);
}

似乎是启用\(V_{DDA}\)电源域的函数。笔者的开发环境有调用计数功能,我一看函数上面赫然显示"0 ref",好家伙,合着您压根没开\(V_{DDA}\)电源域是吗。遂在MX_ADC1_Init中加上启用电源域的代码:

HAL_PWREx_EnableVddA();

然后重新编译调试,果然ADC能够正常采样了。所以....我又跑回去仔细检查了CubeMX配置页面,发现根门没有电源域配置这一项。所以.....即使U5已经上市这么久了,CubeMX的支持还是个半成品......ST你坏事做尽!

DAC调试

虽然学弟提的ADC问题解决了,但那个调过U5的学弟又说了另外一个问题,它的DAC似乎不太对,只能运行在很低的采样率下。我想着反正已经开始调了,不如一鼓作气把DAC也调一下。我没有急着开始Coding,鉴于学弟说速度很低,我首先想到的是时钟树配置问题,我在时钟树里找到了这个: DAC_Sample_Hold_Clock_MUX.png
我打出一个?,怎么会接在LSI或者LSE上的,难道DAC只能低频工作吗?我总觉得不太对劲,遂打开了U5的Reference Manual找到DAC部分的DAC Features表格:
U5_DAC_Features.png Maximum sampling rate(吐槽一下,ST怎么把采样率写成采样时间了)1Msps,我现在一头雾水,32.768KHz的Clk怎么能有1Msps的采样率,遂继续读RM,我先是看到了在DAC conversion里这样一段话:
HFSEL bits of DAC_MCR must be set when dac_hclk or dac_ker_ck clock speed is faster than 80 MHz. It adds an extra delay to the transfer from DAC_DHRx register to DAC_DORx register. Refer to Table HFSEL description below for the limitation of the DAC_DORx update rate depending on HFSEL bits and dac_hclk clock frequency.If the data is updated or a software/hardware trigger event occurs during the non-allowed.period, the peripheral behavior is unpredictable.The above timing is only related to the limitation of the DAC interface. Refer also to the tSETTLING parameter value in the product datasheet.
似乎这个参数我也没配置正确来着,但这不是主要问题,我仍不知道1Msps的采样率从何而来,遂继续看,终于在DAC channel modes一节中找到了问题的答案。U5的DAC分为Normal mode和Sample and hold mode。我找到的那个MUX只负责SHA。。。。破案了。 于是回到CubeMX,配置好DAC High Frequency Mode和Mode Select,生成代码,写一个打锯齿波的程序:

uint16_t i = 0;
HAL_DAC_Start(&hdac1, DAC_CHANNEL_1);
while(1) {
    HAL_DAC_SetValue(&hdac1, DAC_CHANNEL_1, DAC_ALIGN_12B_R, i);
    i = i < 4096 ? i + 1 : 0;
}

编译下载,然后挂上示波器,得到了一个大约2KHz的锯齿波。一个周期4096个采样点,换算出来实际采样率居然达到了惊人的4Msps。看来ST的手册还是过于保守了,强行轮询都能做到4Msps,手册上居然只标注为1Msps.....

0%