<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://alexjin520.github.io/feed.xml" rel="self" type="application/atom+xml"/><link href="https://alexjin520.github.io/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-08-06T02:36:50+00:00</updated><id>https://alexjin520.github.io/feed.xml</id><title type="html">Alex Jin</title><subtitle>Alex Jin&apos;s personal website for software development, embedded systems, projects, and notes. </subtitle><entry><title type="html">Sharing One Debug UART Between Linux and RISC-V with RPMsg</title><link href="https://alexjin520.github.io/blog/2026/sharing-one-debug-uart-between-linux-and-riscv-with-rpmsg/" rel="alternate" type="text/html" title="Sharing One Debug UART Between Linux and RISC-V with RPMsg"/><published>2026-08-06T02:30:00+00:00</published><updated>2026-08-06T02:30:00+00:00</updated><id>https://alexjin520.github.io/blog/2026/sharing-one-debug-uart-between-linux-and-riscv-with-rpmsg</id><content type="html" xml:base="https://alexjin520.github.io/blog/2026/sharing-one-debug-uart-between-linux-and-riscv-with-rpmsg/"><![CDATA[<p>I recently brought up the RISC-V auxiliary core on an Ingenic X2600 board. Linux runs on the main core, while a small FreeRTOS firmware image runs on the RISC-V core. Both sides had useful debug output, but they also tried to use the same physical UART.</p> <p>That made an otherwise simple logging problem surprisingly difficult. A line from Linux could be interrupted halfway through by a line from RISC-V. Boot messages were sometimes missing, shell escape sequences leaked into the output, and the result depended on which core reached the UART first.</p> <p>The solution was not a more complicated UART lock. I gave Linux exclusive ownership of the UART and turned the small core’s output into messages:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RISC-V printf -&gt; ring buffer -&gt; RPMsg -&gt; Linux driver -&gt; printk -&gt; UART
</code></pre></div></div> <p>This article describes the complete path, including the two startup races that only appeared during cold boot.</p> <h2 id="1-one-peripheral-needs-one-owner">1. One peripheral needs one owner</h2> <p>The initial design effectively looked like this:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Linux printk ---------------------&gt; UART
RISC-V printf --------------------&gt; UART
</code></pre></div></div> <p>The two cores do not share a scheduler, and an ordinary mutex on one core cannot serialize code running on the other. A hardware spinlock or a custom shared-memory lock could protect individual writes, but it would introduce several new questions:</p> <ul> <li>What happens if one core crashes while holding the lock?</li> <li>Can an interrupt handler wait for it safely?</li> <li>How are clocks, pin configuration, and baud-rate changes coordinated?</li> <li>How are messages distinguished after they reach the terminal?</li> </ul> <p>The cleaner rule is:</p> <blockquote> <p>Linux owns the debug UART. RISC-V produces log records, not UART transactions.</p> </blockquote> <p>This moves serialization to one place and keeps all board-level UART configuration under Linux control.</p> <h2 id="2-the-resulting-data-path">2. The resulting data path</h2> <p>I reused the board’s existing RPMsg transport. Endpoint 1 is reserved for RISC-V logs, while other endpoints remain available for their original console or application traffic.</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RISC-V / FreeRTOS                         Linux

printf / prom_printk
        |
        v
    _write()
        |
        v
rtos_console_write()
        |
        v
8 KiB circular buffer
        |
        v
log forwarding task
        |
        v
riscv_log_send()
        |
        +------ RPMsg endpoint 1 ------&gt; receive callback
                                              |
                                              v
                                        bounded skb queue
                                              |
                                              v
                                           worker
                                              |
                                              v
                                      printk("[RISCV] ...")
                                              |
                                              v
                                             UART
</code></pre></div></div> <p>There are two important boundaries in this diagram. The circular buffer separates log producers from RPMsg availability on the small core. The Linux queue and worker separate the RPMsg receive callback from text formatting and console output.</p> <h2 id="3-redirecting-the-small-core-output">3. Redirecting the small-core output</h2> <p>The firmware already had a newlib-style <code class="language-plaintext highlighter-rouge">_write()</code> hook used by <code class="language-plaintext highlighter-rouge">printf()</code>. Instead of sending bytes to a UART, <code class="language-plaintext highlighter-rouge">_write()</code> now passes them to <code class="language-plaintext highlighter-rouge">rtos_console_write()</code>, which appends them to an 8 KiB circular buffer.</p> <p>The same path also handles low-level firmware printing. I added relay implementations of <code class="language-plaintext highlighter-rouge">prom_printk()</code>, <code class="language-plaintext highlighter-rouge">prom_putchar()</code>, and <code class="language-plaintext highlighter-rouge">prom_putstr()</code> so code below the C library does not bypass the new ownership rule.</p> <p>Conceptually, every producer now ends at the same function:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">rtos_console_write</span><span class="p">(</span><span class="kt">char</span> <span class="o">*</span><span class="n">data</span><span class="p">,</span> <span class="kt">int</span> <span class="n">len</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="n">written</span> <span class="o">=</span> <span class="n">circ_buffer_write</span><span class="p">(</span><span class="o">&amp;</span><span class="n">log_buffer</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">len</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">log_task_ready</span><span class="p">)</span>
        <span class="n">wake_log_task</span><span class="p">();</span>

    <span class="k">return</span> <span class="n">written</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div> <p>The real implementation also chooses the interrupt-safe semaphore operation when called from interrupt context.</p> <p>A dedicated FreeRTOS task drains the buffer in RPMsg-sized chunks. Application code therefore continues to use normal calls such as:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">printf</span><span class="p">(</span><span class="s">"sensor task started</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
</code></pre></div></div> <p>There is no need to call <code class="language-plaintext highlighter-rouge">riscv_log_send()</code> from each feature. Keeping that transport detail below <code class="language-plaintext highlighter-rouge">printf()</code> makes the mechanism easy to adopt and difficult to bypass accidentally.</p> <p>The RISC-V firmware build also excludes its direct serial implementation. Redirection is only reliable if no second code path can silently reclaim the UART.</p> <h2 id="4-preserving-early-boot-messages">4. Preserving early boot messages</h2> <p>Buffering solves a scheduling problem, but it does not by itself solve startup ordering.</p> <p>The small core may start before Linux has probed the RPMsg device and installed its receive callback. If the forwarding task drains immediately, RPMsg can accept or discard data before a Linux consumer exists. The most valuable boot messages then disappear.</p> <p>I added a tiny ready protocol:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RISC-V creates endpoint 1 and buffers logs
                    |
                    v
Linux probes endpoint 1 and installs callback
                    |
                    v
Linux sends "RISC-V-LOG-READY"
                    |
                    v
RISC-V marks host_ready = true
                    |
                    v
RISC-V drains all buffered startup logs
</code></pre></div></div> <p>Until this exact message arrives, <code class="language-plaintext highlighter-rouge">riscv_log_send()</code> refuses to transmit and the log task leaves the circular buffer intact. Once the handshake is received, the task wakes and flushes both early and current output in order.</p> <p>The marker is deliberately a protocol message instead of a fixed delay. A 100 ms delay may work on one image and fail on another; “the receiver has installed its callback” is the state the sender actually needs to know.</p> <h2 id="5-the-name-service-race-retrying-is-not-waiting">5. The name-service race: retrying is not waiting</h2> <p>The first handshake implementation still failed intermittently during a cold boot. Linux reported that the RPMsg host was online, but it never created the named channels. Endpoint 1 did not appear, so Linux never had a device on which to send the ready message.</p> <p>The problem was in endpoint announcement. The helper attempted <code class="language-plaintext highlighter-rouge">rpmsg_ns_announce()</code> repeatedly while it returned <code class="language-plaintext highlighter-rouge">RL_NOT_READY</code>. It performed 65,535 tight retries, which sounds generous. In practice, all those iterations could finish before Linux brought up the remote virtio/RPMsg link.</p> <p>This is a useful embedded-systems lesson:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>many immediate retries != waiting for a state transition
</code></pre></div></div> <p>The fix was to wait on the condition represented by the transport itself:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">rpmsg_lite_wait_for_link_up</span><span class="p">(</span><span class="n">rpmsg_instance</span><span class="p">);</span>

<span class="n">ret</span> <span class="o">=</span> <span class="n">rpmsg_ns_announce</span><span class="p">(</span><span class="n">rpmsg_instance</span><span class="p">,</span>
                        <span class="n">rpmsg_endpoint</span><span class="p">,</span>
                        <span class="n">endpoint_name</span><span class="p">,</span>
                        <span class="mi">0</span><span class="p">);</span>
