从 0 搭一个 UFS 测试工具骨架:WinForms + 分层架构 + 可扩展测试引擎

大家好,这篇文章记录一下我最近搭建的一个 UFS 测试工具 骨架项目。

它目前还不是一个"直接可量产"的成品,而是一个适合后续持续演进的基础框架:

  • 上层是 WinForms,先把测试工具界面快速跑起来
  • 中间用 ViewModel + Controller + Runner 解耦流程
  • 下层用 Adapter + Service 隔离厂商 SDK / IOCTL 细节
  • 测试项以 ITestCase 插件化扩展,后续加用例不需要大改主流程

如果你也在做 UFS、eMMC、NVMe 或其他底层存储测试工具,这套思路其实都可以复用。


一、为什么我没有一开始就把逻辑全写进窗体里?

很多测试工具最早都是这样长出来的:

  • 窗体上放几个按钮
  • 点击事件里直接调 SDK
  • 成功了弹框,失败了打印日志
  • 后面越加越多,最后 Form1.cs 变成一个巨型文件

这样做前期确实快,但一旦需求开始增长,就会出现几个明显问题:

  1. 硬件调用和 UI 强耦合:后续想换界面框架,或者做命令行版本,会很痛苦。
  2. 厂商切换成本高:如果底层从 A 家 SDK 换成 B 家实现,业务层也得跟着改。
  3. 测试项不好扩展:新增一个测试,经常要改窗体、改流程、改统计逻辑。
  4. 资源生命周期难控制:设备初始化、会话打开、关闭、释放,容易散落在多个地方。

所以这个项目从一开始,就先把"骨架"搭好,把未来最容易膨胀的部分先拆开。


二、项目骨架长什么样?

项目核心代码结构大致如下:

text 复制代码
form1/
├─ Adapter/
│  ├─ IUfsAdapter.cs
│  └─ VendorUfsAdapter.cs
├─ Models/
│  └─ TestResult.cs
├─ Service/
│  ├─ UfsSession.cs
│  └─ UfsService.cs
├─ Test/
│  ├─ ITestCase.cs
│  ├─ TestCaseBase.cs
│  ├─ TestRunner.cs
│  ├─ TestController.cs
│  └─ Cases/
│     ├─ ReadWriteTest.cs
│     └─ StressTest.cs
├─ ViewModels/
│  └─ MainViewModel.cs
├─ Form1.cs
└─ Program.cs

这个结构的核心目标不是"炫技分层",而是为了回答 3 个问题:

  • 谁负责硬件接入?
  • 谁负责测试流程调度?
  • 谁负责界面展示?

只要这 3 件事拆清楚,项目后面就能稳很多。

