Driver Software Architecture
In a typical robotics system architecture, driver software is situated between the hardware peripherals and the middleware. As an embedded engineer, it's essential to understand how to communicate effectively with the developers of these adjacent layers.
Hardware developers provide schematics and chip datasheets. This information is crucial for correctly utilizing hardware I/O and avoiding fundamental errors. For middleware integration, a communication protocol must be jointly established to ensure proper data exchange between the two layers.
The role of the embedded developer involves two main responsibilities. First, to correctly use the board and peripheral resources, processing their data to implement desired functionalities. Second, to interact with upstream software by exchanging processed data, enabling more efficient data utilization and responding to commands.
Getting Started with FreeRTOS
Software Environment Setup
For this guide, we will use Keil as the development environment, which can be downloaded from: https://www.keil.com/download/product
Using FreeRTOS
RTOS stands for Real-Time Operating System. It emphasizes timeliness and is categorized into hard real-time and soft real-time systems. Hard real-time systems require operations to be completed within a strict deadline, whereas soft real-time systems are more lenient with timing constraints. The core of an RTOS is task scheduling.
FreeRTOS is a compact RTOS designed to run on microcontrollers. Microcontrollers are resource-constrained processors that integrate the processor, read-only memory (ROM or Flash) for program storage, and random-access memory (RAM) for program execution on a single chip.
Task Creation
FreeRTOS provides an API for task creation. The format is as follows:
// Task priority
#define START_TASK_PRIO 1
// Task stack size
#define START_STK_SIZE 256
// Task handle
TaskHandle_t StartTask_Handler;
// Task function
void start_task(void *pvParameters);
// Main function
int main(void)
{
// Hardware initialization
systemInit();
// Create the start task
xTaskCreate((TaskFunction_t )start_task,
(const char* )"start_task",
(uint16_t )START_STK_SIZE,
(void* )NULL,
(UBaseType_t )START_TASK_PRIO,
(TaskHandle_t* )&StartTask_Handler);
// Enable task scheduling
vTaskStartScheduler();
}
// Start task function
void start_task(void *pvParameters)
{
// Enter the critical section
taskENTER_CRITICAL();
// Create other tasks
xTaskCreate(Balance_task, "Balance_task", BALANCE_STK_SIZE, NULL, BALANCE_TASK_PRIO, NULL); // Vehicle motion control task
xTaskCreate(MPU6050_task, "MPU6050_task", MPU6050_STK_SIZE, NULL, MPU6050_TASK_PRIO, NULL); // IMU data read task
xTaskCreate(show_task, "show_task", SHOW_STK_SIZE, NULL, SHOW_TASK_PRIO, NULL); // OLED display task
xTaskCreate(led_task, "led_task", LED_STK_SIZE, NULL, LED_TASK_PRIO, NULL); // LED flashing task
xTaskCreate(pstwo_task, "PSTWO_task", PS2_STK_SIZE, NULL, PS2_TASK_PRIO, NULL); // Read PS2 controller task
xTaskCreate(data_task, "DATA_task", DATA_STK_SIZE, NULL, DATA_TASK_PRIO, NULL); // USART3, USART1, and CAN data sending task
// Delete the start task
vTaskDelete(StartTask_Handler);
// Exit the critical section
taskEXIT_CRITICAL();
}
Motion Control and PID
PID Principle
To better control a robot's movement, motor control often relies on the PID (Proportional-Integral-Derivative) algorithm. PID control calculates an error value as the difference between a measured process variable and a desired setpoint. The controller attempts to minimize the error by adjusting the process control inputs through proportional, integral, and derivative terms.
Proportional (P) Control reacts to the present error. The controller's output is the product of the proportional gain (Kp) and the magnitude of the error. A higher Kp results in a stronger response and a faster system, but too high a value can lead to instability and oscillation.
Integral (I) Control addresses the accumulation of past errors. As long as an error persists, the integral term will grow over time, driving the controller to eliminate the steady-state error. While integral action eliminates residual error, it can slow down the system's response and increase overshoot. The integral constant (Ti) adjusts the strength of this effect.
Derivative (D) Control anticipates future error by considering the error's rate of change. It provides a corrective action that dampens the system's response, reducing overshoot and improving stability. The derivative component helps to speed up the system's tracking performance.
For a practical demonstration of PID principles from a ROS perspective, refer to this project.
Project Example
In motor control, wheel speed is often managed by adjusting PWM values. The primary task is to get the current speed (from an encoder) and calculate the target PWM. The origincar_controller
project uses PI control for this purpose. First, we need to obtain the current speed by reading the encoder values.
// Get current encoder value and convert to wheel speed (m/s)
MOTOR_A.Encoder = Encoder_A_pr * CONTROL_FREQUENCY * Wheel_perimeter / Encoder_precision;
MOTOR_B.Encoder = Encoder_B_pr * CONTROL_FREQUENCY * Wheel_perimeter / Encoder_precision;
MOTOR_C.Encoder = Encoder_C_pr * CONTROL_FREQUENCY * Wheel_perimeter / Encoder_precision;
MOTOR_D.Encoder = Encoder_D_pr * CONTROL_FREQUENCY * Wheel_perimeter / Encoder_precision;
For a vehicle with Ackermann steering, the target PWM values are calculated through inverse kinematics based on the desired linear velocity and steering angle.
// Calculate target PWM values
{
float R, Ratio=636.56, AngleR, Angle_Servo;
// Vz represents the front-right wheel steering angle for the Ackermann car
AngleR = Vz;
R = Axle_spacing / tan(AngleR) - 0.5f * Wheel_spacing;
// Limit the front wheel steering angle (in radians)
AngleR = target_limit_float(AngleR, -0.49f, 0.32f);
// Inverse kinematics
if(AngleR != 0)
{
MOTOR_A.Target = Vx * (R - 0.5f * Wheel_spacing) / R;
MOTOR_B.Target = Vx * (R + 0.5f * Wheel_spacing) / R;
}
else
{
MOTOR_A.Target = Vx;
MOTOR_B.Target = Vx;
}
// Calculate servo PWM value for steering
Angle_Servo = -0.628f * pow(AngleR, 3) + 1.269f * pow(AngleR, 2) - 1.772f * AngleR + 1.573f;
Servo = SERVO_INIT + (Angle_Servo - 1.572f) * Ratio;
// Limit target wheel speeds
MOTOR_A.Target = target_limit_float(MOTOR_A.Target, -amplitude, amplitude);
MOTOR_B.Target = target_limit_float(MOTOR_B.Target, -amplitude, amplitude);
MOTOR_C.Target = 0; // Unused
MOTOR_D.Target = 0; // Unused
Servo = target_limit_int(Servo, 800, 2200); // Limit servo PWM value
}
The PI calculation is then performed:
int Incremental_PI(float Encoder, float Target)
{
static float Bias, Pwm, Last_bias;
Bias = Target - Encoder; // Calculate error
Pwm += Velocity_KP * (Bias - Last_bias) + Velocity_KI * Bias;
if(Pwm > 16700) Pwm = 16700;
if(Pwm < -16700) Pwm = -16700;
Last_bias = Bias; // Save previous error
return Pwm;
}
Human-Machine Interface (HMI)
HMI components on a driver board can include LEDs, buzzers, buttons, and relays. Among these, an OLED display is one of the most intuitive peripherals. It is primarily used to facilitate debugging of MCU code by displaying real-time sensor data, such as from a gyroscope, or information received from a controller, like a Wi-Fi IP address.
Key advantages of OLED displays include:
- Self-emitting pixels provide a wide viewing angle of up to 170 degrees with no distortion from the side.
- Good performance in low temperatures, functioning normally at -40°C.
- Fast response time, typically in the range of a few to tens of microseconds.
SSD1306 Driver Example
The OriginCar uses a 0.96-inch OLED display driven by the SSD1306 chip.
Display Development
For details on SSD1306 development, refer to external documentation. In the origincar_controller
project, several functions are already encapsulated for easy use:
// oled.h
void OLED_WR_Byte(u8 dat, u8 cmd);
void OLED_Display_On(void);
void OLED_Display_Off(void);
void OLED_Refresh_Gram(void);
void OLED_Init(void);
void OLED_Clear(void);
void OLED_DrawPoint(u8 x, u8 y, u8 t);
void OLED_ShowChar(u8 x, u8 y, u8 chr, u8 size, u8 mode);
void OLED_ShowNumber(u8 x, u8 y, u32 num, u8 len, u8 size);
void OLED_ShowString(u8 x, u8 y, const u8 *p);
Here is an example of how to display information:
// show.c
void oled_show(void)
{
...
// Display content on the first line
OLED_ShowString(0, 0, "Akm ");
// Display gyroscope bias
OLED_ShowString(55, 0, "BIAS");
if(Deviation_gyro[2] < 0) {
OLED_ShowString(90, 0, "-");
OLED_ShowNumber(100, 0, -Deviation_gyro[2], 3, 12); // Z-axis zero-drift data
} else {
OLED_ShowString(90, 0, "+");
OLED_ShowNumber(100, 0, Deviation_gyro[2], 3, 12);
}
...
OLED_Refresh_Gram();
}
To display data, use functions like OLED_ShowString
and OLED_ShowNumber
within a display function, and remember to call OLED_Refresh_Gram
at the end to update the screen.
Serial Communication
Serial communication is a common method for inter-device communication due to its simplicity. The protocol can be divided into a physical layer and a protocol layer. The physical layer defines the mechanical and electrical characteristics for data transmission over a medium, while the protocol layer defines the logic for data packing and unpacking.
Physical Layer
RS-232 is a standard for serial data transmission, commonly associated with DB9 connectors. Its use of higher voltage levels for logic 1 and 0 provides high noise immunity, making it suitable for industrial environments. Data transmitted over RS-232 must pass through a level-shifter chip to be converted into TTL-level signals that a microcontroller can process.
Protocol Layer
The protocol layer defines the structure of a data packet, which consists of a start bit, data bits, an optional parity bit, and stop bits. Both communicating devices must agree on this format.
- Start and Stop Bits: A data packet begins with a start bit (logic 0) and ends with one or two stop bits (logic 1).
- Data Bits: The payload of the packet, typically 5, 6, 7, or 8 bits long.
- Parity Bit: Used for error checking. With even parity, the parity bit is set to ensure an even number of 1s in the data frame. With odd parity, it ensures an odd number of 1s.
Example
In the origincar_controller
example, the board is connected to a PC via a USB-to-serial adapter. A serial terminal on the PC can then receive data from the board. This data is sent periodically by a FreeRTOS task.
void data_task(void *pvParameters)
{
u32 lastWakeTime = getSysTickCnt();
while(1) {
vTaskDelayUntil(&lastWakeTime, F2T(RATE_20_HZ));
data_transition();
USART1_SEND(); // Send data via USART1
USART3_SEND(); // Send data via USART3 (ROS)
USART5_SEND(); // Send data via USART5
CAN_SEND(); // Send data via CAN
}
}
Taking USART3_SEND
as an example:
void USART3_SEND(void)
{
unsigned char i = 0;
for(i = 0; i < 24; i++) {
usart3_send(Send_Data.buffer[i]);
}
}
The Send_Data.buffer
contains the data packet structured according to the protocol layer. The usart3_send
function handles the low-level transmission:
void usart3_send(u8 data)
{
USART3->DR = data;
while((USART3->SR & 0x40) == 0);
}
This code uses the STM32 firmware library to interact directly with hardware registers.
- Status Register (USART->SR): This register provides real-time status of the USART peripheral. For sending, a flag in this register indicates whether the previous transmission is complete. For receiving, a flag indicates whether a full frame has been received.
- Data Register (USART->DR): This single register is used for both transmitting and receiving. Writing to it places data in the transmit data register (TDR), while reading from it retrieves data from the receive data register (RDR). This explains why the same
DR
register is used for both read and write operations.