ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CODESYS中文乱码溯源:WSTRING与Unicode编码工程实践

CODESYS中文乱码溯源:WSTRING与Unicode编码工程实践 前阵子半夜接到现场电话说新上的中英文报警页面里中文全变成了一排问号。说实话看到这个现象我第一反应是HMI字体问题但后来一层层查下去才发现真正的祸根藏在一个不起眼的变量类型选择上。那台设备的程序跑在CODESYS平台上用的也是WSTRING和Unicode这套标准机制只是我同事在接收外部数据时又把WSTRING塞回了一个STRING参数里。今天把CODESYS处理中文字符串这件小事彻底说透WSTRING函数怎么用、Unicode编码在PLC里怎么落地、以及那些一踩一个准的坑在哪。这篇文章适合所有用CODESYS做项目、但又避不开中文文本处理的工程师。不管是做HMI报警、OPC UA通信还是IIoT数据上报只要你的字符串里有可能出现中文下面这些内容都能帮你少走点弯路。1. 为什么PLC工程师一碰中文就翻车STRING与WSTRING的底层差异1.1 一个字符到底占几字节先搞清楚编码载体很多刚接触CODESYS的人会把STRING当成“能存任意字符的字符串”这个理解在纯英文场景下没问题一旦出现中文就漏馅了。原因是STRING这个类型本质上是单字节字符数组一个元素只对应一个字节而WSTRING是宽字符串类型一个元素对应一个两字节的Unicode代码单元。用大白话说STRING把每个“位置”看成1个字节WSTRING把每个“位置”看成2个字节。中文在Unicode体系里起码需要2字节才能表达一个汉字所以用STRING存中文本质上存的是“一堆UTF-8字节流”而不是真正意义上的字符序列。这两者在CODESYS里的实际表现差异可以用下面这张表一眼看明白操作STRING单字节WSTRING宽字符变量声明STRING(20)WSTRING(20)实际占用内存21字节201终止符42字节20个WORD1个终止WORDLEN(中文)返回6UTF-8字节数按3字节/汉字算返回2字符个数LEN(CODESYS)返回7返回7MID截取第2~3个字符按字节位置截取中文极易截成半个按字符位置截取安全所以如果你写的程序要从某个位置开始截取两个中文字符用STRING的话必须知道前面到底占了多少字节而用WSTRINGMID直接按字符数来算省心得多。这也是我后来在项目里把字符串变量一律改成WSTRING的根本原因。1.2 从ASCII到Unicode中文为什么一度到处乱码要理解CODESYS里的编码选择得先承认一个事实工业现场的老协议和老设备大多数是从ASCII时代走过来的。ASCII只定义了128个字符英文完全够用中文不在里面。为了存中文国内搞出GB2312、GBK这些编码日本有Shift-JIS韩国和欧洲也各有各的一套。结果是同一个字节在不同编码里可能代表完全不同的文字。Unicode的出现就是为了终结这种混乱。它的思路很简单给全世界所有字符分配一个唯一的码点。比如“中”的码点是U4E2D“文”的码点是U6587。但码点只是逻辑编号落到底层还需要一套字节表示规则于是就有了UTF-8、UTF-16这些编码方案。这里有个很关键的类比Unicode相当于一本字典而UTF-8和UTF-16是两种不同的抄写规则。WSTRING内部固定用UTF-16这种“等宽格子的抄写本”每个格子2字节STRING用的则是“变长格子的压缩本”一个英文字母1字节一个汉字3字节。CODESYS的WSTRING走的就是UTF-16路线和Windows系统的宽字符、Java内部字符串是一个思路。1.3 CODESYS中字符串字面量到底怎么存这一步是很多人没搞清楚就踩坑的地方。CODESYS工程文件默认以UTF-8编码保存所以你在ST代码里写的中文字符串字面量在文件层面已经是UTF-8字节序列了。但赋值给不同类型的变量行为完全不同VAR sTxt : STRING(10); wTxt : WSTRING(10); END_VAR sTxt : 中文; (* 直接把UTF-8字节 E4 B8 AD E6 96 87 存进去共6字节 *) wTxt : 中文; (* 编译时转换为UTF-16内存里是 2个WORD4E2D 和 6587 *)也就是说同样一段字面量赋给STRING时CODESYS不做任何编码转换只做字节透传赋给WSTRING时编译器才会把UTF-8转换成UTF-16布局。这个差别直接导致后续一系列函数行为不同。所以在选型上我的建议非常明确使用场景推荐类型原因HMI显示、报警文本WSTRING可视化按字符处理中文不会裂开OPC UA通信WSTRINGOPC UA字符串本身就是UTF-8网关层通常能正确处理MQTT/HTTP JSON上报WSTRING手动转BYTE数组协议载荷需要明确的UTF-8字节序列和老设备Modbus通信STRING/ASCII老设备没有Unicode概念尽量别在报文里放中文2. WSTRING与编码转换从内部结构到外部字节流2.1 WSTRING变量声明与内存布局先看一个具体的内存布局。声明一个WSTRING(9)变量编译器实际分配的空间是10个WORD其中最后一个WORD固定填0作为结束标志总占用20字节。VAR wTxt : WSTRING(9) : 测试; pData : POINTER TO WORD; i : DINT; END_VAR pData : ADR(wTxt); FOR i : 0 TO 9 DO IF pData[i] 0 THEN EXIT; END_IF (* pData[i] 保存的是Unicode码点 *) END_FOR这里有个细节值得注意ADR(wTxt)拿到的是指向第一个WCHAR的地址也就是WORD的地址。你要是直接把ADR结果强转成POINTER TO BYTE去遍历看到的只是每个WORD的低字节顺序完全不对这也是很多人在网上问“为什么WSTRING里汉字看起来像乱码”的原因。2.2 UTF-16与UTF-8的转换算法WSTRING内部是UTF-16但网络传输、文件存储、MQTT报文这些场景业界事实标准是UTF-8。所以做中文字符串处理绕不开“WSTRING转UTF-8字节数组”这件事。UTF-8的编码规则说复杂也复杂说简单也简单一个表就能讲完Unicode码点范围UTF-8字节序列说明U0000 ~ U007F0xxxxxxx1字节ASCII兼容U0080 ~ U07FF110xxxxx 10xxxxxx2字节覆盖西欧、希腊文等U0800 ~ UFFFF1110xxxx 10xxxxxx 10xxxxxx3字节汉字基本都在这一档U10000 ~ U10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx4字节emoji和扩展字符英文在UTF-8里是1字节常见汉字是3字节这就是为什么同样一段中文STRING里占的字节数和WSTRING里的字符数对不上。下面这段是我在项目里沉淀下来的核心转换函数处理了BMP内字符和代理对两种情况FUNCTION F_WStringToUTF8 : BOOL VAR_INPUT sIn : WSTRING; END_VAR VAR_IN_OUT aOut : ARRAY[0..511] OF BYTE; nOutLen : DINT; END_VAR VAR pData : POINTER TO WORD; i : DINT; n : DINT; wch : WORD; hi : WORD; lo : WORD; code : DWORD; END_VAR pData : ADR(sIn); i : 0; n : 0; WHILE pData[i] 0 DO wch : pData[i]; (* 处理代理对高代理 低代理 *) IF (wch 16#D800) AND (wch 16#DBFF) THEN hi : wch; i : i 1; lo : pData[i]; code : (WORD_TO_DWORD(hi) - 16#D800) * 16#400 (WORD_TO_DWORD(lo) - 16#DC00) 16#10000; aOut[n] : DWORD_TO_BYTE(SHR(code, 18) OR 16#F0); aOut[n 1] : DWORD_TO_BYTE((SHR(code, 12) AND 16#3F) OR 16#80); aOut[n 2] : DWORD_TO_BYTE((SHR(code, 6) AND 16#3F) OR 16#80); aOut[n 3] : DWORD_TO_BYTE((code AND 16#3F) OR 16#80); n : n 4; i : i 1; ELSIF wch 16#7F THEN (* 1字节0xxxxxxx *) aOut[n] : WORD_TO_BYTE(wch); n : n 1; ELSIF wch 16#7FF THEN (* 2字节110xxxxx 10xxxxxx *) aOut[n] : WORD_TO_BYTE(SHR(wch, 6) OR 16#C0); aOut[n 1] : WORD_TO_BYTE((wch AND 16#3F) OR 16#80); n : n 2; ELSE (* 3字节1110xxxx 10xxxxxx 10xxxxxx *) aOut[n] : WORD_TO_BYTE(SHR(wch, 12) OR 16#E0); aOut[n 1] : WORD_TO_BYTE((SHR(wch, 6) AND 16#3F) OR 16#80); aOut[n 2] : WORD_TO_BYTE((wch AND 16#3F) OR 16#80); n : n 3; END_IF; i : i 1; END_WHILE; nOutLen : n; F_WStringToUTF8 : TRUE;如果你确认工程里永远不会出现emoji这类辅助平面字符那个代理对分支可以直接去掉。但我的经验是现场操作员发过来的报警里出现emoji的情况越来越常见既然做一次就做个完整的免得以后被动。2.3 反向转换把UTF-8字节流恢复成WSTRING从设备或网络拿到的数据往往是UTF-8字节数组恢复成WSTRING的逻辑是上面函数的逆过程核心步骤是读首字节判断这个字符占用几个字节然后提取有效位并组合成Unicode码点。伪代码逻辑如下读首字节。高位连续1的个数决定该字符总字节数0开头是1字节110开头是2字节1110开头是3字节11110开头是4字节。依次读取后续续字节提取每个字节的低6位和首字节的有效位拼成一个完整的码点。码点小于0x10000时直接写入一个WORD大于等于0x10000时先减去0x10000高10位加上0xD800作为高代理低10位加上0xDC00作为低代理依次写入两个WORD。这个函数在TCP通信场景尤其重要。因为TCP是流式协议不保证按帧边界到达一段中文报文可能被拆成两半如果没有边界判断逻辑转出来的字符串必然乱码。后面第4章我专门讲这个坑。2.4 关于GBK的务实建议网上搜索“LabVIEW怎么把GBK转换成Unicode”“GBK转Unicode”这类问题的人很多核心诉求都是老设备只认GBK新平台要Unicode。但在CODESYS里我强烈不建议在PLC内部做GBK转换。原因很现实GBK和Unicode的映射需要一张覆盖几千甚至上万个汉字的对照表这张表放在PLC里又占内存又费扫描周期。工业控制器的资源是用来算逻辑和跑运动控制的不是用来做字符集转换的。正确做法是在边界设备上处理如果前面接的老设备发GBK让边缘网关或上位机先转成UTF-8再通过Modbus TCP、OPC UA或MQTT送给CODESYS。这样PLC程序干净调试也轻松。3. 手写一套中文字符串工具库核心函数与避坑清单3.1 字符串拼接CONCAT的缓冲边界CODESYS标准库里的CONCAT支持WSTRING拼接但它有个隐形陷阱目标变量长度不够时结果会被静默截断而且不会报任何错误。这在调试阶段非常坑因为你看到的中文串可能少了一截但并不容易意识到是缓冲区容量的问题。我个人的习惯是封装一个安全拼接函数FUNCTION F_SafeAppendW : BOOL VAR_INPUT sSrc : WSTRING; nMaxLen : DINT; (* 目标容量 *) END_VAR VAR_IN_OUT sDst : WSTRING; END_VAR VAR nCurrent : DINT; END_VAR nCurrent : LEN(sDst); IF nCurrent LEN(sSrc) nMaxLen THEN sDst : CONCAT(sDst, sSrc); F_SafeAppendW : TRUE; ELSE F_SafeAppendW : FALSE; END_IF调用前先算一下现有长度和目标长度放不下就直接提示失败而不是默默截断。这算是一个很基础但极其有效的防御性编程习惯。3.2 截取、查找与替换MID、LEFT、RIGHT这些函数在CODESYS里是重载的实参是STRING时按字节位置操作实参是WSTRING时按字符位置操作。所以处理中文时统一使用WSTRING版本就不会出现“截一半汉字”的问题。举个例子温度过高这个字符串用STRING的MID从第2个字节截2个字节拿到的是“度”的前半部分和一个“度”的后半部分显示出来全是乱码改用WSTRING的MID从第2个字符截1个字符拿到的就是完整的“度”字。IEC 61131-3标准里没有直接提供字符串替换函数我通常用FINDLEFTCONCATMID组合实现(* 把wOrigin里的wOld替换成wNew *) pos : FIND(wOrigin, wOld); IF pos 0 THEN wResult : CONCAT(LEFT(wOrigin, pos - 1), wNew); wResult : CONCAT(wResult, MID(wOrigin, pos LEN(wOld), LEN(wOrigin))); END_IF注意FIND返回的起始位置是1基的和LEFT的第2个参数配合时要减1这是新手最容易写错的地方。3.3 按字符反转代理对陷阱字符串反转看起来简单一个循环倒着拷贝就行但在WSTRING里有一个隐蔽的坑如果字符串里包含emoji这种由代理对组成的辅助平面字符简单把每个WORD倒序排列会把一个emoji拆成两个乱码字符。正确做法是在遍历时判断当前WORD是否在高代理区0xD800~0xDBFF如果是说明它和后面一个低代理WORD共同组成一个字符反转时必须作为整体处理。这个逻辑和编解码时的代理对处理完全对应理解了UTF-16的存储结构就不难写。3.4 计算“中文字符串长度”的三种口径很多模糊的Bug都源于“长度”的定义不统一。同样一段文本“中文A”在不同度量方式下的长度完全不同度量方式结果典型用途字符个数人眼看到的字3WSTRING的LENUTF-8字节数7中3字节文3字节A1字节网络发送、文件存储内存占用宽度3个WCHAR6字节分配缓冲区如果把“中文A”换成“中文”情况更明显人眼看到3个字符WSTRING的LEN返回4emoji占2个WCHARUTF-8字节数是33410字节。这三组数字对不上不是程序错了是它们在度量不同维度。写代码前先问清楚协议文档里要求的是哪种长度能少吵很多架。4. IIoT上报与HMI显示场景下的Unicode实战4.1 HMI中文乱码的三种成因CODESYS Visualization里中文显示成乱码绝大多数情况跑不出下面三种原因。第一目标设备没有中文字体。这是最容易被忽略的。运行视图片的工控机或触摸屏如果没安装支持中文的字体Unicode码点对不上字体里的字形呈现出来的就是方块或乱码。处理办法是在可视化风格里指定一个带中文字形的字体比如Arial Unicode MS、微软雅黑或者设备自带的宋体。第二变量类型本身是STRING里面存的是UTF-8字节序列但显示控件按ANSI或者Latin-1去解释。一个汉字3字节被拆成3个“怪字符”显示这种问题换成WSTRING变量就解决了。第三Text List导入时编码不对。CODESYS的文本列表经常是CSV文件如果这个CSV不是UTF-8编码中文部分导入后全部乱套。建议团队统一约定所有资源文件保存为UTF-8编码再导入。排查顺序也很固定先看字体再看变量类型最后查配置文件编码。按照这个顺序能快速缩小范围不用瞎猜。4.2 MQTT/HTTP JSON上报WSTRING转UTF-8的完整链路很多工业现场现在要求PLC直接把数据推到MQTT broker或者HTTP服务器中文报警文本自然也要跟着报文走。这里最典型的翻车点是MQTT库的发布函数参数类型是STRING你顺手把WSTRING变量传进去编译器不报错但运行起来中文全没了。因为WSTRING转STRING的过程中超出ASCII范围的宽字符通常会被替换成0x3F也就是英文问号。你抓包看payload中文位置全部是问号英文和数字都正常。正确的链路应该是WSTRING变量 - F_WStringToUTF8字节数组 - MQTT payload/HTTP body如果用的第三方库本身支持WSTRING类型的输入务必先确认库内部是否真的做了UTF-8编码而不是简单截断成单字节。我在项目里的原则是凡是走网络的字符串一律自己控制编码转换不赌库的默认行为。4.3 TCP粘包/半包与中文截断的缓存处理TCP通信有个老朋友叫粘包/半包在纯英文协议里问题不大因为ASCII是单字节少一个字节顶多解析错一个字符。但中文是3字节或4字节编码一个汉字可能被TCP拆到两个包里如果你每收到一个包就立刻按帧解析帧尾的中文就会被截成残缺序列解码时直接乱码。处理办法是在解析前检查buffer尾部是否存在不完整的UTF-8序列从缓冲区末尾往前回退。如果遇到10xxxxxx开头的续字节继续往前直到找到0xxxxxxx、110xxxxx、1110xxxx、11110xxxx这种首字节。根据首字节判断这个字符应该占几个字节如果剩余字节数不足就把从首字节开始到末尾的数据缓存起来等下一包到达后再合并。这个判断逻辑通常只需要一个小缓冲区因为UTF-8单字符最长4字节最多缓存尾部3字节就够用了。我之前在一段边缘网关程序里用这个思路处理海康设备上传的数据中文再也没有因为分包乱过。5. 排查复盘一次报警中文变问号的完整追查过程5.1 现象与初步判断现场反馈设备报警时HMI触摸屏上中文显示完全正常但云端物联网平台收到的消息里中文变成了问号英文和数字不受影响。这个现象组合很关键。HMI正常说明PLC内部的数据源头没问题问题出在“HMI显示”和“云端上报”这两条链路分叉之后的某一段。5.2 逐层排查从HMI开始往外剥第一步确认HMI和报警变量用的是同一个WSTRING变量正常说明源数据OK。第二步在PLC里用Wireshark抓MQTT broker和终端之间的流量。抓包结果给了关键线索payload的中文位置全是0x3F也就是ASCII问号。这说明在进入MQTT报文之前中文字符就已经被替换掉了。第三步回到程序里检查MQTT发布函数的参数类型。发布函数签名里文本参数是STRING而代码里直接把一个WSTRING报警变量传了进去。CODESYS在隐式转换时对超出ASCII范围的字符采用了替换策略直接变成问号。这一下就锁定了根因。5.3 根因与修复根因可以概括为调用了只接受STRING的接口却没有做显式编码转换。WSTRING里的中文根本没有机会以UTF-8字节的形式进入报文在函数入口就被“净化”掉了。修复方案很简单调用发布函数前先把WSTRING通过F_WStringToUTF8转成BYTE数组再把字节数组放进payload缓冲区。改完后再抓包中文位置出现的是合法的UTF-8字节序列云端解析恢复正常。5.4 防再犯把编码约定写进接口文档这个Bug的本质不是“CODESYS坏了”而是接口参数类型和编码协议不匹配。后来我在项目组里定了一条规矩所有与外部系统交互的函数命名时直接体现编码类型。比如SendAsciiString和SendUtf8Bytes看到函数名就知道该传什么、不该传什么。接口文档里每条报文都明确标注“UTF-8无BOM”从根上避免同类问题。6. 写给后续项目的落地清单6.1 选型与设计阶段项目一开始就统一字符串类型比中途改要省力得多。我的默认原则是凡是要给人看的、要进数据库的、要上报云端的文本一律WSTRING只有和老设备做Modbus等底层协议交互、且报文本身就是纯英文数字时才用STRING。变量命名也建议带类型暗示比如wstrAlarmText、wstrDeviceName一眼能看出这是宽字符串。这不是为了好看是为了在代码评审时就拦住“WSTRING塞进STRING参数”这种低级事故。6.2 编码自测方法我在项目交付前会固定跑一遍编码自测准备一段包含中文、英文、数字、日文平假名、emoji的混合文本。执行一次“WSTRING转UTF-8字节数组”再“UTF-8字节数组转WSTRING”的往返转换。对比转换前后内容是否完全一致。故意把UTF-8字节流尾部砍掉1到3个字节再喂给解析函数验证F_IsIncompleteUtf8Tail能正确识别半截字符并缓存等待补齐。这套自测跑完字符串相关的编码问题基本能兜住八成的坑。6.3 我踩过的坑与习惯做法最后分享几个我自己真实踩过的坑。工程文件编码混乱是最隐蔽的问题。曾经有同事用GBK编码的编辑器打开并保存过CODESYS工程文件结果整个文件里的中文注释和字符串全部乱套。后来团队统一约定工程文件一律UTF-8保存用Git管理任何人不要手动改编码。还踩过WSTRING滥用导致内存膨胀的坑。WSTRING(255)一个变量就占512字节如果数组里定义几十上百个内存占用相当可观。按需定义长度能用WSTRING(9)就不要WSTRING(255)这不是微优化是给大项目留余量。最后一个习惯是抓包。无论HMI显示多正常只要字符串要出PLC进网络我一定会抓包看原始字节。眼睛会骗人抓包不会。中文在报文里到底是E4 B8 ADUTF-8还是2D 4EUTF-16LE一眼就能看清排查效率比反复看代码高得多。
返回列表