当前位置: 首页 > news >正文

C++观察者模式:从原理到现代实现,解决对象间松耦合通知

1. 项目概述:为什么我们需要观察者模式?

如果你写过一些稍微有点规模的C++项目,尤其是涉及到UI更新、事件响应或者数据监控,大概率会遇到一个头疼的问题:一个对象的状态变了,怎么让其他一堆对象都知道,并且自动更新?最直接的办法,就是让状态变化的对象,去“硬编码”调用所有关心它的对象的更新方法。比如,一个气象站的数据中心WeatherData温度变了,它得自己去调用CurrentConditionsDisplayupdate方法,再调用StatisticsDisplayupdate方法,还得调用ForecastDisplayupdate方法……每增加一个显示面板,就得去修改WeatherData的代码。这种紧耦合的设计,维护起来简直是噩梦,违反了“对修改关闭,对扩展开放”的开闭原则。

观察者模式(Observer Pattern)就是为了优雅地解决这个问题而生的。它定义了一种一对多的依赖关系,当一个对象(称为“主题”或“可观察者”)的状态发生改变时,所有依赖于它的对象(称为“观察者”)都会自动得到通知并更新。这就像你订阅了一个公众号(主题),公众号每次发新文章(状态改变),所有订阅者(观察者)都会收到推送(通知)。在C++中实现它,不仅能让你深刻理解这种松耦合的设计思想,更是处理事件驱动系统、模型-视图分离架构的利器。无论是游戏引擎里的成就系统监听玩家状态,还是Qt框架中的信号与槽机制,其核心思想都脱胎于观察者模式。

接下来,我将结合《Head First设计模式》中的经典气象站案例,带你从零开始,用现代C++(我会用到C++11/14的一些特性,让实现更安全、更优雅)实现一个类型安全、避免内存泄漏的观察者模式。我们不止步于“能跑通”,更要深究“为什么这么设计”,以及在实际项目中你会踩到的那些坑。

2. 核心思路与接口设计:建立松耦合的契约

观察者模式的核心在于解耦。主题不应该知道观察者具体是谁、具体做了什么,它只关心有一群观察者实现了某个统一的接口。同样,观察者也只关心主题提供的那个获取状态的接口。这种通过抽象接口建立的契约,是松耦合的基石。

2.1 定义观察者接口

首先,我们定义观察者接口。这是一个纯虚基类,任何想成为观察者的类都必须继承它并实现update方法。

// Observer.h #ifndef OBSERVER_H #define OBSERVER_H // 前向声明,避免头文件循环包含。Subject的具体实现细节,Observer接口并不需要知道。 class Subject; class Observer { public: virtual ~Observer() = default; // 虚析构函数,确保通过基类指针删除派生类对象时正确释放资源。 /** * 当主题状态改变时,主题会调用此方法来通知所有观察者。 * 这是观察者模式的“推”模型(Push Model):主题将更新数据推送给观察者。 * 参数 subject 是一个指向主题的指针,观察者可以通过它来回拉(Pull)更多具体数据。 * 在实际项目中,你也可以设计为“拉”模型,即update()无参数,观察者自己持有主题的引用去拉数据。 */ virtual void update(Subject* subject) = 0; }; #endif // OBSERVER_H

设计要点与避坑指南:

  1. 虚析构函数是必须的:这是C++多态性的基石。如果基类的析构函数不是虚函数,那么通过基类指针删除派生类对象时,只会调用基类的析构函数,导致派生类的部分资源泄漏。= default让编译器生成默认实现,简洁安全。
  2. 使用前向声明:在Observer的头文件中,我们只用了class Subject;而不是#include "Subject.h"。这减少了编译依赖,加快了编译速度,也避免了可能的头文件循环包含问题。只要在Observer.cpp(如果有的话)或者观察者具体实现的.cpp文件中包含Subject.h即可。
  3. “推”模型 vs “拉”模型:我们这里采用的是经典的“推”模型,主题将自身指针传递给观察者。观察者可以决定是否需要、以及需要哪些数据。另一种是“拉”模型,update()函数无参,观察者内部持有主题的引用,需要时主动去“拉”数据。“推”模型更主动,但可能传递不必要的数据;“拉”模型更被动,但观察者需要知道如何获取数据。我们选择“推”模型因为它更符合《Head First》的示例,且灵活性足够。