</code></pre></div></div> <p>After the link-up wait returns, one announcement is enough. Linux then creates the <code class="language-plaintext highlighter-rouge">ingenic_rpmsg</code> channels for endpoints 1, 2, and 3 consistently.</p> <p>This race was easy to misdiagnose because the remote processor itself was already shown as <code class="language-plaintext highlighter-rouge">running</code>. That state only means the firmware is executing. It does not prove that the RPMsg link is up, name service completed, a Linux driver bound, or the log handshake succeeded.</p> <h2 id="6-keeping-the-linux-receive-callback-short">6. Keeping the Linux receive callback short</h2> <p>On Linux, I extended the existing Ingenic RPMsg driver so that one configured endpoint is consumed by the kernel log relay. Other endpoints retain the original character-device behavior.</p> <p>The receive callback executes in a context where blocking console work is undesirable. It therefore performs only bounded operations:</p> <ol> <li>Allocate an <code class="language-plaintext highlighter-rouge">sk_buff</code> with <code class="language-plaintext highlighter-rouge">GFP_ATOMIC</code>.</li> <li>Copy the incoming RPMsg payload.</li> <li>Append it to a spinlock-protected queue.</li> <li>Schedule a work item.</li> <li>Return.</li> </ol> <p>The worker drains the queue later and emits the text with <code class="language-plaintext highlighter-rouge">pr_info()</code>.</p> <p>I also bounded both memory and output behavior:</p> <ul> <li>The queue holds at most 128 RPMsg log messages.</li> <li>A rendered line is limited to 512 bytes.</li> <li>Carriage returns are removed.</li> <li>Unexpected control characters become <code class="language-plaintext highlighter-rouge">.</code> instead of reaching the terminal.</li> <li>Overflow increments a drop counter and produces a warning.</li> </ul> <p>Every relayed line receives an explicit prefix:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[RISCV] riscv start
[RISCV] RISC-V printer log relay ready
</code></pre></div></div> <p>Linux’s own messages keep their normal format. The prefix makes the origin visible even though both cores now share the same final UART.</p> <p>Endpoint 1 becomes a single-consumer channel once the kernel relay is enabled, so userspace opening that character device receives <code class="language-plaintext highlighter-rouge">-EBUSY</code>. This prevents a userspace reader and the kernel worker from competing for the same log records.</p> <h2 id="7-boot-integration-matters-too">7. Boot integration matters too</h2> <p>On this board, U-Boot may start an RISC-V image before Linux boots. The Linux remoteproc state can therefore already say <code class="language-plaintext highlighter-rouge">running</code>, and the firmware name can already be <code class="language-plaintext highlighter-rouge">console.elf</code>.</p> <p>That still does not guarantee that the running bytes match the firmware installed in the current root filesystem. An older image with the same filename may lack the ready-handshake implementation and wait forever or lose early logs.</p> <p>The init script consequently reloads <code class="language-plaintext highlighter-rouge">console.elf</code> once per Linux boot:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>first start in this Linux boot
    -&gt; stop an already-running image if necessary
    -&gt; select console.elf
    -&gt; start remoteproc
    -&gt; create a volatile marker under /run

later duplicate start
    -&gt; firmware is running, endpoint exists, marker exists
    -&gt; do nothing
</code></pre></div></div> <p>The marker prevents unnecessary resets during the same boot, while its location under <code class="language-plaintext highlighter-rouge">/run</code> makes it disappear on reboot. It records that the Linux init script performed its reload; it is not a substitute for the RPMsg ready handshake.</p> <p>This small distinction is important. A process marker, remoteproc state, link state, channel state, and application readiness are five different facts.</p> <h2 id="8-verifying-the-complete-chain">8. Verifying the complete chain</h2> <p>I tested the feature as a chain of observable states rather than relying on one “running” flag.</p> <p>First, confirm that the intended firmware is running:</p> <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> /sys/class/remoteproc/remoteproc0/firmware
<span class="nb">cat</span> /sys/class/remoteproc/remoteproc0/state
</code></pre></div></div> <p>Expected values are <code class="language-plaintext highlighter-rouge">console.elf</code> and <code class="language-plaintext highlighter-rouge">running</code>.</p> <p>Next, confirm that name service produced the endpoints:</p> <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> <span class="nt">-l</span> /sys/class/ingenic_rpmsg/
</code></pre></div></div> <p>The result should include <code class="language-plaintext highlighter-rouge">rpmsg-1</code>, <code class="language-plaintext highlighter-rouge">rpmsg-2</code>, and <code class="language-plaintext highlighter-rouge">rpmsg-3</code>. Endpoint 1 proves that Linux saw the log-channel announcement.</p> <p>Finally, inspect the kernel ring buffer:</p> <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dmesg | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'forwarding RISC-V|ready handshake|\[RISCV\]'</span>
</code></pre></div></div> <p>A successful cold boot contains all three stages:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>forwarding RISC-V endpoint 1 to kernel console
RISC-V log ready handshake sent
[RISCV] riscv start
[RISCV] RISC-V printer log relay ready
</code></pre></div></div> <p>The BusyBox version of <code class="language-plaintext highlighter-rouge">dmesg</code> on this target does not support <code class="language-plaintext highlighter-rouge">dmesg -w</code>, so repeated snapshots or the serial console itself are more portable ways to watch new output.</p> <p>For an active test, any RISC-V task can call <code class="language-plaintext highlighter-rouge">printf("printer relay test\n")</code>. Seeing <code class="language-plaintext highlighter-rouge">[RISCV] printer relay test</code> in the Linux log confirms the full path from the C library, through the FreeRTOS buffer and RPMsg, to the Linux worker and UART.</p> <h2 id="9-what-this-design-taught-me">9. What this design taught me</h2> <p>The final code is relatively small, but the design carries several reusable lessons.</p> <p><strong>Prefer ownership over distributed locking.</strong> When two processors want one peripheral, choose one owner and communicate with it. The resulting failure modes are much easier to reason about.</p> <p><strong>Readiness is protocol state.</strong> Processor running, link up, channel announced, callback installed, and application ready are not synonyms. If a sender depends on one of them, represent it explicitly.</p> <p><strong>Retry based on time or state, not iteration count.</strong> A huge tight loop may take less time than the device on the other side needs for one scheduling interval.</p> <p><strong>Keep transport callbacks bounded.</strong> Queue the data in the callback and do formatting, console output, and other potentially slow work in task or process context.</p> <p><strong>Buffer at asynchronous boundaries.</strong> The small-core ring buffer protects early logs and decouples producers from link timing. The Linux queue decouples RPMsg delivery from the console.</p> <p><strong>Make observability part of the design.</strong> The <code class="language-plaintext highlighter-rouge">[RISCV]</code> prefix, ready-handshake messages, endpoint sysfs entries, and drop warnings turned a multi-stage boot sequence into something that can be inspected one invariant at a time.</p> <p>The most important outcome is not merely that two cores can print. It is that there is now one deterministic logging path, one UART owner, and a startup protocol that explains exactly when logs are safe to send.</p>]]></content><author><name></name></author><category term="Technical Notes"/><category term="Embedded Linux"/><category term="RISC-V"/><category term="RPMsg"/><category term="Linux Kernel"/><summary type="html"><![CDATA[How I replaced cross-core UART contention with a buffered RPMsg log relay, a host-ready handshake, and a race-free startup sequence.]]></summary></entry><entry><title type="html">Designing Reliable Live Data Acquisition and File Synchronization over BLE</title><link href="https://alexjin520.github.io/blog/2026/designing-reliable-live-data-acquisition-and-file-synchronization-over-ble/" rel="alternate" type="text/html" title="Designing Reliable Live Data Acquisition and File Synchronization over BLE"/><published>2026-07-28T07:00:00+00:00</published><updated>2026-07-28T07:00:00+00:00</updated><id>https://alexjin520.github.io/blog/2026/designing-reliable-live-data-acquisition-and-file-synchronization-over-ble</id><content type="html" xml:base="https://alexjin520.github.io/blog/2026/designing-reliable-live-data-acquisition-and-file-synchronization-over-ble/"><![CDATA[<p>I recently worked on an embedded physiological-sensing system with a deceptively simple requirement:</p> <blockquote> <p>Show live waveforms on a phone while recording, download the complete recording at the same time, and deliver whatever remains after the user presses Stop.</p> </blockquote> <p>The device already supported the two endpoints independently. It could stream samples for a live chart, and it could generate a binary file and transfer that file later. Combining them changed the problem.</p> <p>The file was no longer static while it was being downloaded. The producer could append data faster than BLE could transmit it, and stopping acquisition did not mean that the consumer had received the end of the file. A correct design therefore had to coordinate three timelines:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sensor acquisition:  produces new samples
file writer:          appends encoded records
BLE synchronizer:     sends and acknowledges file bytes
</code></pre></div></div> <p>This article explains the mental model and protocol that made the system reliable.</p> <h2 id="1-live-preview-and-complete-delivery-are-different-products">1. Live preview and complete delivery are different products</h2> <p>It is tempting to treat every byte sent over BLE as one stream, but a live waveform and a complete recording have different contracts.</p> <p>A <strong>live preview</strong> is optimized for immediacy:</p> <ul> <li>New samples matter more than old samples.</li> <li>A bounded queue is necessary.</li> <li>Downsampling or compact encoding may be acceptable.</li> <li>If the phone falls behind, dropping stale preview data can be better than increasing latency forever.</li> </ul> <p>A <strong>recording transfer</strong> is optimized for completeness:</p> <ul> <li>Every byte must arrive in order.</li> <li>Missing data must be retransmitted.</li> <li>Progress must survive temporary stalls or reconnects.</li> <li>Completion must be verified against a stable final file.</li> </ul> <p>These paths may share a BLE connection, but they should not share semantics.</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         ┌─&gt; live preview queue ─&gt; waveform notifications
sensor nodes ─&gt; acquire ─┤
                         └─&gt; append-only file ──&gt; file synchronization
