Introduction
Hello. This article discusses software architecture design related to embedded software.
Software architecture has many definitions. For engineers, the exact academic definition is less important than what the architecture can solve in practice.
Software architecture is driven by product and business requirements. Architects express those requirements through the architecture. Architecture is not static; it evolves across the product lifecycle as needs change.
Why a Software Architecture Matters
A well-designed and promoted software architecture can:
- Minimize unnecessary rework
- Provide planning at a macro level for embedded software
- Improve reuse and reduce development cost
- Facilitate internal technical training
- Make technical accumulation easier
Junior engineers often cannot see the full scope of a project due to limited experience, so developing an architectural mindset takes time. However, the architectural perspective is beneficial even early in a career.
Six Practical Steps for Embedded Software Architecture
Below are six steps to guide embedded engineers when designing software architecture:
- Isolate hardware-related code and establish abstraction layers
- Build a unified software infrastructure
- Properly identify and handle product data
- Layer and decompose functionality
- Design components and their interfaces
- Support testing, debugging, and cross-platform development
Reading these six steps alone does not make one an embedded architect, but they provide a correct direction for effort. In the following series, the article will examine each step in more detail.
Step 1: Abstraction Layers and Hardware Isolation
Many engineers, both junior and senior, mix application logic with hardware-related code. This practice is common. For example, consider the following code:
void modbus_rtu_write_reply(uint8_t add, uint8_t func_code, uint16_t reg, uint16_t data){ rs485.buff_tx[0] = add; rs485.buff_tx[1] = func_code; rs485.buff_tx[2] = (uint8_t)(reg >> 8); rs485.buff_tx[3] = (uint8_t)(reg); rs485.buff_tx[4] = (uint8_t)(data >> 8); rs485.buff_tx[5] = (uint8_t)(data); uint16_t crc16 = mb_crc16(rs485.buff_tx, 6); rs485.buff_tx[6] = (uint8_t)(crc16); rs485.buff_tx[7] = (uint8_t)(crc16 >> 8); rs485.tx_total = 8; rs485.tx_num = 0; /* Send data from the uart port. The hardware related program. */ LL_USART_ClearFlag_TC(USART1); LL_USART_EnableIT_TC(USART1); USART1->DR = rs485.buff_tx[rs485.tx_num ++]; }
The line calling LL_USART_ClearFlag_TC shows that the Modbus code is coupled with the MCU firmware library. This violates the dependency inversion principle from SOLID: high-level modules should not depend on low-level modules; both should depend on abstractions. Here, the Modbus module depends directly on MCU firmware APIs.
We will call software that mixes hardware-specific code with application logic a "coupled architecture." The alternative we want is an "isolated architecture" that separates hardware-related code. The next sections compare these approaches.
Problems with a Coupled Architecture
A coupled architecture is understandable given many embedded engineers come from hardware-related backgrounds and tend to think from the hardware perspective. However, continuing to treat software as hardware-centric limits career growth. Engineers should learn abstraction as a core software tool.
Coupled architectures are hard to port. If hardware changes, such as MCU discontinuation or shortages, the embedded software requires extensive modification. Porting a large, hardware-coupled codebase to a new MCU is arduous and often impractical.
For example, the earlier code might have to be rewritten for a different MCU as follows:
{ rs485.buff_tx[0] = add; rs485.buff_tx[1] = func_code; rs485.buff_tx[2] = (uint8_t)(reg >> 8); rs485.buff_tx[3] = (uint8_t)(reg); rs485.buff_tx[4] = (uint8_t)(data >> 8); rs485.buff_tx[5] = (uint8_t)(data); uint16_t crc16 = mb_crc16(rs485.buff_tx, 6); rs485.buff_tx[6] = (uint8_t)(crc16); rs485.buff_tx[7] = (uint8_t)(crc16 >> 8); rs485.tx_total = 8; rs485.tx_num = 0; /* Send data from the uart port. The hardware related program. */ MCU_NEW_USART_ClearFlag_TC(NEW_USART1); MCU_NEW_USART_EnableIT_TC(NEW_USART1); NEW_USART1->DR = rs485.buff_tx[rs485.tx_num ++]; }
Coupled architectures also complicate unit testing in host environments like Windows or Linux. Application code that calls hardware directly requires hardware to validate behavior, so testing often becomes manual and slow. Manual tests are error-prone and reduce overall software quality. Automated unit tests are far more efficient when hardware dependencies are abstracted away.
Finally, coupled designs often lead to poor scalability and widespread use of global shared data, which increases the difficulty of adding new features and raises the risk of bugs.
Note: isolating hardware does not automatically solve all data design issues. Data handling is a key architectural concern that must be addressed through proper infrastructure and data mechanisms, covered in steps 2 and 3 of the series.
How an Isolated Architecture Solves These Issues
The first architectural step is to split the software into hardware-related and hardware-independent parts by introducing abstraction layers. Examples include a hardware abstraction layer (HAL), device abstraction layer (DAL), OS abstraction layer (OSAL), network abstraction layer, file system abstraction, and flash abstraction.
In this article, abstraction layer refers specifically to hardware or device abstraction layers, or both, depending on product needs.
Creating an abstraction layer is a requirement for portability and an application of the dependency inversion principle: application code should depend on abstract interfaces rather than concrete hardware drivers. The abstraction layer allows application code to move from one microcontroller or hardware platform to another without modification. The application only needs to know the abstraction API.
New hardware drivers simply implement the required interfaces, so changing hardware affects only the hardware-specific module rather than the entire codebase.
void modbus_rtu_write_reply(uint8_t add, uint8_t func_code, uint16_t reg, uint16_t data){ rs485.buff_tx[0] = add; rs485.buff_tx[1] = func_code; rs485.buff_tx[2] = (uint8_t)(reg >> 8); rs485.buff_tx[3] = (uint8_t)(reg); rs485.buff_tx[4] = (uint8_t)(data >> 8); rs485.buff_tx[5] = (uint8_t)(data); uint16_t crc16 = mb_crc16(rs485.buff_tx, 6); rs485.buff_tx[6] = (uint8_t)(crc16); rs485.buff_tx[7] = (uint8_t)(crc16 >> 8); rs485.tx_total = 8; rs485.tx_num = 0; /* Send data from the uart port. The hardware related program. */ hal_uart_send(HAL_UART_ID_1, rs485.buff_tx, rs485.tx_total); }
The hardware-specific code should be refactored to call HAL functions, for example:
void hal_uart_send(uint8_t uart_id, void *buffer, uint32_t size) { /* Start the uart sending process, the remaining data will be sent in UART ISR function. */ MCU_NEW_USART_ClearFlag_TC(NEW_USART1); MCU_NEW_USART_EnableIT_TC(NEW_USART1); NEW_USART1->DR = rs485.buff_tx[rs485.tx_num ++]; }
Abstraction layers also enable unit testing: a mock or fake hardware implementation can run on Windows or Linux. Tests can provide input to the fake hardware and verify outputs. This approach allows application development and testing to proceed before real hardware is available.
Another benefit is that software development does not need to wait for hardware readiness. Early application development and delivery for trial use become possible.
How to design an abstraction layer involves decisions about abstraction level, methods, and targets. These topics are complex and will be addressed in subsequent articles focused on abstraction layers.
Conclusion
Embedded software differs from other software domains because it interacts directly with hardware. To handle potential hardware changes, embedded architects should isolate hardware-related code and keep it within the smallest possible scope. Without this separation, a large, entangled codebase is likely.
A successful architecture evolves through iteration. Technical leaders or architects must drive iterative refactoring and improvements. In embedded systems, separating hardware-related code is the first and most critical step. If hardware-related code cannot be cleanly separated, any higher-level architecture efforts will be unstable.
Technical progress starts from small steps. Engineers aiming to improve their skills should start by isolating hardware dependencies.
ALLPCB