Cómo se accede al hardware

Dado el éxito que están teniendo estas entradas, voy a dar otra vuelta de tuerca al tema y voy a explicar cómo un programador puede trastear con el hardware, cosa que al final, todos los micros terminan haciendo. No voy a hablar de Windows, ni de ningún otro sistema operativo, sino que voy a intentar explicar cómo, a través del código, se accede al hardware, periféricos y demás. Esta entrada es completamente teórica y no me voy a centrar en ningún micro ni hardware en concreto, ya que no vale la pena dada la amplia variedad disponible y que, además, esto no se puede hacer desde Windows (ni desde cualquier otro PC si no arrancamos con algo como MS-DOS y a veces ni siquiera así).

Todos sabéis (o deberíais) que un microprocesador consta de varios subconjuntos lógicos: la ALU, que hace cálculos aritméticos, los registros de propósito general, que son unas celdillas de memoria interna al micro para usos varios, la unidad de decodificación y ejecución, que se encarga de interpretar la instrucción cargada, y en micros más modernos podemos encontrarnos con módulos convertidores analógico-digitales, unidades de coma flotante, unidades SIMD, etc.

La arquitectura de los microcontroladores es algo diferente a la de los microprocesadores, muchas veces sólo en apariencia, aunque en general programar un microcontrolador es más fácil porque normalmente sólo hay que ir activando bloques y funcionan sin más. Es decir, si queremos un timer, o un AD, o una UART (puerto serie, para entendernos), simplemente se activan y configuran con las instrucciones adecuadas y listo. En un microprocesador suele ser más complicado, ya que hay de por medio otros temas, como prioridades entre los dispositivos y demás.

Por otro lado, cuando en informática se habla de unos y ceros, se está haciendo una importante abstracción lógica. En general, a nivel de señales eléctricas, lo que se entiende por un uno podría ser que una pata del micro esté a 5V, pero no siempre es así, porque si es una arquitectura basada en lógica negativa, un uno será cuando dicha pata esté a 0V y un cero cuando valga 5V. Es decir, dependiendo de la lógica, un uno será una cosa u otra. De hecho, conforme vamos avanzando en velocidades, un uno ni siquiera es un nivel, sino un flanco. En otras palabras, cuando nosotros leemos una pata de un micro a 1 lo que hemos leído es el paso de la misma de 0 a n voltios, o al revés. De hecho, nunca debemos asumir que, si leemos algo y está a 1, la pata equivalente vaya a estarlo también. Dependerá de muchas cosas y siempre tendremos que morir en la documentación técnica del micro, de la placa o de quien quiera que haya hecho nuestro hardware.

Y con esto hemos dado un paso más: podemos leer el nivel (o mejor dicho, el estado) de una pata. Es decir, dentro de nuestro micro hay algo que cuando nosotros ejecutamos una instrucción en ensamblador, nos devuelve un 0 o un 1 dependiendo de lo que haya ocurrido en una de sus patas. En general, y dependiendo del tipo de micro, las patas vienen agrupadas en bloques de 8 o 16, que es lo que se conoce como un puerto, y una lectura sobre él nos devolverá un byte o un word con el equivalente lógico.

En los microcontroladores es habitual que el espacio de direcciones se encuentre separado por tipos. Tendremos un espacio de direcciones para el código, otro para la RAM (ya sea interna o externa), otro para los puertos que acabamos de ver, para EEPROM interna si tenemos, etc. O visto de otra forma: no nos vale con decir que queremos leer de la dirección de memoria 0x12345678, sino que tenemos que indicar también de qué tipo es. En general, esto se consigue con el tipo de instrucción ensamblador, que es diferente para cada uno.

No obstante, a veces los espacios están mezclados, o solo tenemos uno, o dependiendo del rango de dirección estaremos actuando sobre la RAM, la ROM o lo que sea. Todo ello depende del micro y cómo se configure, y os puedo asegurar que cada fabricante lo hace de una forma, no sólo para los suyos, sino incluso para cada familia o grupo.

Si esos elementos están dentro del micro, lo tenemos fácil, ya que con muy pocas instrucciones (y a veces ninguna), éste está listo para funcionar. Hay micros que tienen RAM, EEPROM, ROM, IO, todo ello dentro de sí mismo, pero hay otros en los que algunos de estos elementos están (o pueden estar) fuera, en circuitos independientes.

Es entonces cuando entran en juego el chip select, que son unas patas extra que sirven para alertar al dispositivo adecuado. Supongamos por un momento que nuestra arquitectura tiene todos los elementos externos y que vamos a configurarlos de la siguiente manera: de la dirección de memoria 0 a la 0x7fff, está la ROM en el chip select 0. De la 0x8000 a la 0x8fff tenemos la EEPROM, y de la 0x9000 a la 0xafff la RAM. Lo primero es conectar esos rangos del bus de direcciones con cada chip equivalente. Por ejemplo, para la ROM podemos dejarnos sin conectar la pata 15 del bus de direcciones porque no va a ser usada nunca, ahorrando complejidad en las pistas.

Luego tenemos que cablear los chip select, que en nuestro caso son dos patas extra del micro. Cuando en ellas haya un 00, estaremos accediendo a la ROM, cuando haya un 01 a la RAM, y cuando haya un 10 a la EEPROM. Si nos damos cuenta nos queda la combinación 11, que podríamos asignar a periféricos o dejar sin usar. Tenemos que poner combinaciones de puertas lógicas de tal forma que cada una de esas combinaciones activen el o los chips correspondientes.

