目录
1. 串行通信
1.1 串行通信概述
1.2 串行通信协议
2. 实验任务
3. 硬件原理
4. 利用STM32CubeMX创建MDK工程
4.1 STM32CubeMX工程创建
4.2 配置调试方式
4.4 配置GPIO
4.5 配置串口参数
4.6 配置时钟
4.7 项目配置:
4.8 串口初始化程序说明
5. 串行通信实验
5.1 UART串口printf,scanf函数串口重定向
5.2 UART串口printf输出实验
5.3 阻塞方式发送数据串口实验
5.4 阻塞方式接收数据串口实验
5.5 串口控制LED实验
5.6 关于MDK中的MicroLIB说明
6. #elif条件编译方法调试串口
7. 调试与验证
8. 串口HAL库函数
9. 总结
本实验是串行通信的第一部分,采用阻塞方式收发数据,中断方式收发见下文:
基础篇007. 串行通信(二)--中断方式接收数据_笑春风oO的博客-CSDN博客
串口调试助手: 下载地址
1. 串行通信
1.1 串行通信概述
串口通信(Serial Communications)的概念非常简单,串口按位(bit)发送和接收字节。
串口:可以算是一个泛称,一般指代的是串口时序标准。UART、RS232、RS485、TTL都遵循着类似的通信时序协议,因此都被通称为串口。
串行通信(Serial Communication)是指计算机主机与外设之间以及主机系统与主机系统之间数据的串行传送。使用一条数据线,将数据一位一位地依次传输,每一位数据占据一个固定的时间长度。其只需要少数几条线就可以在系统间交换信息,特别适用于计算机与计算机、计算机与外设之间的远距离通信。
串行通信按照发送时钟源和接收时钟源是否需要保持一致,又可分为同步通信和异步通信两种。
使用 RS-232 标准的串口设备间常见的通讯结构见下图:

串口通讯结构图
根据通讯使用的电平标准不同,串口通讯可分为 TTL 标准及 RS-232 标准:
| 通讯标准 | 电平标准 (发送端) | 
| 5V TTL | 逻辑 1: 2.4V-5V | 
| 逻辑 0: 0~0.5V | |
| RS-232 | 逻辑 1: -15V~-3V | 
| 逻辑 0: +3V~+15V | 

DB9 接口中的公头及母头的各个引脚的标准信号线接法见下图:

1.2 串行通信协议
通用同步异步收发器 (Universal Synchronous Asynchronous Receiver and Transmitter) 是一个串行通信设备,可以灵活地与外部设备进行全双工数据交换。有别于 USART 还有一个 UART(Universal Asynchronous Receiver and Transmitter),它是在 USART 基础上裁剪掉了同步通信功能,只有异步通信。简单区分同步和异步就是看通信时需不需要对外提供时钟输出,我们平时用的串口通信基本都是UART。
串口通讯的数据包由发送设备通过自身的 TXD 接口传输到接收设备的 RXD 接口。在串口通讯的协议层中,规定了数据包的内容,它由启始位、主体数据、校验位以及停止位组成,通讯双方的数据包格式要约定一致才能正常收发数据,其组成见下图:
2. 实验任务
利用STM32CubeMX,创建MDK工程,采用串口调试助手发送指令,实现对LED的控制。
1 ------ 点亮LED0
2 ------ 点亮LED1
3 ------ 互斥点亮两个LED
4 ------ 互斥点亮两个LED

3. 硬件原理


4. 利用STM32CubeMX创建MDK工程
4.1 STM32CubeMX工程创建
选择File下的New Project:

选择芯片类型(本文为stm32f103RBt6),选择下边的item,然后Start Project:

4.2 配置调试方式
点击左侧的System Core下的SYS,将Debug设置为Serial Wire:

4.3 配置时钟电路
将RCC下的HSE设置为Crystal/Ceramic Resonator

4.4 配置GPIO
结合开发版的硬件电路,进行GPIO设置
选择GPIO,依次将PA8、PD2设置为GPIO_Output:

