연결 인터벌을 조정하고 Rust의 비동기 쓰기 경로를 추적해 Bluetooth LE 처리량을 최대화하는 방법을 살펴본다.
URL: https://medium.com/rustaceans/async-rust-bluetooth-plumbing-where-the-throughput-goes-d2cf21430a90
20분 읽기
2025년 12월 23일
Bluetooth LE 처리량을 극대화하려면 연결 인터벌(connection interval)을 튜닝하고 Rust의 async 쓰기 경로에서 무엇이 일어나는지 조사하라.
Press enter or click to view image in full size

artist fiolisaviy의 이미지
이 시리즈의 목표는 단순하다. Bluetooth LE(LE)가 데이터를 더 빠르게 옮기도록 만든 다음, 그 속도로 재미있는 일을 해보는 것 — 즉, LE로 웹페이지를 제공하는 것이다.
이 글은 Rust로 Bluetooth LE에서 디바이스 데이터보다 더 많이 보내기를 확장하며, 서로 대부분 독립적인 세 부분으로 구성되어 있다¹:
tokio::io::AsyncWriteExt::write_all()을 사용하고, 이것이 Linux의 BlueZ L2CAP 소켓에서 MTU 크기 쓰기를 반복하는 형태로 바뀌는 과정을 살펴본다.l2cat을 통해 파이핑하고 socat으로 TCP에 붙인 올드스쿨 “웹서버”.전체 실험에는 Raspberry Pi 4 두 대를 사용했다. 역할은 순전히 편의상 아래와 Table 1처럼 정했다:
이 Peripheral/Central 분리는 중요하다. LE 연결 파라미터(연결 인터벌 등)는 링크 설정 과정에서 협상되며, 광고자/“연결 시작자” 역할이 협상 방식에 영향을 주기 때문이다.
Press enter or click to view image in full size

Table 1: 처리량 테스트에 사용된 Raspberry Pi의 역할 식별 정보.
이 파트는 Bluetooth LE 패킷이 무선으로 흐르는 “기계적 디테일”을 다룬다: 연결 인터벌, 패킷 간격, LE 크레딧 기반 흐름 제어(LE CoC). 모든 오버-더-에어 캡처는 Teledyne LeCroy Frontline 무선 분석기로 수행했고, Wireless Protocol Suite(WPS) 소프트웨어로 표시했다. 여기서는 Rust가 필요 없다.
LE 연결은 연결 인터벌(Connection Interval, CI)로 박자가 정해진다. CI는 장치들이 통신해도 되는 반복되는 “약속 시간”이라고 생각하면 된다. 약속이 112.5밀리초(msec)마다 있다면 트래픽의 버스트는 112.5msec마다만 발생한다. 약속이 7.5msec마다라면 버스트는 훨씬 자주 발생한다.
CI 값은 1.25msec 단위³의 정수로 지정된다. 예를 들어:
이 실험에서는 Linux 커널의 debugfs 파일시스템 knob를 사용해 CI를 조정했다(반복 가능한 테스트를 위해 가장 직접적인 접근이었고 Appendix A에 설명).
내가 사용한 방법론은 CI의 최소/최대 값을 설정한 뒤 속도 테스트를 실행하는 것이다. 그리고 아래처럼 다음 속도 테스트를 위한 파라미터를 설정해 반복했다.
bash/home/user/prj/bluetooth/potto216/bluer/target/debug/l2cat speed-server -v 255 echo 10 | sudo tee /sys/kernel/debug/bluetooth/hci0/conn_min_interval echo 10 | sudo tee /sys/kernel/debug/bluetooth/hci0/conn_max_interval /home/user/src/bluetooth/bluer/target/debug/l2cat speed-client E4:5F:01:5E:2B:A6 255 echo 6 | sudo tee /sys/kernel/debug/bluetooth/hci0/conn_min_interval echo 6 | sudo tee /sys/kernel/debug/bluetooth/hci0/conn_max_interval /home/user/src/bluetooth/bluer/target/debug/l2cat speed-client E4:5F:01:5E:2B:A6 255
Teledyne LeCroy 분석기의 오버-더-에어 캡처에서 얻은 Figure 1은, CI가 줄어들수록 처리량이 지난 글의 약 55Kb/s에서 224.8Kb/s로(약 4배) 개선됨을 보여준다.
하지만 개선 폭은 일관되지 않다.
처음에는 CI를 줄이는 비율에 거의 비례해 처리량도 증가한다. 그러나 이후에는 동일한 CI 감소 비율이 점점 더 적은 이득을 가져온다 — 체감 수익이다. “약속 스케줄”을 빠르게 하면 도움이 되지만, 다른 한계가 점점 지배적이 된다.
CI를 줄이는 것은 배달 트럭을 더 자주 배차하는 것과 같다. 초반에는 긴 유휴 구간을 제거한다. 하지만 결국 창고가 더 빨리 트럭을 적재할 수 없고, 또는 도로 규칙 때문에 멈춤이 강제되면서 트럭 배차 빈도를 두 배로 늘려도 배달되는 박스가 두 배가 되지 않는다.
이 비유는 Figure 1의 세부에서도 드러난다. CI가 112.5msec에서 50msec(A→B)로 줄면 시간은 2.3배 감소하고 데이터율은 1.9배 증가한다. 시간 감소율과 처리량 증가율이 거의 맞는다. 하지만 CI가 7.5msec로 줄어 112msec 대비 15배 감소했을 때는 체감 수익이 나타나 데이터율 증가가 8.3배에 그친다 — 시간 계수 15배에 한참 못 미친다. 왜일까? 도로가 트럭으로 막힌 것인가(CI가 너무 짧아 모든 비트를 담기 어려운가), 아니면 창고(Bluetooth LE 장치)가 더 빨리 트럭을 적재하지 못하는가?
Press enter or click to view image in full size

