目录
[一、为什么初学 C++ 工程时容易混乱](#一、为什么初学 C++ 工程时容易混乱)
[1.1 能把数据和行为组织清楚](#1.1 能把数据和行为组织清楚)
[1.2 能被多个模块重复使用](#1.2 能被多个模块重复使用)
[1.3 能适应实际工程的开发习惯](#1.3 能适应实际工程的开发习惯)
[二、在工程中,struct 和 class 该如何选](#二、在工程中,struct 和 class 该如何选)
[2.1 struct 更适合"纯数据"](#2.1 struct 更适合“纯数据”)
[2.2 class 更适合"带行为的对象"](#2.2 class 更适合“带行为的对象”)
[2.3 工程中的经验判断标准](#2.3 工程中的经验判断标准)
[(1)适合用 struct 的场景](#(1)适合用 struct 的场景)
[(2)适合用 class 的场景](#(2)适合用 class 的场景)
[三、.h 和 .cpp 分别负责什么](#三、.h 和 .cpp 分别负责什么)
[3.1 .h 是"声明",相当于接口说明书](#3.1 .h 是“声明”,相当于接口说明书)
[3.2 .cpp 是"实现",相当于真正干活的地方](#3.2 .cpp 是“实现”,相当于真正干活的地方)
[3.3 为什么工程里要分开写](#3.3 为什么工程里要分开写)
[四、.h 和 .cpp 之间到底是什么关系](#四、.h 和 .cpp 之间到底是什么关系)
[4.1 头文件声明"我有什么"](#4.1 头文件声明“我有什么”)
[4.2 源文件实现"我怎么做"](#4.2 源文件实现“我怎么做”)
[4.3 外部文件只需要包含 .h](#4.3 外部文件只需要包含 .h)
[五、什么是类,public 和 private 又是什么意思](#五、什么是类,public 和 private 又是什么意思)
[5.1 类可以理解为一个"控制器模板"](#5.1 类可以理解为一个“控制器模板”)
[5.2 public 表示对外公开的接口](#5.2 public 表示对外公开的接口)
[5.3 private 表示类内部状态](#5.3 private 表示类内部状态)
[六、PID 类中的成员变量和成员函数分别是什么](#六、PID 类中的成员变量和成员函数分别是什么)
[6.1 成员变量:负责存储状态](#6.1 成员变量:负责存储状态)
[(1)PID 参数](#(1)PID 参数)
[6.2 成员函数:负责执行动作](#6.2 成员函数:负责执行动作)
[7.1 构造函数的定义](#7.1 构造函数的定义)
[7.2 构造函数的作用](#7.2 构造函数的作用)
[7.3 为什么工程里最好手动写构造函数](#7.3 为什么工程里最好手动写构造函数)
[八、什么是作用域解析符 ::,什么是初始化列表](#八、什么是作用域解析符 ::,什么是初始化列表)
[8.1 :: 是作用域解析符](#8.1 :: 是作用域解析符)
[8.2 : 后面的部分叫初始化列表](#8.2 : 后面的部分叫初始化列表)
[8.3 这一段在 PID 工程中的真实意义](#8.3 这一段在 PID 工程中的真实意义)
[九、#ifndef、#define、#endif 是什么意思](#ifndef、#define、#endif 是什么意思)
[9.1 它的作用是什么](#9.1 它的作用是什么)
[9.2 逐句理解](#9.2 逐句理解)
[(1)#ifndef PID_CONTROLLER_H](#ifndef PID_CONTROLLER_H)
[(2)#define PID_CONTROLLER_H](#define PID_CONTROLLER_H)
[9.3 为什么必须加头文件保护](#9.3 为什么必须加头文件保护)
[十、适合小车底盘控制C++ 的 PID 类该怎么写](#十、适合小车底盘控制C++ 的 PID 类该怎么写)
[10.1 头文件:PidController.h](#10.1 头文件:PidController.h)
[10.2 源文件:PidController.cpp](#10.2 源文件:PidController.cpp)
[十一、PID 控制器在小车底盘中的典型用法](#十一、PID 控制器在小车底盘中的典型用法)
[11.1 初始化阶段](#11.1 初始化阶段)
[11.2 控制循环阶段](#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 会显得非常自然。
cpp
struct Point
{
int x;
int y;
};
这种写法的特点是简单、直接,外部模块可以方便地读取和赋值。
2.2 class 更适合"带行为的对象"
如果一个对象不仅有数据,还需要封装逻辑、控制访问权限、提供成员函数,甚至未来还可能涉及继承和多态,那么更适合使用 class。
例如,PID 控制器就是典型的"带行为对象"。它不仅有 kp、ki、kd 等参数,还要具备设置参数、计算输出、重置状态等功能。
cpp
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 的主要作用是告诉编译器:这里有哪些类、函数、结构体、变量可以用**。
也就是说,头文件通常负责写:
- 类的定义
- 成员函数声明
- 结构体定义
- 宏定义
- 外部变量声明
例如:
cpp
class PidController
{
public:
void setParam(double kp, double ki, double kd);
double calculate(double target, double feedback, double dt);
};
这里并没有写出函数内部具体怎么运行,而只是告诉外部:"这个类里有这些函数,你可以调用它们"。
3.2 .cpp 是"实现",相当于真正干活的地方
源文件**.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 控制器为例,头文件中会写出类名、接口函数和成员变量:
cpp
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 中写出这些函数的具体实现:
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 中,只需要包含头文件即可使用该模块:
cpp
#include "PidController.h"
PidController pid;
从这个角度看,.h 和 .cpp 的关系可以概括为:
头文件对外公开接口,源文件对内实现逻辑。
五、什么是类,public 和 private 又是什么意思
当我们写下如下代码时:
cpp
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 类可以理解为一个"控制器模板"
你可以把类理解成一个模板,或者一个用于创建对象的模型。
当我们后面写:
cpp
PidController pid;
就是根据这个模板创建了一个具体对象 pid。
5.2 public 表示对外公开的接口
public 下面写的函数,外部都可以直接调用。例如:
cpp
pid.setParam(10, 1, 0.1);
pid.calculate(target, feedback, dt);
pid.reset();
5.3 private 表示类内部状态
private 下面的成员变量,外部通常不能直接访问,只能通过类提供的接口来间接使用。
例如,PID 的误差、积分项、参数限幅等,都属于控制器内部状态。如果这些数据随便暴露给外部读写,就很容易造成逻辑混乱。因此更合理的方式是把它们放在 private 中进行封装。
六、PID 类中的成员变量和成员函数分别是什么
在一个类中,通常既有"数据",也有"动作"。
6.1 成员变量:负责存储状态
在 PID 控制器中,常见的成员变量包括:
cpp
double kp_;
double ki_;
double kd_;
double err_;
double err_last_;
double integral_;
double out_min_;
double out_max_;
double integral_max_;
这些量分别表示:
(1)PID 参数
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,那么构造函数就必须叫:
cpp
class PidController
{
public:
PidController();
(2)构造函数没有返回值
前面不能写 void,也不能写 int 或 double。只要写了返回值类型,它就不再是构造函数了。
7.2 构造函数的作用
构造函数会在对象创建时自动调用,用于初始化对象内部状态。
例如:
cpp
PidController pid;
这一行执行时,系统就会自动调用**PidController()**。
7.3 为什么工程里最好手动写构造函数
虽然 C++ 在某些情况下会自动生成默认构造函数,但在工程开发中,尤其是运动控制这种对初始化非常敏感的场景里,通常都建议手动写构造函数。
原因很简单:
如果误差、积分项、限幅等变量没有正确初始化,那么控制器一开始就可能带着"随机值"工作,从而导致输出异常。
八、什么是作用域解析符 ::,什么是初始化列表
很多人第一次看到下面这段代码时会觉得陌生:
cpp
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 这个范围里的东西。
在类的实现中:
cpp
PidController::calculate(...)
表示:这个 calculate 函数是属于 PidController 类的成员函数。
除了类成员实现外,:: 还常用于命名空间,例如:
cpp
std::cout
表示 cout 属于 std 命名空间。
8.2 : 后面的部分叫初始化列表
构造函数中的这一段:
cpp
: kp_(0), ki_(0), kd_(0)
表示在函数体 {} 执行之前,先把这些成员变量初始化为对应值。
它和下面这种写法在效果上类似:
cpp
PidController::PidController()
{
kp_ = 0;
ki_ = 0;
kd_ = 0;
}
不过初始化列表通常更规范,也更符合工程写法,尤其在成员较多或涉及常量、引用成员时更有优势。
8.3 这一段在 PID 工程中的真实意义
对于 PID 控制器来说,初始化列表的意义非常明确:
- 将
kp_、ki_、kd_初始化为默认值- 将误差和积分状态清零
- 给输出与积分设置默认限幅
- 保证控制器对象一创建就是"干净状态"
否则控制器刚启动时,就可能因为内部状态是未定义值而导致输出异常。
九、#ifndef、#define、#endif 是什么意思
在头文件里,我们经常能看到下面这种写法:
cpp
#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
cpp
#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_H
10.2 源文件:PidController.cpp
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 初始化阶段
cpp
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 控制循环阶段
cpp
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 公式本身更重要。