4.5 配置串口参数
USART1参数配置:
在 Connectivity 中选择 USART1 设置,并选择 Asynchronous 异步通信。
波特率为 115200 Bits/s。传输数据长度为 8 Bit。奇偶检验 None,停止位 1 ,接收和发送都使能。

本文使用阻塞方式发送与接收数据,无需使用中断,不用配置NVIC。
4.6 配置时钟
结合开发版的硬件电路,选择Clock Configuration,做如下配置:

4.7 项目配置:
在Project Manager下的Project中设置工程名称和工程路径,并选择编译软件。取消勾选Use lastest available version,选择其他版本:

代码生成设置:

在Code Generate中选择第二个,然后Generate Code,即生成代码:

可以打开MDK工程编辑了。以下将通过几个实验,说明串口的用法。
4.8 串口初始化程序说明
串口设置的一般步骤可以总结为如下几个步骤:
- 串口时钟使能,GPIO 时钟使能
- 串口复位
- GPIO 端口模式设置
- 串口参数初始化
- 开启中断并且初始化 NVIC(如果需要开启中断才需要这个步骤)
- 使能串口
- 编写中断处理函数
使用CubeMX配置,前面6个步骤已经自动生成了,我们只需要编写中断处理函数和主函数就可以了。
5. 串行通信实验
5.1 UART串口printf,scanf函数串口重定向
在学习C语言时我们经常使用C语言标准函数库输入输出函数,比如printf、scanf、getchar等。为让开发板也支持这些函数需要把USART发送和接收函数添加到这些函数的内部函数内。
在C语言HAL库中,fputc函数是printf函数内部的一个函数,功能是将字符ch写入到文件指针f所指向文件的当前写指针位置,简单理解就是把字符写入到特定文件中。
fgetc函数与fputc函数非常相似,实现字符读取功能。在使用scanf函数时需要注意字符输入格式。
注意:
使用fput和fgetc函数达到重定向C语言HAL库输入输出函数必须在MDK的工程选项把“UseMicroLIB”勾选上,MicoroLIB是缺省C库的备选库,它对标准C库进行了高度优化使代码更少,占用更少资源。
为使用printf、scanf函数需要在文件中包含stdio.h头文件。
在usart.c文件的user code 0 区域内:

输入如下内容:

#include "stdio.h"
//加入以下代码,支持printf函数,而不需要选择use MicroLIB	  
#if 1		//方便调试,改为“#if 0”不要以下功能
	#if defined (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050)  	//AC6编译器
		//加入以下代码,支持printf函数,而不需要选择use MicroLIB	
		__asm (".global __use_no_semihosting\n\t");  
		void _sys_exit(int x)
			{//定义_sys_exit()以避免使用半主机模式 
				x = x;
			}
			/* __use_no_semihosting was requested, but _ttywrch was */
		void _ttywrch(int ch)
			{
				ch = ch;
			}
			FILE __stdout;
	#elif   defined ( __CC_ARM )   	//AC5编译器
		//#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)	
		#pragma import(__use_no_semihosting)             
		//标准库需要的支持函数                 
		struct __FILE 
		{ 
			int handle; 
		}; 
		FILE __stdout; 	
		void _sys_exit(int x)
		{
			x = x;
		}
	#endif
	//重定义fputc函数 
	//int fputc(int ch, FILE *f)
	//{ 	
	//	while((USART1->SR&0X40)==0);//循环发送,直到发送完毕   
	//	USART1->DR = (u8) ch;      
	//	return ch;
	//}
  //重定向 c 库函数 printf 到串口 USARTx,重定向后可使用 printf 函数
  //重定向 c 库函数 scanf 到串口 USARTx,重写向后可使用 scanf、 getchar 等函数
  //使用 printf、 scanf 函数需要在文件中包含 stdio.h 头文件。
	#if defined ( __GNUC__ ) && !defined (__clang__) 
		#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
		#define GETCHAR_PROTOTYPE int __io_getchar(FILE *f)
	#else
		#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
		#define GETCHAR_PROTOTYPE int fgetc(FILE *f)
	#endif 
	//改写fputc函数。【有多个串口是,需要修改下面的代码】
	PUTCHAR_PROTOTYPE
	{
	//发送一个字节数据到串口USARTx
		HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 1000); //发送数据到串口
		return ch;
	}
	
	GETCHAR_PROTOTYPE
	{
		uint8_t ch = 0;
		//等待串口输入数据
		//while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) == RESET);
		HAL_UART_Receive(&huart1,&ch, 1, 0xffff);
		return ch;
	}
