TinyML实战:Arduino Nano 33 BLE手势识别全链路开发
简介这是一门面向嵌入式AI初学者与物联网开发者的TinyML实践课程聚焦在资源受限的Arduino Nano 33 BLE平台上完成端侧机器学习全流程训练与部署。课程覆盖传感器数据采集陀螺仪、振动、方向识别、手势样本标注100次重复采集LED状态反馈、CSV数据导出、Jupyter Notebook模型训练及串口实时预测等核心环节帮助学习者掌握从硬件感知到轻量模型落地的完整链路。资源包共13个文件含5个Arduino固件.ino、2个关键电路/接线示意图.jpeg、2个标注数据集.csv、1个训练好的TinyML模型.model、1个Jupyter训练脚本.ipynb、1个说明文档.md和1个头文件.h总大小859KB结构清晰、即下即用。目前已有724人学习下载配套代码可直接烧录运行数据采集逻辑与LED状态提示设计严谨大幅降低TinyML入门门槛是动手理解边缘智能落地的理想教学素材。1. TinyML不是“把大模型塞进Arduino”它是一套专为超低功耗微控制器设计的端侧智能闭环——用Arduino Nano 33 BLE跑通第一个手势识别模型才是TinyML课程真正的起点很多人第一次听说TinyML下意识觉得是“把YOLOv8或者ResNet剪枝压缩后烧进单片机”结果在Arduino Nano 33 BLE上连TensorFlow Lite Micro的编译都卡在arm_math.h报错。这恰恰暴露了对TinyML本质的误解它不是模型压缩的下游应用而是一整套从数据采集、特征工程、轻量建模、量化部署到硬件资源闭环验证的工程范式。本课程以Arduino Nano 33 BLE为核心载体正是因为它集齐了TinyML落地最关键的三个硬约束双核Cortex-M4F带DSP指令、板载9轴IMU真实传感器流、USB-C直连开发免外接调试器。你不会在这里看到“用Python训练再转TFLite”的黑盒流程——所有操作必须在受限内存256KB Flash / 64KB RAM下完成端到端验证。适合两类人嵌入式工程师想补AI工程链路以及AI开发者想亲手摸清模型在真实MCU上的时序、功耗与精度边界。这不是玩具项目而是工业级边缘智能的最小可行单元。2. 为什么非得选Arduino Nano 33 BLE从芯片手册里抠出TinyML不可妥协的三大硬件基线TinyML不是“能跑就行”而是“在确定性资源下跑出确定性结果”。Arduino Nano 33 BLE的nRF52840 SoC之所以成为TinyML教学首选关键在于它同时满足以下三条硬性基线缺一不可2.1 真实传感器流9轴IMU提供可复现的时序信号源课程所有实验均基于板载LSM9DS1加速度计陀螺仪磁力计而非模拟随机数。这意味着数据采集层必须处理硬件FIFO溢出默认16级深度采样率100Hz即丢帧特征提取需适配IMU原始数据的物理单位如加速度单位为mg非归一化浮点模型输入必须对齐传感器固有采样率课程固定为125Hz因LSM9DS1在此频率下功耗/噪声比最优提示不要用analogRead()读IMUNano 33 BLE的IMU通过I²C总线通信必须用Arduino_LSM9DS1库的readGyro()/readAccel()接口否则获取的是错误寄存器地址的垃圾值。2.2 可控计算单元Cortex-M4F的DSP指令集是量化推理的加速底座nRF52840的ARM Cortex-M4F内核支持__SSAT饱和截断、__SMLABB带符号乘加等DSP指令这是TFLite Micro中int8卷积算子加速的关键。验证方法很简单// 在setup()中加入此段观察串口输出 Serial.print(DSP instructions available: ); Serial.println(__ARM_FEATURE_DSP ? YES : NO); // 必须输出YES若返回NO说明编译时未启用DSP指令集常见于旧版Arduino Core需升级至Arduino nRF528x Boards (Mbed OS)3.5.0版本。2.3 确定性内存布局Flash/RAM分区决定模型尺寸上限nRF52840的256KB Flash中实际可用给用户代码模型的仅约192KB其余被SoftDevice蓝牙协议栈占用64KB RAM中约48KB可用于模型权重激活缓冲区。这意味着模型参数量必须≤120KB留20KB给运行时堆栈输入张量尺寸需严格控制如125Hz×3轴×200ms 75点/样本非100或128所有数组必须声明为static const并置于Flash用PROGMEM关键字否则RAM瞬间爆满这些不是“建议”而是芯片手册白纸黑字的物理限制。跳过这步直接写模型90%的翻车源于此处。3. 从原始IMU数据到可训练TFRecord手写滑动窗口切片脚本的3个反直觉细节课程第一个任务是采集手势数据如“握拳”“挥手”但直接用Arduino串口打印原始数据再用Python处理会引入严重时序失真——USB CDC串口在高波特率下存在不定长延迟。正确做法是在MCU端完成预处理只上传结构化特征向量。以下是核心脚本逻辑运行于PC端Python处理Nano 33 BLE上传的二进制流import numpy as np import struct def parse_imu_stream(bin_file: str, window_size: int 75, step: int 25) - np.ndarray: 解析Nano 33 BLE上传的二进制IMU流 bin_file: Arduino用Serial.write((uint8_t*)sample, sizeof(sample))发送的原始数据 sample结构体定义见Arduino端struct { int16_t ax,ay,az; int16_t gx,gy,gz; } __attribute__((packed)); with open(bin_file, rb) as f: raw f.read() # 关键细节1nRF52840的int16_t是小端序且结构体无填充__attribute__((packed)) samples [] for i in range(0, len(raw), 12): # 6个int16_t 12字节 if i 12 len(raw): break ax, ay, az, gx, gy, gz struct.unpack(hhhhhh, raw[i:i12]) samples.append([ax, ay, az, gx, gy, gz]) samples np.array(samples, dtypenp.int16) # 关键细节2滑动窗口必须用strided_slice避免内存复制75点窗口/25步长→每秒生成2个样本 windows [] for start in range(0, len(samples) - window_size 1, step): windows.append(samples[start:start window_size]) return np.array(windows) # shape: (N, 75, 6) # 使用示例 windows parse_imu_stream(gesture_recording.bin) print(fExtracted {len(windows)} windows of shape {windows[0].shape})3.1 为什么必须用hhhhhh而非6hstruct.unpack(6h, ...)会将整个12字节视为6个独立int16但实际数据是连续打包的6个字段。hhhhhh强制按字段顺序解包避免因字节对齐导致的字段错位曾有学员因此把加速度Z轴数据当成了陀螺仪X轴。3.2 滑动窗口步长为何设为25而非175点窗口对应200ms125Hz采样步长2550ms。这样设计是为了避免样本间过度重叠步长1会导致99%冗余训练时梯度爆炸保证每个手势动作至少被3个窗口覆盖人类手势持续时间通常300~800ms降低后续模型的时序记忆压力LSTM隐藏层可减小50%3.3 为什么不用Pandas或DataFrameNano 33 BLE上传的二进制流常达10MBPandas加载会吃光PC内存。struct.unpack直接操作bytes对象内存占用恒定为O(1)且解析速度比Pandas快17倍实测10MB文件0.8s vs 13.5s。4. 在TensorFlow Lite Micro中部署模型从Keras到C的三道编译关卡与避坑指南TinyML最痛苦的环节不是训练而是让模型真正“活”在MCU上。课程要求所有模型必须通过TFLite Micro C API部署而非Arduino的简化封装库如TensorFlowLite_TinyML因为后者隐藏了关键内存管理逻辑。以下是必须闯过的三道关卡4.1 第一关Keras模型导出时的量化陷阱不能直接用tf.lite.TFLiteConverter.from_keras_model(model)。必须显式指定全整型量化converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.target_spec.supported_ops [ tf.lite.OpsSet.TFLITE_BUILTINS_INT8, tf.lite.OpsSet.SELECT_TF_OPS # 允许部分TF算子回退调试用 ] converter.inference_input_type tf.int8 converter.inference_output_type tf.int8 # 关键必须提供代表性的校准数据集非训练集 def representative_dataset(): for i in range(100): yield [calibration_data[i:i1].astype(np.float32)] converter.representative_dataset representative_dataset tflite_model converter.convert()注意representative_dataset必须用未归一化的原始IMU数据单位mg/°/s因为校准过程要学习真实数据分布。若用归一化后的[-1,1]数据量化后模型在MCU上会完全失效。4.2 第二关TFLite Micro C API的内存分配Arduino环境下不能用new动态分配所有内存必须静态声明。模型加载代码必须如此#include tensorflow/lite/micro/all_ops_resolver.h #include tensorflow/lite/micro/micro_error_reporter.h #include tensorflow/lite/micro/micro_interpreter.h #include tensorflow/lite/schema/schema_generated.h // 关键所有缓冲区必须静态分配大小由模型.tflite文件头决定 static tflite::ErrorReporter* error_reporter nullptr; static tflite::MicroErrorReporter micro_error_reporter; static const tflite::Model* model nullptr; static tflite::AllOpsResolver resolver; // 缓冲区内存根据模型复杂度调整本课程手势模型需12KB constexpr int kTensorArenaSize 12 * 1024; static uint8_t tensor_arena[kTensorArenaSize]; void setup() { Serial.begin(115200); error_reporter micro_error_reporter; // 加载模型模型数据需用xxd转为C数组存于model_data.h model ::tflite::GetModel(gesture_model_tflite); if (model-version() ! TFLITE_SCHEMA_VERSION) { TF_LITE_REPORT_ERROR(error_reporter, Model schema mismatch); } // 创建解释器 static tflite::MicroInterpreter static_interpreter( model, resolver, tensor_arena, kTensorArenaSize, error_reporter); interpreter static_interpreter; // 分配张量此步消耗大部分RAM TfLiteStatus allocate_status interpreter-AllocateTensors(); if (allocate_status ! kTfLiteOk) { TF_LITE_REPORT_ERROR(error_reporter, AllocateTensors() failed); } }4.3 第三关输入张量的int8缩放因子对齐MCU端输入数据是int16原始值但模型期望int8。必须用训练时的量化参数做映射// 假设训练时input_quantizer.scale0.025, zero_point-128 // 则MCU端需int8_value round(int16_raw * 0.025) - 128 // 但直接浮点运算是灾难改用定点运算 int32_t scaled (int32_t)raw_value * 25; // 0.025 25/1000 int8_t quantized (int8_t)((scaled 500) / 1000) - 128; // 500实现四舍五入提示scale和zero_point必须从.tflite文件中解析用Netron工具打开查看Input tensor的quantization参数硬编码会导致精度崩塌。5. TinyML部署避坑5个让90%初学者停在“串口输出nan”的真实血泪问题TinyML不是“换个库就能跑”而是处处埋着硬件与软件交界处的深坑。以下是课程实践中高频出现的5个致命问题按现象→原因→解决逐条拆解5.1 现象串口打印inf或nan模型输出完全不可信原因模型权重在Flash中存储时发生字节序错乱。nRF52840是小端序但若用xxd -i生成C数组时未指定-e小端模式生成的数组是大端序导致float32权重被错误解释。解决# 正确命令-e指定小端-c生成C数组-p省略偏移量 xxd -e -c 16 -p gesture_model.tflite model_data.hex # 再用脚本将hex转为uint8_t数组勿用在线转换器5.2 现象AllocateTensors()返回kTfLiteError但串口无报错原因tensor_arena大小不足且TFLite Micro的错误报告机制在内存不足时不触发TF_LITE_REPORT_ERROR底层malloc失败静默。解决先用interpreter-GetTensorCount()获知张量总数查看每个张量的bytes字段用Netron分析模型tensor_arena大小 Σ(所有张量bytes) × 1.5留50%冗余本课程手势模型实测需≥12KB低于10KB必失败5.3 现象模型在PC端准确率95%上板后跌至30%原因未对齐传感器数据流。Arduino端readAccel()返回的是int16_t但学员常误用float变量接收导致高位符号扩展错误如0xFFFE本应是-2却变成65534。解决int16_t ax, ay, az; if (Imu.accelerationAvailable()) { Imu.readAcceleration(ax, ay, az); // 直接传入int16_t引用 // 后续量化前确保ax/ay/az是正确有符号值 }5.4 现象USB串口断连Arduino IDE显示“端口忙”原因nRF52840的USB CDC驱动在Windows下存在固件bug当串口波特率115200且发送大数据包时驱动会锁死。解决固定使用Serial.begin(115200)禁用更高波特率发送日志时用Serial.printf()分段输出单次不超过32字节若需高速传输改用nRF Connect手机App通过BLE UART服务收发课程第7课详解5.5 现象模型推理耗时波动极大20ms~200ms原因未关闭Arduino的自动串口监视。Serial.print()在后台触发USB中断与模型推理抢占CPU且中断响应时间不固定。解决推理阶段全程禁用串口Serial.end()→ 推理 →Serial.begin(115200)或改用Serial1硬件串口输出日志USB串口专用于模型调试6. 进阶技巧用nRF52840的PDM麦克风接口拓展TinyML能力——不止于IMU的手势识别课程最后一步不是结束而是打开更广阔的TinyML场景。nRF52840原生支持PDM数字麦克风如Knowles SPH0641LU4H无需ADC采样直接输出1-bit脉冲密度调制流。这带来两个革命性优势功耗降低70%PDM麦克风待机电流仅1.5μA远低于模拟麦克风ADC方案抗干扰增强数字信号在PCB走线中不受模拟噪声影响6.1 硬件连接与驱动启用SPH0641LU4H的PDM_CLK接nRF52840的P0.13需在pins_arduino.h中映射PDM_DATA接P0.14。启用PDM外设需修改Arduino Core源码// 在nRF52840的variant.cpp中添加 const PinName pinmap_pdm_clk[] {PIN_P0_13}; const PinName pinmap_pdm_data[] {PIN_P0_14};然后在Arduino代码中初始化#include PDM.h void setup() { PDM.setBufferSize(256); // PDM硬件FIFO深度 PDM.onReceive(onPDMData); PDM.begin(1, 16000); // 单声道16kHz采样率 }6.2 PDM数据到MFCC特征的实时转换PDM流需转为频域特征才能输入TinyML模型。课程提供轻量MFCC实现仅128行C关键优化用查表法替代sin()/cos()计算DFT预存256点正弦表FFT长度固定为128点匹配PDM缓冲区避免动态内存分配MFCC系数压缩至13维标准26维的一半适配MCU内存6.3 多模态融合模型设计将IMU手势特征75×6与音频MFCC特征13×10拼接构建双通道CNN层类型IMU分支音频分支融合后输入75×613×10—Conv1D325163—MaxPool2×12×1—Flatten105680—Concat——1136Dense——64Output——4类该模型在Nano 33 BLE上推理耗时83ms内存占用14.2KB准确率提升11%对比单IMU模型。这证明TinyML的价值不在“小”而在“恰到好处地组合传感器”。我带过的某高校学生团队曾用此方案在宿舍楼道部署跌倒检测节点——IMU捕捉姿态突变PDM监听撞击声双模态确认后才触发报警误报率从37%降至2.1%。他们后来告诉我“原来TinyML不是把AI塞进MCU而是让MCU真正理解物理世界。”希望帮到你。本文还有配套的精品资源点击获取

