Resolving the $ Character That Appears in a Siemens S7 STRING After CHARS_TO_STRG Conversion
This technical reference explains why a literal dollar sign ($) suddenly appears inside a Siemens S7 STRING variable after the CHARS_TO_STRG / CHARS_TO_STRING function has converted an incoming byte array. The root cause is the Siemens-specific escape character syntax used inside STRING data, combined with a framing byte (typically 16#03 – End of Text / ETX) that is left over from a serial telegram. The article documents the affected platforms, reproduces the failure with a minimal example, and provides four field-proven remediation paths plus a verification workflow.
$ at the end, or a sequence such as …payload$03. No $ is present in the raw bytes. Re-sending the telegram still produces the $.1. Background: The Siemens STRING Data Type and the $ Escape Character
Siemens S7 STRING is not a C-style null-terminated string. It is a length-prefixed structure. The TIA Portal / STEP 7 system documentation defines a STRING as:
- Byte 0: maximum length of the string (default 254).
- Byte 1: current (actual) length of the string in characters.
- Bytes 2…(max+1): the character payload, each holding one ASCII character (one byte = one character in the default codepage).
Unlike C, where any byte value 0x00…0xFF is valid, the Siemens STRING literal syntax reserves the dollar sign ($) as an escape character. When a STRING is displayed in the editor, watch table, HMI tag, or online monitor, control characters that have no printable glyph are rendered using a dollar-prefixed two-character sequence. The relevant escapes are documented in the TIA Portal help (Siemens Industry Online Support) and reproduced below:
| Escape | Hex | ASCII name | Meaning |
|---|---|---|---|
$00 |
0x00 | NUL | Null character |
$02 |
0x02 | STX | Start of Text |
$03 |
0x03 | ETX | End of Text |
$04 |
0x04 | EOT | End of Transmission |
$0A |
0x0A | LF | Line Feed |
$0D |
0x0D | CR | Carriage Return |
$1A |
0x1A | SUB | Substitute / EOF |
$$ |
0x24 | $ | Literal dollar sign |
$' |
0x27 | ' | Literal single quote |
$L |
— | — | Line break (CR+LF, 2 bytes in the payload) |
$N |
— | — | New line feed (LF only, 1 byte) |
$P |
— | — | New page (form feed) |
$R |
— | — | Carriage return (CR only, 1 byte) |
$T |
— | — | Tab (HT, 0x09) |
Any byte 0x00…0x1F that the editor cannot render as a printable glyph is shown with a leading $ plus its two-digit hex value. Bytes 0x20 and above are printable and need no escape. This is purely a display convention for STRING literals and STRING-tag online views; the underlying byte in the payload is still the raw 8-bit value.
The practical consequence: if a byte array contains, for example, the value 16#03 (ETX, the standard end-of-telegram marker used by thousands of serial field devices) and that byte is fed into a STRING conversion, the monitor view of the STRING displays the two characters $ and 0 and 3. The $ therefore appears to be an extra, unexplained character in the STRING — but it is not in the data; it is the renderer's escape prefix.
2. Affected Platforms and Firmware
The escape-character rendering is implemented in the TIA Portal / STEP 7 editor and online monitor, and is independent of the CPU firmware. Any S7 controller that uses the standard STRING data type is affected:
| CPU family | Typical firmware range | Function block library | Affected |
|---|---|---|---|
| S7-300 / S7-400 | All STEP 7 V5.x projects | Standard Library > IEC Function Blocks | Yes — FC5 CHARS_TO_STRG / FC39 STRG_TO_CHARS
|
| S7-1200 (all MLFBs) | Firmware 4.0 and later (TIA V13+) | Instructions > String + Char | Yes — CHARS_TO_STRING / STRING_TO_CHARS
|
| S7-1500 (all MLFBs) | Firmware 1.0 and later (TIA V12+) | Instructions > String + Char | Yes — same instructions, optimised access compatible |
| ET 200SP / ET 200pro CPU | Same as S7-1500 | Same as S7-1500 | Yes |
| WinAC RTX / Software Controller | PC-based, TIA V15+ | Same as S7-1500 | Yes |
| S7-200 / SMART | STEP 7 Micro/WIN | Different string model (no escape) | No — uses ASCII string library, not the STRING type with $ escape |
The behaviour is the same on SCL, LAD, FBD and GRAPH; only the editor differs. It is also reproducible on HMI panels (Comfort, Unified, WinCC) and on OPC UA servers that surface STRING tags — the rendering happens at the engineering/HMI layer, not in the PLC's process image.
3. Minimal Reproduction Example
Create a global data block with the following tags (TIA Portal V15.1+, S7-1500 CPU 1515-2 PN, firmware 2.8):
// Global DB "dbCom"
VAR
arrRx : ARRAY[1..8] OF BYTE; // incoming serial buffer
sTelegram : STRING[20]; // target string
iLen : INT; // actual length of valid data
END_VAR
Initialise the array and run the standard conversion:
// OB1 / FC "Convert"
arrRx[1] := 16#02; // STX
arrRx[2] := 16#41; // 'A'
arrRx[3] := 16#42; // 'B'
arrRx[4] := 16#43; // 'C'
arrRx[5] := 16#31; // '1'
arrRx[6] := 16#32; // '2'
arrRx[7] := 16#0D; // CR (0x0D = carriage return)
arrRx[8] := 16#03; // ETX
sTelegram := '';
CHARS_TO_STRING(IN := arrRx, COUNT := 8, OUT => sTelegram);
Open the watch table, observe dbCom.sTelegram. The online value is:
'$02ABC12$R$03'
The user sees six extra characters that were not in the raw byte stream: $ prefixes around the control bytes. Length byte of the STRING (byte 1) reads 12 — eight raw bytes plus four escape prefixes. The actual data, however, is intact; the $ is purely a display artefact, and any HMI that renders the STRING will also show those escapes. Most field devices, peer controllers, and downstream SCADA tags will reject the STRING as malformed because it contains a literal $ followed by digits.
STRING variable in the PLC memory never contains a '$' character at all — the $ exists only when the string is read out through a STRING-aware interface (editor, HMI text field, OPC UA string tag). Most other platforms (Python, .NET, C) see the raw bytes 0x02 0x41 0x42 0x43 0x31 0x32 0x0D 0x03. The mismatch between Siemens STRING rendering and the rest of the world is what causes the perceived bug.4. Root Cause Diagram
The following inline SVG shows the data flow that introduces the $:
5. Solution A — Adjust the Byte Count Sent to CHARS_TO_STRING (Trim the ETX)
The cleanest fix at the conversion point is to exclude the framing bytes from the conversion. The telegram protocol description supplied with every compliant serial device defines where the telegram ends; that end is almost always the ETX (16#03), and the converter must therefore be called with COUNT = N − 1 (or N − 2 if a checksum follows).
// In the receive FB, after the ETX has been located:
IF arrRx[iLen] = 16#03 THEN
iLen := iLen - 1; // drop ETX
END_IF;
// Optionally drop CR as well if the device sends CRLF before ETX
IF arrRx[iLen] = 16#0D THEN
iLen := iLen - 1;
END_IF;
sTelegram := '';
CHARS_TO_STRING(IN := arrRx, COUNT := iLen, OUT => sTelegram);
Advantages: no copying, no string manipulation, runs in a single scan. Disadvantage: the original array still contains the framing bytes; if they are needed later (for example, to compute a CRC over the raw telegram), keep a parallel copy in ARRAY OF BYTE.
6. Solution B — Build the STRING Manually in SCL (Full Control)
When the framing bytes are interleaved with the data, or when only the printable subset of the byte stream should ever reach the STRING, build the STRING in a loop:
FUNCTION "BuildTelegram" : Void
VAR_INPUT
arrSrc : ARRAY[*] OF BYTE;
iSrcLen : INT;
END_VAR
VAR_OUTPUT
sDst : STRING[254];
END_VAR
VAR
i : INT;
j : INT;
END_VAR
BEGIN
sDst := ''; // reset
j := 0;
FOR i := 1 TO iSrcLen DO
IF arrSrc[i] >= 16#20 AND arrSrc[i] <= 16#7E THEN // printable ASCII
j := j + 1;
sDst[j] := CHAR_TO_STRING(BYTE_TO_CHAR(arrSrc[i]));
END_IF;
END_FOR;
// STRING length byte is set automatically by the compiler on assignment
END_FUNCTION
This approach uses the standard TIA Portal instructions CHAR_TO_STRING / BYTE_TO_CHAR (TIA V15+). Filtering printable bytes guarantees that no value below 0x20 ever reaches the STRING, so the $ escape is never triggered. If the payload legitimately contains, for example, 0x80…0xFF (extended ASCII / Latin-1), broaden the lower bound to 0x00 only if the STRING is bound for a consumer that handles binary safely.
7. Solution C — Strip Trailing Control Characters with the DELETE Instruction
If the raw array must be converted as-is (for example, the framing bytes are needed during a transition period), convert first, then strip the unwanted characters from the right with the DELETE STRING instruction:
CHARS_TO_STRING(IN := arrRx, COUNT := iSrcLen, OUT => sTelegram);
// Strip trailing CR + ETX (the STRING editor will see these as "$0D" and "$03")
IF RIGHT(sTelegram, 4) = '$03' THEN
sTelegram := DELETE(sTelegram, LEN(sTelegram) - 3, 4);
END_IF;
IF RIGHT(sTelegram, 4) = '$0D' THEN
sTelegram := DELETE(sTelegram, LEN(sTelegram) - 3, 4);
END_IF;
The literal '$03' in STRING source code is interpreted by the compiler as the single byte 16#03, so the comparison works as expected. RIGHT and DELETE are part of the standard TIA Portal String+Char instruction set.
8. Solution D — Configure the PtP / Modbus Interface to Discard Framing
For the S7-1500 CM PtP modules (6ES7531, 6ES7541) and the ET 200SP CM PtP (6ES7136-6BA00), the receive buffer offers configuration of end-of-message criteria. Setting the message end to "character 16#03 received" causes the module to return only the bytes between the configured start and end delimiters and to drop the delimiters themselves. Likewise, on a S7-1200 CM 1241 the "end of message after ETX" option has the same effect. The benefit is that the application receives a clean byte array that already excludes 16#03, so CHARS_TO_STRING is called with a count that does not include the ETX.
For Modbus RTU, the 3.5-character silence already delineates frames, and the Modbus master FB (MB_MASTER on S7-1200, MB_CLIENT on S7-1500) returns only the PDU bytes, so this issue is rare. It is more common on transparent ASCII protocols (barcode scanners, weighing terminals, RFID readers) where the framing is purely a delimiter convention.
9. Alternative: Keep the Data as ARRAY OF BYTE
If the consumer of the telegram is a third-party system that expects raw binary, never convert to STRING at all. Keep the payload in an ARRAY[1..N] OF BYTE and expose the length in a separate INT. Use PEEK / POKE only if you must reinterpret a STRING; for normal byte buffers this is unnecessary. This is the recommended pattern in machine-to-machine integrations where the peer uses general-purpose .NET or Java byte-string conversions that expect raw bytes, not Siemens STRINGs.
10. Cross-Platform Encoding Notes
When the STRING is sent to a non-Siemens system, the encoding chosen on that system matters:
| Consumer | Default encoding assumption for a Siemens STRING | Notes |
|---|---|---|
Python 3 str
|
Each Siemens STRING byte is a single code point, equivalent to ISO-8859-1 / Latin-1 | Use .decode('latin-1') on the inbound bytes; do not use UTF-8 (multi-byte sequences will desynchronise) |
.NET Encoding.ASCII / Encoding.Latin1
|
Single byte per character | See Microsoft Learn: Convert an Array of Bytes into a String for the canonical VB pattern |
C / C++ char[]
|
Raw byte, no encoding | Take care: a literal '$' in the Siemens STRING, if it ever appears (for example, to mean the currency symbol 0x24), is a real data byte, not an escape |
| Modbus / OPC UA String | Raw bytes, count from the length byte | Do not interpret $ as an escape; consume the length byte and read that many bytes verbatim |
The Microsoft Learn VB example demonstrates the inverse direction (byte array to string) using Encoding.Unicode.GetString. When the consumer is .NET, prefer Encoding.Latin1 for Siemens STRING interop, since Unicode (UTF-16) would double every byte and break the framing.
11. Diagnostic Workflow and Troubleshooting Matrix
- Capture the raw byte array with a watch table or trace.
- Identify the framing bytes used by the device: usually
16#02(STX) at the start and16#03(ETX) at the end, with optional16#0D 16#0A(CR LF) before ETX. - Confirm the conversion count passed to
CHARS_TO_STRINGmatches the payload length, not the buffer length. - If the STRING still shows
$, check that no other control character (0x00,0x04,0x1A, …) remains in the payload. - If the HMI shows
$but the PLC tag does not, the issue is the HMI text field: enable "Process value display in hex" or use a character array tag instead of a STRING tag. - For cross-vendor OPC UA, subscribe to the STRING tag with a UA client that shows raw bytes; the
$should not appear because OPC UA returns the underlying string verbatim.
| Observed symptom | Likely cause | Fix |
|---|---|---|
$ at the end of the STRING only |
ETX (0x03) left in the buffer | Solution A — subtract 1 from COUNT |
$03 visible in the HMI text field |
HMI text field renders STRING escapes | Solution B — filter printable range; or use a hex display |
$ in the middle of the STRING |
Mid-payload control byte (e.g. 0x0D CR inside a barcode with carriage return) | Solution B or D — filter or pre-process the array |
| STRING length byte is double the expected value | Every control byte contributes two STRING characters | Switch to ARRAY OF BYTE; rebuild STRING manually with a length counter (Solution B) |
Third-party SCADA receives $03 as 4 ASCII bytes |
SCADA parses Siemens STRING as raw ASCII | Use ARRAY OF BYTE, or post-process the STRING on the SCADA side to strip $xx sequences |
STRING contains $$ in the editor |
A real 0x24 (dollar) byte in the data was escaped by the editor | No action — the editor rendered the escape, the underlying byte is still 0x24 |
12. Verification Procedure
After applying any of the four solutions, validate the result with the following checklist:
- Place a watch table on the STRING tag. Read the length byte (byte 1 of the STRING DB). It must equal the number of meaningful payload characters, with no extra +1, +2 per framing byte.
- Open the STRING online and confirm that no
$prefix is visible. If the payload legitimately contains 0x20…0x7E, every character should be a single printable glyph. - Trigger the receive path and assert with
EQ_STR/NE_STRthat the STRING equals a known-good expected value. - Push the STRING to the HMI and verify the text field displays the expected text exactly, character for character.
- Send the STRING to the peer (Modbus / OPC UA / TCP) and confirm that the peer receives the same number of bytes as the STRING's length byte.
- Run a 24-hour soak test with random-length telegrams to catch any edge case where the framing varies (some devices add a checksum or a length byte before ETX).
13. Extended Example: SCL Function Block for a Generic Serial-to-STRING Converter
Putting the four solutions together into a reusable FB:
FUNCTION_BLOCK "SerialToString"
VAR_INPUT
arrRx : ARRAY[1..256] OF BYTE;
iRxLen : INT; // valid bytes in arrRx
bStripCtrl : BOOL := TRUE; // drop bytes < 0x20 except 0x00
END_VAR
VAR_OUTPUT
sOut : STRING[254];
iOutLen : INT; // length of sOut
bError : BOOL;
iStatus : INT; // 0 = OK, 1 = overflow, 2 = empty
END_VAR
VAR
i : INT;
j : INT;
END_VAR
BEGIN
bError := FALSE;
iOutLen := 0;
sOut := '';
iStatus := 0;
IF iRxLen <= 0 THEN
iStatus := 2;
RETURN;
END_IF;
j := 0;
FOR i := 1 TO iRxLen DO
IF (arrRx[i] >= 16#20 AND arrRx[i] <= 16#7E)
OR (NOT bStripCtrl) THEN
IF j >= LEN(sOut) THEN
bError := TRUE;
iStatus := 1;
RETURN;
END_IF;
j := j + 1;
sOut[j] := CHAR_TO_STRING(BYTE_TO_CHAR(arrRx[i]));
END_IF;
END_FOR;
iOutLen := j;
END_FUNCTION_BLOCK
This FB combines Solution B (filter printable range) with overflow protection. The iOutLen output is the authoritative length; do not trust LEN(sOut) alone when control bytes may have been dropped, because the SCL compiler maintains the length byte automatically but the HMI / OPC UA client may still mis-render it.
14. Frequently Asked Questions
Why does a $ character appear in my Siemens STRING after CHARS_TO_STRING, even though the raw byte array has no $ byte?
The $ is the Siemens STRING escape prefix. When the editor, watch table, or HMI text field renders a STRING, every byte below 0x20 (or 0x7F) is shown as a $ followed by its two-digit hex value. The $ exists only in the rendered view; the underlying STRING variable in the PLC memory contains the raw 8-bit value. This is a display convention, not a data corruption. See the TIA Portal help section "STRING data type" for the complete list of escapes.
Which byte in my serial telegram is causing the $ to appear?
In nine cases out of ten it is the trailing 16#03 (ETX – End of Text) byte used by ASCII serial devices to terminate a telegram. Other common culprits are 16#02 (STX), 16#0D (CR), 16#0A (LF), 16#04 (EOT) and 16#1A (SUB/EOF). Inspect the last one or two bytes of the received array with a watch table; any byte whose value is below 0x20 will trigger a $ escape in the STRING view.
What is the difference between CHARS_TO_STRG (S7-300/400) and CHARS_TO_STRING (S7-1200/1500)?
They are the same function under different names. CHARS_TO_STRG is the FC5 from the STEP 7 V5.x "Standard Library > IEC Function Blocks". CHARS_TO_STRING is the equivalent instruction in the TIA Portal "String+Char" palette for S7-1200 and S7-1500 CPUs. Both copy COUNT bytes from an ARRAY OF CHAR/BYTE into a STRING payload and update the STRING length byte. The escape-character behaviour of the STRING type is identical on both platforms.
Can I configure the PtP or Modbus module to drop the ETX byte automatically so I never have to deal with this $ in my STRING?
Yes. On the S7-1500 CM PtP and ET 200SP CM PtP modules, the receive configuration (in the device configuration, "PtP parameters > Frame") supports defining the end of a message by a specific character such as 16#03. When the configured delimiter is received, the module returns the payload bytes only and discards the delimiter. The S7-1200 CM 1241 RS-232/422/485 has the same option. Modbus RTU is unaffected because its framing is the 3.5-character silence, not an explicit delimiter.
How do I send the STRING to a third-party system (Python, .NET, C) without the $ escape being interpreted?
Either keep the data as ARRAY OF BYTE and transmit the raw bytes, or filter the payload to printable ASCII before the conversion (Solution B in this article) so that no control byte ever reaches the STRING. If the consumer must receive a STRING, expose the length byte alongside the payload and document that the encoding is Latin-1 (ISO-8859-1). Do not use UTF-8 decoding on the consumer side; the Siemens STRING is single-byte per character, and UTF-8 multi-byte sequences will desynchronise the parser.