</code></pre></div></div> <p>The live path answers, “What is happening now?” The file path answers, “Can I reconstruct the entire session exactly?”</p> <h2 id="2-a-growing-file-does-not-have-a-normal-eof">2. A growing file does not have a normal EOF</h2> <p>Downloading a completed file is straightforward:</p> <ol> <li>Read its size.</li> <li>Send chunks until the offset reaches that size.</li> <li>Verify the result.</li> </ol> <p>A growing file breaks the first assumption. Reaching the current end of the file only means that the reader has caught up <strong>for now</strong>. More data may appear on the next acquisition cycle.</p> <p>The transfer loop therefore needs to distinguish two cases:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>reader offset == currently available bytes
    + writer still active   -&gt; wait for growth and continue
    + writer finalized      -&gt; this is the real end of the file
</code></pre></div></div> <p>I modeled the file as an append-only log. The writer owns the production offset, while the synchronizer owns the acknowledged offset:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0                                                        produced_offset
|================ acknowledged ================|---- pending ----|
                                                ^
                                                acknowledged_offset
</code></pre></div></div> <p>The sender never needs to load the entire recording into memory. It reads a bounded chunk from the acknowledged or next-send offset, transmits it, and retains only enough state to retry unacknowledged data.</p> <p>If buffered standard I/O is used, the writer must flush application buffers before expecting another file descriptor to observe newly appended bytes. <code class="language-plaintext highlighter-rouge">fsync()</code> is a separate durability decision: it protects against storage or power failure, but it should not be confused with protocol-level delivery.</p> <h2 id="3-use-offsets-as-the-source-of-truth">3. Use offsets as the source of truth</h2> <p>Packets can be duplicated, delayed, or lost. A transfer protocol becomes much easier to reason about when progress is represented by a byte offset rather than by a count of packets sent.</p> <p>A data message can contain:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>session_id | file_offset | payload_length | payload
</code></pre></div></div> <p>The phone responds with a cumulative acknowledgment:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ACK next_expected_offset
</code></pre></div></div> <p>If the phone acknowledges offset <code class="language-plaintext highlighter-rouge">65536</code>, it is confirming that every byte before <code class="language-plaintext highlighter-rouge">65536</code> is present. The device can discard any buffered chunks fully below that offset.</p> <p>Cumulative acknowledgments provide useful properties:</p> <ul> <li>Repeating a chunk is harmless.</li> <li>Repeating an ACK is harmless.</li> <li>Lost ACKs do not corrupt progress.</li> <li>Reconnection can resume from a confirmed offset.</li> <li>Logs describe transfer state in bytes, independent of BLE packet sizing.</li> </ul> <p>The key invariant is:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0 &lt;= acknowledged_offset &lt;= sent_offset &lt;= produced_offset
</code></pre></div></div> <p>Violating this invariant indicates a state-machine or bookkeeping bug, not a radio-quality problem.</p> <h2 id="4-backpressure-is-unavoidable">4. Backpressure is unavoidable</h2> <p>Suppose the recording grows at rate <code class="language-plaintext highlighter-rouge">R_p</code> and BLE delivers file data at net rate <code class="language-plaintext highlighter-rouge">R_t</code>.</p> <p>If:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>R_t &gt;= R_p
</code></pre></div></div> <p>the synchronizer can eventually catch up while acquisition is still active.</p> <p>If:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>R_t &lt; R_p
</code></pre></div></div> <p>the unsent backlog must grow. No retry strategy or thread optimization can change that conservation law.</p> <p>After an acquisition lasting <code class="language-plaintext highlighter-rouge">T</code>, the approximate backlog is:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>B ~= max(0, R_p - R_t) * T
</code></pre></div></div> <p>and the minimum drain time after Stop is:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>T_drain ~= B / R_t
</code></pre></div></div> <p>Protocol overhead, retransmissions, connection intervals, and flash-read contention make the real time longer.</p> <p>This observation changed the user-interface contract. “Stop acquisition” can happen immediately, but “file synchronization complete” may happen later. Those are separate events and should be displayed separately.</p> <p>It also suggests several engineering choices:</p> <ul> <li>Keep preview traffic bounded so it cannot starve file transfer.</li> <li>Reduce redundant fields in the on-disk format.</li> <li>Batch payloads up to an effective BLE chunk size.</li> <li>Avoid per-packet logging on the critical path.</li> <li>Report both acquisition state and synchronization progress to the phone.</li> </ul> <h2 id="5-stop-is-a-transition-not-global-cleanup">5. Stop is a transition, not global cleanup</h2> <p>The first failing design treated Stop as the end of the whole session:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Stop pressed
    -&gt; stop sensors
    -&gt; close the file
    -&gt; cancel transfer callbacks
    -&gt; release BLE/session state
</code></pre></div></div> <p>That sequence is attractive because it looks like simple cleanup. It is also wrong when file delivery is still active.</p> <p>The acquisition producer should stop first, but the transfer consumer must remain alive until it has drained the finalized tail. I separated the lifecycle into explicit states:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IDLE
  |
  v
COLLECTING_AND_SYNCING
  |
  | Stop requested
  v
STOPPING_PRODUCER
  |
  | writer flushed and final size captured
  v
DRAINING_TAIL
  |
  | acknowledged_offset == final_size
  v
VERIFYING
  |
  | size and CRC32 match
  v
