ARTICLE DETAIL

资讯详情

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

CODESYS中文字符串处理:WSTRING与Unicode编码实战指南

CODESYS中文字符串处理:WSTRING与Unicode编码实战指南 搞PLC开发最容易被低估的坑我觉得就是字符串处理。前面帮客户调试一台汇川AM401CODESYS内核扫码枪扫出来的批次号里带中文结果HMI上死活显示成问号。一开始以为是字体问题换了好几套字体没变化后来把变量从STRING改成WSTRING当场恢复正常。这事让我把WSTRING和Unicode编码机制的来龙去脉完整捋了一遍也攒了不少实战经验。这篇就围绕WSTRING函数、Unicode编码实践和中文字符串处理展开把你实际开发中会遇到的场景、代码、坑一次性说清楚。这篇适合正在用CODESYS做设备开发的电气工程师、上位机工程师也适合使用汇川、欧姆龙以及其他CODESYS内核PLC做数据对接的朋友。重点不是罗列函数手册而是讲清楚为什么这么写、什么时候用哪种方案代码可以直接抄思路可以带到别的平台上。1. 为什么会乱码STRING与WSTRING的本质区别1.1 STRING是“字节数组”不是“字符数组”很多人在CODESYS里第一次处理中文都习惯用STRING因为在梯形图或者ST里写ABC很顺手而且处理英文、数字、符号都没问题。但STRING在内存里就是一个字节接一个字节排列每个字符占用1个字节并且以单字节0作为结束符。ASCII编码下每个字符刚好1字节所以一切正常。可中文在Unicode体系里至少占2个字节GBK编码也需要2字节STRING这种单字节容器根本承载不了。举个直观例子VAR sTest : STRING(10); END_VAR sTest : 中文;这条赋值你能编译过去但变量里实际存的并不是完整的中文。很多情况下中文字符被写入后每个汉字只保留了一个字节另一个字节被丢弃最终显示出来就是乱码、问号或者半截字符。更麻烦的是STRING的结束符也是0而汉字编码的第二个字节很可能就是0这会导致字符串在内存里被提前截断后面的内容全部消失。所以我一直跟身边的人说在CODESYS里把STRING理解成“最多N个字节的字节数组”更准确它不是按“字符数”来数长度的。STRING(10)最多只能放10个ASCII字符如果是中文字符按GBK算最多也就5个字还要看运气。1.2 WSTRING与Unicode编码原理WSTRING就是为解决这个问题设计的。它是宽字符串每个字符固定占用2个字节内部按UTF-16编码来存放Unicode码点。UTF-16用16位即2字节表示一个码元对于常用的汉字来说基本上每个字对应一个UTF-16码元落在0x4E00到0x9FFF这个范围内。比如“中”字的Unicode码点是U4E2D在WSTRING里就是两个字节0x4E 0x2D按小端存储则是0x2D 0x4E。WSTRING的结束符是双字节的16#0000不是单个0。CODESYS声明WSTRING也很简单VAR wTest : WSTRING(20) : 中文测试; wCode : WSTRING(40); END_VAR注意字面量写法STRING用单引号WSTRING用双引号。这是IEC 61131-3对宽字符串的约定CODESYS也完全遵守。如果你在WSTRING的赋值里不小心用了单引号编译器一般会报警告但最好养成习惯看到引号就下意识分辨类型。从容量上说WSTRING(20)表示最多容纳20个Unicode字符每个字符2字节理论上需要42字节内存其中40字节给20个字符还有2字节给结束符。这和STRING(20)只能装20个单字节字符有本质区别。1.3 实际项目中该选谁我的判断标准很简单只要字符串内容可能包含中文、日文、韩文等非拉丁字符一律用WSTRING。如果是HMI上要显示的多语言文本、设备名称、报警信息直接用WSTRING别给自己埋雷。如果字符串只在PLC内部做逻辑标记、命令字、ASCII协议帧内容确定是英文数字符号用STRING没问题节省内存而且通信时直接按字节处理更方便。还有一种情况也要用WSTRING就是与上游系统对接时协议里明确定义了UTF-16或者宽字符字段。比如某些MES系统通过OPC UA下发中文工单号OPC UA里字符串实际按UTF-8编码传输但CODESYS的OPC UA服务器映射到变量时如果目标变量是WSTRING会自动进行UTF-8到UTF-16的转换显示就正常。如果你用STRING去接大概率会出现半个字乱码的情况。2. WSTRING函数详解从声明到裁剪拼接2.1 核心字符串函数的使用要点CODESYS标准库对WSTRING的支持其实已经很完整常用的字符串函数都支持任意字符串类型。这里说几个我在项目里反复用的函数以及它们的细节。LEN函数用于返回字符串的字符数。注意对于WSTRING它返回的是Unicode字符数不是字节数也不是内存容量。这在涉及循环遍历、下标计算时特别重要千万别拿SIZEOF去数有效字符SIZEOF返回的是整个变量占用的内存大小包括没用的缓冲区和结束符。VAR wStr : WSTRING(20) : 中文ABC; nLen : INT; END_VAR nLen : LEN(wStr); // 返回5因为中文字符按1个字符计CONCAT用于拼接两个字符串。WSTRING参与拼接时最好保证两个参数都是WSTRING避免隐式转换带来的不一致。我习惯在工程里统一用双引号字面量防止混用单双引号导致类型不匹配。wResult : CONCAT(设备, CONCAT(3号, 报警));LEFT、RIGHT、MID是裁剪操作。LEFT(wStr, n)取最左边n个字符RIGHT(wStr, n)取最右边n个字符MID(wStr, nStart, nLen)从第nStart个字符开始取nLen个字符起始位置从1开始。这些函数对WSTRING操作时位置和长度都是字符数不是字节数。曾经有同事在取中文子串时按字节数计算位置结果每次取出来的都不是期望的内容排查半天才发现这个点。FIND用于查找子串位置返回也是字符位置从1开始。如果找不到返回0。nPos : FIND(报警代码:E001, E001); // 返回6INSERT、DELETE、REPLACE也很常用。DELETE(wStr, nPos, nLen)从nPos位置开始删除nLen个字符INSERT(wStr, wInsert, nPos)在nPos位置前插入REPLACE(wStr, wReplace, nLen, nPos)从nPos开始用新内容替换nLen个字符。这些函数在WSTRING上的语义都是按字符数计算逻辑和LEFT/MID保持一致。2.2 类型转换函数的隐藏风险CODESYS提供字符串类型转换函数STRING_TO_WSTRING和WSTRING_TO_STRING最常用。但转换有风险尤其WSTRING_TO_STRING。VAR wMsg : WSTRING(20) : 中文消息; sMsg : STRING(40); END_VAR sMsg : WSTRING_TO_STRING(wMsg);假设WSTRING里是中文字符转换到STRING时由于STRING每个字符只有1字节无法表达中文的Unicode码点结果会把每个字符的低字节拼出来得到一长串乱码。更危险的是有些中文字符的低字节是0STRING遇到0就认为字符串结束结果就是内容被截断甚至可能是空字符串。所以我在项目里立了一条规矩只有当字符串内容确定为纯英文数字ASCII字符时才允许从WSTRING转STRING。STRING_TO_WSTRING相对安全因为它把单字节扩展成双字节。但如果源STRING本来存的就是中文字符串且编码是GBK或ANSI直接转换也不会得到正确的中文因为CODESYS不会做代码页转换它只是把一个字节塞进16位码元的低8位高8位补0。这个转换结果和原中文字符根本不是同一个码点显示出来依然是乱码。所以遇到外部设备传入的中文数据先搞清楚它是什么编码再做对应的解码而不是指望转换函数帮你自动处理。2.3 用指针直接读写WSTRING内部数据有些场景下标准库函数不够用需要直接操作WSTRING的每个字符。比如你想把字符串里的某个字符替换掉、把Unicode码点从另一个数据源拷贝进来、或者按字符遍历判断是否包含中文这些用指针操作更高效。VAR wText : WSTRING(20) : AB测试; pWord : POINTER TO WORD; i : DINT; nLen : DINT; END_VAR pWord : ADR(wText); nLen : LEN(wText); FOR i : 0 TO nLen - 1 DO // pWord[i] 就是第 i1 个字符的UTF-16码元 // 中文字符码元范围典型在16#4E00~16#9FA5之间 END_FOR;这里pWord是POINTER TO WORD每次下标加1实际地址偏移2字节正好对应WSTRING一个字符。需要注意CODESYS里的指针越界编译器不会提示所以遍历时长度一定要通过LEN获取而不是想当然用固定值。反向操作也常用到。比如从Modbus寄存器里读到一批数据每个寄存器的值就是一个UTF-16码元你可以直接用指针把这些码元拷到WSTRING里再补上结束符VAR aiRegs : ARRAY[0..9] OF WORD; wBuf : WSTRING(10); pDest : POINTER TO WORD; i : INT; END_VAR pDest : ADR(wBuf); FOR i : 0 TO 9 DO pDest[i] : aiRegs[i]; END_FOR; pDest[10] : 16#0000; // 结束符这种操作在对接第三方显示设备、扫码枪、以及某些总线设备时常碰到比用库函数再来回转换高效得多。3. Unicode编码实践UTF-8与UTF-16互转手写实现3.1 为什么需要自己写编解码既然WSTRING内部是UTF-16而很多外部系统用的是UTF-8这就产生了一个绕不开的转换需求。串口扫码枪、TCP/IP服务器、MQTT客户端、REST API网关这些设备或协议大量使用UTF-8作为标准编码。CODESYS自带的标准库里没有直接的UTF-8与WSTRING转换函数虽然有第三方库声称支持但在现场环境里要么licence受限要么版本不兼容我实际测试后还不如自己写一组功能块可靠。另外理解UTF-8和UTF-16的编码规则对排查乱码问题帮助极大。你一旦掌握了规则一串乱码拿到手里看字节基本能猜到是哪个环节出了问题这对现场调试是非常实用的能力。3.2 UTF-8转WSTRING接收扫码枪数据UTF-8是变长编码一个Unicode码点可能编码为1到4个字节。编码规则如下码点小于0x80用一个字节表示高位补0码点小于0x800用两个字节第一个字节高位是110第二个字节高位是10码点小于0x10000用三个字节第一个字节高位是1110其余用四个字节第一个字节高位是11110。后续每个字节都是10开头里面只保留6位有效数据。所以解码时第一步是读第一个字节判断它是几字节序列第二步把后续字节的6位有效数据拼进来还原码点第三步把码点写入WSTRING字符数组。汉字基本都在三字节序列里一个汉字对应一个UTF-16码元相对好处理。下面是一个典型的UTF-8转WSTRING功能块屏蔽了代理对情况适合绝大多数中英文字符串场景FUNCTION_BLOCK FB_UTF8ToWString VAR_INPUT bExecute : BOOL; sUtf8 : STRING(500); END_VAR VAR_OUTPUT wResult : WSTRING(250); bDone : BOOL; bError : BOOL; iErrCode : INT; END_VAR VAR iLen : DINT; i : DINT; j : DINT; nSeq : INT; dwCp : DWORD; byCur : BYTE; pIn : POINTER TO BYTE; pOut : POINTER TO WORD; nOutLimit : DINT; END_VAR实现逻辑如下IF bExecute THEN bDone : FALSE; bError : FALSE; iErrCode : 0; pIn : ADR(sUtf8); pOut : ADR(wResult); iLen : LEN(sUtf8); i : 0; j : 0; nOutLimit : 249; // 预留一个结束符位置 WHILE i iLen DO byCur : pIn[i]; // 判断UTF-8序列长度 IF (byCur AND 16#80) 0 THEN nSeq : 1; dwCp : byCur; ELSIF (byCur AND 16#E0) 16#C0 THEN nSeq : 2; dwCp : byCur AND 16#1F; ELSIF (byCur AND 16#F0) 16#E0 THEN nSeq : 3; dwCp : byCur AND 16#0F; ELSIF (byCur AND 16#F8) 16#F0 THEN nSeq : 4; dwCp : byCur AND 16#07; ELSE bError : TRUE; iErrCode : 1; EXIT; END_IF; // 数据不完整 IF i nSeq iLen THEN bError : TRUE; iErrCode : 2; EXIT; END_IF; // 提取后续字节中的有效数据 IF nSeq 1 THEN byCur : pIn[i 1]; IF (byCur AND 16#C0) 16#80 THEN bError : TRUE; iErrCode : 3; EXIT; END_IF; dwCp : (dwCp SHL 6) OR (byCur AND 16#3F); END_IF; IF nSeq 2 THEN byCur : pIn[i 2]; IF (byCur AND 16#C0) 16#80 THEN bError : TRUE; iErrCode : 3; EXIT; END_IF; dwCp : (dwCp SHL 6) OR (byCur AND 16#3F); END_IF; IF nSeq 3 THEN byCur : pIn[i 3]; IF (byCur AND 16#C0) 16#80 THEN bError : TRUE; iErrCode : 3; EXIT; END_IF; dwCp : (dwCp SHL 6) OR (byCur AND 16#3F); END_IF; // 超出基本多语言平面的字符用简化方式处理 IF dwCp 16#FFFF THEN bError : TRUE; iErrCode : 4; EXIT; END_IF; // 写入UTF-16 IF j nOutLimit THEN bError : TRUE; iErrCode : 5; EXIT; END_IF; pOut[j] : WORD(dwCp); j : j 1; i : i nSeq; END_WHILE; IF NOT bError THEN pOut[j] : 16#0000; bDone : TRUE; END_IF; END_IF这个功能块运行后wResult就是可以直接塞给HMI显示、写入报警文本、或者存进数据块的WSTRING。实测处理扫码枪的UTF-8中文数据完全没问题。如果你的项目里需要支持emoji或者非常用扩展字符集需要在dwCp 16#FFFF的逻辑里补上代理对处理原理就是把码点拆成两个16位码元一个高代理区一个低代理区后面会提到。3.3 WSTRING转UTF-8上云与文件导出反向转换同样经常用到。比如要把设备报警信息通过MQTT上报到云端云平台大多要求UTF-8编码或者要把日志导出成文件Excel可以直接打开UTF-8编码的CSV用GBK反而容易乱码。这时候需要把WSTRING转成UTF-8的STRING或者字节数组。编码规则是码点小于0x80直接输出1字节小于0x800输出2字节小于0x10000输出3字节否则输出4字节。如果是代理对需要先把两个16位码元还原成完整码点再套用规则。FUNCTION_BLOCK FB_WStringToUtf8 VAR_INPUT bExecute : BOOL; wInput : WSTRING(250); END_VAR VAR_OUTPUT sUtf8 : STRING(1000); bDone : BOOL; bError : BOOL; iErrCode : INT; END_VAR VAR iLen : DINT; i : DINT; j : DINT; wCh : WORD; wChLow : WORD; dwCp : DWORD; pOut : POINTER TO BYTE; pIn : POINTER TO WORD; END_VAR核心实现如下IF bExecute THEN bDone : FALSE; bError : FALSE; iErrCode : 0; pIn : ADR(wInput); pOut : ADR(sUtf8); iLen : LEN(wInput); i : 0; j : 0; WHILE i iLen DO wCh : pIn[i]; // 处理代理对 IF (wCh 16#D800) AND (wCh 16#DBFF) THEN IF i 1 iLen THEN bError : TRUE; iErrCode : 1; EXIT; END_IF; wChLow : pIn[i 1]; IF (wChLow 16#DC00) OR (wChLow 16#DFFF) THEN bError : TRUE; iErrCode : 2; EXIT; END_IF; dwCp : 16#10000 ((WORD_TO_DWORD(wCh) - 16#D800) SHL 10); dwCp : dwCp (WORD_TO_DWORD(wChLow) - 16#DC00); i : i 2; ELSE dwCp : WORD_TO_DWORD(wCh); i : i 1; END_IF; // 编码为UTF-8 IF dwCp 16#80 THEN pOut[j] : BYTE(dwCp); j : j 1; ELSIF dwCp 16#800 THEN pOut[j] : BYTE(16#C0 OR (dwCp SHR 6)); pOut[j 1] : BYTE(16#80 OR (dwCp AND 16#3F)); j : j 2; ELSIF dwCp 16#10000 THEN pOut[j] : BYTE(16#E0 OR (dwCp SHR 12)); pOut[j 1] : BYTE(16#80 OR ((dwCp SHR 6) AND 16#3F)); pOut[j 2] : BYTE(16#80 OR (dwCp AND 16#3F)); j : j 3; ELSE pOut[j] : BYTE(16#F0 OR (dwCp SHR 18)); pOut[j 1] : BYTE(16#80 OR ((dwCp SHR 12) AND 16#3F)); pOut[j 2] : BYTE(16#80 OR ((dwCp SHR 6) AND 16#3F)); pOut[j 3] : BYTE(16#80 OR (dwCp AND 16#3F)); j : j 4; END_IF; // 越界保护 IF j 999 THEN bError : TRUE; iErrCode : 3; EXIT; END_IF; END_WHILE; IF NOT bError THEN pOut[j] : 0; bDone : TRUE; END_IF; END_IF使用时注意缓冲区大小。WSTRING(250)理论上最多250个字符如果全是emoji一个字符编码成4字节UTF-8需要1000字节所以我把输出定义为STRING(1000)。如果你处理的是常规中英文STRING(750)也够但留余量更稳。3.4 字节序问题与Modbus场景WSTRING在内存里按UTF-16存储但不同CPU的字节序不一样。CODESYS最常见的运行平台是x86和ARM都是小端模式所以一个码元0x4E2D在内存里实际是0x2D 0x4E。Modbus TCP和Modbus RTU协议本身是大端模式读写寄存器时驱动层已经帮你把大端字节序转换成CPU本地的WORD值所以从MODBUS的保持寄存器数组里读取数据填充WSTRING不需要额外交换字节顺序因为寄存器里的WORD数值就是逻辑码元值。但如果你是通过TCP/IP原始socket接收字节流数据里明确是UTF-16BE的码元那就得自己处理字节序。常见的做法是取出高字节和低字节组合成WORD如果是小端平台需要把高字节放前面或者直接调换wValue : WORD((byHigh SHL 8) OR byLow);这里的byHigh和byLow来自网络字节流。如果流里是UTF-16BE高字节在前按上面的公式就能还原码元。如果流里是UTF-16LE则反过来。这个细节在对接非标设备时经常遇到排查乱码找不到原因多数都是字节序反了。4. 完整实战扫码枪中文数据从接收到显示4.1 项目场景与变量规划以一个实际项目为例产线上有台扫码枪通过RS232串口接入PLC扫码内容包含中文字符串例如产品型号加批次号“FN-批次-宁波三号”。扫码枪配置成发送UTF-8编码的帧帧以回车换行结束。PLC需要把扫码结果解析出来在HMI上显示并上报到MES系统。变量规划如下VAR byRxBuf : ARRAY[0..255] OF BYTE; // 串口接收缓冲区 nRxLen : INT; // 当前帧实际长度 wScanResult : WSTRING(80); // 解析后的中文结果 fbConvert : FB_UTF8ToWString; // UTF-8转WSTRING功能块 xNewFrame : BOOL; // 帧接收完成标记 END_VAR如果串口模块使用的是CODESYS的SerialLine功能块通常一次最多接收固定字节数需要自己实现帧结束判断。只要检测到回车或者换行就认为一帧数据完整触发xNewFrame。4.2 数据接收与解码流程帧接收完成后第一步把字节数组里的内容预处理成标准STRING。这里有个细节byRxBuf里可能包含结束符0直接转STRING会截断所以不能直接用STRING_TO_BYTE_ARRAY这类转换函数。我用一个循环把有效字节拷贝进STRING缓冲区VAR sRaw : STRING(255); i : INT; END_VAR FOR i : 0 TO nRxLen - 1 DO sRaw[i 1] : BYTE_TO_STRING_CHAR(byRxBuf[i]); // 按字符写入 END_FOR;不过CODESYS里STRING的单个元素访问方式和C语言不太一样上面只是示意。更稳妥的做法是直接拿ADR(byRxBuf)和ADR(sRaw)做内存拷贝再在末尾补0。拷贝前注意目标缓冲区容量防止越界。然后调用转换功能块fbConvert(bExecute : xNewFrame, sUtf8 : sRaw); wScanResult : fbConvert.wResult;这一步执行完后wScanResult里就是正常的中文字符串可以直接绑到HMI变量上或者存到配方数据区。实测中只要扫码枪输出的确实是UTF-8转换后显示完全正常不再出现问号。4.3 HMI显示与日志输出在CODESYS Visualization界面里文本显示控件可以关联WSTRING类型的变量。嵌入式可视化要注意字体问题需要把中文字体嵌入到工程里否则显示出来还是方块。加字体时选择支持CJK的字体文件并设置字符集工程下载后字体文件会一起部署到目标设备。WebVisu则不需要单独嵌入字体浏览器本身有中文字体但要注意目标浏览器所在系统的字体是否齐全。日志输出到CSV文件时我一般直接用FB_WStringToUtf8转成UTF-8字符串然后以二进制方式写入文件。不要直接用FileWrite去写WSTRING内存因为WSTRING的结束符是双字节0而且内部是UTF-16LE直接写出来的文件不是UTF-8Excel打开是乱码。正确的流程是WSTRING转UTF-8再写文件。如果想要Excel无乱码打开CSV文件开头最好写上UTF-8的BOM即三个字节EF BB BF这样兼容性更好。5. 高频踩坑与排查心得5.1 乱码的几种表现与对应处理现场乱码大致有几类表现排查方向完全不一样。第一类是显示成问号。这种情况通常不是编码问题而是字体或者显示控件不支持该字符集。排查时先确认变量类型是WSTRING再看可视化字体有没有包含中文字符最后用WebVisu或在线监控对比如果在线监控里变量值正常、可视化显示问号那问题在字体不在编码。第二类是显示成乱码符号比如“涓皟娴嬭瘯”这种成片乱码。这通常是UTF-8字节流被当成ANSI/GBK解读了或者反过来。常见于串口接收后直接用STRING变量保存再把STRING绑到HMI显示。解决办法就是先确认发送端编码再按第3章的转换功能块处理。第三类是字符串被截断只显示一部分。这种情况很可能是用了STRING存储中文字符遇到字节0提前结束。解决思路很简单改用WSTRING并且检查接收帧的结束符判断逻辑别把结束符当数据。5.2 函数混用导致的类型不匹配CODESYS的字符串函数虽然标明支持ANY_STRING但在实际工程里混用STRING和WSTRING还是容易出问题。比如CONCAT(abc, 中文)这种写法编译器可能提示参数类型不一致或者按隐式转换规则把abc转成WSTRING后拼接可一旦两个参数类型不同返回类型就存在不确定性。我建议在项目里定一个约定涉及中文或Unicode字符串处理的逻辑所有字符串变量和字面量都统一用WSTRING不要混搭。这样不仅代码可读性好而且避免编译器在类型推断上玩花样。如果必须和STRING互相转换单独用转换函数并在注释里标明为什么这么写。5.3 其他容易忽视的细节嵌入式目标设备的内存限制是容易被忽视的坑。WSTRING(250)看起来不大但250个字符就需要502字节内存一个复杂的HMI工程可能几十上百个字符串变量累计起来占用很可观。所以我在规划变量时会先估算字符串最大长度尽量精确设置容量而不是随手写个很大的长度。还有一点CODESYS的在线监控对WSTRING显示中文很友好但监控STRING里的中文字符依旧可能乱码这是正常的不代表程序错误。调试时要以WSTRING为准。另外如果项目涉及多语言比如中英文界面切换用WSTRING数组保存多语言文本是很常见的做法。通过一个INT索引选择语言HMI文本控件直接绑定数组元素切换时赋值新语言。用WSTRING数组确保每种语言的字符串都能正确显示比用STRING数组省心得多。最后分享一个我自己的习惯所有接收外部字符串的底层功能块输出统一设计成WSTRING。这样从底层到HMI、到云平台整条链路都是WSTRING或者UTF-8字节流中间不经过脆弱的STRING中转乱码问题会少掉一大半。如果你正在处理类似项目建议优先把这个规则定下来后面会少很多麻烦。
返回列表