GR4 tutorial: flowgraphs 101

From GNU Radio
Revision as of 11:04, 23 August 2024 by Destevez (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

This tutorial explains the basics of C++ flowgraphs in GNU Radio 4.0.

Minimal flowgraph

The following is an example of a minimal flowgraph. It connects a Null Source to a Null Sink and runs the flowgraph forever. The code for the flowgraph can be found in minimal_flowgraph.cpp.

#include <fmt/core.h>
#include <gnuradio-4.0/Graph.hpp>
#include <gnuradio-4.0/Scheduler.hpp>
#include <gnuradio-4.0/packet-modem/null_sink.hpp>
#include <gnuradio-4.0/packet-modem/null_source.hpp>
#include <boost/ut.hpp>

int main()
{
    using namespace boost::ut;

    gr::Graph fg;
    auto& source = fg.emplaceBlock<gr::packet_modem::NullSource<int>>();
    auto& sink = fg.emplaceBlock<gr::packet_modem::NullSink<int>>();
    expect(eq(gr::ConnectionResult::SUCCESS, fg.connect<"out">(source).to<"in">(sink)));

    gr::scheduler::Simple sched{ std::move(fg) };
    expect(sched.runAndWait().has_value());

    return 0;
}

Here the boost-ut expect() function is used for error handling, since it is also used for unit testing in GNU Radio 4.0, but any other form of error handling can be used.

Creating a flowgraph in GNU Radio 4.0 begins with the instantiation of a gr::Graph object.

The emplaceBlock() method of the gr::Graph is used to add blocks to the flowgraph. Note that this method takes a template parameter which specifies the block class. The block classes can be templates, and they often have template parameters to indicate the input and output types. Here we are creating a NullSource<int>, which has int as output type, and similarly for the null sink. As in GNU Radio 3.10, blocks can get parameters when they are created. These are passed as a gr::property_map argument to emplaceBlock(). In this case, the Null Source and Null Sink don't take any parameters, so emplaceBlock() is called without arguments. We will see an example of blocks with parameters below.

A connection between blocks is done by calling the connect() method of the gr::Graph. Note how the syntax works, using template parameters to specify the name of the ports. In GNU Radio 3.10, block ports have numbers 0, 1, 2, etc. These numbers are specified when calling connect, and 0 is implicit if the number is omitted. In GNU Radio 4.0, all the ports have names which are a string. In simple blocks which only have an input or an output, "in" and "out" are often used as port names. The connect() method checks that the connection is valid (the types of the ports match, etc.) and returns a gr::ConnectionResult value indicating if the connection could be made.

Once all the blocks have been added to the flowgraph and connected, the flowgraph can be run. To do this, it is first necessary to put the flowgraph in a scheduler. GNU Radio 3.10 has a single scheduler implemented in the runtime. In GNU Radio 4.0, schedulers are modular and users can implement their own, so any scheduler could be used here. In this example we use the gr::Scheduler::Simple scheduler, which is included in the GNU Radio 4.0 core. By default it is a single-threaded scheduler, meaning that it runs all of the blocks in the same thread (except for dedicated IO threads). It can also be instantiated as a multi-threaded scheduler that uses a one-thread-per-block approach similar to GNU Radio 3.10.

After the scheduler is created, its runAndWait() method is called. This is equivalent to the GNU Radio 3.10 run() method. It runs the flowgraph until the flowgraph finishes on its own (because the end of an input file has been reached, the limit on a Head block has been hit, etc.) The runAndWait() method also returns if there is an error, such as a block raising an unhandled exception. The method returns a std::expected, so the has_value() method can be used to check if there was an error. This particular example flowgraph actually runs forever, and runAndWait() never returns, because the Null Source always keeps generating data.

Block parameters

To show how block parameters can be passed when calling emplaceBlock(), we will add a Head block between the Null Source and the Null Sink in the flowgraph above. The relevant lines of code are the following. The complete example is in head.cpp.

    auto& source = fg.emplaceBlock<gr::packet_modem::NullSource<int>>();
    auto& head =
        fg.emplaceBlock<gr::packet_modem::Head<int>>({ { "num_items", 1000UL } });
    auto& sink = fg.emplaceBlock<gr::packet_modem::NullSink<int>>();
    expect(eq(gr::ConnectionResult::SUCCESS, fg.connect<"out">(source).to<"in">(head)));
    expect(eq(gr::ConnectionResult::SUCCESS, fg.connect<"out">(head).to<"in">(sink)));

The argument of the emplaceBlock() method is a gr::property_map, which is defined as std::map<std::string, pmtv::pmt>. The pmtv library is a replacement for the Polymorphic Types (PMTs) in GNU Radio 3.10 using modern C++ features. All the simple C++ types that you would expect can be converted to and from a pmtv::pmt. When calling emplaceBlock(), it is often convenient to create the gr::property_map by using C++ initializer lists, as is done in this example. Here the initializer list is { { "num_items", 1000UL } }. Several parameters of different types can also be passed quite naturally, such as for example:

{ { "foo", "var" }, { "amplitude", 37.1f }, { "samples", std::vector<double>{ 1.3, 2.5, -4.7 } } }

Warning: This is the only way of passing parameters to a block on construction. Unlike in GNU Radio 3.10, it is not possible to have a C++ constructor that takes a list of arguments of arbitrary C++ types.

A natural question is how to find the parameters that a block has. We can check the C++ source for the block (here head.hpp). The parameters that can be set with a gr::property_map are declared in the reflection macro, which in this case is:

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

Note that the reflection macro also declares all the ports of the block. The parameters must be public class members. We can look at the class declaration to find their types. In this case we have the following:

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

Warning: types are important when constructing the gr::property_map used to pass parameters to the block. In this case, using { { "num_items", 1000 } } would cause a runtime error, because 1000 is an int, but the block expects num_items to be a uint64_t. Beware of numeric literals in C++. Often the type needs to be specified explicitly for these.

Another thing to note from this example is that connections are always done between two blocks. Unlike in GNU Radio 3.10, there is no shorthand notation to string connections through a list of multiple blocks.

When this flowgraph is run, we can see that it terminates almost immediately, unlike the previous example.

Stopping a flowgraph

Unlike in GNU Radio 3.10, there is no start() method to start running a flowgraph and then do something else while the flowgraph runs. There is only startAndWait(), which only returns when the flowgraph is done. If we want to force the flowgraph to terminate early, the way to do it is to run a thread that sends a REQUEST_STOP message to the scheduler.

The following example shows how this can be done. The complete code for the example is in stop_flowgraph.cpp. In this case the thread sends the REQUEST_STOP message after 100 milliseconds, but any other kind of condition could be used to determine when to stop the flowgraph.

    gr::scheduler::Simple sched{ std::move(fg) };

    gr::MsgPortOut toScheduler;
    expect(eq(gr::ConnectionResult::SUCCESS, toScheduler.connect(sched.msgIn)));

    // Spin a thread that sleeps 100 ms and sends a REQUEST_STOP message to the
    // scheduler. Without this message, the scheduler would run forever with
    // this flowgraph.
    std::thread stopper([&toScheduler]() {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        gr::sendMessage<gr::message::Command::Set>(toScheduler,
                                                   "",
                                                   gr::block::property::kLifeCycleState,
                                                   { { "state", "REQUESTED_STOP" } });
    });

    expect(sched.runAndWait().has_value());

    stopper.join();

After the flowgraph has stopped, the state of the blocks can be examined. The complete example includes a Vector Sink instead of a Null Sink, and it counts the number of items collected by the Vector Sink after the stopper.join() call returns.

Connections to blocks with vectors of ports

In GNU Radio 4.0, blocks can have a port declared as a vector of ports. This is used to specify an arbitrary number of ports of the same type whose number is determined at runtime. For example a Packet Mux block that multiplexes packets from several inputs into one output might have its ports declared like this (see packet_mux.hpp).

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

The num_inputs parameter is passed when the block is added to the flowgraph to specify the desired number of inputs. Other types of blocks that might do the same are arithmetic blocks, such as Add and Multiply. The names of the ports are generated as the base port name ("in" in this case), followed by '#' and a number, so in this case the input ports would be "in#0", "in#1", etc.

The syntax introduced above to connect ports cannot be used with these vectors of ports, because the number of ports that actually exist in the block is only known at runtime, and this method of doing port connections checks that the port names exist at compile time. There is an alternate syntax to do connections that performs the check at runtime. This is the one that needs to be used in this case. It is done like so:

using namespace std::string_literals;
fg.connect(block_0, "out"s, mux_block, "in#0"s);
fg.connect(block_1, "out"s, mux_block, "in#1"s);
// etc

(Rember that connect() returns a gr::ConnectionResult that should be checked for errors). The port names are specified as std::string arguments, so using string literals is handy.

Tip: This alternate connection syntax can also be used to do port connections whose names are evaluated at runtime, regardless of whether they belong to a vector of ports.

Message port connections

In GNU Radio 4.0, message ports are connected in the same way as regular ports. There is no specific msg_connect() to connect message ports. Any of the two methods shown above to do connections works with message ports too.