Interrupções de Hardware e Concurrency Hell
Trabalhando com PIC, PIT e as consequências disso
No post anterior iniciamos o tratamento de interrupções, em especial na instalação do IDT e de exceções (interrupções de software). Fiz algumas correções no código, os códigos hexadecimais no arquivo isr.asm
estavam errados. Mas nada que você não consiga olhar o código e entender a mudança.
Isso posto, vamos ao que interessa: interrupções de hardware! Você vai entender o motivo de termos separado as interrupções. É necessário primeiro entender como as interrupções de hardware ocorrem.
O processador x86 possui uma linha dedicada para ser notificado das notificações de hardware. Quando o nono bit do registrador EFLAGS (começando de zero) está setado, significa que as interrupções estão ligadas. Qualquer indivíduo que envie sinais na linha de interrupção do processador fará com que o processador pare o que está fazendo para tratar o dito cujo. E quem é que está ligado nessa linha de interrupção no processador x86? Ele mesmo, o PIC (Programmable Interrupt Controller)!
O PIC é um microcontrolador da família 8259A, um hardware muito simples (quando comparamos com um processador), responsável apenas por gerenciar as interrupções de hardware. Os periféricos são ligados nas linhas de pedido de interrupção, e o hardware é tão simples que ele só tem 8 dessas linhas disponíveis. O pedido de interrupção é popularmente conhecido como IRQ (Interrupt Request).
Epa, só 8 IRQs? Meu computador tem muito mais hardware que isso!
Você é perspicaz, pequeno jovem. De fato, 8 IRQs para todos os hardwares é muito pouco. Inclusive antigamente, no tempo de guaraná de rolha, você tinha que individualmente nos hardwares indicar qual IRQ ele iria usar. Observe na imagem abaixo de uma placa ISA Soundblaster com seus jumpers de IRQ destacados.
Era muito comum inclusive dois hardwares diferentes usarem a mesma IRQ, ai você já viu onde isso vai dar. Hoje em dia é pouquíssimo comum algum tipo de configuração desse tipo, mas fica aqui o registro.
Mas voltando ao problema original, de ter a disposição apenas 8 IRQs, uma característica interessante do 8259A é que ele pode ser usado em cascata. O que isso significa? Que você usar umas das IRQs do 8259A para ligar outro 8259A! Assim, acabamos com 8 IRQs do primeiro PIC mais 8 IRQs do segundo. Menos um na verdade: umas das IRQs do primeiro é usada para ligar o segundo). Assim, temos a nossa disposição incríveis 15 IRQs para plugar todos os hardwares.
Note que nem todo hardware precisa ser ligado ao PIC, apenas os que interrompem o processador. Por exemplo, dispositivos de saída, como monitores, não necessariamente precisam usar interrupções (hoje em dia tudo é programado, mas isso é outra história).
O diagrama de ligação do processador x86 e dos PICs é apresentado a seguir.
Obs: Se seu sistema está usando APIC (Advanced Programmable Interrupt Controller) ou IOAPIC (Intel I/O Advanced Programmable Interrupt Controller), provavelmente o funcionamento é diferente. Nesse caso, sugiro que você verifique como eles funcionam AQUI e AQUI.
Continuando, então quando um hardware ligado a um dos PICs aciona um IRQ, ele identifica por qual linha de IRQ o pedido veio, e converte essa identificação em um número binário e o armazena dentro do registrador do PIC. Por exemplo, o IRQ 0 se refere ao PIT (Programmable Interval Timer), e o veremos a seguir. Por hora, ele transforma esse número zero em zero binário (duh!) e o armazena nesse registrador. O PIC então aciona a INT Line para avisar o processador, e se ele estiver de bom humor (o nono bit do registrador EFLAGS estiver setado), o processador para o que estiver fazendo para tratar essa interrupção. O processador usa o valor do registrador do PIC para acessar a entrada correspondente no IDT e encontrar a rotina de tratamento. Para ver o post anterior sobre IDT, acesse AQUI.
Como funciona para a cascata do segundo PIC? Suponha que chegou uma IRQ na quarta linha (começando do zero) do segundo PIC. Essa interrupção veio do Mouse PS/2 (sabe o que é isso jovem?). Esse PIC armazena quatro em binário no seu registrador, e ativa a sua linha de interrupção.
Essa linha de interrupção está ligada diretamente na IRQ 2 do PIC master. Quando vem interrupções dessa linha, o PIC sabe que ocorreu uma interrupção no PIC em cascata. Ele então consulta no registrador do primeiro PIC para descobri qual foi a interrupção que ocorreu. Ele então adiciona o offset de suas próprias interrupções (ele possui 8 linhas de interrupção) e armazena em seu registrador o número resultante. Portanto, no PIC master, o que está registrado é o valor 12. Esse, por sua vez, aciona a linha interrupção para indicar um evento de interrupção ao processador.
A tabela de IRQs é bem conhecida e pode ser vista abaixo.
No fim das contas, depois das correções feitas se a interrupção se originou no PIC em cascata, o número de 0 a 15 indicará qual entrada no IDT ficará responsável por tratar a respectiva interrupção de hardware. IRQ 0 é o PIT, IRQ 1 é o teclado e assim por diante.
Algo não me cheira bem…
Os atentos perceberão que a entrada 0 do IDT já está preenchida, muito bem obrigado, pela exceção de software Erro de Divisão. Inclusive, simulamos isso no post anterior. Sendo assim, como o processador sabe se o que ocorreu foi um erro de divisão ou o timer querendo atenção?
A resposta triste é que ele não sabe. Alguém na IBM não se atentou a isso, foi um erro de design (ahhhhh quem nunca, né)?
No modo real, a BIOS remapeia as interrupções 0 a 7 para 8 a 0xf no IVT, conforme tabela abaixo:
Se você notar na tabela de exceções, notará que a maioria das exceções não acontece no modo real 16 bit. Por exemplo, não faz sentido ter uma exceção Page Fault nesse modo. A BIOS é esperta e sabe posicionar tanto as exceções de software quanto as interrupções de hardware fazendo o offset acima no IVT.
Já no modo protegido 32 bit… Ai a gente tomou na rabiola. Agora nós temos 32 exceções, que conflitam com as IRQs.
Nossa sorte é que, assim como a BIOS, nós também conseguimos reprogramar os chips para que eles respondam com offsets diferentes e assim usarem outras entradas no IDT.
Vamos programar esses dito cujos então!
O que mais se encontra na internet são códigos para reprogramar os PICs, se você quer ir direto ao ponto, acesse esses códigos (ou o meu no github) e use-o à vontade. Mas eu gosto de saber o que estou fazendo, da razão das coisas, do motivo que alguns valores são usados e detesto mais ainda Magic Numbers, pelo menos até o limite do meu conhecimento. Isso porque há ocasiões onde eles são inevitáveis, programar microcontroladores em geral tem mais relação com Engenharia Elétrica do que com Ciência da Computação. Mas faremos o possível para esclarecer o que der.
Começamos definindo as funções outb
e inb
(arquivo common.h
).
...
static inline void outb(uint16_t port, uint8_t value) {
asm volatile("outb %0, %1" : : "a"(value), "Nd"(port));
}
static inline uint8_t inb(uint16_t port) {
uint8_t value;
asm volatile("inb %1, %0" : "=a"(value) : "Nd"(port));
return value;
}
...
São instruções para enviar ou receber dados (nesse caso, um byte) na/da porta de hardware definida como argumento. Os hardwares ficam atentos aos valores que passam nesse barramento, se algum dele casa com o endereço de hardware correspondente, o hardware reagirá de acordo.
Definimos ainda no mesmo arquivo a função io_wait
, que usa a função outb
definida anteriormente para enviar um valor qualquer em uma porta não usada por hardware nenhum. Isso garante que o comando emitido anteriormente tenha sido recebido e processado pelo hardware. Encare isso como uma instrução nop
em um pipeline. A porta que estamos usando aqui, no caso é a 0x80
(#define UNUSED_PORT 0x80
).
...
static inline void io_wait() { outb(UNUSED_PORT, 0); }
...
E por onde começamos a programar os PICs? Você começar pelo datasheet do 8259A, uma leitura (nada) agradável. Se você não é engenheiro eletricista, prevejo problemas, você encontrará coisas como os diagramas abaixo.
Lá tem como você deve encontrar o passo-a-passo de como programar o bixo, em uma língua que lembra vagamente inglês.
Eu também tenho dificuldades em ler esse tipo de documento, e perdi algum tempo relacionando o código de programação do PIC disponível (por exemplo, no Skelix e no OSDev) com o que tem no datasheet. O resumo da ópera é que o 8259A é um chip de interrupções de propósito geral, ele tem vários modos de operação, com várias combinações de opções possíveis. Mas existe um subconjunto dessas opções que se adequa à arquitetura x86 e em como os PICs estão organizados em cascata nessa arquitetura. A sequência de comandos que devemos enviar aos PICs é a seguinte:
Começamos enviando à porta de dados dos PICs (
0x20
no master,0xA0
no slave) o byte relativo à inicialização do PIC. Dependendo dos bits enviados, os PICs esperarão outros bytes na porta de dados deles (0x21
no master e0xA1
no slave). Esses bytes são identificados pela sigla ICW (Initialization Command Words). Esse próprio byte que estão enviado é o primeiro deles: ICW1. Essa primeiro byte diz que estamos enviando um comando de inicialização (ICW1_INIT
), que enviaremos posteriormente um byte ICW4 (obviamente depois dos bytes ICW2 e ICW3, se for o caso) (ICW1_WITH_ICW4
), estamos trabalhando com PICs em cascata (ICW1_CASCADE_MODE
), estamos trabalhando no modo de endereçamento das IRQ default (ICW1_INTERVAL_8
), e em modo level default (ICW1_LEVEL_MODE
). Esse último tem relação com a sensibilidade do onda quadrada que dispara a interrupção, e se você quer mais detalhes, veja o quadro abaixo ou vá direto no datasheet.void install_pic(int new_offset_pic_master, int new_offset_pic_slave) { // ICW1 (Initialization Command Words number one - Init command) outb(PIC_MASTER_IO_COMMAND_PORT, ICW1_INIT | ICW1_WITH_ICW4 | ICW1_CASCADE_MODE | ICW1_INTERVAL_8 | ICW1_LEVEL_MODE); outb(PIC_SLAVE_IO_COMMAND_PORT, ICW1_INIT | ICW1_WITH_ICW4 | ICW1_CASCADE_MODE | ICW1_INTERVAL_8 | ICW1_LEVEL_MODE); io_wait();
Com isso, os PICs sabem que estão em modo de inicialização, e esperarão pelos próximos bytes ICW. O segundo deles (ICW2) indica qual é o novo offset que os PICs devem responder. Como queremos índices depois das 32 exceções de software (ou
0x1f
), queremos que o primeiro PIC responda no intervalo de0x20
a0x27
, enquanto o segundo responda do0x28
a0x2f
).// ICW2 (Offset definition) outb(PIC_MASTER_IO_DATA_PORT, new_offset_pic_master); outb(PIC_SLAVE_IO_DATA_PORT, new_offset_pic_slave); io_wait();
Como estamos trabalhando em modo cascata, os PICs esperarão o byte ICW3 para indicar como eles estão arranjados. Lembre-se, os chips 8259A são chips de propósito geral e podem ter várias formas de arranjo. Nada impediria de você cascatear mais alguns PICs no master, ou mesmo pendurar mais algum PIC no slave. Nossa missão é portanto indicar ao master em qual IRQs há PICs slaves, e indicar aos slaves a identificação deles perante o master. No nosso caso, por exemplo, temos que indicar que o slave está na IRQ 2 (
0001b
= IRQ 0,0010b
= IRQ 1,0100b
= IRQ 2,1000b
= IRQ 3 etc), e que o identificador do slave é 2. Se houvesse PICs nas IRQs 5 e 7, por exemplo, indicaríamos ao master o valor10100000b
, ou0xa0
. E para os respectivos PICs, indicaríamos os valores 5 e 7. A figura abaixo ajuda a entender esse processo de identificação das IRQs e do PIC.// ICW3 (Cascade mode) outb(PIC_MASTER_IO_DATA_PORT, ICW3_MASTER_IRQ2); outb(PIC_SLAVE_IO_DATA_PORT, ICW3_SLAVE_IRQ2_BINARY); io_wait();
Por fim, como indicamos inicialmente que usaríamos o ICW4, melhor apresentar o diagrama abaixo. No fim das contas, todos os bits estão zerados/default com exceção do bit zero, onde indicamos que usaremos o modo 8086/8088.
// ICW4 (Operation Model) outb(PIC_MASTER_IO_DATA_PORT, ICW4_8086 | ICW4_NORMAL_EOI | ICW4_NON_BUFFERED | ICW4_NOT_FULLY_NESTED_MODE); outb(PIC_SLAVE_IO_DATA_PORT, ICW4_8086 | ICW4_NORMAL_EOI | ICW4_NON_BUFFERED | ICW4_NOT_FULLY_NESTED_MODE); io_wait();
Os procedimentos acima terminam a programação dos PICs e eles já estão operacionais e podem receber OCWs (Operation Control Words). Por fim, mascaramos todas as interrupções. Isso significa que os PICs sabe que as interrupções estão ocorrendo, eles só avisam ao processador. Fazemos isso a medida que vamos tratando as interrupções individualmente: vamos cuidar do PIT, então desmascaramos a IRQ 0. Depois, o teclado, e desmascaramos a IRQ 1 e assim por diante. Resumindo, bit 0 na IRQ, interrupção inibida. Caso contrário, a IRQ pode notificar sobre interrupções. Isso do lado dos PICs. Do lado do processador, precisamos avisar que as interrupções estão ativas, setando o nono bit (começando do zero) do registrador
EFLAGS
. Isso é feito com a instruçãosti
.outb(PIC_MASTER_IO_DATA_PORT, DISABLE_ALL_INTERRUPTS_MASK); outb(PIC_SLAVE_IO_DATA_PORT, DISABLE_ALL_INTERRUPTS_MASK); sti(); }
Pergunta de prova: há alguma outra forma de setar o bit de interrupções do processador sem usar a instrução
sti
? Claro que sim, só que além das instruçõescli
esti
, as únicas instruções que manipulam diretamente o registrador EFLAGS são as instruçõespushf
/popf
. Você poderia fazer assim então:
pushf
or dword [esp], 0x200
popf
Ou seja, manda pra pilha, faz um or bitwise com o valor 0x200
(nono bit, começando por zero) e pega o valor de volta. Mas divago, isso é só uma curiosidade.
Já podemos aproveitar e definir a operação EOI (End Of Interruption). Após “servir” uma interrupção ao processador, o processador deve enviar um comando de acknowledge, informando ao PIC que ele já tá sabendo da treta e que vai tomar as providências necessárias. Se isso não for feito, o PIC parará de enviar notificações ao processador, até que ele envie o comando EOI. O único problema é que os PICs não se conversam, eles são brigados, esses putos. Então, se IRQ originou no segundo PIC (por exemplo, a IRQ 12 do mouse PS/2), devemos enviar o comando EOI aos dois. É bem fácil de fazer isso: basta checar o número da interrupção, e se ela estiver na faixa do segundo PIC, enviamos o comando para ele também.
Para enviar o EOI ao PIC, basta enviar o valor 0x20
para as portas de comando dos PICs (0x20
para o master e 0xA0
para o slave).
void pic_send_eoi(int isr_nr) {
if (isr_nr >= PIC_SLAVE_OFFSET) {
outb(PIC_SLAVE_IO_COMMAND_PORT, PIC_EOI);
}
outb(PIC_MASTER_IO_COMMAND_PORT, PIC_EOI);
}
Moleza, hein. Vamos agora cuidar da IRQ 0.
PIT - Programmable Interval Timer
Assim como o 8259A, o PIT é um microcontrolador da família 8254, e serve para como um gerador de interrupções a uma frequência pré-determinada. O 8254 também é um microcontrolador de propósito geral, e portanto, novamente há várias opções, modos e frequências que podem ser usadas. O datasheet desse demônio pode ser encontrado AQUI.
O 8254 recebe comandos na porta 0x43
, e possui três canais que respondem nas portas 0x40
, 0x41
e 0x42
, respectivamente. Isso serve para compor várias frequências possíveis. No nosso caso, quero gerar 1000 interrupções por segundo (1000Hz), apenas o canal 0 (porta 0x40
) é suficiente. O PIT opera em aproximadamente 1.193182 MHz, então iremos programar o PIT para executar a esse valor ai dividido pela frequência desejada (vai ser 1000):
void install_pit(int desired_hz) {
unsigned int divisor = FREQUENCY_DIVISOR / desired_hz;
Só calculamos o divisor da frequência, mas ainda não o informamos ao PIT. Primeiro vamos programar o modo de operação, que é enviar o modo para a porta 0x43. Os modos possíveis são:
Queremos então o modo binário (bit 0=0b
), no modo de operação de geração de taxa (bits 1 a 3=110b
), modo de acesso low byte/high byte (bits 4 e 5=11b
) no canal 0 (bits 6 e 7=00b
). Vou tentar explicar o que sei desses valores.
O modo BCD de 4 dígitos (Binary-Coded Decimal) possui variações, mas o mais tradicional a gente já conhece: é a representação binária de um número decimal, e para isso precisamos de 4 bits. O ponto é que não leremos informação nenhuma do PIT, queremos que ele apenas gere as interrupções, o valor interno não nos importa. Por isso o modo binário.
Quanto ao modo de operação, o Skelix usa o modo de gerador de onda quadrada, enquanto o OSDev usa Geração de Taxa. No datasheet, temos o seguinte:
Como queremos usar interrupções em tempo real, e não parece ter taaaaanta diferença assim em relação ao que o Skelix usa, ficaremos com o modo 2 mesmo, rate generator. Na página do OSDev tem uma explicação legal para quem quiser se aprofundar.
Esse trecho do código fica então assim:
outb(MODE_COMMAND_PORT,
BINARY_MODE | RATE_GENERATOR | LOW_BYTE_HIGH_BYTE | COUNTER_0);
Hora de enviar o divisor ao canal do PIT. Como o modo de acesso foi definido para low byte/high byte, primeiro então enviamos os o bits menos significativos do divisor ao canal zero, seguido dois 8 bits mais significativos.
outb(CHANNEL_0_DATA_PORT, divisor & 0xff);
outb(CHANNEL_0_DATA_PORT, divisor >> 8);
É isso. Basta agora desmascarar a IRQ 0 para que o processador seja avisado das novas interrupções do timer. A gente preserva o valor anterior alterando só o bit da IRQ 0, então basta fazer um and bitwise com o valor 0xfe
(todos os bits setados, com exceção do último. Isso vai preservar o valor original de todos os bits e zerar o último bit, que é o bit relacionado à IRQ 0).
outb(PIC_MASTER_IO_DATA_PORT, inb(PIC_MASTER_IO_DATA_PORT) & UNMASK_IRQ_0);
Isso é (quase) suficiente para terminar o PIC e PIT. Só que apesar de termos apresentado a função pic_send_eoi
, a gente ainda não a usou. No primeiro tick do relógio, se não chamarmos essa função, o PIT parará de funcionar. Então, vamos tratar a IRQ 0 (arquivo interrupt.cpp
):
void irq0_system_timer(int isr_nr, __attribute__((unused)) int err_code) {
pic_send_eoi(isr_nr);
...
};
Os três pontinhos representam o trabalho atual do tratador da IRQ 0, que não será feito agora. Basicamente, aqui é que chamaremos o escalonador para trocar as tarefas e de fato tornar este um kernel preemptivo. Mas isso não será feito agora.
Definida o handler irq0_system_timer
, precisamos instalá-lo no arquivo isr_handlers.cpp.
void (*isr_handlers[])(int, int) = {
// exceptions
&division_error, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &default_isr_handler, &default_isr_handler,
&invalid_opcode, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &general_protection_fault, &default_isr_handler,
&default_isr_handler, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &default_isr_handler, &default_isr_handler,
&default_isr_handler, &default_isr_handler,
// IRQS
&irq0_system_timer, &default_irq_handler, &default_irq_handler,
&default_irq_handler, &default_irq_handler, &default_irq_handler,
&default_irq_handler, &default_irq_handler, &default_irq_handler,
&default_irq_handler, &default_irq_handler, &default_irq_handler,
&default_irq_handler, &default_irq_handler, &default_irq_handler,
&default_irq_handler};
Se você notar direitinho, apareceu um handler general_protection_fault
. Já já vai ficar claro o motivo disso…
Enfim, para fazer esse paranauê todo funcionar, basta instalarmos o PIC e o PIT no arquivo kernel.cpp
. A função completa _start
por enquanto está assim:
...
#define DESIRED_TIMER_HZ 1000
...
extern "C" void _start() {
_init();
clear_screen();
install_gdt();
install_idt();
install_pic(PIC_MASTER_OFFSET, PIC_SLAVE_OFFSET);
install_pit(DESIRED_TIMER_HZ);
*user_entry_point = &kernel_entry;
sched->add_task(&thread1, true);
sched->add_task(&thread2, true);
sched->add_task(&thread3, true);
sched->add_task(&thread4, true);
sched->add_task(&thread5, true);
sched->add_task((void (*)())PROCESS1, false);
sched->add_task((void (*)())PROCESS2, false);
do_exit();
while (true) {
/* BUSY LOOP*/
}
/*If code has landed here, something very wrong happened...*/
_fini();
}
E para testar se a IRQ 0 está realmente funcionando, alterei a função irq0_system_timer
para imprimir um contador na tela. Ela ficou assim:
static int teste = 0;
void default_irq_handler(int isr_nr, __attribute__((unused)) int err_code) {
pic_send_eoi(isr_nr);
printk("IRQ ISR %x\n", isr_nr);
};
void irq0_system_timer(int isr_nr, __attribute__((unused)) int err_code) {
pic_send_eoi(isr_nr);
printk("IRQ0 %d\n", teste++);
...
};
Ao compilar e executar, você perceberá que em algum momento o PIT parará de responder.
Concurrency Hell
É agora que a mãe chora e o bebê não vê, meu amigo.
Você está todo feliz ai, tratando de problema de concorrência em modo usuário no Linux? Usando pthreads
, protegendo regiões críticas, programando em CUDA, mexendo com Streams paralelas em Java… Está desesperado? Pfff meu amigo, me acompanha que agora fica tenso.
Meus skills de debugging melhoraram 1000% depois que eu consegui resolver esses problemas. Aqui, o gdb é seu amigo, mas ele é um amigo que não conversa muito, sabe? Só te responde o que você pergunta… Difícil lidar com esse cara.
Voltando ao problema, notei que em algum momento a IRQ 0 parava de responder. Obviamente que, se isso não acontecia antes, deve ser algo que introduzimos agora no código, certo?
Comecei comentando todo o trecho do meu escalonador. No fim das contas, depois da função install_pit
, só tinha o busy loop no fim da função _start
. E quando eu deixava assim, o PIT voltava a funcionar. 1000 vezes por segundo.
O PIC e PIT estão então, de fato funcionando. O que estava havendo afinal?
Depois de muito executar, trocar as tarefas executadas, colocar threads de kernel, processos, etc, em algum momento eu dei a sorte de interromper a execução e verificar o conteúdo dos registradores. Veja se você também nota algo esquisito:
Olhe ali logo acima do registrador EIP. Aquilo é o conteúdo do registrador EFLAGS
: overflow, direct, interrupt, trap, sign, zero, auxiliary carry, parity e carry flags, nessa ordem, da esquerda para a direita. Letra minúscula, flag = 0, letra maiúscula, flag = 1. Nesse exemplo da foto, as flags que estão ativas são a Zero flag e Parity flag. Mas notou que a flag de interrupções está desabilitada? Em que momento desabilitamos as interrupções?
O problema todo, jovem, é que o diabo mora nos detalhes. De fato, não há nenhum momento que EXPLICITAMENTE desabilitamos as interrupções, não intencionalmente.
Lembra das maneiras de se alterar o EFLAGS
? cli
/sti
e pushf
/popf
… Isso é feito assim que as tarefas são colocadas para executar, na função scheduler_entry
:
scheduler_entry:
push ebp
mov ebp, esp
call stop_kernel_timer
mov esp, [current_task]
lea esp, [esp + K_REGS_LIMIT]
pushfd
pushad
mov [esp + ESP_OFFSET], ebp
mov esp, ebp
call scheduler
mov esp, [current_task]
popad
popfd
mov esp, [esp - ESP_OFFSET_FROM_LIMIT]
call start_timer
pop ebp
ret
Logo após call scheduler
vamos no PCB recuperar os registradores da tarefa, e os recuperamos com popad
e popfd
. Mas para uma tarefa recém-criada, quais são esses valores? Pois é, não definimos. E o compilador tratou de zerar os valores no construtor da classe PCB
.
Assim, ao fazer o popf
, o registrador EFLAGS estará todo zerado. Para a tarefa em si, isso não tinha problema nenhum, mas para o sistema, zero significa interrupções desligadas.
Então, a primeira tentativa de corrigir isso foi definindo o registrador EFLAGS como 0x202 (o primeiro bit, começando em zero, fica sempre ligado, conforme essa página dita).
Bom, tá fácil de resolver isso né? Basta ir no método PCB::config
e setar esses registradores:
// set eflags to current value (to allow interrupts, if set)
this->kregs[8] = EFLAGS_RESERVED | EFLAGS_INTERRUPT_ENABLED;
this->uregs[8] = EFLAGS_RESERVED | EFLAGS_INTERRUPT_ENABLED;
Piece of cake! E agora ao executar…
Agora o problema foi mais sério, não foi meu kernel que capotou (ele também) mas o QEMU. Rapaz, para derrubar o emulador é sinal que seu código está MUITO errado.
E nisso vai mais duas semanas debugando…
A volta da região crítica
Depois de muito tempo perdido (ou aprendizado, se você quiser ver o copo meio cheio), notei que em algum momento a retornar da interrupção (instrução iret
) ele retornava para endereços de memória inválidos.
Na realidade, o tratador dessa interrupção estava com o registrador esp
apontando para endereços fora da faixa definida para isso 0x40000
- 0x52000
. O esp estava na verdade apontando para o PCB da tarefa atual. E foi ai que caiu a ficha.
Lembra da função scheduler_entry
? Vamos analisá-la novamente.
scheduler_entry:
push ebp
mov ebp, esp
call stop_kernel_timer
mov esp, [current_task]
lea esp, [esp + K_REGS_LIMIT]
pushfd
pushad
mov [esp + ESP_OFFSET], ebp
mov esp, ebp
call scheduler
mov esp, [current_task]
popad
popfd
mov esp, [esp - ESP_OFFSET_FROM_LIMIT]
call start_timer
pop ebp
ret
Lembre-se que as interrupções são implacáveis, elas podem acontecer a qualquer momento. O que acontece quando uma interrupção ocorre entre as opções mov esp, [current_task]
e lea esp, [esp + K_REGS_LIMIT]
? Nesse momento, estamos preparando para salvar os registradores no PCB, e o registrador esp
NÃO aponta para uma pilha válida, ele está apontando para um PCB. Se uma interrupção acontecer dai até o restauro da pilha na instrução mov esp, [esp - ESP_OFFSET_FROM_LIMIT]
(e isso vai acontecer, acredite em mim!), seu sistema vai para a casa do chapéu.
Isso é uma região crítica. Precisamos proteger o recurso (nesse caso, o registrador esp
) para que outro concorrente não possa bagunçá-lo. Nesse caso, não tem jeito: em todo trecho que há alteração do esp para apontar para o PCB, precisamos desabilitar as interrupções:
scheduler_entry:
push ebp
mov ebp, esp
call stop_kernel_timer
cli
mov esp, [current_task]
lea esp, [esp + K_REGS_LIMIT]
pushfd
pushad
mov [esp + ESP_OFFSET], ebp
mov esp, ebp
sti
call scheduler
cli
mov esp, [current_task]
popad
popfd
mov esp, [esp - ESP_OFFSET_FROM_LIMIT]
sti
call start_timer
pop ebp
ret
O princípio é o mesmo para a troca de contextos de processo para kernel, na função kernel_entry
, mas para evitar repetições, vou omitir aqui. Consulte meu github para verificar os detalhes sórdidos.
Agora não precisamos mais definir o EFLAGS
no PCB
. Ao sair das funções kernel_entry
e scheduler_entry
o código se encarregará de ligar as interrupções com a instrução sti
.
Agora vai?
Bonus Round
No fim das contas, eu ainda estava com problemas. De vez em quando ele gerava um General Protection Fault (o motivo de eu ter instalado um handler para ver o que estava acontecendo), às vezes disparava um Invalid Opcode e os handlers default sem eu ter solicitado… algo definitivamente ainda estava errado. Ele com certeza estava executando códigos que não foi eu quem escrevi.
Como não acreditamos em Poltergeist na computação, em algum momento percebi que ele estava acessando a Vtable da classe Scheduler
. Oxi, mas eu não estava mais usando a classe derivada FairScheduler
, por qual motivo o método virtual sched
estava sendo invocado se meu objeto não era do tipo FairScheduler
? Vamos relembrar como essa classe está:
class Scheduler {
...
public:
...
virtual ~Scheduler() = default;
void operator delete(void *, unsigned int) {};
virtual void sched(QueueNode<PCB> *);
};
class FairScheduler : public Scheduler {
public:
void sched(QueueNode<PCB> *);
};
Não fazia sentido. Notei também que isso acontecia quando eu tentava fazer um unblock
no lock
…
extern Scheduler sched;
void Lock::lock_acquire() {
if (this->mutex.exchange(true)) {
sched.block(this);
}
}
void Lock::lock_release() {
if (this->blocked != nullptr) {
sched.unblock(this);
} else {
// Util::printk("empty queue\n");
this->mutex.store(false);
}
}
Mas calma lá! No arquivo scheduler.cpp, para usar o FairScheduler
, alteramos a variável sched
para usar ponteiro!
// FairScheduler fairSched;
// Scheduler *sched = &fairSched;
Scheduler roundRobinSched;
Scheduler *sched = &roundRobinSched;
Considerando que estamos falando da mesma coisa (por isso o extern
no arquivo lock.cpp
), no arquivo scheduler.cpp
a variável sched
é um ponteiro, mas no arquivo lock.cpp
ele é um objeto! Bom, se for só isso, é fácil de alterar:
extern Scheduler *sched;
void Lock::lock_acquire() {
if (this->mutex.exchange(true)) {
sched->block(this);
}
}
void Lock::lock_release() {
if (this->blocked != nullptr) {
sched->unblock(this);
} else {
// Util::printk("empty queue\n");
this->mutex.store(false);
}
}
Isso é um bug que estava dormente há muito tempo e só se manifestou agora. Magicamente, agora tudo funcionou!
O código, até o momento está no meu github.
Qual é a moral da história dessa linda fábula que acompanhamos até o momento? Não cometa erros, não programe bugs. Espero ter ajudado! :D