Sequential Morse Code Generator in Siemens SCL TIA Portal

David Krause14 min read
S7-1200SiemensTutorial / How-to
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

Problem Overview and Engineering Requirements

The challenge is straightforward: a Siemens S7-1200 (or S7-1500) holds a string such as 'ABCD' and must emit a Morse-code representation of each character one after the other on a single digital output. The naive approach - a long ladder of IF String[N] = 'A' THEN... blocks - fires every character's pattern simultaneously because all IF conditions evaluate every scan. What is required is a true sequential state machine with three responsibilities:

  1. Look up the Morse pattern for the current character from a constant data block.
  2. Drive the key output for the duration of the next element (dot or dash).
  3. Insert the correct inter-element, inter-character, and inter-word gap before advancing the index.

This reference implementation is built on TIA Portal V13 SP1 or later for the S7-1200 firmware 4.x family, and ports cleanly to S7-1500 firmware 2.x. All code is written in SCL (Structured Control Language) so it can be copy-pasted into a single FB and reused on any project that needs deterministic character-at-a-time serial output. The pattern generalises to any sequential code table: morse, Baudot, 7-segment scanning, LED matrix refresh, or one-wire protocols.

Prerequisites

Item Requirement
Engineering software TIA Portal V13 SP1 Update 9 or newer (V15, V16, V17 also supported)
Controller SIMATIC S7-1200 CPU 1212C / 1214C / 1215C / 1217C or any S7-1500 CPU
Firmware S7-1200 firmware V4.2 or higher (required for full SCL features)
Language SCL (Structured Control Language)
Data blocks 1 global DB for the Morse lookup table (constant)
Function blocks 1 FB for the sequencer (multi-instance capable)
Tags required 1 BOOL start trigger, 1 BOOL key output, 1 STRING input
Cycle time 10 ms or smaller recommended (timer resolution depends on OB1 period)
Note on cycle time: The dot duration must be at least 5x the OB1 cycle time to guarantee timing accuracy. If OB1 runs at 20 ms, set the dot duration to at least 100 ms (T#100ms). For 20 WPM operation with a 60 ms dot, target a 4 ms or smaller OB1 period.

System Architecture

The complete pattern uses three artefacts plus OB1 wiring:

DB_MorseCodes ARRAY[0..25] OF STRING[6] A='.-', B='-...', ... Z='--..' CodeLengths[0..25] OF INT FB_MorseSequencer State: IDLE / LOAD / KEY / GAP TON DotTimer, GapTimer Index, CodePtr, CharCode OB1 Main iStart := tag_Start sIn := tag_Message Q_MorseKey := %Q0.0 Sequential Execution Loop Idle → LoadChar(i) → For each element j in Code[i]: Key ON (1u or 3u) → Gap (1u) → next j After last element: CharGap (2u extra) → i++ → if space, WordGap (4u extra) → repeat

The sequencer FB owns all mutable state (CurrentIndex, CodePtr, the two TON timers, and the active State). The DB holds only constants - this lets you put the Morse alphabet in a single block you can also expose read-only on an HMI for diagnostics.

Morse Code Data Block

Build a global DB called DB_MorseCodes with two constant arrays. The first stores the dot/dash pattern as a STRING so you can read it from a watch table, and the second stores the pattern length so the sequencer knows when to stop without calling LEN every cycle. The array indices match the uppercase ASCII offset: index 0 = 'A', index 25 = 'Z'.

Create the DB and paste the following SCL source view (right-click the DB > Properties > Attributes > uncheck 'Optimised block access' if you want classic DB access from HMI tags; for S7-1500 leave optimised on and use symbolic access only):

DATA_BLOCK "DB_MorseCodes"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
  STRUCT
    Codes : ARRAY[0..25] OF STRING[6] := [
        '.-',     // 0  A
        '-...',   // 1  B
        '-.-.',   // 2  C
        '-..',    // 3  D
        '.',      // 4  E
        '..-.',   // 5  F
        '--.',    // 6  G
        '....',   // 7  H
        '..',     // 8  I
        '.---',   // 9  J
        '-.-',    // 10 K
        '.-..',   // 11 L
        '--',     // 12 M
        '-.',     // 13 N
        '---',    // 14 O
        '.--.',   // 15 P
        '--.-',   // 16 Q
        '.-.',    // 17 R
        '...',    // 18 S
        '-',      // 19 T
        '..-',    // 20 U
        '...-',   // 21 V
        '.--',    // 22 W
        '-..-',   // 23 X
        '-.--',   // 24 Y
        '--..'    // 25 Z
    ];
    CodeLengths : ARRAY[0..25] OF INT := [
        2,4,4,3,1,4,3,4,2,4,3,4,2,2,3,4,4,3,3,1,3,4,3,4,4,4
    ];
  END_STRUCT;
END_DATA_BLOCK

The complete international Morse alphabet is defined by the ITU-R M.1677 recommendation. The patterns above cover the 26 uppercase Latin letters. To extend to digits and punctuation, expand the array range and add the corresponding entries.

Char Code Char Code Char Code
A .- J .--- S ...
B -... K -.- T -
C -.-. L .-.. U ..-
D -.. M -- V ...-
E . N -. W .--
F ..-. O --- X -..-
G --. P .--. Y -.--
H .... Q --.- Z --..
I .. R .-.

State Machine Design

The sequencer walks through five states. Each state owns one job and a single transition condition:

State ID Name Action Next state
0 IDLE Wait for StartTrigger. When seen, copy next character from SourceString, reset CodePtr, raise busy. 1 LOAD
1 LOAD If pointer < code length, fetch element at CodePtr, load DotTimer with 1u (dot) or 3u (dash), drive key output. Else, transition to CHAR_GAP. 2 KEY
2 KEY Hold key output ON until DotTimer.Q. Then drop key, load GapTimer with 1u. 3 GAP
3 GAP Hold key output OFF until GapTimer.Q. Increment CodePtr, loop back to LOAD. 1 LOAD
4 CHAR_GAP Already in the gap; after 1u timer expires, load an additional 2u for inter-character spacing. If character was a space, load 4u more for inter-word. 5 ADVANCE
5 ADVANCE Increment CurrentIndex. If < LEN(SourceString), go to LOAD for next character. Else drop busy, raise done. 0 IDLE

The dot unit is defined by the DotDuration input. Dash = 3 * DotDuration. Inter-element gap = 1 * DotDuration. Inter-character gap = 3 * DotDuration. Inter-word gap = 7 * DotDuration. These ratios are mandated by ITU-R M.1677 and ensure the receiver can decode regardless of the absolute speed.

FB_MorseSequencer SCL Implementation

Create a new Function Block named FB_MorseSequencer. Paste the following code into the SCL editor. The block uses two TON timers in multi-instance form (no separate DB instance required - the FB's instance DB holds them). The SourceString input accepts any STRING up to 80 characters, which fits the default S7-1200 string capacity and avoids the 254-byte ceiling of older CPUs.

FUNCTION_BLOCK "FB_MorseSequencer"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 1.0
   VAR_INPUT
      iStart       : BOOL;            // Rising edge starts transmission
      iReset       : BOOL;            // Force back to IDLE
      sMessage     : STRING[80];      // Text to transmit
      tDotDuration : TIME := T#200ms; // 1 unit of Morse time
   END_VAR

   VAR_OUTPUT
      qKey         : BOOL;            // Drive output to transmitter key
      qBusy        : BOOL;            // Transmission in progress
      qDone        : BOOL;            // Set for one cycle when whole string sent
      qCharIndex   : INT;             // Diagnostic: current source index (0-based)
      qState       : INT;             // Diagnostic: current state ID
   END_VAR

   VAR
      State         : INT := 0;       // 0=IDLE 1=LOAD 2=KEY 3=GAP 4=CHAR_GAP 5=ADVANCE
      CharIndex     : INT := 0;
      CodePtr       : INT := 0;
      CurrentChar   : CHAR;
      ElementIsDash : BOOL;
      DotTimer      : TON;
      GapTimer      : TON;
      ExtraGapTimer : TON;
      LastStart     : BOOL;
      EdgeStart     : BOOL;
   END_VAR

   VAR CONSTANT
      ST_IDLE      : INT := 0;
      ST_LOAD      : INT := 1;
      ST_KEY       : INT := 2;
      ST_GAP       : INT := 3;
      ST_CHAR_GAP  : INT := 4;
      ST_ADVANCE   : INT := 5;
   END_VAR

BEGIN
   // ---- Edge detection on start trigger ----
   EdgeStart := iStart AND NOT LastStart;
   LastStart := iStart;

   // ---- Reset has highest priority ----
   IF iReset THEN
      State := ST_IDLE;
      CharIndex := 0;
      CodePtr := 0;
      DotTimer(IN := FALSE, PT := tDotDuration);
      GapTimer(IN := FALSE, PT := tDotDuration);
      ExtraGapTimer(IN := FALSE, PT := tDotDuration);
      qKey := FALSE;
      qBusy := FALSE;
      qDone := FALSE;
      qCharIndex := 0;
      qState := ST_IDLE;
      RETURN;
   END_IF;

   // ---- Main state machine ----
   CASE State OF

      ST_IDLE:
         qKey := FALSE;
         qBusy := FALSE;
         DotTimer(IN := FALSE, PT := tDotDuration);
         GapTimer(IN := FALSE, PT := tDotDuration);
         ExtraGapTimer(IN := FALSE, PT := tDotDuration);
         IF EdgeStart AND LEN(sMessage) > 0 THEN
            CharIndex := 1;                      // S7-STRING is 1-based
            CodePtr := 0;
            qBusy := TRUE;
            State := ST_LOAD;
         END_IF;

      ST_LOAD:
         qKey := FALSE;
         CurrentChar := sMessage[CharIndex];
         // Normalise: only A-Z supported. Lowercase gets uppercased by offset.
         IF CurrentChar >= 'a' AND CurrentChar <= 'z' THEN
            CurrentChar := CHAR(ORD(CurrentChar) - 32);
         END_IF;
         IF CurrentChar >= 'A' AND CurrentChar <= 'Z' THEN
            // Map 'A'..'Z' to index 0..25
            IF CodePtr < "DB_MorseCodes".CodeLengths[ORD(CurrentChar) - ORD('A')] THEN
               ElementIsDash := (MID("DB_MorseCodes".Codes[ORD(CurrentChar) - ORD('A')],
                                     CodePtr + 1, 1) = '-');
               IF ElementIsDash THEN
                  DotTimer(IN := TRUE, PT := tDotDuration * 3);
               ELSE
                  DotTimer(IN := TRUE, PT := tDotDuration);
               END_IF;
               State := ST_KEY;
            ELSE
               // End of this character, schedule inter-character gap
               IF CurrentChar = ' ' THEN
                  ExtraGapTimer(IN := TRUE, PT := tDotDuration * 4);
               ELSE
                  ExtraGapTimer(IN := TRUE, PT := tDotDuration * 2);
               END_IF;
               State := ST_CHAR_GAP;
            END_IF;
         ELSE
            // Non-alphabetic, non-space: skip with same gap as a space
            ExtraGapTimer(IN := TRUE, PT := tDotDuration * 4);
            State := ST_CHAR_GAP;
         END_IF;

      ST_KEY:
         qKey := TRUE;
         DotTimer(IN := TRUE);
         IF DotTimer.Q THEN
            DotTimer(IN := FALSE);
            qKey := FALSE;
            GapTimer(IN := TRUE, PT := tDotDuration);
            State := ST_GAP;
         END_IF;

      ST_GAP:
         GapTimer(IN := TRUE);
         IF GapTimer.Q THEN
            GapTimer(IN := FALSE);
            CodePtr := CodePtr + 1;
            State := ST_LOAD;
         END_IF;

      ST_CHAR_GAP:
         ExtraGapTimer(IN := TRUE);
         IF ExtraGapTimer.Q THEN
            ExtraGapTimer(IN := FALSE);
            State := ST_ADVANCE;
         END_IF;

      ST_ADVANCE:
         CharIndex := CharIndex + 1;
         CodePtr := 0;
         IF CharIndex > LEN(sMessage) THEN
            State := ST_IDLE;
            qBusy := FALSE;
            qDone := TRUE;
         ELSE
            State := ST_LOAD;
         END_IF;

   ELSE
      State := ST_IDLE;
   END_CASE;

   // ---- Diagnostic outputs ----
   qCharIndex := CharIndex;
   qState := State;

   // ---- One-shot done flag ----
   IF qDone THEN
      qDone := FALSE; // pulse for one cycle
   END_IF;
END_FUNCTION_BLOCK
S7-STRING indexing: Siemens strings are 1-based, with byte 1 holding the max length, byte 2 the actual length, and bytes 3..n the character data. The code above uses sMessage[CharIndex] where CharIndex = 1 refers to the first character. LEN() returns the actual length, not the capacity.

OB1 Main Program Wiring

From OB1, instantiate the FB in a global DB called IDB_Morse. The wiring shown below uses a tag from the default tag table (tag_StartBtn) and the string tag_MessageText as inputs. The output drives a digital output module channel wired to the Morse key relay or optocoupler.

// OB1 - Main cyclic program
"IDB_Morse"(iStart       := "tag_StartBtn",
            iReset       := "tag_ResetBtn",
            sMessage     := "tag_MessageText",
            tDotDuration := T#120ms,   // ~20 WPM (PARIS method)
            qKey         => "%Q0.0",
            qBusy        => "tag_MorseBusy",
            qDone        => "tag_MorseDone",
            qCharIndex   => "tag_MorseCharIndex",
            qState       => "tag_MorseState");

Timing Parameters and the PARIS Standard

Morse speed is universally measured in words per minute using the PARIS benchmark (the word "PARIS" plus one space = 50 dot units). The conversion formula is:

DotDuration (seconds) = 1.2 / WPM

Speed (WPM) Dot duration Dash duration Char gap Word gap Typical use
5 240 ms 720 ms 720 ms 1680 ms Beginner CW practice
10 120 ms 360 ms 360 ms 840 ms Slow conversational
15 80 ms 240 ms 240 ms 560 ms Standard CW QRP
20 60 ms 180 ms 180 ms 420 ms Amateur radio standard
25 48 ms 144 ms 144 ms 336 ms Experienced operators
30 40 ms 120 ms 120 ms 280 ms Contest / high-speed CW

For amateur-radio applications on HF (3.5 - 30 MHz), 20 WPM (60 ms dot) is the de facto default and matches the FB default of T#200ms when scaled. Tune tDotDuration at the OB1 call site rather than editing the FB so the same block can drive multiple keys at different speeds.

Verification Procedure

  1. Compile and download. Right-click the PLC in the project tree > Compile > Software (rebuild all). Download to the target CPU. Clear CPU to RUN.
  2. Online watch. Open IDB_Morse in the watch table. Add the static tags State, CharIndex, CodePtr, CurrentChar, DotTimer.Q, and GapTimer.Q with 'Monitor all' toggled on.
  3. Force iStart. Click on the iStart operand and use 'Modify to 1'. Confirm that State advances 0 → 1 → 2 → 3 cyclically and that qKey pulses high for the dot/dash interval.
  4. Scope the output. Connect an oscilloscope or logic analyser to %Q0.0. Trigger on the rising edge. Measure the high-time of the first pulse: for an 'A' it should equal DotDuration (the dot), then a gap of equal duration, then another high-time of 3 * DotDuration (the dash).
  5. End-to-end test. Set tag_MessageText := 'SOS'. The scope should show three short, three long, three short pulses in the canonical distress pattern, followed by seven dot-units of silence before the qDone pulse.
  6. Edge case test. Load 'HI MOM'. Verify that the gap between 'M' and 'M' is exactly 3 dot units while the gap between 'M' and the next character across the space is 7 dot units. If the space gap is wrong, check the ST_LOAD branch that compares CurrentChar = ' '.

Troubleshooting Matrix

Reduce OB1 cycle time in the CPU properties or lengthen DotDurationVerify ST_LOAD branch uses tDotDuration * 2 for letters, * 4 for spaces
Symptom Likely cause Fix
All characters fire at once, output stuck high Original ladder logic running in parallel; sequencer FB not actually driving %Q0.0 Delete the IF String[N] = 'A' THEN blocks and route qKey directly to the output
Output never goes high EdgeStart not detected because start tag held high continuously Use a momentary pushbutton or add a positive-edge helper flag in OB1
First element missing, dashes only CodePtr starts at 1 instead of 0 (off-by-one in ST_LOAD) Confirm CodePtr := 0 in ST_IDLE and after ST_ADVANCE
Dots and dashes at random durations OB1 cycle time > 1/5 of DotDuration
Compiler error "MID requires STRING variable" DB array element not being indexed properly because DB is optimised and accessed by literal Use symbolic access: "DB_MorseCodes".Codes[idx] with a variable index, not a literal
Compilation warning "Uninitialised variable" Static tags declared without default; TIA Portal V13 SP1 is stricter than older versions Add := 0 or := '' to every static declaration
Receiver decodes garbage at high speed Inter-character gap only 1u instead of 3u because ExtraGapTimer was set to tDotDuration rather than tDotDuration * 2
Transmission never finishes (qDone never set) Empty string or zero-length DB array access at end Guard ST_ADVANCE with CharIndex > LEN(sMessage) and skip lookup when CharIndex is past end
Lowercase letters corrupt output Lowercase 'a' maps to ASCII 97, falling outside the 0..25 array range Uppercase via ORD(c) - 32 in ST_LOAD as shown above

Edge Cases and Field Notes

  • Re-triggering during transmission: Calling iStart while qBusy is true is ignored. To re-trigger mid-message, raise iReset first then pulse iStart.
  • Multi-instance usage: The FB is multi-instance capable. Drop two instances - IDB_MorseA and IDB_MorseB - and call them with different tDotDuration values to drive two CW transmitters at different speeds from one CPU.
  • PROFINET output module: If %Q0.0 lives on a remote ET200SP, account for PROFINET update time (typically 1 ms). The dot duration must be at least 10 ms to remain immune to update jitter.
  • Cycle time impact: Each FB call adds roughly 5 to 15 microseconds per instance on an S7-1214C. At 10 ms OB1 period this is negligible; at 1 ms cycle time ten instances will consume about 1 percent of CPU budget.
  • String 80 vs 254: S7-1200 strings default to 254 bytes max. Declaring STRING[80] saves 174 bytes of instance memory per FB call. If you need longer messages, declare STRING[254] and the rest of the code works unchanged.
  • HMI text panel: To send text from a KTP700 Basic HMI, use a text field tied to tag_MessageText (STRING). The HMI panel's variable pointer must be set to WString-compatible string type to avoid truncation at non-ASCII characters.
  • Safety integration: If the key output drives a transmitter on a manned vessel or in an aircraft simulator, route qKey through an F-CPU safety gate. The F-block FDB_MorseKeySafe can wrap the output with a 100-ms discrepancy check against a second channel before the relay pulls in.

FAQ

What is the minimum TIA Portal version for this code?

TIA Portal V13 SP1 Update 9 with S7-1200 firmware 4.2 or newer is the minimum. The code uses only standard SCL features (CASE, TON, MID, STRING indexing) that have been present since V11, but V13 SP1 is required for the optimised DB syntax and the CHAR/ORD functions used in the lowercase-to-uppercase conversion.

How do I change the Morse speed without editing the FB?

Pass a different TIME constant at the OB1 call site: tDotDuration := T#60ms for 20 WPM, T#120ms for 10 WPM. The dot is the fundamental unit and every other interval (dash, gap, char gap, word gap) is derived as a multiple of it.

Why does my output stay high forever after the message finishes?

The most common cause is the sequencer stuck in ST_KEY because DotTimer.Q never goes true. Verify that DotTimer(IN := TRUE) is called every cycle in ST_KEY - omitting the call disables the running timer. Also check that tDotDuration is not zero.

Can the FB handle digits 0 to 9 and punctuation?

Yes. Extend DB_MorseCodes.Codes to ARRAY[0..35] (or 0..39 with punctuation), add the additional patterns, and add a second CASE branch in ST_LOAD for the '0'..'9' range. The mapping changes to ORD(CurrentChar) - ORD('0') + 26 for digits.

How do I reset the sequencer from an HMI?

Wire a boolean tag from the HMI button to the iReset input. Holding iReset for one cycle is enough to force the FB back to ST_IDLE, clear all timers, and drop qBusy. The next rising edge on iStart restarts from the first character.

Back to blog