¿Cómo? Pues con lógica combinatoria, puertas AND, NAND, NOR, XNOR, etc. No hay una única solución, y cuando las placas son algo complejas se suele usar una FPGA o similar, que debe ser programada para esto y para más cosas. Recordemos que quizás un chip esté activo cuando su pata CS esté a cero, y si se tratara de los chips donde está la ROM, tendríamos que primero negar los dos CS que salen del micro y luego poner una NAND entre ellos (y da la casualidad que hay un chip con esa configuración exacta).

Ahora que ya tenemos el hardware, nos hace falta el software. Lo primero es decirle al micro qué CS se corresponde a qué área de memoria, de forma que cuando escribamos dentro de un rango, se active automáticamente la combinación de CS elegida.

No lo he dicho, pero un micro tiene un bus de direcciones y un bus de datos. En el bus de direcciones se pone la dirección a acceder, y en el de datos o bien obtenemos el dato que haya en esa dirección o bien lo ponemos nosotros para que sea leído por el elemento externo. En el caso que estamos viendo, se trata de un micro de 8 bits, que tendrá un bus de 16 bits (generalmente 16 patillas dedicadas a ello, aunque a veces no son tantas y entonces hay que hacer “trucos” que no voy a explicar aquí) y otro de 8 para los datos. Volviendo a los párrafos anteriores, tanto el bus de datos como el de direcciones está conectado a todos los chips externos que los necesiten (ya hemos visto que, por ejemplo, el bit de mayor peso del de direcciones no es necesario para la ROM y por tanto no es necesario cablearlo). Con el CS correspondiente, tan sólo se activará el chip correspondiente, que atenderá la petición.

Ahora necesitamos una pata extra, la WR, para indicar al periférico si queremos leer o escribir. Esto también lo controla el micro automáticamente, es decir, si ejecutamos una instrucción de ensamblador que es una lectura, cambiará la pata de forma adecuada y automáticamente.

Pero todavía no hemos terminado. Nos falta el reloj, ya que sin él todo lo que hemos explicado estaría muerto. El reloj no es más que un generador de onda cuadrada (más o menos, he visto relojes con el osciloscopio que me dan serias dudas de que la placa funcione) al que se conectan absolutamente todos los periféricos y el micro, y aquí sí, aquí lo válido son los flancos. A cada flanco, se produce un paso. Es lo que se llama ciclo de reloj. En algunos micros y hardware son válidos tanto los flancos de subida como los de bajada, en otros sólo los de subida o los de bajada.

Ante cada ciclo, se produce un evento en el sistema. Imaginemos que el micro ya sabe qué instrucción va a ejecutar. Pongamos por caso que se trate de una lectura de una dirección de RAM. Pasa un ciclo y el micro mueve las patas de los CS y WR, alertando al chip de la RAM. En el caso que nos ocupa, los CS valdrían 01, lo que activaría la entrada CS de la RAM (y sólo de la RAM). Al ciclo siguiente el micro pone en el bus de direcciones la dirección a leer. Dado el CS adecuado, sólo la RAM se pondrá a la escucha, o validará lo que quiera que venga después. En el siguiente ciclo la RAM leerá del bus de direcciones, y necesitará un determinado número de ciclos más para hacer su trabajo, al cabo de los cuales pondrá en el bus de datos el valor solicitado, y en el siguiente, el micro lo tomará. Esto es sólo un posible ejemplo de lo que ocurre. Algunos elementos necesitan un solo ciclo. Por ejemplo, el micro podría poner la dirección, los CS y el WR en un solo ciclo, y la RAM ser tan rápida que al siguiente ya tenga la solución en el bus de datos. En general, cuando un elemento complejo necesita un solo ciclo, es que dicho elemento es capaz de subdividir cada ciclo en varios más de forma interna.

Otra de las cosas que debemos decirle al micro antes de empezar es cuántos ciclos tiene que esperar a que el dato pedido esté disponible o sea grabado para cada CS o rango de direcciones. De hecho, grabar todos estos valores (y más) es lo que se conoce como startup del micro. Si os fijáis, el CS para la ROM es 00, lo que viene a decir que cuando el micro arranque empezará a leer a partir de una dirección predefinida (a veces dependiendo de cómo se encuentren las patas de arranque, por llamarlas de alguna manera). Es en esa dirección, y siguientes, en las que se encuentran las instrucciones para ir activando y configurando todos estos elementos.

De hecho, cada micro, cada familia de micros, cada fabricante, lo hace a su manera, indica unas direcciones y unas formas concretas, no sólo de cómo arranca un micro, sino también del tipo de lógica, periféricos permisibles, velocidades de reloj, retardos máximos y mínimos, y un larguísimo etcétera. Por ejemplo, los micros ARM tienen un encendido más que curioso. Necesitan de un chip que inyecte, a través de un canal serie, el código de arranque. Es decir, al encendido el micro está muerto, y es un chip externo el que, a través de una de las patas del micro, inyecte el código necesario para que éste sea capaz de arrancar. Se trata de un sistema de seguridad bastante potente, porque en ese código podría estar el algoritmo para decodificar una ROM encriptada.

***

Bueno, ya tenemos nuestro micro encendido y funcionando. ¿Qué? Ah, se me olvidaba, que todavía no sabemos cómo decirle todo lo que hemos visto. Aquí la cosa resulta algo más sencilla, ya que sólo hay unas pocas aproximaciones posibles.

En general, cada micro tiene un conjunto de registros especiales, que es donde nosotros escribiremos y leeremos la configuración. A veces el registro viene implícito en la instrucción, es decir, existe una única instrucción para modificar dicho elemento. Si tenemos un AX, BX, DX, que vienen codificados en la instrucción, también podemos tener un MBAR…

