AVR. Учебный Курс. Программирование на Си. Атомарные операции.


 


Не раз и не два сталкивался с утверждением, что изучать ассемблер микроконтроллера это всего лишь пустая трата времени, дескать все можно сделать на Си, а если сильно надо то комманды можно и в даташите поглядеть.

Сейчас я одним маленьким примерчиком это утверждение зарою в землю, а сверху накрою могильной плитой.

Итак, есть у нас такой код (не ищите в нем практического смысла, я его просто как пример работы с разными операндами написал):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
volatile char flag_byte; 	
/*Просто флаговая переменная, на разные случаи жизни. Разные события там 
выставляют флажки, опираясь на которые потом работает логика программы. 
Один из способов организации псевдомногозадачности. Когда у нас главный цикл 
анализирует флажки и делает переходы на подпрограммы, а вызов подпрограмм 
осуществляется не напрямую, а установкой соответствющих флажков. Своего 
рода диспетчер переходов. О такой архитектуре я скоро расскажу)*/
 
ISR (USART_RXC_vect)	// Обработчик прерывания, самый обычный.
{
flag_byte|=1< <rcv_buff;
...
...
}
 
int main (void)		// Главная программа
{
INIT_ALL();
SEI();
...
...
...
TCCR0A  	|=1<<WGM01;
...
flag_byte 	|=1<<options;
...
PORTB 	&=~(2<&lt;1);
...
}

Человек ни разу не писавший под МК на ассемблере, начавший изучать МК сразу с Си, схавает такую конструкцию и даже не поморщится. Прожженый ассемблерщик же нецензурно матюгнется, обзовет первого быдлокодером и исправит исходник следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
volatile char flag_byte; 	// Просто флаговая переменная под флаги на разные случаи жизни
 
ISR (USART_RXC_vect)	// Обработчик прерывания
{
flag_byte|=1<<rcv_buff;
...
...
}
 
int main (void)		// Главная программа
{
INIT_ALL();
SEI();
...
...
...
TCCR0A  |=1<<WGM01;
...
CLI();
flag_byte |=1<<options;
SEI();
...
PORTB &=~(2<<1);
...
}

А еще проверит не юзается ли где еще в прерываниях регистр TCCR0A. И внимательно посмотрит на работу с регистрами периферии.

В чем же дело? Какая разница между

1
2
3
TCCR0A  |=1<<WGM01;
flag_byte |=1<<options;
PORTB &=~(1<<2);

Или между

1
PORTB &=~(1<<2|1<<3);

и

1
PORTB &=~(1<<3);

Для программиста сишника в принципе никакой — и там и там какой то регистр или переменная. Единственное что это аппаратный регистры и поэтому могут меняться самопроизвольно, да переменная в прерывании меняется, поэтому идет с квалификатором volatile.

А для ассемблерщика между этими строками заложена огромная разница.

Во первых flag_byte расположена в памяти и это флаговый регистр, а изменение бита в памяти может быть только через чтение-модификацию-запись. Соответственно

1
flag_byte |=1<<options;

Компилируется во что то вроде:

1
2
3
 	LDS	R16,flag_byte	; Чтение
	ORI	R16,1<<options	; Модификация
	STS	flag_byte,R16	; Запись

Во первых TCCR0A это аппаратный регистр, причем имеющий адрес в пределах 00-3F (адресное пространство на котором возможна работа команд IN/OUT), поэтому доступ к нему может быть через команду OUT, причем напрямую в регистр I/O сделать OUT константы нельзя, только через промежуточный регистр R16…R31 так что конструкция

1
TCCR0A  |=1<<WGM01;

Будет подобна предыдущей:

1
2
3
 	IN	R16,TCCR0A	; Чтение
	ORI	R16,1<<WGM01	; Модификация
	OUT	TCCR0A,R16	; Запись

А вот регистр PORTB расположен в адресном пространстве от 00…1F, поэтому кроме команды OUT до него могут дотянуться также команды работы с битами CBI и SBI.

