0%

51单片机学习(上)

最近收拾东西时居然发现了之前买的STM32单片机(普中STM32-F1),之前差点挂闲鱼卖了,很好奇是什么勇气让我直接买了STM32的板子?哈哈哈,买回来直接吃灰,连光盘都在。现在闲暇之余准备系统地学习一下单片机子,没准还能造些有意思的小玩意,那还是从89C51开始吧,哈哈。

单片机(Micro Controller Unit),简称MCU。内部集成了CPU、RAM、ROM、定时器、中断系统、通讯接口等一系列电脑的常用硬件功能。单片机的任务是信息采集(依靠传感器)、处理(依靠CPU)和硬件设备(例如电机,LED等)的控制。单片机跟计算机相比,单片机算是一个袖珍版计算机,一个芯片就能构成完整的计算机系统。但在性能上,与计算机相差甚远,但单片机成本低、体积小、结构简单,在生活和工业控制领域大有所用,而且学习使用单片机是了解计算机原理与结构的最佳选择。

我目前使用的是STC89C52单片机,这种类型的单片机资料也比较多。

STC89C52简介

STC(南通国芯微电子)公司的51单片机系列是8位单片机,所以RAM为512字节,ROM:8K(Flash),工作频率:11.0592MHz(有些是12MHz),命名规则如下:

STC89C52结构

STC89C52系列单片机的内部结构框图如下图所示。STC89C52单片机中包含中央处理器(CPU)、程序存储器(Flash)、数据存储器(SRAM)、定时/计数器、UART串口、I/O接口、EEPROM、看门狗等模块。STC89C52系列单片机几乎包含了数据采集和控制中所需的所有单元模块,可称得上一个微机片上系统。内部结构图:

GPIO与管脚图

GPIO(General Purpose Intput Output)是通用输入输出端口的简称,可以通过软件来控制其输入和输出。51 单片机芯片的 GPIO 引脚与外部设备连接起来,从而实现与外部通讯、控制以及数据采集的功能。不过GPIO 最简单的应用还属点亮LED 灯了,只需通过软件控制GPIO 输出高低电平即可。当然 GPIO 还可以作为输入控制,比如在引脚上接入一个按键,通过电平的高低判断按键是否按下等操作。

这些管脚只需要结合开发板原理图就可以确定,哪些管脚和哪些外部设备相连接:

(1)电源引脚:引脚图中的VCC、GND 都属于电源引脚。
(2)晶振引脚:引脚图中的XTAL1、XTAL2 都属于晶振引脚。
(3)复位引脚:引脚图中的RST/VPD 属于复位引脚,不做其他功能使用。
(4)下载引脚:51 单片机的串口功能引脚(TXD、RXD)可以作为下载引脚使用。
(5)GPIO 引脚:引脚图中带有Px.x 等字样的均属于GPIO 引脚。GPIO 占用了芯片大部分的引脚,共达32个,分为了4组,P0、P1、P2、P3,每组为8个IO,而且在P3组中每个IO都具备额外功能,只要通过相应的寄存器设置即可配置对应的附加功能,同一时刻,每个引脚只能使用该引脚的一个功能。

① 51单片机所有IO口都是双向的,即可以作为输入也可以作为输出使用。

② 由于P0口是漏极开路的,所以要操作P0 口必须外接上拉电阻,其他P1、P2、P3 口都内部自带上拉电阻,可以不加,如果要增强IO口驱动能力,可以外接上拉电阻。

开发环境搭建

1、安装Keil5(汇编器、编译器 + 依赖库 + 头文件),Keil5也是一个IDE,可以直接在里面写代码。

2、Keil5也可以用SDCC替代,http://sdcc.sourceforge.net/,SDCC 是一个可重定向、优化的标准 C(ANSI C89、ISO C99、ISO C11)编译器套件,它针对基于 Intel MCS51 的微处理器(8032、8051、8052 等)。

3、安装STC-ISP (烧录器),也可以使用普中ISP,STC-ISP功能会多一些。

