ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Delphi 12串口通信实战:nrCommLib Pro源码解析与避坑

Delphi 12串口通信实战:nrCommLib Pro源码解析与避坑

简介:这份面向 Delphi 12 开发者的通信控件完整源码包,围绕 nrCommLib Pro v9.54 展开,适合需要在桌面应用中集成串口通信、终端交互、设备信息读取及条码扫描等能力的开发者参考。压缩包共收录 462 个文件、约 18.7MB,以 95 个 pas 源码、66 个 dcu 编译单元、43 个 dfm 窗体、33 个 dpr 工程及 37 个 cpp、19 个 h 文件为主,并配有 res 资源、示例 exe 和 chm、hlp 帮助文档,兼顾 Pascal 与 C++ Builder 环境下的查阅和二次开发。示例工程覆盖终端模拟、串口设备枚举、条码扫描、语音与 Kermit 传输等典型场景,便于理解控件调用方式和协议处理思路。已有 153 人学习下载,完整源码与示例工程适合用于控件机制研究、项目集成验证及通信模块排错参考。

1. 串口通信在 Delphi 12 里到底该怎么做:一个老控件的完整源码能省掉哪些事

做上位机的同行大多碰过这种场面:现场摆着一台电子秤、一台条码枪、一台老 PLC,清一色 RS232,客户要求三天内出个能跑的界面。这时候翻 delphi 控件 的老仓库,串口那一块永远是第一道坎——TComPort 这类轻量控件开个口还行,一旦遇上断线重连、粘包解析、XModem 传文件就顶不住了。nrCommLib Pro 这套 Full Source 版本的价值就在这里:它把串口、Modem、终端、文件传输这些常年重复造轮子的东西做成了一组可编译、可调试的组件,而且源码是全部给的,出问题能一路跟到 ReadFile 那一层,不用对着一个黑匣子 DCU 猜。适合谁?适合手上已经有 Delphi 12 工程、需要把设备通信做稳而不是做完的人。

2. 解包看结构:Full Source 里到底装了什么

拿到一个 7z 包,第一件事不是急着装,而是先看清楚里面分成几层。Full Source 类资源最怕的就是目录结构混乱——源码、预编译包、示例工程、文档全堆在一个根目录下,装到 IDE 里就是一场灾难。nrCommLib Pro 这类成熟控件的组织方式相对规整,按功能分层,源码和示例分开放,装之前先摸清楚这三段式结构,后面编译才不会莫名其妙地引用到旧版本单元。

2.1 解压后第一眼:源码、包、Demo 三段式

解开压缩包,把目录摊平看一遍,典型结构大致是这样(不同小版本会有出入,以实际解压结果为准):

# 解压后先看二级目录,别一头扎进去 7z x nrCommLib_Pro_v9.54_D12.7z -o./nrcomm_src cd ./nrcomm_src && find . -maxdepth 2 -type d | sort # 输出大致会是这几类 # ./Source 运行时核心单元,串口、协议、编码都在这里 # ./Packages .dpk / .dproj,Delphi 12 的包工程 # ./Demos 示例,按组件分子目录 # ./Help 帮助文档,.chm 或 .hlp # ./Lib 可能存在的预编译 DCU,装源码时要留意别用它

这三段式里最容易翻车的是Lib目录。很多包会顺手塞一份预编译好的 DCU 进去,如果你在 IDE 的 Library Path 里同时挂了Lib和Source,编译器会优先命中 DCU,于是一改源码就发现「怎么改了没效果」——因为根本没重新编译。我一般会在安装前直接把Lib挪走或者改名为Lib_bak,强制所有单元都从 Source 编译,宁可变慢几十秒,也不要让调试信息断在中间。

Source目录是这套资源的真正主体。串口通信的全部底层逻辑都在这里,包括端口枚举、握手线控制、读写线程、缓冲区管理,以及上层的协议解析。Full Source 的意义就在这——你可以在这些单元里下断点,看CreateFile返回的句柄、看ReadFile一次到底读回来多少个字节、看线程是怎么在超时之后退出的。商业控件通常只给你 DCU,出错时只能靠日志和猜,这种差异在现场排查时是致命的。

