news 2026/5/17 4:44:20

Arduino nRF52 BLE开发:GATT服务与特征值配置实战详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Arduino nRF52 BLE开发:GATT服务与特征值配置实战详解

1. 项目概述

如果你正在用Arduino和nRF52系列芯片(比如nRF52832或nRF52840)做蓝牙低功耗(BLE)开发,那你肯定绕不开GATT(通用属性配置文件)这一关。GATT是BLE通信的“语言规则”,它定义了设备之间如何组织、发现和使用数据。简单来说,它把数据封装成一个个有特定功能的“服务”,每个服务里又包含多个可读、可写或可订阅的“特征值”。理解并正确配置它们,是从“能让设备被手机搜到”到“能和手机稳定交换有用数据”的关键一步。

我折腾过不少基于Adafruit_nRF52_Arduino库的项目,从简单传感器数据上报到复杂的多服务外设。刚开始时,最让人头疼的不是代码逻辑,而是服务和特征值配置时那些看似不起眼、实则一踩就崩的细节。比如,为什么特征值死活收不到手机端的通知?为什么服务UUID在扫描时看不到?这些问题,十有八九都出在配置顺序、属性设置或回调处理上。

这篇文章,我就以Adafruit的库为例,带你彻底搞懂BLEServiceBLECharacteristic这两个核心类。我不会只给你看API手册(那玩意儿网上都有),而是结合我实际踩过的坑,把配置流程、参数含义、以及那些官方文档里可能没明说但至关重要的“潜规则”讲清楚。目标是让你看完后,能独立设计并实现一个稳定可靠的BLE自定义服务。

2. GATT模型与nRF52库核心类解析

在动手写代码前,我们得先统一一下“世界观”。BLE通信里,作为外设(Peripheral)的设备,比如你的心率手环,它的数据是以一个称为GATT表的层次化结构暴露给中心设备(Central,比如手机)的。

这个结构可以想象成一本有多章节的书:

  • GATT表:整本书。
  • 服务:书里的一个章节,比如“心率监测”章节或“电池信息”章节。每个服务由一个唯一的UUID标识。UUID有16位短格式(如0x180D代表心率服务)和128位长格式(用于自定义服务)。
  • 特征值:章节里的具体段落,是实际承载数据的最小单元。比如“心率测量值”这个段落。每个特征值也拥有自己的UUID、一组定义其行为的属性(读、写、通知等),以及实际的数据值
  • 描述符:对段落的附加说明。最重要的描述符是CCCD,全称是“客户端特征值配置描述符”。中心设备通过写入CCCD来订阅或取消订阅特征值的“通知”或“指示”。

Adafruit_nRF52_Arduino库对这套模型进行了面向对象的封装,让开发变得直观。其中,最核心的两个类就是BLEServiceBLECharacteristic

BLEService:对应GATT服务。它的工作很简单,主要就是宣告自己的存在。在代码中,你实例化一个BLEService对象,并传入服务UUID。它的核心任务是在你调用其.begin()方法时,在nRF52芯片的底层协议栈(SoftDevice)中注册这个服务,为后续添加特征值准备好“容器”。

BLECharacteristic:对应GATT特征值。这是真正干活的类,功能复杂得多。你需要通过它来:

  1. 定义行为:设置属性(setProperties),告诉外界这个数据是只读的、可写的,还是可以主动推送的(通知/指示)。
  2. 设置安全:配置读写权限(setPermission),决定是否需要配对、加密。
  3. 定义数据格式:指定数据是固定长度(setFixedLen)还是可变长度(setMaxLen)。
  4. 处理交互:注册回调函数(如setCccdWriteCallback),以便在手机端读写或订阅数据时,你的固件能做出响应。
  5. 操作数据:使用write()notify()等方法更新特征值的数据。

一个至关重要的“潜规则”:这两个类之间存在一个隐式的“当前服务”指针。当你调用某个BLECharacteristic实例的.begin()方法时,这个特征值会被自动添加到最后一个调用了.begin()BLEService之下。这意味着配置顺序绝对不能错:必须先service.begin(),再characteristic.begin()。顺序反了,特征值就会挂错服务甚至导致初始化失败,这个坑我早期项目里踩过好几次。

