Max30102

原理

介绍

我们从阅读Max30102的用户手册开始。该用户手册是从Max30101和Max30102对比开始的。我们来看一下有什么不同的

手册的链接:Max30102

设备 现有的LED 可用模式
MAX30101 绿色、红色、红外线(IR) 心率、血氧
MAX30102 红色、红外线 心率、血氧

接下来是讲解一些类似模块的原理

透射测量原理

模块使用LED发射特定波长的光到待检测部位上,然后由另一端的光电探测器吸收,每种波长的吸光度变化决定了氧饱和度水平,所以我们一般选择有足够血液灌注的待测试部位,例如手指,耳垂等。以最大限度的提高光的传输。

反射式测量原理

在这种情况下,模块的设置类似于透射式脉搏血氧仪的设置,只不过我们将光电二极管和LED放置在同一个位置。当LED照亮皮肤时,同时监测反射信号以了解光吸收的变化。我们把这种方法叫做光电容积描记法(PPG),我们的Max3010x系列就是使用反射式脉搏血氧仪技术来做测量心率和SP02。

注记测量脉搏血氧都差不多是这种理念,从组织反射或透射足够的光信号,然后被光电二极管捕获。因此,我们实际关注的只有LED的信号强度、脉冲宽度(采样率)来对模块做优化。

算法

我们来重点的介绍模块的运作方式,我们的血液中存在一些血红蛋白化合物,若我们只计算SP02,就只需要含氧血红蛋白和脱氧血红蛋白来做检测。在max30102中,光电二极管检测的反射信号主要是由动脉和毛细血管的体积变化得到的光。我们所用的PPG方法,将会得到两种不同的信号,分别是直流部分和交流部分。如图所示:

PPG信号的AC,DC部分

由于技术原因无法使用中文,我们来做名词的解释。Systole(收缩压),Diastole(舒张压),Pulsatile arterial blood(有脉动动脉血),Venous blood(静脉血),Cardiac cycle(心跳周期)

直流部分是来自非脉动组织的光吸收,包括静脉毛细血管和动脉血。而AC部分来自动脉血的脉动性。由于动脉和心脏直接连接,动脉血就会随着心脏的脉动而脉动,我们通过测量收缩压之间的峰值来计算瞬时心率。

算法

Max3010x系列通过采用两种不同的波长的LED来识别含氧血红蛋白和脱氧血红蛋白比率,红色和红外LED用于单独的确定PPG信号,为了进行比较,我们需要将数据归一化,首先定义一个比率$R$,它与SP02成正比,其中sp02和Sa02(血氧饱和度)具有很好的近似效果。那么我们将$R$定义为:

$R = \cfrac{\frac{AC_{red}}{DC_{red}}}{\frac{AC_{infrared}}{DC_{infrared}}}$

其中red表示红色的LED而infrared表示红外线LED。一旦确定了R,我们就可以通过曲线近似来寻找Sp02的估计值,一个最佳直线近似值导出的比率R大概在$0.4$到$3.4$,而计算公式为:

$SpO_2 = 104-17R$

一般性建议

在一个脉冲宽度中,它告诉我们信号在多长时间内有效,如果信号激活的时间越长,那么消耗的能量就越多。而LED的驱动频率由采样率决定,采样率高就有较高的驱动频率,就会消耗更多能量

在理想状态下,我们应该选择低脉冲宽度和低采样率以降低能源损耗,但这不行,因为两者是反比关系。但是手册给我们提供了一些设置,我们可以根据手册提供的参数选择我们需要的采样率和脉冲宽度。下面的两个表格展示了HR(单LED模式)和Sp02(双LED)模式的允许配置

首先是心率配置

采样率 采样宽度
69 118 215 411
50 $\circ$ $\circ$ $\circ$ $\circ$
100 $\circ$ $\circ$ $\circ$ $\circ$
200 $\circ$ $\circ$ $\circ$ $\circ$
400 $\circ$ $\circ$ $\circ$ $\circ$
800 $\circ$ $\circ$ $\circ$ $\circ$
1000 $\circ$ $\circ$ $\circ$ $\circ$
1600 $\circ$ $\circ$ $\circ$ 不允许
3200 $\circ$ 不允许 不允许 不允许