Cuando uno ejecuta una instrucción en ensamblador, por ejemplo “MOV AX,1234”, su código de operación (lo que realmente está leyendo el micro), es 0x87, por lo que, en la ROM, nos encontraríamos con los valores hexadecimales 0x87, 0x34, 0x12. Con esto no quiero decir que para un Intel, el código de operación de esa instrucción sea 0x87, es solo un ejemplo inventado.

En binario, ese código de operación es 10 000 111. Fijaros que lo he dividido en tres grupos. Lo que hace el micro, cuando está ejecutando esa instrucción, es leer los dos primeros bits, 10. Esos dos primeros bits le dicen a la unidad de decodificación que es una instrucción MOV. Podríamos tener cuatro combinaciones, 00, 01, 10, y 11. La primera podría ser para JMP, la segunda para CMP, la tercera para MOV y la cuarta para una instrucción multibyte o extendida. Es sólo un ejemplo.

Ya sabe que es un MOV, y también sabe que los tres bits siguientes son el registro de destino, y los tres últimos el de origen, así que sigue decodificando (quizás en un nuevo ciclo de reloj). 000 se corresponde al registro AX, y 111 a código inmediato, es decir, el registro de origen no es un registro, sino los dos siguientes bytes dentro de la ROM, por lo que inicia la secuencia descrita arriba para obtenerlos, y una vez que lo tiene todo, termina ejecutando el tema y poniendo 0x1234 en el acumulador. Y aquí es donde entran los ciclos máquina que cada instrucción necesita, que suelen ser diferentes incluso para cada modo de una misma. Por ejemplo, MOV AX,BX quizás requiera sólo dos ciclos, decodificar y ejecutar, pero MOV AX,0x1234 requerirá 2 ciclos más 4 para obtener el primer byte inmediato más 4 para el segundo, y eso sin contar los de espera hasta que los datos han salido de la ROM.

Si os fijáis, el orden de los bytes inmediatos en el ROM está invertido. Es una de esas cosas que son porque son, sin más justificación que el fabricante así lo ha decidido. Aunque no es realmente cierto, quizás al colocarlos así ahorra en puertas lógicas en la unidad de decodificación, pero es otra cosa más a tener en cuenta, y es el orden de los bytes, Little Endian o Big Endian.

Ahora que ya sabemos cómo decodifica una instrucción un micro, quizás el código de operación 0xc5 más los bytes 0x00 0x1f sean la instrucción para decirle al micro que el MBAR está en 0x1f00. Por tanto, nuestras tres primeras instrucciones han sido ejecutadas, y le hemos dicho al micro que, a partir de ahora, los registros especiales están en 0x1f00.

Esto nos lleva a otra forma de configurarlo. Hay micros en los que los registros especiales son simples direcciones de memoria normales y corrientes pero que, en lugar de acceder a memoria normal (sea del tipo que sea), simplemente cambian la configuración interna del mismo. Quizás escribir 0x01 en la dirección 0xffff sea decirle al micro que está usando memoria externa, y que escribir 0x0000 en las direcciones 0xfffe y 0xfffd respectivamente sean decirle que el chip select 0 empieza en 0x0000. Y a su vez al escribir 0x7ffff en las 0xfffc y 0xfffb le estemos diciendo que la dirección final del CS 0 sea esa misma.

Y lo mismo con los demás CS y sus correspondientes registros mapeados en memoria. Esta es la forma más común, pero así estamos usando direcciones que no podremos utilizar en nuestros programas, por lo que algunos micros utilizan instrucciones especiales. Por ejemplo, si hacemos MOV 0xffff,0x01, estaremos escribiendo en RAM, pero si hacemos IMOV 0xffff,0x01 lo haremos en la configuración del micro.

Pero no os asustéis, en general todo esto ya viene predefinido con los ensambladores y los compiladores mediante definiciones y macros, e incluso a veces los fabricantes implementan construcciones y extensiones a C o a C++ (y a ensamblador) para lidiar con todo esto. Por ejemplo, si en un compilador de C de Imagecraft para Atmel colocamos

__flash int cero=0;

en lugar de

int cero=0;

ese entero irá en ROM en lugar de RAM. Sin embargo, la línea con la extensión, en uno de IAR para el mismo micro, nos colocará cero en la flash interna en lugar de la ROM. Y no quiero entrar en esto, en cómo cada fabricante pone las extensiones que les sale de los cojones y como les salen de los cojones, eso daría para otro post con más mala leche que este.

Volviendo al tema que nos ocupa, si ahora queremos leer el estado de bytes de un puerto, una simple instrucción en C no los soluciona:

unsigned char puerto=PINC;

El compilador hará lo que tenga que hacer para que en puerto esté la representación binaria de bits de lo que quiera que haya en dicho puerto. La macro PINC tiene su intríngulis, ya que podría ser cualquier cosa, desde una llamada a una función intrínseca al compilador hasta una simple lectura de una dirección física o una instrucción en ensamblador, quizás RMOV 0xff33,puerto.

Pongamos por caso que hayamos conectado al bit0 del puerto C (que se corresponderá con una pata del micro), un sensor de una puerta, que pondrá dicho bit a 1 (lógico) cuando la puerta se abra. Por tanto, el código:

void main(void)
{
    inicializa_hardware();
    for(;;)
    {
        if((PINC&0x01)!=0)
        {
            suena_sirena();
        }
    }
}

Nos servirá para construir una alarma. Quizás la función suena_sirena() escriba un 1 en otro bit de otro puerto, cuya correspondiente pata del micro vaya conectada a un transistor que a su vez activará un relé que pondrá en marcha una sirena…

Dentro de inicializa_hardware() habremos puesto sentencias como:

PORTC=0xff; //Encender todos los pull-up internos del Puerto C

DDRC=0x00; //Poner el Puerto C como entradas

PORTD=0x00; //Quitar los pull-ups del Puerto D

