GR4 tutorial: blocks 101

From GNU Radio
Jump to navigation Jump to search

This page explains the basics of developing blocks in GNU Radio 4.0.

Structure of a block

A good example to show how a GNU Radio 4.0 block is structured is the Head block. This block has an input and an output, and only lets the first num_items input items make it through to the output. The code for the block is shown below, without the required #includes. Typically the code for the block will be placed in a .hpp file with include guards and at least <gnuradio-4.0/Block.hpp> and <gnuradio-4.0/reflection.hpp> will need to be included. Each part of the code is explained in detail below.

namespace gr::my_oot {

template <typename T>
class Head : public gr::Block<Head<T>>
{
public:
    using Description = gr::Doc<R""(
@brief Head block. Only lets the first N items go through.

This block passes the first `num_items` items from the input to the
output. After `num_items` items have been produced, the block signals that it is
done.
)"">;

    size_t _published = 0;

    gr::PortIn<T> in;
    gr::PortOut<T> out;
    uint64_t num_items = 0;

    void start() { _published = 0; }

    gr::work::Status processBulk(const gr::ConsumableSpan auto& inSpan,
                                 gr::PublishableSpan auto& outSpan)
    {
        if (_published >= num_items) {
            if (!inSpan.consume(0)) {
                thow gr::exception("consume failed");
            }
            outSpan.publish(0);
            return gr::work::Status::DONE;
        }

        const size_t can_publish =
            std::min({ num_items - _published, outSpan.size(), inSpan.size() });
        std::ranges::copy_n(
            inSpan.begin(), static_cast<ssize_t>(can_publish), outSpan.begin());
        if (!inSpan.consume(can_publish)) {
            thow gr::exception("consume failed");
        }
        outSpan.publish(can_publish);
        _published += can_publish;

        return _published == num_items ? gr::work::Status::DONE : gr::work::Status::OK;
    }
};

} // namespace gr::my_oot

ENABLE_REFLECTION_FOR_TEMPLATE(gr::my_oot::Head, in, out, num_items);

A block in GNU Radio 4.0 is a class that inherits from gr::Block. The gr::Block class uses the curiously recurring template pattern, so it is a template that takes the derived block as a template parameter (here gr::Block<Head<T>>). Many blocks in GNU Radio 4.0 are templates. Often, input and output data types are declared as template parameters, so that the block can be instantiated for any kind of data types. Commonly, the template parameter T is used to declare an item data type. For instance, here we have

template <typename T>
class Head : public gr::Block<Head<T>>

The template parameter T is the data type for the input and output ports of the Head block.