先用一张分层架构图看整体关系,会比单看目录更直观:
#mermaid-svg-XVU5Y6ReajFbuFGf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XVU5Y6ReajFbuFGf .error-icon{fill:#552222;}#mermaid-svg-XVU5Y6ReajFbuFGf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XVU5Y6ReajFbuFGf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XVU5Y6ReajFbuFGf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XVU5Y6ReajFbuFGf .marker.cross{stroke:#333333;}#mermaid-svg-XVU5Y6ReajFbuFGf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XVU5Y6ReajFbuFGf p{margin:0;}#mermaid-svg-XVU5Y6ReajFbuFGf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XVU5Y6ReajFbuFGf .cluster-label text{fill:#333;}#mermaid-svg-XVU5Y6ReajFbuFGf .cluster-label span{color:#333;}#mermaid-svg-XVU5Y6ReajFbuFGf .cluster-label span p{background-color:transparent;}#mermaid-svg-XVU5Y6ReajFbuFGf .label text,#mermaid-svg-XVU5Y6ReajFbuFGf span{fill:#333;color:#333;}#mermaid-svg-XVU5Y6ReajFbuFGf .node rect,#mermaid-svg-XVU5Y6ReajFbuFGf .node circle,#mermaid-svg-XVU5Y6ReajFbuFGf .node ellipse,#mermaid-svg-XVU5Y6ReajFbuFGf .node polygon,#mermaid-svg-XVU5Y6ReajFbuFGf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XVU5Y6ReajFbuFGf .rough-node .label text,#mermaid-svg-XVU5Y6ReajFbuFGf .node .label text,#mermaid-svg-XVU5Y6ReajFbuFGf .image-shape .label,#mermaid-svg-XVU5Y6ReajFbuFGf .icon-shape .label{text-anchor:middle;}#mermaid-svg-XVU5Y6ReajFbuFGf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XVU5Y6ReajFbuFGf .rough-node .label,#mermaid-svg-XVU5Y6ReajFbuFGf .node .label,#mermaid-svg-XVU5Y6ReajFbuFGf .image-shape .label,#mermaid-svg-XVU5Y6ReajFbuFGf .icon-shape .label{text-align:center;}#mermaid-svg-XVU5Y6ReajFbuFGf .node.clickable{cursor:pointer;}#mermaid-svg-XVU5Y6ReajFbuFGf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XVU5Y6ReajFbuFGf .arrowheadPath{fill:#333333;}#mermaid-svg-XVU5Y6ReajFbuFGf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XVU5Y6ReajFbuFGf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XVU5Y6ReajFbuFGf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XVU5Y6ReajFbuFGf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XVU5Y6ReajFbuFGf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XVU5Y6ReajFbuFGf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XVU5Y6ReajFbuFGf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XVU5Y6ReajFbuFGf .cluster text{fill:#333;}#mermaid-svg-XVU5Y6ReajFbuFGf .cluster span{color:#333;}#mermaid-svg-XVU5Y6ReajFbuFGf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XVU5Y6ReajFbuFGf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XVU5Y6ReajFbuFGf rect.text{fill:none;stroke-width:0;}#mermaid-svg-XVU5Y6ReajFbuFGf .icon-shape,#mermaid-svg-XVU5Y6ReajFbuFGf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XVU5Y6ReajFbuFGf .icon-shape p,#mermaid-svg-XVU5Y6ReajFbuFGf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XVU5Y6ReajFbuFGf .icon-shape .label rect,#mermaid-svg-XVU5Y6ReajFbuFGf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XVU5Y6ReajFbuFGf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XVU5Y6ReajFbuFGf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XVU5Y6ReajFbuFGf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 💾 硬件层 Hardware
📦 模型层 Models
🔌 适配层 Adapter
🔧 服务层 Service
🧪 测试用例层 Test Cases
⚙️ 测试引擎层 Test Engine
🧠 ViewModel 层
📋 视图层 View
持有
调用 StartAsync/Cancel
调用 RunAllAsync
统一管理 Session
传入 session 遍历
模板方法
继承
继承
调用
创建
调用
实现
调用
返回 List
事件通知
ObservableCollection
Form1

(WinForms 窗口)
MainViewModel

(持有 TestResults 集合)
TestController

(协调/取消/事件通知)
TestRunner

(统一管理 Session + 批量执行)
<<interface>> ITestCase
<<abstract>> TestCaseBase

(模板方法: RunAsync)
ReadWriteTest

(读写验证)
StressTest

(1000次循环)
UfsService

(OpenSession / Read / Write / Close)
UfsSession

(会话生命周期)
<<interface>> IUfsAdapter
VendorUfsAdapter

(厂商 SDK 实现)
TestResult

(TestName, Passed, Duration, ...)
Vendor SDK / DLL / IOCTL

从这张图里你会发现,这个项目的关键不是代码多,而是职责边界已经先划清了。


三、Adapter 层:先把厂商差异挡在最底下

我这里先抽了一个 IUfsAdapter

csharp 复制代码
public interface IUfsAdapter
{
    bool Initialize();
    byte[] Read(int lba, int sectorCount);
    bool Write(int lba, byte[] data);
    string GetDeviceInfo();
    void Dispose();
}

它的意义非常直接:

  • 上层不关心你底层是 Vendor SDKDLLIOCTL,还是别的东西
  • 上层只关心"能不能初始化、能不能读写、能不能拿到设备信息"