COMPLETE
</code></pre></div></div> <p>The important boundary is between <code class="language-plaintext highlighter-rouge">STOPPING_PRODUCER</code> and <code class="language-plaintext highlighter-rouge">DRAINING_TAIL</code>. Only after the writer has finished appending can the system capture a stable <code class="language-plaintext highlighter-rouge">final_size</code> and final CRC32.</p> <p>In simplified pseudocode:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">on_stop_requested</span><span class="p">(</span><span class="k">struct</span> <span class="n">session</span> <span class="o">*</span><span class="n">s</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">s</span><span class="o">-&gt;</span><span class="n">acquisition_state</span> <span class="o">=</span> <span class="n">ACQUISITION_STOPPING</span><span class="p">;</span>
    <span class="n">stop_sensor_requests_async</span><span class="p">(</span><span class="n">s</span><span class="p">);</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="nf">on_writer_finalized</span><span class="p">(</span><span class="k">struct</span> <span class="n">session</span> <span class="o">*</span><span class="n">s</span><span class="p">,</span>
                         <span class="kt">uint64_t</span> <span class="n">final_size</span><span class="p">,</span>
                         <span class="kt">uint32_t</span> <span class="n">final_crc32</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">s</span><span class="o">-&gt;</span><span class="n">final_size</span> <span class="o">=</span> <span class="n">final_size</span><span class="p">;</span>
    <span class="n">s</span><span class="o">-&gt;</span><span class="n">final_crc32</span> <span class="o">=</span> <span class="n">final_crc32</span><span class="p">;</span>
    <span class="n">s</span><span class="o">-&gt;</span><span class="n">transfer_state</span> <span class="o">=</span> <span class="n">TRANSFER_DRAINING</span><span class="p">;</span>
    <span class="n">pump_transfer</span><span class="p">(</span><span class="n">s</span><span class="p">);</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="nf">on_cumulative_ack</span><span class="p">(</span><span class="k">struct</span> <span class="n">session</span> <span class="o">*</span><span class="n">s</span><span class="p">,</span> <span class="kt">uint64_t</span> <span class="n">next_offset</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">advance_acknowledged_offset</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">next_offset</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">s</span><span class="o">-&gt;</span><span class="n">writer_finalized</span> <span class="o">&amp;&amp;</span>
        <span class="n">s</span><span class="o">-&gt;</span><span class="n">acknowledged_offset</span> <span class="o">==</span> <span class="n">s</span><span class="o">-&gt;</span><span class="n">final_size</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">send_completion_metadata</span><span class="p">(</span><span class="n">s</span><span class="p">);</span>
        <span class="n">s</span><span class="o">-&gt;</span><span class="n">transfer_state</span> <span class="o">=</span> <span class="n">TRANSFER_VERIFYING</span><span class="p">;</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">pump_transfer</span><span class="p">(</span><span class="n">s</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p>Nothing in the Stop handler waits synchronously for BLE. The event loop remains free to process acknowledgments, retransmission timers, disconnects, and finalization callbacks.</p> <h2 id="6-asynchronous-shutdown-is-a-lifetime-problem">6. Asynchronous shutdown is a lifetime problem</h2> <p>The most difficult failure appeared only after Stop. Normal streaming could run for a long time, yet teardown occasionally stalled or disconnected.</p> <p>The underlying lesson was broader than BLE: canceling an asynchronous operation does not necessarily erase every callback that has already been queued. A callback may still hold a pointer to session state after cleanup has started.</p> <p>A dangerous sequence looks like this:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>callback becomes ready
        |
Stop begins teardown
        |
session resources are released
        |
previously ready callback executes
        |
stale state is accessed
</code></pre></div></div> <p>The solution is to make ownership and callback lifetime explicit:</p> <ul> <li>Mark the session as closing before cancellation.</li> <li>Make callbacks check the current lifecycle state.</li> <li>Stop registering new work once closing begins.</li> <li>Cancel or close event-loop handles through their asynchronous APIs.</li> <li>Release the session only after all owned handles report completion.</li> <li>Keep transfer state alive during the drain phase.</li> <li>Make finalization idempotent so repeated errors or disconnect events cannot free the same resource twice.</li> </ul> <p>An event-driven program often needs two forms of completion:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>logical completion:  the operation should no longer produce new work
physical completion: no callback can reference its resources again
</code></pre></div></div> <p>Confusing them is a common source of stop-path bugs.</p> <h2 id="7-completion-requires-verification">7. Completion requires verification</h2> <p>Reaching <code class="language-plaintext highlighter-rouge">final_size</code> on the sender is not enough. The phone must confirm that its local file has the same length and content.</p> <p>The completion exchange can include:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>session_id | final_size | final_crc32
</code></pre></div></div> <p>The phone then:</p> <ol> <li>Flushes and closes its local file.</li> <li>Checks the local size.</li> <li>Computes CRC32 over the completed file.</li> <li>Reports success or requests recovery from a known offset.</li> </ol> <p>CRC32 is not a cryptographic integrity mechanism, but it is appropriate for detecting accidental corruption in a transport and storage pipeline. The important point is that verification covers the completed artifact, not only individual BLE messages.</p> <p>This produces a clear definition of success:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>phone_size == device_final_size
    &amp;&amp;
phone_crc32 == device_final_crc32
</code></pre></div></div> <p>Only then should the UI display “synchronized.”</p> <h2 id="8-logging-the-state-not-just-the-error">8. Logging the state, not just the error</h2> <p>Timing-dependent failures are hard to diagnose from messages such as “BLE disconnected” or “timeout.” Those are consequences, not causes.</p> <p>I found it more useful to log a compact snapshot whenever the lifecycle changes:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>session=17
acquisition=stopped
writer=finalized
transfer=draining
produced=1824768
sent=491520
acked=475136
pending_chunks=4
connected=true
</code></pre></div></div> <p>The most valuable fields were:</p> <ul> <li>Session identifier</li> <li>Acquisition state</li> <li>Writer state</li> <li>Transfer state</li> <li>Produced, sent, and acknowledged offsets</li> <li>Stable final size, when available</li> <li>Number of in-flight chunks</li> <li>Retry count</li> <li>Connection and callback-lifetime state</li> </ul> <p>With those values, “the download froze” becomes a narrower question:</p> <ul> <li>Did the writer finalize?</li> <li>Did the produced offset stop changing?</li> <li>Is the sender waiting for an ACK?</li> <li>Did the phone acknowledge beyond what was sent?</li> <li>Is a retry timer still registered?</li> <li>Was the transfer session released too early?</li> </ul> <h2 id="9-test-the-transitions-that-normal-use-hides">9. Test the transitions that normal use hides</h2> <p>The happy path is not enough for a synchronization protocol. I used transition-focused tests:</p> <ul> <li>Stop immediately after starting.</li> <li>Stop while a chunk is in flight.</li> <li>Stop when the reader is exactly at the current file end.</li> <li>Slow acknowledgments until the backlog becomes large.</li> <li>Drop an ACK and verify idempotent retransmission.</li> <li>Disconnect during collection and resume from the last confirmed offset.</li> <li>Disconnect during the drain phase.</li> <li>Repeat Stop and disconnect notifications.</li> <li>Compare device and phone CRC32 values after every completed run.</li> </ul> <p>Randomizing the Stop time was especially effective because it exercised different relationships between file writes, BLE notifications, timers, and callbacks.</p> <p>For each test, the same invariants should hold:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>acknowledged_offset never moves backward
acknowledged_offset never exceeds sent_offset
sent_offset never exceeds available file data
final_size becomes immutable after writer finalization
session memory outlives every callback that can reference it
COMPLETE is reached only after end-to-end verification
</code></pre></div></div> <h2 id="10-what-i-learned">10. What I learned</h2> <p>The final architecture was not one clever BLE optimization. It was a separation of responsibilities:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>acquisition controls when data production stops
the writer defines when the file becomes final
the transfer state machine controls reliable delivery
the phone verifies the completed artifact
the event loop coordinates transitions without blocking
</code></pre></div></div> <p>The most important lessons were:</p> <ol> <li>Live display and lossless recording need different policies.</li> <li>Temporary EOF is not completion when a file is still growing.</li> <li>Byte offsets and cumulative acknowledgments make retries idempotent.</li> <li>If production is faster than transport, Stop must be followed by a drain phase.</li> <li>Asynchronous shutdown is fundamentally about ownership and callback lifetime.</li> <li>Completion means that both endpoints agree on size and integrity.</li> <li>State snapshots and invariants are more useful than generic timeout logs.</li> </ol> <p>What looked at first like a Bluetooth transfer feature became a systems problem spanning acquisition timing, storage visibility, protocol design, event-driven control flow, and resource lifetime. That is exactly why I found it valuable: reliable embedded systems are built at the boundaries between components, where individually reasonable behaviors can combine into an incorrect whole.</p> <h2 id="related-notes">Related notes</h2> <ul> <li><a href="/projects/fnirs-acquisition/">Embedded fNIRS Acquisition System</a></li> <li><a href="/blog/2026/understanding-event-driven-io-with-libevent-and-libuv/">Understanding Event-Driven I/O with libevent and libuv</a></li> <li><a href="/blog/2026/crossing-thread-boundaries-in-libevent/">Crossing Thread Boundaries Safely in libevent</a></li> </ul>]]></content><author><name></name></author><category term="Technical Notes"/><category term="Bluetooth Low Energy"/><category term="Embedded Linux"/><category term="Data Integrity"/><category term="Systems Design"/><summary type="html"><![CDATA[How I separated live waveform preview from lossless file delivery, tailed a growing recording, and made Stop mean drain and verify instead of disconnect.]]></summary></entry><entry><title type="html">Crossing Thread Boundaries Safely in libevent</title><link href="https://alexjin520.github.io/blog/2026/crossing-thread-boundaries-in-libevent/" rel="alternate" type="text/html" title="Crossing Thread Boundaries Safely in libevent"/><published>2026-07-23T07:12:00+00:00</published><updated>2026-07-23T07:12:00+00:00</updated><id>https://alexjin520.github.io/blog/2026/crossing-thread-boundaries-in-libevent</id><content type="html" xml:base="https://alexjin520.github.io/blog/2026/crossing-thread-boundaries-in-libevent/"><![CDATA[<p>In my previous article, I described the basic event-loop model: register file descriptors, keep callbacks short, and let the kernel wake the application when work is ready.</p> <p>That model becomes more interesting when a library already owns another thread and another main loop.</p> <p>I encountered exactly this problem while integrating a Bluetooth GATT server into an embedded fNIRS acquisition program. The application used <strong>libevent</strong> for CAN, UART, timers, and sampling state machines, while the GATT server used the <strong>BlueZ</strong> mainloop in a separate thread.</p> <p>Both sides needed to access the same device state:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GATT thread                              libevent main thread
-----------                              --------------------
receives a phone command                 owns CAN communication
parses the GATT payload                  owns sampling state
must return a GATT response              owns most fNIRS business state
</code></pre></div></div> <p>Calling the business functions directly from the GATT thread would have reintroduced the races that the event-driven refactor was intended to remove. The solution was a small cross-thread dispatch layer built from three pieces:</p> <ul> <li>a mutex-protected job queue,</li> <li>an <code class="language-plaintext highlighter-rouge">eventfd</code> that wakes the libevent loop,</li> <li>and one <code class="language-plaintext highlighter-rouge">socketpair</code> per request for returning the result.</li> </ul> <p>This article explains the design, the mistakes made along the way, and the boundary between safe cross-thread dispatch and a truly asynchronous protocol.</p> <h2 id="1-the-thread-ownership-rule">1. The thread-ownership rule</h2> <p>The most important design decision was not an API. It was an ownership rule:</p> <blockquote> <p>CAN state, sampling state, and fNIRS business state are modified on the libevent main thread.</p> </blockquote> <p>The GATT thread may receive and decode a command, but it does not execute the command against shared device state. Instead, it describes the requested work and sends that description to the owner thread.</p> <p>For example, a request can be represented as a job:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">ble_job</span> <span class="p">{</span>
    <span class="kt">uint8_t</span> <span class="n">command</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">payload_length</span><span class="p">;</span>
    <span class="kt">uint8_t</span> <span class="n">payload</span><span class="p">[</span><span class="mi">512</span><span class="p">];</span>
    <span class="kt">int</span> <span class="n">response_fd</span><span class="p">;</span>
    <span class="k">struct</span> <span class="n">ble_job</span> <span class="o">*</span><span class="n">next</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div> <p>Copying the payload into the job is deliberate. A GATT callback’s input buffer may no longer be valid when the main thread eventually processes the request. The queued job therefore owns all request data needed after the callback returns.</p> <p>The complete request path looks like this:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>phone writes a GATT characteristic
                |
                v