然后是SpO2配置

采样率 采样宽度
69 118 215 411
50 $\circ$ $\circ$ $\circ$ $\circ$
100 $\circ$ $\circ$ $\circ$ $\circ$
200 $\circ$ $\circ$ $\circ$ $\circ$
400 $\circ$ $\circ$ $\circ$ $\circ$
800 $\circ$ $\circ$ $\circ$ 不允许
1000 $\circ$ $\circ$ 不允许 不允许
1600 $\circ$ 不允许 不允许 不允许
3200 不允许 不允许 不允许 不允许

最后我们说一下模块的寄存器和一些设置。模块的使用方法就是设置寄存器来调整模块的工作方式,并得到里面的数据。这很像我们在图灵完备中得到的机器一样。

我们先来看几张表,然后解释一下寄存器的作用

图1 图2 图3 图4 图5 图6 图7 图8

图上是寄存器的名字和他们的地址,由于Max30102模块是通过I2C协议传输的,为此我们需要寻址,和针对寄存器做设置。

0x09寄存器(模式设置)是用来控制关机、复位、HR模式和SpO2模式还有多LED模式。B7是用于控制关机的(shutdown control aka SHDN),B6是复位,而3-5我们不管,0-2的模式在图1可见,它是用来设置要工作的模式的。

例如,当我们对0x09发送请求的时候收到一个ack,然后发送00000111到0x09寄存器上,则寄存器将模块设置在多LED工作模式,这时候可以同时对血氧饱和度和心率进行检测。

其次,对于图1的第二部分中5-6比特位,它是设置关于SpO2的ADC范围控制,我们可以由该控制器设置量程范围,范围在2048nA到16384nA,默认范围为8192nA。

而2-4字节的是对应SpO2的采样率控制,默认为100个样本(Hz)而0-1是LED脉冲宽度设置。

在SpO2模式下,每个样本由6个字节组成,每个字节需要一个I2C读取器才能读取样本。

其他的不太重要,我们来看图5,图5是关于中断的内容。当中断被触发后,这意味着FIFO数据已经满了(A_FULL),而新的FIFO数据(先入先出数据)已经准备录入(PPG_RDY),B4是环境光消除(ALC_DVF)该位是用来减少环境光的影响。比较要注意的是B5的中断位,若中断,则说明环境光抵消功能已经到了最大光,正在影响传感器的输出。

剩下的几个图提供了数据流和FIFO操作图。 FIFO_WR_PTR(FIFO写指针)指向下一个样本的位置,每次将新的样本弹出到FIFO上时,该指针就会前进。但注意的是,当采集的速度超过了存储的数量,这会导致数据溢出,而我们有一个寄存器OVF_COUNTER(FIFO溢出计数器)来计数这些丢失的数据,而当FIFO的数据推送完整个样本时,该计数器会清零。

有几个不是很大的问题,我们想更新FIFO怎么办呢,一般来说当FIFO完全满的时候只有读取FIFO_DATA或者更改FIFO_WR_PTR/FIFO_RD_PTR位置才可以继续更新数据,在这种情况下,标志位FIFO_ROLLOVER_EN可以通过设置其来让FIFO地址滚到0从而可以填充新数据。 一些简单的说明就到此结束,接下来让我们看看代码和实现。

代码&实现

分析的是这个项目的代码:Max30102_FUNCLIB
密码ae87

不过我们做了改动,让arduino变成了stm32的模板

实验环境:

组件 数量
Stm32F103c8t6 1
max30102 1
USB转TTL模块 1
ss1306驱动oled 1

代码只要的主要是四个文件,Max30102.c,Max30102.h和algorithm.c和algorithm.h。我们先来看第一个文件

第一段代码,我们来看

1
2
3
4
5
6
HAL_StatusTypeDef Max30102_WriteData(uint8_t MemAddress,uint8_t Command,uint16_t SendCount)
{
HAL_StatusTypeDef status=HAL_OK;
status=HAL_I2C_Mem_Write(&hi2c2,Max30102_Write_Address,MemAddress,I2C_MEMADD_SIZE_8BIT,&Command,SendCount,100);
return status;
}