对应的实现类 VendorUfsAdapter 里先放了占位逻辑,后续你只要把真实的厂商接口调用补进去即可。

这种做法有两个很现实的好处:

为了更直观地理解各个类之间是怎么协作的,这里也可以直接放出类图:
#mermaid-svg-mcuwFWn1LnTVgcJo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mcuwFWn1LnTVgcJo .error-icon{fill:#552222;}#mermaid-svg-mcuwFWn1LnTVgcJo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mcuwFWn1LnTVgcJo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mcuwFWn1LnTVgcJo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mcuwFWn1LnTVgcJo .marker.cross{stroke:#333333;}#mermaid-svg-mcuwFWn1LnTVgcJo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mcuwFWn1LnTVgcJo p{margin:0;}#mermaid-svg-mcuwFWn1LnTVgcJo g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-mcuwFWn1LnTVgcJo g.classGroup text .title{font-weight:bolder;}#mermaid-svg-mcuwFWn1LnTVgcJo .cluster-label text{fill:#333;}#mermaid-svg-mcuwFWn1LnTVgcJo .cluster-label span{color:#333;}#mermaid-svg-mcuwFWn1LnTVgcJo .cluster-label span p{background-color:transparent;}#mermaid-svg-mcuwFWn1LnTVgcJo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mcuwFWn1LnTVgcJo .cluster text{fill:#333;}#mermaid-svg-mcuwFWn1LnTVgcJo .cluster span{color:#333;}#mermaid-svg-mcuwFWn1LnTVgcJo .nodeLabel,#mermaid-svg-mcuwFWn1LnTVgcJo .edgeLabel{color:#131300;}#mermaid-svg-mcuwFWn1LnTVgcJo .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-mcuwFWn1LnTVgcJo .label text{fill:#131300;}#mermaid-svg-mcuwFWn1LnTVgcJo .labelBkg{background:#ECECFF;}#mermaid-svg-mcuwFWn1LnTVgcJo .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-mcuwFWn1LnTVgcJo .classTitle{font-weight:bolder;}#mermaid-svg-mcuwFWn1LnTVgcJo .node rect,#mermaid-svg-mcuwFWn1LnTVgcJo .node circle,#mermaid-svg-mcuwFWn1LnTVgcJo .node ellipse,#mermaid-svg-mcuwFWn1LnTVgcJo .node polygon,#mermaid-svg-mcuwFWn1LnTVgcJo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mcuwFWn1LnTVgcJo .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo g.clickable{cursor:pointer;}#mermaid-svg-mcuwFWn1LnTVgcJo g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-mcuwFWn1LnTVgcJo g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-mcuwFWn1LnTVgcJo .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-mcuwFWn1LnTVgcJo .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-mcuwFWn1LnTVgcJo .dashed-line{stroke-dasharray:3;}#mermaid-svg-mcuwFWn1LnTVgcJo .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-mcuwFWn1LnTVgcJo #compositionStart,#mermaid-svg-mcuwFWn1LnTVgcJo .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #compositionEnd,#mermaid-svg-mcuwFWn1LnTVgcJo .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #dependencyStart,#mermaid-svg-mcuwFWn1LnTVgcJo .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #dependencyStart,#mermaid-svg-mcuwFWn1LnTVgcJo .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #extensionStart,#mermaid-svg-mcuwFWn1LnTVgcJo .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #extensionEnd,#mermaid-svg-mcuwFWn1LnTVgcJo .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #aggregationStart,#mermaid-svg-mcuwFWn1LnTVgcJo .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #aggregationEnd,#mermaid-svg-mcuwFWn1LnTVgcJo .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #lollipopStart,#mermaid-svg-mcuwFWn1LnTVgcJo .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo #lollipopEnd,#mermaid-svg-mcuwFWn1LnTVgcJo .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mcuwFWn1LnTVgcJo .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-mcuwFWn1LnTVgcJo .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mcuwFWn1LnTVgcJo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mcuwFWn1LnTVgcJo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mcuwFWn1LnTVgcJo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 实现
实现
实现
继承
继承
使用
创建
使用
使用(多个)
使用
管理生命周期
返回
使用
通知
使用
持有
使用
创建
TestResult
+string TestName
+bool Passed
+string? ErrorMessage
+TimeSpan Duration
+DateTime Timestamp
<<interface>>
IUfsAdapter
+bool Initialize()
+byte\[\] Read(int lba, int sectorCount)
+bool Write(int lba, byte\[\] data)
+string GetDeviceInfo()
+void Dispose()
VendorUfsAdapter
-ILogger<VendorUfsAdapter> _logger
-bool _initialized
+VendorUfsAdapter(ILogger<VendorUfsAdapter> logger)
+bool Initialize()
+byte\[\] Read(int lba, int sectorCount)
+bool Write(int lba, byte\[\] data)
+string GetDeviceInfo()
+void Dispose()
UfsSession
+string DeviceInfo
+DateTime OpenedAt
+bool IsOpen
+UfsSession(string deviceInfo)
+void Dispose()
UfsService
-IUfsAdapter _adapter
-ILogger<UfsService> _logger
+UfsService(IUfsAdapter adapter, ILogger<UfsService> logger)
+UfsSession OpenSession()
+byte\[\] Read(UfsSession session, int lba, int sectorCount)
+bool Write(UfsSession session, int lba, byte\[\] data)
+void CloseSession(UfsSession session)
<<interface>>
ITestCase
+string Name
+Task<TestResult> RunAsync(UfsSession session, CancellationToken ct)
<<abstract>>
TestCaseBase
#UfsService UfsService
#ILogger Logger
+abstract string Name
+TestCaseBase(UfsService ufsService, ILogger logger)
+Task<TestResult> RunAsync(UfsSession session, CancellationToken ct)
#Task ExecuteTestAsync(UfsSession session, CancellationToken ct)
ReadWriteTest
+string Name = "UFS 读写测试"
+ReadWriteTest(UfsService ufsService, ILogger<ReadWriteTest> logger)
#Task ExecuteTestAsync(UfsSession session, CancellationToken ct)
StressTest
+string Name = "UFS 压力测试"
+StressTest(UfsService ufsService, ILogger<StressTest> logger)
#Task ExecuteTestAsync(UfsSession session, CancellationToken ct)
TestRunner
-IEnumerable<ITestCase> _testCases
-UfsService _ufsService
-ILogger<TestRunner> _logger
+TestRunner(IEnumerable<ITestCase> testCases, UfsService ufsService, ILogger<TestRunner> logger)
+Task<List<TestResult>> RunAllAsync(CancellationToken ct)
TestController
-TestRunner _runner
-ILogger<TestController> _logger
-CancellationTokenSource? _cts
+event Action<List<TestResult>> OnAllTestsCompleted
+bool IsRunning
+TestController(TestRunner runner, ILogger<TestController> logger)
+Task StartAsync()
+void Cancel()
MainViewModel
-TestController _testController
+ObservableCollection<TestResult> TestResults
+bool IsRunning
+MainViewModel(TestController testController)
+Task StartTestAsync()
+void CancelTest()
Form1
-MainViewModel _viewModel
-ILogger<Form1> _logger
-Button _btnStart
-Button _btnCancel
-ListBox _lstResults
+Form1(MainViewModel viewModel, ILogger<Form1> logger)
-void InitControls()
-Task OnStartClick()
-void OnCancelClick()
IDisposable