BlueZ invokes a callback on the GATT thread
                |
                v
create job + create socketpair
                |
                v
lock queue -&gt; enqueue job -&gt; unlock queue
                |
                v
write to eventfd
                |
                v
libevent wakes and runs the queue callback
                |
                v
main thread executes the fNIRS command
                |
                v
write response through socketpair
                |
                v
GATT thread returns the result to the phone
</code></pre></div></div> <p>Only the short queue operation is protected by a mutex. The business command itself runs after the job has been removed from the queue, so a slow command does not hold the queue lock.</p> <h2 id="2-why-a-queue-is-not-enough">2. Why a queue is not enough</h2> <p>A worker thread can safely append an item to a queue, but the event loop does not automatically know that the queue changed.</p> <p>The main loop might currently be sleeping inside <code class="language-plaintext highlighter-rouge">epoll_wait()</code>, waiting for CAN, UART, or timer events. It needs a file descriptor that becomes readable when another thread submits work.</p> <p>Linux <code class="language-plaintext highlighter-rouge">eventfd</code> is a good fit:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="n">notify_fd</span> <span class="o">=</span> <span class="n">eventfd</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">EFD_NONBLOCK</span> <span class="o">|</span> <span class="n">EFD_CLOEXEC</span><span class="p">);</span>

<span class="k">struct</span> <span class="n">event</span> <span class="o">*</span><span class="n">notify_event</span> <span class="o">=</span> <span class="n">event_new</span><span class="p">(</span>
    <span class="n">base</span><span class="p">,</span>
    <span class="n">notify_fd</span><span class="p">,</span>
    <span class="n">EV_READ</span> <span class="o">|</span> <span class="n">EV_PERSIST</span><span class="p">,</span>
    <span class="n">run_queued_jobs</span><span class="p">,</span>
    <span class="nb">NULL</span>
<span class="p">);</span>