Figure 1: 서로 다른 5개 연결 인터벌 시간 A–E에 대한 처리량 계산. 가장 짧은 CI 7.5msec에서 최대 단방향 처리량 224.8Kb/s를 보인다. 캡처 및 계산은 Teledyne LeCroy 무선 프로토콜 분석기로 수행. 표는 CI의 상대 비율과 데이터율의 상대 비율을 비교하며, CI가 줄어드는 속도에 비해 데이터율은 그만큼 증가하지 않음을 보여준다.
Figure 2는 체감 수익을 더 쉽게 보여준다. 세로축에 연결 인터벌을 놓고, 실제 전송을 직사각형으로 표시하며 테두리 색으로 클라이언트(초록)인지 서버(파랑)인지 구분한다. 데이터 흐름은 CI에 맞춰 정렬되며, 먼저 클라이언트가 데이터를 요청(Figure 2의 CI 1과 CI 3)하고, 이어지는 CI에서 서버가 데이터로 응답(Figure 2의 CI 2와 CI 4)하며 하나 이상의 패킷을 보낸다. 이 요청/응답 사이클은 클라이언트가 다시 데이터 요청을 보내며 반복된다. Figure 2의 핵심 포인트는:
요청/응답 패턴의 공백도, 674바이트 합산 페이로드도 임의가 아니다. 이 패턴은 Bluetooth LE의 Credit Based Connection-Oriented Channels(LE CoC) 및 페이로드 길이와 연결되어 있다. 다음에서 이를 살펴본다.
L2CAP 채널에서 데이터 요청/응답 패턴이 시작되기 전에, 채널이 먼저 생성된다. Linux BlueZ는 채널 설정 동안 두 장치 간에 채널 파라미터를 협상한다. 아래 btmon 캡처는 설정 중 핵심을 보여준다:
< ACL Data TX: Handle 64 flags 0x00 dlen 18
LE L2CAP: LE Connection Request (0x14) ident 1 len 10
PSM: 254 (0x00fe)
Source CID: 64
MTU: 672
MPS: 247
Credits: 3
이 글에서 중요한 LE Connection Request 필드는:
MTU, MPS, 크레딧의 관계를 물류 창고에 비유하면:
따라서 MTU 크기 SDU(672바이트)는 MPS 크기 조각으로 잘려 링크를 건너는 PDU가 된다.
MPS가 247이면 672바이트 SDU는 Figure 2처럼 길이 247, 247, 180바이트의 세 PDU 패킷이 된다. 합계는 674바이트로 MTU보다 2바이트 크다. 이는 이상해 보이지만 다음 디테일을 포함하면 설명된다.
Figure 3의 캡처는 첫 번째 247바이트 패킷에 있는 수수께끼의 추가 2바이트를 16진수(a0 02)로 보여준다. 이 값은 첫 패킷의 페이로드 시작에만 나타나고 이후 패킷에는 없다. LE CoC 세그먼테이션에서는 SDU의 첫 세그먼트(첫 패킷의 PDU)가 2바이트 SDU 길이 필드를 포함한다. 리틀 엔디언이므로:
a0 02 = 0x02a0 = 672이 길이 필드가 “674 vs 672” 불일치를 설명한다:
Press enter or click to view image in full size