我们定义了一个函数Max30102_WriteData,它的参数分别是Max30102模块的寄存器地址,第二个参数是要发送的命令,第三个是发送的字节数。我们定义了一个status,它用来返回一个状态码,HAL_I2C_Mem_Write函数是用来通过I2C向Max30102发送数据的。我们通过该函数向特定寄存器发送特定命令。

接着是第二个函数

1
2
3
4
5
6
7
HAL_StatusTypeDef Max30102_ReadData(uint8_t DatAddress,uint8_t *Data,uint16_t ReceiveCount)
{
HAL_StatusTypeDef status=HAL_OK;
status=HAL_I2C_Mem_Read(&hi2c2,Max30102_Read_Address,DatAddress,I2C_MEMADD_SIZE_8BIT,Data,ReceiveCount,100);
return status;
}

我们通过该函数来读取寄存器中的样本数据。

我们写一个批量读取数据的函数。定义

1
2
3
4
5
6
7
8
void Max30102_FIFO_ReadData(uint8_t DatAddress,uint8_t SixData[6],uint16_t Size)
{
uint8_t temp;
Max30102_ReadData(REG_INTR_STATUS_1,&temp,1);
Max30102_ReadData(REG_INTR_STATUS_2,&temp,1);
Max30102_ReadData(DatAddress,SixData,Size);
}

跟之前一样,我们用这个函数来输出我们需要的6个样本。

接着我们定义一些需要用到的符号,

1
2
3
4
5
6
7
8
9
10
11
12
13
uint8_t TempData[6];
uint32_t red_buffer[500]; //红光数据red,用于计算心率
uint32_t ir_buffer[500]; //红外数据 ir,用于计算血氧
int32_t ir_buffer_length=500; //计算前500个样本得到的数据
int32_t pn_SpO2_value; //血氧实际值
int8_t SpO2_valid; //血氧值有效标志
int32_t pn_hr_value; //心率实际值
int8_t hr_valid; //心率有效标志
uint32_t red_max=0,red_min=0x3FFFF; //红光取值范围
uint32_t prev_data; //前一次的值
float f_temp; //临时变量
int32_t n_brightness; //明确变量

然后我们要来实现算法,我们知道样本是6个字节,注意的是,前3个字节是关于心率的,后三个关于血氧,为此我们可以定义一个函数用于读取红外和红色LED反射到的数据。

1
2
3
red_buffer[i]=((TempData[0])<<16) | (TempData[1]<<8) | (TempData[2]);
ir_buffer[i]=((TempData[3])<<16) | (TempData[4]<<8) | (TempData[5]);

下面的函数是用来计算心率和血氧并输出的

模块通过ADC进行采样储存到寄存器中,当寄存器满了之后输出中断,当检测到中断之后我们取出寄存器中的数据来进行计算。maxim_heart_rate_and_oxygen_saturation函数在等一下讲算法的时候说。

为了速度,我们不妨只传入100个参数来作为计算值,那么我们定义函数体Max30102_Calculate_HR_BO_Value,它的功能是计算,传入的参数分别是心率值的地址,模块校验位,血氧数据的地址和血氧的校验位。当校验位置1的时候说明模块在工作,那么函数为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Max30102_Calculate_HR_BO_Value(int32_t* HR_Value,int8_t* HR_Valid,int32_t* BO_Value,int8_t* BO_Valid)
{
for(int i=100;i<500;i++) //将数组中的100~500采样值向前挪到0~400
{
red_buffer[i-100]=red_buffer[i];
ir_buffer[i-100]=ir_buffer[i];
if(red_min>red_buffer[i]) red_min=red_buffer[i]; //更新当前最小值
if(red_max<red_buffer[i]) red_max=red_buffer[i]; //更新当前最大值
}
for(int i=400;i<500;i++) //实际只取100个采样值来计算
{
prev_data=red_buffer[i-1];
while(Max30102_INT==1); //等待中断引脚相应,默认为高,当触发后会拉低
Max30102_FIFO_ReadData(REG_FIFO_DATA,TempData,6);
red_buffer[i]=((TempData[0] )<<16) | (TempData[1]<<8) | (TempData[2]); //前三位数据组成HR
ir_buffer[i]=((TempData[3] )<<16) | (TempData[4]<<8) | (TempData[5]); //后三位数据组成BO
*HR_Value=pn_hr_value;
*HR_Valid=hr_valid;
*BO_Value=pn_SpO2_value;
*BO_Valid=SpO2_valid;
}
maxim_heart_rate_and_oxygen_saturation(ir_buffer,ir_buffer_length,red_buffer,&pn_SpO2_value,&SpO2_valid,&pn_hr_value,&hr_valid);
}