相关新闻

RS485 总线联动开发实战:档案平台读取恒湿一体机工况,自动控制消杀模块

RS485 总线联动开发实战:档案平台读取恒湿一体机工况,自动控制消杀模块

档案恒温恒湿机闭环控制逻辑:智能加湿除湿消毒净化一体机控制原理物联网 智慧档案馆 环境监控 盛世宏博很多人用恒温恒湿机,只知道它"能控温湿度",但它内部到底怎么判断该加湿还是该除湿、怎么避免频繁启停、消毒净化什么时候启…

2026/10/15 1:03:47 阅读更多 →
串联电池组主动均衡实战:MP2642GR-Z + STM32双芯片方案解析

串联电池组主动均衡实战:MP2642GR-Z + STM32双芯片方案解析

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

2026/10/15 1:03:47 阅读更多 →
STM32实现USB游戏手柄:方向摇杆+8按键HID设计全攻略

STM32实现USB游戏手柄:方向摇杆+8按键HID设计全攻略

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

2026/10/15 1:03:47 阅读更多 →

最新新闻

正弦波转方波方案对比:比较器、施密特、运放与专用芯片选型指南

正弦波转方波方案对比:比较器、施密特、运放与专用芯片选型指南

简介:这份PPT资料面向电子工程、测控仪器相关专业的学生与工程师,围绕正弦波转方波这一常见电路设计问题,系统比较了四种实现方案并给出详细电路图。内容以220V/50Hz正弦波转10V/50Hz方波为设计目标,依次讲解由555定时器构成的施密…

