Let's make a nurse call system Turing complete!
This post details my research on how to set up a nurse call messaging server to be a Turing complete virtual computer.
Introduction
One of the things I am involved with in my day job is nurse call systems, these systems are installed in places such as hospitals and retirement homes to signal staff that a patient is in need of attention. These kind of systems usually consist of room units in the patient rooms or wireless call buttons from which the calls are intiated and central processing system called a messaging server. The messaging server is used to send the calls to pagers, telephones, smartphone apps or other ways to alert the staff. The messaging server is often also used as an interface to connect other systems in the building like fire alarm systems with the staff for messaging. To get all calls to the right people at the right time the messaging server has to route messages according to parameters such as the time of day and urgency of the message.
One of the messaging servers I am familiar with is called Netrix and is part of the nurse call system iCall by IndigoCare. Though personally I am not thrilled this a graphical Windows program (yes, it is not a service), it does its job and has definitely saved plenty of lives. I have written the Wireshark dissectors for the two protocols used by the room units and the messaging server. Recently I was giving in-company training on the iCall system and Netrix messaging server when it struck me that certain routing features might be used for more than just sending text messages to pagers so I spent some time researching how to turn the Netrix application into a rudimentary computer system that will execute a program of assembly style instructions with conditional branching.
Netrix background
Before getting further into the story I will explain the basic mechanics of message routing used by the Netrix application that are relevant for this post.
Conversion table
A conversion table is a table that has two columns: original and translation. The purpose of a conversion table is to search and replace known values, this is used for things like translating wireless call button IDs to room numbers.
Group
A group is a way to specify recipients for a message, recipients are called participants. Each participant can be of a different type and have a different payload. A group is called with parameters called MSG1
to MSG6
, these parameters contain information like room number or patient name. A group is addressed by its number.
Participants can be of a variety of types like phones, pagers, other groups (message parameters can be manipulate before being passed on) or text log files. There is also a special kind of participant that is used to add/overwrite or delete values in a conversion table.
When passing on parameters to group participants they can be manipulated in several ways and all operations are done on text strings. Some examples:
- Look up the translation of a hardcoded string in a conversion table (e.g. to get the value for key keyname in conversiontable foo
&[GETVAR]foo,keyname]
) - Look up the translation of a group call parameter in a hardcoded conversion table (e.g. to get the value for a key with the text in MSG1 in conversiontable foo
&[MSG1C]foo]
) - Look up the reverse translation of a group call parameter in a hardcoded conversion table (e.g. to get the key for a value with the text in MSG1 in conversiontable foo
&[MSG1CS]foo]
) - Hardcoded text, some dynamic values (date/time etc), input parameters (e.g. to get the text in MSG1
&[MSG1]
) and the above translation results can concatenated
Important information about participant operations:
- All lookup operations are nonrecursive
- All fields used to call a participant can be manipulated this includes the number to indicate which group is called as a participant
- Every participant in a group is called on sequentially not in parallel, the order is the order listed in the interface of the Netrix
- If another group is called as a participant this execution will be executed before going to the next participant in the parent group
I will make use of four types of participants:
Type | Explanation |
---|---|
COMMON DEBUG | Print a message to the general debug window |
Group | Execute a specified group, MSG1 to MSG6 are passed on verbatim |
Group MSG1-6 | Execute a specified group, MSG1 to MSG6 can be specified |
CONVERSIONTABLE | Add/overwrite or remove an entry in a conversion table |
Turing completeness
For a full explanation of Turing completeness I have to refer to Wikipedia, but the general idea is the following:
To show that something is Turing complete, it is enough to show that it can be used to simulate some Turing complete system. For example, an imperative language is Turing complete if it has conditional branching (e.g., “if” and “goto” statements, or a “branch if zero” instruction; see one instruction set computer) and the ability to change an arbitrary amount of memory (e.g., the ability to maintain an arbitrary number of data items). Of course, no physical system can have infinite memory; but if the limitation of finite memory is ignored, most programming languages are otherwise Turing complete.
In order to be Turing complete it ought to be sufficient to design a configuration that will allow execution of a program with addressable memory and conditional branching. I will design a configuration of the Netrix that will work as a virtual machine to accomplish this.
General design of the Netrix Virtual Machine
To keep things simple I will employ a mock Von Neumann architecture, this means the program instructions are stored in the same memory where data is manipulated. This makes is easy for me to reserve memory to read/write around the instructions of the program.
Memory
The only data structure available for storage is the conversion table, so I will define a conversion table memory
to act as my addressable memory. Because the conversion tables are tables without indexed rows I use the original column to keep the address of the memory cell and the translation column to keep the data. This will result in the memory
looking something like this when instructions are in memory:
address | content |
---|---|
0 | <instruction> |
1 | <operand> |
2 | <instruction> |
3 | <operand> |
This “memory” is addressable using the original column, it’s of course important to make sure the addressing is correctly entered in this column and that no double addresses are assigned. &[GETVAR]memory,address]
can now be used to read memory, writing can be accomplished by using the special group participant type to add/overwrite a value for a key in a conversion table.
Processor registers
Another conversion table register
will be reserved for registers for the virtual processor, at least a program counter (referring to an address in memory
) and a zero flag will be added as keys here.
Processor operation
As usual the processor starts by reading the instruction at the memory cell that the pc
register indicates, then the instruction is executed and the pc
register is updated to point to the next instruction in memory to execute.
At certain points some scratch memory will be needed for internal usage when executing instructions so another conversion table processor
is added. This memory is not available to the programs and instructions are free to consider this memory volatile.
Read instruction
Because it is not possible to use the functions we can apply on the conversion tables in a recursive way we have to work around this by loading the memory content into a hardcoded place which we can then pass on as the parameter to a group call on the next line. This pattern will be used extensively as a work around.
Execute instruction
This is where we need to have a way for the Netrix to branch on a given input. This is possible because the group number for a group call can be the result of a lookup in a conversion table. To use this in the virtual processor another table, instruction
, is added to map an opcode or mnemonic to a group that will execute this instruction:
mnemonic | group |
---|---|
NOP | 1000 |
HLT | 1010 |
Because more than one group might be needed due to the limitations of nonrecursive operations it makes sense to keep some free groups between instructions.
Updating the PC
Updating the program counter to point to the next instruction will be something most instructions need so that is another basic function to add to the processor.
When looking a implementing a basic NOP all we really have to do is a simple pc++
and go to the next cycle.
This is not as simple as it would appear because Netrix has no arithmetic operations, a work around is to use another conversion table for looking up the result of the operation as a value. Of course this will only work for systems with set maximum values for numbers, but when making 8 bit numbers a constraint it is no problem at all:
number | +1 |
---|---|
0 | 1 |
1 | 2 |
2 | 3 |
… | … |
255 | 0 |
Because reverse lookup is available this table works for both +1
and -1
operations, this table will be known as diff1
. It’s a bit awkward but works well enough.
Putting it all together
Putting all the previous bits together gives the following pseudo code that can read and execute NOP and HLT instructions.
instruction {
"NOP" = instrNOP,
"HLT" = instrHLT
}
diff1 = {
0 = 1,
1 = 2,
2 = 3,
...
255 = 0
}
readInstruction(location) {
processor.instr = memory[location]
}
executeInstruction(mnemonic) {
instructions[mnemonic]()
}
incPC(pc) {
register.pc = diff1[pc]
}
stepCPU() {
readInstruction(register.pc)
executeInstruction(processor.instr)
}
instrNOP() {
incPC(register.pc)
stepCPU()
}
instrHLT() {
}
run() {
register.pc = 0
stepCPU()
}
Translating this to the configuration of the Netrix gives flow given below. I have added an extra group that will print debug messages so we know what’s going on.
Group 1 - run()
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = register var = pc value = 0 |
register.pc = 0 |
Group | group = 100 | stepCPU() |
Group 100 - stepCPU()
Type | Parameters | Explanation |
---|---|---|
Group MSG1-6 | group = 101 MSG1 = &[GETVAR]register,pc] |
readInstruction(register.pc) |
Group MSG1-6 | group = 102 MSG1 = &[GETVAR]processor,instr] |
executeInstruction(processor.instr) |
Group 101 - readInstruction(location)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = processor var = instr value = &[MSG1C]memory] |
processor.instr = memory[MSG1] |
Group 102 - executeInstruction(mnemonic)
Type | Parameters | Explanation |
---|---|---|
Group | group = &[MSG1C]instructions] | instructions[mnemonic]() |
Group 103 - incPC(pc)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = register var = pc value = &[MSG1C]diff1] |
register.pc = diff1[register.pc] |
Group 1000 - instrNOP()
Type | Parameters | Explanation |
---|---|---|
Group MSG1-6 | group = 9000 MSG1 = “NOP” |
debug("NOP") |
Group MSG1-6 | group = 103 MSG1 = &[GETVAR]register,pc] |
incPC(register.pc) |
Group | group = 100 | stepCPU() |
Group 1010 - instrHLT()
Type | Parameters | Explanation |
---|---|---|
Group MSG1-6 | group = 9000 MSG1 = “HLT” |
debug("HLT") |
Group 9000 - debug(message)
Type | Parameters | Explanation |
---|---|---|
COMMON DEBUG | message = &[MSG1] | print(MSG1) |
First test run
Let’s try the vm with a simple program, to do this we load a simple program in the memory table:
address | content |
---|---|
0 | NOP |
1 | NOP |
2 | HLT |
And we rig up a group to start the vm, this group sets the pc field in the register table to 0
and calls group 100 to start the execution. Then we can call this group once to “boot” the vm!
If everything is configured correctly this will result in the common debug window displaying the instructions that were called:
NOP
NOP
HLT
Making Netrix Virtual Machine Turing complete
Unconditional branching
Because the pc
register points to the instruction to execute in the next CPU cycle it is quite easy to implement unconditional branching. We just need to read operand 1 which is the destination of the jump and set the pc
to that address before calling stepCPU().
Since other instructions will also have to read one or more operands it’s a good time to implement a way to read operands following an instruction. These operands will be read into a location of the volatile processor
memory space. We start by making a copy of the value of the pc
and increase this by 1 for each operand we read. The incPC() function will be removed and replaced by a function that calls incVolatilePC()
, register.pc = processor.pc
and stepCPU()
prevent duplicated functions.
incVolatilePCandStep() {
incVolatilePC()
register.pc = processor.pc
stepCPU()
}
setVolatilePC() {
processor.pc = register.pc
}
incVolatilePC(volatilePC) {
processor.pc = diff1[volatilePC]
}
setVolatileFromMemory(destination, address) {
processor.destination = memory[address]
}
setVolatileOperand(destination) {
incVolatilePC(processor.pc)
setVolatileFromMemory(destination, processor.pc)
}
instrJMP() {
setVolatilePC()
setVolatileOperand(operand1)
register.pc = processor.operand1
stepCPU()
}
Group 103 - incVolatilePCandStep()
Type | Parameters | Explanation |
---|---|---|
Group MSG1-6 | group = 105 MSG1 = &[GETVAR]processor,pc] |
incVolatilePC(processor.pc) |
CONVERSIONTABLE | type = Add var table = register var = pc value = &[GETVAR]processor,pc] |
register.pc = processor.pc |
Group | group = 100 | stepCPU() |
Group 104 - setVolatilePC()
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = processor var = pc value = &[GETVAR]register,pc] |
processor.pc = register.pc |
Group 105 - incVolatilePC(volatilePC)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = processor var = pc value = &[MSG1C]diff1] |
processor.pc = diff1[volatilePC] |
Group 106 - setVolatileFromMemory(destination, address)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = processor var = &[MSG1] value = &[MSG2C]memory] |
processor.destination = memory[address] |
Group 107 - setVolatileOperand(destination)
Type | Parameters | Explanation |
---|---|---|
Group MSG1-6 | group = 105 MSG1 = &[GETVAR]processor,pc] |
incVolatilePC(processor.pc) |
Group MSG1-6 | group = 106 MSG1 = &[MSG1] MSG2 = &[GETVAR]processor,pc] |
setVolatileFromMemory(destination, processor.pc) |
Group 1020 - instrJMP()
Type | Parameters | Explanation |
---|---|---|
Group | group = 104 | setVolatilePC() |
Group MSG1-6 | group = 107 MSG1 = “operand1” |
setVolatileOperand(operand1) |
Group MSG1-6 | group = 9000 MSG1 = “JMP &[GETVAR]processor,operand1]” |
debug("JMP " + processor.operand1) |
CONVERSIONTABLE | type = Add var table = register var = pc value = &[GETVAR]processor,operand1] |
register.pc = processor.operand1 |
Group | group = 100 | stepCPU() |
Time for another simple program to test the jump:
address | content |
---|---|
0 | NOP |
1 | JMP |
2 | 4 |
3 | NOP |
4 | HLT |
Executing this program results in a successful jump:
NOP
JMP 4
HLT
Reading and writing from memory
A processor needs to move data around do anything useful, so now is a nice time to implement that. I will add an accumulator register to the virtual cpu called a
and add instructions to move data from memory to the accumulator and move data from the accumulator to memory. This will result in two instructions: MOVMA, MOVAM. Because immediate values are also useful an additional MOVIA will also be implemented.
setRegisterFromMemory(destination, address) {
register.destination = memory[address]
}
setMemoryFromImmediate(address, value) {
memory[address] = value
}
instrMOVMA() {
setVolatilePC()
setVolatileOperand(operand1)
setRegisterFromMemory(a, processor.operand1)
incVolatilePCandStep()
}
instrMOVAM() {
setVolatilePC()
setVolatileOperand(operand1)
setMemoryFromImmediate(processor.operand1, register.a)
incVolatilePCandStep()
}
instrMOVIA() {
setVolatilePC()
setVolatileOperand(operand1)
register.a = processor.operand1
incVolatilePCandStep()
}
Group 108 - setRegisterFromMemory(destination, address)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = register var = &[MSG1] value = &[MSG2C]memory] |
register.destination = memory[address] |
Group 109 - setMemoryFromImmediate(address, value)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = memory var = &[MSG1] value = &[MSG2] |
memory[address] = value |
Group 1030 - instrMOVMA()
Type | Parameters | Explanation |
---|---|---|
Group | group = 104 | setVolatilePC() |
Group MSG1-6 | group = 107 MSG1 = “operand1” |
setVolatileOperand(operand1) |
Group MSG1-6 | group = 9000 MSG1 = “MOVIA &[GETVAR]processor,operand1]” |
debug("MOVMA " + processor.operand1) |
Group MSG1-6 | group = 108 MSG1 = a MSG2 = &[GETVAR]processor,operand1] |
setRegisterFromMemory(destination, processor.operand1) |
Group | group = 103 | incVolatilePCandStep() |
Group 1040 - instrMOVAM()
Type | Parameters | Explanation |
---|---|---|
Group | group = 104 | setVolatilePC() |
Group MSG1-6 | group = 107 MSG1 = “operand1” |
setVolatileOperand(operand1) |
Group MSG1-6 | group = 9000 MSG1 = “MOVAM &[GETVAR]processor,operand1]” |
debug("MOVAM " + processor.operand1) |
Group MSG1-6 | group = 109 MSG1 = &[GETVAR]processor,operand1] MSG2 = &[GETVAR]register,a] |
setMemoryFromImmediate(processor.operand1, register.a) |
Group | group = 103 | incVolatilePCandStep() |
Group 1050 - instrMOVIA()
Type | Parameters | Explanation |
---|---|---|
Group | group = 104 | setVolatilePC() |
Group MSG1-6 | group = 107 MSG1 = “operand1” |
setVolatileOperand(operand1) |
Group MSG1-6 | group = 9000 MSG1 = “MOVIA &[GETVAR]processor,operand1]” |
debug("MOVIA " + processor.operand1) |
CONVERSIONTABLE | type = Add var table = register var = a value = &[GETVAR]processor,operand1] |
register.a = processor.operand1 |
Group | group = 103 | incVolatilePCandStep() |
Testing will show that moving stuff around works like a charm. So let’s move on to doing things with the stuff we can now move.
Decrement accumulator
Before working on conditional branching we must implement something that will give conditions on which to branch. We will do this by adding an instruction to decrement the accumulator. When the accumulator gets down to zero a zeroflag will be set in the register table which we can use for branching later on.
Decrementing is accomplished with reverse lookup in the diff1
table. Because the Netrix cannot do arithmetic and is not aware of numbers a test to check if something is zero will be a bit more complicated. Once again we have to resort to a lookup table, since the constraint of numbers being 8 bits integers was put forth for memory addresses we can continue on this path and generate a lookup table iszero that is true for value 0
and false for 1
upto 255
. The zero flag will take one of the true
or false
values after checking.
DECA explained:
iszero = {
0 = true,
1 = false,
2 = false,
...
255 = false
}
testZero(value) {
register.zero = iszero[value]
}
instrDECA-decr(value) {
register.a = diff1_reverse[value]
}
instrDECA() {
setVolatilePC()
instrDECA-decr(register.a)
testZero(register.a)
incVolatilePCandStep()
}
Group 110 - testZero(value)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = register var = zero value = &[MSG1C]iszero] |
register.zero = iszero[value] |
Group 1060 - instrDECA()
Type | Parameters | Explanation |
---|---|---|
Group | group = 104 | setVolatilePC() |
Group MSG1-6 | group = 9000 MSG1 = “DECA” |
debug("DECA") |
Group MSG1-6 | group = 1061 MSG1 = &[GETVAR]register,a] |
instrDECA-decr(register.a) |
Group MSG1-6 | group = 110 MSG1 = &[GETVAR]register,a] |
testZero(register.a) |
Group | group = 103 | incVolatilePCandStep() |
Group 1061 - instrDECA-decr(value)
Type | Parameters | Explanation |
---|---|---|
CONVERSIONTABLE | type = Add var table = register var = a value = &[MSG1CS]diff1] |
register.a = diff1_reverse[value] |
Conditional branching
Now that the zero flag is in place and working we can finally implement conditional branching: JMPZ (jump if zero flag is true). As seen before when picking the right group to call to execute an instruction conditional operations in the Netrix have to be built using conversion tables. The construction will be to have a conversion table that has a value true pointing to a group that will execute the true branch and another entry to the group that handles the false branch:
value | branch |
---|---|
true | 1072 |
false | 1073 |
To put that in pseudo code:
jmpz-boolean = {
true = instrJMPZ-true,
false = instrJMPZ-false
}
instrJMPZ-test(zeroflag) {
jmpz-boolean[zeroflag]()
}
instrJMPZ-true() {
register.pc = processor.operand1
stepCPU()
}
instrJMPZ-false() {
incVolatilePCandStep()
}
instrJMPZ() {
setVolatilePC()
setVolatileOperand(operand1)
instrJMPZ-test(register.zero)
}
Group 1070 - instrJMPZ()
Type | Parameters | Explanation |
---|---|---|
Group | group = 104 | setVolatilePC() |
Group MSG1-6 | group = 107 MSG1 = “operand1” |
setVolatileOperand(operand1) |
Group MSG1-6 | group = 1071 MSG1 = &[GETVAR]register,zero] |
instrJMPZ-test(register.zero) |
Group 1071 - instrJMPZ-test(zeroflag)
Type | Parameters | Explanation |
---|---|---|
Group | group = &[MSG1C]jmpz-boolean] | jmpz-boolean[zeroflag]() |
Group 1072 - instrJMPZ-true()
Type | Parameters | Explanation |
---|---|---|
Group MSG1-6 | group = 9000 MSG1 = “JMPZ true &[GETVAR]processor,operand1]” |
debug("JMPZ true " + processor.operand1) |
CONVERSIONTABLE | type = Add var table = register var = pc value = &[GETVAR]processor,operand1] |
register.pc = processor.operand1 |
Group | group = 100 | stepCPU() |
Group 1073 - instrJMPZ-false()
Type | Parameters | Explanation |
---|---|---|
Group MSG1-6 | group = 9000 MSG1 = “JMPZ false” |
debug("JMPZ false") |
Group | group = 103 | incVolatilePCandStep() |
So now a little program to bring it all together:
MOVMA var1
repeat:
DECA
JMPZ end
JMP repeat
end:
HLT
var1:
2
As loaded into the memory conversion table:
address | content |
---|---|
0 | MOVMA |
1 | 10 |
2 | DECA |
3 | JMPZ |
4 | 7 |
5 | JMP |
6 | 2 |
7 | HLT |
10 | 2 |
Running this will result in a an output showing the following:
MOVMA 10
DECA
JMPZ false
JMP 2
DECA
JMPZ true 7
HLT
The virtual machine is now Turing complete because we have implemented reading/writing of memory and conditional branching.
All other operations can be synthesised from the operations implemented. For instance to implement (unsigned) subtraction of values a and b (a - b), it is sufficient to keep decrementing a and b both by 1 until b gets down to zero. What’s left in a is the result of the operation.
Stack issues
Alert readers might have noticed that we keep calling the stepCPU() function from within a branch of this function, this means every cycle will be executed as a child of the previous cycle and thus keep eating up stack space on the host system. Using a feature called repeat calls we can work around this. This feature is used to keep repeating actions until the TTL runs out or until it is reset. Thus starting a chain of events from one parent event repeatedly. The problem is the minimum interval before sending a message is 5 seconds so that would make our cpu quite slow. The alternative I came up with means adding two input/outputs of the type IP and configuring them in a loopback configuration. This way a function tickCPU() will send a message that triggers another execution of the stepCPU() group! Where we called the stepCPU() function directly from other groups before, we switch that to tickCPU() and can enjoy the virtual CPU happily ticking away in an explosion of TCP packets :-)
IP-1:
Setting | Value |
---|---|
Name | Loopback out |
Protocol | TCP/IP CLIENT |
Port | 6000 |
Input | EssecProtocol |
IP-2:
Setting | Value |
---|---|
Name | Loopback in |
Protocol | TCP/IP HOST |
Port | 6000 |
Input | EssecProtocol |
Group 112 - tickCPU()
Type | Parameters | Explanation |
---|---|---|
IP | IP = 1 Protocol Header = 10 DI 01 = 100 |
Group call for group 100 on remote host of IP 1 |
In conclusion
I think the goal of making a Turing complete virtual machine within the posed constraints has been reached quite well. It has been fun to look for ways to abuse the system to get to this point. The configuration I made for this article is available in a separate repository to try out on your own Netrix.
Understand that this is merely an exercise of the mind and everybody can understand you should not run hacks like this in a production environment where lives literally depend on this messaging server.
Of course a big thumbs up to the guys from IndigoCare who’re always pretty responsive to feature requests and open about the protocols theirs products use!