当数据输出完后寄存器为空,这时候我们定义如下函数将继续填充数据直到寄存器再次充满

1
2
3
4
5
6
7
8
9
10
11
12
void Max30102_Safety(void)
{
for(int i=0;i<ir_buffer_length;i++)
{
while(Max30102_INT==GPIO_PIN_SET); //等待中断引脚相应,默认为高,当触发后会拉低
Max30102_FIFO_ReadData(REG_FIFO_DATA,TempData,6);
red_buffer[i]=((TempData[0]&0x03)<<16) | (TempData[1]<<8) | (TempData[2]); //前三位数据组成HR
ir_buffer[i]=((TempData[3]&0x03)<<16) | (TempData[4]<<8) | (TempData[5]); //后三位数据组成BO
}
maxim_heart_rate_and_oxygen_saturation(ir_buffer,ir_buffer_length,red_buffer,&pn_SpO2_value,&SpO2_valid,&pn_hr_value,&hr_valid);
}

介绍了一些具体读写寄存器数据的函数后,我们来看一些其他的。

首先是复位函数,我们向0x40地址写入1来完成模块的复位

1
2
3
4
5
6
7
void Max30102_Reset(void)
{
Max30102_WriteData(REG_MODE_CONFIG,0x40,1);
Max30102_WriteData(REG_MODE_CONFIG,0x40,1);
}


接着我们初始化模块,定义函数Max30102_Init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Max30102_Init(void)
{
Max30102_Reset();

Max30102_WriteData(REG_INTR_ENABLE_1,0xc0,1); // INTR setting
Max30102_WriteData(REG_INTR_ENABLE_2,0x00,1);
Max30102_WriteData(REG_FIFO_WR_PTR,0x00,1); //FIFO_WR_PTR[4:0]
Max30102_WriteData(REG_OVF_COUNTER,0x00,1); //OVF_COUNTER[4:0]
Max30102_WriteData(REG_FIFO_RD_PTR,0x00,1); //FIFO_RD_PTR[4:0]
Max30102_WriteData(REG_FIFO_CONFIG,0x0f,1); //sample avg = 1, fifo rollover=false, fifo almost full = 17
Max30102_WriteData(REG_MODE_CONFIG,0x03,1); //0x02 for Red only, 0x03 for SpO2 mode 0x07 multimode LED
Max30102_WriteData(REG_SPO2_CONFIG,0x27,1); // SPO2_ADC range = 4096nA, SPO2 sample rate (100 Hz), LED pulseWidth (400uS)
Max30102_WriteData(REG_LED1_PA,0x24,1); //Choose value for ~ 7mA for LED1
Max30102_WriteData(REG_LED2_PA,0x24,1); // Choose value for ~ 7mA for LED2
Max30102_WriteData(REG_PILOT_PA,0x7f,1); // Choose value for ~ 25mA for Pilot LED
}


其中0x0f置1时,则指针不会滚到地址0重新填写数据。

最后我们定义输出到OLED屏幕上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void Read(void)
{
int32_t HR_Value,BO_Value;
int8_t HR_Valid,BO_Valid;
Max30102_Calculate_HR_BO_Value(&HR_Value,&HR_Valid,&BO_Value,&BO_Valid);
if(HR_Valid==1 && BO_Valid==1)
{
OLED_ShowString(1,1,"HR:");
OLED_ShowString(2,1,"SaO2: ");
OLED_ShowNum(1,5,HR_Value,3);
OLED_ShowNum(2,8,BO_Value,3);
}
}

