Доброго времени суток, уважаемые посетители Habr!
Данная статья будет короткой, но полезной.
В одной из предыдущих статей, уже описывал вычисление sin(x)/cos(x) с применением разложения в ряд Фурье с фиксированной точкой. Вычисление тригонометрических функций, в моем случае занимало порядка 125-130 тактов на пару (sin+cos) на процессорном ядре Cortex M7 (STM32H750). При этом, код компилировался для архитектуры Cortex M3. Точность sin/cos просчитанного таким образом составила менее 1.5LSB. Google посчитал ее как 1.2-1.3LSB, с минимальной дисперсией. Это уже дало динамический диапазон ~183dB. Для сравнения, полный динамический диапазон человеческого уха 120dB от болевого порога до шелеста листвы. А динамически диапазон звука который человек слышит одновременно порядка 40-60dB. Несколько позже поясню для чего приведено сравнение.
В общем и целом такого динамического диапазона и скорости уже достаточно чтобы производить операцию квадратурной свертки сигнала с частотой дискретизации до 450-500KHz. Кстати, на STM32F103C8T6, это заняло бы ~1.8uS на квадратурный отсчет. Т.е. с отключенными прерываниями процессор бы успел выполнить расчет одного бина честного преобразования Фурье в реальном времени. Это эквивалентно квадратурной демодуляцию на одной произвольной поднесущей до частоты 250КГц (хотя лучше брать Fsample/4 ), что позволяет работать с полосой до 125КГц, на простом контроллере в реальном времени.
Однако, этого мало для полноценной обработки сигналов. И тут я задумался. Как можно значительно повысить скорость работы и почти не потерять в точности? Первое что сделал,- разбил преобразование на блоки, фаза которых непрерывна. Это позволило работать с блоками отсчетов, которые, затем можно суммировать скользящим окном со сложностью O(1). Это привело к эффекту квадратурной демодуляции сигнала и без повышения сложности позволяло работать с малыми временными сдвигами. Фактически пришел к поблочной корреляция. Нечто вроде временного Rack-приема.
Такой алгоритм позволял ускорить обработку сигнала, и работать с разными временными сдвигами. В то же время алгоритм позволяет собрать энергию сигнала когерентно, для разных частот и при этом сохранить инвариантность по отношению к фазе. Речь идет о когерентном сложении произвольных частотно-маниупулированных сигналов с непрерывной фазой и разными временным сдвигами. Просто по окончании периода корреляции, вы будете знать вектор, на которой провернулся ваш сигнал на одной частоте относительно сигнала на другой частоте. Вам достаточно домножить вектор на матрицу поворота e^-jPhy, a затем просуммировать квадратурные составляющие векторов. И делать это можно блоками длинной кратной периоду колебания.
Однако, производительность системы оказалась недостаточной, для моих задач и оптимизация алгоритма привела к интересному решению. Это матрица поворота для вектора, с которым производится корреляция исходного сигнала. Т.е. в начале длинного блока, известна начальная фаза вектора с котором следует проводить корреляцию, а значит, можно просчитать начальные значения I/Q компонент этого вектора. Однако, если просчитывать пару (sin, cos) на каждый отсчет, это занимает много времени. В то же время, внутри блока, можно пойти по пути поворота вектора. Такой поворот имеет погрешность менее шести младших бит на на 128 отсчетов при расчете в целых числах. Это позволяет обрабатывать, например 24-битные аудио отсчеты, практически не внося в них дополнительного шума.
Но я планировал работать с отсчетами ADC/DAC процессора STM32F103RE, выровненными влево. Собственно так и родился следующий код:
/* ******************************************************************************************** * @file Modem.s dedicated to STM32Fxx device * @author Evgeny Sobolev * @version V1.0.0 * @date 2026-08-26 */ .syntax unified .cpu cortex-m3 .thumb .extern cosFixed .extern sinFixed .global FskModemModulateFreqInit .global FskModemModulate .global FskModemRemoveDc .global FskModemBlockCorrInit .global FskModemBlockCorr .global _gStm32DacDcOffset .global _gStm32AdcDcOffset //struct FskModem_ModulateStruct { // uint32_t amplitude // Amplitude // uint32_t phaseIncrement // Phase increment // uint32_t rotCos; // cos(phi), i.e. rotation ( I ) // uint32_t rotSin; // sin(phi), i.e. rotation (-Q ) //}; .section .text .type FskModemModulateFreqInit, %function .align 4 FskModemModulateFreqInit: // R0 <= struct pointer // R1 <= Phase increment per sample // R2 <= Amplitude push {lr} // Amplitude str r2, [r0, #0x00] // Phase str r1, [r0, #0x04] mov r2, r0 mov r0, r1 bl cosFixed str r0, [r2, #0x08] mov r0, r1 bl sinFixed str r0, [r2, #0x0C] pop {lr} bx lr .section .text .type FskModemModulate, %function .align 4 FskModemModulate: push {r4-r12, lr} // R0 <= pointer to FskModem_ModulateStruct // R1 <= Phase // R2 <= Buffer // R3 <= Sample count mov r12, r0 // R5 <= Phase Incremnet ldr r5, [r12, #4] // R0 <= Phase + PhaseIncrement * SampleCount mla r0, r5, r3, r1 // Update current phase on STACK push {r0} // Calculate current vector value // R5 <= Q mov r0, r1 bl sinFixed negs r5, r0 // R4 <= I mov r0, r1 bl cosFixed mov r4, r0 // R9 <= cos(phi) ldr r9, [r12, #0x08] // R10 <= sin(phi) ldr r10, [r12, #0x0C] // R11 <= -sin(phi) negs r11, r10 ldr r1, [r12, #0] // R0 <= DacDcOffset ldr r0, =_gStm32DacDcOffset ldr r0, [r0] // R1 <= Amplitude // R2 <= Buffer // R3 <= Sample count // R4 <= I // R5 <= Q // R9 <= cos(phi) // R10 <= sin(phi) // R11 <= -sin(phi) // R0 <= DCoffset FskModemModulateLoop: // Possible to use R6, R7, R8 as temp // R7 is scaled to Q2.30 value of orig smull r6, r7, r5, r1 // Make from scaled -sin, sin value + DC-offset, Q1.15 sub r6, r0, r7, asr #15 // Store value of scaled sin(x) strh r6, [r2], #2 // Update phase use // R4, R5 - I/Q, R9,R10 - angles // R6, R7 - Temp smull r6, r7, r4, r9 smlal r6, r7, r5, r11 // R7 <= I (Q1.31) lsl r7, r7, #1 orr r7, r7, r6, lsr #31 smull r6, r8, r4, r10 smlal r6, r8, r5, r9 // R5 <= Q (Q1.31) lsl r5, r8, #1 orr r5, r5, r6, lsr #31 // R4 <= I (update, Q1.31) mov r4, r7 subs r3, #1 bne FskModemModulateLoop pop {r0} pop {r4-r12, lr} bx lr /* ******************************************************************************************** * @description Demodulator common ******************************************************************************************* */ .section .text .type FskModemRemoveDc, %function .align 4 FskModemRemoveDc: // R0 <= Curent DC offset exp-accumulator, returns the same // R1 <= Input buffer Q0.16 (16bit) // R2 <= Output buffer Q1.15 (16bit) // R3 <= Sample count Warning !!!: Shold be even push {r1-r7} // R6 <= ADC dc point offset ldr r6, =_gStm32AdcDcOffset ldr r6, [r6] FskDcCancelLoop: // Load next sample ldr r4, [r1], #4 ubfx r5, r4, 16, 16 ubfx r4, r4, 0, 16 // Substract 16bit adc middle value sub r4, r4, r6 sub r5, r5, r6 // Recalculate exponentional sum, i.e. replace (2*mid) by two REAL samples asr r7, r0, #(15) sub r0, r0, r7 add r0, r0, r4 add r0, r0, r5 // Get current mid value asr r7, r7, #1 sub r4, r4, r7 sub r5, r5, r7 bfi r4, r5, #16, #16 stmia r2!,{r4} subs r3, r3, #2 bne FskDcCancelLoop pop {r1-r7} bx lr //struct FskModem_BlockCorrStruct { // uint32_t phase; // Current phase // uint32_t phaseIncrPerBlock; // Phase incremnet over block of samples // uint32_t samplesPerBlock; // Sample count in each block // uint32_t rotCos; // cos(phi), i.e. rotation ( I ) // uint32_t rotSin; // sin(phi), i.e. rotation (-Q ) //}; .section .text .type FskModemBlockCorrInit, %function .align 4 FskModemBlockCorrInit: push { lr } // R0 <= Pointer to FskModem_BlockCorrStruct // R1 <= Initial phase value // R2 <= Phase increment // R3 <= Sub block size // Store initial phase str r1, [r0, #0x00] // Calculate & store phase increment per block mul r1, r2, r3 str r1, [r0, #0x04] // Store samplesPerBlock (block size) str r3, [r0, #0x08] // R3 <= BlockCorrStruct* mov r3, r0 // Calc and store cos rotation value mov r0, r2 // WARNING, If functions sinFixed,cosFixed you used, is not my. // I don't need to sore R0-R3 bl cosFixed str r0, [r3, #0x0C] // Calc and store sin rotation value mov r0, r2 bl sinFixed str r0, [r3, #0x10] // Return pop { lr } bx lr .section .text .type FskModemBlockCorr, %function .align 4 FskModemBlockCorr: push {r4-r12, lr} // R0 <= Pointer to FskModem_BlockCorrStruct // R1 <= Output buffer pointer // R2 <= Input buffer pointer // R3 <= Block count // R12 <= Pointer to FskModem_BlockCorrStruct mov r12, r0 // R0,R4 <= Current phase ldr r0, [ r12, #0x00 ] mov r4, r0 // Calculate currnet vector I value bl cosFixed // R5 <= I, i.e cos(currentPhase) mov r6, r0 // R0 <= Current phase mov r0, r4 // Calculate currnent vector Q value bl sinFixed // R7 <= Q, i.e sin(currentPhase) negs r7, r0 // R8 <= Phase Increment per block ldr r8, [ r12, #4 ] // R0 <= R4 + R3*R8, i.e. phase + phase increment // Overflow of phase doesn't metters mla r0, r3, r8, r4 // Store new phase str r0, [ r12, #0 ] // Load cos(phi), sin(phi) // R8 <= cos(phi) ( i.e. rotation I scale ) ldr r8, [ r12, #0x0C ] // R9 <= sin(phi) ( i.e. rotation -Q scale ) ldr r9, [ r12, #0x10 ] negs r10, r9 // R1 <= Output Buffer pointer // R2 <= Input Buffer pointer // R3 <= Sample count // R4 <= I accumulator // R5 <= Q accumulator // R6 <= Current I // R7 <= Current Q // R8 <= I / rotation cos value scale // R9 <= Q / rotation sin value scale // R10 <= Q / rotation -sin value scale // R11 <= Temp / Block counter // R12 <= Struct pointer // R11 <= Block counter mov r11, r3 FskModemBlockCorrLoopExt: // R3 <= Sample count per block ldr r3, [ r12, #8 ] // R4 <= 0, i.e. I accumulator reset mov r4, #0 // R5 <= 0, i.e. Q accumulator reset mov r5, #0 // Stack <= R1, i.e. Output Pointer // Stack <= R11, i.e. Block counter push { r1, r11 } FskModemBlockCorrLoopInt: // Load next sample Q1.11zzzz - 16bit total? // but 12bit max in STM32F103 (Q.S.11zzzz) // R6 is I (Q1.31) - signed i.e Q.S.31 // R7 is Q (Q1.31) - signed i.e Q.S.31 ldrsh r0, [r2], #2 // R0 is Sample Q1.15 - signed // R0 <= Q1.31, i.e ( Q1.15 << 16) lsl r0, r0, #16 // R11 <= R0, i.q. Q1.31 of ADC sample mov r11, r0 // R0 <= Q2.30, i.e (Q1.31 * Q1.31) smull r0, r1, r6, r0 // R4 <= R4 + R1, i.q Q17.15 + Q17.15 add r4, r4, r1, asr #15 // R0 <= Q2.30 i.e. (Q1.31 * Q1.16) smull r0, r1, r7, r11 // R5 <= R5 + R1, i.q Q17.15 + Q17.15 add r5, r5, r1, asr #15 // Inline Bel202PhaseIncrement: // R6 <= I // R7 <= Q // R8 <= Cos Phi // R9 <= Sin Phi // R10 <= -Sin Phi // R0,R1,R11 as temp smull r1, r0, r6, r8 smlal r1, r0, r7, r10 lsr r1, r1, #(32-1) orr r11, r1, r0, lsl #1 smull r1, r0, r6, r9 smlal r1, r0, r7, r8 lsr r1, r1, #(32-1) orr r7, r1, r0, lsl #1 mov r6, r11 // End of Inline Bel202PhaseIncrement // So lets' calculate next value or exit subs r3, r3, #1 bne FskModemBlockCorrLoopInt // POP R1 <= Current output pointer // POP R11 <= Block counter pop { r1, r11 } // Store correlation results str r4, [r1], #4 str r5, [r1], #4 subs r11, #1 // So, loop again, if not all blocks are porecessed bne FskModemBlockCorrLoopExt pop {r4-r12, lr} bx lr .section .rodata .align 4 _gStm32DacDcOffset: .word 32768 _gStm32AdcDcOffset: .word 32768
Собственно, если код немного доработать и заменить скалярное произведение на комплексное, то его можно будет использовать для квадратурной демодуляции I/Q отсчетов. Например квадратурной пары с ADC микроконтроллеров.
Спасибо за внимание.
ValeriyS
Спасибо за статью, тема интересная. Сам когда-то с Bell 202 возился, так что полез в код, ну и… есть пара моментов, напишу как есть.
Сначала думал, что мне показалось. В FskModemBlockCorr Q инициализируется как -sin(phase) (там negs), а крутится потом вектор на +phi. Получается, внутри вызова опорник идет в сторону theta0 - nphi, а в структуру при этом сохраняется theta0 + Nphi. На стыке вызовов скачок на 2Nphi. Я сначала погонял на 1200 Гц и ничего не увидел, ну оно и понятно: при 9600 это 8 отсчетов на бит, ровно период, всё кратно 2pi и баг прячется. А на 2200 опорник прыгает на 120 градусов на каждом вызове. Хотя если у вас блоки всегда кратны периоду, как вы пишете, то вы этого и не видели, наверное. Но 2200 Гц на 1200 бод в целое число периодов не укладывается.
С модулятором, кстати, ровно то же самое, я это уже потом сообразил. Чередовал 1200/2200 по 8 отсчетов, и на стыке символов скачок 60864 единицы, а у непрерывного синуса такой амплитуды шаг больше 43210 не бывает в принципе. То есть это уже не совсем CP FSK выходит :) Лечится просто: убрать negs, а в модуляторе add вместо sub.
Еще одно, это я уже в эмуляторе поймал (unicorn, гонял ваш asm как есть, sin/cos подставлял идеальные). Вектор стартует с полной шкалы Q1.31, запаса ноль. Когда он проходит через ось, ошибка округления иногда выталкивает компоненту за 2^31, она заворачивается, и опорник переворачивается на 180 градусов до следующего пересечения. На 2200/9600 у меня это на 12-м отсчете случилось. Стартовать с половины шкалы, Q2.30, сдвигать на 14 вместо 15, и всё.
А, ну и мелочь: после bl cosFixed/sinFixed используются r1-r3 и r12, а по AAPCS их никто сохранять не обязан. С вашими sin/cos работает, с чужими уже нет. Тем более r4-r11 и так на стеке, можно держать там.
Так, теперь собственно про “можно ли лучше”. Я долго думал, а зачем вообще крутить вектор на каждом отсчете? Для Bell 202 тоны соизмеримы с частотой дискретизации, значит опорный сигнал периодичный. При 9600 у 1200 Гц период 8 отсчетов, у 2200 Гц 48. Кладем один период в табличку {cos, -sin} в Q1.15 и ходим по ней по кругу. Указатель в таблице и есть фаза. Ни sin/cos в рантайме, ни дрейфа, ни переполнения, и непрерывность между вызовами точная, просто по построению. (Если период на длину блока не делится, берем НОК, P = lcm(fs/gcd(fs,f), L), там всё равно получается немного.)
И раз уж Q1.15 хватает за глаза (АЦП-то 12 бит, 183 дБ там, уж простите, не нужны), можно делать MUL 32x32->32. На M3 это 1 такт, а не SMULL/SMLAL по 3-7. По таблицам из TRM у меня вышло где-то 11-15 тактов на отсчет против ~34-50 у вас. На железе не мерил, это оценка, но разница заметная. Внутренний цикл примерно такой (ИИ помогает, да):
Ассемблер (Cortex-M3), полностью совпадает с проверенной версией:
Что забавно, на C это пишется в три строчки, и clang с -O2 собирает практически те же инструкции, еще и разворачивает на 4. Так что asm тут, может, и не нужен вовсе. Точность сравнивал с float корреляцией: на блоке из 8 отсчетов расхождение меньше одного LSB 12-битного АЦП.
Хотя да, бывает, что период получается огромный. 2200 на 44100, например, там 14 тысяч пар. Тогда таблица на один блок с нулевой фазой, а результат блока доворачивается один раз: Y_k = e^(-j*theta_k) * C_k. Это по сути ваша же идея с e^-jPhy, только на уровне блока, а не каждого отсчета.
Для модулятора вообще проще DDS: общий аккумулятор фазы на оба тона плюс синус из таблицы, и непрерывность получается сама собой.
И вот еще, наверное, стоило с этого начать. Если цель именно когерентность, то реальный выигрыш от нее будет в многосимвольном детекторе, Витерби по решетке CPFSK. А ваши поблочные комплексные корреляции как раз готовые метрики для него. Было бы интересно почитать продолжение в эту сторону.