<span class="n">event_add</span><span class="p">(</span><span class="n">notify_event</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">);</span>
</code></pre></div></div> <p>The GATT thread wakes the loop by writing an unsigned 64-bit value:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">uint64_t</span> <span class="n">one</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
<span class="n">write</span><span class="p">(</span><span class="n">notify_fd</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">one</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">one</span><span class="p">));</span>
</code></pre></div></div> <p>The descriptor becomes readable, so libevent schedules <code class="language-plaintext highlighter-rouge">run_queued_jobs()</code> on the main thread. The callback drains the counter and then consumes the queue:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">run_queued_jobs</span><span class="p">(</span><span class="n">evutil_socket_t</span> <span class="n">fd</span><span class="p">,</span> <span class="kt">short</span> <span class="n">events</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">arg</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint64_t</span> <span class="n">value</span><span class="p">;</span>

    <span class="k">while</span> <span class="p">(</span><span class="n">read</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">value</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">value</span><span class="p">))</span> <span class="o">==</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">value</span><span class="p">))</span>
        <span class="p">;</span>

    <span class="k">for</span> <span class="p">(;;)</span> <span class="p">{</span>
        <span class="k">struct</span> <span class="n">ble_job</span> <span class="o">*</span><span class="n">job</span> <span class="o">=</span> <span class="n">dequeue_job</span><span class="p">();</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">job</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span>
            <span class="k">break</span><span class="p">;</span>

        <span class="n">process_ble_job</span><span class="p">(</span><span class="n">job</span><span class="p">);</span>
        <span class="n">free</span><span class="p">(</span><span class="n">job</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">eventfd</code> stores a counter rather than a stream of individual messages. That is fine because the queue contains the actual jobs. The notification only means:</p> <blockquote> <p>The queue may contain work; check it.</p> </blockquote> <p>This separation is useful. The queue carries data, while <code class="language-plaintext highlighter-rouge">eventfd</code> carries readiness.</p> <h2 id="3-returning-a-result-with-socketpair">3. Returning a result with socketpair</h2> <p>Waking the main loop solves only half of the problem. A GATT request often needs a status code and a response payload before the Bluetooth callback can finish.</p> <p>For each request, the dispatcher creates a local Unix-domain socket pair:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="n">pair</span><span class="p">[</span><span class="mi">2</span><span class="p">];</span>

<span class="k">if</span> <span class="p">(</span><span class="n">socketpair</span><span class="p">(</span><span class="n">AF_UNIX</span><span class="p">,</span> <span class="n">SOCK_STREAM</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">pair</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span>
    <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
</code></pre></div></div> <p>The two descriptors are connected:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pair[0] &lt;==============================&gt; pair[1]
job/main-thread side                     GATT-thread side
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">pair[0]</code> is stored in the job. The GATT thread keeps <code class="language-plaintext highlighter-rouge">pair[1]</code>, enqueues the job, wakes the main loop, and then waits for a bounded amount of time:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">pollfd</span> <span class="n">pfd</span> <span class="o">=</span> <span class="p">{</span>
    <span class="p">.</span><span class="n">fd</span> <span class="o">=</span> <span class="n">pair</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span>
    <span class="p">.</span><span class="n">events</span> <span class="o">=</span> <span class="n">POLLIN</span><span class="p">,</span>
<span class="p">};</span>

<span class="k">if</span> <span class="p">(</span><span class="n">poll</span><span class="p">(</span><span class="o">&amp;</span><span class="n">pfd</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="n">timeout_ms</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="cm">/* cancel or mark the operation as failed */</span>
    <span class="n">close</span><span class="p">(</span><span class="n">pair</span><span class="p">[</span><span class="mi">1</span><span class="p">]);</span>
    <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>

<span class="n">read_response</span><span class="p">(</span><span class="n">pair</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="o">&amp;</span><span class="n">status</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">response_length</span><span class="p">);</span>
</code></pre></div></div> <p>The main thread executes the command and writes a framed response to <code class="language-plaintext highlighter-rouge">pair[0]</code>:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+---------+---------+---------+--------+------------+---------+
| magic 0 | magic 1 | command | status | length (2) | payload |
+---------+---------+---------+--------+------------+---------+
</code></pre></div></div> <p>Framing matters because <code class="language-plaintext highlighter-rouge">SOCK_STREAM</code> preserves byte order but not application message boundaries. The receiver first reads and validates the fixed-size header, obtains the payload length, and then reads that many bytes.</p> <p>The response descriptor also gives each request its own completion channel. There is no need to match a result from one shared response queue to the original GATT request.</p> <h2 id="4-why-use-both-eventfd-and-socketpair">4. Why use both eventfd and socketpair?</h2> <p>At first, using two IPC mechanisms inside one process can look unnecessary. They serve different directions and different lifetimes:</p> <table> <thead> <tr> <th>Mechanism</th> <th>Direction</th> <th>Lifetime</th> <th>Purpose</th> </tr> </thead> <tbody> <tr> <td>protected queue</td> <td>GATT → main</td> <td>process lifetime</td> <td>stores jobs</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">eventfd</code></td> <td>GATT → main</td> <td>process lifetime</td> <td>wakes libevent</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">socketpair</code></td> <td>main → GATT</td> <td>one request</td> <td>returns status and payload</td> </tr> </tbody> </table> <p>The persistent <code class="language-plaintext highlighter-rouge">eventfd</code> avoids creating a new libevent registration for every request. The per-request socket pair makes response ownership and cleanup explicit.</p> <p>An <code class="language-plaintext highlighter-rouge">eventfd</code> could also be used for completion if the response data lived in shared memory. A condition variable could wake the waiting thread. A future or promise abstraction could wrap the same idea. I used <code class="language-plaintext highlighter-rouge">socketpair</code> because the response was already naturally expressed as bytes and could be handled with normal <code class="language-plaintext highlighter-rouge">poll()</code>, <code class="language-plaintext highlighter-rouge">read()</code>, and <code class="language-plaintext highlighter-rouge">write()</code> calls.</p> <h2 id="5-ordering-initialization-correctly">5. Ordering initialization correctly</h2> <p>The dispatch layer must be ready before the GATT server can accept a request.</p> <p>The startup order is therefore:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create libevent base
        |
initialize job queue, mutex, and eventfd
        |
register eventfd with libevent
        |
start the GATT thread
        |
enter event_base_dispatch()
</code></pre></div></div> <p>Starting GATT first creates a race: a phone may write a characteristic while the event base or notification descriptor is still uninitialized.</p> <p>Shutdown needs the reverse discipline:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stop accepting GATT requests
        |
stop and join the GATT thread
        |
remove the eventfd event
        |
close descriptors and reject pending jobs
        |
destroy the mutex and event base
</code></pre></div></div> <p>Pending jobs need an explicit policy. Silently freeing a job while another thread waits on its response descriptor turns shutdown into a timeout. Closing or completing the response side lets the waiting thread fail promptly.</p> <h2 id="6-thread-checks-are-valuable-diagnostics">6. Thread checks are valuable diagnostics</h2> <p>The queue callback is intended to run only on the libevent thread. I recorded the main thread ID during initialization and checked it before processing jobs:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">pthread_equal</span><span class="p">(</span><span class="n">pthread_self</span><span class="p">(),</span> <span class="n">main_thread_id</span><span class="p">))</span> <span class="p">{</span>
    <span class="n">log_error</span><span class="p">(</span><span class="s">"job callback ran outside the main thread"</span><span class="p">);</span>
    <span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div> <p>This check does not create thread safety by itself. It makes a broken assumption visible.</p> <p>During development, it is easy to accidentally call an event-loop function from a foreign thread, or to process a job directly as a shortcut. A thread-ID assertion turns an intermittent race into a useful log message near the source of the problem.</p> <p>In the final implementation, simple commands may run directly when the dispatcher is already on the main thread. Commands whose completion depends on future timer callbacks cannot use that optimization, because waiting for their result on the same thread would deadlock the loop.</p> <h2 id="7-bounded-waiting-and-cancellation">7. Bounded waiting and cancellation</h2> <p>The GATT side still waits synchronously for the main-thread result. It must never wait forever.</p> <p>Different commands have different expected durations:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ordinary control command     -&gt; short timeout
stream startup               -&gt; longer timeout
node scan                    -&gt; longest timeout
</code></pre></div></div> <p>When a timeout occurs, the implementation records the command and descriptor in a trace file, cancels any active asynchronous operation, closes its response side, and reports failure to GATT.</p> <p>Useful trace messages include:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dispatch cmd=0x01
run_jobs_cb on main loop
process job cmd=0x01 fd=17
dispatch cmd=0x01 ok
</code></pre></div></div> <p>or, when something goes wrong:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>poll timeout fd=18 waited=12000ms
</code></pre></div></div> <p>Cross-thread systems are much easier to debug when a request has a visible path through enqueue, wake-up, execution, completion, and timeout.</p> <p>One detail deserves special care: after the waiting side times out and closes its descriptor, a late response must not terminate the process with <code class="language-plaintext highlighter-rouge">SIGPIPE</code>. Production code should define the late-completion behavior explicitly, for example by ignoring <code class="language-plaintext highlighter-rouge">SIGPIPE</code>, using a no-signal send option where available, and treating <code class="language-plaintext highlighter-rouge">EPIPE</code> as cancellation.</p> <h2 id="8-a-real-failure-the-scan-returned-0-of-12-nodes">8. A real failure: the scan returned 0 of 12 nodes</h2> <p>The hardest bug was not in the mutex, <code class="language-plaintext highlighter-rouge">eventfd</code>, or socket pair.</p> <p>A Bluetooth SCAN command reached the main thread correctly, and the GATT thread received a valid response. However, the result reported zero nodes even though all 12 CAN nodes were connected.</p> <p>The scan operation contained a loop that advanced the fNIRS discovery state. Because that loop ran inside a job callback, normal control had not yet returned to <code class="language-plaintext highlighter-rouge">event_base_dispatch()</code>. The CAN file descriptor could become readable, but its regular libevent callback did not get its normal opportunity to drain the frames.</p> <p>The first attempted fix pumped only the higher-level hub state:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>advance scan state
process already-decoded hub messages
</code></pre></div></div> <p>That was insufficient. The CAN frames were still waiting in the kernel receive queue.</p> <p>The working pump had to perform the complete path:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>drain readable CAN frames
        |
decode frames into hub messages
        |
advance the scan state
        |
run pending non-blocking events
</code></pre></div></div> <p>After the CAN drain was included, discovery returned all 12 nodes.</p> <p>This produced an important lesson:</p> <blockquote> <p>Calling business logic is not equivalent to servicing its I/O source.</p> </blockquote> <p>If a callback contains a nested wait or a long state transition, every dependency that would normally be serviced by the outer event loop must still make progress. The cleaner long-term solution is usually to split the operation into timer- and I/O-driven phases so that the callback returns. A nested pump can preserve legacy timing, but it increases reentrancy risk and should remain a carefully bounded exception.</p> <h2 id="9-this-is-dispatch-not-full-asynchrony">9. This is dispatch, not full asynchrony</h2> <p>It is tempting to call the entire design asynchronous because the request crosses threads through a queue. That would hide an important limitation.</p> <p>The main thread receives jobs asynchronously, but the GATT thread waits in <code class="language-plaintext highlighter-rouge">poll()</code> until a result arrives:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GATT thread: enqueue -------------------------- wait
                         main thread: execute ------- respond
</code></pre></div></div> <p>Therefore the current design is:</p> <ul> <li>asynchronous with respect to submitting work to the event loop,</li> <li>serialized with respect to shared fNIRS state,</li> <li>synchronous with respect to completing the GATT request.</li> </ul> <p>This is a reasonable compromise when the GATT API expects an immediate response and only one Bluetooth control request is normally in flight. It is not ideal if the GATT thread must remain responsive to many concurrent operations.</p> <p>A fully asynchronous protocol would acknowledge the command quickly, assign a request ID, and later deliver completion through a notification:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>phone sends command
        |
immediate "accepted" response + request ID
        |
main loop performs the operation
        |
GATT notification reports completion
</code></pre></div></div> <p>That design changes the phone protocol and introduces request tracking, cancellation, and reconnect behavior. The extra complexity is worthwhile only when the product requirements need it.</p> <h2 id="10-general-rules-i-would-reuse">10. General rules I would reuse</h2> <p>The implementation is specific to an embedded Bluetooth application, but the design rules apply more broadly:</p> <ol> <li>Give mutable state a clear owner thread.</li> <li>Send commands to the owner instead of sharing direct access.</li> <li>Copy request data whose original lifetime is uncertain.</li> <li>Hold the queue mutex only while changing the queue.</li> <li>Use a file descriptor to wake a file-descriptor-based event loop.</li> <li>Give each synchronous request an explicit response channel.</li> <li>Bound every cross-thread wait with a timeout.</li> <li>Define cancellation, late completion, and shutdown behavior.</li> <li>Verify callback thread identity during development.</li> <li>Do not hide blocking work inside an event-loop callback.</li> <li>If a nested pump is unavoidable, service the real I/O source, not only the business state above it.</li> <li>Describe hybrid designs honestly: safe dispatch and full asynchrony are not the same thing.</li> </ol> <h2 id="conclusion">Conclusion</h2> <p>Connecting two event systems is less about finding one magical API and more about preserving ownership.</p> <p>In this design, BlueZ owns the GATT thread, libevent owns the fNIRS business state, a protected queue transfers commands, <code class="language-plaintext highlighter-rouge">eventfd</code> wakes the owner, and <code class="language-plaintext highlighter-rouge">socketpair</code> carries each result back. The components are ordinary Linux primitives, but together they create a clear boundary between two concurrency domains.</p> <p>The most valuable outcome was not simply reducing data races. The request path became observable and explainable:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>receive -&gt; enqueue -&gt; wake -&gt; execute -&gt; respond
</code></pre></div></div> <p>When concurrency has a visible path and every piece of state has an owner, embedded software becomes much easier to reason about.</p>]]></content><author><name></name></author><category term="Technical Notes"/><category term="Linux"/><category term="libevent"/><category term="Bluetooth"/><category term="Embedded Systems"/><summary type="html"><![CDATA[How I connected a BlueZ GATT thread to a libevent main loop using an eventfd, a protected job queue, and a socketpair response channel.]]></summary></entry><entry><title type="html">Understanding Event-Driven I/O with libevent and libuv</title><link href="https://alexjin520.github.io/blog/2026/understanding-event-driven-io-with-libevent-and-libuv/" rel="alternate" type="text/html" title="Understanding Event-Driven I/O with libevent and libuv"/><published>2026-07-23T06:00:00+00:00</published><updated>2026-07-23T06:00:00+00:00</updated><id>https://alexjin520.github.io/blog/2026/understanding-event-driven-io-with-libevent-and-libuv</id><content type="html" xml:base="https://alexjin520.github.io/blog/2026/understanding-event-driven-io-with-libevent-and-libuv/"><![CDATA[<p>Over the past few days, I have been learning how <strong>libevent</strong> and <strong>libuv</strong> organize asynchronous I/O. The individual APIs are not the hardest part. The real challenge is building the right mental model:</p> <ul> <li>What exactly is a file descriptor?</li> <li>What does “non-blocking” mean?</li> <li>If an event loop has only one thread, how can it handle several devices?</li> <li>When multiple events arrive together, which callback runs?</li> <li>If threads can run in parallel, why use a single-threaded event loop at all?</li> </ul> <p>This article records the answers that finally made the model clear to me.</p> <h2 id="1-start-with-file-descriptors">1. Start with file descriptors</h2> <p>On Linux, a <strong>file descriptor</strong>, usually abbreviated as <strong>fd</strong>, is a small integer that identifies an open resource inside a process.</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fd 0  -&gt; standard input
fd 1  -&gt; standard output
fd 2  -&gt; standard error
fd 4  -&gt; a CAN socket
fd 5  -&gt; a UART device
fd 6  -&gt; a listening socket
</code></pre></div></div> <p>The descriptor is not the resource itself. It is a handle that the process uses when asking the kernel to operate on that resource:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">read</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="n">buffer</span><span class="p">,</span> <span class="n">size</span><span class="p">);</span>
<span class="n">write</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">length</span><span class="p">);</span>
<span class="n">close</span><span class="p">(</span><span class="n">fd</span><span class="p">);</span>
</code></pre></div></div> <p>This common interface is important because an event library can monitor many different resources in the same way. A TCP socket, a CAN socket, a pipe, a <code class="language-plaintext highlighter-rouge">timerfd</code>, and some device files can all participate in one event loop.</p> <h2 id="2-why-blocking-io-becomes-a-problem">2. Why blocking I/O becomes a problem</h2> <p>Consider a normal blocking read:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">read</span><span class="p">(</span><span class="n">can_fd</span><span class="p">,</span> <span class="n">buffer</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">buffer</span><span class="p">));</span>
</code></pre></div></div> <p>If no CAN frame is available, the calling thread waits. While it is waiting, that thread cannot process a UART message, a Bluetooth request, or an expired timer.</p> <p>A naive program might try to check every source repeatedly:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">while</span> <span class="p">(</span><span class="n">running</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">check_can</span><span class="p">();</span>
    <span class="n">check_uart</span><span class="p">();</span>
    <span class="n">check_bluetooth</span><span class="p">();</span>
    <span class="n">check_timers</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div> <p>This is polling. It can waste CPU time when nothing is happening, and it becomes awkward as the number of event sources grows.</p> <p>Linux provides a better mechanism through facilities such as <code class="language-plaintext highlighter-rouge">epoll</code>: the application tells the kernel which descriptors it is interested in, and the kernel reports which ones are ready.</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CAN fd  -----\
UART fd ------&gt; kernel waits for readiness ---&gt; event loop wakes up
GATT fd -----/
timerfd -----/
</code></pre></div></div> <p>The application does not wait on one device at a time. The kernel waits on all registered sources together.</p> <h2 id="3-the-event-loop-model">3. The event loop model</h2> <p>At a high level, an event loop behaves like this:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>while the application is running:
    wait until one or more events are ready
    mark those events as active
    call the callback associated with each active event
</code></pre></div></div> <p>With libevent, a read event may be registered like this:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">event</span> <span class="o">*</span><span class="n">can_event</span> <span class="o">=</span> <span class="n">event_new</span><span class="p">(</span>
    <span class="n">base</span><span class="p">,</span>
    <span class="n">can_fd</span><span class="p">,</span>
    <span class="n">EV_READ</span> <span class="o">|</span> <span class="n">EV_PERSIST</span><span class="p">,</span>
    <span class="n">can_read_callback</span><span class="p">,</span>
    <span class="n">context</span>
<span class="p">);</span>

<span class="n">event_add</span><span class="p">(</span><span class="n">can_event</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">);</span>
</code></pre></div></div> <p>This says:</p> <blockquote> <p>Monitor <code class="language-plaintext highlighter-rouge">can_fd</code>. Whenever it becomes readable, call <code class="language-plaintext highlighter-rouge">can_read_callback</code>.</p> </blockquote> <p>libuv expresses the same general architecture with different types and APIs. Both libraries connect operating-system event notification to application callbacks.</p> <h2 id="4-what-happens-when-several-events-arrive">4. What happens when several events arrive?</h2> <p>Suppose the application has registered three callbacks:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CAN fd  ready -&gt; can_callback()
UART fd ready -&gt; uart_callback()
GATT fd ready -&gt; gatt_callback()
</code></pre></div></div> <p>If all three descriptors become ready, the event loop activates all three events. In a single-threaded loop, the callbacks are normally executed one after another:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>can_callback()
uart_callback()
gatt_callback()
</code></pre></div></div> <p>The order may vary unless explicit priorities are configured, so application correctness should not depend on an accidental ordering between events of equal priority.</p> <p>Sequential execution is not the same as blocking. A callback that processes already-available data and returns quickly is simply taking its turn. Blocking occurs when the callback waits for something that has not happened yet.</p> <p>For example, this is dangerous:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">can_callback</span><span class="p">(</span><span class="kt">int</span> <span class="n">fd</span><span class="p">,</span> <span class="kt">short</span> <span class="n">events</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">arg</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">read</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="n">buffer</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">buffer</span><span class="p">));</span>  <span class="cm">/* current frame */</span>
    <span class="n">read</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="n">buffer</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">buffer</span><span class="p">));</span>  <span class="cm">/* may wait for a future frame */</span>
<span class="p">}</span>
</code></pre></div></div> <p>A common non-blocking pattern is to drain the data that is already available and stop when the kernel reports <code class="language-plaintext highlighter-rouge">EAGAIN</code>:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">can_callback</span><span class="p">(</span><span class="kt">int</span> <span class="n">fd</span><span class="p">,</span> <span class="kt">short</span> <span class="n">events</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">arg</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">for</span> <span class="p">(;;)</span> <span class="p">{</span>
        <span class="kt">ssize_t</span> <span class="n">n</span> <span class="o">=</span> <span class="n">read</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="n">buffer</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">buffer</span><span class="p">));</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">n</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">process_frame</span><span class="p">(</span><span class="n">buffer</span><span class="p">,</span> <span class="n">n</span><span class="p">);</span>
            <span class="k">continue</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">n</span> <span class="o">&lt;</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="n">errno</span> <span class="o">==</span> <span class="n">EAGAIN</span> <span class="o">||</span> <span class="n">errno</span> <span class="o">==</span> <span class="n">EWOULDBLOCK</span><span class="p">))</span>
            <span class="k">break</span><span class="p">;</span>

        <span class="k">break</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>The callback then returns control to the event loop so that other ready events can run.</p> <h2 id="5-asynchronous-and-non-blocking-are-related-but-different">5. Asynchronous and non-blocking are related, but different</h2> <p>These two terms are often used together:</p> <ul> <li><strong>Non-blocking</strong> describes an operation that returns immediately when it cannot make progress.</li> <li><strong>Asynchronous</strong> describes a control flow in which work is started now and its completion is handled later.</li> </ul> <p>For example:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>register interest in a socket
        |
        v
