Accumulate multiline serial reads into variables in CODESYS

7 min read · Published November 4, 2026

The SerialCom example copies every read to the first byte of sReadText, so a multiline scanner reply survives only as its last chunk. Append each read at a tracked offset in a 512-byte accumulation buffer, cut complete lines at the LF byte, and assign fields from each extracted line. One 68-byte reply arriving in a 64-byte and a 4-byte chunk shows the whole method.

The SerialComWithVisu example reads the port correctly and then keeps only the newest piece of what it read. MEM.MemMove writes every completed read to the first byte of sReadText, so a scanner reply that arrives in several reads leaves just its final bytes in the variable. Keep the read call. Change what follows it: receive every chunk into one accumulation buffer at a tracked offset, cut that buffer into lines at the line-feed byte, and assign each complete line to a variable. The worked example below follows one three-line reply through the read buffer in two pieces, which is exactly the case the current code fails.

Why does sReadText only ever hold the last chunk?

Because MEM.MemMove writes to ADR(sReadText) at offset 0 on every completed read: a 68-byte reply arriving as a 64-byte chunk and a 4-byte chunk leaves only those last 4 bytes in the variable, and comRead.szSize counts one chunk, never a whole reply.

One read can return a third of a line, two whole lines, or the tail of one line and the head of the next. The read itself is healthy; the copy discards the history. Walk the variables once with a single triggered scan:

Element Role What it holds after one read
hCom input to comRead handle of the opened port
bReadBuffer pBuffer of comRead the newest chunk, at most 64 bytes
comRead.szSize output of comRead byte count of that one chunk
sReadText copy destination the newest chunk again, as text
xReadSuccess your flag set after any nonempty chunk

The example holds xExecute at TRUE so the block keeps restarting; leave that alone and fix the copy.

What do you need before changing the code?

Four things: the working comRead call from the example, the CAA Memory library for MEM.MemMove and MEM.MemFill, a 512-byte accumulation buffer beside the 64-byte read buffer, and the scanner's interface page that names the delimiter and the field order.

Declare the receiving side like this:

bReadBuffer : ARRAY[0..63]  OF BYTE;   // one chunk from comRead
abyAccum    : ARRAY[0..511] OF BYTE;  // unprocessed bytes kept across reads
diLen       : DINT;                  // bytes currently held in abyAccum
diOverflow  : DINT;                  // discarded partial records
di          : DINT;                  // scan index
iLen        : DINT;                  // length of the extracted line
sLine       : STRING(255);           // one extracted line

Match the port to the scanner: baud rate, data bits, parity and stop bits, one setting each. Serial port setup on Linux covers the port side, and if the open call fails with 5004, the COM.Open error 5004 fix walks that error. Capture one scanner reply as raw hex before writing the parser. CR is 16#0D and LF is 16#0A, control bytes in the same family as those in serial command byte encoding, and a visualization hides both, so take the delimiter from the hex capture.

How do you append a chunk without overwriting what is already there?

Move the chunk to abyAccum plus the retained length instead of to the buffer start: with 38 bytes already held and 4 new bytes, the copy destination is ADR(abyAccum) + 38 and the retained length becomes 42 bytes.

Steps:

  1. Call comRead as the example does and take szRead := comRead.szSize on xDone.
  2. If szRead = 0, leave the buffer alone; a cycle with no traffic keeps diLen unchanged.
  3. If diLen + szRead would pass 512, increment diOverflow, set diLen := 0 and append the chunk at offset 0. The half record is dropped on purpose and the next LF resynchronizes the parser.
  4. Copy the chunk with MEM.MemMove to ADR(abyAccum) + diLen.
  5. Set diLen := diLen + szRead.
  6. Run the line loop of the next section until it finds no more line-feed byte.
comRead(xExecute := TRUE, hCom := hCom,
        pBuffer := ADR(bReadBuffer), szBuffer := SIZEOF(bReadBuffer));
IF comRead.xDone THEN
    szRead := comRead.szSize;
    IF szRead > 0 THEN
        IF diLen + ANY_TO_DINT(szRead) > 512 THEN
            diOverflow := diOverflow + 1;
            diLen := 0;
        END_IF
        MEM.MemMove(ADR(bReadBuffer), ADR(abyAccum) + diLen,
                    ANY_TO_UINT(szRead));
        diLen := diLen + ANY_TO_DINT(szRead);
    END_IF
END_IF

The guard can only trip when diLen already exceeds 448 bytes, because one chunk carries at most the read buffer's 64 bytes. After the reset, the append at offset 0 lands inside the array every time. Keep the MemMove argument order your working line already uses and change only the destination pointer. Your library documentation lists the Source and Destination inputs with a byte count; a similarly named function in other runtimes takes its arguments in a different order, so check your installed signature once, then leave it alone. abyAccum is a byte array, so it needs no terminator inside it.

How do you cut complete lines out of the buffer?

Scan the retained bytes for the LF byte 16#0A, treat everything before it as one line, and strip one leading CR 16#0D after extraction so both CR+LF and LF-only devices work under one rule.

Run this pass repeatedly while a 16#0A is present:

di := 0;
WHILE di < diLen DO
    IF abyAccum[di] = 16#0A THEN EXIT; END_IF
    di := di + 1;