Figure 3: MTU(672)보다 2바이트 큰 674바이트의 근원이 L2CAP SDU 길이 필드임을 보여준다. 또한 클라이언트가 더 많은 데이터를 받을 준비가 되었음을 나타내는 방법은 크레딧을 통해 ‘세 개 패킷을 더 받을 준비가 됐다’는 패킷(L2CAP_FLOW_CONTROL_CREDIT_IND)을 보내는 것이다. 112.5msec 대신 25msec 창을 선택한 것은 단지 인접한 연결 인터벌들에서 패킷이 더 가까이 보이도록 하려는 시각적 이유였다. Teledyne LeCroy 무선 프로토콜 분석기로 캡처.
Figure 3은 또한 클라이언트가 더 많은 데이터를 요청하는 방법을 보여준다. 바이트 수가 아니라 크레딧으로 요청하며, 각 크레딧은 패킷으로 전송되는 한 PDU에 대응된다. 크레딧은 비행 중(in flight)일 수 있는 패킷(PDU/세그먼트) 수를 제한한다. 초기 크레딧=3이면 송신자는 세 PDU를 전송한 뒤, 새로운 크레딧이 도착할 때까지 반드시 멈춰야 한다. 이는 한 CI에서 보낼 수 있는 데이터량을 제한한다. 크레딧은 Bluetooth 표준에서 L2CAP_FLOW_CONTROL_CREDIT_IND 패킷으로 도착한다. Figure 3에서 이 패킷은 클라이언트가 보내며 서버에 3 크레딧을 반환해 서버가 세 개의 데이터 PDU(패킷)를 더 전송할 준비가 되었음을 알린다.
한 걸음 물러서 보면, 설정은 선형적으로 스케일할 것처럼 보인다. CI가 줄면 초당 연결 이벤트가 증가하고, 초당 더 많은 데이터가 흘러야 한다. Figure 1은 처음엔 그렇고 — 이후엔 그렇지 않음을 보여준다. CI가 매우 짧아지면 처리량 곡선이 휘고 증가가 평평해진다. 이는 리듬의 어떤 부분이 더 이상 따라가지 못함을 뜻한다. 기본 리듬은:
CI가 길 때 비효율의 대부분은 유휴 무선 시간에서 나온다. 가장 짧은 CI(7.5msec)에서는 Figure 4가 다른 문제를 보여준다. 링크 스케줄은 빠르지만, 데이터 파이프라인이 이를 안정적으로 채우지 못한다.
Figure 4에서 클라이언트는 CI 1 시작에 3 크레딧을 부여한다. CI 2에서 서버는 세 크레딧을 모두 소비해 SDU를 완성하는 3개 PDU를 전송할 수 있어야 한다. 그러나 서버는 데이터 패킷(두 PDU)만 전송한다 — CI 2에는 약 4.161msec의 사용되지 않은 무선 시간이 남아 있는데도 말이다. 이 남은 시간은 247바이트 페이로드의 또 다른 전체 패킷을 보내는 데 필요한 약 2.238msec보다 충분히 크다. 즉, 연결 이벤트에는 여유가 있고, 서버는 아직 크레딧이 하나 남아 있는데도 세 번째 패킷이 나가지 않았다.
더 나쁜 것은 CI 3에서 서버가 데이터를 전혀 보내지 않고, 매 CI마다 발생하는 일반적인 “keep alive”/빈 ACK만 보낸다는 점이다. 마지막 데이터 패킷은 CI 4에서 나타나며 마지막 크레딧을 소비한다. 이어 CI 5에서 클라이언트가 더 많은 크레딧을 발행하고, 패턴이 반복된다.
이 동작은 완전히 일관되지는 않다. 어떤 때는 세 패킷이 7.5msec 연결 이벤트 하나에 깔끔히 들어가기도 한다. 다른 때는 Figure 4처럼 전송이 “버벅”인다: 사용되지 않은 무선 시간, 그 다음 빈 인터벌, 그 다음 마지막 패킷.
이제 병목은 연결 인터벌 길이(고속도로)가 아니다. 고속도로는 열려 있고 트럭 행렬에 트럭 한 대가 더 들어갈 공간도 있다. 병목은 창고 상하차장(warehousing)처럼 보인다. 트럭은 몇 분마다 출발하도록 배차되지만, 디스패치가 다음 트럭을 제때 출발시키지 못하는 것이다 — 트럭이 실렸고 출발 슬롯이 있어도.
이 불일치가 Rust 코드(창고 내부)를 들여다보게 만드는 동기다.
Press enter or click to view image in full size