continue handling other work
        |
        v
kernel reports that the socket is readable
        |
        v
event loop invokes the read callback
</code></pre></div></div> <p>The application expresses interest at one point in time and receives the result later through a callback. No thread has to remain blocked on that socket.</p> <h2 id="6-a-practical-embedded-example-can-and-sampling">6. A practical embedded example: CAN and sampling</h2> <p>This model became much clearer when I applied it to an embedded acquisition system containing CAN, UART, Bluetooth GATT, and a sampling state machine.</p> <p>The design has two important event sources:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CAN fd readable event  -&gt; receive and parse CAN frames
1 ms timer event       -&gt; advance the sampling state machine
</code></pre></div></div> <p>Sampling is not implemented as one long blocking function. Instead, it is split into small phases:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>configure an LED over CAN
        |
wait until a deadline, without sleeping
        |
send a sampling command
        |
request data from a node
        |
return to the event loop
        |
CAN response arrives and activates the CAN callback
        |
store the response and mark the node as ready
        |
a later timer tick advances the state machine
</code></pre></div></div> <p>The key point is that CAN reception and sampling are not two large tasks where one must finish completely before the other begins. They cooperate:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sampling tick sends a CAN request
CAN callback receives the response
next sampling tick observes the response
sampling state machine continues
</code></pre></div></div> <p>Waiting is represented as state plus a deadline, not as <code class="language-plaintext highlighter-rouge">sleep()</code>:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">state</span><span class="o">-&gt;</span><span class="n">deadline_ms</span> <span class="o">=</span> <span class="n">now_ms</span> <span class="o">+</span> <span class="mi">3</span><span class="p">;</span>
<span class="n">state</span><span class="o">-&gt;</span><span class="n">phase</span> <span class="o">=</span> <span class="n">WAIT_FOR_LED</span><span class="p">;</span>
<span class="k">return</span><span class="p">;</span>
</code></pre></div></div> <p>On later timer ticks:</p> <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">now_ms</span> <span class="o">&lt;</span> <span class="n">state</span><span class="o">-&gt;</span><span class="n">deadline_ms</span><span class="p">)</span>
    <span class="k">return</span><span class="p">;</span>