3. 服务与特征值的配置流程与实战

理论说再多,不如一行代码。我们直接用一个完整的自定义心率监测服务作为例子,把配置流程掰开揉碎讲清楚。这个例子实现了标准的心率服务(UUID: 0x180D),包含一个用于实时推送心率数据的“测量值”特征值(Notify属性),和一个用于读取传感器佩戴位置的“身体传感器位置”特征值(Read属性)。

3.1 服务与特征值的定义与初始化

首先,我们需要在全局范围声明服务和特征值对象。UUID最好使用库预定义的宏,提高可读性。

#include <bluefruit.h> // 服务与特征值定义 // 心率服务 (Heart Rate Service) BLEService hrms = BLEService(UUID16_SVC_HEART_RATE); // 心率测量特征值 (Heart Rate Measurement Characteristic) BLECharacteristic hrmc = BLECharacteristic(UUID16_CHR_HEART_RATE_MEASUREMENT); // 身体传感器位置特征值 (Body Sensor Location Characteristic) BLECharacteristic bslc = BLECharacteristic(UUID16_CHR_BODY_SENSOR_LOCATION); // 用于模拟心率数据的变量 uint8_t heartRateBpm = 72; bool sensorContactDetected = true;

接下来,我们在setup()函数中初始化BLE并配置我们的自定义服务。我强烈建议将服务配置单独写成一个函数,比如setupHRM(),这样结构更清晰。