Figure 4: 크레딧 개념이 패킷 데이터 처리량에 미치는 영향을 보여준다. 연결 인터벌(CI) 1 시작에 클라이언트는 크레딧 요청(L2CAP_FLOW_CONTROL_CREDIT_IND)으로 ‘세 패킷을 더 받을 준비가 됐다’는 패킷을 보낸다. 이어 CI 2에서 데이터 패킷 두 개가 전송되어 하나 더 보낼 공간이 남는다. CI 3에서는 서버가 데이터를 보내지 않고 연결 인터벌 ACK만 교환된다. 이 빈 ACK는 상단 줄(동그라미)에서 하단 줄로 내려갈 때 시각적으로 반복된다. 다음 CI 4에서 세 번째 패킷이 전송되어 클라이언트가 요청한 최대치를 채우고, 이후 CI 5에서 클라이언트가 다시 서버에 세 데이터 패킷을 요청할 때까지 더 이상 보내지지 않는다. Teledyne LeCroy 무선 프로토콜 분석기로 캡처.
Part 1의 오버-더-에어 캡처는 미묘한 점을 보여줬다. CI에 여유 무선 시간이 있고 서버가 크레딧도 남아 있는데도 서버가 때때로 CI를 데이터로 채우지 못한다. 이 “버벅임”이 바이트를 커널 L2CAP 소켓으로 밀어 넣는 비동기 Rust 경로(창고 내부)를 들여다보게 만들었다.
서버 속도 테스트 옵션은 한 번에 4096바이트 블록을 쓰는데, 협상된 L2CAP SDU MTU는 672바이트에 불과하다. 더 큰 버퍼의 목적은 파이프라인을 꽉 채우기 위함이다. 하지만 이는 쓰기 경로가 4096바이트 블록을 소켓이 받아들일 수 있는 더 작은 조각으로 분할해야 함을 뜻한다.
다음은 서버 속도 테스트 코드의 관련 부분이다:
rustuse tokio::io::{AsyncReadExt, AsyncWriteExt}; impl SpeedServerOpts { pub async fn perform(self) -> Result<()> { let mut buf = vec![0; 4096]; if let Err(err) = conn.write_all(&buf).await { println!("Disconnected: {err}"); break; } } } } pub struct Socket<Type> { fd: AsyncFd<OwnedFd>, _ type: PhantomData<Type>, } pub struct Stream { socket: Socket<Stream>, send_mtu: AtomicUsize, }
conn.write_all(&buf).await는 겉보기에는 “4096바이트인 buf를 써라”처럼 읽힌다. 실제로는 “전체 슬라이스가 수용될 때까지 계속 쓰기를 시도하되, 소켓이 더 이상 받을 수 없으면 멈췄다가 나중에 다시 시도하라”에 가깝다.
“Tokio가 4096바이트를 MTU 크기 조각으로 나눈다”고 말하고 싶어지지만, 더 정확히는:
write_all()은 슬라이스가 소진될 때까지 루프를 돈다.poll_write()다.Tokio는 ‘끈기(persistence)’를 제공하고, BlueR는 크기 제한을 제공한다.
write_all()은 future를 만들고 .await가 폴링으로 이를 구동한다write_all()은 즉시 쓰지 않는다. WriteAll future를 반환한다:
rustpub(crate) fn write_all<'a, W>(writer: &'a mut W, buf: &'a [u8]) -> WriteAll<'a, W> where W: AsyncWrite + Unpin + ?Sized, { WriteAll { writer, buf, _pin: PhantomPinned } }
.await 전까지는 아무 일도 일어나지 않는다. .await에 도달하면 Tokio 실행기가 이 future를 Poll::Ready(...)를 반환할 때까지 반복적으로 poll한다.
개념적으로 WriteAll::poll()은 루프에서 세 가지를 한다:
poll_write()를 호출한다.Tokio의 구현은 아래처럼 생겼다(가독성을 위해 일부 생략):
rustimpl<W> Future for WriteAll<'_, W> where W: AsyncWrite + Unpin + ?Sized, { type Output = io::Result<()>; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> { let me = self.project(); while !me.buf.is_empty() { let n = ready!(Pin::new(&mut *me.writer).poll_write(cx, me.buf))?; { let (_, rest) = mem::take(&mut *me.buf).split_at(n); *me.buf = rest; } if n == 0 { return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); } } Poll::Ready(Ok(())) } }
여기서 비동기 I/O는 “폴 기반(poll-driven)”이다. 실행기는 계속 묻는다: 지금 당장 진전을 낼 수 있는가?
쓰기 측에서 writer는 다음 중 하나로 응답한다:
Poll::Ready(Ok(n)) — “n 바이트를 썼다”Poll::Pending — “지금은 쓸 수 없다; 나중에 이 태스크를 깨워라”Tokio는 ready! 매크로로 이 스타일을 선형처럼 보이게 한다:
Ready(x)면 ready!는 x로 평가되고 함수는 계속 진행한다.Pending이면 ready!는 현재 함수에서 즉시 Pending을 반환한다.이 동작이 소켓에 백프레셔가 걸릴 때 .await가 깔끔하게 멈추도록 만든다.
poll_write()를 호출하면, BlueR로 디스패치된다WriteAll::poll()에서 실제로 데이터를 쓰는 핵심 라인은 이것이다:
Pin::new(&mut *me.writer).poll_write(cx, me.buf)
conn이 bluer::l2cap::Stream이므로, 이는 BlueR로 들어가는 호출이 된다:
rustimpl AsyncWrite for Stream { fn poll_write(self: Pin<&mut Self>, cx: &mut Context, buf: &[u8]) -> Poll<Result<usize>> { self.poll_write_priv(cx, buf) } }
이 지점이 핸드오프 포인트다. Tokio는 끈기와 루프를 제공하고, BlueR는 어떤 크기의 쓰기가 합법적인지 및 논블로킹 소켓과 상호작용하는 방법을 결정한다.
Linux에서 LE CoC 소켓은(BlueR 주석에 설명된, 여기서는 요약만 하는) 데이터 스트림에 대해 예상 밖 규칙이 있다: 논리적으로 “stream”이라 하더라도, 단일 send() 에서 L2CAP send MTU보다 많이 쓰려고 하면 에러가 날 수 있다. BlueR는 이를 우회하기 위해 커널로 넘기는 버퍼를 잘라(truncate) 각 시도가 MTU-safe가 되도록 한다.
rustfn poll_write_priv(&self, cx: &mut Context, buf: &[u8]) -> Poll<Result<usize>> { let send_mtu = match self.send_mtu.load(Ordering::Acquire) { 0 => match self.socket.send_mtu() { Ok(mtu) => { let mtu = mtu.into(); self.send_mtu.store(mtu, Ordering::Release); mtu } Err(_) => 16, }, mtu => mtu, }; let max_len = buf.len().min(send_mtu); let buf = &buf[..max_len]; self.socket.poll_send_priv(cx, buf) }
따라서 Tokio 루프에서의 각 호출은 사실상 다음을 의미한다:
min(remaining, send_mtu)로 제한한 뒤poll_send_priv로 그 제한된 조각을 논블로킹 전송 시도send MTU가 672이면, 4096바이트 write_all()은 보통 672, 672, 672, 672, 672, 672, 64 바이트 정도의 약 7회 쓰기로 바뀐다.
Tokio는 MTU에 대해 아무것도 알 필요가 없다. “그 쓰기는 몇 바이트를 받아들였나?”만 물어보고, 그만큼 슬라이스를 전진시키면 된다.
Pending은 실제로 어디서 오는가Poll::Pending은 MTU 클램핑에서 오지 않는다. MTU 로직은 동기적이며 즉시 끝난다.
Pending은 소켓 준비 상태(readiness) 경로에서, poll_send_priv 내부에서 온다:
rustfn poll_send_priv(&self, cx: &mut Context, buf: &[u8]) -> Poll<Result<usize>> { loop { let mut guard = ready!(self.fd.poll_write_ready(cx))?; match guard.try_io(|inner| sock::send(inner.get_ref(), buf, 0)) { Ok(result) => return Poll::Ready(result), Err(_would_block) => continue, } } }
Pending을 유발하는 라인은 이것이다:
let mut guard = ready!(self.fd.poll_write_ready(cx))?;
기저 파일 디스크립터가 현재 writable이 아니면(커널 버퍼 가득 참, 컨트롤러 페이싱, 흐름 제어 등), poll_write_ready()는 Pending을 반환하고 ready!는 이를 즉시 상위로 전파한다.
실제 send 호출은 libc::send의 얇은 래퍼다:
rustpub fn send(socket: &OwnedFd, buf: &[u8], flags: c_int) -> Result<usize> { match unsafe { libc::send(socket.as_raw_fd(), buf.as_ptr() as *const _, buf.len(), flags) } { -1 => Err(Error::last_os_error()), n => Ok(n as _), } }
poll_send_priv에서 Err(_would_block) => continue 분기는 Tokio의 “낙관적 준비 상태(optimistic readiness)” 패턴이다. 때로는 fd가 writable처럼 보였지만 send가 EWOULDBLOCK에 걸릴 수 있다. 이때 try_io는 readiness 상태를 클리어하고 루프는 다시 시도하며, 보통 OS가 다시 writable을 보고할 때까지 poll_write_ready()가 Pending을 반환하게 된다.
여기서 창고 비유가 구체화된다.
만약 L2CAP 소켓이 ‘딱 타이밍이 안 좋을 때’ 잠깐 non-writable이 되면(커널 큐 가득 참, 컨트롤러 스케줄링, 크레딧 페이싱, 또는 일반적인 타이밍 문제), 태스크는 Pending을 반환하고 멈춘다. 이 멈춤은 연결 이벤트 경계를 쉽게 넘어설 수 있다. 무선 관점에서는 “공간이 있는데도 아무것도 안 보냄”처럼 보인다.
그 후 fd가 다시 writable이 되면 Tokio가 태스크를 깨우고 남은 조각이 나간다 — 때로는 기대했던 것보다 CI 하나 늦게.
이 CI “버벅임”의 근본 원인에 대한 내 설명은 가설(hypothesis)이며, 이 글에서는 실제로 이것이 발생하고 있다는 증거를 제시하지 않는다.
여기 제어 흐름은 흥미로운 왕복을 한다: BlueR가 Tokio 헬퍼를 호출하고, Tokio는 트레이트 메서드를 통해 다시 BlueR를 호출한다. 대략적인 체인은:
BlueR perform → Tokio write_all → Tokio 실행기가 poll → BlueR poll_write → BlueR 소켓 send → libc::send
비결은 트레이트 디스패치와 확장 트레이트(extension trait)다.
bluer::l2cap::Stream은 tokio::io::AsyncWrite를 구현하며, 이는 poll 기반 인터페이스다. Tokio의 AsyncWriteExt는 AsyncWrite를 구현한 모든 타입에 대해 blanket impl을 제공하는 확장 트레이트다. 그래서 Stream이 write_all() 같은 메서드를 “마법처럼” 갖게 되는데, 그 메서드들은 BlueR가 아니라 Tokio에서 온 것이다.
write_all() 자체는 즉시 아무 것도 쓰지 않는다. WriteAll future를 만들 뿐이다. .await가 일어나면 실행기가 그 future를 반복적으로 poll하고, WriteAll::poll() 내부에서 Tokio는 다음 호출을 통해 실제 작업을 수행한다:
AsyncWrite::poll_write(Pin<&mut Stream>, cx, buf_remaining)
writer가 BlueR Stream이므로 이 호출은 BlueR의 poll_write로 디스패치되고, 그 안에서 MTU 클램프가 매 poll마다 적용된다. 각 poll은 최대 send_mtu 바이트만 쓰며, write_all은 원래 버퍼(예: 4096바이트)가 모두 수용될 때까지 루프를 계속한다.
이 섹션은 실용적이진 않지만 재미있다. 브라우저나 curl이 만족할 정도의 HTTP를 말하는 작은 CGI 같은 셸 스크립트를 만들고, l2cat을 전송 수단으로 사용하며, socat을 접착제처럼 써서 “Bluetooth 바이트 스트림”을 “localhost의 TCP 소켓”으로 바꾼다. socat은 서로 무관한 두 세계를 연결해 하나의 파이프로 동작하게 만든다. Debian 계열에서는 sudo apt install socat으로 설치할 수 있다.
이 섹션에서는 장치 역할을 뒤집었다: comm-rpi4–02가 Bluetooth LE로 웹서버를 호스팅하고, comm-rpi4–01이 클라이언트가 된다.
웹서버를 만들기 위해 먼저 webtest라고 임의로 부를 디렉터리에 cgi-http.sh 파일을 만든다. 파일 내용은:
bash#!/bin/bash IFS=$'\r\n' read -r request_line path=$(printf '%s' "$request_line" | cut -d ' ' -f2) [ "$path" = "/" ] && path="/index.html" file="www${path}" if [ -f "$file" ]; then size=$(wc -c <"$file") printf 'HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n' "$size" eval "echo \"$(cat \"$file\")\"" else printf 'HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot Found' fi
webtest 디렉터리에 www 하위 디렉터리를 만들고, index.html 웹페이지를 다음 내용으로 만든다:
html<!DOCTYPE html> <html><body> <h1>Hello from BLE HTTP!</h1> <p>Time is $(date)</p> </body></html>
이 페이지는 브라우저가 “Hello from BLE”²와 현재 날짜를 렌더링하게 만든다. 제공하려면 다음을 실행한다:
bashl2cat serve -v 225 ./cgi-http.sh
클라이언트 장치(comm-rpi4–01)에서 8080 포트에 로컬 TCP 리스너를 만들고, socat이 파이프의 다른 끝에서 l2cat connect --raw ...를 실행하게 한다:
bashsudo socat -v TCP-LISTEN:8080,reuseaddr,fork EXEC:'/home/user/.cargo/bin/l2cat connect --raw E4\:5F\:01\:2B\:04\:38 225',pty,raw,echo=0
socat은 매우 유용했지만 사용하면서 몇 가지 거친 부분을 만났고, Appendix B에 이를 설명한다.
다음으로 comm-rpi4–01에서 별도 터미널을 열고 chrome 브라우저로 정상적인 URL을 요청했다:
bashchromium-browser http://localhost:8080/index.html
그러면 페이지가 렌더링된다(Figure 5).
Press enter or click to view image in full size

