标准库3.5实现:
《嵌入式-STM32开发指南》第二部分 基础篇 - 第5章 PWM
5.1 PWM_输出
5.1.1 PWM输出的工作原理
脉冲宽度调制(PWM),是英文“ Pulse Width Modulation” 的缩写,简称脉宽调制,是利用微处理器的数字输出来对模拟电路进行控制的一种非常有效的技术。 简单一点,就是对脉冲宽度的控制。
STM32 的定时器除了 TIM6 和 7(基本定时器)。其他的定时器都可以用来产生 PWM 输出。其中高级定时器 TIM1 和 TIM8 可以同时产生多达 7 路的 PWM 输出。而通用定时器也能同时产生多达 4路的 PWM 输出,这样, STM32 最多可以同时产生 30 路 PWM 输出。
每个定时器有四个通道,每一个通道都有一个捕获比较寄存器,,将寄存器值和计数器值比较,通过比较结果输出高低电平,便可以实现脉冲宽度调制模式(PWM信号)。
在上一节,讲解了定时器的相关寄存器即基本原理,本节将不再赘述。下面谈谈如何使用定时器的寄存器进行PWM输出的。若配置脉冲计数器TIMx_CNT为向上计数,而重载寄存器TIMx_ARR配置为N,即TIMx_CNT的当前计数值数值X在TIMxCLK时钟源的驱动下不断累加,当TIMx_CNT的数值X大于N时,会重置TIMx_CNT数值为0重新计数。而在TIMxCNT计数的同时,TIMxCNT的计数值X会与比较寄存器TIMx_CCR预先存储了的数值A进行比较,当脉冲计数器TIMx_CNT的数值X小于比较寄存器TIMx_CCR的值A时,输出高电平(或低电平),相反地,当脉冲计数器的数值X大于或等于比较寄存器的值A时,输出低电平(或高电平)。如此循环,得到的输出脉冲周期就为重载寄存器TIMx_ARR存储的数值(N+1)乘以触发脉冲的时钟周期,其脉冲宽度则为比较寄存器TIMx_CCR的值A乘以触发脉冲的时钟周期,即输出PWM的占空比为A/(N+1)。
估计很多初学者看了上面的一段话都很蒙圈,没关系,下面以向上计数模式为例进行讲解。
在PWM输出模式下,除了CNT(计数器当前值)、ARR(自动重装载值)之外,还多了一个值CCRx(捕获/比较寄存器值)。当CNT小于CCRx时,TIMx_CHx通道输出低电平;当CNT等于或大于CCRx时,TIMx_CHx通道输出高电平。因此得到PWM的一个周期如下:
1.定时器从0开始向上计数;
2.当0-t1段,定时器计数器TIMx_CNT值小于CCRx值,输出低电平;
3.t1-t2段,定时器计数器TIMx_CNT值大于CCRx值,输出高电平;
4.当TIMx_CNT值达到ARR时,定时器溢出,重新向上计数...循环此过程。
至此一个PWM周期完成。针对PWM重点关注两个寄存器,TIMx_ARR寄存器确定PWM频率,TIMx_CCRx寄存器确定占空比。
上文提到了PWM的输出模式,下面讲解PWM的工作模式:
PWM模式1(向上计数) :计数器从0计数加到自动重装载值(TIMx_ARR),然后重新从0开始计数,并且产生一个计数器溢出事。
PWM模式2(向下计数) :计数器从自动重装载值(TIMx_ARR)减到0,然后重新从重装载值(TIMx_ARR)开始递减,并且产生一个计数器溢出事件。
[ps] 本文以F1系列为例进行讲解,ST不同系列其定时器个数不同
STM32F1系列共有8个定时器:
高级定时器(TIM1、TIM8);通用定时器(TIM2、TIM3、TIM4、TIM5);基本定时器(TIM6、TIM7)。
5.1.2 STM32Cube生成工程
本文介绍在STM32CubeMX进行定时器的配置,这里我们仅利用 TIM3的 4路通道输出,方便我们比较波形。具体不同定时器对应引脚在对应芯片数据手册的引脚说明(pin description) 中查看。
1.设置RCC
设置高速外部时钟HSE,选择外部时钟源。
2.时钟配置
笔者的板子使用的外部晶振为8MHz,选择外部时钟HSE 8MHz ,PLL锁相环9倍频后为72MHz,系统时钟来源选择为PLL,设置APB1分频器为 /2,这时候定时器的时钟频率为72Mhz。本文笔者使用的定时器是TIM3,TIM3挂在APB1上,不同的定时器挂在不同总线上的。
3.Times配置
选择TIM,使能TIM3,指定时钟源。
【注】TIM2的时钟源有两个选项
选项1 :Internal Clock 内部时钟
选项2 : ETR2 外部触发输入(ETR)(仅适用TIM2,3,4)
本文要使用TIM3的四个通道,因此需要将其使能。每个通道有很多模式,这里选择PWM输出。当对应的通道打开后,对应的GPIO也会被使能。
【注】如果使能通道前通道中GPIO使用过,STM32CubeMX会自动将GPIO配置为重映射的GPIO。举个例子,当PB0被占用了,那么四个GPIO会重映射到PC6-PC9。
PWM参数配置如下:
- Counter setting
Prtscaler (定时器分频系数) : 0
Counter Mode(计数模式) :Up(向上计数模式)
Counter Period(自动重装载值) : 999
CKD(时钟分频因子) : No Division 不分频
选项: 可以选择二分频和四分频
auto-reload-preload(自动重装载) : Enable 使能
- TRGO Output (TRGO) Parameters
Master/Slave Mode(MSM bit):Disable
TRGO:定时器的触发信号输出 在定时器的定时时间到达的时候输出一个信号(如:定时器更新产生TRGO信号来触发ADC的同步转换,)
- PWM Generation Channel (四个CH)
Mode(定时模式):PWM mode 1
Pulse(计数比较值):四个通道分别为500,375,250,125
CH Polarity(输出极性):High
根据前面的参数配置,我们可以算出PWM的输出周期:
这里我们 ,
本文选择的是PWM模式1,在向上计数时,一旦TIMx_CNT < TIMx_CCR1(计数比较值)。时通道1为有效电平,否则为无效电平;在向下计数时,一旦TIMx_CNT>TIMx_CCR1时通道1为无效电平(OC1REF=0),否则为有效电平(OC1REF=1)。输出比较极性的指的是你在比较匹配之后输出口输出的极性,也就是设置比较输出的有效电平。你可以设置为高电平有效或者低电平有效。如果设置为高电平有效,那么当定时器比较匹配之后,输出口输出高电平,否则就反一下。
如果是PWM模式1,且向上计数,如果极性设置为低,那么 TIMx_CNT < TIMx_CCR1 时,输出低电平,更简单就是占空比为1 -TIMx_CCR1/(ARR+1). 如果极性为高,占空比就是TIMx_CCR1/(ARR+1)。
好了,到这里,配置就完成了,生成工程就行了。
5.1.3 PWM输出的具体代码分析
我们先看看主函数,其代码如下:
int main(void)
{
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_TIM3_Init();
/* USER CODE BEGIN 2 */
/*使能定时3*/
HAL_TIM_Base_Start_IT(&htim3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4);
//HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_ALL);
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
在主循环前面,需要对TIM3进行初始化配置:
HAL_TIM_Base_Start_IT(&htim3);
然后再开启四路通道的PWM:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4);
如果全部开启,可使用以下代码:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_ALL);
PWM输出最重要就是MX_TIM3_Init()函数,这个函数包含了TIM3的PWM配置,具体再讲。
5.1.4 PWM输出的实验现象
现在,TIM3 的通道 1(PA.06)、2(PA.07)、3(PB.00)、4(PB.01)就会输出不同占空比的 PWM 信号了。PWM 信号可以通过示波器看到。考虑到并不是每个用户手头上都有示波器,我们在这里采用软件仿真的方式来验证我们的程序。
以前我们都是通过 J_LINK 直接将我们的代码烧到开发板的 Flash 中去调试,现在要换成软件仿真,得首先设置一下我们的开发环境,按照如下步骤所示。
1)点击 Target Options 选项图标,选中 Debug 选项卡,选中 Use Simulator 选项,按图中所示进行设置,然后点击“OK”按钮,见图7所示。
2)点击 Start/Stop Debug Session 选项图标,点击System Analysis Windows的下拉选项的 Logic Analysis,弹出窗口后点击Setup…选项卡,在弹出的 Setup Logic Analysis 串口中点击 New(Insert)按钮,然后在文本栏里面分别输入:PORTA.6 、PORTA.7、PORTB.0、PORTB.1,记住,是 New 一个就输入一个信号的 IO,输完之后需要再 New。对应的相关设置见图8。
需要注意的是Display Range的设置,默认的设置是看不到波形的。
3)设置完信号源之后,点击 RUN 按钮,仿真信号即出来了,当信号出来之后,可点击STOP 按钮,让信号不再变化,方便观察。其中 In、Out、All 这三个按钮可以调节显示信号的疏密程度,见图9。
其中 Cursor 选项可以帮助我们测量信号的时间差,Amplitute 则可以帮助我们测量信号的幅值。
【注】有的STM32cudeMX生成的工程不一定能 ,如果实在没法看结果,直接看呼吸灯实验吧。
5.2呼吸灯
5.2.1呼吸灯的工作原理
呼吸灯,就是指灯光设备的亮度随着时间由暗到亮逐渐增强,再由亮到暗逐渐衰减,很有节奏感地一起一伏,就像是在呼吸一样,因而被广泛应用于手机、电脑等电子设备的指示灯中。冰冷的电子设备应用呼吸灯后,顿时增添了几分温暖。
要使用数字器件控制灯光的强弱,我们很自然就想到 PWM(脉冲宽度调制)技术。假如以LED 作为灯光设备,且由控制器输出的 PWM 信号可以直接驱动 LED,PWM 信号中的低电平可点亮 LED 灯。当 LED 以较高的频率进行开关(亮灭)切换时,由于视觉暂留效应,人眼是看不到 LED 灯的闪烁现象的,反映到人眼中能感觉到的是亮度的差别。即以一定的时间长度为周期,LED 灯亮的平均时间越长,亮度就越高,反之越暗。因此,我们可以使用高频率的 PWM 信号,通过调制信号的占空比,控制 LED 灯的亮度。
那么具体我们应该控制 LED 灯以怎样的亮度曲线变化能够达到最好的效果呢?亮度随着时间逐渐变强再衰减,可以用两种常见的数学函数表示,分别是半个周期的正弦函数与指数上升曲线及其对称得到的下降曲线。
相对来说,使用下凹函数曲线灯光处于暗的状态更长,所以指数函数的曲线更符合我们呼吸灯的亮度变化要求。
接下来就要确定呼吸灯的呼吸频率(即一个亮度起伏过程)。据统计,成人的一个呼吸周期为 3 秒钟,即吸气时间(亮度上升时间) 1.5 秒,呼气时间(亮度衰减时间)1.5 秒。我们使用定时器即可精确控制它的呼吸频率,当然,读者想把呼吸灯的频率调快或慢一点都是可以的,呼吸周期为 3 秒钟只是一个参考值。
5.2.2 STM32Cube生成工程
和上一节内容差不多,稍微有些不同罢了。
1.设置RCC
设置高速外部时钟HSE,选择外部时钟源。
2.时钟配置
笔者的板子使用的外部晶振为8MHz,选择外部时钟HSE 8MHz ,PLL锁相环9倍频后为72MHz,系统时钟来源选择为PLL,设置APB1分频器为 /2,这时候定时器的时钟频率为72Mhz。本文笔者使用的定时器是TIM3,TIM3挂在APB1上,不同的定时器挂在不同总线上的。
3.Times配置
选择TIM,使能TIM3,指定时钟源。
本节要使用TIM3的CH3通道,因此需要将其使能。这里选择PWM输出。当对应的通道打开后,对应的GPIO也会被使能。笔者的板子的LED接到了PB0上,这里要根据自己的板子来配置TIM和CH。
PWM参数配置如下:
- Counter setting
Prtscaler (定时器分频系数) : 1999
Counter Mode(计数模式) :Up(向上计数模式)
Counter Period(自动重装载值) : 255
CKD(时钟分频因子) : No Division 不分频
选项: 可以选择二分频和四分频
auto-reload-preload(自动重装载) : Enable 使能
- TRGO Output (TRGO) Parameters
Master/Slave Mode(MSM bit):Disable
TRGO:定时器的触发信号输出 在定时器的定时时间到达的时候输出一个信号(如:定时器更新产生TRGO信号来触发ADC的同步转换,)
- PWM Generation Channel (四个CH)
Mode(定时模式):PWM mode 1
Pulse(计数比较值):0
CH Polarity(输出极性):High
根据前面的参数配置,我们可以算出PWM的输出周期:
这里我们 ,
这个要开启中断。
另外将设置GPIO的速度为高速。
好了,到这里,就配置完成了。
5.2.3呼吸灯的具体代码分析
我们还是先看看主函数。
int main(void)
{
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_TIM3_Init();
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&htim3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
主函数和上一节的差不多,还好了几句,这个只需初始化TIM3的CH3。那么是哪里不同呢,在回答这个问题之前,先看看呼吸灯的编程流程。
1)硬件初始化,系统时钟初始化;
2)GPIO初始化,TIM3以及PWM初始化;
3)启动定时器和PWM相应通道。
4)调用中断回调函数,不断改变TIMx_CCR寄存器的值。
我们就根据这个思路来看看呼吸灯的具体代码。
1.生成指数曲线 PWM 数据
要实现 LED 亮度随着指数曲线变化,我们需要使用占空比呈指数曲线变化的 PWM 信号,而这样的信号由定时器经过查表产生。这个表的数据存储在程序中的数组 indexWave中。
uint8_t indexWave[] = {1,1,2,2,3,4,6,8,10,14,19,25,33,44,59,80,
107,143,191,255,255,191,143,107,80,59,44,33,25,19,14,10,8,6,4,3,2,2,1,1};
这个表有 40 个数字,从图中可以看到这些数字呈指数上升再衰减,正好是呼吸灯的一个控制周期。数字的大小范围是 0~ 255,即把 LED 的亮度分为了 0 ~ 255 个等级。
假如我们把定时器的脉冲计数器 TIMx_CNT 上限设置为 255,把这个表的数据一个一个地赋值到定时器的比较寄存器 TIMx_CCR 中,那么在每个 PWM 周期中,当 TIMx_CNT的计数值小于比较寄存器 TIMx_CCR 的值时, 就会在通道中输出低电平,点亮 LED,而随着 TIMx_CCR 的值由 LED 亮度表得来,所以 LED 点亮的时间就会呈图中的曲线变化,实现呼吸灯的功能。
这个表的数据是使用 matlab 软件生成的。该代码运行后会生成一个“index_wave.c”的文件,用户把该文件中的数据复制到工程中的数组中即可。
%本代码用于产生呼吸灯使用的指数函数数据
clear;
x = [0 : 8/19 : 8]; %设置序列 ,指数上升
up = 2.^x ; %求上升指数序列
up = uint8(up); %化为8位数据
y = [8: -8/19 :0]; %设置序列 ,指数下降
down = 2.^y ; %求下降指数序列
down = uint8(down); %化为8位数据
line = [[0:8/19:8],[8:8/19:16]] %拼接序列
val = [up , down] %拼接输出序列
dlmwrite('index_wave.c',val); %输出到文件index_wave.c
plot(line,val,'.'); %显示波形图
2.初始化GPIO和定时器
硬件初始化,系统时钟初始化就不说了,我们看看GPIO和定时器初始化。
/**
* @brief TIM3 Initialization Function
* @param None
* @retval None
*/
static void MX_TIM3_Init(void)
{
/* USER CODE BEGIN TIM3_Init 0 */
/* USER CODE END TIM3_Init 0 */
TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
/* USER CODE BEGIN TIM3_Init 1 */
/* USER CODE END TIM3_Init 1 */
htim3.Instance = TIM3;
htim3.Init.Prescaler = 1999;
htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
htim3.Init.Period = 255;
htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
if (HAL_TIM_Base_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_PWM_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 0;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
if (HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_3) != HAL_OK)
{
Error_Handler();
}
/* USER CODE BEGIN TIM3_Init 2 */
/* USER CODE END TIM3_Init 2 */
HAL_TIM_MspPostInit(&htim3);
}
/**
* @brief GPIO Initialization Function
* @param None
* @retval None
*/
static void MX_GPIO_Init(void)
{
/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOB_CLK_ENABLE();
}
GPIO初始化说的,就配置了一个时钟。这里主要讲讲TIM3初始化,MX_TIM3_Init()函数有两部分内容,一部分TIM的Counter配置,这部分内容和上一章是一样的,我们重点关注TIM_OC_InitTypeDef结构体,这个结构体就是输出比较配置的结构体,这个就是PWM的具体输出的配置结构体。
typedef struct
{
uint32_t OCMode; /*!< Specifies the TIM mode.
This parameter can be a value of @ref TIM_Output_Compare_and_PWM_modes */
uint32_t Pulse; /*!< Specifies the pulse value to be loaded into the Capture Compare Register.
This parameter can be a number between Min_Data = 0x0000 and Max_Data = 0xFFFF */
uint32_t OCPolarity; /*!< Specifies the output polarity.
This parameter can be a value of @ref TIM_Output_Compare_Polarity */
uint32_t OCNPolarity; /*!< Specifies the complementary output polarity.
This parameter can be a value of @ref TIM_Output_Compare_N_Polarity
@note This parameter is valid only for timer instances supporting break feature. */
uint32_t OCFastMode; /*!< Specifies the Fast mode state.
This parameter can be a value of @ref TIM_Output_Fast_State
@note This parameter is valid only in PWM1 and PWM2 mode. */
uint32_t OCIdleState; /*!< Specifies the TIM Output Compare pin state during Idle state.
This parameter can be a value of @ref TIM_Output_Compare_Idle_State
@note This parameter is valid only for timer instances supporting break feature. */
uint32_t OCNIdleState; /*!< Specifies the TIM Output Compare pin state during Idle state.
This parameter can be a value of @ref TIM_Output_Compare_N_Idle_State
@note This parameter is valid only for timer instances supporting break feature. */
} TIM_OC_InitTypeDef;
OCMode:输出比较模式的选择,对应的是TIMx_CCMR1寄存器的OC1M位。
Pulse:设置电平跳变值,最小值为0x0000 ,最大值为0Xffff。
OCPolarity:设置输出比较的极性。
OCNPolarity:设置互补输出比较极性。
OCFastMode:输出比较快速使能和失能。
OCIdleState:选择空闲状态下的非工作状态。
OCNIdleState:设置非空闲状态下的非工作状态。
根据上述讲解,也就能明白MX_TIM3_Init()的含义了。接下来重点来了,以上是STM32cudeMX自动生成的代码,下面是我们自己实现的代码,也就是呼吸灯的核心代码。
3.PWM输出中断回调函数
关于如何配置中断,这部分昂看上一章,下面讲解如何编写PWM中断服务函数。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
static uint8_t pwm_index = 1; /* 用于PWM查表 */
static uint8_t period_cnt = 0; /* 用于计算周期数 */
period_cnt++;
/* 若输出的周期数大于10,输出下一种脉冲宽的PWM波 */
if(period_cnt >= 10)
{
/* 根据PWM表修改定时器的比较寄存器值 */
__HAL_TIM_SET_COMPARE(htim,TIM_CHANNEL_3,indexWave[pwm_index]);
/* 标志PWM表的下一个元素 */
pwm_index++;
/* 若PWM脉冲表已经输出完成一遍,重置PWM查表标志 */
if( pwm_index >= 40)
{
pwm_index=0;
}
/* 重置周期计数标志 */
period_cnt=0;
}
}
本中断服务函数在每次定时器更新事件发生时执行一次(即 256 个定时器时钟周期)。函数中使用了静态变量 pwm_index 和 period_cnt,它们分别用来查找 PWM 表元素和记录同样占空比的脉冲输出了多少次。
本代码的目的是每 10 次定时器中断更新一次 PWM 表中的数据到比较寄存器TIMx_CCR 中,当遍历完 PWM 表的 40 个元素时,再重头开始遍历 PWM 表,周而复始,重复 LED 的呼吸过程。
整个呼吸过程的时间计算方法如下:
因为定时器的 TIM_Prescaler 设置为 1999;
所以定时器的时钟频率:
即定时器的时钟周期为:
因为定时器的 TIM_Period 设置为 255;
所以定时器的中断周期为:
因为 PWM 表有 个亮度占空比数据,同种占空比信号输出 次
所以一个呼吸周期
5.2.4呼吸灯的实验现象
将程序编译好下载到板子中,可一看到LED像呼吸一样渐渐变明或者渐渐变暗。
代码获取方式
1.关注公众号[嵌入式实验楼]
2.在公众号回复关键词[STM32F1]获取资料
欢迎访问我的网站:
BruceOu的哔哩哔哩
BruceOu的主页
BruceOu的博客
CSDN博客