
1. 为什么要把小车代码拆成 .c 和 .h刚接触单片机那会儿我写小车代码的习惯特别粗暴——所有东西全塞进一个main.c里。电机驱动、循迹逻辑、超声波测距、OLED 显示、PID 计算两千多行代码从头堆到尾。刚开始还能忍毕竟功能就那么点。可一旦要改个东西比如把电机控制从 L298N 换成 TB6612或者把循迹算法从简单的阈值判断升级成 PID问题就来了我得在两千行里翻来覆去地找改完一处还得担心有没有漏掉另一处编译一次等半天报错信息还经常指向一些莫名其妙的行号。后来有一次做电赛的板球控制系统代码量直接飙到四千行我彻底崩溃了。那天晚上我花了整整三个小时就为了找一个变量被谁改了——最后发现是在一个我早就忘了的if分支里。从那以后我就下定决心必须把代码拆开。把代码拆成.c和.h文件本质上就是模块化编程。你可以把它理解成搭积木每个功能模块电机、传感器、显示、算法都是一块独立的积木有自己的内部结构.c文件里的具体实现也有对外的接口.h文件里声明的函数和变量。别人要用这块积木只需要看接口说明书.h不需要关心里面是怎么实现的。这样做的好处非常实在。第一编译速度会快很多。Keil 默认是全部编译你改一个.c文件它只重新编译那一个文件然后链接一下就好了。我实测过一个四千行的项目全量编译要四十多秒拆成八个模块之后改一个模块再编译三秒搞定。第二代码可读性和可维护性大幅提升。你想找电机相关的代码直接打开motor.c就行了不用在几千行里大海捞针。第三方便复用。下次做新项目直接把motor.c和motor.h拷过去改改引脚定义就能用省下来的时间够你多调两遍 PID。这篇文章就是把我这些年踩过的坑、总结出来的经验从头到尾讲清楚。不管你是刚学 C 语言、还在用 Keil 写单片机的新手还是已经写过几个项目但代码还是一团乱麻的老手看完都能直接上手把自己的小车代码拆干净。2. 拆之前先想清楚模块怎么划分才合理2.1 按功能划分而不是按代码量划分很多人第一次拆分代码容易犯一个错误看main.c太长了就从中间一刀切前一半放a.c后一半放b.c。这种拆法比不拆还糟糕因为模块之间会有大量交叉引用最后变成一团乱麻。正确的做法是按功能划分。一辆典型的小车通常可以拆成这么几个模块模块名称职责典型文件电机驱动控制左右轮转速、方向motor.c/motor.h传感器循迹、避障、测距sensor.c/sensor.h显示OLED/LCD 显示状态display.c/display.h控制算法PID、状态机control.c/control.h通信串口、蓝牙、无线comm.c/comm.h系统配置时钟、中断、延时sys.c/sys.h每个模块只负责一件事模块之间通过.h文件里声明的接口来通信。比如control.c需要读传感器数据它不直接去操作 GPIO而是调用sensor.h里声明的Sensor_GetLineError()函数。这样control.c完全不需要知道传感器是怎么接的、用的是 ADC 还是数字 IO。2.2 头文件里放什么不放什么这是新手最容易搞混的地方。我见过太多人把函数实现直接写在.h里然后被多个.c文件包含编译时报一堆multiple definition错误。记住一个原则.h文件只放声明不放定义。具体来说函数声明void Motor_SetSpeed(int left, int right);宏定义#define MOTOR_MAX_SPEED 1000类型定义typedef struct { ... } MotorState;外部变量声明extern int g_motorSpeed;条件编译#ifndef __MOTOR_H这种防止重复包含的宏而.c文件里放的是函数的具体实现模块内部的私有变量用static修饰模块内部的私有函数也用static修饰这里有个关键点能用static就用static。模块内部的变量和函数如果不需要被外部访问一律加static。这样做有两个好处一是避免命名冲突你在motor.c里定义一个static int speed在sensor.c里也可以定义一个static int speed编译器不会报错二是编译器可以做更好的优化因为static函数不需要通过函数指针调用可以直接内联。2.3 模块之间的依赖关系要理清拆分之前建议你先在纸上画一张依赖图。哪个模块依赖哪个模块谁调用谁心里要有数。一个健康的依赖关系应该是单向的、分层的。比如main.c ├── control.c │ ├── sensor.c │ └── motor.c ├── display.c └── comm.cmain.c在最上层负责初始化和主循环调度。control.c依赖sensor.c和motor.c但sensor.c和motor.c之间不应该互相依赖。display.c和comm.c是独立的只被main.c调用。如果你发现两个模块互相依赖比如a.c调用了b.h里的函数b.c又调用了a.h里的函数那说明你的模块划分有问题需要重新调整。这种情况通常是因为你把某个功能拆得太细或者把不该放一起的东西硬塞进了一个模块。注意头文件里尽量不要#include其他头文件能用前置声明就用前置声明。比如control.h里如果需要用到MotorState类型可以在control.h里写typedef struct MotorState MotorState;然后在control.c里再#include motor.h。这样可以减少头文件之间的耦合加快编译速度。3. 手把手实操从单文件到多模块的完整过程3.1 先建好目录结构在 Keil 里我习惯这样组织目录Project/ ├── User/ │ ├── main.c │ └── main.h ├── Drivers/ │ ├── motor.c │ ├── motor.h │ ├── sensor.c │ ├── sensor.h │ ├── display.c │ └── display.h ├── Algorithm/ │ ├── pid.c │ ├── pid.h │ ├── filter.c │ └── filter.h └── System/ ├── sys.c ├── sys.h ├── delay.c └── delay.h在 Keil 的 Project 窗口里右键 Target选择 Manage Project Items然后按照上面的结构建 Group。每个 Group 里添加对应的.c文件。注意只需要添加.c文件.h文件不需要手动添加Keil 会自动根据#include路径去找。3.2 配置头文件搜索路径这一步很多人会漏掉导致编译时报 cannot open source input file 错误。在 Keil 里点击 Options for Target魔术棒图标切换到 C/C 选项卡在 Include Paths 里把你所有存放.h文件的目录都加进去。比如.\User .\Drivers .\Algorithm .\System每个路径一行或者用分号隔开。加完之后你在任何.c文件里都可以直接#include motor.h不需要写相对路径。实操心得我习惯把所有的头文件路径都加进去哪怕某个模块暂时用不到。这样以后新增模块的时候不用再回来改配置省事。另外路径最好用相对路径以.\开头这样项目拷到别的电脑上也能直接编译。3.3 写一个标准的头文件模板下面是我用了很多年的头文件模板你可以直接抄#ifndef __MOTOR_H #define __MOTOR_H #include sys.h /* 宏定义 */ #define MOTOR_PWM_MAX 1000 #define MOTOR_PWM_MIN 0 /* 类型定义 */ typedef enum { MOTOR_DIR_FORWARD 0, MOTOR_DIR_BACKWARD, MOTOR_DIR_STOP } MotorDir; typedef struct { int leftSpeed; int rightSpeed; MotorDir leftDir; MotorDir rightDir; } MotorState; /* 外部变量声明 */ extern MotorState g_motorState; /* 函数声明 */ void Motor_Init(void); void Motor_SetSpeed(int left, int right); void Motor_SetDir(MotorDir left, MotorDir right); void Motor_Stop(void); int Motor_GetLeftSpeed(void); int Motor_GetRightSpeed(void); #endif这个模板里有几个细节值得说第一#ifndef __MOTOR_H这一对宏是防止重复包含的。假如main.c里#include motor.h又#include control.h而control.h里也#include motor.h如果没有这个保护motor.h的内容就会被展开两次导致重复定义错误。加上这个保护之后第二次包含时编译器会发现__MOTOR_H已经定义了直接跳过。第二extern MotorState g_motorState;是外部变量声明。注意这里只是声明不是定义。真正的定义在motor.c里MotorState g_motorState;。这样其他模块可以通过g_motorState访问电机的状态但不会产生重复定义的链接错误。第三函数命名统一用模块名_动作的格式比如Motor_Init、Motor_SetSpeed。这样一看就知道这个函数属于哪个模块而且不容易和其他模块的函数重名。3.4 写对应的 .c 文件motor.c的结构一般是这样的#include motor.h /* 私有变量 */ static int s_leftPwm 0; static int s_rightPwm 0; /* 私有函数声明 */ static void Motor_SetPwm(int channel, int pwm); /* 全局变量定义 */ MotorState g_motorState; /* 函数实现 */ void Motor_Init(void) { /* 初始化 GPIO 和定时器 PWM */ GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; /* ... 具体的初始化代码 ... */ s_leftPwm 0; s_rightPwm 0; g_motorState.leftSpeed 0; g_motorState.rightSpeed 0; g_motorState.leftDir MOTOR_DIR_STOP; g_motorState.rightDir MOTOR_DIR_STOP; } void Motor_SetSpeed(int left, int right) { /* 限幅 */ if (left MOTOR_PWM_MAX) left MOTOR_PWM_MAX; if (left MOTOR_PWM_MIN) left MOTOR_PWM_MIN; if (right MOTOR_PWM_MAX) right MOTOR_PWM_MAX; if (right MOTOR_PWM_MIN) right MOTOR_PWM_MIN; s_leftPwm left; s_rightPwm right; Motor_SetPwm(1, left); Motor_SetPwm(2, right); g_motorState.leftSpeed left; g_motorState.rightSpeed right; } static void Motor_SetPwm(int channel, int pwm) { /* 具体的 PWM 寄存器操作 */ if (channel 1) { TIM_SetCompare1(TIM3, pwm); } else { TIM_SetCompare2(TIM3, pwm); } }注意Motor_SetPwm前面加了static说明它是模块私有函数外部模块看不到也调不到。这样你以后想改 PWM 的实现方式只要改motor.c就行了不会影响其他模块。3.5 在 main.c 里调用main.c就变得非常清爽了#include main.h #include motor.h #include sensor.h #include display.h #include control.h int main(void) { /* 系统初始化 */ Sys_Init(); Delay_Init(); /* 模块初始化 */ Motor_Init(); Sensor_Init(); Display_Init(); Control_Init(); /* 主循环 */ while (1) { Sensor_Update(); Control_Update(); Display_Update(); Delay_ms(10); } }你看main.c里完全看不到任何 GPIO 操作、PWM 寄存器配置、I2C 时序这些底层细节。它只负责调度具体的事情交给各个模块去做。这就是模块化编程的威力。4. 编译链接那些坑从报错到跑通4.1 最常见的三个编译错误错误一undefined symbol这个错误的意思是找不到符号的定义。通常是因为你声明了一个函数但没有实现它或者实现了但没加到 Keil 的工程里。排查方法在 Keil 的 Build Output 窗口里找到报错的符号名然后在整个工程里搜索这个符号。如果只在.h里找到了声明没有在.c里找到实现那就是漏写了。如果.c里明明有实现那就是这个.c文件没有被添加到 Keil 的 Group 里。错误二multiple definition这个错误的意思是重复定义。通常是因为你把变量的定义写在了.h文件里然后被多个.c文件包含。比如你在motor.h里写了int g_motorSpeed;然后main.c和control.c都包含了motor.h链接时就会报multiple definition of g_motorSpeed。正确的做法是在.h里写extern int g_motorSpeed;在.c里写int g_motorSpeed;。错误三cannot open source input file xxx.h这个错误的意思是找不到头文件。通常是因为 Include Paths 没配置对或者头文件名拼错了。排查方法先确认头文件确实存在于你配置的某个 Include Path 目录下。然后检查#include语句里的文件名大小写是否一致。Windows 下文件名不区分大小写但有些编译器比如 GCC是区分的如果你以后要把代码移植到 Linux 下编译大小写不一致就会出问题。4.2 链接脚本和内存布局当你把代码拆成多个.c文件后链接器需要把这些文件生成的.o目标文件合并成一个可执行文件。这个过程涉及到内存布局的问题。Keil 默认会使用芯片厂商提供的链接脚本.sct文件把代码放在 Flash 里变量放在 RAM 里。一般情况下你不需要改这个脚本但如果你用了大量的const常量数组比如字库、图片数据可能会遇到 Flash 不够用的情况。这时候你可以把一些不常访问的常量放到外部 Flash 里或者用__attribute__((section(...)))把特定数据放到指定的段里。不过对于小车项目来说通常不会遇到这个问题STM32F103C8T6 有 64KB Flash 和 20KB RAM拆成模块后代码体积反而会小一些因为链接器可以去掉未使用的函数。实操心得我习惯在每次拆分完一个模块后立刻编译一次确保没有错误再继续拆下一个。不要一次性把所有代码都拆完再编译那样一旦报错你很难定位是哪个模块的问题。另外Keil 的 Build 按钮是全量编译Rebuild 是重新编译所有文件平时用 Build 就行它会自动判断哪些文件需要重新编译。4.3 用 Git 管理你的代码拆成多个文件后代码的变更会变得更频繁这时候强烈建议用 Git 做版本管理。你不需要把整个 Keil 工程都提交上去只需要提交.c和.h文件以及 Keil 的工程文件.uvprojx。在项目根目录下建一个.gitignore文件把编译生成的中间文件排除掉*.o *.d *.crf *.axf *.htm *.lnp *.map *.lst *.build_log.htm Objects/ Listings/这样你的仓库会很干净每次提交的 diff 也很清晰。我习惯每完成一个功能模块就提交一次commit message 写清楚改了什么比如 add motor module with PWM control 或者 fix sensor I2C timeout issue。以后出问题了可以随时回退到之前的版本。5. 进阶技巧让模块化更彻底5.1 用回调函数解耦模块有时候两个模块之间确实需要通信但又不想让它们互相依赖。这时候可以用回调函数。比如sensor.c检测到障碍物后需要通知control.c紧急停车。但sensor.c不应该直接调用control.c的函数因为那样就产生了依赖。可以在sensor.h里定义一个回调函数指针类型typedef void (*SensorCallback)(int sensorId, int value); void Sensor_RegisterCallback(SensorCallback cb);然后在control.c里实现一个回调函数通过Sensor_RegisterCallback注册进去。这样sensor.c只知道有一个回调函数要调用不知道具体是谁实现的。control.c也只依赖sensor.h里的接口不依赖sensor.c的实现。这种设计在大型项目中非常常见也是嵌入式软件架构的基本功。刚开始可能觉得有点绕但用习惯了会发现非常灵活。5.2 条件编译做功能裁剪同一个代码库可能需要适配不同的硬件版本。比如你的小车有普通版和 Pro 版Pro 版多了超声波模块和 OLED 屏幕。这时候可以用条件编译/* 在 sys.h 里定义 */ #define HW_VERSION_PRO 1 // #define HW_VERSION_STD 1 /* 在 main.c 里 */ #if defined(HW_VERSION_PRO) #include ultrasonic.h #include oled.h #endif然后在 Keil 的 Options for Target - C/C - Define 里填入HW_VERSION_PRO编译时就会自动包含 Pro 版的代码。这样一套代码可以同时维护两个硬件版本不用复制两份工程。5.3 用 sizeof 计算数组长度拆分模块后经常需要在.h里定义一些常量数组然后在.c里遍历它们。这时候sizeof就派上用场了。/* 在 motor.h 里 */ #define MOTOR_CHANNEL_NUM 4 extern const int g_motorPins[MOTOR_CHANNEL_NUM]; /* 在 motor.c 里 */ const int g_motorPins[MOTOR_CHANNEL_NUM] {PA0, PA1, PA2, PA3}; void Motor_InitAll(void) { for (int i 0; i MOTOR_CHANNEL_NUM; i) { /* 初始化 g_motorPins[i] */ } }注意sizeof是在编译时计算的所以sizeof(g_motorPins) / sizeof(g_motorPins[0])也能得到数组长度。但如果你把数组作为参数传给函数sizeof就会退化成指针大小这时候就必须用MOTOR_CHANNEL_NUM这个宏了。注意sizeof是 C 语言的关键字不是函数不需要包含任何头文件。很多人以为sizeof需要#include stddef.h或者stdio.h其实不需要。但size_t类型是在stddef.h里定义的如果你要声明一个size_t变量就需要包含这个头文件。6. 常见问题速查与避坑指南6.1 编译报错速查表错误信息可能原因解决方法undefined symbol Motor_Init函数未实现或 .c 未加入工程检查 motor.c 是否在 Keil Group 里multiple definition of g_speed变量定义写在 .h 里改为 extern 声明定义放 .ccannot open source input file motor.hInclude Path 未配置在 Options - C/C 里添加路径implicit declaration of function函数未声明就调用在 .h 里添加函数声明incomplete type is not allowed结构体定义不完整确保 .h 里包含了完整的类型定义L6218E: Undefined symbol链接时找不到符号检查是否漏加了某个 .c 文件6.2 我踩过的三个大坑坑一头文件里定义变量刚开始拆分代码时我在motor.h里写了int g_motorSpeed 0;然后main.c和control.c都包含了这个头文件。编译时直接报了几十个multiple definition错误。后来才知道.h里只能写extern int g_motorSpeed;真正的定义要放在.c里。坑二循环包含有一次我把motor.h和control.h互相包含了。motor.h里#include control.hcontrol.h里又#include motor.h。结果编译器陷入了无限循环报了一堆莫名其妙的错误。后来用前置声明解决了在motor.h里写typedef struct ControlState ControlState;然后在motor.c里再#include control.h。坑三static 函数未使用警告拆分模块后有些static函数暂时没用到编译器会报warning: unused function。虽然不影响编译但看着很烦。后来我养成了习惯暂时不用的函数先注释掉或者用(void)funcName;显式忽略。不过更好的做法是直接删掉等需要的时候再从 Git 历史里找回来。6.3 模块化之后的调试技巧拆成多个模块后调试方式也要相应调整。以前在单文件里你可以直接printf打印变量。现在变量在别的模块里可能是static的你访问不到。这时候有两个办法。一是用调试器Keil 的 Debug 模式在 Watch 窗口里直接输入变量名。但static变量在 Watch 窗口里可能看不到你需要在motor.c里临时加一个非 static 的 getter 函数int Motor_GetLeftPwm(void) { return s_leftPwm; }二是用串口打印。在每个模块里实现一个XXX_DebugPrint()函数把模块内部的关键变量打印出来。这样既不影响模块的封装性又方便调试。实操心得我习惯在sys.h里定义一个DEBUG_EN宏然后在各个模块里用#if DEBUG_EN包住调试打印代码。发布版本把DEBUG_EN设为 0所有调试代码自动消失不会占用 Flash 空间。7. 拆完之后代码结构对比与长期维护7.1 拆分前后的直观对比我拿之前那个四千行的板球控制系统做了个对比指标拆分前拆分后文件数量1 个 .c12 个 .c 12 个 .h单文件最大行数4200 行380 行全量编译时间42 秒38 秒增量编译时间42 秒3 秒找某个功能的耗时平均 2 分钟平均 10 秒复用代码的难度几乎不可能直接拷贝模块增量编译时间从 42 秒降到 3 秒这个提升是最明显的。以前改一行代码要等四十多秒才能看到结果现在三秒就好了调试效率至少翻了一倍。7.2 长期维护的建议代码拆分不是一劳永逸的事情。随着项目迭代模块的职责可能会发生变化这时候需要及时调整。我的建议是每完成一个阶段性功能就花十分钟回顾一下代码结构。看看有没有哪个模块变得太臃肿了超过 500 行有没有哪个模块的职责变得模糊了有没有新的功能可以独立成模块。及时调整不要等到代码又变成一团乱麻才想起来重构。另外给每个模块写一个简短的注释说明它的职责和对外接口。不需要很详细几行字就行。比如在motor.h开头写/** * motor.h - 电机驱动模块 * * 职责控制左右轮的转速和方向 * 依赖sys.hGPIO 和定时器 * 接口Motor_Init / Motor_SetSpeed / Motor_Stop */这样别人拿到你的代码看一眼就知道这个模块是干什么的不用去读实现。7.3 从模块化到分层架构当你把模块化玩熟了之后可以进一步尝试分层架构。把代码分成三层硬件抽象层HAL直接操作寄存器的代码比如 GPIO、定时器、I2C 的底层驱动功能模块层基于 HAL 实现的具体功能比如电机控制、传感器读取应用层业务逻辑比如循迹算法、状态机这样分层之后换芯片平台只需要重写 HAL 层上面的功能模块和应用层完全不用动。我有个项目从 STM32F103 换到 STM32F407只花了一个下午就移植完了就是因为 HAL 层隔离得好。当然分层架构对于小车项目来说可能有点过度设计。但如果你打算长期做嵌入式开发早点养成这个思维习惯没坏处。从简单的模块化开始慢慢过渡到分层架构这是一个很自然的成长路径。最后分享一个我个人的小习惯每次新建一个模块我会先写.h文件把接口定义好然后再写.c文件去实现。这样强迫自己先想清楚模块的职责和对外接口而不是写到哪算哪。这个习惯帮我避免了很多次写到一半发现接口设计不合理又得回头改的情况。