4、但是最好还是用VSCode方便一些,快捷键用着也方便,安装 Embedded IDE(https://docs.em-ide.com)插件即可,Embedded IDE会提供8051、STM8、 Cortex-M、RISC-V… 项目的开发、编译、烧录等功能。安装好Embedded IDE后,直接可以Ctrl点进去就能查看源代码,比较方便。

注意:SDCC和Keil5的头文件是不一样的,SDCC用的是 8052.h,Keil5用的是 REGX52.H

1、GPIO之LED流水灯

通过LED流水灯实验,认识GPIO。通过STC-ISP软件生成延时代码,完成LED流水灯的功能:

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
43
44
45
//此文件中定义了单片机的一些特殊功能寄存器
#include <REGX52.H>
#include <INTRINS.H>

// 睡眠x毫秒
void DelayXms(unsigned int x){
unsigned char i, j;
while(x--){
_nop_();
i = 2;
j = 199;
do
{
while (--j);
} while (--i);
}
}

void main(){
while(1){
P2 = 0xFE; //1111 1110
DelayXms(500);

P2 = 0xFD; //1111 1101
DelayXms(500);

P2 = 0xFB; //1111 1011
DelayXms(500);

P2 = 0xF7; //1111 0111
DelayXms(500);

P2 = 0xEF; //1110 1111
DelayXms(500);

P2 = 0xDF; //1101 1111
DelayXms(500);

P2 = 0xBF; //1011 1111
DelayXms(500);

P2 = 0x7F; //0111 1111
DelayXms(500);
}
}

2、按键抖动与消抖

对于机械开关,当机械触点断开、闭合时,由于机械触点的弹性作用,一个开关在闭合时不会马上稳定地接通,在断开时也不会一下子断开,所以在开关闭合及断开的瞬间会伴随一连串的抖动。

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
#include <REGX52.H>

void DelayXms(unsigned int x)
{
unsigned char i, j;
while (x--)
{
i = 2;
j = 199;
do
{
while (--j);
} while (--i);
}
}

void main(){
while (1)
{
P2_1 = 1;
if(P3_1 == 0){
DelayXms(20); // 消除按下抖动
while (P3_1 == 0);
DelayXms(20); // 消除松手抖动
P2_0 = ~P2_0; // 取反就是开和关
}
}
}

3、数码管MCU扫描

LED数码管:数码管是一种简单、廉价的显示器,是由多个发光二极管封装在一起组成8字型的器件。数码管一般有两种驱动方式:

① 单片机直接扫描:硬件设备简单,但会耗费大量的单片机CPU时间

② 专用驱动芯片:内部自带显存、扫描电路,单片机只需告诉它显示什么即可

下面是通过单片机直接扫描的方式来完成的数据显示示例:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
#include <REG52.H>
#include <INTRINS.H>
#include <stdlib.h>

typedef unsigned char uint8;

uint8 hexArr[] = {0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F,
0x80, // -> 10 小数点
0x40, // -> 11 负 号
};

void DelayXms(unsigned int x) //@11.0592MHz
{
unsigned char i, j;
while (x--)
{
_nop_(); // 空语句
i = 2;
j = 199;
do
{
while (--j)
;
} while (--i);
}
}


/**
* x: LED 0-7
* num: 0-9,10为小数点, 11为负号
**/
void showNumLed(uint8 x, uint8 num)
{
switch (x)
{
case 0: P2_2 = 1; P2_3 = 1; P2_4 = 1; break;
case 1: P2_2 = 0; P2_3 = 1; P2_4 = 1; break;
case 2: P2_2 = 1; P2_3 = 0; P2_4 = 1; break;
case 3: P2_2 = 0; P2_3 = 0; P2_4 = 1; break;
case 4: P2_2 = 1; P2_3 = 1; P2_4 = 0; break;
case 5: P2_2 = 0; P2_3 = 1; P2_4 = 0; break;
case 6: P2_2 = 1; P2_3 = 0; P2_4 = 0; break;
case 7: P2_2 = 0; P2_3 = 0; P2_4 = 0; break;
default:
break;
}

// P0 = 0x7D; -> 6
P0 = hexArr[num];
}

void numToCharArray(char arr[], long num){ // 123456
int i;
if(num < 0) num = -num;
for (i = 7; i >= 0; i--)
{
if(num == 0) break;
arr[i] = num % 10;
num /= 10;
}
}

void showLongByLED(long num){
int i;
int tmp;
// 如果是负数
if(num < 0){
//showNumLed(1, 10);
if(num < -9999999L){
// 溢出
while (1)
{
for (i = 0; i < 8; i++)
{
showNumLed(i, 11);
DelayXms(2);
P0 = 0x00; // 消影
}
}
}else {
char ch[8] = {0};
numToCharArray(ch, num);
for(i = 0; i < 8; i++){
if(ch[i] != 0){ // 0390 9999
tmp = i;
while(1){
showNumLed(tmp - 1, 11);
DelayXms(2);
P0 = 0x00;
for (i = tmp; i < 8; i++)
{
showNumLed(i, ch[i]);
DelayXms(2);
P0 = 0x00; // 消影
}
}
}
}
}
}else if(num == 0){
showNumLed(8, 0);
}else {
char ch[8] = {0};
numToCharArray(ch, num);
for(i = 0; i < 8; i++){
if(ch[i] != 0){
tmp = i;
while(1){
for (i = tmp; i < 8; i++)
{
showNumLed(i, ch[i]);
DelayXms(2);
P0 = 0x00; // 消影
}
}
}
}
}
}


void showLongByLEDAuto(long num){
int i;
int tmp;
// 如果是负数
if(num < 0){
//showNumLed(1, 10);
if(num < -9999999L){
// 溢出
for (i = 1; i <= 8; i++)
{
showNumLed(i, 11);
DelayXms(2);
P0 = 0x00; // 消影
}
}else {

}
}else if(num == 0){
showNumLed(8, 0);
}else {
char ch[8] = {0};
numToCharArray(ch, num);
for(i = 0; i < 8; i++){
if(ch[i] != 0){
tmp = i;
for (i = tmp; i < 8; i++)
{
showNumLed(i, ch[i]);
DelayXms(2);
P0 = 0x00; // 消影
}
}
}
}
}

void main()
{
int i;
int flag = 0;
for (i = 0; i < 10; i++)
{
showNumLed(0, i);
DelayXms(100);
}

showLongByLED(-8900999L);
while(1);
}

这种通过单片机扫描的方式还是过于复杂,而且比较浪费单片机的计算机资源,推荐还是用专用驱动芯片,内部自带显存、扫描电路,单片机只需告诉它显示什么即可,TM1640就是这样一种芯片:

TM1640是一种LED(发光二极管显示器)驱动控制专用电路,内部集成有MCU数字接口、数据锁存器LED驱动等电路。本产品性能优良,质量可靠。主要应用于电子产品LED显示屏驱动。采用SOP28、SSOP28的封装形式。

4、LCD1602显示字符

直接使用下面封装好的函数即可,以后需要模块化的地方一定要模块化,形成代码复用:

LCD1602.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef __LCD1602_H__
#define __LCD1602_H__

//用户调用函数:
void LCD_Init();
void LCD_ShowChar(unsigned char Line,unsigned char Column,char Char);
void LCD_ShowString(unsigned char Line,unsigned char Column,char *String);
void LCD_ShowNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length);
void LCD_ShowSignedNum(unsigned char Line,unsigned char Column,int Number,unsigned char Length);
void LCD_ShowHexNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length);
void LCD_ShowBinNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length);