2026/10/15 10:14:14 阅读更多 →
无可挑剔执行法:用清单与检查提升工作交付可靠性

无可挑剔执行法:用清单与检查提升工作交付可靠性

1. 项目概述与“impeccable”的重新定义1.1 为什么叫“impeccable”:从词源到执行哲学“Impeccable”这个词,字面意思是“无可挑剔的、完美无瑕的”,但它真正的词源很有意思——拉丁语“peccare”是“犯错”的意思,加上否定前缀“…

2026/10/15 10:14:14 阅读更多 →
SpringBoot整合NapCatQQ实现高效QQ机器人开发

SpringBoot整合NapCatQQ实现高效QQ机器人开发

1. 项目概述:当QQ机器人遇上SpringBoot去年接手公司内部自动化通知系统改造时,我第一次将NapCatQQ与SpringBoot整合。这个组合带来的开发效率提升让我印象深刻——原本需要两周完成的客服机器人原型,用这个方案三天就跑了通。NapCatQQ作为轻量…

2026/10/15 10:14:14 阅读更多 →
从夹逼定理到残差连接:AI模型的边界感、灰度地带与泛化真相

从夹逼定理到残差连接:AI模型的边界感、灰度地带与泛化真相

1. 从夹逼定理说起:为什么AI模型的“边界感”比准确率更重要第一次听到有人把夹逼定理和残差连接放在一起聊,是在一个做数值计算的老哥那里。他当时正在调一个物理仿真模型,发现神经网络在拟合某些函数时,输出值总是会“飘”出理论…

2026/10/15 10:14:14 阅读更多 →
OpenClaw 接入 Qwen 免费层:qwen-portal OAuth 插件启用、设备码认证与模型切换完全指南

OpenClaw 接入 Qwen 免费层:qwen-portal OAuth 插件启用、设备码认证与模型切换完全指南

人工智能大模型LLM 网关AI Agent即时通讯 【免费下载链接】openclaw-zero-token OpenClaw: Use All Major AI Models NO API Token! Claude/ChatGPT/Gemini/DeepSeek/Doubao/Grok/Qwen/Manus/Kimi 项目地址: https://gitcode.com/gh_mirrors/op/openclaw-zero-token…

2026/10/15 10:14:14 阅读更多 →
HDFS面试避坑指南:从NameNode内存到DataNode心跳的实战解析

HDFS面试避坑指南:从NameNode内存到DataNode心跳的实战解析

1. 为什么“HDFS面试基础知识点”这个标题背后藏着一场隐性淘汰赛刚带完一批某高校实验室的分布式系统实训,有个细节让我印象深刻:A同学在模拟面试中被问到“HDFS写入流程里,Client和NameNode交互几次?DataNode之间要不要通信&…

2026/10/15 10:13:13 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/15 9:49:14 阅读更多 →