重要的内容写在前面:

  1. 该系列是以up主江协科技的STM32视频教程为基础写下去的,大部分内容都参考了老师的课件,对于一些个人认为比较重要但是老师仅口述的部分,笔者都有用文字的方式记录并标出了重点。
  2. 文中的图片基本都来源于老师的课件以及开发板和芯片的手册,粘贴过来是为了方便阅读。
  3. 如果有条件的可以先学习一些相关课程再去看STM32的教程,学起来会更加轻松(不太建议零基础开始直接STM32,听起来可能会有点困难,可以先学51单片机),相关课程有数字电路(强烈推荐先学数电,不然可能会有很多地方理解起来很困难)、模拟电路、计算机组成原理(像寄存器、存储器、中断等在这门课里有很详细的介绍)、计算机网络等。
  4. 如有错漏欢迎指出。

视频链接:[12-1] Unix时间戳_哔哩哔哩_bilibili

一、时间与时间戳

1、Unix时间戳

(1)Unix时间戳(Unix Timestamp)定义为从UTC/GMT的1970年1月1日0时0分0秒开始所经过的秒数,不考虑闰秒。

(2)时间戳存储在秒计数器中,秒计数器为32位/64位的整型变量

(3)世界上所有时区的秒计数器相同,不同时区通过添加偏移来得到当地时间

2、C语言的time.h模块

        C语言的time.h模块提供了时间获取和时间戳转换的相关函数,可以方便地进行秒计数器、日期时间(是个结构体,其中的成员为年月日时分秒以及星期几等)和字符串之间的转换

函数

作用

time_t time(time_t*);

获取系统时钟

struct tm* gmtime(const time_t*);

秒计数器转换为日期时间(格林尼治时间)

struct tm* localtime(const time_t*);

秒计数器转换为日期时间(当地时间)

time_t mktime(struct tm*);

日期时间转换为秒计数器(当地时间)

char* ctime(const time_t*);

秒计数器转换为字符串(默认格式)

char* asctime(const struct tm*);

日期时间转换为字符串(默认格式)

size_t strftime(char*, size_t, const char*, const struct tm*);

日期时间转换为字符串(自定义格式)

二、BKP(Backup Registers)备份寄存器

1、BKP概述

(1)BKP可用于存储用户应用程序数据、RTC时钟校准值等,当VDD(2.0~3.6V)电源被切断,它们仍然由VBAT(1.8~3.6V)维持供电,当系统在待机模式下被唤醒、或系统复位、或电源复位时,它们也不会被复位

(2)TAMPER引脚检测到侵入事件后,硬件会自动将所有备份寄存器的内容清除(软件无法拦截),同时可以申请中断,在中断函数中可以继续清除其它存储器的数据并锁死设备,常用于预防数据被窃取等恶性事件。

(3)RTC引脚可以输出三类信号——RTC校准时钟(512Hz,用于外部校准32.768kHz晶振)、RTC闹钟脉冲或者秒脉冲(1Hz)。

(4)用户数据存储容量:20字节(中容量和小容量)/ 84字节(大容量和互联型)。

2、BKP的基本结构示意

三、RTC(Real Time Clock)实时时钟

1、RTC概述

(1)RTC是一个独立的定时器,可为系统提供时钟和日历的功能

(2)RTC和时钟配置系统处于后备区域,系统复位时数据不清零,VDD(2.0~3.6V)断电后可借助VBAT(1.8~3.6V)供电继续走时

(3)配备32位的可编程计数器,可对应Unix时间戳的秒计数器。

(4)配备20位的可编程预分频器,可适配不同频率的输入时钟。

(5)可选择三种RTC时钟源:

①HSE时钟除以128(通常为8MHz/128)。

②LSE振荡器时钟(通常为32.768KHz,一般都选用该时钟作为RTCCLK)。

③LSI振荡器时钟(40KHz)。

2、RTC框图

(1)后备区域的电路在主电源掉电后可以使用备用电池维持工作。

(2)RTCCLK是经过选择器输入RTC的时钟,一般选择LSE振荡器时钟,进入RTC的时钟信号首先由预分频器进行分频