Usually, a block has some documentation that is entered with the using Description = gr::Doc<R""( syntax, as shown above.

After this, we have the declarations of the class members. This includes:

  • Any variable that is needed internally by the block (in this case, _published, which counts the number of items that have been written to the output so far).
  • Input and output ports (in this case, in, and out).
  • Any parameter that can be set when the block is added to the flowgraph (in this case num_items). These parameters can be updated at runtime also, for instance by tags in the input samples.
    size_t _published = 0;

    gr::PortIn<T> in;
    gr::PortOut<T> out;
    uint64_t num_items = 0;

Warning: Note that all these members are public, even _published, which is intended as a private variable for the block. Because of C++ class initialization technicalities, GNU Radio 4.0 blocks cannot have non-static private members. The coding style to distinguish members which are intended to be private is to prepend them with an underscore, as in _published.

The block ports and the parameters which are settable when the block is added to the flowgraph are declared in a reflection macro call. Here this is

ENABLE_REFLECTION_FOR_TEMPLATE(gr::my_oot::Head, in, out, num_items);

Note that the macro call is outside of the namespace where the block is defined and it includes the full namespace path to the block.

Warning: All the block parameters that are settable when the block is added to the flowgraph should have reasonable defaults in their member declaration (here we use num_items = 0), because the user can omit parameter values when adding the block.

Some blocks might need to override the start() method. This method is called before the flowgraph runs. It can be used to set up any state that is needed for the block. This Head block uses the following:

    void start() { _published = 0; }

The start() method ensures that _published is zero before the flowgraph starts running. Even though _published is initialized to zero when the block is instantiated, setting it in start() is a good practice, because in some cases the flowgraph might be stopped and reset to its initial state without instantiating the blocks again.

The processBulk() method is the equivalent to GNU Radio 3.10's work function. Here is where the block reads input items and writes output items. In this block, the processBulk() method is declared as follows:

    gr::work::Status processBulk(const gr::ConsumableSpan auto& inSpan,
                                 gr::PublishableSpan auto& outSpan)

The method always returns gr::work::Status, but its arguments depend on the ports that have been declared in the block. An argument qualified as const gr::ConsumableSpan auto& is used for each input port, and an argument qualified as gr::PublishableSpan auto& is used for each output port. The arguments appear in the same order as the ports are declared (in the reflection macro call). A gr::ConsumableSpan behaves similarly to a C++ std::span, but it also has a method consume() that serves to indicate how many input items the block has actually consumed. A gr::PublishableSpan behaves similarly to a C++ std::span, but it also has a method publish() that serves to indicate how many input items the block has actually published (or produced, in GNU Radio 3.10's terminology).

The return value for processBulk() is usually gr::work::Status::OK, but some blocks can indicate that they are done by returning gr::work::Status::DONE. This means that they will not process any more data (for instance, this Head block is done whenever it has published the required number of items, and a File Source is done whenever it has reached the end of the file). The scheduler uses this information to decide if the flowgraph execution should be finished.

The body of processBulk() for this Head block is quite straightforward. First the block checks the special case in which it is already at or past its maximum number of output items. In this case, it notifies the scheduler that it doesn't consume or produce anything, and that it is done.

        if (_published >= num_items) {
            if (!inSpan.consume(0)) {
                thow gr::exception("consume failed");
            }
            outSpan.publish(0);
            return gr::work::Status::DONE;
        }

In the general case, the block determines how many items can be published, copies them from the input span to the output span, informs the scheduler of how many items were consumed and published, and returns either DONE or OK depending on whether num_items has been reached.

        const size_t can_publish =
            std::min({ num_items - _published, outSpan.size(), inSpan.size() });
        std::ranges::copy_n(
            inSpan.begin(), static_cast<ssize_t>(can_publish), outSpan.begin());
        if (!inSpan.consume(can_publish)) {
            thow gr::exception("consume failed");
        }
        outSpan.publish(can_publish);
        _published += can_publish;

        return _published == num_items ? gr::work::Status::DONE : gr::work::Status::OK;

Input-to-output relationships

An important concept in GNU Radio blocks is the input-to-output relationship. This indicates how many input items are consumed for each output item that is published. The relationship might be 1:1 for a block such as Add Const, 1:N for an interpolator block, N:1 for a decimator block, N:M for a rational resampler block, or something that can vary at runtime and cannot be determined a priori. In GNU Radio 3.10 there are different types of blocks for each of these cases, as explained in Guided_Tutorial_GNU_Radio_in_C++#Specific_block_categories. In GNU Radio 4.0, the same gr::Block class is used in all the cases. There are two mechanisms to represent the input-to-output relationships.

The first mechanism is the gr::Resampling block annotation. This annotation is defined as follows:

template<gr::Size_t inputChunkSize = 1U, gr::Size_t outputChunkSize = 1U, bool isConst = false>
struct Resampling

The gr::Resampling annotation indicates a N:M input-to-output relationship, where N is inputChunkSize and M is outputChunkSize. The annotation must be added to the list of template parameters of gr::Block when declaring the block. For instance this declares a block with a 3:7 input-to-output relationship.

template <typename T>
class MyBlock : public gr::Block<MyBlock<T>, gr::Resampling<3UZ, 7UZ>>

Unless isConst has been set to true, the relationship can be modified at runtime by setting this->input_chunk_size and this->output_chunk_size anywhere in the block's implementation.

The second mechanism is the gr::Async port annotation. This is added to ports, such as for instance gr::PortIn<T, gr::Async>. It is used in the case where there is no fixed N:M relation (the equivalent to a general block in GNU Radio 3.10). Broadly speaking, the gr::Async annotation means that the port is not subject to the usual N:M resampling rules, but the detailed semantics of how this annotation works are currently under change. See gnuradio4#369.

Warning: unlike in GNU Radio 3.10, in GNU Radio 4.0 there is no forecast() function that gives the scheduler an approximated hint of how many input items a block currently needs to produce some given number of outputs.

processBulk() versus processOne()

Besides processBulk(), there is another way to specify how the block processes input items to produce output items. This is the processOne() function, which defines the operation of the block one sample at a time. This simple Add block that adds two inputs illustrates how it works.

namespace gr::my_oot {

template <typename T>
class Add : public gr::Block<Add<T>>
{
public:
    using Description = gr::Doc<R""(
@brief Add. Adds two inputs.
)"">;

    gr::PortIn<T> in0;
    gr::PortIn<T> in1;
    gr::PortOut<T> out;

    [[nodiscard]] constexpr auto processOne(T a, T b) const noexcept { return a + b; }
};

} // namespace gr::my_oot

ENABLE_REFLECTION_FOR_TEMPLATE(gr::my_oot::Add, in0, in1, out);

A block can only be implemented using processOne() instead of processBulk() when it has a single output port, a 1:1 input-to-output relationship, and each output item can be computed just by looking at a single input item (on each input port). It is recommended to use processOne() instead of processBulk() when possible, because it is more succinct and might enable more compile time optimizations.

Vectors of ports

As mentioned in GR4 tutorial: flowgraphs 101, blocks can declare a std::vector of ports in order to support an arbitrary number of ports of the same type (the actual number of ports gets defined at runtime). The way to do this is as follows. For the sake of the example, let us assume that we want to implement a block that has an arbitrary number of inputs and one output. This might be, for example, an Add block that adds all the inputs together.

The first step is to declare the ports. There should be a way to decide at runtime how many ports are to be created. This can be as simple as a num_inputs parameter that the user sets when adding the block to the flowgraph.

    std::vector<gr::PortIn<T>> in;
    gr::PortOut<T> out;
    size_t num_inputs = 0;

The settingsChanged method of the block needs to be overridden to resize in according to the required number of ports. This method is called any time that the block parameters are updated (when the block is added to the flowgraph, and also when a tag that modifies a parameter is received). The method gets gr::property_map arguments that contain the previous settings and the current settings. Normally these do not need to be used, since the new settings are already applied and accessible in the class members. In this case we don't support zero inputs, but in other cases it is possible to code any other constraints in the settingsChanged() method.

    void settingsChanged(const gr::property_map& /* old_settings */,
                         const gr::property_map& /* new_settings */)
    {
        if (num_inputs == 0) {
            throw gr::exception("num_inputs cannot be zero");
        }
        in.resize(num_inputs);
    }

The syntax of the processBulk() function is now slightly different. It receives a std::span of gr::ConsumableSpans instead of a single gr::ConsumableSpan. The span of consumable spans can be treated in the natural way. In particular, the spans for each of the inputs are in inSpans[0], inSpans[1], etc., and inSpans.size() indicates the number of inputs.

    template <gr::ConsumableSpan TInput>
    gr::work::Status processBulk(const std::span<TInput>& inSpans,
                                 gr::PublishableSpan auto& outSpan)

Reflection macros

The example block above used the following reflection macro.

ENABLE_REFLECTION_FOR_TEMPLATE(gr::my_oot::Head, in, out, num_items);

This ENABLE_REFLECTION_FOR_TEMPLATE macro is used with most blocks which are templates (specially those which have simple template parameters). Some particular blocks which are templates need the more complicated ENABLE_REFLECTION_FOR_TEMPLATE_FULL. An example of this is the following block.

template <bool invert = false, typename TIn = float, typename TOut = uint8_t>
class BinarySlicer : public gr::Block<BinarySlicer<invert, TIn, TOut>>
{
// ...
};

ENABLE_REFLECTION_FOR_TEMPLATE_FULL((bool invert, typename TIn, typename TOut),
                                    (gr::my_oot::BinarySlicer<invert, TIn, TOut>),
                                    in,
                                    out);

Finally, blocks which are not templates use the ENABLE_REFLECTION macro. This is used similarly to ENABLE_REFLECTION_FOR_TEMPLATE. For instance, if we make a HeadInt block that only works with the int datatype, the reflection would be done like this.

class HeadInt : public gr::Block<HeadInt>
{
// ...
};

ENABLE_REFLECTION(gr::my_oot::HeadInt, in, out, num_items);