原创博客,如有转载,注明出处——在金华的电子民工林。
原先写在其他论坛上的,现在转移到这边来,绝对原创,希望对大家有帮助。
上一篇说了central怎么开始新建一个项目,并讲解了主机发现从机并连接的一个流程,现在详细说一下我是怎么按照自己的思路,修改例程,来达到最终的目的的。
TI提供的demo里,上一篇已经说了,是通过按键实现的,2个按键,一个选择,一个确认,再加上串口打印显示实现功能。
我的项目呢,不需要这些按键实现,只需要串口发送命令来,根据串口的命令来进行操作。
目前为了简化思路,串口只接收一个指令,就是开启扫描,扫描到自己指定类型的从机,就保存这个从机信息,在扫描完成以后,自动进行链接,并读取服务,开启对应通道的Notify的CCC,然后接收从机的notify。
已经有了明确的思路,那很简单,程序就是跟着思路走,我们是怎么想的,程序就是怎么写的。
首先写好串口接收程序,我是建立了一个事件,串口接收符合我的协议规律的串口数据,就启动这个事件,在事件里处理比较串口数据。
建立事件:最后面2个是我建立的事件,一个就是串口事件,另一个是BLE协议的事件,功能后面会说。
// Simple Central Task Events
#define SBC_ICALL_EVT ICALL_MSG_EVENT_ID // Event_Id_31
#define SBC_QUEUE_EVT UTIL_QUEUE_EVENT_ID // Event_Id_30
#define SBC_START_DISCOVERY_EVT Event_Id_00
#define SBC_UART_PROCESS_EVT Event_Id_01
#define SBC_BLE_PROCESS_EVT Event_Id_02
#define SBC_ALL_EVENTS (SBC_ICALL_EVT | \
SBC_QUEUE_EVT | \
SBC_START_DISCOVERY_EVT | \
SBC_UART_PROCESS_EVT | \
SBC_BLE_PROCESS_EVT)
我们的事件,都是用定时器触发,所以,定义定时器时间结构体,然后注册定时器。
// Clock object used to signal timeout
static Clock_Struct startDiscClock;
static Clock_Struct UartProcessClock;
static Clock_Struct BleProcessClock;
// Setup discovery delay as a one-shot timer
Util_constructClock(&startDiscClock, SimpleBLECentral_startDiscHandler,
DEFAULT_SVC_DISCOVERY_DELAY, 0, false, 0); //开启发现服务的延迟
Util_constructClock(&UartProcessClock, SimpleBLECentral_UartProcess,
0, 0, false, 0); //开启串口处理事件
Util_constructClock(&BleProcessClock, SimpleBLECentral_BleProcess,
100, 0, false, 0); //开启蓝牙操作事件。
void SimpleBLECentral_startDiscHandler(UArg a0)
{
Event_post(syncEvent, SBC_START_DISCOVERY_EVT);
}
void SimpleBLECentral_UartProcess(UArg a0)
{
Event_post(syncEvent, SBC_UART_PROCESS_EVT);
}
void SimpleBLECentral_BleProcess(UArg a0)
{
Event_post(syncEvent, SBC_BLE_PROCESS_EVT);
}
void CallUartRXProcess(void)
{
Util_startClock(&UartProcessClock);
}
上面的步骤昨完以后,我们只要调用这个Util_startClock子程序赋予对应的定时器形参,就可以执行对应的事件。我是封装了一个子程序,给串口调用。串口接收调用如下:
我的协议规则,就是符合开头是ZH:,结尾是0x0a的,那就是符合的,调用子程序进行处理,
if((memcmp(SblRxBuf,"ZH:",3)==0)&&(SblRxBuf[rxlen-1]==0x0a))
{
memcpy(Serialpro.lbuf,SblRxBuf,rxlen);
UARTRevnum = rxlen;
CallUartRXProcess();
}
触发事件以后,程序会跑到事件处理的位置,如下图:
if (events & SBC_UART_PROCESS_EVT)
{
UartCommondProcess(Serialpro.lbuf,UARTRevnum);
UARTRevnum = 0;
}
在这个子程序里,就是比较字符串,程序如下:
void UartCommondProcess(uint8_t *str,uint16_t len)
{
if(memcmp(str,"ZH:StartScan\r\n",len)==0)
{
if(len!=14)
{
UartSendString("NotCompleteCom");
return;
}
StartBleScan();
UartSendString("OK");
return;
}
UartSendString("ErrCommond");
}
就是比较字符串,如果就是这个字符串的命令,我们就开启扫描了,扫描子程序StartBleScan,这个也是我自己封装的。具体如下:
uint8_t StartBleScan(void)
{
if(BleScanFlag) return 0;
BleScanFlag = 1;
GAPCentralRole_StartDiscovery(DEFAULT_DISCOVERY_MODE,
DEFAULT_DISCOVERY_ACTIVE_SCAN,
DEFAULT_DISCOVERY_WHITE_LIST);
return BleScanFlag;
}
这里添加了一个寄存器,用来表示目前是不是在扫描中,如果在扫描中了,连续发送本指令是无效的,防止多次启动扫描出问题。其实扫描的子程序就一条,非常简单。
启动扫描了,就是扫描过滤问题了,在上一篇中我们说过,只要发现一个从机,协议栈会调用一个状态回调告诉应用层,这个位置就在如下位置,我们进行了一个过滤处理。
case GAP_DEVICE_INFO_EVENT:
{
// UartSendStringAndint32("EventTyep= ",pEvent->deviceInfo.eventType);
// if filtering device discovery results based on service UUID
if (DEFAULT_DEV_DISC_BY_SVC_UUID == TRUE)
{
// if (SimpleBLECentral_findSvcUuid(SIMPLEPROFILE_SERV_UUID,
// pEvent->deviceInfo.pEvtData,
// pEvent->deviceInfo.dataLen))
if( pEvent->deviceInfo.eventType==GAP_ADRPT_ADV_IND)
{
if(SimpleBLECentral_findFactoryID(pEvent->deviceInfo.pEvtData,
pEvent->deviceInfo.dataLen))
{
SimpleBLECentral_addDeviceInfo(pEvent->deviceInfo.addr,
pEvent->deviceInfo.addrType);
}
}
}
}
break;
我们已经开启过滤判断了,然后我们把协议栈本来的过滤判断子程序给注释掉,自己写一个。第一个eventType上篇说过了,是数据类型的判断过滤,我们现在只判断非指向性的可连接广播类型,就是普通的BLE广播,不指定连接对象,并且是可以连接的。
然后,我们把广播数据和广播长度这两个形参传送到我们的比较子程序,子程序如下,非常简单
static bool SimpleBLECentral_findFactoryID(uint8_t *pData, uint8_t dataLen)
{
uint8_t facID[5] = {GAP_ADTYPE_MANUFACTURER_SPECIFIC,0xff,0x01,0x0c,0x03};
if(dataLen>=9)
{
if(memcmp(pData+4,facID,5)==0)
{
return TRUE;
}
}
return FALSE;
}
就是我在从机广播的相对位置里,添加了这五个信息,然后对广播数据进行比较,是这五个的,那就返回真,就保存了下来。
由于我们设置的是4秒广播时间,广播时间到了以后,协议栈的状态回调又会告诉我们,广播时间到啦,该怎么处理!我在前面说过了,我的思路是扫描到对应的从机,直接自动进行链接:
case GAP_DEVICE_DISCOVERY_EVENT: //搜索完成进入这里处理,可以是主动终止,也可以是时间到了终止
{
// discovery complete
BleScanOver();
// scanningStarted = FALSE;
// if not filtering device discovery results based on service UUID
if (DEFAULT_DEV_DISC_BY_SVC_UUID == FALSE)
{
// Copy results
scanRes = pEvent->discCmpl.numDevs;
memcpy(devList, pEvent->discCmpl.pDevList,
(sizeof(gapDevRec_t) * scanRes));
}
UartSendStringAndint32("Devices Found= ",scanRes);
Display_print1(dispHandle, 2, 0, "Devices Found %d", scanRes);
if (scanRes > 0)
{
scanIdx = scanRes-1;
BleStartConnect(devList[scanIdx].addr,devList[scanIdx].addrType);
就是我们先串口打印一下扫描结果,然后有扫描到从机,就自动进行链接。连接从机的形参就是从机的地址和地址格式。
case GAP_LINK_ESTABLISHED_EVENT: //建立连接。
{
if (pEvent->gap.hdr.status == SUCCESS)
{
state = BLE_STATE_CONNECTED;
connHandle = pEvent->linkCmpl.connectionHandle;
procedureInProgress = TRUE;
// If service discovery not performed initiate service discovery
if (charHdl == 0)
{
Util_startClock(&startDiscClock);
}
UartSendString("Connected");
UartSendStringAndHexToString("DeviceAddr= ",pEvent->linkCmpl.devAddr,B_ADDR_LEN);
Display_print0(dispHandle, 2, 0, "Connected");
Display_print0(dispHandle, 3, 0, Util_convertBdAddr2Str(pEvent->linkCmpl.devAddr));
// Display the initial options for a Right key press.
// SimpleBLECentral_handleKeys(0, KEY_LEFT);
}
发起连接后,如果连接成功,我们就要发起发现服务,发现服务最好延迟执行,因为一连接上,就发送太多指令,没处理好可能会导致断开。
发起发现服务,就是前面设置的定时器了,我们启动发现服务的定时器,那么在1秒后,就会自动执行事件了。
这里要说明一下,BLE为了节约功耗,缩短通讯时间,所以一次连接间隔,只能进行一次读写操作,但是NOTIFY可以进行多次,读和写,都要收到从机的rsp,才算结束,所以没法一股脑的进行大段通讯。我们发起发现服务的命令后,首先等待从机的回复,从机有任何消息回复,就会调用消息回调通知我们应用层,位置在这里:SimpleBLECentral_processGATTMsg,协议栈的例程,将发现服务的这些消息,单独归类到一个地方,因为有连续性。就在这个子程序的最后面有这么段子程序
else if (discState != BLE_DISC_STATE_IDLE)
{
SimpleBLECentral_processGATTDiscEvent(pMsg);
}
``
现在我们跳到这个子程序去看看,都处理些什么:
`
```c
static void SimpleBLECentral_processGATTDiscEvent(gattMsgEvent_t *pMsg)
{
// UartSendStringAndint32("FindDisc= ",pMsg->method);
if (discState == BLE_DISC_STATE_MTU)
{
// MTU size response received, discover simple service
if (pMsg->method == ATT_EXCHANGE_MTU_RSP)
{
uint8_t uuid[ATT_BT_UUID_SIZE] = { LO_UINT16(SIMPLEPROFILE_SERV_UUID),
HI_UINT16(SIMPLEPROFILE_SERV_UUID) };
// Just in case we're using the default MTU size (23 octets)
Display_print1(dispHandle, 4, 0, "MTU Size: %d", ATT_MTU_SIZE);
UartSendStringAndint32("MTU Size: ",ATT_MTU_SIZE);
discState = BLE_DISC_STATE_SVC;
// Discovery simple service
VOID GATT_DiscPrimaryServiceByUUID(connHandle, uuid, ATT_BT_UUID_SIZE,
selfEntity);
}
}
else if (discState == BLE_DISC_STATE_SVC)
{
// Service found, store handles
if (pMsg->method == ATT_FIND_BY_TYPE_VALUE_RSP &&
pMsg->msg.findByTypeValueRsp.numInfo > 0)
{
svcStartHdl = ATT_ATTR_HANDLE(pMsg->msg.findByTypeValueRsp.pHandlesInfo, 0);
svcEndHdl = ATT_GRP_END_HANDLE(pMsg->msg.findByTypeValueRsp.pHandlesInfo, 0);
}
// If procedure complete
if (((pMsg->method == ATT_FIND_BY_TYPE_VALUE_RSP) &&
(pMsg->hdr.status == bleProcedureComplete)) ||
(pMsg->method == ATT_ERROR_RSP))
{
if (svcStartHdl != 0)
{
attReadByTypeReq_t req;
discState = BLE_DISC_STATE_CHAR;
req.startHandle = svcStartHdl;
req.endHandle = svcEndHdl;
req.type.len = ATT_BT_UUID_SIZE;
req.type.uuid[0] = LO_UINT16(SIMPLEPROFILE_CHAR4_UUID);
req.type.uuid[1] = HI_UINT16(SIMPLEPROFILE_CHAR4_UUID);
GATT_DiscCharsByUUID(connHandle,&req, selfEntity);
/* attReadByTypeReq_t req;
// Discover characteristic
discState = BLE_DISC_STATE_CHAR;
req.startHandle = svcStartHdl;
req.endHandle = svcEndHdl;
req.type.len = ATT_BT_UUID_SIZE;
req.type.uuid[0] = LO_UINT16(SIMPLEPROFILE_CHAR1_UUID);
req.type.uuid[1] = HI_UINT16(SIMPLEPROFILE_CHAR1_UUID);
VOID GATT_ReadUsingCharUUID(connHandle, &req, selfEntity);*/
}
}
}
else if (discState == BLE_DISC_STATE_CHAR)
{
// Characteristic found, store handle
if ((pMsg->method == ATT_READ_BY_TYPE_RSP) &&
(pMsg->msg.readByTypeRsp.numPairs > 0))
{
charHdl = BUILD_UINT16(pMsg->msg.readByTypeRsp.pDataList[0],
pMsg->msg.readByTypeRsp.pDataList[1]);
UartSendStringAndint32("NotifyHdl= ",charHdl);
Display_print0(dispHandle, 2, 0, "Simple Svc Found");
Util_startClock(&BleProcessClock);
}
discState = BLE_DISC_STATE_NOTIFYON;
}
else if (discState == BLE_DISC_STATE_NOTIFYON)
{
if ((pMsg->method == ATT_WRITE_RSP) ||
((pMsg->method == ATT_ERROR_RSP) &&
(pMsg->msg.errorRsp.reqOpcode == ATT_WRITE_REQ)))
{
if (pMsg->method == ATT_ERROR_RSP)
{
UartSendString("NotifyOpenFailed");
}
else
{
// After a successful write, display the value that was written and
// increment value
UartSendString("NotifyOpenSuc");
}
procedureInProgress = FALSE;
}
discState = BLE_DISC_STATE_IDLE;
}
}
首先我们看一下,这里面的几个if判断,其实是顺序执行式,先执行了上面,在执行下面。第一段,是定了通讯的带宽,目前是4.0协议的,就是只能载23个字节的长度,这个改成5.0的话,可以直接申请更大长度的,当然,从机不支持,会返回一个rsp,表示不支持,只能23.
然后我们发起发现我们指定UUID的服务,GATT_DiscPrimaryServiceByUUID,就是这个,如果你要发现所有服务,就不能调用这一条了,注意。这条适用我们知道从机的UUID的情况下。
发现服务了,我们就知道这个服务下面成员通道的开始handle和最终handle,但是我们不知道我们要通讯的通道的handle啊,那怎么办呢?官方例程,是通过读取CHAR1的值来获得CHAR1的handle的,其实这个不太通用,因为这个通道如果没有read属性,你这条指令只能得到错误的rsp的,所以,我调用了指定UUID发现通道的子程序,GATT_DiscCharsByUUID,大家一定要多看看GATT层的这些子程序,这些是BLE协议栈的基本操作,当你觉得你有思路,不知道怎么实现的时候,看看这些子程序,说不定就符合你的想法。我们通过这条API,因为已知从机的notify的UUID就是经典的FFF4,那我就把这个形参传送过去。
等到下一步的时候,就返回一个handle啦,这个就是很多初学者要问的问题了, handle到底怎么来的,很多人的例程里handle都是写死的,不是根据不同的程序,不同的情况获得的,有一定的局限性,现在就告诉大家了,主机要获得handle,就是通过发现通道,从机会自动返回这个handle回来的;从机要知道自己的handle,就是查服务属性表的位置获得的,因为从机注册了服务属性表,底层就会将默认的0填写成系统分配的handle。
获得了handle,那我们就可以通过这个handle,去开启notify的CCC了,只要开启了CCC,那么从机就可以发送notify过来,主机也会接收notify通知应用层。如何开启CCC呢?Util_startClock(&BleProcessClock);前面写的这个定时事件,我就是用来开启CCC的,在这个事件里,其实就是很简单的,对CCC进行写操作就行了,程序如下:
uint8_t SendOpenNotifyCCCCommond(uint16 conHdl,uint16 CharHdl,uint8 taskId)
{
attWriteReq_t req;
uint8_t status;
req.pValue = GATT_bm_alloc(conHdl, ATT_WRITE_REQ, 2, NULL);
if ( req.pValue != NULL )
{
req.handle = CharHdl+2;
req.len = 2;
req.pValue[0] = LO_UINT16(GATT_CLIENT_CFG_NOTIFY);
req.pValue[1] = HI_UINT16(GATT_CLIENT_CFG_NOTIFY);
req.sig = 0;
req.cmd = 0;
status = GATT_WriteCharValue(conHdl, &req, taskId);
if ( status != SUCCESS )
{
UartSendStringAndint32("FailedReason= ",status);
GATT_bm_free((gattMsg_t *)&req, ATT_WRITE_REQ);
}
else
{
UartSendString("WriteSuc");
return TRUE;
}
}
else
{
UartSendString("WriteFailed");
}
return FALSE;
}
这里要注意的一点是,req.handle = CharHdl+2;为什么获得的handle要+2?这个要去看从机的程序啦,从机的服务属性表里,FFF4的排列,首先是描述(descriptor),然后是值(value),第三是配置(config),所以我们获得的handle是描述的handle,+1就是value的handle,+2就是CCC的handle。
附上从机的属性表(只截取一部分。):
* Profile Attributes - Table
*/
static gattAttribute_t simpleProfileAttrTbl[SERVAPP_NUM_ATTR_SUPPORTED] =
{
// Simple Profile Service
{
{ ATT_BT_UUID_SIZE, primaryServiceUUID }, /* type */
GATT_PERMIT_READ, /* permissions */
0, /* handle */
(uint8 *)&simpleProfileService /* pValue */
},
// Characteristic 4 Declaration
{
{ ATT_BT_UUID_SIZE, characterUUID },
GATT_PERMIT_READ,
0,
&simpleProfileChar4Props
},
// Characteristic Value 4
{
{ ATT_BT_UUID_SIZE, simpleProfilechar4UUID }, //13
GATT_PERMIT_WRITE,
0,
simpleProfileChar4
},
// Characteristic 4 configuration
{
{ ATT_BT_UUID_SIZE, clientCharCfgUUID },
GATT_PERMIT_READ | GATT_PERMIT_WRITE,
0,
(uint8 *)simpleProfileChar4Config
},
// Characteristic 4 User Description
{
{ ATT_BT_UUID_SIZE, charUserDescUUID },
GATT_PERMIT_READ,
0,
simpleProfileChar4UserDesp
},
这里看第一个成员,里边的注释已经清楚说明了,成员里第三个子成员就是handle,这个0是初始化给他的,注册好后,从机会分配给他一个handle,只要是设备初始化完成,你就可以读取到这个handle。
我们对CCC写1,如果成功,就会返回写成功的rsp。
由于从机的一个连接间隔只能进行一次写操作,所以我执行写CCC子程序的时候,做了一个判断,如下图:
if (events & SBC_BLE_PROCESS_EVT)
{
if(SendOpenNotifyCCCCommond(connHandle,charHdl,selfEntity)==FALSE) Util_startClock(&BleProcessClock);
}
如果返回繁忙,就在200ms后再执行一次(我的这个定时器间隔设置成200ms)。
只要使能成功了,我们要做的就是等从机发送notify来,在第一篇已经说了,在哪里判断了,现在再贴程序上来:
else if(pMsg->method == ATT_HANDLE_VALUE_NOTI)
{
UartSendStringAndHexToString("NotifyData= ",pMsg->msg.handleValueNoti.pValue,pMsg->msg.handleValueNoti.len);
// UartSendString("Receive a notify");
}
收到从机发来的notify,我就在串口打印出来。
到此,我已经实现了我要开头所说的思路了,接下来,还是一样,你怎么想,程序怎么写,希望这个思路对大家有帮助。
最后附上运行的串口打印图:
#define ATT_ERROR_RSP 0x01 //!< ATT Error Response. This method is passed as a gattMsgEvent_t defined as @ref attErrorRsp_t
#define ATT_EXCHANGE_MTU_REQ 0x02 //!< ATT Exchange MTU Request. This method is passed as a GATT message defined as @ref attExchangeMTUReq_t
#define ATT_EXCHANGE_MTU_RSP 0x03 //!< ATT Exchange MTU Response. This method is passed as a GATT message defined as @ref attExchangeMTURsp_t
#define ATT_FIND_INFO_REQ 0x04 //!< ATT Find Information Request. This method is passed as a GATT message defined as @ref attFindInfoReq_t
#define ATT_FIND_INFO_RSP 0x05 //!< ATT Find Information Response. This method is passed as a GATT message defined as @ref attFindInfoRsp_t
#define ATT_FIND_BY_TYPE_VALUE_REQ 0x06 //!< ATT Find By Type Value Request. This method is passed as a GATT message defined as @ref attFindByTypeValueReq_t
#define ATT_FIND_BY_TYPE_VALUE_RSP 0x07 //!< ATT Find By Type Value Response. This method is passed as a GATT message defined as @ref attFindByTypeValueRsp_t
#define ATT_READ_BY_TYPE_REQ 0x08 //!< ATT Read By Type Request. This method is passed as a GATT message defined as @ref attReadByTypeReq_t
#define ATT_READ_BY_TYPE_RSP 0x09 //!< ATT Read By Type Response. This method is passed as a GATT message defined as @ref attReadByTypeRsp_t
#define ATT_READ_REQ 0x0a //!< ATT Read Request. This method is passed as a GATT message defined as @ref attReadReq_t
#define ATT_READ_RSP 0x0b //!< ATT Read Response. This method is passed as a GATT message defined as @ref attReadRsp_t
#define ATT_READ_BLOB_REQ 0x0c //!< ATT Read Blob Request. This method is passed as a GATT message defined as @ref attReadBlobReq_t
#define ATT_READ_BLOB_RSP 0x0d //!< ATT Read Blob Response. This method is passed as a GATT message defined as @ref attReadBlobRsp_t
#define ATT_READ_MULTI_REQ 0x0e //!< ATT Read Multiple Request. This method is passed as a GATT message defined as @ref attReadMultiReq_t
#define ATT_READ_MULTI_RSP 0x0f //!< ATT Read Multiple Response. This method is passed as a GATT message defined as @ref attReadMultiRsp_t
#define ATT_READ_BY_GRP_TYPE_REQ 0x10 //!< ATT Read By Group Type Request. This method is passed as a GATT message defined as @ref attReadByGrpTypeReq_t
#define ATT_READ_BY_GRP_TYPE_RSP 0x11 //!< ATT Read By Group Type Response. This method is passed as a GATT message defined as @ref attReadByGrpTypeRsp_t
#define ATT_WRITE_REQ 0x12 //!< ATT Write Request. This method is passed as a GATT message defined as @ref attWriteReq_t
#define ATT_WRITE_RSP 0x13 //!< ATT Write Response. This method is passed as a GATT message
#define ATT_PREPARE_WRITE_REQ 0x16 //!< ATT Prepare Write Request. This method is passed as a GATT message defined as @ref attPrepareWriteReq_t
#define ATT_PREPARE_WRITE_RSP 0x17 //!< ATT Prepare Write Response. This method is passed as a GATT message defined as @ref attPrepareWriteRsp_t
#define ATT_EXECUTE_WRITE_REQ 0x18 //!< ATT Execute Write Request. This method is passed as a GATT message defined as @ref attExecuteWriteReq_t
#define ATT_EXECUTE_WRITE_RSP 0x19 //!< ATT Execute Write Response. This method is passed as a GATT message defines as @ref attHandleValueNoti_t
#define ATT_HANDLE_VALUE_NOTI 0x1b //!< ATT Handle Value Notification. This method is passed as a GATT message defined as @ref attErrorRsp_t
#define ATT_HANDLE_VALUE_IND 0x1d //!< ATT Handle Value Indication. This method is passed as a GATT message defined as @ref attHandleValueInd_t
#define ATT_HANDLE_VALUE_CFM 0x1e //!< ATT Handle Value Confirmation. This method is passed as a GATT message
Msgod这个数字的含义如上面的,这个可以去程序里查。方便大家对照。
好了,central到这里结束,这里面已经包含了写操作,读操作,notify操作,相信大家理解了以后,可以对BLE协议理解更加深刻
再次重申:原创博客,如有转载,注明出处——在金华的电子民工林。
如果觉得对你有帮助,一起到群里探讨交流。
1)友情伙伴:甜甜的大香瓜
2)声明:喝水不忘挖井人,转载请注明出处。
3)纠错/业务合作:[email protected]
4)香瓜BLE之CC2640R2F群:557278427
5)本文出处:原创连载资料《简单粗暴学蓝牙5》
6)完整开源资料下载地址:
https://shop217632629.taobao.com/?spm=2013.1.1000126.d21.hd2o8i
————————————————
版权声明:本文为CSDN博主「在金华的电子小民工」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/ganjielian0930/article/details/78110918