#endif 5.2 UART串口printf输出实验
打开Keil文件后,点击Application,在 main.c 文件里
在用户头文件代码区域加入:

在while(1) 循环内的user code 3区域:

输入如下代码:

		if (__ARMCC_VERSION > 6000000)
			printf("您编译工程时选择的是ARMCLANG(AC6)编译器\n");
		else
			printf("您编译工程时选择的是ARMCC(AC5)编译器\n");
		
		printf("串口输出测试\n\n");
		HAL_Delay(2000);串口调试助手: 下载地址
打开串口调试助手,实验结果如下:
选择AC5编译器时的调试结果:

选择AC6编译器时的调试结果:

5.3 阻塞方式发送数据串口实验
打开Keil文件后,点击Application,在 main.c 文件里
在用户头文件代码区域加入:

在 while(1) 循环内的user code 3区域:

输入如下代码:

串口调试助手: 下载地址
打开串口调试助手,实验结果如下:
 
5.4 阻塞方式接收数据串口实验
打开Keil文件后,点击Application,在 main.c 文件里:
在用户头文件代码区域加入:

在while(1) 循环内的user code 3区域:

输入如下代码:

串口调试助手: 下载地址
打开串口调试助手,实验结果如下:

5.5 串口控制LED实验
实验说明:串口控制点亮LED,使用了printf和scanf重定向。
打开Keil文件后,点击Application,在 main.c 文件里的 while(1) 循环内的
/* USER CODE BEGIN Includes */
 和
/* USER CODE END Includes */
之间添加以下代码

	char ch;
	GPIO_PinState ledxPinState;在main.c的user code 3区域:

输入如下代码:

		if (__ARMCC_VERSION > 6000000)
			printf("2您编译工程时选择的是ARMCLANG(AC6)编译器\n\n");
		else
			printf("您编译工程时选择的是ARMCC(AC5)编译器\n\n");
		
		printf("串口控制LED实验,请输入以下数字:\n");
		printf("1  ------    点亮LED0\n");
		printf("2  ------    点亮LED1\n");
		printf("3  ------    互斥点亮两个LED\n");
		printf("4  ------    同时点亮两个LED\n");
		printf("您还可以重复输入上次输入的数字,观察灯效果. \n");
		ch=getchar();
		printf("已接收您刚才输入的字符: %c\n",ch);
		
		switch (ch) 
			{
			case '1':
				HAL_GPIO_TogglePin(LED0_GPIO_Port,LED0_Pin);
				if(HAL_GPIO_ReadPin(LED0_GPIO_Port,LED0_Pin))
					printf("\n已关闭LED0,请在开发板上观察效果\n\n\n\n");
				else
					printf("\n已点亮LED0,请在开发板上观察效果\n\n\n\n");
				break;
			case '2':
				HAL_GPIO_TogglePin(LED1_GPIO_Port,LED1_Pin);;
				if(HAL_GPIO_ReadPin(LED1_GPIO_Port,LED1_Pin))
					printf("\n已关闭LED1,请在开发板上观察效果\n\n\n\n");
				else
					printf("\n已点亮LED1,请在开发板上观察效果\n\n\n\n");
				break;
			case '3':
				HAL_GPIO_TogglePin(LED0_GPIO_Port,LED0_Pin);
				ledxPinState = HAL_GPIO_ReadPin(LED0_GPIO_Port,LED0_Pin);
				HAL_GPIO_WritePin(LED1_GPIO_Port,LED1_Pin,(GPIO_PinState)!ledxPinState);
				printf("\n两个LED互斥已点亮,请在开发板上观察效果\n\n\n\n");
				break;
			case '4':
				HAL_GPIO_TogglePin(LED0_GPIO_Port,LED0_Pin);
				ledxPinState = HAL_GPIO_ReadPin(LED0_GPIO_Port,LED0_Pin);
				HAL_GPIO_WritePin(LED1_GPIO_Port,LED1_Pin,ledxPinState);
				if(HAL_GPIO_ReadPin(LED0_GPIO_Port,LED0_Pin))
					printf("\n已同时关闭两个LED,请在开发板上观察效果\n\n\n\n");
				else
					printf("\r\n已同时点亮两个LED,请在开发板上观察效果\n\n\n\n");
				break;
			default:
			/* 如果不是指定指令字符,打印提示信息 */
			printf("\r\n请输入以下1~4之间的数字,然后观察点亮效果。\n\n\n");
			break;
			}实验结果见下一节内容。