DDRD=0x01; //Poner el Pin0 del Puerto D como salida

PIND=0x00; //Apagar la sirena.

Los comentarios son auto descriptivos, y nos enseñan cómo poner un puerto como elemento de entrada (para leer datos de él) o de salida (para escribir), si queremos tener pull-ups (unas resistencias especiales para añadir carga), etc. Luego esto será traducido a código ensamblador del micro correspondiente, y de nuevo la parte izquierda del igual son, en este caso, macros.

De todos modos la cosa no es tan sencilla. En este caso da igual que escribamos en un puerto de entrada, el micro lo ignorará, pero en otros microprocesadores no se puede hacer a riesgo de romper algo, por lo que operaciones mixtas requieren técnicas de doble buffer, como mantener un juego del estado de entradas o salidas anteriores (lo que de paso nos daría para mantener flancos lógicos, que nada tienen que ver con los físicos de la placa).

Por si no ha quedado claro, hacer algo como PINC|=0x01; significa leer el estado del puerto C y, sin importarnos cuál es el valor del bit0, ponerlo a uno (ya que una o lógica con uno de los dos bits a 1 siempre resultará en 1) sin modificar los demás y volver a escribir el resultado en el puerto. Como antes hemos leído los valores, sólo estamos cambiando el bit0. Pero ocurre que en algunos micros las salidas no se pueden leer (no es que no se puedan, sino que no leen nada coherente), por lo que tenemos que mantener variables de estado (de nuevo el doble buffer).

Esta operación de escritura en ese puerto, quizás sea compilada como una escritura en una dirección de memoria, o la ejecución de una instrucción especial de ensamblador, pero el resultado final será que la pata del microprocesador que se corresponda con ese bit pasará a estar a un uno lógico, que realmente podría ser ponerse a 5V, o a cero, o cambiar su estado, o ejecutar un flanco de subida, o de bajada, o uno de subida y luego otro de bajada, o justo al revés. Todo dependerá de qué micro y cómo esté configurado.

Cómo salta Windows entre anillos (Modo Kernel y modo Usuario)

Esta semana estamos de fiesta, y es que una de mis pasiones en el desarrollo siempre ha sido verle las tripas a Windows, y a ello vamos. Antes de nada, lo que voy a contar aquí es un resumen del punto System Service Dispatching del capítulo 3 de Windows Internals 5ª edición, pero es un resumen un poco especial porque voy a añadir cosas de mi cosecha, como siempre hago, ya que no es cosa de ir parafraseando lo que voy aprendiendo/recordando.

En entradas anteriores, y algunas veces en los foros, he comentado sobre saltos entre anillo 0 y anillo 3, y aquí voy a explicar de pasada lo que significa. Un microprocesador moderno (y no tan moderno), cuenta con un juego de instrucciones en cierta medida especiales, instrucciones que son necesarias para configurar el hardware y el propio funcionamiento del software. Hablamos aquí de las interrupciones y toda su parafernalia asociada, del control de la memoria virtual, del acceso directo a hardware (es decir, escribir o leer en una dirección de memoria o en un puerto en el que no hay tal, sino alguna electrónica que debe reaccionar en base a dicha lectura o escritura), etc.

Esas instrucciones son peligrosas de usar, y en general no deben estar disponibles para los programas normales, ya que entonces un error podría llevar a la caída total del sistema o incluso a la rotura de algún elemento electrónico.

Es decir, que podríamos dividir el juego de instrucciones de un microprocesador en dos grandes grupos: aquellas que no son necesarias para que un programa funcione normalmente y las que, si se usan de forma incorrecta, pueden terminar armando un buen pirifostio.

Pues básicamente eso son los anillos 0 y 3. En el 0 están las instrucciones peligrosas, en el 3 las que cualquier programa puede usar, y si un programa normal utiliza una del cero, generará una excepción (¿De qué me sonará esto?) que será capturada por el sistema operativo y manejada a su antojo. Aunque realmente no es del todo así, para nuestro propósito es suficiente.

(Si os dais cuenta, he hablado del 0 y del 3, pero también hay un 1 y un 2, que Windows no utiliza. Digamos que la separación de las instrucciones en estos distintos niveles permiten una serie de facilidades a la hora de construir software de sistemas, ya sean sistemas operativos o aplicaciones que vayan a correr en un microprocesador con estas características, lo que ocurre es que Windows aprovecha estos niveles únicamente de una sola forma, que llama modo usuario y modo kernel).

Pues bien, el kelmer, uis, perdón, el kernel, la mayoría de los drivers, y el motor gráfico (que fue ampliamente criticado en su momento pero que permitió que NT 4.0 pudiera ser usado en ordenadores no tan potentes) se ejecutan en el anillo 0, y los demás subsistemas (incluido Win32) en el 3. De esta forma un programador chapucero (o un compilador) sólo podrá tumbar su programa y nada más. Recordemos los días de Windows 3.x y DOS en los cuales tumbar por completo el sistema era tan fácil como ejecutar dos instrucciones de ensamblador seguidas: cli y luego sti, o al revés, ya no me acuerdo.

Una aplicación llama a una función, que debe terminar siendo ejecutada dentro del kernel (por ejemplo, abrir y leer de un fichero, cosa que necesita que los drivers de disco hagan trabajo de bajo nivel sobre la controladora y sobre el propio disco). Pongamos que nuestro programa llama a WriteFile(), y nos da igual que sea una aplicación .NET llamando a sus clases, ya que terminará en esa función. Esa función, que está en Kernel32.dll y pertenece al subsistema Win32, llamará a NtWriteFile(), que está en Ntdll.dll y no pertenece a ningún subsistema, sino que está disponible para cualquiera de ellos. Esta función llamará entonces a KiSystemService(), que está en Ntoskrnl.exe y se ejecuta en el anillo 0 en lugar del 3. Finalmente, esta última función hará lo que tenga que hacer llamando a otras funciones del kernel y de los drivers hasta que al final devolverá el resultado hacia atrás hasta nuestra aplicación.