(3)RTC的预分频器由两个寄存器组成,RTC_PRL是重装载寄存器(决定分频系数),RTC_DIV是余数寄存器(本质上是一个自减计数器),DIV负责对RTCCLK的时钟脉冲进行计数,当自减为0时会输出一个时钟脉冲到TR_CLK上,同时PRL将重装载值写进DIV中,以此往复达到分频的效果。

(4)RTC_CNT是秒计数器,TR_CLK每来一个时钟脉冲,计数器的值+1;理论上TE_CLK应该配置为每秒产生一个时钟脉冲

(5)RTC_ALR是闹钟寄存器,它与RTC_CNT等长,用于设置闹钟,用户可以在ALR中写一个时间戳,当ALR与CNT的值相等时,会产生RTC_Alarm信号通往右侧的中断系统,同时还可以使STM32退出待机模式。

(6)RTC_Second是秒中断信号,每来一个时钟脉冲就会产生一个秒中断信号。

(7)RTC_Overflow是溢出中断信号,不过对于RTC_CNT而言,它存储的是32位的无符号数,到2106年才会发生计数溢出。(SECF、OWF、ALRF是中断标志位,SECIE、OWIE、ALRIE是中断使能位)

(8)读写RTC中的寄存器需要通过APB1总线(RTC是APB1总线上的设备)。

3、RTC的硬件电路

4、BKP和RTC操作的注意事项

(1)执行以下操作将使能对BKP和RTC的访问:

①设置RCC_APB1ENR的PWREN和BKPEN,使能PWR和BKP时钟。

②设置PWR_CR的DBP,使能对BKP和RTC的访问。(使用PWR_BackupAccessCmd函数)

(2)若在读取RTC寄存器时,RTC的APB1接口曾经处于禁止状态,则软件首先必须等待RTC_CRL寄存器中的RSF位(寄存器同步标志)被硬件置1。

(3)必须设置RTC_CRL寄存器中的CNF位,使RTC进入配置模式后,才能写入RTC_PRL、RTC_CNT、RTC_ALR寄存器。

(4)对RTC任何寄存器的写操作,都必须在前一次写操作结束后进行。可以通过查询RTC_CR寄存器中的RTOFF状态位,判断RTC寄存器是否处于更新中,。仅当RTOFF状态位是1时,才可以写入RTC寄存器。

四、示例程序

1、读写备份寄存器

(1)按照下图所示接好电路,并将OLED显示屏的项目文件夹复制一份作为模板使用。

(2)在stm32f10x_bkp.h文件中有BKP相关的函数。

[1] BKP_DeInit函数:将备份域(Backup Domain)的所有寄存器复位为默认值(即硬件复位后的状态),可以用于清空BKP的所有数据。

函数原型:void BKP_DeInit(void);

参数解释:无参数

返回值:无返回值

[2] BKP_TamperPinLevelConfig函数:配置侵入检测引脚(Tamper Pin,即PC13)的有效电平,即设定当TAMPER出现何种电平状态时,会触发一个侵入检测事件。

函数原型:void BKP_TamperPinLevelConfig(uint16_t BKP_TamperPinLevel);

参数解释:

        uint16_t BKP_TamperPinLevel:BKP_TamperPinLevel_Hig表示当检测到上升沿时触发侵入事件,BKP_TamperPinLevel_Low当检测到下降沿时触发侵入事件

返回值:无返回值

[3] BKP_TamperPinCmd函数:使能或禁用侵入检测引脚的侵入检测功能。

函数原型:void BKP_TamperPinCmd(FunctionalState NewState);

参数解释:

        FunctionalState NewState:ENABLE表示使能侵入检测功能,DISABLE表示禁用侵入检测功能,PC13引脚可用于普通I/O或其它复用功能(如RTC时钟输出)

返回值:无返回值

[4] BKP_ITConfig函数:使能或禁用侵入检测引脚的中断功能。

函数原型:void BKP_ITConfig(FunctionalState NewState);

参数解释:

        FunctionalState NewState:ENABLE表示使能侵入检测中断,DISABLE表示禁用侵入检测中断

返回值:无返回值