5.6 关于MDK中的MicroLIB说明
MicroLIB专为深度嵌入式应用而设计;MicroLIB经过优化,可以使用比使用 ARM 标准库更少的代码和数据存储器;MicroLIB和 ARM 标准库都包含在 Keil MDK-ARM 中。
printf函数位于标准库中,基于嵌入式的printf同样位于MicroLIB中,在嵌入式系统中使用printf函数,需要添加MicroLIB。
因本实验用到printf函数,因此需要选中MicroLIB,方法如下:

如果未选中MicroLIB,程序中会出现如下报错:
.axf: Error: L6200E: Symbol __stdout multiply defined (by stdio_streams.o and usart.o).

串口调试助手: 下载地址
打开串口调试助手,实验结果如下:
选择AC6编译器时的调试结果:

如果你需要AC5编译器,请参考如下博文安装设置:
Keil MDK5.37以上版本自行添加AC5(ARMCC)编译器的方法_armcc下载:
Keil MDK5.37以上版本自行添加AC5(ARMCC)编译器的方法_armcc下载_笑春风oO的博客-CSDN博客
6. #elif条件编译方法调试串口
调试中,往往需要对不同模块进行调试,条件编译是一个好的方法,此处仅贴出实例,有问题请课上与我交流,网友可以直接留言。
阻塞方式需要修改的代码不多,printf和scanf重定向见5.1节。main()函数中需要修改的内容有:
在用户头文件代码区域加入:

在main.c while(1) 循环内的user code 3区域内:

输入如下代码:

#define Debug_CodeArea2 // 定义调试的程序段
#if defined Debug_CodeArea1
    // 串口printf重定向
    if (__ARMCC_VERSION > 6000000)
      printf("您编译工程时选择的是ARMCLANG(AC6)编译器\n");
    else
      printf("您编译工程时选择的是ARMCC(AC5)编译器\n");
    printf("串口输出测试\n\n");
    HAL_Delay(2000);
#elif defined Debug_CodeArea2
    // 阻塞方式发送数据测试
    uint8_t txbuf[50];
    HAL_UART_Transmit(&huart1, "HELLO\n", 6, 1000); // 直接发送
    memcpy(txbuf, "UART阻塞方式--发送数组数据测试\n", 100);
    HAL_UART_Transmit(&huart1, txbuf, strlen((char *)txbuf), 1000); // 发送数组
    HAL_UART_Transmit(&huart1, "\n", 1, 1000);                      // 回车换行
    HAL_Delay(2000);
#elif defined Debug_CodeArea3
    // 阻塞方式接收数据测试
    // 以UART阻塞方式接收数据,将串口接收到的数据发动回上位机。
    uint8_t Rx1buf[256];
    if (HAL_UART_Receive(&huart1, Rx1buf, 1, 20) == HAL_OK) // 接收1个字节
    {
      HAL_UART_Transmit(&huart1, Rx1buf, 6, 20); // 发送1个字节
    }
    // HAL_Delay(500);