Так что конструкция

1
PORTB &=~(1<<2);

Скомпилируется в

1
	CBI 	PORTB,2

А вот :

1
PORTB &=~(1<<2|1<<3);

Может уже быть как

1
2
	CBI  	PORTB,2
	CBI	PORTB,3

так и

1
2
3
	IN	R16,PORTB		; Чтение
	ANDI	R16,~(1<<2|1<<3)	; Модификация
	OUT	PORTB,R16		; Запись

В зависимости от числа одновременно меняемых битов и умности компилятора.

Да, это все здорово. Но к чему я это?

А к тому, что у нас тут есть еще и прерывание. В котором может быть изменены наши значения, а еще тот милый факт что прерывание, будучи разрешенным, может выскочить между двух любых инструкций. И при этом родить очень адский глюк которой может ВНЕЗАПНО вылезти через пару лет безглючной работы, а потом также бесследно исчезнуть, оставив разработчика задумчиво чесать репу на предмет «ЧТО ЭТО БЫЛО»? Часто тут грешат на глюки железа и дырявые процы. Хотя на самом то деле просто программа была корявая.

Покажу на примере, вот наш первоначальный код:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
volatile char flag_byte; 	// Просто флаговая переменная под флаги на разные случаи жизни
ISR (USART_RXC_vect)	// Обработчик прерывания
{
flag_byte|=1<<rcv_buff;
...
...
}
 
int main (void)		// Главная программа
{
INIT_ALL();
SEI();
...
...
...
TCCR0A  |=1<<WGM01;
flag_byte |=1<<options;
PORTB &=~(2<<1);
...
}

Программа выполняется себе, готовится записать бит options в переменную flag_byte…

1
2
; начало операции flag_byte |=1<<options;
 	LDS	R16,flag_byte	; Чтение предыдущего значения

А тут хопа, так фаза Луны совпала и вдруг пришло прерывание именно в этот момет. А что у нас в прерывании? Правильно! Сохранение регистров, что то вида::

1
2
3
4
5
; Начало обработчика вектора ISR (USART_RXC_vect)
	PUSH	R17
	IN	R17,SREG
	PUSH	R17
	PUSH 	R16

А дальше наше тело прерывания:

1
2
3
4
5
; Начало операции flag_byte|=1<<rcv_buff;
	LDS	R16,flag_byte	; Чтение
	ORI	R16,1<<rcv_buff	; Модификация
	STS	flag_byte,R16	; Запись
; Конец операции flag_byte|=1<<rcv_buff;

Выставили мы битик rcv_buff и записали в ту же переменную флагов. А что потом? Правильно — возврат регистров из стека и возврат:

1
2
3
4
5
6
	POP 	R16
	POP	R17
	OUT	SREG,R17
	POP	R17
	RETI
; Конец прерывания ISR (USART_RXC_vect)

Куда возврат? Туда же где прервались:

1
2
3
	ORI	R16,1<<options	; Модификация
	STS	flag_byte,R16	; Запись
; конец операции flag_byte |=1<<options;

Замечательно, а что же у нас в регистре R16 в данный момент?

Очевидно то же самое, что и до входа в прерывание — состояние флаговой переменной flag_byte. Вот только заковыка — это состояние флаговой переменной уже не актуально, устарело! Т.к. его только что изменило прерывание, выставив там какой то другой свой флаг. И теперь, произведя модификацию и запись у нас в flag_byte будет записана не актуальная инфа уже с двумя стоящими флагами rcv_buff и options, а восстановленная из «бэкапа» предыдущая копия с одним лишь options. А событие которое нам пыталось донести прерывание, выставив свой флаг, было забыто вообще.

И что самое гадкое такие ошибки фиг отловишь если не знаешь что такое может произойти. И могут они быть сразу, а могут коварно переждать в засаде весь процесс отладки, а когда девайс уйдет в серийное производство или на ответственный пост внезапно выскочат и хорошо если никто не умрет.