2.2 定义主题接口

接下来定义主题(即可观察者)接口。主题需要提供注册、移除和通知观察者的能力。

// Subject.h #ifndef SUBJECT_H #define SUBJECT_H #include <memory> #include <vector> class Observer; // 前向声明 class Subject { public: virtual ~Subject() = default; /** * 注册一个观察者。从此,这个观察者将接收状态变更通知。 * 参数 observer 是一个指向观察者的智能指针,使用智能指针可以自动管理生命周期,避免裸指针带来的内存泄漏风险。 */ virtual void registerObserver(std::shared_ptr<Observer> observer) = 0; /** * 移除一个观察者。被移除后,该观察者将不再接收通知。 */ virtual void removeObserver(std::shared_ptr<Observer> observer) = 0; /** * 通知所有已注册的观察者。当主题的内部状态发生改变时,调用此方法。 */ virtual void notifyObservers() = 0; }; #endif // SUBJECT_H

设计要点与避坑指南:

  1. 使用智能指针管理观察者生命周期:这里使用了std::shared_ptr<Observer>。这是现代C++工程实践的关键一步。如果使用裸指针Observer*,主题很难知道何时该删除观察者指针,极易造成内存泄漏或悬垂指针。使用shared_ptr,主题和观察者的使用者共享所有权,只有当最后一个shared_ptr被销毁时,观察者对象才会被释放。这大大简化了内存管理。
  2. 接口简洁:主题接口只关心三件事:增、删、通知。它不关心观察者具体是谁,也不关心自己具体是什么状态。这保持了接口的纯粹性和通用性。
  3. notifyObservers的调用时机:这个方法应该由具体主题在状态确实发生改变后调用。通常是在主题的setter方法内部。不要在getter或者不改变状态的方法里调用,否则会导致不必要的、甚至无限循环的通知。

3. 具体实现:从抽象接口到气象站案例

有了抽象的接口,我们就可以实现具体的主题和观察者了。我们以《Head First》中的WeatherData(气象数据)为主题,实现几个具体的显示面板作为观察者。

3.1 实现具体主题:WeatherData

WeatherData是核心,它负责测量、存储气象数据(温度、湿度、气压),并在数据变化时通知所有观察者。

// WeatherData.h #ifndef WEATHERDATA_H #define WEATHERDATA_H #include "Subject.h" #include "Observer.h" #include <memory> #include <vector> #include <algorithm> // 用于std::find class WeatherData : public Subject { public: WeatherData() : temperature_(0.0f), humidity_(0.0f), pressure_(1013.25f) {} // 实现Subject接口 void registerObserver(std::shared_ptr<Observer> observer) override { observers_.push_back(observer); } void removeObserver(std::shared_ptr<Observer> observer) override { // 使用std::remove配合erase来移除元素。注意remove不会真的删除,只是把要删除的元素移到末尾。 auto it = std::find(observers_.begin(), observers_.end(), observer); if (it != observers_.end()) { observers_.erase(it); } // 更现代、更安全的写法是使用std::erase (C++20) 或 std::erase_if // 例如 C++20: std::erase(observers_, observer); } void notifyObservers() override { // 遍历所有观察者,调用其update方法,并传递this指针(即WeatherData对象自身) for (const auto& observer : observers_) { // 注意:这里传递的是基类Subject的指针,但由于WeatherData继承自Subject, // 在观察者的update函数中,可以通过dynamic_cast或static_cast(如果确定类型)转换回WeatherData*来访问具体数据。 observer->update(this); } } // 供观察者拉取数据的公共接口 float getTemperature() const { return temperature_; } float getHumidity() const { return humidity_; } float getPressure() const { return pressure_; } // 模拟气象站测量到新数据,这是触发状态改变和通知的入口 void setMeasurements(float temperature, float humidity, float pressure) { // 在实际项目中,这里可能涉及复杂的传感器读取或网络请求 temperature_ = temperature; humidity_ = humidity; pressure_ = pressure; // 关键步骤:数据更新后,立即通知所有观察者 measurementsChanged(); } private: // 当测量数据改变时被调用的私有方法 void measurementsChanged() { notifyObservers(); } // 气象数据成员变量 float temperature_; float humidity_; float pressure_; // 观察者列表。使用shared_ptr容器存储,主题不独占观察者所有权。 std::vector<std::shared_ptr<Observer>> observers_; }; #endif // WEATHERDATA_H

