DOS下Turbo Pascal实现ModBUS/ASCII串口通信:工业协议栈的复古实践

发布时间:2026/8/18 14:53:17
DOS下Turbo Pascal实现ModBUS/ASCII串口通信:工业协议栈的复古实践 1. 项目概述一次穿越时空的工业协议栈复现最近在整理老硬盘时翻出了一个尘封已久的项目文件夹里面躺着一个用Turbo Pascal写的DOS程序通过RS-232C串口与一台老旧的PLC可编程逻辑控制器进行ModBUS/ASCII协议通信。这瞬间把我拉回了那个“蓝底黄字”的时代。今天我想把这个“复古项目”重新梳理一遍它不仅仅是一次技术怀旧更是一次对工业自动化底层通信原理的深度探索。对于现在习惯了以太网、TCP/IP、JSON/RESTful API的开发者来说理解这种“原始”的、基于字节流的串行通信能让你对现代工业物联网IIoT的底层基石有更透彻的认识。这个项目完整地展示了在DOS环境下如何使用Turbo Pascal这一经典的开发工具通过计算机的COM口RS-232C按照ModBUS/ASCII的帧格式与现场设备进行“一问一答”式的数据交换。整个过程不依赖任何现代操作系统的高级抽象全是裸的端口操作和字节处理。无论你是想了解工业通信的历史脉络还是希望深入理解串行通信协议的本质亦或是单纯享受在复古环境中编程的乐趣这个项目都能给你带来不少启发。接下来我将从设计思路、协议解析、代码实现到调试心得完整地拆解这个项目。2. 核心协议与硬件环境解析2.1 ModBUS/ASCII协议文本化的设备对话ModBUS协议是工业领域的事实标准主要有两种传输模式RTU远程终端单元和ASCII。我们项目用的是ASCII模式。与RTU模式使用紧凑的二进制数据不同ASCII模式将所有数据转换为可打印的ASCII字符进行传输。这听起来效率低但在早期缺乏高级调试工具的场合它的优势很明显你可以直接用终端软件如老式的“超级终端”看到收发到的每一个字符调试直观。一个典型的ModBUS/ASCII请求帧长这样:010300000001CRCCRLF我们来拆解一下起始符:(0x3A)标志一帧数据的开始。从站地址01以两个ASCII字符表示一个字节的十六进制值。这里01代表设备地址是1。功能码03同样用两个ASCII字符表示。03代表“读保持寄存器”。数据域00 00 00 01这里表示起始寄存器地址为0读取1个寄存器。每个字节都被展开为两个ASCII字符。CRC校验CRC循环冗余校验码同样用两个ASCII字符表示其两个字节。这是ModBUS/ASCII帧中唯一不是纯ASCII字符对的部分但它被编码为字符对后传输。结束符CR LF(0x0D, 0x0A)回车换行标志帧结束。响应帧的格式类似。这种文本化的格式使得我们在DOS下用Turbo Pascal处理字符串String类型变得异常方便无需复杂的位操作。2.2 RS-232C与DOS环境下的端口编程RS-232C是串行通信的经典标准定义了DB9或DB25接口的电气特性、信号含义。在DOS时代计算机的串口COM1, COM2是直接映射到CPU的I/O端口的编程时需要直接对这些端口进行读写。关键参数需要手动配置并与设备端严格匹配波特率如9600 bps。决定了每秒传输的比特数。数据位8位。代表一个字节的数据。停止位1位。用于标志一个字节传输结束。奇偶校验通常为无校验None、偶校验Even或奇校验Odd。用于简单的错误检测。在Turbo Pascal中我们通过Port数组如Port[$3F8]对应COM1的数据寄存器直接读写端口或者使用MS-DOS或BIOS的中断服务如INT 14h来初始化串口和收发数据。这种“寄存器级”的操作让你对“数据是如何一位一位发出去的”有最直接的感受。2.3 Turbo Pascal与DOS极简高效的开发组合Turbo Pascal 7.0是DOS时代效率极高的集成开发环境IDE。它编译速度快生成的.EXE文件小巧精悍不依赖任何外部运行时库非常适合编写这种需要直接操作硬件的工具软件。其CRT单元提供了简单的屏幕控制DOS单元则提供了端口操作、中断调用等底层接口。在这个项目中我们主要利用Port数组用于直接I/O操作。MsDos单元/Intr过程用于调用INT 14h串行通信中断。字符串处理函数如Copy,Concat,HexStr等用于构建和解析ModBUS/ASCII帧。简单的用户界面用Write/Writeln在文本模式下构建交互界面。3. 项目设计与实现思路拆解3.1 整体架构一个简单的轮询式主站这个项目的目标是实现一个ModBUS主站Master能够向从站Slave即PLC发送命令并解析响应。架构非常简单就是一个顺序执行的轮询程序初始化配置串口通信参数波特率、校验位等。构建请求帧根据用户输入或预设命令如读取某个寄存器生成符合ModBUS/ASCII格式的字符串并计算CRC。发送帧将字符串通过串口一位一位地发送出去。等待与接收等待一段时间超时设置从串口接收缓冲区读取返回的字符拼接成响应字符串。解析与验证检查响应帧的起始符、地址、功能码并校验CRC。提取与显示从响应帧的数据域中提取出有效的寄存器数据转换为十进制数显示给用户。循环或退出。这种架构清晰地将通信逻辑帧处理、CRC与硬件操作串口读写分离便于理解和调试。3.2 关键模块设计项目代码可以大致分为几个模块串口驱动模块封装INT 14h中断调用或直接端口读写提供UART_Init,UART_SendChar,UART_ReceiveChar,UART_DataReady等函数。这是与硬件打交道的底层。ModBUS帧处理模块核心逻辑所在。包含BuildASCIIFrame根据地址、功能码、数据构建帧字符串并调用CalcCRC计算校验码附加到末尾。CalcCRCModBUS CRC-16校验算法实现。这是协议栈的“灵魂”确保数据完整性。算法需要基于字节计算但结果要转换为两个ASCII字符。ParseASCIIFrame解析接收到的字符串验证起始结束符、CRC并提取出数据部分。HexStrToWord/WordToHexStr用于寄存器数据16位整数与ASCII字符串4个字符之间的转换。主控与用户界面模块一个简单的文本菜单让用户选择操作如读寄存器、写线圈输入参数并显示结果。3.3 为什么选择ASCII模式而非RTU在DOSTurbo Pascal这个特定环境下选择ASCII模式有几个务实的原因调试友好性你可以用Writeln直接把发送和接收的字符串打印到屏幕上一目了然。而RTU模式是二进制数据在屏幕上显示是一堆乱码调试困难。字符串处理优势Turbo Pascal的String类型处理文本得心应手。构建:0103...这样的帧用Concat或运算符即可。RTU模式需要处理字节数组Array of Byte在当时的Pascal中操作起来稍显繁琐。超时处理简单ASCII帧以CR LF结束接收方可以通过检测这两个字符来判断一帧是否接收完毕。RTU模式则需要依赖严格的3.5个字符时间的帧间间隔在纯软件层面计时精度难以保证。当然ASCII模式的缺点是效率低传输同样的数据量需要两倍的字符数。但在当时与低速设备如PLC通信9600波特率下这点开销完全可以接受。4. 核心代码实现与实操要点4.1 串口初始化与底层驱动我们使用DOS的INT 14h中断进行串口操作因为它比直接操作端口更兼容、更简单。初始化函数如下procedure UART_Init(ComPort, BaudRate, Parity, DataBits, StopBits: Word); var Config: Word; begin // 组合配置字 Config : (BaudRate shl 8) or (Parity shl 4) or (StopBits shl 2) or DataBits; // AX寄存器高字节放功能码00h初始化低字节放配置字 Regs.AH : $00; Regs.AL : Lo(Config); Regs.DX : ComPort; // 0COM1, 1COM2 Intr($14, Regs); // 检查AH寄存器返回值判断是否成功 if (Regs.AH and $80) 0 then Writeln(Serial port init failed!); end;实操要点ComPort参数0代表COM11代表COM2。这是DOS的约定。BaudRate参数需要转换为INT 14h识别的代码例如9600波特对应$0C。需要在代码里做一个映射。关键细节调用中断后要检查AH寄存器的最高位。如果为1表示初始化失败可能是端口不存在或参数错误。这是当时调试时最容易忽略的地方。4.2 ModBUS CRC-16校验算法实现这是协议栈中最“硬核”的部分。ModBUS使用CRC-16多项式为0x8005初始值为0xFFFF。function CalcCRC(const Data: String): Word; var i, j: Integer; crc: Word; b: Byte; begin crc : $FFFF; // 初始值 for i : 1 to Length(Data) do begin b : Ord(Data[i]); // 取一个字符的ASCII码 crc : crc xor b; // 与CRC低字节异或 for j : 1 to 8 do // 处理8位 begin if (crc and $0001) 0 then crc : (crc shr 1) xor $A001 // 多项式0x8005的位反转形式 else crc : crc shr 1; end; end; CalcCRC : crc; end;注意事项位反转ModBUS协议使用的CRC是“位反转”的。这意味着多项式0x8005二进制1000 0000 0000 0101在计算时要使用其反转形式0xA0011010 0000 0000 0001。这是新手最容易栽跟头的地方直接套用标准CRC库往往出错。输入数据CalcCRC函数的输入Data是ModBUS/ASCII帧中从起始符:之后到CRC字段之前的所有字符即地址、功能码、数据不包括起始符:和结束符CR LF。结果处理计算出的CRC是一个Word16位整数。在构建ASCII帧时需要将其转换为4个十六进制ASCII字符并且低字节在前。例如CRC计算结果为0x1234在帧中应表示为3412。4.3 帧的构建与发送构建一个读取寄存器的请求帧function BuildReadHoldingRegisters(DeviceAddr, StartReg, NumRegs: Word): String; var FrameBody, CRCStr: String; CRC: Word; begin // 构建帧体不含起始符和CRC FrameBody : WordToHexStr(DeviceAddr, 2) // 地址2字符 03 // 功能码032字符 WordToHexStr(StartReg, 4) // 起始地址4字符 WordToHexStr(NumRegs, 4); // 寄存器数量4字符 // 计算CRC对FrameBody计算 CRC : CalcCRC(FrameBody); // 将CRC转换为低字节在前的4字符字符串 CRCStr : WordToHexStr(CRC, 4); // 注意WordToHexStr通常按字节顺序输出我们需要交换字节顺序 // 假设WordToHexStr(CRC)返回1234我们需要3412 CRCStr : Copy(CRCStr, 3, 2) Copy(CRCStr, 1, 2); // 组合完整帧 BuildReadHoldingRegisters : : FrameBody CRCStr #13#10; // #13#10 即 CR LF end;发送函数procedure SendASCIIFrame(const Frame: String); var i: Integer; begin for i : 1 to Length(Frame) do begin UART_SendChar(Frame[i]); // 调用底层发送一个字符 // 这里可以加入微小延时特别是对于老式慢速串口芯片 Delay(1); // 延时约1毫秒 end; end;经验之谈字节顺序CRC的字节顺序是永远的坑。务必记住ModBUS协议是低字节在前Little-Endian。不仅CRC如此帧中多字节的寄存器地址、寄存器数量、以及响应中的数据都是低字节在前。我们的WordToHexStr需要处理这个细节。发送间隔在循环发送每个字符时加入一个微小的Delay。这是因为早期的UART芯片如8250缓冲区很小发送速度过快可能导致数据丢失。虽然INT 14h本身是阻塞的会等待发送缓冲区空但加上延时更保险。4.4 响应接收与解析接收比发送更复杂因为要处理超时和帧完整性判断。function ReceiveASCIIFrame(var Response: String; TimeoutMs: Word): Boolean; var Ch: Char; StartTime: LongInt; FrameStarted: Boolean; begin Response : ; FrameStarted : False; StartTime : GetMsCount; // 需要自己实现一个毫秒级计时器函数 while (GetMsCount - StartTime) TimeoutMs do begin if UART_DataReady then // 检查串口是否有数据 begin Ch : UART_ReceiveChar; if not FrameStarted then begin if Ch : then // 找到起始符 begin FrameStarted : True; Response : Ch; end; end else begin Response : Response Ch; // 检查是否收到结束符 CR LF if (Length(Response) 2) and (Response[Length(Response)-1] #13) and (Response[Length(Response)] #10) then begin // 收到完整帧 ReceiveASCIIFrame : True; Exit; end; end; end; // 短暂让步避免死循环占用全部CPU asm nop end; end; // 超时 ReceiveASCIIFrame : False; end;解析函数function ParseResponse(const Frame: String; var Data: array of Word): Boolean; var Addr, FuncCode, ByteCount: Word; CRCReceived, CRCCalculated: Word; FrameBody: String; i, DataIndex: Integer; begin // 1. 基本检查长度、起始符、结束符 if (Length(Frame) 11) or (Frame[1] :) or (Copy(Frame, Length(Frame)-1, 2) #13#10) then begin ParseResponse : False; Exit; end; // 2. 提取帧体去掉:和CRLF FrameBody : Copy(Frame, 2, Length(Frame)-3); // 3. 提取接收到的CRC最后4个字符 CRCReceived : HexStrToWord(Copy(FrameBody, Length(FrameBody)-3, 4)); // 注意接收到的CRC字符串是低字节在前的HexStrToWord需要能正确处理 // 4. 计算CRC对CRC之前的部分 CRCCalculated : CalcCRC(Copy(FrameBody, 1, Length(FrameBody)-4)); // 5. 校验CRC if CRCReceived CRCCalculated then begin ParseResponse : False; Exit; end; // 6. 解析地址、功能码等 Addr : HexStrToWord(Copy(FrameBody, 1, 2)); FuncCode : HexStrToWord(Copy(FrameBody, 3, 2)); // 7. 根据功能码解析数据部分... // 例如对于功能码03的响应第三个字节是字节数 if FuncCode $03 then begin ByteCount : HexStrToWord(Copy(FrameBody, 5, 2)); DataIndex : 0; i : 7; // 数据开始位置 while (i Length(FrameBody)-4) and (DataIndex High(Data)) do begin // 每4个字符两个字节代表一个寄存器低字节在前 Data[DataIndex] : HexStrToWord(Copy(FrameBody, i, 4)); Inc(DataIndex); Inc(i, 4); end; end; ParseResponse : True; end;核心技巧超时机制必须实现。工业设备可能无响应或响应慢。超时时间通常设置为几百毫秒到几秒取决于网络线缆长度和设备处理速度。GetMsCount的实现在DOS下可以通过读取BIOS数据区地址0040:006C的每日计时器大约每秒18.2次滴答来估算时间或者使用Turbo Pascal的GetTime过程获取系统时间精度较低。CPU让步在等待循环中使用asm nop end;或调用DOS单元的DosSleep如果可用来避免程序完全霸占CPU这在单任务的DOS中是个好习惯。5. 调试过程与经典问题排查实录在没有任何现代调试器的环境下调试串口通信程序是一场“硬仗”。以下是我踩过的坑和总结出的方法。5.1 问题一发送数据后设备毫无反应现象程序运行发送指示灯闪烁但PLC无任何响应读不到数据。排查思路硬件连接这是第一嫌疑。检查RS-232C电缆是否是“直连线”2-3, 3-2, 5-5交叉。很多商用串口线是“直通线”用于连接计算机和调制解调器而连接两台DTE设备如电脑和PLC需要交叉线。最简单的方法是用万用表通断档测量一下。参数匹配确认电脑和PLC的波特率、数据位、停止位、校验位完全一致。一个标点符号都不能错。最好查阅PLC的硬件手册。端口冲突DOS下你的程序是否独占访问了COM口有没有其他TSR内存驻留程序或中断服务程序也在使用串口可以尝试在干净的DOS启动盘环境下测试。电气信号如果有条件用示波器或逻辑分析仪看一下TX线Pin 3 on DB9上是否有波形。没有波形说明软件根本没发出去有波形但参数不对说明初始化错了。我的土法调试我当年没有示波器。我的方法是制作一个“环回测试”程序。将COM口的TXPin 3和RXPin 2用一根导线短接。然后写一个最简单的程序发送字符串HELLO并立即读取。如果能读回HELLO证明串口驱动和硬件本身是好的。这个办法解决了80%的底层通信问题。5.2 问题二能收到响应但CRC校验总是失败现象设备有响应灯亮程序也能收到一长串字符但ParseResponse函数总是返回CRC错误。排查思路打印原始数据在ReceiveASCIIFrame收到数据后立刻用Writeln将Response字符串的每个字符的ASCII码以十六进制形式打印出来。对比ModBUS协议手册看帧结构是否正确。重点检查CRC部分计算范围确认你的CalcCRC函数计算的是否是帧中正确的那部分。记住从地址开始到CRC字段之前。字节顺序再次强调比较你计算出的CRC转换为字符串后和接收到的CRC字符串。是你计算错了还是接收到的顺序不对手动用计算器算一遍小数据包的CRC验证你的算法。字符大小写ModBUS/ASCII协议规定使用大写字母A-F。确保你的HexStrToWord函数能正确处理大写。粘包问题是否因为接收逻辑不严谨把两帧数据粘在一起了确保你的接收函数在检测到CR LF后立即返回并且清空缓冲区。我的排查记录有一次我发现CRC总是对不上。打印出来发现我收到的CRC是ABCD而我算出来的是CDAB。瞬间明白我的CalcCRC函数计算结果是正确的但在构建请求帧时我错误地将CRC的高字节放在了前面。而PLC遵循协议返回的CRC是低字节在前。这就导致了校验失败。修正了BuildASCIIFrame中CRC字符串的拼接顺序后问题解决。5.3 问题三程序运行不稳定偶尔能成功经常超时现象程序不是完全失败而是时好时坏。排查思路波特率精度早期的计算机和PLC其串口时钟可能不是非常精确。如果两端波特率有微小偏差在长数据帧传输时可能会积累误差导致帧错误。尝试降低波特率比如从9600降到4800测试。电磁干扰RS-232C抗干扰能力一般。如果通信线缆靠近电机、变频器等强干扰源数据可能会出错。使用带屏蔽层的电缆并确保屏蔽层单端接地。流量控制我们的程序没有使用硬件流量控制RTS/CTS。如果设备端发送数据过快而PC端接收处理慢可能导致缓冲区溢出丢数据。在UART_ReceiveChar前确保UART_DataReady为真并且处理速度要快。DOS的“时钟滴答”干扰DOS的定时器中断INT 8h大约每秒发生18.2次。如果在接收一个字节的关键时刻被中断打断可能导致超时判断不准或字符丢失。对于要求不高的应用影响不大但可以尝试在关键通信段用Disable/Enable指令临时关闭中断要非常小心会影响系统时钟。稳定性优化我最终加入了简单的“重试机制”。如果一次请求超时或CRC错误不是立即报错而是自动重试1-2次。很多偶发的干扰问题可以通过重试解决。同时在接收循环中增加了更积极的CPU让步如调用DosSleep(1)让系统有机会处理其他事务整体稳定性得到了提升。5.4 问题速查表问题现象可能原因排查步骤设备无响应1. 电缆接线错误直通/交叉2. 串口参数不匹配3. 设备地址错误4. 串口硬件故障1. 环回测试自检2. 核对设备手册参数3. 使用调试工具监听串口CRC校验失败1. CRC计算范围错误2. 字节顺序高低字节弄反3. 接收数据包含多余字符粘包4. 奇偶校验错误导致数据变化1. 打印并对比收发原始十六进制数据2. 手动计算验证CRC算法3. 检查接收结束符判断逻辑响应超时1. 设备忙或故障2. 波特率偏差过大3. 程序超时时间设置过短4. 流量控制问题设备等待CTS信号1. 延长超时时间如2000ms2. 确认设备处于可通信状态3. 检查硬件流量控制是否需要启用数据解析错误1. 响应功能码与请求不匹配异常响应2. 数据字节顺序解析错误3. 寄存器地址映射理解错误1. 先解析功能码判断是正常响应还是异常响应功能码0x802. 确认设备数据格式如某些设备寄存器数据是32位拆成两个16位6. 项目总结与延伸思考回顾整个项目在DOS和Turbo Pascal的约束下完成一个可用的ModBUS主站是对程序员基本功的一次锤炼。你不得不关注每一个字节的流向理解每一个时间片的含义手动处理所有的错误。这种体验在今天被各种高级框架和库抽象掉的时代显得尤为珍贵。通过这个项目你真正理解了协议的本质协议就是双方约定好的“对话规则”。ModBUS/ASCII用可见字符定界牺牲效率换取了可调试性。同步串行通信的时序艺术没有时钟线全靠波特率这个“节拍器”来同步位流。帧间间隔、超时管理都是保证对话不混乱的关键。嵌入式/工业软件的可靠性设计CRC校验、超时重试、异常响应处理这些机制是如何从底层构建起通信可靠性的。如果你有兴趣将这个“复古”项目现代化这里有几个方向移植到现代环境用C/C、Python甚至Node.js重写核心协议栈CRC计算、帧组帧/解析你会发现逻辑完全通用只是硬件接口变成了虚拟串口如COMx或TCP SocketModBUS TCP。图形化界面在Turbo Pascal时代可以用Graph单元或直接写BGI驱动做简单的图形。今天你可以用Python的Tkinter或PyQt快速做一个配置和监控界面。协议分析器基于这个经验你可以写一个简单的ModBUS协议分析工具监听串口数据并实时解析和显示帧内容这对于调试其他ModBUS设备非常有用。最后分享一个我调试时的小习惯我会把每一次成功和失败的通信日志时间、发送帧、接收帧都写入一个文本文件。这个日志文件在排查间歇性故障时价值连城。在DOS下文件操作很简单几行代码就能实现。这个习惯我一直保留到了现在的网络编程中。