Fácil, ¿no? Pues no, porque si nuestra aplicación hubiera llamado a WriteFile() con algún parámetro mal que fuera crítico (y se me ocurre pasar un valor aleatorio diferente de cero al handle del fichero sobre el que queremos escribir), lo más seguro es que termináramos en una pantalla azul (como más o menos ocurría en Windows 3.x y a veces en Windows 9x). Además, es muy posible que durante el procesamiento de la función, se salte varias veces entre el modo kernel y el user por necesidades del guión.

Pero todavía hay más, ya que no hemos tenido en cuenta que el controlador de tiempos y de procesos puede cortar en cualquier momento esa llamada para atender otros procesos o incluso hilos de nuestra aplicación. Vamos, que no es fácil ni de lejos.

¿Cómo se produce el salto entre el anillo 3 y el 0, que hemos descrito más arriba? Pues depende del procesador que estemos usando. En la época de Windows 9x (y de la parte en modo protegido de Windows 3.x cuando la había), y para micros antiguos, NtWriteFile(), después de verificar que los parámetros recibidos son correctos, coloca en varios registros del procesador lo necesario, y ejecuta la instrucción int 0x2e, que genera una excepción si se hace desde el anillo 3. Esta excepción es recogida por el Kernel, que mediante los parámetros que hay en los registros determina si es una petición válida (y si no lo es protestará) y la procesará mediante la ejecución de la función correspondiente situada en un array de punteros a función que se llama System Service Dispatch Table, de las que creo que hay varias. Básicamente, lo que está haciendo es recibir un índice que se corresponde a una posición dentro del array de punteros, y como nos equivoquemos con esto la hemos cagado a base de bien, y es por eso por lo que antes del salto entre anillos se verifican los parámetros. El retorno al anillo 3 se produce con la ejecución de la instrucción iretd.

En micros Intel más modernos, el salto se hace mediante la instrucción sysenter y la vuelta mediante sysexit (o iret si estamos en modo depuración). En un AMD las instrucciones son syscall y sysret.

Y finalmente, en micros de 64 bits con la arquitectura x64 las instrucciones son las mismas que en el AMD (por que fueron los primeros en implementarla) y en la I64, epc.

Si os dais cuenta, dependiendo del microprocesador se hace de una u otra forma, lo que puede ser un serio quebradero de cabeza. Lo que hace Windows al arrancar un sistema de 32 bits es instalar la instrucción correcta en una posición de memoria y juego ejecutar sobre ella, con lo que a lo anterior añadimos un nuevo nivel de indirección.

La secuencia completa de paso del anillo 3 al 0 consiste, más o menos, en lo siguiente: primero se verifican los parámetros recibidos de la forma más completa posible, luego se ejecuta la instrucción que hay en cierta posición de memoria, que a su vez generará una excepción, que será controlada por el sistema operativo, que mirará si los parámetros son correctos, y si lo son volverá a saltar sobre un puntero a puntero a función, y finalmente lo que haya allí hará el trabajo.

Eso para un solo salto. Imaginaros cuántos son necesarios para que nuestro programa, y el sistema operativo funcione. Y no hemos hablado de que en cada una de esos pasos es necesario que la pila se guarde o se recupere, y que los datos deben ser copiados desde la pila de modo user a la de modo kernel para evitar que la aplicación pueda modificarlos en medio de una operación interna…

En un sistema x64 la operación es algo más sencilla porque la instrucción de salto es siempre la misma y no es necesaria la indirección extra sobre la instrucción ya que da igual que el microprocesador sea un Intel o un AMD.

Esto quizás te presente una duda en el caso de equipos de 32 bits: ¿por qué no está cableada dicha instrucción y simplemente a la hora de instalar Windows o de arrancarlo, el cargador pone en memoria el juego de DLLs correcto? Pues realmente no tengo ni idea, lo que sí tengo claro es que en ese caso Windows debería contar con al menos 4 juegos completos de DLLs y o bien elegir el necesario durante la instalación o casi cuadriplicar el espacio ocupado en disco. Lo que sí es cierto es que si se hubiera hecho así, el Windows de 32 bits funcionaría sensiblemente más rápido… o no.

Sólo nos queda ver, por mor de completitud, cómo funciona internamente el kernel sobre sí mismo. Supongamos por un momento que el kernel necesita acceder a la funcionalidad que hay en WriteFile(), pero en lugar de llamar a esta función directamente, lo que hace es llamar directamente a la rutina a la que se está apuntando en la tabla SSDT y santas pascuas, ya que en principio no se necesita verificar los parámetros que se suponen son correctos, y aparte de eso ya estamos en el anillo 0. No obstante, un driver no puede hacerlo así porque Microsoft no se fía de ellos, y es por ello que existen unas funciones que tiene como prefijo Zw y que son las que llamarán a su homónimo apuntado por la SSDT después de realizar las verificaciones –menos que si se llamara desde el anillo 3- oportunas.

Eligiendo qué hacer cuando pete nuestra aplicación

En la entrada anterior expliqué qué ocurre cuando una aplicación peta y se genera una excepción no controlada (o controlada pero relanzada), y en esta voy a explicar cómo podemos, desde nuestro propio programa, configurar el modo en que eso ocurre. Pero antes una introducción.

