Nested conditional logic main loops are hard to read and maintain.
State machines are a common way to implement complex logic.
States have transitions that are triggered by events or conditions.
States can have entry / exit routines that are executed when the state is entered / exited.
The “run” status of a state is commonly referred to as the “state machine tick” and recurrently loops.
State diagrams are used to visualize state machines.
State structures are used to capture variables associated with describing the state.
Implementation
Switch-Case
The simplest implementation of a state machine is a switch-case statement.
The switch statement is used to select the current state.
The case statements are used to implement the logic for each state.
Cases can be nested to implement sub-states.
Enumerations can be used to give states verbose names instead of numbers.
The break statement is used to exit the switch statement.
The default statement is used to handle unexpected states.
Use the State to Dictate Function
Avoid testing for a condition in a state to figure out what to do; let being in the state dictate the function (i.e., you shouldn’t have to test for the state you are in to know what to do).
Use entry and exit routines to handle state transitions; do not test for first or last iteration of that state.
“Run” states can iterate in a loop until a condition is met to change the state, or can wait for an event.
If you find you need to timeout an event wait to allow other stuff to happen, consider using k_event_test().
Pseudo-Code
enum device_states { init, run, error_entry, error_run, error_exit };int device_state = init;// initialize state/* structure to bookkeep state variables */struct device_state_vars {int var1;int var2;};while(1){switch(device_state){case init:/* do stuff to initialize device */ device_state = run;// change the statebreak;case run:/* run device */if(condition){ device_state = error;}break;case error_entry: illuminate_error_led();break;case error_run:if(condition_to_leave_error){ device_state = error_exit;}breakcase error_entry: turn_off_error_led(); device_state = run;break;default:/* handle unexpected state */break;}}
The switch-case approach loses some of its elegance when there are many states and many transitions and states have entry / exit routines.
The state machine is the foundation of describing the behavior of a system.
Threads as a tool to help implement a function of the state machine that is not readily captured by an exclusive state.
Threads add overhead and complexity to the system, making debugging more difficult.
Threads are not readily captured in a state diagram, but are more commonly described using a sequence diagram.
Avoiding Global Variables with s_object Struct
As you implement functionality within the SMF, you may wonder how to make a variable available in two different states — which is a good question, because a natural instinct might be to utilize a global variable, which we want to avoid.
Generally, you can leverage the fact that the s_object struct we create for smf_ctx (ie, state machine context) is passed to all states as a pointer and populate it with members for your own purpose. You can declare an s_object struct template in the global scope that contains anything you need to access between states. Something like:
struct s_object {struct smf_ctx ctx;// must be first elementstruct struct_type my_struct;int16_t my_variable;int16_t my_array[ARR_LENGTH];};
To be clear, this is merely defining a struct type and does not constitute creating a variable. Then, within main(), you can create an instance of this struct (that will be local to main) and populate the members with initial values — here, this example shows how to do this with a helper function:
#define S_OBJ_SUCCESS 0#define S_OBJ_ERR_NULL_PTR -1#define S_OBJ_ERR_BAD_CONFIG -2// define your own error codesint8_t init_s_object(struct s_object *s){if(s == NULL){return S_OBJ_ERR_NULL_PTR;// invalid pointer} s->my_variable =0;// initial value s->my_struct.member =&s->my_variable;// assign member of my_struct pointer to my_variable s->my_struct.array_size =sizeof(s->my_array[0])*ARR_LENGTH;if(s->my_struct.member == NULL){return S_OBJ_ERR_BAD_CONFIG;}return S_OBJ_SUCCESS;}
As always, utilize function returns for error handling using exit codes.
int main(void){staticstruct s_object s_obj;// create an instance of s_object called s_objint ret = init_s_object(&s_obj);// initialize s_obj using helper functionif(ret !=0){ LOG_ERR("could not initialize s_object stuct (%d)", ret);break;} smf_set_initial(SMF_CTX(&s_obj),&fsm_states[INIT]);for(;;){ ret = smf_run_state(SMF_CTX(&s_obj));if(ret !=0){ LOG_ERR("terminating state machine (%d)", ret);break;}}return0;}
Then, within a state:
staticvoid state_run(void*o){struct s_object *s =(struct s_object *)o;// cast explicitly for compiler(void)my_function(&s->my_variable);// pass pointer to my_variable to my_function// remainder of state code}
Because the default argument for states is a generic pointer (void *o), you will need to cast the input to a pointer for the s_object struct to safely access its members. If you’re unfamiliar with the arrow operator (->), this allows you to access members of a struct via a pointer. This syntax is equivalent to (*s).member , where the . is how we would typically access a member directly (ie, not as a pointer).