实操心得与陷阱:

  1. removeObserver的实现细节:直接使用vector::erase并传入迭代器是正确做法。早期的错误示范是直接在for循环中边遍历边删除,这会导致迭代器失效。我们这里先find,再erase。在C++20中,可以使用std::erase,代码更简洁。
  2. 通知的时机setMeasurements是改变状态的唯一公开入口。在它内部,我们更新私有数据后,调用一个私有方法measurementsChanged(),再由这个方法去调用notifyObservers()。这样设计的好处是,将来如果通知的逻辑需要改变(比如增加日志、条件判断),只需要修改measurementsChanged()一处。这符合“将变化的部分封装起来”的设计原则。
  3. 数据传递:在notifyObservers()中,我们传递this(一个Subject*)给观察者。观察者需要将其转换回WeatherData*来获取具体数据。这里存在一个向下转型(downcast)的问题。我们稍后在观察者实现中讨论如何安全地处理。

3.2 实现具体观察者:显示面板

我们实现三个显示面板:当前状况、统计信息和天气预报。它们都继承自Observer接口。

3.2.1 当前状况显示面板
// CurrentConditionsDisplay.h #ifndef CURRENT_CONDITIONS_DISPLAY_H #define CURRENT_CONDITIONS_DISPLAY_H #include "Observer.h" #include "WeatherData.h" // 这里需要包含具体主题的头文件,因为要调用其getter方法 #include <iostream> #include <memory> class CurrentConditionsDisplay : public Observer { public: // 构造函数中注册自己到主题。使用weak_ptr避免循环引用。 explicit CurrentConditionsDisplay(std::shared_ptr<WeatherData> weatherData) : weatherData_(weatherData), temperature_(0.0f), humidity_(0.0f) { weatherData_->registerObserver(std::shared_ptr<Observer>(this)); // 注意:这里将this转为shared_ptr有风险! } ~CurrentConditionsDisplay() override { // 析构时,从主题中移除自己,避免主题通知一个已销毁的对象。 auto data = weatherData_.lock(); // 尝试提升为shared_ptr if (data) { >// Observer.h (修正版) #ifndef OBSERVER_H #define OBSERVER_H #include <memory> class Subject; class Observer : public std::enable_shared_from_this<Observer> { public: virtual ~Observer() = default; virtual void update(Subject* subject) = 0; // 提供一个安全的获取自身shared_ptr的方法 std::shared_ptr<Observer> getPtr() { return shared_from_this(); } }; #endif // OBSERVER_H

然后,我们修改CurrentConditionsDisplay的创建和使用方式。关键点:观察者对象必须从一开始就由shared_ptr管理。

