You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

固定大小环形数组索引合法仍触发EXC_BAD_ACCESS崩溃排查

环形缓冲区固定数组添加越界防护后仍运行崩溃问题

问题现象

  • 基于固定大小数组实现环形缓冲区循环更新逻辑,即使添加了索引越界防护,仍然触发运行时崩溃
  • 初始测试数组大小为1000时可稳定复现崩溃,500及以下未触发;后续测试推翻该结论,数组大小为500时也会出现崩溃
  • 初步怀疑崩溃由编译器优化导致,寻求对应排查、解决思路

核心业务代码

缓冲区定义实现

struct ZeroCrossing {
  // 第二个采样点的整数索引(过零点发生在两个采样点之间)
  var index: UInt
  // 当前过零点和上一个过零点之间的最高振幅峰值(可正可负)
  let previousPeak: Float
  // 两个采样点索引之间的插值过零点位置
  let indexWithOffset: Double
}

class CrossingBuffer {
  private var array: [ZeroCrossing]
  private let size: Int
  private var nextWriteIndex = 0
  private var full: Bool {
    nextWriteIndex >= size
  }

  init(size: Int) {
    self.size = size
    array = [ZeroCrossing](repeating: ZeroCrossing(index: 0, previousPeak: 0, indexWithOffset: 0), count: size)
    array.reserveCapacity(size)
  }
  
  public func write(_ val: ZeroCrossing) {
    array[nextWriteIndex % size] = val
    nextWriteIndex += 1
  }
    
  public func getAfterIndex(_ refIndex: Double) -> [ZeroCrossing]? {
    if !full { return nil }
    var subArray = [ZeroCrossing]()
    let lastElementIndex = nextWriteIndex - 1
    for i in 0...size - 1 {

      //   崩溃发生在该行
      let thisCrossing = array[(lastElementIndex - i) % size]

      if thisCrossing.indexWithOffset > refIndex {
        subArray.append(thisCrossing)
      } else {
        break
      }
    }
    return subArray.reversed()
  }
  
  public func reset() {
    array = [ZeroCrossing](repeating: ZeroCrossing(index: 0, previousPeak: 0, indexWithOffset: 0), count: size)
    nextWriteIndex = 0
  }
}

崩溃回溯信息

* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x1016dc008)
    frame #0: 0x000000018bf5c090 libswiftCore.dylib`swift_retain + 60
    frame #1: 0x000000018bf9c704 libswiftCore.dylib`swift_bridgeObjectRetain + 56
  * frame #2: 0x0000000100838e50 CrossingBuffer.getAfterIndex(refIndex=431975.76999999583, self=0x00000002800a86f0) at CrossingBuffer.swift:97:31
    frame #3: 0x00000001007cd704 correlate(self=0x000000010130e5c0) at PitchEngine.swift:146:52
    frame #4: 0x00000001007a155c correlate(self=0x0000000283b91200) at TunerEngine.swift:57:39
    frame #5: 0x00000001007a1bd8 @objc correlate() at <compiler-generated>:0

调试异常表现

LLDB调试控制台中,使用崩溃行完全相同的索引表达式可正常访问对应元素,调试输出如下:

(lldb) print (lastElementIndex - i) % size
(Int) $R4 = 838
(lldb) print array.count
(Int) $R5 = 1000
(lldb) print array[(lastElementIndex - i) % size]
(ZeroCrossing) $R6 = (index = 438691, previousPeak = 0.0251232013, indexWithOffset = 438690.12000000477)

复现关联代码

测试发现仅当数组大小大于638时会触发崩溃,且需要麦克风拾取到噪声时才会复现:

import Foundation
import AVKit

final class AudioTap {
  
  private var audioEngine = AVAudioEngine()
  private var windowIndex: UInt = 0
  private var lastPeak: Float = 0
  private var lastSample: Sample?
  public var minPeakSize: Float = 0.005
  
