Android 安全校验下沉到原生层:防 patch 与构建期自动化

把判断写进机器码,再把指纹维护交给构建流程,让 patch 无利可图。

前言

签名校验落地后,很多团队会松一口气:重打包必然重签名,重签名必然改指纹,运行时比对一下就能发现。逻辑没错,问题出在校验逻辑放在哪里。

只要判断还写在应用层,它就和业务代码一样可反编译、可改:让它永远返回通过,再重新打包,一气呵成。它拦得住「拿来就改」的人,拦不住「会改校验逻辑」的人。

我在实际项目里被卡过一次之后,才把思路换成「把判断换到更难下手的地方放」:下沉原生层 → 补防 patch 手段 → 指纹维护自动化,三步走完才算收口。

一、为什么必须往下沉

1.1 两层的差距是量级的

层次反编译产物patch 难度
应用层(字节码)几乎等价源码的中间码改一行,重打包
原生层(机器码)汇编级逆向改二进制、修复格式、处理重定位

应用层被改相当于改文本,原生层被改相当于在二进制上做外科手术,门槛不在一个层级。代价是多一套包与证书解析代码、多一个构建环节,所以边界要划清:只沉校验逻辑,业务逻辑留在应用层。

二、分工:应用层门禁,原生层校验

2.1 一句话设计

应用层只留一个「门禁」,原生层做真正的校验:原生层返回通过与否,应用层据此决定是否继续运行。