#elif defined Debug_CodeArea4
    // 串口控制点亮LED,使用了printf和scanf重定向
    if (__ARMCC_VERSION > 6000000)
      printf("您编译工程时选择的是ARMCLANG(AC6)编译器\n\n");
    else
      printf("您编译工程时选择的是ARMCC(AC5)编译器\n\n");
    printf("串口控制LED实验,请输入以下数字:\n");
    printf("1  ------    点亮LED0\n");
    printf("2  ------    点亮LED1\n");
    printf("3  ------    互斥点亮两个LED\n");
    printf("4  ------    同时点亮两个LED\n");
    printf("您还可以重复输入上次输入的数字,观察灯效果. \n");
    ch = getchar();
    printf("已接收您刚才输入的字符: %c\n", ch);
    switch (ch)
    {
    case '1':
      HAL_GPIO_TogglePin(LED0_GPIO_Port, LED0_Pin);
      if (HAL_GPIO_ReadPin(LED0_GPIO_Port, LED0_Pin))
        printf("\n已关闭LED0,请在开发板上观察效果\n\n\n\n");
      else
        printf("\n已点亮LED0,请在开发板上观察效果\n\n\n\n");
      break;
    case '2':
      HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
      ;
      if (HAL_GPIO_ReadPin(LED1_GPIO_Port, LED1_Pin))
        printf("\n已关闭LED1,请在开发板上观察效果\n\n\n\n");
      else
        printf("\n已点亮LED1,请在开发板上观察效果\n\n\n\n");
      break;
    case '3':
      HAL_GPIO_TogglePin(LED0_GPIO_Port, LED0_Pin);
      ledxPinState = HAL_GPIO_ReadPin(LED0_GPIO_Port, LED0_Pin);
      HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, (GPIO_PinState)!ledxPinState);
      printf("\n两个LED互斥已点亮,请在开发板上观察效果\n\n\n\n");
      break;
    case '4':
      HAL_GPIO_TogglePin(LED0_GPIO_Port, LED0_Pin);
      ledxPinState = HAL_GPIO_ReadPin(LED0_GPIO_Port, LED0_Pin);
      HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, ledxPinState);
      if (HAL_GPIO_ReadPin(LED0_GPIO_Port, LED0_Pin))
        printf("\n已同时关闭两个LED,请在开发板上观察效果\n\n\n\n");
      else
        printf("\r\n已同时点亮两个LED,请在开发板上观察效果\n\n\n\n");
      break;
    default:
      /* 如果不是指定指令字符,打印提示信息 */
      printf("\r\n请输入以下1~4之间的数字,然后观察点亮效果。\n\n\n");
      break;
    }
#endif只需修改第107行(第3行)的宏定义语句,就可以对不同区域的代码进行调试了,调试方法见本文第5部分。
7. 调试与验证
将程序下载到开发板进行验证:

8. 串口HAL库函数
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
串口发送;
发送指定长度的数据;
如果超时没发送完成,则不再发送,返回超时标志(HAL_TIMEOUT)。
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
串口接收;
接收指定长度的数据;
如果超时没发送完成,则不再发送,返回超时标志(HAL_TIMEOUT)。
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
串口中断发送,以中断方式发送指定长度的数据。
把发送缓冲区指针指向要发送的数据,设置 发送长度,发送计数器初值,然后使能串口发送中断,触发串口中断。
 串口中断函数处理,直到数据发送完成,而后关闭中断,不再发送数据,调用串口发送完成回调函数。
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
 串口中断接收,以中断方式接收指定长度数据。
把接收缓冲区指针 指向 要存放接收数据的数组,设置 接收长度,接收计数器初值,然后使能串口接收中断。接收到数据时,会触发串口中断。
串口中断函数处理,直到接收到指定长度数据,而后关闭中断,不再触发接收中断,调用串口接收完成回调函数。
  
HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
 串口DMA发送,以DMA方式发送指定长度的数据。
