大家好,这篇文章记录一下我最近搭建的一个 UFS 测试工具 骨架项目。
它目前还不是一个"直接可量产"的成品,而是一个适合后续持续演进的基础框架:
- 上层是
WinForms,先把测试工具界面快速跑起来 - 中间用
ViewModel + Controller + Runner解耦流程 - 下层用
Adapter + Service隔离厂商 SDK / IOCTL 细节 - 测试项以
ITestCase插件化扩展,后续加用例不需要大改主流程
如果你也在做 UFS、eMMC、NVMe 或其他底层存储测试工具,这套思路其实都可以复用。
一、为什么我没有一开始就把逻辑全写进窗体里?
很多测试工具最早都是这样长出来的:
- 窗体上放几个按钮
- 点击事件里直接调 SDK
- 成功了弹框,失败了打印日志
- 后面越加越多,最后
Form1.cs变成一个巨型文件
这样做前期确实快,但一旦需求开始增长,就会出现几个明显问题:
- 硬件调用和 UI 强耦合:后续想换界面框架,或者做命令行版本,会很痛苦。
- 厂商切换成本高:如果底层从 A 家 SDK 换成 B 家实现,业务层也得跟着改。
- 测试项不好扩展:新增一个测试,经常要改窗体、改流程、改统计逻辑。
- 资源生命周期难控制:设备初始化、会话打开、关闭、释放,容易散落在多个地方。
所以这个项目从一开始,就先把"骨架"搭好,把未来最容易膨胀的部分先拆开。
二、项目骨架长什么样?
项目核心代码结构大致如下:
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 SDK、DLL、IOCTL,还是别的东西 - 上层只关心"能不能初始化、能不能读写、能不能拿到设备信息"
对应的实现类 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
有了这张图,后面再看 Adapter、Service、Test、ViewModel 四层,就不会觉得它们是零散拼起来的。
1)换厂商实现时,影响面最小
如果以后变成:
SamsungUfsAdapterQualcommUfsAdapterCustomIoctlUfsAdapter
理论上只要在依赖注入里替换注册即可,上面的 UfsService、TestRunner、TestCase 基本不用动。
2)更利于做 Mock 和联调
在很多项目早期,硬件、驱动、SDK 并不是同时到位的。
这时候你完全可以先做一个:
csharp
public class MockUfsAdapter : IUfsAdapter
先把 UI、测试流程、日志、结果展示全部联通。等真实设备 ready,再把底层实现替换进去。
这一点对工具开发非常重要,因为它能显著减少"等硬件"的空转时间。
四、Service 层:把"设备会话"抽出来
很多测试工具一开始容易忽略会话概念,直接哪里需要就哪里初始化设备。
这会带来两个问题:
- 初始化逻辑到处散落
- 多个测试项重复开关设备,既慢又不稳定
所以这里我单独做了 UfsSession 和 UfsService。
UfsSession 做什么?
它表示一次已经打开的设备会话,里面维护:
DeviceInfoOpenedAtIsOpen
也就是说,后面的测试项拿到的不是"裸适配器",而是"一个已打开的会话"。
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 自己初始化设备
- 自己打开连接
- 自己跑测试
- 自己关闭资源
看起来每个用例很"独立",但实际问题很多:
- 重复初始化设备:多个测试项会反复触发底层初始化。
- 执行效率低:本来一次打开会话就够,却要做 N 次。
- 资源状态不一致:某个用例忘了释放,后面的用例就可能出问题。
- 异常场景难收口:一旦中间某个测试失败,资源回收不容易统一保证。
所以现在的方式是:
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 -> VendorUfsAdapterUfsService- 多个
ITestCase TestRunnerTestControllerMainViewModelForm1
这意味着什么?
1)新增测试项几乎零侵入
比如你新增一个:
csharp
builder.Services.AddTransient<ITestCase, PowerCycleTest>();
只要它实现了 ITestCase,TestRunner 就会自动把它纳入执行队列。
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,并把工具逐步做成可落地的工程版本。