En algunos lados he dicho que las excepciones son caras. Con eso no me refiero a que cuesten dinero, sino a que es un tipo de característica que resulta muy complicada de procesar y que a veces requiere de bastante tiempo de proceso. Cuando se dispara una interrupción en una aplicación, sobre todo si ocurre en el lado del kernel, éste tiene que entrar y salir varias veces entre su modo y el de usuario, operaciones que no son baratas (de nuevo no en dinero, aunque lo mismo en su servidor en producción sí) ya que significan saltar entre los anillos 0 y 3 del procesador. Aparte de eso, se debe recorrer la pila (a ambos lados de los anillos) para ir buscando el controlador adecuado, ejecutarlo y en la mayoría de casos continuar con la búsqueda.

Cuando ponemos un bloque try/catch, o __try/__except ó __try/__finally, el compilador deja “marcas” en la pila, “marcas” que serán leídas por el controlador de excepciones. Dependiendo de cómo lo hayamos hecho, y el número de bloques y de controladores, el sistema tendrá que salvar y restaurar el estado de la pila y de la aplicación para ir ejecutando los diferentes bloques de captura, y a veces incluso se necesita de cierta “inteligencia” para que la ejecución sea la correcta. El tema queda bastante claro una vez que se han leído los capítulos de los dos libros que mencioné, con una excepción: que esos recorridos requieren tiempo, sobre todo si hay saltos entre anillo 0 y 3, por lo que en general el uso de excepciones está para eso: para ser usadas cuando realmente se produzca una, y no alegremente como una mera característica de nuestro lenguaje.

Dicho esto, considero que el ejemplo que Jeffrey Ritcher pone al final del capítulo 25 no es muy correcto, no porque esté mal, sino porque realizar gestión de memoria mediante excepciones me parece que es matar moscas a cañonazos… aparte del desperdicio de la misma. Ritcher crea una hoja de cálculo de un tamaño predefinido pero sin asignar ningún tipo de memoria. Luego, ante cada requerimiento de que una celda sea llenada con algo, intenta escribir en la posición. Si ya hay memoria asignada todo irá bien, pero si no, se disparará una excepción que asignará un bloque a dicha celda. La barbaridad está en que cada asignación, aunque sea de un solo byte, asignará 4KB (que es el tamaño de página de RAM), y en que el gasto de la excepción y su control es enormemente más caro que simplemente comprobar si la memoria está asignada o no. A no ser que se me escape algo, como ejemplo sobre el funcionamiento de las excepciones, vale, pero como ejemplo de algoritmo genérico, no.

De todos modos, el sistema de excepciones es my potente y versátil, y con la combinación del registro, podemos configurar a base de bien el comportamiento de las no controladas. Windows Internals lista 25 claves para cambiar el comportamiento, alguna de ellas tan radicales como hacer que ni siquiera se ejecute WerFault.exe y nuestra aplicación desaparezca de forma silenciosa.

Pero como diría el personaje de los dibujos animados, no se vayan todavía, que hay más. Aparte de poder instalar y controlar ciertas funciones de control de excepciones, podemos configurar el modo con que nuestra aplicación se comunica con el gestor de las mismas. Es decir, mediante ciertas llamadas a funciones de Win32, podemos decirle al sistema WER cómo debe comportarse, qué ficheros adjuntar, etc.

La mayoría de las funciones que voy a citar aquí de pasada, se encuentran dentro de kernel32.dll y para usarlas debemos incluir werapi.h.

  • WerSetFlags()/WerGetFlags(). Cambiamos o leemos si queremos que se incluya el heap (montículo) en el informe, si se suspenderán todos los hilos o sólo el que falló, si se añadirá el informe a la lista de errores, y si se enviará o no a Microsoft.
  • WerAddExcludedApplication()/WerRemoveExcludedApplication(). Añadir o eliminar una aplicación a la lista de aplicaciones excluidas de la generación de informes.
  • WerRegisterFile()/WerUnregisterFile(). Añadir o quitar el fichero indicado al informe.
  • WerRegisterMemoryBlock()/WerUnregisterMemoryBlock(). Añadir/Qutar un volcado de un área de la memoria de nuestro programa.

También podemos crear un informe desde nuestro programa cuando queramos. Los pasos a seguir son:

  1. Llamar a WerReportCreate(), que inicia el informe.
  2. Llamar tantas veces a WerReportSetParameter() como parámetros queramos cambiar.
  3. Llamar tantas veces a WerReportAddDump() como bloques de memoria queramos incluir.
  4. Llamar tantas veces a WerReportAddFile() como ficheros del tipo que sean queramos incluir.
  5. Llamar a WerReportSetUIString() para añadir opciones a la pantalla de WerFault.exe.
  6. Llamar a WerReportSubmit(), que dependiendo de la configuración para el programa y/o genéricas que se tengan, enviará o no, preguntará o no, etc. qué hacer con el informe.
  7. Llamar a WerReportCloseHandle() para terminar con el tema.

Como podéis ver, la cosa es muy potente, y puede ser enormemente útil en determinadas situaciones. Imaginemos que nuestra aplicación esté petando en casa del cliente y no haya modo de ver qué puñetas pasa. La generación de informes a demanda podría ser la solución. Lo que no he visto es cómo recibir e interpretar esos informes.

También podemos usar estas funciones cuando sea nuestra aplicación la que esté controlando la excepción, pero debemos ser cuidadosos ya que ésta podría estar tan estropeada que el uso de todo esto volviera a genera nuevas excepciones que terminaran en el mismo código, que a su vez generara otras que…

Se supone que Windows es lo suficientemente inteligente como para detener la cadena, pero como yo ya he visto más de una vez petar de forma continua a una aplicación en Windows Vista hasta que la he matado desde el administrador de tareas, debemos ser cuidadosos con su uso.

De excepciones, del depurador, de Windows y de Visual Studio

