做嵌入式开发的工程师,对"单元测试"这个词大概都不陌生。但要说真正在项目里落地了单元测试的,坦白讲,比例不高。
原因也很直接:嵌入式代码跟硬件绑得太紧了。你想测一个温度采集函数,结果它里面直接调了ADC寄存器;你想测一个通信协议的解析逻辑,结果它跟串口驱动缠在一起。代码离开了目标板根本跑不起来,而在板子上做自动化测试,成本又高得离谱。
久而久之,很多嵌入式团队形成了一种默认共识:"我们的代码没法做单元测试。"
但事实是,嵌入式软件不仅可以做单元测试,而且如果方法对了,测试效率比你想象的高得多。 关键在于你得理解一个核心前提:单元测试测的不是硬件,而是逻辑。只要你能把逻辑从硬件依赖中剥离出来,它就能在PC上跑,就能自动化,就能纳入CI流程。
这篇文章,我会从嵌入式单元测试的实际挑战讲起,介绍如何搭建基于Host的测试环境,如何用Mock/Stub替代硬件依赖,如何选择测试框架,以及如何在工程中真正把这件事落地。不讲理论大道理,只聊工程中能用上的实操方案。
一、嵌入式单元测试为什么难?
在聊怎么做之前,先把问题摆清楚。嵌入式单元测试落地难,不是因为工程师不重视测试,而是嵌入式开发有几个天然的障碍,导致常规的单元测试方法直接搬过来水土不服。
1.1 硬件依赖无处不在
这是最核心的障碍。嵌入式代码大量直接操作寄存器、调用HAL库函数、访问外设。一个看起来很简单的业务函数,背后可能拖着一串硬件调用链:
float get_temperature(void){ uint16_t adc_raw = HAL_ADC_GetValue(&hadc1); // 依赖ADC硬件 float voltage = adc_raw * 3.3f / 4096.0f; float temp = (voltage - 0.5f) * 100.0f; return temp;}
这个函数在PC上编译都过不了,因为HAL_ADC_GetValue和hadc1根本不存在。
1.2 交叉编译环境的隔阂
嵌入式项目用的是交叉编译工具链(比如arm-none-eabi-gcc),目标平台是Cortex-M、RISC-V等处理器。而单元测试通常需要在开发主机上运行,用的是主机端编译器(gcc/msvc)。这两套编译环境之间存在天然的隔阂,头文件、链接库、数据类型宽度都可能不一样。
1.3 "在板上测"的高成本
有人说,那我直接在板子上跑测试不就行了?可以,但成本很高:
1.4 缺乏测试意识和基础设施
很多嵌入式项目从立项开始就没有考虑过可测试性。代码里业务逻辑和硬件操作混在一起,模块之间通过全局变量通信,函数副作用多、接口不清晰。等到想补测试的时候,发现代码结构根本不支持。
这几个问题叠加在一起,就形成了嵌入式测试的困境:
那思路是什么?接下来就讲。
二、核心思路:把"硬件"和"逻辑"拆开
嵌入式单元测试能不能落地,取决于一个关键动作:把业务逻辑从硬件依赖中剥离出来。
这不是什么新概念。做过桌面端或服务器端开发的人都知道,单元测试的基本原则就是隔离被测单元的外部依赖。在嵌入式领域,最主要的"外部依赖"就是硬件。
2.1 分层是前提
要实现逻辑和硬件的分离,代码必须有基本的分层结构。至少要分出三层:
中间那层"硬件抽象层"是关键。它像一道隔离墙,让上层的业务逻辑通过一组抽象接口来访问硬件能力,而不是直接去碰寄存器。
2.2 举个具体的例子
还是前面那个温度采集的函数。原来的写法是直接调用HAL库:
// 原始写法:业务逻辑和硬件耦合在一起float get_temperature(void){ uint16_t adc_raw = HAL_ADC_GetValue(&hadc1); float voltage = adc_raw * 3.3f / 4096.0f; float temp = (voltage - 0.5f) * 100.0f; return temp;}
重构之后,我们把ADC读取抽象出来:
// bsp_adc.h - 硬件抽象接口uint16_t bsp_adc_read_channel(uint8_t channel);
// temperature.c - 业务逻辑,只依赖抽象接口#include "bsp_adc.h"#define ADC_CHANNEL_TEMP 0float get_temperature(void){ uint16_t adc_raw = bsp_adc_read_channel(ADC_CHANNEL_TEMP); float voltage = adc_raw * 3.3f / 4096.0f; float temp = (voltage - 0.5f) * 100.0f; return temp;}
现在get_temperature()只依赖一个接口函数bsp_adc_read_channel(),而不是直接绑定在STM32的HAL库上。在PC端做测试的时候,我们只需要提供一个假的bsp_adc_read_channel()实现,就可以让这段代码跑起来了。
这就是Mock的用武之地,后面会详细展开。
2.3 对已有项目怎么办?
如果你的项目已经写了一大堆跟硬件耦合的代码,全部重构不太现实。务实的做法是:
- 2. 对核心模块逐步抽离,优先处理出过bug、逻辑复杂的模块
- 3. 不追求100%覆盖率,先把最容易出问题的逻辑覆盖住
完美主义是单元测试落地的敌人。能测60%的核心逻辑,比什么都不测强太多了。
三、搭建基于Host的测试环境
所谓"Host-based Testing",就是把嵌入式代码中的纯逻辑部分拿到PC上,用主机端的编译器编译运行。这是嵌入式单元测试最主流也最实用的方案。
3.1 基本原理
整体思路如下图所示:
左边是正常的嵌入式工程,右边是测试工程。测试工程复用业务逻辑层的源码(不是复制,是直接引用同一份.c文件),但把底层的硬件抽象层替换成Mock实现。
3.2 工程目录组织
一个支持Host-based测试的嵌入式工程,目录结构通常是这样的:
project/├── src/ # 产品源码│ ├── app/ # 业务逻辑层(单元测试目标)│ │ ├── temperature.c│ │ ├── alarm.c│ │ └── protocol_parser.c│ ├── bsp/ # 硬件抽象层│ │ ├── bsp_adc.h # 抽象接口定义│ │ ├── bsp_adc_stm32.c # STM32平台实现│ │ ├── bsp_gpio.h│ │ └── bsp_gpio_stm32.c│ └── driver/ # 底层驱动│ └── ...├── test/ # 测试工程(PC端)│ ├── mocks/ # Mock实现│ │ ├── mock_bsp_adc.c│ │ └── mock_bsp_gpio.c│ ├── test_temperature.c # 测试用例│ ├── test_alarm.c│ ├── test_protocol_parser.c│ └── CMakeLists.txt # 测试工程的构建脚本└── CMakeLists.txt # 主工程的构建脚本
核心要点:src/app/下面的代码只依赖bsp/*.h中定义的接口,不依赖任何具体平台的实现。测试工程编译时,链接的是test/mocks/下的Mock实现,而不是bsp/*_stm32.c。
3.3 用CMake管理双工程
用CMake可以很方便地同时管理产品工程和测试工程。测试工程的CMakeLists.txt大致长这样:
cmake_minimum_required(VERSION 3.14)project(unit_tests C)# 测试框架(以Unity为例)add_subdirectory(unity)# 被测源码set(SRC_DIR ${CMAKE_SOURCE_DIR}/../src)# 测试可执行文件add_executable(test_temperature test_temperature.c ${SRC_DIR}/app/temperature.c # 被测模块 mocks/mock_bsp_adc.c # Mock替身)target_include_directories(test_temperature PRIVATE ${SRC_DIR}/app ${SRC_DIR}/bsp # 引用bsp头文件中的接口定义 unity/src)target_link_libraries(test_temperature unity)enable_testing()add_test(NAME test_temperature COMMAND test_temperature)
这样一来,make test就能在PC上编译并运行测试了。
四、Mock和Stub:替换硬件依赖的两把利刃
有了分层结构和Host测试环境,接下来最关键的技术环节就是:如何在测试中替换掉那些硬件相关的函数?
这就要用到Mock和Stub。这两个概念经常被混用,但在实践中有明确的区分。
4.1 Stub:最简单的替身
Stub是最轻量的替代方式。它只是提供一个假实现,让代码能编译通过、跑起来,返回一个预设的值。
// mock_bsp_adc.c - Stub实现static uint16_t fake_adc_value = 0;// 提供给测试用例设置返回值的接口void mock_bsp_adc_set_value(uint16_t value){ fake_adc_value = value;}// Stub: 替代真实的ADC读取uint16_t bsp_adc_read_channel(uint8_t channel){ (void)channel; return fake_adc_value;}
测试用例这样写:
// test_temperature.c#include "unity.h"#include "temperature.h"extern void mock_bsp_adc_set_value(uint16_t value);void test_temperature_at_25_degrees(void){ // 25°C对应的ADC值:(25/100 + 0.5) / 3.3 * 4096 ≈ 930 mock_bsp_adc_set_value(930); float temp = get_temperature(); // 允许0.5°C的误差 TEST_ASSERT_FLOAT_WITHIN(0.5f, 25.0f, temp);}void test_temperature_at_0_degrees(void){ // 0°C对应的ADC值:0.5 / 3.3 * 4096 ≈ 621 mock_bsp_adc_set_value(621); float temp = get_temperature(); TEST_ASSERT_FLOAT_WITHIN(0.5f, 0.0f, temp);}void test_temperature_adc_returns_zero(void){ mock_bsp_adc_set_value(0); float temp = get_temperature(); // ADC返回0时,voltage=0, temp=(0-0.5)*100=-50 TEST_ASSERT_FLOAT_WITHIN(0.1f, -50.0f, temp);}
这就是最基本的嵌入式单元测试。通过Stub控制输入(ADC返回值),验证输出(温度计算结果)。整个过程在PC上完成,不需要任何硬件。
4.2 Mock:带验证能力的替身
Stub只管"给假数据",Mock在此基础上还能验证被测代码是否按预期方式调用了依赖函数。
比如,你要测试一个报警模块:当温度超过阈值时,应该调用bsp_gpio_set(PIN_ALARM, 1)来拉高报警引脚。你不仅需要提供GPIO函数的假实现,还需要验证这个函数确实被调用了,并且参数正确。
// mock_bsp_gpio.c - Mock实现static uint8_t last_pin = 0;static uint8_t last_state = 0;static int call_count = 0;void mock_bsp_gpio_reset(void){ last_pin = 0; last_state = 0; call_count = 0;}int mock_bsp_gpio_get_call_count(void){ return call_count;}uint8_t mock_bsp_gpio_get_last_pin(void){ return last_pin;}uint8_t mock_bsp_gpio_get_last_state(void){ return last_state;}// Mock: 替代真实的GPIO操作void bsp_gpio_set(uint8_t pin, uint8_t state){ last_pin = pin; last_state = state; call_count++;}
测试用例:
void test_alarm_triggers_when_over_threshold(void){ mock_bsp_gpio_reset(); // 设置温度为80°C,阈值为70°C alarm_check(80.0f, 70.0f); TEST_ASSERT_EQUAL(1, mock_bsp_gpio_get_call_count()); TEST_ASSERT_EQUAL(PIN_ALARM, mock_bsp_gpio_get_last_pin()); TEST_ASSERT_EQUAL(1, mock_bsp_gpio_get_last_state());}void test_alarm_not_trigger_when_below_threshold(void){ mock_bsp_gpio_reset(); alarm_check(60.0f, 70.0f); TEST_ASSERT_EQUAL(0, mock_bsp_gpio_get_call_count());}
4.3 Stub vs Mock 的选择
什么时候用Stub,什么时候用Mock?一个简单的判断标准:
在实际项目中,大部分场景用Stub就够了。Mock主要用在你需要确认"某个动作确实被执行了"的时候。
五、测试框架选型:嵌入式场景下的务实选择
PC端的测试框架多如牛毛,但适合嵌入式项目的选择其实不多。嵌入式有自己的约束:代码是C语言为主、需要轻量级、最好能同时在Host和Target上跑。
5.1 主流框架对比
| | | |
|---|
| Unity | | | |
| CMock | | | |
| CeedlingBundle | | Unity + CMock + 构建工具的整合方案 | |
| Google Test | | | |
| CppUTest | | | |
5.2 推荐:Unity + 手写Mock
对于大多数嵌入式C项目,我推荐的组合是:Unity框架 + 手写Mock/Stub。
为什么?
Unity足够轻量。 整个框架就三个文件:unity.c、unity.h、unity_internals.h。不需要复杂的构建配置,不依赖任何第三方库,甚至可以直接在目标板上运行(如果你需要的话)。
手写Mock可控性强。 虽然CMock能自动生成Mock代码,但自动生成的代码有时候过于复杂,调试起来不方便。对于嵌入式项目来说,硬件抽象接口通常就那么十几二十个函数,手写Mock的工作量并不大,但你对每个Mock的行为完全可控。
Unity的测试用例结构非常简单:
#include "unity.h"void setUp(void){ // 每个测试用例执行前的初始化}void tearDown(void){ // 每个测试用例执行后的清理}void test_something_works(void){ int result = function_under_test(42); TEST_ASSERT_EQUAL(expected_value, result);}int main(void){ UNITY_BEGIN(); RUN_TEST(test_something_works); return UNITY_END();}
常用的断言宏:
TEST_ASSERT_EQUAL(expected, actual) // 整型比较TEST_ASSERT_EQUAL_STRING(expected, actual) // 字符串比较TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual)// 浮点比较TEST_ASSERT_TRUE(condition) // 条件为真TEST_ASSERT_NULL(pointer) // 空指针TEST_ASSERT_EQUAL_MEMORY(expected, actual, len) // 内存比较TEST_ASSERT_EQUAL_HEX8(expected, actual) // 十六进制比较
这些断言覆盖了嵌入式测试中绝大部分需求。
六、完整实战:为协议解析模块编写单元测试
前面的温度采集例子比较简单,现在来看一个更贴近实际的场景:测试一个自定义协议的解析模块。
协议解析是嵌入式项目里非常典型的纯逻辑模块,天然适合做单元测试,因为它处理的是字节数组到结构体的转换,不涉及任何硬件操作。
6.1 协议格式定义
假设我们的私有协议格式如下:
6.2 解析模块实现
// protocol_parser.h#ifndef PROTOCOL_PARSER_H#define PROTOCOL_PARSER_H#include <stdint.h>#include <stdbool.h>#define PROTO_HEADER_0 0xAA#define PROTO_HEADER_1 0x55#define PROTO_MAX_DATA_LEN 128typedefstruct { uint8_t cmd; uint8_t data[PROTO_MAX_DATA_LEN]; uint8_t data_len;} proto_frame_t;typedefenum { PROTO_OK = 0, PROTO_ERR_HEADER, PROTO_ERR_LENGTH, PROTO_ERR_CRC,} proto_result_t;proto_result_t proto_parse(const uint8_t *buf, uint16_t buf_len, proto_frame_t *frame);uint16_t proto_calc_crc16(const uint8_t *data, uint16_t len);#endif
// protocol_parser.c#include "protocol_parser.h"uint16_t proto_calc_crc16(const uint8_t *data, uint16_t len){ uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc = crc >> 1; } } return crc;}proto_result_t proto_parse(const uint8_t *buf, uint16_t buf_len, proto_frame_t *frame){ if (buf_len < 5) return PROTO_ERR_LENGTH; if (buf[0] != PROTO_HEADER_0 || buf[1] != PROTO_HEADER_1) return PROTO_ERR_HEADER; uint8_t payload_len = buf[2]; if (payload_len == 0 || payload_len > PROTO_MAX_DATA_LEN + 1) return PROTO_ERR_LENGTH; uint16_t expected_total = 2 + 1 + payload_len + 2; if (buf_len < expected_total) return PROTO_ERR_LENGTH; uint16_t crc_received = (buf[expected_total - 2] << 8) | buf[expected_total - 1]; uint16_t crc_calc = proto_calc_crc16(&buf[2], payload_len + 1); if (crc_received != crc_calc) return PROTO_ERR_CRC; frame->cmd = buf[3]; frame->data_len = payload_len - 1; for (uint8_t i = 0; i < frame->data_len; i++) { frame->data[i] = buf[4 + i]; } return PROTO_OK;}
6.3 测试用例
这是重点部分。一个好的单元测试要覆盖正常路径和各种异常路径:
// test_protocol_parser.c#include "unity.h"#include "protocol_parser.h"static proto_frame_t frame;void setUp(void) { memset(&frame, 0, sizeof(frame)); }void tearDown(void) {}// 辅助函数:构造一个完整的合法帧static uint16_t build_valid_frame(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint8_t data_len){ buf[0] = 0xAA; buf[1] = 0x55; buf[2] = data_len + 1; // LEN = CMD + DATA buf[3] = cmd; for (uint8_t i = 0; i < data_len; i++) buf[4 + i] = data[i]; uint16_t crc = proto_calc_crc16(&buf[2], data_len + 2); uint16_t total = 4 + data_len; buf[total] = (crc >> 8) & 0xFF; buf[total + 1] = crc & 0xFF; return total + 2;}/* --- 正常场景 --- */void test_parse_valid_frame(void){ uint8_t buf[32]; uint8_t data[] = {0x01, 0x02, 0x03}; uint16_t len = build_valid_frame(buf, 0x10, data, 3); TEST_ASSERT_EQUAL(PROTO_OK, proto_parse(buf, len, &frame)); TEST_ASSERT_EQUAL_HEX8(0x10, frame.cmd); TEST_ASSERT_EQUAL(3, frame.data_len); TEST_ASSERT_EQUAL_MEMORY(data, frame.data, 3);}void test_parse_frame_no_data(void){ uint8_t buf[32]; uint16_t len = build_valid_frame(buf, 0x20, NULL, 0); TEST_ASSERT_EQUAL(PROTO_OK, proto_parse(buf, len, &frame)); TEST_ASSERT_EQUAL_HEX8(0x20, frame.cmd); TEST_ASSERT_EQUAL(0, frame.data_len);}/* --- 异常场景 --- */void test_parse_wrong_header(void){ uint8_t buf[] = {0xBB, 0x55, 0x01, 0x10, 0x00, 0x00}; TEST_ASSERT_EQUAL(PROTO_ERR_HEADER, proto_parse(buf, 6, &frame));}void test_parse_buffer_too_short(void){ uint8_t buf[] = {0xAA, 0x55, 0x01}; TEST_ASSERT_EQUAL(PROTO_ERR_LENGTH, proto_parse(buf, 3, &frame));}void test_parse_crc_mismatch(void){ uint8_t buf[32]; uint8_t data[] = {0x01}; uint16_t len = build_valid_frame(buf, 0x10, data, 1); buf[len - 1] ^= 0xFF; // 故意破坏CRC TEST_ASSERT_EQUAL(PROTO_ERR_CRC, proto_parse(buf, len, &frame));}void test_parse_length_exceeds_buffer(void){ uint8_t buf[32]; uint8_t data[] = {0x01}; uint16_t len = build_valid_frame(buf, 0x10, data, 1); // 传入的buf_len比实际帧短 TEST_ASSERT_EQUAL(PROTO_ERR_LENGTH, proto_parse(buf, len - 1, &frame));}int main(void){ UNITY_BEGIN(); RUN_TEST(test_parse_valid_frame); RUN_TEST(test_parse_frame_no_data); RUN_TEST(test_parse_wrong_header); RUN_TEST(test_parse_buffer_too_short); RUN_TEST(test_parse_crc_mismatch); RUN_TEST(test_parse_length_exceeds_buffer); return UNITY_END();}
运行结果大概长这样:
test_protocol_parser.c:30:test_parse_valid_frame:PASStest_protocol_parser.c:40:test_parse_frame_no_data:PASStest_protocol_parser.c:49:test_parse_wrong_header:PASStest_protocol_parser.c:55:test_parse_buffer_too_short:PASStest_protocol_parser.c:63:test_parse_crc_mismatch:PASStest_protocol_parser.c:71:test_parse_length_exceeds_buffer:PASS-----------------------6 Tests 0 Failures 0 IgnoredOK
注意看这里的测试策略:正常帧能解析、无数据帧能解析、帧头错误能识别、缓冲区太短能拦截、CRC错误能检测、长度不匹配能发现。这6个测试用例覆盖了协议解析最核心的路径。
这整个过程在PC上几毫秒就能跑完,而且完全不需要任何硬件。
七、接入CI:让测试自动跑起来
单元测试写出来之后,如果只是偶尔在本地手动跑一下,效果会大打折扣。真正发挥价值的方式是把测试接入CI(持续集成),让每次代码提交都自动触发测试。
7.1 整体流程
因为Host-based测试在PC上运行,所以CI环境不需要任何特殊硬件。一台普通的Linux/Windows CI服务器就够了。
7.2 GitHub Actions 示例
如果你的项目托管在GitHub,配置起来非常简单:
# .github/workflows/unit-test.ymlname: Unit Testson: [push, pull_request]jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install dependencies run: sudo apt-get install -y cmake gcc - name: Build tests run: | cd test mkdir build && cd build cmake .. make - name: Run tests run: | cd test/build ctest --output-on-failure
每次push或提交PR时,GitHub Actions会自动编译并运行所有测试。测试不通过,PR会被标记为失败,团队成员一眼就能看到。
7.3 关键建议
- • 测试要快。 Host-based的单元测试通常在秒级完成,这是它最大的优势。如果你的测试跑了好几分钟,说明要么不是单元测试,要么测试写法有问题。
- • 测试要独立。 每个测试用例之间不应该有依赖关系,执行顺序不影响结果。Unity的
setUp()和tearDown()就是用来保证这一点的。 - • 失败要有明确信息。 使用Unity的断言宏,失败时会自动打印期望值、实际值和行号,方便定位问题。
八、嵌入式单元测试中的常见误区
在实践中,我见过不少团队在落地单元测试时踩坑。这里总结几个比较典型的误区。
8.1 "先把功能写完,测试以后再补"
这是最常见的想法,也是最致命的。代码写完再补测试,面临两个问题:一是代码结构可能根本不支持测试(硬件耦合太紧),二是你已经对代码的正确性有了主观信心,写测试的动力大幅下降。
正确的做法是:设计阶段就考虑可测试性,至少保证接口是可Mock的。 不要求先写测试(TDD在嵌入式场景下有时确实不太顺畅),但至少要做到代码写完之后能立刻写测试。
8.2 "我要测所有函数"
不需要。单元测试的精力应该集中在:
- • 业务逻辑层的核心算法(温度换算、PID计算、协议解析、状态机跳转等)
- • 出过bug的模块(线上出了问题,先补测试再修bug,防止回归)
- • 边界条件复杂的函数(各种临界值处理、错误码返回)
不值得测试的:
8.3 "Host上测过了就等于Target上没问题"
Host-based测试验证的是逻辑正确性,但它不能保证代码在目标平台上的运行时行为完全一致。以下差异需要注意:
应对策略:
- • 使用
stdint.h中的固定宽度类型(uint8_t、uint16_t等),避免裸int - • 对齐问题在代码中用
__attribute__((packed))或手动序列化来规避 - • 关键代码在Host测试通过后,仍需在Target上做集成验证
8.4 "覆盖率越高越好"
覆盖率是个参考指标,但不是目标。80%覆盖率和100%覆盖率之间的差距,往往需要花三倍以上的精力去弥补,而这些边边角角的代码可能永远不会出问题。
我在实际项目中的经验是:核心逻辑模块争取80%以上的行覆盖率,整体项目做到50%-60%的逻辑层覆盖率,就已经是一个不错的状态了。
九、总结:一张图串起来
最后,把嵌入式单元测试落地的关键环节串在一起:
嵌入式单元测试不是什么高深的事情,它的核心就是一个朴素的道理:把能在PC上验证的逻辑,就在PC上验证掉。 不需要等硬件就绪,不需要手动烧录,不需要拿着万用表量信号。
当然,单元测试不能替代在板上的集成测试和系统测试。它解决的是"逻辑对不对"的问题,而不是"硬件行不行"的问题。但在嵌入式项目中,相当大比例的bug本质上就是逻辑错误,而这些错误完全可以在PC端、在开发阶段、在几秒钟内被发现。
如果你还没有在项目中尝试单元测试,不妨从一个最简单的模块开始。找一个纯逻辑的函数,写三五个测试用例,体验一下"改了代码,跑一下测试,几秒钟就知道有没有问题"的感觉。一旦尝到甜头,你会想把更多的模块纳入进来。
说到嵌入式软件的架构设计,单元测试只是其中一个切面。代码能测试,前提是代码分层清晰、模块边界明确、依赖方向合理。如果你在项目中也遇到过模块耦合严重、协议和业务缠在一起、状态逻辑混乱、现场问题难定位这些困扰,推荐看看我整理的**《嵌入式软件架构实战合集》**。这个合集从分层设计、接口抽象、协议解耦、状态机、事件驱动、RTOS任务模型到日志诊断体系,都有结合实际项目的详细讲解,帮你建立一套可落地的嵌入式架构设计思路。
合集链接:《嵌入式软件架构实战合集》