[5] BKP_RTCOutputConfig函数:选择在侵入检测引脚上输出的RTC时钟源。

函数原型:void BKP_RTCOutputConfig(uint16_t BKP_RTCOutputSource);

参数解释:

        uint16_t BKP_RTCOutputSource:指定要在侵入检测引脚上输出的RTC时钟源类型

返回值:无返回值

[6] BKP_SetRTCCalibrationValue函数:设置RTC时钟的校准值,以调整RTC的走时精度。

函数原型:void BKP_SetRTCCalibrationValue(uint8_t CalibrationValue);

参数解释:

        uint8_t CalibrationValue:RTC时钟校准值,指定在每2^20个时钟脉冲中被忽略的脉冲数量,0表示不进行校准

返回值:无返回值

[7] BKP_WriteBackupRegister函数:向指定的备份数据寄存器BKP写入用户数据。

函数原型:void BKP_WriteBackupRegister(uint16_t BKP_DR, uint16_t Data);

参数解释:

        uint16_t BKP_DR:指定要写入的备份数据寄存器

        uint16_t Data:要写入备份寄存器的用户数据

返回值:无返回值

[8] BKP_ReadBackupRegister函数:从指定的备份数据寄存器BKP中读取用户数据。

函数原型:uint16_t BKP_ReadBackupRegister(uint16_t BKP_DR);

参数解释:

        uint16_t BKP_DR:指定要读取的备份数据寄存器

返回值:指定备份数据寄存器中存储的16位数据

[9] BKP_GetFlagStatus函数:检查侵入检测事件的标志位是否被置位,即判断是否发生过侵入检测事件。

函数原型:FlagStatus BKP_GetFlagStatus(void);

参数解释:无参数

返回值:SET表示侵入检测事件标志位已被置位——表示发生过侵入检测事件,RESET表示侵入检测事件标志位未被置位——表示未发生过侵入检测事件

[10] BKP_ClearFlag函数:清除侵入检测引脚事件的待处理标志位。

函数原型:void BKP_ClearFlag(void);

参数解释:无参数

返回值:无返回值

[11] BKP_GetITStatus函数:检查侵入检测引脚的中断是否已经发生,即判断侵入检测中断请求是否处于待处理状态。

函数原型:ITStatus BKP_GetITStatus(void);

参数解释:无参数

返回值:SET表示侵入检测中断已发生且已被使能——存在有效的中断请求,RESET表示侵入检测中断未发生,或虽已发生但中断未被使能

[12] BKP_ClearITPendingBit函数:清除侵入检测引脚的中断待处理位。

函数原型:void BKP_ClearITPendingBit(void);

参数解释:无参数

返回值:无返回值

(3)在main.c文件中粘贴以下代码,然后编译,将程序下载到开发板中并进行调试(主要是开发板掉电后VBAT引脚通电与不通电的区别,观察两种情况下BKP中的数据是否会被重置)。

#include "stm32f10x.h"                  // Device headerCmd
#include "OLED.h"
#include "Key.h"

uint16_t ArrayWrite[] = {0x1234, 0x5678};
uint16_t ArrayRead[2];

uint8_t KeyNum;

int main()
{
	OLED_Init();
	Key_Init();
	
	OLED_ShowString(1, 1, "W:");
	OLED_ShowString(2, 1, "R:");
	
	//使能PWR和BKP时钟
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
	
	//使能对BKP和RTC的访问
	PWR_BackupAccessCmd(ENABLE);
	
	while(1)
	{
		KeyNum = Key_GetNum();
		if(KeyNum == 1)   //按下按键1,ArrayWrite中的数据自增并写入BKP
		{
			ArrayWrite[0]++;
			ArrayWrite[1]++;
			
			BKP_WriteBackupRegister(BKP_DR1, ArrayWrite[0]);
			BKP_WriteBackupRegister(BKP_DR2, ArrayWrite[1]);
			
			OLED_ShowHexNum(1,3,ArrayWrite[0],4);
			OLED_ShowHexNum(1,8,ArrayWrite[1],4);
		}
		//读取BKP中的数据
		ArrayRead[0] = BKP_ReadBackupRegister(BKP_DR1);
		ArrayRead[1] = BKP_ReadBackupRegister(BKP_DR2);
		
		OLED_ShowHexNum(2,3,ArrayRead[0],4);
		OLED_ShowHexNum(2,8,ArrayRead[1],4);
	}
}

