news 2026/9/5 3:48:46

STM32C5通过SPI驱动IIS3DWB振动传感器:从硬件连接到数据解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32C5通过SPI驱动IIS3DWB振动传感器:从硬件连接到数据解析

先把话放在前面:这篇文章是系列第一篇,主题是“用STM32C5通过SPI把IIS3DWB10IS这款宽带振动传感器的数据读回来”。做旋转机械振动监测、轴承故障诊断或者状态预测性维护的朋友,大概率会碰到类似需求——传感器选好了,MCU选好了,结果第一步SPI通信就卡了两天。这篇就把我实际调通这套组合的完整过程拆开讲,包括硬件接线、CubeMX配置、寄存器读写、数据解析和踩坑记录。不管你是刚接触STM32还是已经在做振动采集,照着下面这套流程走,能少走不少弯路。

我用的传感器是ST的IIS3DWB10IS,这颗料在振动监测里出镜率挺高。它属于超宽带三轴加速度计,输出数据速率最高能到26.7kHz,平坦带宽6.4kHz,专门为工业振动监测、声学分析这类场景设计。MCU这边选了STM32C5系列,Cortex-M33内核,主频和资源都够用,而且CubeMX支持很完善,直接生成HAL工程特别顺手。这套组合的好处是SPI时钟可以跑到10MHz,数据量大的时候不用担心瓶颈,I2C在这种带宽需求下基本不够看。

1. 项目整体设计与思路拆解

1.1 为什么是IIS3DWB10IS而不是普通六轴传感器

很多朋友看到“振动计”第一反应是拿MPU6050、LSM6DSO这类惯性传感器去测。但这里有个核心差异:普通消费级IMU的带宽大多在几百赫兹到2kHz左右,而工业设备振动信号的特征频率经常落在几kHz甚至更高。比如齿轮箱啮合频率、轴承外圈故障频率(BPFO),高频段才是故障的早期信号。IIS3DWB10IS的优势就在这,满量程支持±2g到±16g,SPI接口下ODR最高26.7kHz,带宽6.4kHz,这数据在同级别里相当能打。

另外这颗传感器在低噪声和温度稳定性上做了优化,长期监测场景下漂移更小。我之前用普通六轴做过一次轴承跑合实验,高频成分直接被带宽卡死,频谱上什么都看不出来,换成IIS3DWB之后同样工况下特征频率一清二楚。所以选型阶段就把带宽和噪声放在第一位,而不是只看分辨率位数。

1.2 SPI为什么比I2C更适合这个项目

热词里很多人搜“iic和spi的区别”,放到这个项目里其实很典型。I2C是半双工、开漏加上拉,速率上限在1MHz左右(实际稳定跑400kHz居多),而且每读一帧数据要发设备地址、寄存器地址,开销很大。IIS3DWB的最高ODR是26.7kHz,每个样本三轴共6字节,按满速率算光数据量就是160KB/s左右,如果再算上FIFO批量读取,I2C的压力非常大。

SPI是全双工,没有地址帧,CS拉低之后直接读写寄存器,时钟能跑到10MHz。同样一帧数据,SPI的耗时只有I2C的十分之一。再加上后续如果要做FFT频谱分析,数据是连续往上位机或者SD卡里灌的,SPI的带宽余量让整个链路没那么容易卡住。

1.3 整体系统架构

这套系统的硬件链路大致是:IIS3DWB10IS通过四线SPI接到STM32C5,传感器中断引脚(INT1)接到MCU的EXTI,用于数据就绪或FIFO阈值通知。MCU收到中断后通过SPI DMA读取FIFO数据,做简单校验后再通过UART/USB发送给上位机。为了省CPU,读取和传输都走DMA,主循环只做状态管理和数据帧拼装。

第一次做这个项目不建议一上来就上DMA+FIFO+中断的组合,先把裸SPI轮询读通,再逐步加复杂度。我后面代码部分也是这样安排的,先保证能读到WHO_AM_I,再谈性能优化。

2. 硬件接线与关键设计要点

2.1 引脚分配与连接对照

STM32C5的SPI引脚复用和F1/F4系列不完全一样,所以不要凭老经验直接对引脚号。我这边用CubeMX查了目标封装的具体AF映射,最终用的SPI1,接线如下表:

功能IIS3DWB10IS引脚STM32C5引脚说明
SCLKSPCPA5 (SPI1_SCK)SPI时钟
MOSISDIPA7 (SPI1_MOSI)主发从收
MISOSDOPA6 (SPI1_MISO)主收从发
CSCSPA4 (GPIO输出)片选,手动控制
INT1INT1PB0 (EXTI)数据就绪/FIFO中断
VDD3.3V3.3V传感器供电
GNDGNDGND共地
VDD_IO3.3V3.3VIO电平参考