// CurrentConditionsDisplay.h (修正版,核心改动) class CurrentConditionsDisplay : public Observer { public: // 使用静态工厂函数来创建对象,确保对象始终被shared_ptr管理。 static std::shared_ptr<CurrentConditionsDisplay> create(std::shared_ptr<WeatherData> weatherData) { // 使用make_shared一次性分配对象和控制块,效率更高且安全。 auto display = std::make_shared<CurrentConditionsDisplay>(weatherData); // 现在可以安全地注册了,因为display已经是一个合法的shared_ptr。 weatherData->registerObserver(display); return display; } // 将构造函数设为private,强制用户使用工厂函数。 private: explicit CurrentConditionsDisplay(std::shared_ptr<WeatherData> weatherData) : weatherData_(weatherData), temperature_(0.0f), humidity_(0.0f) { // 构造函数里不再注册!注册移到工厂函数中。 } public: // 析构函数中移除自己 ~CurrentConditionsDisplay() override { auto data = weatherData_.lock(); if (data) { // 这里可以使用从enable_shared_from_this继承来的shared_from_this() >// StatisticsDisplay.h #ifndef STATISTICS_DISPLAY_H #define STATISTICS_DISPLAY_H #include "Observer.h" #include "WeatherData.h" #include <iostream> #include <vector> #include <algorithm> #include <memory> class StatisticsDisplay : public Observer { public: static std::shared_ptr<StatisticsDisplay> create(std::shared_ptr<WeatherData> weatherData) { auto display = std::make_shared<StatisticsDisplay>(weatherData); weatherData->registerObserver(display); return display; } ~StatisticsDisplay() override { auto data = weatherData_.lock(); if (data) { >// ForecastDisplay.h #ifndef FORECAST_DISPLAY_H #define FORECAST_DISPLAY_H #include "Observer.h" #include "WeatherData.h" #include <iostream> #include <memory> class ForecastDisplay : public Observer { public: static std::shared_ptr<ForecastDisplay> create(std::shared_ptr<WeatherData> weatherData) { auto display = std::make_shared<ForecastDisplay>(weatherData); weatherData->registerObserver(display); return display; } ~ForecastDisplay() override { auto data = weatherData_.lock(); if (data) { >// main.cpp #include "WeatherData.h" #include "CurrentConditionsDisplay.h" #include "StatisticsDisplay.h" #include "ForecastDisplay.h" #include <memory> #include <thread> #include <chrono> int main() { // 1. 创建主题(气象站) auto weatherData = std::make_shared<WeatherData>(); // 2. 创建观察者(显示面板),并通过工厂函数自动注册 auto currentDisplay = CurrentConditionsDisplay::create(weatherData); auto statisticsDisplay = StatisticsDisplay::create(weatherData); auto forecastDisplay = ForecastDisplay::create(weatherData); std::cout << "=== 模拟气象数据更新 ===" << std::endl; // 3. 模拟气象数据变化 weatherData->setMeasurements(25.0f, 65.0f, 1015.0f); std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟1秒间隔 weatherData->setMeasurements(26.5f, 70.0f, 1012.0f); std::this_thread::sleep_for(std::chrono::seconds(1)); weatherData->setMeasurements(23.0f, 90.0f, 1018.0f); std::this_thread::sleep_for(std::chrono::seconds(1)); // 4. 动态移除一个观察者 std::cout << "\n=== 移除当前状况显示面板 ===" << std::endl; // 我们不需要手动调用remove,因为currentDisplay析构时会自动调用。 // 但为了演示,我们可以手动将其重置(释放),触发其析构。 currentDisplay.reset(); // currentDisplay智能指针释放资源,对象被销毁,析构函数中从主题移除自己。 std::cout << "当前状况显示面板已移除。" << std::endl; // 5. 再次更新数据,只有剩下的两个面板会收到通知 weatherData->setMeasurements(21.0f, 85.0f, 1020.0f); // 6. 程序结束,所有智能指针超出作用域,自动清理内存。 // weatherData, statisticsDisplay, forecastDisplay 依次销毁。 // statisticsDisplay 和 forecastDisplay 的析构函数会将自己从 weatherData 的列表中移除。 // 最后 weatherData 被销毁。 return 0; }

编译并运行这个程序(假设你使用g++):

g++ -std=c++14 -o weather_station main.cpp WeatherData.cpp CurrentConditionsDisplay.cpp StatisticsDisplay.cpp ForecastDisplay.cpp ./weather_station

你应该能看到类似以下的输出:

=== 模拟气象数据更新 === Current conditions: 25°C and 65% humidity Avg/Max/Min temperature: 25/25/25°C Forecast: Improving weather on the way! Current conditions: 26.5°C and 70% humidity Avg/Max/Min temperature: 25.75/26.5/25°C Forecast: Watch out for cooler, rainy weather. Current conditions: 23°C and 90% humidity Avg/Max/Min temperature: 24.8333/26.5/23°C Forecast: Improving weather on the way! === 移除当前状况显示面板 === 当前状况显示面板已移除。 Avg/Max/Min temperature: 24.375/26.5/21°C Forecast: Improving weather on the way!

完美!观察者模式成功运行。当数据更新时,所有注册的显示面板都自动更新了。当我们移除“当前状况显示”后,后续的通知它就不再接收。

5. 深入探讨:模式变体、陷阱与进阶优化

我们的基础实现已经可用,但在实际生产环境中,还需要考虑更多细节。

5.1 线程安全

上面的实现是非线程安全的。如果registerObserverremoveObservernotifyObservers可能被多个线程同时调用,或者notifyObservers中观察者的update方法修改了观察者列表,就会导致数据竞争(Data Race)或迭代器失效。

解决方案: 最简单的办法是使用互斥锁(std::mutex)保护观察者列表。但要注意死锁问题,特别是在notifyObservers中调用观察者的update时,观察者又可能回调主题的方法(比如在update中调用removeObserver)。