objectNativeGuard{privatevalavailable=try{System.loadLibrary("secguard")true}catch(e:Throwable){false}// 原生校验:0 失败,1 通过externalfunverify(apkPath:String):Intfuncheck(context:Context):Boolean{// 动态库不可用时降级为应用层校验兜底,避免误杀if(!available)returntruevalpath=context.applicationInfo?.sourceDir?:returnfalsereturntry{verify(path)==1}catch(e:Throwable){false}}}

降级策略必须写:动态库加载失败时交回应用层兜底,否则动态库一旦没打进包,所有正常用户都会被误杀。

loadLibrary("secguard")里是不带前后缀的库名,实际找的是libsecguard.so:先搜java.library.path(应用安装目录的lib/<abi>/在其中),再回退系统库目录;库名写错、该 ABI 缺文件、依赖 so 缺失都会抛UnsatisfiedLinkError。

2.2 绑定发生在什么时候:JNI_OnLoad

external fun verify和 C 函数靠两种方式对上:

  • 按符号名自动绑定:原生层按「包路径 + 类名 + 方法名」拼出符号名找函数,对不上就崩,混淆规则里必须保留原生方法名;
  • JNI_OnLoad手动注册:库加载时回调,用RegisterNatives显式绑定,不受符号名规则约束,还带来「库一加载就自检」的时机。
// secguard.c 片段:库加载时注册原生方法#include<jni.h>staticjintverify(JNIEnv*env,jobject thiz,jstring apkPath);staticconstJNINativeMethod kMethods[]={{"verify","(Ljava/lang/String;)I",(void*)verify}};JNIEXPORT jintJNI_OnLoad(JavaVM*vm,void*reserved){JNIEnv*env=NULL;if((*vm)->GetEnv(vm,(void**)&env,JNI_VERSION_1_6)!=JNI_OK){returnJNI_ERR;// 取不到环境,加载失败}jclass clazz=(*env)->FindClass(env,"com/example/yourapp/security/NativeGuard");if(clazz==NULL)returnJNI_ERR;if((*env)->RegisterNatives(env,clazz,kMethods,1)!=JNI_OK){returnJNI_ERR;}returnJNI_VERSION_1_6;// 必须返回有效版本,否则加载失败}

JNI_OnLoad的返回值就是接下来要用的 JNI 版本,返回非法值等于加载失败。它按字符串找类,所以混淆时要保住类名:-keep class com.example.yourapp.security.NativeGuard { *; };走符号名绑定则加-keepclasseswithmembernames class * { native <methods>; }。

三、原生层怎么读到签名证书(核心难点)

在应用层读签名只需调一个系统接口;原生层这条路走不通——回调应用层接口等于把逻辑交回可 hook 的地方,只能纯原生地解析包本身:定位签名文件 → 解压出签名数据 → 按 ASN.1 逐层剥离出 X.509 证书 → 算指纹与白名单比对。

// 原生层读取签名证书的示意流程funreadSignatureFingerprint(apkPath:String):ByteArray?{// 1. 解析压缩包目录,定位签名文件valsignatureEntry=findSignatureEntry(apkPath)?:returnnull// 2. 解压签名文件,得到结构化签名数据valsignedData=readZipEntry(apkPath,signatureEntry)?:returnnull// 3. 从签名数据中提取签名者证书valcert=extractFirstCertificate(signedData)?:returnnull// 4. 计算证书指纹returnsha256(cert)}

两个坑:压缩包解析不能「整个读进内存再找」——签名文件位置靠中央目录定位,被压缩过还要按对应算法还原;证书解析要按「标签 + 长度 + 内容」逐层剥离 ASN.1 才能定位 X.509 证书。

四、让攻击者下不去手

4.1 指纹怎么存

明文写进动态库,用十六进制编辑器就能搜出来改掉;常见做法是「拆散 + 异或混淆」存储:

// 原生层比对示意:常量以异或混淆存储,运行时还原后比对valKEY=0x5AvalHASH_LIST=arrayOf(byteArrayOf(0xE1,0x9A,0x2C/* ... 共 32 字节,混淆后的指纹 */),byteArrayOf(0x4F,0xB7,0x0D/* ... 第二套签名的指纹 */))funcheckSignature(actual:ByteArray):Boolean{for(expectedinHASH_LIST){vardiff=0for(iinactual.indices){diff=diffor(actual[i].toInt()xor(expected[i].toInt()xorKEY))}if(diff==0)returntrue}returnfalse}

「逐字节累加差异再判断」而非「一发现不同就返回」,是为了避免时序攻击——比较耗时不能随匹配位置变化。

4.2 编译期防 patch

编译期让动态库里「能定位到的函数尽可能少」:打开-fvisibility=hidden、-ffunction-sections -fdata-sections、-Wl,--gc-sections、-Wl,--exclude-libs,ALL,用-Wl,--version-script=export.map把导出收成清单。

版本脚本会把JNI_OnLoad一起藏掉,必须在export.map里显式放行——{ global: JNI_OnLoad; local: *; };就够,这是「隐藏符号」与「绑定对得上」之间最容易踩的冲突。

4.3 运行时防 patch

再加一层反调试、反注入检测(读进程状态里的跟踪者标识,或扫描内存映射里的注入特征):

// 原生层反调试示意:读进程状态中的跟踪者标识funisDebugged():Boolean{valstatus=readFile("/proc/self/status")valline=status.lineSequence().firstOrNull{it.startsWith("TracerPid:")}returnline?.substringAfter(":").orEmpty().trim()!="0"}

再叠自校验与多点分散校验,避免「改掉一个函数就全盘绕过」。

4.4 互锁:dex 与 so 的相互校验关系

只校验自己有个漏洞:入口函数被整体改写,校验代码根本没被执行。更稳的是让两侧互相牵着:

  • dex 侧校验 so:从 APK 里读lib/<abi>/libsecguard.so的摘要,与构建期内置值比对;
  • so 侧校验 dex:从 APK 里读classes*.dex的摘要,与编译进 so 的预期值比对。
// 原生层自校验示意:从 APK 内读条目摘要,与构建期注入的预期值比对// sha256ApkEntry 复用第三节那套轻量解析intcheckEntryDigest(constchar*apkPath,constchar*entryName,constunsignedcharexpected[32]){unsignedcharactual[32];if(sha256ApkEntry(apkPath,entryName,actual)!=0){return0;// 条目缺失或读取失败,一律不通过}unsignedchardiff=0;for(inti=0;i<32;++i){diff|=(unsignedchar)(actual[i]^expected[i]);// 恒定时间比较}returndiff==0;}

有个约束常被写错:两端预期值都必须是构建期锚定的常量,不能互相从对方现取,否则一端被改、另一端照着改后的值去比,两边一起通过。

五、把指纹维护交给构建流程

手动维护内置指纹有三处麻烦:每次换签名都要重算;多套签名时一个动态库只内置一个指纹、覆盖不全;手工填错一个字节就误杀正常用户。解法是交给构建流程:遍历签名配置,读出证书、算指纹、混淆后生成头文件供原生层引用。

// 构建期自动注入示意:遍历签名配置,生成原生层引用的指纹白名单fungenerateSecurityConfig(signingConfigs:List<SigningConfig>){valfingerprints=signingConfigs.mapNotNull{cfg->runCatching{valcert=loadCertificate(cfg.keystore,cfg.alias)sha256(cert.encoded).map{(it.toInt()and0xFF)xor0x5A}}.getOrNull()}writeHeader(fingerprints)}

换签名会自动带上新指纹,多套签名生成白名单数组、原生层「任一匹配即通过」,「填错」这一环也不存在。

5.1 一个能直接抄的 Gradle 任务

// app/build.gradle.kts import java.security.KeyStore import java.security.MessageDigest abstract class GenerateSecurityConfig : DefaultTask() { @get:InputFiles abstract val keystores: ConfigurableFileCollection @get:Input abstract val credentials: Property<String> // ks.storePassword=..,ks.keyAlias=.. @get:OutputFile abstract val header: RegularFileProperty @TaskAction fun generate() { val creds = credentials.get().split(",") .associate { it.substringBefore("=") to it.substringAfter("=") } val list = mutableListOf<String>() keystores.files.sortedBy { it.name }.forEach { ks -> val pass = creds["${ks.name}.storePassword"] ?: return@forEach val alias = creds["${ks.name}.keyAlias"] ?: return@forEach val cert = runCatching { KeyStore.getInstance(KeyStore.getDefaultType()).apply { ks.inputStream().use { load(it, pass.toCharArray()) } }.getCertificate(alias) }.getOrNull() ?: return@forEach val hash = MessageDigest.getInstance("SHA-256").digest(cert.encoded) list += hash.joinToString(", ", " { ", " },") { "0x%02X".format((it.toInt() and 0xFF) xor 0x5A) } } header.get().asFile.apply { parentFile.mkdirs() }.writeText( "#pragma once\nstatic const unsigned char kFingerprints[][32] = {\n" + list.joinToString("\n") + "\n};\n") } }

@InputFiles/@Input/@OutputFile让 Gradle 能比对快照:一致就跳过任务,密钥库或期望值一变就重跑;密钥库读不到时return@forEach跳过,不让构建挂掉。别用outputs.upToDateWhen { false }硬关增量。

5.2 任务依赖顺序与 ABI 配置

任务是按依赖图执行的,「生成头文件」和「编译原生代码」默认没有先后关系,必须显式建立依赖,否则原生编译可能先跑、头文件还不存在,直接失败。

// 让原生编译相关任务依赖配置生成任务,保证顺序tasks.configureEach{task->valname=task.nameif(name.startsWith("externalNativeBuild")||name.startsWith("buildCMake")||name.startsWith("configureCMake")){task.dependsOn("generateSecurityConfig")}}

任务名随插件版本变化,所以要匹配多个前缀;并且要能优雅降级——依赖没挂上时宁可退回「空配置」(跳过校验),也不能让构建失败。注册任务本身只有几行:

tasks.register<GenerateSecurityConfig>("generateSecurityConfig") { keystores.from(fileTree(rootProject.file("keystore")) { include("*.jks", "*.keystore") }) credentials.set(providers.gradleProperty("secguard.credentials")) header.set(layout.buildDirectory.file("generated/secguard/secguard_fingerprints.h")) }

再把生成头文件的目录通过target_include_directories(secguard PRIVATE <生成目录>)引进 CMake。另外:abiFilters决定哪些 ABI 会产出并打进 APK,裁掉一个 ABI 却仍在代码里无脑 load 库,省了体积、直接崩;android:extractNativeLibs(新版插件对应packaging { jniLibs { useLegacyPackaging } })决定 so 是否解压落盘——不落盘时 so 直接从 APK 内映射、磁盘上没有可单独替换的副本,默认值随插件版本与minSdkVersion而变(经验值),读 so 摘要要统一走 APK 内条目解析。

5.3 密钥库路径的解析陷阱

多模块工程里很隐蔽的坑:签名配置常通过共享脚本被多模块引用,而脚本里的相对路径是相对「引用它的模块目录」解析的,不是相对脚本文件自身——同一个../../某密钥库在不同模块解析结果不同,不参与签名的库模块尤其容易拿到错路径却不报错。所以按文件名在项目根目录重新定位:

// 按文件名在项目根目录重新定位密钥库,规避相对路径解析偏差valkeystore=rootProject.file(signingConfig.storeFile.name)valkeyStore=KeyStore.getInstance(KeyStore.getDefaultType()).apply{load(FileInputStream(keystore),signingConfig.storePassword.toCharArray())}valcert=keyStore.getCertificate(signingConfig.keyAlias)

5.4 绕不开的循环依赖:dex 校验值

签名指纹能全自动,但内容校验值(dex 的摘要或 CRC)不行,因为存在一个循环依赖:

  • dex 校验值是打包产物,要等 dex 合并完成后才确定;
  • 这个值要编进动态库,作为原生校验的预期值;
  • 而动态库本身又要打进同一个 APK,反过来影响打包产物的布局。

校验值的「因」和「果」纠缠在同一个产物上,单次构建内无法自举。更要命的是增量构建:原生编译任务一旦被判为「无需重跑」,产物里就会残留上一版本的 dex 摘要,校验静默失效——比报错更危险。签名指纹来自密钥库、与产物无关,能干净地自动注入;内容校验值只有两条出路:两遍构建(第二遍必须让原生任务真正重跑)或手动回填。所以务实的选择是签名指纹全自动、内容校验值按需手动;第 02 篇已证明签名校验足够拦截二次打包,内容校验值只在私钥泄露时才有用。

六、三篇一条线:立靶 → 筑墙 → 收口

  • 第 01 篇《Android 混淆挡不住二次打包:一条完整的攻击链与突破口》——立靶:摊开二次打包链路,指出混淆与映射文件都不是安全边界;
  • 第 02 篇《Android 签名校验为什么能拦住二次打包:指纹与完整性校验》——筑墙:在这个突破口上立起第一道防线;
  • 本篇——收口:把判断挪进机器码、补齐防 patch 手段、把指纹维护自动化。

结论

  • 应用层校验可被改,原生层(机器码)才是抬高门槛的战场:应用层做门禁、原生层做判断。
  • 降级策略不能省:动态库不可用时交回应用层兜底,避免误杀。
  • 纯原生地读签名证书:压缩包 → 签名文件 → ASN.1 取 X.509 → 算指纹,不依赖第三方库。
  • 绑定方式决定混淆规则:RegisterNatives保类名,符号名绑定保 native 方法名;JNI_OnLoad返回非法版本直接失败。
  • 指纹异或混淆存储、比较恒定时间;编译期隐藏符号裁剪无用段、运行时反调试反注入,再叠自校验与分散校验。
  • 互锁要「两条锚点 + 交叉检查」:两端预期值都来自构建期常量,绝不能互相现取。
  • 指纹维护交给构建流程:@InputFiles/@OutputFile保住增量,显式dependsOn原生编译,密钥库按根目录定位并容错。
  • 内容校验值受循环依赖制约:dex 摘要要等合并完成才能算、又要编进同一个包,按需手动即可。

如果你们也在做类似改造,欢迎在评论区说说踩到的坑——尤其是增量构建里「校验静默失效」这类不报错的问题。

关键词标签:#Android#APK安全#原生层校验#防二次打包#JNI#Gradle构建#应用加固

相关新闻

三步装好 meta-overlayfs 元模块:让 KernelSU 模块真正改到 /system

三步装好 meta-overlayfs 元模块:让 KernelSU 模块真正改到 /system

三步装好 meta-overlayfs 元模块&#xff1a;让 KernelSU 模块真正改到 /system 【免费下载链接】KernelSU A Kernel based root solution for Android 项目地址: https://gitcode.com/GitHub_Trending/ke/KernelSU KernelSU 模块对 /system 的修改&#xff0c;要靠元模…

2026/10/20 10:53:45 阅读更多 →
LSTM与Transformer时间序列预测实战对比:源码解析与调参避坑指南

LSTM与Transformer时间序列预测实战对比:源码解析与调参避坑指南

简介&#xff1a;面向时间序列预测任务的LSTMTransformer混合模型PyTorch实现&#xff0c;适合需要多特征输入、单变量输出场景的研究者与开发者&#xff1b;可直接迁移到风电、光伏、寿命预测、浓度预测等项目中&#xff0c;也适合作为学习Transformer与LSTM结合的入门范例。压…

2026/10/20 10:53:45 阅读更多 →
如何快速部署EspoCRM开源CRM:从零到能跑生产业务

如何快速部署EspoCRM开源CRM:从零到能跑生产业务

如何快速部署EspoCRM开源CRM&#xff1a;从零到能跑生产业务 【免费下载链接】espocrm EspoCRM – Open Source CRM Application 项目地址: https://gitcode.com/GitHub_Trending/es/espocrm EspoCRM是一款免费开源的客户关系管理系统&#xff08;CRM&#xff09;&#…

2026/10/20 10:53:45 阅读更多 →

最新新闻

YOLOv8口罩检测实战:数据集、模型改进与Web部署全流程解析

YOLOv8口罩检测实战:数据集、模型改进与Web部署全流程解析

做口罩检测这个项目&#xff0c;对我来说真算得上“看着简单&#xff0c;做起来全是坑”的典型。最初接到需求时&#xff0c;对方以为就是拿YOLOv8随便跑一下&#xff0c;但真正上手之后才发现&#xff0c;光是把“口罩类型”和“佩戴是否正确”这两个任务拆清楚&#xff0c;再…

2026/10/20 11:39:36 阅读更多 →
STM32寄存器白话手册:从GPIO到DMA的底层开发指南

STM32寄存器白话手册:从GPIO到DMA的底层开发指南

拿到一块新板卡&#xff0c;第一反应总是很诚实的&#xff1a;打开参考手册&#xff0c;翻到寄存器章节&#xff0c;然后&#xff0c;陷入沉默。这就是我做“STM32寄存器白话手册”这个项目的起点。不是想重新写一本数据手册&#xff0c;而是想把那些写在datasheet里、每一条注…

2026/10/20 11:39:36 阅读更多 →
748集大模型应用开发实战课程:从RAG知识库到Agent开发

748集大模型应用开发实战课程:从RAG知识库到Agent开发

这次我们来看一套体系非常完整的大模型应用开发视频课程资源&#xff1a;全 748 集&#xff0c;从 AI 应用核心基础讲起&#xff0c;覆盖 RAG 知识库搭建、Agent 开发&#xff0c;一直延伸到行业落地。如果你正在找一条从零开始、能跟着动手做项目的学习路径&#xff0c;这套内…

2026/10/20 11:39:36 阅读更多 →
表征纠缠让DiT训练不再艰难:从原理到实践

表征纠缠让DiT训练不再艰难:从原理到实践

过去这大半年&#xff0c;我一直在跟 DiT&#xff08;Diffusion Transformer&#xff09;的收敛问题死磕。训练曲线就像过山车——前面两三万步看起来一切正常&#xff0c;loss 匀速下降&#xff0c;FID 也在往下走&#xff0c;结果某个拐点之后曲线突然开始剧烈抖动&#xff0…

2026/10/20 11:39:36 阅读更多 →
SMIC18工艺库在Cadence中的正确加载与PDK闭环验证指南

SMIC18工艺库在Cadence中的正确加载与PDK闭环验证指南

简介&#xff1a;本资源为SMIC中芯国际18纳米工艺节点的Cadence专用工艺库文件包&#xff0c;面向集成电路设计工程师、高校微电子专业师生及EDA工具使用者&#xff0c;解决先进工艺下数字/模拟电路仿真、综合与物理实现缺乏标准模型支持的核心问题。压缩包共2000个文件&#x…

2026/10/20 11:39:36 阅读更多 →
网狐框架架设全流程:环境配置、数据库还原与服务端客户端联调

网狐框架架设全流程:环境配置、数据库还原与服务端客户端联调

简介&#xff1a;网狐框架全套源码与详细架设教程整合包&#xff0c;面向希望深入学习网狐开源框架或在实际项目中快速搭建网络应用的IT开发人员。压缩包体积约533.58MB&#xff0c;内含框架完整源码、环境配置说明、数据库脚本与视频操作演示&#xff1b;具体文件总数未标注&a…

2026/10/20 11:38:35 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

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

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

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

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

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

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 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/20 10:02:08 阅读更多 →
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/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练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/20 9:50:45 阅读更多 →