简介面向Android开发者与嵌入式联调人员的串口通信工具源码聚焦在移动端完成串口设备连接、参数配置与数据收发这一常见需求适合具备一定Android基础、希望打通硬件交互链路的初中级开发者参考。压缩包共43个文件、约469KB以7个java源文件为核心辅以6个so动态库、5个xml布局与配置、3个jar依赖及jni目录下的c、h、mk等原生构建文件另有少量图片与工程配置覆盖从Java层调用到本地库编译的完整结构。已有1794人学习下载具备一定参考基础。源码围绕权限声明、波特率与数据位等串口参数配置、后台线程读写、异常捕获、设备枚举与连接状态反馈等环节展开并包含界面输入与接收展示的示例实现读者可据此理解串口通信的分层组织方式对照自身项目排查权限、线程阻塞与设备兼容等问题快速搭建可运行的调试工具雏形。1. 一块开发板插上手机串口调试为什么总在第一步卡住做 Android 串口通信的开发者大概都遇到过这个场景板子已经焊好USB 转 TTL 线也接上了手机插上去却什么反应都没有日志里连个设备节点都看不到。问题往往不在协议层而在最底层——权限、设备枚举、波特率配置任何一环没对上后面全是空谈。这份 Android 串口链接工具源码解决的就是这个「第一步」的问题它把打开串口、配置参数、收发数据这条链路完整封装出来适合做工业手持机、智能硬件调试、嵌入式设备配套 App 的开发者直接拿来改。你拿到手不用从零啃驱动层先把链路跑通再谈业务逻辑。2. 项目定位与技术选型为什么是 JNI 加 NDK 这条路2.1 这个工具到底在做什么从标题就能看出来这是一个「串口链接工具」核心职责是把 Android 设备和串口设备之间的数据通道打通。它不是一个完整的业务 App而是一个可复用的通信底座上层拿到的是打开、关闭、读、写这几个动作底层替你处理 Linux 设备节点、termios 参数、文件描述符这些脏活。Android 本身没有开放标准串口 API系统层面对串口的支持是缺失的。所以这类项目的通用做法是走 JNI把 C 层的串口操作编译成 so 库Java 层通过 native 方法调用。这份源码遵循的正是这条主流路线常见做法是用 CMake 组织 native 代码用open()打开/dev/tty*节点用tcsetattr配置波特率、数据位、停止位、校验位再用read/write做数据搬运。适合谁一类是做智能硬件配套 App 的设备通过串口吐数据App 负责展示和控制另一类是做工业手持终端、POS 机、门禁设备的串口是标配外设通道还有一类是调试工具开发者需要一个稳定的串口收发面板。如果你只是想在 App 里读个传感器蓝牙或 USB HID 可能更省事但只要设备端只给串口这套代码就是绕不开的基础设施。2.2 技术栈拆解Java 层、JNI 层、Native 层三段式整个项目在结构上是清晰的三段式这也是 Android 串口项目的标准分层层级语言职责关键文件类型应用层Java/Kotlin对外 API、回调分发、线程管理.java/.kt桥接层JNIJava 与 C 的类型转换、方法注册.cpp/.c原生层C/C设备打开、termios 配置、读写.c/.cpp应用层负责把 native 的阻塞读写包装成回调或队列避免主线程被卡住桥接层负责jstring和char*的互转、异常抛出原生层才是真正跟内核打交道的地方。这种分层的价值在于换设备、换波特率只动配置换业务只动上层互不干扰。构建侧用的是 NDK CMakebuild.gradle里配置externalNativeBuild把CMakeLists.txt挂进去编译产物是各 ABI 的libserial_port.so。这里有个血泪经验ABI 一定要配全armeabi-v7a、arm64-v8a、x86_64至少覆盖主流设备否则在某些机型上会直接UnsatisfiedLinkError日志里还看不出所以然。2.3 目录结构先认清每个文件夹的角色拿到源码先别急着点运行把目录捋一遍能省很多时间。典型结构如下serial-port-demo/ ├── app/ │ ├── src/main/java/com/example/serial/ │ │ ├── SerialPort.java # Java 层入口native 方法声明 │ │ ├── SerialPortFinder.java # 扫描 /dev 下的串口节点 │ │ └── SerialPortUtil.java # 收发封装、回调 │ ├── src/main/cpp/ │ │ ├── SerialPort.c # 核心 native 实现 │ │ └── CMakeLists.txt # native 构建脚本 │ └── build.gradle # NDK、ABI、CMake 配置 └── settings.gradleSerialPort.java是门面所有 native 方法在这里声明SerialPortFinder.java负责枚举设备节点因为不同板子的串口名可能是ttyS0、ttyS1、ttyUSB0不能写死SerialPort.c是真正干活的地方。把这三者的关系理清后面读代码就不会迷路。3. 核心模块拆解从打开设备到收发数据的完整链路3.1 Java 层入口native 方法怎么声明和加载先看 Java 层的门面类。它的职责很单纯声明 native 方法、加载 so 库、持有文件描述符。代码大致长这样public class SerialPort { private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; // 加载 native 库库名要和 CMakeLists 里的 target 一致 static { System.loadLibrary(serial_port); } // 打开串口路径、波特率、标志位 public native static FileDescriptor open(String path, int baudrate, int flags); // 关闭串口释放 fd public native void close(); public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException { // 先做权限检查避免 open 返回 -1 却不知道原因 if (!device.canRead() || !device.canWrite()) { try { Process su Runtime.getRuntime().exec(/system/bin/su); // 通过 su 改权限仅 root 设备可用 String cmd chmod 666 device.getAbsolutePath() \n exit\n; su.getOutputStream().write(cmd.getBytes()); if (su.waitFor() ! 0 || !device.canRead() || !device.canWrite()) { throw new SecurityException(串口设备无读写权限); } } catch (Exception e) { throw new SecurityException(串口设备无读写权限, e); } } mFd open(device.getAbsolutePath(), baudrate, flags); if (mFd null) { throw new IOException(native open 返回空); } mFileInputStream new FileInputStream(mFd); mFileOutputStream new FileOutputStream(mFd); } }这段代码有几个关键点值得说。System.loadLibrary(serial_port)里的名字必须和CMakeLists.txt中add_library(serial_port ...)的 target 名完全一致差一个字母就是UnsatisfiedLinkError。open返回的是FileDescriptor拿到之后包成FileInputStream/FileOutputStream这样上层就能用标准的流式 API 读写不用自己管缓冲区。权限那段是很多新手翻车的地方。普通 App 没有权限直接打开/dev/ttyS*canRead()/canWrite()返回 false 时代码尝试用su提权。注意这只是兜底量产设备一般会通过ueventd.rc或 SELinux 策略给 App 授权而不是依赖 root。如果你的设备没 root这段会抛SecurityException属于预期行为别以为是 bug。3.2 JNI 桥接方法注册与类型转换JNI 层要做两件事把 Java 的open映射到 C 函数把 C 的fd包装成 Java 的FileDescriptor。方法注册有两种方式静态注册靠命名约定动态注册靠JNINativeMethod数组。这份源码常见做法是静态注册函数名形如Java_com_example_serial_SerialPort_open。#include jni.h #include fcntl.h #include termios.h #include unistd.h // 对应 Java 层 SerialPort.open JNIEXPORT jobject JNICALL Java_com_example_serial_SerialPort_open(JNIEnv *env, jclass clazz, jstring path, jint baudrate, jint flags) { const char *path_utf (*env)-GetStringUTFChars(env, path, NULL); int fd open(path_utf, O_RDWR | O_NOCTTY | flags); (*env)-ReleaseStringUTFChars(env, path, path_utf); if (fd -1) { return NULL; // Java 层据此抛 IOException } // 配置 termios 参数 struct termios cfg; if (tcgetattr(fd, cfg) ! 0) { close(fd); return NULL; } cfmakeraw(cfg); // 原始模式不做行缓冲 cfsetispeed(cfg, baudrate); // 输入波特率 cfsetospeed(cfg, baudrate); // 输出波特率 cfg.c_cflag | (CLOCAL | CREAD); // 忽略调制解调器控制启用接收 cfg.c_cflag ~CSIZE; cfg.c_cflag | CS8; // 8 数据位 cfg.c_cflag ~PARENB; // 无校验 cfg.c_cflag ~CSTOPB; // 1 停止位 tcsetattr(fd, TCSANOW, cfg); // 立即生效 // 把 fd 包装成 Java 的 FileDescriptor jclass fdClass (*env)-FindClass(env, java/io/FileDescriptor); jfieldID fdField (*env)-GetFieldID(env, fdClass, descriptor, I); jobject fdObj (*env)-NewObject(env, fdClass, (*env)-GetMethodID(env, fdClass, init, ()V)); (*env)-SetIntField(env, fdObj, fdField, fd); return fdObj; }逐段解释。open的标志位里O_NOCTTY很关键它告诉内核这个终端不要成为进程的控制终端否则某些信号会干扰通信。cfmakeraw把终端设成原始模式关掉回显、行缓冲、特殊字符处理串口通信必须这样不然你发出去的数据会被终端层篡改。波特率这里有个坑cfsetispeed接受的是B115200这类常量不是数字 115200。如果 Java 层直接传 115200需要在 C 层做一次映射常见做法是写个switch把整数转成Bxxx常量。源码里如果没做这层转换你传 115200 会得到乱码或配置失败这是排查时第一个要看的地方。最后包装FileDescriptor用的是反射式字段访问descriptor字段是 int 类型存的就是 fd 值。这样 Java 层拿到的FileDescriptor是「真」的FileInputStream才能正常工作。3.3 原生读写阻塞模型与线程配合打开之后就是读写。native 层的read/write是阻塞的这意味着不能在主线程调用否则 UI 直接卡死。源码的常见处理是把读操作放到独立线程里循环// 读线程持续从串口取数据通过回调抛给业务层 private class ReadThread extends Thread { Override public void run() { byte[] buffer new byte[1024]; while (!isInterrupted()) { try { int size mFileInputStream.read(buffer); if (size 0 mListener ! null) { byte[] data new byte[size]; System.arraycopy(buffer, 0, data, 0, size); mListener.onDataReceived(data); // 回调到业务层 } } catch (IOException e) { break; // 串口被关闭或异常退出循环 } } } }mFileInputStream.read(buffer)会一直阻塞到有数据或出错所以放在while里是合理的。缓冲区大小 1024 是经验值工业设备单帧一般不超过这个量级如果你的协议帧更大调到 4096 也行。回调里把数据 copy 出来再抛避免业务层持有复用缓冲区导致数据错乱这个细节很多示例代码会忽略。写操作相对简单mFileOutputStream.write(data)即可但要注意flush()否则数据可能停在缓冲区没发出去。另外写操作也要放到子线程尤其是数据量大或设备响应慢的时候。3.4 设备节点扫描为什么不能写死 ttyS0不同硬件的串口节点名不一样写死ttyS0在 A 板子上能跑换 B 板子就找不到设备。所以源码里通常有个SerialPortFinder遍历/dev目录匹配ttyS、ttyUSB、ttyACM等前缀public VectorString getAllDevices() { VectorString devices new Vector(); File dev new File(/dev); File[] files dev.listFiles(); if (files null) return devices; for (File f : files) { String name f.getName(); // 匹配常见串口节点命名 if (name.startsWith(ttyS) || name.startsWith(ttyUSB) || name.startsWith(ttyACM)) { devices.add(f.getAbsolutePath()); } } return devices; }这个扫描逻辑简单但够用。实际项目里我一般会再加一层过滤读/sys/class/tty/*/device/driver判断驱动类型或者用UsbManager拿到 USB 转串口的 VID/PID 做精确匹配。但作为工具源码前缀匹配已经能覆盖大部分场景先跑通再优化。4. 跑起来编译、部署与第一次收发验证4.1 环境准备与 Gradle 配置跑这个项目需要 Android Studio、NDK、CMake 三件套。NDK 版本建议用 r21 以上太老的版本对 CMake 支持不好。build.gradle里关键配置如下android { compileSdk 33 defaultConfig { applicationId com.example.serial minSdk 21 targetSdk 33 ndk { // 按需裁剪 ABI减小包体 abiFilters armeabi-v7a, arm64-v8a, x86_64 } externalNativeBuild { cmake { cppFlags -stdc11 } } } externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt version 3.18.1 } } }minSdk 21是串口项目的常见下限再低的话部分 NDK API 不可用。abiFilters里如果只留arm64-v8a包体会小很多但老设备装不上调试阶段建议全留发布前再裁。CMakeLists.txt里要确保add_library的 target 名和System.loadLibrary一致target_link_libraries里链上log库方便打日志。4.2 部署到设备与权限处理编译出 APK 后装到设备上第一次打开大概率会失败因为权限没给。分两种情况如果是 root 设备代码里的su兜底能生效如果是非 root 设备需要系统层配合。调试阶段我一般用adb shell手动改权限验证链路# 查看设备节点 adb shell ls -l /dev/ttyS* # 临时放开权限仅调试用重启失效 adb shell su -c chmod 666 /dev/ttyS1改完权限再打开 App如果open成功日志里能看到 fd 值。量产方案则是改ueventd.rc加chmod 0666 /dev/ttyS1或者配 SELinux 的file_contexts让 App 的 domain 有权限访问该节点。这一步是串口项目落地的分水岭很多团队卡在这里以为代码有问题其实是系统权限没配。4.3 用回环测试验证收发链路链路通没通最直接的验证是回环把串口的 TX 和 RX 短接发什么收什么。写个简单的测试按钮发一串字节看回调里能不能原样收到// 发送测试数据 byte[] test HELLO.getBytes(); serialPort.getOutputStream().write(test); serialPort.getOutputStream().flush(); // 回调里打印收到的内容 mListener data - Log.d(SerialTest, 收到: new String(data));如果发出去收不到按这个顺序排查先确认 TX/RX 是否真的短接再看波特率是否一致然后看cfmakeraw是否生效最后看读线程是否启动。回环能通说明从 Java 到 native 到硬件这条链路没问题剩下的就是对接真实设备调协议。5. 避坑与排查串口开发最常见的五个坑5.1 现象open 返回 -1日志无任何提示原因通常是权限不足或节点路径错误。open失败时 errno 会给出具体原因但很多示例代码直接返回 NULL 不打印 errno导致黑匣子。解决方式是在 C 层加LOGE(open failed: %s, strerror(errno))把 errno 打出来。如果是EACCES就是权限问题ENOENT就是路径不存在EBUSY说明设备被别的进程占用了。5.2 现象能打开但收到的全是乱码九成是波特率不匹配。检查 Java 层传的整数有没有在 C 层正确映射成Bxxx常量cfsetispeed和cfsetospeed是否都设了。还有一种可能是数据位/校验位/停止位和对方设备不一致比如对方是 7 位数据位带偶校验你按 8N1 配收到的就是乱码。用示波器或逻辑分析仪量一下波形最直接没有仪器就逐个参数试。5.3 现象读线程收不到数据但设备确实在发先确认读线程真的启动了很多代码在open成功后忘了start()。其次看read的返回值如果一直是 -1看 errno 是不是EINTR被信号打断这种情况重试即可。还有一种隐蔽情况O_NONBLOCK标志被误加导致read立即返回 0看起来像没数据。检查open的 flags 参数阻塞模式不要带O_NONBLOCK。5.4 现象应用切后台后串口断连Android 在内存紧张时会回收后台进程fd 随之关闭。如果业务要求后台持续通信需要前台服务加通知把进程优先级提上去。另外部分定制系统有省电策略会主动杀后台这种情况要在系统白名单里加应用。代码层面可以在onDestroy里主动close()避免 fd 泄漏下次打开前先检查旧 fd 是否已释放。5.5 现象某些机型上 UnsatisfiedLinkErrorABI 没覆盖全或者 so 库没打进 APK。用unzip -l app.apk | grep lib看lib/目录下有没有对应 ABI 的 so。如果只有armeabi-v7a而设备是arm64-v8a在 64 位系统上可能加载失败。解决是把abiFilters配全或者用abiSplit分包。还有一种情况是System.loadLibrary的名字和 so 文件名不一致so 叫libserial_port.so加载时写serial_port不带lib前缀和后缀。6. 二次开发把工具源码改造成业务通信层拿到这份源码多数人不是直接用它而是把它嵌进自己的项目。改造的核心思路是「隔离变化」串口参数、协议解析、线程模型这三块最容易变要把它们抽出来。第一步把SerialPort的构造参数收拢成一个配置对象。波特率、数据位、校验位、停止位、节点路径这些用SerialConfig承载避免构造函数参数膨胀。第二步把读线程的回调改成带缓冲的帧解析器。串口数据是流式的业务层需要的是完整帧所以在回调里加一个ByteBuffer累积按协议头尾或长度字段切帧切出完整帧再抛给业务。第三步写操作加发送队列避免多线程同时写导致数据交错。// 帧解析器骨架按固定头尾切帧 public class FrameParser { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); private static final byte HEAD 0xAA; private static final byte TAIL 0x55; public void feed(byte[] data) { buffer.write(data, 0, data.length); byte[] all buffer.toByteArray(); int start -1; for (int i 0; i all.length; i) { if (all[i] HEAD) { start i; break; } } if (start 0) { buffer.reset(); return; } for (int i start 1; i all.length; i) { if (all[i] TAIL) { byte[] frame Arrays.copyOfRange(all, start, i 1); onFrame(frame); // 完整帧交给业务 buffer.reset(); buffer.write(all, i 1, all.length - i - 1); // 保留残余 return; } } } protected void onFrame(byte[] frame) { /* 业务实现 */ } }这个解析器只是骨架真实协议可能是长度字段加 CRC把TAIL判断换成长度读取加校验即可。关键是「残余数据要保留」否则跨包帧会丢。验证方法很简单构造一个分两次发送的帧第一次发一半第二次发另一半看解析器能不能拼出完整帧。这个测试我每次改解析逻辑都会跑一遍因为跨包丢帧是最难复现的 bug。还有一个改造点是错误重连。串口线被拔、设备重启都会导致 fd 失效业务层需要感知并重开。做法是在读线程捕获IOException后置一个标志位由上层定时器尝试重新open重开成功再恢复读线程。重连要有退避别一秒重试十次把 CPU 打满。最后说个习惯。串口项目最怕「在我这能跑」因为硬件差异太大。我现在的做法是每次改完 native 层先在至少两种 ABI 的设备上各跑一遍回环测试再对接真实设备。有一次偷懒只测了 arm64结果客户的老设备是 32 位so 加载失败现场排查了半天。从那以后我每次发版前都强制走一遍多 ABI 回环宁可多花十分钟也不想到现场救火。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
C/C++的OpenCV实现模板匹配的基础实现与优化 前言
模板匹配(template matching)是图像处理里最直观的定位手段:拿一张小图(模板)在一张大图(待搜索图)上逐位置滑动,计算每个位置的相似度,相似度最高的位置就是匹配结… · 2026/11/5 10:27:45
MCP协议:大模型接口标准化的工程实践 1. 项目概述:大模型时代的接口标准化革命 去年在部署一个跨平台AI应用时,我不得不为同一个功能分别适配OpenAI、Claude和本地部署的Llama模型。每种模型都有独特的API规范、参数格式和返回结构,光是协议转换就占用了60%的开发时间。这种碎片化… · 2026/11/5 10:27:45
使用Traefik与Bright Data构建高匿名数据采集系统 1. 项目背景与核心价值数据采集是现代互联网业务中不可或缺的一环,无论是市场调研、竞品分析还是舆情监控,都需要高效可靠的数据获取能力。Bright Data的MCP(Mobile Carrier Proxy)作为一种移动运营商代理解决方案,能够… · 2026/11/5 10:27:45
华为eNSP模拟器下载安装全攻略:环境配置与避坑指南 网络工程这门手艺,光看书是学不会的,必须得上手摸设备、敲命令、看现象。但真机价格不便宜,一台入门级路由器加交换机就得小几千,实验室里也不可能让你随便折腾。所以模拟器就成了绕不开的刚需,而华为eNSP模拟器在国内… · 2026/11/5 10:31:47
AI工作流与技能模块化实践:提升企业自动化效率 1. 项目概述:AI工作流与技能模块化实践 在自动化办公和智能辅助领域,AI工作流与技能模块化(Skill)的配合使用正在改变传统任务处理方式。这种架构模式将复杂业务流程拆解为标准化组件,通过灵活编排实现效率跃升。最近我… · 2026/11/5 10:31:47
LSM-Tree与StarRocks存储引擎深度解析 1. LSM-Tree存储引擎基础解析 LSM-Tree(Log-Structured Merge-Tree)是现代数据库系统中广泛采用的一种存储结构,其核心设计思想是通过顺序写入和后台合并来优化写性能。与传统的B树结构相比,LSM-Tree在写入密集型场景下能提供更高… · 2026/11/5 10:31:47
OpenClaw:AI认知协作如何重构29个行业工作流 1. OpenClaw的本质认知革命 OpenClaw的出现彻底改变了人们对AI工具的刻板印象。这个平台最颠覆性的特点在于:它根本不是传统意义上的"软件工具",而是一个具备认知能力的数字工作伙伴。就像当年智能手机重新定义了"电话"的概念一样&a… · 2026/11/5 10:31:47
eNSP下载安装全攻略:版本匹配、安装顺序与故障排查 1. 为什么一个“下载地址”背后藏着这么多门道每次在技术群里看到有人甩出一句“求一个eNSP下载地址”,底下回复往往五花八门:有人丢个网盘链接,有人让去某论坛翻帖,还有人直接说“官网找不到了,我发你”。表面上看&am… · 2026/11/5 10:31:47
Pathfinder人群仿真软件安装与配置全指南 1. 环境准备与安装指南Pathfinder作为一款专业级人群仿真软件,在应急疏散规划、建筑人流分析等领域有着广泛应用。作为一名使用Pathfinder近三年的建筑安全分析师,我完整经历过从零开始部署到实际项目应用的全流程。下面将分享Windows和Linux双平台下的详… · 2026/11/5 10:30:46
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:07
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:07