  // 调整crossingBuffer的大小会改变崩溃触发行为
  private var crossingBuffer = CrossingBuffer(size: 2000)
  
  private var lastSeekIndex: Double = 0.0
  
  private var timer: Timer?
  
  init() {
    installTunerTap()
    audioEngine.prepare()
    do {
      try audioEngine.start()
    } catch let error as NSError {
      print("AVAudioEngine启动错误: \(error.domain), \(error)")
    }
    timer = Timer.scheduledTimer(
      timeInterval: 0.01,
      target: self,
      selector: #selector(getNext),
      userInfo: nil,
      repeats: true)
  }
  
  private func installTunerTap() {
    let inputNode = audioEngine.inputNode
    inputNode.installTap( onBus: 0,
                          bufferSize: 1000,
                          format: nil,
                          block: { buffer, when in
      let sampleCount = Int(buffer.frameLength)
      var sampleIndex = 0
      
      while (sampleIndex < sampleCount) {
        if let val = buffer.floatChannelData?.pointee[sampleIndex]{
          let sample = Sample(index: self.windowIndex, val: val)
          self.update(sample: sample)
        }
        self.windowIndex += 1
        sampleIndex += 1
      }
    })
  }
  
  private func update(sample: Sample) {
    lastPeak = abs(sample.val) > abs(lastPeak) ? sample.val : lastPeak
    if let last = lastSample {
      if last.val * sample.val < 0 && abs(lastPeak) > minPeakSize { // 检测到过零点
        let offset = Double(sample.index) + Double(round((sample.val/(last.val - sample.val)) * 100) / 100)
        
        let crossing = ZeroCrossing(
          index: sample.index,
          previousPeak: lastPeak,
          indexWithOffset: offset
        )
        crossingBuffer.write(crossing)
        lastPeak = 0
      }
    }
    lastSample = sample
  }
  
  @objc func getNext() {
    if let arr = crossingBuffer.getAfterIndex(lastSeekIndex) {
      if let s = arr.last {
        lastSeekIndex = s.indexWithOffset
      }
    }
  }
}

问题根因与解决方案

根因

该崩溃和编译器优化无关,是多线程并发读写无保护导致的数据竞争、内存损坏:

  • write(_:)方法运行在AVAudioEngine的音频实时渲染线程,getAfterIndex(_:)运行在主线程Timer回调中,两个线程并发读写同一个array存储属性和nextWriteIndex变量,没有任何同步保护
  • Swift的Array虽然是值类型,但作为类的存储属性时会被多个线程共享引用,并发读写会破坏数组内部的内存结构,崩溃点出现在swift_retain是典型的对象引用计数被并发写坏后的表现
  • LLDB中可以正常访问是因为程序暂停在崩溃点时,并发写入已经停止,不存在竞态;数组大小阈值、需要麦克风输入才触发崩溃的特征,完全符合竞态条件的概率性触发规律
  • 索引计算逻辑本身不存在越界问题:缓冲区写满后lastElementIndex >= size - 1,i取值范围为0...size-1,(lastElementIndex - i) % size的计算结果始终在0..<size合法区间内

解决方案

  • 快速验证:临时将所有CrossingBuffer的方法调用都派发到主线程执行,如果崩溃消失即可确认是数据竞争问题
  • 常规实现:创建独立的串行DispatchQueue,所有对缓冲区的读写操作都派发到该队列执行,避免并发访问。注意音频实时线程不能使用会阻塞的同步派发,写入操作建议用异步派发避免阻塞音频渲染
  • 高性能场景:针对音频实时处理需求,实现基于原子变量的单生产者单消费者无锁环形缓冲区,避免线程同步带来的延迟开销
  • 冗余代码清理:初始化方法中array.reserveCapacity(size)是多余操作,数组以count:size初始化时已经分配了足够容量,不需要重复调用

内容的提问来源于stack exchange,提问作者simonas

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 17:48:18