Bus Adapter

Bus Adapter allows to generate cross-platform C++ library and x86 reader/writer programs for interacting with third-party Modbus devices or for building your own.

Unlike using bare bones Modbus libraries directly, the software generates a thin, strongly-typed portable interface that decouples caller’s code from the details of data encoding, pointer arithmetic and other error-prone logic.

Features

  • Generates portable API and reader/writer programs from a model
  • Enables configuration of data and code footprint
  • Uses static memory allocation
  • Supports configurable, portable logging
  • Allows signed/unsigned 16-, 32-, 64-bit integer and double data types
  • Complies with Modbus RTU standard
  • Provides serial I/O device drivers

Platforms

  • x86
    • MacOS, Linux
  • STM32
    • STM32F103
  • AVR
    • ATmega168, ATmega328p, ATmega1280, ATmega2560

Project Example

Documentation

Example Project for Experimentation

The project includes example model and source code for experimentation and unit testing.

Example firmware, built for STM32 / AVR, acts as Modbus slave and polls a time-of-flight sensor (TOF) for range measurements. The slave stores retrieved range value using generated Modbus API. A reader program, acting as Modbus master, queries the slave for the recorded range value.

1
2
3
4
5
6
7
8
9
10
11
+-----------------------+      +------------------------+
| Linux / MacOS, Master |      | STM32 / AVR, Slave     |
|                       |      |                        |
|   +---------------+   |      |  +------------------+  |      +------------+
|   |     Reader    +---+------+->| TOF poller task  +--+----->| TOF sensor | 
|   +---------------+   |      |  +------------------+  |      +------------+
|                       |      |                        |
|   +---------------+   |      |  +------------------+  |
|   |     Writer    +---+------+->| Operation params |  |
|   +---------------+   |      |  +------------------+  |
+-----------------------+      +------------------------+        

Dependencies

MacOS

Linux

MacOS or Linux

For STM32 target

For AVR target

Build

Bus Adapter can be built as a library to integrate with a custom program/firmware. Another option is to just build reader/writer. To demonstrate both options, the project includes an example model that generates the API and the programs that use that API.

Note, in order for some unit tests to execute successfully, example model must be generated. To do that, set ENABLE_EXAMPLE option.

To build example for x86 / STM32 / AVR, from the project’s root directory execute:

1
$ ./make.sh -x -s -a

Parameters:

  • -x - build code generator, generate APIs and reader/writer programs. Must execute at least once
  • -s - build STM32 firmware
  • -a - build AVR firmware

Model

A model descrbes input/output registers and coils that some Modbus device supports. In addition to bus specifics, the model provides indications as to how to generate the source code. Text file format uses JSON notation and defines the following attributes.

namespace

Boltabus code genearator uses namespace array to enclose the generated code into specified C++ namespace. For example if model contains this:

1
'namespace': [ 'example', 'modbus' ],

code generator would output this:

1
2
3
4
5
6
7
8
9
namespace example
{
namespace modbus
{

  ... code ...

} // namespace modbus
} // namespace example

msb

The parameter msb indicates if the numbers are transmitted using most-significant (MSB) or least-significant byte (LSB) order. By default, LSB order is used.

1
'msb':'false',

register_sets

register_sets defines an array containing logically grouped registers or coils.

A group must contain registers or coils of the same type: coil, contact, input, hold. Default type, if parameter is not specified, is “hold”. Multiple groups having the same type can be specified.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
'register_sets':  [
  {
    'registers_type':'coil',
    ...
  },
  {
    'registers_type':'contact',
    ...
  },
  {
    'registers_type':'input',
    ...
  },
  {
    'registers_type':'hold',
    ...
  },
  {
    'registers_type':'hold',
    ...
  }
]
}

start_rid

start_rid is a register or coil number that starts a given group. Default is 0. In the example below, the first holding register has an ID of 4. The code generator will calculate register IDs for the subsequent registers in the group using this first ID.

1
2
3
4
5
6
7
{
  'registers_type':'hold',
  'start_rid':'4',
  'registers': [
    ...
  ]
}

repeat

repeat parameter indicates how many times a group should be repeated in code, i.e. how many instances should exist. For example, a power supply may have multiple value combinations of voltage/current. Instead of creating unique names for each parameter, the model would use parameter repeat to generate an API that accepts a group/”instance” identifier.

