• 发送和接收 RTCP 包的规则

    RTCP Packet Send and Receive Rules: 发送和接收 RTCP 包的规则在此列出。允许在多播环境或多点单播环境中运行的实现必须满足第 6.2 节中的要求。这样的实现可以使用本节定义的算法来满足这些要求,或者可以使用其他算法,只要其性能等同或更优即可。在受限于两方单播操作的实现中,仍然应使用 RTCP 传输间隔的随机化,以避免在相同环境中运行的多个实例产生意外的同步,但可以省略第 6.3.3、6.3.6 和 6.3.7 节中的“计时器再考虑(timer reconsideration)”和“反向再考虑(reverse reconsideration)”算法。 为了执行这些规则,会话参与者必须维护几项状态: tp:上一次发送 RTCP 包的时间; tc:当前时间; tn:计划发送下一个 RTCP 包的时间; pmembers:上次重新计算 tn 时估算的会话成员数量; members:当前估算的会话成员数量; senders:当前估算的会话发送者数量; rtcp_bw:RTCP 目标带宽,即该会话所有成员发送 RTCP 包所使用的总带宽,单位为每秒字节数。这个值将是应用启动时提供的“会话带宽”参数的一个指定比例; we_sent:一个标志,如果应用自前两次 RTCP 报告后已发送数据,则该标志为真; avg_rtcp_size:该参与者发送和接收的 RTCP 包的平均复合大小(以字节为单位)。该大小包括较低层传输和网络协议头(如 UDP 和 IP),具体见第 6.2 节的说明; initial:一个标志,如果应用尚未发送任何 RTCP 包,则该标志为真。 这些规则中的许多使用了“计算出的包传输间隔”。此间隔将在接下来的部分中进行描述。 Computing the RTCP Transmission Interval: 为了保持可扩展性,会话参与者发送包的平均间隔应随着组的大小而变化。这个间隔称为“计算出的间隔(calculated interval)”。它是通过结合上面描述的多个状态来获得的。计算出的间隔 TTT 的确定方法如下: 如果发送者的数量小于或等于成员(members)数量的 25%,则间隔取决于该参与者是否为发送者(依据 we_sent 的值)。若参与者为发送者(we_sent 为真),则常数 C 设为平均 RTCP 包大小(avg_rtcp_size)除以 RTCP 带宽(rtcp_bw)的 25%,常数 n 设为发送者的数量。如果 we_sent 为假,常数 C 设为平均 RTCP 包大小除以 RTCP 带宽的 75%,常数 n 设为接收者的数量(members - senders)。如果发送者数量超过 25%,则发送者和接收者一起处理。常数 C 设为平均 RTCP 包大小除以总的 RTCP 带宽,n 设为成员总数。正如第 6.2 节所述,一个 RTP 配置文件可以规定 RTCP 带宽由两个独立的参数明确定义(分别称为 S 和 R),分别用于发送者和非发送者。在这种情况下,25% 的比例为 S/(S+R),75% 的比例为 R/(S+R)。注意,如果 R 为零,则发送者的比例永远不会超过 S/(S+R),实现时必须避免除零的情况。 如果参与者尚未发送任何 RTCP 包(变量 initial 为真),则常数 Tmin 设为 2.5 秒,否则设为 5 秒。 确定性的计算间隔 Td 设为 max(Tmin, n*C)。 计算出的间隔 T 设为 0.5 到 1.5 倍的确定性计算间隔之间的一个均匀分布的数值。 为了补偿计时器再考虑算法使 RTCP 带宽收敛于低于预期平均值的情况,最终得到的 T 值除以 e^-3/2 = 1.21828。 这个过程得出的间隔是随机的,但平均来看,至少 25% 的 RTCP 带宽分配给发送者,其余分配给接收者。如果发送者数量超过成员的四分之一,则该过程平均上在所有参与者之间平分带宽 Initialization: 加入会话后,参与者将 tp 初始化为 0,tc 初始化为 0,senders 初始化为 0,pmembers 初始化为 1,members 初始化为 1,we_sent 初始化为 false,rtcp_bw 初始化为会话带宽的指定比例,initial 初始化为 true,avg_rtcp_size 初始化为应用程序稍后将构建的第一个 RTCP 包的预计大小。随后计算出间隔 T,并将第一个包的计划发送时间设定为 tn = T。这意味着设置一个传输计时器,使其在时间 T 到期。请注意,应用程序可以使用任何所需的方法来实现该计时器。参与者将其自身的 SSRC 添加到成员表中。 Receiving an RTP or Non-BYE RTCP Packet: 当从一个 SSRC 不在成员表中的参与者接收到 RTP 或 RTCP 包时,该 SSRC 会被添加到表中,并且一旦该参与者按第 6.2.1 节所述验证通过,members 的值将被更新。对于验证通过的 RTP 包中的每个 CSRC,也会进行相同的处理。当从一个 SSRC 不在发送者表中的参与者接收到 RTP 包时,该 SSRC 会被添加到表中,senders 的值将被更新。对于每个接收到的复合 RTCP 包,avg_rtcp_size 的值会被更新: 其中,packet_size 是刚刚接收到的 RTCP 包的大小。 Receiving an RTCP BYE Packet: 除非在第 6.3.7 节中描述的发送 RTCP BYE 的情况外,如果接收到的包是 RTCP BYE 包,则会将其 SSRC 与成员表进行检查。如果该 SSRC 存在于成员表中,则从表中删除该条目,并更新 members 的值。然后,SSRC 将与发送者表进行检查。如果存在,则从表中删除该条目,并更新 senders 的值。 此外,为使 RTCP 包的传输速率能够更适应组成员数的变化,当接收到的 BYE 包使 members 值小于 pmembers 时,应执行以下“反向再考虑”算法: 根据以下公式更新 tn 的值: 根据以下公式更新 tp 的值: 将下一个 RTCP 包的计划发送时间重新设定为 tn,此时该时间比原计划更早。 将 pmembers 的值设为与 members 相等。 该算法并不能完全防止在大型会话中的大部分参与者突然离开但部分仍在的情况下,由于过早超时而导致组大小估算短时间内错误地降至零。然而,该算法可以使估算值更快速地恢复到正确值。这种情况非常少见,且后果较为无害,因此被视为次要问题。 Timing Out an SSRC: 参与者必须定期检查其他参与者是否超时。为此,参与者计算接收者的确定性(不含随机因子)计算间隔 TdTdTd,即 we_sent 为 false 的情况。任何自时间 tc−MTdtc - MTdtc−MTd(其中 MMM 为超时倍数,默认值为 5)起未发送 RTP 或 RTCP 包的会话成员将被视为超时。这意味着其 SSRC 会从成员列表中删除,且 members 的值会更新。发送者列表也需进行类似的检查。任何自时间 tc−2Ttc - 2Ttc−2T(即在最近两个 RTCP 报告间隔内)未发送 RTP 包的发送者会从发送者列表中删除,并更新 senders 的值。如果有任何成员超时,应执行第 6.3.4 节中描述的反向再考虑算法。参与者必须至少在每个 RTCP 传输间隔内执行一次此检查。 Expiration of Transmission Timer: 当数据包传输计时器到期时,参与者执行以下操作: 按第 6.3.1 节所述计算传输间隔 TTT,包括随机因子。 如果 tp+T≤tctp + T \leq tctp+T≤tc,则发送一个 RTCP 包。将 tptptp 设为 tctctc,然后按前一步计算另一个 TTT 值,并将 tntntn 设为 tc+Ttc + Ttc+T。将传输计时器设置为在时间 tntntn 到期。如果 tp+T>tctp + T > tctp+T>tc,则将 tntntn 设为 tp+Ttp + Ttp+T。不发送 RTCP 包,传输计时器设置在时间 tntntn 到期。 将 pmembers 设为 members。 如果发送了 RTCP 包,则将 initial 值设为 FALSE。此外,更新 avg_rtcp_size 的值: 其中,packet_size 是刚刚发送的 RTCP 包的大小. Transmitting a BYE Packet: 当参与者希望离开会话时,会发送一个 BYE 包,以告知其他参与者该事件。为避免当许多参与者离开系统时出现 BYE 包的洪流,如果在参与者选择离开时成员数超过 50,则必须执行以下算法。该算法会改变成员变量的正常作用,将其用于统计 BYE 包的数量: 当参与者决定离开系统时,将 tp 重置为 tc(当前时间),并将 members 和 pmembers 初始化为 1,initial 设为 1,we_sent 设为 false,senders 设为 0,avg_rtcp_size 设为复合 BYE 包的大小。随后计算出的间隔 TTT 会被计算出。BYE 包则计划在 tn=tc+Ttn = tc + Ttn=tc+T 的时间发送。 每当接收到来自其他参与者的 BYE 包时,不论该参与者是否存在于成员表中,以及在使用 SSRC 采样时 BYE 的 SSRC 是否会包含在样本中,members 的值都会增加 1。接收到其他 RTCP 或 RTP 包时,members 不会增加,且仅会对接收到的 BYE 包更新 avg_rtcp_size。当 RTP 包到达时,senders 的值不会更新,保持为 0。 然后,BYE 包的传输遵循与上述常规 RTCP 包传输相同的规则。 这允许立即发送 BYE 包,同时控制其总带宽使用量。在最坏的情况下,RTCP 控制包的带宽使用量可能是正常情况的两倍(10%):非 BYE RTCP 包占 5%,BYE 包占 5%。如果参与者不想等待上述机制允许 BYE 包的传输,则可以选择离开组而不发送 BYE 包。此参与者最终会被其他组成员超时清除。如果成员数估计 members 小于 50 时该参与者决定离开,则可立即发送 BYE 包。或者,参与者也可以选择执行上述 BYE 回退算法。 无论哪种情况,若一个参与者从未发送过 RTP 或 RTCP 包,则在离开组时不得发送 BYE 包。 Updating we_sent: 变量 we_sent 在参与者最近发送了 RTP 包时为 true,否则为 false。该状态的确定机制与管理发送者表中其他参与者的机制相同。如果当 we_sent 为 false 时参与者发送了一个 RTP 包,它会将自己添加到发送者表中,并将 we_sent 设置为 true。应执行第 6.3.4 节中描述的反向再考虑算法,以可能减少发送 SR 包之前的延迟。每次发送另一个 RTP 包时,该包的发送时间都会保存在表中。然后对该参与者应用正常的发送者超时算法 —— 如果自 tc−2Ttc - 2Ttc−2T 以来没有发送过 RTP 包,则该参与者会将自己从发送者表中移除,减少发送者计数,并将 we_sent 设置为 false。 Allocation of Source Description Bandwidth: 本规范定义了几种源描述(SDES)项,除了必需的 CNAME 项之外,还包括 NAME(个人姓名)和 EMAIL(电子邮件地址)。它还提供了定义新的应用程序专用 RTCP 包类型的方法。应用程序在为这些附加信息分配控制带宽时应谨慎,因为这会减慢接收报告和 CNAME 的发送速率,从而影响协议的性能。建议不超过 20% 的单个参与者的 RTCP 带宽用于携带附加信息。此外,并非所有 SDES 项都要包含在每个应用程序中。包含的项应根据其用途分配带宽比例。建议不动态估算这些比例,而是基于项的典型长度,将百分比静态转换为报告间隔计数。 例如,一个应用程序可能仅设计为发送 CNAME、NAME 和 EMAIL,而不发送其他项。由于 NAME 将在应用程序的用户界面中连续显示,可能会比 EMAIL 优先级更高,因为 EMAIL 仅在请求时显示。在每个 RTCP 间隔,都会发送一个 RR 包和一个带有 CNAME 项的 SDES 包。对于在最小间隔运行的小会话,平均每 5 秒会发送一次。每隔第三个间隔(15 秒),SDES 包会包含一项额外的项。在这其中,七分之八的时间是 NAME 项,而每八次(2 分钟)会包含 EMAIL 项。当多个应用程序协同工作,通过每个参与者的公共 CNAME 实现跨应用绑定时,例如在多媒体会议中为每个媒体建立一个 RTP 会话时,附加的 SDES 信息可以仅在一个 RTP 会话中发送。其他会话将仅携带 CNAME 项。特别是,这种方法应适用于分层编码方案的多个会话(见第 2.4 节)。 Sender and Receiver Reports: RTP 接收者使用 RTCP 报告包提供接收质量反馈,具体形式取决于接收者是否也是发送者。这两种报告形式——发送者报告 (SR) 和接收者报告 (RR) 的唯一区别是,除了包类型代码外,发送者报告包含一个 20 字节的发送者信息部分,供活跃的发送者使用。如果在上一次或前一个报告之后的间隔期间内站点发送了任何数据包,则会发送 SR,否则发送 RR。SR 和 RR 都包含零个或多个接收报告块,每个报告块对应一个自上次报告以来该接收者接收到 RTP 数据包的同步源 (SSRC)。不为 CSRC 列表中的贡献源生成报告。每个接收报告块提供有关从该块指示的特定源接收的数据的统计信息。由于 SR 或 RR 包中最多可以包含 31 个接收报告块,因此在必要时,附加的 RR 包应在初始 SR 或 RR 包之后叠加,以包含自上次报告以来接收到的所有源的接收报告。如果有太多的源以至于无法将所有必要的 RR 包包含在一个复合 RTCP 包中而不超出网络路径的 MTU,则每个间隔应仅包含符合 MTU 限制的子集。这些子集应在多个间隔内以轮询方式选择,以便所有源都能被报告。 接下来的章节定义了这两种报告的格式,如何在特定配置中进行扩展(如果应用程序需要额外的反馈信息),以及如何使用这些报告。第 7 节提供了有关翻译器和混合器的接收报告的详细信息。 SR: Sender Report RTCP Packet: 发送者报告 (SR) 包包含三个部分,如果定义了特定配置的扩展部分,则可能跟随一个第四部分。第一部分是 8 字节长的头部。各字段含义如下: 版本 (V):2 位 ○ 标识 RTP 版本,与 RTP 数据包中的版本相同。本规范定义的版本为 2。 填充 (P):1 位 ○ 如果填充位设置为 1,则此 RTCP 包在末尾包含一些附加的填充字节,这些字节不属于控制信息,但包含在长度字段中。填充字节的最后一个字节表示需要忽略的填充字节数,包括自身(将是 4 的倍数)。某些固定块大小的加密算法可能需要填充。在复合 RTCP 包中,仅需在一个 RTCP 包中填充,因为整个复合包会被加密(第 9.1 节方法)。因此,填充仅需添加到最后一个 RTCP 包中,且该包的填充位必须设置为 1。这种方式有助于执行附录 A.2 中描述的头部有效性检查,并允许检测某些早期实现中错误地在第一个 RTCP 包中设置填充位,并在最后一个 RTCP 包中添加填充的情况。 接收报告计数 (RC):5 位 ○ 表示此包中包含的接收报告块数量。0 也是有效值。 包类型 (PT):8 位 ○ 包含常数 200,以标识这是 RTCP SR 包。 长度:16 位 ○ 此 RTCP 包的长度(以 32 位字为单位)减 1,包括头部和填充。(偏移量 1 使得 0 成为有效长度,避免扫描复合 RTCP 包时的无限循环问题,并通过以 32 位字为单位进行计数,避免了 4 的倍数的有效性检查。) SSRC:32 位 ○ 此 SR 包发起者的同步源标识符。 第二部分为发送者信息,长 20 字节,存在于每个发送者报告包中,概述了此发送者的数据传输。各字段含义如下: NTP 时间戳:64 位 ○ 表示发送报告时的挂钟时间(见第 4 节),可与其他接收者返回的接收报告中的时间戳结合,用于测量到这些接收者的往返传播时间。接收者应注意,时间戳的测量精度可能远低于 NTP 时间戳的分辨率。时间戳的不确定性未指明,因为可能未知。在不具备挂钟时间概念但具备特定系统时钟(如系统启动时间)的系统上,发送者可以使用该时钟作为参考来计算相对 NTP 时间戳。重要的是选择一个常用时钟,以便在使用不同实现生成多媒体会话的各个流时,所有实现都使用相同的时钟。在 2036 年之前,相对和绝对时间戳会在高位有所不同,因此(无效的)比较将显示出较大的差异;到那时,希望不再需要相对时间戳。没有挂钟或经过时间概念的发送者可以将 NTP 时间戳设为 0。 RTP 时间戳:32 位 ○ 与 NTP 时间戳(上文)对应,但使用与数据包中的 RTP 时间戳相同的单位和随机偏移。对于 NTP 时间戳已同步的源,该对应关系可用于媒体内部和媒体之间的同步,并可用于媒体无关接收者估算标准 RTP 时钟频率。注意,此时间戳在大多数情况下不会等于任何相邻数据包中的 RTP 时间戳,而是必须根据对应的 NTP 时间戳、通过在采样时刻周期性检查挂钟时间来维护的 RTP 时间戳计数器和实际时间的关系进行计算。 发送者的数据包计数:32 位 ○ 从开始传输至生成此 SR 包时,发送者发送的 RTP 数据包总数。如果发送者更改其 SSRC 标识符,则应重置此计数。 发送者的字节数:32 位 ○ 从开始传输至生成此 SR 包时,发送者在 RTP 数据包中传输的有效负载字节总数(不包括头部或填充)。如果发送者更改其 SSRC 标识符,则应重置此计数。此字段可用于估算平均有效负载数据速率。 第三部分根据自上次报告以来发送者接收到的其他源数量,包含零个或多个接收报告块。每个接收报告块传达从一个同步源接收的 RTP 数据包的统计信息。当源由于冲突更改其 SSRC 标识符时,接收者不应继续使用之前的统计信息。这些统计信息包括: SSRC_n(源标识符):32 位 ○ 与此接收报告块中的信息相关的源的 SSRC 标识符。 丢包率:8 位 ○ 表示自上次发送 SR 或 RR 包以来,从源 SSRC_n 丢失的 RTP 数据包的比例,按左边缘为二进制点的定点数表示(即丢失比例乘以 256 的整数部分) RR: Receiver Report RTCP Packet: 接收者报告 (RR) 包的格式与发送者报告 (SR) 包的格式相同,但包类型字段包含常数 201,且省略了五个字的信息(即 NTP 和 RTP 时间戳以及发送者的数据包和字节计数)。其余字段的含义与 SR 包相同。 当没有数据传输或接收需要报告时,必须将一个空的 RR 包(RC = 0)放在复合 RTCP 包的开头。 Extending the Sender and Receiver Reports: 如果有需要定期报告的发送者或接收者的额外信息,配置文件应定义发送者报告和接收者报告的特定扩展。此方法应优先于定义另一种 RTCP 包类型,因为它所需的开销较少: 包中所需的字节更少(没有 RTCP 头部或 SSRC 字段); 解析更简单快捷,因为运行在该配置文件下的应用程序会编程为始终在接收报告后直接访问扩展字段。 该扩展是发送者报告或接收者报告包的第四部分,在接收报告块(如果有的话)之后的结尾处。如果需要额外的发送者信息,则在发送者报告中会首先包含在扩展部分中,但在接收者报告中则不会出现此信息。如果需要包含接收者的信息,数据应当按照与现有接收报告块数组平行的块数组结构组织;即,块的数量将由 RC 字段指示。 Analyzing Sender and Receiver Reports: 接收质量反馈预计不仅对发送者有用,也对其他接收者和第三方监视器有用。发送者可以根据反馈调整传输;接收者可以确定问题是本地的、区域性的还是全局的;网络管理人员可以使用仅接收 RTCP 包而不接收对应 RTP 数据包的独立配置监视器来评估其网络在多播分发中的性能。 发送者信息和接收者报告块都使用累积计数,这样可以计算任意两个报告之间的差异,以便在长短时间段内进行测量,并提供对报告丢失的韧性保护。最近两次接收到的报告之差可用于估算近期的分发质量。NTP 时间戳的加入,使得可以通过这些差异计算两个报告之间间隔的速率。由于该时间戳独立于数据编码的时钟速率,因此可以实现编码和配置无关的质量监视。一个示例计算是两个接收报告之间的包丢失率。累计丢失包数的差值给出了该间隔内的丢失包数。接收的扩展最高序列号的差值给出了该间隔内期望的包数。两者之比是该间隔的包丢失比例。对于连续的两个报告,这一比例应等于丢失比例字段,否则可能不一致。丢失率每秒可通过将丢失比例除以 NTP 时间戳差值(以秒为单位)获得。接收到的包数是期望包数减去丢失包数。期望包数还可用于评估丢失估算的统计有效性。例如,丢失 5 个包中的 1 个的统计显著性低于丢失 1000 个包中的 200 个。 从发送者信息中,第三方监视器可以在不接收数据的情况下计算间隔内的平均有效负载数据速率和平均包速率。两者的比值给出平均有效负载大小。如果假设包丢失与包大小无关,则特定接收者接收的包数乘以平均有效负载大小(或相应的包大小)给出该接收者的可见吞吐量。 除了允许通过报告间差值进行长期丢包测量的累积计数,丢失比例字段还提供了单个报告中的短期测量。这在会话规模扩大到足以使无法保留所有接收者的接收状态信息,或报告间隔长到仅收到一个特定接收者的报告时显得尤为重要。 到达间抖动 (jitter) 字段提供了第二个短期的网络拥塞测量。丢包跟踪持续性拥塞,而抖动测量跟踪瞬时拥塞。在丢包之前,抖动测量可能预示着拥塞。到达间抖动字段仅是报告时抖动的快照,非量化值。相反,它用于对单个接收者随时间或多个接收者(例如同一网络内的接收者)间的多次报告进行比较。为了便于接收者间比较,所有接收者必须按照相同的公式计算抖动。 由于抖动计算基于 RTP 时间戳,该时间戳表示包中数据首次采样的时刻,因此从采样到包传输的延迟变化会影响计算的抖动值。此延迟变化会出现在不同长度的音频包中,视频编码中也会出现这种情况,因为帧的所有包具有相同的时间戳,但传输时间不同。传输前的延迟变化确实降低了抖动计算作为独立网络行为测量的准确性,但考虑到接收者缓冲区必须适应这一点,将其包括在内是合适的。当抖动计算作为比较测量使用时,传输延迟变化的(恒定)分量会相互抵消,从而可以观察到网络抖动分量的变化,除非该变化非常小。如果变化很小,则可能无关紧要。

    昨天 35浏览
  • Bootloader的见鬼故障

    【前面的话】在近几年的嵌入式社区中,流传着不少关于面相Cortex-M的Bootloader科普文章,借助这些文章,一些较为经典的代码片断和技巧得到了广泛的传播。  在从Bootloader跳转到用户APP的过程中,使用函数指针而非传统的汇编代码则成了一个家喻户晓的小技巧。相信类似下面 JumpToApp() 函数,你一定不会感到陌生: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; __IO uint32_t StackAddr; __IO uint32_t ResetVector; __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 为了读懂这段代码,需要一些从事Cortex-M开发所需的“热知识”: 向量表是一个由 32bit 数据构成的数组 数组的第一个元素是 uintptr_t 类型的指针,保存着复位后主栈顶指针(MSP)的初始值。 从数组第二个元素开始,保存的是 (void (*)(void)) 类型的异常处理程序地址(BIT0固定为1,表示异常处理程序使用Thumb指令集进行编码) 数组的第二个元素保存的是复位异常处理程序的地址(Reset_Handler) 从理论上说,要想保证APP能正常执行,Bootloader通常要在跳转前“隐藏自己存在过的事实”——需要“对房间进行适度的清理”,并模拟芯片硬件的一些行为——假装芯片复位后是直接从APP开始执行的。 总结来说,Bootloader在跳转到App之前需要做两件事: 1. 清理房间——仿佛Bootloader从未执行过一样 2. 模拟处理器的硬件的一些复位行为——假装芯片从复位开始就直接从APP开始执行 一般来说,做到上述两点,就可以实现App将Bootloader视作黑盒子的效果,从而带来极高的兼容性。甚至在App注入了“跳床(trumpline)”的情况下,实现App既可以独立开发、调试和运行,也可以不经修改的与Bootloader一起工作的奇效。 如何在App中加入“跳床(trumpline)”值得专门再写一篇独立的文章,不是本文所需关注的重点,请允许我暂且略过。 这里,“清理房间”的步骤与Bootloader具体“弄脏了什么”(或者说使用了什么资源)有关;而“模拟处理器硬件的一些复位行为”就较为简单和具体:即,从Bootloader跳转到App前的最后两个步骤为: 从APP的向量表中读取MSP的初始值并以此来初始化MSP寄存器; 从APP的向量表中读取Reset_Handler的值,并跳转到其中去执行——完成从Bootloader到APP的权利交接。 结合前面的例子代码,值得我们关注的部分是: 1. 使用自定义的函数指针类型 pFunction 定义一个局部变量: pFunction Jump_To_Application; 2. 根据向量表的首地址 addr 读取第一个元素——作为MSP的初始值暂时保存在局部变量 StackAddr 中: StackAddr = *(__IO uint32_t*)addr; 3. 根据向量表的首地址 addr 读取第二个元素——将Reset_Handler的首地址保存到局部变量 ResetVector 中: ResetVector = *(__IO uint32_t *)(addr + 4); 4. 设置栈顶指针MSP寄存器: __set_MSP(StackAddr); 5. 通过函数指针完成从Bootloader到App的跳转: Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 其实,无论具体的代码如何,只要实现步骤与上述类似,就存在一个隐藏较深的漏洞,而漏洞的“触发与否”则完全“看脸”——简单来说: 只要你是按照上述方法来实现从Bootloader到App的跳转的,那么就一定存在问题——而“似乎可以正常工作”就只是你运气较好,或者“由此引发的问题暂时未能引发注意”罢了。 在你试图争辩“老子代码已经量产了也没有什么不妥”之前,我们先来看看漏洞的原理是什么——在知其所以然后,如何评估风险就是你们自己的事情了。 【C语言基础设施是什么】 嵌入式系统的信息安全(Security)建立在基础设施安全(Safety)的基础之上。 由于“确保信息安全的很多机制”本质上是一套建立在“基础设施能够正常工作”这一前提之上的规则和逻辑,因此很多针对信息安全的攻击往往会绕开信息安全的“马奇诺防线”,转而攻击基础设施。芯片数字逻辑的基础设施是时钟源、供电、总线时序、复位时序等等,因此,针对硬件基础设施的攻击通常也就是针对时钟源、电源、总线时序和复位时序的攻击。此时,好奇的小伙伴会产生疑问:固件一般由C语言进行编写,那么C语言所依赖的基础设施又是什么呢? 对C语言编译器来说,栈的作用是无可替代的: 函数调用 函数间的参数传递 分配局部变量 暂时保存通用寄存器中的内容 …… 可以说,离开了栈C语言寸步难行。因此对很多芯片来说,复位后为了执行用户使用C语言编译的代码,第一个步骤就是要实现栈的初始化。 作为一个有趣的“冷知识”,Cortex-M在宣传中一直强调自己“支持完全使用C语言进行开发”,这让很多人“丈二和尚摸不着头脑”甚至觉得“非常可笑”——因为这年月连51都支持用户使用C语言进行开发了,你这里说的“Cortex-M支持使用C语言进行开发”有什么意义呢? 其实门道就在这里: 由于Cortex-M处理器会在复位时由硬件完成对C语言基础设施(也就是栈顶指针MSP)的初始化,因此无论是理论上还是实践中,从复位异常处理程序Reset_Handler开始用户就可以完全可以使用C语言进行开发了,而整个启动代码(startup)也可以全然不涉及任何汇编; 由于Cortex-M的向量表是一个完全由 32位整数(uintptr_t)构成的数组——保存的都是地址而非具体代码,可以使用C语言的数据结构直接进行描述——因此也完全不需要汇编语言的介入。 这种从复位一开始就完全不需要汇编介入的友好环境才是Cortex-M声称自己“支持完全使用C语言进行开发”的真实意义和底气。从这一角度出发,只要某个芯片架构复位后必须要通过软件来初始化栈顶指针,就不符合“从出生的那一刻就可以使用C语言”的基本要求。 【C语言编译器的约定】 栈对C语言来说如此重要,以至于编译器一直有一条默认的约定,即: 栈必须完全交由C语言编译器进行管理(或者用户对栈的操作必须符合对应平台所提供的调用规约,比如Arm的AAPCS规约)。 简而言之,如果你“偷偷摸摸”的修改了栈顶指针,C语言编译器是会“假装”完全不知道的,而此时所产生的后果C语言编译器会默认自己完全不用负责。 回头再看这段代码: StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 虽然我们觉得自己“正大光明”的使用了 __set_MSP() 来修改了栈顶指针,但它实际上是一段C语言编译器并不理解其具体功能的在线汇编——在编译器看来,无论是谁提供的 __set_MSP(),只要是在线汇编,这就算是用户代码——是编译器管不到的地带。 /** \brief Set Priority Mask \details Assigns the given value to the Priority Mask Register. \param [in] priMask Priority Mask */__STATIC_FORCEINLINE void __set_PRIMASK(uint32_t priMask){ __ASM volatile ("MSR primask, %0" : : "r" (priMask) : "memory");} 或者说:C语言编译器一般情况下会默认你“无论如何都不会修改栈顶指针”——它不仅管不着,也不想管。 从这点来看,上述代码的确打破了这份约定。即便如此,很多小伙伴会心理倔强的认为:我就这么改了,怎么DE了吧?! 【问题的分析】 从原理上说,开篇那个典型的Bootloader跳转代码所存在的问题已经昭然若揭: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; __IO uint32_t StackAddr; __IO uint32_t ResetVector; __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 我们不妨结合上述代码反汇编的结果进行深入解析: AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2 JumpToApp PROC000000 b082 SUB sp,sp,#8000002 4909 LDR r1,|L2.40|000004 9100 STR r1,[sp,#0]000006 6802 LDR r2,[r0,#0]000008 400a ANDS r2,r2,r100000a 2101 MOVS r1,#100000c 0749 LSLS r1,r1,#2900000e 428a CMP r2,r1000010 d107 BNE |L2.34|000012  6801              LDR      r1,[r0,#0]000014 9100 STR r1,[sp,#0]000016 6840 LDR r0,[r0,#4]000018 f3818808 MSR MSP,r100001c 9001 STR r0,[sp,#4]00001e b002 ADD sp,sp,#8000020 4700 BX r0 |L2.34|000022 b002 ADD sp,sp,#8000024 4770 BX lr ENDP 000026 0000 DCW 0x0000 |L2.40| DCD 0x2fff0000 注意这里,StackAddr、ResetVector是两个局部变量,由编译器在栈中进行分配。汇编指令将SP指针向栈底挪动8个字节就是这个意思: 000000 b082 SUB sp,sp,#8 虽然 JumpMask 也是局部变量,但编译器根据自己判断认为它“命不久矣”,因此直接将它分配到了通用寄存器r2中,并配合r1和sp完成了后续运算。这里: __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { ... } 对应: 000002 4909 LDR r1,|L2.40|000004 9100 STR r1,[sp,#0]000006 6802 LDR r2,[r0,#0]000008 400a ANDS r2,r2,r100000a 2101 MOVS r1,#100000c 0749 LSLS r1,r1,#2900000e 428a CMP r2,r1000010 d107 BNE |L2.34|...|L2.34|000022 b002 ADD sp,sp,#8000024 4770 BX lrENDP 000026 0000 DCW 0x0000|L2.40|DCD 0x2fff0000 考虑到JumpMask的内容与本文无关,不妨暂且跳过。 接下来就是重头戏了: 编译器按照用户的指示读取栈顶指针MSP的初始值,并保存在StackAddr中: StackAddr = *(__IO uint32_t*)addr; 对应的汇编是: 000012 6801 LDR r1,[r0,#0]000014 9100 STR r1,[sp,#0] 根据Arm的AAPCS调用规约,编译器在调用函数时会使用R0~R3来传递前4个符合条件的参数(这里的条件可以简单理解为每个参数的宽度要小于等于32bit)。根据函数原型 void JumpToApp(uint32_t addr); 可知,r0 中保存的就是形参 addr 的值。所以第一句汇编的意思就是:根据 (addr + 0)作为地址读取一个uint32_t型的数据保存到r1中。 第二句汇编中,栈顶指针sp此时实际上指向局部变量 StackAddr,因此其含义就是将通用寄存器r1中的值保存到局部变量 StackAddr 中。 对于局部变量 ResetVector 的读取操作,编译器的处理如出一辙: ResetVector = *(__IO uint32_t *)(addr + 4); 对应: 000016  6840              LDR      r0,[r0,#4]00001c 9001 STR r0,[sp,#4] 其实就是从 (addr + 4) 的位置读取 32bit 整数,然后保存到r0里,并随即保存到sp所指向的局部变量 ResetVector 中。到这里,细心地小伙伴会立即跳起来说“不对啊,原文不是这样的!”。是的,这也是最有趣的地方。实际的汇编原文如下: 000016 6840 LDR r0,[r0,#4]000018 f3818808 MSR MSP,r100001c 9001 STR r0,[sp,#4] 作为提醒,它对应的C代码如下: ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); 后面的 __set_MSP(StackAddr) 所对应的汇编代码 MSR MSR,r1 居然插入到了ResetVector赋值语句的中间?! “C语言编译器这么自由的么?” “在我使用sp之前把栈顶指针更新了?!” 先别激动,还记得我们和C语言编译器之间的约定么?C语言编译器默认我们在任何时候都不应该修改栈顶指针。因此在他看来,“你 MSR 指令操作的是r1,关我sp和r0啥事”?“我就算随意更改顺序应该对你一毛钱影响都没有!(因为我不关心、也没法知道用户线汇编语句的具体效果,因此我只关心涉事的通用寄存器是否存在冲突)” 上述“骚操作”的后果是:保存在r0中的Reset_Handler地址值被保存到了新栈中(MSP + 4)的位置。这立即带来两个潜在后果: 由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+4)实际上已经超出栈的合法范围了。 这一操作与其说是会覆盖栈后续的存储空间,倒不如说风险主要体现在BusFault上——因为相当一部分人习惯将栈放到SRAM的最末尾,而MSP+4直接超出SRAM的有效范围。 我们以为的ResetVector其实已经不在原本C编译器所安排的地址上了。 精彩的还在后面: Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 对应的翻译是: 00001e b002 ADD sp,sp,#8000020 4700 BX r0 通过前面的分析,我们知道,此时r0中保存的是Reset_Handler的地址,因此 BX r0 能够成功完成从Bootloader到APP的跳转——也许你会松一口气——好像局部变量ResetVector的错位也没引起严重的后果嘛。 看似如此,但真正吓人的是C语言编译器随后对局部变量的释放: 00001e b002 ADD sp,sp,#8 它与一开始局部变量的分配形成呼应: 000000 b082 SUB sp,sp,#8...00001e b002 ADD sp,sp,#8 好借好还,再借不难。但此sp非彼sp了呀! 这里由于JumpToApp没有加上__NO_RETURN的修饰,因此C编译器并不知道这个函数是有去无回的,因此仍然会像往常一样在函数退出时释放局部变量。 就像刚才分析的那样:由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+8)实际上已经超出栈存储空间的合法范围了。 考虑到相当一部分人习惯将栈放到SRAM的最末尾,而MSP+8直接超出SRAM的有效范围,即便刚跳转到APP的时候还不会有事,但凡APP用了任何压栈操作,(无论是BusFault还是地址空间绕回)就很有可能产生灾难性的后果。 【宏观分析】 就事论事的讲,单从汇编分析来看,上述代码所产生的风险似乎是可控的,甚至某些人会觉得可以“忽略不计”。但最可怕的也就在这里,原因如下: 从原理上说,将关键信息保存在依赖栈的局部变量中,然后在编译器不知情的情况下替换了栈所在的位置,此后只要产生对相关局部变量的访问就有可能出现“刻舟求剑”的数据错误。这种问题是“系统性的”、“原理性的”。 (此图由GorgonMeducer借助GPT4进行一系列关键词调校、配上台词后获得) 不同编译器、同一编译器的不同版本、同一版本的不同优化选项都有可能对同一段C语言代码产生不同的编译结果,因此哪怕我们经过上述分析得出某一段汇编代码似乎不会产生特别严重的后果,在严谨的工程实践上,这也只能算做是“侥幸”,是埋下了一颗不知道什么时候以什么方式引爆的定时炸弹。 根据用户Bootloader代码在修改 MSP 前后对局部变量的使用情况不同、考虑到用户APP行为的不确定性、由上述缺陷代码所产生的Bootloader与APP之间配合问题的组合多种多样、由于涉及到用户栈顶指针位置的不确定性以及新的栈存储器空间中内容的随机性,最终体现出来的现象也是完全随机的。用人话说就是,经常性的“活见鬼” 【解决方案】 既然我们知道不能对上述缺陷代码抱有侥幸心理,该如何妥善解决呢?第一个思路:既然问题是由栈导致的,那么直接让编译器用通用寄存器来保存关键局部变量不就行了?修改代码为: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; register uint32_t StackAddr; register uint32_t ResetVector; register uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 相同编译环境下得出的结果为: AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2 JumpToApp PROC 000002 6801 LDR r1,[r0,#0]000004 4011 ANDS r1,r1,r2000006 2201 MOVS r2,#1000008 0752 LSLS r2,r2,#2900000a 4291 CMP r1,r200000c  d104              BNE |L2.24| 00000e 6801 LDR r1,[r0,#0]000010 6840 LDR r0,[r0,#4]000012 f3818808          MSR      MSP,r1 000016 4700 BX r0 |L2.24|000018 4770 BX       lr ENDP 00001a 0000 DCW 0x0000 |L2.28| DCD 0x2fff0000 可见,上述汇编中半个 sp 的影子都没看到,问题算是得到了解决。 然而,需要注意的是 register 关键字对编译器来说只是一个“建议”,它听不听你的还不一定。加之上述例子代码本身相当简单,涉及到的局部变量数量有限,因此问题似乎得到了解决。 倘若编译器发现你大量使用 register 关键字导致实际可用的通用寄存器数量入不敷出,大概率还是会用栈来进行过渡的——此时,哪些局部变量用栈,哪些用通用寄存器就完全看编译器的心情了。 进一步的,不同编译器、不同版本、不同优化选项又会带来大量不可控的变数。 因此就算使用 register 修饰关键局部变量的方法可以救一时之疾(“只怪老板催我催得紧,莫怪我走后洪水滔天”),也算不得妥当。 第二个思路:既然问题出在局部变量上,我用静态(或者全局)变量不就可以了?修改源代码为: #include "cmsis_compiler.h" typedef void (*pFunction)(void); __NO_RETURNvoid JumpToApp(uint32_t addr){ pFunction Jump_To_Application; static uint32_t StackAddr; static uint32_t ResetVector; register uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 这种方法看似稳如老狗,实际效果可能也不差,但还是存在隐患,因为它“没有完全杜绝编译器会使用栈的情况”,只要我们还会通过 __set_MSP() 在C语言编译器不知道的情况下更新栈顶指针,风险自始至终都是存在的。 对某些连warning都要全数消灭的团队来说,上述方案多半也是不可容忍的。 第三个思路:完全用汇编来处理从Bootloader到App的最后步骤。对此我只想说:稳定可靠,正解。 只不过需要注意的是:这里整个函数都需要用纯汇编打造,而不只是在C函数内容使用在线汇编。 原因很简单:既然我们已经下定决心要追求极端确定性,就不应该使用线汇编这种与C语言存在某些“暧昧交互”的方式——因为它仍然会引入一些意想不到的不确定性。 本着一不做二不休的态度,完全使用汇编代码来编写跳转代码才是万全之策。 【说在后面的话】 在使用栈的情况下,on-fly 的修改栈顶指针就好比在飞行途中更换引擎——不是不行,只是要求有亿点点高。 我在微信群中帮读者分析各类Bootloader的见鬼故障时,经常在大费周章的一通分析和调试后,发现问题的罪魁祸首就是跳转代码。可怕的是,几乎每个故障的具体现象都各不相同,表现出的随机性也常常让人怀疑是不是硬件本身存在问题,亦或是产品工作现场的电磁环境较为恶劣。最要命的当数那种“偶尔出现”而复现条件颇为玄学的情形,甚至在办公室环境下完全无法重现的也大有人在。同样的问题出的多了,我几乎在每次帮人调试Bootloader时都会习惯性的先要求检查跳转代码——虽然不会每次都能猜个正着,但也有个恐怖的十之七八。这也许是某种幸存者偏差吧——毕竟大部分普通问题大家自己总能解决,到我这里的多半就是“驱鬼”了。见得多了,我突然发现,出问题的代码大多使用函数指针来实现跳转——而用局部变量来保存函数指针又成了大家自然而然的选择。加之此前很多文章都曾大规模科普上述技巧,甚至是直接包含一些存在缺陷的Bootloader范例代码,实际受影响的范围真是“细思恐极”。特此撰文,为您解惑。 

    昨天 8浏览
  • timescale指令的用法

    1、什么是`timescale指令?\x26amp;nbsp; \x26amp;nbsp; \x26amp;nbsp;`timescale指令我相信大

    昨天 10浏览
  • Linux C 中的内存屏障是什么?

    一、内存屏障 在 Linux C 语言编程 中,内存屏障(Memory Barrier) 是一种用于控制内存访问顺序的技术。它主要用于多处理器系统中,确保某些操作按预期顺序执行,避免 CPU 和编译器对内存访问进行优化,从而影响程序的正确性。内存屏障的功能在多线程和并发编程中尤为重要。 什么是内存屏障? 内存屏障的障中文意思是保护和隔离的,也有阻止的意思,阻止的是CPU对变量的继续访问,停下来更新下变量,从而保护变量的一致性。 内存屏障是针对线程所有共享变量的,而原子操作仅针对当前原子变量。 内存屏障是一种指令,它的作用是禁止 CPU 重新排序特定的内存操作。它确保在屏障之前的所有读/写操作在屏障之后的操作之前完成。内存屏障一般被用来控制多处理器环境中的内存可见性问题,尤其是在进行原子操作、锁和同步时。 在多核处理器上,每个处理器都有自己的缓存,CPU 会将内存操作缓存到自己的本地缓存中。在不同的 CPU 之间,内存的可见性并非立刻同步,这就可能导致不同线程看到不同的内存值。通过内存屏障,可以确保特定的操作顺序,以避免此类问题。 内存屏障的类型 Linux C 中,内存屏障通常有以下几种类型,主要通过内核提供的原子操作或者内存屏障函数来实现。 1.全屏障(Full Barrier 或者 LFENCE、SFENCE): 作用:以下两种相加。 用途:确保所有的内存操作都在内存屏障前完成,通常用于同步和锁定操作。 内核函数:mb() (Memory Barrier) 2.读屏障(Read Barrier 或者 LFENCE): 作用:保证屏障之前的所有读操作在屏障之后的读操作之前完成。---》翻译过来的有歧义,难以理解,那个“完成”是缓存同步主内存的意思。 本质:作用是强制将 CPU核心 中的 L1/L2 缓存 中的共享变量值写回到 主内存。 用途:在执行并行读操作时确保读顺序。 内核函数:rmb() (Read Memory Barrier) 3.写屏障(Write Barrier 或者 SFENCE): 作用:保证屏障之前的所有写操作在屏障之后的写操作之前完成。--》翻译过来有歧义,难以理解,那个“完成”是主内存同步到缓存的意思。 本质:作用是强制使数据从主内存加载,而不是直接使用可能已经过时的缓存数据。 用途:用于确保写操作顺序。 内核函数:wmb() (Write Memory Barrier) 4.无序屏障(No-op Barrier): 作用:没有实际影响,仅确保 CPU 不会重排序特定的指令。 用途:常用于确保指令的顺序性而不做其他强制性的内存同步。 读写屏障的作用域 读写屏障的作用域并不局限于当前函数或者某个函数调用的局部作用域,而是影响整个 当前线程的内存访问顺序。也就是说,只要在当前线程中,任何在屏障前后的内存操作都会受到屏障影响,而不管这些操作发生在同一个函数里还是不同的函数中。 线程之间的隔离 读写屏障是 线程级别的,因此它们只影响执行这些屏障操作的线程。也就是说,如果线程 1 执行了写屏障,它只会影响线程 1 后续的内存操作,而不会直接影响其他线程。---》翻译过来的,其实就是这个线程的读写屏障只会引发自己线程变量与主内存的同步,管不到其他线程的同步。但是写屏障触发后 会 通知其他线程,如果有现代 CPU 使用缓存一致性协议(如 MESI)的话,其他线程会把主内存中的最新值更新到自己缓存中。 读屏障不会触发其他线程去把自己的缓存同步到主内存中。 如果想让多个线程之间的共享变量同步并保持一致性,通常需要在多线程间使用某些同步机制(如锁、原子操作等),而不仅仅是依赖于单个线程的屏障。 具体来说: 写屏障(Write Barrier):会影响所有在屏障之前执行的写操作,无论这些写操作发生在当前函数内还是其他函数中。它确保屏障前的所有写操作都能同步到主内存,任何与此线程共享的缓存都能看到这些值。 读屏障(Read Barrier):会影响所有在屏障之后执行的读操作,确保这些读操作从主内存读取最新的值,而不是从 CPU 核心的缓存中读取过时的值。读屏障会影响当前线程的所有后续读取操作,无论这些读取发生在哪个函数中。 内存屏障的使用 在 Linux 中,内存屏障主要通过一组原子操作宏来提供。这些操作用于确保不同 CPU 或线程之间的内存同步。常见的内存屏障宏包括: mb():全屏障,防止 CPU 重排序所有内存操作。 rmb():读屏障,确保屏障之前的所有读操作完成。 wmb():写屏障,确保屏障之前的所有写操作完成。 示例代码 #include #include #define wmb() __asm__ __volatile__("sfence" ::: "memory") // 写屏障#define rmb() __asm__ __volatile__("lfence" ::: "memory") // 读屏障#define mb() __asm__ __volatile__("mfence" ::: "memory") // 全屏障 void example_memory_barrier() { int shared_variable = 0; // 写入数据 shared_variable = 42; // 在这里使用写屏障,确保共享变量的写操作 // 在执行屏障之后才会完成 wmb(); // 读取共享数据 printf("Shared Variable: %d\n", shared_variable); // 使用读屏障,确保屏障前的所有读取操作完成 rmb(); // 这里是确保顺序执行的一部分 printf("Shared Variable read again: %d\n", shared_variable);} int main() { example_memory_barrier(); return 0;} 为什么需要内存屏障? 避免重排序:编译器和 CPU 会对内存访问进行优化,尤其是在多处理器系统中,这可能导致指令执行顺序与预期不一致,进而导致错误的程序行为。 保证内存一致性:当一个线程或 CPU 修改共享变量时,其他线程或 CPU 可能会看到不同的内存值,内存屏障可以保证修改操作在其他线程中是可见的。 同步操作:在多线程或多处理器环境中,内存屏障确保执行顺序和同步的正确性,尤其是在没有锁或原子操作的情况下。 缓存一致性协议(例如 MESI) 为了保证多核处理器之间缓存的数据一致性,现代 CPU 会使用缓存一致性协议(如MESI 协议,即 Modified、Exclusive、Shared、Invalid)。这个协议的作用是确保一个核心的缓存修改在其他核心的缓存中得到更新,避免出现“脏数据”。 但即便如此,MESI 协议的具体实现仍然依赖于硬件,缓存之间的同步可能不会在每一次内存访问时都发生。尤其是在没有任何同步机制的情况下,一个线程修改的值可能会暂时不被另一个线程看到,直到某些缓存刷新或同步操作发生。 Linux 内核中的内存屏障 在 Linux 内核中,内存屏障主要是通过原子操作来实现的。例如,atomic_set、atomic_add 等原子操作通常会隐式地使用内存屏障来保证内存操作顺序。而直接的内存屏障通常通过 mb()、wmb() 和 rmb() 函数来实现。 总结 内存屏障在多核处理器和并发程序中非常重要,用于控制内存操作顺序,避免由于硬件优化或编译器优化引起的内存同步问题。Linux 提供了多种类型的内存屏障函数,程序员可以根据需要使用它们来确保内存操作的顺序性。 需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享 二、变量存贮与内存屏障 你提到的问题涉及 程序执行过程中变量的存储位置、内存可见性和线程切换 的多个方面。为了更清晰地解释,我们需要从操作系统的内存管理和多线程模型入手。 1. 程序执行前变量存在哪里? 在程序执行之前,变量的存储位置主要依赖于变量的类型和生命周期。变量可以存储在以下几个区域: 栈区(Stack):局部变量通常会被分配到栈中。栈是线程私有的,每个线程都有一个独立的栈空间。 堆区(Heap):动态分配的内存(通过 malloc()、free() 等)会被存储在堆中。堆是共享的,不同线程可以访问堆中的数据。 全局区/静态区(Data Segment):全局变量和静态变量通常存储在数据段中。在程序启动时,数据段会被分配并初始化。 代码区(Text Segment):存储程序的代码(即机器指令),线程不直接操作。 2. 线程对变量的修改,为什么对其他线程不可见? 当一个线程修改了它的某个变量的值,这个变量的值并不一定立即对其他线程可见,主要是因为现代处理器通常会有缓存(Cache),并且每个线程可能在自己的寄存器或局部缓存中执行操作。具体原因如下: CPU 缓存:每个 CPU 核心都有自己的缓存(例如 L1、L2 Cache),当一个线程运行时,它可能会先将某个变量加载到本地缓存中进行修改,而不是直接操作主内存。这样,修改后的值可能不会立刻反映到主内存中。 内存可见性问题:因为不同的线程可能运行在不同的 CPU 核心上,并且每个核心有自己的缓存系统,其他线程(在不同的 CPU 核心上)可能无法直接看到修改后的变量值。除非通过某种同步机制(如内存屏障、锁、原子操作等)确保所有 CPU 核心的缓存一致性,否则修改的值不会立刻对其他线程可见。 3. 线程被切换时,变量的值存储到哪里? 当一个线程被 调度器切换(例如,从运行状态切换到阻塞状态或就绪状态)时,操作系统会保存该线程的 上下文(即该线程当前的执行状态)。这个过程称为 上下文切换。 CPU 寄存器:线程的 寄存器状态(包括程序计数器、栈指针、CPU 寄存器中的数据等)会被保存在操作系统为该线程分配的 线程控制块(TCB) 中,或者在内核中由特定的机制(如进程控制块、线程栈)保存。 内存:栈中的局部变量会被存储在 栈区,这些数据在线程切换时保持不变,直到线程恢复时。 CPU 缓存:在某些情况下,线程切换后,CPU 的缓存中的数据可能会被清除或更新,以保证在切换后的线程恢复时能正确访问内存。 4. 缓存与主内存 在现代多核处理器上,每个 CPU 核心(CPU Core)通常有自己的 本地缓存(例如 L1 缓存、L2 缓存,甚至是更高层的缓存)。这些缓存的作用是加速内存访问,避免每次访问内存时都直接访问主内存(RAM)。因此,当线程对某个变量进行修改时,这个变量的值首先会被写入到该线程所在 CPU 核心的 缓存 中,而不一定立即写回到主内存。 5. 另一个线程接着运行,变量值从哪里拿? 当一个线程被切换出去后,另一个线程会接管 CPU 的执行,并且继续执行自己的代码。另一个线程获取变量的值依赖于以下几个因素: 内存一致性:如果没有任何内存屏障或同步机制,另一个线程可能会读取到一个过时的缓存值。原因是线程 1 在修改变量时,可能只是修改了本地缓存,而没有把新值写回到主内存中;而线程 2 可能仍然读取到线程 1 的旧值。 线程间同步:为了确保变量的最新值在多个线程之间可见,通常需要使用 同步原语(如互斥锁 mutex、条件变量 condvar、原子操作等)。如果使用了如 mutex 锁定共享资源,或者使用了 volatile 关键字或原子操作,线程间对变量的修改才会更可靠地同步和可见。 缓存一致性协议:现代 CPU 通常使用 缓存一致性协议(如 MESI 协议)来确保不同 CPU 核心之间缓存的一致性。当一个线程修改某个变量时,其他线程会通过协议获取这个变量的最新值,避免缓存中的脏数据。 6. CPU 缓存与线程切换 这里有一个关键点:线程切换并不意味着缓存被清除。如果线程 1 在 CPU 核心 A 上运行,修改了全局变量,并且这个修改存储在核心 A 的缓存中,线程 1 被切换出去,CPU 核心 A 的缓存不会因为线程切换而清空。即使切换到其他线程,CPU 仍然会保留 核心 A 的缓存内容。 当线程 2 在另一个 CPU 核心 B 上开始运行时,如果它访问相同的全局变量,它会根据 自己核心的缓存 来读取这个变量。如果线程 2 看到的是旧值,那么它就是从 核心 B 的缓存 中拿到的旧值,而不是从主内存 中读取的。 7. 内存一致性问题 内存一致性问题通常出现在 多核处理器 中,特别是当多个线程运行在不同的 CPU 核心上时。在这种情况下: 线程 1 可能修改了一个全局变量的值,线程 1 所在的 CPU 核心会将该变量的新值写入到 该核心的缓存(例如 L1 或 L2 缓存)中。 如果 线程 2 运行在 另一个 CPU 核心 上,它可能会直接从 自己本地的缓存 中读取这个变量,而不是从主内存读取。假如线程 2 在缓存中读取到的是 线程 1 之前的旧值(而不是修改后的新值),那么线程 2 就读取到过时的值。 8. 为什么会有内存屏障? 由于多核处理器中的 CPU 会对内存操作进行缓存优化,内存屏障(mb()、wmb()、rmb())的作用是 强制同步 内存操作,确保某些操作的顺序性和内存可见性。通过内存屏障,操作系统或程序员可以确保在特定的内存操作之前或之后的操作按顺序执行,避免缓存带来的不一致性。 9. 为什么一个线程拿到的值可能会是旧的? 这个问题的核心在于 缓存一致性 和 内存可见性: 每个 CPU 核心都有独立的缓存,这意味着它们的缓存可能保存着不同版本的内存数据。 当线程 1 修改变量时,虽然它的 本地缓存 中的数据会被更新,但主内存的更新可能并没有立刻发生,或者其他 CPU 核心的缓存并没有得到通知。 如果没有同步机制(如内存屏障、锁、原子操作等),线程 2 可能会继续读取到旧的缓存值,而不是线程 1 修改后的新值。 10. 综述:内存模型和线程切换 在现代操作系统和多核处理器中,内存模型非常复杂。通过以下几个概念可以理解线程的内存操作: 每个线程有自己的栈,但共享堆和全局变量。 缓存一致性问题:线程修改的变量不会立刻对其他线程可见。 上下文切换:线程切换时,寄存器、栈等状态会保存,并由操作系统恢复。 内存屏障和同步机制:确保变量在多个线程之间同步和可见。 示例 假设有两个线程 A 和 B,它们都操作同一个共享变量 x。假设线程 A 修改了 x 的值,但由于没有同步机制,线程 B 可能不会立刻看到修改后的值。 int x = 0; // 共享变量 void thread_a() { x = 1; // 修改共享变量 wmb(); // 写屏障,确保修改的值会被其他线程看到} void thread_b() { rmb(); // 读屏障,确保读取的是线程 A 修改后的值 printf("%d\n", x); // 打印 x 的值} 总结 线程修改变量时,该变量可能存在于不同的缓存中,其他线程可能无法看到更新的值。 上下文切换时,线程的寄存器和栈会被保存在操作系统的上下文中,恢复时会读取这些数据。 内存屏障 用于控制内存操作的顺序性,确保修改的值能在不同线程间同步。 三、编译器屏障 编译器屏障(Compiler Barrier)是一种用于控制编译器优化的机制,用来确保编译器在生成代码时不会对某些操作进行重排或优化,尤其是在多线程编程和硬件相关编程中非常重要。编译器屏障并不直接控制内存访问,而是用来防止编译器对指令的顺序进行不合适的重新排序,确保代码按照预期的顺序执行。 编译器屏障的作用 编译器屏障主要用于控制编译器如何处理代码中的指令顺序,尤其是: 防止指令重排序:编译器优化时可能会改变指令的顺序,重新排序内存访问或者其他指令。这可能导致多线程程序中某些预期的同步行为失败。编译器屏障防止这种行为。 确保指令的执行顺序:在多核处理器或并发编程中,编译器屏障确保某些关键操作(比如内存访问)在正确的顺序中执行,避免因编译器优化导致的不一致性。 编译器屏障与内存屏障的区别 编译器屏障(Compiler Barrier): 它的目的是确保编译器不会对代码进行不合理的优化或重排序,特别是在涉及并发或多核时。 编译器屏障不能强制 CPU 层面执行同步操作,仅仅是防止编译器重排代码。 内存屏障(Memory Barrier): 内存屏障则是用于确保在多核或多线程环境中,内存访问按照特定的顺序发生,它直接控制内存操作和硬件级别的缓存同步。 内存屏障不仅防止编译器重排,也会影响 CPU 对内存的读取和写入顺序。 编译器屏障的实际应用 编译器屏障通常用于那些需要确保内存或操作顺序的场景,尤其是在处理低级硬件和并发编程时。下面是一些编译器屏障的常见应用场景: 原子操作:在使用原子操作(比如 atomic_add)时,编译器屏障可用于确保原子操作的顺序性。 多线程同步:当线程间存在共享数据时,编译器屏障可以防止编译器重排序线程的操作,以保证正确的同步。 硬件访问:在直接操作硬件时,编译器屏障可以确保 I/O 操作按预期顺序执行,而不被编译器优化掉。 编译器屏障的实现 不同的编译器提供不同的方式来实现编译器屏障。常见的做法包括: volatile 关键字:在 C/C++ 中,volatile 可以告诉编译器不要优化对变量的访问,通常用于内存映射 I/O 或多线程共享变量。虽然它可以防止编译器优化,但它并不能防止 CPU 缓存重排序。 内联汇编:编译器屏障还可以通过内联汇编来实现。许多编译器(如 GCC)支持特定的内联汇编指令,用于告诉编译器不要重排某些指令。例如,GCC 提供了 asm volatile 来控制编译器优化。 编译器内置指令:某些编译器提供内置指令来实现编译器屏障。例如,GCC 中有 __asm__ __volatile__ ("": : : "memory"),这是一个编译器屏障,它不会生成任何机器指令,但会告知编译器不要重排此位置的内存操作。 示例:GCC 中的编译器屏障 在 GCC 中,可以使用 __asm__ __volatile__ ("": : : "memory") 来插入一个编译器屏障。它告诉编译器不要重新排序此点前后的内存操作。 示例代码: #include volatile int shared_var = 0; void func1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("" : : : "memory"); // 插入编译器屏障} void func2() { int local = shared_var; // 读取共享变量 printf("shared_var = %d\n", local);} int main() { func1(); func2(); return 0;} 在这个示例中,__asm__ __volatile__ ("" : : : "memory")强制插入一个编译器屏障,确保编译器在执行shared_var = 1和shared_var之间的读写操作时不会对它们进行优化或重排序。 编译器屏障的局限性 虽然编译器屏障可以阻止编译器对代码的优化,但它并不能保证在多核处理器上,缓存之间的同步或内存访问的正确顺序。要确保内存的一致性,尤其是跨多个 CPU 核心的同步,还需要使用 内存屏障 或 锁 等同步机制。 总结 编译器屏障 主要用于控制编译器优化,防止编译器对代码执行顺序进行重排序。 它不能控制内存访问的实际顺序,但可以防止编译器错误地优化掉重要的内存操作。 它通常与 内存屏障 一起使用,以确保在多线程或并发环境下的正确性。 四、CPU屏障 CPU 屏障,也常称为 处理器屏障,是一个硬件层面的同步机制,主要用于确保 CPU 在执行指令时按特定的顺序访问内存。它是为了处理 CPU 的 指令重排、内存缓存一致性 和 多核 CPU 系统中的缓存同步 等问题。 在多核系统中,每个 CPU 核心都有自己的缓存(L1, L2, L3 缓存),这些缓存可能存储过时的内存值,导致不同核心之间的数据不一致。CPU 屏障通过硬件指令,确保 CPU 按照特定的顺序执行内存操作,从而解决缓存一致性和内存重排序的问题。 CPU 屏障的作用 防止指令重排:现代 CPU 在执行指令时,通常会对指令进行重排序(指令乱序执行)以提高性能。CPU 屏障确保特定的指令顺序不被改变,避免并发编程中的数据不一致性。 确保内存操作顺序:CPU 屏障通过禁止指令重排和缓存同步,确保内存操作按预期的顺序发生。这对多核处理器尤其重要,避免不同核心之间的数据不一致问题。 控制缓存一致性:当一个核心修改内存中的某个值时,CPU 屏障可以确保这个修改值被写回主内存,并通知其他核心从主内存读取最新的值。 与内存屏障的区别 内存屏障(Memory Barrier) 是一种在软件层面控制 CPU 内存操作顺序的机制。它可以是一个指令,告诉 CPU 按照特定顺序访问内存,避免乱序执行或缓存不一致。 CPU 屏障 则是硬件级别的机制,它通过处理器的硬件指令实现类似的同步操作。CPU 屏障直接控制 CPU 内部的缓存管理和指令流水线,从而确保内存操作的顺序。 CPU 屏障的类型 不同的 CPU 和架构(如 x86、ARM、PowerPC)提供不同的屏障指令,以下是一些常见的屏障类型: 全屏障(Full Barrier):也叫作 全内存屏障,会确保指令完全按顺序执行,通常会阻止所有的加载(Load)和存储(Store)操作的重排序。全屏障适用于需要完全同步内存访问的场景。 加载屏障(Load Barrier):用于控制加载指令(读取内存)的顺序,保证在屏障前的加载操作完成后,才能执行屏障后的加载操作。 存储屏障(Store Barrier):用于控制存储指令(写入内存)的顺序,确保在屏障前的写操作完成后,才能执行屏障后的写操作。 轻量级屏障(Light Barrier):有些现代 CPU 提供更细粒度的屏障,能够针对特定类型的指令(如仅仅是缓存一致性)进行同步。 典型的 CPU 屏障指令 1. x86 架构: MFENCE:这是 x86 架构中的一个全屏障指令,它确保所有的加载和存储指令在屏障前后都按顺序执行。 LFENCE:加载屏障,用于确保加载操作的顺序。 SFENCE:存储屏障,用于确保存储操作的顺序。 2. ARM 架构: DMB(Data Memory Barrier):用于确保数据内存操作的顺序。DMB 会阻止内存操作的重排序。 DSB(Data Synchronization Barrier):一个更强的同步屏障,通常会确保所有的内存操作完成,才会继续执行后续操作。 ISB(Instruction Synchronization Barrier):强制 CPU 刷新指令流水线,确保指令同步。 3. PowerPC 架构: sync:PowerPC 中的同步指令,强制执行内存访问的顺序。 CPU 屏障的应用场景 多线程编程和并发控制: 在多线程程序中,多个线程可能会同时访问共享内存。使用 CPU 屏障可以确保线程之间的内存操作顺序,从而避免出现数据不一致的情况。 内存模型: 在某些硬件平台上,特别是在不同架构(如 x86 和 ARM)之间进行程序移植时,CPU 屏障能够确保程序按照预期的内存顺序执行。 硬件编程: 对于直接操作硬件的低级编程(例如内存映射 I/O、嵌入式系统开发等),CPU 屏障能够确保 I/O 操作按顺序完成,避免因为缓存一致性问题导致硬件异常。 例子:在 x86 架构中的应用 假设有一个共享变量 shared_var,多个线程可能会修改它。如果不使用 CPU 屏障,线程 1 修改 shared_var 后,可能没有立即刷新到主内存,线程 2 可能会看到过时的缓存数据。 以下是一个简单的 C 代码示例: #include #include volatile int shared_var = 0; void thread1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("mfence" ::: "memory"); // 使用 MFENCE 全屏障,确保写操作完成} void thread2() { while (shared_var == 0) { // 等待 thread1 更新 shared_var } printf("shared_var updated to 1\n");} int main() { // 模拟两个线程 thread1(); thread2(); return 0;} 在这个示例中,thread1 在修改 shared_var 后使用了 mfence 指令来确保修改后的值及时写入主内存,并且被其他线程看到。thread2 会等待 shared_var 更新后继续执行。 总结 CPU 屏障 是硬件层面的机制,用来控制 CPU 内部指令执行顺序和缓存同步,确保内存操作按照特定顺序发生。 它防止 CPU 对指令的重排序,从而避免多核环境中的缓存一致性问题。 不同的 CPU 架构(如 x86、ARM)提供了不同类型的 CPU 屏障指令,如 MFENCE(x86)、DMB(ARM)等。 小结图示 +-------------------------------+ | 主内存(RAM) | | | | +-------------------------+ | | | 共享变量(shared_var) | | | +-------------------------+ | +-------------------------------+ | | v v +----------------+ +----------------+ | 核心 1 | | 核心 2 | | L1 缓存 | | L1 缓存 | | 存储变量值 | | 存储变量值 | +----------------+ +----------------+ | | v v CPU 屏障(如 MFENCE) 强制同步缓存与主内存 通过 CPU 屏障的使用,确保 核心 1 对 shared_var 的修改能正确地同步到主内存,核心 2 可以看到最新的值。 五、使用内存屏障还需要使用CPU屏障吗? 对于大多数开发人员来说,内存屏障(Memory Barriers)通常是足够的,因为它们是软件级别的同步机制,而CPU 屏障(CPU Barriers)是硬件级别的机制,两者的目标都是确保内存操作按预期顺序执行。内存屏障通过插入特定的指令来控制 CPU 的缓存一致性和内存操作顺序,通常通过编译器提供的原语(如 __sync_synchronize、atomic_thread_fence、mfence 等)来实现。 1. 内存屏障 vs CPU 屏障 内存屏障: 由 程序员显式插入,通常作为一条特殊的汇编指令或者编译器指令。 它确保了程序中的某些内存操作顺序不会被优化或乱序执行。 对于 程序员来说,内存屏障是一个高层的工具,在需要同步共享数据时,它是最常用的同步机制。 CPU 屏障: 是 硬件层面的同步机制,直接控制 CPU 内部的缓存、指令流水线和内存访问顺序。 这些机制通常是针对 处理器架构(如 x86、ARM)的具体硬件指令,用于确保内存操作按顺序发生。 程序员通常 不直接操作 CPU 屏障,而是通过内存屏障指令间接地影响硬件行为。 2. 内存屏障满足开发者需求 对于程序员而言,使用 内存屏障 就足够了,因为: 内存屏障指令是跨平台的,开发人员不需要针对特定的 CPU 指令来编写代码。它们会依赖 编译器 来生成适合特定平台的机器代码。 编译器和操作系统已经为我们处理了硬件的差异。程序员插入的内存屏障会触发适当的 CPU 屏障或其他低层次同步机制,具体取决于目标平台的 CPU 和架构。 在 多核处理器 上,内存屏障确保一个线程对共享数据的修改能够及时写回主内存,并且通知其他线程访问这些数据时能看到最新的值。 3. 实际应用中的差异 3.1. 使用内存屏障 内存屏障(如__sync_synchronize、atomic_thread_fence、mfence)通过插入到代码中来显式指定内存操作顺序,从而控制并发操作时的内存一致性问题。 例如,在多线程编程中,你希望线程 A 在修改共享变量之后,线程 B 能立即看到该修改,可以在 A 中插入内存屏障: #include std::atomic<int> shared_var = 0; void thread_a() { shared_var.store(1, std::memory_order_release); // 写屏障} void thread_b() { while (shared_var.load(std::memory_order_acquire) == 0) { // 等待线程 A 更新 shared_var } // 现在可以安全地使用 shared_var} 在此例中,std::memory_order_release 和 std::memory_order_acquire 就是内存屏障的类型,它们保证了内存操作的顺序。 3.2. CPU 屏障 CPU 屏障是硬件级别的机制。程序员一般不会显式写入 CPU 屏障指令,除非进行非常低级的硬件编程或直接操作硬件。 例如,在嵌入式系统开发或驱动开发中,可能会看到 mfence 或其他处理器指令: __asm__ __volatile__("mfence" ::: "memory"); 在这种情况下,程序员直接插入了硬件指令,显式控制 CPU 执行顺序。但是在 一般应用程序开发 中,通常并不需要手动插入这些指令,而是依赖编译器和标准库中的内存屏障。 4. 总结 对于大多数应用程序开发人员,内存屏障已经足够,不需要直接使用 CPU 屏障指令。编译器会根据目标硬件生成合适的机器代码,确保内存操作的顺序性和一致性。 内存屏障:适用于大多数开发者,提供了一个高层次的同步工具,确保不同线程之间的内存操作顺序。 CPU 屏障:属于硬件层面的操作,通常程序员不需要直接处理,除非进行底层的硬件开发。 5. 应用场景 多线程同步:确保线程对共享数据的修改能够及时生效,避免缓存不一致导致的数据不一致问题。 优化:在多核处理器中,通过内存屏障确保正确的内存操作顺序,避免因指令重排和缓存不一致带来的问题。 6. 建议 a. 如果你在编写并发程序,内存屏障是足够的,尝试使用标准库中的原子操作和内存顺序。 b. 如果你进行低级别硬件编程(例如驱动开发、嵌入式系统),可能需要直接使用 CPU 屏障。 六、“原子操作/互斥锁” 与 “内存屏障” 1. 原子操作与内存屏障的对比 原子操作(Atomic Operation)是对某个特定变量进行原子级别的访问(通常是加、减、交换等操作),它确保操作对这个变量的修改是不可分割的,即操作要么完全执行,要么完全不执行。 原子操作的作用范围:原子操作是针对特定变量的,只会影响该变量的状态,它在进行操作时,也会隐含地执行内存屏障,确保对该变量的修改对其他线程可见。 内存屏障的作用范围:内存屏障则更加广泛,它会影响到线程的所有共享变量,确保整个内存访问顺序符合预期。 简而言之,原子操作是对某个特定变量的一种操作,保证它的正确性;而内存屏障控制的是线程的所有共享变量的访问顺序,确保不同线程之间的同步和一致性。 2. 互斥锁的读写屏障 互斥锁(Mutex)作为线程同步的一种机制,通常会涉及到内存屏障。因为互斥锁的使用会影响多个线程对共享数据的访问顺序,因此通常在加锁和解锁时,操作系统会插入内存屏障,确保在锁被持有时,所有操作会遵循正确的顺序。 加锁(线程持锁)时,需要确保该线程读取的共享数据是 最新的,因此需要通过 读屏障 来保证锁保护的变量被同步到主内存,确保读到的是最新的数据。 解锁(释放锁)时,需要确保该线程修改的数据被 同步到主内存,并通知其他线程,这通常通过 写屏障 来实现,以保证其他线程能看到更新后的数据。 加锁/解锁时的内存屏障作用示意: 加锁:会确保在加锁前,当前线程对共享数据的所有操作(尤其是写入)已经完成,并且数据对其他线程可见。这通常通过插入写屏障来实现。 解锁:会确保在解锁后,其他线程能看到当前线程对共享数据的最新修改。这通常通过插入读屏障来实现。 3. 总结 内存屏障: 内存屏障的作用是保护共享数据的一致性,确保线程对共享变量的操作顺序正确。它影响的是所有共享变量,而不仅仅是某个特定的变量。 它防止 CPU 对内存访问的乱序执行,确保内存操作按预期的顺序执行。 原子操作: 原子操作通常针对单个变量,确保操作是原子性的,即不可中断的,并且会隐式地执行内存屏障,保证操作对其他线程可见。 互斥锁和内存屏障: 互斥锁在加锁和解锁时会执行内存屏障。加锁时插入写屏障,确保当前线程的写操作完成;解锁时插入读屏障,确保其他线程能看到当前线程的修改。 4. 建议 a. 如果你编写多线程程序并使用互斥锁,确保你理解锁的加锁和解锁时的内存屏障作用,以避免数据不一致问题。 b. 了解并使用原子操作和内存屏障来控制线程间共享数据的顺序,特别是在性能要求较高的场合。 七、配合内存屏障来真正理解volatile volatile 关键字在 C 和 C++ 中用于修饰变量,告诉编译器该变量的值可能会在程序的任何时刻发生变化,通常是由外部因素(如硬件、操作系统或其他线程)引起的。使用 volatile 可以防止编译器对该变量进行优化(什么样的优化?),从而确保每次访问时都从内存中获取变量的最新值,而不是从寄存器或缓存中读取过时的值。 优化?变量的优化就是CPU指令对此变量的操作一直在缓存中执行,增加运行速度。 防止优化?其实是防止读优化,即每次读操作都从主内存取,但是写操作不负责优化,可能写完大概率还在缓存中,其他同时在其他CPU核心运行的线程依然拿不到最新值。 等价于每次使用此变量之前都会触发一次“只针对这个变量的读屏障”(这是个比喻,读屏障不会只针对单独一个变量) 主要作用: 1.防止编译器优化: 编译器为了提高性能,通常会将变量存储在寄存器中,并减少对内存的访问。但是,如果一个变量是由外部因素改变的(比如硬件寄存器或其他线程修改),编译器可能不会及时从内存中读取该变量的最新值。 使用 volatile 告诉编译器:“不要优化对这个变量的访问,每次都直接从内存中读取它。” 2.确保变量访问的正确性: 当一个变量的值可能被多个线程、信号处理程序或硬件设备(例如,I/O端口、硬件寄存器)所修改时,volatile 关键字确保每次读取时都会从内存中获取最新的值,而不是使用缓存或寄存器中的值。 3.不保证写操作同步到主内存:如果你修改了一个 volatile 变量,编译器会直接在内存中修改这个变量的值,但这并不意味着其他核心的 CPU 或线程会立即看到这个修改。它不会自动确保 这个修改会立即同步到其他线程或 CPU 核心的缓存中。 4.volatile的作用范围:它确保你每次访问时都能获取到变量的最新值,但并不会同步其他线程或 CPU 中的缓存。例如,在多核处理器环境下,CPU 核心 A 可能会缓存某个变量,而 CPU 核心 B 可能并不知道核心 A 修改了这个变量,volatile 不能解决这种缓存一致性问题。 适用场景: 硬件寄存器:在嵌入式系统中,直接映射到硬件的内存地址经常会用 volatile,以确保每次访问时都读取硬件的最新值,而不是使用缓存。 多线程共享变量:当多个线程共享一个变量时,某个线程对该变量的修改可能会被其他线程及时看到,volatile 可以确保每次访问该变量时,都能获取到最新的值。 信号处理函数中的变量:在多线程或多进程的环境中,如果信号处理程序修改了某个变量,程序中的其他部分在访问这个变量时,需要确保获取到的是最新的值,因此也需要用 volatile。 使用示例: #include volatile int flag = 0; // 声明为volatile,表示它的值可能被外部因素改变 void signal_handler() { flag = 1; // 外部信号处理程序改变flag的值} void wait_for_flag() { while (flag == 0) { // 这里每次访问flag时,都会从内存中读取,而不是使用寄存器或缓存中的值 } printf("Flag is set!\n");} int main() { wait_for_flag(); // 等待flag被信号处理程序修改 return 0;} 在这个例子中: flag 变量声明为 volatile,表示它可能在程序的其他地方(如信号处理函数 signal_handler)被修改。 wait_for_flag 函数会在 flag 被修改之前一直等待。当 flag 被设置为 1 时,程序才会继续执行。 volatile 确保每次检查 flag 时都从内存读取,而不是从寄存器缓存中读取过时的值。 注意事项: 1.volatile 不等同于原子性或线程同步: volatile 只确保了 每次都从内存中读取变量的值,它并不会提供线程安全或原子性。例如,如果多个线程同时修改一个 volatile 变量,它仍然会有竞态条件(race condition)问题,因此在这种情况下,还需要使用互斥锁或原子操作来保证同步。 2.volatile不会防止缓存一致性问题: 在多核 CPU 系统中,volatile 并不能解决不同核心之间的缓存一致性问题。缓存一致性通常是由硬件(如 MESI 协议)或内存屏障来处理的。 3.volatile主要是为了防止编译器优化: 在大多数情况下,volatile 只是告诉编译器不要优化对变量的访问,它并不改变该变量的实际行为或内存模型。 结论: volatile 主要用于防止编译器优化,确保程序每次访问变量时都从内存中读取它的最新值,适用于那些可能被外部因素(硬件、其他线程、信号处理程序)改变的变量。 它 不会 处理多线程之间的同步问题,如果需要同步,则应该配合 互斥锁(mutex)或其他线程同步机制。 相关建议 a. 在多线程编程中,除了 volatile,你还应该了解如何使用 原子操作、互斥锁 或 条件变量 来确保共享数据的一致性。 b. 如果涉及到硬件寄存器的访问,理解 volatile 的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。

    昨天 11浏览
  • 为什么建议你用表驱动法?嵌入式C语言代码开发技巧

    数据压倒一切。如果选择了正确的数据结构并把一切组织的井井有条,正确的算法就不言自明。编程的核心是数据结构,而不是算法。——Rob Pike 说明 本文基于这样的认识:数据是易变的,逻辑是稳定的。本文例举的编程实现多为代码片段,但不影响描述的完整性。本文例举的编程虽然基于C语言,但其编程思想也适用于其他语言。此外,本文不涉及语言相关的运行效率讨论。 概念提出 所谓表驱动法(Table-Driven Approach)简而言之就是用查表的方法获取数据。此处的“表”通常为数组,但可视为数据库的一种体现。 根据字典中的部首检字表查找读音未知的汉字就是典型的表驱动法,即以每个字的字形为依据,计算出一个索引值,并映射到对应的页数。相比一页一页地顺序翻字典查字,部首检字法效率极高。 具体到编程方面,在数据不多时可用逻辑判断语句(if…else或switch…case)来获取值;但随着数据的增多,逻辑语句会越来越长,此时表驱动法的优势就开始显现。 例如,用36进制(A表示10,B表示11,…)表示更大的数字,逻辑判断语句如下: if(ucNum < 10) {     ucNumChar = ConvertToChar(ucNum); } else if(ucNum == 10) {     ucNumChar = 'A'; } else if(ucNum == 11) {     ucNumChar = 'B'; } else if(ucNum == 12) {     ucNumChar = 'C'; } //... ... else if(ucNum == 35) {     ucNumChar = 'Z'; } 当然也可以用 switch…case 结构,但实现都很冗长。而用表驱动法(将numChar 存入数组)则非常直观和简洁。如: CHAR aNumChars[] = {'0', '1', '2', /*3~9*/'A', 'B', 'C', /*D~Y*/'Z'}; CHAR ucNumChar = aNumChars[ucNum % sizeof(aNumChars)]; 像这样直接将变量当作下数组下标来读取数值的方法就是直接查表法。 注意,如果熟悉字符串操作,则上述写法可以更简洁: CHAR ucNumChar = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[ucNum]; 使用表驱动法时需要关注两个问题:一是如何查表,从表中读取正确的数据;二是表里存放什么,如数值或函数指针。前者参见1.1节“查表方式”内容,后者参见1.2节“实战示例”内容。 查表方式 常用的查表方式有直接查找、索引查找和分段查找等。 直接查找 即直接通过数组下标获取到数据。如果熟悉哈希表的话,可以很容易看出这种查表方式就是哈希表的直接访问法。 如获取星期名称,逻辑判断语句如下: if(0 == ucDay) {     pszDayName = "Sunday"; } else if(1 == ucDay) {     pszDayName = "Monday"; } //... ... else if(6 == ucDay) {     pszDayName = "Saturday"; } 而实现同样的功能,可将这些数据存储到一个表里: CHAR *paNumChars[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; CHAR *pszDayName = paNumChars[ucDay]; 类似哈希表特性,表驱动法适用于无需有序遍历数据,且数据量大小可提前预测的情况。 对于过于复杂和庞大的判断,可将数据存为文件,需要时加载文件初始化数组,从而在不修改程序的情况下调整里面的数值。 有时,访问之前需要先进行一次键值转换。如表驱动法表示端口忙闲时,需将槽位端口号映射为全局编号。所生成的端口数目大小的数组,其下标对应全局端口编号,元素值表示相应端口的忙闲状态。 索引查找 有时通过一次键值转换,依然无法把数据(如英文单词等)转为键值。此时可将转换的对应关系写到一个索引表里,即索引访问。 如现有100件商品,4位编号,范围从0000到9999。此时只需要申请一个长度为100的数组,且对应2位键值。但将4位的编号转换为2位的键值,可能过于复杂或没有规律,最合适的方法是建立一个保存该转换关系的索引表。采用索引访问既节省内存,又方便维护。比如索引A表示通过名称访问,索引B表示通过编号访问。 分段查找 通过确定数据所处的范围确定分类(下标)。有的数据可分成若干区间,即具有阶梯性,如分数等级。此时可将每个区间的上限(或下限)存到一个表中,将对应的值存到另一表中,通过第一个表确定所处的区段,再由区段下标在第二个表里读取相应数值。注意要留意端点,可用二分法查找,另外可考虑通过索引方法来代替。 如根据分数查绩效等级: #define MAX_GRADE_LEVEL (INT8U)5 DOUBLE aRangeLimit[MAX_GRADE_LEVEL] = {50.0, 60.0, 70.0, 80.0, 100.0}; CHAR *paGrades[MAX_GRADE_LEVEL] = {"Fail", "Pass", "Credit", "Distinction", "High Distinction"}; static CHAR* EvaluateGrade(DOUBLE dScore) {     INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < aRangeLimit[ucLevel]) return paGrades[ucLevel];     } return paGrades[0]; } 上述两张表(数组)也可合并为一张表(结构体数组),如下所示: typedef struct{     DOUBLE  aRangeLimit;     CHAR    *pszGrade; }T_GRADE_MAP; T_GRADE_MAP gGradeMap[MAX_GRADE_LEVEL] = {     {50.0, "Fail"},     {60.0, "Pass"},     {70.0, "Credit"},     {80.0, "Distinction"},     {100.0, "High Distinction"} }; static CHAR* EvaluateGrade(DOUBLE dScore) {     INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < gGradeMap[ucLevel].aRangeLimit) return gGradeMap[ucLevel].pszGrade;     } return gGradeMap[0].pszGrade; } 该表结构已具备的数据库的雏形,并可扩展支持更为复杂的数据。其查表方式通常为索引查找,偶尔也为分段查找;当索引具有规律性(如连续整数)时,退化为直接查找。 使用分段查找法时应注意边界,将每一分段范围的上界值都考虑在内。 找出所有不在最高一级范围内的值,然后把剩下的值全部归入最高一级中。有时需要人为地为最高一级范围添加一个上界。 同时应小心不要错误地用“<”来代替“<=”。要保证循环在找出属于最高一级范围内的值后恰当地结束,同时也要保证恰当处理范围边界。 实战示例 本节多数示例取自实际项目。表形式为一维数组、二维数组和结构体数组;表内容有数据、字符串和函数指针。基于表驱动的思想,表形式和表内容可衍生出丰富的组合。 字符统计 问题:统计用户输入的一串数字中每个数字出现的次数。 普通解法主体代码如下: INT32U aDigitCharNum[10] = {0}; /* 输入字符串中各数字字符出现的次数 */ INT32U dwStrLen = strlen(szDigits); INT32U dwStrIdx = 0; for(; dwStrIdx < dwStrLen; dwStrIdx++) { switch(szDigits[dwStrIdx])     { case '1':             aDigitCharNum[0]++; break; case '2':             aDigitCharNum[1]++; break; //... ... case '9':             aDigitCharNum[8]++; break;     } } 这种解法的缺点显而易见,既不美观也不灵活。其问题关键在于未将数字字符与数组aDigitCharNum下标直接关联起来。 以下示出更简洁的实现方式: for(; dwStrIdx < dwStrLen; dwStrIdx++) { aDigitCharNum[szDigits[dwStrIdx] - '0']++; } 上述实现考虑到0也为数字字符。该解法也可扩展至统计所有ASCII可见字符。 月天校验 问题:对给定年份和月份的天数进行校验(需区分平年和闰年)。 普通解法主体代码如下: switch(OnuTime.Month) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: if(OnuTime.Day>31 || OnuTime.Day<1)         {             CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~31)!!!\n", OnuTime.Day);             retcode = S_ERROR;         } break; case 2: if(((OnuTime.Year%4 == 0) && (OnuTime.Year%100 != 0)) || (OnuTime.Year%400 == 0))         { if(OnuTime.Day>29 || OnuTime.Day<1)             {                 CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~29)!!!\n", OnuTime.Day);                 retcode = S_ERROR;             }         } else { if(OnuTime.Day>28 || OnuTime.Day<1)             {                 CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~28)!!!\n", OnuTime.Day);                 retcode = S_ERROR;             }         } break; case 4: case 6: case 9: case 11: if(OnuTime.Day>30 || OnuTime.Day<1)         {             CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~30)!!!\n", OnuTime.Day);             retcode = S_ERROR;         } break; default:         CtcOamLog(FUNCTION_Pon,"Don't support this Month: %d(1~12)!!!\n", OnuTime.Month);         retcode = S_ERROR; break; } 以下示出更简洁的实现方式: #define MONTH_OF_YEAR 12 /* 一年中的月份数 */ /* 闰年:能被4整除且不能被100整除,或能被400整除 */ #define IS_LEAP_YEAR(year) ((((year) % 4 == 0) && ((year) % 100 != 0)) || ((year) % 400 == 0)) /* 平年中的各月天数,下标对应月份 */ INT8U aDayOfCommonMonth[MONTH_OF_YEAR] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; INT8U ucMaxDay = 0; if((OnuTime.Month == 2) && (IS_LEAP_YEAR(OnuTime.Year)))     ucMaxDay = aDayOfCommonMonth[1] + 1; else ucMaxDay = aDayOfCommonMonth[OnuTime.Month-1]; if((OnuTime.Day < 1) || (OnuTime.Day > ucMaxDay) {     CtcOamLog(FUNCTION_Pon,"Month %d doesn't have this Day: %d(1~%d)!!!\n",               OnuTime.Month, OnuTime.Day, ucMaxDay);     retcode = S_ERROR; } 名称构造 问题:根据WAN接口承载的业务类型(Bitmap)构造业务类型名称字符串。 普通解法主体代码如下: void Sub_SetServerType(INT8U *ServerType, INT16U wan_servertype) { if ((wan_servertype & 0x0001) == 0x0001)     {         strcat(ServerType, "_INTERNET");     } if ((wan_servertype & 0x0002) == 0x0002)     {         strcat(ServerType, "_TR069");     } if ((wan_servertype & 0x0004) == 0x0004)     {         strcat(ServerType, "_VOIP");     } if ((wan_servertype & 0x0008) == 0x0008)     {         strcat(ServerType, "_OTHER");     } } 以下示出C语言中更简洁的实现方式: /* 获取var变量第bit位,编号从右至左 */ #define GET_BIT(var, bit) (((var) >> (bit)) & 0x1) const CHAR* paSvrNames[] = {"_INTERNET", "_TR069", "_VOIP", "_OTHER"}; const INT8U ucSvrNameNum = sizeof(paSvrNames) / sizeof(paSvrNames[0]); VOID SetServerType(CHAR *pszSvrType, INT16U wSvrType) {     INT8U ucIdx = 0; for(; ucIdx < ucSvrNameNum; ucIdx++) { if(1 == GET_BIT(wSvrType, ucIdx))             strcat(pszSvrType, paSvrNames[ucIdx]);     } } 新的实现将数据和逻辑分离,维护起来非常方便。只要逻辑(规则)不变,则唯一可能的改动就是数据(paSvrNames)。 值名解析 问题:根据枚举变量取值输出其对应的字符串,如PORT_FE(1)输出“Fe”。 //值名映射表结构体定义,用于数值解析器 typedef struct{     INT32U dwElem; //待解析数值,通常为枚举变量 CHAR*  pszName; //指向数值所对应解析名字符串的指针 }T_NAME_PARSER; /****************************************************************************** * 函数名称:  NameParser * 功能说明:  数值解析器,将给定数值转换为对应的具名字符串 * 输入参数:  VOID *pvMap       :值名映射表数组,含T_NAME_PARSER结构体类型元素                                 VOID指针允许用户在保持成员数目和类型不变的前提下,                                 定制更有意义的结构体名和/或成员名。              INT32U dwEntryNum :值名映射表数组条目数              INT32U dwElem     :待解析数值,通常为枚举变量              INT8U* pszDefName :缺省具名字符串指针,可为空 * 输出参数:  NA * 返回值  :  INT8U *: 数值所对应的具名字符串              当无法解析给定数值时,若pszDefName为空,则返回数值对应的16进制格式              字符串;否则返回pszDefName。 ******************************************************************************/ INT8U *NameParser(VOID *pvMap, INT32U dwEntryNum, INT32U dwElem, INT8U* pszDefName) {     CHECK_SINGLE_POINTER(pvMap, "NullPoniter");     INT32U dwEntryIdx = 0; for(dwEntryIdx = 0; dwEntryIdx < dwEntryNum; dwEntryIdx++) { T_NAME_PARSER *ptNameParser = (T_NAME_PARSER *)pvMap; if(dwElem == ptNameParser->dwElem)         { return ptNameParser->pszName;         } //ANSI标准禁止对void指针进行算法操作;GNU标准则指定void*算法操作与char*一致。 //若考虑移植性,可将pvMap类型改为INT8U*,或定义INT8U*局部变量指向pvMap。 pvMap += sizeof(T_NAME_PARSER);     } if(NULL != pszDefName)     { return pszDefName;     } else { static INT8U szName[12] = {0}; //Max:"0xFFFFFFFF" sprintf(szName, "0x%X", dwElem); return szName;     } } 以下给出NameParser的简单应用示例: //UNI端口类型值名映射表结构体定义 typedef struct{     INT32U dwPortType;     INT8U* pszPortName; }T_PORT_NAME; //UNI端口类型解析器 T_PORT_NAME gUniNameMap[] = {     {1, "Fe"},     {3, "Pots"},     {99, "Vuni"} }; const INT32U UNI_NAM_MAP_NUM = (INT32U)(sizeof(gUniNameMap)/sizeof(T_PORT_NAME)); VOID NameParserTest(VOID) {     INT8U ucTestIndex = 1;     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("DefName", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "DefName")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Fe", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 1, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Pots", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 3, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Vuni", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 99, NULL)) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 255, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("0xABCD", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("NullPoniter", NameParser(NULL, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK"); } gUniNameMap在实际项目中有十余个条目,若采用逻辑链实现将非常冗长。 取值映射 问题:不同模块间同一参数枚举值取值可能有所差异,需要适配。 此处不再给出普通的switch…case或if…else if…else结构,而直接示出以下表驱动实现: typedef struct{     PORTSTATE loopMEState;     PORTSTATE loopMIBState; }LOOPMAPSTRUCT; static LOOPMAPSTRUCT s_CesLoop[] = {     {NO_LOOP,                  e_ds1_looptype_noloop},     {PAYLOAD_LOOP,             e_ds1_looptype_PayloadLoop},     {LINE_LOOP,                e_ds1_looptype_LineLoop},     {PON_LOOP,                 e_ds1_looptype_OtherLoop},     {CES_LOOP,                 e_ds1_looptype_InwardLoop}}; PORTSTATE ConvertLoopMEStateToMIBState(PORTSTATE vPortState) {     INT32U num = 0, ii;          num = ARRAY_NUM(s_CesLoop); for(ii = 0; ii < num; ii++) { if(vPortState == s_CesLoop[ii].loopMEState) return s_CesLoop[ii].loopMIBState;     } return e_ds1_looptype_noloop; } 相应地,从loopMIBState映射到loopMEState需要定义一个ConvertLoopMIBStateToMEState函数。更进一步,所有类似的一对一映射关系都必须如上的映射(转换)函数,相当繁琐。事实上,从抽象层面看,该映射关系非常简单。提取共性后定义带参数宏,如下所示: /********************************************************** * 功能描述:进行二维数组映射表的一对一映射,用于参数适配 * 参数说明:map        -- 二维数组映射表             elemSrc    -- 映射源,即待映射的元素值             elemDest   -- 映射源对应的映射结果             direction  -- 映射方向字节,表示从数组哪列映射至哪列。                           高4位对应映射源列,低4位对应映射结果列。             defaultVal -- 映射失败时置映射结果为缺省值 * 示例:ARRAY_MAPPER(gCesLoopMap, 3, ucLoop, 0x10, NO_LOOP);             则ucLoop = 2(LINE_LOOP) **********************************************************/ #define ARRAY_MAPPER(map, elemSrc, elemDest, direction, defaultVal) do{\     INT8U ucMapIdx = 0, ucMapNum = 0; \     ucMapNum = sizeof(map)/sizeof(map[0]); \ for(ucMapIdx = 0; ucMapIdx < ucMapNum; ucMapIdx++) \ { \ if((elemSrc) == map[ucMapIdx][((direction)&0xF0)>>4]) \         { \             elemDest = map[ucMapIdx][(direction)&0x0F]; \ break; \         } \     } \ if(ucMapIdx == ucMapNum) \     { \         elemDest = (defaultVal); \     } \ }while(0) 参数取值转换时直接调用统一的映射器宏,如下: static INT8U gCesLoopMap[][2] = {     {NO_LOOP,                  e_ds1_looptype_noloop},     {PAYLOAD_LOOP,             e_ds1_looptype_PayloadLoop},     {LINE_LOOP,                e_ds1_looptype_LineLoop},     {PON_LOOP,                 e_ds1_looptype_OtherLoop},     {CES_LOOP,                 e_ds1_looptype_InwardLoop}}; ARRAY_MAPPER(gCesLoopMap, tPara.dwParaVal[0], dwLoopConf, 0x01, e_ds1_looptype_noloop); 另举一例: #define CES_DEFAULT_JITTERBUF (INT32U)2000 /* 默认jitterbuf为2000us,而1帧=125us */ #define CES_JITTERBUF_STEP (INT32U)125 /* jitterbuf步长为125us,即1帧 */ #define CES_DEFAULT_QUEUESIZE (INT32U)5 #define CES_DEFAULT_MAX_QUEUESIZE (INT32U)7 #define ARRAY_NUM(array) (sizeof(array) / sizeof((array)[0])) /* 数组元素个数 */ typedef struct{     INT32U  dwJitterBuffer;     INT32U  dwFramePerPkt;     INT32U  dwQueueSize; }QUEUE_SIZE_MAP; /* gCesQueueSizeMap也可以(JitterBuffer / FramePerPkt)值为索引,更加紧凑 */ static QUEUE_SIZE_MAP gCesQueueSizeMap[]= {        {1,1,1},  {1,2,1},  {2,1,2},  {2,2,1},        {3,1,3},  {3,2,1},  {4,1,3},  {4,2,1},        {5,1,4},  {5,2,3},  {6,1,4},  {6,2,3},        {7,1,4},  {7,2,3},  {8,1,4},  {8,2,3},        {9,1,5},  {9,2,4},  {10,1,5}, {10,2,4},        {11,1,5}, {11,2,4}, {12,1,5}, {12,2,4},        {13,1,5}, {13,2,4}, {14,1,5}, {14,2,4},        {15,1,5}, {15,2,4}, {16,1,5}, {16,2,4},        {17,1,6}, {17,2,5}, {18,1,6}, {18,2,5},        {19,1,6}, {19,2,5}, {20,1,6}, {20,2,5},        {21,1,6}, {21,2,5}, {22,1,6}, {22,2,5},        {23,1,6}, {23,2,5}, {24,1,6}, {24,2,5},        {25,1,6}, {25,2,5}, {26,1,6}, {26,2,5},        {27,1,6}, {27,2,5}, {28,1,6}, {28,2,5},        {29,1,6}, {29,2,5}, {30,1,6}, {30,2,5},        {31,1,6}, {31,2,5}, {32,1,6}, {32,2,5}}; /********************************************************** * 函数名称:CalcQueueSize * 功能描述:根据JitterBuffer和FramePerPkt计算QueueSize * 注意事项:配置的最大缓存深度 *            = 2 * JitterBuffer / FramePerPkt *            = 2 * N Packet = 2 ^ QueueSize *            JitterBuffer为125us帧速率的倍数, *            FramePerPkt为每个分组的帧数, *            QueueSize向上取整,最大为7。 **********************************************************/ INT32U CalcQueueSize(INT32U dwJitterBuffer, INT32U dwFramePerPkt) {     INT8U ucIdx = 0, ucNum = 0; //本函数暂时仅考虑E1 ucNum = ARRAY_NUM(gCesQueueSizeMap); for(ucIdx = 0; ucIdx < ucNum; ucIdx++) { if((dwJitterBuffer == gCesQueueSizeMap[ucIdx].dwJitterBuffer) &&           (dwFramePerPkt == gCesQueueSizeMap[ucIdx].dwFramePerPkt))        { return gCesQueueSizeMap[ucIdx].dwQueueSize;        }     } return CES_DEFAULT_MAX_QUEUESIZE; } 版本控制 问题:控制OLT与ONU之间的版本协商。ONU本地设置三比特控制字,其中bit2(MSB)~bit0(LSB)分别对应0x21、0x30和0xAA版本号;且bitX为0表示上报对应版本号,bitX为1表示不上报对应版本号。其他版本号如0x20、0x13和0x1必须上报,即不受控制。 最初的实现采用if…else if…else结构,代码非常冗长,如下: pstSendTlv->ucLength = 0x1f; if (gOamCtrlCode == 0) {     vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3);     pstSendTlv->aucVersionList[3] = 0x30;     vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3);     pstSendTlv->aucVersionList[7] = 0x21;     vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3);     pstSendTlv->aucVersionList[11] = 0x20;     vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3);     pstSendTlv->aucVersionList[15] = 0x13;     vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3);     pstSendTlv->aucVersionList[19] = 0x01;     vosMemCpy(&(pstSendTlv->aucVersionList[20]), ctc_oui, 3);     pstSendTlv->aucVersionList[23] = 0xaa; } else if (gOamCtrlCode == 1) {     vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3);     pstSendTlv->aucVersionList[3] = 0x30;     vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3);     pstSendTlv->aucVersionList[7] = 0x21;     vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3);     pstSendTlv->aucVersionList[11] = 0x20;     vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3);     pstSendTlv->aucVersionList[15] = 0x13;     vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3);     pstSendTlv->aucVersionList[19] = 0x01; } //此处省略gOamCtrlCode == 2~6的处理代码 else if (gOamCtrlCode == 7) {     vosMemCpy(&(pstSendTlv->aucVersionList), ctc_oui, 3);     pstSendTlv->aucVersionList[3] = 0x20;     vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3);     pstSendTlv->aucVersionList[7] = 0x13;     vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3);     pstSendTlv->aucVersionList[11] = 0x01; } 以下示出C语言中更简洁的实现方式(基于二维数组): /********************************************************************** * 版本控制字数组定义 * gOamCtrlCode:   Bitmap控制字。Bit-X为0时上报对应版本,Bit-X为1时屏蔽对应版本。 * CTRL_VERS_NUM:  可控版本个数。 * CTRL_CODE_NUM:  控制字个数。与CTRL_VERS_NUM有关。 * gOamVerCtrlMap: 版本控制字数组。行对应控制字,列对应可控版本。                   元素值为0时不上报对应版本,元素值非0时上报该元素值。 * Note: 该数组旨在实现“数据与控制隔离”。后续若要新增可控版本,只需修改                   -- CTRL_VERS_NUM                   -- gOamVerCtrlMap新增行(控制字)                   -- gOamVerCtrlMap新增列(可控版本) **********************************************************************/ #define CTRL_VERS_NUM 3 #define CTRL_CODE_NUM (1< u8_t gOamVerCtrlMap[CTRL_CODE_NUM][CTRL_VERS_NUM]

    昨天 28浏览
  • 8个问题搞懂linux系统

    背景: 我们学习Linux的系统调用经常会遇到一个概念:“内核态和用户态的切换”,一般人只会告诉你说这个切换代价很大,具体是什么情况?为什么需要切换?一定需要切换吗?怎么就会触发切换?内核怎么知道要切换了?内核态是一个程序吗?是谁来切换的,CPU吗?等等具体细节很多人都不知道。我花时间整理了这些问题的答案,希望能帮到更多人。 要理解以上问题,需要有计算机的基础知识,计算机的本质是开关门三极管控制的高低电平切换,这些高低电平就是二进制的0、1。本质上也是数据的计算和保存。寄存器、缓存器、运存、内存等是数据,CPU负责计算转换这些数据。外设负责显示和传输数据,使用的“人”可以看到这些数据。 现在的操作系统其实就是把以前的“汇编版单程序”做了封装,本质还没变,一个CPU同时只能运行一个一个程序,不过是速度快了,让程序之间来回切换,让我们人感知不到任何切换感和卡顿感(电脑的卡顿不是CPU卡顿是程序的阻塞)。基于这一点:内核就是一个个程序,一上电内核肯定早于用户程序运行在CPU中。内核调度用户程序,把用户程序的上下文加载到CPU 并让出CPU,让出前定好定时器(硬件定时器--可以打断CPU运行),让用户程序时间片到了后可以切换回内核程序。 问题1:linux 系统调用 read/write这种 执行的时候 用户态和内核态的切换过程怎样,有什么缺点? 在 Linux 系统中,read 和 write 系统调用是典型的用户态和内核态之间的切换过程。下面是它们执行时的用户态和内核态切换过程以及潜在的缺点: 1. 执行过程 用户态到内核态切换: 用户程序调用 read() 或 write() 时,首先是从用户态发起系统调用。 由于 read 和 write 是系统调用,它们会触发一个软件中断或调用 syscall 指令,这将导致 CPU 从用户态切换到内核态。 内核在内核态中执行实际的 I/O 操作(例如,磁盘读写、网络操作等)。内核会访问设备驱动程序,可能会通过设备管理层与硬件进行交互。 内核态到用户态切换: 当 I/O 操作完成后,内核会将结果(例如读取的数据,写入操作的字节数)返回给用户程序。 结果返回后,系统会通过中断或返回指令将 CPU 状态从内核态切换回用户态,允许用户程序继续执行。 2. 内核态和用户态切换的缺点 内核态和用户态的切换是开销较大的操作,尤其是在频繁调用 read 和 write 系统调用时。具体的缺点包括: 1. 上下文切换的开销: 每次从用户态切换到内核态(或者反过来),都会发生上下文切换。上下文切换不仅涉及保存和恢复 CPU 寄存器的状态,还包括更新内存中的相关数据结构(如进程控制块),这些操作会带来额外的性能开销。 如果 read 或 write 调用是同步的(阻塞模式),程序会等待 I/O 完成,这时 CPU 可能空闲,浪费了大量的时间。 2. I/O 阻塞与同步: 在传统的阻塞 I/O 模式下,read 和 write 系统调用可能会使调用线程处于阻塞状态,直到 I/O 操作完成。这种情况下的上下文切换会导致 CPU 浪费时间等待 I/O 完成。 如果应用程序需要频繁进行阻塞 I/O 操作(例如,每秒钟执行大量的 read/write 系统调用),会导致频繁的用户态和内核态切换,从而增加系统开销。 3. 缓存一致性和内存访问: read 和 write 系统调用有时会涉及大量的数据拷贝。例如,read 操作可能会将数据从内核空间拷贝到用户空间,这需要额外的内存访问,尤其在大量数据传输的情况下,这种开销非常显著。 现代操作系统通常使用缓冲区或零拷贝技术(如 mmap()、sendfile() 等)来减少这种开销,但仍然需要避免频繁的内核态与用户态之间的数据拷贝。 4. 线程切换: 在多线程环境中,频繁调用 read 和 write 可能会导致频繁的线程切换(尤其是在多个线程共享同一文件描述符时),增加了额外的调度开销。 3. 优化方法 为了减少用户态和内核态切换带来的性能损耗,可以采取以下几种优化方法: 异步 I/O(AIO):通过异步 I/O,应用程序不需要在 read 或 write 调用时阻塞线程,内核会在 I/O 操作完成时通知应用程序,减少了上下文切换的次数。 零拷贝 I/O(Zero-copy I/O):通过减少内核与用户空间之间的数据拷贝来提高性能,例如使用 mmap() 或 sendfile()。 非阻塞 I/O:通过设置文件描述符为非阻塞模式,避免阻塞等待,结合 select() 或 poll() 等 I/O 多路复用技术,提高并发性能。 总结来说,read 和 write 系统调用的用户态和内核态切换过程在多次调用时可能带来显著的性能开销,尤其是上下文切换、阻塞等待和数据拷贝等问题。通过使用异步 I/O、零拷贝技术等优化方法,可以有效减少这些开销。 问题2:fread/fwrite/fopen/fclose/getchar/gets/puts/putchar是系统调用吗,异步IO怎么使用? 1. **fread/fwrite/fopen/fclose/getchar/gets/puts/putchar 是否是系统调用? 这些函数是 C 标准库提供的高级 I/O 函数,它们并不是直接的系统调用,而是通过系统调用实现的。系统调用是内核提供的接口,用于用户程序与操作系统之间的交互,而这些库函数是建立在操作系统提供的系统调用之上的,它们负责提供更高层次的功能,如缓冲、格式化等。 具体来说: fread/fwrite/fopen/fclose:这些是标准 C 库提供的文件 I/O 操作函数,通常会通过 read 和 write 系统调用来实际进行文件操作。 fopen 会调用系统调用(如 open)来打开文件。 fread 和 fwrite 在执行数据读取或写入时,最终会调用系统调用(如 read 和 write),并且会使用缓冲区进行缓存管理。 fclose 会关闭文件,并释放与文件相关的资源,通常会调用系统调用(如 close)。 getchar/puts/putchar: getchar 是标准输入函数,会调用 read 系统调用读取数据。 putchar 和 puts 是输出函数,底层通过 write 系统调用将字符输出到标准输出。 总结: 这些 C 库函数本身并不是系统调用,但它们内部通过对系统调用(如 read, write, open, close 等)的调用来实现实际的 I/O 操作。它们提供了额外的功能,如缓冲区管理、格式化输入输出等。 2. 异步 I/O (AIO) 怎么使用? 异步 I/O(AIO)允许程序在发起 I/O 操作后,不需要阻塞等待操作完成,而是可以继续执行其他任务,直到 I/O 操作完成并通知程序。这对于提高性能、避免线程阻塞非常有效,尤其在高并发的场景下。 在 Linux 上,异步 I/O 主要通过以下几种方式实现: 1. aio_read 和 aio_write: 这些是 POSIX 标准定义的异步 I/O 系统调用。与传统的 read 和 write 系统调用不同,aio_read 和 aio_write 不会阻塞进程,调用者可以在等待 I/O 操作完成时继续执行其他代码。 示例: #include #include #include #include #include #include #include int main() { struct aiocb cb; // 异步 I/O 控制块 int fd = open("example.txt", O_RDONLY); if (fd == -1) { perror("open"); return -1; } // 设置异步读取的参数 memset(&cb, 0, sizeof(struct aiocb)); cb.aio_fildes = fd; cb.aio_buf = malloc(1024); cb.aio_nbytes = 1024; cb.aio_offset = 0; // 发起异步读取 if (aio_read(&cb) == -1) { perror("aio_read"); close(fd); return -1; } // 检查 I/O 操作是否完成 while (aio_error(&cb) == EINPROGRESS) { // 可以继续执行其他操作 printf("I/O in progress...\n"); sleep(1); } // 获取操作结果 int ret = aio_return(&cb); if (ret == -1) { perror("aio_return"); close(fd); return -1; } printf("Read %d bytes: %s\n", ret, (char*)cb.aio_buf); free((void*)cb.aio_buf); close(fd); return 0;} 关键步骤: aio_read 发起异步读操作,aio_write 用于异步写操作。 aio_error 用于检查 I/O 操作是否仍在进行中。 aio_return 用于获取操作结果,如果操作已完成。 2. epoll 和 select: epoll 和 select 是 I/O 多路复用机制,允许一个线程同时监控多个文件描述符的事件,例如可读、可写等。它们本身不是完全异步 I/O,但能够以非阻塞方式进行 I/O 操作,帮助高效地管理多个并发连接。 示例(使用epoll监控文件描述符的可读事件): #include #include #include #include #include int main() { int epoll_fd = epoll_create1(0); if (epoll_fd == -1) { perror("epoll_create1"); exit(EXIT_FAILURE); } int fd = open("example.txt", O_RDONLY | O_NONBLOCK); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } struct epoll_event event; event.events = EPOLLIN; // 监控可读事件 event.data.fd = fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) { perror("epoll_ctl"); exit(EXIT_FAILURE); } struct epoll_event events[10]; int nfds = epoll_wait(epoll_fd, events, 10, -1); if (nfds == -1) { perror("epoll_wait"); exit(EXIT_FAILURE); } for (int i = 0; i < nfds; i++) { if (events[i].events & EPOLLIN) { // 处理可读事件 char buffer[1024]; int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer)); if (bytes_read > 0) { printf("Read %d bytes: %s\n", bytes_read, buffer); } else { printf("End of file or error\n"); } } } close(fd); close(epoll_fd); return 0;} 3. 使用 mmap 和 msync 进行内存映射 I/O: 内存映射 I/O 是一种通过内存操作文件的方式,这种方法可以实现高效的异步 I/O,尤其在处理大文件时可以提高性能。 总结: 异步 I/O 可以通过使用 aio_read/aio_write 等接口来实现,它们允许在 I/O 操作未完成时不阻塞当前线程。 epoll/select 提供了一种更灵活的方式来处理多个并发 I/O 操作,虽然它们并不直接提供异步 I/O,但通过非阻塞 I/O 和事件通知机制,也能达到类似的效果。 问题3:既然系统调用引发了上下文切换,那么其实就是进程间的切换,是用户进程和内核进程间的切换吗,内核进程是每次系统调用时才开始运行 还是一直在运行,对应哪个程序? 系统调用确实会导致上下文切换,但它并不是指进程间的完全切换。系统调用引发的上下文切换主要是用户进程和内核之间的切换,而不是两个不同的用户进程或两个内核进程之间的切换。 1. 用户进程与内核进程的切换 用户进程 在用户态运行,它在执行过程中可以发起 系统调用(如 read、write、open 等),此时发生 上下文切换,CPU 会从用户态切换到内核态。 内核态 由操作系统内核代码控制,内核执行的部分是操作系统的一部分,负责管理硬件资源、内存管理、I/O 操作等。内核态下执行的是内核代码,这时会执行具体的系统调用逻辑,如硬件驱动、内存管理等。 内核进程并不代表是一个普通的进程,它是由操作系统内核管理的,通常不依赖于特定的程序。内核会执行很多任务,并且可以同时管理多个用户进程。内核中有一些特定的执行上下文(如中断处理、系统调用的执行),这些都属于内核层面的“活动”。 2. 内核进程和内核态执行的区别 内核进程:通常是指内核中执行的那些特定任务(如调度、进程管理、网络处理等),这些任务由操作系统内核自己管理。 内核态执行:是指当前正在执行的内核代码,无论是否有内核进程在运行。实际上,内核并不像用户进程那样“常驻”或“独立运行”。当用户进程发起系统调用时,内核会执行对应的内核代码(如 I/O 操作),完成后再返回用户进程。 3. 系统调用时内核的执行 内核态的执行:内核并不是每次系统调用时才开始执行,它始终在内存中并处于休眠或等待状态。当用户进程触发系统调用时,CPU 切换到内核态,执行与该系统调用相关的内核代码(例如磁盘 I/O、网络操作、内存分配等)。系统调用结束后,CPU 再切换回用户态,继续执行用户进程。 在内核态,操作系统会管理多种资源,如中断、设备驱动程序、内存管理等,它通过各种机制(如中断、定时器、系统调用等)来调度内核态代码的执行。 例子:当用户程序发起read()系统调用时,内核需要通过磁盘 I/O 或网络设备获取数据,并把数据从内核空间传递到用户空间。内核执行这些操作时并不依赖于某个单独的“内核进程”,而是由内核中相应的代码块来完成。 4. 内核进程不是每次系统调用才执行 内核进程通常是指内核内部用于特定任务的程序(如内核线程)。但是,在 Linux 等操作系统中,内核是一个整体的操作系统内核,它会管理和调度所有进程(包括用户进程和内核线程)。内核进程并不是系统调用时才运行,而是内核代码在响应系统调用时会启动内核的相应部分。 例如,操作系统内核会创建和管理各种“内核线程”(如 kworker,ksoftirqd 等),这些线程用于执行后台任务,如处理硬件中断、执行调度等。这些内核线程在系统启动时就会存在,并且会一直运行,独立于用户进程。 5. 进程的执行与内核的关系 用户进程:通常会在用户态执行,除非它发起系统调用或者发生中断等需要内核介入的事件。在系统调用时,操作系统的内核代码会被调度执行。 内核进程:并不是像用户进程一样是由用户创建的。内核本身有一些“内核线程”,它们在内核模式下执行,通常由操作系统自动创建和管理。它们执行特定任务(如硬件中断处理、网络协议栈、内存管理等)。 6. 上下文切换的两种类型 用户态到内核态的切换:例如当用户进程发起系统调用时,操作系统会从用户进程切换到内核空间。 内核态到用户态的切换:当系统调用完成时,操作系统会将执行从内核空间切换回用户空间,继续执行用户进程。 上下文切换在这两种情况下都可能发生,但 内核进程并不代表是特定的独立程序,它只是内核的一部分,始终处于内核模式下,并且会响应系统调用、硬件中断等事件。 总结: 系统调用引发的上下文切换,是用户进程从用户态切换到内核态,执行内核代码。并不是进程间的切换,而是用户态和内核态之间的切换。 内核进程 并不是每次系统调用时才开始运行,内核始终在内存中,可以理解为操作系统的一部分,负责管理硬件、内存、进程调度等任务。 内核本身并不像用户进程一样是一个独立的程序,而是操作系统的一部分,内核的不同部分(如内核线程)始终在内核态下执行。 问题4:不管是内核还是用户程序 对于CPU来说就是 一段待执行的指令合集,不过是操作系统内核的指令集很大,正常处于休眠状态,等待中断来触发执行。本质上来说,用户程序是系统调用的,本质上来说也是运行的负责调度的内核程序,这个内核程序负责读取和切换待调度的每个程序的上下文。是这样吗? 1. 用户程序和内核程序的关系 用户程序:是由用户编写的应用程序,运行在 用户态。它通过系统调用与操作系统进行交互,例如使用 read 或 write 执行 I/O 操作、通过 fork 创建子进程等。当用户程序发起系统调用时,会触发用户态到内核态的上下文切换,此时操作系统内核开始执行对应的内核代码(如进程调度、文件系统操作、内存管理等)。 内核程序(内核态代码):是操作系统的核心,运行在 内核态。它负责管理硬件资源、调度进程、提供系统调用接口、处理中断等。内核代码并不是单独的“程序”,而是操作系统的一部分,它的职责是响应系统调用、管理系统资源并调度用户进程。 你说得对,内核负责调度进程并在需要时切换它们的上下文。 2. 内核程序的执行: 是操作系统内核代码的执行 内核 是一个 大指令集,包含很多功能,例如管理 CPU 调度、内存管理、I/O 操作、硬件中断处理等。它通常处于“休眠”状态,即它的代码并不会主动执行,只有在某些事件发生时(例如,系统调用、硬件中断、进程调度等),内核才会被唤醒并开始执行相应的任务。 你可以把内核想象成一个负责协调、管理和调度硬件资源的程序。当 用户程序 发起一个 系统调用 时,内核会响应这个调用,切换到内核态,执行内核代码,执行完毕后再切换回用户态。 3. 上下文切换: 上下文切换 是指 CPU 从一个执行上下文切换到另一个执行上下文。上下文包括程序计数器(PC)、寄存器值、栈指针等信息。每次从 用户程序切换到内核程序(或反之)时,都会进行上下文切换。 内核负责调度进程,并根据各种条件(如时间片、I/O 等待)决定哪个进程应该运行。内核会保存当前进程的上下文,并恢复下一个待执行进程的上下文。这个过程叫做 进程调度,它是操作系统管理多个程序并合理分配 CPU 时间的关键。 内核在执行过程中,会根据不同的事件(比如硬件中断、系统调用、时间片耗尽等)来触发上下文切换。比如,如果一个进程执行了 read 系统调用,CPU 会切换到内核态,由内核代码处理 I/O 操作,处理完毕后再切换回用户进程。 4. 用户程序的调度 用户程序 的调度并不是由用户控制的,而是由操作系统内核负责。内核通过调度算法(如时间片轮转、优先级调度等)来决定哪个进程可以占用 CPU 执行。每当进程的时间片用完,或者进程进行 I/O 等待,或者发生某些调度事件时,内核会触发上下文切换。 所以,你可以理解为 用户程序本质上是由内核调度的。虽然用户程序在用户态下运行,但它的执行是受操作系统内核控制的。 5. 内核程序的角色: 内核程序的职责不仅仅是“调度”用户程序,还包括其他很多核心任务: 硬件管理:内核通过驱动程序与硬件交互,管理 I/O 设备、网络设备等。 内存管理:管理物理内存和虚拟内存,进行地址映射、分页等操作。 进程调度:决定哪个进程应该运行,并在多个进程之间切换。 文件系统管理:管理文件系统和磁盘存储。 中断处理:响应硬件中断,处理外设请求。 6. 总结 用户程序 是由用户编写的程序,运行在 用户态,通过系统调用与操作系统进行交互。 内核程序 是操作系统的一部分,运行在 内核态,负责管理硬件资源、调度进程、提供系统调用接口等。内核并不是“单独的程序”,而是操作系统的核心部分。 操作系统内核的主要任务 包括调度用户进程、处理中断、提供系统调用等,它负责在多进程环境中协调多个程序的执行,并确保它们能有效利用 CPU 和其他资源。 内核通过 上下文切换 进行进程调度,决定哪个进程能够占用 CPU 执行,并在多个进程之间进行切换。 问题5:那用户程序时间片到了是怎么让出CPU的呢(此时内核程序并未在运行,因为CPU一次只能运行一个程序),难道内核程序执行了一个定时中断,来切换用户程序? 是的,用户程序时间片到了时,操作系统会通过 定时中断(Timer Interrupt) 来触发 内核程序 的执行,从而进行 进程调度。具体来说,CPU 在执行用户程序时,操作系统内核并没有持续运行,而是通过中断机制来控制进程的切换。 1. 时间片与定时器中断 每个进程在操作系统中都会分配一个固定的 时间片(Time Slice),这是它在 CPU 上连续执行的时间。当时间片耗尽时,操作系统需要 暂停 当前进程的执行,并将 CPU 切换到另一个进程。 关键步骤: 定时器中断:操作系统会使用硬件定时器(通常是 PIT,可编程间隔定时器)来生成定时中断。定时器中断是一个周期性事件,它在设定的时间间隔内触发一次。例如,操作系统可能每 10 毫秒或 1 毫秒触发一次定时器中断。 定时器中断触发:当定时器中断触发时,CPU 会 暂停当前执行的指令,并跳转到内核代码来处理中断。这个过程涉及 上下文切换,操作系统内核会保存当前用户程序的状态(即上下文),并恢复操作系统的中断处理程序。 进程调度:内核的调度程序会检查当前运行的进程是否已经消耗完时间片。如果时间片已用完,调度程序会选择另一个进程来运行。此时,内核会将当前进程的状态保存在其进程控制块(PCB)中,然后选择下一个进程并恢复它的上下文,切换到该进程。 恢复进程:当调度程序完成切换后,CPU 会从内核态切换回用户态,恢复到新选定进程的执行。 2. 操作系统如何使用定时中断进行调度 操作系统的调度是通过响应定时器中断来进行的。在这个过程中,操作系统内核负责: 在中断发生时 保存当前进程的状态(即上下文),包括程序计数器、寄存器等。 调度 下一个进程并恢复其上下文。 让 CPU 切换到新的进程。 这种机制确保了操作系统可以 公平地分配 CPU 时间 给多个进程,使得每个进程都有机会在 CPU 上运行,并且防止某个进程长期占用 CPU。 3. 上下文切换的具体过程 定时中断触发时,CPU 会进入 内核态,并执行操作系统的 中断处理程序。操作系统会检查是否需要切换进程。 具体的处理步骤: 保存当前进程的上下文:操作系统保存当前进程的寄存器、程序计数器等信息,以便在该进程再次运行时能够恢复它的执行状态。 更新进程调度信息:操作系统会标记当前进程为“已用完时间片”或“等待 I/O”等状态,并选择下一个进程来运行。调度器根据调度算法(如时间片轮转、优先级调度等)选择下一个候选进程。 恢复新进程的上下文:操作系统恢复新选定进程的上下文,恢复其寄存器、程序计数器等,使得新的进程能够继续执行。 返回用户态:CPU 从内核态切换回用户态,继续执行新选定进程。 4. 定时器中断是如何控制时间片的 定时器中断通常由 硬件定时器(如 PIT)生成,操作系统会设置一个时间片的长度,假设时间片为 10 毫秒,那么操作系统每 10 毫秒就会通过定时器中断检查是否需要切换进程。 在每次定时器中断发生时,操作系统内核会检查当前进程是否已经用完了它的时间片。如果用完了,内核会选择另一个进程并进行上下文切换。 5. 举个例子 假设操作系统使用的是 时间片轮转(Round-Robin)调度算法,并且每个进程的时间片为 10 毫秒: 1.进程 A 被调度执行,开始运行。 2.10 毫秒后,定时器中断触发。 内核从用户态切换到内核态,处理定时器中断。 检查进程 A 是否已用完时间片,发现时间片已用完。 保存进程 A 的上下文。 选择下一个进程(比如进程 B)。 恢复进程 B 的上下文。 3.进程 B 开始执行,运行 10 毫秒后再次触发定时器中断。 4.继续这个过程,操作系统通过定时器中断来实现进程的轮换执行。 6. 总结 时间片到时,操作系统会通过定时器中断来 切换进程。定时器中断是由硬件定时器定期触发的,操作系统内核利用定时器中断来响应并进行进程调度。 内核程序 在定时器中断触发时被执行,它负责检查当前进程是否用完时间片,并调度下一个进程运行。 通过这种机制,操作系统能够公平地分配 CPU 时间给多个进程,确保系统的 响应性 和 并发性。 问题6:top命令能看到内核的程序吗? top 命令显示的是当前系统中 用户进程 和 内核线程 的运行状态,但它并不直接显示“内核程序”本身,因为内核本身并不是一个用户进程,而是操作系统的一部分,通常运行在 内核态。 1. top 命令显示的内容 top 命令的输出显示的是系统中 所有进程 的资源使用情况(如 CPU、内存、I/O 等),并且默认情况下列出的是 用户进程。这些进程的状态和资源占用可以帮助你了解系统的负载情况。 对于 内核线程,top 命令也会显示它们的状态,尤其是在 Linux 2.6 及其之后的版本,内核线程会被列出作为进程的一部分。内核线程是内核空间中的执行单元,它们负责执行系统的内部任务,如调度、硬件管理、I/O 操作等。 2. 内核线程的显示 在 top 命令的输出中,内核线程 会以特定的名称显示,这些线程通常以 k 开头(如 kworker、ksoftirqd、kthreadd 等)。 这些内核线程的名字表明它们是由内核创建并在内核态执行的。例如: kworker 线程负责处理内核的工作队列任务。 ksoftirqd 线程用于软中断的处理。 kthreadd 线程是内核线程的创建者,负责启动和管理其他内核线程。 你可以通过 top 的 -H 选项查看线程信息(包括内核线程和用户线程): top -H 这将显示每个进程的 线程,其中包括内核线程和用户线程。 3. 如何识别内核线程 在 top 命令的输出中,内核线程 和 用户进程 是区分开的,主要通过 进程的 UID 和 进程名称 来区分: 内核线程通常会显示为 root 用户(因为大多数内核线程是由 root 权限启动的),且它们的 PID 一般较小。 内核线程的名称通常以 k 开头,例如 kworker, ksoftirqd, kthreadd 等。 4. 查看内核进程 虽然 top 显示了内核线程,但你并不能通过 top 查看完整的 内核程序。因为 内核程序 本身是操作系统的一部分,它并不像用户进程那样是一个独立的程序。内核的执行是在操作系统启动时加载的,并且与用户空间的程序是分离的。操作系统内核代码总是处于内存中,并且由 CPU 在不同的时间片和事件中执行。 5. 示例输出 下面是一个 top 命令输出的例子,包含了内核线程和用户进程的区别: top - 10:21:09 up 1 day, 1:02, 2 users, load average: 0.02, 0.01, 0.00Tasks: 146 total, 1 running, 145 sleeping, 0 stopped, 0 zombie%Cpu(s): 2.0 us, 1.0 sy, 0.0 ni, 97.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 stMiB Mem : 4000.0 total, 900.0 free, 1500.0 used, 1600.0 buff/cacheMiB Swap: 512.0 total, 400.0 free, 112.0 used. 800.0 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 123 root 20 0 8200 1200 1000 S 1.0 0.0 0:01.32 kworker/0:1H 124 root 20 0 8200 1200 1000 S 0.9 0.0 0:01.30 kworker/1:2 125 root 20 0 8200 1200 1000 S 0.8 0.0 0:01.40 ksoftirqd/0 1001 user1 20 0 212304 14288 1044 S 0.5 0.4 0:00.72 firefox 1002 user2 20 0 145236 8544 2052 S 0.3 0.2 0:00.60 gnome-shell 在这个输出中,进程如 kworker/0:1H 和 ksoftirqd/0 是 内核线程,它们在内核空间执行,负责系统的底层任务。 firefox 和 gnome-shell 是 用户进程,它们在用户空间执行,并显示为其他用户的程序。 6. 内核的管理 内核本身不作为单独的进程出现在 top 命令中,因为它并不是一个“用户可见”的进程。内核代码始终在内存中并执行,但它的管理不依赖于用户进程。 内核线程 负责处理系统的底层任务,如硬件管理、任务调度、内存管理、I/O 操作等。它们是由内核启动的,并且通常在 top 中显示为 root 用户,前缀为 k。 7. 总结 top 命令显示了内核线程和用户进程,它们的区别通常在于进程名称(内核线程以 k 开头)和用户 ID(通常是 root 用户)。 内核程序 本身并不作为独立的进程出现,它始终在操作系统内核中执行。top 命令能够显示 内核线程,但并不直接显示“内核程序”这一概念。 问题7:所以内核驱动加载 只是做了注册,不是直接运行,任何驱动程序都有触发条件,否则就是没用的驱动。这个触发条件可以是用户的系统调用,可以是定时中断、硬件中断等? 1. 驱动程序的加载与注册 驱动程序加载:当一个内核驱动被加载时,操作系统会将其编译为内核模块(在 Linux 中通常是 .ko 文件)。这个模块可以在系统启动时自动加载,也可以在运行时由用户手动加载(例如通过 modprobe 或 insmod 命令)。 注册过程:加载的驱动程序需要通过注册来向内核报告它将管理某些硬件或提供某些服务。例如: 字符设备驱动 会通过 register_chrdev 函数注册一个字符设备。 块设备驱动 会通过 register_blkdev 注册块设备。 网络驱动 会通过 register_netdev 注册网络设备。 注册的过程确保内核知道驱动程序的存在,并为它分配合适的资源。 2. 驱动程序的触发条件 驱动程序并不会“持续”运行,而是会根据特定的 事件或触发条件 来执行。驱动程序的执行通常是由内核和硬件事件引起的,以下是几种常见的触发条件: 2.1 用户空间的系统调用 有些驱动程序是通过用户程序发起的 系统调用 来触发的。用户程序可以通过系统调用与内核空间的驱动程序进行交互: 例如,一个字符设备驱动可能等待用户空间程序通过 open(), read(), write() 等系统调用来访问设备。用户程序的这些操作会触发内核中对应驱动的相关函数,进而执行驱动程序的操作。 2.2 硬件中断 许多硬件设备(如网卡、磁盘、USB 设备等)需要在某些事件发生时通知内核,例如数据传输完成、设备状态变化等。硬件中断是一种常见的触发条件: 硬件中断触发:硬件设备会发出中断信号,通知 CPU 该设备需要处理。内核的中断处理程序会触发相应设备驱动程序中的 中断处理函数 来处理中断事件。 例如,网卡驱动在接收到数据包时会触发一个硬件中断,内核会调用网卡驱动中的 中断处理程序,并处理网络数据。 2.3 定时中断 内核中的某些驱动程序可能依赖于 定时中断 来执行周期性任务。这类驱动程序通常是负责一些 定时操作 的,如定时刷盘(写入数据到磁盘)或周期性地检查设备状态: 例如,某些设备的驱动程序可能需要定期轮询设备状态,这种操作会通过内核定时器触发。 2.4 内核事件和工作队列 有些驱动程序会将任务放入 内核的工作队列 中,这些任务会在适当的时候由内核线程执行。工作队列可以由多种事件触发: 内核工作队列:内核使用工作队列(如 kworker 线程)来处理异步任务。例如,磁盘驱动可以将数据写入硬盘的任务放入工作队列中,等待 kworker 线程异步执行。 事件触发:内核会基于某些条件(如 I/O 完成、设备状态变化)来触发工作队列的执行。 2.5 内存或缓冲区管理 有些驱动程序依赖于 内存管理系统,比如内存的分配或释放、缓冲区的满载等。内核的内存管理子系统会触发驱动程序的某些操作: 例如,网络设备的驱动程序会等待内存缓冲区的空闲,当数据包需要发送时,内核会分配内存,并触发驱动程序的相关操作。 3. 驱动程序的生命周期 加载:驱动程序在内核启动时或通过命令行加载。驱动的注册使得内核能够识别并管理设备。 等待事件:驱动程序在注册之后会等待某些事件的发生。这些事件可以是来自用户空间的请求、硬件中断、定时器中断等。 响应事件:当触发条件满足时,驱动程序会执行相应的操作。例如,用户通过 read() 请求数据,驱动程序会读取硬件设备的数据并返回给用户空间;或者硬件中断发生,驱动程序会执行中断处理程序。 卸载:当驱动程序不再需要时(例如设备被移除,或不再使用),它会通过卸载过程从内核中注销并释放相关资源。 4. 总结 驱动程序的加载 只是一个注册过程,它将设备与驱动程序关联,并通知内核可以处理该设备的操作。 驱动程序 并不会持续运行,而是根据 触发条件(如用户程序发起的系统调用、硬件中断、定时中断等)来执行。例如,硬件中断、定时任务、用户发起的 I/O 操作等都可以触发内核中的驱动程序执行。 触发条件 是驱动程序执行的核心,通常这些条件是由外部事件(如硬件操作或用户请求)引发的。 因此,驱动程序的作用是 响应特定的事件,并在合适的时机执行相关操作,而不是持续占用 CPU 执行。 问题8:内核调度程序加载的CPU运行的代码是汇编写的吧,内核还有哪些代码是基于汇编实现的? 是的,内核调度程序以及其他一些关键部分的代码通常会使用 汇编语言 来实现。由于汇编语言能够直接与硬件进行交互,它可以提供更高效和低级的控制,尤其在操作系统内核的某些关键部分。以下是一些内核中的关键代码,它们通常是基于汇编语言实现的: 1. 内核调度程序的汇编实现 内核调度程序负责在不同进程之间进行上下文切换,这是一项至关重要的任务,涉及到保存和恢复 CPU 的上下文(如寄存器、程序计数器等)。其中,部分调度程序的实现确实使用了汇编语言来实现高效的上下文切换,特别是在保存和恢复进程状态时。 上下文切换的实现:上下文切换通常需要直接与硬件寄存器打交道,存储当前进程的寄存器值并恢复另一个进程的寄存器值。这一过程通常用汇编语言来实现,确保对 CPU 状态的精确控制。 汇编与 C 代码结合:虽然大部分调度算法和高层逻辑使用 C 语言实现,但具体的上下文切换过程会涉及到汇编指令,因为这些操作需要访问和修改底层的 CPU 寄存器。通常,汇编代码会嵌入到 C 代码中,或者由单独的汇编文件处理。 2. 中断处理和系统调用的汇编实现 中断处理:当硬件中断发生时,内核需要快速响应并切换到适当的中断处理程序。中断处理程序需要保存当前进程的上下文,处理硬件中断,然后根据需要进行调度。中断的进入和退出通常是通过汇编语言实现的,因为它涉及到修改CPU 状态(如修改程序计数器、堆栈指针等),这些操作必须直接通过汇编指令来完成。 系统调用:系统调用是用户空间与内核空间交互的桥梁,通常通过中断触发。对于 x86 架构,系统调用通过int 0x80(对于 x86)或syscall(对于 x86_64)指令来发起。系统调用的进入和退出也会涉及一些汇编代码来切换上下文(从用户态切换到内核态),并进行参数传递。 3. 内核引导(Bootstrapping) 内核引导过程是操作系统启动时非常重要的一个步骤。通常,内核的启动过程需要做很多低级操作,包括硬件初始化、内存管理的设置等。这些操作通常需要使用汇编语言来直接与硬件交互。 引导加载程序(如 GRUB、LILO 等)负责将内核加载到内存中,并启动内核的初步执行。内核的引导部分通常会使用汇编语言来初始化 CPU、设置堆栈、关闭中断等。 在现代操作系统中,启动过程通常会使用一部分汇编语言来进行硬件初始化(如设置分页、保护模式、GDT/IDT 等),然后再切换到 C 语言编写的内核代码。 4. 内核的低级硬件操作 内核中需要直接与硬件打交道的部分,如 内存管理、I/O 操作 和 硬件抽象层,通常会用汇编语言来实现一部分关键操作,特别是在一些操作必须直接与硬件交互时。 内存管理:内存管理是内核中的一个关键部分,涉及到分页、内存分配等。在某些平台上,分页表的创建和管理需要通过汇编语言进行直接控制,因为它涉及到 CPU 特性(如 CR3 寄存器的设置)和硬件地址的管理。 I/O 操作:操作系统与硬件设备的交互(如磁盘、网络设备、显示器等)通常需要使用汇编语言来执行低级 I/O 操作。例如,通过端口访问、DMA 操作等通常依赖于汇编语言实现。 5. 内核的同步机制(原子操作) 一些内核的同步机制需要通过汇编语言来实现,尤其是在多核处理器环境下,内核需要确保操作的原子性。这些操作包括原子加法、减法、比较和交换等,它们常常需要通过 CPU 提供的特殊指令来实现,譬如lock前缀指令(lock cmpxchg)来保证原子性。 例如,自旋锁 和 信号量 等机制的实现,可能需要使用汇编语言来确保在多处理器系统中对共享资源的安全访问。 6. 汇编在内核中的应用场景总结 可以总结为以下几个关键场景: 上下文切换:实现进程间的上下文切换,保存和恢复寄存器、程序计数器等。 中断处理:处理中断时切换到内核态,保存/恢复状态。 系统调用处理:用户态程序通过系统调用进入内核态的机制,涉及到上下文切换。 引导加载程序和初始化:内核引导过程中的硬件初始化和内存设置。 直接硬件交互:直接与硬件打交道时,汇编提供更精细的控制(如内存管理、I/O 操作等)。 原子操作和同步:提供多核环境下的原子操作支持,确保数据的安全性。 7. C 语言与汇编语言的结合 在现代操作系统中,内核的主要逻辑部分(如调度算法、内存管理、设备驱动等)通常使用 C 语言实现,因为 C 语言具有更好的可读性和维护性。汇编语言主要用于一些需要直接控制硬件、优化性能或实现特殊功能的低级操作。 汇编与 C 的结合:内核中的很多汇编代码通常会被嵌入到 C 语言代码中(例如,通过内联汇编或函数调用),以便 C 代码能够调用汇编实现的功能,同时也能确保代码的跨平台兼容性。 总结: 汇编语言 在操作系统内核中的作用非常重要,尤其是在 上下文切换、硬件中断处理、系统调用、内存管理、I/O 操作、同步机制 等关键部分。 内核中的 高层逻辑(如调度算法、进程管理等)通常由 C 语言 编写,而低级硬件交互、性能优化和中断处理等关键部分常常需要使用汇编语言来实现,以确保对硬件的精确控制和高效执行。

    前天 64浏览
  • 常用的嵌入式软件调试方法与技巧

    在软件开发过程中,一般来说,花在测试比花在编码的时间要多很多,通常为3:1(甚至更多)。 这个比例随着你的编程和测试水平的提高而不断下降,但不论怎样,软件测试对一般人来讲很重要。 今天以嵌入式开发为例,给大家分享一下常见软件的调试方法有哪些? 很多年前,一位开发人员为了在对嵌入式有更深层次的理解,询问了这样的一个问题:我怎么才能知道并懂得我的系统到底在干些什么呢? 面对这个问题有些吃惊,因为在当时没有人这么问过,而同时代的嵌入式开发人员问的最多的大都围绕“我怎么才能使程序跑得更快”、“什么编译器最好”等问题。 面对这个不同寻常却异乎成熟的问题,可能很多人都不知道怎么办,下面就来讲讲软件测试找bug常见方法和秘诀。 1 懂得使用工具 通常嵌入式系统对可靠性的要求比较高。嵌入式系统安全性的失效可能会导致灾难性的后果,即使是非安全性系统,由于大批量生产也会导致严重的经济损失。 这就要求对嵌入式系统,包括嵌入式软件进行严格的测试、确认和验证。随着越来越多的领域使用软件和微处理器控制各种嵌入式设备,对日益复杂的嵌入式软件进行快速有效的测试愈加显得重要。 就像修车需要工具一样,好的程序员应该能够熟练运用各种软件工具。 不同的工具,有不同的使用范围,有不同的功能。使用这些工具,你可以看到你的系统在干些什么,它又占用什么资源,它到底和哪些外界的东西打交道。 让你郁闷好几天的问题可能通过某个工具就能轻松搞定,可惜你就是不知道。 那么为什么那么多的人总是在折腾个半死之后才想到要用测试工具呢?原因很多,主要有两个: 一个是害怕; 另一个是惰性; 害怕是因为加入测试工具或测试模块到代码需要技巧同时有可能引入新的错误,所以他们总喜欢寄希望于通过不断地修改重编译代码来消除bug,结果却无济于事。 懒惰是因为他们习惯了使用printf之类的简单测试手段。 下面来介绍一些嵌入式常用的测试工具(1)、源码级调试器????[Source-levelDebugger] 这种调试器一般提供单步或多步调试、断点设置、内存检测、变量查看等功能,是嵌入式调试最根本有效的调试方法。比如VxWorksTornadoII提供的gdb就属于这一种。 (2)、简单实用的打印显示工具???? [printf] printf或其它类似的打印显示工具估计是最灵活最简单的调试工具。 打印代码执行过程中的各种变量可以让你知道代码执行的情况。但是,printf对正常的代码执行干扰比较大(一般printf占用CPU比较长的时间),需要慎重使用,最好设置打印开关来控制打印。 (3)、ICE或JTAG调试器????[In- circuitEmulator] ICE是用来仿真CPU核心的设备,它可以在不干扰运算器的正常运行情况下,实时的检测CPU的内部工作情况。 像桌面调试软件所提供的:复杂的条件断点、先进的实时跟踪、性能分析和端口分析这些功能,它也都能提供。ICE一般都有一个比较特殊的CPU,称为外合(bond-out)CPU. 这是一种被打开了封装的CPU,并且通过特殊的连接,可以访问到CPU的内部信号,而这些信号,在CPU被封装时,是没法 “看到”的。 当和工作站上强大的调试软件联合使用时,ICE就能提供你所能找到的最全面的调试功能。 但ICE同样有一些缺点:昂贵;不能全速工作;同样,并不是所有的CPU都可以作为外合CPU的,从另一个角度说,这些外合CPU也不大可能及时的被新出的CPU所更换。 JTAG(JointTestActionGroup)虽然它最初开发出来是为了监测IC和电路连接,但是这种串行接口扩展了用途,包括对调试的支持。 (4)、ROM监视器???? [ROMMonitor] ROM监控器是一小程序,驻留在嵌入系统ROM中,通过串行的或网络的连接和运行在工作站上的调试软件通信。 这是一种便宜的方式,当然也是最低端的技术。它除了要求一个通信端口和少量的内存空间外,不需要其它任何专门的硬件。 提供了如下功能:下载代码、运行控制、断点、单步步进、以及观察、修改寄存器和内存。 因为ROM监控器是操作软件的一部分,只有当你的应用程序运行时,它才会工作。 如果你想检查CPU和应用程序的状态,你就必须停下应用程序,再次进入ROM监控器。 (5)、Data监视器???? [DataMonitor] 这种监视器在不停止CPU运行的情况下不仅可以显示指定变量内容,还可以收集并以图形形式显示各个变量的变化过程。 (6)、OS监视器???? [OperatingSystemMonitor] 操作系统监视器可以显示诸如任务切换、信号量收发、中断等事件。 一方面,这些监视器能够为你呈现事件之间的关系和时间联系;另一方面,还可以提供对信号量优先级反转、死锁和中断延时等问题的诊断。 (7)、性能分析工具???? [Profiler] 可以用来测试CPU到底耗在哪里。profiler工具可以让你知道系统的瓶颈在哪里、CPU的使用率以及需要优化的地方。 (8)、内存测试工具???? [MemoryTeseter] 可以找到内存使用的问题所在,比如内存泄露、内存碎片、内存崩溃等问题。如果发现系统出现一些不可预知的或间歇性的问题,就应该使用内存测试工具测测看。 (8)、运行跟踪器???? [ExecutionTracer] 可以显示CPU执行了哪些函数、谁在调用、参数是什么、何时调用等情况。这种工具主要用于测试代码逻辑,可以在大量的事件中发现异常。 (9)、覆盖工具[CoverageTester] 主要显示CPU具体执行了哪些代码,并让你知道那些代码分支没有被执行到哪里。这样有助于提高代码质量并消除无用代码。 (10)、GUI测试工具???? [GUITester] 很多嵌入式应用带有某种形式的图形用户界面进行交互,有些系统性能测试是根据用户输入响应时间进行的。 GUI测试工具可以作为脚本工具有开发环境中运行测试用例,其功能包括对操作的记录和回放、抓取屏幕显示供以后分析和比较、设置和管理测试过程(Rational 公司的robot和Mercury的Loadrunner工具是杰出的代表)。 很多嵌入式设备没有GUI,但常常可以对嵌入式设备进行插装来运行GUI测试脚本,虽然这种方式可能要求对被测代码进行更改,但是节省了功能测试和回归测试的时间。 (11)、自制工具???? [Home-madetester] 在嵌入式应用中,有时候为了特定的目的,需要自行编写一些工具来达到某种测试目的。 本人曾经编写的视频流录显工具在测试视频会议数据流向和变化上帮了大忙,帮公司找到了几个隐藏很深的bug。 2 尽早发现内存问题 内存问题危害很大,不容易排查,主要有三种类型:内存泄露、内存碎片和内存崩溃。 对于内存问题态度必须要明确,那就是早发现早“治疗”。在软件设计中,内存泄露的“名气”最大,主要由于不断分配的内存无法及时地被释放,久而久之,系统的内存耗尽。 即使细心的编程老手有时后也会遭遇内存泄露问题。有测试过内存泄露的朋友估计都有深刻地体验,那就是内存泄露问题一般隐藏很深,很难通过代码阅读来发现。有些内存泄露甚至可能出现在库当中。 有可能这本身是库中的bug,也有可能是因为程序员没有正确理解它们的接口说明文档造成错用。 在很多时候,大多数的内存泄露问题无法探测,但可能表现为随机的故障。程序员们往往会把这种现象怪罪于硬件问题。 如果用户对系统稳定性不是很高,那么重启系统问题也不大;但,如果用户对系统稳定很高,那么这种故障就有可能使用户对产品失去信心,同时也意味着你的项目是个失败的项目。 由于内存泄露危害巨大,现在已经有许多工具来解决这个问题。 这些工具通过查找没有引用或重复使用的代码块、垃圾内存收集、库跟踪等技术来发现内存泄露的问题。 每个工具都有利有弊,不过总的来说,用要比不用好。总之,负责的开发人员应该去测试内存泄露的问题,做到防患于未然。 内存碎片比内存泄露隐藏还要深。随着内存的不断分配并释放,大块内存不断分解为小块内存,从而形成碎片,久而久之,当需要申请大块内存是,有可能就会失败。如果系统内存够大,那么坚持的时间会长一些,但最终还是逃不出分配失败的厄运。在使用动态分配的系统中,内存碎片经常发生。 目前,解决这个问题最效的方法就是使用工具通过显示系统中内存的使用情况来发现谁是导致内存碎片的罪魁祸首,然后改进相应的部分。 由于动态内存管理的种种问题,在嵌入式应用中,很多公司干脆就禁用malloc/free的以绝后患。 内存崩溃是内存使用最严重的结果,主要原因有数组访问越界、写已经释放的内存、指针计算错误、访问堆栈地址越界等等。 这种内存崩溃造成系统故障是随机的,而且很难查找,目前提供用于排查的工具也很少。 总之,如果要使用内存管理单元的话,必须要小心,并严格遵守它们的使用规则,比如谁分配谁释放。 3 深入理解代码优化 讲到系统稳定性,人们更多地会想到实时性和速度,因为代码效率对嵌入式系统来说太重要了。 知道怎么优化代码是每个嵌入式软件开发人员必须具备的技能。就像女孩子减肥一样,起码知道她哪个地方最需要减,才能去购买减肥药或器材来减掉它。 可见,代码优化的前提是找到真正需要优化的地方,然后对症下药,优化相应部分的代码。 前面提到的profile(性能分析工具,一些功能齐全IDE都提供这种内置的工具)能够记录各种情况比如各个任务的CPU占用率、各个任务的优先级是否分配妥当、某个数据被拷贝了多少次、访问磁盘多少次、是否调用了网络收发的程序、测试代码是否已经关闭等等。 但是,profile工具在分析实时系统性能方面还是有不够的地方。 一方面,人们使用profile工具往往是在系统出现问题即CPU耗尽之后,而 profile工具本身对CPU占用较大,所以profile对这种情况很可能不起作用。 根据Heisenberg效应,任何测试手段或多或少都会改变系统运行,这个对profiler同样适用! 总之,提高运行效率的前提是你必须要知道CPU到底干了些什么干的怎么样。 4 不要让自己大海捞针 大海捞针只是对调试的一种生动比喻。经常听到组里有人对自己正在调试的代码说shit! 可以理解,因为代码不是他写的,他有足够的理由去 shitbug百出的代码,只要他自己不要写出这种代码,否则有一天同组的其它人可能同样会shit他写的代码。 为何会有大海捞针呢?肯定是有人把针掉到海里咯;那针为何会掉在海里呢?肯定是有人不小心或草率呗。 所以当你在抱怨针那么难找的时候,你是否想过是你自己草率地丢掉的。 同样,当你调试个半死的时候,你是否想过你要好好反省一下当初为了寻求捷径可能没有严格地遵守好的编码设计规范、没有检测一些假设条件或算法的正确性、没有将一些可能存在问题的代码打上记号呢? 关于如何写高质量请参考林锐的《高质量c++/c编程指南》或《关于C的0x8本“经书》。 如果你确实已经把针掉在海里是,为了防止在找到之前刺到自己,你必须要做一些防范工作,比如戴上安全手套。 同样,为了尽能地暴露和捕捉问题根源,我们可以设计比较全面的错误跟踪代码。怎么来做呢? 尽可能对每个函数调用失败作出处理,尽可能检测每个参数输入输出的有效性,包括指针以及检测是否过多或过少地调用某个过程。错误跟踪能够让你知道你大概把针掉在哪个位置。 5 重现并隔离问题 如果你不是把针掉在大海了,而是掉在草堆里,那要好办些。因为至少我们可以把草堆分成很多块,一块一块的找。 对于模块独立的大型项目,使用隔离方法往往是对付那些隐藏极深bug的最后方法。 如果问题的出现是间歇性的,我们有必要设法去重现它并记录使其重现的整个过程以备在下一次可以利用这些条件去重现问题。 如果你确信可以使用记录的那些条件去重现问题,那么我们就可以着手去隔离问题。怎么隔离呢? 我们可以用#ifdef把一些可能和问题无关的代码关闭,把系统最小化到仍能够重现问题的地步。 如果还是无法定位问题所在,那么有必要打开“工具箱”了。可以试着用ICE或数据监视器去查看某个可疑变量的变化;可以使用跟踪工具获得函数调用的情况包括参数的传递;检查内存是否崩溃以及堆栈溢出的问题。 6 以退为进 猎人为了不使自己在森林里迷路,他常常会在树木上流下一些标记,以备自己将来有一天迷路时可以根据这些标记找到出路。对过去代码的修改进行跟踪记录对将来出现问题之后的调试很有帮助。 假如有一天,你最近一次修改的程序跑了很久之后忽然死掉了,那么你这时的第一反映就是我到底改动了些什么呢,因为上次修改之前是好的。 那么如何检测这次相对于上次的修改呢?没错,代码控制系统SCS或称版本控制系统 VCS可以很好地解决这个问题。 将上个版本checkin下来后和当前测试版本比较。比较的工 具可以是SCS/VCS/CVS自带的diff工具或其它功能更强的比较工具,比如BeyondCompare和 ExamDiff。通过比较,记录所有改动的代码,分析所有可能导致问题的可疑代码。 7 确定测试的完整性 你怎么知道你的测试有多全面呢?覆盖测试(coveragetesting)可以回答这个问题。覆盖测试工具可以告诉你CPU到底执行了哪些代码。 好的覆盖工具通常可以告诉你大概20%到40% 代码没有问题,而其余的可能存在bug.覆盖工具有不同的测试级别,用户可以根据自己的需要选择某个级别。 即使你很确信你的单元测试已经很全面并且没有 deadcode,覆盖工具还是可以为你指出一些潜在的问题。 看下面的代码: if(i>=0&& (almostAlwaysZero==0||(last=i))) 如果almostAlwaysZero为非0,那么last=i赋值语句就被跳过,这可能不是你所期望的。 这种问题通过覆盖工具的条件测试功能可以轻松得被发现。总之,覆盖测试对于提高代码质量很有帮助。 8 提高代码质量意味着节省时间 有研究表明软件开发的时间超过80%被用在下面几个方面:调试自己的代码(单元测试)。 调试自己和其他相关的代码(模块间测试)。调试整个系统(系统测试),更糟糕的是你可能需要花费10-200倍的时间来找一个 bug,而这个bug在开始的时候可能很容易就能找到。 一个小bug可能让你付出巨大的代价,即使这个bug对整个系统的性能没有太大的影响,但很可能会影响让那些你可以看得到的部分。 所以我们必须要养成良好的编码和测试手段以求更高的代码质量,以便缩短调试的代码。 9 发现它、分析它、解决它 这世界没有万能的膏药。profile再强大也有力不从心的时候;内存监视器再好,也有无法发现的时候;覆盖工具再好用,也有不能覆盖的地方。 一些隐藏很深的问题即使用尽所有工具也有可能无法查到其根源,这时我们能做的就是通过这些问题所表现出来的外在现象或一些数据输出来发现其中的规律或异常。 一旦发现任何异常,一定要深入地理解并回溯其根源,直到解决为止。 10 请利用初学者思维 有人这样说过:“有些事情在初学者的脑子里可能有各种各样的情况,可在专家的头脑里可能就很单一”。 有时候,有些简单的问题会被想得很复杂,有些简单的系统被设计得很复杂,就是由于你的“专家思维”。 当你被问题难住时,关掉电脑,出去走走,把你的问题和你的朋友甚至你的小狗说说,或许他们可以给你意想不到的启发。 11 总结 嵌入式调试也是一门艺术。就想其它的艺术一样,如果你想取得成功,你必须具备智慧、经验并懂得使用工具。

    前天 54浏览
  • 详解异步io之io_uring技术

    一、io_uring 概述 io_uring 是 Linux 内核中的一种高效异步 I/O 框架,于 Linux 5.1 版本引入,旨在提高大规模并发 I/O 操作的性能。与传统的异步 I/O 接口(如 epoll、select、poll)相比,io_uring 提供了更低的延迟和更高的吞吐量。 二、核心概念 1.提交队列(Submission Queue, SQ): 用户空间应用程序将 I/O 请求添加到提交队列中。每个请求都会被描述为一个提交队列条目(Submission Queue Entry, SQE),包含操作类型、目标文件描述符、缓冲区等信息。 2.完成队列(Completion Queue, CQ): 当 I/O 操作完成时,内核会将结果添加到完成队列中。每个结果都是一个完成队列条目(Completion Queue Entry, CQE),其中包含了操作的返回值、状态码以及用户自定义的数据。 3.异步操作: io_uring 允许用户将 I/O 操作提交给内核,内核在后台异步处理这些操作。用户不需要等待操作完成,而是可以在稍后查询完成队列以获取操作结果。 三、主要系统调用 1. io_uring_setup 功能: io_uring_setup 是用于创建和初始化一个 io_uring 实例的系统调用。它分配和配置提交队列(SQ)和完成队列(CQ),并返回一个用于标识 io_uring 实例的文件描述符。 依赖函数: io_uring_queue_init:io_uring_queue_init 是最常用的初始化函数,内部调用 io_uring_setup 来创建一个 io_uring 实例。 io_uring_queue_init_params:io_uring_queue_init_params 是一个增强的初始化函数,它允许用户传递 io_uring_params结构体以配置额外的参数。它也依赖于io_uring_setup系统调用来创建和初始化io_uring实例。 2. io_uring_enter 功能: io_uring_enter 是用于将已准备好的 I/O 操作提交给内核并处理这些操作的系统调用。它可以用于提交操作、等待操作完成,或者两者兼而有之。 依赖函数: io_uring_submit:io_uring_submit 是用户提交操作到内核的函数,它在内部调用 io_uring_enter,将所有在提交队列中的 I/O 请求提交给内核。 io_uring_submit_and_wait:io_uring_submit_and_wait 提交 I/O 操作后,还可以等待至少一个操作完成,它也是通过调用 io_uring_enter 来实现这一功能。 io_uring_wait_cqe和io_uring_wait_cqe_nr:这些函数用于等待一个或多个操作完成,它们在内部也依赖于 io_uring_enter,通过传递适当的参数来等待完成队列中的事件。 3. io_uring_register 功能: io_uring_register 是用于将文件描述符、缓冲区或其他资源预先注册到 io_uring 实例中的系统调用。这可以提高操作的效率,因为内核在处理这些操作时可以直接访问预先注册的资源,而无需每次都重新设置。 依赖函数: io_uring_register_buffers:这个函数用于注册一组内存缓冲区,使它们可以在后续的 I/O 操作中重复使用。它在内部调用 io_uring_register 系统调用。 io_uring_unregister_buffers:这个函数用于取消之前注册的缓冲区,它也依赖于 io_uring_register 系统调用来取消注册。 io_uring_register_files和io_uring_unregister_files:这些函数分别用于注册和取消注册文件描述符集合,均依赖于 io_uring_register 系统调用。 io_uring_register_eventfd和io_uring_unregister_eventfd:这些函数用于注册和取消注册一个 eventfd,用来通知完成事件,同样依赖于 io_uring_register 系统调用。 四、常用操作 io_uring_prep_* 系列函数: 用于准备 I/O 操作,如 io_uring_prep_read、io_uring_prep_write、io_uring_prep_accept、io_uring_prep_send 等。这些函数将操作的细节填写到提交队列条目(SQE)中。 io_uring_submit: 将准备好的 SQE 提交给内核,触发内核执行操作,内部依赖 io_uring_enter 系统调用。 io_uring_wait_cqe与io_uring_peek_batch_cqe: io_uring_wait_cqe:阻塞等待至少一个操作完成,并返回完成的 CQE。 io_uring_peek_batch_cqe:非阻塞地检查完成队列,获取已经完成的操作。 五、优势 减少系统调用开销:通过批量提交和批量获取结果,减少了系统调用的次数,降低了上下文切换的开销。 高效的异步操作:内核异步处理 I/O 操作,用户空间无需阻塞等待,可以在处理其他任务的同时等待操作完成。 灵活的事件模型:io_uring 支持多种 I/O 操作,并可以在不同的操作之间灵活切换,适用于网络 I/O、文件 I/O、内存映射等多种场景。 扩展性强:io_uring 支持大量并发的 I/O 操作,适合需要处理高并发连接的应用程序,如高性能服务器和数据库。 六、代码实践 完整代码: #include #include #include #include #include #define EVENT_ACCEPT 0#define EVENT_READ 1#define EVENT_WRITE 2 struct conn_info{ int fd; int event;}; int init_server(unsigned short port){ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serveraddr; memset(&serveraddr, 0, sizeof(struct sockaddr_in)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons(port); if (-1 == bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr))) { perror("bind"); return -1; } listen(sockfd, 10); return sockfd;} #define ENTRIES_LENGTH 1024#define BUFFER_LENGTH 1024 int set_event_recv(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags){ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); struct conn_info accept_info = { .fd = sockfd, .event = EVENT_READ, }; io_uring_prep_recv(sqe, sockfd, buf, len, flags); memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int set_event_send(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags){ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); struct conn_info accept_info = { .fd = sockfd, .event = EVENT_WRITE, }; io_uring_prep_send(sqe, sockfd, buf, len, flags); memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags){ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); struct conn_info accept_info = { .fd = sockfd, .event = EVENT_ACCEPT, }; io_uring_prep_accept(sqe, sockfd, (struct sockaddr *)addr, addrlen, flags); memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));} int main(int argc, char *argv[]){ unsigned short port = 9999; int sockfd = init_server(port); struct io_uring_params params; memset(¶ms, 0, sizeof(params)); struct io_uring ring; io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms); #if 0 struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); accept(sockfd, (struct sockaddr*)&clientaddr, &len);#else struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); #endif char buffer[BUFFER_LENGTH] = {0}; while (1) { io_uring_submit(&ring); struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); struct io_uring_cqe *cqes[128]; int nready = io_uring_peek_batch_cqe(&ring, cqes, 128); // epoll_wait int i = 0; for (i = 0; i < nready; i++) { struct io_uring_cqe *entries = cqes[i]; struct conn_info result; memcpy(&result, &entries->user_data, sizeof(struct conn_info)); if (result.event == EVENT_ACCEPT) { set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); // printf("set_event_accept\n"); // int connfd = entries->res; set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0); } else if (result.event == EVENT_READ) { // int ret = entries->res; // printf("set_event_recv ret: %d, %s\n", ret, buffer); // if (ret == 0) { close(result.fd); } else if (ret > 0) { set_event_send(&ring, result.fd, buffer, ret, 0); } } else if (result.event == EVENT_WRITE) { // int ret = entries->res; // printf("set_event_send ret: %d, %s\n", ret, buffer); set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0); } } io_uring_cq_advance(&ring, nready); }} 1. 服务器初始化 int init_server(unsigned short port){ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serveraddr; memset(&serveraddr, 0, sizeof(struct sockaddr_in)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons(port); if (-1 == bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr))) { perror("bind"); return -1; } listen(sockfd, 10); return sockfd;} 解释: 该函数初始化了一个 TCP 服务器套接字,用于监听客户端连接请求。 socket、bind 和 listen 是常规的服务器初始化步骤,将服务器绑定到指定的端口,并使其开始监听客户端连接。 2. io_uring 环境初始化 struct io_uring_params params;memset(&params, 0, sizeof(params)); struct io_uring ring;io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params); 解释: io_uring_queue_init_params 函数初始化了一个 io_uring 实例,这个实例将用于管理所有的异步 I/O 操作。 ENTRIES_LENGTH 定义了提交队列和完成队列的大小,表示可以同时处理的最大 I/O 操作数量。 3. 设置 accept 事件 struct sockaddr_in clientaddr;socklen_t len = sizeof(clientaddr);set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); 解释: set_event_accept 函数将一个 accept 操作添加到 io_uring 的提交队列中。这个操作用于接受客户端连接请求。 这一步是服务器启动时的初始操作,它告诉 io_uring 开始监听并处理客户端连接。 4. 主循环:提交操作和处理完成事件 while (1){ io_uring_submit(&ring); struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); struct io_uring_cqe *cqes[128]; int nready = io_uring_peek_batch_cqe(&ring, cqes, 128); 解释: io_uring_submit:将之前添加到提交队列中的所有操作提交给内核,由内核异步执行这些操作。 io_uring_wait_cqe:等待至少一个操作完成,这是一个阻塞调用。 io_uring_peek_batch_cqe:批量获取已经完成的操作结果,nready 表示完成的操作数量。 5. 处理完成的事件 for (i = 0; i < nready; i++){ struct io_uring_cqe *entries = cqes[i]; struct conn_info result; memcpy(&result, &entries->user_data, sizeof(struct conn_info)); if (result.event == EVENT_ACCEPT) { set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0); int connfd = entries->res; set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0); } else if (result.event == EVENT_READ) { int ret = entries->res; if (ret == 0) { close(result.fd); } else if (ret > 0) { set_event_send(&ring, result.fd, buffer, ret, 0); } } else if (result.event == EVENT_WRITE) { int ret = entries->res; set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0); }} 解释: EVENT_ACCEPT:处理 accept 事件。当一个新的客户端连接到来时,io_uring 完成队列会返回 EVENT_ACCEPT 事件,表示一个新的连接已经建立。此时,服务器会: 重新设置 accept 事件,继续监听新的客户端连接。 获取新连接的文件描述符 connfd,并设置一个 recv 事件来准备接收数据。 EVENT_READ:处理 recv 事件。当从客户端接收到数据时,io_uring 返回 EVENT_READ 事件。如果接收到的数据长度大于0,则会设置一个 send 事件来将数据发送回客户端。如果 ret == 0,说明客户端关闭了连接,则关闭文件描述符。 EVENT_WRITE:处理 send 事件。当数据成功发送给客户端后,io_uring 返回 EVENT_WRITE 事件。此时,服务器会再次设置一个 recv 事件,准备接收更多数据。 6. 完成队列的推进 io_uring_cq_advance(&ring, nready); 解释: 这个函数通知 io_uring,你已经处理完了 nready 个完成队列条目(CQE)。io_uring 可以释放这些 CQE 供后续操作使用。 7. 总结 io_uring 的作用:在这个示例中,io_uring 被用来高效地处理网络 I/O 操作。通过异步提交和处理 accept、recv、send 操作,服务器能够高效处理多个并发连接,而无需阻塞等待每个I/O操作完成。 异步模型:io_uring 提供了一种低延迟、高并发的异步 I/O 处理方式。操作在提交后由内核异步执行,完成后再由应用程序查询并处理结果。这种方式大大减少了系统调用的开销,提高了程序的并发处理能力。 关键点: 提交操作:使用 io_uring_prep_* 函数准备操作,并提交给内核处理。 等待完成:使用 io_uring_wait_cqe 等方法等待操作完成,并获取结果。 处理结果:根据完成队列中的事件类型(如 EVENT_ACCEPT、EVENT_READ、EVENT_WRITE)进行相应的处理和后续操作。 七、问题与思考 1.提交队列肯定需要多线程操作,需要枷锁? 对于io_uring的提交队列来说,在多线程操作下不需要加锁。io_uring使用了无锁环形队列,通过原子操作来管理队列的头尾指针,确保多个线程可以安全且高效地并发提交I/O请求。这种设计避免了传统锁的开销,尤其适合高并发场景下的性能优化。 无锁环形队列的原理 1.环形缓冲区: 环形队列是一种常用的数据结构,其中队列的头和尾指针在到达缓冲区末尾时回绕到开始位置,从而形成一个“环”。 在io_uring 中,提交队列和完成队列实际上是一个环形缓冲区,允许多个线程高效地提交和消费I/O操作。 2.原子操作: 无锁设计的关键在于使用原子操作来管理队列指针,确保即使在多线程环境下,多个线程同时访问队列时也不会产生竞争条件。 io_uring 使用原子性操作(例如 cmpxchg 或 fetch_add)来更新队列的头尾指针,从而避免了锁的使用。 3.多生产者/多消费者支持: io_uring 的设计支持多生产者(即多个线程提交I/O请求)和多消费者(即多个线程处理I/O完成事件)。在这种模式下,通过原子操作,多个线程可以无锁地同时对提交队列或完成队列进行操作。 2.io_uring如何避免频繁的拷贝的? io_uring 的实现旨在通过减少用户空间和内核空间之间的数据复制来提高 I/O 操作的性能。虽然io_uring并不是直接依赖于mmap来避免拷贝,但它使用了多种技术和机制来减少不必要的数据复制和系统资源消耗。 io_uring 的关键机制 1.提交队列和完成队列(SQ 和 CQ): io_uring 使用了两个环形队列:提交队列(Submission Queue, SQ)和完成队列(Completion Queue, CQ)。用户空间通过 SQ 提交 I/O 请求,而内核通过 CQ 返回操作的完成状态。这两个队列都可以通过 mmap 映射到用户空间,允许用户空间直接操作这些队列,减少了系统调用的频率。 2.直接提交和批量处理: 用户空间可以将多个 I/O 请求直接写入 SQ,然后通过一个系统调用将它们提交给内核。内核可以批量处理这些请求,并将结果写入 CQ。这减少了频繁的系统调用和数据拷贝。 3.注册固定缓冲区和文件描述符: io_uring 允许用户提前注册缓冲区和文件描述符,这些缓冲区和文件描述符在后续的 I/O 操作中可以被重复使用。由于这些资源已经预先注册并映射到内核,因此在实际的 I/O 操作中不需要再次传递和复制这些资源。 4.直接 I/O 支持: io_uring 可以与直接 I/O(Direct I/O)结合使用,使得数据可以直接从用户空间传输到存储设备或网络设备,或者从设备直接读取到用户空间,绕过内核缓冲区。这进一步减少了内核空间和用户空间之间的数据拷贝。 5.零拷贝发送和接收: 在网络传输中,io_uring 支持零拷贝发送和接收,特别是在高性能网络应用中,这意味着数据可以直接从用户空间传输到网络栈,而不需要在内核缓冲区和用户缓冲区之间进行拷贝。 实现细节 内存映射(mmap):SQ 和 CQ 通常会通过 mmap 映射到用户空间,这样用户空间可以直接访问这些队列的数据结构。这不仅减少了系统调用的开销,也避免了在用户空间和内核空间之间的数据复制。 原子操作和无锁队列:在多线程环境下,io_uring 使用原子操作来管理队列的头和尾指针,从而避免了锁的使用,进一步提高了性能。 批量提交与处理:io_uring 支持批量提交 I/O 请求,允许多个请求在一次系统调用中被提交到内核。这种机制减少了系统调用的次数,并且通过批量处理可以减少内核空间与用户空间之间的数据交换。 总结 io_uring 通过设计高效的提交和完成队列、支持直接 I/O 和零拷贝技术,以及允许注册和重用缓冲区来避免频繁的数据复制。这种设计使得 io_uring 在处理大规模异步 I/O 操作时,能够提供极高的性能和低延迟。 3.当服务器通过 listen 函数开始监听端口时,程序会阻塞在哪里? 阻塞点的分析 在程序运行后,当服务器通过 listen 函数开始监听端口时,它并不会立即处理任何连接请求,而是等待客户端连接的到来。在这期间,程序的执行流会阻塞在某些地方,具体来说,它阻塞在两个可能的地方: 1.io_uring_wait_cqe(&ring, &cqe);: 这个调用是 io_uring 的一个重要部分,它用于等待完成队列(CQE)中至少有一个事件完成。在没有事件发生的情况下,比如没有客户端发起连接请求时,这个函数会阻塞,直到有新的事件到达为止。因此,如果没有客户端连接请求,程序会阻塞在这里。 2.io_uring_peek_batch_cqe(&ring, cqes, 128);: 这个函数用于检查 io_uring 完成队列中是否有已完成的事件。虽然它本身并不阻塞,但在 io_uring_wait_cqe 解除了阻塞之后,这个函数会获取已经完成的事件列表并处理它们。 重点总结 阻塞点:程序在没有客户端连接请求时,会阻塞在 io_uring_wait_cqe 函数上,等待 io_uring 完成队列中的事件。 异步处理:一旦有事件完成(例如客户端连接到来),程序会解除阻塞并处理该事件,接着继续等待下一个事件的完成。 4.io_Uring和epoll有什么相同点和区别 相同点 1.高并发I/O处理:两者都旨在高效处理大量并发I/O操作,特别是在网络服务器等需要处理众多客户端连接的场景中。 2.事件驱动模型:两者都采用事件驱动的模型,程序通过等待I/O事件发生然后进行相应处理,避免了轮询的低效性。 3.减少阻塞:epoll 和 io_uring 都通过异步或非阻塞的方式减少了I/O操作中的等待时间,提高了应用程序的响应速度和整体性能。 区别 1.设计与用途: epoll: epoll 是基于 poll 和 select 的改进版,专门用于监控多个文件描述符(socket、文件等)的事件(如读、写、异常等)。 它本身并不执行I/O操作,而是等待并通知I/O事件的发生。 epoll 适合事件驱动的网络编程,例如监视多个客户端连接的服务器。 io_uring: io_uring 是一个更广泛的异步I/O框架,它不仅仅用于事件通知,还直接执行I/O操作。 支持文件读写、网络I/O等操作,并且设计上避免了频繁的上下文切换和数据复制。 io_uring 适合需要处理大量I/O操作的高性能应用,例如高吞吐量的服务器、数据库系统等。 2.系统调用的数量与性能: epoll: 使用时需要多次系统调用。例如,你需要用 epoll_ctl 注册或修改事件,再用 epoll_wait 等待事件发生。 每次等待事件都需要从用户空间切换到内核空间,尽管 epoll 的性能比 select 和 poll 高,但频繁的系统调用仍然是一个瓶颈。 io_uring: 通过提交和完成队列(SQ和CQ)机制,大大减少了系统调用的数量。你可以批量提交多个I/O操作,然后一次性等待它们的完成。 io_uring 利用共享内存区域在用户空间和内核空间之间传递I/O请求和结果,减少了上下文切换和系统调用开销,性能优势明显。 3.I/O操作类型的支持: epoll: 主要用于监听和处理文件描述符上的事件,不直接执行I/O操作。 你可以监控 EPOLLIN、EPOLLOUT 等事件,但具体的I/O操作仍需由用户代码完成。 io_uring: 不仅可以处理事件通知,还可以直接执行I/O操作(如读写文件、网络I/O)。 支持零拷贝传输、固定缓冲区等高级功能,适合需要高效I/O处理的复杂场景。 4.阻塞与非阻塞: epoll: epoll_wait 可以设置为阻塞或非阻塞模式,通常情况下会阻塞直到有事件发生。 io_uring: io_uring 支持完全异步的操作,通过提交和完成队列的机制实现了非阻塞I/O。 可以同时处理多个I/O操作并等待它们的完成,无需像 epoll 那样分别等待每个事件的发生。 开发复杂度: epoll: 相对来说更简单,只需关注文件描述符的事件注册和处理。 io_uring: 功能更强大,支持更多操作类型,但开发复杂度较高。需要管理提交队列和完成队列,以及处理可能的错误和资源管理。

    前天 42浏览
  • C语言函数的返回值的潜规则

    基本上,没有人会将大段的C语言代码全部塞入 main() 函数。更好的做法是按照复用率高、耦合性低的原则,尽可能的将代码拆分不同的功能模块,并封装成函数。C语言代码的组合千变万化,因此函数的功能可能会比较复杂,不同的输入,常常产生不同的输出结果。 C语言函数的返回值 C语言函数可以通过返回值表示输出结果,例如 log() 函数的返回值会根据不同的输入,返回不同的值。再比如,我们定义一个函数 myopen(),用于打开某个文件,那么,这个函数要么能够成功打开文件,要么打开文件失败,这时,可以通过返回值区分“成功”和“失败”。当然,myopen() 函数失败的原因可能很多,但不是本文关注的重点,本文更关心的是,该以何值表示“成功”,何值表示“失败”。按照C语言语法, 0 表示假,非零(常常用 1)表示真,那是否函数也用 0 返回值表示“失败”,1 返回值表示“成功”呢? 行业“潜规则” C语言函数当然可以使用返回值 0 表示“失败”,用返回值 1 表示“成功”。事实上,C语言函数用什么样的返回值表示成功或者失败,只是一种人为的约定,函数的调用者遵守这个“约定”就可以了。C语言也有“行业潜规则”,不过,对于一般的函数,大多数C语言程序员常常使用返回值 0 表示成功,非零值表示失败。因此,如果希望我们定义的函数能够被大多数人愉快的使用,最好不要反其道而行,遵守“行业潜规则”更好一点。仔细考虑下,其实C语言函数使用返回值 0 表示成功是有原因的。更一般的C语言函数返回值并不一定只有两种可能值(成功/失败),它可能还会返回对应错误原因的返回值。总之,函数成功只有一种可能,函数失败却有多种可能。实数要么是 0,要么非 0,因此可以将 0 看作一个比较特殊的“唯一”数值,使用 0 这个“唯一”的返回值,表示唯一的“成功”,多种非零的返回值,表示多种原因的失败,无疑更好一些。当然,我们也可以说“实数要么是 1,要么非 1”,不过这显然也不是“行业潜规则”。例如我们可以规定,如果 myopen() 函数因为“文件或者目录不存在”的原因失败,返回 -1,如果因为“权限不够”的原因失败,则返回 -2。-1 和 -2 都是“非零值”,而成功作为失败的对立面,也即“非零值”的对立面,myopen() 函数使用返回值 0 表示成功无可厚非。 另一种行业“潜规则” 当然,C语言程序员中还有一种“行业潜规则”。如果定义的函数是个布尔函数,也即返回值显式的使用类似于 bool 关键字定义,或者函数名类似于 is_true(),那么显然此时应该遵守C语言语法,使用“真”值表示成功,“假”值表示失败。 if( is_true() ) printf("true\n"); 请看上面这两行C语言代码,显然,遵守C语言语法的布尔函数更便于程序员写出布尔判断类的代码。 小结 本文主要讨论了C语言程序开发中关于函数返回值的问题。可见,使用什么样的返回值表示成功,什么用的返回值表示失败,其实只是一种人为约定。只不过,如果希望我们编写的代码能够被大多数同行愉快的使用,最好遵守下“行业潜规则”。当然了,若是希望我们的C语言代码应用性更广,则可以使用标准头文件里预先定义好的 EXIT_SUCCESS 和 EXIT_FAILURE 宏。

    前天 29浏览
  • C语言中的“悬空指针”和“野指针”是什么意思?

    提起C语言大部分开发者很自然就会想到指针二字,没错,作为C的核心和灵魂,它的地位咱们就不再赘述了,今天我们想

    2024-12-19 38浏览
正在努力加载更多...
EE直播间
更多
广告