Demos目录不要跳过。成熟控件的示例往往比文档更准,因为它必须能编译通过才有资格被发出来。示例里会告诉你哪个组件配哪个单元、事件签名长什么样、典型的初始化顺序是什么。我习惯先把 Demos 里最简的那个工程打开编译一遍,能跑通再谈别的——这比读十页帮助文档有效得多。

2.2 .dpk 与 .dproj:Delphi 12 下的编译单元

Delphi 的组件包分两种:运行时包(Runtime)和设计时包(Design-time)。运行时包包含真正的功能单元,编译出来是 .bpl;设计时包依赖运行时包,只负责把组件注册到工具面板上,还带一个Register过程。装包的正确顺序是先编运行时包,再编设计时包,顺序反了会直接报「找不到单元」或者「类未注册」。

Packages目录里通常能找到对应的两组工程文件。用 Delphi 12 打开.dproj时,IDE 会提示是否转换工程格式——这一步不要犹豫,直接让它转,会生成对应新版本的.dproj.local和编译配置。转换完之后第一件事是检查Target Platforms,Delphi 12 默认可能勾了 Win64 和 Win32 两个平台,如果这个包只支持 Win32,就要把 Win64 取消掉,否则编译时会报一堆平台相关的条件编译错误。

// 典型的 .dpk 内容,重点是 contains 段和 Register 过程 package nrCommProR; // 运行时包:只有单元,没有 Register {$R *.res} {$ALIGN 8} {$ASSERTIONS ON} requires rtl, vcl, vclx; contains nrcomm in '..\Source\nrcomm.pas', nrcore in '..\Source\nrcore.pas', nrcrc in '..\Source\nrcrc.pas'; // 其余单元按实际列表补齐 end.
// 设计时包:依赖运行时包,负责注册组件图标 package nrCommProD; requires rtl, vcl, designide, nrCommProR; // 关键:requires 里引用运行时包 contains nrCommReg in '..\Source\nrCommReg.pas'; end.

这两个文件的差别是理解 Delphi 组件体系的关键。运行时包contains里列的全是功能单元,编译产物可以被最终 EXE 引用;设计时包requires里必须写上运行时包的名称,它自己不包含任何业务逻辑,只调用RegisterComponents。参数上要注意requires段的顺序不能乱,designide必须在设计时包里出现,否则Register过程里的 IDE 相关调用会链接失败。

编译时还有一个隐性坑:包工程的输出路径和搜索路径。默认情况下.dpk的单元搜索路径是相对路径..\Source,如果你把包工程从Packages目录挪到别处,相对路径立刻失效。我一般不改路径,就地在原目录编译,编译完把生成的.bpl和.dcp找出来,手工确认它们落在哪个目录——Delphi 12 默认可能输出到C:\Users\Public\Documents\Embarcadero\Studio\...\Bpl,这个路径在后续工程引用时要写对。

2.3 从单元名反推模块边界

不用等编译,光看Source目录下的文件名就能大致画出模块图。这类库的命名习惯高度一致,基本是nr前缀加功能名:

单元名前缀大致职责典型场景
nrcomm/nrcore端口打开关闭、参数配置、读写线程所有串口通信的地基
nrcrc/nrutils校验算法、字节序转换、缓冲区工具协议封包解包
终端类单元字符流渲染、回车换行处理、ANSI 转义调试串口、AT 命令交互
文件传输类单元XModem / YModem / ZModem 状态机给设备升级固件、传配置
短信 / Modem 类单元AT 命令队列、PDU 编解码工业现场的无线告警

这张表的价值在于帮你判断「我到底要用哪几个单元」。如果只是读个电子秤的重量值,nrcomm加一个校验工具就够,完全不用碰文件传输那套状态机,也就不会把一堆用不上的单元拖进 EXE。反过来,如果要做固件升级,那就必须啃传输协议的实现,而且要重点看它的超时和重传逻辑——这部分自己写一遍至少要两天,还未必稳。

