When I’m designing the control interface for a new device intended for lab equipment use or an OEM device that doesn’t require constant high data rates, SCPI is the obvious first consideration. It’s human-readable and ASCII-based, which makes it suitable for debugging over serial terminals but less suitable for high-throughput or lossy links (though you could add manual CRCs to detect corrupted responses). It is also the language that every other lab device speaks so sticking to the standard is a good first step.
I’m using the SCPI-parser library which is a long standing, actively maintained, and fully compliant C implementation.
scpi_user_config.h
: Define any alterations to the library configurationscpi_commands.hpp
: Define the container class / structscpi_commands.cpp
: Define the container’s init function and the required command tableinterface.hpp
: Declares a set of getter and setter functions to expose the device functionsmain.cpp
: Instantiate and use the parserOptional header imported by scpi-parser/config.h
. Use this to choose the units supported, the memory allocation, and the string-to-value / value-to-string conversion methods. Read through `scpi-parser/config.h`` for what can be configured.
#ifdef SCPI_USER_CONFIG
#include "scpi_user_config.h"
#endif
If you’re using C++ you may want to make a container class such as:
#pragma once
#include "scpi/scpi.h"
#include <array>
#include <cstdint>
struct ScpiInterfaceContainer {
public:
static constexpr std::size_t kBufferLength = 256;
static constexpr std::size_t kErrorQueueLength = 8;
std::array<const char *, 4> idn_fields_;
scpi_interface_t scpi_interface_;
scpi_t scpi_context_{};
std::array<uint8_t, kBufferLength> scpi_input_buffer_{};
std::array<scpi_error_t, kErrorQueueLength> scpi_error_queue_data_{};
ScpiInterfaceContainer(const std::array<const char *, 4> idn,
const scpi_interface_t &scpi_interface)
: idn_fields_{idn}, scpi_interface_{scpi_interface} {
init();
}
void init();
void input(const char *str, const int size) {
SCPI_Input(&scpi_context_, str, size);
}
};
The container is not necessary but it does cut down on clutter when using the module.
Here we only need to define the init function and the command table. Since the command table is only needed in the init function we can use static linkage.
void ScpiInterfaceContainer::init() {
SCPI_Init(
&scpi_context_,
reinterpret_cast<const scpi_command_t *const>(scpi_commands_table.data()),
&scpi_interface_, scpi_units_def, idn_fields_[0], idn_fields_[1],
idn_fields_[2], idn_fields_[3],
reinterpret_cast<char *>(scpi_input_buffer_.data()),
scpi_input_buffer_.size(),
reinterpret_cast<scpi_error_t *>(scpi_error_queue_data_.data()),
static_cast<int16_t>(scpi_error_queue_data_.size()));
}
When defining the command table one way to cut down on the duplication is to use a few well chosen macros.
#define SCPI_DEF_U32_SETTER(name) \
static scpi_result_t SCPI_set_##name(scpi_t *context) { \
uint32_t value; \
if (!SCPI_ParamUInt32(context, &value, true)) { \
return SCPI_RES_ERR; \
} \
set_##name(value); \
return SCPI_RES_OK; \
}
#define SCPI_DEF_U32_GETTER(name) \
static scpi_result_t SCPI_get_##name(scpi_t *context) { \
SCPI_ResultUInt32(context, get_##name()); \
return SCPI_RES_OK; \
}
#define SCPI_DEF_U32_GETTER_SETTER(name) \
SCPI_DEF_U32_GETTER(name) \
SCPI_DEF_U32_SETTER(name)
The requires an interface with standardized names (set_[NAME], get_[NAME]). This also helps force sensible organization and naming which is a plus.
Use the declaration macros at the top of scpi_commands.cpp
along with any functions that making macros for doesn’t make sense. Following the declarations we can use another set of macros to make declaring the setters and getters somewhat easier:
#define DEF_GET_COMMAND(arg, name) {arg, SCPI_get_##name, 0}
#define DEF_SET_COMMAND(arg, name) {arg, SCPI_set_##name, 0}
#define DEF_SET_GET_COMMAND(arg, name) DEF_GET_COMMAND(arg "?", name), DEF_SET_COMMAND(arg, name)
The table itself then looks like this:
static const scpi_command_t scpi_commands_table_[] = {
/* IEEE Mandated Commands (SCPI std V1999.0 4.1.1) */
scpi_command_t{"*CLS", SCPI_CoreCls, 0},
{"*ESE", SCPI_CoreEse, 0},
{"*ESE?", SCPI_CoreEseQ, 0},
{"*ESR?", SCPI_CoreEsrQ, 0},
{"*IDN?", SCPI_CoreIdnQ, 0},
{"*OPC", SCPI_CoreOpc, 0},
{"*OPC?", SCPI_CoreOpcQ, 0},
{"*RST", SCPI_CoreRst, 0},
{"*SRE", SCPI_CoreSre, 0},
{"*SRE?", SCPI_CoreSreQ, 0},
{"*STB?", SCPI_CoreStbQ, 0},
{"*TST?", SCPI_CoreTstQ, 0},
{"*WAI", SCPI_CoreWai, 0},
/* Required SCPI commands (SCPI std V1999.0 4.2.1) */
{"SYSTem:ERRor[:NEXT]?", SCPI_SystemErrorNextQ, 0},
{"SYSTem:ERRor:COUNt?", SCPI_SystemErrorCountQ, 0},
{"SYSTem:VERSion?", SCPI_SystemVersionQ, 0},
{"HELP", SCPI_Help, 0},
DEF_SET_GET_COMMAND("LASer:ENable", laser_enable),
DEF_GET_COMMAND("LASer:ON?", laser_on),
DEF_SET_GET_COMMAND("LASer:CURRent[:LEVel][:IMMediate][:AMPlitude]", ilaser_ua),
DEF_GET_COMMAND("LASer:CURRent:MEASure:CURRent?", ilaser_ua),
...
}
Then to actually do something with this control interface we’ll need to define the interface functions themselves. Since these are factored into getters and setters this makes for a clean way test and mock. I like to place the declarations in interface.hpp
which declares the functions defined usually in main.cpp and expose the devices capabilities.
Occasionally the clean interface needs to be broken in places to improve performance but the vast majority of functions can put up with the function call overhead.
To setup an instance of the parser:
static size_t SCPI_Write(scpi_t *context, const char *data, size_t len) {
const auto written = send_string(data, len);
return written == len ? SCPI_RES_OK : SCPI_RES_ERR;
}
static int SCPI_Error(scpi_t *context, int_fast16_t err) { return SCPI_RES_OK; }
static scpi_result_t SCPI_Control(scpi_t *context, scpi_ctrl_name_t ctrl,
scpi_reg_val_t val) {
return SCPI_RES_OK;
}
static scpi_result_t SCPI_Reset(scpi_t *context) { return SCPI_RES_OK; }
static scpi_result_t SCPI_Flush(scpi_t *context) { return SCPI_RES_OK; }
static ScpiInterfaceContainer scpi_interface(
{manufacturer, hardware_version, "", firmware_version},
{scpi_commands_table.data(), scpi_commands_table.size()},
{SCPI_Error, SCPI_Write, SCPI_Control, SCPI_Flush, SCPI_Reset});
void setup_serial_interface() {
scpi_interface.idn_fields_[2] = get_serial_number();
scpi_interface.init();
}
void process_input_string(const char *buff, const std::size_t size) {
SCPI_Input(&scpi_interface.scpi_context_, buff, size);
}
Since the interface is well factored and the entry point is a single function process_input_string
you can add a #define USE_SCPI_INTERFACE
as a compile option to switch in other protocols. I frequently will do this with a more user friendly terminal interface for board bring-up and diagnostics.
printf
/ scanf
and double-precision math usually will drastically increase code size. If you use the Float
or Number
types then this library goes from a few kB to a few dozen kB. If you want all the bells and whistles that SCPI offers then you should keep this in mind. Consider restricting to only integer values.
Also note that newlib and newlib-nano have options for linking the floating point printf
and scanf
. If you’re seeing differences between your desktop tests and MCU behaviour then that’s the first place to look.
Category | Examples |
---|---|
Control Protocols | MODBus, CANOpen, Profibus, BACNet |
Serialization / Messages | Protobufs (nanopb, EmbeddedProto, protozero), Cap’n Proto, Flatbuffers, MessagePack, CBOR |
Packets | COBS |
Command–Control | Terminal, SCPI |