Esto leyendo la quinta edición de Windows Internals, que cubre Windows Vista y Windows Server 2008 y es la última versión disponible del libro. Allá por los años del Windows 95 y del NT 4.0, un poco antes, un poco después, estaba yo muy interesado en las tripillas de los sistemas operativos, y aparte de empacharme con algún que otro mamotreto genérico sobre el tema, le di caña a los equivalentes, como Windows 95, al descubierto, o el Windows NT de Helen Custer, así como otros de similar temática. Vamos, que prácticamente me leí todo lo que pude conseguir sobre las interioridades de esos sistemas operativos.

Pero desde hace un tiempo me veo en la tesitura de que, conversando aquí y allí, me he dado cuenta de que me he quedado bastante obsoleto en el tema, con cosas que no sólo han sido mejoradas, sino que ahora funcionan de forma completamente diferente, así que me he puesto las pilas y le estoy dando un buen repaso al libro…

Bueno, pues como iba diciendo, el capítulo 3 del citado no es que sea nada del otro mundo. Bueno, sí, lo cierto es que como no sepas de programación de hardware y hayas hecho algo, te vas a quedar poco menos que a dos velas con muchas de las cosas que aparecen, ya que tienen que ver con la gestión de interrupciones, tanto de software como de hardware, así como las excepciones y demás intríngulis que todo sistema operativo ha de cumplir.

Personalmente yo he hecho un par de proyectos basados en un microprocesador, aunque generalmente, cuando lo hago, suelo trastear con microcontroladores más o menos potentes, y la verdad es que muchas de las cosas que aparecen ahí explicadas, con la evidente diferencia entre un sistema operativo como es Windows y lo que un mindundi como yo haya podido hacer, me las he encontrado en mis desarrollos. Tener en cuenta las prioridades y los niveles de las interrupciones, programarlos y cambiarlos en tiempo real, instalar y quitar vectores de interrupción, etc.

Por lo menos eso es de lo que trata la parte del capítulo 3 que he leído, que ciertamente me parece demasiado detallista en cosas que, la verdad, a no ser que vayas a meterte de lleno en la creación de drivers primarios para Windows, de poco te sirve. Y si sabes de sistemas operativos, lo único llamativo es que te enteras de que Windows no es mucho más diferente que otros sistemas.

Volviendo al tema (lo que me mola irme por las ramas), sobre la página ciento y pico (je je, me quedan todavía más de mil cien por leer, ¡qué emoción!) se pone a hablar del control de las excepciones del sistema operativo, y cómo son controladas en conjunción con el software de aplicación (ese que tu y yo hacemos) y el propio sistema. Y entonces te dice que si no tienes claro cómo funciona a nivel de programador, que mires los capítulos finales de Windows via C/C++. Y como resulta que tengo ese libro en la lista de pendientes (hace unos años leí y estudié la misma versión pero mucho más antigua, y encima en castellano), pues dejé uno y me puse con el otro, máxime recordando que fueron precisamente esos capítulos los que no leí en la edición anterior.

El sistema de excepciones de Windows es muy potente y versátil, y todos deberíamos saber lo básico sobre él. Pero no, no voy a entrar en detalles sobre el mismo, simplemente os diré que funciona muy parecido a las excepciones de un lenguaje de programación, con la salvedad de que es el sistema operativo el que las lanza y las gestiona si no lo has hecho tu.

En Windows Vista y siguientes existe una aplicación que se llama WerFault.exe y que seguro que habéis visto en ejecución. De hecho, cada vez que os peta un programa, es esa aplicación la que toma el control y saca la barra de progreso moviéndose y te permite unas u otras acciones una vez que ha hecho sus deberes.

Es decir, cuando una aplicación genera una excepción, o el sistema operativo dispara una causada por la aplicación (por ejemplo, una división entre cero o una escritura fuera de rango), se trastea con la pila de tu programa y se busca un controlador de excepción adecuado. Ojo, no estamos hablando de bloques try/catch, sino de bloques __try/__except… aunque a veces el control termina también en un catch, sobre todo si usamos C++ y hemos habilitado la compatibilidad de control de excepciones del sistema operativo (y esto no viene explicado así en el libro, que asume la compatibildad). En .NET no sé qué ocurrirá, pero no creo que haya mucha diferencia.

Si nuestro programa tiene un manejador para esa excepción, se ejecutará, y dependiendo de lo que devuelva, se reintentará la instrucción (de código máquina, así que ojo) que generó la excepción, se continuará como si nada hubiera pasado (y en este caso podríamos liarla todavía más gorda), o se seguirá buscando otro nuevo manejador. Es decir, independientemente de lo que quiera que hagamos en nuestro controlador, podemos decirle al sistema operativo que reintente, que ignore o que busque otro controlador más.

Una vez que se han recorrido todos los controladores adecuados, además de dos más extra que podemos instalar mediante AddVectoredExceptionHandler() y SetUnhandledExceptionFilter(), y siempre que se haya llegado al final de toda la cadena de excepciones (que es algo más larga y compleja de lo que aquí he expuesto) sin haber sido detenida, Windows lanzará el citado programa, y le pasará un manejador al proceso que está fallando, aparte de detener por completo la ejecución de todos sus hilos (en XP sólo se detenía el que fallaba) mediante unas APIs ALPC no documentadas.

Este programa, en base a algunas claves del registro, hará una cosa u otra. Como ha recibido los manejadores en modo heredado, tendrá control sobre el programa que está fallando. Aparte de añadir el evento correspondiente, podrá enviar el informe a Microsoft, o añadirlo a la lista de pendientes. Incluso existe la posibilidad de que ni siquiera aparezca, haciendo todas sus tareas de forma oculta, o incluso que no haga ninguna. Podemos incluso filtrar el comportamiento según qué aplicación haya fallado.