#endif //!__LCD1602_H__

LCD1602.c:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
#include <REG52.H>

//引脚配置:
sbit LCD_RS=P2^6;
sbit LCD_RW=P2^5;
sbit LCD_EN=P2^7;
#define LCD_DataPort P0

//函数定义:
/**
* @brief LCD1602延时函数,12MHz调用可延时1ms
* @param 无
* @retval 无
*/
void LCD_Delay()
{
unsigned char i, j;

i = 2;
j = 239;
do
{
while (--j);
} while (--i);
}

/**
* @brief LCD1602写命令
* @param Command 要写入的命令
* @retval 无
*/
void LCD_WriteCommand(unsigned char Command)
{
LCD_RS=0;
LCD_RW=0;
LCD_DataPort=Command;
LCD_EN=1;
LCD_Delay();
LCD_EN=0;
LCD_Delay();
}

/**
* @brief LCD1602写数据
* @param Data 要写入的数据
* @retval 无
*/
void LCD_WriteData(unsigned char Data)
{
LCD_RS=1;
LCD_RW=0;
LCD_DataPort=Data;
LCD_EN=1;
LCD_Delay();
LCD_EN=0;
LCD_Delay();
}

/**
* @brief LCD1602设置光标位置
* @param Line 行位置,范围:1~2
* @param Column 列位置,范围:1~16
* @retval 无
*/
void LCD_SetCursor(unsigned char Line,unsigned char Column)
{
if(Line==1)
{
LCD_WriteCommand(0x80|(Column-1));
}
else if(Line==2)
{
LCD_WriteCommand(0x80|(Column-1+0x40));
}
}