2、实时时钟应用

(1)按照下图所示接好电路,并将OLED显示屏的项目文件夹复制一份作为模板使用。

(2)在stm32f10x_rcc.h文件中有几个本例相关的函数。

[1] RCC_LSEConfig函数:配置外部低速晶振(LSE,Low Speed External)的使能、禁用或旁路模式。

函数原型:void RCC_LSEConfig(uint32_t RCC_LSE);

参数解释:

        uint32_t RCC_LSE:指定LSE的新状态,RCC_LSE_OFF表示禁用LSE振荡器,RCC_LSE_ON表示开启LSE振荡器,RCC_LSE_BYPASS表示使用外部时钟源代替外部晶振

返回值:无返回值

[2] RCC_LSICmd函数:使能或禁用内部低速时钟(LSI,Low Speed Internal)振荡器。

函数原型:void RCC_LSICmd(FunctionalState NewState);

参数解释:

        FunctionalState NewState:ENABLE表示使能LSI振荡器,DISABLE表示禁用LSI振荡器

返回值:无返回值

[3] RCC_RTCCLKConfig函数:选择RTC(实时时钟)的时钟源,即配置RTCCLK的数据选择器,决定RTC模块使用哪个时钟信号作为其计时基准。

函数原型:void RCC_RTCCLKConfig(uint32_t RCC_RTCCLKSource);

参数解释:

        uint32_t RCC_RTCCLKSource:RCC_RTCCLKSource_LSE表示选择LSE,RCC_RTCCLKSource_LSI表示选择LSI,RCC_RTCCLKSource_HSE_Div128表示选择HSE经128分频后的时钟作为RTC时钟源

返回值:无返回值

[4] RCC_RTCCLKCmd函数:使能或禁用RTC(实时时钟)模块的时钟。

函数原型:void RCC_RTCCLKCmd(FunctionalState NewState);

参数解释:

        FunctionalState NewState:ENABLE 表示使能RTC时钟,DISABLE表示禁用RTC时钟

返回值:无返回值

[5] RCC_GetFlagStatus函数:检查RCC模块中指定的状态标志位是否被置位,从而判断相应的时钟状态、复位事件等是否已发生。

函数原型:FlagStatus RCC_GetFlagStatus(uint8_t RCC_FLAG);

参数解释:

        uint8_t RCC_FLAG:指定要检查的状态标志位

返回值:SET表示指定的标志位被置位(事件已发生/状态已就绪),RESET表示指定的标志位未被置位(事件未发生/状态未就绪)

[6] RCC_ClearFlag函数:清除RCC模块中的复位标志位。

函数原型:void RCC_ClearFlag(void);

参数解释:无参数

返回值:无返回值

(3)在stm32f10x_rtc.h文件中有RTC模块相关的函数。

[1] RTC_ITConfig函数:使能或禁用指定的RTC中断源。

函数原型:void RTC_ITConfig(uint16_t RTC_IT, FunctionalState NewState);

参数解释:

        uint16_t RTC_IT:指定要使能或禁用的RTC中断源

        FunctionalState NewState:指定中断源的新状态,ENABLE表示使能指定的中断源,DISABLE表示禁用指定的中断源

返回值:无返回值

[2] RTC_EnterConfigMode函数:将RTC外设切换到配置模式(置CRL寄存器的CNF位为1)。

函数原型:void RTC_EnterConfigMode(void);

参数解释:无参数

返回值:无返回值

[3] RTC_ExitConfigMode函数:将RTC外设从配置模式切换回正常运行模式(置CRL寄存器的CNF位为0)。

函数原型:void RTC_ExitConfigMode(void);

参数解释:无参数

返回值:无返回值

[4] RTC_GetCounter函数:读取RTC 32位计数器的当前值。

函数原型:uint32_t RTC_GetCounter(void);

参数解释:无参数

返回值:RTC计数器的当前值,代表从起始时刻开始累积的秒数