单元之间的依赖关系也要留意。核心单元一般不引用 UI 单元,这是好设计;但有些包会在核心单元里uses了Forms或者Dialogs,一旦这样,你在控制台程序或者服务程序里就没法用它。翻源码时顺手 grep 一下uses Forms,能提前避开这个雷。

3. 核心模块拆解:一次收发从打开端口到回调的完整链路

装包只是准备工作,真正要读懂的是数据从设备进来、到你的代码拿到字节,中间经过了哪些层。串口通信看着简单——不就是打开、读、写、关——但每一层都有坑,尤其是缓冲和线程这两块,写错了程序在办公室跑得好好的,到现场就开始丢包。这一章按一次完整收发的顺序,把关键代码和参数拆开讲。

3.1 打开端口:属性表其实就决定了一半的稳定性

打开端口之前要先配参数,参数配错,后面所有的调试都是白费。常见的配置项和取值含义:

属性常用取值说明
端口名COM3/\\.\COM10端口号大于 9 时必须用\\.\前缀
波特率9600 / 19200 / 115200必须和从设备完全一致,差一点都不行
数据位8 / 7多数设备是 8,老仪表可能是 7
停止位1 / 29600 下多数是 1
校验位None / Even / Odd工业仪表常用 Even,通信模块常用 None
流控None / RTS-CTS / XON-XOFF三线制接法必须设 None,否则永远收不到数据
// 打开端口的标准姿势:先配置,最后一步才 Active := True procedure TFormMain.OpenPort(const AName: string); begin nrComm1.Active := False; // 幂等,防止重复打开 nrComm1.CommName := AName; // 端口号大于 9 时内部需补 \\.\ 前缀 nrComm1.BaudRate := br115200; // 与从设备严格一致 nrComm1.DataBits := db8; nrComm1.StopBits := sbOne; nrComm1.Parity := ptNone; nrComm1.Handshake := hsNone; // 三线制接法必须关闭硬件流控 nrComm1.BufferSize := 8192; // 接收缓冲区,高频设备建议调大 try nrComm1.Active := True; // 到这一步才真正 CreateFile except on E: Exception do Log('open %s failed: %s', [AName, E.Message]); end; end;

这段代码里有几个参数值得单独说。CommName在端口号小于等于 9 时写COM3就行,一旦到了COM10及以上,Windows 的CreateFile需要\\.\COM10这种格式,好在多数封装库内部已经做了转换,但如果你直接调 API 就必须自己补。Handshake是最容易出事的一项:现场为了省钱只接 RX、TX、GND 三根线,程序里却开着 RTS/CTS 硬件流控,结果就是发送一直阻塞、接收一个字节都收不到,现象看起来像「设备没反应」,实际是流控在等一根永远不会拉低的信号线。

BufferSize是另一个实际问题。默认缓冲区通常只有 1024 或 4096 字节,如果你的设备是 1ms 一帧连续上报,主线程稍微卡一下缓冲区就溢出了,溢出之后底层驱动会直接丢数据,而且不报错。把缓冲调大到 8K 甚至 32K 是低成本高收益的做法,代价只是多占一点内存。

Active := True要放在 try 里。端口被别的程序占用、USB 转串口没插好、权限不足,都会在这一步抛异常。现场最常见的现象是设备管理器里明明有COM3,程序一打开就报拒绝访问——多半是上一版程序没退干净,句柄还挂在系统里。

3.2 OnReceiveData 与缓冲区:数据不是「一次性到齐」的

串口是字节流,不是消息流。设备发 20 个字节的一帧,你可能第一次收到 8 个,第二次收到 12 个,也可能一次收全,取决于驱动调度和你的读取时机。很多人第一次写串口代码时,在事件里直接把收到的字节当完整帧解析,在办公室的低速设备上跑得挺好,一换成 115200 的高速设备立刻错帧——这就是没处理粘包和半包。

