TWAIN协议实战:扫描仪对接中的状态机与参数配置详解 简介这是面向C开发者的TWAIN扫描设置示例工程旨在帮助需要为应用集成扫描仪功能的读者快速理解TWAIN接口的调用与参数配置流程。项目源码完整演示了从初始化数据源、选择扫描设备到设置分辨率、色彩模式等扫描参数再到获取并保存图像的核心操作同时涉及TWAIN状态机、数据源管理器DSM及常见错误处理机制。压缩包共40个文件以13个头文件和11个C源文件为主体配合BMP/ICO图标、Visual Studio工程文件sln、vcproj及可执行程序整体约460KB结构清晰便于直接打开工程阅读。已有282人浏览学习。通过该工程读者可掌握TWAIN在C环境下的完整集成方法理解Scanner.h等头文件中的类与函数设计并学会根据实际需求灵活调整扫描参数快速搭建具备图像采集功能的应用原型尤其适合有C基础、初次接触TWAIN协议的开发者。1. 这台扫描仪调不通往往不是硬件问题接手过扫描仪对接的工程师基本都绕不开 TWAIN 这个协议但它的资料贫瘠程度在 Windows 生态里相当罕见。网上能找到的示例大多是零散的片段要么止步于打开数据源要么卡在图像传输回调里出不来。ScanSetting.rar是一份可以完整编译的 VC6 工程包含 TWAIN 头文件、C 封装类、扫描参数设置对话框和 BMP 保存链路适合三种人第一次接触 TWAIN 状态机、需要把扫描功能集成进 MFC 应用、以及想弄明白 DSM 和 DST 之间消息流转细节的开发者。它不像 SDK 文档那样事无巨细地罗列常量而是把从TwainOpen到TwainClose的一整条路径摆出来。2. TWAIN 状态机与 DSM为什么先理解协议再碰代码2.1 DSM、DST、APP 三角关系与项目里的对应文件TWAIN 的架构可以简化为三个角色应用程序APP、数据源管理器DSM、数据源DST。APP 是发起方DSM 是系统级的中间协调者负责枚举和管理扫描仪驱动DST 则直接和硬件打交道。在这个工程里TwainCpp.cpp实现了 APP 侧的封装核心工作是维护一个TW_IDENTITY结构体并在 DSM 中注册Scanner.cpp则把 TWAIN 调用包装成更易用的接口比如OpenScanner和ScanDocument。理解这条链路的意义在于TWAIN 的每一个MSG_消息都要求 APP 处于正确的状态否则 DSM 会直接返回错误码常见的TWCC_SEQERR就是这么来的。工程的twain.h和twaincpp.h分别对应 TWAIN 官方头文件和 C 封装层。twain.h定义了TW_xxx结构体、消息 ID 和常量twaincpp.h则把这些 C 风格的接口包装成类方法。注意twaincpp.h不是 TWAIN 组织提供的官方文件而是这个作者自己写的封装这反而是它的价值你能看到真实的 C 适配方式包括如何把TW_IDENTITY的生命周期管理、如何把 DSM 的DSM_Entry回调映射到消息循环里。2.2 状态机七态与 MSG_XFERREADY 的真实走向TWAIN 规范定义了 7 个状态从S1_PRESESSION到S7_TRANSFERRING。大多数示例代码只写了前 4 个状态即打开 DSM、打开 DST、建立会话、设置参数却在S5_ACQUIRING到S7_TRANSFERRING之间处理得很潦草。实际上MSG_XFERREADY的到达标志着一个完整的扫描请求已经被 DST 接受接下来 APP 必须主动发起MSG_GET系列消息来拉取图像数据否则 DST 会一直阻塞在等待状态。这个工程的处理顺序值得细看// 从 Scanner.cpp 中抽出消息处理主干 if (pMsg-message MSG_XFERREADY) { // 通知主窗口进入图像获取流程 ::PostMessage(hWndMain, WM_TWAIN_XFERREADY, 0, 0); return TRUE; }消息被投递到主窗口后主窗口的处理函数会调用图像传输例程而不是直接在 TWAIN 回调里同步传输图像。这样做的原因有两个一是传输图像是一个可能持续数秒的操作阻塞 TWAIN 的调度线程会让 DSM 以为 APP 挂了二是把 UI 更新和传输逻辑分离方便在传输前弹出参数确认对话框。实际开发中用PostMessage而不是SendMessage也很关键前者不会等待接收方处理完才返回避免 DSM 回调线程被 UI 操作拖住。2.3 先闯过 DG_CONTROL再谈图像参数TWAIN 的消息调用统一走DSM_Entry入口参数是四个pOrigin、pDest、DG、DAT、MSG以及一个可选的pData。其中DG是数据组分为DG_CONTROL、DG_IMAGE、DG_AUDIO。DG_CONTROL负责任务管理DG_IMAGE负责图像参数和传输。这个工程里打开数据源用的就是DG_CONTROL/DAT_IDENTITY/MSG_OPENDSTW_UINT16 rc DSM_Entry(m_TwainAppID, NULL, DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, (TW_MEMREF)m_TwainSourceID); if (rc ! TWRC_SUCCESS) { // rc 不是 TWRC_SUCCESS而是像 TWRC_FAILURE 这样的业务错误 return FALSE; }这段代码中m_TwainAppID是 APP 注册到 DSM 的标识m_TwainSourceID在打开成功后保存选定数据源的标识DSM_Entry返回TWRC_SUCCESS表示成功返回其他值则需要用DAT_STATUS/MSG_GETSTATUS查询具体失败原因。注意m_TwainSourceID的清零时机必须在调用MSG_OPENDS之前用TW_IDENTITY的默认构造清零否则 DSM 校验时会认为你传入了非法结构体。3. 工程结构拆解从压缩包文件反推 TWAIN 调用链3.1 压缩包文件分拣哪部分是脚手架、哪部分是 TWAIN 核心解压ScanSetting.rar后文件可以分为三类。第一类是 MFC 应用脚手架包括MainFrm.cpp、SettingView.cpp、SettingDoc.cpp这些是 VC6 的 AppWizard 自动生成的框架代码和 TWAIN 本身关系不大第二类是 TWAIN 核心代码包括twain.h、twaincpp.h、TwainCpp.cpp、Scanner.cpp、Scanner.h第三类是辅助模块比如BmpFile.cpp负责把抓到的DIB数据写成 BMP 文件TrueColorToolBar.cpp处理真彩色工具栏的绘制逻辑。对于想复用这套代码的人建议只把第二类和第三类文件拿到自己的工程里不需要把整个 MFC 框架搬走。Scanner.h是 TWAIN 封装层的门面它暴露的接口数量直接决定了上层模块的复杂度。这个工程里Scanner.h的接口控制得比较克制大概只有打开、扫描、获取状态、关闭这几个核心方法外加一些参数配置入口这种设计值得学习TWAIN 的错误处理足够复杂如果封装类把太多细节暴露给调用方业务代码会变得不可维护。3.2 核心调用链的拆解TwainOpen、TwainAcquire、TwainClose扫描一个文档的完整调用链从打开到关闭的序列是// 1. 初始化 APP 身份 CTwain::InitTwain(hWndMain); // 内部填充 TW_IDENTITY // 2. 弹出选择扫描仪对话框 CTwain::SelectSource(); // 3. 打开数据源进入 S4_EXECTIVE 状态 CTwain::OpenSource(); // 4. 设置扫描参数分辨率、色彩、纸源 CTwain::SetScanParam(dpi, colorMode, paperSize); // 5. 触发扫描进入 S5_ACQUIRING CTwain::Acquire(); // 6. 等待 MSG_XFERREADY然后进入传输循环 // 7. 传输完成返回到 S5_ACQUIRING最终关闭 CTwain::CloseSource(); CTwain::ExitTwain();每一步都会在内部调用DSM_Entry而DSM_Entry的参数组装是出错高发区。比如SelectSource()实际上发送的是DG_CONTROL/DAT_IDENTITY/MSG_USERSELECTDSM 会弹出一个系统扫描仪选择框用户选择后返回TW_IDENTITY结构体。这一步如果返回TWRC_CANCEL表示用户取消了选择业务层应该处理这个分支而不是直接报错。TwainClose的调用顺序也有讲究必须先发MSG_CLOSEDS关闭数据源再发MSG_CLOSEDS注销 App 身份顺序反了会导致 DSM 内部资源泄漏第二次调用扫描直接卡死。这个工程的析构函数里可以看到对这两个调用的显式判断。3.3 消息映射中 TWAIN 消息的路由Setting.cpp中有一段消息映射代码这是链接 TWAIN 回调与 MFC 窗口的桥梁。TWAIN 的 DSM 在扫描仪状态变化时会向注册的窗口句柄发送自定义消息这个工程把消息号定义在Scanner.h里#define WM_TWAIN_XFERREADY (WM_USER 101) #define WM_TWAIN_STATUS (WM_USER 102)消息映射如下BEGIN_MESSAGE_MAP(CSettingView, CView) ON_MESSAGE(WM_TWAIN_XFERREADY, CSettingView::OnTwainXferReady) ON_MESSAGE(WM_TWAIN_STATUS, CSettingView::OnTwainStatus) END_MESSAGE_MAP()OnTwainXferReady里做的事其实是调用传输函数但为了避免 UI 线程卡顿它只做传输请求的发起真正的数据读取放在一个独立函数里。注意如果你的窗口类不是CView而是CWnd或CDialog消息映射的位置和参数不变但要确保ON_MESSAGE宏里的函数名和实现完全一致否则编译通过却收不到消息这种问题往往要调试很久。4. ICAP 与 CAP 参数设置从对话框看扫描仪能力协商4.1 参数家族的划分CAP 与 ICAPTWAIN 把参数分为两类CAP_开头的通用能力和ICAP_开头的图像能力。CAP_系列比如CAP_INDICATORS是否显示扫描仪 UI、CAP_AUTOSCANICAP_系列比如ICAP_XRESOLUTION、ICAP_PIXELTYPE、ICAP_BITDEPTH。区分这两类参数的意义在于它们的取值方式不同CAP_参数通常通过DAT_CAPABILITY/MSG_GET以TW_CAPABILITY结构体传递而ICAP_参数在设置前要先查询设备是否支持否则会收到TWRC_FAILURE加上TWCC_CAPUNSUPPORTED错误码。4.2 分辨率与色彩模式的设置代码这个工程的AreaPropertiesDialog.cpp实现了参数设置对话框核心逻辑是SetScanParameter函数。以分辨率为例它用的是OneTouch方式而不是传统的TW_CAPABILITY结构体TW_CAPABILITY twCap; memset(twCap, 0, sizeof(twCap)); twCap.Cap ICAP_XRESOLUTION; twCap.ConType TWON_ONEVALUE; twCap.hContainer GlobalAlloc(GHND, sizeof(TW_ONEVALUE)); TW_ONEVALUE *pVal (TW_ONEVALUE *)GlobalLock(twCap.hContainer); pVal-ItemType TWTY_FIX32; pVal-Item FloatToFIX32(300.0); // 300 DPI GlobalUnlock(twCap.hContainer); TW_UINT16 rc DSM_Entry(m_TwainAppID, m_TwainSourceID, DG_CONTROL, DAT_CAPABILITY, MSG_SET, (TW_MEMREF)twCap); GlobalFree(twCap.hContainer);这里的浮点运算是关键。TWTY_FIX32是一个 32 位定点数高 16 位是整数部分低 16 位是小数部分。直接用(TW_FIX32){300, 0}赋值也行但用FloatToFIX32转换的好处是不管界面取到的是什么数值都能无损转成 TWAIN 期望的格式避免 255.999 和 256.0 这类边界问题。GlobalAlloc和GlobalLock是 Windows 全局内存的标准用法但注意MSG_SET完成后要立刻GlobalFree否则每设置一次参数就泄漏一块内存扫描 100 次就掉 100 块。色彩模式ICAP_PIXELTYPE的设置与分辨率几乎一样只是Item的值从TWPT_GRAY、TWPT_RGB、TWPT_BW三个常量中取一个。一个实际的建议是设置ICAP_PIXELTYPE之前先调用MSG_GET确认设备是不是支持该模式有的扫描仪在TWPT_RGB彩色模式下只支持 24 位真彩你如果叠加设置ICAP_BITDEPTH到 48 位设备会返回TWCC_BUMMER。更稳妥的做法是用MSG_GET拿到当前模式然后只发送需要变更的那一项参数不要一次性设置全部。4.3 纸源参数与扫描区域CAP_PAPERHANDLING是纸源设置的入口常见的取值包括TWHL_AUTO自动文档进纸器、TWHL_PLATEN平板玻璃、TWHL_FEEDER进纸器。该参数在批量扫描场景下非常重要如果你要扫描整叠文档但设备默认在平板模式每次只能扫一张就需要人工干预。下面的代码展示了如何设置进纸器模式// 设置纸源为自动进纸器 twCap.Cap CAP_PAPERHANDLING; pVal-ItemType TWTY_UINT16; pVal-Item (TW_UINT32)TWHL_AUTO; rc DSM_Entry(m_TwainAppID, m_TwainSourceID, DG_CONTROL, DAT_CAPABILITY, MSG_SET, (TW_MEMREF)twCap);另外一个与纸源配合使用的参数是ICAP_SUPPORTEDSIZES它定义了扫描区域的标准尺寸比如TWSS_A4或TWSS_LEGAL。设备启用自动裁切后ICAP_SUPPORTEDSIZES的设置结果会直接决定输出图像尺寸这个参数也会影响传输的缓冲区大小计算。你还需要知道这两项参数不是所有设备都支持尤其是低端馈纸式扫描仪所以在设置之后最好回读一次核对返回值是否和你设置的一致。5. 图像数据传输与保存三态传输模式中的关键选择5.1 三种传输模式Native、Memory、FileTWAIN 规范定义了三种图像传输模式TWSX_NATIVE原生句柄、TWSX_MEMORY内存缓冲、TWSX_FILE直接写入文件。TWSX_NATIVE是最常用也最容易实现的方式设备返回一个 DIB 的全局内存句柄APP 把它转成HBITMAP即可。TWSX_MEMORY则会分块传输APP 需要反复调用MSG_GET获取下一块数据适合超大图像TWSX_FILE则把数据直接写入设备指定的文件适合告诉用户“保存为 PDF 或 TIFF” 的场景。5.2 Memory Transfer 缓冲循环的完整代码这个工程的Scanner.h中封装了内存缓冲传输的过程精简后的核心如下// 设置传输模式为 Memory Transfer TW_UINT16 rc SetCapability(CAP_XFERCOUNT, TWTY_INT16, 1); if (rc ! TWRC_SUCCESS) return FALSE; // 通知设备开始传输 rc DSM_Entry(m_TwainAppID, m_TwainSourceID, DG_IMAGE, DAT_IMAGELAYOUT, MSG_SET, (TW_MEMREF)layout); rc DSM_Entry(m_TwainAppID, m_TwainSourceID, DG_IMAGE, DAT_IMAGEINFO, MSG_GET, (TW_MEMREF)imgInfo); // 循环拉取内存块 BYTE *pBuf NULL; TW_UINT32 bytesRead 0; do { TW_MEMORY mem; memset(mem, 0, sizeof(mem)); mem.Flags TWMF_APPOWNS; rc DSM_Entry(m_TwainAppID, m_TwainSourceID, DG_IMAGE, DAT_IMAGEMEMXFER, MSG_GET, (TW_MEMREF)mem); if (rc ! TWRC_SUCCESS rc ! TWRC_XFERDONE) break; // mem.Buffer 是本次取到的数据mem.BytesWritten 是有效字节数 // 把 mem.Buffer 追加到 pBuf 或直接写入目标文件 bytesRead mem.BytesWritten; // 释放本次缓冲区准备下一次 GlobalFree(mem.Buffer); } while (rc ! TWRC_XFERDONE); // 传输完成发送 MSG_ENDXFER 结束本次扫描 rc DSM_Entry(m_TwainAppID, m_TwainSourceID, DG_CONTROL, DAT_PENDINGXFERS, MSG_ENDXFER, (TW_MEMREF)NULL);上述循环中的关键点在于rc TWRC_XFERDONE表示整个图像的数据已经传输完成这时必须调用MSG_ENDXFER来结束本次扫描否则设备会认为传输还没结束下次调用MSG_GET会返回脏数据。TWMF_APPOWNS的含义是 APP 负责释放缓冲区内存如果设置成TWMF_DEVICEWOWNS则设备会管理内存你读取完数据后不能自行释放。选取哪种模式通常取决于图像大小你可以先用MSG_GET查询DAT_IMAGEINFO得到TW_IMAGEINFO里的ImageLength和ImageWidth再决定走哪条分支。5.3 BmpFile.cpp 的保存实现与 DIB 兼容性BmpFile.cpp负责把内存中的 DIB 数据落盘为 BMP 文件。这个模块的存在很实用因为 TWAIN 驱动返回的 DIB 是标准 Windows 设备无关位图但很多开发者会惯性当成原始像素数据直接写文件结果写出来的 BMP 无法打开。BmpFile.cpp实现了从 DIB 到文件的头转换// DIB 转 BMP 文件的关键路径 BITMAPFILEHEADER bmf; BITMAPINFOHEADER bmi; // 从 DIB 中提取 BITMAPINFOHEADER memcpy(bmi, pDib, sizeof(BITMAPINFOHEADER)); // 计算像素数据偏移如为 BI_RGB偏移为 bmiHeader 的大小加调色板大小 DWORD dwOffset sizeof(BITMAPINFOHEADER); if (bmi.biBitCount 8) dwOffset (1 bmi.biBitCount) * sizeof(RGBQUAD); DWORD dwSize bmi.biSizeImage dwOffset; bmf.bfType 0x4d42; // BM bmf.bfSize dwSize; bmf.bfOffBits dwOffset; // 写入文件头、信息头、像素数据这里的biSizeImage常常为 0尤其是设备用TWSX_MEMORY传输时很多驱动没有填这个字段。如果你依赖biSizeImage分配缓冲区会发生分配 0 字节然后写越界的问题。标准的做法是用((bmi.biWidth * bmi.biBitCount 31) / 32) * 4 * abs(bmi.biHeight)重新计算样本行大小再乘上行数。bmi.biHeight为正表示自底向上存储为负表示自顶向下读写像素时注意遍历顺序否则图像上下颠倒。6. 状态残留与资源释放TWAIN 排错清单与进阶技巧6.1 状态残留与重复初始化的排查顺序TWAIN 的典型坑是状态残留。如果你的程序第一次扫描正常第二次崩溃或者第二次打开数据源返回TWCC_SEQERR基本可以断定是上一次会话没有正常关闭。排错清单如下检查MSG_CLOSEDS是否完成。TwainClose里如果只是发完消息就不管了DSM 可能还停留在S4_EXECTIVE状态此时再次调用MSG_OPENDS会因状态不合法而失败。好的做法是在关闭后调用MSG_GETSTATUS确认状态已经回到S3_然后才执行后续清理。检查TW_IDENTITY是否被意外修改。有的驱动在MSG_OPENDS之后会修改你传入的TW_IDENTITY如果你保存的是值拷贝而不是地址下次调用时 ID 已经不合法。多数据源并存时最好每个源保存一个独立TW_IDENTITY避免相互覆盖。最后检查CAP_XFERCOUNT。这个参数表示一次会话期望传输的图像张数默认值是 1。如果你设置成 0某些驱动会认为你不想接收图像然后直接终止传输如果你设置大于 1驱动会期待你连续多次调用MSG_GET不处理就会导致状态卡在S6_。6.2 用状态码读设备错误MSG_GETSTATUS是排错时的通用入口。它的调用方式和 CAP 类似返回一个TW_STATUS结构体主要看ConditionCode字段。遇到返回码不是TWRC_SUCCESS时按下面的条件判断ConditionCode意义建议处理TWCC_BUMMER设备本身报错不明确检查线缆连接确认驱动版本TWCC_CAPUNSUPPORTED参数不被支持回读参数能力枚举先查询再设置TWCC_SEQERR状态机顺序错误检查状态转移步骤通常是在未打开数据源时发 MSG_SETTWCC_INVDAT传入的数据结构非法检查TW_CAPABILITY、TW_IMAGEINFO是否清零对于TWCC_SEQERR一个实用的技巧是在你自己的代码里维护一个简化的状态变量记录当前处于S1到S7哪个阶段然后每次调用DSM_Entry前断言当前状态与消息是否匹配。网络上很多 c 面试题都会考“什么是状态机”你如果在面试时说自己写过 TWAIN 状态机远比背教科书里的定义更有说服力。6.3 资源释放的边界与进阶优化资源释放需要注意三点第一GlobalFree必须在DSM_Entry返回之后再调用因为某些驱动在返回前仍会读取内存第二MSG_ENDXFER后要紧接着调用MSG_RESET这个调用有双重作用一是告诉 DSM 可以释放内部缓冲二是让设备回到S5_ACQUIRING状态以接收下一次扫描指令第三MSG_GET返回的TW_MEMORY.Buffer是驱动分配的内存如果你选择TWMF_APPOWNS就一定要用GlobalFree释放不要用free或delete。再给一个进阶优化如果业务需要连续扫描多张文档不要每张都走完打开设备 → 设置参数 → 扫描 → 关闭设备的完整链路。正确做法是在扫描第一张之前完成所有参数设置之后每次只需从S5_ACQUIRING状态开始直接调用MSG_GET扫完后再调用MSG_ENDXFER返回。这个流程能省掉一半的消息往返批量扫描 50 张时的耗时差距非常明显。代码层面只需在Acquire前增加一个hasSession标志判断即可注意此时TwainClose只在最外层被调用一次。本文还有配套的精品资源点击获取