准备工作就到此结束。我们来看硬件方面的,打开文件Max30102.h,其中有一系列的定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#ifndef __Max30102_h
#define __Max30102_h

#include "main.h"

#define Max30102_INT HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_7)

#define Max30102_Write_Address 0xAE
#define Max30102_Read_Address 0xAF
#define REG_INTR_STATUS_1 0x00
#define REG_INTR_STATUS_2 0x01
#define REG_INTR_ENABLE_1 0x02
#define REG_INTR_ENABLE_2 0x03
#define REG_FIFO_WR_PTR 0x04
#define REG_OVF_COUNTER 0x05
#define REG_FIFO_RD_PTR 0x06
#define REG_FIFO_DATA 0x07
#define REG_FIFO_CONFIG 0x08
#define REG_MODE_CONFIG 0x09
#define REG_SPO2_CONFIG 0x0A
#define REG_LED1_PA 0x0C
#define REG_LED2_PA 0x0D
#define REG_PILOT_PA 0x10
#define REG_MULTI_LED_CTRL1 0x11
#define REG_MULTI_LED_CTRL2 0x12
#define REG_TEMP_INTR 0x1F
#define REG_TEMP_FRAC 0x20
#define REG_TEMP_CONFIG 0x21
#define REG_PROX_INT_THRESH 0x30
#define REG_REV_ID 0xFE
#define REG_PART_ID 0xFF

void Max30102_Init(void);
void Max30102_Reset(void);
void Max30102_Safety(void);
void Max30102_Calculate_HR_BO_Value(int32_t* HR_Value,int8_t* HR_Valid,int32_t* BO_Value,int8_t* BO_Valid);

void Read(void);


#endif

1
2
#define Max30102_INT  HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_7)

定义了引脚的位置,这里我们我们通过GPIO函数来读取A7的值,A7接线就是INT的输出。所以我们可以通过该定义使用其他的引脚。

在算法文件algorithm.c中,maxim_heart_rate_and_oxygen_saturation函数是我们的重点关照对象,循环体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// remove DC of ir signal    
un_ir_mean =0;
for (k=0 ; k<n_ir_buffer_length ; k++ ) un_ir_mean += pun_ir_buffer[k] ;
un_ir_mean =un_ir_mean/n_ir_buffer_length ;
for (k=0 ; k<n_ir_buffer_length ; k++ ) an_x[k] = pun_ir_buffer[k] - un_ir_mean ;

// 4 pt Moving Average
for(k=0; k< BUFFER_SIZE-MA4_SIZE; k++){
n_denom= ( an_x[k]+an_x[k+1]+ an_x[k+2]+ an_x[k+3]);
an_x[k]= n_denom/(int32_t)4;
}

// get difference of smoothed IR signal

for( k=0; k<BUFFER_SIZE-MA4_SIZE-1; k++)
an_dx[k]= (an_x[k+1]- an_x[k]);

// 2-pt Moving Average to an_dx
for(k=0; k< BUFFER_SIZE-MA4_SIZE-2; k++){
an_dx[k] = ( an_dx[k]+an_dx[k+1])/2 ;
}


被我们用来处理信号,以得到更加平滑的信号来做运算,第一个for循环计算两个值之间的差距,第二个函数将前四个值加起来除以4做平滑化,后面两个也是对IR信号做一样的操作。

接着算法使用了汉明窗来减少影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   // hamming window
// flip wave form so that we can detect valley with peak detector
for ( i=0 ; i<BUFFER_SIZE-HAMMING_SIZE-MA4_SIZE-2 ;i++){
s= 0;
for( k=i; k<i+ HAMMING_SIZE ;k++){
s -= an_dx[k] *auw_hamm[k-i] ;
}
an_dx[i]= s/ (int32_t)1146; // divide by sum of auw_hamm
}


n_th1=0; // threshold calculation
for ( k=0 ; k<BUFFER_SIZE-HAMMING_SIZE ;k++){
n_th1 += ((an_dx[k]>0)? an_dx[k] : ((int32_t)0-an_dx[k])) ;
}
n_th1= n_th1/ ( BUFFER_SIZE-HAMMING_SIZE);