[5] RTC_SetCounter函数:设置RTC 32位计数器的值。

函数原型:void RTC_SetCounter(uint32_t Counter);

参数解释:

        uint32_t Counter:要写入RTC计数器的初始值

返回值:无返回值

[6] RTC_SetPrescaler函数:设置RTC预分频寄存器的值,从而决定RTC计数器的更新频率。

函数原型:void RTC_SetPrescaler(uint32_t PrescalerValue);

参数解释:

        uint32_t PrescalerValue:要写入RTC预分频寄存器的值

返回值:无返回值

[7] RTC_SetAlarm函数:设置RTC闹钟寄存器的值。

函数原型:void RTC_SetAlarm(uint32_t AlarmValue);

参数解释:

        uint32_t AlarmValue:要写入RTC闹钟寄存器的值

返回值:无返回值

[8] RTC_GetDivider函数:读取RTC预分频器余数寄存器(RTC_DIV)的当前值。

函数原型:uint32_t RTC_GetDivider(void);

参数解释:无参数

返回值:RTC预分频器余数寄存器的当前值,即RTC_DIV的当前计数值

[9] RTC_WaitForLastTask函数:等待最近一次对RTC寄存器的写操作完成(等待RTOFF=1)。

函数原型:void RTC_WaitForLastTask(void);

参数解释:无参数

返回值:无返回值

[10] RTC_WaitForSynchro函数:等待RTC寄存器与APB时钟同步完成(等待RSF=1)。

函数原型:ErrorStatus RTC_WaitForSynchro(void);

参数解释:无参数

返回值:SUCCESS表示同步成功,ERROR表示同步失败

[11] RTC_GetFlagStatus函数:检查指定的RTC状态标志位是否被置位。

函数原型:FlagStatus RTC_GetFlagStatus(uint32_t RTC_FLAG);

参数解释:

        uint32_t RTC_FLAG:指定要检查的RTC标志位

返回值:SET表示指定的标志位被置位,RESET表示指定的标志位未被置位

[12] RTC_ClearFlag函数:清除指定的RTC待处理标志位。

函数原型:void RTC_ClearFlag(uint32_t RTC_FLAG);

参数解释:

        uint32_t RTC_FLAG:指定要清除的RTC标志位

返回值:无返回值

[13] RTC_GetITStatus函数:检查指定的RTC中断是否已经发生。

函数原型:ITStatus RTC_GetITStatus(uint16_t RTC_IT);

参数解释:

        uint16_t RTC_IT:指定要检查的RTC中断源

返回值:SET表示指定的RTC中断已发生,RESET表示指定的RTC中断未发生

[14] RTC_ClearITPendingBit函数:清除指定RTC中断的待处理标志位。

函数原型:void RTC_ClearITPendingBit(uint16_t RTC_IT);

参数解释:

        uint16_t RTC_IT:指定要清除的中断待处理位

返回值:无返回值

(3)在项目的System组中添加MyRTC.h文件和MyRTC.c文件,用于封装RTC模块的代码。

①MyRTC.h文件:

#ifndef __MyRTC_H
#define __MyRTC_H

extern uint16_t MyRTC_Time[];

void MyRTC_Init(void);
void MyRTC_SetTime(void);
void MyRTC_ReadTime(void);

#endif

②MyRTC.c文件:

#include "stm32f10x.h"                  // Device header
#include <time.h>

uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};  //2023年1月1日23:59:55

void MyRTC_SetTime(void);