/**
* @brief LCD1602初始化函数
* @param 无
* @retval 无
*/
void LCD_Init()
{
LCD_WriteCommand(0x38);//八位数据接口,两行显示,5*7点阵
LCD_WriteCommand(0x0c);//显示开,光标关,闪烁关
LCD_WriteCommand(0x06);//数据读写操作后,光标自动加一,画面不动
LCD_WriteCommand(0x01);//光标复位,清屏
}

/**
* @brief 在LCD1602指定位置上显示一个字符
* @param Line 行位置,范围:1~2
* @param Column 列位置,范围:1~16
* @param Char 要显示的字符
* @retval 无
*/
void LCD_ShowChar(unsigned char Line,unsigned char Column,char Char)
{
LCD_SetCursor(Line,Column);
LCD_WriteData(Char);
}

/**
* @brief 在LCD1602指定位置开始显示所给字符串
* @param Line 起始行位置,范围:1~2
* @param Column 起始列位置,范围:1~16
* @param String 要显示的字符串
* @retval 无
*/
void LCD_ShowString(unsigned char Line,unsigned char Column,char *String)
{
unsigned char i;
LCD_SetCursor(Line,Column);
for(i=0;String[i]!='\0';i++)
{
LCD_WriteData(String[i]);
}
}

/**
* @brief 返回值=X的Y次方
*/
int LCD_Pow(int X, int Y)
{
unsigned char i;
int Result=1;
for(i=0;i<Y;i++)
{
Result*=X;
}
return Result;
}

/**
* @brief 在LCD1602指定位置开始显示所给数字
* @param Line 起始行位置,范围:1~2
* @param Column 起始列位置,范围:1~16
* @param Number 要显示的数字,范围:0~65535
* @param Length 要显示数字的长度,范围:1~5
* @retval 无
*/
void LCD_ShowNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length)
{
unsigned char i;
LCD_SetCursor(Line,Column);
for(i=Length; i>0; i--)
{
LCD_WriteData(Number/LCD_Pow(10,i-1)%10+'0');
}
}

/**
* @brief 在LCD1602指定位置开始以有符号十进制显示所给数字
* @param Line 起始行位置,范围:1~2
* @param Column 起始列位置,范围:1~16
* @param Number 要显示的数字,范围:-32768~32767
* @param Length 要显示数字的长度,范围:1~5
* @retval 无
*/
void LCD_ShowSignedNum(unsigned char Line,unsigned char Column,int Number,unsigned char Length)
{
unsigned char i;
unsigned int Number1;
LCD_SetCursor(Line,Column);
if(Number >= 0)
{
LCD_WriteData('+');
Number1=Number;
}
else
{
LCD_WriteData('-');
Number1=-Number;
}
for(i=Length; i>0; i--)
{
LCD_WriteData(Number1/LCD_Pow(10,i-1)%10+'0');
}
}

/**
* @brief 在LCD1602指定位置开始以十六进制显示所给数字
* @param Line 起始行位置,范围:1~2
* @param Column 起始列位置,范围:1~16
* @param Number 要显示的数字,范围:0~0xFFFF
* @param Length 要显示数字的长度,范围:1~4
* @retval 无
*/
void LCD_ShowHexNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length)
{
unsigned char i,SingleNumber;
LCD_SetCursor(Line,Column);
for(i=Length; i>0; i--)
{
SingleNumber = Number/LCD_Pow(16,i-1)%16;
if(SingleNumber < 10)
{
LCD_WriteData(SingleNumber+'0');
}
else
{
LCD_WriteData(SingleNumber-10+'A');
}
}
}

/**
* @brief 在LCD1602指定位置开始以二进制显示所给数字
* @param Line 起始行位置,范围:1~2
* @param Column 起始列位置,范围:1~16
* @param Number 要显示的数字,范围:0~1111 1111 1111 1111
* @param Length 要显示数字的长度,范围:1~16
* @retval 无
*/
void LCD_ShowBinNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length)
{
unsigned char i;
LCD_SetCursor(Line,Column);
for(i=Length; i>0; i--)
{
LCD_WriteData(Number/LCD_Pow(2,i-1)%2+'0');
}
}

5、定时器与中断系统

定时器

51单片机的定时器属于单片机的内部资源,其电路的连接和运转均在单片机内部完成。

1、定时器用于计时系统,可实现软件计时,或者使程序每隔一固定时间完成一项操作。
2、替代长时间的Delay,提高CPU的运行效率和处理速度。

51系列单片机一定有基本的2个定时器,但是STC89C52具有3个定时器(T0、T1、T2),T0和T1与传统的51单片机兼容,T2是此型号单片机增加的资源。需要注意的是:定时器的资源和单片机的型号是关联在一起的,不同的型号可能会有不同的定时器个数和操作方式,但一般来说,T0和T1的操作方式是所有51单片机所共有的。而且通常我们使用的都是基本的2个定时器:T0与T1。