// 事件里只做一件事:把字节搬进自己的缓冲,然后交给解析器 procedure TFormMain.nrComm1ReceiveData(Sender: TObject; Buffer: Pointer; BufferSize: Cardinal); var n: Cardinal; begin if BufferSize = 0 then Exit; // 关键:先申请空间,再一次性拷进去;不要在事件里做解析 n := Length(FRecvBuf); SetLength(FRecvBuf, n + BufferSize); Move(Buffer^, FRecvBuf[n], BufferSize); // 只置标志,解析放到主线程定时器或独立解析线程里做 FHasNewData := True; end;

Buffer是底层缓冲区的一个指针,BufferSize是这一次回调拿到的字节数。注意这个指针只在回调执行期间有效,事件返回之后内存可能被复用,所以必须立刻Move出来,不能把指针存起来稍后再读——这是新手最常犯的错,现象是「有时候数据对,有时候是乱码」。

SetLength加Move的组合是动态数组追加的标准写法,但频率高的时候会引发反复的内存重分配。生产代码里我一般改成环形缓冲区或者固定大小的块式缓冲,避免在 1ms 级别的事件里做堆操作。这里的写法是为了讲清楚语义,真上项目要换实现。

事件里不做解析,是为了把「收数据」和「处理数据」解耦。解析可能要查表、要计算校验、要更新界面,这些操作耗时不确定,一旦卡在事件里,下一次回调就会被推迟,缓冲区堆积,严重时直接丢包。用一个FHasNewData标志把活推给别的线程,事件本身的执行时间可以控制在微秒级。

还有一点:OnReceiveData的触发时机由底层决定,可能是每收到一批就触发一次,也可能是攒够一定量才触发。不要假设「一次事件等于一帧」,也不要假设「事件一定会在多少毫秒内到来」。所有超时判断都应该基于你自己的定时器,而不是靠事件间隔。

3.3 线程模型:为什么事件里不能干重活

串口读写在 Windows 上本来就是异步的,底层驱动有自己的内核缓冲,但你的应用层如果处理得慢,缓冲区迟早会被填满。这是串口程序卡顿和丢包的根本原因,跟控件好坏关系不大,是架构问题。

常见的三种线程模型摆在这里对比:

模型做法优点风险
全主线程事件里直接解析并刷界面代码简单,无同步问题界面一卡就丢包,设备一多必崩
主线程收 + 定时器解析事件入缓冲,Timer 定时消费改动小,同步简单解析慢时仍然拖界面
独立解析线程事件入线程安全队列,worker 消费吞吐稳,界面流畅要处理同步和退出顺序
// 独立解析线程的骨架:队列 + 信号量,退出时必须能干净收尾 type TParseThread = class(TThread) private FQueue: TThreadedQueue<Byte>; // 线程安全队列,Delphi 自带 protected procedure Execute; override; public constructor Create(AQueue: TThreadedQueue<Byte>); end; procedure TParseThread.Execute; var B: Byte; Frame: TBytes; begin while not Terminated do begin // 100ms 超时,保证 Terminated 能被及时检查到 if FQueue.PopItem(B) = wrSignaled then begin Frame := FeedByte(B); // 状态机逐字节喂入 if IsFrameComplete(Frame) then Synchronize(procedure begin OnFrameReady(Frame); end); end; end; end;

TThreadedQueue是 Delphi 自带的线程安全队列,PopItem带超时参数,这是它比TList加临界区好用的地方——超时让你有机会检查Terminated,不至于线程卡在等待上永远退不出去。退出顺序很关键:先Terminate,再往队列里塞一个哨兵值或者等超时,最后WaitFor,三步缺一不可。少写WaitFor的后果是程序关掉之后进程还挂在任务管理器里,现场用户会以为程序崩了。

Synchronize会把调用切回主线程执行,所以界面更新放在这里面是安全的,但它也会阻塞解析线程直到主线程处理完。如果界面更新很频繁,用Queue代替Synchronize更好,后者是异步投递,不等待返回。这个差别在高频数据场景下非常明显。