void MyRTC_Init(void)
{
	//使能PWR和BKP时钟
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
	
	//使能对BKP和RTC的访问
	PWR_BackupAccessCmd(ENABLE);
	
	if(BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)  //RTC只需初始化一遍即可(关机后再开机,计时不会被重置)
	{
		//开启LSE的时钟
		RCC_LSEConfig(RCC_LSE_ON);
		
		//等待LES时钟开启
		while(RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);
		
		//配置RTCCLK的数据选择器,指定LSE为时钟源
		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);
		
		//允许RTCCLK选择的时钟进入RTC
		RCC_RTCCLKCmd(ENABLE);
		
		//等待同步,等待前一次写操作结束
		RTC_WaitForLastTask();
		RTC_WaitForSynchro();
		
		//配置预分频器,输出1Hz的时钟(RTC_SetPrescaler函数中有使RTC进入配置模式的过程)
		RTC_SetPrescaler(32768 - 1);  //32.768KHz / 32768 = 1Hz
		RTC_WaitForLastTask();  //等待前一次写操作结束
		
		//给RTC一个初始时间
		MyRTC_SetTime();
		
		//第一次初始化RTC时给BKP写值,如果程序复位,初始化函数会再执行一遍
		//而程序复位时BKP不会被重置,可以依据BKP中的值是否为A5A5判断是否需要初始化RTC
		BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);
	}
	else
	{
		RTC_WaitForLastTask();
		RTC_WaitForSynchro();
	}
}

void MyRTC_SetTime(void)
{
	time_t time_cnt;       //秒计数器数据类型
	struct tm time_date;   //日期时间数据类型
	
	//将设置的时间参数(北京时间)赋给日期时间结构体
	time_date.tm_year = MyRTC_Time[0] - 1900;
	time_date.tm_mon = MyRTC_Time[1] - 1;
	time_date.tm_mday = MyRTC_Time[2];
	time_date.tm_hour = MyRTC_Time[3];
	time_date.tm_min = MyRTC_Time[4];
	time_date.tm_sec = MyRTC_Time[5];
	
	time_cnt = mktime(&time_date) - 8 * 60 * 60;  
	//日期时间结构体转换为伦敦时间的时间戳,再调整为北京时间的时间戳
	//(这步只是为了CNT使用得到北京时间的时间戳,即使使用伦敦的时间戳也不影响最终结果)
	
	RTC_SetCounter(time_cnt);  //写CNT(设置时间)
	RTC_WaitForLastTask();  //等待前一次写操作结束
}

void MyRTC_ReadTime(void)
{
	time_t time_cnt;       //秒计数器数据类型
	struct tm time_date;   //日期时间数据类型
	
	time_cnt = RTC_GetCounter() + 8 * 60 * 60;
	//当前时间戳是北京时间的时间戳,进行转换时需要换回伦敦时间的时间戳
	//(这步只是为了CNT使用北京时间的时间戳,即使使用伦敦的时间戳也不影响最终结果)
	
	time_date = *localtime(&time_cnt);  //时间戳转换为日期时间结构体
	
	//读取日期时间结构体中的时间参数
	MyRTC_Time[0] = time_date.tm_year + 1900;
	MyRTC_Time[1] = time_date.tm_mon + 1;
	MyRTC_Time[2] = time_date.tm_mday;
	MyRTC_Time[3] = time_date.tm_hour;
	MyRTC_Time[4] = time_date.tm_min;
	MyRTC_Time[5] = time_date.tm_sec;
}

(3)在main.c文件中粘贴以下代码,然后编译,将程序下载到开发板中并进行调试(主要是开发板掉电后VBAT引脚通电与不通电的区别,观察两种情况下RTC的计时是否被重置)。

#include "stm32f10x.h"                  // Device headerCmd
#include "OLED.h"
#include "MyRTC.h"

int main()
{
	OLED_Init();
	MyRTC_Init();
	
	OLED_ShowString(1, 1, "Date:XXXX-XX-XX");
	OLED_ShowString(2, 1, "Time:XX-XX-XX");
	OLED_ShowString(3, 1, "CNT:");
	OLED_ShowString(4, 1, "DIV:");
	
	while(1)
	{
		MyRTC_ReadTime();
		OLED_ShowNum(1,6,MyRTC_Time[0],4);
		OLED_ShowNum(1,11,MyRTC_Time[1],2);
		OLED_ShowNum(1,14,MyRTC_Time[2],2);
		OLED_ShowNum(2,6,MyRTC_Time[3],2);
		OLED_ShowNum(2,9,MyRTC_Time[4],2);
		OLED_ShowNum(2,12,MyRTC_Time[5],2);
		
		OLED_ShowNum(3,6,RTC_GetCounter(),10);
		OLED_ShowNum(4,6,RTC_GetDivider(),10);
	}
}
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