从C++结构体、类到 PID 控制器:运动控制初学者如何理解 C++ 工程代码
目录前言一、为什么初学 C 工程时容易混乱1.1 能把数据和行为组织清楚1.2 能被多个模块重复使用1.3 能适应实际工程的开发习惯二、在工程中struct 和 class 该如何选2.1 struct 更适合“纯数据”2.2 class 更适合“带行为的对象”2.3 工程中的经验判断标准1适合用 struct 的场景2适合用 class 的场景三、.h 和 .cpp 分别负责什么3.1 .h 是“声明”相当于接口说明书3.2 .cpp 是“实现”相当于真正干活的地方3.3 为什么工程里要分开写1方便复用2有利于维护3有利于隐藏细节4更符合工程编译方式四、.h 和 .cpp 之间到底是什么关系4.1 头文件声明“我有什么”4.2 源文件实现“我怎么做”4.3 外部文件只需要包含 .h五、什么是类public 和 private 又是什么意思5.1 类可以理解为一个“控制器模板”5.2 public 表示对外公开的接口5.3 private 表示类内部状态六、PID 类中的成员变量和成员函数分别是什么6.1 成员变量负责存储状态1PID 参数2误差相关状态3限幅参数6.2 成员函数负责执行动作七、构造函数到底是什么为什么名字必须和类名一样7.1 构造函数的定义1函数名必须和类名一样2构造函数没有返回值7.2 构造函数的作用7.3 为什么工程里最好手动写构造函数八、什么是作用域解析符 ::什么是初始化列表8.1 :: 是作用域解析符8.2 : 后面的部分叫初始化列表8.3 这一段在 PID 工程中的真实意义九、#ifndef、#define、#endif 是什么意思9.1 它的作用是什么9.2 逐句理解1#ifndef PID_CONTROLLER_H2#define PID_CONTROLLER_H3#endif9.3 为什么必须加头文件保护十、适合小车底盘控制C 的 PID 类该怎么写10.1 头文件PidController.h10.2 源文件PidController.cpp十一、PID 控制器在小车底盘中的典型用法11.1 初始化阶段11.2 控制循环阶段十二、总结前言在刚接触 C 工程开发时很多人都会被几个问题困住什么情况下该用struct什么情况下该用class.h和.cpp到底有什么区别类里的public、private、构造函数、作用域解析符::又分别是什么意思如果自己是做运动控制算法的例如做小车底盘速度控制那么一个工程里常见的 PID 类该怎么写这些问题看起来分散实际上都围绕着一个核心如何用工程化的方式组织 C 代码。本文就结合运动控制场景从struct、class、头文件与源文件关系一直讲到一个适合小车底盘控制的 PID 控制器实现帮助初学者把这些概念真正串起来。本文内容基于用户整理的学习笔记展开。一、为什么初学 C 工程时容易混乱对于刚从算法、控制理论或者单片机逻辑过渡到 C 工程开发的人来说常见的困惑并不是公式本身而是代码结构。一方面初学者往往已经知道 PID 的比例、积分、微分公式也能写出简单的控制逻辑但另一方面一旦看到工程里的.h、.cpp、class、构造函数、成员变量这些写法就会感觉代码突然“复杂了很多”。实际上这并不是算法变难了而是因为工程代码不仅要“能算”还要“好维护、好复用、好扩展”。从工程视角看一个好的控制模块通常需要做到以下几点1.1 能把数据和行为组织清楚例如PID 控制器不仅仅包含kp、ki、kd这几个参数还需要保存误差、积分累加值、输出限幅等内部状态。如果这些量全部散落在全局变量里代码虽然也能运行但很快就会变得难以维护。1.2 能被多个模块重复使用在小车底盘控制中左轮和右轮通常都需要 PID如果是四轮车、麦克纳姆轮底盘或舵轮平台则往往需要更多控制器对象。这就要求 PID模块具有良好的复用性而不是在每个文件里都重新写一遍。1.3 能适应实际工程的开发习惯真正的工程项目里很少把所有代码都塞进一个文件中。更常见的做法是用头文件.h声明接口用源文件.cpp实现逻辑用类class封装控制器状态用对象实例化具体控制模块用公开接口给外部调用用私有成员保护内部状态。因此要真正看懂 PID 工程代码就必须先把这些基础概念理顺。二、在工程中struct 和 class 该如何选在 C / C 项目中struct和class本质上非常接近。尤其是在 C 里两者最直接的区别其实只有一个默认访问权限不同。struct默认是public而class默认是private。但是在实际工程里大家通常不会只从语法层面去区分而是会遵循一套更常见的使用习惯。2.1 struct 更适合“纯数据”当一个对象只是用来打包一些变量而不承担太多逻辑时通常更适合使用struct。例如坐标点、配置项、协议数据包、消息体这类内容本质上只是“一组数据的集合”并不需要复杂的封装和继承机制。此时用struct会显得非常自然。struct Point { int x; int y; };这种写法的特点是简单、直接外部模块可以方便地读取和赋值。2.2 class 更适合“带行为的对象”如果一个对象不仅有数据还需要封装逻辑、控制访问权限、提供成员函数甚至未来还可能涉及继承和多态那么更适合使用class。例如PID 控制器就是典型的“带行为对象”。它不仅有kp、ki、kd等参数还要具备设置参数、计算输出、重置状态等功能。class Player { private: int hp; public: void attack(); void heal(); };2.3 工程中的经验判断标准在实际开发中可以用一句很实用的话来判断只存数据用 struct需要封装逻辑和状态管理用 class。进一步细化可以总结为1适合用 struct 的场景配置参数坐标、速度、姿态等简单数据网络报文、串口协议结构DTO、消息体、传参对象2适合用 class 的场景PID 控制器电机驱动模块底盘运动学/逆运动学模块轨迹跟踪器状态机、调度器、管理器对于运动控制算法工程师来说工程中最常见的习惯就是数据结构用 struct控制模块和逻辑模块用 class。三、.h 和 .cpp 分别负责什么很多初学者第一次看到 C 工程代码时最容易疑惑的就是为什么一个类要拆成.h和.cpp两个文件实际上这种拆分正是 C 工程化开发的基本习惯。3.1 .h 是“声明”相当于接口说明书头文件.h的主要作用是告诉编译器这里有哪些类、函数、结构体、变量可以用。也就是说头文件通常负责写类的定义成员函数声明结构体定义宏定义外部变量声明例如class PidController { public: void setParam(double kp, double ki, double kd); double calculate(double target, double feedback, double dt); };这里并没有写出函数内部具体怎么运行而只是告诉外部“这个类里有这些函数你可以调用它们”。3.2 .cpp 是“实现”相当于真正干活的地方源文件.cpp负责写函数内部的具体逻辑即程序到底如何执行。例如double PidController::calculate(double target, double feedback, double dt) { double err target - feedback; return err; }这里才是函数真正执行计算的地方。3.3 为什么工程里要分开写之所以要拆分主要有以下几个原因。1方便复用其他文件只需要#include PidController.h就知道如何调用这个类而不需要关心实现细节。2有利于维护头文件主要描述接口源文件主要描述实现。接口稳定后实现可以单独修改不影响外部调用方式。3有利于隐藏细节使用者只需要知道“怎么用”不一定需要看到“里面怎么写”。这种方式有助于模块封装。4更符合工程编译方式C 项目通常是多个.cpp文件分别编译再统一链接成最终程序。如果不区分声明与实现很容易产生重复定义等问题。四、.h 和 .cpp 之间到底是什么关系头文件和源文件不是彼此独立的两份代码而是共同构成一个完整模块的两个部分。4.1 头文件声明“我有什么”以 PID 控制器为例头文件中会写出类名、接口函数和成员变量class PidController { public: PidController(); void setParam(double kp, double ki, double kd); double calculate(double target, double feedback, double dt); private: double kp_; double ki_; double kd_; };4.2 源文件实现“我怎么做”随后在.cpp中写出这些函数的具体实现#include PidController.h PidController::PidController() { kp_ 0; ki_ 0; kd_ 0; } void PidController::setParam(double kp, double ki, double kd) { kp_ kp; ki_ ki; kd_ kd; }4.3 外部文件只需要包含 .h例如在main.cpp、motor.cpp或control.cpp中只需要包含头文件即可使用该模块#include PidController.h PidController pid;从这个角度看.h和.cpp的关系可以概括为头文件对外公开接口源文件对内实现逻辑。五、什么是类public 和 private 又是什么意思当我们写下如下代码时class PidController { public: PidController(); void setParam(double kp, double ki, double kd); void setOutputLimit(double min, double max); void setIntegralLimit(double max); double calculate(double target, double feedback, double dt); void reset(); private: double kp_; double ki_; double kd_; double err_; double err_last_; double integral_; double out_min_; double out_max_; double integral_max_; };这其实就是在定义一个名为PidController的类。5.1 类可以理解为一个“控制器模板”你可以把类理解成一个模板或者一个用于创建对象的模型。当我们后面写PidController pid;就是根据这个模板创建了一个具体对象pid。5.2 public 表示对外公开的接口public下面写的函数外部都可以直接调用。例如pid.setParam(10, 1, 0.1); pid.calculate(target, feedback, dt); pid.reset();5.3 private 表示类内部状态private下面的成员变量外部通常不能直接访问只能通过类提供的接口来间接使用。例如PID 的误差、积分项、参数限幅等都属于控制器内部状态。如果这些数据随便暴露给外部读写就很容易造成逻辑混乱。因此更合理的方式是把它们放在private中进行封装。六、PID 类中的成员变量和成员函数分别是什么在一个类中通常既有“数据”也有“动作”。6.1 成员变量负责存储状态在 PID 控制器中常见的成员变量包括double kp_; double ki_; double kd_; double err_; double err_last_; double integral_; double out_min_; double out_max_; double integral_max_;这些量分别表示1PID 参数kp_比例系数ki_积分系数kd_微分系数2误差相关状态err_当前误差err_last_上一时刻误差integral_误差积分累加值3限幅参数out_min_、out_max_输出限幅integral_max_积分限幅这些变量都属于 PID 类的内部状态所以被称为成员变量。6.2 成员函数负责执行动作PID 类中的成员函数通常包括PidController()构造函数setParam()设置 PID 参数setOutputLimit()设置输出限幅setIntegralLimit()设置积分限幅calculate()计算一次控制输出reset()清零历史状态可以简单理解为不带括号、用于存数据的是成员变量带括号、用于做动作的是成员函数。七、构造函数到底是什么为什么名字必须和类名一样构造函数是初学 C 时必须掌握的概念之一。7.1 构造函数的定义构造函数有两个非常重要的特点1函数名必须和类名一样例如类名叫PidController那么构造函数就必须叫class PidController { public: PidController();2构造函数没有返回值前面不能写void也不能写int或double。只要写了返回值类型它就不再是构造函数了。7.2 构造函数的作用构造函数会在对象创建时自动调用用于初始化对象内部状态。例如PidController pid;这一行执行时系统就会自动调用PidController()。7.3 为什么工程里最好手动写构造函数虽然 C 在某些情况下会自动生成默认构造函数但在工程开发中尤其是运动控制这种对初始化非常敏感的场景里通常都建议手动写构造函数。原因很简单如果误差、积分项、限幅等变量没有正确初始化那么控制器一开始就可能带着“随机值”工作从而导致输出异常。八、什么是作用域解析符 ::什么是初始化列表很多人第一次看到下面这段代码时会觉得陌生PidController::PidController() : kp_(0), ki_(0), kd_(0) , err_(0), err_last_(0) , integral_(0) , out_min_(-100), out_max_(100) , integral_max_(100) { }实际上这段代码只是在实现构造函数并初始化成员变量。8.1 :: 是作用域解析符A::B可以理解成B 是 A 这个范围里的东西。在类的实现中PidController::calculate(...)表示这个calculate函数是属于PidController类的成员函数。除了类成员实现外::还常用于命名空间例如std::cout表示cout属于std命名空间。8.2 : 后面的部分叫初始化列表构造函数中的这一段: kp_(0), ki_(0), kd_(0)表示在函数体{}执行之前先把这些成员变量初始化为对应值。它和下面这种写法在效果上类似PidController::PidController() { kp_ 0; ki_ 0; kd_ 0; }不过初始化列表通常更规范也更符合工程写法尤其在成员较多或涉及常量、引用成员时更有优势。8.3 这一段在 PID 工程中的真实意义对于 PID 控制器来说初始化列表的意义非常明确将kp_、ki_、kd_初始化为默认值将误差和积分状态清零给输出与积分设置默认限幅保证控制器对象一创建就是“干净状态”否则控制器刚启动时就可能因为内部状态是未定义值而导致输出异常。九、#ifndef、#define、#endif 是什么意思在头文件里我们经常能看到下面这种写法#ifndef PID_CONTROLLER_H #define PID_CONTROLLER_H class PidController { // ... }; #endif // PID_CONTROLLER_H这三句合起来通常被称为头文件保护。9.1 它的作用是什么作用非常简单防止头文件被重复包含。9.2 逐句理解1#ifndef PID_CONTROLLER_H表示如果PID_CONTROLLER_H这个宏还没有被定义过。2#define PID_CONTROLLER_H那么现在就定义它。3#endif结束这个条件判断。9.3 为什么必须加头文件保护在实际工程中一个头文件可能会被多个.cpp文件包含。如果没有头文件保护就可能出现类、结构体、函数声明重复定义的问题从而导致编译报错。所以可以把这三句理解成一句话让这个头文件的内容在整个编译过程中只生效一次。十、适合小车底盘控制C 的 PID 类该怎么写在运动控制工程中PID 是最常见的控制器之一。例如小车底盘的左右轮速度闭环、舵机转角控制、位置环外环调节等都可能用到 PID。下面给出一套比较适合工程集成的 PID 控制器实现。其特点是结构清晰封装明确支持输出限幅支持积分限幅适合在底盘速度环或位置环中直接复用10.1 头文件PidController.h#ifndef PID_CONTROLLER_H #define PID_CONTROLLER_H class PidController { public: PidController(); // 设置 PID 参数 void setParam(double kp, double ki, double kd); // 设置输出限幅 void setOutputLimit(double min, double max); // 设置积分限幅 void setIntegralLimit(double max); // 计算一次 PID 输出 double calculate(double target, double feedback, double dt); // 重置 PID void reset(); private: double kp_; double ki_; double kd_; double err_; double err_last_; double integral_; double out_min_; double out_max_; double integral_max_; }; #endif // PID_CONTROLLER_H10.2 源文件PidController.cpp#include PidController.h #include cmath PidController::PidController() : kp_(0), ki_(0), kd_(0) , err_(0), err_last_(0) , integral_(0) , out_min_(-100), out_max_(100) , integral_max_(100) { } void PidController::setParam(double kp, double ki, double kd) { kp_ kp; ki_ ki; kd_ kd; } void PidController::setOutputLimit(double min, double max) { out_min_ min; out_max_ max; } void PidController::setIntegralLimit(double max) { integral_max_ max; } double PidController::calculate(double target, double feedback, double dt) { err_ target - feedback; // 比例项 double p kp_ * err_; // 积分项 integral_ err_ * dt; if (std::fabs(integral_) integral_max_) { integral_ (integral_ 0) ? integral_max_ : -integral_max_; } double i ki_ * integral_; // 微分项 double d kd_ * (err_ - err_last_) / dt; err_last_ err_; // 总输出 double output p i d; // 输出限幅 if (output out_max_) output out_max_; if (output out_min_) output out_min_; return output; } void PidController::reset() { err_ 0; err_last_ 0; integral_ 0; }这套写法与用户给出的控制器结构和解释是一致的本质上就是用一个类来封装 PID 参数、误差状态以及控制输出逻辑。十一、PID 控制器在小车底盘中的典型用法在双轮差速底盘中最常见的做法是左右轮各使用一个 PID 控制器对象。11.1 初始化阶段PidController pid_left; PidController pid_right; void init() { pid_left.setParam(8.0, 0.8, 0.2); pid_right.setParam(8.0, 0.8, 0.2); pid_left.setOutputLimit(-7200, 7200); pid_right.setOutputLimit(-7200, 7200); pid_left.setIntegralLimit(5000); pid_right.setIntegralLimit(5000); }11.2 控制循环阶段void controlLoop() { double dt 0.01; double target_left 120; double target_right 120; double fb_left getMotorSpeedLeft(); double fb_right getMotorSpeedRight(); double pwm_left pid_left.calculate(target_left, fb_left, dt); double pwm_right pid_right.calculate(target_right, fb_right, dt); setMotorPWMLeft(pwm_left); setMotorPWMRight(pwm_right); }这类写法的优点在于每个轮子的 PID 状态相互独立不会互相干扰而且外部控制逻辑也非常清晰只需要给目标值、反馈值和采样周期即可。十二、总结总的来看struct、class、.h、.cpp、构造函数、作用域解析符::、头文件保护这些内容表面上像是彼此分散的 C 基础语法实际上它们共同服务于同一个目标那就是让代码具备更好的工程组织能力。对于刚接触运动控制开发的初学者而言真正需要建立的并不只是“会写几个函数”的能力而是如何把参数、状态和控制逻辑按照模块化、可复用、易维护的方式组织起来。以 PID 控制器为例如果只是为了临时验证公式几行代码或许就足够了但一旦进入实际项目例如小车底盘速度闭环、电机控制、舵机角度调节等场景就必须考虑接口声明与实现分离、内部状态封装、对象初始化、输出限幅与积分限幅等工程问题。也正因为如此使用class来封装 PID 控制器配合.h与.cpp的模块拆分方式才会成为 C 控制工程中最常见、也最实用的写法。因此学习这部分内容的关键并不在于死记硬背每一个语法点而在于理解它们在工程中的真实作用。只有当你能够把这些基础概念和具体控制场景联系起来才能真正从“会写代码”走向“会写工程代码”。对于正在学习机器人、小车底盘或运动控制算法的同学来说这一步往往比单纯掌握 PID 公式本身更重要。