还有一个容易忽略的点:串口对象本身的线程亲和性。多数封装的Active := False必须在创建它的线程里调用,如果在解析线程里直接关端口,可能触发访问冲突。稳妥做法是主线程发指令、设置标志位,等 worker 自己检测到标志再退出,最后由主线程关端口。

3.4 协议层与上层组件:终端、文件传输、短信

底层打通之后,上层组件才是这套库拉开差距的地方。终端组件负责把字节流渲染成可读字符,处理回车换行、退格、ANSI 转义序列——别小看这个,调试 AT 命令的时候,如果没有终端组件,你得自己处理\r\n和各种控制字符,光是把回显对齐就要花半天。

文件传输组件实现的是 XModem / YModem / ZModem 这类经典协议,本质是带确认和重传的分块传输状态机。这类协议的细节很琐碎:块大小、校验方式(累加和还是 CRC16)、超时重传次数、文件名和长度的传递方式。自己实现一版能用的至少要两三天,而且边界情况很难覆盖全。源码在手的好处是出问题能看状态机卡在哪个状态——是等 ACK 超时了,还是 CRC 校验一直失败,日志一打就清楚。

短信和 Modem 相关的组件则是把 AT 命令的收发、响应匹配、PDU 编解码封装起来。工业现场用无线模块做告警上行的场景不少,AT 命令的坑主要在响应匹配上:模块返回OK之前可能先回一堆+CREG、+CSQ之类的主动上报,如果不做甄别,命令队列会全乱。这类组件一般会把「发命令 → 等期望响应 → 超时重试」做成一个队列,看源码的时候重点看它的超时和重试策略,这两个参数直接决定现场稳定性。

4. 跑起来:Delphi 12 装包、编译、第一个 Demo

理论讲完,落到操作。这一节按顺序走一遍:确认环境、编译包、装组件、写第一个能收能发的窗口、用虚拟串口对自测。每一步都写清楚失败时看哪里,照着做基本能在半小时内跑通。

4.1 编译前先把搜索路径和环境确认掉

装包之前先把 IDE 的环境理干净,这一步省不得。Delphi 12 的库路径分全局(Library Path)和工程级(Search Path),全局路径里如果残留了旧版本的单元目录,编译时会优先命中旧单元,报错信息还特别误导人。

操作顺序:打开Tools → Options → Language → Delphi → Library,在Library path里检查有没有旧版本的nr相关路径,有就删掉。然后确认Bpl输出目录,Tools → Options → Environment Options → Delphi Options → Library,记下Package output directory的值,后面装包完成后要去这个目录找.bpl。

磁盘上还建议做一件事:把解压出来的整个目录放在一个不含中文和空格的路径下,比如D:\dev\nrcomm954\。Delphi 的编译器对路径里的空格处理一直不算好,.dproj里的相对路径遇到空格容易断,这种问题排查起来极其浪费时间。

4.2 装包与组件面板

进Packages目录,先打开运行时包的.dproj。IDE 提示转换工程时选「是」,然后在项目管理器里右键 →Build。编译成功的标志是输出窗口出现Success,并且在之前记下的Package output directory里能看到对应的.bpl。

# 编译后确认产物,路径按你机器上的实际值替换 dir "C:\Users\Public\Documents\Embarcadero\Studio\22.0\Bpl\nrComm*.bpl" # 顺便确认 dcp 也生成了,后续工程引用需要它 dir "C:\Users\Public\Documents\Embarcadero\Studio\22.0\Dcp\nrComm*.dcp"

运行时包编译通过后,再打开设计时包,同样 Build。设计时包编译成功会自动弹出安装提示,选「是」即可;如果没弹,右键工程 →Install。装完之后打开一个 VCL 工程,组件面板上应该能看到新增的页签。看不到就先别怀疑包有问题,去Component → Install Packages里确认设计时包是否在列表中并且勾选状态正常——常见情况是编译成功了但没执行安装。

