-
Notifications
You must be signed in to change notification settings - Fork 3
Drivers Architecture
This page is designed to walk through how to interact with the codebase's drivers architecture.
In the same way that Java has a global System.out
stream to let you print without first
constructing an output object, our embedded systems need ways to interact with many types of outside
devices. In general, we refer to these as "Input/Output" (I/O) interfaces. For example, turning an
LED on or off would be a type of I/O, as is controlling a motor or reading a sensor. These
interfaces are called global since all other parts of the codebase interact with the single instance
of this interface. In some embedded devices, it is common for an interface to store a global
instance of itself that all other parts of the code uses. This becomes cluttered and overwhelming
when many global instances are introduced and stored in separate locations. The issues with this
system become especially clear when attempting to integrate all the global instances into a unit
test environment.
The main drivers class present in this codebase is a container that stores global instances of common hardware and architecture interfaces. Members of this class are intended to be singletons, meaning only one instance of each exists in the system; furthermore, the expectation is that they are instantiated (statically!) once when the system starts and remain until we shut down. All interfaces in this class are used to interact directly with single hardware or architecture components.
Now that an architecture has been laid out, let us consider how this is implemented. First off,
consider the Drivers
class. As noted above, this is the central location for singletons. This is
partially defined as follows:
class Drivers
{
friend class DriversSingleton;
// Only in the sim environment can anyone construct a Drivers class
#ifdef ENV_SIMULATOR
public:
#endif
Drivers()
: can(),
...
djiMotorTxHandler(this)
{
}
#if defined(PLATFORM_HOSTED) && defined(ENV_UNIT_TESTS)
// Mock instances of all singletons, for sim environment
CanMock can;
...
DjiMotorTxHandlerMock djiMotorTxHandler;
#else
public:
// Instance of all singletons
can::Can can;
...
motor::DjiMotorTxHandler djiMotorTxHandler;
#endif
}; // class Drivers
As you can see, this class stores unique instances of all system-wide drivers. In the Drivers
constructor, certain drivers are passed this
on construction. Why this is done is explained below.
You can also see that when in simulation mode, mock classes replace the actual driver instances.
More detail on mock drivers is below as well. While these drivers are meant to be singletons, they
are not declared static
. This means we can avoid the difficulties of static global variables,
which are very cumbersome to work with while writing unit tests. Instead
we store a single instance of the Drivers
class in DriversSingleton.cpp
which is used while
running on hardware. The DriversSingleton
class is the only place in the non-simulation
environment that can declare an instance of the Drivers
class. This file defined as follows:
class DriversSingleton
{
public:
static Drivers drivers;
}; // class DriversSingleton
...
Drivers *DoNotUse_getDrivers() { return &DriversSingleton::drivers; }
The Drivers
class is stored statically for optimization and stability purposes as opposed to
allocating the single Drivers
class dynamically. The function DoNotUse_getDrivers()
is meant to
be called only in main.cpp
and the control define files, *_control.cpp
and not in
any drivers, subsystems, or commands.
Driver classes which depend on other drivers are passed a this pointer so they can reference their
"sibling" objects. The Can
class, for example, does not depend on any other drivers. Instead it
interacts with modm HALs.
The DjiMotorTxHandler
, on the other hand, does rely on other drivers. Instead of using the
Drivers
class globally, for unit testing it is necessary to pass in a particular instance of the
Drivers
class to a particular driver. Consider the DjiMotorTxHandler
, partially completed below:
class DjiMotorTxHandler
{
public:
DjiMotorTxHandler(Drivers *drivers) : drivers(drivers) {}
...
void processCanSendData()
{
...
if (drivers->can.isReadyToSend(can::CanBus::CAN_BUS1))
{
drivers->can.sendMessage(can::CanBus::CAN_BUS1, can1MessageLow);
drivers->can.sendMessage(can::CanBus::CAN_BUS1, can1MessageHigh);
}
...
}
...
}; // class DjiMotorTxHandler
The key here is that in the constructor of the DjiMotorTxHandler
, we can pass in a unique
Drivers
object. This allows us the versatility to construct a unique fake one in our test
environments, but use the real ones when running on-robot. It also eliminates the global
dependencies that are mentioned above. The same concept of passing around pointers is applied ot the
device objects, subsystems, and commands.
Removing global references allows for unit tests to be easily written for an isolated driver.
Suppose you would like to write unit tests for the DjiMotorTxHandler
class. Out of necessity,
since the DjiMotorTxHandler
in our environment depends on the Can
class, we must also create a
mock Can
object for it to use. While running unit tests, the Drivers
object's real drivers have
been mocked using the googlemock mocking framework. Mocks have been declared in
test/mock
, each of which have
configurable behavior at runtime that can be defined in a unit test. This allows you to easily mock
any function in the can
class.
The CanMock
class is partially declared below:
class CanMock : public tap::can::Can
{
public:
...
MOCK_METHOD(bool, isReadyToSend, (tap::can::CanBus bus), (const override));
MOCK_METHOD(bool, sendMessage, (tap::can::CanBus bus, const modm::can::Message &message), (override));
}
All mock classes extend the class being mocked and then declare MOCK_METHOD
s for all functions
they intend to mock. A CanMock
object replaces the Can
object in the Drivers
when compiling
the sim environment. The additional step that has to be made in order for the mock to operate as
expected is that functions that are mocked must be virtual
. To avoid decreasing on-hardware
performance that comes with declaring virtual
functions, the mockable
macro was created. This
evaluates to virtual
if running in a simulated environment and is otherwise a no-op.
Using the mock classes in the Drivers
class, a simple googletest TEST
case would look like this:
TEST(DjiMotorTxHandler, processCanSendData_nothing_sent_when_CAN_busy)
{
// Set up the test
Drivers drivers;
DjiMotorTxHandler handler(&drivers);
EXPECT_CALL(drivers.can, sendMessage(_, _)).Times(0);
ON_CALL(drivers.can, isReadyToSend(_)).WillByDefault(Return(false));
// Run the test
handler.processCanSendData();
}
Since this page is not meant to go into detail about how to write unit tests, refer to the
googletest primer and
the googlemock
cookbook
for unit test semantics. The important takeaway is that the Drivers
class can be easily integrated
into a unit test framework with minimal overhead and on-hardware performance cost.
Looking for something else or would like to contribute to the wiki?
This wiki is a readonly mirror of our GitLab wiki. We use mermaid diagrams in this wiki, which are not supported in GitHub. We recommend referring to the GitLab wiki for the best experience or if you would like to contribute.
Architecture Design
- Directory Structure
- Build Targets Overview
- Drivers Architecture
- Command Subsystem Framework
- Generated Documentation
Using Taproot
Software Tools
- Docker Overview
- Debugging Safety Information
- Debugging With ST-Link
- Debugging With J-Link
- Git Tutorial
- How to Chip Erase the MCB
RoboMaster Tools
Software Profiling
System Setup Guides
- Windows Setup
- Debian Linux Setup
- Fedora Linux Setup
- macOS Setup
- Docker Container Setup
- (deprecated) Windows WSL Setup
Control System Design Notes
Miscellaneous and Brainstorming
Submit edits to this wiki via the taproot-wiki-review repo.