为了计算,我们需要找到精确的波峰值,那么我们就定义函数

1
2
3
4
5
for (k=0 ; k<n_ir_buffer_length ; k++ )  {
an_x[k] = pun_ir_buffer[k] ;
an_y[k] = pun_red_buffer[k] ;
}

获取峰值

接着我们使用函数找到想要的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
n_exact_ir_valley_locs_count =0; 
for(k=0 ; k<n_npks ;k++){
un_only_once =1;
m=an_ir_valley_locs[k];
n_c_min= 16777216;//2^24;
if (m+5 < BUFFER_SIZE-HAMMING_SIZE && m-5 >0){
for(i= m-5;i<m+5; i++)
if (an_x[i]<n_c_min){
if (un_only_once >0){
un_only_once =0;
}
n_c_min= an_x[i] ;
an_exact_ir_valley_locs[k]=i;
}
if (un_only_once ==0)
n_exact_ir_valley_locs_count ++ ;
}
}
if (n_exact_ir_valley_locs_count <2 ){
*pn_spo2 = -999 ; // do not use SPO2 since signal ratio is out of range
*pch_spo2_valid = 0;
return;
}

这段代码就2个操作,一是在第二个嵌套的for循环中找最小波峰并储存到n_c_min变量中。对于第二个if,若样本超出范围,设为-999来防止使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

if (n_middle_idx >1)
n_ratio_average =( an_ratio[n_middle_idx-1] +an_ratio[n_middle_idx])/2; // use median
else
n_ratio_average = an_ratio[n_middle_idx ];

if( n_ratio_average>2 && n_ratio_average <184){
n_spo2_calc= uch_spo2_table[n_ratio_average] ;
*pn_spo2 = n_spo2_calc ;
*pch_spo2_valid = 1;// float_SPO2 = -45.060*n_ratio_average* n_ratio_average/10000 + 30.354 *n_ratio_average/100 + 94.845 ; // for comparison with table
}
else{
*pn_spo2 = -999 ; // do not use SPO2 since signal ratio is out of range
*pch_spo2_valid = 0;
}

最后看一下这段代码,实际上当我们计算SpO2的时候,计算器会溢出,所以精度并不是很好,对每个精确的SpO2,都是提前计算的,这主要体现在倒数第一个if上。然后输出标志位1表示数据有效。在这段代码中,我们使用n_ratio_average进行查表,若n_ratio_average>2和 小于184,则根据表我们能得到血氧值。否则我们将输出置为-999并把校验位置0表示不适用血氧仪。表在算法文件中的algorthm.h给出:

1
2
3
4
5
6
7
8
9
10
11
//const uint8_t uch_spo2_table[184]={ 95, 95, 95, 96, 96, 96, 97, 97, 97, 97, 97, 98, 98, 98, 98, 98, 99, 99, 99, 99, 
// 99, 99, 99, 99, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
// 100, 100, 100, 100, 99, 99, 99, 99, 99, 99, 99, 99, 98, 98, 98, 98, 98, 98, 97, 97,
// 97, 97, 96, 96, 96, 96, 95, 95, 95, 94, 94, 94, 93, 93, 93, 92, 92, 92, 91, 91,
// 90, 90, 89, 89, 89, 88, 88, 87, 87, 86, 86, 85, 85, 84, 84, 83, 82, 82, 81, 81,
// 80, 80, 79, 78, 78, 77, 76, 76, 75, 74, 74, 73, 72, 72, 71, 70, 69, 69, 68, 67,
// 66, 66, 65, 64, 63, 62, 62, 61, 60, 59, 58, 57, 56, 56, 55, 54, 53, 52, 51, 50,
// 49, 48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 31, 30, 29,
// 28, 27, 26, 25, 23, 22, 21, 20, 19, 17, 16, 15, 14, 12, 11, 10, 9, 7, 6, 5,
// 3, 2, 1 } ;

纠结计算索引没多大意义,因为没有注释,论文,你不知道作者究竟在表达什么,我们直接讲实现

首先是接线图:

接着只需要烧录就行了。

我自己修改的项目地址:链接