# 2025q1 Homework4 (quiz3+4)
contributed by <`Andrushika`>
## w3q1
本題使用 `mpi_t` 結構,作為包裝大數的基礎資料結構,結構定義如下:
```c
/* mpi: Multi-Precision Integers */
typedef struct {
uint32_t *data;
size_t capacity;
} mpi_t[1];
```
其中 `data` 會是儲存多個 32 bits (實際上是 31 bits) 整數的 array,`capacity` 則代表 array 的大小。注意到最終定義 `mpi_t[1]`,屬於一種小技巧,可以用來模擬物件導向風格的程式。
若要使用 `mpi_t`,在不使用上述技巧的情況下,會需要做以下操作進行初始化:
```c
mpi_t *number = malloc(sizeof(mpi_t));
mpi_init(number);
```
但使用上述技巧後,`mpi_t` 被定義為給定 `struct` 的陣列,所以記憶體空間已經分配好。使用 `mpi_t` 的方式就可以簡化:
```c
mpi_t number;
mpi_init(number);
```
可以發現 `number` 就像 OOP 中包裝好的 object 一樣,`mpi_t` 就像某個 class。
### AAAA
觀察 `ceil_div`,基本的除法向上取整操作:
```c
static size_t ceil_div(size_t n, size_t d)
{
return (n+d-1)/d; // AAAA
}
```
### BBBB
從先前定義的 `mpi_t` 可以發現 `data` 的基本單位應該為 32 bits signed int,但在題目的 context 下,mpi 只支援無號數運算,可以從以下 function 觀察出來:
```c
void mpi_set_u64(mpi_t rop, uint64_t op)
{
size_t capacity = ceil_div(64, 31);
mpi_enlarge(rop, capacity);
for (size_t n = 0; n < capacity; ++n) {
rop->data[n] = op & INTMAX;
op >>= 31;
}
for (size_t n = capacity; n < rop->capacity; ++n)
rop->data[n] = 0;
}
```
以上程式碼會以 31 bits 為單位儲存數值,因此,可以求得 `BBBB` 的答案:
```c
#define INTMAX 0x7fffffff // BBBB
```
### CCCC
`mpi_mul_naive` 使用類似直式乘法的概念來實作,每次相乘一位後持續往高位數進位。
```c
for (size_t n = 0; n < op1->capacity; ++n) {
for (size_t m = 0; m < op2->capacity; ++m) {
uint64_t r = (uint64_t) op1->data[n] * op2->data[m];
uint64_t c = 0;
for (size_t k = CCCC; c || r; ++k) {
if (k >= tmp->capacity)
mpi_enlarge(tmp, tmp->capacity + 1);
tmp->data[k] += (r & INTMAX) + c;
r >>= 31;
c = tmp->data[k] >> 31;
tmp->data[k] &= INTMAX;
}
}
}
```
`r` 代表剩餘需處理進位的數,`c` 表示 carry。
可以推得 `CCCC` 需填入 `n + m`,以對齊直式乘法操作時正確的位置。
### DDDD
`mpi_fdiv_qr` 的實作是使用二進位長除法的概念,維護一個 `r` 代表 remainder,從被除數的最高位做到最低位,每次將一位加入 remainder 中。當 `r > d` 代表有得除,將商的該位 bit 設為 1。
當初考試時一直卡在 `mpi_mul_2exp` 的作用。本函式的作用是將 mpi 乘上 2 的指數,實際上就是做左移操作。因為每次都要將被除數的一位加入 `r`,`r` 就需要事先左移空出一位。所以 `DDDD` 的答案:
```c
mpi_mul_2exp(r, r, 1); // DDDD
```
### EEEE
`mpi_gcd` 使用遞迴版本的[輾轉相除法](https://zh.wikipedia.org/zh-tw/%E8%BC%BE%E8%BD%89%E7%9B%B8%E9%99%A4%E6%B3%95)求最大公因數。每次求得 `op1 % op2` 的結果後,再用該結果進行下次的運算,因此可以填入 `EEEE`:
```c
mpi_gcd(rop, op2, r); // EEEE
```
### 將 `mpi_gcd` 的實作方式改為 iterative
```c
void mpi_gcd(mpi_t rop, const mpi_t op1, const mpi_t op2)
{
// 額外增加 t1, t2 作為 tmp
mpi_t q, r, t1, t2;
mpi_init(q);
mpi_init(r);
mpi_init(t1);
mpi_init(t2);
mpi_set(t1, op1);
mpi_set(t2, op2);
while(mpi_cmp_u32(t2, 0) != 0){
mpi_fdiv_qr(q, r, t1, t2);
mpi_set(t1, t2);
mpi_set(t2, r);
}
mpi_set(rop, t1);
mpi_clear(q);
mpi_clear(r);
mpi_clear(t1);
mpi_clear(t2);
}
```
## w3q2
### `DETECT_NULL` 的巧思
本 macro 被用來確認 `X` 之中是否存在 NULL byte:
```c
#define DETECT_NULL(X) \
(((X) - (0x0101010101010101)) & ~(X) & (0x8080808080808080))
```
為何這樣做有用?可以將每個 byte 分開、並拆成三種 case 來看。
因為最終會做 bitwise AND `0x80`,可以想成是對 MSB 進行檢測;接下來,關注 `MSB` 在不同 case 中是如何變化的。
#### case 1 `byte = 0x00`
在 `(X) - (0x0101010101010101)` 中:`0x00` - `0x01` = `0xFF`, `MSB = 1`
在 `~(X)` 中:`~0x00` = `0xFF`, `MSB = 1`
因為兩個 MSB 都被 set,最後檢測就會被判定為 NULL byte
#### case 2 `byte` 介於 `0x01` 到 `0x7F`
在 `(X) - (0x0101010101010101)` 中:`X` - `0x01`, `MSB = 0`
因此,無論後續做什麼操作,都不會被判定為 NULL byte
#### case 3 `byte` 介於 `0x80` 到 `0xFF`
在 `~(X)` 中:`MSB = 0`
因此,無論後續做什麼操作,都不會被判定為 NULL byte
根據 `memchr_opt` 的定義:
```c
void *memchr_opt(const void *str, int c, size_t len)
```
本函式會在長度為 `len` 的 `str` 中,尋找字元 `c` 是否存在。
### AAAA
`DETECT_CHAR` macro 透過前面提及的 `DETECT_NULL` 來幫助判斷給定的 `mask` 是否存在。只要先將 `mask` 對 `X` 做 XOR,就可以將 target byte 變成 NULL byte,進而能用 `DETECT_NULL` 判斷出來。
```c
#define DETECT_CHAR(X, mask) DETECT_NULL(X ^ mask)
```
### UNALIGNED 時的處理
若記憶體位置沒有和 `sizeof(long)` 對齊,則先一個一個字元讀取,並確定是否為目標字元,直到對齊為止。
```c
while (UNALIGNED(src)) {
if (!len--)
return NULL;
if (*src == d)
return (void *) src;
src++;
}
```
### BBBB, CCCC
當記憶體 `ALIGNED` 之後,就可以用題目所提及之 `SWAR` 方法快速求解。
首先需要將要尋找的目標 `d` 做成用來一次偵測多個 byte 的 `mask`,然後再使用 `DETECT_CHAR` 進行偵測:
```c
unsigned long *asrc = (unsigned long *) src;
unsigned long mask = d << 8 | d;+
mask |= mask << 16;
for (unsigned int i = 32; i < LBLOCKSIZE * 8; i <<= 1)
mask |= mask << i; // BBBB
while (len >= LBLOCKSIZE) {
if (DETECT_CHAR(*asrc, mask)) // CCCC
break;
asrc++;
len -= LBLOCKSIZE;
}
```
## w3q3
範例程式碼中實作了一個 `envs[NENV]` (`NENV = 1024`),目的是保留不同 coroutine 的執行狀態。
### AAAA
在 `coro_create` 之中,會創建新的 `coroutine` 並進入等待被排程的狀態,所以可以填入:
```c
for (env = 0; env < NENV; env++) {
if (envs[env].status == ENV_UNUSED) /* Found a free environment */
break;
}
if (env == NENV) /* No free environments available */
return -1;
envs[env].status = ENV_RUNNABLE; // AAAA
```
注意到 `make_context` 的用法,其將用來設定 `context` 相關函式執行入口點及參數:
```c
makecontext(&envs[env].state, (void (*)(void)) entry, 1, args);
```
其中 `entry` 是在 `coro_create` 中傳入的函式指標。
### BBBB
根據題目敘述,排程採用 round-robin 策略,所以每次會找下一個 index 的 candidates,即:
```c
int candidate = (curenv + 1) % NENV; // BBBB
```
在選定 candidate 之後,就會設定一個 10 ms 的 timer,幫助後續進行 preempt,並使用 `setcontext` 跳回該 coroutine 先前執行到一半的狀態。
### CCCC
`coro_yield` 在讓出 CPU 之前,會先用保存 context,然後交由排程器決定下一個要執行的 coroutine,所以 `BBBB = coro_schedule`。
### DDDD
timer 將會觸發 `preempt()`,強制當前 coroutine 讓出 CPU,因此 `DDDD = coro_yield`
## w4q1
在 `crc32_naive` 做法中,會使用到「反向」二進位多項式除法的原理(即會從最低位開始運算,在二進位運算中,和從最高位開始會是等價的)。
### naive 版本的加速
參考 [Fast CRC32](https://create.stephan-brumme.com/crc32/),一開始計算的迴圈部分如下:
```c
for (unsigned int j = 0; j < 8; j++)
if (crc & 1)
crc = (crc >> 1) ^ Polynomial;
else
crc = crc >> 1;
```
可以加速為 branch free 版本:
```c
for (unsigned int j = 0; j < 8; j++)
crc = (crc >> 1) ^ (crc & 1) * Polynomial;
```
然後再額外提升效能,由於 (crc & 1) 的結果始終只有 0 或 1,可以再簡化成以下版本:
```c
for (unsigned int j = 0; j < 8; j++)
crc = (crc >> 1) ^ (-(int)(crc & 1) & Polynomial);
```
### 作答
根據 [Fast CRC32](https://create.stephan-brumme.com/crc32/) 所提及的 "Half-Byte Algorithm",可以用來產生剩餘的答案:
```c
for (unsigned int i = 0; i < 16; i++){
unsigned int crc = i;
for (unsigned int j = 0; j < 4; j++)
crc = (crc >> 1) ^ (-(int)(crc & 1) & 0x82f63b78);
printf("%x\n", crc);
}
```
<s>
`AAAA` = `0xC38D26C4`
`BBBB` = `0xD3D3E1AB`
`CCCC` = `0xE330A81A`
`DDDD` = `0xF36E6F75`
</s>
:::danger
不要列出參考題解,專注題目本身
:::
## w4q2
### Phase Increment
因為程式是使用離散取樣模擬 phase,要產生特定波的時候,可以想像成每次取樣都推進一點 phase。假設每秒取樣 $f_s$ 次,而我們想產生 $f_0$ Hz 的音的時候,可以透過下列算式計算每次 sample 時 phase 該增加多少:
$$\Delta \theta = \frac{f_0}{f_s} \cdot 2\pi$$
題目中使用 Q15 去表示 phase ($0 \sim 2\pi$):
$$phase\_incr = \frac{f_0}{f_s} \cdot Q15\_MAX$$
對應的實作 code:
```c
#define SYNTH_HZ_TO_PHASE(frequency) ((frequency * Q15_MAX) / SAMPLE_RATE)
static const q15_t octave_phases[12] = {
// start at C8
SYNTH_HZ_TO_PHASE(4186.01), // C
SYNTH_HZ_TO_PHASE(4434.92), // C#
SYNTH_HZ_TO_PHASE(4698.63), // D
SYNTH_HZ_TO_PHASE(4978.03), // D#
SYNTH_HZ_TO_PHASE(5274.04), // E
SYNTH_HZ_TO_PHASE(5587.65), // F
SYNTH_HZ_TO_PHASE(5919.91), // F#
SYNTH_HZ_TO_PHASE(6271.93), // G
SYNTH_HZ_TO_PHASE(6644.88), // G#
SYNTH_HZ_TO_PHASE(7040.00), // A
SYNTH_HZ_TO_PHASE(7458.62), // A#
SYNTH_HZ_TO_PHASE(7902.13), // B
};
static q15_t midi_to_phase_incr(uint8_t note)
{
int octave = note / 12;
int note_index = note - (octave * 12);
q15_t phaseIncr = octave_phases[note_index];
// 每下降一個八度,頻率會是原來的一半
phaseIncr >>= (BASE_OCTAVE - octave + 1);
return phaseIncr;
}
```
### Envelope, ADSR
- Attack: 從沒聲音到最大聲,所花費的「時間」
- Decay: 從最大聲降到平穩音量,所花費的「時間」
- Sustain: 平穩音量的大小(唯一和時間無關的參數)
- Release: 聲音結束後,音量降到完全沒聲音的「時間」

### 低通濾波器 (Low-Pass Filter)
$$y[n] = y[n-1] + \alpha (x[n] - y[n-1])$$
- $y[n-1]$ 是上一次的輸出
- $x[n]$ 是當前的輸入
- $\alpha$ 控制平滑度,其範圍在 0 到 1 之間
### AAAA
接下來是根據 node type 更新 `state` 的部分,此處的 osc 因為要確保 state 始終可以用 Q15 來表示 phase,所以使用模除運算處理。
```c
case SYNTH_NODE_OSCILLATOR:
// 推進相位,加上 detune,並環繞
node->state += *node->osc.phase_incr;
if (node->osc.detune)
node->state += *node->osc.detune;
node->state &= Q15_MAX; /* AAAA, wrap around q15 */
break;
```
### BBBB ~ EEEE
這段集中在處理 envelope,先看先前取 state 值的方式:
```c
case SYNTH_NODE_ENVELOPE:
outputs[i] = (node->state & 0x7FFFFF) >> 4;
```
所以對於 `SYNTH_NODE_ENVELOPE`,低 23 位應該會用來存 value,然後才進行右移,目的是為了保留額外的 4 位精度。
同時註解中提到:
> The highest bit in the state indicates decay mode
所以 `BBBB` 應填入:
```c
int mode_bit = node->state & 0x00800000; // get bit 23
```
`CCCC` 則是 `0x7FFFFF`,如先前所示。
`DDDD` 需要在 attack 階段結束、value 達到最大值時,設置為 decay mode,所以應填入 `0x00800000`。
`EEEE` 的作用是在 gate 關閉時、release 作用時,把 decay bit 清除掉,這樣下一次 gate 打開時才可以直接進入 attack;應填入 `0xFF7FFFFF`。
### FFFF
`sine_wave` 的實作原理是,先使用 look up table 對比較大 scale 下的數值查表,查表後再用線性插值法提升精確度。
由於 input phase 是使用 `q15_t` 表示,且 index 讀取時先右移八位,代表最高七位會用來當 index。
先填入 `FFFF`:
```c
res += ((next - res) * (input & 0xFF) >> 8);
```
以上運算,可以想像是對 res 和 next 中間先計算「距離 table 的下一個點,目前走了多少百分比?」,然後用這個百分比來插值。
### GGGG, HHHH
`square_wave` 也是輸入 phase,輸出對應的波。
需要在前半個週期輸出 `+1`, 後半個週期輸出 `-1`,因此修改如下:
```c
q15_t square_wave(q15_t input)
{
return input < Q15_MAX/2
? Q15_MAX // GGGG
: Q15_MIN; // HHHH
}
```
## w4q3
### 實作原理
本題使用 `select` 實作了 TCP 連線工具,有幾個變數值得注意:
`server_fd`:負責處理新 socket 連線的 file descriptor。
`conns[FD_SETSIZE]`:在接收到 socket 連線請求後,會得到一個連線成功後傳輸資料用的 fd。`conns[fd] = 1` 時代表目前已建立連線,同時會將 fd 加入 `&rfds`,以便使用 `select()` 觀測。
### IIII, JJJJ
此處考的是 select 的用法,參照 linux manual page:
```c
int select(int nfds, fd_set *_Nullable restrict readfds,
fd_set *_Nullable restrict writefds,
fd_set *_Nullable restrict exceptfds,
struct timeval *_Nullable restrict timeout);
```
`IIII` 對應的是 `nfds`,說明如下:
> This argument should be set to the highest-numbered file descriptor in any of the three sets, plus 1. The indicated file descriptors in each set are checked, up to this limit (but see BUGS).
因為 rfds 只有監聽 `server_fd`,所以 `IIII` 應填入 `server_fd + 1`,也就是 `max_fd`。
`JJJJ` 對應的是 `readfds`,此處應填入 `&rfds`。值得注意的是,在 `select()` return 之後,`rfds` 會被改變,只剩下已經 ready 的 fds 被 set。
:::info
關於 select 運作的疑惑:
在我的理解中,`select` 應該是在偵測到任何一個 file descriptor ready 時,就會馬上 return。在微觀的角度下,kernel 應該無法「同時」偵測到多個 file descriptor ready,而在偵測到任一 fds ready 就要馬上 return 的狀況下,為何會出現 rfds 中可以找到多個 `FD_ISSET` 的狀況?
:::
### 關於 select 運作問題:ChatGPT 的回答
#### `do_select()` 的工作流程
以下是 `do_select()` 函數的簡化流程:
```c
int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time) {
// 初始化等待佇列
poll_initwait(&table);
// 設定當前進程的狀態為可中斷的睡眠狀態
set_current_state(TASK_INTERRUPTIBLE);
// 檢查所有關心的檔案描述符是否就緒
for (i = 0; i < n; ++i) {
if (檔案描述符 i 就緒) {
// 如果有描述符就緒,則立即返回
return 就緒的描述符數量;
}
}
// 如果沒有描述符就緒,則進入等待狀態,直到有事件發生或超時
poll_schedule_timeout(&table, TASK_INTERRUPTIBLE, timeout, slack);
// 醒來後再次檢查描述符狀態
...
}
```
這段程式碼展示了 `do_select()` 如何初始化等待佇列,檢查描述符狀態,並在必要時進入等待狀態。
#### 為什麼 `select()` 可能返回多個就緒的描述符?
正如您之前提到的,從物理層面來看,兩個事件不可能在完全相同的時間發生。然而,對於 `select()` 而言,它關心的是在一次系統呼叫期間,哪些描述符在其返回之前變成了就緒狀態。這意味著:
如果在 `select()` 被呼叫後,`fd1` 和 `fd2` 都在其返回之前變成了就緒狀態,則 `select()` 會同時報告這兩個描述符為就緒。
這種行為是因為 `select()` 在返回時會檢查所有關心的描述符,並將所有已就緒的描述符標記在 `fd_set` 中。