2.KMD-9.LinuxKMS-2-《计算机知识》

admin 2025-11-02 22:35:02 系统网络 来源:ZONE.CI 全球网 0 阅读模式
  • 内容和参考
    • 内容
    • 参考
  • KMS输出原理
    • 调试日志
    • 输出框架
      • 图像输入
      • 数据输出
    • Modeset对象抽象
      • 什么是对象
      • 对象的描述
      • 相关接口
    • KMS的属性property
      • 什么是属性
      • 属性类型
      • 属性的描述
      • ">image.png
      • 相关接口
      • 内核标准属性
    • Atomic下得属性配置
      • 用户层接口
      • atomic设置模式
      • drm_atomic_state得分配
      • 内核层属性配置流程
      • prepare_signaling 和 complete_signaling
    • KMS核心功能">KMS核心功能
      • fb_create
      • atomic提交操作

    内容和参考

    内容

    前边学习的内容,有以下几个未解决:

    • 在学习 DRM应用程序进阶-何小龙 的时候,提到了property属性,这个是怎么使用的?
    • libdrm中讲述了 connector, encoder, crtc,plane, framebuffer 在之前都没有提到过?
    • modetest打印硬件资源时,这些资源从哪来的?-a的atomic参数是干什么的?

    本文的目的是先扫盲,大概了解下KMS的相关作用以及使用,比较生疏难懂,也是在不停学习中整理和更新,每一遍都学到一些新的知识。

    DRM的初始化 中描述了,drm驱动在初始化时,除了调用 drm_dev_alloc + drm_dev_register,还需要初始化mode_config配置

    1. include/uapi/drm/drm_mode.h // 用户空间接口
    2. include/drm/drm_mode_config.h
    3. include/drm/drm_mode_object.h
    4. drivers/gpu/drm/drm_mode_config.c
    5. drivers/gpu/drm/drm_mode_object.c
    6. drivers/gpu/drm/drm_property.c

    DRM的初始化: 在前边描述了一个最简单的drm驱动,但实际上啥也干不了,真实使用并不仅仅是 调用了 drm_dev_alloc + drm_dev_register 就可以完成一个显卡驱动的; 比如之前学习libdrm中的 connector,encoder, crtc, framebuffer, plane等 都没有提到过;

    在 Linux DRM Internals 中描述了,必须初始化 struct drm_mode_config 的下列字段:

    • int min_width, min_height; int max_width, max_height; Minimum and maximum width and height of the frame buffers in pixel units.
    • [struct drm_mode_config_funcs](https://www.kernel.org/doc/html/v5.12/gpu/drm-kms.html#c.drm_mode_config_funcs) *funcs; Mode setting functions.

    image.png

    参考: linux-5.8/drivers/gpu/drm/arm/malidp_drv.c 中 malidp_bind,显卡驱动中都做了哪些事

    1. drm_mode_config_init(drm);
    2. drm->mode_config.min_width = hwdev->min_line_size;
    3. drm->mode_config.min_height = hwdev->min_line_size;
    4. drm->mode_config.max_width = hwdev->max_line_size;
    5. drm->mode_config.max_height = hwdev->max_line_size;
    6. drm->mode_config.funcs = &malidp_mode_config_funcs;
    7. drm->mode_config.helper_private = &malidp_mode_config_helpers;
    8. drm->mode_config.allow_fb_modifiers = true;

    所以本节内容主要是: 理清楚drm的Property

    参考

    • Atomic mode setting design overview, part 1 和 2 # 建议先看这个,
    • 何小龙-DRM 驱动程序开发(VKMS) 和 DRM应用程序进阶Property、atomic-crtc、atomic-plane
    • 蜗窝Linux graphic subsytem-1 和 蜗窝Linux graphic subsytem-2
    • Linux GPU Driver Developer’s Guide: Linux DRM Internals-kms (主要参考来源)
    • Update for Atomic Display Updates

    KMS输出原理

    调试日志

    在调试kms的配置时候, 为了看跟多信息,需要打开debug级别echo 0x1ff > /sys/module/drm/parameters/debug # 打开调试信息echo 0x0f > /sys/module/drm/parameters/debug # 关闭部分调试信息

    1. /*
    2. * __drm_debug: Enable debug output.
    3. * Bitmask of DRM_UT_x. See include/drm/drm_print.h for details.
    4. */
    5. unsigned int __drm_debug;
    6. EXPORT_SYMBOL(__drm_debug);
    7. MODULE_PARM_DESC(debug, "Enable debug output, where each bit enables a debug category.\n"
    8. "\t\tBit 0 (0x01) will enable CORE messages (drm core code)\n"
    9. "\t\tBit 1 (0x02) will enable DRIVER messages (drm controller code)\n"
    10. "\t\tBit 2 (0x04) will enable KMS messages (modesetting code)\n"
    11. "\t\tBit 3 (0x08) will enable PRIME messages (prime code)\n"
    12. "\t\tBit 4 (0x10) will enable ATOMIC messages (atomic code)\n" // DRM_DEBUG_ATOMIC
    13. "\t\tBit 5 (0x20) will enable VBL messages (vblank code)\n"
    14. "\t\tBit 6 (0x40) Used for verbose atomic state debugging.\n" // 使用后打开状态更新
    15. "\t\tBit 7 (0x80) will enable LEASE messages (leasing code)\n"
    16. "\t\tBit 8 (0x100) will enable DP messages (displayport code)");
    17. module_param_named(debug, __drm_debug, int, 0600);

    输出框架

    image.pngimage.png

    图像输入

    KMS呈现给用户空间的基本对象结构比较简单:

    • GPU/用户软件 将渲染完的图片放到drm_framebuffer中
    • Framebuffers 将图片feed into planes 中
    • 每个plane(struct drm_plane); plane将Framebuffer给的图片 pixel data 填充到CRTC 来混合 blending

    数据输出

    • 数据输出路由的第一步是 encoder(struct drm_encoder); KMS驱动程序的 helper libraries 组件;

      用来封装connector,使得用户不需要关心crtc与connector的链接;用户直接查找crtc和encoder的关系即可; 一个crtc需要接1-多个encoder

    • 显示链中的最后一个真正的端点是connector(struct drm_connector);

      connector可以有不同的encoders,但是内核驱动程序为每个connector选择要使用的encoder 。

    Modeset对象抽象

    在学习kms前需要先熟悉下对象和属性, 因为kms中每一个参数都是一个个属性组成的,对这部分不熟悉后期看起来比较吃力,因此先学习下。

    什么是对象

    看了基础代码,我们知道 drm_device->mode_config.object_idr 中挂了当前drm的所有object,在驱动中 通过 __drm_mode_object_find 函数来查找我们需要的object,但drm中的object是什么?驱动中通过 drm_mode_object_add 函数创建并给当前drm_device—>mode_config中添加一个对象,根据对象的类型,可以看到 内核中将 CRTC,CONNECTOR, FRAMEBUFFER, Encoder, Plane 都抽象了一个描述:object

    1. #define DRM_MODE_OBJECT_CRTC 0xcccccccc
    2. #define DRM_MODE_OBJECT_CONNECTOR 0xc0c0c0c0
    3. #define DRM_MODE_OBJECT_ENCODER 0xe0e0e0e0
    4. #define DRM_MODE_OBJECT_FB 0xfbfbfbfb
    5. #define DRM_MODE_OBJECT_PLANE 0xeeeeeeee
    6. #define DRM_MODE_OBJECT_MODE 0xdededede
    7. #define DRM_MODE_OBJECT_PROPERTY 0xb0b0b0b0 // 每个属性,自己内部也会分配一个object
    8. #define DRM_MODE_OBJECT_BLOB 0xbbbbbbbb
    9. #define DRM_MODE_OBJECT_ANY 0

    在创建plane的时候:

    1. drm_universal_plane_init
    2. drm_mode_object_add(dev, &plane->base, DRM_MODE_OBJECT_PLANE);
    3. idr_alloc(&dev->mode_config.object_idr, register_obj ? obj......

    在创建crtc的时候

    1. drm_crtc_init_with_planes
    2. drm_mode_object_add(dev, &crtc->base, DRM_MODE_OBJECT_CRTC);

    ……

    1. inno@inno-MS-7B89:linux-git$ git grep -n "drm_mode_object_add"
    2. drivers/gpu/drm/drm_connector.c:231: ret = __drm_mode_object_add(dev, &connector->base,DRM_MODE_OBJECT_CONNECTOR...
    3. drivers/gpu/drm/drm_crtc.c:280: ret = drm_mode_object_add(dev, &crtc->base, DRM_MODE_OBJECT_CRTC);
    4. drivers/gpu/drm/drm_crtc_internal.h:140:int __drm_mode_object_add(struct drm_device *dev, struct drm_mode_object *obj,
    5. drivers/gpu/drm/drm_crtc_internal.h:143:int drm_mode_object_add(struct drm_device *dev, struct drm_mode_object *obj,
    6. drivers/gpu/drm/drm_encoder.c:120: ret = drm_mode_object_add(dev, &encoder->base, DRM_MODE_OBJECT_ENCODER);
    7. drivers/gpu/drm/drm_framebuffer.c:858: ret = __drm_mode_object_add(dev, &fb->base, DRM_MODE_OBJECT_FB,
    8. drivers/gpu/drm/drm_mode_object.c:39:int __drm_mode_object_add(struct drm_device *dev, struct drm_mode_object *obj,
    9. drivers/gpu/drm/drm_mode_object.c:68: * drm_mode_object_add - allocate a new modeset identifier
    10. drivers/gpu/drm/drm_mode_object.c:79:int drm_mode_object_add(struct drm_device *dev,
    11. drivers/gpu/drm/drm_mode_object.c:82: return __drm_mode_object_add(dev, obj, obj_type, true, NULL);
    12. drivers/gpu/drm/drm_plane.c:193: ret = drm_mode_object_add(dev, &plane->base, DRM_MODE_OBJECT_PLANE);
    13. drivers/gpu/drm/drm_property.c:122: ret = drm_mode_object_add(dev, &property->base, DRM_MODE_OBJECT_PROPERTY);
    14. drivers/gpu/drm/drm_property.c:581: ret = __drm_mode_object_add(dev, &blob->base, DRM_MODE_OBJECT_BLOB,

    对象的描述

    • KMS object的描述句柄是drm_mode_object;
    • 属性的描述句柄是 drm_property;
    • 但这里 drm_property 属性不依赖与object对象,是一个独立的描述;
    • 但可以通过 drm_object_attach_property() 附加到不同的kms obejct上(drm_mode_object)

    image.png

    image.png

    • id:这个不用说就是 drm_device->mode_config.object_idr 中挂的ID索引。
    • type:这里只考虑crtc,connector,encoder,fb,plane, 在我们初始化这五大模块时会分配相应的类型。
    • properities:这个字段有些意思, 包含了 有效属性的个数:count,属性的指针, 属性的值。
    • refcount和free_cb 当然就是 drm_mode_object_get()[drm_mode_object_put()](https://www.kernel.org/doc/html/v5.12/gpu/drm-kms.html#c.drm_mode_object_put) 用来获取引用计数,是否对象的函数,但好像 大部分都使用 drm_mode_object_add 接口注册,所以并未使用这两个计数来自动清理,由驱动自己来管理和删除。<br />

    所以说 一个属性 可以挂在多个object上,且值不会出现同步问题,因为值绑定在了object域里,而不是放在了属性域里边

    drm_mode_object 有以下几个功能:

    • 内核通过 drm_mode_object_find()来查找获取KMS object对象当前结构;
    • 在初始化时,通过drm_object_attach_property() 链接(attached)了属性和值,
    • 由drm_crtc, drm_plane and drm_connector 等使用
    • 支持引用计数,可通过 drm_mode_object_get()drm_mode_object_put() 注:前提要自己实现free_cb接口
    • [drm_object_property_set_value()](https://www.kernel.org/doc/html/v5.12/gpu/drm-kms.html#c.drm_object_property_set_value) 和 [drm_object_property_get_value()](https://www.kernel.org/doc/html/v5.12/gpu/drm-kms.html#c.drm_object_property_get_value) 来设置和获取自己的属性值<br />

    image.png

    相关接口

    1. // include/drm/drm_mode_object.h
    2. // drivers/gpu/drm/drm_mode_object.c
    3. // include/drm/drm_mode_config.h
    4. // drivers/gpu/drm/drm_mode_config.c
    5. // 用户态接口, drm_mode_object_find 可被用户来根据id,type等字段查找并获取kms object
    6. // id: drm_object的查找id, 用户空间可见
    7. // type: 就是 drm_mode_object 中的type字段, 上边一堆 DRM_MODE_OBJECT_xxx, 懒得话可以写DRM_MODE_OBJECT_ANY
    8. struct drm_mode_object *drm_mode_object_find(struct drm_device *dev,
    9. struct drm_file *file_priv,
    10. uint32_t id, uint32_t type);
    11. => obj = idr_find(&dev->mode_config.object_idr, id); // id 其实 是 idr的查找索引
    12. => kref_get_unless_zero(&obj->refcount); // drm_mode_object_get
    13. drm_mode_object_get() and drm_mode_object_put(); // 这两个没啥好说的,引用和释放
    14. // 前边说过,属性独立存在,kms object 通过 drm_object_attach_property 来挂载一个属性
    15. // * @obj: drm modeset object
    16. // * @property: property to attach
    17. // * @init_val: initial value of the property
    18. void drm_object_attach_property(struct drm_mode_object *obj, struct drm_property *property, uint64_t init_val);
    19. => obj->properties->properties[count] = property; // 指针数组,指向属于域的指针
    20. => obj->properties->values[count] = init_val; // !!! 给property添加初始值,
    21. // 因为属性独立存在且可以挂载多个obj上,所以值需要存在自己的结构中
    22. => obj->properties->count++; // 属性个数++
    23. // 简单说就是从 struct drm_mode_object *obj 中获取属性的值
    24. int drm_object_property_set_value(struct drm_mode_object *obj, struct drm_property *property, uint64_t val);
    25. int drm_object_property_get_value(struct drm_mode_object *obj, struct drm_property *property, uint64_t *val);

    KMS的属性property

    参考: DRM应用程序进阶-何小龙 和 Linux DRM Internals-kms

    采用property机制的好处是:

    减少上层应用接口的维护工作量。当开发者有新的功能需要添加时,无需增加新的函数名和IOCTL,只需在底层驱动中新增一个property,然后在自己的应用程序中获取/操作该property的值即可。 增强了参数设置的灵活性。一次IOCTL可以同时设置多个property,减少了user space与kernel space切换的次数,同时最大限度的满足了不同硬件对于参数设置的要求,提高了软件效率。

    什么是属性

    KMS的属性,其实就是一个参数的描述, 我们在描述一个参数的时候,至少包含了以下信息:参数 名称、参数的类型(整型,枚举,bool等),参数的值域(我们给参数赋值必须在这个值域范围内)

    属性类型

    参考: DRM应用程序进阶-何小龙

    1. /* Property flags and type 支持以下几种类型的属性
    2. * !!!这个主要是描述 属性对应值得类型
    3. * DRM_MODE_PROP_RANGE // drm_property_create_range() 创建,unsigned类型,限制值得最大值和最小值
    4. * DRM_MODE_PROP_SIGNED_RANGE // drm_property_create_signed_range() 创建,同上,不过是signed类型
    5. * DRM_MODE_PROP_ENUM // drm_property_create_enum() 创建,枚举类型
    6. * DRM_MODE_PROP_BITMASK // drm_property_create_bitmask() 创建,bitmap类型(其实也是一种枚举类型)
    7. * // 以下几个属性是难点
    8. * DRM_MODE_PROB_OBJECT // drm_property_create_object() 创建,数据为 drm_mode_object ID 的索引
    9. * DRM_MODE_PROP_BLOB // drm_property_create() 创建,自定义长度的内存块, 后续详细描述
    10. // 在DRM的property type中,还有2种特殊的type,它们分别是 IMMUTABLE TYPE 和 ATOMIC TYPE。
    11. // 这两种type的特殊性在于,它们可以和上面任意一种property进行组合使用,用来修饰上面的property。
    12. * DRM_MODE_PROP_ATOMIC // 表示该property只有在drm应用程序(drm client)支持ATOMIC操作时才可见。
    13. * DRM_MODE_PROP_IMMUTABLE // 表示该property为只读,应用程序无法修改它的值,如"IN_FORMATS"。
    14. */

    属性的描述

    image.png

    • base : 每个drm驱动在创建属性的时候, 也会分配一个 DRM_MODE_OBJECT_PROPERTY 的对象,放在drm_mode_config中。
    • head:将所有属性挂在 drm_mode_config.property_list 上。
    • flags: 就是属性的类型
    • name:创建的属性名称
    • values:每种属性类型的value不一样

      1. DRM_MODE_PROP_RANGE 和 DRM_MODE_PROP_SIGNED_RANGE
      2. property->values[0] = min;
      3. property->values[1] = max;
      4. DRM_MODE_PROP_OBJECT
      5. property->values[0] = type; type为: DRM_MODE_OBJECT_CRTC, DRM_MODE_OBJECT_CONNECTOR ......之一
      6. DRM_MODE_PROP_ENUM 和 DRM_MODE_PROP_BITMASK
      7. property->value 是一个数组, 存放 struct drm_prop_enum_list 中提供的type
    • dev: 当前的drm_device

    枚举类型和bitmap类型drm_property_create_enum 函数,会先去创建一个枚举类型的property,然后去根据用户提供的property表:struct drm_prop_enum_list 去 分配空间并赋值。image.png参考 drm_mode_create_standard_properties

    1. // 1. 先初始化一个属性描述数组
    2. static const struct drm_prop_enum_list drm_plane_type_enum_list[] = {
    3. { DRM_PLANE_TYPE_OVERLAY, "Overlay" },
    4. { DRM_PLANE_TYPE_PRIMARY, "Primary" },
    5. { DRM_PLANE_TYPE_CURSOR, "Cursor" },
    6. };
    7. drm_property_create_enum(dev, DRM_MODE_PROP_IMMUTABLE,
    8. "type", drm_plane_type_enum_list,
    9. ARRAY_SIZE(drm_plane_type_enum_list));
    10. // 2. 创建并初始化property结构
    11. drm_property_create(dev, flags, name, num_values);
    12. // 3. 给每个枚举分配结构并赋值
    13. for (i = 0; i < num_values; i++)
    14. drm_property_add_enum

    相关接口

    这里关于blob的后续再看,先学习基础功能;

    1. // include/drm/drm_property.h
    2. // drivers/gpu/drm/drm_property.c
    3. // 判断property的 flags & type
    4. bool drm_property_type_is(struct drm_property *property, uint32_t type);
    5. // 根据id 从 struct drm_device *dev->mode_config.object_idr 中 查找property的属性
    6. // obj = idr_find(&dev->mode_config.object_idr, id);
    7. struct drm_property * drm_property_find(struct drm_device *dev, struct drm_file *file_priv, uint32_t id);
    8. // !!!
    9. // 创建property,并添加到 struct drm_device *dev->mode_config.property_list,.
    10. // 同时__drm_mode_object_add 添加idr到struct drm_device *dev->mode_config.object_idr 中,便于 drm_property_find 查找
    11. // kms调用 drm_object_attach_property 来链接上当前property
    12. // kms调用 drm_mode_config_cleanup=>drm_property_destroy 来是否property
    13. struct drm_property * drm_property_create(struct drm_device *dev, u32 flags, const char *name, int num_values);
    14. struct drm_property * drm_property_create_enum(struct drm_device *dev, u32 flags, const char *name,
    15. const struct drm_prop_enum_list *props, int num_values);
    16. struct drm_property * drm_property_create_bitmask(struct drm_device *dev, u32 flags, const char *name,
    17. const struct drm_prop_enum_list *props, int num_props, uint64_t supported_bits);
    18. struct drm_property * drm_property_create_range(struct drm_device *dev, u32 flags, const char *name, uint64_t min, uint64_t max);
    19. struct drm_property * drm_property_create_signed_range(struct drm_device *dev, u32 flags, const char *name, int64_t min, int64_t max);
    20. struct drm_property * drm_property_create_object(struct drm_device *dev, u32 flags, const char *name, uint32_t type);
    21. struct drm_property * drm_property_create_bool(struct drm_device *dev, u32 flags, const char *name);
    22. // 给枚举添加一个枚举值 以及对应的键值对
    23. int drm_property_add_enum(struct drm_property *property, uint64_t value, const char *name);
    24. // kms调用 drm_mode_config_cleanup=>drm_property_destroy 来是否property
    25. void drm_property_destroy(struct drm_device *dev, struct drm_property *property);
    26. // TBD
    27. struct drm_property_blob * drm_property_create_blob(struct drm_device *dev, size_t length, const void *data);
    28. void drm_property_blob_put(struct drm_property_blob *blob);
    29. struct drm_property_blob * drm_property_blob_get(struct drm_property_blob *blob);
    30. struct drm_property_blob * drm_property_lookup_blob(struct drm_device *dev, uint32_t id);
    31. int drm_property_replace_global_blob(struct drm_device *dev, struct drm_property_blob **replace, size_t length, const void *data,
    32. struct drm_mode_object *obj_holds_id, struct drm_property *prop_holds_id);
    33. bool drm_property_replace_blob(struct drm_property_blob **blob, struct drm_property_blob *new_blob)

    内核标准属性

    参考: DRM应用程序进阶-何小龙drm在调用 drm_mode_config_init 初始化 mode_config时,会调用 drm_mode_create_standard_properties 创建一系列的标准属性,可以说是必备的参数image.png

    在 Linux DRM Internals-kms 中 KMS Properities末尾,提供了一些标准属性,包含:

    • Standard Connector Properties
    • HDMI Specific Connector Properties
    • Standard CRTC Properties
    • Standard Plane Properties
    • Plane Composition Properties
    • Damage Tracking Properties
    • Color Management Properties
    • Tile Group Property
    • Explicit Fencing Properties
    • Variable Refresh Properties

    并提供了 : Existing KMS Properties这里不一一列举,直接参考内核文档,在用到的时候在来枚举

    Atomic下得属性配置

    在libdrm得源码中,下边实现区分了atomic和非atomic模式, 用户使用都推荐使用atomic模式来进行参数得配置

    用户层接口

    其实 驱动支持了 atomic, 无论应用 是否 带有 -a 参数, 最终底层调用都是一致得,不过-a得好处是一次ioctl配置完成了所有参数,减少新接口得注册,且对用户来说原子操作image.png

    atomic设置模式

    在执行 drmModeAtomicCommit 时底层支持三种模式:atomic配置一次属性, 需要经过 atomic_check + atomic_commit,以下是这三种模式得区别

    1. /* page-flip flags are valid, plus: */
    2. #define DRM_MODE_ATOMIC_TEST_ONLY 0x0100 // 仅仅调用atomic_check
    3. #define DRM_MODE_ATOMIC_NONBLOCK 0x0200 // atomic_check + atomic_commit(nonblock)
    4. #define DRM_MODE_ATOMIC_ALLOW_MODESET 0x0400 // atomic_check + atomic_commit(block), modetest 使用这种模式,

    drm_atomic_state得分配

    在配置过程中,首先看到的是 drm_atomic_state的更替,这个是做什么的

    1. state = drm_atomic_state_alloc(dev);
    2. drm_atomic_set_property // 以Plane为例, connector和crtc类似
    3. drm_atomic_get_plane_state(state, plane);
    4. drm_atomic_plane_set_property(plane,plane_state, file_priv,prop, prop_value);

    内核层属性配置流程

    drm.drawio

    prepare_signaling 和 complete_signaling

    KMS核心功能

    1. struct drm_mode_config_funcs {
    2. struct drm_framebuffer *(*fb_create)(struct drm_device *dev,struct drm_file *file_priv, const struct drm_mode_fb_cmd2 *mode_cmd);
    3. const struct drm_format_info *(*get_format_info)(const struct drm_mode_fb_cmd2 *mode_cmd);
    4. void (*output_poll_changed)(struct drm_device *dev);
    5. enum drm_mode_status (*mode_valid)(struct drm_device *dev, const struct drm_display_mode *mode);
    6. int (*atomic_check)(struct drm_device *dev, struct drm_atomic_state *state);
    7. int (*atomic_commit)(struct drm_device *dev,struct drm_atomic_state *state, bool nonblock);
    8. struct drm_atomic_state *(*atomic_state_alloc)(struct drm_device *dev);
    9. void (*atomic_state_clear)(struct drm_atomic_state *state);
    10. void (*atomic_state_free)(struct drm_atomic_state *state);
    11. };

    fb_create

    在 libdrm 测试中 ,分配drm buffer 使用 DRM_IOCTL_MODE_ADDFB2 进行分配,这部分在内核实现如下:

    atomic提交操作

    9.Linux KMS-2 - 图12

    01-shell脚本介绍-《shell脚本》 系统网络

    01-shell脚本介绍-《shell脚本》

    一、shell脚本是什么二、为什么要学shell,而不是其他计算机语言三、学习这门课程的优势四、学了能干什么五、学习什么内容六、学习的技巧七、成长路径八、学习环
    评论:0   参与:  18