把 发送缓冲区指针 指向 要发送的数据,设置 发送长度,发送计数器初值,设置 DMA传输完成中断的回调函数,使能DMA控制器中断,使能DMA控制器传输,使能UART的DMA传输请求。
 然后,UART便会发送数据,直到发送完成,触发DMA中断。
 DMA中断处理,如果 DMA模式 是 循环模式,则 直接 调用 DMA传输完成中断的回调函数。
 如果 DMA模式 是 正常模式,则 先 关闭DMA传输完成中断,不再触发DMA中断,再 调用 DMA传输完成中断的回调函数。
 DMA传输完成中断的回调函数处理过程,如果 DMA模式 是 循环模式,则 直接 调用 串口发送完成回调函数。
 如果 DMA模式 是 正常模式,则 先关闭 UART的DMA传输请求, 再 使能串口传输完成中断,直到传输完成,触发中断。
 串口传输完成中断处理,关闭中断,调用串口发送完成回调函数
  
HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);
 串口DMA接收,以DMA方式接收指定长度的数据。
 过程是,把 接收缓冲区指针 指向 要存放接收数据的数组,设置 接收长度,接收计数器初值,
设置 DMA传输完成中断的回调函数,
使能DMA控制器中断,
使能DMA控制器传输,
              使能UART的DMA传输请求。
 然后,UART接收到数据,会通过DMA把数据存到接收缓冲区,直到接收到指定长度数据,触发DMA中断。
 DMA中断处理,
如果 DMA模式 是 循环模式,则 直接 调用 DMA传输完成中断的回调函数。
 如果 DMA模式 是 正常模式,则   先   关闭DMA传输完成中断,不再触发DMA中断,再 调用 DMA传输完成中断的回调函数。
DMA传输完成中断的回调函数处理过程,如果 DMA模式 是 循环模式,则 直接 调用 串口接收完成回调函数。
 如果 DMA模式 是 正常模式,则 先关闭 UART的DMA传输请求, 再 调用 串口接收完成回调函数。
9. 总结
-----------------------------------------------------------------------------------------------------
本实验是串行通信的第一部分,采用阻塞方式收发数据,中断方式收发见下文:
基础篇007. 串行通信(二)--中断方式接收数据_笑春风oO的博客-CSDN博客目录1. 实验任务2. 硬件原理3. 利用STM32CubeMX创建MDK工程3.1 STM32CubeMX工程创建3.2 配置调试方式3.3 配置时钟电路3.4 配置GPIO3.5 配置串口参数3.6 配置时钟3.7 项目配置4. 串行通信实验4.1 UART串口printf,scanf函数串口重定向4.2 开启中断4.3 中断回调函数4.4 main()函数修改5.调试与验证6.总结利用STM32CubeMX,创建MDK工程,使用中断方式,实现串口接收数据,然后在转发到串口。本实验是串行通信的第二部分,https://blog.csdn.net/qcmyqcmy/article/details/130716056
-----------------------------------------------------------------------------------------------------
如果您需要AC5编译器,请参考如下博文安装设置:
Keil MDK5.37以上版本自行添加AC5(ARMCC)编译器的方法_armcc下载:
Keil MDK5.37以上版本自行添加AC5(ARMCC)编译器的方法_armcc下载_笑春风oO的博客-CSDN博客 https://blog.csdn.net/qcmyqcmy/article/details/125814461
https://blog.csdn.net/qcmyqcmy/article/details/125814461
如果您需要虚拟仿真调试,请参考专栏如下博文的5.1节:
基础篇003. 使用STM32CubeMX创建MDK工程,实现流水灯的仿真与下载验证 https://mp.csdn.net/mp_blog/creation/editor/129159801https://blog.csdn.net/qcmyqcmy/article/details/129159801
https://mp.csdn.net/mp_blog/creation/editor/129159801https://blog.csdn.net/qcmyqcmy/article/details/129159801
如果您需要在Proteus中仿真调试,请参考本专栏的博文:
基础篇004. 采用Proteus + STM32CubeMX + MDK-ARM学习流水灯 https://mp.csdn.net/mp_blog/creation/editor/129250108https://blog.csdn.net/qcmyqcmy/article/details/129250108
https://mp.csdn.net/mp_blog/creation/editor/129250108https://blog.csdn.net/qcmyqcmy/article/details/129250108


