有了这张图,后面再看 AdapterServiceTestViewModel 四层,就不会觉得它们是零散拼起来的。

1)换厂商实现时,影响面最小

如果以后变成:

  • SamsungUfsAdapter
  • QualcommUfsAdapter
  • CustomIoctlUfsAdapter

理论上只要在依赖注入里替换注册即可,上面的 UfsServiceTestRunnerTestCase 基本不用动。

2)更利于做 Mock 和联调

在很多项目早期,硬件、驱动、SDK 并不是同时到位的。

这时候你完全可以先做一个:

csharp 复制代码
public class MockUfsAdapter : IUfsAdapter

先把 UI、测试流程、日志、结果展示全部联通。等真实设备 ready,再把底层实现替换进去。

这一点对工具开发非常重要,因为它能显著减少"等硬件"的空转时间。


四、Service 层:把"设备会话"抽出来

很多测试工具一开始容易忽略会话概念,直接哪里需要就哪里初始化设备。

这会带来两个问题:

  • 初始化逻辑到处散落
  • 多个测试项重复开关设备,既慢又不稳定

所以这里我单独做了 UfsSessionUfsService

UfsSession 做什么?

它表示一次已经打开的设备会话,里面维护:

  • DeviceInfo
  • OpenedAt
  • IsOpen