如果编译运行时包时报「找不到单元 xxx」,八成是.dpk里的相对路径和实际目录对不上。直接在 IDE 的工程选项里把Source目录加进Search path(用绝对路径),比改.dpk快。这个改动只影响当前包工程,不会污染全局配置。

4.3 十分钟写一个能发能收的窗口

新建一个 VCL Forms Application,放一个串口组件、两个 Memo、一个 ComboBox 选端口、一个 Button 发送。端口列表用系统 API 枚举,不要硬编码COM1到COM8,现场设备接在COM12上的情况太常见了。

// 枚举本机可用串口,填进 ComboBox procedure TFormMain.FillPorts; var I: Integer; begin cbPort.Items.Clear; for I := 1 to 256 do if nrComm1.IsPortAvailable('COM' + IntToStr(I)) then // 以实际 API 为准 cbPort.Items.Add('COM' + IntToStr(I)); if cbPort.Items.Count > 0 then cbPort.ItemIndex := 0; end; // 发送:先转成字节数组,再一次性写出 procedure TFormMain.btnSendClick(Sender: TObject); var Data: TBytes; begin if not nrComm1.Active then Exit; Data := TEncoding.ASCII.GetBytes(mmSend.Text + #13#10); // 多数设备以 CRLF 结尾 nrComm1.WriteData(Data[0], Length(Data)); end;

IsPortAvailable这类方法在不同版本里名字可能略有差异,以你手上的声明为准;实在没有就自己调CreateFile试打开一次再关掉,也能达到同样效果。发送时把字符串转成字节数组这一步不能省,Delphi 12 默认字符串是 Unicode,直接往串口写字符串在部分版本上会走隐式转换,出来的字节数翻倍,设备收到的全是0x00。TEncoding.ASCII明确指定编码,就没有这个歧义。

#13#10是 CRLF,绝大多数命令行式设备以此作为一帧结束。但有例外:有些称重仪表只认#13,有些 MODBUS 设备根本没有结束符,靠固定长度或时间间隔断帧。先把设备的通信协议翻出来看清楚,再决定发送的结尾是什么,这一步猜错的话,后面解析全是白忙。

5. 避坑与排查:串口通信里那些玄学问题

串口这块的问题有个特点:现象和原因经常对不上号。下面五条是这类库在实际项目里最常遇到的,每条按「现象 → 原因 → 解决」写,省得你一条条去试。

5.1 端口明明在设备管理器里,一打开就报拒绝访问

现象:设备管理器里COM3正常显示,没有黄色感叹号,程序一执行Active := True就抛异常,提示拒绝访问或者端口不存在。

原因:九成是端口被占用。上一个版本的调试程序没退干净,进程还挂在后台;或者串口调试助手开着没关;再或者这个端口被某个系统服务(比如某些虚拟串口工具的后台进程)持有。少数情况是权限问题,比如程序以普通用户运行但端口被以管理员身份启动的进程占着。

解决:先在任务管理器里把可疑进程全部结束,再重试。仍不行就用命令行查占用:handle.exe -a COM3(Sysinternals 工具),或者干脆重启机器——虚拟串口驱动出问题时重启是最快的后悔药。长期方案是在程序启动时先尝试打开再关闭做探测,把「端口被占用」这个状态显式提示给用户,而不是让它以一个看不懂的异常弹窗出现。

5.2 跑到现场就乱码,办公室却好好的

现象:同一套程序,办公室接着调试线收发正常,装到现场设备上收到的全是乱码,偶尔还夹杂几个正确字节。

原因:参数不匹配。最常见的是波特率差一档(9600 和 19200 看起来都「能收到东西」)、校验位设错、数据位设成 8 而设备是 7。其次是接线问题:RS232 的 TX/RX 接反了不会完全收不到,而是收到自己发出去的回显,看起来像乱码。还有一种隐蔽情况是设备要求硬件流控但程序里关了,导致设备发一半被丢弃。

解决:先把波特率、数据位、停止位、校验位四项逐个和设备的说明书核对,不要凭经验。然后拿一个独立的串口监听工具挂在线上抓原始字节,和在程序里收到的对比——如果监听工具收到的就是乱码,问题在物理层或参数;如果监听工具正常而程序乱码,问题在编码转换上,去查TEncoding有没有用错。