Después de que ha hecho sus tareas internas, si existe algún depurador registrado y si la configuración (o lo que hayamos elegido) lo permite, abrirá el programa vsjitdebugger.exe que mostrará los depuradores registrados. De nuevo este programa recibe los manejadores de forma heredada, y mientras, nuestra aplicación está completamente muerta, esperando. Una vez que hemos elegido un depurador, éste será ejecutado pasando, otra vez, la información necesaria para que sea capaz de buscar y localizar la aplicación que ha fallado.

Es entonces cuando ésta se abrirá dentro del depurador, y si están disponibles los símbolos y demás, podremos ver qué ha fallado y por qué.

Si os fijáis, el tema no es sencillo ni de lejos. En primer lugar, el programa fallante no puede hacer nada porque podría estar en un estado indeterminado, con la memoria o la pila completamente corruptas, así que es el sistema operativo el que debe tomar el control y lanzar una nueva aplicación que se encargue de todo el paripé, que a su vez lanzará un depurador que no es tal, sino un concentrador de depuradores, que al final sí, lanzará el depurador elegido, que tomará bajo su ala a nuestro programa petado.

¿Os dais cuenta lo complejo que es algo que asumimos de forma inconsciente como trivial? Pues no todo termina ahí, sino que es ahora el depurador el que debe meter las garras dentro de la aplicación defectuosa, insertándose en su espacio de direcciones de alguna manera.

Para finalizar, comentaros que esto no es más que un resumen de los capítulos 23, 24 y 25 de Windows via C/C++, quinta edición.

Quis custodiet ipsos custodes?

No, no me he vuelto loco (en todo caso ya lo estoy), ni me ha dado por aprender latín (ya tuve bastante de eso en el instituto), ni me he puesto a estudiar ética o filosofía… Bueno, algo de ética sí. Para los que no lo sepáis, es una frase que aparece en una sátira de Juvenal, un escritor romano y que, traducido al castellano, significa “¿Quién vigilará a los vigilantes?”.

Todo esto viene a cuento por lo siguiente. Sed pacientes porque creo que lo que voy a contar hoy aquí es, como poco, un bombazo, si no algo para que algún juez actúe de oficio. Pero comencemos por el principio.

Todos conocéis la campaña de Tractis sobre el DNIe. Por dos euros, destinados a los gastos de envío, puedes recibir en casa un lector de tarjetas inteligentes destinado a operar con el DNIe. Ni trampa ni cartón: te suscribes y a los pocos días recibes tu lector, aunque en mi caso los pocos días han sido como dos meses, pero sé de gente que lo ha recibido en un par de semanas.

Además, el cacharrito funciona de cojones, o al menos se instala sin necesidad de drivers extras. Lo enchufas y listo, a funcionar. No he probado sus bondades porque no tengo DNIe, de hecho pensé en conseguirlo sólo para poder jugar con él, pero a vista de lo que me ha pasado, intentaré usarlo (el DNIe) lo menos posible…

Si sigues las instrucciones, el siguiente paso es instalar el certificado digital y el software anexo de la Policía, que se debe bajar de la propia web. Lo bajas, lo instalas y listo, a funcionar.

Vale, ahora cambiemos de registro.

¿Quién de los que lee esto no utiliza Visual Studio en alguno de sus colores? Pues eso, para muchos es la herramienta con la que se gana las habas. Y todos sabemos que el Visual Studio es muy pejigueras con algunas cosas, y que sin comerlo ni beberlo puede empezar a hacerte cosas raras y a funcionar, cuando menos, raro. Echemos al mejunje una sensible caída del rendimiento general de tu ordenador.

Je, je, algunos ya sabéis por dónde voy. Pero dejad que me regodee un poco en la situación. El ordenador va sensiblemente más lento, sufre enganches en los accesos a disco, y encima el Visual Studio te muestra errores como “All pipe instances are busy”, errores que no has visto en tu miserable vida…

Así que uno se coge el Process Explorer, que si no existiera habría que inventarlo, y le da por inspeccionar el Visual Studio. A la primera, directa en los dientes. Os dejo una captura:

image

Fijaros en la línea seleccionada, y en el delta de consumo de ciclos…

¿Qué cojones hace esa DLL ahí insertada?

Y encima consumiendo un 60% del tiempo de proceso de Visual Studio. Luego uno se pone a mirar otros procesos, como el antivirus, el Messenger… y en todos está. Bueno, todos no, sólo algunos. Sólo aquellos interesantes. Fijaros donde pone State: WrLpcReply. Es una función del Kernel encargada de comunicaciones inter-procesos… y si matamos ese proceso dentro de esa DLL no ocurre nada, Visual Studio sigue funcionando igual –de mal-, pero igual…

La verdad es que no tuve ganas de seguir. Desinstalé el certificado, pero como no me fío un pelo, hice una instalación limpia: apagar del botón de atrás de la fuente. Lanzar una instalación formateando discos, apagar a medio instalar. Volver a lanzar otra, dejando los discos sin particiones. Apagar. Y entonces instalar de nuevo. Así evitamos que se nos queden regalitos en el ordenador.

No tengo nada que ocultar, pero tampoco nada que mostrar.

Las preguntas abiertas para que alguien las responda son:

  • ¿Qué necesidad tiene el DNIe de insertarse en el antivirus, el Messenger o el Visual Studio?
  • ¿Es un error de programación y esa DLL debía insertarse en otro lado y los habituales programadores chapuceros han metido la pata?
  • ¿Por qué ha de consumir esa DLL tanto tiempo de proceso? Un 60% más del normal por aplicación interceptada. Inaceptable.
  • ¿Nos quiere espiar el Estado?