Figure 5: Bluetooth LE 링크를 통해 chromium에서 렌더링된 페이지
Raspberry Pi의 Chromium이 최근 업데이트되었다면 이 방법이 동작하지 않을 수 있다. 동작이 바뀌어 렌더링이 실패할 수 있는데, 그 경우에도 전송은 동작한다. 이때 curl과 lynx는 여전히 원시 응답을 보여준다.
이 시리즈는 단순한 처리량 그래프로 시작해 Bluetooth LE로 제공되는 웹페이지로 끝났다. 그 사이에 성능 한계를 드러냈고, async Rust가 Bluetooth LE 링크로 바이트를 밀어 넣는 방식에 대한 투어를 제공했다.
LE 연결 인터벌을 줄이면 낭비되는 무선 시간이 제거되며 처리량이 크게 증가한다: 대략 55Kb/s에서 224.8Kb/s까지. 그러나 연결 인터벌은 레버 중 하나일 뿐이며, 다른 시스템 파라미터가 개선 계수를 제한해 곡선이 결국 평평해진다. 이는 Figure 4의 “CI 버벅임”에서 확인할 수 있다.
Rust 측에서는 async 경로가 짧은 인터벌에서 링크가 “버벅”이는 이유에 대한 가설을 제공한다. write_all()은 future로 포장된 루프다. 실행기가 이를 poll하고, Tokio가 poll_write()를 호출하며, BlueR는 각 send를 협상된 MTU로 제한하고, AsyncFd는 백프레셔를 바쁜 대기(busy-wait) 대신 Pending 일시정지로 변환한다. 4096바이트 버퍼는 MTU 크기 쓰기들의 시퀀스로 바뀐다.
이 설계에는 중요한 타이밍 고려사항이 있다. 모델은 세 가지가 정렬될 때 매끄럽게 동작한다: 애플리케이션이 계속 데이터를 만들고, Tokio가 계속 poll하며, L2CAP 소켓이 계속 writable이다. 만약 소켓이 ‘나쁜 순간’에 잠깐 non-writable이 되면 — 커널 send 큐가 차거나, 컨트롤러가 패킷을 다르게 스케줄하거나, 크레딧 페이싱이 진전을 늦추는 등의 이유로 — BlueR는 Pending을 반환하고 태스크는 멈춘다. 그 멈춤은 연결 이벤트 경계를 쉽게 넘어갈 수 있다. 무선에서는 “공간이 있었는데 아무 것도 보내지지 않았다”처럼 보인다. fd가 다시 writable이 되면 Tokio가 태스크를 깨우고 다음 조각이 나가며, 때로는 기대보다 CI 하나 늦게 나간다. 이는 가장 짧은 연결 인터벌에서 관찰된 CI 버벅임(사용되지 않은 무선 시간 + 빈 CI)에 기여하는 그럴듯한 요인이다.
다음 단계는 가설을 지지하거나 반박할 증거를 찾는 것이다. 예를 들어 타이밍을 직접 측정해 BlueR가 각 send()를 시도하는 순간, fd가 writable/non-writable로 전환되는 순간, 그리고 이것이 BlueZ 컨트롤러 동작 및 무선 스케줄링과 어떻게 상관되는지 보는 것이다. 그 다음 처리량을 더 높이기 위한 가장 유망한 knob는 크레딧 동작, SDU 크기 전략, 그리고 각 연결 이벤트 내부의 컨트롤러별 스케줄링이다 — 성능의 남은 부분이 그곳에 숨어 있을 가능성이 크고, 이 시리즈의 취지대로 Bluetooth와 Rust를 더 깊이 이해하게 해줄 흥미로운 경로이기 때문이다.
The writing process used ChatGPT 5.2 (Extended Thinking)
글 업데이트: 2025년 12월 24일 — 다크 모드에서 그림이 제대로 표시되도록 수정하고 주 4를 추가.
¹ 이 글은 조금 길어졌다. 분할할까 고민했지만, 세 섹션이 자료로서 함께 있으면 좋겠다고 판단했다. 개인적으로 짧은 글을 좋아하는데, 끝까지 읽으면 성취감을 빨리 얻을 수 있기 때문이다. 즉, 나도 이 글을 안 읽을 수도 — 또는 부분적으로만 읽을 수도 있다:).
² BLE는 Bluetooth Low Energy의 통상적 약자지만 표준에서는 인정되지 않아서, 본문에서는 Low Energy(LE)로만 지칭한다.
³ Bluetooth 6.2에서는 더 이상 사실이 아니다. Bluetooth Shorter Connection Intervals(SCI)라는 기능이 도입되어 1.25msec보다 훨씬 짧은 연결 인터벌이 가능해진다.
⁴ BlueZ의 새로운 버전(커밋 ce60b9231b66710b6ee24042ded26efee120ecfc 이후)에서는 크레딧이 MTU/SDU에 기반해 송신자에게 백프레셔를 거는 방식이 더 이상 아니다.
연결 인터벌을 수정하는 간단한 방법을 찾지 못했다. 내가 선택한 debugfs 파일시스템 사용은 임시적인 방법으로, 다음 재부팅까지만 유효하며 커널이 debugfs 활성화로 빌드되어야 하고 파일시스템이 마운트되어 있어야 한다. 다른 옵션으로는 /etc/bluetooth/main.conf 파일에서 최소/최대 연결 인터벌 관련 파라미터를 수정하는 것이다. 해당 파라미터는:
MinConnectionInterval=MaxConnectionInterval=main.conf를 수정했다면 새 파라미터를 적용하기 위해 bluetooth.service를 리로드해야 한다. 하지만 Bluetooth 관리 도구가 이를 덮어쓸 수 있으므로 이 파라미터가 실제로 사용된다는 보장은 없다.
추가 문제는 광고를 수행하는 peripheral이 그 시간 인터벌을 받아들여야 한다는 점이다. Bluetooth 컨트롤러가 연결 인터벌 설정을 수용하지 않으면 실패할 수 있다.
이 글을 쓰기 전까지 socat을 들어본 적이 없었는데(ChatGPT 제안), 매우 마음에 든다.
싱글 쿼트를 사용하고, 주소의 콜론을 socat 방식으로 이스케이프하는 것이 중요하다. 그렇지 않으면 아래 오류가 난다:
bashsudo socat TCP-LISTEN:8080,reuseaddr,fork EXEC:"l2cat connect --raw E4:5F:01:2B:04:38 225",pty,raw,echo=0
오류:
2025/10/27 14:16:32 socat[17018] E "EXEC:l2cat connect --raw E4": wrong number of parameters (6 instead of 1)
이 오류는 socat이 Bluetooth 주소의 콜론을 새로운 socat 주소 타입으로 해석하기 때문에 발생한다. 해결하려면 몇 가지 변경이 필요하다:
socat 전용 이스케이프 문자(백슬래시)로 이스케이프해야 한다.\: 이스케이프 시퀀스를 해석해 socat이 이를 보지도 못하는 상황을 막기 위해, 문자열 주위의 더블 쿼트를 싱글 쿼트로 바꿔야 한다.l2cat의 절대 경로를 지정해야 한다.변경 사항은 아래와 같으며, 이것이 글에서 사용한 명령이다.
bashsudo socat -v TCP-LISTEN:8080,reuseaddr,fork EXEC:'/home/user/.cargo/bin/l2cat connect --raw E4\:5F\:01\:2B\:04\:38 225',pty,raw,echo=0