// WeatherData.h (线程安全粗略版) #include <mutex> #include <shared_mutex> // C++17 读写锁更好 class WeatherData : public Subject { public: void registerObserver(std::shared_ptr<Observer> observer) override { std::lock_guard<std::mutex> lock(mutex_); observers_.push_back(observer); } void removeObserver(std::shared_ptr<Observer> observer) override { std::lock_guard<std::mutex> lock(mutex_); auto it = std::find(observers_.begin(), observers_.end(), observer); if (it != observers_.end()) { observers_.erase(it); } } void notifyObservers() override { std::vector<std::shared_ptr<Observer>> observers_copy; { // 复制一份观察者列表,减少锁的持有时间。 std::lock_guard<std::mutex> lock(mutex_); observers_copy = observers_; } for (const auto& observer : observers_copy) { observer->update(this); // 注意:update函数本身也应该是线程安全的! } } private: mutable std::mutex mutex_; // ... 其他成员 };

重要提示:在notifyObservers中复制列表是一个常用技巧,它避免了在遍历列表时持有锁,防止了观察者update方法内操作列表导致的死锁。但复制本身有性能开销。另一种方法是使用读写锁(std::shared_mutex),注册/移除时写锁,通知时读锁。

5.2 通知顺序与性能

  • 顺序问题std::vector保证了插入顺序,但通知顺序就是注册顺序。有时你可能需要优先级。可以考虑使用std::multisetstd::priority_queue来存储带优先级的观察者。
  • 性能问题:如果观察者数量非常多(成千上万),notifyObservers的遍历会成为瓶颈。可以考虑异步通知,将通知任务提交到线程池,避免阻塞主题线程。但异步会带来新的复杂度:观察者接收通知的顺序不确定,且需要处理线程间数据同步。

5.3 使用 std::function 与信号槽的现代实现

如果你不需要完整的观察者模式类层次,C++11的std::functionstd::bind(或lambda)可以提供一种更轻量、更灵活的“信号-槽”机制。

#include <functional> #include <vector> class SimpleWeatherData { public: using UpdateCallback = std::function<void(float temp, float humidity, float pressure)>; void registerCallback(const UpdateCallback& cb) { callbacks_.push_back(cb); } void setMeasurements(float t, float h, float p) { temp_ = t; humi_ = h; pres_ = p; for (const auto& cb : callbacks_) { cb(temp_, humi_, pres_); // 直接调用函数对象 } } private: float temp_, humi_, pres_; std::vector<UpdateCallback> callbacks_; }; // 使用lambda注册 SimpleWeatherData swd; swd.registerCallback([](float t, float h, float p) { std::cout << "Lambda: " << t << std::endl; });

这种方式更简洁,类型安全(通过模板可以支持任意签名),但失去了将观察者抽象为对象的能力,更适合简单的回调场景。Qt框架的信号槽、Boost.Signals2库都是这种思想的强大实现。

5.4 主题基类是否需要模板化?

我们的Subject接口使用了Observer基类。这意味着任何观察者都必须继承自Observer。有时我们希望主题能通知任意类型的回调。这时可以使用模板。

template<typename... Args> class Subject { public: using ObserverType = std::function<void(Args...)>; void registerObserver(ObserverType obs) { /*...*/ } void notifyObservers(Args... args) { for(auto& obs : observers_) { obs(args...); } } private: std::vector<ObserverType> observers_; }; // 具体主题 class AdvancedWeatherData : public Subject<float, float, float> { // ... };

模板化提供了极大的灵活性,但代价是失去了运行时的动态类型安全和统一的观察者基类。你需要根据项目需求权衡。

6. 总结与个人心得

