Written by VojtΔch Michal (Discord vojtechmichal), 2025.
CANEPP enables fast and simple parameterization of eForce ECUs without the need to recompile and flash the ECU firmware.
Supports getting and setting values (int, unsigned, float, bool, enums) of objects exposed by firmware through automatic enumeration mechanism. Supports execution of int(*)() functions. Objects can be stored in nonvolatile memory.
Further resources:
- Gitlab β¦ https://eforce1.feld.cvut.cz/gitlab/fse/canepp
- CANdb β¦ https://eforce1.feld.cvut.cz/candb-dev/units/216 (located in Accessory)
- CANEPP wraps objects around various parameters, measurements or procedures.
- Parameters provide control over input mapping, decision thresholds (state machines, safety checks), controller parameters etc.
- Measurements provide simple access to raw sensor readings e.g. for calibration and initial ECU testing.
- Procedures enable performing specific actions, e.g. bypassing missing trigger conditions during debugging or starting a calibration sequence.
- The CAN protocol provides point to point communication between a single slave (the target ECU) and a master (currently only a PC with
cantools).- Multiple slaves may be present on the same CAN bus without interference as each is identified by a unique enumerator from https://eforce1.feld.cvut.cz/candb-dev/enum-types/572
- Multiple masters can theoretically be on the same CAN bus as long as no message clashes occur but this is not an intended use case.
- The CAN communication relies solely on three messages:
- Request - from master to slave, initiates all communication transactions. Identifies a specific operation to perform and a target object (not needed for "global" operations such as writing object values to ECU flash).
- Response - from slave to master, carries the result of performed operation (object value, error code etc).
- Manifest - from slave to master, contains packetized object manifest, i.e. all information about the given object (type, name, descriptions, access methods...)
- All CANEPP objects are defined purely in ECU firmware, there are no databases like CANdb to manage by the master.
- Before performing any meaningful operation with the slave, the master must first enumerate the slave's objects.
- During the enumeration process, the number of CANEPP objects exposed by the slave is requested first, followed by individual object manifests.
- To save communication bandwidth, all CAN messages refer to objects by object ID consisting of integer index and subindex (i.e. array offset).
- Mapping of object IDs in the slave is determined by the order of static constructors (determined by implementation details of the toolchain) and hence can never be relied on. The master obtains its copy of object ID mapping during object enumeration.
- Compiling and flashing new binary may (and most likely will) invalidate current object ID mapping and hence will require re-enumeration.
- Each CANEPP object represents either a quantity or a procedure:
- Procedures are executable only and do not accept any parameters at the moment (this may change in the future).
- Quantities can be scalars or single dimensional arrays:
- Arrays define non-negative size, individual elements are accessible using object subindex.
- Quantities are of bool, enum or numeric type:
- All CANEPP types exposed to the user preserve strong typing and work with
int,unsigned,float,boolor user-definedenumorenum class. - CAN messages
RequestandResponsehave room for a single 32 bit unsigned int value. The library code usesmemcpyto convert between individual data types and their underlying bits.
- All CANEPP types exposed to the user preserve strong typing and work with
- Each object has:
- a user-friendly name that acts as the primary identifier. Dots in the name naturally create nested parameter structures that are fully transparent, i.e. they are only used for sorting and visualization but have no semantic significance.
- a short and (optionally) long description. Short description is always shown in the GUI, the long desc. is shown as tooltip.
- a set of function pointers that define the object's behavior (see later).
- (numeric object only) a physical unit such as V or mA
- (numeric object only) optional upper and lower bounds on the object's value. Any attempt to write a value outside of provided bounds is ignored.
- (enum object only) an enumerator documentation, i.e. a list of valid values of said enumeration with name, numeric value and short description.
- CANEPP quantity objects (i.e. other than procedures) wrap various function pointers:
- get - the only one required.
- set - omitted for read-only objects such as measurements. Providing a setter makes the object writeable and provides extra functionality.
- get/set size - for arrays only
- Values of writeable (i.e. with defined setter) objects can be written to ECU flash to preserve e.g. calibration for future use. These values are loaded early in main (but not before static constructors) and they overwrite default initializer provided by the source code.
- Values of all writeable objects can be saved to JSON file as a snapshot of ECU configuration. This snapshot can be later loaded from JSON file and written to the ECU for quick and simple complete reparametrization.
- See the documentation of
CANEPP GUI(when I create it) for more details.
- See the documentation of
Whenever unsure, check my (VoMi's) reference implementations in AMS, Disruptor or PDL (the following guide is based on PDL implementation). Do not worry to ask on Discord π.
Make sure you meet these requirements:
- You are able to modify your linker script.
- UFSEL is already a submodule of yours and you use UFSEL/Assertion, UFSEL/BitOperations, UFSEL/Math, UFSEL/Memory and UFSEL/Traits.
- Add this repository as submodule to the root of your firmware. Unless you have very specific needs, place the submodule directory into the repository root. To ensure the submodule is found by CI, either manually edit
.gitmodulesto use relative path or specifyGIT_SUBMODULE_FORCE_HTTPS: "true"in your.gitlab-ci.yml(see Gitlab Docs)
$ cd ecu-sw # The root of firmware repository where CMakeLists.txt is located
$ git submodule add ssh://[email protected]:2020/fse/canepp.git
# If needed, manually edit .gitmodules and change path to relative like this:
# [submodule "canepp"]
# path = canepp
# url = ../../fse/canepp.gitCHECKPOINT: You may commit and push the repository. If your CI pipeline does not fail when cloning the submodule, you have done it correctly.
- Modify CANdb (always prefer
candb-dev).
In case your unit does not normally transmit CAN messages, lacks CANdb record or there is anything else that would hinder you from proceeding, consult it with someone experienced.- Make sure the list of Buses on the overview page of
Accessory::Canepplink contains all buses listed in the overview page of formula generation (package) your unit belongs to. For example when adding CANEPP to a unit affiliated with CTU25, make sure that the bootloader contains reference to bothCTU25 - CAN1andCTU25 - CAN2. - Add message
Requestowned byAccessory::Caneppto messages received by your unit and addResponseandManifestto the list of sent messages. - CHECKPOINT: Generate C code for your unit and check that header/source files contain the definition of
Canepp_Response,Canepp_ManifestandCanepp_Request. Generate JSON v2 for your package and check that it contains the unitAccessory::Canepp. - Manually update the CANdb generated code to send messages
Canepp_ResponseandCanepp_Manifestto the bus where we last receivedCanepp_Request. You should change 4 locations as follows:
- Make sure the list of Buses on the overview page of
int Canepp_send_Response_s(const Canepp_Response_t* data) {
// ...
int rc = txSendCANMessage(Canepp_Request_get_rx_bus(), Canepp_Response_id, buffer, sizeof(buffer));
}- Modify your
CMakeLists.txt: addcanepp/src/canepp.cppto the list of compiled sources for the project andcanepp/includeto include paths.
# CMakeLists.txt
set(SRC
canepp/src/canepp.cpp
# ... Other sources
# bootloader/API/BLdriver.cpp
# CANdb/can_PDL.c
# PDL/main.cpp
# BSP/gpio.cpp
# ... Other sources
)
# ... Other include directories
# include_directories(SYSTEM .)
# include_directories(SYSTEM CANdb)
include_directories(SYSTEM canepp/include)- Modify (or create if it does not exist) your configuration file (recommended name
config.hpp) to provide constants listed below to CANEPP for use. This file should also include the CANdb-generatedcan_XXX.hthat contains definitions of messages such asCanepp_Request.
// config.hpp
//Configuration of various submodules and libraries.
#pragma once
#include <ufsel/assert.hpp> // for assert
#include <ufsel/units.hpp> // for KiB literal
#include <can_PDL.h> // Brings Canepp messages
#include <PDL/options.hpp> // Brings debug_printf_0 to scope
namespace config {
// Configuration of other libraries (e.g. xalloc)
namespace canepp {
#define CANEPP_USE_COMMON_OBJECTS 1
constexpr auto unit_identifier = Canepp_ECU_PDL;
constexpr auto nonvolatile_storage_size = 8_KiB;
// Write granularity of flash memory. 8 B for STM32G4, 16 B for STM32H7
constexpr auto nonvolatile_storage_granularity = 8_B;
// Controls the verbosity of prints.
// Prefer this reduced verbosity (i.e. only print on set and execute) unless you are debugging
constexpr bool print_manifest_on_creation = false;
constexpr bool print_manifest_on_creation_full = false;
constexpr bool print_value_on_get = false;
constexpr bool print_value_on_set = true;
constexpr bool print_size_on_get = false;
constexpr bool print_size_on_set = true;
constexpr bool print_on_execute = true;
#define canepp_printf debug_printf_0
} // end namespace canepp
} // end namespace config- Modify the CMake toolchain file(s) (i.e. files with extension
.cmake) to provide the path to your configuration file to CANEPP by defining macroCANEPP_CONFIGURATION_FILE. See the example below (this firmware uses UFSEL as well):
# toolchain.cmake
# ...
SET(CONFIG_FILES "-DUFSEL_CONFIGURATION_FILE=\"<ufsel-configuration.hpp>\" -DCANEPP_CONFIGURATION_FILE=\"<config.hpp>\"")
SET(COMMON_FLAGS "${DEVICE_FLAGS} ${OPTIMIZATIONS_FLAGS} ${DEFINES} ${VALIDATION_FLAGS} ${CONFIG_FILES}")
# ... - Provide implementation (see the reference implementation below) of non-volatile memory storage manipulation functions forward declared in the header
canepp.hpp. These functions provide access to storage address range, its erassure, (un)locking and writing new data:
// canepp/include/canepp.hpp
namespace canepp {
////////////////////////////////////////
// Functions implemented by the user
////////////////////////////////////////
// Functions accessing the nonvolatile storage defined in main.cpp
[[nodiscard]]
std::uintptr_t get_nonvolatile_storage_begin();
[[nodiscard]]
std::size_t get_nonvolatile_storage_size();
// Returns true when success, false on failure
[[nodiscard]]
bool erase_nonvolatile_address_range();
// Only called with lengths an integer multiple of flash granularity (feel free to assert on it)
[[nodiscard]]
bool write_to_nonvolatile_storage(std::uintptr_t address, std::span<std::uint8_t const> data);
void lock_nonvolatile_storage();
void unlock_nonvolatile_storage();
// Returns false when reboot is not possible (e.e.g due to some uninterruptible task)
[[nodiscard]]
bool reboot();
///////////////////////////////////////////////
// End of functions implemented by the user
///////////////////////////////////////////////
}This typically requires slight cooperation between C++ code and the linker script. C++ implements individual functions and defines a large buffer manually placed to a particular section during compilation. The linker script then defines this particular section and reserves space for it. Reference implementation is as follows:
// e.g. in main.cpp
// Implementation of flash storage for CANEPP
namespace canepp {
__attribute__((section(".caneppSection")))
char flash_storage[config::canepp::nonvolatile_storage_size.toBytes()];
std::uintptr_t get_nonvolatile_storage_begin() {
return reinterpret_cast<std::uintptr_t>(&flash_storage);
}
std::size_t get_nonvolatile_storage_size() {
return sizeof(flash_storage);
}
bool erase_nonvolatile_address_range() {
std::uintptr_t const begin = get_nonvolatile_storage_begin();
std::size_t const size = get_nonvolatile_storage_size();
bsp::flash::RAII_unlocker const _;
bsp::flash::EraseRange(begin, begin + size);
return true; // TODO can this go wrong?
}
bool write_to_nonvolatile_storage(std::uintptr_t address, std::span<std::uint8_t const> data) {
assert(data.size() % sizeof(bsp::flash::native_t) == 0);
std::span<bsp::flash::native_t const> native_data{
reinterpret_cast<bsp::flash::native_t const *>(data.data()),
data.size() / sizeof(bsp::flash::native_t)
};
return bsp::flash::Write(address, native_data) == bsp::flash::WriteStatus::Ok;
}
void lock_nonvolatile_storage() {
bsp::flash::lock();
}
void unlock_nonvolatile_storage() {
if (ufsel::bit::all_set(FLASH->CR, FLASH_CR_LOCK))
bsp::flash::unlock();
}
bool reboot() {
NVIC_SystemReset();
return true;
}
} // end namespace canepp// linker_script.ld
/* ... */
/* Specify the memory areas */
MEMORY
{
FLASH_Firmware (rx) : ORIGIN = ORIGIN(ApplicationFlash), LENGTH = LENGTH(ApplicationFlash) - 8K
FLASH_CaneppSector(rw) : ORIGIN = ORIGIN(FLASH_Firmware) + LENGTH(FLASH_Firmware), LENGTH = 8K
}
/* Define output sections */
SECTIONS
{
/* ... stack, .text, .data etc*/
.caneppSection (NOLOAD):
{
caneppFlashStart = .;
*(.caneppSection)
caneppFlashEnd = .;
} > FLASH_CaneppSector
/* ... */
}To verify your implementation is correct, disassemble the binary and check that the C++-defined buffer was placed to the end of flash memory, as requested by the linker script.
- Setup RX callback for
Canepp_Requestand load values of parameters from non-volatile memory.
// typically in main.cpp
#include <canepp/canepp.hpp>
void setup_can_callbacks() {
Bootloader_Ping_on_receive(BootloaderDriver::pingReceivedCallback);
Canepp_Request_on_receive([](Canepp_Request_t * msg) -> int {
return canepp::manager.handle_request(msg);
});
// ... other callbacks
}
void main() {
// ... initialization code
canepp::manager.load_from_nonvolatile_storage();
// ... more initialization code
}- CHECKPOINT You are good to go! π Now
#include <canepp/canepp.hpp>in the source files of your project and use it.
// main.cpp
#include <canepp/canepp.hpp>
// Declare global (i.e. at file level, not local to some function) canepp object. This creates a proxy, exposing the wrapped parameter externally as a parameterization point
canepp::Numeric _reg_current_Idmax_A /* the name of variable does not really matter, can be autogenerated */ {
"mcA.Id_max", // human friendly name
"Maximal D axis current for channel A", // short description
"Maximal magnitude of the field-weakening current.", // longer (more detailed) description. May be nullptr
"A", // physical unit
+[] { return drt::mcA.fw().pid_fw_.clamp_min(); },
+[](float f) { drt::mcA.fw().pid_fw_.clamp_min() = f; },
-60, 0 // lower and upper bounds
};The header canepp/canepp.hpp may define many convenience macros for commonly used objects if you #define CANEPP_USE_COMMON_OBJECTS 1 in your configuration file.
Common objects are:
- family of
CANEPP_ADC_CALIBthat wrapufsel::math::converted_filtered_measurement(itself in turn wrapping ADC measurements) and exposes their correction gain (float) and correction offset (strongly typed, e.g.Voltage,Currentetc). CANEPP_ADC_RAWprovides read-only access to raw ADC channel reading through thebsp::adc::ReadoutRawfunction
Common operations (function pointers) are:
- family of
CANEPP_GET_SETthat provide simple get/set implementation suitable for booleans, plain numbers but also individual strongly typesufsel::units.
Other convenience macros:
CANEPP_AUTO_NAMEsimplifies the creation of variables holding CANEPP objects by providing (mostly) unique names of the formstatic _canepp_declaration_xxx_yyywherexxxrepresent the line number obtained by__LINE__andyyyis the continuously incremented__COUNTER__. Since the preprocessor runs for each compilation unit individually, this mechanism can't provide complete guarantee of uniqueness of generated identifiers, hence it also marks the object as static. These objects shall never appear in a header file.
I suggest using common CANEPP objects since they significantly shorten repetitive typing, hence you will see a lot of macros. None of these macros are complicated, so don't be afraid.
This section lists many examples how to define CANEPP objects. Most examples assume that you use common objects. Please note that in practice, many objects are needed since there are multiple channels (DRT), sensors (PDL, MBOX) etc. It is most natural to start defining simple macros or to manually duplicate object definitions. Once there are more than two similar objects, macros are highly recommended.
// From AMS, (de)activate printing status of Vicor DCDC
namespace options::vicor {
bool print_status = false;
}
canepp::Bool reg_vicor_dump_status {
"vicor.print_status",
"Print status bits of Vicor",
nullptr,
CANEPP_GET_SET(options::vicor::print_status)
};// From PDL, BPPS enable. Example of accessing members of global struct
struct BPPC {
bool enabled_;
} inline bppc;
canepp::Bool _reg_bppc_enabled {
"BPPC.enable",
"Enables Brake Pedal Plausibility Check",
"When BPPC is active, brake pedal overrides the accelerator - when the driver starts braking, APPS reading is forced to zero in PDL::Accelerator.",
+[] { return bppc.enabled_; },
+[](bool new_val) { bppc.enabled_ = new_val; },
};// from PDL, takes the current STW reading and calibrates it as the new STW center
canepp::Procedure _reg_calibrate_stw_center {
"stw.calibrate_center",
"Accept current sensor reading as new STW center. You must manually store results to flash!",
nullptr,
+[] {
int const prev_value = stw.calibrated_center_lsb_;
stw.calibrated_center_lsb_ = stw.unfiltered_reading();
debug_printf_0("Calibrated new STW center %d lsb (was %d lsb). Store to flash to save.\n", stw.calibrated_center_lsb_, prev_value);
return 0;
}
};// from AMS, resets the BMS state machine to reinitialize communication
canepp::Procedure _reg_retry_comm {
"bms.debug.reset_communication",
"Reset all BMS state machine",
nullptr,
+[] {
ams::bmsFaultCount = 0;
ams::numErrors = 0;
ams::commResetCount = 0;
ams::stateHigh = ams::BMS::HighState::pendingInit;
return 0;
}
};// from PDL, allocation (mapping) of individual brake pressure sensors
DEFINE_ENUM_DOC(BP_location_doc,
DEFINE_ENUMERATOR_DOC(pdl::BP_location, front_hydraulics, "front hydraulic circuit (BP5)"),
DEFINE_ENUMERATOR_DOC(pdl::BP_location, rear_hydraulics, "rear hydraulic circuit (BP6)"),
DEFINE_ENUMERATOR_DOC(pdl::BP_location, front_before_valve, "EBS FRONT before release valve (BP1)"),
DEFINE_ENUMERATOR_DOC(pdl::BP_location, rear_before_valve, "EBS REAR before release valve (BP2)"),
DEFINE_ENUMERATOR_DOC(pdl::BP_location, front_after_valve, "EBS FRONT after release valve (BP3)"),
DEFINE_ENUMERATOR_DOC(pdl::BP_location, rear_after_valve, "EBS REAR after release valve (BP4)"),
);
#define CANEPP_BP_SENSOR_LOCATION(adc_signal_name, AIN) \
canepp::Enum _reg_brakes_mapping_ ## adc_signal_name { \
"brakes.mapping." #adc_signal_name, \
"BP sensor connected to input " #adc_signal_name " (" #AIN " in CTU24 PDL)", \
nullptr, \
BP_location_doc, \
+[] { \
auto const iter = std::ranges::find(pdl::sensors, bsp::adc::Signal:: adc_signal_name, &pdl::PressureSensor::adc_signal); \
assert(iter != std::ranges::end(pdl::sensors)); \
return iter->location(); \
}, \
+[](pdl::BP_location loc) { \
auto const iter = std::ranges::find(pdl::sensors, bsp::adc::Signal:: adc_signal_name, &pdl::PressureSensor::adc_signal); \
assert(iter != std::ranges::end(pdl::sensors)); \
iter->set_location(loc); \
pdl::brakes.get_sensor_references(); \
pdl::ebs.get_sensor_references(); \
} \
};
CANEPP_BP_SENSOR_LOCATION(BRK_PRS_1, AIN7);
CANEPP_BP_SENSOR_LOCATION(BRK_PRS_2, AIN8);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_1, AIN1);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_2, AIN2);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_3, AIN3);
CANEPP_BP_SENSOR_LOCATION(EBS_PRS_4, AIN4);// From PDL, sets the width of APPS lower deadzone
canepp::Numeric _reg_apps_deadzone_low {
"accelerator.deadzone_lower",
"Width of deadzone around APPS (accel/regen) zero normalized reading.",
nullptr,
"%",
+[] { return options::APPS::lower_deadzone_width * 100; },
+[](float new_value) { options::APPS::lower_deadzone_width = new_value / 100; },
0, 100
};// from DRT, configures the maximal current used for field weakening separately on channel A and B
#define CANEPP_REG_IDMAX(channel) \
canepp::Numeric _reg_current_Idmax_ ## channel { \
"mc" #channel ".Id_max", \
"Maximal D axis current for channel " #channel, \
"Maximal magnitude of the field-weakening current.", \
"A", \
+[] { return drt::mc ## channel .fw().pid_fw_.clamp_max(); }, \
+[](float f) { drt::mc ## channel .fw().pid_fw_.clamp_max() = f; }, \
0, 60 \
};
CANEPP_REG_IDMAX(A);
CANEPP_REG_IDMAX(B);