END_WHILE
IF di < diLen THEN
    iLen := di;
    IF (iLen > 0) AND (abyAccum[iLen - 1] = 16#0D) THEN
        iLen := iLen - 1;
    END_IF
    IF iLen <= 255 THEN
        MEM.MemMove(ADR(abyAccum), ADR(sLine), ANY_TO_UINT(iLen));
        MEM.MemFill(ADR(sLine) + iLen, 1, 0);
        // map sLine to its field here
    END_IF
    MEM.MemMove(ADR(abyAccum) + di + 1, ADR(abyAccum),
                ANY_TO_UINT(diLen - di - 1));
    diLen := diLen - di - 1;
END_IF

Three details matter. If no 16#0A is found, the incomplete line simply stays in the buffer for the next read. The final MemFill writes one zero byte at sLine + iLen, the same terminator write your example already does, now at the measured offset. And the last MemMove shifts the remaining suffix to the front of the same buffer, which is legal because MemMove is specified for overlapping source and destination. A CR that lands on the final byte of a read waits in the buffer until its LF arrives; the rule needs no special case for it.

How do you assign the lines to variables?

Parse each extracted line the moment its delimiter is consumed, and publish all fields of one record together, so a screen bound to those variables shows one consistent scan instead of half of one record and half of the next.

The scanner's interface page gives the record shape: a fixed line count, a terminator line, or a stated record length. For a fixed shape, give every line a named variable and split at the separator the document names, often a colon:

sSerial := DELETE(sLine, FIND(sLine, ':'), 1);  // SN:1234567 becomes 1234567

Convert a field to a number only after a length check. Keep identifiers such as serial numbers as text, because leading zeros are data. Serial display command parsing covers taking fixed text formats apart; the same rules apply to a scanner line. Parse into working variables as each line closes, then copy the working set to the published set when the last line of the record closes, line 3 of 3 in the example below. When the line count varies by reply, collect into an array of STRING(255) with a line counter instead of named variables. Once the fields are clean variables, logging PLC variables to a database is a straight next step.

What does the worked example look like with real byte counts?

A scanner answers with three lines of 10, 12 and 40 characters, each closed by CR+LF, so the reply is 12 + 14 + 42 = 68 bytes and the 64-byte read buffer hands it over as a 64-byte chunk and a 4-byte chunk.

Event szRead diLen after append Lines cut diLen after the loop
Read 1 64 64 2 lines, 26 bytes consumed 38
Read 2 4 42 1 line, 42 bytes consumed 0

Read 1: diLen goes from 0 to 64. The first 16#0A sits at index 11 with 16#0D at index 10, so line 1 is bytes 0 to 9, the text SN:1234567, 10 characters; consume 12. The next 16#0A sits 14 bytes later, so line 2 is DOC:PASSPORT, 12 characters; consume 14. Consumed so far 12 + 14 = 26, leaving 64 - 26 = 38 bytes of line 3 in the buffer.

Read 2: append 4 bytes at ADR(abyAccum) + 38, so diLen = 38 + 4 = 42. The 16#0A is at index 41 with 16#0D at 40, so line 3 is 40 characters. Consume 42, diLen returns to 0, and sSerial, sDocType and the third field publish in one assignment.

How do you prove the accumulation works on the bench?

Force the two hard cases on the bench: a line longer than one read buffer must survive in pieces, and a reply whose lines all land in one read must extract in the same task cycle, with diLen back to 0 after every complete record.

Watch diLen in online mode while you trigger scans. It climbs while a reply arrives and returns to 0 when the record closes, and diOverflow stays at 0 on healthy traffic. Then run three tests:

  1. Long line. Send one 100-character line: it arrives in at least two reads, and sLine must reproduce all 100 characters.
  2. Burst. Send three short lines in one burst: all three cut in the same cycle, none left waiting.
  3. Seam. Arrange a read whose last byte is 16#0D: the line closes one read later with the CR stripped.

Time the burst against the port speed, because reads inside the arrival window are the normal case this accumulator exists for, and a chunk boundary tells you nothing about the port.

Recommendation: accumulate in a 512-byte array with a DINT length counter, delimit on LF alone and strip one preceding CR, publish each record's fields in one assignment, and size the array for twice the longest reply the scanner's document allows; when a reply can exceed 512 bytes, grow the array instead of loosening the discard rule.

Questions

Can the accumulation buffer be a STRING instead of a byte array?

Yes. Declare it as STRING(511), which reserves 512 bytes including its terminator, so 511 characters are usable. Append at ADR(sAccum) + diLen exactly as with the array, then write a zero byte at index diLen so the buffer stays a valid string for a bound visualization. Keep the length in your own counter: string functions stop at the first zero byte, and serial data can carry one anywhere.

What should the program do when a reply exceeds the buffer?

Increment a diagnostic counter, set the length counter to zero and append the incoming chunk at offset zero. The half record is discarded deliberately, and the parser resynchronizes at the next LF byte it sees, so one overflow costs at most one record. If the counter moves during normal traffic, the array is too small for the device: double it and repeat the capture test.

Is a short read a sign of a port fault?

No. A read returns whatever arrived since the previous call, and bytes arrive at the line speed. At 9600 baud with 8N1 framing, one byte occupies 10 bits, so the port delivers about 960 bytes per second and a 68-byte reply takes roughly 71 ms. Reads inside that window are short by design. Suspect the port only when szRead stays zero across a full reply, then compare the settings with the scanner document.

Which delimiter rule suits a device that sends CR alone?

Flip the single rule: delimit on 16#0D and strip one following LF if it appears. Capture one raw reply first and count the bytes between records; some optical devices send CR alone, and a parser built for LF only would hold their entire reply as one unterminated line and trip the overflow rule on every scan.

Planning information, not legal or engineering advice. Rules differ by place: check codes and permits with your local authority and your licensed trades.

Keep reading

Build your signAsk for a quote