观察者模式在C++中的实现,核心挑战往往不在于模式本身的理解,而在于C++特有的对象生命周期管理资源安全。通过这个完整的实现,我希望你不仅学会了模式,更掌握了以下关键点:

  1. 智能指针是救星:始终优先使用std::shared_ptrstd::weak_ptr来管理涉及多个所有者(主题和观察者)的对象生命周期。裸指针在现代C++项目中是万恶之源。
  2. 警惕循环引用:主题和观察者互相持有对方的shared_ptr会导致内存泄漏。用weak_ptr指向你需要观察但不应拥有所有权的对象。
  3. enable_shared_from_this的必要性:当一个对象需要获取指向自身的shared_ptr时(比如在析构函数中从主题列表移除自己),必须让这个类继承自std::enable_shared_from_this,并且该对象必须从一开始就被shared_ptr管理(通常通过工厂函数和make_shared实现)。
  4. 接口设计关乎扩展性:“推”还是“拉”?传递什么数据?这些决定会影响未来代码的扩展。我们的实现采用了“推”模型+主题指针,让观察者按需拉取,是一个平衡的选择。
  5. 现实世界的复杂性:线程安全、通知性能、动态类型转换(dynamic_cast)的安全性,这些都是理论示例中常常忽略,但实际项目必须面对的。在简单的单线程程序里,我们的基础实现足够用;一旦涉及并发,就必须引入同步机制。

最后,设计模式不是银弹,观察者模式也不例外。它引入了间接性,调试起来可能更费劲。但对于需要解耦事件源和事件处理器的场景——比如GUI框架、游戏引擎、分布式系统中的事件总线——观察者模式提供的松耦合优势是无可替代的。理解其原理,并在C++的语境下安全、高效地实现它,是你迈向资深C++工程师的重要一步。下次当你需要让一个对象的变化通知到其他对象时,不妨想想这个气象站的例子。

http://www.jsqmd.com/news/1178516/

相关文章:

  • eNSP 与 VirtualBox 5.2.12 虚拟网络配置:解决“###”与错误代码40的2个核心要点
  • Subtitle Edit完全指南:从新手到专家的免费字幕编辑解决方案
  • C++并发编程实战指南:从内存模型到线程池与无锁队列
  • Anbox社区贡献指南:如何参与openEuler Android中间件开发
  • 轻量级AI模型在代码安全审计中的应用:从SQL注入检测到自动化修复
  • 【ElevenLabs企业级部署白皮书】:单日10万次请求压测实录,负载均衡+缓存策略+Webhook异常熔断三重保障
  • NumPy新手实战指南:从报错到交付的四大高频场景
  • ZeroMQ与Protobuf构建C++/Python图像处理高速通信框架
  • Unity多平台开发:一键动态切换宏定义与项目配置实战
  • WindowResizer终极指南:免费强制调整Windows窗口大小的完整解决方案
  • GSM与CDMA网络容量对比:基于爱尔兰B表的3种典型场景话务量计算
  • 郑州爱彼回收价格查询和各大回收平台实测排行(2026年7月最新) - 尊奢回收二奢平台
  • UE4蓝图实现第三人称角色自动寻路:从NavMesh配置到AI移动优化
  • 2026年知网CNKI AI检测达标指南:知网论文AI率超标4.8元完整应对方案
  • PIC18F47Q10与PAM8904构建高效警报系统
  • Cocos Creator全屏API实战:兼容性、UI适配与移动端横屏解决方案
  • Ford-Fulkerson 算法实战:Python 实现最大流问题,5步求解23万吨/小时案例
  • 【2026干货】一个人就是一家公司:OpenClaw帮你把“一人公司“真正跑通
  • Jetson TK1刷机实战指南:JetPack 2.3.1稳定部署L4T系统
  • Windows 11 LTSC企业版:老旧电脑重装系统性能优化全攻略
  • 初创团队的规范驱动开发:轻量Spec如何抢回交付时间
  • 广州江诗丹顿回收价格查询和各大回收平台实测排行(2026年7月最新数据) - 收的高名表回收平台
  • PIC18LF46K42与PAM8904构建可定制警报系统
  • Playwright 1.44 多浏览器上下文实战:1个实例并发执行3种设备模拟测试
  • 白沙家电哪家性价比高
  • SAP 预算占用与消耗全流程解析:从采购申请到发票的 4 个关键 BAPI
  • 卓成包装运输包装出口木箱光伏设备精密仪器包装运输包装设计测试仓储与运输赛道:2026年Q3靠谱本地服务商甄选推荐
  • ChatGPT多步骤任务工程化落地(含状态持久化+异常回滚+人工干预锚点):金融/医疗/客服三大场景实测报告
  • 高精度数据采集方案:MCP3428 ADC与TM4C129ENCZAD MCU组合应用
  • Vmware虚拟机安装Centos7