5.3 USB 转串口一拔一插,程序就哑了

现象:USB 转串口线拔掉再插上,端口号可能从COM3变成COM5,程序仍然抓着旧句柄,界面不报错,但数据再也不进来了。

原因:USB 设备的枚举是动态的,拔插之后系统会重新分配端口号,而已经打开的句柄在设备移除后失效,ReadFile返回错误但很多封装不会主动触发断开事件,于是程序就处于「以为自己还连着」的状态。

解决:注册设备变更通知(WM_DEVICECHANGE),收到DBT_DEVICEREMOVECOMPLETE就主动关端口并把状态置为断开,收到DBT_DEVICEARRIVAL就重新枚举端口列表。同时在读线程里判断读操作的返回值,连续 N 次读失败就认定连接断开,触发重连流程。重连要有退避,不要死循环狂开端口,那样会把 CPU 占满。

5.4 编译期报找不到单元 / 版本不匹配

现象:包能编译,但新建的工程一引用就报Unit nrcomm was compiled with a different version of ...,或者干脆提示找不到某个单元。

原因:Delphi 的单元二进制格式和编译器版本强绑定。你的机器上可能同时存在旧版本编译出来的 DCU,被搜索路径优先命中了。另一种情况是工程引用了运行时包,但运行时包的.dcp不在搜索路径里。

解决:第一步,全盘搜一下有没有同名的.dcu散落在别的目录,有就删掉或者把那个目录从 Library Path 里移除。第二步,把Source目录加到工程的Search path,强制从源码编译。第三步,确认工程的Runtime Packages列表里包含运行时包名,并且.dcp目录在Library path里。这三步做完基本能解决,剩下的就是彻底清理一次dcu输出目录重新编译。

5.5 界面一卡一卡,数据还丢包

现象:设备数据频率一高,界面就明显卡顿,按钮点下去要等一两秒才有反应,同时收到的数据开始出现缺帧。

原因:解析和界面刷新都在主线程做,而且是在接收事件里直接做。事件频率高的时候主线程被占满,消息队列积压,界面自然卡;同时因为处理慢,底层缓冲区被填满,驱动开始丢数据。

解决:按 3.3 节的模型改造,事件里只搬运字节,解析放到独立线程,界面更新用Queue异步投递并且做节流——比如每 100ms 才刷新一次显示,而不是每收到一帧就刷。很多人舍不得这个改动,觉得「先跑起来再说」,结果是现场数据一密就崩,最后还是得回来改,白白多花两天。

6. 二次开发:把控件包封装成你自己的通信服务层

拿到源码之后最容易犯的错,是让业务代码直接uses控件单元、直接在窗体上拖一个串口组件、在每个按钮里写发送逻辑。这样做的项目,等设备从一种换成另一种、从串口换成网口,就得全项目改一遍。我的做法是先封一层接口,把控件关在实现层里,业务只认接口。

6.1 先包一层,别让业务代码直接摸控件

接口只暴露业务需要的东西:打开、关闭、发一帧、收一帧、状态变化通知。控件的属性、事件、异常类型全部不出现在接口签名里。

// 业务层只依赖这个接口,换实现时业务代码零改动 type IDeviceLink = interface ['{6B1C2F7A-3E4D-4A21-9C58-0F2D7E9A1B33}'] function Open(const Port: string; Baud: Integer): Boolean; procedure Close; function SendFrame(const Payload: TBytes): Boolean; function IsOpen: Boolean; property OnFrame: TProc<TBytes> read GetOnFrame write SetOnFrame; end; // 串口实现:内部持有控件,把所有细节挡在外面 type TSerialLink = class(TInterfacedObject, IDeviceLink) private FComm: TnrComm; FParser: TFrameParser; procedure HandleData(Sender: TObject; Buffer: Pointer; Size: Cardinal); public constructor Create; destructor Destroy; override; function Open(const Port: string; Baud: Integer): Boolean; procedure Close; function SendFrame(const Payload: TBytes): Boolean; end;