<span class="n">state</span><span class="o">-&gt;</span><span class="n">phase</span> <span class="o">=</span> <span class="n">START_ADC</span><span class="p">;</span>
</code></pre></div></div> <p>During those three milliseconds, the main thread remains free to process CAN, UART, GATT, and other timers.</p> <h2 id="7-why-a-single-threaded-loop-is-still-useful">7. Why a single-threaded loop is still useful</h2> <p>At first, a single-threaded event loop can look inferior to multiple threads. Multiple threads can run on several CPU cores, while callbacks on one loop execute sequentially.</p> <p>The missing detail is that I/O-driven programs usually spend much more time waiting than computing. The event loop lets the kernel wait for many I/O sources simultaneously, then performs only the short amount of work required for each ready event.</p> <p>A single-threaded event loop provides several useful properties:</p> <ul> <li>Shared state is naturally serialized.</li> <li>Callback ordering is easier to reason about.</li> <li>Fewer mutexes are required.</li> <li>Deadlocks and data races are less likely.</li> <li>Thread stacks and context switches are reduced.</li> <li>Startup and shutdown become simpler.</li> </ul> <p>In an embedded system, a Bluetooth command, a CAN response, and a sampling timer may all touch the same state. Running their small handlers on one thread often makes the system more deterministic than allowing three threads to modify that state concurrently.</p> <p>This does not mean that threads are bad. They are valuable for:</p> <ul> <li>CPU-intensive signal processing</li> <li>Compression or encryption</li> <li>Blocking APIs that cannot be converted to event-driven I/O</li> <li>Long-running work that would delay other callbacks</li> </ul> <p>The practical design is often hybrid:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>main event-loop thread
├── CAN
├── UART
├── GATT
├── timers
└── state machines

worker threads
├── expensive calculations
└── unavoidable blocking operations
</code></pre></div></div> <p>The rule is simple: keep event-loop callbacks short, and move genuinely expensive or blocking work elsewhere.</p> <h2 id="8-how-worker-threads-notify-the-event-loop">8. How worker threads notify the event loop</h2> <p>A worker thread does not automatically become part of the main event loop. It needs a thread-safe notification mechanism.</p> <p>libuv provides <code class="language-plaintext highlighter-rouge">uv_async_send()</code> for this purpose:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>worker thread
    |
    | uv_async_send()
    v
main event loop wakes up
    |
    v
async callback runs on the loop thread
</code></pre></div></div> <p>With libevent on Linux, a similar mechanism can be built using an <code class="language-plaintext highlighter-rouge">eventfd</code>, pipe, or socket pair:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>worker thread
    |
    | enqueue result
    | write(eventfd)
    v
eventfd becomes readable
    |
    v
libevent invokes its callback on the main thread
    |
    v
main thread consumes the queued result
</code></pre></div></div> <p>The notification transfers control back to the event-loop thread. Shared data still needs a mutex, atomics, or a thread-safe queue.</p> <h2 id="9-libevent-and-libuv-the-common-idea">9. libevent and libuv: the common idea</h2> <p>libevent and libuv differ in API design and scope, but the central model is similar:</p> <table> <thead> <tr> <th>Concept</th> <th>libevent</th> <th>libuv</th> </tr> </thead> <tbody> <tr> <td>Event loop</td> <td><code class="language-plaintext highlighter-rouge">event_base</code></td> <td><code class="language-plaintext highlighter-rouge">uv_loop_t</code></td> </tr> <tr> <td>I/O readiness</td> <td><code class="language-plaintext highlighter-rouge">event_new()</code> and callbacks</td> <td>stream, poll, and handle callbacks</td> </tr> <tr> <td>Timers</td> <td>timer events</td> <td><code class="language-plaintext highlighter-rouge">uv_timer_t</code></td> </tr> <tr> <td>Cross-thread notification</td> <td>commonly <code class="language-plaintext highlighter-rouge">eventfd</code>, pipe, or <code class="language-plaintext highlighter-rouge">event_active()</code> with configured threading</td> <td><code class="language-plaintext highlighter-rouge">uv_async_t</code> and <code class="language-plaintext highlighter-rouge">uv_async_send()</code></td> </tr> <tr> <td>Worker execution</td> <td>application threads or external thread pools</td> <td><code class="language-plaintext highlighter-rouge">uv_queue_work()</code> and the libuv thread pool</td> </tr> </tbody> </table> <p>libuv offers a broader cross-platform abstraction for networking, files, processes, DNS, and threads. libevent focuses strongly on event notification, timers, and network-oriented building blocks. Choosing between them depends on the application, but understanding either one teaches the same fundamental lesson:</p> <blockquote> <p>Do not block while waiting for I/O. Register interest, return control to the loop, and react when the event becomes ready.</p> </blockquote> <h2 id="10-the-mental-model-i-will-keep">10. The mental model I will keep</h2> <p>My final mental model is:</p> <ol> <li>An fd is a process-local handle for a kernel resource.</li> <li>The kernel can monitor many fds at the same time.</li> <li>The event loop maps each ready fd or timer to a callback.</li> <li>Several active callbacks normally run sequentially on the loop thread.</li> <li>Sequential does not mean blocking.</li> <li>A callback blocks the loop only when it waits or performs excessive work.</li> <li>Long workflows should be split into short state-machine steps.</li> <li>Threads are useful for real parallel work, but they must notify the main loop explicitly.</li> </ol> <p>Once I stopped imagining the event loop as “one thread waiting on one device,” the architecture made sense. It is one thread coordinating many sources of readiness, while the operating system performs the waiting efficiently.</p>]]></content><author><name></name></author><category term="Technical Notes"/><category term="Linux"/><category term="Programming"/><category term="Embedded Systems"/><summary type="html"><![CDATA[What I learned about file descriptors, non-blocking I/O, event loops, callbacks, and the boundary between event-driven code and threads.]]></summary></entry></feed>