注意SDO/SA0这个引脚,在SPI模式下它就是MISO数据输出,但如果是I2C模式,它是地址选择脚。用SPI时直接把SDO接到MCU的MISO就行,SA0功能可以忽略。我实际焊板子时把SDO和SA0搞混过一次,MISO上永远没数据,排查了半天才发现是封装上两个引脚位置看反了。

2.2 硬件设计上容易忽略的几个点

第一,电源去耦。IIS3DWB是精密模拟+数字混合器件,VDD和GND之间建议放一个1uF瓷片电容靠近引脚,有条件的再并联一个0.1uF去耦。板上如果电机驱动或开关电源离得近,还建议加一颗磁珠隔离。这个传感器对电源纹波不是特别敏感,但电源脏了直接体现在输出噪声上,FFT底噪会变差。

第二,电平匹配。IIS3DWB的VDD和VDD_IO不用强制同电压,VDD_IO决定数字引脚的逻辑电平。如果MCU是3.3V,就把VDD_IO也接3.3V。如果MCU那边是1.8V IO,VDD_IO接1.8V,避免逻辑电平不匹配导致读到的数据全是0xFF或者0x00。

第三,CS引脚上拉。CS建议在传感器端接一个10k上拉到VDD_IO,防止上电瞬间误触发片选。之前有一版板子没加上拉,MCU复位时SPI引脚处于高阻态,CS被干扰拉低,传感器产生了莫名其妙的中断。

2.3 中断引脚与MCU的匹配

INT1是推挽输出,直接连MCU的PB0没问题。配成上升沿触发或者电平触发都可以,我一般用上升沿。注意INT1高电平有效还是低电平有效是通过寄存器配置的,默认通常是高有效,别想当然,后面会讲到。

3. CubeMX工程配置与SPI参数计算

3.1 新建工程与时钟树设置

STM32C5在CubeMX里很好找,按具体型号选就行。时钟树方面,我直接用HSI+PLL把系统时钟拉到头(具体主频看型号,C5系列支持到250MHz左右)。SPI外设时钟来自APB总线,先确认APB1/APB2的时钟频率是多少,再算分频系数。

我的SPI1挂载在APB2上,APB2时钟默认是系统时钟除以2。假设系统时钟250MHz,APB2就是125MHz。要把SPI时钟控制在10MHz以内,125MHz除以16等于7.8125MHz,这个值是安全可用的。除以8是15.625MHz,超了IIS3DWB的10MHz上限,不能直接用。

3.2 SPI参数配置的具体选项

在CubeMX的SPI1配置界面里,我用的参数组合:

参数项设置值
ModeFull-Duplex Master
Hardware NSS SignalDisable(用软件片选)
Clock Polarity (CPOL)Low
Clock Phase (CPHA)1Edge
Prescaler16(实际SPI时钟7.8125MHz)
Data Size8 bit
First BitMSB First
CRC CalculationDisabled

CPOL=Low、CPHA=1Edge对应SPI Mode 0,实测这套配置在IIS3DWB上是通的。如果读WHO_AM_I一直不对,可以改成CPOL=High、CPHA=2Edge(SPI Mode 3)。这颗传感器手册里写的是支持Mode 0和Mode 3,别的模式不用试。

这里多说一句为什么不用硬件NSS。STM32的硬件NSS行为比较繁琐,主模式下会自动拉低片选,但时序上和传感器要求的CS边沿配合不好,尤其是连续读FIFO时容易出现片选提前释放的问题。软件片选最直观:要通信就把CS拉低,通信完拉高,时序完全自己控制,排查问题也方便。

3.3 GPIO与DMA配置

SPI1的SCK、MOSI、MISO在CubeMX的Pinout视图里直接选AF5复用即可。CS(PA4)配成GPIO_Output,初始电平设为High。INT1(PB0)配成GPIO_EXTI,上升沿触发,使能对应的EXTI中断。

DMA这里建议先不配,等轮询读通之后再加。如果配DMA,在CubeMX里把SPI1_RX和SPI1_TX都加上,模式Normal,数据宽度Byte。我后续代码示例用的是HAL_UART_Transmit做调试输出,UART也开一下,波特率115200。

4. SPI寄存器读写与代码实现

4.1 理解IIS3DWB的寄存器访问协议

IIS3DWB的SPI访问方式和大多数ST传感器一致:每帧数据第一个字节是高7位寄存器地址加1位读写标志位(最高位为1表示读,0表示写)。CS低有效,SCLK时钟沿采样,MSB先行。