这层的价值不只是「解耦」这种空话。真正的好处是:单元测试可以不接硬件,用一个TFakeLink实现接口,往OnFrame里灌构造好的字节流,把解析逻辑测透。硬件相关的部分(打开端口、参数配置)留给现场联调,逻辑部分在办公室就能验证完。这样做一轮下来,现场调试的时间能砍掉一大半。

SendFrame的返回值要有意义。发送失败的原因分几种:端口没开、写超时、缓冲区满。返回Boolean太粗,生产代码里我一般返回一个枚举或者抛带错误码的异常,让上层能针对不同原因做不同处理——端口没开就重连,写超时就重发,缓冲区满就等一会。

6.2 粘包与断帧:协议解析才是真正的活

串口收到的是纯字节流,业务要的是「一帧」。断帧规则通常有三种:固定长度、固定结束符、长度字段。前两种简单,第三种最稳但要注意长度字段本身的字节序。

// 状态机式的断帧器:喂一个字节,吐一帧或 nil function TFrameParser.FeedByte(B: Byte): TBytes; begin Result := nil; case FState of fsHead: if B = $AA then // 帧头 begin FBuf := [B]; FState := fsLen; end; fsLen: begin FBuf := FBuf + [B]; FExpect := B; // 长度字段,不含帧头帧尾 FState := fsBody; end; fsBody: begin FBuf := FBuf + [B]; if Length(FBuf) >= FExpect + 2 then // 帧头 + 长度 + 长度值 begin if CheckSum(FBuf) = FBuf[High(FBuf)] then Result := FBuf; FBuf := nil; FState := fsHead; // 无论校验成败都回到找帧头 end; end; end; end;

这里有几个细节值得注意。校验失败之后必须把状态机复位到找帧头,否则会一直卡在半帧状态。缓冲区FBuf用动态数组拼接在低频场景够用,高频场景要换成预分配的环形缓冲加读指针,避免频繁SetLength。FExpect一定要设上限,收到一个声称长度是 255 但实际只有 10 个字节的坏帧时,不能无限等下去,超时或者长度越界都要复位。

还有一种情况是帧头出现在数据体里。如果协议没有做字节转义(比如 HDLC 的 0x7D 转义),那么数据体里出现0xAA就会误判。稳妥的断帧器要么依赖长度字段跳过数据体,要么在帧头匹配后加一个二次校验;如果协议本身没有长度字段,那只能靠超时断帧,这时候定时间隔的取值就很关键——太短会切断正常帧,太长会拖慢响应。

6.3 怎么验证改对了

改完代码得知道有没有改对。我的习惯是备一张最小验证清单,每次动通信层都过一遍:

验证项方法通过标准
正常收发接设备发一帧读一帧3 秒内收到正确响应,校验通过
半包用虚拟串口对,分两次发一帧解析器能拼出完整帧
粘包一次发两帧连在一起解析器连续吐出两帧
断线重连拔掉 USB 转串口再插回5 秒内自动恢复,不弹异常框
高频压力100ms 一帧连续跑 30 分钟内存不涨、界面不卡、无丢帧
异常帧故意发校验错的帧解析器丢弃并复位,不卡死

这套用例不需要真实硬件也能跑前四项,用虚拟串口对软件建一对互通的端口,一边写一边读,逻辑层的问题全能暴露出来。后两项必须上真机,尤其是高频压力那一项——办公室里跑十分钟没问题,现场跑一天就内存泄漏的情况太常见了。

最后说一点我自己的教训。早年做这类项目时,总觉得「通信层简单,先把界面做完再说」,结果每次都是界面做完、业务写完,回头一调通信发现底层假设全错,只能推倒重来。从那以后我每次接新设备,都强制先把虚拟串口对搭起来、把断帧器和状态机跑通、把上面那张清单过一遍,再动手写第一个界面。这个顺序看着慢,实际省时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进