void setup() { Serial.begin(115200); while (!Serial) delay(10); // 等待串口就绪,仅用于调试 Serial.println("Bluefruit52 BLE GATT 示例 - 自定义心率监测"); Serial.println("-------------------------------------------"); // 1. 初始化Bluefruit BLE模块 Bluefruit.begin(); // 设置设备名称,这在手机扫描时会显示 Bluefruit.setName("My_BLE_HRM"); // 2. 设置连接回调(可选,用于监控连接状态) Bluefruit.setConnectCallback(connect_callback); Bluefruit.setDisconnectCallback(disconnect_callback); // 3. 配置并启动我们的自定义心率服务 setupHRM(); // 4. 设置广播数据包并开始广播 setupAdvertising(); Serial.println("设备就绪,正在广播..."); } void setupHRM(void) { // --- 核心步骤1:启动服务 --- // 这是铁律!必须先调用服务的.begin() hrms.begin(); // --- 核心步骤2:配置并添加第一个特征值 (心率测量 - Notify) --- // 设置属性:这个特征值支持“通知” hrmc.setProperties(CHR_PROPS_NOTIFY); // 设置权限:允许任何设备读取(SECMODE_OPEN),但不允许写入(SECMODE_NO_ACCESS) // CCCD的写入权限是独立控制的,这里设置的是特征值本身数据的权限。 hrmc.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); // 设置固定长度:根据蓝牙规范,心率测量值至少1字节,最多8字节。 // 我们这里使用2字节格式:1字节标志位 + 1字节心率值。 hrmc.setFixedLen(2); // 注册CCCD写入回调。当手机端启用或禁用通知时,会触发此回调。 hrmc.setCccdWriteCallback(cccd_callback); // 将此特征值添加到上一个begin()的服务(即hrms)中 hrmc.begin(); // 写入初始值:标志位(0x06)表示8位心率值、传感器接触已检测。 uint8_t initialHrmData[2] = { 0b00000110, heartRateBpm }; hrmc.notify(initialHrmData, sizeof(initialHrmData)); // --- 核心步骤3:配置并添加第二个特征值 (身体传感器位置 - Read) --- // 设置属性:这个特征值只读 bslc.setProperties(CHR_PROPS_READ); // 设置权限:允许读,不允许写 bslc.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); // 固定长度1字节 bslc.setFixedLen(1); // 添加到服务 bslc.begin(); // 写入初始值:2 = 手腕 (Wrist) bslc.write8(2); }

关键点解析

  1. setProperties():这里用的是CHR_PROPS_NOTIFYCHR_PROPS_READCHR_PROPS_WRITECHR_PROPS_INDICATE等可以通过位或操作|组合使用。例如,一个既可读又可写的特征值应设为CHR_PROPS_READ | CHR_PROPS_WRITE
  2. setPermission():第一个参数是读权限,第二个是写权限。SECMODE_OPEN表示无安全要求,SECMODE_ENC_NO_MITM表示需要加密但无需MITM保护,SECMODE_ENC_WITH_MITM则需要带人机交互的加密配对。对于心率测量这种一般数据,OPEN即可。如果是门锁密码,则需更高安全等级。
  3. setFixedLen(2):这里指定了特征值数据的固定长度为2字节。如果你要发送可变长度的字符串,应使用setMaxLen(maxLength)
  4. hrmc.notify()vshrmc.write()notify()用于主动向已订阅的客户端推送数据,并更新本地特征值。write()仅更新本地特征值,不发送通知。对于具有NOTIFYINDICATE属性的特征值,向客户端推送数据应始终使用notify()indicate()

3.2 广播配置与连接管理

设备需要广播才能被手机发现。广播包中可以包含服务UUID,让手机提前知道设备具备哪些功能,这称为“服务广播”。

void setupAdvertising(void) { // 配置广播包 Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); Bluefruit.Advertising.addTxPower(); // 包含发射功率信息,有助于距离估算 // 将心率服务的UUID加入广播包,让扫描设备提前知晓 Bluefruit.Advertising.addService(hrms); // 配置扫描响应包(可选)。当手机主动扫描请求时,会回复此包。 // 因为广播包空间有限(31字节),有时会把设备名放在扫描响应里。 Bluefruit.ScanResponse.addName(); // 设置广播参数 Bluefruit.Advertising.restartOnDisconnect(true); // 断开连接后自动重新开始广播 Bluefruit.Advertising.setInterval(32, 244); // 广播间隔:快模式32*0.625ms=20ms,慢模式244*0.625ms≈152.5ms Bluefruit.Advertising.setFastTimeout(30); // 快模式持续30秒后切换至慢模式 // 开始广播,参数0表示永不超时停止 Bluefruit.Advertising.start(0); }

连接和断开回调可以帮助我们管理设备状态,比如控制LED或停止/启动某些任务。

void connect_callback(uint16_t conn_handle) { (void) conn_handle; // 消除未使用参数的警告 Serial.println("客户端已连接"); // 例如,连接后点亮绿色LED digitalWrite(LED_GREEN, HIGH); } void disconnect_callback(uint16_t conn_handle, uint8_t reason) { (void) conn_handle; Serial.print("客户端已断开,原因: 0x"); Serial.println(reason, HEX); // 断开后熄灭绿色LED digitalWrite(LED_GREEN, LOW); }

3.3 处理客户端交互:CCCD回调与数据更新

BLE通信是事件驱动的。当手机端(客户端)想要订阅心率数据时,它会向心率测量特征值的CCCD描述符写入一个值(0x0001启用通知,0x0002启用指示,0x0000禁用)。我们需要通过回调函数来捕获这个事件。

void cccd_callback(uint16_t conn_hdl, BLECharacteristic* chr, uint16_t cccd_value) { // conn_hdl: 连接句柄,用于多连接场景区分不同客户端 // chr: 触发回调的特征值指针 // cccd_value: 客户端写入CCCD的值 Serial.print("CCCD 更新,特征值 UUID: "); // 打印特征值UUID(这里简化处理,实际可打印16进制) Serial.print(",新值: 0x"); Serial.println(cccd_value, HEX); // 判断是哪个特征值的CCCD被更新了 if (chr->uuid == hrmc.uuid) { if (chr->notifyEnabled(conn_hdl)) { // 检查指定连接的通知是否已启用 Serial.println("心率测量 '通知' 已启用"); // 可以在这里开始定时发送心率数据 } else { Serial.println("心率测量 '通知' 已禁用"); // 可以在这里停止发送数据 } } }

loop()函数中,我们可以模拟心率数据的周期性更新。关键点:只有在设备已连接客户端已启用通知的情况下,notify()调用才会成功将数据推送给手机。否则,数据只会更新在本地,不会发送。

void loop() { // 模拟心率测量,每秒更新一次 static uint32_t lastUpdate = 0; if (millis() - lastUpdate > 1000) { lastUpdate = millis(); // 模拟心率波动 heartRateBpm = 70 + random(-5, 6); if (Bluefruit.connected()) { // 准备数据包:标志位 + 心率值 uint8_t hrmData[2] = { 0b00000110, heartRateBpm }; // 尝试发送通知 // .notify()会检查CCCD状态,只有启用通知的连接才会实际发送数据包 if (hrmc.notify(hrmData, sizeof(hrmData))) { Serial.print("心率数据已发送: "); Serial.println(heartRateBpm); } else { // 这可能是因为连接已断开,或者客户端未启用通知 Serial.println("通知发送失败 (未连接或CCCD未启用)"); } } } // 必须调用这个函数来处理底层的BLE事件 Bluefruit.periphService(); // 短暂延时,避免CPU占用率100% delay(10); }

4. 核心API深度解析与配置陷阱

掌握了基本流程后,我们再来深入看看几个核心API的细节和那些容易出错的配置点。

4.1 BLECharacteristic属性与权限详解

属性定义了特征值能做什么,是GATT通信的“行为准则”。

  • CHR_PROPS_READ: 可读。客户端可以使用“读请求”。
  • CHR_PROPS_WRITE/CHR_PROPS_WRITE_WO_RESP: 可写。前者需要客户端确认(有响应),后者无需响应,速度更快但不可靠。
  • CHR_PROPS_NOTIFY: 可通知。服务器可以主动向客户端推送数据,客户端无需确认。这是最常用的数据推送方式。
  • CHR_PROPS_INDICATE: 可指示。与通知类似,但客户端必须收到后回复确认,更可靠但开销稍大。
  • CHR_PROPS_BROADCAST: 可广播。数据可通过广播包发送,无需连接。

属性选择陷阱:一个特征值可以同时拥有多个属性,但NOTIFYINDICATE必须配合READ属性吗?不一定。规范没有强制要求。但很多手机端BLE库(如iOS的CoreBluetooth)在发现一个特征值具有NOTIFY/INDICATE属性时,会自动尝试去读取它的值。如果你的特征值没有READ属性,这次读取会失败,可能导致手机端日志报错(虽然通知功能可能仍正常)。为了更好的兼容性,我建议对用于通知的特征值也加上READ属性,即使用CHR_PROPS_READ | CHR_PROPS_NOTIFY

权限定义了执行这些操作需要满足的安全条件。它独立于属性,但共同生效。例如,一个特征值属性是READ,但权限设置为SECMODE_ENC_WITH_MITM,那么未配对的设备将无法读取它。 权限参数是一个枚举,常见的有:

  • SECMODE_NO_ACCESS: 禁止访问。
  • SECMODE_OPEN: 完全开放,无需安全措施。
  • SECMODE_ENC_NO_MITM: 需要加密链路,但不需要人工确认的配对。
  • SECMODE_ENC_WITH_MITM: 需要加密链路且配对过程需要人工确认(如显示PIN码)。

4.2 数据长度:FixedLen vs MaxLen

  • setFixedLen(len): 设置固定长度。适用于数据格式严格、长度不变的场景(如本例中的2字节心率数据)。协议栈处理效率更高。
  • setMaxLen(max_len): 设置最大长度。适用于可变长度数据,如字符串。实际写入或通知的数据长度可以小于或等于这个最大值。

一个常见的坑:如果你为一个特征值设置了setFixedLen(5),但尝试用hrmc.notify(data, 3)发送3字节数据,操作会失败。长度必须严格匹配。对于可变长度数据,务必使用setMaxLen()

4.3 CCCD回调与多连接处理

cccd_callback的参数中包含uint16_t conn_hdl(连接句柄)。这在设备支持同时连接多个手机(比如一个nRF52840外设连接两部手机)时至关重要。你需要用这个句柄来区分是哪个客户端启用或禁用了通知。

库提供了chr->notifyEnabled(conn_hdl)chr->indicateEnabled(conn_hdl)方法来查询特定连接的状态。在发送通知时,notify()方法内部会自动处理,只向启用了通知的连接发送。

如果你需要向所有已启用通知的连接广播数据,库的默认notify()方法已经做到了。但如果你想针对不同连接发送不同数据,就需要自己维护一个连接句柄列表,并在回调中更新每个连接的CCCD状态,然后在loop中遍历列表发送。

4.4 添加自定义描述符

除了CCCD,你还可以为特征值添加其他描述符,比如“用户描述描述符”来用文本说明这个特征值的用途。

// 在 characteristic.begin() 之后,可以添加描述符 err_t err = hrmc.addDescriptor( UUID16_CHR_USER_DESCRIPTION, // 用户描述描述符的UUID (0x2901) "Heart Rate Measurement in BPM", // 描述内容 strlen("Heart Rate Measurement in BPM"), // 内容长度 SECMODE_OPEN, // 读权限 SECMODE_NO_ACCESS // 写权限 ); if (err) { Serial.println("添加用户描述描述符失败!"); }

5. 调试技巧与常见问题排查

BLE调试,三分靠代码,七分靠工具和耐心。以下是几个我总结的实用技巧和常见问题。

5.1 使用手机APP进行调试

不要只依赖串口打印。在手机上安装专业的BLE调试工具至关重要,我常用的有:

  • nRF Connect(Nordic Semiconductor): 功能最强大,可以扫描、连接、浏览GATT表、读写特征值、订阅通知、查看原始日志。必备神器
  • LightBlue(Punch Through): 界面更友好,适合快速验证基本功能。

调试流程

  1. 烧录程序,打开串口监视器。
  2. 手机打开nRF Connect,扫描设备。确认能看到你的设备名(Bluefruit.setName设置的)。
  3. 点击连接,浏览GATT表。检查你的服务UUID(0x180D)和特征值UUID(0x2A37, 0x2A38)是否正确出现。
  4. 检查特征值的属性图标是否与你的代码设置一致(读、写、通知等)。
  5. 尝试读取“身体传感器位置”特征值,应该返回0x02(手腕)。
  6. 点击心率测量特征值旁边的“通知使能”图标(三个箭头)。此时,你的串口应该打印出“CCCD Updated... Notify enabled”。
  7. 观察是否开始收到心率数据流。数据格式应为两字节,第一字节是标志位0x06,第二字节是变化的心率值。

5.2 常见问题与解决方案

下面是一个快速排查表格,列出了最常见的问题现象、可能原因和解决方法。

问题现象可能原因排查步骤与解决方案
手机扫描不到设备1. 广播未启动。
2. 广播参数设置不当,间隔太短或功率太低。
3. 硬件或电源问题。
1. 检查串口日志,确认Bluefruit.Advertising.start(0)已执行且无报错。
2. 尝试调整setInterval参数,增大慢模式间隔(如setInterval(32, 1600))。确保Bluefruit.begin()成功。
3. 检查硬件连接,确保nRF52模块供电稳定。
能连接,但看不到自定义服务1. 服务未正确初始化(hrms.begin()失败或未调用)。
2. 服务UUID未加入广播包(可选,但推荐)。
3. 手机端缓存了旧的GATT表。
1. 检查setupHRM()函数是否被调用,且hrms.begin()在特征值begin()之前。
2. 确认setupAdvertising()中调用了Bluefruit.Advertising.addService(hrms)
3. 在nRF Connect中,连接后尝试点击“Refresh”按钮,或重启手机蓝牙。
特征值属性显示不正确setProperties()参数设置错误。1. 在代码中核对setProperties()调用。
2. 使用CHR_PROPS_READ | CHR_PROPS_NOTIFY这样的位或操作来组合属性。
无法读取特征值1. 特征值权限setPermission()禁止读取。
2. 特征值未写入初始值。
3. 安全权限要求未满足(如需要配对)。
1. 检查setPermission第一个参数是否为SECMODE_OPEN或相应安全模式。
2. 确保在begin()后调用了write8()write()设置了初始值。
3. 如果设置了加密权限,请先在手机端完成配对。
能启用通知,但收不到数据1.loop()中的notify()调用条件不满足或未执行。
2.notify()调用失败但未检查返回值。
3. 数据长度与setFixedLen()不匹配。
4.最常见:忘记调用Bluefruit.periphService()
1. 在loop中打印调试信息,确认notify函数被调用。
2. 检查if (hrmc.notify(...))的返回值,为false则发送失败。
3. 确认发送的数据字节数等于setFixedLen设置的值。
4.务必在loop()中定期调用Bluefruit.periphService()以处理底层BLE事件!
CCCD回调函数不触发1. 回调函数未正确注册(setCccdWriteCallback)。
2. 回调函数签名错误。
1. 确保在特征值begin()之前调用了setCccdWriteCallback
2. 检查回调函数签名是否为void func(uint16_t conn_hdl, BLECharacteristic* chr, uint16_t cccd_value)
程序运行不稳定或重启1. 栈溢出或内存不足。
2. BLE事件处理阻塞。
1. 尝试减少栈消耗,避免在中断或回调中做复杂操作。
2. 确保loop()delay时间不长,并定期调用Bluefruit.periphService()。使用millis()进行非阻塞定时。

5.3 串口日志是最好朋友

在代码关键位置添加详细的串口打印,是定位问题的根本。

void setupHRM() { Serial.println("[配置] 开始配置HRM服务..."); err_t err = hrms.begin(); if (err) { Serial.print("[错误] 服务初始化失败,错误码: 0x"); Serial.println(err, HEX); while(1); // 停在这里 } Serial.println("[配置] 服务初始化成功."); hrmc.setProperties(CHR_PROPS_NOTIFY | CHR_PROPS_READ); Serial.println("[配置] 特征值属性已设置."); // ... 后续配置 } void cccd_callback(...) { Serial.printf("[回调] CCCD更新. Conn Handle: %d, Char UUID: ", conn_hdl); // 可以打印UUID Serial.printf(", Value: 0x%04X\n", cccd_value); }

通过系统的日志,你可以清晰地看到初始化流程、回调触发时机和数据发送状态,绝大多数问题都能被迅速定位。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/17 4:44:17

Linux桌面光标残留修复:unclutter-xfixes原理与配置指南

1. 项目概述&#xff1a;一个解决鼠标光标“幽灵”问题的桌面利器如果你是一个Linux桌面用户&#xff0c;尤其是喜欢使用多显示器或者经常在窗口之间快速切换&#xff0c;那么你很可能遇到过那个恼人的问题&#xff1a;鼠标光标消失了&#xff0c;或者更确切地说&#xff0c;它…

作者头像 李华
网站建设 2026/5/17 4:40:04

Arm Cortex-A35 Cycle Model技术解析与SoC集成实战

1. Arm Cortex-A35 Cycle Model技术解析在SoC设计领域&#xff0c;虚拟平台验证已成为不可或缺的关键环节。作为Armv8-A架构中的能效比优化核心&#xff0c;Cortex-A35处理器通过Cycle Model提供了RTL级精度的硬件行为模拟能力。我在多个车载SoC项目中验证发现&#xff0c;其Cy…

作者头像 李华
网站建设 2026/5/17 4:39:47

HTTP压缩代理squeez:微服务架构下的网络传输优化实践

1. 项目概述&#xff1a;一个轻量级、高性能的HTTP请求压缩代理最近在排查一个线上服务的性能瓶颈时&#xff0c;发现一个有趣的现象&#xff1a;某个微服务集群与前端应用之间的网络传输数据量巨大&#xff0c;其中包含了大量重复的JSON结构体和静态资源路径。虽然服务本身运行…

作者头像 李华
网站建设 2026/5/17 4:39:41

嵌入式轻量级RTOS OpenPisci:从内核原理到STM32移植实战

1. 项目概述&#xff1a;一个面向嵌入式系统的轻量级实时操作系统最近在折腾一些资源受限的嵌入式设备&#xff0c;比如STM32F103这类Cortex-M3内核的MCU&#xff0c;发现很多现有的RTOS&#xff08;实时操作系统&#xff09;要么太“重”&#xff0c;要么配置起来过于复杂。就…

作者头像 李华
网站建设 2026/5/17 4:39:22

详解C++11 线程休眠函数

C 11之前并未提供专门的休眠函数。c语言的sleep、usleep其实都是系统提供的函数&#xff0c;不同的系统函数的功能还有些差异。在Windows系统中&#xff0c;sleep的参数是毫秒。1sleep(2*1000); //sleep for 2 seconds在类Unix系统中&#xff0c;sleep()函数的单位是秒。1sleep…

作者头像 李华