举个例子,读WHO_AM_I寄存器(地址0x0F)时,发送字节是0x8F。后面跟上1个字节的虚拟数据,接收到的第二个字节就是寄存器内容。写寄存器时,发送0x00|寄存器地址作为第一个字节,后面接要写入的数据。

为了连续读多个寄存器,CTRL3寄存器的IF_ADD_INC位要置1。这个位开启后,SPI读操作会自动递增寄存器地址,读FIFO或读三轴输出时一帧搞定,效率高很多。默认值我记得是1,但保险起见初始化时还是显式设置一次。

注意:不同批次的IIS3DWB寄存器地址可能有细微差异,以下代码中的寄存器定义以ST官方数据手册为准。我这边验证过的是常规量产版本,建议拿到芯片后先读一遍WHO_AM_I确认通信正常。

4.2 SPI底层读写函数模板

用HAL库做SPI读写非常直接,核心就是HAL_SPI_TransmitReceive。CS控制我用宏封装:

#define IIS3DWB_CS_LOW() HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET) #define IIS3DWB_CS_HIGH() HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET) uint8_t iis3dwb_spi_read_reg(uint8_t reg) { uint8_t tx_data[2]; uint8_t rx_data[2] = {0}; tx_data[0] = reg | 0x80; // 读标志 tx_data[1] = 0x00; // dummy byte IIS3DWB_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data, 2, 100); IIS3DWB_CS_HIGH(); return rx_data[1]; } void iis3dwb_spi_write_reg(uint8_t reg, uint8_t value) { uint8_t tx_data[2]; tx_data[0] = reg & 0x7F; // 写标志 tx_data[1] = value; IIS3DWB_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data, 2, 100); IIS3DWB_CS_HIGH(); }

这里要注意HAL_SPI_TransmitReceive的TX和RX长度必须一致,我是直接传同一个长度参数两个数组,读寄存器也一样,保证时钟数是对的。

4.3 多字节连续读取函数

如果要读FIFO或者三轴六字节数据,建议单独写一个多字节读取函数:

void iis3dwb_spi_read_multi(uint8_t reg, uint8_t *buf, uint16_t len) { uint8_t tx_data[1]; uint8_t rx_data[1]; // 第一个字节的接收数据不用 IIS3DWB_CS_LOW(); tx_data[0] = reg | 0x80; HAL_SPI_Transmit(&hspi1, tx_data, 1, 100); // 之后的字节都是时钟驱动,MCU发0x00同时收数据 for (uint16_t i = 0; i < len; i++) { tx_data[0] = 0x00; HAL_SPI_TransmitReceive(&hspi1, tx_data, &rx_data[0], 1, 100); buf[i] = rx_data[0]; } IIS3DWB_CS_HIGH(); }

这个函数在开启动态FIFO读取时非常关键,后面解析FIFO数据会用到。

4.4 芯片ID验证与初始化序列

拿到板子第一步永远是读WHO_AM_I。IIS3DWB10IS的WHO_AM_I寄存器地址是0x0F,正常应该返回0x7B。连上之后我习惯先做个基本检查:

uint8_t who_am_i = iis3dwb_spi_read_reg(0x0F); if (who_am_i == 0x7B) { // 通信正常 } else { // 打印错误,检查接线和SPI模式 }

如果这一步不对,别急着往下调,大概率是SPI模式、引脚复用或者时序问题。我当时就卡在这,后来发现是CPOL/CPHA设成了Mode 1,改成Mode 0立刻好。

接下来是初始化序列。CTRL1寄存器(0x20)用来设置ODR和量程,CTRL3寄存器(0x22)里有个IF_ADD_INC位,记得确认是1。我为了调试方便,先设置了默认的量程±2g和低功耗模式,代码里大概这样:

uint8_t ctrl1 = 0x00; ctrl1 |= (0x0F << 4); // ODR配置,具体值查手册 ctrl1 |= (0x00 << 2); // 量程 ±2g iis3dwb_spi_write_reg(0x20, ctrl1); uint8_t ctrl3 = iis3dwb_spi_read_reg(0x22); ctrl3 |= 0x02; // IF_ADD_INC = 1 iis3dwb_spi_write_reg(0x22, ctrl3);

关于CTRL1的ODR位域具体编码,不同版本手册有差异,最稳妥的做法是直接查你手里的数据手册对照表,不要凭记忆写死。我的经验是,如果ODR配错了,表现就是数据输出速率和你预期不一致,但通信本身正常,这个坑比较隐蔽。

4.5 中断引脚配置(可选)

如果打算用中断方式,把INT1配置成数据就绪输出。具体在CTRL2(0x21)或CTRL4里设置中断映射,把Data-Ready信号映射到INT1。我用的代码片段:

uint8_t ctrl2 = iis3dwb_spi_read_reg(0x21); ctrl2 |= 0x01; // 把DRDY映射到INT1,具体位以手册为准 iis3dwb_spi_write_reg(0x21, ctrl2);

INT1分配好之后,在STM32C5的EXTI回调里置一个标志位,主循环检测到标志位再去读数据。这样CPU不用空转轮询,后面接FFT和上位机逻辑时更从容。

5. 振动数据读取、解析与验证

5.1 输出数据格式与换算

IIS3DWB的三轴原始输出是16位有符号补码,分别存在OUT_X_L(0x28)、OUT_X_H(0x29)、OUT_Y_L(0x2A)、OUT_Y_H(0x2B)、OUT_Z_L(0x2C)、OUT_Z_H(0x2D)。读取顺序是低字节在前,所以拼数据时要先把低字节读出来再拼高字节:

int16_t raw_x = (int16_t)((uint16_t)buf[1] << 8 | buf[0]); int16_t raw_y = (int16_t)((uint16_t)buf[3] << 8 | buf[2]); int16_t raw_z = (int16_t)((uint16_t)buf[5] << 8 | buf[4]);

换算物理量时,要根据量程查灵敏度。IIS3DWB在±2g量程下灵敏度是0.061mg/LSB,±4g是0.122mg/LSB,±8g是0.244mg/LSB,±16g是0.488mg/LSB。换算公式为:

float acc_x_g = (float)raw_x * 0.061f / 1000.0f; // 单位 g

我一般会在代码里把量程和灵敏度做成宏或者查表,避免硬编码。后期如果要切量程,只改初始化配置和灵敏度宏两处即可。

5.2 静态验证:看Z轴是不是1g

硬件接好、代码烧进MCU之后,第一步验证不是看波形,而是把传感器平放在桌面上,读出来的Z轴加速度应该非常接近1g,X轴和Y轴接近0g。如果有晃动或者数值明显不对,多半是量程配置、数据拼字节或者SPI读取字节顺序出了问题。

我当时调试时遇到过一种情况:Z轴读出是0.5g左右。后来发现是FIFO模式配置有误,读出来的数据有一部分是旧数据,平均值就拉低了。所以静态测试时不要只读一帧,可以连续读100帧求平均,这样能过滤掉单帧异常。

5.3 FIFO模式配置与批量读取

高频采样时如果每来一个数据就触发一次中断、读一次寄存器,CPU占用率很高。IIS3DWB内置FIFO,可以把数据先缓存,等缓冲区半满或者满时再一次性读走。这在FFT场景下特别合适,因为FFT本来就要攒够一帧数据才能算。

FIFO相关的寄存器包括FIFO_CTRL和FIFO_SRC,具体地址以手册为准。配置思路是:把FIFO设为Stream模式(连续模式),再设置FIFO阈值,只要堆到N个样本就触发中断。我用的阈值一般是FIFO容量的一半,比如容量是64,就设32,这样永远有时间处理,不会溢出。

读FIFO时用多字节读取函数,一次性读出当前FIFO里的所有数据。代码逻辑大概是:

#define FIFO_SAMPLE_COUNT 32 uint8_t fifo_buf[FIFO_SAMPLE_COUNT * 6]; // 从FIFO读取当前水位 uint8_t fifo_src = iis3dwb_spi_read_reg(FIFO_SRC_REG); uint8_t level = fifo_src & 0x1F; // 低5位是当前样本数 if (level > 0) { iis3dwb_spi_read_multi(OUT_X_L, fifo_buf, level * 6); // 解析 level 个样本 }

FIFO模式下数据的字节顺序和直接读三轴输出寄存器是一致的,所以解析代码可以复用。这里要特别留意FIFO_SRC的低5位才是FIFO水位,高几位是状态标志,如果直接读整个字节当level用,会出现越界读的问题。

5.4 串口输出与上位机联调

调试阶段我习惯用串口把原始值和换算后的g值打出来,格式尽量简单,方便串口助手或者脚本解析。比如:

X=0.012g Y=-0.003g Z=0.998g

这种格式可读性强,同时可以每100帧汇总一次最大值最小值,观察振动冲击事件。串口波特率115200,调试足够用。如果后面的数据量很大,可以把SPI读取和串口发送分开,用DMA+缓冲区,避免互相阻塞。

5.5 用真实振动源做验证

静态验证通过后,找一个可重复的振动源,比如小电机或者音叉,把传感器固定上去,观察输出是否有规律波动。更进阶的验证是用信号发生器驱动一个激振器,但一般实验室用手机振动马达也能凑合。

我这边用小马达做了个简易测试,转速大约3000rpm,对应基频50Hz。传感器粘在电机外壳上,FFT后能看到明显的50Hz峰,还能看到一些2倍频、3倍频的谐波,说明这套采集链路是能反映真实振动的。这一步做完,SPI获取振动数据这件事基本就闭环了。

6. 常见问题排查与调试建议

6.1 问题速查表

我整理了一份实际操作中遇到最多的问题和解决思路:

现象可能原因解决方向
WHO_AM_I读出来全是0xFFMISO没接好/SPI模式不对检查SDO引脚、换SPI Mode 3试试
WHO_AM_I读出来全是0x00MOSI或者CS时序问题用逻辑分析仪抓CS和SCLK波形
读出的数据不变化ODR配置为0/传感器进入低功耗停机检查CTRL1配置
Z轴不是1g字节顺序反了/灵敏度用错低字节在前拼接、按量程换灵敏度
FIFO读出来数据乱跳FIFO水位判断错误检查FIFO_SRC的低5位掩码
中断一直触发INT1极性配置错确认中断是高有效还是低有效
SPI速度上来后丢帧没开DMA/CPU处理不过来上DMA/FIFO批量读取

6.2 踩坑实录:最让我头疼的三个问题

第一个坑是SPI模式设置。开始我用的是Mode 1,结果WHO_AM_I读出来随机值,时好时坏。后来用逻辑分析仪抓时序,发现SCLK空闲是高电平,和传感器的采样沿对不上。改成Mode 0之后一切正常。这个案例说明遇到SPI问题,先抓时序图,别瞎猜。

第二个坑是CS片选时序。我一开始用了STM32的硬件NSS,CS自动控制,结果读FIFO时总是错位,一个样本的最后一个字节会跑到下一个样本的第一个字节。改成软件CS,通信前拉低、读完后拉高,问题立刻解决。软件片选虽然多两行代码,但可控性提升巨大。

第三个坑是FIFO水位解析。FIFO_SRC寄存器高几位有溢出标志和FIFO满标志,我早期直接把整个寄存器的值当成水位用,导致一次读多了数据,缓冲越界。后来按手册加上0x1F掩码才正常。任何寄存器都不要整字节直接信,该掩码就要掩码。

6.3 调试工具推荐

有条件的话,逻辑分析仪是SPI调试的神器。我用的24MHz采样率的入门级逻辑分析仪,抓SPI时序绰绰有余。重点观察CS低电平期间SCLK的沿和MOSI/MISO的数据对应关系。超过10MHz的SPI时钟逻辑分析仪可能吃力,但IIS3DWB的7.8MHz完全覆盖。

如果没有逻辑分析仪,可以先用GPIO翻转法,在代码里手动拉高拉低CS,然后用示波器看SCLK和MISO。示波器时间基准调小一点,抓几个帧,也能确认基本时序。不过论效率,还是逻辑分析仪省心。

6.4 性能优化的进阶方向

裸SPI读通以后,往高性能方向走有几条路:第一是用DMA方式搬数据,SPI读FIFO不占CPU。第二是用两个缓冲区交替使用,采集数据往缓冲A写的时候,CPU处理缓冲B的数据,实现流水线。第三是利用IIS3DWB的FIFO中断触发DMA传输,从传感器到内存全自动,CPU只在DMA传输完成中断里处理数据。这三步做完,即使跑26.7kHz的满速率采样,CPU占用率也能压得很低。

从我个人经验来看,这套“STM32C5 + IIS3DWB10IS + SPI”方案的调试门槛并不高,关键在于先慢后快、先轮询后DMA、先静态后动态,一步步验证。寄存器层面的细节确实多,但每踩一个坑都能让下一版更顺。下一篇我会接着写FFT频谱分析怎么做、如何在时域和频域之间切换视角。如果你正在做振动监测相关的项目,先把SPI基础通信打牢,后面所有分析才有可靠的数据源。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 3:47:27

Kubernetes HPA实战:基于CPU与QPS指标实现微服务智能弹性伸缩

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 3:45:39

Vibe Coding:打造沉浸式高效开发环境的心法与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 3:44:56

构建高可用AI应用:从服务依赖到韧性架构的设计与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 3:42:45

SolidWorks工程图自动标注:从配置到实战的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 3:40:08

用Vision Pro将2D户型图变3D空间:Fusion 360建模与USDZ转换全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华