定时器在单片机内部就像一个小闹钟一样,根据时钟的输出信号,每隔一段时间,计数单元的数值就增加一,当计数单元数值增加到”设定的闹钟提醒时间”时,计数单元就会向中断系统发出中断申请,产生”响铃提醒”,使程序跳转到中断服务函数中执行。

STC89C52的T0和T1均有四种工作模式:

13位定时器/计数器、16位定时器/计数器(常用)、8位自动重装模式、两个8位计数器

以定时器T0为例,通过定时器中断控制D1指示灯间隔1秒闪烁。

中断系统

中断系统是为使CPU具有对外界紧急事件的实时处理能力而设置的。

当中央处理机CPU正在处理某件事的时候外界发生了紧急事件请求,要求CPU暂停当前的工作,转而去处理这个紧急事件,处理完以后,再回到原来被中断的地方,继续原来的工作,这样的过程称为中断。实现这种功能的部件称为中断系统,请示CPU中断的请求源称为中断源。微型机的中断系统一般允许多个中断源,当儿个中断源同时向CPU请求中断,要求为它服务的时候,这就存在CPU优先响应哪一个中断源请求的问题。通常根据中断源的轻重缓急排队,优先处理最紧急事件的中断请求源,即规定每一个中断源有一个优先级别。CPU总是先响应优先级别最高的中断请求。

当CPU正在处理一个中断源请求的时候(执行相应的中断服务程序),发生了另外一个优先级比它还高的中断源请求。如果CPU能够暂停对原来中断源的服务程序,转而去处理优先级更高的中断请求源,处理完以后,再回到原低级中断服务程序,这样的过程称为中断嵌套。这样的中断系统称为多级中断系统,没有中断嵌套功能的中断系统称为单级中断系统。

STC89C52系列单片机提供了8个中断请求源,它们分别是:外部中断O(INTO)、定时器0中断、外部中断1(INT1)、定时器1中断、串口(UART)中断、定时器2中断、外部中断2(INT2)、外部中断3(INT3)。所有的中断都具有4个中断优先级。中断的资源和单片机的型号是关联在一起的,不同的型号可能会有不同的中断资源,例如中断源个数不同、中断优先级个数不同等等,下面是STC89C51的中断号:

寄存器是连接软硬件的媒介。在单片机中寄存器就是一段特殊的RAM存储器,一方面,寄存器可以存储和读取数据,另一方面,就像每一个寄存器背后都连接了一根导线,控制着电路的连接方式。寄存器相当于一个复杂机器的”操作按钮” 。

通过配置中断部分的寄存器就可以理解为什么需要做这样的配置:

时钟Demo

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <REGX51.H>
#include "LCD1602.h"

unsigned int s, m, h;

void Timer0Init(void) //1毫秒@11.0592MHz
{
// 配置定时器 -----------------------
TMOD &= 0xF0; // 设置定时器模式
TMOD |= 0x01; // 设置定时器模式
TL0 = 0x66; // 设置定时初始值
TH0 = 0xFC; // 设置定时初始值
TF0 = 0; // 清除TF0标志
TR0 = 1; // 定时器0开始计时

// 配置中断 -------------------------
ET0 = 1;
EA = 1;
PT0 = 0;
}


void main()
{
LCD_Init();
Timer0Init();

LCD_ShowString(1, 1, "Clock:");
LCD_ShowString(2, 3, ":");
LCD_ShowString(2, 6, ":");
while (1){
// LCD显示属于耗时操作,不要放在定时器中断中完成
LCD_ShowNum(2, 1, h, 2);
LCD_ShowNum(2, 4, m, 2);
LCD_ShowNum(2, 7, s, 2);
}
}

// 中断函数
void Timer0_Routine() interrupt 1
{
static unsigned int T0Count = 0;
TL0 = 0x66; // 设置定时初始值
TH0 = 0xFC; // 设置定时初始值
T0Count++;

if(T0Count > 1000){
T0Count = 0;
s++;
// 时、分、秒
if(s >= 60){
s = 0;
m++;
}

if(m >= 60){
m = 0;
h++;
}

if(h >= 24){
h = 0;
}
}
}

最终效果如下所示:

  • 本文作者: Tim
  • 本文链接: https://zouchanglin.cn/707616799.html
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!