2.3 单一职责——一根梁只承重
一根钢梁的使命
工地上的塔吊正把一根H型钢梁缓缓吊上第五层。这根梁长8米,截面高度400毫米,结构工程师计算过,它要在未来50年里承担上面15层楼板传导下来的恒荷载和活荷载。梁的两端落在混凝土柱的牛腿上,螺栓紧固。就这么一件事:承载。
你顺着这根梁走过去,发现它腹板上焊了一排吊架,吊架上挂着消防水管。再往前走,腹板的另一侧焊了几个支架,上面绑着强电桥架。快到梁端的时候,你甚至看到两根PVC弱电管也从梁腹板的开孔里穿过去。
这不是结构工程师设计的。这是施工队到了现场,发现“诶这根梁刚好在这里,顺便用它走一下管线省事“。
你现在是一个理性的观察者。你觉得这根梁还能正常工作吗?
也许能。也许暂时能。但结构工程师在计算这根梁的截面时,假设它只承受设计荷载。消防水管充满水之后的额外重量、桥架和电缆的重量、梁腹板开孔导致的截面削弱,这些都没有进入结构计算。没有人知道这根梁现在的安全系数是多少。地震来了,或者上面加建了一层,你还能睡得着觉吗?
更关键的问题是:两年后,物业要换消防水管。 工人把旧水管从梁上拆下来,拆除过程中电锤碰歪了一个桥架支架,支架带着电缆往下沉,导致梁承受了一个设计之外的偏心扭矩,没有人能在事前计算这个扭矩有多大。
这就是一根梁承担了多重职责的代价。它不会立刻倒塌,但会丧失可计算性。一个系统的行为变得不可预测,根源在于你不知道它在承受多少种类的荷载。
核心洞察:多重职责的代价是丧失可计算性。 梁承担了水管、桥架、线管后不会立刻倒塌,但它的受力状态变得不可预测,因为没有人知道它在承受多少种荷载;代码亦然——一个模块混入多个变化源,行为就变得不可计算。
单一职责原则:一个模块只有一个理由会改变
在软件工程里,单一职责原则(Single Responsibility Principle, SRP)的经典表述是:“一个模块应该有且只有一个理由会改变。”
用建筑的语言翻译:一根梁只承载。如果你要让水管穿过它,水管变成另一个独立系统,梁只是提供穿过许可。穿过的位置、孔径、补强方式都经过结构计算,且管线的任何变化(换水管、加管线)都不会改变梁的受力状态。
再用C代码翻译:一个 .c 文件只做一件事。如果它同时做两件会因为不同原因而改变的事,它就违反了SRP。
我们来看一个在汽车嵌入式项目中每天都在发生的场景。
核心洞察:“一个模块有且只有一个理由会改变”——理由即变化原因。 用建筑的语言,一根梁只承载,管线穿过它必须独立成系统、并经结构计算;用C的语言,一个
.c文件只做一件事,两件因不同原因而改变的事塞进一个文件,就违反了SRP。
一个文件,四个职责
假设你接手了一个电机控制ECU的维护工作。前任工程师留给你一个叫 can_handler.c 的文件,2800行。它的功能(注意我用词是“它的功能“,不是“它的函数“):
/* can_handler.c — 这根本不是一个"handler",它是整个系统 */
void CAN0_RX_IRQHandler(void) { /* 职责1:CAN报文接收 */
uint32_t id = CAN0->RDID;
uint8_t dlc = CAN0->RDLR & 0x0F;
for (int i = 0; i < dlc; i++) {
rx_buffer[i] = CAN0->RDFIFO[i]; /* 直接读FIFO寄存器 */
}
/* 职责2:报文解析——混在中断里做! */
uint16_t motor_cmd = (rx_buffer[0] << 8) | rx_buffer[1];
float target_speed = motor_cmd * 0.1f;
/* 职责3:数据存储 */
nvm_write_queue[queue_idx].target_speed = target_speed;
nvm_write_queue[queue_idx].timestamp_ms = system_tick;
/* 职责4:响应发送 */
uint8_t ack_data[8] = {0x02, motor_cmd >> 8, motor_cmd & 0xFF};
CAN0_Send(0x201, ack_data, 3);
}
你一眼就能看出问题:
- CAN报文接收(职责1):直接操作寄存器。当芯片从NXP的S32K换到ST的SPC58,这一段要重写。
- 报文解析(职责2):把原始字节拼成
motor_cmd。当CAN矩阵从V2.3升级到V3.0,信号的起始位和长度变了,这一段要重写。 - 数据存储(职责3):把目标转速写入NVM队列。当客户的存储需求从“每秒存一次“改成“每100ms存一次“,这一段要重写。
- 应答发送(职责4):构造并发送ACK报文。当ACK报文的格式从标准CAN升级到CAN FD,这一段要重写。
这四件事有四个完全不同的变化原因,却住在同一个文件的同一个中断服务函数里。
现在的任务是:芯片缺货了,CAN控制器必须从A芯片换成B芯片。你需要修改CAN报文接收的代码。你打开 can_handler.c,在2800行代码里小心翼翼地搜索所有直接访问CAN寄存器的位置(CAN0->),每改一处都要确保不影响旁边的解析、存储、应答逻辑。最可怕的是,你没法单独测试“CAN报文接收“是否正常工作,因为它和解析、存储、应答的代码是紧紧耦合在一起的。
这就是一根钢梁同时走水管、走桥架、穿线管的后果:换某个子系统需要动整根梁。
核心洞察:四个变化原因住进同一个文件,换任何一个子系统都要动整根梁。 CAN接收、解析、存储、应答各有独立的变化源(换芯片、CAN矩阵升级、存储需求、CAN FD),却耦合在同一中断里,导致无法单独测试、改动极易波及——这是“一根钢梁同时走水管“的软件版。
拆分:四根专用的梁
我们把同一个功能拆成四个模块:
/* can_driver.h — 职责1:只负责收发CAN报文 */
Can_Status_t Can_Init(uint32_t baud);
Can_Status_t Can_Send(uint32_t id, const uint8_t *data, uint8_t len);
Can_Status_t Can_Receive(uint32_t *id, uint8_t *data, uint8_t *len);
/* can_parser.h — 职责2:只负责解析CAN报文中的信号 */
typedef struct {
uint16_t motor_cmd; /* 从CAN报文中提取的电机指令 */
uint8_t motor_enable; /* 从CAN报文中提取的使能信号 */
uint8_t checksum_ok; /* 校验结果 */
} CanParsedData_t;
CanParsedData_t CanParser_Parse(const uint8_t *raw_data, uint8_t len);
/* nvm_logger.h — 职责3:只负责记录数据到NVM */
typedef enum { NVM_OK, NVM_QUEUE_FULL, NVM_WRITE_FAILED } NvmStatus_t;
NvmStatus_t NvmLogger_RecordMotorCmd(uint16_t motor_cmd, uint32_t timestamp);
/* can_responder.h — 职责4:只负责构造并发送ACK报文 */
void CanResponder_SendAck(uint16_t motor_cmd);
现在,当中断发生时:
void CAN0_RX_IRQHandler(void) {
uint8_t raw_data[8];
uint32_t id;
uint8_t len;
/* 职责1:只接收 */
if (Can_Receive(&id, raw_data, &len) != CAN_OK) return;
/* 职责2:只解析 */
CanParsedData_t parsed = CanParser_Parse(raw_data, len);
if (!parsed.checksum_ok) return;
/* 职责3:只记录 */
NvmLogger_RecordMotorCmd(parsed.motor_cmd, system_tick);
/* 职责4:只应答 */
CanResponder_SendAck(parsed.motor_cmd);
}
现在芯片缺货换CAN控制器?你只需要重写 can_driver.c,另外三个文件纹丝不动。CAN矩阵升级?你只改 can_parser.c。ACK报文升级CAN FD?你只改 can_responder.c。存储频率调整?你只改 nvm_logger.c。
你会说:“这比原来的代码多了三个文件、三个头文件,函数调用也多了三层:开销增加了吧?”
是的。但你想一想:你后面两年在这四个文件上分别做的修改,如果全部挤在一个2800行的文件里,每次修改你需要花多少时间理解代码、防止波及、手动回归测试?这些时间的总和,远超多几层函数调用的几个微秒。更何况,你那颗Cortex-R5跑在600MHz,一次函数调用的开销大约是0.05微秒。而一个软件工程师的工时成本,你比我清楚。
核心洞察:拆分把“改一处要验证全系统“变成“改哪层只动哪层“。 换芯片只重写
can_driver.c,矩阵升级只改can_parser.c;代价是多几层函数调用(在Cortex-R5上约0.05微秒),收益是两年里每次修改省下的理解、波及与回归时间,远超那几微秒。
“一个理由会改变“的测试方法
怎么判断一个模块是否违反SRP?用这个测试:用“部门提出了新需求,所以我们需要修改.c“来造句。如果一句话里出现了两个截然不同的部门,你的模块就违反了SRP。
回到刚才的例子:
- “采购部门说芯片缺货了,所以我们需要修改can_driver.c。“合理。一个文件,一个变化原因。
- “系统部门升级了CAN矩阵到V3.0,所以我们需要修改can_parser.c。“合理。
- “标定部门要求把存储频率从1秒改成100毫秒,所以我们需要修改nvm_logger.c。“合理。
但如果你的代码没拆分:
- “采购部门说芯片缺货了,所以我们修改can_handler.c里所有的
CAN0->;哦对了还要顺便检查一下CAN报文解析那里有没有用到和CAN0寄存器有关的东西;还有NVM写入那里用的是CAN的中断时间戳吗?等一下,ACK发送也要确认一下在新的芯片上CAN发送函数签名有没有变…”
你感受到“一个文件四个业主“的痛苦了吗?这根梁上挂了消防水管(归消防部门管)、强电桥架(归电气部门管)、弱电管线(归弱电部门管)。三个部门各有一个改造计划,无论谁先动手,都要在梁身上打主意。
核心洞察:用“哪个部门提出需求“造句,一句话出现两个部门就违反了SRP。 采购部门→改can_driver、系统部门→改can_parser、标定部门→改nvm_logger,各对一档;而“修改can_handler.c“一句话要同时安抚三个业主,这就是一个文件四个业主的痛苦。
AUTOSAR里的单一职责
AUTOSAR把SRP执行到了极致。你能在AUTOSAR里找到 Can 模块(负责CAN报文收发)、CanIf 模块(CAN接口,负责报文路由)、CanTp 模块(CAN传输协议,负责多帧拆分与重组)、PduR 模块(PDU路由器,负责把报文从CAN路由到诊断)、Dcm 模块(诊断通信管理,负责解析UDS请求)。
有人会觉得这是“过度设计“:“不就是收一个诊断报文吗,为什么要经过五个模块?”
因为汽车行业面对的变化维度太多。你今天的ECU用CAN,明天可能换成CAN FD,后天可能换成以太网。CanTp 需要改。但 PduR 和 Dcm 不用改,它们的接口是PDU,不管PDU是从CAN来的还是从以太网来的。如果当初把CAN收发包和UDS解析写在一个模块里,物理层一变,整个诊断栈都要重写。
AUTOSAR不是为了让你“方便理解“而设计的,它是为了让你在二十年里面对无数次硬件换代、协议升级、OEM定制,仍然能保持系统的稳定边界而设计的。那些看起来“多此一举“的模块拆分,是在为一个你还没想到的变化预留安全边际。
核心洞察:AUTOSAR把SRP执行到极致,是为二十年里的硬件换代与协议升级预留安全边际。 一个诊断报文经过Can/CanIf/CanTp/PduR/Dcm五层,每层只回应自己的变化维度(物理层、传输层、路由、解析互不牵连);“看起来多此一举“的拆分,是在为还没想到的变化买保险。
本篇小结
- 单一职责原则要求每个模块有且只有一个理由会改变。
- 职责混在一起的风险:在一个嵌入式C文件中混入CAN报文接收、解析、存储、应答四个职责,意味着四个变化源共享同一段代码,任一变化都可能导致其他功能受损。
- 拆分后的收益:每个模块独立响应自己的变化源,互不干扰。
- 测试标准:“一个理由会改变“的测试标准,是变化原因的独立性。
- AUTOSAR的诊断栈:用五个模块处理一个诊断请求,是为了在硬件换代和协议升级中保护系统边界。
【下集预告】:你把模块拆好了,每个模块职责单一。现在问题来了:一个模块怎么获取它需要的外部能力?是自己造,还是别人给?依赖注入告诉你:管道不自己钻井取水。它在Cortex-R5上用函数指针表和静态配置结构实现,不需要任何框架。