也就是说,后面的测试项拿到的不是"裸适配器",而是"一个已打开的会话"。

UfsService 做什么?

UfsService 负责把硬件能力封装成稳定的业务动作:

  • OpenSession()
  • Read(session, lba, sectorCount)
  • Write(session, lba, data)
  • CloseSession(session)

这个类的意义在于:

  • 上层不直接碰 Adapter
  • 参数校验、会话状态判断、日志记录集中处理
  • 后续你想加重试、性能计时、错误码转换,也有统一入口

这一步其实就是把"能调用 SDK"提升成"能提供业务能力"。


五、Test 层:让测试项真正可插拔

这个项目里,我最看重的一层其实是测试引擎层。

因为 UFS 工具一旦开始迭代,测试项只会越来越多,比如:

  • 基础读写测试
  • 顺序读写性能测试
  • 随机读写稳定性测试
  • 电源中断恢复测试
  • 长稳压力测试
  • Trim / Unmap 验证
  • RPMB 相关测试
  • Boot LUN / LU 配置验证

如果没有一个可扩展的用例模型,后面一定会乱。

1)先定义统一测试接口 ITestCase

csharp 复制代码
public interface ITestCase
{
    string Name { get; }
    Task<TestResult> RunAsync(UfsSession session, CancellationToken cancellationToken);
}

这意味着所有测试项,不管复杂度高低,对外都长成一个样子:

  • 有名字
  • 能异步执行
  • 能接收会话
  • 能响应取消
  • 最终输出统一的 TestResult

2)再做一个 TestCaseBase 抽公共逻辑

TestCaseBase 里已经把这些通用动作封起来了:

  • 计时
  • 异常捕获
  • 取消处理
  • 结果对象填充
  • 日志输出

子类只需要关注一件事:

csharp 复制代码
protected abstract Task ExecuteTestAsync(UfsSession session, CancellationToken ct);

也就是说,新增测试时,不需要每次都重复写 try/catch、Stopwatch、Passed/ErrorMessage 这些样板代码。

这就是典型的模板方法思路。

3)当前已经有两个示例测试

ReadWriteTest

逻辑很简单:

  • 生成 512 字节随机数据
  • 写到指定 LBA
  • 延迟后再读回来
  • 比较写入和读取内容是否一致

这个用例虽然基础,但很适合作为第一批 smoke test。

StressTest

当前实现是:

  • 循环 1000 次
  • 每次写一个 sector,再读一个 sector
  • 每 100 次打印一次进度
  • 支持取消

它的价值在于演示了两件事:

  • 测试项可以长时间运行
  • 测试项可以做进度日志输出和取消响应

这对后续扩展复杂压力测试非常有帮助。


六、为什么要让 TestRunner 统一管理 Session?

这是这个骨架里一个非常关键的设计点。

以前很多人会这样写:

  • 每个 TestCase 自己初始化设备
  • 自己打开连接
  • 自己跑测试
  • 自己关闭资源

看起来每个用例很"独立",但实际问题很多:

  1. 重复初始化设备:多个测试项会反复触发底层初始化。
  2. 执行效率低:本来一次打开会话就够,却要做 N 次。
  3. 资源状态不一致:某个用例忘了释放,后面的用例就可能出问题。
  4. 异常场景难收口:一旦中间某个测试失败,资源回收不容易统一保证。

所以现在的方式是:

text 复制代码
TestRunner.OpenSession()
    -> 遍历执行全部 ITestCase
    -> finally 中统一 CloseSession()

这套做法的收益非常明确:

  • 设备只初始化一次
  • 所有测试复用同一个会话
  • 生命周期边界清晰
  • finally 里统一回收资源,不容易漏

从工程角度说,这一步让整个测试框架开始"像一个框架",而不只是几个功能点的拼接。

如果把一次点击"开始测试"的链路画出来,这个设计的价值会更容易理解:
VendorUfsAdapter UfsService ReadWriteTest/StressTest TestRunner TestController MainViewModel Form1 VendorUfsAdapter UfsService ReadWriteTest/StressTest TestRunner TestController MainViewModel Form1 #mermaid-svg-QwbBsjcTRbEgezXa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QwbBsjcTRbEgezXa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QwbBsjcTRbEgezXa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QwbBsjcTRbEgezXa .error-icon{fill:#552222;}#mermaid-svg-QwbBsjcTRbEgezXa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QwbBsjcTRbEgezXa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QwbBsjcTRbEgezXa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QwbBsjcTRbEgezXa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QwbBsjcTRbEgezXa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QwbBsjcTRbEgezXa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QwbBsjcTRbEgezXa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QwbBsjcTRbEgezXa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QwbBsjcTRbEgezXa .marker.cross{stroke:#333333;}#mermaid-svg-QwbBsjcTRbEgezXa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QwbBsjcTRbEgezXa p{margin:0;}#mermaid-svg-QwbBsjcTRbEgezXa .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QwbBsjcTRbEgezXa text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QwbBsjcTRbEgezXa .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QwbBsjcTRbEgezXa .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QwbBsjcTRbEgezXa .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QwbBsjcTRbEgezXa .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QwbBsjcTRbEgezXa #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QwbBsjcTRbEgezXa .sequenceNumber{fill:white;}#mermaid-svg-QwbBsjcTRbEgezXa #sequencenumber{fill:#333;}#mermaid-svg-QwbBsjcTRbEgezXa #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QwbBsjcTRbEgezXa .messageText{fill:#333;stroke:none;}#mermaid-svg-QwbBsjcTRbEgezXa .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QwbBsjcTRbEgezXa .labelText,#mermaid-svg-QwbBsjcTRbEgezXa .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QwbBsjcTRbEgezXa .loopText,#mermaid-svg-QwbBsjcTRbEgezXa .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QwbBsjcTRbEgezXa .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QwbBsjcTRbEgezXa .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QwbBsjcTRbEgezXa .noteText,#mermaid-svg-QwbBsjcTRbEgezXa .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QwbBsjcTRbEgezXa .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QwbBsjcTRbEgezXa .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QwbBsjcTRbEgezXa .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QwbBsjcTRbEgezXa .actorPopupMenu{position:absolute;}#mermaid-svg-QwbBsjcTRbEgezXa .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QwbBsjcTRbEgezXa .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QwbBsjcTRbEgezXa .actor-man circle,#mermaid-svg-QwbBsjcTRbEgezXa line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QwbBsjcTRbEgezXa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop每个 ITestCase 用户 点击"开始测试"StartTestAsync()TestResults.Clear()StartAsync()new CancellationTokenSource()RunAllAsync(ct)OpenSession()Initialize()trueGetDeviceInfo()"Vendor UFS Device v1.0"UfsSessionRunAsync(session, ct)记录开始时间ExecuteTestAsync(session, ct)Write(session, lba, data)Write(lba, data)trueRead(session, lba, count)Read(lba, count)byte\[\]TestResultCloseSession(session)Dispose()session.Dispose()List~TestResult~统计通过/失败OnAllTestsCompleted(results)逐个添加进 TestResultsCollectionChanged 事件ListBox 显示结果 用户

这张时序图其实就回答了一个问题:为什么这套结构后面更容易扩展?因为 UI、流程调度、测试执行、设备访问,已经被拆成了清晰的调用链。

虽然这是个 WinForms 项目,但我依然不建议把所有按钮逻辑都堆到 Form1