Лирическое отступление, можно пропустить:
Когда то давно отлаживал я программу одну, она была на ассемблере, но там я просто забыл что у меня есть переменная — адрес перехода. Которая может меняться и в прерывании тоже. Так вот, все работало идеально, но примерно на 30 минуте работы программа вставала колом или начинала гнать полную ересь. Я убил тогда на отладку около недели. Программа была довольно большая, там могло быть что угодно, начиная от переполнения стека до срыва очередей диспетчера задач. В ход пошла уже тяжелая артиллерия в лице логического анализатора и JTAG — без толку. Попробуй поймай багу на хрен знает какой итерации главного цикла, да еще при совпадении условий внешних воздействий в фиг знает каком порядке. Трассировать или отлаживать в протеусе это почти бесполезно.
Причем малейшие изменения в коде приводили к видоизменению баги. Добавил в код парочку NOP — клиническая картина резко меняется. Чудом мне удалось поймать момент, когда прогу перекашивало точно на 25 итерации главного цикла после отпускания кнопки. Выглядело это примерно так: послать в USART точно 10 байт, причем последний должен быть непременно «R», потом нажать кнопку и не отпускать пока не будет нажата вторая. И вот когда вторую отпустить прога встает колом.

Дотрассировав до этого места в JTAG я наконец понял где собака порылась — адрес перехода состоял из двух байт и лежал в ОЗУ. Первый байт менялся в фоне программы, и мог быть изменен в прерывании. И вот когда это прерывание вклинивалось в между первым и вторым байтом при занесением нового адреса, то в результате получался гибрид состоящий из двух разных половинок адреса, ясен фиг что переход получался черти куда и выглядело это как срыв стека, хотя стек тут был вообще не причем. Впрочем, про характерную клиническую картину ошибок я тоже собираюсь потом написать.

Итак, враг найден. Но что же с ним делать? А делать тут собственно нечего. Единственный способ избежать этой напасти это либо не использовать раздельно используемые переменные, либо запрещать прерывания при их модификации в фоновой программе — делать атомарными.

Атомарная — значит не делимая. Не делимая прерыванием. Поэтому то команды запрета/разрешения прерываний

1
2
3
cli();
flag_byte |=1<<options;
sei();

Спасают ситуацию.

А остальные? Они ведь тоже делаются не за одну операцию! Ну в данном случае в прерывании они не используются и поэтому пока можно спать спокойно, но держать на контроле надо.

А вот операции вроде SBI/CBI делаются за одну команду и являются атомарными от природы.

Страшно да? А ведь многие об этом даже не задумывались.

Да и в самом деле, что считать атомарным а что нет? Не закрывать же в конце концов параноидально все операции с разделяемыми переменными и IO регистрами командами cli/sei. Как никак прерывания вещь важная и созданная именно для того, чтобы реагировать быстро.
И, например, на AVR нельзя сделать атомарную операцию на регистры ввода вывода старше 1F — там просто нет команд логических операций на IO, а вот на PIC24 (а может и на более младших, хз не знаю я PIC) можно, через битовые маски по XOR, т.к. там есть команда xor по IO. Подробней можно прочитать в одной замечательной статье, она хоть и про PIC24, но полезна будет всем.

Так что мало знать что есть вилы, надо уметь их обходить с минимальными потерями. Вот для этого то и надо хорошо знать ассемблер того контроллера на котором пишешь, на уровне хотя бы десятка — двух программ отличных от тупой мигалки светодиодом. Чтобы сразу за сишным кодом видеть возможные ассемблерные инструкции, знать что будет делать компилятор в том или ином случае. Где можно ожидать вилы в стоге сена.

Отлично, враг известен, чем его мочить тоже выяснили. Но любое оружие может замочить и хозяина. Если им неумело пользоваться. Поясню на примере:

Вот сделали мы какую нибудь функцию, а чтобы прерывания нам не нагадили мы добавили в них конструкцию cli/sei на критичные места. Все довольны, все счастливы. Ага, до тех пор пока в целях оптимизации и универсальности кода мы не вкатим эту функцию в обработчик прерывания. В принципе, ничего страшного нет, пользоваться в прерыванях функциями можно. Вот только надо учитывать что в прерываниях прерывания же аппаратно запрещены, а наша функция по выходу из критических мест их разрешает. И тут у нас вылезают другие вилы — вложенные прерывания. Это бага не столь законспирированная как неатомарный доступ, но вылезти тоже может ой как не сразу.

Что делать? А тут все просто. Если у нас есть функция с атомарными операциями и юзается она и в прерываниях и в основном теле программы, то надо бы это учитывать и не менять флаг прерывания зря. Т.е. если он был сброшен (в прерывании) то мы его и не возвращаем обратно — незачем.

На ассемблере это может выглядеть, например, так:

1
2
3
4
5
6
7
8
9
10
11
12
; Begin Atomic Block
	IN	R17,SREG	; Сохранили регистр SREG, а в нем и флаг I
	PUSH	R17		; можно в стеке, можно еще где. Не принципиально
	CLI			; Запретили прерывания
 
	... 			; Тут у нас код который должен быть атомарным
	...			;
	...			;
 
	POP 	R17		; Достали сохраненное в стеке
	OUT	SREG,R17	; Вернули SREG на место
; End Atomic Block

Разрешать прерывания тут не надо. Т.к. мы сохранили сразу весь SREG. Если прерывания были разрешены, то по доставании его из стека они достанутся разрешенными. Ну а если не были разрешены то ничего и не изменится.

На Си же можно тупо в лоб проверять условием наличие флага I регистра SREG и в зависимости от этого делать потом разрешение прерывания или нет. Запрещать их в любом случае. Либо применить инлайновый ассемблер своего Си компилятора.

А еще можно поискать уже готовые макросы в составе компилятора.

В WinAVR GCC например есть такой хидер как util/atomic.h

где есть макросы:

1
2
3
4
     ATOMIC_BLOCK(type)
     {
       код который будет атомарным;
     }

Где в качестве type возможны два варианта: ATOMIC_FORCEON — прерывания по выходу из атомарного блока будут включены и ATOMIC_RESTORESTATE в котором состояние флага I будет таким же какое и на входе в блок. Запрещены — так запрещены, разрешены так разрешены. Но работает чуть медленней и памяти требует больше.

Также там есть макрос разатомаривания.

1
2
3
4
NONATOMIC_BLOCK(type)
     {
       код который будет неатомарным;
     }

Т.е. он делает обратную операцию — разрешает прерывания в своем чреве. Иногда удобней запретить прерывания во всей функции, но в центре разрешить, чем делать два атомарных блока в начале и в конце.

Синтаксис этих макросов такой же, а опции по аналогии NONATOMIC_RESTORESTATE — оставляет как было. NONATOMIC_FORCEOFF — принудительно выключает прерывания по выходу.

Так что можно вместо:

1
2
3
cli();
flag_byte |=1<<options;
sei();

Написать

1
2
3
4
ATOMIC_BLOCK(ATOMIC_FORCEON)
{
flag_byte |=1<<options;
}

И это будет эквивалентно. Единственное что код потеряет часть переносимости. По хорошему бы вообще вынести все эти cli(); sei(); в отдельный хидер где заменить из на что нибудь вроде Interrupt_Enable(); Interrupt_Disable(); и тогда этот код можно будет перетащить на любой контроллер, лишь бы там был Си компилятор. А уж привязать к Interrupt_Enable(); местный аналог sei(); куда проще в одном файле в одном месте чем перелопачивать весь сырок.

З.Ы.
Также недавно появилась отличная статья Виктора Тимофеева про то как надо правильно писать код. Настоятельно рекомендую к прочтению.

 

Добавить комментарий

Обратная связь

Интересуют вопросы реализации алгоритмов, программирования, выбора электроники и прочая информация, постараюсь осветить в отдельных статьях

пишите мне на netdm@mail.ru