In the following example model defines one group to be repeated 2 times. Client C++ code will need to specify parameter group_id as 0 or 1 when getting/setting values:

1
2
3
4
5
6
7
8
{
  'start_rid':'0',
  'repeat':'2',
  'registers': [
    { 'name':'voltage' },
    { 'name':'current' }
  ]
}

C++ code example. Note, function signature includes starting Register ID. This is to prevent potential name clash with another repeated group that would define similar register names:

1
2
uint16_t v1 = adapter->voltage_0(slave_id, 0);
uint16_t v2 = adapter->voltage_0(slave_id, 1);

registers

registers is an group of registers/coils that are contiguous to each other. Code generator needs non-contiguous registers to be defined in different groups. This is required for calculating register/coil IDs automatically and thus minimizing error-prone manual specification. It is especially desired when registers may be defined using non-standard size of a Modbus register, which is two bytes (one word).

For example, given the model below, registers would have the following IDs:

  • r1 - 4 (because start_rid is 4)
  • r2 - 5 (because start_rid + 1 previous 2-byte register)
  • r3 - 9 (because start_rid + 1 starting 2-byte register + 4 previous “2-byte registers”). 4 previous registers is because parameter bytes of r2 is 8 (not the default 2), and 8 / 2 = 4
1
2
3
4
5
6
'start_rid':'4',
'registers': [
  { 'name':'r1', 'bytes':'2' },
  { 'name':'r2', 'bytes':'8' },
  { 'name':'r3', 'bytes':'2' }
]

Because code generator calculates IDs, caller’s C++ code turns out simpler:

1
2
3
uint16_t r1 = adapter->r1(slave_id);
uint64_t r2 = adapter->r2(slave_id);
uint16_t r3 = adapter->r3(slave_id);

register specification

Register specification describes a Modbus register or coil using the following attributes:

  • name - register name. The name is used as part of getter/setter in the generated interface
  • dec_places - a number of decimal places in a value. Code generator uses double data type in the interface to represent the value. On wire, the value is represented using an integer. A penalty for using this on embedded platform is larger code size
  • signed - type of integer: 1 - signed, 0 - unsigned. Default, if not specified, is unsigned
  • bytes - size in bytes of the value for input/hold register types. Valid values:
    • 2 - represented by uint16_t/int16_t. Default, if not specified, is uint16_t; if signed is 1, int16_t
    • 4 - represented by uint32_t/int32_t
    • 8 - represented by uint64_t/int64_t
    • For coil/contact register type, the data type s uint8_t of 1 byte
  • doc - value description. Code generator outputs this text into getter/setter comments

Given this model:

1
2
3
4
'registers': [
  { 'name':'r2', 'dec_places':'2', 'bytes':'8', 'doc':'Analog register 2' }
  { 'name':'r3' }
]}

code generator outputs the following API. Note val data types of r2 and r3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * Analog register 2. Getter.
 *
 * @param sid - server ID
 * @param rc - if not nullptr, it will be set to 0 when the operation
 *  completes successfully, and to -1 if the operation fails
 * @param info - if not nullptr, the structure is updated with the information
 *  about the register 
 * @return the value
 */
double r2(uint16_t sid, int* rc = nullptr, btr::bus::ReadRegInfo* info = nullptr);

/**
 * Analog register 2. Setter.
 *
 * @param sid - server ID
 * @param val - the value to set
 * @param rc - if not nullptr, it will be set to 0 when the operation completes
 *  successfully, and to -1 if the operation fails
 */
void set_r2(uint16_t sid, double val, int* rc = nullptr);

/* ... */
uint16_t r3(uint16_t sid, int* rc = nullptr, btr::bus::ReadRegInfo* info = nullptr);

/* ... */
void set_r3(uint16_t sid, uint16_t val, int* rc = nullptr);

Logging

This project uses Portable Logger library for logging. The logging API is generated from a model in boltabus/model/logger.mdl.

Logging functions can be disabled during the build by setting variable BTR_LOG_ENABLED to 0. This will reduce binary size and speed up program execution.

Code Configuration

The project uses different pre-processor directives to control what will be compiled into a program. On embedded platform, they also indicate which devices to use for Modbus and logging.

CMakeLists in src/x86, src/stm32, src/avr contain directives for the example programs.