这里的分工是这样的:

Form1

负责:

  • 初始化按钮和列表控件
  • 响应开始/取消点击
  • 展示测试结果

MainViewModel

负责:

  • 持有 ObservableCollection<TestResult>
  • 调用 StartTestAsync()CancelTest()
  • 接收控制层返回的测试结果并更新集合

TestController

负责:

  • 创建 CancellationTokenSource
  • 发起测试执行
  • 处理取消
  • 在执行结束后通过事件通知结果

这一层拆开以后,最大的好处是:

  • 以后即使界面从 WinForms 换成 WPF 或 MAUI,测试流程层几乎不用重写
  • UI 逻辑不再直接持有底层硬件对象
  • 调试问题时,很容易定位是在界面层、流程层还是硬件层

对于"工具型软件"来说,这种边界感非常值钱。


八、依赖注入让骨架更容易演进

项目在 Program.cs 里用 Host.CreateApplicationBuilder() 做了依赖注入注册,大致包括:

  • IUfsAdapter -> VendorUfsAdapter
  • UfsService
  • 多个 ITestCase
  • TestRunner
  • TestController
  • MainViewModel
  • Form1

这意味着什么?

1)新增测试项几乎零侵入

比如你新增一个:

csharp 复制代码
builder.Services.AddTransient<ITestCase, PowerCycleTest>();

只要它实现了 ITestCaseTestRunner 就会自动把它纳入执行队列。

2)对象依赖关系变得非常清晰

谁依赖谁、谁的生命周期是单例还是瞬态,一眼就能看出来。

3)后续更容易接入配置系统

比如后面你想加:

  • 测试参数配置文件
  • 厂商类型切换
  • 日志级别控制
  • 测试集合开关

这一套都可以很自然地接在宿主模型上。


九、这个骨架目前还缺什么?

既然是"骨架",那就一定有还没补全的地方。

我认为下一阶段比较值得补的点包括:

1)真实硬件接入

当前 VendorUfsAdapter 里还是占位逻辑,接下来要把真实的:

  • SDK 调用
  • DLL 封装
  • P/Invoke
  • IOCTL 通讯

逐步补进去。

2)日志与结果持久化

现在结果主要展示在界面里,后面可以补:

  • 文件日志
  • CSV / JSON 导出
  • HTML 测试报告

3)测试配置化

例如:

  • LBA 起始地址
  • 循环次数
  • 数据块大小
  • 是否开启校验
  • 超时时间

都可以从硬编码改成配置项。

4)更细粒度的进度反馈

当前压力测试只是每 100 次打一条日志,后面可以做:

  • 进度条
  • 当前步骤说明
  • 实时耗时统计
  • 失败时上下文快照

5)测试用例分组与选择执行

比如支持:

  • 冒烟测试
  • 稳定性测试
  • 性能测试
  • 工厂产线测试

让用户按场景勾选执行。


十、我对这个项目骨架的几个判断

如果只看代码量,这个项目现在并不大。

但从工程结构上看,我认为它已经具备了几个很重要的基础特征:

  • 能跑起来:WinForms + DI + 测试流程已经打通
  • 能扩展:新增测试项、替换 Adapter 成本可控
  • 能演进:后续接真实硬件、加配置、加报告都比较顺
  • 能维护:层次清楚,问题定位不会全堆在一个文件里

对于很多内部测试工具来说,最难的从来不是"先写一个按钮出来",而是"写到 3 个月后还能继续改"。

这个骨架的意义,本质上就在这里。


十一、结语

如果你也在做底层硬件测试工具,我的建议是:

  • 前期哪怕功能少一点,也尽量把边界先划清
  • 尤其是 硬件接入测试调度UI 展示 这三层,越早拆开越省心
  • 测试项一定要按"可插拔"思路设计,不然后面扩一个崩一个

这篇先把整个 UFS 测试工具的骨架思路讲清楚。

下一篇我准备继续展开:如何基于这套骨架继续扩展测试项、接入真实厂商 SDK,并把工具逐步做成可落地的工程版本。